acdream/src/AcDream.App/World/WorldEnvironmentController.cs
Erik d09e246d3a 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>
2026-07-22 10:54:33 +02:00

249 lines
8.3 KiB
C#

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}";
}
}