Skip to content

Commit 735ccc5

Browse files
committed
Extract renderer scene and overlay drawing modules
Split renderer-scene.ts (stars, hex grid, gravity indicators, bodies, base markers, map border, asteroids, landing targets, detection ranges) and renderer-overlay.ts (ordnance, torpedo guidance, combat overlay) out of renderer.ts. Reduces renderer.ts from 1,522 to 1,011 lines.
1 parent 59d5e55 commit 735ccc5

File tree

3 files changed

+680
-543
lines changed

3 files changed

+680
-543
lines changed

src/client/renderer-overlay.ts

Lines changed: 311 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,311 @@
1+
/**
2+
* Gameplay overlay Canvas drawing: ordnance, torpedo guidance, combat overlay.
3+
* Pure functions extracted from Renderer — no class state dependencies.
4+
*/
5+
6+
import {
7+
type HexCoord,
8+
type PixelCoord,
9+
hexToPixel,
10+
hexAdd,
11+
HEX_DIRECTIONS,
12+
} from '../shared/hex';
13+
import type { GameState, CombatAttack, SolarSystemMap } from '../shared/types';
14+
import { SHIP_STATS } from '../shared/constants';
15+
import {
16+
getDetonatedOrdnanceOverlay,
17+
getOrdnanceColor,
18+
getOrdnanceHeading,
19+
getOrdnanceLifetimeView,
20+
getOrdnancePulse,
21+
} from './renderer-entities';
22+
import {
23+
getCombatOverlayHighlights,
24+
getCombatPreview,
25+
getQueuedCombatOverlayAttacks,
26+
} from './renderer-combat';
27+
import { drawOrdnanceVelocity } from './renderer-draw';
28+
import type { AnimationState, PlanningState } from './renderer';
29+
30+
export function renderOrdnance(
31+
ctx: CanvasRenderingContext2D,
32+
state: GameState,
33+
playerId: number,
34+
animState: AnimationState | null,
35+
hexSize: number,
36+
now: number,
37+
interpolatePath: (path: HexCoord[], progress: number) => PixelCoord,
38+
): void {
39+
if (!state.ordnance || state.ordnance.length === 0) return;
40+
41+
for (const ord of state.ordnance) {
42+
if (ord.destroyed) continue;
43+
44+
let p: PixelCoord;
45+
if (animState) {
46+
const om = animState.ordnanceMovements.find(m => m.ordnanceId === ord.id);
47+
if (om) {
48+
const progress = Math.min((now - animState.startTime) / animState.duration, 1);
49+
p = interpolatePath(om.path, progress);
50+
} else {
51+
p = hexToPixel(ord.position, hexSize);
52+
}
53+
} else {
54+
p = hexToPixel(ord.position, hexSize);
55+
}
56+
57+
const color = getOrdnanceColor(ord.owner, playerId);
58+
const pulse = getOrdnancePulse(now);
59+
60+
if (ord.type === 'nuke') {
61+
const s = 6;
62+
const nukeColor = '#ff4444';
63+
ctx.fillStyle = nukeColor;
64+
ctx.globalAlpha = pulse;
65+
ctx.beginPath();
66+
ctx.moveTo(p.x, p.y - s);
67+
ctx.lineTo(p.x + s, p.y);
68+
ctx.lineTo(p.x, p.y + s);
69+
ctx.lineTo(p.x - s, p.y);
70+
ctx.closePath();
71+
ctx.fill();
72+
ctx.strokeStyle = '#ff8888';
73+
ctx.lineWidth = 1;
74+
ctx.stroke();
75+
ctx.globalAlpha = 1;
76+
} else if (ord.type === 'mine') {
77+
const s = 4;
78+
ctx.fillStyle = color;
79+
ctx.globalAlpha = pulse;
80+
ctx.beginPath();
81+
ctx.moveTo(p.x, p.y - s);
82+
ctx.lineTo(p.x + s, p.y);
83+
ctx.lineTo(p.x, p.y + s);
84+
ctx.lineTo(p.x - s, p.y);
85+
ctx.closePath();
86+
ctx.fill();
87+
ctx.globalAlpha = 1;
88+
} else {
89+
const heading = getOrdnanceHeading(ord.position, ord.velocity, hexSize);
90+
const s = 5;
91+
ctx.save();
92+
ctx.translate(p.x, p.y);
93+
ctx.rotate(heading);
94+
ctx.fillStyle = color;
95+
ctx.globalAlpha = pulse;
96+
ctx.beginPath();
97+
ctx.moveTo(s, 0);
98+
ctx.lineTo(-s * 0.6, -s * 0.4);
99+
ctx.lineTo(-s * 0.6, s * 0.4);
100+
ctx.closePath();
101+
ctx.fill();
102+
ctx.globalAlpha = 1;
103+
ctx.restore();
104+
}
105+
106+
if (!animState) {
107+
drawOrdnanceVelocity(ctx, ord.position, ord.velocity, p, color, hexSize);
108+
}
109+
110+
const lifetimeView = getOrdnanceLifetimeView(ord.turnsRemaining, animState !== null);
111+
if (lifetimeView) {
112+
ctx.fillStyle = lifetimeView.color;
113+
ctx.font = 'bold 6px monospace';
114+
ctx.textAlign = 'center';
115+
ctx.fillText(lifetimeView.text, p.x, p.y + 10);
116+
}
117+
}
118+
119+
if (animState) {
120+
const progress = Math.min((now - animState.startTime) / animState.duration, 1);
121+
for (const om of animState.ordnanceMovements) {
122+
if (!om.detonated) continue;
123+
const overlay = getDetonatedOrdnanceOverlay(progress);
124+
if (!overlay) continue;
125+
if (overlay.kind === 'diamond') {
126+
const p = interpolatePath(om.path, progress);
127+
ctx.fillStyle = overlay.color;
128+
ctx.globalAlpha = overlay.alpha;
129+
ctx.beginPath();
130+
ctx.moveTo(p.x, p.y - overlay.size);
131+
ctx.lineTo(p.x + overlay.size, p.y);
132+
ctx.lineTo(p.x, p.y + overlay.size);
133+
ctx.lineTo(p.x - overlay.size, p.y);
134+
ctx.closePath();
135+
ctx.fill();
136+
ctx.globalAlpha = 1;
137+
} else {
138+
const detP = hexToPixel(om.to, hexSize);
139+
ctx.fillStyle = overlay.color;
140+
ctx.globalAlpha = overlay.alpha;
141+
ctx.beginPath();
142+
ctx.arc(detP.x, detP.y, overlay.size, 0, Math.PI * 2);
143+
ctx.fill();
144+
ctx.globalAlpha = 1;
145+
}
146+
}
147+
}
148+
}
149+
150+
export function renderTorpedoGuidance(
151+
ctx: CanvasRenderingContext2D,
152+
state: GameState,
153+
playerId: number,
154+
planningState: PlanningState,
155+
isAnimating: boolean,
156+
hexSize: number,
157+
now: number,
158+
): void {
159+
if (state.phase !== 'ordnance' || state.activePlayer !== playerId) return;
160+
if (isAnimating) return;
161+
162+
const selectedId = planningState.selectedShipId;
163+
if (!selectedId) return;
164+
165+
const ship = state.ships.find(s => s.id === selectedId);
166+
if (!ship || ship.destroyed || ship.landed) return;
167+
168+
const stats = SHIP_STATS[ship.type];
169+
if (!stats?.canOverload) return;
170+
171+
const shipPos = hexToPixel(ship.position, hexSize);
172+
const accel = planningState.torpedoAccel;
173+
const accelSteps = planningState.torpedoAccelSteps;
174+
175+
for (let d = 0; d < 6; d++) {
176+
const targetHex = hexAdd(ship.position, HEX_DIRECTIONS[d]);
177+
const tp = hexToPixel(targetHex, hexSize);
178+
const isActive = accel === d;
179+
180+
ctx.fillStyle = isActive ? 'rgba(255, 120, 60, 0.6)' : 'rgba(255, 120, 60, 0.12)';
181+
ctx.strokeStyle = isActive ? '#ff7744' : 'rgba(255, 120, 60, 0.3)';
182+
ctx.lineWidth = 1.5;
183+
ctx.beginPath();
184+
ctx.arc(tp.x, tp.y, 7, 0, Math.PI * 2);
185+
ctx.fill();
186+
ctx.stroke();
187+
188+
if (isActive) {
189+
ctx.strokeStyle = 'rgba(255, 120, 60, 0.5)';
190+
ctx.lineWidth = 1;
191+
ctx.beginPath();
192+
ctx.moveTo(shipPos.x, shipPos.y);
193+
ctx.lineTo(tp.x, tp.y);
194+
ctx.stroke();
195+
196+
ctx.fillStyle = 'rgba(255, 240, 200, 0.9)';
197+
ctx.font = '7px monospace';
198+
ctx.fillText(`x${accelSteps ?? 1}`, tp.x, tp.y + 2);
199+
}
200+
}
201+
202+
ctx.fillStyle = 'rgba(255, 120, 60, 0.8)';
203+
ctx.font = '8px monospace';
204+
ctx.textAlign = 'center';
205+
ctx.fillText('TORPEDO BOOST', shipPos.x, shipPos.y - 20);
206+
}
207+
208+
export function renderCombatOverlay(
209+
ctx: CanvasRenderingContext2D,
210+
state: GameState,
211+
playerId: number,
212+
planningState: PlanningState,
213+
map: SolarSystemMap | null,
214+
isAnimating: boolean,
215+
hexSize: number,
216+
now: number,
217+
): void {
218+
if (state.phase !== 'combat' || state.activePlayer !== playerId) return;
219+
if (isAnimating) return;
220+
221+
const pulse = 0.5 + 0.3 * Math.sin(now / 300);
222+
223+
for (const queued of getQueuedCombatOverlayAttacks(state, planningState.queuedAttacks)) {
224+
const targetPos = hexToPixel(queued.targetPosition, hexSize);
225+
for (const attackerPosition of queued.attackerPositions) {
226+
const attackerPos = hexToPixel(attackerPosition, hexSize);
227+
ctx.strokeStyle = 'rgba(79, 195, 247, 0.3)';
228+
ctx.lineWidth = 1;
229+
ctx.setLineDash([4, 4]);
230+
ctx.beginPath();
231+
ctx.moveTo(attackerPos.x, attackerPos.y);
232+
ctx.lineTo(targetPos.x, targetPos.y);
233+
ctx.stroke();
234+
ctx.setLineDash([]);
235+
}
236+
ctx.strokeStyle = 'rgba(255, 80, 80, 0.4)';
237+
ctx.lineWidth = 1.5;
238+
ctx.setLineDash([3, 3]);
239+
ctx.beginPath();
240+
ctx.arc(targetPos.x, targetPos.y, 15, 0, Math.PI * 2);
241+
ctx.stroke();
242+
ctx.setLineDash([]);
243+
}
244+
245+
const highlights = getCombatOverlayHighlights(state, playerId, planningState, map);
246+
for (const ship of highlights.shipTargets) {
247+
const p = hexToPixel(ship.position, hexSize);
248+
ctx.strokeStyle = ship.isSelected
249+
? `rgba(255, 80, 80, ${0.8 + pulse * 0.2})`
250+
: `rgba(255, 80, 80, ${0.2 + pulse * 0.15})`;
251+
ctx.lineWidth = ship.isSelected ? 2.5 : 1.5;
252+
ctx.beginPath();
253+
ctx.arc(p.x, p.y, ship.isSelected ? 16 : 13, 0, Math.PI * 2);
254+
ctx.stroke();
255+
}
256+
257+
for (const ordnance of highlights.ordnanceTargets) {
258+
const p = hexToPixel(ordnance.position, hexSize);
259+
ctx.strokeStyle = ordnance.isSelected
260+
? `rgba(255, 210, 80, ${0.8 + pulse * 0.2})`
261+
: `rgba(255, 210, 80, ${0.2 + pulse * 0.15})`;
262+
ctx.lineWidth = ordnance.isSelected ? 2.5 : 1.5;
263+
ctx.beginPath();
264+
ctx.rect(
265+
p.x - (ordnance.isSelected ? 10 : 8),
266+
p.y - (ordnance.isSelected ? 10 : 8),
267+
ordnance.isSelected ? 20 : 16,
268+
ordnance.isSelected ? 20 : 16,
269+
);
270+
ctx.stroke();
271+
}
272+
273+
const preview = getCombatPreview(state, playerId, planningState, map);
274+
if (preview === null) return;
275+
276+
const targetPos = hexToPixel(preview.targetPosition, hexSize);
277+
for (const attackerPosition of preview.attackerPositions) {
278+
const attackerPos = hexToPixel(attackerPosition, hexSize);
279+
ctx.strokeStyle = 'rgba(79, 195, 247, 0.55)';
280+
ctx.lineWidth = 1.5;
281+
ctx.beginPath();
282+
ctx.arc(attackerPos.x, attackerPos.y, 14, 0, Math.PI * 2);
283+
ctx.stroke();
284+
}
285+
286+
for (const attackerPosition of preview.attackerPositions) {
287+
const attackerPos = hexToPixel(attackerPosition, hexSize);
288+
ctx.strokeStyle = 'rgba(255, 80, 80, 0.4)';
289+
ctx.lineWidth = 1.5;
290+
ctx.setLineDash([6, 4]);
291+
ctx.beginPath();
292+
ctx.moveTo(attackerPos.x, attackerPos.y);
293+
ctx.lineTo(targetPos.x, targetPos.y);
294+
ctx.stroke();
295+
ctx.setLineDash([]);
296+
}
297+
298+
ctx.font = 'bold 10px monospace';
299+
const textW = ctx.measureText(preview.label).width;
300+
ctx.fillStyle = 'rgba(0, 0, 0, 0.7)';
301+
ctx.fillRect(targetPos.x - textW / 2 - 4, targetPos.y - 32, textW + 8, 16);
302+
ctx.fillStyle = preview.totalMod > 0 ? '#88ff88' : preview.totalMod < 0 ? '#ff6666' : '#ffdd57';
303+
ctx.textAlign = 'center';
304+
ctx.fillText(preview.label, targetPos.x, targetPos.y - 20);
305+
306+
if (preview.counterattackLabel) {
307+
ctx.fillStyle = 'rgba(255, 170, 0, 0.7)';
308+
ctx.font = '7px monospace';
309+
ctx.fillText(preview.counterattackLabel, targetPos.x, targetPos.y - 38);
310+
}
311+
}

0 commit comments

Comments
 (0)