From bdf1fc85f6ac9bcce5b79e30e4d0ef7b4ad72281 Mon Sep 17 00:00:00 2001 From: bluedotiya <75704012+ChefControl@users.noreply.github.com> Date: Mon, 19 Oct 2026 18:32:56 +0300 Subject: [PATCH] Phone playtest: Auto drops to Low for stutters too (p95/p99, four of five looks) and remembers it; the tutorial's bubble three quarters the size on a phone; the slam lands with a tap anywhere Co-Authored-By: Claude Opus 5.5 --- docs/graphics.md | 2 +- index.html | 8 ++++++++ src/gong.ts | 21 ++++++++++++++++++--- src/graphics.ts | 42 ++++++++++++++++++++++++++++-------------- test/graphics.test.ts | 37 +++++++++++++++++++++++++++++++++---- test/stage3.test.ts | 16 ++++++++++++++++ 6 files changed, 104 insertions(+), 22 deletions(-) diff --git a/docs/graphics.md b/docs/graphics.md index 816b7a8..155942b 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 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. diff --git a/index.html b/index.html index 7fee25f..0cbb2ed 100644 --- a/index.html +++ b/index.html @@ -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); } } diff --git a/src/gong.ts b/src/gong.ts index 3642125..9e9a367 100644 --- a/src/gong.ts +++ b/src/gong.ts @@ -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'; @@ -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! Space 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!']; @@ -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; @@ -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! Space on gold'; + slamGo.innerHTML = slamAsk(); } } /** Whether a rush is a grand win with the slam still to try (for tests). */ diff --git a/src/graphics.ts b/src/graphics.ts index 21783c7..13fac75 100644 --- a/src/graphics.ts +++ b/src/graphics.ts @@ -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)[] = []; @@ -34,6 +37,7 @@ 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); @@ -41,24 +45,34 @@ export function choose(c: Choice) { /** 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'); } diff --git a/test/graphics.test.ts b/test/graphics.test.ts index 85c028f..4b8d183 100644 --- a/test/graphics.test.ts +++ b/test/graphics.test.ts @@ -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'; @@ -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 () => { @@ -66,11 +73,13 @@ 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); @@ -78,10 +87,16 @@ describe('graphics', () => { 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); @@ -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'); }); diff --git a/test/stage3.test.ts b/test/stage3.test.ts index 1c0cefe..6aaa8a7 100644 --- a/test/stage3.test.ts +++ b/test/stage3.test.ts @@ -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;