feedback2 WE: a 玩法说明 page none of whose copies loads shows the official tips text again, as in 0.1.1 (data/assets.json listing pages that are not on disk showed a blank notice); guide.js guideStage, neighbours preload their next loadable copy

This commit is contained in:
sganggs committed 2026-10-03 23:51:11 +08:00
1 parent 2f0a980b2e
commit 7f7df66977
3 files changed
+49 -14

No files matched your search

+1 -3
View File
@@ -61,8 +61,6 @@
.guide__page img { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: fill; user-select: none; opacity: 0; transition: opacity 160ms ease; }
.guide__page.is-loaded img { opacity: 1; }
.guide__loading { position: absolute; inset: 0; display: grid; place-items: center; }
.guide__missing { position: absolute; inset: 0; display: flex; align-items: center; justify-content: center; gap: .1rem; color: var(--text-lo); font-size: .2rem; }
.guide__missing .icon { width: .3rem; height: .3rem; }
.guide__nav {
position: absolute; top: 0; bottom: 0; z-index: 2; width: 1rem; display: grid; place-items: center;
color: rgba(242, 242, 242, .7); background: transparent; border: 0;
@@ -93,7 +91,7 @@
.guide__dot.is-on { width: .42rem; background: var(--mint-500); box-shadow: 0 0 .08rem rgba(23, 249, 183, .5); }
.guide__count { justify-self: end; font-size: .24rem; color: var(--text-md); letter-spacing: .06em; }
/* text fallback (no local tutorial art) */
/* text fallback: the official tips when no tutorial page can be shown (none listed, or none of a page's copies loads) */
.guide__stage--text { width: min(92vw, 10rem); max-height: calc(100vh - 2rem); overflow: auto; padding: .1rem .06rem; }
.guide__tips ol { margin: .14rem 0 0; padding-left: .34rem; display: flex; flex-direction: column; gap: .08rem; }
.guide__tips li { font-size: .18rem; line-height: 1.6; color: var(--text-md); }
+27 -10
View File
@@ -4,7 +4,10 @@
// whose picture fails to load tries its next URL. The pages are stored squashed to 1024² (both copies) and are
// displayed stretched back to 16:9. Three chapters (基础规则 / 调度手册 / 进阶图鉴), ‹ › buttons, ←/→ (A/D) keys, page
// dots, thumbnails; Esc or the backdrop closes. Adjacent pages are preloaded.
// With neither copy the viewer shows the official loading-screen tips (config.tips) instead.
// With no page listed the viewer shows the official loading-screen tips (config.tips) instead, and so does a page none
// of whose copies loads (`guideStage`: data/assets.json lists the downloaded pages but the files are not on disk — a
// `git pull` and restart without setup, an older asset folder, a failed download the shrink guard kept); the chapter
// tabs, the page dots and the keys still reach the other pages.
//
// Global & imperative so every screen can open it: `openGuide(page?)`; <GuideHost/> is mounted once by main.js
// (and by the dev mock harness); <GuideButton/> is the standard trigger (title, lobby, room, in-match menu).
@@ -49,6 +52,19 @@ export function guidePages() {
return out;
}
/**
* What the viewer's stage shows for a page: `{ kind: 'image', src }` — the first of its URLs that has not failed to
* load (the local file, then the downloaded copy) — or `{ kind: 'tips' }`, the official tips text, when none is left
* (no page, or every copy failed to load).
* @param {{ urls?: string[] }|null|undefined} page an entry of guidePages()
* @param {Set<string>} [failed] URLs whose image fired an error
* @returns {{ kind: 'image', src: string } | { kind: 'tips' }}
*/
export function guideStage(page, failed) {
const src = page ? nextArtUrl(page.urls, failed) : null;
return src ? { kind: 'image', src } : { kind: 'tips' };
}
/** Open/closed + current page. */
export const guideStore = createStore({ open: false, page: 0 });
@@ -75,7 +91,7 @@ function preload(url) {
img.src = url;
}
/** Fallback body when the tutorial art is not installed: the official tips as a numbered list. */
/** Fallback body when no tutorial page can be shown: the official tips as a numbered list. */
function TipsFallback() {
const tips = (Array.isArray(data.get('config')?.tips) ? data.get('config').tips : []).map((t) => t?.tip).filter(Boolean);
return html`<div class="guide__tips">
@@ -119,14 +135,15 @@ export function GuideHost() {
useEffect(() => {
if (!open || !n) return;
preload(pages[(i + 1) % n]?.url);
preload(pages[(i + n - 1) % n]?.url);
}, [open, i, n]);
preload(nextArtUrl(pages[(i + 1) % n]?.urls, failed));
preload(nextArtUrl(pages[(i + n - 1) % n]?.urls, failed));
}, [open, i, n, failed]);
if (!open) return null;
const chapter = cur ? GUIDE_CHAPTERS[cur.chapter] : null;
const firstOf = (ci) => pages.findIndex((p) => p.chapter === ci);
const src = cur ? nextArtUrl(cur.urls, failed) : null; // the next copy when one fails to load
const stage = guideStage(cur, failed); // the next copy when one fails to load; the tips when none is left
const src = stage.kind === 'image' ? stage.src : null;
const isLoaded = !!src && loaded.has(src);
return html`<div class="guide" role="presentation" onMouseDown=${(e) => { if (e.target === e.currentTarget) closeGuide(); }}>
<div class="guide__box brackets" role="dialog" aria-modal="true" aria-label="玩法说明" tabindex="-1" ref=${boxRef}>
@@ -147,13 +164,13 @@ export function GuideHost() {
<button type="button" class="guide__close" aria-label="关闭" title="关闭 (Esc)" onClick=${closeGuide}><${Icon} name="close" /></button>
</header>
${n ? html`<div class="guide__stage">
${src ? html`<div class="guide__stage">
<button type="button" class="guide__nav guide__prev" aria-label="上一页" onClick=${() => go(i - 1)}><${Icon} name="chevronLeft" /></button>
<div class=${cx('guide__page', isLoaded && 'is-loaded')}>
${src ? html`<img key=${src} src=${src} alt=${cur.title} draggable=${false}
<img key=${src} src=${src} alt=${cur.title} draggable=${false}
onLoad=${() => setLoaded((s) => new Set(s).add(src))}
onError=${() => setFailed((s) => new Set(s).add(src))} />` : html`<div class="guide__missing"><${Icon} name="info" />该页面暂时无法显示</div>`}
${src && !isLoaded ? html`<span class="guide__loading"><${Spinner} size="md" /></span>` : null}
onError=${() => setFailed((s) => new Set(s).add(src))} />
${!isLoaded ? html`<span class="guide__loading"><${Spinner} size="md" /></span>` : null}
</div>
<button type="button" class="guide__nav guide__next" aria-label="下一页" onClick=${() => go(i + 1)}><${Icon} name="chevronRight" /></button>
</div>` : html`<div class="guide__stage guide__stage--text"><${TipsFallback} /></div>`}
+21 -1
View File
@@ -53,7 +53,7 @@ describe('local-client art fallbacks', () => {
const { guidePages, GUIDE_CHAPTERS } = await import('../../public/js/ui/guide.js');
const { tierChipUrl } = await import('../../public/js/ui/components.js');
assert.equal(emoteArtUrl('autochess_battle_happy'), emoteArtPath('autochess_battle_happy'));
assert.equal(emoteArtUrl('autochess_battle_sad'), null, 'unlisted emote art is never requested');
assert.equal(emoteArtUrl('autochess_battle_sad'), null, 'listed by neither manifest (no data/assets.json here): no URL, never requested');
assert.equal(emoteArtUrl('nope'), null);
const pages = guidePages();
assert.deepEqual(pages.map((p) => p.key), ['autochess_home_1', 'autochess_home_2', 'autochess_shop_1', 'autochess_handbook_4'], 'reading order, missing pages skipped');
@@ -143,6 +143,26 @@ describe('emotes and 玩法说明 pages: local art first, then the mirror copy (
assert.ok(pages.every((p) => p.urls.length === 1));
});
test('the guide stage: the local page, else the downloaded copy, else the official tips text (0.1.1\'s fallback)', async () => {
const { guideStage } = await import('../../public/js/ui/guide.js');
const local = '/assets/local/guide/autochess_home_1.png';
const mirror = '/assets/ui/guide/autochess_home_1.png';
const page = { key: 'autochess_home_1', urls: [local, mirror] };
assert.deepEqual(guideStage(page, new Set()), { kind: 'image', src: local }, 'local ok → local');
assert.deepEqual(guideStage(page), { kind: 'image', src: local }, 'nothing failed yet');
assert.deepEqual(guideStage(page, new Set([local])), { kind: 'image', src: mirror }, 'local fails → the downloaded copy');
assert.deepEqual(guideStage(page, new Set([local, mirror])), { kind: 'tips' }, 'both fail → tips');
// data/assets.json lists the downloaded page but the file is not on disk (a git pull and restart without setup)
assert.deepEqual(guideStage({ urls: [mirror] }, new Set([mirror])), { kind: 'tips' }, 'listed, not on disk → tips, not a blank notice');
assert.deepEqual(guideStage({ urls: [] }), { kind: 'tips' }, 'no copy');
assert.deepEqual(guideStage(null), { kind: 'tips' }, 'no page');
// the viewer shows exactly that: the image stage for a URL, the tips stage otherwise (never the old blank notice)
const src = readFileSync(path.join(ROOT, 'public/js/ui/guide.js'), 'utf8');
assert.match(src, /const stage = guideStage\(cur, failed\)/);
assert.match(src, /: html`<div class="guide__stage guide__stage--text"><\$\{TipsFallback\} \/><\/div>`\}/);
assert.ok(!src.includes('该页面暂时无法显示'), 'no blank notice left');
});
test('tools/assets/plan.mjs GUIDE_PAGES is the viewer\'s page list (GUIDE_CHAPTERS), in reading order', async () => {
const { GUIDE_CHAPTERS } = await import('../../public/js/ui/guide.js');
assert.deepEqual([...GUIDE_PAGES], GUIDE_CHAPTERS.flatMap((c) => c.pages.map(([key]) => key)));