Comprehensive fix: controls, camera, visibility
Browse filesCritical 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.
- js/config.js +36 -36
- js/drone-physics.js +38 -52
- js/environment.js +10 -3
- js/game.js +123 -45
- js/gate-track.js +133 -83
- js/hud.js +53 -1
|
@@ -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 |
-
|
| 11 |
-
export const
|
| 12 |
-
export const
|
| 13 |
-
export const
|
|
|
|
| 14 |
|
| 15 |
-
// Input β target angular-rate
|
| 16 |
-
|
| 17 |
-
export const
|
| 18 |
-
export const
|
|
|
|
| 19 |
|
| 20 |
-
// How quickly commanded rates override current angular
|
| 21 |
-
//
|
| 22 |
-
export const RATE_RESPONSE =
|
| 23 |
|
| 24 |
// Throttle slew so spamming Space/Shift doesn't teleport thrust.
|
| 25 |
-
export const THROTTLE_SLEW =
|
|
|
|
|
|
|
|
|
|
| 26 |
|
| 27 |
// ββ Track / gates ββββββββββββββββββββββββββββββββββββββββ
|
| 28 |
export const GATE_COUNT = 10;
|
| 29 |
-
export const GATE_RADIUS =
|
| 30 |
-
export const GATE_TUBE = 0.
|
| 31 |
export const TOTAL_LAPS = 3;
|
| 32 |
|
| 33 |
-
// Gate pass detection tolerance along the normal axis
|
| 34 |
-
|
| 35 |
-
export const GATE_PLANE_EPS = 0.15;
|
| 36 |
|
| 37 |
// ββ Off-course recovery ββββββββββββββββββββββββββββββββββ
|
| 38 |
-
|
| 39 |
-
|
| 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 =
|
| 46 |
-
export const FOV_CHASE =
|
| 47 |
-
export const CAMERA_TILT = 0.
|
| 48 |
-
export const CHASE_DIST =
|
| 49 |
-
export const CHASE_HEIGHT =
|
| 50 |
|
| 51 |
// ββ World ββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 52 |
-
export const SUN_BASE_ANGLE = 0.12;
|
| 53 |
-
export const SUN_DROP_PER_LAP = 0.
|
| 54 |
-
export const FOG_NEAR =
|
| 55 |
-
export const FOG_FAR =
|
| 56 |
|
| 57 |
// ββ Colors βββββββββββββββββββββββββββββββββββββββββββββββ
|
| 58 |
-
export const C_NEXT_GATE = 0x00eaff; //
|
| 59 |
-
export const C_FUTURE_GATE = 0xff6a3d; // upcoming
|
| 60 |
-
export const C_DONE_GATE =
|
| 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);
|
|
@@ -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);
|
| 15 |
-
const
|
| 16 |
|
| 17 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 18 |
const state = {
|
| 19 |
position: initialPos.clone(),
|
| 20 |
velocity: new THREE.Vector3(),
|
| 21 |
-
|
| 22 |
-
|
| 23 |
-
|
| 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
|
| 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 |
-
//
|
| 47 |
-
|
| 48 |
-
|
|
|
|
| 49 |
|
| 50 |
-
//
|
| 51 |
-
|
| 52 |
-
const
|
| 53 |
-
const
|
| 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 |
-
|
| 64 |
-
const angDrag = Math.max(0, 1 - DRAG_ANGULAR * dt * 0.25);
|
| 65 |
state.angularVelocity.multiplyScalar(angDrag);
|
| 66 |
|
| 67 |
-
//
|
| 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 |
-
//
|
| 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
|
| 88 |
const az = _thrustWorld.z - state.velocity.z * DRAG_LINEAR;
|
| 89 |
|
| 90 |
-
//
|
| 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 |
-
//
|
| 100 |
-
|
| 101 |
-
state.propPhase += (6 + state.throttle * 60) * dt;
|
| 102 |
|
| 103 |
-
//
|
| 104 |
-
|
| 105 |
-
|
| 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 |
-
|
| 114 |
-
function resetToHover(pos, heading) {
|
| 115 |
state.position.copy(pos);
|
| 116 |
state.velocity.set(0, 0, 0);
|
| 117 |
-
state.quaternion.
|
| 118 |
state.angularVelocity.set(0, 0, 0);
|
| 119 |
-
state.throttle =
|
| 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;
|
|
@@ -10,7 +10,8 @@ import {
|
|
| 10 |
|
| 11 |
export function createEnvironment(scene, renderer) {
|
| 12 |
// ββ Background fog for depth cues βββββββββββββββββββββ
|
| 13 |
-
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
| 120 |
scene.add(ambient);
|
| 121 |
|
| 122 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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);
|
|
@@ -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.
|
| 32 |
|
| 33 |
const scene = new THREE.Scene();
|
|
|
|
| 34 |
const camera = new THREE.PerspectiveCamera(
|
| 35 |
-
|
| 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 |
-
|
| 45 |
-
|
|
|
|
|
|
|
| 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: [],
|
| 62 |
currentSplits: [],
|
| 63 |
finishTime: null,
|
| 64 |
-
|
|
|
|
| 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 === '
|
| 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 |
-
|
| 189 |
-
|
| 190 |
-
|
| 191 |
-
|
| 192 |
-
|
| 193 |
-
const
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 194 |
|
| 195 |
function updateCamera(dt) {
|
| 196 |
-
|
| 197 |
-
|
| 198 |
-
|
| 199 |
-
|
| 200 |
-
|
| 201 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 202 |
} else {
|
| 203 |
-
//
|
| 204 |
-
|
| 205 |
-
|
| 206 |
-
|
| 207 |
-
|
| 208 |
-
|
| 209 |
-
|
| 210 |
-
|
| 211 |
-
|
| 212 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 213 |
}
|
| 214 |
}
|
| 215 |
|
| 216 |
// ββ Race logic ββββββββββββββββββββββββββββββββββββββββββ
|
| 217 |
function resetRace() {
|
| 218 |
-
drone.resetToHover(spawnPos,
|
| 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
|
|
|
|
| 231 |
const next = track.getNextGate();
|
| 232 |
if (!next) return;
|
| 233 |
-
const back = next.
|
| 234 |
const pos = next.position.clone().add(back);
|
| 235 |
pos.y = Math.max(pos.y, RESET_HOVER_Y);
|
| 236 |
-
// Heading
|
| 237 |
-
|
| 238 |
-
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 403 |
renderer.render(scene, camera);
|
| 404 |
return;
|
| 405 |
}
|
| 406 |
|
| 407 |
-
// Pause phase:
|
| 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 |
-
|
| 459 |
-
|
| 460 |
-
const
|
|
|
|
|
|
|
|
|
|
| 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
|
| 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);
|
|
@@ -1,43 +1,58 @@
|
|
| 1 |
// βββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 2 |
// GATE TRACK β sequence of neon torus gates placed in 3D
|
| 3 |
-
// space.
|
| 4 |
-
//
|
| 5 |
-
//
|
| 6 |
-
//
|
|
|
|
| 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 |
-
*
|
| 16 |
-
*
|
|
|
|
| 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:
|
| 24 |
-
{ x:
|
| 25 |
-
{ x:
|
| 26 |
-
{ x: 18, y: 18, z: -
|
| 27 |
-
{ x:
|
| 28 |
-
{ x: -
|
| 29 |
-
{ x: -
|
| 30 |
-
{ x: -
|
| 31 |
-
{ x: -
|
| 32 |
-
{ x:
|
| 33 |
];
|
| 34 |
-
|
|
|
|
| 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 |
-
|
|
|
|
| 41 |
});
|
| 42 |
}
|
| 43 |
return gates;
|
|
@@ -46,44 +61,78 @@ function buildGatePath() {
|
|
| 46 |
export function createGateTrack(scene) {
|
| 47 |
const gates = buildGatePath();
|
| 48 |
|
| 49 |
-
// Shared
|
| 50 |
-
const torusGeo = new THREE.TorusGeometry(GATE_RADIUS, GATE_TUBE,
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
//
|
| 58 |
-
//
|
| 59 |
-
|
| 60 |
-
|
|
|
|
| 61 |
scene.add(mesh);
|
| 62 |
g.mesh = mesh;
|
| 63 |
g.material = mat;
|
| 64 |
|
| 65 |
-
//
|
| 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.
|
|
|
|
| 69 |
});
|
| 70 |
const glow = new THREE.Mesh(glowGeo, glowMat);
|
| 71 |
glow.position.copy(mesh.position);
|
| 72 |
-
glow.
|
| 73 |
scene.add(glow);
|
| 74 |
g.glow = glow;
|
| 75 |
-
|
| 76 |
-
|
| 77 |
-
|
| 78 |
-
|
| 79 |
-
|
| 80 |
-
|
| 81 |
-
|
| 82 |
-
|
| 83 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 84 |
}
|
| 85 |
|
| 86 |
-
//
|
| 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.
|
| 97 |
flashMesh.scale.set(0.6, 0.6, 0.6);
|
| 98 |
-
flashTimer = 0.
|
| 99 |
}
|
| 100 |
|
| 101 |
/**
|
| 102 |
-
*
|
| 103 |
-
*
|
|
|
|
| 104 |
*/
|
| 105 |
function testPass(prev, curr, gate) {
|
| 106 |
const p = gate.position;
|
| 107 |
-
const n = gate.
|
| 108 |
-
// Signed distances
|
| 109 |
-
|
| 110 |
-
|
| 111 |
-
|
| 112 |
-
|
|
|
|
|
|
|
| 113 |
if (d0 < GATE_PLANE_EPS || d1 > -GATE_PLANE_EPS) return false;
|
| 114 |
|
| 115 |
-
|
| 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 |
-
|
| 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 |
-
|
|
|
|
|
|
|
| 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.
|
| 147 |
-
const breath = 0.55 + 0.45 * Math.abs(Math.sin(
|
| 148 |
-
g.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 149 |
} else if (isDone) {
|
| 150 |
g.material.color.setHex(C_DONE_GATE);
|
| 151 |
-
g.
|
| 152 |
-
g.
|
|
|
|
|
|
|
| 153 |
} else {
|
| 154 |
g.material.color.setHex(C_FUTURE_GATE);
|
| 155 |
-
g.
|
| 156 |
-
g.
|
|
|
|
|
|
|
| 157 |
}
|
| 158 |
}
|
| 159 |
|
| 160 |
// Flash decay.
|
| 161 |
if (flashTimer > 0) {
|
| 162 |
flashTimer -= dt;
|
| 163 |
-
const
|
| 164 |
-
flashMat.opacity = 0.
|
| 165 |
-
const s = 0.6 + (1 -
|
| 166 |
flashMesh.scale.set(s, s, s);
|
| 167 |
}
|
| 168 |
|
| 169 |
-
// Pass detection
|
| 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 |
-
|
| 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 |
}
|
|
@@ -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 |
|