Skip to content

Commit 9f0e5eb

Browse files
committed
Add ship damage visuals, turn timer, detection ranges, and combat UX improvements
- Damaged ships now show flickering red/orange glow (intensity scales with damage) - Ship icons sized by combat value (dreadnaughts/frigates visually larger) - Turn timer shows elapsed time during player's turn (amber after 30s) - Torpedo/nuke buttons disabled for non-warship ships with tooltip hint - Detection range circle shown for selected ship (dashed line) - Base detection ranges shown as subtle circles - Combat overlay now shows total modifier with color coding (green/red/yellow) - Counterattack warning label shown on targetable enemies
1 parent 0ad71ea commit 9f0e5eb

File tree

5 files changed

+135
-11
lines changed

5 files changed

+135
-11
lines changed

src/client/main.ts

Lines changed: 29 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@ class GameClient {
4545

4646
// Turn timer
4747
private turnStartTime = 0;
48+
private turnTimerInterval: number | null = null;
4849

4950
constructor() {
5051
this.canvas = document.getElementById('gameCanvas') as HTMLCanvasElement;
@@ -192,7 +193,7 @@ class GameClient {
192193

193194
case 'playing_astrogation':
194195
this.ui.showHUD();
195-
this.turnStartTime = Date.now();
196+
this.startTurnTimer();
196197
this.updateHUD();
197198
// Reset planning state
198199
this.renderer.planningState.selectedShipId = null;
@@ -234,17 +235,20 @@ class GameClient {
234235
break;
235236

236237
case 'playing_movementAnim':
238+
this.stopTurnTimer();
237239
this.ui.showHUD();
238240
this.ui.showMovementStatus();
239241
break;
240242

241243
case 'playing_opponentTurn':
244+
this.stopTurnTimer();
242245
this.ui.showHUD();
243246
this.updateHUD();
244247
this.renderer.frameOnShips();
245248
break;
246249

247250
case 'gameOver':
251+
this.stopTurnTimer();
248252
// gameOver overlay is shown via showGameOver
249253
break;
250254
}
@@ -646,6 +650,7 @@ class GameClient {
646650

647651
private exitToMenu() {
648652
this.stopPing();
653+
this.stopTurnTimer();
649654
this.ws?.close();
650655
this.ws = null;
651656
this.gameState = null;
@@ -941,6 +946,7 @@ class GameClient {
941946
cargoFree,
942947
cargoMax,
943948
objective,
949+
stats?.canOverload ?? false,
944950
);
945951
// Update latency display (multiplayer only)
946952
const latencyEl = document.getElementById('latencyInfo')!;
@@ -996,6 +1002,28 @@ class GameClient {
9961002
btn.classList.toggle('muted', m);
9971003
}
9981004

1005+
private startTurnTimer() {
1006+
this.stopTurnTimer();
1007+
this.turnStartTime = Date.now();
1008+
const timerEl = document.getElementById('turnTimer')!;
1009+
this.turnTimerInterval = window.setInterval(() => {
1010+
const elapsed = Math.floor((Date.now() - this.turnStartTime) / 1000);
1011+
const mins = Math.floor(elapsed / 60);
1012+
const secs = elapsed % 60;
1013+
timerEl.textContent = mins > 0 ? `${mins}:${secs.toString().padStart(2, '0')}` : `${secs}s`;
1014+
timerEl.className = 'turn-timer' + (elapsed >= 30 ? ' turn-timer-slow' : ' turn-timer-active');
1015+
}, 1000);
1016+
}
1017+
1018+
private stopTurnTimer() {
1019+
if (this.turnTimerInterval !== null) {
1020+
clearInterval(this.turnTimerInterval);
1021+
this.turnTimerInterval = null;
1022+
}
1023+
const timerEl = document.getElementById('turnTimer');
1024+
if (timerEl) timerEl.textContent = '';
1025+
}
1026+
9991027
private logLandings(movements: ShipMovement[]) {
10001028
if (!this.gameState) return;
10011029
for (const m of movements) {

src/client/renderer.ts

Lines changed: 84 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ import {
99
hexVecLength,
1010
} from '../shared/hex';
1111
import type { GameState, Ship, ShipMovement, OrdnanceMovement, MovementEvent, SolarSystemMap, CelestialBody, CombatResult, PlayerState } from '../shared/types';
12-
import { MOVEMENT_ANIM_DURATION, CAMERA_LERP_SPEED, SHIP_STATS } from '../shared/constants';
12+
import { MOVEMENT_ANIM_DURATION, CAMERA_LERP_SPEED, SHIP_STATS, SHIP_DETECTION_RANGE, BASE_DETECTION_RANGE } from '../shared/constants';
1313
import { computeCourse, predictDestination } from '../shared/movement';
1414
import { computeOdds, computeRangeMod, computeVelocityMod, getCombatStrength, canAttack } from '../shared/combat';
1515

@@ -436,6 +436,7 @@ export class Renderer {
436436
}
437437
}
438438
if (this.gameState && this.map) {
439+
this.renderDetectionRanges(ctx, this.gameState, this.map);
439440
this.renderCourseVectors(ctx, this.gameState, this.map, now);
440441
this.renderOrdnance(ctx, this.gameState, now);
441442
this.renderTorpedoGuidance(ctx, this.gameState, now);
@@ -690,6 +691,56 @@ export class Renderer {
690691
ctx.fillText('▼ TARGET', p.x, p.y + r + 24);
691692
}
692693

694+
private renderDetectionRanges(ctx: CanvasRenderingContext2D, state: GameState, map: SolarSystemMap) {
695+
if (this.animState) return;
696+
697+
// Show detection range for selected own ship
698+
const selectedId = this.planningState.selectedShipId;
699+
for (const ship of state.ships) {
700+
if (ship.owner !== this.playerId || ship.destroyed) continue;
701+
const isSelected = ship.id === selectedId;
702+
if (!isSelected) continue; // Only show for selected ship to avoid clutter
703+
704+
const p = hexToPixel(ship.position, HEX_SIZE);
705+
const detRange = SHIP_DETECTION_RANGE;
706+
// Approximate circle radius from hex distance
707+
const radius = detRange * HEX_SIZE * 1.73; // sqrt(3) * hex_size * range
708+
709+
ctx.strokeStyle = 'rgba(79, 195, 247, 0.08)';
710+
ctx.lineWidth = 1;
711+
ctx.setLineDash([4, 6]);
712+
ctx.beginPath();
713+
ctx.arc(p.x, p.y, radius, 0, Math.PI * 2);
714+
ctx.stroke();
715+
ctx.setLineDash([]);
716+
717+
// Subtle label
718+
ctx.fillStyle = 'rgba(79, 195, 247, 0.15)';
719+
ctx.font = '7px monospace';
720+
ctx.textAlign = 'center';
721+
ctx.fillText('DETECTION', p.x, p.y - radius - 4);
722+
}
723+
724+
// Show base detection ranges for own bases
725+
const player = state.players[this.playerId];
726+
if (player?.homeBody) {
727+
for (const [key, hex] of map.hexes) {
728+
if (!hex.base || hex.base.bodyName !== player.homeBody) continue;
729+
const [q, r] = key.split(',').map(Number);
730+
const p = hexToPixel({ q, r }, HEX_SIZE);
731+
const radius = BASE_DETECTION_RANGE * HEX_SIZE * 1.73;
732+
733+
ctx.strokeStyle = 'rgba(79, 195, 247, 0.05)';
734+
ctx.lineWidth = 1;
735+
ctx.setLineDash([3, 8]);
736+
ctx.beginPath();
737+
ctx.arc(p.x, p.y, radius, 0, Math.PI * 2);
738+
ctx.stroke();
739+
ctx.setLineDash([]);
740+
}
741+
}
742+
}
743+
693744
private renderCourseVectors(ctx: CanvasRenderingContext2D, state: GameState, map: SolarSystemMap, now: number) {
694745
// During animation, don't show planning vectors
695746
if (this.animState) return;
@@ -976,7 +1027,7 @@ export class Renderer {
9761027
// Disabled ships shown dimmer
9771028
const isDisabled = ship.damage.disabledTurns > 0;
9781029
const alpha = isDisabled ? 0.5 : 1.0;
979-
this.drawShipIcon(ctx, pos.x, pos.y, ship.owner, alpha, heading);
1030+
this.drawShipIcon(ctx, pos.x, pos.y, ship.owner, alpha, heading, ship.damage.disabledTurns, ship.type);
9801031

9811032
// Disabled indicator
9821033
if (isDisabled && !this.animState) {
@@ -1008,12 +1059,28 @@ export class Renderer {
10081059
}
10091060
}
10101061

1011-
private drawShipIcon(ctx: CanvasRenderingContext2D, x: number, y: number, owner: number, alpha: number, heading: number) {
1062+
private drawShipIcon(ctx: CanvasRenderingContext2D, x: number, y: number, owner: number, alpha: number, heading: number, disabledTurns = 0, shipType = '') {
10121063
const color = owner === 0 ? `rgba(79, 195, 247, ${alpha})` : `rgba(255, 152, 0, ${alpha})`;
1013-
const size = 8;
1064+
// Size based on ship type combat value
1065+
const stats = SHIP_STATS[shipType];
1066+
const combat = stats?.combat ?? 2;
1067+
const size = combat >= 15 ? 12 : combat >= 8 ? 10 : combat >= 4 ? 9 : 8;
10141068

10151069
ctx.save();
10161070
ctx.translate(x, y);
1071+
1072+
// Damage glow for disabled ships (flickering red/orange)
1073+
if (disabledTurns > 0) {
1074+
const flickerPhase = performance.now() / 200 + x * 0.1; // unique per ship
1075+
const intensity = 0.3 + 0.2 * Math.sin(flickerPhase) + 0.1 * Math.sin(flickerPhase * 2.7);
1076+
const glowColor = disabledTurns >= 4 ? `rgba(255, 50, 50, ${intensity})` : `rgba(255, 150, 50, ${intensity})`;
1077+
const glowRadius = 10 + disabledTurns;
1078+
ctx.fillStyle = glowColor;
1079+
ctx.beginPath();
1080+
ctx.arc(0, 0, glowRadius, 0, Math.PI * 2);
1081+
ctx.fill();
1082+
}
1083+
10171084
ctx.rotate(heading);
10181085
ctx.fillStyle = color;
10191086
ctx.beginPath();
@@ -1299,16 +1366,27 @@ export class Renderer {
12991366
const odds = computeOdds(attackStr, defendStr);
13001367
const rangeMod = computeRangeMod(myAttackers[0], target);
13011368
const velMod = computeVelocityMod(myAttackers[0], target);
1369+
const totalMod = -(rangeMod + velMod);
13021370

13031371
// Background box
1304-
const label = `${odds} R-${rangeMod} V-${velMod}`;
1372+
const modStr = totalMod >= 0 ? `+${totalMod}` : `${totalMod}`;
1373+
const label = `${odds} R-${rangeMod} V-${velMod} (${modStr})`;
13051374
ctx.font = 'bold 10px monospace';
13061375
const textW = ctx.measureText(label).width;
13071376
ctx.fillStyle = 'rgba(0, 0, 0, 0.7)';
13081377
ctx.fillRect(targetPos.x - textW / 2 - 4, targetPos.y - 32, textW + 8, 16);
1309-
ctx.fillStyle = '#ff6666';
1378+
// Color code: green if favorable, red if unfavorable, yellow if neutral
1379+
ctx.fillStyle = totalMod > 0 ? '#88ff88' : totalMod < 0 ? '#ff6666' : '#ffdd57';
13101380
ctx.textAlign = 'center';
13111381
ctx.fillText(label, targetPos.x, targetPos.y - 20);
1382+
1383+
// Show counterattack warning if target can counterattack
1384+
const targetStats = SHIP_STATS[target.type];
1385+
if (targetStats && !targetStats.defensiveOnly && target.damage.disabledTurns === 0) {
1386+
ctx.fillStyle = 'rgba(255, 170, 0, 0.7)';
1387+
ctx.font = '7px monospace';
1388+
ctx.fillText('CAN COUNTER', targetPos.x, targetPos.y - 38);
1389+
}
13121390
}
13131391
}
13141392

src/client/ui.ts

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -191,7 +191,7 @@ export class UIManager {
191191
}
192192
}
193193

194-
updateHUD(turn: number, phase: string, isMyTurn: boolean, fuel: number, maxFuel: number, hasBurns = false, cargoFree = 0, cargoMax = 0, objective = '') {
194+
updateHUD(turn: number, phase: string, isMyTurn: boolean, fuel: number, maxFuel: number, hasBurns = false, cargoFree = 0, cargoMax = 0, objective = '', isWarship = false) {
195195
document.getElementById('turnInfo')!.textContent = `Turn ${turn}`;
196196
document.getElementById('phaseInfo')!.textContent = isMyTurn ? phase.toUpperCase() : 'OPPONENT\'S TURN';
197197
document.getElementById('objective')!.textContent = objective;
@@ -217,17 +217,19 @@ export class UIManager {
217217
launchTorpedoBtn.style.display = showOrd ? 'inline-block' : 'none';
218218
launchNukeBtn.style.display = showOrd ? 'inline-block' : 'none';
219219
skipOrdnanceBtn.style.display = showOrd ? 'inline-block' : 'none';
220-
// Disable buttons based on cargo capacity
220+
// Disable buttons based on cargo capacity and warship status
221221
if (showOrd) {
222222
const canMine = cargoFree >= ORDNANCE_MASS.mine;
223-
const canTorpedo = cargoFree >= ORDNANCE_MASS.torpedo;
224-
const canNuke = cargoFree >= ORDNANCE_MASS.nuke;
223+
const canTorpedo = isWarship && cargoFree >= ORDNANCE_MASS.torpedo;
224+
const canNuke = isWarship && cargoFree >= ORDNANCE_MASS.nuke;
225225
launchMineBtn.disabled = !canMine;
226226
launchTorpedoBtn.disabled = !canTorpedo;
227227
launchNukeBtn.disabled = !canNuke;
228228
launchMineBtn.style.opacity = canMine ? '1' : '0.4';
229229
launchTorpedoBtn.style.opacity = canTorpedo ? '1' : '0.4';
230230
launchNukeBtn.style.opacity = canNuke ? '1' : '0.4';
231+
launchTorpedoBtn.title = isWarship ? '' : 'Warships only';
232+
launchNukeBtn.title = isWarship ? '' : 'Warships only';
231233
}
232234

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

static/index.html

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,7 @@ <h2>Game Created</h2>
7373
<span id="fleetStatus" class="fleet-status"></span>
7474
<span id="fuelGauge">Fuel: 20/20</span>
7575
<span id="objective" class="objective-text"></span>
76+
<span id="turnTimer" class="turn-timer"></span>
7677
<span id="latencyInfo" class="latency-text"></span>
7778
</div>
7879
<div id="bottomBar" class="hud-bottom">

static/style.css

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -526,6 +526,21 @@ body {
526526
letter-spacing: 0.08em;
527527
}
528528

529+
/* Turn timer in HUD */
530+
.turn-timer {
531+
color: #888;
532+
font-size: 0.65rem;
533+
letter-spacing: 0.05em;
534+
}
535+
536+
.turn-timer-active {
537+
color: #b0bec5;
538+
}
539+
540+
.turn-timer-slow {
541+
color: #ffb74d;
542+
}
543+
529544
/* Latency indicator in HUD */
530545
.latency-text {
531546
color: #888;

0 commit comments

Comments
 (0)