// 玩法说明 (How to play): viewer for the 19 official tutorial pages of the mode (local-client art, DESIGN §13:
// data/local-assets.json → guide/autochess_{home 1–9, shop 1–6, handbook 1–4}). The pages are stored squashed to
// 1024² 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.
// Without the local art the viewer shows the official loading-screen tips (config.tips) instead.
//
// 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, localAsset } from '../data.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: '基础规则', micro: 'BASICS', pages: [
['autochess_home_1', '卫戍协议已运行'], ['autochess_home_2', '攻防战'], ['autochess_home_3', '休整期 · 区域'],
['autochess_home_4', '休整期 · 资金与调度'], ['autochess_home_5', '机变阶段'], ['autochess_home_6', '限时战斗'],
['autochess_home_7', '作战期'], ['autochess_home_8', '协同战斗'], ['autochess_home_9', '盟约'],
] },
{ id: 'shop', name: '调度手册', micro: 'HANDBOOK', pages: [
['autochess_shop_1', '调度手册'], ['autochess_shop_2', '干员晋级'], ['autochess_shop_3', '加成情况'],
['autochess_shop_4', '卫戍能力'], ['autochess_shop_5', '盟约'], ['autochess_shop_6', '助战及自选编队'],
] },
{ id: 'handbook', name: '进阶图鉴', micro: 'ADVANCED', pages: [
['autochess_handbook_1', '敌人类型'], ['autochess_handbook_2', '盟约激活与叠加'], ['autochess_handbook_3', '追加盟约'],
['autochess_handbook_4', '策略与轮选'],
] },
];
/**
* Flat page list with URLs (only pages the local manifest lists).
* @returns {Array<{ key: string, title: string, chapter: number, url: string }>}
*/
export function guidePages() {
const out = [];
GUIDE_CHAPTERS.forEach((ch, ci) => {
for (const [key, title] of ch.pages) {
const url = localAsset('guide', key);
if (url) out.push({ key, title, chapter: ci, url });
}
});
return out;
}
/** 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('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 = '玩法说明', square = false }) {
return html`<${Button} variant=${variant} size=${size} icon="book" square=${square} class=${cx('guide-btn', cls)}
onClick=${() => openGuide(0)} title="玩法说明" aria-label="玩法说明">${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 the tutorial art is not installed: 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">TIPS // 模拟要点${MicroLabel}>
${tips.map((t, i) => html`- ${t}
`)}
${!tips.length ? html`
暂无说明内容
` : null}
`;
}
/** The viewer (mounted once near the root). */
export function GuideHost() {
const { open, page } = useStore((s) => s, Object.is, guideStore);
const ready = useData('local', '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(pages[(i + 1) % n]?.url);
preload(pages[(i + n - 1) % n]?.url);
}, [open, i, n]);
if (!open) return null;
const chapter = cur ? GUIDE_CHAPTERS[cur.chapter] : null;
const firstOf = (ci) => pages.findIndex((p) => p.chapter === ci);
const isLoaded = cur && loaded.has(cur.url);
return html` { if (e.target === e.currentTarget) closeGuide(); }}>
${n ? html`
${cur && !failed.has(cur.url) ? html`

setLoaded((s) => new Set(s).add(cur.url))}
onError=${() => setFailed((s) => new Set(s).add(cur.url))} />` : html`
<${Icon} name="info" />该页面暂时无法显示
`}
${!isLoaded && cur && !failed.has(cur.url) ? html`
<${Spinner} size="md" />` : null}
` : html`
<${TipsFallback} />
`}
${n ? html`` : null}
`;
}