// 玩法说明 (How to play): viewer for the 19 official tutorial pages of the mode: guide/autochess_{home 1–9, shop 1–6, // handbook 1–4} from the local-client art (DESIGN §13: data/local-assets.json) when it is there, else the copies setup // downloads from the public mirror (data/assets.json → ui['guide/']; GitHub issue #42) — data.js artUrls; a page // 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 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). import { useEffect, useMemo, useRef, useState } from '../../vendor/hooks.module.js'; import { html, Icon, MicroLabel, Button, Spinner } from './components.js'; import { createStore, useStore } from '../store.js'; import { data, useData, artUrls, nextArtUrl } from '../data.js'; import { t, N_ } from '../../../shared/i18n.js'; const cx = (...p) => p.flat().filter(Boolean).join(' '); /** Page catalogue in reading order (titles transcribed from the pages). */ export const GUIDE_CHAPTERS = [ { id: 'home', name: N_('基础规则'), micro: 'BASICS', pages: [ ['autochess_home_1', N_('卫戍协议已运行')], ['autochess_home_2', N_('攻防战')], ['autochess_home_3', N_('休整期 · 区域')], ['autochess_home_4', N_('休整期 · 资金与调度')], ['autochess_home_5', N_('机变阶段')], ['autochess_home_6', N_('限时战斗')], ['autochess_home_7', N_('作战期')], ['autochess_home_8', N_('协同战斗')], ['autochess_home_9', N_('盟约')], ] }, { id: 'shop', name: N_('调度手册'), micro: 'HANDBOOK', pages: [ ['autochess_shop_1', N_('调度手册')], ['autochess_shop_2', N_('干员晋级')], ['autochess_shop_3', N_('加成情况')], ['autochess_shop_4', N_('卫戍能力')], ['autochess_shop_5', N_('盟约')], ['autochess_shop_6', N_('助战及自选编队')], ] }, { id: 'handbook', name: N_('进阶图鉴'), micro: 'ADVANCED', pages: [ ['autochess_handbook_1', N_('敌人类型')], ['autochess_handbook_2', N_('盟约激活与叠加')], ['autochess_handbook_3', N_('追加盟约')], ['autochess_handbook_4', N_('策略与轮选')], ] }, ]; /** * Flat page list with URLs (only pages the local-art manifest or the asset manifest lists): `urls` best first (the * local file, then the mirror copy), `url` the first of them. * @returns {Array<{ key: string, title: string, chapter: number, url: string, urls: string[] }>} */ export function guidePages() { const out = []; GUIDE_CHAPTERS.forEach((ch, ci) => { for (const [key, title] of ch.pages) { const urls = artUrls('guide', key); if (urls.length) out.push({ key, title, chapter: ci, url: urls[0], urls }); } }); 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 }); /** Open the viewer (optionally at a page index). */ export function openGuide(page = 0) { data.load('local'); data.load('assets'); data.load('config'); guideStore.set({ open: true, page: Math.max(0, page | 0) }); } export const closeGuide = () => guideStore.set({ open: false }); /** Standard 玩法说明 trigger button. */ export function GuideButton({ class: cls, size = 'sm', variant = 'ghost', label = t('玩法说明'), square = false }) { return html`<${Button} variant=${variant} size=${size} icon="book" square=${square} class=${cx('guide-btn', cls)} onClick=${() => openGuide(0)} title=${t('玩法说明')} aria-label=${t('玩法说明')}>${square ? null : label}`; } function preload(url) { if (!url || typeof Image === 'undefined') return; const img = new Image(); img.decoding = 'async'; img.crossOrigin = 'anonymous'; img.src = url; } /** 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`
<${MicroLabel} tone="mint">${t('TIPS // 模拟要点')}
    ${tips.map((t, i) => html`
  1. ${t}
  2. `)}
${!tips.length ? html`

${t('暂无说明内容')}

` : null}
`; } /** The viewer (mounted once near the root). */ export function GuideHost() { const { open, page } = useStore((s) => s, Object.is, guideStore); const ready = useData('local', 'assets', 'config'); const pages = useMemo(() => (ready ? guidePages() : []), [ready]); const [loaded, setLoaded] = useState(() => new Set()); const [failed, setFailed] = useState(() => new Set()); const boxRef = useRef(null); const n = pages.length; const i = n ? Math.min(Math.max(0, page), n - 1) : 0; const cur = pages[i] || null; const go = (k) => { if (n) guideStore.set({ open: true, page: ((k % n) + n) % n }); }; useEffect(() => { if (!open) return undefined; const onKey = (e) => { if (e.ctrlKey || e.metaKey || e.altKey) return; const k = e.key; let handled = true; if (k === 'Escape') closeGuide(); else if (k === 'ArrowRight' || k === 'd' || k === 'D' || k === 'PageDown') go(guideStore.get().page + 1); else if (k === 'ArrowLeft' || k === 'a' || k === 'A' || k === 'PageUp') go(guideStore.get().page - 1); else if (k === 'Home') go(0); else if (k === 'End') go(n - 1); else handled = (!!k && k.length === 1) || k === ' '; // swallow game shortcuts (R/F/D/Space) while open if (handled) { e.preventDefault(); e.stopPropagation(); e.stopImmediatePropagation(); } }; window.addEventListener('keydown', onKey, true); const t = setTimeout(() => boxRef.current?.focus?.(), 30); return () => { window.removeEventListener('keydown', onKey, true); clearTimeout(t); }; }, [open, n]); useEffect(() => { if (!open || !n) return; 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 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``; }