Search the source of every open source package.
24 results
@using Sandbox;
@using Sandbox.UI;
@using System.Threading.Tasks;
@using System.Collections.Generic;
@using System;
@inherits PanelComponent
<root class="@(IsFadingOut ? "fade-out" : "fade-in")" style="background-image: @(!string.IsNullOrEmpty(BackgroundImage) ? $"url({BackgroundImage})" : "none");">
<div class="content">
@* Logo Image (.png / .jpg) *@
@if (!string.IsNullOrEmpty(LogoImage))
{
<img class="logo" src="@LogoImage" />
}
@* Text Lines *@
@if (TextLines != null && TextLines.Count > 0)
{
<div class="text-container">
@foreach (var line in TextLines)
{
<label style="color: @line.TextColor.Hex; font-size: @(line.FontSize)px;">
@line.Text
</label>
}
</div>
}
</div>
</root>
@code {
// === CUSTOM DATA CLASS FOR TEXT LINES ===
public class SplashTextLine
{
[Property, Description("The text to display.")]
public string Text { get; set; } = "NEW LINE";
[Property, Description("Text color for this specific line.")]
public Color TextColor { get; set; } = Color.White;
[Property, Description("Font size for this specific line.")]
public float FontSize { get; set; } = 80f;
}
// === IMAGE SETTINGS ===
[Property, ImageAssetPath, Group("Images"), Description("Supports .png and .jpg. If empty, the background will be black.")]
public string BackgroundImage { get; set; }
[Property, ImageAssetPath, Group("Images"), Description("Main logo image (.png / .jpg). Appears above the text if both are set.")]
public string LogoImage { get; set; }
// === TEXT SETTINGS ===
[Property, Group("Text"), Description("Add text lines with individual settings (color, size).")]
public List<SplashTextLine> TextLines { get; set; } = new();
// === AUDIO & SCENE SETTINGS ===
[Property, Group("Audio"), Description("Select a Sound Event (.sound) that contains your .mp3 or .ogg file.")]
public SoundEvent SplashSound { get; set; }
[Property, Group("Scene"), Description("The scene to load after the splash screen finishes.")]
public SceneFile NextScene { get; set; }
// === LOGIC ===
public bool IsFadingOut { get; set; } = false;
protected override void OnStart()
{
base.OnStart();
// Start the asynchronous sequence
_ = RunSplashSequence();
}
private async Task RunSplashSequence()
{
// 1. Wait half a second before starting to avoid stuttering during load
await Task.Delay(500);
// 2. Play the assigned sound (.mp3 / .ogg via Sound Event)
if (SplashSound != null)
{
Sound.Play(SplashSound);
}
// 3. Wait while the logo/text is visible on the screen (3 seconds)
await Task.Delay(3000);
// 4. Trigger the fade-out animation
IsFadingOut = true;
StateHasChanged(); // Notify the UI to update CSS classes
// 5. Wait for the fade-out animation to finish (matches the CSS transition time)
await Task.Delay(2000);
// 6. Load the next scene
if (NextScene != null)
{
Scene.Load(NextScene);
}
else
{
Log.Warning("Next Scene is not assigned in the Splash Screen component!");
GameObject.Destroy(); // Destroy the component if no scene is assigned
}
}
}@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 ) );
}
}
@namespace FieldGuide.VehiclePhysics
@inherits PanelComponent
@* Kit-native live tuning lab (demo layer). Toggled by the Tune action, bound to the car the chase
camera is following. Writes tuning changes straight onto the running car through the same paths a
consumer would use: it mutates the active CarDefinition (read live by the drivetrain and the brake
model) and pushes suspension/tire values onto the live wheels. This is a demo-scale lab, not the
full game panel: it exposes the highest-feel dials so the demo reads as a physics lab in the first
minute. Replace it with your own UI; it lives only in the demo scene.
Look and layout follow the Field Kit UI system: docs/design/ui-system/vehicle-physics.dc.html
screen 03.A for this panel, 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 rules at its
head. *@
<root>
@if ( Car.IsValid() )
{
@* Key legend, top-right: how to hop between the demo cars. Always visible; the tuning
chip/panel top-left covers the T binding. *@
<div class="legend">
<div class="mono kbd">[ ]</div>
<div class="title">switch car</div>
</div>
}
@if ( !IsOpen && Car.IsValid() )
{
@* Collapsed legend chip: sits exactly where the expanded panel's top-left corner lands, so
pressing T reads as the chip expanding into the lab. Mouse-look stays with the camera. *@
<div class="panel chip" onclick=@ToggleOpen>
<div class="head">
<div class="mono kbd">@ToggleKeyLabel</div>
<div class="title">tuning</div>
</div>
</div>
}
@if ( IsOpen && Car.IsValid() )
{
<div class="panel">
<div class="head">
<div class="title">Tuning lab · @CarName</div>
@* Key chip plus the 42px close x. The x runs the SAME ToggleOpen path the T key and
the collapsed chip use, so cursor visibility stays decided in one place. *@
<div class="hr">
<div class="mono kbd">@ToggleKeyLabel</div>
<div class="x" onclick=@ToggleOpen>×</div>
</div>
</div>
<div class="hint">Tuning the car you are driving. Changes apply live.</div>
<div class="dials">
@foreach ( var d in SliderDials )
{
var dd = d;
<div class="dial">
<div class="drow">
<div class="dname">@d.Name</div>
<div class="mono dval">@d.Display()</div>
</div>
<div class="dctl">
<div class="mono step" onclick=@( () => Step( dd, -1 ) )>-</div>
<div class="track"
onmousedown=@( e => Scrub( dd, e, true ) )
onmousemove=@( e => Scrub( dd, e, false ) )>
<div class="fill" style="width: @WidthPct( dd )%"></div>
</div>
<div class="mono step" onclick=@( () => Step( dd, +1 ) )>+</div>
</div>
</div>
}
<div class="dial">
<div class="drow">
<div class="dname">Assists</div>
<div class="mono cycle" onclick=@CycleAssists>@AssistLabel</div>
</div>
</div>
<div class="dial">
<div class="drow">
<div class="dname">Tires</div>
<div class="mono cycle" onclick=@CycleTires>@TireLabel</div>
</div>
</div>
</div>
<div class="foot">
<div class="btn" onclick=@ResetToStock>Reset to stock</div>
</div>
</div>
}
</root>
@code {
/// <summary>The car this panel tunes: the one the chase camera follows. Set by DemoBootstrap. On
/// change the panel snapshots the car's pristine (authored) values so Reset-to-stock can restore
/// them and the grip/torque multipliers rebase to the new car.</summary>
VehicleController _car;
public VehicleController Car
{
get => _car;
set
{
if ( ReferenceEquals( _car, value ) )
return;
_car = value;
Snapshot();
}
}
/// <summary>Open state. Static so the kit's chase camera can read it through the
/// <see cref="VehicleCamera.CursorModalOpen"/> seam (DemoBootstrap wires that) without holding a
/// reference to this panel. The demo runs exactly one panel, so a single flag is enough.</summary>
public static bool IsOpen;
// Toggle input action. Documented in the README input table; the host ProjectSettings/Input.config
// ships it. Never Escape or an F-key.
const string ToggleAction = "Tune";
string ToggleKeyLabel => "T";
string CarName => Car?.Definition?.Name ?? "Car";
// Live multipliers over the pristine base. Grip scales the (preset-selected) tire curves; torque
// scales the authored peak engine torque.
float _gripScale = 1f;
float _torqueScale = 1f;
int _tirePreset; // 0 Stock, 1 Street, 2 Sport, 3 Offroad
// Pristine snapshot, captured value-by-value when the car is bound (its definition is untouched at
// that point). Value types only (floats, TireCurve struct, enum), so later live tuning of the
// definition can never corrupt these; Reset restores from here.
bool _snapped;
float _stockPeakTorque, _stockSpring, _stockDamper, _stockTravel, _stockBrake;
TireCurve _stockLat, _stockLong;
AssistLevel _stockAssists;
// Current UNSCALED tire base (Stock or a named preset). Grip multiplies these to get the live curves.
TireCurve _baseLat, _baseLong;
void Snapshot()
{
var def = _car?.Definition;
_dials = null;
if ( def is null )
{
_snapped = false;
return;
}
_stockPeakTorque = def.PeakTorque;
_stockSpring = def.SpringRate;
_stockDamper = def.DamperRate;
_stockTravel = def.SuspensionTravel;
_stockBrake = def.BrakeTorque;
_stockLat = def.LateralCurve;
_stockLong = def.LongitudinalCurve;
// Stock assist = the authored definition default. Read it off the definition, not the
// controller: the controller adopts DefaultAssists in its own OnStart, which may run a frame
// or two after this bind, so its live Assists is not reliable yet.
_stockAssists = def.DefaultAssists;
_baseLat = _stockLat;
_baseLong = _stockLong;
_gripScale = 1f;
_torqueScale = 1f;
_tirePreset = 0;
_snapped = true;
StateHasChanged();
}
// ---- dial model ----
class Dial
{
public string Name;
public float Min, Max, Step;
public Func<float> Get;
public Action<float> Set;
public Func<string> Fmt;
public string Display() => Fmt();
}
List<Dial> _dials;
List<Dial> SliderDials => _dials ??= BuildDials();
List<Dial> BuildDials()
{
if ( !Car.IsValid() )
return new List<Dial>();
var def = Car.Definition;
return new List<Dial>
{
new()
{
Name = "Grip", Min = 0.6f, Max = 2.2f, Step = 0.05f,
Get = () => _gripScale, Set = SetGrip,
Fmt = () => _gripScale.ToString( "0.00" ) + "x",
},
new()
{
Name = "Drive torque", Min = 0.5f, Max = 2.0f, Step = 0.05f,
Get = () => _torqueScale, Set = SetTorqueScale,
Fmt = () => _torqueScale.ToString( "0.00" ) + "x",
},
new()
{
Name = "Suspension stiffness", Min = 15000f, Max = 60000f, Step = 2000f,
Get = () => def.SpringRate, Set = v => { def.SpringRate = v; ApplyWheels(); },
Fmt = () => def.SpringRate.ToString( "0" ) + " N/m",
},
new()
{
Name = "Suspension damping", Min = 800f, Max = 6000f, Step = 200f,
Get = () => def.DamperRate, Set = v => { def.DamperRate = v; ApplyWheels(); },
Fmt = () => def.DamperRate.ToString( "0" ) + " Ns/m",
},
new()
{
Name = "Suspension travel", Min = 0.10f, Max = 0.35f, Step = 0.01f,
Get = () => def.SuspensionTravel, Set = v => { def.SuspensionTravel = v; ApplyWheels(); },
Fmt = () => (def.SuspensionTravel * 100f).ToString( "0" ) + " cm",
},
new()
{
Name = "Brake force", Min = 1500f, Max = 8000f, Step = 200f,
Get = () => def.BrakeTorque, Set = v => def.BrakeTorque = v,
Fmt = () => def.BrakeTorque.ToString( "0" ) + " Nm",
},
};
}
float Frac( Dial d )
{
if ( d.Max <= d.Min )
return 0f;
return Math.Clamp( (d.Get() - d.Min) / (d.Max - d.Min), 0f, 1f );
}
string WidthPct( Dial d ) =>
(Frac( d ) * 100f).ToString( "0.#", System.Globalization.CultureInfo.InvariantCulture );
void Step( Dial d, int clicks )
{
float v = Math.Clamp( d.Get() + clicks * d.Step, d.Min, d.Max );
d.Set( v );
StateHasChanged();
}
// Click-to-jump + drag-to-scrub on the dial track (mirrors the game panel's proven pattern). jump
// is true on mousedown (a bare click sets the value at the cursor) and false on mousemove (scrub
// only while the track owns the press). Value = mouse local x over track width, snapped to the
// dial's step, pushed through the SAME Set path as the +/- steps.
void Scrub( Dial d, Sandbox.UI.PanelEvent ev, bool jump )
{
if ( ev is not Sandbox.UI.MousePanelEvent e )
return;
var track = e.This;
if ( track is null )
return;
// mousemove fires whether or not the button is held; only scrub while the track owns the press.
if ( !jump && !track.PseudoClass.HasFlag( Sandbox.UI.PseudoClass.Active ) )
return;
float w = track.Box.Rect.Width;
if ( w <= 0f )
return;
float frac = Math.Clamp( e.LocalPosition.x / w, 0f, 1f );
float v = d.Min + frac * (d.Max - d.Min);
if ( d.Step > 0f )
v = MathF.Round( v / d.Step ) * d.Step;
v = Math.Clamp( v, d.Min, d.Max );
d.Set( v );
StateHasChanged();
}
// ---- apply paths (same seams a consumer would use) ----
static TireCurve Scaled( TireCurve c, float k ) =>
new( c.PeakSlip, c.PeakGrip * k, c.TailSlip, c.TailGrip * k );
// Push the definition's suspension + tire values onto the live wheels. The factory copies these at
// spawn; the wheel re-reads them every substep, so writing them here is the live-apply path.
void ApplyWheels()
{
var def = Car.Definition;
foreach ( var w in Car.Wheels )
{
w.SpringRate = def.SpringRate;
w.DamperRate = def.DamperRate;
w.SuspensionTravel = def.SuspensionTravel;
w.LateralCurve = def.LateralCurve;
w.LongitudinalCurve = def.LongitudinalCurve;
}
}
void SetGrip( float k )
{
_gripScale = k;
var def = Car.Definition;
def.LateralCurve = Scaled( _baseLat, k );
def.LongitudinalCurve = Scaled( _baseLong, k );
ApplyWheels();
}
// Drive torque scale multiplies the authored peak torque. Drivetrain reads def.PeakTorque live
// (it holds the same definition instance), so no drivetrain touch is needed.
void SetTorqueScale( float k )
{
_torqueScale = k;
Car.Definition.PeakTorque = _stockPeakTorque * k;
}
void CycleAssists()
{
Car.Assists = Car.Assists switch
{
AssistLevel.Casual => AssistLevel.Sport,
AssistLevel.Sport => AssistLevel.Sim,
_ => AssistLevel.Casual,
};
StateHasChanged();
}
string AssistLabel => Car.IsValid() ? Car.Assists.ToString() : "";
// Tire preset swaps the UNSCALED base curves, then re-applies the current grip multiplier so the
// grip dial and the preset compose. Stock restores the car's own authored curves.
void CycleTires()
{
_tirePreset = (_tirePreset + 1) % 4;
(_baseLat, _baseLong) = _tirePreset switch
{
1 => (TireCurve.Street, TireCurve.Street),
2 => (TireCurve.Sport, TireCurve.Sport),
3 => (TireCurve.Offroad, TireCurve.Offroad),
_ => (_stockLat, _stockLong),
};
SetGrip( _gripScale );
StateHasChanged();
}
string TireLabel => _tirePreset switch
{
1 => "Street",
2 => "Sport",
3 => "Offroad",
_ => "Stock",
};
// Re-apply the car's pristine authored values (captured at bind). Definitions in the demo roster
// are fresh per car, so "stock" is unambiguous: the values this car spawned with.
void ResetToStock()
{
if ( !Car.IsValid() || !_snapped )
return;
var def = Car.Definition;
def.PeakTorque = _stockPeakTorque;
def.SpringRate = _stockSpring;
def.DamperRate = _stockDamper;
def.SuspensionTravel = _stockTravel;
def.BrakeTorque = _stockBrake;
def.LateralCurve = _stockLat;
def.LongitudinalCurve = _stockLong;
_gripScale = 1f;
_torqueScale = 1f;
_tirePreset = 0;
_baseLat = _stockLat;
_baseLong = _stockLong;
Car.Assists = _stockAssists;
ApplyWheels();
StateHasChanged();
}
protected override void OnEnabled()
{
// Start COLLAPSED: the chip legend keeps the keybind discoverable while mouse-look stays
// with the camera (an open lab on spawn captured the cursor before players ever drove,
// owner call 2026-07-19). Static flag, so reset it here each session.
IsOpen = false;
}
void ToggleOpen()
{
IsOpen = !IsOpen;
Mouse.Visibility = IsOpen ? MouseVisibility.Visible : MouseVisibility.Hidden;
StateHasChanged();
}
protected override void OnUpdate()
{
if ( Input.Pressed( ToggleAction ) )
{
IsOpen = !IsOpen;
Mouse.Visibility = IsOpen ? MouseVisibility.Visible : MouseVisibility.Hidden;
StateHasChanged();
}
// The chase camera re-hides the cursor every frame while it owns it; hold it visible while open.
if ( IsOpen )
Mouse.Visibility = MouseVisibility.Visible;
}
protected override int BuildHash()
{
// Closed still renders the chip legend, and the chip waits on the car binding, so the
// hash must move when the car arrives or the first build would stick on the empty tree.
if ( !IsOpen )
return Car.IsValid() ? 1 : 0;
var h = new HashCode();
h.Add( _gripScale );
h.Add( _torqueScale );
h.Add( _tirePreset );
if ( Car.IsValid() )
{
var def = Car.Definition;
h.Add( def.SpringRate );
h.Add( def.DamperRate );
h.Add( def.SuspensionTravel );
h.Add( def.BrakeTorque );
h.Add( (int)Car.Assists );
}
return h.ToHashCode();
}
}
@using Sandbox
@using Sandbox.UI
@using LobbySystem
@inherits PanelComponent
@namespace LobbySystem.Examples
<root>
@if ( Dir is null )
{
<text></text>
}
else if ( Dir.MenuOpen || Dir.SuggestMenuOpen )
{
<div class="menu">
<div class="title">MULTIPLAYER LOBBY</div>
<div class="sub">@(Dir.MenuOpen ? "Choose a game mode" : "Suggest a mode to the host")</div>
<div class="row">
@for ( int i = 0; i < Dir.Modes.Count; i++ )
{
var idx = i;
<button class="mode" onclick=@(() => Dir.PickMode( idx ))>
<div class="k">@(idx + 1)</div>
<div class="n">@Dir.Modes[idx].DisplayName</div>
</button>
}
</div>
<button class="back" onclick=@(() => Dir.RequestCloseMenu())>Back to lobby [E]</button>
</div>
}
else
{
<div class="top">
@if ( Dir.RoundLive )
{
<div class="mode">@(Dir.ActiveMode?.DisplayName ?? "")</div>
<div class="timer @(Dir.TimeLeftSeconds < 15 ? "urgent" : "")">@TimerText</div>
}
else
{
<div class="lobby">LOBBY</div>
}
</div>
@if ( !Dir.RoundLive && !string.IsNullOrEmpty( Dir.StatusMessage ) )
{
<div class="status">@Dir.StatusMessage</div>
}
@if ( Dir.ChatVisible )
{
<div class="chat">@Dir.ChatLine</div>
}
<div class="hints">
<span>[WASD] Move</span>
<span>[Space] Jump</span>
@if ( !Dir.RoundLive )
{
<span>Walk to the pad and press [E] to start a round</span>
}
</div>
}
</root>
@code
{
LobbyDirector Dir => LobbyDirector.Current;
string TimerText
{
get
{
int t = Dir?.TimeLeftSeconds ?? 0;
if ( t < 0 ) t = 0;
return $"{t / 60:D2}:{t % 60:D2}";
}
}
protected override int BuildHash() => System.HashCode.Combine(
Dir?.State, Dir?.MenuOpen, Dir?.SuggestMenuOpen, Dir?.RoundLive,
Dir?.TimeLeftSeconds, Dir?.StatusMessage, Dir?.ChatLine, Dir?.ChatVisible );
}
@using Sandbox
@using Sandbox.UI
@namespace PanelRenderTarget
@inherits ScreenPanel
<div class="screen">
<div class="title">Example</div>
<div class="debug" @onclick=@Click @onmouseover=@OnDebugMouseOver @onmouseout=@OnDebugMouseOut>
<div class="test-hover">Hover: @IsHovering</div>
<div>Pressed: @IsPressed</div>
<div>Clicks: @ClickCount</div>
<div>Mouse: @MousePosition</div>
<TextEntry @onclick=@TestClick Value:Bind=@test placeholder="Quantitée" />
</div>
<div class="cursor" style="left:@CursorLeft; top:@CursorTop;"></div>
</div>
@code
{
public Vector2 MousePosition { get; private set; }
public bool IsHovering { get; private set; }
public bool IsPressed { get; private set; }
public int ClickCount { get; private set; }
private string CursorLeft => $"{MousePosition.x}px";
private string CursorTop => $"{MousePosition.y}px";
public string test { get; set; } = "teste";
protected override void OnMouseMove(MousePanelEvent e)
{
base.OnMouseMove(e);
var root = FindRootPanel() as TargetRootPanel;
//MousePosition = root.MousePosition;
}
public void OnDebugMouseOver(PanelEvent e)
{
IsHovering = true;
}
public void OnDebugMouseOut(PanelEvent e)
{
IsHovering = false;
}
public void TestClick(PanelEvent e)
{
Log.Info("Clicked on text entry");
}
protected override void OnMouseDown(MousePanelEvent e)
{
base.OnMouseDown(e);
IsPressed = true;
}
protected override void OnMouseUp(MousePanelEvent e)
{
base.OnMouseUp(e);
IsPressed = false;
}
public void Click(PanelEvent e)
{
ClickCount++;
}
protected override int BuildHash()
{
StateHasChanged();
var hash = 17;
hash = hash * 31 + MousePosition.x.GetHashCode();
hash = hash * 31 + MousePosition.y.GetHashCode();
hash = hash * 31 + IsHovering.GetHashCode();
hash = hash * 31 + IsPressed.GetHashCode();
hash = hash * 31 + ClickCount.GetHashCode();
return hash;
}
}
<style>
.screen {
cursor: crosshair;
pointer-events: auto;
position: absolute;
left: 0;
top: 0;
width: 100%;
height: 100%;
background-color: #06111f;
color: white;
font-size: 32px;
font-weight: bold;
}
.title {
position: absolute;
left: 40px;
top: 40px;
color: white;
font-size: 64px;
}
.debug {
position: absolute;
left: 40px;
top: 140px;
font-size: 28px;
color: #9fd4ff;
flex-direction: column;
width:400px;
}
.debug:hover {
background-color: aqua;
}
.debug:active {
background-color: red;
}
.cursor {
position: absolute;
width: 24px;
height: 24px;
background-color: red;
border-radius: 50%;
transform: translate(-50% -50%);
}
TextEntry {
background-color:green;
}
TextEntry:focus {
border-color: red;
}
</style>
@using Sandbox
@using Sandbox.UI
@namespace PanelRenderTarget
@inherits ScreenPanel
<div class="screen">
<div class="title">Example</div>
<div class="debug" @onclick=@Click @onmouseover=@OnDebugMouseOver @onmouseout=@OnDebugMouseOut>
<div class="test-hover">Hover: @IsHovering</div>
<div>Pressed: @IsPressed</div>
<div>Clicks: @ClickCount</div>
<div>Mouse: @MousePosition</div>
<TextEntry @onclick=@TestClick Value:Bind=@test placeholder="Quantitée" />
</div>
<div class="cursor" style="left:@CursorLeft; top:@CursorTop;"></div>
</div>
@code
{
public Vector2 MousePosition { get; private set; }
public bool IsHovering { get; private set; }
public bool IsPressed { get; private set; }
public int ClickCount { get; private set; }
private string CursorLeft => $"{MousePosition.x}px";
private string CursorTop => $"{MousePosition.y}px";
public string test { get; set; } = "teste";
protected override void OnMouseMove(MousePanelEvent e)
{
base.OnMouseMove(e);
var root = FindRootPanel() as TargetRootPanel;
//MousePosition = root.MousePosition;
}
public void OnDebugMouseOver(PanelEvent e)
{
IsHovering = true;
}
public void OnDebugMouseOut(PanelEvent e)
{
IsHovering = false;
}
public void TestClick(PanelEvent e)
{
Log.Info("Clicked on text entry");
}
protected override void OnMouseDown(MousePanelEvent e)
{
base.OnMouseDown(e);
IsPressed = true;
}
protected override void OnMouseUp(MousePanelEvent e)
{
base.OnMouseUp(e);
IsPressed = false;
}
public void Click(PanelEvent e)
{
ClickCount++;
}
protected override int BuildHash()
{
StateHasChanged();
var hash = 17;
hash = hash * 31 + MousePosition.x.GetHashCode();
hash = hash * 31 + MousePosition.y.GetHashCode();
hash = hash * 31 + IsHovering.GetHashCode();
hash = hash * 31 + IsPressed.GetHashCode();
hash = hash * 31 + ClickCount.GetHashCode();
return hash;
}
}
<style>
.screen {
cursor: crosshair;
pointer-events: auto;
position: absolute;
left: 0;
top: 0;
width: 100%;
height: 100%;
background-color: #06111f;
color: white;
font-size: 32px;
font-weight: bold;
}
.title {
position: absolute;
left: 40px;
top: 40px;
color: white;
font-size: 64px;
}
.debug {
position: absolute;
left: 40px;
top: 140px;
font-size: 28px;
color: #9fd4ff;
flex-direction: column;
width:400px;
}
.debug:hover {
background-color: aqua;
}
.debug:active {
background-color: red;
}
.cursor {
position: absolute;
width: 24px;
height: 24px;
background-color: red;
border-radius: 50%;
transform: translate(-50% -50%);
}
TextEntry {
background-color:green;
}
TextEntry:focus {
border-color: red;
}
</style>
@using Sandbox
@using Sandbox.UI
@using System
@using System.Collections.Generic
@using System.Linq
@namespace FieldGuide.Tips
@inherits PanelComponent
@attribute [StyleSheet]
@*
TIPS STUDIO - author a tip inside the running game and watch the real card change as you type.
One modal, three columns, over a dim scrim (the approved Field Kits layout, docs/design/ui-system).
LEFT is the merged catalog with the source each id resolved from, which is also how a tip left behind
by another scene gives itself away. MIDDLE is the editor: wording, order, prerequisites, and the two
trigger pickers, whose action list comes from the project's own input actions. RIGHT is the payoff:
the same card the player sees, rendered twice side by side so the keyboard and controller wordings
read together, and under it the bake-out (Copy to clipboard, or stage it for the editor menu action
that writes Assets/tips).
The lower-left card is still the REAL one: "show it" pushes the draft into the coach and TipsDisplay
draws it with the shipped stylesheet, and the device chips pin TipsCoach.PreviewDevice. The two cards
in the rail are the same component restated inside this panel, because a preview you have to look
away from is not a preview.
Toggle: `fg_tips_studio 1` in the console, or press T (a plain letter; the editor eats F1-F12 in
play-in-editor). The key OPENS only, never closes, so pressing T inside a text box types a T and
nothing else. Close with the x in the header or `fg_tips_studio 0`.
Panel rules this file follows, each of which has cost someone a session: rows come from @foreach in
main markup, never a RenderFragment; the root takes no pointer events and the scrim and modal take
all of them; both scroll regions are a FIXED pixel height, never a percentage; no field being TYPED
into is folded into BuildHash, because a rebuild takes the cursor out of the box.
*@
<root class="ts-root">
@if ( TipsStudio.Open )
{
<div class="ts-scrim">
<div class="ts-modal">
@* ================= header ================= *@
<div class="ts-hdr">
<div class="ts-hdr-left">
<div class="ts-title">TIPS STUDIO</div>
<div class="ts-hdr-meta">@HeaderMeta</div>
</div>
<div class="ts-x" onclick=@Close>×</div>
</div>
<div class="ts-cols">
@* ================= left: the merged catalog ================= *@
<div class="ts-left">
<div class="ts-list-hdr">
<div class="ts-list-t">Tips</div>
<div class="ts-list-m">by priority</div>
</div>
<div class="ts-list" @ref="ListBody">
@if ( Entries.Count == 0 )
{
<div class="ts-empty">
<div class="ts-empty-t">No tips yet</div>
<div class="ts-empty-l">Press New tip to write one.</div>
</div>
}
@foreach ( var e in Entries )
{
var entry = e;
<div class="ts-row @(entry.Selected ? "on" : "")" onclick=@(() => OpenTip( entry.Id ))>
<div class="ts-row-id">@entry.Id</div>
@* State REPLACES the source rather than sitting beside it: the row is 248px wide
and a third thing in it squeezes the id until it blanks out. *@
<div class="ts-row-meta">
@if ( entry.State is null )
{
<div class="ts-row-src">@entry.Source</div>
}
else
{
<div class="ts-row-state">@entry.State</div>
}
</div>
</div>
}
</div>
<div class="ts-left-btns">
<div class="ts-btn pri grow gap" onclick=@NewTip>New tip</div>
<div class="ts-btn" onclick=@Rescan>Rescan</div>
</div>
</div>
@* ================= middle: the draft ================= *@
<div class="ts-mid" @ref="MidBody">
@if ( TipsStudio.DroppedPredicates )
{
<div class="ts-warn">
<div class="ts-warn-b">!</div>
<div class="ts-warn-t">
<div class="ts-warn-l">This tip carries a code predicate.</div>
<div class="ts-warn-l">A .tip file cannot hold one, so baking</div>
<div class="ts-warn-l">keeps the triggers and drops the predicate.</div>
</div>
</div>
}
<div class="ts-frow">
<div class="ts-field w200">
<div class="ts-lab">Id</div>
<TextEntry class="ts-in" Value=@DraftId
OnTextEdited=@((string v) => { TipsStudio.Draft.Id = v; }) onsubmit=@Commit />
</div>
<div class="ts-field w150">
<div class="ts-lab">Priority</div>
<div class="ts-step">
<div class="ts-stp" onclick=@(() => BumpPriority( -10 ))>−</div>
<div class="ts-val">@DraftPriority</div>
<div class="ts-stp" onclick=@(() => BumpPriority( 10 ))>+</div>
</div>
</div>
<div class="ts-field grow last">
<div class="ts-lab">Prerequisites</div>
<div class="ts-drop">
<div class="ts-drop-face @(IsOpen( PrereqDrop ) ? "open" : "")"
onclick=@(() => ToggleDrop( PrereqDrop ))>
<div class="ts-drop-val">@PrereqFace</div>
<div class="ts-chev">expand_more</div>
</div>
@if ( IsOpen( PrereqDrop ) )
{
<div class="ts-drop-list @(Tall( AvailablePrerequisites.Count ))">
@if ( AvailablePrerequisites.Count == 0 )
{
<div class="ts-drop-opt">no other tip ids yet</div>
}
@foreach ( var p in AvailablePrerequisites )
{
var prereq = p;
<div class="ts-drop-opt" onclick=@(() => AddPrerequisite( prereq ))>@prereq</div>
}
</div>
}
</div>
</div>
</div>
@* Its own block under the field, with its own bottom margin: this row used to collapse
into the wording section and paint its chips over the Text label. *@
@if ( PrerequisiteList.Count > 0 )
{
<div class="ts-chips block">
@foreach ( var p in PrerequisiteList )
{
var prereq = p;
<div class="ts-chip on mono"
onclick=@(() => RemovePrerequisite( prereq ))>@($"{prereq} ×")</div>
}
</div>
}
<div class="ts-frow">
<div class="ts-field grow last">
<div class="ts-lab-row">
<div class="ts-lab">Text</div>
<div class="ts-cap">*Space* keycap · `A` pad button</div>
</div>
<TextEntry class="ts-in" Value=@DraftText
OnTextEdited=@((string v) => { TipsStudio.Draft.Text = v; Refresh(); }) onsubmit=@Commit />
</div>
</div>
<div class="ts-frow">
<div class="ts-field grow">
<div class="ts-lab">Pad text · optional</div>
<TextEntry class="ts-in" Value=@DraftTextPad
OnTextEdited=@((string v) => { TipsStudio.Draft.TextPad = v; Refresh(); }) onsubmit=@Commit />
</div>
<div class="ts-field w120 last">
<div class="ts-lab">Icon</div>
<TextEntry class="ts-in" Value=@DraftIcon
OnTextEdited=@((string v) => { TipsStudio.Draft.Icon = v; Refresh(); }) onsubmit=@Commit />
</div>
</div>
@* ---- the two trigger pickers, from one block of markup ---- *@
@foreach ( var s in TriggerSlots )
{
var slot = s;
var trig = slot.Trigger;
var actionDrop = slot.Key;
<div class="ts-sec">
<div class="ts-sec-hd">
<div class="ts-sec-t">@slot.Title</div>
<div class="ts-cap flat">@slot.Blurb</div>
</div>
<div class="ts-chips">
@foreach ( var k in TipStudioTrigger.AllKinds )
{
var kind = k;
<div class="ts-chip @(trig.Kind == kind ? "on" : "")"
onclick=@(() => SetKind( trig, kind ))>@TipStudioTrigger.KindName( kind )</div>
}
</div>
<div class="ts-frow">
@if ( trig.Kind == TipTriggerKind.InputAction )
{
<div class="ts-field w200">
<div class="ts-lab">Input action</div>
<div class="ts-drop">
<div class="ts-drop-face @(IsOpen( actionDrop ) ? "open" : "")"
onclick=@(() => ToggleDrop( actionDrop ))>
<div class="ts-drop-val">@ActionFace( trig )</div>
<div class="ts-chev">expand_more</div>
</div>
@if ( IsOpen( actionDrop ) )
{
<div class="ts-drop-list @(Tall( ActionNames.Count ))">
@if ( ActionNames.Count == 0 )
{
<div class="ts-drop-opt">no input actions bound</div>
}
@foreach ( var a in ActionNames )
{
var action = a;
<div class="ts-drop-opt @(trig.Action == action ? "on" : "")"
onclick=@(() => PickAction( trig, action ))>@action</div>
}
</div>
}
</div>
<div class="ts-cap">from Input.config</div>
</div>
}
@if ( trig.Kind == TipTriggerKind.Key )
{
<div class="ts-field w200">
<div class="ts-lab">Key</div>
<TextEntry class="ts-in" Value=@TrigKey( trig )
OnTextEdited=@((string v) => { trig.Key = v; }) onsubmit=@Commit />
<div class="ts-cap">space, w, mouse1</div>
</div>
}
@if ( slot.NeedsName )
{
<div class="ts-field w200">
<div class="ts-lab">Name</div>
<TextEntry class="ts-in" Value=@TrigName( trig )
OnTextEdited=@((string v) => { trig.Name = v; }) onsubmit=@Commit />
<div class="ts-cap">@slot.NameHint</div>
</div>
}
@if ( trig.Kind == TipTriggerKind.AtLeast )
{
<div class="ts-field w150">
<div class="ts-lab">At least</div>
<div class="ts-step">
<div class="ts-stp" onclick=@(() => BumpThreshold( trig, -1f ))>−</div>
<div class="ts-val">@TrigThreshold( trig )</div>
<div class="ts-stp" onclick=@(() => BumpThreshold( trig, 1f ))>+</div>
</div>
</div>
}
@if ( trig.Kind == TipTriggerKind.Timer )
{
<div class="ts-field w150">
<div class="ts-lab">Seconds</div>
<div class="ts-step">
<div class="ts-stp" onclick=@(() => BumpSeconds( trig, -1f ))>−</div>
<div class="ts-val">@TrigSeconds( trig )</div>
<div class="ts-stp" onclick=@(() => BumpSeconds( trig, 1f ))>+</div>
</div>
</div>
}
@if ( trig.Kind == TipTriggerKind.AnalogAxis )
{
<div class="ts-field w150">
<div class="ts-lab">Magnitude</div>
<div class="ts-step">
<div class="ts-stp" onclick=@(() => BumpMagnitude( trig, -0.1f ))>−</div>
<div class="ts-val">@TrigMagnitude( trig )</div>
<div class="ts-stp" onclick=@(() => BumpMagnitude( trig, 0.1f ))>+</div>
</div>
<div class="ts-cap">0 to 1</div>
</div>
}
@if ( slot.IsCompletion )
{
<div class="ts-field grow last">
<div class="ts-lab">Max show seconds</div>
<div class="ts-step">
<div class="ts-stp" onclick=@(() => BumpMaxShow( -1f ))>−</div>
<div class="ts-val w80">@DraftMaxShow</div>
<div class="ts-stp" onclick=@(() => BumpMaxShow( 1f ))>+</div>
</div>
<div class="ts-cap">0 = never auto-complete</div>
</div>
}
</div>
@if ( trig.Kind == TipTriggerKind.AnalogAxis )
{
<div class="ts-chips">
@foreach ( var src in TipStudioTrigger.AllAnalogSources )
{
var source = src;
<div class="ts-chip @(trig.AnalogSource == source ? "on" : "")"
onclick=@(() => SetSource( trig, source ))>@TipStudioTrigger.SourceName( source )</div>
}
</div>
}
@if ( slot.IsComposite )
{
<div class="ts-chips">
<div class="ts-chip" onclick=@(() => AddChild( trig ))>add one</div>
@if ( trig.Children.Count == 0 )
{
<div class="ts-cap flat">@slot.EmptyCompositeHint</div>
}
</div>
@foreach ( var c in trig.Children.ToList() )
{
var child = c;
<div class="ts-child">
<div class="ts-chips">
@foreach ( var k in TipStudioTrigger.AllKinds )
{
var kind = k;
<div class="ts-chip small @(child.Kind == kind ? "on" : "")"
onclick=@(() => SetKind( child, kind ))>@TipStudioTrigger.KindName( kind )</div>
}
<div class="ts-chip small drop" onclick=@(() => RemoveChild( trig, child ))>remove</div>
</div>
<div class="ts-frow">
<div class="ts-field grow last">
<div class="ts-lab-row">
<div class="ts-lab">Value</div>
<div class="ts-cap flat">@ChildHint( child )</div>
</div>
<TextEntry class="ts-in" Value=@ChildValue( child )
OnTextEdited=@((string v) => SetChildValue( child, v )) onsubmit=@Commit />
</div>
</div>
</div>
}
}
</div>
}
@* ---- what the draft would do wrong ---- *@
@if ( TipsStudio.ShadowedBy is not null )
{
<div class="ts-warn">
<div class="ts-warn-b">!</div>
<div class="ts-warn-t">
<div class="ts-warn-l">A code tip already owns this id.</div>
<div class="ts-warn-l">Your file sits behind it in the catalog.</div>
</div>
</div>
}
@foreach ( var n in NoteBlocks )
{
var note = n;
<div class="ts-warn">
<div class="ts-warn-b">!</div>
<div class="ts-warn-t">
@foreach ( var l in note.Lines )
{
var line = l;
<div class="ts-warn-l">@line</div>
}
</div>
</div>
}
</div>
@* ================= right: preview and bake ================= *@
<div class="ts-rail">
<div class="ts-rail-hdr">
<div class="ts-kicker">LIVE PREVIEW</div>
<div class="ts-btn-row">
<div class="ts-btn small gap" onclick=@CompleteNow>Complete it</div>
<div class="ts-btn pri small" onclick=@TestFire>Test fire</div>
</div>
</div>
@* Pinned above the scrolling preview: these four pick what the REAL lower-left card
shows, and a control that scrolls out of sight is a control nobody finds. *@
<div class="ts-pv-row">
<div class="ts-pv-key">Live card</div>
<div class="ts-chip @(TipsStudio.PreviewOn ? "on" : "")"
onclick=@TogglePreview>@(TipsStudio.PreviewOn ? "showing" : "show it")</div>
<div class="ts-chip @(TipsStudio.PinnedDevice == TipDevice.KeyboardMouse ? "on" : "")"
onclick=@(() => PinDevice( TipDevice.KeyboardMouse ))>keyboard</div>
<div class="ts-chip @(TipsStudio.PinnedDevice == TipDevice.Gamepad ? "on" : "")"
onclick=@(() => PinDevice( TipDevice.Gamepad ))>pad</div>
<div class="ts-chip @(TipsStudio.PinnedDevice is null ? "on" : "")"
onclick=@(() => PinDevice( null ))>live</div>
</div>
<div class="ts-rail-body" @ref="RailBody">
<div class="ts-well">
@foreach ( var p in PreviewCards )
{
var card = p;
<div class="ts-pv @(card.Last ? "last" : "")">
<div class="ts-pv-lab">@card.Label</div>
<div class="tsp-card">
<div class="tsp-stripe"></div>
<div class="tsp-in">
@if ( !string.IsNullOrEmpty( card.Icon ) )
{
<div class="tsp-glyph">@card.Icon</div>
}
<div class="tsp-body">
<div class="tsp-kicker">GUIDE</div>
<div class="tsp-text">
@foreach ( var seg in card.Segments )
{
var run = seg;
if ( run.Kind == TipSegmentKind.Key )
{
<span class="tsp-key">@run.Text</span>
}
else if ( run.Kind == TipSegmentKind.GamepadButton )
{
<span class="tsp-pad">@run.Text</span>
}
else
{
<span>@run.Text</span>
}
}
</div>
</div>
<div class="tsp-x">×</div>
</div>
</div>
</div>
}
<div class="ts-cap well">renders exactly what the coach will show</div>
</div>
</div>
<div class="ts-out">
<div class="ts-btn-row">
<div class="ts-btn grow gap" onclick=@CopyJson>@_copyLabel</div>
<div class="ts-btn pri grow" onclick=@Stage>Write to project</div>
</div>
<div class="ts-out-line">@BakeTarget</div>
<div class="ts-btn-row pad">
<div class="ts-btn small" onclick=@ClearStaged>Clear staged</div>
<div class="ts-out-line inline">@StatusLine</div>
</div>
</div>
</div>
</div>
</div>
</div>
}
</root>
@code
{
// ---- mounting ----
/// <summary>The raw key that OPENS the Studio. A plain letter on purpose: the editor eats F1 to F12 in
/// play-in-editor. It only opens, never closes, so pressing it inside a text box just types the letter.
/// Close with the header's × or <c>fg_tips_studio 0</c>.</summary>
[Property] public string OpenKey { get; set; } = "T";
/// <summary>Whether the Studio starts open. Off by default: an authoring panel that appears unbidden over
/// a game is a bug. This, and only this, decides the boot state; the persisted convar never does.</summary>
[Property] public bool OpenOnStart { get; set; }
/// <summary>Force the Studio shut anywhere but the editor. On by default: it is an authoring tool, and a
/// published build has nothing to author. Turn it off if you want it in your own standalone dev build.</summary>
[Property] public bool EditorOnly { get; set; } = true;
// @ref binds to an auto-PROPERTY. On a bare private field it silently never assigns, and the
// CanDragScroll fix below would quietly do nothing.
Sandbox.UI.Panel ListBody { get; set; }
Sandbox.UI.Panel MidBody { get; set; }
Sandbox.UI.Panel RailBody { get; set; }
bool _booted;
bool _wasOpen;
int _revision;
string _copyLabel = "Copy .tip JSON";
string _stageLine = "";
/// <summary>Which dropdown is showing its options, or null. One at a time: the lists sit in flow under
/// their field, so two open at once would push the column around for no reason.</summary>
string _openDrop;
/// <summary>The prerequisite picker's key. The trigger pickers key off their slot name.</summary>
const string PrereqDrop = "prereq";
bool IsOpen( string key ) => _openDrop == key;
void ToggleDrop( string key )
{
_openDrop = _openDrop == key ? null : key;
Refresh();
}
// ---- the catalog list ----
/// <summary>One row of the tip list. A struct of finished strings so the markup interpolates single
/// identifiers only, never a chained member read (which renders blank in several razor cases).</summary>
public struct Entry
{
public string Id;
public string Source;
public string State;
public bool Selected;
}
/// <summary>Every tip in the merged catalog, read fresh (so an edited .tip appears the moment the
/// catalog rebuilds), labelled with the source it resolved from and ordered the way the coach picks
/// them: highest priority first.</summary>
List<Entry> Entries
{
get
{
var list = new List<Entry>();
var view = TipsCatalog.View;
var activeId = TipsCoach.ActiveTip?.Id;
var opened = TipsStudio.OpenedFrom;
foreach ( var def in view.Tips.OrderByDescending( t => t.Priority ).ThenBy( t => t.Id, StringComparer.Ordinal ) )
{
var source = view.SourceById.TryGetValue( def.Id, out var s ) ? s : "unknown";
list.Add( new Entry
{
Id = def.Id,
Source = source,
State = def.Id == activeId ? "on screen" : ( TipsCoach.IsCompleted( def.Id ) ? "done" : null ),
Selected = def.Id == opened,
} );
}
return list;
}
}
/// <summary>The header's one line: the package, the catalog, and what the middle column is editing.
/// One interpolated string rather than three text nodes, because the in-editor codegen drops the
/// whitespace between a literal and an expression.</summary>
string HeaderMeta => $"fieldguide.tips · {CatalogSummary} · {DraftOrigin}";
/// <summary>How many tips and where they came from. A source you did not expect is a tip left over
/// from somewhere else.</summary>
string CatalogSummary
{
get
{
var view = TipsCatalog.View;
if ( view.Tips.Count == 0 )
return "no tips yet";
var counts = new Dictionary<string, int>();
foreach ( var kv in view.SourceById )
counts[kv.Value] = counts.TryGetValue( kv.Value, out var n ) ? n + 1 : 1;
var parts = counts.OrderBy( kv => kv.Key, StringComparer.Ordinal ).Select( kv => $"{kv.Value} {kv.Key}" );
return $"{view.Tips.Count} tips · {string.Join( ", ", parts )}";
}
}
void OpenTip( string id )
{
TipsStudio.OpenTip( id );
ResetLabels();
_openDrop = null;
Refresh();
}
void NewTip()
{
TipsStudio.NewDraft();
ResetLabels();
_openDrop = null;
Refresh();
}
void Rescan()
{
TipsCatalog.NoteAssetsChanged();
Refresh();
}
// ---- draft editing ----
string DraftOrigin => string.IsNullOrEmpty( TipsStudio.OpenedFrom )
? "new tip"
: $"editing {TipsStudio.OpenedFrom}";
// Single-identifier reads for the markup. A razor interpolation of a CHAINED member read
// (TipsStudio.Draft.Priority) renders blank in several cases; a plain property or a method call does not.
string DraftId => TipsStudio.Draft.Id;
string DraftText => TipsStudio.Draft.Text;
string DraftTextPad => TipsStudio.Draft.TextPad;
string DraftIcon => TipsStudio.Draft.Icon;
int DraftPriority => TipsStudio.Draft.Priority;
string DraftMaxShow => Show( TipsStudio.Draft.MaxShowSeconds );
static string TrigKey( TipStudioTrigger t ) => t.Key;
static string TrigName( TipStudioTrigger t ) => t.Name;
static string TrigThreshold( TipStudioTrigger t ) => Show( t.Threshold );
static string TrigSeconds( TipStudioTrigger t ) => Show( t.Seconds );
static string TrigMagnitude( TipStudioTrigger t ) => Show( t.Magnitude );
static string Show( float value ) => value.ToString( "0.##" );
void BumpPriority( int delta )
{
TipsStudio.Draft.Priority += delta;
Refresh();
}
void BumpMaxShow( float delta )
{
TipsStudio.Draft.MaxShowSeconds = MathF.Max( 0f, TipsStudio.Draft.MaxShowSeconds + delta );
Refresh();
}
List<string> PrerequisiteList => TipsStudio.Draft.PrerequisiteTipIds ?? new List<string>();
/// <summary>What the prerequisite field reads at rest. The list underneath ADDS one; the chips below the
/// row remove them, which is the only honest shape for a field that holds several values.</summary>
string PrereqFace
{
get
{
var have = PrerequisiteList;
if ( have.Count == 0 )
return "none";
return have.Count == 1 ? have[0] : $"{have.Count} tips";
}
}
/// <summary>Catalog ids this tip could wait on: everything except itself, the preview id, and the ones it
/// already waits on.</summary>
List<string> AvailablePrerequisites
{
get
{
var have = new HashSet<string>( PrerequisiteList, StringComparer.Ordinal );
var mine = TipsStudio.Draft.Id ?? "";
return TipsCatalog.Active
.Select( t => t.Id )
.Where( id => id != mine && id != TipsStudio.PreviewId && !have.Contains( id ) )
.OrderBy( id => id, StringComparer.Ordinal )
.ToList();
}
}
void AddPrerequisite( string id )
{
TipsStudio.Draft.PrerequisiteTipIds.Add( id );
_openDrop = null;
Refresh();
}
void RemovePrerequisite( string id )
{
TipsStudio.Draft.PrerequisiteTipIds.Remove( id );
Refresh();
}
// ---- the two trigger pickers ----
/// <summary>The Completion and Relevance pickers as data, so ONE block of markup renders both. A
/// RenderFragment would be the other way to share it, and RenderFragments under-measure here.</summary>
public struct TriggerSlot
{
public string Key;
public string Title;
public string Blurb;
public TipStudioTrigger Trigger;
public bool IsCompletion;
public bool NeedsName;
public string NameHint;
public bool IsComposite;
public string EmptyCompositeHint;
}
List<TriggerSlot> TriggerSlots
{
get
{
var completion = TipsStudio.Draft.Completion ??= new TipStudioTrigger();
var relevance = TipsStudio.Draft.Relevance ??= new TipStudioTrigger();
return new List<TriggerSlot>
{
Slot( "completion", "COMPLETION TRIGGER", "what retires this tip", completion, true ),
Slot( "relevance", "RELEVANCE TRIGGER", "an extra gate before it shows", relevance, false ),
};
}
}
static TriggerSlot Slot( string key, string title, string blurb, TipStudioTrigger trigger, bool isCompletion )
{
var kind = trigger.Kind;
var needsName = kind == TipTriggerKind.Signal || kind == TipTriggerKind.Ever
|| kind == TipTriggerKind.Flag || kind == TipTriggerKind.AtLeast;
var hint = kind switch
{
TipTriggerKind.Signal => "Signal(...) string",
TipTriggerKind.Ever => "ctx.Ever(...)",
TipTriggerKind.Flag => "ctx.SetFlag(...)",
_ => "ctx.SetNumber(...)",
};
return new TriggerSlot
{
Key = key,
Title = title,
Blurb = blurb,
Trigger = trigger,
IsCompletion = isCompletion,
NeedsName = needsName,
NameHint = hint,
IsComposite = kind == TipTriggerKind.AnyOf || kind == TipTriggerKind.AllOf,
EmptyCompositeHint = isCompletion && kind == TipTriggerKind.AllOf
? "empty: the shape a TipTriggerObject retires"
: "empty, so it never fires",
};
}
void SetKind( TipStudioTrigger trigger, TipTriggerKind kind )
{
trigger.Kind = kind;
_openDrop = null;
Refresh();
}
static string ActionFace( TipStudioTrigger trigger )
=> string.IsNullOrEmpty( trigger.Action ) ? "pick an action" : trigger.Action;
void PickAction( TipStudioTrigger trigger, string action )
{
trigger.Action = action;
_openDrop = null;
Refresh();
}
void SetSource( TipStudioTrigger trigger, TipTriggerAnalogSource source )
{
trigger.AnalogSource = source;
Refresh();
}
void BumpThreshold( TipStudioTrigger trigger, float delta )
{
trigger.Threshold = MathF.Max( 0f, trigger.Threshold + delta );
Refresh();
}
void BumpSeconds( TipStudioTrigger trigger, float delta )
{
trigger.Seconds = MathF.Max( 0f, trigger.Seconds + delta );
Refresh();
}
void BumpMagnitude( TipStudioTrigger trigger, float delta )
{
trigger.Magnitude = Math.Clamp( trigger.Magnitude + delta, 0f, 1f );
Refresh();
}
void AddChild( TipStudioTrigger parent )
{
parent.Children.Add( new TipStudioTrigger { Kind = TipTriggerKind.Key } );
Refresh();
}
void RemoveChild( TipStudioTrigger parent, TipStudioTrigger child )
{
parent.Children.Remove( child );
Refresh();
}
/// <summary>A composed child edits its one parameter through a single box, whichever box its kind reads.
/// Nesting a full picker per child would triple the panel for a case the format barely uses.</summary>
static string ChildValue( TipStudioTrigger child ) => child.Kind switch
{
TipTriggerKind.InputAction => child.Action,
TipTriggerKind.Key => child.Key,
TipTriggerKind.Signal or TipTriggerKind.Ever or TipTriggerKind.Flag or TipTriggerKind.AtLeast => child.Name,
TipTriggerKind.Timer => child.Seconds.ToString( "0.##" ),
TipTriggerKind.AnalogAxis => child.Magnitude.ToString( "0.##" ),
_ => "",
};
static void SetChildValue( TipStudioTrigger child, string value )
{
switch ( child.Kind )
{
case TipTriggerKind.InputAction: child.Action = value; break;
case TipTriggerKind.Key: child.Key = value; break;
case TipTriggerKind.Signal:
case TipTriggerKind.Ever:
case TipTriggerKind.Flag:
case TipTriggerKind.AtLeast: child.Name = value; break;
case TipTriggerKind.Timer:
if ( float.TryParse( value, out var seconds ) ) child.Seconds = MathF.Max( 0f, seconds );
break;
case TipTriggerKind.AnalogAxis:
if ( float.TryParse( value, out var magnitude ) ) child.Magnitude = Math.Clamp( magnitude, 0f, 1f );
break;
}
}
static string ChildHint( TipStudioTrigger child ) => TipStudioTrigger.FieldFor( child.Kind ) switch
{
"action" => "an input action name",
"key" => "a raw key name",
"name" => "the named condition",
"name+threshold" => "the named number",
"seconds" => "seconds",
"stick+magnitude" => "magnitude, 0 to 1",
"children" => "nest one level only",
_ => "this kind takes no value",
};
// ---- the two preview cards ----
/// <summary>One rendered card in the rail: the label above it and the runs inside it. Finished data, so
/// the markup walks a list rather than calling into the parser mid-tree.</summary>
public struct PreviewCard
{
public string Label;
public string Icon;
public List<TipSegment> Segments;
public bool Last;
}
/// <summary>The draft as the player will read it on each device, side by side. The pad card runs the same
/// keycap remap the shipped display does (TipsCoach.PadLabelFor), so a chip with no controller equivalent
/// disappears here exactly as it would in the game.</summary>
List<PreviewCard> PreviewCards
{
get
{
var text = TipsStudio.Draft.Text ?? "";
var pad = TipsStudio.Draft.TextPad ?? "";
var icon = TipsStudio.Draft.Icon ?? "";
return new List<PreviewCard>
{
new PreviewCard
{
Label = "KEYBOARD",
Icon = icon,
Segments = TipSegment.Parse( text ).ToList(),
},
new PreviewCard
{
Label = "CONTROLLER",
Icon = icon,
Segments = PadRuns( text, pad ),
Last = true,
},
};
}
}
/// <summary>The pad-mode runs for a wording: its pad text when authored, then every keycap put through
/// the game's pad label map. A mapped label reads as a controller chip; an unmappable one is dropped, the
/// same two rules the shipped card follows.</summary>
static List<TipSegment> PadRuns( string text, string textPad )
{
var runs = new List<TipSegment>();
foreach ( var seg in TipSegment.Parse( TipDeviceText.PadTextOr( text, textPad ) ) )
{
if ( seg.Kind != TipSegmentKind.Key )
{
runs.Add( seg );
continue;
}
var mapped = TipDeviceText.PadCap( seg.Text, TipsCoach.PadLabelFor );
if ( string.IsNullOrEmpty( mapped ) )
continue;
runs.Add( new TipSegment( mapped, mapped == seg.Text ? TipSegmentKind.Key : TipSegmentKind.GamepadButton ) );
}
return runs;
}
// ---- preview, test fire ----
List<string> ActionNames => TipsStudio.ActionNames.ToList();
/// <summary>One authoring note, already broken into lines that fit.</summary>
public struct NoteBlock
{
public List<string> Lines;
}
List<NoteBlock> NoteBlocks => TipsStudio.Notes
.Select( n => new NoteBlock { Lines = Lines( n ) } )
.ToList();
/// <summary>An option list longer than this scrolls at a fixed height instead of growing the column.</summary>
static string Tall( int count ) => count > 6 ? "tall" : "";
/// <summary>
/// Chunk a sentence into lines short enough to lay out as text. A run that overflows its box does not
/// wrap here: the style engine rasterizes it as a solid grey block, or drops it to an empty box. 46
/// characters is one comfortable line in the widest box this panel has, and it is the same ceiling
/// TipStudioText warns tip authors about.
/// </summary>
static List<string> Lines( string text )
{
var lines = new List<string>();
if ( string.IsNullOrWhiteSpace( text ) )
return lines;
var line = "";
foreach ( var word in text.Split( ' ' ) )
{
if ( string.IsNullOrEmpty( word ) )
continue;
if ( line.Length == 0 )
line = word;
else if ( line.Length + 1 + word.Length > 46 )
{
lines.Add( line );
line = word;
}
else
line = line + " " + word;
}
if ( line.Length > 0 )
lines.Add( line );
return lines;
}
void TogglePreview()
{
if ( TipsStudio.PreviewOn )
TipsStudio.StopPreview( Scene );
else
TipsStudio.PushPreview( Scene );
Refresh();
}
void PinDevice( TipDevice? device )
{
TipsStudio.PinnedDevice = device;
Refresh();
}
void TestFire()
{
TipsStudio.TestFire( Scene );
Refresh();
}
void CompleteNow()
{
TipsStudio.CompleteNow();
Refresh();
}
/// <summary>The one-line status under the bake buttons: whatever the last bake action said, or what
/// is waiting in the staging folder when it has said nothing yet.</summary>
string StatusLine
{
get
{
var text = string.IsNullOrEmpty( _stageLine ) ? StagedLine : _stageLine;
var lines = Lines( text );
return lines.Count == 0 ? "" : lines[0];
}
}
// ---- bake ----
string BakeTarget
{
get
{
var file = TipStudioJson.FileNameFor( TipsStudio.Draft.Id );
return file is null
? "Give the tip an id: the file takes its name."
: $"writes Assets/tips/{file}";
}
}
string StagedLine
{
get
{
var count = TipsStudio.StagedCount;
return count == 0 ? "nothing staged" : $"{count} staged for the editor";
}
}
void CopyJson()
{
TipsStudio.CopyJson();
_copyLabel = "Copied!";
Refresh();
}
void Stage()
{
_stageLine = TipsStudio.Stage();
Refresh();
}
void ClearStaged()
{
_stageLine = TipsStudio.ClearStaged();
Refresh();
}
void ResetLabels()
{
_copyLabel = "Copy .tip JSON";
_stageLine = "";
}
// ---- open / close, boot, cursor ----
/// <summary>Bump the panel's own revision so the next frame rebuilds it. Every click calls this; typing
/// into the id box does NOT, because a rebuild would take the cursor out of the box you are typing in.</summary>
void Refresh() => _revision++;
/// <summary>Enter in a text box: push the wording at the preview card and refresh everything derived
/// from it.</summary>
void Commit()
{
if ( TipsStudio.PreviewOn )
TipsStudio.PushPreview( Scene );
Refresh();
}
void Close()
{
TipsStudio.StopPreview( Scene );
TipsStudio.Open = false;
_openDrop = null;
ResetLabels();
}
protected override void OnTreeBuilt()
{
// A background press-drag over a scrolling region must not pan the content or eat a button click; the
// wheel and the scrollbar still scroll.
if ( ListBody is not null )
ListBody.CanDragScroll = false;
if ( MidBody is not null )
MidBody.CanDragScroll = false;
if ( RailBody is not null )
RailBody.CanDragScroll = false;
}
protected override void OnUpdate()
{
// BOOT. `fg_tips_studio` is a convar and s&box persists convars across sessions, so a session could
// otherwise come up with an authoring panel open from a value set weeks ago. This component's own
// OpenOnStart decides the boot state and the persisted value never does. Deliberately in the FIRST
// UPDATE, not OnStart: a panel created in code is configured by whatever created it, and OnStart would
// race that assignment.
if ( !_booted )
{
_booted = true;
TipsStudio.Open = OpenOnStart;
}
// The Studio is an authoring tool; a published build has nothing to author with it.
if ( EditorOnly && !Application.IsEditor )
{
TipsStudio.Open = false;
return;
}
// Opens only. Closing is the header × or the convar, so this key can never fight a text box.
if ( !TipsStudio.Open && !string.IsNullOrEmpty( OpenKey ) && Input.Keyboard.Pressed( OpenKey ) )
{
TipsStudio.Open = true;
Refresh();
}
if ( TipsStudio.Open )
{
Mouse.Visibility = MouseVisibility.Visible;
_wasOpen = true;
}
else if ( _wasOpen )
{
_wasOpen = false;
ResetLabels();
TipsStudio.StopPreview( Scene );
}
}
protected override void OnDestroy()
{
// Everything the Studio pins is static and would otherwise follow the developer into the next scene:
// the preview draft, a test-fire draft, and the pinned preview device.
TipsStudio.Shutdown();
}
// Fold the things a CLICK changes, and nothing anyone TYPES into. A rebuild rehomes every TextEntry, which
// takes the cursor out of the box mid-word, so Id and a trigger's Key and Name are deliberately absent:
// the panel repaints when you press Enter or click, via _revision.
protected override int BuildHash()
{
var hc = new HashCode();
hc.Add( TipsStudio.Open );
hc.Add( TipsStudio.OpenedFrom );
hc.Add( TipsStudio.PreviewOn );
hc.Add( TipsStudio.PinnedDevice );
hc.Add( TipsCoach.ActiveTip?.Id );
hc.Add( _copyLabel );
hc.Add( _stageLine );
hc.Add( _openDrop );
hc.Add( _revision );
hc.Add( TipsStudio.Draft.Priority );
hc.Add( TipsStudio.Draft.MaxShowSeconds );
hc.Add( PrerequisiteList.Count );
foreach ( var slot in TriggerSlots )
{
hc.Add( slot.Trigger.Kind );
hc.Add( slot.Trigger.Action );
hc.Add( slot.Trigger.Threshold );
hc.Add( slot.Trigger.Seconds );
hc.Add( slot.Trigger.AnalogSource );
hc.Add( slot.Trigger.Magnitude );
hc.Add( slot.Trigger.Children.Count );
foreach ( var child in slot.Trigger.Children )
hc.Add( child.Kind );
}
return hc.ToHashCode();
}
}
@using System
@using System.Collections.Generic
@using System.Linq
@using Sandbox
@using Sandbox.UI
@namespace Skafinity
@inherits PanelComponent
@* DontExecuteOnServer: this panel is pure human-client UI (like SkafinityPlayer). A dedicated
server never renders it, so skip its lifecycle there. On a listen server the host is still a
player, so the host-client keeps the panel — this only sheds the headless case. *@
@implements Component.DontExecuteOnServer
@*
The optional drop-in settings board for the Skafinity music engine.
Add this PanelComponent to a GameObject under a ScreenPanel (or WorldPanel). It finds a
SkafinityPlayer in the scene (or set Player explicitly) and offers the whole transport as UI —
you don't have to wire anything. The board's visibility is host-driven: set IsOpen (or call
Toggle()) from your game — e.g. bind it to a hotkey or your own pause/menu UI. This component
intentionally ships no launcher of its own, so it imposes nothing on the host's HUD — which also
means a freshly-dropped panel shows nothing until you bind IsOpen. Run `skafinity_panel` in the
console to see it before you have (SkafinityCommands.cs).
The engine needs nothing from this: SkafinityPlayer plays on its own. This board is pure
convenience for players who want to drive the station rather than tune it in the inspector.
It is drawn against the WEB WIDGET as its design (web/skafinity-element.js) so the two are one
product with one set of habits, and the wording and the derived decisions both sides need live in
SkafinityBoard rather than in either drawing of it. What is deliberately NOT shared is layout:
Razor and the DOM lay out differently enough that a common description of a row would be a lowest
common denominator of both.
Re-theming: the board derives its whole palette from one colour. Set SkafinityTheme.Accent from
your game (e.g. to your own UI accent) and the board follows; leave it unset and it is neutral
gray-on-black. SkafinityMusicPanel.razor.scss holds only the layout/type tokens.
*@
<root class="@( IsOpen ? "open" : "" )">
@if ( IsOpen )
{
@{
var cfg = Player?.EffectiveConfig();
int genre = cfg?.Genre ?? 0;
var here = Player?.Playhead() ?? default;
bool known = here.Duration > 0;
}
<div class="board" style="background-color:@SkafinityTheme.Bg;">
<div class="header">
<div class="title">MUSIC</div>
<div class="close" onclick="@Toggle">✕</div>
</div>
@* ── Transport ─────────────────────────────────────────────────────────────────────
Two rows: the buttons, and the bar that says where in the song they are acting. *@
<div class="transport">
<div class="row">
<div class="btn big" style="@BtnStyle" tooltip="@SkafinityBoard.Copy.PrevTitle"
onclick="@( () => Step( -1 ) )">@SkafinityBoard.Copy.Prev</div>
<div class="btn big primary" style="@BtnOnStyle" tooltip="@SkafinityBoard.Copy.PlayTitle"
onclick="@TogglePlay">@( Playing ? SkafinityBoard.Copy.Pause : SkafinityBoard.Copy.Play )</div>
<div class="btn big" style="@BtnStyle" tooltip="@SkafinityBoard.Copy.NextTitle"
onclick="@( () => Step( 1 ) )">@SkafinityBoard.Copy.Next</div>
<div class="now" style="@LabelStyle">@SkafinityBoard.Copy.NowPlaying<b style="@TextStyle">@( Player?.N ?? 0 )</b></div>
@* Playback stalled on the song you skipped to — as opposed to the silent background
look-ahead, which nobody needs telling about. *@
@if ( Player?.IsBuffering == true )
{
<div class="bufstate" style="@AccentTextStyle">@SkafinityBoard.Copy.Generating( Player?.N ?? 0 )</div>
}
<div class="vol right" style="@LabelStyle">
@SkafinityBoard.Copy.Volume
<SkafinitySlider Min="@(0f)" Max="@(1.5f)" Step="@(0.01f)" FixedWidth="@(120f)"
Value="@( Player?.Volume ?? 1f )"
OnValueChanged="@( (float v) => SetVolume( v ) )"></SkafinitySlider>
</div>
</div>
@* The seek bar. The whole song is already in memory, so a scrub is a stream restart on
PCM we are holding rather than a fetch — which is why this is a plain slider and not a
loading affordance. It goes inert, rather than drawing against a guess, until the song
has been rendered and has a length worth stating. *@
<div class="seek">
<div class="time" style="@LabelStyle">@SkafinityBoard.Time( ShownTime( here ), known )</div>
<SkafinitySlider tooltip="@SkafinityBoard.Copy.SeekTitle" Disabled="@( !known )"
Min="@(0f)" Max="@(1000f)" Step="@(1f)"
Value="@( known ? ShownRatio( here ) * 1000f : 0f )"
OnValueChanged="@( (float v) => Scrub( v / 1000f, here.Duration ) )"></SkafinitySlider>
<div class="time total" style="@LabelStyle">@SkafinityBoard.Time( here.Duration, known )</div>
</div>
</div>
@* ── The seed ──────────────────────────────────────────────────────────────────────
TWO copy buttons, because "share this" means one of two things and neither can be
recovered from the other: the song, with everything it left to chance written down, or
the station as it stands, which keeps rolling for whoever is handed it. *@
<div class="row seed-bar">
<TextEntry @ref="_seedEntry" placeholder="@SkafinityBoard.Copy.SeedPlaceholder" class="grow seed-input" />
<div class="btn primary" style="@BtnOnStyle" onclick="@PlayTyped">@SkafinityBoard.Copy.SeedGo</div>
<div class="btn" style="@BtnStyle" tooltip="@SkafinityBoard.Copy.CopySongTitle"
onclick="@CopySong">@_copySongLabel</div>
<div class="btn" style="@BtnStyle" tooltip="@SkafinityBoard.Copy.CopyStationTitle"
onclick="@CopyStation">@_copyStationLabel</div>
</div>
@* ── What plays ────────────────────────────────────────────────────────────────────
These are not knob controls — genre, reroll and shuffle change the seed, and tinker only
opens the box — so they live on the board itself. Putting them in the mixer made them look
like part of it AND hid them from everyone who never opened it. *@
<div class="row what-plays">
<div class="label" style="@LabelStyle">@SkafinityBoard.Copy.Genre</div>
@* The dropdown IS the seed's genre part: "Random" takes it out of the string so every
song rolls its own again, and without that entry there is no way back out of a genre
once one has been chosen. It reads as selected when nothing is pinned. *@
<DropDown class="genre" style="@BtnStyle" Value="@GenreValue" Options="@GenreOptions"
ValueChanged="@( (string v) => PickGenre( v ) )" />
<div class="btn" style="@BtnStyle" tooltip="@SkafinityBoard.Copy.RerollTitle"
onclick="@RerollStation">@SkafinityBoard.Copy.Reroll</div>
<div class="btn toggle @( Shuffling ? "on" : "" )" style="@( Shuffling ? BtnOnStyle : BtnStyle )"
tooltip="@SkafinityBoard.Copy.ShuffleTitle"
onclick="@ToggleShuffle">@( Shuffling ? SkafinityBoard.Copy.ShuffleOn : SkafinityBoard.Copy.ShuffleOff )</div>
<div class="btn toggle right @( _tinkering ? "on" : "" )" style="@( _tinkering ? BtnOnStyle : BtnStyle )"
onclick="@ToggleTinker">@( _tinkering ? SkafinityBoard.Copy.TinkerOpen : SkafinityBoard.Copy.Tinker )</div>
</div>
@* ── The knobs ─────────────────────────────────────────────────────────────────────
Behind the tinker button. They are the deep end of the toy, and a wall of sliders is
otherwise the first thing anybody meets. *@
@if ( _tinkering )
{
<div class="panel vibe" style="@PanelStyle">
<div class="h2" style="@LabelStyle">@SkafinityBoard.Copy.VibeHeading</div>
<div class="matrix">
<div class="mrow mhead">
<div class="mvoice"></div>
@foreach ( var h in SkafinityBoard.ColumnHeaders )
{
<div class="mcell mhlabel" style="@LabelStyle">@h</div>
}
</div>
@foreach ( var row in SkafinityBoard.Matrix( genre ) )
{
<div class="mrow">
<div class="mvoice">@row.Voice</div>
@for ( int col = 0; col < SkafinityBoard.ColumnHeaders.Length; col++ )
{
var f = row.Cells[col];
<div class="mcell">
@if ( f != null )
{
@Knob( f, cfg, SkafinityBoard.KnobLabel( f, col ) )
}
</div>
}
</div>
}
</div>
@* Only when there IS a global knob. They have all been retired to reserved wire slots
(tempo to GenreProfile, width and reverb to house config), and a heading over an
empty grid reads as a panel that failed to draw something. *@
@if ( SkafinityBoard.Globals( genre ).Count > 0 )
{
<div class="glabel" style="@LabelStyle">@SkafinityBoard.Copy.GlobalHeading</div>
<div class="global-grid">
@foreach ( var f in SkafinityBoard.Globals( genre ) )
{
<div class="knob-cell">@Knob( f, cfg, f.Name )</div>
}
</div>
}
@* The only two buttons that act on the sliders, and they are not two dice. 🎲 always
moves every knob, because it draws a fresh vibe and PINS it. ↺ is the way back out —
dragging a knob pins the whole vibe, so without it one accidental drag turns an
endless station into one song forever — and it is off when there is nothing pinned
rather than looking like a die that did nothing. *@
<div class="row vibe-actions">
<div class="btn" style="@BtnStyle" tooltip="@SkafinityBoard.Copy.VibeRollTitle"
onclick="@RerollVibe">@SkafinityBoard.Copy.VibeRoll</div>
<div class="btn @( VibePinned ? "" : "off" )" style="@BtnStyle"
tooltip="@SkafinityBoard.Copy.VibeRandomTitle"
onclick="@RollVibe">@SkafinityBoard.Copy.VibeRandom</div>
</div>
</div>
}
@* ── The playlist ──────────────────────────────────────────────────────────────────
Past · now · up next. A row addresses its song by POSITION, which is its slot on the
timeline; the number it SHOWS is the song's index in its own station, and under shuffle
those are different things. *@
<div class="panel playlist-panel" style="@PanelStyle">
<div class="h2" style="@LabelStyle">@SkafinityBoard.Copy.PlaylistHeading</div>
<div class="playlist">
@foreach ( var e in Queue() )
{
var ee = e;
<div class="plrow @( e.Current ? "now" : "" ) @( e.Cached ? "cached" : "" ) @( e.Progress >= 0 ? "gen" : "" )"
style="@RowStyle( e )">
<div class="pllabel" onclick="@( () => Player?.SeekTo( ee.Position ) )">
<div class="plcaret">@SkafinityBoard.RowCaret( e )</div>
<div class="plhash">@SkafinityBoard.Copy.Hash</div>
<div class="plnum">@e.N</div>
</div>
<div class="plgenre" style="@LabelStyle">@SkafinityBoard.GenreName( e.Genre )</div>
<div class="plstatus" style="@LabelStyle">
@if ( e.Progress >= 0 )
{
<div class="bar"><div class="bar-fill" style="@BarFillStyle( e.Progress )"></div></div>
}
else
{
@SkafinityBoard.RowStatus( e )
}
</div>
<div class="pldl" style="@LabelStyle" tooltip="@SkafinityBoard.Copy.ExportTitle( e.N )"
onclick="@( () => Save( ee.Position ) )">⬇</div>
</div>
}
</div>
<div class="row jump" style="@LabelStyle">
@SkafinityBoard.Copy.JumpTo
<TextEntry @ref="_jumpEntry" Numeric="@true" class="jump-input" />
<div class="btn" style="@BtnStyle" onclick="@JumpTo">@SkafinityBoard.Copy.JumpGo</div>
<div class="btn right" style="@BtnStyle"
onclick="@( () => Save( Player?.Position ?? 0 ) )">@( _saving ? SkafinityBoard.Copy.ExportBusy : SkafinityBoard.Copy.Export )</div>
</div>
</div>
@if ( _msg != null )
{
<div class="msg" style="@AccentTextStyle">@_msg</div>
}
</div>
}
</root>
@code
{
/// <summary>The player this panel drives. Leave unset to auto-find a <see cref="SkafinityPlayer"/>
/// in the scene on start.</summary>
[Property] public SkafinityPlayer Player { get; set; }
/// <summary>Whether the settings board is showing. Host-driven — set it from your game (or
/// call <see cref="Toggle"/>) to wire the board to a hotkey / pause menu / your own button.
/// This component ships no launcher of its own.</summary>
/// <remarks>Deliberately NOT a <c>[Property]</c>: this is transient UI state. On a networked
/// (<c>NetworkMode: Snapshot</c>) GameObject a serialized <c>[Property]</c> rides the late-join
/// snapshot, so a client joining while the host has the board open would restore
/// <c>IsOpen = true</c> — leaking the host's UI state and rendering the board open (and unstyled,
/// since the panel is rebuilt mid-deserialize). Leaving it un-serialized keeps it host-driven
/// from code while starting <c>false</c> on every client.</remarks>
public bool IsOpen { get; set; }
TextEntry _seedEntry;
TextEntry _jumpEntry;
bool _seedInit;
// The station seed this panel last wrote into the box — what tells a stale box from a typed one.
string _seedShown;
// Both copy buttons keep their own label so pressing one doesn't report "copied!" on the other.
string _copySongLabel = SkafinityBoard.Copy.CopySong;
string _copyStationLabel = SkafinityBoard.Copy.CopyStation;
string _msg;
// The knob matrix is closed until asked for. Not a [Property] for the same reason IsOpen is not.
bool _tinkering;
bool _saving;
// A DRAG, held. A slider reports every mouse-move and there is no "let go" event to wait for, so
// seeking on each report would restart the stream at every pixel. The thumb is therefore followed
// here and the transport is told once the drag has settled — which is the same one-seek-per-gesture
// the web gets from listening to `change` rather than `input`.
const float ScrubSettle = 0.2f;
float _scrubTo = -1f;
TimeSince _scrubSince;
protected override void OnStart()
{
Player ??= Scene.GetAllComponents<SkafinityPlayer>().FirstOrDefault();
if ( Player == null )
Log.Warning( "SkafinityMusicPanel: no SkafinityPlayer found in the scene — add one (or set Player)." );
}
protected override void OnUpdate()
{
// The text entry follows the station as it stands — not once at open, but whenever the station
// moves out from under it. Reroll, a pasted seed and a genre pin all change what the station
// IS, and a box still showing the previous one reads as a reroll that did nothing.
//
// Only ever overwrites text this panel wrote: the moment somebody types, the box is theirs and
// a background change to the station leaves it alone rather than eating what they were typing.
if ( !IsOpen ) { _seedInit = false; return; }
if ( _seedEntry != null )
{
var station = Player?.StationSeed ?? "";
if ( !_seedInit || ( station != _seedShown && _seedEntry.Text == _seedShown ) )
{
_seedEntry.Text = station;
_seedShown = station;
_seedInit = true;
}
}
// The held scrub, applied once the drag settles. See _scrubTo.
if ( _scrubTo >= 0f && _scrubSince > ScrubSettle )
{
var d = Player?.Playhead().Duration ?? 0;
if ( d > 0 ) Player?.SeekWithin( _scrubTo * d );
_scrubTo = -1f;
}
}
// ── Theme bindings ──
// The palette is runtime (SkafinityTheme), so every fill and themed text colour is bound here as
// an inline style rather than named in the .scss. The stylesheet owns every border in return —
// see the header comment there for why the two must not overlap.
//
// Neither a rule nor an inline style reaches inside a control this library did not write, which
// is why the board's slider is one it does (SkafinitySlider) — and why the accent reaches the
// sliders at all.
static string LabelStyle => $"color:{SkafinityTheme.TextDim};";
static string TextStyle => $"color:{SkafinityTheme.Text};";
static string AccentTextStyle => $"color:{SkafinityTheme.AccentCss};";
static string BtnStyle => $"background-color:{SkafinityTheme.Cell}; color:{SkafinityTheme.Text};";
static string BtnOnStyle => $"background-color:{SkafinityTheme.AccentBg}; color:{SkafinityTheme.Text};";
static string PanelStyle => $"background-color:{SkafinityTheme.Cell};";
static string BarFillStyle( float p ) => $"width:{SkafinityBoard.Percent( p )}; background-color:{SkafinityTheme.AccentCss};";
// A playlist row reads as one of three states, brightest first: the song playing now, a song
// already rendered and waiting, anything else.
static string RowStyle( SkafinityPlayer.QueueEntry e ) =>
e.Current ? $"background-color:{SkafinityTheme.AccentBg};"
: e.Cached ? $"background-color:{SkafinityTheme.CellFillSoft};"
: "";
bool Playing => Player != null && !Player.IsPaused;
bool Shuffling => Player?.Shuffle ?? false;
bool VibePinned => Player?.VibePinned ?? false;
bool GenreRolling => Player == null || !Player.GenrePinned;
/// <summary>Open/close the settings board. Convenience for hosts that want to bind a single
/// action; you can also set <see cref="IsOpen"/> directly.</summary>
public void Toggle()
{
IsOpen = !IsOpen;
if ( !IsOpen ) _seedInit = false;
}
// ── The playhead ──
// Mid-drag the thumb the user is holding wins over the clock; every other moment reads the
// transport. Both halves have to agree or the label counts up while the thumb sits still.
float ShownRatio( SkafinityPlayer.SongPosition p ) => _scrubTo >= 0f ? _scrubTo : p.Ratio;
double ShownTime( SkafinityPlayer.SongPosition p ) => _scrubTo >= 0f ? _scrubTo * p.Duration : p.Time;
void Scrub( float ratio, double duration )
{
if ( duration <= 0 ) return;
_scrubTo = Math.Clamp( ratio, 0f, 1f );
_scrubSince = 0;
}
void TogglePlay() { Player?.TogglePlay(); _msg = null; }
void Step( int d ) { Player?.StepN( d ); _msg = null; }
void SetVolume( float v ) { if ( Player != null ) Player.Volume = v; }
// ── The seed ──
void PlayTyped()
{
if ( Player == null ) { _msg = null; return; }
// A seed that will not parse leaves playback exactly where it was and says why, rather than
// starting something adjacent to what was typed.
_msg = Player.PlaySeed( _seedEntry?.Text, out var error )
? SkafinityBoard.Copy.Playing( Player.CurrentSeed ) : error;
}
// This song, with everything it left to chance written down — whoever is handed it hears THIS,
// not whatever their own station rolls at that index.
void CopySong()
{
try { Clipboard.SetText( Player?.CurrentSeed ?? "" ); _copySongLabel = SkafinityBoard.Copy.Copied; }
catch { _copySongLabel = "—"; }
}
// The seed as it stands: whatever this player left rolling keeps rolling for them too.
void CopyStation()
{
try { Clipboard.SetText( Player?.StationSeed ?? "" ); _copyStationLabel = SkafinityBoard.Copy.Copied; }
catch { _copyStationLabel = "—"; }
}
// ── What plays ──
// "" is the Random entry — the genre is not in the seed, so every song rolls its own.
static readonly List<Option> GenreOptions = BuildGenreOptions();
static List<Option> BuildGenreOptions()
{
var list = new List<Option> { new( SkafinityBoard.Copy.GenreRandom, "" ) };
for ( int g = 0; g < VibeCodec.GenreCount; g++ )
list.Add( new Option( VibeCodec.Genres[g], g.ToString() ) );
return list;
}
string GenreValue => GenreRolling ? "" : ( Player?.EffectiveConfig()?.Genre ?? 0 ).ToString();
void PickGenre( string v )
{
if ( Player == null ) return;
if ( string.IsNullOrEmpty( v ) ) { Player.RollGenre(); _msg = SkafinityBoard.Copy.GenreUnpinned; return; }
if ( int.TryParse( v, out var g ) ) { Player.SetGenre( g ); _msg = null; }
}
// A different SONG, not a different taste: a fresh station at song 0, with anything pinned left
// pinned.
void RerollStation() { Player?.RerollStation(); _msg = SkafinityBoard.Copy.NewStation; }
void ToggleShuffle() { if ( Player != null ) Player.SetShuffle( !Player.Shuffle ); _msg = null; }
void ToggleTinker() { _tinkering = !_tinkering; }
// ── The knobs ──
// One knob: a name/value header over a real slider (or a dropdown, where the field is a choice).
// The whole layout comes from the library's field metadata for the current genre, so a new genre
// — or a new knob — is a pure engine change and there is no field table here.
RenderFragment Knob( VibeCodec.Field f, MusicGen.Config cfg, string label )
{
int genre = cfg?.Genre ?? 0;
int idx = SkafinityBoard.FieldIndex( genre, f );
float norm = cfg != null ? f.GetNorm( cfg ) : 0f;
return @<text>
<div class="knob">
<div class="knob-head">
<div class="knob-name" style="@LabelStyle">@label</div>
<div class="knob-val" style="@AccentTextStyle">@( cfg != null ? f.Display( cfg ) : "" )</div>
</div>
@if ( f.Choices != null )
{
<DropDown class="knob-select" style="@BtnStyle" Value="@SkafinityBoard.ChoiceIndex( f, norm ).ToString()"
Options="@ChoiceOptions( f )"
ValueChanged="@( (string v) => SetChoice( idx, f, v ) )" />
}
else
{
@* Snapped to the same discrete grid the seed encodes (one level per base-36 char), so
the slider can only land on values the vibe can actually represent. *@
<SkafinitySlider Min="@(0f)" Max="@( (float)(VibeCodec.Levels - 1) )" Step="@(1f)"
Value="@( MathF.Round( norm * (VibeCodec.Levels - 1) ) )"
OnValueChanged="@( (float v) => SetVibe( idx, v / (VibeCodec.Levels - 1) ) )"></SkafinitySlider>
}
</div>
</text>;
}
static List<Option> ChoiceOptions( VibeCodec.Field f )
{
var list = new List<Option>( f.Choices.Length );
for ( int k = 0; k < f.Choices.Length; k++ ) list.Add( new Option( f.Choices[k], k.ToString() ) );
return list;
}
void SetChoice( int idx, VibeCodec.Field f, string v )
{
if ( int.TryParse( v, out var k ) ) SetVibe( idx, SkafinityBoard.ChoiceNorm( f, k ) );
}
void SetVibe( int index, float norm )
{
if ( index < 0 ) return;
Player?.SetVibe( index, norm );
_msg = null;
}
// RerollVibe()'s defaults: the genre and the per-instrument volumes stay — the die over the
// mixer re-voices the band, it does not swap the band or upend the mix you set.
void RerollVibe() { Player?.RerollVibe(); _msg = SkafinityBoard.Copy.VibeRolled; }
void RollVibe()
{
if ( Player == null || !Player.VibePinned ) return; // nothing pinned — nothing to hand back
Player.RollVibe();
_msg = SkafinityBoard.Copy.VibeUnpinned;
}
// ── The playlist ──
// How many history / look-ahead entries to show either side of the current song.
static int QueueBack => 3;
static int QueueFwd => 5;
IEnumerable<SkafinityPlayer.QueueEntry> Queue() =>
Player?.Timeline( QueueBack, QueueFwd ) ?? Enumerable.Empty<SkafinityPlayer.QueueEntry>();
void JumpTo()
{
if ( Player == null ) return;
if ( int.TryParse( _jumpEntry?.Text, out var p ) ) Player.SeekTo( p );
}
// Rendering a song outside the cache takes seconds, so the button says so — a save that looks
// like it did nothing is a save people press again.
async void Save( int position )
{
if ( Player == null || _saving ) return;
_saving = true;
try
{
var name = await Player.SaveToFileAsync( position );
_msg = string.IsNullOrEmpty( name ) ? SkafinityBoard.Copy.SaveFailed : SkafinityBoard.Copy.Saved( name );
}
finally { _saving = false; }
}
protected override int BuildHash()
{
// Fold in the playhead and the queue's cached/generating state so the board animates as the
// song runs and as songs render. Both are QUANTISED: the seek bar wants to move, but a panel
// that rebuilds every frame to advance a bar by a pixel costs more than it shows. A fifth of
// a second is smooth to look at and cheap to draw.
var q = new HashCode();
q.Add( IsOpen ); q.Add( Player?.CurrentSeed ); q.Add( Player?.CurrentVibe );
q.Add( Player?.Enabled ?? true ); q.Add( Player?.Volume ?? 1f );
q.Add( Player?.GenrePinned ?? false ); q.Add( Player?.VibePinned ?? false );
q.Add( Player?.Shuffle ?? false ); q.Add( Player?.IsPaused ?? false );
q.Add( _tinkering ); q.Add( Player?.IsBuffering ?? false ); q.Add( _saving );
q.Add( _msg ); q.Add( _copySongLabel ); q.Add( _copyStationLabel );
q.Add( _scrubTo );
// The palette rides in inline style= values, so the board has to rebuild when the host
// retints it — nothing else in this hash moves when only SkafinityTheme.Accent changes.
q.Add( SkafinityTheme.Accent );
if ( IsOpen )
{
var here = Player?.Playhead() ?? default;
q.Add( (int)(here.Time * 5) ); q.Add( (int)(here.Duration * 5) );
foreach ( var e in Queue() )
{
q.Add( e.N ); q.Add( e.Position ); q.Add( e.Cached ); q.Add( e.Current ); q.Add( e.Genre );
q.Add( e.Progress >= 0 ? (int)MathF.Round( e.Progress * 20 ) : -1 );
}
}
return q.ToHashCode();
}
}
@namespace FieldGuide.VehiclePhysics
@inherits PanelComponent
@* Kit-native live tuning lab (demo layer). Toggled by the Tune action, bound to the car the chase
camera is following. Writes tuning changes straight onto the running car through the same paths a
consumer would use: it mutates the active CarDefinition (read live by the drivetrain and the brake
model) and pushes suspension/tire values onto the live wheels. This is a demo-scale lab, not the
full game panel: it exposes the highest-feel dials so the demo reads as a physics lab in the first
minute. Replace it with your own UI; it lives only in the demo scene.
Look and layout follow the Field Kit UI system: docs/design/ui-system/vehicle-physics.dc.html
screen 03.A for this panel, 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 rules at its
head. *@
<root>
@if ( Car.IsValid() )
{
@* Key legend, top-right: how to hop between the demo cars. Always visible; the tuning
chip/panel top-left covers the T binding. *@
<div class="legend">
<div class="mono kbd">[ ]</div>
<div class="title">switch car</div>
</div>
}
@if ( !IsOpen && Car.IsValid() )
{
@* Collapsed legend chip: sits exactly where the expanded panel's top-left corner lands, so
pressing T reads as the chip expanding into the lab. Mouse-look stays with the camera. *@
<div class="panel chip" onclick=@ToggleOpen>
<div class="head">
<div class="mono kbd">@ToggleKeyLabel</div>
<div class="title">tuning</div>
</div>
</div>
}
@if ( IsOpen && Car.IsValid() )
{
<div class="panel">
<div class="head">
<div class="title">Tuning lab · @CarName</div>
@* Key chip plus the 42px close x. The x runs the SAME ToggleOpen path the T key and
the collapsed chip use, so cursor visibility stays decided in one place. *@
<div class="hr">
<div class="mono kbd">@ToggleKeyLabel</div>
<div class="x" onclick=@ToggleOpen>×</div>
</div>
</div>
<div class="hint">Tuning the car you are driving. Changes apply live.</div>
<div class="dials">
@foreach ( var d in SliderDials )
{
var dd = d;
<div class="dial">
<div class="drow">
<div class="dname">@d.Name</div>
<div class="mono dval">@d.Display()</div>
</div>
<div class="dctl">
<div class="mono step" onclick=@( () => Step( dd, -1 ) )>-</div>
<div class="track"
onmousedown=@( e => Scrub( dd, e, true ) )
onmousemove=@( e => Scrub( dd, e, false ) )>
<div class="fill" style="width: @WidthPct( dd )%"></div>
</div>
<div class="mono step" onclick=@( () => Step( dd, +1 ) )>+</div>
</div>
</div>
}
<div class="dial">
<div class="drow">
<div class="dname">Assists</div>
<div class="mono cycle" onclick=@CycleAssists>@AssistLabel</div>
</div>
</div>
<div class="dial">
<div class="drow">
<div class="dname">Tires</div>
<div class="mono cycle" onclick=@CycleTires>@TireLabel</div>
</div>
</div>
</div>
<div class="foot">
<div class="btn" onclick=@ResetToStock>Reset to stock</div>
</div>
</div>
}
</root>
@code {
/// <summary>The car this panel tunes: the one the chase camera follows. Set by DemoBootstrap. On
/// change the panel snapshots the car's pristine (authored) values so Reset-to-stock can restore
/// them and the grip/torque multipliers rebase to the new car.</summary>
VehicleController _car;
public VehicleController Car
{
get => _car;
set
{
if ( ReferenceEquals( _car, value ) )
return;
_car = value;
Snapshot();
}
}
/// <summary>Open state. Static so the kit's chase camera can read it through the
/// <see cref="VehicleCamera.CursorModalOpen"/> seam (DemoBootstrap wires that) without holding a
/// reference to this panel. The demo runs exactly one panel, so a single flag is enough.</summary>
public static bool IsOpen;
// Toggle input action. Documented in the README input table; the host ProjectSettings/Input.config
// ships it. Never Escape or an F-key.
const string ToggleAction = "Tune";
string ToggleKeyLabel => "T";
string CarName => Car?.Definition?.Name ?? "Car";
// Live multipliers over the pristine base. Grip scales the (preset-selected) tire curves; torque
// scales the authored peak engine torque.
float _gripScale = 1f;
float _torqueScale = 1f;
int _tirePreset; // 0 Stock, 1 Street, 2 Sport, 3 Offroad
// Pristine snapshot, captured value-by-value when the car is bound (its definition is untouched at
// that point). Value types only (floats, TireCurve struct, enum), so later live tuning of the
// definition can never corrupt these; Reset restores from here.
bool _snapped;
float _stockPeakTorque, _stockSpring, _stockDamper, _stockTravel, _stockBrake;
TireCurve _stockLat, _stockLong;
AssistLevel _stockAssists;
// Current UNSCALED tire base (Stock or a named preset). Grip multiplies these to get the live curves.
TireCurve _baseLat, _baseLong;
void Snapshot()
{
var def = _car?.Definition;
_dials = null;
if ( def is null )
{
_snapped = false;
return;
}
_stockPeakTorque = def.PeakTorque;
_stockSpring = def.SpringRate;
_stockDamper = def.DamperRate;
_stockTravel = def.SuspensionTravel;
_stockBrake = def.BrakeTorque;
_stockLat = def.LateralCurve;
_stockLong = def.LongitudinalCurve;
// Stock assist = the authored definition default. Read it off the definition, not the
// controller: the controller adopts DefaultAssists in its own OnStart, which may run a frame
// or two after this bind, so its live Assists is not reliable yet.
_stockAssists = def.DefaultAssists;
_baseLat = _stockLat;
_baseLong = _stockLong;
_gripScale = 1f;
_torqueScale = 1f;
_tirePreset = 0;
_snapped = true;
StateHasChanged();
}
// ---- dial model ----
class Dial
{
public string Name;
public float Min, Max, Step;
public Func<float> Get;
public Action<float> Set;
public Func<string> Fmt;
public string Display() => Fmt();
}
List<Dial> _dials;
List<Dial> SliderDials => _dials ??= BuildDials();
List<Dial> BuildDials()
{
if ( !Car.IsValid() )
return new List<Dial>();
var def = Car.Definition;
return new List<Dial>
{
new()
{
Name = "Grip", Min = 0.6f, Max = 2.2f, Step = 0.05f,
Get = () => _gripScale, Set = SetGrip,
Fmt = () => _gripScale.ToString( "0.00" ) + "x",
},
new()
{
Name = "Drive torque", Min = 0.5f, Max = 2.0f, Step = 0.05f,
Get = () => _torqueScale, Set = SetTorqueScale,
Fmt = () => _torqueScale.ToString( "0.00" ) + "x",
},
new()
{
Name = "Suspension stiffness", Min = 15000f, Max = 60000f, Step = 2000f,
Get = () => def.SpringRate, Set = v => { def.SpringRate = v; ApplyWheels(); },
Fmt = () => def.SpringRate.ToString( "0" ) + " N/m",
},
new()
{
Name = "Suspension damping", Min = 800f, Max = 6000f, Step = 200f,
Get = () => def.DamperRate, Set = v => { def.DamperRate = v; ApplyWheels(); },
Fmt = () => def.DamperRate.ToString( "0" ) + " Ns/m",
},
new()
{
Name = "Suspension travel", Min = 0.10f, Max = 0.35f, Step = 0.01f,
Get = () => def.SuspensionTravel, Set = v => { def.SuspensionTravel = v; ApplyWheels(); },
Fmt = () => (def.SuspensionTravel * 100f).ToString( "0" ) + " cm",
},
new()
{
Name = "Brake force", Min = 1500f, Max = 8000f, Step = 200f,
Get = () => def.BrakeTorque, Set = v => def.BrakeTorque = v,
Fmt = () => def.BrakeTorque.ToString( "0" ) + " Nm",
},
};
}
float Frac( Dial d )
{
if ( d.Max <= d.Min )
return 0f;
return Math.Clamp( (d.Get() - d.Min) / (d.Max - d.Min), 0f, 1f );
}
string WidthPct( Dial d ) =>
(Frac( d ) * 100f).ToString( "0.#", System.Globalization.CultureInfo.InvariantCulture );
void Step( Dial d, int clicks )
{
float v = Math.Clamp( d.Get() + clicks * d.Step, d.Min, d.Max );
d.Set( v );
StateHasChanged();
}
// Click-to-jump + drag-to-scrub on the dial track (mirrors the game panel's proven pattern). jump
// is true on mousedown (a bare click sets the value at the cursor) and false on mousemove (scrub
// only while the track owns the press). Value = mouse local x over track width, snapped to the
// dial's step, pushed through the SAME Set path as the +/- steps.
void Scrub( Dial d, Sandbox.UI.PanelEvent ev, bool jump )
{
if ( ev is not Sandbox.UI.MousePanelEvent e )
return;
var track = e.This;
if ( track is null )
return;
// mousemove fires whether or not the button is held; only scrub while the track owns the press.
if ( !jump && !track.PseudoClass.HasFlag( Sandbox.UI.PseudoClass.Active ) )
return;
float w = track.Box.Rect.Width;
if ( w <= 0f )
return;
float frac = Math.Clamp( e.LocalPosition.x / w, 0f, 1f );
float v = d.Min + frac * (d.Max - d.Min);
if ( d.Step > 0f )
v = MathF.Round( v / d.Step ) * d.Step;
v = Math.Clamp( v, d.Min, d.Max );
d.Set( v );
StateHasChanged();
}
// ---- apply paths (same seams a consumer would use) ----
static TireCurve Scaled( TireCurve c, float k ) =>
new( c.PeakSlip, c.PeakGrip * k, c.TailSlip, c.TailGrip * k );
// Push the definition's suspension + tire values onto the live wheels. The factory copies these at
// spawn; the wheel re-reads them every substep, so writing them here is the live-apply path.
void ApplyWheels()
{
var def = Car.Definition;
foreach ( var w in Car.Wheels )
{
w.SpringRate = def.SpringRate;
w.DamperRate = def.DamperRate;
w.SuspensionTravel = def.SuspensionTravel;
w.LateralCurve = def.LateralCurve;
w.LongitudinalCurve = def.LongitudinalCurve;
}
}
void SetGrip( float k )
{
_gripScale = k;
var def = Car.Definition;
def.LateralCurve = Scaled( _baseLat, k );
def.LongitudinalCurve = Scaled( _baseLong, k );
ApplyWheels();
}
// Drive torque scale multiplies the authored peak torque. Drivetrain reads def.PeakTorque live
// (it holds the same definition instance), so no drivetrain touch is needed.
void SetTorqueScale( float k )
{
_torqueScale = k;
Car.Definition.PeakTorque = _stockPeakTorque * k;
}
void CycleAssists()
{
Car.Assists = Car.Assists switch
{
AssistLevel.Casual => AssistLevel.Sport,
AssistLevel.Sport => AssistLevel.Sim,
_ => AssistLevel.Casual,
};
StateHasChanged();
}
string AssistLabel => Car.IsValid() ? Car.Assists.ToString() : "";
// Tire preset swaps the UNSCALED base curves, then re-applies the current grip multiplier so the
// grip dial and the preset compose. Stock restores the car's own authored curves.
void CycleTires()
{
_tirePreset = (_tirePreset + 1) % 4;
(_baseLat, _baseLong) = _tirePreset switch
{
1 => (TireCurve.Street, TireCurve.Street),
2 => (TireCurve.Sport, TireCurve.Sport),
3 => (TireCurve.Offroad, TireCurve.Offroad),
_ => (_stockLat, _stockLong),
};
SetGrip( _gripScale );
StateHasChanged();
}
string TireLabel => _tirePreset switch
{
1 => "Street",
2 => "Sport",
3 => "Offroad",
_ => "Stock",
};
// Re-apply the car's pristine authored values (captured at bind). Definitions in the demo roster
// are fresh per car, so "stock" is unambiguous: the values this car spawned with.
void ResetToStock()
{
if ( !Car.IsValid() || !_snapped )
return;
var def = Car.Definition;
def.PeakTorque = _stockPeakTorque;
def.SpringRate = _stockSpring;
def.DamperRate = _stockDamper;
def.SuspensionTravel = _stockTravel;
def.BrakeTorque = _stockBrake;
def.LateralCurve = _stockLat;
def.LongitudinalCurve = _stockLong;
_gripScale = 1f;
_torqueScale = 1f;
_tirePreset = 0;
_baseLat = _stockLat;
_baseLong = _stockLong;
Car.Assists = _stockAssists;
ApplyWheels();
StateHasChanged();
}
protected override void OnEnabled()
{
// Start COLLAPSED: the chip legend keeps the keybind discoverable while mouse-look stays
// with the camera (an open lab on spawn captured the cursor before players ever drove,
// owner call 2026-07-19). Static flag, so reset it here each session.
IsOpen = false;
}
void ToggleOpen()
{
IsOpen = !IsOpen;
Mouse.Visibility = IsOpen ? MouseVisibility.Visible : MouseVisibility.Hidden;
StateHasChanged();
}
protected override void OnUpdate()
{
if ( Input.Pressed( ToggleAction ) )
{
IsOpen = !IsOpen;
Mouse.Visibility = IsOpen ? MouseVisibility.Visible : MouseVisibility.Hidden;
StateHasChanged();
}
// The chase camera re-hides the cursor every frame while it owns it; hold it visible while open.
if ( IsOpen )
Mouse.Visibility = MouseVisibility.Visible;
}
protected override int BuildHash()
{
// Closed still renders the chip legend, and the chip waits on the car binding, so the
// hash must move when the car arrives or the first build would stick on the empty tree.
if ( !IsOpen )
return Car.IsValid() ? 1 : 0;
var h = new HashCode();
h.Add( _gripScale );
h.Add( _torqueScale );
h.Add( _tirePreset );
if ( Car.IsValid() )
{
var def = Car.Definition;
h.Add( def.SpringRate );
h.Add( def.DamperRate );
h.Add( def.SuspensionTravel );
h.Add( def.BrakeTorque );
h.Add( (int)Car.Assists );
}
return h.ToHashCode();
}
}
@using Sandbox;
@using Sandbox.UI;
@inherits PanelComponent
@namespace Sandbox
<root>
<div class="title">@MyStringValue</div>
</root>
@code
{
[Property, TextArea] public string MyStringValue { get; set; } = "Hello World!";
protected override int BuildHash() => System.HashCode.Combine( MyStringValue );
}
@using Sandbox
@using Sandbox.UI
@using System
@using System.Collections.Generic
@namespace FieldGuide.Placement
@inherits PanelComponent
@attribute [StyleSheet]
@*
A live transform tuner for objects hanging off something else: an accessory on a character mount, a
part on a vehicle, a prop in a scene. One tab per ITweakTarget supplied by the scene's TweakSession;
the active tab edits that target GameObject's LocalPosition, LocalRotation (as pitch / yaw / roll) and
uniform LocalScale, so what you drag is the OFFSET, which is the number your game code wants.
Each row is a draggable slider (click-jump + drag-scrub, the engine SliderControl mechanic) with a
small +/- stepper beside it for precision a drag can't hit; the three-state step toggle divides or
multiplies that stepper by ten. Row ranges come from the target's TweakRanges, so an accessory gets
character-scale sliders and a scene prop gets scene-scale ones.
Copy puts the active target's paste-ready bake line on the clipboard (game-side Clipboard.SetText).
Export writes every target and every placed object into FileSystem.Data as JSON plus a C# snippet.
Reset restores the transform captured when the target was registered, never zero.
Rows render in MAIN markup via @foreach (not a RenderFragment) per the fragment-undermeasure gotcha,
and add shapes only (track/fill), keeping the text-run count low. Toggle with P (raw key, letter,
never an F key), the 42px x in the header, or the `placement_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/placement-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.
*@
<root>
@if ( PanelOpen )
{
<div class="tp-card">
<div class="tp-hdr">
<span class="tp-title">TRANSFORM TWEAK</span>
<div class="tp-hr">
<span class="tp-key">P</span>
<div class="tp-x" onclick=@ClosePanel>×</div>
</div>
</div>
@if ( TargetList.Count == 0 )
{
<div class="tp-empty">No targets. Add GameObjects to a TweakSession (Objects list) or call TweakSession.Add(...).</div>
}
else
{
@* ---- one tab per target ---- *@
<div class="tp-tabs">
@for ( int i = 0; i < TargetList.Count; i++ )
{
var idx = i;
string tab = TargetList[idx].DisplayName;
<div class="tp-tab @(idx == ActiveIndex ? "on" : "")" onclick=@(() => SelectTab( idx ))>@tab</div>
}
</div>
<div class="tp-sub">
<span class="tp-item">@ActiveName</span>
<span class="tp-note">@FrameNote</span>
</div>
@* ---- step-size toggle (segments live inside a pill group per the design system) ---- *@
<div class="tp-steprow">
<span class="tp-sl">Step</span>
<div class="tp-seg-group">
<div class="tp-seg @(_step == StepSize.XFine ? "on" : "")" onclick=@(() => _step = StepSize.XFine)>xfine ÷10</div>
<div class="tp-seg @(_step == StepSize.Fine ? "on" : "")" onclick=@(() => _step = StepSize.Fine)>fine</div>
<div class="tp-seg @(_step == StepSize.Coarse ? "on" : "")" onclick=@(() => _step = StepSize.Coarse)>coarse ×10</div>
</div>
</div>
@* ---- editable rows (draggable slider + stepper) ---- *@
<div class="tp-rows">
@foreach ( var r in Rows )
{
var row = r;
float cur = Get( ActiveTarget, row.field );
string val = cur.ToString( row.fmt );
string lab = row.label; // plain local before interpolating: an inline field read can render blank
int fillPct = (int)( Frac( cur, row ) * 100f );
<div class="tp-row @(row.header ? "hdr" : "")">
<div class="tp-rlab">
<span class="tp-rl">@lab</span>
<span class="tp-rv">@val</span>
</div>
<div class="tp-slider">
<span class="tp-stp" onclick=@(() => Nudge( row.field, -row.step * StepMult ))>−</span>
<div class="tp-track"
onmousedown=@(e => TrackPointer( e, row, true ))
onmousemove=@(e => TrackPointer( e, row, false ))>
<div class="tp-fill" style="width: @(fillPct)%;"></div>
</div>
<span class="tp-stp" onclick=@(() => Nudge( row.field, row.step * StepMult ))>+</span>
</div>
</div>
}
</div>
@* ---- actions ---- *@
<div class="tp-btns">
<div class="tp-btn" onclick=@ResetActive>Reset</div>
<div class="tp-btn primary" onclick=@CopyActive>@_copyLabel</div>
</div>
<div class="tp-btns">
<div class="tp-btn" onclick=@ExportNow>Export all to Data</div>
</div>
}
</div>
}
</root>
@code
{
// ---- toggle state (P raw key + `placement_panel` convar fallback) ----
static bool _open;
/// <summary>Console fallback: `placement_panel 1` / `placement_panel 0` toggles the panel (P also toggles).</summary>
[ConVar( "placement_panel", Help = "Open or close the transform tweak panel (same as the P key)" )]
public static bool PanelOpen { get => _open; set => _open = value; }
/// <summary>
/// Whether this panel starts open. Off by default: a tuning panel 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; }
int _activeIndex;
string _copyLabel = "Copy";
bool _wasOpen;
bool _booted;
/// <summary>Three-state stepper size (owner ruling, ported from the World Builder mount tuner): xfine for
/// final seating, fine for normal work, coarse for getting into the neighbourhood.</summary>
enum StepSize { XFine, Fine, Coarse }
StepSize _step = StepSize.Fine;
IReadOnlyList<ITweakTarget> TargetList => TweakSession.Instance?.Targets ?? System.Array.Empty<ITweakTarget>();
int ActiveIndex
{
get => TargetList.Count == 0 ? 0 : Math.Clamp( _activeIndex, 0, TargetList.Count - 1 );
set => _activeIndex = value;
}
// Named ActiveTarget (not Active): PanelComponent/Component already exposes an inherited
// bool Active, and a razor-generated member named Active would hide it (CS0108).
ITweakTarget ActiveTarget => TargetList.Count == 0 ? null : TargetList[ActiveIndex];
/// <summary>The active target's label, resolved to a single-identifier property so the markup never
/// interpolates a chained member read (which renders blank in several razor cases).</summary>
string ActiveName => ActiveTarget?.DisplayName ?? "";
/// <summary>The sub-line under the tabs: what the numbers below are measured against. This is the one
/// piece of context that makes a baked offset readable later, so the panel shows it while you drag.</summary>
string FrameNote
{
get
{
var frame = ActiveTarget?.FrameName;
return string.IsNullOrEmpty( frame ) ? "local offset · edited live" : $"{frame} · edited live";
}
}
void SelectTab( int idx )
{
ActiveIndex = idx;
_copyLabel = "Copy"; // a stale "Copied!" on a different target reads as a lie
}
void ClosePanel()
{
PanelOpen = false;
_copyLabel = "Copy";
}
// ---- field model ----
public enum Field { PosX, PosY, PosZ, Pitch, Yaw, Roll, Scale }
struct Row { public Field field; public string label; public string fmt; public float step; public float min; public float max; public bool header; }
/// <summary>The seven rows for the ACTIVE target, bounded by that target's TweakRanges. Built per read
/// rather than held in a static array, because an accessory and a scene prop want very different
/// ranges out of the same three sliders.</summary>
List<Row> Rows
{
get
{
var g = ActiveTarget?.Ranges ?? TweakRanges.Accessory;
float p = MathF.Max( g.PositionRange, 0.001f );
return new List<Row>
{
new Row { field = Field.PosX, label = "Pos X", fmt = "0.###", step = g.PositionStep, min = -p, max = p },
new Row { field = Field.PosY, label = "Pos Y", fmt = "0.###", step = g.PositionStep, min = -p, max = p },
new Row { field = Field.PosZ, label = "Pos Z", fmt = "0.###", step = g.PositionStep, min = -p, max = p },
new Row { field = Field.Pitch, label = "Pitch °", fmt = "0.#", step = g.RotationStep, min = -180f, max = 180f, header = true },
new Row { field = Field.Yaw, label = "Yaw °", fmt = "0.#", step = g.RotationStep, min = -180f, max = 180f },
new Row { field = Field.Roll, label = "Roll °", fmt = "0.#", step = g.RotationStep, min = -180f, max = 180f },
new Row { field = Field.Scale, label = "Scale", fmt = "0.###", step = g.ScaleStep, min = g.ScaleMin, max = g.ScaleMax, header = true },
};
}
}
float StepMult => _step switch { StepSize.Coarse => 10f, StepSize.XFine => 0.1f, _ => 1f };
static float Frac( float value, Row row )
=> Math.Clamp( (value - row.min) / MathF.Max( row.max - row.min, 0.0001f ), 0f, 1f );
// ---- read/write the active target's LOCAL transform ----
static float Get( ITweakTarget t, Field f )
{
var go = t?.Target;
if ( go is null || !go.IsValid() ) return 0f;
var p = go.LocalPosition;
var a = go.LocalRotation.Angles();
return f switch
{
Field.PosX => p.x,
Field.PosY => p.y,
Field.PosZ => p.z,
Field.Pitch => a.pitch,
Field.Yaw => a.yaw,
Field.Roll => a.roll,
Field.Scale => go.LocalScale.x,
_ => 0f,
};
}
void Nudge( Field f, float delta )
{
var go = ActiveTarget?.Target;
if ( go is null || !go.IsValid() ) return;
var p = go.LocalPosition;
var a = go.LocalRotation.Angles();
switch ( f )
{
case Field.PosX: go.LocalPosition = p.WithX( p.x + delta ); break;
case Field.PosY: go.LocalPosition = p.WithY( p.y + delta ); break;
case Field.PosZ: go.LocalPosition = p.WithZ( p.z + delta ); break;
case Field.Pitch: go.LocalRotation = new Angles( a.pitch + delta, a.yaw, a.roll ).ToRotation(); break;
case Field.Yaw: go.LocalRotation = new Angles( a.pitch, a.yaw + delta, a.roll ).ToRotation(); break;
case Field.Roll: go.LocalRotation = new Angles( a.pitch, a.yaw, a.roll + delta ).ToRotation(); break;
case Field.Scale:
float s = MathF.Max( 0.01f, go.LocalScale.x + delta );
go.LocalScale = new Vector3( s, s, s );
break;
}
}
/// <summary>Draggable track (World Builder left-UI idiom): onmousedown JUMPS to the click, onmousemove
/// SCRUBS while Active. Snaps to the row's fine step, applies as a DELTA through Nudge so the same
/// write path runs whether you drag or step.</summary>
void TrackPointer( PanelEvent ev, Row 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 );
float target = row.min + frac * (row.max - row.min);
if ( row.step > 0f ) target = MathF.Round( target / row.step ) * row.step;
target = Math.Clamp( target, row.min, row.max );
Nudge( row.field, target - Get( ActiveTarget, row.field ) );
}
/// <summary>Restore the transform the target was REGISTERED with, not zero. Zeroing a hand-mounted
/// accessory collapses it into the wrist, which is never the thing you wanted back.</summary>
void ResetActive()
{
var go = ActiveTarget?.Target;
if ( go is null || !go.IsValid() ) return;
if ( TweakSession.Instance?.ResetToSeed( go ) == true ) return;
// Never registered here (a hand-built target list, say): identity is the only baseline we have.
go.LocalPosition = Vector3.Zero;
go.LocalRotation = Rotation.Identity;
go.LocalScale = Vector3.One;
}
/// <summary>Copy the active target's paste-ready bake line to the system clipboard. Game-side
/// Sandbox.UI.Clipboard.SetText, so this works in play without an editor round trip.</summary>
void CopyActive()
{
var t = ActiveTarget;
if ( t is null ) return;
var line = PlacementExport.BakeLine( t );
if ( string.IsNullOrEmpty( line ) ) return;
Sandbox.UI.Clipboard.SetText( line );
_copyLabel = "Copied!";
}
void ExportNow()
{
if ( Scene is not null )
PlacementExport.WriteAll( Scene );
}
// ---- boot state, P toggle, cursor while open ----
protected override void OnUpdate()
{
// BOOT. `placement_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( "[placement] tweak panel was OPEN at session start (persisted convar), forcing closed" );
PanelOpen = OpenOnStart;
}
if ( Input.Keyboard.Pressed( "P" ) )
PanelOpen = !PanelOpen;
if ( PanelOpen )
{
Mouse.Visibility = MouseVisibility.Visible; // keep the cursor usable over the panel
_wasOpen = true;
}
else if ( _wasOpen )
{
_wasOpen = false;
_copyLabel = "Copy"; // closing clears the flash, so a reopen never claims a copy that was not made
}
}
// Fold the toggle, active tab, step mode, the copy label, and every displayed value (rounded) so
// readouts update the instant a value is nudged. Miss one and the number freezes on screen.
protected override int BuildHash()
{
int h = HashCode.Combine( PanelOpen, ActiveIndex, (int)_step, _copyLabel, TargetList.Count );
var a = ActiveTarget;
if ( a is not null )
{
h = HashCode.Combine( h, a.FrameName );
foreach ( var r in Rows )
h = HashCode.Combine( h, (int)MathF.Round( Get( a, r.field ) * 1000f ) );
}
return h;
}
}
@using Sandbox;
@using Sandbox.UI;
@inherits PanelComponent
@namespace Sandbox
<root>
<div class="title">@MyStringValue</div>
</root>
@code
{
[Property, TextArea] public string MyStringValue { get; set; } = "Hello World!";
protected override int BuildHash() => System.HashCode.Combine( MyStringValue );
}
@using Sandbox
@using Sandbox.UI
@using System
@using System.Collections.Generic
@namespace FieldGuide.Placement
@inherits PanelComponent
@attribute [StyleSheet]
@*
A live transform tuner for objects hanging off something else: an accessory on a character mount, a
part on a vehicle, a prop in a scene. One tab per ITweakTarget supplied by the scene's TweakSession;
the active tab edits that target GameObject's LocalPosition, LocalRotation (as pitch / yaw / roll) and
uniform LocalScale, so what you drag is the OFFSET, which is the number your game code wants.
Each row is a draggable slider (click-jump + drag-scrub, the engine SliderControl mechanic) with a
small +/- stepper beside it for precision a drag can't hit; the three-state step toggle divides or
multiplies that stepper by ten. Row ranges come from the target's TweakRanges, so an accessory gets
character-scale sliders and a scene prop gets scene-scale ones.
Copy puts the active target's paste-ready bake line on the clipboard (game-side Clipboard.SetText).
Export writes every target and every placed object into FileSystem.Data as JSON plus a C# snippet.
Reset restores the transform captured when the target was registered, never zero.
Rows render in MAIN markup via @foreach (not a RenderFragment) per the fragment-undermeasure gotcha,
and add shapes only (track/fill), keeping the text-run count low. Toggle with P (raw key, letter,
never an F key), the 42px x in the header, or the `placement_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/placement-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.
*@
<root>
@if ( PanelOpen )
{
<div class="tp-card">
<div class="tp-hdr">
<span class="tp-title">TRANSFORM TWEAK</span>
<div class="tp-hr">
<span class="tp-key">P</span>
<div class="tp-x" onclick=@ClosePanel>×</div>
</div>
</div>
@if ( TargetList.Count == 0 )
{
<div class="tp-empty">No targets. Add GameObjects to a TweakSession (Objects list) or call TweakSession.Add(...).</div>
}
else
{
@* ---- one tab per target ---- *@
<div class="tp-tabs">
@for ( int i = 0; i < TargetList.Count; i++ )
{
var idx = i;
string tab = TargetList[idx].DisplayName;
<div class="tp-tab @(idx == ActiveIndex ? "on" : "")" onclick=@(() => SelectTab( idx ))>@tab</div>
}
</div>
<div class="tp-sub">
<span class="tp-item">@ActiveName</span>
<span class="tp-note">@FrameNote</span>
</div>
@* ---- step-size toggle (segments live inside a pill group per the design system) ---- *@
<div class="tp-steprow">
<span class="tp-sl">Step</span>
<div class="tp-seg-group">
<div class="tp-seg @(_step == StepSize.XFine ? "on" : "")" onclick=@(() => _step = StepSize.XFine)>xfine ÷10</div>
<div class="tp-seg @(_step == StepSize.Fine ? "on" : "")" onclick=@(() => _step = StepSize.Fine)>fine</div>
<div class="tp-seg @(_step == StepSize.Coarse ? "on" : "")" onclick=@(() => _step = StepSize.Coarse)>coarse ×10</div>
</div>
</div>
@* ---- editable rows (draggable slider + stepper) ---- *@
<div class="tp-rows">
@foreach ( var r in Rows )
{
var row = r;
float cur = Get( ActiveTarget, row.field );
string val = cur.ToString( row.fmt );
string lab = row.label; // plain local before interpolating: an inline field read can render blank
int fillPct = (int)( Frac( cur, row ) * 100f );
<div class="tp-row @(row.header ? "hdr" : "")">
<div class="tp-rlab">
<span class="tp-rl">@lab</span>
<span class="tp-rv">@val</span>
</div>
<div class="tp-slider">
<span class="tp-stp" onclick=@(() => Nudge( row.field, -row.step * StepMult ))>−</span>
<div class="tp-track"
onmousedown=@(e => TrackPointer( e, row, true ))
onmousemove=@(e => TrackPointer( e, row, false ))>
<div class="tp-fill" style="width: @(fillPct)%;"></div>
</div>
<span class="tp-stp" onclick=@(() => Nudge( row.field, row.step * StepMult ))>+</span>
</div>
</div>
}
</div>
@* ---- actions ---- *@
<div class="tp-btns">
<div class="tp-btn" onclick=@ResetActive>Reset</div>
<div class="tp-btn primary" onclick=@CopyActive>@_copyLabel</div>
</div>
<div class="tp-btns">
<div class="tp-btn" onclick=@ExportNow>Export all to Data</div>
</div>
}
</div>
}
</root>
@code
{
// ---- toggle state (P raw key + `placement_panel` convar fallback) ----
static bool _open;
/// <summary>Console fallback: `placement_panel 1` / `placement_panel 0` toggles the panel (P also toggles).</summary>
[ConVar( "placement_panel", Help = "Open or close the transform tweak panel (same as the P key)" )]
public static bool PanelOpen { get => _open; set => _open = value; }
/// <summary>
/// Whether this panel starts open. Off by default: a tuning panel 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; }
int _activeIndex;
string _copyLabel = "Copy";
bool _wasOpen;
bool _booted;
/// <summary>Three-state stepper size (owner ruling, ported from the World Builder mount tuner): xfine for
/// final seating, fine for normal work, coarse for getting into the neighbourhood.</summary>
enum StepSize { XFine, Fine, Coarse }
StepSize _step = StepSize.Fine;
IReadOnlyList<ITweakTarget> TargetList => TweakSession.Instance?.Targets ?? System.Array.Empty<ITweakTarget>();
int ActiveIndex
{
get => TargetList.Count == 0 ? 0 : Math.Clamp( _activeIndex, 0, TargetList.Count - 1 );
set => _activeIndex = value;
}
// Named ActiveTarget (not Active): PanelComponent/Component already exposes an inherited
// bool Active, and a razor-generated member named Active would hide it (CS0108).
ITweakTarget ActiveTarget => TargetList.Count == 0 ? null : TargetList[ActiveIndex];
/// <summary>The active target's label, resolved to a single-identifier property so the markup never
/// interpolates a chained member read (which renders blank in several razor cases).</summary>
string ActiveName => ActiveTarget?.DisplayName ?? "";
/// <summary>The sub-line under the tabs: what the numbers below are measured against. This is the one
/// piece of context that makes a baked offset readable later, so the panel shows it while you drag.</summary>
string FrameNote
{
get
{
var frame = ActiveTarget?.FrameName;
return string.IsNullOrEmpty( frame ) ? "local offset · edited live" : $"{frame} · edited live";
}
}
void SelectTab( int idx )
{
ActiveIndex = idx;
_copyLabel = "Copy"; // a stale "Copied!" on a different target reads as a lie
}
void ClosePanel()
{
PanelOpen = false;
_copyLabel = "Copy";
}
// ---- field model ----
public enum Field { PosX, PosY, PosZ, Pitch, Yaw, Roll, Scale }
struct Row { public Field field; public string label; public string fmt; public float step; public float min; public float max; public bool header; }
/// <summary>The seven rows for the ACTIVE target, bounded by that target's TweakRanges. Built per read
/// rather than held in a static array, because an accessory and a scene prop want very different
/// ranges out of the same three sliders.</summary>
List<Row> Rows
{
get
{
var g = ActiveTarget?.Ranges ?? TweakRanges.Accessory;
float p = MathF.Max( g.PositionRange, 0.001f );
return new List<Row>
{
new Row { field = Field.PosX, label = "Pos X", fmt = "0.###", step = g.PositionStep, min = -p, max = p },
new Row { field = Field.PosY, label = "Pos Y", fmt = "0.###", step = g.PositionStep, min = -p, max = p },
new Row { field = Field.PosZ, label = "Pos Z", fmt = "0.###", step = g.PositionStep, min = -p, max = p },
new Row { field = Field.Pitch, label = "Pitch °", fmt = "0.#", step = g.RotationStep, min = -180f, max = 180f, header = true },
new Row { field = Field.Yaw, label = "Yaw °", fmt = "0.#", step = g.RotationStep, min = -180f, max = 180f },
new Row { field = Field.Roll, label = "Roll °", fmt = "0.#", step = g.RotationStep, min = -180f, max = 180f },
new Row { field = Field.Scale, label = "Scale", fmt = "0.###", step = g.ScaleStep, min = g.ScaleMin, max = g.ScaleMax, header = true },
};
}
}
float StepMult => _step switch { StepSize.Coarse => 10f, StepSize.XFine => 0.1f, _ => 1f };
static float Frac( float value, Row row )
=> Math.Clamp( (value - row.min) / MathF.Max( row.max - row.min, 0.0001f ), 0f, 1f );
// ---- read/write the active target's LOCAL transform ----
static float Get( ITweakTarget t, Field f )
{
var go = t?.Target;
if ( go is null || !go.IsValid() ) return 0f;
var p = go.LocalPosition;
var a = go.LocalRotation.Angles();
return f switch
{
Field.PosX => p.x,
Field.PosY => p.y,
Field.PosZ => p.z,
Field.Pitch => a.pitch,
Field.Yaw => a.yaw,
Field.Roll => a.roll,
Field.Scale => go.LocalScale.x,
_ => 0f,
};
}
void Nudge( Field f, float delta )
{
var go = ActiveTarget?.Target;
if ( go is null || !go.IsValid() ) return;
var p = go.LocalPosition;
var a = go.LocalRotation.Angles();
switch ( f )
{
case Field.PosX: go.LocalPosition = p.WithX( p.x + delta ); break;
case Field.PosY: go.LocalPosition = p.WithY( p.y + delta ); break;
case Field.PosZ: go.LocalPosition = p.WithZ( p.z + delta ); break;
case Field.Pitch: go.LocalRotation = new Angles( a.pitch + delta, a.yaw, a.roll ).ToRotation(); break;
case Field.Yaw: go.LocalRotation = new Angles( a.pitch, a.yaw + delta, a.roll ).ToRotation(); break;
case Field.Roll: go.LocalRotation = new Angles( a.pitch, a.yaw, a.roll + delta ).ToRotation(); break;
case Field.Scale:
float s = MathF.Max( 0.01f, go.LocalScale.x + delta );
go.LocalScale = new Vector3( s, s, s );
break;
}
}
/// <summary>Draggable track (World Builder left-UI idiom): onmousedown JUMPS to the click, onmousemove
/// SCRUBS while Active. Snaps to the row's fine step, applies as a DELTA through Nudge so the same
/// write path runs whether you drag or step.</summary>
void TrackPointer( PanelEvent ev, Row 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 );
float target = row.min + frac * (row.max - row.min);
if ( row.step > 0f ) target = MathF.Round( target / row.step ) * row.step;
target = Math.Clamp( target, row.min, row.max );
Nudge( row.field, target - Get( ActiveTarget, row.field ) );
}
/// <summary>Restore the transform the target was REGISTERED with, not zero. Zeroing a hand-mounted
/// accessory collapses it into the wrist, which is never the thing you wanted back.</summary>
void ResetActive()
{
var go = ActiveTarget?.Target;
if ( go is null || !go.IsValid() ) return;
if ( TweakSession.Instance?.ResetToSeed( go ) == true ) return;
// Never registered here (a hand-built target list, say): identity is the only baseline we have.
go.LocalPosition = Vector3.Zero;
go.LocalRotation = Rotation.Identity;
go.LocalScale = Vector3.One;
}
/// <summary>Copy the active target's paste-ready bake line to the system clipboard. Game-side
/// Sandbox.UI.Clipboard.SetText, so this works in play without an editor round trip.</summary>
void CopyActive()
{
var t = ActiveTarget;
if ( t is null ) return;
var line = PlacementExport.BakeLine( t );
if ( string.IsNullOrEmpty( line ) ) return;
Sandbox.UI.Clipboard.SetText( line );
_copyLabel = "Copied!";
}
void ExportNow()
{
if ( Scene is not null )
PlacementExport.WriteAll( Scene );
}
// ---- boot state, P toggle, cursor while open ----
protected override void OnUpdate()
{
// BOOT. `placement_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( "[placement] tweak panel was OPEN at session start (persisted convar), forcing closed" );
PanelOpen = OpenOnStart;
}
if ( Input.Keyboard.Pressed( "P" ) )
PanelOpen = !PanelOpen;
if ( PanelOpen )
{
Mouse.Visibility = MouseVisibility.Visible; // keep the cursor usable over the panel
_wasOpen = true;
}
else if ( _wasOpen )
{
_wasOpen = false;
_copyLabel = "Copy"; // closing clears the flash, so a reopen never claims a copy that was not made
}
}
// Fold the toggle, active tab, step mode, the copy label, and every displayed value (rounded) so
// readouts update the instant a value is nudged. Miss one and the number freezes on screen.
protected override int BuildHash()
{
int h = HashCode.Combine( PanelOpen, ActiveIndex, (int)_step, _copyLabel, TargetList.Count );
var a = ActiveTarget;
if ( a is not null )
{
h = HashCode.Combine( h, a.FrameName );
foreach ( var r in Rows )
h = HashCode.Combine( h, (int)MathF.Round( Get( a, r.field ) * 1000f ) );
}
return h;
}
}
@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 ) );
}
}
@using System
@using System.Collections.Generic
@using System.Linq
@using Sandbox
@using Sandbox.UI
@namespace Skafinity
@inherits PanelComponent
@* DontExecuteOnServer: this panel is pure human-client UI (like SkafinityPlayer). A dedicated
server never renders it, so skip its lifecycle there. On a listen server the host is still a
player, so the host-client keeps the panel — this only sheds the headless case. *@
@implements Component.DontExecuteOnServer
@*
The optional drop-in settings board for the Skafinity music engine.
Add this PanelComponent to a GameObject under a ScreenPanel (or WorldPanel). It finds a
SkafinityPlayer in the scene (or set Player explicitly) and offers the whole transport as UI —
you don't have to wire anything. The board's visibility is host-driven: set IsOpen (or call
Toggle()) from your game — e.g. bind it to a hotkey or your own pause/menu UI. This component
intentionally ships no launcher of its own, so it imposes nothing on the host's HUD — which also
means a freshly-dropped panel shows nothing until you bind IsOpen. Run `skafinity_panel` in the
console to see it before you have (SkafinityCommands.cs).
The engine needs nothing from this: SkafinityPlayer plays on its own. This board is pure
convenience for players who want to drive the station rather than tune it in the inspector.
It is drawn against the WEB WIDGET as its design (web/skafinity-element.js) so the two are one
product with one set of habits, and the wording and the derived decisions both sides need live in
SkafinityBoard rather than in either drawing of it. What is deliberately NOT shared is layout:
Razor and the DOM lay out differently enough that a common description of a row would be a lowest
common denominator of both.
Re-theming: the board derives its whole palette from one colour. Set SkafinityTheme.Accent from
your game (e.g. to your own UI accent) and the board follows; leave it unset and it is neutral
gray-on-black. SkafinityMusicPanel.razor.scss holds only the layout/type tokens.
*@
<root class="@( IsOpen ? "open" : "" )">
@if ( IsOpen )
{
@{
var cfg = Player?.EffectiveConfig();
int genre = cfg?.Genre ?? 0;
var here = Player?.Playhead() ?? default;
bool known = here.Duration > 0;
}
<div class="board" style="background-color:@SkafinityTheme.Bg;">
<div class="header">
<div class="title">MUSIC</div>
<div class="close" onclick="@Toggle">✕</div>
</div>
@* ── Transport ─────────────────────────────────────────────────────────────────────
Two rows: the buttons, and the bar that says where in the song they are acting. *@
<div class="transport">
<div class="row">
<div class="btn big" style="@BtnStyle" tooltip="@SkafinityBoard.Copy.PrevTitle"
onclick="@( () => Step( -1 ) )">@SkafinityBoard.Copy.Prev</div>
<div class="btn big primary" style="@BtnOnStyle" tooltip="@SkafinityBoard.Copy.PlayTitle"
onclick="@TogglePlay">@( Playing ? SkafinityBoard.Copy.Pause : SkafinityBoard.Copy.Play )</div>
<div class="btn big" style="@BtnStyle" tooltip="@SkafinityBoard.Copy.NextTitle"
onclick="@( () => Step( 1 ) )">@SkafinityBoard.Copy.Next</div>
<div class="now" style="@LabelStyle">@SkafinityBoard.Copy.NowPlaying<b style="@TextStyle">@( Player?.N ?? 0 )</b></div>
@* Playback stalled on the song you skipped to — as opposed to the silent background
look-ahead, which nobody needs telling about. *@
@if ( Player?.IsBuffering == true )
{
<div class="bufstate" style="@AccentTextStyle">@SkafinityBoard.Copy.Generating( Player?.N ?? 0 )</div>
}
<div class="vol right" style="@LabelStyle">
@SkafinityBoard.Copy.Volume
<SkafinitySlider Min="@(0f)" Max="@(1.5f)" Step="@(0.01f)" FixedWidth="@(120f)"
Value="@( Player?.Volume ?? 1f )"
OnValueChanged="@( (float v) => SetVolume( v ) )"></SkafinitySlider>
</div>
</div>
@* The seek bar. The whole song is already in memory, so a scrub is a stream restart on
PCM we are holding rather than a fetch — which is why this is a plain slider and not a
loading affordance. It goes inert, rather than drawing against a guess, until the song
has been rendered and has a length worth stating. *@
<div class="seek">
<div class="time" style="@LabelStyle">@SkafinityBoard.Time( ShownTime( here ), known )</div>
<SkafinitySlider tooltip="@SkafinityBoard.Copy.SeekTitle" Disabled="@( !known )"
Min="@(0f)" Max="@(1000f)" Step="@(1f)"
Value="@( known ? ShownRatio( here ) * 1000f : 0f )"
OnValueChanged="@( (float v) => Scrub( v / 1000f, here.Duration ) )"></SkafinitySlider>
<div class="time total" style="@LabelStyle">@SkafinityBoard.Time( here.Duration, known )</div>
</div>
</div>
@* ── The seed ──────────────────────────────────────────────────────────────────────
TWO copy buttons, because "share this" means one of two things and neither can be
recovered from the other: the song, with everything it left to chance written down, or
the station as it stands, which keeps rolling for whoever is handed it. *@
<div class="row seed-bar">
<TextEntry @ref="_seedEntry" placeholder="@SkafinityBoard.Copy.SeedPlaceholder" class="grow seed-input" />
<div class="btn primary" style="@BtnOnStyle" onclick="@PlayTyped">@SkafinityBoard.Copy.SeedGo</div>
<div class="btn" style="@BtnStyle" tooltip="@SkafinityBoard.Copy.CopySongTitle"
onclick="@CopySong">@_copySongLabel</div>
<div class="btn" style="@BtnStyle" tooltip="@SkafinityBoard.Copy.CopyStationTitle"
onclick="@CopyStation">@_copyStationLabel</div>
</div>
@* ── What plays ────────────────────────────────────────────────────────────────────
These are not knob controls — genre, reroll and shuffle change the seed, and tinker only
opens the box — so they live on the board itself. Putting them in the mixer made them look
like part of it AND hid them from everyone who never opened it. *@
<div class="row what-plays">
<div class="label" style="@LabelStyle">@SkafinityBoard.Copy.Genre</div>
@* The dropdown IS the seed's genre part: "Random" takes it out of the string so every
song rolls its own again, and without that entry there is no way back out of a genre
once one has been chosen. It reads as selected when nothing is pinned. *@
<DropDown class="genre" style="@BtnStyle" Value="@GenreValue" Options="@GenreOptions"
ValueChanged="@( (string v) => PickGenre( v ) )" />
<div class="btn" style="@BtnStyle" tooltip="@SkafinityBoard.Copy.RerollTitle"
onclick="@RerollStation">@SkafinityBoard.Copy.Reroll</div>
<div class="btn toggle @( Shuffling ? "on" : "" )" style="@( Shuffling ? BtnOnStyle : BtnStyle )"
tooltip="@SkafinityBoard.Copy.ShuffleTitle"
onclick="@ToggleShuffle">@( Shuffling ? SkafinityBoard.Copy.ShuffleOn : SkafinityBoard.Copy.ShuffleOff )</div>
<div class="btn toggle right @( _tinkering ? "on" : "" )" style="@( _tinkering ? BtnOnStyle : BtnStyle )"
onclick="@ToggleTinker">@( _tinkering ? SkafinityBoard.Copy.TinkerOpen : SkafinityBoard.Copy.Tinker )</div>
</div>
@* ── The knobs ─────────────────────────────────────────────────────────────────────
Behind the tinker button. They are the deep end of the toy, and a wall of sliders is
otherwise the first thing anybody meets. *@
@if ( _tinkering )
{
<div class="panel vibe" style="@PanelStyle">
<div class="h2" style="@LabelStyle">@SkafinityBoard.Copy.VibeHeading</div>
<div class="matrix">
<div class="mrow mhead">
<div class="mvoice"></div>
@foreach ( var h in SkafinityBoard.ColumnHeaders )
{
<div class="mcell mhlabel" style="@LabelStyle">@h</div>
}
</div>
@foreach ( var row in SkafinityBoard.Matrix( genre ) )
{
<div class="mrow">
<div class="mvoice">@row.Voice</div>
@for ( int col = 0; col < SkafinityBoard.ColumnHeaders.Length; col++ )
{
var f = row.Cells[col];
<div class="mcell">
@if ( f != null )
{
@Knob( f, cfg, SkafinityBoard.KnobLabel( f, col ) )
}
</div>
}
</div>
}
</div>
@* Only when there IS a global knob. They have all been retired to reserved wire slots
(tempo to GenreProfile, width and reverb to house config), and a heading over an
empty grid reads as a panel that failed to draw something. *@
@if ( SkafinityBoard.Globals( genre ).Count > 0 )
{
<div class="glabel" style="@LabelStyle">@SkafinityBoard.Copy.GlobalHeading</div>
<div class="global-grid">
@foreach ( var f in SkafinityBoard.Globals( genre ) )
{
<div class="knob-cell">@Knob( f, cfg, f.Name )</div>
}
</div>
}
@* The only two buttons that act on the sliders, and they are not two dice. 🎲 always
moves every knob, because it draws a fresh vibe and PINS it. ↺ is the way back out —
dragging a knob pins the whole vibe, so without it one accidental drag turns an
endless station into one song forever — and it is off when there is nothing pinned
rather than looking like a die that did nothing. *@
<div class="row vibe-actions">
<div class="btn" style="@BtnStyle" tooltip="@SkafinityBoard.Copy.VibeRollTitle"
onclick="@RerollVibe">@SkafinityBoard.Copy.VibeRoll</div>
<div class="btn @( VibePinned ? "" : "off" )" style="@BtnStyle"
tooltip="@SkafinityBoard.Copy.VibeRandomTitle"
onclick="@RollVibe">@SkafinityBoard.Copy.VibeRandom</div>
</div>
</div>
}
@* ── The playlist ──────────────────────────────────────────────────────────────────
Past · now · up next. A row addresses its song by POSITION, which is its slot on the
timeline; the number it SHOWS is the song's index in its own station, and under shuffle
those are different things. *@
<div class="panel playlist-panel" style="@PanelStyle">
<div class="h2" style="@LabelStyle">@SkafinityBoard.Copy.PlaylistHeading</div>
<div class="playlist">
@foreach ( var e in Queue() )
{
var ee = e;
<div class="plrow @( e.Current ? "now" : "" ) @( e.Cached ? "cached" : "" ) @( e.Progress >= 0 ? "gen" : "" )"
style="@RowStyle( e )">
<div class="pllabel" onclick="@( () => Player?.SeekTo( ee.Position ) )">
<div class="plcaret">@SkafinityBoard.RowCaret( e )</div>
<div class="plhash">@SkafinityBoard.Copy.Hash</div>
<div class="plnum">@e.N</div>
</div>
<div class="plgenre" style="@LabelStyle">@SkafinityBoard.GenreName( e.Genre )</div>
<div class="plstatus" style="@LabelStyle">
@if ( e.Progress >= 0 )
{
<div class="bar"><div class="bar-fill" style="@BarFillStyle( e.Progress )"></div></div>
}
else
{
@SkafinityBoard.RowStatus( e )
}
</div>
<div class="pldl" style="@LabelStyle" tooltip="@SkafinityBoard.Copy.ExportTitle( e.N )"
onclick="@( () => Save( ee.Position ) )">⬇</div>
</div>
}
</div>
<div class="row jump" style="@LabelStyle">
@SkafinityBoard.Copy.JumpTo
<TextEntry @ref="_jumpEntry" Numeric="@true" class="jump-input" />
<div class="btn" style="@BtnStyle" onclick="@JumpTo">@SkafinityBoard.Copy.JumpGo</div>
<div class="btn right" style="@BtnStyle"
onclick="@( () => Save( Player?.Position ?? 0 ) )">@( _saving ? SkafinityBoard.Copy.ExportBusy : SkafinityBoard.Copy.Export )</div>
</div>
</div>
@if ( _msg != null )
{
<div class="msg" style="@AccentTextStyle">@_msg</div>
}
</div>
}
</root>
@code
{
/// <summary>The player this panel drives. Leave unset to auto-find a <see cref="SkafinityPlayer"/>
/// in the scene on start.</summary>
[Property] public SkafinityPlayer Player { get; set; }
/// <summary>Whether the settings board is showing. Host-driven — set it from your game (or
/// call <see cref="Toggle"/>) to wire the board to a hotkey / pause menu / your own button.
/// This component ships no launcher of its own.</summary>
/// <remarks>Deliberately NOT a <c>[Property]</c>: this is transient UI state. On a networked
/// (<c>NetworkMode: Snapshot</c>) GameObject a serialized <c>[Property]</c> rides the late-join
/// snapshot, so a client joining while the host has the board open would restore
/// <c>IsOpen = true</c> — leaking the host's UI state and rendering the board open (and unstyled,
/// since the panel is rebuilt mid-deserialize). Leaving it un-serialized keeps it host-driven
/// from code while starting <c>false</c> on every client.</remarks>
public bool IsOpen { get; set; }
TextEntry _seedEntry;
TextEntry _jumpEntry;
bool _seedInit;
// The station seed this panel last wrote into the box — what tells a stale box from a typed one.
string _seedShown;
// Both copy buttons keep their own label so pressing one doesn't report "copied!" on the other.
string _copySongLabel = SkafinityBoard.Copy.CopySong;
string _copyStationLabel = SkafinityBoard.Copy.CopyStation;
string _msg;
// The knob matrix is closed until asked for. Not a [Property] for the same reason IsOpen is not.
bool _tinkering;
bool _saving;
// A DRAG, held. A slider reports every mouse-move and there is no "let go" event to wait for, so
// seeking on each report would restart the stream at every pixel. The thumb is therefore followed
// here and the transport is told once the drag has settled — which is the same one-seek-per-gesture
// the web gets from listening to `change` rather than `input`.
const float ScrubSettle = 0.2f;
float _scrubTo = -1f;
TimeSince _scrubSince;
protected override void OnStart()
{
Player ??= Scene.GetAllComponents<SkafinityPlayer>().FirstOrDefault();
if ( Player == null )
Log.Warning( "SkafinityMusicPanel: no SkafinityPlayer found in the scene — add one (or set Player)." );
}
protected override void OnUpdate()
{
// The text entry follows the station as it stands — not once at open, but whenever the station
// moves out from under it. Reroll, a pasted seed and a genre pin all change what the station
// IS, and a box still showing the previous one reads as a reroll that did nothing.
//
// Only ever overwrites text this panel wrote: the moment somebody types, the box is theirs and
// a background change to the station leaves it alone rather than eating what they were typing.
if ( !IsOpen ) { _seedInit = false; return; }
if ( _seedEntry != null )
{
var station = Player?.StationSeed ?? "";
if ( !_seedInit || ( station != _seedShown && _seedEntry.Text == _seedShown ) )
{
_seedEntry.Text = station;
_seedShown = station;
_seedInit = true;
}
}
// The held scrub, applied once the drag settles. See _scrubTo.
if ( _scrubTo >= 0f && _scrubSince > ScrubSettle )
{
var d = Player?.Playhead().Duration ?? 0;
if ( d > 0 ) Player?.SeekWithin( _scrubTo * d );
_scrubTo = -1f;
}
}
// ── Theme bindings ──
// The palette is runtime (SkafinityTheme), so every fill and themed text colour is bound here as
// an inline style rather than named in the .scss. The stylesheet owns every border in return —
// see the header comment there for why the two must not overlap.
//
// Neither a rule nor an inline style reaches inside a control this library did not write, which
// is why the board's slider is one it does (SkafinitySlider) — and why the accent reaches the
// sliders at all.
static string LabelStyle => $"color:{SkafinityTheme.TextDim};";
static string TextStyle => $"color:{SkafinityTheme.Text};";
static string AccentTextStyle => $"color:{SkafinityTheme.AccentCss};";
static string BtnStyle => $"background-color:{SkafinityTheme.Cell}; color:{SkafinityTheme.Text};";
static string BtnOnStyle => $"background-color:{SkafinityTheme.AccentBg}; color:{SkafinityTheme.Text};";
static string PanelStyle => $"background-color:{SkafinityTheme.Cell};";
static string BarFillStyle( float p ) => $"width:{SkafinityBoard.Percent( p )}; background-color:{SkafinityTheme.AccentCss};";
// A playlist row reads as one of three states, brightest first: the song playing now, a song
// already rendered and waiting, anything else.
static string RowStyle( SkafinityPlayer.QueueEntry e ) =>
e.Current ? $"background-color:{SkafinityTheme.AccentBg};"
: e.Cached ? $"background-color:{SkafinityTheme.CellFillSoft};"
: "";
bool Playing => Player != null && !Player.IsPaused;
bool Shuffling => Player?.Shuffle ?? false;
bool VibePinned => Player?.VibePinned ?? false;
bool GenreRolling => Player == null || !Player.GenrePinned;
/// <summary>Open/close the settings board. Convenience for hosts that want to bind a single
/// action; you can also set <see cref="IsOpen"/> directly.</summary>
public void Toggle()
{
IsOpen = !IsOpen;
if ( !IsOpen ) _seedInit = false;
}
// ── The playhead ──
// Mid-drag the thumb the user is holding wins over the clock; every other moment reads the
// transport. Both halves have to agree or the label counts up while the thumb sits still.
float ShownRatio( SkafinityPlayer.SongPosition p ) => _scrubTo >= 0f ? _scrubTo : p.Ratio;
double ShownTime( SkafinityPlayer.SongPosition p ) => _scrubTo >= 0f ? _scrubTo * p.Duration : p.Time;
void Scrub( float ratio, double duration )
{
if ( duration <= 0 ) return;
_scrubTo = Math.Clamp( ratio, 0f, 1f );
_scrubSince = 0;
}
void TogglePlay() { Player?.TogglePlay(); _msg = null; }
void Step( int d ) { Player?.StepN( d ); _msg = null; }
void SetVolume( float v ) { if ( Player != null ) Player.Volume = v; }
// ── The seed ──
void PlayTyped()
{
if ( Player == null ) { _msg = null; return; }
// A seed that will not parse leaves playback exactly where it was and says why, rather than
// starting something adjacent to what was typed.
_msg = Player.PlaySeed( _seedEntry?.Text, out var error )
? SkafinityBoard.Copy.Playing( Player.CurrentSeed ) : error;
}
// This song, with everything it left to chance written down — whoever is handed it hears THIS,
// not whatever their own station rolls at that index.
void CopySong()
{
try { Clipboard.SetText( Player?.CurrentSeed ?? "" ); _copySongLabel = SkafinityBoard.Copy.Copied; }
catch { _copySongLabel = "—"; }
}
// The seed as it stands: whatever this player left rolling keeps rolling for them too.
void CopyStation()
{
try { Clipboard.SetText( Player?.StationSeed ?? "" ); _copyStationLabel = SkafinityBoard.Copy.Copied; }
catch { _copyStationLabel = "—"; }
}
// ── What plays ──
// "" is the Random entry — the genre is not in the seed, so every song rolls its own.
static readonly List<Option> GenreOptions = BuildGenreOptions();
static List<Option> BuildGenreOptions()
{
var list = new List<Option> { new( SkafinityBoard.Copy.GenreRandom, "" ) };
for ( int g = 0; g < VibeCodec.GenreCount; g++ )
list.Add( new Option( VibeCodec.Genres[g], g.ToString() ) );
return list;
}
string GenreValue => GenreRolling ? "" : ( Player?.EffectiveConfig()?.Genre ?? 0 ).ToString();
void PickGenre( string v )
{
if ( Player == null ) return;
if ( string.IsNullOrEmpty( v ) ) { Player.RollGenre(); _msg = SkafinityBoard.Copy.GenreUnpinned; return; }
if ( int.TryParse( v, out var g ) ) { Player.SetGenre( g ); _msg = null; }
}
// A different SONG, not a different taste: a fresh station at song 0, with anything pinned left
// pinned.
void RerollStation() { Player?.RerollStation(); _msg = SkafinityBoard.Copy.NewStation; }
void ToggleShuffle() { if ( Player != null ) Player.SetShuffle( !Player.Shuffle ); _msg = null; }
void ToggleTinker() { _tinkering = !_tinkering; }
// ── The knobs ──
// One knob: a name/value header over a real slider (or a dropdown, where the field is a choice).
// The whole layout comes from the library's field metadata for the current genre, so a new genre
// — or a new knob — is a pure engine change and there is no field table here.
RenderFragment Knob( VibeCodec.Field f, MusicGen.Config cfg, string label )
{
int genre = cfg?.Genre ?? 0;
int idx = SkafinityBoard.FieldIndex( genre, f );
float norm = cfg != null ? f.GetNorm( cfg ) : 0f;
return @<text>
<div class="knob">
<div class="knob-head">
<div class="knob-name" style="@LabelStyle">@label</div>
<div class="knob-val" style="@AccentTextStyle">@( cfg != null ? f.Display( cfg ) : "" )</div>
</div>
@if ( f.Choices != null )
{
<DropDown class="knob-select" style="@BtnStyle" Value="@SkafinityBoard.ChoiceIndex( f, norm ).ToString()"
Options="@ChoiceOptions( f )"
ValueChanged="@( (string v) => SetChoice( idx, f, v ) )" />
}
else
{
@* Snapped to the same discrete grid the seed encodes (one level per base-36 char), so
the slider can only land on values the vibe can actually represent. *@
<SkafinitySlider Min="@(0f)" Max="@( (float)(VibeCodec.Levels - 1) )" Step="@(1f)"
Value="@( MathF.Round( norm * (VibeCodec.Levels - 1) ) )"
OnValueChanged="@( (float v) => SetVibe( idx, v / (VibeCodec.Levels - 1) ) )"></SkafinitySlider>
}
</div>
</text>;
}
static List<Option> ChoiceOptions( VibeCodec.Field f )
{
var list = new List<Option>( f.Choices.Length );
for ( int k = 0; k < f.Choices.Length; k++ ) list.Add( new Option( f.Choices[k], k.ToString() ) );
return list;
}
void SetChoice( int idx, VibeCodec.Field f, string v )
{
if ( int.TryParse( v, out var k ) ) SetVibe( idx, SkafinityBoard.ChoiceNorm( f, k ) );
}
void SetVibe( int index, float norm )
{
if ( index < 0 ) return;
Player?.SetVibe( index, norm );
_msg = null;
}
// RerollVibe()'s defaults: the genre and the per-instrument volumes stay — the die over the
// mixer re-voices the band, it does not swap the band or upend the mix you set.
void RerollVibe() { Player?.RerollVibe(); _msg = SkafinityBoard.Copy.VibeRolled; }
void RollVibe()
{
if ( Player == null || !Player.VibePinned ) return; // nothing pinned — nothing to hand back
Player.RollVibe();
_msg = SkafinityBoard.Copy.VibeUnpinned;
}
// ── The playlist ──
// How many history / look-ahead entries to show either side of the current song.
static int QueueBack => 3;
static int QueueFwd => 5;
IEnumerable<SkafinityPlayer.QueueEntry> Queue() =>
Player?.Timeline( QueueBack, QueueFwd ) ?? Enumerable.Empty<SkafinityPlayer.QueueEntry>();
void JumpTo()
{
if ( Player == null ) return;
if ( int.TryParse( _jumpEntry?.Text, out var p ) ) Player.SeekTo( p );
}
// Rendering a song outside the cache takes seconds, so the button says so — a save that looks
// like it did nothing is a save people press again.
async void Save( int position )
{
if ( Player == null || _saving ) return;
_saving = true;
try
{
var name = await Player.SaveToFileAsync( position );
_msg = string.IsNullOrEmpty( name ) ? SkafinityBoard.Copy.SaveFailed : SkafinityBoard.Copy.Saved( name );
}
finally { _saving = false; }
}
protected override int BuildHash()
{
// Fold in the playhead and the queue's cached/generating state so the board animates as the
// song runs and as songs render. Both are QUANTISED: the seek bar wants to move, but a panel
// that rebuilds every frame to advance a bar by a pixel costs more than it shows. A fifth of
// a second is smooth to look at and cheap to draw.
var q = new HashCode();
q.Add( IsOpen ); q.Add( Player?.CurrentSeed ); q.Add( Player?.CurrentVibe );
q.Add( Player?.Enabled ?? true ); q.Add( Player?.Volume ?? 1f );
q.Add( Player?.GenrePinned ?? false ); q.Add( Player?.VibePinned ?? false );
q.Add( Player?.Shuffle ?? false ); q.Add( Player?.IsPaused ?? false );
q.Add( _tinkering ); q.Add( Player?.IsBuffering ?? false ); q.Add( _saving );
q.Add( _msg ); q.Add( _copySongLabel ); q.Add( _copyStationLabel );
q.Add( _scrubTo );
// The palette rides in inline style= values, so the board has to rebuild when the host
// retints it — nothing else in this hash moves when only SkafinityTheme.Accent changes.
q.Add( SkafinityTheme.Accent );
if ( IsOpen )
{
var here = Player?.Playhead() ?? default;
q.Add( (int)(here.Time * 5) ); q.Add( (int)(here.Duration * 5) );
foreach ( var e in Queue() )
{
q.Add( e.N ); q.Add( e.Position ); q.Add( e.Cached ); q.Add( e.Current ); q.Add( e.Genre );
q.Add( e.Progress >= 0 ? (int)MathF.Round( e.Progress * 20 ) : -1 );
}
}
return q.ToHashCode();
}
}
@using Sandbox
@using Sandbox.UI
@using LobbySystem
@inherits PanelComponent
@namespace LobbySystem.Examples
<root>
@if ( Dir is null )
{
<text></text>
}
else if ( Dir.MenuOpen || Dir.SuggestMenuOpen )
{
<div class="menu">
<div class="title">MULTIPLAYER LOBBY</div>
<div class="sub">@(Dir.MenuOpen ? "Choose a game mode" : "Suggest a mode to the host")</div>
<div class="row">
@for ( int i = 0; i < Dir.Modes.Count; i++ )
{
var idx = i;
<button class="mode" onclick=@(() => Dir.PickMode( idx ))>
<div class="k">@(idx + 1)</div>
<div class="n">@Dir.Modes[idx].DisplayName</div>
</button>
}
</div>
<button class="back" onclick=@(() => Dir.RequestCloseMenu())>Back to lobby [E]</button>
</div>
}
else
{
<div class="top">
@if ( Dir.RoundLive )
{
<div class="mode">@(Dir.ActiveMode?.DisplayName ?? "")</div>
<div class="timer @(Dir.TimeLeftSeconds < 15 ? "urgent" : "")">@TimerText</div>
}
else
{
<div class="lobby">LOBBY</div>
}
</div>
@if ( !Dir.RoundLive && !string.IsNullOrEmpty( Dir.StatusMessage ) )
{
<div class="status">@Dir.StatusMessage</div>
}
@if ( Dir.ChatVisible )
{
<div class="chat">@Dir.ChatLine</div>
}
<div class="hints">
<span>[WASD] Move</span>
<span>[Space] Jump</span>
@if ( !Dir.RoundLive )
{
<span>Walk to the pad and press [E] to start a round</span>
}
</div>
}
</root>
@code
{
LobbyDirector Dir => LobbyDirector.Current;
string TimerText
{
get
{
int t = Dir?.TimeLeftSeconds ?? 0;
if ( t < 0 ) t = 0;
return $"{t / 60:D2}:{t % 60:D2}";
}
}
protected override int BuildHash() => System.HashCode.Combine(
Dir?.State, Dir?.MenuOpen, Dir?.SuggestMenuOpen, Dir?.RoundLive,
Dir?.TimeLeftSeconds, Dir?.StatusMessage, Dir?.ChatLine, Dir?.ChatVisible );
}
@using System
@using System.Linq
@using Sandbox
@using Sandbox.UI
@namespace FieldGuide.Tips
@inherits PanelComponent
@*
The coach's calm little card: one tip at a time, lower-left, out of the way of the vitals and the
action bar. It renders whatever TipsCoach publishes, the coach owns all the logic; this is a pure
view. All state is read through null-safe statics, so the panel is inert until a coach drives it.
Device-aware (v0.4): the body picks the keyboard or pad segment list off TipsCoach.ActiveDevice, and
on a pad it remaps each keycap through TipsCoach.PadLabelFor (skipping chips with no pad equivalent).
ActiveDevice is folded into BuildHash, so plugging or unplugging a controller mid-display re-renders
the wording and the chips at once.
*@
<root class="tips-root">
@if ( TipsCoach.Visible && TipsCoach.ActiveTip is not null )
{
var onPad = TipsCoach.ActiveDevice == TipDevice.Gamepad;
var segments = onPad ? TipsCoach.ActivePadSegments : TipsCoach.ActiveSegments;
<div class="tip">
@* The accent stripe is its own element carrying its own left radius, rather than a clipped child
of a rounded parent: relying on the parent to clip it is how it ends up a square corner over a
round card. *@
<div class="stripe"></div>
<div class="row">
@if ( !string.IsNullOrEmpty( TipsCoach.ActiveTip.Icon ) )
{
<div class="glyph">@TipsCoach.ActiveTip.Icon</div>
}
<div class="body">
<div class="kicker">GUIDE</div>
<div class="text">
@foreach ( var seg in segments )
{
if ( seg.Kind == TipSegmentKind.Key )
{
if ( onPad && TipsCoach.PadLabelFor is not null )
{
// Pad-mode keycap remap (build plan point 4): swap the keyboard label for its
// controller label, or drop the chip when it has no pad equivalent.
var mapped = TipDeviceText.PadCap( seg.Text, TipsCoach.PadLabelFor );
if ( !string.IsNullOrEmpty( mapped ) )
{
<span class="@(mapped == seg.Text ? "key" : "pad")">@mapped</span>
}
}
else
{
<span class="key">@seg.Text</span>
}
}
else if ( seg.Kind == TipSegmentKind.GamepadButton )
{
<span class="pad">@seg.Text</span>
}
else
{
<span>@seg.Text</span>
}
}
</div>
</div>
@* Optional "hide tips" chip: shows the label that matches the active device (HidePadLabel on a
pad, HideKeyLabel on keyboard). It appears only when the game set that label; the kit itself
has no keybind opinion. Clicking it runs the same Hide() as the close affordance. *@
@if ( onPad && !string.IsNullOrEmpty( TipsCoach.HidePadLabel ) )
{
<div class="hint" onclick=@Hide>
<span class="pad">@TipsCoach.HidePadLabel</span>
<span class="hint-label">hide tips</span>
</div>
}
else if ( !onPad && !string.IsNullOrEmpty( TipsCoach.HideKeyLabel ) )
{
<div class="hint" onclick=@Hide>
<span class="key">@TipsCoach.HideKeyLabel</span>
<span class="hint-label">hide tips</span>
</div>
}
<div class="close" onclick=@Hide>×</div>
</div>
</div>
}
</root>
@code
{
// The close affordance (and the optional hide chip). When the game wired the TipsCoach.HideAll seam it
// runs that (a game-owned "tips off" action, e.g. a persisted preference); otherwise it falls back to the
// v0.1-v0.3 behaviour of dismissing just the current tip. A library cannot reference game controls, so
// HideAll is the only legal bridge for a game-defined hide (a direct game-type reference fails CS0103 in
// the editor's separate library assembly).
private void Hide()
{
if ( TipsCoach.HideAll is not null )
{
TipsCoach.HideAll.Invoke();
return;
}
Scene?.GetAllComponents<TipsCoach>().FirstOrDefault()?.DismissCurrent();
}
// Re-render only when the shown tip, its WORDING, or the input device changes. TipsCoach.ActiveDevice is
// folded in because it picks which segment list renders and which hide-chip label shows, so a mid-display
// controller plug/unplug re-renders the body wording and chips at once (the same "device flag belongs in
// BuildHash" precedent as WB's ItemsHud / RightRail).
//
// Text and TextPad are folded in alongside the id because the Tips Studio rewrites the tip UNDER THE SAME
// ID while you type: this card is the Studio's live preview, so hashing the id alone froze the wording at
// whatever it was when the preview started. A shipped game is unaffected, TipDefinition is a record with
// init-only fields, so its wording cannot change without the tip itself being replaced, which changes the
// id in every case that is not authoring.
protected override int BuildHash() => HashCode.Combine(
TipsCoach.Visible,
TipsCoach.ActiveTip?.Id,
TipsCoach.ActiveTip?.Text,
TipsCoach.ActiveTip?.TextPad,
TipsCoach.ActiveTip?.Icon,
TipsCoach.ActiveDevice );
}
@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 );
}
}
@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 );
}
}
@using Sandbox;
@using Sandbox.UI;
@using System.Threading.Tasks;
@using System.Collections.Generic;
@using System;
@inherits PanelComponent
<root class="@(IsFadingOut ? "fade-out" : "fade-in")" style="background-image: @(!string.IsNullOrEmpty(BackgroundImage) ? $"url({BackgroundImage})" : "none");">
<div class="content">
@* Logo Image (.png / .jpg) *@
@if (!string.IsNullOrEmpty(LogoImage))
{
<img class="logo" src="@LogoImage" />
}
@* Text Lines *@
@if (TextLines != null && TextLines.Count > 0)
{
<div class="text-container">
@foreach (var line in TextLines)
{
<label style="color: @line.TextColor.Hex; font-size: @(line.FontSize)px;">
@line.Text
</label>
}
</div>
}
</div>
</root>
@code {
// === CUSTOM DATA CLASS FOR TEXT LINES ===
public class SplashTextLine
{
[Property, Description("The text to display.")]
public string Text { get; set; } = "NEW LINE";
[Property, Description("Text color for this specific line.")]
public Color TextColor { get; set; } = Color.White;
[Property, Description("Font size for this specific line.")]
public float FontSize { get; set; } = 80f;
}
// === IMAGE SETTINGS ===
[Property, ImageAssetPath, Group("Images"), Description("Supports .png and .jpg. If empty, the background will be black.")]
public string BackgroundImage { get; set; }
[Property, ImageAssetPath, Group("Images"), Description("Main logo image (.png / .jpg). Appears above the text if both are set.")]
public string LogoImage { get; set; }
// === TEXT SETTINGS ===
[Property, Group("Text"), Description("Add text lines with individual settings (color, size).")]
public List<SplashTextLine> TextLines { get; set; } = new();
// === AUDIO & SCENE SETTINGS ===
[Property, Group("Audio"), Description("Select a Sound Event (.sound) that contains your .mp3 or .ogg file.")]
public SoundEvent SplashSound { get; set; }
[Property, Group("Scene"), Description("The scene to load after the splash screen finishes.")]
public SceneFile NextScene { get; set; }
// === LOGIC ===
public bool IsFadingOut { get; set; } = false;
protected override void OnStart()
{
base.OnStart();
// Start the asynchronous sequence
_ = RunSplashSequence();
}
private async Task RunSplashSequence()
{
// 1. Wait half a second before starting to avoid stuttering during load
await Task.Delay(500);
// 2. Play the assigned sound (.mp3 / .ogg via Sound Event)
if (SplashSound != null)
{
Sound.Play(SplashSound);
}
// 3. Wait while the logo/text is visible on the screen (3 seconds)
await Task.Delay(3000);
// 4. Trigger the fade-out animation
IsFadingOut = true;
StateHasChanged(); // Notify the UI to update CSS classes
// 5. Wait for the fade-out animation to finish (matches the CSS transition time)
await Task.Delay(2000);
// 6. Load the next scene
if (NextScene != null)
{
Scene.Load(NextScene);
}
else
{
Log.Warning("Next Scene is not assigned in the Splash Screen component!");
GameObject.Destroy(); // Destroy the component if no scene is assigned
}
}
}@using Sandbox
@using Sandbox.UI
@using System
@namespace FieldGuide.Placement
@inherits PanelComponent
@attribute [StyleSheet]
@*
The demo's on-screen key card, up from the first frame. A tool 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.
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/placement-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>
@if ( CardOpen )
{
<div class="hc-card">
<div class="hc-hdr">
<span class="hc-title">PLACEMENT KIT DEMO</span>
<div class="hc-x" onclick=@(() => CardOpen = false)>×</div>
</div>
<div class="hc-lede">Fit the three accessories to the citizen with the panel on the right, then bake the offsets into your own code.</div>
<div class="hc-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="hc-row">
<span class="hc-key">@key</span>
<span class="hc-what">@what</span>
</div>
}
</div>
<div class="hc-foot">Copy on the tweak panel puts that accessory's offset line on your clipboard.</div>
</div>
}
</root>
@code
{
static bool _open = true;
/// <summary>Console fallback: `placement_hint 1` / `placement_hint 0` shows or hides the card (H also
/// toggles). Starts SHOWN, unlike the tweak panel, because it is the thing that tells you the tweak
/// panel exists.</summary>
[ConVar( "placement_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 (string key, string what)[] Keys =
{
( "P", "show or hide the tweak panel (open on the right)" ),
( "Right mouse", "orbit the view · wheel zooms · WASD pans" ),
( "Q / E", "orbit left and right from the keyboard" ),
( "B", "ghost placement mode on and off" ),
( "[ ]", "cycle the placeable while in placement mode" ),
( "Left mouse", "place on a green spot" ),
( "R", "rotate the ghost · G deletes what is under the cursor" ),
( "H", "hide this card" ),
};
protected override void OnStart()
{
// `placement_hint` is a convar, and s&box persists convars across sessions. Force it SHOWN at mount
// so a session that was closed with the card hidden still opens with instructions on screen.
CardOpen = true;
}
protected override void OnUpdate()
{
if ( Input.Keyboard.Pressed( "H" ) )
CardOpen = !CardOpen;
}
protected override int BuildHash() => HashCode.Combine( CardOpen );
}
@using Sandbox
@using Sandbox.UI
@using System
@using System.Collections.Generic
@using System.Linq
@namespace FieldGuide.Tips
@inherits PanelComponent
@attribute [StyleSheet]
@*
TIPS STUDIO - author a tip inside the running game and watch the real card change as you type.
One modal, three columns, over a dim scrim (the approved Field Kits layout, docs/design/ui-system).
LEFT is the merged catalog with the source each id resolved from, which is also how a tip left behind
by another scene gives itself away. MIDDLE is the editor: wording, order, prerequisites, and the two
trigger pickers, whose action list comes from the project's own input actions. RIGHT is the payoff:
the same card the player sees, rendered twice side by side so the keyboard and controller wordings
read together, and under it the bake-out (Copy to clipboard, or stage it for the editor menu action
that writes Assets/tips).
The lower-left card is still the REAL one: "show it" pushes the draft into the coach and TipsDisplay
draws it with the shipped stylesheet, and the device chips pin TipsCoach.PreviewDevice. The two cards
in the rail are the same component restated inside this panel, because a preview you have to look
away from is not a preview.
Toggle: `fg_tips_studio 1` in the console, or press T (a plain letter; the editor eats F1-F12 in
play-in-editor). The key OPENS only, never closes, so pressing T inside a text box types a T and
nothing else. Close with the x in the header or `fg_tips_studio 0`.
Panel rules this file follows, each of which has cost someone a session: rows come from @foreach in
main markup, never a RenderFragment; the root takes no pointer events and the scrim and modal take
all of them; both scroll regions are a FIXED pixel height, never a percentage; no field being TYPED
into is folded into BuildHash, because a rebuild takes the cursor out of the box.
*@
<root class="ts-root">
@if ( TipsStudio.Open )
{
<div class="ts-scrim">
<div class="ts-modal">
@* ================= header ================= *@
<div class="ts-hdr">
<div class="ts-hdr-left">
<div class="ts-title">TIPS STUDIO</div>
<div class="ts-hdr-meta">@HeaderMeta</div>
</div>
<div class="ts-x" onclick=@Close>×</div>
</div>
<div class="ts-cols">
@* ================= left: the merged catalog ================= *@
<div class="ts-left">
<div class="ts-list-hdr">
<div class="ts-list-t">Tips</div>
<div class="ts-list-m">by priority</div>
</div>
<div class="ts-list" @ref="ListBody">
@if ( Entries.Count == 0 )
{
<div class="ts-empty">
<div class="ts-empty-t">No tips yet</div>
<div class="ts-empty-l">Press New tip to write one.</div>
</div>
}
@foreach ( var e in Entries )
{
var entry = e;
<div class="ts-row @(entry.Selected ? "on" : "")" onclick=@(() => OpenTip( entry.Id ))>
<div class="ts-row-id">@entry.Id</div>
@* State REPLACES the source rather than sitting beside it: the row is 248px wide
and a third thing in it squeezes the id until it blanks out. *@
<div class="ts-row-meta">
@if ( entry.State is null )
{
<div class="ts-row-src">@entry.Source</div>
}
else
{
<div class="ts-row-state">@entry.State</div>
}
</div>
</div>
}
</div>
<div class="ts-left-btns">
<div class="ts-btn pri grow gap" onclick=@NewTip>New tip</div>
<div class="ts-btn" onclick=@Rescan>Rescan</div>
</div>
</div>
@* ================= middle: the draft ================= *@
<div class="ts-mid" @ref="MidBody">
@if ( TipsStudio.DroppedPredicates )
{
<div class="ts-warn">
<div class="ts-warn-b">!</div>
<div class="ts-warn-t">
<div class="ts-warn-l">This tip carries a code predicate.</div>
<div class="ts-warn-l">A .tip file cannot hold one, so baking</div>
<div class="ts-warn-l">keeps the triggers and drops the predicate.</div>
</div>
</div>
}
<div class="ts-frow">
<div class="ts-field w200">
<div class="ts-lab">Id</div>
<TextEntry class="ts-in" Value=@DraftId
OnTextEdited=@((string v) => { TipsStudio.Draft.Id = v; }) onsubmit=@Commit />
</div>
<div class="ts-field w150">
<div class="ts-lab">Priority</div>
<div class="ts-step">
<div class="ts-stp" onclick=@(() => BumpPriority( -10 ))>−</div>
<div class="ts-val">@DraftPriority</div>
<div class="ts-stp" onclick=@(() => BumpPriority( 10 ))>+</div>
</div>
</div>
<div class="ts-field grow last">
<div class="ts-lab">Prerequisites</div>
<div class="ts-drop">
<div class="ts-drop-face @(IsOpen( PrereqDrop ) ? "open" : "")"
onclick=@(() => ToggleDrop( PrereqDrop ))>
<div class="ts-drop-val">@PrereqFace</div>
<div class="ts-chev">expand_more</div>
</div>
@if ( IsOpen( PrereqDrop ) )
{
<div class="ts-drop-list @(Tall( AvailablePrerequisites.Count ))">
@if ( AvailablePrerequisites.Count == 0 )
{
<div class="ts-drop-opt">no other tip ids yet</div>
}
@foreach ( var p in AvailablePrerequisites )
{
var prereq = p;
<div class="ts-drop-opt" onclick=@(() => AddPrerequisite( prereq ))>@prereq</div>
}
</div>
}
</div>
</div>
</div>
@* Its own block under the field, with its own bottom margin: this row used to collapse
into the wording section and paint its chips over the Text label. *@
@if ( PrerequisiteList.Count > 0 )
{
<div class="ts-chips block">
@foreach ( var p in PrerequisiteList )
{
var prereq = p;
<div class="ts-chip on mono"
onclick=@(() => RemovePrerequisite( prereq ))>@($"{prereq} ×")</div>
}
</div>
}
<div class="ts-frow">
<div class="ts-field grow last">
<div class="ts-lab-row">
<div class="ts-lab">Text</div>
<div class="ts-cap">*Space* keycap · `A` pad button</div>
</div>
<TextEntry class="ts-in" Value=@DraftText
OnTextEdited=@((string v) => { TipsStudio.Draft.Text = v; Refresh(); }) onsubmit=@Commit />
</div>
</div>
<div class="ts-frow">
<div class="ts-field grow">
<div class="ts-lab">Pad text · optional</div>
<TextEntry class="ts-in" Value=@DraftTextPad
OnTextEdited=@((string v) => { TipsStudio.Draft.TextPad = v; Refresh(); }) onsubmit=@Commit />
</div>
<div class="ts-field w120 last">
<div class="ts-lab">Icon</div>
<TextEntry class="ts-in" Value=@DraftIcon
OnTextEdited=@((string v) => { TipsStudio.Draft.Icon = v; Refresh(); }) onsubmit=@Commit />
</div>
</div>
@* ---- the two trigger pickers, from one block of markup ---- *@
@foreach ( var s in TriggerSlots )
{
var slot = s;
var trig = slot.Trigger;
var actionDrop = slot.Key;
<div class="ts-sec">
<div class="ts-sec-hd">
<div class="ts-sec-t">@slot.Title</div>
<div class="ts-cap flat">@slot.Blurb</div>
</div>
<div class="ts-chips">
@foreach ( var k in TipStudioTrigger.AllKinds )
{
var kind = k;
<div class="ts-chip @(trig.Kind == kind ? "on" : "")"
onclick=@(() => SetKind( trig, kind ))>@TipStudioTrigger.KindName( kind )</div>
}
</div>
<div class="ts-frow">
@if ( trig.Kind == TipTriggerKind.InputAction )
{
<div class="ts-field w200">
<div class="ts-lab">Input action</div>
<div class="ts-drop">
<div class="ts-drop-face @(IsOpen( actionDrop ) ? "open" : "")"
onclick=@(() => ToggleDrop( actionDrop ))>
<div class="ts-drop-val">@ActionFace( trig )</div>
<div class="ts-chev">expand_more</div>
</div>
@if ( IsOpen( actionDrop ) )
{
<div class="ts-drop-list @(Tall( ActionNames.Count ))">
@if ( ActionNames.Count == 0 )
{
<div class="ts-drop-opt">no input actions bound</div>
}
@foreach ( var a in ActionNames )
{
var action = a;
<div class="ts-drop-opt @(trig.Action == action ? "on" : "")"
onclick=@(() => PickAction( trig, action ))>@action</div>
}
</div>
}
</div>
<div class="ts-cap">from Input.config</div>
</div>
}
@if ( trig.Kind == TipTriggerKind.Key )
{
<div class="ts-field w200">
<div class="ts-lab">Key</div>
<TextEntry class="ts-in" Value=@TrigKey( trig )
OnTextEdited=@((string v) => { trig.Key = v; }) onsubmit=@Commit />
<div class="ts-cap">space, w, mouse1</div>
</div>
}
@if ( slot.NeedsName )
{
<div class="ts-field w200">
<div class="ts-lab">Name</div>
<TextEntry class="ts-in" Value=@TrigName( trig )
OnTextEdited=@((string v) => { trig.Name = v; }) onsubmit=@Commit />
<div class="ts-cap">@slot.NameHint</div>
</div>
}
@if ( trig.Kind == TipTriggerKind.AtLeast )
{
<div class="ts-field w150">
<div class="ts-lab">At least</div>
<div class="ts-step">
<div class="ts-stp" onclick=@(() => BumpThreshold( trig, -1f ))>−</div>
<div class="ts-val">@TrigThreshold( trig )</div>
<div class="ts-stp" onclick=@(() => BumpThreshold( trig, 1f ))>+</div>
</div>
</div>
}
@if ( trig.Kind == TipTriggerKind.Timer )
{
<div class="ts-field w150">
<div class="ts-lab">Seconds</div>
<div class="ts-step">
<div class="ts-stp" onclick=@(() => BumpSeconds( trig, -1f ))>−</div>
<div class="ts-val">@TrigSeconds( trig )</div>
<div class="ts-stp" onclick=@(() => BumpSeconds( trig, 1f ))>+</div>
</div>
</div>
}
@if ( trig.Kind == TipTriggerKind.AnalogAxis )
{
<div class="ts-field w150">
<div class="ts-lab">Magnitude</div>
<div class="ts-step">
<div class="ts-stp" onclick=@(() => BumpMagnitude( trig, -0.1f ))>−</div>
<div class="ts-val">@TrigMagnitude( trig )</div>
<div class="ts-stp" onclick=@(() => BumpMagnitude( trig, 0.1f ))>+</div>
</div>
<div class="ts-cap">0 to 1</div>
</div>
}
@if ( slot.IsCompletion )
{
<div class="ts-field grow last">
<div class="ts-lab">Max show seconds</div>
<div class="ts-step">
<div class="ts-stp" onclick=@(() => BumpMaxShow( -1f ))>−</div>
<div class="ts-val w80">@DraftMaxShow</div>
<div class="ts-stp" onclick=@(() => BumpMaxShow( 1f ))>+</div>
</div>
<div class="ts-cap">0 = never auto-complete</div>
</div>
}
</div>
@if ( trig.Kind == TipTriggerKind.AnalogAxis )
{
<div class="ts-chips">
@foreach ( var src in TipStudioTrigger.AllAnalogSources )
{
var source = src;
<div class="ts-chip @(trig.AnalogSource == source ? "on" : "")"
onclick=@(() => SetSource( trig, source ))>@TipStudioTrigger.SourceName( source )</div>
}
</div>
}
@if ( slot.IsComposite )
{
<div class="ts-chips">
<div class="ts-chip" onclick=@(() => AddChild( trig ))>add one</div>
@if ( trig.Children.Count == 0 )
{
<div class="ts-cap flat">@slot.EmptyCompositeHint</div>
}
</div>
@foreach ( var c in trig.Children.ToList() )
{
var child = c;
<div class="ts-child">
<div class="ts-chips">
@foreach ( var k in TipStudioTrigger.AllKinds )
{
var kind = k;
<div class="ts-chip small @(child.Kind == kind ? "on" : "")"
onclick=@(() => SetKind( child, kind ))>@TipStudioTrigger.KindName( kind )</div>
}
<div class="ts-chip small drop" onclick=@(() => RemoveChild( trig, child ))>remove</div>
</div>
<div class="ts-frow">
<div class="ts-field grow last">
<div class="ts-lab-row">
<div class="ts-lab">Value</div>
<div class="ts-cap flat">@ChildHint( child )</div>
</div>
<TextEntry class="ts-in" Value=@ChildValue( child )
OnTextEdited=@((string v) => SetChildValue( child, v )) onsubmit=@Commit />
</div>
</div>
</div>
}
}
</div>
}
@* ---- what the draft would do wrong ---- *@
@if ( TipsStudio.ShadowedBy is not null )
{
<div class="ts-warn">
<div class="ts-warn-b">!</div>
<div class="ts-warn-t">
<div class="ts-warn-l">A code tip already owns this id.</div>
<div class="ts-warn-l">Your file sits behind it in the catalog.</div>
</div>
</div>
}
@foreach ( var n in NoteBlocks )
{
var note = n;
<div class="ts-warn">
<div class="ts-warn-b">!</div>
<div class="ts-warn-t">
@foreach ( var l in note.Lines )
{
var line = l;
<div class="ts-warn-l">@line</div>
}
</div>
</div>
}
</div>
@* ================= right: preview and bake ================= *@
<div class="ts-rail">
<div class="ts-rail-hdr">
<div class="ts-kicker">LIVE PREVIEW</div>
<div class="ts-btn-row">
<div class="ts-btn small gap" onclick=@CompleteNow>Complete it</div>
<div class="ts-btn pri small" onclick=@TestFire>Test fire</div>
</div>
</div>
@* Pinned above the scrolling preview: these four pick what the REAL lower-left card
shows, and a control that scrolls out of sight is a control nobody finds. *@
<div class="ts-pv-row">
<div class="ts-pv-key">Live card</div>
<div class="ts-chip @(TipsStudio.PreviewOn ? "on" : "")"
onclick=@TogglePreview>@(TipsStudio.PreviewOn ? "showing" : "show it")</div>
<div class="ts-chip @(TipsStudio.PinnedDevice == TipDevice.KeyboardMouse ? "on" : "")"
onclick=@(() => PinDevice( TipDevice.KeyboardMouse ))>keyboard</div>
<div class="ts-chip @(TipsStudio.PinnedDevice == TipDevice.Gamepad ? "on" : "")"
onclick=@(() => PinDevice( TipDevice.Gamepad ))>pad</div>
<div class="ts-chip @(TipsStudio.PinnedDevice is null ? "on" : "")"
onclick=@(() => PinDevice( null ))>live</div>
</div>
<div class="ts-rail-body" @ref="RailBody">
<div class="ts-well">
@foreach ( var p in PreviewCards )
{
var card = p;
<div class="ts-pv @(card.Last ? "last" : "")">
<div class="ts-pv-lab">@card.Label</div>
<div class="tsp-card">
<div class="tsp-stripe"></div>
<div class="tsp-in">
@if ( !string.IsNullOrEmpty( card.Icon ) )
{
<div class="tsp-glyph">@card.Icon</div>
}
<div class="tsp-body">
<div class="tsp-kicker">GUIDE</div>
<div class="tsp-text">
@foreach ( var seg in card.Segments )
{
var run = seg;
if ( run.Kind == TipSegmentKind.Key )
{
<span class="tsp-key">@run.Text</span>
}
else if ( run.Kind == TipSegmentKind.GamepadButton )
{
<span class="tsp-pad">@run.Text</span>
}
else
{
<span>@run.Text</span>
}
}
</div>
</div>
<div class="tsp-x">×</div>
</div>
</div>
</div>
}
<div class="ts-cap well">renders exactly what the coach will show</div>
</div>
</div>
<div class="ts-out">
<div class="ts-btn-row">
<div class="ts-btn grow gap" onclick=@CopyJson>@_copyLabel</div>
<div class="ts-btn pri grow" onclick=@Stage>Write to project</div>
</div>
<div class="ts-out-line">@BakeTarget</div>
<div class="ts-btn-row pad">
<div class="ts-btn small" onclick=@ClearStaged>Clear staged</div>
<div class="ts-out-line inline">@StatusLine</div>
</div>
</div>
</div>
</div>
</div>
</div>
}
</root>
@code
{
// ---- mounting ----
/// <summary>The raw key that OPENS the Studio. A plain letter on purpose: the editor eats F1 to F12 in
/// play-in-editor. It only opens, never closes, so pressing it inside a text box just types the letter.
/// Close with the header's × or <c>fg_tips_studio 0</c>.</summary>
[Property] public string OpenKey { get; set; } = "T";
/// <summary>Whether the Studio starts open. Off by default: an authoring panel that appears unbidden over
/// a game is a bug. This, and only this, decides the boot state; the persisted convar never does.</summary>
[Property] public bool OpenOnStart { get; set; }
/// <summary>Force the Studio shut anywhere but the editor. On by default: it is an authoring tool, and a
/// published build has nothing to author. Turn it off if you want it in your own standalone dev build.</summary>
[Property] public bool EditorOnly { get; set; } = true;
// @ref binds to an auto-PROPERTY. On a bare private field it silently never assigns, and the
// CanDragScroll fix below would quietly do nothing.
Sandbox.UI.Panel ListBody { get; set; }
Sandbox.UI.Panel MidBody { get; set; }
Sandbox.UI.Panel RailBody { get; set; }
bool _booted;
bool _wasOpen;
int _revision;
string _copyLabel = "Copy .tip JSON";
string _stageLine = "";
/// <summary>Which dropdown is showing its options, or null. One at a time: the lists sit in flow under
/// their field, so two open at once would push the column around for no reason.</summary>
string _openDrop;
/// <summary>The prerequisite picker's key. The trigger pickers key off their slot name.</summary>
const string PrereqDrop = "prereq";
bool IsOpen( string key ) => _openDrop == key;
void ToggleDrop( string key )
{
_openDrop = _openDrop == key ? null : key;
Refresh();
}
// ---- the catalog list ----
/// <summary>One row of the tip list. A struct of finished strings so the markup interpolates single
/// identifiers only, never a chained member read (which renders blank in several razor cases).</summary>
public struct Entry
{
public string Id;
public string Source;
public string State;
public bool Selected;
}
/// <summary>Every tip in the merged catalog, read fresh (so an edited .tip appears the moment the
/// catalog rebuilds), labelled with the source it resolved from and ordered the way the coach picks
/// them: highest priority first.</summary>
List<Entry> Entries
{
get
{
var list = new List<Entry>();
var view = TipsCatalog.View;
var activeId = TipsCoach.ActiveTip?.Id;
var opened = TipsStudio.OpenedFrom;
foreach ( var def in view.Tips.OrderByDescending( t => t.Priority ).ThenBy( t => t.Id, StringComparer.Ordinal ) )
{
var source = view.SourceById.TryGetValue( def.Id, out var s ) ? s : "unknown";
list.Add( new Entry
{
Id = def.Id,
Source = source,
State = def.Id == activeId ? "on screen" : ( TipsCoach.IsCompleted( def.Id ) ? "done" : null ),
Selected = def.Id == opened,
} );
}
return list;
}
}
/// <summary>The header's one line: the package, the catalog, and what the middle column is editing.
/// One interpolated string rather than three text nodes, because the in-editor codegen drops the
/// whitespace between a literal and an expression.</summary>
string HeaderMeta => $"fieldguide.tips · {CatalogSummary} · {DraftOrigin}";
/// <summary>How many tips and where they came from. A source you did not expect is a tip left over
/// from somewhere else.</summary>
string CatalogSummary
{
get
{
var view = TipsCatalog.View;
if ( view.Tips.Count == 0 )
return "no tips yet";
var counts = new Dictionary<string, int>();
foreach ( var kv in view.SourceById )
counts[kv.Value] = counts.TryGetValue( kv.Value, out var n ) ? n + 1 : 1;
var parts = counts.OrderBy( kv => kv.Key, StringComparer.Ordinal ).Select( kv => $"{kv.Value} {kv.Key}" );
return $"{view.Tips.Count} tips · {string.Join( ", ", parts )}";
}
}
void OpenTip( string id )
{
TipsStudio.OpenTip( id );
ResetLabels();
_openDrop = null;
Refresh();
}
void NewTip()
{
TipsStudio.NewDraft();
ResetLabels();
_openDrop = null;
Refresh();
}
void Rescan()
{
TipsCatalog.NoteAssetsChanged();
Refresh();
}
// ---- draft editing ----
string DraftOrigin => string.IsNullOrEmpty( TipsStudio.OpenedFrom )
? "new tip"
: $"editing {TipsStudio.OpenedFrom}";
// Single-identifier reads for the markup. A razor interpolation of a CHAINED member read
// (TipsStudio.Draft.Priority) renders blank in several cases; a plain property or a method call does not.
string DraftId => TipsStudio.Draft.Id;
string DraftText => TipsStudio.Draft.Text;
string DraftTextPad => TipsStudio.Draft.TextPad;
string DraftIcon => TipsStudio.Draft.Icon;
int DraftPriority => TipsStudio.Draft.Priority;
string DraftMaxShow => Show( TipsStudio.Draft.MaxShowSeconds );
static string TrigKey( TipStudioTrigger t ) => t.Key;
static string TrigName( TipStudioTrigger t ) => t.Name;
static string TrigThreshold( TipStudioTrigger t ) => Show( t.Threshold );
static string TrigSeconds( TipStudioTrigger t ) => Show( t.Seconds );
static string TrigMagnitude( TipStudioTrigger t ) => Show( t.Magnitude );
static string Show( float value ) => value.ToString( "0.##" );
void BumpPriority( int delta )
{
TipsStudio.Draft.Priority += delta;
Refresh();
}
void BumpMaxShow( float delta )
{
TipsStudio.Draft.MaxShowSeconds = MathF.Max( 0f, TipsStudio.Draft.MaxShowSeconds + delta );
Refresh();
}
List<string> PrerequisiteList => TipsStudio.Draft.PrerequisiteTipIds ?? new List<string>();
/// <summary>What the prerequisite field reads at rest. The list underneath ADDS one; the chips below the
/// row remove them, which is the only honest shape for a field that holds several values.</summary>
string PrereqFace
{
get
{
var have = PrerequisiteList;
if ( have.Count == 0 )
return "none";
return have.Count == 1 ? have[0] : $"{have.Count} tips";
}
}
/// <summary>Catalog ids this tip could wait on: everything except itself, the preview id, and the ones it
/// already waits on.</summary>
List<string> AvailablePrerequisites
{
get
{
var have = new HashSet<string>( PrerequisiteList, StringComparer.Ordinal );
var mine = TipsStudio.Draft.Id ?? "";
return TipsCatalog.Active
.Select( t => t.Id )
.Where( id => id != mine && id != TipsStudio.PreviewId && !have.Contains( id ) )
.OrderBy( id => id, StringComparer.Ordinal )
.ToList();
}
}
void AddPrerequisite( string id )
{
TipsStudio.Draft.PrerequisiteTipIds.Add( id );
_openDrop = null;
Refresh();
}
void RemovePrerequisite( string id )
{
TipsStudio.Draft.PrerequisiteTipIds.Remove( id );
Refresh();
}
// ---- the two trigger pickers ----
/// <summary>The Completion and Relevance pickers as data, so ONE block of markup renders both. A
/// RenderFragment would be the other way to share it, and RenderFragments under-measure here.</summary>
public struct TriggerSlot
{
public string Key;
public string Title;
public string Blurb;
public TipStudioTrigger Trigger;
public bool IsCompletion;
public bool NeedsName;
public string NameHint;
public bool IsComposite;
public string EmptyCompositeHint;
}
List<TriggerSlot> TriggerSlots
{
get
{
var completion = TipsStudio.Draft.Completion ??= new TipStudioTrigger();
var relevance = TipsStudio.Draft.Relevance ??= new TipStudioTrigger();
return new List<TriggerSlot>
{
Slot( "completion", "COMPLETION TRIGGER", "what retires this tip", completion, true ),
Slot( "relevance", "RELEVANCE TRIGGER", "an extra gate before it shows", relevance, false ),
};
}
}
static TriggerSlot Slot( string key, string title, string blurb, TipStudioTrigger trigger, bool isCompletion )
{
var kind = trigger.Kind;
var needsName = kind == TipTriggerKind.Signal || kind == TipTriggerKind.Ever
|| kind == TipTriggerKind.Flag || kind == TipTriggerKind.AtLeast;
var hint = kind switch
{
TipTriggerKind.Signal => "Signal(...) string",
TipTriggerKind.Ever => "ctx.Ever(...)",
TipTriggerKind.Flag => "ctx.SetFlag(...)",
_ => "ctx.SetNumber(...)",
};
return new TriggerSlot
{
Key = key,
Title = title,
Blurb = blurb,
Trigger = trigger,
IsCompletion = isCompletion,
NeedsName = needsName,
NameHint = hint,
IsComposite = kind == TipTriggerKind.AnyOf || kind == TipTriggerKind.AllOf,
EmptyCompositeHint = isCompletion && kind == TipTriggerKind.AllOf
? "empty: the shape a TipTriggerObject retires"
: "empty, so it never fires",
};
}
void SetKind( TipStudioTrigger trigger, TipTriggerKind kind )
{
trigger.Kind = kind;
_openDrop = null;
Refresh();
}
static string ActionFace( TipStudioTrigger trigger )
=> string.IsNullOrEmpty( trigger.Action ) ? "pick an action" : trigger.Action;
void PickAction( TipStudioTrigger trigger, string action )
{
trigger.Action = action;
_openDrop = null;
Refresh();
}
void SetSource( TipStudioTrigger trigger, TipTriggerAnalogSource source )
{
trigger.AnalogSource = source;
Refresh();
}
void BumpThreshold( TipStudioTrigger trigger, float delta )
{
trigger.Threshold = MathF.Max( 0f, trigger.Threshold + delta );
Refresh();
}
void BumpSeconds( TipStudioTrigger trigger, float delta )
{
trigger.Seconds = MathF.Max( 0f, trigger.Seconds + delta );
Refresh();
}
void BumpMagnitude( TipStudioTrigger trigger, float delta )
{
trigger.Magnitude = Math.Clamp( trigger.Magnitude + delta, 0f, 1f );
Refresh();
}
void AddChild( TipStudioTrigger parent )
{
parent.Children.Add( new TipStudioTrigger { Kind = TipTriggerKind.Key } );
Refresh();
}
void RemoveChild( TipStudioTrigger parent, TipStudioTrigger child )
{
parent.Children.Remove( child );
Refresh();
}
/// <summary>A composed child edits its one parameter through a single box, whichever box its kind reads.
/// Nesting a full picker per child would triple the panel for a case the format barely uses.</summary>
static string ChildValue( TipStudioTrigger child ) => child.Kind switch
{
TipTriggerKind.InputAction => child.Action,
TipTriggerKind.Key => child.Key,
TipTriggerKind.Signal or TipTriggerKind.Ever or TipTriggerKind.Flag or TipTriggerKind.AtLeast => child.Name,
TipTriggerKind.Timer => child.Seconds.ToString( "0.##" ),
TipTriggerKind.AnalogAxis => child.Magnitude.ToString( "0.##" ),
_ => "",
};
static void SetChildValue( TipStudioTrigger child, string value )
{
switch ( child.Kind )
{
case TipTriggerKind.InputAction: child.Action = value; break;
case TipTriggerKind.Key: child.Key = value; break;
case TipTriggerKind.Signal:
case TipTriggerKind.Ever:
case TipTriggerKind.Flag:
case TipTriggerKind.AtLeast: child.Name = value; break;
case TipTriggerKind.Timer:
if ( float.TryParse( value, out var seconds ) ) child.Seconds = MathF.Max( 0f, seconds );
break;
case TipTriggerKind.AnalogAxis:
if ( float.TryParse( value, out var magnitude ) ) child.Magnitude = Math.Clamp( magnitude, 0f, 1f );
break;
}
}
static string ChildHint( TipStudioTrigger child ) => TipStudioTrigger.FieldFor( child.Kind ) switch
{
"action" => "an input action name",
"key" => "a raw key name",
"name" => "the named condition",
"name+threshold" => "the named number",
"seconds" => "seconds",
"stick+magnitude" => "magnitude, 0 to 1",
"children" => "nest one level only",
_ => "this kind takes no value",
};
// ---- the two preview cards ----
/// <summary>One rendered card in the rail: the label above it and the runs inside it. Finished data, so
/// the markup walks a list rather than calling into the parser mid-tree.</summary>
public struct PreviewCard
{
public string Label;
public string Icon;
public List<TipSegment> Segments;
public bool Last;
}
/// <summary>The draft as the player will read it on each device, side by side. The pad card runs the same
/// keycap remap the shipped display does (TipsCoach.PadLabelFor), so a chip with no controller equivalent
/// disappears here exactly as it would in the game.</summary>
List<PreviewCard> PreviewCards
{
get
{
var text = TipsStudio.Draft.Text ?? "";
var pad = TipsStudio.Draft.TextPad ?? "";
var icon = TipsStudio.Draft.Icon ?? "";
return new List<PreviewCard>
{
new PreviewCard
{
Label = "KEYBOARD",
Icon = icon,
Segments = TipSegment.Parse( text ).ToList(),
},
new PreviewCard
{
Label = "CONTROLLER",
Icon = icon,
Segments = PadRuns( text, pad ),
Last = true,
},
};
}
}
/// <summary>The pad-mode runs for a wording: its pad text when authored, then every keycap put through
/// the game's pad label map. A mapped label reads as a controller chip; an unmappable one is dropped, the
/// same two rules the shipped card follows.</summary>
static List<TipSegment> PadRuns( string text, string textPad )
{
var runs = new List<TipSegment>();
foreach ( var seg in TipSegment.Parse( TipDeviceText.PadTextOr( text, textPad ) ) )
{
if ( seg.Kind != TipSegmentKind.Key )
{
runs.Add( seg );
continue;
}
var mapped = TipDeviceText.PadCap( seg.Text, TipsCoach.PadLabelFor );
if ( string.IsNullOrEmpty( mapped ) )
continue;
runs.Add( new TipSegment( mapped, mapped == seg.Text ? TipSegmentKind.Key : TipSegmentKind.GamepadButton ) );
}
return runs;
}
// ---- preview, test fire ----
List<string> ActionNames => TipsStudio.ActionNames.ToList();
/// <summary>One authoring note, already broken into lines that fit.</summary>
public struct NoteBlock
{
public List<string> Lines;
}
List<NoteBlock> NoteBlocks => TipsStudio.Notes
.Select( n => new NoteBlock { Lines = Lines( n ) } )
.ToList();
/// <summary>An option list longer than this scrolls at a fixed height instead of growing the column.</summary>
static string Tall( int count ) => count > 6 ? "tall" : "";
/// <summary>
/// Chunk a sentence into lines short enough to lay out as text. A run that overflows its box does not
/// wrap here: the style engine rasterizes it as a solid grey block, or drops it to an empty box. 46
/// characters is one comfortable line in the widest box this panel has, and it is the same ceiling
/// TipStudioText warns tip authors about.
/// </summary>
static List<string> Lines( string text )
{
var lines = new List<string>();
if ( string.IsNullOrWhiteSpace( text ) )
return lines;
var line = "";
foreach ( var word in text.Split( ' ' ) )
{
if ( string.IsNullOrEmpty( word ) )
continue;
if ( line.Length == 0 )
line = word;
else if ( line.Length + 1 + word.Length > 46 )
{
lines.Add( line );
line = word;
}
else
line = line + " " + word;
}
if ( line.Length > 0 )
lines.Add( line );
return lines;
}
void TogglePreview()
{
if ( TipsStudio.PreviewOn )
TipsStudio.StopPreview( Scene );
else
TipsStudio.PushPreview( Scene );
Refresh();
}
void PinDevice( TipDevice? device )
{
TipsStudio.PinnedDevice = device;
Refresh();
}
void TestFire()
{
TipsStudio.TestFire( Scene );
Refresh();
}
void CompleteNow()
{
TipsStudio.CompleteNow();
Refresh();
}
/// <summary>The one-line status under the bake buttons: whatever the last bake action said, or what
/// is waiting in the staging folder when it has said nothing yet.</summary>
string StatusLine
{
get
{
var text = string.IsNullOrEmpty( _stageLine ) ? StagedLine : _stageLine;
var lines = Lines( text );
return lines.Count == 0 ? "" : lines[0];
}
}
// ---- bake ----
string BakeTarget
{
get
{
var file = TipStudioJson.FileNameFor( TipsStudio.Draft.Id );
return file is null
? "Give the tip an id: the file takes its name."
: $"writes Assets/tips/{file}";
}
}
string StagedLine
{
get
{
var count = TipsStudio.StagedCount;
return count == 0 ? "nothing staged" : $"{count} staged for the editor";
}
}
void CopyJson()
{
TipsStudio.CopyJson();
_copyLabel = "Copied!";
Refresh();
}
void Stage()
{
_stageLine = TipsStudio.Stage();
Refresh();
}
void ClearStaged()
{
_stageLine = TipsStudio.ClearStaged();
Refresh();
}
void ResetLabels()
{
_copyLabel = "Copy .tip JSON";
_stageLine = "";
}
// ---- open / close, boot, cursor ----
/// <summary>Bump the panel's own revision so the next frame rebuilds it. Every click calls this; typing
/// into the id box does NOT, because a rebuild would take the cursor out of the box you are typing in.</summary>
void Refresh() => _revision++;
/// <summary>Enter in a text box: push the wording at the preview card and refresh everything derived
/// from it.</summary>
void Commit()
{
if ( TipsStudio.PreviewOn )
TipsStudio.PushPreview( Scene );
Refresh();
}
void Close()
{
TipsStudio.StopPreview( Scene );
TipsStudio.Open = false;
_openDrop = null;
ResetLabels();
}
protected override void OnTreeBuilt()
{
// A background press-drag over a scrolling region must not pan the content or eat a button click; the
// wheel and the scrollbar still scroll.
if ( ListBody is not null )
ListBody.CanDragScroll = false;
if ( MidBody is not null )
MidBody.CanDragScroll = false;
if ( RailBody is not null )
RailBody.CanDragScroll = false;
}
protected override void OnUpdate()
{
// BOOT. `fg_tips_studio` is a convar and s&box persists convars across sessions, so a session could
// otherwise come up with an authoring panel open from a value set weeks ago. This component's own
// OpenOnStart decides the boot state and the persisted value never does. Deliberately in the FIRST
// UPDATE, not OnStart: a panel created in code is configured by whatever created it, and OnStart would
// race that assignment.
if ( !_booted )
{
_booted = true;
TipsStudio.Open = OpenOnStart;
}
// The Studio is an authoring tool; a published build has nothing to author with it.
if ( EditorOnly && !Application.IsEditor )
{
TipsStudio.Open = false;
return;
}
// Opens only. Closing is the header × or the convar, so this key can never fight a text box.
if ( !TipsStudio.Open && !string.IsNullOrEmpty( OpenKey ) && Input.Keyboard.Pressed( OpenKey ) )
{
TipsStudio.Open = true;
Refresh();
}
if ( TipsStudio.Open )
{
Mouse.Visibility = MouseVisibility.Visible;
_wasOpen = true;
}
else if ( _wasOpen )
{
_wasOpen = false;
ResetLabels();
TipsStudio.StopPreview( Scene );
}
}
protected override void OnDestroy()
{
// Everything the Studio pins is static and would otherwise follow the developer into the next scene:
// the preview draft, a test-fire draft, and the pinned preview device.
TipsStudio.Shutdown();
}
// Fold the things a CLICK changes, and nothing anyone TYPES into. A rebuild rehomes every TextEntry, which
// takes the cursor out of the box mid-word, so Id and a trigger's Key and Name are deliberately absent:
// the panel repaints when you press Enter or click, via _revision.
protected override int BuildHash()
{
var hc = new HashCode();
hc.Add( TipsStudio.Open );
hc.Add( TipsStudio.OpenedFrom );
hc.Add( TipsStudio.PreviewOn );
hc.Add( TipsStudio.PinnedDevice );
hc.Add( TipsCoach.ActiveTip?.Id );
hc.Add( _copyLabel );
hc.Add( _stageLine );
hc.Add( _openDrop );
hc.Add( _revision );
hc.Add( TipsStudio.Draft.Priority );
hc.Add( TipsStudio.Draft.MaxShowSeconds );
hc.Add( PrerequisiteList.Count );
foreach ( var slot in TriggerSlots )
{
hc.Add( slot.Trigger.Kind );
hc.Add( slot.Trigger.Action );
hc.Add( slot.Trigger.Threshold );
hc.Add( slot.Trigger.Seconds );
hc.Add( slot.Trigger.AnalogSource );
hc.Add( slot.Trigger.Magnitude );
hc.Add( slot.Trigger.Children.Count );
foreach ( var child in slot.Trigger.Children )
hc.Add( child.Kind );
}
return hc.ToHashCode();
}
}
@using System
@using System.Linq
@using Sandbox
@using Sandbox.UI
@namespace FieldGuide.Tips
@inherits PanelComponent
@*
The coach's calm little card: one tip at a time, lower-left, out of the way of the vitals and the
action bar. It renders whatever TipsCoach publishes, the coach owns all the logic; this is a pure
view. All state is read through null-safe statics, so the panel is inert until a coach drives it.
Device-aware (v0.4): the body picks the keyboard or pad segment list off TipsCoach.ActiveDevice, and
on a pad it remaps each keycap through TipsCoach.PadLabelFor (skipping chips with no pad equivalent).
ActiveDevice is folded into BuildHash, so plugging or unplugging a controller mid-display re-renders
the wording and the chips at once.
*@
<root class="tips-root">
@if ( TipsCoach.Visible && TipsCoach.ActiveTip is not null )
{
var onPad = TipsCoach.ActiveDevice == TipDevice.Gamepad;
var segments = onPad ? TipsCoach.ActivePadSegments : TipsCoach.ActiveSegments;
<div class="tip">
@* The accent stripe is its own element carrying its own left radius, rather than a clipped child
of a rounded parent: relying on the parent to clip it is how it ends up a square corner over a
round card. *@
<div class="stripe"></div>
<div class="row">
@if ( !string.IsNullOrEmpty( TipsCoach.ActiveTip.Icon ) )
{
<div class="glyph">@TipsCoach.ActiveTip.Icon</div>
}
<div class="body">
<div class="kicker">GUIDE</div>
<div class="text">
@foreach ( var seg in segments )
{
if ( seg.Kind == TipSegmentKind.Key )
{
if ( onPad && TipsCoach.PadLabelFor is not null )
{
// Pad-mode keycap remap (build plan point 4): swap the keyboard label for its
// controller label, or drop the chip when it has no pad equivalent.
var mapped = TipDeviceText.PadCap( seg.Text, TipsCoach.PadLabelFor );
if ( !string.IsNullOrEmpty( mapped ) )
{
<span class="@(mapped == seg.Text ? "key" : "pad")">@mapped</span>
}
}
else
{
<span class="key">@seg.Text</span>
}
}
else if ( seg.Kind == TipSegmentKind.GamepadButton )
{
<span class="pad">@seg.Text</span>
}
else
{
<span>@seg.Text</span>
}
}
</div>
</div>
@* Optional "hide tips" chip: shows the label that matches the active device (HidePadLabel on a
pad, HideKeyLabel on keyboard). It appears only when the game set that label; the kit itself
has no keybind opinion. Clicking it runs the same Hide() as the close affordance. *@
@if ( onPad && !string.IsNullOrEmpty( TipsCoach.HidePadLabel ) )
{
<div class="hint" onclick=@Hide>
<span class="pad">@TipsCoach.HidePadLabel</span>
<span class="hint-label">hide tips</span>
</div>
}
else if ( !onPad && !string.IsNullOrEmpty( TipsCoach.HideKeyLabel ) )
{
<div class="hint" onclick=@Hide>
<span class="key">@TipsCoach.HideKeyLabel</span>
<span class="hint-label">hide tips</span>
</div>
}
<div class="close" onclick=@Hide>×</div>
</div>
</div>
}
</root>
@code
{
// The close affordance (and the optional hide chip). When the game wired the TipsCoach.HideAll seam it
// runs that (a game-owned "tips off" action, e.g. a persisted preference); otherwise it falls back to the
// v0.1-v0.3 behaviour of dismissing just the current tip. A library cannot reference game controls, so
// HideAll is the only legal bridge for a game-defined hide (a direct game-type reference fails CS0103 in
// the editor's separate library assembly).
private void Hide()
{
if ( TipsCoach.HideAll is not null )
{
TipsCoach.HideAll.Invoke();
return;
}
Scene?.GetAllComponents<TipsCoach>().FirstOrDefault()?.DismissCurrent();
}
// Re-render only when the shown tip, its WORDING, or the input device changes. TipsCoach.ActiveDevice is
// folded in because it picks which segment list renders and which hide-chip label shows, so a mid-display
// controller plug/unplug re-renders the body wording and chips at once (the same "device flag belongs in
// BuildHash" precedent as WB's ItemsHud / RightRail).
//
// Text and TextPad are folded in alongside the id because the Tips Studio rewrites the tip UNDER THE SAME
// ID while you type: this card is the Studio's live preview, so hashing the id alone froze the wording at
// whatever it was when the preview started. A shipped game is unaffected, TipDefinition is a record with
// init-only fields, so its wording cannot change without the tip itself being replaced, which changes the
// id in every case that is not authoring.
protected override int BuildHash() => HashCode.Combine(
TipsCoach.Visible,
TipsCoach.ActiveTip?.Id,
TipsCoach.ActiveTip?.Text,
TipsCoach.ActiveTip?.TextPad,
TipsCoach.ActiveTip?.Icon,
TipsCoach.ActiveDevice );
}
@using Sandbox
@using Sandbox.UI
@using System
@namespace FieldGuide.Placement
@inherits PanelComponent
@attribute [StyleSheet]
@*
The demo's on-screen key card, up from the first frame. A tool 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.
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/placement-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>
@if ( CardOpen )
{
<div class="hc-card">
<div class="hc-hdr">
<span class="hc-title">PLACEMENT KIT DEMO</span>
<div class="hc-x" onclick=@(() => CardOpen = false)>×</div>
</div>
<div class="hc-lede">Fit the three accessories to the citizen with the panel on the right, then bake the offsets into your own code.</div>
<div class="hc-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="hc-row">
<span class="hc-key">@key</span>
<span class="hc-what">@what</span>
</div>
}
</div>
<div class="hc-foot">Copy on the tweak panel puts that accessory's offset line on your clipboard.</div>
</div>
}
</root>
@code
{
static bool _open = true;
/// <summary>Console fallback: `placement_hint 1` / `placement_hint 0` shows or hides the card (H also
/// toggles). Starts SHOWN, unlike the tweak panel, because it is the thing that tells you the tweak
/// panel exists.</summary>
[ConVar( "placement_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 (string key, string what)[] Keys =
{
( "P", "show or hide the tweak panel (open on the right)" ),
( "Right mouse", "orbit the view · wheel zooms · WASD pans" ),
( "Q / E", "orbit left and right from the keyboard" ),
( "B", "ghost placement mode on and off" ),
( "[ ]", "cycle the placeable while in placement mode" ),
( "Left mouse", "place on a green spot" ),
( "R", "rotate the ghost · G deletes what is under the cursor" ),
( "H", "hide this card" ),
};
protected override void OnStart()
{
// `placement_hint` is a convar, and s&box persists convars across sessions. Force it SHOWN at mount
// so a session that was closed with the card hidden still opens with instructions on screen.
CardOpen = true;
}
protected override void OnUpdate()
{
if ( Input.Keyboard.Pressed( "H" ) )
CardOpen = !CardOpen;
}
protected override int BuildHash() => HashCode.Combine( CardOpen );
}