diff --git a/js/Audio.js b/js/Audio.js
index 1ec9709..f545741 100644
--- a/js/Audio.js
+++ b/js/Audio.js
@@ -38,6 +38,9 @@ export class GameAudio {
this.gear = 0;
this.shiftCooldown = 0;
+ this.nosWhoosh = null;
+ this.prevNosActive = false;
+
}
init( camera ) {
@@ -49,6 +52,7 @@ export class GameAudio {
this.engineSound = new THREE.Audio( this.listener );
this.engineLayerSound = new THREE.Audio( this.listener );
+ this.nosWhoosh = new THREE.Audio( this.listener );
this.engineFilter = this.listener.context.createBiquadFilter();
this.engineFilter.type = 'lowpass';
@@ -68,6 +72,9 @@ export class GameAudio {
this.engineLayerSound.setLoop( true );
this.engineLayerSound.setVolume( 0 );
+ this.nosWhoosh.setBuffer( buffer );
+ this.nosWhoosh.setLoop( false );
+
this.checkReady();
} );
@@ -144,10 +151,23 @@ export class GameAudio {
}
- update( dt, speed, throttle, driftIntensity ) {
+ update( dt, speed, throttle, driftIntensity, nosActive ) {
if ( ! this.ready ) return;
+ const na = nosActive === true;
+ if ( na && ! this.prevNosActive && this.nosWhoosh && this.nosWhoosh.buffer ) {
+
+ if ( this.nosWhoosh.isPlaying ) this.nosWhoosh.stop();
+
+ this.nosWhoosh.setPlaybackRate( 1.55 + Math.random() * 0.25 );
+ this.nosWhoosh.setVolume( 0.2 );
+ this.nosWhoosh.play();
+
+ }
+
+ this.prevNosActive = na;
+
const absSpeed = THREE.MathUtils.clamp( Math.abs( speed ), 0, 1 );
// Only forward throttle counts as engine load. Brake/reverse (throttle < 0)
// should let RPM fall so downshifts can fire as the car decelerates.
diff --git a/js/Camera.js b/js/Camera.js
index cde2d36..f6dc7c4 100644
--- a/js/Camera.js
+++ b/js/Camera.js
@@ -30,6 +30,8 @@ export class Camera {
this.smoothedDesired = new THREE.Vector3();
this.initialized = false;
+ this.nosShakeOffset = new THREE.Vector3();
+
const segments = 64;
const points = [];
for ( let i = 0; i <= segments; i ++ ) {
@@ -55,7 +57,22 @@ export class Camera {
}
- update( dt, target, velocity ) {
+ _applyNosCameraShake( dt, nosIntensity ) {
+
+ const ni = THREE.MathUtils.clamp( nosIntensity ?? 0, 0, 1 );
+ this.nosShakeOffset.multiplyScalar( Math.exp( - dt * 13 ) );
+ this.nosShakeOffset.x += ( Math.random() - 0.5 ) * ni * 3.4 * dt;
+ this.nosShakeOffset.y += ( Math.random() - 0.5 ) * ni * 2.6 * dt;
+ this.nosShakeOffset.z += ( Math.random() - 0.5 ) * ni * 3.4 * dt;
+
+ const shakeLen = this.nosShakeOffset.length();
+ if ( shakeLen > 0.26 ) this.nosShakeOffset.multiplyScalar( 0.26 / shakeLen );
+
+ this.camera.position.add( this.nosShakeOffset );
+
+ }
+
+ update( dt, target, velocity, nosIntensity = 0 ) {
const radius = this.deadzoneRadius;
const radiusSq = radius * radius;
@@ -106,6 +123,8 @@ export class Camera {
this.debug.position.y += 0.05;
this.debug.scale.set( radius, 1, radius );
+ this._applyNosCameraShake( dt, nosIntensity );
+
}
}
diff --git a/js/Controls.js b/js/Controls.js
index 80b8d09..54b614b 100644
--- a/js/Controls.js
+++ b/js/Controls.js
@@ -5,6 +5,9 @@ export class Controls {
this.keys = {};
this.x = 0;
this.z = 0;
+ this.nos = false;
+ /** Pointer / UI hold on the NOS button (see NosHud). */
+ this.nosUiHeld = false;
// Touch state
this.touchActive = false;
@@ -16,6 +19,11 @@ export class Controls {
window.addEventListener( 'keydown', ( e ) => this.keys[ e.code ] = true );
window.addEventListener( 'keyup', ( e ) => this.keys[ e.code ] = false );
+ window.addEventListener( 'blur', () => {
+
+ this.nosUiHeld = false;
+
+ } );
this.setupTouchUI();
@@ -120,6 +128,8 @@ export class Controls {
const gamepads = navigator.getGamepads();
+ let nos = false;
+
for ( const gp of gamepads ) {
if ( ! gp ) continue;
@@ -132,6 +142,10 @@ export class Controls {
if ( rt > 0.1 || lt > 0.1 ) z = rt - lt;
+ // RB — boost (NOS); avoids face buttons used for menus elsewhere.
+ const rb = gp.buttons[ 5 ];
+ if ( rb && rb.pressed ) nos = true;
+
break;
}
@@ -153,10 +167,21 @@ export class Controls {
}
+ if ( this.keys[ 'KeyX' ] ) nos = true;
+ if ( this.nosUiHeld ) nos = true;
+
this.x = x;
this.z = z;
+ this.nos = nos;
+
+ return { x, z, touchActive: this.touchActive, nos };
+
+ }
+
+ /** Call from NOS HUD button pointer / keyboard hold. */
+ setNosUiHeld( held ) {
- return { x, z, touchActive: this.touchActive };
+ this.nosUiHeld = !! held;
}
diff --git a/js/NosHud.js b/js/NosHud.js
new file mode 100644
index 0000000..269c859
--- /dev/null
+++ b/js/NosHud.js
@@ -0,0 +1,195 @@
+import { NOS_DURATION } from './Vehicle.js';
+
+export class NosHud {
+
+ constructor( controls ) {
+
+ this._controls = controls;
+
+ const css = document.createElement( 'style' );
+ css.textContent = `
+ .nos-hud {
+ position: fixed;
+ left: 14px;
+ bottom: 14px;
+ z-index: 20;
+ font-family: system-ui, sans-serif;
+ user-select: none;
+ pointer-events: none;
+ }
+ .nos-hud__label-row {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ margin-bottom: 5px;
+ }
+ .nos-hud__key-btn {
+ appearance: none;
+ border: 0;
+ margin: 0;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ min-width: 22px;
+ height: 22px;
+ padding: 0 7px;
+ border-radius: 5px;
+ background: #ffffff;
+ color: #1a1a1a;
+ font-size: 11px;
+ font-weight: 800;
+ line-height: 1;
+ font-family: inherit;
+ box-shadow:
+ 0 1px 0 rgba( 255, 255, 255, 0.65 ) inset,
+ 0 2px 4px rgba( 0, 0, 0, 0.35 );
+ pointer-events: auto;
+ cursor: pointer;
+ touch-action: manipulation;
+ }
+ .nos-hud__key-btn:hover {
+ filter: brightness( 1.04 );
+ }
+ .nos-hud__key-btn:active {
+ transform: translateY( 1px );
+ box-shadow:
+ 0 1px 0 rgba( 255, 255, 255, 0.4 ) inset,
+ 0 1px 2px rgba( 0, 0, 0, 0.4 );
+ }
+ .nos-hud__key-btn--empty {
+ opacity: 0.4;
+ }
+ .nos-hud__text {
+ display: flex;
+ flex-direction: column;
+ gap: 1px;
+ }
+ .nos-hud__title {
+ font-size: 10px;
+ font-weight: 700;
+ letter-spacing: 0.08em;
+ color: rgba( 255, 255, 255, 0.55 );
+ text-shadow: 0 1px 2px rgba( 0, 0, 0, 0.5 );
+ }
+ .nos-hud__pct {
+ font-size: 11px;
+ font-weight: 700;
+ font-variant-numeric: tabular-nums;
+ color: rgba( 255, 255, 255, 0.92 );
+ text-shadow: 0 1px 2px rgba( 0, 0, 0, 0.55 );
+ }
+ .nos-hud__track {
+ width: 112px;
+ height: 8px;
+ border-radius: 4px;
+ background: rgba( 0, 0, 0, 0.45 );
+ box-shadow: inset 0 1px 2px rgba( 0, 0, 0, 0.45 );
+ overflow: hidden;
+ position: relative;
+ }
+ .nos-hud__fill {
+ position: absolute;
+ left: 0;
+ top: 0;
+ bottom: 0;
+ width: 100%;
+ transform-origin: left center;
+ transform: scaleX( 1 );
+ border-radius: 4px;
+ background: linear-gradient( 180deg, #ff6b4a 0%, #c41e3a 55%, #8b1538 100% );
+ box-shadow: 0 0 10px rgba( 255, 60, 80, 0.45 );
+ transition: transform 0.08s ease-out;
+ }
+ `;
+ document.head.appendChild( css );
+
+ this.root = document.createElement( 'div' );
+ this.root.className = 'nos-hud';
+ this.root.innerHTML = `
+
+
+
+ NOS
+ 100% left
+
+
+
+ `;
+ document.body.appendChild( this.root );
+
+ this.fill = this.root.querySelector( '.nos-hud__fill' );
+ this.percentEl = this.root.querySelector( '.nos-hud__percent' );
+ this.keyBtn = this.root.querySelector( '.nos-hud__key-btn' );
+
+ this.keyBtn.setAttribute( 'aria-label', 'Hold to use NOS boost' );
+
+ const setHeld = ( on ) => {
+
+ this._controls.setNosUiHeld( on );
+
+ };
+
+ this.keyBtn.addEventListener( 'pointerdown', ( e ) => {
+
+ e.preventDefault();
+ this.keyBtn.setPointerCapture( e.pointerId );
+ setHeld( true );
+
+ } );
+
+ this.keyBtn.addEventListener( 'pointerup', ( e ) => {
+
+ e.preventDefault();
+ setHeld( false );
+ try {
+
+ this.keyBtn.releasePointerCapture( e.pointerId );
+
+ } catch ( _ ) {}
+
+ } );
+
+ this.keyBtn.addEventListener( 'pointercancel', () => setHeld( false ) );
+
+ this.keyBtn.addEventListener( 'lostpointercapture', () => setHeld( false ) );
+
+ this.keyBtn.addEventListener( 'keydown', ( e ) => {
+
+ if ( e.code === 'Space' || e.code === 'Enter' ) {
+
+ e.preventDefault();
+ setHeld( true );
+
+ }
+
+ } );
+
+ this.keyBtn.addEventListener( 'keyup', ( e ) => {
+
+ if ( e.code === 'Space' || e.code === 'Enter' ) {
+
+ e.preventDefault();
+ setHeld( false );
+
+ }
+
+ } );
+
+ }
+
+ update( vehicle ) {
+
+ const frac = Math.min( 1, Math.max( 0, vehicle.nosTankRemaining / NOS_DURATION ) );
+ this.fill.style.transform = `scaleX( ${ frac } )`;
+
+ const pct = Math.round( frac * 100 );
+ this.percentEl.textContent = String( pct );
+
+ this.keyBtn.classList.toggle( 'nos-hud__key-btn--empty', pct <= 0 );
+ this.keyBtn.setAttribute( 'aria-disabled', pct <= 0 ? 'true' : 'false' );
+
+ }
+
+}
diff --git a/js/Particles.js b/js/Particles.js
index 8880b97..7d8ebf5 100644
--- a/js/Particles.js
+++ b/js/Particles.js
@@ -10,6 +10,9 @@ const INV_MAX_LIFE = 1 / MAX_LIFE;
const _blPos = new THREE.Vector3();
const _brPos = new THREE.Vector3();
+const _nosFwd = new THREE.Vector3();
+const _nosSpawn = new THREE.Vector3();
+
export class SmokeTrails {
constructor( scene ) {
@@ -94,7 +97,10 @@ export class SmokeTrails {
update( dt, vehicle ) {
- const shouldEmit = vehicle.driftIntensity > 0.7;
+ const driftSmoke = vehicle.driftIntensity > 0.7;
+ const nosSmoke = vehicle.nosActive === true;
+ const shouldEmit = driftSmoke || nosSmoke;
+ const emitBatch = nosSmoke ? PARTICLES_PER_EMIT + 2 : PARTICLES_PER_EMIT;
let aliveCount = 0;
if ( shouldEmit ) {
@@ -103,7 +109,7 @@ export class SmokeTrails {
const bl = vehicle.wheelBL ? vehicle.wheelBL.getWorldPosition( _blPos ) : null;
const br = vehicle.wheelBR ? vehicle.wheelBR.getWorldPosition( _brPos ) : null;
- for ( let i = 0; i < PARTICLES_PER_EMIT; i ++ ) {
+ for ( let i = 0; i < emitBatch; i ++ ) {
if ( bl ) this.emitAt( bl.x, roadY, bl.z );
if ( br ) this.emitAt( br.x, roadY, br.z );
@@ -180,3 +186,214 @@ export class SmokeTrails {
}
}
+
+const NOS_POOL_SIZE = 384;
+const NOS_PARTICLES_PER_EMIT = 4;
+const NOS_EMIT_JITTER = 0.08;
+const NOS_BASE_SIZE = 0.65;
+const NOS_MAX_LIFE = 0.52;
+const INV_NOS_MAX_LIFE = 1 / NOS_MAX_LIFE;
+const NOS_TRAIL_SPEED = 11;
+
+export class NosTaillightTrails {
+
+ constructor( scene ) {
+
+ const positions = new Float32Array( NOS_POOL_SIZE * 3 );
+ const opacities = new Float32Array( NOS_POOL_SIZE );
+ const sizes = new Float32Array( NOS_POOL_SIZE );
+
+ const geometry = new THREE.BufferGeometry();
+
+ const posAttr = new THREE.BufferAttribute( positions, 3 );
+ posAttr.setUsage( THREE.DynamicDrawUsage );
+ geometry.setAttribute( 'position', posAttr );
+
+ const opacityAttr = new THREE.BufferAttribute( opacities, 1 );
+ opacityAttr.setUsage( THREE.DynamicDrawUsage );
+ geometry.setAttribute( 'aOpacity', opacityAttr );
+
+ const sizeAttr = new THREE.BufferAttribute( sizes, 1 );
+ sizeAttr.setUsage( THREE.DynamicDrawUsage );
+ geometry.setAttribute( 'aSize', sizeAttr );
+
+ const map = new THREE.TextureLoader().load( 'sprites/smoke.png' );
+
+ const material = new THREE.PointsMaterial( {
+ map,
+ color: 0xff2838,
+ size: 1,
+ sizeAttenuation: true,
+ transparent: true,
+ depthWrite: false,
+ blending: THREE.AdditiveBlending,
+ opacity: 1,
+ } );
+
+ material.onBeforeCompile = ( shader ) => {
+
+ shader.vertexShader = 'attribute float aSize;\nattribute float aOpacity;\nvarying float vOpacity;\n' + shader.vertexShader;
+ shader.vertexShader = shader.vertexShader.replace(
+ 'void main() {',
+ 'void main() {\n\tvOpacity = aOpacity;'
+ );
+ shader.vertexShader = shader.vertexShader.replace(
+ 'gl_PointSize = size;',
+ 'gl_PointSize = size * aSize;'
+ );
+
+ shader.fragmentShader = 'varying float vOpacity;\n' + shader.fragmentShader;
+ shader.fragmentShader = shader.fragmentShader.replace(
+ 'vec4 diffuseColor = vec4( diffuse, opacity );',
+ 'vec4 diffuseColor = vec4( diffuse, opacity * vOpacity );'
+ );
+
+ };
+
+ const points = new THREE.Points( geometry, material );
+ points.frustumCulled = false;
+ scene.add( points );
+
+ this.posAttr = posAttr;
+ this.opacityAttr = opacityAttr;
+ this.sizeAttr = sizeAttr;
+ this.positions = positions;
+ this.opacities = opacities;
+ this.sizes = sizes;
+
+ this.particles = [];
+
+ for ( let i = 0; i < NOS_POOL_SIZE; i ++ ) {
+
+ this.particles.push( {
+ life: 0,
+ velocity: new THREE.Vector3(),
+ initialSize: 0,
+ } );
+
+ }
+
+ this.emitIndex = 0;
+
+ }
+
+ update( dt, vehicle ) {
+
+ const shouldEmit = vehicle.nosActive === true && vehicle.nosIntensity > 0.04;
+ let aliveCount = 0;
+
+ if ( shouldEmit ) {
+
+ _nosFwd.set( 0, 0, 1 ).applyQuaternion( vehicle.container.quaternion );
+ _nosFwd.y = 0;
+
+ if ( _nosFwd.lengthSq() > 1e-6 ) _nosFwd.normalize();
+
+ const yLift = vehicle.container.position.y + 0.1;
+ const bl = vehicle.wheelBL ? vehicle.wheelBL.getWorldPosition( _blPos ) : null;
+ const br = vehicle.wheelBR ? vehicle.wheelBR.getWorldPosition( _brPos ) : null;
+
+ for ( let i = 0; i < NOS_PARTICLES_PER_EMIT; i ++ ) {
+
+ if ( bl ) {
+
+ _nosSpawn.copy( bl ).addScaledVector( _nosFwd, - 0.42 );
+ _nosSpawn.y = yLift;
+ this.emitAt(
+ _nosSpawn.x + ( Math.random() - 0.5 ) * NOS_EMIT_JITTER,
+ _nosSpawn.y,
+ _nosSpawn.z + ( Math.random() - 0.5 ) * NOS_EMIT_JITTER,
+ _nosFwd
+ );
+
+ }
+
+ if ( br ) {
+
+ _nosSpawn.copy( br ).addScaledVector( _nosFwd, - 0.42 );
+ _nosSpawn.y = yLift;
+ this.emitAt(
+ _nosSpawn.x + ( Math.random() - 0.5 ) * NOS_EMIT_JITTER,
+ _nosSpawn.y,
+ _nosSpawn.z + ( Math.random() - 0.5 ) * NOS_EMIT_JITTER,
+ _nosFwd
+ );
+
+ }
+
+ }
+
+ }
+
+ const damping = 1 - dt * 0.85;
+
+ for ( let i = 0; i < NOS_POOL_SIZE; i ++ ) {
+
+ const p = this.particles[ i ];
+ if ( p.life <= 0 ) continue;
+
+ p.life -= dt;
+
+ if ( p.life <= 0 ) {
+
+ this.opacities[ i ] = 0;
+ aliveCount ++;
+ continue;
+
+ }
+
+ const t = 1 - p.life * INV_NOS_MAX_LIFE;
+
+ p.velocity.multiplyScalar( damping );
+
+ const posIdx = i * 3;
+ this.positions[ posIdx ] += p.velocity.x * dt;
+ this.positions[ posIdx + 1 ] += p.velocity.y * dt;
+ this.positions[ posIdx + 2 ] += p.velocity.z * dt;
+
+ this.opacities[ i ] = ( 1 - t ) * ( 0.28 + vehicle.nosIntensity * 0.22 );
+ this.sizes[ i ] = p.initialSize * ( 0.45 + t * 1.9 );
+
+ aliveCount ++;
+
+ }
+
+ if ( shouldEmit || aliveCount > 0 ) {
+
+ this.posAttr.needsUpdate = true;
+ this.opacityAttr.needsUpdate = true;
+ this.sizeAttr.needsUpdate = true;
+
+ }
+
+ }
+
+ emitAt( x, y, z, fwdXZ ) {
+
+ const i = this.emitIndex;
+ this.emitIndex = ( i + 1 ) % NOS_POOL_SIZE;
+
+ const p = this.particles[ i ];
+
+ const posIdx = i * 3;
+ this.positions[ posIdx ] = x;
+ this.positions[ posIdx + 1 ] = y;
+ this.positions[ posIdx + 2 ] = z;
+
+ p.initialSize = NOS_BASE_SIZE * ( 0.55 + Math.random() * 0.45 );
+
+ const bx = - fwdXZ.x * NOS_TRAIL_SPEED;
+ const bz = - fwdXZ.z * NOS_TRAIL_SPEED;
+
+ p.velocity.set(
+ bx + ( Math.random() - 0.5 ) * 1.2,
+ 0.15 + Math.random() * 0.35,
+ bz + ( Math.random() - 0.5 ) * 1.2
+ );
+
+ p.life = NOS_MAX_LIFE;
+
+ }
+
+}
+
diff --git a/js/Vehicle.js b/js/Vehicle.js
index 97eda6a..bcccb17 100644
--- a/js/Vehicle.js
+++ b/js/Vehicle.js
@@ -14,6 +14,15 @@ const SPEED_SCALE = 12.5;
const LINEAR_DAMP = 0.1;
export const MAX_SPEED = 1.5;
+const NOS_MAX_MULT = 1.65;
+const NOS_DRIVE_MULT = 1.5;
+export const NOS_DURATION = 2.0;
+const NOS_WHEEL_LIFT = 0.065;
+/** Extra pitch (rad), subtracted from rotation.x target — nose-up during NOS. */
+const NOS_BODY_NOSE_UP = 0.24;
+const NOS_DRIFT_THRESHOLD = 0.48;
+const NOS_DRIFT_RECHARGE = 0.55;
+
function lerpAngle( a, b, t ) {
let diff = b - a;
@@ -53,6 +62,13 @@ export class Vehicle {
this.driftIntensity = 0;
+ this.nosTankRemaining = NOS_DURATION;
+ this.nosActive = false;
+ this.nosIntensity = 0;
+
+ this.wheelFLBaseY = 0;
+ this.wheelFRBaseY = 0;
+
}
init( model ) {
@@ -92,6 +108,9 @@ export class Vehicle {
} );
+ if ( this.wheelFL ) this.wheelFLBaseY = this.wheelFL.position.y;
+ if ( this.wheelFR ) this.wheelFRBaseY = this.wheelFR.position.y;
+
return this.container;
}
@@ -101,6 +120,36 @@ export class Vehicle {
this.inputX = controlsInput.x;
this.inputZ = controlsInput.z;
+ const nosInput = controlsInput.nos === true;
+
+ const canBoost = this.nosTankRemaining > 0;
+ let nosOn = nosInput && canBoost;
+
+ if ( nosOn ) {
+
+ this.nosTankRemaining -= dt;
+
+ if ( this.nosTankRemaining <= 0 ) {
+
+ this.nosTankRemaining = 0;
+ nosOn = false;
+
+ }
+
+ }
+
+ this.nosActive = nosOn;
+
+ if ( this.nosActive ) {
+
+ this.nosIntensity = THREE.MathUtils.lerp( this.nosIntensity, 1, Math.min( 1, dt * 14 ) );
+
+ } else {
+
+ this.nosIntensity *= Math.exp( - dt * 9 );
+
+ }
+
if ( controlsInput.touchActive && ( this.inputX !== 0 || this.inputZ !== 0 ) ) {
// Touch: joystick defines world-space direction, auto-gas
@@ -112,7 +161,8 @@ export class Vehicle {
const cross = _forward.x * this.inputZ - _forward.z * this.inputX;
this.inputX = THREE.MathUtils.clamp( - cross * 2, - 1, 1 );
- this.linearSpeed = THREE.MathUtils.lerp( this.linearSpeed, MAX_SPEED, dt * 1.5 );
+ const touchCap = this.nosActive ? MAX_SPEED * NOS_MAX_MULT : MAX_SPEED;
+ this.linearSpeed = THREE.MathUtils.lerp( this.linearSpeed, touchCap, dt * 1.5 );
} else {
@@ -139,7 +189,8 @@ export class Vehicle {
} else {
- this.linearSpeed = THREE.MathUtils.lerp( this.linearSpeed, targetSpeed * MAX_SPEED, dt * 1.5 );
+ const forwardCap = MAX_SPEED * ( this.nosActive ? NOS_MAX_MULT : 1 );
+ this.linearSpeed = THREE.MathUtils.lerp( this.linearSpeed, targetSpeed * forwardCap, dt * 1.5 );
}
@@ -167,7 +218,8 @@ export class Vehicle {
_right.normalize();
const angvel = this.rigidBody.motionProperties.angularVelocity;
- const drive = this.linearSpeed * 100 * dt;
+ let drive = this.linearSpeed * 100 * dt;
+ if ( this.nosActive ) drive *= NOS_DRIVE_MULT;
rigidBody.setAngularVelocity( this.physicsWorld, this.rigidBody, [
angvel[ 0 ] + _right.x * drive,
@@ -204,6 +256,9 @@ export class Vehicle {
this.linearSpeed = 0;
this.angularSpeed = 0;
this.acceleration = 0;
+ this.nosTankRemaining = NOS_DURATION;
+ this.nosActive = false;
+ this.nosIntensity = 0;
this.container.rotation.set( 0, 0, 0 );
this.container.quaternion.identity();
@@ -228,6 +283,20 @@ export class Vehicle {
this.driftIntensity = Math.abs( this.linearSpeed - this.acceleration ) +
( this.bodyNode ? Math.abs( this.bodyNode.rotation.z ) * 2 : 0 );
+ if ( this.driftIntensity > NOS_DRIFT_THRESHOLD ) {
+
+ const driftFactor = THREE.MathUtils.clamp(
+ ( this.driftIntensity - NOS_DRIFT_THRESHOLD ) / ( 1.25 - NOS_DRIFT_THRESHOLD ),
+ 0,
+ 1
+ );
+ this.nosTankRemaining = Math.min(
+ NOS_DURATION,
+ this.nosTankRemaining + NOS_DRIFT_RECHARGE * dt * driftFactor
+ );
+
+ }
+
}
alignWithY( quaternion, newY ) {
@@ -245,9 +314,12 @@ export class Vehicle {
if ( ! this.bodyNode ) return;
+ let pitchTarget = -( this.linearSpeed - this.acceleration ) / 6;
+ if ( this.nosActive ) pitchTarget -= NOS_BODY_NOSE_UP;
+
this.bodyNode.rotation.x = lerpAngle(
this.bodyNode.rotation.x,
- -( this.linearSpeed - this.acceleration ) / 6,
+ pitchTarget,
dt * 10
);
@@ -281,6 +353,22 @@ export class Vehicle {
}
+ const liftTarget = this.nosActive ? NOS_WHEEL_LIFT : 0;
+
+ if ( this.wheelFL ) {
+
+ const ty = this.wheelFLBaseY + liftTarget;
+ this.wheelFL.position.y = THREE.MathUtils.lerp( this.wheelFL.position.y, ty, Math.min( 1, dt * 14 ) );
+
+ }
+
+ if ( this.wheelFR ) {
+
+ const ty = this.wheelFRBaseY + liftTarget;
+ this.wheelFR.position.y = THREE.MathUtils.lerp( this.wheelFR.position.y, ty, Math.min( 1, dt * 14 ) );
+
+ }
+
}
}
diff --git a/js/main.js b/js/main.js
index d87126a..2fa7073 100644
--- a/js/main.js
+++ b/js/main.js
@@ -8,10 +8,11 @@ import { Camera } from './Camera.js';
import { Controls } from './Controls.js';
import { buildTrack, decodeCells, computeSpawnPosition, computeTrackBounds } from './Track.js';
import { buildWallColliders, createSphereBody } from './Physics.js';
-import { SmokeTrails } from './Particles.js';
+import { SmokeTrails, NosTaillightTrails } from './Particles.js';
import { DriftMarks } from './DriftMarks.js';
import { GameAudio } from './Audio.js';
import { LapTimer } from './LapTimer.js';
+import { NosHud } from './NosHud.js';
import { ColorMapGLTFLoader } from './Loader.js';
@@ -226,12 +227,14 @@ async function init() {
const controls = new Controls();
const particles = new SmokeTrails( scene );
+ const nosTrails = new NosTaillightTrails( scene );
const driftMarks = new DriftMarks( scene, mapParam );
const audio = new GameAudio();
audio.init( cam.camera );
const lapTimer = new LapTimer( customCells, mapParam );
+ const nosHud = new NosHud( controls );
const _forward = new THREE.Vector3();
const _camLead = new THREE.Vector3();
@@ -266,6 +269,8 @@ async function init() {
vehicle.update( dt, input );
+ nosHud.update( vehicle );
+
dirLight.position.set(
vehicle.spherePos.x + 11.4,
15,
@@ -274,10 +279,11 @@ 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 );
+ cam.update( dt, vehicle.spherePos, _camLead, vehicle.nosIntensity );
particles.update( dt, vehicle );
+ nosTrails.update( dt, vehicle );
driftMarks.update( dt, vehicle );
- audio.update( dt, vehicle.linearSpeed / MAX_SPEED, input.z, vehicle.driftIntensity );
+ audio.update( dt, vehicle.linearSpeed / MAX_SPEED, input.z, vehicle.driftIntensity, vehicle.nosActive );
const hasInput = input.touchActive || Math.abs( input.x ) > 0.05 || Math.abs( input.z ) > 0.05;
lapTimer.update( dt, vehicle.spherePos, hasInput );