diff --git a/docs/game.md b/docs/game.md
index 96285e5..6cb8c80 100644
--- a/docs/game.md
+++ b/docs/game.md
@@ -23,7 +23,7 @@ npm run dev
- **Fish:** stand on the 🎣 pad to hook fish; they get chopped into fish slices automatically
- **Sell fish (stage 1):** pick slices up from the pile, drop them on the counter's 🐟 pad, and walk over the cash to collect it. Walk-in customers pay $4 a slice; once the drive-up window is built, drivers on the road buy 4–8 at a time at $6
- **Upgrade:** stand on a price tile (the squares) and hold **E**, or the **Buy** button that comes up at the side, to pay into it. On a computer the button can be turned off under Controls in the settings; on a touch screen, with no E, it's always there. Stand on one for 2 seconds without buying and a reminder says how. Squares are for buying (the tiles, and the Upgrade squares' menus) and circles for doing something (fishing, dropping things off, the casino's games). The tiles lie square with the world, with their icon and price turned to face the camera. Some tiles also need a star rating (see below). The chip under the rating shows the stage and how many of its upgrades are built
-- **Graphics (✨ in the settings): Auto, Low or High.** High draws the whole world the way the people are drawn: low poly but rounded, with the small details (the rounded, dressed-up people under Seasons, below; real deck boards, lashed log fences and pilings, fish stalls with crushed ice, a tiled restaurant with a torii and stone lanterns, dry-stone terraces, her house with siding and shutters, and every machine, sled and campaign in the same style). The sea rolls in low-poly facets that glint, foam laps the shore and rings the floes and posts, the paddies and the hot spring ripple, cloud shadows drift over the ground, shadows have soft edges, and roofs, posts and fences get snow in winter, the deck petals in spring and leaves in autumn. Little effects mark the moments that matter: a splash when a fish is pulled out, chips off the board as the cleaver comes down, coins hopping up when cash lands in your pocket and a glint where a customer pays, gold sparkles round a tile when an upgrade is bought, smoke from her chimney and steam from the rice cookers. Low draws everything as it was before all this: block-limbed people, a flat sea, plain boxes and logs, no effects, and the renderer is lighter (a shadow map half the size with hard edges, and no more than one and a half pixels a point). Either way, whatever stands still is drawn merged with its neighbours, so the scenery takes a few draw calls instead of one for every post and plank, and the icons on the floor tiles, pads, signs and menu tags are drawn in the game's own style rather than taken from the phone's emoji, so they look the same everywhere. Auto, the default, lets the game pick, and shows under the choices which one it's using: it starts each visit on High and watches the frame rate, and if that stays under 45 a second for six seconds (not counting the first few seconds, or a pause while the tab is hidden), it drops to Low for the rest of the visit. Choosing Low or High stops that; choosing Auto again starts over on High. The choice is kept on the device. On Low, the look panel leaves out cheeks and shoes, which Low doesn't draw
+- **Graphics (✨ in the settings): Auto, Low or High.** High draws the whole world the way the people are drawn: low poly but rounded, with the small details (the rounded, dressed-up people under Seasons, below; real deck boards, lashed log fences and pilings, fish stalls with crushed ice, a tiled restaurant with a torii and stone lanterns, dry-stone terraces, her house with siding and shutters, and every machine, sled and campaign in the same style). The sea rolls in low-poly facets that glint, foam laps the shore and rings the floes and posts, the paddies and the hot spring ripple, cloud shadows drift over the ground, shadows have soft edges, and roofs, posts and fences get snow in winter, the deck petals in spring and leaves in autumn. Little effects mark the moments that matter: a splash when a fish is pulled out, chips off the board as the cleaver comes down, coins hopping up when cash lands in your pocket and a glint where a customer pays, gold sparkles round a tile when an upgrade is bought, smoke from her chimney and steam from the rice cookers. Low draws everything as it was before all this: block-limbed people, a flat sea, plain boxes and logs, no effects, and the renderer is lighter (a shadow map half the size with hard edges, and no more than one and a half pixels a point). Either way, whatever stands still is drawn merged with its neighbours, so the scenery takes a few draw calls instead of one for every post and plank, and the icons on the floor tiles, pads, signs and menu tags are drawn in the game's own style rather than taken from the phone's emoji, so they look the same everywhere. Auto, the default, lets the game pick, and shows under the choices which one it's using: it starts each visit on High and watches the frame rate, and if that stays under 45 a second for six seconds (not counting the first few seconds, or a pause while the tab is hidden), it drops to Low for the rest of the visit. If the browser takes the 3D view away while you play (a phone short of memory), Auto goes to Low for good, and a dialog says the 3D view stopped, your game is saved, with a Reload button; if the 3D view can't start at all, it says to restart the browser. Choosing Low or High stops that; choosing Auto again starts over on High. The choice is kept on the device. On Low, the look panel leaves out cheeks and shoes, which Low doesn't draw
- **Upgrade square:** each stage has a 📈 Upgrade square (by the counter in stage 1, by the kitchen line in stage 2, and in stage 3 a 🔨 Craft square at the crafting bench in the cellar's workshop, which sells every room's upgrades too). Stand on it to open the stage's repeatable upgrades and tap one to buy its next level: a better product (prices), marketing (customers) or the crew (speed). The list in the top left shows every modifier in play as a percentage, with your rating counted into Customers (it brings them in ×0.6 at ★1 to ×1.4 at ★5); tap the stage chip to fold it away or back (on phones it starts folded, so the HUD leaves the game in view). Every level shows in the world too (see [Economy](#economy))
- **Fertilizer shed (stage 2):** once the takeout kiosk opens, a little thatched storehouse like the farmhouse goes up on a plank deck off the south end of the farm path, by the water wheel, with a green noren over its door and a lantern that glows at dusk. Stand on its 🌿 square to buy rice fertilizer: compost, then fish meal, then spring minerals, each making the terraces ripen half as fast again ($20,000, $40,000 and $80,000; all three make rice grow 3.4 times as fast). A labelled sack of each one bought lies on a pallet beside the shed, and the HUD's list shows it as Rice growth
- **Stage up:** with all nine market upgrades built, a gold 🏯 tile appears in the middle of the dock: Open Floe Sushi, $12,000. Paying it off plays the stage-up: the counters and the south fence come down, the restaurant, its garden and the terraces go up, and day turns to dusk. Cash, rating and upgrades carry over; the counters' leftover fish and cash move to the restaurant, and Korki's statue moves to the garden (the casino boat stays where it is)
diff --git a/docs/graphics.md b/docs/graphics.md
index 3883ed5..3015784 100644
--- a/docs/graphics.md
+++ b/docs/graphics.md
@@ -10,7 +10,7 @@ The player picks **Auto**, **Low** or **High** in the ✨ row of the settings.
- **High** draws the world the way the people are drawn: low poly but rounded, with small details, a rolling sea, cloud shadows, seasonal snow, petals and leaves, and the effects.
- **Low** draws everything exactly as it was before the rework, with a lighter renderer (a half-size shadow map with hard edges, at most 1.5 pixels a point). It's for phones that can't keep up.
-- **Auto**, the default, starts on High and watches the frames in two-second looks. A look is bad if it ran under 50 fps, or it stuttered: its p95 frame slower than 25 ms or its p99 slower than 50 ms. When four of the last five looks are bad it drops to Low, and it starts on Low on that device from then on (`localStorage` key `floe-market-graphics-auto`). A hitch counts as a slow frame; only a gap of over a second (the tab hidden, the phone locked) makes it settle and start watching again. It never switches back up by itself; choosing Auto in the settings starts it on High again.
+- **Auto**, the default, starts on High and watches the frames in two-second looks. A look is bad if it ran under 50 fps, or it stuttered: its p95 frame slower than 25 ms or its p99 slower than 50 ms. When four of the last five looks are bad it drops to Low, and it starts on Low on that device from then on (`localStorage` key `floe-market-graphics-auto`). A hitch counts as a slow frame; only a gap of over a second (the tab hidden, the phone locked) makes it settle and start watching again. It never switches back up by itself; choosing Auto in the settings starts it on High again. It also drops to Low for good when the browser takes the 3D view away while the game's on screen (a WebGL context loss: a phone short of graphics memory, or its graphics chip restarting), since Low needs about half the memory (`lowAfterLoss`, called by render.ts). If the view isn't back within 3 seconds, or the browser won't make one at all, a dialog (`#glLost`, errors.ts) says so and offers a reload.
The choice is kept on the device (`localStorage` key `floe-market-graphics`), never in the save or the cloud.
@@ -180,7 +180,10 @@ Whatever has stood still for a second is drawn merged with its neighbours: one m
- instanced and skinned meshes;
- anything under an object with `userData.noBatch = true`, as people have.
- Use `noBatch` for anything that moves all the time.
+ Use `noBatch` for anything that moves all the time. People who stay put for good (the casino yacht's staff and
+ guests) call `standStill()` (characters.ts): no `noBatch` and no breathing, so they're merged while they hold still,
+ and an arm that moves drops out of the merge while it does. On the yacht that took about 85 draw calls off the frame
+ at the quay.
- **Lots of moving copies:** use one `InstancedMesh` instead, as the effects and the foam rings (sea.ts) do.
### Hidden things aren't placed
diff --git a/index.html b/index.html
index c8a0c65..aa373c7 100644
--- a/index.html
+++ b/index.html
@@ -836,12 +836,14 @@
@keyframes press{ 0%,20%{ background:var(--coral); color:#fff; transform:translateY(2px); box-shadow:none; } 25%,100%{ background:var(--chip); color:inherit; transform:none; } }
@media (prefers-reduced-motion: reduce){ #hint{ transition:opacity .3s; } #hint .stick, #hint kbd{ animation:none; } }
-#stale{ position:fixed; inset:0; z-index:8; display:grid; place-items:center; background:rgba(23,48,66,.55); }
-#stale[hidden]{ display:none; }
-#stale .card{ text-align:center; background:var(--pill); color:var(--pill-ink); padding:22px 26px; border-radius:24px; max-width:min(86vw,360px); box-shadow:0 4px 0 var(--shade); }
-#stale p{ margin:0 0 14px; font-size:19px; font-weight:600; }
-#stale button{ font:800 18px "Floe Emoji","Baloo 2","Trebuchet MS",system-ui,sans-serif; background:var(--hot); color:#fff; border:0; border-radius:999px; padding:8px 22px; cursor:pointer; box-shadow:0 3px 0 rgba(150,45,25,.45); }
-#stale button:focus-visible{ outline:3px solid var(--pill-ink); outline-offset:2px; }
+#stale, #glLost{ position:fixed; inset:0; z-index:8; display:grid; place-items:center; background:rgba(23,48,66,.55); }
+/* (the 3D view gone, errors.ts: over the loading screen too, as it can go before the game's loaded) */
+#glLost{ z-index:10; }
+#stale[hidden], #glLost[hidden]{ display:none; }
+:is(#stale, #glLost) .card{ text-align:center; background:var(--pill); color:var(--pill-ink); padding:22px 26px; border-radius:24px; max-width:min(86vw,360px); box-shadow:0 4px 0 var(--shade); }
+:is(#stale, #glLost) p{ margin:0 0 14px; font-size:19px; font-weight:600; }
+:is(#stale, #glLost) button{ font:800 18px "Floe Emoji","Baloo 2","Trebuchet MS",system-ui,sans-serif; background:var(--hot); color:#fff; border:0; border-radius:999px; padding:8px 22px; cursor:pointer; box-shadow:0 3px 0 rgba(150,45,25,.45); }
+:is(#stale, #glLost) button:focus-visible{ outline:3px solid var(--pill-ink); outline-offset:2px; }
#stick{
position:fixed; width:110px; height:110px; margin:-55px 0 0 -55px; border-radius:50%;
@@ -1639,6 +1641,7 @@
Scoreboard
Floe Market is open in another tab, so this one stopped saving.
+
Click a lit one to buy itE buys the one under the mouseEsc or walk off to close
Your look
diff --git a/src/blackjackTable.ts b/src/blackjackTable.ts
index f9906d2..573159a 100644
--- a/src/blackjackTable.ts
+++ b/src/blackjackTable.ts
@@ -201,7 +201,7 @@ function buildTable() {
[[-0.24, 0xC8323F, 6], [-0.12, 0x22874A, 4], [0, 0x1F2A33, 7], [0.12, 0x6A3FA0, 3], [0.24, 0xC8323F, 5]].forEach(([x, c, n]) => {
const s = mesh(chip, c, x, TOP + n * 0.0065, RACK.z - 0.04, true); s.scale.y = n * 0.013; g.add(s);
});
- const dealer = allYear(new Person(SUITS[0], 'fancy'));
+ const dealer = allYear(new Person(SUITS[0], 'fancy')).standStill();
dealer.position.set(0, 0, -0.95);
g.add(dealer);
// close up, smaller than most: the dealer's top hat reaches the top of the view, and their bubble comes down over it
diff --git a/src/casinoSalon.ts b/src/casinoSalon.ts
index af9b38d..8c09758 100644
--- a/src/casinoSalon.ts
+++ b/src/casinoSalon.ts
@@ -20,6 +20,7 @@ import { clink } from './sfx';
import { allYear, animPerson, Person, SUITS } from './characters';
import { byTheStall, STALL } from './casinoRestroom';
import { atTheWall } from './wallOfShame';
+import { K } from './kit';
import { DECKS, inSalon, pushOutOfBox, SALON, SHIP, stage, UPPER } from './layout';
import { player } from './player';
import { bake, bakePainted, canvasTex, FONT, G, mesh, scene, type Part } from './render';
@@ -171,7 +172,8 @@ const SIGN_TEX = canvasTex(512, 160, (c, w, h) => {
c.lineJoin = 'round'; c.lineWidth = 14; c.strokeStyle = '#2A0F16'; c.strokeText('CASINO', w / 2, h / 2 + 6);
c.fillStyle = '#FFD24A'; c.fillText('CASINO', w / 2, h / 2 + 6);
});
-/** The marquee's bulbs, in two sets that take turns to light: the chase round the CASINO sign. */
+/** The marquee's bulbs, in two sets that take turns to light: the chase round the CASINO sign. Flat colours, so a
+ * bulb a few pixels across is a dot whatever its shape: the kit's small ball (56 triangles, not 252). */
const bulbsOn = new MeshBasicMaterial({ color: 0xFFE08A }), bulbsOff = new MeshBasicMaterial({ color: 0xB07A2A });
/** The top of the jackstaff at the bow (casino.ts), which the strings of lights run down to from the mast. */
export const JACK = { x: SHIP.bow - 0.55, y: FY + 3.0 };
@@ -278,7 +280,7 @@ function buildRoof() {
for (const s of [-1, 1]) {
for (let k = 1; k < 18; k++) {
const t = k / 18, tx = MAST.x + 0.07, ty = mastTop - 0.4;
- strings.push({ geo: G.sphere, at: [JACK.x + (tx - JACK.x) * t, JACK.y + (ty - JACK.y) * t - Math.sin(Math.PI * t) * 0.1, s * MAST.spread * t], scale: [0.06, 0.06, 0.06] });
+ strings.push({ geo: K.ball, at: [JACK.x + (tx - JACK.x) * t, JACK.y + (ty - JACK.y) * t - Math.sin(Math.PI * t) * 0.1, s * MAST.spread * t], scale: [0.06, 0.06, 0.06] });
}
}
const lights = mesh(bake(strings), bulbsOn);
@@ -298,7 +300,7 @@ function buildSigns(g: Group) {
face.position.set(SIGN.x, SIGN.y, z + side * 0.015); if (side < 0) face.rotation.y = Math.PI;
g.add(face);
let i = 0;
- const bulb = (x: number, y: number) => sets[i++ % 2].push({ geo: G.sphere, at: [x, y, z + side * 0.02], scale: [0.04, 0.04, 0.04] });
+ const bulb = (x: number, y: number) => sets[i++ % 2].push({ geo: K.ball, at: [x, y, z + side * 0.02], scale: [0.04, 0.04, 0.04] });
for (let x = -SIGN.w / 2; x <= SIGN.w / 2 + 0.01; x += SIGN.w / 14) { bulb(SIGN.x + x, SIGN.y + SIGN.h / 2 + 0.06); bulb(SIGN.x + x, SIGN.y - SIGN.h / 2 - 0.06); }
for (const e of [-1, 1]) for (let y = -SIGN.h / 2 + 0.14; y < SIGN.h / 2 - 0.08; y += 0.2) bulb(SIGN.x + e * (SIGN.w / 2 + 0.06), SIGN.y + y);
}
@@ -356,7 +358,8 @@ function buildBar(g: Group) {
function guest(i: number, hatless = false) {
const who = crowd();
if (hatless) who.hat = 'bare';
- return allYear(new Person(who.woman ? DRESSES[i % DRESSES.length] : SUITS[i % SUITS.length], 'fancy', who));
+ // (they all stay where they're put: the gamblers at the machines, the drinkers on their stools, the fan by the tables)
+ return allYear(new Person(who.woman ? DRESSES[i % DRESSES.length] : SUITS[i % SUITS.length], 'fancy', who)).standStill();
}
/** One of the salon's slot machines (`i` along the row), its reels spinning when a guest plays it: for `len` seconds,
@@ -394,7 +397,7 @@ function buildCrowd(g: Group) {
});
// the bar: two bartenders behind it, one polishing a glass and one shaking a cocktail, and guests on three stools
const bartenders = [-1.1, 1.3].map(z => {
- const b = allYear(new Person(0x1F1F1F, 'waiter'));
+ const b = allYear(new Person(0x1F1F1F, 'waiter')).standStill();
b.position.set(BAR.x - 0.5, UPPER, z); b.rotation.y = Math.PI / 2;
g.add(b);
return b;
diff --git a/src/characters.ts b/src/characters.ts
index deb7f73..c18b83b 100644
--- a/src/characters.ts
+++ b/src/characters.ts
@@ -200,6 +200,8 @@ export class Person extends Group {
phase = 0;
/** Seconds of breathing, from somewhere different for each of them, so a queue doesn't breathe as one. */
breath: number;
+ /** Stays where they're put for good, and doesn't breathe (standStill). */
+ still = false;
/** A chef's toque: its band, which grows taller, and the puff on top. */
private toqueBand?: Mesh;
private toquePuff?: Mesh;
@@ -253,6 +255,18 @@ export class Person extends Group {
} finally { Math.random = luck; }
}
+ /**
+ * Stays where they're put for good (the casino yacht's staff and guests): without the breathing, which no one sees
+ * from the game's camera, they hold still and are drawn merged with the scenery round them (batch.ts), a dozen people
+ * in a few draw calls instead of six each and as many again for their shadows. An arm that moves (a lever pulled, a
+ * glass raised) drops out of the merge while it does, and back in after.
+ */
+ standStill() {
+ this.still = true;
+ delete this.userData.noBatch;
+ return this;
+ }
+
/** How far out from the middle the arms hang: a woman's a little closer in. */
get armSpread() { return this.style.woman ? 0.28 : 0.3; }
@@ -861,7 +875,7 @@ export function animPerson(p: Person, moving: boolean, dt: number, carrying: boo
b.position.y = -0.3 * (1 - Math.cos(swing));
b.rotation.x += ((moving ? LEAN : 0) - b.rotation.x) * Math.min(1, dt * 8);
b.rotation.z = moving ? s * ROCK : 0;
- b.scale.y = moving ? 1 : 1 + Math.sin(p.breath * 2.4) * BREATH;
+ b.scale.y = moving || p.still ? 1 : 1 + Math.sin(p.breath * 2.4) * BREATH;
}
const glass = new MeshLambertMaterial({ color: 0xBDEBFA, transparent: true, opacity: .65 });
diff --git a/src/errors.ts b/src/errors.ts
index 010d267..ae4341a 100644
--- a/src/errors.ts
+++ b/src/errors.ts
@@ -12,6 +12,8 @@ function note(m: string) {
}
export function showErr(m: string) {
+ // (with the 3D view gone, its dialog says what's wrong: what breaks without it is no news)
+ if (view3d.down) return;
errBox.style.display = 'block';
errMsg.textContent = m;
}
@@ -44,3 +46,30 @@ export function reloadOnce() {
// A deploy removes the last one's files, so a tab open from before it can't fetch Firebase (loaded on demand) any more.
// Reloading gets the new page; the game saves on the way out. Otherwise cloud saves just show as offline.
window.addEventListener('vite:preloadError', e => { if (reloadOnce()) e.preventDefault(); });
+
+// ---------- the 3D view ----------
+// A phone can refuse the game its 3D view, or take it back mid-game: it's low on memory, or its graphics chip has
+// restarted, and after that Chrome may keep 3D off for the site until it's restarted itself. The world would just be
+// blank behind the HUD; a dialog says so instead, with a way to reload (the game saves on the way out).
+const glDialog = document.getElementById('glLost')!, glWhy = document.getElementById('glLostWhy')!;
+/** What the dialog says: the 3D view couldn't start, or it stopped and didn't come back. */
+export const GL_SAYS = { start: 'Restart your browser to bring back the 3D', lost: 'The 3D view stopped. Your game is saved.' };
+/** Whether the 3D view's gone, and the dialog's up. */
+export const view3d = { down: false };
+glDialog.querySelector('button')!.addEventListener('click', () => page.reload());
+
+/** The 3D view's gone: it couldn't start (`start`), or the browser took it back and hasn't given it back (`lost`). */
+export function glGone(why: keyof typeof GL_SAYS, detail = '') {
+ note(`3D view ${why === 'start' ? "didn't start" : 'lost'}${detail ? ': ' + detail : ''}`);
+ view3d.down = true;
+ errBox.style.display = 'none';
+ glWhy.textContent = GL_SAYS[why];
+ glDialog.hidden = false;
+ glDialog.querySelector('button')!.focus(); // it's a dialog: keyboards and screen readers go there
+}
+
+/** The browser gave the 3D view back after all: the game carries on. */
+export function glBack() {
+ view3d.down = false;
+ glDialog.hidden = true;
+}
diff --git a/src/graphics.ts b/src/graphics.ts
index 13fac75..7a719cd 100644
--- a/src/graphics.ts
+++ b/src/graphics.ts
@@ -43,6 +43,15 @@ export function choose(c: Choice) {
setQuality(c === 'auto' ? 'high' : c);
}
+/** The browser took the 3D view away (render.ts), most often as a phone ran low on graphics memory. On Auto, Low from
+ * now on, as if it had been slow: half the memory, so the view it gives back (or the next visit's) is likelier to
+ * last. A player's own pick stays. */
+export function lowAfterLoss() {
+ if (gfx.picked) return;
+ try { localStorage.setItem(AUTO_KEY, 'low'); } catch { /* Low for this visit */ }
+ if (gfx.quality !== 'low') setQuality('low');
+}
+
/** Seconds to let the game settle (loading, or coming back to the tab) before timing it; then how long each look lasts. */
const SETTLE = 3, SPAN = 2;
/** A look is a bad one if it ran under 50 frames a second, or it stuttered: one frame in 20 (its p95) slower than
diff --git a/src/render.ts b/src/render.ts
index a2275d1..7d4a0ba 100644
--- a/src/render.ts
+++ b/src/render.ts
@@ -5,7 +5,8 @@ import {
TorusGeometry, Vector3, WebGLRenderer,
} from 'three';
import { mergeGeometries } from 'three/addons/utils/BufferGeometryUtils.js';
-import { isHigh, onQuality } from './graphics';
+import { glBack, glGone } from './errors';
+import { isHigh, lowAfterLoss, onQuality } from './graphics';
import { V } from './util';
// ---------- renderer / scene ----------
@@ -15,7 +16,27 @@ ColorManagement.enabled = false;
export const canvas = document.getElementById('game') as HTMLCanvasElement;
// (a stencil buffer for the hole under Mr. Fugu's hatch, fugu.ts, which shows the shaft below the deck through it)
-export const renderer = new WebGLRenderer({ canvas, antialias: true, stencil: true });
+export const renderer = (() => {
+ try {
+ return new WebGLRenderer({ canvas, antialias: true, stencil: true });
+ } catch (e) {
+ // the browser won't give the game a 3D view (errors.ts): the game can't go on, but the dialog says why
+ glGone('start', (e as Error).message);
+ throw e;
+ }
+})();
+// The browser can take the 3D view back mid-game (errors.ts). three.js asks for it back and, once it's back, sends
+// everything up again; if it isn't back LOST_WAIT seconds into the game being on screen, the dialog offers a reload.
+// Taken while the game's on screen, it's the phone running short (not just the tab put away): Low from now on.
+const LOST_WAIT = 3;
+let lostT: ReturnType | undefined;
+const waitForIt = () => { lostT = setTimeout(() => (document.hidden ? waitForIt() : glGone('lost')), LOST_WAIT * 1000); };
+canvas.addEventListener('webglcontextlost', () => {
+ if (!document.hidden) lowAfterLoss();
+ clearTimeout(lostT);
+ waitForIt();
+});
+canvas.addEventListener('webglcontextrestored', () => { clearTimeout(lostT); glBack(); });
renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 2));
renderer.outputColorSpace = LinearSRGBColorSpace;
// the fish on the chopping block is cut with a clipping plane (fishing.ts)
diff --git a/src/report.ts b/src/report.ts
index 025e8b9..3a21d56 100644
--- a/src/report.ts
+++ b/src/report.ts
@@ -47,7 +47,7 @@ export const UI_WAIT = 4000;
const NOT_SHOWN = new Set(['report', 'draw', 'sendFx', 'game']);
/** The panels that can be open over the game, for the report's `live` state. */
const PANELS = ['shop', 'look', 'rouletteAsk', 'roulette', 'blackjackAsk', 'blackjack', 'slotsAsk', 'slots', 'bubbly', 'tears',
- 'shameCard', 'shame', 'korki', 'scores', 'cloudPick', 'stale', 'hint', 'gong', 'coin', 'fightCard', 'offer', 'eddie',
+ 'shameCard', 'shame', 'korki', 'scores', 'cloudPick', 'stale', 'glLost', 'hint', 'gong', 'coin', 'fightCard', 'offer', 'eddie',
'monteAsk', 'monte', 'loans', 'denBar', 'denGus'];
const $ = (id: string) => document.getElementById(id)!;
diff --git a/src/rouletteTable.ts b/src/rouletteTable.ts
index 643e7a1..07f045b 100644
--- a/src/rouletteTable.ts
+++ b/src/rouletteTable.ts
@@ -156,7 +156,7 @@ function buildTable() {
const screen = new Mesh(new PlaneGeometry(BOARD_AT.w - 0.02, 2 * (BOARD_AT.w - 0.02)), new MeshBasicMaterial({ map: boardTex.tex }));
screen.position.set(0, BOARD_AT.y, 0.027); board.add(screen);
board.rotation.y = -0.25;
- const croupier = allYear(new Person(SUITS[2], 'fancy'));
+ const croupier = allYear(new Person(SUITS[2], 'fancy')).standStill();
croupier.position.set(0, 0, -1.15);
g.add(croupier);
const voice = speaker(g, 2.0);
diff --git a/test/casino.test.ts b/test/casino.test.ts
index fdf4690..2a78b9c 100644
--- a/test/casino.test.ts
+++ b/test/casino.test.ts
@@ -182,6 +182,22 @@ describe('the casino boat', () => {
expect(aboard.every(p => p.season === 'spring')).toBe(true);
});
+ it('draws everyone aboard who stays put merged with the yacht, not one by one: all but the waiter', async () => {
+ const g = await loadGame({ tiles: bought('roulette', 'blackjack', 'slots') });
+ const { Person } = await import('../src/characters');
+ const aboard: InstanceType[] = [];
+ g.render.scene.traverse(o => { if (o instanceof Person && g.layout.aboard(o.getWorldPosition(o.position.clone()))) aboard.push(o); });
+ const walking = aboard.filter(p => !p.still);
+ expect(walking).toHaveLength(1); // the waiter, with his tray
+ expect(walking[0].userData.noBatch).toBe(true);
+ const still = aboard.filter(p => p.still);
+ expect(still.length).toBeGreaterThanOrEqual(10);
+ expect(still.every(p => !p.userData.noBatch)).toBe(true);
+ // and they hold still while they stand: no breathing
+ g.run(2);
+ expect(still.every(p => p.body.scale.y === 1)).toBe(true);
+ });
+
it('walls its upper deck in as a salon, whose roof lifts and near walls fade while the player is inside', async () => {
const g = await at('roulette', 100);
const salon = await import('../src/casinoSalon');
diff --git a/test/glLost.test.ts b/test/glLost.test.ts
new file mode 100644
index 0000000..16f85dd
--- /dev/null
+++ b/test/glLost.test.ts
@@ -0,0 +1,96 @@
+// The 3D view gone (errors.ts, render.ts): the browser wouldn't make one, or took it back mid-game (a phone short of
+// graphics memory, its graphics chip restarting). A dialog says so with a way to reload, not a blank world or the error
+// box; one taken while playing puts Auto on Low for good.
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+import { resetDom } from './dom';
+
+const dialog = () => document.getElementById('glLost')!;
+const says = () => document.getElementById('glLostWhy')!.textContent;
+const words = (s: string) => s.split(/\s+/).filter(Boolean).length;
+
+beforeEach(() => { vi.resetModules(); resetDom(); localStorage.clear(); });
+afterEach(() => { vi.useRealTimers(); vi.restoreAllMocks(); });
+
+describe('the dialog', () => {
+ it("says the 3D view couldn't start, in place of the error box, and Reload reloads", async () => {
+ const errors = await import('../src/errors');
+ const reload = vi.spyOn(errors.page, 'reload').mockImplementation(() => {});
+ errors.glGone('start', 'THREE.WebGLRenderer: Error creating WebGL context.');
+ errors.showErr('Something broke: THREE.WebGLRenderer: Error creating WebGL context.'); // boot.ts's catch
+ expect(dialog().hidden).toBe(false);
+ expect(says()).toBe(errors.GL_SAYS.start);
+ expect(document.getElementById('err')!.style.display).not.toBe('block');
+ expect(document.activeElement).toBe(dialog().querySelector('button'));
+ expect(errors.recentErrors[errors.recentErrors.length - 1]).toContain('Error creating WebGL context');
+ dialog().querySelector('button')!.click();
+ expect(reload).toHaveBeenCalledTimes(1);
+ });
+
+ it('goes again when the 3D view comes back', async () => {
+ const errors = await import('../src/errors');
+ errors.glGone('lost');
+ expect(says()).toBe(errors.GL_SAYS.lost);
+ errors.glBack();
+ expect(dialog().hidden).toBe(true);
+ errors.showErr('Something broke: x');
+ expect(document.getElementById('err')!.style.display).toBe('block');
+ });
+
+ it('keeps to one caption of 8 words or fewer', async () => {
+ const { GL_SAYS } = await import('../src/errors');
+ for (const s of Object.values(GL_SAYS)) expect(words(s)).toBeLessThanOrEqual(8);
+ });
+});
+
+describe('the renderer', () => {
+ it("shows the dialog when the browser won't make a 3D view", async () => {
+ const three = await import('three');
+ vi.spyOn(three, 'WebGLRenderer').mockImplementation(() => { throw new Error('Error creating WebGL context.'); });
+ await expect(import('../src/render')).rejects.toThrow('Error creating WebGL context');
+ expect(dialog().hidden).toBe(false);
+ expect(says()).toBe((await import('../src/errors')).GL_SAYS.start);
+ });
+
+ it('waits a moment for a lost view to come back before the dialog, and Auto goes to Low', async () => {
+ vi.useFakeTimers();
+ const { canvas } = await import('../src/render');
+ const { gfx } = await import('../src/graphics');
+ expect(gfx.quality).toBe('high');
+ canvas.dispatchEvent(new Event('webglcontextlost'));
+ expect(gfx.quality).toBe('low');
+ expect(localStorage.getItem('floe-market-graphics-auto')).toBe('low');
+ vi.advanceTimersByTime(2000);
+ expect(dialog().hidden).toBe(true);
+ vi.advanceTimersByTime(1500);
+ expect(dialog().hidden).toBe(false);
+ canvas.dispatchEvent(new Event('webglcontextrestored'));
+ expect(dialog().hidden).toBe(true);
+ });
+
+ it('says nothing when the view comes straight back, and keeps a High the player picked', async () => {
+ vi.useFakeTimers();
+ localStorage.setItem('floe-market-graphics', 'high');
+ const { canvas } = await import('../src/render');
+ const { gfx } = await import('../src/graphics');
+ canvas.dispatchEvent(new Event('webglcontextlost'));
+ vi.advanceTimersByTime(1000);
+ canvas.dispatchEvent(new Event('webglcontextrestored'));
+ vi.advanceTimersByTime(5000);
+ expect(dialog().hidden).toBe(true);
+ expect(gfx.quality).toBe('high');
+ });
+
+ it("doesn't count a view taken while the game's put away", async () => {
+ vi.useFakeTimers();
+ const hidden = vi.spyOn(document, 'hidden', 'get').mockReturnValue(true);
+ const { canvas } = await import('../src/render');
+ const { gfx } = await import('../src/graphics');
+ canvas.dispatchEvent(new Event('webglcontextlost'));
+ vi.advanceTimersByTime(10_000);
+ expect(gfx.quality).toBe('high');
+ expect(dialog().hidden).toBe(true);
+ hidden.mockReturnValue(false); // back on screen, and still no 3D view
+ vi.advanceTimersByTime(3500);
+ expect(dialog().hidden).toBe(false);
+ });
+});