Compare commits
4 Commits
ad93d55de9
...
67921925f5
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
67921925f5 | ||
|
|
333f11b9db | ||
|
|
856683832b | ||
|
|
fef136dfbc |
@ -13,6 +13,7 @@
|
|||||||
import * as THREE from 'three'
|
import * as THREE from 'three'
|
||||||
import type { System, World } from '../contracts'
|
import type { System, World } from '../contracts'
|
||||||
import type { BlobHandle } from './createBlob'
|
import type { BlobHandle } from './createBlob'
|
||||||
|
import { SURFACES, type SurfaceName } from '../machine/surfaces'
|
||||||
|
|
||||||
export interface BlobControllerOptions {
|
export interface BlobControllerOptions {
|
||||||
/** Base top speed (m/s) before speedMul. */
|
/** Base top speed (m/s) before speedMul. */
|
||||||
@ -85,6 +86,7 @@ export function createBlobController(
|
|||||||
let prevVy = 0
|
let prevVy = 0
|
||||||
let lastSize = -1
|
let lastSize = -1
|
||||||
let lastMassMul = -1
|
let lastMassMul = -1
|
||||||
|
let rinseAccum = 0 // water-rinse cadence (GDD §5.5)
|
||||||
|
|
||||||
const fwd = new THREE.Vector3()
|
const fwd = new THREE.Vector3()
|
||||||
const right = new THREE.Vector3()
|
const right = new THREE.Vector3()
|
||||||
@ -132,6 +134,19 @@ export function createBlobController(
|
|||||||
}
|
}
|
||||||
grounded = nowGrounded
|
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-relative move basis (flatten camera dir onto ground) ----
|
||||||
camera.getWorldDirection(fwd)
|
camera.getWorldDirection(fwd)
|
||||||
fwd.y = 0
|
fwd.y = 0
|
||||||
@ -165,16 +180,19 @@ export function createBlobController(
|
|||||||
body.applyImpulse({ x: move.x * imp, y: 0, z: move.z * imp }, true)
|
body.applyImpulse({ x: move.x * imp, y: 0, z: move.z * imp }, true)
|
||||||
}
|
}
|
||||||
if (grounded) {
|
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 along2 = v.x * move.x + v.z * move.z
|
||||||
const perpX = v.x - along2 * move.x
|
const perpX = v.x - along2 * move.x
|
||||||
const perpZ = v.z - along2 * move.z
|
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)
|
body.applyImpulse({ x: -perpX * mass * k, y: 0, z: -perpZ * mass * k }, true)
|
||||||
}
|
}
|
||||||
} else if (grounded) {
|
} else if (grounded) {
|
||||||
// no input on ground: brake. grip = how hard we kill the slide.
|
// no input on ground: brake. grip = how hard we kill the slide; a slip
|
||||||
const k = Math.min(1, (6 + m.grip * 22) * dt)
|
// 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)
|
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', {})
|
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
|
prevVy = body.linvel().y
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
171
src/course/spec.ts
Normal file
171
src/course/spec.ts
Normal file
@ -0,0 +1,171 @@
|
|||||||
|
/**
|
||||||
|
* DATA-DRIVEN COURSES — a course is a plain object (boxes + machines + cannons +
|
||||||
|
* spawn/finish), and `buildCourseFromSpec` turns it into a live course by
|
||||||
|
* dispatching to the existing greybox box builder and the Lane C machine
|
||||||
|
* factories. No new gameplay code: this is pure plumbing so a new level is
|
||||||
|
* *data*, not a game.ts fork.
|
||||||
|
*
|
||||||
|
* The flagship "Breakfast Rush" course stays hand-built (it carries a lot of
|
||||||
|
* integration tuning) — see game.ts. New courses live in COURSES below and load
|
||||||
|
* with `?course=<name>`.
|
||||||
|
*/
|
||||||
|
import * as THREE from 'three'
|
||||||
|
import type { World, PaintColor } from '../contracts'
|
||||||
|
import type { PaintSkin } from '../paint/skin'
|
||||||
|
import { PaintCannon } from '../paint/index'
|
||||||
|
import {
|
||||||
|
createPaintMine, createColorGate, createSpringBoot, createSeeSaw, createFan,
|
||||||
|
createBucketDump, createBubbleArch, createConveyorBelt, createPressurePlate,
|
||||||
|
applySurface,
|
||||||
|
} from '../machine/index'
|
||||||
|
import type {
|
||||||
|
PaintMineConfig, ColorGateConfig, SpringBootConfig, SeeSawConfig, FanConfig,
|
||||||
|
BucketDumpConfig, BubbleArchConfig, ConveyorBeltConfig, PressurePlateConfig,
|
||||||
|
SurfaceName,
|
||||||
|
} from '../machine/index'
|
||||||
|
import { TOASTER_ALLEY } from './toaster-alley'
|
||||||
|
|
||||||
|
export interface FinishBox { xMin: number; xMax: number; zMin: number; zMax: number; yMax: number }
|
||||||
|
|
||||||
|
export interface BoxSpec {
|
||||||
|
pos: [number, number, number]
|
||||||
|
/** half-extents [hx, hy, hz]. */
|
||||||
|
half: [number, number, number]
|
||||||
|
color: THREE.ColorRepresentation
|
||||||
|
rot?: [number, number, number]
|
||||||
|
surface?: SurfaceName
|
||||||
|
cast?: boolean
|
||||||
|
transparent?: number
|
||||||
|
roughness?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A machine placement. `cfg` is the exact factory config; the gate's qualifies
|
||||||
|
* is derived from needColor/needPct so the data stays a plain object. */
|
||||||
|
export type MachineSpec =
|
||||||
|
| { kind: 'mine'; cfg: PaintMineConfig }
|
||||||
|
| { kind: 'gate'; cfg: Omit<ColorGateConfig, 'qualifies'>; needColor: PaintColor; needPct: number }
|
||||||
|
| { kind: 'boot'; cfg: SpringBootConfig }
|
||||||
|
| { kind: 'seesaw'; cfg: SeeSawConfig }
|
||||||
|
| { kind: 'fan'; cfg: FanConfig }
|
||||||
|
| { kind: 'bucket'; cfg: BucketDumpConfig }
|
||||||
|
| { kind: 'bubble'; cfg: BubbleArchConfig }
|
||||||
|
| { kind: 'plate'; cfg: PressurePlateConfig }
|
||||||
|
| { kind: 'conveyor'; cfg: ConveyorBeltConfig }
|
||||||
|
| { kind: 'oil'; pos: [number, number, number]; half: [number, number, number] }
|
||||||
|
|
||||||
|
export interface CannonSpec {
|
||||||
|
color: PaintColor
|
||||||
|
position: [number, number, number]
|
||||||
|
triggerId?: string
|
||||||
|
interval?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CourseSpec {
|
||||||
|
name: string
|
||||||
|
spawn: [number, number, number]
|
||||||
|
finish: FinishBox
|
||||||
|
boxes: BoxSpec[]
|
||||||
|
machines?: MachineSpec[]
|
||||||
|
cannons?: CannonSpec[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BuildCtx {
|
||||||
|
blob: { mesh: THREE.Object3D; radius: number }
|
||||||
|
skin: PaintSkin
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Static box: THREE mesh + fixed cuboid collider, optional rotation + surface
|
||||||
|
* tag (mirrored onto the collider so the controller can resolve it). Mirrors
|
||||||
|
* greybox.ts's addBox so spec courses look identical to the hand-built one. */
|
||||||
|
function addBox(world: World, b: BoxSpec): void {
|
||||||
|
const [cx, cy, cz] = b.pos
|
||||||
|
const [hx, hy, hz] = b.half
|
||||||
|
const mat = new THREE.MeshStandardMaterial({
|
||||||
|
color: b.color,
|
||||||
|
roughness: b.roughness ?? 0.9,
|
||||||
|
metalness: 0,
|
||||||
|
transparent: b.transparent !== undefined,
|
||||||
|
opacity: b.transparent ?? 1,
|
||||||
|
})
|
||||||
|
const mesh = new THREE.Mesh(new THREE.BoxGeometry(hx * 2, hy * 2, hz * 2), mat)
|
||||||
|
mesh.position.set(cx, cy, cz)
|
||||||
|
mesh.receiveShadow = true
|
||||||
|
mesh.castShadow = b.cast ?? false
|
||||||
|
if (b.rot) mesh.rotation.set(b.rot[0], b.rot[1], b.rot[2])
|
||||||
|
world.scene.add(mesh)
|
||||||
|
|
||||||
|
const desc = world.rapier.ColliderDesc.cuboid(hx, hy, hz).setTranslation(cx, cy, cz)
|
||||||
|
if (b.rot) {
|
||||||
|
const q = new THREE.Quaternion().setFromEuler(new THREE.Euler(b.rot[0], b.rot[1], b.rot[2]))
|
||||||
|
desc.setRotation({ x: q.x, y: q.y, z: q.z, w: q.w })
|
||||||
|
}
|
||||||
|
const col = world.physics.createCollider(desc)
|
||||||
|
if (b.surface) {
|
||||||
|
applySurface(world.rapier, col, b.surface)
|
||||||
|
;(col as unknown as { userData?: unknown }).userData = { surface: b.surface }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildCourseFromSpec(
|
||||||
|
world: World,
|
||||||
|
spec: CourseSpec,
|
||||||
|
ctx: BuildCtx,
|
||||||
|
): { spawn: THREE.Vector3; finish: FinishBox } {
|
||||||
|
for (const b of spec.boxes) addBox(world, b)
|
||||||
|
|
||||||
|
for (const m of spec.machines ?? []) {
|
||||||
|
switch (m.kind) {
|
||||||
|
case 'mine': createPaintMine(world, m.cfg); break
|
||||||
|
case 'gate':
|
||||||
|
createColorGate(world, {
|
||||||
|
...m.cfg,
|
||||||
|
qualifies: () => ctx.skin.coverage().byColor[m.needColor] >= m.needPct,
|
||||||
|
})
|
||||||
|
break
|
||||||
|
case 'boot': createSpringBoot(world, m.cfg); break
|
||||||
|
case 'seesaw': createSeeSaw(world, m.cfg); break
|
||||||
|
case 'fan': createFan(world, m.cfg); break
|
||||||
|
case 'bucket': createBucketDump(world, m.cfg); break
|
||||||
|
case 'bubble': createBubbleArch(world, m.cfg); break
|
||||||
|
case 'plate': createPressurePlate(world, m.cfg); break
|
||||||
|
case 'conveyor': createConveyorBelt(world, m.cfg); break
|
||||||
|
case 'oil': {
|
||||||
|
const [cx, cy, cz] = m.pos
|
||||||
|
const [hx, hy, hz] = m.half
|
||||||
|
const mat = new THREE.MeshStandardMaterial({ color: '#3a2f4a', roughness: 0.12, metalness: 0.35 })
|
||||||
|
const mesh = new THREE.Mesh(new THREE.BoxGeometry(hx * 2, hy * 2, hz * 2), mat)
|
||||||
|
mesh.position.set(cx, cy, cz)
|
||||||
|
mesh.receiveShadow = true
|
||||||
|
world.scene.add(mesh)
|
||||||
|
const col = world.physics.createCollider(
|
||||||
|
world.rapier.ColliderDesc.cuboid(hx, hy, hz).setTranslation(cx, cy, cz))
|
||||||
|
applySurface(world.rapier, col, 'oil')
|
||||||
|
;(col as unknown as { userData?: unknown }).userData = { surface: 'oil' }
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const c of spec.cannons ?? []) {
|
||||||
|
world.addSystem(new PaintCannon({
|
||||||
|
world,
|
||||||
|
position: new THREE.Vector3(...c.position),
|
||||||
|
color: c.color,
|
||||||
|
target: ctx.blob.mesh,
|
||||||
|
paint: ctx.skin,
|
||||||
|
targetRadius: ctx.blob.radius,
|
||||||
|
triggerId: c.triggerId,
|
||||||
|
interval: c.interval ?? 2.2,
|
||||||
|
muzzleSpeed: 26,
|
||||||
|
splatRadius: 0.45,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
return { spawn: new THREE.Vector3(...spec.spawn), finish: spec.finish }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Selectable data-driven courses (the flagship Breakfast Rush is hand-built in
|
||||||
|
* game.ts and is the default). Load with `?course=<name>`. */
|
||||||
|
export const COURSES: Record<string, CourseSpec> = {
|
||||||
|
toaster: TOASTER_ALLEY,
|
||||||
|
}
|
||||||
48
src/course/toaster-alley.ts
Normal file
48
src/course/toaster-alley.ts
Normal file
@ -0,0 +1,48 @@
|
|||||||
|
/**
|
||||||
|
* TOASTER ALLEY — a second course, authored entirely as DATA (see spec.ts).
|
||||||
|
* Deliberately flat (one big catch-floor, no fall-outs and no elevated
|
||||||
|
* geometry) so it is reliably completable without the fine tuning Breakfast
|
||||||
|
* Rush needed. It also puts the FAN — a machine that shipped but was never
|
||||||
|
* placed — into play as a tailwind.
|
||||||
|
*
|
||||||
|
* Flow (+Z start → -Z finish): a fan sweeps you off the start pad; red cannons
|
||||||
|
* paint you for BURN speed; a green reward mine gives GRIP; a green colour-gate
|
||||||
|
* (fed by that mine) opens the straight line to the finish.
|
||||||
|
*
|
||||||
|
* NOTE: the SEE-SAW is also a supported spec kind (`{kind:'seesaw'}`), but a
|
||||||
|
* good see-saw needs an elevated bridge (approach platform + landing) tuned so
|
||||||
|
* the blob steps on cleanly — that's level design worth doing deliberately, so
|
||||||
|
* it's left out of this flat demo rather than shipped half-working.
|
||||||
|
*/
|
||||||
|
import type { CourseSpec } from './spec'
|
||||||
|
|
||||||
|
export const TOASTER_ALLEY: CourseSpec = {
|
||||||
|
name: 'Toaster Alley',
|
||||||
|
spawn: [0, 1.8, 30],
|
||||||
|
finish: { xMin: -7, xMax: 7, zMin: -38, zMax: -26, yMax: 4 },
|
||||||
|
boxes: [
|
||||||
|
// base floor — spans the whole alley so nothing falls out
|
||||||
|
{ pos: [0, -0.5, -5], half: [14, 0.5, 42], color: '#efe3c6', roughness: 1 },
|
||||||
|
// start pad
|
||||||
|
{ pos: [0, 0.4, 28], half: [6, 0.4, 5], color: '#f7edd2', cast: true },
|
||||||
|
// gate flank walls (centre gap x[-2,2] is the gate) — clear side lanes remain
|
||||||
|
{ pos: [-3, 1.5, -22], half: [1, 1.5, 0.4], color: '#5b6b7a', roughness: 0.85, cast: true },
|
||||||
|
{ pos: [3, 1.5, -22], half: [1, 1.5, 0.4], color: '#5b6b7a', roughness: 0.85, cast: true },
|
||||||
|
// finish podium
|
||||||
|
{ pos: [0, 0.6, -32], half: [7, 0.6, 5], color: '#FF6EB4', cast: true },
|
||||||
|
],
|
||||||
|
machines: [
|
||||||
|
// FAN — a tailwind that sweeps you off the start pad down the alley
|
||||||
|
{ kind: 'fan', cfg: { id: 'ta-fan', position: [0, 1.8, 20], direction: [0, 0, -1], force: 20, range: 18, spread: 4.5 } },
|
||||||
|
// green reward mine — GRIP, and it feeds the green gate downstream
|
||||||
|
{ kind: 'mine', cfg: { id: 'ta-mine', position: [0, 0.05, 4], color: 'green', radius: 0.9, splats: 5, impulse: 5, direction: [0, 1, -0.3], size: [3.4, 3.4] } },
|
||||||
|
// green gate — opens for the runner who took the reward mine (same loop as
|
||||||
|
// Breakfast Rush, proving the gate works identically from a data spec)
|
||||||
|
{ kind: 'gate', cfg: { id: 'ta-gate', position: [0, 0.02, -22], color: 'green', size: [4, 3, 0.6] }, needColor: 'green', needPct: 0.4 },
|
||||||
|
],
|
||||||
|
cannons: [
|
||||||
|
// RED crossfire on the run-up — BURN speed to carry you down the alley
|
||||||
|
{ color: 'red', position: [-9, 3.5, 12], interval: 1.8 },
|
||||||
|
{ color: 'red', position: [9, 3.5, 12], interval: 1.8 },
|
||||||
|
],
|
||||||
|
}
|
||||||
183
src/game.ts
183
src/game.ts
@ -1,46 +1,41 @@
|
|||||||
/**
|
/**
|
||||||
* INTEGRATION SEAM — owned by integration, not by lanes.
|
* INTEGRATION SEAM — owned by integration, not by lanes.
|
||||||
* The playable slice: Lane A blob + course, Lane B paint + buffs, and (when
|
* The playable slice: a blob + paint + buffs, the shared race loop, and a
|
||||||
* lane-c merges) the machine chain on the course's machine area.
|
* COURSE. The flagship "Breakfast Rush" is hand-built (buildBreakfastRush — it
|
||||||
|
* carries a lot of integration tuning); extra courses are data (see course/spec)
|
||||||
|
* and load with `?course=<name>`.
|
||||||
*/
|
*/
|
||||||
import * as THREE from 'three'
|
import * as THREE from 'three'
|
||||||
import type { World } from './contracts'
|
import type { World } from './contracts'
|
||||||
import { buildGreybox } from './course/greybox'
|
import { buildGreybox } from './course/greybox'
|
||||||
import { createBlob } from './blob/createBlob'
|
import { createBlob, type BlobHandle } from './blob/createBlob'
|
||||||
import { createBlobController } from './blob/controller'
|
import { createBlobController } from './blob/controller'
|
||||||
import { createBlobFeel } from './blob/feel'
|
import { createBlobFeel } from './blob/feel'
|
||||||
import { createFollowCamera } from './blob/camera'
|
import { createFollowCamera } from './blob/camera'
|
||||||
import { PaintSkin, PaintCannon, BuffSystem, PaintHUD, installPaint } from './paint/index'
|
import { PaintSkin, PaintCannon, BuffSystem, PaintHUD, installPaint } from './paint/index'
|
||||||
import { createBucketDump, createBubbleArch, createConveyorBelt } from './machine/index'
|
import {
|
||||||
|
createBucketDump, createBubbleArch, createConveyorBelt, createPaintMine, createColorGate,
|
||||||
|
SURFACES, applySurface,
|
||||||
|
} from './machine/index'
|
||||||
import { installZones } from './course/zones'
|
import { installZones } from './course/zones'
|
||||||
|
import { buildCourseFromSpec, COURSES, type FinishBox } from './course/spec'
|
||||||
import { assets } from './assets/registry'
|
import { assets } from './assets/registry'
|
||||||
import { hasOverrides } from './assets/idb'
|
import { hasOverrides } from './assets/idb'
|
||||||
import { installAudio } from './audio/index'
|
import { installAudio } from './audio/index'
|
||||||
import { installGhost } from './ghost/index'
|
import { installGhost } from './ghost/index'
|
||||||
import { installTitle } from './ui/title'
|
import { installTitle } from './ui/title'
|
||||||
|
|
||||||
export function installGame(world: World) {
|
/** Breakfast Rush spawn — matches buildGreybox()'s recommended spawn plateau. */
|
||||||
const course = buildGreybox(world)
|
const BREAKFAST_SPAWN = new THREE.Vector3(0, 3.5, 30)
|
||||||
|
|
||||||
// ---- blob (Lane A) ----
|
/**
|
||||||
const blob = createBlob(world, { position: course.spawn })
|
* Build the hand-tuned flagship course onto the world and return its finish box.
|
||||||
blob.paint = new PaintSkin(blob.mesh, { size: 256 })
|
* (Everything here used to live inline in installGame; it is course-specific.)
|
||||||
world.blob = blob
|
*/
|
||||||
|
function buildBreakfastRush(world: World, blob: BlobHandle, skin: PaintSkin): FinishBox {
|
||||||
world.addSystem(createBlobController(world, blob))
|
buildGreybox(world)
|
||||||
|
|
||||||
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))
|
|
||||||
|
|
||||||
// ---- paint economy (Lane G): puddles + colour zones + MEGA/MINI fork ----
|
// ---- 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 zones = installZones(world, blob, skin)
|
const zones = installZones(world, blob, skin)
|
||||||
for (const spec of zones.cannonConfigs) {
|
for (const spec of zones.cannonConfigs) {
|
||||||
world.addSystem(new PaintCannon({
|
world.addSystem(new PaintCannon({
|
||||||
@ -62,7 +57,6 @@ export function installGame(world: World) {
|
|||||||
// CENTRE lane: un-forked runners get slow-carried under a purple dump —
|
// 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
|
// a free taste of MEGA with no roof above (never dump grow-juice inside
|
||||||
// the MINI tunnel: growing under the roof would wedge you).
|
// the MINI tunnel: growing under the roof would wedge you).
|
||||||
const baseMass = blob.body.mass()
|
|
||||||
createBucketDump(world, {
|
createBucketDump(world, {
|
||||||
id: 'bucket-1', position: [-1.4, 6, -50],
|
id: 'bucket-1', position: [-1.4, 6, -50],
|
||||||
color: 'purple', radius: 0.8,
|
color: 'purple', radius: 0.8,
|
||||||
@ -78,17 +72,67 @@ export function installGame(world: World) {
|
|||||||
id: 'arch-1', position: [-4.5, 0.12, -56], fraction: 0.6, // purple-lane exit: cleansing punishes a failed MEGA attempt
|
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 }))
|
// ---- paint-mines: first-through floor panels on the centre line ----
|
||||||
installPaint(world, { mesh: blob.mesh, paint: skin })
|
// REWARD: roll over it and it launches you forward + coats you GREEN (GRIP) —
|
||||||
|
// a shortcut booster for the runner who takes the middle. HAZARD: further down,
|
||||||
const hud = new PaintHUD()
|
// dumps heavy PURPLE (MEGA) and shoves you toward the pink tunnel lane where a
|
||||||
world.onFrame(() => {
|
// grown blob bonks the roof — the greedy straight-liner pays for it. Both
|
||||||
hud.update(skin.coverage(), blob.modifiers) // coverage() self-caches at 250ms
|
// re-arm each run (race:respawn) so the time-trial stays repeatable.
|
||||||
|
createPaintMine(world, {
|
||||||
|
id: 'mine-reward', position: [0, 0.06, -34], color: 'green', radius: 0.9, splats: 5,
|
||||||
|
impulse: 8, direction: [0, 1, -0.6], size: [3.4, 3.4],
|
||||||
})
|
})
|
||||||
|
createPaintMine(world, {
|
||||||
|
id: 'mine-hazard', position: [0, 0.24, -44], color: 'purple', radius: 0.8, splats: 4,
|
||||||
|
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
|
||||||
|
// line. Arrive un-green and it stays shut, so you steer around the short
|
||||||
|
// flank walls (a detour). Closes the core loop solo: paint decides your route.
|
||||||
|
// Walls only cover the centre (|x|<4) so the fork exits (boot x-6, tunnel
|
||||||
|
// x6.5) are untouched. ----
|
||||||
|
{
|
||||||
|
const gz = -59
|
||||||
|
const wallMat = new THREE.MeshStandardMaterial({ color: '#5b6b7a', roughness: 0.85 })
|
||||||
|
for (const x of [-3, 3] as const) { // flank the centre gate gap (gate is x[-2,2])
|
||||||
|
const wall = new THREE.Mesh(new THREE.BoxGeometry(2, 3, 0.8), wallMat)
|
||||||
|
wall.position.set(x, 1.5, gz)
|
||||||
|
wall.castShadow = true
|
||||||
|
wall.receiveShadow = true
|
||||||
|
world.scene.add(wall)
|
||||||
|
world.physics.createCollider(
|
||||||
|
world.rapier.ColliderDesc.cuboid(1, 1.5, 0.4).setTranslation(x, 1.5, gz))
|
||||||
|
}
|
||||||
|
createColorGate(world, {
|
||||||
|
id: 'gate-green', position: [0, 0.02, gz], color: 'green', size: [4, 3, 0.6],
|
||||||
|
qualifies: () => skin.coverage().byColor.green >= 0.4,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
// ---- gap-recovery trampoline: falling in the gap jump must cost time, not
|
// ---- gap-recovery trampoline: falling in the gap jump must cost time, not
|
||||||
// soft-lock you under the green slope (straight-line drivers wedged at z≈-16.6).
|
// soft-lock you under the green slope (straight-line drivers wedged at z≈-16.6).
|
||||||
// Max-combine restitution bounces fallers back to shelf height. ----
|
// A deterministic launcher bounces fallers back to shelf height. ----
|
||||||
{
|
{
|
||||||
const tramp = new THREE.Mesh(
|
const tramp = new THREE.Mesh(
|
||||||
new THREE.BoxGeometry(12, 0.5, 4),
|
new THREE.BoxGeometry(12, 0.5, 4),
|
||||||
@ -142,14 +186,55 @@ export function installGame(world: World) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- race loop: first movement starts the clock, the pink pad ends it ----
|
|
||||||
// Finish pad volume, extended to z -57.2 so a blob bonking the pad's front
|
// Finish pad volume, extended to z -57.2 so a blob bonking the pad's front
|
||||||
// face (it's a 1.2-high podium, unclimbable from the floor) still counts as
|
// face (a 1.2-high podium, unclimbable from the floor) still counts as a
|
||||||
// touching the finish. Mid-air crossings count too — the boot is a legit finisher.
|
// finish. Mid-air crossings count too — the boot is a legit finisher.
|
||||||
const FINISH = { xMin: -9, xMax: 9, zMin: -70, zMax: -57.2, yMax: 4 }
|
return { xMin: -9, xMax: 9, zMin: -70, zMax: -57.2, yMax: 4 }
|
||||||
|
}
|
||||||
|
|
||||||
|
export function installGame(world: World) {
|
||||||
|
// ---- course selection: default = hand-built Breakfast Rush; ?course=<name>
|
||||||
|
// loads a data-driven course from COURSES. Spawn is known before geometry. ----
|
||||||
|
const which = new URLSearchParams(location.search).get('course')
|
||||||
|
const spec = which ? COURSES[which] : undefined
|
||||||
|
const spawn = spec ? new THREE.Vector3(...spec.spawn) : BREAKFAST_SPAWN.clone()
|
||||||
|
|
||||||
|
// ---- blob (Lane A) ----
|
||||||
|
const blob = createBlob(world, { position: spawn })
|
||||||
|
blob.paint = new PaintSkin(blob.mesh, { size: 256 })
|
||||||
|
world.blob = blob
|
||||||
|
|
||||||
|
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))
|
||||||
|
|
||||||
|
const skin = blob.paint as PaintSkin
|
||||||
|
|
||||||
|
// ---- build the selected course, get its finish box ----
|
||||||
|
const finish = spec
|
||||||
|
? buildCourseFromSpec(world, spec, { blob, skin }).finish
|
||||||
|
: buildBreakfastRush(world, blob, skin)
|
||||||
|
|
||||||
|
world.addSystem(new BuffSystem(world, { mesh: blob.mesh, modifiers: blob.modifiers, paint: skin }))
|
||||||
|
installPaint(world, { mesh: blob.mesh, paint: skin })
|
||||||
|
|
||||||
|
const hud = new PaintHUD()
|
||||||
|
world.onFrame(() => {
|
||||||
|
hud.update(skin.coverage(), blob.modifiers) // coverage() self-caches at 250ms
|
||||||
|
})
|
||||||
|
|
||||||
|
// ---- race loop: first movement starts the clock, the finish box ends it ----
|
||||||
|
// Per-course best time so switching courses doesn't compare apples to oranges.
|
||||||
|
const bestKey = `blobbo:best${spec ? ':' + which : ''}`
|
||||||
let raceState: 'idle' | 'running' | 'finished' = 'idle'
|
let raceState: 'idle' | 'running' | 'finished' = 'idle'
|
||||||
let raceT = 0
|
let raceT = 0
|
||||||
let best: number | null = Number(localStorage.getItem('blobbo:best')) || null
|
let best: number | null = Number(localStorage.getItem(bestKey)) || null
|
||||||
|
|
||||||
const raceUI = document.createElement('div')
|
const raceUI = document.createElement('div')
|
||||||
raceUI.style.cssText =
|
raceUI.style.cssText =
|
||||||
@ -179,14 +264,14 @@ export function installGame(world: World) {
|
|||||||
} else if (raceState === 'running') {
|
} else if (raceState === 'running') {
|
||||||
raceT += dt
|
raceT += dt
|
||||||
const inFinish =
|
const inFinish =
|
||||||
t.x >= FINISH.xMin && t.x <= FINISH.xMax &&
|
t.x >= finish.xMin && t.x <= finish.xMax &&
|
||||||
t.z >= FINISH.zMin && t.z <= FINISH.zMax && t.y <= FINISH.yMax
|
t.z >= finish.zMin && t.z <= finish.zMax && t.y <= finish.yMax
|
||||||
if (inFinish) {
|
if (inFinish) {
|
||||||
raceState = 'finished'
|
raceState = 'finished'
|
||||||
const isBest = best === null || raceT < best
|
const isBest = best === null || raceT < best
|
||||||
if (isBest) {
|
if (isBest) {
|
||||||
best = raceT
|
best = raceT
|
||||||
localStorage.setItem('blobbo:best', String(raceT))
|
localStorage.setItem(bestKey, String(raceT))
|
||||||
}
|
}
|
||||||
banner.innerHTML =
|
banner.innerHTML =
|
||||||
`<div>🏁 FINISH — ${fmt(raceT)}</div>` +
|
`<div>🏁 FINISH — ${fmt(raceT)}</div>` +
|
||||||
@ -208,7 +293,7 @@ export function installGame(world: World) {
|
|||||||
|
|
||||||
// ---- fall respawn + debug ----
|
// ---- fall respawn + debug ----
|
||||||
const respawn = () => {
|
const respawn = () => {
|
||||||
blob.body.setTranslation({ x: course.spawn.x, y: course.spawn.y, z: course.spawn.z }, true)
|
blob.body.setTranslation({ x: spawn.x, y: spawn.y, z: spawn.z }, true)
|
||||||
blob.body.setLinvel({ x: 0, y: 0, z: 0 }, true)
|
blob.body.setLinvel({ x: 0, y: 0, z: 0 }, true)
|
||||||
world.events.emit('paint:request-cleanse', { fraction: 1 })
|
world.events.emit('paint:request-cleanse', { fraction: 1 })
|
||||||
raceState = 'idle'
|
raceState = 'idle'
|
||||||
@ -250,6 +335,20 @@ export function installGame(world: World) {
|
|||||||
setTimeout(() => toast.remove(), 3200)
|
setTimeout(() => toast.remove(), 3200)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// ---- course switcher: a small link to hop between courses (URL param) ----
|
||||||
|
{
|
||||||
|
const other = spec ? { href: 'index.html', label: '🍳 Breakfast Rush' }
|
||||||
|
: { href: '?course=toaster', label: '🍞 Toaster Alley' }
|
||||||
|
const link = document.createElement('a')
|
||||||
|
link.href = other.href
|
||||||
|
link.textContent = `▸ course: ${other.label}`
|
||||||
|
link.style.cssText =
|
||||||
|
'position:fixed;top:56px;right:14px;font:12px ui-monospace,Menlo,monospace;' +
|
||||||
|
'color:#0a2540;background:rgba(255,255,255,.82);padding:6px 11px;border-radius:9px;' +
|
||||||
|
'text-decoration:none;z-index:15;box-shadow:0 1px 6px rgba(0,0,0,.14)'
|
||||||
|
document.body.appendChild(link)
|
||||||
|
}
|
||||||
|
|
||||||
// ---- custom-assets pill: a forgotten Workshop swap otherwise reads as a
|
// ---- custom-assets pill: a forgotten Workshop swap otherwise reads as a
|
||||||
// broken game ("why is the boot a weird cylinder?"), with no way back. ----
|
// broken game ("why is the boot a weird cylinder?"), with no way back. ----
|
||||||
void hasOverrides().then((on) => {
|
void hasOverrides().then((on) => {
|
||||||
@ -270,7 +369,7 @@ export function installGame(world: World) {
|
|||||||
installTitle(world)
|
installTitle(world)
|
||||||
|
|
||||||
// debug handle (dev builds are the only builds right now)
|
// debug handle (dev builds are the only builds right now)
|
||||||
;(window as unknown as { BLOBBO: unknown }).BLOBBO = { world, blob, skin, course }
|
;(window as unknown as { BLOBBO: unknown }).BLOBBO = { world, blob, skin, spawn, finish }
|
||||||
|
|
||||||
return { course, blob }
|
return { blob, spawn, finish }
|
||||||
}
|
}
|
||||||
|
|||||||
@ -21,6 +21,8 @@ export {
|
|||||||
createConveyorBelt,
|
createConveyorBelt,
|
||||||
createBubbleArch,
|
createBubbleArch,
|
||||||
createFan,
|
createFan,
|
||||||
|
createPaintMine,
|
||||||
|
createColorGate,
|
||||||
} from './parts'
|
} from './parts'
|
||||||
export type {
|
export type {
|
||||||
Vec3,
|
Vec3,
|
||||||
@ -32,4 +34,6 @@ export type {
|
|||||||
ConveyorBeltConfig,
|
ConveyorBeltConfig,
|
||||||
BubbleArchConfig,
|
BubbleArchConfig,
|
||||||
FanConfig,
|
FanConfig,
|
||||||
|
PaintMineConfig,
|
||||||
|
ColorGateConfig,
|
||||||
} from './parts'
|
} from './parts'
|
||||||
|
|||||||
@ -770,3 +770,285 @@ export function createFan(world: World, cfg: FanConfig): MachinePart {
|
|||||||
|
|
||||||
return { id: cfg.id, group }
|
return { id: cfg.id, group }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// PaintMine — a floor panel that arms, and when a body rolls onto it TELEGRAPHs
|
||||||
|
// (~0.35s snap windup) then bursts: it coats whoever is on it (paint:request-
|
||||||
|
// splat aimed at each struck body, so installPaint lands it on that blob) and
|
||||||
|
// pops them with an impulse. Reward vs hazard is pure config: a fat splat of a
|
||||||
|
// GOOD colour + an upward launch is a reward; a heavy/unwanted colour + a
|
||||||
|
// sideways shove is a hazard.
|
||||||
|
//
|
||||||
|
// `once` (default true) is the first-through-only trap John asked for: after it
|
||||||
|
// fires it reads as SPENT and won't re-fire — so in a pack the leader trips it
|
||||||
|
// clean and everyone behind sees a used panel (GDD §5.4 persistent course
|
||||||
|
// state). It re-arms on `race:respawn` so every fresh run gets a live panel.
|
||||||
|
// `once:false` re-arms the instant the panel clears (a repeatable hazard).
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
export interface PaintMineConfig {
|
||||||
|
id: string
|
||||||
|
position: Vec3
|
||||||
|
color: PaintColor
|
||||||
|
/** Splat radius requested from the paint lane — bigger = more coverage. */
|
||||||
|
radius: number
|
||||||
|
/** How many splats to douse the body with, spread around it. Default 1.
|
||||||
|
* A single disc only wraps one side; 4-5 reliably crosses the buff threshold. */
|
||||||
|
splats?: number
|
||||||
|
/** Pop impulse applied to each struck body. Default 6. */
|
||||||
|
impulse?: number
|
||||||
|
/** Pop direction (auto-normalised). Default straight up. */
|
||||||
|
direction?: Vec3
|
||||||
|
/** Panel footprint [x, z]. Default [3, 3]. */
|
||||||
|
size?: [number, number]
|
||||||
|
/** First-through-only: fire once, stay spent until race:respawn. Default true. */
|
||||||
|
once?: boolean
|
||||||
|
/** Also fire on this machine:signal (chaining), on top of step-on. */
|
||||||
|
onSignal?: string
|
||||||
|
/** Signal emitted right after it bursts (further chaining). */
|
||||||
|
emits?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createPaintMine(world: World, cfg: PaintMineConfig): MachinePart {
|
||||||
|
const { scene } = world
|
||||||
|
const pos = asVec3(cfg.position)
|
||||||
|
const [w, l] = cfg.size ?? [3, 3]
|
||||||
|
const paintHex = PALETTE[cfg.color]
|
||||||
|
const dir = (cfg.direction ? asVec3(cfg.direction) : new THREE.Vector3(0, 1, 0)).normalize()
|
||||||
|
const impulse = cfg.impulse ?? 6
|
||||||
|
const once = cfg.once !== false
|
||||||
|
|
||||||
|
const group = new THREE.Group()
|
||||||
|
group.position.copy(pos)
|
||||||
|
scene.add(group)
|
||||||
|
|
||||||
|
// dark warning rim under a live colour panel proud of it. NO collider: the
|
||||||
|
// course floor already carries the blob, so a panel can never open a hole or
|
||||||
|
// grow an invisible wall — it's a decal + a trigger volume just above it.
|
||||||
|
const rimMat = new THREE.MeshStandardMaterial({ color: '#1a1a1f', roughness: 0.85 })
|
||||||
|
const rim = new THREE.Mesh(new THREE.BoxGeometry(w + 0.28, 0.16, l + 0.28), rimMat)
|
||||||
|
rim.position.y = 0.05
|
||||||
|
rim.receiveShadow = true
|
||||||
|
group.add(rim)
|
||||||
|
|
||||||
|
const panelMat = new THREE.MeshStandardMaterial({
|
||||||
|
color: paintHex, roughness: 0.5, metalness: 0.1,
|
||||||
|
emissive: paintHex, emissiveIntensity: 0.35,
|
||||||
|
})
|
||||||
|
const panel = new THREE.Mesh(new THREE.BoxGeometry(w, 0.12, l), panelMat)
|
||||||
|
panel.position.y = 0.13
|
||||||
|
panel.receiveShadow = true
|
||||||
|
group.add(panel)
|
||||||
|
|
||||||
|
const detCenter = { x: pos.x, y: pos.y + 0.6, z: pos.z }
|
||||||
|
const detHalf = { x: w * 0.5, y: 0.6, z: l * 0.5 }
|
||||||
|
|
||||||
|
// paint-coloured particle burst (same shape as the bubble arch's burst)
|
||||||
|
const bursts: Array<(dt: number) => void> = []
|
||||||
|
const burst = () => {
|
||||||
|
const parts: THREE.Mesh[] = []
|
||||||
|
const vels: THREE.Vector3[] = []
|
||||||
|
for (let i = 0; i < 16; i++) {
|
||||||
|
const s = new THREE.Mesh(
|
||||||
|
new THREE.SphereGeometry(0.1 + Math.random() * 0.16, 8, 6),
|
||||||
|
new THREE.MeshStandardMaterial({ color: paintHex, transparent: true, opacity: 0.95, roughness: 0.3 }),
|
||||||
|
)
|
||||||
|
s.position.set(pos.x + (Math.random() - 0.5) * w, pos.y + 0.3, pos.z + (Math.random() - 0.5) * l)
|
||||||
|
scene.add(s)
|
||||||
|
parts.push(s)
|
||||||
|
vels.push(new THREE.Vector3((Math.random() - 0.5) * 3, 2 + Math.random() * 3, (Math.random() - 0.5) * 3))
|
||||||
|
}
|
||||||
|
let life = 0
|
||||||
|
const step = (dt: number) => {
|
||||||
|
life += dt
|
||||||
|
parts.forEach((s, i) => {
|
||||||
|
vels[i].y -= 9 * dt
|
||||||
|
s.position.addScaledVector(vels[i], dt)
|
||||||
|
;(s.material as THREE.MeshStandardMaterial).opacity = Math.max(0, 0.95 - life * 0.9)
|
||||||
|
})
|
||||||
|
if (life > 1.1) {
|
||||||
|
for (const s of parts) {
|
||||||
|
scene.remove(s)
|
||||||
|
s.geometry.dispose()
|
||||||
|
;(s.material as THREE.Material).dispose()
|
||||||
|
}
|
||||||
|
const idx = bursts.indexOf(step)
|
||||||
|
if (idx >= 0) bursts.splice(idx, 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
bursts.push(step)
|
||||||
|
}
|
||||||
|
|
||||||
|
let fired = false
|
||||||
|
let armed = true
|
||||||
|
|
||||||
|
const markLive = () => { panelMat.color.set(paintHex); panelMat.emissive.set(paintHex); panelMat.emissiveIntensity = 0.35 }
|
||||||
|
const markSpent = () => { panelMat.color.set('#6b6b6b'); panelMat.emissive.setHex(0x000000); panelMat.emissiveIntensity = 0 }
|
||||||
|
|
||||||
|
const fire = () => {
|
||||||
|
if ((once && fired) || isTelegraphing(world, group)) return
|
||||||
|
// Capture who's on the panel NOW (windup start): a fast roller has left the
|
||||||
|
// trigger box by the time the ~0.35s telegraph completes, so we pop/coat the
|
||||||
|
// bodies that tripped it (at their live position) rather than re-scanning an
|
||||||
|
// empty box at fire time.
|
||||||
|
const struck = dynamicBodiesInBox(world, detCenter, detHalf)
|
||||||
|
telegraph(world, group, {
|
||||||
|
duration: 0.35,
|
||||||
|
flashColor: paintHex,
|
||||||
|
scalePulse: 0.2,
|
||||||
|
shake: 0.06,
|
||||||
|
onFire: () => {
|
||||||
|
// Douse offsets: centre + around the body, so a burst wraps more than one
|
||||||
|
// UV face. splatAtPoint stamps where the world point meets the body, so
|
||||||
|
// offsetting the point hits different sides (installPaint's proximity gate
|
||||||
|
// still passes — offsets are well inside worldRadius + splat radius).
|
||||||
|
const offsets = [[0, 0.35, 0], [0.4, 0, 0], [-0.4, 0, 0], [0, 0, 0.4], [0, 0, -0.4]]
|
||||||
|
const n = Math.max(1, cfg.splats ?? 1)
|
||||||
|
for (const b of struck.values()) {
|
||||||
|
const t = b.translation()
|
||||||
|
b.applyImpulse({ x: dir.x * impulse, y: dir.y * impulse, z: dir.z * impulse }, true)
|
||||||
|
for (let i = 0; i < n; i++) {
|
||||||
|
const [ox, oy, oz] = offsets[i % offsets.length]
|
||||||
|
world.events.emit('paint:request-splat', {
|
||||||
|
point: new THREE.Vector3(t.x + ox, t.y + oy, t.z + oz), color: cfg.color, radius: cfg.radius,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
burst()
|
||||||
|
if (cfg.emits) world.events.emit('machine:signal', { id: cfg.emits })
|
||||||
|
if (once) { fired = true; markSpent() }
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if (cfg.onSignal) {
|
||||||
|
world.events.on('machine:signal', ({ id }: { id: string }) => {
|
||||||
|
if (id === cfg.onSignal) fire()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fresh panel every run: a spent one-shot re-arms when the race restarts.
|
||||||
|
world.events.on('race:respawn', () => { fired = false; armed = true; markLive() })
|
||||||
|
|
||||||
|
world.addSystem({
|
||||||
|
update() {
|
||||||
|
const occupied = dynamicBodiesInBox(world, detCenter, detHalf).size > 0
|
||||||
|
if (occupied && armed) {
|
||||||
|
armed = false
|
||||||
|
fire()
|
||||||
|
} else if (!occupied && !once) {
|
||||||
|
armed = true // repeatable hazard re-arms on clear; a one-shot never does
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
world.onFrame((dt) => {
|
||||||
|
for (const b of [...bursts]) b(dt)
|
||||||
|
})
|
||||||
|
|
||||||
|
return { id: cfg.id, group }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// ColorGate — a portcullis that stays SHUT unless an approaching blob carries
|
||||||
|
// enough of a keyed colour, then slides into the floor to let it through and
|
||||||
|
// closes behind. This is the GDD §5.4 colour-keyed shortcut: the course paints
|
||||||
|
// you, and being painted opens your route. The door colour IS the key (a green
|
||||||
|
// door needs green), matching the pink-tunnel signage language.
|
||||||
|
//
|
||||||
|
// The part never imports the paint lane — integration injects `qualifies()`
|
||||||
|
// (e.g. `() => skin.coverage().byColor.green >= 0.4`), so the seam stays clean
|
||||||
|
// and this works for whatever "enough colour" the course wants.
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
export interface ColorGateConfig {
|
||||||
|
id: string
|
||||||
|
position: Vec3
|
||||||
|
/** Which colour keys the gate — sets the door tint (the visible signage). */
|
||||||
|
color: PaintColor
|
||||||
|
/** Door slab [width, height, depth]. Default [4, 3, 0.6]. */
|
||||||
|
size?: Vec3
|
||||||
|
/** Approach-zone half-extents on the +Z (racer) side. Default derived from size. */
|
||||||
|
detectHalf?: Vec3
|
||||||
|
/** True when the approaching blob carries enough of `color`. Injected by
|
||||||
|
* integration to keep this part decoupled from the paint lane. */
|
||||||
|
qualifies: () => boolean
|
||||||
|
/** Signal emitted on the shut→open edge (further chaining). */
|
||||||
|
emits?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createColorGate(world: World, cfg: ColorGateConfig): MachinePart {
|
||||||
|
const { physics, rapier, scene } = world
|
||||||
|
const pos = asVec3(cfg.position)
|
||||||
|
const [w, h, d] = cfg.size ?? [4, 3, 0.6]
|
||||||
|
const hex = PALETTE[cfg.color]
|
||||||
|
|
||||||
|
const group = new THREE.Group()
|
||||||
|
group.position.copy(pos)
|
||||||
|
scene.add(group)
|
||||||
|
|
||||||
|
// cosmetic frame: two posts + a coloured lintel so the gate reads as a gate.
|
||||||
|
const postMat = new THREE.MeshStandardMaterial({ color: '#37474f', metalness: 0.4, roughness: 0.5 })
|
||||||
|
for (const sx of [-1, 1]) {
|
||||||
|
const post = new THREE.Mesh(new THREE.CylinderGeometry(0.22, 0.22, h + 0.8, 12), postMat)
|
||||||
|
post.position.set(sx * (w / 2 + 0.3), (h + 0.8) / 2, 0)
|
||||||
|
post.castShadow = true
|
||||||
|
group.add(post)
|
||||||
|
}
|
||||||
|
const lintel = new THREE.Mesh(
|
||||||
|
new THREE.BoxGeometry(w + 1.0, 0.4, d + 0.3),
|
||||||
|
new THREE.MeshStandardMaterial({ color: hex, emissive: hex, emissiveIntensity: 0.35, roughness: 0.5 }),
|
||||||
|
)
|
||||||
|
lintel.position.y = h + 0.6
|
||||||
|
group.add(lintel)
|
||||||
|
|
||||||
|
// the door — colour is the key. Fixed body + collider we slide into the floor.
|
||||||
|
const doorMat = new THREE.MeshStandardMaterial({
|
||||||
|
color: hex, roughness: 0.45, metalness: 0.1,
|
||||||
|
emissive: hex, emissiveIntensity: 0.25, transparent: true, opacity: 0.92,
|
||||||
|
})
|
||||||
|
const door = new THREE.Mesh(new THREE.BoxGeometry(w, h, d), doorMat)
|
||||||
|
door.castShadow = true
|
||||||
|
door.receiveShadow = true
|
||||||
|
group.add(door)
|
||||||
|
|
||||||
|
const closedY = pos.y + h / 2
|
||||||
|
const openY = closedY - (h + 0.3) // sink fully into the ground
|
||||||
|
const doorBody = physics.createRigidBody(
|
||||||
|
rapier.RigidBodyDesc.fixed().setTranslation(pos.x, closedY, pos.z),
|
||||||
|
)
|
||||||
|
physics.createCollider(rapier.ColliderDesc.cuboid(w / 2, h / 2, d / 2), doorBody)
|
||||||
|
|
||||||
|
const dh = cfg.detectHalf ?? [w / 2 + 1, h / 2 + 0.6, 3]
|
||||||
|
// detector on the +Z approach side (racers run toward -Z through the course)
|
||||||
|
const detCenter = { x: pos.x, y: pos.y + h / 2, z: pos.z + dh[2] }
|
||||||
|
const detHalf = { x: dh[0], y: dh[1], z: dh[2] }
|
||||||
|
|
||||||
|
let open = false
|
||||||
|
let curY = closedY
|
||||||
|
let targetY = closedY
|
||||||
|
|
||||||
|
// Everything runs in the FIXED step, including the door's slide. The door is a
|
||||||
|
// COLLIDER (gameplay) — if its motion lived in onFrame it would freeze in a
|
||||||
|
// hidden tab and leave a half-open gate anyone could walk through (the lesson
|
||||||
|
// telegraph.ts and BucketDump already learned). Mesh + glow ride along here at
|
||||||
|
// 60Hz, which is smooth enough for a door.
|
||||||
|
world.addSystem({
|
||||||
|
update(dt) {
|
||||||
|
const near = dynamicBodiesInBox(world, detCenter, detHalf).size > 0
|
||||||
|
const shouldOpen = near && cfg.qualifies()
|
||||||
|
if (shouldOpen && !open) {
|
||||||
|
open = true
|
||||||
|
targetY = openY
|
||||||
|
if (cfg.emits) world.events.emit('machine:signal', { id: cfg.emits })
|
||||||
|
} else if (!near && open) {
|
||||||
|
open = false
|
||||||
|
targetY = closedY
|
||||||
|
}
|
||||||
|
curY = lerp(curY, targetY, Math.min(1, dt * 6))
|
||||||
|
door.position.y = curY - pos.y // mesh is a child of the group at pos
|
||||||
|
doorBody.setTranslation({ x: pos.x, y: curY, z: pos.z }, true)
|
||||||
|
doorMat.emissiveIntensity = open ? 0.7 : 0.25
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
return { id: cfg.id, group }
|
||||||
|
}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user