Skip to content
Merged
21 changes: 21 additions & 0 deletions docs/graphics.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
12 changes: 8 additions & 4 deletions src/angler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = <T extends Object3D>(parent: Object3D, m: T) => { parent.add(m); return m; };
Expand All @@ -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;
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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]) {
Expand All @@ -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));
Expand Down
29 changes: 24 additions & 5 deletions src/batch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Member>; mesh: Mesh | null; now: boolean }

const members = new Set<Member>(), known = new WeakSet<Object3D>(), batches = new Map<string, Batch>(), dirty = new Set<Batch>();
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) {
Expand Down Expand Up @@ -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);
Expand All @@ -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;
Expand Down Expand Up @@ -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;
Expand Down
91 changes: 88 additions & 3 deletions src/cellar.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -21,9 +21,11 @@ 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';
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';
Expand Down Expand Up @@ -2969,7 +2971,90 @@ 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 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 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()) {
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; } });
// (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
// 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,
Expand Down
6 changes: 5 additions & 1 deletion src/characters.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand Down
Loading
Loading