feat(blob): honour surface tags — oil luge, water rinse, drag

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 <noreply@anthropic.com>
This commit is contained in:
type-two 2026-07-19 14:59:20 +10:00
parent 856683832b
commit 333f11b9db
2 changed files with 67 additions and 5 deletions

View File

@ -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
},
}

View File

@ -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