diff --git a/src/bowlLine.ts b/src/bowlLine.ts new file mode 100644 index 0000000..80b6c7d --- /dev/null +++ b/src/bowlLine.ts @@ -0,0 +1,144 @@ +// The bowl line (stage 3): the bowl slide, once it's bought, the hearth room's conveyor. The bowl counter by the east +// wall goes, and in its place stands the line's motor, a squat steel drive box with a red flywheel turning on its side and +// its drive belt down through a slot into the floor; the bowls the pot ladles out drop into a steel hopper over the +// line's start by the hearth's east end, and ride a stretch of belt into a hood in the floor; under the hearth room +// (and the motor) and the arena's wall, to come up out of a hood on the ringside of the soup square's north counter, +// by the east wall (where the sake barrels stood, now further up the wall), and climb a cleated ramp onto the counter's +// top, where they're slid into their places. Mama Moray's turn off under the floor before it, and go on under the +// square and the arena's wall, up through the slot in her counter (resto.ts). The game's numbers are the counter's +// still (fight.ts): the bowls it held wait in the hopper and on the line, a few of them on show. +import { Group, type Mesh, type Object3D, type Vector3 } from 'three'; +import { bowlTable, cellar, CY, lineRamp, POT_TOP } from './cellar'; +import { BAR, BAR_PLACES, COUNTER, MAMA_COUNTER, POT, RISE } from './cellarPlan'; +import { F, restoOpen } from './fight'; +import { fly } from './holder'; +import { newBowl } from './items'; +import { apart, dice } from './kit'; +import { KITCHEN, RESTO } from './layout'; +import { G, mesh } from './render'; +import { underBelt } from './underBelt'; +import { V, type XZ } from './util'; + +const STEEL = 0x5E676E, STEEL_DARK = 0x33393D, HOLE = 0x0B0C0D, BRASS = 0xB8923A, HOUSING = 0x7E8890, PAINT = 0x8E2B2B; +/** Where the line starts (under the hopper, by the hearth's east end), and how it gets to the soup square: south under + * the hearth room by the east wall (under the motor), west under it and north, then west under the arena's wall to + * the ramp's foot, and south up it, its top over the north counter. Mama's turn off there, under the floor: south + * under the square and the arena's wall into her hall, and across under her counter's slot. */ +const START = { x: COUNTER.x + 0.2, z: KITCHEN.z0 + 0.4 }, WEST = KITCHEN.x0 + 0.3, RAMP = lineRamp.at; +const LINE: XZ[] = [ + START, { x: START.x, z: COUNTER.z }, { x: WEST, z: COUNTER.z }, { x: WEST, z: RAMP.z0 }, { x: RAMP.x, z: RAMP.z0 }, + { x: RAMP.x, z: RAMP.z1 }, +]; +const HALL = (RESTO.z0 + MAMA_COUNTER.z0) / 2; +const TO_MAMA: XZ[] = [{ x: RAMP.x, z: RAMP.z0 }, { x: RAMP.x, z: HALL }, { x: RISE.x, z: HALL }, { x: RISE.x, z: RISE.z }]; +/** How far along the line its `i`-th point is, plus `off`. */ +const along = (i: number, off = 0) => LINE.slice(1, i + 1).reduce((s, q, j) => s + Math.hypot(q.x - LINE[j].x, q.z - LINE[j].z), 0) + off; +/** Where Mama's turn off, under the floor; and the ramp: up out of its hood, climbing to the counter's top. */ +const FORK = along(4); +const line = apart(() => underBelt(LINE, CY, { + speed: 3, + stretches: [ + { from: 0, to: 0.85, down: true }, + { from: along(4, RAMP.foot - RAMP.z0), to: along(5), up: true, rise: BAR.top - 0.075 }, + ], +}), dice(0xB0371E)); +const toMama = apart(() => underBelt(TO_MAMA, CY, { speed: 3, stretches: [] }), dice(0xB03720)); + +/** The hopper over the line's start the pot's bowls are ladled into, and how high its mouth is. */ +const HOPPER = V(START.x, CY + 0.62, START.z + 0.25), HOPPER_TOP = 0.24; +/** The bowls waiting in the hopper (a few of what the counter's numbers hold), the line's motor's flywheel, and the + * bowls in the air: from the pot into the hopper, and off the line up onto the stall's counter. */ +let tableUp = true, waitBowls: Object3D[] = [], wheel: Group | null = null, flights: { b: Object3D; mush: boolean; busy: boolean }[] = []; + +/** All of the line (shown once it's bought): its stretches, the hopper and the motor; centred on the motor, so it + * pops in from there. */ +export const lineParts = apart(() => { + const g = new Group(), inner = new Group(); + cellar.add(g); g.add(inner); + g.position.set(COUNTER.x, CY, COUNTER.z); inner.position.set(-COUNTER.x, -CY, -COUNTER.z); + inner.add(line.group); + const box = (p: Object3D, c: number, x: number, y: number, z: number, sx: number, sy: number, sz: number, shadow = true) => { + const o = mesh(G.box, c, x, y, z, shadow); o.scale.set(sx, sy, sz); p.add(o); return o; + }; + // the hopper: an open steel funnel on two legs over the belt, its sides leaning out to a brass rim, dark inside + const h = new Group(); h.position.copy(HOPPER); inner.add(h); + const LEAN = 0.35, W = 0.56; + for (const s of [-1, 1]) { + const a = box(h, STEEL, s * (W / 2 - 0.04), HOPPER_TOP / 2, 0, 0.03, HOPPER_TOP + 0.04, W); a.rotation.z = -s * LEAN; + const b = box(h, STEEL, 0, HOPPER_TOP / 2, s * (W / 2 - 0.04), W, HOPPER_TOP + 0.04, 0.03); b.rotation.x = s * LEAN; + box(h, BRASS, s * (W / 2 + 0.005), HOPPER_TOP + 0.01, 0, 0.03, 0.02, W + 0.04, false); + box(h, BRASS, 0, HOPPER_TOP + 0.01, s * (W / 2 + 0.005), W + 0.04, 0.02, 0.03, false); + box(h, STEEL_DARK, s * 0.2, -0.29, 0, 0.04, 0.6, 0.04); + } + box(h, HOLE, 0, 0.01, 0, W - 0.22, 0.01, W - 0.22, false); + waitBowls = [0, 1, 2].map(i => { const b = newBowl(false); b.scale.setScalar(0.78); b.position.set((i - 1) * 0.15, 0.05 + (i % 2) * 0.04, (i % 2) * 0.1 - 0.05); b.visible = false; h.add(b); return b; }); + // the motor, where the counter was: on a plinth, a steel drive box, a vent along it and a brass maker's plate, a + // lamp on top; a flywheel on its west side, its drive belt down through a slot into the floor to the line + const m = new Group(); g.add(m); + box(m, STEEL_DARK, 0, 0.04, 0, 0.85, 0.08, 1.05); + box(m, HOUSING, 0.15, 0.37, 0, 0.5, 0.58, 0.8); + box(m, STEEL_DARK, 0.15, 0.67, 0, 0.54, 0.04, 0.84); + for (let i = 0; i < 4; i++) box(m, STEEL_DARK, -0.105, 0.3 + i * 0.07, 0.15, 0.01, 0.025, 0.36, false); + box(m, BRASS, -0.105, 0.42, -0.22, 0.012, 0.12, 0.2, false); + const lamp = mesh(G.sphere, 0x7CFF8A, 0.3, 0.73, 0.28, false); lamp.scale.setScalar(0.05); m.add(lamp); + // (the flywheel painted red, a brass rim and hub, so it reads turning) + wheel = new Group(); wheel.position.set(-0.19, 0.42, -0.1); m.add(wheel); + const rim = mesh(G.cyl, BRASS, 0, 0, 0, true) as Mesh; rim.scale.set(0.36, 0.07, 0.36); rim.rotation.z = Math.PI / 2; wheel.add(rim); + const face = mesh(G.cyl, PAINT, -0.01, 0, 0, false); face.scale.set(0.31, 0.06, 0.31); face.rotation.z = Math.PI / 2; wheel.add(face); + const hub = mesh(G.cyl, BRASS, -0.045, 0, 0, false); hub.scale.set(0.08, 0.04, 0.08); hub.rotation.z = Math.PI / 2; wheel.add(hub); + for (let i = 0; i < 3; i++) { const sp = box(wheel, STEEL_DARK, -0.045, 0, 0, 0.015, 0.56, 0.06, false); sp.rotation.x = (i * Math.PI) / 3; } + box(m, 0x1E2022, -0.17, 0.2, -0.12, 0.05, 0.42, 0.07); + box(m, HOLE, -0.17, 0.085, -0.12, 0.09, 0.01, 0.16, false); + // the bowls in the air + flights = [false, true].flatMap(mush => Array.from({ length: 6 }, () => { + const b = newBowl(mush); b.scale.setScalar(0.78); b.visible = false; cellar.add(b); return { b, mush, busy: false }; + })); + g.visible = false; + return g; +}, dice(0xB0371F)); + +function hop(mush: boolean, from: Vector3, to: () => Vector3, dur: number, done?: () => void, arc = 0.3) { + const f = flights.find(o => !o.busy && o.mush === mush); + if (!f) { done?.(); return; } + f.busy = true; f.b.position.copy(from); f.b.visible = true; + fly(f.b, to, dur, arc, () => { f.busy = false; f.b.visible = false; done?.(); }); +} +/** Where a bowl ladled out of the pot lands: in the hopper. */ +export const hopperAt = () => V(HOPPER.x, HOPPER.y + 0.08, HOPPER.z); +/** The north counter's places nearest the ramp's top, for the stall's bowls to be slid into off it. */ +const NEAR = BAR_PLACES.map((q, j) => ({ q, j })).filter(({ q }) => q.z < BAR.z0 + BAR.d) + .sort((a, b) => Math.abs(a.q.x - RAMP.x) - Math.abs(b.q.x - RAMP.x)).slice(0, 4); +let nearI = 0; +/** A bowl for the stall: out of the hopper, along the line, up the ramp and slid along the counter's top. */ +export function lineToStall(mush: boolean) { + line.send(mush, undefined, m => { + const q = NEAR[nearI++ % NEAR.length].q; + hop(m, V(RAMP.x, CY + BAR.top + 0.045, RAMP.z1), () => V(q.x, CY + BAR.top + 0.045, q.z), 0.25, undefined, 0.04); + }); +} +/** A bowl for Mama Moray's: ladled across from the pot into the hopper, along the line to where hers turn off, then + * on under the floor, `done` as it comes up under her counter's slot. */ +export function lineToMama(done: () => void) { + hop(false, V(POT.x + 0.4, CY + POT_TOP, POT.z), hopperAt, 0.5, () => line.send(false, FORK, () => toMama.send(false, undefined, done))); +} +/** The bowls on the line (for tests): all of them, or those going on to Mama's. */ +export const lineShows = (mama = false) => (mama ? line.riding() - line.riding(line.length) + toMama.riding() : line.riding() + toMama.riding()); +/** Whether the line's laid, and whether it takes bowls on to Mama's (once she's open). */ +export const lineLaid = () => lineParts.visible; +export const mamaLeg = () => lineParts.visible && restoOpen(); + +/** Every frame, from ringKitchen.ts: the line shown once it's bought (the counter gone), its way on to Mama's once + * she's open; its flywheel turning, the hopper's bowls, and the bowls along it. */ +export function updLine(dt: number) { + lineParts.visible = F.slide; + // (and the ramp's in the way once it's there: cellar.ts) + lineRamp.up = F.slide; + // (the counter's meshes hidden one by one, not its group: hidden, each drops out of the scenery's merged draws) + if (bowlTable.g && tableUp !== !F.slide) { tableUp = !F.slide; bowlTable.g.traverse(o => { if ((o as Mesh).isMesh) o.visible = tableUp; }); } + if (!F.slide) return; + if (wheel) wheel.rotation.x -= dt * 5; + const n = Math.min(F.counter.length, waitBowls.length); + waitBowls.forEach((b, i) => { b.visible = i < n; }); + line.update(dt); + toMama.update(dt); +} diff --git a/src/cellar.ts b/src/cellar.ts index a442638..93e0559 100644 --- a/src/cellar.ts +++ b/src/cellar.ts @@ -18,10 +18,11 @@ import { import { mergeGeometries } from 'three/addons/utils/BufferGeometryUtils.js'; import { ARCH_HALF, ARCHES, AUDIENCE, AUDIENCE_CHAIRS, BAR, BAR_STOOLS, BOOTH_SEAT, BOOTH_TABLE, CHAT, DANCE, DANCERS, inBooth, LOUNGE, WALL_BOOTHS, BARRELS, BENCH, BIN_PADS, BINS, BAND, BANDSTAND, BOOTHS, CASHIER, COAT_SPOT, GUARDS, CLUB_DOOR, CLUB_WAY, DEN_BAR, DEN_TABLES, DESKS, FLAP, LEDGER, STAGE, CHUTE, chuteY, COUNTER, GONG, HEARTH, LIFTS, machineAt, MAX_MACHINES, POT, STORES, TEPPO, TUNNEL_D, TUNNEL_W, TUNNELS, + BASINS, MAMA_COUNTER, STALL_DOOR, STALLS, STOOLS, TABLE_SEAT, TABLES, } from './cellarPlan'; import { Fugu } from './fugu'; import { apart, Build, dice, K, quietly, rbox, shade, shadowsOf, tube } from './kit'; -import { ARENA, CARDS, CELLAR_Y, type CellarRoom, CRAFT, DEN, DOORS, found, GROW, keepOnFloor, KITCHEN, LANDING, LIFT_ROOM, LOANS, OFFICE_ROOM, PASSAGE, PREP, pushOutOfBox, ROOMS, VIP, walkable } from './layout'; +import { ARENA, CARDS, CELLAR_Y, type CellarRoom, CRAFT, DEN, DOORS, found, GROW, keepOnFloor, KITCHEN, LANDING, LIFT_ROOM, LOANS, LOO, OFFICE_ROOM, PREP, pushOutOfBox, RESTO, ROOMS, VIP, walkable, WING } from './layout'; import { type CanvasTex, canvasTex, copyGeo, type Draw, FONT, G, mat, mesh, OFF, paint, rr, scene } from './render'; import type { XZ } from './util'; @@ -110,16 +111,16 @@ scene.add(cellar); /** Parts that come with the venue's tiers, shown when they're bought: the bleachers (the Back-Alley Ring), the upper * tier and the taiko drums (the Underground Arena), and the VIP boxes (the Grand Hall Below). And the grow room, with * the doorway into it, found when the first boss is beaten (until then, `hidden`: the prep hall's tall back wall); and - * the Undertow and its tunnel, found once the doorman lets you in (until then, `denHidden`: the earth over it); and - * `denWall`, the arena's wall across the tunnel's mouth, which the doorman's door stands in for (southDoor.ts). */ + * the Undertow, found once the doorman lets you in through the out-of-order stall (until then, `denHidden`: the earth + * over it). */ export const parts = { bleachers: new Group(), tier: new Group(), vip: new Group(), drums: new Group(), grow: new Group(), hidden: new Group(), // (the Undertow's made on dice of their own, so loading draws from the game's luck just what it did before it: see // BUILT_WITH) - ...apart(() => ({ den: new Group(), denHidden: new Group(), denWall: new Group() }), dice(0xDE4)), + ...apart(() => ({ den: new Group(), denHidden: new Group() }), dice(0xDE4)), }; Object.values(parts).forEach(g => { g.visible = false; cellar.add(g); }); -parts.hidden.visible = parts.denHidden.visible = parts.denWall.visible = true; +parts.hidden.visible = parts.denHidden.visible = true; // ---------- the rooms ---------- const earth = tiling(256, 256, (c, w, h) => { @@ -182,6 +183,23 @@ const denBoards = tiling(256, 256, (c, w, h) => { c.fillStyle = 'rgba(140,210,210,.06)'; c.fillRect(((i * 53) % 180) + 30, i * h / 12 + 6, 80, 3); } }); +/** Green and white checker tiles, for Mama Moray's restroom: grimy in the joints, a few cracked, one or two gone. */ +const looTiles = tiling(256, 256, (c, w, h) => { + const N = 8, T = w / N, hash = (i: number) => { const x = Math.sin(i * 51.37) * 43758.5453; return x - Math.floor(x); }; + for (let y = 0; y < N; y++) for (let x = 0; x < N; x++) { + const i = y * N + x, gone = hash(i) > 0.965; + c.fillStyle = gone ? '#6A5A48' : (x + y) % 2 ? '#2E8A7E' : '#EDEBE2'; + c.fillRect(x * T, y * T, T, T); + if (!gone) { c.fillStyle = 'rgba(70,60,40,.10)'; c.fillRect(x * T + T * hash(i + 9) * 0.5, y * T + T * hash(i + 3) * 0.5, T * 0.5, T * 0.4); } + // (a crack across it, now and then) + if (!gone && hash(i + 40) > 0.86) { + c.strokeStyle = 'rgba(40,34,28,.65)'; c.lineWidth = 1.5; c.beginPath(); + c.moveTo(x * T + T * hash(i + 1), y * T); c.lineTo(x * T + T * hash(i + 2), y * T + T * 0.5); c.lineTo(x * T + T * hash(i + 5), y * T + T); c.stroke(); + } + } + c.fillStyle = 'rgba(60,52,40,.55)'; + for (let k = 0; k <= N; k++) { c.fillRect(k * T - 1, 0, 2, h); c.fillRect(0, k * T - 1, w, 2); } +}); const SIDE = mat(0x3A2C22); /** A floor over `r`, out to the middle of its walls, of earth or boards, its texture repeating every 4 m. */ function floor(r: Rect, tex: CanvasTex, pad = 0.3, parent: Object3D = cellar) { @@ -461,7 +479,7 @@ later(() => { wall(LANDING, 'n', 0.3); wall(LANDING, 'w', 0.3); wall(LANDING, 'e', 0.3); wall(KITCHEN, 'n', 0.3); wall(KITCHEN, 'w', 0.3); wall(KITCHEN, 'e', 0.3); wall(KITCHEN, 's', 0.3); // the cookware shop, through the hearth room's south wall - floor(CRAFT, boards()); wall(CRAFT, 'w'); wall(CRAFT, 'e', 0.3); wall(CRAFT, 's'); + floor(CRAFT, boards()); wall(CRAFT, 'w', 0.3); wall(CRAFT, 'e', 0.3); wall(CRAFT, 's', 0.3); wall(PREP, 'e', 0.3); wall(PREP, 's'); // the lift room behind the prep hall's east wall, where nobody goes: just the dumbwaiters floor(LIFT_ROOM, flags()); wall(LIFT_ROOM, 'n', 0.3); wall(LIFT_ROOM, 'e'); wall(LIFT_ROOM, 's', 0.3); @@ -474,20 +492,20 @@ later(() => { bars(parts.grow, (t.x0 + t.x1) / 2, PREP.z0 - 0.4, t.x1 - t.x0 + 0.1, 1.75, Math.PI / 2 - 0.15); } floor(GROW, earth(), 0.3, parts.grow); tallWall(GROW, 'n', parts.grow); wall(GROW, 'e', 0.1, parts.grow); - // the Undertow: a tunnel through the arena's south wall, into the Family's back room dug into the rock in front of the - // arena. Until the doorman lets you through his door across its mouth (southDoor.ts, which stands in for the arena's - // wall there, denWall), there's only earth behind it (denHidden). - { - const t = DOORS.find(d => d.b === 'passage')!; - planks(parts.denWall, true, line(ARENA, 's', 0.1), t.x0 - 0.3, t.x1 + 0.3); - } - // (the tunnel's dug back to the arena's own wall, and its floor runs from the arena's to the room's) - stands(PASSAGE, 'n', PASSAGE.z0 - line(ARENA, 's', 0.1)); - floor({ x0: PASSAGE.x0, x1: PASSAGE.x1, z0: ARENA.z1 + 0.4, z1: PASSAGE.z1 + 0.1 }, earth(), 0.1, parts.den); - wall(PASSAGE, 'w', 0.1, parts.den); wall(PASSAGE, 'e', 0.1, parts.den); wall(PASSAGE, 's', 0.1, parts.den); - // (the short way through the rock from the tunnel to the club's doors: boarded, and walled either side; its boards run - // from the club's floor's edge to the tunnel's, meeting each without overlapping it, or the two flicker where they lie) - floor({ x0: DEN.x1 + 0.3, x1: PASSAGE.x0 - 0.1, z0: CLUB_WAY.z0 - 0.1, z1: CLUB_WAY.z1 + 0.1 }, denBoards(), 0, parts.den); + // Mama Moray's, through the arena's south wall (resto.ts dresses it): the front hall, back to back with the arena (its + // own wall the arena's), and the cookware shop's west wall between them; the dining wing round behind the shop; and + // the restroom, tiled, in the hall's far corner, off the wing. Boards as the hearth room's, its kitchen's. + stands(RESTO, 'n', RESTO.z0 - line(ARENA, 's', 0.1)); + // (each floor out to the middle of the walls it shares and to the outer walls' faces, so they meet without overlapping) + floor({ x0: RESTO.x0 + 0.2, x1: RESTO.x1, z0: ARENA.z1 + 0.6, z1: RESTO.z1 }, boards()); + floor({ x0: WING.x0, x1: WING.x1 - 0.2, z0: WING.z0, z1: WING.z1 - 0.2 }, boards()); + floor({ x0: LOO.x0 + 0.2, x1: LOO.x1, z0: LOO.z0, z1: LOO.z1 - 0.2 }, looTiles()); + wall(RESTO, 'w'); wall(RESTO, 's', 0.3); wall(LOO, 'w'); wall(LOO, 's'); wall(WING, 'w', 0.3); wall(WING, 'e'); wall(WING, 's'); + // the Undertow, dug into the rock in front of the arena, through the restroom's west wall: until the doorman lets you + // through his door there (southDoor.ts), only earth behind it (denHidden) + // (the short way through the rock from the out-of-order stall to the club: boarded, and walled either side; its boards + // run from the club's floor's edge to the restroom's, meeting each without overlapping it, or the two flicker) + floor({ x0: DEN.x1 + 0.3, x1: LOO.x0 - 0.1, z0: CLUB_WAY.z0 - 0.1, z1: CLUB_WAY.z1 + 0.1 }, denBoards(), 0, parts.den); for (const z of [CLUB_WAY.z0 - 0.1, CLUB_WAY.z1 + 0.1]) planks(parts.den, true, z, CLUB_WAY.x0 + 0.1, CLUB_WAY.x1 - 0.1); floor(DEN, denBoards(), 0.3, parts.den); for (const side of ['n', 'w', 'e', 's'] as const) denWall(DEN, side); @@ -588,14 +606,14 @@ const dug = (r: Rect): Box => { const within = (b: Box, x: number, z: number) => x > b.x0 && x < b.x1 && z > b.z0 && z < b.z1; /** The earth's meshes, once it's built: round the rooms (near the Undertow, lit once it's found and dark till then), and * the secret rooms' places while they're secrets; the crags, and the kerbs round the rooms, round the grow room, round - * the Undertow, and along the arena's wall across the tunnel's mouth while it's a secret. */ -export const ground = {} as Record<'open' | 'nearDen' | 'nearDenShut' | 'hidden' | 'denHidden' | 'rocks' | 'kerb' | 'growKerb' | 'denKerb' | 'mouthKerb', Mesh>; + * the Undertow, and along the stall's back while it's a secret. */ +export const ground = {} as Record<'open' | 'nearDen' | 'nearDenShut' | 'hidden' | 'denHidden' | 'rocks' | 'kerb' | 'growKerb' | 'denKerb' | 'shutKerb', Mesh>; later(() => { // (both sides drawn: a rock face seen from behind, past a wall's end, still shows as rock; and no light of the rooms' // on it at all, so none ever seems to come through a wall: its light is drawn into it, a facet at a time, by `bake`) const EARTH = new MeshBasicMaterial({ map: dirt().tex, vertexColors: true, side: DoubleSide }); const rooms = [...Object.values(ROOMS), LIFT_ROOM, CLUB_WAY], holes = rooms.map(dug); - const grow = holes[rooms.indexOf(GROW)], den = [PASSAGE, DEN, CLUB_WAY, LOANS, VIP, CARDS].map(r => holes[rooms.indexOf(r)]); + const grow = holes[rooms.indexOf(GROW)], den = [DEN, CLUB_WAY, LOANS, VIP, CARDS].map(r => holes[rooms.indexOf(r)]); const A = dug(ARENA), O = dug(OFFICE_ROOM); /** The secret rooms: their holes, what they're shown in once found, and what covers them till then. */ const secrets = [{ holes: [grow], open: parts.grow, shut: parts.hidden }, { holes: den, open: parts.den, shut: parts.denHidden }]; @@ -827,7 +845,7 @@ later(() => { // from above, each block its own length and a little its own height, lit like the rest out here but a good deal // lighter, so where every room stops reads at a glance (never along a bank, nor over a tunnel; round the grow room's // own walls only once it's found) - // (and the stretch of the arena's own across the tunnel's mouth, while the Undertow's a secret) + // (and along the earth over the Undertow, while it's a secret) const kerbs = new Map([[cellar, []], [parts.grow, []], [parts.den, []], [parts.denHidden, []]]); for (const h of holes) { const own = h === grow ? parts.grow : den.includes(h) ? parts.den : cellar; @@ -855,13 +873,13 @@ later(() => { } } const KERB = new MeshBasicMaterial({ color: 0x6E6458, vertexColors: true }); - const [kerb, growKerb, denKerb, mouthKerb] = [...kerbs].map(([parent, gs]) => { + const [kerb, growKerb, denKerb, shutKerb] = [...kerbs].map(([parent, gs]) => { const m = add(parent, new Mesh(mergeGeometries(gs)!, KERB)); gs.forEach(g => g.dispose()); return m; }); const [hidden, denHidden] = secrets.map(s => hiddenSheets.get(s)!.mesh(s.shut)); - Object.assign(ground, { open: far.mesh(cellar), nearDen: lit2.mesh(parts.den), nearDenShut: dark2.mesh(parts.denHidden), hidden, denHidden, rocks, kerb, growKerb, denKerb, mouthKerb }); + Object.assign(ground, { open: far.mesh(cellar), nearDen: lit2.mesh(parts.den), nearDenShut: dark2.mesh(parts.denHidden), hidden, denHidden, rocks, kerb, growKerb, denKerb, shutKerb }); }); // the doorways: a timber frame in the wall, posts and a lintel @@ -869,8 +887,9 @@ later(() => { const acrossX = (t: (typeof DOORS)[number]) => !Object.values(ROOMS).some(r => Math.abs(r.x1 - t.x0) < 0.01 || Math.abs(r.x0 - t.x1) < 0.01); later(() => { for (const t of DOORS) { - // (the club's doors hang in a frame of their own, doorway.ts, and so does the door at the tunnel's mouth, southDoor.ts) - if (t.b === 'den' || t.b === 'passage') continue; + // (the way into the club, the out-of-order stall's back, is the doorman's door: southDoor.ts; and Mama Moray's + // hall opens wide into its dining wing, one room round the corner, with no frame) + if (t.b === 'den' || (t.a === 'resto' && t.b === 'wing')) continue; const ax = acrossX(t), cx = (t.x0 + t.x1) / 2, cz = (t.z0 + t.z1) / 2, w = (ax ? t.x1 - t.x0 : t.z1 - t.z0) + 0.6; const g = t.b === 'grow' ? parts.grow : t.b === 'loans' || t.b === 'vip' || t.b === 'cards' ? parts.den : cellar; for (const s of [-1, 1]) { @@ -1359,9 +1378,13 @@ export function updBarFlap(dt: number, p: XZ | null) { /** Whether the flap's open enough to walk through, and how far open it is (for tests). */ export const flapOpen = () => flap.k > 0.7; export const flapK = () => flap.k; -/** Straw-wrapped sake barrels (komodaru), stacked against the east wall between the landing's door and the soup bar, the - * way a shrine stacks the ones it's given. */ -const KOMO = { x: ARENA.x1 - 0.35, z: 1.95 }; +/** Straw-wrapped sake barrels (komodaru), stacked against the east wall north of the landing's door, the way a shrine + * stacks the ones it's given (south of the door, by the soup bar, is the bowl line's ramp: bowlLine.ts). */ +const KOMO = { x: ARENA.x1 - 0.35, z: -3.75 }; +/** The bowl line's ramp up onto the soup square's north counter from the ringside, by the east wall (bowlLine.ts): its + * `x`, where the line turns south under the floor (`z0`), its hood's mouth on the floor (`foot`), and its top over the + * counter (`z1`); and whether it's laid (it's in the way once it is). */ +export const lineRamp = { at: { x: ARENA.x1 - 0.4, z0: BAR.z0 - 2.25, foot: BAR.z0 - 1.75, z1: BAR.z0 + 0.15 }, up: false }; later(() => { // two wraps, each with its crest on the side facing the arena and the one facing the camera const wrap = (i: number) => canvasTex(256, 128, (c, w, h) => { @@ -1497,10 +1520,13 @@ export const potMesh = (() => { add(ladle, mesh(G.sphere, 0x9AA4AC, 0, -0.02, 0)).scale.set(0.13, 0.07, 0.13); return g; })(); +/** The counter the bowls come out on, once it's made (and hidden for good once the bowl line's bought: bowlLine.ts). */ +export const bowlTable: { g: Group | null } = { g: null }; later(() => { // the counter the bowls come out on: a low table against the east wall in the corner south of the prep hall's door, a // cloth along it for the bowls, and on a shelf underneath, the empty ones stacked const t = add(cellar, new Group()); t.position.set(COUNTER.x, CY, COUNTER.z); + bowlTable.g = t; box(t, WOOD, 0, 0.76, 0, 1.0, 0.08, 1.4); for (const [x, z] of [[-0.42, -0.6], [0.42, -0.6], [-0.42, 0.6], [0.42, 0.6]]) box(t, WOOD_DARK, x, 0.36, z, 0.09, 0.72, 0.09); box(t, 0x2B3A67, 0.05, 0.81, 0, 0.75, 0.01, 1.3, false); @@ -1716,10 +1742,11 @@ later(() => { /** Where the shop's things stand, for walking round them (the way from its doorway to the counter's square is kept * clear): a tansu chest by the counter; crates of crockery and a pallet of clay pots against the north wall, east of * the doorway; shelves of bowls along the east wall; a showcase of food samples in the middle; and along the south - * wall, towers of bamboo steamers, stock pots, and a crock of long spoons and ladles. */ + * wall, either side of its doorway to the dining wing, a crock of long spoons and ladles, then towers of bamboo + * steamers and stock pots. */ const TANSU = { x: CRAFT.x0 + 0.95, z: CRAFT.z0 + 0.25 }, CRATES = { x: 8.3, z: CRAFT.z0 + 0.42 }, DONABE = { x: 9.95, z: CRAFT.z0 + 0.4 }; const SHELF = { x: CRAFT.x1 - 0.45, z: 9.75 }, SHOWCASE = { x: 8.85, z: 10.05 }; -const STEAMERS = { x: 6.3, z: CRAFT.z1 - 0.4 }, POTS = { x: 8.7, z: CRAFT.z1 - 0.4 }, CROCK = { x: CRAFT.x0 + 0.4, z: CRAFT.z1 - 0.3 }; +const STEAMERS = { x: 7.7, z: CRAFT.z1 - 0.4 }, POTS = { x: 9.3, z: CRAFT.z1 - 0.4 }, CROCK = { x: CRAFT.x0 + 0.4, z: CRAFT.z1 - 0.3 }; /** The same picture on several faces, as one mesh: `geo` (facing +z) moved into place by each of `at`. */ function pictures(geo: BufferGeometry, map: CanvasTex['tex'], at: Matrix4[]) { return add(cellar, new Mesh(mergeGeometries(at.map(m => copyGeo(geo).applyMatrix4(m)))!, new MeshLambertMaterial({ map }))); @@ -1845,6 +1872,7 @@ later(() => { m.position.set(x + i * span / 3, ny, z + 0.012); } lantern(cellar, t.x1 + 0.6, CY + 2.2, CRAFT.z0 + 0.15, true); + // (its back doorway's, from Mama Moray's dining wing, is hers: resto.ts) // a tansu chest in the corner by the counter, dark wood with black iron: a long drawer over two short ones over another // long one, iron pulls on round plates, corner plates, a lock plate, and a carrying ring on each end; a lucky cat on @@ -2104,9 +2132,8 @@ later(() => { // ---------- the Undertow: the Family's club ---------- // A dark club dug out of the rock in front of the arena, after a gangster's bar in a city's undercity: near-black boards, -// iron and neon. The way in is a long dark tunnel shored up like a mine's, out of the arena's south wall and down past -// the club, pipes along its wall and beams overhead, to a pair of heavy doors in its side at the far end (the doors, the -// doorman behind them and the tunnel's lights: doorway.ts). Laid out as a jazz club and a speakeasy are: along its back, +// iron and neon. The way in is the out-of-order stall in Mama Moray's restroom out front: its back is the doorman's +// door (southDoor.ts, doorway.ts). Laid out as a jazz club and a speakeasy are: along its back, // under a tall dark wall, the long bar at the west end (its front lit by tubes of amber and pink, bottles lit from under // their shelves in front of a gilt-framed mirror, lamps hung low along it, the club's name over it), then three pointed // iron arches hung with red velvet, iron columns between them with teal lamps at their tops, and the bandstand across @@ -2207,22 +2234,8 @@ later(() => { }).tex); const p = parts.den, BRASS = 0xC9A23A, LEATHER = 0x6B1E22, IRONS = 0x262E33, PIPE = 0x3A454C, MAHOGANY = 0x5A2618; const face = line(DEN, 'n', 0.3) + 0.07; - // ---- the tunnel ---- - // shoring: a frame of timber every so often along it, beams across the top, a lantern at its mouth, and pipes along its - // east wall (one of them leaking steam: doorway.ts) - for (let z = PASSAGE.z0 + 0.5; z < PASSAGE.z1 - 0.3; z += 1.25) { - // (none in front of the doors, nor just behind the south door, whose own frame is there) - if ((z > CLUB_DOOR.z0 - 0.25 && z < CLUB_DOOR.z1 + 0.25) || z < PASSAGE.z0 + 1) continue; - for (const x of [PASSAGE.x0 + 0.04, PASSAGE.x1 - 0.04]) box(p, WOOD_DARK, x, CY + FRAME_H / 2, z, 0.12, FRAME_H, 0.12); - box(p, WOOD_DARK, (PASSAGE.x0 + PASSAGE.x1) / 2, CY + FRAME_H, z, PASSAGE.x1 - PASSAGE.x0 + 0.16, 0.12, 0.14); - } - lantern(p, PASSAGE.x0 - 0.1, CY + 2.3, ARENA.z1 + 1.6, true); - for (const [y, r] of [[1.35, 0.05], [1.55, 0.035]] as const) { - const pipe = add(p, mesh(G.cyl, PIPE, PASSAGE.x1 - 0.12, CY + y, (PASSAGE.z0 + PASSAGE.z1) / 2)); pipe.scale.set(r, PASSAGE.z1 - PASSAGE.z0, r); pipe.rotation.x = Math.PI / 2; - } - for (let z = PASSAGE.z0 + 1.1; z < PASSAGE.z1; z += 2.4) add(p, mesh(new CylinderGeometry(0.075, 0.075, 0.08, 10), IRONS, PASSAGE.x1 - 0.12, CY + 1.35, z)).rotation.x = Math.PI / 2; - // a brass plate by the doors: members only - box(p, BRASS, PASSAGE.x0 + 0.02, CY + 1.55, CLUB_DOOR.z0 - 0.45, 0.02, 0.14, 0.32, false); + // a brass plate by the way in from the restroom: members only + box(p, BRASS, DEN.x1 - 0.12, CY + 1.55, CLUB_DOOR.z0 - 0.45, 0.02, 0.14, 0.32, false); // ---- the club's back wall: the columns and the three arches, each with its curtain ---- const cols = [ARCHES[0] - ARCH_HALF, ...ARCHES.map(x => x + ARCH_HALF)]; for (const x of cols) { @@ -2892,12 +2905,12 @@ export function showVenue(t: number) { parts.tier.visible = parts.drums.visible = t >= 2; parts.vip.visible = t >= 3; } -/** Whether the club's doors are shut (doorway.ts opens and shuts them): shut, they're in the way. */ -export const denDoor = { shut: true }; -/** Whether the doorman's door, across the tunnel's mouth in the arena's south wall, is shut (southDoor.ts opens and +/** Whether the doorman's door, the out-of-order stall's back in the restroom's west wall, is shut (southDoor.ts opens and * shuts it): shut, it's in the way. */ export const southDoor = { shut: true }; -/** The Undertow's found: the arena's wall across the tunnel's mouth comes down, and through it the Family's back room. +/** Whether the out-of-order stall's taped across (resto.ts keeps it): taped, its door's in the way. */ +export const stallTape = { up: true }; +/** The Undertow's found: the earth behind the restroom's west wall is gone, and through it the Family's club. * (Its lamps' light is there all along, dark till then, as the grow room's is.) */ export function showDen(on = true) { parts.den.visible = on; @@ -2919,7 +2932,7 @@ export function showGrowRoom(on = true) { // ---------- light ---------- /** The cellar's lights: the lamp over the ring, two lanterns' worth of warm light over the stands, the hearth's fire * (lighting the hearth room), the hatch's daylight (the landing), the office's lamp, the dumbwaiter's lamp in the prep - * hall, (once it's found) the grow room's sickly glow, the cookware shop's lamp, and (once it's found) the Undertow's + * hall, (once it's found) the grow room's sickly glow, the cookware shop's lamp, (once it's found) the Undertow's * cold light over the room and its bar's warm glow. */ export const lights = [ new PointLight(0xFFD7A0, 16, 15, 1.2), new PointLight(0xFF9A5A, 9, 10, 1.5), new PointLight(0xFF9A5A, 9, 10, 1.5), @@ -2928,6 +2941,8 @@ export const lights = [ // (the Undertow's, on dice of their own, as its parts are: the club's teal over the room, the bar's amber, Harry's // lamp's warm light over his desk, and a low rosy one in Eddie's room) ...apart(() => [new PointLight(0xB8E6DE, 12, 13, 1.3), new PointLight(0xFFA27A, 7, 8, 1.4), new PointLight(0xFFD9A0, 7, 7, 1.4), new PointLight(0xFFC4A8, 7, 7, 1.4)], dice(0xDE11)), + // (and Mama Moray's warm light over her hall and its wing, on dice of its own too) + ...apart(() => [new PointLight(0xFFC080, 8, 11, 1.4)], dice(0x4E572)), ]; lights[0].position.set(RING.x, CY + 5.35, RING.z); lights[1].position.set(RING.x, CY + 3.4, ARENA.z0 + 1.5); @@ -2942,6 +2957,7 @@ lights[9].position.set((DEN.x0 + DEN.x1) / 2 + 1, CY + 3.0, DEN.z0 + 3.2); lights[10].position.set(DEN_BAR.x0 + 1.5, CY + 2.3, DEN.z0 + 3.4); lights[11].position.set(DESKS.harry.x + 0.6, CY + 2.3, DESKS.harry.z + 0.5); lights[12].position.set(DESKS.eddie.x + 1.0, CY + 2.3, DESKS.eddie.z); +lights[13].position.set(RESTO.x1 - 1.0, CY + 2.7, RESTO.z1 + 0.3); const GROW_GLOW = lights[7].intensity, DEN_GLOW = [lights[9].intensity, lights[10].intensity, lights[11].intensity, lights[12].intensity]; lights[7].intensity = lights[9].intensity = lights[10].intensity = lights[11].intensity = lights[12].intensity = 0; lights.forEach(l => { l.visible = false; scene.add(l); }); @@ -2972,6 +2988,8 @@ export function cellarBoxes(machines: Record<'rice' | 'fish' | 'mush', number> = [CASHIER.x, CASHIER.z, 0.65, 1.3], [TEPPO.x, TEPPO.z, 0.5, 0.5], [KOMO.x, KOMO.z, 0.5, 1.45], + // (the bowl line's ramp, hood to top, once it's laid) + ...(lineRamp.up ? [[lineRamp.at.x, (lineRamp.at.foot - 0.65 + BAR.z0) / 2, 0.38, (BAR.z0 - lineRamp.at.foot + 0.65) / 2] as [number, number, number, number]] : []), // the hearth room: the hearth and the counter behind it, and the bowl counter [HEARTH.x, (KITCHEN.z0 + HEARTH.z + HEARTH.d / 2 + 0.6) / 2, HEARTH.w / 2 + 0.3, (HEARTH.z + HEARTH.d / 2 + 0.6 - KITCHEN.z0) / 2 + 0.3], [COUNTER.x, COUNTER.z, 0.75, 0.95], @@ -2989,6 +3007,20 @@ export function cellarBoxes(machines: Record<'rice' | 'fish' | 'mush', number> = [STEAMERS.x, STEAMERS.z, 0.75, 0.4], [POTS.x, POTS.z, 0.85, 0.45], [CROCK.x, CROCK.z, 0.45, 0.45], + // Mama Moray's (resto.ts): her counter and her behind it, the stools along its front, the tables and their stools, + // the restroom's basins and its stalls (the first shut; the second's front either side of its door, its south side + // and its toilet), and the out-of-order stall's tape till the doorman's let you in + [(MAMA_COUNTER.x0 + MAMA_COUNTER.x1) / 2, (RESTO.z0 + MAMA_COUNTER.z1) / 2, (MAMA_COUNTER.x1 - MAMA_COUNTER.x0) / 2 + 0.2, (MAMA_COUNTER.z1 - RESTO.z0) / 2 + 0.2], + [(STOOLS[0].x + STOOLS[STOOLS.length - 1].x) / 2, STOOLS[0].z, (STOOLS[STOOLS.length - 1].x - STOOLS[0].x) / 2 + 0.35, 0.35], + ...TABLES.map((t): [number, number, number, number] => [t.x, t.z, TABLE_SEAT + 0.35, 0.6]), + [RESTO.x0 + 0.42, RESTO.z0 + 0.3, 0.4, 0.3], + ...BASINS.map((b): [number, number, number, number] => [b.x, b.z, 0.4, 0.35]), + [(LOO.x0 + STALLS.x) / 2, (STALLS.a.z0 + STALLS.a.z1) / 2, (STALLS.x - LOO.x0) / 2 + 0.15, (STALLS.a.z1 - STALLS.a.z0) / 2 + 0.15], + [STALLS.x, (STALLS.b.z0 + STALL_DOOR.z0) / 2, 0.15, (STALL_DOOR.z0 - STALLS.b.z0) / 2 + 0.1], + [STALLS.x, (STALL_DOOR.z1 + STALLS.b.z1) / 2, 0.15, (STALLS.b.z1 - STALL_DOOR.z1) / 2 + 0.1], + [(LOO.x0 + STALLS.x) / 2, STALLS.b.z1, (STALLS.x - LOO.x0) / 2 + 0.15, 0.15], + [LOO.x0 + 0.62, STALLS.b.z1 - 0.23, 0.35, 0.25], + ...(!stallTape.up ? [] : [[STALLS.x, (STALL_DOOR.z0 + STALL_DOOR.z1) / 2, 0.15, (STALL_DOOR.z1 - STALL_DOOR.z0) / 2] as [number, number, number, number]]), // the office: the dais and the desk, the safe, the visitors' chairs, the cabinet, the fish tank, the plant and the rack [(DAIS.x0 + DAIS.x1) / 2, (DAIS.z0 + DAIS.z1) / 2, (DAIS.x1 - DAIS.x0) / 2 + 0.3, (DAIS.z1 - DAIS.z0) / 2 + 0.3], [VAULT.x, VAULT.z, 0.9, 0.85], @@ -3001,7 +3033,7 @@ export function cellarBoxes(machines: Record<'rice' | 'fish' | 'mush', number> = // the Undertow, once it's found: the booths under the arches from the wall to the front of their tables, Eddie's room // (his banquette and table, his bodyguards, the champagne, the lamp and the palm), Harry's office, Tommy's card room (his // table, him behind it, his screen, his easel), the bar, the stools, the tables, the stage and the barrels; and the - // club's doors while they're shut, and the doorman's door at the tunnel's mouth while it is + // doorman's door, the out-of-order stall's back, while it's shut if (found.den) { const E = DESKS.eddie, H = DESKS.harry, T = DESKS.tommy; /** A box from z0 to z1, `hx` either side of x. */ @@ -3043,8 +3075,7 @@ export function cellarBoxes(machines: Record<'rice' | 'fish' | 'mush', number> = boxes.push([CHAT.x, CHAT.z, 0.3, 0.3], [CHAT.a.x, CHAT.a.z, 0.22, 0.22], [CHAT.b.x, CHAT.b.z, 0.22, 0.22]); for (const d of DANCERS) boxes.push([d.x, d.z, 0.5, 0.5]); boxes.push([STAGE.x, STAGE.z + STAGE.r / 2, STAGE.r + 0.15, STAGE.r / 2 + 0.15], [BARRELS.x, BARRELS.z, 0.5, 0.85]); - if (denDoor.shut) boxes.push(along(CLUB_DOOR.x, 0.2, CLUB_DOOR.z0, CLUB_DOOR.z1)); - if (southDoor.shut) boxes.push([(PASSAGE.x0 + PASSAGE.x1) / 2, ARENA.z1 + 0.1, (PASSAGE.x1 - PASSAGE.x0) / 2 + 0.1, 0.15]); + if (southDoor.shut) boxes.push(along(CLUB_DOOR.x, 0.2, CLUB_DOOR.z0, CLUB_DOOR.z1)); } for (const k of ['rice', 'fish', 'mush'] as const) { if (k === 'mush' && !found.grow) continue; @@ -3084,6 +3115,20 @@ export function collideCellar(p: XZ, machines?: Record<'rice' | 'fish' | 'mush', /** The vertex-coloured material the furniture's baked with, by side. */ const bakedMats = new Map(); const bakedMat = (side: Facing) => bakedMats.get(side) ?? bakedMats.set(side, new MeshLambertMaterial({ vertexColors: true, side })).get(side)!; +/** The baked furniture's shading by the floor, as if light couldn't quite reach in under things (ambient occlusion, + * baked once into its vertex colours, so it costs nothing to draw): darkest, `low` of its colour, where it meets the + * floor, up to its full colour `up` metres over it; and its undersides a shade darker still. The floor itself, and + * whatever lies flat on it, as it is. */ +const AO = { low: 0.6, up: 0.55, under: 0.85 }; +function occlude(g: BufferGeometry, lift: number) { + const pos = g.attributes.position, nor = g.attributes.normal, col = g.attributes.color; + for (let i = 0; i < pos.count; i++) { + const h = pos.getY(i) + lift, ny = nor.getY(i); + if (ny > 0.7 && h < 0.04) continue; + const k = Math.min(1, Math.max(0, h / AO.up)), f = (AO.low + (1 - AO.low) * k * k * (3 - 2 * k)) * (ny < -0.5 ? AO.under : 1); + col.setXYZ(i, col.getX(i) * f, col.getY(i) * f, col.getZ(i) * f); + } +} /** Whether a mesh is plain furniture: one flat colour, nothing drawn on it, not glowing, not see-through. */ const plain = (m: Material | Material[]): m is MeshLambertMaterial => !Array.isArray(m) && m instanceof MeshLambertMaterial && !m.map && !m.transparent && m.opacity === 1 && !m.vertexColors && m.emissive.getHex() === 0; @@ -3095,7 +3140,7 @@ const plain = (m: Material | Material[]): m is MeshLambertMaterial => !Array.isA */ function bakeStill(root: Object3D, keep: Set) { root.updateMatrixWorld(true); - const toRoot = root.matrixWorld.clone().invert(), m4 = new Matrix4(); + const toRoot = root.matrixWorld.clone().invert(), m4 = new Matrix4(), lift = root.matrixWorld.elements[13] - CY; const bins = new Map(), baked: Mesh[] = []; const walk = (o: Object3D) => { for (const c of o.children) { @@ -3113,6 +3158,7 @@ function bakeStill(root: Object3D, keep: Set) { } } paint(g, m.material.color.getHex()); + occlude(g, lift); const key = `${+m.castShadow}${+m.receiveShadow}${m.material.side}`; (bins.get(key) ?? bins.set(key, []).get(key)!).push(g); baked.push(m); @@ -3211,9 +3257,10 @@ export function buildCellar() { const theirs = [cellar, ...Object.values(parts)].flatMap(g => g.children); apart(() => { for (const f of toBuild) f(); - // (the gong's disc swings, the pot's stew changes colour, Mr. Fugu puffs up, the soup square's flap lifts; and the - // venue's parts, the grow room and the Undertow come and go, so each is baked on its own) - bakeStill(cellar, new Set([gongDisc, potMesh, boss, tankFish, barFrame, ...Object.values(parts), ...theirs, ...moving])); + // (the gong's disc swings, the pot's stew changes colour, Mr. Fugu puffs up, the soup square's flap lifts, the bowl + // counter goes once the bowl line's in; and the venue's parts, the grow room and the Undertow come and go, so each + // is baked on its own) + bakeStill(cellar, new Set([gongDisc, potMesh, boss, tankFish, barFrame, bowlTable.g!, ...Object.values(parts), ...theirs, ...moving])); for (const g of [...Object.values(parts), barFrame]) bakeStill(g, new Set(theirs)); // (and the Undertow's lamps and glows, each kind merged into one: there are dozens of them) bakeAlike(parts.den, new Set(theirs)); diff --git a/src/cellarHands.ts b/src/cellarHands.ts index 03e8bc3..291c85e 100644 --- a/src/cellarHands.ts +++ b/src/cellarHands.ts @@ -1,12 +1,12 @@ // The player's work down in the cellar (stage 3), by standing in the right places, as upstairs: at the dumbwaiter's // bins, rice or fish into their arms; at a drying rack or a salt barrel, raw ones in and ready ones out; at a mushroom // bed, mushrooms; at the pot, ready ones onto the ledge, and the pot cooks full batches while they stand there (a -// cook's are 70%); at the counter, bowls; inside the soup square, bowls out for the fans. What they carry is theirs: the cellar +// cook's too); at the counter, bowls; inside the soup square, bowls out for the fans. What they carry is theirs: the cellar // (fight.ts) only sees what they hand in or take out. And what each place says while they stand at it. import { BAR_PAD, BIN_PADS, BINS, COUNTER_PAD, type Ing, machineAt, machinePad, type Pad, POT_PAD, WATCH } from './cellarPlan'; import { CY } from './cellar'; import { - batchBowls, bowlPrice, cookBowls, COUNTER_CAP, PER_BATCH, POT_CAP, F, fillMachine, ICON, inPot, lineRoom, mach, playerAtPot, STALL_CAP, takeBowl, takeReady, + batchBowls, bowlPrice, COUNTER_CAP, PER_BATCH, POT_CAP, F, fillMachine, ICON, inPot, lineRoom, mach, playerAtPot, STALL_CAP, takeBowl, takeReady, toLedge, toStall, doneCount, } from './fight'; import { fly } from './holder'; @@ -101,8 +101,8 @@ export function cellarWork(p: XZ, dt: number) { } playerAtPot(dt); } - // the counter: bowls - if (at(p, COUNTER_PAD)) { + // the counter: bowls (till the bowl line's in, in its place) + if (!F.slide && at(p, COUNTER_PAD)) { while (player.tPick <= 0 && back.hasRoom() && F.counter.length) { player.tPick += STEP; const m = newBowl(takeBowl()!); m.position.copy(counterAt()); @@ -158,11 +158,10 @@ export function cellarTip(p: XZ): TipContent | null { } if (at(p, POT_PAD)) { const ledge = inPot().map(k => `${ICON[k]} ${F.pantry[k]}/${PER_BATCH}`).join(' ยท '); - // (a cook gets fewer bowls out of a batch than the player does: they stand back while the player cooks) - const cook = F.hires.cook ? ` ยท ๐Ÿ‘จโ€๐Ÿณ ${cookBowls()}` : '', show = F.bonus.t > 0 ? ` ยท ๐Ÿ”ฅ +${F.bonus.pct}%` : ''; - return { name: 'The pot ยท stand here to cook', desc: `${ledge} โ†’ ๐Ÿฒ ${batchBowls()}${cook}${show} ยท ${F.pot.length}/${POT_CAP} in the pot` }; + const show = F.bonus.t > 0 ? ` ยท ๐Ÿ”ฅ +${F.bonus.pct}%` : ''; + return { name: 'The pot ยท stand here to cook', desc: `${ledge} โ†’ ๐Ÿฒ ${batchBowls()}${show} ยท ${F.pot.length}/${POT_CAP} in the pot` }; } - if (at(p, COUNTER_PAD)) return { name: 'Bowls ๐Ÿฒ', desc: `${F.counter.length}/${COUNTER_CAP} โ†’ the soup bar` }; + if (!F.slide && at(p, COUNTER_PAD)) return { name: 'Bowls ๐Ÿฒ', desc: `${F.counter.length}/${COUNTER_CAP} โ†’ the soup bar` }; if (at(p, BAR_PAD)) { return { name: 'The soup bar', desc: `${F.stall.length}/${STALL_CAP} out ยท ${F.queue.length} waiting ยท a bowl pays ๐Ÿก ${chips(bowlPrice(F.beaten >= 1))}` }; } diff --git a/src/cellarPlan.ts b/src/cellarPlan.ts index a455953..9f0e3e3 100644 --- a/src/cellarPlan.ts +++ b/src/cellarPlan.ts @@ -1,7 +1,7 @@ // Where everything is down in the cellar (stage 3), as plain floor positions (x, z; the floor is CELLAR_Y): the stations // of the kitchen's lines, the pot and its ledge, the bowl counter, the soup square, the gong, and the way between rooms. // Kept apart from the cellar's meshes (cellar.ts) so the fight night's simulation (fight.ts) can use it without them. -import { ARENA, CARDS, type CellarRoom, CRAFT, DEN, DOORS, GROW, KITCHEN, LIFT_ROOM, LOANS, OFFICE_ROOM, PASSAGE, PREP, ROOMS, VIP } from './layout'; +import { ARENA, CARDS, type CellarRoom, CRAFT, DEN, DOORS, GROW, KITCHEN, LIFT_ROOM, LOANS, LOO, OFFICE_ROOM, PREP, ROOMS, VIP, WING } from './layout'; import type { XZ } from './util'; export type Ing = 'rice' | 'fish' | 'mush'; @@ -49,7 +49,7 @@ export const POT = { x: HEARTH.x, z: HEARTH.z }; export const LEDGE: Record = { rice: { x: HEARTH.x - 0.7, z: HEARTH.z + 1.0 }, fish: { x: HEARTH.x, z: HEARTH.z + 1.0 }, mush: { x: HEARTH.x + 0.7, z: HEARTH.z + 1.0 }, }; -/** Where ingredients are dropped off, and where standing keeps the pot cooking (full batches: a cook's are 70%). */ +/** Where ingredients are dropped off, and where standing keeps the pot cooking (full batches, as a cook's are). */ export const POT_PAD = pad(HEARTH.x, HEARTH.z + 1.95, 0.9); export const COOK_AT = { x: HEARTH.x + 1.45, z: HEARTH.z + 1.6 }; /** The counter the bowls come out on: a low table against the east wall, in the corner south of the prep hall's door, @@ -263,10 +263,37 @@ export const DEN_TABLES = { * barrels, its back to the front wall, turned to watch the room. */ export const COAT_SPOT = { x: DEN.x1 - 1.45, z: DEN.z1 - 0.55, face: Math.PI + 0.6 }; export const BARRELS = { x: DEN.x1 - 0.6, z: DEN.z1 - 0.8 }; -/** The club's doors: a pair across the short way through the rock from near the far end of the tunnel to the club's - * east wall (`way`), hung at the tunnel's side (`x`), swinging in towards the club. */ -export const CLUB_DOOR = { x: PASSAGE.x0 - 0.15, z0: PASSAGE.z1 - 1.8, z1: PASSAGE.z1 - 0.2 }; -export const CLUB_WAY = { x0: DEN.x1, x1: PASSAGE.x0, z0: CLUB_DOOR.z0, z1: CLUB_DOOR.z1 }; +/** The way into the club: the out-of-order stall's back (`x`, `z0` to `z1`), the doorman's door (southDoor.ts), through + * the restroom's west wall and the club's east one; and the short way through the rock between them (`CLUB_WAY`). */ +export const CLUB_DOOR = (() => { const d = DOORS.find(t => t.b === 'den')!; return { x: (d.x0 + d.x1) / 2, z0: d.z0, z1: d.z1 }; })(); +export const CLUB_WAY = { x0: DEN.x1, x1: LOO.x0, z0: CLUB_DOOR.z0, z1: CLUB_DOOR.z1 }; + +// ---------- Mama Moray's, out front (resto.ts) ---------- +/** Her counter, in the front hall, the belt looped round its top: Mama behind it at her till (`MAMA`, `TILL`), stools + * along its front (`STOOLS`), facing it. The belt comes up under the floor from the hearth (resto.ts, underBelt.ts) and + * through a slot in the counter's top at its east end (`RISE`), inside the loop. */ +export const MAMA_COUNTER = { x0: -0.3, x1: 3.2, z0: 8.7, z1: 9.75, h: 1.0 }; +export const MAMA = { x: 0.9, z: 8.3 }; +export const TILL = { x: 1.75, z: (MAMA_COUNTER.z0 + MAMA_COUNTER.z1) / 2 }; +export const RISE = { x: 2.75, z: (MAMA_COUNTER.z0 + MAMA_COUNTER.z1) / 2 }; +export const STOOLS: XZ[] = [0.15, 0.85, 1.55, 2.25, 2.95].map(x => ({ x, z: MAMA_COUNTER.z1 + 0.42 })); +/** The tables, each with its nabe on a burner and a stool either side (east and west, `TABLE_SEAT` out): one in the + * hall, two rows of four down the dining wing, staggered (each a metre clear of the doorways). */ +export const TABLES: XZ[] = [ + { x: -2.1, z: 10.6 }, + // (none in front of the doorway through to the cookware shop) + ...[4.5, 8.0, 9.6].map(x => ({ x, z: WING.z0 + 1.0 })), + ...[3.0, 5.0, 7.0, 9.0].map(x => ({ x, z: WING.z1 - 1.0 })), +]; +export const TABLE_SEAT = 0.62; +/** The restroom's two stalls along its west wall, their fronts at `x`; the second's out of order, its back the doorman's + * door (`CLUB_DOOR`), and its own door the gap `door` (z0 to z1) in its front. */ +export const STALLS = { x: LOO.x0 + 1.35, a: { z0: LOO.z0 - 0.15, z1: CLUB_DOOR.z0 - 0.13 }, b: { z0: CLUB_DOOR.z0 - 0.1, z1: CLUB_DOOR.z1 + 0.35 } }; +export const STALL_DOOR = { z0: (CLUB_DOOR.z0 + CLUB_DOOR.z1) / 2 - 0.4, z1: (CLUB_DOOR.z0 + CLUB_DOOR.z1) / 2 + 0.4 }; +/** The out-of-order stall's floor, from the restroom's west wall out to its front. */ +export const STALL = { x0: LOO.x0, x1: STALLS.x, z0: STALLS.b.z0, z1: STALLS.b.z1 }; +/** The basins along the restroom's north wall, east of the stalls. */ +export const BASINS: XZ[] = [{ x: -0.95, z: LOO.z0 + 0.25 }, { x: -0.15, z: LOO.z0 + 0.25 }]; // ---------- the landing and the office ---------- /** Stage 3's upgrade square: the shop counter's. */ @@ -278,6 +305,11 @@ export const HOME = { washer: { x: PREP.x0 + 0.9, z: PREP.z0 + 2.3 }, salter: { x: PREP.x0 + 0.9, z: PREP.z1 - 2.3 }, picker: { x: (GROW.x0 + GROW.x1) / 2 + 1.6, z: GROW.z1 - 0.9 }, cook: COOK_AT, attendant: { x: COUNTER_PAD.x - 0.5, z: KITCHEN.z1 - 0.35 }, } as const; +/** Where the attendant works the bowl line's motor once it's in, in the bowl counter's place (bowlLine.ts): at its + * west side, by its flywheel. */ +export const MOTOR_AT = { x: COUNTER.x - 0.8, z: COUNTER.z - 0.1 }; +/** Where a hire goes when there's nothing to do. */ +export const homeOf = (role: keyof typeof HOME, slide: boolean) => (role === 'attendant' && slide ? MOTOR_AT : HOME[role]); // ---------- getting about ---------- type Rect = typeof ARENA; diff --git a/src/chanko.ts b/src/chanko.ts index 3eb9d5d..a690a6b 100644 --- a/src/chanko.ts +++ b/src/chanko.ts @@ -9,7 +9,7 @@ // let go in the band), each turning the stew its colour from where it goes in; and the stew is // stirred round in the boil band (press the soup to take the ladle, then it follows the mouse or finger), whirling with // the ladle, and what went in comes back up. Each step gives up to 3 stars; the 12 buy the pot a bonus for a while -// (fight.ts' showResult): bigger batches, and the cook's 70% of them. It's a bonus game and nothing more: it takes +// (fight.ts' showResult): bigger batches, the cook's too. It's a bonus game and nothing more: it takes // nothing off the ledge and cooks no batch, and the pot goes on cooking from the ledge as it always does. Left half way // (โœ•), it's kept as it was (F.show, saved with the game): Cook carries it on at the same step, with the stars already // won, so a bad start can't be thrown away and played again. Press Cook for a new one once the bonus has run out. A crew @@ -21,7 +21,7 @@ import { } from 'three'; import { cellar, CY, lights, POT_TOP, potMesh } from './cellar'; import { POT, POT_PAD } from './cellarPlan'; -import { batchBowls, BONUS_S, BONUS_TIERS, canShow, cookBowls, F, growOpen, showResult, type ShowRun } from './fight'; +import { batchBowls, BONUS_S, BONUS_TIERS, canShow, F, growOpen, showResult, type ShowRun } from './fight'; import { chipsFx, pourFx, splashFx, steamFx, updFx } from './fx'; import { keyHeld } from './input'; import { newMushroom } from './items'; @@ -988,7 +988,7 @@ function finish() { ui.card.querySelector('.stars')!.textContent = `โ˜… ${total} / 12`; ui.card.querySelector('.tier')!.textContent = tier.name; ui.card.querySelector('.bonus')!.textContent = tier.pct ? `+${tier.pct}% bowls ยท ${BONUS_S} s` : 'No bonus this time'; - ui.card.querySelector('.sub')!.textContent = tier.pct ? `๐Ÿฒ ${batchBowls()} a batch${F.hires.cook ? ` ยท ๐Ÿ‘จโ€๐Ÿณ ${cookBowls()}` : ''}` : 'Cook again any time'; + ui.card.querySelector('.sub')!.textContent = tier.pct ? `๐Ÿฒ ${batchBowls()} a batch` : 'Cook again any time'; const hanko = ui.card.querySelector('.hanko')!; hanko.textContent = tier.pct >= 50 ? 'ๆฅต' : tier.pct >= 35 ? 'ๆ—จ' : tier.pct ? '่‰ฏ' : '่–„'; hanko.classList.remove('show'); void (hanko as HTMLElement).offsetWidth; hanko.classList.add('show'); diff --git a/src/clubFog.ts b/src/clubFog.ts index f73915f..85ecfb3 100644 --- a/src/clubFog.ts +++ b/src/clubFog.ts @@ -1,10 +1,10 @@ -// The fog over the Undertow's club (stage 3), until its doors first open: from the tunnel and the arena, the room +// The fog over the Undertow's club (stage 3), until its door first opens: from the restroom and the arena, the room // behind them is a bank of dark, slowly drifting smoke, the band heard through it but nothing seen. When the doorman // lets you in, it rolls back from the doors across the room and is gone for good (a game loaded after that never sees // it). It's a stack of flat sheets one over another, up to the back wall's height, each a cloud of its own with soft, // ragged edges out over the rock round the room, so from the camera's slant it reads as a bank of smoke; on the -// tunnel's side it stops short at the doors, so they and the tunnel stay in plain sight. (The camera looks down from -// the south-east, so a sheet hides what's under it and behind it, never the tunnel in front of it.) +// restroom's side it stops short at the door, so it and the restroom stay in plain sight. (The camera looks down from +// the south-east, so a sheet hides what's under it and behind it, never the restroom in front of it.) import { Color, DataTexture, Group, LinearFilter, Mesh, PlaneGeometry, RepeatWrapping, RGBAFormat, ShaderMaterial, Vector2, Vector4 } from 'three'; import { CY, parts } from './cellar'; import { CLUB_DOOR } from './cellarPlan'; diff --git a/src/contact.ts b/src/contact.ts new file mode 100644 index 0000000..e3b0686 --- /dev/null +++ b/src/contact.ts @@ -0,0 +1,39 @@ +// Contact shadows: a soft dark smudge on the floor under something lying or standing on it, so it sits on the floor +// rather than floating over it (the cheapest ambient occlusion there is: a few see-through quads, merged into one mesh, +// drawn once). For loose things the cellar's baked furniture shading (cellar.ts, occlude) doesn't reach. +import { Mesh, MeshBasicMaterial, PlaneGeometry } from 'three'; +import { mergeGeometries } from 'three/addons/utils/BufferGeometryUtils.js'; +import { canvasTex } from './render'; + +/** One smudge: its middle on the floor, its half-sizes across (`rx`, and `rz`, or round), turned `ry`. */ +export interface Smudge { x: number; z: number; rx: number; rz?: number; ry?: number } + +let mat: MeshBasicMaterial | null = null; +/** The smudge's look: black, fading from the middle out (made once). */ +function smudgeMat() { + return mat ??= new MeshBasicMaterial({ + map: canvasTex(64, 64, (c, w, h) => { + const gr = c.createRadialGradient(w / 2, h / 2, 0, w / 2, h / 2, w / 2); + gr.addColorStop(0, 'rgba(0,0,0,1)'); gr.addColorStop(0.55, 'rgba(0,0,0,0.65)'); gr.addColorStop(1, 'rgba(0,0,0,0)'); + c.fillStyle = gr; c.fillRect(0, 0, w, h); + }).tex, + transparent: true, depthWrite: false, + }); +} + +/** How far past what's on it a smudge reaches (its sizes are what's on it). */ +const REACH = 1.4; +/** The smudges under `spots`, on the floor at `y`, as one mesh, `opacity` dark at their darkest. */ +export function contactShadows(spots: Smudge[], y: number, opacity = 0.8) { + const geos = spots.map(s => { + const g = new PlaneGeometry(2 * REACH * s.rx, 2 * REACH * (s.rz ?? s.rx)); + g.rotateX(-Math.PI / 2); g.rotateY(s.ry ?? 0); g.translate(s.x, y, s.z); + return g; + }); + const m = smudgeMat().clone(); + m.opacity = opacity; + const o = new Mesh(mergeGeometries(geos)!, m); + o.renderOrder = -1; + geos.forEach(g => g.dispose()); + return o; +} diff --git a/src/doorway.ts b/src/doorway.ts index bd1ac6c..ef616ca 100644 --- a/src/doorway.ts +++ b/src/doorway.ts @@ -1,53 +1,30 @@ -// The way into the Undertow (stage 3): a long dark tunnel and a pair of heavy doors at the end of it. Walking down the -// tunnel, the view closes in and darkens round the edges, its caged bulbs flicker, water drips from the beams and a pipe -// leaks steam, and the club's band comes through the doors, muffled, louder as you go. The first time they open (the -// doorman's let you in already, at the door at the tunnel's mouth: southDoor.ts), they swing open on a burst of light -// and smoke, and the music comes up full. They open as you come up to them, from either side, and shut behind you. The -// doorman's slot, his eyes behind it and his speech bubble are made here, with the rest of the way in, and hung on his -// door. -import { BoxGeometry, CylinderGeometry, Group, Mesh, MeshBasicMaterial, MeshLambertMaterial, PlaneGeometry, Vector3 } from 'three'; -import { noise, tone } from './audio'; -import { CY, denDoor, parts } from './cellar'; -import { CLUB_DOOR, CLUB_WAY } from './cellarPlan'; -import { liftFog, updFog } from './clubFog'; -import { F } from './fight'; +// The way into the Undertow (stage 3): Mama Moray's restroom, out front, and the out-of-order stall in it, whose back +// is the doorman's door (southDoor.ts). In the restroom the view comes in closer, and stepping into the stall the dark +// closes in round the edges of the screen, as it did down the old tunnel; the club's band comes through the stall's +// back, muffled, louder the nearer you get, and full once it swings open. In the club the view comes in closer still. +// The doorman's slot, his eyes behind it and his speech bubble are made here and hung on his door. +import { BoxGeometry, Group, Mesh, MeshBasicMaterial } from 'three'; +import { parts } from './cellar'; +import { CLUB_DOOR, CLUB_WAY, STALL } from './cellarPlan'; +import { updFog } from './clubFog'; +import { CARDS, DEN, found, level, LOANS, LOO, VIP } from './layout'; import { club } from './noir'; -import { dustFx, steamer } from './fx'; -import { CARDS, DEN, found, level, LOANS, PASSAGE, VIP } from './layout'; import { player } from './player'; import { G, mesh } from './render'; import { bubble } from './speech'; import { staging, view } from './stage'; -import { d2xz, V } from './util'; +import type { Vector3 } from 'three'; -const D = CLUB_DOOR, MID = { x: D.x, z: (D.z0 + D.z1) / 2 }, LEAF = (D.z1 - D.z0) / 2, H = 2.1; +/** The doorman's door's middle, on the restroom's west wall. */ +const MID = { x: CLUB_DOOR.x, z: (CLUB_DOOR.z0 + CLUB_DOOR.z1) / 2 }; -// ---------- the doors ---------- -const WOOD = new MeshLambertMaterial({ color: 0x3A2418 }), IRON = new MeshLambertMaterial({ color: 0x23282C }); -/** Each leaf, hung at its outer edge, swinging in towards the club: its pivot, turned to open it. */ -const leaves = [-1, 1].map(s => { - const pivot = new Group(); pivot.position.set(D.x, CY, s < 0 ? D.z0 : D.z1); - const at = -s * LEAF / 2; - const slab = new Mesh(new BoxGeometry(0.09, H, LEAF - 0.02), WOOD); slab.position.set(0, H / 2, at); pivot.add(slab); - for (const y of [0.35, 1.05, 1.75]) { const band = new Mesh(new BoxGeometry(0.1, 0.07, LEAF - 0.04), IRON); band.position.set(0, y, at); pivot.add(band); } - for (const y of [0.35, 1.05, 1.75]) for (const z of [-0.25, 0, 0.25]) { const stud = mesh(G.sphere, 0x6A6F74, 0.05, y, at + z); stud.scale.setScalar(0.022); pivot.add(stud); } - const ring = mesh(new CylinderGeometry(0.06, 0.06, 0.015, 14), 0x8A7A4A, 0.06, 1.0, -s * (LEAF - 0.12)); ring.rotation.z = Math.PI / 2; pivot.add(ring); - parts.den.add(pivot); - return { pivot, s }; -}); -// the frame round them: iron posts and a lintel, a caged lamp over it, and a pink glow under the doors from inside -for (const z of [D.z0 - 0.04, D.z1 + 0.04]) { const post = new Mesh(new BoxGeometry(0.12, H + 0.1, 0.08), IRON); post.position.set(D.x, CY + (H + 0.1) / 2, z); parts.den.add(post); } -{ - const lintel = new Mesh(new BoxGeometry(0.14, 0.1, D.z1 - D.z0 + 0.16), IRON); lintel.position.set(D.x, CY + H + 0.05, MID.z); parts.den.add(lintel); - const under = new Mesh(new PlaneGeometry(0.06, D.z1 - D.z0 - 0.1), new MeshBasicMaterial({ color: 0xFF5FA8, transparent: true, opacity: 0.85 })); - under.position.set(D.x + 0.06, CY + 0.012, MID.z); under.rotation.x = -Math.PI / 2; parts.den.add(under); -} /** The doorman's slot, at his eye height: its cover, which slides aside (along its length, `z`), and the eyes behind * it, looking out along `x` (southDoor.ts hangs it on his door). */ const slot = new Group(); const slotBack = new Mesh(new BoxGeometry(0.015, 0.13, 0.4), new MeshBasicMaterial({ color: 0x050405 })); slot.add(slotBack); -const cover = new Mesh(new BoxGeometry(0.02, 0.16, 0.42), IRON); cover.position.x = 0.012; slot.add(cover); +const cover = new Mesh(new BoxGeometry(0.02, 0.16, 0.42), new MeshBasicMaterial({ color: 0x23282C })); +cover.position.x = 0.012; slot.add(cover); const eyes = new Group(); eyes.position.x = 0.01; slot.add(eyes); for (const s of [-1, 1]) { const e = mesh(G.sphere, new MeshBasicMaterial({ color: 0xF2D27A }), 0, 0, s * 0.085); e.scale.set(0.012, 0.03, 0.042); eyes.add(e); @@ -63,118 +40,53 @@ for (let i = 0; i < 16; i++) Math.random(); /** The doorman's things, for his door (southDoor.ts). */ export const doorman = { slot, cover, eyes, says }; -// ---------- the tunnel ---------- -/** Its caged bulbs, hung from the beams: each flickers now and then. */ -const bulbs = [PASSAGE.z0 + 1.8, (PASSAGE.z0 + PASSAGE.z1) / 2 + 0.2, D.z0 - 0.6].map((z, i) => { - const x = (PASSAGE.x0 + PASSAGE.x1) / 2, g = new Group(); g.position.set(x, CY + 2.05, z); - const cord = mesh(G.rope, 0x1B1A1E, 0, 0.25, 0); cord.scale.y = 0.5; g.add(cord); - const m = new MeshLambertMaterial({ color: 0xFFE2A8, emissive: 0xFFA43A, emissiveIntensity: 1 }); - const bulb = mesh(G.sphere, m, 0, 0, 0); bulb.scale.setScalar(0.07); g.add(bulb); - for (let k = 0; k < 4; k++) { const wire = mesh(G.box, 0x23282C, Math.cos(k * Math.PI / 2) * 0.08, 0, Math.sin(k * Math.PI / 2) * 0.08); wire.scale.set(0.01, 0.18, 0.01); g.add(wire); } - parts.den.add(g); - return { m, seed: i * 7.3 }; -}); -/** Where water drips from the beams, and each drop's place in its fall. */ -const drips = [PASSAGE.z0 + 1.2, PASSAGE.z0 + 3.4, D.z0 - 1.6].map((z, i) => { - const drop = mesh(G.sphere, new MeshLambertMaterial({ color: 0x9EC8D8, transparent: true, opacity: 0.8 }), PASSAGE.x0 + 0.35 + i * 0.25, CY + 2, z); - drop.scale.set(0.018, 0.03, 0.018); - parts.den.add(drop); - return { drop, t: i * 0.9, every: 2.1 + i * 0.6 }; -}); -/** The pipe that leaks steam, at one of its joints. */ -const leak = new Vector3(PASSAGE.x1 - 0.16, CY + 1.42, PASSAGE.z0 + 3.5); -steamer(leak, () => level.down && found.den); - -// ---------- the screen: the dark closing in, and the light when the doors open ---------- -const $ = (id: string) => document.getElementById(id)!; -const shade = $('tunnelShade'), flash = $('doorFlash'); +// ---------- the screen: the dark closing in, in the stall ---------- +const shade = document.getElementById('tunnelShade')!; -// ---------- the doors' state ---------- -/** How far open the doors are, 0 to 1, and where they're going. */ -let open = 0, want = 0; -/** Whether the tunnel or the club has the camera in closer. */ +/** Whether the restroom or the club has the camera in closer. */ let closer = false; -/** How close the camera comes in (of the cellar's own view): down the tunnel, and in the club and its side rooms. */ -export const CLOSE = { tunnel: 0.78, club: 0.55 }; -/** Whether `p` is in the club, its side rooms or the way in from its doors. */ -const inClub = (p: Vector3) => [DEN, LOANS, CARDS, VIP, CLUB_WAY].some(r => p.x > r.x0 - 0.3 && p.x < r.x1 + 0.3 && p.z > r.z0 - 0.3 && p.z < r.z1 + 0.3); - -/** How far down the tunnel the player is, 0 at its mouth to 1 at the doors; -1 out of it. */ -function along(p: Vector3) { - if (p.x < PASSAGE.x0 - 0.2 || p.x > PASSAGE.x1 + 0.2 || p.z < PASSAGE.z0 - 0.4 || p.z > PASSAGE.z1 + 0.2) return -1; - return Math.max(0, Math.min(1, (p.z - PASSAGE.z0) / (MID.z - PASSAGE.z0))); -} -/** How far the doors are open, and whether they've ever opened: for tests. */ -export const doorState = () => ({ open, opened: F.den.door }); - -const sfx = { - bolt: () => { noise({ f: 900, q: 3, a: 0.008, d: 0.08, v: 0.08, at: MID }); tone({ f: 160, f2: 120, a: 0.008, d: 0.1, v: 0.08, at: MID }); }, - creak: () => tone({ f: 130, f2: 92, type: 'triangle', lp: 700, a: 0.12, d: 0.6, v: 0.05, at: MID }), - drip: (at: { x: number; z: number }) => tone({ f: 720, f2: 950, a: 0.01, d: 0.09, v: 0.02, at }), -}; - -/** The doors swing open: their light and smoke spill out into the tunnel (a big burst the first time). */ -function burst(big: boolean) { - want = 1; - sfx.bolt(); sfx.creak(); - const reduce = window.matchMedia?.('(prefers-reduced-motion: reduce)').matches; - if (!reduce) { flash.style.transition = 'none'; flash.style.opacity = big ? '0.75' : '0.3'; void flash.offsetWidth; flash.style.transition = `opacity ${big ? 1.1 : 0.6}s ease-out`; flash.style.opacity = '0'; } - dustFx(V(D.x + 0.4, CY + 0.9, MID.z), big ? 14 : 5, big ? 1.6 : 1, [0x8A7A90, 0xC8A0B8]); - if (big) liftFog(); -} +/** How close the camera comes in (of the cellar's own view): in the restroom, and in the club and its side rooms. */ +export const CLOSE = { loo: 0.78, club: 0.55 }; +const inside = (p: { x: number; z: number }, r: { x0: number; x1: number; z0: number; z1: number }, m = 0.3) => p.x > r.x0 - m && p.x < r.x1 + m && p.z > r.z0 - m && p.z < r.z1 + m; +/** Whether `p` is in the club, its side rooms or the way in from the stall. */ +const inClub = (p: Vector3) => [DEN, LOANS, CARDS, VIP, CLUB_WAY].some(r => inside(p, r)); +/** The out-of-order stall (southDoor.ts sets it, resto.ts dresses it): how open its back, the doorman's door, is, 0 to + * 1; whether its caution tape's been torn away (in the scene at it: after that, `F.den.way`); and seconds left of its + * bulb's flicker. */ +export const stallDoor = { open: 0, torn: false, flicker: 0 }; -/** The way in, every frame (stage 3, once the Undertow's found): the tunnel's lights, drips and dark, and the doors. */ +/** The way in, every frame (stage 3): the dark in the stall, the camera in the restroom and the club, and the band. */ export function updDoorway(dt: number) { - const p = player.g.position, down = level.down && found.den; + const p = player.g.position, down = level.down; updFog(dt); - const k = down ? along(p) : -1; - // the dark closing in down the tunnel, and the view coming in closer, closer still in the club (not while an upgrade - // has the camera) - shade.style.opacity = String(Math.max(0, k) * 0.7 * (1 - open * 0.6)); - const close = k >= 0 ? CLOSE.tunnel : down && inClub(p) ? CLOSE.club : 0; + // the dark closing in in the out-of-order stall, once there's something behind it (not while it's open on the club) + const inStall = down && found.den && inside(p, STALL, 0); + shade.style.opacity = String(inStall ? 0.55 * (1 - stallDoor.open * 0.6) : 0); + // the view coming in closer in the restroom, closer still in the club (not while an upgrade has the camera) + const close = down && inside(p, LOO, 0) ? CLOSE.loo : down && found.den && inClub(p) ? CLOSE.club : 0; // (only ever pulling it back out from where it pulled it in: an upgrade's spotlight has it otherwise) if (close && !staging()) { closer = true; view.close += (close - view.close) * Math.min(1, dt * 2.5); } else if (closer) { view.close += (1 - view.close) * Math.min(1, dt * 2.5); if (view.close > 0.995) { view.close = 1; closer = false; } } - // the player's stride shortened as the view comes in (eased alongside it), so the club's as big on foot as it looks + // the player's stride shortened as the view comes in (eased alongside it), so a room's as big on foot as it looks const stride = close || 1; player.stride += (stride - player.stride) * Math.min(1, dt * 2.5); if (Math.abs(player.stride - stride) < 0.005) player.stride = stride; const heard = clubHeard(p); club.k = heard.k; club.muffle = heard.muffle; club.vol = heard.vol; - if (!down) { want = 0; open = 0; denDoor.shut = true; return; } - const t = performance.now() / 1000; - for (const b of bulbs) { - const f = Math.sin(t * 13 + b.seed) * Math.sin(t * 3.1 + b.seed * 2); - b.m.emissiveIntensity = f > 0.93 ? 0.15 : 0.85 + 0.15 * Math.sin(t * 40 + b.seed); - } - for (const d of drips) { - d.t += dt; - const fall = (d.t % d.every) / 0.55; - d.drop.visible = fall < 1; - d.drop.position.y = CY + 2 - 2 * Math.min(1, fall) ** 2; - if (fall >= 1 && (d.t - dt) % d.every / 0.55 < 1 && k >= 0) sfx.drip(d.drop.position); - } - // the doors open as you come up to them, from either side, and shut behind you; the first time, on a burst of the - // club's light and smoke - const near = (r: number) => d2xz(p, MID) < r * r; - if (near(1.7) && want === 0) { burst(!F.den.door); F.den.door = true; } - if (!near(2.4)) { if (want === 1) sfx.creak(); want = 0; } - open += (want - open) * Math.min(1, dt * (want ? 4 : 2.5)); - denDoor.shut = open < 0.6; - for (const l of leaves) l.pivot.rotation.y = l.s * open * Math.PI * 0.55; } /** How far the club's band is heard, 0 to 1 (the stage's music fading out under it), how muffled (0: clear, 1: through - * the shut doors), and how loud besides: full in the club; in Harry's office, Tommy's card room and Eddie's room, through + * the shut door), and how loud besides: full in the club; in Harry's office, Tommy's card room and Eddie's room, through * their doorways, a little quieter and duller (and the stage's music still all gone, so it never comes back in by a side - * room's wall); through the doors down the tunnel, louder the nearer you get (music.ts). */ + * room's wall); through the stall's back in Mama Moray's restroom, louder the nearer you get (music.ts). */ export function clubHeard(p: { x: number; z: number }) { if (!level.down || !found.den) return { k: 0, muffle: 1, vol: 1 }; - // (the club reaches right up to where the tunnel starts, at the doors: no step between them where neither hears it) - if (p.x > DEN.x0 - 0.5 && p.x < PASSAGE.x0 - 0.2 && p.z > DEN.z0 - 0.5 && p.z < DEN.z1 + 0.5) return { k: 1, muffle: 0, vol: 1 }; + // (the club reaches right up to the stall's back: no step between them where neither hears it) + if (p.x > DEN.x0 - 0.5 && p.x < LOO.x0 && p.z > DEN.z0 - 0.5 && p.z < DEN.z1 + 0.5) return { k: 1, muffle: 0, vol: 1 }; if (p.x > LOANS.x0 - 0.5 && p.x <= DEN.x0 - 0.5 && p.z > LOANS.z0 - 0.5 && p.z < CARDS.z1 + 0.5) return { k: 1, muffle: 0.45, vol: 0.8 }; if (p.x > VIP.x0 - 0.5 && p.x < VIP.x1 + 0.5 && p.z >= DEN.z1 + 0.5 && p.z < VIP.z1 + 0.5) return { k: 1, muffle: 0.45, vol: 0.8 }; - const a = along(V(p.x, 0, p.z)); - if (a < 0) return { k: 0, muffle: 1, vol: 1 }; - return { k: 0.25 + 0.65 * a * a, muffle: 1 - open, vol: 1 }; + if (!inside(p, LOO, 0.3)) return { k: 0, muffle: 1, vol: 1 }; + // (in the restroom: from its far side to right at the stall's back) + const a = Math.max(0, Math.min(1, 1 - (Math.hypot(p.x - MID.x, p.z - MID.z) - 0.6) / 3.4)); + return { k: 0.25 + 0.65 * a * a, muffle: 1 - stallDoor.open, vol: 1 }; } diff --git a/src/family.ts b/src/family.ts index 6b684f3..d5e5c6e 100644 --- a/src/family.ts +++ b/src/family.ts @@ -239,6 +239,63 @@ function tommy() { return g; } +/** Mama Moray, who runs the chanko restaurant out front (resto.ts): a round, motherly moray, Eddie's kind but softer, + * in a flowered headscarf knotted on top, a string of pearls, and a white apron over a purple dress; a wide smile, + * rosy cheeks and big lashed eyes. Built when it's wanted (made on the dice it's made on there, never at load). */ +export function mama() { + const EEL = 0x7E9A3A, THROAT = 0xB4C46A, SPOT = 0xDCD47A, CHEEK = 0xE88A8A, APRON = 0xF6F2EA; + const g = new Hood({ coat: 0x7A3E8E, lapel: 0x5A2A6E, shirt: APRON, hand: EEL }); + // (a little rounder than the men: the coat's her dress, let out) + g.body.children[0].scale.set(1.3, 1, 1.22); + // the apron, its bib and its strings + const apron = put(g.body, mesh(G.sphere, APRON, 0, 0.5, 0.215)); apron.scale.set(0.24, 0.3, 0.06); + put(g.body, mesh(G.sphere, APRON, 0, 0.78, 0.235)).scale.set(0.13, 0.12, 0.03); + for (const s of [-1, 1]) { const strap = put(g.body, mesh(G.box, APRON, s * 0.1, 0.87, 0.2)); strap.scale.set(0.03, 0.12, 0.1); strap.rotation.x = -0.5; } + put(g.body, mesh(G.box, APRON, 0, 0.62, 0)).scale.set(0.6, 0.03, 0.5); + // the pearls, round her neck + const PEARL = new MeshLambertMaterial({ color: 0xF6EEE2, emissive: 0x2A2620 }); + for (let i = 0; i < 11; i++) { + const a = -1.2 + (2.4 * i) / 10; + put(g.body, mesh(G.sphere, PEARL, Math.sin(a) * 0.15, 0.9 - Math.cos(a) * 0.035, Math.cos(a) * 0.15 + 0.03)).scale.setScalar(0.022); + } + const h = put(g.head, new Group()); + put(h, mesh(G.cyl, EEL, 0, 0.15, 0, true)).scale.set(0.17, 0.32, 0.16); + put(h, mesh(G.cyl, THROAT, 0, 0.13, 0.05)).scale.set(0.13, 0.27, 0.12); + put(h, mesh(G.sphere, EEL, 0, 0.37, 0.07, true)).scale.set(0.19, 0.16, 0.28); + const jaw = put(h, mesh(G.sphere, THROAT, 0, 0.3, 0.1)); jaw.scale.set(0.15, 0.06, 0.24); + for (const [a, y] of [[0.5, 0.06], [-0.7, 0.13], [1.3, 0.21], [-1.4, 0.03]]) { + const sp = put(h, mesh(G.sphere, SPOT, Math.sin(a) * 0.17, y, Math.cos(a) * 0.16)); sp.scale.set(0.022, 0.022, 0.006); + sp.quaternion.setFromUnitVectors(new Vector3(0, 0, 1), new Vector3(Math.sin(a), 0, Math.cos(a)).normalize()); + } + // a wide smile (a frown's arc the other way up), and rosy cheeks + const smile = put(h, mesh(frownGeo, 0x3A1A1A, 0, 0.33, 0.33)); smile.scale.set(1.15, 0.55, 1); smile.rotation.set(-0.25, 0, Math.PI); + for (const s of [-1, 1]) put(h, mesh(G.sphere, CHEEK, s * 0.12, 0.35, 0.27)).scale.set(0.04, 0.025, 0.02); + const glint = new MeshBasicMaterial({ color: 0xFFFFFF }); + for (const s of [-1, 1]) { + const x = s * 0.085; + put(h, mesh(G.sphere, 0xF4E27A, x, 0.44, 0.28)).scale.setScalar(0.04); + put(h, mesh(G.sphere, 0x111111, x, 0.44, 0.316)).scale.set(0.016, 0.02, 0.006); + put(h, mesh(G.sphere, glint, x + 0.008, 0.447, 0.321)).scale.setScalar(0.005); + const lid = put(h, mesh(lidGeo, EEL, x, 0.44, 0.28)); lid.scale.setScalar(0.046); lid.rotation.x = -0.75; + // (three lashes off each lid's edge) + for (const k of [-1, 0, 1]) { const lash = put(h, mesh(G.box, 0x1A1A1A, x + s * 0.02 + k * 0.014, 0.478, 0.3)); lash.scale.set(0.004, 0.022, 0.004); lash.rotation.z = -s * 0.4 - k * 0.3; } + } + // the headscarf: a flowered cloth over the top of her head and down to her neck behind, knotted on top + const FLOWERS = cloth((c, w, hh) => { + c.fillStyle = '#D85A8A'; c.fillRect(0, 0, w, hh); + for (const [fx, fy, col] of [[14, 14, '#F6E27A'], [46, 22, '#FFFFFF'], [26, 46, '#F6E27A'], [54, 54, '#FFFFFF'], [6, 38, '#FFFFFF']] as const) { + c.fillStyle = col; + for (let k = 0; k < 5; k++) { const a = (k / 5) * Math.PI * 2; c.beginPath(); c.arc(fx + Math.cos(a) * 4, fy + Math.sin(a) * 4, 3, 0, Math.PI * 2); c.fill(); } + c.fillStyle = '#E8902A'; c.beginPath(); c.arc(fx, fy, 2, 0, Math.PI * 2); c.fill(); + } + }, 3, 2); + const scarf = put(h, mesh(lidGeo, FLOWERS, 0, 0.42, 0.02, true)); scarf.scale.set(0.205, 0.17, 0.27); scarf.rotation.x = -0.3; + put(h, mesh(G.sphere, FLOWERS, 0, 0.3, -0.12)).scale.set(0.17, 0.16, 0.12); + for (const s of [-1, 1]) { const knot = put(h, mesh(G.sphere, FLOWERS, s * 0.06, 0.6, -0.02)); knot.scale.set(0.08, 0.05, 0.05); knot.rotation.z = s * 0.6; } + put(h, mesh(G.sphere, FLOWERS, 0, 0.6, -0.02)).scale.setScalar(0.04); + return g; +} + /** The Family, each built once. */ export const family: Record = { eddie: eddie(), harry: harry(), tommy: tommy() }; diff --git a/src/fight.ts b/src/fight.ts index c290d87..1eb7cd5 100644 --- a/src/fight.ts +++ b/src/fight.ts @@ -16,8 +16,8 @@ // as one more worker doing what a good player would (the mockup's "You"), from where they stand with what they carry. // // Money is Fugu Chips ๐Ÿก down here: 1 chip is $1,000 of the same wallet. -import { boost } from './economy'; -import { BAR_PAD, BIN_PADS, cellarWay, COOK_AT, COUNTER_PAD, HOME, type Ing, LEDGE, machinePad, MAX_MACHINES, POT_PAD } from './cellarPlan'; +import { boost, mods } from './economy'; +import { BAR_PAD, BIN_PADS, cellarWay, COOK_AT, COUNTER_PAD, homeOf, type Ing, LEDGE, machinePad, MAX_MACHINES, POT_PAD } from './cellarPlan'; import type { Kind } from './creatures'; import type { XZ } from './util'; @@ -36,7 +36,9 @@ export interface MachineKind { name: string; cap: number; time: number } export const MACH: Record = { rice: [{ name: 'Drying rack', cap: 6, time: 8 }, { name: 'Steam baskets', cap: 10, time: 5 }], fish: [{ name: 'Salt barrel', cap: 6, time: 8 }, { name: 'Smokehouse', cap: 10, time: 5 }], - mush: [{ name: 'Mushroom bed', cap: 6, time: 8 }, { name: 'Glowing shelf', cap: 9, time: 4 }], + // (a bed grows one mushroom at a time, each `time` seconds, up to `cap` ready: no faster, the grow room would be the + // weakest line the moment it opens) + mush: [{ name: 'Mushroom bed', cap: 6, time: 1.5 }, { name: 'Glowing shelf', cap: 9, time: 0.75 }], }; export interface Boss { face: string; name: string; crowd: number; bell: number; prize: number; @@ -45,18 +47,19 @@ export interface Boss { /** What beating him opens, as his result card says it: "๐Ÿ”“ New: โ€ฆ". */ opens: string; } -/** A rush is won by carrying bowls to the stall (test/capacity.sim.test.ts measures it): your own hands turn about 100 - * of a crowd in a 30 s bell, the attendant about 180, and each level of Cellar crew walks him faster. So each boss's - * crowd sits on a step of that ladder, a little under what the step turns at best: Hakkaku (81 to turn) a stocked - * kitchen by hand, Yokobashiri (151) the attendant, Dobu (225) Cellar crew 4, Samehada (366, a longer bell) crew 6, - * and Fukure (541, a 60 s bell) crew 7 with the glowing shelves. Prizes are kept small, so a win's prize and the bowls his crowd buys - * don't pay for the next boss on the spot (the balance pass of 2026-10-18, for a stage of about 45 minutes). */ +/** A rush is won by carrying bowls to the stall, and since the kitchen holds the rush (the pot, the counter, the slide + * down to the soup bar and the ledges all full when the gong goes), a crowd is what a kitchen of that point in the + * stage turns in its bell: Hakkaku (160) a stocked kitchen by hand, Yokobashiri (440) the attendant and a slide, + * Dobu (880, a 45 s bell) Cellar crew and the cook, Samehada (920, 45 s too) the smokehouse, and Fukure (1250, 60 s) + * the glowing shelves. Prizes are kept small, so a win's prize and the bowls his crowd buys don't pay for the next boss + * on the spot (tuned 2026-10-19 with the stage 3 bot: about 47 minutes to the buyout, a minute or two more without the + * cooking show). */ export const BOSSES: Boss[] = [ { face: '๐Ÿ™', name: 'Hakkaku', crowd: 160, bell: 30, prize: 350, kind: 'octopus', opens: 'grow room, the attendant, Back-Alley Ring' }, - { face: '๐Ÿฆ€', name: 'Yokobashiri', crowd: 300, bell: 30, prize: 250, kind: 'crab', opens: 'steam baskets, a way south' }, - { face: '๐ŸŸ', name: 'Dobu', crowd: 448, bell: 30, prize: 300, kind: 'catfish', opens: 'Underground Arena, smokehouse, the cook' }, - { face: '๐Ÿฆˆ', name: 'Samehada', crowd: 730, bell: 45, prize: 800, kind: 'shark', opens: 'Grand Hall Below, glowing grow shelves' }, - { face: '๐Ÿก', name: 'Fukure', crowd: 1080, bell: 60, prize: 5000, kind: 'puffer', opens: 'buy back all his chips' }, + { face: '๐Ÿฆ€', name: 'Yokobashiri', crowd: 440, bell: 30, prize: 250, kind: 'crab', opens: 'steam baskets, a way south' }, + { face: '๐ŸŸ', name: 'Dobu', crowd: 880, bell: 45, prize: 300, kind: 'catfish', opens: 'Underground Arena, smokehouse, the cook' }, + { face: '๐Ÿฆˆ', name: 'Samehada', crowd: 920, bell: 45, prize: 800, kind: 'shark', opens: 'Grand Hall Below, glowing grow shelves' }, + { face: '๐Ÿก', name: 'Fukure', crowd: 1250, bell: 60, prize: 5000, kind: 'puffer', opens: 'buy back all his chips' }, ]; /** The practice fight before the first boss (once, for a player who's just come down): the champion's own sparring * partner and a small crowd, so the gong's learnt on a fight that's won. His prize hires the two lines' hands, which is @@ -74,18 +77,22 @@ export const TIERS = [ /** Seconds a boss's crowd takes to walk in from the back door to the stall, before his bell starts. */ export const WALK_IN = 6; /** A batch: four of each ingredient in the pot makes twenty bowls (five a set, as when it took one of each, before the - * machines held four more). The pot holds two batches, ladled out onto the counter a bowl every LADLE seconds; the - * counter holds two batches too. */ -export const PER_BATCH = 4, BATCH = 20, POT_CAP = 40, COUNTER_CAP = 40, STALL_CAP = 30, LEDGE_CAP = 40, LADLE = 0.1; -/** The pot's the player's to work: a cook gets 70% of what they do out of the same four of each, a 14-bowl batch. */ -export const COOK_SHARE = 0.7, COOK_BATCH = Math.round(BATCH * COOK_SHARE); + * machines held four more). The pot holds two batches, ladled out onto the counter a bowl every LADLE seconds (twice + * as fast with a cook ladling too); the counter holds two batches too, and the ledge six batches of each. */ +export const PER_BATCH = 4, BATCH = 20, POT_CAP = 40, COUNTER_CAP = 40, STALL_CAP = 30, LEDGE_CAP = 24, LADLE = 0.1; +/** The bowl slide (a hearth room upgrade): bowls a second it sends from the counter down to the soup bar by itself. */ +export const SLIDE = 12, SLIDE_COST = 150, SLIDE_BEAT = 1; +/** Mama Moray's (resto.ts), the chanko restaurant out front: what the pot can't hold goes to her. A batch cooked with + * the pot full and a batch to spare on the ledge of everything goes up the belt to her counter instead, and pays this share of what + * its bowls would at the stall (the Family keeps the rest). Never in a boss's rush, or before the doors open. */ +export const RESTO_SHARE = 0.12; /** The cooking show at the pot (chanko.ts), a bonus game: it takes nothing and cooks nothing, and its stars buy the pot - * bigger batches for a while (a cook's too, at 70%): long enough to see a boss's rush through, Fukure's walk-in and his + * bigger batches for a while (a cook's too): long enough to see a boss's rush through, Fukure's walk-in and his * 60 s bell too. */ export const BONUS_S = 70; export const BONUS_TIERS = [{ min: 12, pct: 50, name: 'Perfect pot' }, { min: 9, pct: 35, name: 'Hearty' }, { min: 6, pct: 20, name: 'Tasty' }, { min: 0, pct: 0, name: 'Bland' }]; /** Chips a bowl sells for (mushroom bowls pay double), and what Mr. Fugu leaves of it until he's bought out. */ -export const PRICE = 1.6, CUT = 0.7; +export const PRICE = 0.9, CUT = 0.7; export type Role = 'washer' | 'salter' | 'picker' | 'cook' | 'attendant'; /** The player, in a forecast. */ export type WorkerRole = Role | 'you'; @@ -125,8 +132,9 @@ export const SLAM = { share: 0.75, at: 9.6, lead: 1.6, perfect: 0.12, good: 0.3, * stall: nobody's served before they're at it (swarm.ts hurries anyone who'd be late). */ const ARRIVE = 0.6; export const REACH = 6; -/** What a hire carries at a time (more once their line is upgraded), and the attendant's armful of bowls. */ -const HIRE_LOAD = 12, HIRE_LOAD_UP = 36, ATTENDANT_BOWLS = 30; +/** What a hire carries at a time (more once their line is upgraded; the picker, a basket four times that), and the + * attendant's armful of bowls. A line's hire sets off for the ledge once it's below SET_OFF of full. */ +const HIRE_LOAD = 12, HIRE_LOAD_UP = 36, PICKER_BASKET = 4, ATTENDANT_BOWLS = 30, SET_OFF = 0.95; /** A hire's walking speed before the Cellar crew upgrade, and seconds of each job at a station. */ const WALK = 3.0, DWELL = 0.35; /** Seconds the player takes over each thing picked up or put down (cellarHands.ts's step). */ @@ -142,8 +150,8 @@ type Act = 'pick' | 'ledge' | 'bin' | 'fill' | 'drop' | 'cook' | 'take' | 'stall interface Step { to: XZ; act: Act; k?: Ing; i?: number; n?: number; done?: boolean } export interface Worker { role: WorkerRole; x: number; z: number; h: number; moving: boolean; - /** The player's own walking speed and arms, in a forecast. */ - speed?: number; cap?: number; + /** The player's own walking speed and arms, in a forecast; and whether they're there to stay at the pot. */ + speed?: number; cap?: number; stay?: boolean; path: XZ[]; steps: Step[]; carry: Carry | null; dwell: number; cooking: number; cookT: number; idle: number; } @@ -205,10 +213,14 @@ export interface Fight { seed: number; cookT: number; /** The cooking show's bonus: how much bigger the pot's batches are (percent), and for how many more seconds. */ bonus: { pct: number; t: number }; + /** The bowl slide's bought, and how many bowls it's due to send down (it sends SLIDE a second). */ + slide: boolean; slideAcc: number; + /** Bowls sent up the belt to Mama Moray's, all told. */ + resto: number; /** A cooking show left half way (chanko.ts), to carry on from, never to start over: null when there's none. */ show: ShowRun | null; /** The Undertow (undertow.ts): whether Mr. Fugu's given you his matchbook yet, whether the doorman's let you in through - * the door at the tunnel's mouth yet (southDoor.ts), whether the club's own doors have first opened (doorway.ts), and + * the out-of-order stall yet (southDoor.ts), whether its back has first opened on the club (the same, now), and * the bout Tommy last played monte in and how many rounds he played in it; Slick Eddie's fix on the next boss, and the * scandal if he's caught; and what's owed to Harry (loan.ts). */ den: Den; @@ -242,7 +254,7 @@ export function blank(): Fight { pantry: { rice: 0, fish: 0, mush: 0 }, pot: [], potT: 0, counter: [], stall: [], queue: [], fed: [], cooked: [], full: [], fullAcc: 0, earned: [], rush: null, bout: freshBout(0), - seed: 3, cookT: 0, bonus: { pct: 0, t: 0 }, show: null, + seed: 3, cookT: 0, bonus: { pct: 0, t: 0 }, show: null, slide: false, slideAcc: 0, resto: 0, den: { book: false, way: false, door: false, monte: { bout: -1, played: 0 }, eddie: { boss: -1, cut: 0, caught: false }, scandal: { cut: 0, bouts: 0 }, loan: { owed: 0, total: 0 }, met: { eddie: false, harry: false, tommy: false }, @@ -256,10 +268,12 @@ let real = true; /** What the game does when something happens down here (set by the cellar's modules; quiet in a forecast). */ export const on = { - /** Chips earned, from bowls sold at the stall or a boss's prize. */ - earn: (_chips: number, _why: 'bowls' | 'prize') => {}, + /** Chips earned, from bowls sold at the stall, a boss's prize, or bowls sent up to Mama Moray's. */ + earn: (_chips: number, _why: 'bowls' | 'prize' | 'resto') => {}, /** A batch of bowls has come out of the pot. */ cooked: () => {}, + /** A batch of `bowls` has gone up the belt to Mama Moray's, for `chips`. */ + resto: (_bowls: number, _chips: number) => {}, /** The gong's been rung (its boss walks in at once, or once the local fight that's on is over). */ gong: () => {}, /** A boss's rush has begun or ended. */ @@ -276,12 +290,11 @@ export const on = { }; /** Something just bought for the cellar: a line's new machine (or, `all`, every one it has: an upgrade swaps them all), * a hire, or the venue's next tier. */ -export type Built = { line: Ing; all?: boolean } | { role: Role } | { tier: number }; +export type Built = { line: Ing; all?: boolean } | { role: Role } | { tier: number } | { slide: true }; /** Starts the cellar over: a fresh one, or a saved one. */ export function resetFight(f: Fight = blank()) { F = f; - youCooked = -Infinity; syncMachines(); fc.clear(); } @@ -303,8 +316,6 @@ export const practiceDue = () => F.practice && F.beaten === 0; export const canCook = () => inPot().every(k => F.pantry[k] >= PER_BATCH); /** Bowls in the player's batch now: more while the cooking show's bonus lasts. */ export const batchBowls = () => Math.round(BATCH * (1 + (F.bonus.t > 0 ? F.bonus.pct : 0) / 100)); -/** ...and in a cook's: 70% of it. */ -export const cookBowls = () => Math.round(batchBowls() * COOK_SHARE); /** Whether the cooking show can start (or carry on from where it was left): not while its bonus lasts. */ export const canShow = () => F.bonus.t <= 0; /** Cooking shows played to the end since the game started (the tutorial's lesson at the pot is learnt by one). */ @@ -384,30 +395,54 @@ function cookBatch(bowls: number) { F.cooked.push(F.t); if (real) on.cooked(); } -/** Cooks a batch every half second while someone's at the pot, if there's four of each and room for the bowls: the - * player's (or the forecast's "You") a full one, a cook's COOK_BATCH. */ +/** The level of Word on the street that opens Mama Moray's (about when the second boss falls): before it, her place + * out front is a ruin, being cleared. */ +export const RESTO_OPENS = 5; +/** Whether Mama Moray's is open. */ +export const restoOpen = () => mods.street >= RESTO_OPENS; +/** Mama Moray's takes a batch: she's open, the pot's full, and the ledge has a batch to spare of everything that goes + * in, over all but a batch of its room (and the doors are open, and no boss is in or called). */ +export const restoWants = () => restoOpen() && F.slide && !F.rush && !F.called && !F.quiet && potFull() && inPot().every(k => F.pantry[k] >= LEDGE_CAP - PER_BATCH); +/** Whether the pot can take a batch now: into the pot, or, with the pot full, up the belt to Mama Moray's. */ +export const canCookNow = () => canCook() && (!potFull() || restoWants()); +/** A batch of `bowls` cooked up the belt to Mama Moray's: the Family's price for them. */ +function toResto(bowls: number) { + for (const k of inPot()) F.pantry[k] -= PER_BATCH; + const got = bowlPrice(growOpen()) * RESTO_SHARE * bowls; + F.resto += bowls; + F.cooked.push(F.t); F.earned.push([F.t, got]); + if (real) { on.cooked(); on.resto(bowls, got); on.earn(got, 'resto'); } +} +/** Cooks a batch every half second while someone's at the pot, if there's four of each and room for the bowls (or + * Mama Moray's wants them): a full one, the player's (or the forecast's "You"), or a cook's. */ function tendPot(timer: { cookT: number }, dt: number, bowls = batchBowls()) { timer.cookT -= dt; if (timer.cookT > 0) return true; timer.cookT = 0.5; if (canCook() && !potFull()) { cookBatch(bowls); return true; } + if (canCook() && restoWants()) { toResto(bowls); return true; } return false; } -/** When the player last stood at the pot (F.t): the cook stands back while they're cooking (in a forecast too, while - * its "You" cooks). */ -let youCooked = -Infinity; -const youCooking = () => F.t - youCooked >= 0 && F.t - youCooked < 0.25; /** The pot ladles its bowls out onto the counter, one at a time, while the counter has room. */ function ladle(dt: number) { F.potT -= dt; while (F.potT <= 0 && F.pot.length && F.counter.length < COUNTER_CAP) { F.counter.push(F.pot.shift()!); - F.potT += LADLE; + F.potT += F.hires.cook ? LADLE / 2 : LADLE; if (real) poured.n++; } if (F.potT < 0) F.potT = 0; } +/** The bowl slide sends bowls down from the counter to the soup bar, SLIDE a second, while there's room. */ +function slideBowls(dt: number) { + if (!F.slide) return; + F.slideAcc = Math.min(F.slideAcc + dt * SLIDE, 2); + while (F.slideAcc >= 1 && F.counter.length && F.stall.length < STALL_CAP) { F.stall.push(F.counter.shift()!); F.slideAcc -= 1; openDoors(); if (real) slid.n++; } + if (!F.counter.length || F.stall.length >= STALL_CAP) F.slideAcc = Math.min(F.slideAcc, 1); +} +/** Bowls sent down the slide so far (in the real cellar), for drawing them going down it. */ +export const slid = { n: 0 }; // ---------- the fans ---------- function sell(n: number) { @@ -595,8 +630,6 @@ export interface Forecast { boss: Boss; turned: number; worst: Ing | null; at: n /** The player as the forecast sees them: where they are, how fast they walk, how much they carry, and what's in their * arms (what's most of it). */ export interface You { x: number; z: number; speed: number; cap: number; carry: Carry | null } -/** How fast the forecast has the player work, of their walking speed. */ -const YOU_PACE = 1; /** Where the player is now, for the forecast (the game sets it; without it, the forecast is the crew alone). */ export const you = { now: (): You | null => null }; /** The forecasts made lately, by how many of his fans are kept home (none, or a fix's) and the cooking show's bonus @@ -609,24 +642,41 @@ const fc = new Map(); * chance. Kept for half a second of game time. `bonus`: as if the cooking show (chanko.ts) had just bought the pot a * bonus of that many percent (a BONUS_TIERS pct: 20, 35, 50), for its BONUS_S seconds from the gong. */ -export function forecast(kept = 0, { bonus = 0 }: { bonus?: number } = {}): Forecast | null { +export function forecast(kept = 0, { bonus = 0, need }: { bonus?: number; need?: number } = {}): Forecast | null { const boss = nextBoss(); if (!boss || F.rush || F.called) return null; - const key = `${kept}|${bonus}`, was0 = fc.get(key); + const key = `${kept}|${bonus}|${need ?? ''}`, was0 = fc.get(key); if (was0 && was0.i === F.beaten && was0.v.boss === boss && F.t - was0.t < 0.5 && F.t >= was0.t) return was0.v; - const was = F, me = you.now(), cooked = youCooked; + // (and once the crew has every job, twice: the player at whatever needs them, and the player gone to the pot and + // staying there, as for the cooking show; the kitchen goes a little differently each way, and the gong promises the + // worse; asked whether it turns `need`, it doesn't go again once the first is short of it) + const me = you.now(); + const runs = [rehearse(boss, kept, bonus, me, false)]; + if (me && handsFree() && !(need !== undefined && runs[0].turned < need)) runs.push(rehearse(boss, kept, bonus, me, true)); + const v = runs.reduce((a, b) => (b.turned < a.turned ? b : a)); + fc.set(key, { t: F.t, i: F.beaten, v }); + return v; +} +/** Whether the crew does everything without the player: both lines hired, the mushrooms too once they grow, and the + * bowls carried out (or slid) to the soup square. */ +const handsFree = () => !!(F.hires.washer && F.hires.salter && (F.hires.attendant || F.slide) && (!growOpen() || F.hires.picker)); +/** One go at the rush in a copy of the cellar: `me`, the player (or nobody), working where they're needed, or (`atPot`) + * gone to the pot to stay. */ +function rehearse(boss: Boss, kept: number, bonus: number, me: You | null, atPot: boolean): Forecast { + const was = F; F = structuredClone(was); real = false; try { if (bonus) F.bonus = { pct: bonus, t: BONUS_S }; if (me) { // (a little slower than they walk: nobody plays a rush perfectly, and "Ready" has to be a promise the gong keeps) - const w: Worker = { ...worker('washer'), role: 'you', x: me.x, z: me.z, speed: me.speed * YOU_PACE, cap: me.cap, carry: me.carry ? structuredClone(me.carry) : null }; + const w: Worker = { ...worker('washer'), role: 'you', x: me.x, z: me.z, speed: me.speed, cap: me.cap, carry: me.carry ? structuredClone(me.carry) : null, stay: atPot }; // first, whatever's in their arms where it goes const c = w.carry; if (c?.k === 'bowl') w.steps = [{ to: BAR_PAD, act: 'stall' }]; else if (c?.ready) w.steps = [{ to: ledgeSpot(c.k), act: 'ledge', k: c.k }]; else w.carry = null; + if (atPot) w.steps.push({ to: POT_PAD, act: 'cook' }); F.crew.push(w); } // (rung during a local fight, he walks in once it's over, the kitchen working on meanwhile: ringGong; with no @@ -638,13 +688,10 @@ export function forecast(kept = 0, { bonus = 0 }: { bonus?: number } = {}): Fore } else startRush(kept); for (let n = 0; n < 4000 && !F.rush!.done; n++) step(0.05); const R = F.rush!; - const v: Forecast = { boss, turned: R.turned, worst: worstShort(R), at: R.t }; - fc.set(key, { t: was.t, i: was.beaten, v }); - return v; + return { boss, turned: R.turned, worst: worstShort(R), at: R.t }; } finally { F = was; real = true; - youCooked = cooked; } } @@ -656,21 +703,21 @@ export function hire(role: Role) { F.crew.push(worker(role)); } function worker(role: WorkerRole): Worker { - const at = role === 'you' ? { x: 0, z: 0 } : HOME[role]; + const at = role === 'you' ? { x: 0, z: 0 } : homeOf(role, F.slide); return { role, x: at.x, z: at.z, h: 0, moving: false, path: [], steps: [], carry: null, dwell: 0, cooking: 0, cookT: 0, idle: 0 }; } const lineOf = (w: Worker) => (w.role === 'you' ? undefined : HIRES[w.role].line); /** How much a hire carries at a time (the player, their arms' worth). */ -export const carryCap = (w: Worker) => w.cap ?? HIRE_LOAD + HIRE_LOAD_UP * (lineOf(w) ? F.lines[lineOf(w)!].up : 1); +export const carryCap = (w: Worker) => w.cap ?? (HIRE_LOAD + HIRE_LOAD_UP * (lineOf(w) ? F.lines[lineOf(w)!].up : 1)) * (w.role === 'picker' ? PICKER_BASKET : 1); function ledgeSpot(k: Ing): XZ { return { x: LEDGE[k].x, z: POT_PAD.z - 0.3 }; } /** A line's hire's next job: ready ones to the ledge, or raw ones from the bins into the machines. */ function planLine(w: Worker, k: Ing): Step[] | null { const ms = F.lines[k].m, cap = carryCap(w); if (!ms.length || (k === 'mush' && !growOpen())) return null; - // take ready ones to the ledge, from as many machines as one armful takes: set off while the ledge still has an - // armful's worth, so the pot never waits on the walk - if (F.pantry[k] < Math.min(LEDGE_CAP / 2, Math.max(8, cap))) { + // take ready ones to the ledge, from as many machines as one armful takes: set off as soon as it's short of full, so + // the pot never waits on the walk (and a full ledge sends Mama Moray's what the pot can't hold) + if (F.pantry[k] < LEDGE_CAP * SET_OFF) { const order = ms.map((m, i) => [i, doneCount(k, m)]).filter(([, d]) => d > 0).sort((a, b) => b[1] - a[1]); const total = order.reduce((t, [, d]) => t + d, 0); if (total && (total >= Math.min(cap, k === 'mush' ? 2 : 3) || F.pantry[k] < PER_BATCH)) { @@ -702,13 +749,15 @@ function planLine(w: Worker, k: Ing): Step[] | null { return steps; } const planBowls = (): Step[] => [{ to: COUNTER_PAD, act: 'take' }, { to: BAR_PAD, act: 'stall' }]; -/** The player's next job, in a forecast: whatever's most pressing that no hire does (the mockup's "You"). */ +/** The player's next job, in a forecast: whatever's most pressing that nothing and nobody else does (the mockup's + * "You"): carrying bowls till the attendant's hired or the slide's bought, cooking till there's a cook, and running the + * lines that have no hire. Only what's theirs alone, so the gong's forecast is the least they'll do, and anything more + * (cooking beside the cook, carrying beside the slide) only turns more. */ function thinkYou(w: Worker): Step[] | null { const opts: [number, () => Step[] | null][] = []; const stallLow = F.stall.length < 6, rush = !!F.rush && !F.rush.done; - if (!F.hires.attendant && F.counter.length && F.stall.length < STALL_CAP) opts.push([3 + (stallLow ? 4 : 0) + (rush ? 4 : 0) + F.counter.length * 0.2, planBowls]); - // (with a cook hired too: he stands back while they're at the pot, and their batches are full ones) - if (canCook() && !potFull()) opts.push([3 + (F.counter.length < BATCH ? 3 : 0), () => [{ to: POT_PAD, act: 'cook' }]]); + if (!F.hires.attendant && !F.slide && F.counter.length && F.stall.length < STALL_CAP) opts.push([3 + (stallLow ? 4 : 0) + (rush ? 4 : 0) + F.counter.length * 0.2, planBowls]); + if (!F.hires.cook && canCook() && !potFull()) opts.push([3 + (F.counter.length < BATCH ? 3 : 0), () => [{ to: POT_PAD, act: 'cook' }]]); for (const k of inPot()) { if (F.hires[k === 'rice' ? 'washer' : k === 'fish' ? 'salter' : 'picker']) continue; opts.push([2 + (F.pantry[k] < 2 ? 3 : 0) + (k === 'rice' ? 0.2 : k === 'fish' ? 0.1 : 0), () => planLine(w, k)]); @@ -725,7 +774,8 @@ function think(w: Worker): Step[] | null { const k = lineOf(w); if (k) return planLine(w, k); if (w.role === 'cook') return [{ to: COOK_AT, act: 'cook' }]; - return F.counter.length && F.stall.length < STALL_CAP ? planBowls() : null; + // (the attendant carries bowls till the bowl line's in, then works its motor) + return !F.slide && F.counter.length && F.stall.length < STALL_CAP ? planBowls() : null; } /** Does a step's job; false means the rest of the job's off (nothing there to take). */ function doAct(w: Worker, st: Step): boolean { @@ -760,8 +810,9 @@ function doAct(w: Worker, st: Step): boolean { return true; } case 'drop': w.carry = null; return true; - // (the cook stays at the pot; the player cooks a few batches, then sees what else needs doing) - case 'cook': w.cooking = w.role === 'cook' ? Infinity : 3.5; return true; + // (the cook stays at the pot; the player cooks a few batches, then sees what else needs doing, unless they're there + // to stay) + case 'cook': w.cooking = w.role === 'cook' || w.stay ? Infinity : 3.5; return true; case 'take': { const n = Math.min(w.cap ?? ATTENDANT_BOWLS, F.counter.length, STALL_CAP - F.stall.length); if (!n) return false; @@ -785,8 +836,8 @@ function updWorker(w: Worker, dt: number) { w.moving = false; if (w.cooking > 0) { w.cooking -= dt; - if (w.role === 'cook') { if (!youCooking()) tendPot(w, dt, cookBowls()); } - else { youCooked = F.t; if (!tendPot(w, dt)) w.cooking = 0; } + if (w.role === 'cook' || w.stay) tendPot(w, dt); + else if (!tendPot(w, dt)) w.cooking = 0; return; } if (w.dwell > 0) { w.dwell -= dt; return; } @@ -810,7 +861,7 @@ function updWorker(w: Worker, dt: number) { if (plan) { w.steps = plan; w.idle = 0; } else { w.idle += dt; - if (w.idle > 2.5 && w.role !== 'you') { w.idle = 0; const home = HOME[w.role]; if (Math.hypot(w.x - home.x, w.z - home.z) > 0.3) w.path = cellarWay(w, home); } + if (w.idle > 2.5 && w.role !== 'you') { w.idle = 0; const home = homeOf(w.role, F.slide); if (Math.hypot(w.x - home.x, w.z - home.z) > 0.3) w.path = cellarWay(w, home); } } return; } @@ -872,6 +923,7 @@ export function step(dt: number) { syncMachines(); updMachines(dt); ladle(dt); + slideBowls(dt); for (const w of F.crew) updWorker(w, dt); updFans(); updRush(dt); @@ -906,9 +958,8 @@ export function toLedge(k: Ing) { F.pantry[k]++; return true; } -/** The player standing at the pot keeps it cooking, full batches (a cook stands back while they do). */ +/** The player standing at the pot keeps it cooking, full batches (a cook beside them cooks his too). */ export function playerAtPot(dt: number) { - youCooked = F.t; tendPot(F, dt); } /** The player at the counter: takes a bowl (true for a mushroom bowl), if there is one. */ @@ -946,6 +997,7 @@ export function upgradeLine(k: Ing) { } export function setTier(t: number) { F.tier = Math.max(F.tier, t); } export function buyOut() { F.boughtOut = true; } +export function buySlide() { F.slide = true; } // ---------- saving ---------- /** The cellar, for saving: a rush cut short by closing the page is called off (no win, no loss). */ @@ -993,8 +1045,9 @@ export function fightFrom(raw: unknown): Fight { seed: clamp(Math.floor(num(r.seed, 3)), 1, 2147483646), cookT: 0, bonus: BONUS_TIERS.some(x => x.pct && x.pct === r.bonus?.pct) && num(r.bonus?.t) > 0 ? { pct: r.bonus!.pct, t: clamp(num(r.bonus?.t), 0, BONUS_S) } : b.bonus, show: showFrom(r.show), + slide: r.slide === true, slideAcc: 0, resto: Math.max(0, Math.floor(num(r.resto))), den: { - // (a game saved before the door at the tunnel's mouth: the tunnel's open already, if the second boss was beaten) + // (a game saved before the doorman's door: the way's open already, if the second boss was beaten) book: typeof r.den?.book === 'boolean' ? r.den.book : num(r.beaten) >= 2, way: typeof r.den?.way === 'boolean' ? r.den.way : num(r.beaten) >= 2, door: r.den?.door === true, monte: { bout: Math.floor(num(r.den?.monte?.bout, -1)), played: clamp(Math.floor(num(r.den?.monte?.played)), 0, 99) }, diff --git a/src/fuguDemo.ts b/src/fuguDemo.ts index 882131d..cc6c3a9 100644 --- a/src/fuguDemo.ts +++ b/src/fuguDemo.ts @@ -2,7 +2,8 @@ // the last diners' bills at the register, which the crew brings in a moment later: then he knocks, and the arrow // points the way. Or, with ?cellar in the address, down in his cellar (stage 3) with chips to spend, to try fight // night (&chips=N for N chips, not 2,000; &beaten=N with N of the bosses beaten already, and what they open open: from -// 2, Mr. Fugu with his matchbook for the door at the tunnel's mouth, or with &south already through it; &pot to start +// 2, Mr. Fugu with his matchbook for the out-of-order stall in Mama Moray's restroom, or with &south already +// through it; &pot to start // at the pot, for its cooking game). // Nothing is kept, so every visit starts over, the tutorial included. import { POT_PAD } from './cellarPlan'; diff --git a/src/game.ts b/src/game.ts index 6e23fce..74074b3 100644 --- a/src/game.ts +++ b/src/game.ts @@ -9,6 +9,7 @@ import { calm, COUNTERS, postCustomers, updCounter, updLeaving } from './counter import { updFarm } from './farm'; import { updFx } from './fx'; import { updGarden } from './garden'; +import { headless } from './headless'; import { updRoof } from './hall'; import { updChopper, updFish, updHooks } from './fishing'; import { updFlights } from './holder'; @@ -56,25 +57,27 @@ export function tick(dt: number) { // the sound and the scene itself go on. // (as they do while Slick Eddie's coin is up at the gong, gong.ts: the boss's crowd waits at the door, and his bell) const coin = coinUp(), held = offerPlaying() || coin; + // (the balance sims leave out what only draws, moves or sounds: headless.ts) + const live = !headless.on; if (coin) updCoin(dt); updPlayer(dt); - updFish(dt, time); - updHooks(dt); + if (live) updFish(dt, time); + if (live) updHooks(dt); if (!held) { - updCasino(dt); - updKorki(dt); - updHouse(dt); + if (live) updCasino(dt); + if (live) updKorki(dt); + if (live) updHouse(dt); updAuto(dt); - updChopper(dt); - updRunners(dt); - updRice(dt); - updRestaurant(dt); - updNeeds(dt); - updGarden(dt); + if (live) updChopper(dt); + if (live) updRunners(dt); + if (live) updRice(dt); + if (live) updRestaurant(dt); + if (live) updNeeds(dt); + if (live) updGarden(dt); // stage 3: the ring under the dock, and the ladder down to it if (stage.n === 3) { updRing(dt); - updRingSound(dt); + if (live) updRingSound(dt); updLadder(dt); updCookButton(); } @@ -82,34 +85,34 @@ export function tick(dt: number) { // While he's on, the world winds down rather than stopping dead: what's in the air lands, and the cars finish their // drive (nobody new pulls in, or passes by, and nobody waiting loses patience); everyone else holds still. calm.on = held; - COUNTERS.forEach(C => updCounter(C, dt)); - updLeaving(dt); - postCustomers(dt); + if (live) COUNTERS.forEach(C => updCounter(C, dt)); + if (live) updLeaving(dt); + if (live) postCustomers(dt); updStars(); updFlights(dt); - updPops(dt); - updFx(dt); - updHorizon(dt); - updFloes(time, player.g.position); + if (live) updPops(dt); + if (live) updFx(dt); + if (live) updHorizon(dt); + if (live) updFloes(time, player.g.position); if (!held) { // (nothing falls in the cellar) // the seasons turn up in the world; down in the cellar, nobody hears about it if (updSeason(dt, player.g.position, level.down ? 1 : rainK) && !level.down) { toast(SEASON_INFO[current()].news, 'season'); seasonSting(current()); } - updFarm(dt); + if (live) updFarm(dt); } updStage(dt); - updSpotlight(dt); + if (live) updSpotlight(dt); updShop(); if (!held) updTutorial(dt); - updLooks(dt); - updBakes(dt); - updRoof(dt, player.g.position, staging()); + if (live) updLooks(dt); + if (live) updBakes(dt); + if (live) updRoof(dt, player.g.position, staging()); updOffer(dt); // (and through his scenes, the scenes on the way south in the cellar (southDoor.ts), and the Family introducing // themselves (meet.ts), the world's own sounds go quiet) hushWorld(offerPlaying() || wayPlaying() || meetPlaying()); - updAudio(dt, player.g.position); - updAmbience(); - updMusic(dt); - updRoomTone(); + if (live) updAudio(dt, player.g.position); + if (live) updAmbience(); + if (live) updMusic(dt); + if (live) updRoomTone(); } diff --git a/src/headless.ts b/src/headless.ts new file mode 100644 index 0000000..d878e65 --- /dev/null +++ b/src/headless.ts @@ -0,0 +1,5 @@ +// The game run without its looks, for the balance sims (test/bot.ts): everything that only draws, animates or sounds +// (the cellar's crowd, wrestlers and kitchen as drawn, the world upstairs going about its day, the sea, the sound) is +// left out of the tick, and what plays the game (the player, the cellar's sim, the shop, what's carried in the air) is +// kept, so a run comes out the same, many times faster. Never on in the game itself. +export const headless = { on: false }; diff --git a/src/layout.ts b/src/layout.ts index a72c1f9..bfcd6eb 100644 --- a/src/layout.ts +++ b/src/layout.ts @@ -34,11 +34,16 @@ export const GROW = { x0: 11.6, x1: 20.6, z0: -9.5, z1: 0.3 }; * ROOMS's): what comes down them slides down a wooden chute each into a store, which keeps a tray by the wall full, * handed over it to whoever stands at the circle on the hall's side. */ export const LIFT_ROOM = { x0: 21.2, x1: 24.8, z0: 1.9, z1: 7.9 }; -/** The Undertow, the Family's club, nobody knows is there until the second boss is beaten: a long dark tunnel out - * through the arena's south wall, between the ring and the soup square, down past the club's east wall to a door in it - * at the far end; and the club, a big smoky room dug into the rock in front of the arena (the camera's side, like the - * cookware shop), from a little west of the arena's west wall, and deep. */ -export const PASSAGE = { x0: -1.9, x1: -0.7, z0: 8.0, z1: 15.6 }; +/** Mama Moray's, the Family's chanko restaurant out front (resto.ts), open from the first night: through the arena's + * south wall, between the ring and the soup square. Its front hall (the counter, the belt round it from the soup + * square, and tables), the dining wing round the back of the cookware shop, and in the hall's far corner, against the + * club's wall, the restroom: two stalls, and the one that's out of order is the way into the Undertow. */ +export const RESTO = { x0: -2.9, x1: 3.5, z0: 8.0, z1: 11.6 }; +export const WING = { x0: 1.0, x1: 10.6, z0: 12.2, z1: 16.2 }; +export const LOO = { x0: -2.9, x1: 0.4, z0: 12.2, z1: 16.2 }; +/** The Undertow, the Family's club, nobody knows is there until the second boss is beaten: a big smoky room dug into + * the rock in front of the arena (the camera's side, like the cookware shop), from a little west of the arena's west + * wall, and deep, its east wall back to back with the restroom's. */ export const DEN = { x0: -15.5, x1: -3.4, z0: 10.2, z1: 22.0 }; /** Hammerhead Harry's office, a side room through the club's west wall: where the loans are made and the books kept. */ export const LOANS = { x0: DEN.x0 - 5.1, x1: DEN.x0 - 0.6, z0: DEN.z0, z1: DEN.z0 + 4.4 }; @@ -47,9 +52,10 @@ export const LOANS = { x0: DEN.x0 - 5.1, x1: DEN.x0 - 0.6, z0: DEN.z0, z1: DEN.z export const VIP = { x0: DEN.x0 + 3.3, x1: DEN.x0 + 8.7, z0: DEN.z1 + 0.6, z1: DEN.z1 + 4.2 }; /** Three-Card Tommy's card room, through the club's west wall south of Harry's office: his table and his screen. */ export const CARDS = { x0: LOANS.x0, x1: LOANS.x1, z0: LOANS.z1 + 0.6, z1: LOANS.z1 + 4.0 }; -export type CellarRoom = 'arena' | 'landing' | 'office' | 'kitchen' | 'prep' | 'grow' | 'craft' | 'passage' | 'den' | 'loans' | 'vip' | 'cards'; +export type CellarRoom = 'arena' | 'landing' | 'office' | 'kitchen' | 'prep' | 'grow' | 'craft' | 'resto' | 'wing' | 'loo' | 'den' | 'loans' | 'vip' | 'cards'; export const ROOMS: Record = { - arena: ARENA, landing: LANDING, office: OFFICE_ROOM, kitchen: KITCHEN, prep: PREP, grow: GROW, craft: CRAFT, passage: PASSAGE, den: DEN, loans: LOANS, vip: VIP, cards: CARDS, + arena: ARENA, landing: LANDING, office: OFFICE_ROOM, kitchen: KITCHEN, prep: PREP, grow: GROW, craft: CRAFT, resto: RESTO, wing: WING, loo: LOO, + den: DEN, loans: LOANS, vip: VIP, cards: CARDS, }; /** The doorways through the walls: the two rooms each joins, and its floor (across the wall). */ export const DOORS: { a: CellarRoom; b: CellarRoom; x0: number; x1: number; z0: number; z1: number }[] = [ @@ -63,9 +69,15 @@ export const DOORS: { a: CellarRoom; b: CellarRoom; x0: number; x1: number; z0: { a: 'kitchen', b: 'craft', x0: 6.0, x1: 7.2, z0: KITCHEN.z1, z1: CRAFT.z0 }, // (at the east end of the prep hall's back wall, past the racks along it) { a: 'prep', b: 'grow', x0: 18.5, x1: 19.7, z0: GROW.z1, z1: PREP.z0 }, - // (the tunnel: through the arena's south wall, and at its far end through the Undertow's east wall) - { a: 'arena', b: 'passage', x0: PASSAGE.x0, x1: PASSAGE.x1, z0: ARENA.z1, z1: PASSAGE.z0 }, - { a: 'passage', b: 'den', x0: DEN.x1, x1: PASSAGE.x0, z0: PASSAGE.z1 - 1.8, z1: PASSAGE.z1 - 0.2 }, + // (Mama Moray's: its front door through the arena's south wall, the wide way through to its dining wing, the + // restroom's door off the wing, and the out-of-order stall's back, through to the Undertow, which the doorman opens: + // southDoor.ts) + { a: 'arena', b: 'resto', x0: -1.9, x1: -0.7, z0: ARENA.z1, z1: RESTO.z0 }, + { a: 'resto', b: 'wing', x0: WING.x0, x1: RESTO.x1, z0: RESTO.z1, z1: WING.z0 }, + { a: 'wing', b: 'loo', x0: LOO.x1, x1: WING.x0, z0: LOO.z0 + 1.3, z1: LOO.z0 + 2.5 }, + // (and from the dining wing into the cookware shop, through its south wall, right below the shop counter's square) + { a: 'wing', b: 'craft', x0: 5.7, x1: 6.9, z0: CRAFT.z1, z1: WING.z0 }, + { a: 'loo', b: 'den', x0: DEN.x1, x1: LOO.x0, z0: LOO.z1 - 2.25, z1: LOO.z1 - 1.05 }, // (Harry's office, through the club's west wall, south of the bar) { a: 'den', b: 'loans', x0: LOANS.x1, x1: DEN.x0, z0: DEN.z0 + 2.4, z1: DEN.z0 + 3.6 }, // (Tommy's card room, through the club's west wall south of Harry's office, near the front) @@ -76,10 +88,10 @@ export const DOORS: { a: CellarRoom; b: CellarRoom; x0: number; x1: number; z0: /** Whether `p` is in the Undertow: the club, or one of its side rooms (Harry's office, Tommy's card room, Eddie's room). */ export const inUndertow = (p: { x: number; z: number }) => [DEN, LOANS, VIP, CARDS].some(r => p.x > r.x0 - 0.5 && p.x < r.x1 + 0.5 && p.z > r.z0 - 0.5 && p.z < r.z1 + 0.5); -/** Which of the cellar's rooms have been found: the grow room once the first boss is beaten, the Undertow (its tunnel - * and Harry's office too) once the second is. */ +/** Which of the cellar's rooms have been found: the grow room once the first boss is beaten, the Undertow (and its side + * rooms) once the doorman lets you in. */ export const found = { grow: false, den: false }; -const shown = (r: CellarRoom) => (r !== 'grow' || found.grow) && ((r !== 'den' && r !== 'passage' && r !== 'loans' && r !== 'vip' && r !== 'cards') || found.den); +const shown = (r: CellarRoom) => (r !== 'grow' || found.grow) && ((r !== 'den' && r !== 'loans' && r !== 'vip' && r !== 'cards') || found.den); /** How far a walker's middle keeps from a cellar wall: about their width, so they stop at its face, not halfway in. */ const BODY = 0.2; const cellarFloors = () => [ diff --git a/src/music.ts b/src/music.ts index 265430f..bd78fd5 100644 --- a/src/music.ts +++ b/src/music.ts @@ -434,7 +434,7 @@ function updLounge(k: number, now: number) { /** * The club's band's place: the eighth it's on and when the next is due. It starts from the top the first time it's * heard, and from then on plays on behind the club's doors whether anyone hears it or not (its notes left out where - * nobody does), so it's the same tune going on down the tunnel and in the club, and walking in or out never starts it + * nobody does), so it's the same tune going on through the stall's back and in the club, and walking in or out never starts it * over. (After a long stall, the page held up, it picks up where it would be by now.) */ let noir = { on: false, step: 0, next: 0 }; diff --git a/src/noir.ts b/src/noir.ts index eba6430..47b6ae0 100644 --- a/src/noir.ts +++ b/src/noir.ts @@ -3,7 +3,7 @@ // harmon mute over brushes, the upright bass and a sparse piano; a smoky tenor sax joins it for the last A, vibes // harmonise the bridge, and each round of sixteen bars ends on the minor-major chord of every film noir. // -// It plays into a mix of its own (music.ts fades it in down the tunnel and has it to itself in the club), through a +// It plays into a mix of its own (music.ts fades it in in Mama Moray's restroom and has it to itself in the club), through a // small room's reverb, and through the club's doors: a low-pass that muffles it while they're shut, and opens with them. import { ac, buses, hz, noise, rnd, tone, type ToneVoice } from './audio'; import { FORM } from './lounge'; diff --git a/src/resto.ts b/src/resto.ts new file mode 100644 index 0000000..8106461 --- /dev/null +++ b/src/resto.ts @@ -0,0 +1,756 @@ +// Mama Moray's Honest Chanko (stage 3): the Family's chanko restaurant out front, through the arena's south doorway. +// What the pot can't hold, once the ledge is stocked, goes to her (fight.ts, toResto): the bowls ride the hearth +// room's bowl line (bowlLine.ts; she gets none till it's bought) past the stall, under the arena's wall, and come up +// through a slot in her counter, and loop round it, and the fans on its stools take them as they pass; she keeps the till, and pays a cut of +// each bowl (the Family keeps the rest). The fans eat at the counter and at tables down the hall and its dining wing, +// each table's nabe on its burner, as many of them in as there's been chanko lately. +// +// It starts a wreck, and is put right level by level of Word on the street (stageOf, lookOf, dress): at 0 and 1 toppled +// tables, rubble, gaps in the floor, cobwebs and a torn curtain, shut; from 2 to 4 being cleared, still shut: the +// rubble stacked, a broom and a bucket, then the floor patched and the tables up, then the stools; at 5 patched and +// open, Mama at her till ("Open for business, dear."), and only then does she take her cut (about when the second +// boss falls, and the stall's way into the Undertow opens); then its looks: at 6 a back-street joint, plank walls, +// paper lanterns and a plain counter; at 7 Mama's touch, framed sumo photos and the family's portrait, red lanterns strung +// along the walls and checked cloths on the tables; at 8 the Family's front, dark panelling with a thin gold line, slim +// purple banners with a gold fish, a lacquered board and a dark counter. Shut or open, its hall's always walkable, to +// the restroom. Each level's close-up waits for the arena's (spotlight.ts, spotBusy). Its sound is restoSound.ts. +// +// Its restroom, off the wing, has two stalls: the first's in use, the second's out of order at every level, taped +// across, its toilet taped up, a bare bulb over it on a wire; and its back is the doorman's door into the Undertow +// (southDoor.ts, which tears the tape and flickers the bulb). Once the second boss is beaten, a pink light leaks under +// it. Everything's made on dice of its own (never the game's luck), the first time stage 3's dressed. +import { + AdditiveBlending, CatmullRomCurve3, CylinderGeometry, DoubleSide, Group, type Material, Mesh, MeshBasicMaterial, + MeshLambertMaterial, type Object3D, PlaneGeometry, RepeatWrapping, TorusGeometry, TubeGeometry, Vector3, +} from 'three'; +import { cellar, CY, lights, stallTape, WALL } from './cellar'; +import { + BASINS, CLUB_DOOR, MAMA, MAMA_COUNTER as K, RISE, STALL_DOOR, STALLS, STOOLS, TABLE_SEAT, TABLES, TILL, +} from './cellarPlan'; +import { belowGround, crowdModel, type Person } from './characters'; +import { stallDoor } from './doorway'; +import { mods } from './economy'; +import { flatten, type Hood, mama, settle } from './family'; +import { F, on } from './fight'; +import { apart, dice } from './kit'; +import { lineShows, lineToMama, mamaLeg } from './bowlLine'; +import { contactShadows, type Smudge } from './contact'; +import { ARENA, CRAFT, DOORS, level, LOO, RESTO, stage, WING } from './layout'; +import { player } from './player'; +import { popIn } from './pop'; +import { updRestoSound } from './restoSound'; +import { spotBusy, spotlight } from './spotlight'; +import { staging } from './stage'; +import { canvasTex, FONT, G, mesh } from './render'; +import { bubble } from './speech'; +import { popText } from './ui'; +import { crowdStyle } from './wardrobe'; + +/** Everything here: in the cellar from the start, empty till stage 3's first dressed. */ +export const resto = apart(() => new Group(), dice(0x4E570)); +cellar.add(resto); + +const V = (x: number, y: number, z: number) => new Vector3(x, y, z); +const MINCHO = '"Hiragino Mincho ProN","Yu Mincho","Noto Serif JP",serif'; +const lam = (color: number, emissive = 0) => new MeshLambertMaterial({ color, emissive }); +const add = (p: Object3D, o: T) => { p.add(o); return o; }; +/** A box `sx` by `sy` by `sz` with its middle at (x, y, z), y up from the cellar's floor. */ +function box(p: Object3D, m: number | Material, x: number, y: number, z: number, sx: number, sy: number, sz: number, cast = true) { + const o = add(p, mesh(G.box, m, x, CY + y, z, cast)); o.scale.set(sx, sy, sz); return o; +} +/** A round thing `r` in radius and `h` high, its middle at (x, y, z). */ +function cyl(p: Object3D, m: number | Material, x: number, y: number, z: number, r: number, h: number, cast = true) { + const o = add(p, mesh(G.cyl, m, x, CY + y, z, cast)); o.scale.set(r, h, r); return o; +} +function ball(p: Object3D, m: number | Material, x: number, y: number, z: number, sx: number, sy = sx, sz = sx) { + const o = add(p, mesh(G.sphere, m, x, CY + y, z)); o.scale.set(sx, sy, sz); return o; +} +/** A picture `w` by `h` facing +z (turned `ry`), its middle at (x, y, z), as `draw` paints it. */ +function sign(p: Object3D, x: number, y: number, z: number, w: number, h: number, ry: number, draw: (c: CanvasRenderingContext2D, w: number, h: number) => void, px = 220) { + const m = new MeshLambertMaterial({ map: canvasTex(Math.round(w * px), Math.round(h * px), draw).tex, side: DoubleSide }); + const o = add(p, new Mesh(new PlaneGeometry(w, h), m)); + o.position.set(x, CY + y, z); o.rotation.y = ry; o.receiveShadow = true; + return o; +} +/** A flat sheet of `m`, `w` by `h`, facing +z (turned `ry`), its middle at (x, y, z). */ +function plane(p: Object3D, m: Material, x: number, y: number, z: number, w: number, h: number, ry = 0) { + const o = add(p, new Mesh(new PlaneGeometry(w, h), m)); + o.position.set(x, CY + y, z); o.rotation.y = ry; o.receiveShadow = true; + return o; +} +/** A texture drawn once, repeating `rx` by `ry` times where it's laid. */ +function repeat(px: number, draw: (c: CanvasRenderingContext2D, w: number, h: number) => void) { + const t = canvasTex(px, px, draw).tex; + return (rx: number, ry: number) => { const c = t.clone(); c.wrapS = c.wrapT = RepeatWrapping; c.repeat.set(rx, ry); c.needsUpdate = true; return c; }; +} +/** A paper lantern hung at (x, y, z): white, or red. */ +function lantern(p: Object3D, m: Material, x: number, y: number, z: number, cord = 0.4) { + ball(p, m, x, y, z, 0.15, 0.2, 0.15); + for (const s of [-1, 1]) cyl(p, 0x1B1A1E, x, y + s * 0.19, z, 0.07, 0.035, false); + cyl(p, 0x1B1A1E, x, y + 0.2 + cord / 2, z, 0.008, cord, false); +} +/** A string hung from `a` to `b`, sagging `drop` in the middle: a point every 1/`n` of the way. */ +const sag = (a: Vector3, b: Vector3, drop: number, n: number) => Array.from({ length: n + 1 }, (_, i) => { + const t = i / n, q = a.clone().lerp(b, t); q.y -= drop * 4 * t * (1 - t); return q; +}); +const wire = (p: Object3D, pts: Vector3[], r: number, c: number) => add(p, mesh(new TubeGeometry(new CatmullRomCurve3(pts), pts.length * 2, r, 4), c)); + +// ---------- what it's made of ---------- +const M = { + wood: lam(0x7A4E32), woodDark: lam(0x4A2C1E), top: lam(0x9A6A44), steel: lam(0x8A9096), steelDark: lam(0x4A5056), + clay: lam(0x3B2A20), claySoft: lam(0x6A4A34), stew: lam(0xC9803A), tofu: lam(0xF4F0E6), cabbage: lam(0x7FB850), + carrot: lam(0xE07A2A), tuna: lam(0x8A4A2A), mush: lam(0xD8C4A0), bowl: lam(0xEDE6D8), iron: lam(0x2E2E33), + porcelain: lam(0xF2F0EA), teal: lam(0x2A7068), tape: lam(0xE8C43A, 0x3A2A00), brass: lam(0xC9A23A, 0x2A1A00), + flame: new MeshLambertMaterial({ color: 0x8AC0FF, emissive: 0x3A7AFF }), + paper: new MeshLambertMaterial({ color: 0xFFF1D6, emissive: 0xFFC46A, emissiveIntensity: 0.9 }), + red: new MeshLambertMaterial({ color: 0xE0392B, emissive: 0xB0250F }), + steam: new MeshLambertMaterial({ color: 0xFFFFFF, transparent: true, opacity: 0.2, depthWrite: false }), + lacquer: lam(0x22140F), purple: lam(0x5B2A86), gold: lam(0xD2AA4A, 0x3A2A00), +}; +/** The belt's steel slats, across it. */ +const slats = repeat(64, (c, w, h) => { + c.fillStyle = '#9AA0A6'; c.fillRect(0, 0, w, h); + for (let x = 0; x < w; x += 16) { c.fillStyle = '#5A6066'; c.fillRect(x, 0, 3, h); c.fillStyle = 'rgba(255,255,255,.25)'; c.fillRect(x + 4, 0, 2, h); } +}); +/** Red and white gingham, for the tables at level 3. */ +const gingham = repeat(64, (c, w, h) => { + c.fillStyle = '#F4EFE6'; c.fillRect(0, 0, w, h); + c.fillStyle = 'rgba(190,40,40,.55)'; for (let i = 0; i < 4; i++) { c.fillRect(i * 16, 0, 8, h); c.fillRect(0, i * 16, w, 8); } +}); +/** Dark panelling, a panel a tile, framed in a thin gold line. */ +const panelling = repeat(128, (c, w, h) => { + c.fillStyle = '#3E2016'; c.fillRect(0, 0, w, h); + for (let i = 0; i < 18; i++) { c.fillStyle = i % 2 ? 'rgba(20,8,4,.2)' : 'rgba(120,60,30,.1)'; c.fillRect((i * 37) % w, 0, 2, h); } + c.strokeStyle = '#D2AA4A'; c.lineWidth = 3; c.strokeRect(12, 14, w - 24, h - 28); +}); +/** Caution tape: yellow, black bands across it, the word along it. */ +const tapeMat = () => new MeshLambertMaterial({ + side: DoubleSide, emissive: 0x2A2000, + map: canvasTex(256, 24, (c, w, h) => { + c.fillStyle = '#F2C81E'; c.fillRect(0, 0, w, h); + c.fillStyle = '#1A1A1A'; c.font = `800 16px ${FONT}`; c.textBaseline = 'middle'; + for (let x = 6; x < w; x += 92) c.fillText('CAUTION', x, h / 2 + 1); + }).tex, +}); + +// ---------- the pieces ---------- +/** A nabe on a burner, its broth with what goes in the cellar's: tofu, cabbage, carrot, cured tuna, mushrooms. */ +function nabe(p: Object3D, x: number, y: number, z: number, s = 1) { + const g = add(p, new Group()); g.position.set(x, CY + y, z); g.scale.setScalar(s); + const b = (m: Material, bx: number, by: number, bz: number, sx: number, sy: number, sz: number) => { const o = add(g, mesh(G.box, m, bx, by, bz)); o.scale.set(sx, sy, sz); return o; }; + const c = (m: Material, cx: number, cy: number, cz: number, r: number, h: number) => { const o = add(g, mesh(G.cyl, m, cx, cy, cz, true)); o.scale.set(r, h, r); return o; }; + b(M.iron, 0, 0.045, 0, 0.42, 0.09, 0.42); + const flame = add(g, mesh(new TorusGeometry(0.13, 0.018, 6, 20), M.flame, 0, 0.1, 0)); flame.rotation.x = Math.PI / 2; + c(M.clay, 0, 0.2, 0, 0.25, 0.18); + const rim = add(g, mesh(new TorusGeometry(0.25, 0.025, 6, 24), M.claySoft, 0, 0.29, 0)); rim.rotation.x = Math.PI / 2; + c(M.stew, 0, 0.28, 0, 0.23, 0.01); + for (const [m, bx, bz, r] of [[M.tofu, 0.1, 0.05, 0.07], [M.tofu, -0.08, 0.1, 0.07], [M.cabbage, -0.12, -0.06, 0.09], [M.cabbage, 0.04, -0.13, 0.08], + [M.carrot, 0.13, -0.08, 0.05], [M.tuna, -0.02, 0.02, 0.09], [M.mush, 0.08, 0.14, 0.06], [M.mush, -0.15, 0.06, 0.05]] as const) b(m, bx, 0.295, bz, r, 0.035, r * 0.8).rotation.y = bx * 9; + return g; +} +/** A bowl of chanko on its little plate, at its foot's middle (for the belt). */ +function bowlModel() { + const g = new Group(); + const plate = add(g, mesh(G.cyl, M.bowl, 0, 0.008, 0)); plate.scale.set(0.1, 0.016, 0.1); + const b = add(g, mesh(new CylinderGeometry(0.08, 0.05, 0.07, 12), M.bowl, 0, 0.05, 0, true)); + b.castShadow = true; + const s = add(g, mesh(G.cyl, M.stew, 0, 0.083, 0)); s.scale.set(0.07, 0.005, 0.07); + const tofu = add(g, mesh(G.box, M.tofu, 0.02, 0.088, 0.01)); tofu.scale.set(0.035, 0.02, 0.035); + const leaf = add(g, mesh(G.box, M.cabbage, -0.025, 0.088, -0.01)); leaf.scale.set(0.04, 0.012, 0.03); + return g; +} +/** A stool, its top `h` up. */ +function stool(p: Object3D, x: number, z: number, h: number) { cyl(p, M.woodDark, x, h, z, 0.17, 0.05); cyl(p, M.iron, x, h / 2, z, 0.03, h); } + +// ---------- the belt ---------- +/** The belt's path: up out of the slot in the counter's top (from under the floor), onto the loop, then round it (west + * along its back, along its front, and back up its east end to where it came on), and its length along the way. */ +const TOP = K.h + 0.065, LX0 = K.x0 + 0.2, LX1 = K.x1 - 0.2, LZ0 = K.z0 + 0.2, LZ1 = K.z1 - 0.2; +const MOUTH = V(RISE.x, CY + TOP - 0.3, RISE.z), FOOT = V(RISE.x, CY + TOP, LZ0); +const LOOP = [FOOT, V(LX0, CY + TOP, LZ0), V(LX0, CY + TOP, LZ1), V(LX1, CY + TOP, LZ1), V(LX1, CY + TOP, LZ0), FOOT]; +const CHUTE = MOUTH.distanceTo(FOOT); +const LAP = LOOP.slice(1).reduce((s, q, i) => s + q.distanceTo(LOOP[i]), 0); +/** Where a bowl `d` along the belt is: down the chute, then round and round. */ +function along(d: number, out: Vector3) { + if (d < CHUTE) return out.copy(MOUTH).lerp(FOOT, d / CHUTE); + let r = (d - CHUTE) % LAP; + for (let i = 1; i < LOOP.length; i++) { + const l = LOOP[i].distanceTo(LOOP[i - 1]); + if (r <= l) return out.copy(LOOP[i - 1]).lerp(LOOP[i], r / l); + r -= l; + } + return out.copy(FOOT); +} +/** How fast the belt runs (metres a second), how close the bowls ride, and how many laps one goes round before someone + * at a table's had it sent over. */ +const BELT_V = 0.32, GAP = 0.34, LAPS = 2; + +// ---------- the diners ---------- +interface Seat { x: number; z: number; face: number; top: number; counter: boolean } +const SEATS: Seat[] = [ + ...STOOLS.map(s => ({ x: s.x, z: s.z, face: Math.PI, top: 0.65, counter: true })), + ...TABLES.flatMap(t => [ + { x: t.x - TABLE_SEAT, z: t.z, face: Math.PI / 2, top: 0.48, counter: false }, + { x: t.x + TABLE_SEAT, z: t.z, face: -Math.PI / 2, top: 0.48, counter: false }, + ]), +]; +/** The order the seats fill in: a couple at the counter, then the hall's table, then down the wing, the rest after. */ +const ORDER = [1, 3, 5, 6, 0, 2, 4, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20]; +interface Diner { p: Person; seat: Seat; eat: number; t: number } + +interface Built { + diners: Diner[]; mama: Hood; bowls: { o: Group; d: number }[]; looks: Group[]; + board: Mesh; boards: [Material, Material]; counter: Mesh; counters: [Material, Material]; + stallB: Group; tapeWhole: Group; tapeTorn: Group; bulb: Mesh; bulbOn: Material; bulbOff: Material; leak: Mesh; + + tables: { g: Group; set: Group }[]; stools: Group; noren: Mesh; norens: Material[]; shopNoren: Mesh; shopNorens: Material[]; ruin: Ruin; + says: ReturnType; opened: boolean; + /** Where its close-up looks: the middle of the hall and its wing. */ + middle: Object3D; +} +/** What she says as she opens. */ +export const OPENING = 'Open for business, dear.', WAITING = 'Waiting on your belt, dear.'; +let B: Built | null = null; + +function build(): Built { + const base = add(resto, new Group()), still = add(base, new Group()); + const looks = [0, 1, 2].map(() => { const g = add(resto, new Group()); g.visible = false; return g; }); + const [, l2, l3] = looks; + + // ---- the front door, from the arena: a noren across it, a red lantern either side ---- + const DOOR = { x0: -1.9, x1: -0.7 }, DX = (DOOR.x0 + DOOR.x1) / 2; + // (three of it: torn while it's a ruin, patched with a paper OPEN sign as it reopens, then whole) + const norenOf = (torn: boolean, open: boolean) => (c: CanvasRenderingContext2D, w: number, h: number) => { + const n = 3, sw = w / n; + for (let i = 0; i < n; i++) { + c.fillStyle = '#23305A'; + if (!torn) { c.fillRect(i * sw + 3, 0, sw - 6, h); continue; } + // a ragged hem, one strip half gone + const end = i === 1 ? h * 0.45 : h * (0.78 + 0.08 * i); + c.beginPath(); c.moveTo(i * sw + 3, 0); c.lineTo(i * sw + sw - 3, 0); + for (let k = 0; k <= 6; k++) c.lineTo(i * sw + sw - 3 - k * (sw - 6) / 6, end - ((k * 7 + i * 3) % 5) * h * 0.035); + c.closePath(); c.fill(); + } + c.fillStyle = '#1A2444'; c.fillRect(0, 0, w, h * 0.14); + c.fillStyle = torn ? '#9A9488' : '#F2EEE2'; c.textAlign = 'center'; c.textBaseline = 'middle'; c.font = `800 ${Math.round(h * 0.4)}px ${MINCHO}`; + c.fillText('ใกใ‚ƒใ‚“ใ“', w / 2, h * 0.6); + if (open) { + c.save(); c.translate(w * 0.72, h * 0.72); c.rotate(-0.12); + c.fillStyle = '#F4EEDC'; c.fillRect(-w * 0.16, -h * 0.13, w * 0.32, h * 0.26); + c.fillStyle = '#B0202E'; c.font = `800 ${Math.round(h * 0.16)}px ${FONT}`; c.fillText('OPEN', 0, 2); + c.restore(); + } + }; + const NW = DOOR.x1 - DOOR.x0 + 0.1; + const noren = sign(base, DX, 1.68, RESTO.z0 - 0.02, NW, 0.62, 0, norenOf(false, false)); + const norens = [norenOf(true, false), norenOf(true, true), null].map(d => d ? new MeshLambertMaterial({ map: canvasTex(Math.round(NW * 220), Math.round(0.62 * 220), d).tex, side: DoubleSide, alphaTest: 0.5 }) : noren.material as Material); + for (const x of [DOOR.x0 - 0.35, DOOR.x1 + 0.35]) lantern(base, M.red, x, 1.75, RESTO.z0 + 0.12, 0.25); + // ---- the cookware shop's back door, from the dining wing: a long noren, the shop's own (้“ๅ…ท, tools), on the wing's + // side of its lintel; in rags, faded and torn, while she's shut, and whole once she's open ---- + const SD = DOORS.find(d => d.a === 'wing' && d.b === 'craft')!, SW = SD.x1 - SD.x0 + 0.5, SH = 1.4; + const shopNorenOf = (torn: boolean) => (c: CanvasRenderingContext2D, w: number, h: number) => { + const n = 3, sw = w / n; + for (let i = 0; i < n; i++) { + c.fillStyle = torn ? '#3B4363' : '#23305A'; + if (!torn) { c.fillRect(i * sw + 3, 0, sw - 6, h); continue; } + // a ragged hem, each strip torn off at its own length, the middle one shortest + const end = h * [0.82, 0.5, 0.68][i]; + c.beginPath(); c.moveTo(i * sw + 3, 0); c.lineTo(i * sw + sw - 3, 0); + for (let k = 0; k <= 7; k++) c.lineTo(i * sw + sw - 3 - k * (sw - 6) / 7, end - ((k * 5 + i * 3) % 4) * h * 0.04); + c.closePath(); c.fill(); + } + c.fillStyle = torn ? '#2A3048' : '#1A2444'; c.fillRect(0, 0, w, h * 0.08); + if (torn) { + // a hole worn through the first strip, and a stain down the last + c.clearRect(sw * 0.35, h * 0.5, sw * 0.25, h * 0.08); + c.fillStyle = 'rgba(40, 30, 20, 0.45)'; c.fillRect(sw * 2.3, h * 0.15, sw * 0.35, h * 0.35); + } + c.fillStyle = torn ? '#8E8A80' : '#F4F1EA'; c.textAlign = 'center'; c.textBaseline = 'middle'; c.font = `800 ${Math.round(sw * 0.62)}px ${FONT}`; + c.fillText('้“', sw / 2, h * 0.3); c.fillText('ๅ…ท', sw * 2.5, h * 0.3); + c.lineWidth = sw * 0.07; c.strokeStyle = c.fillStyle; c.beginPath(); c.arc(w / 2, h * 0.24, sw * 0.17, 0, Math.PI * 2); c.stroke(); + }; + const shopNorens = [true, false].map(t => new MeshLambertMaterial({ map: canvasTex(Math.round(SW * 160), Math.round(SH * 160), shopNorenOf(t)).tex, side: DoubleSide, alphaTest: 0.5 })); + const shopNoren = plane(base, shopNorens[0], (SD.x0 + SD.x1) / 2, 1.93 - SH / 2, (SD.z0 + SD.z1) / 2 + 0.15, SW, SH); + // ---- the back wall behind Mama: a panel over the arena's low wall, the name board on it ---- + const BACK = { x0: K.x0 - 0.1, x1: RISE.x - 0.32 }, BZ = ARENA.z1 + 0.1; + box(still, M.wood, (BACK.x0 + BACK.x1) / 2, WALL + 0.4, BZ, BACK.x1 - BACK.x0, 0.8, 0.1); + box(still, M.woodDark, (BACK.x0 + BACK.x1) / 2, WALL + 0.83, BZ, BACK.x1 - BACK.x0 + 0.06, 0.06, 0.16); + const nameBoard = (lacquer: boolean) => (c: CanvasRenderingContext2D, w: number, h: number) => { + c.fillStyle = lacquer ? '#1A0E0A' : '#5A3422'; c.fillRect(0, 0, w, h); + c.strokeStyle = lacquer ? '#D2AA4A' : '#C9A23A'; c.lineWidth = lacquer ? 5 : 9; c.strokeRect(8, 8, w - 16, h - 16); + c.fillStyle = lacquer ? '#E8C46A' : '#F2E2B0'; c.textAlign = 'center'; c.textBaseline = 'middle'; + c.font = `800 ${Math.round(h * 0.4)}px ${FONT}`; c.fillText('HONEST CHANKO', w / 2, h * 0.44); + c.fillStyle = lacquer ? '#C9A23A' : '#E0B860'; c.font = `700 ${Math.round(h * 0.15)}px ${FONT}`; c.fillText("MAMA MORAY'S ยท SINCE YESTERDAY", w / 2, h * 0.79); + }; + const BW = 1.65, board = sign(base, (K.x0 + RISE.x - 0.32) / 2, WALL + 0.42, BZ + 0.066, BW, 0.48, 0, nameBoard(false), 300); + const boards: [Material, Material] = [board.material as Material, new MeshLambertMaterial({ map: canvasTex(Math.round(BW * 300), 144, nameBoard(true)).tex })]; + // the menu: a chalkboard on its stand in the corner by the door + const menu = sign(base, RESTO.x0 + 0.42, 0.62, RESTO.z0 + 0.35, 0.6, 0.78, 0.55, (c, w, h) => { + c.fillStyle = '#1F2A24'; c.fillRect(0, 0, w, h); c.strokeStyle = '#6A4630'; c.lineWidth = 12; c.strokeRect(0, 0, w, h); + c.fillStyle = '#E8E4D8'; c.textAlign = 'center'; c.font = `800 ${Math.round(h * 0.13)}px ${FONT}`; c.fillText('MENU', w / 2, h * 0.2); + c.font = `600 ${Math.round(h * 0.085)}px ${FONT}`; ['Chanko', 'Chanko', 'Big chanko', 'Ask Mama'].forEach((t, i) => c.fillText(t, w / 2, h * (0.4 + i * 0.15))); + }); + menu.rotation.x = -0.12; + for (const s of [-1, 1]) { const leg = box(still, M.woodDark, RESTO.x0 + 0.42 + s * 0.26 * Math.cos(0.55), 0.5, RESTO.z0 + 0.3 - s * 0.26 * Math.sin(0.55), 0.04, 1.0, 0.04); leg.rotation.x = 0.12; } + + // ---- the counter, the belt round its top, and the slot the belt comes up through ---- + const counter = box(base, M.wood, (K.x0 + K.x1) / 2, K.h / 2, (K.z0 + K.z1) / 2, K.x1 - K.x0, K.h, K.z1 - K.z0); + const counters: [Material, Material] = [M.wood, M.lacquer]; + box(still, M.top, (K.x0 + K.x1) / 2, K.h + 0.02, (K.z0 + K.z1) / 2, K.x1 - K.x0 + 0.12, 0.04, K.z1 - K.z0 + 0.12); + // (a foot rail along its front, for the stools) + box(still, M.brass, (K.x0 + K.x1) / 2, 0.22, K.z1 + 0.09, K.x1 - K.x0 - 0.2, 0.03, 0.03, false); + const belt = new MeshLambertMaterial({ map: slats(12, 1) }); + const run = (a: Vector3, b: Vector3) => { + const mid = a.clone().lerp(b, 0.5), len = a.distanceTo(b), alongX = Math.abs(b.x - a.x) > Math.abs(b.z - a.z); + const o = box(still, belt, mid.x, mid.y - CY - 0.02, mid.z, alongX ? len + 0.26 : 0.26, 0.03, alongX ? 0.26 : len + 0.26, false); + for (const s of [-1, 1]) box(still, M.steelDark, mid.x + (alongX ? 0 : s * 0.15), mid.y - CY, mid.z + (alongX ? s * 0.15 : 0), alongX ? len + 0.3 : 0.025, 0.05, alongX ? 0.025 : len + 0.3, false); + return o; + }; + for (let i = 1; i < LOOP.length - 1; i++) run(LOOP[i - 1], LOOP[i]); + // the slot in the counter's top the bowls come up through, a steel rim round it + box(still, 0x0B0C0D, RISE.x, K.h + 0.045, RISE.z, 0.24, 0.01, 0.26, false); + for (const s of [-1, 1]) { box(still, M.steel, RISE.x + s * 0.14, K.h + 0.05, RISE.z, 0.03, 0.02, 0.32, false); box(still, M.steel, RISE.x, K.h + 0.05, RISE.z + s * 0.15, 0.31, 0.02, 0.03, false); } + // on the counter's inside: her till, a stack of clean bowls, a pot of her own + box(still, M.brass, TILL.x, K.h + 0.16, TILL.z, 0.34, 0.24, 0.26); + box(still, M.brass, TILL.x, K.h + 0.31, TILL.z - 0.03, 0.3, 0.06, 0.18).rotation.x = -0.4; + const NOTES = lam(0x7AA65E); + for (let i = 0; i < 5; i++) { const n = box(still, NOTES, TILL.x - 0.08 + i * 0.04, K.h + 0.3 + (i % 2) * 0.02, TILL.z + 0.1, 0.06, 0.012, 0.12, false); n.rotation.set(0.5, 0, (i - 2) * 0.2); } + for (let i = 0; i < 6; i++) cyl(still, M.bowl, LX0 + 0.45, K.h + 0.07 + i * 0.045, TILL.z, 0.08 - i * 0.002, 0.05); + nabe(base, LX1 - 0.55, K.h + 0.04, TILL.z, 0.6); + // Mama Moray, behind her counter + const mm = mama(); mm.position.set(MAMA.x, CY, MAMA.z); settle(mm); base.add(mm); + // stools along the front + const stools = add(base, new Group()); + for (const s of STOOLS) stool(stools, s.x, s.z, 0.62); + + // ---- the tables: each its own, with what's set out on it (a nabe and two bowls) ---- + const tables = TABLES.map(t => { + const g = add(base, new Group()), set = add(g, new Group()); + cyl(g, M.woodDark, t.x, 0.36, t.z, 0.06, 0.72); cyl(g, M.woodDark, t.x, 0.02, t.z, 0.24, 0.04); + cyl(g, M.top, t.x, 0.73, t.z, 0.42, 0.04); + nabe(set, t.x, 0.75, t.z, 0.72); + for (const s of [-1, 1]) { + stool(g, t.x + s * TABLE_SEAT, t.z, 0.45); + const b = bowlModel(); b.position.set(t.x + s * 0.27, CY + 0.75, t.z + 0.06); set.add(b); + } + // (a paper lantern over each) + lantern(base, M.paper, t.x, 2.25, t.z, 0.5); + return { g, set }; + }); + for (const x of [0.4, 2.4]) lantern(base, M.paper, x, 2.35, (K.z0 + K.z1) / 2, 0.5); + + // ---- the restroom ---- + buildLoo(base, still); + const stallB = add(base, new Group()); stallB.position.set(STALLS.x, CY, STALL_DOOR.z0); + { + const W = STALL_DOOR.z1 - STALL_DOOR.z0; + const d = add(stallB, mesh(G.box, M.teal, 0, 0.8, W / 2, true)); d.scale.set(0.04, 1.25, W - 0.02); + // (the group's at the floor already: its pieces' heights are its own) + const oo = sign(stallB, 0.025, 1.12 - CY, W / 2, 0.7, 0.26, Math.PI / 2, (c, w, h) => { + c.fillStyle = '#F2EEE2'; c.fillRect(0, 0, w, h); + c.fillStyle = '#B0202E'; c.textAlign = 'center'; c.textBaseline = 'middle'; c.font = `800 ${Math.round(h * 0.5)}px ${FONT}`; c.fillText('OUT OF ORDER', w / 2, h / 2 + 2); + }); + (oo.material as MeshLambertMaterial).emissive.setHex(0x404040); + box(stallB, M.steel, 0.04, 0.95 - CY, W - 0.08, 0.04, 0.12, 0.03, false); + } + const T = tapeMat(), tapeWhole = add(base, new Group()), tapeTorn = add(base, new Group()); + { + const x = STALLS.x + 0.06, z0 = STALLS.b.z0 + 0.05, z1 = STALLS.b.z1 - 0.05, len = Math.hypot(z1 - z0, 0.6); + for (const s of [-1, 1]) { + const strip = add(tapeWhole, new Mesh(new PlaneGeometry(len, 0.07), T)); + strip.position.set(x + (s + 1) * 0.004, CY + 0.95, (z0 + z1) / 2); strip.rotation.set(0, -Math.PI / 2, s * Math.atan2(0.6, z1 - z0)); + } + // torn away: a length hanging off the front's north end, and a scrap off its south end + const hang = add(tapeTorn, new Mesh(new PlaneGeometry(0.9, 0.07), T)); + hang.position.set(x, CY + 0.78, z0 + 0.08); hang.rotation.set(0, -Math.PI / 2, 1.35); + const scrap = add(tapeTorn, new Mesh(new PlaneGeometry(0.3, 0.07), T)); + scrap.position.set(x, CY + 1.12, z1 - 0.08); scrap.rotation.set(0, -Math.PI / 2, -1.1); + } + // the bare bulb over the out-of-order stall, on its wire + const bulbOn = new MeshLambertMaterial({ color: 0xFFF2C0, emissive: 0xFFD27A }), bulbOff = lam(0x6A6450); + const BX = (LOO.x0 + STALLS.x) / 2, BZ2 = (CLUB_DOOR.z0 + CLUB_DOOR.z1) / 2; + cyl(still, 0x1B1A1E, BX, 2.75, BZ2, 0.008, 0.7, false); + cyl(still, 0x2A2A2E, BX, 2.38, BZ2, 0.025, 0.05, false); + const bulb = ball(base, bulbOn, BX, 2.29, BZ2, 0.055, 0.07, 0.055); + // a pink light leaking out under the stall's back, once there's something behind it + const leak = add(base, new Mesh(new PlaneGeometry(0.16, CLUB_DOOR.z1 - CLUB_DOOR.z0 - 0.1), new MeshBasicMaterial({ color: 0xFF5FA8, transparent: true, opacity: 0.6, blending: AdditiveBlending, depthWrite: false }))); + leak.rotation.x = -Math.PI / 2; leak.position.set(CLUB_DOOR.x + 0.14, CY + 0.014, BZ2); leak.visible = false; + + // ---- level 3, Mama's touch: photos, red lanterns strung along the walls, checked cloths ---- + const photo = (p: Object3D, x: number, y: number, z: number, ry: number, w: number, h: number, kind: number) => sign(p, x, y, z, w, h, ry, (c, cw, ch) => { + c.fillStyle = '#2A1A10'; c.fillRect(0, 0, cw, ch); + c.fillStyle = kind === 2 ? '#C8B48C' : '#B8B0A0'; c.fillRect(cw * 0.1, ch * 0.08, cw * 0.8, ch * 0.84); + c.fillStyle = 'rgba(40,30,20,.75)'; + if (kind === 2) { + // the family's portrait: Mama in the middle, two of hers either side + for (const [px, r] of [[0.5, 0.17], [0.25, 0.12], [0.75, 0.12]]) { c.beginPath(); c.ellipse(cw * px, ch * 0.42, cw * r * 0.6, ch * r, 0, 0, Math.PI * 2); c.fill(); c.fillRect(cw * (px - r * 0.8), ch * 0.55, cw * r * 1.6, ch * 0.32); } + } else { + // two wrestlers locked at the middle of a ring + c.beginPath(); c.arc(cw * 0.5, ch * 0.78, cw * 0.36, Math.PI, 0); c.fill(); + for (const s of [-1, 1]) { c.beginPath(); c.ellipse(cw * (0.5 + s * 0.13), ch * 0.5, cw * 0.14, ch * 0.2, s * 0.4, 0, Math.PI * 2); c.fill(); c.beginPath(); c.arc(cw * (0.5 + s * 0.2), ch * 0.27, cw * 0.06, 0, Math.PI * 2); c.fill(); } + } + }, 160); + photo(l2, K.x0 - 0.05, WALL + 0.4, BZ + 0.066, 0, 0.26, 0.34, 0); + photo(l2, RISE.x - 0.47, WALL + 0.4, BZ + 0.066, 0, 0.22, 0.3, 1); + photo(l2, RESTO.x0 - 0.02, 0.72, 9.9, Math.PI / 2, 0.6, 0.42, 2); + for (const [i, x] of [4.6, 7.6, 8.6, 9.8].entries()) photo(l2, x, 0.72, WING.z0 - 0.222, 0, 0.36, 0.3, i % 2); + const strings: [Vector3, Vector3][] = [[V(CRAFT.x0 - 0.2, CY + 1.85, WING.z0 - 0.28), V(WING.x1 - 0.1, CY + 1.85, WING.z0 - 0.28)], [V(RESTO.x0 - 0.1, CY + 1.85, RESTO.z0 + 0.2), V(RESTO.x0 - 0.1, CY + 1.85, RESTO.z1 - 0.1)]]; + for (const [a, b] of strings) { + for (const q of [a, b]) cyl(l2, M.woodDark, q.x, (q.y - CY + WALL) / 2, q.z, 0.025, q.y - CY - WALL + 0.1, false); + const pts = sag(a, b, 0.18, 12); + wire(l2, pts, 0.008, 0x1B1A1E); + for (let i = 1; i < 12; i += 1.5) { const q = sag(a, b, 0.18, 24)[Math.round(i * 2)]; lantern(l2, M.red, q.x, q.y - CY - 0.24, q.z, 0.05); } + } + for (const t of TABLES) { const c = cyl(l2, new MeshLambertMaterial({ map: gingham(3, 1) }), t.x, 0.735, t.z, 0.47, 0.06); c.castShadow = false; } + + // ---- level 6, the Family's front: dark panelling, purple banners, the lacquered board and counter, the curtain ---- + const panel = (x0: number, x1: number, z0: number, z1: number) => { + const alongX = Math.abs(x1 - x0) > Math.abs(z1 - z0), len = alongX ? x1 - x0 : z1 - z0; + plane(l3, new MeshLambertMaterial({ map: panelling(Math.max(1, Math.round(len / 1.2)), 1) }), (x0 + x1) / 2, (WALL - 0.16) / 2 + 0.16, (z0 + z1) / 2, len, WALL - 0.16, alongX ? 0 : Math.PI / 2); + }; + panel(RESTO.x0, DOOR.x0 - 0.1, BZ + 0.075, BZ + 0.075); panel(DOOR.x1 + 0.1, RISE.x - 0.32, BZ + 0.075, BZ + 0.075); + panel(RESTO.x0 - 0.032, RESTO.x0 - 0.032, RESTO.z0, RESTO.z1); + // (either side of the doorway to the cookware shop) + panel(CRAFT.x0 - 0.2, 5.6, WING.z0 - 0.233, WING.z0 - 0.233); panel(7.0, WING.x1, WING.z0 - 0.233, WING.z0 - 0.233); + plane(l3, new MeshLambertMaterial({ map: panelling(3, 1) }), (BACK.x0 + BACK.x1) / 2, WALL + 0.4, BZ + 0.058, BACK.x1 - BACK.x0, 0.8); + const fishBanner = new MeshLambertMaterial({ + side: DoubleSide, map: canvasTex(48, 192, (c, w, h) => { + c.fillStyle = '#5B2A86'; c.fillRect(0, 0, w, h); + c.fillStyle = '#D2AA4A'; c.fillRect(0, 0, w, 10); c.fillRect(0, h - 8, w, 8); c.fillRect(4, 10, 2, h - 18); c.fillRect(w - 6, 10, 2, h - 18); + c.beginPath(); c.ellipse(w / 2, h * 0.5, w * 0.24, h * 0.13, 0, 0, Math.PI * 2); c.fill(); + c.beginPath(); c.moveTo(w / 2, h * 0.62); c.lineTo(w * 0.3, h * 0.72); c.lineTo(w * 0.7, h * 0.72); c.fill(); + }).tex, + }); + const banner = (x: number, z: number, ry: number, off: [number, number]) => { + cyl(l3, M.woodDark, x, (WALL + 2.35) / 2, z, 0.025, 2.35 - WALL, false); + box(l3, M.gold, x + off[0] / 2, 2.3, z + off[1] / 2, Math.abs(off[0]) + 0.03, 0.03, Math.abs(off[1]) + 0.03, false); + const b = add(l3, new Mesh(new PlaneGeometry(0.3, 0.95), fishBanner)); b.position.set(x + off[0], CY + 1.8, z + off[1]); b.rotation.y = ry; + }; + for (const x of [5.0, 7.2, 9.4]) banner(x, WING.z0 - 0.3, 0, [0, 0.16]); + for (const z of [8.7, 11.0]) banner(RESTO.x0 - 0.1, z, Math.PI / 2, [0.16, 0]); + box(l3, M.gold, (K.x0 + K.x1) / 2, K.h - 0.06, K.z1 + 0.005, K.x1 - K.x0, 0.02, 0.01, false); + box(l3, M.gold, (BACK.x0 + BACK.x1) / 2, WALL + 0.06, BZ + 0.06, BACK.x1 - BACK.x0, 0.015, 0.01, false); + + // ---- the diners: your fans, in from the arena, as many as there's been chanko lately ---- + const diners = ORDER.slice(0, 14).map((si, i): Diner => { + const p = belowGround(crowdModel(crowdStyle(300_000 + i), i % 2 ? 'hooligan' : 'yakuza')); + p.restyle(0xFF6B4A, p.style); + const seat = SEATS[si]; + p.position.set(seat.x, CY + seat.top - 0.3, seat.z); p.rotation.y = seat.face; + for (const l of p.legs) l.rotation.x = -1.45; + p.visible = false; base.add(p); + return { p, seat, eat: seat.counter ? 0 : 1e9, t: i * 0.7 }; + }); + + // the bowls riding the belt + const bowls = Array.from({ length: 16 }, () => { const o = bowlModel(); o.visible = false; base.add(o); return { o, d: -1 }; }); + flatten(still); + const ruin = buildRuin(base); + // what she says, over her + const says = bubble(base, 2.2); says.sprite.position.set(MAMA.x, CY + 2.35, MAMA.z); + const middle = add(base, new Group()); middle.position.set(3.2, CY, 12.0); + return { middle, says, opened: false, diners, mama: mm, bowls, looks, board, boards, counter, counters, stallB, tapeWhole, tapeTorn, bulb, bulbOn, bulbOff, leak, tables, stools, noren, norens, shopNoren, shopNorens, ruin }; +} + +// ---------- before it opens (Word on the street 0 to 2) ---------- +/** What's out front before Mama Moray's opens, and as it does, by Word on the street's level: at 0 a wreck (its tables + * and stools knocked over where they stood, broken planks along the walls, boards up out of the floor, cobwebs in the + * corners, the restroom's floor cracked); at 1 being cleared (the tables stood back up, the rubble in neat stacks by + * the walls, a broom and a bucket, new pale boards in the floor and nailed over the walls, tape round the broken + * tables); at 2 open on a shoestring (crates for a couple of tables, bare bulbs on wires, the new boards and the tape + * still there). Dust sheets cover her counter till it opens. Nothing of it on the walkways or in front of a door. */ +interface Ruin { wreck: Group; clearing: Group; patched: Group; mended: Group; closed: Group; crates: number[] } +/** The tables that are crates when it reopens. */ +const CRATES = [3, 5, 7]; +function buildRuin(base: Object3D): Ruin { + const [wreck, clearing, patched, mended, closed] = [0, 0, 0, 0, 0].map(() => { const g = add(base, new Group()); g.visible = false; return g; }); + const PLANK = lam(0xB89A72), PLASTER = lam(0x9C968A), PALE = lam(0xD2B088), SHEET = lam(0xD4CFC4), HOLE = lam(0x140D08), CRATE = lam(0xA27848); + const floor = (p: Object3D, m: Material, x: number, z: number, w: number, d: number, ry = 0) => { + const o = add(p, new Mesh(new PlaneGeometry(w, d), m)); o.rotation.set(-Math.PI / 2, 0, ry); o.position.set(x, CY + 0.022, z); o.receiveShadow = true; return o; + }; + // the tables and stools knocked over where they stood, and the counter's stools; each on the floor, a soft shadow + // under it (contact.ts) + const smudges: Record<'wreck' | 'clearing' | 'patched', Smudge[]> = { wreck: [], clearing: [], patched: [] }; + TABLES.forEach((t, i) => { + // (its top down on one edge, the other propped on its pedestal, fallen under it) + const s = i % 2 ? 1 : -1; + const top = cyl(wreck, M.top, t.x + 0.1 * s, 0.2, t.z, 0.42, 0.04); top.rotation.z = 0.45 * s; + const leg = cyl(wreck, M.woodDark, t.x + 0.42 * s, 0.06, t.z + 0.05, 0.06, 0.6); leg.rotation.x = Math.PI / 2; + smudges.wreck.push({ x: t.x + 0.12 * s, z: t.z, rx: 0.55, rz: 0.48 }); + for (const k of [-1, 1]) { + // (a stool on its side: its seat's rim and its little foot on the floor, the leg sloping between, turned any which + // way) + const st = add(wreck, new Group()), yaw = i * 1.7 + k * 0.9, TIP = 0.2; + st.position.set(t.x + k * TABLE_SEAT, CY + 0.15, t.z + (k === s ? 0.15 : -0.1)); st.rotation.order = 'YXZ'; st.rotation.set(Math.PI / 2 - TIP, yaw, 0); + cyl(st, M.woodDark, 0, 0.2 - CY, 0, 0.19, 0.07); cyl(st, M.iron, 0, -CY, 0, 0.03, 0.4); cyl(st, M.iron, 0, -0.2 - CY, 0, 0.11, 0.02); + smudges.wreck.push({ x: st.position.x, z: st.position.z, rx: 0.18, rz: 0.32, ry: yaw }); + } + }); + for (const [i, s] of STOOLS.entries()) if (i % 2 === 0) { + const st = cyl(wreck, M.woodDark, s.x + 0.1, 0.17, s.z, 0.17, 0.05); st.rotation.x = 1.4; + smudges.wreck.push({ x: s.x + 0.1, z: s.z, rx: 0.22, rz: 0.12 }); + } + // broken planks and rubble along the walls, at 0; stacked neat at 1 + const WALLS = [ + { x: RESTO.x0 + 0.3, z: 10.1, ry: Math.PI / 2 }, { x: RESTO.x0 + 0.3, z: 11.1, ry: Math.PI / 2 }, { x: -2.2, z: RESTO.z1 - 0.3, ry: 0 }, + { x: 2.2, z: WING.z1 - 0.3, ry: 0 }, { x: 4.8, z: WING.z1 - 0.3, ry: 0 }, { x: 7.6, z: WING.z1 - 0.3, ry: 0 }, { x: 9.9, z: WING.z1 - 0.3, ry: 0 }, + { x: WING.x1 - 0.3, z: 13.0, ry: Math.PI / 2 }, { x: WING.x1 - 0.3, z: 14.9, ry: Math.PI / 2 }, + ]; + // (twice what a real plank is, so it reads at the game's distance) + WALLS.forEach((w, i) => { + const ax = Math.cos(w.ry), az = -Math.sin(w.ry); + for (let k = 0; k < 5; k++) { + const p = box(wreck, k % 2 ? PLANK : M.woodDark, w.x + ax * (k - 2) * 0.2, 0.05 + (k % 3) * 0.05, w.z + az * (k - 2) * 0.2, 0.9 - k * 0.09, 0.07, 0.14); + p.rotation.set((k % 2) * 0.15, w.ry + (k - 2) * 0.55 + i, 0); + } + for (let k = 0; k < 3; k++) box(wreck, PLASTER, w.x + ax * (k - 1) * 0.3 + 0.1, 0.07, w.z + az * (k - 1) * 0.3 + 0.1, 0.22 - k * 0.04, 0.14 - k * 0.03, 0.18).rotation.y = i + k; + smudges.wreck.push({ x: w.x, z: w.z, rx: 0.65, rz: 0.4, ry: w.ry }); + }); + for (const w of [WALLS[1], WALLS[4], WALLS[8]]) { + for (let k = 0; k < 5; k++) box(clearing, k % 2 ? PLANK : M.woodDark, w.x, 0.04 + k * 0.07, w.z, 0.9, 0.07, 0.14).rotation.y = w.ry + Math.PI / 2 + (k % 2) * 0.06; + smudges.clearing.push({ x: w.x, z: w.z, rx: 0.2, rz: 0.55, ry: w.ry }); + } + // a broom against the wing's east wall, a bucket by it + const broom = add(clearing, new Group()); broom.position.set(WING.x1 - 0.2, CY, 12.8); broom.rotation.z = 0.22; + cyl(broom, 0xB89A6A, 0, 0.6 - CY, 0, 0.018, 1.2, false); box(broom, 0xC9A060, 0, 0.06 - CY, 0, 0.08, 0.12, 0.26); + cyl(clearing, M.steelDark, WING.x1 - 0.32, 0.13, 13.25, 0.13, 0.26); cyl(clearing, 0x2A3A44, WING.x1 - 0.32, 0.25, 13.25, 0.11, 0.01, false); + smudges.clearing.push({ x: WING.x1 - 0.32, z: 13.25, rx: 0.22 }); + // boards up out of the floor at 0 (dark gaps), new pale ones in their place from 1 + const GAPS = [{ x: -1.6, z: 10.95 }, { x: 5.6, z: 14.25 }, { x: 8.1, z: 13.95 }, { x: 3.7, z: 15.45 }]; + for (const g of GAPS) { floor(wreck, HOLE, g.x, g.z, 1.1, 0.16); floor(mended, PALE, g.x, g.z, 1.1, 0.16); floor(mended, PALE, g.x + 0.1, g.z + 0.17, 0.8, 0.15); } + // and pale boards nailed over the walls' broken stretches + for (const [x, z, ry] of [[RESTO.x0 + 0.04, 9.6, Math.PI / 2], [6.3, WING.z1 - 0.04, 0], [WING.x1 - 0.04, 14.0, Math.PI / 2]] as const) { + for (const k of [0, 1]) { const b = box(mended, PALE, x, 0.55 + k * 0.28, z, 0.75, 0.1, 0.03, false); b.rotation.set(0, ry, (k ? -1 : 1) * 0.18); } + } + // tape round the broken tables (1 and 2), and the crates in place of some (2) + TABLES.forEach((t, i) => { + if (i % 3 === 1) for (const a of [0.7, -0.7]) { const tp = box(mended, M.tape, t.x, 0.757, t.z, 0.78, 0.006, 0.06, false); tp.rotation.y = a; } + if (CRATES.includes(i)) { + box(patched, CRATE, t.x, 0.3, t.z, 0.62, 0.6, 0.62); + for (const k of [-1, 0, 1]) box(patched, M.woodDark, t.x, 0.15 + (k + 1) * 0.2, t.z + 0.315, 0.64, 0.025, 0.01, false); + for (const sd of [-1, 1]) stool(patched, t.x + sd * TABLE_SEAT, t.z, 0.4); + smudges.patched.push({ x: t.x, z: t.z, rx: 0.48 }); + } + }); + for (const [k, g] of [['wreck', wreck], ['clearing', clearing], ['patched', patched]] as const) g.add(contactShadows(smudges[k], CY + 0.03)); + // cobwebs in the corners + const web = new MeshLambertMaterial({ + side: DoubleSide, transparent: true, depthWrite: false, map: canvasTex(128, 128, (c, w) => { + // (fanned out from its top middle, where the corner is) + c.strokeStyle = 'rgba(240,240,236,.85)'; c.lineWidth = 3; + const fan = (t: number, q: number): [number, number] => [w / 2 + Math.cos(t) * q, Math.sin(t) * q]; + for (let a = 0; a <= 8; a++) { const t = a / 8 * Math.PI; c.beginPath(); c.moveTo(w / 2, 0); c.lineTo(...fan(t, w)); c.stroke(); } + for (let r = 14; r < w * 0.7; r += 14) { c.beginPath(); for (let a = 0; a <= 8; a++) { const t = a / 8 * Math.PI, q = r + ((a * 7) % 5); if (a) c.lineTo(...fan(t, q)); else c.moveTo(...fan(t, q)); } c.stroke(); } + }).tex, + }); + for (const [x, z, ry] of [[RESTO.x0, RESTO.z1, Math.PI * 0.75], [WING.x1, WING.z1, -Math.PI * 0.75], [WING.x1, WING.z0, -Math.PI * 0.25], [WING.x0, WING.z1, Math.PI * 0.75]] as const) { + const o = add(wreck, new Mesh(new PlaneGeometry(1.3, 0.9), web)); o.position.set(x + Math.sin(ry) * 0.45, CY + WALL - 0.4, z + Math.cos(ry) * 0.45); o.rotation.y = ry; + } + // the restroom's floor cracked, a few tiles gone + for (const [x, z, a] of [[-1.9, 13.3, 0.4], [-0.6, 14.6, -0.7], [-1.4, 15.6, 1.1]] as const) box(wreck, 0x2A2420, x, 0.02, z, 0.7, 0.005, 0.02, false).rotation.y = a; + for (const [x, z] of [[-1.15, 13.9], [-0.25, 15.3]] as const) floor(wreck, HOLE, x, z, 0.24, 0.24); + // bare bulbs on wires as it reopens + const BULB = new MeshLambertMaterial({ color: 0xFFF2C0, emissive: 0xFFD27A }); + for (const [x, z] of [[-1.0, 10.7], [1.8, 10.8], [3.6, 13.3], [6.3, 14.2], [8.8, 13.3], [5.0, 15.3]] as const) { + cyl(patched, 0x1B1A1E, x, 2.6, z, 0.008, 0.7, false); ball(patched, BULB, x, 2.22, z, 0.06, 0.08, 0.06); + } + // dust sheets over her counter while it's shut + box(closed, SHEET, (K.x0 + K.x1) / 2, K.h + 0.06, (K.z0 + K.z1) / 2, K.x1 - K.x0 + 0.16, 0.1, K.z1 - K.z0 + 0.16); + for (const [x, w] of [[K.x0 + 0.9, 1.6], [K.x1 - 0.8, 1.4]] as const) { const d = box(closed, SHEET, x, K.h - 0.28, K.z1 + 0.1, w, 0.62, 0.03); d.rotation.x = 0.06; } + return { wreck, clearing, patched, mended, closed, crates: CRATES }; +} + +/** The restroom: a tiled face along its north wall, a mirror and two basins on it, the WC sign by its door; the two + * stalls along its west wall, the first shut (someone's feet under it), the second out of order. */ +function buildLoo(base: Object3D, still: Object3D) { + const tiles = repeat(128, (c, w, h) => { + c.fillStyle = '#E8E6DE'; c.fillRect(0, 0, w, h); + c.fillStyle = 'rgba(70,80,70,.35)'; for (let k = 0; k <= 4; k++) { c.fillRect(k * w / 4 - 1, 0, 2, h); c.fillRect(0, k * h / 4 - 1, w, 2); } + c.strokeStyle = 'rgba(40,40,30,.5)'; c.lineWidth = 1.5; c.beginPath(); c.moveTo(w * 0.6, h * 0.25); c.lineTo(w * 0.68, h * 0.42); c.lineTo(w * 0.62, h * 0.5); c.stroke(); + c.fillStyle = '#2E8A7E'; c.fillRect(0, h - 10, w, 10); + }); + const FACE = LOO.z0 - 0.17, H = 1.75; + const face = add(still, new Mesh(new PlaneGeometry(LOO.x1 - LOO.x0, H), new MeshLambertMaterial({ map: tiles((LOO.x1 - LOO.x0) / 1.0, H / 1.0) }))); + face.position.set((LOO.x0 + LOO.x1) / 2, CY + H / 2, FACE); face.receiveShadow = true; + box(still, 0xE8E6DE, (LOO.x0 + LOO.x1) / 2, H / 2, FACE - 0.045, LOO.x1 - LOO.x0, H, 0.08); + box(still, 0x2E8A7E, (LOO.x0 + LOO.x1) / 2, H + 0.02, FACE - 0.03, LOO.x1 - LOO.x0 + 0.04, 0.04, 0.14); + // the mirror, over the basins + const mx = (BASINS[0].x + BASINS[1].x) / 2; + box(still, 0x8A8F94, mx, 1.38, FACE + 0.015, 1.42, 0.58, 0.02, false); + add(base, mesh(new PlaneGeometry(1.34, 0.5), new MeshLambertMaterial({ color: 0xA8C8D0, emissive: 0x1A2A30 }), mx, CY + 1.38, FACE + 0.03)); + for (const b of BASINS) { + box(still, M.porcelain, b.x, 0.82, b.z, 0.5, 0.12, 0.36); + cyl(still, M.porcelain, b.x, 0.4, b.z - 0.04, 0.07, 0.8); + box(still, M.steel, b.x, 0.94, b.z - 0.12, 0.03, 0.12, 0.03, false); + } + // the WC sign, on the wing's side by the door + sign(base, WING.x0 - 0.222, 0.78, LOO.z0 + 0.8, 0.36, 0.36, Math.PI / 2, (c, w, h) => { + c.fillStyle = '#2E7A78'; c.fillRect(0, 0, w, h); c.fillStyle = '#F2EEE2'; c.textAlign = 'center'; c.textBaseline = 'middle'; + c.font = `800 ${Math.round(h * 0.42)}px ${FONT}`; c.fillText('WC', w / 2, h / 2 + 3); + }); + // the stalls: their partitions, out from the west wall to their fronts, a gap under each + const SX = STALLS.x, PH = 1.3, PY = 0.15 + PH / 2, mid = (LOO.x0 + SX) / 2, deep = SX - LOO.x0; + for (const z of [STALLS.a.z1 + 0.015, STALLS.b.z1]) box(still, M.teal, mid, PY, z, deep, PH, 0.04); + // the first's front, its door shut; a pair of shoes under it + box(still, M.teal, SX, PY, (STALLS.a.z0 + STALLS.a.z1) / 2, 0.04, PH, STALLS.a.z1 - STALLS.a.z0); + box(still, M.steel, SX + 0.03, 0.95, (STALLS.a.z0 + STALLS.a.z1) / 2 + 0.3, 0.03, 0.1, 0.03, false); + for (const dz of [-0.09, 0.09]) box(still, 0x1A1A1E, SX - 0.22, 0.04, (STALLS.a.z0 + STALLS.a.z1) / 2 + dz, 0.24, 0.07, 0.11); + // the second's front, either side of its door + for (const [z0, z1] of [[STALLS.b.z0, STALL_DOOR.z0], [STALL_DOOR.z1, STALLS.b.z1]]) box(still, M.teal, SX, PY, (z0 + z1) / 2, 0.04, PH, z1 - z0); + // (a cardboard sign stuck on crooked, on the second's front) + const card = sign(base, SX + 0.03, 1.15, (STALL_DOOR.z1 + STALLS.b.z1) / 2, 0.42, 0.3, Math.PI / 2, (c, w, h) => { + c.fillStyle = '#C8A472'; c.fillRect(0, 0, w, h); + c.fillStyle = '#2A1A10'; c.textAlign = 'center'; c.textBaseline = 'middle'; c.font = `800 ${Math.round(h * 0.26)}px ${FONT}`; + c.fillText('OUT OF', w / 2, h * 0.34); c.fillText('ORDER!!', w / 2, h * 0.68); + }); + card.rotation.z = 0.18; (card.material as MeshLambertMaterial).emissive.setHex(0x302418); + // the toilets: the first's against the wall, the second's against its south side, taped across + const toilet = (x: number, z: number, ry: number, taped: boolean) => { + const g = add(base, new Group()); g.position.set(x, CY, z); g.rotation.y = ry; + const bowl = add(g, mesh(G.cyl, M.porcelain, 0, 0.21, 0.05, true)); bowl.scale.set(0.18, 0.42, 0.22); + add(g, mesh(G.cyl, 0xE8E4DA, 0, 0.43, 0.05)).scale.set(0.19, 0.03, 0.23); + const tank = add(g, mesh(G.box, M.porcelain, 0, 0.62, -0.17, true)); tank.scale.set(0.4, 0.4, 0.16); + if (taped) for (const s of [-1, 1]) { const t = add(g, mesh(G.box, M.tape, 0, 0.455, 0.05)); t.scale.set(0.5, 0.012, 0.06); t.rotation.y = s * 0.7; } + }; + toilet(LOO.x0 + 0.32, (STALLS.a.z0 + STALLS.a.z1) / 2, Math.PI / 2, false); + toilet(LOO.x0 + 0.62, STALLS.b.z1 - 0.25, Math.PI, true); +} + +// ---------- keeping it in step ---------- +let made = false, t = 0; +/** Bowls waiting to come down the chute, chips taken at the till since the last pop over it, how busy it's been lately + * (bowls, wearing off), and the look it's dressed in (-1: not yet). */ +let due = 0, sending = 0, sendT = 0, chips = 0, chipsT = 0, busy = 0, dressed = -1; +on.resto = (bowls, got) => { + // (a bowl down the belt for every few sold: ladled across from the pot onto it, under the floor to her counter) + sending = Math.min(sending + bowls / 4, 12); + chips += got; + busy += bowls; +}; +/** Where it is, for Word on the street's level: 0 a wreck (levels 0 and 1), 1 being cleared (2 to 4, a bit more each + * level: the rubble stacked, then the floor patched and the tables stood up, then the stools), 2 open on a shoestring + * (5, Mama's in: fight.ts' RESTO_OPENS), 3 on its looks; and that look: 0 the back-street joint (6), 1 Mama's touch + * (7), 2 the Family's front (8). */ +const stageOf = (n: number) => (n >= 6 ? 3 : n >= 5 ? 2 : n >= 2 ? 1 : 0); +const lookOf = (n: number) => (n >= 8 ? 2 : n >= 7 ? 1 : 0); +/** Her light's brightness, as made (none while it's a wreck, a little as it's cleared). */ +let LIGHT = -1; +const tmp = V(0, 0, 0); + +/** How it is, as drawn, for tests: how far it's come (stageOf), whether Mama's in, what she's saying, the diners in, + * the wreck, whether the bowl line's way on to her is laid, and the bowls on it for her. */ +export const restoShows = () => B && ({ + stage: stageOf(dressed), mama: B.mama.visible, says: B.says.text, diners: B.diners.filter(d => d.p.visible).length, + wreck: B.ruin.wreck.visible, belt: mamaLeg(), under: lineShows(true), +}); +/** Dresses it for Word on the street's level `n`: from a wreck to the Family's front. `up`: bought just now (not just + * loaded), so what's new pops in. */ +function dress(b: Built, n: number, up: boolean) { + const st = stageOf(n), look = lookOf(n), r = b.ruin; + const lit: Object3D[] = []; + const show = (o: Object3D, on: boolean) => { if (on && !o.visible && up) lit.push(o); o.visible = on; }; + // (being cleared, a step a level: the rubble stacked; then the floor patched and the tables up; then the stools) + const cleared = st === 1 ? n - 1 : st > 1 ? 3 : 0; + show(r.wreck, st === 0); show(r.clearing, st === 1); show(r.patched, st === 2); show(r.mended, cleared >= 2 && st <= 2); r.closed.visible = st < 2; + b.tables.forEach((t, i) => { show(t.g, cleared >= 2 && !(st === 2 && r.crates.includes(i))); t.set.visible = st >= 3; }); + show(b.stools, cleared >= 3); + b.mama.visible = st >= 2; + b.noren.material = b.norens[st < 2 ? 0 : st === 2 ? 1 : 2]; + b.shopNoren.material = b.shopNorens[st < 2 ? 0 : 1]; + b.board.rotation.z = st < 2 ? 0.1 : 0; + // (the lanterns dark till it opens; her light out while it's a wreck, a little more light in each step it's cleared) + M.paper.emissiveIntensity = st < 2 ? 0 : 0.9; M.red.emissiveIntensity = st < 2 ? 0 : 1; + M.paper.color.setHex(st < 2 ? 0x6E675A : 0xFFF1D6); M.red.color.setHex(st < 2 ? 0x5A2620 : 0xE0392B); + if (LIGHT < 0) LIGHT = lights[13].intensity; + lights[13].intensity = st === 0 ? 0 : st === 1 ? LIGHT * (0.15 + 0.15 * cleared) : LIGHT; + b.looks.forEach((g, i) => { show(g, st >= 3 && i <= look); }); + b.board.material = b.boards[st >= 3 && look >= 2 ? 1 : 0]; + b.counter.material = b.counters[st >= 3 && look >= 2 ? 1 : 0]; + // (and the moment she opens: a word from her; or, with no bowl line in yet to bring her any, she's waiting on it) + if (st >= 2 && !up) b.opened = F.slide; + if (up && st === 2 && !b.opened) { + if (F.slide) { b.opened = true; b.says.say(OPENING); } else b.says.say(WAITING); + } + // its own close-up, once the arena's is over (two goes, the arena first: cellarLooks.ts) + if (lit.length) spotNext = { on: [b.middle], t: 0, seen: false }; +} +/** The close-up waiting on the arena's: what changed. */ +let spotNext: { on: Object3D[]; t: number; seen: boolean } | null = null; +/** Not with a boss in, nor while a scene has the player (as for the arena's). */ +const free = () => !F.rush && !player.held && !staging(); + +/** Mama Moray's, every frame (stage 3): the belt and its bowls, the diners, Mama, the stall's tape and bulb, and the + * look. */ +export function updResto(dt: number) { + if (stage.n !== 3) return; + if (!made) { made = true; B = apart(build, dice(0x4E571)); } + const b = B!; + t += dt; + // how far it's come, as Word on the street's dressed the arena + if (mods.street !== dressed) { dress(b, mods.street, dressed >= 0 && mods.street > dressed); dressed = mods.street; } + // the bowls onto the bowl line, a few a second (her word, too, once it's in for her) + if (stageOf(dressed) >= 2 && !b.opened && F.slide) { b.opened = true; b.says.say(OPENING); } + sendT -= dt; + if (sending >= 1 && sendT <= 0) { sending -= 1; sendT = 0.35; lineToMama(() => { due += 1; }); } + // the bowls up out of the slot and round the counter: one in as there's room behind the last + busy *= Math.exp(-dt / 90); + const last = Math.min(...b.bowls.filter(w => w.d >= 0).map(w => w.d), Infinity); + if (due >= 1 && last > GAP) { const free = b.bowls.find(w => w.d < 0); if (free) { free.d = 0; free.o.visible = true; } due -= 1; } + due = Math.min(due, 8); + for (const w of b.bowls) { + if (w.d < 0) continue; + const was = w.d; + w.d += BELT_V * dt; + w.o.position.copy(along(w.d, tmp)); + // someone at the counter takes it as it passes (along its front), if they've finished their last + if (w.d > CHUTE) { + const at = (d: number) => along(d, V(0, 0, 0)); + const p0 = at(was), p1 = at(w.d); + const taker = Math.abs(p1.z - LZ1) < 0.01 && b.diners.find(d => d.p.visible && d.seat.counter && d.eat <= 0 && Math.abs(d.seat.x - p1.x) < 0.12 && Math.sign(d.seat.x - p0.x) !== Math.sign(d.seat.x - p1.x)); + if (taker) { taker.eat = 9; w.d = -1; w.o.visible = false; continue; } + } + if (w.d > CHUTE + LAP * LAPS) { w.d = -1; w.o.visible = false; } + } + // the diners, once it's open: as many in as it's been busy (a couple of regulars always), eating + const n = stageOf(mods.street) < 2 ? 0 : Math.min(b.diners.length, 2 + Math.round(busy / 8)); + b.diners.forEach((d, i) => { + const want = i < n; + if (want !== d.p.visible) { d.p.visible = want; if (want) popIn(d.p); } + if (!want) return; + if (!d.p.upToDate) { d.p.redress(); for (const l of d.p.legs) l.rotation.x = -1.45; } + d.t += dt; d.eat -= dt; + const eating = d.eat > 0 && (!d.seat.counter || d.eat < 8.5); + d.p.arms[0].rotation.x = eating ? -1.9 + Math.sin(d.t * 9) * 0.3 : -0.7; + d.p.arms[1].rotation.x = -0.7; + d.p.body.scale.y = 1 + Math.sin(d.t * 2.4) * 0.012; + }); + // Mama: breathing, an eye on whoever's at her counter + const pp = player.g.position; + b.mama.watch = level.down && Math.abs(pp.x - MAMA.x) < 3 && pp.z > RESTO.z0 && pp.z < RESTO.z1 + 0.5 ? pp : null; + b.mama.update(dt); + b.says.upd(dt); + updRestoSound(dt, stageOf(mods.street) >= 2, Math.min(1, busy / 60)); + if (spotNext) { + spotNext.t += dt; + if (spotBusy()) spotNext.seen = true; + else if (spotNext.seen || spotNext.t > 1) { if (free()) spotlight(spotNext.on, { close: 0.72, when: free }); spotNext = null; } + } + // chips over the till, a second's worth at a time + chipsT -= dt; + if (chipsT <= 0) { + chipsT = 1; + if (chips > 0 && level.down) popText(`+๐Ÿก ${chips < 10 ? chips.toFixed(1).replace(/\.0$/, '') : Math.round(chips)}`, { x: TILL.x, y: CY + K.h + 0.6, z: TILL.z }); + chips = 0; + } + // the out-of-order stall: taped across till the scene tears it (its door swung wide then), its bulb flickering when + // southDoor.ts says, and the pink light under its back once the second boss is beaten (not while it's open) + const torn = F.den.way || stallDoor.torn; + b.tapeWhole.visible = !torn; b.tapeTorn.visible = torn; + stallTape.up = !torn; + const swing = torn ? 1.25 : 0.55; + b.stallB.rotation.y += (swing - b.stallB.rotation.y) * Math.min(1, dt * 5); + const lit = stallDoor.flicker <= 0 || Math.floor(t * 14) % 3 === 0 || Math.sin(t * 61) > 0.6; + b.bulb.material = lit ? b.bulbOn : b.bulbOff; + b.leak.visible = F.beaten >= 2 && stallDoor.open < 0.5; + (b.leak.material as MeshBasicMaterial).opacity = 0.3 + 0.12 * Math.sin(t * 2.1); +} diff --git a/src/restoSound.ts b/src/restoSound.ts new file mode 100644 index 0000000..a272f9b --- /dev/null +++ b/src/restoSound.ts @@ -0,0 +1,50 @@ +// Mama Moray's, heard (stage 3), while the player's in it (its hall, its dining wing or its restroom). Shut, a wreck +// or being cleared, it's all but silent: water dripping somewhere by the counter, and now and then the old boards +// creaking. Open, the drip's gone and its diners are heard: the casino yacht's dining room recording (roomTone.ts), +// looped, louder the busier it's been. Fetched the first time the player walks in. +import { ac, buses, noise, prefs, rnd, tone } from './audio'; +import { CY } from './cellar'; +import { level, LOO, RESTO, WING } from './layout'; +import { player } from './player'; +import { loopable } from './roomTone'; +import diningUrl from './sounds/dining.mp3'; + +/** The diners at their loudest, and where the drip drips and the boards creak. */ +const DINERS = 0.11, DRIP = { x: -1.6, y: CY + 0.1, z: 10.9 }, CREAK = { x: 6.2, y: CY + 0.2, z: 14.2 }; +const inside = (p: { x: number; z: number }) => + [RESTO, WING, LOO].some(r => p.x > r.x0 - 0.2 && p.x < r.x1 + 0.2 && p.z > r.z0 - 0.2 && p.z < r.z1 + 0.2); + +let diners: { g: GainNode; src: AudioBufferSourceNode } | null = null, loading = false, dripT = 1, creakT = 5; + +function load() { + loading = true; + fetch(diningUrl).then(r => r.arrayBuffer()).then(b => ac!.decodeAudioData(b)).then(buf => { + const c = ac!, src = c.createBufferSource(), lp = c.createBiquadFilter(), g = c.createGain(); + src.buffer = loopable(buf); src.loop = true; + lp.type = 'lowpass'; lp.frequency.value = 2400; + g.gain.value = 0; + src.connect(lp); lp.connect(g); g.connect(buses.amb); + src.start(0, rnd() * buf.duration); + diners = { g, src }; + }).catch(() => { /* offline: a quiet restaurant */ }); +} + +/** Every frame, from resto.ts: `open`, whether Mama Moray's is, and `busy`, how busy it's been (0 to 1). */ +export function updRestoSound(dt: number, open: boolean, busy: number) { + if (!ac || prefs.mute.amb) return; + const here = level.down && inside(player.g.position); + if (here && !loading) load(); + diners?.g.gain.setTargetAtTime(here && open ? DINERS * (0.35 + 0.65 * busy) : 0, ac.currentTime, 0.6); + if (!here || open) return; + // shut: a drip, slow and uneven; and the boards, once in a while + if ((dripT -= dt) <= 0) { + dripT = 1.4 + rnd() * 2.6; + tone({ f: 1500 + rnd() * 300, f2: 650, a: 0.005, d: 0.08, v: 0.05, at: DRIP }); + tone({ t: 0.05, f: 900, f2: 500, a: 0.005, d: 0.06, v: 0.02, at: DRIP }); + } + if ((creakT -= dt) <= 0) { + creakT = 7 + rnd() * 9; + noise({ f: 340, f2: 240, q: 7, a: 0.12, d: 0.55, v: 0.035, at: CREAK }); + tone({ f: 95, f2: 72, a: 0.1, d: 0.5, v: 0.025, at: CREAK, vib: [11, 6] }); + } +} diff --git a/src/ring.ts b/src/ring.ts index d5e2c28..dd5d220 100644 --- a/src/ring.ts +++ b/src/ring.ts @@ -15,7 +15,9 @@ import { updUndertow } from './undertow'; import { player } from './player'; import { pointAt } from './pointers'; import { popIn } from './pop'; -import { built, updRingKitchen } from './ringKitchen'; +import { built, slideParts, updRingKitchen } from './ringKitchen'; +import { headless } from './headless'; +import { updResto } from './resto'; import { crowd, react } from './ringSound'; import { save } from './save'; import { meetPlaying } from './meet'; @@ -36,6 +38,8 @@ on.earn = (chips, why) => { if (why === 'bowls') chips -= collect(chips); addMoney(Math.round(chips * CHIP)); if (why === 'bowls') { takings += chips; return; } + // (Mama Moray's cut pops up over her till: resto.ts) + if (why === 'resto') return; if (level.down) popText(`+๐Ÿก ${Math.round(chips).toLocaleString('en-US')}`, { x: GONG.x, y: CY + 2.6, z: GONG.z }); }; /** Seconds till the crowd roars for a boss walking in: after the gong's second of silence (sfx.ts). And when the gong @@ -67,7 +71,7 @@ on.grow = () => { }; // The second boss beaten: the way south. Not with his card still up, though, nor while his crowd's still going home: // once the next bout's begun and the player's down here and free, Mr. Fugu wants a word, a "!" over him at his desk: he -// has his matchbook for the doorman at the door at the tunnel's mouth (southDoor.ts). +// has his matchbook for the doorman behind the out-of-order stall in Mama Moray's restroom (southDoor.ts). let denDue = false; on.den = () => { denDue = true; }; // An upgrade bought at the shop counter gets its moment on camera, as upstairs: what it built pops in, and the camera @@ -75,7 +79,7 @@ on.den = () => { denDue = true; }; // leaves; several bought in a row get the one close-up, once the player's done. Never while a boss is in (his rush // waits for nobody) or a scene has the player, up or down the ladder or in Mr. Fugu's office: then it just pops in. on.built = what => { - const os = 'tier' in what ? [what.tier === 1 ? parts.bleachers : what.tier === 2 ? parts.tier : parts.vip] : built(what); + const os = 'tier' in what ? [what.tier === 1 ? parts.bleachers : what.tier === 2 ? parts.tier : parts.vip] : 'slide' in what ? [slideParts] : built(what); os.forEach(popIn); // (asked again as the close-up starts: it waits till the player's stopped buying, spotlight.ts) const free = () => !F.rush && !player.held && !staging(); @@ -121,6 +125,7 @@ export function updRing(dt: number) { updSwarm(dt); if (meeting) updUndertow(dt, true); updSouthDoor(dt); + updResto(dt); return; } step(dt); @@ -133,13 +138,15 @@ export function updRing(dt: number) { takings = 0; } if (roarT > 0 && (roarT -= dt) <= 0) react('charge'); - updRingKitchen(dt); + // (the balance sims leave out the kitchen, the wrestlers and the crowd as drawn: headless.ts) + if (!headless.on) updRingKitchen(dt); updBarFlap(dt, level.down ? player.g.position : null); - updDohyo(dt); + if (!headless.on) updDohyo(dt); updOffice(dt); if (denDue && !F.rush && !cardUp() && level.down && !player.held && !staging()) { denDue = false; callFugu(); } updUndertow(dt); updSouthDoor(dt); - updSwarm(dt); + updResto(dt); + if (!headless.on) updSwarm(dt); updGong(dt); } diff --git a/src/ringKitchen.ts b/src/ringKitchen.ts index 2678542..33451a9 100644 --- a/src/ringKitchen.ts +++ b/src/ringKitchen.ts @@ -5,15 +5,16 @@ // counter and in the soup square's counters, steaming while there are any; the dumbwaiter's trays going round; // the hires at their work, carrying what they carry; and a bubble over the pot when it waits on something (๐ŸŒพ? ๐ŸŸ? // ๐Ÿ„?), or over the bar when fans wait at it with nothing to eat, like the cooks upstairs. -import { CanvasTexture, Color, Group, type Material, Mesh, MeshBasicMaterial, MeshLambertMaterial, type Object3D, PlaneGeometry, Sprite, SpriteMaterial, type Vector3 } from 'three'; +import { CanvasTexture, Color, Group, type Material, Mesh, MeshBasicMaterial, MeshLambertMaterial, type Object3D, PlaneGeometry, Sprite, SpriteMaterial, Vector3 } from 'three'; import { cellar, CY, LIFT_LOOP, parts, POT_TOP, potMesh, potStew } from './cellar'; -import { BAR, BAR_PLACES, BAR_PAD, BIN_PADS, BINS, CHUTE, chuteY, COUNTER, COUNTER_PAD, HEARTH, type Ing, LEDGE, LIFTS, machineAt, machinePad, MAX_MACHINES, POT, POT_PAD, STORES } from './cellarPlan'; +import { BAR, BAR_PLACES, BAR_PAD, BIN_PADS, BINS, CHUTE, chuteY, COUNTER, COUNTER_PAD, HEARTH, type Ing, LEDGE, LIFTS, machineAt, machinePad, MAX_MACHINES, MOTOR_AT, POT, POT_PAD, STORES } from './cellarPlan'; import { decal, drawPad } from './decals'; import { animPerson, belowGround, Person } from './characters'; -import { COUNTER_CAP, F, inPot, POT_CAP, poured, sold, type Machine, mach, potWaits, type Role, STALL_CAP, type Worker } from './fight'; +import { COUNTER_CAP, F, inPot, POT_CAP, poured, slid, sold, type Machine, mach, potWaits, type Role, STALL_CAP, type Worker } from './fight'; import { fly } from './holder'; import { newBowl, newCuredFish, newMushroom, newReadyRice, newRice, newFillet } from './items'; import { Build, K, quietly, rbox, tube } from './kit'; +import { hopperAt, lineParts, lineShows, lineToStall, updLine } from './bowlLine'; import { FONT, G, mesh } from './render'; import { fanAt, heading, turnedAt } from './swarm'; import { V, type XZ } from './util'; @@ -184,7 +185,9 @@ function marker(at: XZ, icon: string, parent: Object3D = cellar) { return d.mesh; } for (const k of ['rice', 'fish'] as const) marker(BIN_PADS[k], k === 'rice' ? '๐Ÿš' : '๐ŸŸ'); -marker(POT_PAD, '๐Ÿฒ').name = 'potPad'; marker(COUNTER_PAD, '๐Ÿฒ'); marker(BAR_PAD, '๐Ÿฎ'); +marker(POT_PAD, '๐Ÿฒ').name = 'potPad'; marker(BAR_PAD, '๐Ÿฎ'); +/** The counter's spot, to pick its bowls up at: gone once the bowl line's in, in its place (bowlLine.ts). */ +const counterPad = marker(COUNTER_PAD, '๐Ÿฒ'); const MARK_ICON: Record = { rice: '๐ŸŒพ', fish: '๐ŸŸ', mush: '๐Ÿ„' }; // (a marker by each of the grow room's beds; one for all the racks, and one for all the barrels, which moves along the // row as it grows: cellarPlan.ts's machinePad) @@ -313,12 +316,31 @@ function serveBowls() { b.position.set(POT.x + (j % 3 - 1) * 0.2, CY + POT_TOP + 0.1, POT.z); b.name = 'potFlight'; inAir++; - fly(b, () => slot.position, 0.5, 1.1, () => { cellar.remove(b); inAir = Math.max(0, inAir - 1); }); + // (into the bowl line's hopper, once it's in: bowlLine.ts) + fly(b, F.slide ? hopperAt : () => slot.position, 0.5, 1.1, () => { cellar.remove(b); inAir = Math.max(0, inAir - 1); }); } seen = poured.n; inAir = Math.min(inAir, n); - showBowls(counterBowls, F.counter.slice(0, n - inAir)); + showBowls(counterBowls, F.slide ? [] : F.counter.slice(0, n - inAir)); } +// ---------- the bowl slide ---------- +/** The bowl slide (once it's bought) is the hearth room's bowl line (bowlLine.ts), in place of the counter: the bowls + * it sends (fight.ts' `slid`) ride it from the hopper by the hearth, under the floor, up along the soup square's north + * counter, and up onto it. */ +export const slideParts = lineParts; +/** The bowls on the line, for tests. */ +export const slideShows = () => lineShows(); +let slidSeen = -1, dropT = 0; +function drawSlide(dt: number) { + if (slidSeen < 0 || slid.n < slidSeen) slidSeen = slid.n; // (a game just loaded: nothing on it) + const fresh = Math.min(slid.n - slidSeen, F.stall.length); + slidSeen = slid.n; + // (more go along it a second, at its best, than there's room to draw: one in a while is shown setting off) + dropT -= dt; + if (fresh && dropT <= 0) { dropT = 0.3; lineToStall(F.stall[F.stall.length - 1]); } + updLine(dt); +} + /** The bowls out on the soup square, each in its own place on the counters: a mushroom bowl or a plain one, and whether * it's sold (waiting there for the fan who bought it, on their way to the bar). When the fan gets there, the bowl * nearest them hops into their hands. (A boss's big crowd is drawn a figure for a few fans: each who comes takes the @@ -572,6 +594,13 @@ function drawCrew(dt: number) { } animPerson(p, w.moving, dt, n > 0); if (w.cooking > 0) p.arms[1].rotation.x = -1.4 + Math.sin(F.t * 8) * 0.4; + // (the attendant at the bowl line's motor: facing its flywheel, a hand on its rim, the other now and then on the + // box, easing it along) + if (w.role === 'attendant' && F.slide && !w.moving && Math.hypot(w.x - MOTOR_AT.x, w.z - MOTOR_AT.z) < 0.3) { + p.rotation.y = Math.PI / 2; + p.arms[1].rotation.x = -1.25 + Math.sin(F.t * 5) * 0.22; + p.arms[0].rotation.x = -0.7 - Math.max(0, Math.sin(F.t * 0.9)) * 0.6; + } } } /** The Person for a hire, if they're in (for tests and pointing at them). */ @@ -640,7 +669,9 @@ export function updRingKitchen(dt: number) { mk.m.position.x = at.x; mk.m.position.z = at.z; } for (const k of ['rice', 'fish', 'mush'] as const) ledge[k].forEach((m, j) => { m.visible = j < F.pantry[k]; }); + counterPad.visible = !F.slide; serveBowls(); + drawSlide(dt); drawBar(); drawBarSteam(dt); drawPot(dt); diff --git a/src/ringSound.ts b/src/ringSound.ts index 89aa279..2f567e4 100644 --- a/src/ringSound.ts +++ b/src/ringSound.ts @@ -21,9 +21,10 @@ import { RING, roomAt } from './cellar'; const MUFFLED = 0.15; /** Out of the arena, by the room: through one doorway on the landing and in the hearth room, two in the prep hall * (and the clatter of its work) and the cookware shop, and hardly at all in the grow room or behind Mr. Fugu's office - * door; in the Undertow's tunnel, a good deal of it, and in the Undertow itself none at all, the club's band all there is - * behind its doors and the rock (and in its back rooms past it). */ -const ROOMS_HEAR = { landing: 0.3, kitchen: 0.24, craft: 0.12, prep: 0.13, grow: 0.07, office: 0.07, passage: 0.35, den: 0, loans: 0, vip: 0, cards: 0 } as const; + * door; in Mama Moray's front hall, through its door, a good deal of it, less round the back in its wing and less + * again in its restroom; and in the Undertow itself none at all, the club's band all there is behind its door and the + * rock (and in its back rooms past it). */ +const ROOMS_HEAR = { landing: 0.3, kitchen: 0.24, craft: 0.12, prep: 0.13, grow: 0.07, office: 0.07, resto: 0.35, wing: 0.2, loo: 0.12, den: 0, loans: 0, vip: 0, cards: 0 } as const; /** In the arena, all of it at the ringside and in the front of the stands, half of it by the far walls. */ const RINGSIDE = 6.5, ARENA_FAR = 12.5, ARENA_LOW = 0.45; /** How much of the crowd the player hears where they are now, before smoothing. */ diff --git a/src/shopTree.ts b/src/shopTree.ts index b0c3a97..9a2a3d9 100644 --- a/src/shopTree.ts +++ b/src/shopTree.ts @@ -35,7 +35,7 @@ export const PLACES: Partial> = { rack: { lane: 'rice', label: 'Drying rack' }, washer: { lane: 'rice', from: 'rack' }, steam: { lane: 'rice', from: 'washer' }, barrel: { lane: 'fish', label: 'Salt barrel' }, salter: { lane: 'fish', from: 'barrel' }, smoke: { lane: 'fish', from: 'salter' }, bed: { lane: 'mush', label: 'Mushroom bed' }, picker: { lane: 'mush', sub: 1, from: 'bed' }, shelves: { lane: 'mush', from: 'picker', label: 'Glowing shelves' }, - attendant: { lane: 'hearth' }, cook: { lane: 'hearth', from: 'attendant' }, + attendant: { lane: 'hearth' }, slide: { lane: 'hearth', sub: 1, from: 'attendant' }, cook: { lane: 'hearth', from: 'attendant' }, tier1: { lane: 'venue', label: 'Back-Alley Ring' }, tier2: { lane: 'venue', from: 'tier1', label: 'Underground Arena' }, tier3: { lane: 'venue', from: 'tier2', label: 'Grand Hall Below' }, buyout: { label: 'Buy him out' }, }; diff --git a/src/southDoor.ts b/src/southDoor.ts index cf383e2..67bec44 100644 --- a/src/southDoor.ts +++ b/src/southDoor.ts @@ -1,31 +1,34 @@ -// The way south (stage 3). In the arena's south wall, between the ring and the soup bar, stands a heavy door, iron-banded, -// in a frame of iron and timber back into the earth: there from the first night in the cellar. Walk up to it and you knock; -// nobody answers. +// The way in (stage 3). Mama Moray's restroom, out front, has two stalls; the far one's out of order, taped across, its +// toilet taped up, and its back is a door: the doorman's, tiled over on this side as the stall's own back. Try the stall +// and it's out of order. Really. // -// Beat Yokobashiri (his card says there's a way south) and Mr. Fugu wants a word: a "!" over him at his desk. Step up +// Beat Yokobashiri (his card says there's a way in) and Mr. Fugu wants a word: a "!" over him at his desk. Step up // to it and the camera comes in on him while the cellar holds still: "Nice work on the crab, Chef." "Some friends want -// to meet you." He slides his matchbook across the desk: "Door by the ring. Show 'em this." Then it's yours to look +// to meet you." He slides his matchbook across the desk: "Out-of-order stall. Show 'em this." Then it's yours to look // over, up close: turn it about with the mouse or a finger (the club's name on the front, his note on the back), and -// pocket it. (Knock before that, and a slot in the door slides open on a pair of eyes: "Password?" โ€ฆ "Beat it.") Both of -// them talk in blips, as Mr. Fugu does on the dock (offer.ts): his a low mumble, the doorman's lower still. +// pocket it. (Try the stall before that, and a slot in its back slides open on a pair of eyes: "Password?" โ€ฆ "Beat it.") +// Both of them talk in blips, as Mr. Fugu does on the dock (offer.ts): his a low mumble, the doorman's lower still. // -// With his matchbook, a "!" over the door. Knock, and the scene: the camera on the door, the slot, the eyes, -// "Password?", the matchbook held up, "...Fugu's book. Go on in." The slot shuts, the bolts go, and the door swings in -// on the tunnel in a burst of the club's light, its band coming up the tunnel. After that the door opens as you come -// up to it, from either side, and shuts behind you; now and then a good evening through the slot first. The doorman -// and his things are doorway.ts's, the rest of the way in. +// With his matchbook, a "!" over the stall. Go up to it, and the scene: the tape torn away, a step into the stall, its +// bulb flickering, the camera in on the slot, the eyes, "Password?", the matchbook held up, "...Fugu's book. Go on in." +// The slot shuts, the bolts go, and the stall's back swings in on the club in a burst of its light, its band coming +// through, its smoke rolling back off the room. After that the door opens as you come up to it, from either side (the +// bulb giving a flicker first), and shuts behind you; now and then a good evening through the slot first. The doorman +// and his things, and the dark closing in in the stall, are doorway.ts's; the restroom and its stalls resto.ts's. import { - BoxGeometry, CylinderGeometry, Group, Mesh, MeshBasicMaterial, MeshLambertMaterial, type Object3D, PlaneGeometry, Quaternion, - Vector3, + BoxGeometry, Group, Mesh, MeshBasicMaterial, MeshLambertMaterial, type Object3D, PlaneGeometry, Quaternion, Vector3, } from 'three'; import { between, noise, tone } from './audio'; -import { boss, cellar, CY, OFFICE, parts, showDen, southDoor, WALL, WOOD, WOOD_DARK } from './cellar'; -import { doorman } from './doorway'; +import { boss, cellar, CY, OFFICE, showDen, southDoor } from './cellar'; +import { CLUB_DOOR, STALL } from './cellarPlan'; +import { liftFog } from './clubFog'; +import { doorman, stallDoor } from './doorway'; import { F } from './fight'; import { dustFx } from './fx'; import { cardUp } from './gong'; +import { moveEnt } from './characters'; import { apart, dice } from './kit'; -import { ARENA, level, PASSAGE } from './layout'; +import { DEN, level } from './layout'; import { club } from './noir'; import { DESK_PAD } from './office'; import { player } from './player'; @@ -37,16 +40,22 @@ import { staging } from './stage'; import { shot } from './swarm'; import { d2xz, V } from './util'; -/** The door: across the tunnel's mouth, on the arena's south wall's line, as wide as the tunnel, and as high as the - * club's doors. */ -const X0 = PASSAGE.x0, X1 = PASSAGE.x1, X = (X0 + X1) / 2, Z = ARENA.z1 + 0.1, W = X1 - X0, H = 2.1; -const FRONT = { x: X, z: Z - 0.75 }; -/** How near the front of the door the player comes to knock, and how far off to walk before knocking again. */ -const KNOCK = 0.85, AWAY = 2.2; +/** The door: the out-of-order stall's back, in the restroom's west wall, from `Z0` (its hinge, north) `W` along it, + * a little higher than the stall's sides; its middle, `MID`. Built in a frame of its own (`root`), as if it stood along x facing -z: + * its `x` along it from the hinge, its front (the restroom's side) to -z; turned so it stands along the wall, its + * front to the east. */ +const DX = CLUB_DOOR.x, Z0 = CLUB_DOOR.z0, W = CLUB_DOOR.z1 - CLUB_DOOR.z0, H = 1.8; +const MID = { x: DX, z: Z0 + W / 2 }; +/** A place `along` the door from its middle (south), `out` in front of it (east, into the restroom), at height `y`. */ +const by = (v: Vector3, along: number, y: number, out: number) => v.set(DX + out, y, MID.z + along); +/** Where the player comes to try the stall (just outside its front, while it's taped across), how near, and how far + * off to walk before trying it again; and where they step to inside it, in the scene. */ +const FRONT = { x: STALL.x1 + 0.25, z: MID.z }, INSIDE = { x: DX + 0.95, z: MID.z }; +const KNOCK = 0.7, AWAY = 2.0; /** What's said: lines of a few words. */ export const LINES = { - nobody: '. . .', ask: 'Password?', no: 'Beat it.', ok: "...Fugu's book. Go on in.", evening: 'Evening.', - fugu: ['Nice work on the crab, Chef.', 'Some friends want to meet you.', "Door by the ring. Show 'em this."], + nobody: 'Out of order. Really.', ask: 'Password?', no: 'Beat it.', ok: "...Fugu's book. Go on in.", evening: 'Evening.', + fugu: ['Nice work on the crab, Chef.', 'Some friends want to meet you.', "Out-of-order stall. Show 'em this."], }; /** The camera's view of a scene here, blended in by `k` (main.ts). */ @@ -54,67 +63,37 @@ export const wayCam = { k: 0, pos: V(0, 0, 0), look: V(0, 0, 0) }; // ---------- the door ---------- const IRON = 0x23282C; +/** The stall's partitions' paint, which its back's painted to match on the restroom's side (resto.ts). */ +export const STALL_PAINT = 0x2A7068; interface Built { root: Group; leaf: Group; book: Group; dim: Mesh } let built: Built | null = null; /** Built the first time the cellar's dressed (on dice of its own, so the game's luck never moves for it). */ function build(): Built { return built ??= apart(() => { - const root = new Group(); cellar.add(root); + const root = new Group(); root.position.set(DX, 0, Z0); root.rotation.y = -Math.PI / 2; cellar.add(root); const box = (parent: Object3D, c: number | MeshLambertMaterial, x: number, y: number, z: number, sx: number, sy: number, sz: number, cast = true) => { const m = mesh(G.box, c, x, y, z, cast); m.scale.set(sx, sy, sz); parent.add(m); return m; }; - // the arena's low plank wall either side of it, as cellar.ts's planks stand (their sill, boards and rail), up to its - // posts - const A0 = X0 - 0.3, A1 = X1 + 0.3; - for (const [a, b] of [[A0, X0 - 0.08], [X1 + 0.08, A1]]) { - const m = (a + b) / 2, l = b - a; - box(root, 0x4E4A46, m, CY + 0.08, Z, l, 0.16, 0.22); - box(root, WOOD, m, CY + WALL / 2, Z, l, WALL, 0.12); - box(root, WOOD_DARK, m, CY + WALL + 0.04, Z, l + 0.05, 0.08, 0.2); - } - for (const a of [A0, A1]) box(root, WOOD_DARK, a, CY + WALL / 2, Z, 0.16, WALL + 0.1, 0.16); - // its frame, slim iron posts and a lintel just over the door; behind it, the tunnel's low plank walls carried on up - // to the posts, so there's no gap either side - for (const x of [X0 - 0.04, X1 + 0.04]) box(root, IRON, x, CY + (H + 0.1) / 2, Z, 0.08, H + 0.1, 0.14); - box(root, IRON, X, CY + H + 0.05, Z, W + 0.16, 0.1, 0.14); - const B0 = Z + 0.07, B1 = PASSAGE.z0 - 0.3 - 0.08; - for (const x of [PASSAGE.x0 - 0.1, PASSAGE.x1 + 0.1]) { - const m = (B0 + B1) / 2, l = B1 - B0; - box(root, 0x4E4A46, x, CY + 0.08, m, 0.22, 0.16, l); - box(root, WOOD, x, CY + WALL / 2, m, 0.12, WALL, l); - box(root, WOOD_DARK, x, CY + WALL + 0.04, m, 0.2, 0.08, l); - } - // (and the tunnel's pipes along its east wall, carried on to the door and turned into the earth beside its post, - // each through a flange) - for (const [y, r] of [[1.35, 0.05], [1.55, 0.035]] as const) { - const px = PASSAGE.x1 - 0.12, run = PASSAGE.z0 - (Z + 0.2); - const pipe = mesh(G.cyl, 0x3A454C, px, CY + y, Z + 0.2 + run / 2); pipe.scale.set(r, run + 0.02, r); pipe.rotation.x = Math.PI / 2; parts.den.add(pipe); - const elbow = mesh(G.sphere, 0x3A454C, px, CY + y, Z + 0.2); elbow.scale.setScalar(r * 1.05); parts.den.add(elbow); - const out = mesh(G.cyl, 0x3A454C, px + 0.12, CY + y, Z + 0.2); out.scale.set(r, 0.24, r); out.rotation.z = Math.PI / 2; parts.den.add(out); - const flange = mesh(G.cyl, 0x262E33, PASSAGE.x1 + 0.005, CY + y, Z + 0.2); flange.scale.set(r + 0.025, 0.03, r + 0.025); flange.rotation.z = Math.PI / 2; parts.den.add(flange); - } - // (a red lantern on an iron bracket off its east post, on the arena's side) - box(root, IRON, X1 + 0.04, CY + H - 0.02, Z - 0.17, 0.035, 0.035, 0.24, false); - const lamp = new Group(); lamp.position.set(X1 + 0.04, CY + H - 0.3, Z - 0.28); root.add(lamp); - const red = new MeshLambertMaterial({ color: 0xE0392B, emissive: 0xB0250F }); - const shade = mesh(G.sphere, red); shade.scale.set(0.16, 0.2, 0.16); lamp.add(shade); - for (const s of [-1, 1]) { const cap = mesh(G.cyl, 0x1B2430, 0, s * 0.19, 0); cap.scale.set(0.09, 0.035, 0.09); lamp.add(cap); } - const cord = mesh(G.rope, 0x1B2430, 0, 0.24, 0); cord.scale.y = 0.1; lamp.add(cord); - // the door, hung on its west side and swinging in, south, into the tunnel: planks, three iron bands studded along - // its front, and a ring for a handle; on its back, the tunnel's side, the planks' seams, three ledges and two braces - // between them, the hinges' straps along the ledges, and a bolt shot into a keeper on the east post - const leaf = new Group(); leaf.position.set(X0, CY, Z); root.add(leaf); + // its frame, slim posts and a lintel just over it, painted as the stall's partitions, and deep enough to meet the + // restroom's wall in front and the way through the rock behind + const PAINT = new MeshLambertMaterial({ color: STALL_PAINT }); + for (const x of [-0.04, W + 0.04]) box(root, PAINT, x, CY + (H + 0.08) / 2, -0.06, 0.08, H + 0.08, 0.2); + box(root, PAINT, W / 2, CY + H + 0.04, -0.06, W + 0.16, 0.08, 0.2); + // the door, hung on its north side and swinging in, west, into the club: on the restroom's side, the stall's back, + // painted, a hairline round where it opens and a slit at eye height (his slot); on the club's, planks, the planks' + // seams, three ledges and two braces between them, the hinges' straps along the ledges, and a bolt shot into a keeper + // on the far post + const leaf = new Group(); leaf.position.set(0, CY, 0); root.add(leaf); const DOOR = new MeshLambertMaterial({ color: 0x3A2418 }); - box(leaf, DOOR, W / 2, H / 2, 0, W - 0.02, H, 0.09); - for (const y of [0.35, 1.05, 1.75]) { - box(leaf, IRON, W / 2, y, -0.035, W - 0.04, 0.07, 0.04); - for (const x of [0.15, W / 2, W - 0.15]) { const stud = mesh(G.sphere, 0x6A6F74, x, y, -0.055); stud.scale.setScalar(0.022); leaf.add(stud); } - } - const ring = mesh(new CylinderGeometry(0.06, 0.06, 0.015, 14), 0x8A7A4A, W - 0.14, 1.0, -0.06); ring.rotation.x = Math.PI / 2; leaf.add(ring); + box(leaf, DOOR, W / 2, H / 2, 0.01, W - 0.02, H, 0.07); + box(leaf, PAINT, W / 2, H / 2, -0.03, W - 0.02, H, 0.012); + // (the hairline, darker paint just inside its edge, so it reads as a panel that's not quite flush) + for (const y of [0.03, H - 0.03]) box(leaf, 0x1F524C, W / 2, y, -0.037, W - 0.06, 0.012, 0.004, false); + for (const x of [0.03, W - 0.03]) box(leaf, 0x1F524C, x, H / 2, -0.037, 0.012, H - 0.06, 0.004, false); const LEDGE = 0x6A4630; for (let k = 1; k < 6; k++) box(leaf, 0x2A1A12, (W * k) / 6, H / 2, 0.046, 0.012, H - 0.04, 0.004, false); - const ledges = [0.3, 1.05, 1.8]; - // (shallow, and clear of the hinge side, so the door opens flat against the tunnel's wall) + const ledges = [0.3, 0.9, 1.5]; + // (shallow, and clear of the hinge side, so the door opens flat against the club's wall) for (const y of ledges) box(leaf, LEDGE, (0.24 + W - 0.08) / 2, y, 0.057, W - 0.32, 0.13, 0.025); for (let k = 0; k < 2; k++) { // (each brace from the hinge side low to the far side high, as a ledged door's are) @@ -127,18 +106,16 @@ function build(): Built { } box(leaf, IRON, W - 0.2, ledges[1] + 0.18, 0.06, 0.34, 0.035, 0.03); for (const x of [W - 0.3, W - 0.12]) box(leaf, IRON, x, ledges[1] + 0.18, 0.07, 0.03, 0.07, 0.04); - box(root, IRON, X1 + 0.03, CY + ledges[1] + 0.18, Z + 0.08, 0.05, 0.09, 0.05); - // the doorman's slot, at his eye height, looking out north, into the arena + box(root, IRON, W + 0.03, CY + ledges[1] + 0.18, 0.08, 0.05, 0.09, 0.05); + // the doorman's slot, at his eye height, looking out east, into the stall const { slot, says } = doorman; - slot.position.set(W * 0.6, 1.62, -0.05); slot.rotation.y = Math.PI / 2; leaf.add(slot); - root.add(says.sprite); + slot.position.set(W * 0.6, 1.5, -0.05); slot.rotation.y = Math.PI / 2; leaf.add(slot); + cellar.add(says.sprite); // Mr. Fugu's matchbook, on his desk to slide across it to the player, and then up close to look over; and the dark // the view goes behind it then const book = bookModel(); book.visible = false; cellar.add(book); const dim = new Mesh(new PlaneGeometry(1, 1), new MeshBasicMaterial({ color: 0x0B1620, transparent: true, opacity: 0, depthWrite: false })); dim.visible = false; cellar.add(dim); - // (it stands in for the arena's own stretch of wall here, from the start) - parts.denWall.visible = false; return { root, leaf, book, dim }; }, dice(0xD00A)); } @@ -192,7 +169,7 @@ function bookModel() { // ---------- sound ---------- /** At the door, heard from where the player is; or (`cue`) on the scene's own bus, heard while the world's hushed. */ -const AT = { x: X, z: Z }; +const AT = MID; const on = (cue: boolean) => (cue ? { bus: 'cue' as const } : { at: AT }); const sfx = { knock: (cue: boolean, t = 0) => { @@ -206,6 +183,10 @@ const sfx = { slam: () => { noise({ f: 600, f2: 400, q: 1.5, a: 0.012, d: 0.14, v: 0.1, at: AT }); tone({ f: 120, f2: 90, a: 0.008, d: 0.12, v: 0.1, at: AT }); }, bolt: (cue: boolean, t = 0) => { noise({ t, f: 900, q: 3, a: 0.008, d: 0.08, v: 0.08, ...on(cue) }); tone({ t, f: 160, f2: 120, a: 0.008, d: 0.1, v: 0.08, ...on(cue) }); }, creak: (cue: boolean) => tone({ f: 130, f2: 92, type: 'triangle', lp: 700, a: 0.12, d: 0.6, v: 0.05, ...on(cue) }), + /** The caution tape torn off the stall's front. */ + tear: () => noise({ f: 2600, f2: 1400, q: 0.9, a: 0.01, d: 0.28, v: 0.05, bus: 'cue' }), + /** The stall's bulb buzzing as it flickers. */ + buzz: (cue: boolean, d = 0.5) => tone({ f: 100, type: 'sawtooth', lp: 600, a: 0.04, d, v: 0.018, ...on(cue) }), /** A syllable of a line, and the lower, longer "hm" at the end of a sentence: Mr. Fugu's mumble from the dock * (offer.ts), and (`deep`) the doorman's, lower still, from behind his door. */ voice: (deep: boolean, cue: boolean, t = 0) => tone({ t, f: deep ? between(78, 100) : between(105, 140), d: 0.08, type: 'triangle', a: 0.008, v: 0.34, ...on(cue) }), @@ -247,7 +228,7 @@ export function callFugu() { calling = true; pointAt({ x: OFFICE.x, y: CY + 1, z: OFFICE.z }, 'โ—', () => calling && level.down); } -const pointDoor = () => pointAt({ x: X, y: CY + 1, z: Z }, 'โ—', () => F.den.book && !F.den.way && level.down); +const pointDoor = () => pointAt({ x: MID.x, y: CY + 1, z: MID.z }, 'โ—', () => F.den.book && !F.den.way && level.down); // ---------- the page: the "!", the speech bubble, the dark round the edges ---------- const $ = (id: string) => document.getElementById(id)!; @@ -281,7 +262,7 @@ function begin(kind: 'fugu' | 'door') { player.held = true; player.moving = false; root.hidden = false; document.body.classList.add('way'); - closeIn = 0; openOut = 0; slotK = 0; + closeIn = 0; openOut = 0; slotK = 0; walkTo = null; } function end(after: () => void) { scene = null; @@ -414,32 +395,39 @@ function doormanSays(text: string, cue: boolean) { } } -/** At the door with Mr. Fugu's matchbook: the knock, the slot, the password, and the door swinging in on the tunnel. */ +/** Where the player's walked to in the scene, if anywhere. */ +let walkTo: { x: number; z: number } | null = null; +/** At the stall with Mr. Fugu's matchbook: the tape torn away, a step in, the bulb flickering, the knock, the slot, the + * password, and the stall's back swinging in on the club. */ function doorScene() { begin('door'); - // (the fans standing where it's filmed kept out of the shot, and the crowd hushed) - shot.hide = (x, z) => x > X - 2.7 && x < X + 2.5 && z > Z - 3.8; + // (the fans and diners standing where it's filmed kept out of the shot, and the crowd hushed) + shot.hide = (x, z) => x > DX - 0.5 && x < DX + 4 && z > MID.z - 2.6 && z < MID.z + 2.8; shot.quiet = true; - at(0.5, () => sfx.knock(true)); - at(1.2, () => { slotTo = 1; sfx.slide(true); closeIn = 1; }); - at(1.45, () => doormanSays(LINES.ask, true)); - at(2.5, () => { showing = true; built!.book.visible = true; bookT = 0; sfx.pocket(); }); - at(3.3, () => doormanSays(LINES.ok, true)); - at(4.4, () => { showing = false; built!.book.visible = false; built!.book.scale.setScalar(1); slotTo = 0; sfx.slide(true); closeIn = 0; }); - at(4.8, () => { sfx.bolt(true); sfx.bolt(true, 0.18); }); - at(5.2, () => { + at(0.4, () => { stallDoor.torn = true; sfx.tear(); }); + at(0.8, () => { walkTo = INSIDE; }); + at(1.6, () => { stallDoor.flicker = 1.1; sfx.buzz(true, 1.1); }); + at(2.0, () => sfx.knock(true)); + at(2.7, () => { slotTo = 1; sfx.slide(true); closeIn = 1; }); + at(2.95, () => doormanSays(LINES.ask, true)); + at(4.0, () => { showing = true; built!.book.visible = true; bookT = 0; sfx.pocket(); }); + at(4.8, () => doormanSays(LINES.ok, true)); + at(5.9, () => { showing = false; built!.book.visible = false; built!.book.scale.setScalar(1); slotTo = 0; sfx.slide(true); closeIn = 0; }); + at(6.3, () => { sfx.bolt(true); sfx.bolt(true, 0.18); }); + at(6.7, () => { want = 1; sfx.creak(true); if (!reduce()) { flash.style.transition = 'none'; flash.style.opacity = '0.75'; void flash.offsetWidth; flash.style.transition = 'opacity 1.1s ease-out'; flash.style.opacity = '0'; } - dustFx(V(X, CY + 0.9, Z + 0.4), 14, 1.4, [0x8A7A90, 0xC8A0B8]); + dustFx(V(DX - 0.4, CY + 0.9, MID.z), 14, 1.4, [0x8A7A90, 0xC8A0B8]); showDen(true); - F.den.way = true; + F.den.way = F.den.door = true; + liftFog(); openOut = 1; }); - at(7.8, () => end(() => pointAt({ x: X, y: CY, z: (PASSAGE.z0 + PASSAGE.z1) / 2 }, '๐Ÿƒ', () => level.down))); + at(9.3, () => end(() => pointAt({ x: DEN.x1 - 2.5, y: CY, z: MID.z }, '๐Ÿƒ', () => level.down))); } // ---------- every frame ---------- -/** The camera: in close on the slot, and back over the tunnel's mouth once the door's open (0 to 1, eased). */ +/** The camera: in close on the slot, and up over the stall once the door's open (0 to 1, eased). */ let closeIn = 0, openOut = 0, close = 0, out = 0; /** How far the slot's open, and where it's going. */ let slotK = 0, slotTo = 0; @@ -450,12 +438,12 @@ const near = V(0, 0, 0), slotAt = V(0, 0, 0); export function updSouthDoor(dt: number) { if (!built) return; const b = built, p = player.g.position, down = level.down, { says, cover, eyes } = doorman; - const d = Math.sqrt(d2xz(p, FRONT)), free = down && !player.held && !staging() && !F.rush && !cardUp(); + const d = Math.sqrt(d2xz(p, FRONT)), dDoor = Math.sqrt(d2xz(p, MID)), free = down && !player.held && !staging() && !F.rush && !cardUp(); if (d > AWAY) armed = true; if (!scene && free) { // Mr. Fugu, with his matchbook to hand over if (F.beaten >= 2 && !F.den.book && d2xz(p, DESK_PAD) < DESK_PAD.r * DESK_PAD.r) fuguScene(); - // the door: knocking (nobody there, or nobody who'll let you in), the scene, or (once you're in) its opening + // the stall: out of order (nobody there, or nobody who'll let you in), the scene, or (once you're in) its back opening else if (!F.den.way && armed && !knock && d < KNOCK) { armed = false; if (F.den.book) doorScene(); @@ -474,35 +462,40 @@ export function updSouthDoor(dt: number) { if (k.t > 3.6) knock = null; } // once the doorman's let you in: the door opens as you come up to it, from either side, and shuts behind you; from the - // arena, the first time a visit, a good evening through the slot first + // restroom, the bulb flickering first, and the first time a visit, a good evening through the slot if (F.den.way && !scene && down) { - const close2 = d2xz(p, { x: X, z: Z }) < 1.7 * 1.7; - if (close2 && want === 0 && evening <= 0) { - if (p.z < Z && !greeted) { greeted = true; evening = 0.9; slotTo = 1; doormanSays(LINES.evening, false); sfx.slide(false); } + if (dDoor < 1.5 && want === 0 && evening <= 0) { + const front = p.x > DX; + if (front) { stallDoor.flicker = 0.4; sfx.buzz(false, 0.4); } + if (front && !greeted) { greeted = true; evening = 0.9; slotTo = 1; doormanSays(LINES.evening, false); sfx.slide(false); } else { want = 1; sfx.bolt(false); sfx.creak(false); } } if (evening > 0 && (evening -= dt) <= 0) { slotTo = 0; if (want === 0) { want = 1; sfx.bolt(false); sfx.creak(false); } } - if (d2xz(p, { x: X, z: Z }) > 2.4 * 2.4) { if (want === 1) sfx.creak(false); want = 0; } - if (p.z < Z - 6 || p.x < X - 6 || p.x > X + 6) greeted = false; + // (a game let in before the club's smoke rolled back off it the first time: it does now) + if (want === 1 && !F.den.door) { F.den.door = true; liftFog(); } + if (dDoor > 2.2) { if (want === 1) sfx.creak(false); want = 0; } + if (dDoor > 6) greeted = false; } - // with his matchbook, the club's band, just, through the door, close to it - if (down && F.den.book && !F.den.way && !scene && d < 4) { club.k = Math.max(club.k, 0.2 * (1 - d / 4)); club.muffle = 1; club.vol = 1; } + stallDoor.flicker = Math.max(0, stallDoor.flicker - dt); + // with his matchbook, the club's band, just, through the stall's back, close to it + if (down && F.den.book && !F.den.way && !scene && dDoor < 4) { club.k = Math.max(club.k, 0.2 * (1 - dDoor / 4)); club.muffle = 1; club.vol = 1; } // the door and its slot open += (want - open) * Math.min(1, dt * (want ? 4 : 2.5)); - southDoor.shut = open < 0.6; + southDoor.shut = open < 0.6; stallDoor.open = open; b.leaf.rotation.y = -open * Math.PI * 0.47; slotK += (slotTo - slotK) * Math.min(1, dt * 14); cover.position.z = -slotK * 0.42; eyes.visible = slotK > 0.3; // (just over the slot, his mouth behind it) - if (says.upd(dt)) says.place(X, CY + 1.95, Z - 0.1); + if (says.upd(dt)) says.place(DX + 0.1, CY + 1.85, MID.z); // the matchbook: slid across the desk, in the office; held up to the slot, at the door, out of the pocket with its // front to you and then turned round to him (so it's his note on the back you see) bookT += dt; if (look) updLook(dt, b); else if (showing) { const up = ease(Math.min(1, bookT / 0.35)), turn = ease(Math.min(1, Math.max(0, (bookT - 0.45) / 0.5))); - b.book.position.set(p.x + 0.12, p.y + 1.05 + 0.4 * up, p.z + 0.32); - b.book.rotation.set(Math.PI / 2, Math.PI * (1 - turn), 0, 'YXZ'); + // (held up in front of them, toward the stall's back: west, and a little to their left) + b.book.position.set(p.x - 0.32, p.y + 1.05 + 0.4 * up, p.z + 0.12); + b.book.rotation.set(Math.PI / 2, Math.PI * (1 - turn) - Math.PI / 2, 0, 'YXZ'); b.book.scale.setScalar(1.3); } else if (b.book.visible) { @@ -517,8 +510,11 @@ export function updSouthDoor(dt: number) { for (const c of s.cues.filter(c => c.t <= s.t)) { s.cues.splice(s.cues.indexOf(c), 1); c.run(); } wayCam.k = Math.min(1, wayCam.k + dt / 0.7); if (s.kind === 'fugu') boss.talking = !!sayLine && sayLine.n < sayLine.text.length; - // (the tunnel's band coming up the tunnel once the door's open, muffled by the club's doors at the far end) - if (s.kind === 'door') { club.k = F.den.way ? 0.45 : 0; club.muffle = 1; club.vol = 1; } + // (the club's band through the stall's back, louder and clearer as it swings open); and the step into the stall + if (s.kind === 'door') { + club.k = 0.2 + 0.7 * open; club.muffle = 1 - open; club.vol = 1; + if (walkTo) { const sp = player.speed; player.speed = 1.6; if (moveEnt(player, walkTo, dt)) walkTo = null; player.speed = sp; } + } } // the camera const back = camK > 1 ? 1.35 : 1, shooting = scene || wayCam.k > 0 ? shotOf : null; @@ -528,13 +524,15 @@ export function updSouthDoor(dt: number) { } else if (shooting === 'door') { close += (closeIn - close) * Math.min(1, dt * 1.6); out += (openOut - out) * Math.min(1, dt * 1.2); - wayCam.pos.set(X - 1.5 * back, CY + 2.6 * back, Z - 3.8 * back); - wayCam.look.set(X, CY + 1.1, Z); - b.leaf.updateMatrixWorld(); doorman.slot.getWorldPosition(slotAt); - wayCam.pos.lerp(near.set(slotAt.x - 0.9, CY + 2.1, Z - 2.9 * back), ease(close)); + // (from the restroom, off to the south-east as the game's own camera is; in on the slot; then up over the stall, + // looking through into the club) + by(wayCam.pos, 1.6 * back, CY + 2.6 * back, 3.6 * back); + by(wayCam.look, 0, CY + 1.1, 0); + b.root.updateMatrixWorld(); doorman.slot.getWorldPosition(slotAt); + wayCam.pos.lerp(near.set(DX + 2.9 * back, CY + 2.1, slotAt.z + 0.9), ease(close)); wayCam.look.lerp(slotAt, ease(close)); - wayCam.pos.lerp(near.set(X + 0.4 * back, CY + 5.0 * back, Z - 3.6 * back), ease(out)); - wayCam.look.lerp(near.set(X - 0.4, CY + 0.2, Z + 1.8), ease(out)); + wayCam.pos.lerp(by(near, -0.4 * back, CY + 5.0 * back, 3.6 * back), ease(out)); + wayCam.look.lerp(by(near, 0.4, CY + 0.2, -1.8), ease(out)); } // the line being said, a letter at a time, his mouth going with it if (sayLine && sayLine.n < sayLine.text.length) { @@ -558,7 +556,7 @@ export function updSouthDoor(dt: number) { * it's drawn). */ export function drawWay() { const target = !level.down || scene ? null : calling ? V(boss.position.x, boss.position.y + 2.9, boss.position.z) - : F.den.book && !F.den.way && built ? V(X, CY + H + 0.75, Z) : null; + : F.den.book && !F.den.way && built ? V(MID.x, CY + H + 0.75, MID.z) : null; if (alertEl.classList.contains('on') !== !!target) alertEl.classList.toggle('on', !!target); if (target) { const a = target.project(camera); diff --git a/src/spotlight.ts b/src/spotlight.ts index 9400eec..8e0c1da 100644 --- a/src/spotlight.ts +++ b/src/spotlight.ts @@ -5,13 +5,17 @@ // close-up once the player's done, on all of them if they're close together, or else on the last. import { Box3, type Object3D, type Vector3 } from 'three'; import { level } from './layout'; +import { player } from './player'; import { view } from './stage'; import { V } from './util'; /** Seconds easing over and in, holding on it, and easing back out to the player. */ export const SPOT = { over: 0.6, hold: 1.6, back: 0.7 }; -/** Seconds after a purchase before its close-up starts, in case the player buys another (each one waits again). */ -export const SPOT_WAIT = 0.7; +/** Seconds after a purchase before its close-up starts (and the shop's menu steps aside for it), in case the player + * buys another (each one waits again). */ +export const SPOT_WAIT = 2.0; +/** How far from the player a close-up can look (further, and it's not what was built: no close-up). */ +const STRAY = 30; /** How far apart (across the floor) what a run of purchases built can be and still all be in the one close-up. */ const TOGETHER = 4.5; /** How close the camera comes in: its distance, as a share of the usual (the casino boat, being big, comes in less). */ @@ -35,9 +39,14 @@ const mid = V(0, 0, 0), tmp = V(0, 0, 0), box = new Box3(); * foot: as it will be once it's popped in, as popping in (pop.ts) grows it from its foot. */ function lit(o: Object3D): Lit { - const foot = o.getWorldPosition(V(0, 0, 0)), k = o.scale.x; - if (box.setFromObject(o).isEmpty() || k <= 0) return { o, up: V(0, 0, 0) }; - return { o, up: box.getCenter(V(0, 0, 0)).sub(foot).divideScalar(k) }; + // (measured at its full size: one still popping in, or only just started, is too small to measure, or nothing at + // all, and then the camera would look at its foot, which for a group laid out from the world's middle is nowhere) + // (popping in, pop.ts, is the same scale every way, up to 1) + const k = o.scale.x, popping = k < 1 && o.scale.y === k && o.scale.z === k; + if (popping) { o.scale.setScalar(1); o.updateMatrixWorld(true); } + const foot = o.getWorldPosition(V(0, 0, 0)), empty = box.setFromObject(o).isEmpty(), up = empty ? V(0, 0, 0) : box.getCenter(V(0, 0, 0)).sub(foot); + if (popping) { o.scale.setScalar(k); o.updateMatrixWorld(true); } + return { o, up }; } /** @@ -68,13 +77,21 @@ function start(d: Due) { due = null; if (d.down !== level.down || (d.when && !d.when())) { d.cue?.run(); return; } const on = together(d.on); + // (nowhere near the player: not what was built, whatever it is, so no close-up) + const lits = on.map(lit); + mid.set(0, 0, 0); + for (const { o, up } of lits) mid.add(o.getWorldPosition(tmp)).add(up); + mid.divideScalar(lits.length); + if (mid.distanceTo(player.g.position) > STRAY) { d.cue?.run(); return; } // a second upgrade while one's still showing: the camera stays in close and goes straight on over to the new one shot?.cue?.run(); shot = shot - ? { t: Math.min(shot.t, SPOT.over), on: on.map(lit), close: d.close, was: shot.was, cue: d.cue, snap: false, down: level.down } - : { t: 0, on: on.map(lit), close: d.close, was: view.focus.clone(), cue: d.cue, snap: true, down: level.down }; + ? { t: Math.min(shot.t, SPOT.over), on: lits, close: d.close, was: shot.was, cue: d.cue, snap: false, down: level.down } + : { t: 0, on: lits, close: d.close, was: view.focus.clone(), cue: d.cue, snap: true, down: level.down }; } +/** Whether a close-up's on, or waiting to start (resto.ts lets the arena's go first). */ +export const spotBusy = () => !!shot || !!due; /** Whether something's in the spotlight now (the shop's menu steps out of the way meanwhile: shop.ts); not while it * waits for the player to stop buying, so the menu's there for the next. */ export const spotOn = () => !!shot; diff --git a/src/swarm.ts b/src/swarm.ts index c0f77c7..15cc5d6 100644 --- a/src/swarm.ts +++ b/src/swarm.ts @@ -12,7 +12,7 @@ // batches: a dozen looks, each one's body, arms and legs an instanced mesh (below). If frames run slow while it's // out, it stops casting shadows. import { BufferGeometry, CanvasTexture, Color, Float32BufferAttribute, InstancedMesh, Matrix4, Quaternion, Sprite, SpriteMaterial, Vector3 } from 'three'; -import { CY, cellarBoxes, parts, placeBuilt, places, RING } from './cellar'; +import { CY, cellarBoxes, lineRamp, parts, placeBuilt, places, RING } from './cellar'; import { BAR, BAR_PAD, BAR_SPOTS, FLOOR, fromBar, GONG_PAD, nearBar, TUNNELS, WATCH } from './cellarPlan'; import { crowdModel, PARKAS, SHIRT } from './characters'; import { bossOf, F, REACH } from './fight'; @@ -100,10 +100,11 @@ const CROWD = { r0: 0.85, r1: 1.45 }; const STAND_GAP = 0.55, SEAT_GAP = 0.6; /** Spots on the floor for up to `n` fans, packed to fit but never closer than STAND_GAP (so there may be fewer), nearest * the square first: round it, then out across the floor. */ -const spotCache = new Map(); +const spotCache = new Map(); function spots(n: number) { const size = n <= 60 ? 60 : n <= 150 ? 150 : MAX_FANS; - let out = spotCache.get(size); + const key = `${size}|${+lineRamp.up}`; + let out = spotCache.get(key); if (out) return out; // (none in the square, or anything else's footprint, or on the gong's pad: the player rings it from there) const { boxes } = navMap(), z0 = Math.min(FLOOR.z0, BAR.z0 - CROWD.r1), w = FLOOR.x1 - FLOOR.x0, h = FLOOR.z1 - z0; @@ -126,7 +127,7 @@ function spots(n: number) { if (out.length >= size || gap * 0.97 < STAND_GAP) break; } out.sort((a, b) => fromBar(a) - fromBar(b)); - spotCache.set(size, out); + spotCache.set(key, out); return out; } @@ -137,7 +138,7 @@ function spots(n: number) { type Box = [number, number, number, number]; const OUT = 0.4, CLEAR = 0.3, NEAR = 0.05; let nav: { key: string; boxes: Box[]; nodes: XZ[]; sees: boolean[][] } | null = null; -const venueKey = () => `${+parts.bleachers.visible}${+parts.tier.visible}${+parts.vip.visible}${+parts.drums.visible}`; +const venueKey = () => `${+parts.bleachers.visible}${+parts.tier.visible}${+parts.vip.visible}${+parts.drums.visible}${+lineRamp.up}`; const inArena = (q: XZ) => q.x > ARENA.x0 + 0.2 && q.x < ARENA.x1 - 0.2 && q.z > ARENA.z0 + 0.2 && q.z < ARENA.z1 - 0.2; function inBox(q: XZ, boxes: Box[], m: number) { return boxes.some(([cx, cz, hx, hz]) => Math.abs(q.x - cx) < hx + m && Math.abs(q.z - cz) < hz + m); diff --git a/src/tutorial.ts b/src/tutorial.ts index aa1f8a6..447d21f 100644 --- a/src/tutorial.ts +++ b/src/tutorial.ts @@ -202,6 +202,7 @@ const cookKey = () => (touchBuy() ? 'Tap Cook' : 'Press E or Cook'); /** The bowls in the kitchen, on their way to the stall: in the player's arms, on the counter, or still in the pot. */ function bowling(): { at: Spot; say: [string, string?] } | null { if (holding('bowl')) return { at: down(BAR_PAD), say: ['Set the bowls out here', 'Walk in through the kitchen door'] }; + if (F.slide) return null; if (F.counter.length) return { at: down(COUNTER_PAD), say: ['Pick up the bowls'] }; if (F.pot.length) return { at: down(COUNTER_PAD), say: ['Bowls come out here'] }; return null; diff --git a/src/ui.ts b/src/ui.ts index fdafef0..18e7bf0 100644 --- a/src/ui.ts +++ b/src/ui.ts @@ -188,8 +188,10 @@ export interface TipContent { name: string; desc: string } const tipEl = $('tip'); let tipFor: TipContent | null = null; export function setTip(t: TipContent | null) { - if (t === tipFor) return; + // (rewritten only when what it says changes: the same tip comes back every frame) + const same = t === tipFor || (!!t && !!tipFor && t.name === tipFor.name && t.desc === tipFor.desc); tipFor = t; + if (same) return; if (t) { tipEl.innerHTML = ''; tipEl.firstChild!.textContent = t.name; diff --git a/src/underBelt.ts b/src/underBelt.ts new file mode 100644 index 0000000..cd713a6 --- /dev/null +++ b/src/underBelt.ts @@ -0,0 +1,178 @@ +// A belt under the cellar's floor (stage 3): the bowl line's, from the pot to the soup square and on to Mama Moray's +// (bowlLine.ts). Like a mine's (or a factory game's) underground belt, all that shows of it is where it's on the floor: +// a stretch of belt, worn rubber over steel rollers in a dark steel frame, coming up out of a steel hood like a chute's +// mouth (a brass edge on its lip) that slopes away into the floor, or going down into one; under the floor between, +// out of sight. The bowls on it show on its stretches, rising out of a hood and dipping into the next. It's all low, +// nothing anyone bumps into. +import { ExtrudeGeometry, Group, Mesh, MeshLambertMaterial, RepeatWrapping, Shape, type Texture } from 'three'; +import { newBowl } from './items'; +import { canvasTex, G, mesh } from './render'; +import type { XZ } from './util'; + +const STEEL = 0x5E676E, STEEL_DARK = 0x33393D, HOLE = 0x0B0C0D, ROLLER = 0x6C7378, BRASS = 0xB8923A; +/** The belt's width and how high its top stands, and a hood's: how wide, how tall at its mouth, and how far back it + * slopes into the floor. */ +const BELT = { w: 0.48, top: 0.075 }, HOOD = { w: 0.7, h: 0.38, d: 0.62 }; +/** A hood's cheek: a wedge, its mouth's height at the front, down to the floor at the back (made once). */ +let cheekGeo: ExtrudeGeometry | null = null; +const cheek = () => cheekGeo ??= (() => { + const sh = new Shape(); sh.moveTo(0, 0); sh.lineTo(HOOD.d, 0); sh.lineTo(0, HOOD.h); sh.closePath(); + const g = new ExtrudeGeometry(sh, { depth: 0.05, bevelEnabled: false }); + g.translate(0, 0, -0.025); g.rotateY(-Math.PI / 2); + return g; +})(); +/** How far from a hood a bowl's dipped right into it. */ +const DIP = 0.18; + +/** A stretch of it on the floor, `from` and `to` metres along it (on one straight leg of its path): `up` out of a hood + * at its start, `down` into one at its end (or else a roller there); and, with `rise`, climbing that high by its end, + * cleated, railed and on legs (up onto a counter). */ +export interface Stretch { from: number; to: number; up?: boolean; down?: boolean; rise?: number } + +export interface UnderBelt { + /** Its stretches on the floor, and the bowls on them. */ + group: Group; + /** Each stretch's own: its belt, and its hoods (to show or not as the caller has it). */ + parts: { g: Group; up: Group | null; down: Group | null }[]; + /** A bowl set down on it at its start (the caller has it land there), taken off `leave` metres along (or at its end), + * `off` then called. */ + send(mush: boolean, leave: number | undefined, off: (mush: boolean) => void): void; + update(dt: number): void; + /** Where `d` metres along it is. */ + at(d: number): { x: number; z: number; ry: number }; + /** How long it is, and how high a bowl rides on it (over the floor). */ + length: number; + ride: number; + /** How many bowls are on it (on show or under the floor): of those taken off `past` metres along or further, or all. */ + riding(past?: number): number; +} + +/** The belt's rubber: dark, with a slat across every so often and, now and then, a faint arrow the way it runs. */ +let rubber: Texture | null = null; +function rubberTex() { + if (rubber) return rubber; + rubber = canvasTex(64, 256, (c, w, h) => { + c.fillStyle = '#26282A'; c.fillRect(0, 0, w, h); + c.fillStyle = '#1A1B1C'; + for (let y = 0; y < h; y += 32) c.fillRect(0, y, w, 5); + c.strokeStyle = 'rgba(200, 190, 160, 0.22)'; c.lineWidth = 5; + c.beginPath(); c.moveTo(w * 0.25, 150); c.lineTo(w * 0.5, 120); c.lineTo(w * 0.75, 150); c.stroke(); + }).tex; + rubber.wrapS = rubber.wrapT = RepeatWrapping; + return rubber; +} + +/** + * A belt along `path`, at `y` (the floor's height), on the floor at its `stretches` and under it everywhere else: + * `speed` metres a second, at least `gap` between bowls (the ones sent closer than that wait at the start). + */ +export function underBelt(path: XZ[], y: number, { speed = 1.6, gap = 0.3, stretches }: { + speed?: number; gap?: number; stretches: Stretch[]; +}): UnderBelt { + const group = new Group(); + const legs = path.slice(1).map((q, i) => ({ a: path[i], b: q, len: Math.hypot(q.x - path[i].x, q.z - path[i].z) })); + const length = legs.reduce((s, l) => s + l.len, 0); + const at = (d: number) => { + for (const l of legs) { + if (d <= l.len || l === legs[legs.length - 1]) { + const k = Math.min(1, Math.max(0, d / l.len)); + return { x: l.a.x + (l.b.x - l.a.x) * k, z: l.a.z + (l.b.z - l.a.z) * k, ry: Math.atan2(l.b.x - l.a.x, l.b.z - l.a.z) }; + } + d -= l.len; + } + return { x: path[0].x, z: path[0].z, ry: 0 }; + }; + const tex = rubberTex().clone(); + tex.needsUpdate = true; + const rubberMat = new MeshLambertMaterial({ map: tex }), steelMat = new MeshLambertMaterial({ color: STEEL }); + /** A hood at `hz` along a stretch's group, its mouth to the belt, the belt under the floor behind it: further on + * (`back` 1, going down) or before (-1, coming up). */ + const hood = (p: Group, hz: number, back: 1 | -1) => { + const g = new Group(); p.add(g); + const box = (c: number, x: number, h: number, z: number, sx: number, sy: number, sz: number, shadow = true) => { + const o = mesh(G.box, c, x, h, z, shadow); o.scale.set(sx, sy, sz); g.add(o); return o; + }; + // (each cheek a wedge, as built facing +z from its mouth: turned about for one behind) + for (const s of [-1, 1]) { + const c = new Mesh(cheek(), steelMat); c.castShadow = true; + c.position.set(s * (HOOD.w / 2 - 0.025), 0, hz); c.rotation.y = back > 0 ? 0 : Math.PI; g.add(c); + } + const top = box(STEEL, 0, HOOD.h / 2 + 0.012, hz + back * HOOD.d / 2, HOOD.w, 0.03, Math.hypot(HOOD.d, HOOD.h) + 0.02); + top.rotation.x = back * Math.atan2(HOOD.h, HOOD.d); + box(STEEL_DARK, 0, HOOD.h, hz + back * 0.015, HOOD.w + 0.03, 0.05, 0.07); + box(BRASS, 0, HOOD.h + 0.027, hz + back * 0.015, HOOD.w + 0.035, 0.012, 0.075, false); + box(HOLE, 0, (HOOD.h - 0.04) / 2, hz + back * 0.04, HOOD.w - 0.1, HOOD.h - 0.05, 0.03, false); + box(STEEL_DARK, 0, 0.015, hz + back * (HOOD.d + 0.02), HOOD.w + 0.03, 0.03, 0.06); + return g; + }; + const parts = stretches.map(s => { + const p = at(s.from), run = s.to - s.from, rise = s.rise ?? 0, g = new Group(); + g.position.set(p.x, y, p.z); g.rotation.y = p.ry; + group.add(g); + // (a climbing stretch's belt tilted up along it, its hoods and legs standing level) + const tilt = new Group(), len = Math.hypot(run, rise); + tilt.rotation.x = -Math.atan2(rise, run); g.add(tilt); + const box = (q: Group, c: number, x: number, h: number, z: number, sx: number, sy: number, sz: number) => { + const o = mesh(G.box, c, x, h, z, true); o.scale.set(sx, sy, sz); q.add(o); return o; + }; + // the frame either side, the rubber between, and a roller at each end (under a hood, where there's one) + for (const k of [-1, 1]) box(tilt, STEEL_DARK, k * (BELT.w / 2 + 0.03), BELT.top / 2 + 0.005, len / 2, 0.06, BELT.top + 0.01, len); + const belt = new Mesh(G.box, rubberMat); + belt.position.set(0, BELT.top - 0.01, len / 2); belt.scale.set(BELT.w, 0.02, len); + tilt.add(belt); + for (const z of [0.02, len - 0.02]) { + const r = mesh(G.cyl, ROLLER, 0, BELT.top - 0.03, z, false); r.scale.set(0.05, BELT.w + 0.04, 0.05); r.rotation.z = Math.PI / 2; tilt.add(r); + } + if (rise) { + // cleats across it to hold the bowls, rails along its sides, and legs down to the floor + for (let z = 0.25; z < len - 0.1; z += 0.22) box(tilt, STEEL_DARK, 0, BELT.top + 0.012, z, BELT.w - 0.04, 0.025, 0.025); + for (const k of [-1, 1]) box(tilt, STEEL, k * (BELT.w / 2 + 0.05), BELT.top + 0.07, len / 2 + 0.05, 0.025, 0.05, len - 0.1); + for (const f of [0.4, 0.75]) for (const k of [-1, 1]) { + const h = rise * f; + box(g, STEEL_DARK, k * (BELT.w / 2 + 0.03), h / 2, run * f, 0.04, h, 0.04); + } + } + return { g, up: s.up ? hood(g, 0, -1) : null, down: s.down ? hood(g, run, 1) : null }; + }); + // the bowls on show, a few of each kind, given each frame to whichever riders are on a stretch + const ride = BELT.top + 0.035; + const room = Math.ceil(stretches.reduce((n, s) => n + s.to - s.from, 0) / gap) + 1; + const shown = [false, true].flatMap(mush => Array.from({ length: room }, () => { + const o = newBowl(mush); o.scale.setScalar(0.78); o.visible = false; group.add(o); return { o, mush }; + })); + type Rider = { d: number; mush: boolean; leave: number; off: (mush: boolean) => void }; + const riders: Rider[] = [], waiting: Rider[] = []; + /** How far a bowl `d` along has dipped under the floor: 0 out on a stretch, 1 out of sight. */ + const dip = (d: number) => { + const s = stretches.find(q => d >= q.from && d <= q.to); + if (!s) return 1; + const down = s.down && parts[stretches.indexOf(s)].down?.visible ? Math.max(0, 1 - (s.to - d) / DIP) : 0; + const up = s.up ? Math.max(0, 1 - (d - s.from) / DIP) : 0; + return Math.max(down, up); + }; + return { + group, parts, at, length, ride, + send(mush, leave, off) { waiting.push({ d: 0, mush, leave: Math.min(leave ?? length, length), off }); if (waiting.length > 40) waiting.shift()!.off(false); }, + riding: past => [...riders, ...waiting].filter(r => past === undefined || r.leave >= past).length, + update(dt) { + tex.offset.y -= speed * dt / 0.5; + const last = Math.min(...riders.map(r => r.d), Infinity); + if (waiting.length && last >= gap) riders.push(waiting.shift()!); + for (let i = riders.length - 1; i >= 0; i--) { + const r = riders[i]; + r.d += speed * dt; + if (r.d >= r.leave) { riders.splice(i, 1); r.off(r.mush); } + } + for (const s of shown) s.o.visible = false; + for (const r of riders) { + const k = dip(r.d); + if (k >= 1) continue; + const s = shown.find(o => o.mush === r.mush && !o.o.visible); + if (!s) continue; + const p = at(r.d), on = stretches.find(q => r.d >= q.from && r.d <= q.to); + const up = on?.rise ? on.rise * (r.d - on.from) / (on.to - on.from) : 0; + s.o.position.set(p.x, y + ride + up - k * 0.25, p.z); s.o.rotation.y = p.ry; s.o.visible = true; + } + }, + }; +} diff --git a/src/unlocks.ts b/src/unlocks.ts index 271cb27..42ac2dc 100644 --- a/src/unlocks.ts +++ b/src/unlocks.ts @@ -12,7 +12,7 @@ import { CY, showVenue } from './cellar'; import { openSled, openTakeout } from './counters'; import { decal, drawTile, type Decal } from './decals'; import { boost } from './economy'; -import { addMachine, BATCH, BOSSES, BUYOUT, CHIP, COOK_BATCH, F, hire, HIRES, type Ing, MACH, MACH_COST, machineCost, on, practiceDue, setTier, TIER_BEAT, TIER_COST, TIERS, upgradeLine, UPS } from './fight'; +import { addMachine, BOSSES, BUYOUT, buySlide, CHIP, F, hire, HIRES, type Ing, MACH, MACH_COST, machineCost, on, practiceDue, setTier, SLIDE, SLIDE_BEAT, SLIDE_COST, TIER_BEAT, TIER_COST, TIERS, upgradeLine, UPS } from './fight'; import { tryCatch } from './fishing'; import { addTables } from './garden'; import { enableKorki, KORKI } from './korki'; @@ -43,7 +43,7 @@ export type UnlockId = | 'paddy' | 'seats' | 'chef' | 'farmer' | 'porter' | 'plot2' | 'tables' | 'chef3' | 'tables2' | 'plot3' | 'kiosk' | 'premium' | 'korki' | 'blackjack' | 'slots' | 'dohyo' | 'rack' | 'barrel' | 'bed' | 'washer' | 'salter' | 'picker' | 'cook' | 'attendant' | 'steam' | 'smoke' | 'shelves' - | 'tier1' | 'tier2' | 'tier3' | 'buyout'; + | 'slide' | 'tier1' | 'tier2' | 'tier3' | 'buyout'; interface Unlock { id: UnlockId; cost: number; @@ -145,7 +145,8 @@ const UNLOCKS: Unlock[] = [ ...ring('picker', 14.7, -3.95, '๐Ÿง‘โ€๐ŸŒพ', HIRES.picker.name, 'Runs the mushroom line: picks the beds and carries mushrooms to the pot', HIRES.picker.beat, HIRES.picker.cost), ...ring('shelves', 16.1, -3.95, 'โœจ', 'Glowing grow shelves', `Swaps every bed for glowing shelves: ${MACH.mush[1].cap} mushrooms, one every ${MACH.mush[1].time} s`, UPS.mush.beat, UPS.mush.cost), ...ring('attendant', 9.6, 5.9, '๐Ÿฒ', HIRES.attendant.name, 'Carries bowls from the counter to the soup bar', HIRES.attendant.beat, HIRES.attendant.cost), - ...ring('cook', 7.6, 5.9, '๐Ÿ‘จโ€๐Ÿณ', HIRES.cook.name, `Cooks while you're away: ${COOK_BATCH} bowls, not ${BATCH}`, HIRES.cook.beat, HIRES.cook.cost), + ...ring('slide', 8.6, 5.9, '๐Ÿ›', 'Bowl slide', `A belt from the pot to the soup bar, ${SLIDE} bowls a second, the attendant at its motor`, SLIDE_BEAT, SLIDE_COST), + ...ring('cook', 7.6, 5.9, '๐Ÿ‘จโ€๐Ÿณ', HIRES.cook.name, 'Cooks while you\'re away, full batches, and ladles twice as fast', HIRES.cook.beat, HIRES.cook.cost), ...[1, 2, 3].flatMap(i => ring(`tier${i}` as UnlockId, 1.6, 1.9, ['', '๐Ÿฎ', '๐Ÿฅ', 'โ›ฉ๏ธ'][i], TIERS[i].name, `The venue grows to ${TIERS[i].seats} seats${i === 2 ? ', with taiko drummers' : ''}`, TIER_BEAT[i], TIER_COST[i], undefined, i > 1 ? `tier${i - 1}` as UnlockId : undefined)), { id: 'buyout', cost: BUYOUT * CHIP, ...below(7.55, -5.25), icon: '๐Ÿก', name: 'Buy back all his chips', desc: "The ring is yours: Mr. Fugu's 30% ends, the till comes back", stage: 3, gold: true, beat: BOSSES.length }, @@ -160,11 +161,12 @@ const UNLOCKS: Unlock[] = [ // attendant and cook; and the venue's tiers. const MENU_OF: Partial> = { rack: 'prep', barrel: 'prep', washer: 'prep', salter: 'prep', steam: 'prep', smoke: 'prep', - bed: 'grow', picker: 'grow', shelves: 'grow', attendant: 'hearth', cook: 'hearth', tier1: 'venue', tier2: 'venue', tier3: 'venue', + bed: 'grow', picker: 'grow', shelves: 'grow', attendant: 'hearth', slide: 'hearth', cook: 'hearth', tier1: 'venue', tier2: 'venue', tier3: 'venue', }; -// The tree's lines: a line's big upgrade needs its hand first, and the cook the attendant (the tiers need the one -// before, above). The two lines' hands wait for the practice fight, whose prize hires them. -const NEEDS: Partial> = { steam: 'washer', smoke: 'salter', shelves: 'picker', cook: 'attendant' }; +// The tree's lines: a line's big upgrade needs its hand first (the bowl slide the attendant: it takes over his +// carrying, and he works its motor), and the cook the attendant (the tiers need the one before, above). The two +// lines' hands wait for the practice fight, whose prize hires them. +const NEEDS: Partial> = { steam: 'washer', smoke: 'salter', shelves: 'picker', slide: 'attendant', cook: 'attendant' }; for (const u of UNLOCKS) { const room = MENU_OF[u.id]; if (room) Object.assign(u, { menu: room, ...below(ROOM_MENUS[room].x, ROOM_MENUS[room].z) }); @@ -384,6 +386,10 @@ function ringUnlock(t: Tile) { upgradeLine(up); on.built({ line: up, all: true }); } + if (id === 'slide') { + buySlide(); + on.built({ slide: true }); + } if (id === 'tier1' || id === 'tier2' || id === 'tier3') { const n = Number(id.slice(4)); setTier(n); showVenue(n); @@ -402,7 +408,7 @@ function syncRingTile(t: Tile) { const id = t.id; t.done = id === 'washer' || id === 'salter' || id === 'picker' || id === 'cook' || id === 'attendant' ? !!F.hires[id] : id === 'steam' ? F.lines.rice.up === 1 : id === 'smoke' ? F.lines.fish.up === 1 : id === 'shelves' ? F.lines.mush.up === 1 - : id.startsWith('tier') ? F.tier >= Number(id.slice(4)) : t.done; + : id === 'slide' ? F.slide : id.startsWith('tier') ? F.tier >= Number(id.slice(4)) : t.done; } t.tip = tipOf(t); if (t.done) t.d.mesh.visible = false; diff --git a/test/balance.sim.test.ts b/test/balance.sim.test.ts index dac6879..fa95117 100644 --- a/test/balance.sim.test.ts +++ b/test/balance.sim.test.ts @@ -6,7 +6,8 @@ // SIM=1 SIM_STAGE=3 SIM_MINUTES=40 SIM_OUT=sim3.txt npx vitest run test/balance.sim.test.ts // How the bot plays the cellar (bot.ts's STYLE): SIM_SHOW=0..12 the cooking show's stars it earns (0: never // plays it), SIM_SHOW_SECS its real seconds, SIM_SLAM=0 never slams, SIM_HELP=0 never helps a hired line, -// SIM_SKIP=cellarcrew,street5,tier3 never buys those (a level's id with the level it stops at). +// SIM_SKIP=cellarcrew,street5,tier3 never buys those (a level's id with the level it stops at). The cellar's played +// headless (src/headless.ts) unless SIM_FULL=1, and stops ten seconds after the buyout. import { writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { describe, it } from 'vitest'; @@ -21,6 +22,8 @@ describe.runIf(process.env.SIM)('balance', () => { ring: { sparred: false, practice: true, quiet: true }, } : undefined); const minutes = Number(process.env.SIM_MINUTES || 60), E = process.env; + // (the cellar's played without its looks, the same game many times faster: headless.ts; SIM_FULL=1 draws it all) + if (ring && E.SIM_FULL !== '1') (await import('../src/headless')).headless.on = true; const style = { show: Number(E.SIM_SHOW ?? STYLE.show), showSecs: Number(E.SIM_SHOW_SECS ?? STYLE.showSecs), slam: E.SIM_SLAM !== '0', help: E.SIM_HELP !== '0', skip: E.SIM_SKIP?.split(',') }; const { bought, samples, trace, fights, best, books } = play(g, minutes * 60, 0.05, ring ? await ringModules() : undefined, style); const out: string[] = []; @@ -37,7 +40,7 @@ describe.runIf(process.env.SIM)('balance', () => { } if (ring) { log(`\nBOOKS (chips): style ${JSON.stringify(style)}`); - log(` earned: bowls ${Math.round(books.bowls)}, prizes ${Math.round(books.prize)}`); + log(` earned: bowls ${Math.round(books.bowls)}, prizes ${Math.round(books.prize)}, Mama Moray's ${Math.round(books.resto)}`); log(` spent: ${Object.entries(books.spent).sort((a, b) => b[1] - a[1]).map(([k, v]) => `${k} ${Math.round(v)}`).join(', ')}`); log(` cooking shows: ${books.shows} (${books.showSecs} s of real time); till the buyout, stood by the pot ${Math.round(books.wait)} s, ${Math.round(books.idle)} s of it with the pot unable to cook`); for (const l of books.lines) log(' ' + l); diff --git a/test/bot.ts b/test/bot.ts index c26c955..d291c94 100644 --- a/test/bot.ts +++ b/test/bot.ts @@ -31,7 +31,7 @@ export const STYLE: Style = { show: 9, showSecs: 40, slam: true, help: true }; /** Stage 3's books: chips earned from bowls and prizes, spent by kind, the cooking shows played (and their seconds), * and the seconds the bot stood about by the pot: cooking there as it does (`wait`), or with nothing at all to do * (`idle`: the pot can't cook). */ -export interface Books { bowls: number; prize: number; spent: Record; shows: number; showSecs: number; idle: number; wait: number; lines: string[] +export interface Books { bowls: number; prize: number; resto: number; spent: Record; shows: number; showSecs: number; idle: number; wait: number; lines: string[] /** Each local fight as its fans sat down: when, how many came, how many ate, and the bowls left over in the pot, on the counter and on the stall. */ locals: { t: number; crowd: number; fed: number; left: number }[] } @@ -42,7 +42,7 @@ export function play(g: Game, seconds: number, dt = 0.05, ring?: Ring, style: St const bought: Purchase[] = [], samples: Sample[] = [], fights: Fought[] = []; const logged = new WeakSet(); let rangFc = -1, wasCalled = false, waited = false; - const books: Books = { bowls: 0, prize: 0, spent: {}, shows: 0, showSecs: 0, idle: 0, wait: 0, lines: [], locals: [] }; + const books: Books = { bowls: 0, prize: 0, resto: 0, spent: {}, shows: 0, showSecs: 0, idle: 0, wait: 0, lines: [], locals: [] }; const spend = (kind: string, v: number) => { if (kind === 'buyout' || !ring?.fight.F.boughtOut) books.spent[kind] = (books.spent[kind] ?? 0) + v; }; if (ring) { const was = ring.fight.on.earn; @@ -280,7 +280,7 @@ export function play(g: Game, seconds: number, dt = 0.05, ring?: Ring, style: St const READY = { rice: 'rrice', fish: 'cfish', mush: 'mush' } as const, HIRE = { rice: 'washer', fish: 'salter', mush: 'picker' } as const; // a boss, once the gong says his whole crowd will turn if (!F.rush && f.nextBoss()) { - const fc = f.forecast(); + const fc = f.forecast(0, { need: f.toBeat(f.nextBoss()!) }); if (fc) best.set(fc.boss.name, Math.max(best.get(fc.boss.name) ?? 0, fc.turned)); if (fc) lastFc = `${fc.boss.face} ${fc.turned}/${f.toBeat(fc.boss)}`; // (the gong's panel says it from the gong: walk over, and ring it only if it still says so there) @@ -292,7 +292,7 @@ export function play(g: Game, seconds: number, dt = 0.05, ring?: Ring, style: St } // not yet, but the cooking show's bonus would do it: play it at the pot first (the game holds still meanwhile) const tier = f.BONUS_TIERS.find(x => style.show >= x.min)!; - const fb = tier.pct && f.canShow() && !F.called ? f.forecast(0, { bonus: tier.pct }) : null; + const fb = tier.pct && f.canShow() && !F.called ? f.forecast(0, { bonus: tier.pct, need: f.toBeat(f.nextBoss()!) }) : null; if (fb && fb.turned >= f.toBeat(fb.boss)) { if (near(P.POT_PAD, 0.3)) { f.showResult(style.show); @@ -305,7 +305,7 @@ export function play(g: Game, seconds: number, dt = 0.05, ring?: Ring, style: St if (back.count('bowl') && F.stall.length < f.STALL_CAP) { go(P.BAR_PAD, () => !back.count('bowl') || F.stall.length >= f.STALL_CAP, 25); return; } // (ready ones with room for them on the ledge: with it full, they're no use at the pot) const carrying = () => f.inPot().some(k => back.count(READY[k]) && F.pantry[k] < f.LEDGE_CAP); - if (carrying()) { go(P.POT_PAD, () => !carrying() && (!!F.hires.cook || !f.canCook() || f.potFull()), 25); return; } + if (carrying()) { go(P.POT_PAD, () => !carrying() && (!!F.hires.cook || !f.canCookNow()), 25); return; } // raw ones for a line that's been hired out from under them: into the bin, so their arms are free for bowls const toss = () => { const bin = cellar.KITCHEN_BINS[0]; go({ x: bin.x + 0.95, z: bin.z }, () => back.n === 0, 10, true); }; if ((['rice', 'fish'] as const).some(k => F.hires[HIRE[k]] && back.count(k)) && !back.count('bowl') && !carrying()) { toss(); return; } @@ -313,7 +313,8 @@ export function play(g: Game, seconds: number, dt = 0.05, ring?: Ring, style: St go(P.COUNTER_PAD, () => !back.hasRoom() || !F.counter.length, 15); return; } - if (!F.hires.cook && f.canCook() && !f.potFull()) { go(P.POT_PAD, () => !f.canCook() || f.potFull(), 15); return; } + // (with the pot full and the ledge too, a batch goes up the belt to Mama Moray's) + if (!F.hires.cook && f.canCookNow()) { go(P.POT_PAD, () => !f.canCookNow(), 15); return; } // the lines with no hire, the one the pot's shortest of first: ready ones out, raw ones in; and with nothing else // to do, a hand on the lines that have one (a player wouldn't stand about) // (not in a boss's rush, though: then the pot's theirs) @@ -331,12 +332,15 @@ export function play(g: Game, seconds: number, dt = 0.05, ring?: Ring, style: St const open = free.reduce((sum, [, n]) => sum + n, 0); if (open > raw && room()) { go(P.BIN_PADS[k], () => back.count(k) >= open || !back.hasRoom(), 10); return; } } - // arms full of raw ones there's nowhere for: into the bin too - if (!room() && !back.count('bowl') && !carrying()) { toss(); return; } + // arms full of raw ones there's nowhere for: into the bin too (not ready ones: they wait at the pot for room on + // the ledge; unless bowls on the counter want carrying to the stall, with the ledge full and nobody else to) + const ready = () => f.inPot().some(k => back.count(READY[k])); + const bowlsWait = !F.hires.attendant && F.counter.length > 0 && F.stall.length < 6; + if (!room() && !back.count('bowl') && !carrying() && (!ready() || bowlsWait)) { toss(); return; } // (nothing to do: waiting by the pot) const t0 = t; go(P.POT_PAD, () => { - if (near(P.POT_PAD, 0.3) && !F.boughtOut) { books.wait += dt; if (!f.canCook() || f.potFull()) books.idle += dt; } + if (near(P.POT_PAD, 0.3) && !F.boughtOut) { books.wait += dt; if (!f.canCookNow()) books.idle += dt; } return t - t0 >= 1; }, 1); } @@ -395,6 +399,8 @@ export function play(g: Game, seconds: number, dt = 0.05, ring?: Ring, style: St const end = t + seconds; while (t < end) { + // (in the cellar, done ten seconds after the buyout: nothing after it is reported) + if (ring?.fight.F.boughtOut) { const out = bought.find(b => b.id === 'buyout'); if (out && t - out.t > 10) break; } if (!plan) decide(); if (plan) walk(); step(); diff --git a/test/cellarLooks.test.ts b/test/cellarLooks.test.ts index 8b52314..3f739a3 100644 --- a/test/cellarLooks.test.ts +++ b/test/cellarLooks.test.ts @@ -51,15 +51,15 @@ describe('the cellar upgrades\' looks', () => { }, 60_000); it('swaps the soup square\'s bamboo for its own look, and stops at the fourth level', async () => { - const { g, cellar: c, shown, buy } = await cellar(); + const { g, cellar: c, shown, buy, SPOT, SPOT_WAIT } = await cellar(); expect(c.barFrame.visible).toBe(true); buy('bowls'); expect(c.barFrame.visible).toBe(false); expect(shown('bowls')).toEqual([1, 1]); buy('bowls', 3); expect(shown('bowls')).toEqual([3, 4]); - // a fifth level's no new look, and no close-up - g.run(3); + // a fifth level's no new look, and no close-up (once the last one's over) + g.run(SPOT_WAIT + SPOT.over + SPOT.hold + SPOT.back + 0.3); buy('bowls'); expect(shown('bowls')).toEqual([3, 4]); g.run(1.5); diff --git a/test/chanko.test.ts b/test/chanko.test.ts index 1e79d76..c0829b0 100644 --- a/test/chanko.test.ts +++ b/test/chanko.test.ts @@ -1,6 +1,6 @@ // The cooking show at the pot (chanko.ts): the Cook button at the pot, the game holding still while it's on, its four // steps (cut, throw, season, stir) and their stars, and the bonus the stars buy the pot (fight.ts): bigger batches for -// a while, the cook's 70% of them too, and no new show until it's run out. It's a bonus game alone (it takes nothing +// a while, the cook's too, and no new show until it's run out. It's a bonus game alone (it takes nothing // and cooks nothing), every piece that's cut is thrown in, the stir starts on the soup, a show left half way carries // on where it was (saved with the game too), and nothing stands in the camera's way. import { Box3, Vector3 } from 'three'; @@ -76,16 +76,14 @@ async function atPot() { } describe('the cooking show at the pot', () => { - it('a bonus makes the pot\'s batches bigger for a while, the cook\'s too, and counts down', async () => { + it('a bonus makes the pot\'s batches bigger for a while (the cook\'s as well as the player\'s), and counts down', async () => { const { fight, F } = await atPot(); expect(fight.batchBowls()).toBe(20); - expect(fight.cookBowls()).toBe(14); expect(fight.canShow()).toBe(true); expect(fight.showResult(5).pct).toBe(0); expect(F().bonus.t).toBe(0); expect(fight.showResult(9)).toMatchObject({ pct: 35, name: 'Hearty' }); expect(fight.batchBowls()).toBe(27); - expect(fight.cookBowls()).toBe(19); expect(fight.canShow()).toBe(false); expect(fight.showResult(12).pct).toBe(50); expect(fight.batchBowls()).toBe(30); diff --git a/test/headless.test.ts b/test/headless.test.ts new file mode 100644 index 0000000..6585d88 --- /dev/null +++ b/test/headless.test.ts @@ -0,0 +1,28 @@ +// The balance sims play the cellar headless (src/headless.ts: what only draws, moves or sounds left out of the tick). +// That's only worth it while it's the same game: the bot's first minutes down there, played both ways, buy the same +// things at the same moments and fight the same fights. +import { describe, expect, it } from 'vitest'; +import { play, ringModules, STYLE } from './bot'; +import { bought as tiles, FLOE_SUSHI, loadGame, MARKET } from './helpers'; + +async function run(headless: boolean) { + const g = await loadGame({ + tiles: tiles(...MARKET, 'sushi', ...FLOE_SUSHI, 'dohyo'), fugu: 2, level: 1, reviews: Array(20).fill(4.6), + ring: { sparred: false, practice: true, quiet: true }, + }); + (await import('../src/headless')).headless.on = headless; + try { + const { bought, fights } = play(g, 6 * 60, 0.05, await ringModules(), STYLE); + return { bought: bought.map(b => `${b.t.toFixed(2)} ${b.id}`), fights: fights.map(f => `${f.t.toFixed(2)} ${f.boss} ${f.turned}/${f.n} ${f.win}`) }; + } finally { + (await import('../src/headless')).headless.on = false; + } +} + +describe('the headless sims', () => { + it('play the cellar just as the whole game does', async () => { + const full = await run(false), headless = await run(true); + expect(full.fights.length).toBeGreaterThan(0); + expect(headless).toEqual(full); + }, 120_000); +}); diff --git a/test/helpers.ts b/test/helpers.ts index 916498f..15958b1 100644 --- a/test/helpers.ts +++ b/test/helpers.ts @@ -37,7 +37,7 @@ export interface SaveFixture { /** Floe Sushi's upgrades (stage 2), in order. */ export const FLOE_SUSHI = ['paddy', 'seats', 'chef', 'farmer', 'porter', 'plot2', 'tables', 'chef3', 'tables2', 'plot3', 'kiosk', 'premium']; /** A cellar (stage 3) with all five bosses beaten and the venue built up: its last stretch, before the buyout. */ -export const RING_DONE = { beaten: 5, tier: 3, hires: { washer: true, salter: true, picker: true, cook: true, attendant: true } }; +export const RING_DONE = { beaten: 5, tier: 3, slide: true, hires: { washer: true, salter: true, picker: true, cook: true, attendant: true } }; /** Tiles entries for already-bought upgrades. */ export const bought = (...ids: string[]) => ids.map(id => ({ id, paid: 999, done: true })); diff --git a/test/softlock.test.ts b/test/softlock.test.ts index 96f3fc6..4b8c1d9 100644 --- a/test/softlock.test.ts +++ b/test/softlock.test.ts @@ -73,7 +73,12 @@ describe('no way to get stuck', () => { const g = await loadGame({ tiles: bought(...SUSHI, 'dohyo'), fugu: 2, level: 1 }); expect(g.wallet.money).toBe(0); // by hand alone, with nothing bought: rice and fish through the machines, the pot, and bowls out to the fans - const { earned } = play(g, 4 * 60, 0.05, await ringModules()); + // (played headless, the same game without its looks: headless.test.ts) + const { headless } = await import('../src/headless'); + const ring = await ringModules(); + headless.on = true; + let earned = 0; + try { earned = play(g, 4 * 60, 0.05, ring).earned; } finally { headless.on = false; } expect(earned).toBeGreaterThanOrEqual(10_000); // the first rack's price, ten chips }); diff --git a/test/sound.test.ts b/test/sound.test.ts index c9a9b8e..ce1bca4 100644 --- a/test/sound.test.ts +++ b/test/sound.test.ts @@ -505,24 +505,24 @@ describe('music', () => { expect(m.harborK({ x: SHIP.x, z: SHIP.z })).toBe(1); }, 30_000); - it("the Undertow's band, down in the stage 3 cellar: through its doors down the tunnel, and the music in the club", async () => { + it("the Undertow's band, down in the stage 3 cellar: through the out-of-order stall in Mama Moray's restroom, and the music in the club", async () => { const s = await withSound({ tiles: bought(...MARKET, 'sushi', ...FLOE_SUSHI, 'dohyo'), fugu: 2, level: 1, reviews: Array(20).fill(5), ring: { beaten: 2, lines: { mush: { n: 1 } }, den: { door: true } }, }); const m = await import('../src/music'), { CY } = await import('../src/cellar'), plan = await import('../src/cellarPlan'); - const { club } = await import('../src/noir'), { DEN, PASSAGE } = s.g.layout; - const at = (x: number, z: number) => s.g.player.g.position.set(x, CY, z), x = (PASSAGE.x0 + PASSAGE.x1) / 2; + const { club } = await import('../src/noir'), { DEN, LOO } = s.g.layout; + const at = (x: number, z: number) => s.g.player.g.position.set(x, CY, z), mid = (plan.CLUB_DOOR.z0 + plan.CLUB_DOOR.z1) / 2; at(plan.GONG_PAD.x, plan.GONG_PAD.z); s.play(2); expect(m.musicMix().noir).toBe(0); - // down the tunnel: the band through the doors, under the stage's music, louder nearer them - at(x, PASSAGE.z0 + 1); + // in the restroom: the band through the stall's back, under the stage's music, louder nearer it + at(LOO.x1 - 0.3, LOO.z0 + 0.5); s.play(1); const far = m.musicMix().noir; expect(far).toBeGreaterThan(0.1); expect(club.muffle).toBe(1); - at(x, plan.CLUB_DOOR.z0 - 1.5); + at(plan.STALLS.x + 0.2, mid); s.play(1); expect(m.musicMix().noir).toBeGreaterThan(far); expect(m.musicMix().stage).toBeGreaterThan(0.01); @@ -536,19 +536,19 @@ describe('music', () => { expect(heard.some(p => near(p.f, hz(38))) && heard.some(p => near(p.f, hz(45)))).toBe(true); expect(heard.some(p => p.type === 'custom')).toBe(true); expect(heard.filter(p => p.kind === 'tone' && p.type !== 'triangle').every(p => p.f < 4000)).toBe(true); - // back up the tunnel and out: gone + // back out through the restroom to the ring: gone at(plan.GONG_PAD.x, plan.GONG_PAD.z); s.play(2); expect(m.musicMix().noir).toBeLessThan(0.001); }, 30_000); - it("the Undertow's band plays on as you walk in through its doors and out again: one tune going on, never started over", async () => { + it("the Undertow's band plays on as you walk in through the out-of-order stall and out again: one tune going on, never started over", async () => { const s = await withSound({ tiles: bought(...MARKET, 'sushi', ...FLOE_SUSHI, 'dohyo'), fugu: 2, level: 1, reviews: Array(20).fill(5), ring: { beaten: 2, lines: { mush: { n: 1 } }, den: { door: true } }, }); const m = await import('../src/music'), { CY } = await import('../src/cellar'), plan = await import('../src/cellarPlan'); - const { club, eighth, NOIR_STEPS } = await import('../src/noir'), { DEN, PASSAGE } = s.g.layout; + const { club, eighth, NOIR_STEPS } = await import('../src/noir'), { DEN, LOO } = s.g.layout; const at = (x: number, z: number) => s.g.player.g.position.set(x, CY, z), mid = (plan.CLUB_DOOR.z0 + plan.CLUB_DOOR.z1) / 2; /** How far the band's got from `from`, against how far it should have by the clock since `t` (in eighths, round its * sixteen bars): 0 when it's played on without a break. */ @@ -556,13 +556,13 @@ describe('music', () => { const d = (m.noirHeard()! - from - (fake.now - t) / eighth()) % NOIR_STEPS; return Math.min(Math.abs(d), NOIR_STEPS - Math.abs(d)); }; - // down the tunnel, at the doors: the band through them - at((PASSAGE.x0 + PASSAGE.x1) / 2, mid); + // in the out-of-order stall, at its back: the band through it + at(LOO.x0 + 0.3, mid); s.play(2); const h0 = m.noirHeard()!, t0 = fake.now; expect(club.k).toBeGreaterThan(0.5); - // through the doors a step a frame: heard all the way, and on into the club without starting again - for (let x = PASSAGE.x0 + 0.3; x > DEN.x1 - 1.5; x -= 0.02) { + // through the stall's back a step a frame: heard all the way, and on into the club without starting again + for (let x = LOO.x0 + 0.3; x > DEN.x1 - 1.5; x -= 0.02) { at(x, mid); s.play(1 / 60); expect(club.k, `x ${x.toFixed(2)}`).toBeGreaterThan(0.5); @@ -570,11 +570,11 @@ describe('music', () => { s.play(1); expect(club.k).toBe(1); expect(drift(h0, t0)).toBeLessThan(0.5); - // out to the ring, where it isn't heard, and back down the tunnel: still the same tune, where it would be by now + // out to the ring, where it isn't heard, and back into the restroom: still the same tune, where it would be by now at(plan.GONG_PAD.x, plan.GONG_PAD.z); s.play(4); expect(m.musicMix().noir).toBeLessThan(0.001); - at((PASSAGE.x0 + PASSAGE.x1) / 2, PASSAGE.z0 + 1); + at(LOO.x1 - 0.3, LOO.z0 + 0.5); s.play(1); expect(m.musicMix().noir).toBeGreaterThan(0.1); expect(drift(h0, t0)).toBeLessThan(0.5); diff --git a/test/stage3.test.ts b/test/stage3.test.ts index cf8f129..251084c 100644 --- a/test/stage3.test.ts +++ b/test/stage3.test.ts @@ -290,7 +290,7 @@ describe('the rooms', () => { // a wall between the rooms, and a way through each doorway (but the grow room's and the Undertow's and its side rooms', not yet found) expect(floorAt((L.ARENA.x1 + L.KITCHEN.x0) / 2, 2)).toBe(false); expect(floorAt((L.KITCHEN.x1 + L.PREP.x0) / 2, 2)).toBe(false); - for (const d of L.DOORS) expect(floorAt((d.x0 + d.x1) / 2, (d.z0 + d.z1) / 2)).toBe(d.b !== 'grow' && d.b !== 'passage' && d.b !== 'den' && d.b !== 'loans' && d.b !== 'vip' && d.b !== 'cards'); + for (const d of L.DOORS) expect(floorAt((d.x0 + d.x1) / 2, (d.z0 + d.z1) / 2)).toBe(d.b !== 'grow' && d.b !== 'den' && d.b !== 'loans' && d.b !== 'vip' && d.b !== 'cards'); expect(floorAt(16, -4)).toBe(false); // where everything is expect(cellar.roomAt(cellar.RING)).toBe('arena'); @@ -506,12 +506,13 @@ describe('the rooms', () => { }; // the walls' tops: every low one's rail at one height, just over the earth's cut (between rooms and round the // outside, the prep hall's stone back wall too); every tall one's cap at one height, just over the bank behind it - // (the arena's piles, north and west, and the office's own wall); and every doorway's lintel at one height - const low = [[-5, L.ARENA.z1 + 0.1], [L.ARENA.x1 + 0.3, 2], [L.KITCHEN.x1 + 0.3, 2], [L.CRAFT.x0 + 0.5, L.CRAFT.z1 + 0.1], [16, L.PREP.z1 + 0.1], + // (the arena's piles, north and west, and the office's own wall); and every doorway's lintel at one height (but + // Mama Moray's hall's wide way into its dining wing, which has none) + const low = [[-5, L.ARENA.z1 + 0.1], [L.ARENA.x1 + 0.3, 2], [L.KITCHEN.x1 + 0.3, 2], [L.CRAFT.x0 + 0.5, L.WING.z1 + 0.1], [16, L.PREP.z1 + 0.1], [L.LIFT_ROOM.x1 + 0.1, 4.9], [13, L.PREP.z0 - 0.3], [L.OFFICE_ROOM.x1 + 0.3, -6]].map(([x, z]) => down(x, z)!); const tall = [[-5, L.ARENA.z0 - 0.75], [L.ARENA.x0 - 0.75, 0], [7, L.OFFICE_ROOM.z0 - 0.25]].map(([x, z]) => down(x, z)!); - const secret = (d: (typeof L.DOORS)[number]) => d.b === 'grow' || d.b === 'passage' || d.b === 'den' || d.b === 'loans' || d.b === 'vip' || d.b === 'cards'; - const lintels = [...L.DOORS.filter(d => !secret(d)).map(d => down((d.x0 + d.x1) / 2, (d.z0 + d.z1) / 2)!), ...plan.TUNNELS.map(t => + const secret = (d: (typeof L.DOORS)[number]) => d.b === 'grow' || d.b === 'den' || d.b === 'loans' || d.b === 'vip' || d.b === 'cards'; + const lintels = [...L.DOORS.filter(d => !secret(d) && !(d.a === 'resto' && d.b === 'wing')).map(d => down((d.x0 + d.x1) / 2, (d.z0 + d.z1) / 2)!), ...plan.TUNNELS.map(t => t.side === 'w' ? down(L.ARENA.x0 - 0.08, t.at + 0.5)! : down(t.at + 0.5, L.ARENA.z0 - 0.08)!)]; for (const h of low) expect(h).toBeCloseTo(low[0], 5); for (const h of tall) expect(h).toBeCloseTo(tall[0], 5); @@ -538,17 +539,17 @@ describe('the rooms', () => { expect(down(x, z, ground.hidden), `${x}, ${z}`).toBeNull(); } // (broken ground like the rest, level with the walls round it at its edges) - for (const [x, z, m] of [...open(L.GROW).map(([x, z]) => [x, z, ground.hidden] as const), ...[L.PASSAGE, L.DEN].flatMap(open).map(([x, z]) => [x, z, ground.denHidden] as const)]) { + for (const [x, z, m] of [...open(L.GROW).map(([x, z]) => [x, z, ground.hidden] as const), ...[L.DEN].flatMap(open).map(([x, z]) => [x, z, ground.denHidden] as const)]) { const h = down(x, z, m)!; expect(h, `${x}, ${z}`).toBeGreaterThan(GROUND.cut - 0.45); expect(h, `${x}, ${z}`).toBeLessThan(GROUND.cut + 0.85); } // out past the walls, broken ground about the cut (level with it right at the walls, lumpy further out); behind the // back walls, banked up; and no edge to it anywhere the camera sees - for (const [x, z] of [[-5, L.ARENA.z1 + 0.35], [7.5, L.CRAFT.z1 + 0.35], [16, L.PREP.z1 + 0.35]]) { + for (const [x, z] of [[-5, L.ARENA.z1 + 0.35], [7.5, L.WING.z1 + 0.35], [16, L.PREP.z1 + 0.35]]) { expect(earthAt(x, z), `${x}, ${z}`).toBeCloseTo(GROUND.cut, 2); } - for (const [x, z] of [[-5, L.ARENA.z1 + 1], [7.5, L.CRAFT.z1 + 1], [16, L.PREP.z1 + 1], [L.LIFT_ROOM.x1 + 1, 5], [L.GROW.x1 + 1, -5], [L.LOANS.x0 - 1, L.ARENA.z1 + 3]]) { + for (const [x, z] of [[-5, L.ARENA.z1 + 1], [7.5, L.WING.z1 + 1], [16, L.PREP.z1 + 1], [L.LIFT_ROOM.x1 + 1, 5], [L.GROW.x1 + 1, -5], [L.LOANS.x0 - 1, L.ARENA.z1 + 3]]) { const h = earthAt(x, z)!; expect(h, `${x}, ${z}`).toBeGreaterThan(GROUND.cut - 0.45); expect(h, `${x}, ${z}`).toBeLessThan(GROUND.cut + 0.85); @@ -566,8 +567,8 @@ describe('the rooms', () => { } // none of it is lit by the rooms' lights (its light's drawn into it), and a kerb of stone runs along the earth's // edge round every room, just past the low walls' rails (round the grow room's own walls, only once it's found) - for (const m of [ground.open, ground.hidden, ground.denHidden, ground.nearDen, ground.nearDenShut, ground.rocks, ground.kerb, ground.growKerb, ground.denKerb, ground.mouthKerb]) expect((m.material as import('three').Material).type).toBe('MeshBasicMaterial'); - for (const [x, z] of [[-5, L.ARENA.z1 + 0.35], [7.5, L.CRAFT.z1 + 0.35], [16, L.PREP.z1 + 0.35], [L.LIFT_ROOM.x1 + 0.35, 5]]) { + for (const m of [ground.open, ground.hidden, ground.denHidden, ground.nearDen, ground.nearDenShut, ground.rocks, ground.kerb, ground.growKerb, ground.denKerb, ground.shutKerb]) expect((m.material as import('three').Material).type).toBe('MeshBasicMaterial'); + for (const [x, z] of [[-5, L.ARENA.z1 + 0.35], [7.5, L.WING.z1 + 0.35], [16, L.PREP.z1 + 0.35], [L.LIFT_ROOM.x1 + 0.35, 5]]) { const h = down(x, z, ground.kerb)!; expect(h, `${x}, ${z}`).toBeGreaterThan(low[0]); expect(h, `${x}, ${z}`).toBeLessThan(low[0] + 0.1); @@ -611,15 +612,12 @@ describe('the rooms', () => { expect(down(L.GROW.x1 + 0.1, -5)).toBeCloseTo(low[0], 5); expect(down(13, L.PREP.z0 - 0.3)).toBeCloseTo(low[0], 5); expect(down(L.GROW.x1 + 0.35, -5, ground.growKerb)! - low[0]).toBeGreaterThan(0); - // and while the Undertow's a secret, the doorman's door stands across its tunnel's mouth (its lintel up over the - // low wall: southDoor.ts), kerbed in front; when it's found, the tunnel and the room are dug out, lit, kerbed round - const mouth = (L.PASSAGE.x0 + L.PASSAGE.x1) / 2; - expect(down(mouth, L.ARENA.z1 + 0.1)).toBeGreaterThan(low[0] + 0.8); - expect(down(mouth, L.ARENA.z1 + 0.35, ground.mouthKerb)! - low[0]).toBeGreaterThan(0); + // and while the Undertow's a secret, the doorman's door stands as the out-of-order stall's back, in the restroom's + // west wall (its lintel up over the low wall: southDoor.ts); when it's found, the room is dug out, lit, kerbed round + const stall = { x: plan.CLUB_DOOR.x, z: (plan.CLUB_DOOR.z0 + plan.CLUB_DOOR.z1) / 2 }; + expect(down(stall.x, stall.z)).toBeGreaterThan(low[0] + 0.6); cellar.showDen(); - expect(down(mouth, L.ARENA.z1 + 0.1)).not.toBeCloseTo(low[0], 2); - expect(down(mouth, L.ARENA.z1 + 0.35, ground.mouthKerb)).toBeNull(); - for (const [x, z] of [L.PASSAGE, L.DEN, L.VIP].flatMap(open)) { + for (const [x, z] of [L.DEN, L.VIP].flatMap(open)) { expect(earthAt(x, z), `${x}, ${z}`).toBeNull(); expect(down(x, z, ground.nearDen), `${x}, ${z}`).toBeNull(); } @@ -1258,7 +1256,7 @@ describe('the finishers', () => { for (const mv of Object.values(m)) expect(mv.name.split(' ').length).toBeLessThanOrEqual(3); } expect(end(0, 4)).toEqual(['heavy', 'PUFF BOUNCE']); - expect(end(810, 4)).toEqual(['grand', 'BIG SQUEEZE']); + expect(end(940, 4)).toEqual(['grand', 'BIG SQUEEZE']); // (the practice fight: the sparring partner's) expect(end(30, -1)).toEqual(['grand', 'BELT THROW']); }); @@ -1299,15 +1297,15 @@ describe('the finishers', () => { g.runUntil(() => { while (F().rush!.turned + F().stall.length < n && fight.toStall(false)); return F().rush!.done; }, 31 + fight.WALK_IN); g.runUntil(() => F().bout.phase === 'pay' && F().bout.t >= 0.6, 25); }; - // under half of the 151 needed: Crab shuffle + // under half of the 221 needed: Crab shuffle await lose(30); expect(F().bout.finish).toBe('heavy'); g.ui.hud(0); expect($('rushBell').textContent).toBe('CRAB SHUFFLE'); g.runUntil(() => F().bout.t >= landsOf(F().rush, 'heavy') + 0.2, 6); expect(down(dohyo.ring.champ, cellar)).toBe(true); - // just short, 140 of 151: Side step - await lose(140); + // just short, 210 of 221: Side step + await lose(210); expect(F().bout.finish).toBe('tight'); g.ui.hud(0); expect($('rushBell').textContent).toBe('SIDE STEP'); @@ -1454,7 +1452,7 @@ describe("the cellar's tiles", () => { // the ring's three levels along the top, then every room's upgrade and the venue's tiers, and the buyout at the end expect([...$('shopRows').querySelectorAll('[data-mod]')].map(b => (b as HTMLElement).dataset.mod)).toEqual(['bowls', 'street', 'cellarcrew']); expect([...$('shopRows').querySelectorAll('[data-tile]')].map(b => b.dataset.tile)).toEqual( - ['rack', 'washer', 'steam', 'barrel', 'salter', 'smoke', 'bed', 'picker', 'shelves', 'attendant', 'cook', 'tier1', 'tier2', 'tier3', 'buyout']); + ['rack', 'washer', 'steam', 'barrel', 'salter', 'smoke', 'bed', 'picker', 'shelves', 'attendant', 'slide', 'cook', 'tier1', 'tier2', 'tier3', 'buyout']); // a gate a column: the start, the practice fight (won: the fixture's sparred), then the bosses, Hakkaku next const gates = () => [...$('shopRows').querySelectorAll('.gate')].map(h => h.className.replace('gate ', '')); expect(gates()).toEqual(['start', 'won', 'next', 'later', 'later', 'later', 'later']); @@ -1581,31 +1579,132 @@ describe("the cellar's tiles", () => { expect(offered(g)).not.toContain('cook'); F().beaten = 3; g.run(0.1); - // (the cook needs the attendant first, in the shop's tree) + // (the cook needs the attendant first, in the shop's tree, and so does the bowl slide: it takes over his carrying) expect(offered(g)).not.toContain('cook'); + expect(offered(g)).not.toContain('slide'); g.unlocks.buyFromMenu(g.unlocks.tiles.find(t => t.id === 'attendant')!); g.run(0.1); expect(offered(g)).toContain('cook'); + expect(offered(g)).toContain('slide'); }); - it('a cook gets 70% of the player\'s batch out of the pot, and stands back while the player cooks', async () => { + it('a cook cooks full batches out of the pot, and ladles them out twice as fast', async () => { const { g, plan, fight, at, F } = await ring({ ring: { beaten: 3 } }); - expect(fight.COOK_BATCH).toBe(14); at(plan.WATCH); + // the pot ladled out onto the counter, a bowl every LADLE seconds, with nobody else there + const ladled = () => { F().pot = Array(fight.BATCH).fill(true); F().counter = []; F().pantry = { rice: 0, fish: 0, mush: 0 }; g.run(fight.LADLE * 8 + 0.01); return F().counter.length; }; + const alone = ladled(); + expect(alone).toBeGreaterThan(4); + // (the cook walks over to the pot, and cooks a batch: as many bowls as the player's) fight.hire('cook'); - // (the cook walks over to the pot, and cooks a batch: 14 bowls) + F().pot = []; F().counter = []; F().cooked = []; F().pantry = { rice: 4, fish: 4, mush: 4 }; g.runUntil(() => F().cooked.length > 0, 20); - expect(F().pot.length + F().counter.length).toBe(fight.COOK_BATCH); - // the player at the pot cooks a full one, the cook standing back - F().pot = []; F().counter = []; F().cooked = []; - at(plan.POT_PAD); - g.run(0.1); - F().pantry = { rice: 8, fish: 8, mush: 8 }; - g.runUntil(() => F().cooked.length >= 2, 5); - expect(F().pot.length + F().counter.length).toBe(2 * fight.BATCH); + expect(F().pot.length + F().counter.length).toBe(fight.BATCH); + // and with him at it, the pot's ladled out twice as fast + expect(ladled()).toBeGreaterThanOrEqual(2 * alone - 1); }, 30_000); + it("once Mama Moray's is open and the bowl line's in, what the pot can't hold, with the ledge stocked, goes there for her cut; never while a boss is in", async () => { + const { g, fight, F } = await ring({ ring: { beaten: 1 } }); + F().quiet = false; F().resto = 0; + const stock = () => { F().pot = Array(fight.POT_CAP).fill(true); F().pantry = { rice: fight.LEDGE_CAP, fish: fight.LEDGE_CAP, mush: fight.LEDGE_CAP }; }; + stock(); + // (a ruin out front till Word on the street opens her: nothing goes there; nor, open, till the bowl line's in to + // take it to her) + F().slide = true; + g.economy.mods.street = fight.RESTO_OPENS - 1; + expect(fight.restoWants()).toBe(false); + g.economy.mods.street = fight.RESTO_OPENS; + F().slide = false; + expect(fight.restoWants()).toBe(false); + F().slide = true; + expect(fight.restoWants()).toBe(true); + const was = F().earned.length; + fight.playerAtPot(0.6); + // (a whole batch up the belt, out of the ledge, and her cut of each bowl in the takings) + expect(F().resto).toBe(fight.BATCH); + expect(F().pantry.rice).toBe(fight.LEDGE_CAP - fight.PER_BATCH); + expect(F().earned.length).toBe(was + 1); + expect(F().earned[F().earned.length - 1][1]).toBeCloseTo(fight.bowlPrice(true) * fight.RESTO_SHARE * fight.BATCH); + // a boss in: the pot keeps it all for his crowd + stock(); + expect(fight.ringGong()).toBe(true); + while (F().called) fight.step(0.05); + stock(); + expect(fight.restoWants()).toBe(false); + }, 30_000); + + it("Mama Moray's starts a wreck, is cleared up, and opens at Word on the street's fifth level, with a word from her", async () => { + const { g, fight, F } = await ring({ ring: { beaten: 1 } }); + const rs = await import('../src/resto'); + for (const n of [0, 1]) { + g.economy.mods.street = n; g.run(0.2); + expect(rs.restoShows()).toMatchObject({ stage: 0, mama: false, diners: 0, wreck: true, belt: false }); + } + for (let n = 2; n < fight.RESTO_OPENS; n++) { + g.economy.mods.street = n; g.run(0.2); + expect(rs.restoShows()).toMatchObject({ stage: 1, mama: false, diners: 0, wreck: false, belt: false }); + } + // (open, with no bowl line in yet to bring her any, she's waiting on it; once it's in, she's open for business, and + // the line runs on past the stall to her) + F().slide = false; + g.economy.mods.street = fight.RESTO_OPENS; g.run(0.2); + expect(rs.restoShows()).toMatchObject({ stage: 2, mama: true, says: rs.WAITING, belt: false }); + expect(rs.restoShows()!.diners).toBeGreaterThan(0); + F().slide = true; g.run(0.2); + expect(rs.restoShows()).toMatchObject({ stage: 2, says: rs.OPENING, belt: true }); + // and a batch for her goes along the line, from the hopper by the pot + F().quiet = false; + fight.on.resto(fight.BATCH, 1); + g.run(1.5); + expect(rs.restoShows()!.under).toBeGreaterThan(0); + }, 30_000); + + it('the bowl slide is a bowl line in place of the counter, the pot to the soup square by itself, under the floor', async () => { + const { g, plan, fight, at, F, cellar } = await ring({ ring: { beaten: 1 } }); + const rk = await import('../src/ringKitchen'); + const table = () => { let up = false; cellar.bowlTable.g!.traverse(o => { if ((o as import('three').Mesh).isMesh && o.visible) up = true; }); return up; }; + at(plan.WATCH); + F().counter = Array(10).fill(false); F().stall = []; + g.run(1); + expect(F().stall.length).toBe(0); + expect(rk.slideParts.visible).toBe(false); + expect(table()).toBe(true); + fight.buySlide(); + g.run(0.5); + expect(F().stall.length).toBeGreaterThan(3); + // (the line laid, the counter gone; bowls on their way, from the hopper by the pot to the soup square) + expect(rk.slideParts.visible).toBe(true); + expect(table()).toBe(false); + expect(rk.slideShows()).toBeGreaterThan(0); + g.run(1); + expect(F().counter.length).toBe(0); + expect(F().stall.length).toBe(10); + // (the attendant, who carried them, goes over to work the line's motor, and carries no more) + fight.hire('attendant'); + F().counter = Array(10).fill(false); F().stall = []; + g.run(8); + const p = rk.crewPerson('attendant')!.position; + expect(Math.hypot(p.x - plan.MOTOR_AT.x, p.z - plan.MOTOR_AT.z)).toBeLessThan(0.3); + expect(F().crew.find(w => w.role === 'attendant')!.carry).toBe(null); + }, 30_000); + + it("with the player at the pot all through the rush, it turns at least what the gong promised", async () => { + const { fight: f, plan, at } = await ring({ level: 0 }); + f.resetFight(); + for (const k of ['rice', 'fish'] as const) for (let i = 0; i < 3; i++) f.addMachine(k); + for (const r of ['washer', 'salter', 'cook', 'attendant'] as const) f.hire(r); + for (let t = 0; t < 90; t += 0.05) f.step(0.05); + at(plan.POT_PAD); + const fc = f.forecast()!; + expect(f.ringGong()).toBe(true); + const tick = () => { f.step(0.05); f.playerAtPot(0.05); }; + while (f.F.called) tick(); + for (let n = 0; n < 4000 && !f.F.rush!.done; n++) tick(); + expect(f.F.rush!.turned).toBeGreaterThanOrEqual(fc.turned); + }); + it('opens with the bosses: the tiers build the venue up and bring more fans', async () => { const { g, buy, F, fight, cellar } = await ring({ money: 10_000_000 }); expect(offered(g)).not.toContain('tier1'); @@ -2001,6 +2100,32 @@ describe("the workshop's crafting bench", () => { } }); + it("a close-up looks at what was built even as it pops in from nothing, and never at somewhere nowhere near the player", async () => { + const { g, cellar } = await ring({ ring: { beaten: 1 } }); + const { BoxGeometry, Group, Mesh, MeshBasicMaterial, Vector3 } = await import('three'); + const sp = await import('../src/spotlight'); + const { view } = g.stage; + g.run(0.5); + const near = g.player.g.position.clone(); + // laid out from the world's middle (its foot's 30 m over the cellar), and only just starting to pop in + const group = new Group(), m = new Mesh(new BoxGeometry(0.5, 0.5, 0.5), new MeshBasicMaterial()); + m.position.set(near.x + 1.5, near.y, near.z); group.add(m); group.scale.setScalar(0.001); + cellar.cellar.add(group); + expect(sp.spotlight([group])).toBe(true); + g.run(sp.SPOT_WAIT + sp.SPOT.over + 0.3); + group.scale.setScalar(1); + expect(view.close).toBeLessThan(1); + expect(view.focus.distanceTo(m.getWorldPosition(new Vector3()))).toBeLessThan(0.3); + g.run(sp.SPOT.hold + sp.SPOT.back + 0.3); + // something far off from the player: no close-up at all + const far = new Mesh(new BoxGeometry(0.5, 0.5, 0.5), new MeshBasicMaterial()); + far.position.set(near.x, near.y + 60, near.z); cellar.cellar.add(far); + sp.spotlight([far]); + g.run(sp.SPOT_WAIT + sp.SPOT.over + 0.3); + expect(view.close).toBe(1); + expect(sp.spotOn()).toBe(false); + }, 30_000); + it('puts what it builds in the spotlight, as upstairs: the new rack, a hire, the racks swapped for steam baskets', async () => { const { g, plan, at, buy } = await ring({ money: 10_000_000, ring: { beaten: 2 } }); const { Box3, Vector3 } = await import('three'); @@ -2174,13 +2299,13 @@ describe('saving the cellar', () => { it('cleans up a bad cellar in a save, and starts an old design\'s ring over', async () => { const g = await loadGame(); - const { fightFrom } = await import('../src/fight'); + const { fightFrom, LEDGE_CAP } = await import('../src/fight'); const f = fightFrom({ beaten: 99, tier: -4, lines: { rice: { n: 40, m: [{ items: [5, 'x', -1] }] } }, pantry: { rice: 1e9 }, counter: 'no', hires: { cook: 1, wizard: true } }); expect(f.beaten).toBe(5); expect(f.tier).toBe(0); expect(f.lines.rice.n).toBe(6); expect(f.lines.rice.m[0].items).toEqual([1, 0]); - expect(f.pantry.rice).toBe(40); + expect(f.pantry.rice).toBe(LEDGE_CAP); expect(f.counter).toEqual([]); expect(Object.keys(f.hires)).toEqual(['cook']); expect(fightFrom('junk').beaten).toBe(0); diff --git a/test/undertow.test.ts b/test/undertow.test.ts index 4098421..4a0a96b 100644 --- a/test/undertow.test.ts +++ b/test/undertow.test.ts @@ -1,5 +1,5 @@ -// The Undertow (stage 3): the Family's club, down a long tunnel out of the arena's south wall and through a pair of -// doors with a doorman behind them, found when the second boss is beaten; the Family's three spread round the room, +// The Undertow (stage 3): the Family's club, behind the out-of-order stall in Mama Moray's restroom, through its back, +// the doorman's door, found when the second boss is beaten; the Family's three spread round the room, // each with a word for whoever steps up to his pad. import { describe, expect, it } from 'vitest'; import { bought, FLOE_SUSHI, loadGame, MARKET, type SaveFixture } from './helpers'; @@ -24,23 +24,29 @@ const standable = (g: Awaited>, cellar: typeof impor return Math.hypot(q.x - p.x, q.z - p.z) < 1e-6; }; const middle = (r: { x0: number; x1: number; z0: number; z1: number }) => ({ x: (r.x0 + r.x1) / 2, z: (r.z0 + r.z1) / 2 }); -/** Where the club's doors stand, in the tunnel's side. */ +/** Where the doorman's door stands, the out-of-order stall's back; where to try the stall from, just outside its front; + * and a step inside it. */ const doorAt = (plan: typeof import('../src/cellarPlan')) => ({ x: plan.CLUB_DOOR.x, z: (plan.CLUB_DOOR.z0 + plan.CLUB_DOOR.z1) / 2 }); +const front = (plan: typeof import('../src/cellarPlan')) => ({ x: plan.STALLS.x + 0.25, z: doorAt(plan).z }); +const inside = (plan: typeof import('../src/cellarPlan')) => ({ x: plan.STALLS.x - 0.5, z: doorAt(plan).z }); describe('the Undertow', () => { - it("is a secret until the second boss is beaten: a door across its tunnel in the arena's wall, nobody answering it, and nobody can walk in", async () => { - const { g, cellar, at } = await ring({ ring: { beaten: 1, lines: { mush: { n: 1 } } } }); + it("is a secret until the second boss is beaten: the out-of-order stall in Mama Moray's restroom, out of order, and nobody can walk in", async () => { + const { g, cellar, plan, at } = await ring({ ring: { beaten: 1, lines: { mush: { n: 1 } } } }); const S = await import('../src/southDoor'); const L = g.layout; expect(L.found.den).toBe(false); expect(cellar.parts.den.visible).toBe(false); expect(cellar.parts.denHidden.visible).toBe(true); - expect(cellar.parts.denWall.visible).toBe(false); expect(cellar.lights[9].intensity).toBe(0); - for (const d of L.DOORS.filter(t => t.b === 'passage' || t.b === 'den')) expect(standable(g, cellar, middle(d))).toBe(false); + for (const d of L.DOORS.filter(t => t.b === 'den')) expect(standable(g, cellar, middle(d))).toBe(false); expect(standable(g, cellar, middle(L.DEN))).toBe(false); - // knock: nobody answers, and the door stays shut - at({ x: (L.PASSAGE.x0 + L.PASSAGE.x1) / 2, z: L.ARENA.z1 - 0.6 }); + // the restroom's open to anyone, through the dining wing, but the stall's taped across + expect(standable(g, cellar, { x: L.LOO.x1 - 0.5, z: L.LOO.z0 + 1 })).toBe(true); + g.run(0.1); + expect(standable(g, cellar, { x: plan.STALLS.x, z: doorAt(plan).z })).toBe(false); + // try it: out of order, really, and its back stays shut + at(front(plan)); g.runUntil(() => S.wayState().says === S.LINES.nobody, 3); expect(S.wayState().scene).toBeNull(); expect(g.player.held).toBe(false); @@ -49,10 +55,9 @@ describe('the Undertow', () => { expect(L.found.den).toBe(false); }); - it("opens when Yokobashiri's beaten: Mr. Fugu hands over his matchbook, the doorman asks for the password, and the door swings in on the tunnel", async () => { + it("opens when Yokobashiri's beaten: Mr. Fugu hands over his matchbook, the doorman asks for the password, and the stall's back swings in on the club", async () => { const { g, plan, fight, cellar, at, F } = await ring({ ring: { beaten: 1, lines: { mush: { n: 1 } } } }); - const S = await import('../src/southDoor'), { DESK_PAD } = await import('../src/office'); - const front = { x: (g.layout.PASSAGE.x0 + g.layout.PASSAGE.x1) / 2, z: g.layout.ARENA.z1 - 0.6 }; + const S = await import('../src/southDoor'), { DESK_PAD } = await import('../src/office'), { fogLeft } = await import('../src/clubFog'); at(plan.GONG_PAD); g.run(0.1); expect(fight.ringGong()).toBe(true); @@ -61,17 +66,17 @@ describe('the Undertow', () => { expect(F().rush!.win).toBe(true); g.run(fight.BOUT.sit + fight.BOUT.fight + 0.5); expect(F().beaten).toBe(2); - // the card says there's a way south now; the room's not there yet + // the card says there's a way in now; the room's not there yet expect($('fightCard').hidden).toBe(false); - expect($('fightCard').textContent).toContain('๐Ÿ”“ New: steam baskets, a way south'); + expect($('fightCard').textContent).toContain('๐Ÿ”“ New: steam baskets, a way'); expect(g.layout.found.den).toBe(false); // the card goes, his crowd goes home, the next bout begins: Mr. Fugu wants a word, a "!" over him $('fightCard').click(); g.runUntil(() => S.wayState().calling, fight.EPIC_PAY + 1); expect(F().rush).toBeNull(); expect(g.layout.found.den).toBe(false); - // knocking before he's had his word: "Password?", and nobody's let in - at(front); + // trying the stall before he's had his word: "Password?" through a slot in its back, and nobody's let in + at(front(plan)); g.runUntil(() => S.wayState().says === S.LINES.ask, 3); g.runUntil(() => S.wayState().says === S.LINES.no, 3); expect(S.wayState().scene).toBeNull(); @@ -109,64 +114,72 @@ describe('the Undertow', () => { expect(F().den.book).toBe(true); expect(S.wayState().scene).toBeNull(); expect(g.player.held).toBe(false); - // back at the door: the slot, "Password?", the matchbook, and in - at(front); + // back at the stall: the tape torn away, a step in, the slot, "Password?", the matchbook, and in; the club's smoke + // rolling back off it + at(front(plan)); g.run(0.1); expect(S.wayState().scene).toBe('door'); expect(g.player.held).toBe(true); - g.runUntil(() => S.wayState().says === S.LINES.ask, 3); + g.runUntil(() => S.wayState().says === S.LINES.ask, 4); + expect(g.player.g.position.x).toBeLessThan(plan.STALLS.x); g.runUntil(() => S.wayState().says === S.LINES.ok, 3); g.runUntil(() => g.layout.found.den, 3); expect(F().den.way).toBe(true); + expect(F().den.door).toBe(true); g.runUntil(() => S.wayState().scene === null, 4); expect(g.player.held).toBe(false); expect(S.wayState().open).toBeGreaterThan(0.9); expect(cellar.parts.den.visible).toBe(true); expect(cellar.parts.denHidden.visible).toBe(false); expect(cellar.lights[9].intensity).toBeGreaterThan(0); - // into the tunnel, down to the club's doors: shut, till you come up to them (below) + g.run(0.5); + expect(fogLeft()).toBeLessThan(1); + // through it and into the club; the stall's front open now const L = g.layout; - for (const d of L.DOORS.filter(t => t.b === 'passage')) expect(standable(g, cellar, middle(d))).toBe(true); - expect(standable(g, cellar, middle(L.PASSAGE))).toBe(true); - expect(standable(g, cellar, doorAt(plan))).toBe(false); + expect(standable(g, cellar, doorAt(plan))).toBe(true); + expect(standable(g, cellar, middle(plan.CLUB_WAY))).toBe(true); + expect(standable(g, cellar, { x: plan.STALLS.x, z: doorAt(plan).z })).toBe(true); expect(cellar.roomAt(middle(L.DEN))).toBe('den'); - // the door shuts behind you, and opens as you come up to it again: from the arena, a good evening through the slot - at(middle(L.PASSAGE)); + // the door shuts behind you, and opens as you come up to it again: from the restroom, a good evening through the slot + at(middle(L.DEN)); g.run(0.5); at({ x: L.ARENA.x0 + 3, z: 0 }); g.run(3); expect(S.wayState().open).toBeLessThan(0.1); - at(front); + expect(standable(g, cellar, doorAt(plan))).toBe(false); + at(inside(plan)); g.run(0.2); expect(S.wayState().says).toBe(S.LINES.evening); - g.run(1.5); - expect(S.wayState().open).toBeGreaterThan(0.9); + g.runUntil(() => S.wayState().open > 0.9, 3); // (a whole bout, and the club built: longer than most) }, 40_000); - it('the way in: down a long dark tunnel to a pair of doors, the band louder through them; the first time they open on a burst of light and smoke', async () => { + it('the way in: the band through the out-of-order stall, louder nearer it; the first time it opens on the club, its smoke rolls back', async () => { const { g, plan, cellar, at, F } = await ring({ ring: { beaten: 2, lines: { mush: { n: 1 } }, den: { door: false } } }); - const D = await import('../src/doorway'), { club } = await import('../src/noir'), { fogLeft } = await import('../src/clubFog'); - const L = g.layout, x = (L.PASSAGE.x0 + L.PASSAGE.x1) / 2, door = doorAt(plan); + const D = await import('../src/doorway'), S = await import('../src/southDoor'), { club } = await import('../src/noir'), { fogLeft } = await import('../src/clubFog'); + const L = g.layout, door = doorAt(plan); expect(F().den.door).toBe(false); - // down the tunnel: the band, muffled, louder as you go - at({ x, z: L.PASSAGE.z0 + 0.5 }); + // in the restroom: the band, muffled, louder nearer the stall; and the view in a little closer + at({ x: L.LOO.x1 - 0.3, z: L.LOO.z0 + 0.5 }); g.run(0.2); const far = club.k; expect(far).toBeGreaterThan(0); - at({ x, z: door.z - 2.2 }); + at(front(plan)); g.run(0.2); expect(club.k).toBeGreaterThan(far); expect(club.muffle).toBe(1); expect(standable(g, cellar, door)).toBe(false); - // till the doors open, the club behind them is under a bank of smoke + g.run(3); + expect(g.stage.view.close).toBeCloseTo(D.CLOSE.loo, 2); + // till it's open, the club behind it is under a bank of smoke expect(fogLeft()).toBe(1); - // up to the doors: they swing open, the band comes up clear - at({ x, z: door.z }); - g.run(1); + // into the stall: a good evening through the slot, and its back swings open, the band coming through clear + at(inside(plan)); + g.runUntil(() => S.wayState().open > 0.9, 3); + g.run(0.5); expect(F().den.door).toBe(true); expect(g.player.held).toBe(false); - expect(D.doorState().open).toBeGreaterThan(0.9); + expect(S.wayState().open).toBeGreaterThan(0.9); expect(standable(g, cellar, door)).toBe(true); expect(club.muffle).toBeLessThan(0.1); // the smoke rolls back off the room @@ -187,26 +200,25 @@ describe('the Undertow', () => { expect(club.muffle, `${px}, ${pz}`).toBeGreaterThan(0); } } - // and in the club the camera's in closer than in the rest of the cellar (closer than down the tunnel) + // and in the club the camera's in closer than in the rest of the cellar (closer than in the restroom) at(middle(L.DEN)); g.run(3); expect(g.stage.view.close).toBeCloseTo(D.CLOSE.club, 2); - expect(D.CLOSE.club).toBeLessThan(D.CLOSE.tunnel); + expect(D.CLOSE.club).toBeLessThan(D.CLOSE.loo); // and the player's stride as much shorter, so the club's as big on foot as it looks expect(g.player.stride).toBeCloseTo(D.CLOSE.club, 2); // (back to full out in the cellar) at(middle(L.ARENA)); g.run(3); expect(g.player.stride).toBe(1); - // the doors shut behind you, and open as you come up to them again - at({ x, z: L.PASSAGE.z0 + 0.5 }); + // the stall's back shuts behind you, and opens as you come up to it again + at({ x: L.LOO.x1 - 0.3, z: L.LOO.z0 + 0.5 }); g.run(2); - expect(D.doorState().open).toBeLessThan(0.1); + expect(S.wayState().open).toBeLessThan(0.1); expect(standable(g, cellar, door)).toBe(false); - at({ x, z: door.z }); - g.run(1); - expect(D.doorState().open).toBeGreaterThan(0.9); - // (and they're open for a game loaded later, the smoke long gone) + at(inside(plan)); + g.runUntil(() => S.wayState().open > 0.9, 2); + // (and it's open for a game loaded later, the smoke long gone) expect(fogLeft()).toBe(0); }); @@ -443,9 +455,9 @@ describe('the Undertow', () => { const B = plan.DEN_BAR; expect(standable(g, cellar, { x: (B.x0 + B.x1) / 2, z: B.front - 0.2 })).toBe(false); for (const t of [...Object.values(plan.DEN_TABLES), plan.STAGE, ...Object.values(plan.BAND), plan.BAR_STOOLS[0]]) expect(standable(g, cellar, t)).toBe(false); - // and there's room to walk in from the doors, between the tables, to the bar and down to the far end of the room + // and there's room to walk in from the stall's back, between the tables, to the bar and down to the far end of the room const L = g.layout, z = doorAt(plan).z; - for (const p of [middle(plan.CLUB_WAY), { x: L.DEN.x1 - 0.5, z }, { x: L.DEN.x1 - 1.0, z }, { x: L.DEN.x1 - 1.3, z: plan.LOUNGE.z - 0.5 }, { x: L.DEN.x1 - 2.6, z: L.DEN.z0 + 3.4 }, { x: (plan.ARCHES[0] + plan.ARCHES[1]) / 2, z: L.DEN.z0 + 2.8 }, { x: (L.DEN.x0 + L.DEN.x1) / 2, z: L.DEN.z1 - 0.3 }]) { + for (const p of [{ x: L.DEN.x1 - 0.5, z }, { x: L.DEN.x1 - 1.0, z }, { x: L.DEN.x1 - 1.3, z: plan.LOUNGE.z - 0.5 }, { x: L.DEN.x1 - 2.6, z: L.DEN.z0 + 3.4 }, { x: (plan.ARCHES[0] + plan.ARCHES[1]) / 2, z: L.DEN.z0 + 2.8 }, { x: (L.DEN.x0 + L.DEN.x1) / 2, z: L.DEN.z1 - 0.3 }]) { expect(standable(g, cellar, p), JSON.stringify(p)).toBe(true); } }); @@ -612,30 +624,30 @@ describe('the Undertow', () => { at(plan.BOOTH_PADS.eddie); g.run(0.2); expect($('eddie').hidden).toBe(false); - // 30% of Dobu's 448 fans: 134 kept home, at 8 chips a fan; a 30% chance it gets out + // 30% of Dobu's 880 fans: 264 kept home, at 8 chips a fan; a 30% chance it gets out E.pickCut(3); - expect($('eddieFix').textContent).toBe('Fix ๐ŸŸ โˆ’134 fans ยท 30% caught'); + expect($('eddieFix').textContent).toBe('Fix ๐ŸŸ โˆ’264 fans ยท 30% caught'); // (each size says what it costs) - expect($('eddie').querySelector('[data-cut="3"]')!.textContent).toBe('๐Ÿก1,072'); + expect($('eddie').querySelector('[data-cut="3"]')!.textContent).toBe('๐Ÿก2,112'); expect(E.payEddie(3, () => 0.99)).toBe(true); - expect(g.wallet.money).toBe(5000 * 1000 - 1072 * 1000); + expect(g.wallet.money).toBe(5000 * 1000 - 2112 * 1000); expect(F().den.eddie).toEqual({ boss: 2, cut: 3, caught: false }); // one fix a boss expect(E.payEddie(1, () => 0.99)).toBe(false); expect(E.eddieMsg()).toContain("You'll know at the gong"); // saved as it's paid for: a reload doesn't undo it, or roll it again expect(fight.fightFrom(fight.saveFight()).den.eddie).toEqual({ boss: 2, cut: 3, caught: false }); - // the gong: your chances if the fix holds (314 fans) and if it's caught (all 448), a word for each face of his coin + // the gong: your chances if the fix holds (616 fans) and if it's caught (all 880), a word for each face of his coin at(plan.GONG_PAD); g.run(0.7); // (each as the forecasts have it, a cooking bonus bought at the gong included) const gong = await import('../src/gong'), dobu = fight.BOSSES[2]; const says = (kept: number) => gong.VERDICT[gong.verdict(dobu, fight.forecast(kept)!, dobu.crowd - kept, { at35: fight.forecast(kept, { bonus: 35 })!, at50: fight.forecast(kept, { bonus: 50 })! })]; - expect([...$('gongCast').children].map(r => r.textContent)).toEqual([`Fix holds${says(134)}`, `Caught${says(0)}`]); + expect([...$('gongCast').children].map(r => r.textContent)).toEqual([`Fix holds${says(264)}`, `Caught${says(0)}`]); expect($('gongCast').querySelectorAll('svg.mini use[href="#coinGrin"], svg.mini use[href="#coinGrimace"]')).toHaveLength(2); - // rung: it held, and he walks in with 134 fewer; the fix is spent + // rung: it held, and he walks in with 264 fewer; the fix is spent expect(fight.ringGong()).toBe(true); - expect(F().rush).toMatchObject({ n: 314, kept: 134, fix: { cut: 3, caught: false } }); + expect(F().rush).toMatchObject({ n: 616, kept: 264, fix: { cut: 3, caught: false } }); expect(fight.fixOn()).toBeNull(); expect(F().den.scandal.bouts).toBe(0); // (and a reload in the middle of it calls the rush off, but keeps the fix for next time: it was paid for) @@ -657,7 +669,7 @@ describe('the Undertow', () => { expect($('fightBanner').hidden).toBe(true); expect($('rush').hidden).toBe(true); g.run(3); - expect(F().rush).toMatchObject({ n: 314, walk: fight.WALK_IN, t: 0 }); + expect(F().rush).toMatchObject({ n: 616, walk: fight.WALK_IN, t: 0 }); expect(F().t).toBe(t); expect(g.player.held).toBe(true); // (a tap before it's said its piece does nothing) @@ -666,12 +678,12 @@ describe('the Undertow', () => { g.run(2.5); expect($('coin').className).toBe('hold ready'); expect($('coinHead').textContent).toBe('THE FIX HOLDS'); - expect($('coinSub').textContent).toBe('Dobu brings 314, not 448'); + expect($('coinSub').textContent).toBe('Dobu brings 616, not 880'); // tapped: he walks in $('coin').click(); expect($('coin').hidden).toBe(true); expect(g.player.held).toBe(false); - expect($('fightBanner').textContent).toBe('ๅ…ฅๅ ดDobu ๐ŸŸ ยท 314 fansTURN 158 TO WIN'); + expect($('fightBanner').textContent).toBe('ๅ…ฅๅ ดDobu ๐ŸŸ ยท 616 fansTURN 309 TO WIN'); g.run(1); expect(F().rush!.walk).toBeLessThan(fight.WALK_IN); expect($('rush').hidden).toBe(false); @@ -682,7 +694,7 @@ describe('the Undertow', () => { expect($('coin').className).toBe('caught'); g.run(6); expect($('coinHead').textContent).toBe('๐Ÿ—ž๏ธ CAUGHT!'); - expect($('coinSub').textContent).toBe('He brings all 448'); + expect($('coinSub').textContent).toBe('He brings all 880'); // (E does it too) g.press('e'); g.run(0.1); @@ -698,7 +710,7 @@ describe('the Undertow', () => { expect(E.payEddie(4, () => 0.2)).toBe(true); // (under 50%: caught) const fans = fight.crowdNow(); expect(fight.ringGong()).toBe(true); - expect(F().rush).toMatchObject({ n: 448, kept: 0, fix: { cut: 4, caught: true } }); + expect(F().rush).toMatchObject({ n: 880, kept: 0, fix: { cut: 4, caught: true } }); // 40% of your own fans stay away, for the next 2 local bouts expect(F().den.scandal).toEqual({ cut: 4, bouts: 2 }); expect(fight.crowdNow()).toBe(Math.round(fans * 0.6));