The controller now resolves the surface under the blob from the ground collider's userData.surface (the course already tags them) and reacts: - slip (oil/ice): steering-scrub + brake throttled ~0.1x → you luge across, and even a grippy green blob slides (Min friction combine beats grip); - drag (honey/mud/water): horizontal speed bleeds off (sticky/heavy feel); - rinse (water): a small periodic paint:request-cleanse — an underwater shortcut costs you your loadout (GDD §5.5). The surfaces.ts framework existed but nothing in the game consumed the behaviour tags; this wires it, per that file's own "the controller queries the surface" contract. Added a demo oil slick (applySurface 'oil' + Min combine) on the machine-pad centre lane. Browser-verified: on oil the blob slides ~2.7u vs 0.42u on normal ground; resting on the milk river rinsed red 56.7% → 35.7% in 2s. honey-climb and mud-antipaint left as marked ponytail TODOs. build green. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
238 lines
9.3 KiB
TypeScript
238 lines
9.3 KiB
TypeScript
/**
|
||
* 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<string>()
|
||
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
|
||
},
|
||
}
|
||
}
|