feat(world): Phase G.1 DerethDateTime + SkyStateProvider + WorldTimeService

Client-side deterministic sky + weather + day/night system per R12.
Retail's model is 95% client-side: the server just delivers its
current PortalYearTicks (double, seconds since boot-seed) at login and
in TimeSync packets; the client computes everything else locally from
the constants in r12 §1.2 + ACE DerethDateTime.cs.

Core layer (AcDream.Core/World):
- DerethDateTime: retail-exact calendar (16 hours/day, 30 days/month,
  12 months/year, 7620 ticks/day, 2,743,200 ticks/year). HourName enum
  covers all 16 named half-hour slots (Darktide → GloamingAndHalf);
  MonthName covers the 12 Derethian months (Snowreap → Frostfell).
  DayFraction, CurrentHour, IsDaytime, ToCalendar.
- SkyKeyframe + SkyStateProvider: 4-keyframe default day/dawn/noon/dusk
  with linear color + angular-wrap heading interpolation + slerp-like
  shortest-arc lerp so heading wraps 350° → 10° don't tween backwards
  through 180°. Default keyframe colors tuned to retail screenshots
  (sunrise warm, noon white, sunset red, midnight deep blue).
- WorldTimeService: owns the live clock. SyncFromServer(ticks) sets
  baseline; NowTicks advances by real-time elapsed. Exposes DayFraction,
  CurrentSky, CurrentSunDirection, IsDaytime for the render thread.

This is the foundation Phase G.2 (dynamic lighting) consumes: lighting
uniforms are fed from CurrentSky's SunColor / AmbientColor / sun
direction, varying smoothly across the day.

Tests (16 new):
- DerethDateTime: midnight, half-day, wrap, Dawnsong, Midsong,
  day/night flag at dawn vs Darktide-Half, year rollover, month
  advance.
- SkyState: 4-default keyframes, noon-exact matches frame data,
  midpoint lerps between neighbours, wrap across midnight doesn't
  produce NaN, sun direction returns unit vector, WorldTimeService
  sync + DayFraction at noon.

Build green, 587 tests pass (up from 570).

Ref: r12 §1 (Portal Year math), §2 (sky objects), §4 (color lerp).
Ref: ACE DerethDateTime.cs + NetworkSession TimeSync handler.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Erik 2026-04-18 17:07:26 +02:00
parent 404cab55ba
commit 6850d716a2
4 changed files with 566 additions and 0 deletions

View file

@ -0,0 +1,236 @@
using System;
using System.Collections.Generic;
using System.Numerics;
namespace AcDream.Core.World;
/// <summary>
/// One sky keyframe — the lighting + fog state for a specific day-fraction.
/// Multiple keyframes across [0, 1) interpolate linearly (with angular
/// wrap on sun direction) to produce the current sky state.
///
/// <para>
/// Retail's <c>SkyTimeOfDay</c> dat struct carries this exact data plus
/// references to sky objects (sun mesh, moon mesh, cloud layer) which
/// belong to the renderer. This class exposes the lighting-relevant
/// subset — sun direction, sun color, ambient color, fog.
/// </para>
/// </summary>
public readonly record struct SkyKeyframe(
float Begin, // [0, 1] day-fraction this keyframe kicks in
float SunHeadingDeg, // compass heading (0=N, 90=E, 180=S, 270=W)
float SunPitchDeg, // elevation above horizon (-90=below, +90=zenith)
Vector3 SunColor, // RGB linear, post-brightness multiply
Vector3 AmbientColor,
Vector3 FogColor,
float FogDensity);
/// <summary>
/// Sky keyframe interpolator — given a day fraction in [0, 1), returns
/// the blended lighting state between the surrounding keyframes.
///
/// <para>
/// Math (r12 §4):
/// <list type="number">
/// <item><description>
/// Pick the two keyframes bracketing <c>t</c>: <c>k1</c> = last
/// keyframe with <c>Begin &lt;= t</c>, <c>k2</c> = next keyframe
/// (wraps: if <c>k1</c> is last, <c>k2</c> is first).
/// </description></item>
/// <item><description>
/// Local blend <c>u = (t - k1.Begin) / (k2.Begin - k1.Begin)</c>
/// with wrap handling.
/// </description></item>
/// <item><description>
/// Lerp every vector component; SLERP the sun direction
/// quaternions to avoid artifacts when heading wraps (e.g. k1.Heading
/// = 350°, k2.Heading = 10°).
/// </description></item>
/// </list>
/// </para>
/// </summary>
public sealed class SkyStateProvider
{
private readonly List<SkyKeyframe> _keyframes;
public SkyStateProvider(IReadOnlyList<SkyKeyframe> keyframes)
{
if (keyframes is null || keyframes.Count == 0)
throw new ArgumentException("At least one keyframe required", nameof(keyframes));
// Sort by Begin so the walk is deterministic regardless of input order.
var sorted = new List<SkyKeyframe>(keyframes);
sorted.Sort((a, b) => a.Begin.CompareTo(b.Begin));
_keyframes = sorted;
}
public int KeyframeCount => _keyframes.Count;
/// <summary>
/// Default keyframe set based on retail observations — sunrise at 6am,
/// noon at 12pm, sunset at 6pm. Used when the dat-loaded set isn't
/// available yet or the player is in a region whose Region dat
/// doesn't override it.
/// </summary>
public static SkyStateProvider Default()
{
// Day fractions: 0.0=midnight, 0.25=dawn, 0.5=noon, 0.75=dusk.
return new SkyStateProvider(new[]
{
new SkyKeyframe(
Begin: 0.0f,
SunHeadingDeg: 0f, // below horizon (north)
SunPitchDeg: -30f,
SunColor: new Vector3(0.02f, 0.02f, 0.08f), // deep blue
AmbientColor: new Vector3(0.05f, 0.05f, 0.12f),
FogColor: new Vector3(0.02f, 0.02f, 0.05f),
FogDensity: 0.004f),
new SkyKeyframe(
Begin: 0.25f,
SunHeadingDeg: 90f, // east at dawn
SunPitchDeg: 0f,
SunColor: new Vector3(1.0f, 0.7f, 0.4f), // sunrise warm
AmbientColor: new Vector3(0.4f, 0.35f, 0.3f),
FogColor: new Vector3(0.8f, 0.55f, 0.4f),
FogDensity: 0.002f),
new SkyKeyframe(
Begin: 0.5f,
SunHeadingDeg: 180f, // south at noon
SunPitchDeg: 70f,
SunColor: new Vector3(1.0f, 0.98f, 0.95f), // bright white-ish
AmbientColor: new Vector3(0.5f, 0.5f, 0.55f),
FogColor: new Vector3(0.7f, 0.75f, 0.85f),
FogDensity: 0.0008f),
new SkyKeyframe(
Begin: 0.75f,
SunHeadingDeg: 270f, // west at dusk
SunPitchDeg: 0f,
SunColor: new Vector3(0.95f, 0.4f, 0.25f), // sunset red
AmbientColor: new Vector3(0.35f, 0.25f, 0.25f),
FogColor: new Vector3(0.85f, 0.45f, 0.35f),
FogDensity: 0.002f),
});
}
/// <summary>
/// Current interpolated sky state at day fraction <paramref name="t"/>.
/// Wraps correctly across the day boundary (1.0 → 0.0).
/// </summary>
public SkyKeyframe Interpolate(float t)
{
t = (float)(t - Math.Floor(t)); // wrap to [0, 1)
// Find k1: last keyframe with Begin <= t.
int k1Index = _keyframes.Count - 1;
for (int i = 0; i < _keyframes.Count; i++)
{
if (_keyframes[i].Begin <= t)
k1Index = i;
else
break;
}
int k2Index = (k1Index + 1) % _keyframes.Count;
var k1 = _keyframes[k1Index];
var k2 = _keyframes[k2Index];
// Compute blend weight, handling wrap (k1 is last, k2 is first).
float k1Begin = k1.Begin;
float k2Begin = k2.Begin;
if (k2Begin <= k1Begin) k2Begin += 1.0f; // unroll wrap
float tWrapped = t;
if (tWrapped < k1Begin) tWrapped += 1.0f;
float span = Math.Max(1e-6f, k2Begin - k1Begin);
float u = (tWrapped - k1Begin) / span;
u = Math.Clamp(u, 0f, 1f);
// Angular lerp for sun heading: pick shortest arc.
float h1 = k1.SunHeadingDeg;
float h2 = k2.SunHeadingDeg;
float delta = h2 - h1;
while (delta > 180f) delta -= 360f;
while (delta < -180f) delta += 360f;
float heading = h1 + delta * u;
return new SkyKeyframe(
Begin: t,
SunHeadingDeg: heading,
SunPitchDeg: k1.SunPitchDeg + (k2.SunPitchDeg - k1.SunPitchDeg) * u,
SunColor: Vector3.Lerp(k1.SunColor, k2.SunColor, u),
AmbientColor: Vector3.Lerp(k1.AmbientColor, k2.AmbientColor, u),
FogColor: Vector3.Lerp(k1.FogColor, k2.FogColor, u),
FogDensity: k1.FogDensity + (k2.FogDensity - k1.FogDensity) * u);
}
/// <summary>
/// World-space sun direction unit vector pointing FROM the surface
/// TOWARDS the sun. Derived from heading + pitch in the returned
/// keyframe — shader sunDir uniform should use -this so lighting
/// math (N·L) works correctly for the side facing the sun.
/// </summary>
public static Vector3 SunDirectionFromKeyframe(SkyKeyframe kf)
{
float yaw = kf.SunHeadingDeg * (MathF.PI / 180f);
float pit = kf.SunPitchDeg * (MathF.PI / 180f);
// Heading 0 = +Y (north), +X=east. Pitch up from horizon.
float cosP = MathF.Cos(pit);
return new Vector3(
MathF.Sin(yaw) * cosP,
MathF.Cos(yaw) * cosP,
MathF.Sin(pit));
}
}
/// <summary>
/// Service that turns server-delivered tick counts into live sky state.
/// Owns the "current time" clock (seeded from server sync, advanced by
/// real-time elapsed between syncs).
/// </summary>
public sealed class WorldTimeService
{
private readonly SkyStateProvider _sky;
private double _lastSyncedTicks;
private DateTime _lastSyncedWallClockUtc = DateTime.UtcNow;
public WorldTimeService(SkyStateProvider sky)
{
_sky = sky ?? throw new ArgumentNullException(nameof(sky));
}
/// <summary>
/// Set the authoritative tick count from a server TimeSync packet.
/// </summary>
public void SyncFromServer(double serverTicks)
{
_lastSyncedTicks = serverTicks;
_lastSyncedWallClockUtc = DateTime.UtcNow;
}
/// <summary>
/// Current ticks at <see cref="DateTime.UtcNow"/>, advanced from the
/// last sync by real-time elapsed seconds.
/// </summary>
public double NowTicks
{
get
{
double elapsed = (DateTime.UtcNow - _lastSyncedWallClockUtc).TotalSeconds;
return _lastSyncedTicks + elapsed;
}
}
/// <summary>Current day fraction in [0, 1).</summary>
public double DayFraction => DerethDateTime.DayFraction(NowTicks);
/// <summary>Current sky lighting state.</summary>
public SkyKeyframe CurrentSky => _sky.Interpolate((float)DayFraction);
/// <summary>Convenience: current sun direction from derived sky state.</summary>
public Vector3 CurrentSunDirection =>
SkyStateProvider.SunDirectionFromKeyframe(CurrentSky);
public DerethDateTime.Calendar CurrentCalendar =>
DerethDateTime.ToCalendar(NowTicks);
public bool IsDaytime => DerethDateTime.IsDaytime(NowTicks);
}