Skip to content

Commit 37bb36f

Browse files
committed
Add combat visual effects, movement event toasts, and ship details
Visual polish: - Beam lines from attackers to targets during combat, with glow effect - Expanding ring explosions at damaged/eliminated ship positions - Counterattack visual effects with staggered timing - Movement event toasts: asteroid hits, crashes, mine/torpedo/nuke detonations displayed with colored text and fade-out - Ship details panel: selected ship shows combat strength, cargo, velocity, damage, and landed status in the ship list 138 tests passing.
1 parent 55a3c04 commit 37bb36f

File tree

4 files changed

+235
-2
lines changed

4 files changed

+235
-2
lines changed

src/client/main.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -234,6 +234,9 @@ class GameClient {
234234
this.renderer.setGameState(this.gameState);
235235
this.input.setGameState(this.gameState);
236236
this.setState('playing_movementAnim');
237+
if (msg.events.length > 0) {
238+
this.renderer.showMovementEvents(msg.events);
239+
}
237240
this.renderer.animateMovements(msg.movements, msg.ordnanceMovements, () => {
238241
this.onAnimationComplete();
239242
});

src/client/renderer.ts

Lines changed: 207 additions & 2 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, OrdnanceMovement, SolarSystemMap, CelestialBody, CombatResult } from '../shared/types';
11+
import type { GameState, Ship, ShipMovement, OrdnanceMovement, MovementEvent, 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';
@@ -128,6 +128,17 @@ export interface AnimationState {
128128
onComplete: () => void;
129129
}
130130

131+
// --- Combat visual effects ---
132+
133+
interface CombatEffect {
134+
type: 'beam' | 'explosion';
135+
from: PixelCoord;
136+
to: PixelCoord;
137+
startTime: number;
138+
duration: number;
139+
color: string;
140+
}
141+
131142
// --- Planning state (controlled by input handler) ---
132143

133144
export interface PlanningState {
@@ -154,6 +165,8 @@ export class Renderer {
154165
private animState: AnimationState | null = null;
155166
planningState: PlanningState = { selectedShipId: null, burns: new Map(), overloads: new Map(), weakGravityChoices: new Map(), torpedoAccel: null, combatTargetId: null };
156167
private combatResults: { results: CombatResult[]; showUntil: number } | null = null;
168+
private combatEffects: CombatEffect[] = [];
169+
private movementEvents: { events: MovementEvent[]; showUntil: number } | null = null;
157170
private lastTime = 0;
158171

159172
constructor(canvas: HTMLCanvasElement) {
@@ -202,7 +215,73 @@ export class Renderer {
202215
}
203216

204217
showCombatResults(results: CombatResult[]) {
205-
this.combatResults = { results, showUntil: performance.now() + 3000 };
218+
const now = performance.now();
219+
this.combatResults = { results, showUntil: now + 3000 };
220+
221+
// Create visual effects for each combat result
222+
for (const r of results) {
223+
const target = this.gameState?.ships.find(s => s.id === r.targetId);
224+
if (!target) continue;
225+
const targetPos = hexToPixel(target.position, HEX_SIZE);
226+
227+
// Beam from first attacker to target
228+
if (r.attackerIds.length > 0 && !r.attackerIds[0].startsWith('base:')) {
229+
const attacker = this.gameState?.ships.find(s => s.id === r.attackerIds[0]);
230+
if (attacker) {
231+
const attackerPos = hexToPixel(attacker.position, HEX_SIZE);
232+
this.combatEffects.push({
233+
type: 'beam',
234+
from: attackerPos,
235+
to: targetPos,
236+
startTime: now,
237+
duration: 600,
238+
color: r.damageType === 'eliminated' ? '#ff4444' : r.damageType === 'disabled' ? '#ffaa00' : '#4fc3f7',
239+
});
240+
}
241+
}
242+
243+
// Explosion at target for damage
244+
if (r.damageType !== 'none') {
245+
this.combatEffects.push({
246+
type: 'explosion',
247+
from: targetPos,
248+
to: targetPos,
249+
startTime: now + 300, // Delay for beam to reach
250+
duration: 800,
251+
color: r.damageType === 'eliminated' ? '#ff4444' : '#ffaa00',
252+
});
253+
}
254+
255+
// Same for counterattack
256+
if (r.counterattack && r.counterattack.damageType !== 'none') {
257+
const counterTarget = this.gameState?.ships.find(s => s.id === r.counterattack!.targetId);
258+
if (counterTarget) {
259+
const counterPos = hexToPixel(counterTarget.position, HEX_SIZE);
260+
this.combatEffects.push({
261+
type: 'beam',
262+
from: targetPos,
263+
to: counterPos,
264+
startTime: now + 500,
265+
duration: 600,
266+
color: r.counterattack.damageType === 'eliminated' ? '#ff4444' : '#ffaa00',
267+
});
268+
this.combatEffects.push({
269+
type: 'explosion',
270+
from: counterPos,
271+
to: counterPos,
272+
startTime: now + 800,
273+
duration: 800,
274+
color: r.counterattack.damageType === 'eliminated' ? '#ff4444' : '#ffaa00',
275+
});
276+
}
277+
}
278+
}
279+
}
280+
281+
showMovementEvents(events: MovementEvent[]) {
282+
if (events.length > 0) {
283+
this.movementEvents = { events, showUntil: performance.now() + 4000 };
284+
}
206285
}
207286

208287
isAnimating(): boolean {
@@ -292,6 +371,7 @@ export class Renderer {
292371
this.renderTorpedoGuidance(ctx, this.gameState, now);
293372
this.renderCombatOverlay(ctx, this.gameState, now);
294373
this.renderShips(ctx, this.gameState, now);
374+
this.renderCombatEffects(ctx, now);
295375
}
296376

297377
ctx.restore();
@@ -304,6 +384,15 @@ export class Renderer {
304384
this.renderCombatResultsToast(ctx, this.combatResults.results, now, w);
305385
}
306386
}
387+
388+
// Movement events toast (screen-space)
389+
if (this.movementEvents && this.gameState) {
390+
if (now > this.movementEvents.showUntil) {
391+
this.movementEvents = null;
392+
} else {
393+
this.renderMovementEventsToast(ctx, this.movementEvents.events, now, w);
394+
}
395+
}
307396
}
308397

309398
// --- Render layers ---
@@ -942,6 +1031,122 @@ export class Renderer {
9421031
}
9431032
}
9441033

1034+
private renderCombatEffects(ctx: CanvasRenderingContext2D, now: number) {
1035+
// Clean up expired effects
1036+
this.combatEffects = this.combatEffects.filter(e => now < e.startTime + e.duration);
1037+
1038+
for (const effect of this.combatEffects) {
1039+
if (now < effect.startTime) continue; // Not yet started
1040+
const progress = (now - effect.startTime) / effect.duration;
1041+
1042+
if (effect.type === 'beam') {
1043+
// Beam line from attacker to target
1044+
const beamAlpha = 1 - progress;
1045+
const beamProgress = Math.min(progress * 3, 1); // Beam reaches target at 1/3 duration
1046+
1047+
ctx.strokeStyle = effect.color;
1048+
ctx.globalAlpha = beamAlpha * 0.8;
1049+
ctx.lineWidth = 2 * (1 - progress);
1050+
ctx.beginPath();
1051+
ctx.moveTo(effect.from.x, effect.from.y);
1052+
ctx.lineTo(
1053+
effect.from.x + (effect.to.x - effect.from.x) * beamProgress,
1054+
effect.from.y + (effect.to.y - effect.from.y) * beamProgress,
1055+
);
1056+
ctx.stroke();
1057+
1058+
// Glow line
1059+
ctx.globalAlpha = beamAlpha * 0.3;
1060+
ctx.lineWidth = 6 * (1 - progress);
1061+
ctx.beginPath();
1062+
ctx.moveTo(effect.from.x, effect.from.y);
1063+
ctx.lineTo(
1064+
effect.from.x + (effect.to.x - effect.from.x) * beamProgress,
1065+
effect.from.y + (effect.to.y - effect.from.y) * beamProgress,
1066+
);
1067+
ctx.stroke();
1068+
ctx.globalAlpha = 1;
1069+
} else if (effect.type === 'explosion') {
1070+
// Expanding ring explosion
1071+
const maxRadius = 20;
1072+
const radius = maxRadius * progress;
1073+
const alpha = 1 - progress;
1074+
1075+
ctx.strokeStyle = effect.color;
1076+
ctx.lineWidth = 3 * (1 - progress);
1077+
ctx.globalAlpha = alpha * 0.8;
1078+
ctx.beginPath();
1079+
ctx.arc(effect.from.x, effect.from.y, radius, 0, Math.PI * 2);
1080+
ctx.stroke();
1081+
1082+
// Inner flash
1083+
if (progress < 0.3) {
1084+
ctx.fillStyle = effect.color;
1085+
ctx.globalAlpha = (1 - progress / 0.3) * 0.6;
1086+
ctx.beginPath();
1087+
ctx.arc(effect.from.x, effect.from.y, radius * 0.5, 0, Math.PI * 2);
1088+
ctx.fill();
1089+
}
1090+
ctx.globalAlpha = 1;
1091+
}
1092+
}
1093+
}
1094+
1095+
private renderMovementEventsToast(ctx: CanvasRenderingContext2D, events: MovementEvent[], now: number, screenW: number) {
1096+
if (events.length === 0) return;
1097+
const fadeStart = this.movementEvents!.showUntil - 1000;
1098+
const alpha = now > fadeStart ? Math.max(0, (this.movementEvents!.showUntil - now) / 1000) : 1;
1099+
1100+
ctx.save();
1101+
ctx.globalAlpha = alpha;
1102+
1103+
let y = 60;
1104+
for (const ev of events) {
1105+
const ship = this.gameState?.ships.find(s => s.id === ev.shipId);
1106+
const shipName = ship ? ship.type : ev.shipId;
1107+
let text: string;
1108+
let color: string;
1109+
1110+
switch (ev.type) {
1111+
case 'crash':
1112+
text = `${shipName}: CRASHED`;
1113+
color = '#ff4444';
1114+
break;
1115+
case 'asteroidHit':
1116+
text = `${shipName}: Asteroid hit [${ev.dieRoll}] — ${ev.damageType === 'eliminated' ? 'ELIMINATED' : ev.damageType === 'disabled' ? `DISABLED ${ev.disabledTurns}T` : 'MISS'}`;
1117+
color = ev.damageType === 'eliminated' ? '#ff4444' : ev.damageType === 'disabled' ? '#ffaa00' : '#88ff88';
1118+
break;
1119+
case 'mineDetonation':
1120+
text = `Mine hit ${shipName} [${ev.dieRoll}] — ${ev.damageType === 'eliminated' ? 'ELIMINATED' : ev.damageType === 'disabled' ? `DISABLED ${ev.disabledTurns}T` : 'NO EFFECT'}`;
1121+
color = ev.damageType === 'eliminated' ? '#ff4444' : ev.damageType === 'disabled' ? '#ffaa00' : '#88ff88';
1122+
break;
1123+
case 'torpedoHit':
1124+
text = `Torpedo hit ${shipName} [${ev.dieRoll}] — ${ev.damageType === 'eliminated' ? 'ELIMINATED' : ev.damageType === 'disabled' ? `DISABLED ${ev.disabledTurns}T` : 'NO EFFECT'}`;
1125+
color = ev.damageType === 'eliminated' ? '#ff4444' : ev.damageType === 'disabled' ? '#ffaa00' : '#88ff88';
1126+
break;
1127+
case 'nukeDetonation':
1128+
text = `NUKE hit ${shipName} [${ev.dieRoll}] — ${ev.damageType === 'eliminated' ? 'ELIMINATED' : ev.damageType === 'disabled' ? `DISABLED ${ev.disabledTurns}T` : 'NO EFFECT'}`;
1129+
color = ev.damageType === 'eliminated' ? '#ff4444' : ev.damageType === 'disabled' ? '#ffaa00' : '#88ff88';
1130+
break;
1131+
default:
1132+
continue;
1133+
}
1134+
1135+
ctx.font = 'bold 12px monospace';
1136+
const w = ctx.measureText(text).width;
1137+
const x = screenW / 2;
1138+
1139+
ctx.fillStyle = 'rgba(0, 0, 0, 0.75)';
1140+
ctx.fillRect(x - w / 2 - 8, y - 12, w + 16, 20);
1141+
ctx.fillStyle = color;
1142+
ctx.textAlign = 'center';
1143+
ctx.fillText(text, x, y + 2);
1144+
y += 26;
1145+
}
1146+
1147+
ctx.restore();
1148+
}
1149+
9451150
private renderCombatResultsToast(ctx: CanvasRenderingContext2D, results: CombatResult[], now: number, screenW: number) {
9461151
if (results.length === 0) return;
9471152
const fadeStart = this.combatResults!.showUntil - 1000;

src/client/ui.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -194,6 +194,19 @@ export class UIManager {
194194
<span class="ship-fuel">${ship.destroyed ? '' : `${ship.fuel}/${stats?.fuel ?? '?'}`}</span>
195195
`;
196196

197+
// Show expanded details for selected ship
198+
if (ship.id === selectedId && !ship.destroyed && stats) {
199+
const details = document.createElement('div');
200+
details.className = 'ship-details';
201+
const combat = stats.combat + (stats.defensiveOnly ? 'D' : '');
202+
const cargo = stats.cargo > 0 ? `Cargo: ${stats.cargo - ship.cargoUsed}/${stats.cargo}` : '';
203+
const velocity = `Vel: (${ship.velocity.dq},${ship.velocity.dr})`;
204+
const dmg = ship.damage.disabledTurns > 0 ? `Dmg: ${ship.damage.disabledTurns}T` : '';
205+
const status = ship.landed ? 'Landed' : '';
206+
details.innerHTML = `<span>ATK:${combat} ${cargo}</span><span>${velocity} ${dmg} ${status}</span>`;
207+
entry.appendChild(details);
208+
}
209+
197210
if (!ship.destroyed) {
198211
entry.addEventListener('click', () => this.onSelectShip?.(ship.id));
199212
}

static/style.css

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -328,6 +328,18 @@ body {
328328
vertical-align: middle;
329329
}
330330

331+
.ship-details {
332+
width: 100%;
333+
display: flex;
334+
flex-direction: column;
335+
gap: 0.1rem;
336+
font-size: 0.6rem;
337+
color: #888;
338+
padding-top: 0.2rem;
339+
border-top: 1px solid rgba(255, 255, 255, 0.05);
340+
margin-top: 0.2rem;
341+
}
342+
331343
/* Game Over */
332344
#gameOver h2 {
333345
font-size: 2rem;

0 commit comments

Comments
 (0)