FINAL-Bench commited on
Commit
034bdc8
Β·
1 Parent(s): 06c3957

Add Tier 1+2 features: pause, nitro, ghost car, camera modes, touch, best lap

Browse files

## Fixes (Tier 0)
A1: Delete unused main.js (1988 lines / 71KB dead code)
A2: Replace /api/telemetry fetch with localStorage (server didn't exist)
- Added loadBestLap/saveBestLap helpers
- Added loadGhost/saveGhost helpers (500KB cap)
A3: Fix outdated '270 km/h' AI comment

## Tier 1 (Core UX)
B2: Best lap tracking persisted to localStorage
- New-best toast + lap notification with time
- HUD shows star BEST label
- Results screen shows last lap + all-time best
B6: Pause menu (ESC/P key)
- Overlay with Resume/Restart/Camera/Ghost/Fullscreen buttons
- Clock delta reset on resume to avoid dt spike
B7: F key fullscreen toggle

## Tier 2 (Premium)
C1: Ghost car (translucent cyan replay of best lap)
- Samples position/heading every 0.1s during current lap
- Saves to localStorage when new best set
- HUD shows live +/- delta to ghost
- Toggle with G key
C2: Nitro boost (SHIFT / mobile button)
- 2.5s duration, 12s recharge
- 1.35x top speed + 1.6x acceleration
- FOV boost for speed sensation
- Nitro bar on HUD below speedometer
C3: Camera modes (C key) - Third-person / Cockpit / Top-down
C4: Mobile touch controls (auto-detect)
- Steering, accel/brake, drift, nitro buttons
- Backdrop blur overlay, touch-action manipulation
C5: AI gap display in HUD
- Shows nearest AI ahead (β–² -X.Xs) and behind (β–Ό +X.Xs)

## Other improvements
- HUD overhaul: current lap time, best lap, ghost delta, nitro bar
- Toast notification system
- Updated index.html controls hint
- All files pass Node syntax check

Files changed (6) hide show
  1. index.html +1 -1
  2. js/ai.js +3 -2
  3. js/game.js +455 -19
  4. js/hud.js +149 -38
  5. js/main.js +0 -1988
  6. js/telemetry.js +86 -12
index.html CHANGED
@@ -21,6 +21,6 @@
21
  <body>
22
  <canvas id="game"></canvas>
23
  <canvas id="minimap" width="160" height="160"></canvas>
24
- <div id="controls">WASD / Arrows Β· SPACE to drift Β· R to restart</div>
25
  </body>
26
  </html>
 
21
  <body>
22
  <canvas id="game"></canvas>
23
  <canvas id="minimap" width="160" height="160"></canvas>
24
+ <div id="controls">WASD/ARROWS Β· SPACE drift Β· SHIFT nitro Β· C camera Β· G ghost Β· P/ESC pause Β· F fullscreen Β· R restart</div>
25
  </body>
26
  </html>
js/ai.js CHANGED
@@ -1,7 +1,8 @@
1
  // ═══════════════════════════════════════════════════════
2
  // AI β€” competitive physics-based racing
3
- // Key insight from telemetry: player NEVER drops below
4
- // 270 km/h. AI must carry corner speed like the player.
 
5
  // ═══════════════════════════════════════════════════════
6
  import * as THREE from 'three';
7
  import {
 
1
  // ═══════════════════════════════════════════════════════
2
  // AI β€” competitive physics-based racing
3
+ // Key insight: MAX_SPEED in config.js is 40 m/s (displayed
4
+ // as ~160 km/h on HUD via Γ—4 scale). AI must carry corner
5
+ // speed to keep up with an aggressive human player.
6
  // ═══════════════════════════════════════════════════════
7
  import * as THREE from 'three';
8
  import {
js/game.js CHANGED
@@ -13,7 +13,7 @@ import { createSmokeSystem, createDustSystem, createSpeedLineSystem } from './pa
13
  import { createTireMarkSystem } from './tire-marks.js';
14
  import { createMinimap } from './minimap.js';
15
  import { createHUD } from './hud.js';
16
- import { TelemetrySession, scheduleSave } from './telemetry.js';
17
  import { createSoundEngine } from './sound.js';
18
  import { createMusic } from './music.js';
19
  import { createAISound } from './ai-sound.js';
@@ -84,15 +84,96 @@ let sound = null;
84
  let music = null;
85
  let aiSound = null;
86
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
87
  // ══ INPUT ══
88
  window.addEventListener('keydown', (e) => {
89
- G.keys[e.code] = true;
90
  if (!sound) {
91
  sound = createSoundEngine();
92
  aiSound = createAISound(sound.ctx, sound.master, sound.noiseBuf);
93
  for (const ai of aiCars) ai.soundIdx = aiSound.addCar();
94
  music = createMusic(); music.start();
95
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
96
  });
97
  window.addEventListener('keyup', (e) => { G.keys[e.code] = false; });
98
 
@@ -193,6 +274,161 @@ function positionForAttract() {
193
  }
194
  positionForAttract();
195
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
196
  // ══ COUNTDOWN OVERLAY ══
197
  const countdownEl = document.createElement('div');
198
  countdownEl.style.cssText = `
@@ -316,7 +552,17 @@ function showResults() {
316
  html += '</div>';
317
  }
318
 
319
- html += '<div style="margin-top:36px; font-size:15px; color:#666; letter-spacing:3px;">PRESS R TO RESTART</div>';
 
 
 
 
 
 
 
 
 
 
320
  html += '</div>';
321
 
322
  resultsEl.innerHTML = html;
@@ -336,6 +582,17 @@ function startRace() {
336
  prevTrackT = 0;
337
  lapNotifyTimer = 0;
338
 
 
 
 
 
 
 
 
 
 
 
 
339
  G.player.speed = 0;
340
  G.player.impulseX = 0;
341
  G.player.impulseZ = 0;
@@ -377,6 +634,17 @@ function restartRace() {
377
  prevTrackT = 0;
378
  lapNotifyTimer = 0;
379
 
 
 
 
 
 
 
 
 
 
 
 
380
  G.player.speed = 0;
381
  G.player.impulseX = 0;
382
  G.player.impulseZ = 0;
@@ -454,9 +722,35 @@ function updatePlayerPhysics(dt) {
454
  const onRoad = nearest.dist < TRACK_WIDTH / 2;
455
  p.onTrack = onRoad;
456
 
457
- const handbrake = G.keys['Space'] || false;
458
- if (G.keys['KeyW'] || G.keys['ArrowUp']) p.speed += ACCEL * dt;
459
- else if (G.keys['KeyS'] || G.keys['ArrowDown']) p.speed -= BRAKE * dt;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
460
  else {
461
  if (p.speed > 0) p.speed = Math.max(0, p.speed - DRAG * dt);
462
  else p.speed = Math.min(0, p.speed + DRAG * dt);
@@ -472,11 +766,10 @@ function updatePlayerPhysics(dt) {
472
  p.speed *= Math.max(0, 1 - grassDrag * dt / Math.max(Math.abs(p.speed), 8));
473
  }
474
 
475
- p.speed = THREE.MathUtils.clamp(p.speed, -MAX_SPEED * 0.3, MAX_SPEED);
476
 
477
  const absSpeed = Math.abs(p.speed);
478
- const steerInput = (G.keys['KeyA'] || G.keys['ArrowLeft']) ? 1 :
479
- (G.keys['KeyD'] || G.keys['ArrowRight']) ? -1 : 0;
480
 
481
  const turnAuthority = Math.min(absSpeed / 12, 1) * Math.max(0.45, 1 - (absSpeed / MAX_SPEED) * 0.55);
482
  const isBraking = (G.keys['KeyS'] || G.keys['ArrowDown']) && p.speed > 2;
@@ -575,10 +868,23 @@ function updatePlayerPhysics(dt) {
575
  }
576
 
577
  function update() {
 
 
 
 
 
 
 
578
  const dt = Math.min(clock.getDelta(), 0.05);
579
  const p = G.player;
580
  const elapsed = clock.elapsedTime;
581
 
 
 
 
 
 
 
582
  // ══════════════════════════════════════════
583
  // STATE: ATTRACT β€” AI car racing, helicopter cam
584
  // ═══════════════════════════════════��══════
@@ -788,6 +1094,24 @@ function update() {
788
  const currentTrackT = nearest.t;
789
 
790
  // ── Player lap tracking (anti-cheat: must pass halfway) ──
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
791
  if (!playerFinished) {
792
  // Only set hasPassedHalf when ACTUALLY near the halfway point
793
  if (currentTrackT > 0.4 && currentTrackT < 0.6) {
@@ -799,9 +1123,31 @@ function update() {
799
  p.lap++;
800
  playerHasPassedHalf = false; // reset for next lap
801
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
802
  // Show lap notification
803
  if (p.lap < TOTAL_LAPS) {
804
- lapNotifyEl.textContent = `LAP ${p.lap + 1} / ${TOTAL_LAPS}`;
 
 
 
805
  lapNotifyEl.style.opacity = '1';
806
  lapNotifyTimer = 2;
807
  }
@@ -905,22 +1251,56 @@ function update() {
905
  scheduleSave(telemetry);
906
  }
907
 
908
- // ── Camera follow ──
909
- const camBehind = moveDir.clone().multiplyScalar(-14);
910
- const camUp = new THREE.Vector3(0, 7, 0);
911
- const targetCamPos = playerCar.position.clone().add(camBehind).add(camUp);
912
- camera.position.lerp(targetCamPos, 5 * dt);
913
- const lookTarget = playerCar.position.clone();
914
- lookTarget.y += 1;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
915
  camera.lookAt(lookTarget);
916
 
917
- const targetFov = 65 + (Math.abs(p.speed) / MAX_SPEED) * 15;
918
  camera.fov = THREE.MathUtils.lerp(camera.fov, targetFov, 3 * dt);
919
  camera.updateProjectionMatrix();
920
 
921
  sun.position.copy(playerCar.position).add(new THREE.Vector3(60, 80, 40));
922
  sun.target.position.copy(playerCar.position);
923
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
924
  // ── AI car sounds ──
925
  if (aiSound) {
926
  const px = p.x, pz = p.z, py = playerCar.position.y;
@@ -937,7 +1317,63 @@ function update() {
937
  const displaySpeed = Math.abs(Math.round(p.speed * 4));
938
  const pos = getPosition(currentTrackT);
939
  const playerLap = Math.min(p.lap + 1, TOTAL_LAPS);
940
- hud.draw(displaySpeed, p.onTrack, playerLap, pos, TOTAL_LAPS, aiCars.length + 1, elapsed);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
941
 
942
  // ── Minimap ──
943
  minimap.draw(p);
 
13
  import { createTireMarkSystem } from './tire-marks.js';
14
  import { createMinimap } from './minimap.js';
15
  import { createHUD } from './hud.js';
16
+ import { TelemetrySession, scheduleSave, loadBestLap, saveBestLap, loadGhost, saveGhost } from './telemetry.js';
17
  import { createSoundEngine } from './sound.js';
18
  import { createMusic } from './music.js';
19
  import { createAISound } from './ai-sound.js';
 
84
  let music = null;
85
  let aiSound = null;
86
 
87
+ // ══ BEST LAP & GHOST CAR ══
88
+ let bestLapRecord = loadBestLap(); // { time, lapNumber, ... } or null
89
+ let bestLapTime = bestLapRecord ? bestLapRecord.time : null;
90
+ let currentLapStart = 0; // elapsed time when current lap started
91
+ let currentLapTime = 0; // elapsed - currentLapStart
92
+ let lastLapTime = null; // most recently completed lap
93
+ let currentLapSamples = []; // [{t, x, z, heading}, ...] for potential ghost save
94
+ let ghostData = loadGhost(); // saved best-lap replay
95
+ let ghostCar = null; // THREE.Group for ghost visualization
96
+ let ghostEnabled = true; // user toggle (G key)
97
+
98
+ // ══ NITRO ══
99
+ let nitroCharge = 1.0; // 0..1
100
+ let nitroActive = false;
101
+ const NITRO_DURATION = 2.5; // seconds of boost
102
+ const NITRO_RECHARGE = 12; // seconds to full recharge
103
+ const NITRO_MULT = 1.35; // top-speed multiplier while active
104
+ const NITRO_ACCEL_MULT = 1.6; // acceleration multiplier
105
+ let nitroTimer = 0; // remaining boost time
106
+
107
+ // ══ CAMERA MODES ══
108
+ const CAM_THIRD = 0;
109
+ const CAM_COCKPIT = 1;
110
+ const CAM_TOP = 2;
111
+ const CAM_NAMES = ['THIRD-PERSON', 'COCKPIT', 'TOP-DOWN'];
112
+ let cameraMode = CAM_THIRD;
113
+
114
+ // ══ PAUSE ══
115
+ let paused = false;
116
+
117
+ // ══ TOUCH CONTROLS (mobile) ══
118
+ const touchState = { accel: false, brake: false, left: false, right: false, drift: false, nitro: false };
119
+
120
+ // ══ GHOST CAR (translucent replay of best lap) ══
121
+ function createGhostCar() {
122
+ const g = new THREE.Group();
123
+ const mat = new THREE.MeshBasicMaterial({ color: 0x00eaff, transparent: true, opacity: 0.35 });
124
+ const body = new THREE.Mesh(new THREE.BoxGeometry(2, 0.7, 4), mat);
125
+ body.position.y = 0.6;
126
+ g.add(body);
127
+ const cabin = new THREE.Mesh(new THREE.BoxGeometry(1.6, 0.5, 2), mat);
128
+ cabin.position.set(0, 1.15, -0.3);
129
+ g.add(cabin);
130
+ g.visible = false;
131
+ return g;
132
+ }
133
+ ghostCar = createGhostCar();
134
+ scene.add(ghostCar);
135
+
136
  // ══ INPUT ══
137
  window.addEventListener('keydown', (e) => {
138
+ // Start audio on first interaction
139
  if (!sound) {
140
  sound = createSoundEngine();
141
  aiSound = createAISound(sound.ctx, sound.master, sound.noiseBuf);
142
  for (const ai of aiCars) ai.soundIdx = aiSound.addCar();
143
  music = createMusic(); music.start();
144
  }
145
+
146
+ // Pause/resume (Escape or P) β€” don't record as game input
147
+ if (e.code === 'Escape' || e.code === 'KeyP') {
148
+ if (raceState === 'racing' || raceState === 'countdown' || paused) {
149
+ togglePause();
150
+ e.preventDefault();
151
+ return;
152
+ }
153
+ }
154
+ // Fullscreen toggle (F) β€” don't record as game input
155
+ if (e.code === 'KeyF') {
156
+ toggleFullscreen();
157
+ e.preventDefault();
158
+ return;
159
+ }
160
+ // Camera mode (C)
161
+ if (e.code === 'KeyC') {
162
+ cameraMode = (cameraMode + 1) % 3;
163
+ showToast(`CAMERA: ${CAM_NAMES[cameraMode]}`);
164
+ e.preventDefault();
165
+ return;
166
+ }
167
+ // Ghost toggle (G)
168
+ if (e.code === 'KeyG') {
169
+ ghostEnabled = !ghostEnabled;
170
+ ghostCar.visible = ghostEnabled && ghostData !== null && raceState === 'racing';
171
+ showToast(`GHOST: ${ghostEnabled ? 'ON' : 'OFF'}`);
172
+ e.preventDefault();
173
+ return;
174
+ }
175
+
176
+ G.keys[e.code] = true;
177
  });
178
  window.addEventListener('keyup', (e) => { G.keys[e.code] = false; });
179
 
 
274
  }
275
  positionForAttract();
276
 
277
+ // ══ PAUSE OVERLAY ══
278
+ const pauseEl = document.createElement('div');
279
+ pauseEl.id = 'pause-overlay';
280
+ pauseEl.style.cssText = `
281
+ position: fixed; top: 0; left: 0; right: 0; bottom: 0;
282
+ background: rgba(0,0,0,0.75);
283
+ display: none; align-items: center; justify-content: center;
284
+ z-index: 400; pointer-events: auto;
285
+ font-family: 'Orbitron', sans-serif;
286
+ `;
287
+ pauseEl.innerHTML = `
288
+ <div style="text-align:center; color:white;">
289
+ <div style="font-size:72px; font-weight:900; letter-spacing:8px; margin-bottom:36px;
290
+ text-shadow:0 0 40px rgba(255,255,255,0.6);">PAUSED</div>
291
+ <div style="display:flex; flex-direction:column; gap:16px; align-items:center;">
292
+ <button id="pause-resume" class="pause-btn">RESUME (ESC)</button>
293
+ <button id="pause-restart" class="pause-btn">RESTART (R)</button>
294
+ <button id="pause-camera" class="pause-btn">CAMERA (C)</button>
295
+ <button id="pause-ghost" class="pause-btn">GHOST (G)</button>
296
+ <button id="pause-fullscreen" class="pause-btn">FULLSCREEN (F)</button>
297
+ </div>
298
+ <div style="margin-top:40px; font-size:13px; color:rgba(255,255,255,0.5); letter-spacing:2px;">
299
+ WASD/ARROWS Β· SPACE DRIFT Β· SHIFT NITRO Β· P/ESC PAUSE
300
+ </div>
301
+ </div>
302
+ `;
303
+ const pauseStyle = document.createElement('style');
304
+ pauseStyle.textContent = `
305
+ .pause-btn {
306
+ font-family: Orbitron, sans-serif;
307
+ font-weight: 900; font-size: 18px;
308
+ background: rgba(255,255,255,0.08);
309
+ color: white;
310
+ border: 2px solid rgba(255,255,255,0.3);
311
+ padding: 12px 36px;
312
+ letter-spacing: 2px;
313
+ cursor: pointer;
314
+ min-width: 280px;
315
+ transition: all 0.15s;
316
+ }
317
+ .pause-btn:hover {
318
+ background: rgba(255,34,0,0.3);
319
+ border-color: #ff4422;
320
+ }
321
+ `;
322
+ document.head.appendChild(pauseStyle);
323
+ document.body.appendChild(pauseEl);
324
+
325
+ // Pause button handlers (added after function definitions)
326
+ setTimeout(() => {
327
+ document.getElementById('pause-resume').onclick = () => togglePause();
328
+ document.getElementById('pause-restart').onclick = () => { togglePause(); restartRace(); };
329
+ document.getElementById('pause-camera').onclick = () => {
330
+ cameraMode = (cameraMode + 1) % 3;
331
+ showToast(`CAMERA: ${CAM_NAMES[cameraMode]}`);
332
+ };
333
+ document.getElementById('pause-ghost').onclick = () => {
334
+ ghostEnabled = !ghostEnabled;
335
+ ghostCar.visible = ghostEnabled && ghostData !== null && raceState === 'racing';
336
+ showToast(`GHOST: ${ghostEnabled ? 'ON' : 'OFF'}`);
337
+ };
338
+ document.getElementById('pause-fullscreen').onclick = () => toggleFullscreen();
339
+ }, 0);
340
+
341
+ function togglePause() {
342
+ paused = !paused;
343
+ pauseEl.style.display = paused ? 'flex' : 'none';
344
+ if (paused) {
345
+ if (music && music.pause) music.pause();
346
+ } else {
347
+ if (music && music.resume) music.resume();
348
+ clock.getDelta(); // reset delta so dt doesn't spike
349
+ }
350
+ }
351
+
352
+ // ══ FULLSCREEN ══
353
+ function toggleFullscreen() {
354
+ if (!document.fullscreenElement) {
355
+ document.documentElement.requestFullscreen().catch(() => {});
356
+ } else {
357
+ document.exitFullscreen().catch(() => {});
358
+ }
359
+ }
360
+
361
+ // ══ TOUCH CONTROLS (mobile) ══
362
+ const isTouchDevice = 'ontouchstart' in window || navigator.maxTouchPoints > 0;
363
+ if (isTouchDevice) {
364
+ const touchStyle = document.createElement('style');
365
+ touchStyle.textContent = `
366
+ .touch-btn {
367
+ position: fixed;
368
+ font-family: Orbitron, sans-serif;
369
+ font-weight: 900;
370
+ font-size: 24px;
371
+ background: rgba(0,0,0,0.35);
372
+ color: white;
373
+ border: 2px solid rgba(255,255,255,0.4);
374
+ border-radius: 50%;
375
+ width: 72px; height: 72px;
376
+ display: flex; align-items: center; justify-content: center;
377
+ z-index: 150;
378
+ touch-action: manipulation;
379
+ user-select: none; -webkit-user-select: none;
380
+ backdrop-filter: blur(6px);
381
+ -webkit-backdrop-filter: blur(6px);
382
+ }
383
+ .touch-btn.wide { width: 92px; border-radius: 12px; font-size: 18px; }
384
+ .touch-btn:active { background: rgba(255,34,0,0.5); }
385
+ .touch-btn.pressed { background: rgba(255,34,0,0.6); }
386
+ `;
387
+ document.head.appendChild(touchStyle);
388
+
389
+ function makeBtn(label, style, key) {
390
+ const btn = document.createElement('div');
391
+ btn.className = 'touch-btn' + (style.wide ? ' wide' : '');
392
+ btn.textContent = label;
393
+ Object.assign(btn.style, style.css || {});
394
+ btn.addEventListener('touchstart', (e) => { touchState[key] = true; btn.classList.add('pressed'); e.preventDefault(); }, { passive: false });
395
+ btn.addEventListener('touchend', (e) => { touchState[key] = false; btn.classList.remove('pressed'); e.preventDefault(); }, { passive: false });
396
+ btn.addEventListener('touchcancel',(e) => { touchState[key] = false; btn.classList.remove('pressed'); }, { passive: true });
397
+ document.body.appendChild(btn);
398
+ return btn;
399
+ }
400
+ // Left cluster β€” steering
401
+ makeBtn('β—€', { css: { left: '24px', bottom: '100px' } }, 'left');
402
+ makeBtn('β–Ά', { css: { left: '116px', bottom: '100px' } }, 'right');
403
+ // Right cluster β€” pedals
404
+ makeBtn('β–²', { css: { right: '24px', bottom: '180px' } }, 'accel');
405
+ makeBtn('β–Ό', { css: { right: '24px', bottom: '100px' } }, 'brake');
406
+ // Drift
407
+ makeBtn('DRIFT', { wide: true, css: { right: '116px', bottom: '100px' } }, 'drift');
408
+ // Nitro
409
+ makeBtn('NITRO', { wide: true, css: { right: '116px', bottom: '180px' } }, 'nitro');
410
+ }
411
+
412
+ // ══ TOAST (transient notification) ══
413
+ const toastEl = document.createElement('div');
414
+ toastEl.style.cssText = `
415
+ position: fixed; top: 50%; left: 50%;
416
+ transform: translate(-50%, -50%);
417
+ font-family: Orbitron, sans-serif;
418
+ font-weight: 900; font-size: 28px;
419
+ color: #fff;
420
+ text-shadow: 0 0 20px rgba(255,255,255,0.6), 0 2px 8px rgba(0,0,0,0.9);
421
+ z-index: 350; pointer-events: none;
422
+ opacity: 0; transition: opacity 0.2s;
423
+ `;
424
+ document.body.appendChild(toastEl);
425
+ let toastTimer = 0;
426
+ function showToast(msg) {
427
+ toastEl.textContent = msg;
428
+ toastEl.style.opacity = '1';
429
+ toastTimer = 1.2;
430
+ }
431
+
432
  // ══ COUNTDOWN OVERLAY ══
433
  const countdownEl = document.createElement('div');
434
  countdownEl.style.cssText = `
 
552
  html += '</div>';
553
  }
554
 
555
+ // Best lap for this race + all-time
556
+ if (lastLapTime !== null) {
557
+ html += '<div style="margin-top:24px; display:flex; gap:36px; justify-content:center; font-size:16px;">';
558
+ html += `<div><span style="color:#888;">LAST LAP:</span> <span style="color:#fff; font-weight:700;">${formatRaceTime(lastLapTime)}</span></div>`;
559
+ if (bestLapTime !== null) {
560
+ html += `<div><span style="color:#888;">β˜… BEST:</span> <span style="color:#ffd700; font-weight:700;">${formatRaceTime(bestLapTime)}</span></div>`;
561
+ }
562
+ html += '</div>';
563
+ }
564
+
565
+ html += '<div style="margin-top:36px; font-size:15px; color:#666; letter-spacing:3px;">PRESS R TO RESTART Β· ESC TO PAUSE Β· C CAMERA Β· G GHOST</div>';
566
  html += '</div>';
567
 
568
  resultsEl.innerHTML = html;
 
582
  prevTrackT = 0;
583
  lapNotifyTimer = 0;
584
 
585
+ // Lap timing reset
586
+ currentLapStart = 0;
587
+ currentLapTime = 0;
588
+ lastLapTime = null;
589
+ currentLapSamples = [];
590
+
591
+ // Nitro reset
592
+ nitroCharge = 1.0;
593
+ nitroActive = false;
594
+ nitroTimer = 0;
595
+
596
  G.player.speed = 0;
597
  G.player.impulseX = 0;
598
  G.player.impulseZ = 0;
 
634
  prevTrackT = 0;
635
  lapNotifyTimer = 0;
636
 
637
+ // Lap timing reset
638
+ currentLapStart = 0;
639
+ currentLapTime = 0;
640
+ lastLapTime = null;
641
+ currentLapSamples = [];
642
+
643
+ // Nitro reset
644
+ nitroCharge = 1.0;
645
+ nitroActive = false;
646
+ nitroTimer = 0;
647
+
648
  G.player.speed = 0;
649
  G.player.impulseX = 0;
650
  G.player.impulseZ = 0;
 
722
  const onRoad = nearest.dist < TRACK_WIDTH / 2;
723
  p.onTrack = onRoad;
724
 
725
+ // Combine keyboard + touch input
726
+ const keyAccel = G.keys['KeyW'] || G.keys['ArrowUp'] || touchState.accel;
727
+ const keyBrake = G.keys['KeyS'] || G.keys['ArrowDown'] || touchState.brake;
728
+ const keyLeft = G.keys['KeyA'] || G.keys['ArrowLeft'] || touchState.left;
729
+ const keyRight = G.keys['KeyD'] || G.keys['ArrowRight'] || touchState.right;
730
+ const handbrake = G.keys['Space'] || touchState.drift || false;
731
+ const nitroKey = G.keys['ShiftLeft'] || G.keys['ShiftRight'] || touchState.nitro;
732
+
733
+ // ── Nitro: activate if pressed and have charge ──
734
+ if (nitroKey && !nitroActive && nitroCharge >= 0.25) {
735
+ nitroActive = true;
736
+ nitroTimer = NITRO_DURATION * nitroCharge; // proportional to charge
737
+ }
738
+ if (nitroActive) {
739
+ nitroTimer -= dt;
740
+ nitroCharge = Math.max(0, nitroCharge - dt / NITRO_DURATION);
741
+ if (nitroTimer <= 0 || nitroCharge <= 0) {
742
+ nitroActive = false;
743
+ nitroTimer = 0;
744
+ }
745
+ } else {
746
+ nitroCharge = Math.min(1, nitroCharge + dt / NITRO_RECHARGE);
747
+ }
748
+
749
+ const accelNow = nitroActive ? ACCEL * NITRO_ACCEL_MULT : ACCEL;
750
+ const maxNow = nitroActive ? MAX_SPEED * NITRO_MULT : MAX_SPEED;
751
+
752
+ if (keyAccel) p.speed += accelNow * dt;
753
+ else if (keyBrake) p.speed -= BRAKE * dt;
754
  else {
755
  if (p.speed > 0) p.speed = Math.max(0, p.speed - DRAG * dt);
756
  else p.speed = Math.min(0, p.speed + DRAG * dt);
 
766
  p.speed *= Math.max(0, 1 - grassDrag * dt / Math.max(Math.abs(p.speed), 8));
767
  }
768
 
769
+ p.speed = THREE.MathUtils.clamp(p.speed, -MAX_SPEED * 0.3, maxNow);
770
 
771
  const absSpeed = Math.abs(p.speed);
772
+ const steerInput = keyLeft ? 1 : (keyRight ? -1 : 0);
 
773
 
774
  const turnAuthority = Math.min(absSpeed / 12, 1) * Math.max(0.45, 1 - (absSpeed / MAX_SPEED) * 0.55);
775
  const isBraking = (G.keys['KeyS'] || G.keys['ArrowDown']) && p.speed > 2;
 
868
  }
869
 
870
  function update() {
871
+ // Pause: render only, no physics
872
+ if (paused) {
873
+ clock.getDelta(); // drain to prevent dt spike on resume
874
+ renderer.render(scene, camera);
875
+ return;
876
+ }
877
+
878
  const dt = Math.min(clock.getDelta(), 0.05);
879
  const p = G.player;
880
  const elapsed = clock.elapsedTime;
881
 
882
+ // Toast fade
883
+ if (toastTimer > 0) {
884
+ toastTimer -= dt;
885
+ if (toastTimer <= 0) toastEl.style.opacity = '0';
886
+ }
887
+
888
  // ══════════════════════════════════════════
889
  // STATE: ATTRACT β€” AI car racing, helicopter cam
890
  // ═══════════════════════════════════��══════
 
1094
  const currentTrackT = nearest.t;
1095
 
1096
  // ── Player lap tracking (anti-cheat: must pass halfway) ──
1097
+ currentLapTime = elapsed - currentLapStart;
1098
+
1099
+ // Record ghost samples every ~0.1s during current lap (if still possibly best)
1100
+ if (!playerFinished) {
1101
+ // Limit sample count to avoid memory blowup (max ~600 samples @ 0.1s = 60s lap)
1102
+ if (currentLapSamples.length < 1000) {
1103
+ const lastSample = currentLapSamples[currentLapSamples.length - 1];
1104
+ if (!lastSample || currentLapTime - lastSample.t >= 0.1) {
1105
+ currentLapSamples.push({
1106
+ t: currentLapTime,
1107
+ x: p.x,
1108
+ z: p.z,
1109
+ heading: p.heading,
1110
+ });
1111
+ }
1112
+ }
1113
+ }
1114
+
1115
  if (!playerFinished) {
1116
  // Only set hasPassedHalf when ACTUALLY near the halfway point
1117
  if (currentTrackT > 0.4 && currentTrackT < 0.6) {
 
1123
  p.lap++;
1124
  playerHasPassedHalf = false; // reset for next lap
1125
 
1126
+ // ── Capture lap time ──
1127
+ lastLapTime = currentLapTime;
1128
+ const isNewBest = bestLapTime === null || lastLapTime < bestLapTime;
1129
+
1130
+ if (isNewBest) {
1131
+ bestLapTime = lastLapTime;
1132
+ saveBestLap(lastLapTime, p.lap, telemetry.id);
1133
+ // Save samples as ghost data
1134
+ if (currentLapSamples.length > 10) {
1135
+ const saved = saveGhost(currentLapSamples, lastLapTime);
1136
+ if (saved) ghostData = loadGhost();
1137
+ }
1138
+ showToast(`NEW BEST LAP! ${formatRaceTime(lastLapTime)}`);
1139
+ }
1140
+
1141
+ // Reset for next lap
1142
+ currentLapStart = elapsed;
1143
+ currentLapSamples = [];
1144
+
1145
  // Show lap notification
1146
  if (p.lap < TOTAL_LAPS) {
1147
+ const lapMsg = isNewBest
1148
+ ? `LAP ${p.lap + 1}/${TOTAL_LAPS} ⭐ ${formatRaceTime(lastLapTime)}`
1149
+ : `LAP ${p.lap + 1}/${TOTAL_LAPS} ${formatRaceTime(lastLapTime)}`;
1150
+ lapNotifyEl.textContent = lapMsg;
1151
  lapNotifyEl.style.opacity = '1';
1152
  lapNotifyTimer = 2;
1153
  }
 
1251
  scheduleSave(telemetry);
1252
  }
1253
 
1254
+ // ── Camera follow (3 modes) ──
1255
+ let targetCamPos, lookTarget;
1256
+ if (cameraMode === CAM_THIRD) {
1257
+ const camBehind = moveDir.clone().multiplyScalar(-14);
1258
+ const camUp = new THREE.Vector3(0, 7, 0);
1259
+ targetCamPos = playerCar.position.clone().add(camBehind).add(camUp);
1260
+ lookTarget = playerCar.position.clone();
1261
+ lookTarget.y += 1;
1262
+ camera.position.lerp(targetCamPos, 5 * dt);
1263
+ } else if (cameraMode === CAM_COCKPIT) {
1264
+ // Cockpit: just above/behind the driver's seat
1265
+ const forward = moveDir.clone().multiplyScalar(-0.2);
1266
+ const up = new THREE.Vector3(0, 1.2, 0);
1267
+ targetCamPos = playerCar.position.clone().add(forward).add(up);
1268
+ const aheadVec = moveDir.clone().multiplyScalar(10);
1269
+ lookTarget = playerCar.position.clone().add(aheadVec);
1270
+ lookTarget.y += 0.6;
1271
+ camera.position.copy(targetCamPos); // snap, not lerp
1272
+ } else if (cameraMode === CAM_TOP) {
1273
+ targetCamPos = playerCar.position.clone().add(new THREE.Vector3(0, 40, 0));
1274
+ lookTarget = playerCar.position.clone();
1275
+ camera.position.lerp(targetCamPos, 8 * dt);
1276
+ }
1277
  camera.lookAt(lookTarget);
1278
 
1279
+ const targetFov = 65 + (Math.abs(p.speed) / MAX_SPEED) * 15 + (nitroActive ? 8 : 0);
1280
  camera.fov = THREE.MathUtils.lerp(camera.fov, targetFov, 3 * dt);
1281
  camera.updateProjectionMatrix();
1282
 
1283
  sun.position.copy(playerCar.position).add(new THREE.Vector3(60, 80, 40));
1284
  sun.target.position.copy(playerCar.position);
1285
 
1286
+ // ── Ghost car update (replay of best lap) ──
1287
+ if (ghostEnabled && ghostData && ghostData.samples && ghostData.samples.length > 0) {
1288
+ ghostCar.visible = true;
1289
+ // Loop the ghost in sync with current lap time
1290
+ const lapDur = ghostData.lapTime;
1291
+ const t = currentLapTime % lapDur;
1292
+ // Find closest sample (binary search would be faster, but linear is fine for 600 samples)
1293
+ let sample = ghostData.samples[0];
1294
+ for (let i = 1; i < ghostData.samples.length; i++) {
1295
+ if (ghostData.samples[i].t > t) break;
1296
+ sample = ghostData.samples[i];
1297
+ }
1298
+ ghostCar.position.set(sample.x, 0.05, sample.z);
1299
+ ghostCar.rotation.y = sample.heading;
1300
+ } else {
1301
+ ghostCar.visible = false;
1302
+ }
1303
+
1304
  // ── AI car sounds ──
1305
  if (aiSound) {
1306
  const px = p.x, pz = p.z, py = playerCar.position.y;
 
1317
  const displaySpeed = Math.abs(Math.round(p.speed * 4));
1318
  const pos = getPosition(currentTrackT);
1319
  const playerLap = Math.min(p.lap + 1, TOTAL_LAPS);
1320
+
1321
+ // ── Compute AI gaps (ahead/behind player) ──
1322
+ const playerProgressTotal = p.lap + currentTrackT;
1323
+ let aheadGap = null, aheadName = null, behindGap = null, behindName = null;
1324
+ let closestAheadProgress = Infinity;
1325
+ let closestBehindProgress = -Infinity;
1326
+ // Player speed in world units/sec (avg); approximate gap in seconds as distance/speed
1327
+ const avgSpeed = Math.max(Math.abs(p.speed), 10); // avoid divide-by-zero
1328
+ for (const ai of aiCars) {
1329
+ const aiProg = ai.lap + ai._trackT;
1330
+ const delta = aiProg - playerProgressTotal;
1331
+ if (delta > 0 && aiProg < closestAheadProgress) {
1332
+ closestAheadProgress = aiProg;
1333
+ // Convert track-t delta to world distance (approximate)
1334
+ aheadGap = (delta * trackLen) / avgSpeed;
1335
+ aheadName = ai.name || 'AI';
1336
+ } else if (delta < 0 && aiProg > closestBehindProgress) {
1337
+ closestBehindProgress = aiProg;
1338
+ behindGap = (-delta * trackLen) / avgSpeed;
1339
+ behindName = ai.name || 'AI';
1340
+ }
1341
+ }
1342
+
1343
+ // ── Compute ghost delta (seconds ahead/behind ghost) ──
1344
+ let ghostDelta = null;
1345
+ if (ghostData && ghostData.samples && ghostData.samples.length > 0) {
1346
+ // Find ghost's track position at current lap time
1347
+ const lapDur = ghostData.lapTime;
1348
+ const t = currentLapTime % lapDur;
1349
+ let ghostSample = ghostData.samples[0];
1350
+ for (let i = 1; i < ghostData.samples.length; i++) {
1351
+ if (ghostData.samples[i].t > t) break;
1352
+ ghostSample = ghostData.samples[i];
1353
+ }
1354
+ // Find ghost track-t and compare with player
1355
+ const ghostNearest = nearestTrackT(ghostSample.x, ghostSample.z);
1356
+ const ghostProgress = ghostNearest.t;
1357
+ const playerProgress = currentTrackT;
1358
+ // Approx delta: if player is ahead on track, negative = faster than ghost
1359
+ let delta = (ghostProgress - playerProgress) * trackLen / avgSpeed;
1360
+ // wrap
1361
+ if (delta > trackLen / (2 * avgSpeed)) delta -= trackLen / avgSpeed;
1362
+ if (delta < -trackLen / (2 * avgSpeed)) delta += trackLen / avgSpeed;
1363
+ ghostDelta = delta;
1364
+ }
1365
+
1366
+ hud.draw(displaySpeed, p.onTrack, playerLap, pos, TOTAL_LAPS, aiCars.length + 1, elapsed, {
1367
+ currentLapTime: currentLapTime,
1368
+ bestLapTime: bestLapTime,
1369
+ lastLapTime: lastLapTime,
1370
+ gapAhead: aheadGap,
1371
+ gapBehind: behindGap,
1372
+ aheadName: aheadName,
1373
+ behindName: behindName,
1374
+ nitroCharge: nitroCharge,
1375
+ ghostDelta: ghostDelta,
1376
+ });
1377
 
1378
  // ── Minimap ──
1379
  minimap.draw(p);
js/hud.js CHANGED
@@ -1,6 +1,7 @@
1
  // ═══════════════════════════════════════════════════════
2
  // HUD β€” Daytona-style minimal white gauge
3
- // One speedometer arc, transparent, vintage arcade
 
4
  // ═══════════════════════════════════════════════════════
5
 
6
  export function createHUD() {
@@ -26,21 +27,21 @@ export function createHUD() {
26
  document.fonts.load('900 24px Orbitron').catch(() => {});
27
 
28
  // ── Single speed gauge ──
29
- function drawGauge(speed) {
30
  const cx = SIZE / 2;
31
  const cy = SIZE / 2 + 10;
32
  const r = 88;
33
  const maxSpeed = 160;
34
 
35
- const startA = Math.PI * 0.8; // ~144Β°
36
- const endA = Math.PI * 2.2; // ~396Β°
37
- const sweep = endA - startA; // ~252Β°
38
 
39
  ctx.clearRect(0, 0, SIZE, SIZE);
40
 
41
  const clamped = Math.max(0, Math.min(speed / maxSpeed, 1.05));
42
 
43
- // ── Outer arc track (dim white) ──
44
  ctx.beginPath();
45
  ctx.arc(cx, cy, r, startA, endA);
46
  ctx.strokeStyle = 'rgba(255,255,255,0.12)';
@@ -48,10 +49,9 @@ export function createHUD() {
48
  ctx.lineCap = 'round';
49
  ctx.stroke();
50
 
51
- // ── Active arc (bright white) ──
52
  const activeEnd = startA + clamped * sweep;
53
  if (clamped > 0.005) {
54
- // Glow layer
55
  ctx.save();
56
  ctx.shadowColor = 'rgba(255,255,255,0.5)';
57
  ctx.shadowBlur = 12;
@@ -63,7 +63,6 @@ export function createHUD() {
63
  ctx.stroke();
64
  ctx.restore();
65
 
66
- // Solid layer on top
67
  ctx.beginPath();
68
  ctx.arc(cx, cy, r, startA, activeEnd);
69
  ctx.strokeStyle = '#ffffff';
@@ -72,7 +71,7 @@ export function createHUD() {
72
  ctx.stroke();
73
  }
74
 
75
- // ── Redline zone indicator ──
76
  const redlineT = 0.78;
77
  const redlineA = startA + redlineT * sweep;
78
  ctx.beginPath();
@@ -95,11 +94,9 @@ export function createHUD() {
95
  ctx.restore();
96
  }
97
 
98
- // ── Tick marks ──
99
  const majorTicks = [0, 20, 40, 60, 80, 100, 120, 140, 160];
100
  const minorPerMajor = 5;
101
-
102
- // Minor ticks
103
  for (let i = 0; i < majorTicks.length - 1; i++) {
104
  for (let j = 1; j < minorPerMajor; j++) {
105
  const t = (majorTicks[i] + (majorTicks[i + 1] - majorTicks[i]) * j / minorPerMajor) / maxSpeed;
@@ -112,21 +109,16 @@ export function createHUD() {
112
  ctx.stroke();
113
  }
114
  }
115
-
116
- // Major ticks + labels
117
  for (const val of majorTicks) {
118
  const t = val / maxSpeed;
119
  const a = startA + t * sweep;
120
  const isRed = t >= redlineT;
121
-
122
  ctx.beginPath();
123
  ctx.moveTo(cx + Math.cos(a) * (r - 16), cy + Math.sin(a) * (r - 16));
124
  ctx.lineTo(cx + Math.cos(a) * (r - 6), cy + Math.sin(a) * (r - 6));
125
  ctx.strokeStyle = isRed ? 'rgba(255,80,60,0.8)' : 'rgba(255,255,255,0.6)';
126
  ctx.lineWidth = 2;
127
  ctx.stroke();
128
-
129
- // Number label
130
  const lr = r - 26;
131
  const lx = cx + Math.cos(a) * lr;
132
  const ly = cy + Math.sin(a) * lr;
@@ -140,7 +132,7 @@ export function createHUD() {
140
  ctx.restore();
141
  }
142
 
143
- // ── Needle ──
144
  const needleA = startA + clamped * sweep;
145
  const nLen = r - 4;
146
  const nTail = 14;
@@ -148,8 +140,6 @@ export function createHUD() {
148
  const tipY = cy + Math.sin(needleA) * nLen;
149
  const tailX = cx - Math.cos(needleA) * nTail;
150
  const tailY = cy - Math.sin(needleA) * nTail;
151
-
152
- // Needle glow
153
  ctx.save();
154
  ctx.shadowColor = clamped > redlineT ? 'rgba(255,60,40,0.8)' : 'rgba(255,255,255,0.6)';
155
  ctx.shadowBlur = 8;
@@ -162,13 +152,12 @@ export function createHUD() {
162
  ctx.stroke();
163
  ctx.restore();
164
 
165
- // Center pivot
166
  ctx.beginPath();
167
  ctx.arc(cx, cy, 4, 0, Math.PI * 2);
168
  ctx.fillStyle = '#fff';
169
  ctx.fill();
170
 
171
- // ── Digital speed readout ──
172
  const speedVal = Math.round(speed);
173
  ctx.save();
174
  ctx.shadowColor = 'rgba(255,255,255,0.4)';
@@ -180,50 +169,83 @@ export function createHUD() {
180
  ctx.fillText(speedVal, cx, cy + 32);
181
  ctx.restore();
182
 
183
- // "km/h" label
184
  ctx.fillStyle = 'rgba(255,255,255,0.4)';
185
  ctx.font = '700 10px Orbitron, sans-serif';
186
  ctx.textAlign = 'center';
187
  ctx.fillText('km/h', cx, cy + 50);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
188
  }
189
 
190
- // ── Position & lap overlay (top corners) ──
191
  const overlay = document.createElement('canvas');
192
  overlay.id = 'hud-overlay';
193
  overlay.width = window.innerWidth;
194
- overlay.height = 80;
195
  overlay.style.cssText = `
196
  position: fixed;
197
  top: 0;
198
  left: 0;
199
  pointer-events: none;
200
  z-index: 100;
201
- opacity: 0.9;
202
  `;
203
  document.body.appendChild(overlay);
204
  const octx = overlay.getContext('2d');
205
 
206
- function drawOverlay(position, lap, totalLaps, onTrack, time) {
 
 
 
 
 
 
 
 
 
207
  octx.clearRect(0, 0, overlay.width, overlay.height);
 
 
 
 
 
 
208
 
209
- // Position β€” top left, bold white
210
  const posText = `${position}`;
211
  octx.save();
212
  octx.font = '900 56px Orbitron, sans-serif';
213
  octx.textAlign = 'left';
214
  octx.textBaseline = 'top';
215
- // Dark outline for readability
216
  octx.strokeStyle = 'rgba(0,0,0,0.7)';
217
  octx.lineWidth = 6;
218
  octx.lineJoin = 'round';
219
  octx.strokeText(posText, 22, 10);
220
- // Position color β€” gold/silver/bronze/white
221
  const posColors = { 1: '#ffd700', 2: '#e0e0e0', 3: '#cd7f32' };
222
  octx.fillStyle = posColors[position] || '#ffffff';
223
  octx.fillText(posText, 22, 10);
224
  octx.restore();
225
 
226
- // "POSITION" label
227
  octx.save();
228
  octx.font = '700 11px Orbitron, sans-serif';
229
  octx.textAlign = 'left';
@@ -235,6 +257,30 @@ export function createHUD() {
235
  octx.fillText('POSITION', 24, 68);
236
  octx.restore();
237
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
238
  // Lap β€” top right
239
  const lapText = `LAP ${lap}/${totalLaps}`;
240
  octx.save();
@@ -244,11 +290,66 @@ export function createHUD() {
244
  octx.strokeStyle = 'rgba(0,0,0,0.7)';
245
  octx.lineWidth = 5;
246
  octx.lineJoin = 'round';
247
- octx.strokeText(lapText, overlay.width - 22, 18);
248
  octx.fillStyle = '#ffffff';
249
- octx.fillText(lapText, overlay.width - 22, 18);
250
  octx.restore();
251
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
252
  // Off-track warning
253
  if (!onTrack) {
254
  const flash = Math.sin(time * 10) > 0;
@@ -271,11 +372,21 @@ export function createHUD() {
271
  overlay.width = window.innerWidth;
272
  });
273
 
274
- // ── Main entry ──
275
- function draw(speed, onTrack, lap, position, totalLaps, totalRacers, time) {
276
  smoothSpeed += (speed - smoothSpeed) * 0.14;
277
- drawGauge(smoothSpeed);
278
- drawOverlay(position, lap, totalLaps, onTrack, time);
 
 
 
 
 
 
 
 
 
 
279
  }
280
 
281
  return { draw };
 
1
  // ═══════════════════════════════════════════════════════
2
  // HUD β€” Daytona-style minimal white gauge
3
+ // Features: speedometer, position, lap, current/best lap time,
4
+ // AI gap, off-track warning, nitro bar
5
  // ═══════════════════════════════════════════════════════
6
 
7
  export function createHUD() {
 
27
  document.fonts.load('900 24px Orbitron').catch(() => {});
28
 
29
  // ── Single speed gauge ──
30
+ function drawGauge(speed, nitroCharge) {
31
  const cx = SIZE / 2;
32
  const cy = SIZE / 2 + 10;
33
  const r = 88;
34
  const maxSpeed = 160;
35
 
36
+ const startA = Math.PI * 0.8;
37
+ const endA = Math.PI * 2.2;
38
+ const sweep = endA - startA;
39
 
40
  ctx.clearRect(0, 0, SIZE, SIZE);
41
 
42
  const clamped = Math.max(0, Math.min(speed / maxSpeed, 1.05));
43
 
44
+ // Outer arc track
45
  ctx.beginPath();
46
  ctx.arc(cx, cy, r, startA, endA);
47
  ctx.strokeStyle = 'rgba(255,255,255,0.12)';
 
49
  ctx.lineCap = 'round';
50
  ctx.stroke();
51
 
52
+ // Active arc
53
  const activeEnd = startA + clamped * sweep;
54
  if (clamped > 0.005) {
 
55
  ctx.save();
56
  ctx.shadowColor = 'rgba(255,255,255,0.5)';
57
  ctx.shadowBlur = 12;
 
63
  ctx.stroke();
64
  ctx.restore();
65
 
 
66
  ctx.beginPath();
67
  ctx.arc(cx, cy, r, startA, activeEnd);
68
  ctx.strokeStyle = '#ffffff';
 
71
  ctx.stroke();
72
  }
73
 
74
+ // Redline
75
  const redlineT = 0.78;
76
  const redlineA = startA + redlineT * sweep;
77
  ctx.beginPath();
 
94
  ctx.restore();
95
  }
96
 
97
+ // Ticks
98
  const majorTicks = [0, 20, 40, 60, 80, 100, 120, 140, 160];
99
  const minorPerMajor = 5;
 
 
100
  for (let i = 0; i < majorTicks.length - 1; i++) {
101
  for (let j = 1; j < minorPerMajor; j++) {
102
  const t = (majorTicks[i] + (majorTicks[i + 1] - majorTicks[i]) * j / minorPerMajor) / maxSpeed;
 
109
  ctx.stroke();
110
  }
111
  }
 
 
112
  for (const val of majorTicks) {
113
  const t = val / maxSpeed;
114
  const a = startA + t * sweep;
115
  const isRed = t >= redlineT;
 
116
  ctx.beginPath();
117
  ctx.moveTo(cx + Math.cos(a) * (r - 16), cy + Math.sin(a) * (r - 16));
118
  ctx.lineTo(cx + Math.cos(a) * (r - 6), cy + Math.sin(a) * (r - 6));
119
  ctx.strokeStyle = isRed ? 'rgba(255,80,60,0.8)' : 'rgba(255,255,255,0.6)';
120
  ctx.lineWidth = 2;
121
  ctx.stroke();
 
 
122
  const lr = r - 26;
123
  const lx = cx + Math.cos(a) * lr;
124
  const ly = cy + Math.sin(a) * lr;
 
132
  ctx.restore();
133
  }
134
 
135
+ // Needle
136
  const needleA = startA + clamped * sweep;
137
  const nLen = r - 4;
138
  const nTail = 14;
 
140
  const tipY = cy + Math.sin(needleA) * nLen;
141
  const tailX = cx - Math.cos(needleA) * nTail;
142
  const tailY = cy - Math.sin(needleA) * nTail;
 
 
143
  ctx.save();
144
  ctx.shadowColor = clamped > redlineT ? 'rgba(255,60,40,0.8)' : 'rgba(255,255,255,0.6)';
145
  ctx.shadowBlur = 8;
 
152
  ctx.stroke();
153
  ctx.restore();
154
 
 
155
  ctx.beginPath();
156
  ctx.arc(cx, cy, 4, 0, Math.PI * 2);
157
  ctx.fillStyle = '#fff';
158
  ctx.fill();
159
 
160
+ // Digital speed
161
  const speedVal = Math.round(speed);
162
  ctx.save();
163
  ctx.shadowColor = 'rgba(255,255,255,0.4)';
 
169
  ctx.fillText(speedVal, cx, cy + 32);
170
  ctx.restore();
171
 
 
172
  ctx.fillStyle = 'rgba(255,255,255,0.4)';
173
  ctx.font = '700 10px Orbitron, sans-serif';
174
  ctx.textAlign = 'center';
175
  ctx.fillText('km/h', cx, cy + 50);
176
+
177
+ // ── Nitro bar (below gauge) ──
178
+ if (nitroCharge !== null && nitroCharge !== undefined) {
179
+ const barY = SIZE - 8;
180
+ const barW = 140;
181
+ const barH = 5;
182
+ const barX = (SIZE - barW) / 2;
183
+ ctx.fillStyle = 'rgba(255,255,255,0.15)';
184
+ ctx.fillRect(barX, barY, barW, barH);
185
+ const fillW = barW * Math.max(0, Math.min(1, nitroCharge));
186
+ ctx.fillStyle = nitroCharge >= 1 ? '#00eaff' : 'rgba(0,200,255,0.7)';
187
+ ctx.fillRect(barX, barY, fillW, barH);
188
+ if (nitroCharge >= 1) {
189
+ ctx.save();
190
+ ctx.shadowColor = '#00eaff';
191
+ ctx.shadowBlur = 8;
192
+ ctx.fillStyle = '#00eaff';
193
+ ctx.fillRect(barX, barY, fillW, barH);
194
+ ctx.restore();
195
+ }
196
+ }
197
  }
198
 
199
+ // ── Top overlay (position, lap, times, gap) ──
200
  const overlay = document.createElement('canvas');
201
  overlay.id = 'hud-overlay';
202
  overlay.width = window.innerWidth;
203
+ overlay.height = 120;
204
  overlay.style.cssText = `
205
  position: fixed;
206
  top: 0;
207
  left: 0;
208
  pointer-events: none;
209
  z-index: 100;
210
+ opacity: 0.95;
211
  `;
212
  document.body.appendChild(overlay);
213
  const octx = overlay.getContext('2d');
214
 
215
+ function formatTime(seconds) {
216
+ if (!isFinite(seconds) || seconds < 0) return '--:--.---';
217
+ const mins = Math.floor(seconds / 60);
218
+ const secs = seconds % 60;
219
+ const wholeSecs = Math.floor(secs);
220
+ const ms = Math.floor((secs - wholeSecs) * 1000);
221
+ return `${mins}:${String(wholeSecs).padStart(2, '0')}.${String(ms).padStart(3, '0')}`;
222
+ }
223
+
224
+ function drawOverlay(data) {
225
  octx.clearRect(0, 0, overlay.width, overlay.height);
226
+ const {
227
+ position, lap, totalLaps, onTrack, time,
228
+ currentLapTime, bestLapTime, lastLapTime,
229
+ gapAhead, gapBehind, aheadName, behindName,
230
+ ghostDelta,
231
+ } = data;
232
 
233
+ // Position β€” top left
234
  const posText = `${position}`;
235
  octx.save();
236
  octx.font = '900 56px Orbitron, sans-serif';
237
  octx.textAlign = 'left';
238
  octx.textBaseline = 'top';
 
239
  octx.strokeStyle = 'rgba(0,0,0,0.7)';
240
  octx.lineWidth = 6;
241
  octx.lineJoin = 'round';
242
  octx.strokeText(posText, 22, 10);
 
243
  const posColors = { 1: '#ffd700', 2: '#e0e0e0', 3: '#cd7f32' };
244
  octx.fillStyle = posColors[position] || '#ffffff';
245
  octx.fillText(posText, 22, 10);
246
  octx.restore();
247
 
248
+ // POSITION label
249
  octx.save();
250
  octx.font = '700 11px Orbitron, sans-serif';
251
  octx.textAlign = 'left';
 
257
  octx.fillText('POSITION', 24, 68);
258
  octx.restore();
259
 
260
+ // Gap ahead / behind (below POSITION label)
261
+ if (gapAhead !== null && aheadName) {
262
+ octx.save();
263
+ octx.font = '700 13px Orbitron, sans-serif';
264
+ octx.textAlign = 'left';
265
+ octx.strokeStyle = 'rgba(0,0,0,0.7)';
266
+ octx.lineWidth = 3;
267
+ octx.strokeText(`β–² ${aheadName} -${gapAhead.toFixed(1)}s`, 24, 86);
268
+ octx.fillStyle = 'rgba(200,220,255,0.85)';
269
+ octx.fillText(`β–² ${aheadName} -${gapAhead.toFixed(1)}s`, 24, 86);
270
+ octx.restore();
271
+ }
272
+ if (gapBehind !== null && behindName) {
273
+ octx.save();
274
+ octx.font = '700 13px Orbitron, sans-serif';
275
+ octx.textAlign = 'left';
276
+ octx.strokeStyle = 'rgba(0,0,0,0.7)';
277
+ octx.lineWidth = 3;
278
+ octx.strokeText(`β–Ό ${behindName} +${gapBehind.toFixed(1)}s`, 24, 102);
279
+ octx.fillStyle = 'rgba(255,200,180,0.85)';
280
+ octx.fillText(`β–Ό ${behindName} +${gapBehind.toFixed(1)}s`, 24, 102);
281
+ octx.restore();
282
+ }
283
+
284
  // Lap β€” top right
285
  const lapText = `LAP ${lap}/${totalLaps}`;
286
  octx.save();
 
290
  octx.strokeStyle = 'rgba(0,0,0,0.7)';
291
  octx.lineWidth = 5;
292
  octx.lineJoin = 'round';
293
+ octx.strokeText(lapText, overlay.width - 22, 10);
294
  octx.fillStyle = '#ffffff';
295
+ octx.fillText(lapText, overlay.width - 22, 10);
296
  octx.restore();
297
 
298
+ // Current lap time
299
+ if (currentLapTime !== null && currentLapTime !== undefined) {
300
+ const curText = formatTime(currentLapTime);
301
+ octx.save();
302
+ octx.font = '700 20px Orbitron, sans-serif';
303
+ octx.textAlign = 'right';
304
+ octx.strokeStyle = 'rgba(0,0,0,0.7)';
305
+ octx.lineWidth = 4;
306
+ octx.strokeText(curText, overlay.width - 22, 44);
307
+ octx.fillStyle = '#ffffff';
308
+ octx.fillText(curText, overlay.width - 22, 44);
309
+ octx.restore();
310
+
311
+ // CURRENT label
312
+ octx.save();
313
+ octx.font = '700 9px Orbitron, sans-serif';
314
+ octx.textAlign = 'right';
315
+ octx.strokeStyle = 'rgba(0,0,0,0.6)';
316
+ octx.lineWidth = 2;
317
+ octx.strokeText('CURRENT', overlay.width - 22, 68);
318
+ octx.fillStyle = 'rgba(255,255,255,0.5)';
319
+ octx.fillText('CURRENT', overlay.width - 22, 68);
320
+ octx.restore();
321
+ }
322
+
323
+ // Best lap time (smaller, below current)
324
+ if (bestLapTime !== null && bestLapTime !== undefined) {
325
+ const bestText = formatTime(bestLapTime);
326
+ octx.save();
327
+ octx.font = '700 14px Orbitron, sans-serif';
328
+ octx.textAlign = 'right';
329
+ octx.strokeStyle = 'rgba(0,0,0,0.7)';
330
+ octx.lineWidth = 3;
331
+ octx.strokeText(`β˜… BEST ${bestText}`, overlay.width - 22, 84);
332
+ octx.fillStyle = '#ffd700';
333
+ octx.fillText(`β˜… BEST ${bestText}`, overlay.width - 22, 84);
334
+ octx.restore();
335
+ }
336
+
337
+ // Ghost delta
338
+ if (ghostDelta !== null && ghostDelta !== undefined && isFinite(ghostDelta)) {
339
+ const sign = ghostDelta >= 0 ? '+' : '';
340
+ const color = ghostDelta >= 0 ? '#ff6644' : '#44ff88';
341
+ const text = `GHOST ${sign}${ghostDelta.toFixed(2)}s`;
342
+ octx.save();
343
+ octx.font = '700 12px Orbitron, sans-serif';
344
+ octx.textAlign = 'right';
345
+ octx.strokeStyle = 'rgba(0,0,0,0.7)';
346
+ octx.lineWidth = 3;
347
+ octx.strokeText(text, overlay.width - 22, 103);
348
+ octx.fillStyle = color;
349
+ octx.fillText(text, overlay.width - 22, 103);
350
+ octx.restore();
351
+ }
352
+
353
  // Off-track warning
354
  if (!onTrack) {
355
  const flash = Math.sin(time * 10) > 0;
 
372
  overlay.width = window.innerWidth;
373
  });
374
 
375
+ // ── Main entry (backward compatible) ──
376
+ function draw(speed, onTrack, lap, position, totalLaps, totalRacers, time, extras = {}) {
377
  smoothSpeed += (speed - smoothSpeed) * 0.14;
378
+ drawGauge(smoothSpeed, extras.nitroCharge);
379
+ drawOverlay({
380
+ position, lap, totalLaps, onTrack, time,
381
+ currentLapTime: extras.currentLapTime ?? null,
382
+ bestLapTime: extras.bestLapTime ?? null,
383
+ lastLapTime: extras.lastLapTime ?? null,
384
+ gapAhead: extras.gapAhead ?? null,
385
+ gapBehind: extras.gapBehind ?? null,
386
+ aheadName: extras.aheadName ?? null,
387
+ behindName: extras.behindName ?? null,
388
+ ghostDelta: extras.ghostDelta ?? null,
389
+ });
390
  }
391
 
392
  return { draw };
js/main.js DELETED
@@ -1,1988 +0,0 @@
1
- // ═══════════════════════════════════════════════════════
2
- // CAR GAME β€” main.js
3
- // ═══════════════════════════════════════════════════════
4
- // TABLE OF CONTENTS:
5
- // L10 β€” GLOBALS
6
- // L13 β€” TRACK DEFINITION
7
- // L51 β€” GEOMETRY BUILDERS
8
- // L136 β€” RENDERER & SCENE
9
- // L153 β€” SKY DOME
10
- // L201 β€” LIGHTS
11
- // L224 β€” GROUND
12
- // L281 β€” TRACK MESHES
13
- // L556 β€” SCENERY (trees, bushes, rocks, objects)
14
- // L931 β€” PLACE SCENERY (positioning + safety checks)
15
- // L1117 β€” CARS (player + AI)
16
- // L1230 β€” ITEM BOXES
17
- // L1289 β€” PARTICLE SYSTEMS
18
- // L1552 β€” MINI-MAP
19
- // L1635 β€” FIND NEAREST POINT ON TRACK
20
- // L1660 β€” WINDOW EXPORTS
21
- // L1669 β€” INPUT
22
- // L1675 β€” TIRE MARKS
23
- // L1766 β€” GAME LOOP
24
- // L1995 β€” RESIZE
25
- // ═══════════════════════════════════════════════════════
26
-
27
- // ═══════════════════════════════════════════════════════
28
- // GLOBALS
29
- // ═══════════════════════════════════════════════════════
30
- const G = {
31
- player: { x: 0, z: 0, heading: 0, speed: 0, lap: 0, onTrack: true },
32
- keys: {},
33
- lapMarker: 0, // track parameter at start/finish line
34
- };
35
-
36
- // window exports moved to bottom (after declarations)
37
-
38
- // ═══════════════════════════════════════════════════════
39
- // TRACK DEFINITION β€” closed-loop circuit
40
- // ═══════════════════════════════════════════════════════
41
- const TRACK_WIDTH = 16;
42
- const CURB_W = 1.2;
43
- const SEG = 600;
44
-
45
- const trackPoints = [
46
- new THREE.Vector3(0, 0, 0), // Start/finish
47
- new THREE.Vector3(55, 0, 0), // End of main straight
48
- new THREE.Vector3(85, 0, -25), // Turn 1 entry (right sweeper)
49
- new THREE.Vector3(95, 0, -60), // Turn 1 apex
50
- new THREE.Vector3(80, 0, -90), // Turn 1 exit
51
- new THREE.Vector3(50, 2, -108), // Uphill back-straight
52
- new THREE.Vector3(20, 1, -100), // Hairpin entry
53
- new THREE.Vector3(0, 0, -78), // Hairpin apex
54
- new THREE.Vector3(8, 0, -52), // Hairpin exit
55
- new THREE.Vector3(-18, 0, -38), // S-curve entry
56
- new THREE.Vector3(-48, 0, -58), // S-curve mid
57
- new THREE.Vector3(-72, 0, -38), // S-curve exit
58
- new THREE.Vector3(-62, 0, -8), // Final sweeper entry
59
- new THREE.Vector3(-32, 0, 12), // Final sweeper exit β†’ approach start
60
- ];
61
-
62
- const trackCurve = new THREE.CatmullRomCurve3(trackPoints, true, 'catmullrom', 0.3);
63
- const trackLen = trackCurve.getLength();
64
-
65
- // Get track frame at parameter t (0–1)
66
- function frame(t) {
67
- t = ((t % 1) + 1) % 1;
68
- const point = trackCurve.getPointAt(t);
69
- const tangent = trackCurve.getTangentAt(t).normalize();
70
- const up = new THREE.Vector3(0, 1, 0);
71
- const side = new THREE.Vector3().crossVectors(tangent, up).normalize();
72
- if (side.lengthSq() < 0.0001) side.set(1, 0, 0);
73
- return { point, tangent, side };
74
- }
75
-
76
- // ═══════════════════════════════════════════════════════
77
- // GEOMETRY BUILDERS
78
- // ═══════════════════════════════════════════════════════
79
- // Track width + dirt runoff zone
80
- const DIRT_WIDTH = 14; // extra dirt on each side beyond curbs
81
-
82
- function buildRoad() {
83
- const v = [], u = [], idx = [];
84
- const hw = TRACK_WIDTH / 2;
85
- for (let i = 0; i <= SEG; i++) {
86
- const t = i / SEG;
87
- const { point, side } = frame(t);
88
- const L = point.clone().add(side.clone().multiplyScalar(-hw));
89
- const R = point.clone().add(side.clone().multiplyScalar(hw));
90
- L.y += 0.02; R.y += 0.02;
91
- v.push(L.x, L.y, L.z, R.x, R.y, R.z);
92
- u.push(0, t * 25, 1, t * 25);
93
- // CCW winding: normals face UP (+Y)
94
- if (i < SEG) { const b = i * 2; idx.push(b, b + 1, b + 2, b + 1, b + 3, b + 2); }
95
- }
96
- const g = new THREE.BufferGeometry();
97
- g.setAttribute('position', new THREE.Float32BufferAttribute(v, 3));
98
- g.setAttribute('uv', new THREE.Float32BufferAttribute(u, 2));
99
- g.setIndex(idx);
100
- g.computeVertexNormals();
101
- return g;
102
- }
103
-
104
- // Build dirt strip around track (4 rows: dirt-dense, dirt, transition, grass-edge)
105
- function buildDirtStrip(sSign) {
106
- const v = [], u = [], idx = [];
107
- const hw = TRACK_WIDTH / 2 + CURB_W;
108
- const rows = 4;
109
- const colW = DIRT_WIDTH / rows; // width per row
110
- for (let i = 0; i <= SEG; i++) {
111
- const t = i / SEG;
112
- const { point, side } = frame(t);
113
- const dir = side.clone().multiplyScalar(sSign);
114
- for (let r = 0; r <= rows; r++) {
115
- const dist = hw + r * colW;
116
- const p = point.clone().add(dir.clone().multiplyScalar(dist));
117
- p.y += 0.01;
118
- v.push(p.x, p.y, p.z);
119
- // U: across strip (0=track edge, 1=grass edge), V: along track
120
- u.push(r / rows, t * 50);
121
- }
122
- // Winding depends on sSign: same logic as curbs
123
- if (i < SEG) {
124
- const base = i * (rows + 1);
125
- for (let r = 0; r < rows; r++) {
126
- if (sSign === -1) {
127
- idx.push(base + r, base + rows + 1 + r, base + r + 1);
128
- idx.push(base + r + 1, base + rows + 1 + r, base + rows + 2 + r);
129
- } else {
130
- idx.push(base + r, base + r + 1, base + rows + 1 + r);
131
- idx.push(base + r + 1, base + rows + 2 + r, base + rows + 1 + r);
132
- }
133
- }
134
- }
135
- }
136
- const g = new THREE.BufferGeometry();
137
- g.setAttribute('position', new THREE.Float32BufferAttribute(v, 3));
138
- g.setAttribute('uv', new THREE.Float32BufferAttribute(u, 2));
139
- g.setIndex(idx);
140
- g.computeVertexNormals();
141
- return g;
142
- }
143
-
144
- function buildCurb(sSign) {
145
- const v = [], c = [], idx = [];
146
- const hw = TRACK_WIDTH / 2;
147
- for (let i = 0; i <= SEG; i++) {
148
- const t = i / SEG;
149
- const { point, side } = frame(t);
150
- const dir = side.clone().multiplyScalar(sSign);
151
- const inner = point.clone().add(dir.clone().multiplyScalar(hw));
152
- const outer = point.clone().add(dir.clone().multiplyScalar(hw + CURB_W));
153
- inner.y += 0.03; outer.y += 0.2;
154
- v.push(inner.x, inner.y, inner.z, outer.x, outer.y, outer.z);
155
- const isRed = Math.floor(i / 4) % 2 === 0;
156
- const col = isRed ? [1, 0.15, 0.15] : [1, 1, 1];
157
- c.push(...col, ...col);
158
- // Winding depends on sSign
159
- if (i < SEG) {
160
- const b = i * 2;
161
- if (sSign === -1) {
162
- idx.push(b, b + 2, b + 1, b + 1, b + 2, b + 3);
163
- } else {
164
- idx.push(b, b + 1, b + 2, b + 1, b + 3, b + 2);
165
- }
166
- }
167
- }
168
- const g = new THREE.BufferGeometry();
169
- g.setAttribute('position', new THREE.Float32BufferAttribute(v, 3));
170
- g.setAttribute('color', new THREE.Float32BufferAttribute(c, 3));
171
- g.setIndex(idx);
172
- g.computeVertexNormals();
173
- return g;
174
- }
175
-
176
- // ═══════════════════════════════════════════════════════
177
- // RENDERER & SCENE
178
- // ═══════════════════════════════════════════════════════
179
- const canvas = document.getElementById('game');
180
- const renderer = new THREE.WebGLRenderer({ canvas, antialias: true });
181
- renderer.setSize(window.innerWidth, window.innerHeight);
182
- renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
183
- renderer.shadowMap.enabled = true;
184
- renderer.shadowMap.type = THREE.PCFSoftShadowMap;
185
- renderer.toneMapping = THREE.ACESFilmicToneMapping;
186
- renderer.toneMappingExposure = 1.1;
187
-
188
- const scene = new THREE.Scene();
189
- scene.fog = new THREE.FogExp2(0xe8926a, 0.0025);
190
-
191
- const camera = new THREE.PerspectiveCamera(65, window.innerWidth / window.innerHeight, 0.5, 600);
192
-
193
- // ═══════════════════════════════════════════════════════
194
- // SKY DOME β€” procedural gradient
195
- // ═══════════════════════════════════════════════════════
196
- {
197
- const skyGeo = new THREE.SphereGeometry(400, 32, 32);
198
- const skyMat = new THREE.ShaderMaterial({
199
- side: THREE.BackSide,
200
- depthWrite: false,
201
- uniforms: {
202
- uTop: { value: new THREE.Color(0x1a1a3e) }, // deep indigo top
203
- uMid: { value: new THREE.Color(0xb07088) }, // muted mauve
204
- uBot: { value: new THREE.Color(0xffa860) }, // warm amber horizon
205
- uSun: { value: new THREE.Color(0xffee88) }, // warm sun glow
206
- uSunDir: { value: new THREE.Vector3(0.6, 0.01, 0.3).normalize() }, // sun right at horizon
207
- },
208
- vertexShader: `
209
- varying vec3 vWorldPos;
210
- void main() {
211
- vWorldPos = (modelMatrix * vec4(position, 1.0)).xyz;
212
- gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
213
- }
214
- `,
215
- fragmentShader: `
216
- uniform vec3 uTop, uMid, uBot, uSun;
217
- uniform vec3 uSunDir;
218
- varying vec3 vWorldPos;
219
- void main() {
220
- vec3 dir = normalize(vWorldPos);
221
- float y = dir.y;
222
- // Sky gradient: top β†’ mid β†’ horizon
223
- vec3 col = mix(uMid, uTop, smoothstep(0.1, 0.8, y));
224
- col = mix(uBot, col, smoothstep(-0.02, 0.15, y));
225
- // Sun disc + glow
226
- float sunDot = max(dot(dir, uSunDir), 0.0);
227
- col += uSun * pow(sunDot, 64.0) * 2.0; // sharp disc
228
- col += uSun * pow(sunDot, 6.0) * 0.4; // wide glow
229
- col += vec3(1.0, 0.8, 0.5) * pow(sunDot, 3.0) * 0.15; // warm haze
230
-
231
- gl_FragColor = vec4(col, 1.0);
232
- }
233
- `,
234
- });
235
- scene.add(new THREE.Mesh(skyGeo, skyMat));
236
- }
237
-
238
- // ═══════════════════════════════════════════════════════
239
- // LIGHTS β€” warm sun + cool ambient
240
- // ═══════════════════════════════════════════════════════
241
- scene.add(new THREE.AmbientLight(0x999088, 0.45));
242
- scene.add(new THREE.HemisphereLight(0xffccaa, 0x443322, 0.3));
243
-
244
- const sun = new THREE.DirectionalLight(0xffe8cc, 1.4);
245
- sun.position.set(120, 6, 50);
246
- sun.castShadow = true;
247
- sun.shadow.mapSize.set(2048, 2048);
248
- sun.shadow.bias = -0.0005;
249
- const sc = sun.shadow.camera;
250
- sc.left = sc.bottom = -130;
251
- sc.right = sc.top = 130;
252
- sc.near = 1; sc.far = 300;
253
- scene.add(sun);
254
- scene.add(sun.target);
255
-
256
- // Subtle fill light from opposite side
257
- const fill = new THREE.DirectionalLight(0x8877aa, 0.2);
258
- fill.position.set(-40, 30, -60);
259
- scene.add(fill);
260
-
261
- // ═══════════════════════════════════════════════════════
262
- // GROUND β€” textured with procedural noise
263
- // ═══════════════════════════════════════════════════════
264
- {
265
- // Create procedural grass texture via canvas
266
- const texSize = 512;
267
- const texCanvas = document.createElement('canvas');
268
- texCanvas.width = texCanvas.height = texSize;
269
- const ctx = texCanvas.getContext('2d');
270
- // Base green
271
- ctx.fillStyle = '#4a7a3a';
272
- ctx.fillRect(0, 0, texSize, texSize);
273
- // Add noise variation
274
- const imgData = ctx.getImageData(0, 0, texSize, texSize);
275
- const d = imgData.data;
276
- for (let i = 0; i < d.length; i += 4) {
277
- const n = (Math.random() - 0.5) * 30;
278
- d[i] = Math.max(0, Math.min(255, d[i] + n * 0.6)); // R
279
- d[i+1] = Math.max(0, Math.min(255, d[i+1] + n)); // G
280
- d[i+2] = Math.max(0, Math.min(255, d[i+2] + n * 0.4)); // B
281
- }
282
- // Darker patches (flowers/shadows)
283
- for (let i = 0; i < 600; i++) {
284
- const x = Math.random() * texSize;
285
- const y = Math.random() * texSize;
286
- const r = 2 + Math.random() * 4;
287
- ctx.fillStyle = Math.random() > 0.7 ? '#3d6a2a' : '#5a8f40';
288
- ctx.beginPath();
289
- ctx.arc(x, y, r, 0, Math.PI * 2);
290
- ctx.fill();
291
- }
292
- // Tiny flower dots
293
- for (let i = 0; i < 60; i++) {
294
- const x = Math.random() * texSize;
295
- const y = Math.random() * texSize;
296
- ctx.fillStyle = ['#ff8866', '#ffcc44', '#ffaa77', '#cc7744'][Math.floor(Math.random() * 4)];
297
- ctx.beginPath();
298
- ctx.arc(x, y, 1.5, 0, Math.PI * 2);
299
- ctx.fill();
300
- }
301
- ctx.putImageData(imgData, 0, 0);
302
-
303
- const grassTex = new THREE.CanvasTexture(texCanvas);
304
- grassTex.wrapS = grassTex.wrapT = THREE.RepeatWrapping;
305
- grassTex.repeat.set(40, 40);
306
- grassTex.anisotropy = renderer.capabilities.getMaxAnisotropy();
307
-
308
- const ground = new THREE.Mesh(
309
- new THREE.PlaneGeometry(800, 800),
310
- new THREE.MeshLambertMaterial({ map: grassTex })
311
- );
312
- ground.rotation.x = -Math.PI / 2;
313
- ground.position.y = -0.1;
314
- ground.receiveShadow = true;
315
- scene.add(ground);
316
- }
317
-
318
- // ═══════════════════════════════════════════════════════
319
- // TRACK MESHES
320
- // ═══════════════════════════════════════════════════════
321
- // Road surface β€” warm brown dirt track (Wind Waker style)
322
- {
323
- const texSize = 256;
324
- const texCanvas = document.createElement('canvas');
325
- texCanvas.width = texCanvas.height = texSize;
326
- const ctx = texCanvas.getContext('2d');
327
- // Dark asphalt β€” goudron style
328
- ctx.fillStyle = '#4a4a4a';
329
- ctx.fillRect(0, 0, texSize, texSize);
330
- // Grain noise
331
- const imgData = ctx.getImageData(0, 0, texSize, texSize);
332
- const d = imgData.data;
333
- for (let i = 0; i < d.length; i += 4) {
334
- const n = (Math.random() - 0.5) * 25;
335
- d[i] = Math.max(0, Math.min(255, d[i] + n * 1.1));
336
- d[i+1] = Math.max(0, Math.min(255, d[i+1] + n * 0.8));
337
- d[i+2] = Math.max(0, Math.min(255, d[i+2] + n * 0.5));
338
- }
339
- ctx.putImageData(imgData, 0, 0);
340
- // Darker packed-earth patches (tire ruts)
341
- for (let j = 0; j < 15; j++) {
342
- const x = Math.random() * texSize;
343
- const y = Math.random() * texSize;
344
- ctx.fillStyle = '#3a3a3a';
345
- ctx.beginPath();
346
- ctx.ellipse(x, y, 8 + Math.random() * 15, 3 + Math.random() * 6, Math.random() * Math.PI, 0, Math.PI * 2);
347
- ctx.fill();
348
- }
349
- // Lighter sandy spots
350
- for (let j = 0; j < 10; j++) {
351
- const x = Math.random() * texSize;
352
- const y = Math.random() * texSize;
353
- ctx.fillStyle = '#5a5a5a';
354
- ctx.beginPath();
355
- ctx.ellipse(x, y, 4 + Math.random() * 8, 3 + Math.random() * 5, Math.random() * Math.PI, 0, Math.PI * 2);
356
- ctx.fill();
357
- }
358
- // Small pebbles
359
- for (let j = 0; j < 30; j++) {
360
- const x = Math.random() * texSize;
361
- const y = Math.random() * texSize;
362
- ctx.fillStyle = '#666666';
363
- ctx.beginPath();
364
- ctx.arc(x, y, 1 + Math.random() * 2, 0, Math.PI * 2);
365
- ctx.fill();
366
- }
367
-
368
- const roadTex = new THREE.CanvasTexture(texCanvas);
369
- roadTex.wrapS = roadTex.wrapT = THREE.RepeatWrapping;
370
- roadTex.repeat.set(2, 25);
371
- roadTex.anisotropy = renderer.capabilities.getMaxAnisotropy();
372
-
373
- const road = new THREE.Mesh(buildRoad(), new THREE.MeshLambertMaterial({ map: roadTex }));
374
- road.receiveShadow = true;
375
- scene.add(road);
376
- }
377
-
378
- // Dirt runoff strips β€” brown-to-green transition (Wind Waker style)
379
- {
380
- const texSize = 128;
381
- const texCanvas = document.createElement('canvas');
382
- texCanvas.width = texSize;
383
- texCanvas.height = texSize;
384
- const ctx = texCanvas.getContext('2d');
385
- // Create gradient: left (near track) = dirt, right (far) = grass
386
- for (let y = 0; y < texSize; y++) {
387
- for (let x = 0; x < texSize; x++) {
388
- const t = x / texSize; // 0=dirt edge, 1=grass edge
389
- // Mix: packed brown β†’ loose dirt β†’ grass
390
- let r, g, b;
391
- if (t < 0.4) {
392
- // Dark asphalt edge (close to track)
393
- const f = t / 0.4;
394
- r = 74 - f * 10 + (Math.random() - 0.5) * 15;
395
- g = 74 + f * 15 + (Math.random() - 0.5) * 15;
396
- b = 74 + f * 5 + (Math.random() - 0.5) * 10;
397
- } else if (t < 0.7) {
398
- // Gravel / transition
399
- const f = (t - 0.4) / 0.3;
400
- r = 64 + f * 20 + (Math.random() - 0.5) * 18;
401
- g = 89 + f * 30 + (Math.random() - 0.5) * 18;
402
- b = 79 + f * 10 + (Math.random() - 0.5) * 10;
403
- } else {
404
- // Grass blend
405
- const f = (t - 0.7) / 0.3;
406
- r = 84 - f * 30 + (Math.random() - 0.5) * 25;
407
- g = 119 + f * 20 + (Math.random() - 0.5) * 20;
408
- b = 89 - f * 30 + (Math.random() - 0.5) * 10;
409
- }
410
- ctx.fillStyle = `rgb(${Math.max(0,Math.min(255,r|0))},${Math.max(0,Math.min(255,g|0))},${Math.max(0,Math.min(255,b|0))})`;
411
- ctx.fillRect(x, y, 1, 1);
412
- }
413
- }
414
- // Sprinkle pebbles in dirt area
415
- for (let j = 0; j < 40; j++) {
416
- const x = Math.random() * texSize * 0.5;
417
- const y = Math.random() * texSize;
418
- ctx.fillStyle = '#666666';
419
- ctx.beginPath();
420
- ctx.arc(x, y, 1 + Math.random() * 1.5, 0, Math.PI * 2);
421
- ctx.fill();
422
- }
423
- // Small grass tufts at transition
424
- for (let j = 0; j < 20; j++) {
425
- const x = texSize * 0.5 + Math.random() * texSize * 0.4;
426
- const y = Math.random() * texSize;
427
- ctx.fillStyle = '#3a8f3a';
428
- ctx.beginPath();
429
- ctx.ellipse(x, y, 2 + Math.random() * 3, 1 + Math.random() * 1, Math.random() * Math.PI, 0, Math.PI * 2);
430
- ctx.fill();
431
- }
432
-
433
- const dirtTex = new THREE.CanvasTexture(texCanvas);
434
- dirtTex.wrapS = dirtTex.wrapT = THREE.RepeatWrapping;
435
- dirtTex.repeat.set(1, 50);
436
- dirtTex.anisotropy = renderer.capabilities.getMaxAnisotropy();
437
-
438
- const dirtMat = new THREE.MeshLambertMaterial({ map: dirtTex });
439
- const dirtL = new THREE.Mesh(buildDirtStrip(-1), dirtMat);
440
- dirtL.receiveShadow = true;
441
- scene.add(dirtL);
442
- const dirtR = new THREE.Mesh(buildDirtStrip(1), dirtMat);
443
- dirtR.receiveShadow = true;
444
- scene.add(dirtR);
445
- }
446
-
447
- // Curbs β€” with 3D raised profile
448
- const curbMat = new THREE.MeshLambertMaterial({ vertexColors: true });
449
- const curbL = new THREE.Mesh(buildCurb(-1), curbMat);
450
- curbL.castShadow = true;
451
- scene.add(curbL);
452
- const curbR = new THREE.Mesh(buildCurb(1), curbMat);
453
- curbR.castShadow = true;
454
- scene.add(curbR);
455
-
456
- // Center-line dashes
457
- const dashGeo = new THREE.BoxGeometry(0.25, 0.02, 3);
458
- const dashMat = new THREE.MeshLambertMaterial({ color: 0xffffcc });
459
- for (let i = 0; i < SEG; i += 10) {
460
- const t = i / SEG;
461
- const { point, tangent } = frame(t);
462
- const d = new THREE.Mesh(dashGeo, dashMat);
463
- d.position.copy(point);
464
- d.position.y += 0.04;
465
- d.lookAt(point.clone().add(tangent));
466
- scene.add(d);
467
- }
468
-
469
- // Start/finish checkerboard line β€” with archway
470
- {
471
- const { point, tangent, side } = frame(0);
472
- const angle = Math.atan2(tangent.x, tangent.z);
473
- // White base
474
- const sl = new THREE.Mesh(
475
- new THREE.BoxGeometry(TRACK_WIDTH, 0.05, 2),
476
- new THREE.MeshLambertMaterial({ color: 0xffffff })
477
- );
478
- sl.position.copy(point);
479
- sl.position.y += 0.04;
480
- sl.rotation.y = angle;
481
- scene.add(sl);
482
- // Black checker squares
483
- const n = 8;
484
- const sw = TRACK_WIDTH / n;
485
- for (let i = 0; i < n; i++) {
486
- for (let j = 0; j < 2; j++) {
487
- if ((i + j) % 2 === 0) continue;
488
- const sq = new THREE.Mesh(
489
- new THREE.BoxGeometry(sw, 0.06, 1),
490
- new THREE.MeshLambertMaterial({ color: 0x111111 })
491
- );
492
- const pos = point.clone()
493
- .add(side.clone().multiplyScalar((i - n / 2 + 0.5) * sw))
494
- .add(tangent.clone().multiplyScalar((j - 0.5) * 1));
495
- sq.position.copy(pos);
496
- sq.position.y += 0.05;
497
- sq.rotation.y = angle;
498
- scene.add(sq);
499
- }
500
- }
501
-
502
- // ══ START/FINISH ARCHWAY ══
503
- const archMat = new THREE.MeshPhongMaterial({ color: 0xdd2222, shininess: 60 });
504
- const poleMat = new THREE.MeshPhongMaterial({ color: 0xeeeeee, shininess: 40 });
505
- // Poles
506
- for (const s2 of [-1, 1]) {
507
- const pole = new THREE.Mesh(
508
- new THREE.CylinderGeometry(0.3, 0.35, 10, 12),
509
- poleMat
510
- );
511
- const pPos = point.clone().add(side.clone().multiplyScalar(s2 * (TRACK_WIDTH / 2 + 1)));
512
- pole.position.copy(pPos);
513
- pole.position.y += 5;
514
- pole.castShadow = true;
515
- scene.add(pole);
516
- }
517
- // Crossbar
518
- const crossbar = new THREE.Mesh(
519
- new THREE.BoxGeometry(TRACK_WIDTH + 3, 1.2, 1.2),
520
- archMat
521
- );
522
- crossbar.position.copy(point);
523
- crossbar.position.y += 9.5;
524
- crossbar.rotation.y = angle;
525
- crossbar.castShadow = true;
526
- scene.add(crossbar);
527
- // "START" banner on crossbar (using a plane)
528
- const bannerGeo = new THREE.PlaneGeometry(TRACK_WIDTH * 0.8, 0.9);
529
- const bannerCanvas = document.createElement('canvas');
530
- bannerCanvas.width = 512; bannerCanvas.height = 64;
531
- const bctx = bannerCanvas.getContext('2d');
532
- bctx.fillStyle = '#dd2222';
533
- bctx.fillRect(0, 0, 512, 64);
534
- bctx.fillStyle = '#ffffff';
535
- bctx.font = 'bold 48px Arial';
536
- bctx.textAlign = 'center';
537
- bctx.textBaseline = 'middle';
538
- bctx.fillText('START / FINISH', 256, 32);
539
- // Checkerboard pattern on banner
540
- for (let bx = 0; bx < 32; bx++) {
541
- for (let by = 0; by < 4; by++) {
542
- if ((bx + by) % 2 === 0) {
543
- bctx.fillStyle = '#111';
544
- bctx.fillRect(bx * 16, by * 16, 16, 16);
545
- }
546
- }
547
- }
548
- bctx.fillStyle = '#fff';
549
- bctx.font = 'bold 40px Arial';
550
- bctx.textAlign = 'center';
551
- bctx.textBaseline = 'middle';
552
- bctx.fillText('START / FINISH', 256, 32);
553
- const bannerTex = new THREE.CanvasTexture(bannerCanvas);
554
- const banner = new THREE.Mesh(bannerGeo, new THREE.MeshBasicMaterial({ map: bannerTex }));
555
- banner.position.copy(point);
556
- banner.position.y += 9.5;
557
- banner.rotation.y = angle;
558
- scene.add(banner);
559
- // Back side of banner
560
- const bannerBack = banner.clone();
561
- bannerBack.rotation.y = angle + Math.PI;
562
- scene.add(bannerBack);
563
- }
564
-
565
- // Barrier fence β€” improved with concrete barriers
566
- for (let i = 0; i < SEG; i += 8) {
567
- const t = i / SEG;
568
- const { point, side } = frame(t);
569
- const dist = TRACK_WIDTH / 2 + CURB_W + 0.3;
570
- for (const s of [-1, 1]) {
571
- const pos = point.clone().add(side.clone().multiplyScalar(s * dist));
572
- // Concrete barrier segment
573
- const barrier = new THREE.Mesh(
574
- new THREE.BoxGeometry(1.0, 0.8, 0.5),
575
- new THREE.MeshLambertMaterial({ color: 0xcccccc })
576
- );
577
- barrier.position.copy(pos);
578
- barrier.position.y += 0.4;
579
- barrier.castShadow = true;
580
- barrier.receiveShadow = true;
581
- scene.add(barrier);
582
- // Red stripe on barrier
583
- const stripe = new THREE.Mesh(
584
- new THREE.BoxGeometry(1.02, 0.15, 0.52),
585
- new THREE.MeshLambertMaterial({ color: 0xdd2222 })
586
- );
587
- stripe.position.copy(pos);
588
- stripe.position.y += 0.55;
589
- scene.add(stripe);
590
- }
591
- }
592
-
593
- // ═══════════════════════════════════════════════════════
594
- // SCENERY β€” dense forest + trackside objects
595
- // ═══════════════════════════════════════════════════════
596
-
597
- // Shared materials (reuse to save draw calls)
598
- const TRUNK_COLORS = [0x5a2d0c, 0x6b3a1f, 0x7a4a2a, 0x4a2510];
599
- const LEAF_COLORS = [0x1a6b1a, 0x228B22, 0x2d9f2d, 0x1a7a1a, 0x0f5f0f, 0x3aaf3a];
600
- const AUTUMN_COLORS = [0xcc6600, 0xdd9900, 0xaa3300, 0xee7722];
601
-
602
- function pick(arr) { return arr[Math.floor(Math.random() * arr.length)]; }
603
- function rand(a, b) { return a + Math.random() * (b - a); }
604
-
605
- // ── Pine tree (natural layered look) ──
606
- function createPine(x, z, s = 1) {
607
- const g = new THREE.Group();
608
- const trunk = new THREE.Mesh(
609
- new THREE.CylinderGeometry(0.1 * s, 0.25 * s, 3 * s, 6),
610
- new THREE.MeshLambertMaterial({ color: pick(TRUNK_COLORS) })
611
- );
612
- trunk.position.y = 1.5 * s;
613
- trunk.castShadow = true;
614
- g.add(trunk);
615
- // Layered cones with wide overlap
616
- // Each layer: wide base cone + a smaller one peeking out top
617
- const layers = [
618
- { baseR: 2.8, topR: 0.0, h: 3.2, y: 3.5 },
619
- { baseR: 2.3, topR: 0.0, h: 2.6, y: 5.6 },
620
- { baseR: 1.8, topR: 0.0, h: 2.2, y: 7.4 },
621
- { baseR: 1.2, topR: 0.0, h: 1.8, y: 8.8 },
622
- ];
623
- const leafCol = pick(LEAF_COLORS);
624
- for (const l of layers) {
625
- // Main cone
626
- const cone = new THREE.Mesh(
627
- new THREE.ConeGeometry(l.baseR * s, l.h * s, 8),
628
- new THREE.MeshLambertMaterial({ color: leafCol })
629
- );
630
- cone.position.y = l.y * s;
631
- cone.castShadow = true;
632
- g.add(cone);
633
- // Slightly darker underside ring (visual depth)
634
- const ring = new THREE.Mesh(
635
- new THREE.CylinderGeometry(l.baseR * 0.85 * s, l.baseR * s, l.h * 0.15 * s, 8, 1, true),
636
- new THREE.MeshLambertMaterial({ color: 0x0f4f0f, side: THREE.DoubleSide })
637
- );
638
- ring.position.y = (l.y - l.h * 0.4) * s;
639
- g.add(ring);
640
- }
641
- g.position.set(x, 0, z);
642
- return g;
643
- }
644
-
645
- // ── Oak tree (broad, round canopy) ──
646
- function createOak(x, z, s = 1) {
647
- const g = new THREE.Group();
648
- const trunk = new THREE.Mesh(
649
- new THREE.CylinderGeometry(0.25 * s, 0.5 * s, 4 * s, 7),
650
- new THREE.MeshLambertMaterial({ color: pick(TRUNK_COLORS) })
651
- );
652
- trunk.position.y = 2 * s;
653
- trunk.castShadow = true;
654
- g.add(trunk);
655
- // Big round canopy (cluster of spheres)
656
- const col = pick(LEAF_COLORS);
657
- const positions = [
658
- [0, 5.5, 0, 3.2], [1.5, 5.0, 0.8, 2.2], [-1.2, 5.2, -1, 2.4],
659
- [0.5, 6.2, -0.5, 2.0], [-0.8, 4.8, 1.2, 2.0],
660
- ];
661
- for (const [px, py, pz, pr] of positions) {
662
- const sphere = new THREE.Mesh(
663
- new THREE.SphereGeometry(pr * s, 7, 5),
664
- new THREE.MeshLambertMaterial({ color: col })
665
- );
666
- sphere.position.set(px * s, py * s, pz * s);
667
- sphere.castShadow = true;
668
- g.add(sphere);
669
- }
670
- g.position.set(x, 0, z);
671
- return g;
672
- }
673
-
674
- // ── Birch tree (white trunk, small canopy) ──
675
- function createBirch(x, z, s = 1) {
676
- const g = new THREE.Group();
677
- const trunk = new THREE.Mesh(
678
- new THREE.CylinderGeometry(0.12 * s, 0.18 * s, 5 * s, 6),
679
- new THREE.MeshLambertMaterial({ color: 0xddd8c8 })
680
- );
681
- trunk.position.y = 2.5 * s;
682
- trunk.castShadow = true;
683
- g.add(trunk);
684
- // Dark marks on trunk
685
- for (let i = 0; i < 4; i++) {
686
- const mark = new THREE.Mesh(
687
- new THREE.BoxGeometry(0.26 * s, 0.08 * s, 0.04 * s),
688
- new THREE.MeshLambertMaterial({ color: 0x333333 })
689
- );
690
- mark.position.set(0, (1 + i * 1.1) * s, 0.09 * s);
691
- g.add(mark);
692
- }
693
- // Small canopy
694
- const canopy = new THREE.Mesh(
695
- new THREE.SphereGeometry(2 * s, 7, 5),
696
- new THREE.MeshLambertMaterial({ color: 0x88cc44 })
697
- );
698
- canopy.position.y = 6 * s;
699
- canopy.scale.y = 0.7;
700
- canopy.castShadow = true;
701
- g.add(canopy);
702
- g.position.set(x, 0, z);
703
- return g;
704
- }
705
-
706
- // ── Autumn tree (orange/red foliage) ──
707
- function createAutumnTree(x, z, s = 1) {
708
- const g = new THREE.Group();
709
- const trunk = new THREE.Mesh(
710
- new THREE.CylinderGeometry(0.2 * s, 0.4 * s, 3.5 * s, 6),
711
- new THREE.MeshLambertMaterial({ color: 0x4a2510 })
712
- );
713
- trunk.position.y = 1.75 * s;
714
- trunk.castShadow = true;
715
- g.add(trunk);
716
- // Layered cones with autumn colors
717
- for (let i = 0; i < 3; i++) {
718
- const r = (2.5 - i * 0.6) * s;
719
- const cone = new THREE.Mesh(
720
- new THREE.ConeGeometry(Math.max(0.3, r), 2.5 * s, 7),
721
- new THREE.MeshLambertMaterial({ color: pick(AUTUMN_COLORS) })
722
- );
723
- cone.position.y = (4.0 + i * 1.6) * s;
724
- cone.castShadow = true;
725
- g.add(cone);
726
- }
727
- g.position.set(x, 0, z);
728
- return g;
729
- }
730
-
731
- // ── Bush (low dense sphere) ──
732
- function createBush(x, z, s = 1) {
733
- const g = new THREE.Group();
734
- const bush = new THREE.Mesh(
735
- new THREE.SphereGeometry(1.2 * s, 7, 5),
736
- new THREE.MeshLambertMaterial({ color: pick(LEAF_COLORS) })
737
- );
738
- bush.position.y = 0.8 * s;
739
- bush.scale.y = 0.6;
740
- bush.castShadow = true;
741
- g.add(bush);
742
- g.position.set(x, 0, z);
743
- return g;
744
- }
745
-
746
- // ── Flower patch ──
747
- function createFlowers(x, z) {
748
- const g = new THREE.Group();
749
- const colors = [0xff8866, 0xffcc44, 0xffaa77, 0xff5544, 0xffddaa, 0xcc7744];
750
- for (let i = 0; i < 12; i++) {
751
- const flower = new THREE.Mesh(
752
- new THREE.SphereGeometry(0.12, 5, 4),
753
- new THREE.MeshLambertMaterial({ color: pick(colors) })
754
- );
755
- flower.position.set(rand(-1.5, 1.5), 0.15, rand(-1.5, 1.5));
756
- g.add(flower);
757
- // Stem
758
- const stem = new THREE.Mesh(
759
- new THREE.CylinderGeometry(0.02, 0.02, 0.3, 4),
760
- new THREE.MeshLambertMaterial({ color: 0x2d7722 })
761
- );
762
- stem.position.set(flower.position.x, 0.05, flower.position.z);
763
- g.add(stem);
764
- }
765
- g.position.set(x, 0, z);
766
- return g;
767
- }
768
-
769
- // ── Rock ──
770
- function createRock(x, z, s = 1) {
771
- const g = new THREE.Group();
772
- const rock = new THREE.Mesh(
773
- new THREE.DodecahedronGeometry(1.2 * s, 0),
774
- new THREE.MeshLambertMaterial({ color: pick([0x888888, 0x777766, 0x999988, 0x666655]) })
775
- );
776
- rock.position.y = 0.5 * s;
777
- rock.scale.set(rand(0.7, 1.3), rand(0.4, 0.8), rand(0.7, 1.3));
778
- rock.rotation.y = rand(0, Math.PI * 2);
779
- rock.castShadow = true;
780
- g.add(rock);
781
- g.position.set(x, 0, z);
782
- return g;
783
- }
784
-
785
- // ── Mushroom ──
786
- function createMushroom(x, z) {
787
- const g = new THREE.Group();
788
- // Stem
789
- const stem = new THREE.Mesh(
790
- new THREE.CylinderGeometry(0.15, 0.2, 0.6, 6),
791
- new THREE.MeshLambertMaterial({ color: 0xeeddcc })
792
- );
793
- stem.position.y = 0.3;
794
- g.add(stem);
795
- // Cap
796
- const cap = new THREE.Mesh(
797
- new THREE.SphereGeometry(0.45, 8, 5, 0, Math.PI * 2, 0, Math.PI / 2),
798
- new THREE.MeshLambertMaterial({ color: 0xcc2222 })
799
- );
800
- cap.position.y = 0.55;
801
- g.add(cap);
802
- // White dots on cap
803
- for (let i = 0; i < 5; i++) {
804
- const dot = new THREE.Mesh(
805
- new THREE.SphereGeometry(0.08, 5, 4),
806
- new THREE.MeshLambertMaterial({ color: 0xffffff })
807
- );
808
- const angle = rand(0, Math.PI * 2);
809
- const r = rand(0.1, 0.3);
810
- dot.position.set(Math.cos(angle) * r, 0.7, Math.sin(angle) * r);
811
- g.add(dot);
812
- }
813
- g.position.set(x, 0, z);
814
- return g;
815
- }
816
-
817
- // ── Wooden sign ──
818
- function createSign(x, z, rot) {
819
- const g = new THREE.Group();
820
- // Post
821
- const post = new THREE.Mesh(
822
- new THREE.CylinderGeometry(0.12, 0.15, 2.5, 6),
823
- new THREE.MeshLambertMaterial({ color: 0x6b3a1f })
824
- );
825
- post.position.y = 1.25;
826
- g.add(post);
827
- // Board
828
- const board = new THREE.Mesh(
829
- new THREE.BoxGeometry(1.8, 0.8, 0.12),
830
- new THREE.MeshLambertMaterial({ color: 0x8b5a2b })
831
- );
832
- board.position.y = 2.2;
833
- g.add(board);
834
- g.position.set(x, 0, z);
835
- g.rotation.y = rot;
836
- return g;
837
- }
838
-
839
- // ── Tire stack ──
840
- function createTireStack(x, z) {
841
- const g = new THREE.Group();
842
- const tireMat = new THREE.MeshLambertMaterial({ color: 0x222222 });
843
- for (let i = 0; i < 3; i++) {
844
- const tire = new THREE.Mesh(
845
- new THREE.TorusGeometry(0.6, 0.25, 8, 12),
846
- tireMat
847
- );
848
- tire.position.y = 0.25 + i * 0.45;
849
- tire.rotation.x = Math.PI / 2;
850
- g.add(tire);
851
- }
852
- g.position.set(x, 0, z);
853
- return g;
854
- }
855
-
856
- // ── Oil drum ──
857
- function createDrum(x, z) {
858
- const g = new THREE.Group();
859
- const drum = new THREE.Mesh(
860
- new THREE.CylinderGeometry(0.45, 0.45, 1.2, 10),
861
- new THREE.MeshPhongMaterial({ color: 0x2244aa, shininess: 40 })
862
- );
863
- drum.position.y = 0.6;
864
- drum.castShadow = true;
865
- g.add(drum);
866
- // Red stripe
867
- const stripe = new THREE.Mesh(
868
- new THREE.CylinderGeometry(0.46, 0.46, 0.15, 10),
869
- new THREE.MeshPhongMaterial({ color: 0xcc2222, shininess: 40 })
870
- );
871
- stripe.position.y = 0.4;
872
- g.add(stripe);
873
- g.position.set(x, 0, z);
874
- return g;
875
- }
876
-
877
- // ── Treasure chest (hidden in forest) ──
878
- function createChest(x, z) {
879
- const g = new THREE.Group();
880
- // Box
881
- const box = new THREE.Mesh(
882
- new THREE.BoxGeometry(1.2, 0.6, 0.8),
883
- new THREE.MeshPhongMaterial({ color: 0x8b5a2b, shininess: 20 })
884
- );
885
- box.position.y = 0.3;
886
- box.castShadow = true;
887
- g.add(box);
888
- // Lid
889
- const lid = new THREE.Mesh(
890
- new THREE.BoxGeometry(1.22, 0.15, 0.82),
891
- new THREE.MeshPhongMaterial({ color: 0x7a4a1a, shininess: 20 })
892
- );
893
- lid.position.y = 0.65;
894
- lid.rotation.z = 0.3;
895
- g.add(lid);
896
- // Gold trim
897
- const trim = new THREE.Mesh(
898
- new THREE.BoxGeometry(1.24, 0.05, 0.84),
899
- new THREE.MeshPhongMaterial({ color: 0xddaa00, shininess: 80 })
900
- );
901
- trim.position.y = 0.6;
902
- g.add(trim);
903
- g.position.set(x, 0, z);
904
- g.rotation.y = rand(0, Math.PI * 2);
905
- return g;
906
- }
907
-
908
- // ── Lamp post ──
909
- function createLampPost(x, z) {
910
- const g = new THREE.Group();
911
- // Pole
912
- const pole = new THREE.Mesh(
913
- new THREE.CylinderGeometry(0.08, 0.1, 5, 6),
914
- new THREE.MeshPhongMaterial({ color: 0x444444, shininess: 60 })
915
- );
916
- pole.position.y = 2.5;
917
- g.add(pole);
918
- // Arm
919
- const arm = new THREE.Mesh(
920
- new THREE.BoxGeometry(0.6, 0.06, 0.06),
921
- new THREE.MeshPhongMaterial({ color: 0x444444, shininess: 60 })
922
- );
923
- arm.position.set(0.3, 4.8, 0);
924
- g.add(arm);
925
- // Light
926
- const light = new THREE.Mesh(
927
- new THREE.SphereGeometry(0.2, 8, 6),
928
- new THREE.MeshBasicMaterial({ color: 0xffcc77 })
929
- );
930
- light.position.set(0.6, 4.6, 0);
931
- g.add(light);
932
- // Small point light
933
- const pl = new THREE.PointLight(0xffaa55, 0.8, 15);
934
- pl.position.set(0.6, 4.6, 0);
935
- g.add(pl);
936
- g.position.set(x, 0, z);
937
- return g;
938
- }
939
-
940
- // ── Wooden fence section ──
941
- function createFence(x, z, length, rot) {
942
- const g = new THREE.Group();
943
- const postMat = new THREE.MeshLambertMaterial({ color: 0x6b3a1f });
944
- const railMat = new THREE.MeshLambertMaterial({ color: 0x8b5a2b });
945
- const posts = Math.ceil(length / 3);
946
- for (let i = 0; i <= posts; i++) {
947
- const post = new THREE.Mesh(
948
- new THREE.CylinderGeometry(0.08, 0.1, 1.2, 5),
949
- postMat
950
- );
951
- post.position.set(i * 3, 0.6, 0);
952
- g.add(post);
953
- }
954
- // Two rails
955
- for (const y of [0.35, 0.85]) {
956
- const rail = new THREE.Mesh(
957
- new THREE.BoxGeometry(length, 0.06, 0.06),
958
- railMat
959
- );
960
- rail.position.set(length / 2, y, 0);
961
- g.add(rail);
962
- }
963
- g.position.set(x, 0, z);
964
- g.rotation.y = rot;
965
- return g;
966
- }
967
-
968
- // ═══════════════════════════════════════════════════════
969
- // PLACE SCENERY β€” dense forest around the track
970
- // ═══════════════════════════════════════════════════════
971
-
972
- // Min distance from track centerline for scenery
973
- // Trees have canopy radius ~3m, track half-width = 8m
974
- // Trees need to be at least 12m from center to keep canopy off road
975
- const MIN_SCENERY_DIST = TRACK_WIDTH / 2 + 4; // 12m β€” canopy clearance from track edge
976
-
977
- // Pre-compute track points for collision checking (dense for accuracy)
978
- const trackCheckPts = [];
979
- for (let i = 0; i < 600; i++) {
980
- trackCheckPts.push(trackCurve.getPointAt(i / 600));
981
- }
982
-
983
- // Check that a position is far enough from ALL track points
984
- function isSafeForScenery(px, pz, minDist) {
985
- for (const tp of trackCheckPts) {
986
- const dx = tp.x - px, dz = tp.z - pz;
987
- if (dx * dx + dz * dz < minDist * minDist) return false;
988
- }
989
- return true;
990
- }
991
-
992
- // Inner ring: trees close to track (just beyond dirt)
993
- for (let i = 0; i < 180; i++) {
994
- const t = i / 180;
995
- const { point, side } = frame(t);
996
- const s2 = Math.random() > 0.5 ? 1 : -1;
997
- const dist = MIN_SCENERY_DIST + Math.random() * 6;
998
- const pos = point.clone().add(side.clone().multiplyScalar(s2 * dist));
999
- if (!isSafeForScenery(pos.x, pos.z, MIN_SCENERY_DIST)) continue;
1000
- const s = rand(0.6, 1.2);
1001
- const treeFn = Math.random() < 0.5 ? createPine :
1002
- Math.random() < 0.6 ? createOak :
1003
- Math.random() < 0.5 ? createBirch : createAutumnTree;
1004
- scene.add(treeFn(pos.x, pos.z, s));
1005
- }
1006
-
1007
- // Mid ring: dense forest 8–30m past dirt edge
1008
- for (let i = 0; i < 350; i++) {
1009
- const t = Math.random();
1010
- const { point, side } = frame(t);
1011
- const s2 = Math.random() > 0.5 ? 1 : -1;
1012
- const dist = MIN_SCENERY_DIST + 8 + Math.random() * 25;
1013
- const pos = point.clone().add(side.clone().multiplyScalar(s2 * dist));
1014
- if (!isSafeForScenery(pos.x, pos.z, MIN_SCENERY_DIST)) continue;
1015
- const s = rand(0.5, 1.4);
1016
- const r = Math.random();
1017
- const treeFn = r < 0.35 ? createPine :
1018
- r < 0.6 ? createOak :
1019
- r < 0.8 ? createBirch : createAutumnTree;
1020
- scene.add(treeFn(pos.x, pos.z, s));
1021
- }
1022
-
1023
- // Outer ring: scattered tall pines far out
1024
- for (let i = 0; i < 300; i++) {
1025
- const t = Math.random();
1026
- const { point, side } = frame(t);
1027
- const s2 = Math.random() > 0.5 ? 1 : -1;
1028
- const dist = MIN_SCENERY_DIST + 30 + Math.random() * 50;
1029
- const pos = point.clone().add(side.clone().multiplyScalar(s2 * dist));
1030
- if (!isSafeForScenery(pos.x, pos.z, MIN_SCENERY_DIST)) continue;
1031
- const s = rand(0.8, 1.6);
1032
- scene.add(createPine(pos.x, pos.z, s));
1033
- }
1034
-
1035
- // Dense bushes just beyond dirt
1036
- for (let i = 0; i < 100; i++) {
1037
- const t = Math.random();
1038
- const { point, side } = frame(t);
1039
- const s2 = Math.random() > 0.5 ? 1 : -1;
1040
- const dist = MIN_SCENERY_DIST + Math.random() * 5;
1041
- const pos = point.clone().add(side.clone().multiplyScalar(s2 * dist));
1042
- if (!isSafeForScenery(pos.x, pos.z, MIN_SCENERY_DIST)) continue;
1043
- scene.add(createBush(pos.x, pos.z, rand(0.4, 1.0)));
1044
- }
1045
-
1046
- // Flower patches
1047
- for (let i = 0; i < 30; i++) {
1048
- const t = Math.random();
1049
- const { point, side } = frame(t);
1050
- const s2 = Math.random() > 0.5 ? 1 : -1;
1051
- const dist = MIN_SCENERY_DIST + 3 + Math.random() * 10;
1052
- const pos = point.clone().add(side.clone().multiplyScalar(s2 * dist));
1053
- if (!isSafeForScenery(pos.x, pos.z, MIN_SCENERY_DIST)) continue;
1054
- scene.add(createFlowers(pos.x, pos.z));
1055
- }
1056
-
1057
- // Rocks scattered around
1058
- for (let i = 0; i < 45; i++) {
1059
- const t = Math.random();
1060
- const { point, side } = frame(t);
1061
- const s2 = Math.random() > 0.5 ? 1 : -1;
1062
- const dist = MIN_SCENERY_DIST + 2 + Math.random() * 20;
1063
- const pos = point.clone().add(side.clone().multiplyScalar(s2 * dist));
1064
- if (!isSafeForScenery(pos.x, pos.z, MIN_SCENERY_DIST - 2)) continue;
1065
- scene.add(createRock(pos.x, pos.z, rand(0.4, 1.2)));
1066
- }
1067
-
1068
- // Mushrooms (hidden in forest)
1069
- for (let i = 0; i < 15; i++) {
1070
- const t = Math.random();
1071
- const { point, side } = frame(t);
1072
- const s2 = Math.random() > 0.5 ? 1 : -1;
1073
- const dist = MIN_SCENERY_DIST + 5 + Math.random() * 25;
1074
- const pos = point.clone().add(side.clone().multiplyScalar(s2 * dist));
1075
- if (!isSafeForScenery(pos.x, pos.z, MIN_SCENERY_DIST)) continue;
1076
- scene.add(createMushroom(pos.x, pos.z));
1077
- }
1078
-
1079
- // ── Trackside objects ──
1080
-
1081
- // Tire stacks at turns
1082
- const turnTs = [0.12, 0.28, 0.45, 0.65, 0.85];
1083
- for (const tt of turnTs) {
1084
- const { point, side } = frame(tt);
1085
- const dist = MIN_SCENERY_DIST - 2; // tire stacks sit on the dirt edge
1086
- for (const s2 of [-1, 1]) {
1087
- const pos = point.clone().add(side.clone().multiplyScalar(s2 * dist));
1088
- if (!isSafeForScenery(pos.x, pos.z, MIN_SCENERY_DIST - 4)) continue;
1089
- scene.add(createTireStack(pos.x, pos.z));
1090
- }
1091
- }
1092
-
1093
- // Oil drums scattered near track
1094
- for (let i = 0; i < 15; i++) {
1095
- const t = rand(0, 1);
1096
- const { point, side } = frame(t);
1097
- const s2 = Math.random() > 0.5 ? 1 : -1;
1098
- const dist = MIN_SCENERY_DIST + Math.random() * 3;
1099
- const pos = point.clone().add(side.clone().multiplyScalar(s2 * dist));
1100
- if (!isSafeForScenery(pos.x, pos.z, MIN_SCENERY_DIST - 2)) continue;
1101
- scene.add(createDrum(pos.x, pos.z));
1102
- }
1103
-
1104
- // Lamp posts along main straight
1105
- for (let i = 0; i < 6; i++) {
1106
- const t = i / 12;
1107
- const { point, side } = frame(t);
1108
- for (const s2 of [-1, 1]) {
1109
- const dist = MIN_SCENERY_DIST + 2;
1110
- const pos = point.clone().add(side.clone().multiplyScalar(s2 * dist));
1111
- if (!isSafeForScenery(pos.x, pos.z, MIN_SCENERY_DIST)) continue;
1112
- scene.add(createLampPost(pos.x, pos.z));
1113
- }
1114
- }
1115
-
1116
- // Wooden signs at key turns
1117
- const signTs = [
1118
- { t: 0.08, rot: 0.5 },
1119
- { t: 0.25, rot: -0.8 },
1120
- { t: 0.45, rot: 1.2 },
1121
- ];
1122
- for (const { t, rot } of signTs) {
1123
- const { point, side } = frame(t);
1124
- const dist = MIN_SCENERY_DIST + 3;
1125
- const s2 = Math.random() > 0.5 ? 1 : -1;
1126
- const pos = point.clone().add(side.clone().multiplyScalar(s2 * dist));
1127
- if (!isSafeForScenery(pos.x, pos.z, MIN_SCENERY_DIST)) continue;
1128
- scene.add(createSign(pos.x, pos.z, rot));
1129
- }
1130
-
1131
- // Treasure chests hidden deep in forest
1132
- for (let i = 0; i < 8; i++) {
1133
- const t = Math.random();
1134
- const { point, side } = frame(t);
1135
- const s2 = Math.random() > 0.5 ? 1 : -1;
1136
- const dist = MIN_SCENERY_DIST + 15 + Math.random() * 20;
1137
- const pos = point.clone().add(side.clone().multiplyScalar(s2 * dist));
1138
- if (!isSafeForScenery(pos.x, pos.z, MIN_SCENERY_DIST)) continue;
1139
- scene.add(createChest(pos.x, pos.z));
1140
- }
1141
-
1142
-
1143
- // ═══════════════════════════════════════════════════════
1144
- // CARS β€” improved with Phong + metallic look
1145
- // ═══════════════════════════════════════════════════════
1146
- function createCar(color) {
1147
- const g = new THREE.Group();
1148
- // Body β€” rounded box approximation using a slightly larger main body + fenders
1149
- const bodyMat = new THREE.MeshPhongMaterial({
1150
- color,
1151
- shininess: 100,
1152
- specular: 0x444444,
1153
- });
1154
- const body = new THREE.Mesh(
1155
- new THREE.BoxGeometry(2, 0.7, 4),
1156
- bodyMat
1157
- );
1158
- body.position.y = 0.6;
1159
- body.castShadow = true;
1160
- g.add(body);
1161
- // Front bumper (rounded)
1162
- const frontBumper = new THREE.Mesh(
1163
- new THREE.BoxGeometry(2.1, 0.35, 0.5),
1164
- new THREE.MeshPhongMaterial({ color: 0x222222, shininess: 60 })
1165
- );
1166
- frontBumper.position.set(0, 0.42, 2.1);
1167
- g.add(frontBumper);
1168
- // Rear bumper
1169
- const rearBumper = new THREE.Mesh(
1170
- new THREE.BoxGeometry(2.1, 0.35, 0.5),
1171
- new THREE.MeshPhongMaterial({ color: 0x222222, shininess: 60 })
1172
- );
1173
- rearBumper.position.set(0, 0.42, -2.1);
1174
- g.add(rearBumper);
1175
- // Cabin β€” dark windshield
1176
- const cabin = new THREE.Mesh(
1177
- new THREE.BoxGeometry(1.6, 0.5, 2),
1178
- new THREE.MeshPhongMaterial({ color: 0x111122, shininess: 200, specular: 0x888888, transparent: true, opacity: 0.85 })
1179
- );
1180
- cabin.position.set(0, 1.15, -0.3);
1181
- cabin.castShadow = true;
1182
- g.add(cabin);
1183
- // Spoiler
1184
- const spoiler = new THREE.Mesh(
1185
- new THREE.BoxGeometry(2, 0.08, 0.5),
1186
- bodyMat
1187
- );
1188
- spoiler.position.set(0, 1.1, -1.8);
1189
- g.add(spoiler);
1190
- const spoilerPosts = new THREE.Mesh(
1191
- new THREE.BoxGeometry(0.1, 0.3, 0.1),
1192
- new THREE.MeshPhongMaterial({ color: 0x222222, shininess: 60 })
1193
- );
1194
- for (const x of [-0.8, 0.8]) {
1195
- const sp = spoilerPosts.clone();
1196
- sp.position.set(x, 0.95, -1.8);
1197
- g.add(sp);
1198
- }
1199
- // Wheels β€” with rims
1200
- const wg = new THREE.CylinderGeometry(0.35, 0.35, 0.3, 12);
1201
- const wm = new THREE.MeshPhongMaterial({ color: 0x111111, shininess: 30 });
1202
- const rimGeo = new THREE.CylinderGeometry(0.18, 0.18, 0.32, 8);
1203
- const rimMat = new THREE.MeshPhongMaterial({ color: 0xcccccc, shininess: 150 });
1204
- for (const [x, z] of [[-1.1, 1.3], [1.1, 1.3], [-1.1, -1.3], [1.1, -1.3]]) {
1205
- const w = new THREE.Mesh(wg, wm);
1206
- w.rotation.z = Math.PI / 2;
1207
- w.position.set(x, 0.35, z);
1208
- g.add(w);
1209
- const rim = new THREE.Mesh(rimGeo, rimMat);
1210
- rim.rotation.z = Math.PI / 2;
1211
- rim.position.set(x, 0.35, z);
1212
- g.add(rim);
1213
- }
1214
- // Headlights β€” glowing
1215
- const hlg = new THREE.SphereGeometry(0.15, 8, 8);
1216
- const hlm = new THREE.MeshBasicMaterial({ color: 0xffffcc });
1217
- for (const x of [-0.7, 0.7]) {
1218
- const hl = new THREE.Mesh(hlg, hlm);
1219
- hl.position.set(x, 0.6, 2);
1220
- g.add(hl);
1221
- }
1222
- // Tail lights β€” glowing red
1223
- const tlm = new THREE.MeshBasicMaterial({ color: 0xff0000 });
1224
- for (const x of [-0.7, 0.7]) {
1225
- const tl = new THREE.Mesh(hlg, tlm);
1226
- tl.position.set(x, 0.6, -2);
1227
- g.add(tl);
1228
- }
1229
- // Racing number circle on sides
1230
- const numGeo = new THREE.CircleGeometry(0.35, 16);
1231
- const numMat = new THREE.MeshBasicMaterial({ color: 0xffffff });
1232
- for (const x of [-1.01, 1.01]) {
1233
- const numCircle = new THREE.Mesh(numGeo, numMat);
1234
- numCircle.position.set(x, 0.85, -0.3);
1235
- numCircle.rotation.y = x > 0 ? Math.PI / 2 : -Math.PI / 2;
1236
- g.add(numCircle);
1237
- }
1238
- return g;
1239
- }
1240
-
1241
- const playerCar = createCar(0xff2200);
1242
- scene.add(playerCar);
1243
-
1244
- // AI racers
1245
- const aiCars = [
1246
- { t: 0.15, speed: 48, lateral: 0.2, color: 0x3366ff, mesh: null, prevT: 0.15 },
1247
- { t: 0.30, speed: 52, lateral: -0.25, color: 0xffcc00, mesh: null, prevT: 0.30 },
1248
- { t: 0.50, speed: 44, lateral: 0.15, color: 0x00cc66, mesh: null, prevT: 0.50 },
1249
- { t: 0.70, speed: 50, lateral: -0.1, color: 0xff6600, mesh: null, prevT: 0.70 },
1250
- ].map(ai => {
1251
- ai.mesh = createCar(ai.color);
1252
- scene.add(ai.mesh);
1253
- return ai;
1254
- });
1255
-
1256
- // ═══════════════════════════════════════════════════════
1257
- // (Item boxes removed)
1258
- // ═══════════════════════════════════════════════════════
1259
- // PARTICLE SYSTEMS
1260
- // ═══════════════════════════════════════════════════════
1261
-
1262
- // Tire smoke particles
1263
- const SMOKE_COUNT = 200;
1264
- const smokeGeo = new THREE.BufferGeometry();
1265
- const smokePositions = new Float32Array(SMOKE_COUNT * 3);
1266
- const smokeSizes = new Float32Array(SMOKE_COUNT);
1267
- const smokeAlphas = new Float32Array(SMOKE_COUNT);
1268
- const smokeVelocities = [];
1269
- const smokeLifetimes = new Float32Array(SMOKE_COUNT);
1270
- for (let i = 0; i < SMOKE_COUNT; i++) {
1271
- smokePositions[i * 3] = 0;
1272
- smokePositions[i * 3 + 1] = -100; // hidden below ground
1273
- smokePositions[i * 3 + 2] = 0;
1274
- smokeSizes[i] = 0;
1275
- smokeAlphas[i] = 0;
1276
- smokeLifetimes[i] = 0;
1277
- smokeVelocities.push(new THREE.Vector3());
1278
- }
1279
- smokeGeo.setAttribute('position', new THREE.BufferAttribute(smokePositions, 3));
1280
- smokeGeo.setAttribute('size', new THREE.BufferAttribute(smokeSizes, 1));
1281
- smokeGeo.setAttribute('alpha', new THREE.BufferAttribute(smokeAlphas, 1));
1282
-
1283
- const smokeMat = new THREE.ShaderMaterial({
1284
- transparent: true,
1285
- depthWrite: false,
1286
- blending: THREE.AdditiveBlending,
1287
- uniforms: {
1288
- uColor: { value: new THREE.Color(0xddbbaa) },
1289
- },
1290
- vertexShader: `
1291
- attribute float size;
1292
- attribute float alpha;
1293
- varying float vAlpha;
1294
- void main() {
1295
- vAlpha = alpha;
1296
- vec4 mvPos = modelViewMatrix * vec4(position, 1.0);
1297
- gl_PointSize = size * (200.0 / -mvPos.z);
1298
- gl_Position = projectionMatrix * mvPos;
1299
- }
1300
- `,
1301
- fragmentShader: `
1302
- uniform vec3 uColor;
1303
- varying float vAlpha;
1304
- void main() {
1305
- float dist = length(gl_PointCoord - 0.5) * 2.0;
1306
- float a = smoothstep(1.0, 0.2, dist) * vAlpha;
1307
- gl_FragColor = vec4(uColor, a);
1308
- }
1309
- `,
1310
- });
1311
- const smokePoints = new THREE.Points(smokeGeo, smokeMat);
1312
- scene.add(smokePoints);
1313
-
1314
- let smokeIdx = 0;
1315
- function emitSmoke(px, py, pz, vx, vy, vz) {
1316
- const i = smokeIdx % SMOKE_COUNT;
1317
- smokePositions[i * 3] = px;
1318
- smokePositions[i * 3 + 1] = py;
1319
- smokePositions[i * 3 + 2] = pz;
1320
- smokeSizes[i] = 2 + Math.random() * 3;
1321
- smokeAlphas[i] = 0.6;
1322
- smokeLifetimes[i] = 1.0;
1323
- smokeVelocities[i].set(vx, vy, vz);
1324
- smokeIdx++;
1325
- }
1326
-
1327
- function updateSmoke(dt) {
1328
- for (let i = 0; i < SMOKE_COUNT; i++) {
1329
- if (smokeLifetimes[i] > 0) {
1330
- smokeLifetimes[i] -= dt * 1.2;
1331
- smokePositions[i * 3] += smokeVelocities[i].x * dt;
1332
- smokePositions[i * 3 + 1] += smokeVelocities[i].y * dt;
1333
- smokePositions[i * 3 + 2] += smokeVelocities[i].z * dt;
1334
- smokeSizes[i] += dt * 8;
1335
- smokeAlphas[i] = Math.max(0, smokeLifetimes[i]) * 0.5;
1336
- } else {
1337
- smokePositions[i * 3 + 1] = -100; // hide
1338
- smokeAlphas[i] = 0;
1339
- }
1340
- }
1341
- smokeGeo.attributes.position.needsUpdate = true;
1342
- smokeGeo.attributes.size.needsUpdate = true;
1343
- smokeGeo.attributes.alpha.needsUpdate = true;
1344
- }
1345
-
1346
- // Dust particles (off-track)
1347
- const DUST_COUNT = 150;
1348
- const dustGeo = new THREE.BufferGeometry();
1349
- const dustPositions = new Float32Array(DUST_COUNT * 3);
1350
- const dustSizes = new Float32Array(DUST_COUNT);
1351
- const dustAlphas = new Float32Array(DUST_COUNT);
1352
- const dustVelocities = [];
1353
- const dustLifetimes = new Float32Array(DUST_COUNT);
1354
- for (let i = 0; i < DUST_COUNT; i++) {
1355
- dustPositions[i * 3] = 0;
1356
- dustPositions[i * 3 + 1] = -100;
1357
- dustPositions[i * 3 + 2] = 0;
1358
- dustSizes[i] = 0;
1359
- dustAlphas[i] = 0;
1360
- dustLifetimes[i] = 0;
1361
- dustVelocities.push(new THREE.Vector3());
1362
- }
1363
- dustGeo.setAttribute('position', new THREE.BufferAttribute(dustPositions, 3));
1364
- dustGeo.setAttribute('size', new THREE.BufferAttribute(dustSizes, 1));
1365
- dustGeo.setAttribute('alpha', new THREE.BufferAttribute(dustAlphas, 1));
1366
-
1367
- const dustMat = new THREE.ShaderMaterial({
1368
- transparent: true,
1369
- depthWrite: false,
1370
- blending: THREE.NormalBlending,
1371
- uniforms: {
1372
- uColor: { value: new THREE.Color(0x9f7f5f) },
1373
- },
1374
- vertexShader: `
1375
- attribute float size;
1376
- attribute float alpha;
1377
- varying float vAlpha;
1378
- void main() {
1379
- vAlpha = alpha;
1380
- vec4 mvPos = modelViewMatrix * vec4(position, 1.0);
1381
- gl_PointSize = size * (200.0 / -mvPos.z);
1382
- gl_Position = projectionMatrix * mvPos;
1383
- }
1384
- `,
1385
- fragmentShader: `
1386
- uniform vec3 uColor;
1387
- varying float vAlpha;
1388
- void main() {
1389
- float dist = length(gl_PointCoord - 0.5) * 2.0;
1390
- float a = smoothstep(1.0, 0.3, dist) * vAlpha;
1391
- gl_FragColor = vec4(uColor, a);
1392
- }
1393
- `,
1394
- });
1395
- const dustPoints = new THREE.Points(dustGeo, dustMat);
1396
- scene.add(dustPoints);
1397
-
1398
- let dustIdx = 0;
1399
- function emitDust(px, py, pz) {
1400
- const i = dustIdx % DUST_COUNT;
1401
- dustPositions[i * 3] = px + (Math.random() - 0.5) * 2;
1402
- dustPositions[i * 3 + 1] = py;
1403
- dustPositions[i * 3 + 2] = pz + (Math.random() - 0.5) * 2;
1404
- dustSizes[i] = 3 + Math.random() * 5;
1405
- dustAlphas[i] = 0.4;
1406
- dustLifetimes[i] = 1.0;
1407
- dustVelocities[i].set(
1408
- (Math.random() - 0.5) * 4,
1409
- 1 + Math.random() * 2,
1410
- (Math.random() - 0.5) * 4
1411
- );
1412
- dustIdx++;
1413
- }
1414
-
1415
- function updateDust(dt) {
1416
- for (let i = 0; i < DUST_COUNT; i++) {
1417
- if (dustLifetimes[i] > 0) {
1418
- dustLifetimes[i] -= dt * 1.0;
1419
- dustPositions[i * 3] += dustVelocities[i].x * dt;
1420
- dustPositions[i * 3 + 1] += dustVelocities[i].y * dt;
1421
- dustPositions[i * 3 + 2] += dustVelocities[i].z * dt;
1422
- dustSizes[i] += dt * 6;
1423
- dustAlphas[i] = Math.max(0, dustLifetimes[i]) * 0.35;
1424
- } else {
1425
- dustPositions[i * 3 + 1] = -100;
1426
- dustAlphas[i] = 0;
1427
- }
1428
- }
1429
- dustGeo.attributes.position.needsUpdate = true;
1430
- dustGeo.attributes.size.needsUpdate = true;
1431
- dustGeo.attributes.alpha.needsUpdate = true;
1432
- }
1433
-
1434
- // Speed lines (appear at high speed)
1435
- const SPEED_LINE_COUNT = 80;
1436
- const speedLineGeo = new THREE.BufferGeometry();
1437
- const slPositions = new Float32Array(SPEED_LINE_COUNT * 3);
1438
- const slAlphas = new Float32Array(SPEED_LINE_COUNT);
1439
- const slVelocities = [];
1440
- for (let i = 0; i < SPEED_LINE_COUNT; i++) {
1441
- slPositions[i * 3] = 0;
1442
- slPositions[i * 3 + 1] = -100;
1443
- slPositions[i * 3 + 2] = 0;
1444
- slAlphas[i] = 0;
1445
- slVelocities.push(new THREE.Vector3());
1446
- }
1447
- speedLineGeo.setAttribute('position', new THREE.BufferAttribute(slPositions, 3));
1448
- speedLineGeo.setAttribute('alpha', new THREE.BufferAttribute(slAlphas, 1));
1449
-
1450
- const speedLineMat = new THREE.ShaderMaterial({
1451
- transparent: true,
1452
- depthWrite: false,
1453
- blending: THREE.AdditiveBlending,
1454
- uniforms: {},
1455
- vertexShader: `
1456
- attribute float alpha;
1457
- varying float vAlpha;
1458
- void main() {
1459
- vAlpha = alpha;
1460
- vec4 mvPos = modelViewMatrix * vec4(position, 1.0);
1461
- gl_PointSize = 3.0 * (200.0 / -mvPos.z);
1462
- gl_Position = projectionMatrix * mvPos;
1463
- }
1464
- `,
1465
- fragmentShader: `
1466
- varying float vAlpha;
1467
- void main() {
1468
- float dist = length(gl_PointCoord - 0.5) * 2.0;
1469
- float a = smoothstep(1.0, 0.0, dist) * vAlpha;
1470
- gl_FragColor = vec4(1.0, 1.0, 1.0, a);
1471
- }
1472
- `,
1473
- });
1474
- const speedLinePoints = new THREE.Points(speedLineGeo, speedLineMat);
1475
- scene.add(speedLinePoints);
1476
-
1477
- let slIdx = 0;
1478
- function emitSpeedLine(px, py, pz, heading, speed) {
1479
- const i = slIdx % SPEED_LINE_COUNT;
1480
- const offset = new THREE.Vector3(
1481
- (Math.random() - 0.5) * 12,
1482
- Math.random() * 5 + 1,
1483
- (Math.random() - 0.5) * 12
1484
- );
1485
- slPositions[i * 3] = px + offset.x;
1486
- slPositions[i * 3 + 1] = py + offset.y;
1487
- slPositions[i * 3 + 2] = pz + offset.z;
1488
- slAlphas[i] = 0.3;
1489
- const dir = new THREE.Vector3(Math.sin(heading), 0, Math.cos(heading));
1490
- slVelocities[i].copy(dir).multiplyScalar(-speed * 0.5);
1491
- slVelocities[i].y = (Math.random() - 0.5) * 5;
1492
- slIdx++;
1493
- }
1494
-
1495
- function updateSpeedLines(dt) {
1496
- const p = G.player;
1497
- const speedRatio = Math.abs(p.speed) / MAX_SPEED;
1498
- for (let i = 0; i < SPEED_LINE_COUNT; i++) {
1499
- slAlphas[i] *= (1 - dt * 3);
1500
- if (slAlphas[i] < 0.01) {
1501
- slPositions[i * 3 + 1] = -100;
1502
- slAlphas[i] = 0;
1503
- } else {
1504
- slPositions[i * 3] += slVelocities[i].x * dt;
1505
- slPositions[i * 3 + 1] += slVelocities[i].y * dt;
1506
- slPositions[i * 3 + 2] += slVelocities[i].z * dt;
1507
- }
1508
- }
1509
- speedLineGeo.attributes.position.needsUpdate = true;
1510
- speedLineGeo.attributes.alpha.needsUpdate = true;
1511
-
1512
- // Emit new speed lines at high speed
1513
- if (speedRatio > 0.6) {
1514
- const count = Math.floor((speedRatio - 0.5) * 6);
1515
- for (let j = 0; j < count; j++) {
1516
- emitSpeedLine(p.x, p.z > 0 ? 0 : 0, p.z, p.heading, p.speed);
1517
- }
1518
- }
1519
- }
1520
-
1521
- // ═══════════════════════════════════════════════════════
1522
- // MINI-MAP (2D canvas overlay)
1523
- // ═══════════════════════════════════════════════════════
1524
- const minimapCanvas = document.getElementById('minimap');
1525
- const minimapCtx = minimapCanvas.getContext('2d');
1526
-
1527
- // Pre-compute track bounds for minimap
1528
- let mMinX = Infinity, mMaxX = -Infinity, mMinZ = Infinity, mMaxZ = -Infinity;
1529
- const minimapPts = [];
1530
- for (let i = 0; i <= 200; i++) {
1531
- const p = trackCurve.getPointAt(i / 200);
1532
- minimapPts.push(p);
1533
- mMinX = Math.min(mMinX, p.x); mMaxX = Math.max(mMaxX, p.x);
1534
- mMinZ = Math.min(mMinZ, p.z); mMaxZ = Math.max(mMaxZ, p.z);
1535
- }
1536
- const padX = (mMaxX - mMinX) * 0.12, padZ = (mMaxZ - mMinZ) * 0.12;
1537
- mMinX -= padX; mMaxX += padX; mMinZ -= padZ; mMaxZ += padZ;
1538
-
1539
- function drawMinimap() {
1540
- const w = minimapCanvas.width, h = minimapCanvas.height;
1541
- const toMX = (x) => ((x - mMinX) / (mMaxX - mMinX)) * (w - 16) + 8;
1542
- const toMY = (z) => ((z - mMinZ) / (mMaxZ - mMinZ)) * (h - 16) + 8;
1543
-
1544
- minimapCtx.clearRect(0, 0, w, h);
1545
- minimapCtx.fillStyle = 'rgba(0,0,0,0.55)';
1546
- minimapCtx.beginPath();
1547
- minimapCtx.roundRect(0, 0, w, h, 8);
1548
- minimapCtx.fill();
1549
-
1550
- // Track outline β€” colored by section
1551
- minimapCtx.strokeStyle = '#888';
1552
- minimapCtx.lineWidth = 5;
1553
- minimapCtx.lineCap = 'round';
1554
- minimapCtx.beginPath();
1555
- minimapPts.forEach((p, i) => {
1556
- const mx = toMX(p.x), my = toMY(p.z);
1557
- i === 0 ? minimapCtx.moveTo(mx, my) : minimapCtx.lineTo(mx, my);
1558
- });
1559
- minimapCtx.closePath();
1560
- minimapCtx.stroke();
1561
-
1562
- // Road fill
1563
- minimapCtx.strokeStyle = '#555';
1564
- minimapCtx.lineWidth = 3;
1565
- minimapCtx.beginPath();
1566
- minimapPts.forEach((p, i) => {
1567
- const mx = toMX(p.x), my = toMY(p.z);
1568
- i === 0 ? minimapCtx.moveTo(mx, my) : minimapCtx.lineTo(mx, my);
1569
- });
1570
- minimapCtx.closePath();
1571
- minimapCtx.stroke();
1572
-
1573
- // AI dots
1574
- for (const ai of aiCars) {
1575
- const ap = frame(ai.t).point;
1576
- minimapCtx.fillStyle = '#' + ai.color.toString(16).padStart(6, '0');
1577
- minimapCtx.beginPath();
1578
- minimapCtx.arc(toMX(ap.x), toMY(ap.z), 3, 0, Math.PI * 2);
1579
- minimapCtx.fill();
1580
- }
1581
-
1582
- // Player dot (world position) β€” with glow
1583
- minimapCtx.shadowColor = '#ff2200';
1584
- minimapCtx.shadowBlur = 6;
1585
- minimapCtx.fillStyle = '#ff2200';
1586
- minimapCtx.strokeStyle = '#fff';
1587
- minimapCtx.lineWidth = 1.5;
1588
- minimapCtx.beginPath();
1589
- minimapCtx.arc(toMX(G.player.x), toMY(G.player.z), 4, 0, Math.PI * 2);
1590
- minimapCtx.fill();
1591
- minimapCtx.stroke();
1592
- minimapCtx.shadowBlur = 0;
1593
- // Player direction indicator
1594
- const dirX = toMX(G.player.x) + Math.sin(G.player.heading) * 8;
1595
- const dirZ = toMY(G.player.z) + Math.cos(G.player.heading) * 8;
1596
- minimapCtx.strokeStyle = '#ff2200';
1597
- minimapCtx.lineWidth = 2;
1598
- minimapCtx.beginPath();
1599
- minimapCtx.moveTo(toMX(G.player.x), toMY(G.player.z));
1600
- minimapCtx.lineTo(dirX, dirZ);
1601
- minimapCtx.stroke();
1602
- }
1603
-
1604
- // ═══════════════════════════════════════════════════════
1605
- // FIND NEAREST POINT ON TRACK
1606
- // ═══════════════════════════════════════════════════════
1607
- // Returns { t, dist } β€” nearest track parameter and distance to centerline
1608
- function nearestTrackT(px, pz) {
1609
- let bestT = 0, bestD = Infinity;
1610
- const steps = 300;
1611
- for (let i = 0; i < steps; i++) {
1612
- const t = i / steps;
1613
- const p = trackCurve.getPointAt(t);
1614
- const d = (p.x - px) ** 2 + (p.z - pz) ** 2;
1615
- if (d < bestD) { bestD = d; bestT = t; }
1616
- }
1617
- // Refine around best
1618
- const range = 1 / steps;
1619
- const fineSteps = 30;
1620
- for (let i = 0; i <= fineSteps; i++) {
1621
- const t = ((bestT - range + i * 2 * range / fineSteps) % 1 + 1) % 1;
1622
- const p = trackCurve.getPointAt(t);
1623
- const d = (p.x - px) ** 2 + (p.z - pz) ** 2;
1624
- if (d < bestD) { bestD = d; bestT = t; }
1625
- }
1626
- return { t: bestT, dist: Math.sqrt(bestD) };
1627
- }
1628
-
1629
- // ═══════════════════════════════════════════════════════
1630
- // WINDOW EXPORTS (must be after all const declarations)
1631
- // ═══════════════════════════════════════════════════════
1632
- window.G = G;
1633
- window.keys = G.keys;
1634
- window.trackCurve = trackCurve;
1635
- window.trackLen = trackLen;
1636
- window.frameFn = frame;
1637
-
1638
- // ═══════════════════════════════════════════════════════
1639
- // INPUT
1640
- // ═══════════════════════════════════════════════════════
1641
- window.addEventListener('keydown', (e) => { G.keys[e.code] = true; });
1642
- window.addEventListener('keyup', (e) => { G.keys[e.code] = false; });
1643
-
1644
- // ═══════════════════════════════════════════════════════
1645
- // TIRE MARKS β€” persistent skid marks on the ground
1646
- // ═══════════════════════════════════════════════════════
1647
- const MAX_TIRE_MARKS = 8000; // 2 marks per frame (left+right wheel)
1648
- const tireMarkGeo = new THREE.BufferGeometry();
1649
- const tireMarkPositions = new Float32Array(MAX_TIRE_MARKS * 6); // 2 triangles (6 verts) per mark
1650
- const tireMarkAlphas = new Float32Array(MAX_TIRE_MARKS * 6); // 1 alpha per vertex
1651
- for (let i = 0; i < MAX_TIRE_MARKS * 6; i++) tireMarkPositions[i] = 0;
1652
- for (let i = 0; i < MAX_TIRE_MARKS * 6; i++) tireMarkAlphas[i] = 0;
1653
- tireMarkGeo.setAttribute('position', new THREE.BufferAttribute(tireMarkPositions, 3));
1654
- tireMarkGeo.setAttribute('alpha', new THREE.BufferAttribute(tireMarkAlphas, 1));
1655
- tireMarkGeo.setDrawRange(0, 0);
1656
-
1657
- const tireMarkMat = new THREE.ShaderMaterial({
1658
- transparent: true,
1659
- depthWrite: false,
1660
- polygonOffset: true,
1661
- polygonOffsetFactor: -1,
1662
- uniforms: {},
1663
- vertexShader: `
1664
- attribute float alpha;
1665
- varying float vAlpha;
1666
- void main() {
1667
- vAlpha = alpha;
1668
- gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
1669
- }
1670
- `,
1671
- fragmentShader: `
1672
- varying float vAlpha;
1673
- void main() {
1674
- gl_FragColor = vec4(0.08, 0.08, 0.08, vAlpha);
1675
- }
1676
- `,
1677
- });
1678
- const tireMarkMesh = new THREE.Mesh(tireMarkGeo, tireMarkMat);
1679
- tireMarkMesh.position.y = 0.025; // just above road surface (road is at 0.02)
1680
- scene.add(tireMarkMesh);
1681
-
1682
- let tireMarkCount = 0;
1683
- // Track previous mark positions per wheel (left=-1, right=+1)
1684
- let prevTireMarkLeft = null;
1685
- let prevTireMarkRight = null;
1686
-
1687
- // Add a single tire mark strip for one wheel.
1688
- // cx, cz = center position of the wheel on the ground
1689
- // perpX, perpZ = perpendicular to car's forward direction (used for mark width)
1690
- // intensity = 0-1 how dark
1691
- function addTireMark(cx, cz, perpX, perpZ, intensity, side) {
1692
- if (tireMarkCount >= MAX_TIRE_MARKS) return;
1693
-
1694
- const markHalfWidth = 0.18; // half-width of a single tire mark strip
1695
- const i = tireMarkCount;
1696
- const i6 = i * 6;
1697
- const i6a = i * 6; // alpha index matches position index
1698
-
1699
- // Left/right edges of this mark strip (perpendicular to car forward)
1700
- const lx = cx + perpX * markHalfWidth;
1701
- const lz = cz + perpZ * markHalfWidth;
1702
- const rx = cx - perpX * markHalfWidth;
1703
- const rz = cz - perpZ * markHalfWidth;
1704
-
1705
- const prev = side < 0 ? prevTireMarkLeft : prevTireMarkRight;
1706
-
1707
- if (prev) {
1708
- // Triangle 1: prevL, prevR, curL
1709
- tireMarkPositions[i6] = prev.lx;
1710
- tireMarkPositions[i6 + 1] = 0;
1711
- tireMarkPositions[i6 + 2] = prev.lz;
1712
- tireMarkPositions[i6 + 3] = prev.rx;
1713
- tireMarkPositions[i6 + 4] = 0;
1714
- tireMarkPositions[i6 + 5] = prev.rz;
1715
- // Triangle 2: prevR, curR, curL
1716
- tireMarkPositions[i6 + 6] = prev.rx;
1717
- tireMarkPositions[i6 + 7] = 0;
1718
- tireMarkPositions[i6 + 8] = prev.rz;
1719
- tireMarkPositions[i6 + 9] = rx;
1720
- tireMarkPositions[i6 + 10] = 0;
1721
- tireMarkPositions[i6 + 11] = rz;
1722
-
1723
- // Per-vertex alpha (6 vertices per quad)
1724
- const avgIntensity = (intensity + prev.intensity) * 0.5;
1725
- tireMarkAlphas[i6] = avgIntensity;
1726
- tireMarkAlphas[i6 + 1] = avgIntensity;
1727
- tireMarkAlphas[i6 + 2] = avgIntensity;
1728
- tireMarkAlphas[i6 + 3] = avgIntensity;
1729
- tireMarkAlphas[i6 + 4] = avgIntensity;
1730
- tireMarkAlphas[i6 + 5] = avgIntensity;
1731
-
1732
- tireMarkCount++;
1733
- tireMarkGeo.setDrawRange(0, tireMarkCount * 6);
1734
- tireMarkGeo.attributes.position.needsUpdate = true;
1735
- tireMarkGeo.attributes.alpha.needsUpdate = true;
1736
- }
1737
-
1738
- if (side < 0) prevTireMarkLeft = { lx, lz, rx, rz, intensity };
1739
- else prevTireMarkRight = { lx, lz, rx, rz, intensity };
1740
- }
1741
-
1742
- // Reset tire mark chain when not skidding
1743
- function breakTireMarkChain() {
1744
- prevTireMarkLeft = null;
1745
- prevTireMarkRight = null;
1746
- }
1747
-
1748
- // ═══════════════════════════════════════════════════════
1749
- // GAME LOOP β€” free-form driving for player
1750
- // ═══════════════════════════════════════════════════════
1751
- const MAX_SPEED = 40;
1752
- const ACCEL = 22;
1753
- const BRAKE = 35;
1754
- const DRAG = 10;
1755
- const TURN_RATE = 2.6; // radians/sec at full speed
1756
- const GRIP_TRACK = 1.0; // 1.0 = full grip on road
1757
- const GRIP_GRASS = 0.6; // reduced grip on grass
1758
- const DRIFT_FACTOR = 0.15; // how much the car slides
1759
-
1760
- const clock = new THREE.Clock();
1761
-
1762
- // Initialize player position at start line, facing forward
1763
- {
1764
- const { point, tangent } = frame(0);
1765
- G.player.x = point.x;
1766
- G.player.z = point.z;
1767
- G.player.heading = Math.atan2(tangent.x, tangent.z);
1768
- G.lapMarker = 0;
1769
- }
1770
-
1771
- // Track previous position for lap detection
1772
- let prevTrackT = 0;
1773
-
1774
- function update() {
1775
- const dt = Math.min(clock.getDelta(), 0.05);
1776
- const p = G.player;
1777
-
1778
- // ── Find nearest track point ──
1779
- const nearest = nearestTrackT(p.x, p.z);
1780
- const onRoad = nearest.dist < TRACK_WIDTH / 2;
1781
- p.onTrack = onRoad;
1782
-
1783
- // ── Accelerate / brake / handbrake ──
1784
- const handbrake = G.keys['Space'] || false;
1785
- if (G.keys['KeyW'] || G.keys['ArrowUp']) p.speed += ACCEL * dt;
1786
- else if (G.keys['KeyS'] || G.keys['ArrowDown']) p.speed -= BRAKE * dt;
1787
- else {
1788
- if (p.speed > 0) p.speed = Math.max(0, p.speed - DRAG * dt);
1789
- else p.speed = Math.min(0, p.speed + DRAG * dt);
1790
- }
1791
- // Handbrake: moderate decel + greatly reduced grip (enables drifting)
1792
- if (handbrake && Math.abs(p.speed) > 2) {
1793
- const hbDrag = 18;
1794
- if (p.speed > 0) p.speed = Math.max(0, p.speed - hbDrag * dt);
1795
- else p.speed = Math.min(0, p.speed + hbDrag * dt);
1796
- }
1797
-
1798
- // Off-road penalty β€” grass friction (proportional to speed)
1799
- if (!onRoad) {
1800
- const grassDrag = 25;
1801
- p.speed *= Math.max(0, 1 - grassDrag * dt / Math.max(Math.abs(p.speed), 8));
1802
- }
1803
-
1804
- p.speed = THREE.MathUtils.clamp(p.speed, -MAX_SPEED * 0.3, MAX_SPEED);
1805
-
1806
- // ── Steer (turn rate scales down at high speed β€” more realistic) ──
1807
- const speedFactor = Math.min(Math.abs(p.speed) / 20, 1);
1808
- const steerInput = (G.keys['KeyA'] || G.keys['ArrowLeft']) ? 1 :
1809
- (G.keys['KeyD'] || G.keys['ArrowRight']) ? -1 : 0;
1810
- const turnDelta = steerInput * TURN_RATE * speedFactor * dt;
1811
- let grip = onRoad ? GRIP_TRACK : GRIP_GRASS;
1812
- if (handbrake) grip *= 0.35; // handbrake kills grip β†’ drift!
1813
-
1814
- // ── Calculate desired vs actual heading ──
1815
- const desiredHeading = p.heading + turnDelta;
1816
-
1817
- // Drift: car doesn't instantly follow heading β€” it slides a bit
1818
- const headingDiff = desiredHeading - p.heading;
1819
- p.heading += headingDiff * grip;
1820
-
1821
- // ── Move car in the direction it's facing ──
1822
- const moveDir = new THREE.Vector3(
1823
- Math.sin(p.heading),
1824
- 0,
1825
- Math.cos(p.heading)
1826
- );
1827
-
1828
- p.x += moveDir.x * p.speed * dt;
1829
- p.z += moveDir.z * p.speed * dt;
1830
-
1831
- // ── Position car on road surface ──
1832
- const surfaceFrame = frame(nearest.t);
1833
- const surfaceY = surfaceFrame.point.y + 0.05;
1834
- playerCar.position.set(p.x, surfaceY, p.z);
1835
- playerCar.rotation.y = p.heading;
1836
-
1837
- // Visual body roll when turning
1838
- const targetRoll = steerInput * 0.08 * (p.speed / MAX_SPEED);
1839
- playerCar.rotation.z = THREE.MathUtils.lerp(playerCar.rotation.z, targetRoll, 5 * dt);
1840
- // Visual pitch when accelerating/braking
1841
- const targetPitch = (G.keys['KeyW'] || G.keys['ArrowUp']) ? -0.03 :
1842
- (G.keys['KeyS'] || G.keys['ArrowDown']) ? 0.04 : 0;
1843
- playerCar.rotation.x = THREE.MathUtils.lerp(playerCar.rotation.x, targetPitch * (p.speed / MAX_SPEED), 5 * dt);
1844
-
1845
- // ── Tire marks when drifting or hard braking ──
1846
- const isDrifting = grip < 0.7 && Math.abs(p.speed) > 3;
1847
- const isHardBraking = handbrake && Math.abs(p.speed) > 3;
1848
- const isAggressiveTurn = Math.abs(steerInput) > 0 && Math.abs(p.speed) > 15 && onRoad;
1849
- if (isDrifting || isHardBraking || isAggressiveTurn) {
1850
- const perpX = moveDir.z;
1851
- const perpZ = -moveDir.x;
1852
- const intensity = Math.min(1, (Math.abs(p.speed) / MAX_SPEED) * (1 - grip + 0.3));
1853
- // Rear wheel positions (car body is ~4 units long, wheels at z=Β±1.3)
1854
- const rearOffset = -1.3; // wheels are 1.3 behind car center in local Z
1855
- const wheelX = p.x + moveDir.x * rearOffset;
1856
- const wheelZ = p.z + moveDir.z * rearOffset;
1857
- const sideDist = 1.1; // wheels are Β±1.1 from center in local X
1858
- // Left wheel
1859
- addTireMark(
1860
- wheelX + perpX * sideDist,
1861
- wheelZ + perpZ * sideDist,
1862
- perpX, perpZ,
1863
- intensity, -1
1864
- );
1865
- // Right wheel
1866
- addTireMark(
1867
- wheelX - perpX * sideDist,
1868
- wheelZ - perpZ * sideDist,
1869
- perpX, perpZ,
1870
- intensity, 1
1871
- );
1872
- } else {
1873
- breakTireMarkChain();
1874
- }
1875
-
1876
- // ── Particles ──
1877
- // Tire smoke when drifting or handbraking
1878
- if ((Math.abs(p.speed) > 20 && grip < 0.7) || (Math.abs(p.speed) > 30 && Math.abs(steerInput) > 0.5)) {
1879
- const behindOffset = moveDir.clone().multiplyScalar(-2);
1880
- for (const side of [-1.2, 1.2]) {
1881
- const sideVec = new THREE.Vector3(moveDir.z, 0, -moveDir.x).multiplyScalar(side);
1882
- emitSmoke(
1883
- p.x + behindOffset.x + sideVec.x,
1884
- 0.2,
1885
- p.z + behindOffset.z + sideVec.z,
1886
- (Math.random() - 0.5) * 3,
1887
- 1.5 + Math.random(),
1888
- (Math.random() - 0.5) * 3
1889
- );
1890
- }
1891
- }
1892
- // Dust when off-track and moving
1893
- if (!onRoad && Math.abs(p.speed) > 5) {
1894
- if (Math.random() < 0.4) {
1895
- const behindOffset = moveDir.clone().multiplyScalar(-1.5);
1896
- emitDust(
1897
- p.x + behindOffset.x + (Math.random() - 0.5),
1898
- 0.3,
1899
- p.z + behindOffset.z + (Math.random() - 0.5)
1900
- );
1901
- }
1902
- }
1903
- updateSmoke(dt);
1904
- updateDust(dt);
1905
- updateSpeedLines(dt);
1906
-
1907
- // ── Lap detection ──
1908
- const currentTrackT = nearest.t;
1909
- // Player crossed the start/finish line (from ~0.95+ to ~0.05-)
1910
- if (prevTrackT > 0.9 && currentTrackT < 0.1 && p.speed > 0) {
1911
- p.lap++;
1912
- }
1913
- if (prevTrackT < 0.1 && currentTrackT > 0.9 && p.speed < 0) {
1914
- p.lap = Math.max(0, p.lap - 1);
1915
- }
1916
- prevTrackT = currentTrackT;
1917
-
1918
- // ── Camera follow β€” with dynamic FOV ──
1919
- const camBehind = moveDir.clone().multiplyScalar(-14);
1920
- const camUp = new THREE.Vector3(0, 7, 0);
1921
- const targetCamPos = playerCar.position.clone().add(camBehind).add(camUp);
1922
- camera.position.lerp(targetCamPos, 5 * dt);
1923
- const lookTarget = playerCar.position.clone();
1924
- lookTarget.y += 1;
1925
- camera.lookAt(lookTarget);
1926
-
1927
- // Dynamic FOV β€” widens at speed
1928
- const targetFov = 65 + (Math.abs(p.speed) / MAX_SPEED) * 15;
1929
- camera.fov = THREE.MathUtils.lerp(camera.fov, targetFov, 3 * dt);
1930
- camera.updateProjectionMatrix();
1931
-
1932
- // Shadow follow
1933
- sun.position.copy(playerCar.position).add(new THREE.Vector3(60, 80, 40));
1934
- sun.target.position.copy(playerCar.position);
1935
-
1936
- // ── AI racers (stay on spline) ──
1937
- for (const ai of aiCars) {
1938
- ai.prevT = ai.t;
1939
- ai.t += (ai.speed * dt) / trackLen;
1940
- ai.t = ((ai.t % 1) + 1) % 1;
1941
- const { point: ap, tangent: at, side: as } = frame(ai.t);
1942
- ai.mesh.position.copy(ap).add(as.clone().multiplyScalar(ai.lateral * (TRACK_WIDTH / 2 - 2)));
1943
- ai.mesh.position.y += 0.05;
1944
- ai.mesh.lookAt(ai.mesh.position.clone().add(at));
1945
- }
1946
-
1947
- // ── HUD ──
1948
- document.getElementById('speed').textContent = `${Math.abs(Math.round(p.speed))} km/h`;
1949
- document.getElementById('lap').textContent = `Lap ${p.lap + 1} / 3`;
1950
- document.getElementById('position').textContent = `P${getPosition(currentTrackT)}`;
1951
- // Off-track indicator
1952
- const trackIndicator = document.getElementById('track-status');
1953
- if (trackIndicator) {
1954
- trackIndicator.textContent = onRoad ? '' : 'OFF TRACK!';
1955
- trackIndicator.style.color = onRoad ? 'inherit' : '#ff4444';
1956
- }
1957
-
1958
- // ── Mini-map ──
1959
- drawMinimap();
1960
- }
1961
-
1962
- function getPosition(playerT) {
1963
- // Compare progress: lap + track parameter
1964
- const playerProgress = G.player.lap + playerT;
1965
- let pos = 1;
1966
- for (const ai of aiCars) {
1967
- const aiProgress = ai.t; // simple: AI stays on lap 0 for now
1968
- if (aiProgress > playerT) pos++;
1969
- }
1970
- return pos;
1971
- }
1972
-
1973
- function animate() {
1974
- requestAnimationFrame(animate);
1975
- update();
1976
- renderer.render(scene, camera);
1977
- }
1978
-
1979
- animate();
1980
-
1981
- // ═══════════════════════════════════════════════════════
1982
- // RESIZE
1983
- // ═══════════════════════════════════════════════════════
1984
- window.addEventListener('resize', () => {
1985
- camera.aspect = window.innerWidth / window.innerHeight;
1986
- camera.updateProjectionMatrix();
1987
- renderer.setSize(window.innerWidth, window.innerHeight);
1988
- });
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
js/telemetry.js CHANGED
@@ -553,9 +553,12 @@ function formatTimeStatic(seconds) {
553
  }
554
 
555
  // ═══════════════════════════════════════════════════════
556
- // Server persistence β€” save telemetry to disk
557
  // ═══════════════════════════════════════════════════════
558
 
 
 
 
559
  let _saveTimeout = null;
560
 
561
  export function scheduleSave(session) {
@@ -563,27 +566,98 @@ export function scheduleSave(session) {
563
  _saveTimeout = setTimeout(() => saveTelemetry(session), 2000);
564
  }
565
 
566
- export async function saveTelemetry(session) {
567
  try {
568
  const data = session.toJSON();
569
- const resp = await fetch('/api/telemetry', {
570
- method: 'POST',
571
- headers: { 'Content-Type': 'application/json' },
572
- body: JSON.stringify(data),
573
- });
574
- if (!resp.ok) console.warn('Telemetry save failed:', resp.status);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
575
  } catch (e) {
576
  console.warn('Telemetry save error:', e.message);
577
  }
578
  }
579
 
 
 
 
 
 
 
 
 
 
580
  // ── Load all saved sessions ──
581
  export async function loadSessions() {
 
 
 
 
 
 
 
582
  try {
583
- const resp = await fetch('/api/telemetry');
584
- if (!resp.ok) return [];
585
- return await resp.json();
 
586
  } catch {
587
- return [];
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
588
  }
589
  }
 
553
  }
554
 
555
  // ═══════════════════════════════════════════════════════
556
+ // Local persistence β€” save telemetry to localStorage
557
  // ═══════════════════════════════════════════════════════
558
 
559
+ const STORAGE_KEY = 'sunset_racing_telemetry_sessions';
560
+ const MAX_SESSIONS = 20; // keep only recent 20 sessions to limit localStorage growth
561
+
562
  let _saveTimeout = null;
563
 
564
  export function scheduleSave(session) {
 
566
  _saveTimeout = setTimeout(() => saveTelemetry(session), 2000);
567
  }
568
 
569
+ export function saveTelemetry(session) {
570
  try {
571
  const data = session.toJSON();
572
+ const existing = loadSessionsSync();
573
+ // Replace or insert by sessionId
574
+ const idx = existing.findIndex(s => s.sessionId === data.sessionId);
575
+ if (idx >= 0) {
576
+ existing[idx] = data;
577
+ } else {
578
+ existing.push(data);
579
+ }
580
+ // Keep only the most recent MAX_SESSIONS
581
+ if (existing.length > MAX_SESSIONS) {
582
+ existing.splice(0, existing.length - MAX_SESSIONS);
583
+ }
584
+ try {
585
+ localStorage.setItem(STORAGE_KEY, JSON.stringify(existing));
586
+ } catch (e) {
587
+ // Quota exceeded β€” keep only half and retry
588
+ const half = existing.slice(Math.floor(existing.length / 2));
589
+ try {
590
+ localStorage.setItem(STORAGE_KEY, JSON.stringify(half));
591
+ } catch {}
592
+ }
593
  } catch (e) {
594
  console.warn('Telemetry save error:', e.message);
595
  }
596
  }
597
 
598
+ function loadSessionsSync() {
599
+ try {
600
+ const raw = localStorage.getItem(STORAGE_KEY);
601
+ return raw ? JSON.parse(raw) : [];
602
+ } catch {
603
+ return [];
604
+ }
605
+ }
606
+
607
  // ── Load all saved sessions ──
608
  export async function loadSessions() {
609
+ return loadSessionsSync();
610
+ }
611
+
612
+ // ── Best lap persistence (fast access) ──
613
+ const BEST_LAP_KEY = 'sunset_racing_best_lap';
614
+
615
+ export function loadBestLap() {
616
  try {
617
+ const raw = localStorage.getItem(BEST_LAP_KEY);
618
+ if (!raw) return null;
619
+ const parsed = JSON.parse(raw);
620
+ return typeof parsed.time === 'number' ? parsed : null;
621
  } catch {
622
+ return null;
623
+ }
624
+ }
625
+
626
+ export function saveBestLap(time, lapNumber, sessionId) {
627
+ try {
628
+ const current = loadBestLap();
629
+ if (!current || time < current.time) {
630
+ const rec = { time, lapNumber, sessionId, savedAt: Date.now() };
631
+ localStorage.setItem(BEST_LAP_KEY, JSON.stringify(rec));
632
+ return rec;
633
+ }
634
+ } catch {}
635
+ return null;
636
+ }
637
+
638
+ // ── Ghost replay persistence ──
639
+ const GHOST_KEY = 'sunset_racing_best_ghost';
640
+
641
+ export function loadGhost() {
642
+ try {
643
+ const raw = localStorage.getItem(GHOST_KEY);
644
+ if (!raw) return null;
645
+ return JSON.parse(raw);
646
+ } catch {
647
+ return null;
648
+ }
649
+ }
650
+
651
+ export function saveGhost(samples, lapTime) {
652
+ try {
653
+ // samples: [{t, x, z, heading}, ...] relative to lap start
654
+ const data = { lapTime, savedAt: Date.now(), samples };
655
+ const json = JSON.stringify(data);
656
+ // Cap at 500 KB to avoid quota issues
657
+ if (json.length > 500 * 1024) return false;
658
+ localStorage.setItem(GHOST_KEY, json);
659
+ return true;
660
+ } catch {
661
+ return false;
662
  }
663
  }