feedback2 WE: setup downloads the 36 battle emotes and the 19 玩法说明 pages from the public mirror (ArknightsAssets2 cn); the client shows the local-client art first, then the mirror copy, then its fallback (#42)

This commit is contained in:
sganggs committed 2026-10-03 23:05:42 +08:00
1 parent 380af1ba80
commit db7b2b1404
11 files changed
+280 -44

No files matched your search

+34
View File
@@ -31,6 +31,7 @@ export const DATA_FILES = Object.freeze({
config: 'config.json',
assets: 'assets.json',
// Optional art extracted from a local game client (DESIGN §13): { groups: { '<subdir>': { name: { path, w, h } } } }.
// The emotes and the 玩法说明 pages are in data/assets.json too (downloaded from the mirror): artUrls().
local: 'local-assets.json',
});
@@ -229,6 +230,39 @@ export function localAsset(group, name) {
return e && typeof e.path === 'string' && e.path ? e.path : null;
}
/**
* Candidate URLs, best first, of art that both the local client and the public mirror have — the 36 battle emotes and
* the 19 玩法说明 pages (GitHub issue #42: a server without the client showed default emote icons): the local-client
* entry (`localAsset(group, name)`), then the copy tools/fetch-assets.mjs downloads, which data/assets.json lists under
* the same group and name as `ui['<group>/<name>']` (tools/assets/plan.mjs UI_EXTRAS). Empty when neither manifest
* lists it (callers draw their own fallback); a caller skips a URL that fails to load and tries the next. Load both
* files first (`useData('local', 'assets')`).
* @param {string} group e.g. 'emoticon/basic', 'guide'
* @param {string} name entry name without extension
* @returns {string[]}
*/
export function artUrls(group, name) {
const out = [];
const local = localAsset(group, name);
if (local) out.push(local);
const ui = data.get('assets')?.ui;
const key = `${group}/${name}`;
const web = ui && typeof ui === 'object' && Object.hasOwn(ui, key) ? ui[key] : null;
if (typeof web === 'string' && web && web !== local) out.push(web);
return out;
}
/**
* The URL to show from an artUrls() list: the first one that has not failed to load (a local file listed by a copied
* data/local-assets.json without its files falls through to the mirror copy), or null when none is left.
* @param {string[]} urls
* @param {Set<string>} [failed] URLs whose image fired an error
*/
export function nextArtUrl(urls, failed) {
for (const u of Array.isArray(urls) ? urls : []) if (typeof u === 'string' && u && !failed?.has(u)) return u;
return null;
}
/**
* Mode record from config.json (`modes[modeId]`), or null.
* @param {string} modeId e.g. 'mode_multi_hard'
+30 -15
View File
@@ -2,8 +2,10 @@
// EMOTE_THEMES / EMOTES, generated reference data/emotes.json). Official emotes are pictures only: nothing here ever
// renders an emote's text (our labels are aria-labels only).
//
// EmoteArt the emote picture (local-client art, data/local-assets.json → emoticon/<dir>/<picId>); a neutral glyph
// when the art is missing or fails to load, an empty box while the manifest is still loading.
// EmoteArt the emote picture: the local-client art (data/local-assets.json → emoticon/<dir>/<picId>) first, else
// the copy setup downloads from the public mirror (data/assets.json → ui['emoticon/<dir>/<picId>'];
// GitHub issue #42), each tried in turn when one fails to load; a neutral glyph when neither is there,
// an empty box while a manifest is still loading.
// EmoteBubble the pop bubble beside the sender's avatar in the team panel (official emoji_bubble_bkg: a dark rounded
// square with a tail pointing left + the icon only); pop-in, 3 s, fade. The parent keys it by the emote's
// seq so a newer emote replaces the old one and pops again, and passes the arrival time (`at`) so a
@@ -20,7 +22,7 @@ import { useEffect, useRef, useState } from '../../vendor/hooks.module.js';
import { EMOTE_THEMES, EMOTE_COOLDOWN_MS, EMOTE_BUBBLE_MS, emoteInfo, emoteArtGroup } from '../../../shared/constants.js';
import { html } from './components.js';
import { GIcon } from './gameComponents.js';
import { data, useData, localAsset } from '../data.js';
import { data, useData, localAsset, artUrls, nextArtUrl } from '../data.js';
import { loadPref, savePref } from '../store.js';
const cx = (...p) => p.flat().filter(Boolean).join(' ');
@@ -48,15 +50,27 @@ export function ensureEmoteCss(doc = globalThis.document) {
}
/**
* URL of an emote's official art, or null when the id is unknown or the local manifest does not list the picture
* (a path that is not listed is never requested).
* URLs of an emote's official art, best first: the local-client picture, then the mirror copy (data.js artUrls). Empty
* when the id is unknown or neither manifest lists the picture (a path that is not listed is never requested).
* @param {string} id official emoji id
* @returns {string[]}
*/
export function emoteArtUrls(id) {
const e = emoteInfo(id);
return e ? artUrls(emoteArtGroup(id), e.picId) : [];
}
/**
* URL of an emote's official art (the first of emoteArtUrls), or null.
* @param {string} id official emoji id
*/
export function emoteArtUrl(id) {
const e = emoteInfo(id);
return e ? localAsset(emoteArtGroup(id), e.picId) : null;
return emoteArtUrls(id)[0] || null;
}
/** True while the local-art manifest or the asset manifest has not settled (EmoteArt shows an empty box meanwhile). */
const artManifestsPending = () => ['local', 'assets'].some((n) => { const st = data.status(n); return st === 'loading' || st === 'idle'; });
/** Official emote UI sprite (ui/battle: emoji_bubble_bkg, emoji_bkg, emoji_cell_bkg, emoji_btn, emoji_btn_disable). */
export const emoteUiSprite = (name) => localAsset('ui/battle', name);
@@ -149,18 +163,19 @@ function EmoteGlyph({ class: cls }) {
}
/**
* Official emote picture; neutral glyph when unavailable.
* Official emote picture (the local-client art, else the mirror copy; the next one when a picture fails to load);
* neutral glyph when unavailable.
* @param {{ id: string, class?: string }} props
*/
export function EmoteArt({ id, class: cls }) {
useData('local');
const [bad, setBad] = useState(null);
const src = emoteArtUrl(id);
if (src && bad !== src) {
return html`<img class=${cx('eart', cls)} src=${src} alt="" draggable=${false} decoding="async" onError=${() => setBad(src)} />`;
useData('local', 'assets');
const [bad, setBad] = useState(() => new Set()); // URLs that failed to load
const src = nextArtUrl(emoteArtUrls(id), bad);
if (src) {
return html`<img key=${src} class=${cx('eart', cls)} src=${src} alt="" draggable=${false} decoding="async"
onError=${() => setBad((s) => new Set(s).add(src))} />`;
}
const st = data.status('local');
if (!src && emoteInfo(id) && (st === 'loading' || st === 'idle')) return html`<span class=${cx('eart', 'eart--pending', cls)} aria-hidden="true"></span>`;
if (emoteInfo(id) && artManifestsPending()) return html`<span class=${cx('eart', 'eart--pending', cls)} aria-hidden="true"></span>`;
return html`<${EmoteGlyph} class=${cls} />`;
}
+21 -16
View File
@@ -1,8 +1,10 @@
// 玩法说明 (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.
// 玩法说明 (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/<key>']; 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 neither copy the viewer shows the official loading-screen tips (config.tips) instead.
//
// 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).
@@ -10,7 +12,7 @@
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';
import { data, useData, artUrls, nextArtUrl } from '../data.js';
const cx = (...p) => p.flat().filter(Boolean).join(' ');
@@ -32,15 +34,16 @@ export const GUIDE_CHAPTERS = [
];
/**
* Flat page list with URLs (only pages the local manifest lists).
* @returns {Array<{ key: string, title: string, chapter: number, url: string }>}
* 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 url = localAsset('guide', key);
if (url) out.push({ key, title, chapter: ci, url });
const urls = artUrls('guide', key);
if (urls.length) out.push({ key, title, chapter: ci, url: urls[0], urls });
}
});
return out;
@@ -52,6 +55,7 @@ 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) });
}
@@ -84,7 +88,7 @@ function TipsFallback() {
/** 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 ready = useData('local', 'assets', 'config');
const pages = useMemo(() => (ready ? guidePages() : []), [ready]);
const [loaded, setLoaded] = useState(() => new Set());
const [failed, setFailed] = useState(() => new Set());
@@ -122,7 +126,8 @@ export function GuideHost() {
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);
const src = cur ? nextArtUrl(cur.urls, failed) : null; // the next copy when one fails to load
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}>
<header class="guide__head">
@@ -145,10 +150,10 @@ export function GuideHost() {
${n ? 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')}>
${cur && !failed.has(cur.url) ? html`<img key=${cur.url} src=${cur.url} alt=${cur.title} draggable=${false}
onLoad=${() => setLoaded((s) => new Set(s).add(cur.url))}
onError=${() => setFailed((s) => new Set(s).add(cur.url))} />` : html`<div class="guide__missing"><${Icon} name="info" />该页面暂时无法显示</div>`}
${!isLoaded && cur && !failed.has(cur.url) ? html`<span class="guide__loading"><${Spinner} size="md" /></span>` : null}
${src ? html`<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}
</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>`}