Skip to content

Commit 163a26a

Browse files
committed
Add QoL shortcuts, timer warning, and AI scenario tests
- H key centers camera on own fleet - L key toggles game log panel - Turn timer warning sound + toast at 30s remaining - Urgent timer pulse animation when under 30s - playWarning() synthesized beep in audio system - 6 new AI tests covering fleet action, blockade, difficulty levels - 182 total tests passing
1 parent c1bd4b8 commit 163a26a

File tree

6 files changed

+165
-2
lines changed

6 files changed

+165
-2
lines changed

src/client/audio.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -171,6 +171,26 @@ export function playPhaseChange() {
171171
osc2.stop(ac.currentTime + 0.25);
172172
}
173173

174+
/** Warning beep for low timer. */
175+
export function playWarning() {
176+
const ac = getCtx();
177+
if (!ac) return;
178+
// Two short beeps
179+
for (let i = 0; i < 2; i++) {
180+
const osc = ac.createOscillator();
181+
const gain = ac.createGain();
182+
osc.connect(gain);
183+
gain.connect(ac.destination);
184+
osc.type = 'square';
185+
const t = ac.currentTime + i * 0.2;
186+
osc.frequency.setValueAtTime(1000, t);
187+
gain.gain.setValueAtTime(0.05, t);
188+
gain.gain.exponentialRampToValueAtTime(0.001, t + 0.1);
189+
osc.start(t);
190+
osc.stop(t + 0.1);
191+
}
192+
}
193+
174194
/** Victory fanfare. */
175195
export function playVictory() {
176196
const ac = getCtx();

src/client/main.ts

Lines changed: 27 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ import { Renderer, HEX_SIZE } from './renderer';
99
import { InputHandler } from './input';
1010
import { UIManager } from './ui';
1111
import { Tutorial } from './tutorial';
12-
import { initAudio, playSelect, playConfirm, playThrust, playCombat, playExplosion, playPhaseChange, playVictory, playDefeat, isMuted, setMuted } from './audio';
12+
import { initAudio, playSelect, playConfirm, playThrust, playCombat, playExplosion, playPhaseChange, playVictory, playDefeat, playWarning, isMuted, setMuted } from './audio';
1313

1414
type ClientState =
1515
| 'menu'
@@ -48,6 +48,7 @@ class GameClient {
4848
// Turn timer
4949
private turnStartTime = 0;
5050
private turnTimerInterval: number | null = null;
51+
private timerWarningPlayed = false;
5152

5253
constructor() {
5354
this.canvas = document.getElementById('gameCanvas') as HTMLCanvasElement;
@@ -134,6 +135,13 @@ class GameClient {
134135
(this.state === 'playing_astrogation' || this.state === 'playing_ordnance' || this.state === 'playing_combat' || this.state === 'playing_opponentTurn')) {
135136
// Focus camera on nearest enemy
136137
this.focusNearestEnemy();
138+
} else if (e.key.toLowerCase() === 'h' && this.gameState &&
139+
(this.state === 'playing_astrogation' || this.state === 'playing_ordnance' || this.state === 'playing_combat' || this.state === 'playing_opponentTurn')) {
140+
// Center camera on own fleet
141+
this.focusOwnFleet();
142+
} else if (e.key.toLowerCase() === 'l' && this.gameState) {
143+
// Toggle game log
144+
this.ui.toggleLog();
137145
} else if (e.key.toLowerCase() === 'w' || e.key === 'ArrowUp') {
138146
this.renderer.camera.pan(0, 40);
139147
} else if (e.key.toLowerCase() === 's' || e.key === 'ArrowDown') {
@@ -998,6 +1006,15 @@ class GameClient {
9981006
this.renderer.centerOnHex(nearest.position);
9991007
}
10001008

1009+
private focusOwnFleet() {
1010+
if (!this.gameState) return;
1011+
const myShips = this.gameState.ships.filter(s => s.owner === this.playerId && !s.destroyed);
1012+
if (myShips.length === 0) return;
1013+
// Center on first alive ship (or selected ship if one is selected)
1014+
const selected = myShips.find(s => s.id === this.renderer.planningState.selectedShipId);
1015+
this.renderer.centerOnHex((selected ?? myShips[0]).position);
1016+
}
1017+
10011018
// --- Burn shortcuts ---
10021019

10031020
private setBurnDirection(dir: number) {
@@ -1141,13 +1158,21 @@ class GameClient {
11411158
private startTurnTimer() {
11421159
this.stopTurnTimer();
11431160
this.turnStartTime = Date.now();
1161+
this.timerWarningPlayed = false;
11441162
const timerEl = document.getElementById('turnTimer')!;
11451163
this.turnTimerInterval = window.setInterval(() => {
11461164
const elapsed = Math.floor((Date.now() - this.turnStartTime) / 1000);
1165+
const remaining = 120 - elapsed;
11471166
const mins = Math.floor(elapsed / 60);
11481167
const secs = elapsed % 60;
11491168
timerEl.textContent = mins > 0 ? `${mins}:${secs.toString().padStart(2, '0')}` : `${secs}s`;
1150-
timerEl.className = 'turn-timer' + (elapsed >= 30 ? ' turn-timer-slow' : ' turn-timer-active');
1169+
timerEl.className = 'turn-timer' + (elapsed >= 90 ? ' turn-timer-urgent' : elapsed >= 30 ? ' turn-timer-slow' : ' turn-timer-active');
1170+
// Warning at 30s remaining
1171+
if (remaining <= 30 && !this.timerWarningPlayed) {
1172+
this.timerWarningPlayed = true;
1173+
playWarning();
1174+
this.ui.showToast('30 seconds remaining!', 'error');
1175+
}
11511176
}, 1000);
11521177
}
11531178

src/client/ui.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -136,6 +136,18 @@ export class UIManager {
136136
});
137137
}
138138

139+
toggleLog() {
140+
if (this.logVisible) {
141+
this.logVisible = false;
142+
this.gameLogEl.style.display = 'none';
143+
this.logShowBtn.style.display = 'block';
144+
} else {
145+
this.logVisible = true;
146+
this.gameLogEl.style.display = 'flex';
147+
this.logShowBtn.style.display = 'none';
148+
}
149+
}
150+
139151
hideAll() {
140152
this.menuEl.style.display = 'none';
141153
this.scenarioEl.style.display = 'none';

src/shared/__tests__/ai.test.ts

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -260,6 +260,24 @@ describe('aiCombat', () => {
260260
expect(attacks[0].targetId).toBe(pilgrim.id);
261261
});
262262

263+
it('easy AI is more conservative about attacking', () => {
264+
const state = createGame(SCENARIOS.biplanetary, map, 'TEST', findBaseHex);
265+
const aiShip = state.ships.find(s => s.owner === 1)!;
266+
const enemyShip = state.ships.find(s => s.owner === 0)!;
267+
268+
// Place them far apart — poor odds
269+
aiShip.position = { q: 0, r: 0 };
270+
aiShip.velocity = { dq: 0, dr: 0 };
271+
aiShip.landed = false;
272+
enemyShip.position = { q: 8, r: 0 };
273+
enemyShip.velocity = { dq: 3, dr: 0 };
274+
enemyShip.landed = false;
275+
276+
// Easy AI should skip combat with bad range + velocity mods
277+
const attacks = aiCombat(state, 1, 'easy');
278+
expect(attacks).toHaveLength(0);
279+
});
280+
263281
it('attack contains valid ship references', () => {
264282
const state = createGame(SCENARIOS.biplanetary, map, 'TEST', findBaseHex);
265283
const aiShip = state.ships.find(s => s.owner === 1)!;
@@ -282,3 +300,79 @@ describe('aiCombat', () => {
282300
}
283301
});
284302
});
303+
304+
describe('AI scenario handling', () => {
305+
it('fleet action: AI generates orders for all 3 ships', () => {
306+
const state = createGame(SCENARIOS.fleetAction, map, 'FA01', findBaseHex);
307+
const orders = aiAstrogation(state, 1, map);
308+
const aiShips = state.ships.filter(s => s.owner === 1);
309+
expect(orders).toHaveLength(aiShips.length);
310+
expect(aiShips.length).toBe(3);
311+
});
312+
313+
it('fleet action: AI seeks combat when no target body', () => {
314+
const state = createGame(SCENARIOS.fleetAction, map, 'FA02', findBaseHex);
315+
// Unland all ships and place opposing fleets nearby
316+
for (const ship of state.ships) {
317+
ship.landed = false;
318+
ship.position = ship.owner === 0
319+
? { q: 0, r: 0 }
320+
: { q: 5, r: 0 };
321+
ship.velocity = { dq: 0, dr: 0 };
322+
}
323+
const orders = aiAstrogation(state, 1, map, 'hard');
324+
// At least one ship should burn toward enemy (not null)
325+
const hasBurn = orders.some(o => o.burn !== null);
326+
expect(hasBurn).toBe(true);
327+
});
328+
329+
it('blockade: AI interceptor seeks enemy runner', () => {
330+
const state = createGame(SCENARIOS.blockade, map, 'BK01', findBaseHex);
331+
// The dreadnaught (player 1) starts in space
332+
const dreadnaught = state.ships.find(s => s.owner === 1)!;
333+
expect(dreadnaught.landed).toBe(false);
334+
const orders = aiAstrogation(state, 1, map);
335+
expect(orders).toHaveLength(1);
336+
});
337+
338+
it('blockade: runner AI navigates toward Mars', () => {
339+
const state = createGame(SCENARIOS.blockade, map, 'BK02', findBaseHex);
340+
const runner = state.ships.find(s => s.owner === 0)!;
341+
// Unland runner and place it in open space
342+
runner.landed = false;
343+
runner.position = { q: -3, r: -5 };
344+
runner.velocity = { dq: 0, dr: 0 };
345+
346+
const orders = aiAstrogation(state, 0, map, 'hard');
347+
expect(orders).toHaveLength(1);
348+
// Hard AI should burn toward Mars (not drift)
349+
expect(orders[0].burn).not.toBeNull();
350+
});
351+
352+
it('AI handles all difficulty levels without errors', () => {
353+
const difficulties: Array<'easy' | 'normal' | 'hard'> = ['easy', 'normal', 'hard'];
354+
const scenarios = [SCENARIOS.biplanetary, SCENARIOS.escape, SCENARIOS.blockade, SCENARIOS.fleetAction];
355+
356+
for (const scenario of scenarios) {
357+
for (const diff of difficulties) {
358+
const state = createGame(scenario, map, 'DF01', findBaseHex);
359+
// Unland ships for meaningful AI decisions
360+
state.ships.forEach(s => {
361+
if (!s.destroyed) {
362+
s.landed = false;
363+
s.velocity = { dq: 0, dr: 0 };
364+
}
365+
});
366+
367+
const orders = aiAstrogation(state, 1, map, diff);
368+
expect(orders.length).toBeGreaterThan(0);
369+
370+
const launches = aiOrdnance(state, 1, map, diff);
371+
expect(Array.isArray(launches)).toBe(true);
372+
373+
const attacks = aiCombat(state, 1, diff);
374+
expect(Array.isArray(attacks)).toBe(true);
375+
}
376+
}
377+
});
378+
});

static/index.html

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -155,6 +155,8 @@ <h3>Combat</h3>
155155
<div class="help-section">
156156
<h3>Other</h3>
157157
<div class="help-row"><span class="help-key">E</span> Focus nearest enemy</div>
158+
<div class="help-row"><span class="help-key">H</span> Center on own fleet</div>
159+
<div class="help-row"><span class="help-key">L</span> Toggle game log</div>
158160
<div class="help-row"><span class="help-key">M</span> Toggle sound</div>
159161
<div class="help-row"><span class="help-key">?</span> Toggle this help</div>
160162
</div>

static/style.css

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -544,6 +544,16 @@ body {
544544
color: #ffb74d;
545545
}
546546

547+
.turn-timer-urgent {
548+
color: #ef5350;
549+
animation: timer-pulse 0.5s ease-in-out infinite alternate;
550+
}
551+
552+
@keyframes timer-pulse {
553+
from { opacity: 1; }
554+
to { opacity: 0.5; }
555+
}
556+
547557
/* Latency indicator in HUD */
548558
.latency-text {
549559
color: #888;

0 commit comments

Comments
 (0)