Spaces:
Running
Running
| <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> | |