Lane A: blob controller, feel layer, follow camera, greybox
- 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>
This commit is contained in:
parent
969f602dd4
commit
4b08ba65ac
89
src/blob/camera.ts
Normal file
89
src/blob/camera.ts
Normal file
@ -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,
|
||||
}
|
||||
}
|
||||
151
src/blob/controller.ts
Normal file
151
src/blob/controller.ts
Normal file
@ -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<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))
|
||||
|
||||
// ---- 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
|
||||
},
|
||||
}
|
||||
}
|
||||
113
src/blob/createBlob.ts
Normal file
113
src/blob/createBlob.ts
Normal file
@ -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(),
|
||||
}
|
||||
}
|
||||
129
src/blob/feel.ts
Normal file
129
src/blob/feel.ts
Normal file
@ -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
|
||||
},
|
||||
}
|
||||
}
|
||||
115
src/course/greybox.ts
Normal file
115
src/course/greybox.ts
Normal file
@ -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,
|
||||
}
|
||||
}
|
||||
@ -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.')
|
||||
|
||||
Loading…
Reference in New Issue
Block a user