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 docs/graphics.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 each visit on High and drops to Low for the rest of the visit if the frame rate stays under 45 for six seconds. It never switches back up by itself.
- **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.

The choice is kept on the device (`localStorage` key `floe-market-graphics`), never in the save or the cloud.

Expand Down
8 changes: 8 additions & 0 deletions index.html
Original file line number Diff line number Diff line change
Expand Up @@ -737,6 +737,14 @@
#coach .dir{ display:none; position:absolute; left:50%; top:50%; width:0; height:0; }
#coach.edge .dir{ display:block; }
#coach .dir::after{ content:""; position:absolute; left:1px; top:-7px; border:7px solid transparent; border-left:20px solid #FFC34A; border-right:0; }
/* on a phone, three quarters the size */
@media (max-width: 720px), (max-height: 500px){
#coach{ max-width:min(180px, calc(100vw - 32px)); padding:5px 11px 6px; border-radius:12px; box-shadow:0 2px 0 var(--shade), 0 0 0 2px #FFC34A; }
#coach b{ font-size:13.5px; }
#coach small{ font-size:10.5px; }
#coach::after{ margin:2px 0 0 -7px; border-width:8px 7px 0; }
#coach .dir::after{ top:-5px; border-width:5px 0 5px 15px; }
}
/* the rating (and down in the cellar the 💪), while the tutorial talks about it */
#stars.teach, #power.teach{ animation:teach 1.2s ease-in-out infinite; }
@keyframes teach{ 50%{ box-shadow:0 3px 0 var(--shade), 0 0 0 5px rgba(255,195,74,.75); transform:scale(1.08); } }
Expand Down
21 changes: 18 additions & 3 deletions src/gong.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import {
type Boss, bossOf, BOSSES, BUYOUT, canRing, canShow, type Carry, F, type Forecast, fixOn, forecast, ICON, type Ing, keptHome, NAME, nextBoss, practiceDue, ringGong,
grand, type Rush, sides, slam, SLAM, slamDue, strength, toBeat, worstShort, you,
} from './fight';
import { isTouch } from './hint';
import { kindOf } from './items';
import { buyHeld, keyHeld } from './input';
import { level, stage } from './layout';
Expand Down Expand Up @@ -320,9 +321,14 @@ function renderBout() {

// ---------- the slam ----------
// A grand win (fight.ts's SLAM): in the fight, a white ring closes on the champion's face on the strip to meet a gold
// one at the belly bump, and Space (or a tap of the button) as they meet lands the slam: perfect or good, for more of
// his prize. One try. What it came to stays on the button for a moment.
// one at the belly bump, and Space (or a click of the button) as they meet lands the slam: perfect or good, for more of
// his prize. On a phone a tap anywhere lands it, counted as the finger touches. One try. What it came to stays on the
// button for a moment.
const slamRing = $('slamRing'), slamClose = slamRing.querySelector('b')!, slamGo = $('slamGo') as HTMLButtonElement;
/** Whether this is a touch screen (or a finger's been seen on it): the button says tap, not Space. */
let touch = isTouch();
const slamAsk = () => touch ? 'SLAM! Tap on gold' : 'SLAM! <kbd>Space</kbd> on gold';
slamGo.innerHTML = slamAsk();
/** Seconds the button keeps saying how the slam went. */
let slamSaid = 0, spaceHeld = false;
const SLAM_SAYS = ['Missed', 'Good slam!', 'Perfect slam!'];
Expand All @@ -335,6 +341,15 @@ function goSlam() {
if (q > 0) chime();
}
slamGo.addEventListener('click', goSlam);
// (a finger, or a pen, anywhere on the screen; the button's own click after it finds the slam already tried)
window.addEventListener('pointerdown', e => {
if (e.pointerType === 'mouse') return;
if (!touch) {
touch = true;
if (!slamGo.classList.contains('said')) slamGo.innerHTML = slamAsk();
}
if (!rushEl.hidden && slamDue()) goSlam();
}, { capture: true });
function updSlam(dt: number) {
const space = keyHeld(' '), press = space && !spaceHeld;
spaceHeld = space;
Expand All @@ -347,7 +362,7 @@ function updSlam(dt: number) {
if (show === slamGo.hidden) slamGo.hidden = !show;
if (!show && slamGo.classList.contains('said')) {
slamGo.classList.remove('said');
slamGo.innerHTML = 'SLAM! <kbd>Space</kbd> on gold';
slamGo.innerHTML = slamAsk();
}
}
/** Whether a rush is a grand win with the slam still to try (for tests). */
Expand Down
42 changes: 28 additions & 14 deletions src/graphics.ts
Original file line number Diff line number Diff line change
@@ -1,19 +1,22 @@
// Graphics, Auto, Low or High: how much detail the game is drawn in. High draws the world the way the people are drawn
// (characters.ts, kit.ts): low poly but rounded, with the small details, a rolling sea, cloud shadows, seasonal
// layers and effects. Low is everything as it was before those, with a lighter renderer, for phones that can't keep
// up. Auto, the default, lets the game pick: it starts each visit on High and watches the frame rate, and if that stays
// under 45 a second for six seconds, it drops to Low for the rest of the visit. The player's choice is kept for the
// device. Before adding to the graphics, read docs/graphics.md.
// up. Auto, the default, lets the game pick: it starts on High and watches each two seconds' frames, and if four of the
// last five were slow or stuttered, it drops to Low, and starts on Low on that device from then on. The player's
// choice is kept for the device. Before adding to the graphics, read docs/graphics.md.
export type Quality = 'low' | 'high';
/** What the player chose in the settings. */
export type Choice = Quality | 'auto';

const KEY = 'floe-market-graphics';
/** Set to 'low' once Auto has dropped to Low on this device, so it starts there next time. */
const AUTO_KEY = 'floe-market-graphics-auto';
/** The detail people are drawn in now, and whether the player chose it (rather than Auto). */
export const gfx = { quality: 'high' as Quality, picked: false };
try {
const q = localStorage.getItem(KEY);
if (q === 'low' || q === 'high') Object.assign(gfx, { quality: q, picked: true });
else if (localStorage.getItem(AUTO_KEY) === 'low') gfx.quality = 'low';
} catch { /* storage blocked: Auto */ }

const listeners: (() => void)[] = [];
Expand All @@ -34,31 +37,42 @@ export function choose(c: Choice) {
try {
if (c === 'auto') localStorage.removeItem(KEY);
else localStorage.setItem(KEY, c);
localStorage.removeItem(AUTO_KEY);
} catch { /* kept for this visit only */ }
if (c === 'auto') watchAgain();
setQuality(c === 'auto' ? 'high' : c);
}

/** 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;
/** Frames a second it needs on High, and how many spans in a row under that before dropping to Low. */
const MIN_FPS = 45, STRIKES = 3;
let settle = SETTLE, span = 0, frames = 0, strikes = 0;
function watchAgain() { settle = SETTLE; span = frames = strikes = 0; }
/** 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
* 25 ms, or one in 100 (its p99) slower than 50 ms. Four bad looks of the last five drop it to Low. */
const MIN_FPS = 50, P95 = 0.025, P99 = 0.05, BAD = 4, OF = 5;
let settle = SETTLE, span = 0;
/** This look's frame times, and the last few looks (true for a bad one). */
const times: number[] = [], looks: boolean[] = [];
function watchAgain() { settle = SETTLE; span = 0; times.length = looks.length = 0; }
/** The frame time `p` of the look's (sorted) frames are quicker than. */
const pct = (sorted: number[], p: number) => sorted[Math.min(sorted.length - 1, Math.floor(sorted.length * p))];

/**
* Times each frame drawn (`sec` since the last one, main.ts) while on Auto. A long gap is a pause (the tab hidden, the
* phone locked), not a slow frame, and the game settles again after it.
* Times each frame drawn (`sec` since the last one, main.ts) while on Auto. A hitch counts as a slow frame; only a
* gap of over a second is a pause (the tab hidden, the phone locked), and the game settles and starts watching again
* after it.
*/
export function frameDrawn(sec: number) {
// a smoothed frame rate, for bug reports (report.ts)
if (sec > 0 && sec < 1) frameRate.fps += (1 / sec - frameRate.fps) * 0.05;
if (gfx.picked || gfx.quality === 'low') return;
if (sec > 0.25 || document.hidden) { watchAgain(); return; }
if (sec > 1 || document.hidden) { watchAgain(); return; }
if (settle > 0) { settle -= sec; return; }
span += sec; frames++;
span += sec; times.push(sec);
if (span < SPAN) return;
strikes = frames / span < MIN_FPS ? strikes + 1 : 0;
span = frames = 0;
if (strikes >= STRIKES) setQuality('low');
times.sort((a, b) => a - b);
looks.push(times.length / span < MIN_FPS || pct(times, 0.95) > P95 || pct(times, 0.99) > P99);
if (looks.length > OF) looks.shift();
span = 0; times.length = 0;
if (looks.filter(Boolean).length < BAD) return;
try { localStorage.setItem(AUTO_KEY, 'low'); } catch { /* Low for this visit */ }
setQuality('low');
}
37 changes: 33 additions & 4 deletions test/graphics.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
// Graphics, Auto, Low or High: Low draws people as they were before the detail, Auto (the default) has the game pick by
// the frame rate, and the player's choice in the settings is kept for the device.
// the frame rate and its stutters, and the player's choice in the settings is kept for the device.
import { Mesh } from 'three';
import { describe, expect, it, vi } from 'vitest';
import type { Person } from '../src/characters';
Expand All @@ -17,6 +17,13 @@ const vertices = (p: Person) => {
const frames = (frameDrawn: (s: number) => void, sec: number, seconds: number) => {
for (let t = 0; t < seconds; t += sec) frameDrawn(sec);
};
/** Frames at 60 a second for `seconds`, with a 0.1 s hitch every `every` seconds. */
const stutter = (frameDrawn: (s: number) => void, every: number, seconds: number) => {
for (let t = 0, next = every; t < seconds;) {
const sec = t >= next ? (next += every, 0.1) : 1 / 60;
frameDrawn(sec); t += sec;
}
};

describe('graphics', () => {
it('draws people plainer on Low: blocks for arms and legs, no ears, nose or buttons', async () => {
Expand Down Expand Up @@ -66,22 +73,30 @@ describe('graphics', () => {
expect(vertices(chef)).toBe(before);
});

it('on Auto, drops to Low when the frame rate stays under 45 for six seconds, but not for a moment or a pause', async () => {
it('on Auto, drops to Low when four of the last five two-second looks are slow or stutter, not for a moment or a pause', async () => {
await loadGame();
const { frameDrawn, gfx, choose } = await graphics();
frames(frameDrawn, 1 / 60, 20);
expect(gfx.quality).toBe('high');
stutter(frameDrawn, 5, 20); // a hitch now and then is fine
expect(gfx.quality).toBe('high');
frames(frameDrawn, 1 / 30, 2.5); // a slow moment
frames(frameDrawn, 1 / 60, 3);
frames(frameDrawn, 1 / 30, 2.5);
expect(gfx.quality).toBe('high');
frameDrawn(2); // the tab was hidden: settles again after
frames(frameDrawn, 1 / 30, 2.9);
expect(gfx.quality).toBe('high');
frames(frameDrawn, 1 / 30, 8);
frames(frameDrawn, 1 / 30, 6);
expect(gfx.quality).toBe('high');
frames(frameDrawn, 1 / 30, 3);
expect(gfx.quality).toBe('low');
frames(frameDrawn, 1 / 60, 10); // it doesn't go back up by itself
expect(gfx.quality).toBe('low');
// and starts on Low on this device from now on
expect(localStorage.getItem('floe-market-graphics-auto')).toBe('low');
vi.resetModules();
expect((await graphics()).gfx).toEqual({ quality: 'low', picked: false });
// the player's choice stands, however slow it gets
choose('high');
frames(frameDrawn, 1 / 20, 20);
Expand All @@ -90,7 +105,21 @@ describe('graphics', () => {
choose('low');
choose('auto');
expect(gfx).toEqual({ quality: 'high', picked: false });
frames(frameDrawn, 1 / 30, 10); // settling, then six seconds
expect(localStorage.getItem('floe-market-graphics-auto')).toBeNull();
frames(frameDrawn, 1 / 30, 12); // settling, then four looks
expect(gfx.quality).toBe('low');
});

it('on Auto, drops to Low for stutters at a smooth frame rate, and a hitch along the way starts nothing over', async () => {
await loadGame();
const { frameDrawn, gfx } = await graphics();
frames(frameDrawn, 1 / 60, 3.05); // settling
// 60 a second, but a hitch every half second: the slowest frame in 100 is slow. Three looks of it
stutter(frameDrawn, 0.5, 6.5);
expect(gfx.quality).toBe('high');
frameDrawn(0.6); // loading something: a slow frame, not a pause
expect(gfx.quality).toBe('high');
stutter(frameDrawn, 0.5, 1.5); // the fourth
expect(gfx.quality).toBe('low');
});

Expand Down
16 changes: 16 additions & 0 deletions test/stage3.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1212,6 +1212,22 @@ describe('the slam', () => {
expect($('fightCard').textContent).toBe('勝YOU WIN🐡 350💥 Slam +🐡 70🔓 New: grow room, the attendant, Back-Alley RingTap to close');
}, 30_000);

it('on a phone, a tap anywhere as the rings meet lands it, and the button says to tap', async () => {
vi.spyOn(navigator, 'userAgent', 'get').mockReturnValue('Mozilla/5.0 (iPhone; CPU iPhone OS 18_0 like Mac OS X) Mobile/15E148');
const { g, fight, F } = await wonBy(120);
const tap = () => window.dispatchEvent(Object.assign(new Event('pointerdown'), { pointerType: 'touch' }));
tap(); // (too early: nothing)
expect(F().rush!.slam).toBe(-1);
g.runUntil(() => F().bout.t >= fight.SLAM.at - fight.SLAM.lead + 0.1, 12);
expect($('slamGo').textContent).toBe('SLAM! Tap on gold');
g.runUntil(() => F().bout.t >= fight.SLAM.at - 0.02, 3);
tap();
expect(F().rush!.slam).toBe(2);
$('slamGo').click(); // (the button's own click, after the finger: one try)
g.run(1 / 30);
expect([F().rush!.slam, $('slamGo').textContent]).toEqual([2, 'Perfect slam!']);
}, 30_000);

it('a good one is a tenth more; too soon is a miss; and a plain win offers none', async () => {
const { fight, F } = await wonBy(120);
F().bout.t = fight.SLAM.at - 0.25;
Expand Down
Loading