From 4b08ba65acecab9923bd91e6ade5c864ede62d80 Mon Sep 17 00:00:00 2001 From: type-two Date: Fri, 17 Jul 2026 20:11:55 +1000 Subject: [PATCH] Lane A: blob controller, feel layer, follow camera, greybox MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- src/blob/camera.ts | 89 ++++++++++++++++++++++++ src/blob/controller.ts | 151 +++++++++++++++++++++++++++++++++++++++++ src/blob/createBlob.ts | 113 ++++++++++++++++++++++++++++++ src/blob/feel.ts | 129 +++++++++++++++++++++++++++++++++++ src/course/greybox.ts | 115 +++++++++++++++++++++++++++++++ src/demo/lane-a.ts | 122 ++++++++++++++++++++++++++++++++- 6 files changed, 718 insertions(+), 1 deletion(-) create mode 100644 src/blob/camera.ts create mode 100644 src/blob/controller.ts create mode 100644 src/blob/createBlob.ts create mode 100644 src/blob/feel.ts create mode 100644 src/course/greybox.ts diff --git a/src/blob/camera.ts b/src/blob/camera.ts new file mode 100644 index 0000000..57f3a73 --- /dev/null +++ b/src/blob/camera.ts @@ -0,0 +1,89 @@ +/** + * Lane A — third-person soft-follow camera. + * + * Trails the blob with position lag, eases its yaw to sit behind the direction + * of travel, and kicks FOV a touch at speed for a sense of hustle. Runs as a + * per-frame (visual-only) hook. The controller reads `world.camera` for its + * move basis, so this cam swinging around keeps WASD camera-relative. + */ +import * as THREE from 'three' +import type { World } from '../contracts' +import type { BlobHandle } from './createBlob' + +export interface FollowCameraOptions { + distance?: number + height?: number + /** How fast the camera position catches up (higher = tighter). */ + followLag?: number + /** How fast the camera swings behind travel (higher = snappier). */ + yawLag?: number +} + +export interface FollowCamera { + update(dt: number): void + yaw(): number +} + +function expo(rate: number, dt: number): number { + return 1 - Math.exp(-rate * dt) +} + +export function createFollowCamera( + world: World, + blob: BlobHandle, + opts: FollowCameraOptions = {}, +): FollowCamera { + const cam = world.camera + const distance = opts.distance ?? 9 + const height = opts.height ?? 4.5 + const followLag = opts.followLag ?? 6 + const yawLag = opts.yawLag ?? 2.5 + + const baseFov = cam.fov + let camYaw = Math.PI // start looking toward -Z (down the course) + const desired = new THREE.Vector3() + const look = new THREE.Vector3() + let lookInit = false + + return { + update(dt: number): void { + const p = blob.body.translation() + const v = blob.body.linvel() + const size = blob.modifiers.size + const speed = Math.hypot(v.x, v.z) + + // ease the rig behind the direction of travel (only while actually moving) + if (speed > 1.5) { + const targetYaw = Math.atan2(v.x, v.z) + let delta = targetYaw - camYaw + delta = Math.atan2(Math.sin(delta), Math.cos(delta)) + camYaw += delta * expo(yawLag, dt) + } + + const dist = distance * size + const h = height * size + desired.set( + p.x - Math.sin(camYaw) * dist, + p.y + h, + p.z - Math.cos(camYaw) * dist, + ) + cam.position.lerp(desired, expo(followLag, dt)) + + // look at a point a little above the blob, itself eased for smoothness + const lookTarget = new THREE.Vector3(p.x, p.y + 1.0 * size, p.z) + if (!lookInit) { + look.copy(lookTarget) + lookInit = true + } else { + look.lerp(lookTarget, expo(8, dt)) + } + cam.lookAt(look) + + // FOV kick at speed + const targetFov = baseFov + Math.min(speed * 0.9, 12) + cam.fov += (targetFov - cam.fov) * expo(4, dt) + cam.updateProjectionMatrix() + }, + yaw: () => camYaw, + } +} diff --git a/src/blob/controller.ts b/src/blob/controller.ts new file mode 100644 index 0000000..d5c18f0 --- /dev/null +++ b/src/blob/controller.ts @@ -0,0 +1,151 @@ +/** + * 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' + +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)) + + // ---- 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 + + 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() + 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 + + // ---- 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 + + 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) + const hasInput = move.lengthSq() > 1e-6 + if (hasInput) move.normalize() + + 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) + 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) + 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. + const k = Math.min(1, (6 + m.grip * 22) * dt) + 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', {}) + } + + prevVy = body.linvel().y + }, + } +} diff --git a/src/blob/createBlob.ts b/src/blob/createBlob.ts new file mode 100644 index 0000000..b83ff84 --- /dev/null +++ b/src/blob/createBlob.ts @@ -0,0 +1,113 @@ +/** + * Lane A — the Blobbo body. + * + * A friendly white soft-looking blob. The physics is a rigid, rotation-locked + * dynamic ball (determinism lives in the capsule); the softness is faked by the + * feel layer wobbling the mesh (see feel.ts). The body mesh is a *clean UV + * sphere with untouched UVs* — Lane B paints splats into that UV space, so we + * never touch its geometry attributes. + */ +import * as THREE from 'three' +import type RAPIER from '@dimforge/rapier3d-compat' +import type { Blob, World } from '../contracts' +import { BASE_WHITE, defaultModifiers } from '../contracts' + +export interface CreateBlobOptions { + position?: THREE.Vector3 + /** Base collider radius before the `size` modifier is applied. */ + radius?: number + /** UV-sphere longitude segments (latitude is derived). Keep high for smooth paint. */ + segments?: number +} + +/** + * Runtime handle. Extends the frozen `Blob` with the concrete bits the Lane A + * controller/feel need (collider, base radius, cosmetic face). Anything typed + * as `Blob` elsewhere still sees exactly the frozen shape. + */ +export interface BlobHandle extends Blob { + collider: RAPIER.Collider + /** Base radius (pre-`size`); collider radius = radius * modifiers.size. */ + radius: number + /** Cosmetic eyes group — NOT paintable, kept separate from `mesh`. */ + face: THREE.Group +} + +function buildFace(radius: number): THREE.Group { + const face = new THREE.Group() + const eyeWhiteMat = new THREE.MeshStandardMaterial({ color: '#ffffff', roughness: 0.3 }) + const pupilMat = new THREE.MeshStandardMaterial({ color: '#1a1a22', roughness: 0.5 }) + + const eyeWhiteGeo = new THREE.SphereGeometry(radius * 0.3, 20, 16) + const pupilGeo = new THREE.SphereGeometry(radius * 0.15, 16, 12) + + for (const side of [-1, 1]) { + const eye = new THREE.Group() + eye.position.set(side * radius * 0.34, radius * 0.34, radius * 0.86) + const white = new THREE.Mesh(eyeWhiteGeo, eyeWhiteMat) + white.castShadow = true + const pupil = new THREE.Mesh(pupilGeo, pupilMat) + pupil.position.set(0, 0, radius * 0.2) + eye.add(white, pupil) + face.add(eye) + } + return face +} + +export function createBlob(world: World, opts: CreateBlobOptions = {}): BlobHandle { + const { scene, physics, rapier } = world + const radius = opts.radius ?? 0.5 + const pos = opts.position ?? new THREE.Vector3(0, 3, 0) + const seg = opts.segments ?? 48 + + // Root group carries world position, facing yaw, and the `size` scale. + const group = new THREE.Group() + group.position.copy(pos) + scene.add(group) + + // Paintable body: clean UV sphere. Default THREE sphere UVs are exactly what + // Lane B wants (equirectangular, seam at the back). Do not decorate this mesh. + const geometry = new THREE.SphereGeometry(radius, seg, Math.round(seg * 0.75)) + const material = new THREE.MeshStandardMaterial({ + color: BASE_WHITE, + roughness: 0.5, + metalness: 0.0, + emissive: new THREE.Color('#88e0ff'), + emissiveIntensity: 0.0, // driven by modifiers.glow in the feel layer + }) + const mesh = new THREE.Mesh(geometry, material) + mesh.castShadow = true + group.add(mesh) + + // Cosmetic face, parented to the group (yaws to face travel, scales with size). + const face = buildFace(radius) + group.add(face) + + // Physics proxy: dynamic ball, rotations locked so it stays an upright + // character instead of a rolling marble. Comedy roll = visual only. + const body = physics.createRigidBody( + rapier.RigidBodyDesc.dynamic() + .setTranslation(pos.x, pos.y, pos.z) + .setLinearDamping(0.15) + .setCanSleep(false), + ) + body.setEnabledRotations(false, false, false, true) + + const collider = physics.createCollider( + rapier.ColliderDesc.ball(radius) + .setRestitution(0.0) // no physical bounce — the bounce is the squash + .setFriction(0.6) + .setMass(1.0), + body, + ) + + return { + group, + mesh, + body, + collider, + radius, + face, + modifiers: defaultModifiers(), + } +} diff --git a/src/blob/feel.ts b/src/blob/feel.ts new file mode 100644 index 0000000..940b136 --- /dev/null +++ b/src/blob/feel.ts @@ -0,0 +1,129 @@ +/** + * 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 + }, + } +} diff --git a/src/course/greybox.ts b/src/course/greybox.ts new file mode 100644 index 0000000..db07e94 --- /dev/null +++ b/src/course/greybox.ts @@ -0,0 +1,115 @@ +/** + * Lane A — greybox course chunk ("Breakfast Rush", primitive edition). + * + * A big, readable, absurdly-proportioned play space built from boxes so the blob + * has somewhere to strut, ramp, gap-jump, slide and splash. Everything sits on + * one generous base floor so a missed jump just drops you a little — never into + * the void. Surfaces that mean something to other lanes are tagged on the mesh + * `userData` (e.g. `{ surface: 'water' }`), and colliders carry the same tag so + * Lane C can look either up. + * + * +Z is "behind the start"; the course runs toward -Z (into the camera view). + */ +import * as THREE from 'three' +import type { World } from '../contracts' + +export interface GreyboxHandle { + /** Recommended blob spawn point (just above the start plateau). */ + spawn: THREE.Vector3 + /** Center of the open flat area where Lane C drops machines at integration. */ + machineArea: THREE.Vector3 + meshes: THREE.Mesh[] +} + +interface BoxOpts { + color: THREE.ColorRepresentation + /** Euler rotation in radians (applied to both mesh and collider). */ + rot?: THREE.Euler + /** Arbitrary surface/zone tag copied to mesh.userData AND collider.userData. */ + surface?: string + transparent?: number // opacity 0..1 when set + roughness?: number + cast?: boolean +} + +export function buildGreybox(world: World): GreyboxHandle { + const { scene, physics, rapier } = world + const meshes: THREE.Mesh[] = [] + + /** Add a static box: THREE mesh + fixed cuboid collider, optionally rotated. */ + function addBox( + cx: number, cy: number, cz: number, + hx: number, hy: number, hz: number, + o: BoxOpts, + ): THREE.Mesh { + const mat = new THREE.MeshStandardMaterial({ + color: o.color, + roughness: o.roughness ?? 0.9, + metalness: 0.0, + transparent: o.transparent !== undefined, + opacity: o.transparent ?? 1, + }) + const mesh = new THREE.Mesh(new THREE.BoxGeometry(hx * 2, hy * 2, hz * 2), mat) + mesh.position.set(cx, cy, cz) + mesh.receiveShadow = true + mesh.castShadow = o.cast ?? false + if (o.rot) mesh.rotation.copy(o.rot) + if (o.surface) mesh.userData.surface = o.surface + scene.add(mesh) + meshes.push(mesh) + + const desc = rapier.ColliderDesc.cuboid(hx, hy, hz).setTranslation(cx, cy, cz) + if (o.rot) { + const q = new THREE.Quaternion().setFromEuler(o.rot) + desc.setRotation({ x: q.x, y: q.y, z: q.z, w: q.w }) + } + const collider = physics.createCollider(desc) + // Mirror the tag onto the collider so Lane C can query from a physics hit. + if (o.surface) (collider as unknown as { userData?: unknown }).userData = { surface: o.surface } + return mesh + } + + // ---- base floor: catches everything, spans the whole course ---- + addBox(0, -0.5, -15, 28, 0.5, 60, { color: '#efe3c6', roughness: 1 }) + + // ---- start plateau (raised cream pad) ---- + addBox(0, 0.75, 30, 10, 0.75, 9, { color: '#f7edd2', cast: true }) + + // ---- ramp up (orange) : lifts the -Z end so you climb toward the high shelf ---- + addBox(0, 1.7, 14, 6, 0.4, 8, { + color: '#FF9500', rot: new THREE.Euler(0.42, 0, 0), cast: true, + }) + + // ---- gap jump: two blue shelves with a real gap over the base floor ---- + addBox(0, 3.0, 6, 7, 0.5, 4, { color: '#0A84FF', cast: true }) // high shelf, z[2..10] + addBox(0, 3.0, -6, 7, 0.5, 4, { color: '#0A84FF', cast: true }) // landing, z[-10..-2] + + // ---- down slope (green) : back to ground level ---- + addBox(0, 1.7, -16, 6, 0.4, 8, { + color: '#34C759', rot: new THREE.Euler(-0.36, 0, 0), cast: true, + }) + + // ---- milk river (water zone) : thin translucent slab flush with the floor ---- + addBox(0, 0.06, -30, 14, 0.06, 7, { + color: '#f4f6ff', surface: 'water', transparent: 0.72, roughness: 0.2, + }) + + // ---- open flat area : Lane C lands machines here at integration ---- + const machineArea = new THREE.Vector3(0, 0.12, -48) + addBox(machineArea.x, 0.12, machineArea.z, 16, 0.12, 11, { + color: '#d9c9a0', surface: 'machine-area', + }) + + // ---- finish pad (bright pink, raised) ---- + addBox(0, 0.6, -64, 9, 0.6, 6, { color: '#FF6EB4', surface: 'finish', cast: true }) + + // ---- absurd-proportion scenery: a giant cereal box beside the start ---- + addBox(-20, 7, 26, 4, 7, 3, { color: '#FFD60A', cast: true }) + addBox(20, 5, 12, 3, 5, 3, { color: '#AF52DE', cast: true }) + + return { + spawn: new THREE.Vector3(0, 3.5, 30), + machineArea, + meshes, + } +} diff --git a/src/demo/lane-a.ts b/src/demo/lane-a.ts index c579f1a..532716d 100644 --- a/src/demo/lane-a.ts +++ b/src/demo/lane-a.ts @@ -1 +1,121 @@ -console.log("lane-a demo: pending — lane agent replaces this file") +/** + * Lane A demo — the playground. + * + * Boots the shared world, builds the greybox, spawns a blob, and wires the + * controller (physics), feel (wobble, via the frozen events) and follow camera. + * + * CONTROLS + * WASD / Arrows move (camera-relative) + * Space jump + * + * DEBUG KEYS (toggle — press again to reset to base). Proves modifier plumbing: + * 1 speedMul 1.6 2 jumpMul 1.5 3 size 1.5 + * 4 massMul 3.0 5 grip 1.0 6 glow 1.0 (super-state pulse) + * 0 reset all modifiers R respawn at start + * + * A HUD (top-left) and a once-per-second console "state" line report live values + * so integration can verify fast without a screenshot. + */ +import { createWorld } from '../world' +import { defaultModifiers } from '../contracts' +import { buildGreybox } from '../course/greybox' +import { createBlob } from '../blob/createBlob' +import { createBlobController } from '../blob/controller' +import { createBlobFeel } from '../blob/feel' +import { createFollowCamera } from '../blob/camera' + +const world = await createWorld(document.getElementById('app')!) +const course = buildGreybox(world) + +const blob = createBlob(world, { position: course.spawn }) +world.blob = blob + +// physics system (fixed 60Hz) +world.addSystem(createBlobController(world, blob)) + +// feel layer — driven by the FROZEN wire events, updated per render frame +const feel = createBlobFeel(blob) +world.events.on('blob:jumped', () => feel.onJump()) +world.events.on('blob:landed', (p: { impact: number }) => feel.onLand(p.impact)) +world.onFrame((dt) => feel.update(dt)) + +// follow camera +const camera = createFollowCamera(world, blob) +world.onFrame((dt) => camera.update(dt)) + +// ---- event counters + last-impact (for HUD/console) ---- +let jumps = 0 +let lands = 0 +let lastImpact = 0 +world.events.on('blob:jumped', () => { jumps++ }) +world.events.on('blob:landed', (p: { impact: number }) => { lands++; lastImpact = p.impact }) + +// ---- respawn if you fall off the world ---- +world.onFrame(() => { + const t = blob.body.translation() + if (t.y < -25) respawn() +}) +function respawn(): void { + blob.body.setTranslation({ x: course.spawn.x, y: course.spawn.y, z: course.spawn.z }, true) + blob.body.setLinvel({ x: 0, y: 0, z: 0 }, true) +} + +// ---- debug keys ---- +const m = blob.modifiers +const toggle = (cur: number, on: number, base: number) => (cur === base ? on : base) +addEventListener('keydown', (e) => { + switch (e.code) { + case 'Digit1': m.speedMul = toggle(m.speedMul, 1.6, 1); break + case 'Digit2': m.jumpMul = toggle(m.jumpMul, 1.5, 1); break + case 'Digit3': m.size = toggle(m.size, 1.5, 1); break + case 'Digit4': m.massMul = toggle(m.massMul, 3.0, 1); break + case 'Digit5': m.grip = toggle(m.grip, 1.0, 0); break + case 'Digit6': m.glow = toggle(m.glow, 1.0, 0); break + case 'Digit0': Object.assign(m, defaultModifiers()); break + case 'KeyR': respawn(); break + default: return + } + console.log('[lane-a] modifiers', JSON.stringify(m)) +}) + +// ---- HUD overlay ---- +const hud = document.createElement('div') +hud.style.cssText = + 'position:fixed;top:10px;left:10px;font:12px/1.5 ui-monospace,Menlo,monospace;' + + 'color:#0a2540;background:rgba(255,255,255,.72);padding:10px 12px;border-radius:8px;' + + 'white-space:pre;pointer-events:none;z-index:10' +document.body.appendChild(hud) + +// ---- per-frame HUD + fps + once/sec console state line ---- +let frames = 0 +let fps = 0 +let acc = 0 +let logAcc = 0 +world.onFrame((dt) => { + frames++ + acc += dt + if (acc >= 0.5) { fps = Math.round(frames / acc); frames = 0; acc = 0 } + + const t = blob.body.translation() + const v = blob.body.linvel() + const speed = Math.hypot(v.x, v.z) + hud.textContent = + `BLOBBO · lane-a ${fps} fps\n` + + `pos ${t.x.toFixed(1)} ${t.y.toFixed(1)} ${t.z.toFixed(1)}\n` + + `spd ${speed.toFixed(2)} jumps ${jumps} lands ${lands} impact ${lastImpact.toFixed(1)}\n` + + `mods speed ${m.speedMul} jump ${m.jumpMul} size ${m.size} mass ${m.massMul} grip ${m.grip} glow ${m.glow}\n` + + `WASD move · Space jump · 1 speed 2 jump 3 size 4 mass 5 grip 6 glow · 0 reset · R respawn` + + logAcc += dt + if (logAcc >= 1) { + logAcc = 0 + console.log( + `[lane-a] state fps=${fps} pos=(${t.x.toFixed(1)},${t.y.toFixed(1)},${t.z.toFixed(1)}) ` + + `spd=${speed.toFixed(2)} jumps=${jumps} lands=${lands} impact=${lastImpact.toFixed(1)} ` + + `mods=${JSON.stringify(m)}`, + ) + } +}) + +world.start() +console.log('[lane-a] booted. WASD to move, Space to jump, keys 1-6 modifiers, 0 reset, R respawn.')