Claude (Opus 4.6) commited on
Commit
0be7225
Β·
1 Parent(s): 19821d3

Comprehensive fix: controls, camera, visibility

Browse files

Critical bugs fixed:
- Drone spawned facing AWAY from first gate (heading=PI wrong)
-> set to 0 so local -Z aligns with -Z world travel
- Initial throttle = 0 caused immediate ground drop
-> spawn + reset use HOVER_THROTTLE (0.60)
- Gate torus lay FLAT on the ground (rotation.x = PI/2 bug)
-> now oriented via lookAt() using auto-computed travel
direction from previous waypoint
- Gate pass-plane normal pointed the wrong way
-> use gate.forward, flipped sign in testPass
- FPV camera copied full drone quaternion so world spun on
every roll input -> both modes now use yaw-only forward
with stable world-up; FPV adds optional pitch, no roll

Control tuning:
- Rates lowered (pitch 2.4, yaw 1.9, roll 2.8 rad/s)
- RATE_RESPONSE softened (9.0 -> 6.5)
- Drag bumped (0.55 -> 0.9) for stable hover
- Throttle slew calmed (2.8 -> 1.6)

Visibility:
- Fog pushed far (near 40->120, far 420->900, camera far 1200->2000)
- Warmer fog tint so horizon blends with sunset
- Ambient light +90%, added HemisphereLight fill
- Exposure 1.1 -> 1.25
- Chase camera is now the default (FPV on Tab)
- Chase cam: wider FOV 70->72, tighter offset tuning
- Every gate has a numbered billboard + breathing glow;
the NEXT gate gets a tall cyan sky beacon column that
reads from anywhere on the map
- HUD now shows a cyan directional arrow pointing at the
next gate with live meters-to-gate readout
- HUD pitch/roll extraction moved off Euler (gimbal-safe)

Countdown/pause phases now drive the camera + mesh sync
so the opening shot composes cleanly instead of showing
the world origin.

Files changed (6) hide show
  1. js/config.js +36 -36
  2. js/drone-physics.js +38 -52
  3. js/environment.js +10 -3
  4. js/game.js +123 -45
  5. js/gate-track.js +133 -83
  6. js/hud.js +53 -1
js/config.js CHANGED
@@ -7,66 +7,66 @@
7
  // ── Drone physics ────────────────────────────────────────
8
  // Thrust magnitude when throttle = 1.0 (gravity is 9.81,
9
  // so MAX_THRUST > 9.81 means we can hover + climb).
10
- export const MAX_THRUST = 22; // m/s^2 acceleration along drone local +Y
11
- export const GRAVITY = 9.81; // m/s^2, world -Y
12
- export const DRAG_LINEAR = 0.55; // per-second linear velocity damping
13
- export const DRAG_ANGULAR = 4.0; // per-second angular velocity damping
 
14
 
15
- // Input β†’ target angular-rate (acro mode has no level lock)
16
- export const PITCH_RATE = 3.8; // rad/s at full stick
17
- export const YAW_RATE = 2.4;
18
- export const ROLL_RATE = 4.6;
 
19
 
20
- // How quickly commanded rates override current angular velocity.
21
- // Higher = snappier / twitchier; lower = floaty.
22
- export const RATE_RESPONSE = 9.0; // 1/s
23
 
24
  // Throttle slew so spamming Space/Shift doesn't teleport thrust.
25
- export const THROTTLE_SLEW = 2.8; // 0..1 per second (full travel ~0.36s)
 
 
 
26
 
27
  // ── Track / gates ────────────────────────────────────────
28
  export const GATE_COUNT = 10;
29
- export const GATE_RADIUS = 4.2; // inner radius (pass if <= this)
30
- export const GATE_TUBE = 0.32; // torus tube radius (visual thickness)
31
  export const TOTAL_LAPS = 3;
32
 
33
- // Gate pass detection tolerance along the normal axis
34
- // (how "in front of" vs "behind" counts as crossing).
35
- export const GATE_PLANE_EPS = 0.15;
36
 
37
  // ── Off-course recovery ──────────────────────────────────
38
- // If the drone strays too far from the previous gate, snap
39
- // back to a hover checkpoint. Radius is large so it only
40
- // triggers on genuine failures, not near-misses.
41
- export const OFF_COURSE_RADIUS = 80;
42
- export const RESET_HOVER_Y = 6; // altitude of reset hover
43
 
44
  // ── Camera ───────────────────────────────────────────────
45
- export const FOV_FPV = 95; // wide FOV like a real FPV quad
46
- export const FOV_CHASE = 70;
47
- export const CAMERA_TILT = 0.42; // radians forward-tilt in FPV (~24Β°)
48
- export const CHASE_DIST = 5.2;
49
- export const CHASE_HEIGHT = 1.8;
50
 
51
  // ── World ────────────────────────────────────────────────
52
- export const SUN_BASE_ANGLE = 0.12; // sun elevation at race start (rad)
53
- export const SUN_DROP_PER_LAP = 0.04; // sun descends each completed lap
54
- export const FOG_NEAR = 40;
55
- export const FOG_FAR = 420;
56
 
57
  // ── Colors ───────────────────────────────────────────────
58
- export const C_NEXT_GATE = 0x00eaff; // highlighted next gate
59
- export const C_FUTURE_GATE = 0xff6a3d; // upcoming dim gates
60
- export const C_DONE_GATE = 0x6b3a66; // already-passed gates
61
  export const C_FLASH = 0xffffff;
 
62
 
63
  // ── Utilities ────────────────────────────────────────────
64
  export function clamp(v, lo, hi) { return v < lo ? lo : (v > hi ? hi : v); }
65
  export function lerp(a, b, t) { return a + (b - a) * t; }
66
  export function rand(a, b) { return a + Math.random() * (b - a); }
67
 
68
- // Exponential smoothing that is framerate-independent:
69
- // result approaches `target` with time constant `tau` seconds.
70
  export function smoothDamp(current, target, tau, dt) {
71
  if (tau <= 0) return target;
72
  const alpha = 1 - Math.exp(-dt / tau);
 
7
  // ── Drone physics ────────────────────────────────────────
8
  // Thrust magnitude when throttle = 1.0 (gravity is 9.81,
9
  // so MAX_THRUST > 9.81 means we can hover + climb).
10
+ // Hover throttle β‰ˆ GRAVITY / MAX_THRUST.
11
+ export const MAX_THRUST = 17; // ~58% hover, 42% headroom
12
+ export const GRAVITY = 9.81;
13
+ export const DRAG_LINEAR = 0.9; // stronger air drag for stable feel
14
+ export const DRAG_ANGULAR = 5.5;
15
 
16
+ // Input β†’ target angular-rate. Tuned down from raw acro
17
+ // to keep the sim readable with keyboard-only input.
18
+ export const PITCH_RATE = 2.4;
19
+ export const YAW_RATE = 1.9;
20
+ export const ROLL_RATE = 2.8;
21
 
22
+ // How quickly commanded rates override current angular
23
+ // velocity. Softer than competitive acro.
24
+ export const RATE_RESPONSE = 6.5;
25
 
26
  // Throttle slew so spamming Space/Shift doesn't teleport thrust.
27
+ export const THROTTLE_SLEW = 1.6;
28
+
29
+ // Default hover throttle (applied on spawn + reset).
30
+ export const HOVER_THROTTLE = 0.60;
31
 
32
  // ── Track / gates ────────────────────────────────────────
33
  export const GATE_COUNT = 10;
34
+ export const GATE_RADIUS = 5.0; // slightly bigger β€” more forgiving
35
+ export const GATE_TUBE = 0.38;
36
  export const TOTAL_LAPS = 3;
37
 
38
+ // Gate pass detection tolerance along the normal axis.
39
+ export const GATE_PLANE_EPS = 0.2;
 
40
 
41
  // ── Off-course recovery ──────────────────────────────────
42
+ export const OFF_COURSE_RADIUS = 95;
43
+ export const RESET_HOVER_Y = 8;
 
 
 
44
 
45
  // ── Camera ───────────────────────────────────────────────
46
+ export const FOV_FPV = 90;
47
+ export const FOV_CHASE = 72;
48
+ export const CAMERA_TILT = 0.22; // mild forward tilt in FPV
49
+ export const CHASE_DIST = 7.5;
50
+ export const CHASE_HEIGHT = 2.8;
51
 
52
  // ── World ────────────────────────────────────────────────
53
+ export const SUN_BASE_ANGLE = 0.12;
54
+ export const SUN_DROP_PER_LAP = 0.035;
55
+ export const FOG_NEAR = 120; // pushed out β€” gates read at distance
56
+ export const FOG_FAR = 900;
57
 
58
  // ── Colors ───────────────────────────────────────────────
59
+ export const C_NEXT_GATE = 0x00eaff; // next gate = cyan
60
+ export const C_FUTURE_GATE = 0xff6a3d; // upcoming = warm orange
61
+ export const C_DONE_GATE = 0x5a2456; // passed = muted violet
62
  export const C_FLASH = 0xffffff;
63
+ export const C_BEACON = 0x00eaff; // sky beacon column
64
 
65
  // ── Utilities ────────────────────────────────────────────
66
  export function clamp(v, lo, hi) { return v < lo ? lo : (v > hi ? hi : v); }
67
  export function lerp(a, b, t) { return a + (b - a) * t; }
68
  export function rand(a, b) { return a + Math.random() * (b - a); }
69
 
 
 
70
  export function smoothDamp(current, target, tau, dt) {
71
  if (tau <= 0) return target;
72
  const alpha = 1 - Math.exp(-dt / tau);
js/drone-physics.js CHANGED
@@ -8,65 +8,59 @@ import * as THREE from 'three';
8
  import {
9
  MAX_THRUST, GRAVITY, DRAG_LINEAR, DRAG_ANGULAR,
10
  PITCH_RATE, YAW_RATE, ROLL_RATE, RATE_RESPONSE,
11
- THROTTLE_SLEW, clamp,
12
  } from './config.js';
13
 
14
- const UP_LOCAL = new THREE.Vector3(0, 1, 0); // drone thrust axis
15
- const GRAVITY_VEC = new THREE.Vector3(0, -GRAVITY, 0);
16
 
17
- export function createDrone(initialPos, initialHeading = 0) {
 
 
 
 
 
 
 
 
 
18
  const state = {
19
  position: initialPos.clone(),
20
  velocity: new THREE.Vector3(),
21
- // Start upright, facing +Z rotated by initialHeading around Y.
22
- quaternion: new THREE.Quaternion().setFromAxisAngle(
23
- new THREE.Vector3(0, 1, 0), initialHeading
24
- ),
25
- angularVelocity: new THREE.Vector3(), // rad/s in body frame
26
- throttle: 0.0,
27
- // Propeller visual spin, not physical β€” just a fast RPM counter.
28
  propPhase: 0,
29
  };
30
 
31
- // Reusable scratch objects to avoid per-frame allocation in the loop.
32
  const _thrustLocal = new THREE.Vector3();
33
  const _thrustWorld = new THREE.Vector3();
34
  const _dq = new THREE.Quaternion();
35
  const _euler = new THREE.Euler();
36
- const _axis = new THREE.Vector3();
37
 
38
- /**
39
- * Advance one physics step.
40
- * @param {object} input - { pitch, yaw, roll, throttleDelta } all in [-1, 1]
41
- * @param {number} dt - seconds
42
- */
43
  function step(input, dt) {
44
  if (dt <= 0) return;
45
 
46
- // ── 1. Throttle slew ─────────────────────────────────
47
- const throttleTarget = clamp(state.throttle + input.throttleDelta * THROTTLE_SLEW * dt, 0, 1);
48
- state.throttle = throttleTarget;
 
49
 
50
- // ── 2. Angular velocity ──────────────────────────────
51
- // Commanded body rates from input (acro mode: no auto-level).
52
- const cmdPitch = input.pitch * PITCH_RATE;
53
- const cmdYaw = input.yaw * YAW_RATE;
54
- const cmdRoll = input.roll * ROLL_RATE;
55
 
56
- // Smoothly approach commanded rates. Using framerate-independent
57
- // exponential lerp: rate = 1 - exp(-RATE_RESPONSE*dt).
58
  const rateAlpha = 1 - Math.exp(-RATE_RESPONSE * dt);
59
  state.angularVelocity.x += (cmdPitch - state.angularVelocity.x) * rateAlpha;
60
  state.angularVelocity.y += (cmdYaw - state.angularVelocity.y) * rateAlpha;
61
  state.angularVelocity.z += (cmdRoll - state.angularVelocity.z) * rateAlpha;
62
 
63
- // Angular drag (small β€” drone keeps spinning in acro).
64
- const angDrag = Math.max(0, 1 - DRAG_ANGULAR * dt * 0.25);
65
  state.angularVelocity.multiplyScalar(angDrag);
66
 
67
- // ── 3. Integrate orientation ─────────────────────────
68
- // Build the small-rotation quaternion for this step from the
69
- // body-frame angular velocity, then right-multiply (body-local).
70
  _euler.set(
71
  state.angularVelocity.x * dt,
72
  state.angularVelocity.y * dt,
@@ -77,17 +71,15 @@ export function createDrone(initialPos, initialHeading = 0) {
77
  state.quaternion.multiply(_dq);
78
  state.quaternion.normalize();
79
 
80
- // ── 4. Forces ────────────────────────────────────────
81
- // Thrust along the drone's local +Y, transformed to world space.
82
  _thrustLocal.copy(UP_LOCAL).multiplyScalar(state.throttle * MAX_THRUST);
83
  _thrustWorld.copy(_thrustLocal).applyQuaternion(state.quaternion);
84
 
85
- // a = thrust + gravity - linear drag proportional to velocity
86
  const ax = _thrustWorld.x - state.velocity.x * DRAG_LINEAR;
87
- const ay = _thrustWorld.y + GRAVITY_VEC.y - state.velocity.y * DRAG_LINEAR;
88
  const az = _thrustWorld.z - state.velocity.z * DRAG_LINEAR;
89
 
90
- // ── 5. Integrate velocity + position ─────────────────
91
  state.velocity.x += ax * dt;
92
  state.velocity.y += ay * dt;
93
  state.velocity.z += az * dt;
@@ -96,30 +88,24 @@ export function createDrone(initialPos, initialHeading = 0) {
96
  state.position.y += state.velocity.y * dt;
97
  state.position.z += state.velocity.z * dt;
98
 
99
- // ── 6. Prop phase (visual only) ──────────────────────
100
- // Spin speed tracks thrust intensity; min spin so idle props still turn.
101
- state.propPhase += (6 + state.throttle * 60) * dt;
102
 
103
- // ── 7. Soft ground collision ─────────────────────────
104
- // Bounce weakly on the floor so you can't tunnel; a proper
105
- // crash model would destroy the drone, but for the MVP we
106
- // just clamp to y=0 and kill vertical velocity.
107
- if (state.position.y < 0.2) {
108
- state.position.y = 0.2;
109
  if (state.velocity.y < 0) state.velocity.y = 0;
110
  }
111
  }
112
 
113
- /** Reset the drone to a hover at a given world position + heading. */
114
- function resetToHover(pos, heading) {
115
  state.position.copy(pos);
116
  state.velocity.set(0, 0, 0);
117
- state.quaternion.setFromAxisAngle(new THREE.Vector3(0, 1, 0), heading);
118
  state.angularVelocity.set(0, 0, 0);
119
- state.throttle = 0.55; // mid-throttle so you don't fall on reset
120
  }
121
 
122
- /** Forward unit vector in world space (drone nose direction). */
123
  function getForward(out) {
124
  out.set(0, 0, -1).applyQuaternion(state.quaternion);
125
  return out;
 
8
  import {
9
  MAX_THRUST, GRAVITY, DRAG_LINEAR, DRAG_ANGULAR,
10
  PITCH_RATE, YAW_RATE, ROLL_RATE, RATE_RESPONSE,
11
+ THROTTLE_SLEW, HOVER_THROTTLE, clamp,
12
  } from './config.js';
13
 
14
+ const UP_LOCAL = new THREE.Vector3(0, 1, 0);
15
+ const Y_AXIS = new THREE.Vector3(0, 1, 0);
16
 
17
+ /**
18
+ * Build a drone quaternion for a given yaw heading where
19
+ * heading = 0 means facing -Z (so the drone looks down the
20
+ * course on spawn when the first gate is in -Z direction).
21
+ */
22
+ function quatFromHeading(heading) {
23
+ return new THREE.Quaternion().setFromAxisAngle(Y_AXIS, heading);
24
+ }
25
+
26
+ export function createDrone(initialPos, initialHeading = 0, initialThrottle = HOVER_THROTTLE) {
27
  const state = {
28
  position: initialPos.clone(),
29
  velocity: new THREE.Vector3(),
30
+ quaternion: quatFromHeading(initialHeading),
31
+ angularVelocity: new THREE.Vector3(),
32
+ throttle: initialThrottle,
 
 
 
 
33
  propPhase: 0,
34
  };
35
 
36
+ // Reusable scratch.
37
  const _thrustLocal = new THREE.Vector3();
38
  const _thrustWorld = new THREE.Vector3();
39
  const _dq = new THREE.Quaternion();
40
  const _euler = new THREE.Euler();
 
41
 
 
 
 
 
 
42
  function step(input, dt) {
43
  if (dt <= 0) return;
44
 
45
+ // 1. Throttle slew
46
+ state.throttle = clamp(
47
+ state.throttle + input.throttleDelta * THROTTLE_SLEW * dt, 0, 1
48
+ );
49
 
50
+ // 2. Angular rate command
51
+ const cmdPitch = input.pitch * PITCH_RATE;
52
+ const cmdYaw = input.yaw * YAW_RATE;
53
+ const cmdRoll = input.roll * ROLL_RATE;
 
54
 
 
 
55
  const rateAlpha = 1 - Math.exp(-RATE_RESPONSE * dt);
56
  state.angularVelocity.x += (cmdPitch - state.angularVelocity.x) * rateAlpha;
57
  state.angularVelocity.y += (cmdYaw - state.angularVelocity.y) * rateAlpha;
58
  state.angularVelocity.z += (cmdRoll - state.angularVelocity.z) * rateAlpha;
59
 
60
+ const angDrag = Math.max(0, 1 - DRAG_ANGULAR * dt * 0.12);
 
61
  state.angularVelocity.multiplyScalar(angDrag);
62
 
63
+ // 3. Integrate orientation (body-frame small-angle)
 
 
64
  _euler.set(
65
  state.angularVelocity.x * dt,
66
  state.angularVelocity.y * dt,
 
71
  state.quaternion.multiply(_dq);
72
  state.quaternion.normalize();
73
 
74
+ // 4. Forces β€” thrust along drone's local +Y to world
 
75
  _thrustLocal.copy(UP_LOCAL).multiplyScalar(state.throttle * MAX_THRUST);
76
  _thrustWorld.copy(_thrustLocal).applyQuaternion(state.quaternion);
77
 
 
78
  const ax = _thrustWorld.x - state.velocity.x * DRAG_LINEAR;
79
+ const ay = _thrustWorld.y - GRAVITY - state.velocity.y * DRAG_LINEAR;
80
  const az = _thrustWorld.z - state.velocity.z * DRAG_LINEAR;
81
 
82
+ // 5. Integrate velocity + position
83
  state.velocity.x += ax * dt;
84
  state.velocity.y += ay * dt;
85
  state.velocity.z += az * dt;
 
88
  state.position.y += state.velocity.y * dt;
89
  state.position.z += state.velocity.z * dt;
90
 
91
+ // 6. Prop phase
92
+ state.propPhase += (8 + state.throttle * 70) * dt;
 
93
 
94
+ // 7. Ground clamp
95
+ if (state.position.y < 0.3) {
96
+ state.position.y = 0.3;
 
 
 
97
  if (state.velocity.y < 0) state.velocity.y = 0;
98
  }
99
  }
100
 
101
+ function resetToHover(pos, heading, throttle = HOVER_THROTTLE) {
 
102
  state.position.copy(pos);
103
  state.velocity.set(0, 0, 0);
104
+ state.quaternion.copy(quatFromHeading(heading));
105
  state.angularVelocity.set(0, 0, 0);
106
+ state.throttle = throttle;
107
  }
108
 
 
109
  function getForward(out) {
110
  out.set(0, 0, -1).applyQuaternion(state.quaternion);
111
  return out;
js/environment.js CHANGED
@@ -10,7 +10,8 @@ import {
10
 
11
  export function createEnvironment(scene, renderer) {
12
  // ── Background fog for depth cues ─────────────────────
13
- scene.fog = new THREE.Fog(0x3a1048, FOG_NEAR, FOG_FAR);
 
14
 
15
  // ── Sky dome: vertical sunset gradient on the inside of
16
  // a large inverted sphere (shader-free, works on HF
@@ -116,10 +117,16 @@ export function createEnvironment(scene, renderer) {
116
  scene.add(mountainGroup);
117
 
118
  // ── Lighting ──────────────────────────────────────────
119
- const ambient = new THREE.AmbientLight(0xffb98a, 0.55);
 
 
120
  scene.add(ambient);
121
 
122
- const sun = new THREE.DirectionalLight(0xffd2a0, 1.35);
 
 
 
 
123
  sun.position.set(120, 90, 200);
124
  sun.castShadow = true;
125
  sun.shadow.mapSize.set(1024, 1024);
 
10
 
11
  export function createEnvironment(scene, renderer) {
12
  // ── Background fog for depth cues ─────────────────────
13
+ // Warm sunset tint so distant geometry blends with sky.
14
+ scene.fog = new THREE.Fog(0x5a1838, FOG_NEAR, FOG_FAR);
15
 
16
  // ── Sky dome: vertical sunset gradient on the inside of
17
  // a large inverted sphere (shader-free, works on HF
 
117
  scene.add(mountainGroup);
118
 
119
  // ── Lighting ──────────────────────────────────────────
120
+ // Brighter ambient so drone + gates stay readable even when
121
+ // the sunset casts long shadows across them.
122
+ const ambient = new THREE.AmbientLight(0xffc99a, 1.05);
123
  scene.add(ambient);
124
 
125
+ // Hemispheric fill β€” sky-to-ground color bleed for free contrast.
126
+ const hemi = new THREE.HemisphereLight(0xffd0a0, 0x2a0a22, 0.6);
127
+ scene.add(hemi);
128
+
129
+ const sun = new THREE.DirectionalLight(0xffe2b0, 1.75);
130
  sun.position.set(120, 90, 200);
131
  sun.castShadow = true;
132
  sun.shadow.mapSize.set(1024, 1024);
js/game.js CHANGED
@@ -9,7 +9,7 @@ import * as THREE from 'three';
9
  import {
10
  TOTAL_LAPS, FOV_FPV, FOV_CHASE, CAMERA_TILT,
11
  CHASE_DIST, CHASE_HEIGHT, OFF_COURSE_RADIUS, RESET_HOVER_Y,
12
- clamp, smoothDamp,
13
  } from './config.js';
14
  import { createDrone } from './drone-physics.js';
15
  import { createDroneMesh } from './drone-mesh.js';
@@ -28,23 +28,31 @@ renderer.setSize(window.innerWidth, window.innerHeight, false);
28
  renderer.shadowMap.enabled = true;
29
  renderer.shadowMap.type = THREE.PCFSoftShadowMap;
30
  renderer.toneMapping = THREE.ACESFilmicToneMapping;
31
- renderer.toneMappingExposure = 1.1;
32
 
33
  const scene = new THREE.Scene();
 
34
  const camera = new THREE.PerspectiveCamera(
35
- FOV_FPV, window.innerWidth / window.innerHeight, 0.1, 1200
36
  );
37
- camera.position.set(0, 8, 50);
38
 
39
  // ── World + drone + track ───────────────────────────────
40
  const env = createEnvironment(scene, renderer);
41
  const droneMesh = createDroneMesh();
42
  scene.add(droneMesh.group);
43
 
44
- const spawnPos = new THREE.Vector3(0, 6, 56);
45
- const drone = createDrone(spawnPos, Math.PI); // facing -Z (into the course)
 
 
46
  const track = createGateTrack(scene);
47
 
 
 
 
 
 
 
48
  // ── HUD + sound ─────────────────────────────────────────
49
  const hud = createHUD();
50
  const sound = createDroneSound();
@@ -58,10 +66,11 @@ const gameState = {
58
  currentLapStartTime: 0,
59
  bestLapTime: null,
60
  lastLapTime: null,
61
- lapSplits: [], // times at which each gate was passed on the best lap
62
  currentSplits: [],
63
  finishTime: null,
64
- cameraMode: 'fpv', // 'fpv' | 'chase'
 
65
  offCourseFlash: 0,
66
  };
67
 
@@ -73,7 +82,7 @@ window.addEventListener('keydown', (e) => {
73
  if (e.code === 'KeyR') resetRace();
74
  if (e.code === 'Tab') {
75
  e.preventDefault();
76
- gameState.cameraMode = gameState.cameraMode === 'fpv' ? 'chase' : 'fpv';
77
  camera.fov = gameState.cameraMode === 'fpv' ? FOV_FPV : FOV_CHASE;
78
  camera.updateProjectionMatrix();
79
  }
@@ -185,37 +194,72 @@ function sampleInput() {
185
  }
186
 
187
  // ── Camera follow ───────────────────────────────────────
188
- const cameraOffset = new THREE.Vector3();
189
- const cameraLookAt = new THREE.Vector3();
190
- const tiltQuat = new THREE.Quaternion().setFromAxisAngle(
191
- new THREE.Vector3(1, 0, 0), -CAMERA_TILT
192
- );
193
- const fpvLocalOffset = new THREE.Vector3(0, 0.2, 0);
 
 
 
 
 
 
 
 
 
 
 
194
 
195
  function updateCamera(dt) {
196
- if (gameState.cameraMode === 'fpv') {
197
- // Lock camera to drone with slight forward tilt.
198
- cameraOffset.copy(fpvLocalOffset).applyQuaternion(drone.state.quaternion);
199
- camera.position.copy(drone.state.position).add(cameraOffset);
200
- const targetQuat = drone.state.quaternion.clone().multiply(tiltQuat);
201
- camera.quaternion.slerp(targetQuat, 0.35);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
202
  } else {
203
- // Chase cam: trail behind the drone, look at it.
204
- const fwd = new THREE.Vector3(0, 0, -1).applyQuaternion(drone.state.quaternion);
205
- const desired = drone.state.position.clone()
206
- .sub(fwd.clone().multiplyScalar(CHASE_DIST))
207
- .add(new THREE.Vector3(0, CHASE_HEIGHT, 0));
208
- camera.position.x = smoothDamp(camera.position.x, desired.x, 0.12, dt);
209
- camera.position.y = smoothDamp(camera.position.y, desired.y, 0.12, dt);
210
- camera.position.z = smoothDamp(camera.position.z, desired.z, 0.12, dt);
211
- cameraLookAt.copy(drone.state.position).add(fwd.clone().multiplyScalar(4));
212
- camera.lookAt(cameraLookAt);
 
 
 
 
 
 
 
 
 
213
  }
214
  }
215
 
216
  // ── Race logic ──────────────────────────────────────────
217
  function resetRace() {
218
- drone.resetToHover(spawnPos, Math.PI);
219
  track.resetForLap();
220
  gameState.phase = 'countdown';
221
  gameState.countdown = 3.2;
@@ -227,15 +271,17 @@ function resetRace() {
227
  }
228
 
229
  function resetToCheckpoint() {
230
- // Snap back to just before the next gate with a small offset.
 
231
  const next = track.getNextGate();
232
  if (!next) return;
233
- const back = next.normal.clone().multiplyScalar(14);
234
  const pos = next.position.clone().add(back);
235
  pos.y = Math.max(pos.y, RESET_HOVER_Y);
236
- // Heading = toward gate
237
- const heading = Math.atan2(-next.normal.x, -next.normal.z);
238
- drone.resetToHover(pos, heading);
 
239
  gameState.offCourseFlash = 1.5;
240
  }
241
 
@@ -389,7 +435,10 @@ function loop() {
389
  if (dt > 0.1) dt = 0.1; // cap after tab-switch
390
  accumulator += dt;
391
 
392
- // Countdown phase
 
 
 
393
  if (gameState.phase === 'countdown') {
394
  gameState.countdown -= dt;
395
  updateCountdown();
@@ -399,13 +448,18 @@ function loop() {
399
  gameState.currentLapStartTime = now;
400
  sound.start();
401
  }
402
- // Still render the scene behind the countdown.
 
 
 
 
403
  renderer.render(scene, camera);
404
  return;
405
  }
406
 
407
- // Pause phase: just draw what we had.
408
  if (gameState.phase === 'paused') {
 
409
  renderer.render(scene, camera);
410
  return;
411
  }
@@ -454,15 +508,18 @@ function loop() {
454
  const speed = drone.state.velocity.length();
455
  sound.update(drone.state.throttle, speed, dt);
456
 
457
- // HUD
458
- const euler = new THREE.Euler().setFromQuaternion(drone.state.quaternion, 'YXZ');
459
- const pitchRad = euler.x;
460
- const rollRad = euler.z;
 
 
 
461
  const altitude = drone.state.position.y;
462
  const curLapTime = gameState.phase === 'racing'
463
  ? now - gameState.currentLapStartTime : 0;
464
 
465
- // Real-time delta vs best: compare current split to best split at same gate index.
466
  let delta = null;
467
  if (gameState.bestLapTime !== null && gameState.phase === 'racing') {
468
  const idx = track.getNextGateIndex();
@@ -473,6 +530,26 @@ function loop() {
473
 
474
  if (gameState.offCourseFlash > 0) gameState.offCourseFlash -= dt;
475
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
476
  hud.draw({
477
  speed, throttle: drone.state.throttle, altitude,
478
  pitch: pitchRad, roll: rollRad,
@@ -483,6 +560,7 @@ function loop() {
483
  bestLapTime: gameState.bestLapTime,
484
  deltaVsBest: delta,
485
  offCourseFlash: gameState.offCourseFlash > 0,
 
486
  });
487
 
488
  renderer.render(scene, camera);
 
9
  import {
10
  TOTAL_LAPS, FOV_FPV, FOV_CHASE, CAMERA_TILT,
11
  CHASE_DIST, CHASE_HEIGHT, OFF_COURSE_RADIUS, RESET_HOVER_Y,
12
+ HOVER_THROTTLE, clamp, smoothDamp,
13
  } from './config.js';
14
  import { createDrone } from './drone-physics.js';
15
  import { createDroneMesh } from './drone-mesh.js';
 
28
  renderer.shadowMap.enabled = true;
29
  renderer.shadowMap.type = THREE.PCFSoftShadowMap;
30
  renderer.toneMapping = THREE.ACESFilmicToneMapping;
31
+ renderer.toneMappingExposure = 1.25;
32
 
33
  const scene = new THREE.Scene();
34
+ // Start with chase FOV since chase is the default camera now.
35
  const camera = new THREE.PerspectiveCamera(
36
+ FOV_CHASE, window.innerWidth / window.innerHeight, 0.1, 2000
37
  );
 
38
 
39
  // ── World + drone + track ───────────────────────────────
40
  const env = createEnvironment(scene, renderer);
41
  const droneMesh = createDroneMesh();
42
  scene.add(droneMesh.group);
43
 
44
+ // Spawn matches gate-track's SPAWN_POS so orientation math agrees.
45
+ // Heading = 0 means drone faces -Z (toward the first gate).
46
+ const spawnPos = new THREE.Vector3(0, 8, 60);
47
+ const drone = createDrone(spawnPos, 0, HOVER_THROTTLE);
48
  const track = createGateTrack(scene);
49
 
50
+ // Park the camera behind the drone at startup so the very
51
+ // first frame (before the loop runs updateCamera) composes
52
+ // a sensible shot instead of a random origin view.
53
+ camera.position.set(spawnPos.x, spawnPos.y + CHASE_HEIGHT, spawnPos.z + CHASE_DIST);
54
+ camera.lookAt(spawnPos.x, spawnPos.y + 0.5, spawnPos.z - 6);
55
+
56
  // ── HUD + sound ─────────────────────────────────────────
57
  const hud = createHUD();
58
  const sound = createDroneSound();
 
66
  currentLapStartTime: 0,
67
  bestLapTime: null,
68
  lastLapTime: null,
69
+ lapSplits: [],
70
  currentSplits: [],
71
  finishTime: null,
72
+ // Chase camera is the default β€” FPV is disorienting for new players.
73
+ cameraMode: 'chase', // 'chase' | 'fpv'
74
  offCourseFlash: 0,
75
  };
76
 
 
82
  if (e.code === 'KeyR') resetRace();
83
  if (e.code === 'Tab') {
84
  e.preventDefault();
85
+ gameState.cameraMode = gameState.cameraMode === 'chase' ? 'fpv' : 'chase';
86
  camera.fov = gameState.cameraMode === 'fpv' ? FOV_FPV : FOV_CHASE;
87
  camera.updateProjectionMatrix();
88
  }
 
194
  }
195
 
196
  // ── Camera follow ───────────────────────────────────────
197
+ // Both FPV and chase modes derive a *yaw-only* heading from
198
+ // the drone so the camera horizon stays stable regardless of
199
+ // how hard the drone is rolling. This is the single biggest
200
+ // "FPV comfort" fix β€” raw quaternion coupling makes the world
201
+ // spin when the drone banks.
202
+ const _camFwd = new THREE.Vector3();
203
+ const _camDesired = new THREE.Vector3();
204
+ const _camLookAt = new THREE.Vector3();
205
+ const _camUp = new THREE.Vector3(0, 1, 0);
206
+
207
+ function getDroneYawForward() {
208
+ // Project the drone's local -Z onto the XZ plane and renormalize.
209
+ _camFwd.set(0, 0, -1).applyQuaternion(drone.state.quaternion);
210
+ _camFwd.y = 0;
211
+ if (_camFwd.lengthSq() < 0.001) _camFwd.set(0, 0, -1);
212
+ return _camFwd.normalize();
213
+ }
214
 
215
  function updateCamera(dt) {
216
+ const fwd = getDroneYawForward();
217
+
218
+ if (gameState.cameraMode === 'chase') {
219
+ // Trail behind the drone on a horizontal plane with a
220
+ // fixed height offset. Smoothly damped so the cam lags
221
+ // the drone gracefully instead of snapping.
222
+ _camDesired.copy(drone.state.position)
223
+ .addScaledVector(fwd, -CHASE_DIST);
224
+ _camDesired.y += CHASE_HEIGHT;
225
+
226
+ const tau = 0.18;
227
+ camera.position.x = smoothDamp(camera.position.x, _camDesired.x, tau, dt);
228
+ camera.position.y = smoothDamp(camera.position.y, _camDesired.y, tau, dt);
229
+ camera.position.z = smoothDamp(camera.position.z, _camDesired.z, tau, dt);
230
+
231
+ // Look slightly ahead of the drone (gives a sense of speed).
232
+ _camLookAt.copy(drone.state.position)
233
+ .addScaledVector(fwd, 6)
234
+ .add(new THREE.Vector3(0, 0.5, 0));
235
+ camera.up.copy(_camUp);
236
+ camera.lookAt(_camLookAt);
237
  } else {
238
+ // Stable FPV β€” camera sits at drone position + tiny offset,
239
+ // looks along the drone's *yaw-only* forward vector with a
240
+ // slight downward pitch from the pitch component. No roll.
241
+ camera.position.copy(drone.state.position);
242
+ camera.position.y += 0.35;
243
+
244
+ // Extract pitch from the full quaternion so the camera still
245
+ // pitches up/down with the drone (useful for climbing/diving).
246
+ const fullFwd = new THREE.Vector3(0, 0, -1).applyQuaternion(drone.state.quaternion);
247
+ const pitch = Math.asin(clamp(fullFwd.y, -1, 1));
248
+ const pitchFwd = new THREE.Vector3(
249
+ fwd.x * Math.cos(pitch * 0.6),
250
+ Math.sin(pitch * 0.6) - CAMERA_TILT,
251
+ fwd.z * Math.cos(pitch * 0.6)
252
+ ).normalize();
253
+
254
+ _camLookAt.copy(drone.state.position).addScaledVector(pitchFwd, 10);
255
+ camera.up.copy(_camUp);
256
+ camera.lookAt(_camLookAt);
257
  }
258
  }
259
 
260
  // ── Race logic ──────────────────────────────────────────
261
  function resetRace() {
262
+ drone.resetToHover(spawnPos, 0, HOVER_THROTTLE);
263
  track.resetForLap();
264
  gameState.phase = 'countdown';
265
  gameState.countdown = 3.2;
 
271
  }
272
 
273
  function resetToCheckpoint() {
274
+ // Snap back to 14m *in front* of the next gate (i.e. against the
275
+ // travel direction) so the drone re-approaches it head-on.
276
  const next = track.getNextGate();
277
  if (!next) return;
278
+ const back = next.forward.clone().multiplyScalar(-14);
279
  const pos = next.position.clone().add(back);
280
  pos.y = Math.max(pos.y, RESET_HOVER_Y);
281
+ // Heading such that drone faces along the gate's travel direction.
282
+ // forward = (-sin h, 0, -cos h) => h = atan2(-forward.x, -forward.z)
283
+ const heading = Math.atan2(-next.forward.x, -next.forward.z);
284
+ drone.resetToHover(pos, heading, HOVER_THROTTLE);
285
  gameState.offCourseFlash = 1.5;
286
  }
287
 
 
435
  if (dt > 0.1) dt = 0.1; // cap after tab-switch
436
  accumulator += dt;
437
 
438
+ // Countdown phase β€” physics paused, but we still need to
439
+ // sync the visual mesh + drive the camera so the player
440
+ // sees the drone sitting on the line instead of an empty
441
+ // origin shot.
442
  if (gameState.phase === 'countdown') {
443
  gameState.countdown -= dt;
444
  updateCountdown();
 
448
  gameState.currentLapStartTime = now;
449
  sound.start();
450
  }
451
+ droneMesh.group.position.copy(drone.state.position);
452
+ droneMesh.group.quaternion.copy(drone.state.quaternion);
453
+ droneMesh.spinProps(drone.state.propPhase += dt * 12);
454
+ track.update(drone.state.position, drone.state.position, dt);
455
+ updateCamera(dt);
456
  renderer.render(scene, camera);
457
  return;
458
  }
459
 
460
+ // Pause phase: still drive the camera so it doesn't freeze oddly.
461
  if (gameState.phase === 'paused') {
462
+ updateCamera(dt);
463
  renderer.render(scene, camera);
464
  return;
465
  }
 
508
  const speed = drone.state.velocity.length();
509
  sound.update(drone.state.throttle, speed, dt);
510
 
511
+ // HUD β€” decompose drone orientation into pitch/roll. Use
512
+ // forward+right vectors instead of Euler extraction which
513
+ // has gimbal-lock artefacts at high pitch angles.
514
+ const fwdVec = new THREE.Vector3(0, 0, -1).applyQuaternion(drone.state.quaternion);
515
+ const rightVec = new THREE.Vector3(1, 0, 0).applyQuaternion(drone.state.quaternion);
516
+ const pitchRad = Math.asin(clamp(fwdVec.y, -1, 1));
517
+ const rollRad = Math.asin(clamp(rightVec.y, -1, 1));
518
  const altitude = drone.state.position.y;
519
  const curLapTime = gameState.phase === 'racing'
520
  ? now - gameState.currentLapStartTime : 0;
521
 
522
+ // Real-time delta vs best.
523
  let delta = null;
524
  if (gameState.bestLapTime !== null && gameState.phase === 'racing') {
525
  const idx = track.getNextGateIndex();
 
530
 
531
  if (gameState.offCourseFlash > 0) gameState.offCourseFlash -= dt;
532
 
533
+ // Next-gate direction arrow: compute the 2D angle from the
534
+ // drone's yaw-forward to the vector pointing at the next gate.
535
+ let gateArrowAngle = null; // radians, 0 = dead ahead, + = right
536
+ let gateDistance = null;
537
+ const nextGate = track.getNextGate();
538
+ if (nextGate) {
539
+ const dx = nextGate.position.x - drone.state.position.x;
540
+ const dz = nextGate.position.z - drone.state.position.z;
541
+ gateDistance = Math.hypot(dx, dz);
542
+ // Drone yaw-forward = fwdVec flattened to XZ plane.
543
+ const fx = fwdVec.x, fz = fwdVec.z;
544
+ const flen = Math.hypot(fx, fz) || 1;
545
+ const fnx = fx / flen, fnz = fz / flen;
546
+ // Angle between drone-forward and gate-direction in world XZ.
547
+ // Cross-product sign tells us left/right.
548
+ const dot = (fnx * dx + fnz * dz) / (gateDistance || 1);
549
+ const cross = (fnx * dz - fnz * dx); // sign only
550
+ gateArrowAngle = Math.atan2(cross, dot);
551
+ }
552
+
553
  hud.draw({
554
  speed, throttle: drone.state.throttle, altitude,
555
  pitch: pitchRad, roll: rollRad,
 
560
  bestLapTime: gameState.bestLapTime,
561
  deltaVsBest: delta,
562
  offCourseFlash: gameState.offCourseFlash > 0,
563
+ gateArrowAngle, gateDistance,
564
  });
565
 
566
  renderer.render(scene, camera);
js/gate-track.js CHANGED
@@ -1,43 +1,58 @@
1
  // ═══════════════════════════════════════════════════════
2
  // GATE TRACK β€” sequence of neon torus gates placed in 3D
3
- // space. Exposes the gate list, pass-through detection
4
- // (line-segment vs gate plane), and a per-frame update
5
- // that handles the highlight color of the "next" gate
6
- // and flash effect on clean pass.
 
7
  // ═══════════════════════════════════════════════════════
8
  import * as THREE from 'three';
9
  import {
10
  GATE_COUNT, GATE_RADIUS, GATE_TUBE, GATE_PLANE_EPS,
11
- C_NEXT_GATE, C_FUTURE_GATE, C_DONE_GATE, C_FLASH,
12
  } from './config.js';
13
 
 
 
 
 
14
  /**
15
- * Generate the gate positions + orientations for a single lap.
16
- * The track is a figure-8-ish loop with vertical variation.
 
17
  */
18
  function buildGatePath() {
19
- const gates = [];
20
- // Parametric loop: spiral-ish canyon.
21
- // Keep it tight enough to memorize, open enough to fly.
22
  const waypoints = [
23
- { x: 0, y: 6, z: 40, heading: Math.PI },
24
- { x: 28, y: 9, z: 20, heading: Math.PI * 0.65 },
25
- { x: 38, y: 14, z: -12, heading: Math.PI * 0.35 },
26
- { x: 18, y: 18, z: -38, heading: Math.PI * 0.0 },
27
- { x: -14, y: 22, z: -46, heading: -Math.PI * 0.25 },
28
- { x: -38, y: 16, z: -22, heading: -Math.PI * 0.55 },
29
- { x: -40, y: 10, z: 14, heading: -Math.PI * 0.85 },
30
- { x: -18, y: 8, z: 34, heading: Math.PI * 0.95 },
31
- { x: -2, y: 12, z: 48, heading: Math.PI },
32
- { x: 16, y: 7, z: 44, heading: Math.PI * 0.82 },
33
  ];
34
- // If GATE_COUNT ever differs, tile the waypoints.
 
35
  for (let i = 0; i < GATE_COUNT; i++) {
36
  const w = waypoints[i % waypoints.length];
 
 
 
 
 
 
 
 
 
 
37
  gates.push({
38
  index: i,
39
  position: new THREE.Vector3(w.x, w.y, w.z),
40
- heading: w.heading, // rotation around Y axis; gate faces this way
 
41
  });
42
  }
43
  return gates;
@@ -46,44 +61,78 @@ function buildGatePath() {
46
  export function createGateTrack(scene) {
47
  const gates = buildGatePath();
48
 
49
- // Shared geometry β€” one torus for all gates.
50
- const torusGeo = new THREE.TorusGeometry(GATE_RADIUS, GATE_TUBE, 10, 40);
 
 
 
 
 
51
 
52
- // Per-gate mesh + outer glow mesh.
53
  for (const g of gates) {
 
54
  const mat = new THREE.MeshBasicMaterial({ color: C_FUTURE_GATE });
55
  const mesh = new THREE.Mesh(torusGeo, mat);
56
  mesh.position.copy(g.position);
57
- // Torus lies in its local XY plane; rotate so the "opening"
58
- // faces along the gate's heading vector.
59
- mesh.rotation.y = g.heading;
60
- mesh.rotation.x = Math.PI / 2;
 
61
  scene.add(mesh);
62
  g.mesh = mesh;
63
  g.material = mat;
64
 
65
- // A fainter, slightly larger ring for glow/bloom stand-in.
66
- const glowGeo = new THREE.TorusGeometry(GATE_RADIUS + 0.25, GATE_TUBE * 0.35, 8, 40);
67
  const glowMat = new THREE.MeshBasicMaterial({
68
- color: C_FUTURE_GATE, transparent: true, opacity: 0.35,
 
69
  });
70
  const glow = new THREE.Mesh(glowGeo, glowMat);
71
  glow.position.copy(mesh.position);
72
- glow.rotation.copy(mesh.rotation);
73
  scene.add(glow);
74
  g.glow = glow;
75
-
76
- // Forward unit vector β€” used for plane math during detection.
77
- g.normal = new THREE.Vector3(0, 0, 1).applyAxisAngle(
78
- new THREE.Vector3(0, 1, 0), g.heading
79
- );
80
-
81
- // A small ember sprite placed in front of the gate (visible
82
- // for the next gate only), a hint arrow shown by game.js.
83
- g.passed = false;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
84
  }
85
 
86
- // A flash sphere used by flashAt() β€” reused between gates.
87
  const flashGeo = new THREE.SphereGeometry(GATE_RADIUS * 0.9, 24, 12);
88
  const flashMat = new THREE.MeshBasicMaterial({
89
  color: C_FLASH, transparent: true, opacity: 0.0, depthWrite: false,
@@ -93,80 +142,86 @@ export function createGateTrack(scene) {
93
  let flashTimer = 0;
94
  function triggerFlashAt(pos) {
95
  flashMesh.position.copy(pos);
96
- flashMat.opacity = 0.75;
97
  flashMesh.scale.set(0.6, 0.6, 0.6);
98
- flashTimer = 0.45; // seconds
99
  }
100
 
101
  /**
102
- * Test whether the line segment (prev β†’ curr) pierces the
103
- * upcoming gate's disc. Returns true on clean pass.
 
104
  */
105
  function testPass(prev, curr, gate) {
106
  const p = gate.position;
107
- const n = gate.normal;
108
- // Signed distances of endpoints to the gate plane.
109
- const d0 = (prev.x - p.x) * n.x + (prev.y - p.y) * n.y + (prev.z - p.z) * n.z;
110
- const d1 = (curr.x - p.x) * n.x + (curr.y - p.y) * n.y + (curr.z - p.z) * n.z;
111
-
112
- // Forward crossing only (front β†’ back relative to gate normal).
 
 
113
  if (d0 < GATE_PLANE_EPS || d1 > -GATE_PLANE_EPS) return false;
114
 
115
- // Find the intersection point on the plane.
116
- const t = d0 / (d0 - d1); // 0..1
117
  if (t < 0 || t > 1) return false;
118
  const ix = prev.x + (curr.x - prev.x) * t;
119
  const iy = prev.y + (curr.y - prev.y) * t;
120
  const iz = prev.z + (curr.z - prev.z) * t;
121
 
122
- // Radial distance from gate center at the intersection.
123
  const dx = ix - p.x;
124
  const dy = iy - p.y;
125
  const dz = iz - p.z;
126
- const dist2 = dx * dx + dy * dy + dz * dz;
127
- return dist2 <= GATE_RADIUS * GATE_RADIUS;
128
  }
129
 
130
  let nextGateIndex = 0;
131
 
132
- /**
133
- * Called each frame with the drone's previous + current positions.
134
- * If the drone passes the next gate, advances the index and
135
- * triggers visual effects. Returns the gate that was passed
136
- * (or null if none).
137
- */
138
  function update(prevPos, currPos, dt) {
139
- // Animate gate materials: breathing glow on next, dim on done.
 
 
140
  for (let i = 0; i < gates.length; i++) {
141
  const g = gates[i];
142
  const isNext = (i === nextGateIndex);
143
  const isDone = g.passed;
 
144
  if (isNext) {
145
  g.material.color.setHex(C_NEXT_GATE);
146
- g.glow.material.color.setHex(C_NEXT_GATE);
147
- const breath = 0.55 + 0.45 * Math.abs(Math.sin(performance.now() * 0.004));
148
- g.glow.material.opacity = 0.2 + 0.5 * breath;
 
 
 
 
 
149
  } else if (isDone) {
150
  g.material.color.setHex(C_DONE_GATE);
151
- g.glow.material.color.setHex(C_DONE_GATE);
152
- g.glow.material.opacity = 0.15;
 
 
153
  } else {
154
  g.material.color.setHex(C_FUTURE_GATE);
155
- g.glow.material.color.setHex(C_FUTURE_GATE);
156
- g.glow.material.opacity = 0.28;
 
 
157
  }
158
  }
159
 
160
  // Flash decay.
161
  if (flashTimer > 0) {
162
  flashTimer -= dt;
163
- const t = Math.max(0, flashTimer / 0.45);
164
- flashMat.opacity = 0.75 * t;
165
- const s = 0.6 + (1 - t) * 1.6;
166
  flashMesh.scale.set(s, s, s);
167
  }
168
 
169
- // Pass detection against just the next gate.
170
  const nextGate = gates[nextGateIndex];
171
  if (!nextGate) return null;
172
  if (testPass(prevPos, currPos, nextGate)) {
@@ -178,23 +233,18 @@ export function createGateTrack(scene) {
178
  return null;
179
  }
180
 
181
- /** Start a new lap: mark all gates unpassed, reset index. */
182
  function resetForLap() {
183
  for (const g of gates) g.passed = false;
184
  nextGateIndex = 0;
185
  }
186
 
187
- /** Get the next gate object (for HUD arrow + AI targeting). */
188
- function getNextGate() {
189
- return gates[nextGateIndex] || null;
190
- }
191
-
192
  function getNextGateIndex() { return nextGateIndex; }
193
  function getGateCount() { return gates.length; }
194
  function getAllGates() { return gates; }
195
 
196
  return {
197
  update, resetForLap, getNextGate, getNextGateIndex,
198
- getGateCount, getAllGates,
199
  };
200
  }
 
1
  // ═══════════════════════════════════════════════════════
2
  // GATE TRACK β€” sequence of neon torus gates placed in 3D
3
+ // space. Gates are oriented by computing a "travel
4
+ // direction" from the previous waypoint to the current
5
+ // one (so the drone always approaches head-on). Next
6
+ // gate gets a tall sky beacon column and a breathing
7
+ // glow so it reads from anywhere on the map.
8
  // ═══════════════════════════════════════════════════════
9
  import * as THREE from 'three';
10
  import {
11
  GATE_COUNT, GATE_RADIUS, GATE_TUBE, GATE_PLANE_EPS,
12
+ C_NEXT_GATE, C_FUTURE_GATE, C_DONE_GATE, C_FLASH, C_BEACON,
13
  } from './config.js';
14
 
15
+ // Spawn position is where the drone starts β€” used to orient
16
+ // the first gate so it faces the drone directly.
17
+ const SPAWN_POS = new THREE.Vector3(0, 8, 60);
18
+
19
  /**
20
+ * Raw waypoint positions. Each gate's orientation is derived
21
+ * from the vector from the previous waypoint (or spawn, for
22
+ * index 0) to this waypoint, so the course is auto-oriented.
23
  */
24
  function buildGatePath() {
 
 
 
25
  const waypoints = [
26
+ { x: 0, y: 8, z: 30 }, // 0 β€” straight ahead from spawn
27
+ { x: 16, y: 11, z: 5 }, // 1 β€” bank right
28
+ { x: 30, y: 14, z: -20 }, // 2 β€” right curve, climbing
29
+ { x: 18, y: 18, z: -44 }, // 3 β€” top of right-side climb
30
+ { x: -8, y: 22, z: -55 }, // 4 β€” apex over the center
31
+ { x: -30, y: 18, z: -40 }, // 5 β€” descending left-side
32
+ { x: -38, y: 13, z: -14 }, // 6 β€” left sweep
33
+ { x: -26, y: 9, z: 10 }, // 7 β€” coming back home
34
+ { x: -8, y: 8, z: 32 }, // 8 β€” home stretch left
35
+ { x: 12, y: 8, z: 46 }, // 9 β€” last gate, near spawn
36
  ];
37
+
38
+ const gates = [];
39
  for (let i = 0; i < GATE_COUNT; i++) {
40
  const w = waypoints[i % waypoints.length];
41
+ const prev = i === 0 ? SPAWN_POS : waypoints[(i - 1) % waypoints.length];
42
+ // Travel direction = normalized (this - prev), this is the
43
+ // direction the drone flies THROUGH the gate. Ignore the
44
+ // vertical component so gates stay upright (torus axis is
45
+ // horizontal, ring is vertical, drone flies through).
46
+ const dx = w.x - prev.x;
47
+ const dz = w.z - prev.z;
48
+ const len = Math.hypot(dx, dz) || 1;
49
+ const forward = new THREE.Vector3(dx / len, 0, dz / len);
50
+
51
  gates.push({
52
  index: i,
53
  position: new THREE.Vector3(w.x, w.y, w.z),
54
+ forward, // unit vector, direction of travel through gate
55
+ passed: false,
56
  });
57
  }
58
  return gates;
 
61
  export function createGateTrack(scene) {
62
  const gates = buildGatePath();
63
 
64
+ // Shared geometries.
65
+ const torusGeo = new THREE.TorusGeometry(GATE_RADIUS, GATE_TUBE, 12, 48);
66
+ const glowGeo = new THREE.TorusGeometry(GATE_RADIUS + 0.35, GATE_TUBE * 0.45, 10, 48);
67
+ // Beacon column: tall thin cylinder that rises from ground to sky.
68
+ const beaconGeo = new THREE.CylinderGeometry(0.45, 0.45, 80, 8, 1, true);
69
+
70
+ const _tmpTarget = new THREE.Vector3();
71
 
 
72
  for (const g of gates) {
73
+ // ── Main ring ──
74
  const mat = new THREE.MeshBasicMaterial({ color: C_FUTURE_GATE });
75
  const mesh = new THREE.Mesh(torusGeo, mat);
76
  mesh.position.copy(g.position);
77
+ // Orient torus so its axis (local +Z by default) points
78
+ // along the travel direction. lookAt rotates so local -Z
79
+ // points AT the target, so point at (pos - forward).
80
+ _tmpTarget.copy(g.position).sub(g.forward);
81
+ mesh.lookAt(_tmpTarget);
82
  scene.add(mesh);
83
  g.mesh = mesh;
84
  g.material = mat;
85
 
86
+ // ── Outer glow ring ──
 
87
  const glowMat = new THREE.MeshBasicMaterial({
88
+ color: C_FUTURE_GATE, transparent: true, opacity: 0.4,
89
+ depthWrite: false,
90
  });
91
  const glow = new THREE.Mesh(glowGeo, glowMat);
92
  glow.position.copy(mesh.position);
93
+ glow.quaternion.copy(mesh.quaternion);
94
  scene.add(glow);
95
  g.glow = glow;
96
+ g.glowMat = glowMat;
97
+
98
+ // ── Sky beacon column (visible from anywhere on map) ──
99
+ const beaconMat = new THREE.MeshBasicMaterial({
100
+ color: C_FUTURE_GATE,
101
+ transparent: true,
102
+ opacity: 0.0, // default hidden; turned on only for next gate
103
+ depthWrite: false,
104
+ side: THREE.DoubleSide,
105
+ });
106
+ const beacon = new THREE.Mesh(beaconGeo, beaconMat);
107
+ beacon.position.set(g.position.x, 40, g.position.z);
108
+ scene.add(beacon);
109
+ g.beacon = beacon;
110
+ g.beaconMat = beaconMat;
111
+
112
+ // ── Gate index label board (a thin sprite-like plane above the gate) ──
113
+ const labelCanvas = document.createElement('canvas');
114
+ labelCanvas.width = 128; labelCanvas.height = 64;
115
+ const lctx = labelCanvas.getContext('2d');
116
+ lctx.fillStyle = 'rgba(0,0,0,0)'; lctx.fillRect(0, 0, 128, 64);
117
+ lctx.fillStyle = '#ffffff';
118
+ lctx.font = '900 48px Orbitron, sans-serif';
119
+ lctx.textAlign = 'center';
120
+ lctx.textBaseline = 'middle';
121
+ lctx.shadowColor = 'rgba(0,0,0,0.9)';
122
+ lctx.shadowBlur = 6;
123
+ lctx.fillText(String(g.index + 1), 64, 34);
124
+ const labelTex = new THREE.CanvasTexture(labelCanvas);
125
+ labelTex.minFilter = THREE.LinearFilter;
126
+ const labelMat = new THREE.SpriteMaterial({ map: labelTex, transparent: true, depthTest: false });
127
+ const label = new THREE.Sprite(labelMat);
128
+ label.scale.set(3.5, 1.75, 1);
129
+ label.position.set(g.position.x, g.position.y + GATE_RADIUS + 1.2, g.position.z);
130
+ label.renderOrder = 10;
131
+ scene.add(label);
132
+ g.label = label;
133
  }
134
 
135
+ // Flash sphere reused between gates.
136
  const flashGeo = new THREE.SphereGeometry(GATE_RADIUS * 0.9, 24, 12);
137
  const flashMat = new THREE.MeshBasicMaterial({
138
  color: C_FLASH, transparent: true, opacity: 0.0, depthWrite: false,
 
142
  let flashTimer = 0;
143
  function triggerFlashAt(pos) {
144
  flashMesh.position.copy(pos);
145
+ flashMat.opacity = 0.85;
146
  flashMesh.scale.set(0.6, 0.6, 0.6);
147
+ flashTimer = 0.5;
148
  }
149
 
150
  /**
151
+ * Line segment (prev β†’ curr) vs gate disc intersection test.
152
+ * Returns true if the drone passed through this gate going
153
+ * in the correct travel direction.
154
  */
155
  function testPass(prev, curr, gate) {
156
  const p = gate.position;
157
+ const n = gate.forward;
158
+ // Signed distances β€” positive = "in front" of gate (approach side).
159
+ // A point is "in front" if it is on the -forward side of the gate,
160
+ // so we flip the sign: d = (point - gatePos) Β· (-forward).
161
+ const d0 = -((prev.x - p.x) * n.x + (prev.y - p.y) * n.y + (prev.z - p.z) * n.z);
162
+ const d1 = -((curr.x - p.x) * n.x + (curr.y - p.y) * n.y + (curr.z - p.z) * n.z);
163
+
164
+ // Drone must go from "in front" (d0>0) to "behind" (d1<0).
165
  if (d0 < GATE_PLANE_EPS || d1 > -GATE_PLANE_EPS) return false;
166
 
167
+ const t = d0 / (d0 - d1);
 
168
  if (t < 0 || t > 1) return false;
169
  const ix = prev.x + (curr.x - prev.x) * t;
170
  const iy = prev.y + (curr.y - prev.y) * t;
171
  const iz = prev.z + (curr.z - prev.z) * t;
172
 
 
173
  const dx = ix - p.x;
174
  const dy = iy - p.y;
175
  const dz = iz - p.z;
176
+ return (dx * dx + dy * dy + dz * dz) <= GATE_RADIUS * GATE_RADIUS;
 
177
  }
178
 
179
  let nextGateIndex = 0;
180
 
 
 
 
 
 
 
181
  function update(prevPos, currPos, dt) {
182
+ const t = performance.now() * 0.004;
183
+
184
+ // Breathing animation + per-state coloring.
185
  for (let i = 0; i < gates.length; i++) {
186
  const g = gates[i];
187
  const isNext = (i === nextGateIndex);
188
  const isDone = g.passed;
189
+
190
  if (isNext) {
191
  g.material.color.setHex(C_NEXT_GATE);
192
+ g.glowMat.color.setHex(C_NEXT_GATE);
193
+ const breath = 0.55 + 0.45 * Math.abs(Math.sin(t));
194
+ g.glowMat.opacity = 0.35 + 0.55 * breath;
195
+ // Beacon column on.
196
+ g.beaconMat.color.setHex(C_BEACON);
197
+ g.beaconMat.opacity = 0.18 + 0.12 * breath;
198
+ // Label bigger
199
+ g.label.scale.set(5.5, 2.75, 1);
200
  } else if (isDone) {
201
  g.material.color.setHex(C_DONE_GATE);
202
+ g.glowMat.color.setHex(C_DONE_GATE);
203
+ g.glowMat.opacity = 0.15;
204
+ g.beaconMat.opacity = 0.0;
205
+ g.label.scale.set(2.8, 1.4, 1);
206
  } else {
207
  g.material.color.setHex(C_FUTURE_GATE);
208
+ g.glowMat.color.setHex(C_FUTURE_GATE);
209
+ g.glowMat.opacity = 0.35;
210
+ g.beaconMat.opacity = 0.0;
211
+ g.label.scale.set(3.5, 1.75, 1);
212
  }
213
  }
214
 
215
  // Flash decay.
216
  if (flashTimer > 0) {
217
  flashTimer -= dt;
218
+ const f = Math.max(0, flashTimer / 0.5);
219
+ flashMat.opacity = 0.85 * f;
220
+ const s = 0.6 + (1 - f) * 2.0;
221
  flashMesh.scale.set(s, s, s);
222
  }
223
 
224
+ // Pass detection.
225
  const nextGate = gates[nextGateIndex];
226
  if (!nextGate) return null;
227
  if (testPass(prevPos, currPos, nextGate)) {
 
233
  return null;
234
  }
235
 
 
236
  function resetForLap() {
237
  for (const g of gates) g.passed = false;
238
  nextGateIndex = 0;
239
  }
240
 
241
+ function getNextGate() { return gates[nextGateIndex] || null; }
 
 
 
 
242
  function getNextGateIndex() { return nextGateIndex; }
243
  function getGateCount() { return gates.length; }
244
  function getAllGates() { return gates; }
245
 
246
  return {
247
  update, resetForLap, getNextGate, getNextGateIndex,
248
+ getGateCount, getAllGates, SPAWN_POS,
249
  };
250
  }
js/hud.js CHANGED
@@ -239,6 +239,54 @@ export function createHUD() {
239
  gctx.fillText(`${Math.round(altitude)}m`, aX, aBarY + aBarH + 14);
240
  }
241
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
242
  // ── Top overlay: lap / gate / time / delta ─────────────
243
  function drawOverlay(data) {
244
  const w = overlay.width;
@@ -291,6 +339,9 @@ export function createHUD() {
291
  octx.fillText('NO BEST YET', w - 22, 40);
292
  }
293
 
 
 
 
294
  // Off-track / reset warning (flashing)
295
  if (data.offCourseFlash) {
296
  const flash = Math.sin(performance.now() * 0.02) > 0;
@@ -315,7 +366,7 @@ export function createHUD() {
315
  // ── Main entry: called by game.js every frame ──────────
316
  function draw({ speed, throttle, altitude, pitch, roll, lap, totalLaps,
317
  gateIdx, totalGates, currentLapTime, bestLapTime,
318
- deltaVsBest, offCourseFlash }) {
319
  smoothSpeed += (speed - smoothSpeed) * 0.18;
320
  smoothAlt += (altitude - smoothAlt) * 0.3;
321
  drawAttitude(pitch, roll);
@@ -323,6 +374,7 @@ export function createHUD() {
323
  drawOverlay({
324
  lap, totalLaps, gateIdx, totalGates,
325
  currentLapTime, bestLapTime, deltaVsBest, offCourseFlash,
 
326
  });
327
  }
328
 
 
239
  gctx.fillText(`${Math.round(altitude)}m`, aX, aBarY + aBarH + 14);
240
  }
241
 
242
+ // ── Next-gate direction arrow (centred on screen) ─────
243
+ // Drawn into the overlay canvas so it composites cleanly
244
+ // above the 3D scene. Angle: 0 = dead ahead, + = right.
245
+ function drawGateArrow(angle, distance, w) {
246
+ if (angle === null || angle === undefined) return;
247
+
248
+ const cx = w / 2;
249
+ const cy = 104; // just below the time readout
250
+
251
+ // Clamp to half-screen range so the arrow always points
252
+ // somewhere sensible.
253
+ const clamped = Math.max(-Math.PI, Math.min(Math.PI, angle));
254
+
255
+ // If the gate is close to dead-ahead, show a centered triangle.
256
+ // If it's off to the side, show a directional chevron.
257
+ octx.save();
258
+ octx.translate(cx, cy);
259
+ octx.rotate(clamped);
260
+
261
+ // Pulse scale with a little "urgency" when distance is small.
262
+ const near = distance !== null && distance < 30;
263
+ const pulse = near ? 1 + 0.1 * Math.sin(performance.now() * 0.012) : 1;
264
+
265
+ octx.shadowColor = 'rgba(0, 234, 255, 0.8)';
266
+ octx.shadowBlur = 14;
267
+ octx.fillStyle = '#00eaff';
268
+ octx.beginPath();
269
+ // Arrow shape: elongated triangle pointing up (= toward gate)
270
+ const s = 18 * pulse;
271
+ octx.moveTo(0, -s * 1.3);
272
+ octx.lineTo(s, s * 0.7);
273
+ octx.lineTo(0, s * 0.2);
274
+ octx.lineTo(-s, s * 0.7);
275
+ octx.closePath();
276
+ octx.fill();
277
+ octx.restore();
278
+
279
+ // Distance readout just below the arrow
280
+ if (distance !== null) {
281
+ octx.textAlign = 'center';
282
+ octx.font = '700 12px Orbitron, sans-serif';
283
+ octx.fillStyle = 'rgba(0, 234, 255, 0.95)';
284
+ octx.shadowColor = 'rgba(0, 0, 0, 0.85)';
285
+ octx.shadowBlur = 6;
286
+ octx.fillText(`${Math.round(distance)}m`, cx, cy + 32);
287
+ }
288
+ }
289
+
290
  // ── Top overlay: lap / gate / time / delta ─────────────
291
  function drawOverlay(data) {
292
  const w = overlay.width;
 
339
  octx.fillText('NO BEST YET', w - 22, 40);
340
  }
341
 
342
+ // Next-gate arrow (points toward upcoming gate)
343
+ drawGateArrow(data.gateArrowAngle, data.gateDistance, w);
344
+
345
  // Off-track / reset warning (flashing)
346
  if (data.offCourseFlash) {
347
  const flash = Math.sin(performance.now() * 0.02) > 0;
 
366
  // ── Main entry: called by game.js every frame ──────────
367
  function draw({ speed, throttle, altitude, pitch, roll, lap, totalLaps,
368
  gateIdx, totalGates, currentLapTime, bestLapTime,
369
+ deltaVsBest, offCourseFlash, gateArrowAngle, gateDistance }) {
370
  smoothSpeed += (speed - smoothSpeed) * 0.18;
371
  smoothAlt += (altitude - smoothAlt) * 0.3;
372
  drawAttitude(pitch, roll);
 
374
  drawOverlay({
375
  lap, totalLaps, gateIdx, totalGates,
376
  currentLapTime, bestLapTime, deltaVsBest, offCourseFlash,
377
+ gateArrowAngle, gateDistance,
378
  });
379
  }
380