727 results

@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;
@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 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")
				&nbsp;&middot;&nbsp; -@((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 &middot; 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 &middot; 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 &middot; 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">&rsaquo;</span>
										<span class="c-o">ORANGE</span>
										<span class="arrow">&rsaquo;</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 &middot; 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 &middot; 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 &middot; 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 &mdash; DRONES JAMMED &nbsp;@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 &mdash; DRONES FREE &nbsp;@Manager.ShieldBreakSecondsLeft.ToString( "0.0" )s
				<span class="sub">it recharges</span>
			</div>
		}

		@if ( Progress.InHollowRun )
		{
			<div class="hollow-banner">
				HOLLOW RUN &middot; @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 &mdash; <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 &nbsp; <b>RMB</b> charge &nbsp; <b>WASD</b> move &nbsp;
					<b>SPACE</b> jump / double jump &nbsp; <b>SHIFT</b> sprint &nbsp; <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
							&nbsp;&middot;&nbsp; @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 &nbsp; <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
							&nbsp;&middot;&nbsp; @($"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 &nbsp;<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) &middot; @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>&lt;</div>
				<div class="selector-value">@OMRSceneFlow.GetBotMatchDifficultyName()</div>
				<div class="selector-arrow" onclick=@BotDifficultyNext>&gt;</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();
	}
}
@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);
	}
}
@namespace Sandbox
@using Sandbox;
@using Sandbox.UI;
@using System;
@inherits Panel
@attribute [StyleSheet("PlayerPerks.razor.scss")]

@{
	var player = Manager.Instance.SelectedPlayer.IsValid()
		? Manager.Instance.SelectedPlayer
		: Manager.Instance.LocalPlayer;

	if (!player.IsValid())
		return;

	var height = Manager.Instance.Players.Count > 1 ? 880f : 950f;
}

<root style="height: @(height)px; max-height: @(height)px;">
	@if( player.IsProxy )
	{
		if (player.SyncPerks.Count == 0)
			return;

		int index = 0;
		<div class="itemlist" style="opacity:@(1f);">
			@foreach (var pair in player.SyncPerks)
			{
				var rot = Utils.Map((player.PerkRandomRotationSeed + index * 3f) % 10, 0, 9, -5f, 5f);
				<PerkIconStatic style="width: 50px; height: 50px; transform: rotate(@(rot)deg);" PerkType=@(PerkManager.IdentityToType(pair.Key)) Level=@(pair.Value) ProgressLevel=@(pair.Value) Banished=@false />

				index++;
			}
		</div>
	}
	else
	{
		if (player.Perks.Count == 0)
			return;

		int index = 0;
		<div class="itemlist @(Manager.Instance.IsGameOver ? "" : "itemlist_hover")">
			@foreach (var (_, perk) in player.Perks)
			{
				var rot = Utils.Map((player.PerkRandomRotationSeed + index * 3f) % 10, 0, 9, -5f, 5f);
				<PerkIcon style="width: 50px; height: 50px;" Perk=@perk Angle=@rot Banished=@false />

				index++;
			}
		</div>
	}
</root>

@code
{
	protected override int BuildHash()
	{
		var player = Manager.Instance.SelectedPlayer.IsValid()
			? Manager.Instance.SelectedPlayer
			: Manager.Instance.LocalPlayer;

		var perkHash = player.IsValid()
			? player.PerkHash
			: 0;

		var perkCount = player.IsValid()
			? player.SyncPerks.Count
			: 0;

		return HashCode.Combine(
			player, 
			// player.Id // todo: this instead?
			perkCount,
			perkHash,
			Manager.Instance.IsGameOver
			// Time.Now
		);
	}
}
@using Sandbox;
@using Sandbox.UI;
@inherits Panel
@namespace Sandbox

<root class="tt">

    <div class="icon">@Icon</div>
    <div class="title">@Title</div>
    <div class="description">@Description</div>

</root>

@code
{
    [Parameter] public string Title { get; set; }
    [Parameter] public string Icon { get; set; }
    [Parameter] public string Description { get; set; }
}
@using Sandbox
@using Sandbox.UI
@inherits PanelComponent
@namespace Sandbox

<root>
	@if ( HasContent )
	{
		@foreach ( var connector in Connectors )
		{
			if ( !connector.Layout.Visible ) continue;
			<div @key=@($"{connector.Source.Id}:{connector.Target.Id}") class="connector">
				@{ var dotCount = ConnectorDotCount( connector ); }
				@for ( var i = 0; i < dotCount; i++ )
				{
					var position = ConnectorDotPosition( connector, dotCount, i );
					<div class="dot" style="left: @(position.x)px; top: @(position.y)px; animation-delay: @(ConnectorDotDelay( connector, dotCount, i ))s;"></div>
				}
			</div>
		}

		@foreach ( var entry in Entries )
		{
			if ( !entry.Layout.Visible ) continue;
			<div class="@EntryClass( entry )" style="left: @(entry.Layout.ScreenPosition.x)px; top: @(entry.Layout.ScreenPosition.y)px;">
				@if ( entry.Card is { } card )
				{
					<div class="card-anchor" style="left: @(entry.Layout.CardOffset.x)px; top: @(entry.Layout.CardOffset.y)px;">
						<div class="card-stack">
							<div class="heading">
								<i>@entry.Icon</i>
								<label class="title">@card.Title</label>
							</div>

						<div class="card">
							@if ( card.Options.Count > 0 )
							{
								<div class="option-list">
									@foreach ( var option in VisibleOptions( card ) )
									{
										<div class="option @(option == SelectedOption( card ) ? "active" : "")">
											<i>@option.Icon</i>
										<div class="option-labels">
											<label class="option-title">@option.Title</label>
											@if ( option == SelectedOption( card ) && !string.IsNullOrWhiteSpace( option.Description ) )
											{
												<label class="description option-description">@option.Description</label>
											}
										</div>
									</div>
								}
							</div>
							}
						</div>
						</div>
					</div>
			}
		</div>
		}
	}
</root>

@code
{
	public sealed class Entry( GameObject target, string icon = "link" )
	{
		public GameObject Target { get; } = target;
		public string Icon { get; } = icon;
		public Card Card { get; init; }

		internal Projection Layout { get; } = new();
	}

	public sealed class Card( string title )
	{
		public string Title { get; } = title;
		public IReadOnlyList<Option> Options { get; init; } = [];
		public int SelectedOption { get; init; }
	}

	public sealed class Option( string title )
	{
		public string Icon { get; init; } = "input";
		public string Title { get; } = title;
		public string Description { get; init; }
	}

	public sealed class Connector( GameObject source, GameObject target )
	{
		public GameObject Source { get; } = source;
		public GameObject Target { get; } = target;

		internal ConnectorProjection Layout { get; } = new();
	}

	internal sealed class Projection
	{
		public Vector2 ScreenPosition { get; set; }
		public Vector2 CardOffset { get; set; }
		public bool Visible { get; set; }
		public bool CardBelow { get; set; }
		public bool CardShiftRight { get; set; }
		public bool CardShiftLeft { get; set; }
	}

	internal sealed class ConnectorProjection
	{
		public Vector2 Start { get; set; }
		public Vector2 End { get; set; }
		public float Length { get; set; }
		public bool Visible { get; set; }
	}

	private readonly record struct Geometry( Vector3 Center, Vector3[] Bounds );

	private const float CollectRadius = 4096f;

	private readonly List<Entry> Entries = new();
	private readonly List<Connector> Connectors = new();
	private readonly List<Connector> _collectedConnectors = new();
	private readonly Dictionary<GameObject, Geometry> _geometry = new();
	private RealTimeSince _sinceGeometryRefresh = 1f;
	private RealTimeSince _sinceShowRequested = 999f;
	private RealTimeSince _sinceCollect = 999f;
	private Entry _hoverEntry;
	private int? _contentHash;
	private int _revision;
	private int _layoutHash;
	private bool _hasLayoutHash;

	/// <summary>
	/// Keep the overlay visible this frame — call every frame while a tool wants it up,
	/// optionally with a hover card to show. The overlay also shows itself while the
	/// inspect menu is open; either way it collects and draws the wires on its own.
	/// </summary>
	public void Show( Entry hoverEntry = null )
	{
		_sinceShowRequested = 0;
		_hoverEntry = hoverEntry;
	}

	private static IEnumerable<GameObject> FindPhysicsRoots( Scene scene, Sphere area )
	{
		var seen = new HashSet<GameObject>();
		foreach ( var physicalObject in scene.FindInPhysics( area ) )
		{
			if ( !physicalObject.IsValid() ) continue;

			var root = physicalObject.Root;
			if ( root.IsValid() && seen.Add( root ) )
				yield return root;
		}
	}

	private void SetContent( IEnumerable<Entry> entries, IEnumerable<Connector> connectors )
	{
		Entries.Clear();
		Entries.AddRange( entries );
		Connectors.Clear();
		Connectors.AddRange( connectors );
		_hasLayoutHash = false;
		_revision++;

		// There won't be an update to project empty content, so clear the existing tree now.
		if ( !HasContent )
			StateHasChanged();
		else
			UpdateLayout();
	}

	private bool HasContent => Entries.Count > 0 || Connectors.Count > 0;

	protected override void OnUpdate()
	{
		base.OnUpdate();
		UpdateContent();
		UpdateLayout();
	}

	private void UpdateContent()
	{
		var toolActive = _sinceShowRequested < 0.1f;
		if ( !toolActive )
			_hoverEntry = null;

		var menuOpen = SpawnMenuHost.IsOpen && SpawnMenuHost.GetActiveMode() is ContextMenuHost;
		if ( !menuOpen && !toolActive )
		{
			if ( HasContent )
				SetContent( [], [] );

			_contentHash = null;
			return;
		}

		if ( _sinceCollect > 0.2f )
		{
			_sinceCollect = 0;
			CollectConnectors( _collectedConnectors );
		}

		// Only rebuild the tree when what's shown actually changed.
		var contentHash = BuildContentHash();
		if ( contentHash == _contentHash ) return;

		_contentHash = contentHash;
		SetContent( _hoverEntry is null ? [] : [_hoverEntry], _collectedConnectors );
	}

	/// <summary>
	/// Find every wire around the camera: signal connections on outputs, and component
	/// links (references and presence) on their ManualLink stamps.
	/// </summary>
	private void CollectConnectors( List<Connector> connectors )
	{
		connectors.Clear();

		var camera = Scene?.Camera;
		if ( !camera.IsValid() ) return;

		foreach ( var root in FindPhysicsRoots( Scene, new Sphere( camera.WorldPosition, CollectRadius ) ) )
		{
			if ( root.Tags.Has( "world" ) || root.Tags.Has( "player" ) ) continue;

			foreach ( var output in SignalSystem.GetOutputs( root ) )
			{
				// Component outputs have no connection list; their wires show from the stamps below.
				if ( output.Port is null ) continue;

				foreach ( var connection in output.Port.Connections )
				{
					if ( connection?.Target.IsValid() == true )
						connectors.Add( new Connector( output.Component.GameObject.Root, connection.Target.GameObject.Root ) );
				}
			}

			foreach ( var link in root.GetComponentsInChildren<ManualLink>() )
			{
				if ( !link.HasWire || !link.IsComponentWire ) continue;
				if ( !link.SignalSource.IsValid() || !link.SignalTarget.IsValid() ) continue;

				// Each pair is stamped on both ends; draw once, from the source side.
				var sourceRoot = link.SignalSource.GameObject.Root;
				if ( sourceRoot != root ) continue;

				connectors.Add( new Connector( sourceRoot, link.SignalTarget.GameObject.Root ) );
			}
		}
	}

	private int BuildContentHash()
	{
		var hash = new HashCode();

		if ( _hoverEntry is { } entry )
		{
			hash.Add( entry.Target.IsValid() ? entry.Target.Id : Guid.Empty );
			hash.Add( entry.Icon );

			if ( entry.Card is { } card )
			{
				hash.Add( card.Title );
				hash.Add( card.SelectedOption );

				foreach ( var option in card.Options )
				{
					hash.Add( option.Icon );
					hash.Add( option.Title );
					hash.Add( option.Description );
				}
			}
		}

		foreach ( var connector in _collectedConnectors )
		{
			hash.Add( connector.Source.Id );
			hash.Add( connector.Target.Id );
		}

		return hash.ToHashCode();
	}

	private void UpdateLayout()
	{
		if ( !HasContent ) return;

		var camera = Scene.Camera;
		var viewport = camera.IsValid() ? camera.ScreenRect.Size : Vector2.Zero;
		var scale = Panel.ScaleFromScreen;
		var panelViewport = viewport * scale;
		if ( _sinceGeometryRefresh > 0.2f ) RefreshGeometry();

		foreach ( var connector in Connectors )
		{
			connector.Layout.Visible = false;
			if ( !camera.IsValid() || !connector.Source.IsValid() || !connector.Target.IsValid() ) continue;

			var start = camera.PointToScreenPixels( GetWorldCenter( connector.Source ), out var startBehind );
			var end = camera.PointToScreenPixels( GetWorldCenter( connector.Target ), out var endBehind );
			if ( startBehind || endBehind ) continue;

			var startCenter = start;
			var endCenter = end;

			// Lines run between the objects' projected bounds edges, not their centers.
			if ( TryGetScreenBounds( camera, connector.Source, out var sourceBounds ) )
				start = ClipToBounds( sourceBounds, start, end );
			if ( TryGetScreenBounds( camera, connector.Target, out var targetBounds ) )
				end = ClipToBounds( targetBounds, end, start );

			start *= scale;
			end *= scale;
			var line = end - start;
			var centerLine = (endCenter - startCenter) * scale;

			// Overlapping bounds leave nothing to draw across — the clipped ends cross over.
			if ( line.Length < 12f || line.x * centerLine.x + line.y * centerLine.y <= 0f ) continue;

			connector.Layout.Start = start;
			connector.Layout.End = end;
			connector.Layout.Length = line.Length;
			connector.Layout.Visible = true;
		}

		foreach ( var entry in Entries )
		{
			entry.Layout.Visible = false;
			if ( entry.Card is null ) continue; // Entries only render their card.
			if ( !camera.IsValid() ) continue;
			if ( !entry.Target.IsValid() ) continue;

			var geometry = GetGeometry( entry.Target );
			var worldPosition = entry.Target.WorldTransform.PointToWorld( geometry.Center );
			var pixels = camera.PointToScreenPixels( worldPosition, out var behind );
			if ( behind || !camera.ScreenRect.IsInside( pixels ) )
				continue;

			var cardPixels = pixels;
			if ( entry.Card is not null )
			{
				var min = new Vector2( float.MaxValue );
				var max = new Vector2( float.MinValue );
				var projected = false;

				foreach ( var localCorner in geometry.Bounds )
				{
					var corner = entry.Target.WorldTransform.PointToWorld( localCorner );
					var screenCorner = camera.PointToScreenPixels( corner, out var cornerBehind );
					if ( cornerBehind ) continue;

					min = Vector2.Min( min, screenCorner );
					max = Vector2.Max( max, screenCorner );
					projected = true;
				}

				if ( projected )
					cardPixels = new Vector2( (min.x + max.x) * 0.5f, min.y );
			}

			entry.Layout.ScreenPosition = pixels * scale;
			entry.Layout.CardOffset = (cardPixels - pixels) * scale;
			entry.Layout.CardBelow = cardPixels.y < (entry.Card?.Options.Count > 0 ? 290f : 180f);
			entry.Layout.CardShiftRight = cardPixels.x * scale < 180f;
			entry.Layout.CardShiftLeft = cardPixels.x * scale > panelViewport.x - 180f;
			entry.Layout.Visible = true;
		}

		// The Razor tree only includes projected entries. Rebuild only when their visible
		// pixel layout changed, rather than recreating the dotted line every update.
		var layoutHash = BuildLayoutHash();
		if ( _hasLayoutHash && _layoutHash == layoutHash ) return;

		_layoutHash = layoutHash;
		_hasLayoutHash = true;
		StateHasChanged();
	}

	private int BuildLayoutHash()
	{
		var hash = new HashCode();
		hash.Add( _revision );

		foreach ( var entry in Entries )
		{
			hash.Add( entry.Layout.Visible );
			hash.Add( (int)MathF.Round( entry.Layout.ScreenPosition.x ) );
			hash.Add( (int)MathF.Round( entry.Layout.ScreenPosition.y ) );
			hash.Add( (int)MathF.Round( entry.Layout.CardOffset.x ) );
			hash.Add( (int)MathF.Round( entry.Layout.CardOffset.y ) );
			hash.Add( entry.Layout.CardBelow );
			hash.Add( entry.Layout.CardShiftRight );
			hash.Add( entry.Layout.CardShiftLeft );
		}

		foreach ( var connector in Connectors )
		{
			hash.Add( connector.Layout.Visible );
			hash.Add( (int)MathF.Round( connector.Layout.Start.x ) );
			hash.Add( (int)MathF.Round( connector.Layout.Start.y ) );
			hash.Add( (int)MathF.Round( connector.Layout.End.x ) );
			hash.Add( (int)MathF.Round( connector.Layout.End.y ) );
		}

		return hash.ToHashCode();
	}

	private void RefreshGeometry()
	{
		_sinceGeometryRefresh = 0;
		var targets = Entries.Where( entry => entry.Target.IsValid() ).Select( entry => entry.Target ).ToHashSet();
		targets.UnionWith( Connectors.Where( connector => connector.Source.IsValid() ).Select( connector => connector.Source ) );
		targets.UnionWith( Connectors.Where( connector => connector.Target.IsValid() ).Select( connector => connector.Target ) );

		foreach ( var target in _geometry.Keys.ToArray() )
		{
			if ( !targets.Contains( target ) )
				_geometry.Remove( target );
		}

		foreach ( var target in targets )
			_geometry[target] = BuildGeometry( target );
	}

/// <summary>
	/// The screen-space box around the object's projected bounds corners, in pixels.
	/// False when any corner is behind the camera — the box would be meaningless.
	/// </summary>
	private bool TryGetScreenBounds( CameraComponent camera, GameObject target, out Rect bounds )
	{
		bounds = default;

		var geometry = GetGeometry( target );
		var min = new Vector2( float.MaxValue );
		var max = new Vector2( float.MinValue );

		foreach ( var localCorner in geometry.Bounds )
		{
			var corner = target.WorldTransform.PointToWorld( localCorner );
			var screenCorner = camera.PointToScreenPixels( corner, out var behind );
			if ( behind ) return false;

			min = Vector2.Min( min, screenCorner );
			max = Vector2.Max( max, screenCorner );
		}

		bounds = new Rect( min, max - min );
		return true;
	}

	/// <summary>
	/// Slide a point sitting inside the box along the line towards the outside point,
	/// stopping at the box edge.
	/// </summary>
	private static Vector2 ClipToBounds( Rect bounds, Vector2 inside, Vector2 outside )
	{
		var direction = outside - inside;
		var t = 1f;

		if ( MathF.Abs( direction.x ) > 0.0001f )
			t = MathF.Min( t, ((direction.x > 0f ? bounds.Right : bounds.Left) - inside.x) / direction.x );

		if ( MathF.Abs( direction.y ) > 0.0001f )
			t = MathF.Min( t, ((direction.y > 0f ? bounds.Bottom : bounds.Top) - inside.y) / direction.y );

		return inside + direction * t.Clamp( 0f, 1f );
	}

	private Vector3 GetWorldCenter( GameObject target )
	{
		return target.WorldTransform.PointToWorld( GetGeometry( target ).Center );
	}

	private Geometry GetGeometry( GameObject target )
	{
		if ( !_geometry.TryGetValue( target, out var geometry ) )
		{
			geometry = BuildGeometry( target );
			_geometry[target] = geometry;
		}

		return geometry;
	}

	private static Geometry BuildGeometry( GameObject target )
	{
		var bounds = target.GetBounds();
		var transform = target.WorldTransform;
		var corners = bounds.Corners.Select( corner => transform.PointToLocal( corner ) ).ToArray();

		return new Geometry( transform.PointToLocal( bounds.Center ), corners );
	}

	private static string EntryClass( Entry entry )
	{
		return $"world-entry{(entry.Layout.CardBelow ? " card-below" : "")}{(entry.Layout.CardShiftRight ? " card-shift-right" : "")}{(entry.Layout.CardShiftLeft ? " card-shift-left" : "")}";
	}

	private static Option SelectedOption( Card card )
	{
		if ( card.Options.Count == 0 ) return null;
		return card.Options[card.SelectedOption.Clamp( 0, card.Options.Count - 1 )];
	}

	private static IEnumerable<Option> VisibleOptions( Card card )
	{
		var count = card.Options.Count;
		var selected = card.SelectedOption.Clamp( 0, count - 1 );
		var first = (selected - 1).Clamp( 0, Math.Max( 0, count - 3 ) );
		return card.Options.Skip( first ).Take( 3 );
	}

	// The line endpoints already sit on the bounds edges; just breathe a little off them.
	private const float ConnectorGap = 10f;
	private const float ConnectorAnimationDuration = 4f;

	private static int ConnectorDotCount( Connector connector )
	{
		var usableLength = connector.Layout.Length + ConnectorAmplitude( connector ) * ConnectorWaveCount( connector ) - ConnectorGap * 2f;
		return usableLength <= 0f ? 0 : ((int)(usableLength / 14f) + 1).Clamp( 1, 64 );
	}

	private static Vector2 ConnectorDotPosition( Connector connector, int count, int index )
	{
		var inset = (ConnectorGap / connector.Layout.Length).Clamp( 0f, 0.45f );
		var progress = count <= 1 ? 0.5f : index / (count - 1f);
		var t = inset + (1f - inset * 2f) * progress;
		return ConnectorPoint( connector, t );
	}

	private static float ConnectorAmplitude( Connector connector ) => MathF.Min( 34f, connector.Layout.Length * 0.09f );
	private static int ConnectorWaveCount( Connector connector ) => ((int)(connector.Layout.Length / 180f)).Clamp( 1, 3 );

	private static Vector2 ConnectorPoint( Connector connector, float t )
	{
		var start = connector.Layout.Start;
		var end = connector.Layout.End;
		var line = end - start;
		var direction = line / connector.Layout.Length;
		var normal = new Vector2( -direction.y, direction.x );
		var wave = MathF.Sin( t * ConnectorWaveCount( connector ) * MathF.PI * 2f );
		return start + line * t + normal * wave * ConnectorAmplitude( connector );
	}

	private static float ConnectorDotDelay( Connector connector, int count, int index )
	{
		return count <= 1 ? 0f : ConnectorAnimationDuration * index / count;
	}

	protected override int BuildHash() => _revision;
}
@using Sandbox;
@using Sandbox.UI;
@using Sandbox.Mounting;
@inherits Panel
@namespace Sandbox

<root>

<Button class="menu-action primary wide" Icon="💾" Text="#spawnmenu.dupes.save_button" Disabled=@( !CanSaveDupe() ) @onclick=@MakeSave></Button>

</root>

@using Sandbox;
@using Sandbox.UI;
@using Sandbox.Mounting;
@inherits Panel
@namespace Sandbox

<root>

	<div class="title">@Item.Title</div>

	<div class="author">

		<div class="avatar" style="background-image: url('@Item.Owner.Avatar');"></div>
		<div class="name">@Item.Owner.Name</div>

	</div>

</root>

@code
{
    public Storage.QueryItem Item { get; set; }

    public override bool WantsDrag => true;

    protected override void OnParametersSet()
    {
        Style.SetBackgroundImage( Item.Preview );
    }

    protected override void OnMouseDown( MousePanelEvent e )
    {
        if ( e.MouseButton == MouseButtons.Right )
        {
            var menu = new Sandbox.UI.Menu();

            SpawnlistData.PopulateContextMenu( menu, this, new SpawnlistItem
            {
                Ident = SpawnlistItem.MakeIdent( "dupe", Item.Id.ToString(), "workshop" ),
                Title = Item.Title,
                Icon = Item.Preview,
            } );

            menu.Open( this, Popup.PositionMode.UnderMouse );
            return;
        }

        base.OnMouseDown( e );
    }

    protected override async void OnDragStart( DragEvent e )
    {
        var data = new DragData
        {
            Type = "dupe",
            Icon = Item.Preview,
            Title = Item.Title,
            Source = this
        };

        DragHandler.StartDragging( data );

		var installed = await Item.Install();
		if ( installed is null ) return;

		data.Data = installed.Files.ReadAllText( "/dupe.json" );
	}

    protected override void OnDragEnd(DragEvent e)
    {
        if ( DragHandler.IsDragging )
        {
            DragHandler.StopDragging();
        }
    }
}
@using Sandbox;
@using Sandbox.UI;
@inherits Panel
@namespace Sandbox

<root Tooltip="#spawnmenu.spawnlist.drag_to_reorder">
    <IconPanel Text="drag_indicator" />
</root>

@code
{
    [Parameter] public SpawnlistItem Item { get; set; }

    public override bool WantsDrag => Ancestors.OfType<SpawnlistView>().FirstOrDefault()?.CanReorder == true;

    protected override void OnDragStart( DragEvent e )
    {
        if ( Item is null ) return;

        DragHandler.StartDragging( new DragData
        {
            Source = this,
            SpawnlistItemId = Item.Id,
            Title = Item.Kind == SpawnlistItemKind.Title ? Item.Title : Game.Language.GetPhrase( "spawnmenu.spawnlist.divider" )
        } );
    }

    protected override void OnDragEnd( DragEvent e )
    {
        DragHandler.StopDragging();
    }
}
@using Sandbox;
@using Sandbox.UI;
@inherits Panel
@namespace Sandbox

@{
    var data = GetData();
    var workshopId = Entry.GetMeta( "_workshopId", 0ul );
    var isPublished = workshopId > 0;
    var isInstalled = Entry.Files.IsReadOnly;
}

<SpawnMenuContent>
    <Header>
        <div class="spawnlist-header">
            <SpawnMenuToolbar>
                <Left>
                    <div class="spawnlist-identity">
                        <div class="folder-mark">
                            <div class="folder-tab"></div>
                            <IconPanel Text="collections_bookmark" />
                        </div>
                        <div class="identity-copy">
                            <h2>
                                <Label Tokenize=@false [email protected] />
                            </h2>
                            <div class="meta">
                                <span><IconPanel Text="widgets" /> @ItemCountText( data.Items.Count( item => item.IsContent ) )</span>
                                <span class="dot"></span>
                                <span><IconPanel Text="@( isInstalled ? "cloud_done" : isPublished ? "public" : "lock" )" /> @Game.Language.GetPhrase( isInstalled ? "spawnmenu.spawnlist.installed" : isPublished ? "spawnmenu.spawnlist.published" : "spawnmenu.spawnlist.local" )</span>
                            </div>
                        </div>
                    </div>
                </Left>
                <Right>
                    @if ( isPublished )
                    {
                        <Button Icon="link" Tooltip="#spawnmenu.spawnlist.copy_url" class="icon-button" @onclick=@( () => Clipboard.SetText( $"https://steamcommunity.com/sharedfiles/filedetails/?id={workshopId}" ) ) />
                        @if ( !Entry.Files.IsReadOnly )
                        {
                            <Button Icon="sync" Tooltip="#spawnmenu.spawnlist.sync" class="icon-button" @onclick=@OnPublish />
                        }
                    }
                    else if ( !Entry.Files.IsReadOnly )
                    {
                        <Button Icon="cloud_upload" Text="#spawnmenu.spawnlist.publish" Tooltip="#spawnmenu.spawnlist.publish" class="menu-button" @onclick=@OnPublish />
                    }
                    @if ( !Entry.Files.IsReadOnly )
                    {
                        @if ( _editMode && CanRename )
                        {
                            <Button Icon="drive_file_rename_outline" Text="#spawnmenu.spawnlist.rename" class="menu-button" @onclick=@OnRename />
                        }
                        <Button Icon="edit" Tooltip="#spawnmenu.spawnlist.edit" class="icon-button" Active=@_editMode @onclick=@ToggleEditMode />
                        <Button Icon="delete" Tooltip="#spawnmenu.spawnlist.delete" class="icon-button" @onclick=@OnDelete />
                    }
                    else
                    {
                        <Button Icon="delete" Tooltip="#spawnmenu.spawnlist.remove" class="icon-button" @onclick=@OnUninstall />
                    }
                </Right>
            </SpawnMenuToolbar>

            @if ( _editMode )
            {
                <div class="edit-toolbar">
                    <Button class="menu-action" Icon="label" Text="#spawnmenu.spawnlist.add_title" @onclick=@AddTitle />
                    <Button class="menu-action" Icon="horizontal_rule" Text="#spawnmenu.spawnlist.add_divider" @onclick=@AddDivider />
                    <div class="edit-instructions">
                        <IconPanel Text="drag_indicator" />
                        <label>@Game.Language.GetPhrase( "spawnmenu.spawnlist.edit_instructions" )</label>
                    </div>
                </div>
            }
        </div>
    </Header>

    <Body>
        @if ( data.Items.Count == 0 )
        {
            <SpawnMenuEmptyState Icon="format_list_bulleted" Title="#spawnmenu.spawnlist.empty_title" Subtitle="#spawnmenu.spawnlist.empty_instructions" />
        }
        else
        {
            <MixedVirtualGrid class="@( _editMode ? "editing" : "" )" [email protected] ItemSize=@(new Vector2( 160, 120 )) CanAcceptDrop=@CanAcceptDrop OnDropIndex=@OnDropIndex>
                <Item Context="item">
                    @if ( item is SpawnlistItem spawnItem )
                    {
                        @if ( spawnItem.IsContent )
                        {
                            <SpawnMenuIcon Ident="@spawnItem.Ident" Title="@spawnItem.Title" Icon="@spawnItem.Icon" [email protected] />
                        }
                        else
                        {
                            <SpawnlistLayoutItem Item=@spawnItem OnRename=@RenameItem OnRemove=@RemoveItem />
                        }
                    }
                </Item>
            </MixedVirtualGrid>
        }
    </Body>
</SpawnMenuContent>

@code
{
    public Storage.Entry Entry { get; set; }

    SpawnlistData _cachedData;
    RealTimeSince _lastRefresh;
    bool _editMode;
    public bool CanReorder => _editMode && Entry?.Files.IsReadOnly == false;
    static string ItemCountText( int count )
        => Game.Language.GetPhrase( count == 1 ? "ui.spawnlist.one_item" : "ui.spawnlist.items", new() { { "count", count } } );

    bool CanRename => Entry?.Files.IsReadOnly == false && Entry.GetMeta( "_workshopId", 0ul ) == 0;

    SpawnlistData GetData()
    {
        if ( _cachedData == null )
        {
            _cachedData = SpawnlistData.Load( Entry );
            _lastRefresh = 0;
        }

        return _cachedData;
    }

    public void RefreshCache()
    {
        _cachedData = SpawnlistData.Load( Entry );
        _lastRefresh = 0;
        StateHasChanged();
    }

    public override void Tick()
    {
        base.Tick();

        // Periodically check for changes from other tabs
        if ( _lastRefresh > 1f )
        {
            var fresh = SpawnlistData.Load( Entry );
            if ( fresh.Name != _cachedData?.Name || !ItemsMatch( fresh.Items, _cachedData?.Items, !Entry.Files.IsReadOnly ) )
            {
                _cachedData = fresh;
                StateHasChanged();
            }
            _lastRefresh = 0;
        }
    }

    protected override int BuildHash() => HashCode.Combine( _cachedData?.Items?.Count, _cachedData?.Name, _editMode, CanRename );

    void OnRename()
    {
        if ( !_editMode || !CanRename ) return;

        _ = new StringQueryPopup
        {
            Title = "#spawnmenu.spawnlist.rename_title",
            Prompt = "#spawnmenu.spawnlist.rename_prompt",
            Placeholder = "#spawnmenu.spawnlist.name_placeholder",
            ConfirmLabel = "#spawnmenu.spawnlist.rename_button",
            InitialValue = GetData().Name,
            OnConfirm = RenameSpawnlist,
            Parent = FindPopupPanel()
        };
    }

    void RenameSpawnlist( string name )
    {
        if ( !CanRename ) return;

        SpawnlistData.Rename( Entry, name );
        RefreshCache();
        Ancestors.OfType<SpawnlistsPage>().FirstOrDefault()?.RefreshList();
    }

    void ToggleEditMode()
    {
        if ( Entry?.Files.IsReadOnly != false ) return;
        _editMode = !_editMode;
    }

    void AddTitle()
    {
        SpawnlistData.AddMarker( Entry, SpawnlistItemKind.Title, Game.Language.GetPhrase( "spawnmenu.spawnlist.new_section" ) );
        RefreshCache();
    }

    void AddDivider()
    {
        SpawnlistData.AddMarker( Entry, SpawnlistItemKind.Divider );
        RefreshCache();
    }

    public bool CanAcceptDrop( PanelEvent e )
    {
        if ( !CanReorder ) return false;
        var drag = DragHandler.Current ?? e?.Target?.UserData as DragData;
        return drag is not null && drag.SpawnlistItemId != Guid.Empty;
    }

    void OnDropIndex( int index, PanelEvent e )
    {
        var drag = DragHandler.Current ?? e.Target?.UserData as DragData;
        if ( drag is null || drag.SpawnlistItemId == Guid.Empty ) return;

        drag.Handled = true;
        if ( drag.Source is not null ) drag.Source.UserData = null;
        SpawnlistData.MoveItem( Entry, drag.SpawnlistItemId, index );
        RefreshCache();
    }

    void RenameItem( Guid id, string title )
    {
        title = title?.Trim() ?? "";
        SpawnlistData.RenameItem( Entry, id, title );
        var item = _cachedData?.Items.FirstOrDefault( item => item.Id == id );
        if ( item is not null ) item.Title = title;
        StateHasChanged();
    }

    static bool ItemsMatch( IReadOnlyList<SpawnlistItem> left, IReadOnlyList<SpawnlistItem> right, bool compareIds )
    {
        if ( left is null || right is null ) return left == right;
        if ( left.Count != right.Count ) return false;

        for ( var i = 0; i < left.Count; i++ )
        {
            var a = left[i];
            var b = right[i];
            if ( (compareIds && a.Id != b.Id) || a.Kind != b.Kind || a.Ident != b.Ident || a.Title != b.Title || a.Icon != b.Icon )
                return false;
        }

        return true;
    }

    void RemoveItem( Guid id )
    {
        SpawnlistData.RemoveItem( Entry, id );
        RefreshCache();
    }

    void OnPublish()
    {
        if ( Entry is null ) return;
        SpawnlistData.Publish( Entry );
    }

    void OnDelete()
    {
        if ( Entry is null ) return;
        var page = Ancestors.OfType<SpawnlistsPage>().FirstOrDefault();
        page?.Collection.Delete( Entry );
    }

    void OnUninstall()
    {
        if ( Entry is null ) return;
        var workshopId = Entry.GetMeta( "_workshopId", 0ul );
        var page = Ancestors.OfType<SpawnlistsPage>().FirstOrDefault();
        page?.Collection.Uninstall( workshopId );
    }
}
@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 Sandbox;
@using Sandbox.UI;
@using NZombies;
@inherits PanelComponent

@*
    BOSS BAR — basalt's beast, top centre: his name, his health, a mark at each phase's line (75, 50, 25%), and the phase.

    ⚠️ ITS OWN PanelComponent, as the powerup banner is, and mounted the same way (`SurvivalHud.OnUpdate`): no scene edit, and
    it draws whatever the survival HUD's `Visible` says — the fight can be played in creative too. It reads the fight as this
    machine was told it (`HexPlatforms.BossBarShown`, `BossBarFraction`, `BossBarPhase`), so every player sees the same bar.
*@

<root class="@HudTheme.Class">
    @if ( HexPlatforms.BossBarShown )
    {
        <div class="boss">
            <div class="name">@HexPlatforms.BossBarName</div>
            <div class="bar">
                <div class="fill" style="width: @(Percent)%"></div>
                <div class="mark" style="left: 75%"></div>
                <div class="mark" style="left: 50%"></div>
                <div class="mark" style="left: 25%"></div>
            </div>
            <div class="phase">@HexPlatforms.BossBarPhase</div>
        </div>
    }
</root>

@code
{
    /// <summary>His health for the fill, in percent, to a tenth.</summary>
    float Percent => System.MathF.Round( System.Math.Clamp( HexPlatforms.BossBarFraction, 0f, 1f ) * 1000f ) / 10f;

    /// <summary>⚠️ REBUILDS ON THE FILL, TO A TENTH OF A PERCENT, AND ON THE PHASE — not every frame.</summary>
    protected override int BuildHash() => System.HashCode.Combine(
        HexPlatforms.BossBarShown, (int)(Percent * 10f), HexPlatforms.BossBarPhase, HudTheme.Class );
}
@using Sandbox;
@using Sandbox.UI;
@using System.Collections.Generic;
@using System.Linq;
@using NZombies;
@inherits PanelComponent

@*
    DAMAGE NUMBERS — the floating "-240" over whatever you just shot.

    ⛔ BuildHash IS CONSTANT, for the same reason PointsPopupHud's is: a
    PanelComponent destroys and recreates its entire tree whenever the hash
    changes, and these elements are repositioned every single frame. A hash that
    tracked them would rebuild the tree every frame — every number recreated
    from scratch, sixty times a second. Children are added and removed from code
    instead.

    ⚠️ POSITION IS SET FROM CODE, NOT CSS. Unlike the points popups these are
    anchored to a WORLD point that moves on screen as you turn, so there is no
    keyframe that could express the path. Style.Left/Top are written each frame;
    writing Style does not rebuild the tree.
*@

<root class="dmg-nums"></root>

@code
{
    /// <summary>⛔ CONSTANT ON PURPOSE — see the header.</summary>
    protected override int BuildHash() => 0;

    readonly Dictionary<DamageNumbers.Num, Panel> _live = new();

    /// <summary>Scratch for expired entries — cleared and refilled, never reallocated.</summary>
    readonly List<DamageNumbers.Num> _dead = new();

    /// <summary>
    /// Retired labels, waiting to be reused.
    ///
    /// ⚠️ CAPPED AT MaxVisible, so a burst that briefly needed forty cannot leave forty hidden
    /// elements parented forever. Anything over the cap is genuinely deleted.
    /// </summary>
    readonly List<Label> _pool = new();

    static int PoolCap => DamageNumbers.MaxVisible;

    protected override void OnUpdate()
    {
        // ⚠️ THE THEME'S CLASS FROM HERE, NOT FROM THE RAZOR: this tree is built once (`HudTheme.Wear`).
        HudTheme.Wear( Panel );

        // ⛔ THE UI WORK, WHICH cpu_dmg_numbers COULD NEVER SEE. That column wraps
        // DamageNumbers.Report -- 1.8us to append a hit to a list. Everything that actually COSTS
        // anything happens here, a frame later: a Label constructed per new number, AddChild
        // triggering layout, an O(n^2) Contains scan over Active, and a .ToList() allocation every
        // frame. Measuring Report and concluding "numbers are cheap" measured the wrong half.
        using var _cpu = NZombies.CpuScope.Measure( "ui.dmgnum" );

        // ⚠️ FIRST, and unconditionally. This is what makes "same frame = same
        // shot" true; skipping it when the list is empty would let two shots
        // separated by a quiet moment share a frame number and merge.
        DamageNumbers.Tick();

        DamageNumbers.Prune();
        using ( NZombies.CpuScope.Measure( "ui.dmgnum.sync" ) )
            Sync();
        using ( NZombies.CpuScope.Measure( "ui.dmgnum.place" ) )
            Place();
    }

    /// <summary>Add elements for new numbers, delete them for expired ones.</summary>
    void Sync()
    {
        if ( Panel is null ) return;

        foreach ( var n in DamageNumbers.Active )
        {
            if ( _live.ContainsKey( n ) ) continue;

            // ⛔ TAKEN FROM A POOL, NOT CONSTRUCTED. A damage number cannot merge the way a
            // points popup can -- each one is anchored over its own zombie, which is the entire
            // point of it -- so the only way to stop the churn is to stop creating and destroying
            // them. Eleven hits a frame used to mean eleven Labels built and eleven deleted, each
            // AddChild triggering a layout pass, every frame. Now it is eleven show/hide.
            //
            // ⚠️ Constructed directly rather than via Panel.Add.Label — those helpers live on
            // Sandbox.UI.Construct and are not in scope here. (Same trap PointsPopupHud documents.)
            Label el;

            if ( _pool.Count > 0 )
            {
                el = _pool[^1];
                _pool.RemoveAt( _pool.Count - 1 );
                el.Text = n.Text;
            }
            else
            {
                el = new Label { Text = n.Text };
                el.AddClass( "num" );
                Panel.AddChild( el );
            }

            // ⛔ SetClass, NOT AddClass. A pooled element keeps whatever it wore last time, so
            // a recycled headshot label would stay gold on an ordinary hit -- and the reverse. This
            // has to assert the state both ways, not just add it.
            el.SetClass( "hs", n.Headshot );
            el.Style.Display = DisplayMode.Flex;

            _live[n] = el;
        }

        if ( _live.Count == 0 ) return;

        // ⚠️ Materialise before mutating — removing while enumerating throws.
        // ⛔ A REUSABLE LIST, NOT .ToList() EVERY FRAME. This ran on every frame of the game
        // whether anything had died or not, allocating a List plus the LINQ closure behind it --
        // and it is the UI phase, so none of it showed up in any damage-path scope.
        _dead.Clear();

        foreach ( var kv in _live )
            if ( !DamageNumbers.Active.Contains( kv.Key ) ) _dead.Add( kv.Key );

        foreach ( var dead in _dead )
        {
            // ⚠️ RETURNED, NOT DELETED. Hidden rather than destroyed so the next hit can have it
            // back without a construction and a layout pass.
            if ( _live[dead] is Label lbl )
            {
                lbl.Style.Display = DisplayMode.None;
                if ( _pool.Count < PoolCap ) _pool.Add( lbl );
                else lbl.Delete();
            }

            _live.Remove( dead );
        }
    }

    /// <summary>Project every live number to the screen and move its element.</summary>
    void Place()
    {
        var camera = Scene?.Camera;
        if ( Panel is null || !camera.IsValid() ) return;

        var eye = camera.WorldPosition;

        foreach ( var (n, el) in _live )
        {
            var life = ((float)n.Age / DamageNumbers.Life).Clamp( 0f, 1f );

            // Rise, plus the addon's distance lift so a far number clears the
            // body instead of sitting inside it.
            // ⚠️ The 6-arg Remap with clamp:true — the same form SpawnDirt uses.
            var lift = MathX.Remap( n.World.Distance( eye ),
                0f, DamageNumbers.DistanceLiftRange,
                0f, DamageNumbers.DistanceLift, true );

            var world = n.World + Vector3.Up * (lift + life * DamageNumbers.Rise);

            var screen = camera.PointToScreenPixels( world, out var behind );
            if ( behind )
            {
                // ⚠️ Hidden, not deleted. It is still inside its lifetime and
                // could come back into view if the player turns; deleting would
                // make a number vanish for good the moment it clipped an edge.
                el.Style.Opacity = 0f;
                continue;
            }

            // ⚠️ ScaleFromScreen converts real pixels to the panel's own units.
            // Without it the numbers land in the right place only at 1080p and
            // drift further off the target the further the resolution differs.
            // ⚠️ Length.Pixels, not a bare float — the form SniperScope uses.
            var s = Panel.ScaleFromScreen;
            el.Style.Left = Length.Pixels( screen.x * s );
            el.Style.Top = Length.Pixels( screen.y * s );

            // Solid for the first half of its life, then linear to nothing —
            // the addon's curve, so a number is readable before it starts going.
            el.Style.Opacity = life > 0.5f ? 2f - 2f * life : 1f;
        }
    }
}