From e46a1ed345630ccfae7455256a8e3a0416d8123c Mon Sep 17 00:00:00 2001 From: Makio64 Date: Mon, 20 Apr 2026 09:35:39 +0200 Subject: [PATCH 1/5] Add throwable turtle shell and NPC racing opponents - Press Space (or gamepad A) to throw a bouncy turtle shell projectile that ricochets off walls and knocks trucks into a spin - Convert the three static NPC trucks into kinematic waypoint-following racers looping around the outer track - Shell hit spins target ~22 rad/s for 2.2 s with vertical hop and tilt wobble; ricochet also stuns the player - Hit burst effect: expanding yellow ring plus pooled gold stars with gravity Co-Authored-By: Claude Opus 4.7 (1M context) --- js/Controls.js | 13 +++- js/HitFX.js | 126 +++++++++++++++++++++++++++++++ js/NPC.js | 194 ++++++++++++++++++++++++++++++++++++++++++++++++ js/Shell.js | 198 +++++++++++++++++++++++++++++++++++++++++++++++++ js/Track.js | 28 +------ js/Vehicle.js | 46 ++++++++++++ js/main.js | 70 +++++++++++++++-- 7 files changed, 640 insertions(+), 35 deletions(-) create mode 100644 js/HitFX.js create mode 100644 js/NPC.js create mode 100644 js/Shell.js 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 ); From fe8d7ada746905237460672fda620e7809b97356 Mon Sep 17 00:00:00 2001 From: Makio64 Date: Mon, 20 Apr 2026 15:05:05 +0200 Subject: [PATCH 2/5] Add transformer mode: car unfolds into a shell-firing mech - Press T (or gamepad B) to run a staged choreography: anticipation dip, body liftoff with overshoot, spine extrude, chest swing, head slide-up, visor flicker open, shoulder cannons unfold, final pose snap - Physics body swaps to KINEMATIC during transform so the vehicle freezes in place; reverse transform restores DYNAMIC - In robot mode A/D rotates the torso turret, W/S tilts cannon pitch, Space rapid-fires shells from alternating cannon tips at 0.12 s cooldown (max 12 active) - Each shot recoils the cannon, pulses the red eye visor, and heats the muzzle tip toward orange glow - Transform FX: staged shockwave rings, steam puffs, cyan electric arcs, yellow screen flash, camera shake, and Mario-Kart-style star burst on the final pose - Procedural mech built from shared chrome / dark panel / yellow / blue-core / red-emissive materials to keep draw calls low Co-Authored-By: Claude Opus 4.7 (1M context) --- js/Controls.js | 12 +- js/MuzzleFlash.js | 90 +++++++++ js/Robot.js | 478 ++++++++++++++++++++++++++++++++++++++++++++++ js/TransformFX.js | 217 +++++++++++++++++++++ js/Vehicle.js | 226 +++++++++++++++++++++- js/main.js | 70 ++++++- 6 files changed, 1081 insertions(+), 12 deletions(-) create mode 100644 js/MuzzleFlash.js create mode 100644 js/Robot.js create mode 100644 js/TransformFX.js diff --git a/js/Controls.js b/js/Controls.js index da2c5de..0fda453 100644 --- a/js/Controls.js +++ b/js/Controls.js @@ -8,6 +8,8 @@ export class Controls { this.prevSpace = false; this.prevGamepadFire = false; + this.prevT = false; + this.prevGamepadTransform = false; // Touch state this.touchActive = false; @@ -119,6 +121,10 @@ export class Controls { let fire = spaceDown && ! this.prevSpace; this.prevSpace = spaceDown; + const tDown = !! this.keys[ 'KeyT' ]; + let transform = tDown && ! this.prevT; + this.prevT = tDown; + // Gamepad const gamepads = navigator.getGamepads(); @@ -139,6 +145,10 @@ export class Controls { if ( gpFire && ! this.prevGamepadFire ) fire = true; this.prevGamepadFire = gpFire; + const gpTransform = gp.buttons[ 1 ] ? !! gp.buttons[ 1 ].pressed : false; + if ( gpTransform && ! this.prevGamepadTransform ) transform = true; + this.prevGamepadTransform = gpTransform; + break; } @@ -163,7 +173,7 @@ export class Controls { this.x = x; this.z = z; - return { x, z, touchActive: this.touchActive, fire }; + return { x, z, touchActive: this.touchActive, fire, transform }; } diff --git a/js/MuzzleFlash.js b/js/MuzzleFlash.js new file mode 100644 index 0000000..58b1c5a --- /dev/null +++ b/js/MuzzleFlash.js @@ -0,0 +1,90 @@ +import * as THREE from 'three'; + +const POOL_SIZE = 8; +const LIFETIME = 0.11; + +const _coreGeom = new THREE.IcosahedronGeometry( 0.22, 0 ); +const _flareGeom = new THREE.PlaneGeometry( 0.9, 0.9 ); + +export class MuzzleFlash { + + constructor( scene ) { + + this.scene = scene; + this.cores = []; + this.flares = []; + this.index = 0; + + for ( let i = 0; i < POOL_SIZE; i ++ ) { + + const coreMat = new THREE.MeshBasicMaterial( { + color: 0xffc266, transparent: true, opacity: 0, depthWrite: false, + } ); + const core = new THREE.Mesh( _coreGeom, coreMat ); + core.visible = false; + scene.add( core ); + + const flareMat = new THREE.MeshBasicMaterial( { + color: 0xfff1b0, transparent: true, opacity: 0, depthWrite: false, depthTest: false, + } ); + const flare = new THREE.Mesh( _flareGeom, flareMat ); + flare.visible = false; + scene.add( flare ); + + this.cores.push( { mesh: core, material: coreMat, life: 0 } ); + this.flares.push( { mesh: flare, material: flareMat, life: 0 } ); + + } + + } + + burst( x, y, z ) { + + const i = this.index; + this.index = ( this.index + 1 ) % POOL_SIZE; + + const core = this.cores[ i ]; + core.mesh.visible = true; + core.mesh.position.set( x, y, z ); + core.mesh.scale.setScalar( 1 ); + core.material.opacity = 1; + core.life = LIFETIME; + + const flare = this.flares[ i ]; + flare.mesh.visible = true; + flare.mesh.position.set( x, y, z ); + flare.mesh.rotation.z = Math.random() * Math.PI; + flare.mesh.scale.setScalar( 1.2 ); + flare.material.opacity = 1; + flare.life = LIFETIME; + + } + + update( dt, cameraQuat ) { + + for ( const c of this.cores ) { + + if ( c.life <= 0 ) continue; + c.life -= dt; + const t = Math.max( 0, c.life / LIFETIME ); + c.mesh.scale.setScalar( 0.4 + t * 0.8 ); + c.material.opacity = t; + if ( c.life <= 0 ) c.mesh.visible = false; + + } + + for ( const f of this.flares ) { + + if ( f.life <= 0 ) continue; + f.life -= dt; + const t = Math.max( 0, f.life / LIFETIME ); + if ( cameraQuat ) f.mesh.quaternion.copy( cameraQuat ); + f.mesh.scale.setScalar( 0.5 + ( 1 - t ) * 1.6 ); + f.material.opacity = t * 0.9; + if ( f.life <= 0 ) f.mesh.visible = false; + + } + + } + +} diff --git a/js/Robot.js b/js/Robot.js new file mode 100644 index 0000000..32889f6 --- /dev/null +++ b/js/Robot.js @@ -0,0 +1,478 @@ +import * as THREE from 'three'; + +const smoothstep = ( lo, hi, x ) => { + + const t = Math.max( 0, Math.min( 1, ( x - lo ) / ( hi - lo ) ) ); + return t * t * ( 3 - 2 * t ); + +}; + +const easeOutBack = ( t ) => { + + const c1 = 1.70158; + const c3 = c1 + 1; + return 1 + c3 * Math.pow( t - 1, 3 ) + c1 * Math.pow( t - 1, 2 ); + +}; + +// --- Shared materials (draw-call friendly) --- +const chromeMat = new THREE.MeshStandardMaterial( { color: 0x2a2f3a, metalness: 0.85, roughness: 0.3 } ); +const darkPanelMat = new THREE.MeshStandardMaterial( { color: 0x181b22, metalness: 0.4, roughness: 0.55 } ); +const yellowMat = new THREE.MeshStandardMaterial( { color: 0xf2c32c, metalness: 0.2, roughness: 0.5, emissive: 0x553d00, emissiveIntensity: 0.25 } ); +const redEmissiveMat = new THREE.MeshStandardMaterial( { color: 0xff2a1a, emissive: 0xff2010, emissiveIntensity: 0, roughness: 0.25, metalness: 0.1 } ); +const blueCoreMat = new THREE.MeshStandardMaterial( { color: 0x33aaff, emissive: 0x2088ff, emissiveIntensity: 1.2, roughness: 0.2, metalness: 0.1 } ); +const muzzleGlowMat = new THREE.MeshStandardMaterial( { color: 0xff9840, emissive: 0xff5a10, emissiveIntensity: 0.6, roughness: 0.3 } ); + +const _tmpVec = new THREE.Vector3(); +const _tmpQuat = new THREE.Quaternion(); + +function podMesh() { + + const g = new THREE.Group(); + const main = new THREE.Mesh( new THREE.BoxGeometry( 0.55, 0.5, 0.9 ), chromeMat ); + g.add( main ); + const trim = new THREE.Mesh( new THREE.BoxGeometry( 0.62, 0.12, 0.35 ), yellowMat ); + trim.position.y = 0.12; + g.add( trim ); + const hub = new THREE.Mesh( new THREE.CylinderGeometry( 0.18, 0.18, 0.62, 12 ), darkPanelMat ); + hub.rotation.z = Math.PI / 2; + hub.position.set( 0.26, 0, 0 ); + g.add( hub ); + return g; + +} + +function splitterPlate() { + + const m = new THREE.Mesh( new THREE.BoxGeometry( 0.9, 0.05, 0.35 ), darkPanelMat ); + return m; + +} + +function buildHead() { + + const head = new THREE.Group(); + + const skull = new THREE.Mesh( new THREE.BoxGeometry( 0.55, 0.5, 0.55 ), chromeMat ); + head.add( skull ); + + // Side plates + const sideL = new THREE.Mesh( new THREE.BoxGeometry( 0.08, 0.35, 0.4 ), yellowMat ); + sideL.position.set( - 0.31, 0.02, 0 ); + head.add( sideL ); + const sideR = sideL.clone(); + sideR.position.x = 0.31; + head.add( sideR ); + + // Visor plate (rotates open). Pivot axis at its top edge — use a pivot group. + const visorPivot = new THREE.Group(); + visorPivot.position.set( 0, 0.12, 0.28 ); + head.add( visorPivot ); + const visor = new THREE.Mesh( new THREE.BoxGeometry( 0.44, 0.22, 0.05 ), darkPanelMat ); + visor.position.y = - 0.11; + visorPivot.add( visor ); + + // Eye strip — revealed as visor opens + const eye = new THREE.Mesh( new THREE.BoxGeometry( 0.36, 0.09, 0.02 ), redEmissiveMat ); + eye.position.set( 0, 0.02, 0.28 ); + head.add( eye ); + + // Antenna pivot for overshoot animation + const antennaPivot = new THREE.Group(); + antennaPivot.position.set( - 0.15, 0.25, 0 ); + head.add( antennaPivot ); + const antenna = new THREE.Mesh( new THREE.CylinderGeometry( 0.025, 0.035, 0.45, 6 ), chromeMat ); + antenna.position.y = 0.225; + antennaPivot.add( antenna ); + const antennaTip = new THREE.Mesh( new THREE.SphereGeometry( 0.06, 10, 8 ), redEmissiveMat ); + antennaTip.position.y = 0.48; + antennaPivot.add( antennaTip ); + + return { head, visorPivot, antennaPivot }; + +} + +function buildCannonArm() { + + // An arm group with pivot at shoulder. Cannon extends along +Z. + const arm = new THREE.Group(); + + const upperArm = new THREE.Mesh( new THREE.BoxGeometry( 0.22, 0.22, 0.35 ), chromeMat ); + upperArm.position.set( 0, 0, 0.17 ); + arm.add( upperArm ); + + const elbow = new THREE.Mesh( new THREE.SphereGeometry( 0.14, 12, 8 ), yellowMat ); + elbow.position.set( 0, 0, 0.35 ); + arm.add( elbow ); + + // Cannon barrel: stack of cylinders + const barrelGroup = new THREE.Group(); + barrelGroup.position.set( 0, 0, 0.5 ); + arm.add( barrelGroup ); + + const barrelBase = new THREE.Mesh( new THREE.CylinderGeometry( 0.13, 0.15, 0.3, 14 ), darkPanelMat ); + barrelBase.rotation.x = Math.PI / 2; + barrelBase.position.z = 0.05; + barrelGroup.add( barrelBase ); + + const barrelMid = new THREE.Mesh( new THREE.CylinderGeometry( 0.1, 0.13, 0.35, 14 ), chromeMat ); + barrelMid.rotation.x = Math.PI / 2; + barrelMid.position.z = 0.3; + barrelGroup.add( barrelMid ); + + const barrelTip = new THREE.Mesh( new THREE.CylinderGeometry( 0.09, 0.1, 0.2, 14 ), chromeMat ); + barrelTip.rotation.x = Math.PI / 2; + barrelTip.position.z = 0.56; + barrelGroup.add( barrelTip ); + + // Muzzle glow at tip — also serves as fire origin pivot + const muzzle = new THREE.Mesh( new THREE.SphereGeometry( 0.095, 12, 8 ), muzzleGlowMat.clone() ); + muzzle.position.z = 0.72; + barrelGroup.add( muzzle ); + + // Vents on the sides of the base for flavor + for ( let i = - 1; i <= 1; i += 2 ) { + + const vent = new THREE.Mesh( new THREE.BoxGeometry( 0.05, 0.18, 0.2 ), yellowMat ); + vent.position.set( i * 0.15, 0, 0.1 ); + barrelGroup.add( vent ); + + } + + return { arm, barrelGroup, muzzle }; + +} + +export class Robot { + + constructor() { + + this.root = new THREE.Group(); + this.root.visible = false; + + // --- Ground-level pods (old wheels become these visually) --- + this.pods = []; + const podOffsets = [ + [ - 0.9, - 1.0 ], [ 0.9, - 1.0 ], + [ - 0.9, 1.0 ], [ 0.9, 1.0 ], + ]; + for ( let i = 0; i < 4; i ++ ) { + + const p = podMesh(); + p.userData.baseX = podOffsets[ i ][ 0 ]; + p.userData.baseZ = podOffsets[ i ][ 1 ]; + p.userData.side = Math.sign( podOffsets[ i ][ 0 ] ); + p.visible = false; + this.root.add( p ); + this.pods.push( p ); + + } + + // --- Chassis splitter plates (explode outward from under the body) --- + this.splitters = []; + for ( let i = 0; i < 4; i ++ ) { + + const s = splitterPlate(); + s.userData.angle = ( i / 4 ) * Math.PI * 2 + Math.PI / 4; + s.visible = false; + this.root.add( s ); + this.splitters.push( s ); + + } + + // --- Torso pivot (holds everything that yaws) --- + this.torsoPivot = new THREE.Group(); + this.torsoPivot.position.y = 0.1; + this.root.add( this.torsoPivot ); + + // Spine cylinder extruding upward + this.spine = new THREE.Mesh( new THREE.CylinderGeometry( 0.12, 0.14, 0.9, 10 ), chromeMat ); + this.spine.position.y = 0.45; + this.spine.scale.y = 0.001; + this.spine.visible = false; + this.torsoPivot.add( this.spine ); + + // Chest plate group (pivot at base, swings forward briefly during deploy) + this.chest = new THREE.Group(); + this.chest.position.y = 0.7; + this.chest.visible = false; + this.torsoPivot.add( this.chest ); + const chestMain = new THREE.Mesh( new THREE.BoxGeometry( 0.9, 0.65, 0.5 ), chromeMat ); + this.chest.add( chestMain ); + const chestTrim = new THREE.Mesh( new THREE.BoxGeometry( 1.0, 0.12, 0.2 ), yellowMat ); + chestTrim.position.set( 0, 0.2, 0.2 ); + this.chest.add( chestTrim ); + // Glowing core + this.core = new THREE.Mesh( new THREE.IcosahedronGeometry( 0.14, 0 ), blueCoreMat ); + this.core.position.set( 0, 0, 0.27 ); + this.chest.add( this.core ); + const coreRing = new THREE.Mesh( new THREE.TorusGeometry( 0.18, 0.025, 8, 18 ), yellowMat ); + coreRing.position.copy( this.core.position ); + this.chest.add( coreRing ); + + // Back plate (hinged up) + this.backPivot = new THREE.Group(); + this.backPivot.position.set( 0, 0.32, - 0.25 ); + this.chest.add( this.backPivot ); + const backPlate = new THREE.Mesh( new THREE.BoxGeometry( 0.85, 0.15, 0.4 ), darkPanelMat ); + backPlate.position.y = 0.075; + this.backPivot.add( backPlate ); + + // Head + const headAssembly = buildHead(); + this.head = headAssembly.head; + this.visorPivot = headAssembly.visorPivot; + this.antennaPivot = headAssembly.antennaPivot; + this.head.position.y = 0.3; // above chest; animated + this.head.visible = false; + this.chest.add( this.head ); + // Find the eye mesh for pulse + this.eyeMesh = this.head.children.find( ( m ) => m.material === redEmissiveMat ); + + // Shoulder pivots (splay slightly outward) + this.leftShoulderPivot = new THREE.Group(); + this.leftShoulderPivot.position.set( - 0.58, 0.28, 0 ); + this.leftShoulderPivot.rotation.z = 0.15; + this.leftShoulderPivot.visible = false; + this.chest.add( this.leftShoulderPivot ); + + this.rightShoulderPivot = new THREE.Group(); + this.rightShoulderPivot.position.set( 0.58, 0.28, 0 ); + this.rightShoulderPivot.rotation.z = - 0.15; + this.rightShoulderPivot.visible = false; + this.chest.add( this.rightShoulderPivot ); + + // Shoulder blocks + const shoulderBlockL = new THREE.Mesh( new THREE.BoxGeometry( 0.35, 0.3, 0.35 ), chromeMat ); + this.leftShoulderPivot.add( shoulderBlockL ); + const shoulderCapL = new THREE.Mesh( new THREE.SphereGeometry( 0.2, 12, 8, 0, Math.PI * 2, 0, Math.PI / 2 ), yellowMat ); + shoulderCapL.position.set( - 0.12, 0.08, 0 ); + shoulderCapL.rotation.z = - Math.PI / 2; + this.leftShoulderPivot.add( shoulderCapL ); + + const shoulderBlockR = shoulderBlockL.clone(); + this.rightShoulderPivot.add( shoulderBlockR ); + const shoulderCapR = shoulderCapL.clone(); + shoulderCapR.position.x = 0.12; + shoulderCapR.rotation.z = Math.PI / 2; + this.rightShoulderPivot.add( shoulderCapR ); + + // Cannon arms + const leftCannon = buildCannonArm(); + this.leftArm = leftCannon.arm; + this.leftArm.rotation.x = Math.PI; // folded back + this.leftShoulderPivot.add( this.leftArm ); + this.leftBarrel = leftCannon.barrelGroup; + this.leftMuzzle = leftCannon.muzzle; + + const rightCannon = buildCannonArm(); + this.rightArm = rightCannon.arm; + this.rightArm.rotation.x = Math.PI; + this.rightShoulderPivot.add( this.rightArm ); + this.rightBarrel = rightCannon.barrelGroup; + this.rightMuzzle = rightCannon.muzzle; + + // Runtime state + this.leftRecoil = 0; + this.rightRecoil = 0; + this.leftHeat = 0; + this.rightHeat = 0; + this.eyePulse = 0; + this.idleBob = 0; + this.progress = 0; + this.turretYaw = 0; + this.turretPitch = 0; + this.time = 0; + + } + + getRoot() { + + return this.root; + + } + + setProgress( p ) { + + this.progress = p; + if ( p < 0.001 ) { + + this.root.visible = false; + return; + + } + this.root.visible = true; + + // Phases + const dip = smoothstep( 0.0, 0.10, p ); + const lift = smoothstep( 0.10, 0.30, p ); + const spinePhase = smoothstep( 0.30, 0.55, p ); + const chestSwingRaw = smoothstep( 0.30, 0.55, p ); + const headPhase = smoothstep( 0.55, 0.75, p ); + const armsPhase = smoothstep( 0.55, 0.75, p ); + const posePhase = smoothstep( 0.75, 1.0, p ); + + const liftEB = lift > 0 ? easeOutBack( lift ) : 0; + const headEB = headPhase > 0 ? easeOutBack( headPhase ) : 0; + const armsEB = armsPhase > 0 ? easeOutBack( armsPhase ) : 0; + const poseEB = posePhase > 0 ? easeOutBack( posePhase ) : 0; + + // Root dip during anticipation + this.root.position.y = - dip * 0.08 + spinePhase * 0.1; + + // Pods splay outward + for ( let i = 0; i < 4; i ++ ) { + + const pod = this.pods[ i ]; + pod.visible = liftEB > 0.001; + const s = Math.max( 0.001, liftEB ); + pod.scale.setScalar( s ); + const side = pod.userData.side; + pod.position.x = pod.userData.baseX + side * liftEB * 0.45; + pod.position.z = pod.userData.baseZ; + pod.position.y = - 0.1 + liftEB * 0.05; + pod.rotation.z = - side * liftEB * 0.6; + + } + + // Chassis splitters explode outward + for ( let i = 0; i < 4; i ++ ) { + + const s = this.splitters[ i ]; + s.visible = liftEB > 0.001; + s.scale.setScalar( Math.max( 0.001, liftEB ) ); + const ang = s.userData.angle; + const r = 0.25 + liftEB * 0.6; + s.position.set( Math.cos( ang ) * r, - 0.2, Math.sin( ang ) * r ); + s.rotation.y = ang; + s.rotation.z = liftEB * 0.4; + + } + + // Spine extrudes up + this.spine.visible = spinePhase > 0.001; + this.spine.scale.y = Math.max( 0.001, spinePhase ); + + // Chest appears with spine, swings forward via a sin pulse + this.chest.visible = spinePhase > 0.05; + this.chest.scale.setScalar( Math.max( 0.001, spinePhase ) ); + const swingAmp = Math.sin( chestSwingRaw * Math.PI ) * 0.45; + this.chest.rotation.x = swingAmp * 0.5; + + // Back plate hinges up + this.backPivot.rotation.x = - ( Math.PI / 2 ) * ( 1 - spinePhase ); + + // Core light pulses + this.core.material.emissiveIntensity = 0.4 + spinePhase * 0.8 + Math.sin( this.time * 4 ) * 0.15 + poseEB * 0.9; + + // Head grows + this.head.visible = headEB > 0.001; + this.head.position.y = 0.28 + headEB * 0.55; + this.head.scale.setScalar( Math.max( 0.001, headEB ) ); + + // Visor rotates open: from flat over eye to flipped up + this.visorPivot.rotation.x = - headEB * 1.4; + + // Eye emissive with flicker around 0.60..0.70 + const flicker = ( p > 0.60 && p < 0.70 ) ? ( Math.sin( p * 220 ) > 0 ? 1 : 0.25 ) : 1; + const baseEye = headPhase * 1.3 + this.eyePulse; + if ( this.eyeMesh ) this.eyeMesh.material.emissiveIntensity = baseEye * flicker; + + // Antenna pop — overshoot via poseEB + this.antennaPivot.scale.y = 0.001 + poseEB * 1.1; + + // Shoulders visible with chest, deploy via spinePhase scale + this.leftShoulderPivot.visible = spinePhase > 0.05; + this.rightShoulderPivot.visible = spinePhase > 0.05; + + // Cannon arm fold→deploy. Folded at rotation.x = Math.PI, deployed at 0, with overshoot oscillation in pose. + const deployAngle = Math.PI * ( 1 - armsEB ); + const springPhase = posePhase * 14; + const springAmp = ( 1 - posePhase ) * 0.25; + const leftRecoilOffset = - this.leftRecoil * 0.25; + const rightRecoilOffset = - this.rightRecoil * 0.25; + + this.leftArm.rotation.x = deployAngle + Math.sin( springPhase ) * springAmp - this.turretPitch + leftRecoilOffset; + this.rightArm.rotation.x = deployAngle + Math.sin( springPhase + 0.3 ) * springAmp - this.turretPitch + rightRecoilOffset; + + // Muzzle heat glow + this.leftMuzzle.material.emissiveIntensity = 0.6 + this.leftHeat * 2.5; + this.rightMuzzle.material.emissiveIntensity = 0.6 + this.rightHeat * 2.5; + + // Apply turret yaw + this.torsoPivot.rotation.y = this.turretYaw; + + } + + setTurretYaw( yaw ) { + + this.turretYaw = yaw; + + } + + setTurretPitch( pitch ) { + + this.turretPitch = pitch; + + } + + onShotFired( side ) { + + if ( side === 'left' ) { + + this.leftRecoil = 1; + this.leftHeat = Math.min( 1, this.leftHeat + 0.35 ); + + } else { + + this.rightRecoil = 1; + this.rightHeat = Math.min( 1, this.rightHeat + 0.35 ); + + } + + this.eyePulse = 0.5; + + } + + getCannonTransform( side, outPos, outDir ) { + + const muzzle = side === 'left' ? this.leftMuzzle : this.rightMuzzle; + muzzle.updateWorldMatrix( true, false ); + outPos.setFromMatrixPosition( muzzle.matrixWorld ); + muzzle.getWorldQuaternion( _tmpQuat ); + outDir.set( 0, 0, 1 ).applyQuaternion( _tmpQuat ); + outDir.y = 0; + outDir.normalize(); + + } + + update( dt ) { + + this.time += dt; + + // Recoil springs: decay recoil values toward 0 + this.leftRecoil = Math.max( 0, this.leftRecoil - dt * 7 ); + this.rightRecoil = Math.max( 0, this.rightRecoil - dt * 7 ); + + // Heat decay + this.leftHeat = Math.max( 0, this.leftHeat - dt * 1.1 ); + this.rightHeat = Math.max( 0, this.rightHeat - dt * 1.1 ); + + // Eye pulse decay + this.eyePulse = Math.max( 0, this.eyePulse - dt * 3 ); + + // Idle torso bob when fully transformed + if ( this.progress >= 0.98 ) { + + this.idleBob = Math.sin( this.time * 2.2 ) * 0.05; + this.torsoPivot.position.y = 0.1 + this.idleBob; + + } else { + + this.torsoPivot.position.y = 0.1; + + } + + } + +} diff --git a/js/TransformFX.js b/js/TransformFX.js new file mode 100644 index 0000000..cfa5f99 --- /dev/null +++ b/js/TransformFX.js @@ -0,0 +1,217 @@ +import * as THREE from 'three'; + +const STEAM_POOL = 18; +const STEAM_LIFE = 0.8; +const ARC_POOL = 8; +const ARC_LIFE = 0.14; +const ARC_POINTS = 6; +const RING_POOL = 3; +const RING_LIFE = 0.6; + +const _steamGeom = new THREE.SphereGeometry( 0.15, 8, 6 ); +const _ringGeom = new THREE.TorusGeometry( 1.0, 0.12, 8, 32 ); + +export class TransformFX { + + constructor( scene ) { + + this.scene = scene; + + // Steam puffs + this.steam = []; + for ( let i = 0; i < STEAM_POOL; i ++ ) { + + const mat = new THREE.MeshStandardMaterial( { + color: 0xd6d8dd, roughness: 1, transparent: true, opacity: 0, depthWrite: false, + } ); + const mesh = new THREE.Mesh( _steamGeom, mat ); + mesh.visible = false; + scene.add( mesh ); + this.steam.push( { mesh, material: mat, life: 0, vx: 0, vy: 0, vz: 0 } ); + + } + this.steamIndex = 0; + + // Electric arcs + this.arcs = []; + for ( let i = 0; i < ARC_POOL; i ++ ) { + + const positions = new Float32Array( ARC_POINTS * 3 ); + const geo = new THREE.BufferGeometry(); + geo.setAttribute( 'position', new THREE.BufferAttribute( positions, 3 ) ); + const mat = new THREE.LineBasicMaterial( { + color: 0x7ae6ff, transparent: true, opacity: 0, linewidth: 2, + } ); + const line = new THREE.Line( geo, mat ); + line.frustumCulled = false; + line.visible = false; + scene.add( line ); + this.arcs.push( { + line, material: mat, positions, geo, life: 0, + a: new THREE.Vector3(), b: new THREE.Vector3(), + } ); + + } + this.arcIndex = 0; + + // Shockwave rings + this.rings = []; + for ( let i = 0; i < RING_POOL; i ++ ) { + + const mat = new THREE.MeshBasicMaterial( { + color: 0xfff0a0, transparent: true, opacity: 0, depthWrite: false, side: THREE.DoubleSide, + } ); + 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, scaleMax: 4 } ); + + } + this.ringIndex = 0; + + // Screen flash (fullscreen quad in ortho camera-facing space) + this.flashMesh = new THREE.Mesh( + new THREE.PlaneGeometry( 2, 2 ), + new THREE.MeshBasicMaterial( { + color: 0xffffff, transparent: true, opacity: 0, depthTest: false, depthWrite: false, + } ) + ); + this.flashMesh.frustumCulled = false; + this.flashMesh.renderOrder = 10000; + scene.add( this.flashMesh ); + this.flashOpacityTarget = 0; + this.flashOpacity = 0; + + } + + steamBurst( x, y, z, count = 4 ) { + + for ( let i = 0; i < count; i ++ ) { + + const p = this.steam[ this.steamIndex ]; + this.steamIndex = ( this.steamIndex + 1 ) % STEAM_POOL; + p.mesh.visible = true; + const a = Math.random() * Math.PI * 2; + const r = Math.random() * 0.3; + p.mesh.position.set( x + Math.cos( a ) * r, y + 0.1, z + Math.sin( a ) * r ); + p.mesh.scale.setScalar( 0.6 + Math.random() * 0.5 ); + p.material.opacity = 0.9; + p.vx = Math.cos( a ) * ( 0.8 + Math.random() ); + p.vz = Math.sin( a ) * ( 0.8 + Math.random() ); + p.vy = 1.2 + Math.random() * 0.8; + p.life = STEAM_LIFE; + + } + + } + + arc( aVec, bVec ) { + + const p = this.arcs[ this.arcIndex ]; + this.arcIndex = ( this.arcIndex + 1 ) % ARC_POOL; + p.a.copy( aVec ); + p.b.copy( bVec ); + p.line.visible = true; + p.material.opacity = 1; + p.life = ARC_LIFE; + this._refreshArc( p ); + + } + + _refreshArc( p ) { + + const pos = p.positions; + for ( let i = 0; i < ARC_POINTS; i ++ ) { + + const t = i / ( ARC_POINTS - 1 ); + const jx = ( Math.random() - 0.5 ) * 0.2 * ( i === 0 || i === ARC_POINTS - 1 ? 0 : 1 ); + const jy = ( Math.random() - 0.5 ) * 0.25 * ( i === 0 || i === ARC_POINTS - 1 ? 0 : 1 ); + const jz = ( Math.random() - 0.5 ) * 0.2 * ( i === 0 || i === ARC_POINTS - 1 ? 0 : 1 ); + pos[ i * 3 + 0 ] = p.a.x + ( p.b.x - p.a.x ) * t + jx; + pos[ i * 3 + 1 ] = p.a.y + ( p.b.y - p.a.y ) * t + jy; + pos[ i * 3 + 2 ] = p.a.z + ( p.b.z - p.a.z ) * t + jz; + + } + + p.geo.attributes.position.needsUpdate = true; + p.geo.computeBoundingSphere(); + + } + + shockwave( x, y, z, scaleMax = 4 ) { + + const r = this.rings[ this.ringIndex ]; + this.ringIndex = ( this.ringIndex + 1 ) % RING_POOL; + r.mesh.visible = true; + r.mesh.position.set( x, y + 0.05, z ); + r.mesh.scale.setScalar( 0.2 ); + r.material.opacity = 1; + r.life = RING_LIFE; + r.scaleMax = scaleMax; + + } + + flash( intensity = 0.15 ) { + + this.flashOpacityTarget = Math.max( this.flashOpacityTarget, intensity ); + + } + + update( dt, camera ) { + + for ( const s of this.steam ) { + + 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 *= Math.max( 0, 1 - dt * 0.9 ); + const t = Math.max( 0, s.life / STEAM_LIFE ); + s.material.opacity = t * 0.9; + s.mesh.scale.setScalar( s.mesh.scale.x + dt * 0.6 ); + if ( s.life <= 0 ) s.mesh.visible = false; + + } + + for ( const a of this.arcs ) { + + if ( a.life <= 0 ) continue; + a.life -= dt; + const t = Math.max( 0, a.life / ARC_LIFE ); + a.material.opacity = t; + this._refreshArc( a ); + if ( a.life <= 0 ) a.line.visible = false; + + } + + for ( const r of this.rings ) { + + if ( r.life <= 0 ) continue; + r.life -= dt; + const prog = 1 - Math.max( 0, r.life / RING_LIFE ); + r.mesh.scale.setScalar( 0.2 + prog * r.scaleMax ); + r.material.opacity = Math.max( 0, 1 - prog ); + if ( r.life <= 0 ) r.mesh.visible = false; + + } + + // Screen flash: ramp up instantly, decay smooth + if ( this.flashOpacityTarget > this.flashOpacity ) this.flashOpacity = this.flashOpacityTarget; + this.flashOpacity *= Math.max( 0, 1 - dt * 9 ); + this.flashOpacityTarget *= Math.max( 0, 1 - dt * 12 ); + this.flashMesh.material.opacity = this.flashOpacity; + + if ( camera ) { + + camera.updateMatrixWorld(); + this.flashMesh.position.copy( camera.position ); + this.flashMesh.quaternion.copy( camera.quaternion ); + this.flashMesh.translateZ( - 1 ); + + } + + } + +} diff --git a/js/Vehicle.js b/js/Vehicle.js index 30a9e78..bee65d3 100644 --- a/js/Vehicle.js +++ b/js/Vehicle.js @@ -1,5 +1,5 @@ import * as THREE from 'three'; -import { rigidBody } from 'crashcat'; +import { rigidBody, MotionType } from 'crashcat'; const _tmpVec = new THREE.Vector3(); const _forward = new THREE.Vector3(); @@ -9,6 +9,8 @@ const _newZ = new THREE.Vector3(); const _mat4 = new THREE.Matrix4(); const _quat = new THREE.Quaternion(); const _up = new THREE.Vector3( 0, 1, 0 ); +const _arcA = new THREE.Vector3(); +const _arcB = new THREE.Vector3(); const SPEED_SCALE = 12.5; const LINEAR_DAMP = 0.1; @@ -58,10 +60,96 @@ export class Vehicle { this.stunSpinVel = 0; this.stunHopVel = 0; + // --- Transformer-mode state --- + this.transformProgress = 0; + this.transformTarget = 0; + this.transformForwardSpeed = 1 / 0.9; + this.transformReverseSpeed = 1 / 0.7; + this.currentDirection = 0; // +1 forward, -1 reverse, 0 idle + this.stageFlags = {}; + + this.turretYaw = 0; + this.turretPitch = 0; + this.turretPitchTarget = 0; + + this.robot = null; + this.transformFX = null; + this.audio = null; + this.hitFX = null; + + this.kinematicActive = false; + this.pinnedPos = [ 3.5, 0.5, 5 ]; + + this.shakeAmplitude = 0; + + this._wheelOriginalY = null; // captured on init + + } + + attachRobot( robot, transformFX, audio, hitFX ) { + + this.robot = robot; + this.transformFX = transformFX; + this.audio = audio; + this.hitFX = hitFX; + this.container.add( robot.getRoot() ); + + } + + toggleTransform() { + + if ( this.stunTimer > 0 ) return; + if ( ! this.robot ) return; + + const goingForward = this.transformTarget < 0.5; + this.transformTarget = goingForward ? 1 : 0; + this.currentDirection = goingForward ? 1 : - 1; + this.stageFlags = {}; + + if ( goingForward && ! this.kinematicActive && this.rigidBody ) { + + this.pinnedPos = [ this.spherePos.x, this.spherePos.y, this.spherePos.z ]; + rigidBody.setMotionType( this.physicsWorld, this.rigidBody, MotionType.KINEMATIC ); + rigidBody.setLinearVelocity( this.physicsWorld, this.rigidBody, [ 0, 0, 0 ] ); + rigidBody.setAngularVelocity( this.physicsWorld, this.rigidBody, [ 0, 0, 0 ] ); + this.kinematicActive = true; + this.linearSpeed = 0; + this.angularSpeed = 0; + this.acceleration = 0; + if ( this.audio ) this.audio.playImpact( 3 ); + + } + + } + + isTransformed() { + + return this.transformProgress >= 0.98; + + } + + isTransforming() { + + return ( this.transformProgress > 0.001 && this.transformProgress < 0.98 ) || this.transformTarget !== ( this.isTransformed() ? 1 : 0 ); + + } + + _checkPhaseCross( threshold, flagName, fn ) { + + if ( this.currentDirection !== 1 ) return; + if ( this.stageFlags[ flagName ] ) return; + if ( this.transformProgress >= threshold ) { + + this.stageFlags[ flagName ] = true; + fn(); + + } + } stun( duration = 2.2, spinVel = 24 ) { + if ( this.transformProgress > 0.01 || this.transformTarget > 0.01 ) return; this.stunTimer = Math.max( this.stunTimer, duration ); this.stunDuration = Math.max( this.stunDuration, duration ); this.stunSpinVel = spinVel * ( Math.random() < 0.5 ? - 1 : 1 ); @@ -112,6 +200,142 @@ export class Vehicle { update( dt, controlsInput ) { + // --- Transformer mode: animation + phase FX + frozen physics --- + const transformActive = this.transformProgress > 0.001 || this.transformTarget > 0.001; + if ( transformActive ) { + + const speed = this.currentDirection >= 0 ? this.transformForwardSpeed : this.transformReverseSpeed; + if ( this.transformProgress < this.transformTarget ) { + + this.transformProgress = Math.min( this.transformTarget, this.transformProgress + speed * dt ); + + } else if ( this.transformProgress > this.transformTarget ) { + + this.transformProgress = Math.max( this.transformTarget, this.transformProgress - speed * dt ); + + } + + if ( this.robot ) this.robot.setProgress( this.transformProgress ); + + const px = this.pinnedPos[ 0 ]; + const py = this.pinnedPos[ 1 ]; + const pz = this.pinnedPos[ 2 ]; + + if ( this.transformFX && this.audio ) { + + this._checkPhaseCross( 0.10, 'p1', () => { + + this.transformFX.shockwave( px, py - 0.5, pz, 2.2 ); + this.transformFX.steamBurst( px - 0.8, py - 0.5, pz, 3 ); + this.transformFX.steamBurst( px + 0.8, py - 0.5, pz, 3 ); + this.audio.playImpact( 4 ); + this.shakeAmplitude = Math.max( this.shakeAmplitude, 0.08 ); + + } ); + + this._checkPhaseCross( 0.42, 'p2', () => { + + _arcA.set( px - 0.5, py + 0.4, pz ); + _arcB.set( px + 0.5, py + 0.4, pz ); + this.transformFX.arc( _arcA, _arcB ); + _arcA.set( px, py + 0.2, pz - 0.3 ); + _arcB.set( px, py + 0.9, pz + 0.3 ); + this.transformFX.arc( _arcA, _arcB ); + this.audio.playImpact( 2 ); + this.shakeAmplitude = Math.max( this.shakeAmplitude, 0.05 ); + + } ); + + this._checkPhaseCross( 0.62, 'p3', () => { + + this.transformFX.steamBurst( px, py + 0.9, pz, 3 ); + this.transformFX.flash( 0.12 ); + this.audio.playImpact( 3 ); + this.shakeAmplitude = Math.max( this.shakeAmplitude, 0.06 ); + + } ); + + this._checkPhaseCross( 0.92, 'p4', () => { + + this.transformFX.shockwave( px, py - 0.5, pz, 5 ); + this.transformFX.flash( 0.18 ); + if ( this.hitFX ) this.hitFX.burst( px, py + 0.5, pz ); + this.audio.playImpact( 5 ); + this.shakeAmplitude = Math.max( this.shakeAmplitude, 0.1 ); + + } ); + + } + + this.shakeAmplitude *= Math.max( 0, 1 - dt * 6 ); + + // Hide wheels and raise body as transform progresses + const hidden = this.transformProgress > 0.15; + for ( const wheel of this.wheels ) wheel.visible = ! hidden; + if ( this.bodyNode ) { + + this.bodyNode.position.y = 0.3 + this.transformProgress * 0.45; + if ( this.transformProgress > 0.08 ) { + + this.bodyNode.rotation.x *= Math.max( 0, 1 - dt * 8 ); + this.bodyNode.rotation.z *= Math.max( 0, 1 - dt * 8 ); + + } + + } + + // Turret input — only when fully transformed + if ( this.isTransformed() ) { + + this.turretYaw -= ( controlsInput.x || 0 ) * 2.5 * dt; + this.turretPitchTarget = ( controlsInput.z || 0 ) * 0.25; + + } else { + + this.turretPitchTarget = 0; + + } + this.turretPitch = THREE.MathUtils.lerp( this.turretPitch, this.turretPitchTarget, Math.min( 1, dt * 5 ) ); + if ( this.robot ) { + + this.robot.setTurretYaw( this.turretYaw ); + this.robot.setTurretPitch( this.turretPitch ); + this.robot.update( dt ); + + } + + // Pin body while kinematic + if ( this.kinematicActive && this.rigidBody ) { + + rigidBody.setPosition( this.physicsWorld, this.rigidBody, this.pinnedPos, false ); + rigidBody.setLinearVelocity( this.physicsWorld, this.rigidBody, [ 0, 0, 0 ] ); + rigidBody.setAngularVelocity( this.physicsWorld, this.rigidBody, [ 0, 0, 0 ] ); + + } + + // Update container visual position (pinned body) + this.spherePos.set( px, py, pz ); + this.container.position.set( px, py - 0.5, pz ); + this.prevModelPos.copy( this.container.position ); + this.modelVelocity.set( 0, 0, 0 ); + + // Exit kinematic when fully reversed + if ( this.transformProgress <= 0.001 && this.kinematicActive && this.rigidBody ) { + + rigidBody.setMotionType( this.physicsWorld, this.rigidBody, MotionType.DYNAMIC ); + this.kinematicActive = false; + this.currentDirection = 0; + this.transformProgress = 0; + if ( this.robot ) this.robot.setProgress( 0 ); + // Restore wheel visibility + for ( const wheel of this.wheels ) wheel.visible = true; + + } + + return; + + } + if ( this.stunTimer > 0 ) { this.stunTimer = Math.max( 0, this.stunTimer - dt ); diff --git a/js/main.js b/js/main.js index b5a85b6..37bc7a6 100644 --- a/js/main.js +++ b/js/main.js @@ -15,6 +15,9 @@ import { GameAudio } from './Audio.js'; import { Shell } from './Shell.js'; import { createNPCs } from './NPC.js'; import { HitFX } from './HitFX.js'; +import { Robot } from './Robot.js'; +import { TransformFX } from './TransformFX.js'; +import { MuzzleFlash } from './MuzzleFlash.js'; const renderer = new THREE.WebGLRenderer( { antialias: true, outputBufferType: THREE.HalfFloatType } ); @@ -233,15 +236,25 @@ async function init() { const particles = new SmokeTrails( scene ); const driftMarks = new DriftMarks( scene ); const hitFX = new HitFX( scene ); + const transformFX = new TransformFX( scene ); + const muzzleFlash = new MuzzleFlash( scene ); const audio = new GameAudio(); audio.init( cam.camera ); + const robot = new Robot(); + vehicle.attachRobot( robot, transformFX, audio, hitFX ); + const _forward = new THREE.Vector3(); + const _cannonPos = new THREE.Vector3(); + const _cannonDir = new THREE.Vector3(); const shells = []; let shellCooldown = 0; + let cannonSide = 0; const MAX_ACTIVE_SHELLS = 3; const SHELL_COOLDOWN = 1.5; + const ROBOT_MAX_SHELLS = 12; + const ROBOT_COOLDOWN = 0.12; const contactListener = { onContactAdded( bodyA, bodyB ) { @@ -281,23 +294,49 @@ async function init() { updateWorld( world, contactListener, dt ); + if ( input.transform ) vehicle.toggleTransform(); + vehicle.update( dt, input ); shellCooldown = Math.max( 0, shellCooldown - dt ); - if ( input.fire && shellCooldown === 0 && shells.length < MAX_ACTIVE_SHELLS ) { + const transformed = vehicle.isTransformed(); + const transforming = vehicle.isTransforming(); + const cooldown = transformed ? ROBOT_COOLDOWN : SHELL_COOLDOWN; + const maxShells = transformed ? ROBOT_MAX_SHELLS : MAX_ACTIVE_SHELLS; + + if ( input.fire && ! transforming && shellCooldown === 0 && shells.length < maxShells ) { + + if ( transformed ) { + + const side = cannonSide === 0 ? 'left' : 'right'; + cannonSide = 1 - cannonSide; + robot.getCannonTransform( side, _cannonPos, _cannonDir ); - _forward.set( 0, 0, 1 ).applyQuaternion( vehicle.container.quaternion ); - _forward.y = 0; - _forward.normalize(); + shells.push( new Shell( world, scene, { + position: _cannonPos, + direction: _cannonDir, + ownerBody: sphereBody, + } ) ); - shells.push( new Shell( world, scene, { - position: vehicle.spherePos, - direction: _forward, - ownerBody: sphereBody, - } ) ); + muzzleFlash.burst( _cannonPos.x, _cannonPos.y, _cannonPos.z ); + robot.onShotFired( side ); - shellCooldown = SHELL_COOLDOWN; + } else { + + _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 = cooldown; } @@ -326,6 +365,17 @@ async function init() { particles.update( dt, vehicle ); driftMarks.update( dt, vehicle ); hitFX.update( dt ); + transformFX.update( dt, cam.camera ); + muzzleFlash.update( dt, cam.camera.quaternion ); + + if ( vehicle.shakeAmplitude > 0.001 ) { + + const a = vehicle.shakeAmplitude; + cam.camera.position.x += ( Math.random() - 0.5 ) * 2 * a; + cam.camera.position.y += ( Math.random() - 0.5 ) * 2 * a; + cam.camera.position.z += ( Math.random() - 0.5 ) * 2 * a; + + } audio.update( dt, vehicle.linearSpeed / MAX_SPEED, input.z, vehicle.driftIntensity ); renderer.render( scene, cam.camera ); From 14ac715b6a4bec932afc6303c1cbdfed2d0b721f Mon Sep 17 00:00:00 2001 From: Makio64 Date: Mon, 20 Apr 2026 15:21:20 +0200 Subject: [PATCH 3/5] Heavier robot shells + yellow mech to match the truck - Shells now carry a power multiplier; robot cannons fire at power=1.4 for ~30% longer stun, stronger spin, and a knockback that shoves the hit target along the shell's travel direction - Targets on impact: NPCs slide in the impact direction with decaying velocity (kinematic body + mesh kept in sync); the player gets a linear-velocity impulse plus a small vertical lift - Shell.onContact passes an impact vector { dirX, dirZ, power } built from the shell's current velocity so hits from any angle push correctly - Recolor the mech primary body to the truck yellow with darker yellow trim so the robot reads as a transformed version of the player's vehicle Co-Authored-By: Claude Opus 4.7 (1M context) --- js/NPC.js | 36 ++++++++++++++++++++++++++++++++---- js/Robot.js | 8 ++++---- js/Shell.js | 17 ++++++++++++++--- js/Vehicle.js | 19 +++++++++++++++++-- js/main.js | 1 + 5 files changed, 68 insertions(+), 13 deletions(-) diff --git a/js/NPC.js b/js/NPC.js index 8137245..cc516f9 100644 --- a/js/NPC.js +++ b/js/NPC.js @@ -8,6 +8,8 @@ const SPIN_DECAY = 0.45; const HIT_HOP_VEL = 5.5; const HIT_GRAVITY = 18; const HIT_TILT = 0.35; +const KNOCKBACK_VEL = 5.5; +const KNOCKBACK_DECAY = 2.3; const ARRIVAL_DIST_SQ = 4.0; const DEFAULT_SPEED = 5.5; const ROT_LERP = 5; @@ -96,15 +98,24 @@ export class NPC { this.hitTimer = 0; this.spinVel = 0; this.hopVel = 0; + this.knockVx = 0; + this.knockVz = 0; this.baseY = y; } - hit() { + hit( impact = null ) { - this.hitTimer = HIT_DURATION; - this.spinVel = HIT_SPIN_VEL * ( Math.random() < 0.5 ? - 1 : 1 ); - this.hopVel = HIT_HOP_VEL; + const power = impact ? impact.power : 1; + this.hitTimer = HIT_DURATION * power; + this.spinVel = HIT_SPIN_VEL * power * ( Math.random() < 0.5 ? - 1 : 1 ); + this.hopVel = HIT_HOP_VEL * ( 0.7 + 0.3 * power ); + if ( impact ) { + + this.knockVx = impact.dirX * KNOCKBACK_VEL * power; + this.knockVz = impact.dirZ * KNOCKBACK_VEL * power; + + } } @@ -125,6 +136,20 @@ export class NPC { } + // Knockback slide — carries the truck in the impact direction + if ( this.knockVx !== 0 || this.knockVz !== 0 ) { + + this.mesh.position.x += this.knockVx * dt; + this.mesh.position.z += this.knockVz * dt; + const decay = Math.max( 0, 1 - KNOCKBACK_DECAY * dt ); + this.knockVx *= decay; + this.knockVz *= decay; + rigidBody.setPosition( this.world, this.body, [ + this.mesh.position.x, this.bodyY, this.mesh.position.z, + ], false ); + + } + 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 ); @@ -132,6 +157,9 @@ export class NPC { } + this.knockVx = 0; + this.knockVz = 0; + if ( this.mesh.rotation.z !== 0 || this.mesh.rotation.x !== 0 ) { this.mesh.rotation.z *= Math.max( 0, 1 - dt * 6 ); diff --git a/js/Robot.js b/js/Robot.js index 32889f6..0915a9c 100644 --- a/js/Robot.js +++ b/js/Robot.js @@ -15,10 +15,10 @@ const easeOutBack = ( t ) => { }; -// --- Shared materials (draw-call friendly) --- -const chromeMat = new THREE.MeshStandardMaterial( { color: 0x2a2f3a, metalness: 0.85, roughness: 0.3 } ); -const darkPanelMat = new THREE.MeshStandardMaterial( { color: 0x181b22, metalness: 0.4, roughness: 0.55 } ); -const yellowMat = new THREE.MeshStandardMaterial( { color: 0xf2c32c, metalness: 0.2, roughness: 0.5, emissive: 0x553d00, emissiveIntensity: 0.25 } ); +// --- Shared materials (draw-call friendly). Body color matches the yellow truck. --- +const chromeMat = new THREE.MeshStandardMaterial( { color: 0xf2c32c, metalness: 0.35, roughness: 0.45 } ); +const darkPanelMat = new THREE.MeshStandardMaterial( { color: 0x25282f, metalness: 0.55, roughness: 0.45 } ); +const yellowMat = new THREE.MeshStandardMaterial( { color: 0xd89418, metalness: 0.3, roughness: 0.45, emissive: 0x3a2600, emissiveIntensity: 0.2 } ); const redEmissiveMat = new THREE.MeshStandardMaterial( { color: 0xff2a1a, emissive: 0xff2010, emissiveIntensity: 0, roughness: 0.25, metalness: 0.1 } ); const blueCoreMat = new THREE.MeshStandardMaterial( { color: 0x33aaff, emissive: 0x2088ff, emissiveIntensity: 1.2, roughness: 0.2, metalness: 0.1 } ); const muzzleGlowMat = new THREE.MeshStandardMaterial( { color: 0xff9840, emissive: 0xff5a10, emissiveIntensity: 0.6, roughness: 0.3 } ); diff --git a/js/Shell.js b/js/Shell.js index 78414a5..bf35f1a 100644 --- a/js/Shell.js +++ b/js/Shell.js @@ -60,11 +60,12 @@ function buildShellMesh() { export class Shell { - constructor( world, scene, { position, direction, ownerBody } ) { + constructor( world, scene, { position, direction, ownerBody, power = 1 } ) { this.world = world; this.scene = scene; this.ownerBody = ownerBody; + this.power = power; this.alive = true; this.lifetime = SHELL_LIFETIME; this.ignoreOwnerTimer = SHELL_IGNORE_OWNER; @@ -154,12 +155,22 @@ export class Shell { } + _buildImpact() { + + const vel = this.body.motionProperties.linearVelocity; + const mag = Math.hypot( vel[ 0 ], vel[ 2 ] ); + const dirX = mag > 0.01 ? vel[ 0 ] / mag : 0; + const dirZ = mag > 0.01 ? vel[ 2 ] / mag : 1; + return { dirX, dirZ, power: this.power }; + + } + onContact( otherBody, bodyToNPC, vehicle, hitFX ) { if ( otherBody === this.ownerBody ) { if ( this.ignoreOwnerTimer > 0 ) return; - if ( vehicle ) vehicle.stun(); + if ( vehicle ) vehicle.stun( this._buildImpact() ); if ( hitFX ) { const p = this.body.position; @@ -174,7 +185,7 @@ export class Shell { const npc = bodyToNPC.get( otherBody ); if ( npc ) { - npc.hit(); + npc.hit( this._buildImpact() ); if ( hitFX ) { const p = this.body.position; diff --git a/js/Vehicle.js b/js/Vehicle.js index bee65d3..5cbd8f7 100644 --- a/js/Vehicle.js +++ b/js/Vehicle.js @@ -147,13 +147,28 @@ export class Vehicle { } - stun( duration = 2.2, spinVel = 24 ) { + stun( impact = null ) { if ( this.transformProgress > 0.01 || this.transformTarget > 0.01 ) return; + const power = impact ? impact.power : 1; + const duration = 2.2 * power; + const spinVel = 24 * power; 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; + this.stunHopVel = 5.5 * ( 0.7 + 0.3 * power ); + + if ( impact && this.rigidBody ) { + + const impulse = 8 * power; + const vel = this.rigidBody.motionProperties.linearVelocity; + rigidBody.setLinearVelocity( this.physicsWorld, this.rigidBody, [ + vel[ 0 ] + impact.dirX * impulse, + vel[ 1 ] + 3 * power, + vel[ 2 ] + impact.dirZ * impulse, + ] ); + + } } diff --git a/js/main.js b/js/main.js index 37bc7a6..8de56a7 100644 --- a/js/main.js +++ b/js/main.js @@ -317,6 +317,7 @@ async function init() { position: _cannonPos, direction: _cannonDir, ownerBody: sphereBody, + power: 1.4, } ) ); muzzleFlash.burst( _cannonPos.x, _cannonPos.y, _cannonPos.z ); From cf694d33633fa48bdff166d71d1c62f21459b989 Mon Sep 17 00:00:00 2001 From: Makio64 Date: Mon, 20 Apr 2026 15:31:48 +0200 Subject: [PATCH 4/5] Double robot turret rotation speed and max active shells MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Turret yaw rate: 2.5 → 5.0 rad/s for snappier aiming - Max concurrent robot-mode shells: 12 → 24 so a sustained barrage stays uninterrupted Co-Authored-By: Claude Opus 4.7 (1M context) --- js/Vehicle.js | 2 +- js/main.js | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/js/Vehicle.js b/js/Vehicle.js index 5cbd8f7..835bb95 100644 --- a/js/Vehicle.js +++ b/js/Vehicle.js @@ -302,7 +302,7 @@ export class Vehicle { // Turret input — only when fully transformed if ( this.isTransformed() ) { - this.turretYaw -= ( controlsInput.x || 0 ) * 2.5 * dt; + this.turretYaw -= ( controlsInput.x || 0 ) * 5.0 * dt; this.turretPitchTarget = ( controlsInput.z || 0 ) * 0.25; } else { diff --git a/js/main.js b/js/main.js index 8de56a7..184e031 100644 --- a/js/main.js +++ b/js/main.js @@ -253,7 +253,7 @@ async function init() { let cannonSide = 0; const MAX_ACTIVE_SHELLS = 3; const SHELL_COOLDOWN = 1.5; - const ROBOT_MAX_SHELLS = 12; + const ROBOT_MAX_SHELLS = 24; const ROBOT_COOLDOWN = 0.12; const contactListener = { From e97bfb886d93dc34e83014e41b641796050f788d Mon Sep 17 00:00:00 2001 From: Makio64 Date: Mon, 20 Apr 2026 15:44:55 +0200 Subject: [PATCH 5/5] Reset turret to car-forward on every transform Zero turretYaw / turretPitch at the start of each forward transform so the tower always deploys pointing the same direction as the car, instead of remembering the last aim. Co-Authored-By: Claude Opus 4.7 (1M context) --- js/Vehicle.js | 28 ++++++++++++++++++---------- 1 file changed, 18 insertions(+), 10 deletions(-) diff --git a/js/Vehicle.js b/js/Vehicle.js index 835bb95..af3beb0 100644 --- a/js/Vehicle.js +++ b/js/Vehicle.js @@ -106,17 +106,25 @@ export class Vehicle { this.currentDirection = goingForward ? 1 : - 1; this.stageFlags = {}; - if ( goingForward && ! this.kinematicActive && this.rigidBody ) { + if ( goingForward ) { - this.pinnedPos = [ this.spherePos.x, this.spherePos.y, this.spherePos.z ]; - rigidBody.setMotionType( this.physicsWorld, this.rigidBody, MotionType.KINEMATIC ); - rigidBody.setLinearVelocity( this.physicsWorld, this.rigidBody, [ 0, 0, 0 ] ); - rigidBody.setAngularVelocity( this.physicsWorld, this.rigidBody, [ 0, 0, 0 ] ); - this.kinematicActive = true; - this.linearSpeed = 0; - this.angularSpeed = 0; - this.acceleration = 0; - if ( this.audio ) this.audio.playImpact( 3 ); + this.turretYaw = 0; + this.turretPitch = 0; + this.turretPitchTarget = 0; + + if ( ! this.kinematicActive && this.rigidBody ) { + + this.pinnedPos = [ this.spherePos.x, this.spherePos.y, this.spherePos.z ]; + rigidBody.setMotionType( this.physicsWorld, this.rigidBody, MotionType.KINEMATIC ); + rigidBody.setLinearVelocity( this.physicsWorld, this.rigidBody, [ 0, 0, 0 ] ); + rigidBody.setAngularVelocity( this.physicsWorld, this.rigidBody, [ 0, 0, 0 ] ); + this.kinematicActive = true; + this.linearSpeed = 0; + this.angularSpeed = 0; + this.acceleration = 0; + if ( this.audio ) this.audio.playImpact( 3 ); + + } }