From 60030af3a8eff1f19e43ec4158e674375d53b37a Mon Sep 17 00:00:00 2001 From: bluedotiya <75704012+ChefControl@users.noreply.github.com> Date: Mon, 19 Oct 2026 03:17:31 +0300 Subject: [PATCH 1/8] Demo: &full buys all of the cellar (the Grand Hall's stands, every hand and line), for timing Fukure's rush Co-Authored-By: Claude Opus 5.5 --- src/fuguDemo.ts | 22 ++++++++++++++++------ 1 file changed, 16 insertions(+), 6 deletions(-) diff --git a/src/fuguDemo.ts b/src/fuguDemo.ts index cc6c3a9..eb02f8c 100644 --- a/src/fuguDemo.ts +++ b/src/fuguDemo.ts @@ -4,9 +4,10 @@ // 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 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). +// at the pot, for its cooking game; &full with all of the cellar bought, the Grand Hall's stands and every hand, line and +// level, for the biggest crowd: beaten=4&full is Fukure's rush, for timing the graphics). // Nothing is kept, so every visit starts over, the tutorial included. -import { POT_PAD } from './cellarPlan'; +import { MAX_MACHINES, POT_PAD } from './cellarPlan'; import { F, LEDGE_CAP } from './fight'; import { player } from './player'; import { REGISTER } from './restaurant'; @@ -17,23 +18,32 @@ import { FY } from './util'; export const DEMO = import.meta.env.VITE_FUGU_DEMO === '1'; +/** A cellar with all of it bought (&full): every hand, six of each machine upgraded, the slide, the Grand Hall's stands. */ +const FULL = (beaten: number) => ({ + beaten, tier: 3, slide: true, resto: 3, pantry: { rice: LEDGE_CAP, fish: LEDGE_CAP, mush: LEDGE_CAP }, + hires: { washer: true, salter: true, picker: true, attendant: true, cook: true }, + lines: { rice: { n: MAX_MACHINES, up: 1 }, fish: { n: MAX_MACHINES, up: 1 }, mush: { n: MAX_MACHINES, up: 1 } }, + den: { book: beaten >= 2, way: beaten >= 2, door: beaten >= 2, met: { eddie: beaten >= 2, harry: beaten >= 2, tommy: beaten >= 2 } }, +}); + export function startDemo() { // (and the tutorial's lessons from a visit before, which the device keeps apart from the save) forgetLessons(); const q = new URLSearchParams(location.search), cellar = q.has('cellar'); const chips = Math.max(0, Number(q.get('chips')) || 2_000), beaten = Math.max(0, Math.floor(Number(q.get('beaten')) || 0)); - const south = q.has('south'); + const south = q.has('south'), full = cellar && q.has('full'); deviceStore.write(JSON.stringify({ v: 4, savedAt: Date.now(), money: cellar ? chips * 1_000 : 999_900, // (stages 1 and 2 built; the ring's own tiles, its $1M buy-in included, wait for his offer) - tiles: tiles.filter(t => (t.stage < 3 && t.id !== 'dohyo') || (cellar && t.id === 'dohyo')).map(t => ({ id: t.id, paid: t.cost, done: true, open: true })), - mods: { fillets: 6, marketing: 8, training: 8, specials: 8, promo: 8, crew: 8 }, + tiles: tiles.filter(t => (t.stage < 3 && t.id !== 'dohyo') || (cellar && t.id === 'dohyo') || (full && t.stage === 3 && !t.gold)) + .map(t => ({ id: t.id, paid: t.cost, done: true, open: true })), + mods: { fillets: 6, marketing: 8, training: 8, specials: 8, promo: 8, crew: 8, ...(full ? { bowls: 8, street: 8, cellarcrew: 8 } : {}) }, reviews: Array(20).fill(5), pile: 20, back: 0, backRice: 0, c1: 0, c1c: 0, c2: 0, c2c: 0, fish: 12, rice: 12, plates: 8, cash: 240, boxes: 3, tcash: 0, field: 10, season: 3, seasonT: 60, fugu: cellar ? 2 : 0, till: 0, ...(cellar ? { level: 1 } : {}), // (the grow room open with its first bed, as beating the first boss leaves it; or, just come down, the doors shut // till the first soup's out and the practice fight still to come) - ...(cellar ? { v: 6, ring: beaten ? { beaten, lines: { mush: { n: 1 } }, den: { book: south, way: south } } : { practice: true, quiet: true } } : {}), + ...(cellar ? { v: 6, ring: full ? FULL(beaten) : beaten ? { beaten, lines: { mush: { n: 1 } }, den: { book: south, way: south } } : { practice: true, quiet: true } } : {}), })); load(); if (!cellar) player.g.position.set(REGISTER.x - 1.6, FY, REGISTER.z - 1.4); From daeb2056692bc69df13ba2575538c936ae34d536 Mon Sep 17 00:00:00 2001 From: bluedotiya <75704012+ChefControl@users.noreply.github.com> Date: Mon, 19 Oct 2026 03:55:20 +0300 Subject: [PATCH 2/8] Stage 3: shadows only from what could be on screen, plainer shadows for the fans and the Undertow's figures, a cheaper merge check; on Low four lamps, no small shadows, no fans' shadows, half the hatch's smoke - levelCull.ts: down in the cellar the sun's shadow box is fitted to the floor on screen, turned square to the screen; the Undertow's figures cast only while they or their shadow could be seen (a third of a boss night's shadow pass came from the club, off screen) - swarm.ts: the fans cast from a copy snapped to a 4 cm grid, the trim (eyes, buttons) not at all; none on Low - family.ts: a figure's merged parts cast a plainer copy of what was made to cast; on Low only the bigger pieces - batch.ts: what isn't merged is looked at one frame in eight (1.4 -> 0.5 ms a frame in Fukure's rush); a shadow put out or back moves a mesh to the right batch without a strike against it - cellar.ts: on Low, four stand-in lamps take the four nearest where the camera looks, fading as they swap; small or rod-thin things cast no shadow - hatchFx.ts: half the puffs on Low Co-Authored-By: Claude Opus 5.5 --- src/batch.ts | 29 ++++++++++--- src/cellar.ts | 80 +++++++++++++++++++++++++++++++++- src/cellarHands.ts | 6 ++- src/characters.ts | 6 ++- src/family.ts | 60 +++++++++++++++++++++++--- src/hatchFx.ts | 7 ++- src/levelCull.ts | 99 +++++++++++++++++++++++++++++++++++++++++-- src/swarm.ts | 45 +++++++++++++++++--- test/eyecandy.test.ts | 5 ++- 9 files changed, 309 insertions(+), 28 deletions(-) diff --git a/src/batch.ts b/src/batch.ts index b99a38f..cbfc872 100644 --- a/src/batch.ts +++ b/src/batch.ts @@ -20,17 +20,24 @@ const STILL = 1, SETTLED = 20; const BUDGET = 40000; /** Times out (fading over half a minute each) before something's left out (it keeps moving or blinking). */ const RESTLESS = 2; +/** What isn't merged is looked at in turns, one frame in this many (its own frame, a few frames apart from the rest): + * thousands of things stand about unmerged (the world up top, hidden while the player's in the cellar; what moves), + * and all that's lost is a few frames before something that's stood still a second is merged. What's merged is + * looked at every frame: it has to drop out the moment it moves or hides. */ +const TURNS = 8; type State = 'out' | 'joining' | 'in'; interface Member { o: Mesh; geo: BufferGeometry; mat: Material; cast: boolean; recv: boolean; ver: number; at: number[]; colour: number; still: number; moves: number; strikes: number; state: State; batch: Batch | null; mask: number; + /** Its turn (of TURNS), and the seconds since it was last looked at. */ + turn: number; since: number; } interface Batch { key: string; mat: Material; cast: boolean; recv: boolean; members: Set; mesh: Mesh | null; now: boolean } const members = new Set(), known = new WeakSet(), batches = new Map(), dirty = new Set(); const plain = Mesh.prototype, centre = new Vector3(), tint = new Color(); -let root: Object3D, drawn: Group, last = 0, scan = 0, rebuilds = 0; +let root: Object3D, drawn: Group, last = 0, scan = 0, rebuilds = 0, frame = 0, turns = 0; /** Starts merging what stands still in `scene`, before each time it's drawn. */ export function batchStill(scene: Object3D) { @@ -88,7 +95,7 @@ function discover() { if (!fits(o)) return; const m: Member = { o, geo: o.geometry, mat: o.material as Material, cast: o.castShadow, recv: o.receiveShadow, ver: -1, at: [], colour: 0, - still: 0, moves: 0, strikes: 0, state: 'out', batch: null, mask: o.layers.mask, + still: 0, moves: 0, strikes: 0, state: 'out', batch: null, mask: o.layers.mask, turn: turns++ % TURNS, since: 0, }; snap(m); members.add(m); @@ -103,7 +110,7 @@ function snap(m: Member) { } function moved(m: Member) { const o = m.o, e = o.matrixWorld.elements; - if (o.geometry !== m.geo || o.material !== m.mat || o.castShadow !== m.cast || o.receiveShadow !== m.recv) return true; + if (o.geometry !== m.geo || o.material !== m.mat) return true; if (version(m.geo) !== m.ver || colourOf(m.mat) !== m.colour) return true; for (let i = 0; i < 16; i++) if (e[i] !== m.at[i]) return true; return false; @@ -212,19 +219,31 @@ function sync() { const now = performance.now(), dt = Math.min(0.1, (now - last) / 1000); last = now; if ((scan -= dt) <= 0) { scan = 0.5; discover(); } + frame = (frame + 1) % TURNS; quietly(() => { for (const m of members) { - m.strikes = Math.max(0, m.strikes - dt / 30); + if (m.state === 'out' && m.turn !== frame) { m.since += dt; continue; } + const step = dt + m.since; + m.since = 0; + m.strikes = Math.max(0, m.strikes - step / 30); const seen = shown(m.o); if (seen === null) { leave(m); members.delete(m); known.delete(m.o); continue; } const move = moved(m); + // (its shadow put out or back, as Low does the small things' in the cellar: into a batch that casts or doesn't, + // as soon as it's looked at again, with no strike against it: it hasn't moved) + if (!move && seen && (m.o.castShadow !== m.cast || m.o.receiveShadow !== m.recv)) { + if (m.state !== 'out') { leave(m); m.strikes--; } + snap(m); + m.still = m.moves ? SETTLED : STILL; + continue; + } if (move || !seen || (m.mat as Material).transparent || !m.mat.visible) { if (m.state !== 'out') leave(m); if (move) { snap(m); m.moves++; } m.still = 0; continue; } - if (m.state !== 'out' || (m.still += dt) < (m.moves ? SETTLED : STILL) || m.strikes >= RESTLESS) continue; + if (m.state !== 'out' || (m.still += step) < (m.moves ? SETTLED : STILL) || m.strikes >= RESTLESS) continue; join(m); } let budget = BUDGET; diff --git a/src/cellar.ts b/src/cellar.ts index 93e0559..c43c85e 100644 --- a/src/cellar.ts +++ b/src/cellar.ts @@ -11,7 +11,7 @@ // This module is the rooms and their furniture; where things are is cellarPlan.ts, and the kitchen's lines, the bouts // and the swarm draw themselves (ringKitchen.ts, dohyo.ts, swarm.ts). import { - AdditiveBlending, BackSide, BoxGeometry, BufferGeometry, CatmullRomCurve3, CircleGeometry, Color, ConeGeometry, CylinderGeometry, DoubleSide, Float32BufferAttribute, Group, IcosahedronGeometry, + AdditiveBlending, BackSide, Box3, BoxGeometry, BufferGeometry, CatmullRomCurve3, CircleGeometry, Color, ConeGeometry, CylinderGeometry, DoubleSide, Float32BufferAttribute, Group, IcosahedronGeometry, type InstancedMesh, type Material, Matrix4, Mesh, MeshBasicMaterial, MeshLambertMaterial, type Object3D, PlaneGeometry, PointLight, type Side as Facing, TorusGeometry, TubeGeometry, Vector3, } from 'three'; @@ -21,6 +21,7 @@ import { BASINS, MAMA_COUNTER, STALL_DOOR, STALLS, STOOLS, TABLE_SEAT, TABLES, } from './cellarPlan'; import { Fugu } from './fugu'; +import { isHigh, onQuality } from './graphics'; 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, 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'; @@ -2969,7 +2970,82 @@ export function showCellar() { buildCellar(); cellar.visible = true; } /** The cellar's lights on while the player's down there, off above (each light costs every material in the scene). */ // (the grow room's glow is there all along, dark till it's found: lighting a new light mid-bout would recompile every // material in the scene) -export function cellarLights(on: boolean) { lights.forEach(l => { l.visible = on; }); } +export function cellarLights(on: boolean) { lit = on; showLamps(); } + +// ---------- Low: four lamps for fourteen ---------- +// Every light is worked out for every pixel of everything drawn, so on Low (graphics.ts) the cellar is lit by four +// lamps standing in for the four of its own nearest where the camera looks, each with that lamp's colour, strength, +// reach and place; High has all fourteen. A lamp that's no longer among the four fades out before its stand-in takes +// the next one, which fades in, so nothing pops as the player goes from room to room. (Switching between them +// recompiles the materials once, as the lights' count changes.) +const STAND_INS = 4, SWAP = 0.3; +/** The stand-ins (on dice of their own, as the Undertow's lamps are). */ +export const standIns = apart(() => Array.from({ length: STAND_INS }, () => new PointLight(0xFFFFFF, 0)), dice(0x1A4B5)); +/** Which lamp each stands in for (-1: none), and how far faded in. */ +const holds = standIns.map(() => ({ i: -1, k: 0 })); +standIns.forEach(l => { l.visible = false; scene.add(l); }); +let lit = false; +function showLamps() { + const low = !isHigh(); + lights.forEach(l => { l.visible = lit && !low; }); + standIns.forEach(l => { l.visible = lit && low; }); +} +onQuality(showLamps); +/** On Low, the four lit lamps nearest `at` (where the camera looks), every frame the player's down there. */ +export function updLamps(at: XZ, dt: number) { + sweepNowAndThen(dt); + if (isHigh() || !lit) return; + // (the lamps held now count a metre nearer, so two about as near don't swap back and forth) + const held = new Set(holds.map(h => h.i)); + const far = lights.map((l, i) => (l.intensity > 0 ? Math.hypot(l.position.x - at.x, l.position.z - at.z) - (held.has(i) ? 1 : 0) : Infinity)); + const want = far.map((_, i) => i).filter(i => far[i] < Infinity).sort((a, b) => far[a] - far[b]).slice(0, STAND_INS); + // (all of them at once the first time: just come down, or just switched to Low) + const first = holds.every(h => h.i < 0); + for (const [n, h] of holds.entries()) { + if (h.i >= 0 && want.includes(h.i)) h.k = Math.min(1, h.k + dt / SWAP); + else if ((h.k = Math.max(0, h.k - dt / SWAP)) === 0) { + h.i = want.find(i => !holds.some(o => o.i === i)) ?? -1; + if (first) h.k = 1; + } + const s = standIns[n], l = lights[h.i]; + if (!l) { s.intensity = 0; continue; } + s.color.copy(l.color); s.distance = l.distance; s.decay = l.decay; s.position.copy(l.position); s.intensity = l.intensity * h.k; + } +} +onQuality(() => { for (const h of holds) { h.i = -1; h.k = 0; } }); + +// ---------- Low: the small things cast no shadows ---------- +// Every shadow is the thing drawn again, so on Low only the big things down here cast one: the walls, the ring, the +// stands, the counters, the machines, people. What's small (under half a metre every way) or thin like a rod (a rail, +// a chair's leg, a rope) casts none. (The furniture baked into one mesh keeps its shadow whole: it's one thing to +// draw either way. Figures merged into a few meshes, family.ts, leave theirs out themselves.) +const SMALL = 0.5, ROD = 0.08, box3 = new Box3(), dims = new Vector3(); +let sweepT = 0; +/** Small or rod-thin, where it stands. */ +function slight(m: Mesh) { + const g = m.geometry; + if (!g.boundingBox) g.computeBoundingBox(); + const d = box3.copy(g.boundingBox!).applyMatrix4(m.matrixWorld).getSize(dims).toArray().sort((a, b) => a - b); + return d[2] < SMALL || d[1] < ROD; +} +/** Puts each small thing's shadow out (Low) or back (High). */ +function sweepShadows() { + const low = !isHigh(); + cellar.updateMatrixWorld(); + cellar.traverse(o => { + const m = o as Mesh; + if (!m.isMesh || (m as unknown as InstancedMesh).isInstancedMesh || m.onBeforeShadow !== Mesh.prototype.onBeforeShadow) return; + if (low && m.castShadow && slight(m)) { m.castShadow = false; m.userData.slight = true; } + else if (!low && m.userData.slight) { m.castShadow = true; delete m.userData.slight; } + }); +} +onQuality(sweepShadows); +/** On Low, now and then, for what's come into the cellar since (bowls, the machines bought). */ +function sweepNowAndThen(dt: number) { + if (isHigh() || (sweepT -= dt) > 0) return; + sweepT = 2; + sweepShadows(); +} // ---------- walking ---------- /** What a walker can't walk through down here, as boxes (centre and half-sizes, a walker's width included): the ring, diff --git a/src/cellarHands.ts b/src/cellarHands.ts index 291c85e..cd68fcf 100644 --- a/src/cellarHands.ts +++ b/src/cellarHands.ts @@ -4,7 +4,7 @@ // 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 { CY, updLamps } from './cellar'; import { batchBowls, bowlPrice, COUNTER_CAP, PER_BATCH, POT_CAP, F, fillMachine, ICON, inPot, lineRoom, mach, playerAtPot, STALL_CAP, takeBowl, takeReady, toLedge, toStall, doneCount, @@ -12,7 +12,7 @@ import { import { fly } from './holder'; import { type Kind, newBowl, newCuredFish, newMushroom, newReadyRice, newRice, newFillet } from './items'; import { player } from './player'; -import { scene } from './render'; +import { scene, sun } from './render'; import { barAt, counterAt, ledgeAt, machineSpot } from './ringKitchen'; import { pick, put } from './sfx'; import type { TipContent } from './ui'; @@ -56,6 +56,8 @@ function handIn(kind: Kind, to: () => ReturnType) { /** The cellar's work, every frame the player's down there. */ export function cellarWork(p: XZ, dt: number) { + // (Low's four lamps, round where the camera looks) + updLamps(sun.target.position, dt); player.tPick -= dt; player.tDrop -= dt; const back = player.back; // the dumbwaiter's plates: rice or fish, only as much as the racks (or the barrels) have room for diff --git a/src/characters.ts b/src/characters.ts index 5403ed4..6b2a041 100644 --- a/src/characters.ts +++ b/src/characters.ts @@ -628,7 +628,11 @@ export class Person extends Group { /** What's drawn of them now: the body's meshes, and an arm and a leg as each hangs from its pivot (for drawing a crowd * of them in batches: swarm.ts). */ baked() { - return { body: [this.shell.geometry, this.trim.geometry].filter(g => g !== NOTHING), arm: this.limbs[0].geometry, leg: this.limbs[2].geometry }; + return { + body: [this.shell.geometry, this.trim.geometry].filter(g => g !== NOTHING), arm: this.limbs[0].geometry, leg: this.limbs[2].geometry, + // (what of the body casts a shadow: the trim, the small things on it, doesn't) + casts: this.shell.geometry, + }; } /** Puts on the clothes for the season (none of them while disguised), and bakes what's worn. */ diff --git a/src/family.ts b/src/family.ts index d5e5c6e..d87f2f3 100644 --- a/src/family.ts +++ b/src/family.ts @@ -4,9 +4,11 @@ // chips; Three-Card Tommy, a catfish in tweed and a flat cap with the queen tucked in its band, runs the monte. As on // their rap sheets (the Undertow Files). Each stands at his booth, breathes, turns his head to whoever's in front of // him, and bobs his head to his words as he talks (chatter.ts). -import { CylinderGeometry, DoubleSide, Group, type Material, Matrix4, type Mesh, MeshBasicMaterial, MeshLambertMaterial, type Object3D, PlaneGeometry, RepeatWrapping, SphereGeometry, TorusGeometry, Vector3 } from 'three'; +import { type BufferGeometry, CylinderGeometry, DoubleSide, Group, type Material, Matrix4, type Mesh, MeshBasicMaterial, MeshLambertMaterial, type Object3D, PlaneGeometry, RepeatWrapping, SphereGeometry, TorusGeometry, Vector3 } from 'three'; import { mergeGeometries } from 'three/addons/utils/BufferGeometryUtils.js'; -import { apart, dice } from './kit'; +import { isHigh } from './graphics'; +import { apart, dice, quietly } from './kit'; +import { castsWhenSeen } from './levelCull'; import { canvasTex, copyGeo, G, mesh, paint, painted } from './render'; import type { XZ } from './util'; @@ -64,6 +66,8 @@ export class Hood extends Group { private said = 0; constructor(d: Dress) { super(); + // (a few thousand triangles of shadow each: cast only while he could be seen, levelCull.ts) + castsWhenSeen(this); for (const s of [-1, 1]) { const l = put(this, new Group()); l.position.set(s * 0.1, 0.3, 0); this.legs.push(l); @@ -300,15 +304,58 @@ export function mama() { export const family: Record = { eddie: eddie(), harry: harry(), tommy: tommy() }; // ---------- merging a figure's still parts ---------- +// A figure's shadow is cast by a plainer copy of the pieces that were made to cast one (fewer sides, as kit.ts' baked +// meshes cast theirs), after its own triangles in the same mesh; on Low (graphics.ts), only by the bigger of them, not +// the eyes, buttons, whiskers and chains. (Made on dice of their own, off the game's luck and the scenery's.) +const shadowDice = dice(0x5AD0F); +const plainers = new WeakMap(); +/** `geo`'s shadow shape: a round one with fewer sides, anything else itself. */ +function plainer(geo: BufferGeometry) { + let p = plainers.get(geo); + if (p) return p; + p = quietly(() => { + if (geo instanceof SphereGeometry) { const a = geo.parameters; return new SphereGeometry(a.radius, 6, 4, a.phiStart, a.phiLength, a.thetaStart, a.thetaLength); } + if (geo instanceof TorusGeometry) { const a = geo.parameters; return new TorusGeometry(a.radius, a.tube, 3, 8, a.arc); } + if (geo instanceof CylinderGeometry) { const a = geo.parameters; return new CylinderGeometry(a.radiusTop, a.radiusBottom, a.height, 6, 1, a.openEnded, a.thetaStart, a.thetaLength); } + return geo; + }, shadowDice); + plainers.set(geo, p); + return p; +} +/** Under this (in metres, its biggest side), or this thin in two directions (a whisker, a chain), a piece casts no + * shadow on Low. */ +const SMALL = 0.12, ROD = 0.03; +const size = new Vector3(); +/** Gives `o`, a figure's merged pieces, `shadow`, the plainer copies of those that cast, after its own triangles: on + * High all of them, on Low the bigger ones. */ +function castAs(o: Mesh, shadow: BufferGeometry[]) { + const big = shadow.filter(g => { + g.computeBoundingBox(); + const d = g.boundingBox!.getSize(size).toArray().sort((a, b) => a - b); + return d[2] >= SMALL && d[1] >= ROD; + }); + const own = o.geometry.attributes.position.count; + for (const g of shadow) paint(g, 0); + const all = mergeGeometries([o.geometry, ...shadow, ...big])!, hi = shadow.reduce((n, g) => n + g.attributes.position.count, 0); + const lo = big.reduce((n, g) => n + g.attributes.position.count, 0); + shadow.forEach(g => g.dispose()); + o.geometry.dispose(); + o.geometry = all; + all.setDrawRange(0, own); + o.onBeforeShadow = () => { if (isHigh()) all.setDrawRange(own, hi); else all.setDrawRange(own + hi, lo); }; + o.onAfterShadow = () => all.setDrawRange(0, own); + // (its drawing's its own: never merged with others, batch.ts) + o.userData.noBatch = true; +} const plain = (m: Material | Material[]): m is MeshLambertMaterial => !Array.isArray(m) && m instanceof MeshLambertMaterial && !m.map && !m.transparent && !m.vertexColors && m.emissive.getHex() === 0; /** Merges the plain meshes under `root` (but not into `keep`) into one, in `root`'s own space, its colours painted on. */ export function flatten(root: Object3D, keep: Set = new Set()) { root.updateMatrixWorld(true); const toRoot = root.matrixWorld.clone().invert(), m4 = new Matrix4(); - /** `m`'s geometry in `root`'s space, keeping only `attrs`. */ - const placed = (m: Mesh, attrs: string[]) => { - const g = m.geometry.index ? m.geometry.toNonIndexed() : copyGeo(m.geometry); + /** `m`'s geometry (or `geo` in its place) in `root`'s space, keeping only `attrs`. */ + const placed = (m: Mesh, attrs: string[], geo = m.geometry) => { + const g = geo.index ? geo.toNonIndexed() : copyGeo(geo); for (const k of Object.keys(g.attributes)) if (!attrs.includes(k)) g.deleteAttribute(k); m4.multiplyMatrices(toRoot, m.matrixWorld); g.applyMatrix4(m4); @@ -334,8 +381,9 @@ export function flatten(root: Object3D, keep: Set = new Set()) { if (flat.length) { const geos = flat.map(m => paint(placed(m, ['position', 'normal']), (m.material as MeshLambertMaterial).color.getHex())); for (const m of flat) m.removeFromParent(); - put(root, mesh(mergeGeometries(geos)!, painted, 0, 0, 0, true)); + const o = put(root, mesh(mergeGeometries(geos)!, painted, 0, 0, 0, true)); geos.forEach(g => g.dispose()); + quietly(() => castAs(o, flat.filter(m => m.castShadow).map(m => placed(m, ['position', 'normal'], plainer(m.geometry)))), shadowDice); } for (const [mat, ms] of alike) { if (ms.length < 2) continue; diff --git a/src/hatchFx.ts b/src/hatchFx.ts index 99414f5..1ea3369 100644 --- a/src/hatchFx.ts +++ b/src/hatchFx.ts @@ -6,6 +6,7 @@ import { AdditiveBlending, BackSide, Color, CylinderGeometry, Mesh, ShaderMaterial, Sprite, SpriteMaterial, type Texture, Vector3, } from 'three'; +import { isHigh } from './graphics'; import { quietly } from './kit'; import { canvasTex, scene } from './render'; @@ -93,9 +94,11 @@ export class HatchFx { private rnd() { return (this.seed = (this.seed * 16807) % 2147483647) / 2147483647; } - /** Puffs of smoke (or dust) out of the hatch. */ + /** Puffs of smoke (or dust) out of the hatch: half as many on Low (graphics.ts), each a see-through sprite drawn + * over the ones behind it. */ emit(o: Emit) { - for (let i = 0; i < o.n; i++) { + const n = isHigh() ? o.n : Math.ceil(o.n / 2); + for (let i = 0; i < n; i++) { const s = this.free.pop(); if (!s) return; const a = this.rnd() * Math.PI * 2, out = (o.out[0] + this.rnd() * (o.out[1] - o.out[0])) * (Math.sin(a) > 0 ? o.toCam ?? 1 : 1); diff --git a/src/levelCull.ts b/src/levelCull.ts index fdba418..c8dbc20 100644 --- a/src/levelCull.ts +++ b/src/levelCull.ts @@ -7,9 +7,17 @@ // What's up top is worked out from each thing's bounds, a few dozen things a frame (all of them on the way down), and // anything that comes down (a dumbwaiter's tray, what the player carries) shows the moment it's below. The effects' // and the fans' instanced meshes are never hidden: they're drawn on both levels, wherever their instances are. -import { Box3, type InstancedMesh, type Object3D } from 'three'; +// +// And down there, the sun's shadows are cast only over what's on screen: its shadow box, a fixed square 36 m across up +// in the world, is fitted to the floor the camera sees (and a little round it, for what stands off screen and throws +// its shadow on). Otherwise everything in that square is drawn again for the shadows, seen or not: from the ring, the +// Undertow's whole club, a third of a boss night's shadows. It's sharper for it, too. The floor seen is narrower +// near the camera than far off, so a box round it still takes in a corner or two off screen: the Undertow's figures, a +// few thousand triangles each, are looked at one by one, and cast their shadows only while they, or the shadow they'd +// throw, could be on screen. +import { Box3, Frustum, type InstancedMesh, Matrix4, type Mesh, type Object3D, Ray, Sphere, Vector3 } from 'three'; import { CELLAR_Y, level } from './layout'; -import { scene } from './render'; +import { camera, scene, sun, sunOff } from './render'; /** Anything wholly above this is up in the world. */ const ABOVE = CELLAR_Y + 15; @@ -31,7 +39,9 @@ const show = () => { /** Hides what's up in the world while the player's down in the cellar, for this frame's render; call what it * returns once it's drawn. */ export function hideAbove(): () => void { - if (!level.down) { wasDown = false; last = 0; return show; } + if (!level.down) { if (wasDown) { unfit(); seenAll(); } wasDown = false; last = 0; return show; } + fitShadows(); + cullFigures(); const kids = scene.children; if (!wasDown) { kids.forEach(judge); wasDown = true; } else for (let i = 0; i < Math.min(PER_FRAME, kids.length); i++) judge(kids[next = (next + 1) % kids.length]); @@ -43,3 +53,86 @@ export function hideAbove(): () => void { } /** How many things the last render left out (for tests). */ export const leftOut = () => last; + +// ---------- the shadows, fitted to the screen ---------- +/** Heights over the floor the screen's edges are taken at (the floor, and the tops of the walls it sees), and how far + * past them a shadow's caster may stand (a 3 m wall's shadow, with the sun as high as it is). */ +const LEVELS = [0, 3.2], MARGIN = 2.5, STEP = 0.5, MOST = 24; +const shadowCam = sun.shadow.camera, kept = { left: 0, right: 0, top: 0, bottom: 0 }; +let fitted = false; +const ray = new Ray(), hit = new Vector3(), ax = new Vector3(), ay = new Vector3(), az = new Vector3(), d = new Vector3(); +/** The shadow box round what the camera sees of the cellar's floor, in the sun's own view (centred where it looks). */ +function fitShadows() { + if (!fitted) { fitted = true; Object.assign(kept, { left: shadowCam.left, right: shadowCam.right, top: shadowCam.top, bottom: shadowCam.bottom }); } + // the sun's view, looking along -sunOff, turned so its up is the screen's up along the floor: the floor seen is a long + // narrow strip up the screen, and a box square to the screen fits it (one square to the world would take a slice + // twice as big, diagonally across it) (three.js' lookAt, as the shadow takes its view) + camera.updateMatrixWorld(); + shadowCam.up.set(-camera.matrixWorld.elements[8], 0, -camera.matrixWorld.elements[10]).normalize(); + az.copy(sunOff).normalize(); ax.copy(shadowCam.up).cross(az).normalize(); ay.copy(az).cross(ax); + let x0 = Infinity, x1 = -Infinity, y0 = Infinity, y1 = -Infinity; + for (const h of LEVELS) { + for (const [u, v] of [[-1, -1], [1, -1], [1, 1], [-1, 1]]) { + ray.origin.setFromMatrixPosition(camera.matrixWorld); + ray.direction.set(u, v, 0.5).unproject(camera).sub(ray.origin).normalize(); + // (a corner that misses the floor, over the horizon: as far as the fog) + const t = ray.direction.y < -1e-3 ? (CELLAR_Y + h - ray.origin.y) / ray.direction.y : 60; + ray.at(Math.min(t, 60), hit).sub(sun.target.position); + const px = d.copy(hit).dot(ax), py = d.copy(hit).dot(ay); + x0 = Math.min(x0, px); x1 = Math.max(x1, px); y0 = Math.min(y0, py); y1 = Math.max(y1, py); + } + } + // (in half-metre steps, so it isn't remade every frame as the camera eases) + const snap = (v: number, up: boolean) => Math.max(-MOST, Math.min(MOST, (up ? Math.ceil : Math.floor)(v / STEP) * STEP)); + const b = { left: snap(x0 - MARGIN, false), right: snap(x1 + MARGIN, true), bottom: snap(y0 - MARGIN, false), top: snap(y1 + MARGIN, true) }; + if (b.left !== shadowCam.left || b.right !== shadowCam.right || b.top !== shadowCam.top || b.bottom !== shadowCam.bottom) { + Object.assign(shadowCam, b); + shadowCam.updateProjectionMatrix(); + } +} +/** Back up in the world: the shadow box as it was. */ +function unfit() { + if (!fitted) return; + fitted = false; + Object.assign(shadowCam, kept); + shadowCam.up.set(0, 1, 0); + shadowCam.updateProjectionMatrix(); +} +/** The shadow box now (for tests). */ +export const shadowBox = () => ({ left: shadowCam.left, right: shadowCam.right, top: shadowCam.top, bottom: shadowCam.bottom }); + +// ---------- figures' shadows, while they could be seen ---------- +/** Figures that cast their shadows only while on screen (or their shadow is), each with whether it does now. */ +const figures = new Map(); +/** A figure (family.ts' Hood) that casts its shadow only while it could be seen (down in the cellar). */ +export function castsWhenSeen(o: Object3D) { figures.set(o, true); } +/** How tall a figure is taken to be, and how far its shadow reaches along the floor for each metre of it. */ +const TALL = 2.2, view = new Frustum(), vp = new Matrix4(), ball = new Sphere(), lay = new Vector3(); +function casting(o: Object3D, on: boolean) { + figures.set(o, on); + o.traverse(c => { + const m = c as Mesh; + if (!m.isMesh) return; + if (on) { if (m.userData.castWhenSeen) m.castShadow = true; } + else if (m.castShadow) { m.userData.castWhenSeen = true; m.castShadow = false; } + }); +} +function cullFigures() { + if (!figures.size) return; + vp.multiplyMatrices(camera.projectionMatrix, camera.matrixWorldInverse); + view.setFromProjectionMatrix(vp); + // (the shadow's tip, from a figure's top: away from the sun, as far along the floor as the sun is low) + lay.set(-sunOff.x, 0, -sunOff.z).multiplyScalar(TALL / sunOff.y); + for (const [o, was] of figures) { + o.getWorldPosition(ball.center); + ball.center.y += TALL / 2; + ball.center.addScaledVector(lay, 0.5); + ball.radius = TALL / 2 + lay.length() / 2 + 0.4; + const seen = o.visible && view.intersectsSphere(ball); + if (seen !== was) casting(o, seen); + } +} +/** Up in the world (nothing of theirs to see): all cast again, as they were. */ +function seenAll() { for (const [o, was] of figures) if (!was) casting(o, true); } +/** How many figures cast their shadows now (for tests). */ +export const figuresCasting = () => [...figures.values()].filter(Boolean).length; diff --git a/src/swarm.ts b/src/swarm.ts index 15cc5d6..e25de16 100644 --- a/src/swarm.ts +++ b/src/swarm.ts @@ -10,13 +10,13 @@ // They're meant to pass for the game's people, so they're made the same way, from the same wardrobe, and walk, sit and // cheer as people do. But there can be 500 of them, and a Person is six draw calls of its own, so the swarm is drawn in // 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. +// out, it stops casting shadows, and on Low it casts none; their shadows are a plainer copy of them (withShadow). import { BufferGeometry, CanvasTexture, Color, Float32BufferAttribute, InstancedMesh, Matrix4, Quaternion, Sprite, SpriteMaterial, Vector3 } from 'three'; 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'; -import { onQuality } from './graphics'; +import { isHigh, onQuality } from './graphics'; import { ARENA, inUndertow, level, pushOutOfBox } from './layout'; import { player } from './player'; import { FONT, painted, rr, scene } from './render'; @@ -67,13 +67,46 @@ function split(g: BufferGeometry): [BufferGeometry | null, BufferGeometry | null return geo; }) as [BufferGeometry | null, BufferGeometry | null]; } +/** A grid the shadows' copies are snapped to: a fan's shadow is a few pixels of the shadow map, and smaller than this + * shows nothing in it. */ +const SNAP = 0.04; +/** `g` (unindexed) with a plainer copy of itself after its own triangles, drawn only in the shadow pass (as kit.ts' baked + * meshes are): its corners snapped to a 4 cm grid, and the triangles that fold away to nothing left out (the eyes, + * the buttons, the round of a sleeve's ends). */ +function withShadow(g: BufferGeometry) { + const pos = g.attributes.position, col = g.attributes.color, nor = g.attributes.normal, n = pos.count; + const snap = (v: number) => Math.round(v / SNAP) * SNAP; + const p: number[] = []; + for (let t = 0; t < n; t += 3) { + const v = [0, 1, 2].map(i => [snap(pos.getX(t + i)), snap(pos.getY(t + i)), snap(pos.getZ(t + i))]); + const same = (a: number[], b: number[]) => a[0] === b[0] && a[1] === b[1] && a[2] === b[2]; + if (same(v[0], v[1]) || same(v[1], v[2]) || same(v[0], v[2])) continue; + p.push(...v[0], ...v[1], ...v[2]); + } + const m = p.length / 3, out = new BufferGeometry(); + const all = (a: { array: ArrayLike }, extra: number[]) => { const f = new Float32Array(n * 3 + m * 3); f.set(a.array as Float32Array); f.set(extra, n * 3); return f; }; + out.setAttribute('position', new Float32BufferAttribute(all(pos, p), 3)); + out.setAttribute('color', new Float32BufferAttribute(all(col, new Array(m * 3).fill(0)), 3)); + out.setAttribute('normal', new Float32BufferAttribute(all(nor, new Array(m * 3).fill(0)), 3)); + out.setDrawRange(0, n); + g.dispose(); + return out; +} function makeLooks() { looks = Array.from({ length: LOOKS }, (_, k) => { const p = crowdModel(crowdStyle(100_000 + k), k % 2 ? 'hooligan' : 'yakuza'), b = p.baked(); const halves = (g: BufferGeometry) => split(g).map((x, i) => [x, i === 0] as const).filter((x): x is [BufferGeometry, boolean] => !!x[0]); - const pieces = (geos: BufferGeometry[], per: number) => geos.flatMap(halves).map(([g, shirt]) => { - const im = new InstancedMesh(g, painted, PER * per); - im.count = 0; im.castShadow = shadows; im.receiveShadow = false; im.frustumCulled = false; + const pieces = (geos: BufferGeometry[], per: number) => geos.flatMap(g => halves(g).map(h => [...h, !b.body.includes(g) || g === b.casts] as const)).map(([g, shirt, casts]) => { + const im = new InstancedMesh(casts ? withShadow(g) : g, painted, PER * per); + // (on Low, none of them casts a shadow: hundreds of fans drawn again, for a few pixels each) + im.count = 0; im.castShadow = casts && shadows && isHigh(); im.receiveShadow = false; im.frustumCulled = false; + im.userData.casts = casts; + if (casts) { + const own = im.geometry.drawRange.count; + im.userData.shadowFrom = own; // (where its shadow's copy starts, as kit.ts has it) + im.onBeforeShadow = () => im.geometry.setDrawRange(own, Infinity); + im.onAfterShadow = () => im.geometry.setDrawRange(0, own); + } if (shirt) im.setColorAt(0, OURS); scene.add(im); return { im, shirt }; @@ -502,7 +535,7 @@ export function updSwarm(dt: number) { } function setShadows(on: boolean) { shadows = on; - allMeshes().forEach(im => { im.castShadow = on; }); + allMeshes().forEach(im => { im.castShadow = on && im.userData.casts && isHigh(); }); } // ---------- what they say ---------- // Now and then someone in the crowd shouts something, in a speech bubble over their head that goes where they go: the diff --git a/test/eyecandy.test.ts b/test/eyecandy.test.ts index b1d5ba7..ad03845 100644 --- a/test/eyecandy.test.ts +++ b/test/eyecandy.test.ts @@ -223,7 +223,10 @@ describe('eye candy', () => { const before = batchInfo().rebuilds; frame(); expect(merged()).toHaveLength(0); // not until they've stood still a moment - for (let i = 0; i < 25; i++) frame(); + // (what isn't merged is looked at one frame in eight, so it's merged a few frames after its second's up) + for (let i = 0; i < 20; i++) frame(); + expect(merged()).toHaveLength(0); + for (let i = 0; i < 20; i++) frame(); // the plain colours in one mesh, painted; the lamp (it glows) in its own; the person not at all expect(merged()).toHaveLength(2); expect(parts.map(p => p.layers.mask)).toEqual([0, 0, 0, 0]); From 618132c6f291788ac615ce393b66ac075aa9a586 Mon Sep 17 00:00:00 2001 From: bluedotiya <75704012+ChefControl@users.noreply.github.com> Date: Mon, 19 Oct 2026 04:15:00 +0300 Subject: [PATCH 3/8] Stage 3: the Undertow's figures in two versions, Low a notch plainer and High finer (coat buttons, hem and pockets, shirt cuffs, thumbs, round trousers and soled shoes), each ball given the sides its size wants; Low's lamps set each time the cellar's drawn - family.ts flatten: Low's merged mesh is made just as the one mesh was (on the game's luck), its balls a notch plainer; High's, on dice of its own, with the pieces only it has (fine/finePiece) and without Low's blocks (lowOnly) - angler.ts: Al's freckles, nostrils, buttons and welts baked from a six-sided dot (a centimetre across) - levelCull.ts eachDrawing: what's done each time the cellar's drawn, scene, menu or not (Low's four lamps) - cellar.ts: the stand-ins go to the lamps that light the spot most, not just the nearest Co-Authored-By: Claude Opus 5.5 --- src/angler.ts | 12 ++- src/cellar.ts | 17 +++- src/cellarHands.ts | 6 +- src/family.ts | 205 ++++++++++++++++++++++++++++++++++----------- src/levelCull.ts | 8 ++ 5 files changed, 188 insertions(+), 60 deletions(-) diff --git a/src/angler.ts b/src/angler.ts index 3969c79..d36eff1 100644 --- a/src/angler.ts +++ b/src/angler.ts @@ -8,6 +8,7 @@ import { type Object3D, Quaternion, RepeatWrapping, SphereGeometry, TorusGeometry, TubeGeometry, Vector3, } from 'three'; import { Hood } from './family'; +import { apart, dice } from './kit'; import { bake, canvasTex, G, mat, mesh, type Part } from './render'; const put = (parent: Object3D, m: T) => { parent.add(m); return m; }; @@ -21,6 +22,9 @@ const browGeo = new TorusGeometry(0.05, 0.05 * 0.011 / 0.084, 6, 16, Math.PI * 0 const GLINT = new MeshBasicMaterial({ color: 0xFFFFFF }); /** A smoother ball for his big sleek head. */ const smooth = new SphereGeometry(1, 36, 22); +/** A small ball, for the specks baked together (his freckles, nostrils, buttons): a few sides is round enough at a + * centimetre across. (On dice of its own: the game's luck is drawn as it was.) */ +const DOT = apart(() => new SphereGeometry(1, 6, 4), dice(0xD07)); const SKIN = 0x667C90, DARK = 0x4A5D71, BELLY = 0xD3DAD8, LIP = 0x56697D, MOUTH = 0x231820, FRECKLE = 0xB4C6D3; const SHIRT = 0xF3EFE6, VEST = 0x7E2236, VEST_DARK = 0x5A1726, BOW = 0x1D1A22, GARTER = 0xB0303F, GOLD = 0xE8B94A; @@ -100,9 +104,9 @@ export function angler(): { g: Hood; lure: Object3D; lids: Object3D[] } { /** A point on the waistcoat's front, `a` round from the middle, at height `y`, `out` off it. */ const facet = (a: number) => Math.cos(Math.PI / 10) / Math.cos(((a % (Math.PI / 5)) + Math.PI / 5) % (Math.PI / 5) - Math.PI / 10); const onVest = (a: number, y: number, out = 0) => new Vector3(Math.sin(a) * (vx(y) * facet(a) + out), y, Math.cos(a) * (vz(y) * facet(a) + out)); - const buttons = bake([0.42, 0.5, 0.58].map(y => ({ geo: G.sphere, at: onVest(0, y, 0.004).toArray() as [number, number, number], scale: [0.016, 0.016, 0.01] as [number, number, number] }))); + const buttons = bake([0.42, 0.5, 0.58].map(y => ({ geo: DOT, at: onVest(0, y, 0.004).toArray() as [number, number, number], scale: [0.016, 0.016, 0.01] as [number, number, number] }))); put(b, mesh(buttons, GOLD)); - const welts = bake([-1, 1].map(s => aimed(G.sphere, onVest(s * 0.6, 0.5, 0.002), new Vector3(Math.sin(s * 0.6), 0, Math.cos(s * 0.6)), [0.055, 0.011, 0.012], FRONT))); + const welts = bake([-1, 1].map(s => aimed(DOT, onVest(s * 0.6, 0.5, 0.002), new Vector3(Math.sin(s * 0.6), 0, Math.cos(s * 0.6)), [0.055, 0.011, 0.012], FRONT))); put(b, mesh(welts, VEST_DARK)); tube(b, GOLD, [onVest(0, 0.5, 0.008), onVest(0.18, 0.455, 0.012), onVest(0.38, 0.45, 0.012), onVest(0.55, 0.485, 0.008), onVest(0.6, 0.5, -0.004)], 0.0055, 0.0055, 20, 5); // the watch's bow and crown peeking out of the pocket @@ -144,7 +148,7 @@ export function angler(): { g: Hood; lure: Object3D; lids: Object3D[] } { put(h, mesh(G.sphere, DARK, ...pts[pts.length - 1].toArray())).scale.setScalar(0.0105); } // two small nostrils over the moustache - put(h, mesh(bake([-1, 1].map(s => { const { p, n } = skin(s * 0.07, 0.305, -0.002); return aimed(G.sphere, p, n, [0.009, 0.012, 0.005], FRONT); })), DARK)); + put(h, mesh(bake([-1, 1].map(s => { const { p, n } = skin(s * 0.07, 0.305, -0.002); return aimed(DOT, p, n, [0.009, 0.012, 0.005], FRONT); })), DARK)); // big half-lidded eyes, and brows, the right one a little raised const lids: Object3D[] = []; for (const s of [-1, 1]) { @@ -157,7 +161,7 @@ export function angler(): { g: Hood; lure: Object3D; lids: Object3D[] } { } // the freckles: across his cheeks under the eyes, and over his crown and down the back of his head const dots: Part[] = []; - const spot = (a: number, y: number, size: number) => { const { p, n } = skin(a, y, -0.001); dots.push(aimed(G.sphere, p, n, [size, size, size * 0.3], FRONT)); }; + const spot = (a: number, y: number, size: number) => { const { p, n } = skin(a, y, -0.001); dots.push(aimed(DOT, p, n, [size, size, size * 0.3], FRONT)); }; for (const s of [-1, 1]) for (const [a, y, z] of [[0.62, 0.31, 0.009], [0.7, 0.29, 0.007], [0.55, 0.29, 0.007], [0.78, 0.32, 0.008], [0.66, 0.335, 0.006]]) spot(s * a, y, z); for (const [a, y, z] of [[0.3, 0.47, 0.01], [-0.35, 0.48, 0.009], [0.9, 0.44, 0.011], [-0.95, 0.42, 0.01], [1.6, 0.4, 0.012], [-1.7, 0.43, 0.011], [2.3, 0.42, 0.012], [-2.4, 0.38, 0.011], [3.0, 0.45, 0.012], [2.7, 0.3, 0.01], [-2.8, 0.27, 0.01], [1.9, 0.27, 0.009], [-1.4, 0.3, 0.009], [3.1, 0.22, 0.01]]) spot(a, y, z); put(h, mesh(bake(dots), FRECKLE)); diff --git a/src/cellar.ts b/src/cellar.ts index c43c85e..58ce534 100644 --- a/src/cellar.ts +++ b/src/cellar.ts @@ -24,7 +24,8 @@ import { Fugu } from './fugu'; import { isHigh, onQuality } from './graphics'; 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, 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 CanvasTex, canvasTex, copyGeo, type Draw, FONT, G, mat, mesh, OFF, paint, rr, scene, sun } from './render'; +import { eachDrawing } from './levelCull'; import type { XZ } from './util'; export { cellarWay, roomAt } from './cellarPlan'; @@ -2995,10 +2996,16 @@ onQuality(showLamps); export function updLamps(at: XZ, dt: number) { sweepNowAndThen(dt); if (isHigh() || !lit) return; - // (the lamps held now count a metre nearer, so two about as near don't swap back and forth) + // (the ones that light the spot most, their strength falling off over their reach, and a little light still from + // those it's out of reach of, nearest first; the lamps held now count a fifth more, so two about as bright there + // don't swap back and forth) const held = new Set(holds.map(h => h.i)); - const far = lights.map((l, i) => (l.intensity > 0 ? Math.hypot(l.position.x - at.x, l.position.z - at.z) - (held.has(i) ? 1 : 0) : Infinity)); - const want = far.map((_, i) => i).filter(i => far[i] < Infinity).sort((a, b) => far[a] - far[b]).slice(0, STAND_INS); + const lit_ = lights.map((l, i) => { + if (l.intensity <= 0) return -1; + const d = Math.hypot(l.position.x - at.x, l.position.z - at.z), k = Math.max(0, 1 - d / (l.distance || 20)); + return (l.intensity * k * k + 0.01 / (1 + d)) * (held.has(i) ? 1.2 : 1); + }); + const want = lit_.map((_, i) => i).filter(i => lit_[i] >= 0).sort((a, b) => lit_[b] - lit_[a]).slice(0, STAND_INS); // (all of them at once the first time: just come down, or just switched to Low) const first = holds.every(h => h.i < 0); for (const [n, h] of holds.entries()) { @@ -3013,6 +3020,8 @@ export function updLamps(at: XZ, dt: number) { } } onQuality(() => { for (const h of holds) { h.i = -1; h.k = 0; } }); +// (each time the cellar's drawn, round where the camera looks) +eachDrawing(dt => updLamps(sun.target.position, dt)); // ---------- Low: the small things cast no shadows ---------- // Every shadow is the thing drawn again, so on Low only the big things down here cast one: the walls, the ring, the diff --git a/src/cellarHands.ts b/src/cellarHands.ts index cd68fcf..291c85e 100644 --- a/src/cellarHands.ts +++ b/src/cellarHands.ts @@ -4,7 +4,7 @@ // 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, updLamps } from './cellar'; +import { CY } from './cellar'; import { batchBowls, bowlPrice, COUNTER_CAP, PER_BATCH, POT_CAP, F, fillMachine, ICON, inPot, lineRoom, mach, playerAtPot, STALL_CAP, takeBowl, takeReady, toLedge, toStall, doneCount, @@ -12,7 +12,7 @@ import { import { fly } from './holder'; import { type Kind, newBowl, newCuredFish, newMushroom, newReadyRice, newRice, newFillet } from './items'; import { player } from './player'; -import { scene, sun } from './render'; +import { scene } from './render'; import { barAt, counterAt, ledgeAt, machineSpot } from './ringKitchen'; import { pick, put } from './sfx'; import type { TipContent } from './ui'; @@ -56,8 +56,6 @@ function handIn(kind: Kind, to: () => ReturnType) { /** The cellar's work, every frame the player's down there. */ export function cellarWork(p: XZ, dt: number) { - // (Low's four lamps, round where the camera looks) - updLamps(sun.target.position, dt); player.tPick -= dt; player.tDrop -= dt; const back = player.back; // the dumbwaiter's plates: rice or fish, only as much as the racks (or the barrels) have room for diff --git a/src/family.ts b/src/family.ts index d87f2f3..9c97dd9 100644 --- a/src/family.ts +++ b/src/family.ts @@ -4,10 +4,9 @@ // chips; Three-Card Tommy, a catfish in tweed and a flat cap with the queen tucked in its band, runs the monte. As on // their rap sheets (the Undertow Files). Each stands at his booth, breathes, turns his head to whoever's in front of // him, and bobs his head to his words as he talks (chatter.ts). -import { type BufferGeometry, CylinderGeometry, DoubleSide, Group, type Material, Matrix4, type Mesh, MeshBasicMaterial, MeshLambertMaterial, type Object3D, PlaneGeometry, RepeatWrapping, SphereGeometry, TorusGeometry, Vector3 } from 'three'; +import { type BufferGeometry, CylinderGeometry, DoubleSide, Group, type Material, Matrix4, Mesh, MeshBasicMaterial, MeshLambertMaterial, type Object3D, PlaneGeometry, RepeatWrapping, SphereGeometry, TorusGeometry, Vector3 } from 'three'; import { mergeGeometries } from 'three/addons/utils/BufferGeometryUtils.js'; -import { isHigh } from './graphics'; -import { apart, dice, quietly } from './kit'; +import { apart, detail, dice, quietly } from './kit'; import { castsWhenSeen } from './levelCull'; import { canvasTex, copyGeo, G, mesh, paint, painted } from './render'; import type { XZ } from './util'; @@ -15,6 +14,20 @@ import type { XZ } from './util'; export type Host = 'eddie' | 'harry' | 'tommy'; const GOLD = 0xF2C14E, SHADES = 0x15181D, TROUSERS = 0x24242C, SHOES = 0x15151A; +/** High's finer things' colours: a trouser's crease (a shade lighter) and cuff, a shoe's sole, a pocket square. */ +const CREASE = 0x34343F, CUFF = 0x2C2C36, SOLE = 0x0A0A0C, POCKET = 0xF2EEE2; +/** The coat's front, `y` up: how far out of his middle (it's a cone, 0.3 round at the hem to 0.22 at the top, made a + * little deeper). */ +const coatAt = (y: number) => (0.3 - (y - 0.3) / 0.6 * 0.08) * 1.05; +/** The dice High's finer pieces and each version's shapes are made on (flatten, below): their own, off the game's luck. */ +const shapeDice = dice(0x5AD0F), fineDice = dice(0xF1AE); +/** High's finer pieces' materials, their own (render.ts' shared ones are made when they always were). */ +const fineMats = new Map(); +const [cuffGeo, hemGeo, trouserGeo, cuffRingGeo] = apart(() => [ + new TorusGeometry(0.058, 0.011, 5, 12), new TorusGeometry(0.3, 0.012, 4, 24), + // (a trouser leg, round, as wide as Low's block at the hip and narrowing a little to the cuff, and the cuff's turn-up) + new CylinderGeometry(0.072, 0.064, 0.3, 10), new TorusGeometry(0.064, 0.012, 4, 14), +], dice(0xC0FF)); /** The top half of a ball, for eyelids. */ const lidGeo = new SphereGeometry(1, 14, 6, 0, Math.PI * 2, 0, Math.PI / 2); const chainGeo = new TorusGeometry(0.11, 0.012, 4, 12, Math.PI); @@ -71,8 +84,11 @@ export class Hood extends Group { for (const s of [-1, 1]) { const l = put(this, new Group()); l.position.set(s * 0.1, 0.3, 0); this.legs.push(l); - l.add(mesh(G.leg, TROUSERS, 0, -0.15, 0, true)); - put(l, mesh(G.box, SHOES, 0, -0.28, 0.04, true)).scale.set(0.13, 0.06, 0.22); + const leg = put(l, mesh(G.leg, TROUSERS, 0, -0.15, 0, true)); + const shoe = put(l, mesh(G.box, SHOES, 0, -0.28, 0.04, true)); shoe.scale.set(0.13, 0.06, 0.22); + // (Low's block of a trouser leg and shoe: High's are round, dressLeg) + leg.userData.lowOnly = shoe.userData.lowOnly = true; + this.dressLeg(l, leg, shoe, true); } this.add(this.body); const coat = put(this.body, mesh(G.body, d.coat, 0, 0.6, 0, true)); coat.scale.set(1.12, 1, 1.05); @@ -88,9 +104,38 @@ export class Hood extends Group { put(a, mesh(G.sphere, d.coat, 0, -0.32, 0)).scale.set(0.066, 0.03, 0.066); put(a, mesh(G.sphere, d.hand, 0, -0.36, 0.01, true)).scale.setScalar(0.05); this.arms.push(a); + // High's: the shirt's cuff showing at the sleeve's end, and a thumb + const af = fine(a); + finePiece(af, cuffGeo, d.shirt, 0, -0.335, 0).rotation.x = Math.PI / 2; + finePiece(af, G.sphere, d.hand, -s * 0.026, -0.352, 0.04).scale.set(0.02, 0.026, 0.02); } this.head.position.set(0, 0.92, 0); this.body.add(this.head); + // High's: the coat's buttons, its front edge and hem, flaps over its pockets and a square in the breast pocket + const bf = fine(this.body), trim = d.lapel; + for (const y of [0.52, 0.62]) finePiece(bf, G.sphere, trim, 0, y, coatAt(y) + 0.004).scale.set(0.017, 0.017, 0.008); + finePiece(bf, G.box, trim, 0, 0.47, coatAt(0.47) - 0.002).scale.set(0.007, 0.34, 0.006); + const hem = finePiece(bf, hemGeo, trim, 0, 0.315, 0, true); hem.rotation.x = Math.PI / 2; hem.scale.set(1.12, 1.05, 1); + for (const s of [-1, 1]) { + const flap = finePiece(bf, G.box, trim, s * 0.17, 0.5, coatAt(0.5) * 0.84); flap.scale.set(0.1, 0.022, 0.012); flap.rotation.y = s * 0.6; + } + const sq = finePiece(bf, G.box, POCKET, 0.17, 0.81, coatAt(0.81) * 0.79); sq.scale.set(0.045, 0.022, 0.01); sq.rotation.set(-0.1, 0.62, 0.1); + } + /** High's leg (`l`, in place of Low's trouser `leg` and `shoe`, blocks): a trouser leg tapering to its cuff, with a + * crease down the front (`standing`), and a rounded shoe on a sole; each placed on Low's as they are, so they move + * with them when he sits. */ + private dressLeg(l: Group, leg: Mesh, shoe: Mesh, standing: boolean) { + l.children.filter(c => c.userData.fine).forEach(c => c.removeFromParent()); + const lf = fine(l); + leg.updateMatrix(); shoe.updateMatrix(); + const on = (o: Object3D, at: Object3D) => { o.updateMatrix(); o.applyMatrix4(at.matrix); }; + // (in the trouser's own size: a box 0.13 by 0.3 by 0.15) + if (standing) { const crease = finePiece(lf, G.box, CREASE, 0, 0.02, 0.068); crease.scale.set(0.006, 0.22, 0.004); on(crease, leg); } + on(finePiece(lf, trouserGeo, TROUSERS, 0, 0, 0, true), leg); + const cuff = finePiece(lf, cuffRingGeo, CUFF, 0, -0.128, 0); cuff.rotation.x = Math.PI / 2; on(cuff, leg); + // (in the shoe's: a box a unit each way) + const toe = finePiece(lf, G.sphere, SHOES, 0, 0.05, 0.04, true); toe.scale.set(0.5, 0.62, 0.52); on(toe, shoe); + const sole = finePiece(lf, G.box, SOLE, 0, -0.42, 0.02, true); sole.scale.set(1.0, 0.24, 1.04); on(sole, shoe); } /** Sits him down on a seat `h` up (where he's put is where his seat is): his thighs out in front of him, a shin * hanging from each knee, his shoes at their feet. */ @@ -98,7 +143,9 @@ export class Hood extends Group { for (const l of this.legs) { l.rotation.x = -Math.PI / 2; l.position.y = h; l.position.z = 0.02; const shin = put(l, mesh(G.leg, TROUSERS, 0, -0.28, -0.15, true)); shin.rotation.x = Math.PI / 2; - const shoe = l.children[1]; shoe.position.set(0, -0.33, -0.31); shoe.rotation.x = Math.PI / 2; + shin.userData.lowOnly = true; + const shoe = l.children[1] as Mesh; shoe.position.set(0, -0.33, -0.31); shoe.rotation.x = Math.PI / 2; + this.dressLeg(l, shin, shoe, false); } this.body.position.y = h - 0.3; return this; @@ -304,60 +351,110 @@ export function mama() { export const family: Record = { eddie: eddie(), harry: harry(), tommy: tommy() }; // ---------- merging a figure's still parts ---------- -// A figure's shadow is cast by a plainer copy of the pieces that were made to cast one (fewer sides, as kit.ts' baked -// meshes cast theirs), after its own triangles in the same mesh; on Low (graphics.ts), only by the bigger of them, not -// the eyes, buttons, whiskers and chains. (Made on dice of their own, off the game's luck and the scenery's.) -const shadowDice = dice(0x5AD0F); -const plainers = new WeakMap(); -/** `geo`'s shadow shape: a round one with fewer sides, anything else itself. */ -function plainer(geo: BufferGeometry) { - let p = plainers.get(geo); +// A figure is drawn as a few merged meshes (flatten), one for each part that moves, in two versions (graphics.ts): +// High, as it was built, with each round piece given as many sides as its size wants (a glint a few millimetres +// across was a ball of 252 triangles), and the finer pieces only High has (`fine`: the cuffs, the buttons, the shoes' +// soles...); and Low, the same pieces as it was built, a notch plainer. Its shadow is cast by a plainer copy still of +// the pieces made to cast one (fewer sides, as kit.ts' baked meshes cast theirs), after its own triangles in the same +// mesh; on Low only by the bigger of them, not the eyes, buttons, whiskers and chains. (Made on dice of their own, off +// the game's luck and the scenery's; Low's merged mesh is made just as the one mesh was, so the luck's drawn as it was.) +type Detail = 'high' | 'low' | 'shadow'; +/** Sides round and rings down a ball, by its size across (metres), for each version: above 25 cm, 10 cm, 4 cm, under. */ +const BALL: Record = { + // (above 25 cm: on High as built, on Low half the sides, but no fewer than 10 by 7) + high: [[Infinity, Infinity], [12, 8], [9, 6], [6, 4]], + low: [[10, 7], [9, 6], [7, 5], [5, 3]], + shadow: [[6, 4], [6, 4], [5, 3], [4, 3]], +}; +const tier = (d: number) => (d >= 0.25 ? 0 : d >= 0.1 ? 1 : d >= 0.04 ? 2 : 3); +const shapes = new WeakMap>(); +/** `geo` in `detail`, `across` metres at its widest where it's put: a round one with the sides its size wants, any other + * itself. */ +function shapeFor(geo: BufferGeometry, detail: Detail, across: number) { + if (!(geo instanceof SphereGeometry || geo instanceof TorusGeometry || geo instanceof CylinderGeometry)) return geo; + const t = tier(across), key = `${detail}${t}`; + let all = shapes.get(geo); + if (!all) shapes.set(geo, all = new Map()); + let p = all.get(key); if (p) return p; p = quietly(() => { - if (geo instanceof SphereGeometry) { const a = geo.parameters; return new SphereGeometry(a.radius, 6, 4, a.phiStart, a.phiLength, a.thetaStart, a.thetaLength); } - if (geo instanceof TorusGeometry) { const a = geo.parameters; return new TorusGeometry(a.radius, a.tube, 3, 8, a.arc); } - if (geo instanceof CylinderGeometry) { const a = geo.parameters; return new CylinderGeometry(a.radiusTop, a.radiusBottom, a.height, 6, 1, a.openEnded, a.thetaStart, a.thetaLength); } - return geo; - }, shadowDice); - plainers.set(geo, p); + if (geo instanceof SphereGeometry) { + const a = geo.parameters; + let [w, h] = BALL[detail][t]; + if (detail === 'low' && t === 0) { w = Math.max(w, Math.round(a.widthSegments / 2)); h = Math.max(h, Math.round(a.heightSegments / 2)); } + return w >= a.widthSegments && h >= a.heightSegments ? geo : new SphereGeometry(a.radius, Math.min(w, a.widthSegments), Math.min(h, a.heightSegments), a.phiStart, a.phiLength, a.thetaStart, a.thetaLength); + } + if (geo instanceof TorusGeometry) { + const a = geo.parameters, most = { high: [6, 16], low: [4, 10], shadow: [3, 8] }[detail]; + return a.radialSegments <= most[0] && a.tubularSegments <= most[1] ? geo : new TorusGeometry(a.radius, a.tube, Math.min(a.radialSegments, most[0]), Math.min(a.tubularSegments, most[1]), a.arc); + } + const a = (geo as CylinderGeometry).parameters, most = { high: 12, low: 8, shadow: 6 }[detail]; + return a.radialSegments <= most ? geo : new CylinderGeometry(a.radiusTop, a.radiusBottom, a.height, most, 1, a.openEnded, a.thetaStart, a.thetaLength); + }, shapeDice); + all.set(key, p); return p; } /** Under this (in metres, its biggest side), or this thin in two directions (a whisker, a chain), a piece casts no * shadow on Low. */ const SMALL = 0.12, ROD = 0.03; const size = new Vector3(); -/** Gives `o`, a figure's merged pieces, `shadow`, the plainer copies of those that cast, after its own triangles: on - * High all of them, on Low the bigger ones. */ +const big = (g: BufferGeometry) => { + g.computeBoundingBox(); + const d = g.boundingBox!.getSize(size).toArray().sort((a, b) => a - b); + return d[2] >= SMALL && d[1] >= ROD; +}; +/** Gives `o`, a figure's merged pieces, `shadow` (the plainer copies of those that cast), after its own triangles, drawn + * only in the shadow pass. */ function castAs(o: Mesh, shadow: BufferGeometry[]) { - const big = shadow.filter(g => { - g.computeBoundingBox(); - const d = g.boundingBox!.getSize(size).toArray().sort((a, b) => a - b); - return d[2] >= SMALL && d[1] >= ROD; - }); + // (its drawing's its own: never merged with others, batch.ts) + o.userData.noBatch = true; + if (!shadow.length) { o.castShadow = false; return; } const own = o.geometry.attributes.position.count; for (const g of shadow) paint(g, 0); - const all = mergeGeometries([o.geometry, ...shadow, ...big])!, hi = shadow.reduce((n, g) => n + g.attributes.position.count, 0); - const lo = big.reduce((n, g) => n + g.attributes.position.count, 0); + const all = mergeGeometries([o.geometry, ...shadow])!; shadow.forEach(g => g.dispose()); o.geometry.dispose(); o.geometry = all; all.setDrawRange(0, own); - o.onBeforeShadow = () => { if (isHigh()) all.setDrawRange(own, hi); else all.setDrawRange(own + hi, lo); }; + o.onBeforeShadow = () => all.setDrawRange(own, Infinity); o.onAfterShadow = () => all.setDrawRange(0, own); - // (its drawing's its own: never merged with others, batch.ts) - o.userData.noBatch = true; +} +/** The finer pieces only High draws: a group of them, under `parent` (merged with it by flatten, into High's mesh). */ +export function fine(parent: Object3D) { + const g = quietly(() => new Group(), fineDice); + g.userData.fine = true; + parent.add(g); + return g; +} +/** Adds a piece to High's finer ones: `geo` in colour `c` at (x, y, z), off the game's luck (made on dice of their own, + * in materials of their own, so render.ts' shared ones are made when they always were). */ +export function finePiece(to: Object3D, geo: BufferGeometry, c: number, x: number, y: number, z: number, cast = false) { + return quietly(() => { + let m = fineMats.get(c); + if (!m) fineMats.set(c, m = new MeshLambertMaterial({ color: c })); + const o = new Mesh(geo, m); + o.position.set(x, y, z); o.castShadow = cast; + to.add(o); + return o; + }, fineDice); } const plain = (m: Material | Material[]): m is MeshLambertMaterial => !Array.isArray(m) && m instanceof MeshLambertMaterial && !m.map && !m.transparent && !m.vertexColors && m.emissive.getHex() === 0; -/** Merges the plain meshes under `root` (but not into `keep`) into one, in `root`'s own space, its colours painted on. */ +/** Merges the plain meshes under `root` (but not into `keep`) into one for each version (High and Low), in `root`'s own + * space, its colours painted on; the others (glowing, see-through, drawn on), each material's lot into one. */ export function flatten(root: Object3D, keep: Set = new Set()) { root.updateMatrixWorld(true); const toRoot = root.matrixWorld.clone().invert(), m4 = new Matrix4(); - /** `m`'s geometry (or `geo` in its place) in `root`'s space, keeping only `attrs`. */ - const placed = (m: Mesh, attrs: string[], geo = m.geometry) => { + /** `m`'s geometry (or its shape in `detail`) in `root`'s space, keeping only `attrs`. */ + const placed = (m: Mesh, attrs: string[], detail?: Detail) => { + m4.multiplyMatrices(toRoot, m.matrixWorld); + let geo = m.geometry; + if (detail) { + if (!geo.boundingSphere) geo.computeBoundingSphere(); + geo = shapeFor(geo, detail, 2 * geo.boundingSphere!.radius * m4.getMaxScaleOnAxis()); + } const g = geo.index ? geo.toNonIndexed() : copyGeo(geo); for (const k of Object.keys(g.attributes)) if (!attrs.includes(k)) g.deleteAttribute(k); - m4.multiplyMatrices(toRoot, m.matrixWorld); g.applyMatrix4(m4); if (m4.determinant() < 0) { for (const a of [g.attributes.position, g.attributes.normal]) { @@ -366,25 +463,37 @@ export function flatten(root: Object3D, keep: Set = new Set()) { } return g; }; - // (the plain ones painted into one; the others, glowing or see-through, merged with any that share their material) - const flat: Mesh[] = [], alike = new Map(); - const walk = (o: Object3D) => { + // (the plain ones painted into one; the others, glowing or see-through, merged with any that share their material; + // the finer ones High's alone) + const flat: Mesh[] = [], finer: Mesh[] = [], alike = new Map(); + const walk = (o: Object3D, isFine: boolean) => { for (const c of o.children) { if (keep.has(c)) continue; - const m = c as Mesh; - if (!m.isMesh || c.children.length) { walk(c); continue; } - if (plain(m.material)) flat.push(m); + const m = c as Mesh, f = isFine || !!c.userData.fine; + if (!m.isMesh || c.children.length) { walk(c, f); continue; } + if (f) { if (plain(m.material)) finer.push(m); } + else if (plain(m.material)) flat.push(m); else if (!Array.isArray(m.material) && !(m.material as MeshLambertMaterial).vertexColors) (alike.get(m.material) ?? alike.set(m.material, []).get(m.material)!).push(m); } }; - walk(root); + walk(root, false); if (flat.length) { - const geos = flat.map(m => paint(placed(m, ['position', 'normal']), (m.material as MeshLambertMaterial).color.getHex())); - for (const m of flat) m.removeFromParent(); - const o = put(root, mesh(mergeGeometries(geos)!, painted, 0, 0, 0, true)); + const colour = (m: Mesh) => (m.material as MeshLambertMaterial).color.getHex(); + // Low's: made just as the one mesh was, on the game's luck + const geos = flat.map(m => paint(placed(m, ['position', 'normal'], 'low'), colour(m))); + const low = put(root, mesh(mergeGeometries(geos)!, painted, 0, 0, 0, true)); geos.forEach(g => g.dispose()); - quietly(() => castAs(o, flat.filter(m => m.castShadow).map(m => placed(m, ['position', 'normal'], plainer(m.geometry)))), shadowDice); - } + quietly(() => { + castAs(low, flat.filter(m => m.castShadow).map(m => placed(m, ['position', 'normal'], 'shadow')).filter(big)); + // High's, and its finer pieces + const all = [...flat.filter(m => !m.userData.lowOnly), ...finer], hg = all.map(m => paint(placed(m, ['position', 'normal'], 'high'), colour(m))); + const high = put(root, mesh(mergeGeometries(hg)!, painted, 0, 0, 0, true)); + hg.forEach(g => g.dispose()); + castAs(high, all.filter(m => m.castShadow).map(m => placed(m, ['position', 'normal'], 'shadow'))); + detail(low, high); + }, shapeDice); + for (const m of [...flat, ...finer]) m.removeFromParent(); + } else for (const m of finer) m.removeFromParent(); for (const [mat, ms] of alike) { if (ms.length < 2) continue; const uv = ms.every(m => m.geometry.attributes.uv), geos = ms.map(m => placed(m, uv ? ['position', 'normal', 'uv'] : ['position', 'normal'])); diff --git a/src/levelCull.ts b/src/levelCull.ts index c8dbc20..5b77c42 100644 --- a/src/levelCull.ts +++ b/src/levelCull.ts @@ -42,6 +42,9 @@ export function hideAbove(): () => void { if (!level.down) { if (wasDown) { unfit(); seenAll(); } wasDown = false; last = 0; return show; } fitShadows(); cullFigures(); + const now = performance.now(), dt = Math.min(0.1, (now - drawnAt) / 1000); + drawnAt = now; + for (const f of beforeDrawing) f(dt); const kids = scene.children; if (!wasDown) { kids.forEach(judge); wasDown = true; } else for (let i = 0; i < Math.min(PER_FRAME, kids.length); i++) judge(kids[next = (next + 1) % kids.length]); @@ -51,6 +54,11 @@ export function hideAbove(): () => void { last = hidden.length; return show; } +/** What's done each time the cellar's drawn (`dt` seconds since the last time), whatever the game's doing: Low's lamps + * (cellar.ts), which have to be right in a scene, a menu or a game held still as much as in play. */ +const beforeDrawing: ((dt: number) => void)[] = []; +let drawnAt = performance.now(); +export const eachDrawing = (f: (dt: number) => void) => { beforeDrawing.push(f); }; /** How many things the last render left out (for tests). */ export const leftOut = () => last; From 95c9d0979c141983aea2ea63b306f0ad9160bee3 Mon Sep 17 00:00:00 2001 From: bluedotiya <75704012+ChefControl@users.noreply.github.com> Date: Mon, 19 Oct 2026 09:41:40 +0300 Subject: [PATCH 4/8] Stage 3: High's finer wrestlers (ears, a nose, eye glints, a navel, thumbs, toes, a cord round the belt) and referee (his sash's bow, a thumb); tests for stage 3's Low and High test/stage3Graphics.test.ts: the luck the same on either, all fourteen lamps on High and four standing in on Low, no small things' or fans' shadows on Low, the figures finer on High and plainer on Low, the wrestlers' finer things on High only, the shadows fitted to the screen down here and the figures casting only when they could be seen Co-Authored-By: Claude Opus 5.5 --- src/gyoji.ts | 19 ++++- src/rikishi.ts | 39 ++++++++- test/stage3Graphics.test.ts | 161 ++++++++++++++++++++++++++++++++++++ 3 files changed, 217 insertions(+), 2 deletions(-) create mode 100644 test/stage3Graphics.test.ts diff --git a/src/gyoji.ts b/src/gyoji.ts index c334101..920c7d0 100644 --- a/src/gyoji.ts +++ b/src/gyoji.ts @@ -5,7 +5,7 @@ // on his chest and sleeves; and his war fan (gunbai), lacquered, gold-rimmed, a cord and tassel off its handle, in his // right hand. He's Mr. Fugu's own: his crest, a puffer, is on the robe and on the fan. The hakama is on its own, so it can shorten and spread as he crouches (dohyo.ts). import { CylinderGeometry, Group, Matrix4, SphereGeometry, TorusGeometry, type BufferGeometry } from 'three'; -import { Build, K, rbox, shade, tube } from './kit'; +import { Build, detail, dice, K, quietly, rbox, shade, tube } from './kit'; import { G } from './render'; /** Mr. Fugu's own referee: in indigo with a white pattern, gold tassels and trim, the house's crest (a puffer in a white @@ -97,6 +97,19 @@ export function gyojiModel() { put(b, G.box, 0x2E2C34, turned(0, HEAD.y + 0.27, HEAD.z - 0.012, 0, -0.36, 0.012, 0.24, 0.01)); put(b, cordGeo, shade(robe, 0.6), turned(0, HEAD.y - 0.03, HEAD.z + 0.02, 0, Math.PI, 1.04, 1.12, 1)); body.add(b.mesh(true)); + // High's finer things (graphics.ts), on dice of their own: the bow his sash is tied in, its ends hanging, and a pleat + // of white at his collar's back + const fine = quietly(() => { + const f = new Build(); + for (const s of [-1, 1]) { + f.add(K.ball, shade(PATTERN, 0.8), s * 0.045, HAKAMA.top + 0.025, 0.285, [0, 0, s * 0.5], [0.04, 0.022, 0.014]); + f.add(G.box, shade(PATTERN, 0.8), s * 0.03, HAKAMA.top - 0.05, 0.288, [0, 0, s * 0.15], [0.022, 0.1, 0.008]); + } + f.add(K.ball, WHITE, 0, 0.93, -0.18, null, [0.08, 0.025, 0.03]); + return f.mesh(false); + }, dice(0x6501)); + body.add(fine); + detail(null, fine); // ---- the arms: wide square sleeves, hanging behind and under them; a hand out of each ---- const arms = [1, -1].map(s => { @@ -128,6 +141,10 @@ export function gyojiModel() { ab.add(tube(0.012, 0.025, 0.07, 6), GOLD, 0.05, -0.72, -0.02); } a.add(ab.mesh(true)); + // (and on High, a thumb) + const thumb = quietly(() => new Build().add(K.ball, SKIN, -s * 0.035, -0.32, 0.07, null, [0.022, 0.03, 0.022]).mesh(false), dice(0x6502 + s)); + a.add(thumb); + detail(null, thumb); return a; }); return { root, body, hakama, arms }; diff --git a/src/rikishi.ts b/src/rikishi.ts index 5ee329a..2ba1192 100644 --- a/src/rikishi.ts +++ b/src/rikishi.ts @@ -7,7 +7,8 @@ // (dohyo.ts): a set of joint angles, mixed one into another as he moves. import { Group, type Mesh, type Object3D, SphereGeometry } from 'three'; import { type Beast, BEASTS, type Kit, type Look } from './creatures'; -import { Build, K, quietly, shade } from './kit'; +import { isHigh, onQuality } from './graphics'; +import { Build, dice, K, quietly, shade } from './kit'; const SKIN = 0xF3C9A4, HAIR = 0x15130F; /** The champion's name, and his belt: the player's colour. */ @@ -45,6 +46,8 @@ export function mixPose(a: Pose, b: Pose, k: number): Pose { export const THIGH = 0.26, SHIN = 0.24, ANKLE = 0.07; const ROUND = quietly(() => new SphereGeometry(1, 16, 12)); const add = (p: Object3D, o: T) => { p.add(o); return o; }; +/** The dice High's finer things are made on: their own, so the scenery's are drawn as they always were. */ +const fineDice = dice(0xF1E5); const bone = (p: Object3D, x: number, y: number, z: number) => { const g = add(p, new Group()); g.position.set(x, y, z); return g; }; /** A baked part: the bone it's on, how it's made, and the mesh made. */ interface Part { at: Object3D; make: (b: Build) => void; m?: Mesh } @@ -68,6 +71,8 @@ export class RikishiModel extends Group { readonly bellyAt: Group; readonly chestAt: Group; private parts: { at: Group; make: Recipe; m?: Mesh }[] = []; + /** High's finer things (graphics.ts), each bone's in a mesh of its own: shown on High only. */ + private fines: { at: Group; make: Recipe; m?: Mesh }[] = []; private skin: number; private belt: number; /** How he looks: his skin and belt, and the creature he is, if he's a boss. */ @@ -160,11 +165,34 @@ export class RikishiModel extends Group { }); this.arms.push({ s, sh, el, hand }); } + this.finer(); + onQuality(() => { for (const p of this.fines) if (p.m) p.m.visible = isHigh(); }); this.scale.setScalar(1.05); this.setWeight(150); this.apply(P0, 0); } private part(at: Group, make: Recipe) { this.parts.push({ at, make }); } + private fine(at: Group, make: Recipe) { this.fines.push({ at, make }); } + /** High's finer things on a man: ears, a nose and a glint in each eye, a navel, a thumb on each hand, toes, and a + * darker cord round his belt (a creature has his own head, hands and feet, and only the cord). */ + private finer() { + const man = () => !this.look.kind; + this.fine(this.neck, (b, sk) => { + if (!man()) return; + for (const s of [-1, 1]) { + b.add(K.ball, sk, s * 0.205, 0.13, -0.005, null, [0.035, 0.05, 0.03]); + b.add(K.dot, 0xFFFFFF, s * 0.07 + 0.009, 0.151, 0.2, null, 0.006); + } + b.add(K.ball, shade(sk, 0.94), 0, 0.105, 0.2, null, [0.032, 0.028, 0.03]); + }); + this.fine(this.bellyAt, (b, sk, _, f) => { b.add(K.dot, shade(this.look.under ?? sk, 0.72), 0, -0.04, 0.375 * f, null, [0.018, 0.024, 0.01]); }); + this.fine(this.hips, (b, _, bl, f) => { b.add(K.post, shade(bl, 0.7), 0, -0.06, 0, null, [0.41 * f, 0.016, 0.33 * f]); }); + for (const L of this.legs) this.fine(L.foot, (b, sk) => { + if (!man()) return; + for (let i = 0; i < 4; i++) b.add(K.dot, shade(sk, 0.97), (i - 1.5) * 0.04 * L.s, -0.055, 0.185 - Math.abs(i - 1.5) * 0.01, null, [0.022, 0.02, 0.026]); + }); + for (const A of this.arms) this.fine(A.el, (b, sk) => { if (this.hands) b.add(K.ball, sk, -A.s * 0.045, -0.23, 0.06, null, [0.03, 0.04, 0.03]); }); + } /** Whether he has hands (an octopus has tentacles, a crab claws). */ private hands = true; /** Bakes every bone's meshes afresh, in his skin and belt, at his weight, and builds the creature he is on them. */ @@ -198,6 +226,15 @@ export class RikishiModel extends Group { if (!b.empty) p.m = add(p.at, b.mesh(true)); } }); + // (High's finer things: small, so they cast no shadow) + quietly(() => { + for (const p of this.fines) { + if (p.m) { p.at.remove(p.m); p.m.geometry.dispose(); p.m = undefined; } + const b = new Build(); + p.make(b, this.skin, this.belt, this.f); + if (!b.empty) { p.m = add(p.at, b.mesh(false)); p.m.visible = isHigh(); } + } + }, fineDice); } /** The creature he is, if he's a boss. */ get kind() { return this.look.kind; } diff --git a/test/stage3Graphics.test.ts b/test/stage3Graphics.test.ts new file mode 100644 index 0000000..d8cf7e8 --- /dev/null +++ b/test/stage3Graphics.test.ts @@ -0,0 +1,161 @@ +// Stage 3's graphics (the cellar), Low and High: High is the cellar as it was drawn, with the Undertow's figures and +// the wrestlers finer; Low is the same place for less: four lamps standing in for fourteen, no shadows from the small +// things or the fans, the figures a notch plainer. Down there the sun's shadows are cast only over what's on screen. +// Neither touches the game's luck. +import { type InstancedMesh, type Mesh, type Object3D, type PointLight } from 'three'; +import { describe, expect, it, vi } from 'vitest'; +import { bought, FLOE_SUSHI, loadGame, MARKET, type SaveFixture } from './helpers'; + +const SUSHI = [...MARKET, 'sushi', ...FLOE_SUSHI]; +/** Whether `o` and everything it's in are showing. */ +const shown = (o: Object3D) => { for (let p: Object3D | null = o; p; p = p.parent) if (!p.visible) return false; return true; }; +/** The same seeded luck as setup.ts. */ +function seeded(seed: number) { + return () => { + seed = seed + 0x6D2B79F5 | 0; + let t = Math.imul(seed ^ seed >>> 15, 1 | seed); + t = t + Math.imul(t ^ t >>> 7, 61 | t) ^ t; + return ((t ^ t >>> 14) >>> 0) / 4294967296; + }; +} +/** A game down in the cellar (the second boss beaten: the Undertow's open), the graphics already chosen on the device. */ +async function cellar(q: 'low' | 'high', save: SaveFixture = {}) { + const get = Storage.prototype.getItem; + const spy = vi.spyOn(Storage.prototype, 'getItem').mockImplementation(function (this: Storage, k: string) { + return k === 'floe-market-graphics' ? q : get.call(this, k); + }); + const g = await loadGame({ + tiles: bought(...SUSHI, 'dohyo'), fugu: 2, level: 1, reviews: Array(20).fill(5), + ring: { beaten: 2, lines: { mush: { n: 1 } }, den: { book: true, way: true, door: true } }, ...save, + }); + spy.mockRestore(); + const [cellarMod, graphics, family, levelCull, render, plan] = await Promise.all([ + import('../src/cellar'), import('../src/graphics'), import('../src/family'), import('../src/levelCull'), import('../src/render'), + import('../src/cellarPlan'), + ]); + /** Draws a frame as main.ts does: the camera and the sun over the player, what's up top hidden and the cellar's own + * work done (levelCull.ts), then shown again. */ + const draw = () => { + const p = g.player.g.position, { camera, sun, OFF } = render; + camera.position.copy(p).add(OFF); camera.lookAt(p); camera.updateMatrixWorld(); + sun.target.position.copy(p); + render.scene.updateMatrixWorld(); + levelCull.hideAbove()(); + }; + return { g, cellar: cellarMod, graphics, family, levelCull, render, plan, draw }; +} + +describe('stage 3, Low and High', () => { + it("builds the cellar off the game's luck: the same luck is left whichever graphics the device has", async () => { + // (loaded once first, so what's made the first time anything's loaded, three.js' own, is made before both) + await cellar('low'); + const luck = vi.spyOn(Math, 'random').mockImplementation(seeded(1)); + await cellar('high'); + const high = [Math.random(), Math.random()]; + luck.mockImplementation(seeded(1)); + await cellar('low'); + expect([Math.random(), Math.random()]).toEqual(high); + }); + + it('lights the cellar with all fourteen lamps on High, and four standing in for the nearest on Low', async () => { + const { cellar: C, graphics, draw } = await cellar('high'); + const shownLights = () => { let n = 0; C.cellar.parent!.traverseVisible(o => { if ((o as PointLight).isPointLight && o.parent && shown(o)) n++; }); return n; }; + expect(C.lights.every(l => l.visible)).toBe(true); + expect(C.standIns.every(l => !l.visible)).toBe(true); + const onHigh = shownLights(); + graphics.choose('low'); + draw(); + expect(C.lights.every(l => !l.visible)).toBe(true); + expect(C.standIns.every(l => l.visible)).toBe(true); + expect(shownLights()).toBe(onHigh - C.lights.length + 4); + // each stands in for one of the lit lamps: its colour, reach and place, all the way faded in on the first frame + for (const s of C.standIns) { + const l = C.lights.find(x => x.position.equals(s.position))!; + expect(l.intensity).toBeGreaterThan(0); + expect([s.color.getHex(), s.distance, s.intensity]).toEqual([l.color.getHex(), l.distance, l.intensity]); + } + graphics.choose('high'); + expect(C.lights.every(l => l.visible)).toBe(true); + expect(C.standIns.every(l => !l.visible)).toBe(true); + }); + + it('casts no shadow from the small things or the fans on Low, and every one of them again on High', async () => { + const { cellar: C, graphics, g, render } = await cellar('high'); + const casters = () => { let n = 0; C.cellar.traverse(o => { if ((o as Mesh).isMesh && (o as Mesh).castShadow) n++; }); return n; }; + const slight = () => { let n = 0; C.cellar.traverse(o => { if (o.userData.slight) n++; }); return n; }; + const onHigh = casters(); + expect(slight()).toBe(0); + graphics.choose('low'); + expect(slight()).toBeGreaterThan(20); + expect(casters()).toBe(onHigh - slight()); + graphics.choose('high'); + expect(slight()).toBe(0); + expect(casters()).toBe(onHigh); + // a boss's crowd: their shadows from a plainer copy on High (after their own shape), none on Low + const { ringGong } = await import('../src/fight'); + ringGong(); + g.run(8); + const fans = () => { const out: InstancedMesh[] = []; render.scene.traverse(o => { if ((o as InstancedMesh).isInstancedMesh && o.userData.casts !== undefined) out.push(o as InstancedMesh); }); return out; }; + expect(fans().length).toBeGreaterThan(0); + const casting = fans().filter(f => f.userData.casts); + expect(casting.every(f => f.castShadow && f.userData.shadowFrom === f.geometry.drawRange.count)).toBe(true); + expect(casting.every(f => f.geometry.attributes.position.count > f.userData.shadowFrom)).toBe(true); + // (the trim, the small things on them, never casts) + expect(fans().filter(f => !f.userData.casts).every(f => !f.castShadow)).toBe(true); + graphics.choose('low'); + g.run(0.5); + expect(fans().every(f => !f.castShadow)).toBe(true); + }); + + it("draws the Undertow's figures finer on High and a notch plainer on Low, each in a few meshes, the same pose", async () => { + const { family, graphics } = await cellar('high'); + const eddie = family.family.eddie; + const meshes = (q: 'low' | 'high') => { + graphics.choose(q); + const out: Mesh[] = []; + eddie.traverse(o => { if ((o as Mesh).isMesh && shown(o)) out.push(o as Mesh); }); + return out; + }; + const tris = (ms: Mesh[]) => ms.reduce((n, m) => n + Math.min(m.geometry.drawRange.count, m.geometry.index?.count ?? m.geometry.attributes.position.count) / 3, 0); + const high = meshes('high'), low = meshes('low'); + // (each moving part merged: a handful of meshes, on either) + expect(high.length).toBeLessThan(20); + expect(low.length).toBe(high.length); + // High's has the finer pieces Low's hasn't; both far fewer triangles than a ball of 252 for every piece + expect(tris(high)).toBeGreaterThan(tris(low)); + expect(tris(high)).toBeLessThan(12000); + // and each casts its shadow from a plainer copy after its own triangles + for (const m of [...high, ...low].filter(x => x.castShadow && x.userData.noBatch)) expect(m.geometry.attributes.position.count).toBeGreaterThan(m.geometry.drawRange.count); + }); + + it('dresses the wrestlers finer on High only: ears, a nose, toes, thumbs', async () => { + const { graphics } = await cellar('high'); + const { RikishiModel } = await import('../src/rikishi'); + const r = new RikishiModel(0xE0552F); + const visible = () => { let n = 0; r.traverse(o => { if ((o as Mesh).isMesh && shown(o)) n++; }); return n; }; + const onHigh = visible(); + graphics.choose('low'); + expect(visible()).toBeLessThan(onHigh); + graphics.choose('high'); + expect(visible()).toBe(onHigh); + }); + + it("fits the sun's shadows to the floor on screen down here, and puts its box back up top", async () => { + const { levelCull, draw, g, plan, cellar: C } = await cellar('high'); + const up = levelCull.shadowBox(); + g.player.g.position.set(plan.BAR_PAD.x, C.CY, plan.BAR_PAD.z); + g.run(1); + draw(); + const down = levelCull.shadowBox(); + expect(down).not.toEqual(up); + // (narrower than the square across the world it was: as wide as the screen's strip of floor) + expect(down.right - down.left).toBeLessThan(up.right - up.left); + // the Undertow's figures, off screen from the bar, cast no shadow; in the club, the ones in sight do + const casting = levelCull.figuresCasting(); + const { DEN } = await import('../src/layout'); + g.player.g.position.set((DEN.x0 + DEN.x1) / 2, C.CY, (DEN.z0 + DEN.z1) / 2 + 2); + g.run(2); + draw(); + expect(levelCull.figuresCasting()).toBeGreaterThan(casting); + }); +}); From 58259b4e2deaa66511fc209ef39de3ed991064eb Mon Sep 17 00:00:00 2001 From: bluedotiya <75704012+ChefControl@users.noreply.github.com> Date: Mon, 19 Oct 2026 09:52:36 +0300 Subject: [PATCH 5/8] Docs: stage 3's High and Low, and what keeps the cellar fast Co-Authored-By: Claude Opus 5.5 --- docs/graphics.md | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/docs/graphics.md b/docs/graphics.md index 43e6081..816b7a8 100644 --- a/docs/graphics.md +++ b/docs/graphics.md @@ -127,6 +127,27 @@ The effects (src/fx.ts) show on High only and are visual only: they never change The DOM interface (the HUD, menus, toasts) isn't drawn in three.js and isn't part of High or Low. Emoji drawn into the 3D world are vector icons (src/icons.ts, `drawIcon`), on both settings, so they look the same on every phone. +## Stage 3 (the cellar) + +Stage 3 was built after the rework, at High's detail, so there **High is the cellar as it was drawn** and Low is a cut-down copy of it for phones: the same place, the same layout, for less. (The rule above, "Low is the old version", doesn't apply down there; the owner decided this on 2026-10-19.) The one exception is the Undertow's figures (family.ts's `Hood`, and the wrestlers and referee): their old look is Low's, and High is finer. + +What Low leaves out down there: + +- **Lamps:** four stand-in lamps (cellar.ts `standIns`) take the colour, strength, reach and place of the four that light where the camera looks most, fading as they swap; High has all fourteen (`lights`). Every light is worked out for every pixel of everything drawn, so this is most of Low's saving on the graphics chip. +- **Small shadows:** anything under half a metre, or rod-thin, casts none (cellar.ts `sweepShadows`); the fans cast none (swarm.ts); the figures cast only their bigger pieces (family.ts `castAs`). +- **The figures** are a notch plainer: their balls have fewer sides (family.ts `shapeFor`), and High's finer pieces (`fine`, `finePiece`) aren't drawn. +- **The hatch's smoke:** half the puffs (hatchFx.ts). + +On both settings, while the player's down there (levelCull.ts): + +- **The sun's shadow box is fitted to the floor on screen,** turned square to the screen. Up top it's a fixed square, and down here that square took in rooms off screen and drew them all again for their shadows. +- **The Undertow's figures cast their shadows only while they, or their shadow, could be on screen** (`castsWhenSeen`). +- **`eachDrawing(f)`** runs `f` each time the cellar's drawn, whatever the game's doing (a scene, a menu): Low's lamps are set there. + +The fans cast their shadow from a copy of themselves snapped to a 4 cm grid (swarm.ts `withShadow`), and what isn't merged is looked at one frame in eight (batch.ts `TURNS`). + +Measure stage 3 in Fukure's rush with the biggest crowd and in the Undertow's club: the demo build's `?stage3=on&cellar&beaten=4&chips=50000&full` (everything bought) and ring the gong. Tests: `test/stage3Graphics.test.ts`. + ## Keeping it fast on phones The bar: an iPhone 16 holds 55–60 fps on High in the busiest scene (inside the stage 2 hall), and Auto never drops to Low there. On a CPU slowed 4× (an older phone), both settings hold 60 fps today. Don't let a change undo that. From 1426e44a6eba2d6b5c7e8d0455fe7674d5d2492f Mon Sep 17 00:00:00 2001 From: bluedotiya <75704012+ChefControl@users.noreply.github.com> Date: Mon, 19 Oct 2026 10:22:11 +0300 Subject: [PATCH 6/8] Test: more time for the cellar's music and the stage 2 till tests (at their limits on CI under coverage: 29.5 s of 30, 13.4 s of 15 on #114) Co-Authored-By: Claude Opus 5.5 --- test/ringSound.test.ts | 3 ++- test/stage3.test.ts | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/test/ringSound.test.ts b/test/ringSound.test.ts index 3f18784..965eb02 100644 --- a/test/ringSound.test.ts +++ b/test/ringSound.test.ts @@ -42,7 +42,8 @@ describe('the gloop ring', () => { expect(full.some(p => p.f === 784)).toBe(true); // the clappers expect(full.some(p => p.type === 'custom' && p.f > hz(57) && p.f < hz(76))).toBe(true); // the shamisen expect(full.some(p => p.type === 'triangle' && p.f < 110)).toBe(true); // the low drone - }, 30_000); + // (it plays the ring's whole music through, in a cellar loaded twice: about 30 s on CI under coverage) + }, 60_000); it("plays in the cellar's damp room: a reverb and an echo on the drums, the tune and the bubbles, but not on the bass", async () => { const s = await ringWithSound(true, RING_DONE); diff --git a/test/stage3.test.ts b/test/stage3.test.ts index 251084c..1c0cefe 100644 --- a/test/stage3.test.ts +++ b/test/stage3.test.ts @@ -128,7 +128,8 @@ describe('buying into the ring', () => { const g2 = await loadGame(localStorage.getItem('floe-market-v1')!); expect(g2.wallet.money).toBe(0); expect((await import('../src/front')).front.till).toBe(499_000_000); - }); + // (a stage 2 and a stage 3 game loaded: about 15 s on CI under coverage) + }, 30_000); it('a stage 3 save from before the till keeps what the cellar can have paid it, and the rest goes in the till', async () => { // (two bosses beaten: the practice, Hakkaku and Yokobashiri paid 35 + 350 + 250 chips, and Dobu's 300 is next) From 7282a73a837bbc9ecf416848687853291c577297 Mon Sep 17 00:00:00 2001 From: bluedotiya <75704012+ChefControl@users.noreply.github.com> Date: Mon, 19 Oct 2026 11:15:01 +0300 Subject: [PATCH 7/8] Test: more time for stage 3's graphics tests (the luck test loads four games, 17 s under coverage on CI; the one after crashed into its still-running load) Co-Authored-By: Claude Opus 5.5 --- test/stage3Graphics.test.ts | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/test/stage3Graphics.test.ts b/test/stage3Graphics.test.ts index d8cf7e8..d993efe 100644 --- a/test/stage3Graphics.test.ts +++ b/test/stage3Graphics.test.ts @@ -46,6 +46,7 @@ async function cellar(q: 'low' | 'high', save: SaveFixture = {}) { } describe('stage 3, Low and High', () => { + // (four games loaded, a few seconds each under coverage on CI: more time than the usual 15 s, here and below) it("builds the cellar off the game's luck: the same luck is left whichever graphics the device has", async () => { // (loaded once first, so what's made the first time anything's loaded, three.js' own, is made before both) await cellar('low'); @@ -55,7 +56,7 @@ describe('stage 3, Low and High', () => { luck.mockImplementation(seeded(1)); await cellar('low'); expect([Math.random(), Math.random()]).toEqual(high); - }); + }, 60_000); it('lights the cellar with all fourteen lamps on High, and four standing in for the nearest on Low', async () => { const { cellar: C, graphics, draw } = await cellar('high'); @@ -77,7 +78,7 @@ describe('stage 3, Low and High', () => { graphics.choose('high'); expect(C.lights.every(l => l.visible)).toBe(true); expect(C.standIns.every(l => !l.visible)).toBe(true); - }); + }, 30_000); it('casts no shadow from the small things or the fans on Low, and every one of them again on High', async () => { const { cellar: C, graphics, g, render } = await cellar('high'); @@ -105,7 +106,7 @@ describe('stage 3, Low and High', () => { graphics.choose('low'); g.run(0.5); expect(fans().every(f => !f.castShadow)).toBe(true); - }); + }, 30_000); it("draws the Undertow's figures finer on High and a notch plainer on Low, each in a few meshes, the same pose", async () => { const { family, graphics } = await cellar('high'); @@ -126,7 +127,7 @@ describe('stage 3, Low and High', () => { expect(tris(high)).toBeLessThan(12000); // and each casts its shadow from a plainer copy after its own triangles for (const m of [...high, ...low].filter(x => x.castShadow && x.userData.noBatch)) expect(m.geometry.attributes.position.count).toBeGreaterThan(m.geometry.drawRange.count); - }); + }, 30_000); it('dresses the wrestlers finer on High only: ears, a nose, toes, thumbs', async () => { const { graphics } = await cellar('high'); @@ -138,7 +139,7 @@ describe('stage 3, Low and High', () => { expect(visible()).toBeLessThan(onHigh); graphics.choose('high'); expect(visible()).toBe(onHigh); - }); + }, 30_000); it("fits the sun's shadows to the floor on screen down here, and puts its box back up top", async () => { const { levelCull, draw, g, plan, cellar: C } = await cellar('high'); @@ -157,5 +158,5 @@ describe('stage 3, Low and High', () => { g.run(2); draw(); expect(levelCull.figuresCasting()).toBeGreaterThan(casting); - }); + }, 30_000); }); From 3be6f65e5aa43a9e18b09469b039bea7199cc826 Mon Sep 17 00:00:00 2001 From: bluedotiya <75704012+ChefControl@users.noreply.github.com> Date: Mon, 19 Oct 2026 17:02:06 +0300 Subject: [PATCH 8/8] Test: more time for six tests near their limits on CI under coverage (the offer's first test timed out at 15.7 s of 15) Co-Authored-By: Claude Opus 5.5 --- test/bin.test.ts | 2 +- test/cellarTutorial.test.ts | 4 ++-- test/offer.test.ts | 2 +- test/persist.test.ts | 2 +- test/presents.test.ts | 2 +- 5 files changed, 6 insertions(+), 6 deletions(-) diff --git a/test/bin.test.ts b/test/bin.test.ts index 5314c58..82c3be9 100644 --- a/test/bin.test.ts +++ b/test/bin.test.ts @@ -54,7 +54,7 @@ describe('the bins', () => { expect(ring.player.back.n).toBe(3); ring.press('e'); ring.run(0.1); ring.press('e', 'keyup'); expect(ring.player.back.n).toBe(0); - }); + }, 30_000); // 10.8 s of 15 on CI under coverage it('holding E down at a bin throws away once: what you pick up after stays', async () => { const g = await loadGame({ back: 3 }); diff --git a/test/cellarTutorial.test.ts b/test/cellarTutorial.test.ts index 2830ca8..fcc4363 100644 --- a/test/cellarTutorial.test.ts +++ b/test/cellarTutorial.test.ts @@ -227,7 +227,7 @@ describe("the cellar's tutorial", () => { expect(tut.learntLessons()).toContain('bench'); expect($('banner').textContent).toContain('Tutorial complete'); expect($('banner').textContent).toContain('Now take on Hakkaku 🐙'); - }, 30_000); + }, 60_000); // 22.7 s of 30 on CI under coverage it('while the doors are shut, the champion trains by the teppo post, and the gong opens them too', async () => { const { g, plan, fight, F, at } = await cellar({ ring: { practice: true, quiet: true }, learnt: [...STAGE1, 'bins', 'prep', 'pot', 'bowls', 'bench'] }); @@ -289,7 +289,7 @@ describe("the cellar's tutorial", () => { // and the practice is over for good, saved g.saveMod.save(); expect(JSON.parse(localStorage.getItem('floe-market-v1')!).ring.practice).toBe(false); - }); + }, 30_000); // 13.0 s of 15 on CI under coverage it('sends them back for more when a line has too few for a batch, never to a pot that would wait', async () => { const { g, tut, plan, fight, F, at, draw, now, said } = await cellar({ learnt: [...STAGE1, 'arrive', 'bins'] }); diff --git a/test/offer.test.ts b/test/offer.test.ts index 6ca7b18..77a544c 100644 --- a/test/offer.test.ts +++ b/test/offer.test.ts @@ -72,7 +72,7 @@ describe("Mr. Fugu's offer", () => { expect(g.player.g.position.z).toBeGreaterThan(offer.HATCH.z + 1.5); // ...and comes on behind the blinks, for the lid opening expect(lit()).toBe(lights + 1); - }); + }, 30_000); // 14.7 s of 15 on CI under coverage it('first he peeks out from under the lid: "Not now" leaves him there till the player walks off and comes back', async () => { const { g, offer, $, hearHimOut, answer, answers } = await setup({ money: 1_000_000 }); diff --git a/test/persist.test.ts b/test/persist.test.ts index 46da143..4e8c802 100644 --- a/test/persist.test.ts +++ b/test/persist.test.ts @@ -83,7 +83,7 @@ describe('saving progress', () => { expect(await after({ tiles: bought('sushi', 'paddy', 'porter'), field: 2 }, g => g.rice.porter!.back.items.length === 2)).toMatchObject({ field: 2 }); expect(await after({ tiles: bought('sushi'), fish: 1, rice: 1 }, g => g.restaurant.sushi.chefs[0].state === 'slice')).toMatchObject({ fish: 1, rice: 1 }); expect(await after({ tiles: bought('sushi', 'kiosk'), boxes: 5 }, g => (g.counters.TAKEOUT.queue[0]?.hands.n ?? 0) > 0)).toMatchObject({ boxes: 5 }); - }); + }, 30_000); // 13.7 s of 15 on CI under coverage it("loses nothing mid-meal: diners' plates and unpaid bills are saved", async () => { const g = await loadGame({ tiles: bought('sushi'), plates: 16 }); diff --git a/test/presents.test.ts b/test/presents.test.ts index e190cf2..1e7e002 100644 --- a/test/presents.test.ts +++ b/test/presents.test.ts @@ -71,7 +71,7 @@ describe('presents for her', () => { expect(top).toBeGreaterThan(2); expect(top).toBeLessThan(2.7); // the eaves expect(await height(5000)).toBe(top); - }); + }, 30_000); // 10.9 s of 15 on CI under coverage it("doesn't rebuild a full pile for presents that only get counted", async () => { const { g, presents } = await atHerDoor({ presents: 2000 });