🔍 s&box Package Code Search
Search C# source code, UI razor templates, shaders, and configs across s&box packages.
🔗 Raw Facepunch API Link: https://public.facepunch.com/sbox/code/search/1/?ident=fieldguide.daynight&take=20
Showing code results for query:
*
(27 total matches found)
Game
library
using System;
namespace FieldGuide.DayNight;
/// <summary>Pure helpers for setting the clock from a UI. Kept separate from the networked component so they
/// are trivially unit-testable and reusable (a preview tool, an editor slider, a debug console).</summary>
public static class TimeMath
{
/// <summary>Set the clock to an hour-of-day while PRESERVING the current day index, so the deterministic
/// per-day weather roll does not re-roll when a player scrubs the time within a day. Result =
/// floor(current/24)*24 + clamp(hourOfDay, 0, 24). Example: day 1 at 15:30 (total 39.5), set 06:00 → 30.0
/// (still day 1).</summary>
public static float ComputeSetHour( float currentTotalHours, float hourOfDay )
=> MathF.Floor( currentTotalHours / 24f ) * 24f + Math.Clamp( hourOfDay, 0f, 24f );
/// <summary>Map a slider/drag fraction across a track (0 left, 1 right) to a quantized hour-of-day in
/// [0,24], rounded to the nearest in-game MINUTE (1/60 h). A pixel-cheap throttle with no timer state: a
/// drag emits at most one distinct value per minute of readout. Feed the result into
/// <see cref="ComputeSetHour"/> to keep the day index.
///
/// The result is clamped AFTER quantizing, and that clamp is load-bearing: one minute is not exactly
/// representable in binary, so 1440 steps of 1/60 accumulate to 24.000002 and a full-right drag would
/// hand <see cref="ComputeSetHour"/> a value past the end of the day. Round first, clamp second.</summary>
public static float ComputeSliderHour( float frac )
{
float hour = Math.Clamp( frac, 0f, 1f ) * 24f;
const float step = 1f / 60f; // one in-game minute
return Math.Clamp( MathF.Round( hour / step ) * step, 0f, 24f );
}
}
Game
library
@using Sandbox
@using Sandbox.UI
@using System
@using System.Collections.Generic
@namespace FieldGuide.DayNight
@inherits PanelComponent
@attribute [StyleSheet]
@*
The demo's on-screen key card, up from the first frame. A scene with no visible instructions reads as
a broken scene: you press nothing, nothing happens, you close it. So this says what the demo is and
which keys do what, before you have touched anything.
It also carries the one thing the demo could not otherwise show. The kit ships no sky shader and no
sky art on purpose; the sky is a SEAM, four normalized crossfade weights per hour. Those weights have
no picture, so the card prints them live and they visibly hand off from one slot to the next as the
clock runs. That is the seam doing its job, on screen, with no art involved.
Rows render in MAIN markup via @foreach per the fragment-undermeasure gotcha. H hides the card (a
letter, never an F key, which the editor eats in play). No ESC anywhere: house law.
Look and layout follow the Field Kit UI system: docs/design/ui-system/daynight-kit.dc.html for this
screen, tokens.dc.html for the values. Font sizes come from the {12, 13, 14, 16} panel scale and there
is no letter-spacing; the stylesheet head lists the rest of the engine-legality rules.
Not part of the kit's runtime surface: delete Code/Demo when you drop the kit into your own project.
*@
<root>
@* DemoActive is the inert-by-construction gate (library law 11): only DayNightDemoBootstrap sets it, so
this card cannot appear in a consumer's game even if Code/Demo was left in the project. *@
@if ( CardOpen && DayNightDemoBootstrap.DemoActive )
{
<div class="dh-card">
<div class="dh-hdr">
<span class="dh-title">DAY / NIGHT KIT DEMO</span>
<div class="dh-x" onclick=@(() => CardOpen = false)>×</div>
</div>
<div class="dh-lede">One directional light, one skybox, one clock. Watch the sun sweep and the colour grade follow it, or open the time panel and drive the cycle yourself.</div>
<div class="dh-rows">
@foreach ( var r in Keys )
{
string key = r.key; // plain locals before interpolating: an inline tuple read can render blank
string what = r.what;
<div class="dh-row">
<span class="dh-key">@key</span>
<span class="dh-what">@what</span>
</div>
}
</div>
<div class="dh-live">
@foreach ( var w in Weights )
{
string run = w;
<span class="dh-lk">@run</span>
}
</div>
<div class="dh-foot">Those four are the sky seam. The kit ships no sky shader and no sky art; you crossfade your own sky from these weights.</div>
</div>
}
</root>
@code
{
static bool _open = true;
/// <summary>Console fallback: `daynight_hint 1` / `daynight_hint 0` shows or hides the card (H also
/// toggles). Starts SHOWN, unlike the time panel, because it is the thing that tells you the time panel
/// exists.</summary>
[ConVar( "daynight_hint", Help = "Show or hide the demo scene's key card (same as the H key)" )]
public static bool CardOpen { get => _open; set => _open = value; }
static readonly List<(string key, string what)> Keys = new()
{
( "N", "Open the time panel: scrub the clock, change the pace, pin the weather" ),
( "H", "Hide this card" ),
};
DayNightClock _clock;
DayNightClock Clock
{
get
{
if ( _clock.IsValid() ) return _clock;
_clock = DayNightClock.For( Scene );
return _clock;
}
}
/// <summary>The live sky weights as four short atomic runs. Split into separate spans rather than one
/// sentence so a wrap breaks BETWEEN runs; a single long run wraps mid-word, which is a live bug class
/// in this engine's text layout.</summary>
List<string> Weights
{
get
{
var c = Clock;
var cfg = c?.Config ?? DayNightConfig.Default;
var w = SkyWeights.WeightsFor( c?.GetTimeHours() ?? 0f, cfg );
return new List<string>
{
$"MORNING {w.x:0.00}",
$"NOON {w.y:0.00}",
$"EVENING {w.z:0.00}",
$"NIGHT {w.w:0.00}",
};
}
}
protected override void OnUpdate()
{
if ( Input.Keyboard.Pressed( "H" ) )
CardOpen = !CardOpen;
}
// Fold the card state and every printed weight (to the two decimals shown), or the strip freezes at
// whatever it read on the first frame while the sun keeps moving.
protected override int BuildHash()
{
var c = Clock;
var cfg = c?.Config ?? DayNightConfig.Default;
var w = SkyWeights.WeightsFor( c?.GetTimeHours() ?? 0f, cfg );
return HashCode.Combine( CardOpen, DayNightDemoBootstrap.DemoActive,
(int)MathF.Round( w.x * 100f ),
(int)MathF.Round( w.y * 100f ),
(int)MathF.Round( w.z * 100f ),
(int)MathF.Round( w.w * 100f ) );
}
}
Game
library
using System;
using System.Linq;
using Sandbox;
namespace FieldGuide.DayNight;
/// <summary>
/// The host-authoritative day/night clock: the ONE thin networked surface in this kit. Everything else
/// (grading, weights, weather, time-set math) is pure and testable; this component is the small, honest
/// piece that cannot run headless because it depends on s&box networking.
///
/// Ownership model: in a peer-hosted s&box session the HOST owns the clock. It accumulates game-time each
/// fixed tick and publishes three <c>[Sync(SyncFlags.FromHost)]</c> fields; clients never write them, they
/// observe and locally extrapolate so the sun advances smoothly between snapshots. Single-player (networking
/// inactive) is the host-of-one and just reads its own field directly.
///
/// THE PAUSE PIN (the hardening worth understanding). A paused host STOPS writing <see cref="NetTimeOfDay"/>.
/// A client only corrects its extrapolated clock toward the host snapshot when that value CHANGES, so with
/// only a time field on the wire, a paused host would leave every client free-running forever (host frozen at
/// noon, clients cycling a whole day). <see cref="NetTimePaused"/> fixes it: the host publishes the pause
/// state on the same FromHost surface, and while it is true a client PINS its local clock to the snapshot and
/// skips the advance. The instant the host unpauses, extrapolation resumes and re-locks onto the moving
/// snapshot. Keep both fields on the wire together, this is the fix, do not drop it.
///
/// Add this component to your session/game-manager GameObject once. Drive the look with
/// <see cref="DayNightDriver"/> (or read <see cref="GetTimeHours"/> / <see cref="EffectiveWeather"/> yourself).
/// </summary>
[Title( "Day Night Clock" )]
[Category( "Field Guide" )]
[Icon( "schedule" )]
public sealed class DayNightClock : Component
{
/// <summary>Tuning (config over constants). Static, not replicated, set the SAME config on every peer
/// (it is authoring data, identical everywhere, not session state). Defaults to the reference grade.</summary>
public DayNightConfig Config { get; set; } = DayNightConfig.Default;
/// <summary>The world seed the deterministic weather roll hashes against. Set it to whatever your game uses
/// as its per-world seed so host and clients roll the same weather. Replicated so a mid-day joiner agrees.</summary>
[Sync( SyncFlags.FromHost )] public int WorldSeed { get; set; }
/// <summary>TOTAL game-hours since world start (dayIndex = floor(t/24), hour-of-day = t % 24). Host writes
/// it each tick; clients observe and extrapolate. FromHost so only the host's write survives.</summary>
[Sync( SyncFlags.FromHost )] public float NetTimeOfDay { get; set; }
/// <summary>Pause replication (see the class remarks). The host's authority-only pause state, published on
/// the SAME FromHost surface as <see cref="NetTimeOfDay"/> so a client can tell a paused host from a slow
/// one and stop free-running. Default false = the clock runs.</summary>
[Sync( SyncFlags.FromHost )] public bool NetTimePaused { get; set; }
/// <summary>Weather override: -1 = derive from the (seed, dayIndex) hash; >=0 = a forced
/// <see cref="WeatherKind"/> (a pin, or an authority carrying a specific day's roll to a late joiner).
/// FromHost so the host owns it.</summary>
[Sync( SyncFlags.FromHost )] public int NetWeatherOverride { get; set; } = -1;
bool _timePaused; // authority-side pause state, mirrored to NetTimePaused every tick
float _clientTimeHours; // client-side extrapolated clock (the host reads NetTimeOfDay directly)
float _lastNetTime = float.NaN;// last observed NetTimeOfDay on a client (NaN ⇒ snap on first sync)
/// <summary>Find the clock for a scene (first one). Returns null before it exists.</summary>
public static DayNightClock For( Scene scene )
=> scene?.GetAllComponents<DayNightClock>().FirstOrDefault();
static bool IsAuthority => !Networking.IsActive || Networking.IsHost;
protected override void OnStart()
{
if ( IsAuthority )
{
NetTimeOfDay = Config.StartHours;
_timePaused = Config.StartPaused;
NetTimePaused = _timePaused;
}
_clientTimeHours = NetTimeOfDay;
}
protected override void OnFixedUpdate()
{
// Authority only. Clients never accumulate here, they extrapolate NetTimeOfDay in OnUpdate.
if ( !IsAuthority ) return;
if ( !_timePaused )
{
// Non-uniform pace: scale the base hoursPerSecond by the pure per-hour-of-day rate so daylight runs
// slower than night. Framerate-independent (dt-scaled) and deterministic (ClockRateScale is pure), so
// host and every client derive the same rate from the same clock.
float hod = NetTimeOfDay - MathF.Floor( NetTimeOfDay / 24f ) * 24f;
NetTimeOfDay += SkyGrade.ClockRateScale( hod, Config ) * (24f / (Config.DayLengthMinutes * 60f)) * Time.Delta;
}
// Mirror the pause state to the wire EVERY tick, so it tracks every _timePaused mutation (the OnStart
// seed, a pin, a UI toggle) even when the clock is not advancing.
NetTimePaused = _timePaused;
}
protected override void OnUpdate()
{
// Only a CLIENT extrapolates. The authority reads NetTimeOfDay directly.
if ( IsAuthority ) return;
// Extrapolate the host clock locally between FromHost snapshots so the sun advances smoothly (snapshots
// arrive at the network tick, not every frame). Same non-uniform pace as the authority (shared pure
// ClockRateScale), derived from THIS client's own extrapolated clock so both peers advance identically
// between snapshots; the ease-toward-net below corrects any residual drift each snapshot.
//
// THE PAUSE PIN: a paused host stops advancing NetTimeOfDay, and the change-gated ease below only corrects
// when NetTimeOfDay MOVES, so a client that kept extrapolating would free-run forever. When the host
// reports paused, PIN the local clock to the host snapshot and skip the advance; the instant the host
// unpauses, extrapolation resumes and the ease re-locks onto the moving snapshot (the pinned _lastNetTime
// avoids a false unpause jump).
if ( NetTimePaused )
{
_clientTimeHours = NetTimeOfDay;
_lastNetTime = NetTimeOfDay;
return;
}
float clientHod = _clientTimeHours - MathF.Floor( _clientTimeHours / 24f ) * 24f;
_clientTimeHours += SkyGrade.ClockRateScale( clientHod, Config ) * (24f / (Config.DayLengthMinutes * 60f)) * Time.Delta;
float net = NetTimeOfDay;
if ( net != _lastNetTime )
{
bool first = float.IsNaN( _lastNetTime );
_lastNetTime = net;
float d = net - _clientTimeHours;
if ( first || MathF.Abs( d ) > 1f ) _clientTimeHours = net; // first sync / pin jump → snap
else _clientTimeHours += d * 0.25f; // small drift → ease
}
}
/// <summary>The effective clock: the host reads its authoritative <see cref="NetTimeOfDay"/>, a client reads
/// its locally-extrapolated clock (smoothed toward the host snapshots). Feed this to the grade and the sky
/// weights so they can never disagree.</summary>
public float GetTimeHours()
=> IsAuthority ? NetTimeOfDay : _clientTimeHours;
/// <summary>The current day index (floor(time / 24)).</summary>
public int CurrentDay => (int)MathF.Floor( GetTimeHours() / 24f );
/// <summary>The effective weather for a day: the host override if set, else the deterministic pure roll for
/// (<see cref="WorldSeed"/>, dayIndex).</summary>
public WeatherKind EffectiveWeather( int dayIndex )
{
if ( NetWeatherOverride >= 0 && System.Enum.IsDefined( typeof( WeatherKind ), NetWeatherOverride ) )
return (WeatherKind)NetWeatherOverride;
return WeatherRoll.For( WorldSeed, dayIndex );
}
/// <summary>The effective weather RIGHT NOW.</summary>
public WeatherKind CurrentWeather => EffectiveWeather( CurrentDay );
// ── authority-guarded writes (a client call is a quiet no-op; only the host's write survives the FromHost sync) ──
/// <summary>Set the clock to an hour-of-day, preserving the current day (so weather does not re-roll). Snaps
/// the client extrapolation trackers so a joiner does not ease across a deliberate jump. Authority only.</summary>
public void SetTimeOfDay( float hourOfDay )
{
if ( Networking.IsActive && !Networking.IsHost ) return;
NetTimeOfDay = TimeMath.ComputeSetHour( NetTimeOfDay, hourOfDay );
_clientTimeHours = NetTimeOfDay;
_lastNetTime = NetTimeOfDay;
}
/// <summary>Nudge the clock by a signed delta in game-hours (clamped at 0). Authority only.</summary>
public void NudgeTime( float deltaHours )
{
if ( Networking.IsActive && !Networking.IsHost ) return;
NetTimeOfDay = MathF.Max( 0f, NetTimeOfDay + deltaHours );
_clientTimeHours = NetTimeOfDay;
_lastNetTime = NetTimeOfDay;
}
/// <summary>Is the clock paused (authority-side)?</summary>
public bool TimePaused => _timePaused;
/// <summary>Pause or resume the clock. Authority only; the pause state replicates on the FromHost surface so
/// clients stop free-running (see the class remarks).</summary>
public void SetPaused( bool paused )
{
if ( Networking.IsActive && !Networking.IsHost ) return;
_timePaused = paused;
NetTimePaused = paused;
}
/// <summary>Force a weather kind (>=0) or clear back to the deterministic hash roll (-1). Authority only.</summary>
public void SetWeatherOverride( int weather )
{
if ( Networking.IsActive && !Networking.IsHost ) return;
NetWeatherOverride = ( weather >= 0 && System.Enum.IsDefined( typeof( WeatherKind ), weather ) ) ? weather : -1;
}
}
Game
library
// ================================================================================================
// FIELD KIT UI SYSTEM · Day / Night Kit · time panel
//
// SOURCE OF TRUTH: docs/design/ui-system/tokens.dc.html
// (+ components.dc.html for the parts, daynight-kit.dc.html for this screen)
//
// DRIFT NOTE. s&box cannot express a library-to-library dependency, so a kit must never @import
// another kit's stylesheet. Every kit therefore carries its OWN copy of the token values below.
// Nothing syncs them. If a value moves on the tokens page, hand-update it here AND in every other
// kit's .razor.scss, then re-check the kits side by side.
//
// ENGINE-LEGAL SUBSET. The mockups are browser HTML and contain CSS this engine cannot parse. The
// translations, all applied below:
// · never `border: 1px solid x` -> border-width + border-color only. border-style is a parse
// error that aborts the WHOLE stylesheet and collapses the panel to 0x0.
// · never box-shadow. All depth comes from the alpha surfaces.
// · never letter-spacing. Hierarchy is size, weight and case.
// · never the `inset` shorthand -> top/left/width/height, expanded.
// · never a percent max-height on an absolute card, and no glyph outside the shipped font
// anywhere (the mockups' dropdown caret included): it renders as tofu.
// · explicit px line-heights, never unitless ratios.
// · font sizes come only from {12, 13, 14, 16}, plus 20 for the hero clock readout and nothing
// else (the tokens page reserves 20 for exactly that). Five distinct sizes against a budget of
// eight per panel assembly.
//
// FONT DECLARATIONS ARE INLINE AND UNQUOTED, AND THAT IS LOAD-BEARING. Write
// `font-family: Poppins, sans-serif;` and `font-family: Roboto Mono, monospace;` literally at every
// site. A SCSS variable holding the family, or a quoted family name, does not survive this engine's
// stylesheet parse: the rule is dropped and the panel silently falls back to the default face. This
// cost a live debugging session; do not "tidy" these into a $token.
//
// CONTRAST LAW (overrides the mockups wherever they are dimmer). Nothing a player reads to operate
// a control is dimmer than #E8EAED. Key badges are pure #FFFFFF at weight 700. The 42px close x is
// present and is the ONLY close affordance: no ESC badge, ever.
//
// SLIDER MECHANIC (components.dc.html, fk-slider-row). .dn-hit is a transparent 7px-padded wrapper
// so the grab area is 28px tall rather than the 14px the track draws; .dn-track is the visible pill
// and .dn-fill is an absolutely positioned, pointer-events:none decoration inside it. Wrapper and
// track BOTH own pointer events and both carry the drag handlers: they share a left edge and a
// width, so whichever one the cursor lands on computes the same fraction, and the bubbled duplicate
// call writes the same value twice. Do not "simplify" the pair away; the fill must never resize the
// row and the drag must be measured against the track, not the fill.
// ================================================================================================
// ---- base tokens (shared across kits, copied per kit) ----
$fk-panel-bg: rgba( 15, 17, 21, 0.92 );
$fk-border: rgba( 255, 255, 255, 0.08 );
$fk-border-hi: rgba( 255, 255, 255, 0.12 );
$fk-row: rgba( 255, 255, 255, 0.05 );
$fk-row-2: rgba( 255, 255, 255, 0.06 );
$fk-hover: rgba( 255, 255, 255, 0.10 );
$fk-track: rgba( 255, 255, 255, 0.12 );
$fk-track-hover: rgba( 255, 255, 255, 0.16 );
$fk-text-hi: #F2F4F7;
$fk-text: #E8EAED;
$fk-key-glyph: #FFFFFF;
// ---- kit accent (Day / Night Kit · hue 300) ----
$fk-accent: #C9AEF2;
$fk-accent-hover: #D4BEF6;
$fk-accent-ink: #140A1A;
DayNightPanel {
position: absolute;
top: 0px;
left: 0px;
width: 100%;
height: 100%;
pointer-events: none;
font-family: Poppins, sans-serif;
// The card is the only thing that takes clicks, so the cursor stays usable over the panel while
// the rest of the scene ignores it.
.dn-card {
position: absolute;
top: 40px;
right: 40px;
width: 420px;
flex-direction: column;
gap: 12px;
padding: 20px;
pointer-events: all;
background-color: $fk-panel-bg;
border-width: 1px;
border-color: $fk-border;
border-radius: 16px;
color: $fk-text;
}
// ---- header ----
.dn-hdr {
flex-direction: row;
justify-content: space-between;
align-items: center;
}
.dn-title {
font-size: 16px;
line-height: 22px;
font-weight: 700;
color: $fk-text-hi;
}
.dn-hr {
flex-direction: row;
align-items: center;
gap: 4px;
}
.dn-key {
font-family: Roboto Mono, monospace;
font-size: 14px;
line-height: 20px;
font-weight: 700;
color: $fk-key-glyph;
background-color: $fk-row;
border-width: 1px;
border-color: $fk-border-hi;
border-radius: 5px;
padding: 3px 8px;
flex-shrink: 0;
}
// 42px hit target on a 16px glyph (owner ruling: 34px is too small to hit). The negative margins
// pull the box back into the 20px card padding so the header stays compact.
.dn-x {
font-size: 16px;
line-height: 22px;
color: $fk-text-hi;
width: 42px;
height: 42px;
margin: -8px -12px -8px 0px;
justify-content: center;
align-items: center;
border-radius: 6px;
cursor: pointer;
pointer-events: all;
transition: all 0.12s ease;
&:hover { background-color: $fk-hover; }
}
.dn-empty {
font-size: 13px;
line-height: 20px;
color: $fk-text;
}
// ---- hero clock readout (the one 20px type site in the kit) ----
.dn-hero {
flex-direction: row;
justify-content: space-between;
align-items: center;
background-color: $fk-row;
border-radius: 10px;
padding: 12px 14px;
}
.dn-hl {
font-size: 13px;
line-height: 20px;
color: $fk-text-hi;
}
.dn-hv {
font-family: Roboto Mono, monospace;
font-size: 20px;
line-height: 26px;
font-weight: 600;
color: $fk-text-hi;
}
// ---- kicker line: day index and where the weather came from ----
// Wrap row of short atomic runs, each one nowrap and unshrinkable, so nothing splits mid-word.
// Single-value gap on purpose. The tokens page asks for 3px between wrapped rows and 8px between
// runs, but nothing shipped in these kits uses the two-value `gap: 3px 8px` form and this engine's
// parser is not proven on it. One value is the safe subset; 8px both ways reads fine.
.dn-meta {
flex-direction: row;
flex-wrap: wrap;
gap: 8px;
}
.dn-mk {
font-family: Roboto Mono, monospace;
font-size: 12px;
line-height: 16px;
font-weight: 500;
color: $fk-text;
white-space: nowrap;
flex-shrink: 0;
}
// ---- label + value + slider rows ----
.dn-row {
flex-direction: column;
gap: 6px;
}
.dn-rlab {
flex-direction: row;
justify-content: space-between;
align-items: center;
}
.dn-rl {
font-size: 13px;
line-height: 20px;
color: $fk-text-hi;
}
.dn-rv {
font-family: Roboto Mono, monospace;
font-size: 14px;
line-height: 20px;
color: $fk-text;
}
.dn-slider {
flex-direction: row;
align-items: center;
gap: 10px;
}
.dn-stp {
font-family: Roboto Mono, monospace;
font-size: 14px;
line-height: 20px;
color: $fk-text-hi;
width: 28px;
height: 28px;
flex-shrink: 0;
justify-content: center;
align-items: center;
background-color: $fk-row-2;
border-width: 1px;
border-color: $fk-hover;
border-radius: 6px;
cursor: pointer;
transition: all 0.12s ease;
&:hover {
color: $fk-accent-ink;
background-color: $fk-accent;
}
}
// Transparent grab wrapper: 14px track plus 7px above and below = the 28px hit area the system
// asks for. Horizontal padding is zero on purpose so its width equals the track's exactly.
.dn-hit {
flex-grow: 1;
flex-direction: column;
justify-content: center;
padding: 7px 0px;
pointer-events: all;
cursor: pointer;
}
.dn-track {
position: relative;
width: 100%;
height: 14px;
border-radius: 99px;
background-color: $fk-track;
pointer-events: all;
cursor: pointer;
transition: all 0.12s ease;
&:hover { background-color: $fk-track-hover; }
.dn-fill {
position: absolute;
left: 0px;
top: 0px;
height: 100%;
min-width: 14px;
border-radius: 99px;
background-color: $fk-accent;
pointer-events: none;
}
}
// ---- jump-to pill chips ----
.dn-chips {
flex-direction: row;
flex-wrap: wrap;
gap: 6px;
}
.dn-chip {
font-family: Roboto Mono, monospace;
font-size: 13px;
line-height: 20px;
font-weight: 600;
color: $fk-text-hi;
background-color: $fk-track;
border-radius: 99px;
padding: 5px 14px;
white-space: nowrap;
flex-shrink: 0;
cursor: pointer;
transition: all 0.12s ease;
&:hover { background-color: $fk-hover; }
&.on {
color: $fk-accent-ink;
background-color: $fk-accent;
&:hover { background-color: $fk-accent-hover; }
}
}
// ---- segmented groups (weather, run/pause) ----
.dn-seg-group {
flex-direction: row;
align-items: center;
background-color: $fk-row;
border-radius: 99px;
padding: 3px;
}
// Weather fills the card width, so its segments share the row evenly.
.dn-seg-group.wide {
width: 100%;
}
.dn-seg {
font-family: Roboto Mono, monospace;
font-size: 13px;
line-height: 20px;
font-weight: 600;
color: $fk-text-hi;
justify-content: center;
align-items: center;
border-radius: 99px;
padding: 5px 16px;
white-space: nowrap;
cursor: pointer;
transition: all 0.12s ease;
&:hover { background-color: $fk-hover; }
&.on {
color: $fk-accent-ink;
background-color: $fk-accent;
&:hover { background-color: $fk-accent-hover; }
}
&.grow { flex-grow: 1; }
// The run / pause pair sits inline beside its label rather than filling the card, and the
// mockup gives that tighter pair 4px of vertical padding against the wide group's 5px.
&.tight { padding: 4px 16px; }
}
// Label beside an inline control (the run / pause row).
.dn-inline {
flex-direction: row;
justify-content: space-between;
align-items: center;
}
// ---- a read-only advisory (shown on a client, where the host owns the clock) ----
.dn-note {
font-size: 13px;
line-height: 20px;
color: $fk-text;
background-color: $fk-row;
border-radius: 10px;
padding: 9px 12px;
}
// ---- actions ----
.dn-btns {
flex-direction: row;
gap: 8px;
border-top-width: 1px;
border-top-color: $fk-border;
padding-top: 12px;
}
.dn-btn {
font-size: 13px;
line-height: 20px;
font-weight: 600;
color: $fk-text-hi;
background-color: $fk-row-2;
border-width: 1px;
border-color: $fk-hover;
border-radius: 10px;
padding: 10px 0px;
flex-grow: 1;
justify-content: center;
align-items: center;
cursor: pointer;
transition: all 0.12s ease;
&:hover { background-color: $fk-hover; }
// Copy config is the primary action on this card: accent fill, ink text, no hairline.
&.primary {
color: $fk-accent-ink;
background-color: $fk-accent;
border-color: $fk-accent;
&:hover { background-color: $fk-accent-hover; }
}
}
}
Game
library
using System;
namespace FieldGuide.DayNight;
/// <summary>Weather kinds (visual-only). Rolled per in-game day as a PURE hash of (seed, dayIndex), so a host
/// and every observer agree on the day's weather from the replicated seed + clock alone, with no extra
/// networking. The int order is the wire/override value: an authority pin publishes the forced kind as its
/// int, and any deserializer maps back through this enum.</summary>
public enum WeatherKind
{
Clear = 0,
Cloudy = 1,
Rain = 2,
}
/// <summary>The deterministic per-day weather roll. Pure and self-contained: no DateTime, no System.Random,
/// just an FNV-1a hash of the world seed and the day index bucketed into the three kinds. Same inputs always
/// yield the same kind, so two peers deriving weather from the same seed never disagree and the roll never
/// flaps mid-day.</summary>
public static class WeatherRoll
{
/// <summary>Roll the weather for a given (world seed, day index). Distribution: ~60% Clear, ~25% Cloudy,
/// ~15% Rain. Byte-stable and deterministic; the hash (offset basis, prime, salt) is ported unchanged from
/// the source game, so a save that recorded a WB day rolls the same kind here.</summary>
public static WeatherKind For( int seed, int dayIndex )
{
ulong h = 1469598103934665603UL; // FNV-1a offset basis
void Mix( long v )
{
for ( int i = 0; i < 8; i++ ) { h ^= (byte)(v >> (i * 8)); h *= 1099511628211UL; }
}
Mix( seed );
Mix( dayIndex );
Mix( 0x5713_9A2FL ); // fixed salt so dayIndex 0 isn't a bare seed hash (ported verbatim)
int r = (int)(h % 100);
if ( r < 60 ) return WeatherKind.Clear; // ~60% clear, ~25% cloudy, ~15% rain
if ( r < 85 ) return WeatherKind.Cloudy;
return WeatherKind.Rain;
}
}
Game
library
// ================================================================================================
// FIELD KIT UI SYSTEM · Day / Night Kit · demo key-hint card
//
// SOURCE OF TRUTH: docs/design/ui-system/tokens.dc.html
// (+ components.dc.html for the parts, daynight-kit.dc.html for this screen)
//
// DRIFT NOTE. s&box cannot express a library-to-library dependency, so a kit must never @import
// another kit's stylesheet. Every kit carries its OWN copy of the token values below, and this
// kit's two panels each carry a copy. Nothing syncs them: if a value moves on the tokens page,
// hand-update it here, in Ui/DayNightPanel.razor.scss, and in every other kit.
//
// ENGINE-LEGAL SUBSET (the mockups are browser HTML and contain CSS this engine cannot parse):
// border-width + border-color only, never `border: 1px solid x` (border-style is a parse error
// that aborts the whole stylesheet); no box-shadow; no letter-spacing; no `inset` shorthand; no
// percent max-height on an absolute card; explicit px line-heights; font sizes only from
// {12, 13, 14, 16}.
//
// FONT DECLARATIONS ARE INLINE AND UNQUOTED, AND THAT IS LOAD-BEARING. `font-family: Poppins,
// sans-serif;` and `font-family: Roboto Mono, monospace;` written out at every site. A SCSS
// variable holding the family, or a quoted family name, does not survive this engine's stylesheet
// parse: the rule is dropped and the card silently falls back to the default face.
//
// CONTRAST LAW (overrides the mockups wherever they are dimmer): nothing a player reads is dimmer
// than #E8EAED, key badges are pure #FFFFFF at weight 700. The 42px close x is the only close
// affordance on this card; there is no ESC badge, here or anywhere.
// ================================================================================================
// ---- base tokens (shared across kits, copied per kit) ----
$fk-panel-bg: rgba( 15, 17, 21, 0.92 );
$fk-border: rgba( 255, 255, 255, 0.08 );
$fk-border-hi: rgba( 255, 255, 255, 0.12 );
$fk-row: rgba( 255, 255, 255, 0.05 );
$fk-hover: rgba( 255, 255, 255, 0.10 );
$fk-text-hi: #F2F4F7;
$fk-text: #E8EAED;
$fk-key-glyph: #FFFFFF;
// No accent token here on purpose: this card is entirely neutral, the way the placement kit's hint
// card is. Tinting the key chips would compete with the time panel, which is where the violet
// (#C9AEF2) does its work.
DayNightHintCard {
position: absolute;
top: 0px;
left: 0px;
width: 100%;
height: 100%;
pointer-events: none;
font-family: Poppins, sans-serif;
// Only the card takes clicks, so the scene keeps the cursor everywhere else.
.dh-card {
position: absolute;
top: 40px;
left: 40px;
width: 440px;
flex-direction: column;
gap: 12px;
padding: 20px;
pointer-events: all;
background-color: $fk-panel-bg;
border-width: 1px;
border-color: $fk-border;
border-radius: 16px;
color: $fk-text;
}
.dh-hdr {
flex-direction: row;
justify-content: space-between;
align-items: center;
}
.dh-title {
font-size: 16px;
line-height: 22px;
font-weight: 700;
color: $fk-text-hi;
}
// 42px hit target on a 16px glyph, matching the time panel. The negative margins pull it back into
// the card's 20px padding so the header row stays compact.
.dh-x {
font-size: 16px;
line-height: 22px;
color: $fk-text-hi;
width: 42px;
height: 42px;
margin: -8px -12px -8px 0px;
justify-content: center;
align-items: center;
border-radius: 6px;
cursor: pointer;
pointer-events: all;
transition: all 0.12s ease;
&:hover { background-color: $fk-hover; }
}
.dh-lede {
font-size: 13px;
line-height: 20px;
color: $fk-text;
}
.dh-rows {
flex-direction: column;
gap: 8px;
}
.dh-row {
flex-direction: row;
// flex-start, not centre: the longer hints wrap to two lines, and a key chip floating half
// way down its own explanation is the one place this card stops looking like the mockup.
// Same correction the placement kit's hint card carries.
align-items: flex-start;
gap: 12px;
}
// Fixed-width chip so every key column lines up and a chip never splits across a wrapped line.
// Width is the whole box (no horizontal padding), which keeps the column exact.
.dh-key {
font-family: Roboto Mono, monospace;
font-size: 14px;
line-height: 20px;
font-weight: 700;
color: $fk-key-glyph;
width: 110px;
flex-shrink: 0;
padding: 3px 0px;
justify-content: center;
align-items: center;
background-color: $fk-row;
border-width: 1px;
border-color: $fk-border-hi;
border-radius: 5px;
}
.dh-what {
font-size: 13px;
line-height: 20px;
color: $fk-text;
flex-grow: 1;
}
// Live status strip: short atomic mono runs, each unshrinkable and nowrap, so a wrap never splits
// one mid-word.
// Single-value gap on purpose: nothing shipped in these kits uses the two-value `gap: 3px 8px` form
// and this engine's parser is not proven on it. One value is the safe subset.
.dh-live {
flex-direction: row;
flex-wrap: wrap;
gap: 8px;
border-top-width: 1px;
border-top-color: $fk-border;
padding-top: 12px;
}
.dh-lk {
font-family: Roboto Mono, monospace;
font-size: 12px;
line-height: 16px;
font-weight: 500;
color: $fk-text;
white-space: nowrap;
flex-shrink: 0;
}
.dh-foot {
font-size: 13px;
line-height: 20px;
color: $fk-text;
}
}
Game
library
using System;
using System.Linq;
using Sandbox;
namespace FieldGuide.DayNight;
/// <summary>
/// The host-authoritative day/night clock: the ONE thin networked surface in this kit. Everything else
/// (grading, weights, weather, time-set math) is pure and testable; this component is the small, honest
/// piece that cannot run headless because it depends on s&box networking.
///
/// Ownership model: in a peer-hosted s&box session the HOST owns the clock. It accumulates game-time each
/// fixed tick and publishes three <c>[Sync(SyncFlags.FromHost)]</c> fields; clients never write them, they
/// observe and locally extrapolate so the sun advances smoothly between snapshots. Single-player (networking
/// inactive) is the host-of-one and just reads its own field directly.
///
/// THE PAUSE PIN (the hardening worth understanding). A paused host STOPS writing <see cref="NetTimeOfDay"/>.
/// A client only corrects its extrapolated clock toward the host snapshot when that value CHANGES, so with
/// only a time field on the wire, a paused host would leave every client free-running forever (host frozen at
/// noon, clients cycling a whole day). <see cref="NetTimePaused"/> fixes it: the host publishes the pause
/// state on the same FromHost surface, and while it is true a client PINS its local clock to the snapshot and
/// skips the advance. The instant the host unpauses, extrapolation resumes and re-locks onto the moving
/// snapshot. Keep both fields on the wire together, this is the fix, do not drop it.
///
/// Add this component to your session/game-manager GameObject once. Drive the look with
/// <see cref="DayNightDriver"/> (or read <see cref="GetTimeHours"/> / <see cref="EffectiveWeather"/> yourself).
/// </summary>
[Title( "Day Night Clock" )]
[Category( "Field Guide" )]
[Icon( "schedule" )]
public sealed class DayNightClock : Component
{
/// <summary>Tuning (config over constants). Static, not replicated, set the SAME config on every peer
/// (it is authoring data, identical everywhere, not session state). Defaults to the reference grade.</summary>
public DayNightConfig Config { get; set; } = DayNightConfig.Default;
/// <summary>The world seed the deterministic weather roll hashes against. Set it to whatever your game uses
/// as its per-world seed so host and clients roll the same weather. Replicated so a mid-day joiner agrees.</summary>
[Sync( SyncFlags.FromHost )] public int WorldSeed { get; set; }
/// <summary>TOTAL game-hours since world start (dayIndex = floor(t/24), hour-of-day = t % 24). Host writes
/// it each tick; clients observe and extrapolate. FromHost so only the host's write survives.</summary>
[Sync( SyncFlags.FromHost )] public float NetTimeOfDay { get; set; }
/// <summary>Pause replication (see the class remarks). The host's authority-only pause state, published on
/// the SAME FromHost surface as <see cref="NetTimeOfDay"/> so a client can tell a paused host from a slow
/// one and stop free-running. Default false = the clock runs.</summary>
[Sync( SyncFlags.FromHost )] public bool NetTimePaused { get; set; }
/// <summary>Weather override: -1 = derive from the (seed, dayIndex) hash; >=0 = a forced
/// <see cref="WeatherKind"/> (a pin, or an authority carrying a specific day's roll to a late joiner).
/// FromHost so the host owns it.</summary>
[Sync( SyncFlags.FromHost )] public int NetWeatherOverride { get; set; } = -1;
bool _timePaused; // authority-side pause state, mirrored to NetTimePaused every tick
float _clientTimeHours; // client-side extrapolated clock (the host reads NetTimeOfDay directly)
float _lastNetTime = float.NaN;// last observed NetTimeOfDay on a client (NaN ⇒ snap on first sync)
/// <summary>Find the clock for a scene (first one). Returns null before it exists.</summary>
public static DayNightClock For( Scene scene )
=> scene?.GetAllComponents<DayNightClock>().FirstOrDefault();
static bool IsAuthority => !Networking.IsActive || Networking.IsHost;
protected override void OnStart()
{
if ( IsAuthority )
{
NetTimeOfDay = Config.StartHours;
_timePaused = Config.StartPaused;
NetTimePaused = _timePaused;
}
_clientTimeHours = NetTimeOfDay;
}
protected override void OnFixedUpdate()
{
// Authority only. Clients never accumulate here, they extrapolate NetTimeOfDay in OnUpdate.
if ( !IsAuthority ) return;
if ( !_timePaused )
{
// Non-uniform pace: scale the base hoursPerSecond by the pure per-hour-of-day rate so daylight runs
// slower than night. Framerate-independent (dt-scaled) and deterministic (ClockRateScale is pure), so
// host and every client derive the same rate from the same clock.
float hod = NetTimeOfDay - MathF.Floor( NetTimeOfDay / 24f ) * 24f;
NetTimeOfDay += SkyGrade.ClockRateScale( hod, Config ) * (24f / (Config.DayLengthMinutes * 60f)) * Time.Delta;
}
// Mirror the pause state to the wire EVERY tick, so it tracks every _timePaused mutation (the OnStart
// seed, a pin, a UI toggle) even when the clock is not advancing.
NetTimePaused = _timePaused;
}
protected override void OnUpdate()
{
// Only a CLIENT extrapolates. The authority reads NetTimeOfDay directly.
if ( IsAuthority ) return;
// Extrapolate the host clock locally between FromHost snapshots so the sun advances smoothly (snapshots
// arrive at the network tick, not every frame). Same non-uniform pace as the authority (shared pure
// ClockRateScale), derived from THIS client's own extrapolated clock so both peers advance identically
// between snapshots; the ease-toward-net below corrects any residual drift each snapshot.
//
// THE PAUSE PIN: a paused host stops advancing NetTimeOfDay, and the change-gated ease below only corrects
// when NetTimeOfDay MOVES, so a client that kept extrapolating would free-run forever. When the host
// reports paused, PIN the local clock to the host snapshot and skip the advance; the instant the host
// unpauses, extrapolation resumes and the ease re-locks onto the moving snapshot (the pinned _lastNetTime
// avoids a false unpause jump).
if ( NetTimePaused )
{
_clientTimeHours = NetTimeOfDay;
_lastNetTime = NetTimeOfDay;
return;
}
float clientHod = _clientTimeHours - MathF.Floor( _clientTimeHours / 24f ) * 24f;
_clientTimeHours += SkyGrade.ClockRateScale( clientHod, Config ) * (24f / (Config.DayLengthMinutes * 60f)) * Time.Delta;
float net = NetTimeOfDay;
if ( net != _lastNetTime )
{
bool first = float.IsNaN( _lastNetTime );
_lastNetTime = net;
float d = net - _clientTimeHours;
if ( first || MathF.Abs( d ) > 1f ) _clientTimeHours = net; // first sync / pin jump → snap
else _clientTimeHours += d * 0.25f; // small drift → ease
}
}
/// <summary>The effective clock: the host reads its authoritative <see cref="NetTimeOfDay"/>, a client reads
/// its locally-extrapolated clock (smoothed toward the host snapshots). Feed this to the grade and the sky
/// weights so they can never disagree.</summary>
public float GetTimeHours()
=> IsAuthority ? NetTimeOfDay : _clientTimeHours;
/// <summary>The current day index (floor(time / 24)).</summary>
public int CurrentDay => (int)MathF.Floor( GetTimeHours() / 24f );
/// <summary>The effective weather for a day: the host override if set, else the deterministic pure roll for
/// (<see cref="WorldSeed"/>, dayIndex).</summary>
public WeatherKind EffectiveWeather( int dayIndex )
{
if ( NetWeatherOverride >= 0 && System.Enum.IsDefined( typeof( WeatherKind ), NetWeatherOverride ) )
return (WeatherKind)NetWeatherOverride;
return WeatherRoll.For( WorldSeed, dayIndex );
}
/// <summary>The effective weather RIGHT NOW.</summary>
public WeatherKind CurrentWeather => EffectiveWeather( CurrentDay );
// ── authority-guarded writes (a client call is a quiet no-op; only the host's write survives the FromHost sync) ──
/// <summary>Set the clock to an hour-of-day, preserving the current day (so weather does not re-roll). Snaps
/// the client extrapolation trackers so a joiner does not ease across a deliberate jump. Authority only.</summary>
public void SetTimeOfDay( float hourOfDay )
{
if ( Networking.IsActive && !Networking.IsHost ) return;
NetTimeOfDay = TimeMath.ComputeSetHour( NetTimeOfDay, hourOfDay );
_clientTimeHours = NetTimeOfDay;
_lastNetTime = NetTimeOfDay;
}
/// <summary>Nudge the clock by a signed delta in game-hours (clamped at 0). Authority only.</summary>
public void NudgeTime( float deltaHours )
{
if ( Networking.IsActive && !Networking.IsHost ) return;
NetTimeOfDay = MathF.Max( 0f, NetTimeOfDay + deltaHours );
_clientTimeHours = NetTimeOfDay;
_lastNetTime = NetTimeOfDay;
}
/// <summary>Is the clock paused (authority-side)?</summary>
public bool TimePaused => _timePaused;
/// <summary>Pause or resume the clock. Authority only; the pause state replicates on the FromHost surface so
/// clients stop free-running (see the class remarks).</summary>
public void SetPaused( bool paused )
{
if ( Networking.IsActive && !Networking.IsHost ) return;
_timePaused = paused;
NetTimePaused = paused;
}
/// <summary>Force a weather kind (>=0) or clear back to the deterministic hash roll (-1). Authority only.</summary>
public void SetWeatherOverride( int weather )
{
if ( Networking.IsActive && !Networking.IsHost ) return;
NetWeatherOverride = ( weather >= 0 && System.Enum.IsDefined( typeof( WeatherKind ), weather ) ) ? weather : -1;
}
}
Game
library
using System;
using System.Collections.Generic;
using System.Linq;
using Sandbox;
namespace FieldGuide.DayNight;
/// <summary>
/// Wires the demo scene in code so the whole kit is exercised from one component.
///
/// The scene it builds is the kit's hero case: a lit ground plane with a few shapes on it, a
/// <see cref="DayNightClock"/> running the time, and a <see cref="DayNightDriver"/> on the scene's
/// DirectionalLight. Nothing else. That is enough, because the kit's product IS the light: the sun sweeps,
/// the shadows swing across the ground, the colour grade warms into dusk and drops into a genuinely dark
/// night, and none of it touches camera exposure. The shapes exist to catch that light and throw the
/// shadows that make the sweep readable; a bare plane shows almost nothing.
///
/// Two surfaces sit on top. The hint card (left) is up from the first frame and prints the live sky
/// weights, which is the only way to SEE the sky seam in a kit that deliberately ships no sky art. The
/// time panel (right) is the kit's dev tuning surface, opened here because a demo whose point is
/// "drive the cycle" should not hide the control behind a keypress.
///
/// Everything it builds ships with the engine: the dev primitives and the default material. The kit adds
/// no art of its own.
///
/// DEMO CONTENT IS INERT BY CONSTRUCTION (library law 11). Two things make that true here rather than by
/// instruction. First, the kit ships no scanned GameResource, so there is no demo content that can load
/// itself into a consumer's game the way a stray demo asset would. Second, the demo's UI is gated on
/// <see cref="DemoActive"/>, a flag ONLY this bootstrap sets: a consumer who forgets to delete Code/Demo,
/// and who somehow ends up with the hint card component in a scene, still renders nothing.
///
/// Not part of the kit's runtime surface: delete Code/Demo when you drop the kit into your own project.
/// </summary>
[Title( "Day Night Demo Bootstrap" )]
[Category( "Field Guide · Day Night" )]
[Icon( "auto_awesome" )]
public sealed class DayNightDemoBootstrap : Component
{
/// <summary>
/// True once this bootstrap has run in this session. The demo's own UI checks it before rendering, so
/// demo content cannot appear in a consumer's game just because Code/Demo was left in the project.
/// Nothing outside Code/Demo reads or writes it.
/// </summary>
public static bool DemoActive { get; private set; }
/// <summary>The world seed the deterministic weather roll hashes against. Any int works; this one is
/// just a fixed number so the demo rolls the same weather every run and two people comparing notes see
/// the same days.</summary>
[Property] public int DemoWorldSeed { get; set; } = 20260731;
/// <summary>Real minutes per in-game day at the night pace. Short by default: the whole point of the
/// demo is watching a full cycle, and the shipped default of 20 minutes is a long wait for that.</summary>
[Property] public float DemoDayLengthMinutes { get; set; } = 4f;
/// <summary>Game-hour the demo starts at. Mid-morning, so the first thing on screen is a lit scene with
/// a sun that is visibly climbing rather than a black frame.</summary>
[Property] public float DemoStartHour { get; set; } = 8.5f;
const string FallbackMaterial = "materials/default.vmat";
/// <summary>One shape in the demo cluster, sized in ENGINE UNITS per axis. The builder divides the size
/// by the model's own bounds to get the scale, so the table below reads as real dimensions and survives
/// the engine changing what a dev primitive measures (the shipped box is 50 units, the sphere 64).</summary>
readonly record struct Shape( string ModelPath, Vector3 SizeUnits, Vector3 Position, Color Tint );
/// <summary>
/// The cluster. A tall slab, a low wall, two blocks and a ball, spread out and at different heights.
///
/// The shapes are chosen for their SHADOWS, not their looks. A tall thin slab throws a long finger that
/// swings a quarter turn across the plane over one day, which is the single clearest read on "the sun is
/// actually moving"; the low wall gives a hard edge for the terminator to crawl along at dawn and dusk;
/// the ball is the only curved surface, so it is where the warm key and the cool sky fill are visibly
/// two different colours rather than one flat tone.
/// </summary>
static readonly Shape[] Cluster =
{
new( "models/dev/box.vmdl", new Vector3( 24f, 24f, 260f ), new Vector3( 0f, 0f, 130f ), new Color( 0.78f, 0.76f, 0.72f ) ),
new( "models/dev/box.vmdl", new Vector3( 420f, 28f, 90f ), new Vector3( -60f, -320f, 45f ), new Color( 0.62f, 0.58f, 0.54f ) ),
new( "models/dev/box.vmdl", new Vector3( 110f, 110f, 110f ), new Vector3( 300f, 140f, 55f ), new Color( 0.70f, 0.55f, 0.42f ) ),
new( "models/dev/box.vmdl", new Vector3( 70f, 70f, 170f ), new Vector3( 190f, -220f, 85f ), new Color( 0.55f, 0.60f, 0.68f ) ),
new( "models/dev/sphere.vmdl", new Vector3( 150f, 150f, 150f ), new Vector3( -280f, 180f, 75f ), new Color( 0.80f, 0.80f, 0.82f ) ),
};
DayNightClock _clock;
RainStreaks _rain;
CameraComponent _camera;
protected override void OnStart()
{
DemoActive = true;
_camera = Scene.GetAllComponents<CameraComponent>().FirstOrDefault();
_clock = EnsureClock();
BuildCluster();
BuildRain();
BuildUi();
Log.Info( $"[daynight] demo ready. Day length {DemoDayLengthMinutes:0.#} real minutes, seed {DemoWorldSeed}. "
+ "N opens the time panel, H hides the hint card." );
}
/// <summary>
/// The clock, configured for a demo rather than for a game.
///
/// The one non-default value is the day length. Everything else is <see cref="DayNightConfig.Default"/>
/// verbatim, on purpose: a demo that tunes the grade is showing you ITS look, not the kit's, and the
/// shipped default is the reference grade a consumer gets on install.
/// </summary>
DayNightClock EnsureClock()
{
var clock = DayNightClock.For( Scene ) ?? Components.GetOrCreate<DayNightClock>();
var cfg = DayNightConfig.Default;
cfg.DayLengthMinutes = MathF.Max( 0.25f, DemoDayLengthMinutes );
cfg.StartHours = Math.Clamp( DemoStartHour, 0f, 24f );
clock.Config = cfg;
clock.WorldSeed = DemoWorldSeed;
// The driver has to agree with the clock: same config on both, which is exactly what the README
// tells a consumer to do. Set it here rather than in the scene file so there is one source of truth.
foreach ( var driver in Scene.GetAllComponents<DayNightDriver>() )
driver.Config = cfg;
return clock;
}
// ---- the shapes that catch the light ----
void BuildCluster()
{
var root = Scene.CreateObject();
root.Name = "Demo Shapes";
for ( int i = 0; i < Cluster.Length; i++ )
BuildShape( root, $"Shape {i + 1}", Cluster[i] );
}
void BuildShape( GameObject parent, string name, Shape shape )
{
var go = Scene.CreateObject();
go.Name = name;
go.SetParent( parent, false );
go.LocalPosition = shape.Position;
go.LocalRotation = Rotation.Identity;
var renderer = go.Components.Create<ModelRenderer>();
var model = Model.Load( shape.ModelPath );
if ( model is null || model.IsError )
{
Log.Warning( $"[daynight] demo model '{shape.ModelPath}' did not load; '{name}' will be invisible." );
return;
}
renderer.Model = model;
var bounds = model.Bounds.Size;
go.LocalScale = new Vector3(
bounds.x > 0.001f ? shape.SizeUnits.x / bounds.x : 1f,
bounds.y > 0.001f ? shape.SizeUnits.y / bounds.y : 1f,
bounds.z > 0.001f ? shape.SizeUnits.z / bounds.z : 1f );
// The engine's models/dev primitives render as missing-material magenta unless a real material is
// forced on, which would swallow the grade this whole demo exists to show.
var mat = Material.Load( FallbackMaterial );
if ( mat is not null ) renderer.MaterialOverride = mat;
renderer.Tint = shape.Tint;
}
// ---- the optional rain module, so a Rain day is visible ----
void BuildRain()
{
var go = Scene.CreateObject();
go.Name = "Demo Rain";
_rain = go.Components.Create<RainStreaks>();
// The shower centres on the camera, which is the seam's whole point: the kit never reaches for your
// player or camera type, you hand it a position.
_rain.Center = () => _camera.IsValid() ? _camera.WorldPosition : Vector3.Up * 200f;
}
protected override void OnUpdate()
{
// Drive the optional rain module from the clock's weather, which is the two-line wiring the README
// describes. Cheap to call every frame.
if ( _rain.IsValid() && _clock.IsValid() )
_rain.SetRaining( _clock.CurrentWeather == WeatherKind.Rain );
}
// ---- screen UI ----
void BuildUi()
{
// One ScreenPanel per PanelComponent (the World Builder UI idiom). Built in code so the demo scene
// needs no razor wiring.
var hintHost = Scene.CreateObject();
hintHost.Name = "Day Night Hint";
hintHost.Components.Create<ScreenPanel>();
hintHost.Components.Create<DayNightHintCard>();
var panelHost = Scene.CreateObject();
panelHost.Name = "Day Night UI";
panelHost.Components.Create<ScreenPanel>();
// Open on arrival. Driving the cycle is what this scene is FOR, so making the visitor find the key
// first is a toll booth on the way to the point. N and the header x still close it. The panel reads
// this on its first update, after every OnStart in the frame, so setting it here always lands.
var panel = panelHost.Components.Create<DayNightPanel>();
panel.OpenOnStart = true;
}
}
Game
library
using System;
namespace FieldGuide.DayNight;
/// <summary>
/// All the tuning for the day/night cycle in one passed-in struct (library law: config over constants).
/// Every pure math call (<see cref="SkyGrade"/>, <see cref="SkyWeights"/>) and the clock accumulate take a
/// config by reference, so a library consumer never reads a project-global static. Grab
/// <see cref="Default"/> and tweak the fields you care about.
///
/// The values in <see cref="Default"/> are the exact reference grade from the source game: an afternoon
/// anchor at 15:00, a symmetric 12 h day (sunrise 6, sunset 18), a warm HDR sun and sky, and a deep-blue
/// night. The arc is DERIVED from <see cref="SunDirection"/> (its noon pitch/yaw come from LookAt→Angles),
/// so nudging <see cref="SunDirection"/> moves the whole arc without touching the pitch/yaw fields.
///
/// EXPOSURE IS NOT IN HERE ON PURPOSE. The whole diurnal look comes from sun rotation + light/sky/envmap
/// colours, never from tone-mapping. If your game locks camera exposure, night renders genuinely dark under
/// it; this kit never writes exposure, so it will not fight your camera. See the README.
/// </summary>
public struct DayNightConfig
{
// ── daylight window + pace ──
/// <summary>Game-hour daylight begins. Default 6, giving a symmetric 12 h day with
/// <see cref="SunsetHour"/>.</summary>
public float SunriseHour;
/// <summary>Game-hour daylight ends. Default 18.</summary>
public float SunsetHour;
/// <summary>Real-time MINUTES per in-game day at the NIGHT pace. Because <see cref="DayRateScale"/> slows
/// the daylight arc, this is the night-arc pace, not the whole-cycle length.</summary>
public float DayLengthMinutes;
/// <summary>Clock-rate multiplier applied through full daylight so the day lasts longer than the night;
/// night stays at rate 1 so its real-time length is preserved exactly. The default is solved so the
/// effective day:night real-time ratio is 3.0 (see <see cref="SkyGrade.ClockRateScale"/> and the ratio
/// self-test). Change the daylight window or twilight width and re-solve this against your target.</summary>
public float DayRateScale;
/// <summary>Smoothstep ramp width (game-hours) at each daylight edge, shared by the pace ramp and the
/// twilight colour blend so the pace shift is masked by the sky already transitioning.</summary>
public float TwilightHours;
// ── session start ──
/// <summary>Game-hour a fresh session's clock starts at.</summary>
public float StartHours;
/// <summary>Whether a fresh session starts paused (held at <see cref="StartHours"/> until something sets
/// the time). Default false = the clock runs.</summary>
public bool StartPaused;
// ── sun-arc shape ──
/// <summary>Near-horizon sun pitch at sunrise/sunset.</summary>
public float HorizonPitch;
/// <summary>Total east→west yaw the sun sweeps across the day.</summary>
public float YawSpan;
/// <summary>Fixed low-moon pitch for deep night.</summary>
public float NightPitch;
/// <summary>Reference sun direction. The arc's noon pitch/yaw are derived from this (LookAt→Angles), so
/// the arc always threads the current reference sun.</summary>
public Vector3 SunDirection;
// ── daytime reference colours (the anchor grade) ──
/// <summary>Reference sun key colour at the anchor daylight. The daytime lerp is anchored so the sun key
/// EQUALS this exactly at <see cref="AnchorHours"/>.</summary>
public Color SunColor;
/// <summary>Reference ambient (sky-fill) colour at the anchor daylight.</summary>
public Color SkyAmbient;
/// <summary>Reference SkyBox2D tint at the anchor daylight.</summary>
public Color SkyTint;
/// <summary>Reference EnvmapProbe tint at the anchor daylight.</summary>
public Color EnvmapTint;
/// <summary>The daylight hour the reference grade is authored at. At this hour (Clear weather) the computed
/// grade equals the reference values above exactly.</summary>
public float AnchorHours;
// ── diurnal key targets ──
/// <summary>Sun key the daytime lerp reaches toward noon.</summary>
public Color NoonKey;
/// <summary>Deep warm sun key at the horizon edge (sunrise/sunset).</summary>
public Color HorizonKey;
/// <summary>Deep-night sun key.</summary>
public Color NightKey;
/// <summary>Deep-night ambient (SkyColor) fill.</summary>
public Color NightAmbient;
/// <summary>Deep-night SkyBox2D tint.</summary>
public Color NightSkyTint;
/// <summary>Deep-night EnvmapProbe tint.</summary>
public Color NightEnvTint;
// ── weather dimming ──
/// <summary>Sun-key dim multiplier under Cloudy weather (1 = no dim).</summary>
public float WeatherDimCloudy;
/// <summary>Sun-key dim multiplier under Rain weather.</summary>
public float WeatherDimRain;
// ── four-slot sky anchors (for SkyWeights, the sky seam) ──
/// <summary>Hour-of-day anchors for the four sky slots the consumer crossfades (night / morning / noon /
/// evening). Evenly 6 h apart by default and aligned to sunrise/sunset so every segment is a clean
/// adjacent-pair crossfade and the midnight wrap is continuous.</summary>
public float SkyNightHour;
/// <summary>Hour-of-day the MORNING sky slot owns outright (weight 1). Default 6, at sunrise.</summary>
public float SkyMorningHour;
/// <summary>Hour-of-day the NOON sky slot owns outright (weight 1). Default 12.</summary>
public float SkyNoonHour;
/// <summary>Hour-of-day the EVENING sky slot owns outright (weight 1). Default 18, at sunset.</summary>
public float SkyEveningHour;
/// <summary>The reference grade lifted verbatim from the source game. Afternoon anchor, symmetric 12 h day,
/// 20 real-minute night pace, day 3x longer than night, warm HDR daylight, deep-blue night.</summary>
public static DayNightConfig Default => new()
{
SunriseHour = 6f,
SunsetHour = 18f,
DayLengthMinutes = 20f,
DayRateScale = 0.31494221f, // solved so day:night == 3.0; re-solve if the window/twilight change
TwilightHours = 0.75f,
StartHours = 7f,
StartPaused = false,
HorizonPitch = 3f,
YawSpan = 150f,
NightPitch = 34f,
SunDirection = new Vector3( 0.35f, 0.62f, -0.70f ),
SunColor = new Color( 1.72f, 1.50f, 1.14f ),
SkyAmbient = new Color( 0.92f, 0.84f, 0.68f ),
SkyTint = new Color( 1.30f, 1.24f, 1.12f ),
EnvmapTint = new Color( 1.02f, 0.90f, 0.70f ),
AnchorHours = 15f,
NoonKey = new Color( 1.95f, 1.85f, 1.60f ),
HorizonKey = new Color( 2.05f, 1.05f, 0.52f ),
NightKey = new Color( 0.10f, 0.14f, 0.24f ),
NightAmbient = new Color( 0.05f, 0.07f, 0.12f ),
NightSkyTint = new Color( 0.05f, 0.06f, 0.10f ),
NightEnvTint = new Color( 0.06f, 0.07f, 0.11f ),
WeatherDimCloudy = 0.62f,
WeatherDimRain = 0.40f,
SkyNightHour = 0f,
SkyMorningHour = 6f,
SkyNoonHour = 12f,
SkyEveningHour = 18f,
};
}
Game
library
using System;
using System.Collections.Generic;
using Sandbox;
namespace FieldGuide.DayNight;
/// <summary>
/// OPTIONAL cosmetic rain module (delete the Weather/ folder if you don't want it). A cheap box-streak shower
/// centred on a point you provide, so the deterministic Rain weather is visible without pulling in your
/// player or camera types. It uses only the engine dev box primitive and an xorshift jitter (no System.Random,
/// no gameplay), and it is entirely client-local.
///
/// Wire it in two lines: set <see cref="Center"/> to a delegate returning where the shower should sit (usually
/// the local player or camera position, in engine units), and each frame call <see cref="SetRaining"/> with
/// whether the current weather is <see cref="WeatherKind.Rain"/> (ask your <see cref="DayNightClock"/>). Or
/// just add it to a GameObject and set <see cref="Center"/>; a sibling script can call SetRaining.
/// </summary>
[Title( "Rain Streaks" )]
[Category( "Field Guide" )]
[Icon( "grain" )]
public sealed class RainStreaks : Component
{
/// <summary>Where the shower centres (engine units). Defaults to this component's own world position.</summary>
public Func<Vector3> Center { get; set; }
/// <summary>Number of streaks in the pool. Set before first enable.</summary>
public int StreakCount { get; set; } = 60;
GameObject _fxRoot;
readonly List<GameObject> _streaks = new();
uint _scatter = 0x2545F491; // xorshift state (no System.Random, determinism hygiene)
bool _raining;
/// <summary>Turn the shower on or off. Cheap to call every frame with your weather check.</summary>
public void SetRaining( bool raining ) => _raining = raining;
Vector3 ResolveCenter() => Center?.Invoke() ?? WorldPosition;
void EnsureRoot()
{
if ( _fxRoot.IsValid() ) return;
_fxRoot = Scene.CreateObject();
_fxRoot.Name = "fg_rain_fx";
_fxRoot.SetParent( GameObject, false );
var model = Model.Load( "models/dev/box.vmdl" );
for ( int i = 0; i < StreakCount; i++ )
{
var go = Scene.CreateObject();
go.Name = "rain_streak";
go.SetParent( _fxRoot, false );
go.Enabled = false;
var r = go.Components.Create<ModelRenderer>();
if ( model is not null ) r.Model = model;
r.Tint = new Color( 0.62f, 0.72f, 0.85f, 0.45f );
_streaks.Add( go );
}
}
protected override void OnUpdate()
{
if ( !_raining )
{
if ( _fxRoot.IsValid() )
foreach ( var s in _streaks )
if ( s.IsValid() ) s.Enabled = false;
return;
}
EnsureRoot();
var center = ResolveCenter();
const float fall = 900f, spread = 900f, top = 650f, bottom = 350f;
foreach ( var s in _streaks )
{
if ( !s.IsValid() ) continue;
if ( !s.Enabled ) { s.Enabled = true; Respawn( s, center, spread, top, bottom ); }
s.WorldPosition += Vector3.Down * fall * Time.Delta;
if ( s.WorldPosition.z <= center.z - 100f || s.WorldPosition.Distance( center ) > 1600f )
Respawn( s, center, spread, top, bottom );
}
}
void Respawn( GameObject s, Vector3 center, float spread, float top, float bottom )
{
s.WorldPosition = center + new Vector3(
(NextJitter() * 2f - 1f) * spread,
(NextJitter() * 2f - 1f) * spread,
bottom + NextJitter() * (top - bottom) );
s.WorldScale = new Vector3( 0.025f, 0.025f, 0.5f ); // thin vertical streak
}
/// <summary>Cheap per-streak scatter in [0,1), an xorshift on local state, NOT System.Random. Cosmetic
/// only; never feeds anything deterministic.</summary>
float NextJitter()
{
_scatter ^= _scatter << 13;
_scatter ^= _scatter >> 17;
_scatter ^= _scatter << 5;
return (_scatter & 0xFFFFFF) / (float)0x1000000;
}
protected override void OnDestroy()
{
if ( _fxRoot.IsValid() ) _fxRoot.Destroy();
_streaks.Clear();
}
}
Game
library
using System;
using System.Collections.Generic;
using Sandbox;
namespace FieldGuide.DayNight;
/// <summary>
/// A pure self-test battery for the kit's deterministic math, ported from the source game's pure-test suite.
/// None of it needs a running scene or networking, it exercises <see cref="SkyGrade"/>, <see cref="SkyWeights"/>,
/// <see cref="WeatherRoll"/>, and <see cref="TimeMath"/> against the default config and returns a pass/fail
/// report. It is the kit's compile-in-isolation smoke proof and doubles as executable documentation.
///
/// Run it from the s&box console with <c>fg_daynight_selftest</c>, or call <see cref="RunAll"/> from your own
/// harness. Every case is a pure function of the config, so a green run here is meaningful without the editor.
/// </summary>
public static class DayNightSelfTest
{
/// <summary>One test result.</summary>
public readonly record struct Case( string Name, bool Passed, string Detail );
/// <summary>Run every case against <see cref="DayNightConfig.Default"/>. Returns the per-case results; the
/// caller decides how to surface them.</summary>
public static List<Case> RunAll()
{
var cfg = DayNightConfig.Default;
return new List<Case>
{
WeatherDeterminism( cfg ),
AnchorExact( cfg ),
TwilightContinuity( cfg ),
RateNightAndMidday( cfg ),
RateContinuousAtTwilight( cfg ),
RatioIs3x( cfg ),
SkyWeightsPartition( cfg ),
TimeSetPreservesDay( cfg ),
};
}
// ── weather: deterministic and seed-sensitive ──
static Case WeatherDeterminism( DayNightConfig cfg )
{
const int seed = 71237;
bool stable = true;
for ( int day = 0; day < 16; day++ )
{
var w = WeatherRoll.For( seed, day );
if ( w != WeatherRoll.For( seed, day ) ) { stable = false; break; }
if ( !System.Enum.IsDefined( typeof( WeatherKind ), w ) ) { stable = false; break; }
}
bool seedSensitive = false;
for ( int day = 0; day < 32 && !seedSensitive; day++ )
if ( WeatherRoll.For( seed, day ) != WeatherRoll.For( seed + 1, day ) )
seedSensitive = true;
bool ok = stable && seedSensitive;
return new( "weather_deterministic", ok, $"stable={stable} seedSensitive={seedSensitive}" );
}
// ── grade: anchor-exact ──
static Case AnchorExact( DayNightConfig cfg )
{
SkyGrade.ComputeGrade( cfg.AnchorHours, WeatherKind.Clear, cfg,
out var rot, out var sun, out _, out _, out _ );
var refRot = Rotation.LookAt( cfg.SunDirection.Normal );
float dot = Math.Clamp( Vector3.Dot( rot.Forward.Normal, refRot.Forward.Normal ), -1f, 1f );
float ang = MathF.Acos( dot ) * (180f / MathF.PI);
bool rotOk = ang < 0.05f;
bool sunOk = MathF.Abs( sun.r - cfg.SunColor.r ) < 1e-3f
&& MathF.Abs( sun.g - cfg.SunColor.g ) < 1e-3f
&& MathF.Abs( sun.b - cfg.SunColor.b ) < 1e-3f;
bool ok = rotOk && sunOk;
return new( "grade_anchor_exact", ok, $"rotDeltaDeg={ang:0.000} sunKeyMatches={sunOk}" );
}
// ── grade: twilight continuity (no teleport across sunset) ──
static Case TwilightContinuity( DayNightConfig cfg )
{
float maxAngleDeg = 0f, maxColorStep = 0f;
Vector3 prevDir = Vector3.Zero;
Color prevSun = default;
bool first = true;
for ( float t = 17.5f; t <= 19.5f + 1e-4f; t += 0.1f )
{
SkyGrade.ComputeGrade( t, WeatherKind.Clear, cfg, out var rot, out var sun, out _, out _, out _ );
var dir = rot.Forward;
if ( !first )
{
float d = Math.Clamp( Vector3.Dot( dir.Normal, prevDir.Normal ), -1f, 1f );
float ang = MathF.Acos( d ) * (180f / MathF.PI);
if ( ang > maxAngleDeg ) maxAngleDeg = ang;
float cstep = MathF.Max( MathF.Abs( sun.r - prevSun.r ),
MathF.Max( MathF.Abs( sun.g - prevSun.g ), MathF.Abs( sun.b - prevSun.b ) ) );
if ( cstep > maxColorStep ) maxColorStep = cstep;
}
prevDir = dir; prevSun = sun; first = false;
}
bool ok = maxAngleDeg < 16f && maxColorStep < 0.45f;
return new( "grade_twilight_continuity", ok, $"maxStepDeg={maxAngleDeg:0.0} maxColorStep={maxColorStep:0.00} (thresholds 16, 0.45)" );
}
// ── rate: night == 1, midday == DayRateScale ──
static Case RateNightAndMidday( DayNightConfig cfg )
{
float n0 = SkyGrade.ClockRateScale( 0f, cfg );
float n3 = SkyGrade.ClockRateScale( 3f, cfg );
float n21 = SkyGrade.ClockRateScale( 21f, cfg );
float mid = SkyGrade.ClockRateScale( 12f, cfg );
bool night = MathF.Abs( n0 - 1f ) < 1e-4f && MathF.Abs( n3 - 1f ) < 1e-4f && MathF.Abs( n21 - 1f ) < 1e-4f;
bool midday = MathF.Abs( mid - cfg.DayRateScale ) < 1e-4f;
bool ok = night && midday;
return new( "rate_night_and_midday", ok, $"night(0/3/21)={n0:0.000}/{n3:0.000}/{n21:0.000} midday={mid:0.000} (want {cfg.DayRateScale:0.000})" );
}
// ── rate: continuous at the twilight boundary ──
static Case RateContinuousAtTwilight( DayNightConfig cfg )
{
const float eps = 0.01f;
float atSunrise = SkyGrade.ClockRateScale( cfg.SunriseHour, cfg );
float atSunset = SkyGrade.ClockRateScale( cfg.SunsetHour, cfg );
bool endsAtOne = MathF.Abs( atSunrise - 1f ) < 1e-3f && MathF.Abs( atSunset - 1f ) < 1e-3f;
float srStep = MathF.Abs( SkyGrade.ClockRateScale( cfg.SunriseHour + eps, cfg ) - SkyGrade.ClockRateScale( cfg.SunriseHour - eps, cfg ) );
float ssStep = MathF.Abs( SkyGrade.ClockRateScale( cfg.SunsetHour - eps, cfg ) - SkyGrade.ClockRateScale( cfg.SunsetHour + eps, cfg ) );
bool ok = endsAtOne && srStep < 5e-3f && ssStep < 5e-3f;
return new( "rate_continuous_at_twilight", ok, $"atSunrise={atSunrise:0.000} atSunset={atSunset:0.000} srStep={srStep:0.0000} ssStep={ssStep:0.0000}" );
}
// ── rate: effective day:night real-time ratio == 3.0 ──
static Case RatioIs3x( DayNightConfig cfg )
{
const int n = 120000;
float a = cfg.SunriseHour, b = cfg.SunsetHour;
float h = (b - a) / n;
double dayRealtime = 0.0;
for ( int i = 0; i < n; i++ )
{
float t = a + (i + 0.5f) * h;
dayRealtime += 1.0 / SkyGrade.ClockRateScale( t, cfg );
}
dayRealtime *= h;
float nightHours = 24f - (b - a);
double ratio = dayRealtime / nightHours;
bool ok = System.Math.Abs( ratio - 3.0 ) <= 0.03;
return new( "rate_ratio_is_3x", ok, $"day:night ratio={ratio:0.0000} (want 3.0 +/-1%)" );
}
// ── sky weights: partition of unity, adjacent-pair only ──
static Case SkyWeightsPartition( DayNightConfig cfg )
{
bool ok = true;
string detail = "sum==1, <=2 nonzero across 24h";
for ( float t = 0f; t < 24f; t += 0.05f )
{
var w = SkyWeights.WeightsFor( t, cfg );
float sum = w.x + w.y + w.z + w.w;
if ( MathF.Abs( sum - 1f ) > 1e-3f ) { ok = false; detail = $"sum={sum:0.000} at t={t:0.00}"; break; }
int nonzero = (w.x > 1e-4f ? 1 : 0) + (w.y > 1e-4f ? 1 : 0) + (w.z > 1e-4f ? 1 : 0) + (w.w > 1e-4f ? 1 : 0);
if ( nonzero > 2 ) { ok = false; detail = $"{nonzero} nonzero weights at t={t:0.00}"; break; }
}
return new( "sky_weights_partition", ok, detail );
}
// ── time-set preserves the day index ──
static Case TimeSetPreservesDay( DayNightConfig cfg )
{
bool preservesDay = TimeMath.ComputeSetHour( 39.5f, 6f ) == 30f; // day 1 15:30 → 06:00, still day 1
bool clampsHigh = TimeMath.ComputeSetHour( 39.5f, 99f ) == 48f;
bool clampsLow = TimeMath.ComputeSetHour( 39.5f, -5f ) == 24f;
bool sliderMid = MathF.Abs( TimeMath.ComputeSliderHour( 0.5f ) - 12f ) < 1e-3f;
// The slider must never hand back a value past the end of the day: one in-game minute is not exactly
// representable, so 1440 quantized steps land at 24.000002 unless the result is clamped.
bool sliderRange = TimeMath.ComputeSliderHour( 1f ) <= 24f && TimeMath.ComputeSliderHour( 0f ) >= 0f;
bool ok = preservesDay && clampsHigh && clampsLow && sliderMid && sliderRange;
return new( "time_set_preserves_day", ok, $"preservesDay={preservesDay} clampHi={clampsHigh} clampLo={clampsLow} sliderMid={sliderMid} sliderRange={sliderRange}" );
}
/// <summary>Console entry: run the battery and print a one-line-per-case report plus a summary.</summary>
[ConCmd( "fg_daynight_selftest" )]
public static void RunFromConsole()
{
int passed = 0, failed = 0;
foreach ( var c in RunAll() )
{
if ( c.Passed ) { passed++; Log.Info( $" ok {c.Name} {c.Detail}" ); }
else { failed++; Log.Warning( $" FAIL {c.Name} {c.Detail}" ); }
}
if ( failed == 0 ) Log.Info( $"fg_daynight_selftest: PASSED {passed}/{passed}" );
else Log.Warning( $"fg_daynight_selftest: FAILED {failed} of {passed + failed}" );
}
}
Game
library
using System;
using System.Linq;
using Sandbox;
namespace FieldGuide.DayNight;
/// <summary>
/// OPTIONAL convenience driver. Put it on the same GameObject as your scene's DirectionalLight and it applies
/// the day/night grade every frame from the <see cref="DayNightClock"/> in the scene: sun rotation + colour,
/// SkyBox2D tint, and EnvmapProbe tint. It resolves the sun from its own GameObject and the sky/envmap from
/// the scene (first of each). Delete this file if you would rather call <see cref="SkyGrade.ApplyGradeTo"/>
/// yourself.
///
/// It never touches exposure/shadows/fog, so it will not fight a locked-exposure camera.
///
/// The <see cref="ShowCycle"/> seam generalizes the source game's "am I possessing a character?" gate. While
/// it returns false the driver holds the stable ANCHOR grade (a fully-lit, non-moving model-viewer look) and
/// leaves the sky at your authoring backdrop; the moment it returns true the live cycle resumes at the true
/// game time (the clock keeps ticking underneath regardless). Default: always show the cycle.
/// </summary>
[Title( "Day Night Driver" )]
[Category( "Field Guide" )]
[Icon( "wb_sunny" )]
public sealed class DayNightDriver : Component
{
/// <summary>Tuning. Defaults to the reference grade; set it to match your clock's config.</summary>
public DayNightConfig Config { get; set; } = DayNightConfig.Default;
/// <summary>Return false to hold the stable anchor grade instead of the live cycle (e.g. while the local
/// player is in a menu / god-camera authoring mode). Null-safe: null means always show the cycle.</summary>
public Func<bool> ShowCycle { get; set; }
/// <summary>Optional sky tint to force while <see cref="ShowCycle"/> is false (your authoring backdrop). If
/// null the anchor sky tint is used.</summary>
public Color? AuthoringSkyTint { get; set; }
DirectionalLight _sun;
SkyBox2D _sky;
EnvmapProbe _env;
DayNightClock _clock;
DayNightClock Clock => _clock ??= DayNightClock.For( Scene );
protected override void OnEnabled()
{
_sun = GetComponent<DirectionalLight>();
_sky = Scene.GetAllComponents<SkyBox2D>().FirstOrDefault();
_env = Scene.GetAllComponents<EnvmapProbe>().FirstOrDefault();
}
protected override void OnUpdate()
{
if ( Scene.IsEditor ) return; // editor renders whatever you authored; the cycle is play-mode
var clock = Clock;
if ( clock is null || !_sun.IsValid() ) return;
if ( ShowCycle is not null && !ShowCycle() )
{
// Stable anchor look (fully lit, not moving). Pass null for the sky so the anchor warm sky tint is not
// applied, then force the authoring backdrop tint.
SkyGrade.ApplyGradeTo( _sun, null, _env, Config.AnchorHours, WeatherKind.Clear, Config );
if ( _sky.IsValid() ) _sky.Tint = AuthoringSkyTint ?? Config.SkyTint;
return;
}
float total = clock.GetTimeHours();
var weather = clock.EffectiveWeather( (int)MathF.Floor( total / 24f ) );
SkyGrade.ApplyGradeTo( _sun, _sky, _env, total, weather, Config );
}
}
Game
library
global using static Sandbox.Internal.GlobalGameNamespace;
global using Microsoft.AspNetCore.Components;
global using Microsoft.AspNetCore.Components.Rendering;
[assembly: global::System.Reflection.AssemblyMetadata( "AddonTitle", "Day Night Kit" )]
[assembly: global::System.Reflection.AssemblyMetadata( "AddonIdent", "daynight" )]
[assembly: global::System.Reflection.AssemblyMetadata( "OrgIdent", "fieldguide" )]
[assembly: global::System.Reflection.AssemblyMetadata( "Ident", "fieldguide.daynight" )]
[assembly: global::System.Reflection.AssemblyMetadata( "EngineVersion", "28" )]
[assembly: global::System.Reflection.AssemblyMetadata( "EngineMinorVersion", "1" )]
[assembly: System.Runtime.Versioning.TargetFramework( ".NETCoreApp,Version=v9.0", FrameworkDisplayName = ".NET 9.0" )]
[assembly: global::System.Reflection.AssemblyMetadata( "CompileTime", "2026-07-31T23:59:06.5652519Z" )]
[assembly: global::System.Reflection.AssemblyVersion("0.0.122.0")]
[assembly: global::System.Reflection.AssemblyFileVersion("0.0.122.0")]
Game
library
using System;
using Sandbox;
namespace FieldGuide.DayNight;
/// <summary>
/// THE SKY SEAM. This kit does not ship a sky shader or sky art (see the README, "Why no sky shader").
/// Instead it publishes, for any total game-hour, four normalized blend weights (morning, noon, evening,
/// night) summing to 1 with only the adjacent anchor pair non-zero. Your game decides what to DO with them:
/// crossfade four equirect skybox textures in your own shader, lerp four flat sky colours, swap SkyBox2D
/// materials, or ignore them entirely and just read <see cref="DayNightClock.GetTimeHours"/>.
///
/// The weights are a STATELESS pure function of the hour: no easing state, no temporal smoothing. So an
/// explicit time jump (a menu preset, a pin) lands the exact target weights the same frame (an instant snap),
/// while natural clock advance moves the hour smoothly and therefore crossfades smoothly. Both behaviours fall
/// out of purity, do not add smoothing on top.
///
/// Feed it the SAME hour the lighting grade uses (<see cref="DayNightClock.GetTimeHours"/>) and the sky can
/// never disagree with the sun.
/// </summary>
public static class SkyWeights
{
/// <summary>Pure: map a TOTAL game-hour to the four slot weights, using the four sky anchors in the config.
/// Component order is (x = morning, y = noon, z = evening, w = night) so it drops straight into a shader
/// float4 or your own four-way lerp. Continuous across every anchor including the midnight wrap, so the
/// crossfade never pops.</summary>
public static Vector4 WeightsFor( float totalHours, in DayNightConfig cfg )
{
float t = totalHours - MathF.Floor( totalHours / 24f ) * 24f; // hour-of-day 0..24
float night = cfg.SkyNightHour, morning = cfg.SkyMorningHour;
float noon = cfg.SkyNoonHour, evening = cfg.SkyEveningHour;
float m = 0f, n = 0f, e = 0f, ni = 0f;
if ( t < morning ) // night -> morning
{
float s = SkyGrade.Smoothstep( (t - night) / (morning - night) );
ni = 1f - s; m = s;
}
else if ( t < noon ) // morning -> noon
{
float s = SkyGrade.Smoothstep( (t - morning) / (noon - morning) );
m = 1f - s; n = s;
}
else if ( t < evening ) // noon -> evening
{
float s = SkyGrade.Smoothstep( (t - noon) / (evening - noon) );
n = 1f - s; e = s;
}
else // evening -> night (wraps to next midnight)
{
float s = SkyGrade.Smoothstep( (t - evening) / (24f - evening) );
e = 1f - s; ni = s;
}
return new Vector4( m, n, e, ni );
}
/// <summary>Convenience: <see cref="WeightsFor(float, in DayNightConfig)"/> with the default anchors
/// (night 0, morning 6, noon 12, evening 18).</summary>
public static Vector4 WeightsFor( float totalHours )
{
var cfg = DayNightConfig.Default;
return WeightsFor( totalHours, cfg );
}
}
Game
library
using System;
namespace FieldGuide.DayNight;
/// <summary>Pure helpers for setting the clock from a UI. Kept separate from the networked component so they
/// are trivially unit-testable and reusable (a preview tool, an editor slider, a debug console).</summary>
public static class TimeMath
{
/// <summary>Set the clock to an hour-of-day while PRESERVING the current day index, so the deterministic
/// per-day weather roll does not re-roll when a player scrubs the time within a day. Result =
/// floor(current/24)*24 + clamp(hourOfDay, 0, 24). Example: day 1 at 15:30 (total 39.5), set 06:00 → 30.0
/// (still day 1).</summary>
public static float ComputeSetHour( float currentTotalHours, float hourOfDay )
=> MathF.Floor( currentTotalHours / 24f ) * 24f + Math.Clamp( hourOfDay, 0f, 24f );
/// <summary>Map a slider/drag fraction across a track (0 left, 1 right) to a quantized hour-of-day in
/// [0,24], rounded to the nearest in-game MINUTE (1/60 h). A pixel-cheap throttle with no timer state: a
/// drag emits at most one distinct value per minute of readout. Feed the result into
/// <see cref="ComputeSetHour"/> to keep the day index.
///
/// The result is clamped AFTER quantizing, and that clamp is load-bearing: one minute is not exactly
/// representable in binary, so 1440 steps of 1/60 accumulate to 24.000002 and a full-right drag would
/// hand <see cref="ComputeSetHour"/> a value past the end of the day. Round first, clamp second.</summary>
public static float ComputeSliderHour( float frac )
{
float hour = Math.Clamp( frac, 0f, 1f ) * 24f;
const float step = 1f / 60f; // one in-game minute
return Math.Clamp( MathF.Round( hour / step ) * step, 0f, 24f );
}
}
Game
library
using System;
using System.Linq;
using Sandbox;
namespace FieldGuide.DayNight;
/// <summary>
/// OPTIONAL convenience driver. Put it on the same GameObject as your scene's DirectionalLight and it applies
/// the day/night grade every frame from the <see cref="DayNightClock"/> in the scene: sun rotation + colour,
/// SkyBox2D tint, and EnvmapProbe tint. It resolves the sun from its own GameObject and the sky/envmap from
/// the scene (first of each). Delete this file if you would rather call <see cref="SkyGrade.ApplyGradeTo"/>
/// yourself.
///
/// It never touches exposure/shadows/fog, so it will not fight a locked-exposure camera.
///
/// The <see cref="ShowCycle"/> seam generalizes the source game's "am I possessing a character?" gate. While
/// it returns false the driver holds the stable ANCHOR grade (a fully-lit, non-moving model-viewer look) and
/// leaves the sky at your authoring backdrop; the moment it returns true the live cycle resumes at the true
/// game time (the clock keeps ticking underneath regardless). Default: always show the cycle.
/// </summary>
[Title( "Day Night Driver" )]
[Category( "Field Guide" )]
[Icon( "wb_sunny" )]
public sealed class DayNightDriver : Component
{
/// <summary>Tuning. Defaults to the reference grade; set it to match your clock's config.</summary>
public DayNightConfig Config { get; set; } = DayNightConfig.Default;
/// <summary>Return false to hold the stable anchor grade instead of the live cycle (e.g. while the local
/// player is in a menu / god-camera authoring mode). Null-safe: null means always show the cycle.</summary>
public Func<bool> ShowCycle { get; set; }
/// <summary>Optional sky tint to force while <see cref="ShowCycle"/> is false (your authoring backdrop). If
/// null the anchor sky tint is used.</summary>
public Color? AuthoringSkyTint { get; set; }
DirectionalLight _sun;
SkyBox2D _sky;
EnvmapProbe _env;
DayNightClock _clock;
DayNightClock Clock => _clock ??= DayNightClock.For( Scene );
protected override void OnEnabled()
{
_sun = GetComponent<DirectionalLight>();
_sky = Scene.GetAllComponents<SkyBox2D>().FirstOrDefault();
_env = Scene.GetAllComponents<EnvmapProbe>().FirstOrDefault();
}
protected override void OnUpdate()
{
if ( Scene.IsEditor ) return; // editor renders whatever you authored; the cycle is play-mode
var clock = Clock;
if ( clock is null || !_sun.IsValid() ) return;
if ( ShowCycle is not null && !ShowCycle() )
{
// Stable anchor look (fully lit, not moving). Pass null for the sky so the anchor warm sky tint is not
// applied, then force the authoring backdrop tint.
SkyGrade.ApplyGradeTo( _sun, null, _env, Config.AnchorHours, WeatherKind.Clear, Config );
if ( _sky.IsValid() ) _sky.Tint = AuthoringSkyTint ?? Config.SkyTint;
return;
}
float total = clock.GetTimeHours();
var weather = clock.EffectiveWeather( (int)MathF.Floor( total / 24f ) );
SkyGrade.ApplyGradeTo( _sun, _sky, _env, total, weather, Config );
}
}
Game
library
@using Sandbox
@using Sandbox.UI
@using System
@using System.Collections.Generic
@namespace FieldGuide.DayNight
@inherits PanelComponent
@attribute [StyleSheet]
@*
The demo's on-screen key card, up from the first frame. A scene with no visible instructions reads as
a broken scene: you press nothing, nothing happens, you close it. So this says what the demo is and
which keys do what, before you have touched anything.
It also carries the one thing the demo could not otherwise show. The kit ships no sky shader and no
sky art on purpose; the sky is a SEAM, four normalized crossfade weights per hour. Those weights have
no picture, so the card prints them live and they visibly hand off from one slot to the next as the
clock runs. That is the seam doing its job, on screen, with no art involved.
Rows render in MAIN markup via @foreach per the fragment-undermeasure gotcha. H hides the card (a
letter, never an F key, which the editor eats in play). No ESC anywhere: house law.
Look and layout follow the Field Kit UI system: docs/design/ui-system/daynight-kit.dc.html for this
screen, tokens.dc.html for the values. Font sizes come from the {12, 13, 14, 16} panel scale and there
is no letter-spacing; the stylesheet head lists the rest of the engine-legality rules.
Not part of the kit's runtime surface: delete Code/Demo when you drop the kit into your own project.
*@
<root>
@* DemoActive is the inert-by-construction gate (library law 11): only DayNightDemoBootstrap sets it, so
this card cannot appear in a consumer's game even if Code/Demo was left in the project. *@
@if ( CardOpen && DayNightDemoBootstrap.DemoActive )
{
<div class="dh-card">
<div class="dh-hdr">
<span class="dh-title">DAY / NIGHT KIT DEMO</span>
<div class="dh-x" onclick=@(() => CardOpen = false)>×</div>
</div>
<div class="dh-lede">One directional light, one skybox, one clock. Watch the sun sweep and the colour grade follow it, or open the time panel and drive the cycle yourself.</div>
<div class="dh-rows">
@foreach ( var r in Keys )
{
string key = r.key; // plain locals before interpolating: an inline tuple read can render blank
string what = r.what;
<div class="dh-row">
<span class="dh-key">@key</span>
<span class="dh-what">@what</span>
</div>
}
</div>
<div class="dh-live">
@foreach ( var w in Weights )
{
string run = w;
<span class="dh-lk">@run</span>
}
</div>
<div class="dh-foot">Those four are the sky seam. The kit ships no sky shader and no sky art; you crossfade your own sky from these weights.</div>
</div>
}
</root>
@code
{
static bool _open = true;
/// <summary>Console fallback: `daynight_hint 1` / `daynight_hint 0` shows or hides the card (H also
/// toggles). Starts SHOWN, unlike the time panel, because it is the thing that tells you the time panel
/// exists.</summary>
[ConVar( "daynight_hint", Help = "Show or hide the demo scene's key card (same as the H key)" )]
public static bool CardOpen { get => _open; set => _open = value; }
static readonly List<(string key, string what)> Keys = new()
{
( "N", "Open the time panel: scrub the clock, change the pace, pin the weather" ),
( "H", "Hide this card" ),
};
DayNightClock _clock;
DayNightClock Clock
{
get
{
if ( _clock.IsValid() ) return _clock;
_clock = DayNightClock.For( Scene );
return _clock;
}
}
/// <summary>The live sky weights as four short atomic runs. Split into separate spans rather than one
/// sentence so a wrap breaks BETWEEN runs; a single long run wraps mid-word, which is a live bug class
/// in this engine's text layout.</summary>
List<string> Weights
{
get
{
var c = Clock;
var cfg = c?.Config ?? DayNightConfig.Default;
var w = SkyWeights.WeightsFor( c?.GetTimeHours() ?? 0f, cfg );
return new List<string>
{
$"MORNING {w.x:0.00}",
$"NOON {w.y:0.00}",
$"EVENING {w.z:0.00}",
$"NIGHT {w.w:0.00}",
};
}
}
protected override void OnUpdate()
{
if ( Input.Keyboard.Pressed( "H" ) )
CardOpen = !CardOpen;
}
// Fold the card state and every printed weight (to the two decimals shown), or the strip freezes at
// whatever it read on the first frame while the sun keeps moving.
protected override int BuildHash()
{
var c = Clock;
var cfg = c?.Config ?? DayNightConfig.Default;
var w = SkyWeights.WeightsFor( c?.GetTimeHours() ?? 0f, cfg );
return HashCode.Combine( CardOpen, DayNightDemoBootstrap.DemoActive,
(int)MathF.Round( w.x * 100f ),
(int)MathF.Round( w.y * 100f ),
(int)MathF.Round( w.z * 100f ),
(int)MathF.Round( w.w * 100f ) );
}
}
Game
library
@using Sandbox
@using Sandbox.UI
@using System
@using System.Collections.Generic
@using System.Linq
@namespace FieldGuide.DayNight
@inherits PanelComponent
@attribute [StyleSheet]
@*
The kit's dev tuning surface for the day/night cycle: scrub the clock, change the pace, jump to an
hour, pin the weather, hold and resume time, and copy the resulting config as a paste-ready C# block.
Every write goes through DayNightClock's authority-guarded setters, so this panel is safe to leave in
a networked session: on a client the setters are quiet no-ops and the card says so instead of
pretending the drag did something.
Optional. Delete Code/Ui if you would rather drive the clock from your own UI; nothing else in the kit
references this file.
Rows render in MAIN markup (no RenderFragment) per the fragment-undermeasure gotcha, and each slider
is a shape pair (track + fill), which keeps the text-run count low. Toggle with N (a raw letter key,
never an F key: the editor eats those in play), the 42px x in the header, or the `daynight_panel`
console convar. Starts closed unless OpenOnStart is set; see the boot block in OnUpdate.
Look and layout follow the Field Kit UI system: docs/design/ui-system/daynight-kit.dc.html for this
screen, tokens.dc.html for the values. The stylesheet carries this kit's own copy of those tokens
(kits cannot import each other) and lists the engine-legality translations at its head, including the
inline-unquoted font-family rule that a $variable silently breaks.
One deliberate departure from the mockup: the weather group carries a fourth segment, "auto". The
mockup shows three pinned kinds, but the clock's deterministic roll is the DEFAULT state and a panel
with no way back to it can only pin, never release. Auto writes the -1 override.
*@
<root>
@if ( PanelOpen )
{
<div class="dn-card">
<div class="dn-hdr">
<span class="dn-title">DAY / NIGHT · dev</span>
<div class="dn-hr">
<span class="dn-key">N</span>
<div class="dn-x" onclick=@ClosePanel>×</div>
</div>
</div>
@if ( Clock is null )
{
<div class="dn-empty">No DayNightClock in this scene. Add one to your session GameObject and this panel drives it.</div>
}
else
{
@* ---- hero readout: the whole point of the kit, in one line ---- *@
<div class="dn-hero">
<span class="dn-hl">Clock</span>
<span class="dn-hv">@ClockText</span>
</div>
<div class="dn-meta">
<span class="dn-mk">@DayText</span>
<span class="dn-mk">@WeatherText</span>
<span class="dn-mk">@PaceText</span>
</div>
@if ( !IsAuthority )
{
<div class="dn-note">The host owns the clock. This card reads the replicated time; the controls below do nothing here.</div>
}
@* ---- the two dials ---- *@
@foreach ( var d in Dials )
{
var dial = d;
string lab = dial.label; // plain locals before interpolating: an inline field read can render blank
string val = ValueText( dial.kind );
int fillPct = (int)( Frac( dial ) * 100f );
<div class="dn-row">
<div class="dn-rlab">
<span class="dn-rl">@lab</span>
<span class="dn-rv">@val</span>
</div>
<div class="dn-slider">
<span class="dn-stp" onclick=@(() => Nudge( dial, -dial.step ))>−</span>
<div class="dn-hit"
onmousedown=@(e => TrackPointer( e, dial, true ))
onmousemove=@(e => TrackPointer( e, dial, false ))>
<div class="dn-track"
onmousedown=@(e => TrackPointer( e, dial, true ))
onmousemove=@(e => TrackPointer( e, dial, false ))>
<div class="dn-fill" style="width: @(fillPct)%;"></div>
</div>
</div>
<span class="dn-stp" onclick=@(() => Nudge( dial, dial.step ))>+</span>
</div>
</div>
}
@* ---- jump to a named hour ---- *@
<div class="dn-row">
<span class="dn-rl">Jump to</span>
<div class="dn-chips">
@foreach ( var j in Jumps )
{
var jump = j;
string jl = jump.label;
<div class="dn-chip @(IsAtHour( jump.hour ) ? "on" : "")" onclick=@(() => JumpTo( jump.hour ))>@jl</div>
}
</div>
</div>
@* ---- weather: three pins plus a way back to the deterministic roll ---- *@
<div class="dn-row">
<span class="dn-rl">Weather</span>
<div class="dn-seg-group wide">
<div class="dn-seg grow @(WeatherPin == -1 ? "on" : "")" onclick=@(() => PinWeather( -1 ))>auto</div>
<div class="dn-seg grow @(WeatherPin == 0 ? "on" : "")" onclick=@(() => PinWeather( 0 ))>clear</div>
<div class="dn-seg grow @(WeatherPin == 1 ? "on" : "")" onclick=@(() => PinWeather( 1 ))>cloudy</div>
<div class="dn-seg grow @(WeatherPin == 2 ? "on" : "")" onclick=@(() => PinWeather( 2 ))>rain</div>
</div>
</div>
@* ---- hold or resume ---- *@
<div class="dn-inline">
<span class="dn-rl">Clock running</span>
<div class="dn-seg-group">
<div class="dn-seg tight @(Paused ? "" : "on")" onclick=@(() => SetPaused( false ))>run</div>
<div class="dn-seg tight @(Paused ? "on" : "")" onclick=@(() => SetPaused( true ))>pause</div>
</div>
</div>
@* ---- actions ---- *@
<div class="dn-btns">
<div class="dn-btn" onclick=@ResetAll>Reset</div>
<div class="dn-btn primary" onclick=@CopyConfig>@_copyLabel</div>
</div>
}
</div>
}
</root>
@code
{
// ---- toggle state (N raw key + `daynight_panel` convar fallback) ----
static bool _open;
/// <summary>Console fallback: `daynight_panel 1` / `daynight_panel 0` opens or closes the time panel
/// (N also toggles).</summary>
[ConVar( "daynight_panel", Help = "Open or close the day/night time panel (same as the N key)" )]
public static bool PanelOpen { get => _open; set => _open = value; }
/// <summary>
/// Whether this panel starts open. Off by default: a dev tuning surface that appears unbidden over a
/// consumer's game is a bug, not a feature. Turn it on for a scene whose whole point is the panel, the
/// way the kit's own demo does.
///
/// This is what decides the panel's boot state, and it is the ONLY thing that decides it. See the boot
/// block in OnUpdate for why that matters.
/// </summary>
[Property] public bool OpenOnStart { get; set; }
/// <summary>Shortest in-game day the pace slider allows, in real minutes at the night pace.</summary>
[Property] public float MinDayLengthMinutes { get; set; } = 1f;
/// <summary>Longest in-game day the pace slider allows, in real minutes at the night pace.</summary>
[Property] public float MaxDayLengthMinutes { get; set; } = 60f;
string _copyLabel = "Copy config";
bool _wasOpen;
bool _booted;
DayNightClock _clock;
/// <summary>The scene's clock, re-resolved while it is missing so a panel built before the clock still
/// finds it. Null until one exists, which the markup handles with an explicit empty state.</summary>
DayNightClock Clock
{
get
{
if ( _clock.IsValid() ) return _clock;
_clock = DayNightClock.For( Scene );
return _clock;
}
}
/// <summary>Single-player, or the host of a live session. Only here do the clock's setters do anything,
/// so the card states the case rather than letting a drag fail silently.</summary>
static bool IsAuthority => !Networking.IsActive || Networking.IsHost;
// ---- readouts ----
float TotalHours => Clock?.GetTimeHours() ?? 0f;
float HourOfDay => TotalHours - MathF.Floor( TotalHours / 24f ) * 24f;
/// <summary>The hero readout, HH:MM on a 24-hour clock. Minutes floor rather than round so the display
/// never shows :60 at the top of an hour.</summary>
string ClockText
{
get
{
float h = HourOfDay;
int hh = (int)MathF.Floor( h );
int mm = (int)MathF.Floor( (h - hh) * 60f );
if ( mm >= 60 ) { mm = 0; hh = (hh + 1) % 24; }
return $"{hh:00}:{mm:00}";
}
}
string DayText => $"DAY {Clock?.CurrentDay ?? 0}";
/// <summary>Names the weather AND where it came from, because "rain" alone does not tell you whether the
/// deterministic roll produced it or somebody pinned it.</summary>
string WeatherText
{
get
{
var c = Clock;
if ( c is null ) return "WEATHER ?";
string kind = c.CurrentWeather.ToString().ToUpperInvariant();
return WeatherPin < 0 ? $"{kind} · ROLLED" : $"{kind} · PINNED";
}
}
/// <summary>The pace the clock is running at right now, as the rate multiplier the daylight ramp applies.
/// Reads 1.00x through the night and DayRateScale at midday.</summary>
string PaceText
{
get
{
var c = Clock;
if ( c is null ) return "PACE ?";
var cfg = c.Config;
return $"PACE {SkyGrade.ClockRateScale( HourOfDay, cfg ):0.00}x";
}
}
int WeatherPin => Clock?.NetWeatherOverride ?? -1;
bool Paused => Clock?.TimePaused ?? false;
// ---- the two dials ----
enum Dial { TimeOfDay, DayLength }
struct DialRow { public Dial kind; public string label; public float step; }
/// <summary>Built per read rather than held in a static, so the pace row always reflects the current
/// MinDayLengthMinutes / MaxDayLengthMinutes properties.</summary>
static List<DialRow> Dials => new()
{
new DialRow { kind = Dial.TimeOfDay, label = "Time of day", step = 0.25f },
new DialRow { kind = Dial.DayLength, label = "Day length", step = 1f },
};
/// <summary>Row value text. Time of day reads as the 0..1 fraction the slider is at (the readable clock
/// is the hero line above it); day length reads in real minutes.</summary>
string ValueText( Dial kind )
{
var c = Clock;
if ( c is null ) return "-";
return kind switch
{
Dial.TimeOfDay => (HourOfDay / 24f).ToString( "0.00" ),
Dial.DayLength => $"{c.Config.DayLengthMinutes:0} min",
_ => "-",
};
}
float Get( Dial kind )
{
var c = Clock;
if ( c is null ) return 0f;
return kind switch
{
Dial.TimeOfDay => HourOfDay,
Dial.DayLength => c.Config.DayLengthMinutes,
_ => 0f,
};
}
float Min( Dial kind ) => kind == Dial.TimeOfDay ? 0f : MathF.Max( 0.1f, MinDayLengthMinutes );
float Max( Dial kind ) => kind == Dial.TimeOfDay ? 24f : MathF.Max( Min( kind ) + 0.1f, MaxDayLengthMinutes );
float Frac( DialRow row )
{
float min = Min( row.kind ), max = Max( row.kind );
return Math.Clamp( (Get( row.kind ) - min) / MathF.Max( max - min, 0.0001f ), 0f, 1f );
}
void Set( Dial kind, float value )
{
var c = Clock;
if ( c is null ) return;
switch ( kind )
{
case Dial.TimeOfDay:
// Day-preserving, so scrubbing inside a day never re-rolls that day's weather. The top of the
// range is 23:59, not 24:00: hour 24 IS the next day's midnight, so a full-right drag would
// tip the day index over, re-roll the weather and snap the slider back to the far left. This
// panel scrubs within a day; the clock is what advances days.
c.SetTimeOfDay( Math.Clamp( value, 0f, 24f - (1f / 60f) ) );
break;
case Dial.DayLength:
WriteDayLength( Math.Clamp( value, Min( kind ), Max( kind ) ) );
break;
}
}
void Nudge( DialRow row, float delta ) => Set( row.kind, Get( row.kind ) + delta );
/// <summary>
/// Draggable track: onmousedown JUMPS to the click, onmousemove SCRUBS while the panel is Active.
///
/// Both the 28px transparent grab wrapper and the 14px visible track carry this handler, and a press on
/// the track bubbles to the wrapper as well, so a single click can run it twice. That is harmless
/// BECAUSE the write is absolute (set to the value under the cursor), not relative: two runs of the same
/// press land on the same value. Keep it absolute if you touch this.
/// </summary>
void TrackPointer( PanelEvent ev, DialRow row, bool jump )
{
if ( ev is not MousePanelEvent e ) return;
var track = e.This;
if ( track is null ) return;
if ( !jump && !track.PseudoClass.HasFlag( PseudoClass.Active ) ) return;
float w = track.Box.Rect.Width;
if ( w <= 0f ) return;
float frac = Math.Clamp( e.LocalPosition.x / w, 0f, 1f );
if ( row.kind == Dial.TimeOfDay )
{
// Quantized to one in-game minute by the kit's own pure helper, so a drag emits at most one
// distinct value per minute of readout instead of one per pixel.
Set( Dial.TimeOfDay, TimeMath.ComputeSliderHour( frac ) );
return;
}
float min = Min( row.kind ), max = Max( row.kind );
float target = min + frac * (max - min);
if ( row.step > 0f ) target = MathF.Round( target / row.step ) * row.step;
Set( row.kind, target );
}
// ---- jump chips ----
struct JumpRow { public string label; public float hour; }
/// <summary>The four named hours, read off the clock's own config so a game with a different daylight
/// window still gets its real dawn and dusk rather than 6 and 18.</summary>
List<JumpRow> Jumps
{
get
{
var cfg = Clock?.Config ?? DayNightConfig.Default;
return new List<JumpRow>
{
new JumpRow { label = "dawn", hour = cfg.SunriseHour },
new JumpRow { label = "noon", hour = (cfg.SunriseHour + cfg.SunsetHour) * 0.5f },
new JumpRow { label = "dusk", hour = cfg.SunsetHour },
new JumpRow { label = "midnight", hour = 0f },
};
}
}
/// <summary>Is the clock within a minute of this named hour? A jump lands exactly, so a one-minute window
/// is enough to light the chip and narrow enough that it goes out as soon as time moves on.</summary>
bool IsAtHour( float hour ) => MathF.Abs( HourOfDay - hour ) < (1f / 60f);
void JumpTo( float hour ) => Set( Dial.TimeOfDay, hour );
// ---- weather, pause, config writes ----
void PinWeather( int kind ) => Clock?.SetWeatherOverride( kind );
void SetPaused( bool paused ) => Clock?.SetPaused( paused );
/// <summary>
/// Write a new day length onto the clock AND every driver in the scene.
///
/// DayNightConfig is a STRUCT, so `clock.Config.DayLengthMinutes = x` would mutate a temporary copy and
/// change nothing. Read, edit, write back. The drivers get the same value because a consumer is told to
/// keep clock and driver config identical, and a tuning panel that quietly desynchronised them would be
/// the exact bug the docs warn about.
///
/// AUTHORITY-GUARDED, unlike the clock's own setters which guard themselves. Config is authoring data and
/// is NOT replicated, so a client that changed its own day length would extrapolate at a different pace
/// than the host and drift between every snapshot. The guard has to live here.
/// </summary>
void WriteDayLength( float minutes )
{
if ( !IsAuthority ) return;
var c = Clock;
if ( c is null ) return;
var cfg = c.Config;
cfg.DayLengthMinutes = minutes;
c.Config = cfg;
foreach ( var driver in Scene.GetAllComponents<DayNightDriver>() )
{
var dcfg = driver.Config;
dcfg.DayLengthMinutes = minutes;
driver.Config = dcfg;
}
}
/// <summary>Back to the shipped reference grade: default config on the clock and every driver, weather
/// released to the deterministic roll, clock running, time at the config's start hour.</summary>
void ResetAll()
{
if ( !IsAuthority ) return; // same reason as WriteDayLength: config is authoring data, not session state
var c = Clock;
if ( c is null ) return;
var def = DayNightConfig.Default;
c.Config = def;
foreach ( var driver in Scene.GetAllComponents<DayNightDriver>() )
driver.Config = def;
c.SetWeatherOverride( -1 );
c.SetPaused( false );
c.SetTimeOfDay( def.StartHours );
_copyLabel = "Copy config";
}
/// <summary>Put the tuned config on the system clipboard as a paste-ready C# block. Game-side
/// Sandbox.UI.Clipboard.SetText, so it works in play without an editor round trip. Only the fields this
/// panel can move are emitted; everything else stays whatever Default gives you.</summary>
void CopyConfig()
{
var c = Clock;
if ( c is null ) return;
var cfg = c.Config;
string text =
"var cfg = DayNightConfig.Default;\n"
+ $"cfg.DayLengthMinutes = {cfg.DayLengthMinutes.ToString( "0.###" )}f;\n"
+ $"cfg.StartHours = {HourOfDay.ToString( "0.###" )}f;\n"
+ $"cfg.StartPaused = {(Paused ? "true" : "false")};\n"
+ "clock.Config = cfg;";
Sandbox.UI.Clipboard.SetText( text );
_copyLabel = "Copied!";
}
// ---- boot state, N toggle, cursor while open ----
protected override void OnUpdate()
{
// BOOT. `daynight_panel` is a convar and s&box PERSISTS convars across sessions, so a session can
// otherwise come up with the panel logically open from whatever someone left set weeks ago. The rule
// that prevents it: this component's own OpenOnStart decides the boot state, and the persisted value
// never does. Default off means a consumer game still cannot be pre-opened by a stale convar; a scene
// that wants the panel up says so explicitly.
//
// Deliberately in the FIRST UPDATE rather than OnStart. A panel built in code is configured by the
// component that created it, and doing this in OnStart would race that assignment: whichever ran
// first would win. The first update is after every OnStart in the frame, so the setting is always
// read, never half-applied.
if ( !_booted )
{
_booted = true;
if ( PanelOpen && !OpenOnStart )
Log.Info( "[daynight] time panel was OPEN at session start (persisted convar), forcing closed" );
PanelOpen = OpenOnStart;
}
if ( Input.Keyboard.Pressed( "N" ) )
PanelOpen = !PanelOpen;
if ( PanelOpen )
{
Mouse.Visibility = MouseVisibility.Visible; // keep the cursor usable over the panel
_wasOpen = true;
}
else if ( _wasOpen )
{
_wasOpen = false;
_copyLabel = "Copy config"; // closing clears the flash, so a reopen never claims a copy that was not made
}
}
void ClosePanel()
{
PanelOpen = false;
_copyLabel = "Copy config";
}
// Fold the toggle, the clock (to the displayed minute), the pace, the weather pin, the pause state and
// the copy label. Miss one and that readout freezes on screen while the world keeps moving.
protected override int BuildHash()
{
var c = Clock;
int minute = (int)MathF.Round( HourOfDay * 60f );
int pace = c is null ? 0 : (int)MathF.Round( SkyGrade.ClockRateScale( HourOfDay, c.Config ) * 1000f );
int length = c is null ? 0 : (int)MathF.Round( c.Config.DayLengthMinutes * 100f );
return HashCode.Combine( PanelOpen, c is not null, minute, pace, length, WeatherPin, Paused, _copyLabel );
}
}
Game
library
using System;
using Sandbox;
namespace FieldGuide.DayNight;
/// <summary>
/// The pure grading math: a total game-hour + weather + config in, a sun rotation and four colour grades out.
/// No engine state, no randomness, so a host and every client compute the SAME look from the same clock, and
/// the editor / a headless test can render or check the exact cycle a play session shows.
///
/// ANCHOR-EXACT BY CONSTRUCTION: at <see cref="DayNightConfig.AnchorHours"/> (Clear) every output equals the
/// reference grade in the config, so pinning the anchor reproduces the authored look byte-for-byte. The
/// daytime arc is derived from <see cref="DayNightConfig.SunDirection"/> and lerps FROM the reference colours,
/// and a twilight band smoothstep-blends dusk/dawn to the night grade so there is no hard flip at the horizon.
///
/// This math NEVER touches exposure, shadows, or fog. Apply it and your night is dark because the sun and sky
/// colours are dark, not because tone-mapping moved.
/// </summary>
public static class SkyGrade
{
/// <summary>Smoothstep 0→1 with clamp, the twilight blend easing (deterministic; no engine state).</summary>
public static float Smoothstep( float x )
{
x = Math.Clamp( x, 0f, 1f );
return x * x * (3f - 2f * x);
}
/// <summary>Compute the sun rotation + all four colour grades for a TOTAL game-hour + weather. Anchor-exact:
/// at <see cref="DayNightConfig.AnchorHours"/> (Clear) every value equals the config reference grade. Never
/// computes exposure, that stays whatever your camera set it to.</summary>
public static void ComputeGrade( float total, WeatherKind weather, in DayNightConfig cfg,
out Rotation sunRot, out Color sunColor, out Color skyColor, out Color skyTint, out Color envTint )
{
var anchor = Rotation.LookAt( cfg.SunDirection.Normal ).Angles(); // reference sun pitch/yaw
int day = (int)MathF.Floor( total / 24f );
float t = total - day * 24f; // hour-of-day 0..24
float sunrise = cfg.SunriseHour, sunset = cfg.SunsetHour;
bool isDay = t >= sunrise && t <= sunset;
float p = isDay ? (t - sunrise) / (sunset - sunrise) : 0f; // 0 at sunrise .. 1 at sunset
float daylight = isDay ? MathF.Sin( p * MathF.PI ) : 0f; // 0 night .. 1 noon
float pAnchor = (cfg.AnchorHours - sunrise) / (sunset - sunrise);
float dlAnchor = MathF.Sin( pAnchor * MathF.PI );
float weatherDim = weather switch
{
WeatherKind.Rain => cfg.WeatherDimRain,
WeatherKind.Cloudy => cfg.WeatherDimCloudy,
_ => 1f,
};
if ( isDay )
{
// ── DAYTIME (anchor-exact by construction) ──
// ROTATION: pitch grows from the near-horizon value to the derived noon max, threading the derived
// anchor pitch; yaw sweeps east→west across the day, threading the anchor yaw.
float pitchSpan = (anchor.pitch - cfg.HorizonPitch) / dlAnchor;
float pitch = cfg.HorizonPitch + daylight * pitchSpan;
float yaw = anchor.yaw + (p - pAnchor) * cfg.YawSpan;
sunRot = Rotation.From( pitch, yaw, 0f );
// KEY COLOUR: lerp from the reference SunColor toward whiter noon / warmer horizon, anchored so the
// value EQUALS SunColor exactly at the anchor daylight.
float rel = daylight - dlAnchor; // 0 at anchor, + toward noon, - toward horizon
sunColor = rel >= 0f
? Color.Lerp( cfg.SunColor, cfg.NoonKey, dlAnchor < 1f ? rel / (1f - dlAnchor) : 0f )
: Color.Lerp( cfg.SunColor, cfg.HorizonKey, -rel / dlAnchor );
sunColor *= weatherDim;
// AMBIENT / SKY / ENVMAP: scale the reference grade by daylight (anchor == 1 == the exact reference).
float skyScale = dlAnchor > 0f ? MathF.Min( daylight / dlAnchor, 1.15f ) : 0f;
skyColor = cfg.SkyAmbient * skyScale;
skyTint = cfg.SkyTint * skyScale;
envTint = cfg.EnvmapTint * skyScale;
return;
}
// ── NIGHT + TWILIGHT. The fixed-low-moon night grade is the deep-night target; a TWILIGHT band
// TwilightHours past sunset (and before sunrise) smoothstep-lerps the sun rotation AND all four colours
// from the HORIZON-edge values (what the day arc reaches at sunrise/sunset, daylight→0) to the night
// values, so dusk reads as the sun continuing its arc below the horizon, not a hard flip. At w=0 it
// equals the day boundary (continuous with daytime); at w=1 it equals deep night. ──
float nightPitch = cfg.NightPitch;
float nightYaw = anchor.yaw + cfg.YawSpan * 0.6f; // fixed low moon direction
Color nightSun = cfg.NightKey;
Color nightSky = cfg.NightAmbient;
Color nightTint = cfg.NightSkyTint;
Color nightEnv = cfg.NightEnvTint;
// Twilight blend factor: 0 = horizon-edge look, 1 = deep night. Evening band (just after sunset) and the
// mirror morning band (just before sunrise); outside the bands it is 1 (deep night).
float tw = cfg.TwilightHours;
float w = 1f;
bool evening = t > sunset && t <= sunset + tw;
bool morning = t >= sunrise - tw && t < sunrise;
if ( evening ) w = Smoothstep( (t - sunset) / tw );
else if ( morning ) w = Smoothstep( (sunrise - t) / tw );
if ( w >= 1f )
{
// deep night (no blend), the fixed night grade.
sunRot = Rotation.From( nightPitch, nightYaw, 0f );
sunColor = nightSun;
skyColor = nightSky;
skyTint = nightTint;
envTint = nightEnv;
return;
}
// HORIZON-edge grade (the day arc evaluated at the sunrise/sunset boundary, daylight→0): pitch sits at the
// near-horizon value, yaw at that boundary's swept position, sun the deep warm HorizonKey, sky/ambient →0.
float boundaryP = evening ? 1f : 0f; // sunset p=1, sunrise p=0
float horizonPitch = cfg.HorizonPitch;
float horizonYaw = anchor.yaw + (boundaryP - pAnchor) * cfg.YawSpan;
Color horizonSun = cfg.HorizonKey * weatherDim;
sunRot = Rotation.From(
MathX.Lerp( horizonPitch, nightPitch, w ),
MathX.LerpDegrees( horizonYaw, nightYaw, w ),
0f );
sunColor = Color.Lerp( horizonSun, nightSun, w );
skyColor = Color.Lerp( Color.Black, nightSky, w ); // day-edge ambient is ~0; rise to the night floor
skyTint = Color.Lerp( Color.Black, nightTint, w );
envTint = Color.Lerp( Color.Black, nightEnv, w );
}
/// <summary>PURE: the clock-advance rate MULTIPLIER for a given hour-of-day (0..24), applied to the base
/// pace in <see cref="DayNightClock"/>. The daylight arc runs slower (<see cref="DayNightConfig.DayRateScale"/>)
/// so the day lasts longer, while night keeps rate 1 so night real-time is preserved exactly. The two ramps
/// live INSIDE the daylight window (a <see cref="DayNightConfig.TwilightHours"/>-wide smoothstep at each edge),
/// reaching rate 1 exactly at sunrise/sunset so there is no rate discontinuity at the night boundary. Bounded
/// to [DayRateScale, 1], pure, so host and every client derive the same rate and stay in lockstep.</summary>
public static float ClockRateScale( float hourOfDay, in DayNightConfig cfg )
{
float sunrise = cfg.SunriseHour, sunset = cfg.SunsetHour;
float tw = cfg.TwilightHours;
float dayScale = cfg.DayRateScale;
const float nightScale = 1f;
float t = hourOfDay - MathF.Floor( hourOfDay / 24f ) * 24f; // wrap to 0..24 for callers passing total hours
// Full daylight interior: the slow pace.
if ( t >= sunrise + tw && t <= sunset - tw ) return dayScale;
// Dawn ramp (inside the day window): rate 1 at sunrise → slow by sunrise+tw. Night stays exact because the
// ramp is spent within daylight, not stolen from the night arc.
if ( t >= sunrise && t < sunrise + tw ) return MathX.Lerp( nightScale, dayScale, Smoothstep( (t - sunrise) / tw ) );
// Dusk ramp (inside the day window): slow until sunset-tw → rate 1 exactly at sunset, matching night.
if ( t > sunset - tw && t <= sunset ) return MathX.Lerp( dayScale, nightScale, Smoothstep( (t - (sunset - tw)) / tw ) );
// Night: unchanged rate, so the night arc's real-time length is preserved exactly.
return nightScale;
}
/// <summary>Apply the computed grade to a specific sun/sky/envmap trio. Leaves exposure / shadows / fog
/// alone. Pass null for any of sky/env you do not have. This is the ONLY method here that writes engine
/// state; everything above is pure.</summary>
public static void ApplyGradeTo( DirectionalLight sun, SkyBox2D sky, EnvmapProbe env,
float total, WeatherKind weather, in DayNightConfig cfg )
{
if ( !sun.IsValid() ) return;
ComputeGrade( total, weather, cfg, out var rot, out var sunColor, out var skyColor, out var skyTint, out var envTint );
sun.WorldRotation = rot;
sun.LightColor = sunColor;
sun.SkyColor = skyColor;
if ( sky.IsValid() ) sky.Tint = skyTint;
if ( env.IsValid() ) env.TintColor = envTint;
}
}
Game
library
@using Sandbox
@using Sandbox.UI
@using System
@using System.Collections.Generic
@using System.Linq
@namespace FieldGuide.DayNight
@inherits PanelComponent
@attribute [StyleSheet]
@*
The kit's dev tuning surface for the day/night cycle: scrub the clock, change the pace, jump to an
hour, pin the weather, hold and resume time, and copy the resulting config as a paste-ready C# block.
Every write goes through DayNightClock's authority-guarded setters, so this panel is safe to leave in
a networked session: on a client the setters are quiet no-ops and the card says so instead of
pretending the drag did something.
Optional. Delete Code/Ui if you would rather drive the clock from your own UI; nothing else in the kit
references this file.
Rows render in MAIN markup (no RenderFragment) per the fragment-undermeasure gotcha, and each slider
is a shape pair (track + fill), which keeps the text-run count low. Toggle with N (a raw letter key,
never an F key: the editor eats those in play), the 42px x in the header, or the `daynight_panel`
console convar. Starts closed unless OpenOnStart is set; see the boot block in OnUpdate.
Look and layout follow the Field Kit UI system: docs/design/ui-system/daynight-kit.dc.html for this
screen, tokens.dc.html for the values. The stylesheet carries this kit's own copy of those tokens
(kits cannot import each other) and lists the engine-legality translations at its head, including the
inline-unquoted font-family rule that a $variable silently breaks.
One deliberate departure from the mockup: the weather group carries a fourth segment, "auto". The
mockup shows three pinned kinds, but the clock's deterministic roll is the DEFAULT state and a panel
with no way back to it can only pin, never release. Auto writes the -1 override.
*@
<root>
@if ( PanelOpen )
{
<div class="dn-card">
<div class="dn-hdr">
<span class="dn-title">DAY / NIGHT · dev</span>
<div class="dn-hr">
<span class="dn-key">N</span>
<div class="dn-x" onclick=@ClosePanel>×</div>
</div>
</div>
@if ( Clock is null )
{
<div class="dn-empty">No DayNightClock in this scene. Add one to your session GameObject and this panel drives it.</div>
}
else
{
@* ---- hero readout: the whole point of the kit, in one line ---- *@
<div class="dn-hero">
<span class="dn-hl">Clock</span>
<span class="dn-hv">@ClockText</span>
</div>
<div class="dn-meta">
<span class="dn-mk">@DayText</span>
<span class="dn-mk">@WeatherText</span>
<span class="dn-mk">@PaceText</span>
</div>
@if ( !IsAuthority )
{
<div class="dn-note">The host owns the clock. This card reads the replicated time; the controls below do nothing here.</div>
}
@* ---- the two dials ---- *@
@foreach ( var d in Dials )
{
var dial = d;
string lab = dial.label; // plain locals before interpolating: an inline field read can render blank
string val = ValueText( dial.kind );
int fillPct = (int)( Frac( dial ) * 100f );
<div class="dn-row">
<div class="dn-rlab">
<span class="dn-rl">@lab</span>
<span class="dn-rv">@val</span>
</div>
<div class="dn-slider">
<span class="dn-stp" onclick=@(() => Nudge( dial, -dial.step ))>−</span>
<div class="dn-hit"
onmousedown=@(e => TrackPointer( e, dial, true ))
onmousemove=@(e => TrackPointer( e, dial, false ))>
<div class="dn-track"
onmousedown=@(e => TrackPointer( e, dial, true ))
onmousemove=@(e => TrackPointer( e, dial, false ))>
<div class="dn-fill" style="width: @(fillPct)%;"></div>
</div>
</div>
<span class="dn-stp" onclick=@(() => Nudge( dial, dial.step ))>+</span>
</div>
</div>
}
@* ---- jump to a named hour ---- *@
<div class="dn-row">
<span class="dn-rl">Jump to</span>
<div class="dn-chips">
@foreach ( var j in Jumps )
{
var jump = j;
string jl = jump.label;
<div class="dn-chip @(IsAtHour( jump.hour ) ? "on" : "")" onclick=@(() => JumpTo( jump.hour ))>@jl</div>
}
</div>
</div>
@* ---- weather: three pins plus a way back to the deterministic roll ---- *@
<div class="dn-row">
<span class="dn-rl">Weather</span>
<div class="dn-seg-group wide">
<div class="dn-seg grow @(WeatherPin == -1 ? "on" : "")" onclick=@(() => PinWeather( -1 ))>auto</div>
<div class="dn-seg grow @(WeatherPin == 0 ? "on" : "")" onclick=@(() => PinWeather( 0 ))>clear</div>
<div class="dn-seg grow @(WeatherPin == 1 ? "on" : "")" onclick=@(() => PinWeather( 1 ))>cloudy</div>
<div class="dn-seg grow @(WeatherPin == 2 ? "on" : "")" onclick=@(() => PinWeather( 2 ))>rain</div>
</div>
</div>
@* ---- hold or resume ---- *@
<div class="dn-inline">
<span class="dn-rl">Clock running</span>
<div class="dn-seg-group">
<div class="dn-seg tight @(Paused ? "" : "on")" onclick=@(() => SetPaused( false ))>run</div>
<div class="dn-seg tight @(Paused ? "on" : "")" onclick=@(() => SetPaused( true ))>pause</div>
</div>
</div>
@* ---- actions ---- *@
<div class="dn-btns">
<div class="dn-btn" onclick=@ResetAll>Reset</div>
<div class="dn-btn primary" onclick=@CopyConfig>@_copyLabel</div>
</div>
}
</div>
}
</root>
@code
{
// ---- toggle state (N raw key + `daynight_panel` convar fallback) ----
static bool _open;
/// <summary>Console fallback: `daynight_panel 1` / `daynight_panel 0` opens or closes the time panel
/// (N also toggles).</summary>
[ConVar( "daynight_panel", Help = "Open or close the day/night time panel (same as the N key)" )]
public static bool PanelOpen { get => _open; set => _open = value; }
/// <summary>
/// Whether this panel starts open. Off by default: a dev tuning surface that appears unbidden over a
/// consumer's game is a bug, not a feature. Turn it on for a scene whose whole point is the panel, the
/// way the kit's own demo does.
///
/// This is what decides the panel's boot state, and it is the ONLY thing that decides it. See the boot
/// block in OnUpdate for why that matters.
/// </summary>
[Property] public bool OpenOnStart { get; set; }
/// <summary>Shortest in-game day the pace slider allows, in real minutes at the night pace.</summary>
[Property] public float MinDayLengthMinutes { get; set; } = 1f;
/// <summary>Longest in-game day the pace slider allows, in real minutes at the night pace.</summary>
[Property] public float MaxDayLengthMinutes { get; set; } = 60f;
string _copyLabel = "Copy config";
bool _wasOpen;
bool _booted;
DayNightClock _clock;
/// <summary>The scene's clock, re-resolved while it is missing so a panel built before the clock still
/// finds it. Null until one exists, which the markup handles with an explicit empty state.</summary>
DayNightClock Clock
{
get
{
if ( _clock.IsValid() ) return _clock;
_clock = DayNightClock.For( Scene );
return _clock;
}
}
/// <summary>Single-player, or the host of a live session. Only here do the clock's setters do anything,
/// so the card states the case rather than letting a drag fail silently.</summary>
static bool IsAuthority => !Networking.IsActive || Networking.IsHost;
// ---- readouts ----
float TotalHours => Clock?.GetTimeHours() ?? 0f;
float HourOfDay => TotalHours - MathF.Floor( TotalHours / 24f ) * 24f;
/// <summary>The hero readout, HH:MM on a 24-hour clock. Minutes floor rather than round so the display
/// never shows :60 at the top of an hour.</summary>
string ClockText
{
get
{
float h = HourOfDay;
int hh = (int)MathF.Floor( h );
int mm = (int)MathF.Floor( (h - hh) * 60f );
if ( mm >= 60 ) { mm = 0; hh = (hh + 1) % 24; }
return $"{hh:00}:{mm:00}";
}
}
string DayText => $"DAY {Clock?.CurrentDay ?? 0}";
/// <summary>Names the weather AND where it came from, because "rain" alone does not tell you whether the
/// deterministic roll produced it or somebody pinned it.</summary>
string WeatherText
{
get
{
var c = Clock;
if ( c is null ) return "WEATHER ?";
string kind = c.CurrentWeather.ToString().ToUpperInvariant();
return WeatherPin < 0 ? $"{kind} · ROLLED" : $"{kind} · PINNED";
}
}
/// <summary>The pace the clock is running at right now, as the rate multiplier the daylight ramp applies.
/// Reads 1.00x through the night and DayRateScale at midday.</summary>
string PaceText
{
get
{
var c = Clock;
if ( c is null ) return "PACE ?";
var cfg = c.Config;
return $"PACE {SkyGrade.ClockRateScale( HourOfDay, cfg ):0.00}x";
}
}
int WeatherPin => Clock?.NetWeatherOverride ?? -1;
bool Paused => Clock?.TimePaused ?? false;
// ---- the two dials ----
enum Dial { TimeOfDay, DayLength }
struct DialRow { public Dial kind; public string label; public float step; }
/// <summary>Built per read rather than held in a static, so the pace row always reflects the current
/// MinDayLengthMinutes / MaxDayLengthMinutes properties.</summary>
static List<DialRow> Dials => new()
{
new DialRow { kind = Dial.TimeOfDay, label = "Time of day", step = 0.25f },
new DialRow { kind = Dial.DayLength, label = "Day length", step = 1f },
};
/// <summary>Row value text. Time of day reads as the 0..1 fraction the slider is at (the readable clock
/// is the hero line above it); day length reads in real minutes.</summary>
string ValueText( Dial kind )
{
var c = Clock;
if ( c is null ) return "-";
return kind switch
{
Dial.TimeOfDay => (HourOfDay / 24f).ToString( "0.00" ),
Dial.DayLength => $"{c.Config.DayLengthMinutes:0} min",
_ => "-",
};
}
float Get( Dial kind )
{
var c = Clock;
if ( c is null ) return 0f;
return kind switch
{
Dial.TimeOfDay => HourOfDay,
Dial.DayLength => c.Config.DayLengthMinutes,
_ => 0f,
};
}
float Min( Dial kind ) => kind == Dial.TimeOfDay ? 0f : MathF.Max( 0.1f, MinDayLengthMinutes );
float Max( Dial kind ) => kind == Dial.TimeOfDay ? 24f : MathF.Max( Min( kind ) + 0.1f, MaxDayLengthMinutes );
float Frac( DialRow row )
{
float min = Min( row.kind ), max = Max( row.kind );
return Math.Clamp( (Get( row.kind ) - min) / MathF.Max( max - min, 0.0001f ), 0f, 1f );
}
void Set( Dial kind, float value )
{
var c = Clock;
if ( c is null ) return;
switch ( kind )
{
case Dial.TimeOfDay:
// Day-preserving, so scrubbing inside a day never re-rolls that day's weather. The top of the
// range is 23:59, not 24:00: hour 24 IS the next day's midnight, so a full-right drag would
// tip the day index over, re-roll the weather and snap the slider back to the far left. This
// panel scrubs within a day; the clock is what advances days.
c.SetTimeOfDay( Math.Clamp( value, 0f, 24f - (1f / 60f) ) );
break;
case Dial.DayLength:
WriteDayLength( Math.Clamp( value, Min( kind ), Max( kind ) ) );
break;
}
}
void Nudge( DialRow row, float delta ) => Set( row.kind, Get( row.kind ) + delta );
/// <summary>
/// Draggable track: onmousedown JUMPS to the click, onmousemove SCRUBS while the panel is Active.
///
/// Both the 28px transparent grab wrapper and the 14px visible track carry this handler, and a press on
/// the track bubbles to the wrapper as well, so a single click can run it twice. That is harmless
/// BECAUSE the write is absolute (set to the value under the cursor), not relative: two runs of the same
/// press land on the same value. Keep it absolute if you touch this.
/// </summary>
void TrackPointer( PanelEvent ev, DialRow row, bool jump )
{
if ( ev is not MousePanelEvent e ) return;
var track = e.This;
if ( track is null ) return;
if ( !jump && !track.PseudoClass.HasFlag( PseudoClass.Active ) ) return;
float w = track.Box.Rect.Width;
if ( w <= 0f ) return;
float frac = Math.Clamp( e.LocalPosition.x / w, 0f, 1f );
if ( row.kind == Dial.TimeOfDay )
{
// Quantized to one in-game minute by the kit's own pure helper, so a drag emits at most one
// distinct value per minute of readout instead of one per pixel.
Set( Dial.TimeOfDay, TimeMath.ComputeSliderHour( frac ) );
return;
}
float min = Min( row.kind ), max = Max( row.kind );
float target = min + frac * (max - min);
if ( row.step > 0f ) target = MathF.Round( target / row.step ) * row.step;
Set( row.kind, target );
}
// ---- jump chips ----
struct JumpRow { public string label; public float hour; }
/// <summary>The four named hours, read off the clock's own config so a game with a different daylight
/// window still gets its real dawn and dusk rather than 6 and 18.</summary>
List<JumpRow> Jumps
{
get
{
var cfg = Clock?.Config ?? DayNightConfig.Default;
return new List<JumpRow>
{
new JumpRow { label = "dawn", hour = cfg.SunriseHour },
new JumpRow { label = "noon", hour = (cfg.SunriseHour + cfg.SunsetHour) * 0.5f },
new JumpRow { label = "dusk", hour = cfg.SunsetHour },
new JumpRow { label = "midnight", hour = 0f },
};
}
}
/// <summary>Is the clock within a minute of this named hour? A jump lands exactly, so a one-minute window
/// is enough to light the chip and narrow enough that it goes out as soon as time moves on.</summary>
bool IsAtHour( float hour ) => MathF.Abs( HourOfDay - hour ) < (1f / 60f);
void JumpTo( float hour ) => Set( Dial.TimeOfDay, hour );
// ---- weather, pause, config writes ----
void PinWeather( int kind ) => Clock?.SetWeatherOverride( kind );
void SetPaused( bool paused ) => Clock?.SetPaused( paused );
/// <summary>
/// Write a new day length onto the clock AND every driver in the scene.
///
/// DayNightConfig is a STRUCT, so `clock.Config.DayLengthMinutes = x` would mutate a temporary copy and
/// change nothing. Read, edit, write back. The drivers get the same value because a consumer is told to
/// keep clock and driver config identical, and a tuning panel that quietly desynchronised them would be
/// the exact bug the docs warn about.
///
/// AUTHORITY-GUARDED, unlike the clock's own setters which guard themselves. Config is authoring data and
/// is NOT replicated, so a client that changed its own day length would extrapolate at a different pace
/// than the host and drift between every snapshot. The guard has to live here.
/// </summary>
void WriteDayLength( float minutes )
{
if ( !IsAuthority ) return;
var c = Clock;
if ( c is null ) return;
var cfg = c.Config;
cfg.DayLengthMinutes = minutes;
c.Config = cfg;
foreach ( var driver in Scene.GetAllComponents<DayNightDriver>() )
{
var dcfg = driver.Config;
dcfg.DayLengthMinutes = minutes;
driver.Config = dcfg;
}
}
/// <summary>Back to the shipped reference grade: default config on the clock and every driver, weather
/// released to the deterministic roll, clock running, time at the config's start hour.</summary>
void ResetAll()
{
if ( !IsAuthority ) return; // same reason as WriteDayLength: config is authoring data, not session state
var c = Clock;
if ( c is null ) return;
var def = DayNightConfig.Default;
c.Config = def;
foreach ( var driver in Scene.GetAllComponents<DayNightDriver>() )
driver.Config = def;
c.SetWeatherOverride( -1 );
c.SetPaused( false );
c.SetTimeOfDay( def.StartHours );
_copyLabel = "Copy config";
}
/// <summary>Put the tuned config on the system clipboard as a paste-ready C# block. Game-side
/// Sandbox.UI.Clipboard.SetText, so it works in play without an editor round trip. Only the fields this
/// panel can move are emitted; everything else stays whatever Default gives you.</summary>
void CopyConfig()
{
var c = Clock;
if ( c is null ) return;
var cfg = c.Config;
string text =
"var cfg = DayNightConfig.Default;\n"
+ $"cfg.DayLengthMinutes = {cfg.DayLengthMinutes.ToString( "0.###" )}f;\n"
+ $"cfg.StartHours = {HourOfDay.ToString( "0.###" )}f;\n"
+ $"cfg.StartPaused = {(Paused ? "true" : "false")};\n"
+ "clock.Config = cfg;";
Sandbox.UI.Clipboard.SetText( text );
_copyLabel = "Copied!";
}
// ---- boot state, N toggle, cursor while open ----
protected override void OnUpdate()
{
// BOOT. `daynight_panel` is a convar and s&box PERSISTS convars across sessions, so a session can
// otherwise come up with the panel logically open from whatever someone left set weeks ago. The rule
// that prevents it: this component's own OpenOnStart decides the boot state, and the persisted value
// never does. Default off means a consumer game still cannot be pre-opened by a stale convar; a scene
// that wants the panel up says so explicitly.
//
// Deliberately in the FIRST UPDATE rather than OnStart. A panel built in code is configured by the
// component that created it, and doing this in OnStart would race that assignment: whichever ran
// first would win. The first update is after every OnStart in the frame, so the setting is always
// read, never half-applied.
if ( !_booted )
{
_booted = true;
if ( PanelOpen && !OpenOnStart )
Log.Info( "[daynight] time panel was OPEN at session start (persisted convar), forcing closed" );
PanelOpen = OpenOnStart;
}
if ( Input.Keyboard.Pressed( "N" ) )
PanelOpen = !PanelOpen;
if ( PanelOpen )
{
Mouse.Visibility = MouseVisibility.Visible; // keep the cursor usable over the panel
_wasOpen = true;
}
else if ( _wasOpen )
{
_wasOpen = false;
_copyLabel = "Copy config"; // closing clears the flash, so a reopen never claims a copy that was not made
}
}
void ClosePanel()
{
PanelOpen = false;
_copyLabel = "Copy config";
}
// Fold the toggle, the clock (to the displayed minute), the pace, the weather pin, the pause state and
// the copy label. Miss one and that readout freezes on screen while the world keeps moving.
protected override int BuildHash()
{
var c = Clock;
int minute = (int)MathF.Round( HourOfDay * 60f );
int pace = c is null ? 0 : (int)MathF.Round( SkyGrade.ClockRateScale( HourOfDay, c.Config ) * 1000f );
int length = c is null ? 0 : (int)MathF.Round( c.Config.DayLengthMinutes * 100f );
return HashCode.Combine( PanelOpen, c is not null, minute, pace, length, WeatherPin, Paused, _copyLabel );
}
}
Debug: View Raw JSON Response
{
"TotalCount": 27,
"Files": [
{
"Ident": "fieldguide.daynight",
"Path": "Code/TimeMath.cs",
"FileName": "TimeMath.cs",
"PackageType": "library",
"CodeKind": "Game",
"AssetVersionId": 337909,
"Code": "using System;\r\n\r\nnamespace FieldGuide.DayNight;\r\n\r\n/// <summary>Pure helpers for setting the clock from a UI. Kept separate from the networked component so they\r\n/// are trivially unit-testable and reusable (a preview tool, an editor slider, a debug console).</summary>\r\npublic static class TimeMath\r\n{\r\n\t/// <summary>Set the clock to an hour-of-day while PRESERVING the current day index, so the deterministic\r\n\t/// per-day weather roll does not re-roll when a player scrubs the time within a day. Result =\r\n\t/// floor(current/24)*24 + clamp(hourOfDay, 0, 24). Example: day 1 at 15:30 (total 39.5), set 06:00 \u2192 30.0\r\n\t/// (still day 1).</summary>\r\n\tpublic static float ComputeSetHour( float currentTotalHours, float hourOfDay )\r\n\t\t=> MathF.Floor( currentTotalHours / 24f ) * 24f + Math.Clamp( hourOfDay, 0f, 24f );\r\n\r\n\t/// <summary>Map a slider/drag fraction across a track (0 left, 1 right) to a quantized hour-of-day in\r\n\t/// [0,24], rounded to the nearest in-game MINUTE (1/60 h). A pixel-cheap throttle with no timer state: a\r\n\t/// drag emits at most one distinct value per minute of readout. Feed the result into\r\n\t/// <see cref=\"ComputeSetHour\"/> to keep the day index.\r\n\t///\r\n\t/// The result is clamped AFTER quantizing, and that clamp is load-bearing: one minute is not exactly\r\n\t/// representable in binary, so 1440 steps of 1/60 accumulate to 24.000002 and a full-right drag would\r\n\t/// hand <see cref=\"ComputeSetHour\"/> a value past the end of the day. Round first, clamp second.</summary>\r\n\tpublic static float ComputeSliderHour( float frac )\r\n\t{\r\n\t\tfloat hour = Math.Clamp( frac, 0f, 1f ) * 24f;\r\n\t\tconst float step = 1f / 60f; // one in-game minute\r\n\t\treturn Math.Clamp( MathF.Round( hour / step ) * step, 0f, 24f );\r\n\t}\r\n}\r\n"
},
{
"Ident": "fieldguide.daynight",
"Path": "Demo/DayNightHintCard.razor",
"FileName": "DayNightHintCard.razor",
"PackageType": "library",
"CodeKind": "Game",
"AssetVersionId": 337909,
"Code": "@using Sandbox\r\n@using Sandbox.UI\r\n@using System\r\n@using System.Collections.Generic\r\n@namespace FieldGuide.DayNight\r\n@inherits PanelComponent\r\n@attribute [StyleSheet]\r\n\r\n@*\r\n\tThe demo's on-screen key card, up from the first frame. A scene with no visible instructions reads as\r\n\ta broken scene: you press nothing, nothing happens, you close it. So this says what the demo is and\r\n\twhich keys do what, before you have touched anything.\r\n\r\n\tIt also carries the one thing the demo could not otherwise show. The kit ships no sky shader and no\r\n\tsky art on purpose; the sky is a SEAM, four normalized crossfade weights per hour. Those weights have\r\n\tno picture, so the card prints them live and they visibly hand off from one slot to the next as the\r\n\tclock runs. That is the seam doing its job, on screen, with no art involved.\r\n\r\n\tRows render in MAIN markup via @foreach per the fragment-undermeasure gotcha. H hides the card (a\r\n\tletter, never an F key, which the editor eats in play). No ESC anywhere: house law.\r\n\r\n\tLook and layout follow the Field Kit UI system: docs/design/ui-system/daynight-kit.dc.html for this\r\n\tscreen, tokens.dc.html for the values. Font sizes come from the {12, 13, 14, 16} panel scale and there\r\n\tis no letter-spacing; the stylesheet head lists the rest of the engine-legality rules.\r\n\r\n\tNot part of the kit's runtime surface: delete Code/Demo when you drop the kit into your own project.\r\n*@\r\n\r\n<root>\r\n@* DemoActive is the inert-by-construction gate (library law 11): only DayNightDemoBootstrap sets it, so\r\n this card cannot appear in a consumer's game even if Code/Demo was left in the project. *@\r\n@if ( CardOpen && DayNightDemoBootstrap.DemoActive )\r\n{\r\n\t<div class=\"dh-card\">\r\n\t\t<div class=\"dh-hdr\">\r\n\t\t\t<span class=\"dh-title\">DAY / NIGHT KIT DEMO</span>\r\n\t\t\t<div class=\"dh-x\" onclick=@(() => CardOpen = false)>\u00d7</div>\r\n\t\t</div>\r\n\r\n\t\t<div class=\"dh-lede\">One directional light, one skybox, one clock. Watch the sun sweep and the colour grade follow it, or open the time panel and drive the cycle yourself.</div>\r\n\r\n\t\t<div class=\"dh-rows\">\r\n\t\t\t@foreach ( var r in Keys )\r\n\t\t\t{\r\n\t\t\t\tstring key = r.key; // plain locals before interpolating: an inline tuple read can render blank\r\n\t\t\t\tstring what = r.what;\r\n\t\t\t\t<div class=\"dh-row\">\r\n\t\t\t\t\t<span class=\"dh-key\">@key</span>\r\n\t\t\t\t\t<span class=\"dh-what\">@what</span>\r\n\t\t\t\t</div>\r\n\t\t\t}\r\n\t\t</div>\r\n\r\n\t\t<div class=\"dh-live\">\r\n\t\t\t@foreach ( var w in Weights )\r\n\t\t\t{\r\n\t\t\t\tstring run = w;\r\n\t\t\t\t<span class=\"dh-lk\">@run</span>\r\n\t\t\t}\r\n\t\t</div>\r\n\r\n\t\t<div class=\"dh-foot\">Those four are the sky seam. The kit ships no sky shader and no sky art; you crossfade your own sky from these weights.</div>\r\n\t</div>\r\n}\r\n</root>\r\n\r\n@code\r\n{\r\n\tstatic bool _open = true;\r\n\r\n\t/// <summary>Console fallback: `daynight_hint 1` / `daynight_hint 0` shows or hides the card (H also\r\n\t/// toggles). Starts SHOWN, unlike the time panel, because it is the thing that tells you the time panel\r\n\t/// exists.</summary>\r\n\t[ConVar( \"daynight_hint\", Help = \"Show or hide the demo scene's key card (same as the H key)\" )]\r\n\tpublic static bool CardOpen { get => _open; set => _open = value; }\r\n\r\n\tstatic readonly List<(string key, string what)> Keys = new()\r\n\t{\r\n\t\t( \"N\", \"Open the time panel: scrub the clock, change the pace, pin the weather\" ),\r\n\t\t( \"H\", \"Hide this card\" ),\r\n\t};\r\n\r\n\tDayNightClock _clock;\r\n\r\n\tDayNightClock Clock\r\n\t{\r\n\t\tget\r\n\t\t{\r\n\t\t\tif ( _clock.IsValid() ) return _clock;\r\n\t\t\t_clock = DayNightClock.For( Scene );\r\n\t\t\treturn _clock;\r\n\t\t}\r\n\t}\r\n\r\n\t/// <summary>The live sky weights as four short atomic runs. Split into separate spans rather than one\r\n\t/// sentence so a wrap breaks BETWEEN runs; a single long run wraps mid-word, which is a live bug class\r\n\t/// in this engine's text layout.</summary>\r\n\tList<string> Weights\r\n\t{\r\n\t\tget\r\n\t\t{\r\n\t\t\tvar c = Clock;\r\n\t\t\tvar cfg = c?.Config ?? DayNightConfig.Default;\r\n\t\t\tvar w = SkyWeights.WeightsFor( c?.GetTimeHours() ?? 0f, cfg );\r\n\t\t\treturn new List<string>\r\n\t\t\t{\r\n\t\t\t\t$\"MORNING {w.x:0.00}\",\r\n\t\t\t\t$\"NOON {w.y:0.00}\",\r\n\t\t\t\t$\"EVENING {w.z:0.00}\",\r\n\t\t\t\t$\"NIGHT {w.w:0.00}\",\r\n\t\t\t};\r\n\t\t}\r\n\t}\r\n\r\n\tprotected override void OnUpdate()\r\n\t{\r\n\t\tif ( Input.Keyboard.Pressed( \"H\" ) )\r\n\t\t\tCardOpen = !CardOpen;\r\n\t}\r\n\r\n\t// Fold the card state and every printed weight (to the two decimals shown), or the strip freezes at\r\n\t// whatever it read on the first frame while the sun keeps moving.\r\n\tprotected override int BuildHash()\r\n\t{\r\n\t\tvar c = Clock;\r\n\t\tvar cfg = c?.Config ?? DayNightConfig.Default;\r\n\t\tvar w = SkyWeights.WeightsFor( c?.GetTimeHours() ?? 0f, cfg );\r\n\t\treturn HashCode.Combine( CardOpen, DayNightDemoBootstrap.DemoActive,\r\n\t\t\t(int)MathF.Round( w.x * 100f ),\r\n\t\t\t(int)MathF.Round( w.y * 100f ),\r\n\t\t\t(int)MathF.Round( w.z * 100f ),\r\n\t\t\t(int)MathF.Round( w.w * 100f ) );\r\n\t}\r\n}\r\n"
},
{
"Ident": "fieldguide.daynight",
"Path": "DayNightClock.cs",
"FileName": "DayNightClock.cs",
"PackageType": "library",
"CodeKind": "Game",
"AssetVersionId": 337909,
"Code": "using System;\r\nusing System.Linq;\r\nusing Sandbox;\r\n\r\nnamespace FieldGuide.DayNight;\r\n\r\n/// <summary>\r\n/// The host-authoritative day/night clock: the ONE thin networked surface in this kit. Everything else\r\n/// (grading, weights, weather, time-set math) is pure and testable; this component is the small, honest\r\n/// piece that cannot run headless because it depends on s&box networking.\r\n///\r\n/// Ownership model: in a peer-hosted s&box session the HOST owns the clock. It accumulates game-time each\r\n/// fixed tick and publishes three <c>[Sync(SyncFlags.FromHost)]</c> fields; clients never write them, they\r\n/// observe and locally extrapolate so the sun advances smoothly between snapshots. Single-player (networking\r\n/// inactive) is the host-of-one and just reads its own field directly.\r\n///\r\n/// THE PAUSE PIN (the hardening worth understanding). A paused host STOPS writing <see cref=\"NetTimeOfDay\"/>.\r\n/// A client only corrects its extrapolated clock toward the host snapshot when that value CHANGES, so with\r\n/// only a time field on the wire, a paused host would leave every client free-running forever (host frozen at\r\n/// noon, clients cycling a whole day). <see cref=\"NetTimePaused\"/> fixes it: the host publishes the pause\r\n/// state on the same FromHost surface, and while it is true a client PINS its local clock to the snapshot and\r\n/// skips the advance. The instant the host unpauses, extrapolation resumes and re-locks onto the moving\r\n/// snapshot. Keep both fields on the wire together, this is the fix, do not drop it.\r\n///\r\n/// Add this component to your session/game-manager GameObject once. Drive the look with\r\n/// <see cref=\"DayNightDriver\"/> (or read <see cref=\"GetTimeHours\"/> / <see cref=\"EffectiveWeather\"/> yourself).\r\n/// </summary>\r\n[Title( \"Day Night Clock\" )]\r\n[Category( \"Field Guide\" )]\r\n[Icon( \"schedule\" )]\r\npublic sealed class DayNightClock : Component\r\n{\r\n\t/// <summary>Tuning (config over constants). Static, not replicated, set the SAME config on every peer\r\n\t/// (it is authoring data, identical everywhere, not session state). Defaults to the reference grade.</summary>\r\n\tpublic DayNightConfig Config { get; set; } = DayNightConfig.Default;\r\n\r\n\t/// <summary>The world seed the deterministic weather roll hashes against. Set it to whatever your game uses\r\n\t/// as its per-world seed so host and clients roll the same weather. Replicated so a mid-day joiner agrees.</summary>\r\n\t[Sync( SyncFlags.FromHost )] public int WorldSeed { get; set; }\r\n\r\n\t/// <summary>TOTAL game-hours since world start (dayIndex = floor(t/24), hour-of-day = t % 24). Host writes\r\n\t/// it each tick; clients observe and extrapolate. FromHost so only the host's write survives.</summary>\r\n\t[Sync( SyncFlags.FromHost )] public float NetTimeOfDay { get; set; }\r\n\r\n\t/// <summary>Pause replication (see the class remarks). The host's authority-only pause state, published on\r\n\t/// the SAME FromHost surface as <see cref=\"NetTimeOfDay\"/> so a client can tell a paused host from a slow\r\n\t/// one and stop free-running. Default false = the clock runs.</summary>\r\n\t[Sync( SyncFlags.FromHost )] public bool NetTimePaused { get; set; }\r\n\r\n\t/// <summary>Weather override: -1 = derive from the (seed, dayIndex) hash; >=0 = a forced\r\n\t/// <see cref=\"WeatherKind\"/> (a pin, or an authority carrying a specific day's roll to a late joiner).\r\n\t/// FromHost so the host owns it.</summary>\r\n\t[Sync( SyncFlags.FromHost )] public int NetWeatherOverride { get; set; } = -1;\r\n\r\n\tbool _timePaused; // authority-side pause state, mirrored to NetTimePaused every tick\r\n\tfloat _clientTimeHours; // client-side extrapolated clock (the host reads NetTimeOfDay directly)\r\n\tfloat _lastNetTime = float.NaN;// last observed NetTimeOfDay on a client (NaN \u21d2 snap on first sync)\r\n\r\n\t/// <summary>Find the clock for a scene (first one). Returns null before it exists.</summary>\r\n\tpublic static DayNightClock For( Scene scene )\r\n\t\t=> scene?.GetAllComponents<DayNightClock>().FirstOrDefault();\r\n\r\n\tstatic bool IsAuthority => !Networking.IsActive || Networking.IsHost;\r\n\r\n\tprotected override void OnStart()\r\n\t{\r\n\t\tif ( IsAuthority )\r\n\t\t{\r\n\t\t\tNetTimeOfDay = Config.StartHours;\r\n\t\t\t_timePaused = Config.StartPaused;\r\n\t\t\tNetTimePaused = _timePaused;\r\n\t\t}\r\n\t\t_clientTimeHours = NetTimeOfDay;\r\n\t}\r\n\r\n\tprotected override void OnFixedUpdate()\r\n\t{\r\n\t\t// Authority only. Clients never accumulate here, they extrapolate NetTimeOfDay in OnUpdate.\r\n\t\tif ( !IsAuthority ) return;\r\n\r\n\t\tif ( !_timePaused )\r\n\t\t{\r\n\t\t\t// Non-uniform pace: scale the base hoursPerSecond by the pure per-hour-of-day rate so daylight runs\r\n\t\t\t// slower than night. Framerate-independent (dt-scaled) and deterministic (ClockRateScale is pure), so\r\n\t\t\t// host and every client derive the same rate from the same clock.\r\n\t\t\tfloat hod = NetTimeOfDay - MathF.Floor( NetTimeOfDay / 24f ) * 24f;\r\n\t\t\tNetTimeOfDay += SkyGrade.ClockRateScale( hod, Config ) * (24f / (Config.DayLengthMinutes * 60f)) * Time.Delta;\r\n\t\t}\r\n\r\n\t\t// Mirror the pause state to the wire EVERY tick, so it tracks every _timePaused mutation (the OnStart\r\n\t\t// seed, a pin, a UI toggle) even when the clock is not advancing.\r\n\t\tNetTimePaused = _timePaused;\r\n\t}\r\n\r\n\tprotected override void OnUpdate()\r\n\t{\r\n\t\t// Only a CLIENT extrapolates. The authority reads NetTimeOfDay directly.\r\n\t\tif ( IsAuthority ) return;\r\n\r\n\t\t// Extrapolate the host clock locally between FromHost snapshots so the sun advances smoothly (snapshots\r\n\t\t// arrive at the network tick, not every frame). Same non-uniform pace as the authority (shared pure\r\n\t\t// ClockRateScale), derived from THIS client's own extrapolated clock so both peers advance identically\r\n\t\t// between snapshots; the ease-toward-net below corrects any residual drift each snapshot.\r\n\t\t//\r\n\t\t// THE PAUSE PIN: a paused host stops advancing NetTimeOfDay, and the change-gated ease below only corrects\r\n\t\t// when NetTimeOfDay MOVES, so a client that kept extrapolating would free-run forever. When the host\r\n\t\t// reports paused, PIN the local clock to the host snapshot and skip the advance; the instant the host\r\n\t\t// unpauses, extrapolation resumes and the ease re-locks onto the moving snapshot (the pinned _lastNetTime\r\n\t\t// avoids a false unpause jump).\r\n\t\tif ( NetTimePaused )\r\n\t\t{\r\n\t\t\t_clientTimeHours = NetTimeOfDay;\r\n\t\t\t_lastNetTime = NetTimeOfDay;\r\n\t\t\treturn;\r\n\t\t}\r\n\r\n\t\tfloat clientHod = _clientTimeHours - MathF.Floor( _clientTimeHours / 24f ) * 24f;\r\n\t\t_clientTimeHours += SkyGrade.ClockRateScale( clientHod, Config ) * (24f / (Config.DayLengthMinutes * 60f)) * Time.Delta;\r\n\t\tfloat net = NetTimeOfDay;\r\n\t\tif ( net != _lastNetTime )\r\n\t\t{\r\n\t\t\tbool first = float.IsNaN( _lastNetTime );\r\n\t\t\t_lastNetTime = net;\r\n\t\t\tfloat d = net - _clientTimeHours;\r\n\t\t\tif ( first || MathF.Abs( d ) > 1f ) _clientTimeHours = net; // first sync / pin jump \u2192 snap\r\n\t\t\telse _clientTimeHours += d * 0.25f; // small drift \u2192 ease\r\n\t\t}\r\n\t}\r\n\r\n\t/// <summary>The effective clock: the host reads its authoritative <see cref=\"NetTimeOfDay\"/>, a client reads\r\n\t/// its locally-extrapolated clock (smoothed toward the host snapshots). Feed this to the grade and the sky\r\n\t/// weights so they can never disagree.</summary>\r\n\tpublic float GetTimeHours()\r\n\t\t=> IsAuthority ? NetTimeOfDay : _clientTimeHours;\r\n\r\n\t/// <summary>The current day index (floor(time / 24)).</summary>\r\n\tpublic int CurrentDay => (int)MathF.Floor( GetTimeHours() / 24f );\r\n\r\n\t/// <summary>The effective weather for a day: the host override if set, else the deterministic pure roll for\r\n\t/// (<see cref=\"WorldSeed\"/>, dayIndex).</summary>\r\n\tpublic WeatherKind EffectiveWeather( int dayIndex )\r\n\t{\r\n\t\tif ( NetWeatherOverride >= 0 && System.Enum.IsDefined( typeof( WeatherKind ), NetWeatherOverride ) )\r\n\t\t\treturn (WeatherKind)NetWeatherOverride;\r\n\t\treturn WeatherRoll.For( WorldSeed, dayIndex );\r\n\t}\r\n\r\n\t/// <summary>The effective weather RIGHT NOW.</summary>\r\n\tpublic WeatherKind CurrentWeather => EffectiveWeather( CurrentDay );\r\n\r\n\t// \u2500\u2500 authority-guarded writes (a client call is a quiet no-op; only the host's write survives the FromHost sync) \u2500\u2500\r\n\r\n\t/// <summary>Set the clock to an hour-of-day, preserving the current day (so weather does not re-roll). Snaps\r\n\t/// the client extrapolation trackers so a joiner does not ease across a deliberate jump. Authority only.</summary>\r\n\tpublic void SetTimeOfDay( float hourOfDay )\r\n\t{\r\n\t\tif ( Networking.IsActive && !Networking.IsHost ) return;\r\n\t\tNetTimeOfDay = TimeMath.ComputeSetHour( NetTimeOfDay, hourOfDay );\r\n\t\t_clientTimeHours = NetTimeOfDay;\r\n\t\t_lastNetTime = NetTimeOfDay;\r\n\t}\r\n\r\n\t/// <summary>Nudge the clock by a signed delta in game-hours (clamped at 0). Authority only.</summary>\r\n\tpublic void NudgeTime( float deltaHours )\r\n\t{\r\n\t\tif ( Networking.IsActive && !Networking.IsHost ) return;\r\n\t\tNetTimeOfDay = MathF.Max( 0f, NetTimeOfDay + deltaHours );\r\n\t\t_clientTimeHours = NetTimeOfDay;\r\n\t\t_lastNetTime = NetTimeOfDay;\r\n\t}\r\n\r\n\t/// <summary>Is the clock paused (authority-side)?</summary>\r\n\tpublic bool TimePaused => _timePaused;\r\n\r\n\t/// <summary>Pause or resume the clock. Authority only; the pause state replicates on the FromHost surface so\r\n\t/// clients stop free-running (see the class remarks).</summary>\r\n\tpublic void SetPaused( bool paused )\r\n\t{\r\n\t\tif ( Networking.IsActive && !Networking.IsHost ) return;\r\n\t\t_timePaused = paused;\r\n\t\tNetTimePaused = paused;\r\n\t}\r\n\r\n\t/// <summary>Force a weather kind (>=0) or clear back to the deterministic hash roll (-1). Authority only.</summary>\r\n\tpublic void SetWeatherOverride( int weather )\r\n\t{\r\n\t\tif ( Networking.IsActive && !Networking.IsHost ) return;\r\n\t\tNetWeatherOverride = ( weather >= 0 && System.Enum.IsDefined( typeof( WeatherKind ), weather ) ) ? weather : -1;\r\n\t}\r\n}\r\n"
},
{
"Ident": "fieldguide.daynight",
"Path": "Code/Ui/DayNightPanel.razor.scss",
"FileName": "DayNightPanel.razor.scss",
"PackageType": "library",
"CodeKind": "Game",
"AssetVersionId": 337909,
"Code": "// ================================================================================================\r\n// FIELD KIT UI SYSTEM \u00b7 Day / Night Kit \u00b7 time panel\r\n//\r\n// SOURCE OF TRUTH: docs/design/ui-system/tokens.dc.html\r\n// (+ components.dc.html for the parts, daynight-kit.dc.html for this screen)\r\n//\r\n// DRIFT NOTE. s&box cannot express a library-to-library dependency, so a kit must never @import\r\n// another kit's stylesheet. Every kit therefore carries its OWN copy of the token values below.\r\n// Nothing syncs them. If a value moves on the tokens page, hand-update it here AND in every other\r\n// kit's .razor.scss, then re-check the kits side by side.\r\n//\r\n// ENGINE-LEGAL SUBSET. The mockups are browser HTML and contain CSS this engine cannot parse. The\r\n// translations, all applied below:\r\n// \u00b7 never `border: 1px solid x` -> border-width + border-color only. border-style is a parse\r\n// error that aborts the WHOLE stylesheet and collapses the panel to 0x0.\r\n// \u00b7 never box-shadow. All depth comes from the alpha surfaces.\r\n// \u00b7 never letter-spacing. Hierarchy is size, weight and case.\r\n// \u00b7 never the `inset` shorthand -> top/left/width/height, expanded.\r\n// \u00b7 never a percent max-height on an absolute card, and no glyph outside the shipped font\r\n// anywhere (the mockups' dropdown caret included): it renders as tofu.\r\n// \u00b7 explicit px line-heights, never unitless ratios.\r\n// \u00b7 font sizes come only from {12, 13, 14, 16}, plus 20 for the hero clock readout and nothing\r\n// else (the tokens page reserves 20 for exactly that). Five distinct sizes against a budget of\r\n// eight per panel assembly.\r\n//\r\n// FONT DECLARATIONS ARE INLINE AND UNQUOTED, AND THAT IS LOAD-BEARING. Write\r\n// `font-family: Poppins, sans-serif;` and `font-family: Roboto Mono, monospace;` literally at every\r\n// site. A SCSS variable holding the family, or a quoted family name, does not survive this engine's\r\n// stylesheet parse: the rule is dropped and the panel silently falls back to the default face. This\r\n// cost a live debugging session; do not \"tidy\" these into a $token.\r\n//\r\n// CONTRAST LAW (overrides the mockups wherever they are dimmer). Nothing a player reads to operate\r\n// a control is dimmer than #E8EAED. Key badges are pure #FFFFFF at weight 700. The 42px close x is\r\n// present and is the ONLY close affordance: no ESC badge, ever.\r\n//\r\n// SLIDER MECHANIC (components.dc.html, fk-slider-row). .dn-hit is a transparent 7px-padded wrapper\r\n// so the grab area is 28px tall rather than the 14px the track draws; .dn-track is the visible pill\r\n// and .dn-fill is an absolutely positioned, pointer-events:none decoration inside it. Wrapper and\r\n// track BOTH own pointer events and both carry the drag handlers: they share a left edge and a\r\n// width, so whichever one the cursor lands on computes the same fraction, and the bubbled duplicate\r\n// call writes the same value twice. Do not \"simplify\" the pair away; the fill must never resize the\r\n// row and the drag must be measured against the track, not the fill.\r\n// ================================================================================================\r\n\r\n// ---- base tokens (shared across kits, copied per kit) ----\r\n$fk-panel-bg: rgba( 15, 17, 21, 0.92 );\r\n$fk-border: rgba( 255, 255, 255, 0.08 );\r\n$fk-border-hi: rgba( 255, 255, 255, 0.12 );\r\n$fk-row: rgba( 255, 255, 255, 0.05 );\r\n$fk-row-2: rgba( 255, 255, 255, 0.06 );\r\n$fk-hover: rgba( 255, 255, 255, 0.10 );\r\n$fk-track: rgba( 255, 255, 255, 0.12 );\r\n$fk-track-hover: rgba( 255, 255, 255, 0.16 );\r\n\r\n$fk-text-hi: #F2F4F7;\r\n$fk-text: #E8EAED;\r\n$fk-key-glyph: #FFFFFF;\r\n\r\n// ---- kit accent (Day / Night Kit \u00b7 hue 300) ----\r\n$fk-accent: #C9AEF2;\r\n$fk-accent-hover: #D4BEF6;\r\n$fk-accent-ink: #140A1A;\r\n\r\nDayNightPanel {\r\n\tposition: absolute;\r\n\ttop: 0px;\r\n\tleft: 0px;\r\n\twidth: 100%;\r\n\theight: 100%;\r\n\tpointer-events: none;\r\n\tfont-family: Poppins, sans-serif;\r\n\r\n\t// The card is the only thing that takes clicks, so the cursor stays usable over the panel while\r\n\t// the rest of the scene ignores it.\r\n\t.dn-card {\r\n\t\tposition: absolute;\r\n\t\ttop: 40px;\r\n\t\tright: 40px;\r\n\t\twidth: 420px;\r\n\t\tflex-direction: column;\r\n\t\tgap: 12px;\r\n\t\tpadding: 20px;\r\n\t\tpointer-events: all;\r\n\t\tbackground-color: $fk-panel-bg;\r\n\t\tborder-width: 1px;\r\n\t\tborder-color: $fk-border;\r\n\t\tborder-radius: 16px;\r\n\t\tcolor: $fk-text;\r\n\t}\r\n\r\n\t// ---- header ----\r\n\t.dn-hdr {\r\n\t\tflex-direction: row;\r\n\t\tjustify-content: space-between;\r\n\t\talign-items: center;\r\n\t}\r\n\t.dn-title {\r\n\t\tfont-size: 16px;\r\n\t\tline-height: 22px;\r\n\t\tfont-weight: 700;\r\n\t\tcolor: $fk-text-hi;\r\n\t}\r\n\t.dn-hr {\r\n\t\tflex-direction: row;\r\n\t\talign-items: center;\r\n\t\tgap: 4px;\r\n\t}\r\n\t.dn-key {\r\n\t\tfont-family: Roboto Mono, monospace;\r\n\t\tfont-size: 14px;\r\n\t\tline-height: 20px;\r\n\t\tfont-weight: 700;\r\n\t\tcolor: $fk-key-glyph;\r\n\t\tbackground-color: $fk-row;\r\n\t\tborder-width: 1px;\r\n\t\tborder-color: $fk-border-hi;\r\n\t\tborder-radius: 5px;\r\n\t\tpadding: 3px 8px;\r\n\t\tflex-shrink: 0;\r\n\t}\r\n\t// 42px hit target on a 16px glyph (owner ruling: 34px is too small to hit). The negative margins\r\n\t// pull the box back into the 20px card padding so the header stays compact.\r\n\t.dn-x {\r\n\t\tfont-size: 16px;\r\n\t\tline-height: 22px;\r\n\t\tcolor: $fk-text-hi;\r\n\t\twidth: 42px;\r\n\t\theight: 42px;\r\n\t\tmargin: -8px -12px -8px 0px;\r\n\t\tjustify-content: center;\r\n\t\talign-items: center;\r\n\t\tborder-radius: 6px;\r\n\t\tcursor: pointer;\r\n\t\tpointer-events: all;\r\n\t\ttransition: all 0.12s ease;\r\n\t\t&:hover { background-color: $fk-hover; }\r\n\t}\r\n\r\n\t.dn-empty {\r\n\t\tfont-size: 13px;\r\n\t\tline-height: 20px;\r\n\t\tcolor: $fk-text;\r\n\t}\r\n\r\n\t// ---- hero clock readout (the one 20px type site in the kit) ----\r\n\t.dn-hero {\r\n\t\tflex-direction: row;\r\n\t\tjustify-content: space-between;\r\n\t\talign-items: center;\r\n\t\tbackground-color: $fk-row;\r\n\t\tborder-radius: 10px;\r\n\t\tpadding: 12px 14px;\r\n\t}\r\n\t.dn-hl {\r\n\t\tfont-size: 13px;\r\n\t\tline-height: 20px;\r\n\t\tcolor: $fk-text-hi;\r\n\t}\r\n\t.dn-hv {\r\n\t\tfont-family: Roboto Mono, monospace;\r\n\t\tfont-size: 20px;\r\n\t\tline-height: 26px;\r\n\t\tfont-weight: 600;\r\n\t\tcolor: $fk-text-hi;\r\n\t}\r\n\r\n\t// ---- kicker line: day index and where the weather came from ----\r\n\t// Wrap row of short atomic runs, each one nowrap and unshrinkable, so nothing splits mid-word.\r\n\t// Single-value gap on purpose. The tokens page asks for 3px between wrapped rows and 8px between\r\n\t// runs, but nothing shipped in these kits uses the two-value `gap: 3px 8px` form and this engine's\r\n\t// parser is not proven on it. One value is the safe subset; 8px both ways reads fine.\r\n\t.dn-meta {\r\n\t\tflex-direction: row;\r\n\t\tflex-wrap: wrap;\r\n\t\tgap: 8px;\r\n\t}\r\n\t.dn-mk {\r\n\t\tfont-family: Roboto Mono, monospace;\r\n\t\tfont-size: 12px;\r\n\t\tline-height: 16px;\r\n\t\tfont-weight: 500;\r\n\t\tcolor: $fk-text;\r\n\t\twhite-space: nowrap;\r\n\t\tflex-shrink: 0;\r\n\t}\r\n\r\n\t// ---- label + value + slider rows ----\r\n\t.dn-row {\r\n\t\tflex-direction: column;\r\n\t\tgap: 6px;\r\n\t}\r\n\t.dn-rlab {\r\n\t\tflex-direction: row;\r\n\t\tjustify-content: space-between;\r\n\t\talign-items: center;\r\n\t}\r\n\t.dn-rl {\r\n\t\tfont-size: 13px;\r\n\t\tline-height: 20px;\r\n\t\tcolor: $fk-text-hi;\r\n\t}\r\n\t.dn-rv {\r\n\t\tfont-family: Roboto Mono, monospace;\r\n\t\tfont-size: 14px;\r\n\t\tline-height: 20px;\r\n\t\tcolor: $fk-text;\r\n\t}\r\n\r\n\t.dn-slider {\r\n\t\tflex-direction: row;\r\n\t\talign-items: center;\r\n\t\tgap: 10px;\r\n\t}\r\n\t.dn-stp {\r\n\t\tfont-family: Roboto Mono, monospace;\r\n\t\tfont-size: 14px;\r\n\t\tline-height: 20px;\r\n\t\tcolor: $fk-text-hi;\r\n\t\twidth: 28px;\r\n\t\theight: 28px;\r\n\t\tflex-shrink: 0;\r\n\t\tjustify-content: center;\r\n\t\talign-items: center;\r\n\t\tbackground-color: $fk-row-2;\r\n\t\tborder-width: 1px;\r\n\t\tborder-color: $fk-hover;\r\n\t\tborder-radius: 6px;\r\n\t\tcursor: pointer;\r\n\t\ttransition: all 0.12s ease;\r\n\t\t&:hover {\r\n\t\t\tcolor: $fk-accent-ink;\r\n\t\t\tbackground-color: $fk-accent;\r\n\t\t}\r\n\t}\r\n\t// Transparent grab wrapper: 14px track plus 7px above and below = the 28px hit area the system\r\n\t// asks for. Horizontal padding is zero on purpose so its width equals the track's exactly.\r\n\t.dn-hit {\r\n\t\tflex-grow: 1;\r\n\t\tflex-direction: column;\r\n\t\tjustify-content: center;\r\n\t\tpadding: 7px 0px;\r\n\t\tpointer-events: all;\r\n\t\tcursor: pointer;\r\n\t}\r\n\t.dn-track {\r\n\t\tposition: relative;\r\n\t\twidth: 100%;\r\n\t\theight: 14px;\r\n\t\tborder-radius: 99px;\r\n\t\tbackground-color: $fk-track;\r\n\t\tpointer-events: all;\r\n\t\tcursor: pointer;\r\n\t\ttransition: all 0.12s ease;\r\n\t\t&:hover { background-color: $fk-track-hover; }\r\n\r\n\t\t.dn-fill {\r\n\t\t\tposition: absolute;\r\n\t\t\tleft: 0px;\r\n\t\t\ttop: 0px;\r\n\t\t\theight: 100%;\r\n\t\t\tmin-width: 14px;\r\n\t\t\tborder-radius: 99px;\r\n\t\t\tbackground-color: $fk-accent;\r\n\t\t\tpointer-events: none;\r\n\t\t}\r\n\t}\r\n\r\n\t// ---- jump-to pill chips ----\r\n\t.dn-chips {\r\n\t\tflex-direction: row;\r\n\t\tflex-wrap: wrap;\r\n\t\tgap: 6px;\r\n\t}\r\n\t.dn-chip {\r\n\t\tfont-family: Roboto Mono, monospace;\r\n\t\tfont-size: 13px;\r\n\t\tline-height: 20px;\r\n\t\tfont-weight: 600;\r\n\t\tcolor: $fk-text-hi;\r\n\t\tbackground-color: $fk-track;\r\n\t\tborder-radius: 99px;\r\n\t\tpadding: 5px 14px;\r\n\t\twhite-space: nowrap;\r\n\t\tflex-shrink: 0;\r\n\t\tcursor: pointer;\r\n\t\ttransition: all 0.12s ease;\r\n\t\t&:hover { background-color: $fk-hover; }\r\n\t\t&.on {\r\n\t\t\tcolor: $fk-accent-ink;\r\n\t\t\tbackground-color: $fk-accent;\r\n\t\t\t&:hover { background-color: $fk-accent-hover; }\r\n\t\t}\r\n\t}\r\n\r\n\t// ---- segmented groups (weather, run/pause) ----\r\n\t.dn-seg-group {\r\n\t\tflex-direction: row;\r\n\t\talign-items: center;\r\n\t\tbackground-color: $fk-row;\r\n\t\tborder-radius: 99px;\r\n\t\tpadding: 3px;\r\n\t}\r\n\t// Weather fills the card width, so its segments share the row evenly.\r\n\t.dn-seg-group.wide {\r\n\t\twidth: 100%;\r\n\t}\r\n\t.dn-seg {\r\n\t\tfont-family: Roboto Mono, monospace;\r\n\t\tfont-size: 13px;\r\n\t\tline-height: 20px;\r\n\t\tfont-weight: 600;\r\n\t\tcolor: $fk-text-hi;\r\n\t\tjustify-content: center;\r\n\t\talign-items: center;\r\n\t\tborder-radius: 99px;\r\n\t\tpadding: 5px 16px;\r\n\t\twhite-space: nowrap;\r\n\t\tcursor: pointer;\r\n\t\ttransition: all 0.12s ease;\r\n\t\t&:hover { background-color: $fk-hover; }\r\n\t\t&.on {\r\n\t\t\tcolor: $fk-accent-ink;\r\n\t\t\tbackground-color: $fk-accent;\r\n\t\t\t&:hover { background-color: $fk-accent-hover; }\r\n\t\t}\r\n\t\t&.grow { flex-grow: 1; }\r\n\r\n\t\t// The run / pause pair sits inline beside its label rather than filling the card, and the\r\n\t\t// mockup gives that tighter pair 4px of vertical padding against the wide group's 5px.\r\n\t\t&.tight { padding: 4px 16px; }\r\n\t}\r\n\r\n\t// Label beside an inline control (the run / pause row).\r\n\t.dn-inline {\r\n\t\tflex-direction: row;\r\n\t\tjustify-content: space-between;\r\n\t\talign-items: center;\r\n\t}\r\n\r\n\t// ---- a read-only advisory (shown on a client, where the host owns the clock) ----\r\n\t.dn-note {\r\n\t\tfont-size: 13px;\r\n\t\tline-height: 20px;\r\n\t\tcolor: $fk-text;\r\n\t\tbackground-color: $fk-row;\r\n\t\tborder-radius: 10px;\r\n\t\tpadding: 9px 12px;\r\n\t}\r\n\r\n\t// ---- actions ----\r\n\t.dn-btns {\r\n\t\tflex-direction: row;\r\n\t\tgap: 8px;\r\n\t\tborder-top-width: 1px;\r\n\t\tborder-top-color: $fk-border;\r\n\t\tpadding-top: 12px;\r\n\t}\r\n\t.dn-btn {\r\n\t\tfont-size: 13px;\r\n\t\tline-height: 20px;\r\n\t\tfont-weight: 600;\r\n\t\tcolor: $fk-text-hi;\r\n\t\tbackground-color: $fk-row-2;\r\n\t\tborder-width: 1px;\r\n\t\tborder-color: $fk-hover;\r\n\t\tborder-radius: 10px;\r\n\t\tpadding: 10px 0px;\r\n\t\tflex-grow: 1;\r\n\t\tjustify-content: center;\r\n\t\talign-items: center;\r\n\t\tcursor: pointer;\r\n\t\ttransition: all 0.12s ease;\r\n\t\t&:hover { background-color: $fk-hover; }\r\n\r\n\t\t// Copy config is the primary action on this card: accent fill, ink text, no hairline.\r\n\t\t&.primary {\r\n\t\t\tcolor: $fk-accent-ink;\r\n\t\t\tbackground-color: $fk-accent;\r\n\t\t\tborder-color: $fk-accent;\r\n\t\t\t&:hover { background-color: $fk-accent-hover; }\r\n\t\t}\r\n\t}\r\n}\r\n"
},
{
"Ident": "fieldguide.daynight",
"Path": "Code/WeatherKind.cs",
"FileName": "WeatherKind.cs",
"PackageType": "library",
"CodeKind": "Game",
"AssetVersionId": 337909,
"Code": "using System;\r\n\r\nnamespace FieldGuide.DayNight;\r\n\r\n/// <summary>Weather kinds (visual-only). Rolled per in-game day as a PURE hash of (seed, dayIndex), so a host\r\n/// and every observer agree on the day's weather from the replicated seed + clock alone, with no extra\r\n/// networking. The int order is the wire/override value: an authority pin publishes the forced kind as its\r\n/// int, and any deserializer maps back through this enum.</summary>\r\npublic enum WeatherKind\r\n{\r\n\tClear = 0,\r\n\tCloudy = 1,\r\n\tRain = 2,\r\n}\r\n\r\n/// <summary>The deterministic per-day weather roll. Pure and self-contained: no DateTime, no System.Random,\r\n/// just an FNV-1a hash of the world seed and the day index bucketed into the three kinds. Same inputs always\r\n/// yield the same kind, so two peers deriving weather from the same seed never disagree and the roll never\r\n/// flaps mid-day.</summary>\r\npublic static class WeatherRoll\r\n{\r\n\t/// <summary>Roll the weather for a given (world seed, day index). Distribution: ~60% Clear, ~25% Cloudy,\r\n\t/// ~15% Rain. Byte-stable and deterministic; the hash (offset basis, prime, salt) is ported unchanged from\r\n\t/// the source game, so a save that recorded a WB day rolls the same kind here.</summary>\r\n\tpublic static WeatherKind For( int seed, int dayIndex )\r\n\t{\r\n\t\tulong h = 1469598103934665603UL; // FNV-1a offset basis\r\n\t\tvoid Mix( long v )\r\n\t\t{\r\n\t\t\tfor ( int i = 0; i < 8; i++ ) { h ^= (byte)(v >> (i * 8)); h *= 1099511628211UL; }\r\n\t\t}\r\n\t\tMix( seed );\r\n\t\tMix( dayIndex );\r\n\t\tMix( 0x5713_9A2FL ); // fixed salt so dayIndex 0 isn't a bare seed hash (ported verbatim)\r\n\t\tint r = (int)(h % 100);\r\n\t\tif ( r < 60 ) return WeatherKind.Clear; // ~60% clear, ~25% cloudy, ~15% rain\r\n\t\tif ( r < 85 ) return WeatherKind.Cloudy;\r\n\t\treturn WeatherKind.Rain;\r\n\t}\r\n}\r\n"
},
{
"Ident": "fieldguide.daynight",
"Path": "Code/Demo/DayNightHintCard.razor.scss",
"FileName": "DayNightHintCard.razor.scss",
"PackageType": "library",
"CodeKind": "Game",
"AssetVersionId": 337909,
"Code": "// ================================================================================================\r\n// FIELD KIT UI SYSTEM \u00b7 Day / Night Kit \u00b7 demo key-hint card\r\n//\r\n// SOURCE OF TRUTH: docs/design/ui-system/tokens.dc.html\r\n// (+ components.dc.html for the parts, daynight-kit.dc.html for this screen)\r\n//\r\n// DRIFT NOTE. s&box cannot express a library-to-library dependency, so a kit must never @import\r\n// another kit's stylesheet. Every kit carries its OWN copy of the token values below, and this\r\n// kit's two panels each carry a copy. Nothing syncs them: if a value moves on the tokens page,\r\n// hand-update it here, in Ui/DayNightPanel.razor.scss, and in every other kit.\r\n//\r\n// ENGINE-LEGAL SUBSET (the mockups are browser HTML and contain CSS this engine cannot parse):\r\n// border-width + border-color only, never `border: 1px solid x` (border-style is a parse error\r\n// that aborts the whole stylesheet); no box-shadow; no letter-spacing; no `inset` shorthand; no\r\n// percent max-height on an absolute card; explicit px line-heights; font sizes only from\r\n// {12, 13, 14, 16}.\r\n//\r\n// FONT DECLARATIONS ARE INLINE AND UNQUOTED, AND THAT IS LOAD-BEARING. `font-family: Poppins,\r\n// sans-serif;` and `font-family: Roboto Mono, monospace;` written out at every site. A SCSS\r\n// variable holding the family, or a quoted family name, does not survive this engine's stylesheet\r\n// parse: the rule is dropped and the card silently falls back to the default face.\r\n//\r\n// CONTRAST LAW (overrides the mockups wherever they are dimmer): nothing a player reads is dimmer\r\n// than #E8EAED, key badges are pure #FFFFFF at weight 700. The 42px close x is the only close\r\n// affordance on this card; there is no ESC badge, here or anywhere.\r\n// ================================================================================================\r\n\r\n// ---- base tokens (shared across kits, copied per kit) ----\r\n$fk-panel-bg: rgba( 15, 17, 21, 0.92 );\r\n$fk-border: rgba( 255, 255, 255, 0.08 );\r\n$fk-border-hi: rgba( 255, 255, 255, 0.12 );\r\n$fk-row: rgba( 255, 255, 255, 0.05 );\r\n$fk-hover: rgba( 255, 255, 255, 0.10 );\r\n\r\n$fk-text-hi: #F2F4F7;\r\n$fk-text: #E8EAED;\r\n$fk-key-glyph: #FFFFFF;\r\n\r\n// No accent token here on purpose: this card is entirely neutral, the way the placement kit's hint\r\n// card is. Tinting the key chips would compete with the time panel, which is where the violet\r\n// (#C9AEF2) does its work.\r\n\r\nDayNightHintCard {\r\n\tposition: absolute;\r\n\ttop: 0px;\r\n\tleft: 0px;\r\n\twidth: 100%;\r\n\theight: 100%;\r\n\tpointer-events: none;\r\n\tfont-family: Poppins, sans-serif;\r\n\r\n\t// Only the card takes clicks, so the scene keeps the cursor everywhere else.\r\n\t.dh-card {\r\n\t\tposition: absolute;\r\n\t\ttop: 40px;\r\n\t\tleft: 40px;\r\n\t\twidth: 440px;\r\n\t\tflex-direction: column;\r\n\t\tgap: 12px;\r\n\t\tpadding: 20px;\r\n\t\tpointer-events: all;\r\n\t\tbackground-color: $fk-panel-bg;\r\n\t\tborder-width: 1px;\r\n\t\tborder-color: $fk-border;\r\n\t\tborder-radius: 16px;\r\n\t\tcolor: $fk-text;\r\n\t}\r\n\r\n\t.dh-hdr {\r\n\t\tflex-direction: row;\r\n\t\tjustify-content: space-between;\r\n\t\talign-items: center;\r\n\t}\r\n\t.dh-title {\r\n\t\tfont-size: 16px;\r\n\t\tline-height: 22px;\r\n\t\tfont-weight: 700;\r\n\t\tcolor: $fk-text-hi;\r\n\t}\r\n\t// 42px hit target on a 16px glyph, matching the time panel. The negative margins pull it back into\r\n\t// the card's 20px padding so the header row stays compact.\r\n\t.dh-x {\r\n\t\tfont-size: 16px;\r\n\t\tline-height: 22px;\r\n\t\tcolor: $fk-text-hi;\r\n\t\twidth: 42px;\r\n\t\theight: 42px;\r\n\t\tmargin: -8px -12px -8px 0px;\r\n\t\tjustify-content: center;\r\n\t\talign-items: center;\r\n\t\tborder-radius: 6px;\r\n\t\tcursor: pointer;\r\n\t\tpointer-events: all;\r\n\t\ttransition: all 0.12s ease;\r\n\t\t&:hover { background-color: $fk-hover; }\r\n\t}\r\n\r\n\t.dh-lede {\r\n\t\tfont-size: 13px;\r\n\t\tline-height: 20px;\r\n\t\tcolor: $fk-text;\r\n\t}\r\n\r\n\t.dh-rows {\r\n\t\tflex-direction: column;\r\n\t\tgap: 8px;\r\n\t}\r\n\t.dh-row {\r\n\t\tflex-direction: row;\r\n\t\t// flex-start, not centre: the longer hints wrap to two lines, and a key chip floating half\r\n\t\t// way down its own explanation is the one place this card stops looking like the mockup.\r\n\t\t// Same correction the placement kit's hint card carries.\r\n\t\talign-items: flex-start;\r\n\t\tgap: 12px;\r\n\t}\r\n\t// Fixed-width chip so every key column lines up and a chip never splits across a wrapped line.\r\n\t// Width is the whole box (no horizontal padding), which keeps the column exact.\r\n\t.dh-key {\r\n\t\tfont-family: Roboto Mono, monospace;\r\n\t\tfont-size: 14px;\r\n\t\tline-height: 20px;\r\n\t\tfont-weight: 700;\r\n\t\tcolor: $fk-key-glyph;\r\n\t\twidth: 110px;\r\n\t\tflex-shrink: 0;\r\n\t\tpadding: 3px 0px;\r\n\t\tjustify-content: center;\r\n\t\talign-items: center;\r\n\t\tbackground-color: $fk-row;\r\n\t\tborder-width: 1px;\r\n\t\tborder-color: $fk-border-hi;\r\n\t\tborder-radius: 5px;\r\n\t}\r\n\t.dh-what {\r\n\t\tfont-size: 13px;\r\n\t\tline-height: 20px;\r\n\t\tcolor: $fk-text;\r\n\t\tflex-grow: 1;\r\n\t}\r\n\r\n\t// Live status strip: short atomic mono runs, each unshrinkable and nowrap, so a wrap never splits\r\n\t// one mid-word.\r\n\t// Single-value gap on purpose: nothing shipped in these kits uses the two-value `gap: 3px 8px` form\r\n\t// and this engine's parser is not proven on it. One value is the safe subset.\r\n\t.dh-live {\r\n\t\tflex-direction: row;\r\n\t\tflex-wrap: wrap;\r\n\t\tgap: 8px;\r\n\t\tborder-top-width: 1px;\r\n\t\tborder-top-color: $fk-border;\r\n\t\tpadding-top: 12px;\r\n\t}\r\n\t.dh-lk {\r\n\t\tfont-family: Roboto Mono, monospace;\r\n\t\tfont-size: 12px;\r\n\t\tline-height: 16px;\r\n\t\tfont-weight: 500;\r\n\t\tcolor: $fk-text;\r\n\t\twhite-space: nowrap;\r\n\t\tflex-shrink: 0;\r\n\t}\r\n\r\n\t.dh-foot {\r\n\t\tfont-size: 13px;\r\n\t\tline-height: 20px;\r\n\t\tcolor: $fk-text;\r\n\t}\r\n}\r\n"
},
{
"Ident": "fieldguide.daynight",
"Path": "Code/DayNightClock.cs",
"FileName": "DayNightClock.cs",
"PackageType": "library",
"CodeKind": "Game",
"AssetVersionId": 337909,
"Code": "using System;\r\nusing System.Linq;\r\nusing Sandbox;\r\n\r\nnamespace FieldGuide.DayNight;\r\n\r\n/// <summary>\r\n/// The host-authoritative day/night clock: the ONE thin networked surface in this kit. Everything else\r\n/// (grading, weights, weather, time-set math) is pure and testable; this component is the small, honest\r\n/// piece that cannot run headless because it depends on s&box networking.\r\n///\r\n/// Ownership model: in a peer-hosted s&box session the HOST owns the clock. It accumulates game-time each\r\n/// fixed tick and publishes three <c>[Sync(SyncFlags.FromHost)]</c> fields; clients never write them, they\r\n/// observe and locally extrapolate so the sun advances smoothly between snapshots. Single-player (networking\r\n/// inactive) is the host-of-one and just reads its own field directly.\r\n///\r\n/// THE PAUSE PIN (the hardening worth understanding). A paused host STOPS writing <see cref=\"NetTimeOfDay\"/>.\r\n/// A client only corrects its extrapolated clock toward the host snapshot when that value CHANGES, so with\r\n/// only a time field on the wire, a paused host would leave every client free-running forever (host frozen at\r\n/// noon, clients cycling a whole day). <see cref=\"NetTimePaused\"/> fixes it: the host publishes the pause\r\n/// state on the same FromHost surface, and while it is true a client PINS its local clock to the snapshot and\r\n/// skips the advance. The instant the host unpauses, extrapolation resumes and re-locks onto the moving\r\n/// snapshot. Keep both fields on the wire together, this is the fix, do not drop it.\r\n///\r\n/// Add this component to your session/game-manager GameObject once. Drive the look with\r\n/// <see cref=\"DayNightDriver\"/> (or read <see cref=\"GetTimeHours\"/> / <see cref=\"EffectiveWeather\"/> yourself).\r\n/// </summary>\r\n[Title( \"Day Night Clock\" )]\r\n[Category( \"Field Guide\" )]\r\n[Icon( \"schedule\" )]\r\npublic sealed class DayNightClock : Component\r\n{\r\n\t/// <summary>Tuning (config over constants). Static, not replicated, set the SAME config on every peer\r\n\t/// (it is authoring data, identical everywhere, not session state). Defaults to the reference grade.</summary>\r\n\tpublic DayNightConfig Config { get; set; } = DayNightConfig.Default;\r\n\r\n\t/// <summary>The world seed the deterministic weather roll hashes against. Set it to whatever your game uses\r\n\t/// as its per-world seed so host and clients roll the same weather. Replicated so a mid-day joiner agrees.</summary>\r\n\t[Sync( SyncFlags.FromHost )] public int WorldSeed { get; set; }\r\n\r\n\t/// <summary>TOTAL game-hours since world start (dayIndex = floor(t/24), hour-of-day = t % 24). Host writes\r\n\t/// it each tick; clients observe and extrapolate. FromHost so only the host's write survives.</summary>\r\n\t[Sync( SyncFlags.FromHost )] public float NetTimeOfDay { get; set; }\r\n\r\n\t/// <summary>Pause replication (see the class remarks). The host's authority-only pause state, published on\r\n\t/// the SAME FromHost surface as <see cref=\"NetTimeOfDay\"/> so a client can tell a paused host from a slow\r\n\t/// one and stop free-running. Default false = the clock runs.</summary>\r\n\t[Sync( SyncFlags.FromHost )] public bool NetTimePaused { get; set; }\r\n\r\n\t/// <summary>Weather override: -1 = derive from the (seed, dayIndex) hash; >=0 = a forced\r\n\t/// <see cref=\"WeatherKind\"/> (a pin, or an authority carrying a specific day's roll to a late joiner).\r\n\t/// FromHost so the host owns it.</summary>\r\n\t[Sync( SyncFlags.FromHost )] public int NetWeatherOverride { get; set; } = -1;\r\n\r\n\tbool _timePaused; // authority-side pause state, mirrored to NetTimePaused every tick\r\n\tfloat _clientTimeHours; // client-side extrapolated clock (the host reads NetTimeOfDay directly)\r\n\tfloat _lastNetTime = float.NaN;// last observed NetTimeOfDay on a client (NaN \u21d2 snap on first sync)\r\n\r\n\t/// <summary>Find the clock for a scene (first one). Returns null before it exists.</summary>\r\n\tpublic static DayNightClock For( Scene scene )\r\n\t\t=> scene?.GetAllComponents<DayNightClock>().FirstOrDefault();\r\n\r\n\tstatic bool IsAuthority => !Networking.IsActive || Networking.IsHost;\r\n\r\n\tprotected override void OnStart()\r\n\t{\r\n\t\tif ( IsAuthority )\r\n\t\t{\r\n\t\t\tNetTimeOfDay = Config.StartHours;\r\n\t\t\t_timePaused = Config.StartPaused;\r\n\t\t\tNetTimePaused = _timePaused;\r\n\t\t}\r\n\t\t_clientTimeHours = NetTimeOfDay;\r\n\t}\r\n\r\n\tprotected override void OnFixedUpdate()\r\n\t{\r\n\t\t// Authority only. Clients never accumulate here, they extrapolate NetTimeOfDay in OnUpdate.\r\n\t\tif ( !IsAuthority ) return;\r\n\r\n\t\tif ( !_timePaused )\r\n\t\t{\r\n\t\t\t// Non-uniform pace: scale the base hoursPerSecond by the pure per-hour-of-day rate so daylight runs\r\n\t\t\t// slower than night. Framerate-independent (dt-scaled) and deterministic (ClockRateScale is pure), so\r\n\t\t\t// host and every client derive the same rate from the same clock.\r\n\t\t\tfloat hod = NetTimeOfDay - MathF.Floor( NetTimeOfDay / 24f ) * 24f;\r\n\t\t\tNetTimeOfDay += SkyGrade.ClockRateScale( hod, Config ) * (24f / (Config.DayLengthMinutes * 60f)) * Time.Delta;\r\n\t\t}\r\n\r\n\t\t// Mirror the pause state to the wire EVERY tick, so it tracks every _timePaused mutation (the OnStart\r\n\t\t// seed, a pin, a UI toggle) even when the clock is not advancing.\r\n\t\tNetTimePaused = _timePaused;\r\n\t}\r\n\r\n\tprotected override void OnUpdate()\r\n\t{\r\n\t\t// Only a CLIENT extrapolates. The authority reads NetTimeOfDay directly.\r\n\t\tif ( IsAuthority ) return;\r\n\r\n\t\t// Extrapolate the host clock locally between FromHost snapshots so the sun advances smoothly (snapshots\r\n\t\t// arrive at the network tick, not every frame). Same non-uniform pace as the authority (shared pure\r\n\t\t// ClockRateScale), derived from THIS client's own extrapolated clock so both peers advance identically\r\n\t\t// between snapshots; the ease-toward-net below corrects any residual drift each snapshot.\r\n\t\t//\r\n\t\t// THE PAUSE PIN: a paused host stops advancing NetTimeOfDay, and the change-gated ease below only corrects\r\n\t\t// when NetTimeOfDay MOVES, so a client that kept extrapolating would free-run forever. When the host\r\n\t\t// reports paused, PIN the local clock to the host snapshot and skip the advance; the instant the host\r\n\t\t// unpauses, extrapolation resumes and the ease re-locks onto the moving snapshot (the pinned _lastNetTime\r\n\t\t// avoids a false unpause jump).\r\n\t\tif ( NetTimePaused )\r\n\t\t{\r\n\t\t\t_clientTimeHours = NetTimeOfDay;\r\n\t\t\t_lastNetTime = NetTimeOfDay;\r\n\t\t\treturn;\r\n\t\t}\r\n\r\n\t\tfloat clientHod = _clientTimeHours - MathF.Floor( _clientTimeHours / 24f ) * 24f;\r\n\t\t_clientTimeHours += SkyGrade.ClockRateScale( clientHod, Config ) * (24f / (Config.DayLengthMinutes * 60f)) * Time.Delta;\r\n\t\tfloat net = NetTimeOfDay;\r\n\t\tif ( net != _lastNetTime )\r\n\t\t{\r\n\t\t\tbool first = float.IsNaN( _lastNetTime );\r\n\t\t\t_lastNetTime = net;\r\n\t\t\tfloat d = net - _clientTimeHours;\r\n\t\t\tif ( first || MathF.Abs( d ) > 1f ) _clientTimeHours = net; // first sync / pin jump \u2192 snap\r\n\t\t\telse _clientTimeHours += d * 0.25f; // small drift \u2192 ease\r\n\t\t}\r\n\t}\r\n\r\n\t/// <summary>The effective clock: the host reads its authoritative <see cref=\"NetTimeOfDay\"/>, a client reads\r\n\t/// its locally-extrapolated clock (smoothed toward the host snapshots). Feed this to the grade and the sky\r\n\t/// weights so they can never disagree.</summary>\r\n\tpublic float GetTimeHours()\r\n\t\t=> IsAuthority ? NetTimeOfDay : _clientTimeHours;\r\n\r\n\t/// <summary>The current day index (floor(time / 24)).</summary>\r\n\tpublic int CurrentDay => (int)MathF.Floor( GetTimeHours() / 24f );\r\n\r\n\t/// <summary>The effective weather for a day: the host override if set, else the deterministic pure roll for\r\n\t/// (<see cref=\"WorldSeed\"/>, dayIndex).</summary>\r\n\tpublic WeatherKind EffectiveWeather( int dayIndex )\r\n\t{\r\n\t\tif ( NetWeatherOverride >= 0 && System.Enum.IsDefined( typeof( WeatherKind ), NetWeatherOverride ) )\r\n\t\t\treturn (WeatherKind)NetWeatherOverride;\r\n\t\treturn WeatherRoll.For( WorldSeed, dayIndex );\r\n\t}\r\n\r\n\t/// <summary>The effective weather RIGHT NOW.</summary>\r\n\tpublic WeatherKind CurrentWeather => EffectiveWeather( CurrentDay );\r\n\r\n\t// \u2500\u2500 authority-guarded writes (a client call is a quiet no-op; only the host's write survives the FromHost sync) \u2500\u2500\r\n\r\n\t/// <summary>Set the clock to an hour-of-day, preserving the current day (so weather does not re-roll). Snaps\r\n\t/// the client extrapolation trackers so a joiner does not ease across a deliberate jump. Authority only.</summary>\r\n\tpublic void SetTimeOfDay( float hourOfDay )\r\n\t{\r\n\t\tif ( Networking.IsActive && !Networking.IsHost ) return;\r\n\t\tNetTimeOfDay = TimeMath.ComputeSetHour( NetTimeOfDay, hourOfDay );\r\n\t\t_clientTimeHours = NetTimeOfDay;\r\n\t\t_lastNetTime = NetTimeOfDay;\r\n\t}\r\n\r\n\t/// <summary>Nudge the clock by a signed delta in game-hours (clamped at 0). Authority only.</summary>\r\n\tpublic void NudgeTime( float deltaHours )\r\n\t{\r\n\t\tif ( Networking.IsActive && !Networking.IsHost ) return;\r\n\t\tNetTimeOfDay = MathF.Max( 0f, NetTimeOfDay + deltaHours );\r\n\t\t_clientTimeHours = NetTimeOfDay;\r\n\t\t_lastNetTime = NetTimeOfDay;\r\n\t}\r\n\r\n\t/// <summary>Is the clock paused (authority-side)?</summary>\r\n\tpublic bool TimePaused => _timePaused;\r\n\r\n\t/// <summary>Pause or resume the clock. Authority only; the pause state replicates on the FromHost surface so\r\n\t/// clients stop free-running (see the class remarks).</summary>\r\n\tpublic void SetPaused( bool paused )\r\n\t{\r\n\t\tif ( Networking.IsActive && !Networking.IsHost ) return;\r\n\t\t_timePaused = paused;\r\n\t\tNetTimePaused = paused;\r\n\t}\r\n\r\n\t/// <summary>Force a weather kind (>=0) or clear back to the deterministic hash roll (-1). Authority only.</summary>\r\n\tpublic void SetWeatherOverride( int weather )\r\n\t{\r\n\t\tif ( Networking.IsActive && !Networking.IsHost ) return;\r\n\t\tNetWeatherOverride = ( weather >= 0 && System.Enum.IsDefined( typeof( WeatherKind ), weather ) ) ? weather : -1;\r\n\t}\r\n}\r\n"
},
{
"Ident": "fieldguide.daynight",
"Path": "Code/Demo/DayNightDemoBootstrap.cs",
"FileName": "DayNightDemoBootstrap.cs",
"PackageType": "library",
"CodeKind": "Game",
"AssetVersionId": 337909,
"Code": "using System;\r\nusing System.Collections.Generic;\r\nusing System.Linq;\r\nusing Sandbox;\r\n\r\nnamespace FieldGuide.DayNight;\r\n\r\n/// <summary>\r\n/// Wires the demo scene in code so the whole kit is exercised from one component.\r\n///\r\n/// The scene it builds is the kit's hero case: a lit ground plane with a few shapes on it, a\r\n/// <see cref=\"DayNightClock\"/> running the time, and a <see cref=\"DayNightDriver\"/> on the scene's\r\n/// DirectionalLight. Nothing else. That is enough, because the kit's product IS the light: the sun sweeps,\r\n/// the shadows swing across the ground, the colour grade warms into dusk and drops into a genuinely dark\r\n/// night, and none of it touches camera exposure. The shapes exist to catch that light and throw the\r\n/// shadows that make the sweep readable; a bare plane shows almost nothing.\r\n///\r\n/// Two surfaces sit on top. The hint card (left) is up from the first frame and prints the live sky\r\n/// weights, which is the only way to SEE the sky seam in a kit that deliberately ships no sky art. The\r\n/// time panel (right) is the kit's dev tuning surface, opened here because a demo whose point is\r\n/// \"drive the cycle\" should not hide the control behind a keypress.\r\n///\r\n/// Everything it builds ships with the engine: the dev primitives and the default material. The kit adds\r\n/// no art of its own.\r\n///\r\n/// DEMO CONTENT IS INERT BY CONSTRUCTION (library law 11). Two things make that true here rather than by\r\n/// instruction. First, the kit ships no scanned GameResource, so there is no demo content that can load\r\n/// itself into a consumer's game the way a stray demo asset would. Second, the demo's UI is gated on\r\n/// <see cref=\"DemoActive\"/>, a flag ONLY this bootstrap sets: a consumer who forgets to delete Code/Demo,\r\n/// and who somehow ends up with the hint card component in a scene, still renders nothing.\r\n///\r\n/// Not part of the kit's runtime surface: delete Code/Demo when you drop the kit into your own project.\r\n/// </summary>\r\n[Title( \"Day Night Demo Bootstrap\" )]\r\n[Category( \"Field Guide \u00b7 Day Night\" )]\r\n[Icon( \"auto_awesome\" )]\r\npublic sealed class DayNightDemoBootstrap : Component\r\n{\r\n\t/// <summary>\r\n\t/// True once this bootstrap has run in this session. The demo's own UI checks it before rendering, so\r\n\t/// demo content cannot appear in a consumer's game just because Code/Demo was left in the project.\r\n\t/// Nothing outside Code/Demo reads or writes it.\r\n\t/// </summary>\r\n\tpublic static bool DemoActive { get; private set; }\r\n\r\n\t/// <summary>The world seed the deterministic weather roll hashes against. Any int works; this one is\r\n\t/// just a fixed number so the demo rolls the same weather every run and two people comparing notes see\r\n\t/// the same days.</summary>\r\n\t[Property] public int DemoWorldSeed { get; set; } = 20260731;\r\n\r\n\t/// <summary>Real minutes per in-game day at the night pace. Short by default: the whole point of the\r\n\t/// demo is watching a full cycle, and the shipped default of 20 minutes is a long wait for that.</summary>\r\n\t[Property] public float DemoDayLengthMinutes { get; set; } = 4f;\r\n\r\n\t/// <summary>Game-hour the demo starts at. Mid-morning, so the first thing on screen is a lit scene with\r\n\t/// a sun that is visibly climbing rather than a black frame.</summary>\r\n\t[Property] public float DemoStartHour { get; set; } = 8.5f;\r\n\r\n\tconst string FallbackMaterial = \"materials/default.vmat\";\r\n\r\n\t/// <summary>One shape in the demo cluster, sized in ENGINE UNITS per axis. The builder divides the size\r\n\t/// by the model's own bounds to get the scale, so the table below reads as real dimensions and survives\r\n\t/// the engine changing what a dev primitive measures (the shipped box is 50 units, the sphere 64).</summary>\r\n\treadonly record struct Shape( string ModelPath, Vector3 SizeUnits, Vector3 Position, Color Tint );\r\n\r\n\t/// <summary>\r\n\t/// The cluster. A tall slab, a low wall, two blocks and a ball, spread out and at different heights.\r\n\t///\r\n\t/// The shapes are chosen for their SHADOWS, not their looks. A tall thin slab throws a long finger that\r\n\t/// swings a quarter turn across the plane over one day, which is the single clearest read on \"the sun is\r\n\t/// actually moving\"; the low wall gives a hard edge for the terminator to crawl along at dawn and dusk;\r\n\t/// the ball is the only curved surface, so it is where the warm key and the cool sky fill are visibly\r\n\t/// two different colours rather than one flat tone.\r\n\t/// </summary>\r\n\tstatic readonly Shape[] Cluster =\r\n\t{\r\n\t\tnew( \"models/dev/box.vmdl\", new Vector3( 24f, 24f, 260f ), new Vector3( 0f, 0f, 130f ), new Color( 0.78f, 0.76f, 0.72f ) ),\r\n\t\tnew( \"models/dev/box.vmdl\", new Vector3( 420f, 28f, 90f ), new Vector3( -60f, -320f, 45f ), new Color( 0.62f, 0.58f, 0.54f ) ),\r\n\t\tnew( \"models/dev/box.vmdl\", new Vector3( 110f, 110f, 110f ), new Vector3( 300f, 140f, 55f ), new Color( 0.70f, 0.55f, 0.42f ) ),\r\n\t\tnew( \"models/dev/box.vmdl\", new Vector3( 70f, 70f, 170f ), new Vector3( 190f, -220f, 85f ), new Color( 0.55f, 0.60f, 0.68f ) ),\r\n\t\tnew( \"models/dev/sphere.vmdl\", new Vector3( 150f, 150f, 150f ), new Vector3( -280f, 180f, 75f ), new Color( 0.80f, 0.80f, 0.82f ) ),\r\n\t};\r\n\r\n\tDayNightClock _clock;\r\n\tRainStreaks _rain;\r\n\tCameraComponent _camera;\r\n\r\n\tprotected override void OnStart()\r\n\t{\r\n\t\tDemoActive = true;\r\n\r\n\t\t_camera = Scene.GetAllComponents<CameraComponent>().FirstOrDefault();\r\n\t\t_clock = EnsureClock();\r\n\t\tBuildCluster();\r\n\t\tBuildRain();\r\n\t\tBuildUi();\r\n\r\n\t\tLog.Info( $\"[daynight] demo ready. Day length {DemoDayLengthMinutes:0.#} real minutes, seed {DemoWorldSeed}. \"\r\n\t\t\t+ \"N opens the time panel, H hides the hint card.\" );\r\n\t}\r\n\r\n\t/// <summary>\r\n\t/// The clock, configured for a demo rather than for a game.\r\n\t///\r\n\t/// The one non-default value is the day length. Everything else is <see cref=\"DayNightConfig.Default\"/>\r\n\t/// verbatim, on purpose: a demo that tunes the grade is showing you ITS look, not the kit's, and the\r\n\t/// shipped default is the reference grade a consumer gets on install.\r\n\t/// </summary>\r\n\tDayNightClock EnsureClock()\r\n\t{\r\n\t\tvar clock = DayNightClock.For( Scene ) ?? Components.GetOrCreate<DayNightClock>();\r\n\r\n\t\tvar cfg = DayNightConfig.Default;\r\n\t\tcfg.DayLengthMinutes = MathF.Max( 0.25f, DemoDayLengthMinutes );\r\n\t\tcfg.StartHours = Math.Clamp( DemoStartHour, 0f, 24f );\r\n\t\tclock.Config = cfg;\r\n\t\tclock.WorldSeed = DemoWorldSeed;\r\n\r\n\t\t// The driver has to agree with the clock: same config on both, which is exactly what the README\r\n\t\t// tells a consumer to do. Set it here rather than in the scene file so there is one source of truth.\r\n\t\tforeach ( var driver in Scene.GetAllComponents<DayNightDriver>() )\r\n\t\t\tdriver.Config = cfg;\r\n\r\n\t\treturn clock;\r\n\t}\r\n\r\n\t// ---- the shapes that catch the light ----\r\n\r\n\tvoid BuildCluster()\r\n\t{\r\n\t\tvar root = Scene.CreateObject();\r\n\t\troot.Name = \"Demo Shapes\";\r\n\r\n\t\tfor ( int i = 0; i < Cluster.Length; i++ )\r\n\t\t\tBuildShape( root, $\"Shape {i + 1}\", Cluster[i] );\r\n\t}\r\n\r\n\tvoid BuildShape( GameObject parent, string name, Shape shape )\r\n\t{\r\n\t\tvar go = Scene.CreateObject();\r\n\t\tgo.Name = name;\r\n\t\tgo.SetParent( parent, false );\r\n\t\tgo.LocalPosition = shape.Position;\r\n\t\tgo.LocalRotation = Rotation.Identity;\r\n\r\n\t\tvar renderer = go.Components.Create<ModelRenderer>();\r\n\t\tvar model = Model.Load( shape.ModelPath );\r\n\t\tif ( model is null || model.IsError )\r\n\t\t{\r\n\t\t\tLog.Warning( $\"[daynight] demo model '{shape.ModelPath}' did not load; '{name}' will be invisible.\" );\r\n\t\t\treturn;\r\n\t\t}\r\n\t\trenderer.Model = model;\r\n\r\n\t\tvar bounds = model.Bounds.Size;\r\n\t\tgo.LocalScale = new Vector3(\r\n\t\t\tbounds.x > 0.001f ? shape.SizeUnits.x / bounds.x : 1f,\r\n\t\t\tbounds.y > 0.001f ? shape.SizeUnits.y / bounds.y : 1f,\r\n\t\t\tbounds.z > 0.001f ? shape.SizeUnits.z / bounds.z : 1f );\r\n\r\n\t\t// The engine's models/dev primitives render as missing-material magenta unless a real material is\r\n\t\t// forced on, which would swallow the grade this whole demo exists to show.\r\n\t\tvar mat = Material.Load( FallbackMaterial );\r\n\t\tif ( mat is not null ) renderer.MaterialOverride = mat;\r\n\t\trenderer.Tint = shape.Tint;\r\n\t}\r\n\r\n\t// ---- the optional rain module, so a Rain day is visible ----\r\n\r\n\tvoid BuildRain()\r\n\t{\r\n\t\tvar go = Scene.CreateObject();\r\n\t\tgo.Name = \"Demo Rain\";\r\n\r\n\t\t_rain = go.Components.Create<RainStreaks>();\r\n\r\n\t\t// The shower centres on the camera, which is the seam's whole point: the kit never reaches for your\r\n\t\t// player or camera type, you hand it a position.\r\n\t\t_rain.Center = () => _camera.IsValid() ? _camera.WorldPosition : Vector3.Up * 200f;\r\n\t}\r\n\r\n\tprotected override void OnUpdate()\r\n\t{\r\n\t\t// Drive the optional rain module from the clock's weather, which is the two-line wiring the README\r\n\t\t// describes. Cheap to call every frame.\r\n\t\tif ( _rain.IsValid() && _clock.IsValid() )\r\n\t\t\t_rain.SetRaining( _clock.CurrentWeather == WeatherKind.Rain );\r\n\t}\r\n\r\n\t// ---- screen UI ----\r\n\r\n\tvoid BuildUi()\r\n\t{\r\n\t\t// One ScreenPanel per PanelComponent (the World Builder UI idiom). Built in code so the demo scene\r\n\t\t// needs no razor wiring.\r\n\t\tvar hintHost = Scene.CreateObject();\r\n\t\thintHost.Name = \"Day Night Hint\";\r\n\t\thintHost.Components.Create<ScreenPanel>();\r\n\t\thintHost.Components.Create<DayNightHintCard>();\r\n\r\n\t\tvar panelHost = Scene.CreateObject();\r\n\t\tpanelHost.Name = \"Day Night UI\";\r\n\t\tpanelHost.Components.Create<ScreenPanel>();\r\n\r\n\t\t// Open on arrival. Driving the cycle is what this scene is FOR, so making the visitor find the key\r\n\t\t// first is a toll booth on the way to the point. N and the header x still close it. The panel reads\r\n\t\t// this on its first update, after every OnStart in the frame, so setting it here always lands.\r\n\t\tvar panel = panelHost.Components.Create<DayNightPanel>();\r\n\t\tpanel.OpenOnStart = true;\r\n\t}\r\n}\r\n"
},
{
"Ident": "fieldguide.daynight",
"Path": "DayNightConfig.cs",
"FileName": "DayNightConfig.cs",
"PackageType": "library",
"CodeKind": "Game",
"AssetVersionId": 337909,
"Code": "using System;\r\n\r\nnamespace FieldGuide.DayNight;\r\n\r\n/// <summary>\r\n/// All the tuning for the day/night cycle in one passed-in struct (library law: config over constants).\r\n/// Every pure math call (<see cref=\"SkyGrade\"/>, <see cref=\"SkyWeights\"/>) and the clock accumulate take a\r\n/// config by reference, so a library consumer never reads a project-global static. Grab\r\n/// <see cref=\"Default\"/> and tweak the fields you care about.\r\n///\r\n/// The values in <see cref=\"Default\"/> are the exact reference grade from the source game: an afternoon\r\n/// anchor at 15:00, a symmetric 12 h day (sunrise 6, sunset 18), a warm HDR sun and sky, and a deep-blue\r\n/// night. The arc is DERIVED from <see cref=\"SunDirection\"/> (its noon pitch/yaw come from LookAt\u2192Angles),\r\n/// so nudging <see cref=\"SunDirection\"/> moves the whole arc without touching the pitch/yaw fields.\r\n///\r\n/// EXPOSURE IS NOT IN HERE ON PURPOSE. The whole diurnal look comes from sun rotation + light/sky/envmap\r\n/// colours, never from tone-mapping. If your game locks camera exposure, night renders genuinely dark under\r\n/// it; this kit never writes exposure, so it will not fight your camera. See the README.\r\n/// </summary>\r\npublic struct DayNightConfig\r\n{\r\n\t// \u2500\u2500 daylight window + pace \u2500\u2500\r\n\t/// <summary>Game-hour daylight begins. Default 6, giving a symmetric 12 h day with\r\n\t/// <see cref=\"SunsetHour\"/>.</summary>\r\n\tpublic float SunriseHour;\r\n\t/// <summary>Game-hour daylight ends. Default 18.</summary>\r\n\tpublic float SunsetHour;\r\n\t/// <summary>Real-time MINUTES per in-game day at the NIGHT pace. Because <see cref=\"DayRateScale\"/> slows\r\n\t/// the daylight arc, this is the night-arc pace, not the whole-cycle length.</summary>\r\n\tpublic float DayLengthMinutes;\r\n\t/// <summary>Clock-rate multiplier applied through full daylight so the day lasts longer than the night;\r\n\t/// night stays at rate 1 so its real-time length is preserved exactly. The default is solved so the\r\n\t/// effective day:night real-time ratio is 3.0 (see <see cref=\"SkyGrade.ClockRateScale\"/> and the ratio\r\n\t/// self-test). Change the daylight window or twilight width and re-solve this against your target.</summary>\r\n\tpublic float DayRateScale;\r\n\t/// <summary>Smoothstep ramp width (game-hours) at each daylight edge, shared by the pace ramp and the\r\n\t/// twilight colour blend so the pace shift is masked by the sky already transitioning.</summary>\r\n\tpublic float TwilightHours;\r\n\r\n\t// \u2500\u2500 session start \u2500\u2500\r\n\t/// <summary>Game-hour a fresh session's clock starts at.</summary>\r\n\tpublic float StartHours;\r\n\t/// <summary>Whether a fresh session starts paused (held at <see cref=\"StartHours\"/> until something sets\r\n\t/// the time). Default false = the clock runs.</summary>\r\n\tpublic bool StartPaused;\r\n\r\n\t// \u2500\u2500 sun-arc shape \u2500\u2500\r\n\t/// <summary>Near-horizon sun pitch at sunrise/sunset.</summary>\r\n\tpublic float HorizonPitch;\r\n\t/// <summary>Total east\u2192west yaw the sun sweeps across the day.</summary>\r\n\tpublic float YawSpan;\r\n\t/// <summary>Fixed low-moon pitch for deep night.</summary>\r\n\tpublic float NightPitch;\r\n\t/// <summary>Reference sun direction. The arc's noon pitch/yaw are derived from this (LookAt\u2192Angles), so\r\n\t/// the arc always threads the current reference sun.</summary>\r\n\tpublic Vector3 SunDirection;\r\n\r\n\t// \u2500\u2500 daytime reference colours (the anchor grade) \u2500\u2500\r\n\t/// <summary>Reference sun key colour at the anchor daylight. The daytime lerp is anchored so the sun key\r\n\t/// EQUALS this exactly at <see cref=\"AnchorHours\"/>.</summary>\r\n\tpublic Color SunColor;\r\n\t/// <summary>Reference ambient (sky-fill) colour at the anchor daylight.</summary>\r\n\tpublic Color SkyAmbient;\r\n\t/// <summary>Reference SkyBox2D tint at the anchor daylight.</summary>\r\n\tpublic Color SkyTint;\r\n\t/// <summary>Reference EnvmapProbe tint at the anchor daylight.</summary>\r\n\tpublic Color EnvmapTint;\r\n\t/// <summary>The daylight hour the reference grade is authored at. At this hour (Clear weather) the computed\r\n\t/// grade equals the reference values above exactly.</summary>\r\n\tpublic float AnchorHours;\r\n\r\n\t// \u2500\u2500 diurnal key targets \u2500\u2500\r\n\t/// <summary>Sun key the daytime lerp reaches toward noon.</summary>\r\n\tpublic Color NoonKey;\r\n\t/// <summary>Deep warm sun key at the horizon edge (sunrise/sunset).</summary>\r\n\tpublic Color HorizonKey;\r\n\t/// <summary>Deep-night sun key.</summary>\r\n\tpublic Color NightKey;\r\n\t/// <summary>Deep-night ambient (SkyColor) fill.</summary>\r\n\tpublic Color NightAmbient;\r\n\t/// <summary>Deep-night SkyBox2D tint.</summary>\r\n\tpublic Color NightSkyTint;\r\n\t/// <summary>Deep-night EnvmapProbe tint.</summary>\r\n\tpublic Color NightEnvTint;\r\n\r\n\t// \u2500\u2500 weather dimming \u2500\u2500\r\n\t/// <summary>Sun-key dim multiplier under Cloudy weather (1 = no dim).</summary>\r\n\tpublic float WeatherDimCloudy;\r\n\t/// <summary>Sun-key dim multiplier under Rain weather.</summary>\r\n\tpublic float WeatherDimRain;\r\n\r\n\t// \u2500\u2500 four-slot sky anchors (for SkyWeights, the sky seam) \u2500\u2500\r\n\t/// <summary>Hour-of-day anchors for the four sky slots the consumer crossfades (night / morning / noon /\r\n\t/// evening). Evenly 6 h apart by default and aligned to sunrise/sunset so every segment is a clean\r\n\t/// adjacent-pair crossfade and the midnight wrap is continuous.</summary>\r\n\tpublic float SkyNightHour;\r\n\t/// <summary>Hour-of-day the MORNING sky slot owns outright (weight 1). Default 6, at sunrise.</summary>\r\n\tpublic float SkyMorningHour;\r\n\t/// <summary>Hour-of-day the NOON sky slot owns outright (weight 1). Default 12.</summary>\r\n\tpublic float SkyNoonHour;\r\n\t/// <summary>Hour-of-day the EVENING sky slot owns outright (weight 1). Default 18, at sunset.</summary>\r\n\tpublic float SkyEveningHour;\r\n\r\n\t/// <summary>The reference grade lifted verbatim from the source game. Afternoon anchor, symmetric 12 h day,\r\n\t/// 20 real-minute night pace, day 3x longer than night, warm HDR daylight, deep-blue night.</summary>\r\n\tpublic static DayNightConfig Default => new()\r\n\t{\r\n\t\tSunriseHour = 6f,\r\n\t\tSunsetHour = 18f,\r\n\t\tDayLengthMinutes = 20f,\r\n\t\tDayRateScale = 0.31494221f, // solved so day:night == 3.0; re-solve if the window/twilight change\r\n\t\tTwilightHours = 0.75f,\r\n\r\n\t\tStartHours = 7f,\r\n\t\tStartPaused = false,\r\n\r\n\t\tHorizonPitch = 3f,\r\n\t\tYawSpan = 150f,\r\n\t\tNightPitch = 34f,\r\n\t\tSunDirection = new Vector3( 0.35f, 0.62f, -0.70f ),\r\n\r\n\t\tSunColor = new Color( 1.72f, 1.50f, 1.14f ),\r\n\t\tSkyAmbient = new Color( 0.92f, 0.84f, 0.68f ),\r\n\t\tSkyTint = new Color( 1.30f, 1.24f, 1.12f ),\r\n\t\tEnvmapTint = new Color( 1.02f, 0.90f, 0.70f ),\r\n\t\tAnchorHours = 15f,\r\n\r\n\t\tNoonKey = new Color( 1.95f, 1.85f, 1.60f ),\r\n\t\tHorizonKey = new Color( 2.05f, 1.05f, 0.52f ),\r\n\t\tNightKey = new Color( 0.10f, 0.14f, 0.24f ),\r\n\t\tNightAmbient = new Color( 0.05f, 0.07f, 0.12f ),\r\n\t\tNightSkyTint = new Color( 0.05f, 0.06f, 0.10f ),\r\n\t\tNightEnvTint = new Color( 0.06f, 0.07f, 0.11f ),\r\n\r\n\t\tWeatherDimCloudy = 0.62f,\r\n\t\tWeatherDimRain = 0.40f,\r\n\r\n\t\tSkyNightHour = 0f,\r\n\t\tSkyMorningHour = 6f,\r\n\t\tSkyNoonHour = 12f,\r\n\t\tSkyEveningHour = 18f,\r\n\t};\r\n}\r\n"
},
{
"Ident": "fieldguide.daynight",
"Path": "Code/Weather/RainStreaks.cs",
"FileName": "RainStreaks.cs",
"PackageType": "library",
"CodeKind": "Game",
"AssetVersionId": 337909,
"Code": "using System;\r\nusing System.Collections.Generic;\r\nusing Sandbox;\r\n\r\nnamespace FieldGuide.DayNight;\r\n\r\n/// <summary>\r\n/// OPTIONAL cosmetic rain module (delete the Weather/ folder if you don't want it). A cheap box-streak shower\r\n/// centred on a point you provide, so the deterministic Rain weather is visible without pulling in your\r\n/// player or camera types. It uses only the engine dev box primitive and an xorshift jitter (no System.Random,\r\n/// no gameplay), and it is entirely client-local.\r\n///\r\n/// Wire it in two lines: set <see cref=\"Center\"/> to a delegate returning where the shower should sit (usually\r\n/// the local player or camera position, in engine units), and each frame call <see cref=\"SetRaining\"/> with\r\n/// whether the current weather is <see cref=\"WeatherKind.Rain\"/> (ask your <see cref=\"DayNightClock\"/>). Or\r\n/// just add it to a GameObject and set <see cref=\"Center\"/>; a sibling script can call SetRaining.\r\n/// </summary>\r\n[Title( \"Rain Streaks\" )]\r\n[Category( \"Field Guide\" )]\r\n[Icon( \"grain\" )]\r\npublic sealed class RainStreaks : Component\r\n{\r\n\t/// <summary>Where the shower centres (engine units). Defaults to this component's own world position.</summary>\r\n\tpublic Func<Vector3> Center { get; set; }\r\n\r\n\t/// <summary>Number of streaks in the pool. Set before first enable.</summary>\r\n\tpublic int StreakCount { get; set; } = 60;\r\n\r\n\tGameObject _fxRoot;\r\n\treadonly List<GameObject> _streaks = new();\r\n\tuint _scatter = 0x2545F491; // xorshift state (no System.Random, determinism hygiene)\r\n\tbool _raining;\r\n\r\n\t/// <summary>Turn the shower on or off. Cheap to call every frame with your weather check.</summary>\r\n\tpublic void SetRaining( bool raining ) => _raining = raining;\r\n\r\n\tVector3 ResolveCenter() => Center?.Invoke() ?? WorldPosition;\r\n\r\n\tvoid EnsureRoot()\r\n\t{\r\n\t\tif ( _fxRoot.IsValid() ) return;\r\n\t\t_fxRoot = Scene.CreateObject();\r\n\t\t_fxRoot.Name = \"fg_rain_fx\";\r\n\t\t_fxRoot.SetParent( GameObject, false );\r\n\t\tvar model = Model.Load( \"models/dev/box.vmdl\" );\r\n\t\tfor ( int i = 0; i < StreakCount; i++ )\r\n\t\t{\r\n\t\t\tvar go = Scene.CreateObject();\r\n\t\t\tgo.Name = \"rain_streak\";\r\n\t\t\tgo.SetParent( _fxRoot, false );\r\n\t\t\tgo.Enabled = false;\r\n\t\t\tvar r = go.Components.Create<ModelRenderer>();\r\n\t\t\tif ( model is not null ) r.Model = model;\r\n\t\t\tr.Tint = new Color( 0.62f, 0.72f, 0.85f, 0.45f );\r\n\t\t\t_streaks.Add( go );\r\n\t\t}\r\n\t}\r\n\r\n\tprotected override void OnUpdate()\r\n\t{\r\n\t\tif ( !_raining )\r\n\t\t{\r\n\t\t\tif ( _fxRoot.IsValid() )\r\n\t\t\t\tforeach ( var s in _streaks )\r\n\t\t\t\t\tif ( s.IsValid() ) s.Enabled = false;\r\n\t\t\treturn;\r\n\t\t}\r\n\r\n\t\tEnsureRoot();\r\n\t\tvar center = ResolveCenter();\r\n\t\tconst float fall = 900f, spread = 900f, top = 650f, bottom = 350f;\r\n\r\n\t\tforeach ( var s in _streaks )\r\n\t\t{\r\n\t\t\tif ( !s.IsValid() ) continue;\r\n\t\t\tif ( !s.Enabled ) { s.Enabled = true; Respawn( s, center, spread, top, bottom ); }\r\n\r\n\t\t\ts.WorldPosition += Vector3.Down * fall * Time.Delta;\r\n\t\t\tif ( s.WorldPosition.z <= center.z - 100f || s.WorldPosition.Distance( center ) > 1600f )\r\n\t\t\t\tRespawn( s, center, spread, top, bottom );\r\n\t\t}\r\n\t}\r\n\r\n\tvoid Respawn( GameObject s, Vector3 center, float spread, float top, float bottom )\r\n\t{\r\n\t\ts.WorldPosition = center + new Vector3(\r\n\t\t\t(NextJitter() * 2f - 1f) * spread,\r\n\t\t\t(NextJitter() * 2f - 1f) * spread,\r\n\t\t\tbottom + NextJitter() * (top - bottom) );\r\n\t\ts.WorldScale = new Vector3( 0.025f, 0.025f, 0.5f ); // thin vertical streak\r\n\t}\r\n\r\n\t/// <summary>Cheap per-streak scatter in [0,1), an xorshift on local state, NOT System.Random. Cosmetic\r\n\t/// only; never feeds anything deterministic.</summary>\r\n\tfloat NextJitter()\r\n\t{\r\n\t\t_scatter ^= _scatter << 13;\r\n\t\t_scatter ^= _scatter >> 17;\r\n\t\t_scatter ^= _scatter << 5;\r\n\t\treturn (_scatter & 0xFFFFFF) / (float)0x1000000;\r\n\t}\r\n\r\n\tprotected override void OnDestroy()\r\n\t{\r\n\t\tif ( _fxRoot.IsValid() ) _fxRoot.Destroy();\r\n\t\t_streaks.Clear();\r\n\t}\r\n}\r\n"
},
{
"Ident": "fieldguide.daynight",
"Path": "Code/SelfTest/DayNightSelfTest.cs",
"FileName": "DayNightSelfTest.cs",
"PackageType": "library",
"CodeKind": "Game",
"AssetVersionId": 337909,
"Code": "using System;\r\nusing System.Collections.Generic;\r\nusing Sandbox;\r\n\r\nnamespace FieldGuide.DayNight;\r\n\r\n/// <summary>\r\n/// A pure self-test battery for the kit's deterministic math, ported from the source game's pure-test suite.\r\n/// None of it needs a running scene or networking, it exercises <see cref=\"SkyGrade\"/>, <see cref=\"SkyWeights\"/>,\r\n/// <see cref=\"WeatherRoll\"/>, and <see cref=\"TimeMath\"/> against the default config and returns a pass/fail\r\n/// report. It is the kit's compile-in-isolation smoke proof and doubles as executable documentation.\r\n///\r\n/// Run it from the s&box console with <c>fg_daynight_selftest</c>, or call <see cref=\"RunAll\"/> from your own\r\n/// harness. Every case is a pure function of the config, so a green run here is meaningful without the editor.\r\n/// </summary>\r\npublic static class DayNightSelfTest\r\n{\r\n\t/// <summary>One test result.</summary>\r\n\tpublic readonly record struct Case( string Name, bool Passed, string Detail );\r\n\r\n\t/// <summary>Run every case against <see cref=\"DayNightConfig.Default\"/>. Returns the per-case results; the\r\n\t/// caller decides how to surface them.</summary>\r\n\tpublic static List<Case> RunAll()\r\n\t{\r\n\t\tvar cfg = DayNightConfig.Default;\r\n\t\treturn new List<Case>\r\n\t\t{\r\n\t\t\tWeatherDeterminism( cfg ),\r\n\t\t\tAnchorExact( cfg ),\r\n\t\t\tTwilightContinuity( cfg ),\r\n\t\t\tRateNightAndMidday( cfg ),\r\n\t\t\tRateContinuousAtTwilight( cfg ),\r\n\t\t\tRatioIs3x( cfg ),\r\n\t\t\tSkyWeightsPartition( cfg ),\r\n\t\t\tTimeSetPreservesDay( cfg ),\r\n\t\t};\r\n\t}\r\n\r\n\t// \u2500\u2500 weather: deterministic and seed-sensitive \u2500\u2500\r\n\tstatic Case WeatherDeterminism( DayNightConfig cfg )\r\n\t{\r\n\t\tconst int seed = 71237;\r\n\t\tbool stable = true;\r\n\t\tfor ( int day = 0; day < 16; day++ )\r\n\t\t{\r\n\t\t\tvar w = WeatherRoll.For( seed, day );\r\n\t\t\tif ( w != WeatherRoll.For( seed, day ) ) { stable = false; break; }\r\n\t\t\tif ( !System.Enum.IsDefined( typeof( WeatherKind ), w ) ) { stable = false; break; }\r\n\t\t}\r\n\t\tbool seedSensitive = false;\r\n\t\tfor ( int day = 0; day < 32 && !seedSensitive; day++ )\r\n\t\t\tif ( WeatherRoll.For( seed, day ) != WeatherRoll.For( seed + 1, day ) )\r\n\t\t\t\tseedSensitive = true;\r\n\t\tbool ok = stable && seedSensitive;\r\n\t\treturn new( \"weather_deterministic\", ok, $\"stable={stable} seedSensitive={seedSensitive}\" );\r\n\t}\r\n\r\n\t// \u2500\u2500 grade: anchor-exact \u2500\u2500\r\n\tstatic Case AnchorExact( DayNightConfig cfg )\r\n\t{\r\n\t\tSkyGrade.ComputeGrade( cfg.AnchorHours, WeatherKind.Clear, cfg,\r\n\t\t\tout var rot, out var sun, out _, out _, out _ );\r\n\t\tvar refRot = Rotation.LookAt( cfg.SunDirection.Normal );\r\n\t\tfloat dot = Math.Clamp( Vector3.Dot( rot.Forward.Normal, refRot.Forward.Normal ), -1f, 1f );\r\n\t\tfloat ang = MathF.Acos( dot ) * (180f / MathF.PI);\r\n\t\tbool rotOk = ang < 0.05f;\r\n\t\tbool sunOk = MathF.Abs( sun.r - cfg.SunColor.r ) < 1e-3f\r\n\t\t\t&& MathF.Abs( sun.g - cfg.SunColor.g ) < 1e-3f\r\n\t\t\t&& MathF.Abs( sun.b - cfg.SunColor.b ) < 1e-3f;\r\n\t\tbool ok = rotOk && sunOk;\r\n\t\treturn new( \"grade_anchor_exact\", ok, $\"rotDeltaDeg={ang:0.000} sunKeyMatches={sunOk}\" );\r\n\t}\r\n\r\n\t// \u2500\u2500 grade: twilight continuity (no teleport across sunset) \u2500\u2500\r\n\tstatic Case TwilightContinuity( DayNightConfig cfg )\r\n\t{\r\n\t\tfloat maxAngleDeg = 0f, maxColorStep = 0f;\r\n\t\tVector3 prevDir = Vector3.Zero;\r\n\t\tColor prevSun = default;\r\n\t\tbool first = true;\r\n\t\tfor ( float t = 17.5f; t <= 19.5f + 1e-4f; t += 0.1f )\r\n\t\t{\r\n\t\t\tSkyGrade.ComputeGrade( t, WeatherKind.Clear, cfg, out var rot, out var sun, out _, out _, out _ );\r\n\t\t\tvar dir = rot.Forward;\r\n\t\t\tif ( !first )\r\n\t\t\t{\r\n\t\t\t\tfloat d = Math.Clamp( Vector3.Dot( dir.Normal, prevDir.Normal ), -1f, 1f );\r\n\t\t\t\tfloat ang = MathF.Acos( d ) * (180f / MathF.PI);\r\n\t\t\t\tif ( ang > maxAngleDeg ) maxAngleDeg = ang;\r\n\t\t\t\tfloat cstep = MathF.Max( MathF.Abs( sun.r - prevSun.r ),\r\n\t\t\t\t\tMathF.Max( MathF.Abs( sun.g - prevSun.g ), MathF.Abs( sun.b - prevSun.b ) ) );\r\n\t\t\t\tif ( cstep > maxColorStep ) maxColorStep = cstep;\r\n\t\t\t}\r\n\t\t\tprevDir = dir; prevSun = sun; first = false;\r\n\t\t}\r\n\t\tbool ok = maxAngleDeg < 16f && maxColorStep < 0.45f;\r\n\t\treturn new( \"grade_twilight_continuity\", ok, $\"maxStepDeg={maxAngleDeg:0.0} maxColorStep={maxColorStep:0.00} (thresholds 16, 0.45)\" );\r\n\t}\r\n\r\n\t// \u2500\u2500 rate: night == 1, midday == DayRateScale \u2500\u2500\r\n\tstatic Case RateNightAndMidday( DayNightConfig cfg )\r\n\t{\r\n\t\tfloat n0 = SkyGrade.ClockRateScale( 0f, cfg );\r\n\t\tfloat n3 = SkyGrade.ClockRateScale( 3f, cfg );\r\n\t\tfloat n21 = SkyGrade.ClockRateScale( 21f, cfg );\r\n\t\tfloat mid = SkyGrade.ClockRateScale( 12f, cfg );\r\n\t\tbool night = MathF.Abs( n0 - 1f ) < 1e-4f && MathF.Abs( n3 - 1f ) < 1e-4f && MathF.Abs( n21 - 1f ) < 1e-4f;\r\n\t\tbool midday = MathF.Abs( mid - cfg.DayRateScale ) < 1e-4f;\r\n\t\tbool ok = night && midday;\r\n\t\treturn new( \"rate_night_and_midday\", ok, $\"night(0/3/21)={n0:0.000}/{n3:0.000}/{n21:0.000} midday={mid:0.000} (want {cfg.DayRateScale:0.000})\" );\r\n\t}\r\n\r\n\t// \u2500\u2500 rate: continuous at the twilight boundary \u2500\u2500\r\n\tstatic Case RateContinuousAtTwilight( DayNightConfig cfg )\r\n\t{\r\n\t\tconst float eps = 0.01f;\r\n\t\tfloat atSunrise = SkyGrade.ClockRateScale( cfg.SunriseHour, cfg );\r\n\t\tfloat atSunset = SkyGrade.ClockRateScale( cfg.SunsetHour, cfg );\r\n\t\tbool endsAtOne = MathF.Abs( atSunrise - 1f ) < 1e-3f && MathF.Abs( atSunset - 1f ) < 1e-3f;\r\n\t\tfloat srStep = MathF.Abs( SkyGrade.ClockRateScale( cfg.SunriseHour + eps, cfg ) - SkyGrade.ClockRateScale( cfg.SunriseHour - eps, cfg ) );\r\n\t\tfloat ssStep = MathF.Abs( SkyGrade.ClockRateScale( cfg.SunsetHour - eps, cfg ) - SkyGrade.ClockRateScale( cfg.SunsetHour + eps, cfg ) );\r\n\t\tbool ok = endsAtOne && srStep < 5e-3f && ssStep < 5e-3f;\r\n\t\treturn new( \"rate_continuous_at_twilight\", ok, $\"atSunrise={atSunrise:0.000} atSunset={atSunset:0.000} srStep={srStep:0.0000} ssStep={ssStep:0.0000}\" );\r\n\t}\r\n\r\n\t// \u2500\u2500 rate: effective day:night real-time ratio == 3.0 \u2500\u2500\r\n\tstatic Case RatioIs3x( DayNightConfig cfg )\r\n\t{\r\n\t\tconst int n = 120000;\r\n\t\tfloat a = cfg.SunriseHour, b = cfg.SunsetHour;\r\n\t\tfloat h = (b - a) / n;\r\n\t\tdouble dayRealtime = 0.0;\r\n\t\tfor ( int i = 0; i < n; i++ )\r\n\t\t{\r\n\t\t\tfloat t = a + (i + 0.5f) * h;\r\n\t\t\tdayRealtime += 1.0 / SkyGrade.ClockRateScale( t, cfg );\r\n\t\t}\r\n\t\tdayRealtime *= h;\r\n\t\tfloat nightHours = 24f - (b - a);\r\n\t\tdouble ratio = dayRealtime / nightHours;\r\n\t\tbool ok = System.Math.Abs( ratio - 3.0 ) <= 0.03;\r\n\t\treturn new( \"rate_ratio_is_3x\", ok, $\"day:night ratio={ratio:0.0000} (want 3.0 +/-1%)\" );\r\n\t}\r\n\r\n\t// \u2500\u2500 sky weights: partition of unity, adjacent-pair only \u2500\u2500\r\n\tstatic Case SkyWeightsPartition( DayNightConfig cfg )\r\n\t{\r\n\t\tbool ok = true;\r\n\t\tstring detail = \"sum==1, <=2 nonzero across 24h\";\r\n\t\tfor ( float t = 0f; t < 24f; t += 0.05f )\r\n\t\t{\r\n\t\t\tvar w = SkyWeights.WeightsFor( t, cfg );\r\n\t\t\tfloat sum = w.x + w.y + w.z + w.w;\r\n\t\t\tif ( MathF.Abs( sum - 1f ) > 1e-3f ) { ok = false; detail = $\"sum={sum:0.000} at t={t:0.00}\"; break; }\r\n\t\t\tint nonzero = (w.x > 1e-4f ? 1 : 0) + (w.y > 1e-4f ? 1 : 0) + (w.z > 1e-4f ? 1 : 0) + (w.w > 1e-4f ? 1 : 0);\r\n\t\t\tif ( nonzero > 2 ) { ok = false; detail = $\"{nonzero} nonzero weights at t={t:0.00}\"; break; }\r\n\t\t}\r\n\t\treturn new( \"sky_weights_partition\", ok, detail );\r\n\t}\r\n\r\n\t// \u2500\u2500 time-set preserves the day index \u2500\u2500\r\n\tstatic Case TimeSetPreservesDay( DayNightConfig cfg )\r\n\t{\r\n\t\tbool preservesDay = TimeMath.ComputeSetHour( 39.5f, 6f ) == 30f; // day 1 15:30 \u2192 06:00, still day 1\r\n\t\tbool clampsHigh = TimeMath.ComputeSetHour( 39.5f, 99f ) == 48f;\r\n\t\tbool clampsLow = TimeMath.ComputeSetHour( 39.5f, -5f ) == 24f;\r\n\t\tbool sliderMid = MathF.Abs( TimeMath.ComputeSliderHour( 0.5f ) - 12f ) < 1e-3f;\r\n\t\t// The slider must never hand back a value past the end of the day: one in-game minute is not exactly\r\n\t\t// representable, so 1440 quantized steps land at 24.000002 unless the result is clamped.\r\n\t\tbool sliderRange = TimeMath.ComputeSliderHour( 1f ) <= 24f && TimeMath.ComputeSliderHour( 0f ) >= 0f;\r\n\t\tbool ok = preservesDay && clampsHigh && clampsLow && sliderMid && sliderRange;\r\n\t\treturn new( \"time_set_preserves_day\", ok, $\"preservesDay={preservesDay} clampHi={clampsHigh} clampLo={clampsLow} sliderMid={sliderMid} sliderRange={sliderRange}\" );\r\n\t}\r\n\r\n\t/// <summary>Console entry: run the battery and print a one-line-per-case report plus a summary.</summary>\r\n\t[ConCmd( \"fg_daynight_selftest\" )]\r\n\tpublic static void RunFromConsole()\r\n\t{\r\n\t\tint passed = 0, failed = 0;\r\n\t\tforeach ( var c in RunAll() )\r\n\t\t{\r\n\t\t\tif ( c.Passed ) { passed++; Log.Info( $\" ok {c.Name} {c.Detail}\" ); }\r\n\t\t\telse { failed++; Log.Warning( $\" FAIL {c.Name} {c.Detail}\" ); }\r\n\t\t}\r\n\t\tif ( failed == 0 ) Log.Info( $\"fg_daynight_selftest: PASSED {passed}/{passed}\" );\r\n\t\telse Log.Warning( $\"fg_daynight_selftest: FAILED {failed} of {passed + failed}\" );\r\n\t}\r\n}\r\n"
},
{
"Ident": "fieldguide.daynight",
"Path": "DayNightDriver.cs",
"FileName": "DayNightDriver.cs",
"PackageType": "library",
"CodeKind": "Game",
"AssetVersionId": 337909,
"Code": "using System;\r\nusing System.Linq;\r\nusing Sandbox;\r\n\r\nnamespace FieldGuide.DayNight;\r\n\r\n/// <summary>\r\n/// OPTIONAL convenience driver. Put it on the same GameObject as your scene's DirectionalLight and it applies\r\n/// the day/night grade every frame from the <see cref=\"DayNightClock\"/> in the scene: sun rotation + colour,\r\n/// SkyBox2D tint, and EnvmapProbe tint. It resolves the sun from its own GameObject and the sky/envmap from\r\n/// the scene (first of each). Delete this file if you would rather call <see cref=\"SkyGrade.ApplyGradeTo\"/>\r\n/// yourself.\r\n///\r\n/// It never touches exposure/shadows/fog, so it will not fight a locked-exposure camera.\r\n///\r\n/// The <see cref=\"ShowCycle\"/> seam generalizes the source game's \"am I possessing a character?\" gate. While\r\n/// it returns false the driver holds the stable ANCHOR grade (a fully-lit, non-moving model-viewer look) and\r\n/// leaves the sky at your authoring backdrop; the moment it returns true the live cycle resumes at the true\r\n/// game time (the clock keeps ticking underneath regardless). Default: always show the cycle.\r\n/// </summary>\r\n[Title( \"Day Night Driver\" )]\r\n[Category( \"Field Guide\" )]\r\n[Icon( \"wb_sunny\" )]\r\npublic sealed class DayNightDriver : Component\r\n{\r\n\t/// <summary>Tuning. Defaults to the reference grade; set it to match your clock's config.</summary>\r\n\tpublic DayNightConfig Config { get; set; } = DayNightConfig.Default;\r\n\r\n\t/// <summary>Return false to hold the stable anchor grade instead of the live cycle (e.g. while the local\r\n\t/// player is in a menu / god-camera authoring mode). Null-safe: null means always show the cycle.</summary>\r\n\tpublic Func<bool> ShowCycle { get; set; }\r\n\r\n\t/// <summary>Optional sky tint to force while <see cref=\"ShowCycle\"/> is false (your authoring backdrop). If\r\n\t/// null the anchor sky tint is used.</summary>\r\n\tpublic Color? AuthoringSkyTint { get; set; }\r\n\r\n\tDirectionalLight _sun;\r\n\tSkyBox2D _sky;\r\n\tEnvmapProbe _env;\r\n\tDayNightClock _clock;\r\n\tDayNightClock Clock => _clock ??= DayNightClock.For( Scene );\r\n\r\n\tprotected override void OnEnabled()\r\n\t{\r\n\t\t_sun = GetComponent<DirectionalLight>();\r\n\t\t_sky = Scene.GetAllComponents<SkyBox2D>().FirstOrDefault();\r\n\t\t_env = Scene.GetAllComponents<EnvmapProbe>().FirstOrDefault();\r\n\t}\r\n\r\n\tprotected override void OnUpdate()\r\n\t{\r\n\t\tif ( Scene.IsEditor ) return; // editor renders whatever you authored; the cycle is play-mode\r\n\t\tvar clock = Clock;\r\n\t\tif ( clock is null || !_sun.IsValid() ) return;\r\n\r\n\t\tif ( ShowCycle is not null && !ShowCycle() )\r\n\t\t{\r\n\t\t\t// Stable anchor look (fully lit, not moving). Pass null for the sky so the anchor warm sky tint is not\r\n\t\t\t// applied, then force the authoring backdrop tint.\r\n\t\t\tSkyGrade.ApplyGradeTo( _sun, null, _env, Config.AnchorHours, WeatherKind.Clear, Config );\r\n\t\t\tif ( _sky.IsValid() ) _sky.Tint = AuthoringSkyTint ?? Config.SkyTint;\r\n\t\t\treturn;\r\n\t\t}\r\n\r\n\t\tfloat total = clock.GetTimeHours();\r\n\t\tvar weather = clock.EffectiveWeather( (int)MathF.Floor( total / 24f ) );\r\n\t\tSkyGrade.ApplyGradeTo( _sun, _sky, _env, total, weather, Config );\r\n\t}\r\n}\r\n"
},
{
"Ident": "fieldguide.daynight",
"Path": ".obj/__compiler_extra.cs",
"FileName": "__compiler_extra.cs",
"PackageType": "library",
"CodeKind": "Game",
"AssetVersionId": 337909,
"Code": "global using static Sandbox.Internal.GlobalGameNamespace;\r\nglobal using Microsoft.AspNetCore.Components;\r\nglobal using Microsoft.AspNetCore.Components.Rendering;\r\n[assembly: global::System.Reflection.AssemblyMetadata( \"AddonTitle\", \"Day Night Kit\" )]\r\n[assembly: global::System.Reflection.AssemblyMetadata( \"AddonIdent\", \"daynight\" )]\r\n[assembly: global::System.Reflection.AssemblyMetadata( \"OrgIdent\", \"fieldguide\" )]\r\n[assembly: global::System.Reflection.AssemblyMetadata( \"Ident\", \"fieldguide.daynight\" )]\r\n[assembly: global::System.Reflection.AssemblyMetadata( \"EngineVersion\", \"28\" )]\r\n[assembly: global::System.Reflection.AssemblyMetadata( \"EngineMinorVersion\", \"1\" )]\r\n\r\n[assembly: System.Runtime.Versioning.TargetFramework( \".NETCoreApp,Version=v9.0\", FrameworkDisplayName = \".NET 9.0\" )]\r\n[assembly: global::System.Reflection.AssemblyMetadata( \"CompileTime\", \"2026-07-31T23:59:06.5652519Z\" )]\r\n[assembly: global::System.Reflection.AssemblyVersion(\"0.0.122.0\")]\r\n[assembly: global::System.Reflection.AssemblyFileVersion(\"0.0.122.0\")]"
},
{
"Ident": "fieldguide.daynight",
"Path": "Code/SkyWeights.cs",
"FileName": "SkyWeights.cs",
"PackageType": "library",
"CodeKind": "Game",
"AssetVersionId": 337909,
"Code": "using System;\r\nusing Sandbox;\r\n\r\nnamespace FieldGuide.DayNight;\r\n\r\n/// <summary>\r\n/// THE SKY SEAM. This kit does not ship a sky shader or sky art (see the README, \"Why no sky shader\").\r\n/// Instead it publishes, for any total game-hour, four normalized blend weights (morning, noon, evening,\r\n/// night) summing to 1 with only the adjacent anchor pair non-zero. Your game decides what to DO with them:\r\n/// crossfade four equirect skybox textures in your own shader, lerp four flat sky colours, swap SkyBox2D\r\n/// materials, or ignore them entirely and just read <see cref=\"DayNightClock.GetTimeHours\"/>.\r\n///\r\n/// The weights are a STATELESS pure function of the hour: no easing state, no temporal smoothing. So an\r\n/// explicit time jump (a menu preset, a pin) lands the exact target weights the same frame (an instant snap),\r\n/// while natural clock advance moves the hour smoothly and therefore crossfades smoothly. Both behaviours fall\r\n/// out of purity, do not add smoothing on top.\r\n///\r\n/// Feed it the SAME hour the lighting grade uses (<see cref=\"DayNightClock.GetTimeHours\"/>) and the sky can\r\n/// never disagree with the sun.\r\n/// </summary>\r\npublic static class SkyWeights\r\n{\r\n\t/// <summary>Pure: map a TOTAL game-hour to the four slot weights, using the four sky anchors in the config.\r\n\t/// Component order is (x = morning, y = noon, z = evening, w = night) so it drops straight into a shader\r\n\t/// float4 or your own four-way lerp. Continuous across every anchor including the midnight wrap, so the\r\n\t/// crossfade never pops.</summary>\r\n\tpublic static Vector4 WeightsFor( float totalHours, in DayNightConfig cfg )\r\n\t{\r\n\t\tfloat t = totalHours - MathF.Floor( totalHours / 24f ) * 24f; // hour-of-day 0..24\r\n\r\n\t\tfloat night = cfg.SkyNightHour, morning = cfg.SkyMorningHour;\r\n\t\tfloat noon = cfg.SkyNoonHour, evening = cfg.SkyEveningHour;\r\n\r\n\t\tfloat m = 0f, n = 0f, e = 0f, ni = 0f;\r\n\t\tif ( t < morning ) // night -> morning\r\n\t\t{\r\n\t\t\tfloat s = SkyGrade.Smoothstep( (t - night) / (morning - night) );\r\n\t\t\tni = 1f - s; m = s;\r\n\t\t}\r\n\t\telse if ( t < noon ) // morning -> noon\r\n\t\t{\r\n\t\t\tfloat s = SkyGrade.Smoothstep( (t - morning) / (noon - morning) );\r\n\t\t\tm = 1f - s; n = s;\r\n\t\t}\r\n\t\telse if ( t < evening ) // noon -> evening\r\n\t\t{\r\n\t\t\tfloat s = SkyGrade.Smoothstep( (t - noon) / (evening - noon) );\r\n\t\t\tn = 1f - s; e = s;\r\n\t\t}\r\n\t\telse // evening -> night (wraps to next midnight)\r\n\t\t{\r\n\t\t\tfloat s = SkyGrade.Smoothstep( (t - evening) / (24f - evening) );\r\n\t\t\te = 1f - s; ni = s;\r\n\t\t}\r\n\t\treturn new Vector4( m, n, e, ni );\r\n\t}\r\n\r\n\t/// <summary>Convenience: <see cref=\"WeightsFor(float, in DayNightConfig)\"/> with the default anchors\r\n\t/// (night 0, morning 6, noon 12, evening 18).</summary>\r\n\tpublic static Vector4 WeightsFor( float totalHours )\r\n\t{\r\n\t\tvar cfg = DayNightConfig.Default;\r\n\t\treturn WeightsFor( totalHours, cfg );\r\n\t}\r\n}\r\n"
},
{
"Ident": "fieldguide.daynight",
"Path": "TimeMath.cs",
"FileName": "TimeMath.cs",
"PackageType": "library",
"CodeKind": "Game",
"AssetVersionId": 337909,
"Code": "using System;\r\n\r\nnamespace FieldGuide.DayNight;\r\n\r\n/// <summary>Pure helpers for setting the clock from a UI. Kept separate from the networked component so they\r\n/// are trivially unit-testable and reusable (a preview tool, an editor slider, a debug console).</summary>\r\npublic static class TimeMath\r\n{\r\n\t/// <summary>Set the clock to an hour-of-day while PRESERVING the current day index, so the deterministic\r\n\t/// per-day weather roll does not re-roll when a player scrubs the time within a day. Result =\r\n\t/// floor(current/24)*24 + clamp(hourOfDay, 0, 24). Example: day 1 at 15:30 (total 39.5), set 06:00 \u2192 30.0\r\n\t/// (still day 1).</summary>\r\n\tpublic static float ComputeSetHour( float currentTotalHours, float hourOfDay )\r\n\t\t=> MathF.Floor( currentTotalHours / 24f ) * 24f + Math.Clamp( hourOfDay, 0f, 24f );\r\n\r\n\t/// <summary>Map a slider/drag fraction across a track (0 left, 1 right) to a quantized hour-of-day in\r\n\t/// [0,24], rounded to the nearest in-game MINUTE (1/60 h). A pixel-cheap throttle with no timer state: a\r\n\t/// drag emits at most one distinct value per minute of readout. Feed the result into\r\n\t/// <see cref=\"ComputeSetHour\"/> to keep the day index.\r\n\t///\r\n\t/// The result is clamped AFTER quantizing, and that clamp is load-bearing: one minute is not exactly\r\n\t/// representable in binary, so 1440 steps of 1/60 accumulate to 24.000002 and a full-right drag would\r\n\t/// hand <see cref=\"ComputeSetHour\"/> a value past the end of the day. Round first, clamp second.</summary>\r\n\tpublic static float ComputeSliderHour( float frac )\r\n\t{\r\n\t\tfloat hour = Math.Clamp( frac, 0f, 1f ) * 24f;\r\n\t\tconst float step = 1f / 60f; // one in-game minute\r\n\t\treturn Math.Clamp( MathF.Round( hour / step ) * step, 0f, 24f );\r\n\t}\r\n}\r\n"
},
{
"Ident": "fieldguide.daynight",
"Path": "Code/DayNightDriver.cs",
"FileName": "DayNightDriver.cs",
"PackageType": "library",
"CodeKind": "Game",
"AssetVersionId": 337909,
"Code": "using System;\r\nusing System.Linq;\r\nusing Sandbox;\r\n\r\nnamespace FieldGuide.DayNight;\r\n\r\n/// <summary>\r\n/// OPTIONAL convenience driver. Put it on the same GameObject as your scene's DirectionalLight and it applies\r\n/// the day/night grade every frame from the <see cref=\"DayNightClock\"/> in the scene: sun rotation + colour,\r\n/// SkyBox2D tint, and EnvmapProbe tint. It resolves the sun from its own GameObject and the sky/envmap from\r\n/// the scene (first of each). Delete this file if you would rather call <see cref=\"SkyGrade.ApplyGradeTo\"/>\r\n/// yourself.\r\n///\r\n/// It never touches exposure/shadows/fog, so it will not fight a locked-exposure camera.\r\n///\r\n/// The <see cref=\"ShowCycle\"/> seam generalizes the source game's \"am I possessing a character?\" gate. While\r\n/// it returns false the driver holds the stable ANCHOR grade (a fully-lit, non-moving model-viewer look) and\r\n/// leaves the sky at your authoring backdrop; the moment it returns true the live cycle resumes at the true\r\n/// game time (the clock keeps ticking underneath regardless). Default: always show the cycle.\r\n/// </summary>\r\n[Title( \"Day Night Driver\" )]\r\n[Category( \"Field Guide\" )]\r\n[Icon( \"wb_sunny\" )]\r\npublic sealed class DayNightDriver : Component\r\n{\r\n\t/// <summary>Tuning. Defaults to the reference grade; set it to match your clock's config.</summary>\r\n\tpublic DayNightConfig Config { get; set; } = DayNightConfig.Default;\r\n\r\n\t/// <summary>Return false to hold the stable anchor grade instead of the live cycle (e.g. while the local\r\n\t/// player is in a menu / god-camera authoring mode). Null-safe: null means always show the cycle.</summary>\r\n\tpublic Func<bool> ShowCycle { get; set; }\r\n\r\n\t/// <summary>Optional sky tint to force while <see cref=\"ShowCycle\"/> is false (your authoring backdrop). If\r\n\t/// null the anchor sky tint is used.</summary>\r\n\tpublic Color? AuthoringSkyTint { get; set; }\r\n\r\n\tDirectionalLight _sun;\r\n\tSkyBox2D _sky;\r\n\tEnvmapProbe _env;\r\n\tDayNightClock _clock;\r\n\tDayNightClock Clock => _clock ??= DayNightClock.For( Scene );\r\n\r\n\tprotected override void OnEnabled()\r\n\t{\r\n\t\t_sun = GetComponent<DirectionalLight>();\r\n\t\t_sky = Scene.GetAllComponents<SkyBox2D>().FirstOrDefault();\r\n\t\t_env = Scene.GetAllComponents<EnvmapProbe>().FirstOrDefault();\r\n\t}\r\n\r\n\tprotected override void OnUpdate()\r\n\t{\r\n\t\tif ( Scene.IsEditor ) return; // editor renders whatever you authored; the cycle is play-mode\r\n\t\tvar clock = Clock;\r\n\t\tif ( clock is null || !_sun.IsValid() ) return;\r\n\r\n\t\tif ( ShowCycle is not null && !ShowCycle() )\r\n\t\t{\r\n\t\t\t// Stable anchor look (fully lit, not moving). Pass null for the sky so the anchor warm sky tint is not\r\n\t\t\t// applied, then force the authoring backdrop tint.\r\n\t\t\tSkyGrade.ApplyGradeTo( _sun, null, _env, Config.AnchorHours, WeatherKind.Clear, Config );\r\n\t\t\tif ( _sky.IsValid() ) _sky.Tint = AuthoringSkyTint ?? Config.SkyTint;\r\n\t\t\treturn;\r\n\t\t}\r\n\r\n\t\tfloat total = clock.GetTimeHours();\r\n\t\tvar weather = clock.EffectiveWeather( (int)MathF.Floor( total / 24f ) );\r\n\t\tSkyGrade.ApplyGradeTo( _sun, _sky, _env, total, weather, Config );\r\n\t}\r\n}\r\n"
},
{
"Ident": "fieldguide.daynight",
"Path": "Code/Demo/DayNightHintCard.razor",
"FileName": "DayNightHintCard.razor",
"PackageType": "library",
"CodeKind": "Game",
"AssetVersionId": 337909,
"Code": "@using Sandbox\r\n@using Sandbox.UI\r\n@using System\r\n@using System.Collections.Generic\r\n@namespace FieldGuide.DayNight\r\n@inherits PanelComponent\r\n@attribute [StyleSheet]\r\n\r\n@*\r\n\tThe demo's on-screen key card, up from the first frame. A scene with no visible instructions reads as\r\n\ta broken scene: you press nothing, nothing happens, you close it. So this says what the demo is and\r\n\twhich keys do what, before you have touched anything.\r\n\r\n\tIt also carries the one thing the demo could not otherwise show. The kit ships no sky shader and no\r\n\tsky art on purpose; the sky is a SEAM, four normalized crossfade weights per hour. Those weights have\r\n\tno picture, so the card prints them live and they visibly hand off from one slot to the next as the\r\n\tclock runs. That is the seam doing its job, on screen, with no art involved.\r\n\r\n\tRows render in MAIN markup via @foreach per the fragment-undermeasure gotcha. H hides the card (a\r\n\tletter, never an F key, which the editor eats in play). No ESC anywhere: house law.\r\n\r\n\tLook and layout follow the Field Kit UI system: docs/design/ui-system/daynight-kit.dc.html for this\r\n\tscreen, tokens.dc.html for the values. Font sizes come from the {12, 13, 14, 16} panel scale and there\r\n\tis no letter-spacing; the stylesheet head lists the rest of the engine-legality rules.\r\n\r\n\tNot part of the kit's runtime surface: delete Code/Demo when you drop the kit into your own project.\r\n*@\r\n\r\n<root>\r\n@* DemoActive is the inert-by-construction gate (library law 11): only DayNightDemoBootstrap sets it, so\r\n this card cannot appear in a consumer's game even if Code/Demo was left in the project. *@\r\n@if ( CardOpen && DayNightDemoBootstrap.DemoActive )\r\n{\r\n\t<div class=\"dh-card\">\r\n\t\t<div class=\"dh-hdr\">\r\n\t\t\t<span class=\"dh-title\">DAY / NIGHT KIT DEMO</span>\r\n\t\t\t<div class=\"dh-x\" onclick=@(() => CardOpen = false)>\u00d7</div>\r\n\t\t</div>\r\n\r\n\t\t<div class=\"dh-lede\">One directional light, one skybox, one clock. Watch the sun sweep and the colour grade follow it, or open the time panel and drive the cycle yourself.</div>\r\n\r\n\t\t<div class=\"dh-rows\">\r\n\t\t\t@foreach ( var r in Keys )\r\n\t\t\t{\r\n\t\t\t\tstring key = r.key; // plain locals before interpolating: an inline tuple read can render blank\r\n\t\t\t\tstring what = r.what;\r\n\t\t\t\t<div class=\"dh-row\">\r\n\t\t\t\t\t<span class=\"dh-key\">@key</span>\r\n\t\t\t\t\t<span class=\"dh-what\">@what</span>\r\n\t\t\t\t</div>\r\n\t\t\t}\r\n\t\t</div>\r\n\r\n\t\t<div class=\"dh-live\">\r\n\t\t\t@foreach ( var w in Weights )\r\n\t\t\t{\r\n\t\t\t\tstring run = w;\r\n\t\t\t\t<span class=\"dh-lk\">@run</span>\r\n\t\t\t}\r\n\t\t</div>\r\n\r\n\t\t<div class=\"dh-foot\">Those four are the sky seam. The kit ships no sky shader and no sky art; you crossfade your own sky from these weights.</div>\r\n\t</div>\r\n}\r\n</root>\r\n\r\n@code\r\n{\r\n\tstatic bool _open = true;\r\n\r\n\t/// <summary>Console fallback: `daynight_hint 1` / `daynight_hint 0` shows or hides the card (H also\r\n\t/// toggles). Starts SHOWN, unlike the time panel, because it is the thing that tells you the time panel\r\n\t/// exists.</summary>\r\n\t[ConVar( \"daynight_hint\", Help = \"Show or hide the demo scene's key card (same as the H key)\" )]\r\n\tpublic static bool CardOpen { get => _open; set => _open = value; }\r\n\r\n\tstatic readonly List<(string key, string what)> Keys = new()\r\n\t{\r\n\t\t( \"N\", \"Open the time panel: scrub the clock, change the pace, pin the weather\" ),\r\n\t\t( \"H\", \"Hide this card\" ),\r\n\t};\r\n\r\n\tDayNightClock _clock;\r\n\r\n\tDayNightClock Clock\r\n\t{\r\n\t\tget\r\n\t\t{\r\n\t\t\tif ( _clock.IsValid() ) return _clock;\r\n\t\t\t_clock = DayNightClock.For( Scene );\r\n\t\t\treturn _clock;\r\n\t\t}\r\n\t}\r\n\r\n\t/// <summary>The live sky weights as four short atomic runs. Split into separate spans rather than one\r\n\t/// sentence so a wrap breaks BETWEEN runs; a single long run wraps mid-word, which is a live bug class\r\n\t/// in this engine's text layout.</summary>\r\n\tList<string> Weights\r\n\t{\r\n\t\tget\r\n\t\t{\r\n\t\t\tvar c = Clock;\r\n\t\t\tvar cfg = c?.Config ?? DayNightConfig.Default;\r\n\t\t\tvar w = SkyWeights.WeightsFor( c?.GetTimeHours() ?? 0f, cfg );\r\n\t\t\treturn new List<string>\r\n\t\t\t{\r\n\t\t\t\t$\"MORNING {w.x:0.00}\",\r\n\t\t\t\t$\"NOON {w.y:0.00}\",\r\n\t\t\t\t$\"EVENING {w.z:0.00}\",\r\n\t\t\t\t$\"NIGHT {w.w:0.00}\",\r\n\t\t\t};\r\n\t\t}\r\n\t}\r\n\r\n\tprotected override void OnUpdate()\r\n\t{\r\n\t\tif ( Input.Keyboard.Pressed( \"H\" ) )\r\n\t\t\tCardOpen = !CardOpen;\r\n\t}\r\n\r\n\t// Fold the card state and every printed weight (to the two decimals shown), or the strip freezes at\r\n\t// whatever it read on the first frame while the sun keeps moving.\r\n\tprotected override int BuildHash()\r\n\t{\r\n\t\tvar c = Clock;\r\n\t\tvar cfg = c?.Config ?? DayNightConfig.Default;\r\n\t\tvar w = SkyWeights.WeightsFor( c?.GetTimeHours() ?? 0f, cfg );\r\n\t\treturn HashCode.Combine( CardOpen, DayNightDemoBootstrap.DemoActive,\r\n\t\t\t(int)MathF.Round( w.x * 100f ),\r\n\t\t\t(int)MathF.Round( w.y * 100f ),\r\n\t\t\t(int)MathF.Round( w.z * 100f ),\r\n\t\t\t(int)MathF.Round( w.w * 100f ) );\r\n\t}\r\n}\r\n"
},
{
"Ident": "fieldguide.daynight",
"Path": "Code/Ui/DayNightPanel.razor",
"FileName": "DayNightPanel.razor",
"PackageType": "library",
"CodeKind": "Game",
"AssetVersionId": 337909,
"Code": "@using Sandbox\r\n@using Sandbox.UI\r\n@using System\r\n@using System.Collections.Generic\r\n@using System.Linq\r\n@namespace FieldGuide.DayNight\r\n@inherits PanelComponent\r\n@attribute [StyleSheet]\r\n\r\n@*\r\n\tThe kit's dev tuning surface for the day/night cycle: scrub the clock, change the pace, jump to an\r\n\thour, pin the weather, hold and resume time, and copy the resulting config as a paste-ready C# block.\r\n\tEvery write goes through DayNightClock's authority-guarded setters, so this panel is safe to leave in\r\n\ta networked session: on a client the setters are quiet no-ops and the card says so instead of\r\n\tpretending the drag did something.\r\n\r\n\tOptional. Delete Code/Ui if you would rather drive the clock from your own UI; nothing else in the kit\r\n\treferences this file.\r\n\r\n\tRows render in MAIN markup (no RenderFragment) per the fragment-undermeasure gotcha, and each slider\r\n\tis a shape pair (track + fill), which keeps the text-run count low. Toggle with N (a raw letter key,\r\n\tnever an F key: the editor eats those in play), the 42px x in the header, or the `daynight_panel`\r\n\tconsole convar. Starts closed unless OpenOnStart is set; see the boot block in OnUpdate.\r\n\r\n\tLook and layout follow the Field Kit UI system: docs/design/ui-system/daynight-kit.dc.html for this\r\n\tscreen, tokens.dc.html for the values. The stylesheet carries this kit's own copy of those tokens\r\n\t(kits cannot import each other) and lists the engine-legality translations at its head, including the\r\n\tinline-unquoted font-family rule that a $variable silently breaks.\r\n\r\n\tOne deliberate departure from the mockup: the weather group carries a fourth segment, \"auto\". The\r\n\tmockup shows three pinned kinds, but the clock's deterministic roll is the DEFAULT state and a panel\r\n\twith no way back to it can only pin, never release. Auto writes the -1 override.\r\n*@\r\n\r\n<root>\r\n@if ( PanelOpen )\r\n{\r\n\t<div class=\"dn-card\">\r\n\t\t<div class=\"dn-hdr\">\r\n\t\t\t<span class=\"dn-title\">DAY / NIGHT \u00b7 dev</span>\r\n\t\t\t<div class=\"dn-hr\">\r\n\t\t\t\t<span class=\"dn-key\">N</span>\r\n\t\t\t\t<div class=\"dn-x\" onclick=@ClosePanel>\u00d7</div>\r\n\t\t\t</div>\r\n\t\t</div>\r\n\r\n\t\t@if ( Clock is null )\r\n\t\t{\r\n\t\t\t<div class=\"dn-empty\">No DayNightClock in this scene. Add one to your session GameObject and this panel drives it.</div>\r\n\t\t}\r\n\t\telse\r\n\t\t{\r\n\t\t\t@* ---- hero readout: the whole point of the kit, in one line ---- *@\r\n\t\t\t<div class=\"dn-hero\">\r\n\t\t\t\t<span class=\"dn-hl\">Clock</span>\r\n\t\t\t\t<span class=\"dn-hv\">@ClockText</span>\r\n\t\t\t</div>\r\n\r\n\t\t\t<div class=\"dn-meta\">\r\n\t\t\t\t<span class=\"dn-mk\">@DayText</span>\r\n\t\t\t\t<span class=\"dn-mk\">@WeatherText</span>\r\n\t\t\t\t<span class=\"dn-mk\">@PaceText</span>\r\n\t\t\t</div>\r\n\r\n\t\t\t@if ( !IsAuthority )\r\n\t\t\t{\r\n\t\t\t\t<div class=\"dn-note\">The host owns the clock. This card reads the replicated time; the controls below do nothing here.</div>\r\n\t\t\t}\r\n\r\n\t\t\t@* ---- the two dials ---- *@\r\n\t\t\t@foreach ( var d in Dials )\r\n\t\t\t{\r\n\t\t\t\tvar dial = d;\r\n\t\t\t\tstring lab = dial.label; // plain locals before interpolating: an inline field read can render blank\r\n\t\t\t\tstring val = ValueText( dial.kind );\r\n\t\t\t\tint fillPct = (int)( Frac( dial ) * 100f );\r\n\t\t\t\t<div class=\"dn-row\">\r\n\t\t\t\t\t<div class=\"dn-rlab\">\r\n\t\t\t\t\t\t<span class=\"dn-rl\">@lab</span>\r\n\t\t\t\t\t\t<span class=\"dn-rv\">@val</span>\r\n\t\t\t\t\t</div>\r\n\t\t\t\t\t<div class=\"dn-slider\">\r\n\t\t\t\t\t\t<span class=\"dn-stp\" onclick=@(() => Nudge( dial, -dial.step ))>\u2212</span>\r\n\t\t\t\t\t\t<div class=\"dn-hit\"\r\n\t\t\t\t\t\t\tonmousedown=@(e => TrackPointer( e, dial, true ))\r\n\t\t\t\t\t\t\tonmousemove=@(e => TrackPointer( e, dial, false ))>\r\n\t\t\t\t\t\t\t<div class=\"dn-track\"\r\n\t\t\t\t\t\t\t\tonmousedown=@(e => TrackPointer( e, dial, true ))\r\n\t\t\t\t\t\t\t\tonmousemove=@(e => TrackPointer( e, dial, false ))>\r\n\t\t\t\t\t\t\t\t<div class=\"dn-fill\" style=\"width: @(fillPct)%;\"></div>\r\n\t\t\t\t\t\t\t</div>\r\n\t\t\t\t\t\t</div>\r\n\t\t\t\t\t\t<span class=\"dn-stp\" onclick=@(() => Nudge( dial, dial.step ))>+</span>\r\n\t\t\t\t\t</div>\r\n\t\t\t\t</div>\r\n\t\t\t}\r\n\r\n\t\t\t@* ---- jump to a named hour ---- *@\r\n\t\t\t<div class=\"dn-row\">\r\n\t\t\t\t<span class=\"dn-rl\">Jump to</span>\r\n\t\t\t\t<div class=\"dn-chips\">\r\n\t\t\t\t\t@foreach ( var j in Jumps )\r\n\t\t\t\t\t{\r\n\t\t\t\t\t\tvar jump = j;\r\n\t\t\t\t\t\tstring jl = jump.label;\r\n\t\t\t\t\t\t<div class=\"dn-chip @(IsAtHour( jump.hour ) ? \"on\" : \"\")\" onclick=@(() => JumpTo( jump.hour ))>@jl</div>\r\n\t\t\t\t\t}\r\n\t\t\t\t</div>\r\n\t\t\t</div>\r\n\r\n\t\t\t@* ---- weather: three pins plus a way back to the deterministic roll ---- *@\r\n\t\t\t<div class=\"dn-row\">\r\n\t\t\t\t<span class=\"dn-rl\">Weather</span>\r\n\t\t\t\t<div class=\"dn-seg-group wide\">\r\n\t\t\t\t\t<div class=\"dn-seg grow @(WeatherPin == -1 ? \"on\" : \"\")\" onclick=@(() => PinWeather( -1 ))>auto</div>\r\n\t\t\t\t\t<div class=\"dn-seg grow @(WeatherPin == 0 ? \"on\" : \"\")\" onclick=@(() => PinWeather( 0 ))>clear</div>\r\n\t\t\t\t\t<div class=\"dn-seg grow @(WeatherPin == 1 ? \"on\" : \"\")\" onclick=@(() => PinWeather( 1 ))>cloudy</div>\r\n\t\t\t\t\t<div class=\"dn-seg grow @(WeatherPin == 2 ? \"on\" : \"\")\" onclick=@(() => PinWeather( 2 ))>rain</div>\r\n\t\t\t\t</div>\r\n\t\t\t</div>\r\n\r\n\t\t\t@* ---- hold or resume ---- *@\r\n\t\t\t<div class=\"dn-inline\">\r\n\t\t\t\t<span class=\"dn-rl\">Clock running</span>\r\n\t\t\t\t<div class=\"dn-seg-group\">\r\n\t\t\t\t\t<div class=\"dn-seg tight @(Paused ? \"\" : \"on\")\" onclick=@(() => SetPaused( false ))>run</div>\r\n\t\t\t\t\t<div class=\"dn-seg tight @(Paused ? \"on\" : \"\")\" onclick=@(() => SetPaused( true ))>pause</div>\r\n\t\t\t\t</div>\r\n\t\t\t</div>\r\n\r\n\t\t\t@* ---- actions ---- *@\r\n\t\t\t<div class=\"dn-btns\">\r\n\t\t\t\t<div class=\"dn-btn\" onclick=@ResetAll>Reset</div>\r\n\t\t\t\t<div class=\"dn-btn primary\" onclick=@CopyConfig>@_copyLabel</div>\r\n\t\t\t</div>\r\n\t\t}\r\n\t</div>\r\n}\r\n</root>\r\n\r\n@code\r\n{\r\n\t// ---- toggle state (N raw key + `daynight_panel` convar fallback) ----\r\n\tstatic bool _open;\r\n\r\n\t/// <summary>Console fallback: `daynight_panel 1` / `daynight_panel 0` opens or closes the time panel\r\n\t/// (N also toggles).</summary>\r\n\t[ConVar( \"daynight_panel\", Help = \"Open or close the day/night time panel (same as the N key)\" )]\r\n\tpublic static bool PanelOpen { get => _open; set => _open = value; }\r\n\r\n\t/// <summary>\r\n\t/// Whether this panel starts open. Off by default: a dev tuning surface that appears unbidden over a\r\n\t/// consumer's game is a bug, not a feature. Turn it on for a scene whose whole point is the panel, the\r\n\t/// way the kit's own demo does.\r\n\t///\r\n\t/// This is what decides the panel's boot state, and it is the ONLY thing that decides it. See the boot\r\n\t/// block in OnUpdate for why that matters.\r\n\t/// </summary>\r\n\t[Property] public bool OpenOnStart { get; set; }\r\n\r\n\t/// <summary>Shortest in-game day the pace slider allows, in real minutes at the night pace.</summary>\r\n\t[Property] public float MinDayLengthMinutes { get; set; } = 1f;\r\n\r\n\t/// <summary>Longest in-game day the pace slider allows, in real minutes at the night pace.</summary>\r\n\t[Property] public float MaxDayLengthMinutes { get; set; } = 60f;\r\n\r\n\tstring _copyLabel = \"Copy config\";\r\n\tbool _wasOpen;\r\n\tbool _booted;\r\n\r\n\tDayNightClock _clock;\r\n\r\n\t/// <summary>The scene's clock, re-resolved while it is missing so a panel built before the clock still\r\n\t/// finds it. Null until one exists, which the markup handles with an explicit empty state.</summary>\r\n\tDayNightClock Clock\r\n\t{\r\n\t\tget\r\n\t\t{\r\n\t\t\tif ( _clock.IsValid() ) return _clock;\r\n\t\t\t_clock = DayNightClock.For( Scene );\r\n\t\t\treturn _clock;\r\n\t\t}\r\n\t}\r\n\r\n\t/// <summary>Single-player, or the host of a live session. Only here do the clock's setters do anything,\r\n\t/// so the card states the case rather than letting a drag fail silently.</summary>\r\n\tstatic bool IsAuthority => !Networking.IsActive || Networking.IsHost;\r\n\r\n\t// ---- readouts ----\r\n\r\n\tfloat TotalHours => Clock?.GetTimeHours() ?? 0f;\r\n\tfloat HourOfDay => TotalHours - MathF.Floor( TotalHours / 24f ) * 24f;\r\n\r\n\t/// <summary>The hero readout, HH:MM on a 24-hour clock. Minutes floor rather than round so the display\r\n\t/// never shows :60 at the top of an hour.</summary>\r\n\tstring ClockText\r\n\t{\r\n\t\tget\r\n\t\t{\r\n\t\t\tfloat h = HourOfDay;\r\n\t\t\tint hh = (int)MathF.Floor( h );\r\n\t\t\tint mm = (int)MathF.Floor( (h - hh) * 60f );\r\n\t\t\tif ( mm >= 60 ) { mm = 0; hh = (hh + 1) % 24; }\r\n\t\t\treturn $\"{hh:00}:{mm:00}\";\r\n\t\t}\r\n\t}\r\n\r\n\tstring DayText => $\"DAY {Clock?.CurrentDay ?? 0}\";\r\n\r\n\t/// <summary>Names the weather AND where it came from, because \"rain\" alone does not tell you whether the\r\n\t/// deterministic roll produced it or somebody pinned it.</summary>\r\n\tstring WeatherText\r\n\t{\r\n\t\tget\r\n\t\t{\r\n\t\t\tvar c = Clock;\r\n\t\t\tif ( c is null ) return \"WEATHER ?\";\r\n\t\t\tstring kind = c.CurrentWeather.ToString().ToUpperInvariant();\r\n\t\t\treturn WeatherPin < 0 ? $\"{kind} \u00b7 ROLLED\" : $\"{kind} \u00b7 PINNED\";\r\n\t\t}\r\n\t}\r\n\r\n\t/// <summary>The pace the clock is running at right now, as the rate multiplier the daylight ramp applies.\r\n\t/// Reads 1.00x through the night and DayRateScale at midday.</summary>\r\n\tstring PaceText\r\n\t{\r\n\t\tget\r\n\t\t{\r\n\t\t\tvar c = Clock;\r\n\t\t\tif ( c is null ) return \"PACE ?\";\r\n\t\t\tvar cfg = c.Config;\r\n\t\t\treturn $\"PACE {SkyGrade.ClockRateScale( HourOfDay, cfg ):0.00}x\";\r\n\t\t}\r\n\t}\r\n\r\n\tint WeatherPin => Clock?.NetWeatherOverride ?? -1;\r\n\tbool Paused => Clock?.TimePaused ?? false;\r\n\r\n\t// ---- the two dials ----\r\n\r\n\tenum Dial { TimeOfDay, DayLength }\r\n\r\n\tstruct DialRow { public Dial kind; public string label; public float step; }\r\n\r\n\t/// <summary>Built per read rather than held in a static, so the pace row always reflects the current\r\n\t/// MinDayLengthMinutes / MaxDayLengthMinutes properties.</summary>\r\n\tstatic List<DialRow> Dials => new()\r\n\t{\r\n\t\tnew DialRow { kind = Dial.TimeOfDay, label = \"Time of day\", step = 0.25f },\r\n\t\tnew DialRow { kind = Dial.DayLength, label = \"Day length\", step = 1f },\r\n\t};\r\n\r\n\t/// <summary>Row value text. Time of day reads as the 0..1 fraction the slider is at (the readable clock\r\n\t/// is the hero line above it); day length reads in real minutes.</summary>\r\n\tstring ValueText( Dial kind )\r\n\t{\r\n\t\tvar c = Clock;\r\n\t\tif ( c is null ) return \"-\";\r\n\t\treturn kind switch\r\n\t\t{\r\n\t\t\tDial.TimeOfDay => (HourOfDay / 24f).ToString( \"0.00\" ),\r\n\t\t\tDial.DayLength => $\"{c.Config.DayLengthMinutes:0} min\",\r\n\t\t\t_ => \"-\",\r\n\t\t};\r\n\t}\r\n\r\n\tfloat Get( Dial kind )\r\n\t{\r\n\t\tvar c = Clock;\r\n\t\tif ( c is null ) return 0f;\r\n\t\treturn kind switch\r\n\t\t{\r\n\t\t\tDial.TimeOfDay => HourOfDay,\r\n\t\t\tDial.DayLength => c.Config.DayLengthMinutes,\r\n\t\t\t_ => 0f,\r\n\t\t};\r\n\t}\r\n\r\n\tfloat Min( Dial kind ) => kind == Dial.TimeOfDay ? 0f : MathF.Max( 0.1f, MinDayLengthMinutes );\r\n\tfloat Max( Dial kind ) => kind == Dial.TimeOfDay ? 24f : MathF.Max( Min( kind ) + 0.1f, MaxDayLengthMinutes );\r\n\r\n\tfloat Frac( DialRow row )\r\n\t{\r\n\t\tfloat min = Min( row.kind ), max = Max( row.kind );\r\n\t\treturn Math.Clamp( (Get( row.kind ) - min) / MathF.Max( max - min, 0.0001f ), 0f, 1f );\r\n\t}\r\n\r\n\tvoid Set( Dial kind, float value )\r\n\t{\r\n\t\tvar c = Clock;\r\n\t\tif ( c is null ) return;\r\n\t\tswitch ( kind )\r\n\t\t{\r\n\t\t\tcase Dial.TimeOfDay:\r\n\t\t\t\t// Day-preserving, so scrubbing inside a day never re-rolls that day's weather. The top of the\r\n\t\t\t\t// range is 23:59, not 24:00: hour 24 IS the next day's midnight, so a full-right drag would\r\n\t\t\t\t// tip the day index over, re-roll the weather and snap the slider back to the far left. This\r\n\t\t\t\t// panel scrubs within a day; the clock is what advances days.\r\n\t\t\t\tc.SetTimeOfDay( Math.Clamp( value, 0f, 24f - (1f / 60f) ) );\r\n\t\t\t\tbreak;\r\n\t\t\tcase Dial.DayLength:\r\n\t\t\t\tWriteDayLength( Math.Clamp( value, Min( kind ), Max( kind ) ) );\r\n\t\t\t\tbreak;\r\n\t\t}\r\n\t}\r\n\r\n\tvoid Nudge( DialRow row, float delta ) => Set( row.kind, Get( row.kind ) + delta );\r\n\r\n\t/// <summary>\r\n\t/// Draggable track: onmousedown JUMPS to the click, onmousemove SCRUBS while the panel is Active.\r\n\t///\r\n\t/// Both the 28px transparent grab wrapper and the 14px visible track carry this handler, and a press on\r\n\t/// the track bubbles to the wrapper as well, so a single click can run it twice. That is harmless\r\n\t/// BECAUSE the write is absolute (set to the value under the cursor), not relative: two runs of the same\r\n\t/// press land on the same value. Keep it absolute if you touch this.\r\n\t/// </summary>\r\n\tvoid TrackPointer( PanelEvent ev, DialRow row, bool jump )\r\n\t{\r\n\t\tif ( ev is not MousePanelEvent e ) return;\r\n\t\tvar track = e.This;\r\n\t\tif ( track is null ) return;\r\n\t\tif ( !jump && !track.PseudoClass.HasFlag( PseudoClass.Active ) ) return;\r\n\r\n\t\tfloat w = track.Box.Rect.Width;\r\n\t\tif ( w <= 0f ) return;\r\n\t\tfloat frac = Math.Clamp( e.LocalPosition.x / w, 0f, 1f );\r\n\r\n\t\tif ( row.kind == Dial.TimeOfDay )\r\n\t\t{\r\n\t\t\t// Quantized to one in-game minute by the kit's own pure helper, so a drag emits at most one\r\n\t\t\t// distinct value per minute of readout instead of one per pixel.\r\n\t\t\tSet( Dial.TimeOfDay, TimeMath.ComputeSliderHour( frac ) );\r\n\t\t\treturn;\r\n\t\t}\r\n\r\n\t\tfloat min = Min( row.kind ), max = Max( row.kind );\r\n\t\tfloat target = min + frac * (max - min);\r\n\t\tif ( row.step > 0f ) target = MathF.Round( target / row.step ) * row.step;\r\n\t\tSet( row.kind, target );\r\n\t}\r\n\r\n\t// ---- jump chips ----\r\n\r\n\tstruct JumpRow { public string label; public float hour; }\r\n\r\n\t/// <summary>The four named hours, read off the clock's own config so a game with a different daylight\r\n\t/// window still gets its real dawn and dusk rather than 6 and 18.</summary>\r\n\tList<JumpRow> Jumps\r\n\t{\r\n\t\tget\r\n\t\t{\r\n\t\t\tvar cfg = Clock?.Config ?? DayNightConfig.Default;\r\n\t\t\treturn new List<JumpRow>\r\n\t\t\t{\r\n\t\t\t\tnew JumpRow { label = \"dawn\", hour = cfg.SunriseHour },\r\n\t\t\t\tnew JumpRow { label = \"noon\", hour = (cfg.SunriseHour + cfg.SunsetHour) * 0.5f },\r\n\t\t\t\tnew JumpRow { label = \"dusk\", hour = cfg.SunsetHour },\r\n\t\t\t\tnew JumpRow { label = \"midnight\", hour = 0f },\r\n\t\t\t};\r\n\t\t}\r\n\t}\r\n\r\n\t/// <summary>Is the clock within a minute of this named hour? A jump lands exactly, so a one-minute window\r\n\t/// is enough to light the chip and narrow enough that it goes out as soon as time moves on.</summary>\r\n\tbool IsAtHour( float hour ) => MathF.Abs( HourOfDay - hour ) < (1f / 60f);\r\n\r\n\tvoid JumpTo( float hour ) => Set( Dial.TimeOfDay, hour );\r\n\r\n\t// ---- weather, pause, config writes ----\r\n\r\n\tvoid PinWeather( int kind ) => Clock?.SetWeatherOverride( kind );\r\n\r\n\tvoid SetPaused( bool paused ) => Clock?.SetPaused( paused );\r\n\r\n\t/// <summary>\r\n\t/// Write a new day length onto the clock AND every driver in the scene.\r\n\t///\r\n\t/// DayNightConfig is a STRUCT, so `clock.Config.DayLengthMinutes = x` would mutate a temporary copy and\r\n\t/// change nothing. Read, edit, write back. The drivers get the same value because a consumer is told to\r\n\t/// keep clock and driver config identical, and a tuning panel that quietly desynchronised them would be\r\n\t/// the exact bug the docs warn about.\r\n\t///\r\n\t/// AUTHORITY-GUARDED, unlike the clock's own setters which guard themselves. Config is authoring data and\r\n\t/// is NOT replicated, so a client that changed its own day length would extrapolate at a different pace\r\n\t/// than the host and drift between every snapshot. The guard has to live here.\r\n\t/// </summary>\r\n\tvoid WriteDayLength( float minutes )\r\n\t{\r\n\t\tif ( !IsAuthority ) return;\r\n\t\tvar c = Clock;\r\n\t\tif ( c is null ) return;\r\n\t\tvar cfg = c.Config;\r\n\t\tcfg.DayLengthMinutes = minutes;\r\n\t\tc.Config = cfg;\r\n\r\n\t\tforeach ( var driver in Scene.GetAllComponents<DayNightDriver>() )\r\n\t\t{\r\n\t\t\tvar dcfg = driver.Config;\r\n\t\t\tdcfg.DayLengthMinutes = minutes;\r\n\t\t\tdriver.Config = dcfg;\r\n\t\t}\r\n\t}\r\n\r\n\t/// <summary>Back to the shipped reference grade: default config on the clock and every driver, weather\r\n\t/// released to the deterministic roll, clock running, time at the config's start hour.</summary>\r\n\tvoid ResetAll()\r\n\t{\r\n\t\tif ( !IsAuthority ) return; // same reason as WriteDayLength: config is authoring data, not session state\r\n\t\tvar c = Clock;\r\n\t\tif ( c is null ) return;\r\n\t\tvar def = DayNightConfig.Default;\r\n\t\tc.Config = def;\r\n\t\tforeach ( var driver in Scene.GetAllComponents<DayNightDriver>() )\r\n\t\t\tdriver.Config = def;\r\n\r\n\t\tc.SetWeatherOverride( -1 );\r\n\t\tc.SetPaused( false );\r\n\t\tc.SetTimeOfDay( def.StartHours );\r\n\t\t_copyLabel = \"Copy config\";\r\n\t}\r\n\r\n\t/// <summary>Put the tuned config on the system clipboard as a paste-ready C# block. Game-side\r\n\t/// Sandbox.UI.Clipboard.SetText, so it works in play without an editor round trip. Only the fields this\r\n\t/// panel can move are emitted; everything else stays whatever Default gives you.</summary>\r\n\tvoid CopyConfig()\r\n\t{\r\n\t\tvar c = Clock;\r\n\t\tif ( c is null ) return;\r\n\t\tvar cfg = c.Config;\r\n\t\tstring text =\r\n\t\t\t\"var cfg = DayNightConfig.Default;\\n\"\r\n\t\t\t+ $\"cfg.DayLengthMinutes = {cfg.DayLengthMinutes.ToString( \"0.###\" )}f;\\n\"\r\n\t\t\t+ $\"cfg.StartHours = {HourOfDay.ToString( \"0.###\" )}f;\\n\"\r\n\t\t\t+ $\"cfg.StartPaused = {(Paused ? \"true\" : \"false\")};\\n\"\r\n\t\t\t+ \"clock.Config = cfg;\";\r\n\t\tSandbox.UI.Clipboard.SetText( text );\r\n\t\t_copyLabel = \"Copied!\";\r\n\t}\r\n\r\n\t// ---- boot state, N toggle, cursor while open ----\r\n\r\n\tprotected override void OnUpdate()\r\n\t{\r\n\t\t// BOOT. `daynight_panel` is a convar and s&box PERSISTS convars across sessions, so a session can\r\n\t\t// otherwise come up with the panel logically open from whatever someone left set weeks ago. The rule\r\n\t\t// that prevents it: this component's own OpenOnStart decides the boot state, and the persisted value\r\n\t\t// never does. Default off means a consumer game still cannot be pre-opened by a stale convar; a scene\r\n\t\t// that wants the panel up says so explicitly.\r\n\t\t//\r\n\t\t// Deliberately in the FIRST UPDATE rather than OnStart. A panel built in code is configured by the\r\n\t\t// component that created it, and doing this in OnStart would race that assignment: whichever ran\r\n\t\t// first would win. The first update is after every OnStart in the frame, so the setting is always\r\n\t\t// read, never half-applied.\r\n\t\tif ( !_booted )\r\n\t\t{\r\n\t\t\t_booted = true;\r\n\t\t\tif ( PanelOpen && !OpenOnStart )\r\n\t\t\t\tLog.Info( \"[daynight] time panel was OPEN at session start (persisted convar), forcing closed\" );\r\n\t\t\tPanelOpen = OpenOnStart;\r\n\t\t}\r\n\r\n\t\tif ( Input.Keyboard.Pressed( \"N\" ) )\r\n\t\t\tPanelOpen = !PanelOpen;\r\n\r\n\t\tif ( PanelOpen )\r\n\t\t{\r\n\t\t\tMouse.Visibility = MouseVisibility.Visible; // keep the cursor usable over the panel\r\n\t\t\t_wasOpen = true;\r\n\t\t}\r\n\t\telse if ( _wasOpen )\r\n\t\t{\r\n\t\t\t_wasOpen = false;\r\n\t\t\t_copyLabel = \"Copy config\"; // closing clears the flash, so a reopen never claims a copy that was not made\r\n\t\t}\r\n\t}\r\n\r\n\tvoid ClosePanel()\r\n\t{\r\n\t\tPanelOpen = false;\r\n\t\t_copyLabel = \"Copy config\";\r\n\t}\r\n\r\n\t// Fold the toggle, the clock (to the displayed minute), the pace, the weather pin, the pause state and\r\n\t// the copy label. Miss one and that readout freezes on screen while the world keeps moving.\r\n\tprotected override int BuildHash()\r\n\t{\r\n\t\tvar c = Clock;\r\n\t\tint minute = (int)MathF.Round( HourOfDay * 60f );\r\n\t\tint pace = c is null ? 0 : (int)MathF.Round( SkyGrade.ClockRateScale( HourOfDay, c.Config ) * 1000f );\r\n\t\tint length = c is null ? 0 : (int)MathF.Round( c.Config.DayLengthMinutes * 100f );\r\n\t\treturn HashCode.Combine( PanelOpen, c is not null, minute, pace, length, WeatherPin, Paused, _copyLabel );\r\n\t}\r\n}\r\n"
},
{
"Ident": "fieldguide.daynight",
"Path": "SkyGrade.cs",
"FileName": "SkyGrade.cs",
"PackageType": "library",
"CodeKind": "Game",
"AssetVersionId": 337909,
"Code": "using System;\r\nusing Sandbox;\r\n\r\nnamespace FieldGuide.DayNight;\r\n\r\n/// <summary>\r\n/// The pure grading math: a total game-hour + weather + config in, a sun rotation and four colour grades out.\r\n/// No engine state, no randomness, so a host and every client compute the SAME look from the same clock, and\r\n/// the editor / a headless test can render or check the exact cycle a play session shows.\r\n///\r\n/// ANCHOR-EXACT BY CONSTRUCTION: at <see cref=\"DayNightConfig.AnchorHours\"/> (Clear) every output equals the\r\n/// reference grade in the config, so pinning the anchor reproduces the authored look byte-for-byte. The\r\n/// daytime arc is derived from <see cref=\"DayNightConfig.SunDirection\"/> and lerps FROM the reference colours,\r\n/// and a twilight band smoothstep-blends dusk/dawn to the night grade so there is no hard flip at the horizon.\r\n///\r\n/// This math NEVER touches exposure, shadows, or fog. Apply it and your night is dark because the sun and sky\r\n/// colours are dark, not because tone-mapping moved.\r\n/// </summary>\r\npublic static class SkyGrade\r\n{\r\n\t/// <summary>Smoothstep 0\u21921 with clamp, the twilight blend easing (deterministic; no engine state).</summary>\r\n\tpublic static float Smoothstep( float x )\r\n\t{\r\n\t\tx = Math.Clamp( x, 0f, 1f );\r\n\t\treturn x * x * (3f - 2f * x);\r\n\t}\r\n\r\n\t/// <summary>Compute the sun rotation + all four colour grades for a TOTAL game-hour + weather. Anchor-exact:\r\n\t/// at <see cref=\"DayNightConfig.AnchorHours\"/> (Clear) every value equals the config reference grade. Never\r\n\t/// computes exposure, that stays whatever your camera set it to.</summary>\r\n\tpublic static void ComputeGrade( float total, WeatherKind weather, in DayNightConfig cfg,\r\n\t\tout Rotation sunRot, out Color sunColor, out Color skyColor, out Color skyTint, out Color envTint )\r\n\t{\r\n\t\tvar anchor = Rotation.LookAt( cfg.SunDirection.Normal ).Angles(); // reference sun pitch/yaw\r\n\t\tint day = (int)MathF.Floor( total / 24f );\r\n\t\tfloat t = total - day * 24f; // hour-of-day 0..24\r\n\r\n\t\tfloat sunrise = cfg.SunriseHour, sunset = cfg.SunsetHour;\r\n\t\tbool isDay = t >= sunrise && t <= sunset;\r\n\t\tfloat p = isDay ? (t - sunrise) / (sunset - sunrise) : 0f; // 0 at sunrise .. 1 at sunset\r\n\t\tfloat daylight = isDay ? MathF.Sin( p * MathF.PI ) : 0f; // 0 night .. 1 noon\r\n\r\n\t\tfloat pAnchor = (cfg.AnchorHours - sunrise) / (sunset - sunrise);\r\n\t\tfloat dlAnchor = MathF.Sin( pAnchor * MathF.PI );\r\n\r\n\t\tfloat weatherDim = weather switch\r\n\t\t{\r\n\t\t\tWeatherKind.Rain => cfg.WeatherDimRain,\r\n\t\t\tWeatherKind.Cloudy => cfg.WeatherDimCloudy,\r\n\t\t\t_ => 1f,\r\n\t\t};\r\n\r\n\t\tif ( isDay )\r\n\t\t{\r\n\t\t\t// \u2500\u2500 DAYTIME (anchor-exact by construction) \u2500\u2500\r\n\t\t\t// ROTATION: pitch grows from the near-horizon value to the derived noon max, threading the derived\r\n\t\t\t// anchor pitch; yaw sweeps east\u2192west across the day, threading the anchor yaw.\r\n\t\t\tfloat pitchSpan = (anchor.pitch - cfg.HorizonPitch) / dlAnchor;\r\n\t\t\tfloat pitch = cfg.HorizonPitch + daylight * pitchSpan;\r\n\t\t\tfloat yaw = anchor.yaw + (p - pAnchor) * cfg.YawSpan;\r\n\t\t\tsunRot = Rotation.From( pitch, yaw, 0f );\r\n\r\n\t\t\t// KEY COLOUR: lerp from the reference SunColor toward whiter noon / warmer horizon, anchored so the\r\n\t\t\t// value EQUALS SunColor exactly at the anchor daylight.\r\n\t\t\tfloat rel = daylight - dlAnchor; // 0 at anchor, + toward noon, - toward horizon\r\n\t\t\tsunColor = rel >= 0f\r\n\t\t\t\t? Color.Lerp( cfg.SunColor, cfg.NoonKey, dlAnchor < 1f ? rel / (1f - dlAnchor) : 0f )\r\n\t\t\t\t: Color.Lerp( cfg.SunColor, cfg.HorizonKey, -rel / dlAnchor );\r\n\t\t\tsunColor *= weatherDim;\r\n\r\n\t\t\t// AMBIENT / SKY / ENVMAP: scale the reference grade by daylight (anchor == 1 == the exact reference).\r\n\t\t\tfloat skyScale = dlAnchor > 0f ? MathF.Min( daylight / dlAnchor, 1.15f ) : 0f;\r\n\t\t\tskyColor = cfg.SkyAmbient * skyScale;\r\n\t\t\tskyTint = cfg.SkyTint * skyScale;\r\n\t\t\tenvTint = cfg.EnvmapTint * skyScale;\r\n\t\t\treturn;\r\n\t\t}\r\n\r\n\t\t// \u2500\u2500 NIGHT + TWILIGHT. The fixed-low-moon night grade is the deep-night target; a TWILIGHT band\r\n\t\t// TwilightHours past sunset (and before sunrise) smoothstep-lerps the sun rotation AND all four colours\r\n\t\t// from the HORIZON-edge values (what the day arc reaches at sunrise/sunset, daylight\u21920) to the night\r\n\t\t// values, so dusk reads as the sun continuing its arc below the horizon, not a hard flip. At w=0 it\r\n\t\t// equals the day boundary (continuous with daytime); at w=1 it equals deep night. \u2500\u2500\r\n\t\tfloat nightPitch = cfg.NightPitch;\r\n\t\tfloat nightYaw = anchor.yaw + cfg.YawSpan * 0.6f; // fixed low moon direction\r\n\t\tColor nightSun = cfg.NightKey;\r\n\t\tColor nightSky = cfg.NightAmbient;\r\n\t\tColor nightTint = cfg.NightSkyTint;\r\n\t\tColor nightEnv = cfg.NightEnvTint;\r\n\r\n\t\t// Twilight blend factor: 0 = horizon-edge look, 1 = deep night. Evening band (just after sunset) and the\r\n\t\t// mirror morning band (just before sunrise); outside the bands it is 1 (deep night).\r\n\t\tfloat tw = cfg.TwilightHours;\r\n\t\tfloat w = 1f;\r\n\t\tbool evening = t > sunset && t <= sunset + tw;\r\n\t\tbool morning = t >= sunrise - tw && t < sunrise;\r\n\t\tif ( evening ) w = Smoothstep( (t - sunset) / tw );\r\n\t\telse if ( morning ) w = Smoothstep( (sunrise - t) / tw );\r\n\r\n\t\tif ( w >= 1f )\r\n\t\t{\r\n\t\t\t// deep night (no blend), the fixed night grade.\r\n\t\t\tsunRot = Rotation.From( nightPitch, nightYaw, 0f );\r\n\t\t\tsunColor = nightSun;\r\n\t\t\tskyColor = nightSky;\r\n\t\t\tskyTint = nightTint;\r\n\t\t\tenvTint = nightEnv;\r\n\t\t\treturn;\r\n\t\t}\r\n\r\n\t\t// HORIZON-edge grade (the day arc evaluated at the sunrise/sunset boundary, daylight\u21920): pitch sits at the\r\n\t\t// near-horizon value, yaw at that boundary's swept position, sun the deep warm HorizonKey, sky/ambient \u21920.\r\n\t\tfloat boundaryP = evening ? 1f : 0f; // sunset p=1, sunrise p=0\r\n\t\tfloat horizonPitch = cfg.HorizonPitch;\r\n\t\tfloat horizonYaw = anchor.yaw + (boundaryP - pAnchor) * cfg.YawSpan;\r\n\t\tColor horizonSun = cfg.HorizonKey * weatherDim;\r\n\r\n\t\tsunRot = Rotation.From(\r\n\t\t\tMathX.Lerp( horizonPitch, nightPitch, w ),\r\n\t\t\tMathX.LerpDegrees( horizonYaw, nightYaw, w ),\r\n\t\t\t0f );\r\n\t\tsunColor = Color.Lerp( horizonSun, nightSun, w );\r\n\t\tskyColor = Color.Lerp( Color.Black, nightSky, w ); // day-edge ambient is ~0; rise to the night floor\r\n\t\tskyTint = Color.Lerp( Color.Black, nightTint, w );\r\n\t\tenvTint = Color.Lerp( Color.Black, nightEnv, w );\r\n\t}\r\n\r\n\t/// <summary>PURE: the clock-advance rate MULTIPLIER for a given hour-of-day (0..24), applied to the base\r\n\t/// pace in <see cref=\"DayNightClock\"/>. The daylight arc runs slower (<see cref=\"DayNightConfig.DayRateScale\"/>)\r\n\t/// so the day lasts longer, while night keeps rate 1 so night real-time is preserved exactly. The two ramps\r\n\t/// live INSIDE the daylight window (a <see cref=\"DayNightConfig.TwilightHours\"/>-wide smoothstep at each edge),\r\n\t/// reaching rate 1 exactly at sunrise/sunset so there is no rate discontinuity at the night boundary. Bounded\r\n\t/// to [DayRateScale, 1], pure, so host and every client derive the same rate and stay in lockstep.</summary>\r\n\tpublic static float ClockRateScale( float hourOfDay, in DayNightConfig cfg )\r\n\t{\r\n\t\tfloat sunrise = cfg.SunriseHour, sunset = cfg.SunsetHour;\r\n\t\tfloat tw = cfg.TwilightHours;\r\n\t\tfloat dayScale = cfg.DayRateScale;\r\n\t\tconst float nightScale = 1f;\r\n\r\n\t\tfloat t = hourOfDay - MathF.Floor( hourOfDay / 24f ) * 24f; // wrap to 0..24 for callers passing total hours\r\n\r\n\t\t// Full daylight interior: the slow pace.\r\n\t\tif ( t >= sunrise + tw && t <= sunset - tw ) return dayScale;\r\n\t\t// Dawn ramp (inside the day window): rate 1 at sunrise \u2192 slow by sunrise+tw. Night stays exact because the\r\n\t\t// ramp is spent within daylight, not stolen from the night arc.\r\n\t\tif ( t >= sunrise && t < sunrise + tw ) return MathX.Lerp( nightScale, dayScale, Smoothstep( (t - sunrise) / tw ) );\r\n\t\t// Dusk ramp (inside the day window): slow until sunset-tw \u2192 rate 1 exactly at sunset, matching night.\r\n\t\tif ( t > sunset - tw && t <= sunset ) return MathX.Lerp( dayScale, nightScale, Smoothstep( (t - (sunset - tw)) / tw ) );\r\n\t\t// Night: unchanged rate, so the night arc's real-time length is preserved exactly.\r\n\t\treturn nightScale;\r\n\t}\r\n\r\n\t/// <summary>Apply the computed grade to a specific sun/sky/envmap trio. Leaves exposure / shadows / fog\r\n\t/// alone. Pass null for any of sky/env you do not have. This is the ONLY method here that writes engine\r\n\t/// state; everything above is pure.</summary>\r\n\tpublic static void ApplyGradeTo( DirectionalLight sun, SkyBox2D sky, EnvmapProbe env,\r\n\t\tfloat total, WeatherKind weather, in DayNightConfig cfg )\r\n\t{\r\n\t\tif ( !sun.IsValid() ) return;\r\n\t\tComputeGrade( total, weather, cfg, out var rot, out var sunColor, out var skyColor, out var skyTint, out var envTint );\r\n\t\tsun.WorldRotation = rot;\r\n\t\tsun.LightColor = sunColor;\r\n\t\tsun.SkyColor = skyColor;\r\n\t\tif ( sky.IsValid() ) sky.Tint = skyTint;\r\n\t\tif ( env.IsValid() ) env.TintColor = envTint;\r\n\t}\r\n}\r\n"
},
{
"Ident": "fieldguide.daynight",
"Path": "Ui/DayNightPanel.razor",
"FileName": "DayNightPanel.razor",
"PackageType": "library",
"CodeKind": "Game",
"AssetVersionId": 337909,
"Code": "@using Sandbox\r\n@using Sandbox.UI\r\n@using System\r\n@using System.Collections.Generic\r\n@using System.Linq\r\n@namespace FieldGuide.DayNight\r\n@inherits PanelComponent\r\n@attribute [StyleSheet]\r\n\r\n@*\r\n\tThe kit's dev tuning surface for the day/night cycle: scrub the clock, change the pace, jump to an\r\n\thour, pin the weather, hold and resume time, and copy the resulting config as a paste-ready C# block.\r\n\tEvery write goes through DayNightClock's authority-guarded setters, so this panel is safe to leave in\r\n\ta networked session: on a client the setters are quiet no-ops and the card says so instead of\r\n\tpretending the drag did something.\r\n\r\n\tOptional. Delete Code/Ui if you would rather drive the clock from your own UI; nothing else in the kit\r\n\treferences this file.\r\n\r\n\tRows render in MAIN markup (no RenderFragment) per the fragment-undermeasure gotcha, and each slider\r\n\tis a shape pair (track + fill), which keeps the text-run count low. Toggle with N (a raw letter key,\r\n\tnever an F key: the editor eats those in play), the 42px x in the header, or the `daynight_panel`\r\n\tconsole convar. Starts closed unless OpenOnStart is set; see the boot block in OnUpdate.\r\n\r\n\tLook and layout follow the Field Kit UI system: docs/design/ui-system/daynight-kit.dc.html for this\r\n\tscreen, tokens.dc.html for the values. The stylesheet carries this kit's own copy of those tokens\r\n\t(kits cannot import each other) and lists the engine-legality translations at its head, including the\r\n\tinline-unquoted font-family rule that a $variable silently breaks.\r\n\r\n\tOne deliberate departure from the mockup: the weather group carries a fourth segment, \"auto\". The\r\n\tmockup shows three pinned kinds, but the clock's deterministic roll is the DEFAULT state and a panel\r\n\twith no way back to it can only pin, never release. Auto writes the -1 override.\r\n*@\r\n\r\n<root>\r\n@if ( PanelOpen )\r\n{\r\n\t<div class=\"dn-card\">\r\n\t\t<div class=\"dn-hdr\">\r\n\t\t\t<span class=\"dn-title\">DAY / NIGHT \u00b7 dev</span>\r\n\t\t\t<div class=\"dn-hr\">\r\n\t\t\t\t<span class=\"dn-key\">N</span>\r\n\t\t\t\t<div class=\"dn-x\" onclick=@ClosePanel>\u00d7</div>\r\n\t\t\t</div>\r\n\t\t</div>\r\n\r\n\t\t@if ( Clock is null )\r\n\t\t{\r\n\t\t\t<div class=\"dn-empty\">No DayNightClock in this scene. Add one to your session GameObject and this panel drives it.</div>\r\n\t\t}\r\n\t\telse\r\n\t\t{\r\n\t\t\t@* ---- hero readout: the whole point of the kit, in one line ---- *@\r\n\t\t\t<div class=\"dn-hero\">\r\n\t\t\t\t<span class=\"dn-hl\">Clock</span>\r\n\t\t\t\t<span class=\"dn-hv\">@ClockText</span>\r\n\t\t\t</div>\r\n\r\n\t\t\t<div class=\"dn-meta\">\r\n\t\t\t\t<span class=\"dn-mk\">@DayText</span>\r\n\t\t\t\t<span class=\"dn-mk\">@WeatherText</span>\r\n\t\t\t\t<span class=\"dn-mk\">@PaceText</span>\r\n\t\t\t</div>\r\n\r\n\t\t\t@if ( !IsAuthority )\r\n\t\t\t{\r\n\t\t\t\t<div class=\"dn-note\">The host owns the clock. This card reads the replicated time; the controls below do nothing here.</div>\r\n\t\t\t}\r\n\r\n\t\t\t@* ---- the two dials ---- *@\r\n\t\t\t@foreach ( var d in Dials )\r\n\t\t\t{\r\n\t\t\t\tvar dial = d;\r\n\t\t\t\tstring lab = dial.label; // plain locals before interpolating: an inline field read can render blank\r\n\t\t\t\tstring val = ValueText( dial.kind );\r\n\t\t\t\tint fillPct = (int)( Frac( dial ) * 100f );\r\n\t\t\t\t<div class=\"dn-row\">\r\n\t\t\t\t\t<div class=\"dn-rlab\">\r\n\t\t\t\t\t\t<span class=\"dn-rl\">@lab</span>\r\n\t\t\t\t\t\t<span class=\"dn-rv\">@val</span>\r\n\t\t\t\t\t</div>\r\n\t\t\t\t\t<div class=\"dn-slider\">\r\n\t\t\t\t\t\t<span class=\"dn-stp\" onclick=@(() => Nudge( dial, -dial.step ))>\u2212</span>\r\n\t\t\t\t\t\t<div class=\"dn-hit\"\r\n\t\t\t\t\t\t\tonmousedown=@(e => TrackPointer( e, dial, true ))\r\n\t\t\t\t\t\t\tonmousemove=@(e => TrackPointer( e, dial, false ))>\r\n\t\t\t\t\t\t\t<div class=\"dn-track\"\r\n\t\t\t\t\t\t\t\tonmousedown=@(e => TrackPointer( e, dial, true ))\r\n\t\t\t\t\t\t\t\tonmousemove=@(e => TrackPointer( e, dial, false ))>\r\n\t\t\t\t\t\t\t\t<div class=\"dn-fill\" style=\"width: @(fillPct)%;\"></div>\r\n\t\t\t\t\t\t\t</div>\r\n\t\t\t\t\t\t</div>\r\n\t\t\t\t\t\t<span class=\"dn-stp\" onclick=@(() => Nudge( dial, dial.step ))>+</span>\r\n\t\t\t\t\t</div>\r\n\t\t\t\t</div>\r\n\t\t\t}\r\n\r\n\t\t\t@* ---- jump to a named hour ---- *@\r\n\t\t\t<div class=\"dn-row\">\r\n\t\t\t\t<span class=\"dn-rl\">Jump to</span>\r\n\t\t\t\t<div class=\"dn-chips\">\r\n\t\t\t\t\t@foreach ( var j in Jumps )\r\n\t\t\t\t\t{\r\n\t\t\t\t\t\tvar jump = j;\r\n\t\t\t\t\t\tstring jl = jump.label;\r\n\t\t\t\t\t\t<div class=\"dn-chip @(IsAtHour( jump.hour ) ? \"on\" : \"\")\" onclick=@(() => JumpTo( jump.hour ))>@jl</div>\r\n\t\t\t\t\t}\r\n\t\t\t\t</div>\r\n\t\t\t</div>\r\n\r\n\t\t\t@* ---- weather: three pins plus a way back to the deterministic roll ---- *@\r\n\t\t\t<div class=\"dn-row\">\r\n\t\t\t\t<span class=\"dn-rl\">Weather</span>\r\n\t\t\t\t<div class=\"dn-seg-group wide\">\r\n\t\t\t\t\t<div class=\"dn-seg grow @(WeatherPin == -1 ? \"on\" : \"\")\" onclick=@(() => PinWeather( -1 ))>auto</div>\r\n\t\t\t\t\t<div class=\"dn-seg grow @(WeatherPin == 0 ? \"on\" : \"\")\" onclick=@(() => PinWeather( 0 ))>clear</div>\r\n\t\t\t\t\t<div class=\"dn-seg grow @(WeatherPin == 1 ? \"on\" : \"\")\" onclick=@(() => PinWeather( 1 ))>cloudy</div>\r\n\t\t\t\t\t<div class=\"dn-seg grow @(WeatherPin == 2 ? \"on\" : \"\")\" onclick=@(() => PinWeather( 2 ))>rain</div>\r\n\t\t\t\t</div>\r\n\t\t\t</div>\r\n\r\n\t\t\t@* ---- hold or resume ---- *@\r\n\t\t\t<div class=\"dn-inline\">\r\n\t\t\t\t<span class=\"dn-rl\">Clock running</span>\r\n\t\t\t\t<div class=\"dn-seg-group\">\r\n\t\t\t\t\t<div class=\"dn-seg tight @(Paused ? \"\" : \"on\")\" onclick=@(() => SetPaused( false ))>run</div>\r\n\t\t\t\t\t<div class=\"dn-seg tight @(Paused ? \"on\" : \"\")\" onclick=@(() => SetPaused( true ))>pause</div>\r\n\t\t\t\t</div>\r\n\t\t\t</div>\r\n\r\n\t\t\t@* ---- actions ---- *@\r\n\t\t\t<div class=\"dn-btns\">\r\n\t\t\t\t<div class=\"dn-btn\" onclick=@ResetAll>Reset</div>\r\n\t\t\t\t<div class=\"dn-btn primary\" onclick=@CopyConfig>@_copyLabel</div>\r\n\t\t\t</div>\r\n\t\t}\r\n\t</div>\r\n}\r\n</root>\r\n\r\n@code\r\n{\r\n\t// ---- toggle state (N raw key + `daynight_panel` convar fallback) ----\r\n\tstatic bool _open;\r\n\r\n\t/// <summary>Console fallback: `daynight_panel 1` / `daynight_panel 0` opens or closes the time panel\r\n\t/// (N also toggles).</summary>\r\n\t[ConVar( \"daynight_panel\", Help = \"Open or close the day/night time panel (same as the N key)\" )]\r\n\tpublic static bool PanelOpen { get => _open; set => _open = value; }\r\n\r\n\t/// <summary>\r\n\t/// Whether this panel starts open. Off by default: a dev tuning surface that appears unbidden over a\r\n\t/// consumer's game is a bug, not a feature. Turn it on for a scene whose whole point is the panel, the\r\n\t/// way the kit's own demo does.\r\n\t///\r\n\t/// This is what decides the panel's boot state, and it is the ONLY thing that decides it. See the boot\r\n\t/// block in OnUpdate for why that matters.\r\n\t/// </summary>\r\n\t[Property] public bool OpenOnStart { get; set; }\r\n\r\n\t/// <summary>Shortest in-game day the pace slider allows, in real minutes at the night pace.</summary>\r\n\t[Property] public float MinDayLengthMinutes { get; set; } = 1f;\r\n\r\n\t/// <summary>Longest in-game day the pace slider allows, in real minutes at the night pace.</summary>\r\n\t[Property] public float MaxDayLengthMinutes { get; set; } = 60f;\r\n\r\n\tstring _copyLabel = \"Copy config\";\r\n\tbool _wasOpen;\r\n\tbool _booted;\r\n\r\n\tDayNightClock _clock;\r\n\r\n\t/// <summary>The scene's clock, re-resolved while it is missing so a panel built before the clock still\r\n\t/// finds it. Null until one exists, which the markup handles with an explicit empty state.</summary>\r\n\tDayNightClock Clock\r\n\t{\r\n\t\tget\r\n\t\t{\r\n\t\t\tif ( _clock.IsValid() ) return _clock;\r\n\t\t\t_clock = DayNightClock.For( Scene );\r\n\t\t\treturn _clock;\r\n\t\t}\r\n\t}\r\n\r\n\t/// <summary>Single-player, or the host of a live session. Only here do the clock's setters do anything,\r\n\t/// so the card states the case rather than letting a drag fail silently.</summary>\r\n\tstatic bool IsAuthority => !Networking.IsActive || Networking.IsHost;\r\n\r\n\t// ---- readouts ----\r\n\r\n\tfloat TotalHours => Clock?.GetTimeHours() ?? 0f;\r\n\tfloat HourOfDay => TotalHours - MathF.Floor( TotalHours / 24f ) * 24f;\r\n\r\n\t/// <summary>The hero readout, HH:MM on a 24-hour clock. Minutes floor rather than round so the display\r\n\t/// never shows :60 at the top of an hour.</summary>\r\n\tstring ClockText\r\n\t{\r\n\t\tget\r\n\t\t{\r\n\t\t\tfloat h = HourOfDay;\r\n\t\t\tint hh = (int)MathF.Floor( h );\r\n\t\t\tint mm = (int)MathF.Floor( (h - hh) * 60f );\r\n\t\t\tif ( mm >= 60 ) { mm = 0; hh = (hh + 1) % 24; }\r\n\t\t\treturn $\"{hh:00}:{mm:00}\";\r\n\t\t}\r\n\t}\r\n\r\n\tstring DayText => $\"DAY {Clock?.CurrentDay ?? 0}\";\r\n\r\n\t/// <summary>Names the weather AND where it came from, because \"rain\" alone does not tell you whether the\r\n\t/// deterministic roll produced it or somebody pinned it.</summary>\r\n\tstring WeatherText\r\n\t{\r\n\t\tget\r\n\t\t{\r\n\t\t\tvar c = Clock;\r\n\t\t\tif ( c is null ) return \"WEATHER ?\";\r\n\t\t\tstring kind = c.CurrentWeather.ToString().ToUpperInvariant();\r\n\t\t\treturn WeatherPin < 0 ? $\"{kind} \u00b7 ROLLED\" : $\"{kind} \u00b7 PINNED\";\r\n\t\t}\r\n\t}\r\n\r\n\t/// <summary>The pace the clock is running at right now, as the rate multiplier the daylight ramp applies.\r\n\t/// Reads 1.00x through the night and DayRateScale at midday.</summary>\r\n\tstring PaceText\r\n\t{\r\n\t\tget\r\n\t\t{\r\n\t\t\tvar c = Clock;\r\n\t\t\tif ( c is null ) return \"PACE ?\";\r\n\t\t\tvar cfg = c.Config;\r\n\t\t\treturn $\"PACE {SkyGrade.ClockRateScale( HourOfDay, cfg ):0.00}x\";\r\n\t\t}\r\n\t}\r\n\r\n\tint WeatherPin => Clock?.NetWeatherOverride ?? -1;\r\n\tbool Paused => Clock?.TimePaused ?? false;\r\n\r\n\t// ---- the two dials ----\r\n\r\n\tenum Dial { TimeOfDay, DayLength }\r\n\r\n\tstruct DialRow { public Dial kind; public string label; public float step; }\r\n\r\n\t/// <summary>Built per read rather than held in a static, so the pace row always reflects the current\r\n\t/// MinDayLengthMinutes / MaxDayLengthMinutes properties.</summary>\r\n\tstatic List<DialRow> Dials => new()\r\n\t{\r\n\t\tnew DialRow { kind = Dial.TimeOfDay, label = \"Time of day\", step = 0.25f },\r\n\t\tnew DialRow { kind = Dial.DayLength, label = \"Day length\", step = 1f },\r\n\t};\r\n\r\n\t/// <summary>Row value text. Time of day reads as the 0..1 fraction the slider is at (the readable clock\r\n\t/// is the hero line above it); day length reads in real minutes.</summary>\r\n\tstring ValueText( Dial kind )\r\n\t{\r\n\t\tvar c = Clock;\r\n\t\tif ( c is null ) return \"-\";\r\n\t\treturn kind switch\r\n\t\t{\r\n\t\t\tDial.TimeOfDay => (HourOfDay / 24f).ToString( \"0.00\" ),\r\n\t\t\tDial.DayLength => $\"{c.Config.DayLengthMinutes:0} min\",\r\n\t\t\t_ => \"-\",\r\n\t\t};\r\n\t}\r\n\r\n\tfloat Get( Dial kind )\r\n\t{\r\n\t\tvar c = Clock;\r\n\t\tif ( c is null ) return 0f;\r\n\t\treturn kind switch\r\n\t\t{\r\n\t\t\tDial.TimeOfDay => HourOfDay,\r\n\t\t\tDial.DayLength => c.Config.DayLengthMinutes,\r\n\t\t\t_ => 0f,\r\n\t\t};\r\n\t}\r\n\r\n\tfloat Min( Dial kind ) => kind == Dial.TimeOfDay ? 0f : MathF.Max( 0.1f, MinDayLengthMinutes );\r\n\tfloat Max( Dial kind ) => kind == Dial.TimeOfDay ? 24f : MathF.Max( Min( kind ) + 0.1f, MaxDayLengthMinutes );\r\n\r\n\tfloat Frac( DialRow row )\r\n\t{\r\n\t\tfloat min = Min( row.kind ), max = Max( row.kind );\r\n\t\treturn Math.Clamp( (Get( row.kind ) - min) / MathF.Max( max - min, 0.0001f ), 0f, 1f );\r\n\t}\r\n\r\n\tvoid Set( Dial kind, float value )\r\n\t{\r\n\t\tvar c = Clock;\r\n\t\tif ( c is null ) return;\r\n\t\tswitch ( kind )\r\n\t\t{\r\n\t\t\tcase Dial.TimeOfDay:\r\n\t\t\t\t// Day-preserving, so scrubbing inside a day never re-rolls that day's weather. The top of the\r\n\t\t\t\t// range is 23:59, not 24:00: hour 24 IS the next day's midnight, so a full-right drag would\r\n\t\t\t\t// tip the day index over, re-roll the weather and snap the slider back to the far left. This\r\n\t\t\t\t// panel scrubs within a day; the clock is what advances days.\r\n\t\t\t\tc.SetTimeOfDay( Math.Clamp( value, 0f, 24f - (1f / 60f) ) );\r\n\t\t\t\tbreak;\r\n\t\t\tcase Dial.DayLength:\r\n\t\t\t\tWriteDayLength( Math.Clamp( value, Min( kind ), Max( kind ) ) );\r\n\t\t\t\tbreak;\r\n\t\t}\r\n\t}\r\n\r\n\tvoid Nudge( DialRow row, float delta ) => Set( row.kind, Get( row.kind ) + delta );\r\n\r\n\t/// <summary>\r\n\t/// Draggable track: onmousedown JUMPS to the click, onmousemove SCRUBS while the panel is Active.\r\n\t///\r\n\t/// Both the 28px transparent grab wrapper and the 14px visible track carry this handler, and a press on\r\n\t/// the track bubbles to the wrapper as well, so a single click can run it twice. That is harmless\r\n\t/// BECAUSE the write is absolute (set to the value under the cursor), not relative: two runs of the same\r\n\t/// press land on the same value. Keep it absolute if you touch this.\r\n\t/// </summary>\r\n\tvoid TrackPointer( PanelEvent ev, DialRow row, bool jump )\r\n\t{\r\n\t\tif ( ev is not MousePanelEvent e ) return;\r\n\t\tvar track = e.This;\r\n\t\tif ( track is null ) return;\r\n\t\tif ( !jump && !track.PseudoClass.HasFlag( PseudoClass.Active ) ) return;\r\n\r\n\t\tfloat w = track.Box.Rect.Width;\r\n\t\tif ( w <= 0f ) return;\r\n\t\tfloat frac = Math.Clamp( e.LocalPosition.x / w, 0f, 1f );\r\n\r\n\t\tif ( row.kind == Dial.TimeOfDay )\r\n\t\t{\r\n\t\t\t// Quantized to one in-game minute by the kit's own pure helper, so a drag emits at most one\r\n\t\t\t// distinct value per minute of readout instead of one per pixel.\r\n\t\t\tSet( Dial.TimeOfDay, TimeMath.ComputeSliderHour( frac ) );\r\n\t\t\treturn;\r\n\t\t}\r\n\r\n\t\tfloat min = Min( row.kind ), max = Max( row.kind );\r\n\t\tfloat target = min + frac * (max - min);\r\n\t\tif ( row.step > 0f ) target = MathF.Round( target / row.step ) * row.step;\r\n\t\tSet( row.kind, target );\r\n\t}\r\n\r\n\t// ---- jump chips ----\r\n\r\n\tstruct JumpRow { public string label; public float hour; }\r\n\r\n\t/// <summary>The four named hours, read off the clock's own config so a game with a different daylight\r\n\t/// window still gets its real dawn and dusk rather than 6 and 18.</summary>\r\n\tList<JumpRow> Jumps\r\n\t{\r\n\t\tget\r\n\t\t{\r\n\t\t\tvar cfg = Clock?.Config ?? DayNightConfig.Default;\r\n\t\t\treturn new List<JumpRow>\r\n\t\t\t{\r\n\t\t\t\tnew JumpRow { label = \"dawn\", hour = cfg.SunriseHour },\r\n\t\t\t\tnew JumpRow { label = \"noon\", hour = (cfg.SunriseHour + cfg.SunsetHour) * 0.5f },\r\n\t\t\t\tnew JumpRow { label = \"dusk\", hour = cfg.SunsetHour },\r\n\t\t\t\tnew JumpRow { label = \"midnight\", hour = 0f },\r\n\t\t\t};\r\n\t\t}\r\n\t}\r\n\r\n\t/// <summary>Is the clock within a minute of this named hour? A jump lands exactly, so a one-minute window\r\n\t/// is enough to light the chip and narrow enough that it goes out as soon as time moves on.</summary>\r\n\tbool IsAtHour( float hour ) => MathF.Abs( HourOfDay - hour ) < (1f / 60f);\r\n\r\n\tvoid JumpTo( float hour ) => Set( Dial.TimeOfDay, hour );\r\n\r\n\t// ---- weather, pause, config writes ----\r\n\r\n\tvoid PinWeather( int kind ) => Clock?.SetWeatherOverride( kind );\r\n\r\n\tvoid SetPaused( bool paused ) => Clock?.SetPaused( paused );\r\n\r\n\t/// <summary>\r\n\t/// Write a new day length onto the clock AND every driver in the scene.\r\n\t///\r\n\t/// DayNightConfig is a STRUCT, so `clock.Config.DayLengthMinutes = x` would mutate a temporary copy and\r\n\t/// change nothing. Read, edit, write back. The drivers get the same value because a consumer is told to\r\n\t/// keep clock and driver config identical, and a tuning panel that quietly desynchronised them would be\r\n\t/// the exact bug the docs warn about.\r\n\t///\r\n\t/// AUTHORITY-GUARDED, unlike the clock's own setters which guard themselves. Config is authoring data and\r\n\t/// is NOT replicated, so a client that changed its own day length would extrapolate at a different pace\r\n\t/// than the host and drift between every snapshot. The guard has to live here.\r\n\t/// </summary>\r\n\tvoid WriteDayLength( float minutes )\r\n\t{\r\n\t\tif ( !IsAuthority ) return;\r\n\t\tvar c = Clock;\r\n\t\tif ( c is null ) return;\r\n\t\tvar cfg = c.Config;\r\n\t\tcfg.DayLengthMinutes = minutes;\r\n\t\tc.Config = cfg;\r\n\r\n\t\tforeach ( var driver in Scene.GetAllComponents<DayNightDriver>() )\r\n\t\t{\r\n\t\t\tvar dcfg = driver.Config;\r\n\t\t\tdcfg.DayLengthMinutes = minutes;\r\n\t\t\tdriver.Config = dcfg;\r\n\t\t}\r\n\t}\r\n\r\n\t/// <summary>Back to the shipped reference grade: default config on the clock and every driver, weather\r\n\t/// released to the deterministic roll, clock running, time at the config's start hour.</summary>\r\n\tvoid ResetAll()\r\n\t{\r\n\t\tif ( !IsAuthority ) return; // same reason as WriteDayLength: config is authoring data, not session state\r\n\t\tvar c = Clock;\r\n\t\tif ( c is null ) return;\r\n\t\tvar def = DayNightConfig.Default;\r\n\t\tc.Config = def;\r\n\t\tforeach ( var driver in Scene.GetAllComponents<DayNightDriver>() )\r\n\t\t\tdriver.Config = def;\r\n\r\n\t\tc.SetWeatherOverride( -1 );\r\n\t\tc.SetPaused( false );\r\n\t\tc.SetTimeOfDay( def.StartHours );\r\n\t\t_copyLabel = \"Copy config\";\r\n\t}\r\n\r\n\t/// <summary>Put the tuned config on the system clipboard as a paste-ready C# block. Game-side\r\n\t/// Sandbox.UI.Clipboard.SetText, so it works in play without an editor round trip. Only the fields this\r\n\t/// panel can move are emitted; everything else stays whatever Default gives you.</summary>\r\n\tvoid CopyConfig()\r\n\t{\r\n\t\tvar c = Clock;\r\n\t\tif ( c is null ) return;\r\n\t\tvar cfg = c.Config;\r\n\t\tstring text =\r\n\t\t\t\"var cfg = DayNightConfig.Default;\\n\"\r\n\t\t\t+ $\"cfg.DayLengthMinutes = {cfg.DayLengthMinutes.ToString( \"0.###\" )}f;\\n\"\r\n\t\t\t+ $\"cfg.StartHours = {HourOfDay.ToString( \"0.###\" )}f;\\n\"\r\n\t\t\t+ $\"cfg.StartPaused = {(Paused ? \"true\" : \"false\")};\\n\"\r\n\t\t\t+ \"clock.Config = cfg;\";\r\n\t\tSandbox.UI.Clipboard.SetText( text );\r\n\t\t_copyLabel = \"Copied!\";\r\n\t}\r\n\r\n\t// ---- boot state, N toggle, cursor while open ----\r\n\r\n\tprotected override void OnUpdate()\r\n\t{\r\n\t\t// BOOT. `daynight_panel` is a convar and s&box PERSISTS convars across sessions, so a session can\r\n\t\t// otherwise come up with the panel logically open from whatever someone left set weeks ago. The rule\r\n\t\t// that prevents it: this component's own OpenOnStart decides the boot state, and the persisted value\r\n\t\t// never does. Default off means a consumer game still cannot be pre-opened by a stale convar; a scene\r\n\t\t// that wants the panel up says so explicitly.\r\n\t\t//\r\n\t\t// Deliberately in the FIRST UPDATE rather than OnStart. A panel built in code is configured by the\r\n\t\t// component that created it, and doing this in OnStart would race that assignment: whichever ran\r\n\t\t// first would win. The first update is after every OnStart in the frame, so the setting is always\r\n\t\t// read, never half-applied.\r\n\t\tif ( !_booted )\r\n\t\t{\r\n\t\t\t_booted = true;\r\n\t\t\tif ( PanelOpen && !OpenOnStart )\r\n\t\t\t\tLog.Info( \"[daynight] time panel was OPEN at session start (persisted convar), forcing closed\" );\r\n\t\t\tPanelOpen = OpenOnStart;\r\n\t\t}\r\n\r\n\t\tif ( Input.Keyboard.Pressed( \"N\" ) )\r\n\t\t\tPanelOpen = !PanelOpen;\r\n\r\n\t\tif ( PanelOpen )\r\n\t\t{\r\n\t\t\tMouse.Visibility = MouseVisibility.Visible; // keep the cursor usable over the panel\r\n\t\t\t_wasOpen = true;\r\n\t\t}\r\n\t\telse if ( _wasOpen )\r\n\t\t{\r\n\t\t\t_wasOpen = false;\r\n\t\t\t_copyLabel = \"Copy config\"; // closing clears the flash, so a reopen never claims a copy that was not made\r\n\t\t}\r\n\t}\r\n\r\n\tvoid ClosePanel()\r\n\t{\r\n\t\tPanelOpen = false;\r\n\t\t_copyLabel = \"Copy config\";\r\n\t}\r\n\r\n\t// Fold the toggle, the clock (to the displayed minute), the pace, the weather pin, the pause state and\r\n\t// the copy label. Miss one and that readout freezes on screen while the world keeps moving.\r\n\tprotected override int BuildHash()\r\n\t{\r\n\t\tvar c = Clock;\r\n\t\tint minute = (int)MathF.Round( HourOfDay * 60f );\r\n\t\tint pace = c is null ? 0 : (int)MathF.Round( SkyGrade.ClockRateScale( HourOfDay, c.Config ) * 1000f );\r\n\t\tint length = c is null ? 0 : (int)MathF.Round( c.Config.DayLengthMinutes * 100f );\r\n\t\treturn HashCode.Combine( PanelOpen, c is not null, minute, pace, length, WeatherPin, Paused, _copyLabel );\r\n\t}\r\n}\r\n"
}
]
}