Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
15 changes: 12 additions & 3 deletions docs/graphics.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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:
Expand Down
3 changes: 3 additions & 0 deletions src/cellar.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
98 changes: 57 additions & 41 deletions src/demo.ts
Original file line number Diff line number Diff line change
@@ -1,80 +1,96 @@
// 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;
font:600 13px/1.3 "Baloo 2",system-ui,sans-serif;color:#1B2430}
#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 = `<style>${css}</style><button type="button" aria-label="Demo tools">πŸ› </button>
<div class="box" hidden>
<div class="stats" id="demoStats">…</div>
<div class="row" id="demoGfx"><b>Graphics</b><button data-q="low">Low</button><button data-q="high">High</button></div>
<div class="row" id="demoSeason"><b>Season</b>${SEASONS.map((s, i) => `<button data-s="${i}">${SEASON_INFO[s].name}</button>`).join('')}</div>
<div class="row" id="demoFx"><b>Effects</b><button data-fx="splash">Splash</button><button data-fx="chips">Chips</button>
<button data-fx="coins">Coins</button><button data-fx="glint">Glint</button><button data-fx="unlock">Sparkles</button></div>
<div class="row" id="demoGame"><b>Game</b><button data-g="0">To buy</button><button data-g="1">Stage 1</button><button data-g="2">Stage 2</button></div>
<div class="row" id="demoGame"><b>Game</b><button data-g="1">Stage 1</button><button data-g="2">Stage 2</button>
<button data-g="3" ${DEMO ? '' : 'hidden'}>Stage 3</button></div>
<div class="sub" ${DEMO ? '' : 'hidden'}>Stage 3</div>
<div class="row" id="demoBeaten" ${DEMO ? '' : 'hidden'}><b>Beaten</b><button data-b="0">0</button>
${BOSSES.map((b, i) => `<button data-b="${i + 1}" title="${b.name}">${i + 1} ${b.face}</button>`).join('')}</div>
<div class="row" id="demoKit" ${DEMO ? '' : 'hidden'}><b>Cellar</b><button data-f="0">As won</button><button data-f="1">All bought</button></div>
<div class="row" id="demoMoney"><b>Money</b>${GIFTS.map(m => `<button data-m="${m}"></button>`).join('')}</div>
</div>`;
document.body.appendChild(el);
const box = el.querySelector<HTMLElement>('.box')!, stats = el.querySelector<HTMLElement>('#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<HTMLButtonElement>('#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<HTMLButtonElement>(`${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<HTMLButtonElement>('#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);
Expand Down
28 changes: 26 additions & 2 deletions src/fight.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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;
}
Expand Down Expand Up @@ -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));
Expand Down
31 changes: 31 additions & 0 deletions src/front.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand All @@ -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);
Expand Down
Loading
Loading