Skip to content

Commit 2fb3014

Browse files
committed
Add combat target selection UI with visual feedback
- Pulsing red rings on enemy ships during combat phase - Attack lines drawn from player ships to selected target - Odds preview box showing combat odds, range and velocity modifiers - Combat results toast with color-coded outcomes - ATTACK button appears when target selected, SKIP COMBAT always available - Click enemy ship to target, click again or empty space to deselect
1 parent 015e762 commit 2fb3014

File tree

6 files changed

+215
-5
lines changed

6 files changed

+215
-5
lines changed

src/client/input.ts

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -140,12 +140,18 @@ export class InputHandler {
140140

141141
private handleClick(screenX: number, screenY: number) {
142142
if (!this.gameState || !this.map) return;
143-
if (this.gameState.phase !== 'astrogation') return;
144143
if (this.gameState.activePlayer !== this.playerId) return;
145144

146145
const worldPos = this.camera.screenToWorld(screenX, screenY);
147146
const clickHex = pixelToHex(worldPos, HEX_SIZE);
148147

148+
if (this.gameState.phase === 'combat') {
149+
this.handleCombatClick(clickHex);
150+
return;
151+
}
152+
153+
if (this.gameState.phase !== 'astrogation') return;
154+
149155
// Check if clicking a burn direction arrow
150156
if (this.planningState.selectedShipId) {
151157
const ship = this.gameState.ships.find(s => s.id === this.planningState.selectedShipId);
@@ -181,4 +187,22 @@ export class InputHandler {
181187
// Clicked empty space — deselect
182188
this.planningState.selectedShipId = null;
183189
}
190+
191+
private handleCombatClick(clickHex: HexCoord) {
192+
if (!this.gameState) return;
193+
194+
// Click an enemy ship to target it
195+
for (const ship of this.gameState.ships) {
196+
if (ship.owner === this.playerId || ship.destroyed) continue;
197+
if (hexEqual(clickHex, ship.position)) {
198+
// Toggle: click same target = deselect
199+
this.planningState.combatTargetId =
200+
this.planningState.combatTargetId === ship.id ? null : ship.id;
201+
return;
202+
}
203+
}
204+
205+
// Clicked empty space — deselect target
206+
this.planningState.combatTargetId = null;
207+
}
184208
}

src/client/main.ts

Lines changed: 38 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
1-
import type { GameState, S2C, AstrogationOrder, ShipMovement } from '../shared/types';
1+
import type { GameState, S2C, AstrogationOrder, CombatAttack } from '../shared/types';
2+
import { canAttack } from '../shared/combat';
23
import { getSolarSystemMap } from '../shared/map-data';
34
import { SHIP_STATS } from '../shared/constants';
45
import { Renderer } from './renderer';
@@ -41,6 +42,7 @@ class GameClient {
4142
this.ui.onCreate = () => this.createGame();
4243
this.ui.onJoin = (code) => this.joinGame(code);
4344
this.ui.onConfirm = () => this.confirmOrders();
45+
this.ui.onAttack = () => this.sendAttack();
4446
this.ui.onSkipCombat = () => this.sendSkipCombat();
4547
this.ui.onRematch = () => this.sendRematch();
4648
this.ui.onExit = () => this.exitToMenu();
@@ -95,6 +97,9 @@ class GameClient {
9597
case 'playing_combat':
9698
this.ui.showHUD();
9799
this.updateHUD();
100+
this.renderer.planningState.combatTargetId = null;
101+
this.ui.showAttackButton(false);
102+
this.startCombatTargetWatch();
98103
break;
99104

100105
case 'playing_movementAnim':
@@ -191,7 +196,8 @@ class GameClient {
191196
this.gameState = this.deserializeState(msg.state);
192197
this.renderer.setGameState(this.gameState);
193198
this.input.setGameState(this.gameState);
194-
// After combat resolves, transition based on new state
199+
this.renderer.showCombatResults(msg.results);
200+
this.renderer.planningState.combatTargetId = null;
195201
this.transitionToPhase();
196202
break;
197203

@@ -270,6 +276,36 @@ class GameClient {
270276
}
271277
}
272278

279+
private sendAttack() {
280+
if (!this.gameState || this.state !== 'playing_combat') return;
281+
const targetId = this.renderer.planningState.combatTargetId;
282+
if (!targetId) return;
283+
284+
const attackerIds = this.gameState.ships
285+
.filter(s => s.owner === this.playerId && !s.destroyed && canAttack(s))
286+
.map(s => s.id);
287+
288+
if (attackerIds.length === 0) return;
289+
290+
const attacks: CombatAttack[] = [{ attackerIds, targetId }];
291+
this.send({ type: 'combat', attacks });
292+
}
293+
294+
private combatWatchInterval: number | null = null;
295+
296+
private startCombatTargetWatch() {
297+
if (this.combatWatchInterval) clearInterval(this.combatWatchInterval);
298+
this.combatWatchInterval = window.setInterval(() => {
299+
if (this.state !== 'playing_combat') {
300+
if (this.combatWatchInterval) clearInterval(this.combatWatchInterval);
301+
this.combatWatchInterval = null;
302+
return;
303+
}
304+
const hasTarget = this.renderer.planningState.combatTargetId !== null;
305+
this.ui.showAttackButton(hasTarget);
306+
}, 100);
307+
}
308+
273309
private sendSkipCombat() {
274310
if (!this.gameState || this.state !== 'playing_combat') return;
275311
this.send({ type: 'skipCombat' });

src/client/renderer.ts

Lines changed: 133 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,9 +8,10 @@ import {
88
HEX_DIRECTIONS,
99
hexVecLength,
1010
} from '../shared/hex';
11-
import type { GameState, Ship, ShipMovement, SolarSystemMap, CelestialBody } from '../shared/types';
11+
import type { GameState, Ship, ShipMovement, SolarSystemMap, CelestialBody, CombatResult } from '../shared/types';
1212
import { MOVEMENT_ANIM_DURATION, CAMERA_LERP_SPEED } from '../shared/constants';
1313
import { computeCourse, predictDestination } from '../shared/movement';
14+
import { computeOdds, computeRangeMod, computeVelocityMod, getCombatStrength, canAttack } from '../shared/combat';
1415

1516
// --- Camera ---
1617

@@ -131,6 +132,7 @@ export interface AnimationState {
131132
export interface PlanningState {
132133
selectedShipId: string | null;
133134
burns: Map<string, number | null>; // shipId -> burn direction (or null for no burn)
135+
combatTargetId: string | null; // enemy ship targeted for combat
134136
}
135137

136138
// --- Renderer ---
@@ -146,7 +148,8 @@ export class Renderer {
146148
private gameState: GameState | null = null;
147149
private playerId = -1;
148150
private animState: AnimationState | null = null;
149-
planningState: PlanningState = { selectedShipId: null, burns: new Map() };
151+
planningState: PlanningState = { selectedShipId: null, burns: new Map(), combatTargetId: null };
152+
private combatResults: { results: CombatResult[]; showUntil: number } | null = null;
150153
private lastTime = 0;
151154

152155
constructor(canvas: HTMLCanvasElement) {
@@ -192,6 +195,10 @@ export class Renderer {
192195
}
193196
}
194197

198+
showCombatResults(results: CombatResult[]) {
199+
this.combatResults = { results, showUntil: performance.now() + 3000 };
200+
}
201+
195202
isAnimating(): boolean {
196203
return this.animState !== null;
197204
}
@@ -269,10 +276,20 @@ export class Renderer {
269276
}
270277
if (this.gameState && this.map) {
271278
this.renderCourseVectors(ctx, this.gameState, this.map, now);
279+
this.renderCombatOverlay(ctx, this.gameState, now);
272280
this.renderShips(ctx, this.gameState, now);
273281
}
274282

275283
ctx.restore();
284+
285+
// Combat results toast (screen-space)
286+
if (this.combatResults && this.gameState) {
287+
if (now > this.combatResults.showUntil) {
288+
this.combatResults = null;
289+
} else {
290+
this.renderCombatResultsToast(ctx, this.combatResults.results, now, w);
291+
}
292+
}
276293
}
277294

278295
// --- Render layers ---
@@ -586,6 +603,120 @@ export class Renderer {
586603
y: from.y + (to.y - from.y) * segT,
587604
};
588605
}
606+
private renderCombatOverlay(ctx: CanvasRenderingContext2D, state: GameState, now: number) {
607+
if (state.phase !== 'combat' || state.activePlayer !== this.playerId) return;
608+
if (this.animState) return;
609+
610+
const targetId = this.planningState.combatTargetId;
611+
const target = targetId ? state.ships.find(s => s.id === targetId) : null;
612+
613+
// Highlight valid enemy targets
614+
for (const ship of state.ships) {
615+
if (ship.owner === this.playerId || ship.destroyed) continue;
616+
const p = hexToPixel(ship.position, HEX_SIZE);
617+
const isTarget = ship.id === targetId;
618+
619+
// Pulsing ring on enemies
620+
const pulse = 0.5 + 0.3 * Math.sin(now / 300);
621+
ctx.strokeStyle = isTarget
622+
? `rgba(255, 80, 80, ${0.8 + pulse * 0.2})`
623+
: `rgba(255, 80, 80, ${0.2 + pulse * 0.15})`;
624+
ctx.lineWidth = isTarget ? 2.5 : 1.5;
625+
ctx.beginPath();
626+
ctx.arc(p.x, p.y, isTarget ? 16 : 13, 0, Math.PI * 2);
627+
ctx.stroke();
628+
}
629+
630+
// Draw attack line and odds preview
631+
if (target && !target.destroyed) {
632+
const myAttackers = state.ships.filter(
633+
s => s.owner === this.playerId && !s.destroyed && canAttack(s),
634+
);
635+
if (myAttackers.length === 0) return;
636+
637+
const targetPos = hexToPixel(target.position, HEX_SIZE);
638+
639+
// Attack lines from each attacker
640+
for (const attacker of myAttackers) {
641+
const attackerPos = hexToPixel(attacker.position, HEX_SIZE);
642+
ctx.strokeStyle = 'rgba(255, 80, 80, 0.4)';
643+
ctx.lineWidth = 1.5;
644+
ctx.setLineDash([6, 4]);
645+
ctx.beginPath();
646+
ctx.moveTo(attackerPos.x, attackerPos.y);
647+
ctx.lineTo(targetPos.x, targetPos.y);
648+
ctx.stroke();
649+
ctx.setLineDash([]);
650+
}
651+
652+
// Odds preview at target
653+
const attackStr = getCombatStrength(myAttackers);
654+
const defendStr = getCombatStrength([target]);
655+
const odds = computeOdds(attackStr, defendStr);
656+
const rangeMod = computeRangeMod(myAttackers[0], target);
657+
const velMod = computeVelocityMod(myAttackers[0], target);
658+
659+
// Background box
660+
const label = `${odds} R-${rangeMod} V-${velMod}`;
661+
ctx.font = 'bold 10px monospace';
662+
const textW = ctx.measureText(label).width;
663+
ctx.fillStyle = 'rgba(0, 0, 0, 0.7)';
664+
ctx.fillRect(targetPos.x - textW / 2 - 4, targetPos.y - 32, textW + 8, 16);
665+
ctx.fillStyle = '#ff6666';
666+
ctx.textAlign = 'center';
667+
ctx.fillText(label, targetPos.x, targetPos.y - 20);
668+
}
669+
}
670+
671+
private renderCombatResultsToast(ctx: CanvasRenderingContext2D, results: CombatResult[], now: number, screenW: number) {
672+
if (results.length === 0) return;
673+
const fadeStart = this.combatResults!.showUntil - 1000;
674+
const alpha = now > fadeStart ? Math.max(0, (this.combatResults!.showUntil - now) / 1000) : 1;
675+
676+
ctx.save();
677+
ctx.globalAlpha = alpha;
678+
679+
let y = 60;
680+
for (const r of results) {
681+
const text = formatCombatResult(r, this.gameState!);
682+
ctx.font = 'bold 12px monospace';
683+
const w = ctx.measureText(text).width;
684+
const x = screenW / 2;
685+
686+
ctx.fillStyle = 'rgba(0, 0, 0, 0.75)';
687+
ctx.fillRect(x - w / 2 - 8, y - 12, w + 16, 20);
688+
ctx.fillStyle = r.damageType === 'eliminated' ? '#ff4444'
689+
: r.damageType === 'disabled' ? '#ffaa00'
690+
: '#88ff88';
691+
ctx.textAlign = 'center';
692+
ctx.fillText(text, x, y + 2);
693+
y += 26;
694+
695+
if (r.counterattack) {
696+
const cText = formatCombatResult(r.counterattack, this.gameState!);
697+
ctx.font = '11px monospace';
698+
const cw = ctx.measureText(cText).width;
699+
ctx.fillStyle = 'rgba(0, 0, 0, 0.65)';
700+
ctx.fillRect(x - cw / 2 - 8, y - 12, cw + 16, 18);
701+
ctx.fillStyle = r.counterattack.damageType === 'eliminated' ? '#ff4444'
702+
: r.counterattack.damageType === 'disabled' ? '#ffaa00'
703+
: '#88ff88';
704+
ctx.fillText(cText, x, y + 2);
705+
y += 24;
706+
}
707+
}
708+
709+
ctx.restore();
710+
}
711+
}
712+
713+
function formatCombatResult(r: CombatResult, state: GameState): string {
714+
const targetShip = state.ships.find(s => s.id === r.targetId);
715+
const targetName = targetShip ? `${targetShip.type}` : r.targetId;
716+
const result = r.damageType === 'eliminated' ? 'ELIMINATED'
717+
: r.damageType === 'disabled' ? `DISABLED ${r.disabledTurns}T`
718+
: 'MISS';
719+
return `${r.odds} [${r.dieRoll}${r.modifiedRoll}] ${targetName}: ${result}`;
589720
}
590721

591722
// --- Utility ---

src/client/ui.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ export class UIManager {
88
onCreate: (() => void) | null = null;
99
onJoin: ((code: string) => void) | null = null;
1010
onConfirm: (() => void) | null = null;
11+
onAttack: (() => void) | null = null;
1112
onSkipCombat: (() => void) | null = null;
1213
onRematch: (() => void) | null = null;
1314
onExit: (() => void) | null = null;
@@ -45,6 +46,7 @@ export class UIManager {
4546
});
4647

4748
document.getElementById('confirmBtn')!.addEventListener('click', () => this.onConfirm?.());
49+
document.getElementById('attackBtn')!.addEventListener('click', () => this.onAttack?.());
4850
document.getElementById('skipCombatBtn')!.addEventListener('click', () => this.onSkipCombat?.());
4951
document.getElementById('rematchBtn')!.addEventListener('click', () => this.onRematch?.());
5052
document.getElementById('exitBtn')!.addEventListener('click', () => this.onExit?.());
@@ -107,6 +109,10 @@ export class UIManager {
107109
}
108110
}
109111

112+
showAttackButton(visible: boolean) {
113+
document.getElementById('attackBtn')!.style.display = visible ? 'inline-block' : 'none';
114+
}
115+
110116
showMovementStatus() {
111117
const statusMsg = document.getElementById('statusMsg')!;
112118
statusMsg.textContent = 'Ships moving...';

static/index.html

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@ <h2>Game Created</h2>
4242
</div>
4343
<div id="bottomBar" class="hud-bottom">
4444
<button id="confirmBtn" class="btn btn-confirm" style="display:none">CONFIRM</button>
45+
<button id="attackBtn" class="btn btn-attack" style="display:none">ATTACK</button>
4546
<button id="skipCombatBtn" class="btn btn-confirm" style="display:none">SKIP COMBAT</button>
4647
<div id="statusMsg" class="status-msg"></div>
4748
</div>

static/style.css

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -108,6 +108,18 @@ body {
108108
background: rgba(102, 187, 106, 0.15);
109109
}
110110

111+
.btn-attack {
112+
border-color: #ff5252;
113+
color: #ff5252;
114+
padding: 0.6rem 2.5rem;
115+
font-size: 1rem;
116+
letter-spacing: 0.15em;
117+
}
118+
119+
.btn-attack:hover {
120+
background: rgba(255, 82, 82, 0.15);
121+
}
122+
111123
.divider {
112124
color: #555;
113125
margin: 1.2rem 0;

0 commit comments

Comments
 (0)