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>
249 lines
8.3 KiB
C#
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}";
|
|
}
|
|
}
|