From c4677f46487440bc010017dc7a6a4cab7b3c51c8 Mon Sep 17 00:00:00 2001 From: Lucas Lima Date: Sun, 3 May 2026 20:54:42 -0300 Subject: [PATCH 1/2] Add multi-mode camera with transitions, speed FOV, and hood steer roll MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Cycle eagle / third / hood on Key C (edge-detected in Controls) - Smooth transitions with per-edge durations; third uses vehicle yaw chase - Third→hood two-phase path (roof approach); fix lookAt via real PerspectiveCamera - Speed-based FOV in third and hood; hood barrel roll from steering × speed - Vehicle: lateral velocity derivative (optional); pass full vehicle to Camera Co-authored-by: Cursor --- js/Camera.js | 403 +++++++++++++++++++++++++++++++++++++++++++++---- js/Controls.js | 8 +- js/Vehicle.js | 24 +++ js/main.js | 5 +- 4 files changed, 411 insertions(+), 29 deletions(-) diff --git a/js/Camera.js b/js/Camera.js index cde2d36..7f9c883 100644 --- a/js/Camera.js +++ b/js/Camera.js @@ -1,8 +1,45 @@ import * as THREE from 'three'; +export const CAMERA_MODE = { + EAGLE: 0, + THIRD: 1, + HOOD: 2, +}; + const _desired = new THREE.Vector3(); const _delta = new THREE.Vector3(); const _lookPoint = new THREE.Vector3(); +const _vehForward = new THREE.Vector3(); +const _vehRight = new THREE.Vector3(); +const _localOffset = new THREE.Vector3(); +const _goalPos = new THREE.Vector3(); +const _roofPos = new THREE.Vector3(); +const _hoodPos = new THREE.Vector3(); +const _thirdGoalPos = new THREE.Vector3(); +const _lookAt = new THREE.Vector3(); +const _tmpQuatA = new THREE.Quaternion(); +const _tmpQuatB = new THREE.Quaternion(); +const _yawQuat = new THREE.Quaternion(); +const _up = new THREE.Vector3( 0, 1, 0 ); +const _camPosSave = new THREE.Vector3(); +const _camQuatSave = new THREE.Quaternion(); + +function smootherstep( t ) { + + if ( t <= 0 ) return 0; + if ( t >= 1 ) return 1; + return t * t * t * ( t * ( t * 6 - 15 ) + 10 ); + +} + +function transitionDuration( from, to ) { + + if ( from === CAMERA_MODE.EAGLE && to === CAMERA_MODE.THIRD ) return 0.42; + if ( from === CAMERA_MODE.THIRD && to === CAMERA_MODE.HOOD ) return 0.58; + if ( from === CAMERA_MODE.HOOD && to === CAMERA_MODE.EAGLE ) return 0.48; + return 0.45; + +} export class Camera { @@ -17,18 +54,49 @@ export class Camera { this.camera.lookAt( 0, 0, 0 ); // Camera-aligned ground basis (XZ plane), derived from offset. - // camRightXZ: screen-right projected to ground. - // camForwardXZ: screen-up (away from camera) projected to ground. this.camRightXZ = new THREE.Vector3( this.offset.z, 0, - this.offset.x ).normalize(); this.camForwardXZ = new THREE.Vector3( - this.offset.x, 0, - this.offset.z ).normalize(); - this.leadFactor = 3.0; - this.cameraSmoothing = 2.0; - this.deadzoneRadius = 5.0; + this.leadFactor = 10.0; + this.cameraSmoothing = 5.0; + this.deadzoneRadius = 2.0; this.screenShiftUp = 1.0; this.smoothedDesired = new THREE.Vector3(); - this.initialized = false; + this.smoothedThird = new THREE.Vector3(); + this.eagleInitialized = false; + this.thirdInitialized = false; + + // Third-person chase (vehicle-local offset, rotated by yaw) + this.thirdBack = 3.0; + this.thirdHeight = 1.85; + this.thirdScreenShift = .5; + + // Hood / cockpit (local space: +Z forward, +X right, +Y up) + this.hoodLocal = new THREE.Vector3( -0.0, 0.1, 0.15 ); + this.roofApproachLocal = new THREE.Vector3( 1.15, 3.05, 2.35 ); + this.hoodLookAhead = 14; + + this.steadyMode = CAMERA_MODE.EAGLE; + this.transitionTargetMode = CAMERA_MODE.EAGLE; + this.transitionEnterSource = CAMERA_MODE.EAGLE; + this.transitionActive = false; + this.transitionElapsed = 0; + this.transitionDuration = 0.45; + + this.startPos = new THREE.Vector3(); + this.startQuat = new THREE.Quaternion(); + this.goalQuat = new THREE.Quaternion(); + this.blendQuat = new THREE.Quaternion(); + + this.baseFov = 40; + this.closeFovMax = 58; + this.smoothedFov = this.baseFov; + + // Hood: barrel roll from steering (same family as Vehicle body rotation.z) + this.hoodRollMax = 0.28; + this.hoodSteerRollGain = 1.35; + this.smoothedHoodRoll = 0; const segments = 64; const points = []; @@ -55,15 +123,122 @@ export class Camera { } - update( dt, target, velocity ) { + _fovCloseModeWeight() { + + if ( ! this.transitionActive ) { + + return ( this.steadyMode === CAMERA_MODE.THIRD || this.steadyMode === CAMERA_MODE.HOOD ) ? 1 : 0; + + } + + const u = THREE.MathUtils.clamp( this.transitionElapsed / this.transitionDuration, 0, 1 ); + const e = smootherstep( u ); + + if ( this.transitionTargetMode === CAMERA_MODE.EAGLE ) return 1 - e; + + if ( this.transitionTargetMode === CAMERA_MODE.THIRD && this.transitionEnterSource === CAMERA_MODE.EAGLE ) return e; + + return 1; + + } + + _hoodGForceBlend() { + + if ( ! this.transitionActive ) { + + return this.steadyMode === CAMERA_MODE.HOOD ? 1 : 0; + + } + + const u = THREE.MathUtils.clamp( this.transitionElapsed / this.transitionDuration, 0, 1 ); + const e = smootherstep( u ); + + const thirdToHood = this.transitionEnterSource === CAMERA_MODE.THIRD && + this.transitionTargetMode === CAMERA_MODE.HOOD; + + if ( thirdToHood ) { + + const split = 0.48; + if ( u <= split ) return 0; + + return smootherstep( ( u - split ) / ( 1 - split ) ); + + } + + if ( this.transitionTargetMode === CAMERA_MODE.EAGLE && this.transitionEnterSource === CAMERA_MODE.HOOD ) { + + return 1 - e; + + } + + return 0; + + } + + _applyHoodSteerRoll( dt, vehicle ) { + + const w = this._hoodGForceBlend(); + // Match bodyNode.rotation.z drive: -(inputX/5)*linearSpeed — tilt against steering side. + const steer = vehicle.inputX ?? 0; + const speed = vehicle.linearSpeed ?? 0; + const raw = - ( steer / 10 ) * speed * this.hoodSteerRollGain; + const target = THREE.MathUtils.clamp( raw, - this.hoodRollMax, this.hoodRollMax ) * w; + const alpha = 1 - Math.exp( - dt * 2 ); + this.smoothedHoodRoll += ( target - this.smoothedHoodRoll ) * alpha; + this.camera.rotateZ( -this.smoothedHoodRoll ); + + } + + _applySpeedFov( dt, speedNorm ) { + + const w = this._fovCloseModeWeight(); + const s = THREE.MathUtils.clamp( speedNorm, 0, 1 ); + const se = s * s * ( 3 - 2 * s ); + const targetFov = THREE.MathUtils.lerp( this.baseFov, this.closeFovMax, w * se ); + const alpha = 1 - Math.exp( - dt * 14 ); + this.smoothedFov += ( targetFov - this.smoothedFov ) * alpha; + this.camera.fov = this.smoothedFov; + this.camera.updateProjectionMatrix(); + + } + + advanceMode( spherePos ) { + + const prevDest = this.transitionActive ? this.transitionTargetMode : this.steadyMode; + const next = ( prevDest + 1 ) % 3; + + this.transitionEnterSource = prevDest; + this.transitionTargetMode = next; + this.transitionDuration = transitionDuration( prevDest, next ); + this.transitionElapsed = 0; + this.transitionActive = true; + this.startPos.copy( this.camera.position ); + this.startQuat.copy( this.camera.quaternion ); + + if ( next === CAMERA_MODE.THIRD && prevDest === CAMERA_MODE.EAGLE && spherePos ) { + + this.smoothedThird.copy( spherePos ); + this.thirdInitialized = true; + + } + + if ( next === CAMERA_MODE.EAGLE && prevDest === CAMERA_MODE.HOOD && spherePos ) { + + this.smoothedDesired.copy( spherePos ); + this.eagleInitialized = true; + + } + + } + + _applyLeadDeadzone( target, velocity, rightXZ, forwardXZ, smoothed, initializedFlag, dt ) { const radius = this.deadzoneRadius; const radiusSq = radius * radius; + const leadMul = this.transitionActive ? 0.42 : 1; - // Lead = velocity projected onto camera-aligned ground basis, scaled, clamped to the deadzone disk. - // Becomes the camera's offset from the car: car settles at the trailing edge of the circle. - let leadX = velocity.dot( this.camRightXZ ) * this.leadFactor; - let leadY = velocity.dot( this.camForwardXZ ) * this.leadFactor; + let leadX = velocity.dot( rightXZ ) * this.leadFactor * leadMul; + let leadY = velocity.dot( forwardXZ ) * this.leadFactor * leadMul; const leadLenSq = leadX * leadX + leadY * leadY; if ( leadLenSq > radiusSq ) { @@ -74,38 +249,212 @@ export class Camera { } _desired.copy( target ) - .addScaledVector( this.camRightXZ, leadX ) - .addScaledVector( this.camForwardXZ, leadY ); + .addScaledVector( rightXZ, leadX ) + .addScaledVector( forwardXZ, leadY ); - const alpha = this.initialized ? 1 - Math.exp( - dt * this.cameraSmoothing ) : 1; - this.smoothedDesired.lerp( _desired, alpha ); - this.initialized = true; + const alpha = initializedFlag ? 1 - Math.exp( - dt * this.cameraSmoothing ) : 1; + smoothed.lerp( _desired, alpha ); - // Hard-clamp: car must not escape the deadzone, even if the lerp lags at high speed. - _delta.subVectors( target, this.smoothedDesired ); - const offsetX = _delta.dot( this.camRightXZ ); - const offsetY = _delta.dot( this.camForwardXZ ); + _delta.subVectors( target, smoothed ); + const offsetX = _delta.dot( rightXZ ); + const offsetY = _delta.dot( forwardXZ ); const offsetLenSq = offsetX * offsetX + offsetY * offsetY; if ( offsetLenSq > radiusSq ) { const offsetLen = Math.sqrt( offsetLenSq ); const k = ( offsetLen - radius ) / offsetLen; - this.smoothedDesired - .addScaledVector( this.camRightXZ, offsetX * k ) - .addScaledVector( this.camForwardXZ, offsetY * k ); + smoothed + .addScaledVector( rightXZ, offsetX * k ) + .addScaledVector( forwardXZ, offsetY * k ); } - // Shift the entire view (camera + lookAt) so smoothedDesired sits higher on screen. - _lookPoint.copy( this.smoothedDesired ).addScaledVector( this.camForwardXZ, - this.screenShiftUp ); + return radius; + + } + + _vehicleYawBasis( vehicle ) { + + const g = vehicle.container; + _vehForward.set( 0, 0, 1 ).applyQuaternion( g.quaternion ); + _vehForward.y = 0; + const fl = _vehForward.length(); + if ( fl < 1e-5 ) _vehForward.set( 0, 0, 1 ); + else _vehForward.multiplyScalar( 1 / fl ); - this.camera.position.copy( _lookPoint ).add( this.offset ); - this.camera.lookAt( _lookPoint ); + _vehRight.crossVectors( _up, _vehForward ).normalize(); + + const yaw = Math.atan2( _vehForward.x, _vehForward.z ); + _yawQuat.setFromAxisAngle( _up, yaw ); + + } + + _poseFromLookAt( eye, lookAt, outPos, outQuat ) { + + // Must use PerspectiveCamera.lookAt: plain Object3D.lookAt swaps eye/target in three.js, + // so a dummy object yields the wrong quaternion for this.camera. + const cam = this.camera; + _camPosSave.copy( cam.position ); + _camQuatSave.copy( cam.quaternion ); + cam.position.copy( eye ); + cam.lookAt( lookAt ); + outPos.copy( eye ); + outQuat.copy( cam.quaternion ); + cam.position.copy( _camPosSave ); + cam.quaternion.copy( _camQuatSave ); + + } + + _eagleGoal( dt, target, velocity, outPos, outQuat ) { + + const radius = this._applyLeadDeadzone( + target, velocity, this.camRightXZ, this.camForwardXZ, + this.smoothedDesired, this.eagleInitialized, dt + ); + this.eagleInitialized = true; + + _lookPoint.copy( this.smoothedDesired ).addScaledVector( this.camForwardXZ, - this.screenShiftUp ); + outPos.copy( _lookPoint ).add( this.offset ); + this._poseFromLookAt( outPos, _lookPoint, outPos, outQuat ); this.debug.position.copy( this.smoothedDesired ); this.debug.position.y += 0.05; this.debug.scale.set( radius, 1, radius ); + return radius; + + } + + _thirdGoal( dt, target, velocity, vehicle, outPos, outQuat ) { + + this._vehicleYawBasis( vehicle ); + + const radius = this._applyLeadDeadzone( + target, velocity, _vehRight, _vehForward, + this.smoothedThird, this.thirdInitialized, dt + ); + this.thirdInitialized = true; + + _lookPoint.copy( this.smoothedThird ).addScaledVector( _vehForward, - this.thirdScreenShift ); + + _localOffset.set( 0, this.thirdHeight, - this.thirdBack ); + _localOffset.applyQuaternion( _yawQuat ); + + outPos.copy( _lookPoint ).add( _localOffset ); + this._poseFromLookAt( outPos, _lookPoint, outPos, outQuat ); + + return radius; + + } + + _roofGoal( spherePos, vehicle, outPos, outQuat ) { + + this._vehicleYawBasis( vehicle ); + + outPos.copy( this.roofApproachLocal ).applyQuaternion( _yawQuat ).add( spherePos ); + _lookAt.copy( _vehForward ).multiplyScalar( 10 ).add( outPos ); + _lookAt.y -= 0.35; + this._poseFromLookAt( outPos, _lookAt, outPos, outQuat ); + + } + + _hoodGoal( spherePos, vehicle, outPos, outQuat ) { + + this._vehicleYawBasis( vehicle ); + + outPos.copy( this.hoodLocal ).applyQuaternion( _yawQuat ).add( spherePos ); + _lookAt.copy( _vehForward ).multiplyScalar( this.hoodLookAhead ).add( outPos ); + _lookAt.y -= 0.22; + this._poseFromLookAt( outPos, _lookAt, outPos, outQuat ); + + } + + _rawModeGoal( mode, dt, spherePos, velocity, vehicle, outPos, outQuat ) { + + switch ( mode ) { + + case CAMERA_MODE.EAGLE: + this._eagleGoal( dt, spherePos, velocity, outPos, outQuat ); + break; + case CAMERA_MODE.THIRD: + this._thirdGoal( dt, spherePos, velocity, vehicle, outPos, outQuat ); + break; + default: + this._hoodGoal( spherePos, vehicle, outPos, outQuat ); + + } + + } + + _transitionGoal( dt, spherePos, velocity, vehicle, outPos, outQuat ) { + + const u = this.transitionElapsed / this.transitionDuration; + + const thirdToHood = this.transitionEnterSource === CAMERA_MODE.THIRD && + this.transitionTargetMode === CAMERA_MODE.HOOD; + + if ( thirdToHood ) { + + this._thirdGoal( dt, spherePos, velocity, vehicle, _thirdGoalPos, _tmpQuatA ); + this._roofGoal( spherePos, vehicle, _roofPos, _tmpQuatB ); + + const split = 0.48; + let k; + if ( u <= split ) { + + k = smootherstep( u / split ); + outPos.copy( _thirdGoalPos ).lerp( _roofPos, k ); + this.blendQuat.copy( _tmpQuatA ).slerp( _tmpQuatB, k ); + + } else { + + k = smootherstep( ( u - split ) / ( 1 - split ) ); + this._hoodGoal( spherePos, vehicle, _hoodPos, this.goalQuat ); + outPos.copy( _roofPos ).lerp( _hoodPos, k ); + this.blendQuat.copy( _tmpQuatB ).slerp( this.goalQuat, k ); + + } + + outQuat.copy( this.blendQuat ); + return; + + } + + this._rawModeGoal( this.transitionTargetMode, dt, spherePos, velocity, vehicle, outPos, outQuat ); + + } + + update( dt, spherePos, velocity, vehicle, speedNorm ) { + + if ( ! this.transitionActive ) { + + this._rawModeGoal( this.steadyMode, dt, spherePos, velocity, vehicle, _goalPos, this.goalQuat ); + this.camera.position.copy( _goalPos ); + this.camera.quaternion.copy( this.goalQuat ); + + } else { + + this._transitionGoal( dt, spherePos, velocity, vehicle, _goalPos, this.goalQuat ); + + const u = smootherstep( this.transitionElapsed / this.transitionDuration ); + this.camera.position.copy( this.startPos ).lerp( _goalPos, u ); + this.blendQuat.copy( this.startQuat ).slerp( this.goalQuat, u ); + this.camera.quaternion.copy( this.blendQuat ); + + this.transitionElapsed += dt; + if ( this.transitionElapsed >= this.transitionDuration ) { + + this.transitionActive = false; + this.steadyMode = this.transitionTargetMode; + this.transitionElapsed = this.transitionDuration; + + } + + } + + this._applyHoodSteerRoll( dt, vehicle ); + this._applySpeedFov( dt, speedNorm ?? 0 ); + } } diff --git a/js/Controls.js b/js/Controls.js index 80b8d09..7c349b6 100644 --- a/js/Controls.js +++ b/js/Controls.js @@ -14,6 +14,8 @@ export class Controls { this.steerStartX = 0; this.steerStartY = 0; + this._keyCDown = false; + window.addEventListener( 'keydown', ( e ) => this.keys[ e.code ] = true ); window.addEventListener( 'keyup', ( e ) => this.keys[ e.code ] = false ); @@ -156,7 +158,11 @@ export class Controls { this.x = x; this.z = z; - return { x, z, touchActive: this.touchActive }; + const cDown = !! this.keys[ 'KeyC' ]; + const cycleCamera = cDown && ! this._keyCDown; + this._keyCDown = cDown; + + return { x, z, touchActive: this.touchActive, cycleCamera }; } diff --git a/js/Vehicle.js b/js/Vehicle.js index 97eda6a..8c11555 100644 --- a/js/Vehicle.js +++ b/js/Vehicle.js @@ -53,6 +53,10 @@ export class Vehicle { this.driftIntensity = 0; + /** World-space lateral acceleration along car right (XZ), for cockpit camera roll. */ + this.lateralAccel = 0; + this._prevVLateral = 0; + } init( model ) { @@ -181,6 +185,24 @@ export class Vehicle { const vel = this.rigidBody.motionProperties.linearVelocity; this.sphereVel.set( vel[ 0 ], vel[ 1 ], vel[ 2 ] ); + const vLat = this.sphereVel.dot( _right ); + if ( dt > 1e-6 && dt < 0.2 ) { + + this.lateralAccel = ( vLat - this._prevVLateral ) / dt; + + } else { + + this.lateralAccel = 0; + + } + + this._prevVLateral = vLat; + + } else { + + this.lateralAccel = 0; + this._prevVLateral = 0; + } this.acceleration = THREE.MathUtils.lerp( @@ -204,6 +226,8 @@ export class Vehicle { this.linearSpeed = 0; this.angularSpeed = 0; this.acceleration = 0; + this.lateralAccel = 0; + this._prevVLateral = 0; this.container.rotation.set( 0, 0, 0 ); this.container.quaternion.identity(); diff --git a/js/main.js b/js/main.js index d87126a..ea2f430 100644 --- a/js/main.js +++ b/js/main.js @@ -266,6 +266,8 @@ async function init() { vehicle.update( dt, input ); + if ( input.cycleCamera ) cam.advanceMode( vehicle.spherePos ); + dirLight.position.set( vehicle.spherePos.x + 11.4, 15, @@ -274,7 +276,8 @@ async function init() { const mv = vehicle.modelVelocity; _camLead.set( 0, 0, 1 ).applyQuaternion( vehicle.container.quaternion ).multiplyScalar( Math.sqrt( mv.x * mv.x + mv.z * mv.z ) ); - cam.update( dt, vehicle.spherePos, _camLead ); + const speedNorm = Math.min( Math.abs( vehicle.linearSpeed ) / MAX_SPEED, 1 ); + cam.update( dt, vehicle.spherePos, _camLead, vehicle, speedNorm ); particles.update( dt, vehicle ); driftMarks.update( dt, vehicle ); audio.update( dt, vehicle.linearSpeed / MAX_SPEED, input.z, vehicle.driftIntensity ); From 02f1f6b9347c1e0e61da336d4c7b2b45c09e5a59 Mon Sep 17 00:00:00 2001 From: Lucas Lima Date: Sun, 3 May 2026 21:14:40 -0300 Subject: [PATCH 2/2] Add camera HUD under lap timer with Switch control - CameraHud: Switch button (camera icon + label), mode list with active styling - Position below #lap-timer when present; Camera.getHudMode() for highlight sync - Wire HUD in main loop with same advanceMode callback as Key C Co-authored-by: Cursor --- js/Camera.js | 7 +++ js/CameraHud.js | 139 ++++++++++++++++++++++++++++++++++++++++++++++++ js/main.js | 7 +++ 3 files changed, 153 insertions(+) create mode 100644 js/CameraHud.js diff --git a/js/Camera.js b/js/Camera.js index 7f9c883..4316d09 100644 --- a/js/Camera.js +++ b/js/Camera.js @@ -231,6 +231,13 @@ export class Camera { } + /** Mode index for HUD highlight (0 eagle, 1 third, 2 hood). */ + getHudMode() { + + return this.transitionActive ? this.transitionTargetMode : this.steadyMode; + + } + _applyLeadDeadzone( target, velocity, rightXZ, forwardXZ, smoothed, initializedFlag, dt ) { const radius = this.deadzoneRadius; diff --git a/js/CameraHud.js b/js/CameraHud.js new file mode 100644 index 0000000..621f5cf --- /dev/null +++ b/js/CameraHud.js @@ -0,0 +1,139 @@ +const LABELS = [ 'Eagle', 'Third person', 'Hood' ]; + +export class CameraHud { + + constructor( { onSwap } ) { + + this._onSwap = onSwap; + this._build(); + this._syncTop(); + window.addEventListener( 'resize', () => this._syncTop() ); + + } + + _build() { + + const style = document.createElement( 'style' ); + style.textContent = ` + #camera-hud { + position: absolute; + left: 12px; + z-index: 10; + font: 600 13px -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; + user-select: none; + min-width: 140px; + } + #camera-hud .cam-switch { + display: flex; + align-items: center; + justify-content: center; + gap: 8px; + width: 100%; + padding: 8px 12px; + margin: 0 0 8px 0; + border: none; + border-radius: 8px; + background: #fff; + color: #000; + font: inherit; + font-weight: 700; + letter-spacing: 0.04em; + cursor: pointer; + box-shadow: 0 2px 8px rgba(0,0,0,0.2); + } + #camera-hud .cam-switch svg { + flex-shrink: 0; + width: 20px; + height: 20px; + } + #camera-hud .cam-switch:hover { filter: brightness(0.96); } + #camera-hud .cam-switch:active { transform: scale(0.98); } + #camera-hud .cam-list { + margin: 0; + padding: 10px 12px 10px 1.35em; + list-style: disc; + line-height: 1.55; + background: rgba(0,0,0,0.45); + border-radius: 10px; + backdrop-filter: blur(8px); + -webkit-backdrop-filter: blur(8px); + } + #camera-hud .cam-list li { + margin: 0; + padding: 0; + } + #camera-hud .cam-list li.is-active { + color: #fff; + -webkit-text-stroke: 0.9px #000; + paint-order: stroke fill; + } + #camera-hud .cam-list li.is-idle { + color: rgba(255,255,255,0.38); + -webkit-text-stroke: 0; + } + `; + document.head.appendChild( style ); + + this.root = document.createElement( 'div' ); + this.root.id = 'camera-hud'; + + const btn = document.createElement( 'button' ); + btn.type = 'button'; + btn.className = 'cam-switch'; + btn.innerHTML = + '' + + 'Switch (C)'; + btn.addEventListener( 'click', () => this._onSwap() ); + this.root.appendChild( btn ); + + const ul = document.createElement( 'ul' ); + ul.className = 'cam-list'; + this._items = []; + for ( let i = 0; i < 3; i ++ ) { + + const li = document.createElement( 'li' ); + li.textContent = LABELS[ i ]; + ul.appendChild( li ); + this._items.push( li ); + + } + + this.root.appendChild( ul ); + document.body.appendChild( this.root ); + + } + + _syncTop() { + + const lap = document.getElementById( 'lap-timer' ); + if ( lap ) { + + const r = lap.getBoundingClientRect(); + this.root.style.top = `${ Math.round( r.bottom + 8 ) }px`; + + } else { + + this.root.style.top = '12px'; + + } + + } + + update( cam ) { + + const mode = cam.getHudMode(); + for ( let i = 0; i < 3; i ++ ) { + + const li = this._items[ i ]; + const on = i === mode; + li.classList.toggle( 'is-active', on ); + li.classList.toggle( 'is-idle', ! on ); + + } + + } + +} diff --git a/js/main.js b/js/main.js index ea2f430..f2cbe0b 100644 --- a/js/main.js +++ b/js/main.js @@ -12,6 +12,7 @@ import { SmokeTrails } from './Particles.js'; import { DriftMarks } from './DriftMarks.js'; import { GameAudio } from './Audio.js'; import { LapTimer } from './LapTimer.js'; +import { CameraHud } from './CameraHud.js'; import { ColorMapGLTFLoader } from './Loader.js'; @@ -233,6 +234,10 @@ async function init() { const lapTimer = new LapTimer( customCells, mapParam ); + const cameraHud = new CameraHud( { + onSwap: () => cam.advanceMode( vehicle.spherePos ), + } ); + const _forward = new THREE.Vector3(); const _camLead = new THREE.Vector3(); @@ -285,6 +290,8 @@ async function init() { const hasInput = input.touchActive || Math.abs( input.x ) > 0.05 || Math.abs( input.z ) > 0.05; lapTimer.update( dt, vehicle.spherePos, hasInput ); + cameraHud.update( cam ); + renderer.render( scene, cam.camera ); }