diff --git a/js/Controls.js b/js/Controls.js index 8aad508..da2c5de 100644 --- a/js/Controls.js +++ b/js/Controls.js @@ -6,6 +6,9 @@ export class Controls { this.x = 0; this.z = 0; + this.prevSpace = false; + this.prevGamepadFire = false; + // Touch state this.touchActive = false; this.touchDirX = 0; @@ -112,6 +115,10 @@ export class Controls { if ( this.keys[ 'KeyW' ] || this.keys[ 'ArrowUp' ] ) z += 1; if ( this.keys[ 'KeyS' ] || this.keys[ 'ArrowDown' ] ) z -= 1; + const spaceDown = !! this.keys[ 'Space' ]; + let fire = spaceDown && ! this.prevSpace; + this.prevSpace = spaceDown; + // Gamepad const gamepads = navigator.getGamepads(); @@ -128,6 +135,10 @@ export class Controls { if ( rt > 0.1 || lt > 0.1 ) z = rt - lt; + const gpFire = gp.buttons[ 0 ] ? !! gp.buttons[ 0 ].pressed : false; + if ( gpFire && ! this.prevGamepadFire ) fire = true; + this.prevGamepadFire = gpFire; + break; } @@ -152,7 +163,7 @@ export class Controls { this.x = x; this.z = z; - return { x, z, touchActive: this.touchActive }; + return { x, z, touchActive: this.touchActive, fire }; } diff --git a/js/HitFX.js b/js/HitFX.js new file mode 100644 index 0000000..cfe12e3 --- /dev/null +++ b/js/HitFX.js @@ -0,0 +1,126 @@ +import * as THREE from 'three'; + +const RING_COUNT = 8; +const STAR_COUNT = 48; +const STARS_PER_BURST = 8; + +const RING_LIFETIME = 0.45; +const RING_MAX_SCALE = 2.8; +const STAR_LIFETIME = 0.7; +const STAR_SPEED_MIN = 3; +const STAR_SPEED_RANGE = 3; +const STAR_UP_MIN = 2.5; +const STAR_UP_RANGE = 2; +const STAR_GRAVITY = 11; + +const _ringGeom = new THREE.TorusGeometry( 0.4, 0.07, 8, 28 ); +const _starGeom = new THREE.IcosahedronGeometry( 0.15, 0 ); + +export class HitFX { + + constructor( scene ) { + + this.scene = scene; + + this.rings = []; + for ( let i = 0; i < RING_COUNT; i ++ ) { + + const mat = new THREE.MeshBasicMaterial( { color: 0xffee55, transparent: true, opacity: 0, depthWrite: false } ); + const mesh = new THREE.Mesh( _ringGeom, mat ); + mesh.rotation.x = - Math.PI / 2; + mesh.visible = false; + scene.add( mesh ); + this.rings.push( { mesh, material: mat, life: 0 } ); + + } + this.ringIndex = 0; + + this.stars = []; + for ( let i = 0; i < STAR_COUNT; i ++ ) { + + const mat = new THREE.MeshStandardMaterial( { + color: 0xfff29a, emissive: 0xffcc33, emissiveIntensity: 1.2, + roughness: 0.3, transparent: true, opacity: 0, + } ); + const mesh = new THREE.Mesh( _starGeom, mat ); + mesh.visible = false; + scene.add( mesh ); + this.stars.push( { + mesh, material: mat, life: 0, + vx: 0, vy: 0, vz: 0, + rotX: 0, rotY: 0, + } ); + + } + this.starIndex = 0; + + } + + burst( x, y, z ) { + + const ring = this.rings[ this.ringIndex ]; + this.ringIndex = ( this.ringIndex + 1 ) % RING_COUNT; + ring.mesh.visible = true; + ring.mesh.position.set( x, y + 0.1, z ); + ring.mesh.scale.setScalar( 0.2 ); + ring.material.opacity = 1; + ring.life = RING_LIFETIME; + + for ( let i = 0; i < STARS_PER_BURST; i ++ ) { + + const star = this.stars[ this.starIndex ]; + this.starIndex = ( this.starIndex + 1 ) % STAR_COUNT; + + const angle = ( i / STARS_PER_BURST ) * Math.PI * 2 + Math.random() * 0.4; + const speed = STAR_SPEED_MIN + Math.random() * STAR_SPEED_RANGE; + + star.mesh.visible = true; + star.mesh.position.set( x, y + 0.3, z ); + star.mesh.scale.setScalar( 0.8 + Math.random() * 0.6 ); + star.material.opacity = 1; + star.vx = Math.cos( angle ) * speed; + star.vz = Math.sin( angle ) * speed; + star.vy = STAR_UP_MIN + Math.random() * STAR_UP_RANGE; + star.rotX = ( Math.random() - 0.5 ) * 20; + star.rotY = ( Math.random() - 0.5 ) * 20; + star.life = STAR_LIFETIME; + + } + + } + + update( dt ) { + + for ( const r of this.rings ) { + + if ( r.life <= 0 ) continue; + r.life -= dt; + const prog = 1 - Math.max( 0, r.life / RING_LIFETIME ); + r.mesh.scale.setScalar( 0.2 + prog * RING_MAX_SCALE ); + r.material.opacity = Math.max( 0, 1 - prog ); + if ( r.life <= 0 ) r.mesh.visible = false; + + } + + for ( const s of this.stars ) { + + if ( s.life <= 0 ) continue; + s.life -= dt; + + s.mesh.position.x += s.vx * dt; + s.mesh.position.y += s.vy * dt; + s.mesh.position.z += s.vz * dt; + s.vy -= STAR_GRAVITY * dt; + + s.mesh.rotation.x += s.rotX * dt; + s.mesh.rotation.y += s.rotY * dt; + + s.material.opacity = Math.max( 0, s.life / STAR_LIFETIME ); + + if ( s.life <= 0 ) s.mesh.visible = false; + + } + + } + +} diff --git a/js/NPC.js b/js/NPC.js new file mode 100644 index 0000000..8137245 --- /dev/null +++ b/js/NPC.js @@ -0,0 +1,194 @@ +import * as THREE from 'three'; +import { rigidBody, box, MotionType } from 'crashcat'; + +const NPC_HALF_EXTENTS = [ 0.5, 0.4, 1.0 ]; +const HIT_DURATION = 2.2; +const HIT_SPIN_VEL = 22; +const SPIN_DECAY = 0.45; +const HIT_HOP_VEL = 5.5; +const HIT_GRAVITY = 18; +const HIT_TILT = 0.35; +const ARRIVAL_DIST_SQ = 4.0; +const DEFAULT_SPEED = 5.5; +const ROT_LERP = 5; + +// Outer loop waypoints around the figure-8 track, in driving order +export const WAYPOINTS = [ + [ 3.75, 11.25 ], + [ 3.75, 18.75 ], + [ -3.75, 18.75 ], + [ -11.25, 18.75 ], + [ -11.25, 11.25 ], + [ -11.25, -3.75 ], + [ -18.75, -11.25 ], + [ -18.75, -18.75 ], + [ -11.25, -18.75 ], + [ -3.75, -18.75 ], + [ 3.75, -18.75 ], + [ 3.75, -11.25 ], + [ 3.75, -3.75 ], + [ 3.75, 3.75 ], +]; + +function lerpAngle( a, b, t ) { + + let diff = b - a; + while ( diff > Math.PI ) diff -= Math.PI * 2; + while ( diff < - Math.PI ) diff += Math.PI * 2; + return a + diff * t; + +} + +function nearestWaypointIndex( x, z ) { + + let best = 0; + let bestDist = Infinity; + for ( let i = 0; i < WAYPOINTS.length; i ++ ) { + + const [ wx, wz ] = WAYPOINTS[ i ]; + const d = ( wx - x ) * ( wx - x ) + ( wz - z ) * ( wz - z ); + if ( d < bestDist ) { + + bestDist = d; + best = i; + + } + + } + + return best; + +} + +export class NPC { + + constructor( world, scene, modelSource, x, y, z, rotDeg, speed = DEFAULT_SPEED ) { + + this.world = world; + + this.mesh = modelSource.clone(); + this.mesh.position.set( x, y, z ); + this.mesh.rotation.y = THREE.MathUtils.degToRad( rotDeg + 180 ); + this.mesh.traverse( ( c ) => { + + if ( c.isMesh ) { + + c.castShadow = true; + c.receiveShadow = true; + + } + + } ); + scene.add( this.mesh ); + + this.bodyY = y + NPC_HALF_EXTENTS[ 1 ]; + this.body = rigidBody.create( world, { + shape: box.create( { halfExtents: NPC_HALF_EXTENTS } ), + motionType: MotionType.KINEMATIC, + objectLayer: world._OL_MOVING, + position: [ x, this.bodyY, z ], + quaternion: [ 0, Math.sin( this.mesh.rotation.y / 2 ), 0, Math.cos( this.mesh.rotation.y / 2 ) ], + } ); + + this.speed = speed; + this.waypointIndex = ( nearestWaypointIndex( x, z ) + 1 ) % WAYPOINTS.length; + + this.hitTimer = 0; + this.spinVel = 0; + this.hopVel = 0; + this.baseY = y; + + } + + hit() { + + this.hitTimer = HIT_DURATION; + this.spinVel = HIT_SPIN_VEL * ( Math.random() < 0.5 ? - 1 : 1 ); + this.hopVel = HIT_HOP_VEL; + + } + + update( dt ) { + + if ( this.hitTimer > 0 ) { + + this.hitTimer = Math.max( 0, this.hitTimer - dt ); + this.mesh.rotation.y += this.spinVel * dt; + this.spinVel *= Math.max( 0, 1 - SPIN_DECAY * dt ); + + this.hopVel -= HIT_GRAVITY * dt; + this.mesh.position.y = Math.max( this.baseY, this.mesh.position.y + this.hopVel * dt ); + if ( this.mesh.position.y <= this.baseY && this.hopVel < 0 ) { + + this.hopVel *= - 0.35; + this.mesh.position.y = this.baseY; + + } + + const tiltPhase = this.hitTimer * 9; + this.mesh.rotation.z = Math.sin( tiltPhase ) * HIT_TILT * ( this.hitTimer / HIT_DURATION ); + this.mesh.rotation.x = Math.cos( tiltPhase * 0.7 ) * HIT_TILT * 0.5 * ( this.hitTimer / HIT_DURATION ); + return; + + } + + if ( this.mesh.rotation.z !== 0 || this.mesh.rotation.x !== 0 ) { + + this.mesh.rotation.z *= Math.max( 0, 1 - dt * 6 ); + this.mesh.rotation.x *= Math.max( 0, 1 - dt * 6 ); + + } + + const [ tx, tz ] = WAYPOINTS[ this.waypointIndex ]; + const dx = tx - this.mesh.position.x; + const dz = tz - this.mesh.position.z; + const distSq = dx * dx + dz * dz; + + if ( distSq < ARRIVAL_DIST_SQ ) { + + this.waypointIndex = ( this.waypointIndex + 1 ) % WAYPOINTS.length; + return; + + } + + const dist = Math.sqrt( distSq ); + const dirX = dx / dist; + const dirZ = dz / dist; + const step = this.speed * dt; + + const newX = this.mesh.position.x + dirX * step; + const newZ = this.mesh.position.z + dirZ * step; + this.mesh.position.x = newX; + this.mesh.position.z = newZ; + + const targetRot = Math.atan2( dirX, dirZ ); + this.mesh.rotation.y = lerpAngle( this.mesh.rotation.y, targetRot, Math.min( 1, dt * ROT_LERP ) ); + + const halfAngle = this.mesh.rotation.y * 0.5; + rigidBody.setPosition( this.world, this.body, [ newX, this.bodyY, newZ ], false ); + rigidBody.setQuaternion( this.world, this.body, [ 0, Math.sin( halfAngle ), 0, Math.cos( halfAngle ) ], false ); + rigidBody.setLinearVelocity( this.world, this.body, [ dirX * this.speed, 0, dirZ * this.speed ] ); + + } + +} + +export function createNPCs( world, scene, models, npcDefs ) { + + const npcs = []; + const bodyToNPC = new Map(); + + for ( const [ key, x, y, z, rotDeg ] of npcDefs ) { + + const src = models[ key ]; + if ( ! src ) continue; + + const npc = new NPC( world, scene, src, x, y, z, rotDeg ); + npcs.push( npc ); + bodyToNPC.set( npc.body, npc ); + + } + + return { npcs, bodyToNPC }; + +} diff --git a/js/Shell.js b/js/Shell.js new file mode 100644 index 0000000..78414a5 --- /dev/null +++ b/js/Shell.js @@ -0,0 +1,198 @@ +import * as THREE from 'three'; +import { rigidBody, sphere, MotionType, MotionQuality } from 'crashcat'; + +const SHELL_SPEED = 25; +const SHELL_RADIUS = 0.25; +const SHELL_Y = 0.25; +const SHELL_LIFETIME = 6.0; +const SHELL_IGNORE_OWNER = 0.15; +const SPAWN_OFFSET = 1.2; + +const _shellDomeGeom = new THREE.SphereGeometry( SHELL_RADIUS, 16, 10, 0, Math.PI * 2, 0, Math.PI / 2 ); +_shellDomeGeom.scale( 1, 0.75, 1 ); +const _shellBellyGeom = new THREE.CylinderGeometry( SHELL_RADIUS * 0.95, SHELL_RADIUS * 0.7, 0.06, 16 ); +const _shellRimGeom = new THREE.TorusGeometry( SHELL_RADIUS * 0.98, 0.035, 6, 20 ); + +const _domeMat = new THREE.MeshStandardMaterial( { color: 0x2f7a32, roughness: 0.35, metalness: 0.0, flatShading: true } ); +const _bellyMat = new THREE.MeshStandardMaterial( { color: 0xe8c373, roughness: 0.6 } ); +const _rimMat = new THREE.MeshStandardMaterial( { color: 0x1f4f1f, roughness: 0.5 } ); + +function buildShellMesh() { + + const group = new THREE.Group(); + + const dome = new THREE.Mesh( _shellDomeGeom, _domeMat ); + dome.position.y = 0.02; + dome.castShadow = true; + group.add( dome ); + + // Darker hex segments on the shell, made from scaled-down spheres pressed into the dome + const segGeom = new THREE.IcosahedronGeometry( SHELL_RADIUS * 0.18, 0 ); + const segMat = new THREE.MeshStandardMaterial( { color: 0x4ca64c, roughness: 0.4, flatShading: true } ); + const segRing = 6; + for ( let i = 0; i < segRing; i ++ ) { + + const a = ( i / segRing ) * Math.PI * 2; + const r = SHELL_RADIUS * 0.55; + const seg = new THREE.Mesh( segGeom, segMat ); + seg.position.set( Math.cos( a ) * r, SHELL_RADIUS * 0.42, Math.sin( a ) * r ); + group.add( seg ); + + } + + const crown = new THREE.Mesh( segGeom, segMat ); + crown.position.y = SHELL_RADIUS * 0.7; + crown.scale.setScalar( 1.2 ); + group.add( crown ); + + const rim = new THREE.Mesh( _shellRimGeom, _rimMat ); + rim.rotation.x = Math.PI / 2; + rim.position.y = 0.02; + group.add( rim ); + + const belly = new THREE.Mesh( _shellBellyGeom, _bellyMat ); + belly.position.y = - 0.01; + group.add( belly ); + + return group; + +} + +export class Shell { + + constructor( world, scene, { position, direction, ownerBody } ) { + + this.world = world; + this.scene = scene; + this.ownerBody = ownerBody; + this.alive = true; + this.lifetime = SHELL_LIFETIME; + this.ignoreOwnerTimer = SHELL_IGNORE_OWNER; + + const dir = direction.clone(); + dir.y = 0; + dir.normalize(); + + const spawnPos = [ + position.x + dir.x * SPAWN_OFFSET, + SHELL_Y, + position.z + dir.z * SPAWN_OFFSET, + ]; + + this.body = rigidBody.create( world, { + shape: sphere.create( { radius: SHELL_RADIUS } ), + motionType: MotionType.DYNAMIC, + objectLayer: world._OL_MOVING, + position: spawnPos, + mass: 20, + friction: 0.0, + restitution: 1.0, + linearDamping: 0.0, + angularDamping: 0.0, + gravityFactor: 0, + motionQuality: MotionQuality.LINEAR_CAST, + } ); + + rigidBody.setLinearVelocity( world, this.body, [ + dir.x * SHELL_SPEED, + 0, + dir.z * SHELL_SPEED, + ] ); + + this.mesh = buildShellMesh(); + this.mesh.position.set( spawnPos[ 0 ], spawnPos[ 1 ], spawnPos[ 2 ] ); + this.spinAxis = Math.atan2( dir.x, dir.z ) + Math.PI / 2; + this.mesh.rotation.y = this.spinAxis; + scene.add( this.mesh ); + + } + + update( dt ) { + + if ( ! this.alive ) return; + + this.ignoreOwnerTimer = Math.max( 0, this.ignoreOwnerTimer - dt ); + this.lifetime -= dt; + + if ( this.lifetime <= 0 ) { + + this.alive = false; + return; + + } + + const pos = this.body.position; + const vel = this.body.motionProperties.linearVelocity; + + // Clamp to ground plane + if ( pos[ 1 ] !== SHELL_Y || vel[ 1 ] !== 0 ) { + + rigidBody.setPosition( this.world, this.body, [ pos[ 0 ], SHELL_Y, pos[ 2 ] ], false ); + rigidBody.setLinearVelocity( this.world, this.body, [ vel[ 0 ], 0, vel[ 2 ] ] ); + + } + + // Maintain constant horizontal speed (compensates for energy drift on bounces) + const speedSq = vel[ 0 ] * vel[ 0 ] + vel[ 2 ] * vel[ 2 ]; + if ( speedSq > 0.01 ) { + + const scale = SHELL_SPEED / Math.sqrt( speedSq ); + if ( Math.abs( scale - 1 ) > 0.02 ) { + + rigidBody.setLinearVelocity( this.world, this.body, [ + vel[ 0 ] * scale, + 0, + vel[ 2 ] * scale, + ] ); + + } + + } + + this.mesh.position.set( pos[ 0 ], pos[ 1 ], pos[ 2 ] ); + this.mesh.rotation.y += dt * 6; + + } + + onContact( otherBody, bodyToNPC, vehicle, hitFX ) { + + if ( otherBody === this.ownerBody ) { + + if ( this.ignoreOwnerTimer > 0 ) return; + if ( vehicle ) vehicle.stun(); + if ( hitFX ) { + + const p = this.body.position; + hitFX.burst( p[ 0 ], p[ 1 ], p[ 2 ] ); + + } + this.alive = false; + return; + + } + + const npc = bodyToNPC.get( otherBody ); + if ( npc ) { + + npc.hit(); + if ( hitFX ) { + + const p = this.body.position; + hitFX.burst( p[ 0 ], p[ 1 ], p[ 2 ] ); + + } + this.alive = false; + + } + // Walls and non-target bodies: let physics handle bounce + + } + + destroy() { + + rigidBody.remove( this.world, this.body ); + this.scene.remove( this.mesh ); + + } + +} diff --git a/js/Track.js b/js/Track.js index fe4a00e..da0d44f 100644 --- a/js/Track.js +++ b/js/Track.js @@ -109,7 +109,7 @@ const DECO_CELLS = [ [ 2, 4, 'decoration-forest', 0 ], ]; -const NPC_TRUCKS = [ +export const NPC_TRUCKS = [ [ 'vehicle-truck-green', -3.51, -0.01, 12.70, 98.0 ], [ 'vehicle-truck-purple', -23.78, -0.14, -13.56, 0.0 ], [ 'vehicle-truck-red', -1.36, -0.15, -23.80, 155.9 ], @@ -277,32 +277,6 @@ export function buildTrack( scene, models, customCells ) { } ); - if ( ! customCells ) { - - for ( const [ key, x, y, z, rotDeg ] of NPC_TRUCKS ) { - - const src = models[ key ]; - if ( ! src ) continue; - - const npc = src.clone(); - npc.position.set( x, y, z ); - npc.rotation.y = THREE.MathUtils.degToRad( rotDeg + 180 ); - npc.traverse( ( c ) => { - - if ( c.isMesh ) { - - c.castShadow = true; - c.receiveShadow = true; - - } - - } ); - scene.add( npc ); - - } - - } - } export function placePiece( models, key, gx, gz, orient ) { diff --git a/js/Vehicle.js b/js/Vehicle.js index e4d31ac..30a9e78 100644 --- a/js/Vehicle.js +++ b/js/Vehicle.js @@ -53,6 +53,20 @@ export class Vehicle { this.driftIntensity = 0; + this.stunTimer = 0; + this.stunDuration = 0; + this.stunSpinVel = 0; + this.stunHopVel = 0; + + } + + stun( duration = 2.2, spinVel = 24 ) { + + this.stunTimer = Math.max( this.stunTimer, duration ); + this.stunDuration = Math.max( this.stunDuration, duration ); + this.stunSpinVel = spinVel * ( Math.random() < 0.5 ? - 1 : 1 ); + this.stunHopVel = 5.5; + } init( model ) { @@ -98,6 +112,38 @@ export class Vehicle { update( dt, controlsInput ) { + if ( this.stunTimer > 0 ) { + + this.stunTimer = Math.max( 0, this.stunTimer - dt ); + this.container.rotateY( this.stunSpinVel * dt ); + this.stunSpinVel *= Math.max( 0, 1 - 0.55 * dt ); + this.linearSpeed *= Math.max( 0, 1 - 3.0 * dt ); + this.angularSpeed = 0; + this.inputX = 0; + this.inputZ = 0; + + // Dramatic tilt + hop on the body node + if ( this.bodyNode ) { + + const t = this.stunDuration > 0 ? ( this.stunTimer / this.stunDuration ) : 0; + const phase = this.stunTimer * 10; + this.bodyNode.rotation.z = Math.sin( phase ) * 0.5 * t; + this.bodyNode.rotation.x = Math.cos( phase * 0.6 ) * 0.35 * t; + this.stunHopVel -= 18 * dt; + this.bodyNode.position.y += this.stunHopVel * dt; + if ( this.bodyNode.position.y < 0.3 ) { + + this.bodyNode.position.y = 0.3; + if ( this.stunHopVel < 0 ) this.stunHopVel *= - 0.35; + + } + + } + + controlsInput = { x: 0, z: 0, touchActive: false, fire: false }; + + } + this.inputX = controlsInput.x; this.inputZ = controlsInput.z; diff --git a/js/main.js b/js/main.js index 8a7d1e0..b5a85b6 100644 --- a/js/main.js +++ b/js/main.js @@ -7,11 +7,14 @@ import { createWorldSettings, createWorld, addBroadphaseLayer, addObjectLayer, e import { Vehicle, MAX_SPEED } from './Vehicle.js'; import { Camera } from './Camera.js'; import { Controls } from './Controls.js'; -import { buildTrack, decodeCells, computeSpawnPosition, computeTrackBounds } from './Track.js'; +import { buildTrack, decodeCells, computeSpawnPosition, computeTrackBounds, NPC_TRUCKS } from './Track.js'; import { buildWallColliders, createSphereBody } from './Physics.js'; import { SmokeTrails } from './Particles.js'; import { DriftMarks } from './DriftMarks.js'; import { GameAudio } from './Audio.js'; +import { Shell } from './Shell.js'; +import { createNPCs } from './NPC.js'; +import { HitFX } from './HitFX.js'; const renderer = new THREE.WebGLRenderer( { antialias: true, outputBufferType: THREE.HalfFloatType } ); @@ -188,6 +191,10 @@ async function init() { buildWallColliders( world, null, customCells ); + const { npcs, bodyToNPC } = customCells + ? { npcs: [], bodyToNPC: new Map() } + : createNPCs( world, scene, models, NPC_TRUCKS ); + const roadHalf = groundSize / 2; rigidBody.create( world, { shape: box.create( { halfExtents: [ roadHalf, 0.01, roadHalf ] } ), @@ -225,23 +232,38 @@ async function init() { const particles = new SmokeTrails( scene ); const driftMarks = new DriftMarks( scene ); + const hitFX = new HitFX( scene ); const audio = new GameAudio(); audio.init( cam.camera ); const _forward = new THREE.Vector3(); + const shells = []; + let shellCooldown = 0; + const MAX_ACTIVE_SHELLS = 3; + const SHELL_COOLDOWN = 1.5; const contactListener = { onContactAdded( bodyA, bodyB ) { - if ( bodyA !== sphereBody && bodyB !== sphereBody ) return; + if ( bodyA === sphereBody || bodyB === sphereBody ) { - _forward.set( 0, 0, 1 ).applyQuaternion( vehicle.container.quaternion ); - _forward.y = 0; - _forward.normalize(); + _forward.set( 0, 0, 1 ).applyQuaternion( vehicle.container.quaternion ); + _forward.y = 0; + _forward.normalize(); + + const impactVelocity = Math.abs( vehicle.modelVelocity.dot( _forward ) ); + audio.playImpact( impactVelocity ); + + } + + for ( const shell of shells ) { - const impactVelocity = Math.abs( vehicle.modelVelocity.dot( _forward ) ); - audio.playImpact( impactVelocity ); + if ( ! shell.alive ) continue; + if ( shell.body === bodyA ) shell.onContact( bodyB, bodyToNPC, vehicle, hitFX ); + else if ( shell.body === bodyB ) shell.onContact( bodyA, bodyToNPC, vehicle, hitFX ); + + } } }; @@ -261,6 +283,39 @@ async function init() { vehicle.update( dt, input ); + shellCooldown = Math.max( 0, shellCooldown - dt ); + + if ( input.fire && shellCooldown === 0 && shells.length < MAX_ACTIVE_SHELLS ) { + + _forward.set( 0, 0, 1 ).applyQuaternion( vehicle.container.quaternion ); + _forward.y = 0; + _forward.normalize(); + + shells.push( new Shell( world, scene, { + position: vehicle.spherePos, + direction: _forward, + ownerBody: sphereBody, + } ) ); + + shellCooldown = SHELL_COOLDOWN; + + } + + for ( const shell of shells ) shell.update( dt ); + + for ( let i = shells.length - 1; i >= 0; i -- ) { + + if ( ! shells[ i ].alive ) { + + shells[ i ].destroy(); + shells.splice( i, 1 ); + + } + + } + + for ( const npc of npcs ) npc.update( dt ); + dirLight.position.set( vehicle.spherePos.x + 11.4, 15, @@ -270,6 +325,7 @@ async function init() { cam.update( dt, vehicle.spherePos ); particles.update( dt, vehicle ); driftMarks.update( dt, vehicle ); + hitFX.update( dt ); audio.update( dt, vehicle.linearSpeed / MAX_SPEED, input.z, vehicle.driftIntensity ); renderer.render( scene, cam.camera );