From 323c1e8241de0a6a1ee0eb563eab491154914d9f Mon Sep 17 00:00:00 2001 From: jedarden Date: Fri, 22 May 2026 14:51:32 -0400 Subject: [PATCH] =?UTF-8?q?Viewer:=20directed=20attack=20arrows=20from=20k?= =?UTF-8?q?illers[]=20in=20combat=5Fdeath=20events=20(=C2=A716.9)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Updated CombatDeathDetails type to include killers[] array with each killer's bot_id, owner, and position - Modified drawCombatEffects() to handle combat_death events by drawing solid arrows from each killer to the victim - Added drawArrow() helper method to draw arrows with arrowheads - Maintained backward compatibility: old replays without combat_death events use proximity-inference lines Co-Authored-By: Claude Opus 4.7 --- web/src/replay-viewer.ts | 179 ++++++++++++++++++++++++++++----------- web/src/types.ts | 15 ++-- 2 files changed, 139 insertions(+), 55 deletions(-) diff --git a/web/src/replay-viewer.ts b/web/src/replay-viewer.ts index ddcc0a4..7690e26 100644 --- a/web/src/replay-viewer.ts +++ b/web/src/replay-viewer.ts @@ -1880,76 +1880,155 @@ export class ReplayViewer { const { ctx, cellSize } = this; const events = turnData.events ?? []; - // Collect death positions + // Collect combat_death events with killers array (new format) + const combatDeaths: Array<{pos: Position; owner: number; killers: Array<{bot_id: number; owner: number; position: Position}>}> = []; + // Collect bot_died events without combat_death (fallback for old replays) const deaths: Array<{pos: Position; owner: number}> = []; + for (const event of events) { - if (event.type === 'bot_died') { + if (event.type === 'combat_death') { const d = event.details as any; const rawPos = d.position ?? d.pos ?? d; const pos: Position = {row: rawPos.Row ?? rawPos.row ?? 0, col: rawPos.Col ?? rawPos.col ?? 0}; - if (pos.row === 0 && pos.col === 0 && !d.position && !d.pos) continue; // skip if no real position + if (pos.row === 0 && pos.col === 0 && !d.position && !d.pos) continue; + const killers = d.killers ?? []; + combatDeaths.push({pos, owner: d.owner ?? 0, killers}); + } else if (event.type === 'bot_died') { + const d = event.details as any; + const rawPos = d.position ?? d.pos ?? d; + const pos: Position = {row: rawPos.Row ?? rawPos.row ?? 0, col: rawPos.Col ?? rawPos.col ?? 0}; + if (pos.row === 0 && pos.col === 0 && !d.position && !d.pos) continue; deaths.push({pos, owner: d.owner ?? 0}); } } - if (deaths.length === 0) return; + // Handle combat_death events with killers[] array (new format) - directed arrows + if (combatDeaths.length > 0) { + for (const death of combatDeaths) { + if (visible && !visible.has(this.posKey(death.pos))) continue; - // Find living bots this turn to draw attack lines from nearby enemies - const livingBots = turnData.bots.filter(b => b.alive); - const attackRadius = Math.sqrt(this.replay?.config?.attack_radius2 ?? 5) * cellSize; + const dx = death.pos.col * cellSize + cellSize / 2; + const dy = death.pos.row * cellSize + cellSize / 2; - for (const death of deaths) { - if (visible && !visible.has(this.posKey(death.pos))) continue; + // Draw directed arrows from each killer to the victim + for (const killer of death.killers) { + if (visible && !visible.has(this.posKey(killer.position))) continue; - const dx = death.pos.col * cellSize + cellSize / 2; - const dy = death.pos.row * cellSize + cellSize / 2; + const kx = killer.position.col * cellSize + cellSize / 2; + const ky = killer.position.row * cellSize + cellSize / 2; + const color = colors[killer.owner]; - // Draw attack lines from nearby enemy bots to the death position - for (const attacker of livingBots) { - if (attacker.owner === death.owner) continue; - const ax = attacker.position.col * cellSize + cellSize / 2; - const ay = attacker.position.row * cellSize + cellSize / 2; - const dist = Math.hypot(ax - dx, ay - dy); - - if (dist < attackRadius + cellSize * 3) { - ctx.strokeStyle = colors[attacker.owner]; - ctx.lineWidth = 1.5; - ctx.globalAlpha = 0.4; - ctx.setLineDash([4, 4]); - ctx.beginPath(); - ctx.moveTo(ax, ay); - ctx.lineTo(dx, dy); - ctx.stroke(); - ctx.setLineDash([]); - ctx.globalAlpha = 1; + // Draw solid line with arrowhead (attacker player color) + this.drawArrow(kx, ky, dx, dy, color, 1.5); } + + // Draw red explosion flash behind the X + const flashRadius = cellSize * 0.8; + const gradient = ctx.createRadialGradient(dx, dy, 0, dx, dy, flashRadius); + gradient.addColorStop(0, 'rgba(239, 68, 68, 0.6)'); + gradient.addColorStop(1, 'rgba(239, 68, 68, 0)'); + ctx.fillStyle = gradient; + ctx.beginPath(); + ctx.arc(dx, dy, flashRadius, 0, Math.PI * 2); + ctx.fill(); + + // Draw death X marker + const xSize = cellSize * 0.35; + ctx.strokeStyle = '#fca5a5'; + ctx.lineWidth = 2.5; + ctx.lineCap = 'round'; + ctx.beginPath(); + ctx.moveTo(dx - xSize, dy - xSize); + ctx.lineTo(dx + xSize, dy + xSize); + ctx.moveTo(dx + xSize, dy - xSize); + ctx.lineTo(dx - xSize, dy + xSize); + ctx.stroke(); + ctx.lineCap = 'butt'; } + } else if (deaths.length > 0) { + // Fallback for old replays: proximity-inference lines + const livingBots = turnData.bots.filter(b => b.alive); + const attackRadius = Math.sqrt(this.replay?.config?.attack_radius2 ?? 5) * cellSize; - // Draw red explosion flash behind the X - const flashRadius = cellSize * 0.8; - const gradient = ctx.createRadialGradient(dx, dy, 0, dx, dy, flashRadius); - gradient.addColorStop(0, 'rgba(239, 68, 68, 0.6)'); - gradient.addColorStop(1, 'rgba(239, 68, 68, 0)'); - ctx.fillStyle = gradient; - ctx.beginPath(); - ctx.arc(dx, dy, flashRadius, 0, Math.PI * 2); - ctx.fill(); + for (const death of deaths) { + if (visible && !visible.has(this.posKey(death.pos))) continue; - // Draw death X marker - const xSize = cellSize * 0.35; - ctx.strokeStyle = '#fca5a5'; - ctx.lineWidth = 2.5; - ctx.lineCap = 'round'; - ctx.beginPath(); - ctx.moveTo(dx - xSize, dy - xSize); - ctx.lineTo(dx + xSize, dy + xSize); - ctx.moveTo(dx + xSize, dy - xSize); - ctx.lineTo(dx - xSize, dy + xSize); - ctx.stroke(); - ctx.lineCap = 'butt'; + const dx = death.pos.col * cellSize + cellSize / 2; + const dy = death.pos.row * cellSize + cellSize / 2; + + // Draw attack lines from nearby enemy bots to the death position + for (const attacker of livingBots) { + if (attacker.owner === death.owner) continue; + const ax = attacker.position.col * cellSize + cellSize / 2; + const ay = attacker.position.row * cellSize + cellSize / 2; + const dist = Math.hypot(ax - dx, ay - dy); + + if (dist < attackRadius + cellSize * 3) { + ctx.strokeStyle = colors[attacker.owner]; + ctx.lineWidth = 1.5; + ctx.globalAlpha = 0.4; + ctx.setLineDash([4, 4]); + ctx.beginPath(); + ctx.moveTo(ax, ay); + ctx.lineTo(dx, dy); + ctx.stroke(); + ctx.setLineDash([]); + ctx.globalAlpha = 1; + } + } + + // Draw red explosion flash behind the X + const flashRadius = cellSize * 0.8; + const gradient = ctx.createRadialGradient(dx, dy, 0, dx, dy, flashRadius); + gradient.addColorStop(0, 'rgba(239, 68, 68, 0.6)'); + gradient.addColorStop(1, 'rgba(239, 68, 68, 0)'); + ctx.fillStyle = gradient; + ctx.beginPath(); + ctx.arc(dx, dy, flashRadius, 0, Math.PI * 2); + ctx.fill(); + + // Draw death X marker + const xSize = cellSize * 0.35; + ctx.strokeStyle = '#fca5a5'; + ctx.lineWidth = 2.5; + ctx.lineCap = 'round'; + ctx.beginPath(); + ctx.moveTo(dx - xSize, dy - xSize); + ctx.lineTo(dx + xSize, dy + xSize); + ctx.moveTo(dx + xSize, dy - xSize); + ctx.lineTo(dx - xSize, dy + xSize); + ctx.stroke(); + ctx.lineCap = 'butt'; + } } } + // Draw an arrow from (x1,y1) to (x2,y2) with an arrowhead at the end + private drawArrow(x1: number, y1: number, x2: number, y2: number, color: string, lineWidth: number): void { + const { ctx } = this; + const headLen = 6; // length of arrowhead + const angle = Math.atan2(y2 - y1, x2 - x1); + + ctx.strokeStyle = color; + ctx.fillStyle = color; + ctx.lineWidth = lineWidth; + ctx.globalAlpha = 1; + + // Draw line + ctx.beginPath(); + ctx.moveTo(x1, y1); + ctx.lineTo(x2, y2); + ctx.stroke(); + + // Draw arrowhead + ctx.beginPath(); + ctx.moveTo(x2, y2); + ctx.lineTo(x2 - headLen * Math.cos(angle - Math.PI / 6), y2 - headLen * Math.sin(angle - Math.PI / 6)); + ctx.lineTo(x2 - headLen * Math.cos(angle + Math.PI / 6), y2 - headLen * Math.sin(angle + Math.PI / 6)); + ctx.closePath(); + ctx.fill(); + } + // Draw threat lines between bots of different owners within attack range private drawThreatLines( turnData: ReplayTurn, diff --git a/web/src/types.ts b/web/src/types.ts index c3ae600..1477887 100644 --- a/web/src/types.ts +++ b/web/src/types.ts @@ -120,14 +120,19 @@ export interface CoreCapturedDetails { new_owner: number; } -export interface CombatDeathDetails { - attacker_id: number; - attacker_owner: number; - defender_id: number; - defender_owner: number; +export interface CombatDeathKiller { + bot_id: number; + owner: number; position: Position; } +export interface CombatDeathDetails { + bot_id: number; + owner: number; + position: Position; + killers: CombatDeathKiller[]; +} + export interface CollisionDeathDetails { bot_ids: number[]; position: Position;