卫戍协议:盟约 web remake — initial import

Online co-op browser remake of Arknights' Stronghold Protocol: Alliance
(client-simulated combat, official waves/bonds/items/strategies, 3D board,
facing wheel, operator loadout, official emotes, deployment scripts).
This commit is contained in:
sganggs committed 2026-09-29 16:21:31 +08:00
commit 01a44cd288
408 files changed
+308986

No files matched your search

+191
View File
@@ -0,0 +1,191 @@
// Asset URL resolution against data/assets.json (docs/ASSETS.md). Pure: every function takes the
// manifest object (or null) and returns a URL string or null — callers draw their own fallback
// (glyph, CSS shape) when null. Only URLs present in the manifest are ever returned, so the client
// never requests files the asset pipeline did not produce (no 404 noise in the console).
const str = (v) => (typeof v === 'string' && v ? v : null);
const obj = (v) => (v && typeof v === 'object' ? v : null);
/** @param {any} m manifest @param {string} key 'group/key' */
export function uiUrl(m, key) {
return str(obj(obj(m)?.ui)?.[key]);
}
/**
* Operator avatar for a chess record (golden → E2 art when present).
* @param {any} m manifest
* @param {any} chess chess.json record (or { assets: { avatar } })
*/
export function chessAvatarUrl(m, chess) {
const chars = obj(obj(m)?.chars);
const id = str(chess?.assets?.avatar) || str(chess?.charId);
if (!chars || !id) return null;
if (id.endsWith('_2') && !chars[id]) {
const base = chars[id.slice(0, -2)];
return str(base?.avatarE2) || str(base?.avatar);
}
return str(chars[id]?.avatar) || (chess?.charId ? str(chars[chess.charId]?.avatar) : null);
}
/**
* Half-body portrait (180×360) for a chess record (golden → E2 portrait when present).
* @param {any} m
* @param {any} chess
*/
export function chessPortraitUrl(m, chess) {
const chars = obj(obj(m)?.chars);
const id = str(chess?.assets?.portrait);
if (!chars) return null;
if (id) {
if (id.endsWith('_2')) {
const base = chars[id.slice(0, -2)];
if (base) return str(base.portraitE2) || str(base.portrait);
}
if (id.endsWith('_1')) {
const base = chars[id.slice(0, -2)];
if (base) return str(base.portrait);
}
}
const byChar = chess?.charId ? chars[chess.charId] : null;
if (!byChar) return null;
return (chess?.isGolden ? str(byChar.portraitE2) : null) || str(byChar.portrait);
}
/** Skill icon of a chess record (manifest `skills[iconId]`, else the empty skill sprite). */
export function skillIconUrl(m, chess) {
const skills = obj(obj(m)?.skills);
const id = str(chess?.assets?.skillIcon) || str(chess?.skill?.iconId) || str(chess?.skill?.skillId);
if (skills && id && skills[id]) return str(skills[id]);
const byId = obj(obj(m)?.skillsById);
const alt = byId && chess?.skill?.skillId ? byId[chess.skill.skillId] : null;
if (skills && alt && skills[alt]) return str(skills[alt]);
return uiUrl(m, 'skillIcon/empty');
}
/**
* Skill icon of one skill record (DESIGN §16 `skills[]`: iconId / skillId), else the empty skill sprite — or null with
* `{ empty: false }` (the asset manifest only carries the default skills' icons: callers then draw a lettered S1–S3
* tile instead of a blank square).
*/
export function skillRecordIconUrl(m, skill, { empty = true } = {}) {
const skills = obj(obj(m)?.skills);
for (const id of [str(skill?.iconId), str(skill?.skillId)]) if (skills && id && skills[id]) return str(skills[id]);
const byId = obj(obj(m)?.skillsById);
const alt = byId && skill?.skillId ? byId[skill.skillId] : null;
if (skills && alt && skills[alt]) return str(skills[alt]);
return empty ? uiUrl(m, 'skillIcon/empty') : null;
}
const PROF_KEY = {
PIONEER: 'pioneer', WARRIOR: 'warrior', TANK: 'tank', SNIPER: 'sniper', CASTER: 'caster', MEDIC: 'medic',
SUPPORT: 'support', SPECIAL: 'special',
};
/** Profession icon (small white glyph). */
export function profIconUrl(m, profession) {
const k = PROF_KEY[String(profession || '').toUpperCase()];
return k ? str(obj(obj(obj(m)?.prof)?.icon)?.[k]) : null;
}
/** Sub-profession icon from a chess record (`assets.subProfIcon` = 'sub_<id>_icon'). */
export function subProfIconUrl(m, chess) {
const sub = obj(obj(obj(m)?.prof)?.sub);
if (!sub) return null;
const raw = str(chess?.assets?.subProfIcon);
const id = raw ? raw.replace(/^sub_/, '').replace(/_icon$/, '') : str(chess?.subProfessionId);
return id ? str(sub[id]) : null;
}
/** Bond glyph (white; tint in CSS). */
export function bondIconUrl(m, bondId) {
return bondId ? str(obj(obj(m)?.bonds)?.[bondId]) : null;
}
/** Band (strategy) icon. */
export function bandIconUrl(m, bandId) {
return bandId ? str(obj(obj(m)?.bands)?.[bandId]) : null;
}
/** Item icon for an items.json record (by trapId / iconId) or a raw trap id. */
export function itemIconUrl(m, item) {
const items = obj(obj(m)?.items);
if (!items) return null;
if (typeof item === 'string') return str(items[item]);
return str(items[item?.iconId]) || str(items[item?.trapId]);
}
/** Enemy icon (manifest keeps fallbacks resolved). */
export function enemyIconUrl(m, enemyKey) {
const e = enemyKey ? obj(obj(obj(m)?.enemies)?.[enemyKey]) : null;
if (e?.icon) return str(e.icon);
// `_2` / `_3` variants fall back to their base enemy
const base = typeof enemyKey === 'string' ? enemyKey.replace(/_\d+$/, '') : null;
return base && base !== enemyKey ? str(obj(obj(obj(m)?.enemies)?.[base])?.icon) : null;
}
/** Token avatar, falling back to its owner operator's avatar. */
export function tokenAvatarUrl(m, tokenId) {
const tokens = obj(obj(m)?.tokens);
const t = tokenId ? obj(tokens?.[tokenId]) : null;
if (t?.avatar) return str(t.avatar);
if (t?.owner) return str(obj(obj(obj(m)?.chars)?.[t.owner])?.avatar);
return null;
}
/** Faction (特训敌人 type) icon: `enemyTypeIcon/<icon>`. */
export function factionIconUrl(m, iconId) {
return iconId ? uiUrl(m, `enemyTypeIcon/${iconId}`) : null;
}
/** Title (评语) icon: `titleIcon/<picId>`. */
export function titleIconUrl(m, picId) {
return picId ? uiUrl(m, `titleIcon/${picId}`) : null;
}
/** Greek type letters of some modules (ISW-α, …) → the Latin letter of the client's icon file names (isw-a). */
const GREEK = { 'α': 'a', 'β': 'b', 'γ': 'g', 'δ': 'd', 'Δ': 'd' };
/** Lower-cased key → path index of a local `groups.module` object (built once per manifest object). */
const moduleIconIndex = new WeakMap();
/**
* Official module (uniequip) TYPE icon from the local-client art (DESIGN §13 / §16): `data/local-assets.json`
* `groups.module[<type>]`, matched case-insensitively against the module's typeName ('MAR-X' → key 'mar-x', 'PRI-X' →
* key 'PRI-X' — the client's file names are mixed case). null when the manifest, the group or the entry is missing
* (callers draw the lettered tile / type text instead).
* @param {any} local the local-art manifest (`data.get('local')`) or null
* @param {string} typeName e.g. 'MAR-X'
*/
export function moduleTypeIconUrl(local, typeName) {
const g = obj(obj(obj(local)?.groups)?.module);
const t = str(typeName)?.trim();
if (!g || !t) return null;
const exact = str(obj(g[t])?.path);
if (exact) return exact;
let idx = moduleIconIndex.get(g);
if (!idx) {
idx = new Map();
for (const [k, v] of Object.entries(g)) {
const p = str(obj(v)?.path);
if (p && !idx.has(k.toLowerCase())) idx.set(k.toLowerCase(), p);
}
moduleIconIndex.set(g, idx);
}
const key = t.replace(/[αβγδΔ]/g, (c) => GREEK[c]).toLowerCase();
return idx.get(key) || null;
}
/**
* Icon for an m.private.effects entry: { iconKind: 'band'|'choice'|'team'|'item'|'garrison', iconId }.
* @param {any} m
* @param {{ iconKind?: string, iconId?: string }} eff
*/
export function effectIconUrl(m, eff) {
const kind = eff?.iconKind;
const id = str(eff?.iconId);
if (kind === 'band') return bandIconUrl(m, id) || (id && id.startsWith('icon_') ? bandIconUrl(m, `band_${id.slice(5)}`) : null);
if (kind === 'item') return itemIconUrl(m, id);
if (kind === 'garrison') return uiUrl(m, `garrisonTypeIcon/${id || 's_icon_bond'}`) || uiUrl(m, 'garrisonTypeIcon/s_icon_bond');
if (kind === 'team') return uiUrl(m, `buffIcon/${id && id.startsWith('icon_') ? id : 'icon_team_buff'}`);
if (kind === 'choice') return uiUrl(m, `buffIcon/${id && id.startsWith('icon_') ? id : 'icon_player_buff'}`);
return id ? uiUrl(m, `buffIcon/${id}`) : null;
}