diff --git a/public/css/screens/guide.css b/public/css/screens/guide.css
index 665d53f1..21e7dbd7 100644
--- a/public/css/screens/guide.css
+++ b/public/css/screens/guide.css
@@ -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); }
diff --git a/public/js/ui/guide.js b/public/js/ui/guide.js
index 156b5827..abffa869 100644
--- a/public/js/ui/guide.js
+++ b/public/js/ui/guide.js
@@ -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?)`; is mounted once by main.js
// (and by the dev mock harness); 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} [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`
@@ -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`
{ if (e.target === e.currentTarget) closeGuide(); }}>