/** * Lane A — blob controller (the fixed-step System). * * Physics only. Reads keyboard + camera, drives the rigid ball with impulses, * respects `blob.modifiers` EVERY frame, and emits the frozen wire events * `blob:jumped` / `blob:landed`. It never sets velocity outright (except the * jump kick), so external forces — catapults, fans, shoves from Lane C — always * stack on top instead of being clobbered. * * Movement is CAMERA-RELATIVE: W is "away from camera", derived from the live * camera yaw, so it stays correct as the follow-cam swings around. */ import * as THREE from 'three' import type { System, World } from '../contracts' import type { BlobHandle } from './createBlob' import { SURFACES, type SurfaceName } from '../machine/surfaces' export interface BlobControllerOptions { /** Base top speed (m/s) before speedMul. */ maxSpeed?: number /** Ground acceleration (m/s^2) before massMul sluggishness. */ accel?: number /** Take-off vertical speed (m/s) before jumpMul. */ jumpSpeed?: number } export function createBlobController( world: World, blob: BlobHandle, opts: BlobControllerOptions = {}, ): System { const { physics, rapier, events, camera } = world const maxSpeed = opts.maxSpeed ?? 8 const accel = opts.accel ?? 60 const jumpSpeed = opts.jumpSpeed ?? 7.6 // ---- input (self-contained; debug keys live in the demo) ---- const keys = new Set() let jumpBuffer = 0 addEventListener('keydown', (e) => { keys.add(e.code) if (e.code === 'Space') jumpBuffer = 0.12 // buffered so an early tap still fires }) addEventListener('keyup', (e) => keys.delete(e.code)) const held = (...codes: string[]) => codes.some((c) => keys.has(c)) // ---- gamepad (poll API — read fresh each fixed step) ---- // Left stick = analog camera-relative move (radial deadzone, magnitude kept // so gentle stick = gentle roll), d-pad = digital, A/Cross or B/Circle = jump // through the same buffered-edge path as Space. const pad = { x: 0, z: 0 } let padJumpHeld = false function pollGamepad(): void { pad.x = 0 pad.z = 0 let jump = false const pads = typeof navigator !== 'undefined' && navigator.getGamepads ? navigator.getGamepads() : null if (pads) { for (const p of pads) { if (!p || !p.connected) continue const dz = 0.15 const ax = p.axes[0] ?? 0 const ay = p.axes[1] ?? 0 const mag = Math.hypot(ax, ay) if (mag > dz) { const scale = Math.min(1, (mag - dz) / (1 - dz)) / mag pad.x = ax * scale pad.z = ay * scale } if (p.buttons[14]?.pressed) pad.x = -1 // d-pad left if (p.buttons[15]?.pressed) pad.x = 1 // d-pad right if (p.buttons[12]?.pressed) pad.z = -1 // d-pad up if (p.buttons[13]?.pressed) pad.z = 1 // d-pad down jump = !!(p.buttons[0]?.pressed || p.buttons[1]?.pressed) break // first connected pad wins } } if (jump && !padJumpHeld) jumpBuffer = 0.12 padJumpHeld = jump } // ---- state ---- let grounded = false let coyote = 0 // seconds of "still counts as grounded" after leaving ground let prevVy = 0 let lastSize = -1 let lastMassMul = -1 let rinseAccum = 0 // water-rinse cadence (GDD §5.5) const fwd = new THREE.Vector3() const right = new THREE.Vector3() const move = new THREE.Vector3() function applyModifiers(): void { const m = blob.modifiers if (m.size !== lastSize) { blob.collider.setRadius(blob.radius * m.size) blob.group.scale.setScalar(m.size) lastSize = m.size lastMassMul = -1 // radius change resets mass props → re-apply mass below } if (m.massMul !== lastMassMul) { blob.collider.setMass(Math.max(0.05, m.massMul)) lastMassMul = m.massMul } // grip boosts real contact friction (slopes / standing) ... blob.collider.setFriction(0.6 + m.grip * 1.6) } return { update(dt: number): void { applyModifiers() const m = blob.modifiers const body = blob.body const r = blob.radius * m.size // ---- grounded check: short ray straight down, excluding self ---- const t = body.translation() // Sync the visual root to the body in the FIXED step: gameplay reads this // transform (paint proximity gating), so it must track the sim even when // render frames stall (hidden tab). The feel layer re-sets it per frame // with wobble on top — that stays visual-only. blob.group.position.set(t.x, t.y, t.z) const ray = new rapier.Ray({ x: t.x, y: t.y, z: t.z }, { x: 0, y: -1, z: 0 }) const hit = physics.castRay(ray, r + 0.18, true, undefined, undefined, blob.collider, body) const nowGrounded = hit !== null coyote = nowGrounded ? 0.1 : Math.max(0, coyote - dt) // landing edge → squash event, impact = downward speed just before contact if (nowGrounded && !grounded) { const impact = Math.max(0, -prevVy) if (impact > 0.8) events.emit('blob:landed', { impact }) } grounded = nowGrounded // ---- surface under the blob (GDD §5.5): oil/ice = slip, honey/mud/water // = drag, water = rinse. The course tags colliders `userData.surface`; the // grounded ray already excludes self, so its hit is the ground we resolve. // ponytail: honey `climb` and mud `antipaint` are not wired yet — they need // more than a drag knob (wall-climb traversal / buff suppression). let surf: (typeof SURFACES)[SurfaceName] | null = null if (hit) { const name = (hit.collider as unknown as { userData?: { surface?: string } }) .userData?.surface as SurfaceName | undefined if (name && SURFACES[name]) surf = SURFACES[name] } const slip = !!surf && surf.tags.includes('slip') // ---- camera-relative move basis (flatten camera dir onto ground) ---- camera.getWorldDirection(fwd) fwd.y = 0 if (fwd.lengthSq() < 1e-6) fwd.set(0, 0, -1) fwd.normalize() right.set(-fwd.z, 0, fwd.x) // forward × up pollGamepad() move.set(0, 0, 0) if (held('KeyW', 'ArrowUp')) move.add(fwd) if (held('KeyS', 'ArrowDown')) move.sub(fwd) if (held('KeyD', 'ArrowRight')) move.add(right) if (held('KeyA', 'ArrowLeft')) move.sub(right) if (pad.x !== 0 || pad.z !== 0) { move.addScaledVector(fwd, -pad.z).addScaledVector(right, pad.x) // stick up = away from camera } const hasInput = move.lengthSq() > 1e-6 if (move.lengthSq() > 1) move.normalize() // keep sub-unit analog magnitudes const v = body.linvel() const mass = body.mass() const speedCap = maxSpeed * m.speedMul const control = grounded ? 1 : 0.4 // reduced air control const sluggish = 1 / Math.sqrt(Math.max(0.3, m.massMul)) // heavy = accelerates slower // ---- drive: additive impulse, but stop pushing past the cap ---- if (hasInput) { const along = v.x * move.x + v.z * move.z if (along < speedCap) { const imp = mass * accel * control * sluggish * dt body.applyImpulse({ x: move.x * imp, y: 0, z: move.z * imp }, true) } if (grounded) { // scrub sideways drift so turns don't feel like ice (grip tightens it). // On a slip surface the scrub is throttled right down: you luge, you // can't just carve — even a grippy green blob slides (oil beats grip). const along2 = v.x * move.x + v.z * move.z const perpX = v.x - along2 * move.x const perpZ = v.z - along2 * move.z const k = Math.min(1, (2 + m.grip * 10) * dt) * (slip ? 0.12 : 1) body.applyImpulse({ x: -perpX * mass * k, y: 0, z: -perpZ * mass * k }, true) } } else if (grounded) { // no input on ground: brake. grip = how hard we kill the slide; a slip // surface all but removes the brake so you keep sliding. const k = Math.min(1, (6 + m.grip * 22) * dt) * (slip ? 0.08 : 1) body.applyImpulse({ x: -v.x * mass * k, y: 0, z: -v.z * mass * k }, true) } // ---- jump (buffered + coyote) ---- jumpBuffer = Math.max(0, jumpBuffer - dt) if (jumpBuffer > 0 && coyote > 0) { const vy = body.linvel().y const dvy = Math.max(0, jumpSpeed * m.jumpMul - vy) // bring vy up to target body.applyImpulse({ x: 0, y: mass * dvy, z: 0 }, true) jumpBuffer = 0 coyote = 0 grounded = false events.emit('blob:jumped', {}) } // ---- surface effects (only while grounded on a tagged surface) ---- if (surf && grounded) { // drag: honey/mud/water bleed horizontal speed (sticky / heavy feel) if (surf.drag > 0) { const keep = Math.max(0, 1 - surf.drag) const lv = body.linvel() body.setLinvel({ x: lv.x * keep, y: lv.y, z: lv.z * keep }, true) } // rinse: water gradually washes paint off — an underwater shortcut costs // you your loadout (GDD §5.5). Small fraction on a fixed cadence. if (surf.tags.includes('rinse')) { rinseAccum += dt if (rinseAccum >= 0.4) { rinseAccum = 0 events.emit('paint:request-cleanse', { fraction: 0.03 }) } } else { rinseAccum = 0 } } else { rinseAccum = 0 } prevVy = body.linvel().y }, } }