Files
RocketLeagueBot-Renderer/assets/viewer.html
T
fdestefano d5e65fbb03
release / goreleaser (push) Failing after 51s
Release v1.0.0 #major
2026-06-02 23:12:36 -05:00

1648 lines
66 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!DOCTYPE html>
<html>
<head>
<title>RocketSim Viewer</title>
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no">
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body { background: #0d0d1a; overflow: hidden; font-family: monospace; touch-action: none; color: #ccc; }
#canvas { display: block; }
#hud {
position: absolute; top: 12px; left: 12px;
color: #ccc; font-size: 12px;
background: rgba(0,0,0,0.6);
padding: 10px 14px; border-radius: 6px;
line-height: 1.7; pointer-events: none;
border: 1px solid rgba(255,255,255,0.08);
white-space: pre;
max-width: 380px;
}
#status {
position: absolute; top: 12px; right: 12px;
font-size: 12px; padding: 6px 14px;
border-radius: 6px; font-family: monospace;
z-index: 100; display: flex; gap: 8px; align-items: center;
}
.rec-dot {
display: inline-block; width: 8px; height: 8px; border-radius: 50%;
background: #ff3030; animation: pulse 1.2s infinite;
}
@keyframes pulse { 0%,100% { opacity: 1; } 50% { opacity: 0.3; } }
.connected { background: rgba(0,180,80,0.25); color: #00e676; border: 1px solid rgba(0,230,118,0.3); }
.disconnected { background: rgba(200,40,40,0.25); color: #ff5252; border: 1px solid rgba(255,82,82,0.3); }
#controls {
position: absolute; bottom: 12px; left: 12px;
color: #555; font-size: 11px; pointer-events: none;
}
#recordings-panel {
position: absolute; top: 50px; right: 12px;
background: rgba(0,0,0,0.85); color: #ccc; font-size: 12px;
padding: 10px 14px; border-radius: 6px; font-family: monospace;
border: 1px solid rgba(255,255,255,0.08);
max-height: 70vh; overflow-y: auto; min-width: 280px;
z-index: 50; transition: all 0.2s ease;
}
#recordings-panel.collapsed { max-height: none; overflow: visible; }
#recordings-panel.collapsed #rec-list,
#recordings-panel.collapsed #filter-row,
#recordings-panel.collapsed #stats-box { display: none; }
#recordings-panel h3 {
margin: -10px -14px 8px; padding: 10px 14px;
color: #aaa; font-size: 13px;
display: flex; align-items: center; gap: 6px; flex-wrap: wrap;
position: sticky; top: -10px; z-index: 2;
background: rgba(0,0,0,0.95);
border-bottom: 1px solid rgba(255,255,255,0.08);
backdrop-filter: blur(4px);
}
#recordings-panel button, #recordings-panel select {
background: rgba(255,255,255,0.1); color: #ccc; border: 1px solid rgba(255,255,255,0.15);
padding: 3px 8px; border-radius: 4px; cursor: pointer; font-family: monospace; font-size: 11px;
margin: 1px;
}
#recordings-panel select { padding: 3px 4px; }
#recordings-panel button:hover, #recordings-panel select:hover { background: rgba(255,255,255,0.2); }
#recordings-panel button.active { background: rgba(0,180,80,0.3); color: #00e676; border-color: rgba(0,230,118,0.3); }
#recordings-panel button.danger { color: #ff5252; }
#filter-row { margin-bottom: 8px; display: flex; gap: 6px; align-items: center; flex-wrap: wrap; }
#filter-row label { color: #888; font-size: 10px; }
.rec-item { padding: 4px 0; border-bottom: 1px solid rgba(255,255,255,0.05); display: flex; align-items: center; gap: 6px; }
.rec-name { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 11px; }
.rec-size { color: #666; font-size: 10px; }
#stats-box {
margin-top: 8px; padding-top: 8px; border-top: 1px solid rgba(255,255,255,0.08);
color: #888; font-size: 10px; line-height: 1.5;
}
#stats-box .k { color: #666; }
#stats-box .v { color: #aaa; }
#minimap {
position: absolute; bottom: 12px; right: 12px;
width: 180px; height: 220px;
background: rgba(0,0,0,0.75);
border: 1px solid rgba(255,255,255,0.1);
border-radius: 6px;
display: none;
z-index: 40;
}
#minimap.visible { display: block; }
#minimap canvas { display: block; width: 100%; height: 100%; border-radius: 6px; }
#playback-bar {
position: absolute; bottom: 16px; left: 50%; transform: translateX(-50%);
background: rgba(0,0,0,0.88); color: #ccc; font-size: 12px;
padding: 10px 16px; border-radius: 8px; font-family: monospace;
border: 1px solid rgba(255,255,255,0.08); display: none; gap: 10px; align-items: center;
z-index: 60; flex-wrap: wrap; justify-content: center; min-width: 560px;
}
#playback-bar button {
background: rgba(255,255,255,0.1); color: #ccc;
border: 1px solid rgba(255,255,255,0.15);
padding: 4px 10px; border-radius: 4px; cursor: pointer; font-family: monospace;
}
#playback-bar button:hover { background: rgba(255,255,255,0.2); }
#pb-name { font-weight: bold; color: #fff; }
#pb-scrub {
flex: 1; min-width: 200px; height: 6px; appearance: none;
background: rgba(255,255,255,0.1); border-radius: 3px; cursor: pointer;
}
#pb-scrub::-webkit-slider-thumb {
appearance: none; width: 14px; height: 14px; border-radius: 50%;
background: #00e676; cursor: pointer;
}
#pb-scrub::-moz-range-thumb {
width: 14px; height: 14px; border-radius: 50%; background: #00e676; border: none; cursor: pointer;
}
#pb-time { color: #aaa; min-width: 110px; text-align: center; font-variant-numeric: tabular-nums; }
#pb-speed { width: 70px; }
/* Mobile styles */
@media (max-width: 700px) {
#hud { font-size: 10px; padding: 6px 10px; top: 8px; left: 8px; max-width: 50%; line-height: 1.5; }
#status { font-size: 10px; padding: 4px 10px; top: 8px; right: 8px; }
#controls { display: none; }
#recordings-panel {
top: auto; bottom: 8px; right: 8px; left: 8px;
max-height: 35vh; min-width: auto;
font-size: 11px;
}
#recordings-panel h3 { font-size: 12px; }
#recordings-panel button, #recordings-panel select { padding: 6px 10px; font-size: 12px; }
.rec-item { padding: 6px 0; }
.rec-name { font-size: 12px; }
.rec-size { font-size: 11px; }
#playback-bar { bottom: auto; top: 50px; left: 8px; right: 8px; transform: none; padding: 10px; font-size: 11px; min-width: 0; }
#playback-bar button { padding: 6px 10px; font-size: 12px; }
}
</style>
</head>
<body>
<canvas id="canvas"></canvas>
<div id="hud">Waiting for data...</div>
<div id="status" class="disconnected"><span id="status-text">Disconnected</span></div>
<div id="controls">Drag to orbit · Scroll to zoom · Right-drag to pan · Space=play/pause · ←/→=±5s · [/]=speed · C=chase · V=vel · H=heat · M=map</div>
<div id="minimap"><canvas id="minimap-canvas" width="180" height="220"></canvas></div>
<div id="recordings-panel">
<h3>
<span onclick="togglePanel()" style="cursor:pointer">📼 Recordings</span>
<button onclick="togglePanel()" id="collapse-btn"></button>
<button onclick="loadRecordings()"></button>
<button id="mode-btn" class="active" onclick="stopPlayback()">● LIVE</button>
</h3>
<div id="filter-row">
<label>Show:</label>
<select id="time-filter" onchange="loadRecordings()">
<option value="0">All</option>
<option value="5">Last 5 min</option>
<option value="15">Last 15 min</option>
<option value="30">Last 30 min</option>
<option value="60">Last 1 hour</option>
<option value="360">Last 6 hours</option>
<option value="1440">Last 24 hours</option>
</select>
<label><input type="checkbox" id="quality-toggle"> Low Quality</label>
<label><input type="checkbox" id="trail-toggle"> Trail</label>
<label><input type="checkbox" id="predict-toggle"> Predict</label>
<label><input type="checkbox" id="bloom-toggle"> Bloom</label>
<label><input type="checkbox" id="vel-toggle"> Vel</label>
<label><input type="checkbox" id="heat-toggle"> Heat</label>
<label><input type="checkbox" id="map-toggle"> Map</label>
<label><input type="checkbox" id="chase-toggle"> Chase</label>
</div>
<div id="rec-list">Loading...</div>
<div id="stats-box"></div>
</div>
<div id="playback-bar">
<span id="pb-name"></span>
<button id="pb-prev" title="Prev frame (Shift+←)"></button>
<button id="pb-play"></button>
<button id="pb-next" title="Next frame (Shift+→)"></button>
<input type="range" id="pb-scrub" min="0" max="1000" value="0">
<span id="pb-time">0:00.000 / 0:00.000</span>
<select id="pb-speed">
<option value="0.25">0.25×</option>
<option value="0.5">0.5×</option>
<option value="1" selected>1×</option>
<option value="2">2×</option>
<option value="4">4×</option>
<option value="8">8×</option>
</select>
<button id="pb-autoplay" class="active">⏭ Auto: ON</button>
<button onclick="stopPlayback()"></button>
</div>
<script src="https://cdnjs.cloudflare.com/ajax/libs/three.js/r128/three.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/three@0.128.0/examples/js/postprocessing/EffectComposer.js"></script>
<script src="https://cdn.jsdelivr.net/npm/three@0.128.0/examples/js/postprocessing/RenderPass.js"></script>
<script src="https://cdn.jsdelivr.net/npm/three@0.128.0/examples/js/postprocessing/ShaderPass.js"></script>
<script src="https://cdn.jsdelivr.net/npm/three@0.128.0/examples/js/postprocessing/UnrealBloomPass.js"></script>
<script src="https://cdn.jsdelivr.net/npm/three@0.128.0/examples/js/shaders/CopyShader.js"></script>
<script src="https://cdn.jsdelivr.net/npm/three@0.128.0/examples/js/shaders/LuminosityHighPassShader.js"></script>
<script>
'use strict';
// ── Constants (Unreal Units; 1 uu = 1 cm) ─────────────────────────────────
const SIDE_WALL_X = 4096;
const BACK_WALL_Y = 5120;
const CEILING_Z = 2044;
const CORNER_CAT = 1152;
const GOAL_HALF_W = 892.755;
const GOAL_HEIGHT = 642.775;
const GOAL_DEPTH = 880;
const BALL_RADIUS = 91.25;
const SUPERSONIC = 2200;
const WS_LIVE = (location.protocol === 'https:' ? 'wss://' : 'ws://') + location.host + '/ws';
const WS_PLAY = (location.protocol === 'https:' ? 'wss://' : 'ws://') + location.host + '/ws/playback';
// ── Scene setup ───────────────────────────────────────────────────────────
const scene = new THREE.Scene();
scene.background = new THREE.Color(0x0d0d1a);
scene.fog = new THREE.FogExp2(0x0d0d1a, 0.000032);
const camera = new THREE.PerspectiveCamera(55, innerWidth / innerHeight, 10, 30000);
camera.position.set(0, 5000, 4000);
camera.lookAt(0, 0, 500);
const isMobile = matchMedia('(max-width: 700px)').matches;
let lowQuality = isMobile;
const renderer = new THREE.WebGLRenderer({
canvas: document.getElementById('canvas'),
antialias: !lowQuality,
powerPreference: 'high-performance',
});
if (!renderer.getContext()) {
document.body.innerHTML = '<div style="color:white;padding:20px;font-family:monospace">No WebGL context available.</div>';
throw new Error('WebGL unavailable');
}
renderer.setSize(innerWidth, innerHeight);
renderer.toneMapping = THREE.ACESFilmicToneMapping;
renderer.toneMappingExposure = 1.05;
renderer.outputEncoding = THREE.sRGBEncoding;
// Postprocessing (bloom) — gated by quality + toggle. Declared before applyQuality.
let composer = null, bloomPass = null, renderPass = null;
let useBloom = (localStorage.getItem('use_bloom') ?? (isMobile ? '0' : '1')) === '1';
function setupComposer() {
if (composer) { composer.passes.length = 0; composer = null; bloomPass = null; renderPass = null; }
if (lowQuality || !useBloom || typeof THREE.EffectComposer === 'undefined') return;
composer = new THREE.EffectComposer(renderer);
renderPass = new THREE.RenderPass(scene, camera);
composer.addPass(renderPass);
bloomPass = new THREE.UnrealBloomPass(new THREE.Vector2(innerWidth, innerHeight), 0.55, 0.7, 0.85);
composer.addPass(bloomPass);
}
function applyQuality() {
renderer.setPixelRatio(lowQuality ? 1 : Math.min(devicePixelRatio, 2));
renderer.shadowMap.enabled = !lowQuality;
renderer.shadowMap.type = THREE.PCFSoftShadowMap;
scene.traverse(o => { if (o.isMesh) { o.castShadow = o.userData._castShadow && !lowQuality; o.receiveShadow = o.userData._receiveShadow && !lowQuality; } });
setupComposer();
}
window.addEventListener('resize', () => {
camera.aspect = innerWidth / innerHeight;
camera.updateProjectionMatrix();
renderer.setSize(innerWidth, innerHeight);
if (composer) composer.setSize(innerWidth, innerHeight);
cameraDirty = true;
});
// ── Lighting ──────────────────────────────────────────────────────────────
scene.add(new THREE.HemisphereLight(0xbcd3ff, 0x202028, 0.55));
scene.add(new THREE.AmbientLight(0x8899bb, 0.35));
const sun = new THREE.DirectionalLight(0xffffff, 0.85);
sun.position.set(2000, 4000, 5000);
sun.castShadow = true;
sun.userData._castShadow = true;
sun.shadow.mapSize.set(2048, 2048);
sun.shadow.camera.near = 100;
sun.shadow.camera.far = 20000;
sun.shadow.camera.left = -8000; sun.shadow.camera.right = 8000;
sun.shadow.camera.top = 8000; sun.shadow.camera.bottom = -8000;
scene.add(sun);
const blueLight = new THREE.PointLight(0x4488ff, 1.4, 4500);
blueLight.position.set(0, 400, BACK_WALL_Y + 500);
scene.add(blueLight);
const orangeLight = new THREE.PointLight(0xff6611, 1.4, 4500);
orangeLight.position.set(0, 400, -(BACK_WALL_Y + 500));
scene.add(orangeLight);
// ── Field geometry (unchanged from previous) ──────────────────────────────
function line(pts, color = 0x2a4a2a) {
return new THREE.Line(
new THREE.BufferGeometry().setFromPoints(pts.map(([x,y,z]) => new THREE.Vector3(x, z ?? 1, -y))),
new THREE.LineBasicMaterial({ color, transparent: true, opacity: 0.8 })
);
}
const shape = new THREE.Shape();
const W = SIDE_WALL_X, L = BACK_WALL_Y, C = CORNER_CAT;
shape.moveTo(-W+C, L); shape.lineTo(W-C, L);
shape.lineTo(W, L-C); shape.lineTo(W, -L+C);
shape.lineTo(W-C, -L); shape.lineTo(-W+C, -L);
shape.lineTo(-W, -L+C); shape.lineTo(-W, L-C);
shape.closePath();
const floorMesh = new THREE.Mesh(new THREE.ShapeGeometry(shape), new THREE.MeshLambertMaterial({ color: 0x152515, side: THREE.DoubleSide }));
floorMesh.rotation.x = Math.PI / 2; floorMesh.receiveShadow = true; floorMesh.userData._receiveShadow = true;
scene.add(floorMesh);
scene.add(line([[-W, 0], [W, 0]]));
{ const c = []; for (let i = 0; i <= 64; i++) { const a = i/64*Math.PI*2; c.push([Math.cos(a)*880, Math.sin(a)*880]); } scene.add(line(c)); }
scene.add(line([[-W, -L+C], [-W, L-C]])); scene.add(line([[W, -L+C], [W, L-C]]));
scene.add(line([[-W+C, -L], [-GOAL_HALF_W, -L]])); scene.add(line([[GOAL_HALF_W, -L], [W-C, -L]]));
scene.add(line([[-W+C, L], [-GOAL_HALF_W, L]])); scene.add(line([[GOAL_HALF_W, L], [W-C, L]]));
scene.add(line([[-W+C, L], [-W, L-C]])); scene.add(line([[W-C, L], [W, L-C]]));
scene.add(line([[-W+C, -L], [-W, -L+C]])); scene.add(line([[W-C, -L], [W, -L+C]]));
function wall(w, h, pos, ry = 0) {
const m = new THREE.Mesh(new THREE.PlaneGeometry(w, h),
new THREE.MeshLambertMaterial({ color: 0x1a1a30, side: THREE.DoubleSide, transparent: true, opacity: 0.4 }));
m.position.set(...pos); m.rotation.y = ry; scene.add(m);
}
const wallSpan = (L - C) * 2;
wall(wallSpan, CEILING_Z, [W, CEILING_Z/2, 0], Math.PI/2);
wall(wallSpan, CEILING_Z, [-W, CEILING_Z/2, 0], Math.PI/2);
const sidePanel = W - GOAL_HALF_W - C/2;
wall(sidePanel, CEILING_Z, [-(GOAL_HALF_W + sidePanel/2), CEILING_Z/2, L], 0);
wall(sidePanel, CEILING_Z, [GOAL_HALF_W + sidePanel/2, CEILING_Z/2, L], 0);
wall(GOAL_HALF_W*2, CEILING_Z - GOAL_HEIGHT, [0, GOAL_HEIGHT + (CEILING_Z-GOAL_HEIGHT)/2, L], 0);
wall(sidePanel, CEILING_Z, [-(GOAL_HALF_W + sidePanel/2), CEILING_Z/2, -L], 0);
wall(sidePanel, CEILING_Z, [GOAL_HALF_W + sidePanel/2, CEILING_Z/2, -L], 0);
wall(GOAL_HALF_W*2, CEILING_Z - GOAL_HEIGHT, [0, GOAL_HEIGHT + (CEILING_Z-GOAL_HEIGHT)/2, -L], 0);
const diagLen = Math.sqrt(2) * C;
[[-1,-1],[-1,1],[1,-1],[1,1]].forEach(([sx, sy]) => {
const m = new THREE.Mesh(new THREE.PlaneGeometry(diagLen, CEILING_Z),
new THREE.MeshLambertMaterial({ color: 0x1a1a30, side: THREE.DoubleSide, transparent: true, opacity: 0.35 }));
m.position.set(sx*(W - C/2), CEILING_Z/2, -sy*(L - C/2));
m.rotation.y = Math.PI/4 * (sx*sy > 0 ? 1 : -1);
scene.add(m);
});
const ceilMesh = new THREE.Mesh(new THREE.ShapeGeometry(shape),
new THREE.MeshLambertMaterial({ color: 0x111122, side: THREE.DoubleSide, transparent: true, opacity: 0.25 }));
ceilMesh.rotation.x = Math.PI / 2; ceilMesh.position.y = CEILING_Z;
scene.add(ceilMesh);
const edgeC = 0x334466;
scene.add(line([[-W, -L+C], [-W+C, -L]], edgeC));
scene.add(line([[W, -L+C], [W-C, -L]], edgeC));
scene.add(line([[-W, L-C], [-W+C, L]], edgeC));
scene.add(line([[W, L-C], [W-C, L]], edgeC));
function makeGoal(ySign, teamColor) {
const g = new THREE.Group();
const netMat = new THREE.MeshLambertMaterial({ color: teamColor, transparent: true, opacity: 0.3, side: THREE.DoubleSide });
const postMat = new THREE.MeshLambertMaterial({ color: 0xdddddd });
const T = 28, GW = GOAL_HALF_W * 2, GH = GOAL_HEIGHT, GD = GOAL_DEPTH;
const back = new THREE.Mesh(new THREE.BoxGeometry(GW, T, GH), netMat);
back.position.set(0, GH/2, -ySign*(BACK_WALL_Y + GD)); g.add(back);
for (const sx of [-1, 1]) {
const s = new THREE.Mesh(new THREE.BoxGeometry(T, GD, GH), netMat);
s.position.set(sx*GOAL_HALF_W, GH/2, -ySign*(BACK_WALL_Y + GD/2)); g.add(s);
}
const fn = new THREE.Mesh(new THREE.BoxGeometry(GW, GD, T), netMat);
fn.position.set(0, T/2, -ySign*(BACK_WALL_Y + GD/2)); g.add(fn);
const cb = new THREE.Mesh(new THREE.BoxGeometry(GW + T*2, T, T), postMat);
cb.position.set(0, GH, -ySign*BACK_WALL_Y); g.add(cb);
for (const sx of [-1, 1]) {
const p = new THREE.Mesh(new THREE.BoxGeometry(T, GH, T), postMat);
p.position.set(sx*GOAL_HALF_W, GH/2, -ySign*BACK_WALL_Y); g.add(p);
}
scene.add(g);
}
makeGoal(-1, 0x3366ff);
makeGoal(1, 0xff6600);
// ── Boost pads ────────────────────────────────────────────────────────────
const PAD_DEFS = [
{p:[-3584, 0], big:true}, {p:[3584, 0], big:true},
{p:[-3072, 4096], big:true}, {p:[3072, 4096], big:true},
{p:[-3072, -4096], big:true}, {p:[3072, -4096], big:true},
{p:[0, 4240]}, {p:[1792, 4184]}, {p:[-1792, 4184]},
{p:[2048, 1024]}, {p:[-2048, 1024]},
{p:[512, 512]}, {p:[-512, 512]},
{p:[512, -512]}, {p:[-512, -512]},
{p:[2048, -1024]}, {p:[-2048, -1024]},
{p:[1792, -4184]}, {p:[-1792, -4184]}, {p:[0, -4240]},
{p:[1024, 0]}, {p:[-1024, 0]}, {p:[0, 1024]}, {p:[0, -1024]},
{p:[3584, 2484]}, {p:[-3584, 2484]},
{p:[3584, -2484]}, {p:[-3584, -2484]},
{p:[1788, 2300]}, {p:[-1788, 2300]},
{p:[1788, -2300]}, {p:[-1788, -2300]},
{p:[2560, 0]}, {p:[-2560, 0]},
];
const COLOR_BOOST_ON = new THREE.Color(0xffcc00);
const COLOR_BOOST_OFF = new THREE.Color(0x2a2a18);
const EMI_BOOST_ON = new THREE.Color(0x553300);
const EMI_BOOST_OFF = new THREE.Color(0x000000);
const boostMeshes = PAD_DEFS.map(({ p, big }) => {
const r = big ? 144 : 72;
const m = new THREE.Mesh(
new THREE.CylinderGeometry(r, r, big ? 24 : 16, 16),
new THREE.MeshLambertMaterial({ color: COLOR_BOOST_ON.clone(), emissive: EMI_BOOST_ON.clone() })
);
m.position.set(p[0], 8, -p[1]);
scene.add(m);
return { m, big, on: true };
});
const activeBoosts = new Set(boostMeshes.map((_, i) => i));
// ── Ball ──────────────────────────────────────────────────────────────────
const ball = new THREE.Mesh(
new THREE.SphereGeometry(BALL_RADIUS, 32, 24),
new THREE.MeshStandardMaterial({ color: 0xf2f2f5, emissive: 0x222233, roughness: 0.35, metalness: 0.3 })
);
ball.castShadow = true; ball.userData._castShadow = true;
scene.add(ball);
const ballRing = new THREE.Mesh(
new THREE.RingGeometry(BALL_RADIUS * 0.85, BALL_RADIUS * 1.15, 32),
new THREE.MeshBasicMaterial({ color: 0xffffff, transparent: true, opacity: 0.15, side: THREE.DoubleSide })
);
ballRing.rotation.x = -Math.PI / 2;
scene.add(ballRing);
// Ball blob shadow (fades with height).
const ballBlob = new THREE.Mesh(
new THREE.CircleGeometry(BALL_RADIUS * 0.95, 28),
new THREE.MeshBasicMaterial({ color: 0x000000, transparent: true, opacity: 0.5, depthWrite: false })
);
ballBlob.rotation.x = -Math.PI/2;
scene.add(ballBlob);
// Supersonic ball glow (additive sprite-like sphere).
const ballGlow = new THREE.Mesh(
new THREE.SphereGeometry(BALL_RADIUS * 1.6, 16, 12),
new THREE.MeshBasicMaterial({ color: 0xffaa55, transparent: true, opacity: 0, blending: THREE.AdditiveBlending, depthWrite: false })
);
scene.add(ballGlow);
// Touch flash pool (expanding rings).
const flashPool = [];
for (let i = 0; i < 12; i++) {
const m = new THREE.Mesh(
new THREE.RingGeometry(40, 60, 32),
new THREE.MeshBasicMaterial({ color: 0xffffff, transparent: true, opacity: 0, side: THREE.DoubleSide, blending: THREE.AdditiveBlending, depthWrite: false })
);
m.rotation.x = -Math.PI/2;
m.visible = false;
flashPool.push({ mesh: m, t0: 0, life: 0.6, color: new THREE.Color() });
scene.add(m);
}
function spawnFlash(x, y, z, colorHex) {
for (const f of flashPool) {
if (!f.mesh.visible) {
f.mesh.visible = true;
f.mesh.position.set(x, y, z);
f.t0 = performance.now();
f.color.setHex(colorHex);
f.mesh.material.color.copy(f.color);
return;
}
}
}
// Goal explosion: simple particle burst, pooled.
const goalParticles = [];
const goalGroup = new THREE.Group();
scene.add(goalGroup);
function spawnGoalBurst(teamColorHex, position) {
const N = 80;
const geo = new THREE.BufferGeometry();
const pos = new Float32Array(N * 3);
const vel = new Float32Array(N * 3);
for (let i = 0; i < N; i++) {
pos[i*3] = position.x; pos[i*3+1] = position.y; pos[i*3+2] = position.z;
const th = Math.random() * Math.PI * 2;
const ph = Math.acos(2 * Math.random() - 1);
const sp = 800 + Math.random() * 2400;
vel[i*3] = sp * Math.sin(ph) * Math.cos(th);
vel[i*3+1] = sp * Math.cos(ph);
vel[i*3+2] = sp * Math.sin(ph) * Math.sin(th);
}
geo.setAttribute('position', new THREE.BufferAttribute(pos, 3));
const mat = new THREE.PointsMaterial({ color: teamColorHex, size: 30, transparent: true, opacity: 1, blending: THREE.AdditiveBlending, depthWrite: false });
const pts = new THREE.Points(geo, mat);
goalGroup.add(pts);
goalParticles.push({ pts, vel, t0: performance.now(), life: 1.6 });
}
// Ball trail (recent positions) + predicted path (ballistic from velocity).
const TRAIL_LEN = 120; // ~2s @ 60Hz
const PREDICT_STEPS = 60; // forward samples
const PREDICT_DT = 0.033; // seconds per sample → ~2s lookahead
const GRAVITY = -650; // RL units/s² (z-down, becomes -y in three space)
const trailPositions = new Float32Array(TRAIL_LEN * 3);
const trailColors = new Float32Array(TRAIL_LEN * 3);
const trailGeo = new THREE.BufferGeometry();
trailGeo.setAttribute('position', new THREE.BufferAttribute(trailPositions, 3));
trailGeo.setAttribute('color', new THREE.BufferAttribute(trailColors, 3));
const trailLine = new THREE.Line(
trailGeo,
new THREE.LineBasicMaterial({ vertexColors: true, transparent: true, opacity: 0.9 })
);
trailLine.frustumCulled = false;
scene.add(trailLine);
let trailCount = 0; // valid samples currently in buffer
const predictPositions = new Float32Array(PREDICT_STEPS * 3);
const predictGeo = new THREE.BufferGeometry();
predictGeo.setAttribute('position', new THREE.BufferAttribute(predictPositions, 3));
const predictLine = new THREE.Line(
predictGeo,
new THREE.LineDashedMaterial({ color: 0x00e0ff, dashSize: 30, gapSize: 18, transparent: true, opacity: 0.7 })
);
predictLine.frustumCulled = false;
scene.add(predictLine);
let showTrail = (localStorage.getItem('show_trail') ?? '1') === '1';
let showPredict = (localStorage.getItem('show_predict') ?? '1') === '1';
trailLine.visible = showTrail;
predictLine.visible = showPredict;
function resetTrail() {
trailCount = 0;
trailGeo.setDrawRange(0, 0);
}
function pushTrail(x, y, z) {
// Shift left by one sample, append new at end.
if (trailCount < TRAIL_LEN) {
const o = trailCount * 3;
trailPositions[o] = x; trailPositions[o+1] = y; trailPositions[o+2] = z;
trailCount++;
} else {
trailPositions.copyWithin(0, 3);
const o = (TRAIL_LEN - 1) * 3;
trailPositions[o] = x; trailPositions[o+1] = y; trailPositions[o+2] = z;
}
// Recolor: oldest → faint blue, newest → bright white.
for (let i = 0; i < trailCount; i++) {
const t = i / Math.max(1, trailCount - 1); // 0..1
const o = i * 3;
trailColors[o] = 0.3 + 0.7 * t; // r
trailColors[o+1] = 0.6 + 0.4 * t; // g
trailColors[o+2] = 1.0; // b
}
trailGeo.attributes.position.needsUpdate = true;
trailGeo.attributes.color.needsUpdate = true;
trailGeo.setDrawRange(0, trailCount);
}
// Compute ballistic prediction: integrate position with gravity until ground or steps exhausted.
// Inputs are RL-space (x, y, z); output written to predictPositions in three-space.
function updatePredict(px, py, pz, vx, vy, vz) {
let x = px, y = py, z = pz;
let ux = vx, uy = vy, uz = vz;
for (let i = 0; i < PREDICT_STEPS; i++) {
// Step
x += ux * PREDICT_DT;
y += uy * PREDICT_DT;
z += uz * PREDICT_DT;
uz += GRAVITY * PREDICT_DT;
// Ground clamp (RL z=BALL_RADIUS is the floor for the ball center).
if (z < BALL_RADIUS) { z = BALL_RADIUS; uz = -uz * 0.6; }
const o = i * 3;
// RL → three: (x, z, -y)
predictPositions[o] = x;
predictPositions[o+1] = z;
predictPositions[o+2] = -y;
}
predictGeo.attributes.position.needsUpdate = true;
predictLine.computeLineDistances(); // required for dashed material
}
// ── Cars ──────────────────────────────────────────────────────────────────
const carMeshes = {};
const carData = {}; // per-id auxiliary: { flame, flameMat, trailGeo, trailPos, trailIdx, trailLine, blob, velArrow, lastBoost, lastTouchedFlag }
const FLAME_TRAIL_LEN = 48;
const TEAM_COLOR = { 0: 0x4aa6ff, 1: 0xff7733 };
const TEAM_EMISSIVE = { 0: 0x113355, 1: 0x331a00 };
// Procedural Octane-ish silhouette. Built from extruded chassis + cockpit wedge + wheels.
// All units in cm (RL). Approximate dimensions matched to Octane hitbox (118 × 84 × 36).
function buildCar(teamNum) {
const color = TEAM_COLOR[teamNum] ?? 0xcccccc;
const emissive = TEAM_EMISSIVE[teamNum] ?? 0x222222;
const g = new THREE.Group();
// Lower chassis: extruded shape giving tapered nose + flared rear.
const chassisShape = new THREE.Shape();
chassisShape.moveTo( 60, 18); // nose top
chassisShape.lineTo( 70, 0); // nose tip
chassisShape.lineTo( 60, -18);
chassisShape.lineTo(-58, -22);
chassisShape.lineTo(-72, -8);
chassisShape.lineTo(-72, 18);
chassisShape.lineTo(-58, 24);
chassisShape.lineTo( 60, 18);
const chassisGeo = new THREE.ExtrudeGeometry(chassisShape, {
depth: 80, bevelEnabled: true, bevelSize: 6, bevelThickness: 4, bevelSegments: 2, steps: 1,
});
chassisGeo.translate(0, 0, -40);
chassisGeo.rotateX(Math.PI / 2);
chassisGeo.translate(0, 16, 0);
const chassisMat = new THREE.MeshStandardMaterial({
color, metalness: 0.55, roughness: 0.35, emissive, emissiveIntensity: 0.15,
});
const chassis = new THREE.Mesh(chassisGeo, chassisMat);
chassis.castShadow = true; chassis.userData._castShadow = true;
g.add(chassis);
// Cockpit wedge (smaller, set back).
const cockpitShape = new THREE.Shape();
cockpitShape.moveTo( 18, 0);
cockpitShape.lineTo( 32, 14);
cockpitShape.lineTo(-28, 22);
cockpitShape.lineTo(-40, 0);
const cockpitGeo = new THREE.ExtrudeGeometry(cockpitShape, {
depth: 56, bevelEnabled: true, bevelSize: 3, bevelThickness: 2, bevelSegments: 1, steps: 1,
});
cockpitGeo.translate(0, 0, -28);
cockpitGeo.rotateX(Math.PI / 2);
cockpitGeo.translate(0, 38, 4);
const cockpit = new THREE.Mesh(cockpitGeo, new THREE.MeshStandardMaterial({
color: 0x111422, metalness: 0.8, roughness: 0.25,
}));
cockpit.castShadow = true; cockpit.userData._castShadow = true;
g.add(cockpit);
// Windshield glass.
const windGeo = new THREE.PlaneGeometry(36, 26);
const wind = new THREE.Mesh(windGeo, new THREE.MeshStandardMaterial({
color: 0x6688aa, metalness: 0.95, roughness: 0.05, transparent: true, opacity: 0.55,
}));
wind.position.set(12, 50, 0);
wind.rotation.x = -Math.PI / 2;
wind.rotation.z = Math.PI / 2;
wind.rotation.y = -0.45;
g.add(wind);
// Team accent strip on hood (small, emissive — picks up bloom).
const strip = new THREE.Mesh(
new THREE.BoxGeometry(48, 1.5, 32),
new THREE.MeshStandardMaterial({ color, emissive: color, emissiveIntensity: 1.6, metalness: 0.3, roughness: 0.4 })
);
strip.position.set(30, 38, 0);
g.add(strip);
// Rear spoiler.
const spoilerPosts = new THREE.Mesh(new THREE.BoxGeometry(4, 12, 56), new THREE.MeshStandardMaterial({ color: 0x222222, metalness: 0.8, roughness: 0.3 }));
spoilerPosts.position.set(-56, 42, 0);
g.add(spoilerPosts);
const spoilerWing = new THREE.Mesh(new THREE.BoxGeometry(18, 3, 70), new THREE.MeshStandardMaterial({ color: 0x111111, metalness: 0.7, roughness: 0.3 }));
spoilerWing.position.set(-58, 50, 0);
g.add(spoilerWing);
// Wheels (4): low-poly torus rims look prettier than cylinders.
const tyreGeo = new THREE.CylinderGeometry(20, 20, 14, 18);
const tyreMat = new THREE.MeshStandardMaterial({ color: 0x0a0a0a, roughness: 0.85, metalness: 0.1 });
const rimGeo = new THREE.TorusGeometry(11, 4, 6, 12);
const rimMat = new THREE.MeshStandardMaterial({ color: 0xaaaaaa, metalness: 0.9, roughness: 0.25 });
[[42,42],[42,-42],[-42,42],[-42,-42]].forEach(([x, z]) => {
const t = new THREE.Mesh(tyreGeo, tyreMat); t.rotation.x = Math.PI/2; t.position.set(x, 20, z); t.castShadow = true; t.userData._castShadow = true; g.add(t);
const r = new THREE.Mesh(rimGeo, rimMat); r.rotation.y = Math.PI/2; r.position.set(x, 20, z); g.add(r);
});
// Headlights.
const hlMat = new THREE.MeshStandardMaterial({ color: 0xffffee, emissive: 0xffffcc, emissiveIntensity: 1.2 });
[[64, 24, 22], [64, 24, -22]].forEach(([x,y,z]) => {
const hl = new THREE.Mesh(new THREE.SphereGeometry(5, 8, 6), hlMat); hl.position.set(x, y, z); g.add(hl);
});
// Blob shadow (always cheap, replaces shadow map on LowQ).
const blob = new THREE.Mesh(
new THREE.CircleGeometry(78, 24),
new THREE.MeshBasicMaterial({ color: 0x000000, transparent: true, opacity: 0.45, depthWrite: false })
);
blob.rotation.x = -Math.PI/2;
// Flame trail (ribbon of segments behind exhaust). Buffered line for cheapness.
const flamePos = new Float32Array(FLAME_TRAIL_LEN * 3);
const flameCol = new Float32Array(FLAME_TRAIL_LEN * 3);
const flameGeo = new THREE.BufferGeometry();
flameGeo.setAttribute('position', new THREE.BufferAttribute(flamePos, 3));
flameGeo.setAttribute('color', new THREE.BufferAttribute(flameCol, 3));
flameGeo.setDrawRange(0, 0);
const flameMat = new THREE.LineBasicMaterial({ vertexColors: true, transparent: true, opacity: 0.85, blending: THREE.AdditiveBlending, depthWrite: false });
const flameLine = new THREE.Line(flameGeo, flameMat);
flameLine.frustumCulled = false;
flameLine.visible = false;
// Velocity arrow (debug overlay).
const velArrow = new THREE.ArrowHelper(new THREE.Vector3(1,0,0), new THREE.Vector3(0,0,0), 200, color, 60, 30);
velArrow.visible = false;
carData[g.uuid] = {
blob, flameLine, flameGeo, flamePos, flameCol, flameCount: 0,
velArrow, teamColor: new THREE.Color(color),
touchFlashEnd: 0,
};
return g;
}
function getCar(id, teamNum) {
if (!carMeshes[id]) {
const m = buildCar(teamNum);
carMeshes[id] = m;
scene.add(m);
const d = carData[m.uuid];
scene.add(d.blob);
scene.add(d.flameLine);
scene.add(d.velArrow);
}
return carMeshes[id];
}
// ── Coordinate conversion: RL (x, y, z) → Three (x, z, -y) ────────────────
function rlToThree(out, x, y, z) { out.set(x, z, -y); return out; }
// ── Camera orbit ──────────────────────────────────────────────────────────
let drag = false, rDrag = false, mx = 0, my = 0;
let theta = 0.3, phi = 0.6, radius = 11000;
let cameraDirty = true;
const camTgt = new THREE.Vector3(0, 400, 0);
const cvs = document.getElementById('canvas');
cvs.addEventListener('mousedown', e => { drag = true; rDrag = e.button===2; mx = e.clientX; my = e.clientY; });
cvs.addEventListener('contextmenu', e => e.preventDefault());
window.addEventListener('mouseup', () => drag = false);
window.addEventListener('mousemove', e => {
if (!drag) return;
const dx = e.clientX - mx, dy = e.clientY - my;
if (rDrag) {
camTgt.x += -Math.sin(theta) * dx * 2.5;
camTgt.z += Math.cos(theta) * dx * 2.5;
camTgt.y -= dy * 2.5;
} else {
theta += dx*0.006;
phi = Math.max(0.05, Math.min(1.4, phi - dy*0.006));
}
mx = e.clientX; my = e.clientY;
cameraDirty = true;
});
cvs.addEventListener('wheel', e => {
radius = Math.max(1500, Math.min(20000, radius + e.deltaY*4));
if (chaseMode && savedRadius !== null) savedRadius = Math.max(1500, Math.min(20000, savedRadius + e.deltaY*4));
cameraDirty = true;
});
let savedRadius = null;
let savedCamTgt = new THREE.Vector3();
function updateCam() {
// Chase mode: follow ball with smoothed look-at; save/restore prior cam state.
if (chaseMode) {
if (savedRadius === null) { savedRadius = radius; savedCamTgt.copy(camTgt); }
const tx = ball.position.x, ty = ball.position.y, tz = ball.position.z;
camTgt.lerp(_v1.set(tx, ty + 200, tz), 0.12);
radius = Math.max(1500, Math.min(radius, 4500));
} else if (savedRadius !== null) {
// Restore prior camera state when leaving chase.
radius = savedRadius;
camTgt.copy(savedCamTgt);
savedRadius = null;
}
camera.position.set(
camTgt.x + radius * Math.sin(phi) * Math.cos(theta),
camTgt.y + radius * Math.cos(phi),
camTgt.z + radius * Math.sin(phi) * Math.sin(theta)
);
// Camera shake offset.
if (shakeAmp > 0.1) {
_shakeOff.set((Math.random()-0.5)*shakeAmp, (Math.random()-0.5)*shakeAmp, (Math.random()-0.5)*shakeAmp);
camera.position.add(_shakeOff);
shakeAmp *= 0.82;
} else shakeAmp = 0;
camera.lookAt(camTgt);
}
// ── State & scratch objects (no per-frame allocation) ─────────────────────
let gs = null;
let stateDirty = false;
const LERP_LIVE = 0.22;
const LERP_PLAYBACK = 0.85;
let currentLerp = LERP_LIVE;
const hudEl = document.getElementById('hud');
const statEl = document.getElementById('status');
const statTxtEl = document.getElementById('status-text');
const _v1 = new THREE.Vector3();
const _v2 = new THREE.Vector3();
const _v3 = new THREE.Vector3();
const _v4 = new THREE.Vector3();
const _m4 = new THREE.Matrix4();
const _q = new THREE.Quaternion();
// Frame timing
let lastFrameTs = 0;
let fpsEst = 0;
let pktPerSec = 0;
let pktCount = 0;
let pktWindowStart = performance.now();
function recordPacket() {
pktCount++;
const now = performance.now();
if (now - pktWindowStart >= 1000) {
pktPerSec = pktCount * 1000 / (now - pktWindowStart);
pktCount = 0;
pktWindowStart = now;
}
lastFrameTs = now;
}
// Last-touch tracking for "TOUCH" highlight
const lastTouchAt = {};
// Camera shake (decays exponentially each frame).
let shakeAmp = 0;
const _shakeOff = new THREE.Vector3();
// Chase cam.
let chaseMode = (localStorage.getItem('chase_mode') ?? '0') === '1';
let chaseTargetId = null;
// Velocity arrow & heatmap toggles.
let showVel = (localStorage.getItem('show_vel') ?? '0') === '1';
let showHeat = (localStorage.getItem('show_heat') ?? '0') === '1';
let showMap = (localStorage.getItem('show_map') ?? '0') === '1';
// Heatmap: low-res 2D bin of ball XY samples.
const HEAT_W = 80, HEAT_H = 100;
const heatBins = new Float32Array(HEAT_W * HEAT_H);
let heatMax = 1;
const heatCanvas = document.createElement('canvas');
heatCanvas.width = HEAT_W; heatCanvas.height = HEAT_H;
const heatCtx = heatCanvas.getContext('2d');
const heatTex = new THREE.CanvasTexture(heatCanvas);
heatTex.minFilter = THREE.LinearFilter;
heatTex.magFilter = THREE.LinearFilter;
const heatMesh = new THREE.Mesh(
new THREE.PlaneGeometry(SIDE_WALL_X*2, BACK_WALL_Y*2),
new THREE.MeshBasicMaterial({ map: heatTex, transparent: true, opacity: 0.55, depthWrite: false, blending: THREE.AdditiveBlending })
);
heatMesh.rotation.x = -Math.PI/2;
heatMesh.position.y = 3;
heatMesh.visible = showHeat;
scene.add(heatMesh);
let heatRedrawDue = 0;
function recordHeat(x, y) {
const ix = Math.floor((x + SIDE_WALL_X) / (SIDE_WALL_X*2) * HEAT_W);
const iy = Math.floor((y + BACK_WALL_Y) / (BACK_WALL_Y*2) * HEAT_H);
if (ix < 0 || iy < 0 || ix >= HEAT_W || iy >= HEAT_H) return;
const v = (heatBins[iy * HEAT_W + ix] += 1);
if (v > heatMax) heatMax = v;
}
function redrawHeat() {
const img = heatCtx.createImageData(HEAT_W, HEAT_H);
for (let i = 0; i < heatBins.length; i++) {
const v = heatBins[i] / heatMax;
const o = i * 4;
// analytical palette: cyan→white through warm
img.data[o] = Math.min(255, v * 320);
img.data[o+1] = Math.min(255, v * 200);
img.data[o+2] = Math.min(255, 80 + v * 180);
img.data[o+3] = Math.min(220, v * 255);
}
heatCtx.putImageData(img, 0, 0);
heatTex.needsUpdate = true;
}
// Per-touch detection (rising edge of ball_touched per car).
const prevTouched = {};
const BALL_VEL_PREV = new Float32Array(3);
function applyState(s) {
if (!s || !s.ball_phys || !s.ball_phys.pos) return;
const bp = s.ball_phys.pos;
const bv = s.ball_phys.vel || [0, 0, 0];
const bspd = Math.sqrt(bv[0]*bv[0] + bv[1]*bv[1] + bv[2]*bv[2]);
rlToThree(_v1, bp[0], bp[1], bp[2]);
ball.position.lerp(_v1, currentLerp);
ballRing.position.set(ball.position.x, 1, ball.position.z);
// Ball spin derived from velocity magnitude (no angular vel in stream, fake it).
if (bspd > 5) {
const spinRate = bspd / BALL_RADIUS * 0.02; // empirical
ball.rotation.x += spinRate * (bv[1] / Math.max(1, Math.abs(bv[1]))) * 0.5;
ball.rotation.z -= spinRate * (bv[0] / Math.max(1, Math.abs(bv[0]))) * 0.5;
}
// Ball blob shadow + fade with height.
ballBlob.position.set(ball.position.x, 2, ball.position.z);
const h = Math.max(BALL_RADIUS, ball.position.y);
const hk = Math.max(0.1, Math.min(1, 1 - (h - BALL_RADIUS) / 2500));
ballBlob.material.opacity = 0.5 * hk;
ballBlob.scale.setScalar(1 + (h - BALL_RADIUS) / 1500);
// Supersonic ball: glow & ring color shift.
const ballSonic = bspd >= 4400; // ball "supersonic" threshold
ballGlow.position.copy(ball.position);
ballGlow.material.opacity = ballSonic ? 0.45 : Math.max(0, (bspd - 2200) / 4400) * 0.25;
ballRing.material.color.setHex(ballSonic ? 0xff8844 : 0xffffff);
// Trail: append current ball position (in three-space).
if (showTrail) pushTrail(ball.position.x, ball.position.y, ball.position.z);
// Predicted path: forward-integrate from current pos/vel.
if (showPredict) {
updatePredict(bp[0], bp[1], bp[2], bv[0], bv[1], bv[2]);
}
// Heatmap accumulation (sample every frame, redraw at 5Hz).
if (showHeat) {
recordHeat(bp[0], bp[1]);
const now = performance.now();
if (now - heatRedrawDue > 200) { redrawHeat(); heatRedrawDue = now; }
}
if (s.cars) {
for (let i = 0; i < s.cars.length; i++) {
const c = s.cars[i];
if (!c.phys || !c.phys.pos) continue;
const mesh = getCar(c.car_id, c.team_num);
const d = carData[mesh.uuid];
const p = c.phys.pos, f = c.phys.forward, u = c.phys.up, r = c.phys.right;
mesh.visible = !c.is_demoed;
if (d) { d.blob.visible = mesh.visible; d.flameLine.visible = mesh.visible && !!(c.boost_active || c.is_boosting); d.velArrow.visible = mesh.visible && showVel; }
rlToThree(_v1, p[0], p[1], p[2]);
mesh.position.lerp(_v1, currentLerp);
if (f && u && r) {
// RL basis → Three basis (in-place)
_v2.set(f[0], f[2], -f[1]).normalize(); // forward
_v3.set(r[0], r[2], -r[1]).normalize(); // right
_v4.set(u[0], u[2], -u[1]).normalize(); // up
_v2.negate(); // car "forward" maps to -Z in Three basis we use
_m4.makeBasis(_v3, _v4, _v2);
_q.setFromRotationMatrix(_m4);
mesh.quaternion.slerp(_q, currentLerp);
}
// Blob under car, fade by height.
if (d) {
d.blob.position.set(mesh.position.x, 2, mesh.position.z);
const ch = Math.max(20, mesh.position.y);
const chk = Math.max(0.1, Math.min(1, 1 - (ch - 20) / 1800));
d.blob.material.opacity = 0.45 * chk;
d.blob.scale.setScalar(1 + (ch - 20) / 1200);
}
// Velocity arrow.
if (d && showVel && c.phys.vel) {
const cv = c.phys.vel;
const csp = Math.sqrt(cv[0]*cv[0] + cv[1]*cv[1] + cv[2]*cv[2]);
if (csp > 1) {
_v2.set(cv[0], cv[2], -cv[1]).normalize();
d.velArrow.position.set(mesh.position.x, mesh.position.y + 40, mesh.position.z);
d.velArrow.setDirection(_v2);
d.velArrow.setLength(Math.min(800, csp * 0.4), 50, 25);
}
}
// Flame trail: append exhaust position when boosting.
if (d && (c.boost_active || c.is_boosting)) {
// Exhaust = behind car (-forward) offset by ~70 from center, slightly above floor.
if (f) {
const fx = f[0], fy = f[1], fz = f[2];
// RL space: exhaust pos = car_pos - forward*70 (RL units)
const ex = p[0] - fx * 70, ey = p[1] - fy * 70, ez = p[2] - fz * 70 + 10;
rlToThree(_v2, ex, ey, ez);
pushFlame(d, _v2.x, _v2.y, _v2.z, c.team_num);
}
} else if (d && d.flameCount > 0) {
// Decay flame.
d.flameCount = Math.max(0, d.flameCount - 3);
d.flameGeo.setDrawRange(0, d.flameCount);
}
// Rising-edge touch detection → flash + camera shake.
const wasT = !!prevTouched[c.car_id];
const nowT = !!c.ball_touched;
if (nowT && !wasT) {
lastTouchAt[c.car_id] = performance.now();
const colHex = c.team_num === 0 ? 0x4aa6ff : 0xff7733;
spawnFlash(ball.position.x, ball.position.y, ball.position.z, colHex);
shakeAmp = Math.min(40, shakeAmp + 18);
}
prevTouched[c.car_id] = nowT;
}
}
// Boost pad diffing
if (s.boost_pad_states) {
const arr = s.boost_pad_states;
for (let i = 0; i < arr.length; i++) {
const bm = boostMeshes[i];
if (!bm) continue;
const on = !!arr[i];
if (on !== bm.on) {
bm.on = on;
bm.m.material.color.copy(on ? COLOR_BOOST_ON : COLOR_BOOST_OFF);
bm.m.material.emissive.copy(on ? EMI_BOOST_ON : EMI_BOOST_OFF);
if (on) activeBoosts.add(i); else { activeBoosts.delete(i); bm.m.scale.set(1, 1, 1); }
}
}
}
// Goal detection via ball position crossing back wall.
if (Math.abs(bp[1]) > BACK_WALL_Y + 100 && Math.abs(BALL_VEL_PREV[1]) > 100) {
const team = bp[1] > 0 ? 1 : 0; // ball in orange goal → blue scored, color orange goal blue (attacker)
const colorHex = team === 0 ? 0x4aa6ff : 0xff7733;
spawnGoalBurst(colorHex, ball.position);
shakeAmp = Math.min(80, shakeAmp + 50);
BALL_VEL_PREV[1] = 0; // debounce
} else {
BALL_VEL_PREV[0] = bv[0]; BALL_VEL_PREV[1] = bv[1]; BALL_VEL_PREV[2] = bv[2];
}
}
// Push a flame sample to a car's ribbon.
function pushFlame(d, x, y, z, teamNum) {
const colors = teamNum === 0 ? [0.4, 0.7, 1.0] : [1.0, 0.55, 0.2];
if (d.flameCount < FLAME_TRAIL_LEN) {
const o = d.flameCount * 3;
d.flamePos[o] = x; d.flamePos[o+1] = y; d.flamePos[o+2] = z;
d.flameCount++;
} else {
d.flamePos.copyWithin(0, 3);
const o = (FLAME_TRAIL_LEN - 1) * 3;
d.flamePos[o] = x; d.flamePos[o+1] = y; d.flamePos[o+2] = z;
}
for (let i = 0; i < d.flameCount; i++) {
const t = i / Math.max(1, d.flameCount - 1);
const o = i * 3;
d.flameCol[o] = colors[0] * t + (1 - t) * 1.0; // hot at head, cool at tail
d.flameCol[o+1] = colors[1] * t + (1 - t) * 0.9;
d.flameCol[o+2] = colors[2] * t + (1 - t) * 0.5;
}
d.flameGeo.attributes.position.needsUpdate = true;
d.flameGeo.attributes.color.needsUpdate = true;
d.flameGeo.setDrawRange(0, d.flameCount);
}
// HUD throttled to ~10 Hz
let lastHud = 0;
function updateHud(s) {
if (!s || !s.ball_phys || !s.ball_phys.pos) return;
const now = performance.now();
if (now - lastHud < 100) return;
lastHud = now;
const bp = s.ball_phys.pos;
const bv = s.ball_phys.vel || [0,0,0];
const ballSpeed = Math.sqrt(bv[0]*bv[0] + bv[1]*bv[1] + bv[2]*bv[2]);
const distBlue = Math.sqrt(bp[0]*bp[0] + (bp[1]+BACK_WALL_Y)*(bp[1]+BACK_WALL_Y));
const distOrange = Math.sqrt(bp[0]*bp[0] + (bp[1]-BACK_WALL_Y)*(bp[1]-BACK_WALL_Y));
let lines = [];
lines.push(`BALL (${bp[0].toFixed(0)}, ${bp[1].toFixed(0)}, ${bp[2].toFixed(0)}) ${ballSpeed.toFixed(0)} uu/s`);
lines.push(` height: ${bp[2].toFixed(0)} →blue: ${distBlue.toFixed(0)} →orange: ${distOrange.toFixed(0)}`);
if (s.cars) {
for (const c of s.cars) {
const t = c.team_num === 0 ? 'BLUE ' : 'ORANGE';
const b = Math.round((c.boost_amount ?? c.boost ?? 0) * 100);
const cv = (c.phys && c.phys.vel) || [0,0,0];
const sp = Math.sqrt(cv[0]*cv[0] + cv[1]*cv[1] + cv[2]*cv[2]);
let dball = 0;
if (c.phys && c.phys.pos) {
const dx = c.phys.pos[0]-bp[0], dy = c.phys.pos[1]-bp[1], dz = c.phys.pos[2]-bp[2];
dball = Math.sqrt(dx*dx + dy*dy + dz*dz);
}
const flags = [
c.is_demoed && 'DEMO',
!c.on_ground && 'AIR',
sp >= SUPERSONIC && 'SONIC',
(lastTouchAt[c.car_id] && now - lastTouchAt[c.car_id] < 500) && 'TOUCH',
].filter(Boolean).join(' ');
lines.push(`${t} #${c.car_id} boost:${String(b).padStart(3)}% ${sp.toFixed(0).padStart(4)} uu/s Δball:${dball.toFixed(0).padStart(5)} ${flags}`);
}
}
// Connection telemetry
const age = lastFrameTs ? Math.round(now - lastFrameTs) : -1;
lines.push(`net ${pktPerSec.toFixed(1)} pps last: ${age}ms`);
hudEl.textContent = lines.join('\n');
}
// ── Live WebSocket ────────────────────────────────────────────────────────
let liveWs = null;
let liveConnected = false;
let serverRecording = null; // {id, name, since: ms}
function setStatus() {
if (playback.active) {
statTxtEl.textContent = `Playing: ${playback.name || ''}`;
statEl.className = 'connected';
return;
}
statEl.innerHTML = '';
if (serverRecording) {
const d = document.createElement('span'); d.className = 'rec-dot'; statEl.appendChild(d);
}
const span = document.createElement('span');
span.id = 'status-text';
span.textContent = liveConnected ? (serverRecording ? `REC ${serverRecording.name}` : 'Connected (Live)') : 'Disconnected — retrying…';
statEl.appendChild(span);
statEl.className = liveConnected ? 'connected' : 'disconnected';
}
function connectLive() {
liveWs = new WebSocket(WS_LIVE);
liveWs.binaryType = 'arraybuffer';
liveWs.onopen = () => { liveConnected = true; setStatus(); };
liveWs.onmessage = e => {
try {
const data = typeof e.data === 'string' ? e.data : new TextDecoder().decode(e.data);
const parsed = JSON.parse(data);
if (parsed && parsed.type === 'event') { handleServerEvent(parsed); return; }
if (playback.active) return; // ignore live data during playback
gs = parsed; stateDirty = true;
recordPacket();
} catch(err) { console.error('Live WS parse error:', err); }
};
liveWs.onclose = () => { liveConnected = false; setStatus(); setTimeout(connectLive, 2000); };
liveWs.onerror = () => { try { liveWs.close(); } catch(_){} };
}
connectLive();
function handleServerEvent(ev) {
if (ev.kind === 'recording_started') {
serverRecording = { id: ev.recording.id, name: ev.recording.name, since: Date.now() };
setStatus();
scheduleListRefresh();
} else if (ev.kind === 'recording_stopped') {
serverRecording = null;
setStatus();
scheduleListRefresh();
} else if (ev.kind === 'recording_deleted') {
cachedRecs = cachedRecs.filter(r => r.id !== ev.recording.id);
renderList();
}
}
// ── Recordings list & stats ───────────────────────────────────────────────
let cachedRecs = [];
let panelCollapsed = false;
let listRefreshTimer = null;
function togglePanel() {
panelCollapsed = !panelCollapsed;
document.getElementById('recordings-panel').classList.toggle('collapsed', panelCollapsed);
document.getElementById('collapse-btn').textContent = panelCollapsed ? '▶' : '▼';
}
function formatDuration(ms) {
if (!ms || ms < 1000) return (ms||0) + 'ms';
const s = Math.floor(ms/1000);
if (s < 60) return s + 's';
return Math.floor(s/60) + 'm' + String(s%60).padStart(2,'0') + 's';
}
function formatTime(ms) {
const total = Math.max(0, Math.floor(ms));
const min = Math.floor(total / 60000);
const sec = Math.floor((total % 60000) / 1000);
const mss = total % 1000;
return `${min}:${String(sec).padStart(2,'0')}.${String(mss).padStart(3,'0')}`;
}
function scheduleListRefresh() {
if (listRefreshTimer) return;
listRefreshTimer = setTimeout(() => { listRefreshTimer = null; loadRecordings(); }, 200);
}
async function loadRecordings() {
const el = document.getElementById('rec-list');
const filterMins = parseInt(document.getElementById('time-filter').value) || 0;
let url = '/api/recordings';
if (filterMins > 0) {
const since = new Date(Date.now() - filterMins * 60000).toISOString();
url += '?since=' + encodeURIComponent(since);
}
try {
const res = await fetch(url);
if (!res.ok) throw new Error('HTTP ' + res.status);
cachedRecs = await res.json() || [];
renderList();
return cachedRecs;
} catch(e) {
el.innerHTML = '<div style="color:#f55">Error loading</div>';
console.error(e);
return [];
}
}
function renderList() {
const el = document.getElementById('rec-list');
if (cachedRecs.length === 0) { el.innerHTML = '<div style="color:#666">No recordings</div>'; return; }
el.innerHTML = cachedRecs.map(r => `
<div class="rec-item">
<span class="rec-name" title="${r.name}">${r.name}</span>
<span class="rec-size">${formatDuration(r.duration_ms)} · ${r.frames}f</span>
<button onclick="playRec(${r.id})">▶</button>
<button class="danger" onclick="deleteRec(${r.id})">✕</button>
</div>
`).join('');
}
async function deleteRec(id) {
try { await fetch('/api/recordings/' + id, { method: 'DELETE' }); }
catch(e) { console.error(e); }
cachedRecs = cachedRecs.filter(r => r.id !== id);
renderList();
}
async function refreshStats() {
if (panelCollapsed) return;
try {
const r = await fetch('/api/stats');
if (!r.ok) return;
const s = await r.json();
const ratio = s.compression_ratio ? s.compression_ratio.toFixed(2) + '×' : '—';
const lastAge = s.udp_last_packet_age_ms < 0 ? '—' : s.udp_last_packet_age_ms + 'ms';
const dbMB = (s.db_size_bytes / (1024*1024)).toFixed(2);
document.getElementById('stats-box').innerHTML = `
<div><span class="k">uptime:</span> <span class="v">${s.uptime_s}s</span></div>
<div><span class="k">udp pkts:</span> <span class="v">${s.udp_packets_total.toLocaleString()}</span> <span class="k">(last ${lastAge})</span></div>
<div><span class="k">ws clients:</span> <span class="v">${s.ws_clients}</span></div>
<div><span class="k">frames written:</span> <span class="v">${s.frames_written_total.toLocaleString()}</span> <span class="k">dropped:</span> <span class="v">${s.frames_dropped_total}</span></div>
<div><span class="k">compression:</span> <span class="v">${s.compression_enabled ? 'on' : 'off'}</span> <span class="k">ratio:</span> <span class="v">${ratio}</span></div>
<div><span class="k">db size:</span> <span class="v">${dbMB} MB</span></div>
<div><span class="k">retention:</span> <span class="v">${s.retention_hours}h</span></div>
`;
} catch(e) {}
}
loadRecordings();
setInterval(loadRecordings, 60000); // safety-net polling; primary refresh is event-driven
setInterval(refreshStats, 2000);
// ── Playback controller ──────────────────────────────────────────────────
const playback = {
ws: null,
active: false,
id: null,
name: '',
frames: 0,
durationMs: 0,
positionMs: 0,
playing: false,
speed: 1,
scrubbing: false,
endNotified: false,
};
const pbBar = document.getElementById('playback-bar');
const pbName = document.getElementById('pb-name');
const pbScrub = document.getElementById('pb-scrub');
const pbTime = document.getElementById('pb-time');
const pbSpeed = document.getElementById('pb-speed');
const pbPlay = document.getElementById('pb-play');
const pbAuto = document.getElementById('pb-autoplay');
let autoplay = (localStorage.getItem('autoplay') ?? '1') === '1';
function syncAutoplayUI() {
pbAuto.textContent = autoplay ? '⏭ Auto: ON' : '⏭ Auto: OFF';
pbAuto.className = autoplay ? 'active' : '';
}
syncAutoplayUI();
pbAuto.onclick = () => { autoplay = !autoplay; localStorage.setItem('autoplay', autoplay ? '1' : '0'); syncAutoplayUI(); };
const savedSpeed = parseFloat(localStorage.getItem('playback_speed') || '1');
if (savedSpeed > 0) { pbSpeed.value = String(savedSpeed); playback.speed = savedSpeed; }
pbSpeed.onchange = () => {
playback.speed = parseFloat(pbSpeed.value);
localStorage.setItem('playback_speed', pbSpeed.value);
sendCmd({ cmd: 'speed', value: playback.speed });
};
pbPlay.onclick = () => {
if (!playback.active) return;
if (playback.playing) sendCmd({ cmd: 'pause' });
else sendCmd({ cmd: 'play' });
};
let scrubThrottle = 0;
pbScrub.addEventListener('input', () => {
if (!playback.active) return;
playback.scrubbing = true;
const ms = Math.round((parseInt(pbScrub.value) / 1000) * playback.durationMs);
playback.positionMs = ms;
updateTimeDisplay();
const now = performance.now();
if (now - scrubThrottle > 50) {
scrubThrottle = now;
resetTrail();
sendCmd({ cmd: 'seek', ms });
}
});
pbScrub.addEventListener('change', () => {
if (!playback.active) return;
const ms = Math.round((parseInt(pbScrub.value) / 1000) * playback.durationMs);
resetTrail();
sendCmd({ cmd: 'seek', ms });
playback.scrubbing = false;
});
document.getElementById('pb-prev').onclick = () => {
sendCmd({ cmd: 'pause' });
resetTrail();
sendCmd({ cmd: 'seek', ms: Math.max(0, playback.positionMs - 50) });
};
document.getElementById('pb-next').onclick = () => {
sendCmd({ cmd: 'pause' });
resetTrail();
sendCmd({ cmd: 'seek', ms: Math.min(playback.durationMs, playback.positionMs + 50) });
};
function updateTimeDisplay() {
pbTime.textContent = `${formatTime(playback.positionMs)} / ${formatTime(playback.durationMs)}`;
if (!playback.scrubbing && playback.durationMs > 0) {
pbScrub.value = String(Math.round((playback.positionMs / playback.durationMs) * 1000));
}
}
function sendCmd(cmd) {
if (!playback.ws || playback.ws.readyState !== 1) return;
try { playback.ws.send(JSON.stringify(cmd)); } catch(_){}
}
function playRec(id) {
// Close any existing playback connection.
if (playback.ws) {
try { playback.ws.onclose = null; playback.ws.close(); } catch(_){}
playback.ws = null;
}
playback.active = true;
playback.id = id;
playback.endNotified = false;
playback.playing = true;
currentLerp = LERP_PLAYBACK;
document.getElementById('mode-btn').className = '';
document.getElementById('mode-btn').textContent = '▶ PLAYBACK';
pbBar.style.display = 'flex';
pbName.textContent = 'loading…';
setStatus();
playback.ws = new WebSocket(WS_PLAY + '?id=' + id);
playback.ws.binaryType = 'arraybuffer';
playback.ws.onopen = () => {
sendCmd({ cmd: 'speed', value: playback.speed });
};
playback.ws.onmessage = e => {
const text = typeof e.data === 'string' ? e.data : new TextDecoder().decode(e.data);
let msg;
try { msg = JSON.parse(text); } catch(err) { return; }
if (msg.type === 'playback_start') {
resetTrail();
playback.name = msg.name || '';
playback.frames = msg.frames || 0;
playback.durationMs = msg.duration_ms || 0;
pbName.textContent = playback.name;
updateTimeDisplay();
setStatus();
} else if (msg.type === 'playback_frame') {
gs = msg.data;
stateDirty = true;
playback.positionMs = msg.offset_ms;
if (!playback.scrubbing) updateTimeDisplay();
} else if (msg.type === 'playback_state') {
playback.playing = !!msg.playing;
playback.speed = msg.speed;
if (!playback.scrubbing) {
playback.positionMs = msg.position_ms;
updateTimeDisplay();
}
pbPlay.textContent = playback.playing ? '⏸' : '▶';
if (pbSpeed.value !== String(playback.speed)) pbSpeed.value = String(playback.speed);
} else if (msg.type === 'playback_end') {
playback.endNotified = true;
handleAutoplayNext();
} else if (msg.error) {
console.error('Playback error:', msg.error);
}
};
playback.ws.onclose = () => {
if (playback.active && !playback.endNotified) handleAutoplayNext();
};
}
async function handleAutoplayNext() {
if (!autoplay) { stopPlayback(); return; }
const curId = playback.id;
// Refresh in case new recordings appeared.
await loadRecordings();
const idx = cachedRecs.findIndex(r => Number(r.id) === Number(curId));
if (idx > 0) {
const next = cachedRecs[idx - 1];
setTimeout(() => playRec(next.id), 400);
} else {
stopPlayback();
}
}
function stopPlayback() {
if (playback.ws) {
try { playback.ws.onclose = null; sendCmd({ cmd: 'stop' }); playback.ws.close(); } catch(_){}
playback.ws = null;
}
playback.active = false;
playback.id = null;
playback.playing = false;
currentLerp = LERP_LIVE;
pbBar.style.display = 'none';
document.getElementById('mode-btn').className = 'active';
document.getElementById('mode-btn').textContent = '● LIVE';
resetTrail();
setStatus();
}
// ── Keyboard shortcuts ───────────────────────────────────────────────────
window.addEventListener('keydown', e => {
if (e.target.tagName === 'INPUT' || e.target.tagName === 'SELECT') return;
if (e.code === 'Space') {
if (playback.active) { e.preventDefault(); pbPlay.click(); }
} else if (e.code === 'ArrowLeft') {
if (playback.active) {
const delta = e.shiftKey ? 50 : 5000;
resetTrail();
sendCmd({ cmd: 'seek', ms: Math.max(0, playback.positionMs - delta) });
}
} else if (e.code === 'ArrowRight') {
if (playback.active) {
const delta = e.shiftKey ? 50 : 5000;
resetTrail();
sendCmd({ cmd: 'seek', ms: Math.min(playback.durationMs, playback.positionMs + delta) });
}
} else if (e.code === 'BracketLeft') {
const opts = [...pbSpeed.options].map(o => parseFloat(o.value));
const i = opts.indexOf(playback.speed);
if (i > 0) { pbSpeed.value = String(opts[i-1]); pbSpeed.onchange(); }
} else if (e.code === 'BracketRight') {
const opts = [...pbSpeed.options].map(o => parseFloat(o.value));
const i = opts.indexOf(playback.speed);
if (i >= 0 && i < opts.length - 1) { pbSpeed.value = String(opts[i+1]); pbSpeed.onchange(); }
} else if (e.code === 'KeyC') {
document.getElementById('chase-toggle').click();
} else if (e.code === 'KeyV') {
document.getElementById('vel-toggle').click();
} else if (e.code === 'KeyH') {
document.getElementById('heat-toggle').click();
} else if (e.code === 'KeyM') {
document.getElementById('map-toggle').click();
}
});
// ── Quality toggle ───────────────────────────────────────────────────────
document.getElementById('quality-toggle').checked = lowQuality;
document.getElementById('quality-toggle').onchange = e => {
lowQuality = e.target.checked;
applyQuality();
};
applyQuality();
// Trail / predict toggles.
const trailToggle = document.getElementById('trail-toggle');
const predictToggle = document.getElementById('predict-toggle');
trailToggle.checked = showTrail;
predictToggle.checked = showPredict;
trailToggle.onchange = e => {
showTrail = e.target.checked;
localStorage.setItem('show_trail', showTrail ? '1' : '0');
trailLine.visible = showTrail;
if (!showTrail) resetTrail();
stateDirty = true;
};
predictToggle.onchange = e => {
showPredict = e.target.checked;
localStorage.setItem('show_predict', showPredict ? '1' : '0');
predictLine.visible = showPredict;
stateDirty = true;
};
// Bloom, vel arrows, heat, minimap, chase toggles.
const bloomToggle = document.getElementById('bloom-toggle');
const velToggle = document.getElementById('vel-toggle');
const heatToggle = document.getElementById('heat-toggle');
const mapToggle = document.getElementById('map-toggle');
const chaseToggle = document.getElementById('chase-toggle');
const minimapEl = document.getElementById('minimap');
const minimapCanvas = document.getElementById('minimap-canvas');
const minimapCtx = minimapCanvas.getContext('2d');
bloomToggle.checked = useBloom;
velToggle.checked = showVel;
heatToggle.checked = showHeat;
mapToggle.checked = showMap;
chaseToggle.checked = chaseMode;
heatMesh.visible = showHeat;
minimapEl.classList.toggle('visible', showMap);
bloomToggle.onchange = e => {
useBloom = e.target.checked;
localStorage.setItem('use_bloom', useBloom ? '1' : '0');
setupComposer();
stateDirty = true;
};
velToggle.onchange = e => {
showVel = e.target.checked;
localStorage.setItem('show_vel', showVel ? '1' : '0');
for (const id in carMeshes) {
const d = carData[carMeshes[id].uuid];
if (d) d.velArrow.visible = showVel && carMeshes[id].visible;
}
stateDirty = true;
};
heatToggle.onchange = e => {
showHeat = e.target.checked;
localStorage.setItem('show_heat', showHeat ? '1' : '0');
heatMesh.visible = showHeat;
stateDirty = true;
};
mapToggle.onchange = e => {
showMap = e.target.checked;
localStorage.setItem('show_map', showMap ? '1' : '0');
minimapEl.classList.toggle('visible', showMap);
};
chaseToggle.onchange = e => {
chaseMode = e.target.checked;
localStorage.setItem('chase_mode', chaseMode ? '1' : '0');
cameraDirty = true;
};
// ── Render loop ──────────────────────────────────────────────────────────
let t = 0;
let lastRenderTs = 0;
let lastMinimapDraw = 0;
function drawMinimap() {
const W = minimapCanvas.width, H = minimapCanvas.height;
const ctx = minimapCtx;
ctx.fillStyle = '#0c0c18';
ctx.fillRect(0, 0, W, H);
// Field outline.
ctx.strokeStyle = '#2a4a2a'; ctx.lineWidth = 1;
ctx.strokeRect(6, 6, W - 12, H - 12);
// Centerline.
ctx.beginPath(); ctx.moveTo(6, H/2); ctx.lineTo(W-6, H/2); ctx.stroke();
// Goals.
ctx.strokeStyle = '#4aa6ff'; ctx.beginPath(); ctx.moveTo(W*0.35, 6); ctx.lineTo(W*0.65, 6); ctx.stroke();
ctx.strokeStyle = '#ff7733'; ctx.beginPath(); ctx.moveTo(W*0.35, H-6); ctx.lineTo(W*0.65, H-6); ctx.stroke();
// Ball + cars (use last gs).
if (gs && gs.ball_phys && gs.ball_phys.pos) {
const bp = gs.ball_phys.pos;
const bx = 6 + (bp[0] + SIDE_WALL_X) / (SIDE_WALL_X*2) * (W - 12);
const by = 6 + (1 - (bp[1] + BACK_WALL_Y) / (BACK_WALL_Y*2)) * (H - 12);
ctx.fillStyle = '#fff'; ctx.beginPath(); ctx.arc(bx, by, 3, 0, Math.PI*2); ctx.fill();
if (gs.cars) {
for (const c of gs.cars) {
if (!c.phys || !c.phys.pos || c.is_demoed) continue;
const cx = 6 + (c.phys.pos[0] + SIDE_WALL_X) / (SIDE_WALL_X*2) * (W - 12);
const cy = 6 + (1 - (c.phys.pos[1] + BACK_WALL_Y) / (BACK_WALL_Y*2)) * (H - 12);
ctx.fillStyle = c.team_num === 0 ? '#4aa6ff' : '#ff7733';
ctx.beginPath(); ctx.arc(cx, cy, 2.5, 0, Math.PI*2); ctx.fill();
}
}
}
}
(function animate(now) {
requestAnimationFrame(animate);
// Skip work entirely when tab hidden (browser already throttles, but be explicit).
if (document.hidden) return;
const dt = lastRenderTs ? (now - lastRenderTs) / 1000 : 0.016;
lastRenderTs = now;
t += dt;
if (gs) {
applyState(gs);
updateHud(gs);
}
// Animate only active boost pads.
if (activeBoosts.size > 0) {
const s = 1 + Math.sin(t*3) * 0.05;
activeBoosts.forEach(i => { const m = boostMeshes[i].m; m.scale.x = s; m.scale.z = s; });
}
// Touch flashes: expand and fade.
for (const f of flashPool) {
if (!f.mesh.visible) continue;
const age = (now - f.t0) / 1000;
if (age >= f.life) { f.mesh.visible = false; continue; }
const k = age / f.life;
f.mesh.scale.setScalar(1 + k * 4);
f.mesh.material.opacity = (1 - k) * 0.9;
}
// Goal particles: integrate + fade + free.
for (let i = goalParticles.length - 1; i >= 0; i--) {
const g = goalParticles[i];
const age = (now - g.t0) / 1000;
if (age >= g.life) {
goalGroup.remove(g.pts); g.pts.geometry.dispose(); g.pts.material.dispose();
goalParticles.splice(i, 1); continue;
}
const pos = g.pts.geometry.attributes.position.array;
const N = pos.length / 3;
for (let j = 0; j < N; j++) {
pos[j*3] += g.vel[j*3] * dt;
pos[j*3+1] += g.vel[j*3+1] * dt;
pos[j*3+2] += g.vel[j*3+2] * dt;
g.vel[j*3+1] -= 1800 * dt; // gravity
}
g.pts.geometry.attributes.position.needsUpdate = true;
g.pts.material.opacity = 1 - age / g.life;
}
// Force a render when shake/flash/particles are active.
const dynamic = shakeAmp > 0.1 || goalParticles.length > 0 || flashPool.some(f => f.mesh.visible);
if (cameraDirty || stateDirty || activeBoosts.size > 0 || chaseMode || dynamic) {
updateCam();
if (composer) composer.render(); else renderer.render(scene, camera);
cameraDirty = false;
stateDirty = false;
}
// Minimap draw at ~15Hz.
if (showMap && now - lastMinimapDraw > 66) {
drawMinimap();
lastMinimapDraw = now;
}
})(performance.now());
</script>
</body>
</html>