Skip to content

Commit 7a5f94a

Browse files
committed
Add weak gravity UI, torpedo guidance, base defense, ramming, and nukes
- Weak gravity player choice: click gravity hex indicators to toggle ignoring weak gravity during course preview - Torpedo guidance direction: click hex around warship during ordnance phase to set terminal guidance acceleration - Base defense fire: bases fire at 2:1 odds on enemy ships in adjacent gravity hexes, fires even when players skip combat - Ramming: opposing ships on same hex after movement both roll on Other Damage table - Nukes: new ordnance type (mass 20, warships only), uses Gun Combat table at 2:1 odds, affects all ships in hex, destroys asteroids - Tests for base defense, ramming, and nuke launch validation (137 total)
1 parent e2a47d1 commit 7a5f94a

File tree

12 files changed

+497
-32
lines changed

12 files changed

+497
-32
lines changed

src/client/input.ts

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import {
44
hexToPixel,
55
hexAdd,
66
hexEqual,
7+
hexKey,
78
HEX_DIRECTIONS,
89
} from '../shared/hex';
910
import type { GameState, Ship, SolarSystemMap } from '../shared/types';
@@ -151,6 +152,11 @@ export class InputHandler {
151152
return;
152153
}
153154

155+
if (this.gameState.phase === 'ordnance') {
156+
this.handleOrdnanceClick(clickHex);
157+
return;
158+
}
159+
154160
if (this.gameState.phase !== 'astrogation') return;
155161

156162
// Check if clicking a burn or overload direction arrow
@@ -162,6 +168,21 @@ export class InputHandler {
162168
? computeCourse(ship, null, this.map).path[0] // launch hex
163169
: predictDestination(ship);
164170

171+
// Check weak gravity toggle clicks
172+
const overload = this.planningState.overloads.get(ship.id) ?? null;
173+
const wgChoices = this.planningState.weakGravityChoices.get(ship.id) ?? {};
174+
const course = computeCourse(ship, currentBurn, this.map, { overload, weakGravityChoices: wgChoices });
175+
for (const grav of course.gravityEffects) {
176+
if (grav.strength !== 'weak') continue;
177+
if (hexEqual(clickHex, grav.hex)) {
178+
const key = hexKey(grav.hex);
179+
const newChoices = { ...wgChoices };
180+
newChoices[key] = !newChoices[key];
181+
this.planningState.weakGravityChoices.set(ship.id, newChoices);
182+
return;
183+
}
184+
}
185+
165186
// Check overload arrows first (they overlap with burn arrow space)
166187
if (currentBurn !== null) {
167188
const stats = SHIP_STATS[ship.type];
@@ -212,6 +233,36 @@ export class InputHandler {
212233
this.planningState.selectedShipId = null;
213234
}
214235

236+
private handleOrdnanceClick(clickHex: HexCoord) {
237+
if (!this.gameState) return;
238+
239+
const selectedId = this.planningState.selectedShipId;
240+
if (selectedId) {
241+
const ship = this.gameState.ships.find(s => s.id === selectedId);
242+
if (ship) {
243+
// Check if clicking a torpedo guidance direction
244+
for (let d = 0; d < 6; d++) {
245+
const target = hexAdd(ship.position, HEX_DIRECTIONS[d]);
246+
if (hexEqual(clickHex, target)) {
247+
this.planningState.torpedoAccel =
248+
this.planningState.torpedoAccel === d ? null : d;
249+
return;
250+
}
251+
}
252+
}
253+
}
254+
255+
// Check if clicking on own ship to select it
256+
for (const ship of this.gameState.ships) {
257+
if (ship.owner !== this.playerId || ship.destroyed) continue;
258+
if (hexEqual(clickHex, ship.position)) {
259+
this.planningState.selectedShipId = ship.id;
260+
this.planningState.torpedoAccel = null;
261+
return;
262+
}
263+
}
264+
}
265+
215266
private handleCombatClick(clickHex: HexCoord) {
216267
if (!this.gameState) return;
217268

src/client/main.ts

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -104,6 +104,7 @@ class GameClient {
104104
this.renderer.planningState.selectedShipId = null;
105105
this.renderer.planningState.burns.clear();
106106
this.renderer.planningState.overloads.clear();
107+
this.renderer.planningState.weakGravityChoices.clear();
107108
// Auto-select the player's first ship
108109
if (this.gameState) {
109110
const myShip = this.gameState.ships.find(s => s.owner === this.playerId && !s.destroyed);
@@ -294,8 +295,10 @@ class GameClient {
294295
if (ship.owner !== this.playerId) continue;
295296
const burn = this.renderer.planningState.burns.get(ship.id) ?? null;
296297
const overload = this.renderer.planningState.overloads.get(ship.id) ?? null;
298+
const wgChoices = this.renderer.planningState.weakGravityChoices.get(ship.id);
297299
const order: AstrogationOrder = { shipId: ship.id, burn };
298300
if (overload !== null) order.overload = overload;
301+
if (wgChoices && Object.keys(wgChoices).length > 0) order.weakGravityChoices = wgChoices;
299302
orders.push(order);
300303
}
301304

@@ -354,7 +357,7 @@ class GameClient {
354357
}, 100);
355358
}
356359

357-
private sendOrdnanceLaunch(ordType: 'mine' | 'torpedo') {
360+
private sendOrdnanceLaunch(ordType: 'mine' | 'torpedo' | 'nuke') {
358361
if (!this.gameState || this.state !== 'playing_ordnance') return;
359362
const selectedId = this.renderer.planningState.selectedShipId;
360363
if (!selectedId) return;
@@ -367,8 +370,8 @@ class GameClient {
367370
ordnanceType: ordType,
368371
};
369372

370-
// For torpedoes, use the selected torpedo direction (if any)
371-
if (ordType === 'torpedo') {
373+
// For torpedoes and nukes, use the selected guidance direction (if any)
374+
if (ordType === 'torpedo' || ordType === 'nuke') {
372375
launch.torpedoAccel = this.renderer.planningState.torpedoAccel ?? null;
373376
}
374377

src/client/renderer.ts

Lines changed: 105 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -133,6 +133,7 @@ export interface PlanningState {
133133
selectedShipId: string | null;
134134
burns: Map<string, number | null>; // shipId -> burn direction (or null for no burn)
135135
overloads: Map<string, number | null>; // shipId -> overload direction (warships only, 2 fuel total)
136+
weakGravityChoices: Map<string, Record<string, boolean>>; // shipId -> { hexKey: true to ignore }
136137
torpedoAccel: number | null; // direction for torpedo terminal guidance
137138
combatTargetId: string | null; // enemy ship targeted for combat
138139
}
@@ -150,7 +151,7 @@ export class Renderer {
150151
private gameState: GameState | null = null;
151152
private playerId = -1;
152153
private animState: AnimationState | null = null;
153-
planningState: PlanningState = { selectedShipId: null, burns: new Map(), overloads: new Map(), torpedoAccel: null, combatTargetId: null };
154+
planningState: PlanningState = { selectedShipId: null, burns: new Map(), overloads: new Map(), weakGravityChoices: new Map(), torpedoAccel: null, combatTargetId: null };
154155
private combatResults: { results: CombatResult[]; showUntil: number } | null = null;
155156
private lastTime = 0;
156157

@@ -285,6 +286,7 @@ export class Renderer {
285286
if (this.gameState && this.map) {
286287
this.renderCourseVectors(ctx, this.gameState, this.map, now);
287288
this.renderOrdnance(ctx, this.gameState, now);
289+
this.renderTorpedoGuidance(ctx, this.gameState, now);
288290
this.renderCombatOverlay(ctx, this.gameState, now);
289291
this.renderShips(ctx, this.gameState, now);
290292
}
@@ -437,7 +439,8 @@ export class Renderer {
437439

438440
if (burn !== null || isSelected) {
439441
const overload = this.planningState.overloads.get(ship.id) ?? null;
440-
const course = computeCourse(ship, burn, map, { overload });
442+
const wgChoices = this.planningState.weakGravityChoices.get(ship.id) ?? {};
443+
const course = computeCourse(ship, burn, map, { overload, weakGravityChoices: wgChoices });
441444
const from = hexToPixel(ship.landed ? course.path[0] : ship.position, HEX_SIZE);
442445
const to = hexToPixel(course.destination, HEX_SIZE);
443446

@@ -499,6 +502,39 @@ export class Renderer {
499502
}
500503
}
501504

505+
// Weak gravity toggle indicators on the path
506+
if (isSelected) {
507+
for (const grav of course.gravityEffects) {
508+
if (grav.strength !== 'weak') continue;
509+
const gp = hexToPixel(grav.hex, HEX_SIZE);
510+
const key = hexKey(grav.hex);
511+
const isIgnored = wgChoices[key] === true;
512+
513+
// Draw hollow/filled circle to indicate ignore/apply
514+
ctx.strokeStyle = isIgnored ? 'rgba(180, 130, 255, 0.5)' : 'rgba(180, 130, 255, 0.8)';
515+
ctx.fillStyle = isIgnored ? 'rgba(180, 130, 255, 0.1)' : 'rgba(180, 130, 255, 0.35)';
516+
ctx.lineWidth = 1.5;
517+
ctx.beginPath();
518+
ctx.arc(gp.x, gp.y, 10, 0, Math.PI * 2);
519+
ctx.fill();
520+
ctx.stroke();
521+
522+
// "G" label and strikethrough when ignored
523+
ctx.fillStyle = isIgnored ? 'rgba(180, 130, 255, 0.4)' : 'rgba(180, 130, 255, 0.9)';
524+
ctx.font = 'bold 8px monospace';
525+
ctx.textAlign = 'center';
526+
ctx.fillText('G', gp.x, gp.y + 3);
527+
if (isIgnored) {
528+
ctx.strokeStyle = 'rgba(255, 100, 100, 0.7)';
529+
ctx.lineWidth = 1;
530+
ctx.beginPath();
531+
ctx.moveTo(gp.x - 6, gp.y + 4);
532+
ctx.lineTo(gp.x + 6, gp.y - 4);
533+
ctx.stroke();
534+
}
535+
}
536+
}
537+
502538
// Fuel cost indicator
503539
if (burn !== null) {
504540
ctx.fillStyle = '#ffcc00';
@@ -673,7 +709,24 @@ export class Renderer {
673709
const color = ord.owner === this.playerId ? '#4fc3f7' : '#ff9800';
674710
const pulse = 0.6 + 0.3 * Math.sin(now / 400);
675711

676-
if (ord.type === 'mine') {
712+
if (ord.type === 'nuke') {
713+
// Nuke: larger pulsing red diamond with glow
714+
const s = 6;
715+
const nukeColor = '#ff4444';
716+
ctx.fillStyle = nukeColor;
717+
ctx.globalAlpha = pulse;
718+
ctx.beginPath();
719+
ctx.moveTo(p.x, p.y - s);
720+
ctx.lineTo(p.x + s, p.y);
721+
ctx.lineTo(p.x, p.y + s);
722+
ctx.lineTo(p.x - s, p.y);
723+
ctx.closePath();
724+
ctx.fill();
725+
ctx.strokeStyle = '#ff8888';
726+
ctx.lineWidth = 1;
727+
ctx.stroke();
728+
ctx.globalAlpha = 1;
729+
} else if (ord.type === 'mine') {
677730
// Mine: small diamond shape
678731
const s = 4;
679732
ctx.fillStyle = color;
@@ -725,6 +778,55 @@ export class Renderer {
725778
}
726779
}
727780

781+
private renderTorpedoGuidance(ctx: CanvasRenderingContext2D, state: GameState, now: number) {
782+
if (state.phase !== 'ordnance' || state.activePlayer !== this.playerId) return;
783+
if (this.animState) return;
784+
785+
const selectedId = this.planningState.selectedShipId;
786+
if (!selectedId) return;
787+
788+
const ship = state.ships.find(s => s.id === selectedId);
789+
if (!ship || ship.destroyed || ship.landed) return;
790+
791+
// Only show for warships (torpedo-capable)
792+
const stats = SHIP_STATS[ship.type];
793+
if (!stats?.canOverload) return;
794+
795+
const shipPos = hexToPixel(ship.position, HEX_SIZE);
796+
const accel = this.planningState.torpedoAccel;
797+
798+
// Show 6 direction arrows around the ship for torpedo terminal guidance
799+
for (let d = 0; d < 6; d++) {
800+
const targetHex = hexAdd(ship.position, HEX_DIRECTIONS[d]);
801+
const tp = hexToPixel(targetHex, HEX_SIZE);
802+
const isActive = accel === d;
803+
804+
ctx.fillStyle = isActive ? 'rgba(255, 120, 60, 0.6)' : 'rgba(255, 120, 60, 0.12)';
805+
ctx.strokeStyle = isActive ? '#ff7744' : 'rgba(255, 120, 60, 0.3)';
806+
ctx.lineWidth = 1.5;
807+
ctx.beginPath();
808+
ctx.arc(tp.x, tp.y, 7, 0, Math.PI * 2);
809+
ctx.fill();
810+
ctx.stroke();
811+
812+
// Arrow from ship to target direction
813+
if (isActive) {
814+
ctx.strokeStyle = 'rgba(255, 120, 60, 0.5)';
815+
ctx.lineWidth = 1;
816+
ctx.beginPath();
817+
ctx.moveTo(shipPos.x, shipPos.y);
818+
ctx.lineTo(tp.x, tp.y);
819+
ctx.stroke();
820+
}
821+
}
822+
823+
// Label
824+
ctx.fillStyle = 'rgba(255, 120, 60, 0.8)';
825+
ctx.font = '8px monospace';
826+
ctx.textAlign = 'center';
827+
ctx.fillText('GUIDANCE', shipPos.x, shipPos.y - 20);
828+
}
829+
728830
private renderCombatOverlay(ctx: CanvasRenderingContext2D, state: GameState, now: number) {
729831
if (state.phase !== 'combat' || state.activePlayer !== this.playerId) return;
730832
if (this.animState) return;

src/client/ui.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ export class UIManager {
1313
onSelectScenario: ((scenario: string) => void) | null = null;
1414
onJoin: ((code: string) => void) | null = null;
1515
onConfirm: (() => void) | null = null;
16-
onLaunchOrdnance: ((type: 'mine' | 'torpedo') => void) | null = null;
16+
onLaunchOrdnance: ((type: 'mine' | 'torpedo' | 'nuke') => void) | null = null;
1717
onSkipOrdnance: (() => void) | null = null;
1818
onAttack: (() => void) | null = null;
1919
onSkipCombat: (() => void) | null = null;
@@ -72,6 +72,7 @@ export class UIManager {
7272
document.getElementById('confirmBtn')!.addEventListener('click', () => this.onConfirm?.());
7373
document.getElementById('launchMineBtn')!.addEventListener('click', () => this.onLaunchOrdnance?.('mine'));
7474
document.getElementById('launchTorpedoBtn')!.addEventListener('click', () => this.onLaunchOrdnance?.('torpedo'));
75+
document.getElementById('launchNukeBtn')!.addEventListener('click', () => this.onLaunchOrdnance?.('nuke'));
7576
document.getElementById('skipOrdnanceBtn')!.addEventListener('click', () => this.onSkipOrdnance?.());
7677
document.getElementById('attackBtn')!.addEventListener('click', () => this.onAttack?.());
7778
document.getElementById('skipCombatBtn')!.addEventListener('click', () => this.onSkipCombat?.());
@@ -128,9 +129,11 @@ export class UIManager {
128129

129130
const launchMineBtn = document.getElementById('launchMineBtn')!;
130131
const launchTorpedoBtn = document.getElementById('launchTorpedoBtn')!;
132+
const launchNukeBtn = document.getElementById('launchNukeBtn')!;
131133
const skipOrdnanceBtn = document.getElementById('skipOrdnanceBtn')!;
132134
launchMineBtn.style.display = isMyTurn && phase === 'ordnance' ? 'inline-block' : 'none';
133135
launchTorpedoBtn.style.display = isMyTurn && phase === 'ordnance' ? 'inline-block' : 'none';
136+
launchNukeBtn.style.display = isMyTurn && phase === 'ordnance' ? 'inline-block' : 'none';
134137
skipOrdnanceBtn.style.display = isMyTurn && phase === 'ordnance' ? 'inline-block' : 'none';
135138

136139
const skipCombatBtn = document.getElementById('skipCombatBtn')!;

src/server/game-do.ts

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -225,14 +225,20 @@ export class GameDO extends DurableObject {
225225
const gameState = await this.getGameState();
226226
if (!gameState) return;
227227

228-
const result = skipCombat(gameState, playerId);
228+
const map = getSolarSystemMap();
229+
const result = skipCombat(gameState, playerId, map);
229230

230231
if ('error' in result) {
231232
this.send(ws, { type: 'error', message: result.error });
232233
return;
233234
}
234235

235-
this.broadcast({ type: 'stateUpdate', state: result.state });
236+
// If base defense fire happened, send as combat results
237+
if (result.baseDefenseResults && result.baseDefenseResults.length > 0) {
238+
this.broadcast({ type: 'combatResult', results: result.baseDefenseResults, state: result.state });
239+
}
240+
241+
this.broadcastEndOrUpdate(result.state);
236242
await this.saveGameState(result.state);
237243
}
238244

0 commit comments

Comments
 (0)