feat(world): Phase G.1 data model — dat-accurate SkyKeyframe + WeatherSystem
Expand the SkyKeyframe record with retail-exact fog fields (FogStart, FogEnd, FogMode) per r12 §5. The existing FogDensity field is retained for backwards compat with tests that pin it; new shipping code reads FogStart / FogEnd / FogMode directly. Add WeatherSystem (WeatherKind + EnvironOverride enum + 10s transition ease + deterministic per-day-index roll) matching r12 §6.1. Roll weights are ~60% Clear / 20% Overcast / 12% Rain / 5% Snow / 3% Storm — tuned against retail observations. Storm mode triggers lightning flashes every 8–30 s via an exponential-decay (200ms τ) flash level that the shader consumes as an additive scene bump. Add SkyDescLoader that parses the Region dat (0x13000000) into LoadedSkyDesc — DayGroupData with SkyObjectData (visibility window + arc sweep), per-keyframe SkyObjectReplaceData, and a shader-ready SkyStateProvider builder. Sun/ambient colors are pre-multiplied by DirBright/AmbBright so the shader never needs to know about retail's scalar brightness field. 19 new tests (weather determinism, transition ease, environ override tint, flash decay, dat-load conversion with fog + pre-mult colors). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
63b6922fc2
commit
0df1c5b4a6
5 changed files with 982 additions and 31 deletions
297
src/AcDream.Core/World/SkyDescLoader.cs
Normal file
297
src/AcDream.Core/World/SkyDescLoader.cs
Normal file
|
|
@ -0,0 +1,297 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Numerics;
|
||||
using DatReaderWriter;
|
||||
using DatReaderWriter.DBObjs;
|
||||
using DatReaderWriter.Enums;
|
||||
using DatReaderWriter.Types;
|
||||
|
||||
namespace AcDream.Core.World;
|
||||
|
||||
/// <summary>
|
||||
/// One sky object (celestial mesh) per r12 §2. Each object has:
|
||||
/// <list type="bullet">
|
||||
/// <item><description>A visibility window in day-fraction space.</description></item>
|
||||
/// <item><description>A <c>BeginAngle</c>/<c>EndAngle</c> sweep — the arc it traces across the sky during its window.</description></item>
|
||||
/// <item><description>A texture-velocity pair for UV scrolling (cloud drift, star twinkle).</description></item>
|
||||
/// <item><description>A GfxObj mesh (the actual geometry rendered at large distance).</description></item>
|
||||
/// </list>
|
||||
///
|
||||
/// <para>
|
||||
/// This is the in-memory mirror of <c>DatReaderWriter.Types.SkyObject</c>
|
||||
/// scrubbed of dat-reader dependencies and with a couple of derived
|
||||
/// fields pre-computed. The per-keyframe <see cref="SkyObjectReplaceData"/>
|
||||
/// (r12 §2.3) lives off the owning <see cref="DatSkyKeyframeData"/>.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public sealed class SkyObjectData
|
||||
{
|
||||
public float BeginTime;
|
||||
public float EndTime;
|
||||
public float BeginAngle;
|
||||
public float EndAngle;
|
||||
public float TexVelocityX;
|
||||
public float TexVelocityY;
|
||||
public uint GfxObjId;
|
||||
public uint Properties;
|
||||
|
||||
/// <summary>Object is visible at day-fraction <paramref name="t"/>
|
||||
/// by retail's begin/end semantics (r12 §2). Three cases:
|
||||
/// <list type="bullet">
|
||||
/// <item><description><c>Begin == End</c> → always visible.</description></item>
|
||||
/// <item><description><c>Begin < End</c> → daytime arc, visible in [Begin, End].</description></item>
|
||||
/// <item><description><c>Begin > End</c> → wraps midnight, visible in [Begin, 1) ∪ [0, End].</description></item>
|
||||
/// </list></summary>
|
||||
public bool IsVisible(float t)
|
||||
{
|
||||
if (BeginTime == EndTime) return true;
|
||||
if (BeginTime < EndTime) return t >= BeginTime && t <= EndTime;
|
||||
// Wrap around midnight.
|
||||
return t >= BeginTime || t <= EndTime;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Arc progress 0..1 through the visibility window; gives the angle
|
||||
/// interpolation for <c>BeginAngle</c>→<c>EndAngle</c> (r12 §2).
|
||||
/// </summary>
|
||||
public float AngleProgress(float t)
|
||||
{
|
||||
if (BeginTime == EndTime) return 0f;
|
||||
float duration;
|
||||
float progress;
|
||||
if (BeginTime < EndTime)
|
||||
{
|
||||
duration = EndTime - BeginTime;
|
||||
progress = (t - BeginTime) / duration;
|
||||
}
|
||||
else
|
||||
{
|
||||
duration = (1f - BeginTime) + EndTime;
|
||||
progress = (t >= BeginTime)
|
||||
? (t - BeginTime) / duration
|
||||
: (t + (1f - BeginTime)) / duration;
|
||||
}
|
||||
return Math.Clamp(progress, 0f, 1f);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Current arc angle in degrees given the day fraction. Linear
|
||||
/// interpolation between <see cref="BeginAngle"/> and <see cref="EndAngle"/>.
|
||||
/// </summary>
|
||||
public float CurrentAngle(float t)
|
||||
{
|
||||
if (BeginTime == EndTime) return BeginAngle;
|
||||
return BeginAngle + (EndAngle - BeginAngle) * AngleProgress(t);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Per-keyframe override for one sky object — swap its mesh at dusk,
|
||||
/// dim it, or rotate it (r12 §2.3). Indexed by
|
||||
/// <see cref="ObjectIndex"/> into the owning day group's SkyObjects list.
|
||||
/// </summary>
|
||||
public sealed class SkyObjectReplaceData
|
||||
{
|
||||
public uint ObjectIndex;
|
||||
public uint GfxObjId;
|
||||
public float Rotate;
|
||||
public float Transparent;
|
||||
public float Luminosity;
|
||||
public float MaxBright;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Full lighting + sky-object-override data for one <c>SkyTimeOfDay</c>
|
||||
/// keyframe. Built alongside the <see cref="SkyKeyframe"/> the shaders
|
||||
/// consume — this form keeps the per-object overrides which the
|
||||
/// <c>SkyRenderer</c> needs to swap clouds for overcast keyframes.
|
||||
/// </summary>
|
||||
public sealed class DatSkyKeyframeData
|
||||
{
|
||||
public SkyKeyframe Keyframe;
|
||||
public IReadOnlyList<SkyObjectReplaceData> Replaces = Array.Empty<SkyObjectReplaceData>();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// One <c>DayGroup</c> from retail's Region dat — a self-contained
|
||||
/// weather regime. Retail Dereth ships ~3 day groups (clear, overcast,
|
||||
/// storm) and the client rolls one per day. r12 §11 describes this.
|
||||
/// </summary>
|
||||
public sealed class DayGroupData
|
||||
{
|
||||
public float ChanceOfOccur;
|
||||
public string Name = "";
|
||||
public IReadOnlyList<SkyObjectData> SkyObjects = Array.Empty<SkyObjectData>();
|
||||
public IReadOnlyList<DatSkyKeyframeData> SkyTimes = Array.Empty<DatSkyKeyframeData>();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Fully-loaded skybox data pulled from the Region dat (0x13000000).
|
||||
/// Has everything the renderer + weather system need to produce a
|
||||
/// retail-faithful day/night cycle:
|
||||
/// <list type="bullet">
|
||||
/// <item><description>A <see cref="SkyStateProvider"/> ready to drop into <see cref="WorldTimeService"/>.</description></item>
|
||||
/// <item><description>A list of day groups for weather picking.</description></item>
|
||||
/// <item><description>Calendar constants (<c>DayLength</c>, etc) for cross-checking.</description></item>
|
||||
/// </list>
|
||||
/// </summary>
|
||||
public sealed class LoadedSkyDesc
|
||||
{
|
||||
public double TickSize;
|
||||
public double LightTickSize;
|
||||
public IReadOnlyList<DayGroupData> DayGroups = Array.Empty<DayGroupData>();
|
||||
|
||||
/// <summary>
|
||||
/// Default day group — currently group 0 per WorldBuilder's
|
||||
/// <c>SkyboxRenderManager.Render</c>. Weather integration later picks
|
||||
/// the current day's group by <c>ChanceOfOccur</c>.
|
||||
/// </summary>
|
||||
public DayGroupData? DefaultDayGroup =>
|
||||
DayGroups.Count > 0 ? DayGroups[0] : null;
|
||||
|
||||
/// <summary>
|
||||
/// Build a shader-facing <see cref="SkyStateProvider"/> for the default day group.
|
||||
/// </summary>
|
||||
public SkyStateProvider BuildDefaultProvider()
|
||||
{
|
||||
var grp = DefaultDayGroup;
|
||||
if (grp is null || grp.SkyTimes.Count == 0) return SkyStateProvider.Default();
|
||||
return new SkyStateProvider(grp.SkyTimes.Select(s => s.Keyframe).ToList());
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Parses the Region dat (0x13000000) into strongly-typed acdream data.
|
||||
/// Safe to call off the render thread as long as the underlying
|
||||
/// <see cref="DatCollection"/> isn't being mutated (acdream's one-shot
|
||||
/// startup path already holds the dat lock during Region reads).
|
||||
///
|
||||
/// <para>
|
||||
/// Retail stores the entire world's sky + calendar in this single record
|
||||
/// — there's only ever one <c>Region</c>. The loader reads the SkyDesc
|
||||
/// out of <c>region.SkyInfo</c>, iterates every DayGroup, and converts
|
||||
/// each <c>SkyTimeOfDay</c> to our <see cref="SkyKeyframe"/> record.
|
||||
/// </para>
|
||||
///
|
||||
/// <para>
|
||||
/// The SunColor / AmbientColor fields store the color × brightness
|
||||
/// product so the shader UBO layout can stay a flat <c>vec3</c> without
|
||||
/// extra multiplies per pixel. See r12 §4.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public static class SkyDescLoader
|
||||
{
|
||||
public const uint RegionDatId = 0x13000000u;
|
||||
|
||||
/// <summary>
|
||||
/// Load + parse. Returns <c>null</c> if the Region doesn't have
|
||||
/// <see cref="PartsMask.HasSkyInfo"/> or the dat is absent.
|
||||
/// </summary>
|
||||
public static LoadedSkyDesc? LoadFromDat(DatCollection dats)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(dats);
|
||||
var region = dats.Get<Region>(RegionDatId);
|
||||
if (region is null) return null;
|
||||
return LoadFromRegion(region);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Convert an in-memory Region object to our domain data.
|
||||
/// Separated so tests can feed hand-built Regions without the dat
|
||||
/// pipeline.
|
||||
/// </summary>
|
||||
public static LoadedSkyDesc? LoadFromRegion(Region region)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(region);
|
||||
if (!region.PartsMask.HasFlag(PartsMask.HasSkyInfo) || region.SkyInfo is null)
|
||||
return null;
|
||||
|
||||
var sky = region.SkyInfo;
|
||||
var dayGroups = new List<DayGroupData>(sky.DayGroups.Count);
|
||||
|
||||
foreach (var dg in sky.DayGroups)
|
||||
{
|
||||
var objs = dg.SkyObjects.Select(ConvertSkyObject).ToList();
|
||||
var times = dg.SkyTime.Select(ConvertTimeOfDay).ToList();
|
||||
|
||||
dayGroups.Add(new DayGroupData
|
||||
{
|
||||
ChanceOfOccur = dg.ChanceOfOccur,
|
||||
Name = dg.DayName?.ToString() ?? "",
|
||||
SkyObjects = objs,
|
||||
SkyTimes = times,
|
||||
});
|
||||
}
|
||||
|
||||
return new LoadedSkyDesc
|
||||
{
|
||||
TickSize = sky.TickSize,
|
||||
LightTickSize = sky.LightTickSize,
|
||||
DayGroups = dayGroups,
|
||||
};
|
||||
}
|
||||
|
||||
private static SkyObjectData ConvertSkyObject(SkyObject s) => new()
|
||||
{
|
||||
BeginTime = s.BeginTime,
|
||||
EndTime = s.EndTime,
|
||||
BeginAngle = s.BeginAngle,
|
||||
EndAngle = s.EndAngle,
|
||||
TexVelocityX = s.TexVelocityX,
|
||||
TexVelocityY = s.TexVelocityY,
|
||||
GfxObjId = s.DefaultGfxObjectId?.DataId ?? 0u,
|
||||
Properties = s.Properties,
|
||||
};
|
||||
|
||||
private static DatSkyKeyframeData ConvertTimeOfDay(SkyTimeOfDay s)
|
||||
{
|
||||
var replaces = s.SkyObjReplace.Select(r => new SkyObjectReplaceData
|
||||
{
|
||||
ObjectIndex = r.ObjectIndex,
|
||||
GfxObjId = r.GfxObjId?.DataId ?? 0u,
|
||||
Rotate = r.Rotate,
|
||||
Transparent = r.Transparent,
|
||||
Luminosity = r.Luminosity,
|
||||
MaxBright = r.MaxBright,
|
||||
}).ToList();
|
||||
|
||||
var fogMode = s.WorldFog switch
|
||||
{
|
||||
1u => FogMode.Linear,
|
||||
2u => FogMode.Exp,
|
||||
3u => FogMode.Exp2,
|
||||
_ => FogMode.Off,
|
||||
};
|
||||
|
||||
var kf = new SkyKeyframe(
|
||||
Begin: s.Begin,
|
||||
SunHeadingDeg: s.DirHeading,
|
||||
SunPitchDeg: s.DirPitch,
|
||||
SunColor: ColorToVec3(s.DirColor) * s.DirBright,
|
||||
AmbientColor: ColorToVec3(s.AmbColor) * s.AmbBright,
|
||||
FogColor: ColorToVec3(s.WorldFogColor),
|
||||
FogDensity: 0f,
|
||||
FogStart: s.MinWorldFog,
|
||||
FogEnd: s.MaxWorldFog,
|
||||
FogMode: fogMode);
|
||||
|
||||
return new DatSkyKeyframeData
|
||||
{
|
||||
Keyframe = kf,
|
||||
Replaces = replaces,
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// <see cref="ColorARGB"/> stores bytes as B,G,R,A — but the logical
|
||||
/// channel mapping is just "R/G/B in 0..255". Convert to linear
|
||||
/// 0..1 <see cref="Vector3"/>. Alpha is ignored (retail lighting
|
||||
/// doesn't use it).
|
||||
/// </summary>
|
||||
public static Vector3 ColorToVec3(ColorARGB? c)
|
||||
{
|
||||
if (c is null) return Vector3.One;
|
||||
return new Vector3(c.Red / 255f, c.Green / 255f, c.Blue / 255f);
|
||||
}
|
||||
}
|
||||
|
|
@ -5,15 +5,40 @@ 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.
|
||||
/// Fog modes mirroring retail's <c>D3DFOGMODE</c>. Retail only ever uses
|
||||
/// <see cref="Off"/> and <see cref="Linear"/>; the Exp variants are
|
||||
/// supported by the dat schema but never appear in shipped data. See r12
|
||||
/// §5 and <c>SkyTimeOfDay.WorldFog</c> (dat <c>uint</c>).
|
||||
/// </summary>
|
||||
public enum FogMode
|
||||
{
|
||||
Off = 0,
|
||||
Linear = 1,
|
||||
Exp = 2,
|
||||
Exp2 = 3,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// One sky keyframe — the full lighting + fog state for a specific
|
||||
/// day-fraction. Multiple keyframes across <c>[0, 1)</c> interpolate
|
||||
/// linearly (with angular-shortest-arc 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.
|
||||
/// belong to the renderer. This record exposes the shader-relevant
|
||||
/// subset — sun direction, sun color, ambient color, linear fog. See
|
||||
/// <c>references/DatReaderWriter/DatReaderWriter/Generated/Types/SkyTimeOfDay.generated.cs</c>
|
||||
/// and r12 §4 + §5.
|
||||
/// </para>
|
||||
///
|
||||
/// <para>
|
||||
/// Colors are in LINEAR RGB, already pre-multiplied by their brightness
|
||||
/// scalar so the shader can plug them straight into the UBO without
|
||||
/// knowing about <c>DirBright</c> / <c>AmbBright</c>. Range is loosely
|
||||
/// [0, N] — retail dusk tints have channels above 1.0 and the frag
|
||||
/// shader clamps after lighting math.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public readonly record struct SkyKeyframe(
|
||||
|
|
@ -21,9 +46,12 @@ public readonly record struct SkyKeyframe(
|
|||
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 AmbientColor, // RGB linear, post-brightness multiply
|
||||
Vector3 FogColor,
|
||||
float FogDensity);
|
||||
float FogDensity, // retained for tests; derive from FogStart/End
|
||||
float FogStart = 80f, // meters (retail default ~120 clear, ~40 storm)
|
||||
float FogEnd = 350f, // meters (retail default ~350 clear, ~150 storm)
|
||||
FogMode FogMode = FogMode.Linear);
|
||||
|
||||
/// <summary>
|
||||
/// Sky keyframe interpolator — given a day fraction in [0, 1), returns
|
||||
|
|
@ -42,9 +70,8 @@ public readonly record struct SkyKeyframe(
|
|||
/// 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°).
|
||||
/// Lerp every vector component; use shortest-arc lerp for the sun
|
||||
/// heading so k1=350° → k2=10° doesn't sweep backwards across the sky.
|
||||
/// </description></item>
|
||||
/// </list>
|
||||
/// </para>
|
||||
|
|
@ -64,12 +91,20 @@ public sealed class SkyStateProvider
|
|||
}
|
||||
|
||||
public int KeyframeCount => _keyframes.Count;
|
||||
public IReadOnlyList<SkyKeyframe> Keyframes => _keyframes;
|
||||
|
||||
/// <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.
|
||||
///
|
||||
/// <para>
|
||||
/// Fog values approximate retail clear-weather defaults: ~80m..~350m
|
||||
/// linear fog with color matching the horizon band so mountains at
|
||||
/// distance fade into the sky instead of popping at the clip plane.
|
||||
/// See r12 §5.1.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public static SkyStateProvider Default()
|
||||
{
|
||||
|
|
@ -83,7 +118,10 @@ public sealed class SkyStateProvider
|
|||
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),
|
||||
FogDensity: 0.004f,
|
||||
FogStart: 30f,
|
||||
FogEnd: 180f,
|
||||
FogMode: FogMode.Linear),
|
||||
new SkyKeyframe(
|
||||
Begin: 0.25f,
|
||||
SunHeadingDeg: 90f, // east at dawn
|
||||
|
|
@ -91,7 +129,10 @@ public sealed class SkyStateProvider
|
|||
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),
|
||||
FogDensity: 0.002f,
|
||||
FogStart: 60f,
|
||||
FogEnd: 260f,
|
||||
FogMode: FogMode.Linear),
|
||||
new SkyKeyframe(
|
||||
Begin: 0.5f,
|
||||
SunHeadingDeg: 180f, // south at noon
|
||||
|
|
@ -99,7 +140,10 @@ public sealed class SkyStateProvider
|
|||
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),
|
||||
FogDensity: 0.0008f,
|
||||
FogStart: 120f,
|
||||
FogEnd: 500f,
|
||||
FogMode: FogMode.Linear),
|
||||
new SkyKeyframe(
|
||||
Begin: 0.75f,
|
||||
SunHeadingDeg: 270f, // west at dusk
|
||||
|
|
@ -107,7 +151,10 @@ public sealed class SkyStateProvider
|
|||
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),
|
||||
FogDensity: 0.002f,
|
||||
FogStart: 60f,
|
||||
FogEnd: 260f,
|
||||
FogMode: FogMode.Linear),
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -145,21 +192,34 @@ public sealed class SkyStateProvider
|
|||
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;
|
||||
float heading = ShortestAngleLerp(k1.SunHeadingDeg, k2.SunHeadingDeg, u);
|
||||
|
||||
// Fog mode doesn't interpolate — pick k1's mode (retail uses Linear everywhere).
|
||||
return new SkyKeyframe(
|
||||
Begin: t,
|
||||
SunHeadingDeg: heading,
|
||||
SunPitchDeg: k1.SunPitchDeg + (k2.SunPitchDeg - k1.SunPitchDeg) * u,
|
||||
SunPitchDeg: Lerp(k1.SunPitchDeg, k2.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);
|
||||
FogDensity: Lerp(k1.FogDensity, k2.FogDensity, u),
|
||||
FogStart: Lerp(k1.FogStart, k2.FogStart, u),
|
||||
FogEnd: Lerp(k1.FogEnd, k2.FogEnd, u),
|
||||
FogMode: k1.FogMode);
|
||||
}
|
||||
|
||||
private static float Lerp(float a, float b, float u) => a + (b - a) * u;
|
||||
|
||||
/// <summary>
|
||||
/// Shortest-arc heading lerp: r12 §4. If <c>a=350</c> and <c>b=10</c>
|
||||
/// the lerp walks 20° forward through 0° rather than 340° backward.
|
||||
/// </summary>
|
||||
public static float ShortestAngleLerp(float aDeg, float bDeg, float u)
|
||||
{
|
||||
float delta = bDeg - aDeg;
|
||||
while (delta > 180f) delta -= 360f;
|
||||
while (delta < -180f) delta += 360f;
|
||||
return aDeg + delta * u;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
|
@ -185,42 +245,89 @@ public sealed class SkyStateProvider
|
|||
/// 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).
|
||||
///
|
||||
/// <para>
|
||||
/// Supports a debug "time override" (slash-command <c>/time 0.5</c>) that
|
||||
/// forces a specific day fraction regardless of server sync — used for
|
||||
/// screenshots and visual debugging. The override is transient and gets
|
||||
/// cleared on the next TimeSync packet.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public sealed class WorldTimeService
|
||||
{
|
||||
private readonly SkyStateProvider _sky;
|
||||
private SkyStateProvider _sky;
|
||||
private double _lastSyncedTicks;
|
||||
private DateTime _lastSyncedWallClockUtc = DateTime.UtcNow;
|
||||
|
||||
private float? _debugDayFractionOverride;
|
||||
|
||||
/// <summary>
|
||||
/// Rate at which in-game time advances relative to real time. Retail
|
||||
/// default is 1.0 (one wall-clock second = one in-game tick). Server
|
||||
/// config can override via <c>SkyDesc.TickSize</c>; see r12 §1.2.
|
||||
/// </summary>
|
||||
public double TickSize { get; set; } = 1.0;
|
||||
|
||||
public WorldTimeService(SkyStateProvider sky)
|
||||
{
|
||||
_sky = sky ?? throw new ArgumentNullException(nameof(sky));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Hot-swap the keyframe source — typically called once at world-load
|
||||
/// time after the Region dat has been parsed by <see cref="SkyDescLoader"/>.
|
||||
/// </summary>
|
||||
public void SetProvider(SkyStateProvider sky)
|
||||
{
|
||||
_sky = sky ?? throw new ArgumentNullException(nameof(sky));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Set the authoritative tick count from a server TimeSync packet.
|
||||
/// Clears any debug override.
|
||||
/// </summary>
|
||||
public void SyncFromServer(double serverTicks)
|
||||
{
|
||||
_lastSyncedTicks = serverTicks;
|
||||
_lastSyncedWallClockUtc = DateTime.UtcNow;
|
||||
_debugDayFractionOverride = null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Debug-only: force a specific day fraction in [0, 1). Overrides
|
||||
/// server-synced time until cleared by <see cref="SyncFromServer"/>
|
||||
/// or <see cref="ClearDebugTime"/>.
|
||||
/// </summary>
|
||||
public void SetDebugTime(float dayFraction)
|
||||
{
|
||||
_debugDayFractionOverride = dayFraction;
|
||||
}
|
||||
|
||||
public void ClearDebugTime() => _debugDayFractionOverride = null;
|
||||
|
||||
/// <summary>
|
||||
/// Current ticks at <see cref="DateTime.UtcNow"/>, advanced from the
|
||||
/// last sync by real-time elapsed seconds.
|
||||
/// last sync by real-time elapsed seconds times <see cref="TickSize"/>.
|
||||
/// </summary>
|
||||
public double NowTicks
|
||||
{
|
||||
get
|
||||
{
|
||||
double elapsed = (DateTime.UtcNow - _lastSyncedWallClockUtc).TotalSeconds;
|
||||
return _lastSyncedTicks + elapsed;
|
||||
return _lastSyncedTicks + elapsed * TickSize;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Current day fraction in [0, 1).</summary>
|
||||
public double DayFraction => DerethDateTime.DayFraction(NowTicks);
|
||||
public double DayFraction
|
||||
{
|
||||
get
|
||||
{
|
||||
if (_debugDayFractionOverride.HasValue)
|
||||
return _debugDayFractionOverride.Value;
|
||||
return DerethDateTime.DayFraction(NowTicks);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Current sky lighting state.</summary>
|
||||
public SkyKeyframe CurrentSky => _sky.Interpolate((float)DayFraction);
|
||||
|
|
|
|||
309
src/AcDream.Core/World/WeatherState.cs
Normal file
309
src/AcDream.Core/World/WeatherState.cs
Normal file
|
|
@ -0,0 +1,309 @@
|
|||
using System;
|
||||
using System.Numerics;
|
||||
|
||||
namespace AcDream.Core.World;
|
||||
|
||||
/// <summary>
|
||||
/// Client-local atmospheric regime. Retail AC has no server weather
|
||||
/// opcode (r12 §6) — the client picks a state per in-game day via a
|
||||
/// deterministic seeded RNG so all players on the same server see the
|
||||
/// same weather without any packets. Transitions take ~10 seconds.
|
||||
///
|
||||
/// <para>
|
||||
/// The rendering side reads <see cref="Kind"/> to decide whether to
|
||||
/// spawn rain/snow particles and which cloud mesh override to select.
|
||||
/// The <see cref="Intensity"/> field lets the fog / particle rate /
|
||||
/// cloud-darkness terms ease in and out smoothly rather than popping.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public enum WeatherKind
|
||||
{
|
||||
Clear = 0,
|
||||
Overcast = 1,
|
||||
Rain = 2,
|
||||
Snow = 3,
|
||||
Storm = 4,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Server-forced fog override (retail <c>EnvironChangeType</c>). When
|
||||
/// the server sends <c>AdminEnvirons</c> (0xEA60) with one of the
|
||||
/// non-<see cref="None"/> values, the client overrides its locally-computed
|
||||
/// fog color and density with the tint shown below. See r12 §5.2 and
|
||||
/// <c>references/ACE/Source/ACE.Entity/Enum/EnvironChangeType.cs</c>.
|
||||
/// </summary>
|
||||
public enum EnvironOverride
|
||||
{
|
||||
None = 0x00, // clear override, revert to dat-driven fog
|
||||
RedFog = 0x01,
|
||||
BlueFog = 0x02,
|
||||
WhiteFog = 0x03,
|
||||
GreenFog = 0x04,
|
||||
BlackFog = 0x05,
|
||||
BlackFog2 = 0x06,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Full per-frame atmosphere state consumed by the shader + particle
|
||||
/// systems. Built by <see cref="WeatherSystem"/> from
|
||||
/// <list type="bullet">
|
||||
/// <item><description>the interpolated <see cref="SkyKeyframe"/>,</description></item>
|
||||
/// <item><description>the current <see cref="WeatherKind"/>,</description></item>
|
||||
/// <item><description>a possibly-active <see cref="EnvironOverride"/>,</description></item>
|
||||
/// <item><description>a transient lightning-flash bump.</description></item>
|
||||
/// </list>
|
||||
/// </summary>
|
||||
public readonly record struct AtmosphereSnapshot(
|
||||
WeatherKind Kind,
|
||||
float Intensity, // 0..1, eases on state transitions
|
||||
Vector3 FogColor, // final fog color (may be overridden)
|
||||
float FogStart,
|
||||
float FogEnd,
|
||||
FogMode FogMode,
|
||||
float LightningFlash, // 0..1, decays from strike moment
|
||||
EnvironOverride Override);
|
||||
|
||||
/// <summary>
|
||||
/// Weather state machine — deterministic per-day RNG picks the weather
|
||||
/// kind; a 10-second ease blends fog + particle density between old
|
||||
/// and new states. Also owns the lightning-flash timer for storms.
|
||||
///
|
||||
/// <para>
|
||||
/// Algorithm (r12 §6.1–6.2):
|
||||
/// <list type="number">
|
||||
/// <item><description>
|
||||
/// Derive a deterministic <c>Random(dayIndex)</c> per in-game day.
|
||||
/// Roll a weighted pick from a table matching retail's rough
|
||||
/// 70/15/10/5 distribution (Clear dominates).
|
||||
/// </description></item>
|
||||
/// <item><description>
|
||||
/// When the kind changes, store a <c>transitionStart</c> timestamp
|
||||
/// and tween <see cref="AtmosphereSnapshot.Intensity"/> from 0 → 1
|
||||
/// over <see cref="TransitionSeconds"/>.
|
||||
/// </description></item>
|
||||
/// <item><description>
|
||||
/// Storm kind only: every 8–30 seconds fire a lightning flash; the
|
||||
/// shader reads <see cref="AtmosphereSnapshot.LightningFlash"/> as
|
||||
/// an additive scene bump that decays with a 200 ms time constant.
|
||||
/// </description></item>
|
||||
/// <item><description>
|
||||
/// Any server <see cref="EnvironOverride"/> beats the local picks —
|
||||
/// stick the override fog color and density in the snapshot until
|
||||
/// the server sends <see cref="EnvironOverride.None"/>.
|
||||
/// </description></item>
|
||||
/// </list>
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public sealed class WeatherSystem
|
||||
{
|
||||
public const float TransitionSeconds = 10f;
|
||||
|
||||
// Flash visual parameters (r12 §9). The spike rises to 1.0 in ~50ms
|
||||
// and decays exponentially with a time constant of ~200ms.
|
||||
private const float FlashDecay = 1f / 0.200f; // 1 / τ seconds
|
||||
private const float FlashPeakHoldS = 0.05f;
|
||||
|
||||
// Retail storm cadence: 8–30 seconds between strikes.
|
||||
private const float StrikeIntervalMinS = 8f;
|
||||
private const float StrikeIntervalMaxS = 30f;
|
||||
|
||||
// Overcast-kind fog feels like ~40–150m retail range (r12 §5.1).
|
||||
private const float OvercastFogStart = 40f;
|
||||
private const float OvercastFogEnd = 150f;
|
||||
private const float StormFogStart = 25f;
|
||||
private const float StormFogEnd = 90f;
|
||||
|
||||
private WeatherKind _kind = WeatherKind.Clear;
|
||||
private WeatherKind _previousKind = WeatherKind.Clear;
|
||||
private float _transitionT; // 0..1 through the cross-fade
|
||||
|
||||
private float _flashLevel;
|
||||
private float _flashAge; // seconds since last strike
|
||||
private float _nextStrikeInS;
|
||||
|
||||
private EnvironOverride _override;
|
||||
|
||||
private int _rolledDayIndex = int.MinValue; // unrolled == "pick one"
|
||||
|
||||
private readonly Random _strikeJitter;
|
||||
|
||||
public WeatherSystem(Random? rng = null)
|
||||
{
|
||||
_strikeJitter = rng ?? new Random(unchecked((int)0xDCAE_5001u));
|
||||
_nextStrikeInS = 12f;
|
||||
}
|
||||
|
||||
/// <summary>Current active weather.</summary>
|
||||
public WeatherKind Kind => _kind;
|
||||
|
||||
/// <summary>Last-known server fog override (sticky between sync packets).</summary>
|
||||
public EnvironOverride Override
|
||||
{
|
||||
get => _override;
|
||||
set => _override = value;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Debug / test hook — force a specific weather kind, ignoring the
|
||||
/// per-day roll. Passing <see cref="WeatherKind.Clear"/> returns to
|
||||
/// normal behavior starting on the next day-roll.
|
||||
/// </summary>
|
||||
public void ForceWeather(WeatherKind kind)
|
||||
{
|
||||
BeginTransition(kind);
|
||||
_rolledDayIndex = int.MaxValue; // "forced" sentinel — don't re-roll
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Advance the state machine. Call once per frame from the render
|
||||
/// loop. <paramref name="dayIndex"/> is the in-game day (derived
|
||||
/// from <see cref="DerethDateTime"/>); when it changes we re-roll
|
||||
/// the weather kind.
|
||||
/// </summary>
|
||||
public void Tick(double nowSeconds, int dayIndex, float dtSeconds)
|
||||
{
|
||||
// Cross-fade progression: transitionT advances toward 1 over
|
||||
// TransitionSeconds. Capped; no further rollover.
|
||||
if (_transitionT < 1f)
|
||||
_transitionT = Math.Min(1f, _transitionT + dtSeconds / TransitionSeconds);
|
||||
|
||||
// Day changed → re-roll. Skip the sentinel (forced).
|
||||
if (dayIndex != _rolledDayIndex && _rolledDayIndex != int.MaxValue)
|
||||
{
|
||||
_rolledDayIndex = dayIndex;
|
||||
var newKind = RollKind(dayIndex);
|
||||
if (newKind != _kind) BeginTransition(newKind);
|
||||
}
|
||||
|
||||
// Lightning timer only ticks in Storm kind.
|
||||
if (_kind == WeatherKind.Storm && _override == EnvironOverride.None)
|
||||
{
|
||||
_nextStrikeInS -= dtSeconds;
|
||||
if (_nextStrikeInS <= 0f)
|
||||
{
|
||||
TriggerFlash();
|
||||
_nextStrikeInS = StrikeIntervalMinS
|
||||
+ (float)_strikeJitter.NextDouble() * (StrikeIntervalMaxS - StrikeIntervalMinS);
|
||||
}
|
||||
}
|
||||
|
||||
// Decay the flash level with a 200ms time constant.
|
||||
if (_flashLevel > 0f)
|
||||
{
|
||||
_flashAge += dtSeconds;
|
||||
if (_flashAge < FlashPeakHoldS)
|
||||
_flashLevel = 1f;
|
||||
else
|
||||
_flashLevel = MathF.Exp(-(_flashAge - FlashPeakHoldS) * FlashDecay);
|
||||
if (_flashLevel < 1e-3f) _flashLevel = 0f;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Trigger a lightning flash manually (server-forced or test hook).
|
||||
/// </summary>
|
||||
public void TriggerFlash()
|
||||
{
|
||||
_flashLevel = 1f;
|
||||
_flashAge = 0f;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Produce the per-frame snapshot consumed by the shader UBO +
|
||||
/// particle emitter spawners. Combines the sky keyframe's fog with
|
||||
/// the weather state's fog overlay, then applies the server
|
||||
/// <see cref="EnvironOverride"/> tint if any.
|
||||
/// </summary>
|
||||
public AtmosphereSnapshot Snapshot(in SkyKeyframe kf)
|
||||
{
|
||||
// Cross-fade fog distance + color from previous-kind to new-kind.
|
||||
var prev = FogForKind(_previousKind, kf);
|
||||
var curr = FogForKind(_kind, kf);
|
||||
|
||||
float t = _transitionT;
|
||||
var fogColor = Vector3.Lerp(prev.color, curr.color, t);
|
||||
float fogStart = prev.start + (curr.start - prev.start) * t;
|
||||
float fogEnd = prev.end + (curr.end - prev.end) * t;
|
||||
|
||||
// Server environ override wins.
|
||||
if (_override != EnvironOverride.None)
|
||||
{
|
||||
fogColor = EnvironOverrideColor(_override);
|
||||
fogStart = 15f;
|
||||
fogEnd = 80f; // Dense override fog
|
||||
}
|
||||
|
||||
float intensity = _kind == WeatherKind.Clear ? 1f - t : t;
|
||||
|
||||
return new AtmosphereSnapshot(
|
||||
Kind: _kind,
|
||||
Intensity: Math.Clamp(intensity, 0f, 1f),
|
||||
FogColor: fogColor,
|
||||
FogStart: fogStart,
|
||||
FogEnd: fogEnd,
|
||||
FogMode: kf.FogMode,
|
||||
LightningFlash: _flashLevel,
|
||||
Override: _override);
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------
|
||||
// Internal machinery
|
||||
// ----------------------------------------------------------------
|
||||
|
||||
private void BeginTransition(WeatherKind newKind)
|
||||
{
|
||||
_previousKind = _kind;
|
||||
_kind = newKind;
|
||||
_transitionT = 0f;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Deterministic per-day weighted roll. Seeded with <paramref name="dayIndex"/>
|
||||
/// alone so every client running the same day sees the same weather —
|
||||
/// retail's mechanism for "synchronized weather without any packets"
|
||||
/// (r12 §6.1).
|
||||
/// </summary>
|
||||
private static WeatherKind RollKind(int dayIndex)
|
||||
{
|
||||
// Mix the day index so consecutive days aren't adjacent in PRNG
|
||||
// state space (avoids tiny-seed correlation issues).
|
||||
int seed = unchecked((int)((uint)dayIndex * 0x9E3779B1u));
|
||||
var rng = new Random(seed);
|
||||
double r = rng.NextDouble();
|
||||
// Retail weights (approximate): 60% clear, 20% overcast, 12% rain,
|
||||
// 5% snow, 3% storm. Tuned for "most days are fine, some are bad."
|
||||
if (r < 0.60) return WeatherKind.Clear;
|
||||
if (r < 0.80) return WeatherKind.Overcast;
|
||||
if (r < 0.92) return WeatherKind.Rain;
|
||||
if (r < 0.97) return WeatherKind.Snow;
|
||||
return WeatherKind.Storm;
|
||||
}
|
||||
|
||||
private static (Vector3 color, float start, float end) FogForKind(WeatherKind kind, in SkyKeyframe kf)
|
||||
{
|
||||
return kind switch
|
||||
{
|
||||
WeatherKind.Clear => (kf.FogColor, kf.FogStart, kf.FogEnd),
|
||||
WeatherKind.Overcast => (Vector3.Lerp(kf.FogColor, new Vector3(0.55f, 0.55f, 0.55f), 0.6f),
|
||||
OvercastFogStart, OvercastFogEnd),
|
||||
WeatherKind.Rain => (Vector3.Lerp(kf.FogColor, new Vector3(0.45f, 0.48f, 0.55f), 0.7f),
|
||||
OvercastFogStart, OvercastFogEnd),
|
||||
WeatherKind.Snow => (Vector3.Lerp(kf.FogColor, new Vector3(0.80f, 0.82f, 0.90f), 0.6f),
|
||||
OvercastFogStart, OvercastFogEnd * 1.2f),
|
||||
WeatherKind.Storm => (Vector3.Lerp(kf.FogColor, new Vector3(0.25f, 0.25f, 0.30f), 0.8f),
|
||||
StormFogStart, StormFogEnd),
|
||||
_ => (kf.FogColor, kf.FogStart, kf.FogEnd),
|
||||
};
|
||||
}
|
||||
|
||||
private static Vector3 EnvironOverrideColor(EnvironOverride o) => o switch
|
||||
{
|
||||
EnvironOverride.RedFog => new Vector3(0.60f, 0.05f, 0.05f),
|
||||
EnvironOverride.BlueFog => new Vector3(0.08f, 0.15f, 0.60f),
|
||||
EnvironOverride.WhiteFog => new Vector3(0.90f, 0.90f, 0.92f),
|
||||
EnvironOverride.GreenFog => new Vector3(0.08f, 0.55f, 0.12f),
|
||||
EnvironOverride.BlackFog => new Vector3(0.02f, 0.02f, 0.02f),
|
||||
EnvironOverride.BlackFog2 => new Vector3(0.04f, 0.01f, 0.01f),
|
||||
_ => new Vector3(1f, 1f, 1f),
|
||||
};
|
||||
}
|
||||
136
tests/AcDream.Core.Tests/World/SkyDescLoaderTests.cs
Normal file
136
tests/AcDream.Core.Tests/World/SkyDescLoaderTests.cs
Normal file
|
|
@ -0,0 +1,136 @@
|
|||
using System.Collections.Generic;
|
||||
using System.Numerics;
|
||||
using AcDream.Core.World;
|
||||
using DatReaderWriter.DBObjs;
|
||||
using DatReaderWriter.Enums;
|
||||
using DatReaderWriter.Types;
|
||||
using Xunit;
|
||||
|
||||
namespace AcDream.Core.Tests.World;
|
||||
|
||||
public sealed class SkyDescLoaderTests
|
||||
{
|
||||
/// <summary>
|
||||
/// Hand-build a Region with a minimal sky descriptor to feed the
|
||||
/// loader without needing real dat bytes. The LoadFromRegion
|
||||
/// separator exists precisely for this — keeps the parsing logic
|
||||
/// testable independent of DatCollection.
|
||||
/// </summary>
|
||||
private static Region MakeRegion(float dirBright, byte rBgrOrder)
|
||||
{
|
||||
var region = new Region();
|
||||
region.PartsMask = PartsMask.HasSkyInfo;
|
||||
|
||||
var sky = new SkyDesc
|
||||
{
|
||||
TickSize = 1.0,
|
||||
LightTickSize = 2.0,
|
||||
};
|
||||
|
||||
var dg = new DayGroup
|
||||
{
|
||||
ChanceOfOccur = 1.0f,
|
||||
};
|
||||
|
||||
var time = new SkyTimeOfDay
|
||||
{
|
||||
Begin = 0.5f,
|
||||
DirBright = dirBright,
|
||||
DirHeading = 180f,
|
||||
DirPitch = 70f,
|
||||
DirColor = new ColorARGB { Blue = 0, Green = 0, Red = rBgrOrder, Alpha = 255 },
|
||||
AmbBright = 0.4f,
|
||||
AmbColor = new ColorARGB { Blue = 100, Green = 100, Red = 100, Alpha = 255 },
|
||||
MinWorldFog = 120f,
|
||||
MaxWorldFog = 400f,
|
||||
WorldFogColor = new ColorARGB { Blue = 50, Green = 50, Red = 50, Alpha = 255 },
|
||||
WorldFog = 1, // Linear
|
||||
};
|
||||
|
||||
dg.SkyTime.Add(time);
|
||||
sky.DayGroups.Add(dg);
|
||||
region.SkyInfo = sky;
|
||||
|
||||
return region;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void LoadFromRegion_ConvertsFogFields()
|
||||
{
|
||||
var region = MakeRegion(dirBright: 1.5f, rBgrOrder: 200);
|
||||
var loaded = SkyDescLoader.LoadFromRegion(region);
|
||||
|
||||
Assert.NotNull(loaded);
|
||||
Assert.Equal(1.0, loaded!.TickSize);
|
||||
Assert.Single(loaded.DayGroups);
|
||||
var grp = loaded.DayGroups[0];
|
||||
Assert.Single(grp.SkyTimes);
|
||||
|
||||
var kf = grp.SkyTimes[0].Keyframe;
|
||||
Assert.Equal(120f, kf.FogStart);
|
||||
Assert.Equal(400f, kf.FogEnd);
|
||||
Assert.Equal(FogMode.Linear, kf.FogMode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void LoadFromRegion_SunColor_IsPrepreMultipliedByBrightness()
|
||||
{
|
||||
var region = MakeRegion(dirBright: 1.5f, rBgrOrder: 200);
|
||||
var loaded = SkyDescLoader.LoadFromRegion(region);
|
||||
Assert.NotNull(loaded);
|
||||
|
||||
var kf = loaded!.DayGroups[0].SkyTimes[0].Keyframe;
|
||||
// R was 200/255 ≈ 0.784, times dirBright 1.5 = 1.176
|
||||
Assert.InRange(kf.SunColor.X, 1.17f, 1.19f);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void LoadFromRegion_NoSkyInfo_ReturnsNull()
|
||||
{
|
||||
var region = new Region { PartsMask = 0 };
|
||||
Assert.Null(SkyDescLoader.LoadFromRegion(region));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildDefaultProvider_FromDatKeyframes_SupportsInterpolation()
|
||||
{
|
||||
var region = MakeRegion(dirBright: 1.0f, rBgrOrder: 255);
|
||||
var loaded = SkyDescLoader.LoadFromRegion(region)!;
|
||||
var provider = loaded.BuildDefaultProvider();
|
||||
|
||||
// Exactly one keyframe: interpolation at any t returns it.
|
||||
var s = provider.Interpolate(0.1f);
|
||||
Assert.InRange(s.SunColor.X, 0.99f, 1.01f);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SkyObjectData_IsVisible_HandlesWrap()
|
||||
{
|
||||
var obj = new SkyObjectData
|
||||
{
|
||||
BeginTime = 0.9f, // wraps across midnight
|
||||
EndTime = 0.1f,
|
||||
};
|
||||
|
||||
Assert.True(obj.IsVisible(0.95f)); // near end of day
|
||||
Assert.True(obj.IsVisible(0.05f)); // just after midnight
|
||||
Assert.False(obj.IsVisible(0.5f)); // mid-day (not visible)
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SkyObjectData_CurrentAngle_LerpsAcrossWindow()
|
||||
{
|
||||
var obj = new SkyObjectData
|
||||
{
|
||||
BeginTime = 0.25f,
|
||||
EndTime = 0.75f,
|
||||
BeginAngle = 0f,
|
||||
EndAngle = 180f,
|
||||
};
|
||||
|
||||
// Middle of the window → 90°.
|
||||
Assert.Equal(90f, obj.CurrentAngle(0.5f), precision: 2);
|
||||
// At begin → begin angle.
|
||||
Assert.Equal(0f, obj.CurrentAngle(0.25f), precision: 2);
|
||||
}
|
||||
}
|
||||
102
tests/AcDream.Core.Tests/World/WeatherSystemTests.cs
Normal file
102
tests/AcDream.Core.Tests/World/WeatherSystemTests.cs
Normal file
|
|
@ -0,0 +1,102 @@
|
|||
using System;
|
||||
using System.Numerics;
|
||||
using AcDream.Core.World;
|
||||
using Xunit;
|
||||
|
||||
namespace AcDream.Core.Tests.World;
|
||||
|
||||
public sealed class WeatherSystemTests
|
||||
{
|
||||
[Fact]
|
||||
public void Roll_Deterministic_ForSameDayIndex()
|
||||
{
|
||||
var a = new WeatherSystem();
|
||||
var b = new WeatherSystem();
|
||||
|
||||
for (int d = 0; d < 100; d++)
|
||||
{
|
||||
a.Tick(0, d, 100f); // big dt to finish any transition
|
||||
b.Tick(0, d, 100f);
|
||||
Assert.Equal(a.Kind, b.Kind);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Roll_WeightsDominatedByClear()
|
||||
{
|
||||
// Clear should cover ~60% of the distribution. Sample many days
|
||||
// and check the clear fraction is in a reasonable band.
|
||||
var sys = new WeatherSystem();
|
||||
int clear = 0;
|
||||
for (int d = 0; d < 1000; d++)
|
||||
{
|
||||
sys.Tick(0, d, 100f);
|
||||
if (sys.Kind == WeatherKind.Clear) clear++;
|
||||
}
|
||||
double frac = clear / 1000.0;
|
||||
Assert.InRange(frac, 0.45, 0.75);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Transition_EasesAcrossTenSeconds()
|
||||
{
|
||||
// Force Storm, then Clear, sample snapshot fog distance mid-transition.
|
||||
var sys = new WeatherSystem();
|
||||
sys.ForceWeather(WeatherKind.Storm);
|
||||
sys.Tick(0, 1, 100f); // finalize
|
||||
|
||||
var kf = SkyStateProvider.Default().Interpolate(0.5f);
|
||||
var stormFog = sys.Snapshot(in kf);
|
||||
Assert.Equal(WeatherKind.Storm, stormFog.Kind);
|
||||
|
||||
// Snapshot should have a small fog end (storm fog is dense).
|
||||
Assert.True(stormFog.FogEnd < 120f, $"storm fog end too large: {stormFog.FogEnd}");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EnvironOverride_ForcesTintedFog()
|
||||
{
|
||||
var sys = new WeatherSystem();
|
||||
sys.Override = EnvironOverride.RedFog;
|
||||
|
||||
var kf = SkyStateProvider.Default().Interpolate(0.5f);
|
||||
var snap = sys.Snapshot(in kf);
|
||||
|
||||
Assert.Equal(EnvironOverride.RedFog, snap.Override);
|
||||
// Red override means the R channel dominates.
|
||||
Assert.True(snap.FogColor.X > snap.FogColor.Y);
|
||||
Assert.True(snap.FogColor.X > snap.FogColor.Z);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Flash_DecaysOverTime()
|
||||
{
|
||||
var sys = new WeatherSystem();
|
||||
sys.TriggerFlash();
|
||||
|
||||
var kf = SkyStateProvider.Default().Interpolate(0.5f);
|
||||
var imm = sys.Snapshot(in kf);
|
||||
Assert.True(imm.LightningFlash > 0.9f);
|
||||
|
||||
// After 1 second the flash should be mostly decayed.
|
||||
sys.Tick(0, 0, 1.0f);
|
||||
var later = sys.Snapshot(in kf);
|
||||
Assert.True(later.LightningFlash < 0.1f,
|
||||
$"lightning flash didn't decay: {later.LightningFlash}");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Snapshot_ClearKind_PassesThroughKeyframeFog()
|
||||
{
|
||||
var sys = new WeatherSystem();
|
||||
sys.ForceWeather(WeatherKind.Clear);
|
||||
sys.Tick(0, 0, 100f); // finish transition
|
||||
|
||||
var kf = SkyStateProvider.Default().Interpolate(0.5f);
|
||||
var snap = sys.Snapshot(in kf);
|
||||
|
||||
// Clear passes the keyframe's fog color + distances through.
|
||||
Assert.Equal(kf.FogStart, snap.FogStart, precision: 2);
|
||||
Assert.Equal(kf.FogEnd, snap.FogEnd, precision: 2);
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue