Search the source of every open source package.
796 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;
@inherits Panel
@namespace Sandbox
<root>
@if (Header != null)
{
<div class="header">@Header</div>
}
<div class="body menuinner">@Body</div>
</root>
@code
{
[Parameter] public RenderFragment Header { get; set; }
[Parameter] public RenderFragment Body { get; set; }
}
@using Sandbox;
@using Sandbox.UI;
@inherits Panel
@namespace Sandbox
<root>
@Path
</root>
@code
{
public string Path { get; set; }
}
@using Sandbox;
@using Sandbox.UI;
@using Sandbox.Mounting;
@inherits Panel
@namespace Sandbox
<root>
<Button class="menu-action primary wide" Icon="➕" Text="#spawnmenu.spawnlist.new_button" Disabled=@( !CanCreate() ) @onclick=@CreatePopup></Button>
</root>
@using Sandbox;
@using Sandbox.UI;
@namespace Sandbox
@inherits Panel
<root>
<div class="menu-segmented-group">
@{
var activeMode = SpawnMenuHost.GetActiveMode();
foreach ( var mode in Game.TypeLibrary.GetTypesWithAttribute<SpawnMenuHost.SpawnMenuMode>().OrderBy( x => x.Type.Order ) )
{
if ( !mode.Attribute.CheckCondition() ) continue;
var activeClass = mode.Type.TargetType == activeMode?.GetType() ? "active" : "";
<div class="menu-mode-button @activeClass" @onclick="@( () => SpawnMenuHost.SwitchMode( mode.Type.Name ) )">
<div class="icon">@mode.Type.Icon</div>
<div class="title">@mode.Type.Title</div>
</div>
}
}
</div>
</root>
@code
{
protected override int BuildHash() => HashCode.Combine( SpawnMenuHost.GetActiveMode(), Game.TypeLibrary.GetTypesWithAttribute<SpawnMenuHost.SpawnMenuMode>() );
}
@using System
@using Sandbox
@using Sandbox.UI
@namespace Breakout
@inherits Panel
@if ( Game.IsValid() && Game.State != GameState.Menu && Game.Level <= 1 )
{
<root>
<div class="container">
<span class="controls-label">CONTROLS</span>
<div class="column">
<div class="input">
@*There is no mouse/ controller move input hint so we do this.*@
@if(Input.UsingController)
{
<label class="type">Move</label><img src="ui/glyphs/xbox/leftjoystickbutton.svg" class="mouse-glyph" />
}
else
{
<label class="type">Move Paddle</label><img src="ui/glyphs/default/mouse.svg" class="mouse-glyph" />
}
</div>
<div class="input">
<label class="type">Launch</label><InputHint Action="attack1" class="key-glyph" Dark=@true />
</div>
</div>
</div>
</root>
}
@code
{
/// <summary>
/// The game this bar reads score, lives, level and mode from.
/// </summary>
[Parameter] public BreakoutGame Game { get; set; }
// Update the ui when the game state changes or when the input method changes.
protected override int BuildHash() => System.HashCode.Combine(Game?.State, Game?.Mode, Game?.Level, Input.UsingController);
}
@using System
@using Sandbox
@using Sandbox.UI
@namespace Breakout
@inherits Panel
@*
HelpScreen is the "how to play" page you reach from the menu's HELP button. It shows a guide
of every block type and what it does. Pressing BACK runs the Back callback, which tells the
menu to show itself again.
*@
<root>
<div class="help">
<button class="back-btn" onclick=@(() => Back?.Invoke())>
<span class="back-icon">◀</span>
BACK
</button>
<div class="help-body">
<div class="help-title">HELP</div>
<div class="help-sub">Here is what each brick does when you hit it.</div>
<div class="block-grid">
@foreach ( var block in Blocks )
{
<div class="block-card">
<img class="block-icon" src="@block.Icon" />
<div class="block-info">
<div class="block-name">@block.Name</div>
<div class="block-desc">@block.Description</div>
</div>
</div>
}
</div>
</div>
</div>
</root>
@code
{
/// <summary>
/// Called when the player presses BACK, so the menu can show itself again.
/// </summary>
[Parameter] public Action Back { get; set; }
/// <summary>
/// One row in the block guide: the icon to show, the block's name, and a short description.
/// </summary>
private record BlockGuideEntry( string Icon, string Name, string Description );
// The list shown on the help page. Add a new line here to document another block type.
private static readonly BlockGuideEntry[] Blocks =
{
new( "textures/blocks/normal.png", "Brick", "Breaks in a single hit." ),
new( "textures/blocks/armoured.png", "Armoured", "Takes two hits to break." ),
new( "textures/blocks/unbreakable.png", "Unbreakable", "Can't be broken, so play around it." ),
new( "textures/blocks/explosion.png", "Explosive", "Blows up nearby bricks when it breaks." ),
new( "textures/blocks/speed.png", "Fast Ball", "Turns your ball into a faster one." ),
new( "textures/blocks/big_ball.png", "Big Ball", "Makes your ball big enough to smash armour in one hit." ),
new( "textures/blocks/fire_ball.png", "Fireball", "Turns your ball into a fireball that burns straight through bricks." ),
new( "textures/blocks/split.png", "Multi-Ball", "Drops a pickup that splits your ball into more." ),
new( "textures/blocks/expand.png", "Expand", "Drops a pickup that makes your paddle wider." ),
};
}
@using Sandbox;
@using Sandbox.UI;
@using Sandbox.UI.Navigation;
@using Machines.Components;
@using Machines.Resources;
@namespace Machines.UI
@inherits Panel
@{
var flow = LobbyFlow.Current;
var map = flow?.SelectedMap;
var total = Connection.All.Count;
var ready = ReadyCount();
}
<root class="lobby-status @(Visible ? "" : "hidden") @(Input.UsingController ? "controller" : "mouse")" onclick=@GoToPlay>
@if ( Visible )
{
<div class="ls-map">
<div class="ls-thumb-wrap">
<image @ref="ThumbImage" class="ls-thumb" />
@if ( map?.Thumbnail == null )
{
<div class="ls-thumb-empty">NO IMAGE</div>
}
</div>
<div class="ls-map-info">
<span class="ls-eyebrow">IN LOBBY</span>
<span class="ls-title">@(map?.Title ?? "No map")</span>
</div>
</div>
<PlayerSlots class="ls-slots" />
<div class="ls-status">
<span class="ls-state">@StateLabel( flow )</span>
<span class="ls-ready">@ready / @System.Math.Max( total, 1 ) READY</span>
</div>
}
</root>
@code
{
public Sandbox.UI.Image ThumbImage { get; set; }
// Show when a lobby exists, except on /play which shows the full picker.
private bool Visible =>
LobbyFlow.Current is not null
&& MainMenuPanel.Instance?.CurrentUrl != "/play";
public override void Tick()
{
base.Tick();
if ( ThumbImage != null )
ThumbImage.Texture = LobbyFlow.Current?.SelectedMap?.Thumbnail;
StackAbovePatchNotes();
}
// Sit above the patch-notes card; explicit px so the CSS transition works both ways.
private void StackAbovePatchNotes()
{
var revision = FindRootPanel()?.Descendants.OfType<RevisionCard>().FirstOrDefault();
var cardHeight = revision is not null && revision.IsVisible
? revision.Box.Rect.Height * ScaleFromScreen + 14f
: 0f;
// Nudge up on the main menu home page so it clears the home layout.
var menuOffset = MainMenuPanel.Instance?.CurrentUrl == "/home" ? 32f : 0f;
Style.Bottom = Length.Pixels( 56f + cardHeight + menuOffset ); // $deadzone-y (+ card + gap)
}
// Click navigates back to the map selector.
private void GoToPlay()
{
this.Navigate( "/play" );
}
private static string StateLabel( LobbyFlow flow )
{
if ( flow == null )
return "WAITING";
return flow.State switch
{
LobbyState.Countdown => $"STARTING IN {System.Math.Max( 0, (int)System.Math.Ceiling( (float)flow.CountdownEnds ) )}…",
LobbyState.Launching => "STARTING…",
_ => "WAITING"
};
}
private static int ReadyCount()
{
var flow = LobbyFlow.Current;
if ( flow == null )
return 0;
return Connection.All.Count( c => flow.Ready.TryGetValue( c.Id, out var r ) && r );
}
protected override int BuildHash()
{
var flow = LobbyFlow.Current;
var countdownTick = flow?.State == LobbyState.Countdown ? (int)((float)flow.CountdownEnds * 4) : 0;
return System.HashCode.Combine(
Visible,
flow?.SelectedMapIdent,
flow?.State,
Connection.All.Count,
ReadyCount(),
countdownTick,
Input.UsingController );
}
}
@using Sandbox;
@using Sandbox.UI;
@using Machines.Components;
@using Machines.Race;
@using System;
@using System.Collections.Generic;
@namespace Machines.UI
@inherits Panel
@{
EnsureBaked();
var hasMap = _texture != null && _maxExtent > 0.001f && _trackRadius > 0.001f;
var center = new Vector2( 0.5f, 0.5f ); // whole track, centred on the map
var fitRadius = PanelSize * 0.5f - RimMargin; // fit the whole track inside the round clip
var disp = _maxExtent * (fitRadius / _trackRadius); // displayed track-image size in logical px
}
<root class="minimap @(hasMap ? "" : "empty")">
<div class="clip">
@if ( hasMap )
{
var imgLeft = PanelSize * 0.5f - center.x * disp;
var imgTop = PanelSize * 0.5f - center.y * disp;
<image @ref="MapImage" class="map-img"
style="width: @(disp)px; height: @(disp)px; left: @(imgLeft)px; top: @(imgTop)px;" />
@foreach ( var m in GetCheckpointMarkers( center, disp ) )
{
<div class="cp-bar @(m.IsFinish ? "finish" : "")"
style="left: @(m.X)px; top: @(m.Y)px; width: @(m.LengthPx)px; transform: translate(-50%, -50%) rotate(@(m.AngleDeg)deg);"></div>
}
@foreach ( var dot in GetBlipDots( center, disp ) )
{
var c = dot.Color;
var rgb = $"rgb({(int)(c.r * 255)}, {(int)(c.g * 255)}, {(int)(c.b * 255)})";
<div class="dot @dot.Class"
style="left: @(dot.X)px; top: @(dot.Y)px; background-color: @rgb;"></div>
}
}
</div>
</root>
@code
{
/// <summary>
/// Diameter of the minimap in logical px.
/// </summary>
public float PanelSize { get; set; } = 320;
/// <summary>
/// Inset (logical px) from the minimap edge that the track is fit within.
/// </summary>
private const float RimMargin = 32f;
/// <summary>
/// Resolution of the baked track texture (square).
/// </summary>
public int TextureResolution { get; set; } = 1024;
/// <summary>
/// Half-width of the drawn road in world units; tune to match the track mesh.
/// </summary>
public float RoadHalfWidth { get; set; } = 80f;
/// <summary>
/// Outline thickness around the road, in world units.
/// </summary>
public float OutlineWorld { get; set; } = 4f;
private Image MapImage { get; set; }
private Texture _texture;
private RacingLine _bakedFor; // rebake when this changes
private Vector2 _center; // world-XY centre of the baked region
private float _maxExtent; // world units covered by the baked region
private float _trackRadius; // world units from centre to outermost road edge
public override void Tick()
{
base.Tick();
if ( MapImage != null && _texture != null )
MapImage.Texture = _texture;
}
/// <summary>
/// Bakes the track texture once the racing line is available, or rebakes on change.
/// </summary>
private void EnsureBaked()
{
var line = RacingPath.Current?.Optimal;
if ( line is null || !line.IsValid )
return;
if ( _texture != null && _bakedFor == line )
return;
Bake( line, RoadHalfWidth );
_bakedFor = line;
}
private void Bake( RacingLine line, float halfWidth )
{
var res = TextureResolution;
// AA feather sized to ~1 displayed pixel for a smooth outline.
var (_, _, trackRadius) = TrackMapBaker.ComputeBounds( line, halfWidth, OutlineWorld );
var pxPerWorld = (PanelSize * 0.5f - RimMargin) / trackRadius;
var aaW = pxPerWorld > 0.0001f ? 1f / pxPerWorld : 0f;
var bake = TrackMapBaker.Bake( line, halfWidth, OutlineWorld, res, aaW );
if ( bake is null )
return;
_center = bake.Center;
_maxExtent = bake.MaxExtent;
_trackRadius = bake.TrackRadius;
_texture = Texture.Create( res, res )
.WithFormat( ImageFormat.RGBA8888 )
.WithData( bake.Rgba )
.Finish();
}
private struct CpMarker
{
public float X;
public float Y;
public float AngleDeg;
public float LengthPx;
public bool IsFinish;
}
/// <summary>
/// Bar across the track at each checkpoint, oriented to the line tangent.
/// </summary>
private List<CpMarker> GetCheckpointMarkers( Vector2 center, float disp )
{
var list = new List<CpMarker>();
if ( _maxExtent < 0.001f )
return list;
var line = RacingPath.Current?.Optimal;
var half = PanelSize * 0.5f;
var pxPerWorld = disp / _maxExtent;
var roadWidthWorld = RoadHalfWidth * 2f;
var lengthPx = MathF.Max( roadWidthWorld * pxPerWorld * 1.4f, 14f );
foreach ( var cp in Scene.GetAll<Checkpoint>() )
{
if ( !cp.IsValid() )
continue;
var n = WorldToNorm( cp.WorldPosition );
var sx = half + (n.x - center.x) * disp;
var sy = half + (n.y - center.y) * disp;
// Bar is perpendicular to the line tangent.
float angle = 0f;
if ( line is not null && line.IsValid )
{
var d = line.GetDistanceAtPosition( cp.WorldPosition );
var tan = line.GetTangentAtDistance( d );
// North-up map: screenX ~ -worldY, screenY ~ -worldX.
var tsx = -tan.y;
var tsy = -tan.x;
// Perpendicular = bar's long axis.
angle = MathF.Atan2( tsx, -tsy ) * (180f / MathF.PI);
}
list.Add( new CpMarker
{
X = sx,
Y = sy,
AngleDeg = angle,
LengthPx = lengthPx,
IsFinish = cp.IsFinishLine
} );
}
return list;
}
private struct BlipDot
{
public float X;
public float Y;
public Color Color;
public string Class;
public int Priority;
}
private List<BlipDot> GetBlipDots( Vector2 center, float disp )
{
var dots = new List<BlipDot>();
var half = PanelSize * 0.5f;
var maxR = half - 8f; // keep dots inside the round clip
// Off-screen blips clamp to the rim.
Vector2 Project( Vector3 world )
{
var n = WorldToNorm( world );
var off = new Vector2( (n.x - center.x) * disp, (n.y - center.y) * disp );
if ( off.Length > maxR )
off = off.Normal * maxR;
return new Vector2( half + off.x, half + off.y );
}
// GetAllComponents returns only enabled components.
foreach ( var blip in Scene.GetAllComponents<IMinimapBlip>() )
{
if ( blip is not Component c || !c.IsValid() || !blip.ShowOnMinimap )
continue;
var p = Project( c.WorldPosition );
dots.Add( new BlipDot
{
X = p.x,
Y = p.y,
Color = blip.BlipColor,
Class = blip.BlipClass,
Priority = blip.BlipPriority
} );
}
// Draw order: ghost < items < racers < local player.
dots.Sort( ( x, y ) => x.Priority.CompareTo( y.Priority ) );
return dots;
}
/// <summary>
/// Normalized [0,1] texel coords for a world position (north-up).
/// </summary>
private Vector2 WorldToNorm( Vector3 w )
{
// North-up: world +X = screen up, world +Y = screen left.
var nu = 0.5f - (w.y - _center.y) / _maxExtent;
var nv = 0.5f - (w.x - _center.x) / _maxExtent;
return new Vector2( nu, nv );
}
protected override int BuildHash()
{
return HashCode.Combine( _texture != null, Time.Now );
}
}
@using Sandbox;
@using Sandbox.UI;
@using Machines.Player;
@using Machines.Components;
@using Machines.GameModes;
@namespace Machines.UI
@inherits Panel
<root class="score-hud">
@{
var car = VisibleCar();
if ( car is not null )
{
var score = car.Score;
<div class="score-stack" style="opacity: @ComboAlpha( score ).ToString( "0.###", System.Globalization.CultureInfo.InvariantCulture );">
<div class="combo-value">@score.ComboScore</div>
@foreach ( var entry in score.Entries.OrderByDescending( e => e.LastTime ) )
{
<div class="combo-entry">
<span class="entry-source">@entry.Source</span>
<span class="entry-amount">+@entry.Amount</span>
</div>
}
</div>
}
}
</root>
@code
{
// Tail of the combo timeout over which the whole stack fades out (seconds).
private const float FadeTail = 0.4f;
/// <summary>
/// Local car to show, only while a combo is running; null hides the overlay (so it never
/// shows on start, and disappears once the chain lapses). Mirrors CarBoostHud's gating.
/// </summary>
private Car VisibleCar()
{
if ( !BaseGameMode.Current.IsValid() || BaseGameMode.Current.State != GameModeState.Playing )
return null;
if ( Game.ActiveScene?.Get<SpectatorCamera>()?.IsActive ?? false )
return null;
var car = Car.Local;
if ( !car.IsValid() || car.Autopilot )
return null;
if ( !car.Score.IsValid() || !car.Score.IsComboActive )
return null;
return car;
}
// Full opacity until the last FadeTail seconds of the combo, then ramp to zero.
private static float ComboAlpha( CarScore score )
{
var remaining = CarScore.ComboTimeout - score.SinceLastGain;
return MathX.Clamp( remaining / FadeTail, 0f, 1f );
}
protected override int BuildHash() => HashCode.Combine( Time.Now );
}
@using System
@using Sandbox
@using Sandbox.UI
@namespace BrickJam.UI
@inherits PanelComponent
<root>
@if ( Loot.IsValid() )
{
<div class="container">
<span>@Loot.FullName</span>
<div class="row">
<span class="currency">$</span><span>@($"{Loot.MonetaryValue:n0}")</span>
</div>
</div>
}
</root>
@code {
private Loot loot;
private Loot Loot => loot ??= GetComponentInParent<Loot>();
protected override int BuildHash() => HashCode.Combine( Loot?.FullName, Loot?.MonetaryValue ?? 0 );
}
<style>
GroundLootPanel {
transition: transform 0.5s ease-in-out;
justify-content: center;
align-items: center;
width: 100%;
height: 100%;
.container {
padding: 10px;
padding-right: 30px;
padding-left: 30px;
font-size: 32px;
align-items: center;
font-family: "alagard";
flex-direction: column;
color: white;
text-shadow: 3px 3px 0px black;
background: linear-gradient(to left, rgba(black, 0) 0%, rgba(black, 0.5) 20%, rgba(black, 0.5) 80%, rgba(black, 0) 100%);
.currency {
padding-right: 4px;
font-size: 22px;
top: 5px;
color: rgba(50, 205, 50, 1);
}
}
&:outro {
transform: scale(0);
}
&:intro {
transform: scale(0);
}
}
</style>
@using System
@using Sandbox
@using Sandbox.UI
@namespace BrickJam.UI
@inherits PanelComponent
<root>
@{
var player = Player.Local;
var isSpectator = player?.Spectating ?? false;
var isPlayer = player?.IsValid() ?? false;
}
<MoneyCounter />
<Eventlog />
<PlayerCounter />
<StunIndicator />
<QuickPing />
<SubtitlesList />
<Lockpicker />
<BlackScreen />
<div class="input-hints">
@if ( isSpectator )
{
<div class="hint"><inputglyph action="StopFollowing"/>Stop following</div>
<div class="hint"><inputglyph action="FollowNext"/>Next player</div>
<div class="hint"><inputglyph action="FollowPrevious"/>Previous player</div>
}
else
{
<div class="hint"><inputglyph action="inventory"/>Inventory</div>
<div class="hint"><inputglyph action="chat"/>Chat</div>
<div class="hint"><inputglyph action="ping"/>Quick Ping</div>
}
</div>
@if ( player.IsValid() )
{
<Inventory />
<Shop />
<TextInput Ghost="Say something..." class="chat-input" @ref="ChatInput" onsubmit=@OnChatSubmit />
@if ( !player.SeenTips )
{
<TutorialTips />
}
}
@if ( player.IsValid() && player.IsAlive && !LockpickerBus.IsOpen )
{
<div class="crosshair">
<div class="mark">+</div>
<RadialProgress />
<InteractionTip />
</div>
}
@if ( player.IsValid() && !player.IsAlive )
{
<div class="death">
<span class="title">YOU'RE DEAD!</span>
</div>
}
</root>
@code {
private TextInput ChatInput { get; set; }
protected override void OnUpdate()
{
if ( ChatInput is null )
return;
if ( Input.Pressed( "chat" ) )
{
ChatInput.Focus();
ChatInput.AddClass( "visible" );
}
}
private void OnChatSubmit()
{
if ( ChatInput is null )
return;
var text = ChatInput.Text;
if ( !string.IsNullOrWhiteSpace( text ) )
Player.Local?.Say( text );
ChatInput.Text = "";
ChatInput.RemoveClass( "visible" );
}
protected override int BuildHash() => HashCode.Combine(
Player.Local,
Player.Local?.IsAlive ?? false,
LockpickerBus.IsOpen );
}
<style>
Hud {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
align-items: center;
font-family: "alagard";
justify-content: flex-start;
.crosshair {
position: absolute;
top: 0; left: 0;
width: 100%;
height: 100%;
flex-direction: column;
align-items: center;
justify-content: center;
.mark {
color: white;
font-size: 28px;
text-shadow: 2px 2px 0px black;
}
}
.input-hints {
position: absolute;
bottom: 30px;
right: 0px;
align-items: flex-end;
flex-direction: column;
z-index: 3;
.hint {
color: white;
height: 32px;
justify-content: center;
font-size: 32px;
padding-left: 50px;
padding-top: 10px;
padding-bottom: 40px;
padding-right: 10px;
background: linear-gradient(to left, rgba(black, 0.5) 0%, rgba(black, 0.2) 75%, rgba(black, 0) 100%);
margin-top: 5px;
text-shadow: 3px 3px 0px black;
InputGlyph {
width: 32px;
aspect-ratio: 1;
margin-right: 10px;
}
}
}
.chat-input {
position: absolute;
width: 450px;
min-height: 20px;
font-size: 24px;
opacity: 0;
pointer-events: all;
background-color: rgba(37, 64, 98, 1);
box-shadow: 3px 3px 0px 0px rgba(17, 44, 78, 1);
transition: opacity 0.2s ease-in-out, transform 0.2s ease-in-out;
transform: translate(-50% 200px);
text-shadow: 3px 3px 0px black;
left: 50%;
top: 50%;
margin-top: 40px;
&.visible {
opacity: 1;
transform: translate(-50% 0px);
}
}
.death {
position: absolute;
width: 100%;
height: 100%;
left: 0px;
top: 0px;
color: white;
font-size: 32px;
z-index: 2;
backdrop-filter: grayscale(100%);
justify-content: center;
transition: opacity 1s ease-in-out;
opacity: 0;
text-shadow: 4px 4px 0px black;
&.visible {
opacity: 1;
}
.container {
flex-direction: column;
align-items: center;
}
span {
background: linear-gradient(to left, rgba(black, 0) 0%, rgba(black, 0.4) 20%, rgba(black, 0.4) 80%, rgba(black, 0) 100%);
padding: 10px;
padding-left: 40px;
padding-right: 40px;
}
.title {
color: red;
font-size: 64px;
margin-top: 150px;
margin-bottom: 20px;
}
}
}
</style>
@using System
@using Sandbox
@using Sandbox.UI
@using WorldPanel = Sandbox.WorldPanel
@namespace BrickJam.UI
@inherits PanelComponent
<root>
@if ( !IsLocal && Player.IsValid() && Player.IsAlive )
{
<div class="container">
<span>@Name</span>
</div>
}
</root>
@code {
private Player player;
private Player Player => player ??= GetComponentInParent<Player>();
// Don't draw your own nametag in first person.
private bool IsLocal => Player == Player.Local;
private string Name => Player.IsValid() && Player.Network.Owner is { } owner ? owner.DisplayName : "player";
protected override int BuildHash() => HashCode.Combine( IsLocal, Name, Player?.IsAlive ?? false );
}
<style>
Nametag {
transition: transform 0.5s ease-in-out;
justify-content: center;
align-items: center;
width: 100%;
height: 100%;
.container {
padding: 10px;
padding-right: 30px;
padding-left: 30px;
font-size: 32px;
align-items: center;
font-family: "alagard";
flex-direction: column;
color: white;
text-shadow: 3px 3px 0px black;
background: linear-gradient(to left, rgba(black, 0) 0%, rgba(black, 0.5) 20%, rgba(black, 0.5) 80%, rgba(black, 0) 100%);
}
&:outro {
transform: scale(0);
}
&:intro {
transform: scale(0);
}
}
</style>
@using System
@using System.Linq
@using System.Collections.Generic
@using Sandbox
@using Sandbox.UI
@namespace BrickJam.UI
@inherits Panel
<root>
@foreach ( var line in lines )
{
<div class="subtitle">
@if ( !string.IsNullOrEmpty( line.Speaker ) )
{
<span class="speaker">@line.Speaker:</span>
}
<span class="text">@line.Text</span>
</div>
}
</root>
@code {
private readonly List<(string Speaker, string Text, TimeUntil Expire)> lines = new();
public SubtitlesList()
{
SubtitleBus.OnSubtitle += Add;
}
public override void OnDeleted()
{
base.OnDeleted();
SubtitleBus.OnSubtitle -= Add;
}
private void Add( string speaker, string text, float duration )
{
lines.Add( (speaker, text, duration) );
if ( lines.Count > 3 )
lines.RemoveAt( 0 );
}
public override void Tick()
{
lines.RemoveAll( l => l.Expire );
}
protected override int BuildHash() => HashCode.Combine( lines.Count, lines.LastOrDefault().Text );
}
<style>
SubtitlesList {
position: absolute;
left: 0; right: 0;
bottom: 60px;
flex-direction: column;
align-items: center;
.subtitle {
flex-direction: row;
padding: 6px 16px;
margin-top: 4px;
background-color: rgba(0,0,0,0.6);
font-size: 26px;
text-shadow: 2px 2px 0px black;
.speaker { color: rgba(255,220,80,1); margin-right: 8px; }
.text { color: white; }
}
}
</style>
@using System;
@using Sandbox;
@using Sandbox.UI;
@inherits PanelComponent
@namespace Sandbox
<root>
@if(_active)
{
<div class="survival-tut-bg"></div>
<div class="survival-tut-card">
<div class="tut-step-count">@(_step + 1) / @_steps.Length</div>
<div class="survival-tut-title">@_steps[_step].Title</div>
<div class="survival-tut-text">@_steps[_step].Text</div>
@if(_steps[_step].ShowGridDemo)
{
<div class="survival-demo-grid">
...
</div>
<div class="survival-demo-caption">@_demoCaption</div>
}
<button class="survival-tut-close" onclick=@NextStep>
@(_step == _steps.Length - 1 ? "▶ Let's Survive!" : "Got it →")
</button>
</div>
}
</root>
@code {
public static SurvivalTutorial Instance { get; private set; }
public bool IsActive => _active;
bool _active = false;
int _step = 0;
struct SurvivalTutStep
{
public string Title;
public string Text;
public bool ShowGridDemo;
}
static readonly SurvivalTutStep[] _steps = new SurvivalTutStep[]
{
new SurvivalTutStep {
Title = "Welcome to Survival! ⚡",
Text = "No waves here — chests spawn continuously and slide downward. Your goal: last as long as possible.",
ShowGridDemo = false
},
new SurvivalTutStep {
Title = "It Gets Faster... 🔥",
Text = "Chests spawn every couple seconds at first. After about a minute, spawn rate speeds up and more chests appear at once. Stay sharp — it never really stops.",
ShowGridDemo = false
},
new SurvivalTutStep {
Title = "Block the Slide! 🛡️",
Text = "Here's the key trick: a placed bomb blocks a chest from sliding into its column. Use this to buy time and set up bigger chain reactions before detonating.",
ShowGridDemo = true
},
new SurvivalTutStep {
Title = "Survive as Long as You Can 💀",
Text = "One chest reaching the bottom row ends your run. Place smart, chain big, and see how long you can hold on!",
ShowGridDemo = false
},
};
// ── Mini grid demo animation state ──
float _demoTimer = 0f;
int _demoPhase = 0; // 0=chest falling, 1=bomb placed blocks it, 2=hold
string _demoCaption = "A chest is about to slide down...";
protected override void OnStart()
{
Instance = this;
_active = false;
}
public void Activate()
{
_step = 0;
_active = true;
_demoPhase = 0;
_demoTimer = 0f;
StateHasChanged();
}
protected override void OnUpdate()
{
if (!_active || !_steps[_step].ShowGridDemo) return;
_demoTimer += Time.Delta;
// Simple 3-phase loop: chest falls (0-1.2s) -> bomb blocks it (1.2-2.4s) -> hold (2.4-3.6s) -> reset
if (_demoTimer < 1.2f)
{
_demoPhase = 0;
_demoCaption = "A chest slides down each cycle...";
}
else if (_demoTimer < 2.4f)
{
_demoPhase = 1;
_demoCaption = "...but a placed bomb blocks it from moving further!";
}
else if (_demoTimer < 3.6f)
{
_demoPhase = 2;
_demoCaption = "Now you have time to plan your chain reaction.";
}
else
{
_demoTimer = 0f;
}
StateHasChanged();
}
// Simple fixed layout: chest at (0,1) falling toward bomb at (2,1)
string GetDemoCellClass(int r, int c)
{
if (c != 1) return "empty";
if (_demoPhase == 0)
{
// chest animates from row 0 toward row 1
if (r == 0) return "chest";
if (r == 2) return "bomb";
return "empty";
}
else
{
// blocked — chest sits right above the bomb, doesn't pass
if (r == 1) return "chest";
if (r == 2) return "bomb";
return "empty";
}
}
string GetDemoCellIcon(int r, int c)
{
var cls = GetDemoCellClass(r, c);
return cls switch {
"chest" => "🎁",
"bomb" => "💣",
_ => ""
};
}
void NextStep()
{
_step++;
Sound.Play("UI_Button");
if (_step >= _steps.Length)
{
_active = false;
ChainReactionGame.Instance?.UnfreezeSurvivalSpawn(); // new public method
StateHasChanged();
return;
}
_demoTimer = 0f;
_demoPhase = 0;
StateHasChanged();
}
protected override int BuildHash() =>
System.HashCode.Combine(_active, _step, _demoPhase);
}@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 Sandbox;
@using Sandbox.UI;
@namespace Coilgarden
@inherits Panel
@attribute [StyleSheet( "/UI/GameUi.razor.scss" )]
@*
The live readout: score and best, and nothing else. Deliberately small - everything else
the player needs during a run is in the arena itself, and length lives in the game-over
summary instead of here because it is interesting after a run and noise during one.
Refreshes itself off its own Tick(), independently of whichever overlay GameUi has on top
of it, so a paused or finished run still shows a live-looking score without dragging the
rest of the interface through a rebuild to do it.
*@
<root class="readout">
<div class="@ScoreClass">@ScoreText</div>
<div class="best">
<span class="best-label">BEST</span>
<span class="best-value">@BestText</span>
</div>
</root>
@code
{
[Parameter] public GameSession Session { get; set; }
private string ScoreText { get; set; } = "0";
private string BestText { get; set; } = "0";
/// <summary>Carries the pop class while the score is counting, built here rather than in
/// markup so the attribute is never a mix of literal text and an @@expression.</summary>
private string ScoreClass { get; set; } = "score";
/// <summary>
/// The score as it is being shown, which chases the real one rather than jumping to it.
/// A number that counts up turns a state change into an event the eye can follow, and it
/// is the difference between the score being a readout and the score being the reward.
/// Kept as a float so the chase is smooth, and always floored so the player never sees a
/// number higher than what they have actually earned.
/// </summary>
private float shownScore;
private int lastRealScore;
/// <summary>Seconds since the score last changed, driving the pop.</summary>
private float scoreAge = 99f;
public override void Tick()
{
base.Tick();
var run = Session?.Run;
if ( run is null ) return;
TickScore( run.Score );
ScoreText = ((int)shownScore).ToString();
BestText = (Session.Records?.BestScore ?? 0).ToString();
}
/// <summary>
/// Advances the displayed score towards the real one and drives the pop.
/// <para>
/// A restart drops the target to zero, and that is the one case where counting is wrong -
/// watching your score tick down to nothing is a strange thing to do to somebody who just
/// asked to play again, so it snaps instead.
/// </para>
/// </summary>
private void TickScore( int real )
{
if ( real != lastRealScore )
{
if ( real < lastRealScore ) shownScore = real;
lastRealScore = real;
scoreAge = 0f;
}
scoreAge += Time.Delta;
// Rate comes from the size of the gap, so an award of any size lands in about the same
// time rather than a big one taking noticeably longer.
var gap = MathF.Abs( real - shownScore );
if ( gap > 0.01f )
{
var rate = MathF.Max( gap, 1f ) / GameConfig.ScoreCountDuration;
shownScore = shownScore < real
? MathF.Min( shownScore + rate * Time.Delta, real )
: MathF.Max( shownScore - rate * Time.Delta, real );
}
else
{
shownScore = real;
}
ScoreClass = scoreAge < GameConfig.ScoreCountDuration ? "score popped" : "score";
}
/// <summary>
/// The counting score is folded in as its whole number, so the panel redraws once per
/// digit change rather than every frame.
/// </summary>
protected override int BuildHash() => System.HashCode.Combine( (int)shownScore, ScoreClass, BestText );
}
@using Sandbox;
@using Sandbox.UI;
@namespace Coilgarden
@inherits Panel
@attribute [StyleSheet( "/UI/GameUi.razor.scss" )]
@*
The title screen. A keyboard player can skip it entirely by just pressing a direction -
GameSession takes that as both "start" and the first turn - so the buttons here exist for
the mouse player and for reaching Settings, not because the game waits on them.
*@
<root class="scrim">
<div class="card">
<div class="wordmark">Coilgarden</div>
<div class="tagline">A small garden. One apple at a time.</div>
<div class="menu">
<div [email protected] class="btn primary" onclick=@Play>Play</div>
<div [email protected] class="btn" onclick=@OpenSettingsClicked>Settings</div>
</div>
<div class="prompt">or press a direction to begin</div>
@if ( HasRecord )
{
<div class="records">
<div class="records-title">Best run</div>
<div class="record-row"><span>Score</span><span>@BestScoreText</span></div>
<div class="record-row"><span>Length</span><span>@BestLengthText</span></div>
<div class="record-row"><span>Runs played</span><span>@RunsText</span></div>
</div>
}
</div>
</root>
@code
{
[Parameter] public GameSession Session { get; set; }
[Parameter] public Action SettingsRequested { get; set; }
private HighScoreData Records => Session?.Records;
private bool HasRecord => Records is { RunsPlayed: > 0 };
private string BestScoreText => (Records?.BestScore ?? 0).ToString();
private string BestLengthText => (Records?.BestLength ?? 0).ToString();
private string RunsText => (Records?.RunsPlayed ?? 0).ToString();
private void Play()
{
UiSound.Click();
Session?.StartRun();
}
private void OpenSettingsClicked()
{
UiSound.Click();
SettingsRequested?.Invoke();
}
protected override int BuildHash() => System.HashCode.Combine(
Records?.BestScore, Records?.BestLength, Records?.RunsPlayed );
}
@using System;
@using Sandbox;
@using Sandbox.Game.Menu
@using Sandbox.UI;
@inherits PanelComponent
@namespace Sandbox
<root>
@if(_active)
{
@* ── Debug card ── *@
@if(_debugMode)
{
<div class="tut-debug-card">
<div class="tut-debug-title">MM TUT — Step @_step</div>
<div class="tut-debug-row">
<span>BL: @_debugBL.ToString("F1")%</span>
<button class="tut-debug-btn" onclick=@(() => { _debugBL -= 1f; StateHasChanged(); })>◀</button>
<button class="tut-debug-btn" onclick=@(() => { _debugBL += 1f; StateHasChanged(); })>▶</button>
</div>
<div class="tut-debug-row">
<span>BY: @_debugBY.ToString("F0")px</span>
<button class="tut-debug-btn" onclick=@(() => { _debugBY -= 10f; StateHasChanged(); })>▲</button>
<button class="tut-debug-btn" onclick=@(() => { _debugBY += 10f; StateHasChanged(); })>▼</button>
</div>
<div class="tut-debug-row">
<span>BR: @_debugBR.ToString("F1")%</span>
<button class="tut-debug-btn" onclick=@(() => { _debugBR -= 1f; StateHasChanged(); })>◀</button>
<button class="tut-debug-btn" onclick=@(() => { _debugBR += 1f; StateHasChanged(); })>▶</button>
</div>
<div class="tut-debug-row">
<span>HX: @_debugHX.ToString("F1")%</span>
<button class="tut-debug-btn" onclick=@(() => { _debugHX -= 1f; StateHasChanged(); })>◀</button>
<button class="tut-debug-btn" onclick=@(() => { _debugHX += 1f; StateHasChanged(); })>▶</button>
</div>
<div class="tut-debug-row">
<span>HY: @_debugHY.ToString("F0")px</span>
<button class="tut-debug-btn" onclick=@(() => { _debugHY -= 10f; StateHasChanged(); })>▲</button>
<button class="tut-debug-btn" onclick=@(() => { _debugHY += 10f; StateHasChanged(); })>▼</button>
</div>
<div class="tut-debug-row">
<span>HW: @_debugHW.ToString("F0")px</span>
<button class="tut-debug-btn" onclick=@(() => { _debugHW -= 10f; StateHasChanged(); })>◀</button>
<button class="tut-debug-btn" onclick=@(() => { _debugHW += 10f; StateHasChanged(); })>▶</button>
</div>
<div class="tut-debug-row">
<span>HH: @_debugHH.ToString("F0")px</span>
<button class="tut-debug-btn" onclick=@(() => { _debugHH -= 10f; StateHasChanged(); })>▲</button>
<button class="tut-debug-btn" onclick=@(() => { _debugHH += 10f; StateHasChanged(); })>▼</button>
</div>
<button class="tut-debug-log" onclick=@LogCurrentValues>📋 Log</button>
</div>
}
@* ── Dim layer ── *@
<div class="tut-dim"></div>
@* ── Highlight ── *@
@if(_step < _steps.Length && _steps[_step].HW > 0)
{
var s = _steps[_step];
float hx = _debugHX != 0f ? _debugHX : s.HX;
float hy = _debugHY != 0f ? _debugHY : s.HY;
float hw = _debugHW != 0f ? _debugHW : s.HW;
float hh = _debugHH != 0f ? _debugHH : s.HH;
<div class="tut-highlight"
style="left:@hx.ToString("F1")%;top:@hy.ToString("F0")px;width:@hw.ToString("F0")px;height:@hh.ToString("F0")px;">
</div>
}
@* ── Instruction box ── *@
@if(_step < _steps.Length)
{
var s = _steps[_step];
float bl = _debugBL != 0f ? _debugBL : s.BL;
float br = _debugBR != 0f ? _debugBR : s.BR;
float by = _debugBY != 0f ? _debugBY : s.BY;
float boxWidthPct = s.BW > 0f ? (s.BW / 1920f * 100f) : (380f / 1920f * 100f);
string posStyle = br != 0f
? $"left:{(100f - br - boxWidthPct).ToString("F1")}%;"
: $"left:{bl.ToString("F1")}%;";
string boxStyle = $"{posStyle}top:{by.ToString("F0")}px;{(s.BW > 0f ? $"width:{s.BW.ToString("F0")}px;" : "")}";
<div class="tut-box" style="@boxStyle">
<div class="tut-step-count">@(_step + 1) / @_steps.Length</div>
<div class="tut-title">@s.Title</div>
<div class="tut-text">@s.Text</div>
@* Action hint — shown when player must act *@
@if(s.Trigger == MMTutTrigger.HeartPurchased)
{
<div class="tut-action-hint">👆 @s.ActionHint</div>
}
@* Next button — shown on Any steps *@
@if(s.Trigger == MMTutTrigger.Any)
{
<button class="tut-btn-next" onclick=@NextStep>Got it →</button>
}
@* Final step — Play button *@
@if(s.Trigger == MMTutTrigger.Done)
{
<button class="tut-btn-next" onclick=@OnPlayAndComplete>▶ Let's play!</button>
}
</div>
}
}
</root>
@code {
public static MainMenuTutorial Instance { get; private set; }
public bool IsActive => _active;
bool _active = false;
public int Step => _step;
int _step = 0;
// ── Debug mode ──────────────────────────────────────────────────── //
const bool _debugMode = false; // set false before release
float _debugBL = 0f;
float _debugBR = 0f;
float _debugBY = 0f;
float _debugHX = 0f;
float _debugHY = 0f;
float _debugHW = 0f;
float _debugHH = 0f;
void LogCurrentValues()
{
Log.Info($"// MM Step {_step} — {_steps[_step].Title}");
Log.Info($"BL={_debugBL:F1}f, BY={_debugBY:F0}f, BR={_debugBR:F1}f,");
Log.Info($"HX={_debugHX:F1}f, HY={_debugHY:F0}f, HW={_debugHW:F0}f, HH={_debugHH:F0}f");
}
// ── Trigger types ────────────────────────────────────────────────── //
public enum MMTutTrigger
{
Any, // advances via Got it button
HeartPurchased, // advances when player buys Heart consumable
Done, // final step — shows Play button
}
// ── Step data ────────────────────────────────────────────────────── //
public struct MMTutStep
{
public string Title;
public string Text;
public string ActionHint;
public MMTutTrigger Trigger;
public float HX, HY, HW, HH; // highlight
public float BL, BR, BY, BW; // box position
}
// ── Steps ────────────────────────────────────────────────────────── //
// Positions are placeholder — tune with debug card in-game
static readonly MMTutStep[] _steps = new MMTutStep[]
{
// Step 1 — Welcome
new MMTutStep {
Title = "Welcome to Chain Reaction!",
Text = "Here's a quick tour of how the game works. It'll take 30 seconds.",
Trigger = MMTutTrigger.Any,
HW = 0,
BL = 47f, BY = 370f
},
// Step 2 — Play button
new MMTutStep {
Title = "Play runs to earn 💠 Fragments",
Text = "Fragments are the game's currency. The further you go each run, the more you earn.",
Trigger = MMTutTrigger.Any,
HW = 0,
BL = 47f, BY = 370f
},
// Step 3 — Shop → Modifiers
new MMTutStep {
Title = "The Shop — Modifiers 🎲",
Text = "Spend fragments to unlock Modifiers. These permanently strengthen every run you play. Unlock once, keep forever.",
Trigger = MMTutTrigger.Any,
HX = 40f, HY = 200f, HW = 110f, HH = 40f,
BR = 61f, BY = 120f
},
// Step 4 — Consumables explanation
new MMTutStep {
Title = "Consumables 🎁",
Text = "Consumables are single-use items you bring into a run. Buy them once, use them at any time during a run. A bar will appear in-game when you own one.",
Trigger = MMTutTrigger.Any,
HX = 46f, HY = 200f, HW = 130f, HH = 40f,
BR = 61f, BY = 110
},
// Step 5 — Buy Heart (action step)
new MMTutStep {
Title = "Get yourself a Heart ❤️",
Text = "We've added 100 💠 to your balance. Buy the Heart consumable — it gives you an extra life mid-run when you need it most.",
ActionHint = "Click the buy button on the Heart card",
Trigger = MMTutTrigger.HeartPurchased,
HX = 65f, HY = 860f, HW = 80f, HH = 70f,
BR = 7f, BY = 800f
},
// Step 6 — Pet tab
new MMTutStep {
Title = "Your secret package 📦",
Text = "Play 3 runs to unlock what's inside. This section is for cosmetic skins — coming soon.",
Trigger = MMTutTrigger.Any,
HX = 53f, HY = 360f, HW = 70f, HH = 40f,
BR = 50f, BY = 340f
},
// Step 7 — Bombs
new MMTutStep {
Title = "Unlock new Bombs 💣",
Text = "Different bomb types open up new chain reaction strategies. Mix and match to find your playstyle.",
Trigger = MMTutTrigger.Any,
HX = 57f, HY = 220f, HW = 90f, HH = 40f,
BR = 5f, BY = 130f
},
// Step 8 — Leaderboard
new MMTutStep {
Title = "Compete Globally 🏅",
Text = "The leaderboard tracks your best score, chain reactions, and pet level. How far can you go?",
Trigger = MMTutTrigger.Any,
HX = 75f, HY = 400f, HW = 10f, HH = 10f,
BR = 5f, BY = 160f
},
// Step 9 — Done
new MMTutStep {
Title = "You're ready. 💥",
Text = "Play runs → earn fragments → unlock things → become stronger. Good luck!",
Trigger = MMTutTrigger.Done,
BR = 20f, BY = 370f
},
};
// ── Lifecycle ────────────────────────────────────────────────────── //
protected override void OnStart()
{
Instance = this;
_active = false;
}
public void Activate(PlayerSave save)
{
//var save = ChainReactionGame.Save ?? PlayerSave.Load();
if (save?.MenuTutorialCompleted == true) return;
_step = save?.MenuTutorialStep ?? 0;
_active = true;
ResetDebug();
// If resuming at step 4 (Buy Heart) but heart already purchased, skip it
if (_step == 4 && (save?.GetConsumableCount(ConsumableId.Heart) ?? 0) > 0)
_step = 5;
// Step 3 requires shop/mods to be visible — navigate there
ApplySideEffect(_step);
StateHasChanged();
}
// ── Called from MainMenu when Heart is bought ────────────────────── //
public void OnHeartPurchased()
{
if (!_active || _step >= _steps.Length) return;
if (_steps[_step].Trigger != MMTutTrigger.HeartPurchased) return;
NextStep();
}
// ── Step advancement ─────────────────────────────────────────────── //
void NextStep()
{
_step++;
ResetDebug();
if (_step >= _steps.Length)
{
Complete();
return;
}
// Persist current step so we can resume after crash - save tuto step
var save = PlayerSave.Load();
if (save != null)
{
save.MenuTutorialStep = _step;
save.Save();
}
ApplySideEffect(_step);
Sound.Play("UI_Button");
StateHasChanged();
}
// ── Side effects — navigate tabs automatically ───────────────────── //
void ApplySideEffect(int step)
{
var mm = MainMenu.Instance;
if (mm == null) return;
switch(step)
{
case 2: // Show shop modifiers
mm.SetTabPublic("shop");
mm.SetShopTabPublic("mods");
// Grant frags on step before heart purchase
break;
case 3: // Show consumables
mm.SetTabPublic("shop");
mm.SetShopTabPublic("consumables");
break;
case 4: // Buy Heart — shop consumables + grant frags if not already granted
var save = ChainReactionGame.Save ?? PlayerSave.Load();
if (save != null && !save.TutorialHeartFragsGranted)
{
save.TotalFragments += 100;
save.TutorialHeartFragsGranted = true;
save.Save();
}
mm.SetTabPublic("shop");
mm.SetShopTabPublic("consumables");
mm.ReloadSave();
break;
case 5: // Pet tab
mm.SetTabPublic("shop");
mm.SetShopTabPublic("pets");
break;
case 6: // Bombs
mm.SetTabPublic("shop");
mm.SetShopTabPublic("bombs");
break;
case 7: // Leaderboard
mm.SetTabPublic("lb");
break;
case 8: // Done — no navigation needed
break;
}
}
void Skip() => Complete();
void OnPlayAndComplete()
{
Complete();
MainMenu.Instance?.StartGamePublic();
}
void Complete()
{
_active = false;
// Get save from MainMenu directly
var mm = MainMenu.Instance;
// just use ChainReactionGame.Save or load fresh
var save = PlayerSave.Load();
if (save != null)
{
save.MenuTutorialCompleted = true;
save.MenuTutorialStep = 0;
save.Save();
}
StateHasChanged();
}
void ResetDebug()
{
_debugBL = 0f; _debugBR = 0f; _debugBY = 0f;
_debugHX = 0f; _debugHY = 0f; _debugHW = 0f; _debugHH = 0f;
}
public void ForceReset()
{
_active = false;
_step = 0;
ResetDebug();
StateHasChanged();
}
protected override int BuildHash() =>
System.HashCode.Combine(_active, _step, _debugBL, _debugBY, _debugHX, _debugHY, _debugHW, _debugHH);
}@using Sandbox;
@using Sandbox.UI;
@using System;
@using System.Linq;
@using System.Collections.Generic;
@inherits PanelComponent
@namespace Sandbox
@*
IMPORTANT — s&box text rendering quirk.
Razor compiles a literal containing non-ASCII characters (emoji, ×, ·, —)
into AddMarkupContent, and plain-ASCII literals into AddContent. The panel
renderer hoists every AddMarkupContent node ahead of every AddContent node,
so "@A × @B" comes out as "×AB".
The rule this file follows: an element holds EITHER pure literal text OR a
single @expression. Anything composed goes through a helper below.
*@
<root>
@if ( Visible )
{
<div class="idle-root">
@* ══════════════════════ AMBIENT BACKDROP ══════════════════════ *@
<div class="idle-bg"></div>
<div class="idle-bg-glow @GlowClass()"></div>
@for ( int i = 0; i < 12; i++ )
{
float pLeft = (i * 8.3f) % 100f;
float pDur = 10f + (i * 0.9f) % 8f;
float pDel = (i * 1.4f) % 12f;
int pSz = (i % 4 == 0) ? 4 : 2;
string pCol = (i % 3 == 0) ? "rgba(94,234,212,0.45)" : "rgba(96,165,250,0.30)";
<div class="idle-particle"
style="left:@pLeft.ToString("F1")%;width:@(pSz)px;height:@(pSz)px;background-color:@pCol;animation-duration:@pDur.ToString("F1")s;animation-delay:@pDel.ToString("F1")s;"></div>
}
@* ══════════════════════════ TOP BAR ══════════════════════════ *@
<div class="idle-top @TutDim("top")">
<button class="idle-back" onclick=@OnBack>⌂</button>
<button class="idle-help @HelpClass()" onclick=@OnStartTutorial>?</button>
<div class="idle-brand">
<div class="idle-brand-title">THE REACTOR</div>
<div class="idle-brand-sub">chain reactions, forever</div>
</div>
<div class="idle-currencies">
<div class="idle-cur cores">
<span class="idle-cur-icon">⚛️</span>
<span class="idle-cur-val">@CoresLabel()</span>
<span class="idle-cur-lbl">Cores</span>
</div>
<div class="idle-cur rate">
<span class="idle-cur-icon">📈</span>
<span class="idle-cur-val">@RateLabel()</span>
<span class="idle-cur-lbl">per sec</span>
</div>
<div class="idle-cur flux">
<span class="idle-cur-icon">🔷</span>
<span class="idle-cur-val">@FluxLabel()</span>
<span class="idle-cur-lbl">Flux</span>
</div>
<div class="idle-cur frag">
<span class="idle-cur-icon">💠</span>
<span class="idle-cur-val">@FragLabel()</span>
<span class="idle-cur-lbl">Frags</span>
</div>
</div>
</div>
@* ═══════════════════════════ BODY ════════════════════════════ *@
@* Hidden behind a modal — the panel renderer draws emoji glyphs above
overlay panels, so the board has to actually leave the tree. *@
@if ( !ModalOpen )
{
<div class="idle-body">
@* ─────────────────────── LEFT COLUMN ─────────────────────── *@
<div class="idle-col idle-left @TutDim("left")">
@* ── Daily contracts ──
First card in the column on purpose: it is the only part of
the mode with a deadline, so it has to be the first thing a
returning player's eye lands on. *@
<div class="idle-card idle-card-contracts @ContractsCardClass()">
<div class="idle-card-hd row">
<span class="idle-card-hd-txt">DAILY CONTRACTS</span>
<span class="idle-card-hd-side">@ContractResetLabel()</span>
</div>
@for ( int ci = 0; ci < IdleContracts.DailyCount; ci++ )
{
int slot = ci;
var contract = IdleContracts.At( S, slot );
if ( contract != null )
{
<div class="idle-contract @ContractClass( slot )" onclick=@(() => OnClaimContract( slot ))>
<span class="idle-contract-icon">@ContractIcon( slot )</span>
<div class="idle-contract-body">
<div class="idle-contract-top">
<span class="idle-contract-name">@contract.Name</span>
<span class="idle-contract-reward">@ContractRewardLabel( slot )</span>
</div>
<div class="idle-contract-desc">@ContractDescLabel( slot )</div>
<div class="idle-contract-track">
<div class="idle-contract-fill" style="width:@Pct( IdleContracts.Fraction( S, slot ) )%;"></div>
</div>
<div class="idle-contract-prog">@ContractProgLabel( slot )</div>
</div>
</div>
}
}
<div class="idle-card-note">@ContractFootLabel()</div>
</div>
<div class="idle-card idle-card-output">
<div class="idle-card-hd">OUTPUT</div>
<div class="idle-stat-row">
<span class="idle-stat-lbl">Cores / sec</span>
<span class="idle-stat-val hot">@RateLabel()</span>
</div>
<div class="idle-stat-row">
<span class="idle-stat-lbl">Global multiplier</span>
<span class="idle-stat-val">@GlobalMultLabel()</span>
</div>
<div class="idle-stat-row">
<span class="idle-stat-lbl">Chain exponent</span>
<span class="idle-stat-val">@ExponentLabel()</span>
</div>
<div class="idle-stat-row">
<span class="idle-stat-lbl">Last chain</span>
<span class="idle-stat-val">@LastChainLabel()</span>
</div>
<div class="idle-stat-row">
<span class="idle-stat-lbl">Best chain</span>
<span class="idle-stat-val">@BestChainLabel()</span>
</div>
<div class="idle-stat-row">
<span class="idle-stat-lbl">Board</span>
<span class="idle-stat-val">@BoardLabel()</span>
</div>
<div class="idle-stat-row">
<span class="idle-stat-lbl">Feed line</span>
<span class="idle-stat-val @FeedClass()">@FeedLabel()</span>
</div>
</div>
@* ── Next goal ──
The Reactor Log is four clicks away in the right panel, which
means the long-tail goals are invisible exactly when they are
supposed to be pulling. This pins the closest one. *@
@if ( NextGoal != null )
{
<div class="idle-card idle-card-goal">
<div class="idle-card-hd">NEXT GOAL</div>
<div class="idle-goal-row">
<span class="idle-goal-icon">@NextGoal.Icon</span>
<div class="idle-goal-body">
<div class="idle-goal-name">@NextGoal.Name</div>
<div class="idle-goal-desc">@NextGoal.Desc</div>
</div>
</div>
<div class="idle-bar-track">
<div class="idle-bar-fill goal" style="width:@Pct( NextGoal.Fraction( S ) )%;"></div>
</div>
<div class="idle-goal-foot">
<span class="idle-goal-prog">@NextGoalProgLabel()</span>
<span class="idle-goal-reward">@NextGoalRewardLabel()</span>
</div>
</div>
}
@if ( ShowBoosts )
{
<div class="idle-card idle-card-active">
<div class="idle-card-hd">ACTIVE</div>
@if ( R.SurgeActive )
{
<div class="idle-boost surge">
<span class="idle-boost-icon">⚡</span>
<span class="idle-boost-lbl">@SurgeBoostLabel()</span>
<span class="idle-boost-time">@SecondsLabel( R.SurgeTimeLeft )</span>
</div>
}
@foreach ( var boost in R.Boosts )
{
var b = boost;
<div class="idle-boost">
<span class="idle-boost-icon">@b.Icon</span>
<span class="idle-boost-lbl">@BoostLabel( b )</span>
<span class="idle-boost-time">@SecondsLabel( b.SecondsLeft )</span>
</div>
}
</div>
}
<div class="idle-card idle-card-condenser">
<div class="idle-card-hd">CONDENSER</div>
<div class="idle-bar-track">
<div class="idle-bar-fill frag" style="width:@Pct( R.FragmentProgressFraction )%;"></div>
</div>
<div class="idle-card-note">@CondenserLabel()</div>
<div class="idle-stat-row">
<span class="idle-stat-lbl">Today</span>
<span class="idle-stat-val @CapClass()">@FragTodayLabel()</span>
</div>
</div>
<div class="idle-card">
<div class="idle-card-hd">MANIFEST</div>
@for ( int ti = 0; ti < IdleBalance.Tiers.Count; ti++ )
{
var tier = IdleBalance.Tiers[ti];
bool got = R.TierUnlocked( ti );
<div class="idle-tier @(got ? "on" : "")">
<span class="idle-tier-icon">@tier.Icon</span>
<div class="idle-tier-body">
<div class="idle-tier-name">@tier.Name</div>
<div class="idle-tier-blurb">@TierBlurb( ti, got )</div>
</div>
</div>
}
</div>
@* Rank card — neighbours, not a top-N. See RebuildRankRows(). *@
<div class="idle-card">
<div class="idle-card-hd">REACTOR RANK</div>
@if ( _rankRows.Count == 0 )
{
<div class="idle-card-note">@RankEmptyLabel()</div>
}
else
{
@foreach ( var row in _rankRows )
{
var rw = row;
<div class="idle-rank-row @RankRowClass( rw )">
<span class="idle-rank-pos">@rw.RankLabel</span>
<span class="idle-rank-name">@rw.Name</span>
<span class="idle-rank-val">@rw.ValueLabel</span>
</div>
}
<div class="idle-card-note">@GapLabel()</div>
@if ( _rankClimb > 0 )
{
<div class="idle-card-note up">@ClimbLabel()</div>
}
}
</div>
</div>
@* ────────────────────── CENTER COLUMN ────────────────────── *@
<div class="idle-col idle-center">
<div class="idle-board-wrap @TutDim("board")">
<div class="idle-board @SurgeClass() @CascadeClass() @BoardShake()">
@for ( int r = 0; r < R.Size; r++ )
{
<div class="idle-row">
@for ( int c = 0; c < R.Size; c++ )
{
int row = r;
int col = c;
var cell = R.Board[row, col];
bool golden = R.GoldenActive && R.GoldenRow == row && R.GoldenCol == col;
<div class="idle-cell @CellClass( cell, golden )"
style="width:@(CellPx)px;height:@(CellPx)px;"
onclick=@(() => OnCellClick( row, col ))>
@if ( golden )
{
<div class="idle-golden">🌟</div>
<div class="idle-golden-timer">@GoldenTimerLabel()</div>
}
@if ( !golden && cell.HasChest )
{
<div class="idle-chest" style="@ChestStyle( cell )">@ChestGlyph( cell )</div>
}
@if ( !golden && cell.HasChest && cell.MaxHits > 1 )
{
<div class="idle-hits">@cell.HitsLeft</div>
}
@if ( cell.HasDrone )
{
<div class="idle-drone" style="@DroneStyle( cell )">💣</div>
}
</div>
}
</div>
}
</div>
@foreach ( var floater in _floaters )
{
var f = floater;
<div class="idle-float @f.Cls"
style="left:@f.X.ToString("F1")%;top:@f.Y.ToString("F1")%;opacity:@f.Alpha.ToString("F2");transform:translateY(@f.Rise.ToString("F0")px) scale(@f.Scale.ToString("F2"));">
@f.Text
</div>
}
@if ( _calloutLife > 0f )
{
<div class="idle-callout @_calloutCls" style="opacity:@_calloutLife.ToString("F2");">@_calloutText</div>
}
</div>
<div class="idle-controls @TutDim("controls")">
<button class="idle-overload @OverloadClass()" onclick=@OnOverload>
<div class="idle-overload-fill" style="width:@OverloadFillPct()%;"></div>
<span class="idle-overload-label">@OverloadLabel()</span>
<span class="idle-overload-sub">@OverloadSubLabel()</span>
</button>
<div class="idle-surge-wrap">
<div class="idle-surge-track @SurgeReadyClass()">
<div class="idle-surge-fill" style="width:@Pct( R.Charge )%;"></div>
<span class="idle-surge-lbl">@SurgeLabel()</span>
</div>
@if ( R.SurgeReady )
{
<button class="idle-surge-btn" onclick=@OnSurge>@UnleashLabel()</button>
}
</div>
</div>
<div class="idle-meltdown-strip @MeltdownStripClass() @TutDim("meltdown")">
<span class="idle-melt-icon">☢️</span>
<span class="idle-melt-text">@MeltdownLabel()</span>
@if ( R.CanMeltdown )
{
<button class="idle-melt-btn" onclick=@OnOpenMeltdown>REVIEW</button>
}
@if ( !R.CanMeltdown )
{
<div class="idle-melt-bar">
<div class="idle-melt-fill" style="width:@Pct( R.MeltdownProgress )%;"></div>
</div>
}
</div>
@* Only appears once the perk tree is most of the way bought — the
layer is meaningless before that and just clutters the column. *@
@if ( R.SingularityVisible )
{
<div class="idle-sing-strip @SingStripClass() @TutDim("meltdown")">
<span class="idle-melt-icon">✴️</span>
<span class="idle-melt-text">@SingularityLabel()</span>
@if ( R.CanSingularity )
{
<button class="idle-sing-btn" onclick=@OnOpenSingularity>COLLAPSE</button>
}
@if ( !R.CanSingularity )
{
<div class="idle-melt-bar">
<div class="idle-sing-fill" style="width:@Pct( R.SingularityProgress )%;"></div>
</div>
}
</div>
}
</div>
@* ─────────────────────── RIGHT COLUMN ────────────────────── *@
<div class="idle-col idle-right @TutDim("right")">
<div class="idle-tabs">
<button class="idle-tab @TabClass("reactor")" onclick=@(() => SetTab("reactor"))>⚙️</button>
<button class="idle-tab @TabClass("chains")" onclick=@(() => SetTab("chains"))>⛓️</button>
<button class="idle-tab @TabClass("systems")" onclick=@(() => SetTab("systems"))>🛠️</button>
<button class="idle-tab fluxtab @TabClass("flux")" onclick=@(() => SetTab("flux"))>🔷</button>
@if ( R.SingularityVisible )
{
<button class="idle-tab singtab @TabClass("powers")" onclick=@(() => SetTab("powers"))>✴️</button>
}
<button class="idle-tab @TabClass("log")" onclick=@(() => SetTab("log"))>📜</button>
</div>
@if ( IsUpgradeTab )
{
<div class="idle-buyrow">
<span class="idle-buyrow-lbl">BUY</span>
@foreach ( var amount in BuyAmounts )
{
int amt = amount;
<button class="idle-buy-amt @BuyAmtClass( amt )" onclick=@(() => SetBuyAmount( amt ))>@BuyAmountLabel( amt )</button>
}
</div>
}
<div class="idle-panel">
@if ( IsUpgradeTab )
{
@foreach ( var upgrade in IdleUpgradeCatalog.InCategory( CurrentCategory ) )
{
var up = upgrade;
bool revealed = R.IsRevealed( up );
int lvl = S.Level( up.Id );
bool maxed = lvl >= up.MaxLevel;
int count = revealed ? BuyCount( up.Id ) : 0;
double cost = (!revealed || maxed) ? 0 : R.CostFor( up.Id, count );
bool afford = revealed && !maxed && count > 0 && Cores >= cost;
<div class="idle-up @UpgradeClass( revealed, maxed, afford )" onclick=@(() => OnBuy( up.Id ))>
<div class="idle-up-icon">@UpgradeIcon( up, revealed )</div>
<div class="idle-up-body">
<div class="idle-up-top">
<span class="idle-up-name">@UpgradeName( up, revealed )</span>
<span class="idle-up-lvl">@LevelLabel( up, lvl, revealed )</span>
</div>
<div class="idle-up-desc">@UpgradeDesc( up, revealed )</div>
@if ( revealed )
{
<div class="idle-up-bottom">
<span class="idle-up-effect">@up.Effect( lvl )</span>
<span class="idle-up-cost @MaxedClass( maxed )">@CostLabel( maxed, cost, count )</span>
</div>
}
</div>
</div>
}
}
@if ( _tab == "flux" )
{
<div class="idle-flux-head">
<div class="idle-flux-amount">@FluxHeadLabel()</div>
<div class="idle-flux-sub">@FluxSubLabel()</div>
</div>
@foreach ( var perk in VisiblePerks() )
{
var p = perk;
int lvl = S.PerkLevel( p.Id );
bool maxed = lvl >= p.MaxLevel;
int cost = p.CostAt( lvl );
bool afford = !maxed && S.Flux >= cost;
<div class="idle-up perk @UpgradeClass( true, maxed, afford )" onclick=@(() => OnBuyPerk( p.Id ))>
<div class="idle-up-icon">@p.Icon</div>
<div class="idle-up-body">
<div class="idle-up-top">
<span class="idle-up-name">@p.Name</span>
<span class="idle-up-lvl">@PerkLevelLabel( p, lvl )</span>
</div>
<div class="idle-up-desc">@p.Desc</div>
<div class="idle-up-bottom">
<span class="idle-up-effect">@p.Effect( lvl )</span>
<span class="idle-up-cost flux @MaxedClass( maxed )">@PerkCostLabel( maxed, cost )</span>
</div>
</div>
</div>
}
}
@if ( _tab == "powers" )
{
<div class="idle-flux-head sing">
<div class="idle-flux-amount">@ShardHeadLabel()</div>
<div class="idle-flux-sub">@ShardSubLabel()</div>
</div>
@foreach ( var power in IdleUpgradeCatalog.Powers )
{
var pw = power;
int lvl = S.PowerLevel( pw.Id );
bool maxed = lvl >= pw.MaxLevel;
int cost = pw.CostAt( lvl );
bool afford = !maxed && S.Shards >= cost;
<div class="idle-up power @UpgradeClass( true, maxed, afford )" onclick=@(() => OnBuyPower( pw.Id ))>
<div class="idle-up-icon">@pw.Icon</div>
<div class="idle-up-body">
<div class="idle-up-top">
<span class="idle-up-name">@pw.Name</span>
<span class="idle-up-lvl">@PowerLevelLabel( pw, lvl )</span>
</div>
<div class="idle-up-desc">@pw.Desc</div>
<div class="idle-up-bottom">
<span class="idle-up-effect">@pw.Effect( lvl )</span>
<span class="idle-up-cost shard @MaxedClass( maxed )">@PowerCostLabel( maxed, cost )</span>
</div>
</div>
</div>
}
}
@if ( _tab == "log" )
{
<div class="idle-flux-head">
<div class="idle-flux-amount">@LogHeadLabel()</div>
<div class="idle-flux-sub">Each one pays out once.</div>
</div>
@foreach ( var achievement in IdleAchievements.All )
{
var a = achievement;
bool done = S.HasAchievement( a.Id );
<div class="idle-up ach @(done ? "done" : "")">
<div class="idle-up-icon">@AchIcon( a, done )</div>
<div class="idle-up-body">
<div class="idle-up-top">
<span class="idle-up-name">@a.Name</span>
<span class="idle-ach-reward">@RewardLabel( a )</span>
</div>
<div class="idle-up-desc">@a.Desc</div>
@if ( !done )
{
<div class="idle-ach-bar">
<div class="idle-ach-fill" style="width:@Pct( a.Fraction( S ) )%;"></div>
</div>
<div class="idle-ach-prog">@ProgressLabel( a )</div>
}
</div>
</div>
}
}
</div>
</div>
</div>
}
@* ═════════════════════════ TOASTS ═════════════════════════ *@
<div class="idle-toasts">
@foreach ( var toast in _toasts )
{
var t = toast;
<div class="idle-toast @t.Cls" style="opacity:@t.Alpha.ToString("F2");">
<span class="idle-toast-icon">@t.Icon</span>
<span class="idle-toast-text">@t.Text</span>
</div>
}
</div>
@* ═══════════════════ WELCOME BACK OVERLAY ═══════════════════ *@
@if ( R.OfflinePending )
{
<div class="idle-modal-bg"></div>
<div class="idle-modal offline">
<div class="idle-modal-eyebrow">REACTOR REPORT</div>
<div class="idle-modal-title">Welcome back</div>
<div class="idle-modal-sub">@OfflineSubLabel()</div>
<div class="idle-offline-haul">
<div class="idle-offline-amount">@OfflineAmountLabel()</div>
<div class="idle-offline-lbl">CORES</div>
</div>
@if ( R.PendingOfflineFrags > 0 )
{
<div class="idle-offline-extra">@OfflineFragLabel()</div>
}
@if ( OfflineTankFull )
{
<div class="idle-offline-warn">@OfflineWarnLabel()</div>
}
<button class="idle-modal-btn" onclick=@OnClaimOffline>COLLECT</button>
</div>
}
@* ═══════════════════ DAILY STREAK OVERLAY ═══════════════════ *@
@if ( !R.OfflinePending && !_showIntro && R.DailyPending )
{
<div class="idle-modal-bg"></div>
<div class="idle-modal daily">
<div class="idle-modal-eyebrow">DAILY CYCLE</div>
<div class="idle-modal-title">@DailyTitleLabel()</div>
<div class="idle-modal-sub">Come back tomorrow to keep the streak climbing. Day 7 pays eight times.</div>
<div class="idle-streak-row">
@for ( int d = 1; d <= 7; d++ )
{
int day = d;
<div class="idle-streak-pip @StreakPipClass( day )">@day</div>
}
</div>
<div class="idle-daily-reward">
<div class="idle-daily-mult">@DailyMultLabel()</div>
<div class="idle-daily-lbl">@DailyRewardLabel()</div>
</div>
<button class="idle-modal-btn" onclick=@OnClaimDaily>CLAIM</button>
</div>
}
@* ══════════════════════ MELTDOWN MODAL ══════════════════════ *@
@if ( !R.OfflinePending && !_showIntro && !R.DailyPending && _showMeltdown )
{
<div class="idle-modal-bg"></div>
<div class="idle-modal meltdown">
<div class="idle-modal-eyebrow">PRESTIGE</div>
<div class="idle-modal-title">MELTDOWN</div>
<div class="idle-modal-sub">Collapse this cycle into permanent Flux. Every Flux you have ever earned adds income forever, and Flux buys perks that survive every reset.</div>
<div class="idle-melt-payout">
<div class="idle-melt-payout-val">@MeltPayoutLabel()</div>
<div class="idle-melt-payout-lbl">FLUX</div>
</div>
@* The Flux number alone does not answer the only question that
matters at this button — "am I actually going to be faster
afterwards". This does. *@
<div class="idle-melt-gain">
<span class="idle-melt-gain-from">@MeltMultBeforeLabel()</span>
<span class="idle-melt-gain-arrow">→</span>
<span class="idle-melt-gain-to">@MeltMultAfterLabel()</span>
<span class="idle-melt-gain-lbl">permanent income</span>
</div>
<div class="idle-melt-cols">
<div class="idle-melt-col keep">
<div class="idle-melt-col-hd">KEPT</div>
<div class="idle-melt-line">Flux and every perk</div>
<div class="idle-melt-line">Reactor Log entries</div>
<div class="idle-melt-line">Records and Fragments</div>
<div class="idle-melt-line">Kickstart head start</div>
</div>
<div class="idle-melt-col lose">
<div class="idle-melt-col-hd">BURNED</div>
<div class="idle-melt-line">@BurnedCoresLabel()</div>
<div class="idle-melt-line">All Core upgrades</div>
<div class="idle-melt-line">Reactor Frame size</div>
<div class="idle-melt-line">Unlocked chest tiers</div>
</div>
</div>
<div class="idle-modal-btns">
<button class="idle-modal-btn ghost" onclick=@OnCloseMeltdown>Not yet</button>
<button class="idle-modal-btn danger" onclick=@OnMeltdown>MELT DOWN</button>
</div>
</div>
}
@* ════════════════════ SINGULARITY MODAL ════════════════════ *@
@if ( !R.OfflinePending && !_showIntro && !R.DailyPending && !_showMeltdown && _showSingularity )
{
<div class="idle-modal-bg"></div>
<div class="idle-modal singularity">
<div class="idle-modal-eyebrow">SECOND PRESTIGE</div>
<div class="idle-modal-title">SINGULARITY</div>
<div class="idle-modal-sub">Collapse your entire Flux career into Shards. Shards buy Powers — and Powers change how a cycle runs, not just how much it pays.</div>
<div class="idle-melt-payout">
<div class="idle-sing-payout-val">@SingPayoutLabel()</div>
<div class="idle-melt-payout-lbl">SHARDS</div>
</div>
<div class="idle-melt-cols">
<div class="idle-melt-col keep">
<div class="idle-melt-col-hd">KEPT</div>
<div class="idle-melt-line">Shards and every Power</div>
<div class="idle-melt-line">Reactor Log entries</div>
<div class="idle-melt-line">Records, Fragments, pet</div>
<div class="idle-melt-line">@ShardKeepLabel()</div>
</div>
<div class="idle-melt-col lose">
<div class="idle-melt-col-hd">COLLAPSED</div>
<div class="idle-melt-line">@BurnedFluxLabel()</div>
<div class="idle-melt-line">Every Flux perk</div>
<div class="idle-melt-line">All Core upgrades</div>
<div class="idle-melt-line">This cycle's Cores</div>
</div>
</div>
<div class="idle-modal-btns">
<button class="idle-modal-btn ghost" onclick=@OnCloseSingularity>Not yet</button>
<button class="idle-modal-btn sing" onclick=@OnSingularity>COLLAPSE</button>
</div>
</div>
}
@* ════════════════════════ INTRO MODAL ═══════════════════════ *@
@if ( !R.OfflinePending && _showIntro )
{
<div class="idle-modal-bg"></div>
<div class="idle-modal intro">
<div class="idle-modal-eyebrow">HOW IT WORKS</div>
<div class="idle-modal-title">THE REACTOR</div>
<div class="idle-modal-sub">A board that plays itself. Chests drop, drones bomb them, chain reactions pay Cores. You never lose here — you only grow.</div>
<div class="idle-intro-steps">
<div class="idle-intro-step">
<span class="idle-intro-num">1</span>
<span class="idle-intro-txt">Spend Cores on the right. Longer chains are worth far more than richer chests.</span>
</div>
<div class="idle-intro-step">
<span class="idle-intro-num">2</span>
<span class="idle-intro-txt">Hit OVERLOAD, or press SPACE, for a burst. It charges the Surge meter — ten times output when you unleash it.</span>
</div>
<div class="idle-intro-step">
<span class="idle-intro-num">3</span>
<span class="idle-intro-txt">It keeps running while you are on the menu or another screen. Close the game and it stops.</span>
</div>
<div class="idle-intro-step">
<span class="idle-intro-num">4</span>
<span class="idle-intro-txt">Cores condense into Fragments, which feed your companion for a permanent output bonus. Meltdown turns a whole cycle into Flux.</span>
</div>
</div>
<div class="idle-modal-btns">
<button class="idle-modal-btn ghost" onclick=@OnCloseIntro>I'll figure it out</button>
<button class="idle-modal-btn" onclick=@OnIntroTutorial>SHOW ME HOW</button>
</div>
</div>
}
@* ═════════════════════ GUIDED WALKTHROUGH ═════════════════════
Coach marks over the live HUD. The board keeps running underneath —
an idle game that freezes for its own tutorial teaches the wrong
thing about what the game is. Everything here is measured from the
real panels at runtime, so it cannot drift out of sync with layout.
*@
@if ( TutorialActive )
{
<div class="rt-layer">
@* Four quads around the spotlight, rather than one dim sheet with
a hole — the panel renderer has no masking, so this is how you
get a real cut-out that still swallows clicks everywhere else. *@
<div class="rt-dim" style="@DimTopStyle()"></div>
<div class="rt-dim" style="@DimBottomStyle()"></div>
<div class="rt-dim" style="@DimLeftStyle()"></div>
<div class="rt-dim" style="@DimRightStyle()"></div>
@if ( TutHasSpot )
{
<div class="rt-ring" style="@SpotStyle()"></div>
<div class="rt-spot" style="@SpotStyle()">
<div class="rt-corner tl"></div>
<div class="rt-corner tr"></div>
<div class="rt-corner bl"></div>
<div class="rt-corner br"></div>
</div>
}
@* The class alternates per step purely so the entrance animation
replays — a stable class string would only ever play once. *@
<div class="rt-card @TutCardAnimClass()" style="@CardStyle()">
<div class="rt-card-top">
<span class="rt-eyebrow">HOW TO PLAY</span>
<span class="rt-count">@TutCountLabel()</span>
</div>
<div class="rt-title">@TutTitleLabel()</div>
<div class="rt-text">@TutTextLabel()</div>
<div class="rt-dots">
@for ( int di = 0; di < IdleTutorial.Count; di++ )
{
<div class="rt-dot @DotClass( di )"></div>
}
</div>
@if ( TutWaitingOnAction )
{
<div class="rt-hint">
<span class="rt-hint-pip">👆</span>
<span class="rt-hint-txt">@TutHintLabel()</span>
</div>
}
@if ( !TutWaitingOnAction )
{
<button class="rt-next" onclick=@OnTutNext>@TutNextLabel()</button>
}
<div class="rt-foot">
<button class="rt-back @TutBackClass()" onclick=@OnTutBack>Back</button>
<button class="rt-skip" onclick=@OnTutSkip>Skip the tour</button>
</div>
</div>
</div>
}
</div>
}
</root>
@code {
public static IdleHud Instance { get; private set; }
IdleReactor R => IdleReactor.Instance;
ChainReactionGame Game => ChainReactionGame.Instance;
IdleSave S => ChainReactionGame.Save?.Idle;
bool Visible => Game?.GameStarted == true
&& Game?.Mode == GameMode.Idle
&& R != null && S != null;
double Cores => S?.Cores ?? 0;
bool ModalOpen => R.OfflinePending || R.DailyPending || _showMeltdown || _showSingularity || _showIntro;
bool ShowBoosts => R.Boosts.Count > 0 || R.SurgeActive;
bool OfflineTankFull => R.PendingOfflineSeconds > R.OfflineCapHours * 3600f;
// ── UI state ──────────────────────────────────────────────────
string _tab = "reactor";
int _buyAmount = 1; // -1 == MAX
bool _showMeltdown;
bool _showSingularity;
bool _showIntro;
bool _subscribed;
bool _wasVisible;
static readonly int[] BuyAmounts = { 1, 10, 25, -1 };
bool IsUpgradeTab => _tab == "reactor" || _tab == "chains" || _tab == "systems";
IdleUpgradeCategory CurrentCategory => _tab switch
{
"reactor" => IdleUpgradeCategory.Reactor,
"chains" => IdleUpgradeCategory.Chains,
_ => IdleUpgradeCategory.Systems,
};
// ── Floating numbers ──────────────────────────────────────────
class Floater
{
public string Text;
public string Cls;
public float X, Y;
public float Life, MaxLife;
public float Alpha => MathF.Min( 1f, Life / 0.35f );
public float Rise => -(MaxLife - Life) * 42f;
public float Scale => 0.85f + MathF.Min( 0.35f, (MaxLife - Life) * 1.2f );
}
readonly List<Floater> _floaters = new();
class ToastMsg
{
public string Icon;
public string Text;
public string Cls;
public float Life;
public float Alpha => MathF.Min( 1f, Life / 0.4f );
}
readonly List<ToastMsg> _toasts = new();
string _calloutText = "";
string _calloutCls = "";
float _calloutLife;
// Board shake is kept local rather than going through ScreenShaker so the
// class string stays stable between rebuilds — a changing class would
// restart the animation on every repaint and read as a vibration.
string _shakeCls = "";
float _shakeUntil;
// ══════════════════════════════════════════════════════════════
// Guided walkthrough
//
// Coach marks measured off the real panels every frame, so the spotlight
// cannot drift when the board resizes, the window changes shape, or a card
// appears above the one being pointed at. The previous tutorial in this
// codebase hard-coded pixel rectangles and needed a debug nudge UI to keep
// them honest; this one has nothing to keep honest.
// ══════════════════════════════════════════════════════════════
bool _tutRunning;
int _tutStep;
float _tutStepAt; // Time.Now the step opened, for the action grace period
// Spotlight rect and card position, in design pixels relative to the root.
float _spotX, _spotY, _spotW, _spotH;
float _cardX, _cardY;
float _rootW = 1920f, _rootH = 1080f;
bool _spotFound;
// Measured on the frame after the card first renders; the fallback only
// ever shows for that single frame.
float _cardW = 380f, _cardH = 240f;
const float CardGap = 26f; // spotlight → card
const float EdgePad = 20f; // card → screen edge
const float ActionGrace = 0.45f; // the click that opened a step cannot also close it
/// <summary>
/// Set by the menu's "How to play" button. Consumed on the frame the HUD
/// becomes visible, since the tour points at panels that do not exist until
/// then.
/// </summary>
public static bool PendingTutorial { get; set; }
public bool TutorialActive => _tutRunning && Visible && !ModalOpen;
bool TutHasSpot => _spotFound && _spotW > 1f && _spotH > 1f;
IdleTutStep CurrentTutStep
=> _tutStep >= 0 && _tutStep < IdleTutorial.Count ? IdleTutorial.Steps[_tutStep] : null;
bool TutWaitingOnAction
=> CurrentTutStep != null && CurrentTutStep.Trigger != IdleTutTrigger.Button;
public void StartTutorial()
{
_tutRunning = true;
_tutStep = 0;
_tutStepAt = Time.Now;
_showIntro = false;
_spotFound = false;
MeasureTutorial();
StateHasChanged();
}
void EndTutorial( bool completed )
{
_tutRunning = false;
if ( S != null )
{
S.TutorialOffered = true;
if ( completed ) S.TutorialDone = true;
ChainReactionGame.Save?.Save();
}
StateHasChanged();
}
void OnTutNext()
{
Sound.Play( "UI_Button" );
AdvanceTutorial();
}
void OnTutBack()
{
if ( _tutStep <= 0 ) return;
Sound.Play( "UI_Button" );
_tutStep--;
_tutStepAt = Time.Now;
MeasureTutorial();
StateHasChanged();
}
void OnTutSkip()
{
Sound.Play( "UI_Button" );
EndTutorial( completed: true );
}
void AdvanceTutorial()
{
_tutStep++;
_tutStepAt = Time.Now;
if ( _tutStep >= IdleTutorial.Count )
{
Sound.Play( "PlayButton" );
EndTutorial( completed: true );
return;
}
MeasureTutorial();
StateHasChanged();
}
/// <summary>
/// Satisfies a step that is waiting on the player doing something. The grace
/// period stops the very click that advanced onto a step from also
/// advancing off it — buying an upgrade lands on the "buy an upgrade" step
/// otherwise, and the player never reads it.
/// </summary>
void TutSatisfy( IdleTutTrigger trigger )
{
if ( !_tutRunning ) return;
var step = CurrentTutStep;
if ( step == null || step.Trigger != trigger ) return;
if ( Time.Now - _tutStepAt < ActionGrace ) return;
Sound.Play( "Notification_1" );
AdvanceTutorial();
}
/// <summary>Recursive class lookup rather than Descendants, so the first match
/// found is the shallowest one — an inner panel sharing a class cannot win.</summary>
static Sandbox.UI.Panel FindByClass( Sandbox.UI.Panel from, string cls )
{
if ( from == null || string.IsNullOrEmpty( cls ) ) return null;
if ( from.HasClass( cls ) ) return from;
foreach ( var child in from.Children )
{
var hit = FindByClass( child, cls );
if ( hit != null ) return hit;
}
return null;
}
void MeasureTutorial()
{
var root = Panel;
if ( root == null ) return;
float scale = root.ScaleFromScreen;
var rr = root.Box.Rect;
_rootW = MathF.Max( 320f, rr.Width * scale );
_rootH = MathF.Max( 240f, rr.Height * scale );
var step = CurrentTutStep;
_spotFound = false;
if ( step != null && !string.IsNullOrEmpty( step.Target ) )
{
var target = FindByClass( root, step.Target );
if ( target != null )
{
var tr = target.Box.Rect;
if ( tr.Width > 1f && tr.Height > 1f )
{
_spotX = (tr.Left - rr.Left) * scale - step.Pad;
_spotY = (tr.Top - rr.Top ) * scale - step.Pad;
_spotW = tr.Width * scale + step.Pad * 2f;
_spotH = tr.Height * scale + step.Pad * 2f;
// A column that runs off the bottom of the viewport reports a
// rect taller than the screen; clamping keeps the dim quads
// from collapsing to nothing.
_spotX = Math.Clamp( _spotX, 0f, _rootW - 8f );
_spotY = Math.Clamp( _spotY, 0f, _rootH - 8f );
_spotW = Math.Clamp( _spotW, 8f, _rootW - _spotX );
_spotH = Math.Clamp( _spotH, 8f, _rootH - _spotY );
_spotFound = true;
}
}
}
// The card measures itself once it exists; until then the constructor
// defaults are close enough that nothing visibly jumps.
var card = FindByClass( root, "rt-card" );
if ( card != null )
{
var cr = card.Box.Rect;
if ( cr.Width > 1f && cr.Height > 1f )
{
_cardW = cr.Width * scale;
_cardH = cr.Height * scale;
}
}
PlaceCard( step );
}
void PlaceCard( IdleTutStep step )
{
if ( step == null || !TutHasSpot )
{
_cardX = (_rootW - _cardW) * 0.5f;
_cardY = (_rootH - _cardH) * 0.5f;
return;
}
float cx = _spotX + _spotW * 0.5f;
float cy = _spotY + _spotH * 0.5f;
var place = step.Place;
// Fall back to the other side when the requested one has no room. This
// is what makes the script survive an ultrawide or a small window
// without every step needing a per-aspect-ratio override.
if ( place == IdleTutPlace.Right && _spotX + _spotW + CardGap + _cardW > _rootW - EdgePad )
place = IdleTutPlace.Left;
else if ( place == IdleTutPlace.Left && _spotX - CardGap - _cardW < EdgePad )
place = IdleTutPlace.Right;
else if ( place == IdleTutPlace.Above && _spotY - CardGap - _cardH < EdgePad )
place = IdleTutPlace.Below;
else if ( place == IdleTutPlace.Below && _spotY + _spotH + CardGap + _cardH > _rootH - EdgePad )
place = IdleTutPlace.Above;
switch ( place )
{
case IdleTutPlace.Left:
_cardX = _spotX - CardGap - _cardW;
_cardY = cy - _cardH * 0.5f;
break;
case IdleTutPlace.Right:
_cardX = _spotX + _spotW + CardGap;
_cardY = cy - _cardH * 0.5f;
break;
case IdleTutPlace.Above:
_cardX = cx - _cardW * 0.5f;
_cardY = _spotY - CardGap - _cardH;
break;
case IdleTutPlace.Below:
_cardX = cx - _cardW * 0.5f;
_cardY = _spotY + _spotH + CardGap;
break;
default:
_cardX = (_rootW - _cardW) * 0.5f;
_cardY = (_rootH - _cardH) * 0.5f;
break;
}
_cardX = Math.Clamp( _cardX, EdgePad, MathF.Max( EdgePad, _rootW - _cardW - EdgePad ) );
_cardY = Math.Clamp( _cardY, EdgePad, MathF.Max( EdgePad, _rootH - _cardH - EdgePad ) );
}
// ── Inline styles ─────────────────────────────────────────────
// Ints only. A decimal separator from a French locale would be a comma and
// would silently kill the whole declaration.
static string Px( float v ) => ((int)MathF.Round( v )).ToString();
string SpotStyle()
=> $"left:{Px( _spotX )}px;top:{Px( _spotY )}px;width:{Px( _spotW )}px;height:{Px( _spotH )}px;";
string CardStyle() => $"left:{Px( _cardX )}px;top:{Px( _cardY )}px;";
// With no spotlight the top quad covers everything and the other three
// collapse — which is exactly the plain dim sheet a centred step wants.
string DimTopStyle()
=> TutHasSpot
? $"left:0px;top:0px;width:{Px( _rootW )}px;height:{Px( _spotY )}px;"
: $"left:0px;top:0px;width:{Px( _rootW )}px;height:{Px( _rootH )}px;";
string DimBottomStyle()
{
if ( !TutHasSpot ) return "width:0px;height:0px;";
float top = _spotY + _spotH;
return $"left:0px;top:{Px( top )}px;width:{Px( _rootW )}px;height:{Px( _rootH - top )}px;";
}
string DimLeftStyle()
=> TutHasSpot
? $"left:0px;top:{Px( _spotY )}px;width:{Px( _spotX )}px;height:{Px( _spotH )}px;"
: "width:0px;height:0px;";
string DimRightStyle()
{
if ( !TutHasSpot ) return "width:0px;height:0px;";
float left = _spotX + _spotW;
return $"left:{Px( left )}px;top:{Px( _spotY )}px;width:{Px( _rootW - left )}px;height:{Px( _spotH )}px;";
}
// ── Labels ────────────────────────────────────────────────────
string TutTitleLabel() => CurrentTutStep?.Title ?? "";
string TutTextLabel() => CurrentTutStep?.Text ?? "";
string TutHintLabel() => CurrentTutStep?.Hint ?? "";
string TutCountLabel() => $"{_tutStep + 1} / {IdleTutorial.Count}";
string TutNextLabel()
=> _tutStep >= IdleTutorial.Count - 1 ? "RUN THE REACTOR" : "GOT IT";
string TutBackClass() => _tutStep <= 0 ? "hidden" : "";
string TutCardAnimClass() => (_tutStep % 2) == 0 ? "in-a" : "in-b";
/// <summary>
/// Dims a HUD block that is not the subject of the current step.
///
/// The dim quads are click-blockers and a background scrim; they cannot do
/// this job on their own, because this renderer draws emoji above overlay
/// panels and every chest, icon and tab glyph would shine straight through
/// the scrim at full brightness. Ancestor opacity takes the glyphs with it.
/// </summary>
string TutDim( string region )
{
if ( !TutorialActive ) return "";
string want = CurrentTutStep?.Region ?? "";
if ( string.IsNullOrEmpty( want ) ) return ""; // centred step dims nothing
return region == want ? "" : "tut-dim";
}
string DotClass( int i )
{
if ( i == _tutStep ) return "on";
return i < _tutStep ? "done" : "";
}
string HelpClass() => _tutRunning ? "on" : "";
int CellPx => R == null ? 92 : R.Size switch
{
5 => 96,
6 => 84,
7 => 74,
8 => 66,
_ => 59,
};
// ══════════════════════════════════════════════════════════════
// ══════════════════════════════════════════════════════════════
// Reactor rank card
//
// Deliberately NOT a top-N board. A top-N motivates whoever is already in
// it and is scenery for everyone else; the crossable goal is the gap to the
// player directly above, so the card shows a window around the local player
// (two up, you, one down).
//
// The board snapshot is stale between refreshes but S.AllTimeCores is live,
// so the local row and the gap are always computed from the local value.
// That makes the gap tick down while the player watches, which is the whole
// point of the card.
// ══════════════════════════════════════════════════════════════
class RankRow
{
public string RankLabel;
public string Name;
public string ValueLabel;
public bool IsMe;
}
readonly List<RankRow> _rankRows = new();
Sandbox.Services.Leaderboards.Board2 _rankBoard;
bool _rankLoading;
bool _rankFetched;
float _rankNextFetch;
int _myRank; // 1-based, 0 == not on the board
double _nextUpValue; // stat value of the player directly above, 0 == none above
int _rankClimb; // places gained since the last session
int _rankTotalPlayers; // Board2.TotalEntries — real population, not window size
const float RankRefreshSeconds = 90f;
// Same stat the main-menu leaderboard ranks on, so the two can never disagree.
static string RankStat => CommunityGoal.CoresStat;
/// <summary>
/// Network call — only ever driven by the throttle in OnUpdate, never from
/// the render path. BuildHash changes many times a second, so fetching from
/// there would hammer the backend into rate-limiting us.
/// </summary>
async System.Threading.Tasks.Task LoadRank()
{
if ( _rankLoading ) return;
_rankLoading = true;
try
{
var b = Sandbox.Services.Leaderboards.GetFromStat( RankStat );
b.SetAggregationMax();
b.SetSortDescending();
// CenterOnMe is exactly what this card wants: Board2 returns the
// entries surrounding a target player, so asking for a small window
// centred on us gives the neighbours directly. No manual slicing.
b.CenterOnMe();
b.MaxEntries = 5;
await b.Refresh();
_rankBoard = b;
_rankTotalPlayers = (int)b.TotalEntries;
Log.Info( $"Rank '{RankStat}' — window: {b.Entries?.Length ?? -1}, board total: {b.TotalEntries}" );
}
catch ( Exception e )
{
Log.Warning( $"Reactor rank: {e.Message}" );
}
_rankLoading = false;
_rankFetched = true;
RebuildRankRows();
StateHasChanged();
}
void RebuildRankRows()
{
_rankRows.Clear();
_myRank = 0;
_nextUpValue = 0;
_rankClimb = 0;
var entries = _rankBoard?.Entries;
if ( entries == null || entries.Length == 0 ) return;
// CenterOnMe means these indices are window-relative, NOT board-relative,
// so ranks must come from Entry.Rank. Using index+1 here would label the
// window "#1..#5" no matter where the player actually sits.
long myId = Sandbox.Connection.Local?.SteamId ?? 0;
int meIdx = -1;
for ( int i = 0; i < entries.Length; i++ )
{
if ( entries[i].SteamId == myId ) { meIdx = i; break; }
}
if ( meIdx < 0 )
{
// No entry for us yet — show the window we were given so there is
// still a named target to chase rather than an empty card.
for ( int i = 0; i < entries.Length; i++ )
_rankRows.Add( MakeRankRow( entries[i], false ) );
_nextUpValue = entries[entries.Length - 1].Value;
return;
}
_myRank = (int)entries[meIdx].Rank;
int start = Math.Max( 0, meIdx - 2 );
int end = Math.Min( entries.Length - 1, meIdx + 1 );
for ( int i = start; i <= end; i++ )
_rankRows.Add( MakeRankRow( entries[i], i == meIdx ) );
if ( meIdx > 0 ) _nextUpValue = entries[meIdx - 1].Value;
// Places gained since we last recorded a rank. Persisting this is what
// turns offline production into "I moved up while I was away".
int prev = S?.LastReactorRank ?? 0;
if ( prev > 0 && _myRank > 0 && _myRank < prev ) _rankClimb = prev - _myRank;
if ( S != null && _myRank > 0 && S.LastReactorRank != _myRank )
{
S.LastReactorRank = _myRank;
ChainReactionGame.Save?.Save();
}
}
RankRow MakeRankRow( Sandbox.Services.Leaderboards.Board2.Entry e, bool isMe )
{
// The snapshot lags our own earnings, so never show the player a number
// lower than what they have actually banked.
double val = isMe
? Math.Max( e.Value, S?.AllTimeCores ?? 0 )
: e.Value;
return new RankRow
{
RankLabel = "#" + e.Rank.ToString(),
Name = isMe ? "you" : RankName( e ),
ValueLabel = IdleFormat.Short( val ),
IsMe = isMe,
};
}
string RankName( Sandbox.Services.Leaderboards.Board2.Entry e )
{
if ( !string.IsNullOrWhiteSpace( e.DisplayName ) ) return e.DisplayName;
try
{
var friend = new Sandbox.Friend( e.SteamId );
if ( !string.IsNullOrWhiteSpace( friend.Name ) ) return friend.Name;
}
catch { }
return "Anonymous";
}
string RankRowClass( RankRow r ) => r.IsMe ? "me" : "";
string RankEmptyLabel()
{
if ( !_rankFetched ) return "Reading the board";
return "Produce Cores to enter the rankings";
}
string GapLabel()
{
if ( _nextUpValue <= 0 ) return "Top of the board";
double mine = S?.AllTimeCores ?? 0;
double gap = _nextUpValue - mine;
if ( gap <= 0 ) return "Ahead — board updates shortly";
return IdleFormat.Short( gap ) + " Cores to pass";
}
string ClimbLabel()
{
if ( _rankClimb == 1 ) return "Up 1 place since last time";
return "Up " + _rankClimb.ToString() + " places since last time";
}
// Lifecycle
// ══════════════════════════════════════════════════════════════
protected override void OnStart()
{
Instance = this;
}
protected override void OnUpdate()
{
if ( !_subscribed && R != null ) Subscribe();
bool vis = Visible;
if ( vis != _wasVisible )
{
_wasVisible = vis;
if ( vis )
{
_floaters.Clear();
_toasts.Clear();
_calloutLife = 0f;
_shakeUntil = 0f;
_showMeltdown = false;
_showSingularity = false;
// Offered on the walkthrough flag rather than IntroShown, so a
// save from before the walkthrough existed still gets asked
// once instead of being silently skipped past it.
_showIntro = S != null && !S.TutorialDone && !S.TutorialOffered;
_tutRunning = false;
_tab = "reactor";
_rankNextFetch = 0f; // refetch on entering idle mode
_nextGoalAt = 0f;
RefreshNextGoal();
if ( PendingTutorial )
{
PendingTutorial = false;
StartTutorial();
}
}
StateHasChanged();
}
if ( !vis ) return;
// Wall-clock throttle. Never move this into BuildHash or the render path.
if ( RealTime.Now >= _rankNextFetch )
{
_rankNextFetch = RealTime.Now + RankRefreshSeconds;
_ = LoadRank();
}
// SPACE is the detonate key everywhere else in the game, so it fires the
// reactor's manual burst here too.
if ( Input.Pressed( "Jump" ) && R.OverloadReady && !ModalOpen )
R.Overload();
if ( RealTime.Now >= _nextGoalAt )
{
_nextGoalAt = RealTime.Now + 0.5f;
RefreshNextGoal();
}
// Re-measured every frame rather than only on step change: the board
// resizes on a Reactor Frame purchase, cards appear and disappear in the
// left column, and the window can be dragged to another size mid-tour.
if ( TutorialActive ) MeasureTutorial();
float dt = Time.Delta;
for ( int i = _floaters.Count - 1; i >= 0; i-- )
{
_floaters[i].Life -= dt;
if ( _floaters[i].Life <= 0f ) _floaters.RemoveAt( i );
}
for ( int i = _toasts.Count - 1; i >= 0; i-- )
{
_toasts[i].Life -= dt;
if ( _toasts[i].Life <= 0f ) _toasts.RemoveAt( i );
}
if ( _calloutLife > 0f ) _calloutLife -= dt * 1.4f;
}
void Subscribe()
{
var r = R;
if ( r == null || _subscribed ) return;
r.OnPop += OnPop;
r.OnToast += OnToastEvent;
r.OnDetonation += OnDetonation;
// Walkthrough gates — a step that says "press OVERLOAD" only closes when
// the player actually presses OVERLOAD.
r.OnManualOverload += () => TutSatisfy( IdleTutTrigger.Overload );
r.OnUpgradeBought += () => TutSatisfy( IdleTutTrigger.Buy );
r.OnManualCrack += () => TutSatisfy( IdleTutTrigger.CellClick );
_subscribed = true;
}
/// <summary>
/// Rebuilds only when something on screen would actually differ.
///
/// The first version forced a repaint 15 times a second, which meant
/// rebuilding the board, both side columns and every upgrade row constantly
/// — that was the source of the stutter. Between detonations almost nothing
/// changes, so hashing real state instead of a clock cuts the repaint rate
/// by roughly an order of magnitude.
/// </summary>
protected override int BuildHash()
{
if ( !Visible ) return 0;
var hc = new HashCode();
hc.Add( _tab );
hc.Add( _buyAmount );
hc.Add( _showMeltdown );
hc.Add( _showSingularity );
hc.Add( R?.CascadeActive ?? false );
hc.Add( S?.Shards ?? 0 );
hc.Add( S?.ShardsEarned ?? 0 );
hc.Add( S?.Singularities ?? 0 );
hc.Add( R?.SingularityVisible ?? false );
hc.Add( _showIntro );
hc.Add( ModalOpen );
// Walkthrough — geometry is quantised to whole pixels, so a static
// spotlight leaves the hash alone and the panel is not rebuilt.
hc.Add( TutorialActive );
hc.Add( _tutStep );
hc.Add( TutHasSpot );
hc.Add( (int)_spotX ); hc.Add( (int)_spotY );
hc.Add( (int)_spotW ); hc.Add( (int)_spotH );
hc.Add( (int)_cardX ); hc.Add( (int)_cardY );
// Contracts — progress bars, claim state and the reset countdown
for ( int i = 0; i < IdleContracts.DailyCount; i++ )
{
hc.Add( (int)(IdleContracts.Fraction( S, i ) * 200f) );
hc.Add( IdleContracts.IsClaimed( S, i ) );
}
hc.Add( (int)(IdleContracts.SecondsUntilReset() / 60.0) );
var goal = NextGoal;
hc.Add( goal?.Id ?? "" );
hc.Add( goal == null ? 0 : (int)(goal.Fraction( S ) * 200f) );
// Currencies — quantised to what the labels actually show
hc.Add( (long)Cores );
hc.Add( (long)R.Cps );
hc.Add( S.Flux );
hc.Add( S.FluxEarned );
hc.Add( _rankRows.Count );
hc.Add( _myRank );
hc.Add( _rankClimb );
hc.Add( ChainReactionGame.Save?.TotalFragments ?? 0 );
hc.Add( S.TodayReactorFragments );
hc.Add( (int)(R.FragmentProgressFraction * 60f) );
// Live controls
hc.Add( (int)(R.OverloadCooldownLeft * 10f) );
hc.Add( (int)(R.Charge * 100f) );
hc.Add( R.SurgeActive );
hc.Add( (int)R.SurgeTimeLeft );
hc.Add( (int)R.GoldenTimeLeft );
hc.Add( R.Boosts.Count );
hc.Add( R.Boosts.Count > 0 ? (int)R.Boosts[0].SecondsLeft : 0 );
hc.Add( R.FeedStarved );
// Transient effects
hc.Add( _floaters.Count );
hc.Add( (int)(_calloutLife * 20f) );
hc.Add( Time.Now < _shakeUntil ? _shakeCls : "" );
hc.Add( _toasts.Count );
// Board — chest layout, damage, drones and any live flash
hc.Add( R.Size );
float now = Time.Now;
bool animating = _floaters.Count > 0 || _calloutLife > 0f || now < _shakeUntil;
for ( int r = 0; r < R.Size; r++ )
for ( int c = 0; c < R.Size; c++ )
{
var cell = R.Board[r, c];
hc.Add( (int)cell.Chest );
hc.Add( cell.HitsLeft );
hc.Add( cell.HasDrone );
hc.Add( cell.FlashUntil > now ? cell.FlashKind : 0 );
if ( cell.FlashUntil > now ) animating = true;
if ( cell.HasChest && now - cell.SpawnAt < 0.25f ) animating = true;
if ( cell.HasDrone && now - cell.DroneAt < 0.18f ) animating = true;
}
hc.Add( R.GoldenRow );
hc.Add( R.GoldenCol );
hc.Add( R.LastChain );
// Anything mid-flight is drawn from inline styles rather than keyframes,
// so it needs a clock — but only while it is actually moving. The rest of
// the time this hash is stable and the panel is never rebuilt at all.
if ( animating ) hc.Add( (int)(now * 20f) );
return hc.ToHashCode();
}
// ══════════════════════════════════════════════════════════════
// Reactor events
// ══════════════════════════════════════════════════════════════
void OnPop( int row, int col, double value, bool crit )
{
if ( R == null || _floaters.Count > 10 ) return;
float step = 100f / R.Size;
_floaters.Add( new Floater {
Text = "+" + IdleFormat.Short( value ),
Cls = crit ? "crit" : "",
X = step * col + step * 0.5f,
Y = step * row + step * 0.5f,
Life = 0.9f,
MaxLife = 0.9f,
} );
}
void OnToastEvent( string icon, string text, string cls )
{
_toasts.Insert( 0, new ToastMsg { Icon = icon, Text = text, Cls = cls, Life = 3.2f } );
while ( _toasts.Count > 4 ) _toasts.RemoveAt( _toasts.Count - 1 );
}
void OnDetonation( int chain, double payout, bool crit )
{
string shake = crit || chain >= 25 ? "idle-shake-3"
: chain >= 12 ? "idle-shake-2"
: chain >= 4 ? "idle-shake-1"
: "";
if ( shake != "" )
{
_shakeCls = shake;
_shakeUntil = Time.Now + (shake == "idle-shake-3" ? 0.45f : 0.28f);
}
if ( chain < 15 && !crit ) return;
double critPower = IdleBalance.CritPower( S?.Level( IdleUpgradeId.CritPower ) ?? 0 );
_calloutText = crit
? $"CRIT ×{critPower:F0} · +{IdleFormat.Short( payout )}"
: $"{chain} CHAIN · +{IdleFormat.Short( payout )}";
_calloutCls = crit ? "crit" : chain >= 35 ? "mega" : "big";
_calloutLife = 1f;
}
// ══════════════════════════════════════════════════════════════
// Input
// ══════════════════════════════════════════════════════════════
void SetTab( string tab )
{
bool changed = _tab != tab;
_tab = tab;
Sound.Play( "UI_Button" );
if ( changed ) TutSatisfy( IdleTutTrigger.TabChanged );
}
void SetBuyAmount( int amount ) { _buyAmount = amount; Sound.Play( "UI_Button" ); }
int BuyCount( IdleUpgradeId id )
=> _buyAmount < 0 ? Math.Max( 1, R.MaxAffordable( id ) ) : _buyAmount;
void OnBuy( IdleUpgradeId id )
{
if ( R == null ) return;
if ( !R.TryBuy( id, BuyCount( id ) ) ) Sound.Play( "Pop_Button_1" );
}
void OnBuyPerk( IdlePerkId id )
{
if ( R == null ) return;
if ( !R.TryBuyPerk( id ) ) Sound.Play( "Pop_Button_1" );
}
void OnOverload() => R?.Overload();
void OnSurge() => R?.ActivateSurge();
void OnCellClick( int r, int c ) => R?.ClickCell( r, c );
void OnClaimOffline() => R?.ClaimOffline();
void OnClaimDaily() => R?.ClaimDaily();
void OnOpenMeltdown() { _showMeltdown = true; Sound.Play( "UI_Button" ); }
void OnCloseMeltdown() { _showMeltdown = false; Sound.Play( "UI_Button" ); }
void OnOpenSingularity()
{
_showSingularity = true;
Sound.Play( "UI_Button" );
if ( S != null && !S.SingularitySeen )
{
S.SingularitySeen = true;
ChainReactionGame.Save?.Save();
}
}
void OnCloseSingularity() { _showSingularity = false; Sound.Play( "UI_Button" ); }
void OnSingularity()
{
_showSingularity = false;
R?.DoSingularity();
_tab = "powers";
}
void OnBuyPower( IdlePowerId id ) => R?.BuyPower( id );
void OnMeltdown()
{
_showMeltdown = false;
R?.DoMeltdown();
}
void OnCloseIntro()
{
_showIntro = false;
if ( S != null )
{
S.IntroShown = true;
S.TutorialOffered = true;
ChainReactionGame.Save?.Save();
}
Sound.Play( "PlayButton" );
}
void OnIntroTutorial()
{
if ( S != null )
{
S.IntroShown = true;
ChainReactionGame.Save?.Save();
}
Sound.Play( "PlayButton" );
StartTutorial();
}
/// <summary>The ? button in the top bar — replayable from anywhere, any time.</summary>
void OnStartTutorial()
{
Sound.Play( "UI_Button" );
if ( _tutRunning ) { EndTutorial( completed: true ); return; }
StartTutorial();
}
void OnClaimContract( int slot )
{
if ( R == null ) return;
if ( !R.ClaimContract( slot ) ) Sound.Play( "Pop_Button_1" );
}
void OnBack()
{
Sound.Play( "UI_Button" );
Game?.ReturnToMenu();
}
// ══════════════════════════════════════════════════════════════
// Class helpers
// ══════════════════════════════════════════════════════════════
string Pct( float f ) => (Math.Clamp( f, 0f, 1f ) * 100f).ToString( "F1" );
string GlowClass() => R.SurgeActive ? "surging" : "";
string SurgeClass() => R.SurgeActive ? "surging" : "";
// Surge is the louder event and its styles are declared first, so a cascade
// would otherwise paint over it. Let surge own the board while it lasts.
string CascadeClass() => R.CascadeActive && !R.SurgeActive ? "cascading" : "";
string SurgeReadyClass() => R.SurgeReady ? "ready" : "";
string OverloadClass() => R.OverloadReady ? "ready" : "cooling";
string BoardShake() => Time.Now < _shakeUntil ? _shakeCls : "";
string TabClass( string t ) => _tab == t ? "on" : "";
string BuyAmtClass( int amt ) => _buyAmount == amt ? "on" : "";
string MaxedClass( bool maxed ) => maxed ? "maxed" : "";
string CapClass() => R.FragmentsToday >= R.DailyFragmentCap ? "capped" : "";
string FeedClass() => R.FeedStarved ? "starved" : "";
string MeltdownStripClass() => R.CanMeltdown ? "ready" : "";
string StreakPipClass( int day )
{
int streak = S?.DailyStreak ?? 1;
if ( day == streak ) return "on today";
return day < streak ? "on" : "";
}
// ══════════════════════════════════════════════════════════════
// Text helpers — one composed string per element (see note at top)
// ══════════════════════════════════════════════════════════════
// ── Daily contracts ───────────────────────────────────────────
string ContractsCardClass() => R.ContractReady ? "ready" : "";
string ContractClass( int slot )
{
if ( IdleContracts.IsClaimed( S, slot ) ) return "claimed";
if ( IdleContracts.IsComplete( S, slot ) ) return "ready";
return "";
}
string ContractIcon( int slot )
{
if ( IdleContracts.IsClaimed( S, slot ) ) return "✅";
return IdleContracts.At( S, slot )?.Icon ?? "📋";
}
string ContractDescLabel( int slot )
{
var c = IdleContracts.At( S, slot );
if ( c == null ) return "";
return c.Desc( IdleContracts.Target( S, slot ) );
}
string ContractRewardLabel( int slot )
{
if ( IdleContracts.IsClaimed( S, slot ) ) return "CLAIMED";
if ( IdleContracts.IsComplete( S, slot ) ) return "CLAIM →";
return $"+{IdleContracts.Reward( S, slot )} 💠";
}
string ContractProgLabel( int slot )
{
var c = IdleContracts.At( S, slot );
if ( c == null ) return "";
double cur = Math.Min( IdleContracts.Progress( S, slot ), IdleContracts.Target( S, slot ) );
double tgt = IdleContracts.Target( S, slot );
if ( c.Id == "runtime" )
return $"{IdleFormat.Duration( cur )} / {IdleFormat.Duration( tgt )}";
return c.BigNumbers
? $"{IdleFormat.Short( cur )} / {IdleFormat.Short( tgt )}"
: $"{(long)cur} / {(long)tgt}";
}
string ContractResetLabel()
=> "resets in " + IdleFormat.Duration( IdleContracts.SecondsUntilReset() );
string ContractFootLabel()
{
int streak = S?.ContractStreak ?? 0;
if ( IdleContracts.AllClaimed( S ) )
return streak > 1
? $"All three cleared · {streak} days running"
: "All three cleared. Back tomorrow.";
return "Clear all three for a ×3 boost and double Fragments on the last one.";
}
// ── Next goal pin ─────────────────────────────────────────────
/// <summary>
/// The unfinished Reactor Log entry closest to completion. Picking by
/// fraction rather than by list order means the pin always shows something
/// nearly done, which is the only version of this card that pulls.
///
/// Cached: BuildHash runs every frame and the markup reads this five more
/// times per rebuild, so recomputing it on every access would walk the
/// achievement list a hundred-odd times a frame for a card that changes
/// once every few minutes.
/// </summary>
IdleAchievement _nextGoal;
float _nextGoalAt;
IdleAchievement NextGoal => _nextGoal;
void RefreshNextGoal()
{
var s = S;
if ( s == null ) { _nextGoal = null; return; }
IdleAchievement best = null;
float bestFrac = -1f;
foreach ( var a in IdleAchievements.All )
{
if ( s.HasAchievement( a.Id ) ) continue;
float f = a.Fraction( s );
if ( f > bestFrac ) { bestFrac = f; best = a; }
}
_nextGoal = best;
}
string NextGoalProgLabel()
{
var a = NextGoal;
if ( a == null ) return "";
return ProgressLabel( a );
}
string NextGoalRewardLabel()
{
var a = NextGoal;
if ( a == null ) return "";
return RewardLabel( a );
}
string CoresLabel() => IdleFormat.Short( Cores );
string RateLabel() => IdleFormat.Short( R.Cps );
string FluxLabel() => (S?.Flux ?? 0).ToString();
string FragLabel() => (ChainReactionGame.Save?.TotalFragments ?? 0).ToString();
string GlobalMultLabel() => "×" + IdleFormat.Short( R.GlobalMultiplier );
string ExponentLabel() => R.ChainExponent.ToString( "F2" );
string LastChainLabel() => $"{R.LastChain} chests";
string BestChainLabel() => $"{S.BestChain} chests";
string BoardLabel() => $"{R.Size} × {R.Size}";
string FeedLabel() => R.FeedStarved ? "starving" : "keeping up";
string SurgeBoostLabel() => $"SURGE ×{IdleBalance.SurgeMultiplier:F0}";
string BoostLabel( IdleReactor.Boost b ) => $"{b.Label} ×{b.Mult:F1}";
string SecondsLabel( float s ) => $"{s:F0}s";
string CondenserLabel()
=> $"next at {IdleFormat.Short( R.FragmentCost )} Cores · price climbs with each one";
string FragTodayLabel() => $"{R.FragmentsToday} / {R.DailyFragmentCap}";
string TierBlurb( int index, bool unlocked )
{
var tier = IdleBalance.Tiers[index];
if ( unlocked ) return tier.Blurb;
// Tier Memory can drop the gate to zero, so read the effective figure
// rather than the catalog one.
double need = IdleBalance.TierRequirement( index, S?.PowerLevel( IdlePowerId.TierSkip ) ?? 0 );
return $"unlocks at {IdleFormat.Short( need )} cycle Cores";
}
string GoldenTimerLabel() => R.GoldenTimeLeft.ToString( "F0" );
string OverloadLabel()
=> R.OverloadReady ? "⚡ OVERLOAD" : $"{R.OverloadCooldownLeft:F1}s";
string OverloadSubLabel() => $"×{IdleBalance.OverloadMultiplier:F1} burst · SPACE";
string OverloadFillPct() => ((1f - R.OverloadCooldownFraction) * 100f).ToString( "F0" );
string UnleashLabel() => $"UNLEASH ×{IdleBalance.SurgeMultiplier:F0}";
string SurgeLabel()
{
if ( R.SurgeActive ) return $"SURGE {R.SurgeTimeLeft:F0}s";
if ( R.SurgeReady ) return "SURGE READY";
return $"CHARGE {R.Charge * 100f:F0}%";
}
string MeltdownLabel()
=> R.CanMeltdown
? $"Meltdown available — +{R.PendingFlux} Flux"
: $"Meltdown unlocks at {IdleFormat.Short( R.MeltdownRequirement )} cycle Cores · {IdleFormat.Short( S.CycleCores )} banked";
string BuyAmountLabel( int amount ) => amount < 0 ? "MAX" : $"×{amount}";
string UpgradeClass( bool revealed, bool maxed, bool afford )
{
if ( !revealed ) return "locked";
if ( maxed ) return "maxed";
return afford ? "afford" : "poor";
}
string UpgradeIcon( IdleUpgrade up, bool revealed ) => revealed ? up.Icon : "🔒";
string UpgradeName( IdleUpgrade up, bool revealed ) => revealed ? up.Name : "Locked";
string UpgradeDesc( IdleUpgrade up, bool revealed )
=> revealed ? up.Desc : $"Reach {IdleFormat.Short( up.RevealAt )} lifetime Cores to reveal.";
string LevelLabel( IdleUpgrade up, int lvl, bool revealed )
{
if ( !revealed ) return "—";
return up.MaxLevel < 100 ? $"Lv {lvl} / {up.MaxLevel}" : $"Lv {lvl}";
}
string CostLabel( bool maxed, double cost, int count )
{
if ( maxed ) return "MAXED";
return count > 1
? $"{IdleFormat.Short( cost )} ×{count}"
: IdleFormat.Short( cost );
}
string PerkLevelLabel( IdlePerk p, int lvl ) => p.Uncapped ? $"Lv {lvl}" : $"Lv {lvl} / {p.MaxLevel}";
string PerkCostLabel( bool maxed, int cost ) => maxed ? "MAXED" : $"🔷 {cost}";
/// <summary>
/// Containment Field is deliberately hidden until the player has enough Flux
/// history to understand what a sink is for — offered at Flux 0 it just reads
/// as a bad first purchase.
/// </summary>
IEnumerable<IdlePerk> VisiblePerks()
=> IdleUpgradeCatalog.Perks.Where( p => p.RevealAtFluxEarned <= 0
|| (S?.FluxEarned ?? 0) >= p.RevealAtFluxEarned
|| (S?.PerkLevel( p.Id ) ?? 0) > 0 );
string PowerLevelLabel( IdlePower p, int lvl ) => $"Lv {lvl} / {p.MaxLevel}";
string PowerCostLabel( bool maxed, int cost ) => maxed ? "MAXED" : $"✴️ {cost}";
string ShardHeadLabel() => $"✴️ {S.Shards}";
string ShardSubLabel()
=> $"{S.ShardsEarned} earned · ×{IdleBalance.ShardMultiplier( S.ShardsEarned ):F2} permanent income";
string SingStripClass() => R.CanSingularity ? "ready" : "";
string SingularityLabel()
{
if ( R.CanSingularity )
return $"Singularity available — +{R.PendingShards} Shards";
return $"Singularity unlocks at {IdleBalance.SingularityRequirement} lifetime Flux · {S.FluxEarned} earned";
}
string SingPayoutLabel() => "+" + R.PendingShards.ToString();
string BurnedFluxLabel() => $"{S.FluxEarned} lifetime Flux";
string ShardKeepLabel()
{
int after = S.ShardsEarned + R.PendingShards;
return $"×{IdleBalance.ShardMultiplier( after ):F2} income from Shards";
}
string FluxHeadLabel() => $"🔷 {S.Flux}";
string FluxSubLabel()
=> $"{S.FluxEarned} earned · ×{IdleBalance.FluxMultiplier( S.FluxEarned ):F2} permanent income";
string LogHeadLabel() => $"{IdleAchievements.CompletedCount( S )} / {IdleAchievements.All.Count}";
string AchIcon( IdleAchievement a, bool done ) => done ? a.Icon : "🔘";
string RewardLabel( IdleAchievement a )
{
if ( a.RewardFlux > 0 && a.RewardFrags > 0 ) return $"+{a.RewardFlux}🔷 +{a.RewardFrags}💠";
if ( a.RewardFlux > 0 ) return $"+{a.RewardFlux}🔷";
return $"+{a.RewardFrags}💠";
}
string ProgressLabel( IdleAchievement a )
{
double cur = a.Progress( S );
return a.BigNumbers
? $"{IdleFormat.Short( cur )} / {IdleFormat.Short( a.Target )}"
: $"{(long)cur} / {(long)a.Target}";
}
// ── Modal labels ──────────────────────────────────────────────
string OfflineSubLabel()
=> $"The reactor ran for {IdleFormat.Duration( R.PendingOfflineSeconds )} at {IdleFormat.Pct( R.OfflineEfficiency )} efficiency.";
string OfflineAmountLabel() => "+" + IdleFormat.Short( R.PendingOfflineCores );
string OfflineFragLabel() => $"condensing {R.PendingOfflineFrags} Fragments";
string OfflineWarnLabel()
=> $"Storage filled up after {R.OfflineCapHours:F0}h. Upgrade Core Storage to bank longer trips.";
string DailyTitleLabel() => $"Day {S?.DailyStreak ?? 1}";
string DailyMultLabel() => $"×{IdleBalance.DailyReward( S?.DailyStreak ?? 1 ).mult:F1}";
string DailyRewardLabel()
{
var reward = IdleBalance.DailyReward( S?.DailyStreak ?? 1 );
return $"for {IdleFormat.Duration( reward.seconds )} · +{reward.frags} Fragments";
}
string MeltPayoutLabel() => "+" + R.PendingFlux;
string BurnedCoresLabel() => $"{IdleFormat.Short( Cores )} Cores";
string MeltMultBeforeLabel()
=> "×" + IdleBalance.FluxMultiplier( S?.FluxEarned ?? 0 ).ToString( "F2" );
string MeltMultAfterLabel()
=> "×" + IdleBalance.FluxMultiplier( (S?.FluxEarned ?? 0) + R.PendingFlux ).ToString( "F2" );
// ══════════════════════════════════════════════════════════════
// Cell rendering
// ══════════════════════════════════════════════════════════════
string CellClass( IdleReactor.Cell cell, bool golden )
{
if ( golden ) return "golden";
string cls = cell.HasChest ? "filled" : "empty";
if ( cell.FlashUntil > Time.Now )
{
cls += cell.FlashKind switch
{
3 => " fx-drone",
2 => " fx-kill",
_ => " fx-blast",
};
}
if ( cell.HasDrone ) cls += " armed";
if ( cell.HasChest )
{
cls += cell.Chest switch
{
GridManager.ChestType.Gem => " c-gem",
GridManager.ChestType.BombChest => " c-bomb",
GridManager.ChestType.Locked => " c-locked",
GridManager.ChestType.Cursed => " c-cursed",
GridManager.ChestType.Void => " c-void",
GridManager.ChestType.Boss => " c-boss",
_ => " c-normal",
};
}
return cls;
}
/// <summary>
/// Spawn pop, computed here rather than as a keyframe: the panel can rebuild
/// at any moment and a class-driven animation would restart mid-flight.
/// </summary>
string ChestStyle( IdleReactor.Cell cell )
{
float age = Time.Now - cell.SpawnAt;
if ( age >= 0.25f || age < 0f ) return "";
float scale = 0.4f + 0.6f * (age / 0.25f);
return $"transform:scale({scale.ToString( "F2" )});";
}
/// <summary>Drone drop — falls onto the cell as the volley arms.</summary>
string DroneStyle( IdleReactor.Cell cell )
{
float age = Time.Now - cell.DroneAt;
if ( age >= 0.18f || age < 0f ) return "";
float t = age / 0.18f;
float scale = 2.0f - 1.0f * t;
return $"transform:scale({scale.ToString( "F2" )});opacity:{t.ToString( "F2" )};";
}
string ChestGlyph( IdleReactor.Cell cell ) => cell.Chest switch
{
GridManager.ChestType.BombChest => "💣",
GridManager.ChestType.Locked => "🔒",
GridManager.ChestType.Cursed => "💀",
GridManager.ChestType.Void => "🌑",
GridManager.ChestType.Boss => "👑",
GridManager.ChestType.Gem => "💎",
_ => "🎁",
};
}
@using System
@using System.Collections.Generic
@using System.Linq
@using Sandbox
@using Sandbox.UI
@namespace AdminSuite.UI
@inherits Panel
<root class="tab ranks-tab">
@if ( Sys is null )
{
<label class="empty">Admin system not available.</label>
}
else
{
<div class="column list">
<div class="section-title"><i>military_tech</i><label>Ranks (@Sys.ClientRanks.Count)</label></div>
<div class="scroll">
@foreach ( var rank in Sys.ClientRanks.OrderByDescending( r => r.Priority ) )
{
<div class="row rank @(SelectedId == rank.Id ? "selected" : "")" onclick=@( () => Select( rank ) )>
<div class="dot" style="background-color: @rank.Color"></div>
<div class="meta">
<label class="name">@rank.Name</label>
<label class="sub">@rank.Id · priority @rank.Priority@(rank.BuiltIn ? " · built-in" : "")</label>
</div>
</div>
}
</div>
@if ( Sys.LocalHas( AdminPerm.RanksManage ) )
{
<div class="create-row">
<TextEntry class="input grow" Value:bind=@NewRankName Placeholder="New rank name" />
<div class="btn accent" onclick=@( () => Create() )><i>add</i><label>Create</label></div>
</div>
}
</div>
<div class="column detail">
@{ var rank = Sys.ClientRanks.FirstOrDefault( r => r.Id == SelectedId ); }
@if ( rank is null )
{
<div class="placeholder"><i>touch_app</i><label>Select a rank to view or edit it</label></div>
}
else
{
var canEdit = Sys.LocalCanEditRank( rank );
<div class="detail-header">
<div class="dot big" style="background-color: @rank.Color"></div>
<div class="meta">
<label class="name">@rank.Name</label>
<label class="sub">@rank.Id · @(rank.BuiltIn ? "built-in rank" : "custom rank")</label>
</div>
<div class="badge" style="background-color: @rank.Color">priority @rank.Priority</div>
</div>
if ( !canEdit )
{
<div class="notice"><i>lock</i><label>You can only edit ranks below your own rank.</label></div>
}
else
{
if ( rank.BuiltIn )
{
<div class="notice"><i>info</i><label>Built-in ranks keep their name and priority — only the colour and permissions can be changed.</label></div>
}
<div class="form">
@if ( !rank.BuiltIn )
{
<div class="field">
<label class="key">Name</label>
<TextEntry class="input" Value:bind=@EditName />
</div>
<div class="field">
<label class="key">Priority (1–99, must stay below your own rank)</label>
<TextEntry class="input" Value:bind=@EditPriority />
</div>
}
<div class="field">
<label class="key">Colour (hex, e.g. #b28dff)</label>
<div class="colour-row">
<TextEntry class="input grow" Value:bind=@EditColor />
<div class="swatch" style="background-color: @EditColor"></div>
</div>
</div>
<div class="form-actions">
<div class="btn accent" onclick=@( () => Apply( rank ) )><i>save</i><label>Apply</label></div>
@if ( !rank.BuiltIn )
{
<div class="btn danger" onclick=@( () => Delete( rank ) )>
<i>delete</i><label>@(ConfirmingDelete ? "Confirm delete?" : "Delete Rank")</label>
</div>
}
</div>
</div>
<label class="explain">Permissions for this rank are edited in the Permissions tab. Players holding a deleted rank fall back to the server's default rank.</label>
}
}
</div>
}
</root>
@code
{
private AdminSystem Sys => AdminSystem.Instance;
public string SelectedId { get; set; } = "";
public string NewRankName { get; set; } = "";
public string EditName { get; set; } = "";
public string EditColor { get; set; } = "";
public string EditPriority { get; set; } = "";
private float _confirmUntil;
private bool ConfirmingDelete => RealTime.Now < _confirmUntil;
private void Select( AdminRank rank )
{
SelectedId = rank.Id;
EditName = rank.Name;
EditColor = rank.Color;
EditPriority = rank.Priority.ToString();
_confirmUntil = 0f;
StateHasChanged();
}
private void Create()
{
if ( Sys is null ) return;
if ( string.IsNullOrWhiteSpace( NewRankName ) )
{
AdminMenu.Instance?.Toast( "Enter a name for the new rank first.", 2 );
return;
}
Sys.RequestCreateRank( NewRankName.Trim() );
NewRankName = "";
}
private void Apply( AdminRank rank )
{
if ( Sys is null || !Sys.LocalCanEditRank( rank ) ) return;
var priority = int.TryParse( EditPriority, out var p ) ? p : rank.Priority;
var name = string.IsNullOrWhiteSpace( EditName ) ? rank.Name : EditName.Trim();
Sys.RequestUpdateRank( rank.Id, name, EditColor?.Trim() ?? "", priority );
}
private void Delete( AdminRank rank )
{
if ( Sys is null || !Sys.LocalCanEditRank( rank ) || rank.BuiltIn ) return;
if ( !ConfirmingDelete )
{
_confirmUntil = RealTime.Now + 3f;
return;
}
_confirmUntil = 0f;
Sys.RequestDeleteRank( rank.Id );
SelectedId = "";
}
protected override int BuildHash() =>
HashCode.Combine( Sys?.StateHash ?? 0, SelectedId, ConfirmingDelete, (int)RealTime.Now );
}
@using System
@using System.Collections.Generic
@using System.Linq
@using Sandbox
@using Sandbox.UI
@namespace AdminSuite.UI
@inherits Panel
<root class="tab permissions-tab">
@if ( Sys is null )
{
<label class="empty">Admin system not available.</label>
}
else
{
<div class="section-title"><i>key</i><label>Rank Permissions</label></div>
<div class="rank-pills">
@foreach ( var rank in Sys.ClientRanks.OrderByDescending( r => r.Priority ) )
{
<div class="pill @(SelectedId == rank.Id ? "selected" : "")" onclick=@( () => Select( rank.Id ) )>
<div class="dot" style="background-color: @rank.Color"></div>
<label>@rank.Name</label>
</div>
}
</div>
var selected = Sys.ClientRanks.FirstOrDefault( r => r.Id == SelectedId );
if ( selected is null )
{
<div class="placeholder"><i>touch_app</i><label>Pick a rank above to view or edit its permissions</label></div>
}
else
{
var locked = !Sys.LocalHas( AdminPerm.RanksManage )
|| !Sys.LocalCanEditRank( selected )
|| selected.Id == AdminDefaults.SuperAdminId;
if ( selected.Id == AdminDefaults.SuperAdminId )
{
<div class="notice"><i>verified_user</i><label>Super Admin always has every permission — this can't be changed.</label></div>
}
else if ( locked )
{
<div class="notice"><i>lock</i><label>You can't edit this rank's permissions.</label></div>
}
if ( Sys.LocalHasAll && selected.Id != AdminDefaults.SuperAdminId && !locked )
{
<div class="perm-row master">
<div class="meta">
<label class="name">All Permissions (*)</label>
<label class="sub">Grants every current and future permission — effectively a second Super Admin tier.</label>
</div>
<div class="toggle @(selected.HasAll ? "on" : "")" onclick=@( () => ToggleAll( selected ) )>
<i>@(selected.HasAll ? "check" : "")</i>
</div>
</div>
}
<div class="scroll perms">
@foreach ( var group in AdminPerm.ByCategory )
{
<div class="section-title"><label>@group.Key</label></div>
foreach ( var perm in group )
{
var granted = selected.Has( perm.Id );
<div class="perm-row @(selected.HasAll ? "implied" : "")">
<div class="meta">
<label class="name">@perm.Name</label>
<label class="sub">@perm.Description</label>
</div>
<div class="toggle @(granted ? "on" : "") @(locked || selected.HasAll ? "locked" : "")"
onclick=@( () => { if ( !locked && !selected.HasAll ) Toggle( selected, perm.Id ); } )>
<i>@(granted ? "check" : "")</i>
</div>
</div>
}
}
</div>
}
}
</root>
@code
{
private AdminSystem Sys => AdminSystem.Instance;
public string SelectedId { get; set; } = "";
private void Select( string rankId )
{
SelectedId = rankId;
StateHasChanged();
}
private void Toggle( AdminRank rank, string permId )
{
if ( Sys is null ) return;
var perms = new List<string>( rank.Permissions );
if ( !perms.Remove( permId ) ) perms.Add( permId );
Sys.RequestSetRankPermissions( rank.Id, Json.Serialize( perms ) );
}
private void ToggleAll( AdminRank rank )
{
if ( Sys is null ) return;
var perms = new List<string>( rank.Permissions );
if ( !perms.Remove( AdminPerm.All ) ) perms.Add( AdminPerm.All );
Sys.RequestSetRankPermissions( rank.Id, Json.Serialize( perms ) );
}
protected override int BuildHash() =>
HashCode.Combine( Sys?.StateHash ?? 0, SelectedId );
}
@using System
@using System.Collections.Generic
@using System.Linq
@using Sandbox
@using Sandbox.UI
@namespace AdminSuite.UI
@inherits PanelComponent
<root class="adminsuite @(IsOpen ? "open" : "")">
<div class="toasts">
@foreach ( var toast in Toasts )
{
<div class="toast [email protected]">
<i>@ToastIcon( toast.Level )</i>
<label>@toast.Message</label>
</div>
}
</div>
@if ( IsOpen && Sys is not null )
{
var me = Connection.Local;
var myRank = me is not null ? Sys.GetDisplayRank( me.SteamId ) : null;
<div class="window">
<div class="titlebar">
<div class="brand">
<i>local_police</i>
<label class="name">ADMIN SUITE</label>
</div>
@if ( me is not null && myRank is not null )
{
<div class="whoami">
<label>@me.DisplayName</label>
<div class="badge" style="background-color: @myRank.Color">@myRank.Name</div>
</div>
}
<div class="close" onclick=@( () => Close() )><i>close</i></div>
</div>
<div class="main">
<div class="nav">
@foreach ( var item in NavItems() )
{
<div class="nav-item @(Current == item.Tab ? "active" : "")" onclick=@( () => SetTab( item.Tab ) )>
<i>@item.Icon</i>
<label>@item.Label</label>
</div>
}
</div>
<div class="content">
@if ( Current == Tab.Players ) { <PlayersTab /> }
else if ( Current == Tab.Ranks ) { <RanksTab /> }
else if ( Current == Tab.Permissions ) { <PermissionsTab /> }
else if ( Current == Tab.Server ) { <ServerTab /> }
else if ( Current == Tab.Settings ) { <SettingsTab /> }
</div>
</div>
<div class="statusbar">
<label>@Connection.All.Count player(s) online</label>
<label class="hint">Toggle key: @AdminClientPrefs.Current.MenuKey.ToUpperInvariant() · console: adminsuite_menu</label>
</div>
</div>
}
</root>
@code
{
public static AdminMenu Instance { get; private set; }
public enum Tab { Players, Ranks, Permissions, Server, Settings }
public bool IsOpen { get; private set; }
public Tab Current { get; private set; } = Tab.Players;
public record struct ToastItem( string Message, int Level, float ExpiresAt );
public List<ToastItem> Toasts { get; } = new();
private bool _keyWasDown;
private AdminSystem Sys => AdminSystem.Instance;
protected override void OnStart()
{
Instance = this;
}
protected override void OnDestroy()
{
if ( Instance == this ) Instance = null;
}
protected override void OnUpdate()
{
// Raw-key toggle — edge-detected so holding the key doesn't flicker the menu.
var key = AdminClientPrefs.Current.MenuKey;
var isDown = !string.IsNullOrWhiteSpace( key ) && Input.Keyboard.Down( key );
if ( isDown && !_keyWasDown ) Toggle();
_keyWasDown = isDown;
if ( IsOpen )
{
Mouse.Visible = true;
if ( Input.EscapePressed )
{
Input.EscapePressed = false;
Close();
}
// If our access was revoked (or the system vanished) while open, close.
if ( Sys is null || !Sys.LocalHas( AdminPerm.MenuAccess ) )
Close();
else if ( NavItems().All( n => n.Tab != Current ) )
Current = Tab.Players;
}
Toasts.RemoveAll( t => t.ExpiresAt < RealTime.Now );
}
public void Toggle()
{
if ( IsOpen ) Close();
else Open();
}
public void Open()
{
if ( Sys is null )
{
Toast( "Admin system is not running on this server.", 2 );
return;
}
if ( !Sys.LocalHas( AdminPerm.MenuAccess ) )
{
Toast( "You don't have access to the admin menu.", 2 );
return;
}
IsOpen = true;
}
public void Close()
{
IsOpen = false;
Mouse.Visible = false;
}
public void SetTab( Tab tab ) => Current = tab;
public void Toast( string message, int level = 0 )
{
if ( string.IsNullOrEmpty( message ) ) return;
Toasts.Add( new ToastItem( message, level, RealTime.Now + 6f ) );
while ( Toasts.Count > 6 ) Toasts.RemoveAt( 0 );
StateHasChanged();
}
private static string ToastIcon( int level ) => level switch
{
1 => "check_circle",
2 => "error",
3 => "campaign",
_ => "info"
};
private record struct NavItem( Tab Tab, string Icon, string Label );
private IEnumerable<NavItem> NavItems()
{
yield return new NavItem( Tab.Players, "group", "Players" );
if ( Sys?.LocalHas( AdminPerm.RanksManage ) == true )
{
yield return new NavItem( Tab.Ranks, "military_tech", "Ranks" );
yield return new NavItem( Tab.Permissions, "key", "Permissions" );
}
if ( ShowServerTab )
yield return new NavItem( Tab.Server, "dns", "Server" );
yield return new NavItem( Tab.Settings, "settings", "Settings" );
}
private bool ShowServerTab =>
Sys is not null &&
(Sys.LocalHas( AdminPerm.PropsCleanAll )
|| Sys.LocalHas( AdminPerm.PropsCleanDisconnected )
|| Sys.LocalHas( AdminPerm.ServerBroadcast )
|| Sys.LocalHas( AdminPerm.PlayersBan )
|| Sys.LocalHas( AdminPerm.ServerSettings ));
protected override int BuildHash() =>
HashCode.Combine( IsOpen, Current, Toasts.Count, Sys?.StateHash ?? 0, (int)RealTime.Now );
}
@using System
@using System.Collections.Generic
@using System.Linq
@using Sandbox
@using Sandbox.UI
@namespace AdminSuite.UI
@inherits Panel
<root class="tab server-tab">
@if ( Sys is null )
{
<label class="empty">Admin system not available.</label>
}
else
{
<div class="scroll">
@if ( Sys.LocalHas( AdminPerm.PropsCleanAll ) || Sys.LocalHas( AdminPerm.PropsCleanDisconnected ) )
{
<div class="card">
<div class="section-title"><i>cleaning_services</i><label>Cleanup</label></div>
<div class="action-row">
@if ( Sys.LocalHas( AdminPerm.PropsCleanDisconnected ) )
{
<div class="btn accent" onclick=@( () => Sys.RequestCleanDisconnectedProps() )>
<i>person_off</i><label>Clean Disconnected Players' Props</label>
</div>
}
@if ( Sys.LocalHas( AdminPerm.PropsCleanAll ) )
{
<div class="btn danger" onclick=@( () => CleanAll() )>
<i>delete_sweep</i><label>@(ConfirmingCleanAll ? "Confirm — remove ALL props?" : "Clean ALL Props")</label>
</div>
}
</div>
<label class="explain">Cleanup only removes networked spawned objects — the map itself is never touched. Per-player cleanup lives in the Players tab.</label>
</div>
}
@if ( Sys.LocalHas( AdminPerm.ServerBroadcast ) )
{
<div class="card">
<div class="section-title"><i>campaign</i><label>Broadcast</label></div>
<div class="action-row">
<TextEntry class="input grow" Value:bind=@BroadcastText Placeholder="Message to every player..." />
<div class="btn accent" onclick=@( () => Send() )><i>send</i><label>Send</label></div>
</div>
</div>
}
@if ( Sys.LocalHas( AdminPerm.PlayersBan ) )
{
<div class="card">
<div class="section-title"><i>block</i><label>Active Bans (@Sys.ClientBans.Count)</label></div>
@if ( Sys.ClientBans.Count == 0 )
{
<label class="explain">No active bans.</label>
}
@foreach ( var ban in Sys.ClientBans )
{
<div class="row ban">
<div class="meta">
<label class="name">@ban.Name</label>
<label class="sub">@ban.SteamId · "@ban.Reason" — by @ban.BannedByName · @Expiry( ban )</label>
</div>
<div class="btn ghost" onclick=@( () => Sys.RequestUnban( ban.SteamId ) )>
<i>undo</i><label>Unban</label>
</div>
</div>
}
</div>
}
</div>
}
</root>
@code
{
private AdminSystem Sys => AdminSystem.Instance;
public string BroadcastText { get; set; } = "";
private float _confirmUntil;
private bool ConfirmingCleanAll => RealTime.Now < _confirmUntil;
private void CleanAll()
{
if ( Sys is null ) return;
if ( !ConfirmingCleanAll )
{
_confirmUntil = RealTime.Now + 3f;
return;
}
_confirmUntil = 0f;
Sys.RequestCleanAllProps();
}
private void Send()
{
if ( Sys is null || string.IsNullOrWhiteSpace( BroadcastText ) ) return;
Sys.RequestBroadcast( BroadcastText.Trim() );
BroadcastText = "";
}
private static string Expiry( BanRecord ban ) =>
ban.ExpiresUtc is null ? "permanent" : $"until {ban.ExpiresUtc.Value.ToLocalTime():g}";
protected override int BuildHash() =>
HashCode.Combine( Sys?.StateHash ?? 0, ConfirmingCleanAll, (int)RealTime.Now );
}
@using System
@using System.Collections.Generic
@using System.Linq
@using Sandbox
@using Sandbox.UI
@namespace AdminSuite.UI
@inherits Panel
<root class="tab ranks-tab">
@if ( Sys is null )
{
<label class="empty">Admin system not available.</label>
}
else
{
<div class="column list">
<div class="section-title"><i>military_tech</i><label>Ranks (@Sys.ClientRanks.Count)</label></div>
<div class="scroll">
@foreach ( var rank in Sys.ClientRanks.OrderByDescending( r => r.Priority ) )
{
<div class="row rank @(SelectedId == rank.Id ? "selected" : "")" onclick=@( () => Select( rank ) )>
<div class="dot" style="background-color: @rank.Color"></div>
<div class="meta">
<label class="name">@rank.Name</label>
<label class="sub">@rank.Id · priority @rank.Priority@(rank.BuiltIn ? " · built-in" : "")</label>
</div>
</div>
}
</div>
@if ( Sys.LocalHas( AdminPerm.RanksManage ) )
{
<div class="create-row">
<TextEntry class="input grow" Value:bind=@NewRankName Placeholder="New rank name" />
<div class="btn accent" onclick=@( () => Create() )><i>add</i><label>Create</label></div>
</div>
}
</div>
<div class="column detail">
@{ var rank = Sys.ClientRanks.FirstOrDefault( r => r.Id == SelectedId ); }
@if ( rank is null )
{
<div class="placeholder"><i>touch_app</i><label>Select a rank to view or edit it</label></div>
}
else
{
var canEdit = Sys.LocalCanEditRank( rank );
<div class="detail-header">
<div class="dot big" style="background-color: @rank.Color"></div>
<div class="meta">
<label class="name">@rank.Name</label>
<label class="sub">@rank.Id · @(rank.BuiltIn ? "built-in rank" : "custom rank")</label>
</div>
<div class="badge" style="background-color: @rank.Color">priority @rank.Priority</div>
</div>
if ( !canEdit )
{
<div class="notice"><i>lock</i><label>You can only edit ranks below your own rank.</label></div>
}
else
{
if ( rank.BuiltIn )
{
<div class="notice"><i>info</i><label>Built-in ranks keep their name and priority — only the colour and permissions can be changed.</label></div>
}
<div class="form">
@if ( !rank.BuiltIn )
{
<div class="field">
<label class="key">Name</label>
<TextEntry class="input" Value:bind=@EditName />
</div>
<div class="field">
<label class="key">Priority (1–99, must stay below your own rank)</label>
<TextEntry class="input" Value:bind=@EditPriority />
</div>
}
<div class="field">
<label class="key">Colour (hex, e.g. #b28dff)</label>
<div class="colour-row">
<TextEntry class="input grow" Value:bind=@EditColor />
<div class="swatch" style="background-color: @EditColor"></div>
</div>
</div>
<div class="form-actions">
<div class="btn accent" onclick=@( () => Apply( rank ) )><i>save</i><label>Apply</label></div>
@if ( !rank.BuiltIn )
{
<div class="btn danger" onclick=@( () => Delete( rank ) )>
<i>delete</i><label>@(ConfirmingDelete ? "Confirm delete?" : "Delete Rank")</label>
</div>
}
</div>
</div>
<label class="explain">Permissions for this rank are edited in the Permissions tab. Players holding a deleted rank fall back to the server's default rank.</label>
}
}
</div>
}
</root>
@code
{
private AdminSystem Sys => AdminSystem.Instance;
public string SelectedId { get; set; } = "";
public string NewRankName { get; set; } = "";
public string EditName { get; set; } = "";
public string EditColor { get; set; } = "";
public string EditPriority { get; set; } = "";
private float _confirmUntil;
private bool ConfirmingDelete => RealTime.Now < _confirmUntil;
private void Select( AdminRank rank )
{
SelectedId = rank.Id;
EditName = rank.Name;
EditColor = rank.Color;
EditPriority = rank.Priority.ToString();
_confirmUntil = 0f;
StateHasChanged();
}
private void Create()
{
if ( Sys is null ) return;
if ( string.IsNullOrWhiteSpace( NewRankName ) )
{
AdminMenu.Instance?.Toast( "Enter a name for the new rank first.", 2 );
return;
}
Sys.RequestCreateRank( NewRankName.Trim() );
NewRankName = "";
}
private void Apply( AdminRank rank )
{
if ( Sys is null || !Sys.LocalCanEditRank( rank ) ) return;
var priority = int.TryParse( EditPriority, out var p ) ? p : rank.Priority;
var name = string.IsNullOrWhiteSpace( EditName ) ? rank.Name : EditName.Trim();
Sys.RequestUpdateRank( rank.Id, name, EditColor?.Trim() ?? "", priority );
}
private void Delete( AdminRank rank )
{
if ( Sys is null || !Sys.LocalCanEditRank( rank ) || rank.BuiltIn ) return;
if ( !ConfirmingDelete )
{
_confirmUntil = RealTime.Now + 3f;
return;
}
_confirmUntil = 0f;
Sys.RequestDeleteRank( rank.Id );
SelectedId = "";
}
protected override int BuildHash() =>
HashCode.Combine( Sys?.StateHash ?? 0, SelectedId, ConfirmingDelete, (int)RealTime.Now );
}
@using Sandbox
@using Sandbox.UI
@using System
@using System.Linq
@inherits PanelComponent
@namespace Monolith
<root class="hud @(PhotoHidden ? "photo-hidden" : "")">
@if ( Progress != null )
{
@if ( Manager != null && Manager.StageJustCleared )
{
<div class="cleared">
<div class="big">@Manager.LastClearedName BROKEN</div>
<div class="next">@StageName condensing</div>
</div>
}
<div class="topbar">
@* The shared Monolith is NOT a rung on the solo ladder, so it must not borrow the
ladder's numbering. It was showing "STAGE 48" because Tier is still whatever solo
stage you left, which made a separate destination look like more of the same. *@
<div class="tier">@TierLine</div>
<div class="bar">
<div class="fill" style="width: @(ClearedPercent)%;"></div>
</div>
<div class="counts">
<span class="remaining">@Num.Short( Remaining )</span>
<span class="sep">/</span>
<span class="total">@Num.Short( Total )</span>
<span class="pct">@ClearedPercent.ToString( "0.000" )% cleared</span>
</div>
</div>
<div class="wallet">
<div class="dust">
<span class="label">DUST</span>
<span class="value">@Num.Short( Progress.Data.Dust )</span>
</div>
<div class="line">
<span class="label">Cubes mined</span>
<span class="value">@Num.Short( Progress.Data.LifetimeCubes )</span>
</div>
<div class="line">
<span class="label">Cores</span>
<span class="value">@Progress.Data.Cores (x@(Progress.CoreMultiplier.ToString( "0.00" )))</span>
</div>
<div class="line">
<span class="label">Shots</span>
<span class="value">@($"x{Progress.ProjectileCount}")</span>
</div>
<div class="line timer">
<span class="label">@(InMonolith ? "Monolith run" : "Ladder run")</span>
<span class="value">@Num.Duration( InMonolith ? (Manager?.MonolithSeconds ?? 0f) : Progress.RunSeconds )</span>
</div>
</div>
@if ( Leech.All.Count > 0 )
{
<div class="leeches">
@Leech.All.Count LEECH@(Leech.All.Count == 1 ? "" : "ES")
· -@((Leech.TotalSiphon * 100f).ToString( "0" ))% dust
<span class="sub">shoot them to take it back, with interest</span>
</div>
}
@* No Spotter warning here on purpose. The threat is told entirely by its light: the
beam snapping onto you, reddening, tightening and pulsing faster as the lock fills.
A text overlay would let you read the HUD instead of reading the world.
The LOSS is different, and is the one place text earns its keep. A threat you can see
coming should be read from the world; a stage that has already reset underneath you
has no world-state left to read, and without this the most likely interpretation was
that the game had glitched. *@
@* WELCOME PAGES. Shown once, on a save that has never been played.
Everything in this game is taught by the world rather than by text (GOALS 6b), which
works for a threat you can watch and fails completely for a threat you have not met
yet. Five pages is the smallest thing that stops a new player being killed by a
Spotter, or by the floor, without ever learning what either one was. *@
@if ( ShowTutorial )
{
<div class="overlay">
<div class="sheet">
<div class="sheet-eyebrow">@($"{TutorialPage + 1} / {TutorialPages}")</div>
@if ( TutorialPage == 0 )
{
<div class="sheet-title">MONOLITH</div>
<div class="sheet-lead">A shape of solid cubes hangs in the void. Take it apart.</div>
<div class="sheet-rows">
<div class="srow">
<div class="icon rock"></div>
<div class="stext">
<b>Mine it</b>
Hold left click. Every cube you break pays DUST, and dust buys
upgrades that break more cubes. Clear the shape and the next
one condenses in its place.
</div>
</div>
<div class="srow">
<div class="icon ladder"></div>
<div class="stext">
<b>The ladder is 100 stages</b>
Each is a little bigger than the last. Reaching the end unlocks
COLLAPSE, which resets your dust and upgrades but pays permanent
cores. You come back stronger and climb faster.
</div>
</div>
</div>
}
else if ( TutorialPage == 1 )
{
<div class="sheet-title">YOUR TWO WEAPONS</div>
<div class="sheet-lead">One is constant. One is a decision.</div>
<div class="sheet-rows">
<div class="srow">
<div class="icon bolt"></div>
<div class="stext">
<b>Left click · the drill</b>
A stream of bolts that travel. They can be blocked, and they
can hit things other than rock, so you can shoot down what is
shooting at you.
</div>
</div>
<div class="srow">
<div class="icon bore"></div>
<div class="stext">
<b>Right click · the charge</b>
Fires a charge that bores INTO the shape. Left click sets it
off wherever it has reached. It removes a share of the whole
stage, so it never stops mattering, and burying it deeper
means more of the blast lands in rock instead of air.
</div>
</div>
<div class="srow">
<div class="icon rocket"></div>
<div class="stext">
<b>Shoot the FLOOR to rocket jump</b>
A charge that meets the ground goes off on its own. No
second click and no timing. It throws you, hardest when it
lands closest, and it still mines whatever was beside it,
though less than a charge you let bore in first.
<span class="c-o">Going faster costs you a charge.</span>
</div>
</div>
<div class="srow">
<div class="icon volatile"></div>
<div class="stext">
<b>Red cubes are volatile</b>
Hit one squarely and it detonates. The only large explosion
ordinary fire can produce, so they are worth aiming at, but
<span class="c-r">the blast throws burning chunks of rock outward
and the pieces come down around YOU.</span> Each one marks the
ground with a ring before it lands. Rings creep toward you, so
standing still never works, but they creep slowly, so walking
out always does. Jumping does not: the blast reaches higher
than you can. The real cost is WHERE it makes you go.
</div>
</div>
</div>
}
else if ( TutorialPage == 2 )
{
<div class="sheet-title">WHAT IS OUT THERE</div>
<div class="sheet-lead">
Nothing can kill you. The Spotter can take the stage back.
</div>
<div class="sheet-rows">
<div class="srow">
<div class="icon spotter"></div>
<div class="stext">
<b>Spotter · the searchlight</b>
Sweeps a wide beam. If it finds you it locks on and a line
attaches, and it changes colour as the lock fills:
<div class="ramp">
<span class="c-y">YELLOW</span>
<span class="arrow">›</span>
<span class="c-o">ORANGE</span>
<span class="arrow">›</span>
<span class="c-r">RED, THE LAST SECOND</span>
</div>
Let it finish and the stage restarts. Break line of sight behind
the shape, outrun the beam, or shoot it down.
</div>
</div>
<div class="srow">
<div class="icon sentinel"></div>
<div class="stext">
<b>Sentinel · the turret</b>
Turns to face you, glows brighter as it charges, then fires a
slow pink orb. Dodge it, shoot the orb, or kill the sentinel.
It also radios your position to Spotters, so leaving one alive
is how you get found.
</div>
</div>
<div class="srow">
<div class="icon anchor"></div>
<div class="stext">
<b>Anchor · the tether</b>
Fires a green cable that halves your speed. You cannot outrun
it and cover will not save you. Shoot the far end.
</div>
</div>
<div class="srow">
<div class="icon crawler"></div>
<div class="stext">
<b>Crawler · the one on the floor</b>
Runs at you across the ground and gets FASTER the longer it
chases, glowing from ember to white hot as it winds up. A fresh
one you can walk away from; a wound-up one needs a sprint. If
it reaches you it takes the stage. Cut any Anchor cable first:
<span class="c-r">tethered, you cannot outrun a crawler at all.</span>
</div>
</div>
</div>
}
else if ( TutorialPage == 3 )
{
<div class="sheet-title">THE FLOOR IS A CLOCK</div>
<div class="sheet-lead">
Standing still is the only thing that is always wrong.
</div>
<div class="sheet-rows">
<div class="srow">
<div class="icon warn"></div>
<div class="stext">
<b>Half the floor turns on you</b>
Every three seconds a new half lights up. It goes
<span class="c-o">AMBER</span> first, and that is your one
second to be somewhere else. Then it goes red and takes
the stage.
</div>
</div>
<div class="srow">
<div class="icon dead"></div>
<div class="stext">
<b>The raised violet blocks never turn off</b>
A few squares stand proud of the floor and glow violet.
Lethal permanently: no warning, no cycle, no second
chance. They are the holes in whatever route you were
planning, and they are the only thing out here that is
not orange, so learn them at a glance.
</div>
</div>
<div class="srow">
<div class="icon air"></div>
<div class="stext">
<b>In the air you are safe</b>
The floor can only take you while you are standing on it.
One ordinary jump clears one tile, a sprint clears two, a
good hop chain clears three.
<span class="c-y">Every movement trick you have is a way
of being off the ground for longer.</span>
</div>
</div>
</div>
}
else
{
<div class="sheet-title">AND THE REST</div>
<div class="sheet-lead">Things that cost you, and things that pay you.</div>
<div class="sheet-rows">
<div class="srow">
<div class="icon barrier"></div>
<div class="stext">
<b>Barriers</b>
Frames that orbit the shape and eat any shot fired INTO it.
They cannot be destroyed. Move, or wait for the gap.
</div>
</div>
<div class="srow">
<div class="icon leech"></div>
<div class="stext">
<b>Leeches</b>
Clamp to the shape and siphon your income. They never touch
your cubes. Pop one and it pays back everything it took, with
interest, so letting one feed first is a real choice.
</div>
</div>
<div class="srow">
<div class="icon move"></div>
<div class="stext">
<b>Move like it matters</b>
Speed comes from TIMING your jumps, not from steering: press
jump just as you land for a much bigger hop. A second jump in
the air dashes you along your current direction, which is how
you break a beam.
</div>
</div>
</div>
}
<div class="sheet-actions">
@if ( TutorialPage > 0 )
{
<div class="sheet-btn ghost" onclick=@TutorialBack>BACK</div>
}
<div class="sheet-btn" onclick=@TutorialNext>
@(TutorialPage < TutorialPages - 1 ? "NEXT" : "BEGIN")
</div>
</div>
</div>
</div>
}
else if ( ShowStart )
{
@* START SCREEN. Where every session begins, fresh or returning.
Note for editors: an else must touch the closing brace of its if, a Razor comment
between the two breaks the chain, and there is no such thing as an at-else because
the at sign was already consumed by the opening if.
Also, and this is what broke the build a second time: a Razor comment CANNOT
contain the two character sequence that closes one. Writing it inside the prose
ended the comment early and the remaining words were compiled as C#. *@
<div class="overlay">
<div class="sheet start">
<div class="sheet-title">MONOLITH</div>
<div class="sheet-lead">
@if ( Progress.IsFreshSave )
{
@("A new descent.")
}
else
{
@($"Welcome back. {Num.Short( Progress.Data.LifetimeCubes )} cubes mined, "
+ $"{Progress.Data.Collapses} collapses, {Progress.Data.Cores} cores.")
}
</div>
<div class="sheet-actions column">
<div class="sheet-btn big" onclick=@StartLadder>
@(Progress.IsFreshSave ? "START THE LADDER" : "CONTINUE THE LADDER")
<span class="sub">
@($"stage {Math.Max( 1, Progress.Data.HighestStage )} of {Tuning.PrestigeStageRequirement}"
+ " · climb, upgrade, collapse")
</span>
</div>
<div class="sheet-btn big @(Progress.IsFreshSave ? "locked" : "")"
onclick=@StartMonolith>
THE MONOLITH
<span class="sub">
@if ( Progress.IsFreshSave )
{
@("the shared rock, 16.7 million cubes. Climb a little first: you will not dent it yet.")
}
else
{
@("the shared rock, 16.7 million cubes. Felling it pays cores and prestige.")
}
</span>
</div>
<div class="sheet-btn ghost" onclick=@ReopenTutorial>HOW TO PLAY</div>
</div>
</div>
</div>
}
@* PAUSE MENU. The way out, which the game did not have at all: once you were in, the only
exit was killing the process. Also the only place that tells you the world is frozen. *@
@if ( ShowPause )
{
<div class="overlay transparent">
<div class="sheet pause">
<div class="sheet-title">PAUSED</div>
<div class="sheet-lead">@PauseLead</div>
<div class="sheet-actions column">
<div class="sheet-btn big @(IsPauseSelected( 0 ) ? "selected" : "")"
onclick=@Resume>
RESUME
<span class="sub">or press escape</span>
</div>
<div class="toggle @(IsPauseSelected( 1 ) ? "selected" : "")"
onclick=@ToggleMusic>
<span class="name">MUSIC</span>
<span class="state @(AudioSettings.MusicMuted ? "off" : "on")">
@(AudioSettings.MusicMuted ? "OFF" : "ON")
</span>
</div>
<div class="toggle @(IsPauseSelected( 2 ) ? "selected" : "")"
onclick=@ToggleEffects>
<span class="name">SOUND EFFECTS</span>
<span class="state @(AudioSettings.EffectsMuted ? "off" : "on")">
@(AudioSettings.EffectsMuted ? "OFF" : "ON")
</span>
</div>
<div class="sheet-btn ghost @(IsPauseSelected( 3 ) ? "selected" : "")"
onclick=@ReopenTutorial>HOW TO PLAY</div>
<div class="sheet-btn ghost @(IsPauseSelected( 4 ) ? "selected" : "")"
onclick=@QuitGame>
QUIT
<span class="sub">your progress is saved first</span>
</div>
</div>
</div>
</div>
}
@* The end of the ladder. Stops the run and asks for a decision, instead of silently
condensing stage 101 as if nothing had happened. *@
@if ( Manager != null && Manager.LadderComplete )
{
<div class="ladder-done">
<div class="done-title">LADDER COMPLETE</div>
<div class="done-sub">@($"all {Tuning.PrestigeStageRequirement} stages")</div>
<div class="done-time">@Num.Duration( Manager.LadderCompleteSeconds )</div>
<div class="done-rank">@LadderRankLine</div>
<div class="done-actions">
<div class="done-btn" onclick=@CollapseFromSummary>
COLLAPSE
<span class="sub">
@($"reset to stage 1 and bank {Progress?.PendingCores ?? 0} cores, kept forever")
</span>
</div>
<div class="done-btn" onclick=@GoToMonolith>
THE MONOLITH
<span class="sub">
take your upgrades to the shared rock and keep this run alive
</span>
</div>
</div>
</div>
}
@if ( StageLostEffect.Intensity > 0f )
{
<div class="stage-lost">
<div class="lost-title">STAGE LOST</div>
<div class="lost-sub">a Spotter had you. nothing earned was taken.</div>
</div>
}
@if ( Manager != null && Manager.ShieldActive )
{
<div class="shield-banner">
SHIELDED — DRONES JAMMED @Manager.ShieldSecondsLeft.ToString( "0" )s
<span class="sub">shoot the node above the shape</span>
</div>
}
else if ( Manager != null && Manager.ShieldBroken )
{
<div class="shield-banner broken">
NODE DOWN — DRONES FREE @Manager.ShieldBreakSecondsLeft.ToString( "0.0" )s
<span class="sub">it recharges</span>
</div>
}
@if ( Progress.InHollowRun )
{
<div class="hollow-banner">
HOLLOW RUN · @HollowRuns.Get( Progress.Hollow )?.Name
</div>
}
@* Hidden while the panel is up. It is a live combat readout and it rendered straight
through the upgrade list, on top of the text you were trying to read. *@
@if ( Progress.Resonance > 0 && !PanelOpen )
{
<div class="resonance">
@* Explicit expression, not an implicit one. "[email protected](...) TEXT" is
fragile in Razor: the parser can swallow or mangle the surrounding
literal, which is how this rendered as garbled text. *@
<div class="stacks">@($"RESONANCE x{Progress.ResonanceMultiplier:0.0}")</div>
<div class="rbar">
<div class="fill"
style="width: @((Progress.ResonanceRemaining / Tuning.ResonanceWindow * 100f).ToString( "0.0" ))%;"></div>
</div>
<div class="count">@Progress.Resonance stacks</div>
</div>
}
@if ( Progress.LastMark != null && Progress.TimeSinceMark < 4f )
{
<div class="mark-toast">
<div class="head">MARK EARNED</div>
<div class="name">@Progress.LastMark.Name</div>
</div>
}
@if ( Residue )
{
<div class="residue">
@Remaining @(Remaining == 1 ? "CUBE" : "CUBES") LEFT
<span class="sub">@ResidueHint</span>
</div>
}
@* BURNING. Standing on a live tile no longer kills instantly, so there has to be
something telling you the clock is running. Drawn as a border closing in from the
edges rather than as a bar, because a bar is something you have to look AT and the
whole point is that you are looking at the rock. *@
@if ( Burning )
{
<div class="burn" style="opacity: @(BurnAmount.ToString( "0.00" ));"></div>
<div class="burn-word" style="opacity: @(BurnAmount.ToString( "0.00" ));">MOVE</div>
}
@if ( !PanelOpen && !BlockingScreen )
{
<div class="crosshair @(Aiming ? "on-target" : "")"></div>
@* The charge is buried inside solid rock, so nothing in the world can show you
where it is. Project it onto the screen instead: this is the only reliable way
to know what you are about to detonate. *@
@if ( ChargeOnScreen )
{
<div class="charge-marker" style="left: @(ChargeScreen.x * 100f)%; top: @(ChargeScreen.y * 100f)%;">
<div class="ring"></div>
<div class="label">@((miner.DrillQuality * 100f).ToString( "0" ))% buried</div>
</div>
}
else if ( miner != null && miner.ChargeLive )
{
<div class="charge-offscreen">CHARGE IS BEHIND YOU</div>
}
@if ( miner != null )
{
<div class="charge @ChargeClass">
@* Track A: the charge in the rock, waiting for a left click. *@
@if ( miner.ChargeLive )
{
<div class="live">
CHARGE IN THE ROCK — <b>LMB</b> to detonate
<div class="depth">
<div class="fill" style="width: @((miner.DrillQuality * 100f).ToString( "0" ))%;"></div>
</div>
</div>
}
@* Track B: the reload, entirely independent of the above. *@
@if ( miner.ChargeReady )
{
<div class="ready-line"><b>RMB</b> to fire a charge</div>
}
else if ( miner.ReloadAttemptOpen )
{
@* The 2 second minigame. Target position is rerolled every shot. *@
<div class="reload attempt">
<div class="bar">
<div class="window"
style="left: @((miner.ReloadWindowStart * 100f).ToString( "0.0" ))%;
width: @((miner.ReloadWindowWidth * 100f).ToString( "0.0" ))%;"></div>
<div class="playhead"
style="left: @((miner.ReloadAttemptProgress * 100f).ToString( "0.0" ))%;"></div>
</div>
<span class="sub">
@if ( miner.InActiveReloadWindow )
{
<b>RMB NOW</b>
}
else
{
@("hit the mark to reload instantly")
}
</span>
</div>
}
else
{
<div class="reload">
<div class="bar">
<div class="fill" style="width: @((miner.ReloadProgress * 100f).ToString( "0.0" ))%;"></div>
</div>
<span class="sub">@("reloading " + miner.ChargeCooldownLeft.ToString( "0.0" ) + "s")</span>
</div>
}
</div>
}
@* Prompts follow the DEVICE.
`Input.GetGlyph( action, ... )` returns the icon for whatever the action is bound
to on the connected controller, so an Xbox player sees A where a PlayStation
player sees Cross, from one binding. With no pad it returns an UNBOUND glyph, so
the keyboard branch stays as text rather than showing a row of question marks. *@
@if ( UsingController )
{
<div class="hint pad">
<Image Texture=@Glyph( "Attack1" ) /> <span>mine</span>
<Image Texture=@Glyph( "Attack2" ) /> <span>charge</span>
<Image Texture=@Glyph( "Jump" ) /> <span>jump / hop</span>
<Image Texture=@Glyph( "Run" ) /> <span>sprint</span>
<Image Texture=@Glyph( "Score" ) /> <span>upgrades</span>
</div>
}
else
{
<div class="hint">
<b>LMB</b> mine <b>RMB</b> charge <b>WASD</b> move
<b>SPACE</b> jump / double jump <b>SHIFT</b> sprint <b>TAB</b> upgrades
</div>
}
}
@if ( PanelOpen )
{
@* Full-screen catcher BEHIND the panel. Clicking the play field closes the menu and
hands the mouse back to shooting, alongside TAB, ESC and WASD.
It works because it sits earlier in the tree with a lower z-index, so the panel is
on top and still receives its own clicks. Putting the handler on a backdrop drawn
OVER the panel would eat every purchase. *@
<div class="panel-backdrop" onclick=@ClosePanel></div>
<div class="panel">
<div class="panel-head">
<span class="title @(Tab == 0 ? "on" : "")" onclick=@( () => SetTab( 0 ) )>UPGRADES</span>
@if ( CoresUnlocked )
{
<span class="title @(Tab == 1 ? "on" : "")" onclick=@( () => SetTab( 1 ) )>CORES</span>
}
<span class="title @(Tab == 2 ? "on" : "")" onclick=@( () => SetTab( 2 ) )>MARKS</span>
<span class="title @(Tab == 3 ? "on" : "")" onclick=@( () => SetTab( 3 ) )>RANKS</span>
<span class="dust-inline">@Num.Short( Progress.Data.Dust ) dust</span>
</div>
@* Pad controls are only discoverable if they are shown. A mouse player already
knows to click, so this appears for controllers only. *@
@if ( UsingController )
{
<div class="pad-hints">
<Image Texture=@Glyph( "TabPrev" ) /><Image Texture=@Glyph( "TabNext" ) />
<span>tabs</span>
<Image Texture=@Glyph( "UiConfirm" ) /><span>buy</span>
<Image Texture=@Glyph( "UiAlt" ) /><span>buy max</span>
<Image Texture=@Glyph( "UiBack" ) /><span>close</span>
</div>
}
@if ( Tab == 1 )
{
<div class="upgrades">
<div class="cores-head">
<b>@Progress.AvailableCores</b> cores unspent
· @Progress.Data.Cores earned
</div>
@* None of this was explained anywhere, so it read as three unrelated
currencies. Each block now says what the thing IS before it offers
you anything to spend. *@
<div class="primer">
<div class="ptitle">WHAT ARE CORES?</div>
<div class="ptext">
@($"Cores are permanent. You earn 1 for every {Tuning.StagesPerCore} stages you reached when you COLLAPSE, which resets your dust and upgrade levels back to zero and puts you at stage 1 again. Clearing all {Tuning.PrestigeStageRequirement} stages is worth {Tuning.PrestigeStageRequirement / Tuning.StagesPerCore} cores.")
</div>
<div class="ptext">
@($"You spend them below. Nothing here is lost on a later Collapse, so the point of resetting is that you come back permanently stronger and climb the same ladder faster. You have collapsed {Progress.Data.Collapses} times.")
</div>
<div class="ptext">
@(Progress.CanCollapse
? "You can Collapse right now."
: $"Collapse unlocks at stage {Tuning.PrestigeStageRequirement}. You have reached {Progress.Data.HighestStage}, so {Progress.StagesToCollapse} to go.")
</div>
</div>
@* Slag accrues on a real clock, so it is worth showing even at zero. *@
<div class="slag">
<div class="row">
<span class="name">SLAG <b>@Progress.Data.Slag</b> / @Tuning.SlagMax</span>
<span class="level">
@(Progress.Data.Slag >= Tuning.SlagMax
? "full"
: "next in " + Num.Duration( Progress.MinutesToNextSlag * 60.0 ))
</span>
</div>
<div class="desc">
@($"One Slag every {Tuning.SlagMinutesEach / 60.0:0.#} hours of real time, capped at {Tuning.SlagMax}. It accrues while the game is shut, so there is a reason to open it on a day you do not intend to grind. It is NOT earned by playing.")
</div>
<div class="slag-actions">
<div class="slag-btn @(Progress.Data.Slag >= Tuning.SlagCostFracture ? "on" : "")"
onclick=@Fracture>
FRACTURE <b>@Tuning.SlagCostFracture</b>
<span class="sub">
@($"instantly destroys {Tuning.FractureFraction * 100f:0}% of the cubes left in the current shape ({Num.Short( Remaining * Tuning.FractureFraction )} right now) and pays you the dust for them")
</span>
</div>
<div class="slag-btn @(Progress.Data.Slag >= Tuning.SlagCostReroll && !InMonolith ? "on" : "")"
onclick=@Reroll>
REROLL <b>@Tuning.SlagCostReroll</b>
<span class="sub">
rebuilds this stage as a different shape at full size. For
when you have drawn a shape you find awkward to clear
</span>
</div>
</div>
</div>
@* Hollow runs: restriction plus Collapse, paying Cores only. *@
<div class="hollow">
<div class="row">
<span class="name">HOLLOW RUNS</span>
<span class="level">
@(Progress.InHollowRun
? "active: " + HollowRuns.Get( Progress.Hollow )?.Name
: "+" + Tuning.HollowRunCoreReward + " cores each")
</span>
</div>
<div class="desc">Collapse under a restriction. Pays cores and nothing else.</div>
@foreach ( var run in HollowRuns.All )
{
var cleared = Progress.HasClearedHollow( run.Kind );
var active = Progress.Hollow == run.Kind;
<div class="hollow-btn @(cleared ? "cleared" : "") @(active ? "active" : "") @(Progress.CanCollapse && !Progress.InHollowRun ? "on" : "")"
onclick=@( () => StartHollow( run.Kind ) )>
<span class="hname">@run.Name @(cleared ? " - cleared" : "")</span>
<span class="sub">@run.Rule</span>
</div>
}
</div>
@for ( int i = 0; i < CoreTree.All.Length; i++ )
{
var node = CoreTree.All[i];
var lvl = Progress.CoreLevel( node.Kind );
var cost = Progress.CoreCost( node.Kind );
var affordable = Progress.CanBuyCore( node.Kind );
<div class="upgrade core @(affordable ? "affordable" : "") @(IsSelected( i ) ? "selected" : "")"
onclick=@( () => BuyCore( node.Kind ) )>
<div class="body">
<div class="row">
<span class="name">@node.Name</span>
<span class="level">Lv @lvl</span>
</div>
<div class="desc">@node.Description</div>
<div class="gain">@GainText( node, lvl )</div>
<div class="row">
<span class="current">@(lvl > 0 ? "now: " + FormatText( node, lvl ) : "not taken")</span>
<span class="cost">@cost @(cost == 1 ? "core" : "cores")</span>
</div>
</div>
</div>
}
</div>
}
else if ( Tab == 2 )
{
<div class="upgrades">
<div class="cores-head">
<b>@Progress.MarksEarned</b> / @Marks.All.Length marks
· @($"x{Progress.MarkMultiplier:0.00} to everything")
</div>
<div class="primer">
<div class="ptitle">WHAT ARE MARKS?</div>
<div class="ptext">
@($"One-off achievements. Each one you earn adds a permanent +{Tuning.MarkPowerEach * 100f:0}% to ALL dust you ever collect, and they are never lost, not even to a Collapse. All {Marks.All.Length} of them is x{1f + Marks.All.Length * Tuning.MarkPowerEach:0.00}.")
</div>
<div class="ptext">
You do not buy them and you cannot choose them. They are a
background hum of progress that rewards playing in different
ways rather than doing the same thing faster.
</div>
</div>
@foreach ( var mark in Marks.All )
{
var got = Progress.HasMark( mark.Id );
var pct = got ? 1f : Math.Clamp( mark.Progress?.Invoke( Progress ) ?? 0f, 0f, 1f );
<div class="upgrade mark @(got ? "earned" : "")">
<div class="body">
<div class="row">
<span class="name">@mark.Name</span>
<span class="level">@(got ? "EARNED" : (pct * 100f).ToString( "0" ) + "%")</span>
</div>
<div class="desc">@mark.Hint</div>
@if ( !got )
{
<div class="markbar">
<div class="fill" style="width: @((pct * 100f).ToString( "0.0" ))%;"></div>
</div>
}
</div>
</div>
}
</div>
}
else if ( Tab == 3 )
{
<div class="boards">
@foreach ( var board in Boards )
{
<div class="board">
<div class="board-title">@board.Title</div>
@if ( board.Loading )
{
<div class="board-note">loading...</div>
}
else if ( board.Error != null )
{
<div class="board-note error">@board.Error</div>
}
else if ( board.Rows.Count == 0 )
{
<div class="board-note">
No entries yet. Global boards only populate once the
game is published, so this stays empty while it runs
locally.
</div>
}
else
{
@foreach ( var row in board.Rows )
{
<div class="rank-row @(row.IsMe ? "me" : "")">
<span class="rank">#@row.Rank</span>
<span class="who">@row.Name</span>
<span class="score">@row.Value</span>
</div>
}
}
</div>
}
<div class="refresh" onclick=@RefreshBoards>REFRESH</div>
</div>
}
else
{
<div class="upgrades">
@for ( int i = 0; i < Upgrades.All.Length; i++ )
{
var def = Upgrades.All[i];
var level = Progress.LevelOf( def.Kind );
var cost = Progress.CostOf( def.Kind );
var affordable = Progress.CanAfford( def.Kind );
var bulk = Progress.MaxAffordable( def.Kind );
<div class="upgrade @(affordable ? "affordable" : "") @(IsSelected( i ) ? "selected" : "")">
<div class="body" onclick=@( () => Buy( def.Kind ) )>
@* Header: what it is, and how deep you already are. *@
<div class="head">
<div class="glyph @(def.Kind.ToString().ToLower())"></div>
<span class="name">@def.Name</span>
<span class="level">@level</span>
</div>
@* The one line that answers "what do I get". Given the most weight in the card,
because it is the only part anyone actually reads twice. *@
<div class="gain">@GainText( def, level )</div>
<div class="desc">@def.Description</div>
<div class="foot">
<span class="current">@FormatText( def, level )</span>
<span class="cost">@Num.Short( cost )</span>
</div>
</div>
@if ( bulk > 1 )
{
<div class="buymax" onclick=@( () => BuyMax( def.Kind ) )>
BUY MAX <b>+@bulk</b>
</div>
}
</div>
}
</div>
<div class="travel @(InMonolith ? "back" : "")" onclick=@ToggleMonolith>
@if ( InMonolith )
{
<span class="title">RETURN TO THE LADDER</span>
<span class="sub">Stage @(Tier + 1) · @Stages.NameFor( Tier )</span>
}
else
{
<span class="title">GO TO THE MONOLITH</span>
<span class="sub">Shared. Open to anyone. It will not care that you are here yet.</span>
}
</div>
<div class="collapse @(Progress.CanCollapse ? "ready" : "")" onclick=@Collapse>
<span class="title">COLLAPSE</span>
@if ( Progress.CanCollapse )
{
<span class="sub">
Back to stage 1 for +@Progress.PendingCores cores,
permanently x@((1f + (Progress.Data.Cores + Progress.PendingCores) * Tuning.CorePowerPerCore).ToString( "0.00" ))
</span>
}
else
{
<span class="sub">
@Progress.StagesToCollapse more stages
(reach @Tuning.PrestigeStageRequirement)
</span>
}
</div>
}
</div>
}
}
</root>
@code
{
/// <summary>True while the upgrade panel owns the mouse, so the Miner holds fire.</summary>
public static bool CursorVisible { get; private set; }
private bool PanelOpen;
/// <summary>0 = upgrades, 1 = core tree, 2 = leaderboards.</summary>
private int Tab;
// Deliberately ONE board. Ranking solo progress too would tell players the solo ladder is
// the destination; the only thing worth a global rank is what you take off the Monolith.
private readonly LeaderboardData[] Boards =
{
new( "CUBES REMOVED FROM THE MONOLITH", MonolithStats.StatCubes ),
new( "MOST PRESTIGES", MonolithStats.StatBestCollapses ),
new( "FASTEST FULL LADDER", MonolithStats.StatFastestLadder ) { LowestWins = true },
new( "FASTEST MONOLITH FELLING", MonolithStats.StatFastestMonolith ) { LowestWins = true },
};
// ---------------------------------------------------------------- welcome and start
/// <summary>Number of welcome pages.</summary>
private const int TutorialPages = 5;
private int TutorialPage;
/// <summary>Set once the start screen has been dismissed, for this session only.</summary>
private bool started;
/// <summary>Opened from the start screen, so the pages can be re-read on purpose.</summary>
private bool tutorialReopened;
/// <summary>
/// The welcome pages show automatically on a save that has not seen them, and on demand from
/// the start screen. Waits for Progress to exist, since it is the thing that remembers.
/// </summary>
private bool ShowTutorial
=> Progress != null && (tutorialReopened || !Progress.Data.SeenTutorial);
/// <summary>The start screen sits between the welcome pages and play, once per session.</summary>
private bool ShowStart => Progress != null && !started;
/// <summary>Set while the pause menu is up. Session only, never saved.</summary>
private bool paused;
/// <summary>
/// The pause menu, suppressed behind the welcome and start sheets so escape cannot stack two
/// full-screen sheets on top of each other.
/// </summary>
private bool ShowPause => paused && !ShowTutorial && !ShowStart;
/// <summary>
/// True while any full-screen sheet is up. Firing, movement and the upgrade panel are all
/// suppressed by the same flag that suppresses them for the upgrade panel, so nothing new
/// had to learn about these screens.
/// </summary>
private bool BlockingScreen => ShowTutorial || ShowStart || ShowPause;
/// <summary>
/// Says whether the world actually stopped. Worth stating rather than assuming, because the
/// answer changes with the session: solo really does freeze, and in company it cannot.
/// </summary>
private string PauseLead
=> GameTime.Paused
? "The monolith waits."
: "Others are still playing, so the world keeps going.";
private void Resume()
{
Audio.Close();
paused = false;
PauseSelected = 0;
}
/// <summary>Highlighted row of the pause menu: resume, how to play, quit.</summary>
private int PauseSelected;
private const int PauseItems = 5;
/// <summary>
/// The mute switches. Both play their own confirmation, which is the only feedback that makes
/// sense here: turning effects ON should make a sound, and turning them OFF should be the
/// last sound you hear. The music toggle is silent because the music itself is the answer.
/// </summary>
private void ToggleMusic()
{
AudioSettings.ToggleMusic();
Audio.PlayUi( Audio.UiPress, 0.5f, 1f );
}
private void ToggleEffects()
{
// Ordered so the click lands while effects are still audible when switching OFF, and
// after they are back when switching ON. Either way you hear exactly one.
if ( AudioSettings.EffectsMuted )
{
AudioSettings.ToggleEffects();
Audio.PlayUi( Audio.UiPress, 0.6f, 1.1f );
return;
}
Audio.PlayUi( Audio.UiPress, 0.6f, 0.9f );
AudioSettings.ToggleEffects();
}
private bool IsPauseSelected( int index )
=> UsingController && ShowPause && index == PauseSelected;
/// <summary>
/// Stick and dpad through the pause menu, A to pick, B to resume.
///
/// Not optional the way panel navigation was. The pause menu is the only exit from the game,
/// and on a Steam Deck there is no mouse to reach QUIT with, so a mouse-only pause menu would
/// leave a handheld player with no way out at all.
/// </summary>
private void UpdatePauseSelection()
{
float move = Input.AnalogMove.x;
if ( MathF.Abs( move ) < 0.5f )
timeSinceNav = NavRepeat;
else if ( timeSinceNav >= NavRepeat )
{
timeSinceNav = 0f;
PauseSelected = (PauseSelected + (move > 0f ? -1 : 1) + PauseItems) % PauseItems;
Audio.Navigate();
}
// B always resumes, wherever the highlight happens to be sitting.
if ( Input.Pressed( "UiBack" ) )
{
Resume();
return;
}
if ( !Input.Pressed( "UiConfirm" ) )
return;
switch ( PauseSelected )
{
case 0: Resume(); break;
case 1: ToggleMusic(); break;
case 2: ToggleEffects(); break;
case 3: ReopenTutorial(); break;
case 4: QuitGame(); break;
}
}
/// <summary>
/// Saves before leaving. The autosave runs on a fifteen second timer, so quitting without
/// this can silently discard up to fifteen seconds of mining, which is exactly the kind of
/// loss that makes a player distrust a game that never technically crashed.
/// </summary>
private void QuitGame()
{
Audio.PlayUi( Audio.UiPress, 0.8f, 0.8f );
Progress?.Save();
try { Game.Close(); }
catch ( Exception e ) { Log.Warning( $"Could not close the game: {e.Message}" ); }
}
private void TutorialNext()
{
Audio.Forward();
if ( TutorialPage < TutorialPages - 1 )
{
TutorialPage++;
return;
}
// Recorded on FINISHING rather than on opening, so quitting midway shows them again.
Progress?.MarkTutorialSeen();
TutorialPage = 0;
tutorialReopened = false;
}
private void TutorialBack()
{
Audio.Back();
TutorialPage = Math.Max( 0, TutorialPage - 1 );
}
private void ReopenTutorial()
{
Audio.Open();
TutorialPage = 0;
tutorialReopened = true;
}
private void StartLadder()
{
Audio.Forward();
if ( InMonolith )
Manager?.SetMonolithMode( false );
started = true;
Progress?.StartRunTimer();
}
private void StartMonolith()
{
// A brand new save cannot meaningfully touch 16.7M cubes, so the button says so and
// declines rather than dropping the player somewhere hopeless with no explanation.
if ( Progress?.IsFreshSave ?? true )
{
Audio.Refused();
return;
}
Audio.PlayUi( Audio.UiPress, 0.8f, 1.1f );
Manager?.SetMonolithMode( true );
started = true;
}
/// <summary>
/// One escape key, several meanings, resolved in a fixed order so it is always predictable.
///
/// The engine treats escape and the pad START button as the same press, so this gives the
/// controller a pause button without any binding of our own.
///
/// Escape never dismisses the first-run welcome. It is four pages long, it is the only
/// explanation of the game, and the button that skips it should not also be the button
/// everyone mashes. Reopened on purpose from a menu, it closes on escape like anything else.
/// </summary>
private void UpdateEscape()
{
if ( !Input.EscapePressed )
return;
if ( ShowTutorial )
{
if ( tutorialReopened )
{
TutorialPage = 0;
tutorialReopened = false;
}
return;
}
// Nothing to back out to, and quitting has its own button once you are in.
if ( ShowStart )
return;
if ( ShowPause )
{
Resume();
return;
}
// The panel is a layer over the game, so escape peels that off before it reaches the game.
if ( PanelOpen )
{
PanelOpen = false;
return;
}
// The summary is a decision, not a screen to escape from.
if ( Manager?.LadderComplete ?? false )
return;
Audio.Open();
paused = true;
}
/// <summary>Keyboard and pad paths for the sheets, so neither needs a mouse.</summary>
private void UpdateScreenInput()
{
if ( ShowPause )
{
UpdatePauseSelection();
return;
}
if ( ShowTutorial )
{
if ( Input.Pressed( "UiConfirm" ) || Input.Pressed( "Jump" ) )
TutorialNext();
else if ( Input.Pressed( "UiBack" ) )
TutorialBack();
return;
}
if ( !ShowStart )
return;
if ( Input.Pressed( "UiConfirm" ) || Input.Pressed( "Jump" ) )
StartLadder();
else if ( Input.Pressed( "UiAlt" ) )
StartMonolith();
}
/// <summary>
/// True while a controller is connected, so prompts and navigation follow the device.
///
/// Deliberately based on a pad being PRESENT rather than on the last input used. A Deck
/// player always has one; a desktop player who owns one but is on mouse and keyboard would
/// briefly see the wrong prompts, which is the lesser of the two errors and is easy to
/// revisit if it grates.
/// </summary>
private static bool UsingController
{
get
{
try { return Input.ControllerCount > 0; }
catch ( Exception ) { return false; }
}
}
/// <summary>
/// Glyph for an action on the connected controller. One binding, correct icon per device:
/// Steam Input maps our logical `A` to Cross on a DualSense and A on an Xbox pad, so nothing
/// in this file ever names a physical button.
/// </summary>
private static Texture Glyph( string action )
{
try { return Input.GetGlyph( action, InputGlyphSize.Small, true ); }
catch ( Exception ) { return null; }
}
/// <summary>
/// Closes the upgrade panel. Wired to the backdrop, so clicking the play field dismisses it
/// and hands the mouse straight back to shooting.
/// </summary>
private void ClosePanel() => PanelOpen = false;
/// <summary>
/// Moves along the tabs that are actually open, so a bumper never lands on a hidden one.
///
/// Indices stay fixed rather than being renumbered when Cores unlocks. Renumbering would
/// silently move every hardcoded `Tab == n` check in this file the day the player finishes
/// their first ladder, which is the worst possible moment to introduce a bug.
/// </summary>
private int StepTab( int direction )
{
var open = OpenTabs;
int at = Array.IndexOf( open, Tab );
if ( at < 0 )
return open[0];
return open[(at + direction + open.Length) % open.Length];
}
private void SetTab( int tab )
{
Tab = tab;
// Fetch lazily the first time the boards tab is opened, not on every HUD build.
if ( tab == 3 && Boards.Any( b => !b.HasLoaded && !b.Loading ) )
RefreshBoards();
}
private void BuyCore( CoreNodeKind kind )
{
Progress?.TryBuyCore( kind );
}
private void Fracture()
{
if ( Progress == null || Manager == null ) return;
if ( !Progress.TrySpendSlag( Tuning.SlagCostFracture ) ) return;
Manager.RequestFracture();
}
private void StartHollow( HollowKind kind )
{
if ( Progress == null || Progress.InHollowRun || !Progress.CanCollapse )
return;
Progress.BeginHollowRun( kind );
}
private void Reroll()
{
if ( Progress == null || Manager == null || InMonolith ) return;
if ( !Progress.TrySpendSlag( Tuning.SlagCostReroll ) ) return;
Manager.RerollShape();
}
private void RefreshBoards()
{
foreach ( var board in Boards )
_ = board.Load();
}
private PlayerProgress Progress => PlayerProgress.Local;
private MonolithManager Manager => MonolithManager.Instance;
/// <summary>
/// The "what does the next level buy me" line, made harmless.
///
/// A throwing or NULL Gain used to take the whole list down with it. Razor abandons the
/// render at the point of the exception, so a single bad delegate on the SECOND upgrade left
/// only Rate of Fire on screen and every other upgrade simply gone. Hotload is the usual
/// cause: it cannot always remap lambdas held in a static array
/// ("Unable to find matching substitution for a lambda method"), and leaves them null.
///
/// One missing hint line is a cosmetic problem. An empty upgrade panel is not.
/// </summary>
private static string GainText( UpgradeDef def, int level )
{
try { return def?.Gain?.Invoke( level ) ?? ""; }
catch ( Exception ) { return ""; }
}
private static string GainText( CoreNodeDef def, int level )
{
try { return def?.Gain?.Invoke( level ) ?? ""; }
catch ( Exception ) { return ""; }
}
/// <summary>The same guard for the current-value line, which has the same failure mode.</summary>
private static string FormatText( UpgradeDef def, int level )
{
try { return def?.Format?.Invoke( level ) ?? ""; }
catch ( Exception ) { return ""; }
}
private static string FormatText( CoreNodeDef def, int level )
{
try { return def?.Format?.Invoke( level ) ?? ""; }
catch ( Exception ) { return ""; }
}
/// <summary>
/// The headline. Counts MONOLITHS on the shared rock and STAGES on the solo ladder, because
/// they are different progressions and sharing a number made the Monolith read as stage 48.
/// </summary>
private string TierLine
{
get
{
if ( !InMonolith )
return $"STAGE {Tier + 1} · {StageName}";
int felled = Progress?.Data.MonolithCredits ?? 0;
return $"MONOLITH {felled + 1}";
}
}
private long Remaining => Manager?.World?.SolidCount ?? 0;
private long Total => Manager?.World?.InitialCount ?? 0;
private int Tier => Manager?.Tier ?? 0;
private string StageName => Manager?.StageName ?? "";
/// <summary>How close the player is to burning on a live floor tile, 0 to 1.</summary>
private static float BurnAmount => FloorHazard.BurnFraction;
private static bool Burning => BurnAmount > 0.02f;
/// <summary>
/// Hides the whole HUD for a clean screenshot. Driven by <see cref="PhotoMode"/>.
/// </summary>
/// <remarks>
/// A CLASS rather than disabling the component. `OnUpdate` decides the pause, manages the
/// cursor and projects the charge marker, so switching the component off would quietly change
/// how the game behaves in exactly the frames being photographed.
/// </remarks>
public static bool PhotoHidden { get; set; }
private bool Residue => Remaining > 0 && Remaining <= Tuning.ResidueRevealThreshold;
/// <summary>
/// Turns the leftover cubes into a direction and a distance relative to where the camera
/// is actually looking. In an empty void a compass bearing is useless; "behind you" is not.
/// </summary>
private string ResidueHint
{
get
{
var centre = Manager?.ResidueCentre;
var camera = Scene?.Camera;
if ( !centre.HasValue || !camera.IsValid() )
return "the last of it is lit up";
var delta = centre.Value - camera.WorldPosition;
float distance = delta.Length;
if ( distance < 1f )
return "right here";
var dir = delta.Normal;
var rot = camera.WorldRotation;
float forward = Vector3.Dot( dir, rot.Forward );
float right = Vector3.Dot( dir, rot.Right );
float up = Vector3.Dot( dir, rot.Up );
string where;
if ( forward > 0.75f ) where = "dead ahead";
else if ( forward < -0.5f ) where = "BEHIND YOU";
else if ( right > 0.35f ) where = "to your right";
else if ( right < -0.35f ) where = "to your left";
else if ( up > 0.35f ) where = "above you";
else if ( up < -0.35f ) where = "below you";
else where = "just off to the side";
// Plain text, not an HTML entity: this string is escaped when rendered.
return $"{where} - {distance:N0} units away";
}
}
private float ClearedPercent => Total == 0 ? 0f : (float)(100.0 * (Total - Remaining) / Total);
private Miner miner;
private bool Aiming => miner?.HasAim ?? false;
/// <summary>Screen position of the live charge, 0 to 1 across the viewport.</summary>
private Vector2 ChargeScreen;
private bool ChargeOnScreen;
/// <summary>
/// Projects the live charge to screen space. Computed in OnUpdate rather than lazily in a
/// property, because BuildHash reads it and must see the value for the frame it is hashing.
/// </summary>
private void UpdateChargeMarker()
{
ChargeOnScreen = false;
if ( miner == null || !miner.ChargeLive )
return;
var camera = Scene?.Camera;
if ( !camera.IsValid() )
return;
var world = miner.LiveChargePosition;
// Reject anything behind the camera: the projection wraps round and would place the
// marker on the opposite side of the screen, pointing you the wrong way.
if ( Vector3.Dot( world - camera.WorldPosition, camera.WorldRotation.Forward ) <= 0f )
return;
ChargeScreen = camera.PointToScreenNormal( world );
ChargeOnScreen = ChargeScreen.x >= -0.02f && ChargeScreen.x <= 1.02f
&& ChargeScreen.y >= -0.02f && ChargeScreen.y <= 1.02f;
}
private string ChargeClass
{
get
{
if ( miner == null ) return "";
if ( miner.ActiveReloadJustHit ) return "caught";
if ( miner.InActiveReloadWindow ) return "window";
if ( miner.ChargeLive ) return "armed";
if ( miner.ChargeReady ) return "ready";
return "";
}
}
protected override void OnUpdate()
{
if ( !miner.IsValid() )
miner = Scene?.GetAllComponents<Miner>().FirstOrDefault();
UpdateChargeMarker();
UpdateEscape();
// Freezing the world is decided here, in the one place that knows every sheet that can be
// up. The tutorial used to play out over a live game: hazards hunting you and the run
// timer counting while you read about them.
GameTime.SetPaused( BlockingScreen );
// The sheets own the input while they are up. Checked FIRST so opening the upgrade panel
// or firing cannot happen behind them.
if ( BlockingScreen )
{
PanelOpen = false;
UpdateScreenInput();
CursorVisible = true;
Mouse.Visibility = MouseVisibility.Visible;
SetClass( "panel-open", true );
return;
}
if ( Input.Pressed( "Score" ) )
PanelOpen = !PanelOpen;
if ( PanelOpen && MovementPressed() )
PanelOpen = false;
UpdatePanelInput();
UpdateSummaryInput();
// The summary has buttons, so it needs the cursor exactly like the upgrade panel does.
// Firing is suppressed for the same reason: you are being asked a question, not mining.
bool wantsCursor = PanelOpen || (Manager?.LadderComplete ?? false);
CursorVisible = wantsCursor;
Mouse.Visibility = wantsCursor ? MouseVisibility.Visible : MouseVisibility.Hidden;
SetClass( "panel-open", wantsCursor );
}
/// <summary>
/// Buying was silent whether or not it worked, so a refused purchase and a successful one
/// were indistinguishable without reading the dust counter. The return value was already
/// there; nothing was listening to it.
/// </summary>
private void Buy( UpgradeKind kind )
{
if ( Progress?.TryBuy( kind ) ?? false )
Audio.Purchase();
else
Audio.Refused();
}
private void BuyMax( UpgradeKind kind )
{
int bought = Progress?.BuyMax( kind ) ?? 0;
if ( bought <= 0 )
{
Audio.Refused();
return;
}
Audio.Purchase();
// A block purchase should not sound like a single one. The second, quieter note is the
// whole difference between "bought" and "bought a lot".
if ( bought > 1 )
Audio.PlayUi( Audio.UiReward, 0.4f, 1.35f );
}
private bool InMonolith => Manager?.InMonolith ?? false;
private void ToggleMonolith()
{
Manager?.SetMonolithMode( !InMonolith );
}
/// <summary>
/// Any movement key closes the panel, so you never have to reach for Tab to fly.
/// </summary>
/// <remarks>
/// Jump is EXCLUDED when a controller is connected. On a pad, Jump is bound to A and A is
/// also the confirm button, so leaving it in meant every attempt to buy an upgrade closed
/// the panel instead. The keyboard has separate keys for the two and does not have the
/// problem, which is exactly the kind of thing a keyboard-only test never surfaces.
/// </remarks>
private static bool MovementPressed()
{
bool moved = Input.Down( "Forward" ) || Input.Down( "Backward" )
|| Input.Down( "Left" ) || Input.Down( "Right" )
|| Input.Down( "Duck" );
if ( !UsingController )
moved = moved || Input.Down( "Jump" );
return moved;
}
/// <summary>
/// Pad navigation for the upgrade panel. Runs only while it is open.
///
/// Without this a Steam Deck player has to drive a mouse cursor with the trackpad, which is
/// the thing the whole controller pass exists to avoid. Bumpers cycle tabs because the
/// triggers are mine and charge and the face buttons are wanted for confirm.
/// </summary>
private void UpdatePanelInput()
{
if ( !PanelOpen )
return;
// A progress reset can take Cores away again while the tab is open. Nothing else moves
// the player off a tab that has stopped existing.
if ( Tab == 1 && !CoresUnlocked )
SetTab( 0 );
if ( Input.Pressed( "TabNext" ) )
{
SetTab( StepTab( 1 ) );
Selected = 0;
}
if ( Input.Pressed( "TabPrev" ) )
{
SetTab( StepTab( -1 ) );
Selected = 0;
}
if ( Input.Pressed( "UiBack" ) )
{
PanelOpen = false;
return;
}
UpdateSelection();
}
/// <summary>Tabs in the panel: Upgrades, Cores, Marks, Ranks.</summary>
/// <summary>
/// Whether the permanent Core tree is offered at all.
///
/// **Hidden until a run has been finished.** It was a tab on the panel from the first second
/// of a brand new save, next to the upgrades you actually can buy, explaining a currency you
/// cannot earn for another hundred stages. That is three screens of text about a system that
/// does not apply to you yet, and it made the panel read as a specification rather than as a
/// shop.
///
/// It appears the moment it becomes real: cores in hand, a ladder finished, or a Collapse
/// already behind you. Anything you have earned stays visible forever after.
/// </summary>
private bool CoresUnlocked
=> Progress != null
&& (Progress.Data.Collapses > 0
|| Progress.AvailableCores > 0
|| Progress.Data.Cores > 0
|| Progress.CanCollapse);
/// <summary>Tabs the player can actually reach right now, in order.</summary>
private int[] OpenTabs
=> CoresUnlocked
? new[] { 0, 1, 2, 3 }
: new[] { 0, 2, 3 };
private const int TabCount = 4;
/// <summary>Index of the highlighted card in the current tab.</summary>
private int Selected;
private TimeSince timeSinceNav;
/// <summary>
/// Seconds between steps while the stick is held. Long enough that a flick moves exactly
/// one row, which is what makes a list navigable rather than a slot machine.
/// </summary>
private const float NavRepeat = 0.18f;
/// <summary>
/// How many cards the current tab offers. Marks and Ranks are read-only, so they have no
/// selection and the stick does nothing there rather than moving an invisible cursor.
/// </summary>
private int SelectableCount => Tab switch
{
0 => Upgrades.All.Length,
1 => CoreTree.All.Length,
_ => 0,
};
/// <summary>
/// True if this card is the pad selection. Returns false without a controller, so the
/// mouse experience is untouched and no stray highlight follows a cursor around.
/// </summary>
private bool IsSelected( int index )
=> UsingController && PanelOpen && index == Selected;
/// <summary>
/// Stick or dpad moves the highlight, A buys, Y buys max.
///
/// The stick is free for this because our movement actions are keyboard-only bindings, so
/// pushing it never reaches `MovementPressed` and cannot close the panel. That is a
/// consequence of the binding audit rather than an accident, and it is worth knowing before
/// anyone gives Forward a `GamepadCode`.
/// </summary>
private void UpdateSelection()
{
int count = SelectableCount;
if ( count <= 0 )
return;
// Forward on the stick is +x, and forward should move UP the list.
float move = Input.AnalogMove.x;
if ( MathF.Abs( move ) < 0.5f )
{
// Released: arm the next step so a deliberate flick responds instantly instead of
// waiting out a repeat delay it never earned.
timeSinceNav = NavRepeat;
}
else if ( timeSinceNav >= NavRepeat )
{
timeSinceNav = 0f;
Selected = (Selected + (move > 0f ? -1 : 1) + count) % count;
}
Selected = Math.Clamp( Selected, 0, count - 1 );
if ( Input.Pressed( "UiConfirm" ) )
ConfirmSelected();
if ( Input.Pressed( "UiAlt" ) )
AltSelected();
}
/// <summary>A on the highlighted card: buy one.</summary>
private void ConfirmSelected()
{
if ( Tab == 0 && Selected < Upgrades.All.Length )
Buy( Upgrades.All[Selected].Kind );
else if ( Tab == 1 && Selected < CoreTree.All.Length )
BuyCore( CoreTree.All[Selected].Kind );
}
/// <summary>Y on the highlighted card: buy max. Cores have no bulk purchase, so it is a no-op there.</summary>
private void AltSelected()
{
if ( Tab == 0 && Selected < Upgrades.All.Length )
BuyMax( Upgrades.All[Selected].Kind );
}
/// <summary>
/// Pad navigation for the ladder-complete summary, which had NO keyboard or pad path at all:
/// two clickable buttons and nothing else. Confirm takes the Monolith, Back collapses, so
/// the destructive one is not the default.
/// </summary>
private void UpdateSummaryInput()
{
if ( !(Manager?.LadderComplete ?? false) )
return;
if ( Input.Pressed( "UiConfirm" ) )
GoToMonolith();
else if ( Input.Pressed( "UiBack" ) )
CollapseFromSummary();
}
private void Collapse()
{
Progress?.Collapse();
}
/// <summary>
/// Where this run's time places on the global board. Reads the already-fetched ladder board
/// rather than issuing a request, so the summary never waits on the network to appear.
/// </summary>
private string LadderRankLine
{
get
{
var board = Boards.FirstOrDefault( b => b.Title == "FASTEST FULL LADDER" );
if ( board == null || board.Rows == null || board.Rows.Count == 0 )
return "global rank unavailable";
float mine = Manager?.LadderCompleteSeconds ?? 0f;
// Row is a STRUCT, so FirstOrDefault cannot be null-checked. Ask whether one exists
// before taking it.
if ( board.Rows.Any( r => r.IsMe ) )
return $"global rank #{board.Rows.First( r => r.IsMe ).Rank}";
int beaten = board.Rows.Count( r => r.Raw > mine );
return $"faster than {beaten} of the top {board.Rows.Count}";
}
}
/// <summary>Collapse from the summary. Ends the run and banks the Cores.</summary>
private void CollapseFromSummary()
{
Manager?.ClearLadderComplete();
Progress?.Collapse();
Audio.Collapse();
}
/// <summary>
/// Travel to the shared Monolith instead of collapsing. Keeps the run, and the upgrades,
/// which is the whole reason a fully armed player is the one who should be going there.
/// </summary>
private void GoToMonolith()
{
Manager?.ClearLadderComplete();
Manager?.SetMonolithMode( true );
Audio.PlayUi( Audio.UiPress, 0.8f, 1.1f );
}
protected override int BuildHash()
{
var p = Progress;
int boardHash = 17;
foreach ( var b in Boards )
boardHash = boardHash * 31 + b.Revision;
// Charge state is quantised to a tenth of a second: the cooldown readout needs to tick,
// but rebuilding the whole HUD every frame for it would be wasteful.
// The reload minigame runs for two seconds and needs a smooth playhead, so it is
// quantised finely while it is open and coarsely the rest of the time.
int chargeHash = miner == null ? 0
: HashCode.Combine( miner.ChargeLive, miner.ChargeReady, miner.ReloadAttemptOpen,
miner.InActiveReloadWindow, miner.ActiveReloadJustHit,
(int)(miner.ReloadAttemptProgress * 300f),
(int)(miner.ReloadProgress * 100f),
HashCode.Combine( (int)(miner.DrillQuality * 50f),
(int)(ChargeScreen.x * 400f), (int)(ChargeScreen.y * 400f) ) );
// While hunting the last cubes the hint depends on where you are looking, so refresh it
// several times a second. Outside that state nothing here changes per frame.
int residueTick = Residue ? (int)(Time.Now * 5f) : 0;
// The run timer is always on screen, so the HUD needs to rebuild ten times a second
// regardless of what else changed.
int timerTick = (int)(Time.Now * 10f);
return System.HashCode.Combine(
PanelOpen || (Manager?.LadderComplete ?? false)
|| ShowTutorial || ShowStart || ShowPause || PhotoHidden,
// Selection and device folded into the tab slot: both change what the panel draws,
// and a highlight that does not repaint is a highlight that does not exist.
Tab ^ (Selected * 31) ^ (UsingController ? 7717 : 0) ^ (TutorialPage * 131)
^ (PauseSelected * 1973)
^ (AudioSettings.MusicMuted ? 2609 : 0)
^ (AudioSettings.EffectsMuted ? 3299 : 0),
Aiming,
Remaining ^ residueTick ^ timerTick ^ ((int)(BurnAmount * 20f) * 6151)
^ ((p?.Resonance ?? 0) * 7919) ^ (Leech.All.Count * 104729)
^ ((Manager?.ShieldActive ?? false) ? 31337 : 0),
Tier + (InMonolith ? 100000 : 0),
p?.Data.Dust.GetHashCode() ?? 0,
boardHash ^ chargeHash,
p == null ? 0 : UpgradeHash( p ) );
}
private static int UpgradeHash( PlayerProgress p )
{
int hash = 17;
foreach ( var level in p.Data.UpgradeLevels )
hash = hash * 31 + level;
return hash;
}
}
@using System
@using Sandbox.UI
@namespace Sandbox
@inherits Panel
<root>
@if ( !_isSubmenu )
{
<div class="background" @onmousedown=@Close @onclick=@Close></div>
}
<div class="inner @( _submenuOpensLeft && _options.Any( o => o.SubmenuBuilder != null ) ? "opens-left" : "" )">
@foreach ( var option in _options )
{
if ( option.IsSpacer )
{
<div class="spacer"></div>
continue;
}
var opt = option;
<div class="option @( opt.SubmenuBuilder != null ? "has-submenu" : "" )"
@onclick="@(() => OnOptionClick( opt ))"
@onmouseover="@(() => OnOptionHover( opt ))">
<div class="icon">@opt.Icon</div>
<div class="text">@opt.Text</div>
@if ( opt.SubmenuBuilder != null )
{
<div class="submenu-arrow">@( _submenuOpensLeft ? "◀" : "▶" )</div>
}
</div>
}
</div>
</root>
@code
{
record MenuOption( string Icon, string Text, Action Action, Action<MenuPanel> SubmenuBuilder = null )
{
public bool IsSpacer => Icon == "__spacer__";
}
readonly List<MenuOption> _options = new();
MenuPanel _openSubmenu;
MenuOption? _activeSubmenuOption;
bool _isSubmenu;
bool _openLeft;
float _parentLeft;
bool _submenuOpensLeft;
Rect _layoutRect;
Panel _source;
public static MenuPanel Open( Panel source )
{
var root = source.FindRootPanel();
var menu = root.AddChild<MenuPanel>();
menu._source = source;
menu.Style.Left = root.MousePosition.x * root.ScaleFromScreen;
menu.Style.Top = root.MousePosition.y * root.ScaleFromScreen;
menu.PlaySound( "sounds/kenney/ui/ui.navigate.forward.sound" );
return menu;
}
public override void Tick()
{
base.Tick();
if ( _isSubmenu ) return;
if ( !_source.IsValid() ) { Close(); return; }
// Walk the source ancestor chain — if any panel becomes invisible, close the menu
if ( _source.AncestorsAndSelf.Any( p => !p.IsVisible ) )
Close();
}
public void AddOption( string icon, string text, Action action )
{
_options.Add( new MenuOption( icon, text, action ) );
}
public void AddSubmenu( string icon, string text, Action<MenuPanel> builder )
{
_options.Add( new MenuOption( icon, text, null, builder ) );
}
public void AddSpacer()
{
_options.Add( new MenuOption( "__spacer__", "", null ) );
}
void OnOptionClick( MenuOption option )
{
if ( option.SubmenuBuilder != null )
OnOptionHover( option );
else
SelectOption( option );
}
void OnOptionHover( MenuOption option )
{
if ( _activeSubmenuOption == option ) return;
_activeSubmenuOption = option;
CloseSubmenu();
if ( option.SubmenuBuilder == null ) return;
const int minWidth = 200;
const int gap = 4;
const int padding = 10;
bool hasRoomRight = Box.Right + gap + minWidth < Screen.Width - padding;
var root = FindRootPanel();
var submenu = root.AddChild<MenuPanel>();
submenu._isSubmenu = true;
submenu._openLeft = !hasRoomRight;
submenu._parentLeft = Box.Left;
submenu.Style.Left = hasRoomRight ? Box.Right + gap : Box.Left - gap - minWidth;
submenu.Style.Top = root.MousePosition.y * root.ScaleFromScreen - gap;
option.SubmenuBuilder( submenu );
submenu.StateHasChanged();
_openSubmenu = submenu;
}
void CloseSubmenu()
{
if ( _openSubmenu == null ) return;
_openSubmenu.Delete( false );
_openSubmenu = null;
}
void SelectOption( MenuOption option )
{
CloseAll( this );
option.Action?.Invoke();
}
public void Close()
{
CloseAll( this );
}
static void CloseAll( Panel source )
{
var root = source.FindRootPanel();
foreach ( var menu in root.Children.OfType<MenuPanel>().ToList() )
menu.Delete( false );
}
public override void OnLayout( ref Rect layoutRect )
{
const int padding = 10;
const int gap = 4;
if ( _openLeft )
{
var w = layoutRect.Width;
layoutRect.Right = _parentLeft - gap;
layoutRect.Left = layoutRect.Right - w;
}
if ( layoutRect.Right > Screen.Width - padding )
{
layoutRect.Left -= layoutRect.Right - ( Screen.Width - padding );
layoutRect.Right = Screen.Width - padding;
}
if ( layoutRect.Bottom > Screen.Height - padding )
{
layoutRect.Top -= layoutRect.Bottom - ( Screen.Height - padding );
layoutRect.Bottom = Screen.Height - padding;
}
_layoutRect = layoutRect;
_submenuOpensLeft = !_isSubmenu && !( layoutRect.Right + 4 + 200 < Screen.Width - 10 );
}
}
@using Sandbox;
@using Sandbox.UI;
@inherits Panel
@namespace Sandbox
<root>
<div class="menu-icon-toggle-group">
<IconPanel Tooltip="Compact" class=@( Size == 60 ? "active" : "" ) Text="view_compact" @onclick=@( () => ChangeSize( 60 ) )></IconPanel>
<IconPanel Tooltip="Medium" class=@( Size == 120 ? "active" : "" ) Text="view_module" @onclick=@( () => ChangeSize( 120 ) )></IconPanel>
<IconPanel Tooltip="Large" class=@( Size == 200 ? "active" : "" ) Text="grid_view" @onclick=@( () => ChangeSize( 200 ) )></IconPanel>
</div>
</root>
@code
{
public int Size { get; set; } = 120;
protected override int BuildHash() => HashCode.Combine( Size );
void ChangeSize( int size )
{
Size = size;
}
}
@using Sandbox;
@using Sandbox.UI;
@inherits Panel
@namespace Sandbox
<root>
@if ( Left != null )
{
<div class="left">@Left</div>
}
<div class="body">@Body</div>
</root>
@code
{
[Parameter] public RenderFragment Left { get; set; }
[Parameter] public RenderFragment Body { get; set; }
}
@using Sandbox;
@using Sandbox.UI;
@attribute [InspectorEditor(typeof(Dresser))]
@inherits Panel
@namespace Sandbox
@implements IInspectorEditor
<root>
<div class="body">
<Label>todo: add / remove clothing resources</Label>
</div>
<div class="footer">
<Button class="menu-action primary" Text="Randomize" Icon="🎲" onclick=@DoRandomize></Button>
<Button class="menu-action primary" Text="Clear" Icon="🧹" onclick=@DoClear></Button>
</div>
</root>
@code
{
public string Title => "👗 Dresser";
public Dresser Target { get; private set; }
public bool TrySetTarget(List<GameObject> selection)
{
Dresser found = null;
foreach (var go in selection)
{
if ( go.Tags.Has( "player" ) ) continue;
found = go.GetComponentInChildren<Dresser>();
if (found != null) break;
}
if (found == Target) return Target != null;
Target = found;
StateHasChanged();
return Target != null;
}
void DoRandomize() => TryBroadcastRandomize( Target.GameObject );
void DoClear() => TryBroadcastClear( Target.GameObject );
[Rpc.Host]
private static void TryBroadcastRandomize(GameObject go)
{
if ( !go.IsValid() ) return;
var dresser = go.GetComponentInChildren<Dresser>();
if ( dresser is null ) return;
dresser.Randomize();
// Refresh the object to update the clothing items for everyone
go.Network.Refresh();
}
[Rpc.Host]
private static async void TryBroadcastClear(GameObject go)
{
if ( !go.IsValid() ) return;
var dresser = go.GetComponentInChildren<Dresser>();
if ( dresser is null ) return;
dresser.Clothing.Clear();
dresser.Clear();
await dresser.Apply();
// Refresh the object to update the clothing items for everyone
go.Network.Refresh();
}
}
@using Sandbox;
@using Sandbox.UI;
@inherits Panel
@namespace Sandbox
<root>
@if ( _selected.IsValid() )
{
<div class="header">
<h2>
<span>🎨</span>
<span>@_selected.Title</span>
</h2>
</div>
@if ( _properties.Count > 0 )
{
<ControlSheet Target="@_properties" />
}
else
{
<div class="empty-state">No editable properties.</div>
}
}
else
{
<div class="empty-state">Select an effect to edit its properties.</div>
}
</root>
@code
{
PostProcessManager _manager;
PostProcessResource _selected;
List<SerializedProperty> _properties = new();
public override void Tick()
{
if ( _manager is null )
_manager = Game.ActiveScene.GetSystem<PostProcessManager>();
var selectedPath = _manager?.SelectedPath;
var resource = selectedPath != null
? ResourceLibrary.Get<PostProcessResource>( selectedPath )
: null;
if ( resource != _selected )
{
_selected = resource;
RebuildProperties();
}
if ( _selected is not null )
StateHasChanged();
}
void RebuildProperties()
{
var path = _selected?.ResourcePath;
if ( path is null )
{
_properties = [];
return;
}
var props = new List<SerializedProperty>();
props.Add( TypeLibrary.CreateProperty( "Enabled",
() => _manager?.IsEnabled( path ) ?? false,
v => _manager?.Set( path, v ) ) );
foreach ( var component in _manager?.GetSelectedComponents() ?? [] )
{
var so = TypeLibrary.GetSerializedObject( component );
so.OnPropertyChanged = OnPropertyChanged;
props.AddRange( so.Where( FilterProperties ) );
}
_properties = props;
}
static bool FilterProperties( SerializedProperty o )
{
if ( o.PropertyType is null ) return false;
if ( o.PropertyType.IsAssignableTo( typeof(Delegate) ) ) return false;
if ( o.IsMethod ) return true;
if ( !o.HasAttribute<PropertyAttribute>() ) return false;
return true;
}
void OnPropertyChanged( SerializedProperty prop )
{
foreach ( var target in prop.Parent.Targets )
{
if ( target is Component component )
GameManager.ChangeProperty( component, prop.Name, prop.GetValue<object>() );
}
}
protected override int BuildHash() => HashCode.Combine( _manager?.SelectedPath );
}
@using Sandbox;
@using Sandbox.UI;
@inherits Panel
@namespace Sandbox
<root>
<div class="label @(IsActive ? "active" : "")">@Title</div>
<div class="sliders">
<SliderControl Property=@_so?.GetProperty(nameof(Strength)) ShowTextEntry=@(false) ShowValueTooltip=@(false)></SliderControl>
@if (IsPaired)
{
<SliderControl Property=@_so?.GetProperty(nameof(Side)) ShowTextEntry=@(false) ShowValueTooltip=@(false)></SliderControl>
}
</div>
</root>
@code
{
[Parameter] public SkinnedModelRenderer Target { get; set; }
[Parameter] public string NameA { get; set; }
[Parameter] public string NameB { get; set; }
[Parameter] public string Title { get; set; }
[Parameter] public Action<string, float> OnMorphChanged { get; set; }
bool IsPaired => NameB != null;
bool IsActive => (Target?.SceneModel?.Morphs.Get(NameA) ?? 0f) > 0f || (IsPaired && (Target?.SceneModel?.Morphs.Get(NameB) ?? 0f) > 0f);
SerializedObject _so;
// Live helpers — always read from the rendered mesh so sliders stay in sync
float LiveStrength()
{
if (!Target.IsValid()) return 0f;
var l = Target.SceneModel.Morphs.Get(NameA);
var r = IsPaired ? Target.SceneModel.Morphs.Get(NameB) : 0f;
return MathF.Max(l, r);
}
float LiveSide()
{
if (!Target.IsValid() || !IsPaired) return 0f;
var l = Target.SceneModel.Morphs.Get(NameA);
var r = Target.SceneModel.Morphs.Get(NameB);
if (r > l) return l == 0 ? -1f : -(1f - l / r);
if (l > r) return r == 0 ? 1f : 1f - r / l;
return 0f;
}
[Range(0f, 1f), Step(0.01f)]
public float Strength
{
get => LiveStrength();
set
{
if (!Target.IsValid()) return;
var side = LiveSide();
if (IsPaired)
{
var valA = value * side.Remap(0, -1, 1, 0).Clamp(0, 1);
var valB = value * side.Remap(0, 1, 1, 0).Clamp(0, 1);
Target.SceneModel.Morphs.Set(NameA, valA);
Target.SceneModel.Morphs.Set(NameB, valB);
OnMorphChanged?.Invoke(NameA, valA);
OnMorphChanged?.Invoke(NameB, valB);
}
else
{
Target.SceneModel.Morphs.Set(NameA, value);
OnMorphChanged?.Invoke(NameA, value);
}
}
}
[Range(-1f, 1f), Step(0.01f)]
public float Side
{
get => LiveSide();
set
{
if (!Target.IsValid() || !IsPaired) return;
var strength = LiveStrength();
var valA = strength * value.Remap(0, -1, 1, 0).Clamp(0, 1);
var valB = strength * value.Remap(0, 1, 1, 0).Clamp(0, 1);
Target.SceneModel.Morphs.Set(NameA, valA);
Target.SceneModel.Morphs.Set(NameB, valB);
OnMorphChanged?.Invoke(NameA, valA);
OnMorphChanged?.Invoke(NameB, valB);
}
}
protected override void OnParametersSet()
{
base.OnParametersSet();
_so ??= TypeLibrary.GetSerializedObject(this);
}
public void Clear()
{
if (!Target.IsValid()) return;
Target.Morphs.Clear(NameA);
if (IsPaired) Target.Morphs.Clear(NameB);
StateHasChanged();
}
protected override int BuildHash() => HashCode.Combine(Target, NameA, NameB);
}
@using Sandbox;
@using Sandbox.UI;
@using Sandbox.Mounting;
@inherits Panel
@namespace Sandbox
<SpawnMenuContent>
<Body>
<VirtualGrid Items=@( GetData() ) ItemSize=@ItemSize>
<Item Context="item">
@if (item is Storage.Entry content)
{
<LocalDupeIcon Content="@content" @onclick="@(() => SwitchToDupe(content))"></LocalDupeIcon>
}
</Item>
</VirtualGrid>
</Body>
</SpawnMenuContent>
@code
{
public static int ItemSize { get; set; } = 120;
protected override int BuildHash() => HashCode.Combine(ItemSize);
protected override async Task OnParametersSetAsync()
{
}
void SwitchToDupe( Storage.Entry content )
{
var localPlayer = Player.FindLocalPlayer();
if ( localPlayer == null ) return;
var inventory = localPlayer.GetComponent<PlayerInventory>();
if ( !inventory.IsValid() ) return;
inventory.SetToolMode( "Duplicator" );
var toolmode = localPlayer.GetComponentInChildren<Duplicator>();
if ( toolmode is null )
{
// we are a client and didn't switch tools in time, we need to wait for the server to tell us we have the tool
// Some kind of async wait on an rpc would work, probably.
Log.Warning( "I thought this might happen. We are a client and didn't switch tools in time." );
return;
}
var json = content.Files.ReadAllText( "/dupe.json" );
toolmode.Load( json );
}
IEnumerable<object> GetData()
{
return Storage.GetAll( "dupe" ).OrderByDescending( x => x.Created );
}
}
@using Sandbox;
@using Sandbox.UI;
@namespace Sandbox
@inherits Panel
@attribute [SpawnMenuHost.SpawnMenuMode]
@attribute [Icon("💫")]
@attribute [Title( "Ultimate Spawn Menu" )]
@attribute [Order( -100 )]
<root>
<div class="container">
<div class="spawnmenuleft">
<div class="tabs">
@foreach (var t in tabs)
{
var type = Game.TypeLibrary.GetType(t.GetType());
var c = activeTab == t ? "active" : "";
<div class="@c" @onclick=@(() => SwitchTab(t))>
<span>@type.Icon</span>
<span>@type.Title</span>
</div>
}
</div>
<div class="body" @ref="TabContainer">
</div>
</div>
<div class="spawnmenuright">
<div class="tabs">
@foreach (var t in utilityTabs)
{
var type = Game.TypeLibrary.GetType(t.GetType());
var c = activeUtilityTab == t ? "active" : "";
<div class="@c" @onclick=@(() => SwitchUtilityTab(t))>
<span>@type.Icon</span>
<span>@type.Title</span>
</div>
}
</div>
<div class="body" @ref="UtilityContainer">
</div>
</div>
</div>
</root>
@code
{
Panel activeTab;
Panel TabContainer = default;
Panel activeUtilityTab;
Panel UtilityContainer = default;
List<Panel> tabs = new List<Panel>();
List<Panel> utilityTabs = new List<Panel>();
public void BuildTabs()
{
foreach (var p in Game.TypeLibrary.GetTypes<ISpawnMenuTab>().OrderBy(x => x.Order).ThenBy(x => x.Title))
{
if (p.IsAbstract) continue;
if (p.TargetType == typeof(BaseSpawnMenu)) continue;
var panel = p.Create<Panel>();
TabContainer.AddChild(panel);
tabs.Add(panel);
}
SwitchTab( RestoreTab( tabs, "spawnmenu.tab" ) );
}
public void BuildUtilityTabs()
{
foreach (var p in Game.TypeLibrary.GetTypes<IUtilityTab>().OrderBy(x => x.Order).ThenBy(x => x.Title))
{
if (p.IsAbstract) continue;
var panel = p.Create<Panel>();
UtilityContainer.AddChild(panel);
utilityTabs.Add(panel);
}
SwitchUtilityTab( RestoreTab( utilityTabs, "spawnmenu.utilitytab" ) );
}
Panel RestoreTab( List<Panel> panels, string cookieKey )
{
var saved = Game.Cookies.Get<string>( cookieKey, null );
if ( !string.IsNullOrEmpty( saved ) )
{
var match = panels.FirstOrDefault( p => p.GetType().Name == saved );
if ( match is not null ) return match;
}
return panels.FirstOrDefault();
}
protected override void OnAfterTreeRender(bool firstTime)
{
if (firstTime && TabContainer.IsValid())
{
BuildTabs();
}
if (firstTime && UtilityContainer.IsValid())
{
BuildUtilityTabs();
}
}
void SwitchTab(Panel tab)
{
activeTab = tab;
Game.Cookies.Set( "spawnmenu.tab", tab?.GetType().Name );
StateHasChanged();
foreach (var t in tabs)
{
t.SetClass("active", t == tab);
t.SetClass("hidden", t != tab);
}
}
void SwitchUtilityTab(Panel tab)
{
activeUtilityTab = tab;
Game.Cookies.Set( "spawnmenu.utilitytab", tab?.GetType().Name );
StateHasChanged();
foreach (var t in utilityTabs)
{
t.SetClass("active", t == tab);
t.SetClass("hidden", t != tab);
}
}
// When clicking anywhere in the spawn menu, blur any focused text inputs
// so we can close the menu by pressing the spawnmenu key.
void OnMenuClicked()
{
Sandbox.UI.InputFocus.Clear();
}
}
@using Sandbox.UI;
@using System;
@inherits Panel
@namespace PlanetMeat
<root>
@{
<div class="game-title">
<span>#game.by.title</span>
</div>
<div class="game-by">
<span>#game.by.alxl</span>
</div>
<div></div>
var ctx = Career.Current.CareerStats;
<div class="category">
@{
var (myAllDmg, globalAllDmg) = GetDamageText(ctx, "all");
<span class="title key">#stats.dmg.all</span>
<span class="value">@myAllDmg</span>
<span class="value">@globalAllDmg</span>
}
@foreach (var tool in Catalogue.Current.BlameTools)
{
var (myDmg, globalDmg) = GetDamageText(ctx, "by_" + tool);
if (myDmg != "" && globalDmg != "")
{
<span class="key">#tool.@(tool).title</span>
<span class="value">@myDmg</span>
<span class="value">@globalDmg</span>
}
}
</div>
@foreach (var (header, headerStat, statPrefix, statIter, getDisplayName) in Categories)
{
var (myHeadStat, globalHeadStat) = GetGenericText(ctx, headerStat);
<div class="category">
<span class="title key">#stats.@header</span>
<span class="value">@myHeadStat</span>
<span class="value">@globalHeadStat</span>
@foreach (var stat in statIter)
{
var (myStat, globalStat) = GetGenericText(ctx, statPrefix + stat);
if (myStat != "" && globalStat != "")
{
<span class="key">#@(getDisplayName.Invoke(stat))</span>
<span class="value">@myStat</span>
<span class="value">@globalStat</span>
}
}
</div>
}
<div class="category">
@{
var (myBank, globalBank) = GetDamageText(ctx, "meat_banked");
<span class="title key">#stats.meat.banked</span>
<span class="value">@myBank</span>
<span class="value">@globalBank</span>
var (myLosses, globalLosses) = GetDamageText(ctx, "meat_lost");
<span class="key">#stats.meat.lost</span>
<span class="value">@myLosses</span>
<span class="value">@globalLosses</span>
var (myWins, globalWins) = GetDamageText(ctx, "runs_banked");
<span class="key">#stats.runs.banked</span>
<span class="value">@myWins</span>
<span class="value">@globalWins</span>
var (myRounds, globalRounds) = GetDamageText(ctx, "round");
<span class="key">#stats.rounds.passed</span>
<span class="value">@myRounds</span>
<span class="value">@globalRounds</span>
}
</div>
<div class="category">
@{
var (mySpent, globalSpent) = GetDamageText(ctx, "meat_spent");
<span class="title key">#stats.meat.spent</span>
<span class="value">@mySpent</span>
<span class="value">@globalSpent</span>
var (myImps, globalImps) = GetDamageText(ctx, "improvements");
<span class="key">#stats.improvements.bought</span>
<span class="value">@myImps</span>
<span class="value">@globalImps</span>
var (myTechs, globalTechs) = GetDamageText(ctx, "tech");
<span class="key">#stats.tech.researched</span>
<span class="value">@myTechs</span>
<span class="value">@globalTechs</span>
}
</div>
<div></div>
<div></div>
<div class="hint">
<span>#stats.hint</span>
</div>
}
</root>
@code
{
private IEnumerable<(string, string, string, IEnumerable<string>, Func<string, string>)> Categories => field ??= [
("kill_weps.all", "wep_kills", "kills_by_", Catalogue.Current.BlameTools, (t) => "tool." + t + ".title"),
("kill_types.all", "kills_all", "kills_", Agenda.Current.AllVariantIdents, EnemyVariant.GetDisplayName),
("runs.lost", "runs_lost", "defeated_by_", Agenda.Current.AllVariantIdents, EnemyVariant.GetDisplayName),
];
private static string GetDamageString(double dmg, double overkill) => LargeNumberString.ToCompact(dmg - overkill) + " · " + LargeNumberString.ToCompact(overkill);
private (string, string) GetDamageText(StatContext ctx, string scope)
{
var dmgIdent = "damage_" + scope;
var overkillIdent = "overkill_" + scope;
var myDmg = ctx.GetNow(dmgIdent);
var myOverkill = ctx.GetNow(overkillIdent);
var worldDmg = Career.GetGlobalStat(dmgIdent);
var worldOverkill = Career.GetGlobalStat(overkillIdent);
return (GetDamageString(myDmg, myOverkill), "(" + GetDamageString(worldDmg, worldOverkill) + ")");
}
private (string, string) GetGenericText(StatContext ctx, string ident)
{
var me = ctx.GetNow(ident);
var world = Career.GetGlobalStat(ident);
return (LargeNumberString.ToCompact(me), "(" + LargeNumberString.ToCompact(world) + ")");
}
protected override int BuildHash() => HashCode.Combine(
HqNavigator.View
);
}
@namespace KOTH.UI
@using KOTH.Notification
@using Sandbox.Events
@inherits PanelComponent
@if (!DeathCameraData.IsValid())
{
return;
}
<root>
<div class="dead">
killed by:
</div>
<div class="name">
@DeathCameraData.KillerName
</div>
<div class="hp">
@DeathCameraData.KillerHealth HP
</div>
</root>
@code
{
public static PlayerDeathHUD Instance { get; set; }
public PlayerDeathHUD()
{
Instance = this;
}
public FDeathCameraData DeathCameraData { get; set; }
public void SetData(FDeathCameraData Data)
{
DeathCameraData = Data;
}
protected override void OnEnabled()
{
base.OnEnabled();
}
protected override void OnUpdate()
{
base.OnUpdate();
}
protected override int BuildHash()
{
return HashCode.Combine(Time.Now);
}
}@* @namespace KOTH.UI
@inherits Panel
@attribute [StyleSheet]
@if (!Player.IsValid() || !EngiePlayer.IsValid() || EngiePlayer.ActiveTurretComponent == null)
{
return;
}
<root>
<label class="building">@(EngiePlayer.ActiveTurretComponent.DamageComponent.Health)/@(EngiePlayer.ActiveTurretComponent.DamageComponent.MaxBaseHealth)</label>
</root>
@code
{
public PlayerPawn Player => PlayerState.Local.PlayerPawn;
public EngiePlayer EngiePlayer => Player.GameObject.GetComponent<EngiePlayer>();
protected override int BuildHash()
{
if (!Player.IsValid())
return 0;
return HashCode.Combine(Time.Now);
}
}
*@@namespace KOTH.UI
@attribute [StyleSheet]
@inherits Panel
<root class="">
@* @foreach (var voice in VoiceList.Where(x => CanDisplay(x)))
{
<div class="row">
<div class="item-row with-background">
@{
var player = GetPlayer(voice);
}
<Icon class="square rounded shrink-0" File="/ui/chat/radio.png" size="24" style="transform: scale( @GetAmplitudeScale( voice ) )" />
<img class="avatar" src="avatar:@player.SteamId" />
<PlayerPanel PlayerState=@player />
</div>
</div>
} *@
</root>
@code
{
public IEnumerable<PlayerVoiceComponent> VoiceList => Scene.GetAllComponents<PlayerVoiceComponent>().Where(x => x.PlayerState.IsConnected);
private PlayerState GetPlayer(Voice voice)
{
return voice.Components.Get<PlayerState>();
}
private bool CanDisplay(Voice voice)
{
return voice.LastPlayed < 0.25f;
}
private string GetAmplitudeScale(Voice voice)
{
var scale = 0.5f + (voice.Amplitude * 2.365f);
return (1f + scale).ToString("0.#");
}
protected override int BuildHash()
{
return HashCode.Combine(Time.Now);
}
}@namespace KOTH.UI
@inherits Panel
@attribute [StyleSheet]
@if ( !Player.IsValid() )
return;
<root>
<div class="circle"></div>
</root>
@code
{
/// The player
public PlayerPawn Player => PlayerState.Local.PlayerPawn;
protected override int BuildHash()
{
if ( !Player.IsValid() || !Player.CurrentEquipment.IsValid() )
return 0;
return HashCode.Combine(Player, Player.CurrentEquipment);
}
}
@using Sandbox;
@using Sandbox.UI;
@using System;
@inherits Panel
@namespace Causal
<root class="@RootClass">
<div class="pause-layout">
<div class="pause-hitbox" @ref=_resumeHitbox @onclick=@Resume></div>
<div class="pause-hitbox" @ref=_settingsHitbox @onclick=@ToggleSettings></div>
<div class="pause-hitbox" @ref=_quitHitbox @onclick=@QuitGame></div>
</div>
<CausalSettingsPanel style="@SettingsStyle" @ref=SettingsView></CausalSettingsPanel>
</root>
@code
{
public static CausalPauseMenu Instance { get; private set; }
public CausalSettingsPanel SettingsView { get; set; }
public static ViewState CurrentView { get; set; } = ViewState.None;
private bool _open;
private Panel _resumeHitbox;
private Panel _settingsHitbox;
private Panel _quitHitbox;
private float _resumeHover;
private float _settingsHover;
private float _quitHover;
private Texture _logoTexture;
private bool _logoTried;
private string RootClass => _open ? "focused" : "hidden";
private string SettingsStyle => CurrentView == ViewState.Settings
? "opacity: 1; pointer-events: all; transform: translateX(0);"
: "opacity: 0; pointer-events: none; transform: translateX(18px);";
protected override void OnAfterTreeRender( bool firstTime )
{
Instance = this;
if ( firstTime )
{
_open = false;
CurrentView = ViewState.None;
return;
}
if ( !_open && CurrentView != ViewState.None )
{
CurrentView = ViewState.None;
}
}
public override void OnDraw( Painter painter )
{
if ( !_open )
{
return;
}
var bounds = painter.Bounds;
// Dim scrim - GPU
painter.Fill = new Color( 0f, 0f, 0f, 0.55f );
painter.Stroke = Stroke.None;
painter.Rect( bounds );
// Logo + PAUSED - Painter image + text (drawn first so chrome can anchor to hitboxes after)
DrawBrand( painter );
// Resolve hitbox rects early so chrome can anchor to Razor's viewport tracking
Rect resumeRect = default;
Rect quitRect = default;
bool hasMenu = _resumeHitbox.IsValid() && _quitHitbox.IsValid();
if ( hasMenu )
{
Vector2 rootPos = Box.Rect.Position;
resumeRect = new Rect( _resumeHitbox.Box.Rect.Position - rootPos, _resumeHitbox.Box.Rect.Size );
quitRect = new Rect( _quitHitbox.Box.Rect.Position - rootPos, _quitHitbox.Box.Rect.Size );
// Fallback if flex pushed outside viewport (your bottom-drift screenshot) - use bounds-centered
if ( resumeRect.Top < -100f || resumeRect.Top > bounds.Height - 20f )
{
float totalH = 3f * 52f + 2f * 12f;
float startY = bounds.Height * 0.5f - totalH * 0.5f;
resumeRect = new Rect( 150f, startY, 360f, 52f );
quitRect = new Rect( 150f, startY + 2f * (52f + 12f), 360f, 52f );
}
}
// Subtle chrome - now anchored to hitboxes like Razor does, not fixed screen coords
using ( painter.Scope() )
{
var cyan = new Color( 0.67f, 0.86f, 1f );
// Vertical gutter 18px left of menu, spanning menu height with 24px padding
if ( hasMenu && resumeRect.Width > 0 && quitRect.Width > 0 )
{
float x = resumeRect.Left - 18f;
float top = resumeRect.Top - 24f;
float bottom = quitRect.Bottom + 24f;
// Clamp inside viewport like Razor's overflow clip
top = Math.Max( top, bounds.Top + 100f );
bottom = Math.Min( bottom, bounds.Bottom - 40f );
if ( top < bottom && x > bounds.Left && x < bounds.Right )
{
painter.Stroke = Stroke.Solid( cyan.WithAlpha( 0.22f ), 1.2f );
painter.Line( new Vector2( x, top ), new Vector2( x, bottom ) );
}
}
else
{
// Fallback fixed if hitboxes not yet laid out
painter.Stroke = Stroke.Solid( cyan.WithAlpha( 0.18f ), 1.5f );
painter.Line( new Vector2( 132f, 118f ), new Vector2( 132f, bounds.Height - 80f ) );
}
// Horizontal hairline under brand - use brand rect width, not fixed 300
painter.Stroke = Stroke.Solid( cyan.WithAlpha( 0.14f ), 1f );
float hy = 82f;
// Brand bottom is at 32+36=68, so hairline 14px below
painter.Line( new Vector2( 32f, hy ), new Vector2( Math.Min( 320f, bounds.Width - 40f ), hy ) );
}
// Menu items - Painter text drawn into invisible hitboxes
DrawMenuItems( painter );
// Hint - Painter text
DrawHint( painter );
}
private void DrawBrand( Painter painter )
{
if ( !_logoTried )
{
_logoTried = true;
try
{
_logoTexture = Texture.Load( "textures/causal-logo.png" );
}
catch
{
_logoTexture = null;
}
}
var cyan = new Color( 0.67f, 0.86f, 1f );
var bounds = painter.Bounds;
// Use bounds as viewport like Razor does - brand stays viewport-top-left
// regardless of flex. This mirrors how Razor's Box.Rect for an absolute
// brand would be computed from screen bounds.
float bx = 40f;
float by = 32f;
if ( _logoTexture.IsValid() )
{
float logoW = 160f;
float aspect = _logoTexture.Height > 0 ? (float)_logoTexture.Width / _logoTexture.Height : 1f;
float logoH = logoW / (aspect > 0 ? aspect : 1f);
if ( logoH > 36f )
{
logoH = 36f;
logoW = logoH * aspect;
}
// Clamp logo rect to viewport so it never drifts off-screen if bounds shrinks
logoW = Math.Min( logoW, bounds.Width - bx - 240f );
var logoRect = new Rect( bx, by, logoW, logoH );
painter.Texture( _logoTexture, logoRect, Color.White );
var pausedRect = new Rect( logoRect.Right + 18f, by, 220f, logoH );
// Ensure paused stays inside viewport
if ( pausedRect.Right > bounds.Width - 20f )
{
pausedRect = new Rect( pausedRect.Left, pausedRect.Top, bounds.Width - pausedRect.Left - 20f, pausedRect.Height );
}
painter.TextStyle = new TextStyle( "Roboto", 22f, new Color( 1f, 1f, 1f, 0.9f ) )
.WithWeight( 600 )
.WithLetterSpacing( 8f )
.WithAlignment( TextFlag.LeftCenter )
.WithShadow( cyan.WithAlpha( 0.9f ), blur: 8f, offset: new Vector2( 0f, 0f ) );
painter.Text( "PAUSED", pausedRect );
}
else
{
painter.TextStyle = new TextStyle( "Roboto", 28f, new Color( 1f, 1f, 1f, 0.9f ) )
.WithWeight( 600 )
.WithLetterSpacing( 8f )
.WithAlignment( TextFlag.LeftCenter )
.WithShadow( cyan.WithAlpha( 0.9f ), blur: 8f, offset: new Vector2( 0f, 0f ) );
painter.Text( "PAUSED", new Rect( bx, by, 260f, 36f ) );
}
}
private void DrawMenuItems( Painter painter )
{
if ( !_resumeHitbox.IsValid() || !_settingsHitbox.IsValid() || !_quitHitbox.IsValid() )
{
return;
}
Vector2 rootPos = Box.Rect.Position;
var bounds = painter.Bounds;
Rect ToLocal( Panel p )
{
if ( !p.IsValid() )
{
return default;
}
var screen = p.Box.Rect;
var pos = screen.Position - rootPos;
bool outside = pos.y < -100f || pos.y > bounds.Height - 20f;
if ( outside )
{
int idx = p == _resumeHitbox ? 0 : p == _settingsHitbox ? 1 : 2;
float totalH = 3f * 52f + 2f * 12f;
float startY = bounds.Height * 0.5f - totalH * 0.5f;
float y = startY + idx * (52f + 12f);
return new Rect( 150f, y, 360f, 52f );
}
return new Rect( pos, screen.Size );
}
var resumeRect = ToLocal( _resumeHitbox );
var settingsRect = ToLocal( _settingsHitbox );
var quitRect = ToLocal( _quitHitbox );
var cyan = new Color( 0.67f, 0.86f, 1f );
DrawMenuEntry( painter, "RESUME", resumeRect, _resumeHover, _resumeHitbox.HasActive, cyan );
DrawMenuEntry( painter, "SETTINGS", settingsRect, _settingsHover, _settingsHitbox.HasActive, cyan );
DrawMenuEntry( painter, "QUIT", quitRect, _quitHover, _quitHitbox.HasActive, cyan );
}
private void DrawMenuEntry( Painter painter, string text, Rect rect, float hover, bool active, Color cyan )
{
if ( rect.Width <= 0 || rect.Height <= 0 )
{
return;
}
// Smooth eased transition like CSS transition: color 0.2s ease, transform 0.2s ease, width 0.28s ease-out, arrow 0.3s ease
hover = Math.Clamp( hover, 0f, 1f );
float eased = hover * hover * (3f - 2f * hover); // smoothstep - matches ease
float nudgeX = 5f * eased;
if ( active )
{
nudgeX += 1f * eased;
}
// Interpolate idle -> hover
var baseColor = new Color( 1f, 1f, 1f, 0.7f + 0.3f * eased );
var idleShadow = new Color( 0.47f, 0.78f, 1f, 0.35f );
var hoverShadow = cyan.WithAlpha( 0.95f );
var shadowColor = Color.Lerp( idleShadow, hoverShadow, eased );
float shadowBlur = 6f + 6f * eased;
painter.TextStyle = new TextStyle( "Roboto", 32f, baseColor )
.WithWeight( 500 )
.WithLetterSpacing( 4f )
.WithAlignment( TextFlag.LeftCenter )
.WithShadow( shadowColor, blur: shadowBlur, offset: new Vector2( 0f, 0f ) );
var textRect = new Rect( rect.Position + new Vector2( nudgeX, 0f ), rect.Size );
float scale = active ? 0.98f : 1f;
if ( active )
{
textRect = new Rect( textRect.Position + new Vector2( 1f, 1f ) * eased, textRect.Size * (1f - 0.02f * eased) );
}
_ = scale;
painter.Text( text, textRect );
// Arrow - fades/ slides in with eased hover, replicates CSS transition: all 0.3s ease, left -10 -> -15
if ( eased > 0.01f )
{
using ( painter.Scope() )
{
float arrowAlpha = eased;
// Idle left -10 (alpha 0), hover left -15 (alpha 1) - lerp
float cxBase = rect.Left - 10f - 5f * eased + nudgeX;
float cy = rect.Top + rect.Height * 0.5f;
painter.Fill = cyan.WithAlpha( 0.9f * arrowAlpha );
painter.Stroke = Stroke.None;
painter.Polygon( new Vector2[]
{
new Vector2( cxBase - 8f, cy - 6f ),
new Vector2( cxBase, cy ),
new Vector2( cxBase - 8f, cy + 6f ),
} );
}
}
// Hover line - width 0% -> 100% with ease-out, like CSS width 0.28s ease-out
if ( eased > 0.01f )
{
using ( painter.Scope() )
{
float y = rect.Bottom - 3f;
float w = rect.Width * eased;
var lineRect = new Rect( rect.Left + nudgeX, y, w, 3f );
var gradStart = new Color( 0.55f, 0.82f, 1f, 0.9f );
var gradEnd = new Color( 0.47f, 0.78f, 1f, 0.9f );
// Fade gradient with eased alpha so line grows smoothly
painter.Fill = Fill.LinearGradient( gradStart.WithAlpha( 0.9f * eased ), gradEnd.WithAlpha( 0.9f * eased ), 0f );
painter.Stroke = Stroke.None;
painter.Rect( lineRect );
painter.Fill = new Color( 0.55f, 0.82f, 1f, 0.18f * eased );
painter.Rect( new Rect( lineRect.Left, lineRect.Top - 1f, lineRect.Width, lineRect.Height + 2f ) );
}
}
}
private void DrawHint( Painter painter )
{
if ( !_quitHitbox.IsValid() )
{
return;
}
Vector2 rootPos = Box.Rect.Position;
var quitScreen = _quitHitbox.Box.Rect;
var quitLocal = new Rect( quitScreen.Position - rootPos, quitScreen.Size );
var hintRect = new Rect( quitLocal.Left, quitLocal.Bottom + 18f, 220f, 20f );
var pulse = 0.65f + 0.35f * MathF.Sin( Time.Now * 2.6f );
var hintColor = new Color( 0.67f, 0.82f, 0.94f, 0.45f * pulse + 0.35f );
painter.TextStyle = new TextStyle( "Roboto", 12f, hintColor )
.WithWeight( 400 )
.WithLetterSpacing( 2f )
.WithAlignment( TextFlag.LeftTop );
painter.Text( "ESC TO RESUME", hintRect );
}
public override void Tick()
{
var manager = CausalGameManager.Instance;
if ( !manager.IsValid() || !manager.IsActive )
{
if ( _open )
{
_open = false;
CurrentView = ViewState.None;
UpdateControls();
StateHasChanged();
}
}
if ( !_open && CurrentView != ViewState.None )
{
CurrentView = ViewState.None;
StateHasChanged();
}
// Animate hover transitions like CSS transition 0.2s ease / 0.28s ease-out / 0.3s ease
float dt = Time.Delta;
float spd = dt * 11f; // ~0.2s to 90%
if ( _resumeHitbox.IsValid() )
{
float t = _resumeHitbox.HasHovered ? 1f : 0f;
_resumeHover = _resumeHover.LerpTo( t, spd );
}
if ( _settingsHitbox.IsValid() )
{
float t = _settingsHitbox.HasHovered ? 1f : 0f;
_settingsHover = _settingsHover.LerpTo( t, spd );
}
if ( _quitHitbox.IsValid() )
{
float t = _quitHitbox.HasHovered ? 1f : 0f;
_quitHover = _quitHover.LerpTo( t, spd );
}
}
public void Toggle()
{
if ( _open )
{
Close();
}
else
{
Open();
}
}
public void Open()
{
_open = true;
CurrentView = ViewState.None;
SettingsView?.SyncDraft();
UpdateControls();
StateHasChanged();
}
public void Close()
{
Gate( () =>
{
_open = false;
CurrentView = ViewState.None;
UpdateControls();
StateHasChanged();
} );
}
private void Resume()
{
Close();
}
private void ToggleSettings()
{
if ( CurrentView != ViewState.Settings )
{
CurrentView = ViewState.Settings;
SettingsView?.SyncDraft();
StateHasChanged();
return;
}
Gate( () =>
{
CurrentView = ViewState.None;
StateHasChanged();
} );
}
private void QuitGame()
{
Gate( () => Game.Close() );
}
private void Gate( Action action )
{
if ( CurrentView == ViewState.Settings && SettingsView is not null && SettingsView.IsDirty() )
{
SettingsView.RequestExternalClose( action );
return;
}
action();
}
private void UpdateControls()
{
var manager = CausalGameManager.Instance;
if ( manager.IsValid() )
{
manager.SetPaused( _open );
}
}
protected override int BuildHash() => HashCode.Combine( _open, CurrentView );
public enum ViewState
{
None,
Settings
}
}
@namespace Sunless.Libraries.UI
@using Sandbox
@using Sandbox.UI
@attribute [StyleSheet]
@inherits Panel
<root class="modal-overlay @(IsOpen ? "is-open" : "") @ExtraClass">
@if ( IsOpen )
{
<div class="modal-scrim" @onclick=@HandleScrimClick></div>
<div class="modal-dialog" style=@DialogStyle>
@if ( Header is not null || !string.IsNullOrEmpty( Title ) || ShowClose )
{
<div class="modal-header">
@if ( Header is not null )
{
@Header
}
else
{
<div class="modal-title">@Title</div>
}
@if ( ShowClose )
{
<div class="modal-close" @onclick=@HandleClose>X</div>
}
</div>
}
@if ( Body is not null )
{
<div class="modal-body">@Body</div>
}
@if ( Footer is not null )
{
<div class="modal-footer">@Footer</div>
}
</div>
}
</root>
@code
{
public bool IsOpen { get; set; }
public string Title { get; set; }
public bool ShowClose { get; set; } = true;
public bool DismissOnScrim { get; set; } = true;
public string ExtraClass { get; set; }
public string DialogStyle { get; set; }
public Action OnClose { get; set; }
public RenderFragment Header { get; set; }
public RenderFragment Body { get; set; }
public RenderFragment Footer { get; set; }
void HandleClose()
{
try { OnClose?.Invoke(); } catch ( Exception e ) { Log.Warning( $"[Modal] OnClose threw: {e.Message}" ); }
}
void HandleScrimClick()
{
if ( !DismissOnScrim ) return;
HandleClose();
}
protected override int BuildHash() => HashCode.Combine( IsOpen, Title, ShowClose, ExtraClass, DialogStyle );
}
@using System;
@using System.Linq;
@using Sandbox;
@using Sandbox.UI;
@inherits PanelComponent
<root>
@if ( ShouldShowCrosshair )
{
<div
class="omr-crosshair"
style="@CrosshairRootStyle"
>
<div
class="omr-crosshair-line horizontal"
style="@CrosshairLeftStyle"
></div>
<div
class="omr-crosshair-line horizontal"
style="@CrosshairRightStyle"
></div>
<div
class="omr-crosshair-line vertical"
style="@CrosshairUpStyle"
></div>
<div
class="omr-crosshair-line vertical"
style="@CrosshairDownStyle"
></div>
</div>
}
</root>
@code
{
private readonly OMRSceneReferences _sceneReferences = new();
private OMRSceneReferences SceneReferences
{
get { _sceneReferences.Refresh( Scene ); return _sceneReferences; }
}
private PlayerState LocalPlayerState => SceneReferences.LocalPlayer;
private BaseInventoryComponent LocalInventory => SceneReferences.LocalInventory;
private OneMoreRoundWeapon LocalWeapon =>
LocalInventory?
.ActiveItem as OneMoreRoundWeapon;
private RoundManager LocalRoundManager => SceneReferences.Rounds;
/// <summary>
/// Crosshair visibility follows actual gameplay context rather than only
/// weapon state.
///
/// Visible:
/// - Sandbox gameplay
/// - Online warmup
/// - Active Fighting phase
///
/// Hidden:
/// - pause/settings
/// - pre-match lobby/ready screen
/// - countdown
/// - round end
/// - winner/modifier decisions
/// - match end/menu
/// </summary>
private bool IsCrosshairGameplayContext
{
get
{
if ( PauseMenu.IsAnyPauseOpen )
return false;
RoundManager roundManager =
LocalRoundManager;
if ( roundManager is null )
return false;
return roundManager.Mode switch
{
SessionMode.Sandbox =>
true,
SessionMode.Match =>
roundManager.Phase ==
RoundPhase.Fighting,
SessionMode.Menu =>
LocalPlayerState?.
PreMatchState ==
PreMatchPlayerState.Warmup,
_ =>
false
};
}
}
private bool ShouldShowCrosshair =>
IsCrosshairGameplayContext &&
LocalWeapon is not null &&
LocalWeapon.IsValid &&
LocalWeapon.ShouldShowScreenCrosshair;
private Vector2 Gap =>
LocalWeapon?
.ScreenCrosshairGap ??
Vector2.Zero;
private float LineLength =>
MathF.Max(
LocalWeapon?
.CrosshairLineLength ??
0f,
0f
);
private float LineWidth =>
MathF.Max(
LocalWeapon?
.CrosshairLineWidth ??
0.5f,
0.5f
);
private string CrosshairRootStyle
{
get
{
Color color =
LocalWeapon?
.CrosshairColor ??
Color.White;
return $"color: {color.Rgba};";
}
}
private string CrosshairLeftStyle =>
$"width: {Css( LineLength )}px; " +
$"height: {Css( LineWidth )}px; " +
$"left: {Css( -(Gap.x + LineLength) )}px; " +
$"top: {Css( -LineWidth * 0.5f )}px;";
private string CrosshairRightStyle =>
$"width: {Css( LineLength )}px; " +
$"height: {Css( LineWidth )}px; " +
$"left: {Css( Gap.x )}px; " +
$"top: {Css( -LineWidth * 0.5f )}px;";
private string CrosshairUpStyle =>
$"width: {Css( LineWidth )}px; " +
$"height: {Css( LineLength )}px; " +
$"left: {Css( -LineWidth * 0.5f )}px; " +
$"top: {Css( -(Gap.y + LineLength) )}px;";
private string CrosshairDownStyle =>
$"width: {Css( LineWidth )}px; " +
$"height: {Css( LineLength )}px; " +
$"left: {Css( -LineWidth * 0.5f )}px; " +
$"top: {Css( Gap.y )}px;";
private static string Css(
float value
)
{
return value.ToString(
"0.###",
System.Globalization.CultureInfo.InvariantCulture
);
}
protected override int BuildHash()
{
OneMoreRoundWeapon weapon =
LocalWeapon;
RoundManager roundManager =
LocalRoundManager;
PlayerState player =
LocalPlayerState;
HashCode hash =
new();
//
// UI context belongs in the hash too. Without this, opening Pause or
// entering a decision phase can change ShouldShowCrosshair without
// giving Razor a weapon-property change to rebuild from.
//
hash.Add(
PauseMenu.IsAnyPauseOpen
);
hash.Add(
roundManager?.
Mode ??
SessionMode.Menu
);
hash.Add(
roundManager?.
Phase ??
RoundPhase.Waiting
);
hash.Add(
player?.
PreMatchState ??
PreMatchPlayerState.Lobby
);
if (
weapon is null ||
!weapon.IsValid
)
{
return hash.ToHashCode();
}
Vector2 gap =
weapon.ScreenCrosshairGap;
hash.Add(
weapon.IsAiming
);
hash.Add(
gap.x
);
hash.Add(
gap.y
);
hash.Add(
weapon.CrosshairLineLength
);
hash.Add(
weapon.CrosshairLineWidth
);
hash.Add(
weapon.CrosshairColor
);
return hash.ToHashCode();
}
}
@using Sandbox.UI;
@using Sandbox.UI.Navigation;
@inherits Panel
@attribute [Route( "/practice" )]
<root class="menu-page">
<label class="page-heading">PRACTICE</label>
<label class="page-copy">
Fight ROOK through the full competitive loop, or open Sandbox
for persistent weapon, arena and AI testing.
</label>
<div class="setup-section bot-match-setup">
<div class="setup-section-title">BOT MATCH</div>
<div class="setup-row">
<div class="setup-label">DIFFICULTY PROFILE</div>
<div class="setup-selector">
<div class="selector-arrow" onclick=@BotDifficultyPrevious><</div>
<div class="selector-value">@OMRSceneFlow.GetBotMatchDifficultyName()</div>
<div class="selector-arrow" onclick=@BotDifficultyNext>></div>
</div>
</div>
</div>
<div
class="menu-button"
onclick=@StartBotMatch
>
<label class="button-title">VS BOT MATCH</label>
<label class="button-description">
Full match rules · @(OMRSceneFlow.GetBotMatchDifficultyName()) · BANK / ONE MORE ROUND · adaptive ROOK
</label>
</div>
<div
class="menu-button"
onclick=@OpenSandboxSetup
>
<label class="button-title">SANDBOX</label>
<label class="button-description">
Solo or multiplayer · custom weapons and arena modules
</label>
</div>
<a
href="/"
class="menu-back"
>
BACK
</a>
</root>
@code
{
private void BotDifficultyPrevious()
{
OMRSceneFlow.CycleBotMatchDifficulty( -1 );
}
private void BotDifficultyNext()
{
OMRSceneFlow.CycleBotMatchDifficulty( 1 );
}
private void StartBotMatch()
{
MainMenuHost.Current?.StartBotMatch();
}
private void OpenSandboxSetup()
{
MainMenuHost.Current?.OpenSandboxSetup();
}
}
@using System
@using System.Linq
@using Sandbox
@using Sandbox.UI
@namespace Bimp
@inherits PanelComponent
@implements Component.ISceneStage
@*
The full controls, shown on the local player's screen when they USE a media player.
Created on demand by MediaRemote.Open, never networked.
*@
<root class="mediaremote @(PadMode ? "pad" : "")">
@if ( Target is not null )
{
<div class="window">
<div class="header">
<i>@(Target.AudioOnlyPlayer ? "speaker" : "smart_display")</i>
<div class="title">Media Player</div>
<div class="close nav" onclick="@Close"><i>close</i></div>
</div>
<div class="now">
@if ( Target.HasMedia )
{
<div class="label">Now playing</div>
<div class="track">@Target.Title</div>
<div class="sub">
@if ( !string.IsNullOrWhiteSpace( Target.RequestedBy ) )
{
<span>Requested by @Target.RequestedBy</span>
}
<span class="time">@TimeText</span>
</div>
}
else
{
<div class="track dim">Nothing playing</div>
}
@if ( !string.IsNullOrWhiteSpace( Target.Status ) )
{
<div class="status">@Target.Status</div>
}
@if ( !string.IsNullOrWhiteSpace( Target.LocalNotice ) )
{
<div class="notice">@Target.LocalNotice</div>
}
</div>
@if ( Target.HasMedia && !Target.IsLive && Target.MediaDuration > 0 )
{
<div class="seek @(Target.CanLocalControl ? "nav" : "disabled")">
<SliderControl Min="@(0f)" Max="@Target.MediaDuration" Step="@(1f)" ShowValueTooltip="@false" Value="@SeekValue" OnValueChanged="@OnSeekChanged"></SliderControl>
</div>
}
@if ( Target.CanLocalControl && Target.HasMedia )
{
<div class="transport">
@if ( !Target.IsLive )
{
<div class="btn nav" onclick="@( () => Target.RequestSeek( Target.CurrentTime - 10 ) )"><i>replay_10</i></div>
<div class="btn big nav nav-default" onclick="@( () => Target.RequestSetPaused( !Target.Paused ) )"><i>@(Target.Paused ? "play_arrow" : "pause")</i></div>
<div class="btn nav" onclick="@( () => Target.RequestSeek( Target.CurrentTime + 10 ) )"><i>forward_10</i></div>
}
<div class="btn nav" onclick="@Target.RequestSkip"><i>skip_next</i></div>
<div class="btn nav" onclick="@Target.RequestStop"><i>stop</i></div>
</div>
}
@if ( Target.HasMedia && (Target.AvailableQualities.Count > 0 || Target.AvailableAudioTracks.Count > 0 || Target.AvailableCaptionTracks.Count > 0) )
{
<div class="stream-options">
@if ( Target.AvailableQualities.Count > 0 )
{
<div class="option">
<i>high_quality</i>
<DropDown class="quality nav" Value="@QualityValue" ValueChanged="@OnQualityChanged" BuildOptions="@BuildQualityOptions"></DropDown>
</div>
}
@if ( Target.AvailableAudioTracks.Count > 0 )
{
<div class="option">
<i>translate</i>
<DropDown class="audio-track nav" Value="@AudioTrackValue" ValueChanged="@OnAudioTrackChanged" BuildOptions="@BuildAudioTrackOptions"></DropDown>
</div>
}
@if ( Target.AvailableCaptionTracks.Count > 0 )
{
<div class="option">
<i>closed_caption</i>
<DropDown class="audio-track nav" Value="@CaptionValue" ValueChanged="@OnCaptionChanged" BuildOptions="@BuildCaptionOptions"></DropDown>
</div>
}
<div class="playing-as">@PlayingAsText</div>
</div>
}
@if ( (Target.CanLocalControl || Target.CanLocalQueue) && PadMode )
{
@* a pad can't type a link *@
<div class="pad-note"><i>keyboard</i> Use a keyboard to add a link</div>
}
else if ( Target.CanLocalControl || Target.CanLocalQueue )
{
<div class="entry">
<TextEntry @ref="UrlEntry" Placeholder="Paste a YouTube link or a media url (mp4, webm, mp3...)" onsubmit="@OnSubmit"></TextEntry>
@if ( Target.CanLocalControl )
{
<div class="action primary" onclick="@PlayNow"><i>play_arrow</i> Play now</div>
}
@if ( Target.CanLocalQueue )
{
<div class="action" onclick="@AddToQueue"><i>playlist_add</i> Queue</div>
}
</div>
}
else
{
<div class="locked"><i>lock</i> You can't control this player.</div>
}
<div class="queue">
<div class="queue-header">
<div class="tab nav @(ShowRecent ? "" : "active")" onclick="@( () => ShowRecent = false )">Up next <span class="count">@Target.Queue.Count / @Target.MaxQueue</span></div>
<div class="tab nav @(ShowRecent ? "active" : "")" onclick="@( () => ShowRecent = true )">Recent <span class="count">@RecentLinks.All.Count</span></div>
<div class="spacer"></div>
@if ( !ShowRecent && Target.CanLocalControl && Target.Queue.Count > 0 )
{
<div class="small-btn nav" onclick="@Target.RequestClearQueue">Clear</div>
}
</div>
<div class="queue-list">
@if ( ShowRecent )
{
@if ( RecentLinks.All.Count == 0 )
{
<div class="empty">Links you play show up here.</div>
}
@foreach ( var recent in RecentLinks.All )
{
var url = recent.Url;
<div class="queue-item recent">
<div class="info">
<div class="name">@recent.Title</div>
<div class="by">@recent.Url</div>
</div>
<div class="dur">@(recent.IsLive ? "Live" : recent.Duration > 0 ? MediaSource.FormatTime( recent.Duration ) : "")</div>
@if ( Target.CanLocalControl )
{
<div class="small-btn go nav" onclick="@( () => Target.RequestPlay( url ) )"><i>play_arrow</i></div>
}
@if ( Target.CanLocalQueue )
{
<div class="small-btn go nav" onclick="@( () => Target.RequestEnqueue( url ) )"><i>playlist_add</i></div>
}
<div class="small-btn nav" onclick="@( () => RecentLinks.Remove( url ) )"><i>close</i></div>
</div>
}
}
else if ( Target.Queue.Count == 0 )
{
<div class="empty">The queue is empty.</div>
}
@for ( int i = 0; !ShowRecent && i < Target.Queue.Count; i++ )
{
var index = i;
var item = Target.Queue[i];
<div class="queue-item">
<div class="num">@(index + 1)</div>
<div class="info">
<div class="name">@item.Title</div>
<div class="by">@item.RequestedBy</div>
</div>
<div class="dur">@(item.IsLive ? "Live" : item.Duration > 0 ? MediaSource.FormatTime( item.Duration ) : "")</div>
@if ( Target.CanLocalControl )
{
<div class="small-btn nav" onclick="@( () => Target.RequestRemove( index ) )"><i>close</i></div>
}
</div>
}
</div>
</div>
<div class="footer">
<div class="btn small nav" onclick="@ToggleMute"><i>@(MediaSettings.Mute ? "volume_off" : "volume_up")</i></div>
<SliderControl class="volume nav" Min="@(0f)" Max="@(1f)" Step="@(0.01f)" ShowValueTooltip="@false" Value="@MediaSettings.Volume" OnValueChanged="@OnVolumeChanged"></SliderControl>
<div class="btn small nav audio-mode @(MediaSettings.SpatialAudio ? "" : "flat")" onclick="@ToggleSpatial" tooltip="@(MediaSettings.SpatialAudio ? "3D audio: sound comes from the screen. Click for 2D" : "2D audio: no direction, still quieter further away. Click for 3D")"><i>@(MediaSettings.SpatialAudio ? "surround_sound" : "headphones")</i><span>@(MediaSettings.SpatialAudio ? "3D" : "2D")</span></div>
@if ( PadMode )
{
<div class="hint">
<InputGlyph Analog="LeftStickY" Size="@InputGlyphSize.Small"></InputGlyph><span>Move</span>
@if ( Pad.SelectAction is not null )
{
<InputGlyph Action="@Pad.SelectAction" Size="@InputGlyphSize.Small"></InputGlyph><span>Select</span>
}
<InputGlyph Action="@(Pad.BackAction ?? "use")" Size="@InputGlyphSize.Small"></InputGlyph><span>Close</span>
</div>
}
else
{
<div class="hint"><span>Your audio (all players) ·</span><InputGlyph Action="use" Size="@InputGlyphSize.Small"></InputGlyph><span>or</span><InputGlyph Key="escape" Size="@InputGlyphSize.Small"></InputGlyph><span>to close</span></div>
}
</div>
</div>
}
</root>
@code
{
/// <summary>
/// The player being controlled.
/// </summary>
public MediaPlayer Target { get; set; }
/// <summary>
/// Close automatically when the camera gets further than this from the player.
/// </summary>
public float MaxDistance { get; set; } = 1500.0f;
TextEntry UrlEntry { get; set; }
/// <summary> The list shows the links this client has played (<see cref="RecentLinks"/>) instead of the queue. </summary>
bool ShowRecent { get; set; }
RealTimeSince sinceOpened = 0;
float? pendingSeek;
RealTimeSince sincePendingSeek;
float SeekValue => pendingSeek ?? Target?.CurrentTime ?? 0;
string TimeText
{
get
{
if ( Target is null || !Target.HasMedia ) return "";
if ( Target.IsLive ) return "Live";
var cur = MediaSource.FormatTime( Target.CurrentTime );
var s = Target.MediaDuration > 0 ? $"{cur} / {MediaSource.FormatTime( Target.MediaDuration )}" : cur;
return Target.Paused ? $"{s} (paused)" : s;
}
}
/// <summary>
/// Open the remote for this player, or close it if it's already open for it.
/// </summary>
public static void Toggle( MediaPlayer target )
{
if ( !target.IsValid() || Application.IsDedicatedServer ) return;
var existing = target.Scene.GetAllComponents<MediaRemote>().FirstOrDefault();
if ( existing.IsValid() )
{
if ( existing.Target == target )
{
existing.Close();
return;
}
existing.Target = target;
existing.sinceOpened = 0;
existing.StateHasChanged();
return;
}
var go = target.Scene.CreateObject();
go.Name = "bimp remote";
go.NetworkMode = NetworkMode.Never;
go.Flags |= GameObjectFlags.NotSaved | GameObjectFlags.Hidden;
var screen = go.Components.Create<ScreenPanel>();
screen.ZIndex = 200;
var remote = go.Components.Create<MediaRemote>();
remote.Target = target;
}
/// <summary>
/// Is a remote open right now? Games can use this to e.g. hide their crosshair.
/// </summary>
public static bool IsOpen( Scene scene ) => scene?.GetAllComponents<MediaRemote>().Any() ?? false;
public void Close()
{
GameObject.Destroy();
}
protected override void OnTreeFirstBuilt()
{
base.OnTreeFirstBuilt();
UrlEntry?.Focus();
}
//
// Controller: the D-pad / left stick moves a highlight between the controls (the "nav" ones), A presses,
// left/right adjusts sliders and choices, B closes. See PadNavigation.
//
/// <summary> Played with a controller right now (the engine switches as soon as they touch the keyboard or pad). </summary>
bool PadMode => Input.UsingController;
readonly PadNavigation Pad = new();
/// <summary> The engine's virtual cursor (left stick moves a pointer) is off while we navigate; this was its setting. </summary>
bool? virtualCursorWas;
/// <summary>
/// The very first thing in the frame, before anything (fixed update included) reads input: navigate, then suppress the
/// frame's input, so the pad doesn't also move, jump or duck the player behind the remote.
/// </summary>
void Component.ISceneStage.Start()
{
if ( !PadMode || !Target.IsValid() || Panel is null )
{
RestoreVirtualCursor();
return;
}
virtualCursorWas ??= Input.EnableVirtualCursor;
Input.EnableVirtualCursor = false;
Pad.Press ??= PressControl;
Pad.Adjust ??= AdjustControl;
Pad.Update( Panel );
// B closes (unless it just closed a drop down's menu), and USE again, as on keyboard
var close = sinceOpened > 0.2f && (Pad.BackPressed || Input.Pressed( "use" ));
Input.Suppressed = true;
if ( close ) Close();
}
/// <summary> The window's width in the stylesheet (.window), at 1080p. </summary>
const float WindowWidth = 760;
/// <summary>
/// The screen panel already scales the remote with the screen height (as if every screen were 1080 high); this sizes
/// it up from there by bimp_ui_scale - at its design size it was small at every resolution - but never wider than
/// 90% of the screen.
/// </summary>
void FitToScreen()
{
if ( Components.Get<ScreenPanel>() is not { } screen ) return;
var heightScale = Screen.Size.y / 1080.0f;
var widest = heightScale > 0 ? Screen.Size.x * 0.9f / (WindowWidth * heightScale) : 1;
var scale = Math.Clamp( MediaSettings.RemoteScale, 0.5f, Math.Max( 0.5f, widest ) );
if ( MathF.Abs( screen.Scale - scale ) > 0.001f ) screen.Scale = scale;
}
/// <summary> For probes: each option of an open menu, its classes, and * on the pad's selection. </summary>
IEnumerable<string> MenuOptions()
{
var top = Panel;
while ( top?.Parent is not null ) top = top.Parent;
var menu = top?.Descendants.OfType<Popup>().FirstOrDefault();
return menu is null ? Enumerable.Empty<string>() : menu.Children.Select( c => $"{(c as Button)?.Text}:{c.Classes}{(c == menu.SelectedChild ? "*" : "")}" );
}
/// <summary> For probes: the controller state and what's highlighted. </summary>
internal string DescribePad() => $"usingController={Input.UsingController} controllers={Input.ControllerCount} select={Pad.SelectAction ?? "-"} back={Pad.BackAction ?? "-"} virtualCursor={Input.EnableVirtualCursor} screen={Screen.Size} desktopScale={Screen.DesktopScale} uiScale={Panel?.ScaleToScreen} window={Panel?.Descendants.FirstOrDefault( p => p.HasClass( "window" ) )?.Box.Rect} focused=[{Pad.Focused?.Classes}] menu=[{string.Join( " | ", MenuOptions() )}]" + '\n' +
string.Join( '\n', Panel.Descendants.Where( p => p.HasClass( "nav" ) ).Select( p => $" [{p.Classes}] visible={p.IsVisible} rect={p.Box.Rect}" ) );
/// <summary> For probes: move the highlight as the pad would (up, down, left, right, or "a" to press it), and say where it went. </summary>
internal string ProbeMove( string direction )
{
if ( direction == "a" )
{
if ( Pad.Focused is { } focused ) PressControl( focused );
return $"a on [{Pad.Focused?.Classes}]";
}
var d = direction switch { "up" => new Vector2( 0, -1 ), "down" => new Vector2( 0, 1 ), "left" => new Vector2( -1, 0 ), _ => new Vector2( 1, 0 ) };
Pad.ProbeStep( Panel, d );
return $"{direction} -> [{Pad.Focused?.Classes}] volume={MediaSettings.Volume:0.00}";
}
void RestoreVirtualCursor()
{
if ( virtualCursorWas is not { } was ) return;
Input.EnableVirtualCursor = was;
virtualCursorWas = null;
}
protected override void OnDestroy()
{
RestoreVirtualCursor();
base.OnDestroy();
}
/// <summary> A on a control: a drop down opens its menu (navigated by PadNavigation), a button is clicked. Sliders only take left/right. </summary>
void PressControl( Panel control )
{
if ( control.HasClass( "seek" ) || control.HasClass( "volume" ) ) return;
if ( control is DropDown dropDown )
{
dropDown.Open();
return;
}
control.CreateEvent( new MousePanelEvent( "onclick", control, "mouseleft" ) );
}
/// <summary> Left/right on a slider: seek by 10 s, volume by 5%. Anything else moves left/right. </summary>
bool AdjustControl( Panel control, int step )
{
if ( control.HasClass( "seek" ) )
{
OnSeekChanged( Math.Clamp( SeekValue + step * 10, 0, Target.MediaDuration ) );
return true;
}
if ( control.HasClass( "volume" ) )
{
OnVolumeChanged( MathF.Round( (MediaSettings.Volume + step * 0.05f) * 20 ) / 20 );
return true;
}
return false;
}
protected override void OnUpdate()
{
if ( !Target.IsValid() )
{
Close();
return;
}
MenuStyle.Apply( Panel );
FitToScreen();
if ( Input.EscapePressed )
{
Input.EscapePressed = false;
Close();
return;
}
// USE again closes it (not while typing - a focused text entry takes the keys)
if ( sinceOpened > 0.2f && Input.Pressed( "use" ) && UseHandled.TryConsume() )
{
Close();
return;
}
if ( Scene.Camera is not null && Scene.Camera.WorldPosition.Distance( Target.WorldPosition ) > MaxDistance )
{
Close();
return;
}
// Seek once the slider has settled, rather than spamming the host while dragging
if ( pendingSeek.HasValue && sincePendingSeek > 0.3f )
{
Target.RequestSeek( pendingSeek.Value );
pendingSeek = null;
}
}
void OnSeekChanged( float value )
{
if ( Target is null || !Target.CanLocalControl ) return;
if ( MathF.Abs( value - Target.CurrentTime ) < 1.5f && !pendingSeek.HasValue ) return; // our own refresh
pendingSeek = value;
sincePendingSeek = 0;
}
//
// Resolution / audio track. These are this player's own preferences (saved convars), applied to every
// media player - picking one reloads the local stream at the same synced time.
//
string QualityValue => MediaSettings.Quality <= 0 ? "0" : Target.SelectedQuality.ToString();
List<Option> BuildQualityOptions()
{
var auto = $"Auto (up to {MediaSettings.MaxVideoHeight}p)";
var list = new List<Option> { new Option( auto, "0" ) };
foreach ( var h in Target.AvailableQualities )
list.Add( new Option( MediaStreamOptions.QualityLabel( h ), h.ToString() ) );
return list;
}
void OnQualityChanged( string value )
{
if ( !int.TryParse( value, out var h ) ) return;
if ( h == (MediaSettings.Quality <= 0 ? 0 : Target.SelectedQuality) ) return;
MediaSettings.Quality = h;
}
string AudioTrackValue
{
get
{
var selected = Target.SelectedAudioTrack ?? Target.AvailableAudioTracks.FirstOrDefault( t => t.IsDefault );
return selected?.Id ?? "";
}
}
List<Option> BuildAudioTrackOptions()
{
return Target.AvailableAudioTracks
.Select( t => new Option( t.IsDefault && !t.Name.Contains( "original", StringComparison.OrdinalIgnoreCase ) ? $"{t.Name} (original)" : t.Name, t.Id ) )
.ToList();
}
void OnAudioTrackChanged( string id )
{
if ( id == AudioTrackValue ) return;
var track = Target.AvailableAudioTracks.FirstOrDefault( t => t.Id == id );
// picking the original clears the preference, so dubbed videos default to original again
MediaSettings.AudioLanguage = track is null || track.IsDefault ? "" : track.Id;
}
//
// Captions: off, or a language. The choice is this player's own (saved convars) and applies to every media player.
// Auto-generated tracks are only offered for languages without a written one - that's what picking a language gets.
//
IEnumerable<MediaCaptionTrack> ListedCaptionTracks
=> Target.AvailableCaptionTracks.Where( t => !t.IsAuto || !Target.AvailableCaptionTracks.Any( w => !w.IsAuto && w.Lang == t.Lang ) );
string CaptionValue => Target.SelectedCaptionTrack?.Id ?? "";
List<Option> BuildCaptionOptions()
{
var list = new List<Option> { new Option( "Captions off", "" ) };
foreach ( var t in ListedCaptionTracks )
list.Add( new Option( t.IsAuto && !t.Name.Contains( "auto", StringComparison.OrdinalIgnoreCase ) ? $"{t.Name} (auto-generated)" : t.Name, t.Id ) );
return list;
}
void OnCaptionChanged( string id )
{
if ( id == CaptionValue ) return;
var track = Target.AvailableCaptionTracks.FirstOrDefault( t => t.Id == id );
MediaSettings.Subtitles = track is not null;
if ( track is not null ) MediaSettings.SubtitleLanguage = track.Lang;
}
string PlayingAsText
{
get
{
var b = Target.Backend;
if ( b is null || !b.Loaded ) return "Loading...";
return b.HasVideo ? $"Playing {b.Height}p" : "";
}
}
void OnVolumeChanged( float value )
{
MediaSettings.Volume = value.Clamp( 0, 1 );
if ( value > 0 ) MediaSettings.Mute = false;
}
void ToggleMute()
{
MediaSettings.Mute = !MediaSettings.Mute;
}
/// <summary> 3D audio from every media player, or flat 2D for this player (bimp_spatial). </summary>
void ToggleSpatial()
{
MediaSettings.SpatialAudio = !MediaSettings.SpatialAudio;
}
string TakeUrl()
{
var text = UrlEntry?.Text?.Trim();
if ( UrlEntry is not null ) UrlEntry.Text = "";
return string.IsNullOrWhiteSpace( text ) ? null : text;
}
void OnSubmit()
{
// Enter plays right away if we can, otherwise queues
if ( Target.CanLocalControl && !Target.HasMedia ) PlayNow();
else if ( Target.CanLocalQueue ) AddToQueue();
else PlayNow();
}
void PlayNow()
{
var url = TakeUrl();
if ( url is null ) return;
Target.RequestPlay( url );
}
void AddToQueue()
{
var url = TakeUrl();
if ( url is null ) return;
Target.RequestEnqueue( url );
}
protected override int BuildHash()
{
if ( Target is null ) return 0;
return HashCode.Combine(
HashCode.Combine( Target.PlayId, Target.Title, Target.Paused, Target.Status, Target.LocalNotice, (int)Target.CurrentTime, Target.MediaDuration ),
HashCode.Combine( QueueHash(), MediaSettings.Mute, MediaSettings.Volume, MediaSettings.SpatialAudio, Target.CanLocalControl, pendingSeek, PadMode ),
HashCode.Combine( Target.Qualities, Target.AudioTracks, MediaSettings.Quality, MediaSettings.AudioLanguage, PlayingAsText, ShowRecent, RecentLinks.Version ),
HashCode.Combine( Target.CaptionTracks, MediaSettings.Subtitles, MediaSettings.SubtitleLanguage )
);
}
int QueueHash()
{
var h = new HashCode();
h.Add( Target.Queue.Count );
foreach ( var item in Target.Queue ) h.Add( item?.Url );
return h.ToHashCode();
}
}
@using System
@using Sandbox
@using Sandbox.UI
@namespace Bimp
@inherits PanelComponent
@implements Component.ExecuteInEditor
@*
A small display for audio-only players (speakers, radios, jukeboxes).
Put this on a GameObject with a WorldPanel (PanelSize ~800x400).
*@
<root class="mediaspeaker @(Player?.HasMedia == true ? "playing" : "idle")">
<div class="header">
<i>@(Player?.HasMedia == true ? "graphic_eq" : "speaker")</i>
@if ( ShowUseHint )
{
<div class="title hint"><span>Press</span><InputGlyph Action="use"></InputGlyph><span>to play something</span></div>
}
else
{
<ScrollingText class="title" Text="@TitleText"></ScrollingText>
}
</div>
<SpectrumBars Player="@Player"></SpectrumBars>
<div class="footer">
<div class="time">@TimeText</div>
@if ( Player is not null && Player.Queue.Count > 0 )
{
<div class="queue"><i>queue_music</i> @Player.Queue.Count</div>
}
</div>
@if ( !string.IsNullOrWhiteSpace( Player?.LocalNotice ) )
{
<div class="notice">@Player.LocalNotice</div>
}
</root>
@code
{
/// <summary>
/// The player this display shows. Found automatically in this object or its parents if unset.
/// </summary>
[Property] public MediaPlayer Player { get; set; }
/// <summary>
/// Scale the UI to fit the WorldPanel's PanelSize instead of getting cut off. Sets the WorldPanel's RenderScale.
/// </summary>
[Property, Group( "Layout" )] public bool FitToPanel { get; set; } = true;
/// <summary>
/// The panel size the UI is designed for.
/// </summary>
[Property, Group( "Layout" ), ShowIf( nameof( FitToPanel ), true )] public Vector2 DesignSize { get; set; } = new Vector2( 560, 280 );
string TitleText
{
get
{
if ( Player is null ) return "";
if ( Player.HasMedia ) return Player.Title;
if ( !string.IsNullOrWhiteSpace( Player.Status ) ) return Player.Status;
return "";
}
}
/// <summary> Nothing playing or happening: show how to start something, with the USE button's glyph. </summary>
bool ShowUseHint => Player is not null && !Player.HasMedia && string.IsNullOrWhiteSpace( Player.Status );
string TimeText
{
get
{
if ( Player is null || !Player.HasMedia ) return "";
if ( Player.IsLive ) return "Live";
var cur = MediaSource.FormatTime( Player.CurrentTime );
var s = Player.MediaDuration > 0 ? $"{cur} / {MediaSource.FormatTime( Player.MediaDuration )}" : cur;
return Player.Paused ? $"{s} (paused)" : s;
}
}
protected override void OnUpdate()
{
if ( FitToPanel ) PanelFit.Apply( this, DesignSize );
if ( Scene.IsEditor ) return;
Player ??= GetComponentInParent<MediaPlayer>();
}
protected override int BuildHash()
{
if ( Player is null ) return 0;
return HashCode.Combine( Player.PlayId, Player.Title, Player.Status, Player.Paused, (int)Player.CurrentTime, Player.Queue.Count, Player.LocalNotice );
}
}
@using System
@using Sandbox
@using Sandbox.UI
@namespace Bimp
@inherits PanelComponent
@implements Component.ExecuteInEditor
@*
A small display for audio-only players (speakers, radios, jukeboxes).
Put this on a GameObject with a WorldPanel (PanelSize ~800x400).
*@
<root class="mediaspeaker @(Player?.HasMedia == true ? "playing" : "idle")">
<div class="header">
<i>@(Player?.HasMedia == true ? "graphic_eq" : "speaker")</i>
@if ( ShowUseHint )
{
<div class="title hint"><span>Press</span><InputGlyph Action="use"></InputGlyph><span>to play something</span></div>
}
else
{
<ScrollingText class="title" Text="@TitleText"></ScrollingText>
}
</div>
<SpectrumBars Player="@Player"></SpectrumBars>
<div class="footer">
<div class="time">@TimeText</div>
@if ( Player is not null && Player.Queue.Count > 0 )
{
<div class="queue"><i>queue_music</i> @Player.Queue.Count</div>
}
</div>
@if ( !string.IsNullOrWhiteSpace( Player?.LocalNotice ) )
{
<div class="notice">@Player.LocalNotice</div>
}
</root>
@code
{
/// <summary>
/// The player this display shows. Found automatically in this object or its parents if unset.
/// </summary>
[Property] public MediaPlayer Player { get; set; }
/// <summary>
/// Scale the UI to fit the WorldPanel's PanelSize instead of getting cut off. Sets the WorldPanel's RenderScale.
/// </summary>
[Property, Group( "Layout" )] public bool FitToPanel { get; set; } = true;
/// <summary>
/// The panel size the UI is designed for.
/// </summary>
[Property, Group( "Layout" ), ShowIf( nameof( FitToPanel ), true )] public Vector2 DesignSize { get; set; } = new Vector2( 560, 280 );
string TitleText
{
get
{
if ( Player is null ) return "";
if ( Player.HasMedia ) return Player.Title;
if ( !string.IsNullOrWhiteSpace( Player.Status ) ) return Player.Status;
return "";
}
}
/// <summary> Nothing playing or happening: show how to start something, with the USE button's glyph. </summary>
bool ShowUseHint => Player is not null && !Player.HasMedia && string.IsNullOrWhiteSpace( Player.Status );
string TimeText
{
get
{
if ( Player is null || !Player.HasMedia ) return "";
if ( Player.IsLive ) return "Live";
var cur = MediaSource.FormatTime( Player.CurrentTime );
var s = Player.MediaDuration > 0 ? $"{cur} / {MediaSource.FormatTime( Player.MediaDuration )}" : cur;
return Player.Paused ? $"{s} (paused)" : s;
}
}
protected override void OnUpdate()
{
if ( FitToPanel ) PanelFit.Apply( this, DesignSize );
if ( Scene.IsEditor ) return;
Player ??= GetComponentInParent<MediaPlayer>();
}
protected override int BuildHash()
{
if ( Player is null ) return 0;
return HashCode.Combine( Player.PlayId, Player.Title, Player.Status, Player.Paused, (int)Player.CurrentTime, Player.Queue.Count, Player.LocalNotice );
}
}
@namespace Sandbox
@using Sandbox;
@using Sandbox.UI;
@using System;
@inherits Panel
@attribute [StyleSheet("DifficultyPanel.razor.scss")]
@{
var isClient = Networking.IsClient;
int diff = DontChangeGameDifficulty ? DifficultyToDisplay : Manager.Instance.Difficulty;
// var canDecreaseDifficulty = diff > Manager.MinDifficulty;
var isAtMinDifficulty = diff <= Manager.MinDifficulty - (DontChangeGameDifficulty ? 1 : 0);
var canIncreaseDifficulty = DontChangeGameDifficulty || ( diff < Manager.MaxDifficulty && Manager.Instance.HasBeatenDifficulty(diff) );
var isAtMaxDifficulty = diff >= Manager.MaxDifficulty;
var difficultyDesc = Manager.GetDescriptionForDifficulty( diff );
}
<root>
<div class="top-row">
@if( isAtMinDifficulty)
{
<div class="difficulty_decrease disabled_button" style="opacity:0;"> </div>
}
else
{
<div class="difficulty_decrease @(isClient && !DontChangeGameDifficulty ? "disabled_button" : "")" onclick="@(() => ButtonLeft())">
@if(Input.UsingController && (!isClient || DontChangeGameDifficulty)) { <InputHint class="inputbutton" Button="Slot1" /> }
</div>
}
<div class="middle">
<div class="difficulty_label" style="color:@(Manager.GetDifficultyLabelColor(diff).Rgba);">@($"{Manager.GetNameForDifficulty(diff)}")</div>
@if ( !string.IsNullOrEmpty( difficultyDesc ) )
{
<label class="description">@difficultyDesc</label>
}
</div>
@if(isAtMaxDifficulty)
{
<div class="difficulty_increase disabled_button" style="opacity:0;"></div>
}
else
{
if(canIncreaseDifficulty)
{
<div class="difficulty_increase @(isClient && !DontChangeGameDifficulty ? "disabled_button" : "")" onclick="@(() => ButtonRight())">
@if(Input.UsingController && (!isClient || DontChangeGameDifficulty)) { <InputHint class="inputbutton" Button="Slot3" /> }
</div>
}
else
{
<div class="difficulty_locked" Tooltip=@($"Beat {Manager.GetNameForDifficulty(diff)} first")></div>
}
}
</div>
</root>
@code
{
public bool DontChangeGameDifficulty { get; set; }
public static int DifficultyToDisplay { get; set; } = -1; // -1 equals all difficulties combined
public override void Tick()
{
base.Tick();
if(!Input.UsingController)
return;
if(Networking.IsClient && !DontChangeGameDifficulty)
return;
int diff = DontChangeGameDifficulty ? DifficultyToDisplay : Manager.Instance.Difficulty;
var isAtMinDifficulty = diff <= Manager.MinDifficulty - (DontChangeGameDifficulty ? 1 : 0);
var canIncreaseDifficulty = DontChangeGameDifficulty || ( diff < Manager.MaxDifficulty && Manager.Instance.HasBeatenDifficulty(diff) );
var isAtMaxDifficulty = diff >= Manager.MaxDifficulty;
if(Input.Pressed("Slot1") && !isAtMinDifficulty) { Manager.Instance.PlaySfxUI("click", pitch: 1.15f, volume: 0.75f); ButtonLeft(); }
else if(Input.Pressed("Slot3") && !isAtMaxDifficulty) { Manager.Instance.PlaySfxUI("click", pitch: 1.15f, volume: 0.75f); ButtonRight(); }
}
protected override int BuildHash()
{
return HashCode.Combine(
Manager.Instance.Difficulty,
DifficultyToDisplay,
Input.UsingController
);
}
void ButtonLeft()
{
GameSettingsSystem.Save();
ButtonLeftAsync();
}
async void ButtonLeftAsync()
{
if(DontChangeGameDifficulty)
{
if(DifficultyToDisplay == Manager.MinDifficulty)
DifficultyToDisplay = -1;
else
DifficultyToDisplay = Math.Max(DifficultyToDisplay - 1, Manager.MinDifficulty);
}
else
{
Manager.Instance.FadeRpc(fadeIn: false);
await Task.Frame();
var difficulty = Math.Max(Manager.Instance.Difficulty - 1, Manager.MinDifficulty);
Manager.Instance.SetDifficulty(difficulty);
}
}
void ButtonRight()
{
ButtonRightAsync();
GameSettingsSystem.Save();
}
async void ButtonRightAsync()
{
if(DontChangeGameDifficulty)
{
DifficultyToDisplay = Math.Min(DifficultyToDisplay + 1, Manager.MaxDifficulty);
}
else
{
Manager.Instance.FadeRpc(fadeIn: false);
await Task.Frame();
var difficulty = Math.Min(Manager.Instance.Difficulty + 1, Manager.MaxDifficulty);
Manager.Instance.SetDifficulty(difficulty);
}
}
}
@namespace Sandbox
@using Sandbox;
@using Sandbox.UI;
@using System;
@inherits Panel
@attribute [StyleSheet("InfoPanel.razor.scss")]
<root>
@{
// todo: split each ui line into its own component
var xp_percent = Player.ExperienceCurrent / (float)Player.ExperienceRequired;
var xp_transition_enabled = Player.GetUiStat( PlayerStat.DisableXpBarTransition ) == 0f;
var hp_percent = Math.Clamp(Math.Max(Player.Health, 0f) / Player.GetSyncStat(PlayerStat.MaxHp), 0f, 1f);
// var hp_regen = Player.Stats[PlayerStat.HealthRegen] + (Player.IsMoving ? 0f : Player.Stats[PlayerStat.HealthRegenStill]);
var hp_regen = Player.GetHpRegenAmount(forDisplay: true);
var hpBarHidden = Player.GetUiStat( PlayerStat.HpBarHidden ) > 0f;
}
<div class="info_bar">
<div class="bar_container" style=@("mask-image: url(\"/textures/ui/panel/info_panel_xp_mask.png\");")>
<div class="info_bar_overlay @(xp_transition_enabled ? "xp_transition_on" : "")" style="width:@(xp_percent * 100f)%; background-color: #8888ff77;"></div>
@if(Manager.Instance.Difficulty >= Manager.Instance.FirstDifficultyWithCurses && Player.AvailableCurseCount > 0)
{
var cursePercent = Player.IsBeingShownCurseChoices ? 1f : Utils.Map(Player.CurrLevelsUntilCurseChoice, Player.NumLevelsBetweenCurseChoices, 0, 0f, 1f);
var colorSpeed = Player.IsBeingShownCurseChoices ? 15f : Utils.Map(Player.CurrLevelsUntilCurseChoice, Player.NumLevelsBetweenCurseChoices, 0, 3f, 15f, EasingType.SineIn);
var curseColor = Color.Lerp(new Color(0.2f, 0.1f, 0.4f, 0.5f), new Color(0.8f, 0.1f, 0.9f, 0.25f), Utils.Map(Utils.FastSin(RealTime.Now * colorSpeed), -1f, 1f, 0f, 1f));
<div class="info_bar_overlay" style="width:@(cursePercent * 100f)%; background-color: @curseColor.Rgba;"></div>
}
</div>
<div class="info_bar_label">XP</div>
<div class="data_container">
<div class="data" style="opacity: 0.3;">@($"LVL {Player.Level}")</div>
</div>
</div>
@{
var regularMaxDashes = (int)MathF.Round( Player.GetUiStat( PlayerStat.NumDashes ) );
var maxDashes = regularMaxDashes + Player.NumTempDashesAvailable;
var totalAvailable = Player.NumDashesAvailable + Player.NumTempDashesAvailable;
}
@if( maxDashes < 9 )
{
<div class="info_dash_container" style="gap: @(Utils.Map(maxDashes, 1, 8, 8, 4, EasingType.QuadIn))px;">
<div class="dash_bar_container")>
@for(int i = 0; i < maxDashes; i++)
{
// Bars i < regularMaxDashes are regular dashes; i >= regularMaxDashes are temporary (from PerkDashTemporary).
// Temp bars are always shown to the right and colored purple.
var isTemp = i >= regularMaxDashes;
float progress;
bool isRecharging;
if ( isTemp )
{
// Temp dashes are either fully available or gone — no partial recharge animation.
int tempIndex = i - regularMaxDashes;
progress = tempIndex < Player.NumTempDashesAvailable ? 1f : 0f;
isRecharging = false;
}
else
{
// The bar at NumDashesAvailable is the one currently recharging (partial fill).
// Bars below it are full; bars above it are empty.
progress = i == Player.NumDashesAvailable ? Player.DashRechargeProgress : (i < Player.NumDashesAvailable ? 1f : 0f);
isRecharging = i >= Player.NumDashesAvailable;
}
var filledColor = isTemp ? "#b34dff88" : "#00ff0088";
var rechargingColor = isTemp ? "#b34dff44" : "#00ff4444";
<div class="info_bar" style="background-color: #00000077;">
<div class="info_bar_overlay" style="width:@(progress * 100f)%; background-color: @(isRecharging ? rechargingColor : filledColor);"></div>
</div>
}
</div>
@if(maxDashes <= 0)
{
<div class="info_bar">
</div>
}
<div class="info_bar_label" style="left: 8px;">
@("DASH")
</div>
</div>
}
else
{
var dashPercent = (float)totalAvailable / (float)maxDashes;
<div class="info_bar">
<div class="info_bar_overlay" style="width:@(dashPercent * 100f)%; background-color: #00ff0055; transition: all 0.2s ease-in-out;"></div>
<div class="info_bar_label">DASH</div>
<div class="data_container">
<div class="data">@maxDashes</div>
<div class="data">/</div>
<div class="data">@totalAvailable</div>
</div>
</div>
}
<div class="info_bar">
<div class="bar_container" style=@("mask-image: url(\"/textures/ui/panel/info_panel_hp_mask.png\");")>
@if ( hpBarHidden )
{
<div class="info_bar_overlay" style="width: 100%; background-color: #7a0040ff;"></div>
}
else
{
<div class="info_bar_overlay" style="width:@(hp_percent * 100f)%; background-color: #ffffffff; transition: all 0.4s ease-in-out "></div>
<div class="info_bar_overlay" style="width:@(hp_percent * 100f)%; background-color: #ff0000ff; transition: all 0.2s ease-in-out;"></div>
}
</div>
<div class="info_bar_label">HP</div>
<div class="data_container">
<div class="data">@($"{(int)MathF.Round(Player.GetSyncStat(PlayerStat.MaxHp))}")</div>
<div class="data">/</div>
@if ( hpBarHidden )
{
<div class="data">?</div>
}
else
{
<div class="data">@($"{(Math.Max(Player.Health, 0f) > 0f && Math.Max(Player.Health, 0f) < 1f ? (int)Math.Ceiling(Math.Max(Player.Health, 0f)) : (int)Math.Round(Math.Max(Player.Health, 0f)))}")</div>
@if (Math.Abs(hp_regen) > 0f)
{
<div class="data" style="color:@((hp_regen > 0f ? new Color(0f, 1f, 0f) : new Color(1f, 0f, 0f)).Rgba); letter-spacing: 2px; padding-right: 10px;">@($"{(hp_regen > 0f ? "+" : "")}{hp_regen.ToString("0.##")}")</div>
}
}
</div>
</div>
@if (Player.Armor > 0)
{
<div class="armor_icon" style="transform: scale(@(Utils.Map(Player.RealTimeSinceArmorChanged, 0f, 0.5f, 1.35f, 1f, EasingType.QuadOut)));">
<label class="armor_text">@($"{MathX.CeilToInt(Player.Armor)}")</label>
</div>
}
</root>
@code
{
public Player Player { get; set; }
protected override int BuildHash()
{
if( Manager.Instance.Difficulty >= Manager.Instance.FirstDifficultyWithCurses )
{
return HashCode.Combine(RealTime.Now);
}
var hpBarHidden = Player.GetUiStat( PlayerStat.HpBarHidden ) > 0f;
var hpHash = HashCode.Combine(
Player.Health,
Player.GetSyncStat(PlayerStat.MaxHp),
Player.GetSyncStat( PlayerStat.HealthRegen ),
Player.GetSyncStat( PlayerStat.HealthRegenStill ),
Player.IsMoving,
Player.GetHpRegenAmount( forDisplay: true ),
hpBarHidden
);
var armorHash = HashCode.Combine(
Player.RealTimeSinceArmorChanged > 0.5f ? 0f : Player.RealTimeSinceArmorChanged.Relative,
Player.Armor
);
var xpHash = HashCode.Combine(
Player.Level,
Player.ExperienceCurrent,
Player.ExperienceRequired
);
return HashCode.Combine(
Player.DashRechargeProgress,
Player.NumDashesAvailable,
Player.NumTempDashesAvailable,
Player.GetUiStat( PlayerStat.NumDashes ),
hpHash,
armorHash,
xpHash
);
}
}
@namespace Sandbox
@using Sandbox;
@using Sandbox.UI;
@using System;
@using System.Linq;
@using System.Collections.Generic;
@inherits Panel
@attribute [StyleSheet("LoadoutPanel.razor.scss")]
<root>
<div class="hide_button" onclick=@(() => Close())></div>
<div class="title_label">Loadout</div>
<div class="tabs">
@for(int t = 0; t < TabNames.Length; t++)
{
var tabIndex = t;
<div class="tab @(_selectedTab == tabIndex ? "active" : "")" onclick=@(() => SelectTab(tabIndex))>
@TabNames[tabIndex]
</div>
}
</div>
<div class="panel_body">
@if(_selectedTab == 2)
{
var equippedGems = ProgressManager.GetEquippedGems();
var ownedGems = ProgressManager.GetOwnedItemsByCategory(ShopItemCategory.Gem);
var maxGemSlots = ProgressManager.GetSelectedGunSocketCount();
var gemsFull = equippedGems.Count >= maxGemSlots;
@if(ownedGems.Count > 0)
{
<div class="items_grid">
@foreach(var gem in ownedGems)
{
var isEquipped = equippedGems.Contains(gem.Id);
var gemCapture = gem;
<LoadoutItemPanel Item=@gem IsSelected=@isEquipped IsSlotFull=@(!isEquipped && gemsFull) OnClick=@(() => OnSelectGem(gemCapture)) />
}
</div>
}
else
{
<div class="coming_soon">Buy gems in the Shop to equip them here.</div>
}
}
else
{
var items = GetItemsForTab(_selectedTab);
@if(_selectedTab == 1)
{
var selectedCharmIds = ProgressManager.GetSelectedCharmIds();
var maxCharmSlots = ProgressManager.GetSelectedGunCharmSlotCount();
var charmsFull = selectedCharmIds.Count >= maxCharmSlots;
<div class="items_grid">
@foreach(var item in items)
{
var isSelected = selectedCharmIds.Contains(item.Id);
var itemCapture = item;
<LoadoutItemPanel Item=@item IsSelected=@isSelected IsSlotFull=@(!isSelected && charmsFull && maxCharmSlots > 1) OnClick=@(() => OnSelectItem(itemCapture)) />
}
</div>
}
else
{
var selectedGunId = ProgressManager.GetSelectedGunId();
<div class="items_grid">
@foreach(var item in items)
{
var isSelected = item.Id == selectedGunId;
var itemCapture = item;
<LoadoutItemPanel Item=@item IsSelected=@isSelected OnClick=@(() => OnSelectItem(itemCapture)) />
}
</div>
}
}
</div>
</root>
@code
{
static readonly string[] TabNames = { "Guns", "Charms", "Gems" };
static readonly ShopItemCategory[] TabCategories = { ShopItemCategory.Gun, ShopItemCategory.Charm, ShopItemCategory.Gem };
static int _selectedTab = 0;
static bool _initialTabSet = false;
protected override void OnAfterTreeRender(bool firstTime)
{
base.OnAfterTreeRender(firstTime);
if(firstTime)
{
GunPreviewController.Show();
if(!_initialTabSet)
{
_selectedTab = BestStartingTab();
_initialTabSet = true;
}
}
}
int BestStartingTab()
{
for(int i = 0; i < TabCategories.Length; i++)
if(GetItemsForTab(i).Count > 0)
return i;
return 0;
}
List<ShopItemDef> GetItemsForTab(int tab)
{
if(tab < 0 || tab >= TabCategories.Length)
return new List<ShopItemDef>();
return ProgressManager.GetOwnedItemsByCategory(TabCategories[tab]);
}
void OnSelectItem(ShopItemDef item)
{
if(_selectedTab == 0)
{
var currentGunId = ProgressManager.GetSelectedGunId();
var deselecting = currentGunId == item.Id;
var targetDef = deselecting ? ProgressManager.DefaultGun : item;
// Unequip gems that exceed the target gun's socket count
var newSocketCount = targetDef.GemSocketCount > 0 ? targetDef.GemSocketCount : 3;
var equippedGems = ProgressManager.GetEquippedGems();
while(equippedGems.Count > newSocketCount)
{
ProgressManager.UnequipGem(equippedGems[equippedGems.Count - 1]);
equippedGems = ProgressManager.GetEquippedGems();
}
// Trim selected charms to the target gun's charm slot count
var newCharmSlots = targetDef.CharmSlotCount > 0 ? targetDef.CharmSlotCount : 1;
var selectedCharms = ProgressManager.GetSelectedCharmIds();
if(selectedCharms.Count > newCharmSlots)
ProgressManager.SetSelectedCharmIds(selectedCharms.Take(newCharmSlots).ToList());
ProgressManager.SetSelectedGunId(deselecting ? ProgressManager.DefaultGun.Id : item.Id);
// Broadcast gun swap so all clients see the new model
var player = Manager.Instance.LocalPlayer;
if(player.IsValid())
{
var gunPrefab = ProgressManager.GetPrefabPath(targetDef.Id) ?? "prefabs/guns/gun_default.prefab";
var charmIds = ProgressManager.GetSelectedCharmIds();
var charm0 = charmIds.Count > 0 ? ProgressManager.GetPrefabPath(charmIds[0]) ?? "" : "";
var charm1 = charmIds.Count > 1 ? ProgressManager.GetPrefabPath(charmIds[1]) ?? "" : "";
var (g0, g1, g2, g3) = GetEquippedGemPrefabs();
player.SwapGunRpc(gunPrefab, charm0, g0, g1, g2, g3, charm1);
}
GunPreviewController.Refresh();
}
else if(_selectedTab == 1)
{
var selectedIds = ProgressManager.GetSelectedCharmIds();
var maxSlots = ProgressManager.GetSelectedGunCharmSlotCount();
if(selectedIds.Contains(item.Id))
{
// Deselect
ProgressManager.SetSelectedCharmIds(selectedIds.Where(id => id != item.Id).ToList());
}
else if(maxSlots == 1)
{
// Single-slot gun: replace
ProgressManager.SetSelectedCharmIds(new List<string> { item.Id });
}
else if(selectedIds.Count < maxSlots)
{
// Multi-slot gun with a free slot: add
ProgressManager.SetSelectedCharmIds(selectedIds.Concat(new[] { item.Id }).ToList());
}
else
{
// All slots full: do nothing, player must deselect first
return;
}
// Broadcast charm swap so all clients see the new model
var player = Manager.Instance.LocalPlayer;
if(player.IsValid())
{
var ids = ProgressManager.GetSelectedCharmIds();
var charm0 = ids.Count > 0 ? ProgressManager.GetPrefabPath(ids[0]) ?? "" : "";
var charm1 = ids.Count > 1 ? ProgressManager.GetPrefabPath(ids[1]) ?? "" : "";
player.SwapCharmRpc(charm0, charm1);
}
GunPreviewController.Refresh();
}
StateHasChanged();
}
void OnSelectGem(ShopItemDef gem)
{
var equipped = ProgressManager.GetEquippedGems();
var maxSlots = ProgressManager.GetSelectedGunSocketCount();
if(equipped.Contains(gem.Id))
ProgressManager.UnequipGem(gem.Id);
else if(equipped.Count < maxSlots)
ProgressManager.EquipGem(gem.Id, maxSlots);
BroadcastGemSwap();
GunPreviewController.Refresh();
StateHasChanged();
}
void BroadcastGemSwap()
{
var player = Manager.Instance.LocalPlayer;
if(!player.IsValid()) return;
var (g0, g1, g2, g3) = GetEquippedGemPrefabs();
player.SwapGemsRpc(g0, g1, g2, g3);
}
static (string, string, string, string) GetEquippedGemPrefabs()
{
var equipped = ProgressManager.GetEquippedGems();
string Get(int i) => i < equipped.Count ? (ProgressManager.GetPrefabPath(equipped[i]) ?? "") : "";
return (Get(0), Get(1), Get(2), Get(3));
}
void SelectTab(int tabIndex)
{
if(_selectedTab == tabIndex) return;
_selectedTab = tabIndex;
StateHasChanged();
}
void Close()
{
GunPreviewController.Hide();
Manager.Instance.ShowLoadoutPanel = false;
}
protected override int BuildHash()
{
return System.HashCode.Combine(
Manager.Instance.ShowLoadoutPanel,
_selectedTab,
ProgressManager.StateVersion
);
}
}
@namespace Sandbox
@using Sandbox;
@using Sandbox.UI;
@using System;
@using System.Text.Json;
@inherits Panel
@attribute [StyleSheet("LobbyNametagPanel.razor.scss")]
<root>
<LeaderboardPlayerIcon style="width: 42px; height: 42px;" PlayerInfo=@PlayerInfo />
<div class="name">@PlayerInfo.displayName</div>
@if(Networking.IsHost || PlayerInfo.steamId == Game.SteamId)
{
<i class="remove-btn" onclick=@KickPlayer>disabled_by_default</i>
}
</root>
@code
{
const int MAX_NAME_LENGTH = 13;
public PlayerInfo PlayerInfo { get; set; }
void KickPlayer()
{
Manager.Instance?.RequestKickPlayer(PlayerInfo.steamId);
}
}