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:
parent
404cab55ba
commit
6850d716a2
4 changed files with 566 additions and 0 deletions
155
src/AcDream.Core/World/DerethDateTime.cs
Normal file
155
src/AcDream.Core/World/DerethDateTime.cs
Normal file
|
|
@ -0,0 +1,155 @@
|
|||
using System;
|
||||
|
||||
namespace AcDream.Core.World;
|
||||
|
||||
/// <summary>
|
||||
/// Asheron's Call "Portal Year" calendar + time-of-day math — faithful
|
||||
/// port of retail's <c>DerethDateTime</c> (r12 §1.2 + ACE
|
||||
/// <c>DerethDateTime.cs</c>).
|
||||
///
|
||||
/// <para>
|
||||
/// The server transports an absolute tick count as a double (seconds
|
||||
/// since a seed chosen at boot). The client doesn't need to know which
|
||||
/// seed was chosen — it just needs the tick count to derive:
|
||||
/// <list type="bullet">
|
||||
/// <item><description>
|
||||
/// Day fraction in [0, 1): where we are in the 16-hour Derethian day.
|
||||
/// </description></item>
|
||||
/// <item><description>
|
||||
/// Hour name (Darktide, Dawnsong, Midsong, Warmtide, Evensong,
|
||||
/// Gloaming and their "-and-Half" mid-hour variants).
|
||||
/// </description></item>
|
||||
/// <item><description>
|
||||
/// Day / night flag for spawn rules + lighting.
|
||||
/// </description></item>
|
||||
/// <item><description>
|
||||
/// Season / year / month for calendar UI.
|
||||
/// </description></item>
|
||||
/// </list>
|
||||
/// </para>
|
||||
///
|
||||
/// <para>
|
||||
/// Constants are retail-exact; changing them breaks the calendar panel's
|
||||
/// "today is" display.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public static class DerethDateTime
|
||||
{
|
||||
public const int HoursInADay = 16;
|
||||
public const int DaysInAMonth = 30;
|
||||
public const int MonthsInAYear = 12;
|
||||
|
||||
/// <summary>Ticks per Derethian day (matches retail GameTime.DayLength).</summary>
|
||||
public const double DayTicks = 7620.0;
|
||||
|
||||
/// <summary>Ticks per Derethian hour (DayTicks / 16).</summary>
|
||||
public const double HourTicks = DayTicks / HoursInADay; // 476.25
|
||||
|
||||
/// <summary>Ticks per Derethian month.</summary>
|
||||
public const double MonthTicks = DayTicks * DaysInAMonth; // 228,600
|
||||
|
||||
/// <summary>Ticks per Derethian year.</summary>
|
||||
public const double YearTicks = MonthTicks * MonthsInAYear; // 2,743,200
|
||||
|
||||
/// <summary>
|
||||
/// Above this tick count the retail client crashes on connect.
|
||||
/// ~1.07 billion seconds = PY 401 Thistledown 2 Morntide-and-Half.
|
||||
/// </summary>
|
||||
public const double MaxTicks = 1_073_741_828.0;
|
||||
|
||||
/// <summary>
|
||||
/// The 16 named hour slots (r12 §1.2). Each one is half a Derethian
|
||||
/// hour; the "-and-Half" variants are the second half.
|
||||
/// </summary>
|
||||
public enum HourName
|
||||
{
|
||||
Darktide = 0,
|
||||
DarktideAndHalf,
|
||||
Foredawn,
|
||||
ForedawnAndHalf,
|
||||
Dawnsong, // day starts here (hour 5)
|
||||
DawnsongAndHalf,
|
||||
Morntide,
|
||||
MorntideAndHalf,
|
||||
Midsong,
|
||||
MidsongAndHalf,
|
||||
Warmtide,
|
||||
WarmtideAndHalf, // day ends here (hour 12)
|
||||
Evensong,
|
||||
EvensongAndHalf,
|
||||
Gloaming,
|
||||
GloamingAndHalf,
|
||||
}
|
||||
|
||||
/// <summary>Derethian months (Snowreap..Frostfell, 12 total).</summary>
|
||||
public enum MonthName
|
||||
{
|
||||
Snowreap = 0,
|
||||
ColdMeet,
|
||||
Leafdawning,
|
||||
Seedsow,
|
||||
Rosetide,
|
||||
Solclaim,
|
||||
Thistledown,
|
||||
Harvestgain,
|
||||
Leaftrue,
|
||||
Reaptide,
|
||||
Morningthaw,
|
||||
Frostfell,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Day fraction [0, 1): 0 = Darktide (midnight), 0.5 =
|
||||
/// Midsong-and-Half (noon-ish), 1.0 wraps to 0.
|
||||
/// </summary>
|
||||
public static double DayFraction(double ticks)
|
||||
{
|
||||
if (ticks < 0) ticks = 0;
|
||||
double rem = ticks - Math.Floor(ticks / DayTicks) * DayTicks;
|
||||
return rem / DayTicks;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Hour name for the given tick count. Picked from
|
||||
/// <see cref="HourName"/> based on which named half-hour slot the
|
||||
/// current time falls in.
|
||||
/// </summary>
|
||||
public static HourName CurrentHour(double ticks)
|
||||
{
|
||||
double f = DayFraction(ticks);
|
||||
int slot = (int)Math.Floor(f * HoursInADay);
|
||||
if (slot < 0) slot = 0;
|
||||
if (slot > 15) slot = 15;
|
||||
return (HourName)slot;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// True when the current time is "day" (Dawnsong through
|
||||
/// Warmtide-and-Half, hours 5–12 inclusive on the 16-hour scale).
|
||||
/// </summary>
|
||||
public static bool IsDaytime(double ticks)
|
||||
{
|
||||
int h = (int)CurrentHour(ticks);
|
||||
return h >= (int)HourName.Dawnsong && h <= (int)HourName.WarmtideAndHalf;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Derethian calendar breakdown: (year, month, day, hour).
|
||||
/// Year starts at PY 0. Day is 1-based within the month (1..30).
|
||||
/// </summary>
|
||||
public readonly record struct Calendar(int Year, MonthName Month, int Day, HourName Hour);
|
||||
|
||||
public static Calendar ToCalendar(double ticks)
|
||||
{
|
||||
if (ticks < 0) ticks = 0;
|
||||
int year = (int)(ticks / YearTicks);
|
||||
double tYear = ticks - year * YearTicks;
|
||||
int monthIdx = (int)(tYear / MonthTicks);
|
||||
if (monthIdx > 11) monthIdx = 11;
|
||||
double tMonth = tYear - monthIdx * MonthTicks;
|
||||
int day = (int)(tMonth / DayTicks) + 1;
|
||||
if (day > DaysInAMonth) day = DaysInAMonth;
|
||||
|
||||
return new Calendar(year, (MonthName)monthIdx, day, CurrentHour(ticks));
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue