Compare commits
7 Commits
c169bf73aa
...
d4d41208f8
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d4d41208f8 | ||
|
|
6979c0db38 | ||
|
|
8aaa11cdc1 | ||
|
|
827b39be2e | ||
|
|
72d8c7ca80 | ||
|
|
eed2eb2646 | ||
|
|
4b08ba65ac |
BIN
assets/meshes/blobbo-base.glb
Normal file
BIN
assets/meshes/prop-paint-bucket.glb
Normal file
BIN
assets/meshes/prop-spring-boot.glb
Normal file
BIN
assets/meshes/prop-toaster-launcher.glb
Normal file
BIN
assets/meshes/src/blobbo-base-cut.png
Normal file
|
After Width: | Height: | Size: 499 KiB |
BIN
assets/meshes/src/blobbo-base.png
Normal file
|
After Width: | Height: | Size: 436 KiB |
BIN
assets/meshes/src/prop-paint-bucket-cut.png
Normal file
|
After Width: | Height: | Size: 801 KiB |
BIN
assets/meshes/src/prop-paint-bucket.png
Normal file
|
After Width: | Height: | Size: 712 KiB |
BIN
assets/meshes/src/prop-spring-boot-cut.png
Normal file
|
After Width: | Height: | Size: 635 KiB |
BIN
assets/meshes/src/prop-spring-boot.png
Normal file
|
After Width: | Height: | Size: 566 KiB |
BIN
assets/meshes/src/prop-toaster-launcher-cut.png
Normal file
|
After Width: | Height: | Size: 668 KiB |
BIN
assets/meshes/src/prop-toaster-launcher.png
Normal file
|
After Width: | Height: | Size: 587 KiB |
@ -1,6 +1,25 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head><meta charset="utf-8" /><title>BLOBBO lane c demo</title>
|
||||
<style>html,body{margin:0;height:100%;overflow:hidden}#app{width:100%;height:100%}</style></head>
|
||||
<body><div id="app"></div><script type="module" src="/src/demo/lane-c.ts"></script></body>
|
||||
<style>
|
||||
html,body{margin:0;height:100%;overflow:hidden;background:#bfe3ff;font-family:system-ui,sans-serif}
|
||||
#app{width:100%;height:100%}
|
||||
#legend{position:fixed;top:10px;left:10px;padding:10px 12px;border-radius:8px;
|
||||
background:rgba(20,24,40,.72);color:#eaf2ff;font-size:13px;line-height:1.5;pointer-events:none}
|
||||
#legend b{color:#ffd60a}
|
||||
#legend .k{display:inline-block;min-width:14px;padding:1px 5px;margin-right:6px;border-radius:4px;
|
||||
background:#354569;color:#fff;font-weight:600;text-align:center}
|
||||
</style></head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<div id="legend">
|
||||
<b>BLOBBO — Lane C machine demo</b><br>
|
||||
<span class="k">T</span>re-run heavy chain ball<br>
|
||||
<span class="k">L</span>drop light ball (plate must NOT trip)<br>
|
||||
<span class="k">S</span>re-run ice/honey surface probes<br>
|
||||
<span class="k">F</span>drop light ball in the fan beam<br>
|
||||
<span class="k">P</span>print machine state to console
|
||||
</div>
|
||||
<script type="module" src="/src/demo/lane-c.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
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,
|
||||
}
|
||||
}
|
||||
156
src/blob/controller.ts
Normal file
@ -0,0 +1,156 @@
|
||||
/**
|
||||
* 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()
|
||||
// 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
|
||||
|
||||
// ---- 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
@ -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
@ -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
|
||||
},
|
||||
}
|
||||
}
|
||||
@ -82,6 +82,10 @@ export interface World {
|
||||
addSystem(s: System): void
|
||||
/** Per-render-frame hooks (visual-only work: cameras, jiggle, particles). */
|
||||
onFrame(fn: (dt: number) => void): void
|
||||
/** Manually advance the fixed-step sim (tests, headless verification). */
|
||||
tick(steps?: number): void
|
||||
/** Run frame hooks + render one frame (screenshots while backgrounded). */
|
||||
renderOnce(): void
|
||||
start(): void
|
||||
}
|
||||
|
||||
|
||||
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.')
|
||||
|
||||
@ -1 +1,262 @@
|
||||
console.log("lane-b demo: pending — lane agent replaces this file")
|
||||
/**
|
||||
* Lane B demo — the paint core, standalone. A stub white test ball (NOT Lane A's
|
||||
* blob) orbits between a RED and a GREEN cannon. Paint lands where each glob
|
||||
* hits, coverage drives buffs (speed/embers/grip/waterproof/mass/glow), and the
|
||||
* HUD reads it live. Cleanse scrubs it off.
|
||||
*
|
||||
* Everything Lane B ships is exercised here: PaintSkin, PaintCannon, BuffSystem,
|
||||
* PaintHUD, and the inbound event protocol via installPaint().
|
||||
*
|
||||
* DEBUG KEYS
|
||||
* 1 / 2 fire the RED / GREEN cannon at the ball right now
|
||||
* B / N emit machine:signal for the RED / GREEN cannon's trigger id
|
||||
* 9 / 0 flood ~80% RED / GREEN (fast path to the super state)
|
||||
* G emit paint:request-splat on the ball (simulates a Lane C bucket dump)
|
||||
* K emit paint:request-cleanse {0.5} (simulates a Lane C bubble arch)
|
||||
* C cleanse 50% (scrub holes) X cleanse 100% (full clean)
|
||||
* P pause / resume the ball's orbit (watch the paint sit)
|
||||
* H toggle the HUD
|
||||
* A state line prints to the console every second: coverage %s, buffs, splats/s.
|
||||
*/
|
||||
import * as THREE from 'three'
|
||||
import { createWorld } from '../world'
|
||||
import { defaultModifiers, PALETTE } from '../contracts'
|
||||
import type { PaintColor } from '../contracts'
|
||||
import {
|
||||
BuffSystem,
|
||||
PaintCannon,
|
||||
PaintHUD,
|
||||
PaintSkin,
|
||||
installPaint,
|
||||
COLOR_ORDER,
|
||||
} from '../paint'
|
||||
|
||||
const RED_SIGNAL = 'sig-red'
|
||||
const GREEN_SIGNAL = 'sig-green'
|
||||
const ORBIT_R = 3.6
|
||||
const BALL_R = 0.6
|
||||
|
||||
const world = await createWorld(document.getElementById('app')!)
|
||||
const { scene, camera, physics, rapier, events } = world
|
||||
|
||||
camera.position.set(0, 7.5, 12)
|
||||
|
||||
// ---- floor ----------------------------------------------------------------
|
||||
const floor = new THREE.Mesh(
|
||||
new THREE.BoxGeometry(40, 1, 40),
|
||||
new THREE.MeshStandardMaterial({ color: '#e8d9b8' }),
|
||||
)
|
||||
floor.position.y = -0.5
|
||||
floor.receiveShadow = true
|
||||
scene.add(floor)
|
||||
physics.createCollider(
|
||||
rapier.ColliderDesc.cuboid(20, 0.5, 20).setTranslation(0, -0.5, 0).setFriction(1.2),
|
||||
)
|
||||
|
||||
// ---- stub test ball (our own — NOT Lane A's blob) -------------------------
|
||||
const ballMesh = new THREE.Mesh(
|
||||
new THREE.SphereGeometry(BALL_R, 48, 32),
|
||||
new THREE.MeshStandardMaterial({ color: '#F5F5F7', roughness: 0.35, metalness: 0 }),
|
||||
)
|
||||
ballMesh.castShadow = true
|
||||
scene.add(ballMesh)
|
||||
|
||||
const ballBody = physics.createRigidBody(
|
||||
rapier.RigidBodyDesc.dynamic().setTranslation(ORBIT_R, BALL_R + 0.2, 0).setAngularDamping(0.4),
|
||||
)
|
||||
physics.createCollider(
|
||||
rapier.ColliderDesc.ball(BALL_R).setRestitution(0.2).setFriction(1.4).setDensity(2),
|
||||
ballBody,
|
||||
)
|
||||
const baseMass = ballBody.mass()
|
||||
|
||||
// the blob-like target the paint systems operate on
|
||||
const blob = {
|
||||
mesh: ballMesh,
|
||||
body: ballBody,
|
||||
modifiers: defaultModifiers(),
|
||||
paint: new PaintSkin(ballMesh, { size: 256 }),
|
||||
}
|
||||
|
||||
// ---- paint systems --------------------------------------------------------
|
||||
const hud = new PaintHUD()
|
||||
|
||||
const redCannon = new PaintCannon({
|
||||
world,
|
||||
position: new THREE.Vector3(-7, 0, -1.5),
|
||||
color: 'red',
|
||||
target: ballMesh,
|
||||
paint: blob.paint,
|
||||
targetRadius: BALL_R,
|
||||
triggerId: RED_SIGNAL,
|
||||
interval: 0.9,
|
||||
muzzleSpeed: 15,
|
||||
splatRadius: 0.3,
|
||||
})
|
||||
const greenCannon = new PaintCannon({
|
||||
world,
|
||||
position: new THREE.Vector3(7, 0, 1.5),
|
||||
color: 'green',
|
||||
target: ballMesh,
|
||||
paint: blob.paint,
|
||||
targetRadius: BALL_R,
|
||||
triggerId: GREEN_SIGNAL,
|
||||
interval: 1.0,
|
||||
muzzleSpeed: 15,
|
||||
splatRadius: 0.3,
|
||||
})
|
||||
|
||||
const buffs = new BuffSystem(world, blob)
|
||||
|
||||
// inbound Lane C protocol → this blob (proximity-gated splats + cleanse)
|
||||
installPaint(world, { mesh: ballMesh, paint: blob.paint })
|
||||
|
||||
world.addSystem(redCannon)
|
||||
world.addSystem(greenCannon)
|
||||
world.addSystem(buffs)
|
||||
|
||||
// ---- ball orbit controller (reads modifiers so buffs visibly change it) ---
|
||||
let paused = false
|
||||
world.addSystem({
|
||||
update(dt: number) {
|
||||
// paint = weight: reflect massMul in the physics body
|
||||
ballBody.setAdditionalMass(Math.max(0, baseMass * (blob.modifiers.massMul - 1)), true)
|
||||
|
||||
const p = ballBody.translation()
|
||||
if (!paused) {
|
||||
// chase a point a little ahead on the ring; force-based so it rolls
|
||||
const cur = Math.atan2(p.z, p.x)
|
||||
const targetAngle = cur + 0.6
|
||||
const tx = Math.cos(targetAngle) * ORBIT_R
|
||||
const tz = Math.sin(targetAngle) * ORBIT_R
|
||||
const dx = tx - p.x
|
||||
const dz = tz - p.z
|
||||
const len = Math.hypot(dx, dz) || 1
|
||||
const push = 9 * blob.modifiers.speedMul // BURN makes it faster
|
||||
ballBody.applyImpulse({ x: (dx / len) * push * dt, y: 0, z: (dz / len) * push * dt }, true)
|
||||
|
||||
// cap horizontal speed (scaled by speed buff)
|
||||
const v = ballBody.linvel()
|
||||
const sp = Math.hypot(v.x, v.z)
|
||||
const maxSp = 4.2 * blob.modifiers.speedMul
|
||||
if (sp > maxSp) {
|
||||
const k = maxSp / sp
|
||||
ballBody.setLinvel({ x: v.x * k, y: v.y, z: v.z * k }, true)
|
||||
}
|
||||
}
|
||||
|
||||
// sync mesh to body (rotation included → paint rides the spinning body)
|
||||
ballMesh.position.set(p.x, p.y, p.z)
|
||||
const q = ballBody.rotation()
|
||||
ballMesh.quaternion.set(q.x, q.y, q.z, q.w)
|
||||
},
|
||||
})
|
||||
|
||||
// ---- camera ---------------------------------------------------------------
|
||||
world.onFrame(() => {
|
||||
camera.lookAt(ballMesh.position.x * 0.3, 0.5, ballMesh.position.z * 0.3)
|
||||
})
|
||||
|
||||
// ---- debug keys -----------------------------------------------------------
|
||||
function floodColor(color: PaintColor, splats = 130) {
|
||||
for (let i = 0; i < splats; i++) {
|
||||
blob.paint.applySplat(
|
||||
new THREE.Vector2(Math.random(), 0.1 + Math.random() * 0.8),
|
||||
color,
|
||||
0.55,
|
||||
)
|
||||
}
|
||||
console.log(`[flood] ${color} — ${splats} splats`)
|
||||
}
|
||||
|
||||
addEventListener('keydown', (e) => {
|
||||
switch (e.code) {
|
||||
case 'Digit1':
|
||||
redCannon.fire()
|
||||
break
|
||||
case 'Digit2':
|
||||
greenCannon.fire()
|
||||
break
|
||||
case 'KeyB':
|
||||
events.emit('machine:signal', { id: RED_SIGNAL })
|
||||
console.log('[signal] machine:signal', RED_SIGNAL)
|
||||
break
|
||||
case 'KeyN':
|
||||
events.emit('machine:signal', { id: GREEN_SIGNAL })
|
||||
console.log('[signal] machine:signal', GREEN_SIGNAL)
|
||||
break
|
||||
case 'Digit9':
|
||||
floodColor('red')
|
||||
break
|
||||
case 'Digit0':
|
||||
floodColor('green')
|
||||
break
|
||||
case 'KeyG': {
|
||||
// simulate a Lane C bucket dump (BLUE) landing on the ball
|
||||
const pos = ballMesh.position.clone().add(new THREE.Vector3(0, BALL_R, 0))
|
||||
events.emit('paint:request-splat', { point: pos, color: 'blue' as PaintColor, radius: 0.35 })
|
||||
console.log('[request-splat] blue on ball')
|
||||
break
|
||||
}
|
||||
case 'KeyK':
|
||||
events.emit('paint:request-cleanse', { fraction: 0.5 })
|
||||
console.log('[request-cleanse] 0.5')
|
||||
break
|
||||
case 'KeyC':
|
||||
blob.paint.cleanse(0.5)
|
||||
console.log('[cleanse] 0.5 (scrub)')
|
||||
break
|
||||
case 'KeyX':
|
||||
blob.paint.cleanse(1)
|
||||
console.log('[cleanse] full')
|
||||
break
|
||||
case 'KeyP':
|
||||
paused = !paused
|
||||
console.log(`[orbit] ${paused ? 'paused' : 'running'}`)
|
||||
break
|
||||
case 'KeyH':
|
||||
hud.root.style.display = hud.root.style.display === 'none' ? '' : 'none'
|
||||
break
|
||||
}
|
||||
})
|
||||
|
||||
// ---- HUD + console state line ---------------------------------------------
|
||||
let splatCount = 0
|
||||
events.on('paint:splatted', (p: { color: PaintColor; target: string }) => {
|
||||
if (p.target === 'blob') splatCount++
|
||||
})
|
||||
|
||||
let lastLog = performance.now()
|
||||
world.onFrame(() => {
|
||||
const cov = blob.paint.coverage()
|
||||
hud.update(cov, blob.modifiers)
|
||||
|
||||
const now = performance.now()
|
||||
if (now - lastLog >= 1000) {
|
||||
lastLog = now
|
||||
const pcts = COLOR_ORDER.filter((c) => cov.byColor[c] > 0.005)
|
||||
.map((c) => `${c}:${(cov.byColor[c] * 100).toFixed(0)}%`)
|
||||
.join(' ')
|
||||
const m = blob.modifiers
|
||||
const buffStr = [
|
||||
m.speedMul > 1.001 ? `speed×${m.speedMul.toFixed(2)}` : '',
|
||||
m.grip > 0.001 ? `grip${m.grip.toFixed(2)}` : '',
|
||||
m.waterproof ? 'waterproof' : '',
|
||||
`mass×${m.massMul.toFixed(2)}`,
|
||||
m.glow > 0 ? 'SUPER' : '',
|
||||
].filter(Boolean).join(' ')
|
||||
console.log(
|
||||
`[paint] total ${(cov.total * 100).toFixed(0)}% | ${pcts || '(clean)'} | ${buffStr} | +${splatCount} splats/s`,
|
||||
)
|
||||
splatCount = 0
|
||||
}
|
||||
})
|
||||
|
||||
// friendly banner
|
||||
console.log(
|
||||
'%cBLOBBO Lane B — Paint demo',
|
||||
`color:${PALETTE.red};font-weight:700`,
|
||||
)
|
||||
console.log('keys: 1/2 fire B/N signal 9/0 flood G request-splat K request-cleanse C/X cleanse P pause H hud')
|
||||
|
||||
world.start()
|
||||
|
||||
@ -1 +1,302 @@
|
||||
console.log("lane-c demo: pending — lane agent replaces this file")
|
||||
/**
|
||||
* Lane C demo — the Rube-Goldberg chain + surfaces sandbox.
|
||||
*
|
||||
* Chain (pure data wiring via `machine:signal`, no cross-instance references):
|
||||
* heavy ball rests on PressurePlate ──emit 'sig:boot'──▶ SpringBoot
|
||||
* SpringBoot TELEGRAPHs then kicks the ball ▶ it lands on the SeeSaw
|
||||
* SeeSaw (real revolute joint) tips ▶ ball rolls into the BucketDump catch zone
|
||||
* BucketDump TELEGRAPHs (teeter) then dumps ▶ emits 'paint:request-splat' (logged)
|
||||
* Side rigs: ConveyorBelt, BubbleArch, a Fan, plus ICE and HONEY patches with two
|
||||
* probe balls so surfaces measurably change rolling behaviour.
|
||||
*
|
||||
* Debug keys (browser):
|
||||
* T — re-run the heavy chain ball from the start
|
||||
* L — drop a LIGHT ball on the plate (must NOT trip it)
|
||||
* S — re-run the ice/honey surface probes
|
||||
* F — drop a light ball into the fan beam
|
||||
* P — print a one-line machine state snapshot
|
||||
*
|
||||
* Console: every machine:signal / paint:* event is logged, and a STATE line is
|
||||
* printed once per second, so integration can verify the chain without the screen.
|
||||
*/
|
||||
import * as THREE from 'three'
|
||||
import type { World } from '../contracts'
|
||||
import {
|
||||
createPressurePlate, createSpringBoot, createSeeSaw, createBucketDump,
|
||||
createConveyorBelt, createBubbleArch, createFan,
|
||||
SurfaceRegistry, SURFACES, type SurfaceName,
|
||||
} from '../machine'
|
||||
|
||||
// ---- tunables (kept in one place so integration can retune the layout) ----
|
||||
// Values were tuned in a headless physics sweep so the ball reliably lands on
|
||||
// the plank and the ice runway carries it into the bucket's catch zone.
|
||||
const CFG = {
|
||||
station: [0, 0, 0] as [number, number, number], // plate + boot
|
||||
plateThreshold: 2.5,
|
||||
bootImpulse: 36,
|
||||
bootDir: [0.5, 1.0, 0] as [number, number, number],
|
||||
seesaw: [4.5, 1.3, 0] as [number, number, number],
|
||||
plankLen: 5,
|
||||
maxTilt: 0.45,
|
||||
restTilt: -0.3, // +X end starts low → delivers +X
|
||||
runway: [6, 10] as [number, number], // ice strip seesaw → bucket
|
||||
bucket: [11, 2.4, 0] as [number, number, number],
|
||||
heavyBall: { radius: 0.7, density: 2.5, spawn: [0, 2.4, 0] as [number, number, number] },
|
||||
lightBall: { radius: 0.5, density: 0.35 },
|
||||
}
|
||||
|
||||
interface Handles {
|
||||
world: World
|
||||
surfaces: SurfaceRegistry
|
||||
spawnHeavyBall: () => void
|
||||
spawnLightBall: () => void
|
||||
runSurfaceProbes: () => void
|
||||
spawnFanBall: () => void
|
||||
snapshot: () => Record<string, unknown>
|
||||
}
|
||||
|
||||
export function buildLaneC(world: World): Handles {
|
||||
const { scene, physics, rapier } = world
|
||||
|
||||
// -------------------------------------------------------------- lighting/floor
|
||||
const floor = new THREE.Mesh(
|
||||
new THREE.BoxGeometry(80, 1, 40),
|
||||
new THREE.MeshStandardMaterial({ color: '#cbb98a', roughness: 0.95 }),
|
||||
)
|
||||
floor.position.y = -0.5
|
||||
floor.receiveShadow = true
|
||||
scene.add(floor)
|
||||
const floorCol = physics.createCollider(
|
||||
rapier.ColliderDesc.cuboid(40, 0.5, 20).setTranslation(0, -0.5, 0).setFriction(1.0),
|
||||
)
|
||||
|
||||
const surfaces = new SurfaceRegistry(rapier)
|
||||
surfaces.apply(floorCol, 'normal')
|
||||
|
||||
// low containment wall past the bucket so the chain ball can't overshoot
|
||||
const wall = new THREE.Mesh(
|
||||
new THREE.BoxGeometry(1, 3, 8),
|
||||
new THREE.MeshStandardMaterial({ color: '#b0a06e', roughness: 0.9 }),
|
||||
)
|
||||
wall.position.set(CFG.bucket[0] + 2.5, 0.75, 0)
|
||||
scene.add(wall)
|
||||
physics.createCollider(
|
||||
rapier.ColliderDesc.cuboid(0.5, 1.5, 4).setTranslation(wall.position.x, 0.75, 0),
|
||||
)
|
||||
|
||||
// ICE runway on the chain path: carries the ball off the seesaw into the bucket.
|
||||
const [rx0, rx1] = CFG.runway
|
||||
const rMid = (rx0 + rx1) / 2, rHalf = (rx1 - rx0) / 2
|
||||
const runwayMesh = new THREE.Mesh(
|
||||
new THREE.BoxGeometry(rHalf * 2, 0.2, 4),
|
||||
new THREE.MeshStandardMaterial({ color: SURFACES.ice.color, roughness: 0.15, metalness: 0.1 }),
|
||||
)
|
||||
runwayMesh.position.set(rMid, 0.1, 0)
|
||||
runwayMesh.receiveShadow = true
|
||||
scene.add(runwayMesh)
|
||||
const runwayCol = physics.createCollider(
|
||||
rapier.ColliderDesc.cuboid(rHalf, 0.1, 2).setTranslation(rMid, 0.1, 0),
|
||||
)
|
||||
surfaces.apply(runwayCol, 'ice')
|
||||
|
||||
// ---------------------------------------------------------------- the chain
|
||||
// (1) PressurePlate — emits the boot signal only past its mass threshold.
|
||||
const plate = createPressurePlate(world, {
|
||||
id: 'plate-launch',
|
||||
position: CFG.station,
|
||||
massThreshold: CFG.plateThreshold,
|
||||
size: [3, 3],
|
||||
emits: 'sig:boot',
|
||||
})
|
||||
|
||||
// (2) SpringBoot — co-mounted on the plate (no pad of its own); kicks on signal.
|
||||
createSpringBoot(world, {
|
||||
id: 'boot',
|
||||
position: CFG.station,
|
||||
impulse: CFG.bootImpulse,
|
||||
direction: CFG.bootDir,
|
||||
onSignal: 'sig:boot',
|
||||
strikeSize: [1.4, 0.9, 1.4],
|
||||
pad: false,
|
||||
emits: 'sig:boot-fired',
|
||||
})
|
||||
|
||||
// (3) SeeSaw — real revolute joint; pre-biased so the ball is delivered +X.
|
||||
createSeeSaw(world, {
|
||||
id: 'seesaw',
|
||||
position: CFG.seesaw,
|
||||
plankSize: [CFG.plankLen, 0.4, 2.6],
|
||||
maxTilt: CFG.maxTilt,
|
||||
restTilt: CFG.restTilt,
|
||||
})
|
||||
|
||||
// (4) BucketDump — self-contained catch zone (placed beyond the plank's reach)
|
||||
// triggers the teeter+dump when the ball slides in off the ice runway.
|
||||
createBucketDump(world, {
|
||||
id: 'bucket',
|
||||
position: CFG.bucket,
|
||||
color: 'purple',
|
||||
radius: 2.2,
|
||||
trigger: { size: [1.4, 2.2, 4], offset: [-1.7, -2.4, 0] },
|
||||
emits: 'sig:bucket-dumped',
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------- side rigs
|
||||
createConveyorBelt(world, {
|
||||
id: 'belt',
|
||||
position: [-6, 0.25, 8],
|
||||
size: [10, 0.5, 3],
|
||||
velocity: [3.5, 0, 0],
|
||||
})
|
||||
|
||||
createBubbleArch(world, {
|
||||
id: 'arch',
|
||||
position: [4, 0, 8],
|
||||
fraction: 0.5,
|
||||
size: [4, 4, 2],
|
||||
})
|
||||
|
||||
createFan(world, {
|
||||
id: 'fan',
|
||||
position: [-4, 1.2, -8],
|
||||
force: 9,
|
||||
direction: [1, 0.1, 0],
|
||||
range: 12,
|
||||
spread: 1.8,
|
||||
})
|
||||
|
||||
// ------------------------------------------------- surface probe lanes (ice/honey)
|
||||
// Two identical lanes side by side; a probe ball on each gets the same push.
|
||||
// Ice lets it slide far, honey stops it fast — measurable in the STATE line.
|
||||
const makePatch = (name: SurfaceName, z: number) => {
|
||||
const mat = new THREE.MeshStandardMaterial({ color: SURFACES[name].color, roughness: 0.4 })
|
||||
const patch = new THREE.Mesh(new THREE.BoxGeometry(18, 0.2, 3), mat)
|
||||
patch.position.set(3, 0.1, z)
|
||||
patch.receiveShadow = true
|
||||
scene.add(patch)
|
||||
const col = physics.createCollider(
|
||||
rapier.ColliderDesc.cuboid(9, 0.1, 1.5).setTranslation(3, 0.1, z),
|
||||
)
|
||||
surfaces.apply(col, name)
|
||||
return z
|
||||
}
|
||||
const iceZ = makePatch('ice', -14)
|
||||
const honeyZ = makePatch('honey', -18)
|
||||
|
||||
// ----------------------------------------------------------- test balls (stub)
|
||||
// NOTE: this is Lane C's own throwaway ball — NOT Lane A's blob (never imported).
|
||||
let chainBall: { mesh: THREE.Mesh; body: import('@dimforge/rapier3d-compat').RigidBody } | null = null
|
||||
|
||||
const makeBall = (
|
||||
pos: [number, number, number], radius: number, density: number, color = '#F5F5F7',
|
||||
) => {
|
||||
const mesh = new THREE.Mesh(
|
||||
new THREE.SphereGeometry(radius, 24, 18),
|
||||
new THREE.MeshStandardMaterial({ color, roughness: 0.4 }),
|
||||
)
|
||||
mesh.castShadow = true
|
||||
scene.add(mesh)
|
||||
const body = physics.createRigidBody(
|
||||
rapier.RigidBodyDesc.dynamic().setTranslation(pos[0], pos[1], pos[2]).setLinearDamping(0.05),
|
||||
)
|
||||
physics.createCollider(
|
||||
rapier.ColliderDesc.ball(radius).setDensity(density).setFriction(1.0).setRestitution(0.15),
|
||||
body,
|
||||
)
|
||||
world.onFrame(() => {
|
||||
const t = body.translation(), r = body.rotation()
|
||||
mesh.position.set(t.x, t.y, t.z)
|
||||
mesh.quaternion.set(r.x, r.y, r.z, r.w)
|
||||
})
|
||||
return { mesh, body }
|
||||
}
|
||||
|
||||
const spawnHeavyBall = () => {
|
||||
if (chainBall) {
|
||||
scene.remove(chainBall.mesh)
|
||||
physics.removeRigidBody(chainBall.body)
|
||||
}
|
||||
const { radius, density, spawn } = CFG.heavyBall
|
||||
chainBall = makeBall(spawn, radius, density, '#F5F5F7')
|
||||
console.log(`[demo] heavy chain ball spawned (mass=${chainBall.body.mass().toFixed(2)}) — chain should run`)
|
||||
}
|
||||
|
||||
const spawnLightBall = () => {
|
||||
const b = makeBall([0, 2.4, 0], CFG.lightBall.radius, CFG.lightBall.density, '#ffd1dc')
|
||||
console.log(`[demo] LIGHT ball on plate (mass=${b.body.mass().toFixed(2)} < threshold ${CFG.plateThreshold}) — plate must NOT trip`)
|
||||
}
|
||||
|
||||
const spawnFanBall = () => {
|
||||
const b = makeBall([-3, 2.2, -8], 0.5, 0.35, '#c8f7c5')
|
||||
console.log(`[demo] light ball dropped in fan beam (mass=${b.body.mass().toFixed(2)}) — should be blown +X`)
|
||||
}
|
||||
|
||||
// surface probes: give each an identical +X kick and log resting distance
|
||||
let iceProbe: import('@dimforge/rapier3d-compat').RigidBody | null = null
|
||||
let honeyProbe: import('@dimforge/rapier3d-compat').RigidBody | null = null
|
||||
const runSurfaceProbes = () => {
|
||||
for (const p of [iceProbe, honeyProbe]) if (p) physics.removeRigidBody(p)
|
||||
const ice = makeBall([-4, 0.7, iceZ], 0.5, 1.0, '#e6f7ff')
|
||||
const honey = makeBall([-4, 0.7, honeyZ], 0.5, 1.0, '#fff0c2')
|
||||
iceProbe = ice.body
|
||||
honeyProbe = honey.body
|
||||
ice.body.setLinvel({ x: 8, y: 0, z: 0 }, true)
|
||||
honey.body.setLinvel({ x: 8, y: 0, z: 0 }, true)
|
||||
console.log('[demo] surface probes launched (+8 m/s): ICE vs HONEY — compare rest x')
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------- event logging
|
||||
world.events.on('machine:signal', (p: { id: string }) =>
|
||||
console.log(`[event] machine:signal id="${p.id}"`))
|
||||
world.events.on('paint:request-splat', (p: any) =>
|
||||
console.log('[event] paint:request-splat', { color: p.color, radius: p.radius }))
|
||||
world.events.on('paint:request-cleanse', (p: any) =>
|
||||
console.log('[event] paint:request-cleanse', p))
|
||||
|
||||
const snapshot = () => {
|
||||
const ball = chainBall?.body.translation()
|
||||
const surfUnderBall = ball
|
||||
? surfaces.surfaceAt(physics, new THREE.Vector3(ball.x, ball.y, ball.z), 2.5)?.name
|
||||
: 'n/a'
|
||||
return {
|
||||
ballX: ball ? +ball.x.toFixed(2) : null,
|
||||
ballY: ball ? +ball.y.toFixed(2) : null,
|
||||
surfaceUnderBall: surfUnderBall,
|
||||
iceProbeX: iceProbe ? +iceProbe.translation().x.toFixed(2) : null,
|
||||
honeyProbeX: honeyProbe ? +honeyProbe.translation().x.toFixed(2) : null,
|
||||
}
|
||||
}
|
||||
|
||||
return { world, surfaces, spawnHeavyBall, spawnLightBall, runSurfaceProbes, spawnFanBall, snapshot }
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------- browser bootstrap
|
||||
if (typeof document !== 'undefined') {
|
||||
const { createWorld } = await import('../world')
|
||||
const world = await createWorld(document.getElementById('app')!)
|
||||
world.camera.position.set(2, 10, 20)
|
||||
const h = buildLaneC(world)
|
||||
|
||||
// frame a nice overview
|
||||
world.onFrame(() => world.camera.lookAt(5, 1, 0))
|
||||
|
||||
// start the chain, and print a STATE line once a second
|
||||
h.spawnHeavyBall()
|
||||
h.runSurfaceProbes()
|
||||
let acc = 0
|
||||
world.onFrame((dt) => {
|
||||
acc += dt
|
||||
if (acc >= 1) { acc = 0; console.log('[STATE]', JSON.stringify(h.snapshot())) }
|
||||
})
|
||||
|
||||
addEventListener('keydown', (e) => {
|
||||
if (e.code === 'KeyT') h.spawnHeavyBall()
|
||||
else if (e.code === 'KeyL') h.spawnLightBall()
|
||||
else if (e.code === 'KeyS') h.runSurfaceProbes()
|
||||
else if (e.code === 'KeyF') h.spawnFanBall()
|
||||
else if (e.code === 'KeyP') console.log('[STATE]', JSON.stringify(h.snapshot()))
|
||||
})
|
||||
|
||||
world.start()
|
||||
console.log('[demo] lane-c ready — keys: T re-run ball · L light ball · S surfaces · F fan · P state')
|
||||
}
|
||||
|
||||
141
src/game.ts
@ -1,56 +1,117 @@
|
||||
/**
|
||||
* INTEGRATION SEAM — owned by integration, not by lanes.
|
||||
* Composes lane modules into the playable slice. While lanes are in flight this
|
||||
* boots a placeholder ball on a flat floor so `npm run dev` always shows life.
|
||||
* The playable slice: Lane A blob + course, Lane B paint + buffs, and (when
|
||||
* lane-c merges) the machine chain on the course's machine area.
|
||||
*/
|
||||
import * as THREE from 'three'
|
||||
import type { World } 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'
|
||||
import { PaintSkin, PaintCannon, BuffSystem, PaintHUD, installPaint } from './paint/index'
|
||||
import { createPressurePlate, createSpringBoot, createBucketDump, createBubbleArch, createConveyorBelt } from './machine/index'
|
||||
|
||||
export function installGame(world: World) {
|
||||
const { scene, physics, rapier } = world
|
||||
const course = buildGreybox(world)
|
||||
|
||||
// floor
|
||||
const floor = new THREE.Mesh(
|
||||
new THREE.BoxGeometry(60, 1, 60),
|
||||
new THREE.MeshStandardMaterial({ color: '#e8d9b8' }))
|
||||
floor.position.y = -0.5
|
||||
floor.receiveShadow = true
|
||||
scene.add(floor)
|
||||
physics.createCollider(rapier.ColliderDesc.cuboid(30, 0.5, 30)
|
||||
.setTranslation(0, -0.5, 0))
|
||||
// ---- blob (Lane A) ----
|
||||
const blob = createBlob(world, { position: course.spawn })
|
||||
blob.paint = new PaintSkin(blob.mesh, { size: 256 })
|
||||
world.blob = blob
|
||||
|
||||
// placeholder blob-ball (replaced by lane A's createBlob at integration)
|
||||
const ball = new THREE.Mesh(
|
||||
new THREE.SphereGeometry(0.6, 32, 24),
|
||||
new THREE.MeshStandardMaterial({ color: '#F5F5F7', roughness: 0.35 }))
|
||||
ball.castShadow = true
|
||||
scene.add(ball)
|
||||
const body = physics.createRigidBody(
|
||||
rapier.RigidBodyDesc.dynamic().setTranslation(0, 3, 0))
|
||||
physics.createCollider(
|
||||
rapier.ColliderDesc.ball(0.6).setRestitution(0.4).setFriction(1.0), body)
|
||||
world.addSystem(createBlobController(world, blob))
|
||||
|
||||
const keys = new Set<string>()
|
||||
addEventListener('keydown', (e) => keys.add(e.code))
|
||||
addEventListener('keyup', (e) => keys.delete(e.code))
|
||||
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))
|
||||
|
||||
world.addSystem({
|
||||
update() {
|
||||
const f = 18
|
||||
const impulse = { x: 0, y: 0, z: 0 }
|
||||
if (keys.has('KeyW') || keys.has('ArrowUp')) impulse.z -= f
|
||||
if (keys.has('KeyS') || keys.has('ArrowDown')) impulse.z += f
|
||||
if (keys.has('KeyA') || keys.has('ArrowLeft')) impulse.x -= f
|
||||
if (keys.has('KeyD') || keys.has('ArrowRight')) impulse.x += f
|
||||
body.applyImpulse({ x: impulse.x / 60, y: 0, z: impulse.z / 60 }, true)
|
||||
const p = body.translation()
|
||||
ball.position.set(p.x, p.y, p.z)
|
||||
const q = body.rotation()
|
||||
ball.quaternion.set(q.x, q.y, q.z, q.w)
|
||||
},
|
||||
const camera = createFollowCamera(world, blob)
|
||||
world.onFrame((dt) => camera.update(dt))
|
||||
|
||||
// ---- paint (Lane B): cannons staged along the racing line ----
|
||||
const skin = blob.paint as PaintSkin
|
||||
const cannonSpecs = [
|
||||
{ color: 'red' as const, position: new THREE.Vector3(-9, 2.5, 14), triggerId: 'cannon-red' },
|
||||
{ color: 'green' as const, position: new THREE.Vector3(9, 4.5, -6), triggerId: 'cannon-green' },
|
||||
{ color: 'blue' as const, position: new THREE.Vector3(-9, 2.0, -30), triggerId: 'cannon-blue' },
|
||||
]
|
||||
for (const spec of cannonSpecs) {
|
||||
world.addSystem(new PaintCannon({
|
||||
world,
|
||||
position: spec.position,
|
||||
color: spec.color,
|
||||
target: blob.mesh,
|
||||
paint: skin,
|
||||
targetRadius: blob.radius,
|
||||
triggerId: spec.triggerId,
|
||||
interval: 2.2,
|
||||
muzzleSpeed: 26, // default 16 caps ballistic range at ~18u — course distances need ~26
|
||||
splatRadius: 0.45,
|
||||
}))
|
||||
}
|
||||
|
||||
// ---- machine chain (Lane C) on the course's machine area ----
|
||||
// The pressure plate needs MORE than a clean blob's mass: paint = weight =
|
||||
// machine access (GDD §5.2/§5.4). Clean blobs roll over it; painted trip it.
|
||||
const baseMass = blob.body.mass()
|
||||
createPressurePlate(world, {
|
||||
id: 'plate-1', position: [0, 0.24, -40],
|
||||
massThreshold: baseMass * 1.25, emits: 'boot-1', size: [4, 3],
|
||||
})
|
||||
createSpringBoot(world, {
|
||||
id: 'boot-1', position: [0, 0.2, -42.5], // 0.5s telegraph ≈ 3u of roll past the plate
|
||||
impulse: baseMass * 18, // sized for a plate-tripping (painted, heavy) blob to reach the bucket
|
||||
direction: [0, 1, -0.55],
|
||||
onSignal: 'boot-1', strikeSize: [2.4, 1.4, 3],
|
||||
})
|
||||
// Bucket hangs over the conveyor: the belt delivers you underneath at 4 u/s,
|
||||
// the catch trigger arms the teeter, and the pour lands at the spout point
|
||||
// (bucket.x + 1.4) — which is the belt's centerline. Linger and get soaked.
|
||||
createBucketDump(world, {
|
||||
// Timed empirically: catch trips at z≈-46, teeter ≈0.8s, belt 3 u/s →
|
||||
// blob reaches the spout (bucket.z) as the pour lands. Miss was 1.73u
|
||||
// with bucket at -50 / belt 4.
|
||||
id: 'bucket-1', position: [4.6, 6, -46.5],
|
||||
color: 'purple', radius: 0.8,
|
||||
// Shallow catch just upstream of the spout: observed teeter drift is only
|
||||
// 0.4-2u depending on speed, so trip near the spout, not a belt-length away.
|
||||
trigger: { size: [3, 2, 1.6], offset: [1.4, -5.2, 0.75] },
|
||||
})
|
||||
createBubbleArch(world, {
|
||||
id: 'arch-1', position: [-4.5, 0.12, -56], fraction: 0.6, // far-side lane: cleansing is a detour you choose
|
||||
})
|
||||
createConveyorBelt(world, {
|
||||
id: 'belt-1', position: [6, 0.25, -50],
|
||||
size: [3, 0.5, 10], velocity: [0, 0, -3],
|
||||
})
|
||||
|
||||
world.addSystem(new BuffSystem(world, { mesh: blob.mesh, modifiers: blob.modifiers, paint: skin }))
|
||||
installPaint(world, { mesh: blob.mesh, paint: skin })
|
||||
|
||||
const hud = new PaintHUD()
|
||||
world.onFrame(() => {
|
||||
world.camera.lookAt(ball.position)
|
||||
hud.update(skin.coverage(), blob.modifiers) // coverage() self-caches at 250ms
|
||||
})
|
||||
|
||||
// ---- fall respawn + debug ----
|
||||
const respawn = () => {
|
||||
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)
|
||||
world.events.emit('paint:request-cleanse', { fraction: 1 })
|
||||
}
|
||||
world.onFrame(() => {
|
||||
if (blob.body.translation().y < -25) respawn()
|
||||
})
|
||||
addEventListener('keydown', (e) => {
|
||||
if (e.code === 'KeyR') respawn()
|
||||
if (e.code === 'KeyC') world.events.emit('paint:request-cleanse', { fraction: 0.5 })
|
||||
})
|
||||
|
||||
// debug handle (dev builds are the only builds right now)
|
||||
;(window as unknown as { BLOBBO: unknown }).BLOBBO = { world, blob, skin, course }
|
||||
|
||||
return { course, blob }
|
||||
}
|
||||
|
||||
35
src/machine/index.ts
Normal file
@ -0,0 +1,35 @@
|
||||
/**
|
||||
* Lane C — Machine toolkit public surface.
|
||||
* Contraptions chain via `world.events` ('machine:signal') and talk to the paint
|
||||
* lane via 'paint:request-splat' / 'paint:request-cleanse' only.
|
||||
*/
|
||||
export { telegraph, isTelegraphing } from './telegraph'
|
||||
export type { TelegraphOptions } from './telegraph'
|
||||
|
||||
export {
|
||||
SURFACES,
|
||||
SurfaceRegistry,
|
||||
applySurface,
|
||||
} from './surfaces'
|
||||
export type { SurfaceName, SurfaceMaterial } from './surfaces'
|
||||
|
||||
export {
|
||||
createPressurePlate,
|
||||
createSpringBoot,
|
||||
createSeeSaw,
|
||||
createBucketDump,
|
||||
createConveyorBelt,
|
||||
createBubbleArch,
|
||||
createFan,
|
||||
} from './parts'
|
||||
export type {
|
||||
Vec3,
|
||||
MachinePart,
|
||||
PressurePlateConfig,
|
||||
SpringBootConfig,
|
||||
SeeSawConfig,
|
||||
BucketDumpConfig,
|
||||
ConveyorBeltConfig,
|
||||
BubbleArchConfig,
|
||||
FanConfig,
|
||||
} from './parts'
|
||||
730
src/machine/parts.ts
Normal file
@ -0,0 +1,730 @@
|
||||
/**
|
||||
* PARTS — the Rube-Goldberg contraption toolkit (GDD §5.4).
|
||||
*
|
||||
* Every part is data-placeable: a factory takes `(world, config)` where config is
|
||||
* a plain `{ id, position, ... , onSignal?, emits? }` object, wires up its own
|
||||
* physics + visuals, and chains to other parts ONLY through `world.events`:
|
||||
* - a part with `emits` fires `machine:signal { id: emits }` when it activates
|
||||
* - a part with `onSignal` listens for `machine:signal` and acts when the id matches
|
||||
* There are NO hardcoded references between part instances — the wiring is the
|
||||
* config strings, so the whole course is pure data.
|
||||
*
|
||||
* PILLAR: every part that applies an impulse or dumps paint plays a mandatory
|
||||
* ~0.5s telegraph windup first (see telegraph.ts). SpringBoot and BucketDump do.
|
||||
* PressurePlate is an input sensor (no force). ConveyorBelt / Fan / BubbleArch are
|
||||
* ambient continuous fields whose constant motion (sliding belt, spinning blades,
|
||||
* bubbling arch) is itself the readable, always-on telegraph.
|
||||
*
|
||||
* Communicates with paint (lane B) only via events: `paint:request-splat`,
|
||||
* `paint:request-cleanse`. Never imports src/paint or src/blob.
|
||||
*/
|
||||
import * as THREE from 'three'
|
||||
import type RAPIER from '@dimforge/rapier3d-compat'
|
||||
import type { World, PaintColor } from '../contracts'
|
||||
import { PALETTE } from '../contracts'
|
||||
import { telegraph, isTelegraphing } from './telegraph'
|
||||
|
||||
export type Vec3 = [number, number, number]
|
||||
|
||||
export interface MachinePart {
|
||||
id: string
|
||||
group: THREE.Group
|
||||
}
|
||||
|
||||
const IDENTITY_ROT = { x: 0, y: 0, z: 0, w: 1 }
|
||||
|
||||
const v = (p: Vec3) => ({ x: p[0], y: p[1], z: p[2] })
|
||||
const asVec3 = (p: Vec3) => new THREE.Vector3(p[0], p[1], p[2])
|
||||
|
||||
/** All *dynamic* rigid bodies whose colliders overlap an axis-aligned box. */
|
||||
function dynamicBodiesInBox(
|
||||
world: World,
|
||||
center: { x: number; y: number; z: number },
|
||||
half: { x: number; y: number; z: number },
|
||||
): Map<number, RAPIER.RigidBody> {
|
||||
const shape = new world.rapier.Cuboid(half.x, half.y, half.z)
|
||||
const found = new Map<number, RAPIER.RigidBody>()
|
||||
world.physics.intersectionsWithShape(center, IDENTITY_ROT, shape, (col) => {
|
||||
const b = col.parent()
|
||||
if (b && b.isDynamic()) found.set(b.handle, b)
|
||||
return true
|
||||
})
|
||||
return found
|
||||
}
|
||||
|
||||
/** Axis-aligned box covering a beam of length `range` from `origin` along unit `dir`. */
|
||||
function beamBox(origin: THREE.Vector3, dir: THREE.Vector3, range: number, cross: number) {
|
||||
const center = {
|
||||
x: origin.x + dir.x * range * 0.5,
|
||||
y: origin.y + dir.y * range * 0.5,
|
||||
z: origin.z + dir.z * range * 0.5,
|
||||
}
|
||||
const half = {
|
||||
x: Math.max(Math.abs(dir.x) * range * 0.5, cross),
|
||||
y: Math.max(Math.abs(dir.y) * range * 0.5, cross),
|
||||
z: Math.max(Math.abs(dir.z) * range * 0.5, cross),
|
||||
}
|
||||
return { center, half }
|
||||
}
|
||||
|
||||
const lerp = (a: number, b: number, t: number) => a + (b - a) * t
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// PressurePlate — depresses under real weight; trips only past massThreshold.
|
||||
// ---------------------------------------------------------------------------
|
||||
export interface PressurePlateConfig {
|
||||
id: string
|
||||
position: Vec3
|
||||
/** Trip only when total resting body mass ≥ this (uses body.mass()). */
|
||||
massThreshold: number
|
||||
/** Signal emitted on the untripped→tripped edge. */
|
||||
emits?: string
|
||||
/** [width, length] of the plate. Default [3, 3]. */
|
||||
size?: [number, number]
|
||||
}
|
||||
|
||||
export function createPressurePlate(world: World, cfg: PressurePlateConfig): MachinePart {
|
||||
const { physics, rapier, scene } = world
|
||||
const [w, l] = cfg.size ?? [3, 3]
|
||||
const capH = 0.28
|
||||
const pos = asVec3(cfg.position)
|
||||
|
||||
const group = new THREE.Group()
|
||||
group.position.copy(pos)
|
||||
scene.add(group)
|
||||
|
||||
// static base frame (also catches the body so weight registers)
|
||||
const base = new THREE.Mesh(
|
||||
new THREE.BoxGeometry(w + 0.6, 0.4, l + 0.6),
|
||||
new THREE.MeshStandardMaterial({ color: '#4a4a52', roughness: 0.8 }),
|
||||
)
|
||||
base.position.y = -0.2
|
||||
base.receiveShadow = true
|
||||
group.add(base)
|
||||
|
||||
// the moving cap the blob stands on
|
||||
const capMat = new THREE.MeshStandardMaterial({ color: '#8a8a98', roughness: 0.5 })
|
||||
const cap = new THREE.Mesh(new THREE.BoxGeometry(w, capH, l), capMat)
|
||||
cap.position.y = capH * 0.5 + 0.02
|
||||
cap.castShadow = true
|
||||
cap.receiveShadow = true
|
||||
group.add(cap)
|
||||
|
||||
// physics: fixed platform the blob actually rests on
|
||||
const body = physics.createRigidBody(
|
||||
rapier.RigidBodyDesc.fixed().setTranslation(pos.x, pos.y + cap.position.y, pos.z),
|
||||
)
|
||||
physics.createCollider(
|
||||
rapier.ColliderDesc.cuboid(w * 0.5, capH * 0.5, l * 0.5).setFriction(1.0),
|
||||
body,
|
||||
)
|
||||
|
||||
const detCenter = { x: pos.x, y: pos.y + cap.position.y + 0.5, z: pos.z }
|
||||
const detHalf = { x: w * 0.5, y: 0.55, z: l * 0.5 }
|
||||
|
||||
let tripped = false
|
||||
const restY = cap.position.y
|
||||
const pressedY = restY - 0.16
|
||||
let targetY = restY
|
||||
|
||||
world.addSystem({
|
||||
update() {
|
||||
const bodies = dynamicBodiesInBox(world, detCenter, detHalf)
|
||||
let load = 0
|
||||
for (const b of bodies.values()) load += b.mass()
|
||||
|
||||
if (!tripped && load >= cfg.massThreshold) {
|
||||
tripped = true
|
||||
targetY = pressedY
|
||||
if (cfg.emits) world.events.emit('machine:signal', { id: cfg.emits })
|
||||
} else if (tripped && load < cfg.massThreshold * 0.5) {
|
||||
// hysteresis re-arm so a settled-then-departed weight can trip again
|
||||
tripped = false
|
||||
targetY = restY
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
world.onFrame((dt) => {
|
||||
cap.position.y = lerp(cap.position.y, targetY, Math.min(1, dt * 12))
|
||||
capMat.emissive.setHex(tripped ? 0x224400 : 0x000000)
|
||||
capMat.emissiveIntensity = tripped ? 0.6 : 0
|
||||
})
|
||||
|
||||
return { id: cfg.id, group }
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// SpringBoot — on signal: TELEGRAPH → kick everything in its strike volume.
|
||||
// ---------------------------------------------------------------------------
|
||||
export interface SpringBootConfig {
|
||||
id: string
|
||||
position: Vec3
|
||||
/** Impulse magnitude applied to each struck body. */
|
||||
impulse: number
|
||||
/** Launch direction (auto-normalised). Default straight up. */
|
||||
direction?: Vec3
|
||||
/** Signal that triggers the kick. */
|
||||
onSignal: string
|
||||
/** Optional signal emitted right after the kick fires (for further chaining). */
|
||||
emits?: string
|
||||
/** Strike volume half-extents above the boot. Default [1, 0.8, 1]. */
|
||||
strikeSize?: Vec3
|
||||
/** Build its own landing pad collider (true) or kick whatever rests on an
|
||||
* existing surface, e.g. when mounted on a PressurePlate (false). Default true. */
|
||||
pad?: boolean
|
||||
}
|
||||
|
||||
export function createSpringBoot(world: World, cfg: SpringBootConfig): MachinePart {
|
||||
const { physics, rapier, scene } = world
|
||||
const pos = asVec3(cfg.position)
|
||||
const dir = (cfg.direction ? asVec3(cfg.direction) : new THREE.Vector3(0, 1, 0)).normalize()
|
||||
const strike = cfg.strikeSize ?? [1, 0.8, 1]
|
||||
|
||||
const group = new THREE.Group()
|
||||
group.position.copy(pos)
|
||||
scene.add(group)
|
||||
|
||||
// landing pad the blob sits on before being kicked
|
||||
const padMat = new THREE.MeshStandardMaterial({ color: '#c0392b', roughness: 0.4, metalness: 0.1 })
|
||||
const pad = new THREE.Mesh(new THREE.CylinderGeometry(1.1, 1.1, 0.3, 20), padMat)
|
||||
pad.position.y = 0.15
|
||||
pad.castShadow = true
|
||||
pad.receiveShadow = true
|
||||
group.add(pad)
|
||||
|
||||
// coil spring under the pad (telegraph target scales/shakes the whole group)
|
||||
const coilMat = new THREE.MeshStandardMaterial({ color: '#7f8c8d', metalness: 0.6, roughness: 0.3 })
|
||||
for (let i = 0; i < 3; i++) {
|
||||
const ring = new THREE.Mesh(new THREE.TorusGeometry(0.7, 0.09, 8, 20), coilMat)
|
||||
ring.rotation.x = Math.PI / 2
|
||||
ring.position.y = -0.15 - i * 0.22
|
||||
group.add(ring)
|
||||
}
|
||||
|
||||
// physics pad so the ball can rest here between signal and kick
|
||||
if (cfg.pad !== false) {
|
||||
const body = physics.createRigidBody(
|
||||
rapier.RigidBodyDesc.fixed().setTranslation(pos.x, pos.y + 0.15, pos.z),
|
||||
)
|
||||
physics.createCollider(
|
||||
rapier.ColliderDesc.cylinder(0.15, 1.1).setFriction(0.9), body,
|
||||
)
|
||||
}
|
||||
|
||||
const strikeCenter = { x: pos.x, y: pos.y + 0.3 + strike[1], z: pos.z }
|
||||
const strikeHalf = { x: strike[0], y: strike[1], z: strike[2] }
|
||||
|
||||
world.events.on('machine:signal', ({ id }: { id: string }) => {
|
||||
if (id !== cfg.onSignal || isTelegraphing(world, group)) return
|
||||
telegraph(world, group, {
|
||||
duration: 0.5,
|
||||
flashColor: '#ff5533',
|
||||
scalePulse: 0.28,
|
||||
shake: 0.06,
|
||||
onFire: () => {
|
||||
const bodies = dynamicBodiesInBox(world, strikeCenter, strikeHalf)
|
||||
for (const b of bodies.values()) {
|
||||
b.applyImpulse({ x: dir.x * cfg.impulse, y: dir.y * cfg.impulse, z: dir.z * cfg.impulse }, true)
|
||||
}
|
||||
if (cfg.emits) world.events.emit('machine:signal', { id: cfg.emits })
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
return { id: cfg.id, group }
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// SeeSaw — plank on a real Rapier revolute joint. Pure physics: the heavier
|
||||
// side wins. No telegraph (it applies no force of its own; it just tips).
|
||||
// ---------------------------------------------------------------------------
|
||||
export interface SeeSawConfig {
|
||||
id: string
|
||||
position: Vec3
|
||||
/** Plank [length(x), thickness(y), width(z)]. Default [8, 0.4, 2.4]. */
|
||||
plankSize?: Vec3
|
||||
/** Max tilt each way, radians. Default 0.5 (~29°). */
|
||||
maxTilt?: number
|
||||
/** Initial tilt (rad, about Z). Negative = +X end starts low. Default 0 (level). */
|
||||
restTilt?: number
|
||||
}
|
||||
|
||||
export function createSeeSaw(world: World, cfg: SeeSawConfig): MachinePart {
|
||||
const { physics, rapier, scene } = world
|
||||
const pos = asVec3(cfg.position)
|
||||
const [px, py, pz] = cfg.plankSize ?? [8, 0.4, 2.4]
|
||||
const maxTilt = cfg.maxTilt ?? 0.5
|
||||
|
||||
const group = new THREE.Group()
|
||||
scene.add(group)
|
||||
|
||||
// visual fulcrum wedge (fixed)
|
||||
const fulcrum = new THREE.Mesh(
|
||||
new THREE.CylinderGeometry(0.05, 1.0, 1.2, 3),
|
||||
new THREE.MeshStandardMaterial({ color: '#5d4037', roughness: 0.9 }),
|
||||
)
|
||||
fulcrum.position.set(pos.x, pos.y - 0.6, pos.z)
|
||||
fulcrum.castShadow = true
|
||||
group.add(fulcrum)
|
||||
|
||||
// fixed anchor body at the pivot
|
||||
const anchor = physics.createRigidBody(
|
||||
rapier.RigidBodyDesc.fixed().setTranslation(pos.x, pos.y, pos.z),
|
||||
)
|
||||
|
||||
// dynamic plank (optionally pre-tilted so delivery direction is deterministic)
|
||||
const restTilt = cfg.restTilt ?? 0
|
||||
const q0 = new THREE.Quaternion().setFromAxisAngle(new THREE.Vector3(0, 0, 1), restTilt)
|
||||
const plankBody = physics.createRigidBody(
|
||||
rapier.RigidBodyDesc.dynamic()
|
||||
.setTranslation(pos.x, pos.y, pos.z)
|
||||
.setRotation({ x: q0.x, y: q0.y, z: q0.z, w: q0.w })
|
||||
.setAngularDamping(0.6),
|
||||
)
|
||||
physics.createCollider(
|
||||
rapier.ColliderDesc.cuboid(px * 0.5, py * 0.5, pz * 0.5)
|
||||
.setDensity(0.4)
|
||||
.setFriction(1.0),
|
||||
plankBody,
|
||||
)
|
||||
|
||||
// revolute joint about Z → the plank tips in the X/Y plane (blob rolls along X)
|
||||
const jd = rapier.JointData.revolute(
|
||||
{ x: 0, y: 0, z: 0 }, { x: 0, y: 0, z: 0 }, { x: 0, y: 0, z: 1 },
|
||||
)
|
||||
jd.limitsEnabled = true
|
||||
jd.limits = [-maxTilt, maxTilt]
|
||||
physics.createImpulseJoint(jd, anchor, plankBody, true)
|
||||
|
||||
const plank = new THREE.Mesh(
|
||||
new THREE.BoxGeometry(px, py, pz),
|
||||
new THREE.MeshStandardMaterial({ color: '#a1887f', roughness: 0.7 }),
|
||||
)
|
||||
plank.castShadow = true
|
||||
plank.receiveShadow = true
|
||||
group.add(plank)
|
||||
|
||||
world.onFrame(() => {
|
||||
const t = plankBody.translation()
|
||||
const r = plankBody.rotation()
|
||||
plank.position.set(t.x, t.y, t.z)
|
||||
plank.quaternion.set(r.x, r.y, r.z, r.w)
|
||||
})
|
||||
|
||||
return { id: cfg.id, group }
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// BucketDump — on trigger (body in its catch zone) or signal: TELEGRAPH (teeter)
|
||||
// → tips over and pours, emitting `paint:request-splat` under the spout.
|
||||
// ---------------------------------------------------------------------------
|
||||
export interface BucketDumpConfig {
|
||||
id: string
|
||||
position: Vec3
|
||||
color: PaintColor
|
||||
/** Splat radius requested from the paint lane. */
|
||||
radius: number
|
||||
/** Signal that triggers a dump (optional; a catch trigger works too). */
|
||||
onSignal?: string
|
||||
/** Optional signal emitted after the dump (further chaining). */
|
||||
emits?: string
|
||||
/** Self-contained catch zone: a body entering it triggers the dump. */
|
||||
trigger?: { size: Vec3; offset?: Vec3 }
|
||||
}
|
||||
|
||||
export function createBucketDump(world: World, cfg: BucketDumpConfig): MachinePart {
|
||||
const { physics, scene } = world
|
||||
const pos = asVec3(cfg.position)
|
||||
const paintHex = PALETTE[cfg.color]
|
||||
|
||||
// pivot group tips about its edge to pour
|
||||
const group = new THREE.Group()
|
||||
group.position.copy(pos)
|
||||
scene.add(group)
|
||||
|
||||
const shell = new THREE.Mesh(
|
||||
new THREE.CylinderGeometry(1.3, 1.0, 2.0, 20, 1, true),
|
||||
new THREE.MeshStandardMaterial({ color: '#455a64', metalness: 0.5, roughness: 0.4, side: THREE.DoubleSide }),
|
||||
)
|
||||
shell.castShadow = true
|
||||
group.add(shell)
|
||||
|
||||
// paint fill (colour reads even before it pours)
|
||||
const fillMat = new THREE.MeshStandardMaterial({ color: paintHex, roughness: 0.3 })
|
||||
const fill = new THREE.Mesh(new THREE.CylinderGeometry(1.15, 0.9, 1.4, 20), fillMat)
|
||||
fill.position.y = -0.15
|
||||
group.add(fill)
|
||||
|
||||
let dumping = false
|
||||
let tip = 0 // current tip angle
|
||||
let tipTarget = 0
|
||||
let splatted = false
|
||||
|
||||
const doDump = () => {
|
||||
if (dumping) return
|
||||
dumping = true
|
||||
splatted = false
|
||||
tipTarget = Math.PI * 0.85
|
||||
}
|
||||
|
||||
const startDump = () => {
|
||||
if (dumping || isTelegraphing(world, group)) return
|
||||
telegraph(world, group, {
|
||||
duration: 0.5,
|
||||
flashColor: paintHex,
|
||||
scalePulse: 0.12,
|
||||
shake: 0.1, // the teeter
|
||||
onFire: doDump,
|
||||
})
|
||||
}
|
||||
|
||||
if (cfg.onSignal) {
|
||||
world.events.on('machine:signal', ({ id }: { id: string }) => {
|
||||
if (id === cfg.onSignal) startDump()
|
||||
})
|
||||
}
|
||||
|
||||
// optional self-contained catch trigger
|
||||
let triggerCenter: { x: number; y: number; z: number } | null = null
|
||||
let triggerHalf: { x: number; y: number; z: number } | null = null
|
||||
let armed = true
|
||||
if (cfg.trigger) {
|
||||
const off = cfg.trigger.offset ?? [0, -1.5, 0]
|
||||
triggerCenter = { x: pos.x + off[0], y: pos.y + off[1], z: pos.z + off[2] }
|
||||
triggerHalf = { x: cfg.trigger.size[0] * 0.5, y: cfg.trigger.size[1] * 0.5, z: cfg.trigger.size[2] * 0.5 }
|
||||
}
|
||||
|
||||
const spawnSlosh = () => {
|
||||
const blob = new THREE.Mesh(
|
||||
new THREE.SphereGeometry(cfg.radius * 0.5, 12, 10),
|
||||
new THREE.MeshStandardMaterial({ color: paintHex, transparent: true, opacity: 0.9, roughness: 0.2 }),
|
||||
)
|
||||
const spoutWorld = new THREE.Vector3(pos.x + 1.1, pos.y - 0.2, pos.z)
|
||||
blob.position.copy(spoutWorld)
|
||||
scene.add(blob)
|
||||
let vy = -1
|
||||
let life = 0
|
||||
const step = (dt: number) => {
|
||||
life += dt
|
||||
vy -= 14 * dt
|
||||
blob.position.y += vy * dt
|
||||
blob.scale.setScalar(1 + life * 1.5)
|
||||
;(blob.material as THREE.MeshStandardMaterial).opacity = Math.max(0, 0.9 - life * 0.8)
|
||||
if (life > 1.2) {
|
||||
scene.remove(blob)
|
||||
blob.geometry.dispose()
|
||||
;(blob.material as THREE.Material).dispose()
|
||||
const i = sloshers.indexOf(step)
|
||||
if (i >= 0) sloshers.splice(i, 1)
|
||||
}
|
||||
}
|
||||
sloshers.push(step)
|
||||
}
|
||||
const sloshers: Array<(dt: number) => void> = []
|
||||
|
||||
world.addSystem({
|
||||
update() {
|
||||
if (!triggerCenter || !triggerHalf) return
|
||||
const bodies = dynamicBodiesInBox(world, triggerCenter, triggerHalf)
|
||||
const occupied = bodies.size > 0
|
||||
if (occupied && armed && !dumping) {
|
||||
armed = false
|
||||
startDump()
|
||||
} else if (!occupied && !dumping) {
|
||||
armed = true
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
// Visual-only sloshers stay on the render frame…
|
||||
world.onFrame((dt) => {
|
||||
for (const s of [...sloshers]) s(dt)
|
||||
})
|
||||
// …but the tip drives the SPLAT — gameplay, so fixed-step (onFrame stalls in
|
||||
// hidden tabs and would silence every dump; found at integration).
|
||||
world.addSystem({
|
||||
update(dt) {
|
||||
tip = lerp(tip, tipTarget, Math.min(1, dt * 4))
|
||||
group.rotation.z = -tip
|
||||
|
||||
if (dumping && !splatted && tip > Math.PI * 0.4) {
|
||||
splatted = true
|
||||
const groundPoint = new THREE.Vector3(pos.x + 1.4, 0, pos.z)
|
||||
const ray = new world.rapier.Ray(
|
||||
{ x: groundPoint.x, y: pos.y, z: groundPoint.z }, { x: 0, y: -1, z: 0 })
|
||||
const hit = world.physics.castRay(ray, 20, true)
|
||||
if (hit) groundPoint.y = pos.y - hit.timeOfImpact
|
||||
spawnSlosh()
|
||||
// ---- paint-lane wire (lane B renders; here we log the request) ----
|
||||
world.events.emit('paint:request-splat', {
|
||||
point: groundPoint, color: cfg.color, radius: cfg.radius,
|
||||
})
|
||||
console.log(
|
||||
`[machine] BucketDump "${cfg.id}" → paint:request-splat`,
|
||||
{ color: cfg.color, radius: cfg.radius, point: groundPoint.toArray().map((n) => +n.toFixed(2)) },
|
||||
)
|
||||
if (cfg.emits) world.events.emit('machine:signal', { id: cfg.emits })
|
||||
}
|
||||
|
||||
// right the bucket back once poured, then re-arm
|
||||
if (dumping && tip > Math.PI * 0.8) tipTarget = 0
|
||||
if (dumping && tipTarget === 0 && tip < 0.05) dumping = false
|
||||
},
|
||||
})
|
||||
|
||||
return { id: cfg.id, group }
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// ConveyorBelt — ambient surface that carries bodies at a target velocity.
|
||||
// (Its constantly-scrolling stripes are the always-on telegraph.)
|
||||
// ---------------------------------------------------------------------------
|
||||
export interface ConveyorBeltConfig {
|
||||
id: string
|
||||
position: Vec3
|
||||
/** [length(x), thickness(y), width(z)]. Default [10, 0.5, 3]. */
|
||||
size?: Vec3
|
||||
/** Target surface velocity carried to bodies on the belt. */
|
||||
velocity: Vec3
|
||||
}
|
||||
|
||||
export function createConveyorBelt(world: World, cfg: ConveyorBeltConfig): MachinePart {
|
||||
const { physics, rapier, scene } = world
|
||||
const pos = asVec3(cfg.position)
|
||||
const [sx, sy, sz] = cfg.size ?? [10, 0.5, 3]
|
||||
const vel = asVec3(cfg.velocity)
|
||||
|
||||
const group = new THREE.Group()
|
||||
group.position.copy(pos)
|
||||
scene.add(group)
|
||||
|
||||
const belt = new THREE.Mesh(
|
||||
new THREE.BoxGeometry(sx, sy, sz),
|
||||
new THREE.MeshStandardMaterial({ color: '#2c3e50', roughness: 0.6 }),
|
||||
)
|
||||
belt.receiveShadow = true
|
||||
group.add(belt)
|
||||
|
||||
// direction chevrons that scroll to advertise travel direction
|
||||
const dir = vel.clone().normalize()
|
||||
const chevronMat = new THREE.MeshStandardMaterial({ color: '#f1c40f', emissive: '#4a3b00', emissiveIntensity: 0.4 })
|
||||
const chevrons: THREE.Mesh[] = []
|
||||
const alongX = Math.abs(dir.x) >= Math.abs(dir.z)
|
||||
const span = alongX ? sx : sz
|
||||
const n = 6
|
||||
for (let i = 0; i < n; i++) {
|
||||
const c = new THREE.Mesh(new THREE.BoxGeometry(alongX ? 0.5 : sz * 0.6, 0.06, alongX ? sz * 0.6 : 0.5), chevronMat)
|
||||
c.position.y = sy * 0.5 + 0.03
|
||||
group.add(c)
|
||||
chevrons.push(c)
|
||||
}
|
||||
const placeChevron = (c: THREE.Mesh, offset: number) => {
|
||||
const t = ((offset % span) + span) % span - span * 0.5
|
||||
if (alongX) c.position.x = t
|
||||
else c.position.z = t
|
||||
}
|
||||
|
||||
const detCenter = { x: pos.x, y: pos.y + sy * 0.5 + 0.4, z: pos.z }
|
||||
const detHalf = { x: sx * 0.5, y: 0.5, z: sz * 0.5 }
|
||||
|
||||
physics.createCollider(
|
||||
rapier.ColliderDesc.cuboid(sx * 0.5, sy * 0.5, sz * 0.5)
|
||||
.setTranslation(pos.x, pos.y, pos.z)
|
||||
.setFriction(1.2),
|
||||
)
|
||||
|
||||
world.addSystem({
|
||||
update() {
|
||||
const bodies = dynamicBodiesInBox(world, detCenter, detHalf)
|
||||
for (const b of bodies.values()) {
|
||||
const lv = b.linvel()
|
||||
b.setLinvel({ x: lerp(lv.x, vel.x, 0.18), y: lv.y, z: lerp(lv.z, vel.z, 0.18) }, true)
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
let scroll = 0
|
||||
const speed = alongX ? vel.x : vel.z
|
||||
world.onFrame((dt) => {
|
||||
scroll += speed * dt
|
||||
chevrons.forEach((c, i) => placeChevron(c, scroll + (i / n) * span))
|
||||
})
|
||||
|
||||
return { id: cfg.id, group }
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// BubbleArch — walk-through cleanse zone. On blob entry: `paint:request-cleanse`
|
||||
// + a bubble burst. Ambient bubbling is the telegraph; you opt in by entering.
|
||||
// ---------------------------------------------------------------------------
|
||||
export interface BubbleArchConfig {
|
||||
id: string
|
||||
position: Vec3
|
||||
/** Cleanse fraction requested from the paint lane (0..1). */
|
||||
fraction: number
|
||||
/** Arch [width(x), height(y), depth(z)]. Default [4, 4, 2]. */
|
||||
size?: Vec3
|
||||
}
|
||||
|
||||
export function createBubbleArch(world: World, cfg: BubbleArchConfig): MachinePart {
|
||||
const { scene } = world
|
||||
const pos = asVec3(cfg.position)
|
||||
const [w, h, d] = cfg.size ?? [4, 4, 2]
|
||||
|
||||
const group = new THREE.Group()
|
||||
group.position.copy(pos)
|
||||
scene.add(group)
|
||||
|
||||
const postMat = new THREE.MeshStandardMaterial({ color: '#00bcd4', roughness: 0.3, metalness: 0.2 })
|
||||
for (const sx of [-1, 1]) {
|
||||
const post = new THREE.Mesh(new THREE.CylinderGeometry(0.2, 0.2, h, 12), postMat)
|
||||
post.position.set((sx * w) / 2, h / 2, 0)
|
||||
post.castShadow = true
|
||||
group.add(post)
|
||||
}
|
||||
const bar = new THREE.Mesh(new THREE.BoxGeometry(w + 0.4, 0.4, 0.4), postMat)
|
||||
bar.position.y = h
|
||||
group.add(bar)
|
||||
|
||||
// a few permanent decorative bubbles clinging to the arch (ambient telegraph)
|
||||
const bubbleMat = new THREE.MeshStandardMaterial({ color: '#e0f7ff', transparent: true, opacity: 0.5, roughness: 0.05 })
|
||||
for (let i = 0; i < 10; i++) {
|
||||
const b = new THREE.Mesh(new THREE.SphereGeometry(0.15 + Math.random() * 0.2, 10, 8), bubbleMat)
|
||||
b.position.set((Math.random() - 0.5) * w, Math.random() * h, (Math.random() - 0.5) * d)
|
||||
group.add(b)
|
||||
}
|
||||
|
||||
const detCenter = { x: pos.x, y: pos.y + h * 0.5, z: pos.z }
|
||||
const detHalf = { x: w * 0.5, y: h * 0.5, z: d * 0.5 }
|
||||
|
||||
const inside = new Set<number>()
|
||||
const bursts: Array<(dt: number) => void> = []
|
||||
|
||||
const burst = () => {
|
||||
const spheres: THREE.Mesh[] = []
|
||||
for (let i = 0; i < 14; i++) {
|
||||
const s = new THREE.Mesh(
|
||||
new THREE.SphereGeometry(0.12 + Math.random() * 0.18, 10, 8),
|
||||
new THREE.MeshStandardMaterial({ color: '#eafaff', transparent: true, opacity: 0.85, roughness: 0.05 }),
|
||||
)
|
||||
s.position.set(pos.x + (Math.random() - 0.5) * w, pos.y + 0.5, pos.z + (Math.random() - 0.5) * d)
|
||||
scene.add(s)
|
||||
spheres.push(s)
|
||||
}
|
||||
const vels = spheres.map(() => 1 + Math.random() * 1.5)
|
||||
let life = 0
|
||||
const step = (dt: number) => {
|
||||
life += dt
|
||||
spheres.forEach((s, i) => {
|
||||
s.position.y += vels[i] * dt
|
||||
;(s.material as THREE.MeshStandardMaterial).opacity = Math.max(0, 0.85 - life * 0.7)
|
||||
})
|
||||
if (life > 1.2) {
|
||||
for (const s of spheres) {
|
||||
scene.remove(s)
|
||||
s.geometry.dispose()
|
||||
;(s.material as THREE.Material).dispose()
|
||||
}
|
||||
const idx = bursts.indexOf(step)
|
||||
if (idx >= 0) bursts.splice(idx, 1)
|
||||
}
|
||||
}
|
||||
bursts.push(step)
|
||||
}
|
||||
|
||||
world.addSystem({
|
||||
update() {
|
||||
const bodies = dynamicBodiesInBox(world, detCenter, detHalf)
|
||||
const now = new Set(bodies.keys())
|
||||
for (const h of now) {
|
||||
if (!inside.has(h)) {
|
||||
// rising edge — a blob just entered the arch
|
||||
world.events.emit('paint:request-cleanse', { fraction: cfg.fraction })
|
||||
console.log(`[machine] BubbleArch "${cfg.id}" → paint:request-cleanse`, { fraction: cfg.fraction })
|
||||
burst()
|
||||
}
|
||||
}
|
||||
inside.clear()
|
||||
for (const h of now) inside.add(h)
|
||||
},
|
||||
})
|
||||
|
||||
world.onFrame((dt) => {
|
||||
for (const b of [...bursts]) b(dt)
|
||||
})
|
||||
|
||||
return { id: cfg.id, group }
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Fan — constant directional force volume. Same force on every body, so light
|
||||
// blobs get blown hard and heavy blobs barely budge (mass does the work).
|
||||
// ---------------------------------------------------------------------------
|
||||
export interface FanConfig {
|
||||
id: string
|
||||
position: Vec3
|
||||
/** Force magnitude applied to each body in the beam, per step. */
|
||||
force: number
|
||||
/** Blow direction (auto-normalised). Default +x. */
|
||||
direction?: Vec3
|
||||
/** Beam length. Default 10. */
|
||||
range?: number
|
||||
/** Beam half-cross-section. Default 1.6. */
|
||||
spread?: number
|
||||
}
|
||||
|
||||
export function createFan(world: World, cfg: FanConfig): MachinePart {
|
||||
const { scene } = world
|
||||
const pos = asVec3(cfg.position)
|
||||
const dir = (cfg.direction ? asVec3(cfg.direction) : new THREE.Vector3(1, 0, 0)).normalize()
|
||||
const range = cfg.range ?? 10
|
||||
const spread = cfg.spread ?? 1.6
|
||||
|
||||
const group = new THREE.Group()
|
||||
group.position.copy(pos)
|
||||
// orient housing so blades face the blow direction
|
||||
group.quaternion.setFromUnitVectors(new THREE.Vector3(0, 0, 1), dir)
|
||||
scene.add(group)
|
||||
|
||||
const housing = new THREE.Mesh(
|
||||
new THREE.CylinderGeometry(1.5, 1.5, 0.6, 24),
|
||||
new THREE.MeshStandardMaterial({ color: '#34495e', metalness: 0.4, roughness: 0.5 }),
|
||||
)
|
||||
housing.rotation.x = Math.PI / 2
|
||||
housing.castShadow = true
|
||||
group.add(housing)
|
||||
|
||||
const blades = new THREE.Group()
|
||||
const bladeMat = new THREE.MeshStandardMaterial({ color: '#95a5a6', metalness: 0.6, roughness: 0.3 })
|
||||
for (let i = 0; i < 4; i++) {
|
||||
const blade = new THREE.Mesh(new THREE.BoxGeometry(0.3, 2.4, 0.08), bladeMat)
|
||||
blade.rotation.z = (i / 4) * Math.PI * 2
|
||||
blades.add(blade)
|
||||
}
|
||||
blades.position.z = 0.05
|
||||
group.add(blades)
|
||||
|
||||
const beam = beamBox(pos, dir, range, spread)
|
||||
|
||||
world.addSystem({
|
||||
update(dt) {
|
||||
// Per-step impulse (force·dt) rather than addForce: Rapier's force
|
||||
// accumulator persists across steps, so re-adding a force every tick would
|
||||
// compound into a runaway. An impulse each step models a constant force
|
||||
// cleanly — same force on every body, so light blobs get flung and heavy
|
||||
// blobs barely move (mass does the work).
|
||||
const bodies = dynamicBodiesInBox(world, beam.center, beam.half)
|
||||
const k = cfg.force * dt
|
||||
for (const b of bodies.values()) {
|
||||
b.applyImpulse({ x: dir.x * k, y: dir.y * k, z: dir.z * k }, true)
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
world.onFrame((dt) => {
|
||||
blades.rotation.z += dt * 12 // constant spin = always-on telegraph
|
||||
})
|
||||
|
||||
return { id: cfg.id, group }
|
||||
}
|
||||
113
src/machine/surfaces.ts
Normal file
@ -0,0 +1,113 @@
|
||||
/**
|
||||
* SURFACES — the material framework (GDD §5.5). Friction / restitution / tags
|
||||
* per material so honey, ice, oil, mud and water are *data*, not code. The blob
|
||||
* controller (lane A) queries `surfaceAt(point)` to react to what it is standing
|
||||
* on; contraptions and courses stamp materials onto colliders via `applySurface`.
|
||||
*
|
||||
* Self-contained: no imports from other lanes. Colliders are tagged in a local
|
||||
* registry keyed by collider handle so `surfaceAt` can resolve a world point back
|
||||
* to its material with a short downward ray.
|
||||
*/
|
||||
import * as THREE from 'three'
|
||||
import type RAPIER from '@dimforge/rapier3d-compat'
|
||||
|
||||
export type SurfaceName = 'normal' | 'ice' | 'oil' | 'honey' | 'mud' | 'water'
|
||||
|
||||
export interface SurfaceMaterial {
|
||||
name: SurfaceName
|
||||
/** Rapier collider friction coefficient. */
|
||||
friction: number
|
||||
/** Rapier collider restitution (bounciness). */
|
||||
restitution: number
|
||||
/**
|
||||
* How friction combines with the *other* collider (the blob). `Min` lets ice
|
||||
* stay slippery even under a grippy/green blob; `Max` lets honey stay sticky.
|
||||
*/
|
||||
combine: 'average' | 'min' | 'max' | 'multiply'
|
||||
/** Extra per-frame linear damping the controller may apply while on it. */
|
||||
drag: number
|
||||
/** Behaviour flags read by gameplay (grip/slip/climb/rinse/anti-paint...). */
|
||||
tags: string[]
|
||||
/** Debug/VFX tint. */
|
||||
color: string
|
||||
}
|
||||
|
||||
export const SURFACES: Record<SurfaceName, SurfaceMaterial> = {
|
||||
// baseline grippy course material
|
||||
normal: { name: 'normal', friction: 1.0, restitution: 0.15, combine: 'average', drag: 0.0, tags: [], color: '#c9b98f' },
|
||||
// near-frictionless: a rolling ball keeps going for ages
|
||||
ice: { name: 'ice', friction: 0.02, restitution: 0.05, combine: 'min', drag: 0.0, tags: ['slip'], color: '#bfe9ff' },
|
||||
// slick + flowy luge; no steering (GDD §5.5 oil)
|
||||
oil: { name: 'oil', friction: 0.04, restitution: 0.0, combine: 'min', drag: 0.02, tags: ['slip', 'flow', 'flammable'], color: '#3a2f4a' },
|
||||
// sticky: kills momentum fast, lets you climb (GDD §5.5 honey)
|
||||
honey: { name: 'honey', friction: 2.0, restitution: 0.0, combine: 'max', drag: 0.12, tags: ['sticky', 'climb'], color: '#e7a72b' },
|
||||
// anti-paint sludge: sticky and masks colour (GDD §5.5 mud)
|
||||
mud: { name: 'mud', friction: 1.6, restitution: 0.0, combine: 'max', drag: 0.18, tags: ['sticky', 'antipaint'], color: '#6b4a2b' },
|
||||
// gradual rinse lane
|
||||
water: { name: 'water', friction: 0.45, restitution: 0.0, combine: 'average', drag: 0.08, tags: ['flow', 'rinse'], color: '#2f7fd0' },
|
||||
}
|
||||
|
||||
function combineRule(rapier: typeof RAPIER, combine: SurfaceMaterial['combine']) {
|
||||
switch (combine) {
|
||||
case 'min': return rapier.CoefficientCombineRule.Min
|
||||
case 'max': return rapier.CoefficientCombineRule.Max
|
||||
case 'multiply': return rapier.CoefficientCombineRule.Multiply
|
||||
default: return rapier.CoefficientCombineRule.Average
|
||||
}
|
||||
}
|
||||
|
||||
/** Registry mapping collider.handle -> surface, so points can be resolved back. */
|
||||
export class SurfaceRegistry {
|
||||
private byHandle = new Map<number, SurfaceMaterial>()
|
||||
constructor(private rapier: typeof RAPIER) {}
|
||||
|
||||
/** Stamp a material onto a live collider and remember it. */
|
||||
apply(collider: RAPIER.Collider, name: SurfaceName): SurfaceMaterial {
|
||||
const surf = SURFACES[name]
|
||||
collider.setFriction(surf.friction)
|
||||
collider.setRestitution(surf.restitution)
|
||||
const rule = combineRule(this.rapier, surf.combine)
|
||||
collider.setFrictionCombineRule(rule)
|
||||
collider.setRestitutionCombineRule(rule)
|
||||
this.byHandle.set(collider.handle, surf)
|
||||
return surf
|
||||
}
|
||||
|
||||
/** Look up the material for a specific collider (or undefined). */
|
||||
forCollider(collider: RAPIER.Collider): SurfaceMaterial | undefined {
|
||||
return this.byHandle.get(collider.handle)
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the surface under a world point (e.g. beneath the blob) by casting a
|
||||
* short ray straight down. Returns `normal` if the hit collider is untagged,
|
||||
* `undefined` only when nothing is below.
|
||||
*/
|
||||
surfaceAt(physics: RAPIER.World, point: THREE.Vector3, reach = 1.5): SurfaceMaterial | undefined {
|
||||
const ray = new this.rapier.Ray(
|
||||
{ x: point.x, y: point.y + 0.05, z: point.z },
|
||||
{ x: 0, y: -1, z: 0 },
|
||||
)
|
||||
const hit = physics.castRay(ray, reach, true)
|
||||
if (!hit) return undefined
|
||||
return this.byHandle.get(hit.collider.handle) ?? SURFACES.normal
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Convenience for callers that don't hold a registry: stamp friction/restitution
|
||||
* directly onto a collider. (Prefer `SurfaceRegistry.apply` so `surfaceAt` works.)
|
||||
*/
|
||||
export function applySurface(
|
||||
rapier: typeof RAPIER,
|
||||
collider: RAPIER.Collider,
|
||||
name: SurfaceName,
|
||||
): SurfaceMaterial {
|
||||
const surf = SURFACES[name]
|
||||
collider.setFriction(surf.friction)
|
||||
collider.setRestitution(surf.restitution)
|
||||
const rule = combineRule(rapier, surf.combine)
|
||||
collider.setFrictionCombineRule(rule)
|
||||
collider.setRestitutionCombineRule(rule)
|
||||
return surf
|
||||
}
|
||||
157
src/machine/telegraph.ts
Normal file
@ -0,0 +1,157 @@
|
||||
/**
|
||||
* TELEGRAPH — the readable-chaos pillar (GDD §2.3, §5.4).
|
||||
*
|
||||
* Every contraption that applies force or dumps paint MUST wind up visibly for
|
||||
* ~0.5s before it fires. This helper plays that windup on a mesh/group — an
|
||||
* accelerating flash + shake + scale pulse that is unmissable — then resolves
|
||||
* (and calls `onFire`) at the moment of firing. A part that fires without first
|
||||
* awaiting a telegraph is a bug, not a shortcut.
|
||||
*
|
||||
* The animation is ticked once per render frame via a single `world.onFrame`
|
||||
* hook installed lazily per world (so importing this module has no side effects
|
||||
* and parts never wire their own tickers).
|
||||
*/
|
||||
import * as THREE from 'three'
|
||||
import type { World } from '../contracts'
|
||||
|
||||
export interface TelegraphOptions {
|
||||
/** Windup length in seconds before firing. Pillar default is ~0.5s. */
|
||||
duration?: number
|
||||
/** Peak positional jitter amplitude (world units). */
|
||||
shake?: number
|
||||
/** Peak extra uniform scale (1 -> 1 + scalePulse at the crescendo). */
|
||||
scalePulse?: number
|
||||
/** Emissive flash colour — the "danger" tint. */
|
||||
flashColor?: THREE.ColorRepresentation
|
||||
/** Called exactly once, at the instant the windup completes (firing). */
|
||||
onFire?: () => void
|
||||
}
|
||||
|
||||
const DEFAULTS: Required<Omit<TelegraphOptions, 'onFire'>> = {
|
||||
duration: 0.5,
|
||||
shake: 0.08,
|
||||
scalePulse: 0.22,
|
||||
flashColor: '#FFEB3B',
|
||||
}
|
||||
|
||||
interface EmissiveCache {
|
||||
mat: THREE.MeshStandardMaterial
|
||||
color: THREE.Color
|
||||
intensity: number
|
||||
}
|
||||
|
||||
interface ActiveTelegraph {
|
||||
root: THREE.Object3D
|
||||
basePos: THREE.Vector3
|
||||
baseScale: THREE.Vector3
|
||||
emissives: EmissiveCache[]
|
||||
flash: THREE.Color
|
||||
duration: number
|
||||
shake: number
|
||||
scalePulse: number
|
||||
t: number
|
||||
onFire?: () => void
|
||||
resolve: () => void
|
||||
}
|
||||
|
||||
const perWorld = new WeakMap<World, ActiveTelegraph[]>()
|
||||
|
||||
function collectEmissives(root: THREE.Object3D): EmissiveCache[] {
|
||||
const out: EmissiveCache[] = []
|
||||
root.traverse((o) => {
|
||||
const mesh = o as THREE.Mesh
|
||||
const mat = mesh.material as THREE.Material | THREE.Material[] | undefined
|
||||
const mats = Array.isArray(mat) ? mat : mat ? [mat] : []
|
||||
for (const m of mats) {
|
||||
if ((m as THREE.MeshStandardMaterial).isMeshStandardMaterial) {
|
||||
const sm = m as THREE.MeshStandardMaterial
|
||||
out.push({ mat: sm, color: sm.emissive.clone(), intensity: sm.emissiveIntensity })
|
||||
}
|
||||
}
|
||||
})
|
||||
return out
|
||||
}
|
||||
|
||||
function tick(list: ActiveTelegraph[], dt: number) {
|
||||
for (let i = list.length - 1; i >= 0; i--) {
|
||||
const tg = list[i]
|
||||
tg.t += dt
|
||||
const p = Math.min(tg.t / tg.duration, 1)
|
||||
|
||||
// Intensity ramps up (p^1.6) so the windup visibly *builds* toward firing.
|
||||
const ramp = Math.pow(p, 1.6)
|
||||
const wobble = 0.5 + 0.5 * Math.sin(tg.t * 34) // fast panic flicker
|
||||
|
||||
// shake around the base position
|
||||
const amp = tg.shake * ramp
|
||||
tg.root.position.set(
|
||||
tg.basePos.x + (Math.random() * 2 - 1) * amp,
|
||||
tg.basePos.y + (Math.random() * 2 - 1) * amp,
|
||||
tg.basePos.z + (Math.random() * 2 - 1) * amp,
|
||||
)
|
||||
// scale pulse
|
||||
const s = 1 + tg.scalePulse * ramp * (0.55 + 0.45 * wobble)
|
||||
tg.root.scale.set(tg.baseScale.x * s, tg.baseScale.y * s, tg.baseScale.z * s)
|
||||
// emissive flash
|
||||
const glow = ramp * (0.4 + 1.8 * wobble)
|
||||
for (const e of tg.emissives) {
|
||||
e.mat.emissive.copy(tg.flash)
|
||||
e.mat.emissiveIntensity = glow
|
||||
}
|
||||
|
||||
if (p >= 1) {
|
||||
// restore the untouched look, then fire
|
||||
tg.root.position.copy(tg.basePos)
|
||||
tg.root.scale.copy(tg.baseScale)
|
||||
for (const e of tg.emissives) {
|
||||
e.mat.emissive.copy(e.color)
|
||||
e.mat.emissiveIntensity = e.intensity
|
||||
}
|
||||
list.splice(i, 1)
|
||||
tg.onFire?.()
|
||||
tg.resolve()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Play a windup on `target` and resolve when it fires. Awaitable so a part can
|
||||
* do `await telegraph(world, mesh, {onFire})` and only apply force afterwards.
|
||||
*/
|
||||
export function telegraph(
|
||||
world: World,
|
||||
target: THREE.Object3D,
|
||||
opts: TelegraphOptions = {},
|
||||
): Promise<void> {
|
||||
let list = perWorld.get(world)
|
||||
if (!list) {
|
||||
list = []
|
||||
perWorld.set(world, list)
|
||||
// Fixed-step, not onFrame: the windup GATES gameplay (forces, paint dumps),
|
||||
// so it must advance with the sim — onFrame stalls in hidden tabs, which
|
||||
// would freeze every contraption mid-telegraph (found at integration).
|
||||
world.addSystem({ update: (dt) => tick(list!, dt) })
|
||||
}
|
||||
const o = { ...DEFAULTS, ...opts }
|
||||
return new Promise<void>((resolve) => {
|
||||
list!.push({
|
||||
root: target,
|
||||
basePos: target.position.clone(),
|
||||
baseScale: target.scale.clone(),
|
||||
emissives: collectEmissives(target),
|
||||
flash: new THREE.Color(o.flashColor),
|
||||
duration: o.duration,
|
||||
shake: o.shake,
|
||||
scalePulse: o.scalePulse,
|
||||
t: 0,
|
||||
onFire: opts.onFire,
|
||||
resolve,
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
/** True while `target` is mid-windup — parts use it to avoid re-triggering. */
|
||||
export function isTelegraphing(world: World, target: THREE.Object3D): boolean {
|
||||
const list = perWorld.get(world)
|
||||
return !!list && list.some((tg) => tg.root === target)
|
||||
}
|
||||
159
src/paint/buffs.ts
Normal file
@ -0,0 +1,159 @@
|
||||
/**
|
||||
* BuffSystem — every fixed step, reads the blob's coverage() and writes
|
||||
* blob.modifiers per GDD §6 (MVP subset), then drives the two cheap visual
|
||||
* side-effects that make the buff READ on the body: an ember trail while the RED
|
||||
* BURN buff is active, and an emissive glow pulse in the super state.
|
||||
*
|
||||
* The number-crunching lives in computeModifiers() (coverage-math.ts, pure &
|
||||
* unit-tested); this System just applies it and paints the juice.
|
||||
*/
|
||||
import * as THREE from 'three'
|
||||
import type { BlobModifiers, System, PaintColor } from '../contracts'
|
||||
import { PALETTE } from '../contracts'
|
||||
import type { PaintSkin } from './skin'
|
||||
import {
|
||||
ACTIVATE,
|
||||
buffStrength,
|
||||
computeModifiers,
|
||||
dominantColor,
|
||||
superColor,
|
||||
} from './coverage-math'
|
||||
|
||||
export interface BuffTarget {
|
||||
mesh: THREE.Mesh
|
||||
modifiers: BlobModifiers
|
||||
paint?: PaintSkin
|
||||
}
|
||||
|
||||
const EMBER_COUNT = 90
|
||||
|
||||
export class BuffSystem implements System {
|
||||
private readonly scene: THREE.Scene
|
||||
private readonly blob: BuffTarget
|
||||
|
||||
// ember pool
|
||||
private readonly embers: THREE.Points
|
||||
private readonly emberPos: Float32Array
|
||||
private readonly emberCol: Float32Array
|
||||
private readonly emberVel: Float32Array
|
||||
private readonly emberLife: Float32Array
|
||||
private emberCursor = 0
|
||||
private readonly emberHex = new THREE.Color()
|
||||
private readonly _spawnAt = new THREE.Vector3()
|
||||
|
||||
private clock = 0
|
||||
|
||||
constructor(world: { scene: THREE.Scene }, blob: BuffTarget) {
|
||||
this.scene = world.scene
|
||||
this.blob = blob
|
||||
// ember tint: red→orange blend, computed once
|
||||
this.emberHex.set(PALETTE.red).lerp(new THREE.Color(PALETTE.orange), 0.5)
|
||||
|
||||
this.emberPos = new Float32Array(EMBER_COUNT * 3)
|
||||
this.emberCol = new Float32Array(EMBER_COUNT * 3)
|
||||
this.emberVel = new Float32Array(EMBER_COUNT * 3)
|
||||
this.emberLife = new Float32Array(EMBER_COUNT) // 0 = dead
|
||||
const geo = new THREE.BufferGeometry()
|
||||
geo.setAttribute('position', new THREE.BufferAttribute(this.emberPos, 3))
|
||||
geo.setAttribute('color', new THREE.BufferAttribute(this.emberCol, 3))
|
||||
const mat = new THREE.PointsMaterial({
|
||||
size: 0.16,
|
||||
vertexColors: true,
|
||||
transparent: true,
|
||||
depthWrite: false,
|
||||
blending: THREE.AdditiveBlending,
|
||||
sizeAttenuation: true,
|
||||
})
|
||||
this.embers = new THREE.Points(geo, mat)
|
||||
this.embers.frustumCulled = false
|
||||
this.scene.add(this.embers)
|
||||
}
|
||||
|
||||
update(dt: number): void {
|
||||
this.clock += dt
|
||||
const paint = this.blob.paint
|
||||
if (!paint) return
|
||||
|
||||
const cov = paint.coverage() // 250ms-cached; no per-frame readback
|
||||
const mods = computeModifiers(cov)
|
||||
// copy into the shared modifiers object (readers hold this reference)
|
||||
const m = this.blob.modifiers
|
||||
m.speedMul = mods.speedMul
|
||||
m.jumpMul = mods.jumpMul
|
||||
m.massMul = mods.massMul
|
||||
m.grip = mods.grip
|
||||
m.size = mods.size
|
||||
m.waterproof = mods.waterproof
|
||||
m.glow = mods.glow
|
||||
|
||||
this.updateEmbers(dt, cov.byColor.red)
|
||||
this.updateGlow(mods.glow, superColor(cov) ?? dominantColor(cov))
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
this.scene.remove(this.embers)
|
||||
this.embers.geometry.dispose()
|
||||
;(this.embers.material as THREE.Material).dispose()
|
||||
}
|
||||
|
||||
// ---- ember trail (RED BURN) ---------------------------------------------
|
||||
|
||||
private updateEmbers(dt: number, red: number): void {
|
||||
const strength = buffStrength(red) // 0 below 20%
|
||||
if (red >= ACTIVATE) {
|
||||
// spawn rate scales with how hot the blob is
|
||||
const spawn = strength * 3
|
||||
let n = Math.floor(spawn) + (Math.random() < spawn % 1 ? 1 : 0)
|
||||
this.blob.mesh.getWorldPosition(this._spawnAt)
|
||||
while (n-- > 0) this.spawnEmber(this._spawnAt)
|
||||
}
|
||||
|
||||
const emberHex = this.emberHex
|
||||
for (let i = 0; i < EMBER_COUNT; i++) {
|
||||
if (this.emberLife[i] <= 0) continue
|
||||
this.emberLife[i] -= dt
|
||||
const j = i * 3
|
||||
if (this.emberLife[i] <= 0) {
|
||||
this.emberCol[j] = this.emberCol[j + 1] = this.emberCol[j + 2] = 0 // invisible
|
||||
continue
|
||||
}
|
||||
this.emberVel[j + 1] += 2.2 * dt // buoyant rise
|
||||
this.emberPos[j] += this.emberVel[j] * dt
|
||||
this.emberPos[j + 1] += this.emberVel[j + 1] * dt
|
||||
this.emberPos[j + 2] += this.emberVel[j + 2] * dt
|
||||
const fade = Math.min(1, this.emberLife[i] / 0.5)
|
||||
this.emberCol[j] = emberHex.r * fade
|
||||
this.emberCol[j + 1] = emberHex.g * fade
|
||||
this.emberCol[j + 2] = emberHex.b * fade
|
||||
}
|
||||
this.embers.geometry.attributes.position.needsUpdate = true
|
||||
this.embers.geometry.attributes.color.needsUpdate = true
|
||||
}
|
||||
|
||||
private spawnEmber(center: THREE.Vector3): void {
|
||||
const i = this.emberCursor
|
||||
this.emberCursor = (this.emberCursor + 1) % EMBER_COUNT
|
||||
const j = i * 3
|
||||
this.emberPos[j] = center.x + (Math.random() - 0.5) * 0.5
|
||||
this.emberPos[j + 1] = center.y + (Math.random() - 0.3) * 0.4
|
||||
this.emberPos[j + 2] = center.z + (Math.random() - 0.5) * 0.5
|
||||
this.emberVel[j] = (Math.random() - 0.5) * 0.8
|
||||
this.emberVel[j + 1] = 0.6 + Math.random() * 0.8
|
||||
this.emberVel[j + 2] = (Math.random() - 0.5) * 0.8
|
||||
this.emberLife[i] = 0.5 + Math.random() * 0.4
|
||||
}
|
||||
|
||||
// ---- glow (super state) --------------------------------------------------
|
||||
|
||||
private updateGlow(glow: number, color: PaintColor | null): void {
|
||||
const mat = this.blob.mesh.material as THREE.MeshStandardMaterial
|
||||
if (!('emissive' in mat)) return
|
||||
if (glow > 0 && color) {
|
||||
const pulse = 0.55 + 0.45 * Math.sin(this.clock * 8)
|
||||
mat.emissive.set(PALETTE[color])
|
||||
mat.emissiveIntensity = glow * pulse * 1.4
|
||||
} else {
|
||||
mat.emissiveIntensity = 0
|
||||
}
|
||||
}
|
||||
}
|
||||
305
src/paint/cannon.ts
Normal file
@ -0,0 +1,305 @@
|
||||
/**
|
||||
* PaintCannon — a placed emitter that lobs paint-glob projectiles on a gravity
|
||||
* arc. On hitting the blob it stamps a splat exactly where it struck (the magic
|
||||
* moment) and emits `paint:splatted`. Fires on an interval AND on
|
||||
* `machine:signal {id}` matching its trigger id (Lane C wires the signals).
|
||||
*
|
||||
* Projectiles are simulated by a manual sweep (no Rapier collider churn): each
|
||||
* step advances position under gravity and does a swept sphere-vs-sphere test
|
||||
* against the target so fast shots can't tunnel through.
|
||||
*/
|
||||
import * as THREE from 'three'
|
||||
import type { PaintColor, System, World } from '../contracts'
|
||||
import { PALETTE } from '../contracts'
|
||||
import type { PaintSkin } from './skin'
|
||||
|
||||
export interface PaintCannonConfig {
|
||||
world: World
|
||||
position: THREE.Vector3
|
||||
color: PaintColor
|
||||
/** Aim target (usually the blob mesh) — sampled at fire time for a lead shot. */
|
||||
target: THREE.Object3D
|
||||
/** Skin to stamp on a blob hit. */
|
||||
paint: PaintSkin
|
||||
/** Target bounding radius (world units) for the hit test. */
|
||||
targetRadius: number
|
||||
/** Fire on `machine:signal {id}` when id === triggerId. */
|
||||
triggerId?: string
|
||||
/** Seconds between auto-fires. 0/undefined disables the auto cadence. */
|
||||
interval?: number
|
||||
muzzleSpeed?: number
|
||||
projRadius?: number
|
||||
/** World-space splat radius handed to the skin on hit. */
|
||||
splatRadius?: number
|
||||
/** Gravity magnitude for the arc (default 14, matching world.ts). */
|
||||
gravity?: number
|
||||
/** y below which a projectile is considered to have hit the ground. */
|
||||
groundY?: number
|
||||
/** Lead a moving target using its estimated velocity (default true). */
|
||||
lead?: boolean
|
||||
}
|
||||
|
||||
interface Projectile {
|
||||
mesh: THREE.Mesh
|
||||
vel: THREE.Vector3
|
||||
prev: THREE.Vector3
|
||||
life: number
|
||||
}
|
||||
|
||||
const MAX_LIFE = 6 // seconds before a stray glob is culled
|
||||
|
||||
export class PaintCannon implements System {
|
||||
readonly color: PaintColor
|
||||
private readonly cfg: Required<
|
||||
Omit<PaintCannonConfig, 'triggerId'>
|
||||
> & { triggerId?: string }
|
||||
private readonly scene: THREE.Scene
|
||||
private readonly projectiles: Projectile[] = []
|
||||
private readonly projGeo: THREE.SphereGeometry
|
||||
private readonly projMat: THREE.MeshStandardMaterial
|
||||
private readonly barrel: THREE.Group
|
||||
private acc = 0
|
||||
private readonly unsub: () => void
|
||||
|
||||
// target velocity estimate (for leading a moving blob)
|
||||
private readonly _lastTarget = new THREE.Vector3()
|
||||
private readonly _targetVel = new THREE.Vector3()
|
||||
private hasLast = false
|
||||
|
||||
// scratch
|
||||
private readonly _tpos = new THREE.Vector3()
|
||||
private readonly _aim = new THREE.Vector3()
|
||||
private readonly _hit = new THREE.Vector3()
|
||||
private readonly _vtmp = new THREE.Vector3()
|
||||
|
||||
constructor(config: PaintCannonConfig) {
|
||||
this.color = config.color
|
||||
this.cfg = {
|
||||
interval: 0,
|
||||
muzzleSpeed: 16,
|
||||
projRadius: 0.12,
|
||||
splatRadius: 0.28,
|
||||
gravity: 14,
|
||||
groundY: 0,
|
||||
lead: true,
|
||||
...config,
|
||||
}
|
||||
this.scene = config.world.scene
|
||||
|
||||
const hex = PALETTE[config.color]
|
||||
this.projGeo = new THREE.SphereGeometry(this.cfg.projRadius, 12, 10)
|
||||
this.projMat = new THREE.MeshStandardMaterial({
|
||||
color: hex,
|
||||
emissive: hex,
|
||||
emissiveIntensity: 0.35,
|
||||
roughness: 0.4,
|
||||
})
|
||||
|
||||
this.barrel = this.buildBarrel(hex)
|
||||
this.barrel.position.copy(config.position)
|
||||
this.scene.add(this.barrel)
|
||||
|
||||
this.unsub = config.world.events.on('machine:signal', (p: { id: string }) => {
|
||||
if (this.cfg.triggerId && p?.id === this.cfg.triggerId) this.fire()
|
||||
})
|
||||
}
|
||||
|
||||
/** Fire one glob now, leading the target with a ballistic solve. */
|
||||
fire(): void {
|
||||
this.target(this._tpos)
|
||||
const from = this.muzzleWorld()
|
||||
|
||||
// Lead: aim where the target will be after the glob's ~time-of-flight.
|
||||
this._aim.copy(this._tpos)
|
||||
if (this.cfg.lead && this.hasLast) {
|
||||
const flight = from.distanceTo(this._tpos) / this.cfg.muzzleSpeed
|
||||
this._aim.addScaledVector(this._targetVel, flight)
|
||||
}
|
||||
|
||||
const vel =
|
||||
solveBallisticVelocity(from, this._aim, this.cfg.muzzleSpeed, this.cfg.gravity) ??
|
||||
this._aim.clone().sub(from).normalize().multiplyScalar(this.cfg.muzzleSpeed)
|
||||
|
||||
// Point the barrel along the launch direction.
|
||||
this.aimBarrel(vel)
|
||||
|
||||
const mesh = new THREE.Mesh(this.projGeo, this.projMat)
|
||||
mesh.castShadow = true
|
||||
mesh.position.copy(from)
|
||||
this.scene.add(mesh)
|
||||
this.projectiles.push({
|
||||
mesh,
|
||||
vel: vel.clone(),
|
||||
prev: from.clone(),
|
||||
life: 0,
|
||||
})
|
||||
}
|
||||
|
||||
update(dt: number): void {
|
||||
// maintain a smoothed target-velocity estimate for leading
|
||||
if (dt > 0) {
|
||||
this.target(this._tpos)
|
||||
if (this.hasLast) {
|
||||
const inv = 1 / dt
|
||||
this._vtmp.copy(this._tpos).sub(this._lastTarget).multiplyScalar(inv)
|
||||
this._targetVel.lerp(this._vtmp, 0.4)
|
||||
}
|
||||
this._lastTarget.copy(this._tpos)
|
||||
this.hasLast = true
|
||||
}
|
||||
|
||||
// auto cadence
|
||||
if (this.cfg.interval > 0) {
|
||||
this.acc += dt
|
||||
while (this.acc >= this.cfg.interval) {
|
||||
this.acc -= this.cfg.interval
|
||||
this.fire()
|
||||
}
|
||||
}
|
||||
|
||||
if (this.projectiles.length === 0) return
|
||||
this.target(this._tpos)
|
||||
const hitDist = this.cfg.targetRadius + this.cfg.projRadius
|
||||
|
||||
for (let i = this.projectiles.length - 1; i >= 0; i--) {
|
||||
const p = this.projectiles[i]
|
||||
p.prev.copy(p.mesh.position)
|
||||
p.vel.y -= this.cfg.gravity * dt
|
||||
p.mesh.position.addScaledVector(p.vel, dt)
|
||||
p.life += dt
|
||||
|
||||
// swept hit test against the target sphere (prev -> current segment)
|
||||
const d = closestDistToSegment(this._tpos, p.prev, p.mesh.position, this._hit)
|
||||
if (d <= hitDist) {
|
||||
this.cfg.paint.splatAtPoint(this._hit, this.color, this.cfg.splatRadius)
|
||||
this.cfg.world.events.emit('paint:splatted', {
|
||||
color: this.color,
|
||||
target: 'blob',
|
||||
})
|
||||
this.despawn(i)
|
||||
continue
|
||||
}
|
||||
|
||||
if (p.mesh.position.y <= this.cfg.groundY || p.life > MAX_LIFE) {
|
||||
// Ground/world decals are V1; just despawn (protocol-complete signal).
|
||||
this.cfg.world.events.emit('paint:splatted', {
|
||||
color: this.color,
|
||||
target: 'world',
|
||||
})
|
||||
this.despawn(i)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
this.unsub()
|
||||
for (let i = this.projectiles.length - 1; i >= 0; i--) this.despawn(i)
|
||||
this.scene.remove(this.barrel)
|
||||
this.projGeo.dispose()
|
||||
this.projMat.dispose()
|
||||
}
|
||||
|
||||
// ---- internals ----------------------------------------------------------
|
||||
|
||||
private target(out: THREE.Vector3): THREE.Vector3 {
|
||||
return this.cfg.target.getWorldPosition(out)
|
||||
}
|
||||
|
||||
private muzzleWorld(): THREE.Vector3 {
|
||||
// muzzle a little in front of / above the barrel base
|
||||
return this.cfg.position.clone().add(new THREE.Vector3(0, 0.55, 0))
|
||||
}
|
||||
|
||||
private despawn(i: number): void {
|
||||
const p = this.projectiles[i]
|
||||
this.scene.remove(p.mesh)
|
||||
this.projectiles.splice(i, 1)
|
||||
}
|
||||
|
||||
private buildBarrel(hex: string): THREE.Group {
|
||||
const g = new THREE.Group()
|
||||
const base = new THREE.Mesh(
|
||||
new THREE.CylinderGeometry(0.45, 0.55, 0.5, 16),
|
||||
new THREE.MeshStandardMaterial({ color: '#3a3a44', roughness: 0.7 }),
|
||||
)
|
||||
base.castShadow = true
|
||||
g.add(base)
|
||||
const tube = new THREE.Mesh(
|
||||
new THREE.CylinderGeometry(0.22, 0.28, 1.1, 16),
|
||||
new THREE.MeshStandardMaterial({ color: hex, roughness: 0.5 }),
|
||||
)
|
||||
tube.castShadow = true
|
||||
// pivot the tube from the base and tilt it up; child group lets us aim it
|
||||
const pivot = new THREE.Group()
|
||||
pivot.position.y = 0.45
|
||||
tube.position.y = 0.4
|
||||
tube.rotation.x = Math.PI / 2 // point +Z by default
|
||||
pivot.add(tube)
|
||||
pivot.name = 'pivot'
|
||||
g.add(pivot)
|
||||
return g
|
||||
}
|
||||
|
||||
private aimBarrel(vel: THREE.Vector3): void {
|
||||
const pivot = this.barrel.getObjectByName('pivot')
|
||||
if (!pivot) return
|
||||
const dir = vel.clone().normalize()
|
||||
const m = new THREE.Matrix4().lookAt(
|
||||
new THREE.Vector3(0, 0, 0),
|
||||
dir,
|
||||
new THREE.Vector3(0, 1, 0),
|
||||
)
|
||||
pivot.quaternion.setFromRotationMatrix(m)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Ballistic launch velocity to hit `to` from `from` at fixed `speed` under
|
||||
* gravity magnitude `g` (down -y). Returns the LOW-arc solution, or null if the
|
||||
* target is out of range. Exported for potential reuse/testing.
|
||||
*/
|
||||
export function solveBallisticVelocity(
|
||||
from: THREE.Vector3,
|
||||
to: THREE.Vector3,
|
||||
speed: number,
|
||||
g: number,
|
||||
): THREE.Vector3 | null {
|
||||
const dx = to.x - from.x
|
||||
const dz = to.z - from.z
|
||||
const dy = to.y - from.y
|
||||
const x = Math.hypot(dx, dz)
|
||||
if (x < 1e-3) {
|
||||
return new THREE.Vector3(0, Math.sign(dy || 1) * speed, 0)
|
||||
}
|
||||
const s2 = speed * speed
|
||||
const disc = s2 * s2 - g * (g * x * x + 2 * dy * s2)
|
||||
if (disc < 0) return null
|
||||
const root = Math.sqrt(disc)
|
||||
const tanTheta = (s2 - root) / (g * x) // low arc
|
||||
const theta = Math.atan(tanTheta)
|
||||
const hx = dx / x
|
||||
const hz = dz / x
|
||||
const vh = speed * Math.cos(theta)
|
||||
const vy = speed * Math.sin(theta)
|
||||
return new THREE.Vector3(hx * vh, vy, hz * vh)
|
||||
}
|
||||
|
||||
/** Closest distance from point `p` to segment `a`->`b`; writes the point to `out`. */
|
||||
function closestDistToSegment(
|
||||
p: THREE.Vector3,
|
||||
a: THREE.Vector3,
|
||||
b: THREE.Vector3,
|
||||
out: THREE.Vector3,
|
||||
): number {
|
||||
const abx = b.x - a.x
|
||||
const aby = b.y - a.y
|
||||
const abz = b.z - a.z
|
||||
const len2 = abx * abx + aby * aby + abz * abz
|
||||
let t = 0
|
||||
if (len2 > 1e-12) {
|
||||
t = ((p.x - a.x) * abx + (p.y - a.y) * aby + (p.z - a.z) * abz) / len2
|
||||
t = t < 0 ? 0 : t > 1 ? 1 : t
|
||||
}
|
||||
out.set(a.x + abx * t, a.y + aby * t, a.z + abz * t)
|
||||
return out.distanceTo(p)
|
||||
}
|
||||
117
src/paint/coverage-math.test.ts
Normal file
@ -0,0 +1,117 @@
|
||||
/**
|
||||
* Unit checks for the pure paint math. No test runner / no deps — run directly:
|
||||
* node --experimental-strip-types src/paint/coverage-math.test.ts
|
||||
* (Node ≥22; on Node ≥23 the flag is unnecessary.) Uses only console + throw so
|
||||
* it also type-checks cleanly under the project's DOM tsconfig and is never
|
||||
* bundled (no demo entry imports it).
|
||||
*/
|
||||
import type { CoverageReport, PaintColor } from '../contracts'
|
||||
import {
|
||||
ACTIVATE,
|
||||
SUPER,
|
||||
buffStrength,
|
||||
computeModifiers,
|
||||
countBuckets,
|
||||
coverageReportFromCounts,
|
||||
colorIndex,
|
||||
} from './coverage-math'
|
||||
|
||||
let passed = 0
|
||||
function ok(cond: boolean, msg: string): void {
|
||||
if (!cond) throw new Error('FAIL: ' + msg)
|
||||
passed++
|
||||
}
|
||||
function near(a: number, b: number, msg: string, eps = 1e-9): void {
|
||||
ok(Math.abs(a - b) <= eps, `${msg} (got ${a}, want ${b})`)
|
||||
}
|
||||
|
||||
// helper: build a CoverageReport from a partial byColor map
|
||||
function cov(part: Partial<Record<PaintColor, number>>): CoverageReport {
|
||||
const byColor = {
|
||||
red: 0, orange: 0, yellow: 0, green: 0, blue: 0, purple: 0, pink: 0,
|
||||
...part,
|
||||
}
|
||||
const total = Object.values(byColor).reduce((a, b) => a + b, 0)
|
||||
return { total, byColor }
|
||||
}
|
||||
|
||||
// ---- colorIndex ----------------------------------------------------------
|
||||
ok(colorIndex('red') === 1, 'red index is 1')
|
||||
ok(colorIndex('pink') === 7, 'pink index is 7')
|
||||
|
||||
// ---- countBuckets --------------------------------------------------------
|
||||
{
|
||||
const mask = new Uint8Array(100) // all base (0)
|
||||
for (let i = 0; i < 30; i++) mask[i] = 1 // red
|
||||
for (let i = 30; i < 50; i++) mask[i] = 4 // green
|
||||
const c = countBuckets(mask, 7)
|
||||
ok(c[0] === 50, 'base count 50')
|
||||
ok(c[1] === 30, 'red count 30')
|
||||
ok(c[4] === 20, 'green count 20')
|
||||
ok(c[7] === 0, 'pink count 0')
|
||||
ok(c.length === 8, 'counts length 8 (base + 7)')
|
||||
}
|
||||
|
||||
// ---- coverageReportFromCounts --------------------------------------------
|
||||
{
|
||||
// counts: [base, red, orange, yellow, green, blue, purple, pink]
|
||||
const counts = [50, 30, 0, 0, 20, 0, 0, 0]
|
||||
const r = coverageReportFromCounts(counts, 100)
|
||||
near(r.byColor.red, 0.3, 'red fraction')
|
||||
near(r.byColor.green, 0.2, 'green fraction')
|
||||
near(r.total, 0.5, 'total painted fraction')
|
||||
// empty texture → all zeros, no NaN
|
||||
const z = coverageReportFromCounts([0, 0, 0, 0, 0, 0, 0, 0], 0)
|
||||
near(z.total, 0, 'empty total 0')
|
||||
near(z.byColor.blue, 0, 'empty blue 0')
|
||||
}
|
||||
|
||||
// ---- buffStrength --------------------------------------------------------
|
||||
near(buffStrength(0.0), 0, 'strength below threshold is 0')
|
||||
near(buffStrength(0.199), 0, 'strength just below 20% is 0')
|
||||
near(buffStrength(ACTIVATE), 0.15, 'strength at 20% is the floor')
|
||||
near(buffStrength(SUPER), 1, 'strength at 70% is 1')
|
||||
near(buffStrength(0.9), 1, 'strength above 70% pinned at 1')
|
||||
near(buffStrength(0.45), 0.15 + 0.85 * 0.5, 'strength ramps linearly at midpoint')
|
||||
|
||||
// ---- computeModifiers ----------------------------------------------------
|
||||
{
|
||||
const clean = computeModifiers(cov({}))
|
||||
near(clean.speedMul, 1, 'clean speedMul 1')
|
||||
near(clean.grip, 0, 'clean grip 0')
|
||||
ok(clean.waterproof === false, 'clean not waterproof')
|
||||
near(clean.massMul, 1, 'clean massMul 1')
|
||||
near(clean.glow, 0, 'clean no glow')
|
||||
near(clean.size, 1, 'clean size 1 (no grow/shrink in MVP)')
|
||||
}
|
||||
{
|
||||
// RED just under activation: no buff yet
|
||||
const m = computeModifiers(cov({ red: 0.19 }))
|
||||
near(m.speedMul, 1, 'red 19% → no speed buff')
|
||||
}
|
||||
{
|
||||
// RED at 45% → speedMul = 1 + 0.6 * buffStrength(0.45)
|
||||
const s = 0.15 + 0.85 * 0.5
|
||||
const m = computeModifiers(cov({ red: 0.45 }))
|
||||
near(m.speedMul, 1 + 0.6 * s, 'red 45% speedMul')
|
||||
near(m.massMul, 1 + 0.8 * 0.45, 'massMul tracks total 45%')
|
||||
near(m.glow, 0, 'red 45% not super')
|
||||
}
|
||||
{
|
||||
// GREEN super (70%) → grip maxed + glow
|
||||
const m = computeModifiers(cov({ green: 0.7 }))
|
||||
near(m.grip, 1, 'green 70% grip maxed')
|
||||
near(m.glow, 1, 'green 70% is super (glow)')
|
||||
}
|
||||
{
|
||||
// BLUE ≥20% → waterproof
|
||||
const m = computeModifiers(cov({ blue: 0.25 }))
|
||||
ok(m.waterproof === true, 'blue 25% waterproof')
|
||||
}
|
||||
{
|
||||
// massMul clamps at 1.8 when fully painted
|
||||
const m = computeModifiers(cov({ red: 0.5, blue: 0.5 }))
|
||||
near(m.massMul, 1.8, 'full coverage massMul 1.8')
|
||||
}
|
||||
|
||||
console.log(`coverage-math.test: ${passed} assertions passed ✓`)
|
||||
127
src/paint/coverage-math.ts
Normal file
@ -0,0 +1,127 @@
|
||||
/**
|
||||
* Pure paint math — no THREE / Rapier / DOM imports, so it is unit-testable in a
|
||||
* bare node runtime (see coverage-math.test.ts). Everything that touches only
|
||||
* numbers lives here: the exact pixel-bucket coverage count and the
|
||||
* coverage -> BlobModifiers buff mapping (GDD §6 MVP subset).
|
||||
*/
|
||||
import type { BlobModifiers, CoverageReport, PaintColor } from '../contracts'
|
||||
|
||||
/**
|
||||
* Canonical colour ordering. Index 0 in the mask is the unpainted base; palette
|
||||
* colours occupy indices 1..7 in this order (matches the PALETTE key order in
|
||||
* contracts.ts). Keep this list and the mask encoding in lockstep.
|
||||
*/
|
||||
export const COLOR_ORDER: readonly PaintColor[] = [
|
||||
'red', 'orange', 'yellow', 'green', 'blue', 'purple', 'pink',
|
||||
]
|
||||
|
||||
/** Mask palette index for a colour (1..7). Base/unpainted is 0. */
|
||||
export function colorIndex(color: PaintColor): number {
|
||||
return COLOR_ORDER.indexOf(color) + 1
|
||||
}
|
||||
|
||||
/**
|
||||
* Exact integer bucketing of a quantized mask. `mask[i]` is a palette index
|
||||
* (0 = base, 1..n = colour). Returns counts of length n+1 where counts[0] is the
|
||||
* unpainted pixel count. This is the whole point of quantizing on stamp: coverage
|
||||
* is an exact histogram, never a fuzzy nearest-colour match.
|
||||
*/
|
||||
export function countBuckets(mask: Uint8Array, numColors: number): number[] {
|
||||
const counts = new Array<number>(numColors + 1).fill(0)
|
||||
for (let i = 0; i < mask.length; i++) counts[mask[i]]++
|
||||
return counts
|
||||
}
|
||||
|
||||
/**
|
||||
* Turn raw bucket counts into a CoverageReport (fractions of TOTAL surface;
|
||||
* unpainted counts in the denominator, per contracts).
|
||||
*/
|
||||
export function coverageReportFromCounts(
|
||||
counts: number[],
|
||||
totalPixels: number,
|
||||
): CoverageReport {
|
||||
const byColor = {} as Record<PaintColor, number>
|
||||
for (let i = 0; i < COLOR_ORDER.length; i++) {
|
||||
byColor[COLOR_ORDER[i]] = totalPixels > 0 ? counts[i + 1] / totalPixels : 0
|
||||
}
|
||||
const painted = totalPixels > 0 ? (totalPixels - counts[0]) / totalPixels : 0
|
||||
return { total: painted, byColor }
|
||||
}
|
||||
|
||||
// ---- Buff thresholds (GDD §5.1 / §6 MVP subset) ---------------------------
|
||||
export const ACTIVATE = 0.20 // buff activates at ≥20% of that colour
|
||||
export const SUPER = 0.70 // ≥70% one colour = super state
|
||||
/** Strength floor at the activation threshold so the buff visibly "kicks in". */
|
||||
const ACTIVATE_FLOOR = 0.15
|
||||
|
||||
const clamp01 = (x: number) => (x < 0 ? 0 : x > 1 ? 1 : x)
|
||||
|
||||
/**
|
||||
* Buff strength 0..1 for a single colour's coverage fraction.
|
||||
* 0 below the 20% threshold; ramps ACTIVATE_FLOOR..1 across 20%→70%; pinned at 1
|
||||
* once super. This is the "strength scales linearly between thresholds" curve.
|
||||
*/
|
||||
export function buffStrength(coverage: number): number {
|
||||
if (coverage < ACTIVATE) return 0
|
||||
if (coverage >= SUPER) return 1
|
||||
const t = (coverage - ACTIVATE) / (SUPER - ACTIVATE)
|
||||
return ACTIVATE_FLOOR + (1 - ACTIVATE_FLOOR) * t
|
||||
}
|
||||
|
||||
/** Is any single colour at/above the super threshold? */
|
||||
export function superColor(cov: CoverageReport): PaintColor | null {
|
||||
let best: PaintColor | null = null
|
||||
let bestV = SUPER
|
||||
for (const c of COLOR_ORDER) {
|
||||
if (cov.byColor[c] >= bestV) {
|
||||
bestV = cov.byColor[c]
|
||||
best = c
|
||||
}
|
||||
}
|
||||
return best
|
||||
}
|
||||
|
||||
/** Dominant (highest-coverage) colour, or null if nothing is painted. */
|
||||
export function dominantColor(cov: CoverageReport): PaintColor | null {
|
||||
let best: PaintColor | null = null
|
||||
let bestV = 0
|
||||
for (const c of COLOR_ORDER) {
|
||||
if (cov.byColor[c] > bestV) {
|
||||
bestV = cov.byColor[c]
|
||||
best = c
|
||||
}
|
||||
}
|
||||
return best
|
||||
}
|
||||
|
||||
/**
|
||||
* The core mapping: coverage -> modifiers (GDD §6 MVP subset).
|
||||
* RED -> speedMul up to 1.6 (BURN)
|
||||
* GREEN -> grip up to 1.0 (GRIP)
|
||||
* BLUE -> waterproof (SLICK)
|
||||
* total coverage -> massMul 1.0..1.8 (paint = weight, §5.2)
|
||||
* any colour ≥70% -> super: that buff pinned at max + glow
|
||||
* Pure and deterministic; the visual side-effects (ember trail, emissive pulse)
|
||||
* live in BuffSystem and read `glow` / dominant colour off this result + cov.
|
||||
*/
|
||||
export function computeModifiers(cov: CoverageReport): BlobModifiers {
|
||||
const red = cov.byColor.red
|
||||
const green = cov.byColor.green
|
||||
const blue = cov.byColor.blue
|
||||
|
||||
const speedMul = 1 + 0.6 * buffStrength(red)
|
||||
const grip = buffStrength(green) // 0..1
|
||||
const waterproof = blue >= ACTIVATE
|
||||
const massMul = 1 + 0.8 * clamp01(cov.total)
|
||||
const glow = superColor(cov) ? 1 : 0
|
||||
|
||||
return {
|
||||
speedMul,
|
||||
jumpMul: 1,
|
||||
massMul,
|
||||
grip,
|
||||
size: 1, // purple/pink grow-shrink is V1 (§5.3) — untouched in MVP
|
||||
waterproof,
|
||||
glow,
|
||||
}
|
||||
}
|
||||
106
src/paint/hud.ts
Normal file
@ -0,0 +1,106 @@
|
||||
/**
|
||||
* PaintHUD — a tiny DOM overlay: per-colour coverage bars + the active buff kit.
|
||||
* No framework, no per-frame layout thrash (throttled to ~10Hz and only rewrites
|
||||
* text/width, never rebuilds nodes).
|
||||
*/
|
||||
import type { BlobModifiers, CoverageReport, PaintColor } from '../contracts'
|
||||
import { PALETTE } from '../contracts'
|
||||
import { COLOR_ORDER, ACTIVATE, SUPER } from './coverage-math'
|
||||
|
||||
export class PaintHUD {
|
||||
readonly root: HTMLDivElement
|
||||
private readonly bars = new Map<PaintColor, { fill: HTMLDivElement; pct: HTMLSpanElement }>()
|
||||
private readonly totalPct: HTMLSpanElement
|
||||
private readonly buffLine: HTMLDivElement
|
||||
private lastUpdate = -1e9
|
||||
|
||||
constructor(container: HTMLElement = document.body) {
|
||||
const root = document.createElement('div')
|
||||
root.style.cssText = [
|
||||
'position:fixed', 'top:12px', 'left:12px', 'z-index:10',
|
||||
'font:12px/1.4 ui-monospace,SFMono-Regular,Menlo,monospace',
|
||||
'color:#fff', 'background:rgba(18,18,24,0.72)', 'padding:10px 12px',
|
||||
'border-radius:10px', 'min-width:210px', 'user-select:none',
|
||||
'box-shadow:0 4px 18px rgba(0,0,0,0.35)', 'backdrop-filter:blur(4px)',
|
||||
].join(';')
|
||||
|
||||
const title = document.createElement('div')
|
||||
title.textContent = 'PAINT COVERAGE'
|
||||
title.style.cssText = 'font-weight:700;letter-spacing:0.08em;opacity:0.85;margin-bottom:8px'
|
||||
root.appendChild(title)
|
||||
|
||||
for (const color of COLOR_ORDER) {
|
||||
const row = document.createElement('div')
|
||||
row.style.cssText = 'display:flex;align-items:center;gap:6px;margin:3px 0'
|
||||
|
||||
const swatch = document.createElement('div')
|
||||
swatch.style.cssText = `width:10px;height:10px;border-radius:3px;background:${PALETTE[color]};flex:0 0 auto`
|
||||
row.appendChild(swatch)
|
||||
|
||||
const track = document.createElement('div')
|
||||
track.style.cssText = 'flex:1;height:8px;background:rgba(255,255,255,0.12);border-radius:4px;overflow:hidden'
|
||||
const fill = document.createElement('div')
|
||||
fill.style.cssText = `height:100%;width:0%;background:${PALETTE[color]};transition:width 0.12s linear`
|
||||
track.appendChild(fill)
|
||||
row.appendChild(track)
|
||||
|
||||
const pct = document.createElement('span')
|
||||
pct.textContent = '0%'
|
||||
pct.style.cssText = 'width:34px;text-align:right;opacity:0.9'
|
||||
row.appendChild(pct)
|
||||
|
||||
root.appendChild(row)
|
||||
this.bars.set(color, { fill, pct })
|
||||
}
|
||||
|
||||
const totalRow = document.createElement('div')
|
||||
totalRow.style.cssText = 'margin-top:8px;border-top:1px solid rgba(255,255,255,0.15);padding-top:6px'
|
||||
totalRow.innerHTML = 'total painted: '
|
||||
this.totalPct = document.createElement('span')
|
||||
this.totalPct.textContent = '0%'
|
||||
this.totalPct.style.fontWeight = '700'
|
||||
totalRow.appendChild(this.totalPct)
|
||||
root.appendChild(totalRow)
|
||||
|
||||
this.buffLine = document.createElement('div')
|
||||
this.buffLine.style.cssText = 'margin-top:6px;opacity:0.95;min-height:16px'
|
||||
root.appendChild(this.buffLine)
|
||||
|
||||
container.appendChild(root)
|
||||
this.root = root
|
||||
}
|
||||
|
||||
/** Call each frame; internally throttled to ~10Hz. */
|
||||
update(cov: CoverageReport, mods: BlobModifiers): void {
|
||||
const now = performance.now()
|
||||
if (now - this.lastUpdate < 100) return
|
||||
this.lastUpdate = now
|
||||
|
||||
for (const color of COLOR_ORDER) {
|
||||
const b = this.bars.get(color)!
|
||||
const v = cov.byColor[color]
|
||||
b.fill.style.width = `${Math.min(100, v * 100).toFixed(0)}%`
|
||||
b.pct.textContent = `${(v * 100).toFixed(0)}%`
|
||||
const active = v >= ACTIVATE
|
||||
b.pct.style.opacity = active ? '1' : '0.5'
|
||||
b.fill.style.boxShadow = v >= SUPER ? `0 0 8px ${PALETTE[color]}` : 'none'
|
||||
}
|
||||
this.totalPct.textContent = `${(cov.total * 100).toFixed(0)}%`
|
||||
|
||||
this.buffLine.innerHTML = this.buffText(cov, mods)
|
||||
}
|
||||
|
||||
private buffText(cov: CoverageReport, mods: BlobModifiers): string {
|
||||
const parts: string[] = []
|
||||
if (cov.byColor.red >= ACTIVATE) parts.push(`<b style="color:${PALETTE.red}">BURN</b> ×${mods.speedMul.toFixed(2)}`)
|
||||
if (cov.byColor.green >= ACTIVATE) parts.push(`<b style="color:${PALETTE.green}">GRIP</b> ${mods.grip.toFixed(2)}`)
|
||||
if (cov.byColor.blue >= ACTIVATE) parts.push(`<b style="color:${PALETTE.blue}">SLICK</b> waterproof`)
|
||||
parts.push(`mass ×${mods.massMul.toFixed(2)}`)
|
||||
if (mods.glow > 0) parts.push('<b style="color:#fff;text-shadow:0 0 6px #fff">★ SUPER</b>')
|
||||
return parts.length ? parts.join(' · ') : '<span style="opacity:0.5">no buffs</span>'
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
this.root.remove()
|
||||
}
|
||||
}
|
||||
74
src/paint/index.ts
Normal file
@ -0,0 +1,74 @@
|
||||
/**
|
||||
* Lane B (Paint) public surface. Integration (game.ts) imports from here.
|
||||
*/
|
||||
import * as THREE from 'three'
|
||||
import type { PaintColor, World } from '../contracts'
|
||||
import { PaintSkin } from './skin'
|
||||
|
||||
export { PaintSkin } from './skin'
|
||||
export type { PaintSkinOptions } from './skin'
|
||||
export { PaintCannon, solveBallisticVelocity } from './cannon'
|
||||
export type { PaintCannonConfig } from './cannon'
|
||||
export { BuffSystem } from './buffs'
|
||||
export type { BuffTarget } from './buffs'
|
||||
export { PaintHUD } from './hud'
|
||||
export {
|
||||
COLOR_ORDER,
|
||||
colorIndex,
|
||||
countBuckets,
|
||||
coverageReportFromCounts,
|
||||
computeModifiers,
|
||||
buffStrength,
|
||||
superColor,
|
||||
dominantColor,
|
||||
ACTIVATE,
|
||||
SUPER,
|
||||
} from './coverage-math'
|
||||
|
||||
export interface PaintEventTarget {
|
||||
/** The paintable body — its world position is used for proximity gating. */
|
||||
mesh: THREE.Object3D
|
||||
paint: PaintSkin
|
||||
}
|
||||
|
||||
/**
|
||||
* Wire the inbound paint protocol from Lane C onto a blob's skin:
|
||||
* 'paint:request-splat' {point, color, radius} — bucket dumps, sprays
|
||||
* 'paint:request-cleanse' {fraction} — bubble arches, rinses
|
||||
* request-splat is proximity-gated to the blob (ground decals are V1), and a
|
||||
* matched splat re-emits 'paint:splatted' {target:'blob'} so downstream listeners
|
||||
* (VFX, audio, netcode) see a single canonical splat event. Returns an unsub fn.
|
||||
*/
|
||||
export function installPaint(world: World, target: PaintEventTarget): () => void {
|
||||
const center = new THREE.Vector3()
|
||||
const scale = new THREE.Vector3()
|
||||
|
||||
const worldRadius = (): number => {
|
||||
target.mesh.getWorldScale(scale)
|
||||
return target.paint.boundingRadius * Math.max(scale.x, scale.y, scale.z)
|
||||
}
|
||||
|
||||
const offSplat = world.events.on(
|
||||
'paint:request-splat',
|
||||
(p: { point: THREE.Vector3; color: PaintColor; radius: number }) => {
|
||||
if (!p?.point) return
|
||||
target.mesh.getWorldPosition(center)
|
||||
if (center.distanceTo(p.point) <= worldRadius() + p.radius) {
|
||||
target.paint.splatAtPoint(p.point, p.color, p.radius)
|
||||
world.events.emit('paint:splatted', { color: p.color, target: 'blob' })
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
const offCleanse = world.events.on(
|
||||
'paint:request-cleanse',
|
||||
(p: { fraction: number }) => {
|
||||
target.paint.cleanse(p?.fraction ?? 1)
|
||||
},
|
||||
)
|
||||
|
||||
return () => {
|
||||
offSplat()
|
||||
offCleanse()
|
||||
}
|
||||
}
|
||||
308
src/paint/skin.ts
Normal file
@ -0,0 +1,308 @@
|
||||
/**
|
||||
* PaintSkin — the core invention. A Canvas-2D colour mask painted onto a mesh's
|
||||
* UVs, wrapped in a THREE.CanvasTexture that becomes the body's material.map over
|
||||
* the white base. Splats are stamped WHERE the projectile hit (raycast → face UV),
|
||||
* and per-colour coverage is an exact integer histogram of a quantized mask.
|
||||
*
|
||||
* Two coupled representations, generated from the SAME set of sub-circles per
|
||||
* splat so they always agree spatially:
|
||||
* - `canvas` : soft-edged, irregular, pretty — this is what you SEE.
|
||||
* - `mask` : a Uint8Array of palette indices (0 = base, 1..7 = colour),
|
||||
* hard-edged — this is what we COUNT. Quantized on stamp, so
|
||||
* coverage() is exact bucketing, never a fuzzy colour match, and
|
||||
* we NEVER call getImageData (the real per-frame perf killer).
|
||||
*/
|
||||
import * as THREE from 'three'
|
||||
import type { CoverageReport, PaintColor, PaintSkin as IPaintSkin } from '../contracts'
|
||||
import { BASE_WHITE, PALETTE } from '../contracts'
|
||||
import {
|
||||
COLOR_ORDER,
|
||||
colorIndex,
|
||||
countBuckets,
|
||||
coverageReportFromCounts,
|
||||
} from './coverage-math'
|
||||
|
||||
export interface PaintSkinOptions {
|
||||
/** Mask/texture resolution (square). MVP default 256. */
|
||||
size?: number
|
||||
/**
|
||||
* How many canvas pixels one world-unit of splat radius maps to, expressed as
|
||||
* a fraction of the texture width per (radius / boundingRadius). Tune for splat
|
||||
* size on screen; exact value is cosmetic (coverage is area-based regardless).
|
||||
*/
|
||||
splatScale?: number
|
||||
}
|
||||
|
||||
interface SubCircle {
|
||||
x: number
|
||||
y: number
|
||||
r: number
|
||||
}
|
||||
|
||||
export class PaintSkin implements IPaintSkin {
|
||||
readonly canvas: HTMLCanvasElement
|
||||
readonly texture: THREE.CanvasTexture
|
||||
readonly boundingRadius: number
|
||||
|
||||
private readonly mesh: THREE.Mesh
|
||||
private readonly ctx: CanvasRenderingContext2D
|
||||
private readonly size: number
|
||||
private readonly splatScale: number
|
||||
private readonly mask: Uint8Array
|
||||
private readonly raycaster = new THREE.Raycaster()
|
||||
|
||||
// 250ms coverage cache (never recount more often than that).
|
||||
private cache: CoverageReport | null = null
|
||||
private cacheTime = -1e9
|
||||
|
||||
// scratch vectors (avoid per-splat allocation)
|
||||
private readonly _center = new THREE.Vector3()
|
||||
private readonly _dir = new THREE.Vector3()
|
||||
private readonly _origin = new THREE.Vector3()
|
||||
|
||||
constructor(mesh: THREE.Mesh, opts: PaintSkinOptions = {}) {
|
||||
this.mesh = mesh
|
||||
this.size = opts.size ?? 256
|
||||
this.splatScale = opts.splatScale ?? 0.16
|
||||
|
||||
mesh.geometry.computeBoundingSphere()
|
||||
this.boundingRadius = mesh.geometry.boundingSphere?.radius ?? 1
|
||||
|
||||
const canvas = document.createElement('canvas')
|
||||
canvas.width = canvas.height = this.size
|
||||
const ctx = canvas.getContext('2d')
|
||||
if (!ctx) throw new Error('PaintSkin: 2D canvas context unavailable')
|
||||
ctx.fillStyle = BASE_WHITE
|
||||
ctx.fillRect(0, 0, this.size, this.size)
|
||||
this.canvas = canvas
|
||||
this.ctx = ctx
|
||||
|
||||
this.mask = new Uint8Array(this.size * this.size) // 0 everywhere = all base
|
||||
|
||||
const tex = new THREE.CanvasTexture(canvas)
|
||||
tex.colorSpace = THREE.SRGBColorSpace
|
||||
tex.wrapS = THREE.RepeatWrapping // U seam wraps (sphere longitude)
|
||||
tex.wrapT = THREE.ClampToEdgeWrapping
|
||||
tex.anisotropy = 4
|
||||
this.texture = tex
|
||||
|
||||
// Attach as the base colour map. Keep material colour white so the map isn't
|
||||
// tinted; the canvas already carries the white base.
|
||||
const mat = mesh.material as THREE.MeshStandardMaterial
|
||||
mat.map = tex
|
||||
if (mat.color) mat.color.set('#ffffff')
|
||||
mat.needsUpdate = true
|
||||
}
|
||||
|
||||
// ---- public API (contracts.PaintSkin) -----------------------------------
|
||||
|
||||
applySplat(uv: THREE.Vector2, color: PaintColor, radius: number): void {
|
||||
const px = uv.x * this.size
|
||||
// CanvasTexture default flipY=true: uv v=0 samples canvas bottom row.
|
||||
const py = (1 - uv.y) * this.size
|
||||
const pr = this.pixelRadius(radius)
|
||||
const circles = this.makeSplatCircles(px, py, pr)
|
||||
this.stampMask(circles, colorIndex(color))
|
||||
this.stampCanvas(circles, PALETTE[color])
|
||||
this.texture.needsUpdate = true
|
||||
}
|
||||
|
||||
splatAtPoint(worldPoint: THREE.Vector3, color: PaintColor, radius: number): void {
|
||||
const uv = this.uvAtWorldPoint(worldPoint)
|
||||
if (uv) this.applySplat(uv, color, radius)
|
||||
}
|
||||
|
||||
coverage(): CoverageReport {
|
||||
const now = performance.now()
|
||||
if (this.cache && now - this.cacheTime < 250) return this.cache
|
||||
const counts = countBuckets(this.mask, COLOR_ORDER.length)
|
||||
this.cache = coverageReportFromCounts(counts, this.mask.length)
|
||||
this.cacheTime = now
|
||||
return this.cache
|
||||
}
|
||||
|
||||
cleanse(fraction: number): void {
|
||||
const f = Math.max(0, Math.min(1, fraction))
|
||||
if (f <= 0) return
|
||||
|
||||
if (f >= 1) {
|
||||
// Full clean: reset everything to base.
|
||||
this.mask.fill(0)
|
||||
this.ctx.fillStyle = BASE_WHITE
|
||||
this.ctx.fillRect(0, 0, this.size, this.size)
|
||||
this.invalidate()
|
||||
return
|
||||
}
|
||||
|
||||
// Partial: punch random circular "scrub" holes until ~fraction of the
|
||||
// currently-painted pixels are erased. Looks scrubbed rather than uniform.
|
||||
let painted = 0
|
||||
for (let i = 0; i < this.mask.length; i++) if (this.mask[i] !== 0) painted++
|
||||
if (painted === 0) return
|
||||
let target = Math.floor(painted * f)
|
||||
|
||||
this.ctx.fillStyle = BASE_WHITE
|
||||
let guard = 0
|
||||
while (target > 0 && guard++ < 20000) {
|
||||
const cx = Math.random() * this.size
|
||||
const cy = Math.random() * this.size
|
||||
const r = 6 + Math.random() * 12
|
||||
target -= this.eraseCircle(cx, cy, r)
|
||||
}
|
||||
this.invalidate()
|
||||
}
|
||||
|
||||
// ---- geometry: world point -> UV ----------------------------------------
|
||||
|
||||
private uvAtWorldPoint(worldPoint: THREE.Vector3): THREE.Vector2 | null {
|
||||
this.mesh.updateWorldMatrix(true, false)
|
||||
this.mesh.getWorldPosition(this._center)
|
||||
|
||||
this._dir.copy(worldPoint).sub(this._center)
|
||||
if (this._dir.lengthSq() < 1e-12) return null
|
||||
this._dir.normalize()
|
||||
|
||||
// Cast from just outside the bounding sphere, straight through the centre.
|
||||
// The near-surface hit is the point closest to the impact; THREE gives us the
|
||||
// barycentric-interpolated UV for free.
|
||||
const worldRadius = this.worldBoundingRadius()
|
||||
this._origin.copy(this._center).addScaledVector(this._dir, worldRadius * 2.2)
|
||||
this.raycaster.set(this._origin, this._dir.clone().negate())
|
||||
this.raycaster.near = 0
|
||||
this.raycaster.far = worldRadius * 4.4
|
||||
const hits = this.raycaster.intersectObject(this.mesh, false)
|
||||
const hit = hits.find((h) => h.uv)
|
||||
return hit?.uv ? hit.uv.clone() : null
|
||||
}
|
||||
|
||||
private worldBoundingRadius(): number {
|
||||
const s = this.mesh.getWorldScale(this._origin) // reuse scratch
|
||||
return this.boundingRadius * Math.max(s.x, s.y, s.z)
|
||||
}
|
||||
|
||||
private pixelRadius(radius: number): number {
|
||||
const px = (radius / this.boundingRadius) * this.size * this.splatScale
|
||||
return Math.max(4, Math.min(this.size * 0.4, px))
|
||||
}
|
||||
|
||||
// ---- splat rasterization (mask + canvas share the same circles) ----------
|
||||
|
||||
private makeSplatCircles(px: number, py: number, pr: number): SubCircle[] {
|
||||
const circles: SubCircle[] = [{ x: px, y: py, r: pr * 0.95 }]
|
||||
// irregular lobes
|
||||
const lobes = 3 + Math.floor(Math.random() * 3)
|
||||
for (let i = 0; i < lobes; i++) {
|
||||
const a = Math.random() * Math.PI * 2
|
||||
const d = pr * (0.35 + Math.random() * 0.55)
|
||||
circles.push({
|
||||
x: px + Math.cos(a) * d,
|
||||
y: py + Math.sin(a) * d,
|
||||
r: pr * (0.3 + Math.random() * 0.35),
|
||||
})
|
||||
}
|
||||
// stray droplets
|
||||
const drops = 2 + Math.floor(Math.random() * 3)
|
||||
for (let i = 0; i < drops; i++) {
|
||||
const a = Math.random() * Math.PI * 2
|
||||
const d = pr * (1.0 + Math.random() * 0.7)
|
||||
circles.push({
|
||||
x: px + Math.cos(a) * d,
|
||||
y: py + Math.sin(a) * d,
|
||||
r: pr * (0.08 + Math.random() * 0.14),
|
||||
})
|
||||
}
|
||||
return circles
|
||||
}
|
||||
|
||||
/** Hard-edged union of circles into the quantized mask, with U-seam wrap. */
|
||||
private stampMask(circles: SubCircle[], idx: number): void {
|
||||
const S = this.size
|
||||
for (const c of circles) {
|
||||
// Wrap in X (longitude seam). Draw the circle and, if near an edge, its
|
||||
// wrapped twin so a stamp straddling u=0/1 lands on both sides.
|
||||
const offsets = this.wrapOffsets(c.x, c.r, S)
|
||||
for (const ox of offsets) {
|
||||
const cx = c.x + ox
|
||||
const r2 = c.r * c.r
|
||||
const x0 = Math.max(0, Math.floor(cx - c.r))
|
||||
const x1 = Math.min(S - 1, Math.ceil(cx + c.r))
|
||||
const y0 = Math.max(0, Math.floor(c.y - c.r))
|
||||
const y1 = Math.min(S - 1, Math.ceil(c.y + c.r))
|
||||
for (let y = y0; y <= y1; y++) {
|
||||
const dy = y - c.y
|
||||
for (let x = x0; x <= x1; x++) {
|
||||
const dx = x - cx
|
||||
if (dx * dx + dy * dy <= r2) this.mask[y * S + x] = idx
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Soft, alpha-blended gradient blobs into the visible canvas, with wrap. */
|
||||
private stampCanvas(circles: SubCircle[], hex: string): void {
|
||||
const S = this.size
|
||||
const ctx = this.ctx
|
||||
const rgb = hexToRgb(hex)
|
||||
for (const c of circles) {
|
||||
const offsets = this.wrapOffsets(c.x, c.r, S)
|
||||
for (const ox of offsets) {
|
||||
const cx = c.x + ox
|
||||
const g = ctx.createRadialGradient(cx, c.y, c.r * 0.15, cx, c.y, c.r)
|
||||
g.addColorStop(0, `rgba(${rgb},1)`)
|
||||
g.addColorStop(0.7, `rgba(${rgb},0.95)`)
|
||||
g.addColorStop(1, `rgba(${rgb},0)`)
|
||||
ctx.fillStyle = g
|
||||
ctx.beginPath()
|
||||
ctx.arc(cx, c.y, c.r, 0, Math.PI * 2)
|
||||
ctx.fill()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Erase a scrub hole from both mask and canvas; returns pixels erased. */
|
||||
private eraseCircle(cx: number, cy: number, r: number): number {
|
||||
const S = this.size
|
||||
let erased = 0
|
||||
const r2 = r * r
|
||||
const x0 = Math.max(0, Math.floor(cx - r))
|
||||
const x1 = Math.min(S - 1, Math.ceil(cx + r))
|
||||
const y0 = Math.max(0, Math.floor(cy - r))
|
||||
const y1 = Math.min(S - 1, Math.ceil(cy + r))
|
||||
for (let y = y0; y <= y1; y++) {
|
||||
const dy = y - cy
|
||||
for (let x = x0; x <= x1; x++) {
|
||||
const dx = x - cx
|
||||
if (dx * dx + dy * dy <= r2) {
|
||||
const i = y * S + x
|
||||
if (this.mask[i] !== 0) {
|
||||
this.mask[i] = 0
|
||||
erased++
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
this.ctx.beginPath()
|
||||
this.ctx.arc(cx, cy, r, 0, Math.PI * 2)
|
||||
this.ctx.fill() // fillStyle already BASE_WHITE at call sites
|
||||
return erased
|
||||
}
|
||||
|
||||
private wrapOffsets(x: number, r: number, S: number): number[] {
|
||||
if (x - r < 0) return [0, S]
|
||||
if (x + r > S) return [0, -S]
|
||||
return [0]
|
||||
}
|
||||
|
||||
private invalidate(): void {
|
||||
this.texture.needsUpdate = true
|
||||
this.cache = null
|
||||
this.cacheTime = -1e9
|
||||
}
|
||||
}
|
||||
|
||||
function hexToRgb(hex: string): string {
|
||||
const h = hex.replace('#', '')
|
||||
const n = parseInt(h, 16)
|
||||
return `${(n >> 16) & 255},${(n >> 8) & 255},${n & 255}`
|
||||
}
|
||||
28
src/world.ts
@ -49,27 +49,47 @@ export async function createWorld(container: HTMLElement): Promise<World> {
|
||||
const frameHooks: Array<(dt: number) => void> = []
|
||||
const events = makeEvents()
|
||||
|
||||
const step = () => {
|
||||
physics.step()
|
||||
for (const s of systems) s.update(FIXED_DT)
|
||||
}
|
||||
|
||||
const world: World = {
|
||||
scene, camera, renderer, physics, rapier: RAPIER, events,
|
||||
addSystem: (s) => void systems.push(s),
|
||||
onFrame: (fn) => void frameHooks.push(fn),
|
||||
tick(steps = 1) {
|
||||
for (let i = 0; i < steps; i++) step()
|
||||
},
|
||||
renderOnce() {
|
||||
for (const fn of frameHooks) fn(FIXED_DT)
|
||||
renderer.render(scene, camera)
|
||||
},
|
||||
start() {
|
||||
let last = performance.now()
|
||||
let acc = 0
|
||||
const loop = (now: number) => {
|
||||
requestAnimationFrame(loop)
|
||||
const advance = (now: number) => {
|
||||
const dt = Math.min((now - last) / 1000, 0.1)
|
||||
last = now
|
||||
acc += dt
|
||||
while (acc >= FIXED_DT) {
|
||||
physics.step()
|
||||
for (const s of systems) s.update(FIXED_DT)
|
||||
step()
|
||||
acc -= FIXED_DT
|
||||
}
|
||||
return dt
|
||||
}
|
||||
const loop = (now: number) => {
|
||||
requestAnimationFrame(loop)
|
||||
const dt = advance(now)
|
||||
for (const fn of frameHooks) fn(dt)
|
||||
renderer.render(scene, camera)
|
||||
}
|
||||
requestAnimationFrame(loop)
|
||||
// Hidden tabs suspend rAF entirely — keep the sim alive on a coarse
|
||||
// interval so the game (and headless verification) survives backgrounding.
|
||||
setInterval(() => {
|
||||
if (performance.now() - last > 500) advance(performance.now())
|
||||
}, 250)
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
18
tools/mesh-wave-1.json
Normal file
@ -0,0 +1,18 @@
|
||||
[
|
||||
{
|
||||
"name": "blobbo-base",
|
||||
"prompt": "Single friendly white blob mascot creature standing in a relaxed T-pose, arms slightly out, soft rounded michelin-man-like body, big cute glossy black eyes, tiny smile, stubby legs, three-quarter front view, full body centered, clean flat white background, no shadow, 3D game character render style"
|
||||
},
|
||||
{
|
||||
"name": "prop-toaster-launcher",
|
||||
"prompt": "Single cartoon chrome toaster as a game prop, chunky friendly toy-like design, oversized launch lever on the side, slightly tilted three-quarter view, whole object centered and fully visible, clean flat white background, no shadow, 3D game asset render style"
|
||||
},
|
||||
{
|
||||
"name": "prop-spring-boot",
|
||||
"prompt": "Single cartoon giant red rubber boot mounted on a big metal coil spring, chunky toy-like game prop design, three-quarter view, whole object centered and fully visible, clean flat white background, no shadow, 3D game asset render style"
|
||||
},
|
||||
{
|
||||
"name": "prop-paint-bucket",
|
||||
"prompt": "Single cartoon metal paint bucket brimming with glossy bright red paint, one thick drip over the rim, chunky toy-like game prop design, three-quarter view, whole object centered and fully visible, clean flat white background, no shadow, 3D game asset render style"
|
||||
}
|
||||
]
|
||||
99
tools/mesh_pipeline.py
Normal file
@ -0,0 +1,99 @@
|
||||
#!/usr/bin/env python3
|
||||
"""mesh_pipeline.py — flux image -> bg_remove -> image->GLB, per spec entry.
|
||||
|
||||
Usage: mesh_pipeline.py tools/mesh-wave-1.json assets/meshes/
|
||||
Retries each farm job up to 3x (the m4 node's broken mflux venv 126s any flux
|
||||
job routed to it — resubmission re-rolls the load-balancer). Skips any entry
|
||||
whose .glb already exists, so reruns are cheap.
|
||||
"""
|
||||
import json, os, sys, time
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
import mb
|
||||
|
||||
RETRIES = 3
|
||||
|
||||
|
||||
def run_job(operator, params=None, asset_id=None, label=''):
|
||||
for attempt in range(1, RETRIES + 1):
|
||||
jid = None
|
||||
while jid is None:
|
||||
jid = mb.submit(operator, params, asset_id=asset_id)
|
||||
if jid is None:
|
||||
print(f'[pipe] {label}: throttled, 15s', flush=True)
|
||||
time.sleep(15)
|
||||
st = mb.wait(jid, f'{label} ({operator} try {attempt})', interval=10)
|
||||
if st == 'done':
|
||||
return jid
|
||||
time.sleep(5)
|
||||
raise RuntimeError(f'{label}: {operator} failed {RETRIES}x')
|
||||
|
||||
|
||||
PRIMARY = 'm3ultra@100.89.131.57'
|
||||
|
||||
|
||||
def scp_from_outdir(jid, out, suffix='.glb'):
|
||||
"""Fallback for a farm bug (2026-07-17): hunyuan jobs run on remote workers
|
||||
ship outputs back to the primary's job outdir but never register them as
|
||||
assets. Pull the textured mesh (not *_shape.glb) straight from outdir."""
|
||||
import subprocess
|
||||
j = mb.req(f'/api/jobs/{jid}')
|
||||
outdir = j.get('outdir')
|
||||
if not outdir:
|
||||
return False
|
||||
ls = subprocess.run(['ssh', '-o', 'ConnectTimeout=8', '-o', 'BatchMode=yes',
|
||||
PRIMARY, f'ls {outdir}'], capture_output=True, text=True)
|
||||
names = [n for n in ls.stdout.split()
|
||||
if n.endswith(suffix) and not n.endswith('_shape.glb')]
|
||||
if not names:
|
||||
return False
|
||||
r = subprocess.run(['scp', '-o', 'ConnectTimeout=8', '-o', 'BatchMode=yes',
|
||||
f'{PRIMARY}:{outdir}/{names[0]}', out], capture_output=True)
|
||||
return r.returncode == 0 and os.path.exists(out)
|
||||
|
||||
|
||||
def fetch_output(jid, out, suffix=None):
|
||||
outs = mb.outputs_for(jid, suffix)
|
||||
if outs:
|
||||
n = mb.fetch(outs[0]['id'], out)
|
||||
print(f'[pipe] wrote {out} ({n//1024}KB)', flush=True)
|
||||
return
|
||||
if suffix and scp_from_outdir(jid, out, suffix):
|
||||
print(f'[pipe] wrote {out} ({os.path.getsize(out)//1024}KB, outdir fallback)', flush=True)
|
||||
return
|
||||
raise RuntimeError(f'no output asset for {jid} (api + outdir fallback both empty)')
|
||||
|
||||
|
||||
def main(spec_path, outdir):
|
||||
specs = json.load(open(spec_path))
|
||||
srcdir = os.path.join(outdir, 'src')
|
||||
os.makedirs(srcdir, exist_ok=True)
|
||||
failed = []
|
||||
for s in specs:
|
||||
name = s['name']
|
||||
glb = os.path.join(outdir, f'{name}.glb')
|
||||
if os.path.exists(glb):
|
||||
print(f'[pipe] {name}: glb exists, skip', flush=True)
|
||||
continue
|
||||
try:
|
||||
img = os.path.join(srcdir, f'{name}.png')
|
||||
if not os.path.exists(img):
|
||||
jid = run_job('flux_local', {'prompt': s['prompt']}, label=name)
|
||||
fetch_output(jid, img)
|
||||
cut = os.path.join(srcdir, f'{name}-cut.png')
|
||||
if not os.path.exists(cut):
|
||||
aid = mb.upload(img)
|
||||
jid = run_job('bg_remove_local', {}, asset_id=aid, label=name)
|
||||
fetch_output(jid, cut)
|
||||
aid = mb.upload(cut)
|
||||
jid = run_job('hunyuan3d_mlx', {}, asset_id=aid, label=name)
|
||||
fetch_output(jid, glb, suffix='.glb')
|
||||
except Exception as e:
|
||||
print(f'[pipe] {name}: FAILED — {e}', flush=True)
|
||||
failed.append(name)
|
||||
ok = len(specs) - len(failed)
|
||||
print(f'[pipe] mesh wave complete: {ok}/{len(specs)} ok'
|
||||
+ (f' — failed: {failed}' if failed else ''), flush=True)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main(sys.argv[1], sys.argv[2])
|
||||