1648 lines
66 KiB
HTML
1648 lines
66 KiB
HTML
<!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>
|