ChatCraft / frontend /src /lib /components /TutorialOverlay.svelte
gabraken's picture
The expand
8c8d636
<script lang="ts">
import { goto } from "$app/navigation";
import { getSocket } from "$lib/socket";
import {
gameState,
isTutorial,
myPlayerId,
playerName,
} from "$lib/stores/game";
import { onDestroy } from "svelte";
import { get } from "svelte/store";
const socket = getSocket();
const TUTORIAL_DUMMY_ID = "tutorial_dummy";
// โ”€โ”€ Objective definitions โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
interface Objective {
id: string;
label: string;
hint: string;
done: boolean;
}
const HINT_DELAY_MS = 25_000; // show hint after 25s of no progress
let objectives: Objective[] = [
{
id: "gather",
label: "Start gathering resources",
hint: 'Type "gather minerals" or "gather" to send your SCVs to mine the nearest mineral patch.',
done: false,
},
{
id: "scvs",
label: "Train 3 SCVs",
hint: 'You start with 5 SCVs. Type "train 3 scv" in the command box below to train more from your Command Center.',
done: false,
},
{
id: "supply_depot",
label: "Build a Supply Depot",
hint: 'Type "build supply depot" โ€” an SCV will construct it near your base. You need it before training Marines!',
done: false,
},
{
id: "barracks",
label: "Build a Barracks",
hint: 'Type "build barracks". Marines require a Barracks to be trained.',
done: false,
},
{
id: "marines",
label: "Train 2 Marines",
hint: 'Once your Barracks is ready, type "train 2 marines" to deploy your infantry.',
done: false,
},
{
id: "move",
label: "Move units to the Ashen Crater",
hint: 'Type "move all units to the Ashen Crater".',
done: false,
},
];
// Tutorial always starts with 5 SCVs โ€” objective is to train 3 more (total โ‰ฅ 8)
const STARTING_SCV_COUNT = 5;
// โ”€โ”€ One derived boolean per objective (no circular dependency) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
$: _player = $myPlayerId ? ($gameState?.players[$myPlayerId] ?? null) : null;
$: _units = _player ? Object.values(_player.units) : [];
$: _buildings = _player ? Object.values(_player.buildings) : [];
$: doneGather = _units.some(
(u) =>
u.unit_type === "scv" &&
(u.status === "mining_minerals" || u.status === "mining_gas"),
);
$: doneSCVs =
_units.filter((u) => u.unit_type === "scv").length >=
STARTING_SCV_COUNT + 3;
$: doneSupplyDepot = _buildings.some(
(b) => b.building_type === "supply_depot" && b.status !== "destroyed",
);
$: doneBarracks = _buildings.some(
(b) => b.building_type === "barracks" && b.status !== "destroyed",
);
$: doneMArines = _units.filter((u) => u.unit_type === "marine").length >= 2;
$: doneMove = (() => {
const tx = $gameState?.tutorial_target_x;
const ty = $gameState?.tutorial_target_y;
if (tx == null || ty == null) return false;
return _units.some((u) => Math.hypot(u.x - tx, u.y - ty) < 12);
})();
// Once an objective is done it stays done โ€” locked permanently
const OBJECTIVE_IDS = [
"gather",
"scvs",
"supply_depot",
"barracks",
"marines",
"move",
] as const;
// lockedCount is a plain reactive number so $: allDone re-evaluates reliably
let lockedCount = 0;
let doneMap: Record<string, boolean> = {};
$: {
const fresh: Record<string, boolean> = {
gather: doneGather,
scvs: doneSCVs,
supply_depot: doneSupplyDepot,
barracks: doneBarracks,
marines: doneMArines,
move: doneMove,
};
let changed = false;
for (const id of OBJECTIVE_IDS) {
if (fresh[id] && !doneMap[id]) {
doneMap[id] = true;
changed = true;
}
}
if (changed) {
doneMap = { ...doneMap };
lockedCount = OBJECTIVE_IDS.filter((id) => doneMap[id]).length;
}
}
// Hint state
let activeHintId: string | null = null;
let hintTimers: Map<string, ReturnType<typeof setTimeout>> = new Map();
let shownHints: Set<string> = new Set();
// Tutorial completion โ€” driven by lockedCount so Svelte tracks it correctly
$: allDone = lockedCount === OBJECTIVE_IDS.length;
let completionReported = false;
$: if (allDone && !completionReported) {
completionReported = true;
socket.emit("tutorial_complete", {});
const pName = get(playerName);
if (pName) {
localStorage.setItem("sc_player_name", pName);
localStorage.setItem("sc_name_locked", "true");
}
}
// โ”€โ”€ Side-effects: schedule/clear hints when done states change โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
$: {
for (const obj of objectives) {
const nowDone = doneMap[obj.id] ?? false;
if (nowDone) {
clearHintTimer(obj.id);
if (activeHintId === obj.id) activeHintId = null;
} else {
scheduleHintIfNeeded(obj.id);
}
}
}
function scheduleHintIfNeeded(id: string) {
if (shownHints.has(id)) return;
if (hintTimers.has(id)) return;
// Only show hint if all previous objectives are completed
const idx = (OBJECTIVE_IDS as readonly string[]).indexOf(id);
if (idx > 0 && (OBJECTIVE_IDS as readonly string[]).slice(0, idx).some((prevId) => !doneMap[prevId])) return;
const t = setTimeout(() => {
hintTimers.delete(id);
if (!doneMap[id] && !shownHints.has(id)) {
activeHintId = id;
}
}, HINT_DELAY_MS);
hintTimers.set(id, t);
}
function clearHintTimer(id: string) {
const t = hintTimers.get(id);
if (t !== undefined) {
clearTimeout(t);
hintTimers.delete(id);
}
}
function dismissHint() {
const dismissedId = activeHintId;
activeHintId = null;
if (dismissedId !== null) {
shownHints.add(dismissedId);
}
}
function backToLobby() {
isTutorial.set(false);
gameState.set(null);
goto("/");
}
onDestroy(() => {
hintTimers.forEach((t) => clearTimeout(t));
hintTimers.clear();
});
let panelCollapsed = false;
$: currentObjectiveIndex = objectives.findIndex((o) => !doneMap[o.id]);
$: activeHint = activeHintId
? objectives.find((o) => o.id === activeHintId)
: null;
</script>
<!-- Tutorial objectives panel -->
<div class="tutorial-panel" class:collapsed={panelCollapsed}>
<button
class="collapse-btn"
on:click={() => (panelCollapsed = !panelCollapsed)}
aria-label={panelCollapsed ? "Expand tutorial" : "Collapse tutorial"}
title={panelCollapsed ? "Show tutorial" : "Hide tutorial"}
>
{panelCollapsed ? "๐Ÿ“‹" : "ร—"}
</button>
{#if !panelCollapsed}
<div class="panel-header">
<span class="panel-title">Tutorial</span>
<span class="panel-progress"
>{objectives.filter((o) => doneMap[o.id])
.length}/{objectives.length}</span
>
</div>
<ol class="objectives-list">
{#each objectives as obj, i}
{@const done = doneMap[obj.id] ?? false}
<li
class="objective"
class:done
class:active={i === currentObjectiveIndex && !done}
>
<span class="obj-check"
>{done ? "โœ“" : i === currentObjectiveIndex ? "โ–ถ" : "โ—‹"}</span
>
<span class="obj-label">{obj.label}</span>
</li>
{/each}
</ol>
<button class="quit-btn" on:click={backToLobby}>Quit tutorial</button>
{/if}
</div>
<!-- Hint tooltip -->
{#if activeHint && !doneMap[activeHint.id]}
<div class="hint-bubble" role="status" aria-live="polite">
<div class="hint-header">
<span class="hint-icon">๐Ÿ’ก</span>
<span class="hint-title">Hint โ€” {activeHint.label}</span>
</div>
<p class="hint-text">{activeHint.hint}</p>
<button class="hint-dismiss" on:click={dismissHint}>Got it</button>
</div>
{/if}
<!-- Completion modal -->
{#if allDone}
<div class="modal">
<div class="modal-icon">๐Ÿ†</div>
<h2 class="modal-title">Tutorial Complete!</h2>
<p class="modal-body">
Well done, Commander! You've mastered the basics.<br />
Ready to face a real opponent?
</p>
<button class="btn-primary" on:click={backToLobby}>
Play a real match
</button>
</div>
{/if}
<style>
/* โ”€โ”€ Panel โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ */
.tutorial-panel {
position: absolute;
top: 12px;
right: 12px;
z-index: 50;
background: rgba(15, 20, 30, 0.92);
border: 1px solid rgba(88, 166, 255, 0.35);
border-radius: 12px;
padding: 14px 16px 12px;
min-width: 220px;
max-width: 260px;
backdrop-filter: blur(8px);
box-shadow: 0 4px 24px rgba(0, 0, 0, 0.5);
transition:
min-width 0.2s,
padding 0.2s;
}
.tutorial-panel.collapsed {
min-width: 0;
padding: 8px;
border-radius: 50%;
width: 44px;
height: 44px;
display: flex;
align-items: center;
justify-content: center;
}
.collapse-btn {
position: absolute;
top: 8px;
right: 8px;
background: none;
border: none;
color: rgba(255, 255, 255, 0.5);
font-size: 0.85rem;
cursor: pointer;
line-height: 1;
padding: 2px 4px;
border-radius: 4px;
transition: color 0.15s;
}
.collapsed .collapse-btn {
position: static;
font-size: 1.1rem;
color: rgba(88, 166, 255, 0.9);
}
.collapse-btn:hover {
color: #fff;
}
.panel-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 10px;
padding-right: 20px;
}
.panel-title {
font-size: 0.8rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.08em;
color: #58a6ff;
}
.panel-progress {
font-size: 0.75rem;
color: rgba(255, 255, 255, 0.45);
font-weight: 600;
}
/* โ”€โ”€ Objectives list โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ */
.objectives-list {
list-style: none;
padding: 0;
margin: 0 0 12px;
display: flex;
flex-direction: column;
gap: 6px;
}
.objective {
display: flex;
align-items: center;
gap: 7px;
font-size: 0.8rem;
color: rgba(255, 255, 255, 0.45);
transition: color 0.2s;
}
.objective.active {
color: rgba(255, 255, 255, 0.92);
}
.objective.done {
color: rgba(80, 220, 130, 0.8);
text-decoration: line-through;
text-decoration-color: rgba(80, 220, 130, 0.4);
}
.obj-check {
font-size: 0.75rem;
width: 14px;
text-align: center;
flex-shrink: 0;
}
.objective.active .obj-check {
color: #58a6ff;
}
.objective.done .obj-check {
color: #50dc82;
}
.obj-label {
line-height: 1.3;
}
/* โ”€โ”€ Quit button โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ */
.quit-btn {
width: 100%;
padding: 6px;
background: rgba(255, 80, 80, 0.12);
border: 1px solid rgba(255, 80, 80, 0.25);
border-radius: 7px;
color: rgba(255, 120, 120, 0.85);
font-size: 0.75rem;
cursor: pointer;
transition:
background 0.15s,
color 0.15s;
}
.quit-btn:hover {
background: rgba(255, 80, 80, 0.22);
color: #ff8888;
}
/* โ”€โ”€ Hint bubble โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ */
.hint-bubble {
position: absolute;
bottom: 90px;
left: 50%;
transform: translateX(-50%);
z-index: 55;
background: rgba(15, 20, 30, 0.96);
border: 1px solid rgba(255, 200, 60, 0.5);
border-radius: 14px;
padding: 14px 18px;
max-width: 340px;
width: calc(100% - 48px);
box-shadow:
0 6px 32px rgba(0, 0, 0, 0.6),
0 0 0 1px rgba(255, 200, 60, 0.1);
animation: hint-pop 0.3s cubic-bezier(0.34, 1.56, 0.64, 1);
}
@keyframes hint-pop {
from {
transform: translateX(-50%) scale(0.88);
opacity: 0;
}
to {
transform: translateX(-50%) scale(1);
opacity: 1;
}
}
.hint-header {
display: flex;
align-items: center;
gap: 7px;
margin-bottom: 7px;
}
.hint-icon {
font-size: 1rem;
}
.hint-title {
font-size: 0.78rem;
font-weight: 700;
color: #ffc83c;
text-transform: uppercase;
letter-spacing: 0.06em;
}
.hint-text {
font-size: 0.85rem;
color: rgba(255, 255, 255, 0.82);
line-height: 1.5;
margin: 0 0 12px;
}
.hint-dismiss {
display: block;
margin-left: auto;
padding: 6px 18px;
background: rgba(255, 200, 60, 0.15);
border: 1px solid rgba(255, 200, 60, 0.35);
border-radius: 8px;
color: #ffc83c;
font-size: 0.8rem;
font-weight: 600;
cursor: pointer;
transition: background 0.15s;
}
.hint-dismiss:hover {
background: rgba(255, 200, 60, 0.28);
}
/* โ”€โ”€ Completion modal โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ */
.modal {
position: fixed;
bottom: 24px;
right: 24px;
z-index: 200;
background: var(--surface, #1a2030);
border: 1px solid var(--border, rgba(255, 255, 255, 0.1));
border-radius: 20px;
padding: 40px 32px;
text-align: center;
max-width: 340px;
width: 90%;
animation: pop-in 0.28s cubic-bezier(0.34, 1.56, 0.64, 1);
}
@keyframes pop-in {
from {
transform: scale(0.8);
opacity: 0;
}
to {
transform: scale(1);
opacity: 1;
}
}
.modal-icon {
font-size: 3.2rem;
margin-bottom: 14px;
}
.modal-title {
font-size: 1.7rem;
font-weight: 800;
margin-bottom: 12px;
background: linear-gradient(135deg, #ffd700, #58a6ff);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
.modal-body {
color: rgba(255, 255, 255, 0.65);
font-size: 0.92rem;
line-height: 1.6;
margin-bottom: 28px;
}
.btn-primary {
background: linear-gradient(135deg, #58a6ff, #a78bfa);
color: #fff;
padding: 14px 30px;
border-radius: 12px;
font-size: 1rem;
font-weight: 700;
width: 100%;
transition:
filter 0.15s,
transform 0.1s;
cursor: pointer;
border: none;
}
.btn-primary:hover {
filter: brightness(1.12);
}
.btn-primary:active {
transform: scale(0.97);
}
</style>