Wave 2 integration: zones staged, fork machines own the district, tuning pass

- installZones + cannonConfigs through the single cannon path; old centre
  plate/boot removed (fork versions supersede); belt+bucket to centre lane
  (sunk flush — proud face curbed the line); arch punishes failed MEGA runs
- puddles: distance-based stamping (speed-independent coverage), rate 4/u,
  contact tolerance 1.0 (bounce hops starved strips), fork strips widened
  toward the racing line + neutral-centre red top-up strip
- under-shelf volumes filled solid in the centre (side corridors left open);
  launcher catch widened to the full gap
- verified: naive hold-W finishes ~12s, ghost records+replays, MINI (0.77)
  passes the tunnel with 47% pink, buffs activate (GRIP live at 29% green),
  78% total coverage on a wandering run

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
type-two 2026-07-18 12:18:30 +10:00
parent 8875663c18
commit 0f2aca542b
4 changed files with 60 additions and 53 deletions

View File

@ -89,8 +89,15 @@ const PUDDLE_LAYOUT: PaintPuddleConfig[] = [
// 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 },
// Widened toward the racing line (integration tune): at race speed a fork
// strip at x±6 was unreachable by the time you could steer post-launcher.
// A ~1.5u neutral gap at x≈0 still lets a centre runner stay unforked.
{ color: 'purple', position: [-4.5, 0.27, -40], size: [8, 8], splatRadius: 0.34 },
{ color: 'pink', position: [5, 0.27, -40], size: [8, 8], splatRadius: 0.34 },
// Neutral-centre red top-up (integration tune): the fast launcher route is
// airborne through most strips; this grounded stretch lets a centre runner's
// red cross the 20% threshold — every route should earn SOME buff.
{ color: 'red', position: [0, 0.27, -44], size: [2.2, 12], splatRadius: 0.34 },
]
// Fork geometry constants (obstacles sit PAST the entrance strips).

View File

@ -11,7 +11,8 @@ 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'
import { createBucketDump, createBubbleArch, createConveyorBelt } from './machine/index'
import { installZones } from './course/zones'
import { installAudio } from './audio/index'
import { installGhost } from './ghost/index'
import { installTitle } from './ui/title'
@ -34,62 +35,46 @@ export function installGame(world: World) {
const camera = createFollowCamera(world, blob)
world.onFrame((dt) => camera.update(dt))
// ---- paint (Lane B): cannons staged along the racing line ----
// ---- paint economy (Lane G): puddles + colour zones + MEGA/MINI fork ----
// Zones build the fork's own plate/boot (purple lane) and the MINI tunnel
// (pink lane), and return cannon configs for the one cannon-staging path here.
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) {
const zones = installZones(world, blob, skin)
for (const spec of zones.cannonConfigs) {
world.addSystem(new PaintCannon({
world,
position: spec.position,
position: new THREE.Vector3(...spec.position),
color: spec.color,
target: blob.mesh,
paint: skin,
targetRadius: blob.radius,
triggerId: spec.triggerId,
interval: 2.2,
interval: spec.interval,
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.
// ---- centre-lane machine garnish (Lane C) ----
// The fork owns plate/boot now (zones.ts). The belt+bucket moved to the
// CENTRE lane: un-forked runners get slow-carried under a purple dump —
// a free taste of MEGA with no roof above (never dump grow-juice inside
// the MINI tunnel: growing under the roof would wedge you).
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],
id: 'bucket-1', position: [-1.4, 6, -50],
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.
// Shallow catch just upstream of the spout (bucket.x+1.4 = belt centre x=0):
// observed teeter drift is 0.4-2u depending on speed.
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],
id: 'belt-1', position: [0, 0.0, -50], // sunk flush with the machine pad — a proud belt face curbed the racing line
size: [3, 0.5, 10], velocity: [0, 0, -3],
})
createBubbleArch(world, {
id: 'arch-1', position: [-4.5, 0.12, -56], fraction: 0.6, // purple-lane exit: cleansing punishes a failed MEGA attempt
})
world.addSystem(new BuffSystem(world, { mesh: blob.mesh, modifiers: blob.modifiers, paint: skin }))
installPaint(world, { mesh: blob.mesh, paint: skin })
@ -111,16 +96,20 @@ export function installGame(world: World) {
world.scene.add(tramp)
world.physics.createCollider(
world.rapier.ColliderDesc.cuboid(6, 0.25, 2).setTranslation(0, 0.25, 0))
// Seal the under-shelf cavity: fast racers overshoot the slot and slip
// beneath the landing shelf into an inescapable wedge (constant minZ -17 in
// testing). Wall is flush with the shelf face, so it reads as its plinth.
const plinth = new THREE.Mesh(
new THREE.BoxGeometry(14, 2.5, 0.5),
new THREE.MeshStandardMaterial({ color: '#0A5FCB', roughness: 0.8 }))
plinth.position.set(0, 1.25, -2)
world.scene.add(plinth)
world.physics.createCollider(
world.rapier.ColliderDesc.cuboid(7, 1.25, 0.25).setTranslation(0, 1.25, -2))
// Fill BOTH under-shelf volumes solid: the front-face plinth stopped
// overshooters, but lane-huggers rolled in from the open x±7 sides and
// wedged (observed at (-4.1,-7.9)). Under-shelf space serves no gameplay —
// make the shelves read as solid blocks down to the floor.
const plinthMat = new THREE.MeshStandardMaterial({ color: '#0A5FCB', roughness: 0.8 })
// Central fill only (x±5.2): observed wedges are all |x|<4.5; full-width
// fills walled off the floor-level side corridors lane-hug routes use.
for (const [zc, hz] of [[6, 4], [-6, 4]] as const) {
const fill = new THREE.Mesh(new THREE.BoxGeometry(10.4, 2.8, hz * 2), plinthMat)
fill.position.set(0, 1.4, zc)
world.scene.add(fill)
world.physics.createCollider(
world.rapier.ColliderDesc.cuboid(5.2, 1.4, hz).setTranslation(0, 1.4, zc))
}
// Deterministic launcher, not restitution: raw bounciness pachinkos off the
// shelf's front edge and can still slip underneath. Catch a falling blob on
// the pad and set a clean arc onto the landing shelf.
@ -130,7 +119,7 @@ export function installGame(world: World) {
bounceCooldown = Math.max(0, bounceCooldown - dt)
const t = blob.body.translation()
const v = blob.body.linvel()
const onPad = Math.abs(t.x) < 6 && t.z > -2.2 && t.z < 2.2 && t.y < 1.4
const onPad = Math.abs(t.x) < 7.2 && t.z > -2.2 && t.z < 2.2 && t.y < 1.4 // full gap width — lane-huggers fall at |x|≈7
if (onPad && v.y < 0.5 && bounceCooldown === 0) {
// High + centered: held-W air control (~24 u/s²) flattens shallow arcs
// into the shelf face, so go UP hard and pull toward the slot center;

View File

@ -34,10 +34,13 @@ export interface PaintPuddleConfig {
splatRadius?: number
}
const DEFAULT_RATE = 6
/** Stamps per world-unit TRAVELLED (distance-based; speed-independent coverage). */
const DEFAULT_RATE = 4
const DEFAULT_SPLAT_RADIUS = 0.3
/** Idle drip: parking in paint still coats, slowly (world-units-equivalent/s). */
const IDLE_DRIP = 0.35
/** How close the belly must be to the strip plane to count as "rolling on it". */
const CONTACT_TOLERANCE = 0.6
const CONTACT_TOLERANCE = 1.0 // integration tune: landing-bounce hops starved strips at 0.6
export class PaintPuddle {
readonly color: PaintColor
@ -98,6 +101,7 @@ export class PaintPuddle {
*/
step(
dt: number,
moved: number,
cx: number,
cz: number,
bellyY: number,
@ -111,7 +115,11 @@ export class PaintPuddle {
this.acc = 0
return 0
}
this.acc += dt
// Distance-based accumulation (integration fix, 2026-07-18): a time-based
// cadence made coverage inversely proportional to speed — a racer at 9 u/s
// crossed a 7u strip with ~5 stamps while a slow test ball got dozens.
// Stamp per unit TRAVELLED, plus a small idle drip so parking still coats.
this.acc += moved + IDLE_DRIP * dt
let laid = 0
while (this.acc >= this.period) {
this.acc -= this.period
@ -163,6 +171,7 @@ export function installPuddles(
const bellyY = t.y - r
// advance the roll phase from horizontal displacement (roll w/o slipping)
let movedStep = 0
if (hasPrev) {
const dx = t.x - prev.x
const dz = t.z - prev.z
@ -170,6 +179,7 @@ export function installPuddles(
if (moved > 1e-5) {
dir.set(dx / moved, 0, dz / moved)
phase += moved / r
movedStep = moved
}
}
prev.set(t.x, t.y, t.z)
@ -184,7 +194,7 @@ export function installPuddles(
}
for (const p of puddles) {
p.step(dt, t.x, t.z, bellyY, phase, stampPoint, skin, world.events, scratch)
p.step(dt, movedStep, t.x, t.z, bellyY, phase, stampPoint, skin, world.events, scratch)
}
},
})

View File

@ -12,6 +12,7 @@ export default defineConfig({
'lane-b': resolve(__dirname, 'demos/lane-b.html'),
'lane-c': resolve(__dirname, 'demos/lane-c.html'),
'lane-f': resolve(__dirname, 'demos/lane-f.html'),
'lane-g': resolve(__dirname, 'demos/lane-g.html'),
'lane-h': resolve(__dirname, 'demos/lane-h.html'),
},
},