Skip to content

Commit 14027b0

Browse files
committed
Add interactive tutorial system for new players
- Tutorial shows contextual tips during first game (persisted in localStorage) - Tips for astrogation (vector movement, burn selection, gravity, fuel) - Tips for ordnance and combat phases on first encounter - Progress dots show tutorial advancement - 'Got it' to advance, 'Skip tutorial' to dismiss permanently - Tips auto-hide during movement animation and non-player turns - Styled with blue accent border and subtle fade-in animation
1 parent 6251ffa commit 14027b0

File tree

4 files changed

+259
-0
lines changed

4 files changed

+259
-0
lines changed

src/client/main.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import { aiAstrogation, aiOrdnance, aiCombat, type AIDifficulty } from '../share
88
import { Renderer, HEX_SIZE } from './renderer';
99
import { InputHandler } from './input';
1010
import { UIManager } from './ui';
11+
import { Tutorial } from './tutorial';
1112
import { initAudio, playSelect, playConfirm, playThrust, playCombat, playExplosion, playPhaseChange, playVictory, playDefeat, isMuted, setMuted } from './audio';
1213

1314
type ClientState =
@@ -35,6 +36,7 @@ class GameClient {
3536
renderer: Renderer;
3637
private input: InputHandler;
3738
private ui: UIManager;
39+
private tutorial: Tutorial;
3840
private map = getSolarSystemMap();
3941
private tooltipEl: HTMLElement;
4042

@@ -52,6 +54,7 @@ class GameClient {
5254
this.renderer = new Renderer(this.canvas);
5355
this.input = new InputHandler(this.canvas, this.renderer.camera, this.renderer.planningState);
5456
this.ui = new UIManager();
57+
this.tutorial = new Tutorial();
5558
this.tooltipEl = document.getElementById('shipTooltip')!;
5659

5760
this.renderer.setMap(this.map);
@@ -185,6 +188,7 @@ class GameClient {
185188
switch (newState) {
186189
case 'menu':
187190
this.ui.showMenu();
191+
this.tutorial.hideTip();
188192
// Reset camera to default view centered on the solar system
189193
this.renderer.resetCamera();
190194
break;
@@ -212,6 +216,7 @@ class GameClient {
212216
if (myShip) {
213217
this.renderer.planningState.selectedShipId = myShip.id;
214218
}
219+
this.tutorial.onPhaseChange('astrogation', this.gameState.turnNumber);
215220
}
216221
this.renderer.frameOnShips();
217222
break;
@@ -230,6 +235,7 @@ class GameClient {
230235
if (launchable) {
231236
this.renderer.planningState.selectedShipId = launchable.id;
232237
}
238+
this.tutorial.onPhaseChange('ordnance', this.gameState.turnNumber);
233239
}
234240
break;
235241

@@ -240,10 +246,14 @@ class GameClient {
240246
this.renderer.planningState.combatTargetId = null;
241247
this.ui.showAttackButton(false);
242248
this.startCombatTargetWatch();
249+
if (this.gameState) {
250+
this.tutorial.onPhaseChange('combat', this.gameState.turnNumber);
251+
}
243252
break;
244253

245254
case 'playing_movementAnim':
246255
this.stopTurnTimer();
256+
this.tutorial.hideTip();
247257
this.ui.showHUD();
248258
this.ui.showMovementStatus();
249259
break;
@@ -257,6 +267,7 @@ class GameClient {
257267

258268
case 'gameOver':
259269
this.stopTurnTimer();
270+
this.tutorial.hideTip();
260271
// gameOver overlay is shown via showGameOver
261272
break;
262273
}

src/client/tutorial.ts

Lines changed: 158 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,158 @@
1+
/**
2+
* Interactive tutorial system for new players.
3+
* Shows contextual tips during the first game.
4+
* Tips are shown at specific game phases and dismissed by the player.
5+
* Tutorial state is persisted in localStorage so it only shows once.
6+
*/
7+
8+
const STORAGE_KEY = 'deltav_tutorial_done';
9+
10+
interface TutorialStep {
11+
id: string;
12+
phase: 'astrogation' | 'ordnance' | 'combat' | 'movement' | 'any';
13+
text: string;
14+
/** Only show after this turn number */
15+
minTurn?: number;
16+
/** Only show once per game */
17+
once?: boolean;
18+
}
19+
20+
const STEPS: TutorialStep[] = [
21+
{
22+
id: 'welcome',
23+
phase: 'astrogation',
24+
text: 'Welcome to Delta-V! Your ship starts landed on a planet. Each turn you choose a burn direction to accelerate. Your ship keeps its velocity between turns — this is vector movement.',
25+
},
26+
{
27+
id: 'select-ship',
28+
phase: 'astrogation',
29+
text: 'Click your ship or press Tab to select it. The dashed arrow shows where your ship will drift. The 6 arrows around that point are your burn options — click one or press 1-6 to accelerate.',
30+
},
31+
{
32+
id: 'gravity',
33+
phase: 'astrogation',
34+
text: 'Planets and the sun have gravity. When your course passes through a gravity hex, your path is deflected. The colored rings around bodies show gravity fields. Plan your burns to use gravity to your advantage!',
35+
minTurn: 2,
36+
},
37+
{
38+
id: 'fuel',
39+
phase: 'astrogation',
40+
text: 'Each burn costs 1 fuel. You can also drift without burning (free). Your fuel gauge is at the top of the screen. Land at a friendly base to refuel and repair.',
41+
minTurn: 3,
42+
},
43+
{
44+
id: 'ordnance-intro',
45+
phase: 'ordnance',
46+
text: 'Ordnance phase: warships can launch mines, torpedoes, or nukes from their cargo. Mines sit in space, torpedoes track toward enemies. Use keyboard shortcuts: N=mine, T=torpedo, K=nuke.',
47+
once: true,
48+
},
49+
{
50+
id: 'combat-intro',
51+
phase: 'combat',
52+
text: 'Combat phase: click an enemy ship to target it. Combat odds depend on your combined firepower vs theirs, modified by range and relative velocity. Press Enter to attack or skip.',
53+
once: true,
54+
},
55+
];
56+
57+
export class Tutorial {
58+
private currentStep = 0;
59+
private completed = false;
60+
private shownSteps = new Set<string>();
61+
private tipEl: HTMLElement;
62+
private textEl: HTMLElement;
63+
private progressEl: HTMLElement;
64+
private activeSteps: TutorialStep[] = [];
65+
66+
constructor() {
67+
this.tipEl = document.getElementById('tutorialTip')!;
68+
this.textEl = document.getElementById('tutorialTipText')!;
69+
this.progressEl = document.getElementById('tutorialProgress')!;
70+
71+
// Check if tutorial already completed
72+
if (localStorage.getItem(STORAGE_KEY) === '1') {
73+
this.completed = true;
74+
}
75+
76+
// Wire buttons
77+
document.getElementById('tutorialNextBtn')!.addEventListener('click', () => this.advance());
78+
document.getElementById('tutorialSkipBtn')!.addEventListener('click', () => this.skip());
79+
}
80+
81+
/** Check if tutorial is active (not completed) */
82+
isActive(): boolean {
83+
return !this.completed;
84+
}
85+
86+
/** Called when game phase changes. Shows relevant tip if applicable. */
87+
onPhaseChange(phase: string, turn: number) {
88+
if (this.completed) return;
89+
90+
// Find the next step that matches this phase
91+
const step = STEPS.find(s => {
92+
if (this.shownSteps.has(s.id)) return false;
93+
if (s.phase !== 'any' && s.phase !== phase) return false;
94+
if (s.minTurn && turn < s.minTurn) return false;
95+
return true;
96+
});
97+
98+
if (step) {
99+
this.showStep(step);
100+
} else {
101+
this.hideTip();
102+
}
103+
}
104+
105+
/** Hide the tutorial tip */
106+
hideTip() {
107+
this.tipEl.style.display = 'none';
108+
}
109+
110+
private showStep(step: TutorialStep) {
111+
this.textEl.textContent = step.text;
112+
this.tipEl.style.display = 'block';
113+
// Re-trigger animation
114+
this.tipEl.style.animation = 'none';
115+
void this.tipEl.offsetHeight; // force reflow
116+
this.tipEl.style.animation = '';
117+
118+
// Update progress dots
119+
const totalRelevant = STEPS.filter(s => !s.once || !this.shownSteps.has(s.id)).length;
120+
const shownCount = this.shownSteps.size;
121+
this.progressEl.innerHTML = STEPS.map((s, i) => {
122+
const cls = this.shownSteps.has(s.id) ? 'done' : s.id === step.id ? 'active' : '';
123+
return `<div class="tutorial-dot ${cls}"></div>`;
124+
}).join('');
125+
}
126+
127+
private advance() {
128+
// Mark current step as shown
129+
const currentVisibleStep = STEPS.find(s => !this.shownSteps.has(s.id));
130+
if (currentVisibleStep) {
131+
this.shownSteps.add(currentVisibleStep.id);
132+
}
133+
134+
// Check if all steps are shown
135+
if (this.shownSteps.size >= STEPS.length) {
136+
this.complete();
137+
}
138+
139+
this.hideTip();
140+
}
141+
142+
private skip() {
143+
this.complete();
144+
this.hideTip();
145+
}
146+
147+
private complete() {
148+
this.completed = true;
149+
localStorage.setItem(STORAGE_KEY, '1');
150+
}
151+
152+
/** Reset tutorial (for testing or when player wants to see it again) */
153+
reset() {
154+
this.completed = false;
155+
this.shownSteps.clear();
156+
localStorage.removeItem(STORAGE_KEY);
157+
}
158+
}

static/index.html

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -168,6 +168,16 @@ <h3>Other</h3>
168168
</div>
169169
</div>
170170

171+
<!-- Tutorial tooltip -->
172+
<div id="tutorialTip" class="tutorial-tip" style="display:none">
173+
<div id="tutorialTipText" class="tutorial-tip-text"></div>
174+
<div class="tutorial-tip-actions">
175+
<button id="tutorialNextBtn" class="btn btn-tutorial">Got it</button>
176+
<button id="tutorialSkipBtn" class="btn btn-tutorial-skip">Skip tutorial</button>
177+
</div>
178+
<div id="tutorialProgress" class="tutorial-progress"></div>
179+
</div>
180+
171181
<!-- Toast notifications -->
172182
<div id="toastContainer" class="toast-container"></div>
173183

static/style.css

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -914,6 +914,86 @@ body {
914914
to { opacity: 0; }
915915
}
916916

917+
/* Tutorial tip */
918+
.tutorial-tip {
919+
position: fixed;
920+
bottom: 6rem;
921+
left: 50%;
922+
transform: translateX(-50%);
923+
z-index: 20;
924+
background: rgba(10, 10, 40, 0.95);
925+
border: 1px solid rgba(79, 195, 247, 0.5);
926+
border-radius: 6px;
927+
padding: 1rem 1.4rem;
928+
max-width: 380px;
929+
width: calc(100% - 2rem);
930+
backdrop-filter: blur(8px);
931+
animation: tipFadeIn 0.4s ease-out;
932+
}
933+
934+
@keyframes tipFadeIn {
935+
from { opacity: 0; transform: translateX(-50%) translateY(10px); }
936+
to { opacity: 1; transform: translateX(-50%) translateY(0); }
937+
}
938+
939+
.tutorial-tip-text {
940+
font-size: 0.85rem;
941+
line-height: 1.6;
942+
color: #e0e0e0;
943+
margin-bottom: 0.8rem;
944+
}
945+
946+
.tutorial-tip-actions {
947+
display: flex;
948+
gap: 0.5rem;
949+
justify-content: flex-end;
950+
}
951+
952+
.btn-tutorial {
953+
border-color: #4fc3f7;
954+
color: #4fc3f7;
955+
padding: 0.4rem 1.2rem;
956+
font-size: 0.8rem;
957+
}
958+
959+
.btn-tutorial:hover {
960+
background: rgba(79, 195, 247, 0.12);
961+
}
962+
963+
.btn-tutorial-skip {
964+
border-color: rgba(255, 255, 255, 0.15);
965+
color: #888;
966+
padding: 0.4rem 0.8rem;
967+
font-size: 0.75rem;
968+
}
969+
970+
.btn-tutorial-skip:hover {
971+
color: #aaa;
972+
border-color: rgba(255, 255, 255, 0.25);
973+
}
974+
975+
.tutorial-progress {
976+
margin-top: 0.6rem;
977+
display: flex;
978+
justify-content: center;
979+
gap: 0.3rem;
980+
}
981+
982+
.tutorial-dot {
983+
width: 6px;
984+
height: 6px;
985+
border-radius: 50%;
986+
background: rgba(79, 195, 247, 0.2);
987+
}
988+
989+
.tutorial-dot.active {
990+
background: #4fc3f7;
991+
}
992+
993+
.tutorial-dot.done {
994+
background: rgba(79, 195, 247, 0.5);
995+
}
996+
917997
/* Disabled button state */
918998
.btn[disabled] {
919999
opacity: 0.4;

0 commit comments

Comments
 (0)