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 = ` `; 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); +});