Lane G: colour zones, paint puddles, MEGA/MINI scale buffs
Fixes the live finding that a racer can finish with 0% paint. Paint is now ROUTE-guaranteed by ground puddles, and colours come in single-colour zones so buffs actually cross the 20% threshold. - paint/puddles.ts: PaintPuddle + installPuddles. Flat coloured strips (no collider) that stamp the blob's underside via skin.splatAtPoint on a ~6/s cadence while rolling through, emitting paint:splatted. The body doesn't physically tumble (rotations locked), so an accumulated roll phase walks the contact point along the travel great circle — a straight run paints a stripe and coverage climbs instead of piling on the south pole. - paint/coverage-math.ts: extend computeModifiers with purple MEGA (size→1.45 + heavier) and pink MINI (size→0.62 + lighter than clean at high %). They fight: net size uses (purple − pink) strength; mass floored so MINI stays positive. (Lives here, not buffs.ts, because the pure mapping must stay THREE-free for the bare-node test; BuffSystem already forwards mods.size/massMul unchanged.) - paint/coverage-math.test.ts: +MEGA/MINI/fight cases (48 assertions pass). - course/zones.ts: installZones lays RED/GREEN/BLUE puddles down the centre line and the PURPLE(left)/PINK(right) fork onto the machine district — a pressure plate (baseMass*1.25) + boot on the MEGA lane, a visibly-low pink tunnel (underside y≈1.12) on the MINI lane. Returns cannon configs for integration. - demo (demos/lane-g.html + src/demo/lane-g.ts): drive the zones, F = hands-free hold-forward, 1-4 teleport to each zone, live coverage/size/mass + plate HUD. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
fedb43817c
commit
3699a1635a
26
demos/lane-g.html
Normal file
26
demos/lane-g.html
Normal file
@ -0,0 +1,26 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head><meta charset="utf-8" /><title>BLOBBO lane g demo</title>
|
||||||
|
<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;max-width:340px}
|
||||||
|
#legend b{color:#FF6EB4}
|
||||||
|
#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 G: zones · puddles · MEGA/MINI</b><br>
|
||||||
|
WASD move · Space jump<br>
|
||||||
|
<span class="k">F</span>AUTO hold-forward (hands-free, ≥40% by route)<br>
|
||||||
|
<span class="k">1</span>teleport RED start · <span class="k">2</span>BLUE river<br>
|
||||||
|
<span class="k">3</span>PURPLE/MEGA fork · <span class="k">4</span>PINK/MINI tunnel<br>
|
||||||
|
<span class="k">X</span>full clean · <span class="k">C</span>cleanse 50%<br>
|
||||||
|
<span class="k">R</span>respawn · <span class="k">P</span>state to console · <span class="k">H</span>HUD
|
||||||
|
</div>
|
||||||
|
<script type="module" src="/src/demo/lane-g.ts"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
219
src/course/zones.ts
Normal file
219
src/course/zones.ts
Normal file
@ -0,0 +1,219 @@
|
|||||||
|
/**
|
||||||
|
* Colour zones (Lane G) — laid ON the existing greybox (course spans z 30 → -70,
|
||||||
|
* see greybox.ts). Two jobs:
|
||||||
|
*
|
||||||
|
* 1. PAINT BY ROUTE. Each stretch of track gets a single-colour puddle run so a
|
||||||
|
* racer picks up ONE dominant colour there instead of confetti. That matters
|
||||||
|
* because the buff threshold is 20% of a single colour (coverage-math.ts) —
|
||||||
|
* three colours diluting each other never crossed it (observed live). One
|
||||||
|
* colour per zone makes buffs actually activate.
|
||||||
|
*
|
||||||
|
* 2. THE MEGA/MINI TEACHING FORK (machine district, z -37..-58). Paint = size =
|
||||||
|
* route:
|
||||||
|
* LEFT lane (x<0): PURPLE puddle → MEGA (grow + heavy) → trips a pressure
|
||||||
|
* plate → SpringBoot yeet. The course teaches paint=weight.
|
||||||
|
* RIGHT lane (x>3): PINK puddle → MINI (shrink) → fits a deliberately LOW
|
||||||
|
* tunnel (pink roof slab at y≈1.0) that shortcuts toward
|
||||||
|
* the finish. A normal/MEGA blob bonks the roof.
|
||||||
|
*
|
||||||
|
* Zone → colour → buff intent (GDD §6):
|
||||||
|
* RED ramp approach BURN speed helps the climb
|
||||||
|
* GREEN shelves + slope GRIP helps the slope / future gravity flips
|
||||||
|
* BLUE milk river SLICK waterproof — your paint survives the water
|
||||||
|
* PURPLE left fork MEGA heavy → pressure-plate route
|
||||||
|
* PINK right fork MINI small → tunnel shortcut
|
||||||
|
*
|
||||||
|
* Cannon placement decision: installZones does NOT instantiate PaintCannons. It
|
||||||
|
* RETURNS `cannonConfigs` (plain data) so integration builds them through the one
|
||||||
|
* cannon-staging path it already owns in game.ts (target=blob.mesh, muzzle tuning,
|
||||||
|
* etc.), avoiding double-placement. Puddles are the guaranteed paint; cannons are
|
||||||
|
* bonus pressure, so ignoring the configs still satisfies the ≥40% route rule.
|
||||||
|
*/
|
||||||
|
import * as THREE from 'three'
|
||||||
|
import type { Blob, PaintColor, World } from '../contracts'
|
||||||
|
import { PALETTE } from '../contracts'
|
||||||
|
import type { PaintSkin } from '../paint/skin'
|
||||||
|
import { installPuddles, type PaintPuddle, type PaintPuddleConfig } from '../paint/puddles'
|
||||||
|
import { createPressurePlate, createSpringBoot, type Vec3 } from '../machine'
|
||||||
|
|
||||||
|
/** A cannon integration should stage for a zone (fires that zone's colour). */
|
||||||
|
export interface ZoneCannonConfig {
|
||||||
|
color: PaintColor
|
||||||
|
position: Vec3
|
||||||
|
/** Fires on `machine:signal {id}` matching this (Lane C wiring). */
|
||||||
|
triggerId: string
|
||||||
|
/** Suggested seconds between auto-fires. */
|
||||||
|
interval: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TunnelInfo {
|
||||||
|
/** World Y of the roof's underside — only blobs shorter than this fit. */
|
||||||
|
roofUndersideY: number
|
||||||
|
center: Vec3
|
||||||
|
/** Roof slab half-extents [x, y, z]. */
|
||||||
|
half: Vec3
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ZonesHandle {
|
||||||
|
puddles: PaintPuddle[]
|
||||||
|
cannonConfigs: ZoneCannonConfig[]
|
||||||
|
/** Left-fork pressure plate: trips at baseMass*1.25 (needs a MEGA blob). */
|
||||||
|
plateId: string
|
||||||
|
plateThreshold: number
|
||||||
|
bootSignal: string
|
||||||
|
tunnel: TunnelInfo
|
||||||
|
totalStamps(): number
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Puddle layout — absolute course coordinates (see greybox.ts surface heights).
|
||||||
|
// base floor top y≈0 · shelves top y≈3.5 · river top y≈0.12 · machine top y≈0.24
|
||||||
|
// Centre-line strips (x≈0) coat a hold-forward run; fork strips sit off-centre.
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
const PUDDLE_LAYOUT: PaintPuddleConfig[] = [
|
||||||
|
// RED zone (ramp approach, z≈22..8) — wide centre strip off the plateau + onto
|
||||||
|
// the ramp base. Generous so BURN reliably activates before the climb.
|
||||||
|
{ color: 'red', position: [0, 0.05, 18.5], size: [9, 9], splatRadius: 0.34 },
|
||||||
|
{ color: 'red', position: [0, 1.1, 12], size: [8, 6], splatRadius: 0.34 },
|
||||||
|
|
||||||
|
// GREEN zone (shelves + slope, z≈8..-24) — both blue shelves are flat & always
|
||||||
|
// rolled, so GRIP activates there; a strip on the slope tops it up.
|
||||||
|
{ color: 'green', position: [0, 3.55, 6], size: [11, 7], splatRadius: 0.34 },
|
||||||
|
{ color: 'green', position: [0, 3.55, -6], size: [11, 7], splatRadius: 0.34 },
|
||||||
|
{ color: 'green', position: [0, 1.5, -15.5], size: [9, 6], splatRadius: 0.34 },
|
||||||
|
|
||||||
|
// BLUE zone (milk river, z≈-24..-37) — long centre strip across the crossing so
|
||||||
|
// SLICK/waterproof is up before you hit the water.
|
||||||
|
{ color: 'blue', position: [0, 0.15, -30], size: [11, 13], splatRadius: 0.36 },
|
||||||
|
|
||||||
|
// PURPLE/PINK fork (machine district, z≈-37..-58) on the machine-area floor.
|
||||||
|
// Strips sit at the district ENTRANCE so the blob is fully MEGA/MINI BEFORE it
|
||||||
|
// reaches the plate / tunnel further in.
|
||||||
|
{ color: 'purple', position: [-6, 0.27, -40], size: [6, 7], splatRadius: 0.34 },
|
||||||
|
{ color: 'pink', position: [6.5, 0.27, -40], size: [6, 7], splatRadius: 0.34 },
|
||||||
|
]
|
||||||
|
|
||||||
|
// Fork geometry constants (obstacles sit PAST the entrance strips).
|
||||||
|
const PLATE_POS: Vec3 = [-6, 0.24, -47]
|
||||||
|
const BOOT_POS: Vec3 = [-6, 0.2, -49.5]
|
||||||
|
const PLATE_ID = 'zone-plate-purple'
|
||||||
|
const BOOT_SIGNAL = 'zone-boot-purple'
|
||||||
|
|
||||||
|
// Roof underside = 1.27 - 0.15 = 1.12. On the machine floor (top y≈0.24) a blob
|
||||||
|
// rests with its belly down, so its top ≈ 0.24 + 2·(r·size):
|
||||||
|
// normal (size 1.0) → top ≈ 1.24 → BONK
|
||||||
|
// ≥35% pink (≤0.846) → top ≤ 1.086 → CLEARS
|
||||||
|
// full MINI (0.62) → top ≈ 0.86 → easy clearance
|
||||||
|
const TUNNEL_CENTER: Vec3 = [6.5, 1.27, -51]
|
||||||
|
const TUNNEL_HALF: Vec3 = [3.5, 0.15, 7] // spans z -58..-44
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build the colour zones + the MEGA/MINI fork onto the live greybox and wire the
|
||||||
|
* puddle paint system. Integration calls this once, after the blob/skin exist.
|
||||||
|
*/
|
||||||
|
export function installZones(world: World, blob: Blob, skin: PaintSkin): ZonesHandle {
|
||||||
|
const { scene, physics, rapier } = world
|
||||||
|
|
||||||
|
// ---- puddles (guaranteed paint by route) ----
|
||||||
|
const puddlesHandle = installPuddles(world, blob, skin, PUDDLE_LAYOUT)
|
||||||
|
|
||||||
|
// ---- LEFT fork: PURPLE → MEGA → pressure plate → boot yeet ----
|
||||||
|
// Clean blob mass is ~1; a MEGA (purple) blob is heavy enough to cross *1.25.
|
||||||
|
const baseMass = blob.body.mass()
|
||||||
|
const plateThreshold = baseMass * 1.25
|
||||||
|
createPressurePlate(world, {
|
||||||
|
id: PLATE_ID,
|
||||||
|
position: PLATE_POS,
|
||||||
|
massThreshold: plateThreshold,
|
||||||
|
emits: BOOT_SIGNAL,
|
||||||
|
size: [4, 4],
|
||||||
|
})
|
||||||
|
createSpringBoot(world, {
|
||||||
|
id: 'zone-boot',
|
||||||
|
position: BOOT_POS,
|
||||||
|
impulse: baseMass * 20, // sized for a heavy MEGA blob to carry toward the finish
|
||||||
|
direction: [0, 1, -0.7], // up + forward (-Z)
|
||||||
|
onSignal: BOOT_SIGNAL,
|
||||||
|
strikeSize: [2.6, 1.4, 3],
|
||||||
|
})
|
||||||
|
|
||||||
|
// ---- RIGHT fork: PINK → MINI → LOW TUNNEL shortcut ----
|
||||||
|
buildTunnel(scene, physics, rapier)
|
||||||
|
|
||||||
|
// ---- cannons (returned as data for integration to stage) ----
|
||||||
|
const cannonConfigs: ZoneCannonConfig[] = [
|
||||||
|
// RED crossfire: the existing red position + a mirror across the lane.
|
||||||
|
{ color: 'red', position: [-9, 2.5, 14], triggerId: 'cannon-red', interval: 2.2 },
|
||||||
|
{ color: 'red', position: [9, 2.5, 14], triggerId: 'cannon-red-2', interval: 2.2 },
|
||||||
|
// GREEN over the shelves.
|
||||||
|
{ color: 'green', position: [9, 4.8, -6], triggerId: 'cannon-green', interval: 2.2 },
|
||||||
|
// BLUE over the river.
|
||||||
|
{ color: 'blue', position: [-9, 2.0, -30], triggerId: 'cannon-blue', interval: 2.4 },
|
||||||
|
// Fork colours fire on their own lanes.
|
||||||
|
{ color: 'purple', position: [-11, 2.0, -43], triggerId: 'cannon-purple', interval: 2.6 },
|
||||||
|
{ color: 'pink', position: [12, 2.0, -43], triggerId: 'cannon-pink', interval: 2.6 },
|
||||||
|
]
|
||||||
|
|
||||||
|
return {
|
||||||
|
puddles: puddlesHandle.puddles,
|
||||||
|
cannonConfigs,
|
||||||
|
plateId: PLATE_ID,
|
||||||
|
plateThreshold,
|
||||||
|
bootSignal: BOOT_SIGNAL,
|
||||||
|
tunnel: { roofUndersideY: TUNNEL_CENTER[1] - TUNNEL_HALF[1], center: TUNNEL_CENTER, half: TUNNEL_HALF },
|
||||||
|
totalStamps: puddlesHandle.totalStamps,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The pink low tunnel: a visibly-low pink roof slab (collider) over the right
|
||||||
|
* lane, on stubby posts. Only a MINI blob clears its underside; taller blobs bonk
|
||||||
|
* and must take another lane. Posts are cosmetic (no lane-blocking colliders).
|
||||||
|
*/
|
||||||
|
function buildTunnel(
|
||||||
|
scene: THREE.Scene,
|
||||||
|
physics: World['physics'],
|
||||||
|
rapier: World['rapier'],
|
||||||
|
): void {
|
||||||
|
const [cx, cy, cz] = TUNNEL_CENTER
|
||||||
|
const [hx, hy, hz] = TUNNEL_HALF
|
||||||
|
|
||||||
|
const roofMat = new THREE.MeshStandardMaterial({
|
||||||
|
color: PALETTE.pink,
|
||||||
|
roughness: 0.4,
|
||||||
|
metalness: 0.1,
|
||||||
|
transparent: true,
|
||||||
|
opacity: 0.82,
|
||||||
|
emissive: PALETTE.pink,
|
||||||
|
emissiveIntensity: 0.18,
|
||||||
|
})
|
||||||
|
const roof = new THREE.Mesh(new THREE.BoxGeometry(hx * 2, hy * 2, hz * 2), roofMat)
|
||||||
|
roof.position.set(cx, cy, cz)
|
||||||
|
roof.castShadow = true
|
||||||
|
roof.receiveShadow = true
|
||||||
|
scene.add(roof)
|
||||||
|
// Solid roof collider — this is what bonks a too-tall blob.
|
||||||
|
physics.createCollider(
|
||||||
|
rapier.ColliderDesc.cuboid(hx, hy, hz).setTranslation(cx, cy, cz),
|
||||||
|
)
|
||||||
|
|
||||||
|
// "MIND YOUR HEAD" clearance bar hanging just under the lip so the low ceiling
|
||||||
|
// reads before you reach it (visual only).
|
||||||
|
const barMat = new THREE.MeshStandardMaterial({
|
||||||
|
color: PALETTE.pink, emissive: PALETTE.pink, emissiveIntensity: 0.4, roughness: 0.5,
|
||||||
|
})
|
||||||
|
const bar = new THREE.Mesh(new THREE.BoxGeometry(hx * 2, 0.06, 0.12), barMat)
|
||||||
|
bar.position.set(cx, cy - hy - 0.02, cz + hz) // entrance lip
|
||||||
|
scene.add(bar)
|
||||||
|
|
||||||
|
// cosmetic corner posts (no colliders — they don't block the lane)
|
||||||
|
const postMat = new THREE.MeshStandardMaterial({ color: '#c94f86', roughness: 0.6 })
|
||||||
|
for (const sx of [-1, 1]) {
|
||||||
|
for (const sz of [-1, 1]) {
|
||||||
|
const post = new THREE.Mesh(new THREE.CylinderGeometry(0.16, 0.16, cy, 10), postMat)
|
||||||
|
post.position.set(cx + sx * hx, cy * 0.5, cz + sz * hz)
|
||||||
|
post.castShadow = true
|
||||||
|
scene.add(post)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
203
src/demo/lane-g.ts
Normal file
203
src/demo/lane-g.ts
Normal file
@ -0,0 +1,203 @@
|
|||||||
|
/**
|
||||||
|
* Lane G demo — colour zones, paint puddles, and the MEGA/MINI scale fork.
|
||||||
|
*
|
||||||
|
* Builds the REAL greybox + blob + controller, then lays Lane G on top via
|
||||||
|
* installZones (puddles + purple/pink fork + low tunnel). Drive through and watch
|
||||||
|
* coverage climb by ROUTE — no aiming, no cannon luck. The HUD reads live coverage
|
||||||
|
* / modifiers, whether the MEGA pressure plate has tripped, and your size so you
|
||||||
|
* can see MEGA grow and MINI shrink.
|
||||||
|
*
|
||||||
|
* The three things this demo proves (acceptance):
|
||||||
|
* 1. A hold-forward run (press F) ends ≥40% painted with a buff active — puddles
|
||||||
|
* guarantee it. The console STATE line reports total% + active buffs.
|
||||||
|
* 2. PURPLE puddle → MEGA (grow + heavy) → trips the left pressure plate → boot
|
||||||
|
* yeet. A clean blob rolls over the plate without tripping it.
|
||||||
|
* 3. PINK puddle → MINI (shrink) → fits the low pink tunnel on the right. A
|
||||||
|
* normal/clean blob bonks the roof and can't pass.
|
||||||
|
*
|
||||||
|
* CONTROLS
|
||||||
|
* WASD / Arrows move (camera-relative) Space jump
|
||||||
|
* DEBUG KEYS
|
||||||
|
* F toggle AUTO hold-forward (hands-free straight run through the zones)
|
||||||
|
* 1 teleport to RED start 2 teleport to the BLUE river
|
||||||
|
* 3 teleport to the PURPLE (left/MEGA) fork mouth
|
||||||
|
* 4 teleport to the PINK (right/MINI) tunnel mouth
|
||||||
|
* X full cleanse (go clean — then try the tunnel/plate to see the difference)
|
||||||
|
* C cleanse 50%
|
||||||
|
* R respawn at start (also cleanses)
|
||||||
|
* P print a STATE snapshot to the console
|
||||||
|
* H toggle the HUD
|
||||||
|
* A STATE line prints once/second: pos, coverage %s, size/mass, active buffs, plate.
|
||||||
|
*/
|
||||||
|
import * as THREE from 'three'
|
||||||
|
import { createWorld } from '../world'
|
||||||
|
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, COLOR_ORDER } from '../paint'
|
||||||
|
import { installZones } from '../course/zones'
|
||||||
|
|
||||||
|
const world = await createWorld(document.getElementById('app')!)
|
||||||
|
const course = buildGreybox(world)
|
||||||
|
|
||||||
|
// ---- blob + Lane A layers (controller/feel/camera) ----
|
||||||
|
const blob = createBlob(world, { position: course.spawn })
|
||||||
|
blob.paint = new PaintSkin(blob.mesh, { size: 256 })
|
||||||
|
world.blob = blob
|
||||||
|
const skin = blob.paint as PaintSkin
|
||||||
|
|
||||||
|
world.addSystem(createBlobController(world, blob))
|
||||||
|
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))
|
||||||
|
const camera = createFollowCamera(world, blob)
|
||||||
|
world.onFrame((dt) => camera.update(dt))
|
||||||
|
|
||||||
|
// ---- buffs + inbound paint protocol ----
|
||||||
|
world.addSystem(new BuffSystem(world, { mesh: blob.mesh, modifiers: blob.modifiers, paint: skin }))
|
||||||
|
installPaint(world, { mesh: blob.mesh, paint: skin })
|
||||||
|
|
||||||
|
// ---- LANE G: zones (puddles) + purple/pink fork + low tunnel ----
|
||||||
|
const zones = installZones(world, blob, skin)
|
||||||
|
|
||||||
|
// ---- stage the returned cannon configs as bonus pressure (integration's job in
|
||||||
|
// the real game; done here so the demo shows cannons + puddles together) ----
|
||||||
|
for (const c of zones.cannonConfigs) {
|
||||||
|
world.addSystem(new PaintCannon({
|
||||||
|
world,
|
||||||
|
position: new THREE.Vector3(...c.position),
|
||||||
|
color: c.color,
|
||||||
|
target: blob.mesh,
|
||||||
|
paint: skin,
|
||||||
|
targetRadius: blob.radius,
|
||||||
|
triggerId: c.triggerId,
|
||||||
|
interval: c.interval,
|
||||||
|
muzzleSpeed: 26,
|
||||||
|
splatRadius: 0.45,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- demo-only gap trampoline (stands in for game.ts's real one) so an AUTO
|
||||||
|
// hold-forward run can clear the shelf gap instead of soft-locking in the slot ----
|
||||||
|
{
|
||||||
|
world.physics.createCollider(
|
||||||
|
world.rapier.ColliderDesc.cuboid(6, 0.25, 2).setTranslation(0, 0.25, 0))
|
||||||
|
const tramp = new THREE.Mesh(
|
||||||
|
new THREE.BoxGeometry(12, 0.5, 4),
|
||||||
|
new THREE.MeshStandardMaterial({ color: '#FF9500', roughness: 0.6 }))
|
||||||
|
tramp.position.set(0, 0.25, 0)
|
||||||
|
world.scene.add(tramp)
|
||||||
|
let cd = 0
|
||||||
|
world.addSystem({
|
||||||
|
update(dt) {
|
||||||
|
cd = Math.max(0, cd - dt)
|
||||||
|
const t = blob.body.translation()
|
||||||
|
const v = blob.body.linvel()
|
||||||
|
if (Math.abs(t.x) < 6 && t.z > -2.2 && t.z < 2.2 && t.y < 1.4 && v.y < 0.5 && cd === 0) {
|
||||||
|
blob.body.setLinvel({ x: v.x * 0.4, y: 14, z: Math.max(-3, Math.min(3, (0.5 - t.z) * 1.5)) }, true)
|
||||||
|
cd = 0.6
|
||||||
|
world.events.emit('blob:jumped', {})
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- MEGA plate telemetry: did purple weight trip it? ----
|
||||||
|
let plateTripped = false
|
||||||
|
world.events.on('machine:signal', (p: { id: string }) => {
|
||||||
|
if (p.id === zones.bootSignal) {
|
||||||
|
plateTripped = true
|
||||||
|
console.log('%c[zone] PRESSURE PLATE TRIPPED — MEGA is heavy enough. BOOT YEET!', 'color:#AF52DE;font-weight:700')
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// ---- AUTO hold-forward (dispatch a held KeyW so the real controller drives) ----
|
||||||
|
let auto = false
|
||||||
|
function setAuto(on: boolean) {
|
||||||
|
auto = on
|
||||||
|
const type = on ? 'keydown' : 'keyup'
|
||||||
|
dispatchEvent(new KeyboardEvent(type, { code: 'KeyW' }))
|
||||||
|
console.log(`[demo] AUTO hold-forward ${on ? 'ON' : 'OFF'}`)
|
||||||
|
}
|
||||||
|
// re-assert the held key each second (some browsers coalesce synthetic repeats)
|
||||||
|
world.onFrame(() => { if (auto) dispatchEvent(new KeyboardEvent('keydown', { code: 'KeyW' })) })
|
||||||
|
|
||||||
|
function 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)
|
||||||
|
skin.cleanse(1)
|
||||||
|
plateTripped = false
|
||||||
|
}
|
||||||
|
function teleport(x: number, y: number, z: number, label: string) {
|
||||||
|
blob.body.setTranslation({ x, y, z }, true)
|
||||||
|
blob.body.setLinvel({ x: 0, y: 0, z: 0 }, true)
|
||||||
|
console.log(`[demo] teleport → ${label}`)
|
||||||
|
}
|
||||||
|
world.onFrame(() => { if (blob.body.translation().y < -25) respawn() })
|
||||||
|
|
||||||
|
// ---- HUD (declared before the key handler that toggles it) ----
|
||||||
|
const hud = new PaintHUD()
|
||||||
|
|
||||||
|
addEventListener('keydown', (e) => {
|
||||||
|
switch (e.code) {
|
||||||
|
case 'KeyF': setAuto(!auto); break
|
||||||
|
case 'Digit1': teleport(0, 2.5, 20, 'RED start'); break
|
||||||
|
case 'Digit2': teleport(0, 1.0, -26, 'BLUE river'); break
|
||||||
|
case 'Digit3': teleport(-6, 1.0, -37, 'PURPLE / MEGA fork mouth'); break
|
||||||
|
case 'Digit4': teleport(6.5, 0.8, -37, 'PINK / MINI tunnel mouth'); break
|
||||||
|
case 'KeyX': skin.cleanse(1); console.log('[demo] full cleanse — blob is CLEAN'); break
|
||||||
|
case 'KeyC': skin.cleanse(0.5); console.log('[demo] cleanse 50%'); break
|
||||||
|
case 'KeyR': respawn(); break
|
||||||
|
case 'KeyP': console.log('[STATE]', JSON.stringify(snapshot())); break
|
||||||
|
case 'KeyH': hud.root.style.display = hud.root.style.display === 'none' ? '' : 'none'; break
|
||||||
|
default: return
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// ---- telemetry -------------------------------------------------------------
|
||||||
|
function snapshot() {
|
||||||
|
const cov = skin.coverage()
|
||||||
|
const t = blob.body.translation()
|
||||||
|
const m = blob.modifiers
|
||||||
|
return {
|
||||||
|
pos: [+t.x.toFixed(1), +t.y.toFixed(1), +t.z.toFixed(1)],
|
||||||
|
total: +(cov.total * 100).toFixed(0),
|
||||||
|
colours: COLOR_ORDER.filter((c) => cov.byColor[c] > 0.005).map((c) => `${c}:${(cov.byColor[c] * 100).toFixed(0)}%`),
|
||||||
|
size: +m.size.toFixed(2),
|
||||||
|
mass: +m.massMul.toFixed(2),
|
||||||
|
stamps: zones.totalStamps(),
|
||||||
|
plateThreshold: +zones.plateThreshold.toFixed(2),
|
||||||
|
plateTripped,
|
||||||
|
tunnelUndersideY: zones.tunnel.roofUndersideY,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let acc = 0
|
||||||
|
world.onFrame((dt) => {
|
||||||
|
const cov = skin.coverage()
|
||||||
|
hud.update(cov, blob.modifiers)
|
||||||
|
acc += dt
|
||||||
|
if (acc >= 1) {
|
||||||
|
acc = 0
|
||||||
|
const s = snapshot()
|
||||||
|
const m = blob.modifiers
|
||||||
|
const buffs = [
|
||||||
|
m.speedMul > 1.001 ? `speed×${m.speedMul.toFixed(2)}` : '',
|
||||||
|
m.grip > 0.001 ? `grip${m.grip.toFixed(2)}` : '',
|
||||||
|
m.waterproof ? 'waterproof' : '',
|
||||||
|
m.size > 1.05 ? `MEGA×${m.size.toFixed(2)}` : m.size < 0.95 ? `MINI×${m.size.toFixed(2)}` : '',
|
||||||
|
m.glow > 0 ? 'SUPER' : '',
|
||||||
|
].filter(Boolean).join(' ') || '(none)'
|
||||||
|
console.log(
|
||||||
|
`[STATE] pos=(${s.pos.join(',')}) total=${s.total}% ${s.colours.join(' ') || '(clean)'} | ` +
|
||||||
|
`size=${s.size} mass=${s.mass} | buffs: ${buffs} | plate ${plateTripped ? 'TRIPPED' : 'armed'} | stamps=${s.stamps}`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
world.start()
|
||||||
|
console.log('%cBLOBBO Lane G — colour zones · puddles · MEGA/MINI', 'color:#AF52DE;font-weight:700')
|
||||||
|
console.log('WASD move · Space jump · F auto-forward · 1-4 teleport zones · X clean · C cleanse50 · R respawn · P state · H hud')
|
||||||
@ -76,7 +76,10 @@ export class BuffSystem implements System {
|
|||||||
|
|
||||||
const cov = paint.coverage() // 250ms-cached; no per-frame readback
|
const cov = paint.coverage() // 250ms-cached; no per-frame readback
|
||||||
const mods = computeModifiers(cov)
|
const mods = computeModifiers(cov)
|
||||||
// copy into the shared modifiers object (readers hold this reference)
|
// Copy into the shared modifiers object (readers hold this reference).
|
||||||
|
// NOTE: `size` + `massMul` now also carry the purple MEGA / pink MINI scale
|
||||||
|
// buffs (Lane G) straight out of computeModifiers — the controller reads
|
||||||
|
// `size` to scale the collider + visuals, so no extra work is needed here.
|
||||||
const m = this.blob.modifiers
|
const m = this.blob.modifiers
|
||||||
m.speedMul = mods.speedMul
|
m.speedMul = mods.speedMul
|
||||||
m.jumpMul = mods.jumpMul
|
m.jumpMul = mods.jumpMul
|
||||||
|
|||||||
@ -9,6 +9,11 @@ import type { CoverageReport, PaintColor } from '../contracts'
|
|||||||
import {
|
import {
|
||||||
ACTIVATE,
|
ACTIVATE,
|
||||||
SUPER,
|
SUPER,
|
||||||
|
MEGA_SIZE,
|
||||||
|
MINI_SIZE,
|
||||||
|
MEGA_MASS,
|
||||||
|
MINI_MASS,
|
||||||
|
MASS_FLOOR,
|
||||||
buffStrength,
|
buffStrength,
|
||||||
computeModifiers,
|
computeModifiers,
|
||||||
countBuckets,
|
countBuckets,
|
||||||
@ -109,9 +114,65 @@ near(buffStrength(0.45), 0.15 + 0.85 * 0.5, 'strength ramps linearly at midpoint
|
|||||||
ok(m.waterproof === true, 'blue 25% waterproof')
|
ok(m.waterproof === true, 'blue 25% waterproof')
|
||||||
}
|
}
|
||||||
{
|
{
|
||||||
// massMul clamps at 1.8 when fully painted
|
// massMul clamps at 1.8 when fully painted (no purple/pink → base only)
|
||||||
const m = computeModifiers(cov({ red: 0.5, blue: 0.5 }))
|
const m = computeModifiers(cov({ red: 0.5, blue: 0.5 }))
|
||||||
near(m.massMul, 1.8, 'full coverage massMul 1.8')
|
near(m.massMul, 1.8, 'full coverage massMul 1.8')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---- purple MEGA (grow + heavy) ------------------------------------------
|
||||||
|
{
|
||||||
|
// below 20% purple → no size change, no extra mass
|
||||||
|
const m = computeModifiers(cov({ purple: 0.19 }))
|
||||||
|
near(m.size, 1, 'purple 19% → size unchanged')
|
||||||
|
near(m.massMul, 1 + 0.8 * 0.19, 'purple 19% → base mass only')
|
||||||
|
}
|
||||||
|
{
|
||||||
|
// purple super (70%) → size maxed to 1.45, MEGA mass added, glow
|
||||||
|
const m = computeModifiers(cov({ purple: 0.7 }))
|
||||||
|
near(m.size, 1 + MEGA_SIZE, 'purple 70% → size 1.45')
|
||||||
|
near(m.massMul, 1 + 0.8 * 0.7 + MEGA_MASS, 'purple 70% → base + full MEGA mass')
|
||||||
|
near(m.glow, 1, 'purple 70% is super (glow)')
|
||||||
|
ok(m.massMul > 1.25, 'MEGA is heavy enough to trip a *1.25 plate')
|
||||||
|
}
|
||||||
|
{
|
||||||
|
// purple mid (45%) → partial grow via buffStrength
|
||||||
|
const s = 0.15 + 0.85 * 0.5
|
||||||
|
const m = computeModifiers(cov({ purple: 0.45 }))
|
||||||
|
near(m.size, 1 + MEGA_SIZE * s, 'purple 45% → partial grow')
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- pink MINI (shrink + light) ------------------------------------------
|
||||||
|
{
|
||||||
|
// below 20% pink → no size change
|
||||||
|
const m = computeModifiers(cov({ pink: 0.19 }))
|
||||||
|
near(m.size, 1, 'pink 19% → size unchanged')
|
||||||
|
}
|
||||||
|
{
|
||||||
|
// pink super (70%) → size shrunk to 0.62, lighter than clean
|
||||||
|
const m = computeModifiers(cov({ pink: 0.7 }))
|
||||||
|
near(m.size, 1 - MINI_SIZE, 'pink 70% → size 0.62')
|
||||||
|
ok(m.size < 1, 'pink shrinks the blob')
|
||||||
|
near(m.massMul, 1 + 0.8 * 0.7 - MINI_MASS, 'pink 70% → base mass minus full MINI reduction')
|
||||||
|
ok(m.massMul < 1, 'MINI is lighter than a clean blob at high %')
|
||||||
|
}
|
||||||
|
{
|
||||||
|
// pink can never drive mass to/below zero (MASS_FLOOR guards it)
|
||||||
|
const m = computeModifiers(cov({ pink: 1 }))
|
||||||
|
ok(m.massMul >= MASS_FLOOR, 'pink mass floored at MASS_FLOOR')
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- purple vs pink fight -------------------------------------------------
|
||||||
|
{
|
||||||
|
// equal purple + pink strength cancel → net normal size
|
||||||
|
const m = computeModifiers(cov({ purple: 0.5, pink: 0.5 }))
|
||||||
|
near(m.size, 1, 'equal purple+pink → size nets to 1 (they fight)')
|
||||||
|
}
|
||||||
|
{
|
||||||
|
// more purple than pink → still grows, but less than pure purple
|
||||||
|
const strong = computeModifiers(cov({ purple: 0.6 }))
|
||||||
|
const fought = computeModifiers(cov({ purple: 0.6, pink: 0.3 }))
|
||||||
|
ok(fought.size > 1, 'purple-dominant mix still grows')
|
||||||
|
ok(fought.size < strong.size, 'pink drags the grow down')
|
||||||
|
}
|
||||||
|
|
||||||
console.log(`coverage-math.test: ${passed} assertions passed ✓`)
|
console.log(`coverage-math.test: ${passed} assertions passed ✓`)
|
||||||
|
|||||||
@ -54,6 +54,21 @@ export const SUPER = 0.70 // ≥70% one colour = super state
|
|||||||
/** Strength floor at the activation threshold so the buff visibly "kicks in". */
|
/** Strength floor at the activation threshold so the buff visibly "kicks in". */
|
||||||
const ACTIVATE_FLOOR = 0.15
|
const ACTIVATE_FLOOR = 0.15
|
||||||
|
|
||||||
|
// ---- Scale buffs (GDD §5.3 / §6): purple MEGA + pink MINI (Lane G) ---------
|
||||||
|
// Both drive `modifiers.size` (the controller scales collider + visuals) and
|
||||||
|
// bias `massMul` on top of the paint=weight base. They oppose each other: a blob
|
||||||
|
// carrying both purple and pink nets out toward normal size (they "fight").
|
||||||
|
/** Uniform scale added at full purple strength: 1.0 → 1.45 (grow, blocks/plates). */
|
||||||
|
export const MEGA_SIZE = 0.45
|
||||||
|
/** Uniform scale removed at full pink strength: 1.0 → 0.62 (shrink, tunnels). */
|
||||||
|
export const MINI_SIZE = 0.38
|
||||||
|
/** Extra massMul at full purple strength — MEGA is heavy beyond its paint total. */
|
||||||
|
export const MEGA_MASS = 1.5
|
||||||
|
/** massMul removed at full pink strength — MINI is lighter than clean at high %. */
|
||||||
|
export const MINI_MASS = 0.9
|
||||||
|
/** Lower bound so a fully-MINI blob stays positively massed (flingable, not zero). */
|
||||||
|
export const MASS_FLOOR = 0.35
|
||||||
|
|
||||||
const clamp01 = (x: number) => (x < 0 ? 0 : x > 1 ? 1 : x)
|
const clamp01 = (x: number) => (x < 0 ? 0 : x > 1 ? 1 : x)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -95,11 +110,13 @@ export function dominantColor(cov: CoverageReport): PaintColor | null {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The core mapping: coverage -> modifiers (GDD §6 MVP subset).
|
* The core mapping: coverage -> modifiers (GDD §6).
|
||||||
* RED -> speedMul up to 1.6 (BURN)
|
* RED -> speedMul up to 1.6 (BURN)
|
||||||
* GREEN -> grip up to 1.0 (GRIP)
|
* GREEN -> grip up to 1.0 (GRIP)
|
||||||
* BLUE -> waterproof (SLICK)
|
* BLUE -> waterproof (SLICK)
|
||||||
* total coverage -> massMul 1.0..1.8 (paint = weight, §5.2)
|
* PURPLE -> size up to 1.45 + extra mass (MEGA — grow big & heavy, §5.3)
|
||||||
|
* PINK -> size down to 0.62 + less mass (MINI — shrink small & light)
|
||||||
|
* total coverage -> massMul base 1.0..1.8 (paint = weight, §5.2)
|
||||||
* any colour ≥70% -> super: that buff pinned at max + glow
|
* any colour ≥70% -> super: that buff pinned at max + glow
|
||||||
* Pure and deterministic; the visual side-effects (ember trail, emissive pulse)
|
* Pure and deterministic; the visual side-effects (ember trail, emissive pulse)
|
||||||
* live in BuffSystem and read `glow` / dominant colour off this result + cov.
|
* live in BuffSystem and read `glow` / dominant colour off this result + cov.
|
||||||
@ -108,11 +125,24 @@ export function computeModifiers(cov: CoverageReport): BlobModifiers {
|
|||||||
const red = cov.byColor.red
|
const red = cov.byColor.red
|
||||||
const green = cov.byColor.green
|
const green = cov.byColor.green
|
||||||
const blue = cov.byColor.blue
|
const blue = cov.byColor.blue
|
||||||
|
const purpleS = buffStrength(cov.byColor.purple)
|
||||||
|
const pinkS = buffStrength(cov.byColor.pink)
|
||||||
|
|
||||||
const speedMul = 1 + 0.6 * buffStrength(red)
|
const speedMul = 1 + 0.6 * buffStrength(red)
|
||||||
const grip = buffStrength(green) // 0..1
|
const grip = buffStrength(green) // 0..1
|
||||||
const waterproof = blue >= ACTIVATE
|
const waterproof = blue >= ACTIVATE
|
||||||
const massMul = 1 + 0.8 * clamp01(cov.total)
|
|
||||||
|
// ---- scale (MEGA vs MINI) ----
|
||||||
|
// net grow/shrink strength; purple and pink oppose so a mixed blob nets out.
|
||||||
|
const net = purpleS - pinkS
|
||||||
|
const size = net >= 0 ? 1 + MEGA_SIZE * net : 1 + MINI_SIZE * net
|
||||||
|
|
||||||
|
// ---- mass (paint=weight base, biased by MEGA heavier / MINI lighter) ----
|
||||||
|
const massMul = Math.max(
|
||||||
|
MASS_FLOOR,
|
||||||
|
1 + 0.8 * clamp01(cov.total) + MEGA_MASS * purpleS - MINI_MASS * pinkS,
|
||||||
|
)
|
||||||
|
|
||||||
const glow = superColor(cov) ? 1 : 0
|
const glow = superColor(cov) ? 1 : 0
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@ -120,7 +150,7 @@ export function computeModifiers(cov: CoverageReport): BlobModifiers {
|
|||||||
jumpMul: 1,
|
jumpMul: 1,
|
||||||
massMul,
|
massMul,
|
||||||
grip,
|
grip,
|
||||||
size: 1, // purple/pink grow-shrink is V1 (§5.3) — untouched in MVP
|
size,
|
||||||
waterproof,
|
waterproof,
|
||||||
glow,
|
glow,
|
||||||
}
|
}
|
||||||
|
|||||||
196
src/paint/puddles.ts
Normal file
196
src/paint/puddles.ts
Normal file
@ -0,0 +1,196 @@
|
|||||||
|
/**
|
||||||
|
* PaintPuddle (Lane G) — a flat coloured strip laid ON the track. Its job is to
|
||||||
|
* make paint ROUTE-guaranteed instead of aim-guaranteed: while the blob's ground
|
||||||
|
* contact is inside the strip, we stamp its UNDERSIDE on a steady cadence, so
|
||||||
|
* simply rolling through a puddle coats your belly. Ballistic cannons (Lane B)
|
||||||
|
* become bonus pressure on top; a hold-forward racer can no longer finish clean.
|
||||||
|
*
|
||||||
|
* A puddle is purely a paint source: a decorative mesh (NO collider — you roll
|
||||||
|
* straight over it) plus a footprint trigger. Each stamp goes through the SAME
|
||||||
|
* `skin.splatAtPoint` the cannons use and emits the frozen `paint:splatted`
|
||||||
|
* {color, target:'blob'} event (audio/VFX/netcode hook it downstream).
|
||||||
|
*
|
||||||
|
* Belly-coating detail: the blob's physics body doesn't tumble (rotations are
|
||||||
|
* locked — the comedy roll is visual only), so naively stamping straight down
|
||||||
|
* would pile every stamp onto the south pole. Instead we fake the roll: an
|
||||||
|
* accumulated roll phase (distance / radius) walks the contact point along the
|
||||||
|
* travel great circle, so crossing a strip paints a proper stripe around the
|
||||||
|
* body and coverage actually climbs.
|
||||||
|
*/
|
||||||
|
import * as THREE from 'three'
|
||||||
|
import type { Blob, PaintColor, World } from '../contracts'
|
||||||
|
import { PALETTE } from '../contracts'
|
||||||
|
import type { PaintSkin } from './skin'
|
||||||
|
|
||||||
|
export interface PaintPuddleConfig {
|
||||||
|
/** Strip centre in world space; y is the track surface the strip lies on. */
|
||||||
|
position: [number, number, number]
|
||||||
|
/** Footprint [width(x), length(z)] in world units. */
|
||||||
|
size: [number, number]
|
||||||
|
color: PaintColor
|
||||||
|
/** Underside stamps per second while rolling through. Default 6. */
|
||||||
|
rate?: number
|
||||||
|
/** World-space splat radius per stamp. Default 0.3. */
|
||||||
|
splatRadius?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEFAULT_RATE = 6
|
||||||
|
const DEFAULT_SPLAT_RADIUS = 0.3
|
||||||
|
/** How close the belly must be to the strip plane to count as "rolling on it". */
|
||||||
|
const CONTACT_TOLERANCE = 0.6
|
||||||
|
|
||||||
|
export class PaintPuddle {
|
||||||
|
readonly color: PaintColor
|
||||||
|
readonly mesh: THREE.Mesh
|
||||||
|
/** Total underside stamps this puddle has laid (demo/telemetry). */
|
||||||
|
stamps = 0
|
||||||
|
|
||||||
|
private readonly px: number
|
||||||
|
private readonly pz: number
|
||||||
|
private readonly py: number
|
||||||
|
private readonly hx: number
|
||||||
|
private readonly hz: number
|
||||||
|
private readonly period: number
|
||||||
|
private readonly splatRadius: number
|
||||||
|
private acc = 0
|
||||||
|
|
||||||
|
constructor(world: World, cfg: PaintPuddleConfig) {
|
||||||
|
this.color = cfg.color
|
||||||
|
const [x, y, z] = cfg.position
|
||||||
|
const [w, l] = cfg.size
|
||||||
|
this.px = x
|
||||||
|
this.py = y
|
||||||
|
this.pz = z
|
||||||
|
this.hx = w * 0.5
|
||||||
|
this.hz = l * 0.5
|
||||||
|
this.period = 1 / (cfg.rate ?? DEFAULT_RATE)
|
||||||
|
this.splatRadius = cfg.splatRadius ?? DEFAULT_SPLAT_RADIUS
|
||||||
|
|
||||||
|
// Decorative wet-paint slab — thin, flat, no collider (you roll over it).
|
||||||
|
const mat = new THREE.MeshStandardMaterial({
|
||||||
|
color: PALETTE[cfg.color],
|
||||||
|
roughness: 0.25,
|
||||||
|
metalness: 0.0,
|
||||||
|
transparent: true,
|
||||||
|
opacity: 0.9,
|
||||||
|
emissive: PALETTE[cfg.color],
|
||||||
|
emissiveIntensity: 0.12,
|
||||||
|
})
|
||||||
|
this.mesh = new THREE.Mesh(new THREE.BoxGeometry(w, 0.05, l), mat)
|
||||||
|
this.mesh.position.set(x, y + 0.03, z)
|
||||||
|
this.mesh.receiveShadow = true
|
||||||
|
world.scene.add(this.mesh)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** True while the blob's belly is resting inside this strip's footprint. */
|
||||||
|
private covers(cx: number, cz: number, bellyY: number): boolean {
|
||||||
|
return (
|
||||||
|
cx >= this.px - this.hx && cx <= this.px + this.hx &&
|
||||||
|
cz >= this.pz - this.hz && cz <= this.pz + this.hz &&
|
||||||
|
Math.abs(bellyY - this.py) <= CONTACT_TOLERANCE
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Advance one fixed step. `stampPoint(out, phase)` writes the world contact
|
||||||
|
* point for the current roll phase (shared across all puddles so the fake
|
||||||
|
* roll is continuous). Returns the number of stamps laid this step.
|
||||||
|
*/
|
||||||
|
step(
|
||||||
|
dt: number,
|
||||||
|
cx: number,
|
||||||
|
cz: number,
|
||||||
|
bellyY: number,
|
||||||
|
phase: number,
|
||||||
|
stampPoint: (out: THREE.Vector3, phase: number) => void,
|
||||||
|
skin: PaintSkin,
|
||||||
|
events: World['events'],
|
||||||
|
scratch: THREE.Vector3,
|
||||||
|
): number {
|
||||||
|
if (!this.covers(cx, cz, bellyY)) {
|
||||||
|
this.acc = 0
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
this.acc += dt
|
||||||
|
let laid = 0
|
||||||
|
while (this.acc >= this.period) {
|
||||||
|
this.acc -= this.period
|
||||||
|
stampPoint(scratch, phase)
|
||||||
|
skin.splatAtPoint(scratch, this.color, this.splatRadius)
|
||||||
|
events.emit('paint:splatted', { color: this.color, target: 'blob' })
|
||||||
|
this.stamps++
|
||||||
|
laid++
|
||||||
|
}
|
||||||
|
return laid
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PuddlesHandle {
|
||||||
|
puddles: PaintPuddle[]
|
||||||
|
/** Total stamps laid across all puddles (demo/telemetry). */
|
||||||
|
totalStamps(): number
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build the puddles from `configs` and register the single fixed-step system
|
||||||
|
* that coats the blob's belly while it rolls through any of them. Integration
|
||||||
|
* (and zones.ts) call this; it owns no course geometry beyond the strips.
|
||||||
|
*/
|
||||||
|
export function installPuddles(
|
||||||
|
world: World,
|
||||||
|
blob: Blob,
|
||||||
|
skin: PaintSkin,
|
||||||
|
configs: PaintPuddleConfig[],
|
||||||
|
): PuddlesHandle {
|
||||||
|
const puddles = configs.map((c) => new PaintPuddle(world, c))
|
||||||
|
|
||||||
|
// Continuous fake-roll state (see class docstring).
|
||||||
|
const prev = new THREE.Vector3()
|
||||||
|
let hasPrev = false
|
||||||
|
let phase = 0
|
||||||
|
const dir = new THREE.Vector3(0, 0, -1) // course runs toward -Z; sane default
|
||||||
|
const axis = new THREE.Vector3()
|
||||||
|
const down = new THREE.Vector3(0, -1, 0)
|
||||||
|
const center = new THREE.Vector3()
|
||||||
|
const scratch = new THREE.Vector3()
|
||||||
|
const rotated = new THREE.Vector3()
|
||||||
|
|
||||||
|
world.addSystem({
|
||||||
|
update(dt: number): void {
|
||||||
|
const t = blob.body.translation()
|
||||||
|
center.set(t.x, t.y, t.z)
|
||||||
|
const r = skin.boundingRadius * Math.max(0.01, blob.modifiers.size)
|
||||||
|
const bellyY = t.y - r
|
||||||
|
|
||||||
|
// advance the roll phase from horizontal displacement (roll w/o slipping)
|
||||||
|
if (hasPrev) {
|
||||||
|
const dx = t.x - prev.x
|
||||||
|
const dz = t.z - prev.z
|
||||||
|
const moved = Math.hypot(dx, dz)
|
||||||
|
if (moved > 1e-5) {
|
||||||
|
dir.set(dx / moved, 0, dz / moved)
|
||||||
|
phase += moved / r
|
||||||
|
}
|
||||||
|
}
|
||||||
|
prev.set(t.x, t.y, t.z)
|
||||||
|
hasPrev = true
|
||||||
|
|
||||||
|
// horizontal axis perpendicular to travel; contact point rides the great
|
||||||
|
// circle in the travel-vertical plane as `phase` grows.
|
||||||
|
axis.set(-dir.z, 0, dir.x)
|
||||||
|
const stampPoint = (out: THREE.Vector3, ph: number) => {
|
||||||
|
rotated.copy(down).applyAxisAngle(axis, ph)
|
||||||
|
out.copy(center).addScaledVector(rotated, r)
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const p of puddles) {
|
||||||
|
p.step(dt, t.x, t.z, bellyY, phase, stampPoint, skin, world.events, scratch)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
puddles,
|
||||||
|
totalStamps: () => puddles.reduce((s, p) => s + p.stamps, 0),
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user