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:
1 parent
2f0a980b2e
commit
7f7df66977
3 files changed
+49
-14
No files matched your search
@@ -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
@@ -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>`}
|
||||
|
||||
@@ -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)));
|
||||
|
||||
Reference in new issue
Block a user