SOURCE / PINNED RELEASE
Made of little things.
Mini Samurai
- Release
- a7beb798adbc…
- Author-recorded commit
- 4f97be1515b8…
- License
- LICENSE
- Author’s source reference
- nostr://npub12lm3rxsva8kfempyjkxupt09e02n3fezurkyadyqfejdc5e0r2kscqty6r/wss%3A%2F%2Fgit.napplet.soy%2F/n-3b28f0efc5b
Archive hash verified: 401ec9347ab8f8cd…. The source-to-build association is the author’s claim; it has not been independently rebuilt.
import * as THREE from 'three';
import { buildTrack } from './train';
import type { Area, DripDef, MachineDef, PoolDef, RollerDef, RoomData, RoomId, Solid, SpawnPoint, TrackDef, WallPlane } from './types';
/**
* The four arenas: giant rooms built from axis-aligned boxes at 1 unit = 10 cm.
* Every big furniture top is reachable from the floor through a chain of
* household "steps" 1.0–2.6 units apart (fighters jump ~1.4 and grab ledges
* 0.8–1.75 above their feet). Gaps between solids are either 0 or >= 0.7.
*/
export const ROOMS: { id: RoomId; name: string; blurb: string }[] = [
{ id: 'living', name: 'Living Room', blurb: 'Sofa cliffs, a coffee-table plateau and a bookcase fort.' },
{ id: 'kitchen', name: 'Kitchen', blurb: 'Checker tiles, a cherry-red fridge and the great counter climb.' },
{ id: 'bedroom', name: 'Bedroom', blurb: 'Blanket hills, a crawl under the bed and a dresser of open drawers.' },
{ id: 'bathroom', name: 'Bathroom', blurb: 'Slick tiles, a bathtub pit and a toilet-paper tower.' },
{ id: 'garage', name: 'Garage', blurb: 'A stepladder, a lawn mower, tyres and a workbench of tools.' },
{ id: 'house', name: 'Whole House', blurb: 'Every room off one hallway, a dining room, and a toy train to ride.' },
];
const ROOM_NAMES: Partial<Record<RoomId, string>> = { dining: 'Dining Room' };
export function buildRoom(id: RoomId): RoomData {
switch (id) {
case 'living':
return buildLiving();
case 'kitchen':
return buildKitchen();
case 'bedroom':
return buildBedroom();
case 'bathroom':
return buildBathroom();
case 'dining':
return buildDining();
case 'garage':
return buildGarage();
case 'house':
return buildHouse();
}
}
// ---------------------------------------------------------------------------
// Shared resources (geometries are unit-sized and scaled per mesh; materials are
// cached by colour). They are shared between rooms, so callers must not dispose them.
type Shape = 'box' | 'cyl' | 'cyl6' | 'cone' | 'shade' | 'bowl' | 'ball';
const GEO: Record<Shape, THREE.BufferGeometry> = {
box: new THREE.BoxGeometry(1, 1, 1),
cyl: new THREE.CylinderGeometry(0.5, 0.5, 1, 10),
cyl6: new THREE.CylinderGeometry(0.5, 0.5, 1, 6),
cone: new THREE.CylinderGeometry(0.05, 0.5, 1, 7),
shade: new THREE.CylinderGeometry(0.3, 0.5, 1, 8),
bowl: new THREE.CylinderGeometry(0.5, 0.34, 1, 10),
ball: new THREE.IcosahedronGeometry(0.5, 1),
};
const MATS = new Map<string, THREE.MeshStandardMaterial>();
function mat(color: number, emissive = 0): THREE.MeshStandardMaterial {
const key = `${color}:${emissive}`;
let m = MATS.get(key);
if (!m) {
m = new THREE.MeshStandardMaterial({
color,
emissive,
emissiveIntensity: emissive ? 0.85 : 0,
flatShading: true,
roughness: 0.8,
metalness: 0,
});
MATS.set(key, m);
}
return m;
}
function rng(seed: number): () => number {
let s = seed;
return () => (s = (s * 16807) % 2147483647) / 2147483647;
}
// ---------------------------------------------------------------------------
// Builder
interface Opts {
shape?: Shape;
name?: string;
grab?: boolean;
parent?: THREE.Object3D;
rotY?: number;
emissive?: number;
slide?: boolean;
cast?: boolean;
}
/** A doorway in one wall: from u0 to u1 along the wall, h high (20 by default). */
interface DoorSpec {
u0: number;
u1: number;
h?: number;
}
type Doors = Partial<Record<WallSide, DoorSpec[]>>;
class RoomBuilder {
readonly root = new THREE.Group();
readonly solids: Solid[] = [];
readonly cutaway: RoomData['cutaway'] = [];
/** Walkable strips through the walls' doorways (part of a house). */
readonly doorAreas: Area[] = [];
/** `doors` is set when the room is part of the house: walls get openings and the floor stops at the walls. */
constructor(
readonly bounds: RoomData['bounds'],
readonly doors?: Doors,
) {}
/** Collider only. */
solid(x0: number, y0: number, z0: number, x1: number, y1: number, z1: number, name?: string, grab = true, slide = false, round = false): void {
this.solids.push({
round: round || undefined,
minX: Math.min(x0, x1),
minY: Math.min(y0, y1),
minZ: Math.min(z0, z1),
maxX: Math.max(x0, x1),
maxY: Math.max(y0, y1),
maxZ: Math.max(z0, z1),
grab,
slide: slide || undefined,
name,
});
}
/** Mesh only, filling the given box. */
deco(x0: number, y0: number, z0: number, x1: number, y1: number, z1: number, color: number, o: Opts = {}): THREE.Mesh {
const m = new THREE.Mesh(GEO[o.shape ?? 'box'], mat(color, o.emissive ?? 0));
m.position.set((x0 + x1) / 2, (y0 + y1) / 2, (z0 + z1) / 2);
m.scale.set(Math.abs(x1 - x0), Math.abs(y1 - y0), Math.abs(z1 - z0));
if (o.rotY) m.rotation.y = o.rotY;
m.castShadow = o.cast ?? true;
m.receiveShadow = true;
(o.parent ?? this.root).add(m);
return m;
}
/** Mesh plus matching collider (round when the shape is, and its footprint is square). */
box(x0: number, y0: number, z0: number, x1: number, y1: number, z1: number, color: number, o: Opts = {}): THREE.Mesh {
const roundShape = o.shape === 'cyl' || o.shape === 'cyl6' || o.shape === 'bowl' || o.shape === 'ball' || o.shape === 'shade';
const square = Math.abs(Math.abs(x1 - x0) - Math.abs(z1 - z0)) < 0.05;
this.solid(x0, y0, z0, x1, y1, z1, o.name, o.grab ?? true, o.slide ?? false, roundShape && square);
return this.deco(x0, y0, z0, x1, y1, z1, color, o);
}
/** Round object (cylinder by default) centred on (cx, cz), with its AABB as collider. */
round(cx: number, y0: number, cz: number, r: number, h: number, color: number, o: Opts = {}, solid = true): THREE.Mesh {
const args = [cx - r, y0, cz - r, cx + r, y0 + h, cz + r] as const;
const opts = { shape: 'cyl' as Shape, ...o };
return solid ? this.box(...args, color, opts) : this.deco(...args, color, opts);
}
}
// ---------------------------------------------------------------------------
// Room shell: floor collider, four cutaway walls with baseboards, windows
type WallSide = 'back' | 'front' | 'left' | 'right';
interface Wall {
group: THREE.Group;
along: 'x' | 'z';
face: number;
inward: 1 | -1;
}
/** Deco box on a wall: u runs along the wall, d is depth into the room from its face. */
function wallBox(
b: RoomBuilder,
w: Wall,
u0: number,
u1: number,
y0: number,
y1: number,
d0: number,
d1: number,
color: number,
o: Opts = {},
): THREE.Mesh {
const a = w.face + w.inward * d0;
const c = w.face + w.inward * d1;
const opts = { parent: w.group, ...o };
return w.along === 'x' ? b.deco(u0, y0, a, u1, y1, c, color, opts) : b.deco(a, y0, u0, c, y1, u1, color, opts);
}
/** Collider on a wall, in the same wall coordinates as wallBox. */
function wallSolid(b: RoomBuilder, w: Wall, u0: number, u1: number, y0: number, y1: number, d0: number, d1: number, name: string, grab = false): void {
const a = w.face + w.inward * d0;
const c = w.face + w.inward * d1;
if (w.along === 'x') b.solid(u0, y0, a, u1, y1, c, name, grab);
else b.solid(a, y0, u0, c, y1, u1, name, grab);
}
/** The parts of [lo, hi] not covered by doorways. */
function openSpans(lo: number, hi: number, doors: DoorSpec[]): [number, number][] {
const out: [number, number][] = [];
let u = lo;
for (const d of [...doors].sort((p, q) => p.u0 - q.u0)) {
if (d.u0 > u) out.push([u, d.u0]);
u = Math.max(u, d.u1);
}
if (u < hi) out.push([u, hi]);
return out;
}
const WALL_T = 1;
/** Height of the bottom strip of wall that stays solid when the rest fades away. */
const WALL_BASE = 1.5;
const WALL_H = 25;
const DOOR_H = 20;
function shell(
b: RoomBuilder,
wallColor: number,
trim: number,
wainscot?: { color: number; top: number; rail: number },
): Record<WallSide, Wall> {
const { minX, maxX, minZ, maxZ } = b.bounds;
const T = WALL_T;
const H = WALL_H;
// In the house the floor stops at the walls, so the AI only finds its way between rooms through doorways.
if (b.doors) b.solid(minX, -1, minZ, maxX, 0, maxZ, 'floor', false);
else b.solid(minX - 2, -1, minZ - 2, maxX + 2, 0, maxZ + 2, 'floor', false);
const make = (
side: WallSide,
along: 'x' | 'z',
face: number,
inward: 1 | -1,
normal: THREE.Vector3,
w0: number,
w1: number,
u0: number,
u1: number,
): Wall => {
const group = new THREE.Group();
group.name = `wall-${side}`;
b.root.add(group);
const w: Wall = { group, along, face, inward };
const plane: WallPlane = { axis: along === 'x' ? 'z' : 'x', at: face - (inward * T) / 2, u0: w0, u1: w1 };
b.cutaway.push({ mesh: group, normal, plane });
const doors = b.doors?.[side] ?? [];
for (const [s0, s1] of openSpans(w0, w1, doors)) {
wallSolid(b, w, s0, s1, -1, H, -T, 0, 'wall');
wallBox(b, w, s0, s1, -1, WALL_BASE, -T, 0, wallColor, { cast: false, parent: b.root });
wallBox(b, w, s0, s1, WALL_BASE, H, -T, 0, wallColor, { cast: false });
}
const open = openSpans(u0, u1, doors);
for (const [s0, s1] of open) wallBox(b, w, s0, s1, 0, 0.8, 0, 0.15, trim, { cast: false, parent: b.root });
if (wainscot) {
for (const [s0, s1] of open) {
wallBox(b, w, s0, s1, 0.8, wainscot.top, 0, 0.06, wainscot.color, { cast: false });
wallBox(b, w, s0, s1, wainscot.top, wainscot.top + 0.3, 0, 0.14, wainscot.rail, { cast: false });
}
}
for (const d of doors) {
const h = d.h ?? DOOR_H;
// Lintel over the opening, a casing round it and a floor strip through the wall.
wallSolid(b, w, d.u0, d.u1, h, H, -T, 0, 'wall');
wallBox(b, w, d.u0, d.u1, h, H, -T, 0, wallColor, { cast: false });
wallBox(b, w, d.u0 - 0.6, d.u0, 0, h + 0.6, 0, 0.18, trim);
wallBox(b, w, d.u1, d.u1 + 0.6, 0, h + 0.6, 0, 0.18, trim);
wallBox(b, w, d.u0 - 0.6, d.u1 + 0.6, h, h + 0.6, 0, 0.18, trim);
wallBox(b, w, d.u0, d.u1, -0.3, 0.02, -T, 0, 0x8a6a48, { cast: false });
wallSolid(b, w, d.u0, d.u1, -1, 0, -T, 0, 'doorway');
const a = face - inward * T;
if (along === 'x') b.doorAreas.push({ minX: d.u0, maxX: d.u1, minZ: Math.min(a, face), maxZ: Math.max(a, face), doorway: true });
else b.doorAreas.push({ minX: Math.min(a, face), maxX: Math.max(a, face), minZ: d.u0, maxZ: d.u1, doorway: true });
}
return w;
};
return {
back: make('back', 'x', minZ, 1, new THREE.Vector3(0, 0, 1), minX - T, maxX + T, minX, maxX),
front: make('front', 'x', maxZ, -1, new THREE.Vector3(0, 0, -1), minX - T, maxX + T, minX, maxX),
left: make('left', 'z', minX, 1, new THREE.Vector3(1, 0, 0), minZ, maxZ, minZ, maxZ),
right: make('right', 'z', maxX, -1, new THREE.Vector3(-1, 0, 0), minZ, maxZ, minZ, maxZ),
};
}
/** Glowing afternoon window with frame, mullions, sill and optional curtains. */
function windowOn(b: RoomBuilder, w: Wall, u0: number, u1: number, y0: number, y1: number, trim: number, curtain?: number): void {
wallBox(b, w, u0 - 0.6, u1 + 0.6, y0 - 0.6, y1 + 0.6, 0, 0.2, trim);
wallBox(b, w, u0, u1, y0, y1, 0, 0.26, 0xfff4d6, { emissive: 0xffd99a, cast: false });
const um = (u0 + u1) / 2;
const ym = (y0 + y1) / 2;
wallBox(b, w, um - 0.15, um + 0.15, y0, y1, 0, 0.35, trim);
wallBox(b, w, u0, u1, ym - 0.15, ym + 0.15, 0, 0.35, trim);
// The sill is a real ledge: grab it, climb up and stand on it.
wallBox(b, w, u0 - 1, u1 + 1, y0 - 1, y0 - 0.6, 0, 0.9, trim);
const s0 = w.face;
const s1 = w.face + w.inward * 0.9;
if (w.along === 'x') b.solid(u0 - 1, y0 - 1, s0, u1 + 1, y0 - 0.6, s1, 'window-sill');
else b.solid(s0, y0 - 1, u0 - 1, s1, y0 - 0.6, u1 + 1, 'window-sill');
if (curtain !== undefined) {
wallBox(b, w, u0 - 2.4, u0 - 0.8, y0 - 2.5, y1 + 1.2, 0.3, 0.8, curtain);
wallBox(b, w, u1 + 0.8, u1 + 2.4, y0 - 2.5, y1 + 1.2, 0.3, 0.8, curtain);
wallBox(b, w, u0 - 3, u1 + 3, y1 + 1.2, y1 + 1.5, 0.4, 0.7, 0x6b4428);
}
}
/** Picture frame with a coloured canvas. */
function picture(b: RoomBuilder, w: Wall, u0: number, u1: number, y0: number, y1: number, frame: number, art: number): void {
wallBox(b, w, u0, u1, y0, y1, 0, 0.2, frame);
wallBox(b, w, u0 + 0.5, u1 - 0.5, y0 + 0.5, y1 - 0.5, 0, 0.25, art, { cast: false });
}
// ---------------------------------------------------------------------------
// Floors
function plankFloor(b: RoomBuilder, c1: number, c2: number, seam: number): void {
const { minX, maxX, minZ, maxZ } = b.bounds;
b.deco(minX, -0.4, minZ, maxX, -0.1, maxZ, seam, { cast: false });
const rows = Math.round((maxZ - minZ) / 2);
for (let i = 0; i < rows; i++) {
const z0 = minZ + i * 2 + 0.04;
const z1 = z0 + 1.92;
const split = minX + 8 + ((i * 7) % 5) * 6.5;
b.deco(minX, -0.3, z0, split - 0.05, 0, z1, i % 2 ? c1 : c2, { cast: false });
b.deco(split + 0.05, -0.3, z0, maxX, 0, z1, i % 2 ? c2 : c1, { cast: false });
}
}
/** Checkerboard tiles as two instanced meshes over a grout base. */
function tileFloor(b: RoomBuilder, size: number, c1: number, c2: number, grout: number): void {
const { minX, maxX, minZ, maxZ } = b.bounds;
b.deco(minX, -0.4, minZ, maxX, -0.08, maxZ, grout, { cast: false });
const nx = Math.ceil((maxX - minX) / size);
const nz = Math.ceil((maxZ - minZ) / size);
const cells: number[][] = [[], []];
for (let i = 0; i < nx; i++) for (let j = 0; j < nz; j++) cells[(i + j) % 2].push(i, j);
const m = new THREE.Matrix4();
const s = size - 0.08;
[c1, c2].forEach((color, k) => {
const list = cells[k];
const inst = new THREE.InstancedMesh(GEO.box, mat(color), list.length / 2);
for (let n = 0; n < list.length; n += 2) {
m.makeScale(s, 0.3, s);
m.setPosition(minX + (list[n] + 0.5) * size, -0.15, minZ + (list[n + 1] + 0.5) * size);
inst.setMatrixAt(n / 2, m);
}
inst.instanceMatrix.needsUpdate = true;
inst.receiveShadow = true;
b.root.add(inst);
});
}
// ---------------------------------------------------------------------------
// Furniture helpers
/** A solid stack of books (one collider, several coloured covers). */
function bookStack(
b: RoomBuilder,
x0: number,
y0: number,
z0: number,
x1: number,
z1: number,
h: number,
colors: number[],
seed: number,
name = 'books',
): void {
b.solid(x0, y0, z0, x1, y0 + h, z1, name);
const r = rng(seed);
const n = Math.max(2, Math.round(h / 0.6));
const t = h / n;
for (let k = 0; k < n; k++) {
const ix0 = r() * 0.25;
const ix1 = r() * 0.25;
const iz0 = r() * 0.25;
const iz1 = r() * 0.25;
const by = y0 + k * t;
b.deco(x0 + ix0, by, z0 + iz0, x1 - ix1, by + t, z1 - iz1, colors[k % colors.length]);
// page edge on the +z side
b.deco(x0 + ix0 + 0.15, by + 0.07, z1 - iz1 - 0.1, x1 - ix1 - 0.05, by + t - 0.07, z1 - iz1 + 0.02, 0xf4ecd6, { cast: false });
}
}
type Side = 'x-' | 'x+' | 'z-' | 'z+';
/** Four-legged seat (chair or stool) with an optional solid backrest. */
function chair(
b: RoomBuilder,
x0: number,
z0: number,
x1: number,
z1: number,
seatY: number,
color: number,
legColor: number,
back?: { side: Side; top: number },
name = 'chair',
): void {
const leg = 0.5;
const t = 0.5;
b.box(x0, seatY - t, z0, x1, seatY, z1, color, { name: `${name}-seat` });
const corners: [number, number][] = [
[x0, z0],
[x1 - leg, z0],
[x0, z1 - leg],
[x1 - leg, z1 - leg],
];
for (const [lx, lz] of corners) b.box(lx, 0, lz, lx + leg, seatY - t, lz + leg, legColor, { name: `${name}-leg`, grab: false });
if (!back) return;
const s = back.side;
const [bx0, bz0, bx1, bz1] =
s === 'x-' ? [x0, z0, x0 + t, z1] : s === 'x+' ? [x1 - t, z0, x1, z1] : s === 'z-' ? [x0, z0, x1, z0 + t] : [x0, z1 - t, x1, z1];
b.box(bx0, seatY, bz0, bx1, back.top, bz1, color, { name: `${name}-back` });
}
/** Table top on four inset legs. */
function table(
b: RoomBuilder,
x0: number,
z0: number,
x1: number,
z1: number,
top: number,
thick: number,
leg: number,
color: number,
legColor: number,
name = 'table',
): void {
b.box(x0, top - thick, z0, x1, top, z1, color, { name });
const i = 0.2;
for (const lx of [x0 + i, x1 - i - leg])
for (const lz of [z0 + i, z1 - i - leg]) b.box(lx, 0, lz, lx + leg, top - thick, lz + leg, legColor, { name: `${name}-leg`, grab: false });
}
/** Rubber duck inside the AABB (x0..x0+s, y0..y0+s, z0..z0+s), facing +x. */
function duck(b: RoomBuilder, x0: number, y0: number, z0: number, s: number, solid = true): void {
// Colliders follow the shape: a low round body, and a smaller head on top of it.
if (solid) {
b.solid(x0 + s * 0.06, y0, z0 + s * 0.06, x0 + s * 0.94, y0 + s * 0.6, z0 + s * 0.94, 'rubber-duck', false, true);
b.solid(x0 + s * 0.45, y0 + s * 0.6, z0 + s * 0.27, x0 + s * 0.9, y0 + s * 0.97, z0 + s * 0.73, 'rubber-duck-head', false, true);
}
const Y = 0xf6cf3a;
b.deco(x0, y0, z0, x0 + s, y0 + s * 0.62, z0 + s, Y, { shape: 'ball' });
b.deco(x0 + s * 0.4, y0 + s * 0.45, z0 + s * 0.2, x0 + s * 0.95, y0 + s, z0 + s * 0.8, Y, { shape: 'ball' });
b.deco(x0 + s * 0.88, y0 + s * 0.62, z0 + s * 0.38, x0 + s * 1.12, y0 + s * 0.74, z0 + s * 0.62, 0xf08a24);
b.deco(x0 + s * 0.8, y0 + s * 0.82, z0 + s * 0.24, x0 + s * 0.88, y0 + s * 0.9, z0 + s * 0.32, 0x222222, { cast: false });
b.deco(x0 + s * 0.8, y0 + s * 0.82, z0 + s * 0.68, x0 + s * 0.88, y0 + s * 0.9, z0 + s * 0.76, 0x222222, { cast: false });
}
/** Cardboard box with packing tape over the top seam and a shipping label. */
function cardboard(b: RoomBuilder, x0: number, y0: number, z0: number, x1: number, y1: number, z1: number, name: string): void {
const CARD = 0xc59a66;
const SEAM = 0x8d6438;
const TAPE = 0xe6d2a6;
b.box(x0, y0, z0, x1, y1, z1, CARD, { name });
const xm = (x0 + x1) / 2;
const zm = (z0 + z1) / 2;
const drop = Math.min(0.5, (y1 - y0) * 0.25);
if (x1 - x0 >= z1 - z0) {
// Tape runs along X over the top and folds down both ends.
b.deco(x0 - 0.012, y1 - drop, zm - 0.2, x1 + 0.012, y1 + 0.012, zm + 0.2, TAPE, { cast: false });
b.deco(x0 + 0.02, y1 + 0.012, zm - 0.025, x1 - 0.02, y1 + 0.024, zm + 0.025, SEAM, { cast: false });
// Flap creases along the long top edges.
b.deco(x0, y1 - 0.06, z0 - 0.01, x1, y1 - 0.03, z0, SEAM, { cast: false });
b.deco(x0, y1 - 0.06, z1, x1, y1 - 0.03, z1 + 0.01, SEAM, { cast: false });
} else {
b.deco(xm - 0.2, y1 - drop, z0 - 0.012, xm + 0.2, y1 + 0.012, z1 + 0.012, TAPE, { cast: false });
b.deco(xm - 0.025, y1 + 0.012, z0 + 0.02, xm + 0.025, y1 + 0.024, z1 - 0.02, SEAM, { cast: false });
b.deco(x0 - 0.01, y1 - 0.06, z0, x0, y1 - 0.03, z1, SEAM, { cast: false });
b.deco(x1, y1 - 0.06, z0, x1 + 0.01, y1 - 0.03, z1, SEAM, { cast: false });
}
// Shipping label with a couple of printed lines and a this-way-up arrow on the +z face.
const ly = y0 + (y1 - y0) * 0.55;
const lw = Math.min(0.45, (x1 - x0) * 0.22);
const lh = Math.min(0.32, (y1 - y0) * 0.18);
b.deco(xm - lw, ly - lh, z1, xm + lw, ly + lh, z1 + 0.012, 0xf4efe2, { cast: false });
b.deco(xm - lw * 0.75, ly + lh * 0.25, z1 + 0.012, xm + lw * 0.5, ly + lh * 0.45, z1 + 0.018, 0x3a3330, { cast: false });
b.deco(xm - lw * 0.75, ly - lh * 0.3, z1 + 0.012, xm + lw * 0.2, ly - lh * 0.12, z1 + 0.018, 0x3a3330, { cast: false });
b.deco(x0 + 0.15, y1 - drop - 0.45, z1, x0 + 0.22, y1 - drop - 0.15, z1 + 0.012, 0x6b4a2a, { cast: false });
b.deco(x0 + 0.1, y1 - drop - 0.2, z1, x0 + 0.27, y1 - drop - 0.13, z1 + 0.012, 0x6b4a2a, { cast: false });
}
/** 2x2x2 pack of toilet rolls (2 x 2 x 2 units). */
function tpPack(b: RoomBuilder, x0: number, z0: number): void {
b.solid(x0, 0, z0, x0 + 2, 2, z0 + 2, 'toilet-paper');
for (let ly = 0; ly < 2; ly++)
for (let ix = 0; ix < 2; ix++)
for (let iz = 0; iz < 2; iz++) {
const cx = x0 + 0.5 + ix;
const cz = z0 + 0.5 + iz;
b.round(cx, ly, cz, 0.5, 1, 0xfbfbf7, {}, false);
if (ly === 1) b.round(cx, 1.99, cz, 0.16, 0.03, 0xb89a72, { cast: false }, false);
}
}
function duelSpawns(ax: number, bx: number, z0: number, z1: number): SpawnPoint[] {
const fa = Math.atan2(bx - ax, 0);
const fb = Math.atan2(ax - bx, 0);
return [
{ x: ax, y: 0, z: z0, facing: fa },
{ x: bx, y: 0, z: z0, facing: fb },
{ x: ax, y: 0, z: z1, facing: fa },
{ x: bx, y: 0, z: z1, facing: fb },
];
}
function finish(
b: RoomBuilder,
id: RoomId,
d: Pick<RoomData, 'spawns' | 'itemSpawns' | 'itemKinds' | 'ceiling' | 'sky'> &
Partial<Pick<RoomData, 'machines' | 'rollers' | 'pools' | 'drips' | 'crates'>> & { sun: [number, number, number] },
): RoomData {
const meta = ROOMS.find((r) => r.id === id);
b.root.name = `room-${id}`;
return {
id,
name: meta ? meta.name : (ROOM_NAMES[id] ?? id),
root: b.root,
solids: b.solids,
spawns: d.spawns,
itemSpawns: d.itemSpawns,
itemKinds: d.itemKinds,
bounds: { ...b.bounds },
ceiling: d.ceiling,
cutaway: b.cutaway,
sky: d.sky,
sunDir: new THREE.Vector3(...d.sun).normalize(),
machines: d.machines,
rollers: d.rollers,
crates: d.crates,
pools: d.pools,
drips: d.drips,
// Part of the house: the room's floor plus its doorways.
areas: b.doors ? [{ ...b.bounds, room: id }, ...b.doorAreas] : undefined,
};
}
// ---------------------------------------------------------------------------
// Living room — walnut, mustard, teal
//
// Chains: floor → books 2.2 → sofa seat 4.5 → pillow 6.3 / arm 6.5 → back cushion 7.2 → sofa back 8.5
// floor → magazines 2.3 → coffee table 4.5
// floor → board games 2.2 → shoebox 4.6 → bookcase 7.0
// floor → toy block 2.2 → TV stand 4.6
function buildLiving(doors?: Doors): RoomData {
const b = new RoomBuilder({ minX: -24, maxX: 24, minZ: -16, maxZ: 16 }, doors);
const WALNUT = 0x6b4428;
const WALNUT_D = 0x4e301b;
const MUSTARD = 0xd9a72b;
const TEAL = 0x2f8f8a;
const TEAL_D = 0x23706c;
const BRASS = 0xc9a24a;
const BOOKS = [0xc8473a, TEAL, MUSTARD, 0x3e5c8a, 0x7a9a4a, 0xe8dcc0];
const walls = shell(b, 0xe9d5b5, 0xf7efe0);
plankFloor(b, 0x8a5a36, 0x9c6a40, 0x3b2414);
windowOn(b, walls.back, 12, 21, 10, 19, 0xf7efe0, MUSTARD);
picture(b, walls.back, -6, 6, 12, 18, WALNUT_D, TEAL);
b.round(0, 14, -15.7, 1.6, 0.3, MUSTARD, { parent: walls.back.group, cast: false }, false).rotation.x = Math.PI / 2;
picture(b, walls.right, -12, -6, 9, 15, WALNUT_D, MUSTARD);
// rug
b.deco(-12.5, 0, -6.5, 12.5, 0.05, 5.5, TEAL, { cast: false });
b.deco(-11.5, 0, -5.5, 11.5, 0.07, 4.5, MUSTARD, { cast: false });
// sofa against the back wall
b.solid(-8, 0, -14, 8, 4.5, -7, 'sofa-seat');
b.deco(-8, 0, -13.8, 8, 0.6, -7.4, WALNUT_D);
b.deco(-8, 0.6, -14, 8, 3.4, -7, TEAL_D);
b.deco(-8, 3.4, -14, -0.05, 4.5, -7, TEAL);
b.deco(0.05, 3.4, -14, 8, 4.5, -7, TEAL);
b.box(-10, 0, -14, -8, 6.5, -7, TEAL, { name: 'sofa-arm' });
b.box(8, 0, -14, 10, 6.5, -7, TEAL, { name: 'sofa-arm' });
b.box(-10, 0, -16, 10, 8.5, -14, TEAL_D, { name: 'sofa-back' });
b.solid(-8, 4.5, -14, 8, 7.2, -13, 'sofa-cushion');
b.deco(-8, 4.5, -14, -0.05, 7.2, -13, TEAL);
b.deco(0.05, 4.5, -14, 8, 7.2, -13, TEAL);
b.box(-8, 4.5, -13, -5.3, 6.3, -11.3, MUSTARD, { name: 'pillow' });
bookStack(b, -7, 0, -7, -4.5, -5, 2.2, BOOKS, 11);
// coffee table with a mug and a magazine
table(b, -5, -3.5, 5, 0, 4.5, 0.6, 0.6, WALNUT, WALNUT_D, 'coffee-table');
b.round(3.6, 4.5, -2.4, 0.45, 0.9, 0xf2ece0, { name: 'mug' });
b.deco(-3.5, 4.5, -2.8, -0.8, 4.58, -0.8, 0xc8473a, { cast: false });
bookStack(b, 5, 0, -3.1, 6.9, -0.7, 2.3, BOOKS, 17, 'magazines');
// low bookcase on the left wall (single collider, open-shelf visuals)
b.solid(-24, 0, -2, -21, 7, 8, 'bookcase');
b.deco(-24, 0, -2, -23.8, 7, 8, WALNUT_D);
b.deco(-24, 0, -2, -21, 7, -1.6, WALNUT);
b.deco(-24, 0, 7.6, -21, 7, 8, WALNUT);
b.deco(-23.8, 0, -1.6, -21, 0.6, 7.6, WALNUT);
b.deco(-23.8, 3.3, -1.6, -21, 3.6, 7.6, WALNUT);
b.deco(-24, 6.6, -2, -21, 7, 8, WALNUT);
const r = rng(7);
for (const [base, space] of [
[0.6, 2.7],
[3.6, 3.0],
]) {
let z = -1.6;
while (z < 7.2) {
const w = Math.min(0.35 + r() * 0.35, 7.6 - z);
if (r() > 0.1) {
const h = space * (0.7 + r() * 0.22);
b.deco(-23.8, base, z, -21.25 - r() * 0.3, base + h, z + w - 0.03, BOOKS[Math.floor(r() * BOOKS.length)]);
}
z += w;
}
}
// board games + shoebox: the stairs up to the bookcase
b.solid(-21, 0, 4, -18, 2.2, 8, 'board-games');
b.deco(-21, 0, 4, -18, 0.7, 8, 0xc8473a);
b.deco(-20.9, 0.7, 4.1, -18.1, 1.5, 7.9, 0x3e5c8a);
b.deco(-21, 1.5, 4, -18, 2.2, 8, 0x7a9a4a);
cardboard(b, -21, 2.2, 5.5, -19.5, 4.6, 8, 'shoebox');
// TV stand and TV on the right wall
b.box(20, 0, -6, 24, 4.6, 6, WALNUT_D, { name: 'tv-stand' });
b.deco(19.92, 0.6, -5.6, 20, 4.0, -0.2, WALNUT, { cast: false });
b.deco(19.92, 0.6, 0.2, 20, 4.0, 5.6, WALNUT, { cast: false });
b.solid(22, 4.6, -5, 24, 11, 5, 'tv');
b.deco(22.2, 4.6, -1.5, 23.4, 5.2, 1.5, 0x1d1f24);
b.deco(22.4, 5.2, -5, 23.2, 11, 5, 0x1d1f24);
b.deco(22.34, 5.6, -4.6, 22.4, 10.6, 4.6, 0x2b3d4a, { emissive: 0x0c1b26, cast: false });
b.box(20.2, 4.6, 2, 21.2, 4.9, 4, 0xeeeeee, { name: 'console' });
cardboard(b, 17.8, 0, 3.6, 20, 2.2, 5.8, 'box');
b.deco(17.74, 0.5, 4.1, 17.8, 1.7, 5.3, TEAL, { cast: false });
// potted plant in the back-left corner
b.box(-22, 0, -14, -19, 3.2, -11, 0xc0643a, { shape: 'bowl', name: 'plant-pot' });
b.deco(-21.8, 3.0, -13.8, -19.2, 3.25, -11.2, 0x3b2a1e, { shape: 'cyl', cast: true });
for (const [x, y, z, s] of [
[-20.5, 4.0, -12.5, 3.2],
[-21.4, 6.0, -13.2, 2.4],
[-19.6, 6.4, -11.8, 2.6],
[-20.4, 8.0, -12.6, 2.0],
])
b.deco(x - s / 2, y, z - s / 2, x + s / 2, y + s * 1.2, z + s / 2, 0x4f8a3a, { shape: 'cone', rotY: x });
// floor lamp
b.round(17, 0, -12, 1.2, 0.3, BRASS, { name: 'lamp-base' });
b.round(17, 0.3, -12, 0.2, 14.7, BRASS, { shape: 'cyl6', name: 'lamp-pole', grab: false });
b.deco(15, 14, -14, 19, 17.5, -10, 0xfbe7b5, { shape: 'shade', emissive: 0xffc870 });
// toys
cardboard(b, -15, 0, 9, -13.8, 1.2, 10.2, 'box');
// China hutch beside the sofa: sofa back 8.5 → shelf 10.6 → hutch top 12.8.
b.box(-15, 0, -16, -10, 6, -12.5, WALNUT_D, { name: 'hutch' });
b.deco(-14.8, 0.6, -12.48, -12.6, 5.4, -12.45, WALNUT, { cast: false });
b.deco(-12.4, 0.6, -12.48, -10.2, 5.4, -12.45, WALNUT, { cast: false });
for (const x of [-13.1, -11.9]) b.deco(x - 0.15, 3, -12.45, x + 0.15, 3.4, -12.3, BRASS);
b.box(-15, 6, -16, -10, 12.8, -14.5, WALNUT_D, { name: 'hutch' });
b.deco(-14.7, 6.4, -14.52, -10.3, 12.4, -14.48, 0x9fc3c8, { emissive: 0x0c1b26, cast: false });
for (const [px, py, pc] of [
[-13.6, 7.2, 0xf4ecd6],
[-11.4, 7.2, 0x3e6ea8],
[-13.6, 11, 0xd9a72b],
[-11.4, 11, 0xf4ecd6],
]) {
// Plates propped up on display along the front.
const plate = b.deco(px - 0.9, 0, -0.9, px + 0.9, 0.1, 0.9, pc, { shape: 'cyl', cast: false });
plate.position.set(px, py + 0.9, -14.42);
plate.rotation.x = Math.PI / 2;
}
b.box(-15, 10.2, -14.5, -10, 10.6, -13.5, WALNUT, { name: 'hutch-shelf' });
b.deco(-15.2, 12.8, -16, -9.8, 13.1, -14.3, WALNUT, { cast: false });
// ceiling fan in the middle of the room, high up: reach it from the top of the cat tree
b.deco(-0.12, 15.7, -4.12, 0.12, 25, -3.88, BRASS, { cast: false });
b.box(-1, 14.7, -5, 1, 15.7, -3, BRASS, { shape: 'cyl', name: 'fan-motor', grab: false });
// Cat tree by the coffee table: platforms spiral up its sisal post to a perch right
// under the fan's blades: 2.4 → 4.8 → 7.2 → 9.6 → 12.0.
const SISAL = 0xc9a874;
const PAD = 0xb7a58f;
b.box(6.6, 0, -5.6, 8.4, 0.4, -3.8, PAD, { name: 'cat-tree-base' });
b.round(7.5, 0.4, -4.7, 0.4, 11.3, SISAL, { name: 'cat-tree-post', grab: false });
for (let y = 1; y < 11.5; y += 0.7) b.round(7.5, y, -4.7, 0.42, 0.08, 0x9c7f52, { cast: false }, false);
for (const [px, pz, top] of [
[9.3, -4.7, 2.4],
[7.5, -2.9, 4.8],
[5.7, -4.7, 7.2],
[7.5, -2.9, 9.6],
[7.5, -4.7, 12.0],
] as [number, number, number][]) {
b.round(px, top - 0.35, pz, 1.3, 0.35, PAD, { name: 'cat-tree-platform' });
b.round(px, top - 0.4, pz, 1.34, 0.06, 0x8a7a66, { cast: false }, false);
}
// a toy mouse dangling from the top perch
b.deco(8.65, 9.9, -4.72, 8.69, 11.65, -4.68, 0xe8e0cc, { cast: false });
b.deco(8.4, 9.5, -4.95, 8.95, 9.9, -4.45, 0x9a9a9a, { shape: 'ball' });
// record player on a low cabinet: floor → box of records 2.4 → cabinet 4.0 → deck 4.6 → platter
b.box(12, 0, 10.5, 19, 4, 16, WALNUT, { name: 'record-cabinet' });
b.deco(12.4, 0.6, 10.42, 18.6, 3.4, 10.5, 0x3b2a1e, { cast: false });
for (let k = 0; k < 6; k++) b.deco(12.8 + k * 1, 0.9, 10.38, 13.2 + k * 1, 3.1, 10.42, 0x6b4428, { cast: false });
b.box(12, 4, 10.5, 19, 4.6, 16, 0x2b2622, { name: 'record-deck' });
b.deco(17.9, 4.6, 14.6, 18.3, 5.2, 15.0, 0xc9d1d6); // tone-arm post
b.deco(15.9, 5.05, 14.75, 18.1, 5.15, 14.85, 0xc9d1d6, { cast: false }); // tone arm
b.deco(12.9, 4.6, 15.0, 13.4, 4.75, 15.4, 0xc8473a, { cast: false }); // start button
cardboard(b, 19, 0, 11, 21.5, 2.4, 14.5, 'record-crate');
for (let k = 0; k < 4; k++) b.deco(19.2 + k * 0.55, 1.2, 11.4, 19.5 + k * 0.55, 3.0, 14.1, [0x1b1b1f, 0xd9a72b, 0x2f8f8a, 0xc8473a][k]!);
return finish(b, 'living', {
spawns: duelSpawns(-5, 5, 4, 12),
machines: [
{ kind: 'fan', x: 0, y: 14.4, z: -4, r: 9, speed: 0.55 },
{ kind: 'turntable', x: 15.5, y: 4.85, z: 13.25, r: 2.0, speed: 1.1 },
],
rollers: [{ kind: 'ball', x: 8, y: 0, z: 9 }],
itemSpawns: [
{ x: 0, y: 0, z: 8 },
{ x: 13, y: 4.6, z: 11.4 },
{ x: -12.5, y: 12.8, z: -15.2 },
{ x: 7.5, y: 12, z: -4.2 },
{ x: -17, y: 0, z: 13 },
{ x: 2, y: 4.5, z: -10 },
{ x: 1, y: 4.5, z: -1.8 },
{ x: -22.5, y: 7, z: 2 },
{ x: 21, y: 4.6, z: -3 },
{ x: 4, y: 8.5, z: -15 },
{ x: 14, y: 0, z: -2 },
],
itemKinds: ['screwdriver', 'wrench', 'pencil', 'chopstick', 'paintbrush', 'fork', 'clothespeg', 'nunchaku'],
ceiling: 14.7,
sky: 0xf0d2a8,
sun: [-0.35, -0.75, 0.55],
});
}
// ---------------------------------------------------------------------------
// Kitchen — cream, mint, cherry red
//
// Chains: floor → cookbooks 2.0 → open drawer 4.2 → upper drawer 6.6 → counter 9.0 → toaster / pot 11.0
// floor → books 2.4 → chair seat 4.8 → dining table 7.4 → cereal box 10.0
function buildKitchen(doors?: Doors): RoomData {
const b = new RoomBuilder({ minX: -23, maxX: 23, minZ: -16, maxZ: 16 }, doors);
const CREAM = 0xf4ecd6;
const MINT = 0x8fd4b6;
const MINT_D = 0x6bb898;
const CHERRY = 0xc8323c;
const CHROME = 0xc9d1d6;
const WOOD = 0xc99a5e;
const walls = shell(b, 0xf6eedb, 0xfffaf0, { color: MINT, top: 7, rail: CHERRY });
tileFloor(b, 2, CREAM, MINT, 0xc7c0aa);
windowOn(b, walls.back, -21, -13, 12, 20, 0xfffaf0, CHERRY);
wallBox(b, walls.back, -23, 5, 9, 14, 0, 0.08, 0xfbfaf3, { cast: false }); // backsplash
wallBox(b, walls.back, -10, 5, 14, 21, 0, 3, MINT); // upper cabinets
for (const u of [-10, -5, 0]) {
wallBox(b, walls.back, u + 0.3, u + 4.7, 14.4, 20.6, 3, 3.08, MINT_D, { cast: false });
wallBox(b, walls.back, u + 2.2, u + 2.8, 14.8, 15.2, 3, 3.3, CHROME);
}
picture(b, walls.left, 2, 10, 9, 15, CHERRY, CREAM);
// counter run along the back wall
b.solid(-23, 0, -16, 5, 9, -10, 'counter');
b.deco(-23, 0, -16, 5, 0.8, -10.4, 0x5b5148);
b.deco(-23, 0.8, -16, 5, 8.5, -10, MINT);
b.deco(-23, 8.5, -16, 5, 9, -9.8, CREAM);
for (const [u0, u1] of [
[-23, -19],
[-19, -14],
[-10, -6],
[1, 5],
]) {
b.deco(u0 + 0.3, 1.2, -10, u1 - 0.3, 8.0, -9.92, MINT_D, { cast: false });
b.deco((u0 + u1) / 2 - 0.3, 6.6, -9.92, (u0 + u1) / 2 + 0.3, 7.2, -9.7, CHERRY);
}
// pulled-out drawers (x -14..-10)
b.deco(-13.8, 0.9, -10, -10.2, 2.7, -9.92, MINT_D, { cast: false });
b.deco(-13.8, 3.0, -10, -10.2, 4.2, -9.96, 0x2c2a26, { cast: false });
b.deco(-13.8, 5.6, -10, -10.2, 6.6, -9.96, 0x2c2a26, { cast: false });
b.box(-14, 3.0, -10, -10, 4.2, -7, MINT, { name: 'drawer' });
b.deco(-12.4, 3.4, -7, -11.6, 3.8, -6.8, CHERRY);
b.box(-14, 5.6, -10, -10, 6.6, -8.5, MINT, { name: 'drawer' });
b.deco(-12.4, 5.9, -8.5, -11.6, 6.3, -8.3, CHERRY);
bookStack(b, -14, 0, -7, -11.5, -4.5, 2.0, [CHERRY, 0x3e5c8a, 0xf2c14e], 21, 'cookbooks');
// stove section (x -6..1)
b.deco(-5.5, 1.5, -10, 0.5, 6.4, -9.9, 0x333a40, { cast: false });
b.deco(-5, 6.8, -9.9, 0, 7.1, -9.6, CHROME);
for (const kx of [-4.6, -3.2, -1.8, -0.4]) b.deco(kx - 0.25, 7.6, -10, kx + 0.25, 8.1, -9.75, 0x26282b, { shape: 'cyl' });
b.deco(-6, 9, -15.5, 1, 9.06, -10.5, 0x26282b, { cast: false });
for (const [bx, bz] of [
[-4.2, -14],
[-1, -14],
[-4.2, -12],
[-1, -12],
])
b.round(bx, 9.06, bz, 0.9, 0.06, 0x4a4e54, { cast: false }, false);
b.round(-1.5, 9, -13, 1.5, 2, CHERRY, { name: 'pot' });
b.deco(-1.6, 11, -13.1, -1.4, 11.3, -12.9, 0x26282b);
b.deco(-0.2, 10, -13.25, 0.8, 10.3, -12.75, 0x26282b);
// sink, faucet, toaster
b.deco(-20, 9, -14.5, -15, 9.04, -11, 0xb8c2c8, { cast: false });
b.deco(-19.6, 9.04, -14.1, -15.4, 9.06, -11.4, 0x8c979e, { cast: false });
// the leak: a wet streak over the counter's lip
b.deco(-17.7, 8.3, -9.82, -17.3, 9.02, -9.74, 0x8fd4f0, { cast: false });
b.box(-17.8, 9, -16, -17.2, 11.6, -15.2, CHROME, { name: 'faucet' });
b.deco(-17.75, 11, -15.2, -17.25, 11.4, -14, CHROME);
b.box(-11, 9, -15, -8.5, 11, -12.5, CHERRY, { name: 'toaster' });
b.deco(-10.6, 11, -14.3, -10.3, 11.03, -13.2, 0x26282b, { cast: false });
b.deco(-9.2, 11, -14.3, -8.9, 11.03, -13.2, 0x26282b, { cast: false });
b.deco(-8.5, 9.8, -13.9, -8.2, 10.1, -13.6, CHROME);
// fridge + bin
b.box(5, 0, -16, 12, 18, -9.5, CHERRY, { name: 'fridge' });
b.deco(5, 11.9, -9.5, 12, 12.1, -9.42, 0x8a1f27, { cast: false });
b.deco(10.7, 13, -9.5, 11.2, 16, -9.1, CHROME);
b.deco(10.7, 7, -9.5, 11.2, 10.5, -9.1, CHROME);
for (const [mx, my, mc] of [
[6.5, 15.5, 0xf2c14e],
[8, 14.4, MINT],
[7, 9.5, 0x3e5c8a],
])
b.deco(mx, my, -9.5, mx + 0.7, my + 0.7, -9.4, mc, { cast: false });
b.round(14.5, 0, -13.5, 1.5, 5, 0xdedbd0, { name: 'bin' });
b.round(14.5, 5, -13.5, 1.55, 0.25, CHROME, { cast: false }, false);
// dining table, chairs, fruit bowl, cereal box
table(b, 9, 3, 19, 11, 7.4, 0.6, 0.8, WOOD, 0xa77b45, 'dining-table');
chair(b, 10.5, -1.4, 14.5, 2.3, 4.8, CHERRY, CREAM, { side: 'z-', top: 9 });
chair(b, 19.7, 5, 23, 9, 4.8, CHERRY, CREAM, { side: 'x+', top: 9 });
bookStack(b, 8, 0, -0.9, 10.5, 1.8, 2.4, [MINT_D, 0xf2c14e, 0x3e5c8a, CHERRY], 33);
b.box(13.4, 7.4, 6.4, 16.6, 8.4, 9.6, 0xf7f3ea, { shape: 'bowl', name: 'fruit-bowl' });
b.deco(13.9, 8.1, 7, 15.1, 9.3, 8.2, 0xd8343c, { shape: 'ball' });
b.deco(15, 8.1, 7.6, 16.2, 9.3, 8.8, 0xf39a2b, { shape: 'ball' });
b.deco(14.2, 8.2, 8.4, 15.6, 8.9, 9.2, 0x8fc04a, { shape: 'ball' });
b.box(17, 7.4, 3.3, 18.6, 10, 5.6, 0xf2c14e, { name: 'cereal-box' });
b.deco(16.94, 8.2, 3.7, 17, 9.4, 5.2, CHERRY, { cast: false });
return finish(b, 'kitchen', {
spawns: duelSpawns(-8, 2, 2, 10),
itemSpawns: [
{ x: -3, y: 0, z: 6 },
{ x: -17, y: 0, z: 9 },
{ x: 7, y: 0, z: 14 },
{ x: 14, y: 0, z: 7 },
{ x: 11.5, y: 7.4, z: 6 },
{ x: -13.5, y: 9, z: -12 },
{ x: 3, y: 9, z: -13 },
{ x: 12.5, y: 4.8, z: 0.8 },
{ x: -12, y: 6.6, z: -9.2 },
],
itemKinds: ['fork', 'spoon', 'butterknife', 'whisk', 'spatula', 'chopstick', 'ladle', 'pizzacutter', 'nunchaku'],
machines: [{ kind: 'toaster', x: -9.75, y: 11, z: -13.75, yaw: 0, period: 7 }],
// The sink tap leaks: water runs over the counter's edge and puddles on the floor.
drips: [{ x: -17.5, y: 8.95, z: -9.75, r: 2.6 }],
rollers: [{ kind: 'can', x: -4, y: 0, z: 12 }],
ceiling: 15,
sky: 0xf6e7c8,
sun: [0.35, -0.75, 0.55],
});
}
// ---------------------------------------------------------------------------
// Bedroom — lavender, soft blue, pine
//
// Chains: floor → foam block 2.0 → stacked block 4.0 → bed 5.0 → pillow 6.2 → headboard 8.6
// bed 5.0 → nightstand 6.0
// floor → books 2.4 → desk chair 4.8 → desk 7.4
// floor → drawer 2.4 → drawer 5.0 → drawer 7.6 → dresser 10 → jewellery box 11.2
function buildBedroom(doors?: Doors): RoomData {
const b = new RoomBuilder({ minX: -22, maxX: 22, minZ: -16, maxZ: 16 }, doors);
const LAV = 0xb9a7d9;
const LAV_D = 0x9887c0;
const BLUE = 0x9cc3e6;
const PINE = 0xc99a5e;
const PINE_D = 0xa77b45;
const WHITE = 0xf4f1ea;
const YELLOW = 0xf2c14e;
const walls = shell(b, 0xcfdff0, 0xf8f6f0);
b.deco(-22, -0.3, -16, 22, 0, 16, 0xa79bc4, { cast: false }); // carpet
b.deco(-7, 0, 2, 7, 0.06, 16, BLUE, { shape: 'cyl', cast: false });
b.deco(-5, 0, 4, 5, 0.08, 14, 0xd8e6f4, { shape: 'cyl', cast: false });
windowOn(b, walls.back, 2, 12, 9, 18, 0xf8f6f0, LAV);
picture(b, walls.left, -6, 0, 9, 14, PINE_D, YELLOW);
picture(b, walls.right, 8, 13, 10, 16, PINE_D, LAV);
// bed: crawlspace 0.9 underneath
b.box(-16, 0, -16, -2, 8.6, -15, PINE, { name: 'headboard' });
b.solid(-16, 0.9, -15, -2, 5, 4, 'bed');
b.deco(-16, 0.9, -15, -2, 2.4, 4, PINE);
b.deco(-15.8, 2.4, -15, -2.2, 5, 3.8, WHITE);
b.deco(-16.1, 2.6, -8, -1.9, 5.04, 4.1, BLUE);
b.deco(-16.12, 4.3, -8.6, -1.88, 5.06, -7.6, WHITE);
for (const lx of [-16, -3]) for (const lz of [-15, 3]) b.box(lx, 0, lz, lx + 1, 0.9, lz + 1, PINE_D, { name: 'bed-leg', grab: false });
b.box(-15, 5, -15, -10, 6.2, -12, WHITE, { name: 'pillow' });
b.box(-8, 5, -15, -3, 6.2, -12, WHITE, { name: 'pillow' });
b.deco(-11, 0, -9, -9.6, 0.25, -7.8, LAV_D, { cast: false }); // lost sock
// foam blocks forming a stair to the bed
cardboard(b, -2, 0, -3, 0, 2, -1, 'box');
cardboard(b, -2, 0, -1, 0, 2, 1, 'box');
cardboard(b, -2, 2, -1, 0, 4, 1, 'box');
b.deco(-1.6, 0.4, -1.06, -0.4, 1.6, -1, 0xc8473a, { cast: false });
b.deco(-1.6, 2.4, 1, -0.4, 3.6, 1.06, YELLOW, { cast: false });
// nightstand + lamp
b.box(-22, 0, -16, -17.5, 6, -12, PINE, { name: 'nightstand' });
b.deco(-21.6, 3.4, -12, -17.9, 5.4, -11.92, LAV, { cast: false });
b.deco(-20, 4.2, -11.92, -19.5, 4.6, -11.7, PINE_D);
b.round(-20, 6, -15.1, 0.9, 0.4, LAV_D, { name: 'lamp-base' });
b.deco(-20.1, 6.4, -15.2, -19.9, 8.4, -15.0, PINE_D, { shape: 'cyl6' });
b.deco(-21.4, 8.2, -16.5, -18.6, 10.4, -13.7, 0xfff0c8, { shape: 'shade', emissive: 0xffc870 });
// desk + chair on the right wall
const deskEnd = doors ? 4.4 : 6;
b.box(16, 6.8, -6, 22, 7.4, deskEnd, PINE, { name: 'desk' });
b.box(16.2, 0, -6, 22, 6.8, -5.4, PINE_D, { name: 'desk-side', grab: false });
b.box(16.2, 0, deskEnd - 0.6, 22, 6.8, deskEnd, PINE_D, { name: 'desk-side', grab: false });
chair(b, 11.5, -2, 15.3, 2, 4.8, BLUE, PINE_D, { side: 'x-', top: 9 }, 'desk-chair');
// In the house the train passes close by: the books sit a little further under the chair.
const booksZ = doors ? 1.6 : 2;
bookStack(b, 12, 0, booksZ, 14.8, booksZ + 2.5, 2.4, [LAV_D, YELLOW, 0x5b7fb0, 0xc8473a], 41);
bookStack(b, 19.5, 7.4, -5, 22, -2.5, 1.2, [BLUE, LAV, PINE_D], 42);
const cupZ = doors ? 3.7 : 4;
b.round(18.5, 7.4, cupZ, 0.5, 1.2, LAV_D, { name: 'pencil-cup' });
for (const [px, pz, pc] of [
[18.35, cupZ - 0.1, YELLOW],
[18.6, cupZ + 0.15, 0xc8473a],
])
b.deco(px - 0.08, 8.6, pz - 0.08, px + 0.08, 9.6, pz + 0.08, pc, { shape: 'cyl6' });
b.round(21.3, 7.4, 3.7, 0.7, 0.3, 0x5b7fb0, { name: 'lamp-base' });
b.deco(21.2, 7.7, 3.6, 21.4, 10.2, 3.8, 0x5b7fb0);
b.deco(20.0, 9.6, 3.1, 21.5, 10.6, 4.3, 0x5b7fb0, { shape: 'shade', emissive: 0x3a3020 });
b.deco(17, 7.4, -1.5, 19.2, 7.45, 1.5, WHITE, { cast: false }); // notebook
// dresser on the left wall with drawers pulled out as steps
b.solid(-22, 0, 7, -17, 10, 16, 'dresser');
b.deco(-22, 0, 7, -17, 10, 16, PINE);
const open = new Set(['0:0', '1:1', '0:2']);
const cols: [number, number][] = [
[7.4, 11.3],
[11.7, 15.6],
];
const rows: [number, number][] = [
[0.8, 3.2],
[3.6, 6.4],
[6.8, 9.4],
];
cols.forEach(([z0, z1], c) =>
rows.forEach(([y0, y1], rr) => {
const isOpen = open.has(`${c}:${rr}`);
b.deco(-17, y0, z0, -16.92, y1, z1, isOpen ? 0x3b2b1e : LAV, { cast: false });
if (!isOpen) b.deco(-16.92, (y0 + y1) / 2 - 0.2, (z0 + z1) / 2 - 0.2, -16.7, (y0 + y1) / 2 + 0.2, (z0 + z1) / 2 + 0.2, PINE_D);
}),
);
const drawer = (x1: number, y0: number, y1: number, z0: number, z1: number) => {
b.box(-17, y0, z0, x1, y1, z1, PINE_D, { name: 'drawer' });
b.deco(x1, y0 - 0.1, z0 - 0.1, x1 + 0.1, y1 + 0.1, z1 + 0.1, LAV, { cast: false });
b.deco(x1 + 0.1, (y0 + y1) / 2 - 0.2, (z0 + z1) / 2 - 0.2, x1 + 0.3, (y0 + y1) / 2 + 0.2, (z0 + z1) / 2 + 0.2, PINE_D);
};
drawer(-14.5, 1.0, 2.4, 8, 11);
drawer(-15.5, 4.0, 5.0, 12, 15);
drawer(-15.6, 7.0, 7.6, 8, 11);
b.box(-21, 10, 12.5, -18.5, 11.2, 15, LAV_D, { name: 'jewellery-box' });
b.deco(-21.05, 10.9, 12.45, -18.45, 11, 15.05, YELLOW, { cast: false });
// floor clutter
bookStack(b, 4, 0, -12, 6.5, -10, 1.2, [0x5b7fb0, YELLOW], 51);
// toy shelf under the window: books 1.2 → toy box 3.4 → shelf 6.0 → window sill 8.4
b.box(3, 0, -16, 9, 6, -13.6, PINE, { name: 'toy-shelf' });
b.deco(3.2, 2.9, -13.62, 8.8, 3.1, -13.58, PINE_D, { cast: false });
cardboard(b, 3, 0, -13.6, 5.4, 3.4, -12, 'toy-box');
b.deco(-12, 0, 6, -11, 0.3, 8.2, LAV_D, { rotY: 0.2 });
b.deco(-10.6, 0, 6.3, -9.6, 0.3, 8.5, LAV_D, { rotY: -0.15 });
return finish(b, 'bedroom', {
spawns: duelSpawns(-5, 5, 6, 14),
rollers: [
{ kind: 'ball', x: 7.9, y: 0, z: -6.1 },
{ kind: 'reel', x: -7, y: 0, z: 9 },
],
itemSpawns: [
{ x: 0, y: 0, z: 10 },
{ x: 10, y: 0, z: 12 },
{ x: -9, y: 0, z: -5 },
{ x: -9, y: 5, z: -4 },
{ x: -12, y: 8.6, z: -15.5 },
{ x: -19.5, y: 6, z: -12.8 },
{ x: 18, y: 7.4, z: 0.5 },
{ x: -19.5, y: 10, z: 9 },
{ x: 13.4, y: 4.8, z: 0 },
],
itemKinds: ['pencil', 'ruler', 'paintbrush', 'knittingneedle', 'crayon', 'hairpin', 'toothpick'],
ceiling: 11.2,
sky: 0xd9d0ee,
sun: [-0.3, -0.75, 0.6],
});
}
// ---------------------------------------------------------------------------
// Bathroom — aqua, white, sunny yellow
//
// Chains: floor → toilet paper 2.0 → bath stool 3.4 → tub rim 5.5; inside: basin 1.0 → duck 3.0 → rim 5.5
// floor → toilet paper 2.0 → toilet lid 4.2 → spare roll 5.3 → tank 7.9
// floor → towels 2.0 → laundry basket 3.8 → vanity drawer 6.0 → vanity 8.5
function buildBathroom(doors?: Doors): RoomData {
const b = new RoomBuilder({ minX: -20, maxX: 20, minZ: -15, maxZ: 15 }, doors);
const AQUA = 0x5cc8c8;
const AQUA_D = 0x3fa9ab;
const WHITE = 0xf7f7f2;
const YELLOW = 0xf6cf3a;
const CHROME = 0xc9d1d6;
const walls = shell(b, 0xf4f6f4, WHITE, { color: AQUA, top: 8, rail: YELLOW });
tileFloor(b, 2, 0xf2f4f1, 0x9fdcd8, 0xb9c8c6);
windowOn(b, walls.left, -1, 8, 10, 18, WHITE, YELLOW);
wallBox(b, walls.right, -5.5, 3.5, 11, 19, 0, 0.15, YELLOW); // mirror
wallBox(b, walls.right, -5, 3, 11.5, 18.5, 0, 0.2, 0xcfeef2, { emissive: 0x1d3a3c, cast: false });
wallBox(b, walls.back, 0, 4, 10.6, 10.9, 0, 0.6, CHROME); // towel bar
wallBox(b, walls.back, 0.5, 3.5, 6.5, 10.9, 0.2, 0.55, AQUA);
wallBox(b, walls.back, 11.5, 12.7, 5.6, 6.8, 0.1, 1.2, WHITE); // paper holder
// bathtub: four rims around a basin you can drop into
b.box(-20, 0, -15, -3, 5.5, -14, WHITE, { name: 'tub-rim' });
b.box(-20, 0, -9, -3, 5.5, -8, WHITE, { name: 'tub-rim' });
b.box(-20, 0, -14, -19, 5.5, -9, WHITE, { name: 'tub-rim' });
b.box(-4, 0, -14, -3, 5.5, -9, WHITE, { name: 'tub-rim' });
b.box(-19, 0, -14, -4, 1, -9, 0xe4f0f0, { name: 'tub-basin' });
b.round(-17.5, 1, -11.5, 0.4, 0.03, 0x8c979e, { cast: false }, false);
b.box(-17, 5.5, -15, -16.2, 6.6, -14, CHROME, { name: 'tub-faucet' });
b.deco(-16.9, 6.2, -14, -16.3, 6.6, -13.3, CHROME);
cardboard(b, -10, 1, -11, -8, 3, -9, 'tub-box');
b.box(-5.5, 1, -14, -4, 4.2, -12.5, 0xff8fa3, { name: 'shampoo' });
b.deco(-5.1, 4.2, -13.6, -4.4, 4.7, -12.9, WHITE, { shape: 'cyl' });
// outside steps up to the rim
b.deco(-18, 0, -7.6, -13, 0.06, -3.4, YELLOW, { cast: false }); // bath mat
chair(b, -12, -7.3, -8, -4.3, 3.4, AQUA, AQUA_D, undefined, 'bath-stool');
tpPack(b, -7.3, -6.8);
// toilet
b.solid(6.3, 0, -15, 10.2, 7.9, -13, 'toilet-tank');
b.deco(6.3, 0, -15, 10.2, 7.5, -13, WHITE);
b.deco(6.2, 7.5, -15, 10.3, 7.9, -12.9, WHITE);
b.deco(8.0, 7.9, -14.2, 8.5, 8.0, -13.7, CHROME, { shape: 'cyl', cast: false });
b.box(7.2, 0, -13, 9.3, 3, -8.5, WHITE, { name: 'toilet-base' });
b.solid(6.5, 3, -13, 10, 4.2, -7.5, 'toilet-lid');
b.deco(6.5, 3, -13, 10, 3.9, -7.5, WHITE);
b.deco(6.6, 3.9, -12.9, 9.9, 4.2, -7.6, YELLOW);
b.box(8.6, 4.2, -13, 9.7, 5.3, -11.9, WHITE, { shape: 'cyl', name: 'toilet-roll' });
tpPack(b, 10.7, -11);
// vanity with an open drawer, laundry basket and towels as steps
b.solid(15, 0, -6, 20, 8.5, 4, 'vanity');
b.deco(15, 0, -6, 20, 8.1, 4, WHITE);
b.deco(14.9, 8.1, -6.1, 20, 8.5, 4.1, AQUA);
b.deco(14.92, 0.8, -5.6, 15, 4.5, -1.2, AQUA, { cast: false });
b.deco(14.92, 0.8, -0.8, 15, 4.5, 3.6, AQUA, { cast: false });
b.deco(14.94, 5.0, -5, 15, 6.0, -1, 0x2c3a3a, { cast: false });
b.deco(14.92, 5.0, -0.6, 15, 7.6, 3.6, AQUA, { cast: false });
b.box(13, 5, -5, 15, 6, -1, AQUA_D, { name: 'drawer' });
b.deco(12.8, 5.3, -3.4, 13, 5.7, -2.6, YELLOW);
b.deco(16, 8.5, -3, 18.6, 8.54, 1, 0xdfeff0, { shape: 'cyl', cast: false });
b.box(19, 8.5, -1.4, 20, 10.3, -0.6, CHROME, { name: 'faucet' });
b.deco(18.3, 9.9, -1.2, 19, 10.2, -0.8, CHROME);
b.box(16, 8.5, 2, 17.4, 8.9, 3, 0xf7a1b5, { name: 'soap' });
b.round(18.8, 8.5, 2.7, 0.5, 1.4, AQUA, { name: 'cup' });
b.deco(18.6, 9.9, 2.5, 18.75, 11.2, 2.65, YELLOW);
b.deco(18.85, 9.9, 2.75, 19.0, 11.0, 2.9, 0xe86f8a);
b.solid(9, 0, -5, 12.3, 3.8, -1, 'laundry-basket');
b.deco(9, 0, -5, 12.3, 3.6, -1, 0xd4b27a, { shape: 'bowl' });
b.deco(9.4, 3.1, -4.6, 11.0, 3.9, -3.0, AQUA, { shape: 'ball' });
b.deco(10.6, 3.2, -3.0, 11.9, 3.85, -1.5, YELLOW, { shape: 'ball' });
b.solid(9, 0, 0, 11.5, 2, 2.5, 'towels');
b.deco(9, 0, 0, 11.5, 0.65, 2.5, AQUA);
b.deco(9.1, 0.65, 0.1, 11.4, 1.3, 2.4, YELLOW);
b.deco(9, 1.3, 0, 11.5, 2, 2.5, WHITE);
// floor bits
b.box(-17, 0, 5, -13.5, 0.5, 8.5, 0xdfe6e8, { name: 'scale' });
// towel cabinet under the window: bath toys 2.2 → towels 4.6 → cabinet 7.0 → window sill 9.4
b.box(-20, 0, 0, -17.5, 7, 4, WHITE, { name: 'towel-cabinet' });
b.deco(-17.52, 0.6, 0.3, -17.45, 6.4, 3.7, AQUA_D, { cast: false });
b.box(-17.5, 0, 0.5, -15.5, 4.6, 2.5, AQUA, { name: 'towels' });
cardboard(b, -15.5, 0, 0.5, -13.5, 2.2, 2.5, 'toy-crate');
b.deco(-16, 0.5, 5.6, -14.5, 0.52, 6.6, 0x2c3a3a, { cast: false });
duck(b, -16, 0, 11, 1.4);
return finish(b, 'bathroom', {
spawns: duelSpawns(-5, 5, 3, 11),
// The bath taps run: it fills slowly, then drains and fills again.
pools: [{ minX: -19, maxX: -4, minZ: -14, maxZ: -9, floor: 1, low: 1, high: 5, fill: 75, tap: { x: -16.6, y: 6.1, z: -13.55 } }],
itemSpawns: [
{ x: 0, y: 0, z: 7 },
{ x: -8, y: 0, z: 12 },
{ x: 14, y: 0, z: 10 },
{ x: -15.2, y: 0.5, z: 6.7 },
{ x: -14, y: 1, z: -11.5 },
{ x: -12, y: 5.5, z: -14.5 },
{ x: 8.2, y: 4.2, z: -9 },
{ x: 17, y: 8.5, z: -3.5 },
{ x: 10.6, y: 3.8, z: -3 },
{ x: -10, y: 3.4, z: -5.8 },
],
itemKinds: ['toothbrush', 'comb', 'razor', 'cottonswab', 'nailfile', 'tweezers', 'toothpick'],
ceiling: 10.3,
sky: 0xcdeef0,
sun: [0.6, -0.75, 0.28],
});
}
// ---------------------------------------------------------------------------
// Dining room — oak, sage, wine red (only in the house)
//
// Chains: floor → books 2.4 → chair seat 4.8 → dining table 7.4
// floor → wine crate 2.2 → sideboard drawer 4.6 → upper drawer 6.8 → sideboard 8.5
// The train runs under the table and two of the chairs: legs straddle the track.
function buildDining(doors?: Doors): RoomData {
const b = new RoomBuilder({ minX: -20, maxX: 20, minZ: -16, maxZ: 16 }, doors);
const OAK = 0xb07a45;
const OAK_D = 0x7d5230;
const SAGE = 0x8fa889;
const WINE = 0x8e2f3c;
const CREAM = 0xf5ecd8;
const BRASS = 0xc9a24a;
const WHITE = 0xf8f6f0;
const walls = shell(b, 0xf1e6cf, CREAM, { color: SAGE, top: 7, rail: OAK_D });
plankFloor(b, 0xa8723f, 0xb98049, 0x4a2e18);
windowOn(b, walls.back, -13, -4, 10, 19, CREAM, WINE);
picture(b, walls.back, 4, 14, 11, 18, OAK_D, 0xd9a72b);
picture(b, walls.right, 5, 13, 10, 16, BRASS, SAGE);
// wall clock
wallBox(b, walls.front, -11, -7, 13, 17, 0, 0.3, OAK_D);
wallBox(b, walls.front, -10.6, -7.4, 13.4, 16.6, 0, 0.36, WHITE, { cast: false });
wallBox(b, walls.front, -9.1, -8.9, 15, 16.2, 0, 0.42, 0x26282b, { cast: false });
wallBox(b, walls.front, -9.1, -8.1, 14.9, 15.1, 0, 0.42, 0x26282b, { cast: false });
// rug under the table
b.deco(-6, 0, -15, 18, 0.04, 3, WINE, { cast: false });
b.deco(-5, 0, -14, 17, 0.06, 2, 0xd9b77a, { cast: false });
// dining table, its legs clear of the track (which turns the corner underneath it)
table(b, 0, -13, 15, -1, 7.4, 0.6, 0.8, OAK, OAK_D, 'dining-table');
b.deco(1, 7.4, -8, 14, 7.44, -6, CREAM, { cast: false });
for (const [px, pz] of [
[3.5, -11],
[11.5, -11],
[3.5, -3],
[11.5, -3],
])
b.round(px, 7.4, pz, 0.95, 0.08, WHITE, { cast: false }, false);
for (const cx of [6, 9]) {
b.round(cx, 7.4, -7, 0.35, 0.25, BRASS, { name: 'candlestick' });
b.round(cx, 7.65, -7, 0.14, 1.4, BRASS, { name: 'candlestick', grab: false });
b.deco(cx - 0.12, 9.05, -7.12, cx + 0.12, 9.9, -6.88, 0xfbf6e8, { shape: 'cyl' });
b.deco(cx - 0.08, 9.9, -7.08, cx + 0.08, 10.2, -6.92, 0xffc870, { shape: 'cone', emissive: 0xffa040, cast: false });
}
b.round(3, 7.4, -7.2, 0.8, 1.3, WHITE, { name: 'teapot' });
b.deco(2.6, 8.7, -7.6, 3.4, 9.0, -6.8, WHITE, { shape: 'ball' });
b.deco(3.7, 7.9, -7.35, 4.4, 8.15, -7.05, WHITE);
b.box(11.6, 7.4, -8.2, 13.8, 8.2, -6.2, 0xd4b27a, { shape: 'bowl', name: 'bread-basket' });
b.deco(11.9, 8.0, -7.9, 13.0, 8.6, -6.9, 0xc98a3e, { shape: 'ball' });
b.deco(12.6, 8.0, -7.4, 13.6, 8.5, -6.5, 0xd99a4a, { shape: 'ball' });
// chairs: the train passes between the legs of the east and south ones
chair(b, 15.3, -8.5, 19.3, -4.5, 4.8, OAK, OAK_D, { side: 'x+', top: 9 }, 'dining-chair');
chair(b, 5, 0, 9, 4, 4.8, OAK, OAK_D, { side: 'z+', top: 9 }, 'dining-chair');
chair(b, -4.5, -9, -0.5, -5, 4.8, OAK, OAK_D, { side: 'x-', top: 9 }, 'dining-chair');
chair(b, 5, -16, 9, -12, 4.8, OAK, OAK_D, { side: 'z-', top: 9 }, 'dining-chair');
for (const [x0, z0, x1, z1] of [
[15.4, -8.4, 18.9, -4.6],
[5.1, 0.1, 8.9, 3.6],
[-4.0, -8.9, -0.6, -5.1],
[5.1, -15.4, 8.9, -12.1],
])
b.deco(x0, 4.8, z0, x1, 4.95, z1, WINE, { cast: false });
bookStack(b, -4, 0, -5, -1.5, -2.5, 2.4, [WINE, SAGE, 0xd9a72b, 0x3e5c8a], 61);
// sideboard on the left wall, with two drawers pulled out as steps
b.solid(-20, 0, 3, -15, 8.5, 15, 'sideboard');
b.deco(-20, 0, 3, -15.2, 8.1, 15, OAK_D);
b.deco(-20, 8.1, 2.8, -14.9, 8.5, 15.2, OAK);
for (const [z0, z1] of [
[3.4, 8.8],
[9.2, 14.6],
]) {
b.deco(-15.2, 0.8, z0, -14.95, 3.3, z1, OAK, { cast: false });
b.deco(-14.95, 1.9, (z0 + z1) / 2 - 0.2, -14.8, 2.3, (z0 + z1) / 2 + 0.2, BRASS);
}
for (const [y0, y1] of [
[3.6, 4.8],
[5.6, 6.8],
]) {
for (const [z0, z1] of [
[3.4, 7.6],
[11.9, 14.6],
]) {
b.deco(-15.2, y0, z0, -14.95, y1, z1, OAK, { cast: false });
b.deco(-14.95, (y0 + y1) / 2 - 0.15, (z0 + z1) / 2 - 0.4, -14.85, (y0 + y1) / 2 + 0.15, (z0 + z1) / 2 + 0.4, BRASS);
}
b.deco(-15.2, y0, 8, -15.1, y1, 11.5, 0x2e1c10, { cast: false });
}
b.box(-15, 3.6, 8, -13, 4.6, 11.5, OAK, { name: 'drawer' });
b.deco(-13, 3.9, 9.4, -12.8, 4.3, 10.1, BRASS);
b.box(-15, 5.8, 8, -14, 6.8, 11.5, OAK, { name: 'drawer' });
b.deco(-14, 6.1, 9.4, -13.8, 6.5, 10.1, BRASS);
// wine crate step
b.box(-15, 0, 5, -12.5, 2.2, 8, 0xc99a5e, { name: 'crate' });
for (const y of [0.5, 1.3]) b.deco(-12.52, y, 5, -12.45, y + 0.5, 8, 0xa77b45, { cast: false });
for (const z of [5.6, 6.5, 7.4]) b.deco(-14.4, 2.2, z - 0.15, -13.1, 2.5, z + 0.15, 0x5a1f2a, { shape: 'cyl', cast: false });
// on top: a vase of flowers, a stack of plates and a lamp
b.round(-17.5, 8.5, 4.8, 0.6, 2.2, 0x3e6ea8, { name: 'vase' });
for (const [fx, fz, fc] of [
[-17.8, 4.5, 0xe86f8a],
[-17.2, 4.9, 0xf2c14e],
[-17.5, 5.2, 0xd8443a],
])
b.deco(fx - 0.35, 11.0, fz - 0.35, fx + 0.35, 11.7, fz + 0.35, fc, { shape: 'ball' });
b.deco(-17.55, 10.7, 4.75, -17.45, 11.1, 4.85, 0x4f8a3a);
b.round(-17.5, 8.5, 9, 1.0, 0.7, WHITE, { name: 'plates' });
b.round(-17.5, 8.5, 13, 0.7, 0.3, BRASS, { name: 'lamp-base' });
b.deco(-17.6, 8.8, 12.9, -17.4, 10.6, 13.1, BRASS, { shape: 'cyl6' });
b.deco(-18.8, 10.4, 11.7, -16.2, 12.2, 14.3, 0xfbe7b5, { shape: 'shade', emissive: 0xffc870 });
// potted plant in the back corner
b.box(16, 0, -15, 19, 3, -12, 0xc0643a, { shape: 'bowl', name: 'plant-pot' });
for (const [x, y, z, s] of [
[17.5, 3.5, -13.5, 2.8],
[16.8, 5.4, -14.1, 2.2],
[18.2, 5.8, -12.9, 2.2],
])
b.deco(x - s / 2, y, z - s / 2, x + s / 2, y + s * 1.2, z + s / 2, 0x4f8a3a, { shape: 'cone', rotY: x });
// chandelier over the table
b.deco(7.45, 15.6, -7.05, 7.55, 25, -6.95, 0x3a3330, { cast: false });
b.deco(5.3, 15.2, -9.2, 9.7, 15.5, -4.8, BRASS, { shape: 'cyl', cast: false });
for (const [lx, lz] of [
[5.6, -7],
[9.4, -7],
[7.5, -8.9],
[7.5, -5.1],
])
b.deco(lx - 0.3, 15.5, lz - 0.3, lx + 0.3, 16.2, lz + 0.3, 0xfff0c8, { shape: 'ball', emissive: 0xffc870, cast: false });
return finish(b, 'dining', {
spawns: duelSpawns(-11, -1, 6, 12),
itemSpawns: [
{ x: -8, y: 0, z: 8 },
{ x: 12, y: 0, z: 10 },
{ x: -12, y: 0, z: -10 },
{ x: 0, y: 0, z: 12 },
{ x: 4, y: 7.4, z: -4.5 },
{ x: 12.5, y: 7.4, z: -11.5 },
{ x: -17.5, y: 8.5, z: 6.8 },
{ x: -2.5, y: 4.8, z: -7 },
{ x: -13.7, y: 2.2, z: 6.5 },
],
itemKinds: ['fork', 'spoon', 'butterknife', 'ladle', 'chopstick', 'pizzacutter'],
ceiling: 11.2,
sky: 0xf2dcc0,
sun: [-0.35, -0.75, 0.55],
});
}
// ---------------------------------------------------------------------------
// Garage — concrete, pale grey, safety yellow
//
// Chains: floor → stepladder 2.2 → 4.4 → 6.6 → top 8.8 → workbench 9.0 / cabinet 9.0
// floor → tyre 1.8 → two tyres 3.6 → three tyres 5.4
// floor → grass bag 2.2 → mower deck 3.4 → engine 5.2
// floor → box 3.0 → box 5.2
// In the house the train comes in from the hallway, runs past the big roll-up door
// and leaves into the bedroom, so the east side (local -x) holds the furniture.
function buildGarage(doors?: Doors): RoomData {
const b = new RoomBuilder({ minX: -22, maxX: 22, minZ: -16, maxZ: 16 }, doors);
const CONCRETE = 0xb9bcb8;
const GREY = 0x8d9296;
const YELLOW = 0xf2c14e;
const RED = 0xc8323c;
const WOOD = 0xb88a55;
const WOOD_D = 0x8a6238;
const GREEN = 0x3f8a4a;
const RUBBER = 0x26282b;
const walls = shell(b, 0xd6d8d4, 0xbfc3c0);
tileFloor(b, 4, CONCRETE, 0xadb0ac, 0x9a9d99);
b.deco(6, 0, -6, 11, 0.02, -2, 0x55524d, { shape: 'cyl', cast: false }); // oil stain
windowOn(b, walls.left, 9, 14, 11, 17, 0xbfc3c0);
// The big roll-up garage door across the back wall, shut.
wallBox(b, walls.back, -3.6, 19.6, 0, 18.6, 0, 0.2, 0x9a9d99);
wallBox(b, walls.back, -3, 19, 0, 18, 0, 0.3, 0xece9df);
for (let y = 3; y < 18; y += 3) wallBox(b, walls.back, -3, 19, y - 0.08, y + 0.08, 0, 0.36, 0xc9c5b8, { cast: false });
for (let u = -2; u < 18; u += 4) wallBox(b, walls.back, u, u + 3, 15.3, 17.2, 0, 0.34, 0xfff4d6, { emissive: 0xffd99a, cast: false });
wallBox(b, walls.back, 7, 9, 1.2, 1.7, 0, 0.6, GREY);
// Workbench on the east wall, a pegboard of tools above it.
table(b, -22, -8, -16, 6, 9, 0.8, 0.7, WOOD, WOOD_D, 'workbench');
b.deco(-21.8, 3, -7.6, -16.2, 3.2, 5.6, WOOD_D); // lower shelf
wallBox(b, walls.left, -8, 6, 10, 17, 0, 0.12, 0xd9b77a);
for (let u = -7.4; u < 5.6; u += 1) for (let y = 10.6; y < 16.6; y += 1) wallBox(b, walls.left, u, u + 0.1, y, y + 0.1, 0.12, 0.13, 0x6b5a40, { cast: false });
// hammer, saw, spanner, screwdriver hung on the board
wallBox(b, walls.left, -6.5, -6.1, 11, 15.5, 0.12, 0.35, WOOD_D);
wallBox(b, walls.left, -7.2, -5.4, 15, 15.9, 0.12, 0.45, GREY);
wallBox(b, walls.left, -3.8, 0.8, 12.4, 14.6, 0.12, 0.18, 0xc9d1d6);
wallBox(b, walls.left, -4.4, -3.6, 12.2, 15, 0.12, 0.4, RED);
wallBox(b, walls.left, 2, 2.4, 11, 16, 0.12, 0.3, 0xa9b0b8);
wallBox(b, walls.left, 4, 4.3, 11.5, 15.5, 0.12, 0.3, 0xa9b0b8);
wallBox(b, walls.left, 3.85, 4.45, 15.2, 16.4, 0.12, 0.4, YELLOW);
b.box(-17.6, 9, 3, -16.2, 10.2, 5, GREY, { name: 'vise' });
b.box(-21, 9, -3, -18, 10.8, 0, RED, { name: 'toolbox' });
b.deco(-20.6, 10.8, -1.75, -18.4, 11.1, -1.25, 0x26282b);
b.round(-20, 9, -6, 0.6, 1.4, 0xc9d1d6, { name: 'screw-tin' });
// Metal cabinet beside it, paint tins on top.
b.box(-22, 0, -16, -16, 9, -8, GREY, { name: 'cabinet' });
b.deco(-16.02, 0.6, -15.6, -15.95, 8.4, -12.1, 0x7a7f84, { cast: false });
b.deco(-16.02, 0.6, -11.9, -15.95, 8.4, -8.4, 0x7a7f84, { cast: false });
b.round(-19, 9, -12.5, 0.8, 1.6, 0x3e6ea8, { name: 'paint-tin' });
b.round(-20.5, 9, -10, 0.7, 1.3, YELLOW, { name: 'paint-tin' });
// Yellow stepladder up to the workbench.
for (const [x0, x1, y] of [
[-11.5, -10.6, 2.2],
[-12.6, -11.7, 4.4],
[-13.7, -12.8, 6.6],
[-16, -13.8, 8.8],
])
b.box(x0, y - 0.2, -3.5, x1, y, 0.5, YELLOW, { name: 'ladder-step' });
// Side rails rise from the floor in front and lean back with the steps; the back legs
// drop from the top cap to the floor under the bench.
for (const z of [-3.7, 0.5]) {
const rail = b.deco(-0.12, -5.2, z, 0.12, 5.2, z + 0.2, 0xd9a72b);
rail.position.set(-12.6, 4.6, z + 0.1);
rail.rotation.z = Math.atan2(4.4, 9.2);
const leg = b.deco(-0.12, -4.7, z, 0.12, 4.7, z + 0.2, 0xd9a72b);
leg.position.set(-17.2, 4.4, z + 0.1);
leg.rotation.z = -Math.atan2(2.6, 8.8);
}
// Tyres: one, two and three high.
const tyre = (cx: number, cz: number, n: number): void => {
b.solid(cx - 2.6, 0, cz - 2.6, cx + 2.6, n * 1.8, cz + 2.6, 'tyres', true, false, true);
for (let i = 0; i < n; i++) {
b.round(cx, i * 1.8, cz, 2.6, 1.8, RUBBER, {}, false);
b.round(cx, i * 1.8 + 1.79, cz, 1.4, 0.03, 0x111214, { cast: false }, false);
}
};
tyre(-4.8, 12, 1);
tyre(-10, 12, 2);
tyre(-15.2, 12, 3);
// Lawn mower: grass bag 2.2 → deck 3.4 → engine 5.2, handle reaching up behind.
b.box(-8.5, 0.3, -13, -2.5, 3.4, -7, GREEN, { name: 'mower-deck' });
b.box(-7, 3.4, -11.5, -4, 5.2, -8.5, 0x2b2d30, { name: 'mower-engine' });
b.deco(-6.2, 5.2, -10.7, -4.8, 5.6, -9.3, RED);
for (const [wx, wz] of [
[-8.6, -13.4],
[-8.6, -6.6],
[-2.4, -13.4],
[-2.4, -6.6],
]) {
const w = b.round(wx, 0, wz, 1.1, 0.5, RUBBER, { cast: true }, false);
w.rotation.x = Math.PI / 2;
w.position.y = 1.1;
b.solid(wx - 1.1, 0, wz - 0.25, wx + 1.1, 2.2, wz + 0.25, 'mower-wheel', false);
}
b.box(-2.5, 0, -12.5, 0.5, 2.2, -7.5, 0x6b8a4a, { name: 'grass-bag' });
for (const z of [-12.6, -7.6]) {
const bar = b.deco(-0.3, 0, z - 0.15, 0.3, 7.5, z + 0.15, 0x1d1f24);
bar.position.set(-0.6, 5.6, z);
bar.rotation.z = -0.45;
// Colliders in short pieces up the slanting bar.
for (const t of [0.15, 0.4, 0.65, 0.9]) {
const px = -2.23 + 3.26 * t;
const py = 2.22 + 6.76 * t;
b.solid(px - 0.35, py - 0.9, z - 0.15, px + 0.35, py + 0.9, z + 0.15, 'mower-handle', false);
}
}
b.box(0.9, 8.6, -13, 1.9, 9.1, -7.2, 0x1d1f24, { name: 'mower-handle' });
// Paint tins and a bucket (inside the train's loop in the house).
b.round(9, 0, -6, 1.1, 2.2, 0xe86f4a, { name: 'paint-tin' });
b.round(9, 2.2, -6, 1.1, 2.2, 0x3e6ea8, { name: 'paint-tin' });
b.round(11.2, 0, -6, 1.1, 2.2, YELLOW, { name: 'paint-tin' });
b.box(12, 0, 0, 14.6, 2.6, 2.6, 0x3e86c8, { shape: 'bowl', name: 'bucket' });
// Storage boxes by the hallway wall.
cardboard(b, 9, 0, 11, 13, 3, 14.7, 'box');
cardboard(b, 13, 0, 11.4, 16.5, 5.2, 14.7, 'box');
// A strip light.
b.deco(-6, 20, -1, 6, 20.4, 0, 0xf4f8ff, { emissive: 0xe8f0ff, cast: false });
b.deco(-0.05, 20.4, -0.55, 0.05, 25, -0.45, GREY, { cast: false });
return finish(b, 'garage', {
spawns: duelSpawns(-6, 6, -2, 4),
rollers: [{ kind: 'can', x: 9, y: 0, z: 1 }],
// Four car boxes: break one open and drive the car inside.
crates: [
{ x: -6, y: 0, z: 5 },
{ x: -1.5, y: 0, z: -3 },
{ x: 10, y: 0, z: -10.5 },
{ x: 19, y: 0, z: 10 },
],
itemSpawns: [
{ x: 0, y: 0, z: 0 },
{ x: -6, y: 0, z: 6 },
{ x: 8, y: 0, z: 9 },
{ x: -19, y: 9, z: 2 },
{ x: -15.2, y: 5.4, z: 12 },
{ x: 14.8, y: 5.2, z: 13 },
{ x: -8, y: 3.4, z: -8 },
{ x: -15, y: 8.8, z: -1.5 },
],
itemKinds: ['screwdriver', 'wrench', 'paintbrush', 'paperclip', 'clothespeg', 'nunchaku'],
ceiling: 11,
sky: 0xd9dde0,
sun: [0.35, -0.75, 0.55],
});
}
// ---------------------------------------------------------------------------
// The whole house: the four rooms and the dining room round one long hallway.
//
// north: Kitchen | Dining | Living (their windows on the outside walls)
// ========== Hallway ========== [front door]
// south: Bathroom | Bedroom | Garage (turned to face the hallway)
//
// Rooms keep their own walls, so inner walls are two thick. Doorways stand open.
// A toy train loops Dining → Living → Hallway → Garage (past its roll-up door) →
// Bedroom → Hallway → Dining, under the dining table and chairs.
const HALL = { minX: -41, maxX: 70, minZ: -7, maxZ: 7 };
const TRACK: TrackDef = {
points: [
[6, -30.5],
[34, -30.5],
[34, 1.5],
[66, 1.5],
[66, 37.5],
[52.5, 37.5],
[52.5, 18.3],
[6, 18.3],
],
r: 4,
};
/** How a room is set into the house: as built, turned half round, or mirrored front to back. */
type Placing = 'as-is' | 'turned' | 'mirrored';
function buildHouse(): RoomData {
const root = new THREE.Group();
root.name = 'room-house';
const solids: Solid[] = [];
const cutaway: RoomData['cutaway'] = [];
const areas: Area[] = [];
const itemSpawns: RoomData['itemSpawns'] = [];
const kinds = new Set<string>();
const machines: MachineDef[] = [];
const rollers: RollerDef[] = [];
const crates: { x: number; y: number; z: number }[] = [];
const pools: PoolDef[] = [];
const drips: DripDef[] = [];
const place = (part: RoomData, X: number, Z: number, how: Placing): void => {
const kx = how === 'turned' ? -1 : 1;
const kz = how === 'as-is' ? 1 : -1;
const holder = new THREE.Group();
holder.name = `holder-${part.id}`;
holder.position.set(X, 0, Z);
if (how === 'turned') holder.rotation.y = Math.PI;
if (how === 'mirrored') holder.scale.z = -1;
holder.add(part.root);
root.add(holder);
const rx = (a: number, c: number): [number, number] => {
const p = X + kx * a;
const q = X + kx * c;
return [Math.min(p, q), Math.max(p, q)];
};
const rz = (a: number, c: number): [number, number] => {
const p = Z + kz * a;
const q = Z + kz * c;
return [Math.min(p, q), Math.max(p, q)];
};
const rect = <T extends { minX: number; maxX: number; minZ: number; maxZ: number }>(r: T): T => {
const [minX, maxX] = rx(r.minX, r.maxX);
const [minZ, maxZ] = rz(r.minZ, r.maxZ);
return { ...r, minX, maxX, minZ, maxZ };
};
for (const s of part.solids) solids.push(rect(s));
for (const c of part.cutaway) {
const normal = new THREE.Vector3(c.normal.x * kx, c.normal.y, c.normal.z * kz);
let plane: WallPlane | undefined;
if (c.plane) {
const p = c.plane;
const [u0, u1] = p.axis === 'z' ? rx(p.u0, p.u1) : rz(p.u0, p.u1);
plane = { axis: p.axis, at: p.axis === 'z' ? Z + kz * p.at : X + kx * p.at, u0, u1 };
}
cutaway.push({ mesh: c.mesh, normal, plane });
}
for (const a of part.areas ?? []) areas.push(rect(a));
const yawOf = (yaw: number) => (how === 'turned' ? yaw + Math.PI : how === 'mirrored' ? Math.PI - yaw : yaw);
for (const m of part.machines ?? []) machines.push({ ...m, x: X + kx * m.x, z: Z + kz * m.z, ...('yaw' in m ? { yaw: yawOf(m.yaw) } : {}) } as MachineDef);
for (const r of part.rollers ?? []) rollers.push({ ...r, x: X + kx * r.x, z: Z + kz * r.z });
for (const c of part.crates ?? []) crates.push({ ...c, x: X + kx * c.x, z: Z + kz * c.z });
for (const p of part.pools ?? []) pools.push({ ...rect(p), tap: { ...p.tap, x: X + kx * p.tap.x, z: Z + kz * p.tap.z } });
for (const d of part.drips ?? []) drips.push({ ...d, x: X + kx * d.x, z: Z + kz * d.z });
for (const p of part.itemSpawns) itemSpawns.push({ x: X + kx * p.x, y: p.y, z: Z + kz * p.z, kinds: part.itemKinds });
for (const id of part.itemKinds) kinds.add(id);
};
// Rooms, with doorways where they meet the hallway and each other.
place(buildKitchen({ front: [{ u0: 10, u1: 18 }], right: [{ u0: -7, u1: -1 }] }), -46, -24, 'as-is');
place(buildDining({ left: [{ u0: -7, u1: -1 }], front: [{ u0: 3, u1: 11 }], right: [{ u0: -10, u1: -3 }] }), -1, -24, 'as-is');
place(buildLiving({ left: [{ u0: -10, u1: -3 }], front: [{ u0: -15, u1: -7 }] }), 45, -24, 'as-is');
place(buildBathroom({ front: [{ u0: -4, u1: 4 }] }), -20, 23, 'mirrored');
place(buildBedroom({ front: [{ u0: -14, u1: -6 }, { u0: 14.5, u1: 21.5 }], left: [{ u0: 3, u1: 7 }] }), 24, 24, 'turned');
place(buildGarage({ front: [{ u0: 0.5, u1: 7.5 }], right: [{ u0: 3, u1: 7 }] }), 70, 24, 'turned');
// ---- Hallway (built straight in house coordinates) ----
const hb = new RoomBuilder({ ...HALL }, {});
const PAPER = 0xe6c9a0;
const PANEL = 0xf3ead8;
const RAIL = 0x8a5a36;
const TRIM = 0xfaf3e6;
const WALNUT = 0x6b4428;
const WALNUT_D = 0x4e301b;
const BRASS = 0xc9a24a;
const DOOR = 0xf6f1e6;
hb.solid(HALL.minX, -1, HALL.minZ, HALL.maxX, 0, HALL.maxZ, 'floor', false);
plankFloor(hb, 0x9a6a42, 0xa8774a, 0x3b2414);
// long runner rug
hb.deco(-38, 0, -2.6, 64, 0.04, 2.6, 0x8e2f3c, { cast: false });
hb.deco(-37.4, 0, -2.0, 63.4, 0.06, 2.0, 0xd9a72b, { cast: false });
hb.deco(-36.8, 0, -1.6, 62.8, 0.065, 1.6, 0x8e2f3c, { cast: false });
/** A hallway wall surface: wallpaper, panelling, rail and skirting, with casings round its doorways. */
const dress = (w: Wall, lo: number, hi: number, doors: DoorSpec[]): void => {
for (const [s0, s1] of openSpans(lo, hi, doors)) {
wallBox(hb, w, s0, s1, WALL_BASE, WALL_H, 0, 0.04, PAPER, { cast: false });
wallBox(hb, w, s0, s1, 0.8, WALL_BASE, 0, 0.08, PANEL, { cast: false, parent: hb.root });
wallBox(hb, w, s0, s1, WALL_BASE, 6, 0, 0.08, PANEL, { cast: false });
wallBox(hb, w, s0, s1, 6, 6.3, 0, 0.16, RAIL, { cast: false });
wallBox(hb, w, s0, s1, 0, 0.8, 0, 0.16, TRIM, { cast: false, parent: hb.root });
}
for (const d of doors) {
wallBox(hb, w, d.u0, d.u1, DOOR_H, WALL_H, 0, 0.04, PAPER, { cast: false });
wallBox(hb, w, d.u0 - 0.6, d.u0, 0, DOOR_H + 0.6, 0, 0.18, TRIM);
wallBox(hb, w, d.u1, d.u1 + 0.6, 0, DOOR_H + 0.6, 0, 0.18, TRIM);
wallBox(hb, w, d.u0 - 0.6, d.u1 + 0.6, DOOR_H, DOOR_H + 0.6, 0, 0.18, TRIM);
}
};
/** A group of wall dressing that hides together with the wall it covers. */
const skin = (along: 'x' | 'z', face: number, inward: 1 | -1, plane: WallPlane): Wall => {
const group = new THREE.Group();
hb.root.add(group);
hb.cutaway.push({ mesh: group, normal: along === 'x' ? new THREE.Vector3(0, 0, inward) : new THREE.Vector3(inward, 0, 0), plane });
return { group, along, face, inward };
};
/** A wall of the hallway's own: collider, mesh and dressing. */
const ownWall = (along: 'x' | 'z', face: number, inward: 1 | -1, lo: number, hi: number, dressLo: number, dressHi: number): Wall => {
const w = skin(along, face, inward, { axis: along === 'x' ? 'z' : 'x', at: face - inward * 0.5, u0: lo, u1: hi });
wallSolid(hb, w, lo, hi, -1, WALL_H, -WALL_T, 0, 'wall');
wallBox(hb, w, lo, hi, -1, WALL_BASE, -WALL_T, 0, PAPER, { cast: false, parent: hb.root });
wallBox(hb, w, lo, hi, WALL_BASE, WALL_H, -WALL_T, 0, PAPER, { cast: false });
dress(w, dressLo, dressHi, []);
return w;
};
// The rooms' walls seen from the hallway, dressed to match it.
const north = (lo: number, hi: number, wallLo: number, wallHi: number, doors: DoorSpec[]): Wall => {
const w = skin('x', HALL.minZ, 1, { axis: 'z', at: HALL.minZ - 0.5, u0: wallLo, u1: wallHi });
dress(w, lo, hi, doors);
return w;
};
const south = (lo: number, hi: number, wallLo: number, wallHi: number, doors: DoorSpec[]): Wall => {
const w = skin('x', HALL.maxZ, -1, { axis: 'z', at: HALL.maxZ + 0.5, u0: wallLo, u1: wallHi });
dress(w, lo, hi, doors);
return w;
};
const nKitchen = north(-41, -22, -70, -22, [{ u0: -36, u1: -28 }]);
const nDining = north(-22, 20, -22, 20, [{ u0: 2, u1: 10 }]);
const nLiving = north(20, 70, 20, 70, [{ u0: 30, u1: 38 }]);
const sBath = south(-41, 1, -41, 1, [{ u0: -24, u1: -16 }]);
const sBed = south(1, 47, 1, 47, [
{ u0: 2.5, u1: 9.5 },
{ u0: 30, u1: 38 },
]);
south(47, 70, 47, 93, [{ u0: 62.5, u1: 69.5 }]);
const west = ownWall('z', HALL.minX, 1, -8, 8, -7, 7);
const east = ownWall('z', HALL.maxX, -1, -8, 8, -7, 7);
// Pictures along the hallway.
picture(hb, nKitchen, -40, -37.2, 10, 15, WALNUT_D, 0x7a9a4a);
picture(hb, nLiving, 39.5, 43, 11, 16, BRASS, 0xc8473a);
picture(hb, sBath, -12, -4, 10, 15, WALNUT_D, 0xd9a72b);
picture(hb, sBed, 18.5, 27.5, 10, 16, BRASS, 0x9cc3e6);
picture(hb, west, -4, 4, 10, 17, BRASS, 0xd9c7a0);
// mirror over the console table
wallBox(hb, nDining, -18.9, -12.9, 10.5, 17.5, 0, 0.2, BRASS);
wallBox(hb, nDining, -18.4, -13.4, 11, 17, 0, 0.26, 0xcfe6ec, { emissive: 0x1d3036, cast: false });
// Coat hooks by the front door, with a coat and a scarf hanging.
wallBox(hb, nLiving, 44, 56, 13, 13.6, 0, 0.3, WALNUT);
for (const u of [45.5, 49, 52.5, 55]) wallBox(hb, nLiving, u - 0.15, u + 0.15, 12.6, 13.3, 0.3, 0.8, BRASS);
wallBox(hb, nLiving, 47.6, 50.4, 6.5, 12.8, 0.4, 1.4, 0x3e5c8a);
wallBox(hb, nLiving, 48.6, 49.4, 11.6, 12.9, 0.35, 1.5, 0x2c4466);
wallBox(hb, nLiving, 54.5, 55.4, 7.5, 12.8, 0.35, 0.7, 0xd8443a);
wallBox(hb, nLiving, 55.0, 55.9, 8.5, 12.8, 0.7, 1.05, 0xf2c14e);
// The front door at the far end: shut, with a fanlight, letterbox and knob.
wallBox(hb, east, -5.1, 5.1, 0, 20.6, 0, 0.2, TRIM);
wallBox(hb, east, -4.5, 4.5, 0, 20, 0, 0.32, 0x2f6b4a);
for (const [u0, u1, y0, y1] of [
[-3.8, -0.5, 1.5, 8.5],
[0.5, 3.8, 1.5, 8.5],
[-3.8, -0.5, 11, 18.4],
[0.5, 3.8, 11, 18.4],
])
wallBox(hb, east, u0, u1, y0, y1, 0.32, 0.44, 0x27593d);
wallBox(hb, east, -1.6, 1.6, 9.4, 10.1, 0.32, 0.46, BRASS);
wallBox(hb, east, 2.9, 3.6, 9.4, 10.1, 0.32, 0.8, BRASS);
wallBox(hb, east, -0.4, 0.4, 15, 15.6, 0.32, 0.5, BRASS);
wallBox(hb, east, -4.5, 4.5, 20.8, 23.4, 0, 0.24, 0xfff4d6, { emissive: 0xffd99a, cast: false });
wallBox(hb, east, -4.9, 4.9, 20.6, 20.8, 0, 0.3, TRIM);
hb.deco(66.2, 0, -3.6, 69.8, 0.08, 3.6, 0x8a6a3c, { cast: false });
hb.deco(66.6, 0, -3.2, 69.4, 0.1, 3.2, 0x6b4f2a, { cast: false });
// Shoe rack, a pair of trainers and an umbrella stand by the door.
hb.solid(58, 0, -7, 66, 3.2, -4.6, 'shoe-rack');
for (const x of [58, 65.7]) hb.deco(x, 0, -7, x + 0.3, 3.2, -4.6, WALNUT);
for (const y of [0.25, 1.6, 2.95]) hb.deco(58.3, y, -7, 65.7, y + 0.25, -4.6, WALNUT);
for (const [sx, sy, sc] of [
[58.8, 0.5, 0xd8443a],
[61.6, 0.5, 0x3e5c8a],
[59.2, 1.85, 0x2c2a26],
[62.4, 1.85, 0xf2c14e],
]) {
hb.deco(sx, sy, -6.6, sx + 2.4, sy + 0.25, -5.1, 0xf4f1ea, { cast: false });
hb.deco(sx + 0.2, sy + 0.25, -6.5, sx + 1.6, sy + 0.9, -5.2, sc, { cast: false });
}
const trainer = (x0: number, z0: number, color: number): void => {
hb.solid(x0, 0, z0, x0 + 3, 1.1, z0 + 1.2, 'trainer');
hb.deco(x0, 0, z0, x0 + 3, 0.3, z0 + 1.2, 0xf4f1ea);
hb.deco(x0 + 0.1, 0.3, z0 + 0.05, x0 + 2.0, 1.1, z0 + 1.15, color);
hb.deco(x0 + 2.0, 0.3, z0 + 0.1, x0 + 2.95, 0.7, z0 + 1.1, color);
for (const lx of [1.9, 2.2, 2.5]) hb.deco(x0 + lx, 0.7, z0 + 0.3, x0 + lx + 0.1, 0.78, z0 + 0.9, 0xf4f1ea, { cast: false });
};
trainer(52, -6.6, 0x2f8f8a);
trainer(52.4, -4.7, 0x2f8f8a);
hb.round(68, 0, -5.6, 0.9, 5, 0x3e5c8a, { name: 'umbrella-stand' });
for (const [ux, uz, uc] of [
[67.7, -5.8, 0xd8443a],
[68.3, -5.4, 0xf2c14e],
]) {
hb.deco(ux - 0.4, 5, uz - 0.4, ux + 0.4, 9.2, uz + 0.4, uc, { shape: 'cone' });
hb.deco(ux - 0.05, 9.2, uz - 0.05, ux + 0.05, 10.4, uz + 0.05, WALNUT_D);
}
// Console table, a stool and a box: floor → box 2.4 → stool 4.8 → console 7.6
table(hb, -19.4, -7, -12.4, -4, 7.6, 0.6, 0.6, WALNUT, WALNUT_D, 'console-table');
hb.box(-18.8, 7.6, -6.4, -17, 8.1, -4.6, 0x2f8f8a, { shape: 'bowl', name: 'key-bowl' });
hb.deco(51.8, 8.0, 5.4, 52.3, 8.1, 5.9, BRASS, { cast: false });
hb.round(-13.3, 7.6, -6.1, 0.6, 0.3, BRASS, { name: 'lamp-base' });
hb.deco(-13.4, 7.9, -6.2, -13.2, 9.8, -6.0, BRASS, { shape: 'cyl6' });
hb.deco(-14.5, 9.6, -7.3, -12.1, 11.2, -4.9, 0xfbe7b5, { shape: 'shade', emissive: 0xffc870 });
hb.deco(-16.4, 7.6, -6.2, -14.8, 7.66, -5.0, 0xf4ecd6, { cast: false });
chair(hb, -12.4, -7, -8.8, -4, 4.8, WALNUT, WALNUT_D, undefined, 'stool');
cardboard(hb, -8.8, 0, -6.6, -6.6, 2.4, -4.2, 'box');
// Grandfather clock: its brass pendulum swings out in front of the case. Stand on the
// plinth (3), jump and catch it, ride it, let go at the end of a swing to be flung.
hb.box(13, 0, -7, 19, 3, -3, WALNUT_D, { name: 'clock-plinth' });
hb.box(13.5, 3, -7, 18.5, 14, -5, WALNUT, { name: 'clock-case', grab: false });
hb.deco(14.2, 4, -5.03, 17.8, 13.2, -4.98, 0x2b2420, { cast: false });
hb.box(12.5, 14, -7, 19.5, 21, -3.5, WALNUT_D, { name: 'clock-hood' });
hb.deco(12.2, 21, -7, 19.8, 21.8, -3.2, WALNUT, { cast: false });
hb.deco(15.2, 21.8, -6.2, 16.8, 23, -4.6, 0xc9a24a, { shape: 'ball' });
const dial = hb.round(16, 17.5, -3.5, 2.5, 0.12, 0xf4ecd6, { cast: false }, false);
dial.rotation.x = Math.PI / 2;
dial.position.set(16, 17.5, -3.42);
const ring = hb.round(16, 17.5, -3.5, 2.75, 0.1, 0xc9a24a, { cast: false }, false);
ring.rotation.x = Math.PI / 2;
ring.position.set(16, 17.5, -3.46);
for (let h = 0; h < 12; h++) {
const a = (h / 12) * Math.PI * 2;
hb.deco(16 + Math.sin(a) * 2.1 - 0.1, 17.5 + Math.cos(a) * 2.1 - 0.1, -3.38, 16 + Math.sin(a) * 2.1 + 0.1, 17.5 + Math.cos(a) * 2.1 + 0.1, -3.34, 0x2b2420, { cast: false });
}
const hand = (len: number, angle: number, w: number): void => {
const m = hb.deco(-w / 2, 0, -3.33, w / 2, len, -3.3, 0x1d1f24, { cast: false });
m.geometry = m.geometry.clone().translate(0, 0.5, 0);
m.position.set(16, 17.5, -3.31);
m.rotation.z = -angle;
};
hand(1.4, 2.0, 0.16);
hand(2.0, 5.2, 0.1);
// Radiator and a plant at the west end.
hb.box(-41, 0, -3, -40.2, 5, 3, 0xf4f1ea, { name: 'radiator' });
for (let z = -2.6; z < 3; z += 0.6) hb.deco(-40.2, 0.4, z, -40.05, 4.6, z + 0.3, 0xe2ddd2, { cast: false });
hb.box(-40.5, 0, 3.8, -38, 2.8, 6.5, 0xc0643a, { shape: 'bowl', name: 'plant-pot' });
for (const [x, y, z, s] of [
[-39.2, 3.2, 5.1, 2.4],
[-39.8, 4.8, 4.6, 1.8],
[-38.7, 5.0, 5.6, 1.8],
])
hb.deco(x - s / 2, y, z - s / 2, x + s / 2, y + s * 1.2, z + s / 2, 0x4f8a3a, { shape: 'cone', rotY: x });
// ---- Open doors, swung right back against the wall beside each doorway ----
/** A door leaf standing flat against wall `w` from u0 to u1, hinged at one end; it hides with that wall. */
const leaf = (w: Wall, u0: number, u1: number, hingeAtU0: boolean, plane: WallPlane): void => {
const group = new THREE.Group();
hb.root.add(group);
hb.cutaway.push({ mesh: group, normal: new THREE.Vector3(), plane });
const g: Wall = { ...w, group };
wallSolid(hb, g, u0, u1, 0.1, DOOR_H - 0.4, 0, 0.58, 'door');
wallBox(hb, g, u0, u1, 0.1, DOOR_H - 0.4, 0.22, 0.58, DOOR);
for (const [y0, y1] of [
[2, 8.5],
[10.5, 18],
])
wallBox(hb, g, u0 + 0.8, u1 - 0.8, y0, y1, 0.58, 0.66, 0xe8e0cf, { cast: false });
const free = hingeAtU0 ? u1 - 0.8 : u0 + 0.8;
wallBox(hb, g, free - 0.3, free + 0.3, 9.3, 9.9, 0.58, 0.95, BRASS);
const hinge = hingeAtU0 ? u0 : u1;
for (const y of [3, 16]) wallBox(hb, g, hinge - 0.15, hinge + 0.15, y, y + 1, 0.15, 0.62, BRASS, { cast: false });
};
const N: WallPlane = { axis: 'z', at: HALL.minZ - 0.5, u0: 0, u1: 0 };
const S: WallPlane = { axis: 'z', at: HALL.maxZ + 0.5, u0: 0, u1: 0 };
const hallN: Wall = { group: hb.root, along: 'x', face: HALL.minZ, inward: 1 };
const hallS: Wall = { group: hb.root, along: 'x', face: HALL.maxZ, inward: -1 };
leaf(hallN, -27.4, -19.6, true, { ...N, u0: -70, u1: -22 }); // kitchen
leaf(hallN, -5.8, 1.4, false, { ...N, u0: -22, u1: 20 }); // dining room
leaf(hallN, 22.2, 29.4, false, { ...N, u0: 20, u1: 70 }); // living room
leaf(hallS, -31.8, -24.6, false, { ...S, u0: -41, u1: 1 }); // bathroom
leaf(hallS, 10.1, 16.9, true, { ...S, u0: 1, u1: 47 }); // bedroom (west)
leaf(hallS, 38.6, 46.4, true, { ...S, u0: 1, u1: 47 }); // bedroom (east)
// Between rooms: kitchen → dining opens against the dining room's west wall,
// dining → living room against the dining room's east wall.
leaf({ group: hb.root, along: 'z', face: -21, inward: 1 }, -36.8, -31.6, false, { axis: 'x', at: -21.5, u0: -40, u1: -8 });
leaf({ group: hb.root, along: 'z', face: 19, inward: -1 }, -26.4, -19.6, true, { axis: 'x', at: 19.5, u0: -40, u1: -8 });
// Hallway → garage opens against the garage's front wall; bedroom → garage against the garage's west wall.
leaf({ group: hb.root, along: 'x', face: 8, inward: 1 }, 54.1, 61.9, false, { axis: 'z', at: 7.5, u0: 47, u1: 93 });
leaf({ group: hb.root, along: 'z', face: 48, inward: 1 }, 12.6, 16.4, false, { axis: 'x', at: 47.5, u0: 8, u1: 40 });
// Garden lawn round the house and a path to the front door.
hb.deco(-170, -1.4, -140, 180, -0.5, 140, 0x86b35e, { cast: false });
hb.deco(71, -0.5, -4.5, 100, -0.42, 4.5, 0xcdbb9c, { cast: false });
// The train track.
hb.root.add(buildTrack(TRACK));
root.add(hb.root);
solids.push(...hb.solids);
cutaway.push(...hb.cutaway);
areas.push({ ...HALL });
for (const p of [
{ x: -30, y: 0, z: 3 },
{ x: 20, y: 0, z: -4 },
{ x: 44, y: 0, z: 3 },
{ x: -15.5, y: 7.6, z: -5.6 },
{ x: 62, y: 3.2, z: -5.8 },
{ x: -10.6, y: 4.8, z: -5.5 },
{ x: 17.8, y: 3, z: -3.4 },
])
itemSpawns.push({ ...p, kinds: ['screwdriver', 'wrench', 'pencil', 'paintbrush'] });
return {
id: 'house',
name: 'Whole House',
root,
solids,
spawns: duelSpawns(-30, -18, -2, 3),
itemSpawns,
itemKinds: [...kinds],
bounds: { minX: -70, maxX: 93, minZ: -41, maxZ: 41 },
ceiling: 15,
cutaway,
sky: 0xeedcbc,
sunDir: new THREE.Vector3(-0.35, -0.75, 0.55).normalize(),
areas,
track: { ...TRACK },
machines: [...machines, { kind: 'pendulum', style: 'clock', x: 16, y: 13.8, z: -4.4, yaw: 0, length: 8.9, swing: 0.4, period: 2.6 }],
rollers,
crates,
pools,
drips,
};
}
