From 333f11b9dbebbbbe35fb524d9bf7d8829cc430ab Mon Sep 17 00:00:00 2001 From: type-two Date: Sun, 19 Jul 2026 14:59:20 +1000 Subject: [PATCH] =?UTF-8?q?feat(blob):=20honour=20surface=20tags=20?= =?UTF-8?q?=E2=80=94=20oil=20luge,=20water=20rinse,=20drag?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The controller now resolves the surface under the blob from the ground collider's userData.surface (the course already tags them) and reacts: - slip (oil/ice): steering-scrub + brake throttled ~0.1x → you luge across, and even a grippy green blob slides (Min friction combine beats grip); - drag (honey/mud/water): horizontal speed bleeds off (sticky/heavy feel); - rinse (water): a small periodic paint:request-cleanse — an underwater shortcut costs you your loadout (GDD §5.5). The surfaces.ts framework existed but nothing in the game consumed the behaviour tags; this wires it, per that file's own "the controller queries the surface" contract. Added a demo oil slick (applySurface 'oil' + Min combine) on the machine-pad centre lane. Browser-verified: on oil the blob slides ~2.7u vs 0.42u on normal ground; resting on the milk river rinsed red 56.7% → 35.7% in 2s. honey-climb and mud-antipaint left as marked ponytail TODOs. build green. Co-Authored-By: Claude Opus 4.8 --- src/blob/controller.ts | 49 ++++++++++++++++++++++++++++++++++++++---- src/game.ts | 23 +++++++++++++++++++- 2 files changed, 67 insertions(+), 5 deletions(-) diff --git a/src/blob/controller.ts b/src/blob/controller.ts index bc7b6b6..ea2e431 100644 --- a/src/blob/controller.ts +++ b/src/blob/controller.ts @@ -13,6 +13,7 @@ import * as THREE from 'three' import type { System, World } from '../contracts' import type { BlobHandle } from './createBlob' +import { SURFACES, type SurfaceName } from '../machine/surfaces' export interface BlobControllerOptions { /** Base top speed (m/s) before speedMul. */ @@ -85,6 +86,7 @@ export function createBlobController( let prevVy = 0 let lastSize = -1 let lastMassMul = -1 + let rinseAccum = 0 // water-rinse cadence (GDD §5.5) const fwd = new THREE.Vector3() const right = new THREE.Vector3() @@ -132,6 +134,19 @@ export function createBlobController( } grounded = nowGrounded + // ---- surface under the blob (GDD §5.5): oil/ice = slip, honey/mud/water + // = drag, water = rinse. The course tags colliders `userData.surface`; the + // grounded ray already excludes self, so its hit is the ground we resolve. + // ponytail: honey `climb` and mud `antipaint` are not wired yet — they need + // more than a drag knob (wall-climb traversal / buff suppression). + let surf: (typeof SURFACES)[SurfaceName] | null = null + if (hit) { + const name = (hit.collider as unknown as { userData?: { surface?: string } }) + .userData?.surface as SurfaceName | undefined + if (name && SURFACES[name]) surf = SURFACES[name] + } + const slip = !!surf && surf.tags.includes('slip') + // ---- camera-relative move basis (flatten camera dir onto ground) ---- camera.getWorldDirection(fwd) fwd.y = 0 @@ -165,16 +180,19 @@ export function createBlobController( 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) + // scrub sideways drift so turns don't feel like ice (grip tightens it). + // On a slip surface the scrub is throttled right down: you luge, you + // can't just carve — even a grippy green blob slides (oil beats grip). 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) + const k = Math.min(1, (2 + m.grip * 10) * dt) * (slip ? 0.12 : 1) 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) + // no input on ground: brake. grip = how hard we kill the slide; a slip + // surface all but removes the brake so you keep sliding. + const k = Math.min(1, (6 + m.grip * 22) * dt) * (slip ? 0.08 : 1) body.applyImpulse({ x: -v.x * mass * k, y: 0, z: -v.z * mass * k }, true) } @@ -190,6 +208,29 @@ export function createBlobController( events.emit('blob:jumped', {}) } + // ---- surface effects (only while grounded on a tagged surface) ---- + if (surf && grounded) { + // drag: honey/mud/water bleed horizontal speed (sticky / heavy feel) + if (surf.drag > 0) { + const keep = Math.max(0, 1 - surf.drag) + const lv = body.linvel() + body.setLinvel({ x: lv.x * keep, y: lv.y, z: lv.z * keep }, true) + } + // rinse: water gradually washes paint off — an underwater shortcut costs + // you your loadout (GDD §5.5). Small fraction on a fixed cadence. + if (surf.tags.includes('rinse')) { + rinseAccum += dt + if (rinseAccum >= 0.4) { + rinseAccum = 0 + events.emit('paint:request-cleanse', { fraction: 0.03 }) + } + } else { + rinseAccum = 0 + } + } else { + rinseAccum = 0 + } + prevVy = body.linvel().y }, } diff --git a/src/game.ts b/src/game.ts index 8080b78..ac33ff1 100644 --- a/src/game.ts +++ b/src/game.ts @@ -11,7 +11,10 @@ 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 { createBucketDump, createBubbleArch, createConveyorBelt, createPaintMine, createColorGate } from './machine/index' +import { + createBucketDump, createBubbleArch, createConveyorBelt, createPaintMine, createColorGate, + SURFACES, applySurface, +} from './machine/index' import { installZones } from './course/zones' import { assets } from './assets/registry' import { hasOverrides } from './assets/idb' @@ -93,6 +96,24 @@ export function installGame(world: World) { impulse: 7, direction: [0.8, 0.5, 0], size: [3, 3], }) + // ---- oil slick: a slippery patch on the flat approach. applySurface gives + // it oil friction with a Min combine rule, so it stays slick under ANY blob + // (even a grippy green one), and the userData tag lets the controller throttle + // steering + add drag → you luge across it. Demo of the surface framework. ---- + { + const oilMat = new THREE.MeshStandardMaterial({ + color: SURFACES.oil.color, roughness: 0.12, metalness: 0.35, + }) + const oil = new THREE.Mesh(new THREE.BoxGeometry(6, 0.08, 4), oilMat) + oil.position.set(0, 0.28, -40) // machine-pad centre lane (top y0.24), clear of the fork exits + hazard mine + oil.receiveShadow = true + world.scene.add(oil) + const oilCol = world.physics.createCollider( + world.rapier.ColliderDesc.cuboid(3, 0.04, 2).setTranslation(0, 0.28, -40)) + applySurface(world.rapier, oilCol, 'oil') + ;(oilCol as unknown as { userData?: unknown }).userData = { surface: 'oil' } + } + // ---- colour-keyed gate: a GREEN shortcut through the centre lane, just // before the finish. Roll over the green reward mine upstream and you arrive // green enough for the gate to sink into the floor — a straight blast to the