using System; using System.Numerics; namespace AcDream.Core.World; /// /// 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. /// /// /// The rendering side reads to decide whether to /// spawn rain/snow particles and which cloud mesh override to select. /// The field lets the fog / particle rate / /// cloud-darkness terms ease in and out smoothly rather than popping. /// /// public enum WeatherKind { Clear = 0, Overcast = 1, Rain = 2, Snow = 3, Storm = 4, } /// /// Server-forced fog override (retail EnvironChangeType). When /// the server sends AdminEnvirons (0xEA60) with one of the /// non- values, the client overrides its locally-computed /// fog color and density with the tint shown below. See r12 §5.2 and /// references/ACE/Source/ACE.Entity/Enum/EnvironChangeType.cs. /// public enum EnvironOverride { None = 0x00, // clear override, revert to dat-driven fog RedFog = 0x01, BlueFog = 0x02, WhiteFog = 0x03, GreenFog = 0x04, BlackFog = 0x05, BlackFog2 = 0x06, } /// /// Full per-frame atmosphere state consumed by the shader + particle /// systems. Built by from /// /// the interpolated , /// the current , /// a possibly-active , /// a transient lightning-flash bump. /// /// 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); /// /// 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. /// /// /// Algorithm (r12 §6.1–6.2): /// /// /// Derive a deterministic Random(dayIndex) per in-game day. /// Roll a weighted pick from a table matching retail's rough /// 70/15/10/5 distribution (Clear dominates). /// /// /// When the kind changes, store a transitionStart timestamp /// and tween from 0 → 1 /// over . /// /// /// Storm kind only: every 8–30 seconds fire a lightning flash; the /// shader reads as /// an additive scene bump that decays with a 200 ms time constant. /// /// /// Any server beats the local picks — /// stick the override fog color and density in the snapshot until /// the server sends . /// /// /// /// 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; } /// Current active weather. public WeatherKind Kind => _kind; /// Last-known server fog override (sticky between sync packets). public EnvironOverride Override { get => _override; set => _override = value; } /// /// Debug / test hook — force a specific weather kind, ignoring the /// per-day roll. Passing returns to /// normal behavior starting on the next day-roll. /// public void ForceWeather(WeatherKind kind) { BeginTransition(kind); _rolledDayIndex = int.MaxValue; // "forced" sentinel — don't re-roll } /// /// Advance the state machine. Call once per frame from the render /// loop. is the in-game day (derived /// from ); when it changes we re-roll /// the weather kind. /// 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; } } /// /// Trigger a lightning flash manually (server-forced or test hook). /// public void TriggerFlash() { _flashLevel = 1f; _flashAge = 0f; } /// /// 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 /// tint if any. /// 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; } /// /// Deterministic per-day weighted roll. Seeded with /// alone so every client running the same day sees the same weather — /// retail's mechanism for "synchronized weather without any packets" /// (r12 §6.1). /// 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), }; }