A Razor UI component that displays an in-game tutorial overlay. It shows step-by-step instructions, optional highlight pulses around UI elements, debug controls for positioning/sizing during development, and advances/completes based on game events (triggers) or a Next/Skip button.
@using System;
@using Sandbox;
@using Sandbox.UI;
@inherits PanelComponent
@namespace Sandbox
<root>
@if (_active)
{
@* ── Debug card — remove before release ── *@
<div class="tut-root">
@if(DEV_MODE)
{
<div class="tut-debug-card">
<div class="tut-debug-title">DEBUG — 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>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>
<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>
@* Log button — prints current values to console *@
<button class="tut-debug-log" onclick=@LogCurrentValues>📋 Log values</button>
</div>
}
@* ── Semi-transparent dim layer ── *@
<div class="tut-dim"></div>
@* ── Highlight pulse around target area ── *@
@* Only renders if the step has a highlight (HW > 0) *@
@if (_step < _steps.Length && _steps[_step].HW > 0)
{
@* s = shorthand for current step data *@
@* Apply debug overrides if non-zero (WASD to move, 1/2/3/4 to resize) *@
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)
{
@* s = shorthand for current step — avoids writing _steps[_step].X every time *@
var s = _steps[_step];
@* Apply debug overrides if non-zero (arrow keys to move, R to log) *@
float bl = _debugBL != 0f ? _debugBL : s.BL;
float br = _debugBR != 0f ? _debugBR : s.BR;
float by = _debugBY != 0f ? _debugBY : s.BY;
float bw = s.BW;
@* Use BR (right anchor) if set, otherwise BL (left anchor) *@
@* Never set both BL and BR on the same step *@
string posStyle = br != 0f
? $"right:{br.ToString("F1")}%;" @* anchored from right edge *@
: $"left:{bl.ToString("F1")}%;"; @* anchored from left edge *@
string boxStyle = $"{posStyle}top:{by.ToString("F0")}px;{(bw > 0f ? $"width:{bw.ToString("F0")}px;" : "")}";
<div class="tut-box" style="@boxStyle">
@* Step counter e.g. "3 / 12" *@
<div class="tut-step-count">@(_step + 1) / @_steps.Length</div>
@* Title *@
<div class="tut-title">@s.Title</div>
@* Body text *@
<div class="tut-text">@s.Text</div>
@* Action hint — shown when player must perform an action to advance *@
@if (s.Trigger != TutorialTrigger.Any && !string.IsNullOrEmpty(s.ActionHint))
{
<div class="tut-action-hint">👆 @s.ActionHint</div>
}
@* Next button — only shown when step advances via button (Trigger == Any) *@
@if (s.Trigger == TutorialTrigger.Any)
{
<button class="tut-btn-next" onclick=@NextStep>Got it →</button>
}
@* Skip — always available, marks tutorial as complete *@
<button class="tut-btn-skip" onclick=@Skip>Skip tutorial</button>
</div>
}
</div>
}
</root>
@code {
public static TutorialOverlay Instance { get; private set; }
bool DEV_MODE = false;
public bool IsActive => _active;
bool _active = false;
int _step = 0;
public int Step => _step;
public TutStep[] Steps => _steps;
bool _subbed = false;
// ── Live debug nudge fields ───────────────────────────────────────── //
// Arrow keys = move box, WASD = move highlight, 1/2/3/4 = resize highlight
// Press R to log current values — copy into step data, then restart
// All reset to 0 when advancing to next step
float _debugBL = 0f; // box left anchor override %
float _debugBR = 0f; // box right anchor override %
float _debugBY = 0f; // box top override px
float _debugHX = 0f; // highlight left override %
float _debugHY = 0f; // highlight top override px
float _debugHW = 0f; // highlight width override px
float _debugHH = 0f; // highlight height override px
// ── Trigger types ─────────────────────────────────────────────────── //
public enum TutorialTrigger
{
Any, // advances via "Got it" button
SniperRotated, // advances when player rotates sniper bomb (right click)
BombPlaced, // advances when all hand bombs are placed on grid
Detonated, // advances when player presses detonate
ChainFired, // advances when a chain reaction fires (bomb depth > 0)
WaveEnd, // advances when detonation sequence finishes
HeartUsed, // fires when player uses Heart consumable
}
void LogCurrentValues()
{
Log.Info($"// 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");
}
// ── Step data struct ──────────────────────────────────────────────── //
public struct TutStep
{
public string Title;
public string Text;
public string ActionHint; // shown when player must act to advance
public TutorialTrigger Trigger; // what event advances this step
// ── Highlight box — draws pulsing border around a UI element ──
// All zero = no highlight shown
public float HX; // left edge of highlight in % of screen width
public float HY; // top edge of highlight in px from top
public float HW; // highlight width in px
public float HH; // highlight height in px
// ── Instruction box position ──────────────────────────────────
// Use BL OR BR — not both on the same step
public float BL; // distance from LEFT edge in % → increase to move RIGHT
public float BR; // distance from RIGHT edge in % → increase to move LEFT
public float BY; // distance from TOP in px → increase to move DOWN
public float BW; // box width in px — 0 = use CSS default (380px)
/* Quick reference for common positions:
BL = 5 → near left edge
BL = 35 → center-left
BL = 48 → roughly center screen
BL = 62 → right of grid
BR = 2 → near right edge / sidebar
BY = 170 → just below timer bar
BY = 210 → just below challenge bar
BY = 380 → mid screen (grid area)
BY = 580 → near bottom (above bomb wheel)
BW = 0 → default CSS width (~380px)
BW = 500 → wider box
*/
}
// ── Steps definition ─────────────────────────────────────────────── //
// Tune with arrow keys + WASD + R in-game, then paste logged values here
static readonly TutStep[] _steps = new TutStep[]
{
// Step 1 — Welcome
new TutStep {
Title = "Welcome to 10XPLODE!",
Text = "Chests appear on the grid each wave. Your goal: destroy as many as possible by placing bombs and detonating them.",
Trigger = TutorialTrigger.Any,
BL = 38f, BY = 400f
},
// Step 2 — Bomb wheel
new TutStep {
Title = "Your Bombs",
Text = "The bomb slots at the bottom are your hand. Select a bomb by clicking it or pressing 1 / 2 / 3.",
Trigger = TutorialTrigger.Any,
HX = 42f, HY = 960f, HW = 240f, HH = 110f,
BL = 38f, BY = 700f
},
// Step 3 — Sniper rotation
new TutStep {
Title = "Rotate Your Sniper! 🏹",
Text = "The Sniper bomb fires in one direction. Right-click on an empty cell to rotate it before placing.\n\nTry rotating it now!",
ActionHint = "Right-click on the grid to rotate",
Trigger = TutorialTrigger.SniperRotated,
HX = 35f, HY = 260f, HW = 570f, HH = 560f,
BL = 14f, BY = 380f
},
// Step 4 — Place bombs
new TutStep {
Title = "Place Your Bombs",
Text = "Click empty cells on the grid to place your bombs. Colored cells show the blast range.\n\nPlace all your bombs to continue!",
ActionHint = "Place all your bombs on the grid",
Trigger = TutorialTrigger.BombPlaced,
HX = 35f, HY = 260f, HW = 570f, HH = 560f,
BL = 14f, BY = 380f
},
// Step 5 — Blast preview
new TutStep {
Title = "Blast Preview",
Text = "🟦🟩🟧 cells = your bombs blast range (empty)\n\n🟥 cells = chests that will be destroyed\n\n🟨 Yellow cells = chests that survive (need more hits)",
Trigger = TutorialTrigger.Any,
HX = 35f, HY = 260f, HW = 570f, HH = 560f,
BL = 14f, BY = 380f
},
// Step 6 — Chest strength (gem guaranteed on wave 1)
new TutStep {
Title = "Chest Strength 💎",
Text = "Some chests require multiple hits — the 💎 Gem chest needs 2 hits but rewards more points!\n\nThe number shows hits remaining.",
Trigger = TutorialTrigger.Any,
HX = 35f, HY = 260f, HW = 570f, HH = 560f,
BL = 14f, BY = 380f
},
// Step 7 — Detonate
new TutStep {
Title = "Detonate!",
Text = "Press SPACE or click the 💥 button to trigger your bombs. Watch the chain reaction!",
ActionHint = "Press SPACE to detonate",
Trigger = TutorialTrigger.Detonated,
HX = 52f, HY = 970f, HW = 110f, HH = 100f,
BL = 45f, BY = 730f
},
// Step 8 — Chain reaction & hype bar
new TutStep {
Title = "Chain Reaction & Hype Bar! 🔥",
Text = "Explosions can trigger other bombs — creating a chain reaction! More chests cleared = bigger multiplier.",
Trigger = TutorialTrigger.Any,
HX = 13f, HY = 160f, HW = 30f, HH = 870f,
BL = 15f, BY = 430f
},
// Step 9 — Bomb chests: place
new TutStep {
Title = "Bomb Chests 💣",
Text = "See the 💣 chests on the grid? They explode when hit — triggering nearby chests too!\n\nFilling the hype bar doubles your score this wave!\n\nNotice your blast range increases because of the bombs range in the chain themselves.\n\nPlace your bomb next to one to start a chain.",
ActionHint = "Place your bomb near a 💣 chest",
Trigger = TutorialTrigger.BombPlaced,
HX = 35f, HY = 260f, HW = 570f, HH = 560f,
BL = 14f, BY = 380f
},
// Step 10 — Bomb chests: detonate
new TutStep {
Title = "Now detonate! 💥",
Text = "Your bomb is placed. Watch what happens when it hits the 💣 chest — the explosion will keep going!",
ActionHint = "Press SPACE to detonate",
Trigger = TutorialTrigger.Detonated,
HX = 52f, HY = 970f, HW = 110f, HH = 100f,
BL = 45f, BY = 730f
},
// Step 11 — Chain reaction congrats
new TutStep {
Title = "BOOM! 🔥💥",
Text = "That's a chain reaction! The more 💣 chests you chain together, the bigger your score multiplier gets.\n\nThis is how you reach the top of the leaderboard.",
Trigger = TutorialTrigger.Any,
HX = 35f, HY = 260f, HW = 570f, HH = 560f,
BL = 14f, BY = 380f
},
// Step 12 — Danger column
new TutStep {
Title = "Don't Let Them Reach the Bottom!",
Text = "Each wave, surviving chests slide down one row. If a chest reaches the last row, the column glows yellow — danger!\n\nIf any chest is in the bottom row at wave end, it's game over.",
Trigger = TutorialTrigger.Any,
HX = 36f, HY = 700f, HW = 540f, HH = 110f,
BL = 15f, BY = 590f
},
// Step 13 — Timer
new TutStep {
Title = "Beat the Clock ⏱",
Text = "The bar at the top is your timer. It shrinks each wave. Place and detonate before time runs out — or your bombs fire automatically!",
Trigger = TutorialTrigger.Any,
HX = 0f, HY = 90f, HW = 2150f, HH = 20f,
BL = 41f, BY = 110f
},
// Step 14 — Wave challenge
new TutStep {
Title = "Wave Challenge ⚡",
Text = "Each wave has a challenge shown above the grid. Complete it for bonus fragments!\n\nFailing costs nothing — it's just extra reward.",
Trigger = TutorialTrigger.Any,
HX = 38f, HY = 110f, HW = 490f, HH = 30f,
BL = 41f, BY = 150f
},
// Step 15 — Use Heart consumable
new TutStep {
Title = "You have a Heart ❤️",
Text = "See the consumable bar below? You bought a Heart before playing.\n\nUse it now — it gives you an extra life!",
ActionHint = "Click the ❤️ in the consumable bar",
Trigger = TutorialTrigger.HeartUsed,
HX = 35f, HY = 900f, HW = 560f, HH = 60f,
BL = 41f, BY = 640f
},
// Step 16 — Heart confirmation
new TutStep {
Title = "Extra life! ❤️",
Text = "Your hearts just increased. If you don't clear the minimum chest requirement each wave, you lose a life — that's your safety net.",
Trigger = TutorialTrigger.Any,
HX = 8f, HY = 840f, HW = 80f, HH = 180f,
BL = 13f, BY = 780f
},
// Step 17 — Min chest requirement
new TutStep {
Title = "Minimum Chest Requirement ⚠️",
Text = "Each wave you must destroy at least the minimum number of chests shown in the left. Miss it and you lose a life. Lose all lives — game over.",
Trigger = TutorialTrigger.Any,
HX = 1f, HY = 160f, HW = 210f, HH = 60f,
BL = 12f, BY = 150f
},
// Step 18 — Done
new TutStep {
Title = "You're Ready! 💥",
Text = "Clear chests, build chains, beat your best wave. Good luck!",
Trigger = TutorialTrigger.Any,
BL = 41f, BY = 380f
},
};
// ── Lifecycle ─────────────────────────────────────────────────────── //
protected override void OnStart()
{
Instance = this;
_active = false; // starts inactive — only activates on wave 1 first run
var g = ChainReactionGame.Instance;
if (g != null)
g.OnStateChanged += OnGameStateChanged;
}
protected override void OnUpdate()
{
// ── Live position debug ───────────────────────────────────────── //
// Arrow keys = move box | WASD = move highlight | 1234 = resize highlight
// Press R to log current values, then copy into step data and restart
if (_active)
{
float speed = 60f * Time.Delta;
// Press R to log all current values — copy into step struct then restart
if (Input.Pressed("reload"))
{
Log.Info($"[Tutorial] Step {_step} BOX → BL={_debugBL:F1} BY={_debugBY:F0}");
Log.Info($"[Tutorial] Step {_step} HIGHLIGHT → HX={_debugHX:F1} HY={_debugHY:F0} HW={_debugHW:F0} HH={_debugHH:F0}");
}
}
// ── Safety net — subscribe if ChainReactionGame wasn't ready in OnStart ── //
if (_subbed) return;
var g = ChainReactionGame.Instance;
if (g == null) return;
g.OnStateChanged += OnGameStateChanged;
_subbed = true;
}
// ── Activates tutorial on wave 1 first run only ───────────────────── //
void OnGameStateChanged()
{
var g = ChainReactionGame.Instance;
var save = ChainReactionGame.Save;
if (!_active
&& g?.IsRunning == true
&& g?.Wave == 1
&& g?.Score == 0
&& save != null
&& !save.TutorialCompleted)
{
_active = true;
_step = 0;
ChainReactionGame.Instance?.PauseTimer();
StateHasChanged();
}
//Log.Info($"TutorialOverlay: active={_active}, completed={save?.TutorialCompleted}");
}
// ── Called from other components when relevant events fire ────────── //
public void TryAdvance(TutorialTrigger trigger)
{
if (!_active || _step >= _steps.Length) return;
if (_steps[_step].Trigger != trigger) return;
// BombPlaced — only advance when ALL hand slots are placed
if (trigger == TutorialTrigger.BombPlaced)
{
var hand = ChainReactionGame.Instance?.Components.Get<BombHandSystem>();
if (hand == null) return;
if (hand.PlacedSlots.Count < hand.HandSize) return;
}
NextStep();
}
// ── Internal step advancement ─────────────────────────────────────── //
void NextStep()
{
_step++;
// Reset all debug nudges when moving to a new step
// so each step starts fresh from its own BL/BY/HX etc. values
_debugBL = 0f;
_debugBR = 0f;
_debugBY = 0f;
_debugHX = 0f;
_debugHY = 0f;
_debugHW = 0f;
_debugHH = 0f;
if (_step >= _steps.Length)
Complete();
else
{
StateHasChanged();
}
Sound.Play("UI_Button");
}
void Skip() => Complete();
void Complete()
{
_active = false;
// Resume game timer that was paused when tutorial started
ChainReactionGame.Instance?.ResumeTimer();
// Persist completion so tutorial never shows again
var save = ChainReactionGame.Save;
if (save != null)
{
save.TutorialCompleted = true;
save.Save();
}
StateHasChanged();
}
protected override int BuildHash()
{
// Remove _multLabel from the hash so re-renders don't kill the animation
int a = System.HashCode.Combine(_active, _step, _debugBL, _debugBR, _debugBY, _debugHX);
int b = System.HashCode.Combine(
_debugHY, _debugHW, _debugHH);
return a ^ b;
}
}