feat(course): data-driven courses + a second course (Toaster Alley)
A course is now a plain data object (boxes + machines + cannons + spawn/finish) built by buildCourseFromSpec, which dispatches to the greybox box builder and the Lane C machine factories. New courses are DATA, not a game.ts fork; load one with ?course=<name>. Added COURSES.toaster = Toaster Alley, a flat, reliably completable second level that puts the never-placed FAN into play (tailwind) and reuses the mine→gate loop from a spec. game.ts refactor: Breakfast Rush's tuned build moves verbatim into buildBreakfastRush (returns its finish box); installGame picks the course by ?course=, parametrises the race loop's finish + respawn spawn, keeps a per-course best time, and adds a small on-screen course switcher. Breakfast Rush is untouched behaviourally (greybox now builds after the blob — colliders still exist before the first physics step). Deliberately NOT done: porting the finely-tuned Breakfast Rush to a spec (no gameplay gain, real regression risk), and placing a see-saw (it's a supported spec kind but wants an elevated bridge = deliberate level design). Browser-verified: Toaster Alley completes end-to-end (best 4.73s saved under its own key, green mine→gate loop opens the line); Breakfast Rush unchanged (spawns on the plateau, crosses the gap-jump, puddles paint it). tsc + vite build green; all 10 test suites pass (360 assertions). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
333f11b9db
commit
67921925f5
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 },
|
||||
],
|
||||
}
|
||||
124
src/game.ts
124
src/game.ts
@ -1,12 +1,14 @@
|
||||
/**
|
||||
* INTEGRATION SEAM — owned by integration, not by lanes.
|
||||
* The playable slice: Lane A blob + course, Lane B paint + buffs, and (when
|
||||
* lane-c merges) the machine chain on the course's machine area.
|
||||
* The playable slice: a blob + paint + buffs, the shared race loop, and a
|
||||
* 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 type { World } from './contracts'
|
||||
import { buildGreybox } from './course/greybox'
|
||||
import { createBlob } from './blob/createBlob'
|
||||
import { createBlob, type BlobHandle } from './blob/createBlob'
|
||||
import { createBlobController } from './blob/controller'
|
||||
import { createBlobFeel } from './blob/feel'
|
||||
import { createFollowCamera } from './blob/camera'
|
||||
@ -16,34 +18,24 @@ import {
|
||||
SURFACES, applySurface,
|
||||
} from './machine/index'
|
||||
import { installZones } from './course/zones'
|
||||
import { buildCourseFromSpec, COURSES, type FinishBox } from './course/spec'
|
||||
import { assets } from './assets/registry'
|
||||
import { hasOverrides } from './assets/idb'
|
||||
import { installAudio } from './audio/index'
|
||||
import { installGhost } from './ghost/index'
|
||||
import { installTitle } from './ui/title'
|
||||
|
||||
export function installGame(world: World) {
|
||||
const course = buildGreybox(world)
|
||||
/** Breakfast Rush spawn — matches buildGreybox()'s recommended spawn plateau. */
|
||||
const BREAKFAST_SPAWN = new THREE.Vector3(0, 3.5, 30)
|
||||
|
||||
// ---- blob (Lane A) ----
|
||||
const blob = createBlob(world, { position: course.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))
|
||||
/**
|
||||
* Build the hand-tuned flagship course onto the world and return its finish box.
|
||||
* (Everything here used to live inline in installGame; it is course-specific.)
|
||||
*/
|
||||
function buildBreakfastRush(world: World, blob: BlobHandle, skin: PaintSkin): FinishBox {
|
||||
buildGreybox(world)
|
||||
|
||||
// ---- 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)
|
||||
for (const spec of zones.cannonConfigs) {
|
||||
world.addSystem(new PaintCannon({
|
||||
@ -65,7 +57,6 @@ export function installGame(world: World) {
|
||||
// CENTRE lane: un-forked runners get slow-carried under a purple dump —
|
||||
// a free taste of MEGA with no roof above (never dump grow-juice inside
|
||||
// the MINI tunnel: growing under the roof would wedge you).
|
||||
const baseMass = blob.body.mass()
|
||||
createBucketDump(world, {
|
||||
id: 'bucket-1', position: [-1.4, 6, -50],
|
||||
color: 'purple', radius: 0.8,
|
||||
@ -139,17 +130,9 @@ export function installGame(world: World) {
|
||||
})
|
||||
}
|
||||
|
||||
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
|
||||
})
|
||||
|
||||
// ---- 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).
|
||||
// Max-combine restitution bounces fallers back to shelf height. ----
|
||||
// A deterministic launcher bounces fallers back to shelf height. ----
|
||||
{
|
||||
const tramp = new THREE.Mesh(
|
||||
new THREE.BoxGeometry(12, 0.5, 4),
|
||||
@ -203,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
|
||||
// face (it's a 1.2-high podium, unclimbable from the floor) still counts as
|
||||
// touching the 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 }
|
||||
// face (a 1.2-high podium, unclimbable from the floor) still counts as a
|
||||
// finish. Mid-air crossings count too — the boot is a legit finisher.
|
||||
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 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')
|
||||
raceUI.style.cssText =
|
||||
@ -240,14 +264,14 @@ export function installGame(world: World) {
|
||||
} else if (raceState === 'running') {
|
||||
raceT += dt
|
||||
const inFinish =
|
||||
t.x >= FINISH.xMin && t.x <= FINISH.xMax &&
|
||||
t.z >= FINISH.zMin && t.z <= FINISH.zMax && t.y <= FINISH.yMax
|
||||
t.x >= finish.xMin && t.x <= finish.xMax &&
|
||||
t.z >= finish.zMin && t.z <= finish.zMax && t.y <= finish.yMax
|
||||
if (inFinish) {
|
||||
raceState = 'finished'
|
||||
const isBest = best === null || raceT < best
|
||||
if (isBest) {
|
||||
best = raceT
|
||||
localStorage.setItem('blobbo:best', String(raceT))
|
||||
localStorage.setItem(bestKey, String(raceT))
|
||||
}
|
||||
banner.innerHTML =
|
||||
`<div>🏁 FINISH — ${fmt(raceT)}</div>` +
|
||||
@ -269,7 +293,7 @@ export function installGame(world: World) {
|
||||
|
||||
// ---- fall respawn + debug ----
|
||||
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)
|
||||
world.events.emit('paint:request-cleanse', { fraction: 1 })
|
||||
raceState = 'idle'
|
||||
@ -311,6 +335,20 @@ export function installGame(world: World) {
|
||||
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
|
||||
// broken game ("why is the boot a weird cylinder?"), with no way back. ----
|
||||
void hasOverrides().then((on) => {
|
||||
@ -331,7 +369,7 @@ export function installGame(world: World) {
|
||||
installTitle(world)
|
||||
|
||||
// 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 }
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user