Skip to content

Commit 55a3c04

Browse files
committed
Fix bugs, add ordnance animation and undo button
Bugs fixed: - Validate max 1 ordnance launch per ship per turn - Ordnance now detonates on contact along entire path, not just final position - Rematch requires both players to agree (was single-player restart) New features: - Ordnance movement animation: mines, torpedoes, and nukes interpolate along their path during movement phase, with detonation flash effect - Undo button: clears selected ship's burn/overload during astrogation - Rematch pending state: button shows "Waiting..." until both agree 138 tests passing.
1 parent 7a5f94a commit 55a3c04

File tree

8 files changed

+184
-51
lines changed

8 files changed

+184
-51
lines changed

src/client/main.ts

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@ class GameClient {
4343
// Wire UI callbacks
4444
this.ui.onSelectScenario = (scenario) => this.createGame(scenario);
4545
this.ui.onJoin = (code) => this.joinGame(code);
46+
this.ui.onUndo = () => this.undoSelectedShipBurn();
4647
this.ui.onConfirm = () => this.confirmOrders();
4748
this.ui.onLaunchOrdnance = (ordType) => this.sendOrdnanceLaunch(ordType);
4849
this.ui.onSkipOrdnance = () => this.sendSkipOrdnance();
@@ -233,7 +234,7 @@ class GameClient {
233234
this.renderer.setGameState(this.gameState);
234235
this.input.setGameState(this.gameState);
235236
this.setState('playing_movementAnim');
236-
this.renderer.animateMovements(msg.movements, () => {
237+
this.renderer.animateMovements(msg.movements, msg.ordnanceMovements, () => {
237238
this.onAnimationComplete();
238239
});
239240
break;
@@ -264,6 +265,10 @@ class GameClient {
264265
);
265266
break;
266267

268+
case 'rematchPending':
269+
this.ui.showRematchPending();
270+
break;
271+
267272
case 'opponentDisconnected':
268273
this.setState('gameOver');
269274
this.ui.showGameOver(true, 'Opponent disconnected');
@@ -287,6 +292,17 @@ class GameClient {
287292

288293
// --- Game actions ---
289294

295+
private undoSelectedShipBurn() {
296+
if (!this.gameState || this.state !== 'playing_astrogation') return;
297+
const shipId = this.renderer.planningState.selectedShipId;
298+
if (shipId) {
299+
this.renderer.planningState.burns.delete(shipId);
300+
this.renderer.planningState.overloads.delete(shipId);
301+
this.renderer.planningState.weakGravityChoices.delete(shipId);
302+
}
303+
this.updateHUD();
304+
}
305+
290306
private confirmOrders() {
291307
if (!this.gameState || this.state !== 'playing_astrogation') return;
292308

@@ -420,12 +436,15 @@ class GameClient {
420436
const selectedId = this.renderer.planningState.selectedShipId;
421437
const selectedShip = myShips.find(s => s.id === selectedId) ?? myShips.find(s => !s.destroyed);
422438
const stats = selectedShip ? SHIP_STATS[selectedShip.type] : null;
439+
// Check if any ship has a burn set (for undo button visibility)
440+
const hasBurns = Array.from(this.renderer.planningState.burns.values()).some(b => b !== null);
423441
this.ui.updateHUD(
424442
this.gameState.turnNumber,
425443
this.gameState.phase,
426444
isMyTurn,
427445
selectedShip?.fuel ?? 0,
428446
stats?.fuel ?? 0,
447+
hasBurns,
429448
);
430449
this.ui.updateShipList(
431450
myShips,

src/client/renderer.ts

Lines changed: 65 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ import {
88
HEX_DIRECTIONS,
99
hexVecLength,
1010
} from '../shared/hex';
11-
import type { GameState, Ship, ShipMovement, SolarSystemMap, CelestialBody, CombatResult } from '../shared/types';
11+
import type { GameState, Ship, ShipMovement, OrdnanceMovement, SolarSystemMap, CelestialBody, CombatResult } from '../shared/types';
1212
import { MOVEMENT_ANIM_DURATION, CAMERA_LERP_SPEED, SHIP_STATS } from '../shared/constants';
1313
import { computeCourse, predictDestination } from '../shared/movement';
1414
import { computeOdds, computeRangeMod, computeVelocityMod, getCombatStrength, canAttack } from '../shared/combat';
@@ -122,6 +122,7 @@ function generateStars(count: number, range: number): Star[] {
122122

123123
export interface AnimationState {
124124
movements: ShipMovement[];
125+
ordnanceMovements: OrdnanceMovement[];
125126
startTime: number;
126127
duration: number;
127128
onComplete: () => void;
@@ -174,25 +175,27 @@ export class Renderer {
174175
this.playerId = id;
175176
}
176177

177-
animateMovements(movements: ShipMovement[], onComplete: () => void) {
178+
animateMovements(movements: ShipMovement[], ordnanceMovements: OrdnanceMovement[], onComplete: () => void) {
178179
this.animState = {
179180
movements,
181+
ordnanceMovements,
180182
startTime: performance.now(),
181183
duration: MOVEMENT_ANIM_DURATION,
182184
onComplete,
183185
};
184186

185-
// Frame camera on all moving ships
186-
if (this.map && movements.length > 0) {
187+
// Frame camera on all moving ships and ordnance
188+
const allFrom = [...movements.map(m => m.from), ...ordnanceMovements.map(m => m.from)];
189+
const allTo = [...movements.map(m => m.to), ...ordnanceMovements.map(m => m.to)];
190+
const allHexes = [...allFrom, ...allTo];
191+
if (this.map && allHexes.length > 0) {
187192
let minX = Infinity, maxX = -Infinity, minY = Infinity, maxY = -Infinity;
188-
for (const m of movements) {
189-
for (const h of [m.from, m.to]) {
190-
const p = hexToPixel(h, HEX_SIZE);
191-
minX = Math.min(minX, p.x);
192-
maxX = Math.max(maxX, p.x);
193-
minY = Math.min(minY, p.y);
194-
maxY = Math.max(maxY, p.y);
195-
}
193+
for (const h of allHexes) {
194+
const p = hexToPixel(h, HEX_SIZE);
195+
minX = Math.min(minX, p.x);
196+
maxX = Math.max(maxX, p.x);
197+
minY = Math.min(minY, p.y);
198+
maxY = Math.max(maxY, p.y);
196199
}
197200
this.camera.frameBounds(minX, maxX, minY, maxY, 150);
198201
}
@@ -705,7 +708,21 @@ export class Renderer {
705708

706709
for (const ord of state.ordnance) {
707710
if (ord.destroyed) continue;
708-
const p = hexToPixel(ord.position, HEX_SIZE);
711+
712+
let p: PixelCoord;
713+
// During animation, interpolate ordnance position along its path
714+
if (this.animState) {
715+
const om = this.animState.ordnanceMovements.find(m => m.ordnanceId === ord.id);
716+
if (om) {
717+
const progress = Math.min((now - this.animState.startTime) / this.animState.duration, 1);
718+
p = this.interpolatePath(om.path, progress);
719+
} else {
720+
p = hexToPixel(ord.position, HEX_SIZE);
721+
}
722+
} else {
723+
p = hexToPixel(ord.position, HEX_SIZE);
724+
}
725+
709726
const color = ord.owner === this.playerId ? '#4fc3f7' : '#ff9800';
710727
const pulse = 0.6 + 0.3 * Math.sin(now / 400);
711728

@@ -761,8 +778,8 @@ export class Renderer {
761778
ctx.restore();
762779
}
763780

764-
// Velocity vector for ordnance
765-
if (ord.velocity.dq !== 0 || ord.velocity.dr !== 0) {
781+
// Velocity vector for ordnance (hide during animation)
782+
if (!this.animState && (ord.velocity.dq !== 0 || ord.velocity.dr !== 0)) {
766783
const dest = hexToPixel(hexAdd(ord.position, ord.velocity), HEX_SIZE);
767784
ctx.strokeStyle = color;
768785
ctx.globalAlpha = 0.3;
@@ -776,6 +793,39 @@ export class Renderer {
776793
ctx.globalAlpha = 1;
777794
}
778795
}
796+
797+
// During animation, also render ordnance that detonated (show until detonation point)
798+
if (this.animState) {
799+
const progress = Math.min((now - this.animState.startTime) / this.animState.duration, 1);
800+
for (const om of this.animState.ordnanceMovements) {
801+
if (!om.detonated) continue;
802+
// Already removed from state.ordnance — render at interpolated position until detonation
803+
if (progress < 0.9) {
804+
const p = this.interpolatePath(om.path, progress);
805+
ctx.fillStyle = '#ff4444';
806+
ctx.globalAlpha = 0.7;
807+
const s = 4;
808+
ctx.beginPath();
809+
ctx.moveTo(p.x, p.y - s);
810+
ctx.lineTo(p.x + s, p.y);
811+
ctx.lineTo(p.x, p.y + s);
812+
ctx.lineTo(p.x - s, p.y);
813+
ctx.closePath();
814+
ctx.fill();
815+
ctx.globalAlpha = 1;
816+
} else {
817+
// Flash at detonation point
818+
const detP = hexToPixel(om.to, HEX_SIZE);
819+
const flashSize = 12 * (1 - (progress - 0.9) / 0.1);
820+
ctx.fillStyle = '#ffaa00';
821+
ctx.globalAlpha = 0.8;
822+
ctx.beginPath();
823+
ctx.arc(detP.x, detP.y, flashSize, 0, Math.PI * 2);
824+
ctx.fill();
825+
ctx.globalAlpha = 1;
826+
}
827+
}
828+
}
779829
}
780830

781831
private renderTorpedoGuidance(ctx: CanvasRenderingContext2D, state: GameState, now: number) {

src/client/ui.ts

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ export class UIManager {
1212
// Callbacks
1313
onSelectScenario: ((scenario: string) => void) | null = null;
1414
onJoin: ((code: string) => void) | null = null;
15+
onUndo: (() => void) | null = null;
1516
onConfirm: (() => void) | null = null;
1617
onLaunchOrdnance: ((type: 'mine' | 'torpedo' | 'nuke') => void) | null = null;
1718
onSkipOrdnance: (() => void) | null = null;
@@ -69,6 +70,7 @@ export class UIManager {
6970
});
7071
});
7172

73+
document.getElementById('undoBtn')!.addEventListener('click', () => this.onUndo?.());
7274
document.getElementById('confirmBtn')!.addEventListener('click', () => this.onConfirm?.());
7375
document.getElementById('launchMineBtn')!.addEventListener('click', () => this.onLaunchOrdnance?.('mine'));
7476
document.getElementById('launchTorpedoBtn')!.addEventListener('click', () => this.onLaunchOrdnance?.('torpedo'));
@@ -119,11 +121,14 @@ export class UIManager {
119121
this.shipListEl.style.display = 'flex';
120122
}
121123

122-
updateHUD(turn: number, phase: string, isMyTurn: boolean, fuel: number, maxFuel: number) {
124+
updateHUD(turn: number, phase: string, isMyTurn: boolean, fuel: number, maxFuel: number, hasBurns = false) {
123125
document.getElementById('turnInfo')!.textContent = `Turn ${turn}`;
124126
document.getElementById('phaseInfo')!.textContent = isMyTurn ? phase.toUpperCase() : 'OPPONENT\'S TURN';
125127
document.getElementById('fuelGauge')!.textContent = `Fuel: ${fuel}/${maxFuel}`;
126128

129+
const undoBtn = document.getElementById('undoBtn')!;
130+
undoBtn.style.display = isMyTurn && phase === 'astrogation' && hasBurns ? 'inline-block' : 'none';
131+
127132
const confirmBtn = document.getElementById('confirmBtn')!;
128133
confirmBtn.style.display = isMyTurn && phase === 'astrogation' ? 'inline-block' : 'none';
129134

@@ -212,5 +217,13 @@ export class UIManager {
212217
this.gameOverEl.style.display = 'flex';
213218
document.getElementById('gameOverText')!.textContent = won ? 'VICTORY' : 'DEFEAT';
214219
document.getElementById('gameOverReason')!.textContent = reason;
220+
document.getElementById('rematchBtn')!.textContent = 'Rematch';
221+
document.getElementById('rematchBtn')!.removeAttribute('disabled');
222+
}
223+
224+
showRematchPending() {
225+
const btn = document.getElementById('rematchBtn')!;
226+
btn.textContent = 'Waiting...';
227+
btn.setAttribute('disabled', 'true');
215228
}
216229
}

src/server/game-do.ts

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -122,7 +122,7 @@ export class GameDO extends DurableObject {
122122
await this.handleSkipCombat(playerId, ws);
123123
break;
124124
case 'rematch':
125-
await this.initGame();
125+
await this.handleRematch(playerId, ws);
126126
break;
127127
case 'ping':
128128
this.send(ws, { type: 'pong', t: msg.t });
@@ -242,6 +242,23 @@ export class GameDO extends DurableObject {
242242
await this.saveGameState(result.state);
243243
}
244244

245+
private async handleRematch(playerId: number, ws: WebSocket) {
246+
const requests = await this.ctx.storage.get<number[]>('rematchRequests') ?? [];
247+
if (!requests.includes(playerId)) {
248+
requests.push(playerId);
249+
}
250+
251+
if (requests.length >= 2) {
252+
// Both players want a rematch — restart
253+
await this.ctx.storage.delete('rematchRequests');
254+
await this.initGame();
255+
} else {
256+
// First request — notify both players
257+
await this.ctx.storage.put('rematchRequests', requests);
258+
this.broadcast({ type: 'rematchPending' });
259+
}
260+
}
261+
245262
private broadcastEndOrUpdate(state: GameState) {
246263
if (state.phase === 'gameOver') {
247264
this.broadcast({ type: 'gameOver', winner: state.winner!, reason: state.winReason! });

src/shared/__tests__/game-engine.test.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -690,3 +690,24 @@ describe('nuke ordnance', () => {
690690
}
691691
});
692692
});
693+
694+
describe('ordnance validation', () => {
695+
it('rejects multiple launches from the same ship', () => {
696+
// Set up a frigate with enough cargo for 2 mines but enforce 1-per-turn
697+
const ship = initialState.ships[0];
698+
ship.type = 'frigate';
699+
ship.landed = false;
700+
ship.fuel = 20;
701+
initialState.phase = 'ordnance';
702+
703+
const launches: OrdnanceLaunch[] = [
704+
{ shipId: ship.id, ordnanceType: 'mine' },
705+
{ shipId: ship.id, ordnanceType: 'mine' },
706+
];
707+
const result = processOrdnance(initialState, 0, launches, map);
708+
expect('error' in result).toBe(true);
709+
if ('error' in result) {
710+
expect(result.error).toContain('one ordnance per turn');
711+
}
712+
});
713+
});

0 commit comments

Comments
 (0)