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:
1 parent
380af1ba80
commit
db7b2b1404
11 files changed
+280
-44
No files matched your search
+1
-1
@@ -1 +1 @@
|
||||
{"version":1,"hash":"dce1fe3af4fb","generator":"tools/fetch-assets.mjs","stats":{"files":3959,"bytes":259726913,"chars":138,"charsWithBack":135,"enemies":252,"enemiesWithSpine":251,"tokens":20,"tokensWithSpine":13,"spineModels":529,"bonds":23,"items":59,"bands":40,"skills":317,"ui":546,"sfxUnits":382},"chars":{"char_1012_skadi2":{"avatar":"/assets/char/avatar/char_1012_skadi2.png","avatarE2":"/assets/char/avatar/char_1012_skadi2_2.png","portrait":"/assets/char/portrait/char_1012_skadi2_1.png","portraitE2":"/assets/char/portrait/char_1012_skadi2_2.png","spine":{"front":{"skel":"/assets/spine/op/char_1012_skadi2/front/char_1012_skadi2.skel","atlas":"/assets/spine/op/char_1012_skadi2/front/char_1012_skadi2.atlas","textures":["/assets/spine/op/char_1012_skadi2/front/char_1012_skadi2.png"],"pma":false,"anims":{"idle":"Idle","deploy":"Start","attack":{"begin":null,"loop":"Skill_1_Loop","end":null,"via":"skill"},"attackDown":null,"skill":{"begin":"Skill_3_Begin","loop":"Skill_3_Loop","end":null,"index":2,"idle":null},"die":"Die","move":null,"stun":{"begin":"Stun_Begin","loop":"Stun","end":null},"skills":{"0":{"begin":"Skill_1_Begin","loop":"Skill_1_Loop","end":null,"index":0,"idle":null},"1":{"begin":"Skill_2_Begin","loop":"Skill_2_Loop","end":null,"index":1,"idle":null},"2":{"begin":"Skill_3_Begin","loop":"Skill_3_Loop","end":null,"index":2,"idle":null}}},"animations":{"Default":0,"Die":1,"Idle":8.767,"Skill_1_Begin":3.2,"Skill_1_Loop":8.767,"Skill_2_Begin":3.933,"Skill_2_Loop":3.333,"Skill_3_Begin":4.4,"Skill_3_Loop":8.767,"Start":1,"Stun":2,"Stun_Begin":1},"events":["OnAttack","OnStart"],"hits":{},"bounds":{"x":-190.586,"y":-25.785,"width":393.516,"height":443.426}}}},"char_1014_nearl2":{"avatar":"/assets/char/avatar/char_1014_nearl2.png","avatarE2":"/assets/char/avatar/char_1014_nearl2_2.png","portrait":"/assets/char/portrait/char_1014_nearl2_1.png","portraitE2":"/assets/char/portrait/char_1014_nearl2_2.png","spine":{"front":{"skel":"/assets/spine/op/char_1014_nearl2/front/char_1014_nearl2.skel","atlas":"/assets/spine/op/char_1014_nearl2/front/char_1014_nearl2.atlas","textures":["/assets/spine/op/char_1014_nearl2/front/char_1014_nearl2.png"],"pma":false,"anims":{"idle":"Idle","deploy":"Start","attack":{"begin":"Attack_Begin","loop":"Attack_Loop","end":"Attack_End"},"attackDown":null,"skill":{"begin":null,"loop":"Skill_3","end":null,"index":2,"idle":"Skill_3_Idle"},"die":"Die","move":null,"stun":null,"skills":{"0":{"begin":"Skill_1_Begin","loop":"Skill_1_Loop","end":null,"index":0,"idle":"Skill_1_Idle"},"1":{"begin":null,"loop":"Skill_2_Die","end":null,"index":1,"idle":null},"2":{"begin":null,"loop":"Skill_3","end":null,"index":2,"idle":"Skill_3_Idle"}}},"animations":{"Attack_Begin":0.1,"Attack_End":0.167,"Attack_Loop":1.5,"Default":0,"Die":1,"Idle":2.667,"Skill_3":1.5,"Skill_1_Begin":0.1,"Skill_1_Idle":1,"Skill_1_Loop":1.5,"Skill_1_Loop_Down":1.5,"Skill_2_Die":1.167,"Skill_3_Down":1.5,"Skill_3_Idle":1.333,"Start":1},"events":["OnAttack","OnStart"],"hits":{"Attack_Loop":[0.433],"Skill_3":[0.5],"Skill_1_Loop":[0.467],"Skill_1_Loop_Down":[0.467],"Skill_3_Down":[0.5]},"bounds":{"x":-400.77,"y":-461.007,"width":720.545,"height":872.242}},"back":{"skel":"/assets/spine/op/char_1014_nearl2/back/char_1014_nearl2.skel","atlas":"/assets/spine/op/char_1014_nearl2/back/char_1014_nearl2.atlas","textures":["/assets/spine/op/char_1014_nearl2/back/char_1014_nearl2.png"],"pma":false,"anims":{"idle":"Idle","deploy":"Start","attack":{"begin":"Attack_Begin","loop":"Attack_Loop","end":"Attack_End"},"attackDown":null,"skill":{"begin":null,"loop":"Skill_3","end":null,"index":2,"idle":"Skill_3_Idle"},"die":null,"move":null,"stun":null,"skills":{"0":{"begin":"Skill_1_Begin","loop":"Skill_1_Loop","end":null,"index":0,"idle":"Skill_1_Idle"},"1":{"begin":"Attack_Begin","loop":"Attack_Loop","end":"Attack_End","via":"attack","index":1,"idle":null},"2":{"begin":null,"loop":"Skill_3","end":null,"index":2,"idle":"Skill_3_Idle"}}},"animations":{"Attack_Begin":0.1,"Attack_End":0.167,"Attack_Loop":1.5,"Default":0,"Idle":2.667,"Skill_1_Begin":0.1,"Skill_1_Idle":1,"Skill_1_Loop":1.5,"Skill_3":1.5,"Skill_3_Idle":1.333,"Start":1},"events":["OnAttack","OnStart"],"hits":{"Attack_Loop":[0.433],"Skill_1_Loop":[0.467],"Skill_3":[0.5]},"bounds":null}}},"char_1016_agoat2":{"avatar":"/assets/char/avatar/char_1016_agoat2.png","avatarE2":"/assets/char/avatar/char_1016_agoat2_2.png","portrait":"/assets/char/portrait/char_1016_agoat2_1.png","portraitE2":"/assets/char/portrait/char_1016_agoat2_2.png","spine":{"front":{"skel":"/assets/spine/op/char_1016_agoat2/front/char_1016_agoat2.skel","atlas":"/assets/spine/op/char_1016_agoat2/front/char_1016_agoat2.atlas","textures":["/assets/spine/op/char_1016_agoat2/front/char_1016_agoat2.png"],"pma":false,"anims":{"idle":"Idle","deploy":"Start","attack":{"begin":null,"loop":"Attack","end":null},"attackDown":null,"skill":{"begin":"Skill_3_Begin","loop":"Skill_3_Loop","end":"Skill_3_End","index":2,"idle":"Skill_3_Idle"},"diLine truncated
|
||||
{"version":1,"hash":"375e961f7569","generator":"tools/fetch-assets.mjs","stats":{"files":4014,"bytes":282091345,"chars":138,"charsWithBack":135,"enemies":252,"enemiesWithSpine":251,"tokens":20,"tokensWithSpine":13,"spineModels":529,"bonds":23,"items":59,"bands":40,"skills":317,"ui":601,"sfxUnits":382},"chars":{"char_1012_skadi2":{"avatar":"/assets/char/avatar/char_1012_skadi2.png","avatarE2":"/assets/char/avatar/char_1012_skadi2_2.png","portrait":"/assets/char/portrait/char_1012_skadi2_1.png","portraitE2":"/assets/char/portrait/char_1012_skadi2_2.png","spine":{"front":{"skel":"/assets/spine/op/char_1012_skadi2/front/char_1012_skadi2.skel","atlas":"/assets/spine/op/char_1012_skadi2/front/char_1012_skadi2.atlas","textures":["/assets/spine/op/char_1012_skadi2/front/char_1012_skadi2.png"],"pma":false,"anims":{"idle":"Idle","deploy":"Start","attack":{"begin":null,"loop":"Skill_1_Loop","end":null,"via":"skill"},"attackDown":null,"skill":{"begin":"Skill_3_Begin","loop":"Skill_3_Loop","end":null,"index":2,"idle":null},"die":"Die","move":null,"stun":{"begin":"Stun_Begin","loop":"Stun","end":null},"skills":{"0":{"begin":"Skill_1_Begin","loop":"Skill_1_Loop","end":null,"index":0,"idle":null},"1":{"begin":"Skill_2_Begin","loop":"Skill_2_Loop","end":null,"index":1,"idle":null},"2":{"begin":"Skill_3_Begin","loop":"Skill_3_Loop","end":null,"index":2,"idle":null}}},"animations":{"Default":0,"Die":1,"Idle":8.767,"Skill_1_Begin":3.2,"Skill_1_Loop":8.767,"Skill_2_Begin":3.933,"Skill_2_Loop":3.333,"Skill_3_Begin":4.4,"Skill_3_Loop":8.767,"Start":1,"Stun":2,"Stun_Begin":1},"events":["OnAttack","OnStart"],"hits":{},"bounds":{"x":-190.586,"y":-25.785,"width":393.516,"height":443.426}}}},"char_1014_nearl2":{"avatar":"/assets/char/avatar/char_1014_nearl2.png","avatarE2":"/assets/char/avatar/char_1014_nearl2_2.png","portrait":"/assets/char/portrait/char_1014_nearl2_1.png","portraitE2":"/assets/char/portrait/char_1014_nearl2_2.png","spine":{"front":{"skel":"/assets/spine/op/char_1014_nearl2/front/char_1014_nearl2.skel","atlas":"/assets/spine/op/char_1014_nearl2/front/char_1014_nearl2.atlas","textures":["/assets/spine/op/char_1014_nearl2/front/char_1014_nearl2.png"],"pma":false,"anims":{"idle":"Idle","deploy":"Start","attack":{"begin":"Attack_Begin","loop":"Attack_Loop","end":"Attack_End"},"attackDown":null,"skill":{"begin":null,"loop":"Skill_3","end":null,"index":2,"idle":"Skill_3_Idle"},"die":"Die","move":null,"stun":null,"skills":{"0":{"begin":"Skill_1_Begin","loop":"Skill_1_Loop","end":null,"index":0,"idle":"Skill_1_Idle"},"1":{"begin":null,"loop":"Skill_2_Die","end":null,"index":1,"idle":null},"2":{"begin":null,"loop":"Skill_3","end":null,"index":2,"idle":"Skill_3_Idle"}}},"animations":{"Attack_Begin":0.1,"Attack_End":0.167,"Attack_Loop":1.5,"Default":0,"Die":1,"Idle":2.667,"Skill_3":1.5,"Skill_1_Begin":0.1,"Skill_1_Idle":1,"Skill_1_Loop":1.5,"Skill_1_Loop_Down":1.5,"Skill_2_Die":1.167,"Skill_3_Down":1.5,"Skill_3_Idle":1.333,"Start":1},"events":["OnAttack","OnStart"],"hits":{"Attack_Loop":[0.433],"Skill_3":[0.5],"Skill_1_Loop":[0.467],"Skill_1_Loop_Down":[0.467],"Skill_3_Down":[0.5]},"bounds":{"x":-400.77,"y":-461.007,"width":720.545,"height":872.242}},"back":{"skel":"/assets/spine/op/char_1014_nearl2/back/char_1014_nearl2.skel","atlas":"/assets/spine/op/char_1014_nearl2/back/char_1014_nearl2.atlas","textures":["/assets/spine/op/char_1014_nearl2/back/char_1014_nearl2.png"],"pma":false,"anims":{"idle":"Idle","deploy":"Start","attack":{"begin":"Attack_Begin","loop":"Attack_Loop","end":"Attack_End"},"attackDown":null,"skill":{"begin":null,"loop":"Skill_3","end":null,"index":2,"idle":"Skill_3_Idle"},"die":null,"move":null,"stun":null,"skills":{"0":{"begin":"Skill_1_Begin","loop":"Skill_1_Loop","end":null,"index":0,"idle":"Skill_1_Idle"},"1":{"begin":"Attack_Begin","loop":"Attack_Loop","end":"Attack_End","via":"attack","index":1,"idle":null},"2":{"begin":null,"loop":"Skill_3","end":null,"index":2,"idle":"Skill_3_Idle"}}},"animations":{"Attack_Begin":0.1,"Attack_End":0.167,"Attack_Loop":1.5,"Default":0,"Idle":2.667,"Skill_1_Begin":0.1,"Skill_1_Idle":1,"Skill_1_Loop":1.5,"Skill_3":1.5,"Skill_3_Idle":1.333,"Start":1},"events":["OnAttack","OnStart"],"hits":{"Attack_Loop":[0.433],"Skill_1_Loop":[0.467],"Skill_3":[0.5]},"bounds":null}}},"char_1016_agoat2":{"avatar":"/assets/char/avatar/char_1016_agoat2.png","avatarE2":"/assets/char/avatar/char_1016_agoat2_2.png","portrait":"/assets/char/portrait/char_1016_agoat2_1.png","portraitE2":"/assets/char/portrait/char_1016_agoat2_2.png","spine":{"front":{"skel":"/assets/spine/op/char_1016_agoat2/front/char_1016_agoat2.skel","atlas":"/assets/spine/op/char_1016_agoat2/front/char_1016_agoat2.atlas","textures":["/assets/spine/op/char_1016_agoat2/front/char_1016_agoat2.png"],"pma":false,"anims":{"idle":"Idle","deploy":"Start","attack":{"begin":null,"loop":"Attack","end":null},"attackDown":null,"skill":{"begin":"Skill_3_Begin","loop":"Skill_3_Loop","end":"Skill_3_End","index":2,"idle":"Skill_3_Idle"},"diLine truncated
|
||||
@@ -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
@@ -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
@@ -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>`}
|
||||
|
||||
+3
-1
@@ -152,7 +152,9 @@ export const ERR_TEXT = {
|
||||
// (tools/build-emotes.mjs); test/ui/emotes.test.js keeps this table identical to it.
|
||||
// Official emotes have no text (desc is null): `label` is ours and only ever an aria-label, never displayed.
|
||||
// Art: extracted from a local client (tools/local-extract) to /assets/local/emoticon/<dir>/<picId>.png and listed in
|
||||
// data/local-assets.json group `emoticon/<dir>`; the UI shows a neutral glyph when it is absent. The picId is not
|
||||
// data/local-assets.json group `emoticon/<dir>`; also downloaded from the public mirror by tools/fetch-assets.mjs
|
||||
// (tools/assets/plan.mjs UI_EXTRAS → data/assets.json ui['emoticon/<dir>/<picId>'], GitHub issue #42). The UI takes the
|
||||
// local picture first, then the mirror copy, and shows a neutral glyph when neither is there. The picId is not
|
||||
// derived from the id (autochess_battle_fooldoctor_03…06 → pic_fooldoctor_04/05/06/08_battle).
|
||||
const emo = (id, sortId, picId, label) => Object.freeze({ id, sortId, picId, label });
|
||||
export const EMOTE_THEMES = Object.freeze([
|
||||
|
||||
+62
-1
@@ -18,9 +18,10 @@ import { pngSize, isCompletePng, isMp3, validate } from '../tools/assets/formats
|
||||
import { encodeWoff2, decodeWoff2Tables, readSfnt, uintBase128 } from '../tools/assets/woff2.mjs';
|
||||
import { assetToPath, pickUnitSfx, indexAudio } from '../tools/assets/audio.mjs';
|
||||
import { mirrorUrl, safeName, encodePath } from '../tools/assets/sources.mjs';
|
||||
import { collectEnemyIds, skillIndicesByChar } from '../tools/assets/plan.mjs';
|
||||
import { collectEnemyIds, skillIndicesByChar, buildPlan, GUIDE_PAGES, UI_EXTRAS } from '../tools/assets/plan.mjs';
|
||||
import { resolveTemplate, collectLeaves } from '../tools/assets/manifest.mjs';
|
||||
import { spineEntry } from '../public/js/assets.js';
|
||||
import { EMOTE_CATALOG, emoteArtGroup } from '../shared/constants.js';
|
||||
|
||||
const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..');
|
||||
const PUBLIC = join(ROOT, 'public');
|
||||
@@ -468,6 +469,66 @@ describe('downloader (fake network)', () => {
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// GitHub issue #42: a source deploy on a server without the Arknights client had no public/assets/local, so the battle
|
||||
// emotes showed default icons and 玩法说明 showed text tips. The public mirror has both (checked 2026-10-03: all 55
|
||||
// URLs answer 200 on raw.githubusercontent.com and on jsDelivr, same pictures and sizes as the local extraction), so the
|
||||
// plan downloads them like any other UI sprite, keyed by the data/local-assets.json group and name.
|
||||
describe('emotes and 玩法说明 pages from the public mirror (GitHub issue #42)', () => {
|
||||
const AA2 = 'https://raw.githubusercontent.com/ArknightsAssets/ArknightsAssets2/cn/assets/dyn/';
|
||||
const plan = () => buildPlan({ assets07: {}, ops03: {}, enemies05: {}, maps05: {}, audio: indexAudio({}), modelsData: {} }).template;
|
||||
|
||||
test('the plan fetches the 36 battle emotes and the 19 guide pages from ArknightsAssets2 cn, keyed like the local art', () => {
|
||||
const ui = plan().ui;
|
||||
assert.equal(EMOTE_CATALOG.length, 36);
|
||||
for (const e of EMOTE_CATALOG) {
|
||||
const key = `${emoteArtGroup(e.id)}/${e.picId}`; // the local manifest's group + name (emoticon/<dir>/<picId>)
|
||||
assert.deepEqual(ui[key]?.alts, [{ rel: `ui/${key}.png`, urls: [`${AA2}ui/emoticon/theme/%5Buc%5D${e.themeId}/icon/${e.picId}.png`], kind: 'png' }], key);
|
||||
}
|
||||
assert.ok(ui['emoticon/fooldoctor/pic_fooldoctor_08_battle'], 'keyed by picId, not by the emote id (fooldoctor_06 → pic 08)');
|
||||
assert.notEqual(ui['emoticon/slug/pic_thanks_battle'].alts[0].rel, ui['emoticon/basic/pic_thanks_battle'].alts[0].rel, 'same picId, two themes');
|
||||
assert.deepEqual(GUIDE_PAGES.slice(0, 2), ['autochess_home_1', 'autochess_home_2']);
|
||||
assert.equal(GUIDE_PAGES.length, 19);
|
||||
for (const k of GUIDE_PAGES) {
|
||||
assert.deepEqual(ui[`guide/${k}`]?.alts, [{ rel: `ui/guide/${k}.png`, urls: [`${AA2}arts/guidebookpages/%5Bpack%5Dautochess/${k}.png`], kind: 'png' }], k);
|
||||
}
|
||||
const rels = Object.values(ui).map((l) => l.alts[0].rel);
|
||||
assert.equal(new Set(rels).size, rels.length, 'no two UI entries share a file');
|
||||
assert.ok(rels.every((r) => !r.startsWith('local/')), 'public/assets/local belongs to tools/local-extract');
|
||||
assert.equal(mirrorUrl(ui['emoticon/fooldoctor/pic_fooldoctor_08_battle'].alts[0].urls[0]),
|
||||
'https://cdn.jsdelivr.net/gh/ArknightsAssets/ArknightsAssets2@cn/assets/dyn/ui/emoticon/theme/%5Buc%5Demoticon_foolsday_doctor/icon/pic_fooldoctor_08_battle.png',
|
||||
'the jsDelivr fallback (downloader) serves the same path');
|
||||
assert.ok(Object.isFrozen(UI_EXTRAS) && UI_EXTRAS.every(Object.isFrozen));
|
||||
});
|
||||
|
||||
test('a rebuilt manifest lists the copies that are on disk under /assets/ui/…; a missing one is left out', async () => {
|
||||
const { mkdtemp, mkdir, writeFile } = await import('node:fs/promises');
|
||||
const { tmpdir } = await import('node:os');
|
||||
const root = await mkdtemp(join(tmpdir(), 'sp-mirror-art-'));
|
||||
const ui = plan().ui;
|
||||
const keys = ['emoticon/basic/pic_happy_battle', 'guide/autochess_home_1', 'emoticon/slug/pic_bye_battle'];
|
||||
for (const k of keys.slice(0, 2)) {
|
||||
await mkdir(join(root, dirname(ui[k].alts[0].rel)), { recursive: true });
|
||||
await writeFile(join(root, ui[k].alts[0].rel), 'x');
|
||||
}
|
||||
const r = resolveTemplate({ ui: Object.fromEntries(keys.map((k) => [k, ui[k]])) }, { root, spine: new Map() });
|
||||
assert.deepEqual(r.value.ui, {
|
||||
'emoticon/basic/pic_happy_battle': '/assets/ui/emoticon/basic/pic_happy_battle.png',
|
||||
'guide/autochess_home_1': '/assets/ui/guide/autochess_home_1.png',
|
||||
});
|
||||
assert.deepEqual(r.misses, ['ui.emoticon/slug/pic_bye_battle']);
|
||||
});
|
||||
|
||||
test('the committed data/assets.json lists all 55, so setup on an install made before them downloads them', () => {
|
||||
// tools/setup.mjs runs fetch-assets when data/assets.json lists a file that is not on disk (checkAssets)
|
||||
const m = readJson('data/assets.json');
|
||||
for (const e of EMOTE_CATALOG) assert.equal(m.ui[`emoticon/${e.dir}/${e.picId}`], `/assets/ui/emoticon/${e.dir}/${e.picId}.png`, e.id);
|
||||
for (const k of GUIDE_PAGES) assert.equal(m.ui[`guide/${k}`], `/assets/ui/guide/${k}.png`, k);
|
||||
assert.equal(m.stats.ui, Object.keys(m.ui).length);
|
||||
assert.ok(!JSON.stringify(m).includes('/assets/local/'), 'never a local-client path');
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
describe('generated manifest data/assets.json', () => {
|
||||
const haveManifest = existsSync(MANIFEST);
|
||||
|
||||
@@ -71,6 +71,22 @@ test('fetch-assets shrink guard: the committed manifest minus some audio entries
|
||||
assert.equal(statSync(MANIFEST).mtimeMs, before, 'importing the tool runs nothing');
|
||||
});
|
||||
|
||||
test('a server where the mirror emotes / 玩法说明 pages failed to download keeps the committed manifest, so the next setup retries them (#42)', async () => {
|
||||
// setup re-runs fetch-assets only while data/assets.json lists a file that is not on disk: a manifest rewritten without
|
||||
// these entries would stop the retries, and the emotes would stay default icons on that server
|
||||
const { shrinkGuard, parseArgs } = await import('../tools/fetch-assets.mjs');
|
||||
const prev = JSON.parse(readFileSync(MANIFEST, 'utf8'));
|
||||
const next = JSON.parse(JSON.stringify(prev));
|
||||
for (const k of Object.keys(next.ui)) if (/^(emoticon|guide)\//.test(k)) delete next.ui[k];
|
||||
next.stats = { ...next.stats, ui: Object.keys(next.ui).length };
|
||||
const g = shrinkGuard(prev, next, parseArgs([]));
|
||||
assert.equal(g.write, false);
|
||||
assert.equal(g.dropped.length, 36 + 19);
|
||||
for (const k of ['ui.emoticon/basic/pic_happy_battle', 'ui.emoticon/fooldoctor/pic_fooldoctor_08_battle', 'ui.guide/autochess_home_1', 'ui.guide/autochess_handbook_4']) {
|
||||
assert.ok(g.dropped.includes(k), k);
|
||||
}
|
||||
});
|
||||
|
||||
test('fetch-assets still runs as a script: --help lists --allow-shrink', () => {
|
||||
const r = spawnSync(process.execPath, [join(ROOT, 'tools', 'fetch-assets.mjs'), '--help'], { encoding: 'utf8', timeout: 30000 });
|
||||
assert.equal(r.status, 0, r.stderr);
|
||||
|
||||
@@ -1,12 +1,16 @@
|
||||
// Local-client art is optional (DESIGN §13): the emote art, the 玩法说明 pages and the official tier chips resolve
|
||||
// from data/local-assets.json when present and fall back (text / tips / CSS) when the manifest or an entry is missing.
|
||||
// The emotes and the 玩法说明 pages are also on the public mirror (GitHub issue #42): data/assets.json lists the copies
|
||||
// tools/fetch-assets.mjs downloads under the same group and name (ui['<group>/<name>']); the client takes the local
|
||||
// file first, then the mirror copy, then its fallback (data.js artUrls / nextArtUrl).
|
||||
|
||||
import { test, describe } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { EMOTES, emoteArtPath, emoteArtGroup, emoteInfo } from '../../shared/constants.js';
|
||||
import { EMOTES, EMOTE_CATALOG, emoteArtPath, emoteArtGroup, emoteInfo } from '../../shared/constants.js';
|
||||
import { GUIDE_PAGES } from '../../tools/assets/plan.mjs';
|
||||
|
||||
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..');
|
||||
|
||||
@@ -19,13 +23,28 @@ const manifest = {
|
||||
'ui/battle': { img_chess_level_3: { path: '/assets/local/ui/battle/img_chess_level_3.png', w: 45, h: 45 } },
|
||||
},
|
||||
};
|
||||
// what server/index.js serves when data/local-assets.json is absent
|
||||
const EMPTY_LOCAL = { version: 1, source: 'none', count: 0, groups: {} };
|
||||
|
||||
let mode = 'ok';
|
||||
let mode = 'ok'; // data/local-assets.json: 'ok' (the manifest above) | 'empty' (the server's stand-in) | 'missing' (404)
|
||||
let assetsDoc = null; // data/assets.json, or null (404)
|
||||
globalThis.fetch = async (url) => {
|
||||
if (String(url).endsWith('/local-assets.json') && mode === 'ok') return { ok: true, status: 200, json: async () => manifest };
|
||||
const u = String(url);
|
||||
if (u.endsWith('/local-assets.json')) {
|
||||
if (mode === 'ok') return { ok: true, status: 200, json: async () => manifest };
|
||||
if (mode === 'empty') return { ok: true, status: 200, json: async () => EMPTY_LOCAL };
|
||||
} else if (u.endsWith('/assets.json') && assetsDoc) return { ok: true, status: 200, json: async () => assetsDoc };
|
||||
return { ok: false, status: 404, json: async () => ({}) };
|
||||
};
|
||||
|
||||
/** Reload both manifests after changing `mode` / `assetsDoc`. */
|
||||
async function reload(data) {
|
||||
const orig = console.warn; console.warn = () => {};
|
||||
try {
|
||||
for (const n of ['local', 'assets']) { await data.invalidate(n); await data.load(n); }
|
||||
} finally { console.warn = orig; }
|
||||
}
|
||||
|
||||
describe('local-client art fallbacks', () => {
|
||||
test('manifest present: listed entries resolve, unlisted ones stay null', async () => {
|
||||
const { data, localAsset } = await import('../../public/js/data.js');
|
||||
@@ -48,9 +67,9 @@ describe('local-client art fallbacks', () => {
|
||||
|
||||
test('manifest missing: everything falls back', async () => {
|
||||
mode = 'missing';
|
||||
assetsDoc = null;
|
||||
const { data } = await import('../../public/js/data.js');
|
||||
const orig = console.warn; console.warn = () => {};
|
||||
try { await data.invalidate('local'); } finally { console.warn = orig; }
|
||||
await reload(data);
|
||||
assert.equal(data.status('local'), 'missing');
|
||||
const { emoteArtUrl } = await import('../../public/js/ui/emotes.js');
|
||||
const { guidePages } = await import('../../public/js/ui/guide.js');
|
||||
@@ -71,6 +90,65 @@ describe('local-client art fallbacks', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('emotes and 玩法说明 pages: local art first, then the mirror copy (GitHub issue #42)', () => {
|
||||
test('lookup order: the local file, then data/assets.json ui[\'<group>/<name>\'], then nothing', async () => {
|
||||
mode = 'ok';
|
||||
assetsDoc = { ui: {
|
||||
'emoticon/basic/pic_happy_battle': '/assets/ui/emoticon/basic/pic_happy_battle.png', // both: the local one wins
|
||||
'emoticon/basic_2/pic_sad_battle': '/assets/ui/emoticon/basic_2/pic_sad_battle.png', // the mirror copy only
|
||||
'guide/autochess_home_1': '/assets/ui/guide/autochess_home_1.png',
|
||||
'guide/autochess_home_9': '/assets/ui/guide/autochess_home_9.png',
|
||||
'ui/battle/img_chess_level_4': '/assets/ui/x.png', // not one of the mirrored groups' names
|
||||
} };
|
||||
const { data, artUrls, nextArtUrl, localAsset } = await import('../../public/js/data.js');
|
||||
await reload(data);
|
||||
const { emoteArtUrl, emoteArtUrls } = await import('../../public/js/ui/emotes.js');
|
||||
const { guidePages } = await import('../../public/js/ui/guide.js');
|
||||
assert.deepEqual(emoteArtUrls('autochess_battle_happy'), ['/assets/local/emoticon/basic/pic_happy_battle.png', '/assets/ui/emoticon/basic/pic_happy_battle.png']);
|
||||
assert.equal(emoteArtUrl('autochess_battle_happy'), '/assets/local/emoticon/basic/pic_happy_battle.png', 'local first');
|
||||
assert.equal(emoteArtUrl('autochess_battle_sad'), '/assets/ui/emoticon/basic_2/pic_sad_battle.png', 'no local file: the mirror copy');
|
||||
assert.deepEqual(emoteArtUrls('autochess_battle_dying'), [], 'neither: the glyph');
|
||||
assert.equal(emoteArtUrl('autochess_battle_dying'), null);
|
||||
assert.deepEqual(emoteArtUrls('__proto__'), []);
|
||||
const pages = guidePages();
|
||||
assert.deepEqual(pages.map((p) => p.key), ['autochess_home_1', 'autochess_home_2', 'autochess_home_9', 'autochess_shop_1', 'autochess_handbook_4'], 'reading order, either source');
|
||||
assert.deepEqual(pages[0].urls, ['/assets/local/guide/autochess_home_1.png', '/assets/ui/guide/autochess_home_1.png']);
|
||||
assert.equal(pages[0].url, '/assets/local/guide/autochess_home_1.png');
|
||||
assert.deepEqual(pages[2].urls, ['/assets/ui/guide/autochess_home_9.png']);
|
||||
assert.deepEqual(artUrls('guide', 'autochess_home_2'), ['/assets/local/guide/autochess_home_2.png']);
|
||||
assert.deepEqual(artUrls('ui/battle', 'img_chess_level_3'), ['/assets/local/ui/battle/img_chess_level_3.png']);
|
||||
assert.deepEqual(artUrls('guide', '__proto__'), []);
|
||||
assert.deepEqual(artUrls('constructor', 'x'), []);
|
||||
assert.equal(localAsset('guide', 'autochess_home_9'), null, 'localAsset stays local-only');
|
||||
// an image that fails to load (a copied local-assets.json without its files) falls through to the next URL
|
||||
const urls = emoteArtUrls('autochess_battle_happy');
|
||||
assert.equal(nextArtUrl(urls, new Set()), urls[0]);
|
||||
assert.equal(nextArtUrl(urls, new Set([urls[0]])), urls[1]);
|
||||
assert.equal(nextArtUrl(urls, new Set(urls)), null, 'both failed: the fallback');
|
||||
assert.equal(nextArtUrl(urls), urls[0]);
|
||||
assert.equal(nextArtUrl(null), null);
|
||||
});
|
||||
|
||||
test('no local client (a source deploy on a server, issue #42): every emote and all 19 pages come from the committed data/assets.json', async () => {
|
||||
mode = 'empty';
|
||||
assetsDoc = JSON.parse(readFileSync(path.join(ROOT, 'data/assets.json'), 'utf8'));
|
||||
const { data } = await import('../../public/js/data.js');
|
||||
await reload(data);
|
||||
const { emoteArtUrl } = await import('../../public/js/ui/emotes.js');
|
||||
const { guidePages } = await import('../../public/js/ui/guide.js');
|
||||
for (const e of EMOTE_CATALOG) assert.equal(emoteArtUrl(e.id), `/assets/ui/emoticon/${e.dir}/${e.picId}.png`, e.id);
|
||||
const pages = guidePages();
|
||||
assert.deepEqual(pages.map((p) => p.key), GUIDE_PAGES);
|
||||
assert.deepEqual(pages.map((p) => p.url), GUIDE_PAGES.map((k) => `/assets/ui/guide/${k}.png`));
|
||||
assert.ok(pages.every((p) => p.urls.length === 1));
|
||||
});
|
||||
|
||||
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)));
|
||||
});
|
||||
});
|
||||
|
||||
function safeRead() {
|
||||
try { return readFileSync(path.join(ROOT, 'data/local-assets.json'), 'utf8'); } catch { return null; }
|
||||
}
|
||||
+26
-4
@@ -15,12 +15,14 @@
|
||||
// act2autochess match (07 enemy list ∪ act1autochess wave/boss levels used by
|
||||
// act2 modes ∪ bosses ∪ their summons ∪ enemy units spawned by operator kits),
|
||||
// the 23 bonds, 59 shop items, 40 bands,
|
||||
// default-skill icons, profession icons, autochess UI sprites, BGM and SFX.
|
||||
// default-skill icons, profession icons, autochess UI sprites (the 36 battle
|
||||
// emotes and the 19 玩法说明 pages included: UI_EXTRAS), BGM and SFX.
|
||||
|
||||
import { RAW, joinUrl, safeName, urlBase, urlDir } from './sources.mjs';
|
||||
import { kindOf } from './formats.mjs';
|
||||
import { pickUnitSfx, UI_SFX, BATTLE_SFX, resolveSpec } from './audio.mjs';
|
||||
import { literal } from './manifest.mjs';
|
||||
import { EMOTE_CATALOG } from '../../shared/constants.js';
|
||||
|
||||
/**
|
||||
* Enemies whose Spine no community dump carries: the web model is another enemy's (research 07 §5.6). Their official
|
||||
@@ -38,8 +40,26 @@ const LOADING_USED = new Set(['loading_ac_core', 'loading_ac_prototype', 'loadin
|
||||
|
||||
const PROFESSIONS = ['caster', 'medic', 'pioneer', 'sniper', 'special', 'support', 'tank', 'warrior'];
|
||||
|
||||
/** Extra UI sprites (not in 07-assets.json groups): [group, key, path under ArknightsAssets2 cn assets/dyn]. */
|
||||
const UI_EXTRAS = (() => {
|
||||
/**
|
||||
* The 19 official 玩法说明 (tutorial) pages, in the reading order of public/js/ui/guide.js GUIDE_CHAPTERS (a test keeps
|
||||
* the two identical): 基础规则 home 1–9, 调度手册 shop 1–6, 进阶图鉴 handbook 1–4.
|
||||
*/
|
||||
export const GUIDE_PAGES = Object.freeze([
|
||||
...[1, 2, 3, 4, 5, 6, 7, 8, 9].map((i) => `autochess_home_${i}`),
|
||||
...[1, 2, 3, 4, 5, 6].map((i) => `autochess_shop_${i}`),
|
||||
...[1, 2, 3, 4].map((i) => `autochess_handbook_${i}`),
|
||||
]);
|
||||
|
||||
/**
|
||||
* Extra UI sprites (not in 07-assets.json groups): [group, key, path under ArknightsAssets2 cn assets/dyn].
|
||||
* The last two blocks are art that used to come from the local client only (tools/local-extract, DESIGN §13) and that
|
||||
* the mirror carries too (GitHub issue #42: a server without the client showed default emote icons): the 36 battle
|
||||
* emotes (shared/constants.js EMOTE_CATALOG; the bundle ui/emoticon/theme/[uc]<themeId>.ab → its icon/<picId>.png) and
|
||||
* the 19 玩法说明 pages (arts/guidebookpages/[pack]autochess.ab, 1024² like the local copies: displayed at 16:9). Their
|
||||
* manifest keys are the data/local-assets.json group and name — ui['emoticon/<dir>/<picId>'], ui['guide/<key>'] — so
|
||||
* the client looks both up by the same names (public/js/data.js artUrls: the local file first, then this copy).
|
||||
*/
|
||||
export const UI_EXTRAS = (() => {
|
||||
const L = [];
|
||||
const mc = 'ui/autochess/[uc]autochessouter/modechoice/auto_chess_mode_choice_state/';
|
||||
for (const m of ['normal', 'hard', 'abyss', 'funny']) L.push(['modeChoice', `${m}_rhodes_island`, `${mc}${m}_rhodes_island.png`]);
|
||||
@@ -67,7 +87,9 @@ const UI_EXTRAS = (() => {
|
||||
L.push(['prepReady', 'countdown_arrow', `${pr}countdown_arrow.png`]);
|
||||
const si = 'ui/autochess/[uc]autochessouter/stageinfo/auto_chess_stage_info_state/';
|
||||
for (const k of ['img_title_mode_abyss', 'img_title_mode_funny', 'img_title_mode_hard', 'img_title_mode_normal', 'btn_confirm', 'btn_confirmed']) L.push(['stageInfo', k, `${si}${k}.png`]);
|
||||
return L;
|
||||
for (const e of EMOTE_CATALOG) L.push([`emoticon/${e.dir}`, e.picId, `ui/emoticon/theme/[uc]${e.themeId}/icon/${e.picId}.png`]);
|
||||
for (const k of GUIDE_PAGES) L.push(['guide', k, `arts/guidebookpages/[pack]autochess/${k}.png`]);
|
||||
return Object.freeze(L.map((x) => Object.freeze(x)));
|
||||
})();
|
||||
|
||||
/** Renames of research 07 `arts` groups to manifest UI groups. */
|
||||
|
||||
@@ -14,7 +14,8 @@
|
||||
// themes: [{ themeId, dir, sortId, isBasic, name, emotes: [id…] }], pages, enabled-list order
|
||||
// emotes: [{ id, themeId, sortId, picId, art, label }] } 36, page order then sortId
|
||||
// `art` = /assets/local/emoticon/<dir>/<picId>.png (tools/local-extract; key by picId — fooldoctor_03…06 use pics
|
||||
// 04/05/06/08). Official emotes carry no text (desc is null); `label` is ours and only ever an aria-label.
|
||||
// 04/05/06/08); the mirror copy that tools/fetch-assets.mjs downloads is data/assets.json ui['emoticon/<dir>/<picId>'].
|
||||
// Official emotes carry no text (desc is null); `label` is ours and only ever an aria-label.
|
||||
// shared/constants.js EMOTE_THEMES mirrors this file (test/ui/emotes.test.js keeps them identical).
|
||||
//
|
||||
// Usage: node tools/build-emotes.mjs [--offline] [--check] [--out data/emotes.json] [--cache .cache/gamedata]
|
||||
|
||||
@@ -8,6 +8,8 @@ aklz4.py registers a decoder for it. This script pulls the art the web sources l
|
||||
- the 6 in-match emoticon themes of 盟约 (activity_table autoChessData.enabledEmoticonThemeIdList; only the
|
||||
*_battle sprites, keyed by display_meta_table picId: emoticon/<dir>/<picId>.png, see data/emotes.json)
|
||||
- the autochess guidebook pages, battle projectile sprites and a few token/skin Spine models missing upstream
|
||||
(the emotes and the guidebook pages are on the public mirror too: tools/fetch-assets.mjs downloads them, and the
|
||||
client uses these local copies first — GitHub issue #42)
|
||||
- the enemy battle Spine models no community dump carries (ENEMY_SPINES: 灼热源石虫 / 炽焰源石虫), from the enemy art
|
||||
bundles (refs/arts/enm_art_*.ab) → spine/enemy/<enemyId>/<stem>.skel|.atlas + page PNGs with the [alpha] texture
|
||||
merged in (premultiplied RGB + A, the Ark-Models format; the atlas gets `size:` / `pma: true` like the fetched
|
||||
|
||||
Reference in new issue
Block a user