refactor(world): own live environment state
Move the DAT sky, selected day group, world clock, weather, AdminEnvirons bridge, and debug cycles into a one-shot WorldEnvironmentController while preserving GameWindow's public aliases and accepted startup/session/render order. Correct the named retail citations and register the remaining environment audio and fog/radar gaps. Co-authored-by: Codex <codex@openai.com>
This commit is contained in:
parent
557eb7ef6b
commit
d09e246d3a
19 changed files with 606 additions and 368 deletions
249
src/AcDream.App/World/WorldEnvironmentController.cs
Normal file
249
src/AcDream.App/World/WorldEnvironmentController.cs
Normal file
|
|
@ -0,0 +1,249 @@
|
|||
using AcDream.App.Rendering;
|
||||
using AcDream.Core.World;
|
||||
using DatReaderWriter.DBObjs;
|
||||
|
||||
namespace AcDream.App.World;
|
||||
|
||||
/// <summary>
|
||||
/// Owns the live Dereth clock, selected retail day group, weather state, and
|
||||
/// server environment overrides as one coherent environment lifetime.
|
||||
/// </summary>
|
||||
internal sealed class WorldEnvironmentController : IWorldSceneSkyStateSource
|
||||
{
|
||||
private static readonly WeatherKind[] DebugWeatherKinds =
|
||||
[
|
||||
WeatherKind.Clear,
|
||||
WeatherKind.Overcast,
|
||||
WeatherKind.Rain,
|
||||
WeatherKind.Snow,
|
||||
WeatherKind.Storm,
|
||||
];
|
||||
|
||||
private readonly Action<string> _log;
|
||||
private LoadedSkyDesc? _loadedSkyDesc;
|
||||
private long _loadedSkyDayIndex = long.MinValue;
|
||||
private int _timeDebugStep;
|
||||
private int _weatherDebugStep;
|
||||
private bool _initializationClaimed;
|
||||
|
||||
public WorldEnvironmentController(Action<string>? log = null)
|
||||
: this(
|
||||
new WorldTimeService(SkyStateProvider.Default()),
|
||||
new WeatherSystem(),
|
||||
log)
|
||||
{
|
||||
}
|
||||
|
||||
internal WorldEnvironmentController(
|
||||
WorldTimeService worldTime,
|
||||
WeatherSystem weather,
|
||||
Action<string>? log = null)
|
||||
{
|
||||
WorldTime = worldTime ?? throw new ArgumentNullException(nameof(worldTime));
|
||||
Weather = weather ?? throw new ArgumentNullException(nameof(weather));
|
||||
_log = log ?? (_ => { });
|
||||
}
|
||||
|
||||
public WorldTimeService WorldTime { get; }
|
||||
|
||||
public WeatherSystem Weather { get; }
|
||||
|
||||
public DayGroupData? ActiveDayGroup { get; private set; }
|
||||
|
||||
public float DayFraction => (float)WorldTime.DayFraction;
|
||||
|
||||
/// <summary>
|
||||
/// Loads the Region DAT environment and seeds the pre-session clock exactly
|
||||
/// as the former <c>GameWindow.OnLoad</c> body did.
|
||||
/// </summary>
|
||||
public void Initialize(Region region)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(region);
|
||||
Initialize(
|
||||
SkyDescLoader.LoadFromRegion(region),
|
||||
region.GameTime?.ZeroTimeOfYear);
|
||||
}
|
||||
|
||||
internal void Initialize(
|
||||
LoadedSkyDesc? loadedSkyDesc,
|
||||
double? zeroTimeOfYear)
|
||||
{
|
||||
if (_initializationClaimed)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
"The world environment is a one-shot GameWindow lifetime owner.");
|
||||
}
|
||||
|
||||
_initializationClaimed = true;
|
||||
_loadedSkyDesc = loadedSkyDesc;
|
||||
_loadedSkyDayIndex = long.MinValue;
|
||||
ActiveDayGroup = null;
|
||||
|
||||
// Region GameTime is authoritative when present. A Region without it
|
||||
// gets the documented offline fallback rather than inheriting mutable
|
||||
// process-global state from an earlier window or test lifetime.
|
||||
double origin = zeroTimeOfYear
|
||||
?? DerethDateTime.DayFractionOriginOffsetTicks;
|
||||
DerethDateTime.SetOriginOffsetFromDat(origin);
|
||||
|
||||
if (_loadedSkyDesc is not null)
|
||||
{
|
||||
// SkyDesc.TickSize is retail's next-sky-update throttle, not the
|
||||
// rate of PortalYearTicks. ACE advances the server clock at one
|
||||
// tick per real second, so client extrapolation must remain 1.0.
|
||||
WorldTime.TickSize = 1.0;
|
||||
|
||||
if (zeroTimeOfYear.HasValue)
|
||||
{
|
||||
_log(
|
||||
$"sky: GameTime ZeroTimeOfYear={zeroTimeOfYear.Value} " +
|
||||
$"(was default {DerethDateTime.DayFractionOriginOffsetTicks})");
|
||||
}
|
||||
|
||||
_log(
|
||||
$"sky: loaded Region 0x13000000 — {_loadedSkyDesc.DayGroups.Count} day groups, " +
|
||||
$"SkyDesc.TickSize={_loadedSkyDesc.TickSize} (throttle, not rate), " +
|
||||
$"LightTickSize={_loadedSkyDesc.LightTickSize}");
|
||||
|
||||
// The initial roll intentionally precedes the offline noon seed,
|
||||
// preserving the accepted OnLoad order. The first server sync
|
||||
// below will select the authoritative day.
|
||||
RefreshSkyForCurrentDay();
|
||||
}
|
||||
|
||||
WorldTime.SyncFromServer(DerethDateTime.DayTicks / 16.0);
|
||||
}
|
||||
|
||||
public void SynchronizeFromServer(double ticks)
|
||||
{
|
||||
WorldTime.SyncFromServer(ticks);
|
||||
RefreshSkyForCurrentDay();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Environment packet bridge researched from
|
||||
/// <c>CPlayerSystem::Handle_Admin__Environs @ 0x0055DE20</c>. The current
|
||||
/// fog approximation and missing centered UI-sound playback remain
|
||||
/// explicitly registered as TS-55 and TS-54 respectively.
|
||||
/// </summary>
|
||||
public void ApplyAdminEnvirons(uint environChangeType)
|
||||
{
|
||||
if (environChangeType <= 0x06u)
|
||||
{
|
||||
Weather.Override = (EnvironOverride)environChangeType;
|
||||
_log(
|
||||
$"live: AdminEnvirons fog override = " +
|
||||
$"{(EnvironOverride)environChangeType}");
|
||||
return;
|
||||
}
|
||||
|
||||
string name = environChangeType switch
|
||||
{
|
||||
0x65u => "RoarSound",
|
||||
0x66u => "BellSound",
|
||||
0x67u => "Chant1Sound",
|
||||
0x68u => "Chant2Sound",
|
||||
0x69u => "DarkWhispers1Sound",
|
||||
0x6Au => "DarkWhispers2Sound",
|
||||
0x6Bu => "DarkLaughSound",
|
||||
0x6Cu => "DarkWindSound",
|
||||
0x6Du => "DarkSpeechSound",
|
||||
0x6Eu => "DrumsSound",
|
||||
0x6Fu => "GhostSpeakSound",
|
||||
0x70u => "BreathingSound",
|
||||
0x71u => "HowlSound",
|
||||
0x72u => "LostSoulsSound",
|
||||
0x75u => "SquealSound",
|
||||
0x76u => "Thunder1Sound",
|
||||
0x77u => "Thunder2Sound",
|
||||
0x78u => "Thunder3Sound",
|
||||
0x79u => "Thunder4Sound",
|
||||
0x7Au => "Thunder5Sound",
|
||||
0x7Bu => "Thunder6Sound",
|
||||
_ => $"Unknown(0x{environChangeType:X2})",
|
||||
};
|
||||
|
||||
_log(
|
||||
$"live: AdminEnvirons sound cue = {name} " +
|
||||
$"(0x{environChangeType:X2}) — audio binding pending");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Selects the active DAT day group when the authoritative Dereth day
|
||||
/// changes. The picker itself is the retail-verbatim
|
||||
/// <c>SkyDesc::CalcPresentDayGroup @ 0x00500E10</c> port (older-build
|
||||
/// cross-reference <c>FUN_00501990</c>).
|
||||
/// </summary>
|
||||
public void RefreshSkyForCurrentDay()
|
||||
{
|
||||
if (_loadedSkyDesc is null || _loadedSkyDesc.DayGroups.Count == 0)
|
||||
return;
|
||||
|
||||
double ticks = WorldTime.NowTicks;
|
||||
int absYear = DerethDateTime.AbsoluteYear(ticks);
|
||||
int dayOfYear = DerethDateTime.DayOfYear(ticks);
|
||||
int daysPerYear = DerethDateTime.DaysInAMonth
|
||||
* DerethDateTime.MonthsInAYear;
|
||||
long dayIndex = (long)absYear * 360 + dayOfYear;
|
||||
|
||||
int idx = _loadedSkyDesc.SelectDayGroupIndex(
|
||||
absYear,
|
||||
daysPerYear,
|
||||
dayOfYear);
|
||||
DayGroupData? group = idx >= 0 && idx < _loadedSkyDesc.DayGroups.Count
|
||||
? _loadedSkyDesc.DayGroups[idx]
|
||||
: null;
|
||||
|
||||
bool dayChanged = dayIndex != _loadedSkyDayIndex;
|
||||
bool groupChanged = !ReferenceEquals(group, ActiveDayGroup);
|
||||
if (!dayChanged && !groupChanged)
|
||||
return;
|
||||
|
||||
_loadedSkyDayIndex = dayIndex;
|
||||
ActiveDayGroup = group;
|
||||
|
||||
if (group is null || group.SkyTimes.Count == 0)
|
||||
return;
|
||||
|
||||
WorldTime.SetProvider(
|
||||
new SkyStateProvider(
|
||||
group.SkyTimes.Select(s => s.Keyframe).ToList()));
|
||||
Weather.SetKindFromDayGroupName(group.Name);
|
||||
|
||||
_log(
|
||||
$"sky: PY{absYear} day{dayOfYear} → DayGroup[{idx}] \"{group.Name}\" " +
|
||||
$"(Chance={group.ChanceOfOccur:F2}, {group.SkyObjects.Count} objects, " +
|
||||
$"{group.SkyTimes.Count} keyframes, weather={Weather.Kind})");
|
||||
}
|
||||
|
||||
public string CycleTimeOfDay()
|
||||
{
|
||||
_timeDebugStep = (_timeDebugStep + 1) % 5;
|
||||
float? selection = _timeDebugStep switch
|
||||
{
|
||||
0 => null,
|
||||
1 => 0.0f,
|
||||
2 => 0.25f,
|
||||
3 => 0.5f,
|
||||
4 => 0.75f,
|
||||
_ => null,
|
||||
};
|
||||
|
||||
if (selection.HasValue)
|
||||
{
|
||||
WorldTime.SetDebugTime(selection.Value);
|
||||
return $"Time override = {selection.Value:F2}";
|
||||
}
|
||||
|
||||
WorldTime.ClearDebugTime();
|
||||
return "Time override cleared";
|
||||
}
|
||||
|
||||
public string CycleWeather()
|
||||
{
|
||||
_weatherDebugStep = (_weatherDebugStep + 1) % DebugWeatherKinds.Length;
|
||||
WeatherKind kind = DebugWeatherKinds[_weatherDebugStep];
|
||||
Weather.ForceWeather(kind);
|
||||
return $"Weather = {kind}";
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue