BLOBBO/src/blob/controller.ts
type-two 333f11b9db feat(blob): honour surface tags — oil luge, water rinse, drag
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>
2026-07-19 14:59:20 +10:00

238 lines
9.3 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 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
},
}
}