- src/blob/createBlob.ts: rotation-locked dynamic ball + clean UV-sphere body mesh (untouched UVs for Lane B) + cosmetic eyes; implements frozen Blob. - src/blob/controller.ts: fixed-step System. Camera-relative WASD via impulses, buffered+coyote jump, grounded raycast, emits blob:jumped/blob:landed. Reads blob.modifiers every frame: speedMul, jumpMul, massMul, grip, size. - src/blob/feel.ts: the comedy engine. Underdamped springs for landing squash, jump stretch, inertial jelly lean, idle breathing + run bob; base-pivoted so squash plants on the floor. Driven by the frozen wire events. glow -> emissive. - src/blob/camera.ts: soft-follow third-person cam with position lag, yaw ease behind travel, and FOV kick at speed. - src/course/greybox.ts: Breakfast Rush greybox — start plateau, ramp, gap jump, slope, translucent milk river (surface:water), machine drop area, finish pad. - src/demo/lane-a.ts: boots world+greybox+blob, HUD + once/sec state log, debug keys 1/2/3 (speed/jump/size) plus 4/5/6 (mass/grip/glow), 0 reset, R respawn. npm run build passes (tsc strict + vite). Owned paths only. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
130 lines
4.7 KiB
TypeScript
130 lines
4.7 KiB
TypeScript
/**
|
|
* Lane A — the FEEL layer. This is BLOBBO's comedy engine.
|
|
*
|
|
* The physics body is a rigid ball. Everything soft and wobbly happens here, on
|
|
* the *visual* mesh only, once per rendered frame. We run underdamped springs so
|
|
* the jiggle OVERSHOOTS — a landing squash should read from across the room, a
|
|
* jump should pop tall, and the body should keep quivering for a beat after.
|
|
*
|
|
* squash : signed vertical squash/stretch (+ = tall, - = flat), spring→0
|
|
* leanX/Z : inertial jelly lean, driven by horizontal acceleration
|
|
* idle : always-on breathing so a resting blob is never dead-still
|
|
*
|
|
* Wired to the frozen events by the demo/integration:
|
|
* world.events.on('blob:jumped', () => feel.onJump())
|
|
* world.events.on('blob:landed', (p) => feel.onLand(p.impact))
|
|
*/
|
|
import * as THREE from 'three'
|
|
import type { BlobHandle } from './createBlob'
|
|
|
|
export interface BlobFeel {
|
|
update(dt: number): void
|
|
onJump(): void
|
|
onLand(impact: number): void
|
|
}
|
|
|
|
// Underdamped so it overshoots and quivers. Higher K = snappier, lower C = wobblier.
|
|
const SQUASH_K = 190
|
|
const SQUASH_C = 11
|
|
const LEAN_K = 150
|
|
const LEAN_C = 13
|
|
|
|
function spring(pos: number, vel: number, k: number, c: number, dt: number): [number, number] {
|
|
// semi-implicit Euler — stable at 60fps for these constants.
|
|
const acc = -k * pos - c * vel
|
|
const nv = vel + acc * dt
|
|
return [pos + nv * dt, nv]
|
|
}
|
|
|
|
export function createBlobFeel(blob: BlobHandle): BlobFeel {
|
|
let squash = 0
|
|
let squashVel = 0
|
|
let leanX = 0
|
|
let leanXVel = 0
|
|
let leanZ = 0
|
|
let leanZVel = 0
|
|
let t = 0
|
|
|
|
let yaw = 0
|
|
const prevVel = new THREE.Vector3()
|
|
const mat = blob.mesh.material as THREE.MeshStandardMaterial
|
|
|
|
return {
|
|
onJump() {
|
|
// Pop tall on the way up.
|
|
squashVel += 8
|
|
},
|
|
onLand(impact: number) {
|
|
// Squash flat proportional to how hard we hit. Overshoots, then rebounds.
|
|
const k = THREE.MathUtils.clamp(impact / 9, 0, 1)
|
|
squashVel -= 5 + 9 * k
|
|
},
|
|
|
|
update(dt: number) {
|
|
// Clamp dt so a stall / tab-switch can't explode the springs.
|
|
const d = Math.min(dt, 1 / 30)
|
|
t += d
|
|
|
|
const bt = blob.body.translation()
|
|
const bv = blob.body.linvel()
|
|
|
|
// ---- position sync (visual follows the rigid ball) ----
|
|
blob.group.position.set(bt.x, bt.y, bt.z)
|
|
|
|
// ---- facing: turn to face horizontal travel, keep last heading when idle ----
|
|
const horizSpeed = Math.hypot(bv.x, bv.z)
|
|
if (horizSpeed > 1.2) {
|
|
const target = Math.atan2(bv.x, bv.z)
|
|
// shortest-arc ease
|
|
let delta = target - yaw
|
|
delta = Math.atan2(Math.sin(delta), Math.cos(delta))
|
|
yaw += delta * (1 - Math.exp(-d * 9))
|
|
}
|
|
blob.group.rotation.y = yaw
|
|
|
|
// ---- inertial lean, computed in the blob's local frame ----
|
|
const ax = (bv.x - prevVel.x) / Math.max(d, 1e-4)
|
|
const az = (bv.z - prevVel.z) / Math.max(d, 1e-4)
|
|
prevVel.set(bv.x, bv.y, bv.z)
|
|
// world -> local (undo yaw)
|
|
const cy = Math.cos(yaw)
|
|
const sy = Math.sin(yaw)
|
|
const localAx = ax * cy - az * sy
|
|
const localAz = ax * sy + az * cy
|
|
const leanTargetX = THREE.MathUtils.clamp(-localAz * 0.012, -0.22, 0.22) // nod on accel/brake
|
|
const leanTargetZ = THREE.MathUtils.clamp(localAx * 0.012, -0.22, 0.22) // bank on strafe/turn
|
|
;[leanX, leanXVel] = spring(leanX - leanTargetX, leanXVel, LEAN_K, LEAN_C, d)
|
|
leanX += leanTargetX
|
|
;[leanZ, leanZVel] = spring(leanZ - leanTargetZ, leanZVel, LEAN_K, LEAN_C, d)
|
|
leanZ += leanTargetZ
|
|
|
|
// ---- squash/stretch spring ----
|
|
;[squash, squashVel] = spring(squash, squashVel, SQUASH_K, SQUASH_C, d)
|
|
|
|
// ---- procedural life: idle breathing + a gallop bob while running ----
|
|
const idle = Math.sin(t * 2.4) * 0.02
|
|
const speedNorm = THREE.MathUtils.clamp(horizSpeed / 8, 0, 1)
|
|
const bob = Math.sin(t * 15) * 0.05 * speedNorm
|
|
|
|
const s = squash + idle + bob
|
|
let syScale = 1 + s
|
|
let sxzScale = 1 - 0.55 * s // rough volume preservation: tall => thin
|
|
syScale = THREE.MathUtils.clamp(syScale, 0.3, 1.95)
|
|
sxzScale = THREE.MathUtils.clamp(sxzScale, 0.55, 1.7)
|
|
|
|
// Pivot the squash at the blob's base so a landing plants on the floor
|
|
// instead of shrinking toward its own center. (group scale handles `size`.)
|
|
blob.mesh.scale.set(sxzScale, syScale, sxzScale)
|
|
blob.mesh.position.y = blob.radius * (syScale - 1)
|
|
blob.mesh.rotation.set(leanX, 0, leanZ)
|
|
|
|
// Face rides the squash a touch (googly life) and never gets painted.
|
|
blob.face.position.y = blob.radius * (syScale - 1) * 0.7
|
|
|
|
// ---- super-state glow (modifiers.glow) ----
|
|
const glow = blob.modifiers.glow
|
|
mat.emissiveIntensity = glow > 0 ? glow * (0.6 + 0.4 * Math.sin(t * 8)) : 0
|
|
},
|
|
}
|
|
}
|