Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 21 additions & 1 deletion js/Audio.js
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,9 @@ export class GameAudio {
this.gear = 0;
this.shiftCooldown = 0;

this.nosWhoosh = null;
this.prevNosActive = false;

}

init( camera ) {
Expand All @@ -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';
Expand All @@ -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();

} );
Expand Down Expand Up @@ -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.
Expand Down
21 changes: 20 additions & 1 deletion js/Camera.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 ++ ) {
Expand All @@ -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;
Expand Down Expand Up @@ -106,6 +123,8 @@ export class Camera {
this.debug.position.y += 0.05;
this.debug.scale.set( radius, 1, radius );

this._applyNosCameraShake( dt, nosIntensity );

}

}
27 changes: 26 additions & 1 deletion js/Controls.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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();

Expand Down Expand Up @@ -120,6 +128,8 @@ export class Controls {

const gamepads = navigator.getGamepads();

let nos = false;

for ( const gp of gamepads ) {

if ( ! gp ) continue;
Expand All @@ -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;

}
Expand All @@ -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;

}

Expand Down
195 changes: 195 additions & 0 deletions js/NosHud.js
Original file line number Diff line number Diff line change
@@ -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 = `
<div class="nos-hud__label-row">
<button type="button" class="nos-hud__key-btn">X</button>
<div class="nos-hud__text">
<span class="nos-hud__title">NOS</span>
<span class="nos-hud__pct"><span class="nos-hud__percent">100</span>% left</span>
</div>
</div>
<div class="nos-hud__track">
<div class="nos-hud__fill"></div>
</div>
`;
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' );

}

}
Loading