diff --git a/AI-Car-Racer/buttonResponse.js b/AI-Car-Racer/buttonResponse.js index 2be105b..88ed87f 100644 --- a/AI-Car-Racer/buttonResponse.js +++ b/AI-Car-Racer/buttonResponse.js @@ -1,20 +1,109 @@ function pauseGame(){ - pause=!pause; + // App not booted yet (classic scripts still loading). Queue the intent; + // main.js drains __pendingStart after begin/animate are defined. + if (typeof pause === 'undefined' || typeof phase === 'undefined'){ + window.__pendingStart = true; + markStartOverlayStarting(); + return; + } + + // First Start click: leave the page-load gate and build+run the swarm. + // (Population build is deferred until this moment so the Start CTA is + // clickable as soon as the overlay paints — not after a multi-second + // worker/brain setup.) + if (window.__awaitingStart){ + window.__awaitingStart = false; + window.__firstStart = false; + window.__pendingStart = false; + pause = false; + const btn = document.getElementById("pause"); + if (btn){ + btn.textContent = "Pause"; + btn.classList.remove('start-cta'); + } + markStartOverlayStarting(); + try { if (typeof syncStartOverlay === 'function') syncStartOverlay(); } catch (_) {} + if (typeof begin === 'function'){ + begin(); + } else { + // main.js still evaluating — re-queue so the boot footer can start. + window.__pendingStart = true; + } + return; + } + + // Normal Pause / Play toggle after training has started. + pause = !pause; const btn = document.getElementById("pause"); if (btn){ - btn.textContent = pause?"Play":"Pause"; - // First-press of the Start CTA graduates the button to the normal - // Pause/Play cycle. + btn.textContent = pause ? "Play" : "Pause"; btn.classList.remove('start-cta'); } - window.__firstStart = false; // Halt / resume the worker's AI step loop too. Without this, sim-worker // would keep burning CPU while the user has paused — and on resume the // accumulator would stampede a huge backlog of physics steps at once. if (typeof simWorker !== 'undefined' && simWorker){ simWorker.postMessage({ type: 'setPause', pause }); } + try { if (typeof syncStartOverlay === 'function') syncStartOverlay(); } catch (_) {} +} + +function markStartOverlayStarting(){ + const ob = document.getElementById('startOverlayBtn'); + if (!ob) return; + ob.classList.add('is-starting'); + ob.disabled = true; + const label = ob.querySelector('.start-overlay-label'); + if (label) label.textContent = 'Starting\u2026'; +} + +// Canvas-centered Start overlay — present in index.html for first paint, +// kept in sync here once the app boots. Mirrors the panel's ▶ Start CTA. +function syncStartOverlay(){ + let el = document.getElementById('startOverlay'); + // Show while awaiting Start on the training screen. Before main.js sets + // `phase`, treat as showable so the static HTML overlay stays visible + // during script load (that was the multi-second "can't click yet" gap). + const show = !!window.__awaitingStart && + (typeof phase === 'undefined' || phase === 4); + if (!show){ + if (el) el.hidden = true; + return; + } + if (!el){ + el = document.createElement('div'); + el.id = 'startOverlay'; + el.setAttribute('role', 'dialog'); + el.setAttribute('aria-modal', 'true'); + el.setAttribute('aria-label', 'Start training'); + el.innerHTML = + ''; + const host = document.getElementById('canvasDiv') || document.body; + host.appendChild(el); + } + const btn = el.querySelector('#startOverlayBtn'); + if (btn && btn.dataset.earlyBound !== '1' && btn.dataset.bound !== '1'){ + btn.dataset.bound = '1'; + btn.addEventListener('click', () => { + if (typeof pauseGame === 'function') pauseGame(); + else window.__pendingStart = true; + }); + } + // Reset busy state when re-showing (e.g. Customize Track → back to train). + if (btn && window.__awaitingStart){ + btn.disabled = false; + btn.classList.remove('is-starting'); + const label = btn.querySelector('.start-overlay-label'); + if (label) label.textContent = 'Start Training'; + } + el.hidden = false; } +window.syncStartOverlay = syncStartOverlay; +window.markStartOverlayStarting = markStartOverlayStarting; // Route a phase-4 user back into the track editor. Mirrors backPhase()'s // cleanup (pause the worker, close any SONA trajectory) but sets phase=0 diff --git a/AI-Car-Racer/demoPresentation.js b/AI-Car-Racer/demoPresentation.js index 73b63ba..d7d4213 100644 --- a/AI-Car-Racer/demoPresentation.js +++ b/AI-Car-Racer/demoPresentation.js @@ -12,8 +12,9 @@ // • Optional 3D perspective view of the same 2D world // • Demo mode: auto-start, follow, story beats // -// URL flags: ?demo=1 ?follow=1 ?view=3d ?trails=0 ?heatmap=0 -// Keyboard: F follow · 3 3D · T trails · H heatmap · D demo · 0 reset cam +// URL flags: ?demo=1 ?follow=1 ?view=3d ?trails=0 ?heatmap=0 ?rays=0 +// Keyboard: F follow · 3 3D · T trails · H heatmap · R rays · M demo · 0 reset cam +// Note: W/A/S/D are reserved for driving and never bind presentation toggles. (function (global) { 'use strict'; @@ -33,6 +34,7 @@ view3d: params.get('view') === '3d', trails: flag('trails', true), heatmap: flag('heatmap', true), + rays: flag('rays', true), rankColors: true, // camera camScale: 1, @@ -96,7 +98,7 @@ syncControlButtons(); setStory(state.demoMode ? 'Demo mode — watch a swarm learn to drive.' - : 'Train a neural net to race. Press D for demo mode.', 6000); + : 'Train a neural net to race. Press M for demo mode (W/A/S/D drive).', 6000); } function ensureHud() { @@ -129,7 +131,8 @@ '' + '' + '' + - ''; + '' + + ''; el.addEventListener('click', (ev) => { const btn = ev.target.closest('[data-act]'); if (!btn) return; @@ -138,6 +141,7 @@ else if (act === 'view3d') setView3d(!state.view3d); else if (act === 'trails') { state.trails = !state.trails; if (!state.trails) state.trail.length = 0; } else if (act === 'heatmap') state.heatmap = !state.heatmap; + else if (act === 'rays') setRays(!state.rays); else if (act === 'demo') startDemoMode(); syncControlButtons(); }); @@ -152,6 +156,7 @@ view3d: state.view3d, trails: state.trails, heatmap: state.heatmap, + rays: state.rays, demo: state.demoMode, }; state.controlsEl.querySelectorAll('[data-act]').forEach((btn) => { @@ -167,15 +172,32 @@ const t = e.target; if (t && (t.tagName === 'INPUT' || t.tagName === 'TEXTAREA' || t.tagName === 'SELECT' || t.isContentEditable)) return; const k = e.key; + // Never steal driving keys. WASD controls the player car (phase 3) and + // must not toggle Demo / Follow / other presentation chrome. Demo used + // to bind D, which flipped Follow on every right-turn press. + if (k === 'w' || k === 'a' || k === 's' || k === 'd' || + k === 'W' || k === 'A' || k === 'S' || k === 'D') { + return; + } + // While the user is actively driving (physics-tune phase), ignore all + // presentation hotkeys so arrow keys and free driving stay clean. + try { + if (typeof phase !== 'undefined' && phase === 3) return; + } catch (_) {} if (k === 'f' || k === 'F') { setFollow(!state.followBest); syncControlButtons(); } else if (k === '3') { setView3d(!state.view3d); syncControlButtons(); } else if (k === 't' || k === 'T') { state.trails = !state.trails; if (!state.trails) state.trail.length = 0; syncControlButtons(); } else if (k === 'h' || k === 'H') { state.heatmap = !state.heatmap; syncControlButtons(); } - else if (k === 'd' || k === 'D') { startDemoMode(); syncControlButtons(); } + else if (k === 'r' || k === 'R') { setRays(!state.rays); syncControlButtons(); } + else if (k === 'm' || k === 'M') { startDemoMode(); syncControlButtons(); } else if (k === '0') { resetCamera(); } }); } + function setRays(on) { + state.rays = !!on; + } + // --------------------------------------------------------------- road cache function useRoadCache() { // Cache only during training when the editor is locked — during track @@ -785,8 +807,8 @@ const x = best.x, y = best.y, ang = best.angle; if (state.view3d) { drawProjectedCar(ctx, x, y, ang, CHAMPION, 1, state.carHeight * 1.15); - // Sensor rays - if (best.sensor && best.sensor.rays && best.sensor.rays.length) { + // Sensor rays (toggle via Rays chip / R) + if (state.rays && best.sensor && best.sensor.rays && best.sensor.rays.length) { for (let i = 0; i < best.sensor.rays.length; i++) { const ray = best.sensor.rays[i]; const reading = best.sensor.readings[i]; @@ -832,7 +854,7 @@ ctx.stroke(); ctx.restore(); - if (best.sensor && best.sensor.rays && best.sensor.rays.length) { + if (state.rays && best.sensor && best.sensor.rays && best.sensor.rays.length) { for (let i = 0; i < best.sensor.rays.length; i++) { const ray = best.sensor.rays[i]; const reading = best.sensor.readings[i]; @@ -1006,12 +1028,18 @@ if (ss) ss.value = '5'; } catch (_) {} - // Unpause if waiting on first start. + // Unpause / first-start: pauseGame() clears __awaitingStart and calls + // begin() to build the swarm. A second begin() is only needed when the + // sim was already past the Start gate (restart with demo knobs). try { - if (typeof pause !== 'undefined' && pause && typeof pauseGame === 'function') { + if (window.__awaitingStart && typeof pauseGame === 'function') { + pauseGame(); + } else if (typeof pause !== 'undefined' && pause && typeof pauseGame === 'function') { pauseGame(); + if (typeof begin === 'function') begin(); + } else if (typeof begin === 'function') { + begin(); } - if (typeof begin === 'function') begin(); } catch (_) {} state.demoStarted = true; @@ -1066,6 +1094,7 @@ onGenEnd, setFollow, setView3d, + setRays, resetCamera, startDemoMode, setStory, diff --git a/AI-Car-Racer/index.html b/AI-Car-Racer/index.html index 00f87dd..43e7a3b 100644 --- a/AI-Car-Racer/index.html +++ b/AI-Car-Racer/index.html @@ -44,7 +44,7 @@ -
+
+
@@ -75,7 +86,7 @@

Train Your Model

- @@ -99,6 +110,31 @@

Train Your Model

+ + @@ -187,10 +223,15 @@

Train Your Model

toggle.setAttribute('aria-expanded', collapsed ? 'false' : 'true'); toggle.setAttribute('aria-label', collapsed ? 'Expand control panel' : 'Collapse control panel'); }; - // Restore — default to expanded. localStorage read is wrapped because - // Safari private mode throws synchronously on access. - let saved = false; - try { saved = localStorage.getItem(KEY) === '1'; } catch (_) {} + // Restore preference. Default to collapsed so the track claims the + // viewport; the canvas Start overlay stays reachable either way. + // localStorage read is wrapped because Safari private mode throws. + let saved = true; // default collapsed + try { + const v = localStorage.getItem(KEY); + if (v === '0') saved = false; + else if (v === '1') saved = true; + } catch (_) {} apply(saved); toggle.addEventListener('click', () => { const nowCollapsed = !display.classList.contains('panel-collapsed'); diff --git a/AI-Car-Racer/main.js b/AI-Car-Racer/main.js index 834d9dd..5a77995 100644 --- a/AI-Car-Racer/main.js +++ b/AI-Car-Racer/main.js @@ -253,10 +253,21 @@ let pause=true; var phase = 0; //0 welcome, 1 track, 2 checkpoints, 3 physics, 4 training var maxSpeed = 15; // Default entry flow: land straight on phase 4 (training) with a preloaded -// rectangle track so visitors see cars racing immediately behind a prominent -// Start button. The old draw-a-track-from-scratch flow is still one click -// away via "Customize Track" (see customizeTrack() in buttonResponse.js) and -// can also be forced with `?edit=1` on the URL for dev use. +// rectangle track. The sim stays paused until the user hits Start — no +// auto-run on page load. The old draw-a-track-from-scratch flow is still one +// click away via "Customize Track" (buttonResponse.js) and can also be forced +// with `?edit=1` on the URL for dev use. +// +// __awaitingStart — session gate: every fresh page load waits for an explicit +// Start / Demo click before the worker steps. Cleared by pauseGame(). +// Inline script in index.html sets this true for first paint; do NOT force +// it back to true here if an early Start click already cleared the gate +// while the rest of main.js was still evaluating. +// __firstStart — first-ever visitor (no trainCount yet); drives CTA copy and +// the stronger amber Start button styling. +if (window.__awaitingStart !== false) { + window.__awaitingStart = true; +} window.__firstStart = !localStorage.getItem('trainCount'); if (new URLSearchParams(location.search).get('edit') === '1') { nextPhase(); // → phase 1 (track draw) @@ -742,7 +753,13 @@ simWorker.onmessage = (ev) => { switch (m.type){ case 'ready': workerReady = true; - if (pendingBegin){ const pb = pendingBegin; pendingBegin = null; performBegin(pb.N); } + // Never auto-start a pending begin while the Start CTA is still + // waiting — boot used to race performBegin here and freeze input. + if (pendingBegin && !window.__awaitingStart){ + const pb = pendingBegin; pendingBegin = null; performBegin(pb.N); + } else if (window.__awaitingStart){ + pendingBegin = null; + } break; case 'snapshot': handleSnapshot(m); @@ -1207,6 +1224,30 @@ function buildBrainsBuffer(N){ // ----------------------------------------------------------------------------- function begin(){ seconds = nextSeconds; + // Page-load gate: while awaiting an explicit Start click, do NOT build + // the 500-car population or touch the worker. That work used to run on + // boot and blocked the main thread / delayed a responsive Start CTA for + // multiple seconds. pauseGame() clears __awaitingStart then calls begin() + // again to actually engage the worker. + if (window.__awaitingStart) { + pause = true; + computeStartInfoInPlace(currentCheckpointList()); + // Lightweight placeholders so phase-3 tooling still has objects if the + // user hops into Customize Track before starting. AI swarm is deferred. + try { + playerCar = new Car(startInfo.x, startInfo.y, 30, 50, "KEYS", maxSpeed, startInfo.heading); + playerCar2 = new Car(startInfo.x, startInfo.y, 30, 50, "WASD", maxSpeed, startInfo.heading); + } catch (_) {} + frameCount = 0; + wallStart = performance.now(); + _simStepAccum = 1; + _lastTickWall = performance.now(); + bestCar = null; + _bestProxyEpoch = -1; + latestSnapshot = null; + pendingBegin = null; + return; + } pause = false; computeStartInfoInPlace(currentCheckpointList()); playerCar = new Car(startInfo.x, startInfo.y, 30, 50, "KEYS", maxSpeed, startInfo.heading); @@ -1245,12 +1286,11 @@ function performBegin(N){ workerInited = true; } const brains = buildBrainsBuffer(N); - // Keep worker speed/pause aligned with main defaults. setSimSpeed() only - // posts when the user moves the dropdown — without this, a default - // simSpeed≠1 never reaches the worker until the first manual change. + // Keep worker speed aligned with main defaults. setSimSpeed() only posts + // when the user moves the dropdown — without this, a default simSpeed≠1 + // never reaches the worker until the first manual change. try { simWorker.postMessage({ type: 'setSimSpeed', v: simSpeed }); - simWorker.postMessage({ type: 'setPause', pause: !!pause }); } catch (_) {} simWorker.postMessage({ type: 'begin', @@ -1259,6 +1299,12 @@ function performBegin(N){ poseJitter: Object.assign({ radiusPx: 0, angleDeg: 0, maxAttempts: 8 }, window.__poseJitter || {}), brains }, [brains.buffer]); + // Worker handleBegin always sets pause=false and starts the step loop. + // Re-assert the intended pause state AFTER begin so a pre-start page + // load keeps the swarm frozen until the user opts in. + try { + simWorker.postMessage({ type: 'setPause', pause: !!pause }); + } catch (_) {} // Co-start the A/B baseline worker so both canvases begin each generation // on the same wall-clock tick. Without this, B auto-restarts on its own // genEnd (no archive/observe/seed delay) and drifts ahead of A. @@ -1403,13 +1449,18 @@ function performNextBatch(genData){ } begin(); -// First-visit: keep the sim paused so the user clicks the "▶ Start Training" -// CTA. begin() flips pause=false, so we re-pause here AFTER it runs. Phase-4 -// layout already relabels the pause button; this just makes sure the sim -// doesn't start stepping before the user opts in. -if (window.__firstStart){ +// Page-load gate: keep the sim paused until the user clicks Start / Demo. +// Population build is deferred (see begin() early-return on __awaitingStart). +if (window.__awaitingStart){ pause = true; } +// Honor Start clicks that arrived while classic scripts were still loading. +if (window.__pendingStart){ + window.__pendingStart = false; + try { pauseGame(); } catch (_) {} +} else { + try { if (typeof syncStartOverlay === 'function') syncStartOverlay(); } catch (_) {} +} animate(); // ----------------------------------------------------------------------------- @@ -1623,7 +1674,11 @@ function drawCarQuad(x, y, angle){ function drawBestCar(bc){ ctx.fillStyle = bc.damaged ? "gray" : "rgb(227, 138, 15)"; drawCarQuad(bc.x, bc.y, bc.angle); - if (bc.sensor && bc.sensor.rays && bc.sensor.rays.length){ + // Honor DemoPresentation rays toggle when the presentation layer is + // present but fell through to this legacy draw path. + const raysOn = !(window.DemoPresentation && window.DemoPresentation.state + && window.DemoPresentation.state.rays === false); + if (raysOn && bc.sensor && bc.sensor.rays && bc.sensor.rays.length){ for (let i = 0; i < bc.sensor.rays.length; i++){ const ray = bc.sensor.rays[i]; const reading = bc.sensor.readings[i]; diff --git a/AI-Car-Racer/roadEditor.js b/AI-Car-Racer/roadEditor.js index 0886670..186f386 100644 --- a/AI-Car-Racer/roadEditor.js +++ b/AI-Car-Racer/roadEditor.js @@ -4,11 +4,10 @@ class roadEditor{ this.checkPointMode=false; this.editMode = true; // Fresh visitors land with the Rectangle preset exactly (same walls - // AND same 4-gate bottom-right-spawn checkpoint layout as clicking - // "Load preset: Rectangle" from the dropdown). If TRACK_PRESETS is - // loaded by the time we boot (it's a sibling script), mirror it; - // otherwise fall back to the hardcoded values that match the preset - // at the time of this file's authoring. localStorage always wins. + // AND same checkpoint layout as clicking "Load preset: Rectangle"). + // If TRACK_PRESETS is loaded by the time we boot, mirror it; + // otherwise fall back to the hardcoded values that match the preset. + // localStorage always wins over both. const rectPreset = (typeof window !== 'undefined' && window.TRACK_PRESETS && window.TRACK_PRESETS[0]) || null; if(localStorage.getItem("trackInner") && localStorage.getItem("trackOuter")){ this.points=JSON.parse(localStorage.getItem("trackInner")); @@ -32,13 +31,14 @@ class roadEditor{ ]); } else{ - // Fallback mirroring Rectangle preset's 4-gate layout: bottom-right - // spawn, right-top, top-mid, left-mid. + // Fallback mirroring Rectangle preset's 5-gate layout: diagonal + // lower/upper-right spawn pair, top-mid, left-mid, bottom-mid. this.checkPointListEditor = [ - [{x:3100,y:1200},{x:2450,y:1200}], - [{x:3100,y:600 },{x:2450,y:600 }], + [{x:3128,y:1316},{x:2418,y:1068}], + [{x:3135,y:376 },{x:2414,y:725 }], [{x:1600,y:300 },{x:1600,y:700 }], - [{x:250, y:900 },{x:650, y:900 }] + [{x:250, y:900 },{x:650, y:900 }], + [{x:1600,y:1060},{x:1600,y:1565}] ]; } this.drag_point = -1; diff --git a/AI-Car-Racer/sim-worker.js b/AI-Car-Racer/sim-worker.js index 8eeadc6..91a87c9 100644 --- a/AI-Car-Racer/sim-worker.js +++ b/AI-Car-Racer/sim-worker.js @@ -44,22 +44,40 @@ let seconds = 15; let startInfo = null; let _accum = 1; let _lastTickWall = 0; -const MAX_STEPS = 60; -let _loopHandle = null; +// Soft per-tick step safety (overridden by maxStepsForSpeed at runtime). +const MAX_STEPS = 60; // legacy name; runtime uses maxAccumForSpeed / maxStepsPerTick let bestEpoch = 0; -// Per-tick wall-time budget. A single tick runs steps until it's been -// stepping for ~TICK_BUDGET_MS, then yields + posts a snapshot. Two regimes: -// - Heavy sim (N=10k, perStep≈10ms): budget hits after ~2 steps → -// snapshots flow at ~50Hz, visuals stay smooth. Sim throughput is -// whatever the CPU permits; worker just doesn't hog it. -// - Light sim (N<500, perStep<1ms): hundreds of steps fit in one budget → -// full simSpeed reached without being throttled by setTimeout(0)'s -// ~4ms clamp. Critical for the N≈100, simSpeed=100× "fast training" -// use case that the old main-thread loop could hit but a naive -// "few steps per tick" worker cannot. +// Per-tick wall-time budget base. Scaled up with simSpeed so 100× can burn +// real CPU instead of yielding every 20ms after only a handful of steps. +// Heavy N still self-limits via the budget; light/mid N at high speed needs +// a larger slice to approach the requested multiplier. const TICK_BUDGET_MS = 20; +// Max sim-frame backlog kept across ticks. Old hard cap of 60 dropped almost +// all of a 100× accumulator every tick (100× × 16ms × 60fps ≈ 96 steps +// requested, 60 kept, rest discarded) — effective rate collapsed to ~1–2×. +function maxAccumForSpeed(ss) { + // Keep up to ~1s of sim-time debt so temporary main-thread stalls can + // catch up, without letting the queue grow without bound. + const s = (Number.isFinite(ss) && ss > 0) ? ss : 1; + return Math.max(60, Math.ceil(s * 60)); +} +function tickBudgetForSpeed(ss) { + const s = (Number.isFinite(ss) && ss > 0) ? ss : 1; + if (s <= 2) return 16; + if (s <= 5) return 24; + if (s <= 20) return 40; + if (s <= 50) return 60; + return 100; // 100× — burn hard; main thread paints from latest snapshot only +} +function maxStepsPerTick(ss) { + // Don't artificially stop early when budget remains; allow a full + // second of sim per tick at high speed (budget will cut first if heavy). + const s = (Number.isFinite(ss) && ss > 0) ? ss : 1; + return Math.max(60, Math.ceil(s * 60)); +} + // Flat-brain layout — see brainCodec.js on the main side. Hard-coded here so // the worker doesn't have to importScripts a module (importScripts only loads // classic scripts). If TOPOLOGY changes upstream, bump both sides. @@ -75,7 +93,11 @@ self.onmessage = (ev) => { switch (m.type) { case 'init': handleInit(m); break; case 'begin': handleBegin(m); break; - case 'setSimSpeed': simSpeed = m.v; break; + case 'setSimSpeed': + simSpeed = (Number.isFinite(m.v) && m.v > 0) ? m.v : 1; + // Avoid a huge catch-up step right after a speed jump. + _lastTickWall = performance.now(); + break; case 'setPause': handlePause(m.pause); break; case 'setTraction': self.traction = m.v; break; case 'setMaxSpeed': self.maxSpeed = m.v; break; @@ -215,13 +237,25 @@ function handleBegin(m) { _lastTickWall = performance.now(); pause = false; startLoop(); - self.postMessage({ type: 'debug', event: 'beginBuilt', N, ms: performance.now() - _t0 }); + // Publish one pose snapshot immediately so the main thread can paint the + // parked swarm even when page-load re-pauses before the first step ticks. + // steps=0 keeps the HUD honest ("not stepping yet"). Must run after + // beginBuilt so a postSnapshot throw doesn't hide the build timing. + try { + postSnapshot(0, 0); + } catch (e) { + self.postMessage({ + type: 'debug', + event: 'previewSnapFail', + err: (e && e.message) || String(e) + }); + } } function handlePause(p) { @@ -255,55 +289,70 @@ function flattenBrain(brain) { } // --- main loop --------------------------------------------------------------- +// MessageChannel scheduling avoids the browser's setTimeout(0) ~4ms clamp, +// which capped throughput at ~250 ticks/s and made high simSpeed starve. + +const _tickChannel = new MessageChannel(); +let _loopScheduled = false; +_tickChannel.port1.onmessage = () => { + _loopScheduled = false; + if (pause) return; + stepOnce(); + // Re-arm immediately while running so the worker stays hot at high speed. + if (!pause) scheduleTick(); +}; + +function scheduleTick() { + if (_loopScheduled) return; + _loopScheduled = true; + _tickChannel.port2.postMessage(null); +} function startLoop() { - if (_loopHandle != null) return; - const tick = () => { - _loopHandle = setTimeout(tick, 0); - if (pause) return; - stepOnce(); - }; - _loopHandle = setTimeout(tick, 0); + scheduleTick(); } function computeStride(ss) { + // Heavier LOD at high speed: non-champion AI reuse last controls more often. if (ss <= 2) return 1; if (ss <= 5) return 2; - if (ss <= 20) return 3; - return 4; + if (ss <= 20) return 4; + if (ss <= 50) return 8; + return 16; } let _prevTickEnd = 0; const WORKER_HITCH_MS = 60; +// At high speed, snapshot less often so postMessage packing doesn't dominate. +let _stepsSinceSnap = 0; function stepOnce() { const now = performance.now(); - // Gap between end of the previous tick and start of this one. Normally - // ~4-10ms (setTimeout(0) clamp). Large values here indicate the worker - // thread was paused — usually GC, sometimes OS scheduling. + // Gap between end of the previous tick and start of this one. const tickGap = _prevTickEnd > 0 ? now - _prevTickEnd : 0; let dt = (now - _lastTickWall) / 1000; _lastTickWall = now; - if (dt > 0.25) dt = 0.25; + // Slightly larger clamp at high speed so a single long tick after archive + // work still converts into useful catch-up instead of being truncated to + // 0.25s of wall (which dropped most of a 100× burst under the old cap). + const dtCap = simSpeed >= 50 ? 0.5 : 0.25; + if (dt > dtCap) dt = dtCap; _accum += simSpeed * dt * 60; - // Cap accumulator against runaway catch-up after a stall. MAX_STEPS - // bounds the worst-case backlog we'll try to absorb; anything beyond - // that is dropped so sim-time falls behind wall-time gracefully. - if (_accum > MAX_STEPS) _accum = MAX_STEPS; - // Drain up to floor(_accum) steps, but break as soon as we've burned - // TICK_BUDGET_MS of wall time. Remaining steps go back into the - // accumulator and drain on subsequent ticks. This keeps snapshots - // flowing at ~50Hz under heavy load while still letting a light sim - // rip through hundreds of steps per tick for high simSpeed. - let steps = Math.floor(_accum); - _accum -= steps; + const accumCap = maxAccumForSpeed(simSpeed); + if (_accum > accumCap) _accum = accumCap; + + const budgetMs = tickBudgetForSpeed(simSpeed); + const stepCap = maxStepsPerTick(simSpeed); self.SENSOR_STRIDE = computeStride(simSpeed); + // Drain until empty, step cap, or wall budget — leftover stays in _accum + // for the next tick (no pre-debit of the whole queue). const simStart = performance.now(); const cpLen = self.road.checkPointList.length; let stepsRun = 0; let maxStepMs = 0; - for (let s = 0; s < steps; s++) { + while (_accum >= 1 && stepsRun < stepCap) { + if (performance.now() - simStart > budgetMs) break; const stepStart = performance.now(); if (self.frameCount >= 60 * seconds) { postSnapshot(performance.now() - simStart, stepsRun); @@ -311,6 +360,7 @@ function stepOnce() { return; } self.frameCount++; + _accum -= 1; for (let i = 0; i < cars.length; i++) { const cc = cars[i]; const prevDamaged = cc.damaged; @@ -327,38 +377,54 @@ function stepOnce() { cc.deathY = cc.y; } } - // O(N) bestCar scan — mirror of main.js's pre-worker logic. - let bestFit = -Infinity, bestC = null; - for (let i = 0; i < cars.length; i++) { - const c = cars[i]; - const f = c.checkPointsCount + c.laps * cpLen; - if (f > bestFit) { bestFit = f; bestC = c; } - } - if (bestC) { - const currentFit = self.bestCar - ? (self.bestCar.checkPointsCount + self.bestCar.laps * cpLen) - : -Infinity; - if (self.bestCar !== bestC && bestFit > currentFit) { + // O(N) live-champion scan (prefer alive). At high simSpeed scan every + // other step — a 1-frame lag on the yellow highlight is invisible and + // saves a full population walk. Always scan if the current champion + // just died so rays hop immediately. + const needBestScan = simSpeed < 50 + || (self.frameCount & 1) === 0 + || (self.bestCar && self.bestCar.damaged); + if (needBestScan) { + let bestFitAlive = -Infinity, bestAlive = null; + let bestFitAll = -Infinity, bestAll = null; + for (let i = 0; i < cars.length; i++) { + const c = cars[i]; + const f = c.checkPointsCount + c.laps * cpLen; + if (f > bestFitAll) { bestFitAll = f; bestAll = c; } + if (!c.damaged && f > bestFitAlive) { bestFitAlive = f; bestAlive = c; } + } + const bestC = bestAlive || bestAll; + if (bestC && self.bestCar !== bestC) { self.bestCar = bestC; bestEpoch++; + // New champion wasn't privileged this step (LOD may have skipped + // its perception). Refresh rays immediately so the same-tick + // snapshot shows sensors on the new leader, not a blank/stale set. + if (!bestC.damaged && bestC.sensor) { + try { bestC.sensor.update(self.road.borders); } catch (_) {} + } } } stepsRun++; const stepMs = performance.now() - stepStart; if (stepMs > maxStepMs) maxStepMs = stepMs; - if (performance.now() - simStart > TICK_BUDGET_MS) { - // Budget tripped — hand remaining steps back to the accumulator - // so next tick catches up. - _accum += (steps - stepsRun); - break; - } } const simMs = performance.now() - simStart; - // Always snapshot at the end of a tick. Main rAF pulls at 60Hz and - // keeps only the latest — overshoot is dropped for free. + // Snapshot cadence: every tick at low speed; every ~2–3 ticks at 100× so + // packing N poses doesn't eat the budget we just freed for stepping. + _stepsSinceSnap += stepsRun; + const snapEvery = simSpeed >= 50 ? 3 : (simSpeed >= 20 ? 2 : 1); + const wantSnap = stepsRun > 0 && ( + _stepsSinceSnap >= snapEvery || + // Always snap if we did a meaningful chunk this tick. + stepsRun >= 30 + ); const postStart = performance.now(); - if (stepsRun > 0) postSnapshot(simMs, stepsRun); + if (wantSnap) { + postSnapshot(simMs, stepsRun); + _stepsSinceSnap = 0; + } const postMs = performance.now() - postStart; const tickEnd = performance.now(); @@ -492,10 +558,20 @@ function postSnapshot(simMs, steps) { } function endGen() { - if (!self.bestCar) return; - const bc = self.bestCar; - const flat = flattenBrain(bc.brain); + // Re-pick the fitness elite across the whole population — including + // damaged cars. Live bestCar prefers survivors for the ray overlay; the + // brain we archive/seed from should still be whoever got furthest. const cpLen = self.road.checkPointList.length || 0; + let elite = null, eliteFit = -Infinity; + for (let i = 0; i < cars.length; i++) { + const c = cars[i]; + const f = c.checkPointsCount + c.laps * cpLen; + if (f > eliteFit) { eliteFit = f; elite = c; } + } + if (!elite) return; + self.bestCar = elite; + const bc = elite; + const flat = flattenBrain(bc.brain); // Population-wide stats: per-car checkpoint counts + death frames. Main // thread derives median / p90 / survival@T percentiles from these arrays, diff --git a/AI-Car-Racer/style.css b/AI-Car-Racer/style.css index c9c432e..9120f98 100644 --- a/AI-Car-Racer/style.css +++ b/AI-Car-Racer/style.css @@ -284,7 +284,12 @@ p { line-height: 1.5; margin: 0 0 .6em 0; } } /* Sits at the midline between the stacked A and B canvases (top:50%) and anchored to the left so it doesn't collide with the primary "metrics" - HUD which is fixed at top-center of the viewport. */ + HUD which is fixed at top-center of the viewport. + + IMPORTANT: `display: flex` would otherwise override the HTML `hidden` + attribute (UA [hidden]{display:none} loses to an author #id rule), which + left empty #ab-hud-b / #ab-hud-delta chips floating on the track when A/B + mode was off. Always pair the flex rule with an explicit [hidden] kill. */ #ab-hud { position: absolute; top: calc(50% - 32px); @@ -297,6 +302,9 @@ p { line-height: 1.5; margin: 0 0 .6em 0; } font: 11px/1.35 ui-monospace, Menlo, monospace; color: #a8c8ff; } +#ab-hud[hidden] { + display: none !important; +} #ab-hud-b, #ab-hud-delta { background: rgba(12, 14, 18, .88); padding: 6px 9px; @@ -566,7 +574,7 @@ p { line-height: 1.5; margin: 0 0 .6em 0; } } /* ---------- Start-training CTA ---------------------------------- - On first visit the Pause button is repurposed as an explicit Start + On page load the Pause button is repurposed as an explicit Start Training call-to-action. Filled amber, larger type, centered icon. */ .controlButton.start-cta { color: #fff; @@ -597,6 +605,78 @@ p { line-height: 1.5; margin: 0 0 .6em 0; } .controlButton.start-cta { animation: none; } } +/* Canvas-centered Start overlay — reachable when the right panel is + collapsed. Mirrors the panel Start CTA so the main action is never + buried behind a hidden sidebar. */ +#startOverlay { + position: absolute; + inset: 0; + z-index: 20; + display: flex; + align-items: center; + justify-content: center; + pointer-events: none; + background: + radial-gradient(ellipse 55% 45% at 50% 50%, + rgba(12, 14, 18, 0.55), + rgba(12, 14, 18, 0.12) 70%, + transparent 100%); +} +#startOverlay[hidden] { display: none !important; } +.start-overlay-btn { + pointer-events: auto; + display: flex; + flex-direction: column; + align-items: center; + gap: 6px; + min-width: min(72vw, 280px); + padding: 1.15em 1.6em 1em; + border: 1.5px solid var(--amber-600); + border-radius: var(--r-xl); + background: linear-gradient(180deg, var(--amber-600), var(--amber-700)); + color: #fff; + font-family: var(--font-body); + box-shadow: var(--shadow-lg), 0 0 0 0 rgba(211, 139, 75, 0.4); + cursor: pointer; + animation: start-cta-pulse 2200ms var(--ease-in-out) infinite; + transition: transform var(--dur-fast) var(--ease-out), background var(--dur-fast) var(--ease-out); +} +.start-overlay-btn:hover { + transform: translateY(-2px) scale(1.02); + background: linear-gradient(180deg, var(--amber-500), var(--amber-700)); +} +.start-overlay-btn:focus-visible { + outline: none; + box-shadow: var(--ring), var(--shadow-lg); +} +.start-overlay-icon { + font-size: 1.6rem; + line-height: 1; +} +.start-overlay-label { + font-weight: 700; + font-size: 1.15rem; + letter-spacing: 0.03em; +} +.start-overlay-hint { + font-size: 0.72rem; + font-weight: 500; + opacity: 0.85; + letter-spacing: 0.01em; +} +.start-overlay-btn.is-starting { + opacity: 0.9; + cursor: progress; + animation: none; + transform: none; +} +.start-overlay-btn:disabled { + cursor: progress; +} +@media (prefers-reduced-motion: reduce) { + .start-overlay-btn { animation: none; } +} + /* Secondary control button tint — used for Customize Track so it reads as subordinate to the Start CTA above it. */ .controlButton.secondary { @@ -682,7 +762,9 @@ p { line-height: 1.5; margin: 0 0 .6em 0; } scene behind it stays legible. */ #bottomText { position: absolute; - left: 50%; + /* Slightly left of center so the cinema control chips (bottom-right) + never sit on top of the phase banner. */ + left: 42%; bottom: var(--sp-3); transform: translateX(-50%); padding: var(--sp-2) var(--sp-5); @@ -694,7 +776,7 @@ p { line-height: 1.5; margin: 0 0 .6em 0; } box-shadow: var(--shadow-sm); color: var(--ink-900); text-align: center; - max-width: min(80%, 620px); + max-width: min(48%, 320px); pointer-events: none; z-index: 5; } @@ -2302,10 +2384,12 @@ label { ============================================================= */ #cinema-hud { position: absolute; - top: 10px; - left: 10px; + /* Sit below the fixed help stack (Explain / Tour / GitHub) which lives + at viewport top-left over the canvas. Stack ≈ sp-4 + 3×(pill+gap). */ + top: calc(var(--sp-4) + 3 * (36px + var(--sp-2)) + var(--sp-2)); + left: 12px; z-index: 8; - max-width: min(92%, 560px); + max-width: min(72%, 420px); padding: 8px 12px; border-radius: 12px; background: rgba(12, 14, 18, 0.72); @@ -2355,17 +2439,18 @@ label { /* Bottom-right of the track cell so we don't collide with the bottom-left perf HUD or the centered #bottomText banner. */ position: absolute; - bottom: 12px; - right: 12px; + bottom: 14px; + right: 14px; left: auto; - z-index: 8; + z-index: 9; display: flex; flex-wrap: wrap; justify-content: flex-end; gap: 6px; - max-width: min(70%, 480px); + max-width: min(56%, 420px); pointer-events: auto; } + #cinema-controls button { appearance: none; border: 1px solid rgba(255, 255, 255, 0.14); @@ -2414,17 +2499,29 @@ label { #cinema-hud { font-size: 11px; padding: 6px 9px; - top: 6px; - left: 6px; + /* On mobile the help FABs move to bottom-right, so reclaim top-left. */ + top: 8px; + left: 8px; + max-width: min(92%, 360px); } #cinema-controls { bottom: 8px; right: 8px; + max-width: min(88%, 360px); } #cinema-controls button { padding: 4px 9px; font-size: 10px; } + #bottomText { + left: 50%; + max-width: min(70%, 280px); + bottom: calc(var(--sp-3) + 36px); /* clear cinema chips */ + } + .start-overlay-btn { + min-width: min(84vw, 260px); + padding: 1em 1.2em 0.85em; + } } @media (prefers-reduced-motion: reduce) { diff --git a/AI-Car-Racer/trackPresets.js b/AI-Car-Racer/trackPresets.js index f348ea1..3d5c3b9 100644 --- a/AI-Car-Racer/trackPresets.js +++ b/AI-Car-Racer/trackPresets.js @@ -11,11 +11,11 @@ // orientation consistent across presets, which helps the ruvector cross- // track seed recall. // -// Checkpoint order (4 gates per preset): bottom-right → top-right → -// top-mid → left-mid → back to bottom-right (CW in canvas). The 4-gate -// layout gives a 33% denser fitness gradient than the old 3-gate one, -// helping partial survivors get ranked before any full laps. See -// sim-worker.js:295 for the fitness formula consuming checkPointsCount. +// Checkpoint order (CW in canvas, spawn bottom-right): typically +// bottom-right → top-right → top-mid → left-mid [→ bottom-mid] → lap. +// Most presets use 4 gates; Rectangle uses 5 (extra bottom-mid) for a +// denser fitness gradient. See sim-worker.js for fitness = checkPointsCount +// + laps * gateCount. // // Data format matches roadEditor.js: // points: inner wall, array of {x, y} — saved under `trackInner` @@ -31,26 +31,30 @@ window.TRACK_PRESETS = [ { name: 'Rectangle', - description: '4-corner rectangular loop. Widest corridor, easiest to learn.', + description: '4-corner rectangular loop. Widest corridor, easiest to learn. 5 gates with diagonal right-lobe spawn.', points: [ { x: 650, y: 700 }, { x: 2450, y: 700 }, { x: 2450, y: 1100 }, { x: 650, y: 1100 } ], - // Outer right pushed to x=3100 so the start (2880,900) has ~220px - // buffer instead of the old ~70px squeeze against x=2950. + // Outer right pushed to x=3100 so the start has corridor buffer. points2: [ { x: 250, y: 300 }, { x: 3100, y: 300 }, { x: 3100, y: 1500 }, { x: 250, y: 1500 } ], + // Hand-tuned gates (outer→inner). Right lobe uses diagonals so spawn + // sits in the lower-right corridor with heading toward the upper-right + // gate; top/left stay axis-aligned; bottom-mid closes the loop for a + // denser fitness gradient than the old 4-gate layout. checkPointListEditor: [ - [{ x: 3100, y: 1200 }, { x: 2450, y: 1200 }], // 1: right-low (spawn) - [{ x: 3100, y: 600 }, { x: 2450, y: 600 }], // 2: right-top (above cp[0] → heading up) + [{ x: 3128, y: 1316 }, { x: 2418, y: 1068 }], // 1: lower-right diagonal (spawn) + [{ x: 3135, y: 376 }, { x: 2414, y: 725 }], // 2: upper-right diagonal (heading up) [{ x: 1600, y: 300 }, { x: 1600, y: 700 }], // 3: top-mid - [{ x: 250, y: 900 }, { x: 650, y: 900 }] // 4: left-mid + [{ x: 250, y: 900 }, { x: 650, y: 900 }], // 4: left-mid + [{ x: 1600, y: 1060 }, { x: 1600, y: 1565 }] // 5: bottom-mid ] }, { diff --git a/AI-Car-Racer/utils.js b/AI-Car-Racer/utils.js index 7389e9e..37ae275 100644 --- a/AI-Car-Racer/utils.js +++ b/AI-Car-Racer/utils.js @@ -106,7 +106,7 @@ function phaseToLayout(phase){ rightPanel.innerHTML = ` - +