diff --git a/demos/lane-g.html b/demos/lane-g.html new file mode 100644 index 0000000..0965234 --- /dev/null +++ b/demos/lane-g.html @@ -0,0 +1,26 @@ + + +BLOBBO lane g demo + + +
+
+ BLOBBO — Lane G: zones · puddles · MEGA/MINI
+ WASD move · Space jump
+ FAUTO hold-forward (hands-free, ≥40% by route)
+ 1teleport RED start · 2BLUE river
+ 3PURPLE/MEGA fork · 4PINK/MINI tunnel
+ Xfull clean · Ccleanse 50%
+ Rrespawn · Pstate to console · HHUD +
+ + + diff --git a/src/course/zones.ts b/src/course/zones.ts new file mode 100644 index 0000000..8d35180 --- /dev/null +++ b/src/course/zones.ts @@ -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) + } + } +} diff --git a/src/demo/lane-g.ts b/src/demo/lane-g.ts new file mode 100644 index 0000000..cc334e6 --- /dev/null +++ b/src/demo/lane-g.ts @@ -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') diff --git a/src/paint/buffs.ts b/src/paint/buffs.ts index 1b18cad..abcd920 100644 --- a/src/paint/buffs.ts +++ b/src/paint/buffs.ts @@ -76,7 +76,10 @@ export class BuffSystem implements System { const cov = paint.coverage() // 250ms-cached; no per-frame readback 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 m.speedMul = mods.speedMul m.jumpMul = mods.jumpMul diff --git a/src/paint/coverage-math.test.ts b/src/paint/coverage-math.test.ts index 1712140..ac89e12 100644 --- a/src/paint/coverage-math.test.ts +++ b/src/paint/coverage-math.test.ts @@ -9,6 +9,11 @@ import type { CoverageReport, PaintColor } from '../contracts' import { ACTIVATE, SUPER, + MEGA_SIZE, + MINI_SIZE, + MEGA_MASS, + MINI_MASS, + MASS_FLOOR, buffStrength, computeModifiers, 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') } { - // 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 })) 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 ✓`) diff --git a/src/paint/coverage-math.ts b/src/paint/coverage-math.ts index cbb8567..21ad760 100644 --- a/src/paint/coverage-math.ts +++ b/src/paint/coverage-math.ts @@ -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". */ 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) /** @@ -95,11 +110,13 @@ export function dominantColor(cov: CoverageReport): PaintColor | null { } /** - * 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) + * The core mapping: coverage -> modifiers (GDD §6). + * RED -> speedMul up to 1.6 (BURN) + * GREEN -> grip up to 1.0 (GRIP) + * BLUE -> waterproof (SLICK) + * 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 * Pure and deterministic; the visual side-effects (ember trail, emissive pulse) * 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 green = cov.byColor.green 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 grip = buffStrength(green) // 0..1 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 return { @@ -120,7 +150,7 @@ export function computeModifiers(cov: CoverageReport): BlobModifiers { jumpMul: 1, massMul, grip, - size: 1, // purple/pink grow-shrink is V1 (§5.3) — untouched in MVP + size, waterproof, glow, } diff --git a/src/paint/puddles.ts b/src/paint/puddles.ts new file mode 100644 index 0000000..d62d54c --- /dev/null +++ b/src/paint/puddles.ts @@ -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), + } +}