diff --git a/README.md b/README.md
index cc5dfc7..9ee8c60 100644
--- a/README.md
+++ b/README.md
@@ -306,7 +306,7 @@ Each deploy removes the last one's files, and GitHub Pages lets browsers keep th
| `src/sea.ts` | On High: the rolling, glinting sea, foam on the shore and round the floes and posts, cloud shadows |
| `src/fx.ts` | On High: splashes, chips, coins, glints, sparkles, chimney smoke and steam, each kind one pooled mesh |
| `src/icons.ts` | The drawn icons for floor tiles, pads, signs, menu tags and faces, in place of emoji |
-| `src/demo.ts` | The demo build's tool panel (`vite build --mode demo`): frame rate, Low/High, seasons, effects, a stage 1 or 2 game, or a market with everything to buy |
+| `src/demo.ts` | The demo build's tool panel (`vite build --mode demo`): frame rate, Low/High, a stage 1, 2 or 3 game (stage 3 with N bosses beaten, as won or all bought), and money to add |
| `src/fuguDemo.ts` | The Mr. Fugu demo (`VITE_FUGU_DEMO=1`): Floe Sushi built and the till one bill short of a million, so he knocks a moment in; nothing is kept |
| `src/render.ts` | Renderer, scene, camera, lights, shared materials/geometry, canvas helpers |
| `src/world.ts` | Static scenery: water, the dock (both stages'), fences, roads, trees |
diff --git a/docs/graphics.md b/docs/graphics.md
index 155942b..3883ed5 100644
--- a/docs/graphics.md
+++ b/docs/graphics.md
@@ -146,6 +146,15 @@ On both settings, while the player's down there (levelCull.ts):
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`).
+### One level at a time
+
+In stage 3 only the level the player's on is drawn and runs; the other holds still, as it was left, and pays by a formula instead:
+
+- **Up top** (the dock, the restaurant, the casino boat), the cellar isn't drawn: levelCull.ts hides what's marked `userData.below` (the cellar's group, the fans' meshes, which are never frustum culled) and whatever's wholly below `CELLAR_Y + 15`, for each frame's render. With no boss in or called, it doesn't run either (ring.ts `restingUp`). What it makes on its own comes in at a steady rate instead: fight.ts `idleRate()` runs a copy of the cellar 5 minutes ahead with nobody in it, as the gong's forecast runs a rush, and counts what its bowls and Mama Moray's take. It's worked out as the player climbs up (about 11 ms), and paid a chip at a time, Harry taking his share; near the hatch a "+🐡" pops over it every 3 s. With a boss in or called, the cellar runs as it is, undrawn.
+- **Down in the cellar**, the world up top isn't drawn (`hideAbove`), and its fishing, boats, customers, crew, counters, farm, sea and roof don't run (game.ts `away`). Its tills pay the player's share (`tillShare`) of what its customers were paying a second over the last 3 minutes it ran (front.ts `tillRate`, `updTills`): none while it's Mr. Fugu's rent, all of it once he's bought out. Not kept in the save.
+
+Anything new on one level that has to go on while the player's on the other (a timer, a sound) goes outside those updates. Tests: `test/stage3Away.test.ts`.
+
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
@@ -191,9 +200,9 @@ Each frame, the scene only works out world positions for what's showing (render.
- **Look at it on both settings, in winter and summer, on a phone-sized and a desktop-sized screen.** Force a setting with `localStorage.setItem('floe-market-graphics', 'high')` before the game loads; otherwise Auto may drop to Low in a slow headless browser. Make sure Low looks exactly as it did on main.
- **The demo build:** `npx vite build --mode demo` adds a 🛠 panel with:
- the frame rate, draw calls and triangles;
- - Low/High buttons and any season;
- - each effect;
- - jumps into a ready stage 1 or stage 2 game.
+ - Low/High buttons;
+ - jumps into a ready stage 1, 2 or 3 game, and for stage 3 how many bosses are beaten and whether the cellar is as won or all bought (the playtest build, `VITE_FUGU_DEMO=1`, only);
+ - money to add (chips down in the cellar).
It's the one to try on a real phone. The panel is loaded only in that mode (main.ts), never in the game itself.
- **Measure the cost.** Measure in the stage 2 hall at iPhone size (393×852), on both settings, after 20 s of customers:
diff --git a/src/cellar.ts b/src/cellar.ts
index 58ce534..3d87923 100644
--- a/src/cellar.ts
+++ b/src/cellar.ts
@@ -109,6 +109,9 @@ function tiling(w: number, h: number, draw: Draw) {
/** The whole cellar, hidden until stage 3. Everything in it is placed in world coordinates (y from CY). */
export const cellar = new Group();
cellar.visible = false;
+// (it reaches up the ladder's shaft to the dock, so it's marked as the cellar's: hidden while the player's upstairs,
+// levelCull.ts)
+cellar.userData.below = true;
scene.add(cellar);
/** Parts that come with the venue's tiers, shown when they're bought: the bleachers (the Back-Alley Ring), the upper
* tier and the taiko drums (the Underground Arena), and the VIP boxes (the Grand Hall Below). And the grow room, with
diff --git a/src/demo.ts b/src/demo.ts
index 3d03aeb..25e8c26 100644
--- a/src/demo.ts
+++ b/src/demo.ts
@@ -1,14 +1,13 @@
-// The demo build's tool panel (`vite build --mode demo`), for trying the graphics on a phone: the frame rate and how
-// much is drawn, Low or High, any season, a stage 1 or stage 2 game to jump into, and buttons that set off each effect
-// beside you. The game itself never loads this (main.ts).
-import { chipsFx, coinsFx, glint, splashFx, unlockFx } from './fx';
+// The demo build's tool panel (`vite build --mode demo`), for trying the game on a phone: the frame rate and how much
+// is drawn, Low or High, a stage 1, 2 or 3 game to jump into (stage 3 with as many of its bosses beaten as you like,
+// and the cellar as won or all bought), and money to spend. The game itself never loads this (main.ts).
+import { CHIP, BOSSES } from './fight';
+import { DEMO, marketSave } from './fuguDemo';
import { choose, gfx } from './graphics';
-import { player } from './player';
+import { level } from './layout';
import { renderer } from './render';
import { replaceSave } from './save';
-import { setSeason, SEASONS, SEASON_INFO } from './season';
-import { tiles } from './unlocks';
-import { V } from './util';
+import { addMoney } from './wallet';
const css = `
#demo{position:fixed;left:max(8px,env(safe-area-inset-left));bottom:max(8px,env(safe-area-inset-bottom));z-index:60;
@@ -16,65 +15,82 @@ const css = `
#demo>button{width:44px;height:44px;border-radius:50%;border:0;background:#fff;box-shadow:0 3px 0 rgba(0,0,0,.18);font-size:20px}
#demo .box{position:absolute;bottom:52px;left:0;width:min(300px,calc(100vw - 16px));background:#fff;border-radius:16px;
padding:10px 12px;box-shadow:0 4px 0 rgba(0,0,0,.18);display:grid;gap:8px}
+#demo .box[hidden], #demo [hidden]{display:none}
#demo .row{display:flex;flex-wrap:wrap;gap:6px;align-items:center}
#demo .row b{width:62px}
#demo .row button{border:0;border-radius:10px;padding:7px 10px;background:#E8F1F6;font:inherit;color:inherit}
#demo .row button.on{background:#1E8FC0;color:#fff}
+#demo .sub{border-top:1px solid #E8F1F6;padding-top:6px;color:#5A6B78}
#demo .stats{font-variant-numeric:tabular-nums;color:#5A6B78}`;
+/** What this visit was opened as (fuguDemo.ts reads the same address): a stage, and in the cellar, how far in. */
+const q = new URLSearchParams(location.search);
+const now = { stage: q.has('cellar') ? 3 : q.get('stage') === '1' ? 1 : 2, beaten: Math.max(0, Math.floor(Number(q.get('beaten')) || 0)), full: q.has('full') };
+/** Money a button adds, as cash up top and in Fugu Chips down in the cellar. */
+const GIFTS = [100_000, 1_000_000];
+
const el = document.createElement('div');
el.id = 'demo';
el.innerHTML = `
…
Graphics
-
Season${SEASONS.map((s, i) => ``).join('')}
-
Effects
-
-
Game
+
Game
+
+
Stage 3
+
Beaten
+ ${BOSSES.map((b, i) => ``).join('')}
+
Cellar
+
Money${GIFTS.map(m => ``).join('')}
`;
document.body.appendChild(el);
const box = el.querySelector('.box')!, stats = el.querySelector('#demoStats')!;
el.querySelector('#demo>button')!.addEventListener('click', () => { box.hidden = !box.hidden; });
+/** Lights the buttons for what's on now, and words the money ones for where the player is. */
function marks() {
- for (const b of el.querySelectorAll('#demoGfx button')) b.classList.toggle('on', b.dataset.q === gfx.quality);
+ const on = (sel: string, k: string, v: string) => {
+ for (const b of el.querySelectorAll(`${sel} button`)) b.classList.toggle('on', b.dataset[k] === v);
+ };
+ on('#demoGfx', 'q', gfx.quality);
+ on('#demoGame', 'g', String(now.stage));
+ on('#demoBeaten', 'b', now.stage === 3 ? String(now.beaten) : '');
+ on('#demoKit', 'f', now.stage === 3 ? (now.full ? '1' : '0') : '');
+ for (const b of el.querySelectorAll('#demoMoney button')) {
+ const m = Number(b.dataset.m);
+ b.textContent = level.down ? `+${(m / CHIP).toLocaleString('en-US')} chips` : `+$${m >= 1e6 ? m / 1e6 + 'M' : m / 1e3 + 'k'}`;
+ }
}
el.querySelector('#demoGfx')!.addEventListener('click', e => {
const q = (e.target as HTMLElement).dataset.q;
if (q === 'low' || q === 'high') { choose(q); marks(); }
});
-el.querySelector('#demoSeason')!.addEventListener('click', e => {
- const s = (e.target as HTMLElement).dataset.s;
- if (s !== undefined) setSeason(+s);
-});
-el.querySelector('#demoFx')!.addEventListener('click', e => {
- const fx = (e.target as HTMLElement).dataset.fx, p = player.g.position;
- // in front of the player, a step toward the camera's left, so it isn't hidden behind them
- const at = V(p.x - 0.8, p.y + 0.9, p.z + 0.6);
- if (fx === 'splash') splashFx(at, p.y + 0.1);
- if (fx === 'chips') chipsFx(at);
- if (fx === 'coins') coinsFx(at, 3);
- if (fx === 'glint') glint(at);
- if (fx === 'unlock') unlockFx(p, 1.1, p.y + 0.05);
-});
+
+/** Starts over in stage 3's cellar with `beaten` of its bosses beaten, as won or with all of it bought. */
+const toCellar = (beaten: number, full: boolean) => {
+ location.search = `?stage3=on&cellar&beaten=${beaten}${full ? '&full' : ''}`;
+};
el.querySelector('#demoGame')!.addEventListener('click', e => {
- const g = (e.target as HTMLElement).dataset.g;
- if (!g) return;
- // To buy: a market with nothing built yet and the cash to buy it all, for trying out buying upgrades; stage 2: stages
- // 1 and 2 built, and none of the ring's tiles (they wait for Mr. Fugu's offer)
- const done = (t: { id: string; stage: number }) =>
- g === '2' ? t.stage < 3 && t.id !== 'dohyo' : g === '1' && t.stage === 1 && t.id !== 'sushi';
- replaceSave(JSON.stringify({
- v: 4, savedAt: Date.now(), money: g === '2' ? 300_000 : g === '0' ? 20_000 : 9_000, best: 300_000,
- tiles: tiles.map(t => ({ id: t.id, paid: done(t) ? t.cost : 0, done: done(t), open: true })),
- mods: g === '2' ? { fillets: 6, marketing: 8, training: 8, specials: 8, promo: 8, crew: 8 } : { fillets: 4, marketing: 5, crew: 3 },
- reviews: Array(20).fill(5), pile: 20, back: 0, backRice: 0, c1: 5, c1c: 0, c2: 4, c2c: 0, fish: 8, rice: 8, plates: 6,
- cash: 0, boxes: 2, tcash: 0, field: 6, season: 0, seasonT: 30, presents: 2, presentPaid: 0,
- learnt: ['fish', 'pick', 'sell', 'cash', 'buy', 'shop', 'harpoon', 'stars'],
- }));
+ const g = (e.target as HTMLElement).closest('button')?.dataset.g;
+ if (g === '3') return toCellar(0, false);
+ if (g !== '1' && g !== '2') return;
+ // (the playtest build writes its own game each time it loads, from the address; any other demo build keeps this one)
+ if (DEMO) { location.search = g === '1' ? '?stage=1' : ''; return; }
+ replaceSave(marketSave(+g as 1 | 2));
location.reload();
});
+el.querySelector('#demoBeaten')!.addEventListener('click', e => {
+ const b = (e.target as HTMLElement).closest('button')?.dataset.b;
+ if (b !== undefined) toCellar(+b, now.stage === 3 && now.full);
+});
+el.querySelector('#demoKit')!.addEventListener('click', e => {
+ const f = (e.target as HTMLElement).closest('button')?.dataset.f;
+ if (f !== undefined) toCellar(now.stage === 3 ? now.beaten : 0, f === '1');
+});
+el.querySelector('#demoMoney')!.addEventListener('click', e => {
+ const m = Number((e.target as HTMLElement).closest('button')?.dataset.m);
+ if (m > 0) addMoney(m);
+});
// the frame rate over the last second, and what the last frame drew
const draw = renderer.render.bind(renderer);
diff --git a/src/fight.ts b/src/fight.ts
index 1eb7cd5..5d0e6d8 100644
--- a/src/fight.ts
+++ b/src/fight.ts
@@ -410,7 +410,7 @@ function toResto(bowls: number) {
for (const k of inPot()) F.pantry[k] -= PER_BATCH;
const got = bowlPrice(growOpen()) * RESTO_SHARE * bowls;
F.resto += bowls;
- F.cooked.push(F.t); F.earned.push([F.t, got]);
+ F.cooked.push(F.t); F.earned.push([F.t, got]); made += got;
if (real) { on.cooked(); on.resto(bowls, got); on.earn(got, 'resto'); }
}
/** Cooks a batch every half second while someone's at the pot, if there's four of each and room for the bowls (or
@@ -448,7 +448,7 @@ export const slid = { n: 0 };
function sell(n: number) {
let got = 0;
for (let i = 0; i < n && F.stall.length; i++) { got += bowlPrice(F.stall.shift()!); F.fed.push(F.t); if (real) sold.n++; }
- F.earned.push([F.t, got]);
+ F.earned.push([F.t, got]); made += got;
if (real && got) on.earn(got, 'bowls');
return got;
}
@@ -657,6 +657,30 @@ export function forecast(kept = 0, { bonus = 0, need }: { bonus?: number; need?:
fc.set(key, { t: F.t, i: F.beaten, v });
return v;
}
+/** Chips the stall's bowls and Mama Moray's have made, in this cellar or a copy of it (idleRate). */
+let made = 0;
+/** Seconds of the cellar run ahead to say what it makes on its own. */
+const IDLE_S = 300;
+/**
+ * Chips a second the cellar makes on its own, with nobody else in it: its hands, its fans, its local fights and Mama
+ * Moray's, run ahead in a copy for IDLE_S seconds from now, as the forecast runs a rush (exact: nothing down here is
+ * left to chance). While the player's upstairs, the cellar holds still and pays this instead (ring.ts). None while a
+ * boss is in or called: then the cellar runs as it is.
+ */
+export function idleRate() {
+ if (F.rush || F.called) return 0;
+ const was = F, made0 = made;
+ F = structuredClone(was);
+ real = false;
+ try {
+ for (let t = 0; t < IDLE_S; t += 0.05) step(0.05);
+ return (made - made0) / IDLE_S;
+ } finally {
+ F = was;
+ real = true;
+ made = made0;
+ }
+}
/** Whether the crew does everything without the player: both lines hired, the mushrooms too once they grow, and the
* bowls carried out (or slid) to the soup square. */
const handsFree = () => !!(F.hires.washer && F.hires.salter && (F.hires.attendant || F.slide) && (!growOpen() || F.hires.picker));
diff --git a/src/front.ts b/src/front.ts
index d833acf..e737d0f 100644
--- a/src/front.ts
+++ b/src/front.ts
@@ -5,6 +5,7 @@
// upstairs just ticks over for Mr. Fugu while the player's below, where there's no rating at all.
import { stage } from './layout';
import { addReview } from './rating';
+import { addMoney } from './wallet';
export type FrontMode = 'rent' | 'trickle';
/** How much of the takings the trickle lets through. */
@@ -31,10 +32,40 @@ export function tillShare() {
/** Bills for `values`, cut down to what reaches the till (none at all, while it's all rent). */
export const toTill = (values: number[]) => {
+ for (const v of values) taken += v;
const k = tillShare();
return k === 1 ? values : values.map(v => Math.round(v * k)).filter(v => v > 0);
};
+// ---------- with the player down in the cellar ----------
+// While the player's down in the cellar, the world up top holds still and isn't drawn (game.ts, levelCull.ts), as the
+// cellar does while they're up there (ring.ts). What its tills would take comes in at a steady rate instead: what its
+// customers have paid a second, over the last few minutes it was running, of which the player's share (tillShare:
+// none while it's Mr. Fugu's rent, all of it once he's bought out). Not kept in the save: a game loaded in the cellar
+// pays from the tills once it's been up top for a while.
+/** Dollars paid at the tills up top since the last second's count; each second's, over the last WINDOW; and what's
+ * owed the player down below. */
+let taken = 0, takenT = 0, owed = 0;
+const seconds: number[] = [], WINDOW = 180;
+/** Dollars a second the tills up top take, by the last few minutes up there. */
+export const tillRate = () => (seconds.length ? seconds.reduce((a, b) => a + b, 0) / seconds.length : 0);
+/** Each frame, `away` while the world up top's holding still: counts what its tills take while it runs, or pays the
+ * player their share of that rate while it doesn't. */
+export function updTills(dt: number, away: boolean) {
+ if (!away) {
+ owed = 0;
+ if ((takenT += dt) < 1) return;
+ takenT -= 1;
+ seconds.push(taken); taken = 0;
+ if (seconds.length > WINDOW) seconds.shift();
+ return;
+ }
+ owed += tillRate() * tillShare() * dt;
+ if (owed < 1) return;
+ const whole = Math.floor(owed);
+ addMoney(whole); owed -= whole;
+}
+
/** A customer's review of the restaurant or the kiosk: it only counts before the ring. */
export function frontReview(stars: number) {
if (stage.n < 3) addReview(stars);
diff --git a/src/fuguDemo.ts b/src/fuguDemo.ts
index eb02f8c..bb6d115 100644
--- a/src/fuguDemo.ts
+++ b/src/fuguDemo.ts
@@ -2,12 +2,12 @@
// the last diners' bills at the register, which the crew brings in a moment later: then he knocks, and the arrow
// points the way. Or, with ?cellar in the address, down in his cellar (stage 3) with chips to spend, to try fight
// night (&chips=N for N chips, not 2,000; &beaten=N with N of the bosses beaten already, and what they open open: from
-// 2, Mr. Fugu with his matchbook for the 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; &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).
+// 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; &gong on the gong's pad; &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). Or ?stage=1 for a stage 1 market instead (the 🛠 panel's buttons, demo.ts, open these).
// Nothing is kept, so every visit starts over, the tutorial included.
-import { MAX_MACHINES, POT_PAD } from './cellarPlan';
+import { GONG_PAD, MAX_MACHINES, POT_PAD } from './cellarPlan';
import { F, LEDGE_CAP } from './fight';
import { player } from './player';
import { REGISTER } from './restaurant';
@@ -26,10 +26,26 @@ const FULL = (beaten: number) => ({
den: { book: beaten >= 2, way: beaten >= 2, door: beaten >= 2, met: { eddie: beaten >= 2, harry: beaten >= 2, tommy: beaten >= 2 } },
});
+/** A market game for the 🛠 panel (demo.ts): stage 1 built (Floe Sushi to come), or stages 1 and 2 with none of the
+ * ring's tiles (they wait for Mr. Fugu's offer). */
+export function marketSave(stage: 1 | 2) {
+ const done = (t: { id: string; stage: number }) => (stage === 2 ? t.stage < 3 && t.id !== 'dohyo' : t.stage === 1 && t.id !== 'sushi');
+ return JSON.stringify({
+ v: 4, savedAt: Date.now(), money: stage === 2 ? 300_000 : 9_000, best: 300_000,
+ tiles: tiles.map(t => ({ id: t.id, paid: done(t) ? t.cost : 0, done: done(t), open: true })),
+ mods: stage === 2 ? { fillets: 6, marketing: 8, training: 8, specials: 8, promo: 8, crew: 8 } : { fillets: 4, marketing: 5, crew: 3 },
+ reviews: Array(20).fill(5), pile: 20, back: 0, backRice: 0, c1: 5, c1c: 0, c2: 4, c2c: 0, fish: 8, rice: 8, plates: 6,
+ cash: 0, boxes: 2, tcash: 0, field: 6, season: 0, seasonT: 30, presents: 2, presentPaid: 0,
+ learnt: ['fish', 'pick', 'sell', 'cash', 'buy', 'shop', 'harpoon', 'stars'],
+ });
+}
+
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');
+ // (?stage=1: a stage 1 market instead, from the 🛠 panel)
+ if (!cellar && q.get('stage') === '1') { deviceStore.write(marketSave(1)); load(); return; }
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'), full = cellar && q.has('full');
deviceStore.write(JSON.stringify({
@@ -48,5 +64,7 @@ export function startDemo() {
load();
if (!cellar) player.g.position.set(REGISTER.x - 1.6, FY, REGISTER.z - 1.4);
// (with a full ledge, so the show can be played again and again)
+ // (on the gong's pad, to ring the next boss in)
+ else if (q.has('gong')) player.g.position.set(GONG_PAD.x, player.g.position.y, GONG_PAD.z);
else if (q.has('pot')) { player.g.position.set(POT_PAD.x, player.g.position.y, POT_PAD.z); F.pantry = { rice: LEDGE_CAP, fish: LEDGE_CAP, mush: LEDGE_CAP }; }
}
diff --git a/src/game.ts b/src/game.ts
index 74074b3..fc86c8e 100644
--- a/src/game.ts
+++ b/src/game.ts
@@ -7,6 +7,7 @@ import { showing, updCookButton, updShow } from './chanko';
import { updBakes } from './characters';
import { calm, COUNTERS, postCustomers, updCounter, updLeaving } from './counters';
import { updFarm } from './farm';
+import { updTills } from './front';
import { updFx } from './fx';
import { updGarden } from './garden';
import { headless } from './headless';
@@ -59,21 +60,26 @@ export function tick(dt: number) {
const coin = coinUp(), held = offerPlaying() || coin;
// (the balance sims leave out what only draws, moves or sounds: headless.ts)
const live = !headless.on;
+ // Down in the cellar in stage 3, the world up top holds still: nobody sees it (levelCull.ts), so its fishing, boats,
+ // customers, crew, counters and farm wait where they are, and what its tills would take comes in at a steady rate
+ // instead (front.ts). As the cellar does while the player's up there (ring.ts).
+ const away = stage.n === 3 && level.down && !staging(), world = live && !away;
+ updTills(dt, away);
if (coin) updCoin(dt);
updPlayer(dt);
- if (live) updFish(dt, time);
- if (live) updHooks(dt);
+ if (world) updFish(dt, time);
+ if (world) updHooks(dt);
if (!held) {
- if (live) updCasino(dt);
- if (live) updKorki(dt);
- if (live) updHouse(dt);
- updAuto(dt);
- if (live) updChopper(dt);
- if (live) updRunners(dt);
- if (live) updRice(dt);
- if (live) updRestaurant(dt);
- if (live) updNeeds(dt);
- if (live) updGarden(dt);
+ if (world) updCasino(dt);
+ if (world) updKorki(dt);
+ if (world) updHouse(dt);
+ if (!away) updAuto(dt);
+ if (world) updChopper(dt);
+ if (world) updRunners(dt);
+ if (world) updRice(dt);
+ if (world) updRestaurant(dt);
+ if (world) updNeeds(dt);
+ if (world) updGarden(dt);
// stage 3: the ring under the dock, and the ladder down to it
if (stage.n === 3) {
updRing(dt);
@@ -85,28 +91,29 @@ export function tick(dt: number) {
// While he's on, the world winds down rather than stopping dead: what's in the air lands, and the cars finish their
// drive (nobody new pulls in, or passes by, and nobody waiting loses patience); everyone else holds still.
calm.on = held;
- if (live) COUNTERS.forEach(C => updCounter(C, dt));
- if (live) updLeaving(dt);
- if (live) postCustomers(dt);
+ if (world) COUNTERS.forEach(C => updCounter(C, dt));
+ if (world) updLeaving(dt);
+ if (world) postCustomers(dt);
updStars();
updFlights(dt);
if (live) updPops(dt);
if (live) updFx(dt);
- if (live) updHorizon(dt);
- if (live) updFloes(time, player.g.position);
+ if (world) updHorizon(dt);
+ if (world) updFloes(time, player.g.position);
if (!held) {
// (nothing falls in the cellar)
// the seasons turn up in the world; down in the cellar, nobody hears about it
if (updSeason(dt, player.g.position, level.down ? 1 : rainK) && !level.down) { toast(SEASON_INFO[current()].news, 'season'); seasonSting(current()); }
- if (live) updFarm(dt);
+ if (world) updFarm(dt);
}
updStage(dt);
if (live) updSpotlight(dt);
updShop();
if (!held) updTutorial(dt);
+ // (both levels' upgrades: the cellar's too)
if (live) updLooks(dt);
if (live) updBakes(dt);
- if (live) updRoof(dt, player.g.position, staging());
+ if (world) updRoof(dt, player.g.position, staging());
updOffer(dt);
// (and through his scenes, the scenes on the way south in the cellar (southDoor.ts), and the Family introducing
// themselves (meet.ts), the world's own sounds go quiet)
diff --git a/src/levelCull.ts b/src/levelCull.ts
index 5b77c42..222192d 100644
--- a/src/levelCull.ts
+++ b/src/levelCull.ts
@@ -8,6 +8,11 @@
// 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.
//
+// Upstairs in stage 3 it's the other way round: the cellar's 30 m down, under the dock's floor, and the fans' meshes
+// (never culled: they're drawn wherever their instances are) and everything else wholly down there would still be
+// gone through and drawn. So while the player's up top, all of that is hidden for the render too: what's marked as
+// the cellar's own (`userData.below`) and whatever's wholly below. (It holds still meanwhile: ring.ts.)
+//
// 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
@@ -19,12 +24,15 @@ import { Box3, Frustum, type InstancedMesh, Matrix4, type Mesh, type Object3D, R
import { CELLAR_Y, level } from './layout';
import { camera, scene, sun, sunOff } from './render';
-/** Anything wholly above this is up in the world. */
+/** Anything wholly above this is up in the world, and anything wholly below it down in the cellar. */
const ABOVE = CELLAR_Y + 15;
+/** Whether the cellar's out of sight now: stage 3, the player upstairs, and no stage-up show taking them down (ring.ts
+ * says). */
+export const cellarAway = { now: () => false };
/** Things whose bounds are looked at again each frame. */
const PER_FRAME = 48;
-const up = new WeakMap(), box = new Box3(), hidden: Object3D[] = [];
-let next = 0, wasDown = false, last = 0;
+const up = new WeakMap(), below = new WeakMap(), box = new Box3(), hidden: Object3D[] = [];
+let next = 0, wasDown = false, last = 0, nextBelow = 0, wasAway = false;
function judge(o: Object3D) {
box.setFromObject(o);
@@ -32,6 +40,21 @@ function judge(o: Object3D) {
up.set(o, v);
return v;
}
+function judgeBelow(o: Object3D) {
+ box.setFromObject(o);
+ const v = !!o.userData.below || (!(o as InstancedMesh).isInstancedMesh && !box.isEmpty() && box.max.y < ABOVE);
+ below.set(o, v);
+ return v;
+}
+/** Upstairs: hides the cellar for this frame's render. */
+function hideBelow() {
+ const kids = scene.children;
+ if (!wasAway) { kids.forEach(judgeBelow); wasAway = true; }
+ else for (let i = 0; i < Math.min(PER_FRAME, kids.length); i++) judgeBelow(kids[nextBelow = (nextBelow + 1) % kids.length]);
+ for (const o of kids) {
+ if (o.visible && (below.get(o) ?? judgeBelow(o))) { o.visible = false; hidden.push(o); }
+ }
+}
const show = () => {
for (const o of hidden) o.visible = true;
hidden.length = 0;
@@ -39,7 +62,13 @@ 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) { if (wasDown) { unfit(); seenAll(); } wasDown = false; last = 0; return show; }
+ if (!level.down) {
+ if (wasDown) { unfit(); seenAll(); }
+ wasDown = false; last = 0;
+ if (cellarAway.now()) hideBelow(); else wasAway = false;
+ return show;
+ }
+ wasAway = false;
fitShadows();
cullFigures();
const now = performance.now(), dt = Math.min(0.1, (now - drawnAt) / 1000);
diff --git a/src/ring.ts b/src/ring.ts
index dd5d220..3e0dbfd 100644
--- a/src/ring.ts
+++ b/src/ring.ts
@@ -5,10 +5,11 @@
import { CY, parts, showDen, showGrowRoom, showVenue, updBarFlap } from './cellar';
import { BAR, GONG } from './cellarPlan';
import { openRing, updDohyo } from './dohyo';
-import { CHIP, F, fightFrom, on, resetFight, step } from './fight';
+import { CHIP, F, fightFrom, idleRate, on, resetFight, step } from './fight';
import { front } from './front';
import { cardUp, showCard, updGong, walkIn } from './gong';
-import { DOORS, level, PREP } from './layout';
+import { DOORS, HATCH_AT, level, PREP, stage } from './layout';
+import { cellarAway } from './levelCull';
import { collect } from './loan';
import { updOffice } from './office';
import { updUndertow } from './undertow';
@@ -28,6 +29,7 @@ import { staging } from './stage';
import { inHall, updSwarm } from './swarm';
import { popText } from './ui';
import { showProgress, visibleTiles } from './unlocks';
+import { FY } from './util';
import { addMoney } from './wallet';
/** Chips taken at the soup bar since the last "+🐡" over it (one a second, not one a bowl). */
@@ -112,7 +114,38 @@ export function loadCellar(raw: unknown) {
}
/** The cellar, every frame (stage 3 only). */
+// ---------- upstairs ----------
+// Upstairs, with no boss in or called, the cellar holds still and isn't drawn (levelCull.ts): what it would make on its
+// own, its hands, its fans and Mama Moray's, comes in at a steady rate instead, worked out as the player climbs up
+// (fight.ts, idleRate), a chip at a time, Harry taking his share as at the stall; and near the hatch, a "+🐡" over it
+// every few seconds. Back down, it's all as it was left.
+/** Chips a second the cellar makes on its own (-1 till it's worked out), what's come in since it was last paid, what's
+ * been paid since the last "+🐡", and when that's next. */
+let idle = -1, owed = 0, paid = 0, popT = 0;
+const POP_EVERY = 3, POP_NEAR = 10;
+// (and isn't drawn, a boss in or not, unless the stage-up's show is taking the player down)
+cellarAway.now = () => stage.n === 3 && !level.down && !staging();
+/** Whether the cellar's holding still for the player being upstairs. */
+export const restingUp = () => !level.down && !staging() && !F.rush && !F.called;
+function upstairs(dt: number) {
+ if (idle < 0) { idle = idleRate(); popT = POP_EVERY; }
+ owed += idle * dt;
+ if (owed >= 1) {
+ const got = owed - collect(owed);
+ addMoney(Math.round(got * CHIP));
+ paid += got; owed = 0;
+ }
+ if ((popT -= dt) > 0) return;
+ popT = POP_EVERY;
+ const p = player.g.position;
+ if (paid >= 1 && Math.hypot(p.x - HATCH_AT.x, p.z - HATCH_AT.z) < POP_NEAR) popText(`+🐡 ${Math.round(paid).toLocaleString('en-US')}`, { x: HATCH_AT.x, y: FY + 1.4, z: HATCH_AT.z });
+ paid = 0;
+}
+
export function updRing(dt: number) {
+ // (the gong's side of the HUD goes on up top: the 💪 chip giving way to the season's, a card or banner timing out)
+ if (restingUp()) { upstairs(dt); updGong(dt); return; }
+ idle = -1; owed = 0;
// While a scene on the way south has the player (southDoor.ts), the cellar winds down and holds still: nothing goes on
// in the fight, and so nobody new comes or goes and nobody's turn comes; but whoever's walking somewhere gets there,
// the soup square's flap settles, and the wrestlers ease into where they were going.
diff --git a/src/swarm.ts b/src/swarm.ts
index e25de16..1a004fe 100644
--- a/src/swarm.ts
+++ b/src/swarm.ts
@@ -101,6 +101,7 @@ function makeLooks() {
// (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;
+ im.userData.below = true; // (only ever in the cellar: hidden while the player's upstairs, levelCull.ts)
if (casts) {
const own = im.geometry.drawRange.count;
im.userData.shadowFrom = own; // (where its shadow's copy starts, as kit.ts has it)
@@ -424,8 +425,9 @@ const ROUTES_A_FRAME = 200;
let slow = 0, shadows = true;
/** How far a seated fan's legs come up in front of them, as a diner's do (ads.ts), and how fast they sit and stand. */
const SIT_LEGS = -1.45, SIT_RATE = 5;
-/** How fast fans walk home (before the bar's open, they walk in at the same pace). */
-const HOME_PACE = 2.6;
+/** How fast fans walk home (before the bar's open, they walk in at the same pace), and the fastest a local fan late
+ * for the bar runs (metres a second): one due there sooner than that gets there late, served on the way. */
+const HOME_PACE = 2.6, RUN = 6;
/** Where the fans who got their bowl this frame were when they did (for tests). */
const turned: (XZ & { key: number })[] = [];
export const turnedAt = () => turned;
@@ -458,8 +460,9 @@ export function updSwarm(dt: number) {
// anyone running late hurries)
let v = (f.out ? HOME_PACE : pace) * f.speed * dt;
if (f.out) { /* (at a walk) */ } else if (R && R.walk > 0 && !f.seat) v = Math.max(v, (d + wayLeft(f)) / Math.max(dt, R.walk) * dt);
- // (and so does a local fan who'd get to the bar after they're due at it, or one with their bowl waiting)
- else if (!R && !f.seat && f.due > 0) v = Math.max(v, (d + wayLeft(f)) / Math.max(dt, f.due - F.t) * dt);
+ // (and so does a local fan who'd get to the bar after they're due at it, or one with their bowl waiting: at a run,
+ // never faster, however late)
+ else if (!R && !f.seat && f.due > 0) v = Math.max(v, Math.min(RUN * dt, (d + wayLeft(f)) / Math.max(dt, f.due - F.t) * dt));
else if (f.via) v = Math.max(v, 3.2 * f.speed * dt);
// (the last step onto a bench climbs it)
const last = f.way.length === 1, ty = last && f.seat ? f.seat.y : 0;
diff --git a/test/stage3.test.ts b/test/stage3.test.ts
index 6aaa8a7..88d4a33 100644
--- a/test/stage3.test.ts
+++ b/test/stage3.test.ts
@@ -154,18 +154,19 @@ describe('buying into the ring', () => {
});
describe('the ladder', () => {
- it("down in the cellar, the player's under the road: the drivers up top don't stop for them", async () => {
- const { g, cellar } = await ring({ level: 1 });
- const { TAKEOUT } = g.counters, { HOUSE_PATH_Z } = await import('../src/layout');
+ it("down in the cellar, the player's under the road: the drivers up top wait where they are, and don't stop for them", async () => {
+ const { g, cellar } = await ring({ level: 0 });
+ const { TAKEOUT } = g.counters, { HOUSE_PATH_Z } = await import('../src/layout'), { setLevel } = await import('../src/stage');
for (let i = 0; i < 12; i++) TAKEOUT.stock.put(g.items.newBox(30, false));
- // (right under the road on a takeout driver's way home, where they'd be held up and get out to boop them)
- const under = () => g.player.g.position.set(g.world.ROAD2_X, cellar.CY, HOUSE_PATH_Z + 0.4);
- g.runUntil(() => { under(); return TAKEOUT.queue[0]?.got > 0; }, 40);
- const c = TAKEOUT.queue[0];
- let held = 0;
- g.runUntil(() => { under(); held = Math.max(held, c.held); return c.g.position.z > HOUSE_PATH_Z + 2; }, 30);
- expect(held).toBe(0);
- expect(g.player.booped).toBeNull();
+ g.runUntil(() => TAKEOUT.queue[0]?.got > 0, 40);
+ // (right under the road on a takeout driver's way home, where they'd be held up and get out to boop them; but up
+ // top holds still while the player's down: game.ts)
+ setLevel(true);
+ const c = TAKEOUT.queue[0], at = c.g.position.clone();
+ g.player.g.position.set(g.world.ROAD2_X, cellar.CY, HOUSE_PATH_Z + 0.4);
+ g.run(10);
+ expect(c.g.position.equals(at)).toBe(true);
+ expect([c.held, g.player.booped]).toEqual([0, null]);
});
it("doesn't draw the world up top while the player's down in the cellar, and puts it all back after", async () => {
@@ -1755,6 +1756,28 @@ describe('the swarm and the watch spot', () => {
expect(Math.min(...stood.map(p => plan.fromBar(p)))).toBeLessThan(1.5);
});
+ it('a local fan late for the bar runs there, no faster than a person can', async () => {
+ // (a busy kitchen, its hands all hired, four bosses down: a crowd that comes in fast)
+ const { g, swarm } = await ring({ ring: {
+ beaten: 4, tier: 3, slide: true, resto: 3, pantry: { rice: 40, fish: 40, mush: 40 },
+ hires: { washer: true, salter: true, picker: true, attendant: true, cook: true },
+ lines: { rice: { n: 6, up: 1 }, fish: { n: 6, up: 1 }, mush: { n: 6, up: 1 } },
+ } });
+ const dt = 1 / 60, was = new Map();
+ let fastest = 0;
+ for (let i = 0; i < 20 / dt; i++) {
+ g.run(dt, dt);
+ for (const f of swarm.whereabouts()) {
+ const p = was.get(f.key);
+ // (from half a second after they're first seen: a new fan is set in place in their tunnel before they set off)
+ if (p && p.seen > 0.5) fastest = Math.max(fastest, Math.hypot(f.x - p.x, f.z - p.z) / dt);
+ was.set(f.key, { x: f.x, z: f.z, seen: (p?.seen ?? 0) + dt });
+ }
+ }
+ expect(fastest).toBeGreaterThan(2);
+ expect(fastest).toBeLessThan(7); // (swarm.ts RUN, 6 m/s: a person running, not 58 as late ones were)
+ }, 30_000);
+
it('a bowl sold waits on the soup square for the fan who bought it, and hops to them when they get there', async () => {
const { g, swarm, fight, F } = await ring();
const { barShows } = await import('../src/ringKitchen');
diff --git a/test/stage3Away.test.ts b/test/stage3Away.test.ts
new file mode 100644
index 0000000..18e4ac1
--- /dev/null
+++ b/test/stage3Away.test.ts
@@ -0,0 +1,116 @@
+// Stage 3, one level at a time: with the player upstairs, the cellar holds still and isn't drawn, and what it would
+// make on its own (its hands, its fans, Mama Moray's) comes in at a steady rate instead, worked out from a copy of it
+// run ahead (fight.ts, idleRate; ring.ts). Down in the cellar, the world up top holds still in its turn, and its tills
+// pay the player's share of what they were taking (front.ts). Each is as it was left when the player's back.
+import { Box3, type Object3D } from 'three';
+import { describe, expect, it } from 'vitest';
+import { bought, FLOE_SUSHI, loadGame, MARKET, type SaveFixture } from './helpers';
+
+const SUSHI = [...MARKET, 'sushi', ...FLOE_SUSHI];
+const HANDS = { washer: true, salter: true, picker: true, attendant: true, cook: true };
+const LINES = { rice: { n: 6, up: 1 }, fish: { n: 6, up: 1 }, mush: { n: 6, up: 1 } };
+/** A cellar with everything bought, four bosses down. */
+const BUSY = { beaten: 4, tier: 3, slide: true, resto: 3, pantry: { rice: 40, fish: 40, mush: 40 }, hires: HANDS, lines: LINES };
+/** A game in stage 3, the player up top (`level` 0) or down in the cellar (1). */
+async function game(level: 0 | 1, ring: object, save: SaveFixture = {}) {
+ const g = await loadGame({ tiles: bought(...SUSHI, 'dohyo'), fugu: 2, level, reviews: Array(20).fill(5), ring, ...save } as SaveFixture);
+ const [fight, levelCull, render, cellar, layout] = await Promise.all([
+ import('../src/fight'), import('../src/levelCull'), import('../src/render'), import('../src/cellar'), import('../src/layout'),
+ ]);
+ return { g, fight, levelCull, render, cellar, layout, F: () => fight.F };
+}
+/** Chips made over `s` seconds. */
+const chipsOver = (g: Awaited>['g'], s: number) => {
+ const m0 = g.wallet.money;
+ for (let i = 0; i < s; i++) g.run(1);
+ return (g.wallet.money - m0) / 1000;
+};
+
+describe('the cellar, with the player upstairs', () => {
+ it('holds still, and pays what it makes on its own, as much as it makes running', async () => {
+ const down = await game(1, BUSY);
+ const running = chipsOver(down.g, 300);
+ const { g, F, fight } = await game(0, BUSY);
+ const rate = fight.idleRate(), t = F().t, crew = JSON.stringify(F().crew);
+ expect(rate * 60).toBeGreaterThan(100);
+ const paid = chipsOver(g, 300);
+ // (nothing moves down there: its clock, its hands)
+ expect([F().t, JSON.stringify(F().crew)]).toEqual([t, crew]);
+ // at the rate it worked out, a chip at a time, and within a few percent of the cellar running for those 5 minutes
+ expect(paid).toBeCloseTo(rate * 300, -1);
+ expect(Math.abs(paid - running) / running).toBeLessThan(0.08);
+ }, 60_000);
+
+ it('pays nothing before there are hands to work it, as it makes nothing on its own', async () => {
+ const { g, fight } = await game(0, { beaten: 1, lines: { mush: { n: 1 } } });
+ expect(fight.idleRate()).toBe(0);
+ expect(chipsOver(g, 60)).toBe(0);
+ }, 30_000);
+
+ it('runs as ever with a boss called, up top or not', async () => {
+ const { g, F, fight, layout } = await game(1, BUSY);
+ expect(fight.ringGong()).toBe(true);
+ const { setLevel } = await import('../src/stage');
+ setLevel(false);
+ expect(layout.level.down).toBe(false);
+ const t = F().t;
+ g.run(2);
+ expect(F().t).toBeGreaterThan(t + 1.5);
+ expect(fight.idleRate()).toBe(0);
+ }, 30_000);
+
+ it("isn't drawn from up top, and is again back down", async () => {
+ const { g, levelCull, render, cellar, layout } = await game(1, BUSY);
+ g.run(2);
+ const { setLevel } = await import('../src/stage');
+ setLevel(false);
+ g.run(1);
+ const below = (o: Object3D) => !!o.userData.below;
+ const fans = render.scene.children.filter(o => below(o) && o !== cellar.cellar);
+ expect(fans.length).toBeGreaterThan(0);
+ const was = render.scene.children.filter(o => o.visible);
+ expect(cellar.cellar.visible).toBe(true);
+ const show = levelCull.hideAbove();
+ // (for the render: the cellar, its fans' meshes and whatever's wholly down there, and nothing up in the world)
+ const gone = was.filter(o => !o.visible), box = new Box3();
+ expect(gone).toContain(cellar.cellar);
+ expect(fans.every(o => !o.visible)).toBe(true);
+ for (const o of gone) expect(below(o) || box.setFromObject(o).max.y < layout.CELLAR_Y + 15).toBe(true);
+ show();
+ expect(was.every(o => o.visible)).toBe(true);
+ // down the ladder: drawn
+ setLevel(true);
+ const show2 = levelCull.hideAbove();
+ expect([cellar.cellar.visible, fans.every(o => o.visible)]).toEqual([true, true]);
+ show2();
+ }, 30_000);
+});
+
+describe('the world up top, with the player down in the cellar', () => {
+ it('holds still: the belt, the diners', async () => {
+ const { g } = await game(1, BUSY, { plates: 30 });
+ g.run(2);
+ const { sushi } = g.restaurant, was = JSON.stringify([sushi.offset, sushi.t, sushi.diners.map(d => d.g.position)]);
+ g.run(30);
+ expect(JSON.stringify([sushi.offset, sushi.t, sushi.diners.map(d => d.g.position)])).toBe(was);
+ }, 30_000);
+
+ it("pays the player's share of what its tills were taking: none while it's Mr. Fugu's rent, all once he's bought out", async () => {
+ // (a cellar that makes nothing on its own yet: whatever comes in down there is from up top)
+ const { g, F } = await game(0, { beaten: 1, lines: { mush: { n: 1 } } }, { plates: 30 });
+ const front = await import('../src/front'), { setLevel } = await import('../src/stage');
+ g.run(120);
+ const rate = front.tillRate();
+ expect(rate).toBeGreaterThan(0);
+ setLevel(true);
+ let m0 = g.wallet.money;
+ g.run(30);
+ expect(g.wallet.money).toBe(m0);
+ F().boughtOut = true; front.front.boughtOut = true;
+ m0 = g.wallet.money;
+ g.run(60);
+ expect(g.wallet.money - m0).toBeGreaterThan(rate * 60 - 2);
+ expect(g.wallet.money - m0).toBeLessThanOrEqual(rate * 60 + 1);
+ expect(front.tillRate()).toBe(rate);
+ }, 60_000);
+});