phase-9: implement match event timeline with computed events
- Client-side event extraction from replay turn data - Icon ribbon overlaid on replay viewer timeline - Click-to-jump to event moment - Computed events: mass death (5+ bots), spawn wave (3+ spawns), momentum shift (win prob crosses 50%), critical moment (>15% shift) - Energy milestone detection (3+ energy collected) - Hover tooltips with event descriptions - Updated icons matching plan §14.8 specification Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
parent
b93ea06d4c
commit
199a2ea0fe
3 changed files with 163 additions and 21 deletions
|
|
@ -1 +1 @@
|
|||
0c223aa10dee97194b2eb7d8c62dada24226e7f5
|
||||
b93ea06d4c202f3d1ff53b6ccb2decb3bb522ba8
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
// Event Timeline Ribbon - Visual event strip with click-to-jump
|
||||
// §14.8 Match Event Timeline - client-side event extraction with computed events
|
||||
import type { GameEvent } from '../types';
|
||||
import type { Annotation } from './annotation';
|
||||
|
||||
|
|
@ -7,17 +8,23 @@ export interface TimelineEvent {
|
|||
type: string;
|
||||
icon: string;
|
||||
color: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
// Map event types to icons and colors
|
||||
const EVENT_CONFIG: Record<string, { icon: string; color: string }> = {
|
||||
bot_spawned: { icon: '◆', color: '#22c55e' },
|
||||
bot_died: { icon: '✕', color: '#ef4444' },
|
||||
combat_death: { icon: '⚔', color: '#f97316' },
|
||||
collision_death: { icon: '💥', color: '#eab308' },
|
||||
energy_collected: { icon: '★', color: '#fbbf24' },
|
||||
core_captured: { icon: '◉', color: '#3b82f6' },
|
||||
core_destroyed: { icon: '⊘', color: '#6b7280' },
|
||||
// Map event types to icons and colors (matching plan §14.8)
|
||||
const EVENT_CONFIG: Record<string, { icon: string; color: string; label: string }> = {
|
||||
// Raw game events
|
||||
bot_spawned: { icon: '🐣', color: '#22c55e', label: 'Spawn' },
|
||||
bot_died: { icon: '💀', color: '#ef4444', label: 'Death' },
|
||||
energy_collected: { icon: '💎', color: '#fbbf24', label: 'Energy' },
|
||||
core_captured: { icon: '🏰', color: '#3b82f6', label: 'Core' },
|
||||
core_destroyed: { icon: '🏰', color: '#6b7280', label: 'Core' },
|
||||
// Computed events (§14.8)
|
||||
combat: { icon: '⚔️', color: '#f97316', label: 'Combat' },
|
||||
mass_death: { icon: '💀', color: '#dc2626', label: 'Mass Death' },
|
||||
spawn_wave: { icon: '🐣', color: '#22c55e', label: 'Spawn Wave' },
|
||||
momentum_shift: { icon: '📈', color: '#8b5cf6', label: 'Momentum' },
|
||||
critical_moment: { icon: '🌟', color: '#fbbf24', label: 'Critical' },
|
||||
};
|
||||
|
||||
export class EventTimeline {
|
||||
|
|
@ -34,22 +41,137 @@ export class EventTimeline {
|
|||
this.render();
|
||||
}
|
||||
|
||||
// Extract events from replay turns
|
||||
setEvents(turns: { turn: number; events?: GameEvent[] }[]): void {
|
||||
// Extract events from replay turns with client-side computed events (§14.8)
|
||||
setEvents(
|
||||
turns: { turn: number; events?: GameEvent[]; energy_collected?: any }[],
|
||||
winProb?: number[][]
|
||||
): void {
|
||||
this.events = [];
|
||||
this.totalTurns = turns.length;
|
||||
|
||||
for (const turnData of turns) {
|
||||
const turnEvents = turnData.events ?? [];
|
||||
for (const event of turnEvents) {
|
||||
const config = EVENT_CONFIG[event.type] || { icon: '•', color: '#94a3b8' };
|
||||
const turn = turnData.turn;
|
||||
const rawEvents = turnData.events ?? [];
|
||||
|
||||
// Count deaths this turn
|
||||
const deaths = rawEvents.filter((e: GameEvent) => e.type === 'bot_died').length;
|
||||
|
||||
// Count spawns this turn
|
||||
const spawns = rawEvents.filter((e: GameEvent) => e.type === 'bot_spawned').length;
|
||||
|
||||
// Count energy collected per player
|
||||
const energyByPlayer = new Map<number, number>();
|
||||
for (const e of rawEvents) {
|
||||
if (e.type === 'energy_collected') {
|
||||
const owner = (e.details as any)?.owner ?? 0;
|
||||
energyByPlayer.set(owner, (energyByPlayer.get(owner) ?? 0) + 1);
|
||||
}
|
||||
}
|
||||
const maxEnergy = Math.max(0, ...energyByPlayer.values());
|
||||
|
||||
// Add raw game events
|
||||
for (const event of rawEvents) {
|
||||
const config = EVENT_CONFIG[event.type] || { icon: '•', color: '#94a3b8', label: 'Event' };
|
||||
this.events.push({
|
||||
turn: turnData.turn,
|
||||
turn,
|
||||
type: event.type,
|
||||
icon: config.icon,
|
||||
color: config.color,
|
||||
description: `${config.label}: turn ${turn}`,
|
||||
});
|
||||
}
|
||||
|
||||
// Add computed events (§14.8)
|
||||
|
||||
// Mass death: 5+ bots died this turn
|
||||
if (deaths >= 5) {
|
||||
const config = EVENT_CONFIG.mass_death;
|
||||
this.events.push({
|
||||
turn,
|
||||
type: 'mass_death',
|
||||
icon: config.icon,
|
||||
color: config.color,
|
||||
description: `Mass Death: ${deaths} bots eliminated`,
|
||||
});
|
||||
}
|
||||
|
||||
// Combat: 2+ bots died (but not mass death)
|
||||
if (deaths >= 2 && deaths < 5) {
|
||||
const config = EVENT_CONFIG.combat;
|
||||
this.events.push({
|
||||
turn,
|
||||
type: 'combat',
|
||||
icon: config.icon,
|
||||
color: config.color,
|
||||
description: `Combat: ${deaths} bots killed`,
|
||||
});
|
||||
}
|
||||
|
||||
// Spawn wave: 3+ bots spawned this turn
|
||||
if (spawns >= 3) {
|
||||
const config = EVENT_CONFIG.spawn_wave;
|
||||
this.events.push({
|
||||
turn,
|
||||
type: 'spawn_wave',
|
||||
icon: config.icon,
|
||||
color: config.color,
|
||||
description: `Spawn Wave: ${spawns} new bots`,
|
||||
});
|
||||
}
|
||||
|
||||
// Energy milestone: player collected 3+ energy
|
||||
if (maxEnergy >= 3) {
|
||||
const config = EVENT_CONFIG.energy_collected;
|
||||
this.events.push({
|
||||
turn,
|
||||
type: 'energy_milestone',
|
||||
icon: config.icon,
|
||||
color: config.color,
|
||||
description: `Energy: ${maxEnergy} collected`,
|
||||
});
|
||||
}
|
||||
|
||||
// Momentum shift: win probability crossed 50%
|
||||
if (winProb && winProb[turn] && winProb[turn - 1]) {
|
||||
const prevProb = winProb[turn - 1];
|
||||
const currProb = winProb[turn];
|
||||
// Check if any player crossed 50%
|
||||
for (let p = 0; p < currProb.length; p++) {
|
||||
const prevWasAbove = prevProb[p] > 0.5;
|
||||
const currIsAbove = currProb[p] > 0.5;
|
||||
if (prevWasAbove !== currIsAbove) {
|
||||
const config = EVENT_CONFIG.momentum_shift;
|
||||
this.events.push({
|
||||
turn,
|
||||
type: 'momentum_shift',
|
||||
icon: config.icon,
|
||||
color: config.color,
|
||||
description: `Momentum Shift: player ${p}`,
|
||||
});
|
||||
break; // Only add one marker per turn
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Critical moment: win probability shifted >15%
|
||||
if (winProb && winProb[turn] && winProb[turn - 1]) {
|
||||
const prevProb = winProb[turn - 1];
|
||||
const currProb = winProb[turn];
|
||||
for (let p = 0; p < currProb.length; p++) {
|
||||
const delta = Math.abs(currProb[p] - prevProb[p]);
|
||||
if (delta > 0.15) {
|
||||
const config = EVENT_CONFIG.critical_moment;
|
||||
this.events.push({
|
||||
turn,
|
||||
type: 'critical_moment',
|
||||
icon: config.icon,
|
||||
color: config.color,
|
||||
description: `Critical: ${(delta * 100).toFixed(0)}% shift`,
|
||||
});
|
||||
break; // Only add one marker per turn
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
this.render();
|
||||
|
|
@ -80,8 +202,9 @@ export class EventTimeline {
|
|||
<div class="timeline-event"
|
||||
data-index="${idx}"
|
||||
data-turn="${e.turn}"
|
||||
data-tooltip="${e.description}"
|
||||
style="left: ${leftPercent}%; color: ${e.color}"
|
||||
title="Turn ${e.turn}: ${e.type.replace(/_/g, ' ')}">
|
||||
title="Turn ${e.turn}: ${e.description}">
|
||||
${e.icon}
|
||||
</div>
|
||||
`;
|
||||
|
|
@ -199,7 +322,7 @@ export const EVENT_TIMELINE_STYLES = `
|
|||
|
||||
.timeline-track {
|
||||
position: relative;
|
||||
height: 28px;
|
||||
height: 32px;
|
||||
background-color: var(--bg-tertiary, #334155);
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
|
|
@ -221,7 +344,7 @@ export const EVENT_TIMELINE_STYLES = `
|
|||
position: absolute;
|
||||
top: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
font-size: 14px;
|
||||
font-size: 16px;
|
||||
cursor: pointer;
|
||||
transition: transform 0.15s, text-shadow 0.15s;
|
||||
z-index: 1;
|
||||
|
|
@ -231,11 +354,29 @@ export const EVENT_TIMELINE_STYLES = `
|
|||
.timeline-event:hover {
|
||||
transform: translate(-50%, -50%) scale(1.4);
|
||||
text-shadow: 0 0 4px currentColor;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.timeline-event:hover::after {
|
||||
content: attr(data-tooltip);
|
||||
position: absolute;
|
||||
bottom: 100%;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
background-color: rgba(0, 0, 0, 0.9);
|
||||
color: #fff;
|
||||
padding: 4px 8px;
|
||||
border-radius: 4px;
|
||||
font-size: 11px;
|
||||
white-space: nowrap;
|
||||
pointer-events: none;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.timeline-event.active {
|
||||
transform: translate(-50%, -50%) scale(1.5);
|
||||
text-shadow: 0 0 6px currentColor;
|
||||
z-index: 5;
|
||||
}
|
||||
|
||||
.timeline-turn-label {
|
||||
|
|
|
|||
|
|
@ -983,12 +983,13 @@ function initReplayViewer(ReplayViewerClass: any, initialUrl?: string): void {
|
|||
updateEventLog();
|
||||
},
|
||||
});
|
||||
// Extract events from replay turns and feed to timeline
|
||||
// Extract events from replay turns and feed to timeline with win probability data
|
||||
const timelineTurns = replay.turns.map((t: any, i: number) => ({
|
||||
turn: i,
|
||||
events: t.events ?? [],
|
||||
energy_collected: t.energy_collected ?? [],
|
||||
}));
|
||||
eventTimeline.setEvents(timelineTurns);
|
||||
eventTimeline.setEvents(timelineTurns, replay.win_prob);
|
||||
timelineContainer.style.display = '';
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue