卫戍协议:盟约 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

+618
View File
@@ -0,0 +1,618 @@
// assets.js — asset manifest (/data/assets.json, docs/ASSETS.md) URL helpers, image cache/preload, and the
// Spine loader (LRU + refcount + memory budget + timeout + concurrency cap) used by the battlefield renderer.
//
// Pure at import time: no PIXI, no DOM access until a loader actually runs (Node tests import this file).
//
// import { assets } from './assets.js'; // browser singleton (manifest fetched on first use)
// await assets.ready(); // manifest loaded (or failed → every helper returns null)
// assets.avatar('char_002_amiya', { e2: true }) // URL or null
// const data = await assets.spine.acquire(entry); // spineData (PIXI.spine) — throws on failure/timeout
// assets.spine.release(entry);
// await assets.local(); // optional local-client art manifest (data/local-assets.json,
// assets.localUrl('map/autochess', 'TX_autochessi_D') DESIGN §13) → URL or null (never required)
//
// Every URL helper is also exported as a pure function taking the manifest first (`avatarUrl(manifest, …)`),
// so it can be unit tested without a browser. Helpers never throw on unknown ids — they return null and the
// caller falls back (docs/ASSETS.md "Other fallbacks").
const isObj = (v) => v !== null && typeof v === 'object' && !Array.isArray(v);
const str = (v) => (typeof v === 'string' && v ? v : null);
const get = (o, k) => (isObj(o) && Object.hasOwn(o, k) ? o[k] : undefined);
// ---- pure URL helpers (manifest first) ---------------------------------------------------------------------
/** Strip a trailing `_1` / `_2` art-phase suffix (data/chess.json asset ids). */
export function baseCharId(id) {
const s = str(id);
if (!s) return null;
return s.replace(/_(1|2)$/, '');
}
/**
* Operator avatar. Accepts a charId (`char_002_amiya`) or an asset id with the E2 suffix (`char_002_amiya_2`).
* `opts.e2` prefers the E2 art. Falls back to the base avatar, then null.
*/
export function avatarUrl(m, id, opts) {
const s = str(id);
if (!s) return null;
const direct = get(get(m, 'chars'), s);
let rec = direct, e2 = !!(opts && opts.e2);
if (!rec) {
const base = baseCharId(s);
rec = get(get(m, 'chars'), base);
if (rec && /_2$/.test(s)) e2 = true;
}
if (rec) return str(e2 && rec.avatarE2) || str(rec.avatar) || null;
return null;
}
/** Operator half-body portrait (`_1` base, `_2` E2). */
export function portraitUrl(m, id, opts) {
const s = str(id);
if (!s) return null;
let rec = get(get(m, 'chars'), s), e2 = !!(opts && opts.e2);
if (!rec) {
rec = get(get(m, 'chars'), baseCharId(s));
if (rec && /_2$/.test(s)) e2 = true;
}
if (!rec) return null;
return str(e2 && rec.portraitE2) || str(rec.portrait) || null;
}
export function enemyIconUrl(m, enemyId) {
const rec = get(get(m, 'enemies'), str(enemyId) || '');
return str(rec && rec.icon);
}
/** Token avatar, falling back to its owner's avatar (docs/ASSETS.md token fallbacks). */
export function tokenAvatarUrl(m, tokenId) {
const rec = get(get(m, 'tokens'), str(tokenId) || '');
if (!rec) return null;
return str(rec.avatar) || (rec.owner ? avatarUrl(m, rec.owner) : null) || str(get(get(get(m, 'prof'), 'battlecard'), 'token'));
}
export const bondIconUrl = (m, bondId) => str(get(get(m, 'bonds'), str(bondId) || ''));
export const bandIconUrl = (m, bandId) => str(get(get(m, 'bands'), str(bandId) || ''));
/** Item icon by trapId (`trap_1041_acarm041`) or item record (`{ trapId | iconId }`). */
export function itemIconUrl(m, item) {
const id = isObj(item) ? (str(item.iconId) || str(item.trapId)) : str(item);
return id ? str(get(get(m, 'items'), id)) : null;
}
/** Skill icon by iconId, or by skillId via `skillsById`. Falls back to the empty skill icon. */
export function skillIconUrl(m, id, opts) {
const fallback = !(opts && opts.fallback === false);
const s = str(id);
const skills = get(m, 'skills');
let url = s ? str(get(skills, s)) : null;
if (!url && s) {
const icon = str(get(get(m, 'skillsById'), s));
if (icon) url = str(get(skills, icon));
}
return url || (fallback ? uiUrl(m, 'skillIcon/empty') : null);
}
/** UI sprite by `group/key` (e.g. 'battle/sprite_shadow'). */
export const uiUrl = (m, name) => str(get(get(m, 'ui'), str(name) || ''));
/** Profession icon: kind 'icon' | 'large' | 'battlecard'; profession in any case (SNIPER → sniper). */
export function profIconUrl(m, profession, kind = 'icon') {
const p = str(profession);
if (!p) return null;
return str(get(get(get(m, 'prof'), kind), p.toLowerCase()));
}
/** Sub-profession icon by subProfessionId or `sub_<id>_icon` asset id. */
export function subProfIconUrl(m, sub) {
const s = str(sub);
if (!s) return null;
const id = s.replace(/^sub_/, '').replace(/_icon$/, '');
return str(get(get(get(m, 'prof'), 'sub'), id));
}
/**
* Spine manifest entry for a unit asset id (operator charId, token id, enemy id). `opts.back` asks for the Back
* model (operators only; falls back to Front). Enemy aliases are resolved transparently. Returns the Spine
* object of docs/ASSETS.md or null.
*/
export function spineEntry(m, id, opts) {
const s = str(id);
if (!s) return null;
const ch = get(get(m, 'chars'), s) || get(get(m, 'chars'), baseCharId(s));
if (ch && isObj(ch.spine)) {
const sp = (opts && opts.back && isObj(ch.spine.back)) ? ch.spine.back : ch.spine.front;
return validSpine(sp) ? sp : null;
}
const tk = get(get(m, 'tokens'), s);
if (tk) return validSpine(tk.spine) ? tk.spine : null;
const en = get(get(m, 'enemies'), s);
if (en) {
if (validSpine(en.spine)) return en.spine;
const alias = str(en.spineAliasOf);
const al = alias ? get(get(m, 'enemies'), alias) : null;
return al && validSpine(al.spine) ? al.spine : null;
}
return null;
}
/** Whether an operator/token/enemy has a Back model. */
export function hasBackSpine(m, id) {
const ch = get(get(m, 'chars'), str(id) || '') || get(get(m, 'chars'), baseCharId(id) || '');
return !!(ch && isObj(ch.spine) && validSpine(ch.spine.back));
}
export function validSpine(sp) {
return isObj(sp) && typeof sp.skel === 'string' && /^\/[^\s]*\.skel$/.test(sp.skel) && typeof sp.atlas === 'string' && isObj(sp.anims);
}
/** Best 2D picture for a unit asset id (operator avatar, token avatar, enemy icon, item icon). */
export function unitPictureUrl(m, id) {
return avatarUrl(m, id) || tokenAvatarUrl(m, id) || enemyIconUrl(m, id) || itemIconUrl(m, id) || null;
}
// ---- audio ---------------------------------------------------------------------------------------------------
/** BGM `{ intro?, loop }` for 'lobby' | 'prep' | 'combat' | 'boss', or a boss id. */
export function bgmEntry(m, kind) {
const a = get(m, 'audio');
const b = get(get(a, 'bossBgm'), str(kind) || '') || get(get(a, 'bgm'), str(kind) || '');
return isObj(b) && str(b.loop) ? b : null;
}
/** SFX URL: group 'ui' | 'battle', key e.g. 'buy'. */
export const sfxUrl = (m, group, key) => str(get(get(get(get(m, 'audio'), 'sfx'), str(group) || ''), str(key) || ''));
/** Per-unit SFX: kind 'attack' | 'hit' | 'skill' | 'die' | 'born'; `skillIndex` picks `skills[i]` when present. */
export function unitSfxUrl(m, id, kind, skillIndex) {
const u = get(get(get(get(m, 'audio'), 'sfx'), 'units'), str(id) || '') || get(get(get(get(m, 'audio'), 'sfx'), 'units'), baseCharId(id) || '');
if (!isObj(u)) return null;
if (kind === 'skill' && Number.isInteger(skillIndex) && isObj(u.skills)) {
const s = str(u.skills[skillIndex]);
if (s) return s;
}
return str(u[kind]);
}
// ---- generic caches ----------------------------------------------------------------------------------------
/**
* LRU cache of refcounted async resources. `load(key, arg)` → Promise<value>; `unload(key, value, record)` frees it
* (`record` is the cache record the value belonged to).
* Entries with refs > 0 are never evicted; the cache may exceed `max` while everything is in use.
* Failures are remembered for `failTtl` ms (so a missing model is not refetched every frame) and rethrown.
* Memory budget (optional): `weigh(key, value, arg)` → cost of a ready value; idle (refs 0) ready entries are evicted,
* least recently used first, while their total weight exceeds `maxIdleWeight` — or `quietWeight` while nothing at all
* is referenced (no scene on screen) — once they have been idle for `idleGrace` ms (a scene switch releases and
* re-acquires its models within that window, so it never reloads them); a timer sweeps what the grace held back.
*/
export class RefLru {
constructor({ load, unload = () => {}, max = 60, timeout = 20000, concurrency = 6, failTtl = 60000, now = () => Date.now(),
weigh = null, maxIdleWeight = Infinity, quietWeight, idleGrace = 0, timers } = {}) {
if (typeof load !== 'function') throw new TypeError('RefLru: load required');
this._load = load;
this._unload = unload;
this.max = Math.max(1, max | 0);
this.timeout = timeout;
this.concurrency = Math.max(1, concurrency | 0);
this.failTtl = failTtl;
this.now = now;
this._weigh = typeof weigh === 'function' ? weigh : null;
const budget = (v, d) => (typeof v === 'number' && v >= 0 ? v : d);
this.maxIdleWeight = budget(maxIdleWeight, Infinity);
this.quietWeight = budget(quietWeight, this.maxIdleWeight);
this.idleGrace = budget(idleGrace, 0);
this._timers = timers && typeof timers.set === 'function' ? timers : {
set: (fn, ms) => { const t = setTimeout(fn, ms); t?.unref?.(); return t; },
clear: (t) => clearTimeout(t),
};
this._sweep = null; // { timer, at } pending weight sweep
/** @type {Map<string, { key, promise, value, state: 'loading'|'ready'|'failed', refs: number, used: number, weight: number, idleSince: number|null, error?, failedAt? }>} */
this.map = new Map();
this._active = 0;
this._queue = [];
this._tick = 0;
}
/** Synchronous peek: the value if ready (does not change refs). */
peek(key) {
const e = this.map.get(key);
return e && e.state === 'ready' ? e.value : null;
}
has(key) { return this.map.has(key); }
get size() { return this.map.size; }
/** Acquire a reference; resolves to the value (or rejects). Always pair with release(key). */
acquire(key, arg) {
let e = this.map.get(key);
if (e && e.state === 'failed' && this.now() - e.failedAt > this.failTtl) { this.map.delete(key); e = null; }
if (!e) {
e = { key, promise: null, value: null, state: 'loading', refs: 0, used: ++this._tick, weight: 0, idleSince: null };
this.map.set(key, e);
e.promise = this._schedule(() => this._withTimeout(this._load(key, arg), key)).then(
(v) => {
if (this.map.get(key) !== e) { try { this._unload(key, v, e); } catch { /* ignore */ } return v; }
e.value = v; e.state = 'ready';
if (this._weigh) { let w = 0; try { w = Number(this._weigh(key, v, arg)); } catch { /* ignore */ } e.weight = w > 0 ? w : 0; }
this._evict();
return v;
},
(err) => {
e.state = 'failed'; e.error = err; e.failedAt = this.now();
throw err;
},
);
e.promise.catch(() => {});
}
e.refs++;
e.idleSince = null;
e.used = ++this._tick;
return e.promise;
}
/** Drop one reference (never below 0). */
release(key) {
const e = this.map.get(key);
if (!e) return;
if (e.refs > 0 && --e.refs === 0) e.idleSince = this.now();
e.used = ++this._tick;
this._evict();
}
/**
* Evict least-recently-used unreferenced ready/failed entries above `max`, then idle ready entries past their
* grace while the idle weight is over budget (`maxIdleWeight`, or `quietWeight` when nothing is referenced).
*/
_evict() {
if (this.map.size > this.max) {
const idle = [...this.map.values()].filter((e) => e.refs === 0 && e.state !== 'loading').sort((a, b) => a.used - b.used);
for (const e of idle) {
if (this.map.size <= this.max) break;
this._drop(e);
}
}
if (!this._weigh) return;
let refs = 0, idleWeight = 0;
const idle = [];
for (const e of this.map.values()) {
refs += e.refs;
if (e.refs === 0 && e.state === 'ready') { idleWeight += e.weight; idle.push(e); }
}
const budget = refs === 0 ? this.quietWeight : this.maxIdleWeight;
if (idleWeight <= budget) return;
idle.sort((a, b) => a.used - b.used);
const now = this.now();
let wait = Infinity;
for (const e of idle) {
if (idleWeight <= budget) break;
const age = now - (e.idleSince ?? now);
if (age < this.idleGrace) { wait = Math.min(wait, this.idleGrace - age); continue; }
idleWeight -= e.weight;
this._drop(e);
}
if (idleWeight > budget && wait < Infinity) this._sweepIn(wait);
}
_drop(e) {
this.map.delete(e.key);
if (e.state === 'ready') { try { this._unload(e.key, e.value, e); } catch { /* ignore */ } }
}
/** Re-run eviction in `ms` (keeps the earliest pending sweep). */
_sweepIn(ms) {
const at = this.now() + ms;
if (this._sweep && this._sweep.at <= at) return;
if (this._sweep) this._timers.clear(this._sweep.timer);
const timer = this._timers.set(() => { this._sweep = null; this._evict(); }, Math.ceil(ms) + 5);
this._sweep = { timer, at };
}
/** Unload everything (refs ignored). */
clear() {
if (this._sweep) { this._timers.clear(this._sweep.timer); this._sweep = null; }
const all = [...this.map.values()];
this.map.clear();
for (const e of all) if (e.state === 'ready') { try { this._unload(e.key, e.value, e); } catch { /* ignore */ } }
}
stats() {
let ready = 0, loading = 0, failed = 0, refs = 0, weight = 0, idleWeight = 0;
for (const e of this.map.values()) {
if (e.state === 'ready') ready++; else if (e.state === 'loading') loading++; else failed++;
refs += e.refs;
weight += e.weight || 0;
if (e.refs === 0) idleWeight += e.weight || 0;
}
return { size: this.map.size, ready, loading, failed, refs, weight, idleWeight, active: this._active, queued: this._queue.length };
}
_schedule(fn) {
return new Promise((resolve, reject) => {
const run = () => {
this._active++;
let p;
try { p = Promise.resolve(fn()); } catch (err) { p = Promise.reject(err); }
p.then(resolve, reject).finally(() => {
this._active--;
const next = this._queue.shift();
if (next) next();
});
};
if (this._active < this.concurrency) run(); else this._queue.push(run);
});
}
_withTimeout(p, key) {
if (!(this.timeout > 0)) return p;
return new Promise((resolve, reject) => {
const t = setTimeout(() => reject(new Error(`load timeout: ${key}`)), this.timeout);
p.then((v) => { clearTimeout(t); resolve(v); }, (err) => { clearTimeout(t); reject(err); });
});
}
}
/** URL of a local-client art entry from a data/local-assets.json manifest (`{ groups: { g: { name: { path } } } }`). */
export function localAssetUrl(m, group, name) {
const e = get(get(get(m, 'groups'), str(group) || ''), str(name) || '');
return isObj(e) ? str(e.path) : null;
}
/** Default browser image loader. */
export function loadImageElement(url) {
return new Promise((resolve, reject) => {
if (typeof Image === 'undefined') { reject(new Error('no Image in this environment')); return; }
const img = new Image();
img.decoding = 'async';
img.onload = () => resolve(img);
img.onerror = () => reject(new Error(`image failed: ${url}`));
img.src = url;
});
}
/** Default Spine loader: PIXI.Assets.load(skel) → spineData (needs globalThis.PIXI + PIXI.spine). */
export async function loadSpineData(entry) {
const PIXI = globalThis.PIXI;
if (!PIXI || !PIXI.Assets || !PIXI.spine) throw new Error('PIXI / pixi-spine not loaded');
const res = await PIXI.Assets.load(entry.skel);
const data = res && (res.spineData || res);
if (!data || !Array.isArray(data.animations)) throw new Error(`bad spine data: ${entry.skel}`);
return data;
}
/**
* Estimated memory (bytes) held by a parsed skeleton (pixi-spine 3.8 SkeletonData): its typed/plain arrays (mesh
* vertices/UVs/triangles, timeline frames/curves, deform frames, draw orders) plus a flat cost per object. A parsed
* .skel costs ~10–15× its file size (0.1–1.4 MB files → ~1–12 MB of heap + backing store), mostly timelines, so a
* count cap alone pins anywhere from tens to hundreds of MB; the spine LRU budgets idle skeletons by this weight.
* Never throws; unknown shapes weigh `SPINE_WEIGHT_MIN`.
*/
export const SPINE_WEIGHT_MIN = 64 * 1024;
export function spineDataWeight(data) {
if (!data || typeof data !== 'object') return SPINE_WEIGHT_MIN;
const OBJ = 96, NUM = 8;
const list = (a) => (Array.isArray(a) ? a : []);
const arr = (a) => (!a || typeof a !== 'object' ? 0 : ArrayBuffer.isView(a) ? a.byteLength + OBJ
: Array.isArray(a) ? a.length * NUM + OBJ : 0);
let w = 0;
try {
for (const k of ['bones', 'slots', 'events', 'ikConstraints', 'transformConstraints', 'pathConstraints']) w += list(data[k]).length * OBJ * 2;
for (const skin of list(data.skins)) {
for (const slot of list(skin && skin.attachments)) {
if (!slot || typeof slot !== 'object') continue;
for (const at of Object.values(slot)) {
w += OBJ * 2;
if (at && typeof at === 'object') for (const f of ['vertices', 'regionUVs', 'uvs', 'triangles', 'bones', 'edges', 'lengths', 'offset']) w += arr(at[f]);
}
}
}
for (const anim of list(data.animations)) {
w += OBJ;
for (const t of list(anim && anim.timelines)) {
if (!t || typeof t !== 'object') continue;
w += OBJ + arr(t.frames) + arr(t.curves) + arr(t.attachmentNames) + arr(t.events);
for (const f of list(t.frameVertices)) w += arr(f);
for (const d of list(t.drawOrders)) w += arr(d);
}
}
} catch { /* exotic shape: whatever was counted */ }
return Math.max(SPINE_WEIGHT_MIN, w);
}
/** Atlas page image URLs of a Spine manifest entry (`textures`, else the `<skel name>.png` next to it). */
export function spinePages(entry) {
if (!isObj(entry)) return [];
const list = Array.isArray(entry.textures) ? entry.textures.filter((u) => str(u)) : [];
if (list.length) return list;
const skel = str(entry.skel);
return skel ? [skel.replace(/\.skel$/, '.png')] : [];
}
/**
* Free a skeleton evicted from the LRU: the skeleton asset, its .atlas asset (pixi-spine loads the atlas as a
* separate cached asset, whose unload disposes the page textures on the GPU) AND the atlas page images: the atlas
* parser loads each page through the loader (`loader.load({ src })`), so the decoded page (an ImageBitmap) stays in
* `PIXI.Assets.loader.promiseCache` until that URL is unloaded too. Pages listed in `keep` (still used by another
* cached skeleton) are left alone.
* @param {object} entry manifest spine entry { skel, atlas?, textures? }
* @param {any} [_value] the unloaded spineData (unused)
* @param {Set<string>} [keep] page URLs not to unload
*/
export function unloadSpineData(entry, _value, keep) {
const PIXI = globalThis.PIXI;
const skel = entry && entry.skel;
if (!skel || !PIXI?.Assets?.unload) return;
const atlas = (typeof entry.atlas === 'string' && entry.atlas) || skel.replace(/\.skel$/, '.atlas');
const loader = PIXI.Assets.loader;
const unload = (url, direct) => {
try {
const p = direct && loader && typeof loader.unload === 'function' ? loader.unload(url) : PIXI.Assets.unload(url);
Promise.resolve(p).catch(() => {});
} catch { /* ignore */ }
};
unload(skel, false);
unload(atlas, false);
// pages were loaded by the atlas parser straight through the loader (never through Assets.load / the resolver)
for (const url of spinePages(entry)) if (!(keep && keep.has(url))) unload(url, true);
}
// ---- store ---------------------------------------------------------------------------------------------------
/** Spine LRU memory budget: estimated bytes (`spineDataWeight`) of idle skeletons kept while a scene is shown. */
export const SPINE_IDLE_BYTES = 48 * 1024 * 1024;
/** How long a released skeleton is safe from the weight budget (a prep ⇄ battle switch re-acquires within it). */
export const SPINE_IDLE_GRACE_MS = 15000;
/**
* Create an asset store.
* @param {{ url?: string, fetch?: typeof fetch, manifest?: object, loadImage?: (url) => Promise<any>,
* loadSpine?: (entry) => Promise<any>, unloadSpine?: (entry, value, keepPages:Set<string>) => void, spineMax?: number,
* spineTimeout?: number, spineWeigh?: (key, spineData) => number, spineIdleBytes?: number,
* spineQuietBytes?: number, spineIdleGrace?: number, spineTimers?: { set, clear }, spineNow?: () => number }} [opts]
*/
export function createAssets(options) {
const opts = options && typeof options === 'object' ? options : {};
const url = opts.url || '/data/assets.json';
const localUrl = opts.localUrl || '/data/local-assets.json';
let localPromise = isObj(opts.localManifest) ? Promise.resolve(opts.localManifest) : null;
let localManifest = isObj(opts.localManifest) ? opts.localManifest : null;
const doFetch = opts.fetch || ((...a) => globalThis.fetch(...a));
let manifest = isObj(opts.manifest) ? opts.manifest : null;
let readyPromise = manifest ? Promise.resolve(manifest) : null;
const loadImage = opts.loadImage || loadImageElement;
const images = new Map(); // url → { promise, value }
function ready() {
if (!readyPromise) {
readyPromise = (async () => {
try {
const res = await doFetch(url, { cache: 'no-cache' });
if (!res || !res.ok) throw new Error(`HTTP ${res ? res.status : '???'}`);
const json = await res.json();
manifest = isObj(json) ? json : {};
} catch (err) {
console.warn(`[assets] ${url} unavailable (${err?.message || err}); using fallbacks`);
manifest = {};
}
return manifest;
})();
}
return readyPromise;
}
const spineEntries = new Map(); // skel URL → manifest entry (for the atlas / page URLs on unload)
const spine = new RefLru({
load: (key, entry) => { spineEntries.set(key, entry); return (opts.loadSpine || loadSpineData)(entry); },
unload: (key, value, rec) => {
// a late value of an entry that was dropped while loading, with the key already loading/cached again: the
// PIXI caches are shared by URL, so unloading would pull the resources out from under the new entry
const cur = spine.map.get(key);
if (cur && rec && cur !== rec) return;
const e = spineEntries.get(key) || { skel: key };
spineEntries.delete(key);
const keep = new Set();
for (const [k, other] of spineEntries) if (k !== key && spine.map.has(k)) for (const u of spinePages(other)) keep.add(u);
(opts.unloadSpine || unloadSpineData)(e, value, keep);
},
max: opts.spineMax ?? 60,
timeout: opts.spineTimeout ?? 20000,
concurrency: opts.spineConcurrency ?? 6,
// memory budget (a parsed skeleton holds ~0.1–12 MB): idle skeletons beyond SPINE_IDLE_BYTES go, and all of
// them once no scene references any (lobby / room), each after a grace that covers a prep ⇄ battle switch
weigh: opts.spineWeigh || ((key, value) => spineDataWeight(value)),
maxIdleWeight: opts.spineIdleBytes ?? SPINE_IDLE_BYTES,
quietWeight: opts.spineQuietBytes ?? 0,
idleGrace: opts.spineIdleGrace ?? SPINE_IDLE_GRACE_MS,
timers: opts.spineTimers,
now: opts.spineNow,
});
/** Image element (cached; failures resolve to null). */
function image(u) {
const s = str(u);
if (!s) return Promise.resolve(null);
let e = images.get(s);
if (!e) {
e = { value: null, done: false, promise: null };
e.promise = Promise.resolve().then(() => loadImage(s)).then(
(img) => { e.value = img; e.done = true; return img; },
() => { e.done = true; return null; },
);
images.set(s, e);
}
return e.promise;
}
/** Optional local-client art manifest (null when absent: every consumer falls back). Fetched once. */
function local() {
if (!localPromise) {
localPromise = (async () => {
try {
const res = await doFetch(localUrl, { cache: 'no-cache' });
if (!res || !res.ok) return null;
const json = await res.json();
localManifest = isObj(json) && isObj(json.groups) ? json : null;
} catch { localManifest = null; }
return localManifest;
})();
}
return localPromise;
}
const m = () => manifest || {};
return {
ready,
local,
localUrl: (group, name) => localAssetUrl(localManifest, group, name),
get manifest() { return manifest; },
get loaded() { return !!manifest; },
avatar: (id, o) => avatarUrl(m(), id, o),
portrait: (id, o) => portraitUrl(m(), id, o),
enemyIcon: (id) => enemyIconUrl(m(), id),
tokenAvatar: (id) => tokenAvatarUrl(m(), id),
bondIcon: (id) => bondIconUrl(m(), id),
bandIcon: (id) => bandIconUrl(m(), id),
itemIcon: (item) => itemIconUrl(m(), item),
skillIcon: (id, o) => skillIconUrl(m(), id, o),
profIcon: (p, kind) => profIconUrl(m(), p, kind),
subProfIcon: (s) => subProfIconUrl(m(), s),
ui: (name) => uiUrl(m(), name),
picture: (id) => unitPictureUrl(m(), id),
spineEntry: (id, o) => spineEntry(m(), id, o),
hasBack: (id) => hasBackSpine(m(), id),
audio: {
bgm: (kind) => bgmEntry(m(), kind),
sfx: (group, key) => sfxUrl(m(), group, key),
unit: (id, kind, idx) => unitSfxUrl(m(), id, kind, idx),
},
/** Cached image element promise (null on failure). */
image,
/** Already-loaded image element or null (sync). */
imageNow(u) { const e = images.get(str(u) || ''); return e && e.done ? e.value : null; },
/**
* Preload URLs; `onProgress(done, total, url)` after each. Resolves to { ok, failed } counts (never rejects).
*/
async preload(urls, onProgress) {
const list = [...new Set((Array.isArray(urls) ? urls : []).map(str).filter(Boolean))];
let done = 0, ok = 0;
await Promise.all(list.map((u) => image(u).then((img) => {
done++;
if (img) ok++;
try { onProgress?.(done, list.length, u); } catch { /* ignore */ }
})));
return { ok, failed: list.length - ok, total: list.length };
},
/** Spine data LRU: acquire(entry) → Promise<spineData>; release(entry); peek(entry). */
spine: {
acquire: (entry) => (validSpine(entry) ? spine.acquire(entry.skel, entry) : Promise.reject(new Error('no spine entry'))),
release: (entry) => { if (entry && entry.skel) spine.release(entry.skel); },
peek: (entry) => (entry && entry.skel ? spine.peek(entry.skel) : null),
stats: () => spine.stats(),
clear: () => spine.clear(),
cache: spine,
},
};
}
/** Browser singleton. */
export const assets = createAssets();
+622
View File
@@ -0,0 +1,622 @@
// Audio manager (Web Audio): BGM per phase, UI SFX, per-unit battle SFX. Never throws.
//
// Sources: data/assets.json → audio (docs/ASSETS.md):
// bgm { lobby, prep, combat, boss: { intro?, loop } }, bossBgm { [bossId]: { intro?, loop } },
// sfx.ui { click, buy, sell, refresh, freeze, levelup, merge, equip, ready, timer, yourTurn, … },
// sfx.battle { deploy, tokenDeploy, charDie, tokenDie?, enemyDie, enemyHit, heal, killCoin, … },
// sfx.units { [charId|tokenId|enemyId]: { attack?, hit?, skill?, die?, born? } }.
//
// - The AudioContext is created on the first user gesture (pointerdown/keydown/touchend), so browsers
// never block or warn; everything requested before that is remembered (BGM) or dropped (SFX).
// - Channels: master → { bgm, sfx } gains; volumes from settings (0..1) + mute. Tab hidden ⇒ suspend.
// - BGM: `intro` then `loop` (1 s crossfade); switching tracks fades out/in (0.8 s). The same loop URL
// keeps playing across phases (prep and combat share a track).
// - Battle SFX from `b.ev` tuples (`handleBattleEvents`): at most MAX_VOICES concurrent unit sounds,
// a per-unit cooldown and a per-URL minimum gap (SfxLimiter), so a 60-unit fight stays listenable.
// - Deaths/deployments follow the official per-class defaults (unitSoundClass): only operators play the
// operator-knocked-down sound; summons use the token sounds; a summon used up by its own effect (fx `consumed`,
// 香槟炸弹) plays its impact sound instead of a death sound.
// - Buffers are fetched once and cached (LRU); failed fetch/decode ⇒ silent (logged once as a warning).
//
// `bgmKeyFor(route, pub)` picks the track for the current screen/phase (main.js calls `audio.install()`,
// which follows the store).
import { PHASE } from '../../shared/constants.js';
const MAX_VOICES = 8;
const UNIT_COOLDOWN_MS = 160;
const URL_GAP_MS = 45;
const BUFFER_CACHE = 180;
const XFADE_S = 1;
const FADE_S = 0.8;
// ---- pure helpers (unit-tested) -----------------------------------------------------------------------
/**
* BGM key for a route + match phase.
* @param {'title'|'lobby'|'room'|'game'|string} route
* @param {any} pub m.public (may be null)
* @returns {string|null} 'lobby' | 'prep' | 'combat' | 'boss' | 'boss:<bossId>' | null
*/
export function bgmKeyFor(route, pub) {
if (route !== 'game') return route === 'title' || route === 'lobby' || route === 'room' ? 'lobby' : null;
const phase = pub?.phase;
if (!phase) return 'lobby';
switch (phase) {
case PHASE.INFO_CHECK: case PHASE.BAND_DRAFT: case PHASE.BATTLE_CHECK: case PHASE.RESULT: case PHASE.LOBBY:
return 'lobby';
case PHASE.COMBAT: case PHASE.UNITE:
return 'combat';
case PHASE.FINAL_ASSAULT:
return pub.bossId ? `boss:${pub.bossId}` : 'boss';
case PHASE.HIDDEN_CORE:
return pub.hiddenBossId ? `boss:${pub.hiddenBossId}` : pub.bossId ? `boss:${pub.bossId}` : 'boss';
default:
return 'prep';
}
}
/**
* Resolve a BGM key to { intro?, loop } URLs from the manifest (boss:<id> falls back to the generic boss track).
* @param {any} manifest
* @param {string|null} key
* @returns {{ intro: string|null, loop: string }|null}
*/
export function resolveBgm(manifest, key) {
const a = manifest?.audio;
if (!a || !key) return null;
let t = null;
if (key.startsWith('boss:')) t = a.bossBgm?.[key.slice(5)] || a.bgm?.boss;
else t = a.bgm?.[key];
if (!t || typeof t.loop !== 'string') return null;
return { intro: typeof t.intro === 'string' ? t.intro : null, loop: t.loop };
}
/**
* Official sound class of a battle unit (audio_data `battle.ON_UNIT_DEAD|BORN.<class>` defaults):
* 'enemy' | 'char' (operators: b_char_dead “干员被击倒” / b_char_set) | 'token' (summons: b_char_tokendead /
* b_char_tokenset) | 'device' (stage devices: the act crate trap_1105 dies with b_char_tokendead, no born sound).
* Band map characters (预备干员-医疗 / Touch, `char_*` ids) are characters although the sim runs them as tokens.
* @param {{ side?: string, kind?: string, defId?: string, def?: string }|null} info tracked unit (UnitInfo subset)
*/
export function unitSoundClass(info) {
if (!info) return 'char';
if (info.side === 'enemy') return 'enemy';
const id = String(info.defId ?? info.def ?? '');
if (info.kind === 'device') return 'device';
if (info.kind === 'token') return /^char_/.test(id) ? 'char' : 'token';
return 'char';
}
/** URL of the generic token death sound (b_char_tokendead): sfx.battle.tokenDie, else next to charDie. */
function tokenDieUrl(manifest) {
const b = manifest?.audio?.sfx?.battle;
if (typeof b?.tokenDie === 'string') return b.tokenDie;
return typeof b?.charDie === 'string' && /b_char_dead\.mp3$/.test(b.charDie) ? b.charDie.replace(/b_char_dead\.mp3$/, 'b_char_tokendead.mp3') : null;
}
/**
* Death sound of a battle unit ('die' event): the unit's own ON_UNIT_DEAD sound, else its class default — only
* operators play the operator-knocked-down sound (charDie). A summon that fired and was used up (香槟炸弹: its
* explosion is the sound) is silent, and so is an operator leaving without being knocked out, when the event says so
* (`reason` ≠ 'killed').
* @param {any} manifest data/assets.json
* @param {{ side?: string, kind?: string, defId?: string, def?: string, boss?: boolean }|null} info
* @param {{ consumed?: boolean, reason?: string|null }} [o]
* @returns {string|null} sound URL
*/
export function deathSfxUrl(manifest, info, { consumed = false, reason = null } = {}) {
if (!info || consumed) return null;
const cls = unitSoundClass(info);
if (cls === 'char' && reason && reason !== 'killed') return null;
const own = manifest?.audio?.sfx?.units?.[info.def]?.die;
if (typeof own === 'string') return own;
const b = manifest?.audio?.sfx?.battle ?? {};
if (cls === 'enemy') return (info.boss ? b.enemyDieHeavy : null) ?? b.enemyDie ?? null;
if (cls === 'char') return typeof b.charDie === 'string' ? b.charDie : null;
return tokenDieUrl(manifest);
}
/**
* Deployment sound of an allied unit ('deploy' event): its own ON_UNIT_BORN sound, else operators b_char_set
* (sfx.battle.deploy), summons b_char_tokenset (tokenDeploy); stage devices have none.
* @returns {string|null}
*/
export function deploySfxUrl(manifest, info) {
if (!info || info.side === 'enemy') return null;
const own = manifest?.audio?.sfx?.units?.[info.def]?.born;
if (typeof own === 'string') return own;
const b = manifest?.audio?.sfx?.battle ?? {};
const cls = unitSoundClass(info);
if (cls === 'device') return null;
const url = cls === 'token' ? (b.tokenDeploy ?? b.deploy) : b.deploy;
return typeof url === 'string' ? url : null;
}
/** Concurrency + cooldown gate for battle SFX. Pure (time is passed in). */
/** Gestures that may unlock audio: iOS Safari only accepts touchend / click / keydown; pointerdown covers the rest. */
const UNLOCK_EVENTS = ['pointerdown', 'touchend', 'click', 'keydown'];
export class SfxLimiter {
/** @param {{ maxVoices?: number, unitCooldownMs?: number, urlGapMs?: number }} [o] */
constructor(o = {}) {
this.maxVoices = o.maxVoices ?? MAX_VOICES;
this.unitCooldownMs = o.unitCooldownMs ?? UNIT_COOLDOWN_MS;
this.urlGapMs = o.urlGapMs ?? URL_GAP_MS;
this.active = 0;
this.lastByUnit = new Map();
this.lastByUrl = new Map();
}
/**
* Whether a sound may start now; records it when allowed (call `release()` when it ends).
* @param {number} now ms
* @param {string|number|null} unitKey e.g. `${unitId}:atk`
* @param {string} url
*/
tryAcquire(now, unitKey, url) {
if (this.active >= this.maxVoices) return false;
if (unitKey != null) {
const t = this.lastByUnit.get(unitKey);
if (t != null && now - t < this.unitCooldownMs) return false;
}
const u = this.lastByUrl.get(url);
if (u != null && now - u < this.urlGapMs) return false;
if (unitKey != null) this.lastByUnit.set(unitKey, now);
this.lastByUrl.set(url, now);
if (this.lastByUnit.size > 600) this.lastByUnit.clear();
if (this.lastByUrl.size > 400) this.lastByUrl.clear();
this.active += 1;
return true;
}
release() { this.active = Math.max(0, this.active - 1); }
}
// ---- manager -----------------------------------------------------------------------------------------------
export class AudioManager {
/**
* @param {{ getManifest?: () => any, win?: any }} [opts]
*/
constructor(opts = {}) {
this.getManifest = typeof opts.getManifest === 'function' ? opts.getManifest : () => null;
this.win = opts.win ?? (typeof window !== 'undefined' ? window : null);
this.ctx = null;
this.master = null;
this.bgmGain = null;
this.sfxGain = null;
this.volumes = { bgm: 0.6, sfx: 0.8, muted: false };
this.buffers = new Map(); // url → Promise<AudioBuffer|null> (insertion order = LRU)
this.warned = new Set();
this.limiter = new SfxLimiter();
this.uiVoices = 0;
this.wantBgm = null; // desired key (kept while locked)
this.bgm = null; // { key, loopUrl, nodes: [{src, gain}], gain }
this.bgmToken = 0;
this.units = new Map(); // battle unit id → defId
this.lastAttacker = new Map(); // target id → attacker defId
this.consumed = new Set(); // summons used up by their own effect (香槟炸弹 exploded): no death sound
this.installed = false;
this._unlock = this._unlock.bind(this);
this._onVis = this._onVis.bind(this);
}
/** Attach gesture unlock + visibility handling. Idempotent. */
install() {
if (this.installed || !this.win) return;
this.installed = true;
try {
for (const ev of UNLOCK_EVENTS) this.win.addEventListener(ev, this._unlock, { capture: true, passive: true });
this.win.document?.addEventListener?.('visibilitychange', this._onVis);
// iOS / iPadOS: a phone call, Siri or another app puts the context into 'interrupted'; coming back to the page
// (pageshow / focus) resumes it (plus the next gesture, below)
this.win.addEventListener?.('pageshow', this._onVis);
this.win.addEventListener?.('focus', this._onVis);
} catch { /* ignore */ }
}
get unlocked() { return !!this.ctx; }
/**
* First user gesture: create the context. The gesture listeners stay until the context actually runs — iOS Safari
* only counts touchend / click (not pointerdown / touchstart) as activation, so a context created on pointerdown can
* stay 'suspended' until the finger lifts. A 1-sample silent buffer is played inside the gesture (older WebKit only
* unlocks output after something was started in a gesture).
*/
_unlock() {
if (this.ctx) {
const st = this.ctx.state;
if (st === 'running') { this._dropUnlock(); return; }
if (!this.win?.document?.hidden) {
this._primeOutput();
const p = this.ctx.resume?.();
if (p && typeof p.then === 'function') p.then(() => { if (this.ctx?.state === 'running') this._dropUnlock(); }, () => {});
}
return;
}
try {
const AC = this.win?.AudioContext || this.win?.webkitAudioContext;
if (!AC) return;
this.ctx = new AC();
this.master = this.ctx.createGain();
this.bgmGain = this.ctx.createGain();
this.sfxGain = this.ctx.createGain();
this.bgmGain.connect(this.master);
this.sfxGain.connect(this.master);
this.master.connect(this.ctx.destination);
// iOS / iPadOS: a call, Siri or another app's audio moves a running context to 'interrupted' (or 'suspended');
// a resume without a gesture may then be refused — listen for the next gesture again (dropped once it runs)
try {
this.ctx.addEventListener?.('statechange', () => {
const s = this.ctx?.state;
if (s && s !== 'running' && s !== 'closed' && !this.win?.document?.hidden) this._armUnlock();
});
} catch { /* ignore */ }
this._applyVolumes();
this._primeOutput();
if (this.ctx.state === 'running') this._dropUnlock();
else {
const p = this.ctx.resume?.();
if (p && typeof p.then === 'function') p.then(() => { if (this.ctx?.state === 'running') this._dropUnlock(); }, () => {});
}
if (this.wantBgm) { const k = this.wantBgm; this.wantBgm = null; this.playBgm(k); }
} catch (err) {
this._warn('ctx', err);
this.ctx = null;
}
}
/** (Re-)attach the gesture listeners after the context stopped running while visible (see _unlock / _onVis). */
_armUnlock() {
if (!this._unlockDropped || !this.win) return;
this._unlockDropped = false;
try { for (const ev of UNLOCK_EVENTS) this.win.addEventListener(ev, this._unlock, { capture: true, passive: true }); } catch { /* ignore */ }
}
/** Remove the first-gesture listeners (the context runs). */
_dropUnlock() {
if (this._unlockDropped || !this.win) return;
this._unlockDropped = true;
try { for (const ev of UNLOCK_EVENTS) this.win.removeEventListener(ev, this._unlock, { capture: true }); } catch { /* ignore */ }
}
/** Start a silent 1-sample buffer (inside a user gesture: unlocks output on older WebKit). */
_primeOutput() {
try {
const c = this.ctx;
if (!c || typeof c.createBuffer !== 'function') return;
const src = c.createBufferSource();
src.buffer = c.createBuffer(1, 1, c.sampleRate || 44100);
src.connect(c.destination);
src.start ? src.start(0) : src.noteOn?.(0);
} catch { /* ignore */ }
}
_onVis() {
try {
if (!this.ctx) return;
if (this.win?.document?.hidden) this.ctx.suspend().catch(() => {});
else if (this.ctx.state !== 'running') {
// back on the page: resume, and keep a gesture ready in case the browser wants one first (iOS after a call)
this._armUnlock();
this.ctx.resume().then(() => { if (this.ctx?.state === 'running') this._dropUnlock(); }, () => {});
}
} catch { /* ignore */ }
}
_warn(key, err) {
if (this.warned.has(key)) return;
this.warned.add(key);
try { console.warn(`[audio] ${key} unavailable`, err?.message || err || ''); } catch { /* ignore */ }
}
/**
* Set channel volumes (0..1) and mute.
* @param {{ bgm?: number, sfx?: number, muted?: boolean }} v
*/
setVolumes(v) {
const n = (x, d) => (Number.isFinite(x) ? Math.max(0, Math.min(1, x)) : d);
this.volumes = { bgm: n(v?.bgm, this.volumes.bgm), sfx: n(v?.sfx, this.volumes.sfx), muted: typeof v?.muted === 'boolean' ? v.muted : this.volumes.muted };
this._applyVolumes();
}
_applyVolumes() {
if (!this.ctx) return;
try {
const t = this.ctx.currentTime;
this.master.gain.setTargetAtTime(this.volumes.muted ? 0 : 1, t, 0.03);
// perceptual curve
this.bgmGain.gain.setTargetAtTime(this.volumes.bgm ** 2 * 0.55, t, 0.05);
this.sfxGain.gain.setTargetAtTime(this.volumes.sfx ** 2 * 0.9, t, 0.03);
} catch { /* ignore */ }
}
/** Fetch + decode (cached, LRU). Resolves null on failure. */
_buffer(url) {
if (!this.ctx || typeof url !== 'string' || !url) return Promise.resolve(null);
const hit = this.buffers.get(url);
if (hit) {
this.buffers.delete(url);
this.buffers.set(url, hit);
return hit;
}
const p = (async () => {
try {
const res = await fetch(url);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const ab = await res.arrayBuffer();
return await new Promise((resolve) => {
try {
const r = this.ctx.decodeAudioData(ab, resolve, () => resolve(null));
if (r && typeof r.then === 'function') r.then(resolve, () => resolve(null));
} catch { resolve(null); }
});
} catch (err) {
this._warn(url, err);
return null;
}
})();
this.buffers.set(url, p);
while (this.buffers.size > BUFFER_CACHE) {
const first = this.buffers.keys().next().value;
// never evict the playing BGM
if (this.bgm && first === this.bgm.loopUrl) { const v = this.buffers.get(first); this.buffers.delete(first); this.buffers.set(first, v); break; }
this.buffers.delete(first);
}
return p;
}
/** Preload a list of URLs (e.g. UI SFX) once unlocked. */
preload(urls) {
if (!this.ctx) return;
for (const u of Array.isArray(urls) ? urls : []) this._buffer(u);
}
// ---- BGM ------------------------------------------------------------------------------------------------
/**
* Switch BGM (null stops). Same loop URL ⇒ no restart.
* @param {string|null} key see bgmKeyFor
*/
playBgm(key) {
try {
if (!this.ctx) { this.wantBgm = key; return; }
const track = resolveBgm(this.getManifest(), key);
if (this.bgm && track && this.bgm.loopUrl === track.loop) { this.bgm.key = key; return; }
if (!track && !this.bgm) return;
const token = ++this.bgmToken;
this._fadeOutBgm();
if (!track) return;
this._startBgm(key, track, token);
} catch (err) { this._warn('bgm', err); }
}
async _startBgm(key, track, token) {
const [intro, loop] = await Promise.all([track.intro ? this._buffer(track.intro) : null, this._buffer(track.loop)]);
if (token !== this.bgmToken || !this.ctx || !loop) return;
try {
const ctx = this.ctx;
const gain = ctx.createGain();
gain.connect(this.bgmGain);
const t0 = ctx.currentTime + 0.05;
gain.gain.setValueAtTime(0, t0);
gain.gain.linearRampToValueAtTime(1, t0 + FADE_S);
const nodes = [];
let loopAt = t0;
if (intro) {
const s = ctx.createBufferSource();
s.buffer = intro;
const g = ctx.createGain();
s.connect(g); g.connect(gain);
s.start(t0);
const end = t0 + intro.duration;
const xf = Math.min(XFADE_S, intro.duration / 2);
g.gain.setValueAtTime(1, Math.max(t0, end - xf));
g.gain.linearRampToValueAtTime(0, end);
nodes.push({ src: s, gain: g });
loopAt = end - xf;
}
const s = ctx.createBufferSource();
s.buffer = loop;
s.loop = true;
const g = ctx.createGain();
s.connect(g); g.connect(gain);
if (intro) {
g.gain.setValueAtTime(0, loopAt);
g.gain.linearRampToValueAtTime(1, loopAt + Math.min(XFADE_S, intro.duration / 2));
}
s.start(loopAt);
nodes.push({ src: s, gain: g });
this.bgm = { key, loopUrl: track.loop, nodes, gain };
} catch (err) { this._warn('bgm-start', err); }
}
_fadeOutBgm() {
const cur = this.bgm;
this.bgm = null;
if (!cur || !this.ctx) return;
try {
const t = this.ctx.currentTime;
cur.gain.gain.cancelScheduledValues(t);
cur.gain.gain.setValueAtTime(cur.gain.gain.value, t);
cur.gain.gain.linearRampToValueAtTime(0, t + FADE_S);
for (const n of cur.nodes) { try { n.src.stop(t + FADE_S + 0.05); } catch { /* ignore */ } }
setTimeout(() => { try { cur.gain.disconnect(); } catch { /* ignore */ } }, (FADE_S + 0.3) * 1000);
} catch { /* ignore */ }
}
// ---- SFX ------------------------------------------------------------------------------------------------
_play(url, { volume = 1, rate = 1, limited = false, unitKey = null } = {}) {
if (!this.ctx || !url || this.volumes.muted || this.volumes.sfx <= 0) return;
const now = typeof performance !== 'undefined' ? performance.now() : Date.now();
if (limited) { if (!this.limiter.tryAcquire(now, unitKey, url)) return; }
else if (this.uiVoices >= 12) return;
else this.uiVoices += 1;
const release = () => { if (limited) this.limiter.release(); else this.uiVoices = Math.max(0, this.uiVoices - 1); };
this._buffer(url).then((buf) => {
if (!buf || !this.ctx) { release(); return; }
try {
const s = this.ctx.createBufferSource();
s.buffer = buf;
s.playbackRate.value = rate;
const g = this.ctx.createGain();
g.gain.value = Math.max(0, Math.min(1.5, volume));
s.connect(g); g.connect(this.sfxGain);
let done = false;
const end = () => { if (!done) { done = true; release(); try { g.disconnect(); } catch { /* ignore */ } } };
s.onended = end;
setTimeout(end, (buf.duration / rate) * 1000 + 250); // safety if onended never fires
s.start();
} catch { release(); }
}, release);
}
/**
* UI sound by name (sfx.ui keys). Unknown names are ignored.
* @param {string} name
* @param {{ volume?: number }} [o]
*/
sfx(name, o = {}) {
try {
const url = this.getManifest()?.audio?.sfx?.ui?.[name];
if (typeof url === 'string') this._play(url, { volume: o.volume ?? 0.9 });
} catch { /* ignore */ }
}
/** Battle sound by name (sfx.battle keys), limited like unit sounds. */
battle(name, o = {}) {
try {
const url = this.getManifest()?.audio?.sfx?.battle?.[name];
if (typeof url === 'string') this._play(url, { volume: o.volume ?? 0.7, limited: true, unitKey: o.unitKey ?? `b:${name}` });
} catch { /* ignore */ }
}
/**
* Per-unit sound (attack/hit/skill/die/born), throttled.
* @param {string} defId charId/tokenId/enemyId (or chess id — mapped via its spine/char id by the caller)
* @param {'attack'|'hit'|'skill'|'die'|'born'} kind
* @param {number|string} unitId battle unit id (cooldown key)
* @returns {boolean} whether a unit-specific sound exists
*/
unit(defId, kind, unitId, skillIndex) {
try {
const u = this.getManifest()?.audio?.sfx?.units?.[defId];
// DESIGN §16: the equipped skill's own ON_SKILL_START sound (`skills[index]`) when the manifest has it
const own = kind === 'skill' && Number.isInteger(skillIndex) && u?.skills ? u.skills[skillIndex] : null;
const url = typeof own === 'string' ? own : u?.[kind];
if (typeof url !== 'string') return false;
this._play(url, { volume: kind === 'attack' || kind === 'hit' ? 0.55 : 0.8, limited: true, unitKey: `${unitId}:${kind}` });
return true;
} catch { return false; }
}
// ---- battle events ------------------------------------------------------------------------------------------
/** Reset the unit map for a new field (m.field.units = UnitInfo[]). */
setFieldUnits(units) {
this.units.clear();
this.lastAttacker.clear();
this.consumed.clear();
for (const u of Array.isArray(units) ? units : []) this._track(u);
}
_track(u) {
if (!u || typeof u !== 'object' || u.id == null) return;
// UnitInfo.spine is the model id (charId / tokenId / enemyId) — the key of sfx.units; kind/defId pick the
// official class sounds (operator vs summon vs device)
this.units.set(u.id, { def: u.spine || u.defId, defId: u.defId ?? null, kind: u.kind ?? null, side: u.side, boss: !!u.boss,
skillIndex: Number.isInteger(u.skillIndex) ? u.skillIndex : null });
}
/** Play a resolved battle sound for a unit event, limited like unit sounds. */
_playUnitUrl(url, unitKey, volume = 0.8) {
if (typeof url === 'string') this._play(url, { volume, limited: true, unitKey });
}
/**
* React to `b.ev` tuples (DESIGN §8.2).
* @param {any[]} ev
*/
handleBattleEvents(ev) {
if (!this.ctx || !Array.isArray(ev)) return;
try {
for (const e of ev) {
if (!Array.isArray(e)) continue;
const kind = e[0];
if (kind === 'spawn') { this._track(e[1]); continue; }
if (kind === 'atk') {
const src = this.units.get(e[1]);
if (!src) continue;
this.lastAttacker.set(e[2], src.def);
if (!this.unit(src.def, 'attack', e[1]) && src.side === 'enemy') this.battle('enemyHit', { unitKey: `${e[1]}:atk`, volume: 0.35 });
} else if (kind === 'dmg') {
const by = this.lastAttacker.get(e[1]);
if (by) this.unit(by, 'hit', `h${e[1]}`);
} else if (kind === 'heal') {
this.battle('heal', { unitKey: `heal:${e[1]}`, volume: 0.35 });
} else if (kind === 'skill' && e[2]) {
const u = this.units.get(e[1]);
if (u) this.unit(u.def, 'skill', e[1], u.skillIndex ?? undefined);
} else if (kind === 'die') {
const u = this.units.get(e[1]);
if (!u) continue;
const consumed = this.consumed.delete(e[1]);
const m = this.getManifest();
const url = deathSfxUrl(m, u, { consumed, reason: typeof e[2] === 'string' ? e[2] : null });
if (!url) continue;
const own = url === m?.audio?.sfx?.units?.[u.def]?.die;
this._playUnitUrl(url, own ? `${e[1]}:die` : `die:${e[1]}`, own ? 0.8 : 0.7);
} else if (kind === 'deploy') {
const u = this.units.get(e[1]);
if (!u || u.side === 'enemy') continue;
const m = this.getManifest();
const url = deploySfxUrl(m, u);
if (!url) continue;
const own = url === m?.audio?.sfx?.units?.[u.def]?.born;
this._playUnitUrl(url, own ? `${e[1]}:born` : 'deploy', own ? 0.8 : 0.5);
} else if (kind === 'fx') {
// a summon used up by its own effect (香槟炸弹 exploding: `consumed`): its impact sound now, no death sound
const ex = e[4];
if (!ex || typeof ex !== 'object' || !ex.consumed || ex.id == null) continue;
const u = this.units.get(ex.id);
if (!u || u.side === 'enemy') continue;
this.consumed.add(ex.id);
if (this.consumed.size > 200) this.consumed.delete(this.consumed.values().next().value);
this.unit(u.def, 'hit', `${ex.id}:boom`);
} else if (kind === 'bounty') {
this.battle('killCoin', { unitKey: 'coin' });
}
}
} catch (err) { this._warn('events', err); }
}
}
let manifestGetter = () => null;
/** App-wide audio manager. */
export const audio = new AudioManager({ getManifest: () => manifestGetter() });
/**
* Wire the singleton to the app (called once by main.js): manifest source, settings and store-driven BGM.
* @param {{ getManifest: () => any, subscribe: (fn: (s:any, prev:any) => void) => () => void, getState: () => any,
* selectRoute: (s:any) => string, settings?: { bgm:number, sfx:number, muted:boolean } }} deps
*/
export function installAudio(deps) {
try {
manifestGetter = typeof deps?.getManifest === 'function' ? deps.getManifest : manifestGetter;
audio.install();
if (deps?.settings) audio.setVolumes(deps.settings);
if (typeof deps?.subscribe === 'function' && typeof deps?.getState === 'function') {
const sync = (s) => {
try { audio.playBgm(bgmKeyFor(deps.selectRoute(s), s.match?.public)); } catch { /* ignore */ }
};
sync(deps.getState());
return deps.subscribe((s, prev) => {
if (s.match?.public?.phase !== prev?.match?.public?.phase || s.room !== prev?.room || s.session !== prev?.session
|| s.match?.public?.bossId !== prev?.match?.public?.bossId) sync(s);
});
}
} catch (err) { console.warn('[audio] install failed', err); }
return () => {};
}
+107
View File
@@ -0,0 +1,107 @@
// Observing rules and labels of client-side combat (research 09 §3.1 / §6.3, DESIGN §14 "Spectating") — pure helpers
// for the game screen, the team panel and the combat HUD (mirror of server/match/Match.js _watchClient):
// * prep (休整期): tap a teammate → 前往查看 → their board (read-only);
// * own normal battle running: no observing ("当前无法查看");
// * own battle over: "⌛ 作战结束,等待队友完成作战" + the teammates' progress; tap a teammate → 前往查看 → a local
// replica of their battle; 返回战场 goes back;
// * 联防 / 最终攻势: the ‹ › pill switches the camera LEFT half / 全景 / RIGHT half of the own field; the other pair's
// boss field is never shown to a fighting player;
// * eliminated: anything.
import { PHASE } from '../../../shared/constants.js';
const isObj = (v) => !!v && typeof v === 'object';
const COMBAT = new Set([PHASE.COMBAT, PHASE.UNITE, PHASE.FINAL_ASSAULT, PHASE.HIDDEN_CORE]);
/** The server runs client-side combat (m.public.combatMode). */
export const isClientCombat = (pub) => !!pub && pub.combatMode === 'client';
const players = (pub) => (Array.isArray(pub?.players) ? pub.players.filter(isObj) : []);
const fields = (pub) => (Array.isArray(pub?.fields) ? pub.fields.filter(isObj) : []);
/** The field listing a player, or null. */
export function fieldOf(pub, playerId) {
return fields(pub).find((f) => Array.isArray(f.players) && f.players.includes(playerId)) || null;
}
/** Display name of a player id ('队友' when unknown). */
export function nameOf(pub, playerId) {
return players(pub).find((p) => p.playerId === playerId)?.name || '队友';
}
/**
* What tapping a team row does: `{ back: true }` (own row while observing), `{ fieldId }` to observe, or `{ reason }`.
* @param {any} p m.public player row
* @param {any} pub
* @param {string} myId
* @param {{ observing?: boolean, ownDone?: boolean }} [o] ownDone: the local simulation of the own battle already ended
* (its result is on the way to the server)
*/
export function observeTarget(p, pub, myId, { observing = false, ownDone = false } = {}) {
if (!isObj(p)) return { reason: '无效的目标' };
if (p.playerId === myId) return observing ? { back: true } : { reason: null };
if (p.alive === false || p.status === 'left') return { reason: '该队友已被淘汰,无法查看' };
const phase = pub?.phase;
const me = players(pub).find((x) => x.playerId === myId) || null;
const meAlive = me ? me.alive !== false : true;
if (!COMBAT.has(phase)) return { fieldId: `n:${p.playerId}` };
const target = fieldOf(pub, p.playerId);
if (!target) return { reason: '该队友当前没有战场' };
const own = fieldOf(pub, myId);
if (!meAlive || !own) return { fieldId: target.fieldId };
if (own.fieldId === target.fieldId) return { reason: '队友与你在同一战场,使用 ‹ › 切换视角' };
if (target.kind === 'boss' || target.kind === 'hidden') return { reason: '无法查看另一组队友的战场' };
if (own.kind === 'normal' && own.live !== false && !ownDone) return { reason: '作战中无法查看队友,作战结束后可前往查看' };
return { fieldId: target.fieldId };
}
/** Teammates' progress for the waiting pill: [{ playerId, name, killed, total, done, isBot }]. */
export function teammateProgress(pub, myId) {
const out = [];
for (const f of fields(pub)) {
if (f.kind !== 'normal' || !Array.isArray(f.players)) continue;
const pid = f.players[0];
if (pid === myId) continue;
const p = players(pub).find((x) => x.playerId === pid);
const pr = isObj(f.progress) ? f.progress : null;
out.push({
playerId: pid, name: p?.name || '队友', isBot: !!p?.isBot,
killed: Number.isFinite(pr?.killed) ? pr.killed : null, total: Number.isFinite(pr?.total) ? pr.total : null,
done: f.live === false || !!pr?.done,
});
}
return out;
}
/** Side of each player of a local field meta (`sides` from the runner, else seat order). */
export function sidesOf(field) {
if (isObj(field?.sides)) return field.sides;
const ps = Array.isArray(field?.players) ? field.players : [];
return Object.fromEntries(ps.map((pid, i) => [pid, i === 1 ? 'R' : 'L']));
}
/**
* The ‹ › camera layers of a 联防 / boss field: LEFT half, 全景, RIGHT half with captions "你自己" / "👁 name" /
* "全景" / "无人在家". Empty for normal fields and single-player boss fields.
*/
export function cameraLayers(field, pub, myId) {
if (!isObj(field) || (field.kind !== 'unite' && field.kind !== 'boss' && field.kind !== 'hidden')) return [];
const sides = sidesOf(field);
const at = (side) => Object.keys(sides).find((pid) => sides[pid] === side) || null;
const label = (pid) => (!pid ? '无人在家' : pid === myId ? '你自己' : nameOf(pub, pid));
const left = at('L');
const right = at('R');
if ((field.kind === 'boss' || field.kind === 'hidden') && (!left || !right)) return [];
return [
{ key: 'L', label: label(left), self: left === myId, watch: !!left && left !== myId },
{ key: 'ALL', label: '全景', self: false, watch: false },
{ key: 'R', label: label(right), self: right === myId, watch: !!right && right !== myId },
];
}
/** Camera options of a layer for view.setCamera(kind, …). */
export function layerCamera(field, layer, mySide = 'L') {
const rect = field?.rect;
if (layer === 'L' || layer === 'R') return { rect, side: layer, half: true };
return { rect, side: mySide };
}
+536
View File
@@ -0,0 +1,536 @@
// Local battle runner — client-side combat (DESIGN §14, research 09 §3.2 / §6.4).
//
// Like the official client, the browser simulates battles itself with the shared deterministic sim (server/sim,
// served read-only at /sim/). The server sends a JSON BattleSpec (`b.start`); this module builds the Battle with
// /sim/spec.js createBattleFromSpec and
// * steps it at the battle speed (fixed 1/30 s ticks, 2× → 60 ticks per real second; at most
// max(8, 4·speed) ticks per animation frame, bounded fast-forward up to CATCHUP_TICKS per frame when it is far
// behind: a reconnect, observing a running field, a tab back from the background),
// * feeds the render engine every frame through the same wire formats the server used to stream
// (`snap` = b.snap frame from battle.snapshot(), `ev` = b.ev frame from battle.drainEvents()) and publishes the
// field meta (m.field shape) into store.match.field so the game screen enters the battle,
// * when authoritative: reports b.progress (~1 Hz; boss fields 4 Hz with the shared-pool damage and the LP meter)
// and b.result (compactResult) at the end; applies b.pool (LocalBossPool.sync) and b.end (forceEnd / takeover),
// * keeps an authoritative battle running while the tab is hidden (a 250 ms interval pump; browsers throttle it to
// ~1 Hz, the bounded fast-forward absorbs that); the server deadline + takeover cover anything worse.
// Display replicas (a teammate's field after the own battle, 联防 observers, the partner of a boss pair) run the same
// spec fast-forwarded to the server's clock (`elapsed`) and never report.
// A b.result lost with the socket (the request failed DISCONNECTED / OFFLINE, or timed out twice) is kept and sent
// again when the session is back online ('status' → 'online') or when a b.start still names this finished battle
// authoritative (the server is waiting for it); the server takes a duplicate idempotently. A server refusal is final.
// Solo pause (g.pause, DESIGN §14): while `m.public.paused` is true every local battle clock stands still (no ticks, no
// reports); on resume the clocks move on by the paused time, like the server's field clock.
//
// The sim (≈ 0.2–1 ms per tick) runs on the main thread: one battle at a time is stepped for display (plus an
// authoritative one if it is not the one on screen). stats() exposes the measured cost.
//
// import { battleRunner } from './battle/runner.js' (browser singleton wired to net.js + store.js; null in Node)
// battleRunner.on('snap' | 'ev' | 'field' | 'state', fn) → off
// battleRunner.state() → { battleId, fieldId, kind, authoritative, watch, done, own, members, loading, paused } | null
// battleRunner.stats() → { ticks, stepMs, avgTickMs, maxFrameMs, catchups, errors, battles }
//
// createBattleRunner(deps) builds an instance with injectable net / store / clock / frame scheduler / sim loader
// (test/match/runner.test.js drives it under Node).
import { net as appNet } from '../net.js';
import { store as appStore } from '../store.js';
const TICK = 1 / 30;
/** Fast-forward budget per frame (ticks) when far behind. */
export const CATCHUP_TICKS = 240;
/** Silent catch-up slice (ticks) while a new battle is prepared before it is shown. */
const PREPARE_SLICE = 600;
const MAX_ENTRIES = 4;
const STATE_EV = new Set(['spawn', 'die', 'deploy', 'status', 'skill', 'leak']);
/** Data files the simulation reads (DataSource + content/support gameData()). */
export const SIM_DATA_FILES = Object.freeze(['chess', 'enemies', 'tokens', 'stages', 'waves', 'bonds', 'items', 'garrisons', 'bands', 'effects']);
/** Request failures after which a b.result counts as never delivered (re-sent on resume / b.start). */
export const LOST_RESULT_CODES = Object.freeze(['DISCONNECTED', 'OFFLINE', 'TIMEOUT']);
/** Ticks per frame at a speed (same cap as the server pacing: server/match/fields.js maxTicksPerInterval). */
export const ticksPerFrameCap = (speed) => Math.max(8, Math.ceil((Number(speed) || 2) * 4));
function deepFreeze(root) {
const stack = [root];
while (stack.length) {
const o = stack.pop();
if (o === null || typeof o !== 'object' || Object.isFrozen(o)) continue;
Object.freeze(o);
for (const v of Object.values(o)) if (v !== null && typeof v === 'object') stack.push(v);
}
return root;
}
/**
* Browser sim loader: the /sim/ modules + the data files (own frozen copies — the server's data is frozen too, so a
* content bug that writes into a record fails identically on both sides).
*/
export async function loadBrowserSim({ base = '/sim/', dataBase = '/data/', fetchFn = (...a) => globalThis.fetch(...a) } = {}) {
const [spec, simdata, support] = await Promise.all([
import(`${base}spec.js`), import(`${base}simdata.js`), import(`${base}content/support/index.js`),
]);
const fetchOnce = async (n) => {
try {
const res = await fetchFn(`${dataBase}${n}.json`, { cache: 'no-cache' });
return res && res.ok ? await res.json() : null;
} catch { return null; }
};
const files = await Promise.all(SIM_DATA_FILES.map(async (n) => (await fetchOnce(n)) ?? fetchOnce(n)));
// a battle simulated without part of the data would still produce a plausible result the server accepts: never run
// one — the loader fails (the next b.start retries it; meanwhile the server's deadline takes the field over)
const missing = SIM_DATA_FILES.filter((n, i) => !files[i] || typeof files[i] !== 'object');
if (missing.length) throw new Error(`simulation data unavailable: ${missing.join(', ')}`);
const raw = {};
SIM_DATA_FILES.forEach((n, i) => { raw[n] = deepFreeze(files[i]); });
simdata.setSimData(raw);
if (typeof support.setGameData === 'function') support.setGameData(null); // re-read through the injected data
return { spec, ds: new simdata.DataSource(raw, null) };
}
/** Battle logger: content errors are isolated by the sim; report them as warnings (the server logs its own). */
const SIM_LOGGER = Object.freeze({
error: (...a) => console.warn('[sim]', ...a),
warn: (...a) => console.warn('[sim]', ...a),
info() {},
debug() {},
});
/**
* @param {{ net: any, store: any, loadSim?: () => Promise<{ spec: any, ds: any }>, now?: () => number,
* raf?: (fn: (t: number) => void) => any, caf?: (h: any) => void, setInterval?: Function, clearInterval?: Function,
* doc?: { hidden?: boolean, addEventListener?: Function } | null, logger?: object }} deps
*/
export function createBattleRunner(deps) {
const net = deps.net;
const store = deps.store;
const now = deps.now || (() => (globalThis.performance ? performance.now() : Date.now()));
const raf = deps.raf || ((fn) => globalThis.requestAnimationFrame(fn));
const caf = deps.caf || ((h) => globalThis.cancelAnimationFrame(h));
const setIv = deps.setInterval || ((fn, ms) => globalThis.setInterval(fn, ms));
const clearIv = deps.clearInterval || ((h) => globalThis.clearInterval(h));
const doc = deps.doc !== undefined ? deps.doc : (typeof document !== 'undefined' ? document : null);
const loadSim = deps.loadSim || (() => loadBrowserSim());
const logger = deps.logger || SIM_LOGGER;
const listeners = new Map();
const emit = (type, payload) => {
const set = listeners.get(type);
if (!set) return;
for (const fn of [...set]) {
try { fn(payload); } catch (err) { console.warn(`[runner] ${type} listener failed`, err); }
}
};
/** @type {Map<string, any>} battleId → entry */
const entries = new Map();
let cur = null; // entry on screen
let simP = null;
let startSeq = 0;
let loading = null; // b.start being prepared
let rafH = null;
let ivH = null;
let lastPool = null;
/** solo pause: the runner clock's instant when m.public.paused turned true (null while running) */
let pausedAt = null;
const stats = { ticks: 0, stepMs: 0, maxFrameMs: 0, catchups: 0, errors: 0, battles: 0, frames: 0 };
const hidden = () => !!(doc && doc.hidden);
/** The battle clock: frozen at the pause instant while the solo battle is paused. */
const clock = () => (pausedAt != null ? pausedAt : now());
const bossLike = (e) => e.kind === 'boss' || e.kind === 'hidden';
function ensureSim() {
if (!simP) {
simP = loadSim().catch((err) => { simP = null; throw err; });
}
return simP;
}
function state() {
const e = cur;
if (!e) return loading ? { loading: true, battleId: loading.battleId, fieldId: loading.fieldId, kind: loading.kind } : null;
return {
battleId: e.battleId, fieldId: e.fieldId, kind: e.kind, authoritative: e.authoritative, watch: e.watch,
done: e.done, own: e.own, members: e.members.slice(), loading: !!loading, speed: e.speed, paused: pausedAt != null,
};
}
function publishState() {
const s = state();
try { store.patch('match', { battle: s }); } catch { /* store without a match slice */ }
emit('state', s);
}
/** Target tick of an entry on its clock. */
const targetTick = (e, t) => Math.max(0, Math.floor((((t - e.t0) / 1000) * e.speed) / TICK + 1e-9));
function stepEntry(e, n) {
const b = e.battle;
const t = now();
let k = 0;
try {
for (; k < n && !b.finished; k++) b.step();
} catch (err) {
stats.errors++;
console.warn('[runner] battle step failed', err);
try { b.forceEnd('timeout'); } catch { /* ignore */ }
}
const dt = now() - t;
stats.ticks += k;
stats.stepMs += dt;
return dt;
}
function frameOf(e) {
const snap = e.battle.snapshot();
const { t: gt, ...rest } = snap || {};
return { ...rest, t: 'b.snap', fieldId: e.fieldId, gt: Number.isFinite(gt) ? gt : 0 };
}
function emitFrame(e, catchingUp) {
let ev = [];
try { ev = e.battle.drainEvents() || []; } catch { ev = []; }
const gt = Number(e.battle.time) || 0;
if (ev.length) {
const list = catchingUp ? ev.filter((x) => Array.isArray(x) && STATE_EV.has(x[0])) : ev;
if (list.length) emit('ev', { t: 'b.ev', fieldId: e.fieldId, gt, ev: list });
}
try { emit('snap', frameOf(e)); } catch (err) { console.warn('[runner] snapshot failed', err); }
}
function progress(e, force = false) {
if (!e.authoritative || e.resultSent || !net) return;
const t = now();
const every = bossLike(e) ? 250 : 1000;
if (!force && t - e.lastProgressAt < every) return;
e.lastProgressAt = t;
const p = e.sim.spec.battleProgress(e.battle);
const msg = { battleId: e.battleId, gt: Math.min(1e5, p.gt), killed: Math.min(p.killed, p.total), total: Math.min(1e5, p.total), done: !!p.done };
if (bossLike(e)) {
const pool = e.battle.sharedBoss;
msg.leaks = Math.min(1e6, e.meter.lp);
msg.bossDmg = pool && Number.isFinite(pool.cum) ? pool.cum : 0;
if (pool && pool.byPlayer) {
const by = {};
for (const pid of Object.keys(pool.byPlayer).slice(0, 4)) by[pid] = pool.byPlayer[pid];
msg.by = by;
}
} else {
msg.leaks = Math.min(1e6, p.leaks);
}
try { net.send('b.progress', msg); } catch { /* offline */ }
}
function finished(e) {
if (e.done) return;
e.done = true;
if (e.authoritative && !e.resultSent && net) {
progress(e, true);
e.resultSent = true;
let result = null;
try {
result = e.sim.spec.compactResult(e.battle.result());
// an oversized frame would close the socket at the very end of the battle (64 KB inbound limit)
if (typeof e.sim.spec.fitResult === 'function') result = e.sim.spec.fitResult(result, { bossLike: bossLike(e), battleId: e.battleId });
} catch (err) { console.warn('[runner] result failed', err); }
if (result) {
e.result = result;
deliver(e);
}
}
if (e === cur) publishState();
}
/**
* Send an entry's b.result (retried once on a timeout). `e.delivery`: 'pending' while a request is out, 'delivered'
* once the server answered (ok, or a refusal — final), 'undelivered' when it never got there (LOST_RESULT_CODES): kept
* for redeliver() (session back online) and for an authoritative b.start of the finished battle.
*/
function deliver(e) {
if (!net || !e.result || e.delivery === 'pending') return;
e.delivery = 'pending';
const msg = { battleId: e.battleId, result: e.result };
const send = (tries) => {
let req;
try { req = net.request('b.result', msg, { timeout: 15000 }); } catch (err) { req = Promise.reject(err); }
return Promise.resolve(req).then(() => { e.delivery = 'delivered'; }, (err) => {
const code = err && err.code;
if (code === 'TIMEOUT' && tries > 0) return send(tries - 1);
if (LOST_RESULT_CODES.includes(code)) {
e.delivery = 'undelivered';
console.warn(`[runner] b.result not delivered (${code}) — sent again when the session resumes`);
} else {
e.delivery = 'delivered';
console.warn('[runner] b.result refused', code);
}
return null;
});
};
send(1);
}
/** The session is back: every result lost with the socket goes out again. */
function redeliver() {
for (const e of entries.values()) if (e.delivery === 'undelivered') deliver(e);
}
/** Solo pause (m.public.paused): freeze / resume every local battle clock. */
function setPaused(on) {
if (on === (pausedAt != null)) return;
if (on) {
pausedAt = now();
} else {
const d = Math.max(0, now() - pausedAt);
pausedAt = null;
for (const e of entries.values()) { e.t0 += d; e.lastProgressAt += d; }
}
publishState();
schedule();
}
/** Advance one entry to its clock (bounded); render it when it is on screen. */
function advance(e, t, render) {
if (e.battle.finished) { if (!e.done) finished(e); return; }
const behind = targetTick(e, t) - (e.battle.tickCount || 0);
if (behind <= 0) return;
const cap = ticksPerFrameCap(e.speed);
const catchingUp = behind > cap * 4;
if (catchingUp) stats.catchups++;
const n = Math.min(behind, catchingUp ? CATCHUP_TICKS : cap);
const dt = stepEntry(e, n);
if (dt > stats.maxFrameMs) stats.maxFrameMs = dt;
if (render) emitFrame(e, catchingUp);
else { try { e.battle.drainEvents(); } catch { /* ignore */ } }
progress(e);
if (e.battle.finished) finished(e);
}
function running(e) { return !e.battle.finished && (e === cur || (e.authoritative && !e.resultSent)); }
function frame() {
rafH = null;
stats.frames++;
const t = clock();
for (const e of [...entries.values()]) if (running(e)) advance(e, t, e === cur);
schedule();
}
function pump() {
// hidden tab: no animation frames — keep authoritative battles on their clock (no rendering)
if (!hidden()) return;
for (const e of [...entries.values()]) if (e.authoritative && running(e)) advance(e, clock(), false);
}
function schedule() {
// paused: nothing to step (the view keeps its last frame); resume reschedules
const any = pausedAt == null && [...entries.values()].some(running);
if (any && rafH == null && !hidden()) rafH = raf(frame);
const needPump = pausedAt == null && [...entries.values()].some((e) => e.authoritative && running(e));
if (needPump && ivH == null) ivH = setIv(pump, 250);
else if (!needPump && ivH != null) { clearIv(ivH); ivH = null; }
}
/** Put an entry on screen: field meta into the store (the game screen enters it), then its current frame. */
function show(e) {
cur = e;
let meta = null;
try { meta = e.battle.fieldMeta(); } catch { meta = { units: [] }; }
const field = {
t: 'm.field', ...meta, fieldId: e.fieldId, kind: e.kind, rect: meta.rect ?? e.spec.rect, stageId: meta.stageId ?? e.spec.stageId,
live: !e.done, battleId: e.battleId, players: e.members.slice(), local: true, speed: e.speed,
// which half each player holds (联防: the first helper takes the right half; boss pairs: L / R)
sides: Object.fromEntries((e.spec.players || []).filter((p) => p && p.playerId).map((p) => [p.playerId, p.side === 'R' || Number(p.colOffset) >= 8 ? 'R' : 'L'])),
};
emit('field', field);
try { store.patch('match', { field }); } catch { /* ignore */ }
publishState();
try { e.battle.drainEvents(); } catch { /* the view starts from the meta + this frame */ }
emit('snap', frameOf(e));
schedule();
}
function evict() {
if (entries.size <= MAX_ENTRIES) return;
for (const [id, e] of entries) {
if (entries.size <= MAX_ENTRIES) break;
if (e === cur || (e.authoritative && !e.resultSent) || e.own || e.delivery === 'pending' || e.delivery === 'undelivered') continue;
entries.delete(id);
}
}
function yieldFrame() { return new Promise((resolve) => { if (hidden()) setTimeout(resolve, 0); else raf(() => resolve()); }); }
async function onStart(msg) {
if (!msg || typeof msg !== 'object' || !msg.spec || typeof msg.battleId !== 'string') return;
const speed = Number(msg.speed) > 0 ? Number(msg.speed) : 2;
const existing = entries.get(msg.battleId);
if (existing) {
const was = existing.authoritative;
existing.authoritative = !!msg.authoritative && !existing.resultSent;
existing.watch = !!msg.watch;
// the server still waits for this finished battle's result (lost with the socket, or its answer was): again
if (msg.authoritative && existing.resultSent) deliver(existing);
if (existing.authoritative && !was) {
// handover (the partner left): continue from the field's clock and report from now on
existing.t0 = clock() - ((Number(msg.elapsed) || 0) / speed) * 1000;
existing.lastProgressAt = -Infinity;
if (existing.battle.finished) { existing.done = false; finished(existing); }
}
++startSeq;
loading = null;
// a resend of what is already on screen (reconnect / resync) only updates the state; switching back shows it
if (cur !== existing) show(existing);
else { publishState(); schedule(); }
return;
}
const seq = ++startSeq;
loading = { battleId: msg.battleId, fieldId: msg.fieldId, kind: msg.kind };
publishState();
let sim;
try { sim = await ensureSim(); } catch (err) {
console.warn('[runner] simulation unavailable', err);
if (seq === startSeq) { loading = null; publishState(); }
return;
}
if (seq !== startSeq) return; // superseded by a newer b.start
let battle;
try {
battle = sim.spec.createBattleFromSpec(msg.spec, sim.ds, { logger });
} catch (err) {
console.warn('[runner] battle construction failed', err);
loading = null;
publishState();
return;
}
stats.battles++;
const e = {
battleId: msg.battleId, fieldId: msg.fieldId || msg.spec.fieldId, kind: msg.kind || msg.spec.kind, spec: msg.spec, sim, battle,
authoritative: !!msg.authoritative, watch: !!msg.watch, own: !msg.watch, speed,
members: (msg.spec.players || []).map((p) => p && p.playerId).filter(Boolean),
t0: clock() - ((Number(msg.elapsed) || 0) / speed) * 1000, lastProgressAt: -Infinity, done: false, resultSent: false,
result: null, delivery: null,
meter: sim.spec.attachLpMeter(battle),
};
if (lastPool && battle.sharedBoss && typeof battle.sharedBoss.sync === 'function') {
battle.sharedBoss.sync(lastPool.hp, lastPool.acked ? lastPool.acked[e.fieldId] : undefined);
}
// silent catch-up to the field's clock before it is shown (a reconnect / observing a running field)
while (!battle.finished && targetTick(e, clock()) - battle.tickCount > ticksPerFrameCap(speed)) {
const n = Math.min(PREPARE_SLICE, targetTick(e, clock()) - battle.tickCount);
stepEntry(e, n);
try { battle.drainEvents(); } catch { /* ignore */ }
if (e.authoritative) progress(e);
await yieldFrame();
if (seq !== startSeq) {
// superseded while preparing: an authoritative battle must still finish (it is kept), a replica is dropped
if (e.authoritative) { entries.set(e.battleId, e); evict(); schedule(); }
return;
}
}
entries.set(e.battleId, e);
evict();
loading = null;
show(e);
if (battle.finished) finished(e);
}
function onPool(msg) {
if (!msg || typeof msg !== 'object') return;
lastPool = msg;
for (const e of entries.values()) {
const pool = e.battle.sharedBoss;
if (pool && typeof pool.sync === 'function') pool.sync(msg.hp, msg.acked ? msg.acked[e.fieldId] : undefined);
}
emit('pool', msg);
}
function onEnd(msg) {
const e = msg && entries.get(msg.battleId);
if (!e) return;
if (msg.reason === 'takeover') {
e.authoritative = false;
if (e === cur) publishState();
schedule();
return;
}
if (!e.battle.finished) {
try { e.battle.forceEnd(msg.reason === 'timeout' ? 'timeout' : 'forced'); } catch { /* ignore */ }
}
if (e === cur) emitFrame(e, false);
finished(e);
schedule();
}
/** Drop every battle (a new round's prep, the match ended, the player left). */
function clear() {
++startSeq;
loading = null;
for (const e of entries.values()) {
// never drop an unreported authoritative result (the round already moved on: the server has its own)
if (e.authoritative && !e.resultSent && !e.battle.finished) { try { e.battle.forceEnd('forced'); } catch { /* ignore */ } }
}
entries.clear();
cur = null;
lastPool = null;
if (rafH != null) { caf(rafH); rafH = null; }
if (ivH != null) { clearIv(ivH); ivH = null; }
publishState();
}
const offs = [];
if (net && typeof net.on === 'function') {
offs.push(net.on('b.start', (m) => { onStart(m).catch((err) => console.warn('[runner] b.start failed', err)); }));
offs.push(net.on('b.pool', onPool));
offs.push(net.on('b.end', onEnd));
// the session is back (reconnect / resume): results lost with the old socket go out again
offs.push(net.on('status', (st) => { if (st && st.status === 'online') redeliver(); }));
}
// phase changes: combat fields live until the next prep; leaving the match drops everything
let lastPhase = null;
if (store && typeof store.subscribe === 'function') {
offs.push(store.subscribe((s) => {
const pub = s && s.match && s.match.public ? s.match.public : null;
// solo pause (DESIGN §14): the local battle clocks follow m.public.paused
setPaused(!!(pub && pub.paused));
const phase = pub ? pub.phase : null;
if (phase === lastPhase) return;
lastPhase = phase;
if (!phase || ['PREP', 'ROUND_START', 'SP_DRAFT', 'RESULT', 'LOBBY', 'INFO_CHECK', 'BAND_DRAFT', 'BATTLE_CHECK'].includes(phase)) {
if (entries.size || loading) clear();
}
// warm the simulation up as soon as a match runs (the first b.start then starts at once)
if (phase && phase !== 'LOBBY' && !simP) ensureSim().catch(() => {});
}));
}
if (doc && typeof doc.addEventListener === 'function') {
doc.addEventListener('visibilitychange', () => { if (!hidden()) schedule(); });
}
return {
on(type, fn) {
if (typeof fn !== 'function') return () => {};
if (!listeners.has(type)) listeners.set(type, new Set());
listeners.get(type).add(fn);
return () => listeners.get(type)?.delete(fn);
},
state,
stats() {
return { ...stats, avgTickMs: stats.ticks ? stats.stepMs / stats.ticks : 0, entries: entries.size, loadingSim: !!simP };
},
/** Re-show the current battle (the game screen remounted). */
reshow() { if (cur) show(cur); },
/** Test / debug hooks. */
_entries: entries,
_frame: frame,
_pump: pump,
onStart, onPool, onEnd, clear, ensureSim, redeliver, setPaused,
dispose() { clear(); for (const off of offs) { try { off?.(); } catch { /* ignore */ } } },
};
}
/** The browser runner, wired to the app's socket and store (null outside a browser). */
export const battleRunner = typeof window !== 'undefined' && typeof document !== 'undefined'
? createBattleRunner({ net: appNet, store: appStore })
: null;
if (battleRunner) globalThis.__SP_RUNNER__ = battleRunner; // dev / E2E introspection
+228
View File
@@ -0,0 +1,228 @@
// Lazy loader + cache for the generated game data served at /data/*.json.
//
// Files are fetched on first use and cached (the Promise is shared, so concurrent callers trigger a
// single request). Missing files (404, network failure, bad JSON) resolve to `null` and are
// reported once on the console — the UI must degrade gracefully while data is being generated.
//
// Each file is indexed tolerantly so the getters work whether a file is
// - an array of records carrying an id field (id / chessId / bondId / itemId / …),
// - an object map { id: record },
// - or either of those wrapped as { <name>: … } / { list: … } / { data: … }.
// Synchronous getters (getChess, getBond, …) return null until the file has loaded; use
// `loadData(...)` to await, or the `useData(...)` hook to re-render when files arrive.
import { useEffect, useReducer } from '../vendor/hooks.module.js';
/** Known data files (name → URL basename). Unknown names are allowed too (`/data/<name>.json`). */
export const DATA_FILES = Object.freeze({
chess: 'chess.json',
bonds: 'bonds.json',
items: 'items.json',
bands: 'bands.json',
enemies: 'enemies.json',
bosses: 'bosses.json',
stages: 'stages.json',
tokens: 'tokens.json',
choices: 'choices.json',
config: 'config.json',
assets: 'assets.json',
// Optional art extracted from a local game client (DESIGN §13): { groups: { '<subdir>': { name: { path, w, h } } } }.
local: 'local-assets.json',
});
const ID_KEYS = ['id', 'chessId', 'bondId', 'itemId', 'bandId', 'enemyKey', 'enemyId', 'stageId', 'bossId', 'tokenId', 'choiceId', 'key'];
const WRAPPER_KEYS = ['list', 'data', 'records', 'entries'];
function idOf(rec) {
if (!rec || typeof rec !== 'object') return null;
for (const k of ID_KEYS) {
const v = rec[k];
if ((typeof v === 'string' && v) || Number.isFinite(v)) return String(v);
}
return null;
}
/**
* Build an id → record Map from a data file's JSON, tolerating the shapes listed in the header.
* @param {string} name file name (used to unwrap `{ [name]: … }`)
* @param {any} json
* @returns {Map<string, any>}
*/
export function buildIndex(name, json) {
const map = new Map();
let src = json;
if (src && typeof src === 'object' && !Array.isArray(src)) {
for (const k of [name, ...WRAPPER_KEYS]) {
const inner = src[k];
if (inner && typeof inner === 'object') { src = inner; break; }
}
}
if (Array.isArray(src)) {
for (const rec of src) {
const id = idOf(rec);
if (id != null && !map.has(id)) map.set(id, rec);
}
} else if (src && typeof src === 'object') {
for (const [k, v] of Object.entries(src)) {
if (v && typeof v === 'object') map.set(k, v);
}
}
return map;
}
/**
* Create a data store bound to a fetch implementation (injectable for tests).
* @param {{ fetch?: typeof fetch, base?: string }} [opts]
*/
export function createDataStore(opts = {}) {
const base = opts.base ?? '/data/';
const doFetch = opts.fetch || ((...a) => globalThis.fetch(...a));
/** @type {Map<string, { status: 'loading'|'ready'|'missing', promise: Promise<any>, value: any, index: Map<string, any>|null }>} */
const entries = new Map();
const listeners = new Set();
const warned = new Set();
const notify = (name) => {
for (const fn of [...listeners]) {
try { fn(name); } catch (err) { console.error('[data] listener failed', err); }
}
};
const urlFor = (name) => base + (DATA_FILES[name] || `${name}.json`);
function load(name) {
if (typeof name !== 'string' || !/^[A-Za-z0-9_-]+$/.test(name)) return Promise.resolve(null);
const cur = entries.get(name);
if (cur) return cur.promise;
const entry = { status: 'loading', promise: null, value: null, index: null };
entry.promise = (async () => {
try {
const res = await doFetch(urlFor(name), { cache: 'no-cache' });
if (!res || !res.ok) throw new Error(`HTTP ${res ? res.status : '???'}`);
const json = await res.json();
entry.value = json;
entry.status = 'ready';
} catch (err) {
if (!warned.has(name)) {
warned.add(name);
console.warn(`[data] ${urlFor(name)} unavailable (${err?.message || err}); continuing without it`);
}
entry.value = null;
entry.status = 'missing';
}
// A load superseded by invalidate() must not announce itself (its entry is no longer cached).
if (entries.get(name) === entry) notify(name);
return entry.value;
})();
entries.set(name, entry);
return entry.promise;
}
function index(name) {
const e = entries.get(name);
if (!e || e.status !== 'ready') return null;
if (!e.index) e.index = buildIndex(name, e.value);
return e.index;
}
return {
/** Fetch (once) and return a file's JSON, or null when missing. */
load,
/** Load several files; resolves when all settled. */
loadAll: (...names) => Promise.all(names.flat().map(load)),
/** Raw JSON of a loaded file (null when missing / not loaded yet). */
get: (name) => entries.get(name)?.value ?? null,
/** 'idle' | 'loading' | 'ready' | 'missing' */
status: (name) => entries.get(name)?.status ?? 'idle',
/** Record by id from a loaded file (null when unknown / not loaded). */
lookup(name, id) {
if (id == null) return null;
return index(name)?.get(String(id)) ?? null;
},
/** All records of a loaded file as an array (empty when not loaded). */
list: (name) => [...(index(name)?.values() ?? [])],
/** Drop a cached file and refetch it now (subscribers are notified when it settles). */
invalidate(name) {
if (!entries.has(name)) return Promise.resolve(null);
entries.delete(name);
warned.delete(name);
const p = load(name);
notify(name); // status is 'loading' again, never a stuck 'idle'
return p;
},
/** Called with the file name whenever a file finishes loading (or is invalidated). */
subscribe(fn) {
listeners.add(fn);
return () => listeners.delete(fn);
},
};
}
/** Browser data store singleton. */
export const data = createDataStore();
/** @param {...string} names @returns {Promise<any[]>} */
export const loadData = (...names) => data.loadAll(...names);
/** @param {string} id chess id (normal or elite) */
export const getChess = (id) => data.lookup('chess', id);
/** @param {string} id bond id, e.g. 'yanShip' */
export const getBond = (id) => data.lookup('bonds', id);
/** @param {string} id item (trap) id */
export const getItem = (id) => data.lookup('items', id);
/** @param {string} id band (strategy) id */
export const getBand = (id) => data.lookup('bands', id);
/** @param {string} key enemy key */
export const getEnemy = (key) => data.lookup('enemies', key);
/** @param {string} id boss id */
export const getBoss = (id) => data.lookup('bosses', id);
/** @param {string} id stage id */
export const getStage = (id) => data.lookup('stages', id);
/** @param {string} id token id */
export const getToken = (id) => data.lookup('tokens', id);
/** @returns {any|null} data/config.json */
export const getConfig = () => data.get('config');
/**
* URL of a local-client art entry (data/local-assets.json, DESIGN §13), or null when the manifest / entry is missing
* (callers draw their own fallback). Load it first with `data.load('local')` / `useData('local')`.
* @param {string} group e.g. 'ui/battle', 'emoticon/basic', 'guide'
* @param {string} name entry name without extension
*/
export function localAsset(group, name) {
const g = data.get('local')?.groups?.[group];
const e = g && typeof g === 'object' ? g[name] : null;
return e && typeof e.path === 'string' && e.path ? e.path : null;
}
/**
* Mode record from config.json (`modes[modeId]`), or null.
* @param {string} modeId e.g. 'mode_multi_hard'
*/
export function getMode(modeId) {
const cfg = getConfig();
const modes = cfg && typeof cfg === 'object' ? (cfg.modes || cfg.modeDataDict) : null;
if (!modes || typeof modes !== 'object') return null;
if (Array.isArray(modes)) return modes.find((m) => m && (m.modeId === modeId || m.id === modeId)) || null;
return modes[modeId] || null;
}
/**
* Preact hook: start loading the given files and re-render when any of them settles.
* @param {...string} names
* @returns {boolean} true once every file is settled (ready or missing)
*/
export function useData(...names) {
const [, force] = useReducer((c) => c + 1, 0);
const key = names.join('|');
useEffect(() => {
let alive = true;
const unsub = data.subscribe((n) => { if (alive && names.includes(n)) force(); });
for (const n of names) data.load(n);
return () => { alive = false; unsub(); };
}, [key]); // eslint-disable-line react-hooks/exhaustive-deps
return names.every((n) => {
const s = data.status(n);
return s === 'ready' || s === 'missing';
});
}
+336
View File
@@ -0,0 +1,336 @@
// Client entry: boot (fonts, identity, socket), net → store wiring, router, global overlays.
//
// Router (derived from the store, no URL routes):
// not entered → Title
// m.public.phase ≠ LOBBY → Game (screens/game.js: briefing / band draft / match / result by phase)
// room.inMatch → Game (match starting, m.public on its way)
// in a room → Room
// otherwise → Lobby
// Deep link `?room=CODE`: remembered at boot, auto-joined once the player has entered and the
// session is online (after a short grace period in case the server restores a room on resume).
// Reloading a tab that already passed the title re-enters automatically (sessionStorage flag) and
// resumes the server session with the saved token; stale room/match state is dropped if the
// server does not re-push it within RESTORE_GRACE_MS after `welcome`. Boot waits for
// `identity.init()` (which token this tab may use without stealing another live tab's session)
// before the first connect; a page restored from the back/forward cache reloads.
// A `welcome` with a NEW playerId (the server restarted / the session expired) while a room or match was on screen:
// toast 「服务器会话已重置,上一局模拟已结束」 (store.js sessionResetNotice) and return to the lobby with nothing stale.
//
// Shared modules are imported relatively ('../../shared/…' resolves to /shared/… in the browser).
// Multi-device support (ui/device.js + css/devices.css): feature classes on <html>, no page zoom, safe areas, rotation
// re-layout; ui/compat.js polyfills are imported before anything else.
// 干员调配 (DESIGN §16): an overlay over any route (<LoadoutHost/>, opened from lobby / room / briefing); its loadout is
// kept in sync with the server by installLoadoutSync (room.loadout after every welcome and edit).
// Polyfills first (older Safari / Firefox ESR): every module evaluated after this one sees them.
import './ui/compat.js';
import { render } from '../vendor/preact.module.js';
import { useErrorBoundary } from '../vendor/hooks.module.js';
import { html, UiHosts, Button, MicroLabel, closeAllDialogs } from './ui/components.js';
import { ConnectionBanner } from './ui/connBanner.js';
import { ToastHost, toast, toastError, describeError } from './ui/toasts.js';
import { net, identity, NetError } from './net.js';
import { store, useStore, emptyMatch, selectRoute, sessionResetNotice } from './store.js';
import { data } from './data.js';
import { TitleScreen, sanitizeName } from './screens/title.js';
import { LobbyScreen, rememberRoom, parseRoomParam } from './screens/lobby.js';
import { RoomScreen } from './screens/room.js';
import { GameScreen } from './screens/game.js';
import { installAudio } from './audio.js';
import { settingsStore } from './ui/settings.js';
import { GuideHost } from './ui/guide.js';
import { installDeviceSupport } from './ui/device.js';
import { LoadoutHost } from './screens/loadout.js';
import { installLoadoutSync } from './ui/loadoutSync.js';
const RESTORE_GRACE_MS = 1500;
const JOIN_DELAY_MS = 350;
const TICKER_KEEP = 20;
const EMOTE_KEEP = 20;
const SCREENS = { title: TitleScreen, lobby: LobbyScreen, room: RoomScreen, game: GameScreen };
/** Copy of a server message without transport fields. */
function payload(msg) {
const { t, rid, ...rest } = msg; // eslint-disable-line no-unused-vars
return rest;
}
function clearRoomParam() {
try {
const url = new URL(location.href);
if (!url.searchParams.has('room')) return;
url.searchParams.delete('room');
history.replaceState(history.state, '', url.pathname + (url.search || '') + url.hash);
} catch { /* ignore */ }
}
// ---- net → store wiring ---------------------------------------------------------------------------
let seq = 0;
let welcomeAt = 0;
let roomStateAt = 0;
let matchAt = 0;
let restoreTimer = null;
let joinTimer = null;
let joinInFlight = false;
function clearPendingJoin() {
store.patch('ui', { pendingJoin: null });
clearRoomParam();
}
/** Auto-join the deep-linked room once entered + online (idempotent). */
function schedulePendingJoin() {
clearTimeout(joinTimer);
joinTimer = setTimeout(async () => {
const s = store.get();
const code = s.ui.pendingJoin;
if (!code || joinInFlight || !s.session.entered || net.status !== 'online') return;
if (s.room) {
if (s.room.code !== code) toast('你已在其他同盟中,请先离开当前同盟', 'warn');
clearPendingJoin();
return;
}
joinInFlight = true;
try {
await net.request('room.join', { code });
} catch (err) {
toastError(err);
} finally {
joinInFlight = false;
clearPendingJoin();
}
}, JOIN_DELAY_MS);
}
/** End the post-resume "syncing" state early once the server re-pushed what we were showing. */
function maybeFinishRestore() {
const s = store.get();
if (!s.ui.restoring) return;
const roomOk = !s.room || roomStateAt >= welcomeAt;
const matchOk = !s.match.public || matchAt >= welcomeAt || !s.room?.inMatch;
if (roomOk && matchOk) {
clearTimeout(restoreTimer);
store.patch('ui', { restoring: false });
}
}
/**
* Leave whatever room / match was on screen for the lobby with nothing stale left behind: open imperative dialogs,
* the post-resume restore timer, ticker lines and emote bubbles of the old match (the battle runner and the 暂离 flag
* follow the store themselves).
*/
function backToLobby() {
clearTimeout(restoreTimer);
const s = store.get();
if (s.room || s.match.public) closeAllDialogs();
store.set({ room: null, match: emptyMatch(), ticker: [], emotes: [] });
store.patch('ui', { restoring: false });
}
function onWelcome(msg) {
identity.saveToken(msg.token);
const prev = store.get();
const prevId = prev.me.playerId;
const name = typeof msg.name === 'string' && msg.name ? msg.name : prev.me.name;
store.set({ me: { playerId: msg.playerId ?? null, name, token: typeof msg.token === 'string' ? msg.token : null } });
welcomeAt = Date.now();
if (prevId != null && prevId !== msg.playerId) {
// A brand-new server session (the server restarted — crashed / killed, so no room.closed arrived — or this session
// expired on it): whatever we showed before is gone — back to the lobby cleanly and say why.
const notice = sessionResetNotice(prev, msg.playerId);
backToLobby();
if (notice) toast(notice, 'warn', { ttl: 7000 });
} else if (prev.room || prev.match.public) {
// Resumed session: the server re-pushes room/match state; drop whatever it doesn't.
store.patch('ui', { restoring: true });
clearTimeout(restoreTimer);
restoreTimer = setTimeout(() => {
const s = store.get();
const patch = {};
if (s.room && roomStateAt < welcomeAt) patch.room = null;
if (s.match.public && matchAt < welcomeAt) patch.match = emptyMatch();
store.set(patch);
store.patch('ui', { restoring: false });
}, RESTORE_GRACE_MS);
}
schedulePendingJoin();
}
function onRoomState(msg) {
const room = payload(msg);
roomStateAt = Date.now();
const myId = store.get().me.playerId;
const seats = Array.isArray(room.seats) ? room.seats : [];
if (myId != null && seats.length && !seats.some((s) => s && s.playerId === myId)) {
// We are no longer seated (kicked / left elsewhere).
if (store.get().room) toast('你已不在该同盟中', 'warn');
store.set({ room: null, match: emptyMatch() });
return;
}
const prevRoom = store.get().room;
// A (new) match starts: forget the previous match's state so stale results never show.
if (room.inMatch && !(prevRoom && prevRoom.inMatch && prevRoom.code === room.code)) store.set({ match: emptyMatch() });
store.set({ room });
if (room.mode === 'coop' && typeof room.code === 'string') rememberRoom(room.code);
maybeFinishRestore();
}
const CLOSE_REASON = {
// 'timeout' = this player was removed after staying disconnected past the lobby grace (server/lobby.js)
host_left: '创建者已离开,同盟已解散', timeout: '由于长时间断开连接,你已离开同盟', empty: '同盟已解散',
kicked: '你已被移出同盟', ended: '模拟已结束', expired: '同盟已过期', shutdown: '服务器维护中,同盟已关闭',
};
function wireNet() {
net.on('status', (snap) => {
const cur = store.get().connection;
store.set({
connection: {
status: snap.status, ping: snap.ping, attempt: snap.attempt, retryAt: snap.retryAt,
lastError: snap.lastError, everOnline: cur.everOnline || snap.status === 'online',
},
});
});
net.on('clock', (c) => store.set({ clock: { offset: c.offset, rtt: c.rtt, synced: c.synced } }));
net.on('welcome', onWelcome);
net.on('helloError', (err) => toastError(err));
net.on('replaced', () => toast('该身份已在其他页面登录,本页已断开', 'warn', { ttl: 6000 }));
net.on('unhandledError', (err) => toastError(err));
net.on('room.state', onRoomState);
net.on('room.closed', (msg) => {
backToLobby();
toast(CLOSE_REASON[msg.reason] || (typeof msg.reason === 'string' && msg.reason.length < 60 ? `同盟已关闭:${msg.reason}` : '同盟已关闭'), 'warn');
});
net.on('m.public', (msg) => { matchAt = Date.now(); store.patch('match', { public: payload(msg) }); maybeFinishRestore(); });
net.on('m.private', (msg) => { matchAt = Date.now(); store.patch('match', { private: payload(msg) }); });
net.on('m.field', (msg) => store.patch('match', { field: payload(msg) }));
net.on('m.result', (msg) => store.patch('match', { result: payload(msg) }));
net.on('m.toast', (msg) => {
const kind = ['info', 'success', 'warn', 'error'].includes(msg.kind) ? msg.kind : 'info';
toast(msg.text, kind);
});
net.on('m.ticker', (msg) => {
if (typeof msg.text !== 'string') return;
store.set((s) => ({ ticker: [...s.ticker.slice(-(TICKER_KEEP - 1)), { id: ++seq, text: msg.text, at: Date.now() }] }));
});
net.on('m.emote', (msg) => {
store.set((s) => ({ emotes: [...s.emotes.slice(-(EMOTE_KEEP - 1)), { seq: ++seq, playerId: msg.playerId, id: msg.id, at: Date.now() }] }));
});
// Entering (title → lobby) while already online also needs the deep-link join.
store.subscribe((s, prev) => {
if (s.session.entered && !prev.session.entered) schedulePendingJoin();
});
}
// ---- UI chrome (the connection banner lives in ui/connBanner.js) -----------------------------------
function ScreenCrashed({ error, reset }) {
return html`<div class="screen crash">
<div class="crash__box brackets">
<${MicroLabel} tone="mint">SYSTEM FAULT<//>
<h2>界面发生错误</h2>
<p class="t-lo">${String(error?.message || error).slice(0, 200)}</p>
<${Button} variant="primary" icon="refresh" onClick=${reset}>重新加载界面<//>
</div>
</div>`;
}
function App() {
const route = useStore(selectRoute);
const [error, resetError] = useErrorBoundary((err) => console.error('[ui] screen crashed', err));
const Screen = SCREENS[route] || LobbyScreen;
return html`<div class="app-root">
<div class="app-bg" aria-hidden="true"></div>
${error ? html`<${ScreenCrashed} error=${error} reset=${resetError} />` : html`<${Screen} key=${route} />`}
<${ConnectionBanner} />
<${ToastHost} />
<${UiHosts} />
<${GuideHost} />
<${LoadoutHost} />
</div>`;
}
// ---- boot -----------------------------------------------------------------------------------------
async function waitForFonts(ms) {
const fonts = document.fonts;
if (!fonts || typeof fonts.load !== 'function') return;
const loads = [
fonts.load('900 1em "Noto Sans SC"', '卫戍协议盟约'),
fonts.load('700 1em "Noto Sans SC"', '开始'),
fonts.load('700 1em Bender', '0123456789'),
fonts.load('700 1em Rajdhani', '0123456789'),
].map((p) => p.catch(() => null));
await Promise.race([Promise.all(loads), new Promise((r) => setTimeout(r, ms))]);
}
function installGlobalErrorHandlers() {
window.addEventListener('unhandledrejection', (ev) => {
const err = ev.reason;
// Media autoplay/abort rejections (audio.play() before a user gesture, interrupted loads) are
// expected browser behaviour, not app errors: log quietly, never toast.
if (err && (err.name === 'NotAllowedError' || err.name === 'AbortError')) { console.warn('[app] ignored rejection', err.name); return; }
console.error('[app] unhandled rejection', err);
if (err instanceof NetError) toastError(err);
else toast(`发生意外错误:${describeError(err)}`.slice(0, 120), 'error');
});
window.addEventListener('error', (ev) => {
if (!(ev instanceof ErrorEvent)) return; // resource load errors are not script errors
console.error('[app] uncaught error', ev.error || ev.message);
});
}
async function boot() {
installGlobalErrorHandlers();
// touch / hover / fullscreen classes, zoom-gesture blocking, rotation re-layout (ui/device.js, css/devices.css)
installDeviceSupport();
// A page restored from the back/forward cache has a dead socket and a stale token choice: start over.
window.addEventListener('pageshow', (ev) => { if (ev.persisted) location.reload(); });
// Pick this tab's reconnect token (asks other live tabs; ≤150 ms) while fonts load.
const identityReady = identity.init();
const pendingJoin = parseRoomParam(location.search);
const savedName = sanitizeName(identity.loadName());
const entered = identity.wasEntered() && !!savedName;
store.set((s) => ({
me: { ...s.me, name: savedName },
session: { entered },
ui: { ...s.ui, pendingJoin },
}));
wireNet();
installLoadoutSync({ net });
net.attachBrowserHooks();
// Audio: unlock on first gesture, BGM follows the route / match phase (js/audio.js).
installAudio({ getManifest: () => data.get('assets'), subscribe: store.subscribe, getState: store.get, selectRoute, settings: settingsStore.get() });
data.load('assets').catch(() => {});
// Warm the data cache in the background (missing files are tolerated).
data.loadAll('config').catch(() => {});
// Optional local-client art manifest (emotes, tutorial pages, official UI sprites; DESIGN §13).
data.load('local').catch(() => {});
const connectWhenReady = identityReady.then(() => {
if (entered) net.setName(savedName);
else net.connect();
});
await Promise.all([waitForFonts(1200), connectWhenReady]);
const root = document.getElementById('app');
render(html`<${App} />`, root);
const splash = document.getElementById('boot');
if (splash) {
splash.classList.add('is-done');
setTimeout(() => splash.remove(), 300);
}
globalThis.__SP__ = { store, net, data, version: 1 };
}
boot().catch((err) => {
console.error('[app] boot failed', err);
const el = document.getElementById('boot-err');
if (el) el.textContent = '启动失败,请刷新页面重试';
});
+835
View File
@@ -0,0 +1,835 @@
// WebSocket client for the game server (DESIGN §8).
//
// - One socket at ws(s)://<host>/ws, JSON text frames `{ t, ...payload }`.
// - Auto-reconnect with exponential backoff + jitter; a heartbeat (`ping`) measures latency and
// detects dead sockets (a ping left unanswered — no inbound frame at all — for DEAD_AFTER_MS ⇒
// close ⇒ reconnect). Measured from the oldest unanswered ping, not from the last inbound frame,
// so a background tab whose timers the browser throttles to ~1/min is not mistaken for dead.
// - On every (re)connect, once a player name is known, sends `hello {name, token, version}`;
// the session is "online" after `welcome`.
// - `request(t, fields)` adds a `rid` and resolves on the matching `ok` (or any reply carrying the
// rid), rejects with a NetError on `error` or after REQUEST_TIMEOUT_MS. Requests made while
// reconnecting are queued and flushed after `welcome` (still bound by their timeout).
// - Every server push is emitted by its `t` (see shared/protocol.js S2C) and as '*'.
// Extra events: 'status', 'ping', 'clock', 'helloError', 'unhandledError', 'replaced'.
// - Server close code 4001 ("session replaced": the same token connected from another tab) stops
// auto-reconnect (status 'closed', lastError REPLACED) so two tabs never fight over one session.
// - Before a name is known (title screen) the socket only pings; the server closes such sockets
// with 4002 (hello timeout). That close is answered with a quiet swap: a fresh socket is opened
// while the status stays 'connected', so the title's server indicator doesn't flicker.
// - Server clock offset (serverNow ≈ Date.now() + offset) from pong samples (lowest RTT wins),
// bootstrapped from `welcome.serverNow` / `m.public.serverNow`.
//
// Identity persistence (`identity`, see the section at the end): the name lives in localStorage;
// the reconnect token lives in sessionStorage (survives reloads of this tab) plus a short list of
// recent tokens in localStorage (resume after closing/reopening the tab). `identity.init()` asks
// the other live tabs over a BroadcastChannel which tokens they hold, so a second or duplicated tab
// of the same browser becomes a separate player instead of hijacking another tab's session.
//
// Shared modules are imported relatively: in the browser '../../shared/x.js' from /js/ resolves
// to /shared/x.js (URL resolution clamps at the root); under Node it resolves to <repo>/shared.
import { PROTOCOL_VERSION, ERR_TEXT } from '../../shared/constants.js';
import { validateC2S } from '../../shared/protocol.js';
export const REQUEST_TIMEOUT_MS = 8000;
export const HELLO_TIMEOUT_MS = 8000;
export const PING_INTERVAL_MS = 4000;
export const DEAD_AFTER_MS = 15000;
export const BACKOFF = Object.freeze({ base: 500, factor: 2, max: 10000, jitter: 0.2 });
/** Client-side error codes (in addition to shared ERR codes). */
export const CLIENT_ERR_TEXT = Object.freeze({
TIMEOUT: '请求超时,请重试',
OFFLINE: '未连接到服务器',
DISCONNECTED: '连接已断开,请重试',
CLOSED: '连接已关闭',
REPLACED: '该身份已在其他页面登录',
VERSION: '客户端版本与服务器不一致,请刷新页面',
});
/** Server close code: the session was taken over by another socket (server/net.js CLOSE.REPLACED). */
export const CLOSE_REPLACED = 4001;
/** Server close code: the socket never sent `hello` (server/net.js CLOSE.HELLO_TIMEOUT, ~30–60 s). */
export const CLOSE_HELLO_TIMEOUT = 4002;
/** A pre-hello socket must have lived this long before a 4002 close is swapped quietly (no loop). */
const QUIET_SWAP_MIN_AGE_MS = 5000;
/**
* Human readable (Chinese) text for an error code.
* @param {string} code
* @param {string} [msg] server-provided message (fallback)
* @returns {string}
*/
export function errorText(code, msg) {
return ERR_TEXT[code] || CLIENT_ERR_TEXT[code] || (typeof msg === 'string' && msg) || String(code || '未知错误');
}
/** Error thrown/rejected by requests. `code` is an ERR code or a CLIENT_ERR_TEXT key. */
export class NetError extends Error {
/** @param {string} code @param {string} [msg] server text @param {string} [detail] server developer detail */
constructor(code, msg, detail) {
const versionMismatch = typeof detail === 'string' && /version/i.test(detail);
super(versionMismatch ? CLIENT_ERR_TEXT.VERSION : errorText(code, msg));
this.name = 'NetError';
this.code = String(code || 'INTERNAL');
this.serverMsg = msg ?? null;
this.detail = typeof detail === 'string' ? detail : null;
}
}
/**
* Reconnect delay for the n-th consecutive failed attempt (0-based), with ±jitter.
* @param {number} attempt
* @param {() => number} [rand]
* @returns {number} milliseconds
*/
export function backoffDelay(attempt, rand = Math.random) {
const n = Math.max(0, Math.min(30, Math.floor(Number(attempt) || 0)));
const raw = Math.min(BACKOFF.max, BACKOFF.base * BACKOFF.factor ** n);
const jitter = raw * BACKOFF.jitter * (rand() * 2 - 1);
return Math.max(100, Math.round(raw + jitter));
}
/**
* WebSocket URL for the current page (`ws(s)://host/ws`).
* @param {{protocol: string, host: string}} [loc]
* @returns {string}
*/
export function defaultWsUrl(loc = globalThis.location) {
if (!loc || !loc.host) return 'ws://localhost:3000/ws';
return `${loc.protocol === 'https:' ? 'wss' : 'ws'}://${loc.host}/ws`;
}
const WS_OPEN = 1;
const WS_CONNECTING = 0;
/**
* Game server connection. Construct with injectable dependencies for tests.
*/
export class Net {
/**
* @param {object} [opts]
* @param {string} [opts.url] socket URL (default: derived from location at connect time)
* @param {any} [opts.WebSocket] WebSocket constructor (default: globalThis.WebSocket)
* @param {() => (string|null)} [opts.getToken] reconnect-token provider for `hello`
* @param {() => number} [opts.now]
* @param {() => number} [opts.random]
* @param {{setTimeout: Function, clearTimeout: Function, setInterval: Function, clearInterval: Function}} [opts.timers]
*/
constructor(opts = {}) {
this.url = opts.url || null;
this.WS = opts.WebSocket || null;
this.getToken = typeof opts.getToken === 'function' ? opts.getToken : () => null;
this.now = opts.now || (() => Date.now());
this.random = opts.random || Math.random;
this.timers = opts.timers || {
setTimeout: (fn, ms) => globalThis.setTimeout(fn, ms),
clearTimeout: (id) => globalThis.clearTimeout(id),
setInterval: (fn, ms) => globalThis.setInterval(fn, ms),
clearInterval: (id) => globalThis.clearInterval(id),
};
/** @type {'idle'|'connecting'|'connected'|'handshaking'|'online'|'reconnecting'|'closed'} */
this.status = 'idle';
this.ws = null;
this.name = null; // desired player name (hello is sent when set)
this.helloName = null; // name we sent in the hello that got the last welcome
this.serverName = null; // name as normalised by the server
this.playerId = null;
this.attempt = 0; // consecutive failed connection attempts
this.retryAt = 0; // epoch ms of the next reconnect attempt (0 = none)
this.ping = null; // last RTT in ms
this.lastError = null; // last NetError relevant to the connection (e.g. hello rejected)
this.clockOffset = 0;
this.clockSynced = false;
this._rid = 0;
this._pending = new Map(); // rid -> { msg, resolve, reject, timer, sent }
this._expired = new Set(); // rids that timed out locally (their late error was already reported)
this._listeners = new Map();
this._manualClose = false;
this._quietSwap = false; // next connect() keeps the current status (see CLOSE_HELLO_TIMEOUT)
this._openedAt = 0;
this._reconnectTimer = null;
this._pingTimer = null;
this._helloTimer = null;
this._helloRid = null;
this._helloSentName = null;
this._lastRx = 0;
this._unansweredSince = null; // time of the oldest ping sent since the last inbound frame
this._clockSamples = []; // [{ offset, rtt }]
}
// ---- events ----------------------------------------------------------------------------------
/**
* Subscribe to a message type (any S2C `t`), '*' (all messages) or a client event
* ('status' | 'ping' | 'clock' | 'helloError' | 'unhandledError').
* @param {string} type
* @param {(payload: any) => void} fn
* @returns {() => void} unsubscribe
*/
on(type, fn) {
if (typeof fn !== 'function') return () => {};
let set = this._listeners.get(type);
if (!set) this._listeners.set(type, (set = new Set()));
set.add(fn);
return () => this.off(type, fn);
}
/** @param {string} type @param {Function} fn */
off(type, fn) {
this._listeners.get(type)?.delete(fn);
}
_emit(type, payload) {
const set = this._listeners.get(type);
if (!set || set.size === 0) return;
for (const fn of [...set]) {
try { fn(payload); } catch (err) { console.error(`[net] listener for "${type}" failed`, err); }
}
}
/** Snapshot of the connection state (what the 'status' event carries). */
snapshot() {
return {
status: this.status, attempt: this.attempt, retryAt: this.retryAt, ping: this.ping,
lastError: this.lastError ? { code: this.lastError.code, text: this.lastError.message } : null,
playerId: this.playerId,
};
}
_setStatus(status) {
this.status = status;
this._emit('status', this.snapshot());
}
// ---- lifecycle -------------------------------------------------------------------------------
/** Open the socket (no-op when already open/connecting). Also resumes after close()/replacement. */
connect() {
const quiet = this._quietSwap;
this._quietSwap = false;
if (this.ws && (this.ws.readyState === WS_OPEN || this.ws.readyState === WS_CONNECTING)) return;
this._manualClose = false;
this._clearTimer('_reconnectTimer', 'clearTimeout');
this.retryAt = 0;
const WS = this.WS || globalThis.WebSocket;
const url = this.url || defaultWsUrl();
let ws;
try {
ws = new WS(url);
} catch (err) {
console.warn('[net] cannot create WebSocket', err);
this._scheduleReconnect();
return;
}
this.ws = ws;
if (!quiet) this._setStatus(this.attempt > 0 ? 'reconnecting' : 'connecting');
ws.onopen = () => { if (ws === this.ws) this._onOpen(); };
ws.onmessage = (ev) => { if (ws === this.ws) this._onMessage(ev?.data); };
ws.onerror = () => { /* followed by close */ };
ws.onclose = (ev) => { if (ws === this.ws) this._onClose(ev); };
}
/** Close for good (no reconnect). Pending requests reject with CLOSED. */
close() {
this._manualClose = true;
this._clearTimer('_reconnectTimer', 'clearTimeout');
this.retryAt = 0;
const ws = this.ws;
this._teardownSocket();
try { ws?.close(1000, 'client close'); } catch { /* ignore */ }
this._failPending('CLOSED', true);
this._setStatus('closed');
}
/** Skip the backoff wait and reconnect right now (used on `online` / tab focus / retry button). */
retryNow() {
if (this._manualClose) return;
if (this.ws && (this.ws.readyState === WS_OPEN || this.ws.readyState === WS_CONNECTING)) return;
this.connect();
}
/** Drop the current socket and reconnect immediately (fresh hello). */
reconnectNow() {
if (this._manualClose) return;
const ws = this.ws;
this._teardownSocket();
try { ws?.close(4000, 'reconnect'); } catch { /* ignore */ }
this._failPending('DISCONNECTED', false);
this.attempt = 0;
this.connect();
}
/**
* Set the player name used in `hello`. Sends hello right away when the socket is open (the server
* accepts a repeated hello on a live socket: it renames the session and resyncs room/match
* state), otherwise as soon as it opens. No-op when that name is already accepted.
* @param {string} name
*/
setName(name) {
const n = typeof name === 'string' ? name.trim() : '';
if (!n) return;
const changed = n !== this.name;
this.name = n;
if (!this.ws || this.ws.readyState !== WS_OPEN) {
if (!this.ws && !this._reconnectTimer && !this._manualClose) this.connect();
return; // hello goes out on open
}
if (this.status === 'online' && this.helloName === n) return;
if (this.status === 'handshaking' && !changed && this._helloSentName === n) return;
// The server accepts a repeated hello on a live socket (it renames the session and resyncs).
this._sendHello();
}
_onOpen() {
this._openedAt = this.now();
this._lastRx = this._openedAt;
this._unansweredSince = null;
this.retryAt = 0;
this._startHeartbeat();
if (this.name) {
this._setStatus('handshaking');
this._sendHello();
} else {
this.attempt = 0;
this._setStatus('connected');
this._sendPing();
}
}
_onClose(ev) {
this._teardownSocket();
this._failPending('DISCONNECTED', false);
if (this._manualClose) { this._setStatus('closed'); return; }
if (ev && ev.code === CLOSE_REPLACED) {
// Another tab/socket took this session over: don't fight it; the user may reconnect manually.
this._manualClose = true;
this._failPending('REPLACED', true);
this.lastError = new NetError('REPLACED');
this._setStatus('closed');
this._emit('replaced', this.lastError);
return;
}
if (ev && ev.code === CLOSE_HELLO_TIMEOUT && !this.name && this.status === 'connected'
&& this.now() - this._openedAt >= QUIET_SWAP_MIN_AGE_MS) {
// Idle pre-hello socket dropped by the server: replace it without a visible status change.
this._quietSwap = true;
this.connect();
return;
}
this._scheduleReconnect();
}
_scheduleReconnect() {
if (this._manualClose) return;
const delay = backoffDelay(this.attempt, this.random);
this.attempt += 1;
this.retryAt = this.now() + delay;
this._clearTimer('_reconnectTimer', 'clearTimeout');
this._reconnectTimer = this.timers.setTimeout(() => {
this._reconnectTimer = null;
this.connect();
}, delay);
this._setStatus('reconnecting');
}
_teardownSocket() {
const ws = this.ws;
if (ws) {
ws.onopen = ws.onmessage = ws.onerror = ws.onclose = null;
}
this.ws = null;
this._unansweredSince = null;
this._clearTimer('_pingTimer', 'clearInterval');
this._clearTimer('_helloTimer', 'clearTimeout');
this._helloRid = null;
this._helloSentName = null;
}
_clearTimer(field, fn) {
if (this[field] != null) {
try { this.timers[fn](this[field]); } catch { /* ignore */ }
this[field] = null;
}
}
// ---- handshake -------------------------------------------------------------------------------
_sendHello() {
if (!this.name) return;
const rid = this._nextRid();
const msg = { t: 'hello', rid, name: this.name, version: PROTOCOL_VERSION };
let token = null;
try { token = this.getToken(); } catch { token = null; }
if (typeof token === 'string' && token.length > 0 && token.length <= 64) msg.token = token;
this._helloRid = rid;
this._helloSentName = this.name;
if (this.status !== 'handshaking') this._setStatus('handshaking');
if (!this._sendRaw(msg)) return;
this._clearTimer('_helloTimer', 'clearTimeout');
this._helloTimer = this.timers.setTimeout(() => {
this._helloTimer = null;
if (this.status !== 'handshaking') return;
console.warn('[net] hello timed out; reconnecting');
const ws = this.ws;
this._teardownSocket();
try { ws?.close(4000, 'hello timeout'); } catch { /* ignore */ }
this._failPending('DISCONNECTED', false);
this._scheduleReconnect();
}, HELLO_TIMEOUT_MS);
}
_onWelcome(msg) {
this._clearTimer('_helloTimer', 'clearTimeout');
this._helloRid = null;
// Compare future setName() calls against what we sent (the server may normalise the name).
this.helloName = this._helloSentName;
this.serverName = typeof msg.name === 'string' && msg.name ? msg.name : this._helloSentName;
this.playerId = msg.playerId ?? null;
this.attempt = 0;
this.lastError = null;
if (Number.isFinite(msg.serverNow)) this._addClockSample(msg.serverNow + (this.ping ?? 0) / 2 - this.now(), Infinity);
this._setStatus('online');
this._flushQueue();
this._sendPing();
}
_onHelloError(msg) {
this._clearTimer('_helloTimer', 'clearTimeout');
this._helloRid = null;
this.lastError = new NetError(msg.code, msg.msg, msg.detail);
this._setStatus('connected');
// Queued requests can't be sent without a session.
this._failPending('OFFLINE', true);
this._emit('helloError', this.lastError);
}
// ---- requests --------------------------------------------------------------------------------
_nextRid() {
this._rid = (this._rid % 2147483000) + 1;
return this._rid;
}
/**
* Send a request and wait for its `ok` (resolves with the reply message) or `error`
* (rejects with NetError). Rejects with TIMEOUT after `timeout` ms, OFFLINE when there is no
* session to send it on, BAD_MSG when it fails shared/protocol.js validation locally.
* @param {string} t message type (C2S)
* @param {object} [fields]
* @param {{timeout?: number}} [opts]
* @returns {Promise<any>}
*/
request(t, fields = {}, opts = {}) {
return new Promise((resolve, reject) => {
if (t === 'hello') { reject(new NetError('BAD_MSG', 'use setName() for hello')); return; }
const rid = this._nextRid();
const msg = { ...(fields && typeof fields === 'object' ? fields : {}), t, rid };
const bad = validateC2S(msg);
if (bad) {
console.warn(`[net] refusing invalid ${t}: ${bad}`);
reject(new NetError('BAD_MSG', bad));
return;
}
// Queue while a session is being (re)established; fail fast when none can come.
const establishing = this.status === 'connecting' || this.status === 'handshaking' || this.status === 'reconnecting';
if (this.status !== 'online' && !(establishing && this.name && !this._manualClose)) {
reject(new NetError(this._manualClose ? 'CLOSED' : 'OFFLINE'));
return;
}
const timeout = Number.isFinite(opts.timeout) && opts.timeout > 0 ? opts.timeout : REQUEST_TIMEOUT_MS;
const entry = { msg, resolve, reject, sent: false, timer: null };
entry.timer = this.timers.setTimeout(() => {
if (this._pending.get(rid) !== entry) return;
this._pending.delete(rid);
if (entry.sent) {
this._expired.add(rid);
if (this._expired.size > 64) this._expired.delete(this._expired.values().next().value);
}
reject(new NetError('TIMEOUT'));
}, timeout);
this._pending.set(rid, entry);
if (this.status === 'online') entry.sent = this._sendRaw(msg);
});
}
/**
* Fire-and-forget message (no rid). Returns false when it could not be sent.
* @param {string} t
* @param {object} [fields]
* @returns {boolean}
*/
send(t, fields = {}) {
if (t === 'hello') return false;
const msg = { ...(fields && typeof fields === 'object' ? fields : {}), t };
const bad = validateC2S(msg);
if (bad) { console.warn(`[net] refusing invalid ${t}: ${bad}`); return false; }
if (this.status !== 'online') return false;
return this._sendRaw(msg);
}
_sendRaw(obj) {
const ws = this.ws;
if (!ws || ws.readyState !== WS_OPEN) return false;
try {
ws.send(JSON.stringify(obj));
return true;
} catch (err) {
console.warn('[net] send failed', err);
return false;
}
}
_flushQueue() {
for (const entry of this._pending.values()) {
if (!entry.sent) entry.sent = this._sendRaw(entry.msg);
}
}
/**
* Reject pending requests. Sent ones always fail (their reply is lost with the socket);
* unsent (queued) ones fail only when `includeQueued` is true.
*/
_failPending(code, includeQueued) {
for (const [rid, entry] of [...this._pending]) {
if (!entry.sent && !includeQueued) continue;
this._pending.delete(rid);
this._clearEntryTimer(entry);
try { entry.reject(new NetError(code)); } catch { /* ignore */ }
}
}
_clearEntryTimer(entry) {
if (entry.timer != null) {
try { this.timers.clearTimeout(entry.timer); } catch { /* ignore */ }
entry.timer = null;
}
}
/** Number of requests waiting for a reply (sent or queued). */
get pendingCount() {
return this._pending.size;
}
// ---- incoming --------------------------------------------------------------------------------
_onMessage(data) {
this._lastRx = this.now();
this._unansweredSince = null;
let msg;
try {
msg = JSON.parse(typeof data === 'string' ? data : String(data));
} catch {
console.warn('[net] dropped non-JSON frame');
return;
}
if (!msg || typeof msg !== 'object' || Array.isArray(msg) || typeof msg.t !== 'string') return;
const { t } = msg;
const rid = msg.rid;
const isHelloError = t === 'error' && rid != null && rid === this._helloRid;
if (t === 'welcome') {
this._onWelcome(msg);
} else if (t === 'pong') {
this._onPong(msg);
} else if (isHelloError) {
this._onHelloError(msg);
} else if (t === 'm.public' && Number.isFinite(msg.serverNow) && !this.clockSynced) {
this._addClockSample(msg.serverNow + (this.ping ?? 0) / 2 - this.now(), Infinity);
}
let handled = false;
if (rid != null && t !== 'welcome' && this._pending.has(rid)) {
const entry = this._pending.get(rid);
this._pending.delete(rid);
this._clearEntryTimer(entry);
handled = true;
try {
if (t === 'error') entry.reject(new NetError(msg.code, msg.msg, msg.detail));
else entry.resolve(msg);
} catch (err) { console.error('[net] request callback failed', err); }
}
const late = rid != null && !handled && this._expired.delete(rid); // reply to a request that already timed out
if (t === 'error' && !handled && !isHelloError && !late) {
this._emit('unhandledError', new NetError(msg.code, msg.msg, msg.detail));
}
this._emit(t, msg);
this._emit('*', msg);
}
// ---- heartbeat & clock -----------------------------------------------------------------------
_startHeartbeat() {
this._clearTimer('_pingTimer', 'clearInterval');
this._pingTimer = this.timers.setInterval(() => this._heartbeat(), PING_INTERVAL_MS);
}
_heartbeat() {
const ws = this.ws;
if (!ws || ws.readyState !== WS_OPEN) return;
const live = this.status === 'online' || this.status === 'connected';
if (live && this._unansweredSince != null && this.now() - this._unansweredSince > DEAD_AFTER_MS) {
console.warn('[net] connection silent; reconnecting');
this._teardownSocket();
try { ws.close(4000, 'heartbeat timeout'); } catch { /* ignore */ }
this._failPending('DISCONNECTED', false);
this._scheduleReconnect();
return;
}
this._sendPing();
}
_sendPing() {
if (this.status !== 'online' && this.status !== 'connected') return;
const now = this.now();
if (this._sendRaw({ t: 'ping', c: now }) && this._unansweredSince == null) this._unansweredSince = now;
}
/** Force an immediate latency probe (e.g. when the tab regains focus). */
probe() {
this._heartbeat();
}
_onPong(msg) {
const now = this.now();
const c = Number(msg.c);
if (!Number.isFinite(c)) return;
const rtt = now - c;
if (!(rtt >= 0 && rtt < 60000)) return;
this.ping = Math.round(rtt);
if (Number.isFinite(msg.s)) this._addClockSample(msg.s + rtt / 2 - now, rtt);
this._emit('ping', this.ping);
this._emit('status', this.snapshot());
}
_addClockSample(offset, rtt) {
if (!Number.isFinite(offset)) return;
this._clockSamples.push({ offset, rtt });
if (this._clockSamples.length > 8) this._clockSamples.shift();
let best = this._clockSamples[0];
for (const s of this._clockSamples) if (s.rtt < best.rtt) best = s;
const prev = this.clockOffset;
this.clockOffset = Math.round(best.offset);
const wasSynced = this.clockSynced;
this.clockSynced = this.clockSynced || Number.isFinite(rtt);
if (Math.abs(prev - this.clockOffset) >= 1 || wasSynced !== this.clockSynced || this._clockSamples.length === 1) {
this._emit('clock', { offset: this.clockOffset, rtt: Number.isFinite(best.rtt) ? best.rtt : null, synced: this.clockSynced });
}
}
/** Current server time estimate (ms epoch). */
serverNow() {
return this.now() + this.clockOffset;
}
/**
* Hook browser signals: reconnect immediately when the network comes back or the tab becomes
* visible, and probe latency on focus. Safe to call in non-browser environments (no-op).
*/
attachBrowserHooks() {
if (typeof window === 'undefined' || this._hooked) return;
this._hooked = true;
window.addEventListener('online', () => this.retryNow());
document.addEventListener('visibilitychange', () => {
if (document.visibilityState !== 'visible') return;
if (this.status === 'reconnecting') this.retryNow();
else if (this.status === 'online' || this.status === 'connected') this.probe();
});
}
}
// ---- identity persistence -------------------------------------------------------------------------
//
// Which reconnect token a tab uses is decided once per page load by `identity.init()`:
// 1. this tab's own token (sessionStorage: survives reloads of this tab),
// 2. else the most recent token of this browser (localStorage, so closing the tab and reopening
// the game within the server's reconnect window resumes the seat),
// skipping every token that another *live* tab of this browser is using. Liveness is asked over a
// BroadcastChannel ('who holds these token hashes?' → 'mine'), not inferred from timer heartbeats:
// browsers throttle background-tab timers to ~1/min, which would make a busy background tab look
// dead. A duplicated tab (sessionStorage is copied) therefore drops the copied token and becomes a
// new player instead of kicking the original tab off its session (server close 4001). Two tabs
// resolving at the same moment tie-break on tab id. Without BroadcastChannel a tab only ever uses
// its own token (never another tab's), so it can't steal a live session either.
const K_NAME = 'sp.name';
const K_TOKEN = 'sp.token'; // sessionStorage: this tab's token
const K_RECENT = 'sp.tokens'; // localStorage: this browser's recent tokens, most recent first
const K_ENTERED = 'sp.entered'; // sessionStorage: this tab passed the title screen
const RECENT_MAX = 4;
const TOKEN_MAX_LEN = 64; // protocol limit for hello.token
const CHANNEL_NAME = 'sp.identity';
/** How long init() waits for other tabs to claim a candidate token (ms). */
export const CLAIM_QUERY_MS = 150;
function safeStorage(kind) {
try {
const s = globalThis[kind];
if (!s) return null;
const probe = '__sp_probe__';
s.setItem(probe, '1');
s.removeItem(probe);
return s;
} catch {
return null;
}
}
function sget(s, k) { try { return s ? s.getItem(k) : null; } catch { return null; } }
function sset(s, k, v) { try { if (s) s.setItem(k, v); } catch { /* quota / privacy mode */ } }
function sdel(s, k) { try { if (s) s.removeItem(k); } catch { /* ignore */ } }
const isToken = (t) => typeof t === 'string' && t.length > 0 && t.length <= TOKEN_MAX_LEN;
/**
* Short, stable hash of a token (FNV-1a, hex) so tabs never broadcast tokens in clear.
* @param {string} token
* @returns {string}
*/
export function tokenHash(token) {
let h = 2166136261;
const s = String(token);
for (let i = 0; i < s.length; i++) {
h ^= s.charCodeAt(i);
h = Math.imul(h, 16777619) >>> 0;
}
return h.toString(16).padStart(8, '0');
}
function defaultChannel() {
try {
return typeof globalThis.BroadcastChannel === 'function' ? new globalThis.BroadcastChannel(CHANNEL_NAME) : null;
} catch {
return null;
}
}
/**
* Create the identity helper (nickname, "entered" flag and reconnect-token selection; see above).
* @param {{ local?: Storage|null, session?: Storage|null, tabId?: string,
* channel?: { postMessage: Function, addEventListener?: Function, onmessage?: any } | null,
* setTimeout?: (fn: Function, ms: number) => any, queryMs?: number }} [deps]
*/
export function createIdentity(deps = {}) {
const local = deps.local !== undefined ? deps.local : safeStorage('localStorage');
const session = deps.session !== undefined ? deps.session : safeStorage('sessionStorage');
const tabId = deps.tabId || Math.random().toString(36).slice(2) + Date.now().toString(36);
const setTimer = deps.setTimeout || ((fn, ms) => globalThis.setTimeout(fn, ms));
const queryMs = Number.isFinite(deps.queryMs) && deps.queryMs >= 0 ? deps.queryMs : CLAIM_QUERY_MS;
let channel = null;
let initPromise = null;
let initialized = false;
let current = null; // token this tab uses (null = let the server create a session)
let resolving = null; // { taken: Set<hash> } while init() waits for claims
const readRecent = () => {
try {
const v = JSON.parse(sget(local, K_RECENT) || '[]');
return Array.isArray(v) ? v.filter(isToken) : [];
} catch {
return [];
}
};
const writeRecent = (list) => sset(local, K_RECENT, JSON.stringify([...new Set(list)].slice(0, RECENT_MAX)));
const post = (msg) => {
try { channel?.postMessage({ ...msg, from: tabId }); } catch { /* channel closed */ }
};
function onChannelMessage(ev) {
const m = ev && ev.data;
if (!m || typeof m !== 'object' || m.from === tabId || typeof m.from !== 'string') return;
if (m.type === 'who' && Array.isArray(m.hashes)) {
if (current) {
const h = tokenHash(current);
if (m.hashes.includes(h)) post({ type: 'mine', hash: h, to: m.from });
}
// Another tab is choosing at the same time: the smaller tab id has precedence.
if (resolving && m.from < tabId) for (const h of m.hashes) if (typeof h === 'string') resolving.taken.add(h);
} else if (m.type === 'mine' && resolving && m.to === tabId && typeof m.hash === 'string') {
resolving.taken.add(m.hash);
}
}
async function resolveToken() {
channel = deps.channel !== undefined ? deps.channel : defaultChannel();
if (channel) {
if (typeof channel.addEventListener === 'function') channel.addEventListener('message', onChannelMessage);
else channel.onmessage = onChannelMessage;
}
const own = sget(session, K_TOKEN);
const ownOk = isToken(own) ? own : null;
// Without a channel we cannot tell whether a shared token is in use: never adopt one.
const candidates = [...new Set([ownOk, ...(channel ? readRecent() : [])].filter(isToken))];
if (!channel || candidates.length === 0) return ownOk;
resolving = { taken: new Set() };
post({ type: 'who', hashes: candidates.map(tokenHash) });
await new Promise((r) => setTimer(r, queryMs));
const { taken } = resolving;
resolving = null;
return candidates.find((t) => !taken.has(tokenHash(t))) || null;
}
return {
tabId,
/**
* Decide which reconnect token this tab uses (once per page load; later calls return the same
* promise). Call before the first `hello`. Never rejects.
* @returns {Promise<string|null>}
*/
init() {
if (!initPromise) {
initPromise = resolveToken().catch(() => null).then((pick) => {
const own = sget(session, K_TOKEN);
if (pick) sset(session, K_TOKEN, pick);
else if (own) sdel(session, K_TOKEN); // duplicated tab: the copied token belongs to a live tab
// A welcome may already have set a token while we were waiting: that one wins.
if (!current) current = pick;
initialized = true;
return current;
});
}
return initPromise;
},
/** @returns {string} remembered player name ('' if none) */
loadName: () => (sget(local, K_NAME) || '').slice(0, 64),
/** @param {string} name */
saveName: (name) => sset(local, K_NAME, String(name)),
/** Token for `hello` (null ⇒ new session). Before init() only this tab's own token is used. */
getToken() {
if (current) return current;
if (initialized) return null;
const own = sget(session, K_TOKEN);
return isToken(own) ? own : null;
},
/** @param {string} token from `welcome` */
saveToken(token) {
if (!isToken(token)) return;
current = token;
sset(session, K_TOKEN, token);
writeRecent([token, ...readRecent().filter((t) => t !== token)]);
},
/** Forget this tab's token (e.g. the server said the session is invalid). */
clearToken() {
const t = current || sget(session, K_TOKEN);
current = null;
sdel(session, K_TOKEN);
if (t) writeRecent(readRecent().filter((x) => x !== t));
},
/** Whether this tab already passed the title screen (survives reloads, not new tabs). */
wasEntered: () => sget(session, K_ENTERED) === '1',
/** @param {boolean} on */
setEntered: (on) => (on ? sset(session, K_ENTERED, '1') : sdel(session, K_ENTERED)),
};
}
/** Browser identity singleton (main.js awaits `identity.init()` before connecting). */
export const identity = createIdentity();
/** Browser connection singleton (created lazily-safe: nothing touches the network until connect()). */
export const net = new Net({ getToken: () => identity.getToken() });
File diff suppressed because it is too large. Load diff
+136
View File
@@ -0,0 +1,136 @@
// render/board3d/atlas.js — which pixels of the official board textures dress each surface of the 3D board
// (DESIGN §15). Pure data + UV helpers (no three import): the geometry builder (layout.js) maps these rects straight
// onto the tiles, the three.js scene samples the textures of MT_autochess (TX_autochessi_D + _N + _M + _E, which
// share one UV layout) and MT_autochess_common (TX_autochessi_common_D/_E, device plates).
//
// Source of truth for the rects: this table. tools/crop-board-atlas.mjs validates every rect against the extracted
// images and writes it into public/assets/local/map/autochess/tiles.json → `board3d`, which the renderer prefers
// (so a re-crop needs no code change); `resolveUvTable(tiles)` merges it over these defaults.
//
// surface = { src: 'D' | 'common', rect: [x, y, w, h] (source px, y down), rot?: 0|90|180|270 (clockwise, of the
// texture on the face), flipX?: bool, tint?: '#rrggbb' (vertex colour multiply), glow?: number }
//
// Atlas pieces (TX_autochessi_D 2048², 256 px per floor cell; picked by eye, validated by the crop tool): gold-framed
// high-ground plates (row 0: strip L / M / R + single), concrete slabs (rows 1–2: rails, ✕ marker, hazard stripes,
// chevrons, red no-deploy stripes), wall panels (REST AREA / EVACUATION), REINFORCEMENTS / EQUIPMENTS bench pads
// (512² each), ribbed side panels (gold / grey), the hazard-framed glass hatch, the ▲▼ lift plate, grille slats,
// dark machinery hatch, gold ring hatch (objective), orange crate panels.
export const SOURCES = Object.freeze({
D: Object.freeze({ w: 2048, h: 2048 }),
common: Object.freeze({ w: 1024, h: 1024 }),
});
const S = (src, rect, o = {}) => Object.freeze({ src, rect: Object.freeze(rect), ...o });
/** Surface → atlas region (see header). Names are used by layout.js. */
export const SURFACES = Object.freeze({
// floors
concrete: S('D', [256, 512, 256, 256]),
concreteRailTL: S('D', [0, 256, 256, 256]),
concreteRailT: S('D', [256, 256, 256, 256]),
concreteRailL: S('D', [0, 512, 256, 256]),
concreteStripe: S('D', [512, 256, 256, 256]),
concreteArrow: S('D', [768, 256, 256, 256]),
concreteRed: S('D', [768, 512, 256, 256]),
hatch: S('D', [549, 1787, 255, 256]),
lift: S('D', [804, 1790, 234, 234]),
ringHatch: S('D', [1290, 1400, 190, 190]),
slats: S('D', [1036, 480, 404, 216]),
mech: S('D', [1088, 708, 356, 310]),
steel: S('D', [12, 776, 196, 196]),
// high ground (gold-framed plates; texture top = the tile's far edge)
plateL: S('D', [0, 0, 256, 256]),
plateM: S('D', [256, 0, 256, 256]),
plateR: S('D', [512, 0, 256, 256]),
plateS: S('D', [768, 0, 256, 256]),
// bench pads
padReinf: S('D', [0, 1024, 512, 512]),
padEquip: S('D', [512, 1024, 512, 512]),
// side panels (cropped to the face aspect at use: see sideRect)
goldSide: S('D', [0, 1664, 272, 384]),
graySide: S('D', [272, 1664, 272, 384]),
benchRail: S('D', [0, 1538, 512, 98]),
restPanel: S('D', [0, 768, 512, 208]),
evacPanel: S('D', [512, 768, 256, 208]),
pipePanel: S('D', [544, 1540, 480, 244]),
// devices
crateSide: S('D', [1087, 1938, 134, 108]),
crateTop: S('D', [1222, 1938, 134, 108], { rot: 90 }),
// common atlas plates (MT_autochess_common, alpha-tested decals)
hazardX: S('common', [2, 2, 250, 248]),
heal: S('common', [264, 2, 256, 250]),
shield: S('common', [538, 2, 256, 250]),
target: S('common', [2, 262, 250, 250]),
blast: S('common', [538, 262, 250, 250]),
enemyMark: S('common', [792, 258, 232, 262]),
fast: S('common', [2, 520, 250, 250]),
arrowUp: S('common', [508, 762, 244, 244]),
arrowDown: S('common', [776, 762, 244, 244]),
});
const isObj = (v) => v !== null && typeof v === 'object' && !Array.isArray(v);
/** Validate one surface record (from tiles.json) → a clean record or null. */
export function cleanSurface(v) {
if (!isObj(v) || !SOURCES[v.src] || !Array.isArray(v.rect) || v.rect.length !== 4) return null;
const [x, y, w, h] = v.rect.map(Number);
const src = SOURCES[v.src];
if (![x, y, w, h].every(Number.isFinite) || w < 4 || h < 4 || x < 0 || y < 0 || x + w > src.w || y + h > src.h) return null;
const rot = [0, 90, 180, 270].includes(v.rot) ? v.rot : 0;
const out = { src: v.src, rect: [x, y, w, h], rot, flipX: !!v.flipX };
if (typeof v.tint === 'string' && /^#[0-9a-f]{6}$/i.test(v.tint)) out.tint = v.tint;
return out;
}
/** Surface table: defaults overridden by a tiles.json `board3d` section (invalid entries ignored). */
export function resolveUvTable(tiles) {
const out = {};
for (const [k, v] of Object.entries(SURFACES)) out[k] = cleanSurface(v);
const extra = isObj(tiles) && isObj(tiles.board3d) ? tiles.board3d : null;
if (extra) for (const [k, v] of Object.entries(extra)) { const c = cleanSurface(v); if (c) out[k] = c; }
return out;
}
/**
* UV corners of a surface for a quad whose corners are given as [bottom-left, bottom-right, top-right, top-left]
* of the face as seen from outside (for a top face: near-left, near-right, far-right, far-left). Optional
* `sub` = [u0, v0, u1, v1] (fractions of the rect, v down) crops the rect (side panels, partial faces).
* Returns [u,v, u,v, u,v, u,v] in three's convention (v up, textures flipped on upload). `inset` (source px)
* keeps mip filtering inside the region.
*/
export function surfaceUV(surface, sub = null, inset = 2) {
const src = SOURCES[surface.src] || SOURCES.D;
let [x, y, w, h] = surface.rect;
if (sub) {
const [a, b, c, d] = sub;
x += w * a; y += h * b; const w2 = w * (c - a), h2 = h * (d - b); w = w2; h = h2;
}
const ix = Math.min(inset, w / 4), iy = Math.min(inset, h / 4);
const u0 = (x + ix) / src.w, u1 = (x + w - ix) / src.w;
const vTop = 1 - (y + iy) / src.h, vBot = 1 - (y + h - iy) / src.h;
// texture corners in face order BL, BR, TR, TL
let c = [[u0, vBot], [u1, vBot], [u1, vTop], [u0, vTop]];
if (surface.flipX) c = [c[1], c[0], c[3], c[2]];
const turns = ((surface.rot | 0) / 90) & 3;
// rotating the texture clockwise on the face = the face corner k samples texture corner k − turns
for (let t = 0; t < turns; t++) c = [c[1], c[2], c[3], c[0]];
return [c[0][0], c[0][1], c[1][0], c[1][1], c[2][0], c[2][1], c[3][0], c[3][1]];
}
/** Sub-rect (fractions) of a side panel that keeps the texture undistorted on a face `width` × `height` tiles. */
export function sideRect(surface, width, height) {
const [, , w, h] = surface.rect;
const faceAspect = Math.max(1e-3, height / Math.max(1e-3, width));
const texAspect = h / w;
if (faceAspect >= texAspect) return [0, 0, 1, 1];
const frac = faceAspect / texAspect;
return [0, 0, 1, frac];
}
/** '#rrggbb' → [r, g, b] (0..1); null → [1, 1, 1]. */
export function tintRgb(hex) {
if (typeof hex !== 'string' || !/^#[0-9a-f]{6}$/i.test(hex)) return [1, 1, 1];
const n = parseInt(hex.slice(1), 16);
return [((n >> 16) & 255) / 255, ((n >> 8) & 255) / 255, (n & 255) / 255];
}
+560
View File
@@ -0,0 +1,560 @@
// render/board3d/layout.js — the official board's geometry, built from a stage grid the way the client builds the
// autochess board at runtime (DESIGN §15): pure JS, no three import (tested in Node; render/board3d/scene.js turns
// the buckets into merged BufferGeometries).
//
// const board = buildBoard(stage, { uv: resolveUvTable(tiles), crate?: objMesh })
// → { grid, buckets: { board, decal, pipe }, gates: [{ r, c, kind: 'start'|'end' }], devices: [...],
// terrain: { water, mire, smog, infection: [[r,c]…] }, edges: [...cyan field edge strips], bounds, stats }
//
// World space = render/projection.js: x = col, y = row, z = height (up); tile (r,c) covers [c±½]×[r±½].
// Heights follow render/style.js TILE_H (the Pixi layers stand units on the same tops: tiles.js heightAt).
//
// Tiles (glyph legend DATA.md §12):
// LOW r road → concrete slab (8 rotation/flip variants) · R → hazard-striped concrete · f / p lane & preview pen →
// the hazard-framed glass hatch · b fence → concrete + an orange tubular railing around the fenced region ·
// S gate → chevron concrete (+ the official red start box, scene.js) · E objective → gold ring hatch (+ blue
// end box) · I / O → lift plate + ▼/▲ plate · m / g / d / i → concrete / grille / sunk basin / concrete with the
// animated terrain overlay of scene.js
// HIGH h → gold-framed plates chosen by connectivity (strip L / M / R, vertical strips, single) with gold ribbed
// sides · # / X → dark hatched blocks ("▨", the official boss-field / arena look; X with REST AREA /
// EVACUATION wall panels) · a / A → REINFORCEMENTS / EQUIPMENTS bench pads (own bench), concrete plates
// (Final Assault benches)
// Every top has a small bevel (tile seams catch the light), raised blocks have chamfered edges, vertex colours carry
// ambient occlusion at wall feet. The enemy preview pen's hatches (enemy_place_rect) go to their own `glass` bucket:
// the official pen reads as glossy glass panes in a hazard frame reflecting the sky (scene.js glassMaterial), not as
// the striped hatch. The Final Assault benches (rows 0–1) are dark-rimmed plates with a 2×2 grid of light panels. The island ("drawn" tiles: content + the forbidden ring around it) stands on
// panelled cliff faces; beyond it the official background plane shows through (scene.js).
import { SURFACES, surfaceUV, sideRect, tintRgb } from './atlas.js';
import { GLYPH, TILEKEY_GLYPH, TILE_H } from '../style.js';
import { parsePenRect, PEN_RECT } from '../pen.js';
export const ROWS = 19;
export const COLS = 21;
/** How far the island's cliff faces go down (tiles). */
export const CLIFF = 0.75;
/** Top bevel: inset (tiles) and drop (tiles) of low tiles and raised blocks. */
export const BEVEL = Object.freeze({ low: [0.035, 0.018], block: [0.04, 0.035] });
/** Deep-sea basins: floor depth below the ground. */
export const BASIN = 0.14;
const CONTENT_OF = (g) => g !== '#' && g !== 'X';
/**
* Areas built per phase (inclusive tile rects; configBlackBoard leftNormal ((6,0),(12,10)), rightNormal
* ((6,11),(12,20)), leftBoss ((0,0),(6,10)), rightBoss ((0,11),(6,20)); enemy_place_rect ((14,7),(18,13)) + the
* separator in front of it). The official prep / battle view shows only the player's own field and the preview pen
* (the island ends under the bench row); 联防 joins both normal halves; the Final Assault builds the boss field.
*/
export const AREAS = Object.freeze({
normal: Object.freeze([Object.freeze({ r0: 6, r1: 12, c0: 0, c1: 10 }), Object.freeze({ r0: 13, r1: 18, c0: 6, c1: 14 })]),
unite: Object.freeze([Object.freeze({ r0: 6, r1: 12, c0: 0, c1: 20 }), Object.freeze({ r0: 13, r1: 18, c0: 6, c1: 14 })]),
boss: Object.freeze([Object.freeze({ r0: 0, r1: 6, c0: 0, c1: 20 })]),
all: Object.freeze([Object.freeze({ r0: 0, r1: 18, c0: 0, c1: 20 })]),
});
/** Area set of a camera kind ('prep' | 'normal' | 'pen' | 'unite' | 'boss' | 'hidden' | 'bossPrep'). */
export function areaFor(kind) {
if (kind === 'unite') return AREAS.unite;
if (kind === 'boss' || kind === 'hidden' || kind === 'bossPrep') return AREAS.boss;
return AREAS.normal;
}
/** Union of area sets (camera transitions show both). */
export function unionAreas(...sets) {
const out = [];
const seen = new Set();
for (const s of sets) for (const a of s || []) { const k = `${a.r0},${a.r1},${a.c0},${a.c1}`; if (!seen.has(k)) { seen.add(k); out.push(a); } }
return out;
}
const hash2 = (r, c) => ((r * 73856093) ^ (c * 19349663)) >>> 0;
// ---- geometry accumulator ----------------------------------------------------------------------------------
export class Geom {
constructor() { this.pos = []; this.nrm = []; this.uv = []; this.col = []; this.idx = []; this.n = 0; }
/**
* Quad from 4 corners in counter-clockwise order seen from the front (BL, BR, TR, TL), `uv` = 8 numbers in the
* same corner order, `nrm` one [x,y,z] (flat) or 4, `col` one [r,g,b] or 4.
*/
quad(p, uv, nrm, col) {
const base = this.n;
for (let k = 0; k < 4; k++) {
const q = p[k];
this.pos.push(q[0], q[1], q[2]);
const nn = Array.isArray(nrm[0]) ? nrm[k] : nrm;
this.nrm.push(nn[0], nn[1], nn[2]);
this.uv.push(uv ? uv[k * 2] : 0, uv ? uv[k * 2 + 1] : 0);
const cc = col ? (Array.isArray(col[0]) ? col[k] : col) : [1, 1, 1];
this.col.push(cc[0], cc[1], cc[2]);
}
this.idx.push(base, base + 1, base + 2, base, base + 2, base + 3);
this.n += 4;
return this;
}
/** Triangle (CCW from the front). */
tri(p, nrm, col) {
const base = this.n;
for (let k = 0; k < 3; k++) {
this.pos.push(p[k][0], p[k][1], p[k][2]);
const nn = Array.isArray(nrm[0]) ? nrm[k] : nrm;
this.nrm.push(nn[0], nn[1], nn[2]);
this.uv.push(0, 0);
const cc = col ? (Array.isArray(col[0]) ? col[k] : col) : [1, 1, 1];
this.col.push(cc[0], cc[1], cc[2]);
}
this.idx.push(base, base + 1, base + 2);
this.n += 3;
return this;
}
get vertexCount() { return this.n; }
get triangleCount() { return this.idx.length / 3; }
/** Typed arrays for a BufferGeometry. */
finish() {
return {
position: new Float32Array(this.pos), normal: new Float32Array(this.nrm), uv: new Float32Array(this.uv),
color: new Float32Array(this.col), index: this.n > 65535 ? new Uint32Array(this.idx) : new Uint16Array(this.idx),
vertexCount: this.n, triangleCount: this.idx.length / 3,
};
}
}
const norm3 = (x, y, z) => { const l = Math.hypot(x, y, z) || 1; return [x / l, y / l, z / l]; };
/** Bilinear UV at fractions (fx right, fy far) of a face whose corner UVs are [BL, BR, TR, TL] (8 numbers). */
export function uvAt(uv, fx, fy) {
const bu = uv[0] + (uv[2] - uv[0]) * fx, bv = uv[1] + (uv[3] - uv[1]) * fx;
const tu = uv[6] + (uv[4] - uv[6]) * fx, tv = uv[7] + (uv[5] - uv[7]) * fx;
return [bu + (tu - bu) * fy, bv + (tv - bv) * fy];
}
// ---- stage classification -----------------------------------------------------------------------------------
/**
* Per-tile description of a stage: `{ r, c, glyph, content, drawn, h (top height), raised, surf (top surface),
* tint, side (side panel surface) }`. Tolerant of malformed stages (missing rows → forbidden). `area` = the built
* areas (inclusive tile rects, see AREAS): content outside them is not built, like the client which only builds the
* areas of the current phase (configBlackBoard leftNormal / rightNormal / leftBoss / rightBoss + the preview pen).
*/
export function classifyStage(stage, area = null) {
const rows = Array.isArray(stage?.rows) ? stage.rows : [];
const pen = parsePenRect(stage?.config?.enemy_place_rect) || PEN_RECT;
const legend = stage && typeof stage.tiles === 'object' && stage.tiles ? stage.tiles : {};
const grid = [];
for (let r = 0; r < ROWS; r++) {
const line = typeof rows[r] === 'string' ? rows[r] : '';
const row = [];
for (let c = 0; c < COLS; c++) {
let g = line[c] || '#';
if (!GLYPH[g]) g = TILEKEY_GLYPH[legend[g]?.tileKey] || (legend[g]?.height === 'HIGH' ? '#' : 'R');
const def = GLYPH[g];
const inArea = !area || area.some((a) => r >= a.r0 && r <= a.r1 && c >= a.c0 && c <= a.c1);
row.push({ r, c, glyph: g, content: inArea && CONTENT_OF(g), inArea, drawn: false, h: def.h ? TILE_H[def.h] : 0, raised: !!def.h });
}
grid.push(row);
}
// the island: content tiles + the forbidden / separator ring around them (Chebyshev 1)
for (let r = 0; r < ROWS; r++) for (let c = 0; c < COLS; c++) {
const t = grid[r][c];
if (t.content) { t.drawn = true; continue; }
if (!t.inArea) continue;
let near = false;
for (let dr = -1; dr <= 1 && !near; dr++) for (let dc = -1; dc <= 1; dc++) {
const n = grid[r + dr]?.[c + dc];
if (n && n.content) { near = true; break; }
}
t.drawn = near;
}
const at = (r, c) => grid[r]?.[c] || null;
for (const row of grid) for (const t of row) {
t.tint = [1, 1, 1];
t.surf = 'concrete';
t.rot = 0; t.flipX = false;
const h = hash2(t.r, t.c);
switch (t.glyph) {
case 'r': t.rot = (h % 4) * 90; t.flipX = ((h >> 3) & 1) === 1; break;
case 'R': t.surf = 'concreteStripe'; t.rot = (h & 1) ? 180 : 0; break;
case 'f': case 'p':
t.surf = 'hatch'; t.rot = (h % 4) * 90; if (t.glyph === 'p') t.tint = [0.9, 0.92, 0.95];
t.glass = t.r >= pen.r0 && t.r <= pen.r1 && t.c >= pen.c0 && t.c <= pen.c1;
break;
case 'b': t.rot = (h % 4) * 90; break;
case 'S': t.surf = 'concreteArrow'; break;
case 'E': t.surf = 'ringHatch'; break;
case 'I': case 'O': t.surf = 'lift'; t.rot = t.glyph === 'O' ? 180 : 0; break;
case 'g': t.surf = 'slats'; t.tint = [0.72, 0.76, 0.78]; break;
case 'm': t.rot = (h % 4) * 90; t.tint = [0.78, 0.8, 0.7]; break;
case 'i': t.rot = (h % 4) * 90; t.tint = [0.95, 0.88, 0.86]; break;
case 'd': t.surf = 'concrete'; t.tint = [0.35, 0.42, 0.46]; break;
// the own bench (rows 7–8): REINFORCEMENTS / EQUIPMENTS pads; the Final Assault benches (rows 0–1) are plain
// 2×2-slab concrete plates in the official boss field
case 'a': t.surf = t.r <= 1 ? 'concrete' : 'padReinf'; t.side = 'benchRail'; if (t.r <= 1) { t.tint = [0.3, 0.32, 0.35]; t.panels = true; } break;
case 'A': t.surf = t.r <= 1 ? 'concrete' : 'padEquip'; t.side = 'benchRail'; if (t.r <= 1) { t.tint = [0.3, 0.32, 0.35]; t.panels = true; } break;
case 'h': t.side = 'goldSide'; break;
// forbidden blocks and separators: the dark hatched ("▨") blocks of the official boss field / arena rows
case '#': t.surf = 'hatch'; t.rot = (h & 1) ? 90 : 0; t.tint = [0.34, 0.36, 0.39]; t.side = 'graySide'; t.sideTint = [0.52, 0.55, 0.58]; break;
case 'X': t.surf = 'hatch'; t.tint = [0.36, 0.38, 0.41]; t.side = (t.c % 5 === 2) ? 'evacPanel' : 'restPanel'; t.sideTint = [0.8, 0.82, 0.86]; break;
default: break;
}
}
// high-ground plates by connectivity (texture top = the tile's far edge)
const wall = (r, c) => { const n = at(r, c); return !!n && n.glyph === 'h'; };
for (const row of grid) for (const t of row) {
if (t.glyph !== 'h') continue;
const L = wall(t.r, t.c - 1), R = wall(t.r, t.c + 1);
if (L || R) { t.surf = L && R ? 'plateM' : R ? 'plateL' : 'plateR'; continue; }
const near = wall(t.r - 1, t.c), far = wall(t.r + 1, t.c);
if (near && far) { t.surf = 'plateM'; t.rot = 90; }
else if (far) { t.surf = 'plateL'; t.rot = 270; }
else if (near) { t.surf = 'plateL'; t.rot = 90; }
else t.surf = 'plateS';
}
return grid;
}
/** Top height of tile (r,c) of a classified grid (0 off-grid / not drawn), like tiles.js heightAt. */
export function heightOf(grid, r, c) {
const t = grid[r]?.[c];
return t && t.drawn ? t.h : 0;
}
// ---- builder ----------------------------------------------------------------------------------------------------
const DIRS = [['S', 0, -1], ['E', 1, 0], ['N', 0, 1], ['W', -1, 0]];
const NRM = { S: [0, -1, 0], E: [1, 0, 0], N: [0, 1, 0], W: [-1, 0, 0] };
/** Corners (BL, BR, TR, TL seen from outside) of the side face `dir` of box [x0,x1]×[y0,y1] between zb and zt. */
export function sideCorners(dir, x0, x1, y0, y1, zb, zt) {
switch (dir) {
case 'S': return [[x0, y0, zb], [x1, y0, zb], [x1, y0, zt], [x0, y0, zt]];
case 'N': return [[x1, y1, zb], [x0, y1, zb], [x0, y1, zt], [x1, y1, zt]];
case 'E': return [[x1, y0, zb], [x1, y1, zb], [x1, y1, zt], [x1, y0, zt]];
default: return [[x0, y1, zb], [x0, y0, zb], [x0, y0, zt], [x0, y1, zt]];
}
}
const mul = (a, b) => [a[0] * b[0], a[1] * b[1], a[2] * b[2]];
const scl = (a, k) => [a[0] * k, a[1] * k, a[2] * k];
/**
* Beveled top of a tile: an inset flat top at `zt` + 4 sloped strips down to `zt − drop` at the tile border.
* `ao` = corner occlusion [NL, NR, FR, FL] (1 = open).
*/
function bevelTop(g, uvTable, t, zt, inset, drop, ao, tint) {
const x0 = t.c - 0.5, x1 = t.c + 0.5, y0 = t.r - 0.5, y1 = t.r + 0.5;
const surf = uvTable[t.surf] || uvTable.concrete;
const uv = surfaceUV({ ...surf, rot: ((surf.rot | 0) + (t.rot | 0)) % 360, flipX: !!surf.flipX !== !!t.flipX });
const b = inset, zo = zt - drop;
const f = (fx, fy) => uvAt(uv, fx, fy);
const colAt = (fx, fy) => {
// bilinear corner AO
const a = ao[0] + (ao[1] - ao[0]) * fx, d = ao[3] + (ao[2] - ao[3]) * fx;
return scl(tint, a + (d - a) * fy);
};
const ib = b, ie = 1 - b;
// inner flat top
const P = (fx, fy, z) => [x0 + fx, y0 + fy, z];
g.quad([P(ib, ib, zt), P(ie, ib, zt), P(ie, ie, zt), P(ib, ie, zt)],
[...f(ib, ib), ...f(ie, ib), ...f(ie, ie), ...f(ib, ie)], [0, 0, 1],
[colAt(ib, ib), colAt(ie, ib), colAt(ie, ie), colAt(ib, ie)]);
if (drop <= 0) return;
const ns = norm3(0, -drop, b), nn = norm3(0, drop, b), ne = norm3(drop, 0, b), nw = norm3(-drop, 0, b);
// south strip (outer edge y0)
g.quad([P(0, 0, zo), P(1, 0, zo), P(ie, ib, zt), P(ib, ib, zt)], [...f(0, 0), ...f(1, 0), ...f(ie, ib), ...f(ib, ib)], ns,
[colAt(0, 0), colAt(1, 0), colAt(ie, ib), colAt(ib, ib)]);
// north strip (outer edge y1), seen from the north: BL = (1,1)
g.quad([P(1, 1, zo), P(0, 1, zo), P(ib, ie, zt), P(ie, ie, zt)], [...f(1, 1), ...f(0, 1), ...f(ib, ie), ...f(ie, ie)], nn,
[colAt(1, 1), colAt(0, 1), colAt(ib, ie), colAt(ie, ie)]);
// east strip (outer edge x1), seen from the east: BL = (1,0)
g.quad([P(1, 0, zo), P(1, 1, zo), P(ie, ie, zt), P(ie, ib, zt)], [...f(1, 0), ...f(1, 1), ...f(ie, ie), ...f(ie, ib)], ne,
[colAt(1, 0), colAt(1, 1), colAt(ie, ie), colAt(ie, ib)]);
// west strip (outer edge x0), seen from the west: BL = (0,1)
g.quad([P(0, 1, zo), P(0, 0, zo), P(ib, ib, zt), P(ib, ie, zt)], [...f(0, 1), ...f(0, 0), ...f(ib, ib), ...f(ib, ie)], nw,
[colAt(0, 1), colAt(0, 0), colAt(ib, ib), colAt(ib, ie)]);
}
/** Final Assault bench plate: a 2×2 grid of light concrete panels on the dark-rimmed top (the official boss field). */
export const PANEL = Object.freeze({ rim: 0.085, seam: 0.05, lift: 0.004 });
function benchPanels(g, uvTable, t, zt, ao) {
const surf = uvTable.concrete || uvTable.steel;
if (!surf) return;
const x0 = t.c - 0.5, y0 = t.r - 0.5, z = zt + PANEL.lift;
const a = PANEL.rim, h = PANEL.seam / 2;
const light = [0.9, 0.92, 0.94];
const span = [[a, 0.5 - h], [0.5 + h, 1 - a]];
for (let i = 0; i < 2; i++) for (let j = 0; j < 2; j++) {
const [fx0, fx1] = span[i], [fy0, fy1] = span[j];
// each panel shows a quarter of the concrete slab (texture v runs down: the far edge is the top of the crop)
const uv = surfaceUV(surf, [i * 0.5, (1 - j) * 0.5, i * 0.5 + 0.5, (1 - j) * 0.5 + 0.5]);
const col = (fx, fy) => { const s0 = ao[0] + (ao[1] - ao[0]) * fx, s1 = ao[3] + (ao[2] - ao[3]) * fx; return scl(light, s0 + (s1 - s0) * fy); };
g.quad([[x0 + fx0, y0 + fy0, z], [x0 + fx1, y0 + fy0, z], [x0 + fx1, y0 + fy1, z], [x0 + fx0, y0 + fy1, z]], uv, [0, 0, 1],
[col(fx0, fy0), col(fx1, fy0), col(fx1, fy1), col(fx0, fy1)]);
}
}
/** A panelled side face from zb to zt; the panel texture keeps its aspect (repeats along the face if needed). */
function sideFace(g, uvTable, surfName, dir, x0, x1, y0, y1, zb, zt, colBottom, colTop, width = 1) {
if (zt - zb < 1e-4) return;
const surf = uvTable[surfName] || uvTable.graySide;
const uv = surfaceUV(surf, sideRect(surf, width, zt - zb));
const c = sideCorners(dir, x0, x1, y0, y1, zb, zt);
g.quad(c, uv, NRM[dir], [colBottom, colBottom, colTop, colTop]);
}
/**
* Build the whole board of a stage.
* @param {any} stage data/stages.json entry
* @param {{ uv?: object, devices?: boolean }} [opts] uv = resolveUvTable(tiles.json) (defaults to the built-in table)
*/
export function buildBoard(stage, opts = {}) {
const uvTable = opts.uv || null;
const UVT = uvTable || Object.fromEntries(Object.entries(SURFACES).map(([k, v]) => [k, { ...v, rect: [...v.rect] }]));
const grid = classifyStage(stage, opts.area || null);
const at = (r, c) => grid[r]?.[c] || null;
const topZ = (t) => (t.glyph === 'd' ? -BASIN : t.h);
const board = new Geom(), decal = new Geom(), pipe = new Geom(), glass = new Geom();
const gates = [], terrain = { water: [], mire: [], smog: [], infection: [] };
let minX = Infinity, maxX = -Infinity, minY = Infinity, maxY = -Infinity;
for (let r = 0; r < ROWS; r++) for (let c = 0; c < COLS; c++) {
const t = grid[r][c];
if (!t.drawn) continue;
minX = Math.min(minX, c - 0.5); maxX = Math.max(maxX, c + 0.5); minY = Math.min(minY, r - 0.5); maxY = Math.max(maxY, r + 0.5);
const zt = topZ(t);
const x0 = c - 0.5, x1 = c + 0.5, y0 = r - 0.5, y1 = r + 0.5;
const tint = t.tint;
// corner occlusion: a corner touching a taller neighbour darkens (NL, NR, FR, FL)
const occl = (dx, dy) => {
let hi = false;
for (const [ax, ay] of [[0, 0], [dx, 0], [0, dy], [dx, dy]]) {
const n = at(r + ay, c + ax);
if (n && n.drawn && topZ(n) > zt + 0.05) hi = true;
}
return hi ? 0.66 : 1;
};
const ao = [occl(-1, -1), occl(1, -1), occl(1, 1), occl(-1, 1)];
const [inset, drop] = t.raised ? BEVEL.block : BEVEL.low;
bevelTop(t.glass ? glass : board, UVT, t, zt, inset, t.glyph === 'd' ? 0 : drop, ao, tint);
if (t.panels) benchPanels(board, UVT, t, zt, ao);
const zEdge = zt - (t.glyph === 'd' ? 0 : drop);
// sides: towards lower drawn neighbours (block sides / basin walls) and the island edge (cliffs)
for (const [dir, dx, dy] of DIRS) {
const n = at(r + dy, c + dx);
const nDrawn = !!(n && n.drawn);
const nz = nDrawn ? topZ(n) - (n.glyph === 'd' ? 0 : (n.raised ? BEVEL.block[1] : BEVEL.low[1])) : -CLIFF;
if (!nDrawn) {
// island edge: the tile's own side (if raised) then the platform's panelled cliff
const sideTint = t.sideTint || [1, 1, 1];
if (zEdge > 0.001) sideFace(board, UVT, t.side || 'graySide', dir, x0, x1, y0, y1, 0, zEdge, scl(sideTint, 0.7), sideTint);
sideFace(board, UVT, 'pipePanel', dir, x0, x1, y0, y1, -CLIFF, Math.min(0, zEdge), [0.16, 0.18, 0.2], [0.62, 0.66, 0.7], 1);
continue;
}
if (zEdge > nz + 1e-4) {
const surf = t.glyph === 'd' ? 'graySide' : (t.side || 'graySide');
const st = t.sideTint || (t.glyph === 'd' ? [0.45, 0.5, 0.55] : [1, 1, 1]);
sideFace(board, UVT, surf, dir, x0, x1, y0, y1, nz, zEdge, scl(st, 0.62), st);
}
}
// per-glyph extras
switch (t.glyph) {
case 'S': gates.push({ r, c, kind: 'start', z: 0 }); break;
case 'E': gates.push({ r, c, kind: 'end', z: 0 }); break;
case 'I': case 'O': {
const surf = UVT[t.glyph === 'I' ? 'arrowDown' : 'arrowUp'];
if (surf) {
const a = 0.3, z = zt + 0.004;
decal.quad([[c - a, r - a, z], [c + a, r - a, z], [c + a, r + a, z], [c - a, r + a, z]], surfaceUV(surf), [0, 0, 1], [1, 1, 1]);
}
break;
}
case 'd': terrain.water.push([r, c]); break;
case 'm': terrain.mire.push([r, c]); break;
case 'g': terrain.smog.push([r, c]); break;
case 'i': terrain.infection.push([r, c]); break;
default: break;
}
}
// fence railings: orange pipes around every connected region of fenced tiles ('b'), posts at the corners
const fence = (r, c) => { const n = at(r, c); return !!n && n.drawn && n.glyph === 'b'; };
const rail = { inset: 0.07, h: 0.26, rad: 0.032, post: 0.038 };
const posts = new Set();
for (let r = 0; r < ROWS; r++) for (let c = 0; c < COLS; c++) {
if (!fence(r, c)) continue;
const e = rail.inset;
const x0 = c - 0.5, x1 = c + 0.5, y0 = r - 0.5, y1 = r + 0.5;
const S = !fence(r - 1, c), N = !fence(r + 1, c), W = !fence(r, c - 1), E = !fence(r, c + 1);
const xa = W ? x0 + e : x0, xb = E ? x1 - e : x1, ya = S ? y0 + e : y0, yb = N ? y1 - e : y1;
if (S) tube(pipe, [xa, y0 + e, rail.h], [xb, y0 + e, rail.h], rail.rad);
if (N) tube(pipe, [xa, y1 - e, rail.h], [xb, y1 - e, rail.h], rail.rad);
if (W) tube(pipe, [x0 + e, ya, rail.h], [x0 + e, yb, rail.h], rail.rad);
if (E) tube(pipe, [x1 - e, ya, rail.h], [x1 - e, yb, rail.h], rail.rad);
const corner = (x, y, key) => { if (!posts.has(key)) { posts.add(key); tube(pipe, [x, y, 0], [x, y, rail.h + rail.rad], rail.post); } };
if (S && W) corner(x0 + e, y0 + e, `${r},${c},sw`);
if (S && E) corner(x1 - e, y0 + e, `${r},${c},se`);
if (N && W) corner(x0 + e, y1 - e, `${r},${c},nw`);
if (N && E) corner(x1 - e, y1 - e, `${r},${c},ne`);
// a mid post on long straight runs keeps the railing from floating
if (S && !W && !E && (c % 2 === 0)) corner(x0, y0 + e, `${r},${c},sm`);
if (N && !W && !E && (c % 2 === 0)) corner(x0, y1 - e, `${r},${c},nm`);
if (W && !S && !N && (r % 2 === 0)) corner(x0 + e, y0, `${r},${c},wm`);
if (E && !S && !N && (r % 2 === 0)) corner(x1 - e, y0, `${r},${c},em`);
}
// cyan field edge: emissive strips along the play areas' outer border (the official boundary glow)
const edges = [];
const zone = (t) => !!t && t.drawn && t.content && t.glyph !== 'a' && t.glyph !== 'A';
// "outside": undrawn tiles and the forbidden / separator tiles connected to them (interior holes are not)
const outside = new Set();
const stack = [];
for (let r = -1; r <= ROWS; r++) for (let c = -1; c <= COLS; c++) {
const t = at(r, c);
if (!t || !t.drawn) { outside.add(`${r},${c}`); stack.push([r, c]); }
}
while (stack.length) {
const [r, c] = stack.pop();
for (const [, dx, dy] of DIRS) {
const rr = r + dy, cc = c + dx, n = at(rr, cc);
if (!n || outside.has(`${rr},${cc}`) || !n.drawn || n.content) continue;
outside.add(`${rr},${cc}`);
stack.push([rr, cc]);
}
}
for (let r = 0; r < ROWS; r++) for (let c = 0; c < COLS; c++) {
const t = grid[r][c];
if (!zone(t)) continue;
for (const [dir, dx, dy] of DIRS) {
const n = at(r + dy, c + dx);
if (zone(n)) continue;
if (!outside.has(`${r + dy},${c + dx}`)) continue; // interior holes, bench pads: no boundary glow
edges.push({ r, c, dir, z: topZ(t) });
}
}
const devices = opts.devices === false ? [] : stageDevices(stage, grid);
for (const d of devices) {
if (d.kind === 'platform' || d.kind === 'sealedFloor' || d.kind === 'waterPlatform') slabDevice(board, decal, UVT, d, grid);
}
return {
grid, gates, devices, terrain, edges,
buckets: { board: board.finish(), decal: decal.finish(), pipe: pipe.finish(), glass: glass.finish() },
bounds: { x0: minX, x1: maxX, y0: minY, y1: maxY },
stats: { tiles: grid.flat().filter((t) => t.drawn).length, vertices: board.vertexCount + decal.vertexCount + pipe.vertexCount },
};
}
// ---- pipes -----------------------------------------------------------------------------------------------------
/** Closed-side cylinder from a to b (8 segments) with flat caps. */
export function tube(g, a, b, rad, seg = 8, col = [1, 1, 1]) {
const d = [b[0] - a[0], b[1] - a[1], b[2] - a[2]];
const len = Math.hypot(d[0], d[1], d[2]);
if (len < 1e-6) return g;
const w = [d[0] / len, d[1] / len, d[2] / len];
const ref = Math.abs(w[2]) < 0.9 ? [0, 0, 1] : [1, 0, 0];
let u = [w[1] * ref[2] - w[2] * ref[1], w[2] * ref[0] - w[0] * ref[2], w[0] * ref[1] - w[1] * ref[0]];
const ul = Math.hypot(...u); u = [u[0] / ul, u[1] / ul, u[2] / ul];
const v = [w[1] * u[2] - w[2] * u[1], w[2] * u[0] - w[0] * u[2], w[0] * u[1] - w[1] * u[0]];
const ring = (k) => { const t = (k / seg) * Math.PI * 2, cs = Math.cos(t), sn = Math.sin(t); return [u[0] * cs + v[0] * sn, u[1] * cs + v[1] * sn, u[2] * cs + v[2] * sn]; };
for (let k = 0; k < seg; k++) {
const n0 = ring(k), n1 = ring(k + 1);
const p = (base, n) => [base[0] + n[0] * rad, base[1] + n[1] * rad, base[2] + n[2] * rad];
g.quad([p(a, n0), p(a, n1), p(b, n1), p(b, n0)], null, [n0, n1, n1, n0], col);
}
for (const [end, sgn] of [[a, -1], [b, 1]]) {
const n = [w[0] * sgn, w[1] * sgn, w[2] * sgn];
for (let k = 0; k < seg; k++) {
const r0 = ring(k), r1 = ring(k + 1);
const p0 = [end[0] + r0[0] * rad, end[1] + r0[1] * rad, end[2] + r0[2] * rad];
const p1 = [end[0] + r1[0] * rad, end[1] + r1[1] * rad, end[2] + r1[2] * rad];
g.tri(sgn > 0 ? [end, p0, p1] : [end, p1, p0], n, col);
}
}
return g;
}
// ---- devices ----------------------------------------------------------------------------------------------------
const DEVICE_KINDS = new Set(['crate', 'platform', 'mound', 'blower', 'turret', 'waterPlatform', 'bush', 'sealedFloor']);
/** Active stage devices standing on drawn tiles: `{ kind, r, c, dir, z0, key, alias }`. */
export function stageDevices(stage, grid) {
const list = Array.isArray(stage?.devices) ? stage.devices : [];
const out = [];
for (const d of list) {
if (!d || !Array.isArray(d.pos) || !Number.isInteger(d.pos[0]) || !Number.isInteger(d.pos[1])) continue;
if (!DEVICE_KINDS.has(d.role)) continue;
const active = typeof d.active === 'boolean' ? d.active : !d.hidden;
if (!active) continue;
const [r, c] = d.pos;
const t = grid?.[r]?.[c];
if (!t || !t.drawn) continue;
out.push({ kind: d.role, r, c, dir: ['UP', 'DOWN', 'LEFT', 'RIGHT'].includes(d.dir) ? d.dir : 'UP', z0: t.h, key: d.key || null, alias: d.alias || null, rangeTiles: Array.isArray(d.rangeTiles) ? d.rangeTiles : null });
}
return out;
}
/** Device heights on their tile (platform tops are standing surfaces: tiles.js heightAt adds them). */
export const DEVICE_H = Object.freeze({ platform: TILE_H.platform, sealedFloor: 0.05, waterPlatform: 0.2 });
function slabDevice(board, decal, UVT, d, grid) {
const size = d.kind === 'sealedFloor' ? 0.92 : 0.94;
const h = DEVICE_H[d.kind] || 0.2;
const z0 = grid[d.r][d.c].h, zt = z0 + h;
const a = size / 2, x0 = d.c - a, x1 = d.c + a, y0 = d.r - a, y1 = d.r + a;
const topSurf = d.kind === 'sealedFloor' ? null : UVT.plateS;
if (topSurf) board.quad([[x0, y0, zt], [x1, y0, zt], [x1, y1, zt], [x0, y1, zt]], surfaceUV(topSurf), [0, 0, 1], [1.02, 1.02, 1.02]);
else decal.quad([[x0, y0, zt], [x1, y0, zt], [x1, y1, zt], [x0, y1, zt]], surfaceUV(UVT.hazardX), [0, 0, 1], [1, 1, 1]);
const side = UVT.graySide;
for (const dir of ['S', 'E', 'N', 'W']) {
const uv = surfaceUV(side, sideRect(side, size, h));
board.quad(sideCorners(dir, x0, x1, y0, y1, z0, zt), uv, NRM[dir], [[0.5, 0.53, 0.56], [0.5, 0.53, 0.56], [0.85, 0.88, 0.9], [0.85, 0.88, 0.9]]);
}
}
// ---- OBJ meshes → board space -------------------------------------------------------------------------------------
/**
* Convert a parsed OBJ (board3d/obj.js, Unity Y-up exported right-handed with X mirrored) into board space
* (x = col, y = row, z = up): (x', y', z') → (−x'·s, z'·s, y'·s) — a proper rotation, windings stay outward.
*/
export function objToBoard(obj, s = 1) {
const p = obj.position, n = obj.normal;
const pos = new Float32Array(p.length), nrm = n ? new Float32Array(n.length) : null;
for (let i = 0; i < p.length; i += 3) {
pos[i] = -p[i] * s; pos[i + 1] = p[i + 2] * s; pos[i + 2] = p[i + 1] * s;
if (nrm) { nrm[i] = -n[i]; nrm[i + 1] = n[i + 2]; nrm[i + 2] = n[i + 1]; }
}
return { ...obj, position: pos, normal: nrm };
}
/**
* Box-project UVs of a (board-space) mesh onto atlas surfaces: faces pointing up take `top`, the others `side`
* (mapped across the face's horizontal extent × height). Used for the official crate mesh (s_common_box_01), whose
* own material is assigned at runtime by the client.
*/
export function boxProjectUV(mesh, top, side) {
const p = mesh.position, n = mesh.normal;
let x0 = Infinity, x1 = -Infinity, y0 = Infinity, y1 = -Infinity, z0 = Infinity, z1 = -Infinity;
for (let i = 0; i < p.length; i += 3) {
x0 = Math.min(x0, p[i]); x1 = Math.max(x1, p[i]); y0 = Math.min(y0, p[i + 1]); y1 = Math.max(y1, p[i + 1]); z0 = Math.min(z0, p[i + 2]); z1 = Math.max(z1, p[i + 2]);
}
const uvTop = surfaceUV(top), uvSide = surfaceUV(side);
const out = new Float32Array((p.length / 3) * 2);
for (let i = 0, j = 0; i < p.length; i += 3, j += 2) {
const nx = n ? n[i] : 0, ny = n ? n[i + 1] : 0, nz = n ? n[i + 2] : 1;
let uv;
if (nz > 0.6) uv = uvAt(uvTop, (p[i] - x0) / (x1 - x0 || 1), (p[i + 1] - y0) / (y1 - y0 || 1));
else {
const fy = (p[i + 2] - z0) / (z1 - z0 || 1);
// horizontal coordinate along the face (seen from outside)
let fx;
if (Math.abs(nx) >= Math.abs(ny)) fx = nx > 0 ? (p[i + 1] - y0) / (y1 - y0 || 1) : (y1 - p[i + 1]) / (y1 - y0 || 1);
else fx = ny < 0 ? (p[i] - x0) / (x1 - x0 || 1) : (x1 - p[i]) / (x1 - x0 || 1);
uv = uvAt(uvSide, fx, fy);
}
out[j] = uv[0]; out[j + 1] = uv[1];
}
return { ...mesh, uv: out, bounds: { x0, x1, y0, y1, z0, z1 } };
}
+146
View File
@@ -0,0 +1,146 @@
// render/board3d/load.js — loading of three.js and the official board art (local-client extraction, DESIGN §13/§15).
//
// const THREE = await loadThree() // '/vendor/three.module.js' (tools/vendor.mjs), null when missing
// const pack = await loadBoardPack(assets) // null when the board art is not installed (→ 2D board)
// → { key, images: { D, N?, R?, E?, common?, commonE?, BG?, wind?, gate?, waterN?, caustics?, noise? },
// meshes: { crate?, blower?, bgPlane?, gate: { startDown?, startUp?, startBack?, endDown?, endUp? } },
// tiles, uv, materials: { theme?, fx? } }
//
// Everything is optional except the diffuse atlas: a missing map or mesh only drops that feature (e.g. no normal
// map → flat shading of the atlas; no crate mesh → a procedural box). URLs come from data/local-assets.json only
// (assets.localUrl) — nothing is guessed. Results are cached per page.
import { resolveUvTable } from './atlas.js';
import { parseObj } from './obj.js';
export const THREE_URL = '/vendor/three.module.js';
let threePromise = null;
/** Dynamic import of the vendored three.js ESM build (browser only); null when unavailable. */
export function loadThree(url = THREE_URL) {
if (!threePromise) {
threePromise = import(/* @vite-ignore */ url).then((m) => (m && m.WebGLRenderer ? m : null), () => null);
}
return threePromise;
}
/**
* Can this browser run the 3D board? (WebGL2 — three r163+ requires it.) Software / blocklisted GPUs ("major
* performance caveat") count as unavailable unless `allowSlow` (an explicit `?board=3d`).
*/
export function webgl2Available(allowSlow = false) {
try {
if (typeof document === 'undefined') return false;
const c = document.createElement('canvas');
const gl = c.getContext('webgl2', allowSlow ? {} : { failIfMajorPerformanceCaveat: true });
if (!gl) return false;
const lose = gl.getExtension('WEBGL_lose_context');
if (lose) lose.loseContext();
return true;
} catch { return false; }
}
const isObj = (v) => v !== null && typeof v === 'object' && !Array.isArray(v);
/** Manifest key → pack slot. */
export const PACK_IMAGES = Object.freeze({
D: ['map/autochess', 'TX_autochessi_D'],
N: ['map/autochess', 'TX_autochessi_N_rgb'],
R: ['map/autochess', 'TX_autochessi_M_rough'],
E: ['map/autochess', 'TX_autochessi_E'],
common: ['map/autochess', 'TX_autochessi_common_D'],
commonE: ['map/autochess', 'TX_autochessi_common_E'],
BG: ['map/autochess', 'TX_autochessi_BG'],
wind: ['map/common', 'TX_wind_device'],
gate: ['map/fx', '[opt]merged_textures'],
waterN: ['map/water', '[ucp]TX_water_normal'],
caustics: ['map/water', 'TX_Caustics256'],
noise: ['map/water', 'T_noise_clouds_01'],
});
export const PACK_MESHES = Object.freeze({
crate: ['mesh/s_common_box_01', 'pCube2'],
blower: ['mesh/s_wind_device', 'S_wild_wind_device'],
bgPlane: ['mesh/s_background_common', 'pPlane1'],
});
/** The official gate / objective boxes: prefab node → pack slot (meshes resolved through map/fx/prefab.json). */
export const GATE_NODES = Object.freeze({
startDown: 'Start_down', startUp: 'Start_up', startBack: 'Start_back', endDown: 'Start_down1', endUp: 'Start_up1',
});
let cached = null;
/**
* Does the local-art manifest list the board atlas? Reads the manifest only (no images): decides whether the
* ~2 MB three.js build is worth downloading at all (most hosts have no local-client art → 2D board, no three).
*/
export async function boardArtListed(assets) {
if (!assets || typeof assets.local !== 'function' || typeof assets.localUrl !== 'function') return false;
try { await assets.local(); } catch { return false; }
const u = assets.localUrl(...PACK_IMAGES.D);
return typeof u === 'string' && u.length > 0;
}
async function fetchText(url) {
try { const r = await fetch(url, { cache: 'no-cache' }); return r.ok ? await r.text() : null; } catch { return null; }
}
async function fetchJson(url) {
try { const r = await fetch(url, { cache: 'no-cache' }); return r.ok ? await r.json() : null; } catch { return null; }
}
/**
* Load the board pack through the asset store of public/js/assets.js (needs local(), localUrl(), image()).
* @returns {Promise<object|null>}
*/
export function loadBoardPack(assets) {
if (cached) return cached;
cached = (async () => {
if (!assets || typeof assets.local !== 'function' || typeof assets.localUrl !== 'function' || typeof assets.image !== 'function') return null;
const manifest = await assets.local().catch(() => null);
if (!isObj(manifest)) return null;
const url = (g, n) => { const u = assets.localUrl(g, n); return typeof u === 'string' && u ? encodeURI(u) : null; };
const dUrl = url(...PACK_IMAGES.D);
if (!dUrl) return null;
const images = {};
await Promise.all(Object.entries(PACK_IMAGES).map(async ([k, [g, n]]) => {
const u = url(g, n);
if (!u) return;
const img = await assets.image(u).catch(() => null);
if (img && (img.width || img.naturalWidth) > 0) images[k] = img;
}));
if (!images.D) return null;
const dir = dUrl.replace(/\/[^/]*$/, '');
const [tiles, theme, fxMats, fxPrefab] = await Promise.all([
fetchJson(`${dir}/tiles.json`),
(async () => { const u = url('map/autochess', 'materials'); return u ? fetchJson(u) : null; })(),
(async () => { const u = url('map/fx', 'materials'); return u ? fetchJson(u) : null; })(),
(async () => { const u = url('map/fx', 'prefab'); return u ? fetchJson(u) : null; })(),
]);
const meshes = { gate: {} };
await Promise.all(Object.entries(PACK_MESHES).map(async ([k, [g, n]]) => {
const u = url(g, n);
const text = u ? await fetchText(u) : null;
const m = text ? parseObj(text) : null;
if (m) meshes[k] = m;
}));
const prefab = Array.isArray(fxPrefab) ? fxPrefab : [];
await Promise.all(Object.entries(GATE_NODES).map(async ([slot, node]) => {
const rec = prefab.find((p) => p && p.name === node && (node !== 'Start_back' || p.parent === '[opt]start_box'));
const key = rec && typeof rec.mesh === 'string' ? rec.mesh : node;
const u = url('map/fx', key);
const text = u ? await fetchText(u) : null;
const m = text ? parseObj(text) : null;
if (m) meshes.gate[slot] = { mesh: m, material: rec && Array.isArray(rec.materials) ? rec.materials[0] || null : null };
}));
return {
key: `${dUrl}#${tiles?.version || 0}`,
images, meshes, tiles: isObj(tiles) ? tiles : null, uv: resolveUvTable(isObj(tiles) ? tiles : null),
materials: { theme: isObj(theme) ? theme : null, fx: isObj(fxMats) ? fxMats : null },
};
})().catch((err) => { console.warn('[board3d] art load failed', err); return null; });
return cached;
}
/** Forget the cached pack (tests / hot reload). */
export function resetBoardPack() { cached = null; threePromise = null; }
+391
View File
@@ -0,0 +1,391 @@
// render/board3d/materials.js — three.js materials of the official board scene (DESIGN §15). THREE is injected
// (the renderer loads /vendor/three.module.js; Node tests pass the npm package).
//
// MT_autochess → MeshStandardMaterial: albedo TX_autochessi_D (sRGB), normal TX_autochessi_N (BC5 → RGB,
// tools/local-extract DERIVED), roughness 1 − smoothness of TX_autochessi_M (DERIVED), metal 0,
// emission TX_autochessi_E (_EmissionColor white), vertex colours = baked AO / tints
// MT_autochess_common → device plates of TX_autochessi_common_D, alpha test 0.408, emission _E × 0.559
// MT_autochessi_BG → unlit TX_autochessi_BG × 0.713 (Torappu/Unlit/Texture, _Color)
// MT_wind_device → unlit TX_wind_device (the blower mesh)
// [opt]start_end_add / _ab → the gate / objective boxes: Torappu/Particles/Additive (2 · tint · tex) / AlphaBlend
// Every lit material gets the "focus" spotlight: fragments outside the camera's field rect fade towards `dim`
// (a soft world-space falloff), so the active field reads like the original's lit board without re-building
// geometry when the camera moves.
/** Shared focus uniforms (one object for every lit material). */
export function focusUniforms(THREE) {
return {
uFocus: { value: new THREE.Vector4(0, 9, 10, 12) }, // x0, y0, x1, y1 (world)
uFocusDim: { value: 0.72 },
uFocusSoft: { value: 2.2 },
// glossy "arena light" sheen: smooth surfaces (glass hatches, steel, plate centres; smoothness from TX_…_M) pick
// up a bright reflection like the official baked reflections — added as sheen · smoothness²
uSheen: { value: new THREE.Color(0.4, 0.45, 0.52) },
};
}
/** Inject the focus falloff into a built-in material (onBeforeCompile). */
export function addFocus(material, uniforms) {
const prev = material.onBeforeCompile;
material.onBeforeCompile = (shader, renderer) => {
if (typeof prev === 'function') prev(shader, renderer);
Object.assign(shader.uniforms, uniforms);
shader.vertexShader = shader.vertexShader
.replace('#include <common>', '#include <common>\nvarying vec3 vFocusWorld;')
.replace('#include <project_vertex>', '#include <project_vertex>\nvFocusWorld = (modelMatrix * vec4(transformed, 1.0)).xyz;');
shader.fragmentShader = shader.fragmentShader
// tinted (dark) blocks glow less: emission follows the vertex tint
.replace('#include <emissivemap_fragment>', '#include <emissivemap_fragment>\n#if defined( USE_COLOR )\n { float ek = (vColor.r + vColor.g + vColor.b) / 3.0; totalEmissiveRadiance *= ek * ek; }\n#endif')
.replace('#include <common>', '#include <common>\nvarying vec3 vFocusWorld;\nuniform vec4 uFocus;\nuniform float uFocusDim;\nuniform float uFocusSoft;\nuniform vec3 uSheen;')
.replace('#include <opaque_fragment>', [
'#ifdef USE_ROUGHNESSMAP',
' { float sm = clamp(1.0 - texelRoughness.g, 0.0, 1.0); float tintk = 1.0;',
' #if defined( USE_COLOR )',
' tintk = (vColor.r + vColor.g + vColor.b) / 3.0; tintk *= tintk;',
' #endif',
' outgoingLight += uSheen * sm * sm * tintk * diffuseColor.a; }',
'#endif',
'{',
' vec2 fq = vFocusWorld.xy;',
' vec2 fd = max(vec2(0.0), max(uFocus.xy - fq, fq - uFocus.zw));',
' float fm = 1.0 - smoothstep(0.0, uFocusSoft, length(fd));',
' outgoingLight *= mix(uFocusDim, 1.0, fm);',
'}',
'#include <opaque_fragment>',
].join('\n'));
};
material.customProgramCacheKey = () => 'sp-focus';
return material;
}
/** Texture from a loaded image (sRGB colour maps; linear data maps), mipmapped, anisotropic. */
export function makeTexture(THREE, image, { srgb = true, aniso = 1, repeat = false, flipY = true } = {}) {
if (!image) return null;
const t = new THREE.Texture(image);
t.colorSpace = srgb ? THREE.SRGBColorSpace : THREE.NoColorSpace;
t.anisotropy = Math.max(1, aniso | 0);
t.flipY = flipY;
if (repeat) { t.wrapS = THREE.RepeatWrapping; t.wrapT = THREE.RepeatWrapping; }
t.generateMipmaps = true;
t.minFilter = THREE.LinearMipmapLinearFilter;
t.magFilter = THREE.LinearFilter;
t.needsUpdate = true;
return t;
}
/** MT_autochess (see header). `tex` = { D, N?, R?, E? } textures. */
export function boardMaterial(THREE, tex, focus, opts = {}) {
const m = new THREE.MeshStandardMaterial({
map: tex.D, vertexColors: true,
normalMap: tex.N || null,
roughnessMap: tex.R || null, roughness: tex.R ? (opts.roughness ?? 0.78) : 0.78, metalness: 0,
emissiveMap: tex.E || null, emissive: tex.E ? new THREE.Color(1, 1, 1) : new THREE.Color(0, 0, 0),
emissiveIntensity: opts.emissive ?? 1,
});
if (tex.N) m.normalScale = new THREE.Vector2(opts.normalScale ?? 1, opts.normalScale ?? 1);
return addFocus(m, focus);
}
/**
* The enemy preview pen's glass hatches (official look: glossy panes in a hazard frame reflecting a cloudy sky, not the
* stripes under the glass): MT_autochess with the hatch's pane (the inner square of every tile, tile-local so the
* random rotation does not matter; the hazard frame and the plate rim stay as they are) re-shaded as sky-lit glass: a
* soft cloud reflection (T_noise_clouds_01 in world space), a faint trace of the pattern below, low roughness for the
* key light's glint. Same UVs / atlas as the board.
*/
export function glassMaterial(THREE, tex, focus, opts = {}) {
const m = boardMaterial(THREE, tex, focus, opts);
const prev = m.onBeforeCompile;
const uniforms = {
uCloud: { value: tex.noise || null },
uSkyLow: { value: new THREE.Color(0.4, 0.47, 0.54) },
uSkyHigh: { value: new THREE.Color(0.72, 0.77, 0.82) },
};
m.userData.glass = uniforms;
m.onBeforeCompile = (shader, renderer) => {
if (typeof prev === 'function') prev(shader, renderer);
Object.assign(shader.uniforms, uniforms);
shader.fragmentShader = shader.fragmentShader
.replace('#include <common>', '#include <common>\nuniform sampler2D uCloud;\nuniform vec3 uSkyLow;\nuniform vec3 uSkyHigh;')
.replace('#include <roughnessmap_fragment>', [
'#include <roughnessmap_fragment>',
'{',
// the pane: the inner square of the hatch (tile-local, any rotation), inside the hazard frame
' vec2 gq = abs(fract(vFocusWorld.xy + 0.5) - 0.5);',
' float glassK = 1.0 - smoothstep(0.355, 0.375, max(gq.x, gq.y));',
' float cl = texture2D(uCloud, vFocusWorld.xy * 0.06 + vec2(0.13, 0.41), 3.0).r * 0.7 + texture2D(uCloud, vFocusWorld.xy * 0.17, 2.0).r * 0.3;',
' vec3 sky = mix(uSkyLow, uSkyHigh, smoothstep(0.2, 0.8, cl));',
' vec3 under = diffuseColor.rgb;',
' diffuseColor.rgb = mix(under, sky + (under - vec3(0.35)) * 0.08, glassK);',
' roughnessFactor = mix(roughnessFactor, 0.22, glassK);',
' #ifdef USE_ROUGHNESSMAP',
' texelRoughness.g = mix(texelRoughness.g, 0.4, glassK);', // the sheen (addFocus) sees an even pane
' #endif',
'}',
].join('\n'));
};
m.customProgramCacheKey = () => 'sp-glass';
return m;
}
/** MT_autochess_common: alpha-tested device plates (drawn slightly above the tops). */
export function decalMaterial(THREE, tex, focus) {
const m = new THREE.MeshStandardMaterial({
map: tex.common, alphaTest: 0.408, roughness: 0.7, metalness: 0,
emissiveMap: tex.commonE || null, emissive: tex.commonE ? new THREE.Color(0.559, 0.559, 0.559) : new THREE.Color(0, 0, 0),
polygonOffset: true, polygonOffsetFactor: -2, polygonOffsetUnits: -2,
});
return addFocus(m, focus);
}
/** Orange tubular fence railings. */
export function pipeMaterial(THREE, focus) {
return addFocus(new THREE.MeshStandardMaterial({ color: 0xf0922b, roughness: 0.42, metalness: 0.35 }), focus);
}
/** Unlit textured material (Torappu/Unlit/Texture): background plane, wind device. */
export function unlitMaterial(THREE, map, color = 0xffffff, opts = {}) {
return new THREE.MeshBasicMaterial({ map, color, fog: opts.fog ?? true, side: opts.side ?? THREE.FrontSide });
}
/**
* Gate / objective box material ([opt]start_end_add: additive; [opt]start_end_ab: alpha blend). `uniforms.uPulse`
* animates the intensity (the clip's _TintColor.a curve).
*/
export function gateMaterial(THREE, map, { additive = true, tint = [1, 1, 1] } = {}) {
const uniforms = { map: { value: map }, uTint: { value: new THREE.Color(tint[0], tint[1], tint[2]) }, uPulse: { value: 1 }, uFlash: { value: new THREE.Color(0, 0, 0) } };
const m = new THREE.ShaderMaterial({
uniforms,
vertexShader: [
'varying vec2 vUv;',
'#include <common>',
'#include <fog_pars_vertex>',
'void main() {',
' vUv = uv;',
' vec4 mvPosition = modelViewMatrix * vec4(position, 1.0);',
' gl_Position = projectionMatrix * mvPosition;',
' #include <fog_vertex>',
'}',
].join('\n'),
fragmentShader: [
'uniform sampler2D map;',
'uniform vec3 uTint;',
'uniform float uPulse;',
'uniform vec3 uFlash;',
'varying vec2 vUv;',
'#include <common>',
'#include <fog_pars_fragment>',
'void main() {',
' vec4 t = texture2D(map, vUv);',
' vec3 c = t.rgb * uTint;',
' c = mix(c, uFlash * max(max(t.r, t.g), t.b) * 1.6, clamp(length(uFlash), 0.0, 1.0));',
additive ? ' gl_FragColor = vec4(c * t.a * uPulse, 1.0);' : ' gl_FragColor = vec4(c, t.a * clamp(uPulse, 0.0, 1.0));',
' #include <colorspace_fragment>',
'}',
].join('\n'),
transparent: true, depthWrite: false, side: THREE.DoubleSide,
blending: additive ? THREE.AdditiveBlending : THREE.NormalBlending,
fog: false,
});
m.userData.additive = additive;
return m;
}
/** Additive emissive strips (the cyan field edge); `map` = a dash pattern (alpha). */
export function glowMaterial(THREE, map, color = 0x39d6ff) {
return new THREE.MeshBasicMaterial({ map, color, transparent: true, depthWrite: false, blending: THREE.AdditiveBlending, fog: false, polygonOffset: true, polygonOffsetFactor: -3, polygonOffsetUnits: -3 });
}
// ---- animated special terrain (ShaderMaterials, own cheap lighting) -------------------------------------------
const TERRAIN_VERT = [
'varying vec2 vUv;',
'varying vec3 vWorld;',
'#include <common>',
'void main() {',
' vUv = uv;',
' vec4 w = modelMatrix * vec4(position, 1.0);',
' vWorld = w.xyz;',
' gl_Position = projectionMatrix * viewMatrix * w;',
'}',
].join('\n');
const FOCUS_GLSL = [
'uniform vec4 uFocus; uniform float uFocusDim; uniform float uFocusSoft;',
'float focusMask(vec2 q) { vec2 d = max(vec2(0.0), max(uFocus.xy - q, q - uFocus.zw)); return mix(uFocusDim, 1.0, 1.0 - smoothstep(0.0, uFocusSoft, length(d))); }',
].join('\n');
/** Deep sea: teal water with the official water normal map (two scrolling layers) and caustics. */
export function waterMaterial(THREE, tex, focus) {
const uniforms = { uTime: { value: 0 }, uNormal: { value: tex.waterN || null }, uCaustics: { value: tex.caustics || null }, ...focus };
return new THREE.ShaderMaterial({
uniforms,
vertexShader: TERRAIN_VERT,
fragmentShader: [
'uniform float uTime; uniform sampler2D uNormal; uniform sampler2D uCaustics;',
FOCUS_GLSL,
'varying vec2 vUv; varying vec3 vWorld;',
'void main() {',
' vec2 p = vWorld.xy * 0.45;',
' vec3 n1 = texture2D(uNormal, p + vec2(uTime * 0.03, uTime * 0.021)).xyz * 2.0 - 1.0;',
' vec3 n2 = texture2D(uNormal, p * 1.7 - vec2(uTime * 0.025, -uTime * 0.017)).xyz * 2.0 - 1.0;',
' vec3 n = normalize(vec3(n1.xy + n2.xy, 2.2));',
' vec3 L = normalize(vec3(-0.45, -0.3, 0.84));',
' float diff = clamp(dot(n, L), 0.0, 1.0);',
' vec3 V = normalize(vec3(0.0, -0.5, 0.866));',
' float spec = pow(clamp(dot(reflect(-L, n), V), 0.0, 1.0), 40.0);',
' float ca = texture2D(uCaustics, vWorld.xy * 0.6 + n.xy * 0.08 + vec2(uTime * 0.02, 0.0)).r;',
' vec3 deep = vec3(0.03, 0.2, 0.27), shallow = vec3(0.09, 0.46, 0.55);',
' vec3 c = mix(deep, shallow, 0.35 + 0.45 * diff) + ca * vec3(0.18, 0.32, 0.34) + spec * vec3(0.8, 0.95, 1.0);',
' c *= focusMask(vWorld.xy);',
' gl_FragColor = vec4(c, 0.9);',
' #include <colorspace_fragment>',
'}',
].join('\n'),
transparent: true, depthWrite: false,
});
}
/** Mire: slow brown-green sludge (cloud noise), sticky bubbles. */
export function mireMaterial(THREE, tex, focus) {
const uniforms = { uTime: { value: 0 }, uNoise: { value: tex.noise || null }, ...focus };
return new THREE.ShaderMaterial({
uniforms,
vertexShader: TERRAIN_VERT,
fragmentShader: [
'uniform float uTime; uniform sampler2D uNoise;',
FOCUS_GLSL,
'varying vec2 vUv; varying vec3 vWorld;',
'void main() {',
' vec2 p = vWorld.xy * 0.35;',
' float a = texture2D(uNoise, p + vec2(uTime * 0.012, uTime * 0.008)).r;',
' float b = texture2D(uNoise, p * 2.3 - vec2(uTime * 0.01, -uTime * 0.014)).r;',
' float m = smoothstep(0.25, 0.75, a * 0.65 + b * 0.5);',
' vec3 c = mix(vec3(0.16, 0.19, 0.08), vec3(0.37, 0.4, 0.17), m);',
' float bub = smoothstep(0.82, 0.9, b) * (0.5 + 0.5 * sin(uTime * 3.0 + a * 20.0));',
' c += bub * vec3(0.35, 0.4, 0.2);',
' vec2 e = min(vUv, 1.0 - vUv);',
' float edge = smoothstep(0.0, 0.12, min(e.x, e.y));',
' c *= focusMask(vWorld.xy);',
' gl_FragColor = vec4(c, 0.86 * edge);',
' #include <colorspace_fragment>',
'}',
].join('\n'),
transparent: true, depthWrite: false,
});
}
/** Infection (活性源石): dark originium crust patches with glowing, pulsing orange veins over the concrete. */
export function infectionMaterial(THREE, tex, focus) {
const uniforms = { uTime: { value: 0 }, uNoise: { value: tex.noise || null }, ...focus };
return new THREE.ShaderMaterial({
uniforms,
vertexShader: TERRAIN_VERT,
fragmentShader: [
'uniform float uTime; uniform sampler2D uNoise;',
FOCUS_GLSL,
'varying vec2 vUv; varying vec3 vWorld;',
'void main() {',
' float n = texture2D(uNoise, vWorld.xy * 0.55).r;',
' float n2 = texture2D(uNoise, vWorld.xy * 1.3 + 0.37).r;',
' float crust = smoothstep(0.38, 0.62, n * 0.7 + n2 * 0.45);',
' float vein = smoothstep(0.86, 0.98, 1.0 - abs(n2 * 2.0 - 1.0)) * (0.35 + 0.65 * crust);',
' float pulse = 0.6 + 0.4 * sin(uTime * 2.2 + n * 9.0);',
' vec2 e = min(vUv, 1.0 - vUv);',
' float edge = smoothstep(0.0, 0.1, min(e.x, e.y));',
' vec3 base = mix(vec3(0.16, 0.05, 0.05), vec3(0.3, 0.1, 0.07), n2);',
' vec3 glow = vec3(1.0, 0.46, 0.18) * (1.2 + 0.8 * pulse);',
' vec3 c = mix(base, glow, vein) * focusMask(vWorld.xy);',
' float a = clamp(crust * 0.72 + vein, 0.0, 1.0) * edge;',
' gl_FragColor = vec4(c, a);',
' #include <colorspace_fragment>',
'}',
].join('\n'),
transparent: true, depthWrite: false,
});
}
/** Smog: drifting exhaust haze (vertical billboards over the grilles). */
export function smogMaterial(THREE, tex, focus) {
const uniforms = { uTime: { value: 0 }, uNoise: { value: tex.noise || null }, ...focus };
return new THREE.ShaderMaterial({
uniforms,
vertexShader: TERRAIN_VERT,
fragmentShader: [
'uniform float uTime; uniform sampler2D uNoise;',
FOCUS_GLSL,
'varying vec2 vUv; varying vec3 vWorld;',
'void main() {',
' vec2 p = vec2(vUv.x * 0.8 + vWorld.x * 0.13, vUv.y * 0.7 - uTime * 0.12);',
' float n = texture2D(uNoise, p).r * 0.7 + texture2D(uNoise, p * 2.1 + vec2(uTime * 0.03, 0.0)).r * 0.5;',
' float fade = smoothstep(0.0, 0.25, vUv.y) * (1.0 - smoothstep(0.55, 1.0, vUv.y)) * smoothstep(0.0, 0.2, vUv.x) * (1.0 - smoothstep(0.8, 1.0, vUv.x));',
' float a = smoothstep(0.3, 0.9, n) * fade * 0.8;',
' vec3 c = vec3(0.74, 0.79, 0.78) * focusMask(vWorld.xy);',
' gl_FragColor = vec4(c, a);',
' #include <colorspace_fragment>',
'}',
].join('\n'),
transparent: true, depthWrite: false, side: THREE.DoubleSide,
});
}
/**
* Soft studio environment for image-based light (PMREM of a canvas equirect: bright sky with light panels above the
* horizon, dark void below): gives the glass hatches, steel and gold frames their sheen and fills the shadows.
* Y-up equirect — the scene rotates it to the board's z-up (scene.environmentRotation).
*/
export function environmentMap(THREE, renderer) {
if (typeof document === 'undefined' || !renderer) return null;
const W = 256, H = 128;
const c = document.createElement('canvas');
c.width = W; c.height = H;
const g = c.getContext('2d');
const grad = g.createLinearGradient(0, 0, 0, H);
grad.addColorStop(0, '#dfe7ef'); grad.addColorStop(0.32, '#b9c3cc'); grad.addColorStop(0.5, '#6d757d');
grad.addColorStop(0.56, '#262b30'); grad.addColorStop(1, '#0c0e10');
g.fillStyle = grad;
g.fillRect(0, 0, W, H);
// overhead light panels (the arena's floodlights) for crisp highlights
g.fillStyle = 'rgba(255,255,255,0.85)';
for (let i = 0; i < 6; i++) g.fillRect(8 + i * 42, 14 + (i % 2) * 8, 26, 7);
const tex = new THREE.CanvasTexture(c);
tex.mapping = THREE.EquirectangularReflectionMapping;
tex.colorSpace = THREE.SRGBColorSpace;
const pm = new THREE.PMREMGenerator(renderer);
const rt = pm.fromEquirectangular(tex);
pm.dispose();
tex.dispose();
return rt.texture;
}
/** Canvas texture of a dash pattern (cyan field edge). */
export function dashTexture(THREE) {
if (typeof document === 'undefined') return null;
const c = document.createElement('canvas');
c.width = 128; c.height = 8;
const g = c.getContext('2d');
g.clearRect(0, 0, 128, 8);
const grad = g.createLinearGradient(0, 0, 0, 8);
grad.addColorStop(0, 'rgba(255,255,255,0)'); grad.addColorStop(0.5, 'rgba(255,255,255,1)'); grad.addColorStop(1, 'rgba(255,255,255,0)');
g.fillStyle = grad;
for (let x = 2; x < 128; x += 32) g.fillRect(x, 0, 26, 8);
const t = new THREE.CanvasTexture(c);
t.wrapS = THREE.RepeatWrapping;
t.colorSpace = THREE.SRGBColorSpace;
return t;
}
/** Soft radial falloff texture (contact shadow of the island on the background, glow cards). */
export function softTexture(THREE, size = 64) {
if (typeof document === 'undefined') return null;
const c = document.createElement('canvas');
c.width = c.height = size;
const g = c.getContext('2d');
const grad = g.createRadialGradient(size / 2, size / 2, 0, size / 2, size / 2, size / 2);
grad.addColorStop(0, 'rgba(255,255,255,1)'); grad.addColorStop(1, 'rgba(255,255,255,0)');
g.fillStyle = grad;
g.fillRect(0, 0, size, size);
const t = new THREE.CanvasTexture(c);
t.colorSpace = THREE.SRGBColorSpace;
return t;
}
+94
View File
@@ -0,0 +1,94 @@
// render/board3d/obj.js — minimal Wavefront OBJ reader for the meshes exported by tools/local-extract (UnityPy:
// `v x y z [r g b [a]]`, `vt u v`, `vn x y z`, `f v/vt/vn …`, `g name`, `usemtl name`). Pure (Node-testable).
//
// parseObj(text) → { position: Float32Array, normal: Float32Array|null, uv: Float32Array|null,
// color: Float32Array|null, index: Uint16Array|Uint32Array, groups: [{ name, start, count }],
// bounds: { min: [x,y,z], max: [x,y,z] } } | null (no geometry)
// Faces with more than 3 corners are fanned; negative (relative) indices are honoured; unknown statements are
// ignored. Vertices are de-duplicated per (v, vt, vn) corner triplet.
export function parseObj(text) {
if (typeof text !== 'string' || !text) return null;
const v = [], vt = [], vn = [], vc = [];
const outPos = [], outNrm = [], outUv = [], outCol = [], idx = [];
const groups = [];
const map = new Map();
let group = { name: 'default', start: 0, count: 0 };
let hasColor = false;
const lines = text.split(/\r?\n/);
const ref = (s, len) => {
if (s === undefined || s === '') return -1;
const n = parseInt(s, 10);
if (!Number.isFinite(n) || n === 0) return -1;
return n > 0 ? n - 1 : len + n;
};
const corner = (tok) => {
const [a, b, c] = tok.split('/');
const iv = ref(a, v.length / 3), it = ref(b, vt.length / 2), inn = ref(c, vn.length / 3);
if (iv < 0 || iv * 3 + 2 >= v.length) return -1;
const key = `${iv}/${it}/${inn}`;
let id = map.get(key);
if (id !== undefined) return id;
id = outPos.length / 3;
outPos.push(v[iv * 3], v[iv * 3 + 1], v[iv * 3 + 2]);
outCol.push(vc[iv * 3] ?? 1, vc[iv * 3 + 1] ?? 1, vc[iv * 3 + 2] ?? 1);
if (it >= 0 && it * 2 + 1 < vt.length) outUv.push(vt[it * 2], vt[it * 2 + 1]); else outUv.push(0, 0);
if (inn >= 0 && inn * 3 + 2 < vn.length) outNrm.push(vn[inn * 3], vn[inn * 3 + 1], vn[inn * 3 + 2]); else outNrm.push(NaN, NaN, NaN);
map.set(key, id);
return id;
};
for (const raw of lines) {
const line = raw.trim();
if (!line || line[0] === '#') continue;
const parts = line.split(/\s+/);
switch (parts[0]) {
case 'v': {
const x = +parts[1], y = +parts[2], z = +parts[3];
v.push(Number.isFinite(x) ? x : 0, Number.isFinite(y) ? y : 0, Number.isFinite(z) ? z : 0);
if (parts.length >= 7) { hasColor = true; vc[v.length - 3] = +parts[4]; vc[v.length - 2] = +parts[5]; vc[v.length - 1] = +parts[6]; }
break;
}
case 'vt': vt.push(+parts[1] || 0, +parts[2] || 0); break;
case 'vn': vn.push(+parts[1] || 0, +parts[2] || 0, +parts[3] || 0); break;
case 'g': case 'o': case 'usemtl': {
if (group.count > 0) groups.push(group);
group = { name: parts.slice(1).join(' ') || 'default', start: idx.length, count: 0 };
break;
}
case 'f': {
const ids = parts.slice(1).map(corner);
if (ids.some((i) => i < 0) || ids.length < 3) break;
for (let k = 1; k + 1 < ids.length; k++) { idx.push(ids[0], ids[k], ids[k + 1]); group.count += 3; }
break;
}
default: break;
}
}
if (group.count > 0) groups.push(group);
if (!idx.length) return null;
const position = new Float32Array(outPos);
const min = [Infinity, Infinity, Infinity], max = [-Infinity, -Infinity, -Infinity];
for (let i = 0; i < position.length; i += 3) for (let a = 0; a < 3; a++) { min[a] = Math.min(min[a], position[i + a]); max[a] = Math.max(max[a], position[i + a]); }
const haveNormals = outNrm.every((x) => Number.isFinite(x));
return {
position,
normal: haveNormals ? new Float32Array(outNrm) : null,
uv: vt.length ? new Float32Array(outUv) : null,
color: hasColor ? new Float32Array(outCol) : null,
index: position.length / 3 > 65535 ? new Uint32Array(idx) : new Uint16Array(idx),
groups,
bounds: { min, max },
};
}
/** Uniformly transform a parsed mesh: `fn(x, y, z) → [x, y, z]` for positions, `nfn` for normals (optional). */
export function mapMesh(mesh, fn, nfn) {
const p = mesh.position, pos = new Float32Array(p.length);
for (let i = 0; i < p.length; i += 3) { const q = fn(p[i], p[i + 1], p[i + 2]); pos[i] = q[0]; pos[i + 1] = q[1]; pos[i + 2] = q[2]; }
let normal = mesh.normal;
if (normal && nfn) {
const n = mesh.normal; normal = new Float32Array(n.length);
for (let i = 0; i < n.length; i += 3) { const q = nfn(n[i], n[i + 1], n[i + 2]); normal[i] = q[0]; normal[i + 1] = q[1]; normal[i + 2] = q[2]; }
}
return { ...mesh, position: pos, normal };
}
+615
View File
@@ -0,0 +1,615 @@
// render/board3d/scene.js — the official 卫戍协议 board as a real three.js scene (DESIGN §15), rendered on its own
// canvas UNDER the Pixi canvas (units, FX, highlights, HP bars stay in Pixi). One camera model drives both layers:
// render/projection.js `syncThreeCamera` makes the three.js PerspectiveCamera project exactly like the Pixi one.
//
// const board = new BoardScene(THREE, pack, { canvas, quality }) (pack = load.js loadBoardPack)
// board.setStage(stage) geometry from the stage grid (layout.js) + devices + gates + terrain
// board.setArea(rects) the built areas (layout.js AREAS / areaFor: normal field + pen, 联防, boss)
// board.setFocus(rect) the lit field (others fade, animated)
// board.setBattleRect(rect|null) crates / turrets inside a running battle are sim units: static ones hide
// board.createDevice() → handle { update(cam, { x, y, z, size, height, top, side, alpha, rot }), mesh: null, destroy() }
// (render/units.js DeviceView boxes, drawn as the official crate mesh)
// board.flashObjective(r, c) leak flash of a blue objective box
// board.render(cam, timeSec) sync the camera, animate, draw
// board.resize(w, h, dpr) · board.stats() · board.destroy()
//
// Draw calls: board (tiles + blocks + platforms, merged, MT_autochess), the pen's glass hatches (glassMaterial),
// static crates (merged, same material),
// decals (MT_autochess_common), fence pipes, blowers (merged, unlit), gate boxes (additive + alpha, merged per
// material), cyan field edges, terrain overlays (≤ 4), background plane + its shadow catcher — ~12–16 in total.
// The key light's shadow map is rendered only when the geometry changes (autoUpdate off).
import { buildBoard, objToBoard, boxProjectUV, ROWS, COLS, DEVICE_H, AREAS } from './layout.js';
import { surfaceUV } from './atlas.js';
import {
focusUniforms, makeTexture, boardMaterial, glassMaterial, decalMaterial, pipeMaterial, unlitMaterial, gateMaterial, glowMaterial,
waterMaterial, mireMaterial, infectionMaterial, smogMaterial, dashTexture, environmentMap,
} from './materials.js';
import { syncThreeCamera } from '../projection.js';
/** Lighting rig (tuned against the official screenshots: bright even tops, darker sides, soft shadows). */
export const LIGHTING = Object.freeze({
key: { color: 0xfff1df, intensity: 2.6, dir: [-5.2, -3.4, 10] },
hemi: { sky: 0xe4ecf4, ground: 0x4a5058, intensity: 0.6 },
env: 1.0,
emissive: 0.25,
roughness: 0.78,
shadow: { size: 2048, radius: 3, bias: -0.0004, normalBias: 0.025 },
exposure: 1.0,
// the clear colour is the fog colour: the far background fades into the void without a visible edge
clear: 0x0c1114,
fog: { color: 0x0c1114, near: 17, far: 36 },
// S_Background_common (100 × 0.4316 = 43.16 tiles); `tiles` × `tiles` mirrored copies around it so no framing
// (21:9, 4:3, portrait fit, camera flights) sees past its edge
bg: { z: -8, size: 43.16, color: 0.713, dim: 0.8, tiles: 3 },
});
/** Additive gain of the gate boxes at the curve's mean (tuned against the official screenshots). */
export const GATE_GAIN = 0.6;
/** Gate pulse: the official clip's _TintColor.a curve (2 s loop, 0.134 → 0.229 → 0.134) scaled to our intensity. */
export function gatePulse(t, phase = 0) {
const k = 0.5 - 0.5 * Math.cos(((t / 2 + phase) % 1) * Math.PI * 2);
return 0.134 + (0.229 - 0.134) * k;
}
const areaKey = (list) => (list || []).map((a) => `${a.r0},${a.r1},${a.c0},${a.c1}`).sort().join(';');
const mergeInto = (list) => {
// concatenate { position, normal, uv, color?, index } records
let nv = 0, ni = 0;
for (const g of list) { nv += g.position.length / 3; ni += g.index.length; }
const position = new Float32Array(nv * 3), normal = new Float32Array(nv * 3), uv = new Float32Array(nv * 2), color = new Float32Array(nv * 3);
const index = nv > 65535 ? new Uint32Array(ni) : new Uint16Array(ni);
let vo = 0, io = 0;
for (const g of list) {
const n = g.position.length / 3;
position.set(g.position, vo * 3);
if (g.normal) normal.set(g.normal, vo * 3);
if (g.uv) uv.set(g.uv, vo * 2);
if (g.color) color.set(g.color, vo * 3); else color.fill(1, vo * 3, (vo + n) * 3);
for (let i = 0; i < g.index.length; i++) index[io + i] = g.index[i] + vo;
vo += n; io += g.index.length;
}
return { position, normal, uv, color, index };
};
/** Apply a 4×4-free transform (scale s, rotate about z by `rot` quarter turns, translate) to board-space data. */
function placeMesh(src, { x = 0, y = 0, z = 0, s = 1, sz = s, rot = 0 }) {
const p = src.position, n = src.normal;
const pos = new Float32Array(p.length), nrm = n ? new Float32Array(n.length) : null;
const a = rot * Math.PI / 2, ca = Math.round(Math.cos(a)), sa = Math.round(Math.sin(a));
for (let i = 0; i < p.length; i += 3) {
const px = p[i] * s, py = p[i + 1] * s;
pos[i] = x + px * ca - py * sa; pos[i + 1] = y + px * sa + py * ca; pos[i + 2] = z + p[i + 2] * sz;
if (nrm) { nrm[i] = n[i] * ca - n[i + 1] * sa; nrm[i + 1] = n[i] * sa + n[i + 1] * ca; nrm[i + 2] = n[i + 2]; }
}
return { ...src, position: pos, normal: nrm };
}
/**
* Grow a textured plane `k`× about (cx, cy) keeping its texel density: positions scale by k, UVs scale by k about
* the texture centre (0..1 → (1−k)/2..(1+k)/2), so a MirroredRepeat texture tiles seamlessly around the original.
*/
export function extendPlane(src, cx, cy, k = 1) {
if (!(k > 1) || !src || !src.position) return src;
const p = src.position, pos = new Float32Array(p.length);
for (let i = 0; i < p.length; i += 3) { pos[i] = cx + (p[i] - cx) * k; pos[i + 1] = cy + (p[i + 1] - cy) * k; pos[i + 2] = p[i + 2]; }
let uv = src.uv;
if (uv) { uv = new Float32Array(src.uv.length); for (let i = 0; i < uv.length; i++) uv[i] = 0.5 + (src.uv[i] - 0.5) * k; }
return { ...src, position: pos, uv };
}
/** Quarter turns (counter-clockwise about +z) that point the wind device's outlet (−x in the mesh) along `dir`. */
export const DIR_TURNS = Object.freeze({ LEFT: 0, DOWN: 1, RIGHT: 2, UP: 3 });
export class BoardScene {
/**
* @param {any} THREE three.js module
* @param {any} pack load.js loadBoardPack result
* @param {{ canvas?: HTMLCanvasElement, quality?: string, antialias?: boolean, renderer?: any }} [opts]
*/
constructor(THREE, pack, opts = {}) {
this.THREE = THREE;
this.pack = pack;
this.opts = opts;
this.destroyed = false;
this.lost = false;
const T = THREE;
this.renderer = opts.renderer || new T.WebGLRenderer({
canvas: opts.canvas, antialias: opts.antialias ?? true, alpha: false, stencil: false, depth: true,
powerPreference: 'high-performance', preserveDrawingBuffer: !!opts.preserveDrawingBuffer,
});
const R = this.renderer;
R.outputColorSpace = T.SRGBColorSpace;
R.toneMapping = T.NoToneMapping;
R.setClearColor(LIGHTING.clear, 1);
R.shadowMap.enabled = opts.shadows !== false;
R.shadowMap.type = T.PCFShadowMap;
R.shadowMap.autoUpdate = false;
this.canvas = R.domElement;
this._onLost = (e) => { e.preventDefault?.(); this.lost = true; };
this.canvas.addEventListener?.('webglcontextlost', this._onLost);
this.scene = new T.Scene();
this.scene.fog = new T.Fog(LIGHTING.fog.color, LIGHTING.fog.near, LIGHTING.fog.far);
this.camera = new T.PerspectiveCamera(40, 16 / 9, 0.5, 120);
this.focus = focusUniforms(T);
if (opts.environment !== false) {
try {
this.envMap = environmentMap(T, R);
if (this.envMap) {
this.scene.environment = this.envMap;
this.scene.environmentIntensity = LIGHTING.env;
this.scene.environmentRotation?.set?.(-Math.PI / 2, 0, 0);
}
} catch { this.envMap = null; }
}
this.focusTarget = null;
const aniso = Math.min(8, R.capabilities?.getMaxAnisotropy?.() || 1);
const img = pack?.images || {};
this.tex = {
D: makeTexture(T, img.D, { aniso }), N: makeTexture(T, img.N, { srgb: false, aniso }), R: makeTexture(T, img.R, { srgb: false, aniso }),
E: makeTexture(T, img.E, { aniso }), common: makeTexture(T, img.common, { aniso }), commonE: makeTexture(T, img.commonE, { aniso }),
BG: makeTexture(T, img.BG, { aniso }), wind: makeTexture(T, img.wind, { aniso }), gate: makeTexture(T, img.gate, {}),
waterN: makeTexture(T, img.waterN, { srgb: false, repeat: true }), caustics: makeTexture(T, img.caustics, { srgb: false, repeat: true }),
noise: makeTexture(T, img.noise, { srgb: false, repeat: true }),
};
if (this.tex.BG) { this.tex.BG.wrapS = T.MirroredRepeatWrapping; this.tex.BG.wrapT = T.MirroredRepeatWrapping; }
this.mat = {
board: boardMaterial(T, this.tex, this.focus, { emissive: LIGHTING.emissive, roughness: LIGHTING.roughness }),
glass: glassMaterial(T, this.tex, this.focus, { emissive: LIGHTING.emissive, roughness: LIGHTING.roughness }),
decal: this.tex.common ? decalMaterial(T, this.tex, this.focus) : null,
pipe: pipeMaterial(T, this.focus),
bg: this.tex.BG ? unlitMaterial(T, this.tex.BG, new T.Color().setScalar(LIGHTING.bg.color * LIGHTING.bg.dim)) : new T.MeshBasicMaterial({ color: 0x1a1d1f }),
wind: this.tex.wind ? unlitMaterial(T, this.tex.wind) : null,
gateStartAdd: this.tex.gate ? gateMaterial(T, this.tex.gate, { additive: true }) : null,
gateEndAdd: this.tex.gate ? gateMaterial(T, this.tex.gate, { additive: true }) : null,
gateEndAb: this.tex.gate ? gateMaterial(T, this.tex.gate, { additive: false }) : null,
edge: glowMaterial(T, dashTexture(T)),
water: waterMaterial(T, this.tex, this.focus),
mire: mireMaterial(T, this.tex, this.focus),
infection: infectionMaterial(T, this.tex, this.focus),
smog: smogMaterial(T, this.tex, this.focus),
shadowCatcher: new T.ShadowMaterial({ opacity: 0.32, color: 0x000000 }),
};
this.mat.crateFade = null;
// lights
const hemi = new T.HemisphereLight(LIGHTING.hemi.sky, LIGHTING.hemi.ground, LIGHTING.hemi.intensity);
hemi.position.set(0, 0, 1);
this.scene.add(hemi);
const key = new T.DirectionalLight(LIGHTING.key.color, LIGHTING.key.intensity);
key.castShadow = R.shadowMap.enabled;
key.shadow.mapSize.set(LIGHTING.shadow.size, LIGHTING.shadow.size);
key.shadow.radius = LIGHTING.shadow.radius;
key.shadow.bias = LIGHTING.shadow.bias;
key.shadow.normalBias = LIGHTING.shadow.normalBias;
this.scene.add(key, key.target);
this.key = key;
this.hemi = hemi;
// static board geometry (rebuilt by setStage / setArea) and the battle device meshes (owned by render/units.js
// DeviceView handles: they must survive a rebuild — a camera flight between areas rebuilds mid-battle)
this.root = new T.Group();
this.dynamic = new T.Group();
this.scene.add(this.root, this.dynamic);
this.stageKey = null;
this.stage = null;
this.area = AREAS.normal;
this.areaKey = areaKey(AREAS.normal);
this.battleRect = null;
this.devices = new Set();
this.flashes = [];
this.time = 0;
this.size = { w: 1, h: 1, dpr: 1 };
this.camVersion = -1;
this.camRef = null;
this.frames = 0;
this.lastMs = 0;
this._crateGeom = null;
}
// ---- geometry ----------------------------------------------------------------------------------------------
_geometry(data) {
const T = this.THREE;
const g = new T.BufferGeometry();
g.setAttribute('position', new T.BufferAttribute(data.position, 3));
if (data.normal) g.setAttribute('normal', new T.BufferAttribute(data.normal, 3));
if (data.uv) g.setAttribute('uv', new T.BufferAttribute(data.uv, 2));
if (data.color) g.setAttribute('color', new T.BufferAttribute(data.color, 3));
g.setIndex(new T.BufferAttribute(data.index, 1));
if (!data.normal) g.computeVertexNormals();
g.computeBoundingSphere();
return g;
}
_mesh(data, material, { cast = true, receive = true, order = 0 } = {}) {
if (!data || !material || !(data.index?.length > 0)) return null;
const m = new this.THREE.Mesh(this._geometry(data), material);
m.castShadow = cast; m.receiveShadow = receive; m.renderOrder = order;
m.matrixAutoUpdate = false; m.updateMatrix();
this.root.add(m);
return m;
}
_clear() {
for (const ch of [...this.root.children]) {
this.root.remove(ch);
ch.traverse?.((o) => { if (o.geometry) o.geometry.dispose(); });
}
this.meshes = {};
}
/** The crate mesh in board space (s_common_box_01 when loaded, else a unit chamfer-free box), UVs on D. */
crateGeometry() {
if (this._crateGeom) return this._crateGeom;
const uv = this.pack?.uv || {};
const obj = this.pack?.meshes?.crate;
let base;
if (obj && obj.normal) base = objToBoard(obj, 0.01);
else base = boxData(0.9, 0.675);
this._crateGeom = boxProjectUV(base, uv.crateTop || { src: 'D', rect: [1222, 1938, 134, 108] }, uv.crateSide || { src: 'D', rect: [1087, 1938, 134, 108] });
this._crateGeom.color = null;
return this._crateGeom;
}
/** Built areas (inclusive tile rects): rebuilds when they change. */
setArea(rects) {
const list = Array.isArray(rects) && rects.length ? rects : AREAS.normal;
const k = areaKey(list);
if (k === this.areaKey) return false;
this.area = list;
this.areaKey = k;
if (this.stage) { this.stageKey = null; this.setStage(this.stage); }
return true;
}
/** Rebuild everything for a stage (no-op when the same stage object/grid is set again). */
setStage(stage) {
if (this.destroyed) return;
const key = stage ? `${stage.id || ''}|${(stage.rows || []).join('/')}|${JSON.stringify((stage.devices || []).map((d) => [d.key, d.pos, d.active, d.dir]))}` : '';
if (key === this.stageKey) return;
this.stageKey = key;
this.stage = stage || null;
this._clear();
if (!stage) return;
const board = buildBoard(stage, { uv: this.pack?.uv || null, area: this.area });
this.board = board;
const M = this.meshes = {};
M.board = this._mesh(board.buckets.board, this.mat.board);
M.glass = this._mesh(board.buckets.glass, this.mat.glass);
M.decal = this._mesh(board.buckets.decal, this.mat.decal, { cast: false });
M.pipe = this._mesh(board.buckets.pipe, this.mat.pipe);
this._buildDevices(board);
this._buildGates(board);
this._buildEdges(board);
this._buildTerrain(board);
this._buildBackground(board);
this._fitShadow(board);
this.renderer.shadowMap.needsUpdate = true;
}
_buildDevices(board) {
const crates = [], blowers = [];
this.staticCrates = [];
const wind = this.pack?.meshes?.blower ? objToBoard(this.pack.meshes.blower, 1) : null;
for (const d of board.devices) {
if (d.kind === 'crate') this.staticCrates.push(d);
else if (d.kind === 'blower' && wind && this.mat.wind) blowers.push(placeMesh(wind, { x: d.c, y: d.r, z: d.z0 + 0.078, rot: DIR_TURNS[d.dir] ?? 0 }));
else if (d.kind === 'blower' || d.kind === 'turret') crates.push(placeMesh(boxWithTop(this.pack?.uv, d.kind), { x: d.c, y: d.r, z: d.z0 }));
else if (d.kind === 'mound') crates.push(placeMesh(this.crateGeometry(), { x: d.c, y: d.r, z: d.z0, s: 0.95, sz: 0.55 }));
else if (d.kind === 'bush') crates.push(placeMesh(bushData(), { x: d.c, y: d.r, z: d.z0 }));
}
if (blowers.length) this.meshes.blowers = this._mesh(mergeInto(blowers), this.mat.wind, { receive: false });
if (crates.length) this.meshes.props = this._mesh(mergeInto(crates), this.mat.board);
this._rebuildCrates();
}
_rebuildCrates() {
if (this.meshes.crates) { this.root.remove(this.meshes.crates); this.meshes.crates.geometry.dispose(); this.meshes.crates = null; }
const R = this.battleRect;
const inside = (d) => !!R && d.r >= R.r0 && d.r <= R.r1 && d.c >= R.c0 && d.c <= R.c1;
const list = (this.staticCrates || []).filter((d) => !inside(d)).map((d) => placeMesh(this.crateGeometry(), { x: d.c, y: d.r, z: d.z0 }));
if (list.length) this.meshes.crates = this._mesh(mergeInto(list), this.mat.board);
this.renderer.shadowMap.needsUpdate = true;
}
/** Battle rect (null in prep): static crates inside it are hidden (the sim spawns them as device units). */
setBattleRect(rect) {
const next = rect ? { r0: rect.r0, r1: rect.r1, c0: rect.c0, c1: rect.c1 } : null;
if (JSON.stringify(next) === JSON.stringify(this.battleRect)) return;
this.battleRect = next;
if (this.board) this._rebuildCrates();
}
_buildGates(board) {
const G = this.pack?.meshes?.gate || {};
if (!this.tex.gate) return;
const place = (slot) => {
const rec = G[slot];
if (!rec) return null;
return objToBoard(rec.mesh, 0.01);
};
const parts = { startDown: place('startDown'), startUp: place('startUp'), startBack: place('startBack'), endDown: place('endDown'), endUp: place('endUp') };
const startAdd = [], endAdd = [], endAb = [];
for (const g of board.gates) {
// the tile effect's prefab rotation (0, .707, −.707, 0) maps the mesh to (x', −z', y') of the exported OBJ:
// objToBoard's frame turned half a turn — the warning glyph on the top face points north (the official look)
const at = { x: g.c, y: g.r, z: g.z + 0.5 + 0.003, rot: 2 };
if (g.kind === 'start') for (const k of ['startDown', 'startUp', 'startBack']) { if (parts[k]) startAdd.push(placeMesh(parts[k], at)); }
else {
if (parts.endDown) endAdd.push(placeMesh(parts.endDown, at));
if (parts.endUp) endAb.push(placeMesh(parts.endUp, at));
}
}
const opt = { cast: false, receive: false, order: 5 };
if (startAdd.length) this.meshes.gateStart = this._mesh(mergeInto(startAdd), this.mat.gateStartAdd, opt);
if (endAdd.length) this.meshes.gateEndAdd = this._mesh(mergeInto(endAdd), this.mat.gateEndAdd, opt);
if (endAb.length) this.meshes.gateEndAb = this._mesh(mergeInto(endAb), this.mat.gateEndAb, { ...opt, order: 4 });
}
_buildEdges(board) {
const pos = [], uv = [], idx = [];
const w = 0.085, e = 0.018;
for (const s of board.edges) {
const x0 = s.c - 0.5, x1 = s.c + 0.5, y0 = s.r - 0.5, y1 = s.r + 0.5, z = s.z + 0.006;
let q;
switch (s.dir) {
case 'S': q = [[x0, y0 + e, z], [x1, y0 + e, z], [x1, y0 + e + w, z], [x0, y0 + e + w, z]]; break;
case 'N': q = [[x1, y1 - e, z], [x0, y1 - e, z], [x0, y1 - e - w, z], [x1, y1 - e - w, z]]; break;
case 'E': q = [[x1 - e, y0, z], [x1 - e, y1, z], [x1 - e - w, y1, z], [x1 - e - w, y0, z]]; break;
default: q = [[x0 + e, y1, z], [x0 + e, y0, z], [x0 + e + w, y0, z], [x0 + e + w, y1, z]]; break;
}
const b = pos.length / 3;
for (const p of q) pos.push(...p);
uv.push(0, 0, 1, 0, 1, 1, 0, 1);
// winding: all strips face up
const n = [(q[1][0] - q[0][0]) * (q[3][1] - q[0][1]) - (q[1][1] - q[0][1]) * (q[3][0] - q[0][0])];
if (n[0] >= 0) idx.push(b, b + 1, b + 2, b, b + 2, b + 3); else idx.push(b, b + 2, b + 1, b, b + 3, b + 2);
}
if (!pos.length) return;
const nrm = new Float32Array(pos.length); for (let i = 2; i < nrm.length; i += 3) nrm[i] = 1;
this.meshes.edges = this._mesh({ position: new Float32Array(pos), normal: nrm, uv: new Float32Array(uv), color: null, index: new Uint16Array(idx) }, this.mat.edge, { cast: false, receive: false, order: 3 });
}
_buildTerrain(board) {
const quads = (tiles, z, inset = 0) => {
const pos = [], uv = [], idx = [];
for (const [r, c] of tiles) {
const a = 0.5 - inset, b = pos.length / 3;
pos.push(c - a, r - a, z, c + a, r - a, z, c + a, r + a, z, c - a, r + a, z);
uv.push(0, 0, 1, 0, 1, 1, 0, 1);
idx.push(b, b + 1, b + 2, b, b + 2, b + 3);
}
const nrm = new Float32Array(pos.length); for (let i = 2; i < nrm.length; i += 3) nrm[i] = 1;
return { position: new Float32Array(pos), normal: nrm, uv: new Float32Array(uv), color: null, index: new Uint16Array(idx) };
};
const T = board.terrain;
const opt = { cast: false, receive: false, order: 2 };
if (T.water.length) this.meshes.water = this._mesh(quads(T.water, -0.035), this.mat.water, opt);
if (T.mire.length) this.meshes.mire = this._mesh(quads(T.mire, 0.006, 0.02), this.mat.mire, opt);
if (T.infection.length) this.meshes.infection = this._mesh(quads(T.infection, 0.007, 0.02), this.mat.infection, opt);
if (T.smog.length) {
// two crossed haze cards per grille tile, standing up
const pos = [], uv = [], idx = [];
for (const [r, c] of T.smog) {
for (const [dx, dy] of [[1, 0], [0.7, 0.7]]) {
const b = pos.length / 3, h = 0.95, w = 0.55;
pos.push(c - dx * w, r - dy * w, 0.02, c + dx * w, r + dy * w, 0.02, c + dx * w, r + dy * w, h, c - dx * w, r - dy * w, h);
uv.push(0, 0, 1, 0, 1, 1, 0, 1);
idx.push(b, b + 1, b + 2, b, b + 2, b + 3);
}
}
const nrm = new Float32Array(pos.length); for (let i = 1; i < nrm.length; i += 3) nrm[i] = -1;
this.meshes.smog = this._mesh({ position: new Float32Array(pos), normal: nrm, uv: new Float32Array(uv), color: null, index: new Uint16Array(idx) }, this.mat.smog, { ...opt, order: 6 });
}
}
_buildBackground(board) {
const T = this.THREE;
const B = LIGHTING.bg;
const cx = (COLS - 1) / 2, cy = (ROWS - 1) / 2;
let plane = null;
const obj = this.pack?.meshes?.bgPlane;
if (obj) plane = objToBoard(obj, B.size / 100);
const data = plane || { position: new Float32Array([-B.size / 2, -B.size / 2, 0, B.size / 2, -B.size / 2, 0, B.size / 2, B.size / 2, 0, -B.size / 2, B.size / 2, 0]), normal: new Float32Array([0, 0, 1, 0, 0, 1, 0, 0, 1, 0, 0, 1]), uv: new Float32Array([0, 0, 1, 0, 1, 1, 0, 1]), index: new Uint16Array([0, 1, 2, 0, 2, 3]) };
const placed = extendPlane(placeMesh(data, { x: cx, y: cy, z: B.z }), cx, cy, B.tiles);
// the plane may face either way after the axis swap: make it face up
if (placed.normal && placed.normal[2] < 0) {
for (let i = 0; i < placed.normal.length; i++) placed.normal[i] = -placed.normal[i];
const ix = placed.index;
for (let i = 0; i < ix.length; i += 3) { const t = ix[i + 1]; ix[i + 1] = ix[i + 2]; ix[i + 2] = t; }
}
this.meshes.bg = this._mesh({ ...placed, color: null }, this.mat.bg, { cast: false, receive: false, order: -2 });
// the key light's shadow of the island on the ground far below (S_Background_shadow's role)
const s = B.size;
const q = { position: new Float32Array([cx - s / 2, cy - s / 2, B.z + 0.02, cx + s / 2, cy - s / 2, B.z + 0.02, cx + s / 2, cy + s / 2, B.z + 0.02, cx - s / 2, cy + s / 2, B.z + 0.02]), normal: new Float32Array([0, 0, 1, 0, 0, 1, 0, 0, 1, 0, 0, 1]), uv: new Float32Array([0, 0, 1, 0, 1, 1, 0, 1]), color: null, index: new Uint16Array([0, 1, 2, 0, 2, 3]) };
this.meshes.bgShadow = this._mesh(q, this.mat.shadowCatcher, { cast: false, receive: true, order: -1 });
void T;
}
_fitShadow(board) {
const k = this.key;
const b = board.bounds;
const cx = (b.x0 + b.x1) / 2, cy = (b.y0 + b.y1) / 2;
const d = LIGHTING.key.dir;
const len = Math.hypot(d[0], d[1], d[2]);
const dist = 30;
k.position.set(cx + (d[0] / len) * dist, cy + (d[1] / len) * dist, (d[2] / len) * dist);
k.target.position.set(cx, cy, 0);
k.target.updateMatrixWorld();
const cam = k.shadow.camera;
// light-space box covering the island and its shadow on the background plane
const half = Math.max(b.x1 - b.x0, b.y1 - b.y0) / 2 + 9;
cam.left = -half; cam.right = half; cam.top = half; cam.bottom = -half;
cam.near = 1; cam.far = dist + 30;
cam.updateProjectionMatrix();
k.updateMatrixWorld();
}
// ---- dynamic devices (battle) ------------------------------------------------------------------------------
/** A crate / device box driven by render/units.js DeviceView (see header). */
createDevice() {
const T = this.THREE;
const self = this;
const geom = this._geometry({ ...this.crateGeometry(), color: null });
const mat = this.mat.board.clone();
mat.transparent = true;
mat.vertexColors = false;
// keep the focus falloff on the clone
mat.onBeforeCompile = this.mat.board.onBeforeCompile;
const mesh = new T.Mesh(geom, mat);
mesh.castShadow = true; mesh.receiveShadow = true;
this.dynamic.add(mesh);
const bx = this.crateGeometry().bounds || { x0: -0.45, x1: 0.45, z0: 0, z1: 0.675 };
const baseSize = Math.max(0.01, bx.x1 - bx.x0), baseH = Math.max(0.01, bx.z1 - bx.z0);
let dead = false;
const h = {
mesh: null,
board: this,
get destroyed() { return dead; },
update(cam, b) {
if (!b || dead) return;
mesh.visible = (b.alpha ?? 1) > 0.01;
mesh.position.set(b.x, b.y, b.z);
const s = (b.size || baseSize) / baseSize;
mesh.scale.set(s, s, Math.max(0.001, (b.height ?? baseH) / baseH));
mat.opacity = Math.max(0, Math.min(1, b.alpha ?? 1));
mat.depthWrite = mat.opacity > 0.98;
},
destroy() {
if (dead) return;
dead = true;
self.dynamic.remove(mesh); geom.dispose(); mat.dispose(); self.devices.delete(h);
if (!self.destroyed) self.renderer.shadowMap.needsUpdate = true;
},
};
this.devices.add(h);
this.renderer.shadowMap.needsUpdate = true;
return h;
}
/** Red leak flash on the objective box at (r, c). */
flashObjective(r, c) {
this.flashes.push({ r, c, t: 0 });
if (this.flashes.length > 8) this.flashes.shift();
}
// ---- view ------------------------------------------------------------------------------------------------------
/** The lit field rect ({ r0, r1, c0, c1 } tiles; null = everything lit). Animated in render(). */
setFocus(rect) {
this.focusTarget = rect ? [rect.c0 - 0.5, rect.r0 - 0.5, rect.c1 + 0.5, rect.r1 + 0.5] : [-50, -50, 70, 70];
}
/** Settings quality change: 'low' drops the key light's shadow (antialias is fixed at context creation). */
setQuality(q) {
const on = q !== 'low';
const R = this.renderer;
if (R.shadowMap.enabled === on && this.key.castShadow === on) return false;
R.shadowMap.enabled = on;
this.key.castShadow = on;
R.shadowMap.needsUpdate = true;
return true;
}
resize(w, h, dpr = 1) {
const W = Math.max(1, Math.round(w)), H = Math.max(1, Math.round(h));
if (W === this.size.w && H === this.size.h && dpr === this.size.dpr) return;
this.size = { w: W, h: H, dpr };
this.renderer.setPixelRatio(dpr);
this.renderer.setSize(W, H, false);
this.camVersion = -1;
}
/** Draw a frame with projection camera `cam` at time `t` (seconds). */
render(cam, t = 0) {
if (this.destroyed || this.lost || !cam) return false;
const t0 = typeof performance !== 'undefined' ? performance.now() : 0;
const dt = Math.max(0, Math.min(0.1, t - this.time));
this.time = t;
if (cam !== this.camRef || cam.version !== this.camVersion) {
this.camRef = cam; this.camVersion = cam.version;
syncThreeCamera(cam, this.camera, this.size.w, this.size.h, { near: Math.max(0.3, cam.dist * 0.08), far: cam.dist + 70 });
}
// focus falloff (eased)
const F = this.focus.uFocus.value;
if (this.focusTarget) {
const k = Math.min(1, dt * 5);
const tg = this.focusTarget;
if (Math.abs(F.x - tg[0]) + Math.abs(F.y - tg[1]) + Math.abs(F.z - tg[2]) + Math.abs(F.w - tg[3]) > 40) F.set(tg[0], tg[1], tg[2], tg[3]);
else F.set(F.x + (tg[0] - F.x) * k, F.y + (tg[1] - F.y) * k, F.z + (tg[2] - F.z) * k, F.w + (tg[3] - F.w) * k);
}
// gate pulses (official 2 s curve → additive intensity), objective leak flashes
const pulse = gatePulse(t);
if (this.mat.gateStartAdd) this.mat.gateStartAdd.uniforms.uPulse.value = GATE_GAIN * pulse / 0.18;
if (this.mat.gateEndAdd) this.mat.gateEndAdd.uniforms.uPulse.value = GATE_GAIN * gatePulse(t, 0.5) / 0.18;
if (this.mat.gateEndAb) this.mat.gateEndAb.uniforms.uPulse.value = 0.9;
let flash = 0;
for (let i = this.flashes.length - 1; i >= 0; i--) { const f = this.flashes[i]; f.t += dt; if (f.t > 1.2) this.flashes.splice(i, 1); else flash = Math.max(flash, 1 - f.t / 1.2); }
for (const m of [this.mat.gateEndAdd, this.mat.gateEndAb]) if (m) m.uniforms.uFlash.value.setRGB(flash, flash * 0.12, flash * 0.1);
for (const k of ['water', 'mire', 'infection', 'smog']) this.mat[k].uniforms.uTime.value = t;
this.renderer.render(this.scene, this.camera);
this.frames++;
if (t0) this.lastMs = this.lastMs * 0.9 + ((typeof performance !== 'undefined' ? performance.now() : t0) - t0) * 0.1;
return true;
}
stats() {
const info = this.renderer.info;
return { calls: info?.render?.calls ?? 0, triangles: info?.render?.triangles ?? 0, textures: info?.memory?.textures ?? 0, geometries: info?.memory?.geometries ?? 0, frames: this.frames, cpuMs: Math.round(this.lastMs * 100) / 100, lost: this.lost };
}
destroy() {
if (this.destroyed) return;
this.destroyed = true;
for (const d of [...this.devices]) d.destroy();
this._clear();
this.mat.edge?.map?.dispose?.(); // canvas dash texture (not in this.tex)
for (const m of Object.values(this.mat)) m?.dispose?.();
for (const t of Object.values(this.tex)) t?.dispose?.();
this.envMap?.dispose?.();
this.canvas.removeEventListener?.('webglcontextlost', this._onLost);
try { this.renderer.dispose(); } catch { /* ignore */ }
try { this.renderer.forceContextLoss?.(); } catch { /* ignore */ }
}
}
// ---- small procedural meshes (fallbacks / rare devices) ------------------------------------------------------------
/** Axis-aligned box on the ground, centred on the origin: size × size × height. */
export function boxData(size, height) {
const a = size / 2, pos = [], nrm = [], uv = [], idx = [];
const faces = [
[[0, 0, 1], [[-a, -a, height], [a, -a, height], [a, a, height], [-a, a, height]]],
[[0, -1, 0], [[-a, -a, 0], [a, -a, 0], [a, -a, height], [-a, -a, height]]],
[[1, 0, 0], [[a, -a, 0], [a, a, 0], [a, a, height], [a, -a, height]]],
[[0, 1, 0], [[a, a, 0], [-a, a, 0], [-a, a, height], [a, a, height]]],
[[-1, 0, 0], [[-a, a, 0], [-a, -a, 0], [-a, -a, height], [-a, a, height]]],
];
for (const [n, q] of faces) {
const b = pos.length / 3;
for (const p of q) { pos.push(...p); nrm.push(...n); }
uv.push(0, 0, 1, 0, 1, 1, 0, 1);
idx.push(b, b + 1, b + 2, b, b + 2, b + 3);
}
return { position: new Float32Array(pos), normal: new Float32Array(nrm), uv: new Float32Array(uv), index: new Uint16Array(idx) };
}
/** Device box with an atlas top (blower fallback ▶▶ hatch, turret target plate) and grey sides. */
function boxWithTop(uvTable, kind) {
const size = kind === 'turret' ? 0.72 : 0.86, h = kind === 'turret' ? 0.34 : DEVICE_H.platform;
const b = boxData(size, h);
const top = surfaceUV(uvTable?.mech || { src: 'D', rect: [1088, 708, 356, 310] });
const side = surfaceUV(uvTable?.graySide || { src: 'D', rect: [272, 1664, 272, 120] });
const uv = new Float32Array(b.uv.length);
for (let f = 0; f < 5; f++) uv.set(f === 0 ? top : side, f * 8);
return { ...b, uv, color: null };
}
/** Low green blob (act1 m07 bushes: inactive in 盟约, kept for completeness). */
function bushData() {
const b = boxData(0.7, 0.32);
return { ...b, uv: new Float32Array(b.uv.length).fill(0.02), color: new Float32Array((b.position.length / 3) * 3).fill(0).map((_, i) => [0.25, 0.48, 0.22][i % 3]) };
}
+45
View File
@@ -0,0 +1,45 @@
// render/boardArt.js — optional real board art from the local game client (DESIGN §13).
//
// const art = await loadBoardArt(assets) // null when the art (or its crop table) is not installed
// → { key, tiles, images: { D, common?, BG? } } (tiles = tiles.json of tools/crop-board-atlas.mjs)
//
// Discovery never guesses URLs: the local-art manifest (data/local-assets.json, assets.local()) must list the atlas
// `map/autochess → TX_autochessi_D`; its crop table `tiles.json` sits next to it (written by
// tools/crop-board-atlas.mjs). Every image referenced by the table is loaded through the shared image cache; a
// missing image drops only the materials that use it. Everything is cached per page (one load for every view).
let cached = null;
const isObj = (v) => v !== null && typeof v === 'object' && !Array.isArray(v);
/** @param {any} assets store of public/js/assets.js (needs local(), localUrl(), image()) */
export function loadBoardArt(assets) {
if (cached) return cached;
cached = (async () => {
if (!assets || typeof assets.local !== 'function' || typeof assets.image !== 'function') return null;
const manifest = await assets.local().catch(() => null);
if (!manifest) return null;
const atlasUrl = assets.localUrl ? assets.localUrl('map/autochess', 'TX_autochessi_D') : null;
if (!atlasUrl) return null;
const dir = atlasUrl.replace(/\/[^/]*$/, '');
let tiles = null;
try {
const res = await fetch(`${dir}/tiles.json`, { cache: 'no-cache' });
tiles = res.ok ? await res.json() : null;
} catch { tiles = null; }
if (!isObj(tiles) || !isObj(tiles.materials) || !isObj(tiles.source)) return null;
const images = {};
await Promise.all(Object.entries(tiles.source).map(async ([k, s]) => {
const url = isObj(s) && typeof s.path === 'string' ? s.path : null;
if (!url) return;
const img = await assets.image(url).catch(() => null);
if (img && img.width > 0) images[k] = img;
}));
if (!images.D) return null;
return { key: `${atlasUrl}#${tiles.version || 0}`, tiles, images };
})().catch(() => null);
return cached;
}
/** Forget the cached art (tests / hot reload). */
export function resetBoardArt() { cached = null; }
+252
View File
@@ -0,0 +1,252 @@
// render/drag.js — prep-phase pointer interactions: hover, click, right-click / long-press detail, and
// drag & drop of pieces between hand, temp and board (DESIGN §9, §10). Pure logic (no PIXI, no DOM access):
// app.js feeds it canvas pointer events and supplies hit-testing callbacks, so the state machine and the drop
// target resolution are unit tested in Node.
//
// Drop targets (protocol g.move `to`): { area: 'board', row, col } | { area: 'hand', idx }, plus
// { area: 'outside', clientX, clientY } when the pointer is released off the stage grid or over DOM UI that
// covers the canvas (the UI decides what, if anything, that means). Releasing over a grid tile that is not a legal
// target (temp slot, enemy lane, `canPlace` false, or the piece's own bench slot) cancels the drag without a
// pieceDrop. A board piece released on its OWN tile is a drop (when `canPlace` allows it): the UI opens the
// direction wheel to re-orient it in place (research 09 §1.2 — "drag the unit onto its own tile and swipe").
//
// Events emitted through `emit(name, payload)`:
// pieceHover { uid, piece, clientX, clientY } | { uid: null } (mouse/pen hover changes)
// tileHover { row, col, area, idx } | null (hover / drag tile changes)
// pieceClick { uid, piece, button, detail, clientX, clientY } (detail: right-click / long-press)
// pieceDetail { uid, piece, clientX, clientY } (right-click / long-press)
// pieceDragStart { uid, piece, from }
// pieceDragMove { uid, piece, x, y, clientX, clientY, target, legal } (for the ghost; high frequency)
// pieceDrop { uid, piece, from, target }
// pieceDragEnd { uid, piece, dropped, cancelled }
import { GEO } from '../../../shared/constants.js';
/** Where a piece currently sits, as a tile. `piece.area` 'board' uses row/col; 'hand'/'temp' use idx. */
export function pieceTile(piece) {
if (!piece || typeof piece !== 'object') return null;
if (piece.area === 'hand' && Number.isInteger(piece.idx)) return { row: GEO.HAND_ROW, col: piece.idx };
if (piece.area === 'temp' && Number.isInteger(piece.idx)) return { row: GEO.TEMP_ROW, col: GEO.TEMP_C0 + piece.idx };
if (Number.isInteger(piece.row) && Number.isInteger(piece.col)) return { row: piece.row, col: piece.col };
return null;
}
/** The slot a piece occupies, in protocol target form (temp slots → { area: 'temp', idx }). */
export function pieceSlot(piece) {
if (!piece || typeof piece !== 'object') return null;
if (piece.area === 'hand' && Number.isInteger(piece.idx)) return { area: 'hand', idx: piece.idx };
if (piece.area === 'temp' && Number.isInteger(piece.idx)) return { area: 'temp', idx: piece.idx };
if (Number.isInteger(piece.row) && Number.isInteger(piece.col)) return { area: 'board', row: piece.row, col: piece.col };
return null;
}
/**
* Classify a grid tile as a potential drop slot.
* @returns {{area:'board',row,col}|{area:'hand',idx}|{area:'temp',idx}|null}
*/
export function tileSlot(tile, geo = GEO) {
if (!tile || !Number.isInteger(tile.row) || !Number.isInteger(tile.col)) return null;
const { row, col } = tile;
if (row === geo.HAND_ROW && col >= 0 && col < geo.HAND_SIZE) return { area: 'hand', idx: col };
if (row === geo.TEMP_ROW && col >= geo.TEMP_C0 && col < geo.TEMP_C0 + geo.TEMP_SIZE) return { area: 'temp', idx: col - geo.TEMP_C0 };
const F = geo.FIELD;
if (row >= F.r0 && row <= F.r1 && col >= F.c0 && col <= F.c1) return { area: 'board', row, col };
return null;
}
export const sameSlot = (a, b) => !!a && !!b && a.area === b.area &&
(a.area === 'board' ? a.row === b.row && a.col === b.col : a.idx === b.idx);
/**
* Decide what a release does.
* @param {{ piece, from, tile, overCanvas: boolean, clientX: number, clientY: number,
* canPlace?: (piece, row, col, target) => boolean, geo? }} p
* @returns {{ kind: 'drop', target } | { kind: 'cancel', reason: string }}
*/
export function resolveDrop(p) {
const { piece, from, tile, overCanvas, clientX, clientY } = p;
if (!overCanvas || !tile) {
return { kind: 'drop', target: { area: 'outside', clientX: Number(clientX) || 0, clientY: Number(clientY) || 0 } };
}
const slot = tileSlot(tile, p.geo || GEO);
if (!slot) return { kind: 'cancel', reason: 'notTarget' };
if (slot.area === 'temp') return { kind: 'cancel', reason: 'temp' };
if (sameSlot(slot, from) && slot.area !== 'board') return { kind: 'cancel', reason: 'same' };
if (!isLegal(p.canPlace, piece, slot)) return { kind: 'cancel', reason: 'illegal' };
return { kind: 'drop', target: slot };
}
/** Legality via the UI callback (board → (piece,row,col); hand → (piece, HAND_ROW, idx)). Throwing ⇒ illegal. */
export function isLegal(canPlace, piece, slot) {
if (!slot || slot.area === 'temp' || slot.area === 'outside') return false;
if (typeof canPlace !== 'function') return true;
const row = slot.area === 'board' ? slot.row : GEO.HAND_ROW;
const col = slot.area === 'board' ? slot.col : slot.idx;
try { return canPlace(piece, row, col, slot) !== false; } catch { return false; }
}
const DEFAULTS = { threshold: 6, touchThreshold: 10, longPressMs: 480 };
/**
* Pointer state machine.
* @param {{
* hitPiece: (x, y) => (object|null), // piece under canvas point (front-most); piece.uid required
* pickTile: (x, y) => ({row, col}|null), // grid tile under canvas point
* isOverCanvas?: (clientX, clientY) => boolean, // false when DOM UI covers the point (default true)
* canPlace?: (piece, row, col, target) => boolean,
* emit: (name, payload) => void,
* setTimeout?, clearTimeout?, threshold?, touchThreshold?, longPressMs?
* }} hooks
*/
export function createDragController(hooks) {
const h = { ...DEFAULTS, ...hooks };
const setT = h.setTimeout || ((fn, ms) => setTimeout(fn, ms));
const clearT = h.clearTimeout || ((id) => clearTimeout(id));
let editable = false;
let canPlace = typeof h.canPlace === 'function' ? h.canPlace : null;
/** @type {null | { mode: 'pressed'|'dragging'|'held', pointerId, pointerType, piece, from, sx, sy, timer }} */
let st = null;
let hoverUid = null;
let hoverTileKey = '';
const emit = (name, payload) => { try { h.emit(name, payload); } catch (err) { console.error('[drag] listener failed', err); } };
const draggable = (piece) => !!piece && piece.draggable !== false && piece.uid != null;
const tileAt = (x, y) => { try { return h.pickTile(x, y); } catch { return null; } };
const pieceAt = (x, y) => { try { return h.hitPiece(x, y); } catch { return null; } };
const overCanvas = (cx, cy) => { try { return h.isOverCanvas ? h.isOverCanvas(cx, cy) !== false : true; } catch { return true; } };
function setHoverTile(tile) {
const slot = tile ? tileSlot(tile) : null;
const key = tile ? `${tile.row},${tile.col}` : '';
if (key === hoverTileKey) return;
hoverTileKey = key;
emit('tileHover', tile ? { row: tile.row, col: tile.col, area: slot ? slot.area : null, idx: slot && slot.area !== 'board' ? slot.idx : undefined } : null);
}
function setHoverPiece(piece, e) {
const uid = piece ? piece.uid : null;
if (uid === hoverUid) return;
hoverUid = uid;
emit('pieceHover', piece ? { uid, piece, clientX: e.clientX, clientY: e.clientY } : { uid: null });
}
function clearTimer() { if (st && st.timer != null) { clearT(st.timer); st.timer = null; } }
function startDrag(e) {
clearTimer();
st.mode = 'dragging';
setHoverPiece(null, e);
emit('pieceDragStart', { uid: st.piece.uid, piece: st.piece, from: st.from });
moveDrag(e);
}
function moveDrag(e) {
const tile = tileAt(e.x, e.y);
const over = overCanvas(e.clientX, e.clientY);
const slot = over && tile ? tileSlot(tile) : null;
const legal = !!slot && slot.area !== 'temp' && (slot.area === 'board' || !sameSlot(slot, st.from)) && isLegal(canPlace, st.piece, slot);
setHoverTile(over ? tile : null);
emit('pieceDragMove', { uid: st.piece.uid, piece: st.piece, x: e.x, y: e.y, clientX: e.clientX, clientY: e.clientY, target: slot, legal });
}
function endDrag(e, cancelled) {
const s = st;
st = null;
if (!s) return;
let dropped = false;
if (!cancelled) {
const tile = tileAt(e.x, e.y);
const d = resolveDrop({ piece: s.piece, from: s.from, tile, overCanvas: overCanvas(e.clientX, e.clientY), clientX: e.clientX, clientY: e.clientY, canPlace });
if (d.kind === 'drop') {
dropped = true;
emit('pieceDrop', { uid: s.piece.uid, piece: s.piece, from: s.from, target: d.target });
}
}
emit('pieceDragEnd', { uid: s.piece.uid, piece: s.piece, dropped, cancelled: !dropped });
}
return {
get editable() { return editable; },
get dragging() { return !!st && st.mode === 'dragging'; },
get dragPiece() { return st && st.mode === 'dragging' ? st.piece : null; },
get hoverUid() { return hoverUid; },
setEditable(v) {
editable = !!v;
if (!editable && st && st.mode === 'dragging') endDrag(null, true);
},
setCanPlace(fn) { canPlace = typeof fn === 'function' ? fn : null; },
/** e: { pointerId, pointerType, button, x, y (canvas CSS px), clientX, clientY } */
pointerDown(e) {
if (st) return false; // one pointer at a time
const piece = pieceAt(e.x, e.y);
if (!piece) { setHoverTile(tileAt(e.x, e.y)); return false; }
if (e.button === 2) {
emit('pieceClick', { uid: piece.uid, piece, button: 2, detail: true, clientX: e.clientX, clientY: e.clientY });
emit('pieceDetail', { uid: piece.uid, piece, clientX: e.clientX, clientY: e.clientY });
return true;
}
if (e.button != null && e.button !== 0) return false;
st = { mode: 'pressed', pointerId: e.pointerId, pointerType: e.pointerType || 'mouse', piece, from: pieceSlot(piece), sx: e.x, sy: e.y, cx: e.clientX, cy: e.clientY, timer: null };
if (st.pointerType === 'touch' || st.pointerType === 'pen') {
const snapshot = st;
st.timer = setT(() => {
if (st !== snapshot || st.mode !== 'pressed') return;
st.timer = null;
st.mode = 'held';
emit('pieceClick', { uid: piece.uid, piece, button: 0, detail: true, clientX: snapshot.cx, clientY: snapshot.cy });
emit('pieceDetail', { uid: piece.uid, piece, clientX: snapshot.cx, clientY: snapshot.cy });
}, h.longPressMs);
}
return true;
},
pointerMove(e) {
if (!st) {
if (e.pointerType === 'touch') return;
setHoverPiece(pieceAt(e.x, e.y), e);
setHoverTile(tileAt(e.x, e.y));
return;
}
if (e.pointerId !== st.pointerId) return;
if (st.mode === 'pressed') {
const th = st.pointerType === 'touch' ? h.touchThreshold : h.threshold;
if (Math.hypot(e.x - st.sx, e.y - st.sy) >= th) {
if (editable && draggable(st.piece)) startDrag(e);
else { clearTimer(); st.mode = 'held'; }
}
} else if (st.mode === 'dragging') moveDrag(e);
},
pointerUp(e) {
if (!st || e.pointerId !== st.pointerId) return;
if (st.mode === 'dragging') { endDrag(e, false); return; }
const s = st;
clearTimer();
st = null;
if (s.mode === 'pressed') emit('pieceClick', { uid: s.piece.uid, piece: s.piece, button: 0, detail: false, clientX: e.clientX, clientY: e.clientY });
},
pointerCancel(e) {
if (!st || (e && e.pointerId !== st.pointerId)) return;
if (st.mode === 'dragging') { endDrag(e, true); return; }
clearTimer();
st = null;
},
pointerLeave(e) {
if (st) return;
setHoverPiece(null, e || {});
setHoverTile(null);
},
/** Abort any interaction (piece list replaced, view destroyed…). */
reset() {
if (st && st.mode === 'dragging') endDrag(null, true);
else { clearTimer(); st = null; }
hoverUid = null;
hoverTileKey = '';
},
};
}
File diff suppressed because it is too large. Load diff
+168
View File
@@ -0,0 +1,168 @@
// render/impostor.js — shared render-target atlas for Spine impostors (render/units.js).
//
// Crowded fields render skeletons through impostors (a sprite showing a cached image of the skeleton, refreshed
// every few frames), and skeletons with clipping attachments always do: pixi-spine implements clipping with
// stencil masks, and every mask inside the main (MSAA) pass costs a render-pass break with a full-screen
// load/store on tiled GPUs. Instead of one RenderTexture per unit (one framebuffer switch each), every due
// impostor of a frame is drawn into slots of a few shared atlas pages in ONE render call per page, before the
// main pass starts:
// page = RenderTexture (2048² physical px) + a batch container [erasers…, skeletons…]
// slot = shelf-packed rect (16 px grid) owned by one unit; erased (ERASE blend quad) then redrawn when due
// Pages are created on demand (≤ MAX_PAGES); when every page is full `alloc` returns null and the unit renders
// directly (still correct, just not batched). A shelf whose last slot is freed is emptied for reuse, and empty shelves
// at the top of a page are given back (a page whose slots are all freed — every battle / prep rebuild — starts over):
// without that, zooming a crowd in and out (the enemy pen camera) left pages full of shelves of stale sizes and the
// next battle's units fell back to one private render target each. Clipped skeletons get slots on small separate "clip" pages: their
// stencil clears break the page's render pass, and the cost of a break scales with the page size.
//
// const imp = new ImpostorAtlas(renderer)
// const slot = imp.alloc(w, h) // CSS px; null when full
// imp.draw(slot, displayObject, matrixLike { a, d, tx, ty }) // queue for this frame (object must be parked here)
// imp.park(displayObject) / imp.unpark(displayObject) // keep skeletons of impostor units in the batch
// imp.flush() // once per frame, after every unit update, before the main render
// imp.free(slot) ; imp.destroy()
const MAX_PAGES = { main: 3, clip: 2 };
const PAGE_PX = { main: 2048, clip: 1024 };
const GRID = 16;
export class ImpostorAtlas {
/** @param {any} renderer PIXI renderer */
constructor(renderer) {
this.P = globalThis.PIXI;
this.R = renderer;
this.res = Math.max(1, Math.min(2, renderer.resolution || 1));
this.pages = [];
this.parked = new this.P.Container(); // skeletons of impostor units (never rendered directly)
this.parked.visible = false;
this.stats = { pages: 0, slots: 0, drawn: 0, full: 0 };
}
/** CSS px per side of a page of `kind`. */
sizeOf(kind) { return Math.floor(PAGE_PX[kind] / this.res); }
_page(kind) {
if (this.pages.filter((p) => p.kind === kind).length >= MAX_PAGES[kind]) return null;
const P = this.P;
const size = this.sizeOf(kind);
const rt = P.RenderTexture.create({ width: size, height: size, resolution: this.res });
try { rt.framebuffer.enableStencil(); } catch { /* masks fall back to scissor */ }
const batch = new P.Container();
const erasers = new P.Container();
const bodies = new P.Container();
batch.addChild(erasers, bodies);
const page = { kind, size, rt, batch, erasers, bodies, shelves: [], nextY: 0, queued: 0, cleared: false, index: this.pages.length };
this.pages.push(page);
this.stats.pages = this.pages.length;
return page;
}
/** Allocate a slot of at least w×h CSS px (rounded up to the grid); null when every page is full. */
alloc(w, h, { clip = false } = {}) {
const kind = clip ? 'clip' : 'main';
const W = Math.max(GRID, Math.ceil(w / GRID) * GRID), H = Math.max(GRID, Math.ceil(h / GRID) * GRID);
if (W > this.sizeOf(kind) || H > this.sizeOf(kind)) return null;
const pages = this.pages.filter((p) => p.kind === kind);
for (let i = 0; i <= pages.length; i++) {
const page = pages[i] || this._page(kind);
if (!page) break;
const s = this._allocIn(page, W, H);
if (s) { this.stats.slots++; return s; }
}
this.stats.full++;
return null;
}
_allocIn(page, W, H) {
// reuse a freed slot of a fitting size first
for (const sh of page.shelves) {
if (sh.h < H || sh.h > H * 1.5) continue;
const k = sh.free.findIndex((f) => f.w >= W && f.w <= W * 1.5);
if (k >= 0) { const f = sh.free.splice(k, 1)[0]; return this._slot(page, sh, f.x, f.w); }
if (sh.x + W <= page.size) { const x = sh.x; sh.x += W; return this._slot(page, sh, x, W); }
}
if (page.nextY + H > page.size) return null;
const sh = { y: page.nextY, h: H, x: W, free: [], live: 0 };
page.nextY += H;
page.shelves.push(sh);
return this._slot(page, sh, 0, W);
}
_slot(page, sh, x, w) {
const P = this.P;
const y = sh.y, h = sh.h;
const eraser = new P.Sprite(P.Texture.WHITE);
eraser.blendMode = P.BLEND_MODES.ERASE;
eraser.position.set(x, y);
eraser.width = w; eraser.height = h;
eraser.visible = false;
page.erasers.addChild(eraser);
const tex = new P.Texture(page.rt.baseTexture, new P.Rectangle(x, y, w, h));
sh.live = (sh.live || 0) + 1;
return { page, shelf: sh, clip: page.kind === 'clip', x, y, w, h, eraser, tex, freed: false };
}
free(slot) {
if (!slot || slot.freed) return;
slot.freed = true;
this.stats.slots = Math.max(0, this.stats.slots - 1);
try { slot.eraser.destroy(); } catch { /* ignore */ }
try { slot.tex.destroy(false); } catch { /* ignore */ }
const page = slot.page;
const sh = slot.shelf && page.shelves.includes(slot.shelf) ? slot.shelf : page.shelves.find((s) => s.y === slot.y);
if (!sh) return;
sh.live = Math.max(0, (sh.live || 0) - 1);
if (sh.live > 0) { sh.free.push({ x: slot.x, w: slot.w }); return; }
// the shelf's last slot: empty it; empty shelves at the top of the page are given back
sh.x = 0;
sh.free.length = 0;
const L = page.shelves;
while (L.length && L[L.length - 1].live === 0) {
const top = L.pop();
page.nextY = top.y;
}
}
/** Keep a skeleton here while its unit is an impostor (hidden unless drawn this frame). */
park(obj) { if (obj && obj.parent !== this.parked) this.parked.addChild(obj); obj.visible = false; }
unpark(obj) { if (obj && obj.parent === this.parked) this.parked.removeChild(obj); if (obj) obj.visible = true; }
/** Queue `obj` to be drawn into `slot` this frame with the local transform (scale a/d, offset tx/ty in the slot). */
draw(slot, obj, m) {
if (!slot || slot.freed || !obj) return;
const page = slot.page;
slot.eraser.visible = true;
obj.position.set(slot.x + m.tx, slot.y + m.ty);
obj.scale.set(m.a, m.d);
obj.visible = true;
if (obj.parent !== page.bodies) page.bodies.addChild(obj);
page.queued++;
}
/** Render every queued slot: one render call per page with queued work. */
flush() {
let drawn = 0;
for (const page of this.pages) {
if (!page.queued) continue;
try {
this.R.render(page.batch, { renderTexture: page.rt, clear: !page.cleared, skipUpdateTransform: false });
page.cleared = true;
} catch { /* lost context etc. */ }
drawn += page.queued;
page.queued = 0;
for (const e of page.erasers.children) e.visible = false;
// back to the parking lot until due again
for (const b of [...page.bodies.children]) this.park(b);
}
this.stats.drawn = drawn;
}
destroy() {
for (const page of this.pages) {
try { page.batch.destroy({ children: false }); } catch { /* ignore */ }
try { page.rt.destroy(true); } catch { /* ignore */ }
}
this.pages = [];
try { this.parked.destroy({ children: false }); } catch { /* ignore */ }
}
}
+313
View File
@@ -0,0 +1,313 @@
// render/interp.js — snapshot interpolation buffer for battle rendering (pure logic, no PIXI / DOM).
//
// The server streams `b.snap` at ~20 Hz real time; snapshot time `t` is GAME seconds and combat runs at 2×
// real time (DESIGN §4), so the game clock advances `rate` ≈ 2 game-s per real second. The buffer:
// * keeps ~2 s of snapshots (each indexed id → tuple once, at push time),
// * estimates `rate` from arrival times (sliding window, clamped, default 2),
// * runs a render clock `renderT` that trails the newest snapshot by `delay` real seconds (default 100 ms),
// advancing at `rate` and gently steered back when network jitter pushes it off target (hard snap when it
// is more than `snapAfter` real seconds off),
// * extrapolation guard: renderT never runs more than `maxExtrapolate` real seconds past the newest snapshot;
// positions are extrapolated along the last velocity for at most that long, then freeze,
// * sample(): per unit, lerps x/y/hp/sp between the two snapshots bracketing renderT; flags/anim come from
// the older one. A unit missing from the newer snapshot (died/left mid-buffer) holds its last position
// until renderT reaches the newer snapshot; a unit that only exists in the newer one (spawned mid-buffer)
// appears when renderT reaches it. Moves longer than `teleport` tiles between two snapshots snap.
// * event queue: a `b.ev` batch is stamped with its game time (`gt`, the snapshot it was drained with); a batch
// without one is placed inside the latest snapshot interval (newest snapshot time minus half an interval),
// and handed out by `takeEvents()` once renderT passes the stamp. Stale cosmetic events (> `eventMaxLag`
// game s behind) are dropped by the caller's choice (`isCosmeticEvent`); state events are always delivered.
//
// Snapshot tuple layout (DESIGN §8.2): [id, x, y, hp, maxHp, sp, spMax, flags, anim].
export const TUPLE = Object.freeze({ ID: 0, X: 1, Y: 2, HP: 3, MAXHP: 4, SP: 5, SPMAX: 6, FLAGS: 7, ANIM: 8 });
const MAX_EVENTS = 6000;
const finite = (v, d = 0) => (typeof v === 'number' && Number.isFinite(v) ? v : d);
const clamp = (v, lo, hi) => (v < lo ? lo : v > hi ? hi : v);
/** Cosmetic event kinds that may be dropped when far behind (never state-changing). */
export const COSMETIC_EVENTS = new Set(['atk', 'dmg', 'heal', 'fx', 'layer', 'bounty']);
export const isCosmeticEvent = (ev) => Array.isArray(ev) && COSMETIC_EVENTS.has(ev[0]);
/**
* Game time (s) of a b.snap / b.ev payload, or NaN. On the wire every frame is `{ t: '<type>', … }`, so the server
* sends the game time as `gt` (server/match/fields.js snapFrame); raw Battle snapshots and the demo recordings
* carry it as a numeric `t`. This is the only place that conversion happens.
*/
export function frameTime(msg) {
if (!msg || typeof msg !== 'object') return NaN;
const t = typeof msg.gt === 'number' ? msg.gt : msg.t;
return typeof t === 'number' && Number.isFinite(t) ? t : NaN;
}
/**
* Validate & normalise a b.snap payload. Returns `{ t, units: Map<id, tuple>, raw }` or null when unusable.
* Tuples with a non-finite id/x/y are skipped; other numbers default to 0.
*/
export function normalizeSnapshot(snap) {
if (!snap || typeof snap !== 'object') return null;
const t = frameTime(snap);
if (!Number.isFinite(t)) return null;
const units = new Map();
const list = Array.isArray(snap.units) ? snap.units : [];
for (const u of list) {
if (!Array.isArray(u) || u.length < 3) continue;
const id = u[0];
if (!(typeof id === 'number' || typeof id === 'string')) continue;
const x = u[1], y = u[2];
if (typeof x !== 'number' || typeof y !== 'number' || !Number.isFinite(x) || !Number.isFinite(y)) continue;
units.set(id, [id, x, y, finite(u[3]), finite(u[4]), finite(u[5]), finite(u[6]), finite(u[7]) | 0, finite(u[8]) | 0]);
}
return { t, units, raw: snap };
}
export class SnapshotBuffer {
/**
* @param {{ delay?: number, rate?: number, maxExtrapolate?: number, keep?: number, teleport?: number,
* snapAfter?: number, minRate?: number, maxRate?: number }} [opts]
* delay / maxExtrapolate / snapAfter / keep are REAL seconds.
*/
constructor(options) {
const opts = options && typeof options === 'object' ? options : {};
this.delay = Math.max(0, finite(opts.delay, 0.1));
this.defaultRate = clamp(finite(opts.rate, 2), 0.05, 20);
this.minRate = finite(opts.minRate, 0.25);
this.maxRate = finite(opts.maxRate, 8);
this.maxExtrapolate = Math.max(0, finite(opts.maxExtrapolate, 0.12));
this.keep = Math.max(0.5, finite(opts.keep, 2.5));
this.teleport = Math.max(0.5, finite(opts.teleport, 2.5));
this.snapAfter = Math.max(0.2, finite(opts.snapAfter, 0.75));
this.reset();
}
reset() {
/** @type {{t:number, units:Map<any, any[]>, raw:any, at:number}[]} ascending by t */
this.snaps = [];
/** @type {{t:number, at:number}[]} arrival log for rate estimation */
this.arrivals = [];
this.rate = this.defaultRate;
this.renderT = NaN;
this.lastNow = NaN;
this.interval = 0.1 * this.defaultRate / 2; // game s between snapshots (estimated)
/** @type {{t:number, ev:any}[]} */
this.events = [];
this.meta = null; // latest snapshot's extra fields (dp, killed, total, boss…)
return this;
}
get newestT() { return this.snaps.length ? this.snaps[this.snaps.length - 1].t : NaN; }
get oldestT() { return this.snaps.length ? this.snaps[0].t : NaN; }
get size() { return this.snaps.length; }
/**
* Add a snapshot received at real time `now` (seconds). Out-of-order / duplicate snapshots are dropped;
* a snapshot far older than the newest (a server-side reset, e.g. a new battle) resets the buffer.
* @returns {boolean} accepted
*/
push(snap, now) {
const s = normalizeSnapshot(snap);
if (!s) return false;
now = finite(now, this.lastNow || 0);
const newest = this.newestT;
if (this.snaps.length) {
if (s.t < newest - 5) this.reset(); // the server restarted the stream (new battle): old events are obsolete
else if (s.t <= newest) return false;
}
s.at = now;
const prev = this.snaps[this.snaps.length - 1];
if (prev) {
const dt = s.t - prev.t;
if (dt > 0 && dt < 2) this.interval = this.interval * 0.8 + dt * 0.2;
}
this.snaps.push(s);
this.meta = s.raw;
// rate estimate over a ~1.5 s window of arrivals
this.arrivals.push({ t: s.t, at: now });
while (this.arrivals.length > 2 && now - this.arrivals[0].at > 1.5) this.arrivals.shift();
if (this.arrivals.length >= 4) {
const a = this.arrivals[0], b = this.arrivals[this.arrivals.length - 1];
const dReal = b.at - a.at, dGame = b.t - a.t;
if (dReal > 0.2 && dGame > 0) this.rate = clamp(this.rate * 0.7 + (dGame / dReal) * 0.3, this.minRate, this.maxRate);
}
// trim old snapshots (keep at least 2). Trimming never waits for the render clock: while the page is hidden
// (no animation frames) snapshots keep arriving and must not pile up; a clock left behind is clamped.
const horizon = s.t - this.keep * this.rate;
while (this.snaps.length > 2 && this.snaps[1].t < horizon) this.snaps.shift();
if (!Number.isFinite(this.renderT)) this.renderT = Math.max(this.snaps[0].t, s.t - this.delay * this.rate);
else if (this.renderT < this.snaps[0].t) this.renderT = this.snaps[0].t;
return true;
}
/**
* Queue a b.ev batch. `t` (game s, the frame's `gt`) when the server stamps it; otherwise the batch is placed
* inside the latest snapshot interval (see header).
*/
pushEvents(evs, now, t) {
if (!Array.isArray(evs) || !evs.length) return 0;
let stamp = typeof t === 'number' && Number.isFinite(t) ? t : NaN;
if (!Number.isFinite(stamp)) {
const n = this.newestT;
stamp = Number.isFinite(n) ? n - this.interval * 0.5 : (Number.isFinite(this.renderT) ? this.renderT : 0);
}
let added = 0;
for (const ev of evs) {
if (!Array.isArray(ev) || typeof ev[0] !== 'string') continue;
this.events.push({ t: stamp, ev });
added++;
}
// bounded queue (hidden tab: nothing consumes it) — shed the oldest cosmetic events first
if (this.events.length > MAX_EVENTS) {
const keep = [];
const excess = this.events.length - MAX_EVENTS;
let shed = 0;
for (const e of this.events) {
if (shed < excess && isCosmeticEvent(e.ev)) { shed++; continue; }
keep.push(e);
}
this.events = keep.length > MAX_EVENTS * 2 ? keep.slice(keep.length - MAX_EVENTS * 2) : keep;
}
// keep sorted (batches normally arrive in order; a server stamp may be older)
if (this.events.length > 1 && this.events[this.events.length - 1].t < this.events[this.events.length - 1 - added]?.t) {
this.events.sort((a, b) => a.t - b.t);
}
return added;
}
/**
* Advance the render clock to real time `now` (seconds). Returns renderT (NaN until the first snapshot).
*/
update(now) {
now = finite(now, 0);
if (!this.snaps.length) { this.lastNow = now; return NaN; }
const dt = Number.isFinite(this.lastNow) ? clamp(now - this.lastNow, 0, 1) : 0;
this.lastNow = now;
const newest = this.newestT;
const target = newest - this.delay * this.rate;
if (!Number.isFinite(this.renderT)) this.renderT = target;
this.renderT += dt * this.rate;
const err = target - this.renderT;
if (Math.abs(err) > this.snapAfter * this.rate) this.renderT = target;
else this.renderT += err * Math.min(1, dt * 3);
const cap = newest + this.maxExtrapolate * this.rate;
if (this.renderT > cap) this.renderT = cap;
if (this.renderT < this.snaps[0].t) this.renderT = this.snaps[0].t;
return this.renderT;
}
/** Jump the render clock to the newest snapshot (scrubbing / first frame after a reset). */
snapToNewest() {
if (this.snaps.length) this.renderT = this.newestT;
return this.renderT;
}
/** Index of the newest snapshot with t ≤ time (−1 when time is before all). */
_indexAt(time) {
const s = this.snaps;
let lo = 0, hi = s.length - 1, ans = -1;
while (lo <= hi) {
const mid = (lo + hi) >> 1;
if (s[mid].t <= time) { ans = mid; lo = mid + 1; } else hi = mid - 1;
}
return ans;
}
/**
* Interpolated state at `time` (default renderT). Fills and returns `out` (a Map id → sample object reused
* across calls: `{ id, x, y, hp, maxHp, sp, spMax, flags, anim, vx, vy, seen }`). Samples of units no longer
* present are deleted from `out`.
*/
sample(time = this.renderT, out = new Map()) {
const s = this.snaps;
if (!s.length || !Number.isFinite(time)) { out.clear(); return out; }
let ia = this._indexAt(time);
if (ia < 0) ia = 0;
const A = s[ia];
const B = ia + 1 < s.length ? s[ia + 1] : null;
const span = B ? B.t - A.t : 0;
const alpha = B && span > 0 ? clamp((time - A.t) / span, 0, 1) : 0;
const P = !B && ia > 0 ? s[ia - 1] : null; // for extrapolation
const ext = !B ? clamp(time - A.t, 0, this.maxExtrapolate * this.rate) : 0;
const stamp = A.t;
for (const [id, a] of A.units) {
let o = out.get(id);
if (!o) { o = { id, x: 0, y: 0, hp: 0, maxHp: 0, sp: 0, spMax: 0, flags: 0, anim: 0, vx: 0, vy: 0, seen: 0 }; out.set(id, o); }
const b = B ? B.units.get(id) : null;
if (b) {
const dx = b[1] - a[1], dy = b[2] - a[2];
const tele = dx * dx + dy * dy > this.teleport * this.teleport;
o.x = tele ? (alpha < 1 ? a[1] : b[1]) : a[1] + dx * alpha;
o.y = tele ? (alpha < 1 ? a[2] : b[2]) : a[2] + dy * alpha;
o.vx = !tele && span > 0 ? dx / span : 0;
o.vy = !tele && span > 0 ? dy / span : 0;
o.hp = a[3] + (b[3] - a[3]) * alpha;
o.maxHp = b[4] || a[4];
o.sp = a[5] + (b[5] - a[5]) * alpha;
o.spMax = b[6] || a[6];
} else {
let vx = 0, vy = 0;
if (P && ext > 0) {
const p = P.units.get(id);
const dtp = A.t - P.t;
if (p && dtp > 0) {
vx = (a[1] - p[1]) / dtp; vy = (a[2] - p[2]) / dtp;
if (vx * vx + vy * vy > (this.teleport / dtp) ** 2) { vx = 0; vy = 0; }
}
}
o.x = a[1] + vx * ext;
o.y = a[2] + vy * ext;
o.vx = vx; o.vy = vy;
o.hp = a[3]; o.maxHp = a[4]; o.sp = a[5]; o.spMax = a[6];
}
// the sim rounds hp up and maxHp to nearest, so a snapshot may say hp = maxHp + 1
if (o.hp > o.maxHp && o.maxHp > 0) o.hp = o.maxHp;
else if (!(o.hp > 0)) o.hp = 0;
o.flags = a[7];
o.anim = a[8];
o.seen = stamp;
}
for (const id of out.keys()) if (!A.units.has(id)) out.delete(id);
return out;
}
/**
* Remove and return (in order) the queued events due at `time` (default renderT). When `dropCosmeticBefore`
* is a number, cosmetic events stamped earlier than it are discarded instead of returned.
*/
takeEvents(time = this.renderT, out = [], dropCosmeticBefore = null) {
if (!this.events.length || !Number.isFinite(time)) return out;
let n = 0;
while (n < this.events.length && this.events[n].t <= time) n++;
if (!n) return out;
const list = this.events;
for (let i = 0; i < n; i++) {
const e = list[i];
if (typeof dropCosmeticBefore === 'number' && e.t < dropCosmeticBefore && isCosmeticEvent(e.ev)) continue;
out.push(e.ev);
}
const rest = list.length - n;
list.copyWithin(0, n);
list.length = rest;
return out;
}
/**
* Look ahead: call `fn(ev, t)` for every queued event stamped in (from, to] (ascending; nothing is removed).
* Lets the renderer start animations that must peak when an event becomes due (attack wind-ups).
*/
forEachUpcoming(from, to, fn) {
const list = this.events;
for (let i = 0; i < list.length; i++) {
const e = list[i];
if (e.t > to) break;
if (e.t > from) fn(e.ev, e.t);
}
}
/** Deliver every queued event now (used before a reset so state events are not lost). */
flushEvents(out = []) {
for (const e of this.events) out.push(e.ev);
this.events.length = 0;
return out;
}
}
+236
View File
@@ -0,0 +1,236 @@
// render/pen.js — the enemy preview pen of the prep phase (research 09 §2.2, research 08 §4.2, DESIGN §3): where the
// next round's enemies stand, idling and facing left, behind the gates. Pure logic (no PIXI): render/app.js turns the
// layout into idle enemy views.
//
// const pen = layoutPen(nextEnemies, { stage }) // m.private.nextEnemies (per-action entries with counts)
// → { figures: [{ key, enemyKey, row, col, x, y, fly, elite, boss, t, zone }], total, shown, sig }
//
// Official algorithm (client AutoChessEnemyPreviewManager, decoded in research 08 §4.2), with the documented choices:
// * entries sorted by spawn time (`t`, _CompareActionByTime; stable);
// * thinning: total = Σ count, ratio = min(1, 50 / total); each entry shows max(1, round(count · ratio)) figures,
// elites and bosses (handbook level ≠ NORMAL) always their full count; normal figures past MAX_PREVIEW (50) are
// dropped from the latest spawns first (elites / bosses are never dropped);
// * zones: the pen rect `enemy_place_rect` ((14,7),(18,13)) minus the `previewNotAlloed` row (16) and the two
// `tile_start` anchors (15,7) / (18,7); every tile belongs to the Manhattan-nearest anchor → lower zone rows 14–15,
// upper zone rows 17–18 (13 tiles each). Upper-gate enemies → upper zone, lower-gate → lower zone;
// * tile choice: the k-th figure sent to a zone goes to zoneTiles[clamp(round(k / totalShowCnt · len) − jitter)]
// (totalShowCnt = min(50, Σ count), jitter ∈ {0, 1}, seeded — `Random.Range(−1, 0)` [ASSUMED both values]); each
// zone's tiles in ROW-MAJOR order as the client builds them (research 08 §4.2 step 3 [DATA]: low row first, col
// 7 → 13), so the first spawns stand next to the gate anchor and a small zone stays by its gate;
// * at most PER_TILE (3) figures per tile ([ASSUMED], research 09 §2.2): a full tile passes the figure to the next
// tile of its zone with room; a zone whose every tile is full stacks the rest on its least-loaded tiles — never in
// the other gate's zone ("keep the grouping exact: one zone per gate", research 08 §4.2 step 6; only a zone without
// tiles falls back to any pen tile, m_backUpTiles); figures sharing a tile stand in a small cluster (≤ 6 distinct
// spots) with a seeded jitter.
// Deterministic for a given preview (the seed is a hash of the entries), so re-sending the same m.private never
// reshuffles the pen.
export const PEN_RECT = Object.freeze({ r0: 14, r1: 18, c0: 7, c1: 13 });
export const MAX_PREVIEW = 50;
export const PER_TILE = 3;
/** Cluster offsets (tiles; x = col, y = row) of 1–6 figures sharing a tile (deeper ones a bit to the right). */
const CLUSTER = Object.freeze([
Object.freeze([[0, 0]]),
Object.freeze([[-0.17, 0.13], [0.17, -0.13]]),
Object.freeze([[-0.22, 0.16], [0.2, 0.18], [-0.02, -0.2]]),
Object.freeze([[-0.22, 0.2], [0.22, 0.2], [-0.22, -0.2], [0.22, -0.2]]),
Object.freeze([[-0.25, 0.22], [0.25, 0.22], [0, 0], [-0.25, -0.22], [0.25, -0.22]]),
Object.freeze([[-0.27, 0.22], [0, 0.26], [0.27, 0.22], [-0.27, -0.22], [0, -0.26], [0.27, -0.22]]),
]);
const isObj = (v) => !!v && typeof v === 'object';
/** mulberry32 */
function rng(seed) {
let a = seed >>> 0;
return () => {
a = (a + 0x6d2b79f5) >>> 0;
let t = a;
t = Math.imul(t ^ (t >>> 15), t | 1);
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
function hashStr(s) {
let h = 2166136261;
for (let i = 0; i < s.length; i++) { h ^= s.charCodeAt(i); h = Math.imul(h, 16777619); }
return h >>> 0;
}
/** Parse a configBlackBoard rect "((r0,c0),(r1,c1))" → { r0, r1, c0, c1 } (null when malformed). */
export function parsePenRect(v) {
if (typeof v !== 'string') return null;
const n = v.replace(/[()\s]/g, '').split(',').map(Number);
if (n.length !== 4 || !n.every(Number.isInteger)) return null;
return { r0: Math.min(n[0], n[2]), r1: Math.max(n[0], n[2]), c0: Math.min(n[1], n[3]), c1: Math.max(n[1], n[3]) };
}
/**
* The pen's two zones for a stage (defaults when the stage is missing or malformed): `{ rect, lower, upper }`, each
* zone `{ anchor: [r, c], tiles: [[r, c]…] }` in row-major order (research 08 §4.2: low row first, then col).
*/
export function penZones(stage) {
const rect = parsePenRect(stage?.config?.enemy_place_rect) || { ...PEN_RECT };
const rows = Array.isArray(stage?.rows) ? stage.rows : null;
const legend = isObj(stage?.tiles) ? stage.tiles : {};
const glyph = (r, c) => (rows && typeof rows[r] === 'string' ? rows[r][c] : null);
const anchors = [];
const cand = [];
for (let r = rect.r0; r <= rect.r1; r++) {
for (let c = rect.c0; c <= rect.c1; c++) {
const g = glyph(r, c);
const def = g ? legend[g] : null;
if (rows) {
if (!def) continue;
if (def.tileKey === 'tile_start') { anchors.push([r, c]); continue; }
if (def.bb && def.bb.previewNotAlloed) continue;
if (def.passable && def.passable !== 'ALL') continue;
cand.push([r, c]);
} else {
// no stage: the official layout ((14,7),(18,13)), anchors (15,7) / (18,7), row 16 not allowed
if (c === rect.c0 && (r === rect.r0 + 1 || r === rect.r1)) { anchors.push([r, c]); continue; }
if (r === rect.r0 + 2) continue;
cand.push([r, c]);
}
}
}
if (!anchors.length) anchors.push([rect.r0 + 1, rect.c0], [rect.r1, rect.c0]);
anchors.sort((a, b) => a[0] - b[0] || a[1] - b[1]);
const zones = anchors.map((a) => ({ anchor: a, tiles: [] }));
const dist = (a, t) => Math.abs(a[0] - t[0]) + Math.abs(a[1] - t[1]);
for (const t of cand) {
let best = zones[0], bd = Infinity;
for (const z of zones) { const d = dist(z.anchor, t); if (d < bd) { bd = d; best = z; } }
best.tiles.push(t);
}
// row-major, as the client lists them (the candidates are collected row by row; kept explicit for custom rects)
for (const z of zones) z.tiles.sort((p, q) => p[0] - q[0] || p[1] - q[1]);
return { rect, lower: zones[0], upper: zones[zones.length - 1], zones };
}
/** Clean, time-sorted entries of an m.private.nextEnemies list. */
export function penEntries(list) {
const out = [];
if (!Array.isArray(list)) return out;
list.forEach((e, i) => {
if (!isObj(e) || typeof e.enemyKey !== 'string' || !e.enemyKey) return;
const count = Math.max(1, Math.min(200, Math.trunc(Number(e.count) || 1)));
out.push({
enemyKey: e.enemyKey, count, gate: e.gate === 'upper' ? 'upper' : 'lower', t: Number.isFinite(Number(e.t)) ? Number(e.t) : 0,
fly: !!e.fly, boss: !!e.boss || e.tag === 'boss', elite: !!e.elite || !!e.boss || e.tag === 'boss', i,
});
});
out.sort((a, b) => a.t - b.t || a.i - b.i);
return out;
}
/** Signature of a preview list (unchanged signature ⇒ the pen is left as it is). */
export function penSignature(list) {
return penEntries(list).map((e) => `${e.enemyKey}:${e.count}:${e.gate[0]}:${e.t}:${e.fly ? 1 : 0}${e.elite ? 1 : 0}${e.boss ? 1 : 0}`).join('|');
}
/**
* Figures shown per entry after thinning (see header): [{ entry, n }] in spawn order.
* @param {ReturnType<typeof penEntries>} entries
*/
export function thinPreview(entries, max = MAX_PREVIEW) {
const total = entries.reduce((s, e) => s + e.count, 0);
const ratio = total > 0 ? Math.min(1, max / total) : 1;
const plan = entries.map((e) => ({ entry: e, n: e.elite ? e.count : Math.max(1, Math.round(e.count * ratio)) }));
let shown = plan.reduce((s, p) => s + p.n, 0);
// over the cap (rounding, elites): take figures from the biggest normal groups first, so every action keeps a
// figure while it can; then drop single normal figures from the latest spawns (elites / bosses always stay)
while (shown > max) {
let big = null;
for (const p of plan) if (!p.entry.elite && p.n > 1 && (!big || p.n > big.n)) big = p;
if (!big) break;
big.n--;
shown--;
}
for (let i = plan.length - 1; i >= 0 && shown > max; i--) {
const p = plan[i];
if (p.entry.elite || !p.n) continue;
p.n--;
shown--;
}
return plan.filter((p) => p.n > 0);
}
/**
* Lay the preview out in the pen (see header).
* @param {Array<object>} list m.private.nextEnemies
* @param {{ stage?: object, max?: number, perTile?: number }} [opts]
*/
export function layoutPen(list, opts = {}) {
const entries = penEntries(list);
const sig = penSignature(list);
const Z = penZones(opts.stage);
const max = Number.isInteger(opts.max) && opts.max > 0 ? opts.max : MAX_PREVIEW;
const perTile = Number.isInteger(opts.perTile) && opts.perTile > 0 ? opts.perTile : PER_TILE;
const plan = thinPreview(entries, max);
const shown = plan.reduce((s, p) => s + p.n, 0);
const total = entries.reduce((s, e) => s + e.count, 0);
// the index divisor of the client: totalShowCnt = min(MAX_PREVIEW_CNT, Σ count) (research 08 §4.2 step 2)
const totalShowCnt = Math.max(1, Math.min(max, total));
const rand = rng(hashStr(sig) || 1);
const load = new Map(); // "r,c" → figures on the tile
const key = (t) => `${t[0]},${t[1]}`;
const has = (t) => (load.get(key(t)) || 0) < perTile;
const allTiles = [...Z.lower.tiles, ...Z.upper.tiles];
const placed = [];
const kOf = { lower: 0, upper: 0 };
for (const { entry, n } of plan) {
const zoneName = entry.gate === 'upper' ? 'upper' : 'lower';
const zone = Z[zoneName];
for (let j = 0; j < n; j++) {
const k = kOf[zoneName]++;
const len = zone.tiles.length;
let tile = null;
if (len) {
const jitter = rand() < 0.5 ? 1 : 0;
let idx = Math.round((k / totalShowCnt) * len) - jitter;
idx = Math.max(0, Math.min(len - 1, idx));
// a full tile passes the figure on (further along the zone list, then back towards its start)
for (let d = 0; d < len && !tile; d++) {
for (const ii of d ? [idx + d, idx - d] : [idx]) if (ii >= 0 && ii < len && has(zone.tiles[ii])) { tile = zone.tiles[ii]; break; }
}
// the whole zone is full: stack on its least-loaded tile nearest the index (never in the other gate's zone)
if (!tile) {
let best = Infinity;
for (let ii = 0; ii < len; ii++) {
const score = (load.get(key(zone.tiles[ii])) || 0) * len + Math.abs(ii - idx);
if (score < best) { best = score; tile = zone.tiles[ii]; }
}
}
}
if (!tile) tile = allTiles.find(has) || allTiles[0] || [Z.rect.r0, Z.rect.c1];
load.set(key(tile), (load.get(key(tile)) || 0) + 1);
placed.push({ entry, tile, zone: zoneName, slot: 0 });
}
}
// cluster offsets once every tile's final occupancy is known
const seen = new Map();
const figures = placed.map((p, idx) => {
const kk = key(p.tile);
const cnt = Math.min(CLUSTER.length, load.get(kk) || 1);
const slot = seen.get(kk) || 0;
seen.set(kk, slot + 1);
const off = CLUSTER[cnt - 1][slot % cnt] || [0, 0];
const jx = (rand() - 0.5) * 0.08, jy = (rand() - 0.5) * 0.08;
const e = p.entry;
return {
key: `${idx}:${e.enemyKey}`, enemyKey: e.enemyKey, row: p.tile[0], col: p.tile[1],
x: p.tile[1] + off[0] + jx, y: p.tile[0] + off[1] + jy,
fly: e.fly, elite: e.elite, boss: e.boss, t: e.t, zone: p.zone,
};
});
return { figures, total, shown, sig };
}
/** Whether a tile lies inside the pen rect (rows 14–18 × cols 7–13 by default). */
export function inPen(row, col, rect = PEN_RECT) {
return row >= rect.r0 && row <= rect.r1 && col >= rect.c0 && col <= rect.c1;
}
+70
View File
@@ -0,0 +1,70 @@
// render/prepfield.js — where the prep board is shown (research 09 §1.2, DESIGN §3 / §15): normally the own board
// (rows 7–12, the stage grid as is); in the Final Assault / Hidden Core prep the player's pieces stand on THEIR half of
// the boss field — the sim's mapping (server/match/finalAssault.js bossFieldPlacement, sim Battle.mapTile / mapDir):
// board rows ≥ 7 → row − 7 (board 9–12 → boss 2–5, the bench row 7 → 0, the temp row 8 → 1); the right-hand player
// (side 'R') is mirrored col c → 20 − c with RIGHT ↔ LEFT (UP / DOWN unchanged, ConvertChessPositionInfoToBossMap).
// Pieces only ever stand on rows ≥ 7; other board rows (range tiles reaching past the field) shift the same way, so a
// DOWN range below the bench falls off the grid (row < 0) instead of landing on the boss rows, and `tilesToDisp` drops
// every tile that is not on the boss field (rows 0–6) — e.g. an UP range past the top wall onto the normal board.
//
// The view keeps every public coordinate in BOARD space (the server's, g.move targets, canPlace, highlightTiles,
// tileScreen, holdPiece, setPieceDir): only what is drawn and picked goes through this transform. Pure functions.
export const BOSS_ROW_SHIFT = -7;
export const MAX_COL = 20;
const MIRROR = Object.freeze({ RIGHT: 'LEFT', LEFT: 'RIGHT', UP: 'UP', DOWN: 'DOWN' });
/** RIGHT ↔ LEFT (UP / DOWN unchanged); unknown values pass through. */
export function mirrorDir(d) {
return typeof d === 'string' && MIRROR[d.toUpperCase()] ? MIRROR[d.toUpperCase()] : d;
}
/** The identity transform (normal prep). */
export const IDENTITY = Object.freeze({
kind: 'board', side: 'L', mirror: false,
toDisp: (row, col) => ({ row, col }),
toBoard: (row, col) => ({ row, col }),
dirToDisp: (d) => d,
dirToBoard: (d) => d,
});
/**
* Transform of the Final Assault prep on side 'L' | 'R' of the boss field. `toBoard` returns null for display tiles
* that are not on the player's half of the boss rows (0–5) — nothing of the board is there.
*/
export function bossPrepField(side) {
const R = side === 'R';
const lo = R ? 10 : 0, hi = R ? MAX_COL : 10;
return Object.freeze({
kind: 'bossPrep', side: R ? 'R' : 'L', mirror: R,
toDisp: (row, col) => ({ row: row + BOSS_ROW_SHIFT, col: R ? MAX_COL - col : col }),
toBoard: (row, col) => {
if (!Number.isInteger(row) || !Number.isInteger(col) || row < 0 || row > 5 || col < lo || col > hi) return null;
return { row: row - BOSS_ROW_SHIFT, col: R ? MAX_COL - col : col };
},
dirToDisp: (d) => (R ? mirrorDir(d) : d),
dirToBoard: (d) => (R ? mirrorDir(d) : d),
});
}
/** Last display row of the boss field band (rows 0–5 + the wall row 6) in the Final Assault prep. */
export const BOSS_DISP_MAX_ROW = 6;
/**
* Map a list of board tiles ([[r,c]] or [{row,col}]) to display tiles ([[r,c]]). In the Final Assault prep tiles that
* do not land on the boss field (display rows 0–6, cols 0–20) are dropped.
*/
export function tilesToDisp(xf, tiles) {
const out = [];
if (!Array.isArray(tiles)) return out;
const boss = xf && xf.kind === 'bossPrep';
for (const t of tiles) {
const r = Array.isArray(t) ? t[0] : t?.row, c = Array.isArray(t) ? t[1] : t?.col;
if (!Number.isInteger(r) || !Number.isInteger(c)) continue;
const d = xf.toDisp(r, c);
if (boss && (d.row < 0 || d.row > BOSS_DISP_MAX_ROW || d.col < 0 || d.col > MAX_COL)) continue;
out.push([d.row, d.col]);
}
return out;
}
+432
View File
@@ -0,0 +1,432 @@
// render/projection.js — the battlefield camera: one perspective camera model shared by the three.js board scene
// (render/board3d, DESIGN §15) and the Pixi layers on top of it (units, FX, drag & drop, hit-testing). Pure math, no
// PIXI / three import (Node tests and the 2D fallback use it as is).
//
// World space (DESIGN §3): x = col, y = row (row 0 = bottom = nearest to the camera), z = height in tiles (up).
// Tile (r,c) covers x ∈ [c−½, c+½], y ∈ [r−½, r+½]. The camera is a three.js-style PerspectiveCamera: a pinhole
// south of its target (towards −y), pitched `tilt` degrees away from looking straight down, `dist` tiles from the
// target point on the ground, with an off-axis lens (principal point (cx, cy), three's setViewOffset):
//
// C = T + dist·(0, −sin tilt, cos tilt) camera position
// f = (0, sin tilt, −cos tilt) forward u = (0, cos tilt, sin tilt) up r = (1,0,0)
// depth = (P−C)·f ; screen = (cx, cy) + scale·dist·((P−C)·r, −(P−C)·u) / depth
//
// `scale` = CSS px per world unit at the target's depth, i.e. the focal length is scale·dist px and the vertical
// field of view of a viewport H px high is 2·atan(H / 2 / (scale·dist)). `threeCameraParams` / `syncThreeCamera`
// turn a Camera into the equivalent THREE.PerspectiveCamera (fov, aspect, position, up, look-at, view offset) —
// test/render/projection.test.js projects every tile centre through both and requires identical pixels.
//
// Official framing (research 09 §2.1, fitted against official 1920×1080 screenshots; DESIGN §15): every 卫戍协议
// level's configBlackBoard carries camera params "(x,y,z,w)" (left_prepare / left_shop / left_battle / mid_battle /
// *_boss_* / enemy). They are offsets of Arknights' standard battle camera: position = OFFICIAL.base + param in the
// level's centred Unity frame (x = col − 10, y = row − 9, z = −height), pitched OFFICIAL.pitch = 30° with a vertical
// field of view of OFFICIAL.fovY = 40° and the principal point at the screen centre. (Measured: the shop camera
// (−4.83, −1.75, −3.77) puts the camera at col 5.17, row 2.44, height 11.53 — the least-squares fit of tile seams in
// an official shop-view screenshot gave col 5.17, row 2.40, height 11.57 at 30°/40°.) Screens narrower than 16:9
// back the camera off like the client (up to 4:3: Δ = (0, −1.4, −2.8)·t). `presetCamera` returns these official
// cameras; `fitCamera` (frame any rect inside a padded viewport) remains for custom rects and portrait viewports.
export const DEG = Math.PI / 180;
/** Arknights' standard battle camera (see header). */
export const OFFICIAL = Object.freeze({
base: Object.freeze([0, -4.81, -7.76]), // standard view position in the centred Unity frame
pitch: 30, // degrees from straight down
fovY: 40, // vertical field of view, degrees
center: Object.freeze([10, 9]), // (col, row) of the Unity frame origin on the 19×21 grid
aspectAdjust: Object.freeze([0, -1.4, -2.8]), // camera offset at 3:4 (H/W), interpolated from 9:16
});
/** configBlackBoard camera params (identical in all 11 autochess stages; stage.config overrides). */
export const OFFICIAL_PARAMS = Object.freeze({
left_prepare_camera_param: '(-4.67,0.3,-2.46,1)',
left_shop_camera_param: '(-4.83,-1.75,-3.77,1)',
left_battle_camera_param: '(-4,1.91,-0.6,1)',
right_battle_camera_param: '(4,1.91,-0.6,1)',
mid_battle_camera_param: '(0,0,-4.22,1)',
left_boss_prepare_camera_param: '(-4.67,-6.68,-2.46,1)',
left_boss_shop_camera_param: '(-4.83,-8.73,-3.77,1)',
left_boss_battle_camera_param: '(-4,-5.54,-0.6,1)',
right_boss_battle_camera_param: '(4,-5.54,-0.6,1)',
mid_boss_battle_camera_param: '(0,-7.95,-4.22,1)',
right_boss_prepare_camera_param: '(5.93,-6.68,-2.46,1)',
right_boss_shop_camera_param: '(5.83,-8.73,-3.77,1)',
enemy_camera_param: '(0,7.86,0.9,1)',
});
/** Default optics of the fitted (non-official) cameras: the official pitch; dist is a moderate lens. */
export const DEFAULT_OPTICS = Object.freeze({ tilt: OFFICIAL.pitch, dist: 16 });
/**
* Rects framed by each camera kind (inclusive tile bounds), framing parameters of the fitted fallback, and the
* official configBlackBoard param(s) of the kind (`official`: side → param key; `half` for the ‹ › half views).
*/
export const CAMERA_PRESETS = Object.freeze({
prep: Object.freeze({
rect: Object.freeze({ r0: 7, r1: 12, c0: 0, c1: 10 }), margin: 0.35, headroom: 1.25, tilt: 30, dist: 13,
official: Object.freeze({ L: 'left_shop_camera_param', R: 'left_shop_camera_param' }),
officialNoShop: Object.freeze({ L: 'left_prepare_camera_param', R: 'left_prepare_camera_param' }),
}),
normal: Object.freeze({
rect: Object.freeze({ r0: 9, r1: 12, c0: 0, c1: 10 }), margin: 0.45, headroom: 1.35, tilt: 30, dist: 13,
official: Object.freeze({ L: 'left_battle_camera_param', R: 'right_battle_camera_param' }),
}),
unite: Object.freeze({
rect: Object.freeze({ r0: 9, r1: 12, c0: 0, c1: 20 }), margin: 0.25, headroom: 1.35, tilt: 30, dist: 18,
official: Object.freeze({ L: 'mid_battle_camera_param', R: 'mid_battle_camera_param' }),
half: Object.freeze({ L: 'left_battle_camera_param', R: 'right_battle_camera_param' }),
}),
boss: Object.freeze({
rect: Object.freeze({ r0: 0, r1: 5, c0: 0, c1: 20 }), margin: 0.2, headroom: 1.5, tilt: 30, dist: 18,
official: Object.freeze({ L: 'mid_boss_battle_camera_param', R: 'mid_boss_battle_camera_param' }),
half: Object.freeze({ L: 'left_boss_battle_camera_param', R: 'right_boss_battle_camera_param' }),
}),
bossPrep: Object.freeze({
rect: Object.freeze({ r0: 0, r1: 5, c0: 0, c1: 10 }), margin: 0.35, headroom: 1.25, tilt: 30, dist: 13,
official: Object.freeze({ L: 'left_boss_shop_camera_param', R: 'right_boss_shop_camera_param' }),
officialNoShop: Object.freeze({ L: 'left_boss_prepare_camera_param', R: 'right_boss_prepare_camera_param' }),
}),
pen: Object.freeze({
rect: Object.freeze({ r0: 14, r1: 18, c0: 7, c1: 13 }), margin: 0.3, headroom: 1.2, tilt: 30, dist: 11,
official: Object.freeze({ L: 'enemy_camera_param', R: 'enemy_camera_param' }),
}),
});
const EPS = 1e-6;
const MIN_DEPTH = 0.05;
const finite = (v, d) => (typeof v === 'number' && Number.isFinite(v) ? v : d);
const clamp = (v, lo, hi) => (v < lo ? lo : v > hi ? hi : v);
/**
* Perspective camera. Mutable; call `update()` after changing fields directly (setters do it for you).
*/
export class Camera {
/**
* @param {{ tx?: number, ty?: number, tz?: number, tilt?: number, dist?: number, scale?: number, cx?: number, cy?: number }} [o]
*/
constructor(options) {
const o = options && typeof options === 'object' ? options : {};
this.tx = finite(o.tx, 5);
this.ty = finite(o.ty, 10);
this.tz = finite(o.tz, 0);
this.tilt = clamp(finite(o.tilt, DEFAULT_OPTICS.tilt), 0, 80);
this.dist = Math.max(1, finite(o.dist, DEFAULT_OPTICS.dist));
this.scale = Math.max(EPS, finite(o.scale, 64));
this.cx = finite(o.cx, 0);
this.cy = finite(o.cy, 0);
/** bumped by update(); lets consumers cache projections */
this.version = 0;
this.update();
}
/** Recompute derived values (camera position, basis). */
update() {
const t = this.tilt * DEG;
this._s = Math.sin(t);
this._c = Math.cos(t);
this._px = this.tx;
this._py = this.ty - this.dist * this._s;
this._pz = this.tz + this.dist * this._c;
this._k = this.scale * this.dist;
this.version++;
return this;
}
/** Copy every parameter from another camera. */
copy(o) {
this.tx = o.tx; this.ty = o.ty; this.tz = o.tz; this.tilt = o.tilt; this.dist = o.dist;
this.scale = o.scale; this.cx = o.cx; this.cy = o.cy;
return this.update();
}
clone() { return new Camera(this.params()); }
/** Plain parameter object (serialisable). */
params() {
return { tx: this.tx, ty: this.ty, tz: this.tz, tilt: this.tilt, dist: this.dist, scale: this.scale, cx: this.cx, cy: this.cy };
}
/** World position of the camera (the pinhole). */
position() { return { x: this._px, y: this._py, z: this._pz }; }
/** Focal length in CSS px (screen distance of the image plane). */
focal() { return this._k; }
/** Depth of a world point along the view axis (≥ MIN_DEPTH). */
depthOf(x, y, z = 0) {
const d = (y - this._py) * this._s - (z - this._pz) * this._c;
return d > MIN_DEPTH ? d : MIN_DEPTH;
}
/**
* Project a world point. Writes `{ x, y, s, depth }` into `out` (allocated when omitted):
* screen position (CSS px) and `s` = CSS px per world unit at that point.
*/
project(x, y, z = 0, out = { x: 0, y: 0, s: 0, depth: 0 }) {
const vx = x - this._px, vy = y - this._py, vz = z - this._pz;
let depth = vy * this._s - vz * this._c;
if (!(depth > MIN_DEPTH)) depth = MIN_DEPTH;
const up = vy * this._c + vz * this._s;
const k = this._k / depth;
out.x = this.cx + vx * k;
out.y = this.cy - up * k;
out.s = k;
out.depth = depth;
return out;
}
/** CSS px per world unit at a world point. */
scaleAt(x, y, z = 0) { return this._k / this.depthOf(x, y, z); }
/**
* Inverse projection onto the horizontal plane at height `z`. Returns `{ x, y }` (written into `out`) or
* null when the view ray does not hit the plane in front of the camera.
*/
unproject(sx, sy, z = 0, out = { x: 0, y: 0 }) {
// Ray direction for screen point: d = f + r·a + u·b with a = (sx−cx)/k, b = −(sy−cy)/k.
const a = (sx - this.cx) / this._k;
const b = -(sy - this.cy) / this._k;
const dx = a;
const dy = this._s + b * this._c;
const dz = -this._c + b * this._s;
if (Math.abs(dz) < EPS) return null;
const t = (z - this._pz) / dz;
if (!(t > 0) || !Number.isFinite(t)) return null;
out.x = this._px + t * dx;
out.y = this._py + t * dy;
return out;
}
/** The equivalent THREE.PerspectiveCamera parameters for a `width`×`height` CSS px viewport (see below). */
threeParams(width, height, opts) { return threeCameraParams(this, width, height, opts); }
}
// ---- three.js PerspectiveCamera equivalence ------------------------------------------------------------------
/**
* Parameters of the THREE.PerspectiveCamera that projects exactly like `cam` onto a `width`×`height` viewport:
* `{ fov (vertical, degrees), aspect, near, far, position: [x,y,z], up: [x,y,z], target: [x,y,z],
* view: { fullWidth, fullHeight, offsetX, offsetY, width, height } }` (three's setViewOffset: the off-axis
* principal point (cx, cy)). World axes are the projection's (x = col, y = row, z = up).
*/
export function threeCameraParams(cam, width, height, opts) {
const o = opts && typeof opts === 'object' ? opts : {};
const W = Math.max(1, finite(width, 1)), H = Math.max(1, finite(height, 1));
const t = cam.tilt * DEG, s = Math.sin(t), c = Math.cos(t);
const fovY = 2 * Math.atan(H / 2 / Math.max(EPS, cam.scale * cam.dist)) / DEG;
const pos = [cam.tx, cam.ty - cam.dist * s, cam.tz + cam.dist * c];
return {
fov: fovY, aspect: W / H,
near: Math.max(0.01, finite(o.near, cam.dist * 0.05)), far: Math.max(1, finite(o.far, cam.dist + 80)),
position: pos, up: [0, c, s], target: [cam.tx, cam.ty, cam.tz],
view: { fullWidth: W, fullHeight: H, offsetX: W / 2 - cam.cx, offsetY: H / 2 - cam.cy, width: W, height: H },
};
}
/**
* Configure a THREE.PerspectiveCamera (duck-typed: fov, aspect, near, far, position, up, lookAt, setViewOffset,
* updateProjectionMatrix, updateMatrixWorld) to project exactly like `cam` on a `width`×`height` CSS px viewport.
*/
export function syncThreeCamera(cam, three, width, height, opts) {
const p = threeCameraParams(cam, width, height, opts);
three.fov = p.fov;
three.aspect = p.aspect;
three.near = p.near;
three.far = p.far;
three.position.set(p.position[0], p.position[1], p.position[2]);
three.up.set(p.up[0], p.up[1], p.up[2]);
three.lookAt(p.target[0], p.target[1], p.target[2]);
const v = p.view;
three.setViewOffset(v.fullWidth, v.fullHeight, v.offsetX, v.offsetY, v.width, v.height);
three.updateProjectionMatrix();
three.updateMatrixWorld(true);
return three;
}
/** Ease for camera transitions. */
export const easeInOutCubic = (t) => (t <= 0 ? 0 : t >= 1 ? 1 : t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2);
export const easeOutCubic = (t) => (t <= 0 ? 0 : t >= 1 ? 1 : 1 - Math.pow(1 - t, 3));
/** Interpolate two cameras into `out` (scale in log space so zooms feel linear). */
export function lerpCamera(a, b, t, out = new Camera()) {
const u = t <= 0 ? 0 : t >= 1 ? 1 : t;
const L = (p, q) => p + (q - p) * u;
out.tx = L(a.tx, b.tx); out.ty = L(a.ty, b.ty); out.tz = L(a.tz, b.tz);
out.tilt = L(a.tilt, b.tilt); out.dist = L(a.dist, b.dist);
out.scale = Math.exp(L(Math.log(Math.max(EPS, a.scale)), Math.log(Math.max(EPS, b.scale))));
out.cx = L(a.cx, b.cx); out.cy = L(a.cy, b.cy);
return out.update();
}
/** Normalised copy of a rect ({r0,r1,c0,c1} inclusive; swapped bounds fixed; clamped to the 19×21 grid). */
export function normRect(rect, rows = 19, cols = 21) {
const src = rect && typeof rect === 'object' ? rect : {};
let r0 = Math.round(finite(src.r0, 0)), r1 = Math.round(finite(src.r1, rows - 1));
let c0 = Math.round(finite(src.c0, 0)), c1 = Math.round(finite(src.c1, cols - 1));
if (r0 > r1) [r0, r1] = [r1, r0];
if (c0 > c1) [c0, c1] = [c1, c0];
return { r0: clamp(r0, 0, rows - 1), r1: clamp(r1, 0, rows - 1), c0: clamp(c0, 0, cols - 1), c1: clamp(c1, 0, cols - 1) };
}
/**
* Camera that frames `rect` inside the viewport.
* @param {{r0,r1,c0,c1}} rect inclusive tile bounds
* @param {{ width: number, height: number, padding?: { top?, right?, bottom?, left? } }} viewport CSS px
* @param {{ tilt?, dist?, margin?: number, headroom?: number, maxTilePx?: number, minTilePx?: number }} [opts]
* margin (tiles) around the rect; headroom (tiles) of vertical space kept above the far row for units.
*/
export function fitCamera(rect, viewport, options) {
const opts = options && typeof options === 'object' ? options : {};
const R = normRect(rect);
const W = Math.max(1, finite(viewport?.width, 1));
const H = Math.max(1, finite(viewport?.height, 1));
const pad = viewport?.padding || {};
const pt = Math.max(0, finite(pad.top, 0)), pr = Math.max(0, finite(pad.right, 0));
const pb = Math.max(0, finite(pad.bottom, 0)), pl = Math.max(0, finite(pad.left, 0));
const availW = Math.max(16, W - pl - pr), availH = Math.max(16, H - pt - pb);
const margin = Math.max(0, finite(opts.margin, 0.35));
const headroom = Math.max(0, finite(opts.headroom, 1.2));
const cam = new Camera({
tx: (R.c0 + R.c1) / 2, ty: (R.r0 + R.r1) / 2, tz: 0,
tilt: finite(opts.tilt, DEFAULT_OPTICS.tilt), dist: finite(opts.dist, DEFAULT_OPTICS.dist), scale: 1, cx: 0, cy: 0,
});
const x0 = R.c0 - 0.5 - margin, x1 = R.c1 + 0.5 + margin;
const y0 = R.r0 - 0.5 - margin, y1 = R.r1 + 0.5 + margin;
let minX = Infinity, maxX = -Infinity, minY = Infinity, maxY = -Infinity;
const p = { x: 0, y: 0, s: 0, depth: 0 };
for (const x of [x0, x1]) for (const y of [y0, y1]) for (const z of [0, headroom]) {
cam.project(x, y, z, p);
if (p.x < minX) minX = p.x; if (p.x > maxX) maxX = p.x;
if (p.y < minY) minY = p.y; if (p.y > maxY) maxY = p.y;
}
const bw = Math.max(EPS, maxX - minX), bh = Math.max(EPS, maxY - minY);
let s = Math.min(availW / bw, availH / bh);
const maxTile = finite(opts.maxTilePx, 360), minTile = finite(opts.minTilePx, 4);
s = clamp(s, minTile, maxTile);
cam.scale = s;
cam.cx = pl + availW / 2 - s * (minX + maxX) / 2;
cam.cy = pt + availH / 2 - s * (minY + maxY) / 2;
return cam.update();
}
/** Parse a configBlackBoard camera param "(x,y,z,w)" → [x, y, z, w] (null when malformed). */
export function parseCameraParam(v) {
if (Array.isArray(v)) {
const a = v.map(Number);
return a.length >= 3 && a.slice(0, 3).every(Number.isFinite) ? [a[0], a[1], a[2], Number.isFinite(a[3]) ? a[3] : 1] : null;
}
if (typeof v !== 'string') return null;
const nums = v.replace(/[()\s]/g, '').split(',').map(Number);
if (nums.length < 3 || !nums.slice(0, 3).every(Number.isFinite)) return null;
return [nums[0], nums[1], nums[2], Number.isFinite(nums[3]) ? nums[3] : 1];
}
/**
* The official camera of a configBlackBoard param on a `width`×`height` viewport (see the header): position =
* OFFICIAL.base + param (+ the narrow-screen adjustment), pitch 30°, vertical FOV 40°, centred principal point.
* @param {string|number[]} param "(x,y,z,w)" or [x,y,z]
* @param {{ width: number, height: number }} viewport
*/
export function officialCamera(param, viewport, opts) {
const o = opts && typeof opts === 'object' ? opts : {};
const p = parseCameraParam(param) || [0, 0, 0, 1];
const W = Math.max(1, finite(viewport?.width, 1920)), H = Math.max(1, finite(viewport?.height, 1080));
const pitch = finite(o.pitch, OFFICIAL.pitch), fovY = finite(o.fovY, OFFICIAL.fovY);
const base = Array.isArray(o.base) ? o.base : OFFICIAL.base;
// narrow screens (up to 4:3) back the camera off like the client; wider ones keep the 16:9 position
const ratio = H / W;
const k = clamp((9 / 16 - ratio) / (9 / 16 - 3 / 4), 0, 1);
const adj = OFFICIAL.aspectAdjust;
const ux = p[0] + base[0] + adj[0] * k, uy = p[1] + base[1] + adj[1] * k, uz = p[2] + base[2] + adj[2] * k;
const cx = ux + OFFICIAL.center[0], cy = uy + OFFICIAL.center[1], height = Math.max(0.5, -uz);
const t = clamp(pitch, 1, 80) * DEG;
const dist = height / Math.cos(t);
const focal = (H / 2) / Math.tan((clamp(fovY, 5, 150) * DEG) / 2);
return new Camera({ tx: cx, ty: cy + height * Math.tan(t), tz: 0, tilt: pitch, dist, scale: focal / dist, cx: W / 2, cy: H / 2 });
}
const sameRect = (a, b) => !!a && !!b && a.r0 === b.r0 && a.r1 === b.r1 && a.c0 === b.c0 && a.c1 === b.c1;
/**
* Preset camera for a field kind: 'prep' | 'normal' | 'unite' | 'boss' ('hidden') | 'bossPrep' | 'pen'.
* Official framing (configBlackBoard params, `opts.config` = stage.config overrides the defaults) unless the
* viewport is narrower than 4:3 (portrait), `opts.fit` is set, or `opts.rect` is a custom rect — then the rect is
* fitted into the padded viewport (`fitCamera`). `opts.side` 'L'|'R' picks the side's param (right boss half);
* `opts.half` frames only that half of a unite/boss field; `opts.shop === false` uses the shop-collapsed prep camera.
*/
export function presetCamera(kind, viewport, options) {
const opts = options && typeof options === 'object' ? options : {};
let k = kind === 'hidden' ? 'boss' : kind;
if (!CAMERA_PRESETS[k]) k = 'normal';
let rect = opts.rect ? normRect(opts.rect) : null;
// the Final Assault prep happens on the boss field: a 'prep' camera asked for the boss rows is the boss one
if (k === 'prep' && rect && rect.r1 <= 6) k = 'bossPrep';
const preset = CAMERA_PRESETS[k];
const side = opts.side === 'R' ? 'R' : 'L';
const W = Math.max(1, finite(viewport?.width, 1)), H = Math.max(1, finite(viewport?.height, 1));
const knownRect = !rect || sameRect(rect, preset.rect) || (k === 'prep' && rect.r0 >= 6 && rect.r1 <= 12 && rect.c1 <= 10)
|| (k === 'normal' && rect.r0 >= 6 && rect.r1 <= 13) || (k === 'unite' && rect.r0 >= 6 && rect.r1 <= 13)
|| (k === 'boss' && rect.r1 <= 6) || k === 'bossPrep' || k === 'pen';
const useOfficial = !opts.fit && knownRect && W / H >= 4 / 3 - 1e-6;
if (useOfficial) {
const table = opts.half && preset.half ? preset.half : opts.shop === false && preset.officialNoShop ? preset.officialNoShop : preset.official;
const key = table[side];
const cfg = opts.config && typeof opts.config === 'object' ? opts.config : null;
const param = (cfg && parseCameraParam(cfg[key])) || parseCameraParam(OFFICIAL_PARAMS[key]);
return officialCamera(param, { width: W, height: H }, opts);
}
if (!rect) {
rect = { ...preset.rect };
// the right-hand Final Assault player prepares on the right boss half (mirrored col c → 20 − c)
if (k === 'bossPrep' && side === 'R') rect = { ...rect, c0: 20 - preset.rect.c1, c1: 20 - preset.rect.c0 };
}
if (k === 'prep') rect = { ...rect, r0: Math.min(rect.r0, 7) };
if (opts.half && (k === 'boss' || k === 'unite')) {
const mid = Math.floor((rect.c0 + rect.c1) / 2);
rect = side === 'R' ? { ...rect, c0: mid } : { ...rect, c1: mid };
}
// a narrow rect (e.g. a solo boss half) keeps the normal optics; wide ones use the preset's wider lens
const wide = rect.c1 - rect.c0 >= 14;
const base = wide ? preset : CAMERA_PRESETS[k === 'prep' || k === 'bossPrep' ? 'prep' : 'normal'];
return fitCamera(rect, viewport, {
tilt: finite(opts.tilt, base.tilt), dist: finite(opts.dist, base.dist),
margin: finite(opts.margin, preset.margin), headroom: finite(opts.headroom, preset.headroom),
maxTilePx: opts.maxTilePx, minTilePx: opts.minTilePx,
});
}
/**
* Tile under a screen point, honouring raised tiles: planes are tried from the highest height down, and a hit
* counts only when the tile found there really has that height. Returns `{ row, col }` or null (off-grid).
* @param {Camera} cam
* @param {(row:number, col:number) => number} heightAt tile top height (0 for low / off-grid)
* @param {number[]} [levels] distinct heights to test (descending), default [heights present…, 0]
*/
export function pickTile(cam, sx, sy, heightAt, levels = [0], rows = 19, cols = 21) {
const p = { x: 0, y: 0 };
const hs = [...levels].sort((a, b) => b - a);
if (!hs.includes(0)) hs.push(0);
for (const h of hs) {
if (!cam.unproject(sx, sy, h, p)) continue;
const col = Math.round(p.x) + 0, row = Math.round(p.y) + 0; // + 0 turns -0 into 0
if (row < 0 || row >= rows || col < 0 || col >= cols) {
if (h === 0) return null;
continue;
}
const th = heightAt ? finite(heightAt(row, col), 0) : 0;
if (h === 0 || Math.abs(th - h) < 1e-3) return { row, col, x: p.x, y: p.y };
}
return null;
}
/** Screen-space quad (4 projected corners, counter-clockwise from near-left) of a tile top at height z. */
export function tileQuad(cam, row, col, z = 0, inset = 0) {
const a = 0.5 - inset;
return [
cam.project(col - a, row - a, z, { x: 0, y: 0, s: 0, depth: 0 }),
cam.project(col + a, row - a, z, { x: 0, y: 0, s: 0, depth: 0 }),
cam.project(col + a, row + a, z, { x: 0, y: 0, s: 0, depth: 0 }),
cam.project(col - a, row + a, z, { x: 0, y: 0, s: 0, depth: 0 }),
];
}
+361
View File
@@ -0,0 +1,361 @@
// render/spine.js — Spine battle chibi wrapper + animation state machine (research 07 §5.4–5.5, ASSETS.md Roles).
//
// SpineActor owns one PIXI.spine.Spine built from cached skeleton data (assets.spine LRU; the instance never
// owns the atlas, so destroying it never frees shared textures). Animation roles come from the manifest
// (`anims`: idle, deploy, attack{begin,loop,end}, attackDown, skill{begin,loop,end,idle}, die, move, stun).
//
// Driving (units.js calls these; the sim is authoritative, the actor only visualises):
// setBase('idle'|'move'|'stun') the resting state from the snapshot anim code
// attack(interval) one attack happened now (b.ev 'atk'): plays begin→loop, re-phases the loop
// so its OnAttack frame lands now, timeScale = loopDuration / interval
// setSkill(on) skill begin→loop while active (skill idle replaces idle), end on stop
// deploy() 'Start' once, then base
// die() die clip once (callers fade out afterwards)
// stunned (setBase('stun')) stun clip, or the current track frozen at timeScale 0
// update(dt) advances the skeleton (autoUpdate is off: one clock for everything)
// Attack mode lasts until ~1.4 attack intervals without a new attack, then the end clip (if any) and base.
const clampN = (v, lo, hi) => (v < lo ? lo : v > hi ? hi : v);
/** Longest wind-up compression (× the rhythm speed) when the look-ahead is shorter than the natural wind-up. */
export const MAX_WIND_SPEEDUP = 2.5;
/**
* Attack clip timing (pure). `loopDur` / `hit` are clip seconds (strike frame at `hit`), `interval` and `lead` game
* seconds. The clip plays at `ts = loopDur / interval` (one loop per attack); started `lead` before the attack it
* reaches the strike frame on time: from `start = hit − lead·ts` at `ts` when little of the wind-up is lost,
* otherwise from (nearly) the beginning at up to MAX_WIND_SPEEDUP × ts (`tsWind`).
* @returns {{ ts: number, tsWind: number, start: number }}
*/
export function windUpPlan(loopDur, hit, interval, lead) {
const ts = clampN(loopDur / Math.max(0.08, interval), 0.35, 4);
const L = Math.max(0, lead);
let start = Math.max(0, hit - L * ts), tsWind = ts;
if (start > hit * 0.15 && L > 0) {
tsWind = Math.min(ts * MAX_WIND_SPEEDUP, hit / L);
start = Math.max(0, hit - L * tsWind);
}
return { ts, tsWind, start };
}
/** Whether any skin of the skeleton data has a clipping attachment (pixi-spine AttachmentType.Clipping = 6). */
export function hasClipping(data) {
try {
for (const skin of data?.skins || []) {
const list = typeof skin.getAttachments === 'function' ? skin.getAttachments() : [];
for (const e of list) {
const a = e && e.attachment;
if (a && (a.type === 6 || a.constructor?.name === 'ClippingAttachment' || ('endSlot' in a && 'vertices' in a && !('uvs' in a)))) return true;
}
}
} catch { /* unknown runtime shape: assume none */ }
return false;
}
export class SpineActor {
/**
* @param {any} spineData PIXI.spine skeleton data
* @param {object} entry manifest Spine entry (anims, animations, hits, bounds, pma)
*/
constructor(spineData, entry) {
const P = globalThis.PIXI;
this.entry = entry;
this.roles = entry.anims || {};
this.durations = entry.animations || {};
this.spine = new P.spine.Spine(spineData);
this.spine.autoUpdate = false;
this.names = new Set((spineData.animations || []).map((a) => a.name));
/**
* Clipping attachments render as stencil masks (≈1.5 ms of GPU each per frame on tiled GPUs): such skeletons
* are drawn through the impostor atlas while clipping is on, and clipping is switched off (unclipped slots,
* visually negligible on battle chibis) when too many of them share a field (app.js budget).
*/
this.clipped = hasClipping(spineData);
this.clipOn = true;
if (this.clipped) {
const sp = this.spine;
const orig = typeof sp.createGraphics === 'function' ? sp.createGraphics.bind(sp) : null;
if (orig) sp.createGraphics = (slot, att) => { const g = orig(slot, att); if (!this.clipOn && slot.clippingContainer) { slot.clippingContainer.mask = null; g.renderable = false; } return g; };
}
try { this.spine.stateData.defaultMix = 0.12; } catch { /* ignore */ }
this.base = 'idle';
this.mode = 'base'; // base | attack | skillBegin | deploy | die | stun
this.skillOn = false;
this.attackUntil = 0;
this.clock = 0;
this.current = '';
this.frozen = false;
this.dead = false;
this.interval = 1;
this._play(this._idleName(), true);
}
/** Enable / disable the skeleton's clipping masks. */
setClipping(on) {
on = !!on;
if (!this.clipped || on === this.clipOn) return;
this.clipOn = on;
for (const slot of this.spine?.skeleton?.slots || []) {
if (!slot.clippingContainer) continue;
slot.clippingContainer.mask = on ? slot.currentGraphics || null : null;
// PIXI makes a released mask renderable again: the clip polygon must never draw as a white shape
if (slot.currentGraphics) slot.currentGraphics.renderable = false;
}
}
/**
* The equipped skill (DESIGN §16 loadout, UnitInfo.skillIndex): its own Spine clip when the model has one per skill
* index (`anims.skills`), else the primary skill's clip.
* @param {number|undefined} index 0-based skill index
*/
setSkillIndex(index) {
const anims = this.entry?.anims || {};
const clip = Number.isInteger(index) && anims.skills ? anims.skills[String(index)] : null;
this.roles = clip ? { ...anims, skill: clip } : anims;
}
has(name) { return !!name && this.names.has(name); }
dur(name) { const d = this.durations[name]; return typeof d === 'number' && d > 0 ? d : this._durFromData(name); }
_durFromData(name) {
try { const a = this.spine.spineData.findAnimation(name); return a && a.duration > 0 ? a.duration : 1; } catch { return 1; }
}
_idleName() {
const sk = this.roles.skill;
if (this.skillOn && sk && this.has(sk.idle)) return sk.idle;
return this.has(this.roles.idle) ? this.roles.idle : (this.has('Idle') ? 'Idle' : [...this.names][0]);
}
_baseName() {
if (this.base === 'move') {
const mv = this.roles.move;
if (mv && this.has(mv.loop)) return mv.loop;
}
return this._idleName();
}
_play(name, loop, { timeScale = 1, mix, track = 0, start = 0 } = {}) {
if (!this.has(name)) return false;
const st = this.spine.state;
const e = st.setAnimation(track, name, loop);
if (e) {
e.timeScale = timeScale;
if (mix != null) e.mixDuration = mix;
if (start) e.trackTime = start;
}
this.current = name;
return true;
}
_queue(name, loop, timeScale = 1) {
if (!this.has(name)) return false;
const e = this.spine.state.addAnimation(0, name, loop, 0);
if (e) e.timeScale = timeScale;
return true;
}
/** Resting state from the snapshot. */
setBase(base) {
if (this.dead) return;
const b = base === 'move' || base === 'stun' ? base : 'idle';
if (b === 'stun') { this._enterStun(); return; }
if (this.mode === 'stun') this._leaveStun();
if (b === this.base && this.mode !== 'stun') return;
this.base = b;
if (this.mode === 'base') this._play(this._baseName(), true);
}
_enterStun() {
if (this.mode === 'stun') return;
this.mode = 'stun';
const s = this.roles.stun;
if (s && this.has(s.loop)) {
if (this.has(s.begin)) { this._play(s.begin, false); this._queue(s.loop, true); }
else this._play(s.loop, true);
} else {
this.frozen = true;
}
}
_leaveStun() {
this.frozen = false;
this.mode = 'base';
this._play(this._baseName(), true);
}
/**
* An attack is due in `lead` game seconds (the renderer sees it ahead in the snapshot buffer): start the attack
* clip from its wind-up so the strike frame lands when the attack event is rendered. Returns true once started;
* false when it is still too early (call again next frame) or there is nothing to wind up.
*/
windUp(interval, lead) {
if (this.dead || this.mode === 'stun' || this.mode === 'die' || !(lead >= 0)) return false;
const clip = this._attackClip();
if (!clip) return false;
if (this.mode === 'attack' && this.current === clip.loop) return false; // in rhythm: attack() re-phases
const iv = clampN(Number.isFinite(interval) && interval > 0 ? interval : this.interval, 0.08, 8);
const loopDur = this.dur(clip.loop);
const hit = this._hitTime(clip.loop, loopDur);
const plan = windUpPlan(loopDur, hit, iv, lead);
if (!(hit > 0) || lead * plan.ts > hit + 1e-6) return false;
this.interval = iv;
this.mode = 'attack';
this.attackUntil = this.clock + lead + Math.max(0.45, iv * 1.4);
this._play(clip.loop, true, { timeScale: plan.tsWind, start: plan.start, mix: 0.06 });
this.windTs = plan.ts;
this.windUntil = this.clock + lead;
return true;
}
/** An attack happened now. `interval` = seconds between attacks (game time already scaled to real). */
attack(interval) {
if (this.dead || this.mode === 'stun' || this.mode === 'die') return;
this.interval = clampN(Number.isFinite(interval) && interval > 0 ? interval : this.interval, 0.08, 8);
const clip = this._attackClip();
if (!clip) return;
const loopDur = this.dur(clip.loop);
const ts = clampN(loopDur / this.interval, 0.35, 4);
const hit = this._hitTime(clip.loop, loopDur);
const wasAttacking = this.mode === 'attack' && this.current === clip.loop;
this.mode = 'attack';
this.attackUntil = this.clock + Math.max(0.45, this.interval * 1.4);
this.windUntil = null;
if (!wasAttacking) {
// not wound up (no look-ahead, e.g. a batch that arrived late): the sim already resolved the hit, so show
// the strike frame now
this._play(clip.loop, true, { timeScale: ts, start: hit, mix: 0.06 });
} else {
const e = this.spine.state.tracks[0];
if (e) {
e.timeScale = ts;
// re-phase gently so the strike frame lines up with this attack
const t = e.trackTime % loopDur;
let d = hit - t;
if (d > loopDur / 2) d -= loopDur; else if (d < -loopDur / 2) d += loopDur;
e.trackTime += d * 0.8;
}
}
}
_attackClip() {
const sk = this.roles.skill;
if (this.skillOn && sk && this.has(sk.loop) && sk.via !== 'attack' && !this._skillIsBuffOnly()) return sk;
const a = this.roles.attack;
if (a && this.has(a.loop)) return a;
return null;
}
// Skill loops that are pure stances (no OnAttack in the loop) are still valid attack visuals during a skill;
// only an idle-typed skill loop is treated as buff-only.
_skillIsBuffOnly() {
const sk = this.roles.skill;
return !!sk && sk.loop === this.roles.idle;
}
_hitTime(anim, dur) {
const hits = this.entry.hits && this.entry.hits[anim];
if (Array.isArray(hits) && hits.length && Number.isFinite(hits[0])) return clampN(hits[0], 0, dur);
return dur * 0.5;
}
/** Skill active flag changed. */
setSkill(on) {
on = !!on;
if (on === this.skillOn || this.dead) return;
this.skillOn = on;
const sk = this.roles.skill;
if (this.mode === 'stun' || this.mode === 'die') return;
if (on && sk) {
if (this.has(sk.begin)) {
this.mode = 'skillBegin';
this._play(sk.begin, false, { mix: 0.08 });
this.skillBeginUntil = this.clock + this.dur(sk.begin);
if (this.has(sk.loop)) this._queue(sk.loop, true);
} else if (this.mode === 'base') this._play(this._baseName(), true);
} else if (!on && sk) {
if (this.has(sk.end)) {
this.mode = 'skillEnd';
this._play(sk.end, false, { mix: 0.08 });
this.skillEndUntil = this.clock + this.dur(sk.end);
} else {
this.mode = 'base';
this._play(this._baseName(), true);
}
}
}
deploy() {
if (this.dead) return;
const d = this.roles.deploy;
if (this.has(d) && d !== this.roles.idle) {
this.mode = 'deploy';
this._play(d, false, { mix: 0 });
this.deployUntil = this.clock + this.dur(d);
}
}
/** Play the death clip; returns its duration (0 when there is none). */
die() {
if (this.dead) return 0;
this.dead = true;
this.frozen = false;
this.mode = 'die';
const d = this.roles.die || (this.has('Die') ? 'Die' : null);
if (d && this.has(d)) { this._play(d, false, { mix: 0.05 }); return this.dur(d); }
return 0;
}
/** Revive (redeploy after death). */
revive() {
this.dead = false;
this.mode = 'base';
this.skillOn = false;
this._play(this._baseName(), true);
}
update(dt) {
this.clock += dt;
if (this.windUntil != null && this.clock >= this.windUntil) {
// a compressed wind-up reached its strike frame: back to the rhythm speed (attack() also does it)
this.windUntil = null;
const e = this.spine.state.tracks[0];
if (e && this.mode === 'attack') e.timeScale = this.windTs;
}
switch (this.mode) {
case 'attack':
if (this.clock > this.attackUntil) {
this.mode = 'base';
const clip = this._attackClip() || this.roles.attack;
if (clip && this.has(clip.end)) { this._play(clip.end, false); this._queue(this._baseName(), true); }
else this._play(this._baseName(), true, { mix: 0.15 });
}
break;
case 'skillBegin':
if (this.clock >= this.skillBeginUntil) { this.mode = 'base'; if (!this.has(this.roles.skill?.loop)) this._play(this._baseName(), true); }
break;
case 'skillEnd':
if (this.clock >= this.skillEndUntil) { this.mode = 'base'; this._play(this._baseName(), true); }
break;
case 'deploy':
if (this.clock >= this.deployUntil) { this.mode = 'base'; this._play(this._baseName(), true); }
break;
default: break;
}
if (!this.frozen) {
try { this.spine.update(dt); } catch { /* a broken skeleton must not stop the frame */ }
}
}
/** Model height in skeleton units (setup-pose bounds, else a chibi default). */
get height() {
const b = this.entry.bounds;
if (b && Number.isFinite(b.height) && b.height > 20) return Math.min(b.height, 900);
return 380;
}
destroy() {
try { this.spine.destroy({ children: true, texture: false, baseTexture: false }); } catch { /* ignore */ }
this.spine = null;
}
}
+138
View File
@@ -0,0 +1,138 @@
// render/style.js — palette and visual tunables of the battlefield renderer (pure data).
// Colours follow research 07 §7 (tile palette, tuned by eye) and the UI design system (theme.css).
export const TILE_H = Object.freeze({
wall: 0.42, // 'h' high ground
forbid: 0.3, // '#' forbidden block (data: HIGH)
sep: 0.55, // 'X' hard separator wall (rows 6 / 13)
bench: 0.16, // 'a' / 'A' hand & temp slots (data: HIGH)
platform: 0.26, // active 射击台 / mound devices
});
/** Stage glyph → material + height class (data/stages.json legend, DATA.md §12). */
export const GLYPH = Object.freeze({
'#': { mat: 'forbid', h: 'forbid' },
X: { mat: 'sep', h: 'sep' },
r: { mat: 'road', h: null },
R: { mat: 'roadN', h: null },
f: { mat: 'floor', h: null },
p: { mat: 'preview', h: null },
h: { mat: 'wall', h: 'wall' },
b: { mat: 'fence', h: null },
a: { mat: 'hand', h: 'bench' },
A: { mat: 'temp', h: 'bench' },
S: { mat: 'start', h: null },
E: { mat: 'end', h: null },
I: { mat: 'telin', h: null },
O: { mat: 'telout', h: null },
m: { mat: 'mire', h: null },
g: { mat: 'smog', h: null },
d: { mat: 'deepsea', h: null },
i: { mat: 'infection', h: null },
});
/** tileKey (when a stage uses glyphs outside the legend) → glyph. */
export const TILEKEY_GLYPH = Object.freeze({
tile_forbidden: '#', tile_road: 'r', tile_floor: 'f', tile_wall: 'h', tile_fence_bound: 'b', tile_fence: 'b',
tile_achand: 'a', tile_start: 'S', tile_end: 'E', tile_telin: 'I', tile_telout: 'O', tile_mire: 'm',
tile_smog: 'g', tile_deepsea: 'd', tile_infection: 'i', tile_grass: 'r', tile_hole: '#',
});
export const COLORS = Object.freeze({
bgTop: '#0b0f0e',
bgMid: '#131a18',
bgBottom: '#07090a',
fog: [0.62, 0.68, 0.72],
mint: 0x4ed8af,
mintHi: 0x59f4ca,
gold: 0xffc600,
gateRed: 0xff3b30,
gateRedDeep: 0xd0453b,
objBlue: 0x39a7ff,
objBlueDeep: 0x2d7fd6,
legal: 0x3ce08c,
illegal: 0xff4040,
range: 0xff9c33,
rangeStand: 0x4ed8af,
hpAlly: 0x5fe07a,
hpAllyLow: 0xe8c547,
hpEnemy: 0xff4b3e,
hpBoss: 0xff2d55,
hpGhost: 0xfff0c8,
hpBack: 0x0c0f0e,
sp: 0x6fd3ff,
spReady: 0xffe066,
spActive: 0xffb347,
shield: 0xdfe8ff,
});
/** Chess tier accents (theme.css --tier-1…6) and rarity-ish frames for enemies. */
export const TIER_COLORS = Object.freeze([0x9aa5a0, 0x9aa5a0, 0x7fd37a, 0x52b6ff, 0xb98cff, 0xffc600, 0xff6b3d]);
export const ENEMY_FRAME = Object.freeze({ normal: 0xc84a3c, elite: 0xff7a33, boss: 0xff2d55 });
export const DMG_STYLE = Object.freeze({
phys: { font: 'sp-dmg-phys', fill: ['#fffbe8', '#ffb35c'], stroke: '#3b1400' },
arts: { font: 'sp-dmg-arts', fill: ['#fbe8ff', '#c77dff'], stroke: '#2a0b45' },
true: { font: 'sp-dmg-true', fill: ['#ffffff', '#e8e8e8'], stroke: '#2a2a2a' },
heal: { font: 'sp-dmg-heal', fill: ['#eafff0', '#62f08a'], stroke: '#07361a' },
elem: { font: 'sp-dmg-elem', fill: ['#fff4e0', '#ff7b3a'], stroke: '#3a1000' },
});
/** Map b.ev dmg types to a style key. */
export function dmgStyleKey(type) {
if (type === 'phys' || type === 'arts' || type === 'true' || type === 'heal') return type;
if (type === 'burn' || type === 'neural' || type === 'necrosis' || type === 'apoptosis' || type === 'erosion' || type === 'element') return 'elem';
return 'phys';
}
/** Hit spark colour per damage type. */
export const HIT_TINT = Object.freeze({ phys: 0xffd9a0, arts: 0xc77dff, true: 0xffffff, heal: 0x62f08a, elem: 0xff7b3a });
/** Projectile visuals per atk projKind (sim: none|arrow|bolt|bomb|lob|orb|drone|enemy|chain|chainHeal). */
export const PROJ = Object.freeze({
arrow: { speed: 14, tex: 'streak', tint: 0xfff2d0, len: 0.55, width: 0.09, trail: 0 },
bolt: { speed: 11, tex: 'orb', tint: 0xd49bff, len: 0.28, width: 0.28, trail: 0xa35cff },
orb: { speed: 11, tex: 'orb', tint: 0x9dffb8, len: 0.26, width: 0.26, trail: 0x4ee07a },
bomb: { speed: 8, tex: 'orb', tint: 0xffb066, len: 0.24, width: 0.24, trail: 0xff7a33, arc: 0.9 },
lob: { speed: 8, tex: 'orb', tint: 0xffc78a, len: 0.22, width: 0.22, trail: 0xffa04a, arc: 1.2 },
drone: { speed: 16, tex: 'orb', tint: 0x8fe6ff, len: 0.16, width: 0.16, trail: 0x57c9ff },
enemy: { speed: 12, tex: 'orb', tint: 0xff6a5a, len: 0.22, width: 0.22, trail: 0xff3b30 },
});
/** Status keys (b.ev 'status' + UF flags) → icon atlas key (render/textures.js) and colour. */
export const STATUS_ICON = Object.freeze({
stun: 'stun', freeze: 'freeze', cold: 'cold', stealth: 'stealth', shield: 'shield', fragile: 'fragile',
artsFragile: 'fragile', physFragile: 'fragile', elemFragile: 'fragile', sleep: 'sleep', invulnerable: 'invuln',
silence: 'silence', slow: 'slow', sluggish: 'slow', bind: 'bind', fear: 'fear', tremble: 'fear', weaken: 'weaken',
levitate: 'levitate', taunt: 'taunt', defDown: 'weaken', resDown: 'weaken', aspdDown: 'slow', disarm: 'silence',
burn: 'burn', burnBurst: 'burn', neural: 'neural', neuralBurst: 'neural', necrosis: 'necrosis', apoptosis: 'necrosis',
});
/** Keyword fallbacks for namespaced / content status keys ('ab:frost', 'reed2:scorch', 'skill:shotst_shred' …). */
const STATUS_GUESS = [
[/frost|chill|cold/i, 'cold'], [/freez/i, 'freeze'], [/scorch|burn|ignit|flame/i, 'burn'], [/stun|daze/i, 'stun'],
[/sleep|slumber/i, 'sleep'], [/silenc/i, 'silence'], [/slow|slugg|bind|root|snare/i, 'slow'], [/fear|trembl/i, 'fear'],
[/fragil|shred|expos|wanted|vulner|mark/i, 'fragile'], [/weak|down$/i, 'weaken'], [/shield|resist|guard|ward|barrier/i, 'shield'],
[/invul|immun/i, 'invuln'], [/stealth|camou|invis/i, 'stealth'], [/levit|float/i, 'levitate'], [/taunt/i, 'taunt'],
[/neural/i, 'neural'], [/necro|apopt|erosion/i, 'necrosis'],
];
/** Icon key (textures STATUS_KEYS) for a b.ev status key or flag name; null when it has no icon. */
export function statusIconKey(key) {
if (typeof key !== 'string' || !key) return null;
if (STATUS_ICON[key]) return STATUS_ICON[key];
const tail = key.includes(':') ? key.slice(key.lastIndexOf(':') + 1) : key;
if (STATUS_ICON[tail]) return STATUS_ICON[tail];
for (const [re, icon] of STATUS_GUESS) if (re.test(tail)) return icon;
return null;
}
export const UNIT = Object.freeze({
/** Spine skeleton units → world tiles (skeletons are ~360–450 units tall ⇒ ~1.2 tiles). */
modelScale: 1 / 320,
/** Fallback diamond size in tiles. */
diamond: 0.78,
headroom: 1.18, // tiles above the feet where bars sit (operators; enemies use their bounds)
barWidth: 0.64,
bossBarWidth: 2.2,
});
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+216
View File
@@ -0,0 +1,216 @@
// Band draft — BAND_DRAFT "2/2 选择策略" (research 06 §4.2, D1): left = draft order (avatar, name, state:
// … waiting / ⌛ 决策中 / chosen band ✓), current picker highlighted; centre = grid of every band allowed
// for the mode type (icon, name, LP); a band a teammate already picked carries the picker's avatar and is marked
// 队友已选 — it cannot be chosen again (research 09 §5, guidebook 策略与轮选; the server refuses it too); right =
// detail pane (icon, 初始生命值, name, effect name + rich description) with 跳过 (co-op, once) and 确认选择.
// A timeout assigns 「华法琳」, or — when a teammate already holds it — the first free strategy (timeoutBand; the tip
// under the order list names the one I would get). Solo: free pick, no timer.
import { useEffect, useMemo, useState } from '../../vendor/hooks.module.js';
import { html, Button, Icon, MicroLabel, useTicker, secondsLeft } from '../ui/components.js';
import { useGameData, BandIcon, RichText, PlayerAvatar, LpTower, Sprite } from '../ui/gameComponents.js';
import { StepHeader, ExitModal } from '../ui/matchChrome.js';
import { actions } from '../ui/gameActions.js';
import { normalizeDraft, sortedPlayers, phaseTotalSeconds } from '../ui/gameLogic.js';
import { useStore } from '../store.js';
import { audio } from '../audio.js';
const cx = (...p) => p.flat().filter(Boolean).join(' ');
/**
* Bands selectable in a mode (modeTypeList contains the mode's type), sorted by sortId.
* @param {any[]} bands
* @param {string|null} modeType 'SINGLE'|'MULTI'
*/
export function allowedBands(bands, modeType) {
const sid = (b) => (Number.isFinite(b.sortId) ? b.sortId : 99);
return (Array.isArray(bands) ? bands : [])
.filter((b) => b && (!modeType || !Array.isArray(b.modeTypeList) || b.modeTypeList.includes(modeType)))
.sort((a, b) => sid(a) - sid(b) || (a.bandId < b.bandId ? -1 : a.bandId > b.bandId ? 1 : 0));
}
/** The official default strategy of an automatic assignment (data/config.json bandDraft.timeoutBandId). */
export const DEFAULT_TIMEOUT_BAND = 'band_bldsk';
/**
* The strategy the server assigns me when my turn times out (server/match/Match.js defaultBand): the official default
* 「华法琳」 while no teammate holds it, else the first free strategy in draft order (sortId) — never one a teammate
* already picked (队友已选).
* @param {any[]} bands allowedBands(...) (sortId order)
* @param {Map<string, any>} taken teammateBands(...)
* @param {string} [defaultId]
* @returns {string|null}
*/
export function timeoutBand(bands, taken, defaultId = DEFAULT_TIMEOUT_BAND) {
const list = Array.isArray(bands) ? bands : [];
const has = (id) => !!(taken && typeof taken.has === 'function' && taken.has(id));
if (defaultId && !has(defaultId) && (!list.length || list.some((b) => b.bandId === defaultId))) return defaultId;
return list.find((b) => !has(b.bandId))?.bandId || defaultId || null;
}
/**
* Bands taken by teammates (队友已选): bandId → the picking players (never the viewer).
* @param {Map<string, string>} picks normalizeDraft(...).picks (playerId → bandId)
* @param {string} myId
*/
export function teammateBands(picks, myId) {
const out = new Map();
for (const [pid, bid] of picks instanceof Map ? picks : []) {
if (pid === myId || typeof bid !== 'string') continue;
if (!out.has(bid)) out.set(bid, []);
out.get(bid).push(pid);
}
return out;
}
/**
* The band the detail pane shows: the current one, else my pick, else the first band no teammate took. When it is my
* turn and the shown band was taken meanwhile (队友已选), the first free one instead (confirm would be disabled).
* @param {string|null} sel
* @param {{ bands: any[], taken: Map<string, any>, myPick: string|null, myTurn: boolean }} o
*/
export function draftSelection(sel, { bands, taken, myPick, myTurn }) {
if (!Array.isArray(bands) || !bands.length) return sel;
const free = (bands.find((b) => !taken.has(b.bandId)) || bands[0]).bandId;
if (!sel) return myPick || free;
if (!myPick && myTurn && taken.has(sel)) return free;
return sel;
}
/** BAND_DRAFT screen. */
export function BandDraftScreen() {
const pub = useStore((s) => s.match.public);
const priv = useStore((s) => s.match.private);
const myId = useStore((s) => s.me.playerId);
const roomSolo = useStore((s) => s.room?.mode === 'solo');
const gd = useGameData();
const [sel, setSel] = useState(null);
const [busy, setBusy] = useState(null);
const [exit, setExit] = useState(false);
const [skipped, setSkipped] = useState(false);
const mode = gd.config?.modes?.[pub?.modeId];
const solo = roomSolo || mode?.type === 'SINGLE' || String(pub?.modeId || '').includes('single');
const bands = useMemo(() => allowedBands(gd.list('bands'), mode?.type || (solo ? 'SINGLE' : 'MULTI')), [gd.ready, mode?.type, solo]);
const players = sortedPlayers(pub);
const draft = normalizeDraft(pub?.draft, players);
const myPick = draft.picks.get(myId) || priv?.bandId || null;
const myTurn = !myPick && (solo || draft.turnPid === myId);
const skipsLeft = draft.skipsLeft.has(myId) ? draft.skipsLeft.get(myId) : (skipped ? 0 : 1);
const canSkip = !solo && myTurn && skipsLeft > 0 && draft.order.length > 1;
const taken = solo ? new Map() : teammateBands(draft.picks, myId);
const pickers = new Map(); // bandId → players
for (const [pid, bid] of draft.picks) {
const p = players.find((x) => x.playerId === pid);
if (!pickers.has(bid)) pickers.set(bid, []);
pickers.get(bid).push(p || { playerId: pid, name: '?' });
}
// default selection: my pick, else the first band nobody else took; when my turn comes while the selected band has
// been taken by a teammate meanwhile (队友已选), move the selection to the first free one
const takenKey = [...taken.keys()].sort().join(',');
useEffect(() => {
const next = draftSelection(sel, { bands, taken, myPick, myTurn });
if (next !== sel) setSel(next);
}, [bands.length, myPick, myTurn, takenKey]);
// "your turn" cue
useEffect(() => { if (myTurn && !solo) audio.sfx('yourTurn'); }, [myTurn]);
// what a timeout gives me (the server never assigns a strategy a teammate holds — Match.js defaultBand)
const defaultId = gd.config?.bandDraft?.timeoutBandId || DEFAULT_TIMEOUT_BAND;
const autoId = myPick ? null : timeoutBand(bands, taken, defaultId);
const defaultName = gd.band(defaultId)?.name || '华法琳';
const autoName = (autoId && gd.band(autoId)?.name) || defaultName;
const band = sel ? gd.band(sel) : null;
const selTaken = !!band && taken.has(band.bandId);
const confirm = async () => {
if (!band || busy || !myTurn || selTaken) return;
setBusy('pick');
await actions.band(band.bandId);
setBusy(null);
};
const skip = async () => {
if (busy || !canSkip) return;
setBusy('skip');
if (await actions.bandSkip()) setSkipped(true);
setBusy(null);
};
const turnName = players.find((p) => p.playerId === draft.turnPid)?.name;
const turnDeadline = Number(pub?.draft?.turnDeadline) || 0;
useTicker(!solo && turnDeadline > 0 ? 250 : 0);
const turnSecs = !solo && turnDeadline > 0 ? secondsLeft(turnDeadline) : null;
return html`<div class="screen draft">
<div class="brief__bg" aria-hidden="true"></div>
<${StepHeader} step=${2} of=${2} title="选择策略" micro="STRATEGY // BAND CHECK" pub=${pub}
total=${phaseTotalSeconds(pub, gd.config)} onExit=${() => setExit(true)} />
<main class="draft__main">
<aside class="draft-order">
<h3 class="brief-h"><span>${solo ? '独立模拟' : '决策顺序'}</span><${MicroLabel}>${solo ? 'FREE PICK' : 'RANDOM ORDER'}</${MicroLabel}></h3>
${(solo ? players.filter((p) => p.playerId === myId) : draft.order.map((pid) => players.find((p) => p.playerId === pid)).filter(Boolean)).map((p, i) => {
const picked = draft.picks.get(p.playerId) || (p.playerId === myId ? myPick : p.bandId) || null;
const cur = !picked && (solo || draft.turnPid === p.playerId);
const pband = picked ? gd.band(picked) : null;
return html`<div key=${p.playerId} class=${cx('dorder', cur && 'is-cur', picked && 'is-done', p.playerId === myId && 'is-self')}>
${!solo ? html`<span class="dorder__idx num">${i + 1}</span>` : null}
<${PlayerAvatar} player=${p} self=${p.playerId === myId} />
<div class="dorder__text">
<b class="dorder__name">${p.name || '博士'}${p.isBot ? html`<span class="dorder__ai">AI</span>` : null}</b>
<span class="dorder__state">${picked ? html`<span class="t-mint">${pband?.name || '已选择'}</span>`
: cur ? html`<span class="t-gold"><${Icon} name="hourglass" />决策中${turnSecs != null ? html`<b class="num dorder__secs">${turnSecs}s</b>` : null}</span>`
: html`<span class="t-dim"><${Icon} name="dots" />等待中</span>`}</span>
</div>
<span class="dorder__box">
${picked ? html`<${BandIcon} bandId=${picked} size="sm" /><span class="dorder__check"><${Icon} name="check" /></span>`
: cur && p.playerId === myId ? html`<${Sprite} k="bandChoose/youturn_finger" class="dorder__finger" fallback=${html`<${Icon} name="chevronLeft" />`} />`
: null}
</span>
</div>`;
})}
${!solo ? html`<p class="draft-order__tip">联合模拟在选择策略时可以进行一次跳过;超时将自动选择「${autoName}」${autoId && autoId !== defaultId ? `(「${defaultName}」已被队友选择)` : ''}</p>` : null}
</aside>
<section class="draft-grid" role="listbox" aria-label="策略">
${bands.map((b) => {
const who = pickers.get(b.bandId) || [];
const isTaken = taken.has(b.bandId);
return html`<button key=${b.bandId} type="button" role="option" aria-selected=${sel === b.bandId ? 'true' : 'false'}
aria-disabled=${isTaken ? 'true' : 'false'} title=${isTaken ? '队友已选' : undefined}
class=${cx('dband', sel === b.bandId && 'is-sel', myPick === b.bandId && 'is-mine', isTaken && 'is-taken')} onClick=${() => { setSel(b.bandId); audio.sfx('tab', { volume: 0.5 }); }}>
<${BandIcon} bandId=${b.bandId} size="lg" />
<span class="dband__name">${b.name}</span>
<span class="dband__lp num"><i></i>${b.totalHp}</span>
${who.length ? html`<span class="dband__who">${who.slice(0, 4).map((p) => html`<${PlayerAvatar} key=${p.playerId} player=${p} size="sm" />`)}</span>` : null}
${isTaken ? html`<span class="dband__taken">队友已选</span>` : null}
</button>`;
})}
</section>
<aside class="draft-detail brackets">
${band ? html`
<div class="draft-detail__art">
<${BandIcon} bandId=${band.bandId} size="xl" />
</div>
<div class="draft-detail__hp"><span>初始生命值</span><${LpTower} value=${band.totalHp} size="lg" /></div>
<h2 class="draft-detail__name">${band.name}</h2>
<div class="draft-detail__eff">
<${MicroLabel} tone="mint">EFFECT</${MicroLabel}>
<b>${band.effectName || ''}</b>
<${RichText} as="p" text=${band.descRaw || band.desc} class="draft-detail__desc" />
</div>` : html`<p class="t-dim">选择一个策略查看详情</p>`}
<div class="draft-detail__actions">
${myPick ? html`<p class="draft-detail__status t-mint"><${Icon} name="check" />已选择「${gd.band(myPick)?.name || ''}」${!solo && !draft.done ? ',等待其他博士' : ''}</p>`
: selTaken ? html`<p class="draft-detail__status draft-detail__status--taken"><${Icon} name="close" />队友已选,请选择其他策略</p>`
: !myTurn ? html`<p class="draft-detail__status"><${Icon} name="hourglass" />${turnName ? `${turnName} 正在决策…` : '等待轮到你'}</p>` : null}
<div class="draft-detail__btns">
${!solo ? html`<${Button} variant="secondary" size="lg" icon="chevrons" disabled=${!canSkip} loading=${busy === 'skip'} onClick=${skip}
title=${skipsLeft > 0 ? '跳过本轮,稍后再选' : '跳过次数已用完'}>跳过${skipsLeft > 0 ? '' : '(已用)'}<//>` : null}
<${Button} variant="primary" size="lg" icon="check" disabled=${!myTurn || !band || selTaken} loading=${busy === 'pick'} onClick=${confirm}>${selTaken ? '队友已选' : '确认选择'}<//>
</div>
</div>
</aside>
</main>
<${ExitModal} open=${exit} onClose=${() => setExit(false)} solo=${solo} />
</div>`;
}
+136
View File
@@ -0,0 +1,136 @@
// Briefing — INFO_CHECK "1/2 确认本局信息" (research 06 §4.1, D1): enemy leader (silhouette, name,
// abilities), stage (+ its pool: 战场固定 / 战场随机(共N张)), 特训敌人 factions (icon, name, description), 核心盟约 / 附加盟约 rows of bond discs
// (disabled bonds greyed with the banned-member badge), 本局禁用干员 avatars, difficulty tag, the ready
// count x/N with person pips, the 准备就绪 button (g.infoReady) and the countdown.
import { useState } from '../../vendor/hooks.module.js';
import { html, Button, Icon, MicroLabel, BondDisc, Tooltip } from '../ui/components.js';
import { useGameData, Img, UnitThumb, RichText } from '../ui/gameComponents.js';
import { StepHeader, ExitModal } from '../ui/matchChrome.js';
import { LoadoutButton } from './loadout.js';
import { actions } from '../ui/gameActions.js';
import { factionTypes, bannedPerBond, sortedPlayers, phaseTotalSeconds, disabledBondSets, briefingBondTip } from '../ui/gameLogic.js';
import { bondIconUrl, enemyIconUrl, factionIconUrl } from '../ui/assetUrls.js';
import { useStore } from '../store.js';
import { data } from '../data.js';
const cx = (...p) => p.flat().filter(Boolean).join(' ');
function BondRow({ title, micro, bonds, sets, perBond }) {
const m = data.get('assets');
return html`<div class="brief-bonds">
<h3 class="brief-h"><span>${title}</span><${MicroLabel}>${micro}</${MicroLabel}></h3>
<div class="brief-bonds__row">
${bonds.map((b) => {
// greyed: the drawn set D (still activatable, roster incomplete) and the mode's static inactive bonds
const state = sets.off.has(b.bondId) ? 'off' : sets.drawn.has(b.bondId) ? 'drawn' : null;
const off = !!state;
const bannedN = perBond.get(b.bondId) || 0;
const tip = briefingBondTip(b.name, state, bannedN);
return html`<${Tooltip} key=${b.bondId} text=${tip}>
<div class=${cx('brief-bond', off && 'is-off', state === 'drawn' && 'is-incomplete', !off && bannedN > 0 && 'is-partial')}>
<${BondDisc} name=${b.name} icon=${bondIconUrl(m, b.bondId)} active=${!off} disabled=${off} tier=${off ? 0 : (b.thresholds?.length || 1)}
maxTier=${Math.max(1, b.thresholds?.length || 1)} size="md" />
${bannedN > 0 ? html`<span class="brief-bond__ban num"><${Icon} name="user" />${bannedN}</span>` : null}
</div>
<//>`;
})}
</div>
</div>`;
}
/** INFO_CHECK screen. */
export function BriefingScreen() {
const pub = useStore((s) => s.match.public);
const myId = useStore((s) => s.me.playerId);
const solo = useStore((s) => s.room?.mode === 'solo') || String(pub?.modeId || '').includes('single');
const gd = useGameData();
const [exit, setExit] = useState(false);
const [busy, setBusy] = useState(false);
if (!pub) return null;
const players = sortedPlayers(pub);
const me = players.find((p) => p.playerId === myId);
const readyN = players.filter((p) => p.ready).length;
const mode = gd.config?.modes?.[pub.modeId];
const sets = disabledBondSets(pub, mode?.inactiveBondIds);
const bonds = gd.list('bonds').sort((a, b) => (a.bondOrder ?? 0) - (b.bondOrder ?? 0) || (a.identifier ?? 0) - (b.identifier ?? 0));
const core = bonds.filter((b) => b.isCore);
const addon = bonds.filter((b) => !b.isCore);
const banned = (Array.isArray(pub.bannedChess) ? pub.bannedChess : []).filter((id) => gd.chess(id));
banned.sort((a, b) => (gd.chess(a)?.tier ?? 0) - (gd.chess(b)?.tier ?? 0));
const perBond = bannedPerBond(bonds, banned);
const boss = pub.bossId ? gd.boss(pub.bossId) : null;
const bossEnemy = boss ? gd.enemy(boss.enemyKey) : null;
const stage = pub.stageId ? gd.stage(pub.stageId) : null;
// the mode's battlefield pool (config.json modes[].stages): 标准 is always 战场#01, 险境+ draw one at random
const poolN = Array.isArray(mode?.stages) ? mode.stages.length : 0;
const pool = poolN > 1 ? `战场随机(共${poolN}张)` : poolN === 1 ? '战场固定' : '';
const types = factionTypes(pub.factions);
const factions = gd.factions?.types || {};
const m = gd.m;
const ready = async () => {
if (busy || me?.ready) return;
setBusy(true);
await actions.infoReady();
setBusy(false);
};
return html`<div class="screen brief">
<div class="brief__bg" aria-hidden="true"></div>
<${StepHeader} step=${1} of=${2} title="确认本局信息" micro="BRIEFING // INFO CHECK" pub=${pub}
total=${phaseTotalSeconds(pub, gd.config)} onExit=${() => setExit(true)} />
<main class="brief__main">
<section class="brief__left">
<div class="brief-boss brackets">
<${MicroLabel} tone="gold">ENEMY LEADER // 敌方领袖</${MicroLabel}>
<div class="brief-boss__art">
<div class="brief-boss__ring" aria-hidden="true"></div>
${bossEnemy ? html`<${Img} src=${enemyIconUrl(m, boss.enemyKey)} class="brief-boss__sil" fallback=${html`<span class="brief-boss__q">?</span>`} />`
: html`<span class="brief-boss__q">?</span>`}
</div>
<div class="brief-boss__text">
<h2 class="brief-boss__name">${boss?.name || bossEnemy?.name || '未知领袖'}</h2>
<span class="brief-boss__when">第 <b class="num">${mode?.bossRound ?? pub.lastRound ?? 14}</b> 回合 · 最终攻势</span>
${Array.isArray(boss?.abilities) && boss.abilities.length ? html`<ul class="brief-boss__abil">
${boss.abilities.slice(0, 3).map((a, i) => html`<li key=${i}><${RichText} text=${a} /></li>`)}
</ul>` : null}
</div>
</div>
<div class="brief-stage">
<span class="brief-stage__k"><${MicroLabel}>BATTLEFIELD</${MicroLabel}>${pool ? html`<span class="brief-stage__pool">${pool}</span>` : null}</span>
<span class="brief-stage__name"><${Icon} name="rook" />${stage?.name || pub.stageId || '—'}</span>
</div>
<div class="brief-factions">
<h3 class="brief-h"><span>特训敌人</span><${MicroLabel}>SPECIAL ENEMIES</${MicroLabel}></h3>
${types.length ? types.map((t) => html`<div key=${t} class="brief-faction">
<span class="brief-faction__icon"><${Img} src=${factionIconUrl(m, factions[t]?.icon)} /></span>
<span class="brief-faction__text"><b>${factions[t]?.name || t}</b><span>${factions[t]?.desc || ''}</span></span>
</div>`) : html`<p class="t-dim">本局没有特训敌人</p>`}
</div>
</section>
<section class="brief__right">
<${BondRow} title="核心盟约" micro="CORE BONDS" bonds=${core} sets=${sets} perBond=${perBond} />
<${BondRow} title="附加盟约" micro="ADD-ON BONDS" bonds=${addon} sets=${sets} perBond=${perBond} />
<p class="brief-legend"><span class="brief-legend__off"></span>灰色:部分盟约所含干员阵容不完整(仍可通过其他盟约的干员或装备激活)${sets.off.size ? ',或本模式禁用' : ''} · <span class="brief-legend__ban"><${Icon} name="user" /></span>该盟约中无法出现的干员数</p>
<div class="brief-banned">
<h3 class="brief-h"><span>本局禁用干员</span><${MicroLabel}>BANNED OPERATORS</${MicroLabel}><b class="num brief-banned__n">${banned.length}</b></h3>
${banned.length ? html`<div class="brief-banned__grid">
${banned.map((id) => html`<${UnitThumb} key=${id} kind="chess" id=${id} size="sm" dim=${true} />`)}
</div>` : html`<p class="t-dim">本局没有禁用干员</p>`}
</div>
</section>
</main>
<footer class="brief__foot">
<${LoadoutButton} from="briefing" size="lg" class="brief-loadout" />
<div class="brief-ready">
<span class="brief-ready__txt">已就绪 <b class="num">${readyN}</b><span class="num">/${players.length}</span></span>
<span class="brief-ready__pips">${players.map((p) => html`<i key=${p.playerId} class=${cx(p.ready && 'on', p.playerId === myId && 'me')} title=${p.name}><${Icon} name="user" /></i>`)}</span>
</div>
<${Button} variant="primary" size="xl" icon=${me?.ready ? 'check' : 'play'} active=${!!me?.ready} loading=${busy}
disabled=${!!me?.ready || !me} onClick=${ready}>${me?.ready ? '已就绪' : '准备就绪'}<//>
</footer>
<${ExitModal} open=${exit} onClose=${() => setExit(false)} solo=${solo} />
</div>`;
}
File diff suppressed because it is too large. Load diff
+434
View File
@@ -0,0 +1,434 @@
// 干员调配 (Operator loadout, DESIGN §16): choose the equipped skill of every chess and the module of its elite before a
// match (official rule: "开始游戏前无法调整干员的等级,但可调整其所携带的技能和模组"). A full-screen overlay opened
// from the lobby, the room and the briefing (INFO_CHECK) — `openLoadout(from)` / <LoadoutButton/>; <LoadoutHost/> is
// mounted once by main.js. Styles: css/screens/loadout.css (official preset sprites from the local-client art when
// present: operator_preset, image_skill_select_outline, skill_select_deco, icon_equip_non; the module type icons of
// groups.module — lettered tiles without them).
//
// Left: roster of the 112 visible chess (tier / class / bond filters, search, 仅看已调整) — each card shows the equipped
// skill (S1–S3) and, when changed, the elite's module badge. Right: the selected chess — skills (icon, name, 默认,
// SP recovery, 初始 / 消耗 SP, duration, description at 普通 Lv.4 or 精锐 Lv.7) and the elite's modules (不装备 / X / Y …
// with the stat bonus, the trait upgrade and the talent changes), 恢复默认; 全部恢复默认 in the top bar.
// The loadout lives in ui/loadoutSync.js (localStorage + room.loadout); the model is ui/loadoutModel.js.
// Keyboard: Esc closes, ←/→ move through the (filtered) roster when focus is not in the search field.
import { useEffect, useMemo, useRef, useState } from '../../vendor/hooks.module.js';
import { html, Icon, MicroLabel, Button, TierChip, TextField, Countdown, confirmDialog, hasDeadline } from '../ui/components.js';
import { Img, RichText, UnitThumb } from '../ui/gameComponents.js';
import { chessAvatarUrl, chessPortraitUrl, subProfIconUrl, bondIconUrl, moduleTypeIconUrl } from '../ui/assetUrls.js';
import { data, useData, localAsset } from '../data.js';
import { useStore } from '../store.js';
import { PHASE } from '../../../shared/constants.js';
import {
MODULE_NONE, PROF_ORDER, PROF_NAME, rosterOf, filterRoster, recordsOf, chessOptions, effectiveChoice, setChoice, resetChoice,
changedCount, skillLabel, moduleBadge, attrRows, skillTags,
} from '../ui/loadoutModel.js';
import { loadoutStore, openLoadout, closeLoadout, setEntries } from '../ui/loadoutSync.js';
export { openLoadout, closeLoadout };
const cx = (...p) => p.flat().filter(Boolean).join(' ');
const ROMAN = ['', 'I', 'II', 'III', 'IV', 'V', 'VI'];
/** Square profession glyph (manifest prof.large: black glyph on white, drawn as a white glyph by loadout.css). */
function profGlyphUrl(m, prof) {
const k = String(prof || '').toLowerCase();
const v = m && m.prof && m.prof.large ? m.prof.large[k] : null;
return typeof v === 'string' ? v : null;
}
/** Skill icon URL for a SkillRecord (assets manifest `skills[iconId]` / `skillsById`), null when absent. */
function skillIconOf(m, rec) {
const skills = m && typeof m === 'object' ? m.skills : null;
if (!skills || !rec) return null;
const id = rec.iconId || rec.skillId;
if (id && typeof skills[id] === 'string') return skills[id];
const alt = m.skillsById && rec.skillId ? m.skillsById[rec.skillId] : null;
return alt && typeof skills[alt] === 'string' ? skills[alt] : null;
}
/**
* Module icon URL: the official type icon from the local-client art (`local-assets.json` groups.module, matched
* case-insensitively by typeName), else manifest `modules[icon]` / `uniequip[icon]` when the asset pipeline provides one.
*/
function moduleIconOf(m, rec) {
if (!rec) return null;
const local = moduleTypeIconUrl(data.get('local'), rec.typeName || rec.type);
if (local) return local;
if (!m) return null;
for (const group of ['modules', 'uniequip', 'equip']) {
const g = m[group];
if (g && typeof g === 'object') {
const v = g[rec.icon] || g[rec.uniEquipId] || g[rec.typeIcon];
if (typeof v === 'string') return v;
}
}
return null;
}
/** Skill icon with the official selection outline; falls back to a lettered tile. */
function SkillIcon({ m, rec, index, on = false, size = 'md' }) {
const src = skillIconOf(m, rec);
return html`<span class=${cx('lo-sicon', `lo-sicon--${size}`, on && 'is-on')}>
<${Img} src=${src} fallback=${html`<span class="lo-sicon__glyph num">${skillLabel(index)}</span>`} />
${on && size !== 'xs' ? html`<${Img} src=${localAsset('ui/outer', 'image_skill_select_outline')} class="lo-sicon__outline" fallback=${html`<i class="lo-sicon__ring"></i>`} />` : null}
</span>`;
}
/** Module badge tile: the official type icon when the local-client art has it, else the type letter; ⊘ for 不装备. */
export function ModuleGlyph({ m, rec, id, size = 'md' }) {
const src = rec ? moduleIconOf(m, rec) : null;
if (id === MODULE_NONE || !rec) {
return html`<span class=${cx('lo-mglyph', 'lo-mglyph--none', `lo-mglyph--${size}`)}>
<${Img} src=${localAsset('ui/outer', 'icon_equip_non')} fallback=${html`<i class="lo-mglyph__slash"></i>`} />
</span>`;
}
return html`<span class=${cx('lo-mglyph', `lo-mglyph--${size}`)} data-type=${moduleBadge(rec)}>
<${Img} src=${src} fallback=${html`<b class="lo-mglyph__t num">${moduleBadge(rec)}</b>`} />
</span>`;
}
// ---- roster card ---------------------------------------------------------------------------------------------------
function RosterCard({ m, chess, golden, entries, selected, onPick }) {
const choice = effectiveChoice(entries, chess, golden);
const opt = chessOptions(chess, golden);
const skillRec = opt.skillOptions.find((s) => s.index === choice.skill)?.normal || chess.skill;
const modRec = golden && choice.module !== MODULE_NONE ? opt.moduleOptions.find((x) => x.id === choice.module)?.rec || null : null;
const modChanged = golden && choice.module !== opt.defaultModule;
return html`<button type="button" role="option" aria-selected=${selected ? 'true' : 'false'} data-chess=${chess.chessId}
class=${cx('lo-card', `lo-card--t${chess.tier}`, selected && 'is-sel', choice.changed && 'is-changed')} onClick=${() => onPick(chess.chessId)}
title=${`${chess.name} · ${skillRec?.name || ''}`}>
<span class="lo-card__art">
<${Img} src=${chessAvatarUrl(m, chess)} fallback=${html`<span class="lo-card__glyph">${[...(chess.name || '?')][0]}</span>`} />
</span>
<${TierChip} tier=${chess.tier} size="sm" class="lo-card__tier" />
${choice.changed ? html`<span class="lo-card__flag" aria-label="已调整"></span>` : null}
<span class="lo-card__name">${chess.name}</span>
<span class="lo-card__kit">
<span class=${cx('lo-card__sk', choice.skill !== opt.defaultSkill && 'is-alt')}>
<${SkillIcon} m=${m} rec=${skillRec} index=${choice.skill} size="xs" />
<b class="num">${skillLabel(choice.skill)}</b>
</span>
${golden ? html`<span class=${cx('lo-card__mod', modChanged && 'is-alt')} title=${modRec ? `${modRec.typeName} ${modRec.name}` : '不装备模组'}>
${choice.module === MODULE_NONE ? '—' : moduleBadge(modRec)}
</span>` : null}
</span>
</button>`;
}
// ---- detail --------------------------------------------------------------------------------------------------------------
function SkillOption({ m, opt, on, level, onPick }) {
const rec = level === 'elite' ? opt.elite || opt.normal : opt.normal || opt.elite;
const tags = skillTags(rec);
return html`<button type="button" role="radio" aria-checked=${on ? 'true' : 'false'} class=${cx('lo-skill', on && 'is-on')}
data-skill=${opt.index} onClick=${() => onPick(opt.index)}>
${on ? html`<span class="lo-skill__deco" aria-hidden="true"><${Img} src=${localAsset('ui/outer', 'skill_select_deco')} fallback=${html`<i></i>`} /></span>` : null}
<${SkillIcon} m=${m} rec=${rec} index=${opt.index} on=${on} size="md" />
<span class="lo-skill__body">
<span class="lo-skill__head">
<span class="lo-skill__slot num">${skillLabel(opt.index)}</span>
<b class="lo-skill__name">${rec?.name || '未知技能'}</b>
${opt.isDefault ? html`<span class="lo-badge lo-badge--def">默认</span>` : null}
${on ? html`<span class="lo-badge lo-badge--on"><${Icon} name="check" />已装备</span>` : null}
</span>
<span class="lo-skill__tags">
<span class=${cx('lo-sp', `lo-sp--${tags.spKind}`)}>${tags.sp}</span>
${tags.init != null ? html`<span class="lo-tag">初始 <b class="num">${tags.init}</b></span>` : null}
${tags.cost != null ? html`<span class="lo-tag">消耗 <b class="num">${tags.cost}</b></span>` : null}
${tags.duration ? html`<span class="lo-tag">持续 <b class="num">${tags.duration}</b></span>` : null}
${tags.charges ? html`<span class="lo-tag">充能 <b class="num">${tags.charges}</b></span>` : null}
</span>
<${RichText} as="span" class="lo-skill__desc" text=${rec?.descRaw || rec?.desc || ''} />
</span>
</button>`;
}
function ModuleInfo({ m, golden, opt }) {
if (!golden) return null;
const rec = opt.id === MODULE_NONE ? null : opt.rec;
if (!rec) {
const traitBase = golden.traitBase || null;
return html`<div class="lo-minfo lo-minfo--none">
<p class="lo-minfo__lead">不装备模组:精锐干员以基础属性、特性与天赋作战。</p>
${traitBase?.desc ? html`<div class="lo-minfo__row"><span class="lo-minfo__k">特性</span><${RichText} class="lo-minfo__v" text=${traitBase.descRaw || traitBase.desc} /></div>` : null}
</div>`;
}
const rows = attrRows(rec.attr);
const trait = rec.traitOverride;
const traitText = trait ? (trait.moduleDescRaw || trait.moduleDesc || trait.descRaw || trait.desc) : null;
const talents = (Array.isArray(rec.talentChanges) ? rec.talentChanges : []).filter((t) => t && (t.name || t.desc) && !t.hidden);
return html`<div class="lo-minfo">
<div class="lo-minfo__title"><${Img} src=${moduleIconOf(m, rec)} class="lo-minfo__icon" /><span class="lo-minfo__type num">${rec.typeName || ''}</span><b>${rec.name || rec.uniEquipId}</b>
${opt.isDefault ? html`<span class="lo-badge lo-badge--def">默认</span>` : null}</div>
<div class="lo-minfo__row">
<span class="lo-minfo__k">属性</span>
<span class="lo-minfo__v lo-attrs">${rows.length ? rows.map((r) => html`<span key=${r.key} class=${cx('lo-attr', r.positive ? 'is-up' : 'is-down')}>${r.label}<b class="num">${r.text}</b></span>`) : html`<span class="t-dim">无属性加成</span>`}</span>
</div>
${traitText ? html`<div class="lo-minfo__row"><span class="lo-minfo__k">特性</span><${RichText} class="lo-minfo__v" text=${traitText} /></div>` : null}
${talents.map((t, i) => html`<div key=${i} class="lo-minfo__row"><span class="lo-minfo__k">天赋</span>
<span class="lo-minfo__v">${t.name ? html`<b class="lo-minfo__tname">${t.name}</b>` : null}<${RichText} text=${t.descRaw || t.desc || ''} /></span></div>`)}
</div>`;
}
function Detail({ m, chess, golden, entries, onChange, onReset, locked }) {
const [level, setLevel] = useState('normal');
const bodyRef = useRef(null);
useEffect(() => { if (bodyRef.current) bodyRef.current.scrollTop = 0; }, [chess?.chessId]);
if (!chess) return html`<aside class="lo-detail lo-detail--empty"><p class="t-dim">没有符合条件的干员</p></aside>`;
const opt = chessOptions(chess, golden);
const choice = effectiveChoice(entries, chess, golden);
const modOpt = opt.moduleOptions.find((x) => x.id === choice.module) || null;
const lv = (c) => c?.status?.skillLevel ?? '—';
return html`<aside class="lo-detail" aria-label=${`${chess.name} 调配`}>
<div class="lo-dhead">
<div class=${cx('lo-dhead__art', `lo-dhead__art--t${chess.tier}`)}>
<${Img} src=${chessPortraitUrl(m, golden || chess)} fallback=${html`<${UnitThumb} kind="chess" id=${chess.chessId} size="lg" />`} />
</div>
<div class="lo-dhead__info">
<div class="lo-dhead__chips"><${TierChip} tier=${chess.tier} size="md" />
${choice.changed ? html`<span class="lo-badge lo-badge--changed">已调整</span>` : html`<span class="lo-badge lo-badge--plain">默认配置</span>`}</div>
<h2 class="lo-dhead__name">${chess.name}</h2>
<span class="lo-dhead__en">${chess.appellation || ''}</span>
<span class="lo-dhead__class">
<${Img} src=${profGlyphUrl(m, chess.profession)} class="lo-dhead__prof lo-profglyph" />${PROF_NAME[chess.profession] || ''}
<i class="lo-sep"></i><${Img} src=${subProfIconUrl(m, chess)} class="lo-dhead__prof" />${chess.subProfessionName || ''}
</span>
<span class="lo-dhead__bonds">${(chess.bonds || []).map((b) => html`<span key=${b} class="lo-bond">
<${Img} src=${bondIconUrl(m, b)} class="lo-bond__icon" fallback=${html`<i class="lo-bond__dot"></i>`} />${data.lookup('bonds', b)?.name || b}</span>`)}</span>
</div>
<${Button} variant="ghost" size="sm" icon="refresh" class="lo-dhead__reset" disabled=${!choice.changed} onClick=${onReset}>恢复默认<//>
</div>
<div class="lo-detail__body" ref=${bodyRef}>
<section class="lo-sec">
<header class="lo-sec__head">
<h3>技能<${MicroLabel}>SKILL<//></h3>
<div class="lo-seg" role="tablist" aria-label="技能等级">
<button type="button" role="tab" aria-selected=${level === 'normal' ? 'true' : 'false'} class=${cx(level === 'normal' && 'is-on')} onClick=${() => setLevel('normal')}>普通 <span class="num">Lv.${lv(chess)}</span></button>
<button type="button" role="tab" aria-selected=${level === 'elite' ? 'true' : 'false'} class=${cx(level === 'elite' && 'is-on')} disabled=${!golden} onClick=${() => setLevel('elite')}>精锐 <span class="num">Lv.${lv(golden)}</span></button>
</div>
</header>
<div class="lo-skills" role="radiogroup" aria-label="选择技能">
${opt.skillOptions.map((s) => html`<${SkillOption} key=${s.index} m=${m} opt=${s} level=${level} on=${s.index === choice.skill}
onPick=${(i) => onChange({ skill: i })} />`)}
</div>
</section>
${golden ? html`<section class="lo-sec lo-sec--mod">
<header class="lo-sec__head">
<h3>模组<${MicroLabel}>MODULE<//></h3>
<span class="lo-sec__note">仅精锐干员装备 · 模组等级 <b class="num">${golden.status?.equipLevel ?? 1}</b></span>
</header>
<div class="lo-mods" role="radiogroup" aria-label="选择模组">
${opt.moduleOptions.map((mo) => html`<button key=${mo.id} type="button" role="radio" aria-checked=${mo.id === choice.module ? 'true' : 'false'}
data-module=${mo.id} class=${cx('lo-mod', mo.id === choice.module && 'is-on', mo.id === MODULE_NONE && 'lo-mod--none')}
onClick=${() => onChange({ module: mo.id })}>
<${ModuleGlyph} m=${m} rec=${mo.rec} id=${mo.id} />
<span class="lo-mod__text">
<span class="lo-mod__type num">${mo.id === MODULE_NONE ? 'NONE' : mo.rec?.typeName || ''}</span>
<b class="lo-mod__name">${mo.id === MODULE_NONE ? '不装备' : mo.rec?.name || mo.id}</b>
</span>
${mo.isDefault ? html`<span class="lo-badge lo-badge--def lo-mod__def">默认</span>` : null}
</button>`)}
</div>
${modOpt ? html`<${ModuleInfo} m=${m} golden=${golden} opt=${modOpt} />` : null}
</section>` : null}
${locked ? html`<p class="lo-locknote"><${Icon} name="info" />本局的调配已锁定,修改将在下一局生效</p>` : null}
</div>
</aside>`;
}
// ---- filters -------------------------------------------------------------------------------------------------------------
function Filters({ m, filters, onFilters, bonds }) {
const set = (patch) => onFilters({ ...filters, ...patch });
return html`<div class="lo-filters">
<div class="lo-frow">
<div class="lo-chips" role="group" aria-label="阶级">
<button type="button" class=${cx('lo-chip', !filters.tier && 'is-on')} onClick=${() => set({ tier: null })}>全部</button>
${[1, 2, 3, 4, 5, 6].map((t) => html`<button key=${t} type="button" class=${cx('lo-chip', 'lo-chip--tier', `lo-chip--t${t}`, filters.tier === t && 'is-on')}
aria-pressed=${filters.tier === t ? 'true' : 'false'} title=${`${t}阶`} onClick=${() => set({ tier: filters.tier === t ? null : t })}><span class="num">${ROMAN[t]}</span></button>`)}
</div>
<${TextField} size="sm" icon="search" value=${filters.query} placeholder="搜索干员 / 职业 / 盟约" class="lo-search"
onInput=${(v) => set({ query: String(v).slice(0, 24) })} />
</div>
<div class="lo-frow">
<div class="lo-chips lo-chips--prof" role="group" aria-label="职业">
${PROF_ORDER.map((p) => html`<button key=${p} type="button" class=${cx('lo-chip', 'lo-chip--prof', filters.prof === p && 'is-on')}
aria-pressed=${filters.prof === p ? 'true' : 'false'} title=${PROF_NAME[p]} onClick=${() => set({ prof: filters.prof === p ? null : p })}>
<${Img} src=${profGlyphUrl(m, p)} class="lo-chip__icon lo-profglyph" fallback=${html`<span>${PROF_NAME[p][0]}</span>`} /><span class="lo-chip__lbl">${PROF_NAME[p]}</span></button>`)}
</div>
<label class="lo-select">
<span class="lo-select__k">盟约</span>
<select value=${filters.bond || ''} onChange=${(e) => set({ bond: e.currentTarget.value || null })} aria-label="按盟约筛选">
<option value="">全部盟约</option>
${bonds.map((b) => html`<option key=${b.bondId} value=${b.bondId}>${b.name}</option>`)}
</select>
</label>
<button type="button" class=${cx('lo-toggle', filters.changedOnly && 'is-on')} aria-pressed=${filters.changedOnly ? 'true' : 'false'}
onClick=${() => set({ changedOnly: !filters.changedOnly })}><i class="lo-toggle__box"><${Icon} name="check" /></i>仅看已调整</button>
</div>
</div>`;
}
// ---- screen -------------------------------------------------------------------------------------------------------------
const SYNC_TEXT = {
idle: ['', ''], pending: ['保存中…', 'is-busy'], sending: ['同步中…', 'is-busy'], synced: ['已同步', 'is-ok'],
locked: ['本局已锁定 · 下一局生效', 'is-warn'], error: ['同步失败', 'is-bad'],
};
/** The overlay screen. */
function LoadoutScreen({ st }) {
const ready = useData('chess', 'bonds', 'assets', 'local');
const phase = useStore((s) => s.match?.public?.phase || null);
const inMatch = useStore((s) => !!s.room?.inMatch);
// co-op briefing (INFO_CHECK, 25 s): the overlay covers the briefing's own countdown, so it shows the time left — the
// match locks the loadout when it runs out (review fix: edits were silently only for the next match)
const infoDeadline = useStore((s) => (s.match?.public?.phase === PHASE.INFO_CHECK ? s.match.public.deadline : 0));
const m = data.get('assets');
const getChess = (id) => data.lookup('chess', id);
const getBond = (id) => data.lookup('bonds', id);
const roster = useMemo(() => rosterOf(data.list('chess')), [ready]);
const bonds = useMemo(() => {
const used = new Set(roster.flatMap((c) => c.bonds || []));
return (data.list('bonds') || []).filter((b) => b && used.has(b.bondId))
.sort((a, b) => (b.isCore ? 1 : 0) - (a.isCore ? 1 : 0) || (a.bondOrder ?? 0) - (b.bondOrder ?? 0) || String(a.name).localeCompare(String(b.name), 'zh'));
}, [ready, roster]);
const list = filterRoster(roster, st.filters, st.entries, getChess, getBond);
const selId = st.sel && roster.some((c) => c.chessId === st.sel) ? st.sel : list[0]?.chessId || roster[0]?.chessId || null;
const { base, golden } = selId ? recordsOf(selId, getChess) : { base: null, golden: null };
const nChanged = changedCount(st.entries, getChess);
const locked = (inMatch && phase && phase !== PHASE.INFO_CHECK && phase !== PHASE.LOBBY) || st.sync === 'locked';
const gridRef = useRef(null);
const [narrowDetail, setNarrowDetail] = useState(false); // phones: the detail slides over the roster
const pick = (id) => { loadoutStore.set({ sel: id }); setNarrowDetail(true); };
const change = (patch) => { if (base) setEntries(setChoice(loadoutStore.get().entries, base, golden, patch)); };
const resetOne = () => { if (base) setEntries(resetChoice(loadoutStore.get().entries, base.chessId)); };
const resetAll = async () => {
if (!nChanged) return;
const ok = await confirmDialog({ title: '全部恢复默认', text: `将 ${nChanged} 名干员的技能与模组恢复为默认配置?`, okText: '恢复默认', danger: true });
if (ok) setEntries({});
};
// Esc closes; ←/→ browse the filtered roster (not while typing in the search field)
useEffect(() => {
const onKey = (e) => {
if (e.ctrlKey || e.metaKey || e.altKey) return;
if (document.querySelector('.modal')) return; // a confirm dialog handles its own keys
const typing = e.target && /^(INPUT|SELECT|TEXTAREA)$/.test(e.target.tagName);
if (e.key === 'Escape') { e.preventDefault(); e.stopImmediatePropagation(); closeLoadout(); return; }
if (typing) return;
if (e.key === 'ArrowRight' || e.key === 'ArrowLeft') {
const ids = filterRoster(rosterOf(data.list('chess')), loadoutStore.get().filters, loadoutStore.get().entries, getChess, getBond).map((c) => c.chessId);
if (!ids.length) return;
const cur = Math.max(0, ids.indexOf(loadoutStore.get().sel));
const next = ids[(cur + (e.key === 'ArrowRight' ? 1 : -1) + ids.length) % ids.length];
loadoutStore.set({ sel: next });
e.preventDefault();
}
// swallow single-key game shortcuts (R / F / D / Space …) while the overlay is open
if (e.key.length === 1 || e.key === ' ') { e.stopImmediatePropagation(); }
};
window.addEventListener('keydown', onKey, true);
return () => window.removeEventListener('keydown', onKey, true);
}, []);
// keep the selected card in view
useEffect(() => {
const el = gridRef.current?.querySelector(`[data-chess="${selId}"]`);
if (el && typeof el.scrollIntoView === 'function') el.scrollIntoView({ block: 'nearest' });
}, [selId]);
const [syncText, syncCls] = SYNC_TEXT[st.sync] || SYNC_TEXT.idle;
const fromText = st.from === 'briefing' ? '确认本局信息阶段结束前可调整本局配置' : '开始模拟前可调整干员携带的技能与模组,干员等级不可调整';
return html`<div class="lo" role="dialog" aria-modal="true" aria-label="干员调配">
<div class="lo__bg" aria-hidden="true"></div>
<header class="lo-top">
<div class="lo-top__left">
<${Button} variant="ghost" size="md" icon="chevronLeft" class="lo-back" onClick=${closeLoadout} aria-label="返回" title="返回 (Esc)">返回<//>
</div>
<div class="lo-top__center">
<${MicroLabel} tone="mint">OPERATOR LOADOUT<//>
<h1 class="lo-top__title"><${Img} src=${localAsset('ui/outer', 'operator_preset')} class="lo-top__icon" fallback=${html`<${Icon} name="edit" class="lo-top__icon" />`} />干员调配</h1>
</div>
<div class="lo-top__right">
${inMatch && hasDeadline(infoDeadline) ? html`<${Countdown} deadline=${infoDeadline} size="sm" gauge=${false} label="调配截止" class="lo-deadline" />` : null}
${syncText ? html`<span class=${cx('lo-sync', syncCls)} role="status">${syncText}</span>` : null}
<span class="lo-count">已调整 <b class="num">${nChanged}</b><span class="num t-dim">/${roster.length}</span></span>
<${Button} variant="secondary" size="sm" icon="refresh" disabled=${!nChanged} onClick=${resetAll}>全部恢复默认<//>
</div>
</header>
<p class=${cx('lo-note', locked && 'is-locked')}><${Icon} name="info" />${locked ? '本局的调配已锁定(确认本局信息后无法修改),修改将在下一局生效' : fromText}</p>
${!ready ? html`<div class="lo-loading"><span class="spinner"></span>正在读取干员数据…</div>` : html`<main class=${cx('lo-body', narrowDetail && 'is-detail')}>
<section class="lo-roster">
<${Filters} m=${m} filters=${st.filters} bonds=${bonds} onFilters=${(filters) => loadoutStore.set({ filters })} />
<div class="lo-grid" role="listbox" aria-label="干员列表" ref=${gridRef}>
${list.length ? list.map((c) => html`<${RosterCard} key=${c.chessId} m=${m} chess=${c} golden=${c.goldenId ? getChess(c.goldenId) : null}
entries=${st.entries} selected=${c.chessId === selId} onPick=${pick} />`) : html`<p class="lo-empty t-dim">没有符合条件的干员</p>`}
</div>
</section>
<div class="lo-detail-wrap">
<button type="button" class="lo-detail-back tapx" onClick=${() => setNarrowDetail(false)}><${Icon} name="chevronLeft" />干员列表</button>
<${Detail} m=${m} chess=${base} golden=${golden} entries=${st.entries} onChange=${change} onReset=${resetOne} locked=${locked} />
</div>
</main>`}
</div>`;
}
/**
* Whether the overlay must close because its context moved on: opened from the briefing and the match left INFO_CHECK
* (the strategy draft needs the player), or opened from the lobby / room and a match started (its briefing takes over).
* @param {{ open: boolean, from: string|null }} st @param {string|null} phase @param {boolean} inMatch @param {boolean} wasInMatch
*/
export function shouldAutoClose(st, phase, inMatch, wasInMatch) {
if (!st || !st.open) return false;
if (st.from === 'briefing') return !!phase && phase !== PHASE.INFO_CHECK;
return inMatch && !wasInMatch;
}
/** Mounted once (main.js): renders the overlay while open. */
export function LoadoutHost() {
const st = useStore((s) => s, Object.is, loadoutStore);
const phase = useStore((s) => s.match?.public?.phase || null);
const inMatch = useStore((s) => !!s.room?.inMatch);
const wasInMatch = useRef(inMatch);
useEffect(() => {
if (shouldAutoClose(st, phase, inMatch, wasInMatch.current)) closeLoadout();
wasInMatch.current = inMatch;
}, [phase, inMatch, st.open]);
useEffect(() => {
if (st.open) document.documentElement.classList.add('sp-loadout-open');
else document.documentElement.classList.remove('sp-loadout-open');
}, [st.open]);
if (!st.open) return null;
return html`<${LoadoutScreen} st=${st} />`;
}
/**
* The entry button's badge: the same number as the screen's 已调整 N (a stored entry of a chess the data no longer offers
* never applies) once chess.json is loaded; before that the stored entries (the badge alone must not trigger the 1.6 MB
* download). Review fix: it counted stale entries the screen does not.
* @param {Record<string, any>} entries @param {((id: string) => any) | null} getChess null while chess.json is not loaded
*/
export function badgeCount(entries, getChess) {
return getChess ? changedCount(entries, getChess) : Object.keys(entries || {}).length;
}
/**
* Entry button (lobby / room / briefing).
* @param {{ from: 'lobby'|'room'|'briefing', size?: string, variant?: string, class?: string, label?: string }} props
*/
export function LoadoutButton({ from, size = 'md', variant = 'secondary', class: cls, label = '干员调配' }) {
useData('local'); // the official preset icon (re-render once the local-art manifest arrives)
const entries = useStore((s) => s.entries, Object.is, loadoutStore);
const n = badgeCount(entries, data.status('chess') === 'ready' ? (id) => data.lookup('chess', id) : null);
return html`<button type="button" class=${cx('btn', `btn--${variant}`, `btn--${size}`, 'lo-entry', cls)} data-testid="loadout-open"
onClick=${() => openLoadout(from)} title="调整干员携带的技能与模组">
<${Img} src=${localAsset('ui/outer', 'operator_preset')} class="lo-entry__icon" fallback=${html`<${Icon} name="edit" class="btn__icon" />`} />
<span class="btn__label">${label}</span>
${n ? html`<span class="lo-entry__n num" aria-label=${`${n} 名干员已调整`}>${n}</span>` : null}
</button>`;
}
+324
View File
@@ -0,0 +1,324 @@
// Lobby screen: pick 独立模拟 / 同盟模拟 and a difficulty (标准/险境/绝境/终极), create a room,
// or join one with a 同盟密钥 (recent codes remembered). Shows connection status + ping.
//
// Difficulty descriptions come from data/config.json `modes[modeId]` when present, else from the
// official act2autochess `modeDataDict` texts embedded below (desc + effectDescList), so the
// screen is complete before data is generated. Rounds: solo 标准 = 9, everything else 14 (+R15
// hidden core on 险境+), per research 00-INDEX §2. Battlefield pool (`modes[].stages`): 标准 always
// plays 战场#01, 险境 draws one of 8, 绝境 / 终极 one of 7 (m01 excluded).
import { useEffect, useRef, useState } from '../../vendor/hooks.module.js';
import { DIFFICULTIES, DIFFICULTY_NAMES, DIFFICULTY_COLORS, ROOM_CODE_LEN, MAX_SEATS, modeIdFor } from '../../../shared/constants.js';
import { html, Button, Icon, MicroLabel, Panel, TextField, PingPill, AvatarFrame, Tooltip, Spinner, DifficultyIcon, doctorNo } from '../ui/components.js';
import { toast, toastError } from '../ui/toasts.js';
import { GuideButton } from '../ui/guide.js';
import { LoadoutButton } from './loadout.js';
import { net, identity } from '../net.js';
import { store, useStore, shallowEqual, loadPref, savePref } from '../store.js';
import { getConfig, getMode, getStage, useData } from '../data.js';
/** Official mode texts (activity_table act2autochess.modeDataDict), fallback when config.json is absent. */
export const MODE_TEXT = {
single: {
FUNNY: { code: 'AC-1', desc: '时长较短的模拟训练', effects: ['可以快速完成作战', '常规奖励'] },
NORMAL: { code: 'AC-2', desc: '敌方攻击强度较高的模拟训练', effects: ['可使用盟约数增加', '大幅增加奖励'] },
HARD: { code: 'AC-3', desc: '敌方攻击强度极高的模拟训练', effects: ['作战环境困难', '出现更加危险的敌人'] },
ABYSS: { code: 'AC-4', desc: '敌方攻击强度到达极限的模拟训练', effects: ['作战环境无比困难', '出现极度危险的敌人'] },
},
multi: {
FUNNY: { code: 'AC-1', desc: '敌方攻击强度较低的模拟训练', effects: ['作战环境较为温和', '常规奖励'] },
NORMAL: { code: 'AC-2', desc: '敌方攻击强度较高的模拟训练', effects: ['可使用盟约数增加', '大幅增加奖励'] },
HARD: { code: 'AC-3', desc: '敌方攻击强度极高的模拟训练', effects: ['作战环境困难', '出现更加危险的敌人'] },
ABYSS: { code: 'AC-4', desc: '敌方攻击强度到达极限的模拟训练', effects: ['作战环境无比困难', '出现极度危险的敌人'] },
},
};
/** Battlefield pool per difficulty when config.json is absent (the modes' `stages` lists; same for solo and co-op). */
export const STAGE_POOL = { FUNNY: ['act1autochess_m01'], NORMAL: 8, HARD: 7, ABYSS: 7 };
/**
* Display name of a stage: stages.json when it is loaded, else derived from the id (act1 m0N → 战场#0N, act2 m0N → 战场#0(N+4)).
* @param {string} id e.g. 'act1autochess_m01'
*/
export function stageLabel(id) {
const rec = getStage(id);
if (rec && typeof rec.name === 'string' && rec.name) return rec.name.split(/\s+/)[0];
const m = String(id || '').match(/^act(\d)autochess_m(\d+)$/);
return m ? `战场#${String(Number(m[2]) + (m[1] === '2' ? 4 : 0)).padStart(2, '0')}` : '';
}
/**
* The battlefield note of a difficulty (official wording): a single-stage pool is fixed ("战场固定为 战场#01"), a larger
* one is drawn at random ("战场随机(共8张)").
* @param {string[] | number | null | undefined} stages the mode's `stages` list (or a count)
* @returns {string} '' when unknown
*/
export function stageNote(stages) {
if (Array.isArray(stages)) {
const ids = stages.filter((s) => typeof s === 'string' && s);
if (ids.length === 1) { const name = stageLabel(ids[0]); return name ? `战场固定为 ${name}` : '战场固定'; }
return ids.length > 1 ? `战场随机(共${ids.length}张)` : '';
}
return Number.isInteger(stages) && stages > 1 ? `战场随机(共${stages}张)` : '';
}
const MODE_CARDS = [
{
id: 'solo', name: '独立模拟', en: 'SOLO SIMULATION', icon: 'user',
desc: '独自调配资金与干员,以自己的节奏完成整场模拟。',
points: ['1 名博士', '休整期与机变阶段不限时'],
},
{
id: 'coop', name: '同盟模拟', en: 'ALLIANCE SIMULATION', icon: 'users',
desc: `与至多 ${MAX_SEATS - 1} 名博士组成同盟,共享干员池,联防协作抵御敌潮。`,
points: [`1–${MAX_SEATS} 名博士 · 可由 AI 队友补位`, '联防阶段 · 最终攻势合并生命值'],
},
];
/**
* Text for a difficulty card, preferring data/config.json.
* @param {'solo'|'coop'} roomMode
* @param {string} difficulty
* @returns {{ code: string, desc: string, effects: string[], rounds: number, hidden: boolean, stageNote: string }}
*/
export function difficultyInfo(roomMode, difficulty) {
const fallback = MODE_TEXT[roomMode === 'solo' ? 'single' : 'multi'][difficulty] || { code: '', desc: '', effects: [] };
// modeIdFor() lower-cases the difficulty: never call it with a value the server did not validate.
const m = DIFFICULTIES.includes(difficulty) ? getMode(modeIdFor(roomMode, difficulty)) : null;
const effects = Array.isArray(m?.effectDescList)
? m.effectDescList.map((e) => String(e).replace(/^[·•\s]+/, '')).filter(Boolean)
: fallback.effects;
const rounds = Number.isFinite(m?.lastRound) ? m.lastRound : roomMode === 'solo' && difficulty === 'FUNNY' ? 9 : 14;
return {
code: typeof m?.code === 'string' ? m.code : fallback.code,
desc: typeof m?.desc === 'string' ? m.desc : fallback.desc,
effects,
rounds,
hidden: difficulty !== 'FUNNY',
stageNote: stageNote(Array.isArray(m?.stages) && m.stages.length ? m.stages : STAGE_POOL[difficulty]),
};
}
const CODE_RE = new RegExp(`^[A-Z0-9]{${ROOM_CODE_LEN}}$`);
/**
* Normalise user input into a room code: accepts a pasted invite link (`…?room=ABCD`), keeps
* upper-cased alphanumerics and clamps to the code length.
* @param {string} v
* @returns {string}
*/
export function normalizeCode(v) {
let s = String(v ?? '');
const m = s.match(/[?&]room=([A-Za-z0-9]+)/);
if (m) s = m[1];
return s.toUpperCase().replace(/[^A-Z0-9]/g, '').slice(0, ROOM_CODE_LEN);
}
/**
* Room code from a deep link query string (`?room=CODE`), or null when absent/malformed.
* Accepts ROOM_CODE_LEN..ROOM_CODE_LEN+2 alphanumerics (the protocol's join limit).
* @param {string} search e.g. location.search
* @returns {string|null}
*/
export function parseRoomParam(search) {
try {
const raw = new URLSearchParams(search || '').get('room');
if (!raw) return null;
const code = raw.trim().toUpperCase();
if (!/^[A-Z0-9]+$/.test(code)) return null;
return code.length >= ROOM_CODE_LEN && code.length <= ROOM_CODE_LEN + 2 ? code : null;
} catch {
return null;
}
}
/** Recently joined/created co-op room codes (most recent first). */
export function recentRooms() {
const list = loadPref('recentRooms', []);
return Array.isArray(list) ? list.filter((c) => typeof c === 'string' && CODE_RE.test(c)).slice(0, 4) : [];
}
/** @param {string} code */
export function rememberRoom(code) {
if (!CODE_RE.test(code)) return;
savePref('recentRooms', [code, ...recentRooms().filter((c) => c !== code)].slice(0, 4));
}
const FALLBACK_TIPS = [
'联合模拟在选择策略时可以进行一次跳过',
'调度中心即使冻结,依然可以主动刷新',
'两件同名装备可以合成一件更强力的装备',
'只有达成完美作战的队友可以进行联防',
];
const TIP_ROTATE_MS = 5000; // matchingTipRotateInterval
/** Rotating tactical tips (config.json `tips`, weighted list of { tip, weight }). */
function TipsPanel() {
const cfg = getConfig();
const tips = Array.isArray(cfg?.tips)
? cfg.tips.map((t) => (typeof t === 'string' ? t : t?.tip)).filter((t) => typeof t === 'string' && t)
: FALLBACK_TIPS;
const list = tips.length ? tips : FALLBACK_TIPS;
const [idx, setIdx] = useState(() => Math.floor(Math.random() * list.length));
useEffect(() => {
const id = setInterval(() => setIdx((i) => i + 1), TIP_ROTATE_MS);
return () => clearInterval(id);
}, []);
const i = ((idx % list.length) + list.length) % list.length;
return html`<div class="tips brackets">
<div class="tips__head">
<${Icon} name="info" />
<span>作战提示</span>
<${MicroLabel}>TACTICAL TIPS<//>
<span class="tips__idx num">${String(i + 1).padStart(2, '0')}<span class="t-dim">/${String(list.length).padStart(2, '0')}</span></span>
<button type="button" class="tips__nav" onClick=${() => setIdx(i - 1 + list.length)} aria-label="上一条"><${Icon} name="chevronLeft" /></button>
<button type="button" class="tips__nav" onClick=${() => setIdx(i + 1)} aria-label="下一条"><${Icon} name="chevronRight" /></button>
</div>
<p key=${i} class="tips__text">${list[i]}</p>
</div>`;
}
function ModeCard({ card, selected, onSelect }) {
return html`<button type="button" class=${`mode-card brackets${selected ? ' is-selected' : ''}`} onClick=${() => onSelect(card.id)}
aria-pressed=${selected ? 'true' : 'false'}>
<span class="mode-card__bg" aria-hidden="true"></span>
<span class="mode-card__icon"><${Icon} name=${card.icon} /></span>
<span class="mode-card__text">
<${MicroLabel} tone=${selected ? 'mint' : undefined}>${card.en}<//>
<span class="mode-card__name">${card.name}</span>
<span class="mode-card__desc">${card.desc}</span>
<span class="mode-card__points">${card.points.map((p) => html`<span key=${p}>${p}</span>`)}</span>
</span>
<span class="mode-card__check" aria-hidden="true"><${Icon} name="check" />已选定</span>
</button>`;
}
function DifficultyCard({ roomMode, difficulty, selected, onSelect }) {
const info = difficultyInfo(roomMode, difficulty);
return html`<button type="button" class=${`diff-card${selected ? ' is-selected' : ''}`}
style=${`--d-color:${DIFFICULTY_COLORS[difficulty]}`} onClick=${() => onSelect(difficulty)} aria-pressed=${selected ? 'true' : 'false'}>
<span class="diff-card__bar" aria-hidden="true"></span>
<span class="diff-card__head">
<${DifficultyIcon} difficulty=${difficulty} class="diff-card__glyph" />
<span class="diff-card__name">${DIFFICULTY_NAMES[difficulty]}</span>
<span class="diff-card__code num">${info.code}</span>
<span class="diff-card__meta">
<span class="num">${info.rounds}</span> 回合${info.hidden ? html`<span class="diff-card__hidden">+ 隐秘核心</span>` : null}
</span>
</span>
<span class="diff-card__desc">${info.desc}</span>
<span class="diff-card__effects">${info.effects.map((e) => html`<span key=${e}>${e}</span>`)}${info.stageNote ? html`<span key="stage" class="diff-card__stage"><${Icon} name="rook" />${info.stageNote}</span>` : null}</span>
<span class="diff-card__check" aria-hidden="true"><${Icon} name="check" /><span>已选定</span></span>
</button>`;
}
/** Lobby screen component. */
export function LobbyScreen() {
const me = useStore((s) => s.me, shallowEqual);
const conn = useStore((s) => s.connection, shallowEqual);
useData('config');
const [roomMode, setRoomMode] = useState(() => (loadPref('lobby.mode', 'coop') === 'solo' ? 'solo' : 'coop'));
const [difficulty, setDifficulty] = useState(() => {
const d = loadPref('lobby.difficulty', 'FUNNY');
return DIFFICULTIES.includes(d) ? d : 'FUNNY';
});
const [code, setCode] = useState('');
const [busy, setBusy] = useState(null);
const [recent] = useState(recentRooms);
const alive = useRef(true);
const inFlight = useRef(false); // synchronous guard against double clicks (state updates are async)
useEffect(() => () => { alive.current = false; }, []);
const online = conn.status === 'online';
const codeOk = CODE_RE.test(code);
const pickMode = (m) => { setRoomMode(m); savePref('lobby.mode', m); };
const pickDifficulty = (d) => { setDifficulty(d); savePref('lobby.difficulty', d); };
const run = async (kind, fn) => {
if (inFlight.current) return;
if (!online) { toast('尚未连接到服务器,请稍候', 'warn'); return; }
inFlight.current = true;
setBusy(kind);
try { await fn(); } catch (err) { toastError(err); } finally {
inFlight.current = false;
if (alive.current) setBusy(null);
}
};
const create = () => run('create', () => net.request('room.create', { mode: roomMode, difficulty }));
const join = (c = code) => {
const k = normalizeCode(c);
if (!CODE_RE.test(k)) { toast(`同盟密钥为 ${ROOM_CODE_LEN} 位字母或数字`, 'warn'); return; }
run('join', () => net.request('room.join', { code: k }));
};
const backToTitle = () => {
identity.setEntered(false);
store.set((s) => ({ session: { ...s.session, entered: false } }));
};
return html`<div class="screen lobby-screen">
<header class="topbar">
<div class="topbar__left">
<${Button} variant="ghost" size="sm" icon="chevronLeft" onClick=${backToTitle} title="返回标题">返回<//>
<${PingPill} ms=${conn.ping} online=${online} />
</div>
<div class="topbar__center">
<${MicroLabel} tone="mint">SIMULATION PROTOCOL SELECT<//>
<h1 class="topbar__title">选择模拟协议</h1>
</div>
<div class="topbar__right">
<${GuideButton} class="lobby-guide" variant="secondary" />
<${LoadoutButton} from="lobby" size="sm" class="lobby-loadout" />
<div class="me-chip">
<${AvatarFrame} size="sm" name=${me.name} seat=${0} self=${true} />
<div class="me-chip__text">
<span class="me-chip__name">${me.name || '博士'}</span>
<${MicroLabel}>${me.playerId != null ? `DOCTOR #${doctorNo(me.playerId)}` : 'DOCTOR'}<//>
</div>
</div>
</div>
</header>
<div class="lobby-body screen__scroll">
<section class="lobby-left">
<div class="section-label"><span class="section-label__idx num">01</span>模拟方式<${MicroLabel}>MODE<//></div>
<div class="mode-cards">
${MODE_CARDS.map((c) => html`<${ModeCard} key=${c.id} card=${c} selected=${roomMode === c.id} onSelect=${pickMode} />`)}
</div>
<div class="section-label"><span class="section-label__idx num">03</span>加入同盟<${MicroLabel}>JOIN WITH ALLIANCE KEY<//></div>
<${Panel} class="join-panel" tone="amber">
<div class="join-row">
<${TextField} size="code" icon="key" value=${code} placeholder="输入同盟密钥 / 粘贴邀请链接"
transform=${normalizeCode} onInput=${(v) => setCode(normalizeCode(v))} onEnter=${() => join()} />
<${Button} variant="amber" size="lg" icon="users" loading=${busy === 'join'} disabled=${!codeOk || !online} onClick=${() => join()}>加入同盟<//>
</div>
<div class="join-foot">
${recent.length ? html`<span class="t-lo">最近的同盟</span>
${recent.map((c) => html`<button key=${c} type="button" class="code-chip num" onClick=${() => { setCode(c); join(c); }}>${c}</button>`)}`
: html`<span class="t-dim">向同伴索取 ${ROOM_CODE_LEN} 位同盟密钥,或直接打开邀请链接</span>`}
</div>
<//>
<${TipsPanel} />
</section>
<section class="lobby-right">
<div class="section-label"><span class="section-label__idx num">02</span>模拟难度<${MicroLabel}>DIFFICULTY<//></div>
<div class="diff-list">
${DIFFICULTIES.map((d) => html`<${DifficultyCard} key=${d} roomMode=${roomMode} difficulty=${d} selected=${difficulty === d} onSelect=${pickDifficulty} />`)}
</div>
<div class="create-box">
<${Tooltip} block=${true} text=${online ? null : '正在连接服务器…'}>
<${Button} variant="primary" size="xl" block=${true} iconRight="chevrons" loading=${busy === 'create'} disabled=${!online} onClick=${create}>
${roomMode === 'solo' ? '开始独立模拟' : '创建同盟'}
<//>
<//>
<div class="create-box__hint">
${online
? html`<span>${roomMode === 'solo' ? '创建后即可开始模拟' : '创建后可邀请好友或添加 AI 队友'}</span>`
: html`<${Spinner} size="sm" label="CONNECTING" />`}
</div>
</div>
</section>
</div>
</div>`;
}
+128
View File
@@ -0,0 +1,128 @@
// Result screen (research 06 §10.6): victory / defeat hero with rounds passed, boss medallions, and a
// card per player — band, final lineup avatars with elite marks, stats, title (评语) with its icon —
// plus 返回同盟.
//
// Expected m.result (free-form in DESIGN §8.2; fields read tolerantly, see gameLogic.normalizeResult):
// { victory, roundsPassed, lastRound?, hiddenCleared?, bossId?, hiddenBossId?, difficulty?, modeId?, durationMs?,
// players: [{ playerId, seat, name, isBot, alive, lp, bandId, roundsPassed?,
// title: 'comment_1' | { id, name? } | null,
// lineup: [{ id /* chessId, golden id if elite */, golden?, tier?, items?: [itemId] }],
// bonds?: [{ bondId, layers, active }],
// stats: { dmgDealt, kills, leaks, gold /* funds SPENT */, refreshes, merges, bossDamage?, itemsEquipped?,
// activatedLayers?, lpLost?, perfectRounds? } }] }
import { useEffect } from '../../vendor/hooks.module.js';
import { html, Button, Icon, MicroLabel, DifficultyTag } from '../ui/components.js';
import { useGameData, Img, UnitThumb, BandIcon, PlayerAvatar, BondGlyph, LpTower, Sprite } from '../ui/gameComponents.js';
import { normalizeResult, fmtNum } from '../ui/gameLogic.js';
import { enemyIconUrl, titleIconUrl, uiUrl } from '../ui/assetUrls.js';
import { store, useStore, emptyMatch } from '../store.js';
import { audio } from '../audio.js';
const cx = (...p) => p.flat().filter(Boolean).join(' ');
// The first seven present are shown (+ remaining LP = a 4×2 grid); the title (评语) stats come first.
// `gold` is the funds a player SPENT (server/match/PlayerState.js spend(); the 挥金如土 title stat).
const STAT_ROWS = [
['dmgDealt', '造成伤害'], ['kills', '击倒敌人'], ['bossDamage', '领袖伤害'], ['activatedLayers', '盟约层数'],
['merges', '晋升次数'], ['itemsEquipped', '配发装备'], ['gold', '消耗资金'], ['perfectRounds', '完美作战'],
['refreshes', '刷新次数'], ['leaks', '未击倒'], ['lpLost', '损失生命'],
];
function PlayerCard({ p, myId, titles, best, solo = false }) {
const gd = useGameData();
const titleRec = p.title ? titles.find((t) => t.id === p.title.id) || null : null;
const titleName = p.title?.name || titleRec?.name || null;
const stats = STAT_ROWS.filter(([k]) => Number.isFinite(p.stats[k])).slice(0, 7);
const band = p.bandId ? gd.band(p.bandId) : null;
const lineup = p.lineup.slice(0, 10);
const bonds = p.bonds.filter((b) => b.active || b.layers > 0).sort((a, b) => (b.layers || 0) - (a.layers || 0)).slice(0, 6);
return html`<article class=${cx('rcard', p.playerId === myId && 'is-self', p.alive === false && 'is-dead')}>
<header class="rcard__head">
<${PlayerAvatar} player=${p} self=${p.playerId === myId} />
<div class="rcard__who">
<b class="rcard__name">${p.name}${p.isBot ? html`<span class="rcard__ai">AI</span>` : null}${p.playerId === myId ? html`<span class="rcard__you">你</span>` : null}</b>
<span class="rcard__band">${band ? html`<${BandIcon} bandId=${band.bandId} size="xs" />${band.name}` : '—'}</span>
</div>
<div class="rcard__mid">
<div class="rcard__lineup">
${lineup.length ? lineup.map((u, i) => html`<${UnitThumb} key=${i} kind=${u.kind === 'token' ? 'token' : 'chess'} id=${u.id} golden=${!!u.golden} tier=${u.tier} size="sm" />`)
: html`<span class="rcard__noinfo">${p.alive === false ? '阵容已撤离' : 'NO INFO'}</span>`}
</div>
${bonds.length ? html`<div class="rcard__bonds">${bonds.map((b) => html`<span key=${b.bondId} class=${cx('rbond', b.active && 'is-on')} title=${gd.bond(b.bondId)?.name || b.bondId}>
<${BondGlyph} bondId=${b.bondId} /><b class="num">${b.layers ?? 0}</b></span>`)}</div>` : null}
</div>
<div class="rcard__round"><${MicroLabel}>ROUNDS</${MicroLabel}><b class="num">${p.roundsPassed}</b>
${p.trophies > 0 || p.reward > 0 ? html`<span class="rcard__gain">${p.trophies > 0 ? html`<span title="获得奖杯"><${Icon} name="crown" /><b class="num">+${p.trophies}</b></span>` : null}${p.reward > 0 ? html`<span title="卫戍认证"><${Icon} name="shield" /><b class="num">+${p.reward}</b></span>` : null}</span>` : null}
</div>
${titleName ? html`<div class="rcard__title" title=${titleRec?.text || p.title?.text || ''}>
<${Img} src=${titleIconUrl(gd.m, titleRec?.picId || p.title?.picId || String(p.title.id || '').replace('comment_', 'comment_icon_'))} class="rcard__ticon" fallback=${html`<${Icon} name="crown" />`} />
<span><${MicroLabel} tone="gold">评语</${MicroLabel}><b>${titleName}</b></span>
</div>` : html`<span class="rcard__title rcard__title--none" aria-hidden="true"></span>`}
</header>
<div class="rcard__stats">
${stats.map(([k, label]) => html`<div key=${k} class=${cx('rstat', best[k] === p.playerId && 'is-best')}><span>${label}</span><b class="num">${fmtNum(p.stats[k])}</b></div>`)}
${Number.isFinite(p.lp) ? html`<div class="rstat" title=${p.lpShared && !solo ? '最终攻势起全队共享目标生命值' : ''}><span>${p.lpShared && !solo ? '同盟剩余生命' : '剩余生命'}</span><${LpTower} value=${p.lp} size="sm" /></div>` : null}
</div>
</article>`;
}
/** RESULT screen. */
export function ResultScreen() {
const res = useStore((s) => s.match.result);
const pub = useStore((s) => s.match.public);
const myId = useStore((s) => s.me.playerId);
const hasRoom = useStore((s) => !!s.room);
const gd = useGameData();
const r = normalizeResult(res, pub);
const titles = Array.isArray(gd.config?.titles) ? gd.config.titles : [];
const best = {};
for (const [k] of STAT_ROWS) {
let top = null;
for (const p of r.players) if (Number.isFinite(p.stats[k]) && p.stats[k] > 0 && (top == null || p.stats[k] > top.v)) top = { v: p.stats[k], id: p.playerId };
if (top && r.players.length > 1) best[k] = top.id;
}
useEffect(() => { audio.sfx(r.victory ? 'settlementSucceed' : 'settlementFail'); }, []);
const back = () => store.set({ match: emptyMatch() });
const boss = r.bossId ? gd.boss(r.bossId) : null;
// the Hidden Core medal (and its corrupted leader) only once R15 was actually fought
const hidden = r.hiddenBossId && r.hiddenReached ? gd.boss(r.hiddenBossId) : null;
const bg = uiUrl(gd.m, r.victory ? 'settle/settlemen_teamshow_success' : 'settle/settlemen_teamshow_fail');
const mins = r.durationMs ? Math.round(r.durationMs / 60000) : null;
return html`<div class=${cx('screen', 'result', r.victory ? 'is-win' : 'is-lose')}>
<div class="result__bg" aria-hidden="true" style=${bg ? `--result-bg:url("${bg}")` : undefined}></div>
<div class="result__grid" aria-hidden="true"></div>
<main class="result__main">
<section class="result__hero">
<div class="result__logo"><${Sprite} k="entry/season_logo_settle" class="result__logoimg" fallback=${html`<${MicroLabel} tone="mint">STRONGHOLD PROTOCOL</${MicroLabel}>`} /></div>
${r.difficulty ? html`<${DifficultyTag} difficulty=${r.difficulty} size="lg" />` : null}
<h1 class="result__headline">${r.victory ? '模拟完成' : '模拟失败'}</h1>
<p class="result__sub">${r.victory ? '成功卫戍 · 敌方领袖已被击败' : '防线已被突破'}</p>
<div class="result__rounds">
<span class="result__rlabel">通过回合</span>
<b class="result__rnum num">${r.roundsPassed}</b>
${r.roundsPassed <= r.lastRound ? html`<span class="result__rof num">/${r.lastRound}</span>` : null}
</div>
<div class="result__medals">
${boss ? html`<div class=${cx('medal', r.victory && 'is-done')} title=${boss.name}>
<${Img} src=${enemyIconUrl(gd.m, boss.enemyKey)} /><span class="medal__check">${r.victory ? html`<${Icon} name="check" />` : html`<${Icon} name="close" />`}</span>
<span class="medal__label">敌方领袖</span></div>` : null}
${hidden ? html`<div class=${cx('medal', 'medal--hidden', r.hiddenCleared && 'is-done')} title=${hidden.name}>
<${Img} src=${enemyIconUrl(gd.m, hidden.enemyKey)} /><span class="medal__check">${r.hiddenCleared ? html`<${Icon} name="check" />` : html`<${Icon} name="close" />`}</span>
<span class="medal__label">隐秘核心</span></div>` : null}
</div>
${mins ? html`<p class="result__time t-lo">本局耗时 <b class="num">${mins}</b> 分钟</p>` : null}
<footer class="result__foot">
<${Button} variant="primary" size="xl" icon="chevronLeft" onClick=${back}>${hasRoom ? '返回同盟' : '返回大厅'}<//>
</footer>
</section>
<section class="result__players">
<h2 class="brief-h"><span>同盟成员</span><${MicroLabel}>ALLIANCE REPORT</${MicroLabel}></h2>
${r.players.length ? r.players.map((p) => html`<${PlayerCard} key=${p.playerId} p=${p} myId=${myId} titles=${titles} best=${best} solo=${r.players.length < 2} />`)
: html`<p class="t-dim">暂无结算数据</p>`}
</section>
</main>
</div>`;
}
+315
View File
@@ -0,0 +1,315 @@
// Room screen (同盟等待室): 4 seat cards (avatar frame, name, ready state, AI badge, host crown),
// host controls (difficulty picker, add/remove AI in co-op, start), invite code with copy code /
// copy link, ready toggle and leave.
//
// Start rule (server/lobby.js): room.start needs every *other* human connected and ready; the
// host's start counts as the host's ready. So 开始模拟 is enabled exactly then and sends room.start
// alone (no separate room.ready round trip that could leave the host "ready" after a failed start).
// Solo rooms show a single seat.
import { useEffect, useRef, useState } from '../../vendor/hooks.module.js';
import { DIFFICULTIES, DIFFICULTY_NAMES, DIFFICULTY_COLORS, MAX_SEATS } from '../../../shared/constants.js';
import {
html, Button, Icon, MicroLabel, PingPill, AvatarFrame, DifficultyTag, DifficultyIcon, Tooltip, confirmDialog, doctorNo,
} from '../ui/components.js';
import { toast, toastError } from '../ui/toasts.js';
import { GuideButton } from '../ui/guide.js';
import { LoadoutButton } from './loadout.js';
import { net } from '../net.js';
import { store, useStore, shallowEqual, emptyMatch } from '../store.js';
import { difficultyInfo } from './lobby.js';
/**
* Seats padded to the room's capacity (co-op 4, solo 1), each null or a seat record.
* @param {any} room room.state payload
* @returns {(null | {seat:number, playerId:any, name:string, isBot:boolean, ready:boolean, connected:boolean})[]}
*/
export function normalizeSeats(room) {
const cap = room?.mode === 'solo' ? 1 : MAX_SEATS;
const src = Array.isArray(room?.seats) ? room.seats : [];
const out = [];
for (let i = 0; i < cap; i++) {
const s = src[i];
out.push(s && typeof s === 'object' ? { ...s, seat: Number.isInteger(s.seat) ? s.seat : i } : null);
}
return out;
}
/**
* Derived room facts for the local player.
* @param {any} room
* @param {any} myId
*/
export function roomFacts(room, myId) {
const seats = normalizeSeats(room);
const occupied = seats.filter(Boolean);
const humans = occupied.filter((s) => !s.isBot);
const mine = occupied.find((s) => s.playerId === myId) || null;
const isHost = room?.hostId != null && room.hostId === myId;
const others = humans.filter((s) => s.playerId !== myId);
// The host never readies: starting the match is the host's ready (server rule), so the count
// treats the host as ready — "已就绪 0/1" next to "准许进入模拟" would contradict itself.
const isReady = (s) => !!s.ready || s.playerId === room?.hostId;
const readyHumans = humans.filter(isReady).length;
const othersReady = others.every((s) => s.ready && s.connected !== false);
return {
seats, occupied, humans, mine, isHost, readyHumans, isReady,
emptySeats: seats.filter((s) => !s).length,
canStart: isHost && othersReady && !!mine,
othersReady,
};
}
/** Invite link for a room code (current page URL with ?room=CODE). */
export function inviteLink(code) {
const loc = globalThis.location;
const base = loc ? `${loc.origin}${loc.pathname}` : '';
return `${base}?room=${encodeURIComponent(code)}`;
}
/**
* Copy text to the clipboard (async API with a textarea fallback for insecure contexts).
* @param {string} text
* @returns {Promise<boolean>}
*/
export async function copyText(text) {
try {
if (globalThis.navigator?.clipboard && globalThis.isSecureContext) {
await navigator.clipboard.writeText(text);
return true;
}
} catch { /* fall through */ }
try {
const ta = document.createElement('textarea');
ta.value = text;
ta.setAttribute('readonly', '');
// 16 px: iOS zooms into smaller focused fields; `readonly` keeps the keyboard away
ta.style.cssText = 'position:fixed;left:-9999px;top:0;opacity:0;font-size:16px';
document.body.appendChild(ta);
ta.select();
// iOS Safari ignores select() on a textarea: an explicit range is what it copies (LAN play over http has no
// navigator.clipboard, so this path is the one iPhones / iPads take)
try { ta.setSelectionRange(0, ta.value.length); } catch { /* ignore */ }
const ok = document.execCommand('copy');
ta.remove();
return ok;
} catch {
return false;
}
}
function SeatCard({ seat, index, room, facts, myId, busy, onAddBot, onRemoveBot }) {
const coop = room.mode !== 'solo';
if (!seat) {
const canAdd = coop && facts.isHost;
return html`<article class="seat seat--empty" style=${`--seat-i:${index}`}>
<header class="seat__head"><span class="seat__no num">P${index + 1}</span><${MicroLabel}>SEAT ${String(index + 1).padStart(2, '0')}<//></header>
<div class="seat__art seat__art--empty">
<div class="seat__radar" aria-hidden="true"></div>
<span class="seat__wait">等待博士加入</span>
<${MicroLabel}>AWAITING DOCTOR<//>
</div>
<footer class="seat__foot">
${canAdd
? html`<${Button} variant="secondary" size="sm" icon="robot" block=${true} loading=${busy === `add`} onClick=${onAddBot}>添加 AI 队友<//>`
: html`<span class="seat__state t-dim">空位</span>`}
</footer>
</article>`;
}
const isMe = seat.playerId === myId;
const isHostSeat = seat.playerId === room.hostId;
const offline = seat.connected === false && !seat.isBot;
// The host never needs to toggle ready: starting the match readies them (server rule).
const state = offline ? 'offline' : seat.ready || seat.isBot ? 'ready' : isHostSeat ? 'host' : 'waiting';
return html`<article class=${`seat brackets${isMe ? ' is-me' : ''}${isHostSeat ? ' is-host' : ''}${seat.isBot ? ' is-bot' : ''} is-${state}`}
style=${`--seat-i:${index}`}>
<header class="seat__head">
<span class="seat__no num">P${index + 1}</span>
<${MicroLabel}>SEAT ${String(index + 1).padStart(2, '0')}<//>
${isHostSeat ? html`<span class="seat__host"><${Icon} name="crown" />创建者</span>` : null}
</header>
<div class="seat__art">
<div class="seat__stripes" aria-hidden="true"></div>
<${AvatarFrame} size="xl" name=${seat.name} seat=${index} bot=${seat.isBot} self=${isMe} ready=${state === 'ready'} offline=${offline} />
${seat.isBot ? html`<span class="seat__bot-label"><${Icon} name="robot" />AI 队友</span>` : null}
</div>
<div class="seat__who">
<span class="seat__name">${seat.name || '博士'}</span>
${isMe ? html`<span class="seat__you">你</span>` : null}
</div>
<${MicroLabel}>${seat.isBot ? 'AUTONOMOUS UNIT' : `DOCTOR #${doctorNo(seat.playerId)}`}<//>
<footer class="seat__foot">
<span class=${`seat__state seat__state--${state}`}>
${state === 'ready' ? html`<${Icon} name="check" />已就绪`
: state === 'offline' ? html`<${Icon} name="wifiOff" />连接中断`
: state === 'host' ? html`<${Icon} name="crown" />待命中`
: html`<${Icon} name="hourglass" />准备中`}
</span>
${seat.isBot && facts.isHost ? html`<${Tooltip} text="移除该 AI 队友">
<${Button} variant="ghost" size="sm" square=${true} icon="close" loading=${busy === `rm${index}`} onClick=${() => onRemoveBot(index)} aria-label="移除 AI 队友" />
<//>` : null}
</footer>
</article>`;
}
function InviteBox({ code }) {
const copy = async (what) => {
const ok = await copyText(what === 'code' ? code : inviteLink(code));
if (ok) toast(what === 'code' ? `已复制同盟密钥 ${code}` : '已复制邀请链接', 'success');
else toast('复制失败,请手动复制', 'warn');
};
return html`<div class="invite brackets">
<div class="invite__label"><${Icon} name="key" /><span>同盟密钥</span><${MicroLabel}>ALLIANCE KEY<//></div>
<div class="invite__code num selectable" aria-label=${`同盟密钥 ${code}`}>${[...String(code)].map((ch, i) => html`<span key=${i}>${ch}</span>`)}</div>
<div class="invite__btns">
<${Button} size="sm" icon="copy" onClick=${() => copy('code')}>复制密钥<//>
<${Button} size="sm" icon="link" onClick=${() => copy('link')}>复制链接<//>
</div>
</div>`;
}
function DifficultyPicker({ room, isHost, busy, onPick }) {
if (!isHost) {
return html`<div class="dpick dpick--ro">
<${DifficultyTag} difficulty=${room.difficulty} size="lg" code=${difficultyInfo(room.mode, room.difficulty).code} />
<span class="t-dim">由创建者选择</span>
</div>`;
}
return html`<div class="dpick" role="radiogroup" aria-label="模拟难度">
${DIFFICULTIES.map((d) => html`<button key=${d} type="button" role="radio" aria-checked=${room.difficulty === d ? 'true' : 'false'}
class=${`dpick__opt${room.difficulty === d ? ' is-active' : ''}`} style=${`--d-color:${DIFFICULTY_COLORS[d]}`}
disabled=${!!busy} onClick=${() => room.difficulty !== d && onPick(d)}>
<${DifficultyIcon} difficulty=${d} />${DIFFICULTY_NAMES[d].replace('模拟', '')}
</button>`)}
</div>`;
}
/** Room screen component. */
export function RoomScreen() {
const room = useStore((s) => s.room);
const me = useStore((s) => s.me, shallowEqual);
const conn = useStore((s) => s.connection, shallowEqual);
const [busy, setBusy] = useState(null);
const alive = useRef(true);
const inFlight = useRef(false); // synchronous guard against double clicks (state updates are async)
useEffect(() => () => { alive.current = false; }, []);
if (!room) return null;
const online = conn.status === 'online';
const coop = room.mode !== 'solo';
const facts = roomFacts(room, me.playerId);
const myReady = !!facts.mine?.ready;
const info = difficultyInfo(room.mode, room.difficulty);
const run = async (kind, fn) => {
if (inFlight.current) return;
if (!online) { toast('连接中断,请稍候重试', 'warn'); return; }
inFlight.current = true;
setBusy(kind);
try { await fn(); } catch (err) { toastError(err); } finally {
inFlight.current = false;
if (alive.current) setBusy(null);
}
};
const toggleReady = () => run('ready', () => net.request('room.ready', { ready: !myReady }));
const start = () => run('start', () => net.request('room.start', {}));
const addBot = () => run('add', () => net.request('room.addBot', {}));
const removeBot = (seat) => run(`rm${seat}`, () => net.request('room.removeBot', { seat }));
const setDifficulty = (difficulty) => run('diff', () => net.request('room.setDifficulty', { difficulty }));
const leave = async () => {
if (inFlight.current) return;
const othersHere = facts.humans.some((s) => s.playerId !== me.playerId);
if (facts.isHost && othersHere) {
const ok = await confirmDialog({ title: '离开同盟', text: '你是同盟的创建者,离开后创建者身份将移交或同盟解散。确定离开吗?', okText: '离开', danger: true });
if (!ok) return;
}
inFlight.current = true;
setBusy('leave');
try {
await net.request('room.leave', {});
} catch (err) {
if (err?.code !== 'NOT_IN_ROOM') toastError(err);
} finally {
// Leaving locally is always safe: the server either confirmed or no longer has us in the room.
store.set({ room: null, match: emptyMatch() });
inFlight.current = false;
if (alive.current) setBusy(null);
}
};
const statusLine = !online
? html`<span class="t-orange"><${Icon} name="wifiOff" />连接中断,正在重连…</span>`
: !coop
? html`<span class="t-mint">*模拟协议已就绪,准许进入模拟</span>`
: facts.isHost
? facts.canStart
? html`<span class="t-mint">*同盟人数达标,准许进入模拟</span>`
: html`<span class="t-lo">等待所有博士准备就绪</span>`
: myReady
? html`<span class="t-mint">已就绪 · 等待创建者开始模拟</span>`
: html`<span class="t-lo">准备就绪后,创建者即可开始模拟</span>`;
return html`<div class="screen room-screen">
<header class="topbar">
<div class="topbar__left">
<${Tooltip} text="离开同盟" placement="bottom">
<${Button} variant="danger" size="lg" square=${true} icon="exit" loading=${busy === 'leave'} onClick=${leave} aria-label="离开同盟" />
<//>
<div class="room-ping">
<${PingPill} ms=${conn.ping} online=${online} />
<${MicroLabel}>当前延迟<//>
</div>
<${GuideButton} class="room-guide" variant="secondary" />
</div>
<div class="topbar__center">
<${MicroLabel} tone="mint">${coop ? 'ALLIANCE LOBBY' : 'SOLO SIMULATION'}<//>
<h1 class="topbar__title">${coop ? '同盟模拟' : '独立模拟'}<span class="topbar__sep"></span><${DifficultyTag} difficulty=${room.difficulty} size="lg" /></h1>
</div>
<div class="topbar__right">
${coop ? html`<${InviteBox} code=${room.code} />` : html`<div class="solo-note"><${MicroLabel}>SINGLE OPERATOR<//><span>仅限 1 名博士</span></div>`}
</div>
</header>
<main class=${`seats${coop ? '' : ' seats--solo'}`}>
${facts.seats.map((s, i) => html`<${SeatCard} key=${s ? `p${s.playerId}` : `e${i}`} seat=${s} index=${i} room=${room} facts=${facts}
myId=${me.playerId} busy=${busy} onAddBot=${addBot} onRemoveBot=${removeBot} />`)}
${coop ? null : html`<aside class="solo-brief brackets">
<${MicroLabel} tone="mint">BRIEFING<//>
<h2>${DIFFICULTY_NAMES[room.difficulty] || ''}<span class="num t-dim"> ${info.code}</span></h2>
<p>${info.desc}</p>
<ul>
${info.effects.map((e) => html`<li key=${e}>${e}</li>`)}
<li>共 <b class="num">${info.rounds}</b> 回合${info.hidden ? ',满足条件时进入隐秘核心' : ''}</li>
<li>独立模拟中休整期与机变阶段不限时</li>
</ul>
</aside>`}
</main>
<footer class="room-bar">
<div class="room-bar__left">
<span class="room-bar__label">模拟难度<${MicroLabel}>DIFFICULTY<//></span>
<${DifficultyPicker} room=${room} isHost=${facts.isHost} busy=${busy} onPick=${setDifficulty} />
</div>
<div class="room-bar__center">
<div class="ready-count" hidden=${!coop}>
<span class="t-lo">已就绪</span>
<b class="num">${facts.readyHumans}</b><span class="num t-dim">/${facts.humans.length}</span>
<span class="ready-count__icons" aria-hidden="true">
${facts.humans.map((s) => html`<${Icon} key=${s.playerId} name="user" class=${facts.isReady(s) ? 'is-on' : ''} />`)}
</span>
</div>
<div class="room-bar__status">${statusLine}</div>
</div>
<div class="room-bar__right">
<${LoadoutButton} from="room" size="lg" class="room-loadout" />
${facts.isHost
? html`<${Tooltip} text=${facts.canStart ? null : '仍有博士未准备就绪'}>
<${Button} variant="primary" size="xl" icon="play" loading=${busy === 'start'} disabled=${!facts.canStart || !online} onClick=${start}>开始模拟<//>
<//>`
: html`<${Button} variant=${myReady ? 'primary' : 'secondary'} size="xl" icon=${myReady ? 'check' : 'hourglass'} active=${myReady}
loading=${busy === 'ready'} disabled=${!online || !facts.mine} onClick=${toggleReady}>${myReady ? '已就绪' : '准备就绪'}<//>`}
</div>
</footer>
</div>`;
}
+271
View File
@@ -0,0 +1,271 @@
// Title screen: season-style backdrop, big title 卫戍协议:盟约, remembered nickname, 开始.
//
// Pressing 开始 validates the nickname (1..NAME_MAX_LEN chars, no control characters), stores it,
// marks this tab as "entered" (so reloads skip the title) and hands the name to net.js, which
// sends `hello` (now, or as soon as the socket is open). The router then shows the lobby.
//
// Backdrop art: if data/assets.json lists a UI backdrop (`ui.titleBackdrop`, or one of the
// entry/loading illustration names) it is layered under the CSS art; otherwise the screen is
// pure CSS/SVG (radar, ridgelines, glow), so it never issues a request that can 404.
import { useMemo, useState } from '../../vendor/hooks.module.js';
import { NAME_MAX_LEN } from '../../../shared/constants.js';
import { html, Button, Icon, MicroLabel, TextField, PingPill } from '../ui/components.js';
import { GuideButton } from '../ui/guide.js';
import { toast } from '../ui/toasts.js';
import { net, identity } from '../net.js';
import { store, useStore, shallowEqual } from '../store.js';
import { data, useData } from '../data.js';
import { FullscreenButton, detectFeatures } from '../ui/device.js';
// Same character classes as server/net.js sanitizeName (control, zero-width, bidi, BOM), so a name
// the client accepts is never rejected by the server's hello validation.
const CONTROL_CHARS = /[\u0000-\u001f\u007f-\u009f\u00ad\u200b-\u200f\u2028-\u202e\u2060-\u206f\ufeff]/g;
// Lone surrogates are removed by a scan, not a regex: the lookbehind such a regex needs is a *syntax error* in Safari
// < 16.4, which would stop the whole client from loading there.
export function stripLoneSurrogates(str) {
let out = '';
for (let i = 0; i < str.length; i++) {
const c = str.charCodeAt(i);
if (c >= 0xd800 && c <= 0xdbff) {
const n = i + 1 < str.length ? str.charCodeAt(i + 1) : 0;
if (n >= 0xdc00 && n <= 0xdfff) { out += str[i] + str[i + 1]; i++; }
continue;
}
if (c >= 0xdc00 && c <= 0xdfff) continue;
out += str[i];
}
return out;
}
/**
* Normalise a nickname like the server does (NFC, strip lone surrogates / control / invisible /
* bidi characters, collapse whitespace, trim), then clamp to NAME_MAX_LEN UTF-16 code units — the
* protocol's `hello.name` limit — without splitting a surrogate pair.
* @param {any} raw
* @returns {string}
*/
export function sanitizeName(raw) {
let s = String(raw ?? '');
try { s = s.normalize('NFC'); } catch { /* keep as is */ }
s = stripLoneSurrogates(s).replace(/\s+/g, ' ').replace(CONTROL_CHARS, '').replace(/ {2,}/g, ' ').trim();
if (s.length > NAME_MAX_LEN) {
s = s.slice(0, NAME_MAX_LEN);
// Don't leave half a surrogate pair at the end.
if (/[\ud800-\udbff]$/.test(s)) s = s.slice(0, -1);
s = s.trim();
}
return s;
}
/** @param {any} raw @returns {boolean} */
export const isValidName = (raw) => sanitizeName(raw).length > 0;
/**
* Enter the game shell with a nickname (title → lobby).
* @param {string} rawName
* @returns {boolean} false when the name is invalid
*/
export function enterSession(rawName) {
const name = sanitizeName(rawName);
if (!name) return false;
identity.saveName(name);
identity.setEntered(true);
store.set((s) => ({ me: { ...s.me, name }, session: { ...s.session, entered: true } }));
net.setName(name);
return true;
}
// data/assets.json `ui` keys are 'group/key' (docs/ASSETS.md).
const BACKDROP_KEYS = ['titleBackdrop', 'entry/bkg_01', 'entry/bkg_02'];
const RIDGE_KEYS = ['titleRidges', 'entry/bg_mountains_tiled'];
/**
* Find a UI image URL in data/assets.json (tolerant of a few plausible shapes).
* @param {any} assets
* @param {string[]} names
* @returns {string|null}
*/
export function findUiAsset(assets, names) {
if (!assets || typeof assets !== 'object') return null;
const asUrl = (v) => {
if (typeof v === 'string') return v;
if (v && typeof v === 'object') return v.url || v.path || v.src || null;
return null;
};
const ui = assets.ui;
if (ui && typeof ui === 'object' && !Array.isArray(ui)) {
for (const n of names) {
const u = asUrl(ui[n]);
if (u) return u;
}
}
const lists = [Array.isArray(ui) ? ui : null, Array.isArray(assets.files) ? assets.files : null].filter(Boolean);
for (const list of lists) {
for (const n of names) {
const hit = list.map(asUrl).find((u) => typeof u === 'string' && u.includes('/ui/') && u.toLowerCase().split('/').pop().startsWith(n.toLowerCase()));
if (hit) return hit;
}
}
return null;
}
// Dot-matrix watchtower emblem (13×14 bitmap; dots grow toward the base for depth).
const EMBLEM = [
'XXX..XXX..XXX',
'XXX..XXX..XXX',
'XXXXXXXXXXXXX',
'.XXXXXXXXXXX.',
'..XXXXXXXXX..',
'..XXXXXXXXX..',
'..XXXX.XXXX..',
'..XXXX.XXXX..',
'..XXXXXXXXX..',
'..XXXXXXXXX..',
'..XXXXXXXXX..',
'.XXXXXXXXXXX.',
'XXXXXXXXXXXXX',
'XXXXXXXXXXXXX',
];
function Emblem() {
const dots = useMemo(() => {
const out = [];
EMBLEM.forEach((row, r) => {
[...row].forEach((ch, c) => {
if (ch !== 'X') return;
const rad = 0.2 + (r / (EMBLEM.length - 1)) * 0.2;
const accent = (r === 6 || r === 7) && (c === 5 || c === 7);
out.push({ cx: c + 0.5, cy: r + 0.5, r: rad, accent, d: (r * 13 + c) % 7 });
});
});
return out;
}, []);
return html`<div class="emblem" aria-hidden="true">
<span class="emblem__bracket emblem__bracket--l"></span>
<svg class="emblem__svg" viewBox="-0.5 -0.5 14 15">
${dots.map((d, i) => html`<circle key=${i} cx=${d.cx} cy=${d.cy} r=${d.r} class=${d.accent ? 'is-accent' : `d${d.d}`} />`)}
</svg>
<span class="emblem__bracket emblem__bracket--r"></span>
</div>`;
}
function Ridges() {
return html`<svg class="title-bg__ridges" viewBox="0 0 1920 420" preserveAspectRatio="none" aria-hidden="true">
<defs>
<linearGradient id="ridge-far" x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stop-color="#16231f" /><stop offset="1" stop-color="#0a0e0d" />
</linearGradient>
<linearGradient id="ridge-near" x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stop-color="#0f1714" /><stop offset=".6" stop-color="#080b0a" />
</linearGradient>
<linearGradient id="ridge-edge" x1="0" y1="0" x2="1" y2="0">
<stop offset="0" stop-color="#17f9b7" stop-opacity="0" />
<stop offset=".3" stop-color="#17f9b7" stop-opacity=".55" />
<stop offset=".7" stop-color="#17f9b7" stop-opacity=".55" />
<stop offset="1" stop-color="#17f9b7" stop-opacity="0" />
</linearGradient>
</defs>
<path class="ridge ridge--far" fill="url(#ridge-far)" stroke="url(#ridge-edge)"
d="M0 420V250l120-40 90 30 90-70 60 20 80-70 80 55 80-25 90 70 90-20 80 50 100-15 90 25 90-55 90-55 70-55 70 45 80-20 90 65 80-20 100 55 100-20 100 40v180z" />
<path class="ridge ridge--near" fill="url(#ridge-near)" stroke="url(#ridge-edge)"
d="M0 420V322l160-32 100 20 120-50 90 40 130-20 120 50 140-30 140 35 120-35 140 20 140-50 120 30 120-20 120 40 160-20v147z" />
</svg>`;
}
const STATUS_TEXT = {
idle: '准备连接', connecting: '正在连接服务器', connected: '已连接服务器', handshaking: '正在验证身份',
online: '已连接服务器', reconnecting: '连接中断,正在重连', closed: '连接已关闭',
};
/** Title screen component. */
export function TitleScreen() {
const conn = useStore((s) => s.connection, shallowEqual);
const pendingJoin = useStore((s) => s.ui.pendingJoin);
const [name, setName] = useState(() => store.get().me.name || identity.loadName() || '');
const assetsSettled = useData('assets');
const assets = data.get('assets');
const backdrop = findUiAsset(assets, BACKDROP_KEYS);
const ridges = findUiAsset(assets, RIDGE_KEYS);
// Track load/fail per URL (not as booleans reset in effects: an image can load before an effect runs).
const [bgLoadedUrl, setBgLoadedUrl] = useState(null);
const [ridgesLoadedUrl, setRidgesLoadedUrl] = useState(null);
const [ridgesFailedUrl, setRidgesFailedUrl] = useState(null);
const bgLoaded = !!backdrop && bgLoadedUrl === backdrop;
const ridgesLoaded = !!ridges && ridgesLoadedUrl === ridges;
const ridgesFailed = !!ridges && ridgesFailedUrl === ridges;
// CSS ridgelines only when there is no ridge art (avoids a swap flash when the art arrives).
const cssRidges = assetsSettled && (!ridges || ridgesFailed);
const valid = isValidName(name);
const start = () => {
if (!valid) { toast('请输入博士代号', 'warn'); return; }
enterSession(name);
};
const online = conn.status === 'online' || conn.status === 'connected';
const dotClass = online ? 'is-on' : conn.status === 'reconnecting' || conn.status === 'connecting' || conn.status === 'handshaking' ? 'is-warn' : 'is-bad';
// touch screens: no autofocus (it would pop the on-screen keyboard over a landscape phone's whole view)
const touchUi = useMemo(() => detectFeatures().coarse, []);
return html`<div class="screen title-screen">
<div class=${`title-bg${bgLoaded ? ' has-art' : ''}${ridgesLoaded ? ' has-ridges' : ''}`} aria-hidden="true">
${backdrop ? html`<img class="title-bg__art" src=${backdrop} alt="" draggable=${false}
onLoad=${() => setBgLoadedUrl(backdrop)} />` : null}
<div class="title-bg__glow"></div>
<div class="title-bg__radar"><div class="title-bg__sweep"></div></div>
<div class="title-bg__target"></div>
${cssRidges ? html`<${Ridges} />` : null}
${ridges && !ridgesFailed ? html`<div class="title-bg__ridge-art" style=${`background-image:url("${ridges}")`}>
<img src=${ridges} alt="" hidden onLoad=${() => setRidgesLoadedUrl(ridges)} onError=${() => setRidgesFailedUrl(ridges)} />
</div>` : null}
<div class="title-bg__haze"></div>
<span class="cross" style="left:7%;top:22%"></span>
<span class="cross" style="left:93%;top:30%"></span>
<span class="cross" style="left:14%;top:70%"></span>
<span class="cross" style="left:88%;top:62%"></span>
<span class="cross" style="left:60%;top:12%"></span>
</div>
<div class="title-corner title-corner--tl">
<span class="title-corner__mark"></span>
<div><${MicroLabel} tone="mint">RHODES ISLAND // SIMULATION SERVICE<//><br /><${MicroLabel}>TACTICAL CO-OP NODE · 02<//></div>
</div>
<div class="title-corner title-corner--tr">
<${MicroLabel} tone="hi">TARGET POINT<//><br /><${MicroLabel}>STRONGHOLD PROTOCOL<//>
</div>
<main class="title-main">
<${Emblem} />
<div class="title-en">
<span class="title-en__a">STRONGHOLD PROTOCOL</span>
<span class="title-en__b">ALLIANCE</span>
</div>
<h1 class="title-cn">卫戍协议<span class="title-cn__colon">:</span><em>盟约</em></h1>
<p class="title-tag">调配资金与干员,与同伴协同布防,抵御多波次进攻,直至击败敌方领袖。</p>
<div class="title-login">
${pendingJoin ? html`<div class="title-invite">
<${Icon} name="key" />
<span>收到同盟邀请</span><b class="num">${pendingJoin}</b><span class="t-lo">· 输入代号后将自动加入</span>
</div>` : null}
<${TextField} label="博士代号" micro="CALLSIGN" size="lg" icon="user" value=${name} maxLength=${NAME_MAX_LEN}
placeholder="输入你的代号(最多 ${NAME_MAX_LEN} 字)" autoFocus=${!touchUi}
onInput=${setName} onEnter=${start} />
<${Button} variant="primary" size="xl" block=${true} iconRight="chevrons" disabled=${!valid} onClick=${start}>开始<//>
<div class="title-conn">
<span class=${`status-dot ${dotClass}`}></span>
<span>${STATUS_TEXT[conn.status] || conn.status}</span>
${conn.status === 'online' ? html`<${PingPill} ms=${conn.ping} />` : null}
<${GuideButton} class="title-guide" />
<${FullscreenButton} class="title-fs" />
</div>
</div>
</main>
<footer class="title-foot">
<span>非官方同人复刻 · 游戏素材版权归 上海鹰角网络 / Yostar 所有</span>
<${MicroLabel}>PROTOCOL v1 · WEB SIMULATION<//>
</footer>
</div>`;
}
+208
View File
@@ -0,0 +1,208 @@
// Minimal observable store for the browser client.
//
// One app-wide store holds everything the UI renders from:
// connection – socket status + latency (fed by net.js via main.js)
// me – { playerId, name, token } from `welcome`
// session – { entered } (the player pressed 开始 on the title screen in this tab)
// room – last `room.state` payload (without `t`) or null
// match – { public, private, field, result } from the `m.*` pushes; `battle` = the local battle runner's
// state (client-side combat, public/js/battle/runner.js): { battleId, fieldId, kind, authoritative,
// watch, done, own, members, loading } | null. Under client-side combat `field` is published by the
// runner (the m.field shape of the battle on screen, `local: true`).
// ticker – recent `m.ticker` lines, emotes – recent `m.emote` events
// clock – { offset, rtt } server clock correction: serverNow ≈ Date.now() + offset
// ui – small bits of local UI state shared between screens
//
// The store is framework-agnostic (get/set/subscribe); `useStore(selector)` binds it to Preact.
// Updates are immutable at the top level: `set` shallow-merges a patch object, `patch(key, obj)`
// shallow-merges into one nested object. Subscribers are notified synchronously.
import { useLayoutEffect, useReducer, useRef } from '../vendor/hooks.module.js';
import { PHASE } from '../../shared/constants.js';
/**
* Create an observable store.
* @template S
* @param {S} initial initial state object
* @returns {{ get: () => S, set: (patch: Partial<S> | ((s: S) => Partial<S>)) => void,
* patch: (key: keyof S, value: object | ((prev: any) => object)) => void,
* subscribe: (fn: (s: S, prev: S) => void) => () => void, reset: () => void }}
*/
export function createStore(initial) {
let state = { ...initial };
const listeners = new Set();
const notify = (prev) => {
for (const fn of [...listeners]) {
try { fn(state, prev); } catch (err) { console.error('[store] listener failed', err); }
}
};
return {
get: () => state,
set(patch) {
const p = typeof patch === 'function' ? patch(state) : patch;
if (!p || typeof p !== 'object') return;
const prev = state;
state = { ...state, ...p };
notify(prev);
},
patch(key, value) {
const cur = state[key];
const p = typeof value === 'function' ? value(cur) : value;
if (!p || typeof p !== 'object') return;
const prev = state;
state = { ...state, [key]: { ...(cur && typeof cur === 'object' ? cur : {}), ...p } };
notify(prev);
},
subscribe(fn) {
listeners.add(fn);
return () => listeners.delete(fn);
},
reset() {
const prev = state;
state = { ...initial };
notify(prev);
},
};
}
/** Fresh (empty) match slice. */
export const emptyMatch = () => ({ public: null, private: null, field: null, result: null, battle: null });
/** Initial app state (exported for tests and resets). */
export const initialState = Object.freeze({
connection: { status: 'idle', ping: null, attempt: 0, retryAt: 0, lastError: null, everOnline: false },
me: { playerId: null, name: '', token: null },
session: { entered: false },
room: null,
match: emptyMatch(),
ticker: [],
emotes: [],
clock: { offset: 0, rtt: null, synced: false },
ui: { pendingJoin: null, restoring: false },
});
/** The app-wide store singleton. */
export const store = createStore(initialState);
/**
* Which screen the router shows for a given app state:
* not entered → title; m.public.phase ≠ LOBBY or room.inMatch → game; in a room → room; else lobby.
* @param {any} s store state
* @returns {'title'|'lobby'|'room'|'game'}
*/
export function selectRoute(s) {
if (!s?.session?.entered) return 'title';
const phase = s.match?.public?.phase;
if (phase && phase !== PHASE.LOBBY) return 'game';
if (s.room?.inMatch) return 'game'; // match starting: m.public is on its way
if (s.room) return 'room';
return 'lobby';
}
/**
* The notice after a `welcome` that starts a brand-new server session (the server restarted, or this session expired
* on it: a new playerId) while a room or a match was on screen — everything shown is gone. Null when nothing is lost
* (the first welcome, a resumed session, only the title / lobby on screen).
* @param {any} prev store state before the welcome
* @param {string|null|undefined} playerId the welcome's playerId
* @returns {string|null}
*/
export function sessionResetNotice(prev, playerId) {
const prevId = prev?.me?.playerId;
if (prevId == null || prevId === playerId) return null;
if (prev?.match?.public || prev?.room?.inMatch) return '服务器会话已重置,上一局模拟已结束';
if (prev?.room) return '服务器会话已重置,已返回大厅';
return null;
}
/**
* Current server time in ms since epoch, corrected by the measured clock offset.
* @returns {number}
*/
export function serverNow() {
const off = store.get().clock?.offset;
return Date.now() + (Number.isFinite(off) ? off : 0);
}
/**
* Shallow equality for plain objects / arrays (use as `useStore(sel, shallowEqual)`).
* @param {any} a
* @param {any} b
* @returns {boolean}
*/
export function shallowEqual(a, b) {
if (Object.is(a, b)) return true;
if (!a || !b || typeof a !== 'object' || typeof b !== 'object') return false;
if (Array.isArray(a) !== Array.isArray(b)) return false;
const ka = Object.keys(a);
const kb = Object.keys(b);
if (ka.length !== kb.length) return false;
for (const k of ka) if (!Object.prototype.hasOwnProperty.call(b, k) || !Object.is(a[k], b[k])) return false;
return true;
}
// ---- tiny persisted preferences (localStorage, failure-tolerant) --------------------------------
/**
* Read a JSON preference from localStorage.
* @template T
* @param {string} key
* @param {T} fallback
* @returns {T}
*/
export function loadPref(key, fallback) {
try {
const raw = globalThis.localStorage?.getItem(`sp.pref.${key}`);
return raw == null ? fallback : JSON.parse(raw);
} catch {
return fallback;
}
}
/**
* Write a JSON preference to localStorage (silently ignores quota/privacy errors).
* @param {string} key
* @param {any} value
*/
export function savePref(key, value) {
try { globalThis.localStorage?.setItem(`sp.pref.${key}`, JSON.stringify(value)); } catch { /* ignore */ }
}
const identity = (s) => s;
/**
* Preact hook: subscribe to a slice of a store. Re-renders only when the selected value changes
* (by `isEqual`, default `Object.is`).
* @template S, T
* @param {(s: S) => T} [selector]
* @param {(a: T, b: T) => boolean} [isEqual]
* @param {ReturnType<typeof createStore>} [target] store to read (defaults to the app store)
* @returns {T}
*/
export function useStore(selector = identity, isEqual = Object.is, target = store) {
const [, force] = useReducer((c) => c + 1, 0);
const selRef = useRef(selector);
const eqRef = useRef(isEqual);
selRef.current = selector;
eqRef.current = isEqual;
let value;
try { value = selector(target.get()); } catch (err) { console.error('[store] selector failed', err); value = undefined; }
const valRef = useRef(value);
valRef.current = value;
useLayoutEffect(() => {
const check = (s) => {
let next;
try { next = selRef.current(s); } catch { return; }
if (!eqRef.current(next, valRef.current)) force();
};
const unsub = target.subscribe(check);
check(target.get()); // catch updates between render and subscription
return unsub;
}, [target]);
return value;
}
+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;
}
+102
View File
@@ -0,0 +1,102 @@
// Bond strip (active-bond discs under the top bar) and the bond detail popup: the key facts first (user playtest #2
// item 9: name, members in play / next threshold, layers, reached tier and its threshold row, the current effect with
// layer-resolved numbers), then the full description and the member list with owned / on-board state. Opened from
// the detail panel's bond chips it docks beside that panel (`beside`: the panel's side).
// Research 06 §11.1: round mint discs, stack count over the disc, name below, sorted by stacks; grey =
// present but inactive; in 联防 / boss rounds the strip is dimmed ("层数叠加已禁用").
import { html, BondDisc, Icon, MicroLabel, Tooltip } from './components.js';
import { RichText, UnitThumb, BondGlyph } from './gameComponents.js';
import { sortBonds, bondMembers, nextThreshold, bondTier } from './gameLogic.js';
import { formatBondEffect } from './richText.js';
import { bondIconUrl } from './assetUrls.js';
import { data } from '../data.js';
const cx = (...p) => p.flat().filter(Boolean).join(' ');
/**
* @param {{ bonds: any[], layersDisabled?: boolean, onOpen:(bondId:string)=>void, openId?: string|null, max?: number }} props
*/
export function BondStrip({ bonds, layersDisabled = false, onOpen, openId = null, max = 14, owner = null }) {
const sorted = sortBonds(bonds, (id) => data.lookup('bonds', id));
if (!sorted.length) {
return html`<div class="bstrip bstrip--empty"><${MicroLabel}>BONDS</${MicroLabel}><span>${owner ? `${owner} 尚未激活盟约` : '部署干员以激活盟约'}</span></div>`;
}
const m = data.get('assets');
const shown = sorted.slice(0, max);
const strip = html`<div class=${cx('bstrip', layersDisabled && 'is-frozen', owner && 'is-other')} role="list">
${owner ? html`<span class="bstrip__owner"><${MicroLabel}>${owner}</${MicroLabel}></span>` : null}
${shown.map((b) => {
const rec = data.lookup('bonds', b.bondId);
const th = Array.isArray(b.thresholds) && b.thresholds.length ? b.thresholds : rec?.thresholds || [];
const next = nextThreshold(b.count ?? 0, th);
return html`<div key=${b.bondId} role="listitem" class=${cx('bslot', b.active && 'is-active', openId === b.bondId && 'is-open')}>
<${BondDisc} name=${rec?.name || b.bondId} icon=${bondIconUrl(m, b.bondId)} layers=${b.layers ?? 0}
tier=${b.tier ?? 0} maxTier=${Math.max(1, th.length)} active=${!!b.active} size="sm" showName=${true}
layersDisabled=${layersDisabled} onClick=${() => onOpen(b.bondId)}
title=${`${rec?.name || b.bondId} ${b.count ?? 0}/${next ?? th[th.length - 1] ?? '-'}`} />
<span class=${cx('bslot__count', 'num', next == null && 'is-max')}>${b.count ?? 0}<small>/${next ?? th[th.length - 1] ?? '-'}</small></span>
</div>`;
})}
${sorted.length > shown.length ? html`<span class="bstrip__more num">+${sorted.length - shown.length}</span>` : null}
</div>`;
return layersDisabled ? html`<${Tooltip} text="层数叠加已禁用" placement="bottom">${strip}<//>` : strip;
}
/**
* Bond detail popup.
* @param {{ bondId: string, entry?: any, priv?: any, banned?: string[], onClose: Function, onMember?: (chessId:string)=>void,
* place?: 'left'|'beside'|'besideR'|'right'|null, over?: boolean, beside?: 'left'|'right'|null }} props — `place`: where it
* opens (gameLogic bondPopupPlace; `beside: 'left'` = the older spelling of 'beside'); `over`: above the detail card
*/
export function BondPopup({ bondId, entry, priv, banned = [], onClose, onMember, place = null, over = false, beside = null }) {
const b = data.lookup('bonds', bondId);
if (!b) return null;
const count = entry?.count ?? 0;
const layers = entry?.layers ?? 0;
const th = Array.isArray(entry?.thresholds) && entry.thresholds.length ? entry.thresholds : b.thresholds || [];
const tier = entry?.tier ?? bondTier(count, th, b.maxCount);
const active = entry ? !!entry.active : tier > 0;
const members = bondMembers(b, priv, banned, (id) => data.lookup('chess', id));
const countsHand = entry?.countsHand ?? b.countsHand;
const next = nextThreshold(count, th);
const hasNow = !!(b.effectDescRaw || b.effectDesc);
const at = place || (beside === 'left' ? 'beside' : 'left');
return html`<div class=${cx('bpop', 'brackets', `bpop--${at}`, over && 'is-over')} data-place=${at} role="dialog" aria-label=${`盟约:${b.name}`}>
<button type="button" class="bpop__close" aria-label="关闭" onClick=${onClose}><${Icon} name="close" /></button>
<header class="bpop__head">
<div class=${cx('bpop__disc', active && 'is-active')}><${BondGlyph} bondId=${bondId} /></div>
<div class="bpop__titles">
<${MicroLabel} tone="mint">${b.isCore ? 'CORE BOND // 核心盟约' : 'ADD-ON BOND // 附加盟约'}</${MicroLabel}>
<h3 class="bpop__name">${b.name}</h3>
<div class="bpop__facts">
<span>在场 <b class="num">${count}</b>${next != null ? html`<small class="num">/${next}</small>` : null}${countsHand ? html`<small>(含整备区)</small>` : null}</span>
<span>层数 <b class="num t-mint">${layers}</b></span>
<span class=${active ? 't-mint' : 't-lo'}>${active ? `已激活${th.length > 1 ? ` · ${tier} 阶` : ''}` : '未激活'}</span>
</div>
</div>
</header>
<div class="bpop__tiers">
${th.map((n, i) => html`<span key=${i} class=${cx('bpop__tier', i < tier && 'is-on')}><b class="num">${n}</b><small>${b.maxCount != null ? '名及以下' : '名'}</small></span>`)}
</div>
${hasNow ? html`<section class="bpop__sec bpop__sec--now">
<h4>当前效果 <small class="num">(${layers} 层)</small></h4>
<${RichText} as="p" text=${formatBondEffect(b, layers)} class="bpop__desc" />
</section>` : null}
<section class="bpop__sec">
<h4>盟约效果</h4>
<${RichText} as="p" text=${b.descRaw || b.desc} class="bpop__desc" />
</section>
<section class="bpop__sec">
<h4>成员 <small>${members.filter((x) => x.onBoard).length}/${members.length}</small></h4>
<div class="bpop__members">
${members.map((mb) => html`<button key=${mb.id} type="button" class=${cx('bpop__member', mb.onBoard && 'is-on', mb.owned && !mb.onBoard && 'is-owned', mb.banned && 'is-banned')}
onClick=${() => onMember?.(mb.id)} title=${`${mb.name}${mb.banned ? '(本局禁用)' : mb.onBoard ? '(在场)' : mb.owned ? '(整备区)' : ''}`}>
<${UnitThumb} kind="chess" id=${mb.id} size="sm" dim=${!mb.owned || mb.banned} />
<span class="bpop__mname">${mb.name}</span>
${mb.banned ? html`<span class="bpop__ban"><${Icon} name="close" /></span>` : null}
</button>`)}
</div>
</section>
</div>`;
}
+117
View File
@@ -0,0 +1,117 @@
// 机变 draft overlay (research 06 §4.4 / §11.5): family title | description, "倒计时结束后仍未选定将自动分配",
// whose turn ("当前轮到你决策" / "{name} 正在决策…") with countdown, pick order with ✓ / ⌛ / … / door,
// and a 3×2 grid (solo: 3 cards) of cards with icon, title, rich description, tier chip and the taker's
// avatar badge. Clicking an available card on your turn sends g.choice.
import { html, Icon, TierChip, Countdown, MicroLabel, Spinner } from './components.js';
import { Img, RichText, PlayerAvatar, GIcon } from './gameComponents.js';
import { itemIconUrl, enemyIconUrl, uiUrl } from './assetUrls.js';
import { richTextPlain } from './richText.js';
import { sortedPlayers } from './gameLogic.js';
import { data } from '../data.js';
const cx = (...p) => p.flat().filter(Boolean).join(' ');
/**
* Resolve an sp card to display fields.
* @param {any} card normalised card ({ idx, takenBy, effectId?, itemId?, id?, name?, desc?, descRaw?, tier?, team?, enemyKey? })
* @param {string|null} family 'bounty'|'supply'|'shop'|'tactic'
*/
export function resolveSpCard(card, family) {
const choices = data.get('choices');
const byEffect = (list, id) => (Array.isArray(list) && id ? list.find((x) => x && x.effectId === id) : null);
const effectId = card.effectId || (!card.itemId && typeof card.id === 'string' && data.lookup('effects', card.id) ? card.id : null);
const eff = effectId ? data.lookup('effects', effectId) : null;
const itemId = card.itemId || (!effectId && typeof card.id === 'string' && data.lookup('items', card.id) ? card.id : null);
const item = itemId ? data.lookup('items', itemId) : null;
const bounty = byEffect(choices?.cards?.bounty, effectId);
const tactic = byEffect(choices?.cards?.tactic, effectId);
const m = data.get('assets');
const kind = card.kind === 'item' || item ? 'item' : card.kind === 'bounty' || bounty || family === 'bounty' ? 'bounty' : 'tactic';
const enemyKey = card.enemyKey || bounty?.enemyKey || eff?.params?.enemy_id || null;
const team = card.team ?? tactic?.team ?? (eff?.decoIconId === 'icon_team_buff');
let icon = null;
if (item) icon = itemIconUrl(m, item);
else if (kind === 'bounty' && enemyKey) icon = enemyIconUrl(m, enemyKey);
else if ((card.tacticKind || tactic?.kind) === 'terrain') icon = uiUrl(m, 'buffIcon/icon_stage_buff');
else if ((card.tacticKind || tactic?.kind) === 'enemyDebuff') icon = uiUrl(m, 'buffIcon/icon_enemy_debuff');
else icon = uiUrl(m, `buffIcon/${team ? 'icon_team_buff' : 'icon_player_buff'}`);
return {
kind,
name: card.name || item?.name || eff?.name || bounty?.name || tactic?.name || '机变',
desc: card.descRaw || card.desc || item?.descRaw || item?.desc || eff?.descRaw || eff?.desc || bounty?.desc || tactic?.desc || '',
tier: Number.isFinite(card.tier) ? card.tier : item?.tier ?? bounty?.tier ?? null,
icon,
team: !!team,
coin: card.coin ?? bounty?.coin ?? eff?.enemyPrice ?? null,
};
}
/**
* @param {{ pub:any, sp:any, myId:string, solo:boolean, onPick:(idx:number)=>void, busyIdx?:number|null, total?:number|null }} props
*/
export function ChoiceOverlay({ pub, sp, myId, solo, onPick, busyIdx = null, total = null }) {
if (!sp) return null;
const fam = data.get('choices')?.families?.[sp.family] || null;
const players = new Map(sortedPlayers(pub).map((p) => [p.playerId, p]));
const myTurn = solo || sp.turnPid === myId;
const mine = sp.pickOf.get(myId);
const turnName = players.get(sp.turnPid)?.name || '队友';
const special = /_s$/.test(String(sp.family || ''));
const order = solo ? [] : sp.order;
return html`<div class="spov" role="dialog" aria-label="机变阶段">
<div class="spov__veil" aria-hidden="true"></div>
<div class="spov__inner">
<header class="spov__head">
<div class="spov__titles">
<${MicroLabel} tone="mint">CONTINGENCY // 机变阶段</${MicroLabel}>
<h2 class=${cx('spov__title', special && 'is-special')}>${sp.name || fam?.name || '机变'}<span class="spov__bar">|</span><span class="spov__desc"><${RichText} text=${sp.desc || fam?.desc || '选择一项'} /></span></h2>
${!solo ? html`<p class="spov__sub">倒计时结束后仍未选定将自动分配</p>` : html`<p class="spov__sub">选择一项(无时间限制)</p>`}
</div>
<div class="spov__turn">
${mine != null ? html`<span class="spov__turntxt is-done"><${Icon} name="check" />已完成选择</span>`
: myTurn ? html`<span class="spov__turntxt is-mine">当前轮到你决策</span>`
: html`<span class="spov__turntxt">${turnName} 正在决策…<${Icon} name="hourglass" /></span>`}
${!solo && !sp.untimed ? html`<${Countdown} deadline=${pub?.deadline} total=${total ?? undefined} size="sm" />` : null}
</div>
</header>
${order.length ? html`<div class="spov__order" aria-label="决策顺序">
${order.map((pid, i) => {
const p = players.get(pid);
const picked = sp.pickOf.has(pid);
const cur = sp.turnPid === pid && !picked;
const left = p?.status === 'left';
return html`<div key=${pid} class=${cx('spov__who', cur && 'is-cur', picked && 'is-done', pid === myId && 'is-self')}>
<span class="spov__idx num">${i + 1}</span>
<${PlayerAvatar} player=${p || { name: '?' }} size="sm" self=${pid === myId} />
<span class="spov__wname">${p?.name || '博士'}</span>
<span class="spov__wstate">${left ? html`<${Icon} name="exit" />` : picked ? html`<${Icon} name="check" />` : cur ? html`<${Icon} name="hourglass" />` : html`<${Icon} name="dots" />`}</span>
</div>`;
})}
</div>` : null}
<div class=${cx('spov__grid', sp.cards.length <= 3 && 'spov__grid--3')}>
${sp.cards.map((card) => {
const r = resolveSpCard(card, sp.family);
const taker = card.takenBy ? players.get(card.takenBy) : null;
const can = myTurn && mine == null && !card.takenBy && busyIdx == null;
return html`<button key=${card.idx} type="button" class=${cx('spcard', `spcard--${r.kind}`, card.takenBy && 'is-taken', card.takenBy === myId && 'is-mine', can && 'is-pickable', busyIdx === card.idx && 'is-busy')}
disabled=${!can} onClick=${() => can && onPick(card.idx)} aria-label=${r.name} title=${`${r.name}\n${richTextPlain(r.desc)}`}>
<span class="spcard__glow" aria-hidden="true"></span>
${r.tier ? html`<${TierChip} tier=${r.tier} size="md" class="spcard__tier" />` : null}
<span class="spcard__icon"><${Img} src=${r.icon} fallback=${html`<${GIcon} name=${r.kind === 'bounty' ? 'target' : 'bolt'} />`} /></span>
<span class="spcard__body">
<b class="spcard__name">${r.name}</b>
<${RichText} text=${r.desc} class="spcard__desc" />
<span class="spcard__tags">
${r.team ? html`<span class="spcard__tag spcard__tag--team">全队获得</span>` : null}
${r.kind === 'bounty' && r.coin ? html`<span class="spcard__tag spcard__tag--coin">赏金 ${r.coin}</span>` : null}
</span>
</span>
${taker ? html`<span class="spcard__taker" title=${`${taker.name} 已选择`}><${PlayerAvatar} player=${taker} size="sm" /><span>${card.takenBy === myId ? '你' : taker.name}</span></span>` : null}
${busyIdx === card.idx ? html`<span class="spcard__spin"><${Spinner} size="sm" /></span>` : null}
</button>`;
})}
</div>
</div>
</div>`;
}
+86
View File
@@ -0,0 +1,86 @@
// Combat HUD (research 06 §11.3/§11.7, research 09 §3.1 / §6.3): DP counter at the right edge and the bottom-centre
// pills.
// Client-side combat (`client` prop, DESIGN §14 — the official behaviour):
// * own normal battle over: "⌛ 作战结束,等待队友完成作战" with each teammate's live progress (kills / total, ✓);
// * observing a teammate (team row → 前往查看): "👁 name" + 返回战场;
// * 联防 / 最终攻势: the ‹ › pill switches the camera LEFT half / 全景 / RIGHT half ("你自己" / name / "全景");
// * there is NO view switcher during normal combat.
// Server-run combat (legacy streaming mode): the ‹ 自己 › switcher cycling the live fields of m.public.fields.
import { html, Icon } from './components.js';
import { DpCounter } from './hud.js';
import { GIcon } from './gameComponents.js';
import { switcherLabel, cycleField } from './gameLogic.js';
const cx = (...p) => p.flat().filter(Boolean).join(' ');
/** Teammates' progress under the waiting pill (css/screens/game.css .chud__progress / .chud__prog). */
function ProgressList({ list }) {
if (!Array.isArray(list) || !list.length) return null;
return html`<div class="chud__progress" role="list" aria-label="队友作战进度">
${list.map((p) => html`<span key=${p.playerId} role="listitem" class=${cx('chud__prog', p.done && 'is-done')}>
<b>${p.name}</b>
${p.done
? html`<span class="chud__prog__ok" aria-label="作战结束">✓</span>`
: html`<span class="num">${p.killed != null && p.total != null ? `${p.killed}/${p.total}` : '•••'}</span>`}
</span>`)}
</div>`;
}
/**
* @param {{ pub:any, myId:string, watching:string|null, hud:any, myDone:boolean, onWatch:(fieldId:string)=>void, spectating?: boolean,
* client?: null | { progress?: any[]|null, observing?: { name: string }|null, onBack?: () => void,
* layers?: Array<{ key: string, label: string, self: boolean, watch: boolean }>, layer?: string, onLayer?: (k: string) => void } }} props
*/
export function CombatHud({ pub, myId, watching, hud, myDone, onWatch, spectating = false, client = null }) {
if (client) return ClientHud({ hud, myDone, spectating, client });
const fields = (Array.isArray(pub?.fields) ? pub.fields : []).filter((f) => f && f.live !== false);
// the watched field may already have finished (not live): still name it
const label = switcherLabel(pub, watching, myId, spectating);
const canCycle = fields.length > 1;
return html`<div class="chud">
<${DpCounter} dp=${hud?.dp} />
<div class="chud__bottom">
${myDone ? html`<div class="chud__msg" role="status"><${Icon} name="hourglass" /><span>作战结束,等待队友完成作战</span></div>` : null}
${spectating ? html`<div class="chud__msg chud__msg--dead" role="status"><${Icon} name="close" /><span>你已被淘汰,正在观战</span></div>` : null}
${fields.length ? html`<div class=${cx('vswitch', !canCycle && 'is-single')}>
<button type="button" class="vswitch__arrow" disabled=${!canCycle} aria-label="上一个战场"
onClick=${() => { const n = cycleField(fields, watching, -1); if (n && n !== watching) onWatch(n); }}><${Icon} name="chevronLeft" /></button>
<span class="vswitch__label">${label}</span>
<button type="button" class="vswitch__arrow" disabled=${!canCycle} aria-label="下一个战场"
onClick=${() => { const n = cycleField(fields, watching, 1); if (n && n !== watching) onWatch(n); }}><${Icon} name="chevronRight" /></button>
</div>` : null}
</div>
</div>`;
}
function ClientHud({ hud, myDone, spectating, client }) {
const layers = Array.isArray(client.layers) ? client.layers : [];
const idx = Math.max(0, layers.findIndex((l) => l.key === (client.layer || 'ALL')));
const cur = layers[idx] || null;
const step = (d) => {
if (!layers.length || !client.onLayer) return;
const n = Math.min(layers.length - 1, Math.max(0, idx + d));
if (n !== idx) client.onLayer(layers[n].key);
};
const observing = client.observing;
return html`<div class="chud">
<${DpCounter} dp=${hud?.dp} />
<div class="chud__bottom">
${myDone && !observing ? html`<div class="chud__msg chud__wait" role="status"><${Icon} name="hourglass" /><span>作战结束,等待队友完成作战</span></div>
<${ProgressList} list=${client.progress} />` : null}
${spectating && !observing ? html`<div class="chud__msg chud__msg--dead" role="status"><${Icon} name="close" /><span>你已被淘汰,可点击队友头像前往查看</span></div>` : null}
${observing ? html`<div class="vswitch chud__observe is-single" role="status">
<span class="vswitch__label"><${GIcon} name="eye" /><span>${observing.name}</span></span>
${client.onBack ? html`<button type="button" class="btn btn--secondary btn--sm chud__back" onClick=${() => client.onBack()}>
<span class="btn__label">返回战场</span></button>` : null}
</div>` : null}
${!observing && layers.length ? html`<div class="vswitch chud__layers">
<button type="button" class="vswitch__arrow" disabled=${idx <= 0} aria-label="左侧战场" onClick=${() => step(-1)}><${Icon} name="chevronLeft" /></button>
<span class="vswitch__label">
${cur && cur.watch ? html`<${GIcon} name="eye" />` : null}<span>${cur ? cur.label : '全景'}</span></span>
<button type="button" class="vswitch__arrow" disabled=${idx >= layers.length - 1} aria-label="右侧战场" onClick=${() => step(1)}><${Icon} name="chevronRight" /></button>
</div>` : null}
</div>
</div>`;
}
+93
View File
@@ -0,0 +1,93 @@
// Small polyfills for the older browsers the client supports (Safari / iOS 15.0–15.3, Firefox ESR, older Chromium on
// Android tablets). Imported first by main.js (and the dev harnesses), so every later module — including the ones
// loaded on demand (render engine, battle sim) — sees them. Each one is installed only when the browser lacks it
// (feature detection, never UA sniffing) and is a faithful subset of the standard behaviour:
// Object.hasOwn Safari < 15.4, Firefox < 92
// Array/String/TypedArray .at() Safari < 15.4, Firefox < 90
// Array .findLast / .findLastIndex Safari < 15.4, Firefox < 104
// structuredClone Safari < 15.4, Firefox < 94 (JSON-safe values + Map/Set/Date; enough for
// the plain state objects the client clones)
// globalThis.queueMicrotask very old WebViews
// `installCompat(target)` is exported for tests (it patches the given global object instead).
/**
* Install the polyfills on `g` (default globalThis). Returns the names that were missing and got installed.
* @param {any} [g]
* @returns {string[]}
*/
export function installCompat(g = globalThis) {
const added = [];
const def = (obj, name, fn) => {
if (!obj || typeof obj[name] === 'function') return;
try {
Object.defineProperty(obj, name, { value: fn, writable: true, configurable: true, enumerable: false });
added.push(name);
} catch { /* frozen: leave it */ }
};
const O = g.Object || Object;
def(O, 'hasOwn', function hasOwn(obj, key) {
if (obj == null) throw new TypeError('Cannot convert undefined or null to object');
return Object.prototype.hasOwnProperty.call(Object(obj), key);
});
function at(i) {
const len = this.length >>> 0;
let k = Math.trunc(Number(i)) || 0;
if (k < 0) k += len;
if (k < 0 || k >= len) return undefined;
return typeof this === 'string' || this instanceof String ? String(this).charAt(k) : this[k];
}
def((g.Array || Array).prototype, 'at', at);
def((g.String || String).prototype, 'at', at);
const TA = g.Int8Array ? Object.getPrototypeOf(g.Int8Array.prototype) : null;
if (TA) def(TA, 'at', at);
def((g.Array || Array).prototype, 'findLast', function findLast(fn, thisArg) {
for (let i = (this.length >>> 0) - 1; i >= 0; i--) if (fn.call(thisArg, this[i], i, this)) return this[i];
return undefined;
});
def((g.Array || Array).prototype, 'findLastIndex', function findLastIndex(fn, thisArg) {
for (let i = (this.length >>> 0) - 1; i >= 0; i--) if (fn.call(thisArg, this[i], i, this)) return i;
return -1;
});
def(g, 'structuredClone', function structuredClone(value) { return cloneValue(value, new Map()); });
def(g, 'queueMicrotask', function queueMicrotask(fn) { Promise.resolve().then(fn).catch((err) => setTimeout(() => { throw err; })); });
return added;
}
/** Deep clone of plain data (objects, arrays, Map, Set, Date, RegExp, typed arrays), cycles preserved. */
function cloneValue(v, seen) {
if (v === null || typeof v !== 'object') {
if (typeof v === 'function' || typeof v === 'symbol') throw new TypeError('structuredClone: value could not be cloned');
return v;
}
if (seen.has(v)) return seen.get(v);
let out;
if (v instanceof Date) out = new Date(v.getTime());
else if (v instanceof RegExp) out = new RegExp(v.source, v.flags);
else if (ArrayBuffer.isView(v)) out = new v.constructor(v);
else if (v instanceof ArrayBuffer) out = v.slice(0);
else if (v instanceof Map) {
out = new Map();
seen.set(v, out);
for (const [k, x] of v) out.set(cloneValue(k, seen), cloneValue(x, seen));
return out;
} else if (v instanceof Set) {
out = new Set();
seen.set(v, out);
for (const x of v) out.add(cloneValue(x, seen));
return out;
} else if (Array.isArray(v)) {
out = new Array(v.length);
seen.set(v, out);
for (let i = 0; i < v.length; i++) out[i] = cloneValue(v[i], seen);
return out;
} else {
out = {};
seen.set(v, out);
for (const k of Object.keys(v)) out[k] = cloneValue(v[k], seen);
return out;
}
seen.set(v, out);
return out;
}
export const installedPolyfills = installCompat();
+823
View File
@@ -0,0 +1,823 @@
// Design-system components (Preact + htm, no build step). Styles live in css/components.css.
//
// Exports: html (bound htm), Icon, Button, Panel, MicroLabel, Chevrons, HexBadge, TierChip,
// BondDisc, SevenSeg, Countdown, Modal, confirmDialog/alertDialog + DialogHost, Tooltip +
// TooltipLayer, ProgressBar, Tabs, Spinner, AvatarFrame, PhaseBanner, PingPill, DifficultyTag,
// DifficultyIcon, TextField, UiHosts (mount once: dialogs + tooltips), useTicker, secondsLeft/hasDeadline,
// roman(), doctorNo().
//
// Look: research 06 §11–§12 — near-black green-grey panels, 1px lines, mint accents, bracket
// corners, hexagon badges, roman tier chips, bond discs with segmented rings, 7-segment countdown
// (orange ≤10 s), chevron phase banners. Motion 150–250 ms. All sizes are rem (1rem = 100 design
// px at 1920×1080, see css/theme.css).
import { h, Fragment } from '../../vendor/preact.module.js';
import { useEffect, useLayoutEffect, useMemo, useReducer, useRef, useState } from '../../vendor/hooks.module.js';
import htm from '../../vendor/htm.module.js';
import { DIFFICULTY_NAMES, DIFFICULTY_COLORS } from '../../../shared/constants.js';
import { serverNow } from '../store.js';
import { data, useData, localAsset } from '../data.js';
import { uiUrl } from './assetUrls.js';
/**
* h() for htm with its static-subtree cache switched off. htm keeps the vnode it built for a fully static subtree in
* the template's op list and hands the SAME object out on every render; Preact mounts that shared vnode (it gets
* its DOM node and parent chain written into it), so after the screen unmounts the module-level template cache
* still points at the detached DOM, its listeners and the old component tree — whole stale screens pile up over a
* session. `this[0] = 3` marks the element dynamic (htm's documented opt-out), so every render gets fresh vnodes.
*/
export function hFresh(type, props, ...children) {
if (this && typeof this === 'object') this[0] = 3;
return h(type, props, ...children);
}
/** Tagged-template JSX replacement bound to Preact's h() (no shared static vnodes, see hFresh). */
export const html = htm.bind(hFresh);
export { h, Fragment };
const cx = (...parts) => parts.flat().filter(Boolean).join(' ');
// ---- icons ---------------------------------------------------------------------------------------
/** 24×24 glyph paths (original, simple geometric shapes). `eo` = even-odd fill rule. */
export const ICONS = {
check: { d: 'M9.5 16.2 5.3 12l-1.4 1.4 5.6 5.6L20.1 8.4 18.7 7z' },
close: { d: 'M6.4 5 5 6.4 10.6 12 5 17.6 6.4 19 12 13.4 17.6 19 19 17.6 13.4 12 19 6.4 17.6 5 12 10.6z' },
exit: { d: 'M20 3H10v2h8v14h-8v2h10zM8.4 7.4 7 6l-6 6 6 6 1.4-1.4L4.8 13H15v-2H4.8z' },
crown: { d: 'M3 7l4.6 4.2L12 4l4.4 7.2L21 7l-1.8 10H4.8zM5 19h14v2H5z' },
robot: { d: 'M11 2h2v3h4a3 3 0 0 1 3 3v8a3 3 0 0 1-3 3H7a3 3 0 0 1-3-3V8a3 3 0 0 1 3-3h4zM8.5 9.5a1.75 1.75 0 1 0 0 3.5 1.75 1.75 0 0 0 0-3.5zm7 0a1.75 1.75 0 1 0 0 3.5 1.75 1.75 0 0 0 0-3.5zM9 15v1.6h6V15zM1 10h2v5H1zm20 0h2v5h-2z', eo: true },
copy: { d: 'M8 3h11v13h-2V5H8zM5 7h10v14H5zm2 2v10h6V9z', eo: true },
link: { d: 'M9 7H6.5a5 5 0 0 0 0 10H9v-2H6.5a3 3 0 0 1 0-6H9zm6 0h2.5a5 5 0 0 1 0 10H15v-2h2.5a3 3 0 0 0 0-6H15zM8 11h8v2H8z' },
plus: { d: 'M11 4h2v7h7v2h-7v7h-2v-7H4v-2h7z' },
minus: { d: 'M4 11h16v2H4z' },
user: { d: 'M12 3a4 4 0 1 1 0 8 4 4 0 0 1 0-8zM4 21a8 7 0 0 1 16 0z' },
users: { d: 'M9 4a3.5 3.5 0 1 1 0 7 3.5 3.5 0 0 1 0-7zM2 20a7 6.5 0 0 1 14 0zM16.5 5a3 3 0 1 1 0 6 3 3 0 0 1 0-6zm.9 8.1A6.5 6 0 0 1 22.5 20H18a8.6 8 0 0 0-2.4-6.1 6 6 0 0 1 1.8-.8z' },
rook: { d: 'M5 3h3v2h2V3h4v2h2V3h3v5l-2 2v7l2 2v2H5v-2l2-2v-7L5 8z' },
signal: { d: 'M2 17h3v4H2zm6-4h3v8H8zm6-4h3v12h-3zm6-4h3v16h-3z' },
refresh: { d: 'M12 4a8 8 0 0 1 7.4 5H17v2h6V5h-2v2.3A10 10 0 0 0 2 12h2a8 8 0 0 1 8-8zm0 16a8 8 0 0 1-7.4-5H7v-2H1v6h2v-2.3A10 10 0 0 0 22 12h-2a8 8 0 0 1-8 8z' },
snow: { d: 'M11 2h2v4.2l2.3-2.3 1.4 1.4-3.7 3.7v2h2l3.7-3.7 1.4 1.4-2.3 2.3H22v2h-4.2l2.3 2.3-1.4 1.4-3.7-3.7h-2v2l3.7 3.7-1.4 1.4-2.3-2.3V22h-2v-4.2l-2.3 2.3-1.4-1.4 3.7-3.7v-2H9l-3.7 3.7-1.4-1.4L6.2 13H2v-2h4.2L3.9 8.7l1.4-1.4L9 11h2V9L7.3 5.3l1.4-1.4L11 6.2z' },
chevronRight: { d: 'M8.6 5 7.2 6.4 12.8 12l-5.6 5.6L8.6 19l7-7z' },
chevronLeft: { d: 'M15.4 5l1.4 1.4-5.6 5.6 5.6 5.6-1.4 1.4-7-7z' },
chevrons: { d: 'M5.6 5 4.2 6.4 9.8 12l-5.6 5.6L5.6 19l7-7zm7 0-1.4 1.4 5.6 5.6-5.6 5.6 1.4 1.4 7-7z' },
hourglass: { d: 'M6 2h12v2h-1v3.5L13.5 12l3.5 4.5V20h1v2H6v-2h1v-3.5l3.5-4.5L7 7.5V4H6zm3 2v2.8l3 3.9 3-3.9V4zm0 16h6v-2.8l-3-3.9-3 3.9z', eo: true },
dots: { d: 'M5 10.3a1.7 1.7 0 1 1 0 3.4 1.7 1.7 0 0 1 0-3.4zm7 0a1.7 1.7 0 1 1 0 3.4 1.7 1.7 0 0 1 0-3.4zm7 0a1.7 1.7 0 1 1 0 3.4 1.7 1.7 0 0 1 0-3.4z' },
key: { d: 'M14.5 3a6.5 6.5 0 1 1-2.6 12.5l-1.1 1.1H9v2H7v2H3v-3.6l5.5-5.5A6.5 6.5 0 0 1 14.5 3zm1.5 3a2 2 0 1 0 0 4 2 2 0 0 0 0-4z', eo: true },
search: { d: 'M10 3a7 7 0 0 1 5.6 11.2l5.6 5.6-1.4 1.4-5.6-5.6A7 7 0 1 1 10 3zm0 2a5 5 0 1 0 0 10 5 5 0 0 0 0-10z', eo: true },
info: { d: 'M12 2a10 10 0 1 1 0 20 10 10 0 0 1 0-20zm-1 8v7h2v-7zm0-3v2h2V7z', eo: true },
warn: { d: 'M12 2 1 21h22zm-1 7v6h2V9zm0 7.5v2h2v-2z', eo: true },
play: { d: 'M7 4v16l13-8z' },
edit: { d: 'M3 17.2V21h3.8l11-11-3.8-3.8zM20.7 7.1a1 1 0 0 0 0-1.4l-2.4-2.4a1 1 0 0 0-1.4 0l-1.8 1.8 3.8 3.8z' },
sword: { d: 'M20 3h1v4L10.4 17.6l1.4 1.4-1.4 1.4-2.1-2.1-3.5 3.5-1.4-1.4 3.5-3.5L4.8 14.8l1.4-1.4 1.4 1.4L18 4z' },
shield: { d: 'M12 2 4 5v6c0 5 3.4 9.3 8 11 4.6-1.7 8-6 8-11V5z' },
wifiOff: { d: 'M2 17h3v4H2zm6-4h3v8H8zm6-4h3v12h-3zm6-4h3v16h-3zM2.4 1 23 21.6 21.6 23 1 2.4z' },
expand: { d: 'M3 3h7v2H5v5H3zm11 0h7v7h-2V5h-5zM3 14h2v5h5v2H3zm16 0h2v7h-7v-2h5z' },
collapse: { d: 'M8 3h2v7H3V8h5zm6 0h2v5h5v2h-7zM3 14h7v7H8v-5H3zm11 0h7v2h-5v5h-2z' },
rotate: { d: 'M7 2h8a2 2 0 0 1 2 2v7h-2V4H7v16h4v2H7a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2zm8 12h5a2 2 0 0 1 2 2v4a2 2 0 0 1-2 2h-5a2 2 0 0 1-2-2v-4a2 2 0 0 1 2-2zm0 2v4h5v-4z', eo: true },
book: { d: 'M2 4h7.5A3.5 3.5 0 0 1 12 5.1 3.5 3.5 0 0 1 14.5 4H22v16h-7.5a1.5 1.5 0 0 0-1.5 1.5h-2A1.5 1.5 0 0 0 9.5 20H2zm2 2v12h5.5c.5 0 1 .1 1.5.3V7.5A1.5 1.5 0 0 0 9.5 6zm10.5 0A1.5 1.5 0 0 0 13 7.5v10.8c.5-.2 1-.3 1.5-.3H20V6z', eo: true },
};
/**
* Inline SVG icon.
* @param {{ name: keyof typeof ICONS, size?: string, class?: string, title?: string }} props
*/
export function Icon({ name, size, class: cls, title }) {
const ic = ICONS[name];
if (!ic) return null;
return html`<svg class=${cx('icon', cls)} viewBox="0 0 24 24" width=${size || '1em'} height=${size || '1em'}
aria-hidden=${title ? undefined : 'true'} role=${title ? 'img' : undefined} focusable="false">
${title ? html`<title>${title}</title>` : null}
<path d=${ic.d} fill-rule=${ic.eo ? 'evenodd' : undefined} />
</svg>`;
}
// ---- small primitives ------------------------------------------------------------------------
/** Tiny uppercase techno label (e.g. "RHODES ISLAND // STRONGHOLD PROTOCOL"). */
export function MicroLabel({ children, class: cls, tone }) {
return html`<span class=${cx('micro', tone && `micro--${tone}`, cls)}>${children}</span>`;
}
/**
* Row of chevrons (»»») with an optional flowing animation.
* @param {{ count?: number, dir?: 'right'|'left', tone?: string, animated?: boolean, class?: string }} props
*/
export function Chevrons({ count = 3, dir = 'right', tone = 'mint', animated = true, class: cls }) {
const n = Math.max(1, Math.min(8, count | 0));
return html`<span class=${cx('chevrons', `chevrons--${tone}`, dir === 'left' && 'chevrons--left', animated && 'is-animated', cls)} aria-hidden="true">
${Array.from({ length: n }, (_, i) => html`<svg key=${i} viewBox="0 0 12 20" style=${`--i:${dir === 'left' ? n - 1 - i : i}`}><path d="M0 0h5l7 10-7 10H0l7-10z" /></svg>`)}
</span>`;
}
// ---- Button ------------------------------------------------------------------------------------
/**
* Button.
* @param {{ variant?: 'primary'|'secondary'|'danger'|'amber'|'ice'|'ghost', size?: 'sm'|'md'|'lg'|'xl',
* icon?: string, iconRight?: string, loading?: boolean, disabled?: boolean, block?: boolean, square?: boolean,
* active?: boolean, onClick?: Function, type?: string, title?: string, class?: string, children?: any }} props
*/
export function Button({
variant = 'secondary', size = 'md', icon, iconRight, loading = false, disabled = false, block = false,
square = false, active = false, onClick, type = 'button', title, class: cls, children, ...rest
}) {
const isDisabled = disabled || loading;
return html`<button type=${type} title=${title}
class=${cx('btn', `btn--${variant}`, `btn--${size}`, block && 'btn--block', square && 'btn--square',
active && 'is-active', loading && 'is-loading', cls)}
disabled=${isDisabled} aria-busy=${loading ? 'true' : undefined} aria-pressed=${active ? 'true' : undefined}
onClick=${(e) => { if (!isDisabled && onClick) onClick(e); }} ...${rest}>
${loading ? html`<span class="btn__spin" aria-hidden="true"></span>` : icon ? html`<${Icon} name=${icon} class="btn__icon" />` : null}
${children != null && children !== false ? html`<span class="btn__label">${children}</span>` : null}
${iconRight ? html`<${Icon} name=${iconRight} class="btn__icon btn__icon--right" />` : null}
</button>`;
}
// ---- Panel -------------------------------------------------------------------------------------
/**
* Framed panel with bracket corners, optional header (title + micro label) and actions.
* @param {{ title?: any, micro?: string, tone?: string, brackets?: boolean, glow?: boolean, pad?: boolean,
* actions?: any, class?: string, children?: any, as?: string }} props
*/
export function Panel({ title, micro, tone = 'mint', brackets = true, glow = false, pad = true, actions, class: cls, children, ...rest }) {
return html`<section class=${cx('panel', `panel--${tone}`, brackets && 'brackets', glow && 'panel--glow', !pad && 'panel--flush', cls)} ...${rest}>
${title || micro || actions ? html`<header class="panel__head">
<div class="panel__titles">
${micro ? html`<${MicroLabel}>${micro}<//>` : null}
${title ? html`<h2 class="panel__title">${title}</h2>` : null}
</div>
${actions ? html`<div class="panel__actions">${actions}</div>` : null}
</header>` : null}
<div class="panel__body">${children}</div>
</section>`;
}
// ---- badges & chips ----------------------------------------------------------------------------
/**
* Hexagon badge (prices, costs, funds). Width grows with content.
* @param {{ value?: any, tone?: 'gold'|'mint'|'red'|'amber'|'ice'|'dark'|'discount'|'premium', size?: 'sm'|'md'|'lg',
* icon?: string, class?: string, title?: string }} props
*/
export function HexBadge({ value, tone = 'gold', size = 'md', icon, class: cls, title, children }) {
return html`<span class=${cx('hex', `hex--${tone}`, `hex--${size}`, cls)} title=${title}>
<span class="hex__fill">
${icon ? html`<${Icon} name=${icon} class="hex__icon" />` : null}
<span class="hex__value">${value ?? children}</span>
</span>
</span>`;
}
const ROMAN = ['', 'I', 'II', 'III', 'IV', 'V', 'VI', 'VII', 'VIII', 'IX', 'X'];
/** @param {number} n 1..10 @returns {string} roman numeral ('' when out of range) */
export const roman = (n) => ROMAN[n | 0] || '';
/**
* Official tier chip sprite URL (web manifest `shopCard/img_chess_level_N`, else the local-client
* `ui/battle/img_chess_level_N`), or null (CSS fallback).
* @param {number} t 1..6
*/
export function tierChipUrl(t) {
return uiUrl(data.get('assets'), `shopCard/img_chess_level_${t}`) || localAsset('ui/battle', `img_chess_level_${t}`);
}
/**
* Roman tier chip (I–VI): the official octagon sprite, or a CSS octagon in the official tier colours.
* Golden = elite (精锐) variant (gold glow).
* @param {{ tier: number, golden?: boolean, size?: 'sm'|'md'|'lg', class?: string }} props
*/
export function TierChip({ tier, golden = false, size = 'md', class: cls }) {
const t = Math.max(1, Math.min(6, Number(tier) | 0 || 1));
const src = tierChipUrl(t);
const [bad, setBad] = useState(null);
const img = !!src && bad !== src;
return html`<span class=${cx('tier', `tier--${t}`, `tier--${size}`, golden && 'tier--golden', img && 'tier--img', cls)} aria-label=${`${t}阶`}>
${img ? html`<img src=${src} alt="" draggable=${false} onError=${() => setBad(src)} />` : roman(t)}
</span>`;
}
/**
* Bond disc: round glyph disc with segmented tier ring, stack count and name.
* @param {{ name?: string, icon?: string, count?: number, layers?: number, tier?: number, maxTier?: number,
* active?: boolean, disabled?: boolean, layersDisabled?: boolean, size?: 'sm'|'md'|'lg', showName?: boolean,
* onClick?: Function, class?: string, title?: string }} props
*/
export function BondDisc({
name = '', icon, count, layers, tier = 0, maxTier = 3, active = false, disabled = false, layersDisabled = false,
size = 'md', showName = true, onClick, class: cls, title,
}) {
// Remember which URL failed (a per-URL flag can't race a reset effect).
const [badSrc, setBadSrc] = useState(null);
const imgOk = !!icon && badSrc !== icon;
const n = Math.max(1, Math.min(8, maxTier | 0 || 3));
const filled = Math.max(0, Math.min(n, tier | 0));
const R = 46;
const C = 2 * Math.PI * R;
const gap = n > 1 ? 5 : 0;
const seg = C / n - gap;
const stack = layers ?? count;
const glyph = (name || '?').trim().charAt(0) || '?';
const Tag = onClick ? 'button' : 'div';
return html`<${Tag} type=${onClick ? 'button' : undefined}
class=${cx('bond', `bond--${size}`, active && 'is-active', disabled && 'is-disabled', layersDisabled && 'is-frozen', onClick && 'is-clickable', cls)}
onClick=${onClick} title=${title || name}>
<span class="bond__disc">
<svg class="bond__ring" viewBox="-50 -50 100 100" aria-hidden="true">
${Array.from({ length: n }, (_, i) => html`<circle key=${i} r=${R}
class=${i < filled ? 'on' : 'off'} stroke-dasharray=${`${seg} ${C - seg}`}
stroke-dashoffset=${-(i * C) / n - gap / 2} transform="rotate(-90)" />`)}
</svg>
<span class="bond__core">
${imgOk
? html`<img class="bond__icon" src=${icon} alt="" draggable=${false} onError=${() => setBadSrc(icon)} />`
: html`<span class="bond__glyph">${glyph}</span>`}
</span>
${stack != null ? html`<span class="bond__count">${stack}</span>` : null}
${disabled ? html`<span class="bond__ban" aria-label="禁用"><${Icon} name="close" /></span>` : null}
</span>
${showName && name ? html`<span class="bond__name">${name}</span>` : null}
<//>`;
}
// ---- seven-segment display & countdown ---------------------------------------------------------
const SEG_W = 56;
const SEG_H = 100;
const SEG_T = 12;
const SEG_GAP = 2.2;
const pts = (arr) => arr.map(([x, y]) => `${x},${y}`).join(' ');
function hSeg(y) {
const t = SEG_T / 2;
const x1 = t + SEG_GAP;
const x2 = SEG_W - t - SEG_GAP;
return pts([[x1, y], [x1 + t, y - t], [x2 - t, y - t], [x2, y], [x2 - t, y + t], [x1 + t, y + t]]);
}
function vSeg(x, y1, y2) {
const t = SEG_T / 2;
return pts([[x, y1], [x + t, y1 + t], [x + t, y2 - t], [x, y2], [x - t, y2 - t], [x - t, y1 + t]]);
}
const SEG_POLYS = (() => {
const t = SEG_T / 2;
const mid = SEG_H / 2;
return {
a: hSeg(t), g: hSeg(mid), d: hSeg(SEG_H - t),
f: vSeg(t, t + SEG_GAP, mid - SEG_GAP), e: vSeg(t, mid + SEG_GAP, SEG_H - t - SEG_GAP),
b: vSeg(SEG_W - t, t + SEG_GAP, mid - SEG_GAP), c: vSeg(SEG_W - t, mid + SEG_GAP, SEG_H - t - SEG_GAP),
};
})();
const SEG_ORDER = ['a', 'b', 'c', 'd', 'e', 'f', 'g'];
const DIGIT_SEGS = {
0: 'abcdef', 1: 'bc', 2: 'abged', 3: 'abgcd', 4: 'fgbc', 5: 'afgcd', 6: 'afgedc', 7: 'abc', 8: 'abcdefg', 9: 'abcdfg',
'-': 'g', ' ': '', _: 'd', A: 'abcefg', b: 'cdefg', C: 'adef', d: 'bcdeg', E: 'adefg', F: 'aefg', H: 'bcefg', L: 'def', P: 'abefg', o: 'cdeg', r: 'eg', U: 'bcdef',
};
function SegChar({ ch }) {
if (ch === ':') {
return html`<svg class="seg__char seg__char--colon" viewBox="0 0 20 100" aria-hidden="true">
<rect class="on" x="4" y="26" width="12" height="12" /><rect class="on" x="4" y="62" width="12" height="12" /></svg>`;
}
const on = DIGIT_SEGS[ch] ?? '';
return html`<svg class="seg__char" viewBox=${`-4 -2 ${SEG_W + 8} ${SEG_H + 4}`} aria-hidden="true">
<g transform="skewX(-7) translate(6 0)">
${SEG_ORDER.map((s) => html`<polygon key=${s} points=${SEG_POLYS[s]} class=${on.includes(s) ? 'on' : 'off'} />`)}
</g></svg>`;
}
/**
* Seven-segment text (digits, '-', ':' and a few letters).
* @param {{ text: string|number, tone?: 'mint'|'orange'|'gold'|'red'|'ice', size?: string, class?: string, flicker?: boolean }} props
* size = CSS height (default via --seg-h)
*/
export function SevenSeg({ text, tone = 'mint', size, class: cls, flicker = false }) {
const s = String(text ?? '');
return html`<span class=${cx('seg', `seg--${tone}`, flicker && 'is-flicker', cls)} style=${size ? `--seg-h:${size}` : undefined}
role="img" aria-label=${s}>
${[...s].map((ch, i) => html`<${SegChar} key=${i} ch=${ch} />`)}
</span>`;
}
// Shared ticker so every live countdown re-renders from one interval per period.
const tickers = new Map(); // ms -> { subs:Set, id }
/**
* Re-render the calling component every `ms` milliseconds (0/undefined = never).
* @param {number} ms
*/
export function useTicker(ms) {
const [, force] = useReducer((c) => c + 1, 0);
useEffect(() => {
if (!(ms > 0)) return undefined;
let tk = tickers.get(ms);
if (!tk) {
tk = { subs: new Set(), id: null };
tk.id = setInterval(() => { for (const fn of [...tk.subs]) fn(); }, ms);
tickers.set(ms, tk);
}
tk.subs.add(force);
return () => {
tk.subs.delete(force);
if (tk.subs.size === 0) { clearInterval(tk.id); tickers.delete(ms); }
};
}, [ms]);
}
/**
* Whether a server deadline is set (servers send 0 / null for untimed phases).
* @param {any} deadline
* @returns {boolean}
*/
export const hasDeadline = (deadline) => Number.isFinite(deadline) && deadline > 0;
/**
* Seconds left until a server deadline (ceil, ≥ 0), or null when there is no deadline (null / 0).
* @param {number|null|undefined} deadline server epoch ms
* @returns {number|null}
*/
export function secondsLeft(deadline) {
if (!hasDeadline(deadline)) return null;
return Math.max(0, Math.ceil((deadline - serverNow()) / 1000));
}
/**
* 7-segment countdown with "COUNTDOWN" micro label and a 5-bar gauge; turns orange ≤ warnAt s.
* @param {{ deadline?: number, seconds?: number, total?: number, warnAt?: number, label?: string,
* size?: 'sm'|'md'|'lg', gauge?: boolean, onExpire?: Function, class?: string }} props
* deadline: server epoch ms (live); seconds: static value (overrides deadline); total: seconds for the gauge.
*/
export function Countdown({ deadline, seconds, total, warnAt = 10, label = 'COUNTDOWN', size = 'md', gauge = true, onExpire, class: cls }) {
const live = seconds == null && hasDeadline(deadline);
useTicker(live ? 200 : 0);
const remain = seconds != null && Number.isFinite(Number(seconds)) ? Math.max(0, Math.floor(Number(seconds))) : live ? secondsLeft(deadline) : null;
const warn = remain != null && remain <= warnAt;
const text = remain == null ? '--' : String(Math.min(999, remain)).padStart(2, '0');
const bars = 5;
const lit = remain == null ? 0 : Number.isFinite(total) && total > 0 ? Math.max(0, Math.min(bars, Math.ceil((remain / total) * bars))) : bars;
const expiredFor = useRef(null);
useEffect(() => {
if (!onExpire || !live) return;
if (remain === 0 && expiredFor.current !== deadline) {
expiredFor.current = deadline;
onExpire();
}
});
return html`<div class=${cx('countdown', `countdown--${size}`, warn && 'is-warn', cls)} role="timer" aria-label=${remain == null ? '无倒计时' : `剩余${remain}秒`}>
<div class="countdown__main">
<${SevenSeg} text=${text} tone=${warn ? 'orange' : 'mint'} flicker=${warn && remain > 0} />
${label ? html`<span class="countdown__label">${label}</span>` : null}
</div>
${gauge ? html`<div class="countdown__gauge" aria-hidden="true">
${Array.from({ length: bars }, (_, i) => html`<i key=${i} class=${bars - i <= lit ? 'on' : ''}></i>`)}
</div>` : null}
</div>`;
}
// ---- Modal & dialogs ---------------------------------------------------------------------------
const modalStack = []; // open modals, topmost last: only the topmost reacts to Escape
/**
* Modal dialog (declarative). Esc / backdrop click call onClose.
* @param {{ open: boolean, title?: any, micro?: string, tone?: string, onClose?: Function, actions?: any,
* width?: string, closeOnBackdrop?: boolean, class?: string, children?: any }} props
*/
export function Modal({ open, title, micro, tone = 'mint', onClose, actions, width, closeOnBackdrop = true, class: cls, children }) {
const boxRef = useRef(null);
const closeRef = useRef(onClose);
closeRef.current = onClose;
useEffect(() => {
if (!open) return undefined;
const token = {};
modalStack.push(token);
const prevFocus = typeof document !== 'undefined' ? document.activeElement : null;
const onKey = (e) => {
if (e.key !== 'Escape' || modalStack[modalStack.length - 1] !== token || !closeRef.current) return;
e.stopPropagation();
closeRef.current();
};
window.addEventListener('keydown', onKey);
const t = setTimeout(() => {
const el = boxRef.current?.querySelector('[data-autofocus]') || boxRef.current?.querySelector('button, input');
el?.focus?.();
}, 30);
return () => {
window.removeEventListener('keydown', onKey);
clearTimeout(t);
const i = modalStack.indexOf(token);
if (i >= 0) modalStack.splice(i, 1);
prevFocus?.focus?.();
};
}, [open]);
if (!open) return null;
return html`<div class="modal" role="presentation"
onMouseDown=${(e) => { if (closeOnBackdrop && e.target === e.currentTarget && onClose) onClose(); }}>
<div ref=${boxRef} class=${cx('modal__box', 'brackets', `modal__box--${tone}`, cls)} role="dialog" aria-modal="true"
style=${width ? `width:${width}` : undefined}>
<div class="modal__stripe" aria-hidden="true"></div>
${title || micro ? html`<header class="modal__head">
${micro ? html`<${MicroLabel}>${micro}<//>` : null}
${title ? html`<h2 class="modal__title">${title}</h2>` : null}
</header>` : null}
<div class="modal__body">${children}</div>
${actions ? html`<footer class="modal__actions">${actions}</footer>` : null}
</div>
</div>`;
}
let dialogSeq = 0;
let dialogs = [];
const dialogListeners = new Set();
const emitDialogs = () => { for (const fn of [...dialogListeners]) fn(); };
function openDialog(opts, kind) {
return new Promise((resolve) => {
dialogs = [...dialogs, { id: ++dialogSeq, kind, opts: opts || {}, resolve }];
emitDialogs();
});
}
function closeDialog(id, result) {
const d = dialogs.find((x) => x.id === id);
if (!d) return;
dialogs = dialogs.filter((x) => x.id !== id);
emitDialogs();
try { d.resolve(result); } catch { /* ignore */ }
}
/**
* Imperative confirm dialog (requires <UiHosts/> or <DialogHost/> mounted).
* @param {{ title?: string, text?: any, okText?: string, cancelText?: string, tone?: string, danger?: boolean, micro?: string }} opts
* @returns {Promise<boolean>}
*/
export const confirmDialog = (opts) => openDialog(opts, 'confirm');
/**
* Imperative alert dialog.
* @param {{ title?: string, text?: any, okText?: string, tone?: string, micro?: string }} opts
* @returns {Promise<true>}
*/
export const alertDialog = (opts) => openDialog(opts, 'alert');
/**
* Dismiss every queued imperative dialog (confirm → false, alert → true) — e.g. the server session was reset and the
* screen they belonged to is gone.
*/
export function closeAllDialogs() {
const all = dialogs;
if (!all.length) return;
dialogs = [];
emitDialogs();
for (const d of all) { try { d.resolve(d.kind !== 'confirm'); } catch { /* ignore */ } }
}
/** Renders queued imperative dialogs (one at a time). */
export function DialogHost() {
const [, force] = useReducer((c) => c + 1, 0);
useEffect(() => {
dialogListeners.add(force);
return () => dialogListeners.delete(force);
}, []);
const d = dialogs[0];
if (!d) return null;
const { title = '确认', text, okText = '确认', cancelText = '取消', tone, danger, micro = 'CONFIRMATION' } = d.opts;
const isConfirm = d.kind === 'confirm';
// Enter confirms unless a specific button has focus (then the native click decides).
const onKey = (e) => {
if (e.key === 'Enter' && e.target?.tagName !== 'BUTTON') { e.preventDefault(); closeDialog(d.id, true); }
};
return html`<div onKeyDown=${onKey}>
<${Modal} key=${d.id} open=${true} title=${title} micro=${micro} tone=${tone || (danger ? 'red' : 'mint')}
onClose=${() => closeDialog(d.id, isConfirm ? false : true)}
actions=${html`
${isConfirm ? html`<${Button} variant="secondary" onClick=${() => closeDialog(d.id, false)}>${cancelText}<//>` : null}
<${Button} variant=${danger ? 'danger' : 'primary'} icon="check" data-autofocus onClick=${() => closeDialog(d.id, true)}>${okText}<//>`}>
${typeof text === 'string' ? html`<p class="modal__text">${text}</p>` : text}
<//>
</div>`;
}
// ---- Tooltip -----------------------------------------------------------------------------------
let tipState = null; // { id, content, rect, placement }
let tipSeq = 0;
const tipListeners = new Set();
const setTip = (s) => { tipState = s; for (const fn of [...tipListeners]) fn(); };
/**
* Hover/focus tooltip around its children. Content renders in <TooltipLayer/> (fixed, viewport-clamped).
* @param {{ text: any, placement?: 'top'|'bottom', delay?: number, block?: boolean, class?: string, children?: any }} props
*/
const TOUCH_TIP_MS = 450;
export function Tooltip({ text, placement = 'top', delay = 120, block = false, class: cls, children }) {
const ref = useRef(null);
const idRef = useRef(0);
const timer = useRef(null);
const textRef = useRef(text);
const pressed = useRef(false); // pointer pressed inside: the focus that follows is not a keyboard focus
textRef.current = text;
if (!idRef.current) idRef.current = ++tipSeq;
const show = () => {
clearTimeout(timer.current);
if (textRef.current == null || textRef.current === '') return;
timer.current = setTimeout(() => {
const el = ref.current;
const content = textRef.current; // the text of the render current when the delay ends (a click may change it)
if (!el || !el.isConnected || content == null || content === '') return;
setTip({ id: idRef.current, content, rect: el.getBoundingClientRect(), placement });
}, delay);
};
const hide = () => {
clearTimeout(timer.current);
if (tipState?.id === idRef.current) setTip(null);
};
// A click hides the tooltip until the pointer leaves: the focus it gives the button must not bring it back
// (with the text of the click's closure) over whatever the click opened. Keyboard focus still shows it.
// Touch: a tap never shows it (the compatibility mouseenter that follows a tap is ignored); a long press
// (≥ 450 ms) does, and it goes away shortly after the finger lifts.
const touchAt = useRef(0);
const longTimer = useRef(null);
const onPointerDown = (e) => {
pressed.current = true;
hide();
clearTimeout(longTimer.current);
if (e && e.pointerType && e.pointerType !== 'mouse') {
touchAt.current = Date.now();
longTimer.current = setTimeout(() => { pressed.current = false; show(); }, TOUCH_TIP_MS);
}
};
const onPointerEnd = (e) => {
if (!e || e.pointerType === 'mouse') return;
touchAt.current = Date.now();
clearTimeout(longTimer.current);
if (tipState?.id === idRef.current) { clearTimeout(timer.current); timer.current = setTimeout(hide, 1600); }
};
const onMouseEnter = () => { if (Date.now() - touchAt.current > 900) show(); };
const onFocusIn = () => { if (!pressed.current && Date.now() - touchAt.current > 900) show(); };
const onLeave = () => { pressed.current = false; if (Date.now() - touchAt.current > 900) hide(); };
useEffect(() => () => { clearTimeout(longTimer.current); hide(); }, []);
// Keep the visible tooltip's content fresh when `text` changes (and hide it when text goes away).
useEffect(() => {
if (tipState?.id !== idRef.current) return;
if (text == null || text === '') setTip(null);
else setTip({ ...tipState, content: text });
}, [text]);
return html`<span ref=${ref} class=${cx('tt-anchor', block && 'tt-anchor--block', cls)}
onMouseEnter=${onMouseEnter} onMouseLeave=${onLeave} onFocusIn=${onFocusIn} onFocusOut=${onLeave} onPointerDown=${onPointerDown}
onPointerUp=${onPointerEnd} onPointerCancel=${onPointerEnd} onContextMenu=${(e) => { if (Date.now() - touchAt.current < 900) e.preventDefault(); }}>
${children}
</span>`;
}
/** Renders the active tooltip. Mount once (UiHosts does). */
export function TooltipLayer() {
const [, force] = useReducer((c) => c + 1, 0);
const boxRef = useRef(null);
const [pos, setPos] = useState(null);
useEffect(() => {
tipListeners.add(force);
return () => tipListeners.delete(force);
}, []);
useLayoutEffect(() => {
const el = boxRef.current;
if (!tipState || !el) { if (pos) setPos(null); return; }
const r = tipState.rect;
const b = el.getBoundingClientRect();
const vw = window.innerWidth;
const vh = window.innerHeight;
const m = 8;
let place = tipState.placement;
let top = place === 'bottom' ? r.bottom + m : r.top - b.height - m;
if (place === 'top' && top < m) { place = 'bottom'; top = r.bottom + m; }
if (place === 'bottom' && top + b.height > vh - m) { place = 'top'; top = Math.max(m, r.top - b.height - m); }
let left = r.left + r.width / 2 - b.width / 2;
left = Math.max(m, Math.min(vw - b.width - m, left));
const next = { top: Math.round(top), left: Math.round(left), place, id: tipState.id };
if (!pos || pos.top !== next.top || pos.left !== next.left || pos.id !== next.id || pos.place !== next.place) setPos(next);
});
if (!tipState) return null;
const ready = pos && pos.id === tipState.id;
return html`<div ref=${boxRef} class=${cx('tooltip', ready && 'is-shown', ready && `tooltip--${pos.place}`)} role="tooltip"
style=${ready ? `top:${pos.top}px;left:${pos.left}px` : 'top:-9999px;left:-9999px'}>${tipState.content}</div>`;
}
// ---- ProgressBar, Tabs, Spinner ----------------------------------------------------------------
/**
* Progress bar.
* @param {{ value: number, max?: number, tone?: 'mint'|'gold'|'amber'|'orange'|'red'|'ice', segments?: number,
* label?: any, showValue?: boolean, size?: 'sm'|'md'|'lg', class?: string }} props
*/
export function ProgressBar({ value, max = 100, tone = 'mint', segments = 0, label, showValue = false, size = 'md', class: cls }) {
const m = Number.isFinite(max) && max > 0 ? max : 100;
const v = Number.isFinite(value) ? Math.max(0, Math.min(m, value)) : 0;
const pct = (v / m) * 100;
return html`<div class=${cx('pbar', `pbar--${tone}`, `pbar--${size}`, cls)}>
${label != null || showValue ? html`<div class="pbar__meta">
<span class="pbar__label">${label}</span>
${showValue ? html`<span class="pbar__value">${Math.round(v)}<small>/${Math.round(m)}</small></span>` : null}
</div>` : null}
<div class="pbar__track" role="progressbar" aria-valuemin="0" aria-valuemax=${m} aria-valuenow=${v}
style=${segments > 1 ? `--segs:${segments}` : undefined}>
<div class="pbar__fill" style=${`width:${pct}%`}></div>
${segments > 1 ? html`<div class="pbar__segs"></div>` : null}
</div>
</div>`;
}
/**
* Tabs (controlled).
* @param {{ items: {id: string, label: any, badge?: any, disabled?: boolean}[], value: string,
* onChange: (id: string) => void, size?: 'sm'|'md', class?: string }} props
*/
export function Tabs({ items = [], value, onChange, size = 'md', class: cls }) {
const onKey = (e) => {
if (e.key !== 'ArrowRight' && e.key !== 'ArrowLeft') return;
const enabled = items.filter((i) => !i.disabled);
const idx = enabled.findIndex((i) => i.id === value);
if (idx < 0 || enabled.length === 0) return;
const next = enabled[(idx + (e.key === 'ArrowRight' ? 1 : enabled.length - 1)) % enabled.length];
onChange?.(next.id);
e.preventDefault();
};
return html`<div class=${cx('tabs', `tabs--${size}`, cls)} role="tablist" onKeyDown=${onKey}>
${items.map((it) => html`<button key=${it.id} type="button" role="tab" aria-selected=${it.id === value ? 'true' : 'false'}
tabIndex=${it.id === value ? 0 : -1} disabled=${it.disabled}
class=${cx('tabs__tab', it.id === value && 'is-active')} onClick=${() => !it.disabled && onChange?.(it.id)}>
<span>${it.label}</span>
${it.badge != null ? html`<span class="tabs__badge">${it.badge}</span>` : null}
</button>`)}
</div>`;
}
/**
* Loading spinner (rotating hexagon).
* @param {{ size?: 'sm'|'md'|'lg', label?: any, tone?: string, class?: string }} props
*/
export function Spinner({ size = 'md', label, tone = 'mint', class: cls }) {
return html`<span class=${cx('spinner', `spinner--${size}`, `spinner--${tone}`, cls)} role="status">
<svg viewBox="0 0 50 50" aria-hidden="true">
<polygon class="spinner__track" points="25,3 44,14 44,36 25,47 6,36 6,14" />
<polygon class="spinner__arc" points="25,3 44,14 44,36 25,47 6,36 6,14" />
</svg>
${label ? html`<span class="spinner__label">${label}</span>` : html`<span class="sr-only">加载中</span>`}
</span>`;
}
// ---- Avatar frame ------------------------------------------------------------------------------
const SEAT_HUES = [162, 196, 38, 280];
/**
* Square avatar frame with bracket corners. Falls back to a glyph (first letter / robot).
* @param {{ name?: string, src?: string, size?: 'sm'|'md'|'lg'|'xl', seat?: number, host?: boolean, bot?: boolean,
* self?: boolean, ready?: boolean, offline?: boolean, dead?: boolean, empty?: boolean, class?: string }} props
*/
export function AvatarFrame({ name = '', src, size = 'md', seat = 0, host, bot, self, ready, offline, dead, empty, class: cls }) {
const [badSrc, setBadSrc] = useState(null);
const imgOk = !!src && badSrc !== src;
const hue = SEAT_HUES[((seat | 0) % 4 + 4) % 4];
const glyph = [...(name || '').trim()][0] || '?';
return html`<div class=${cx('avatar', `avatar--${size}`, 'brackets', host && 'is-host', bot && 'is-bot', self && 'is-self',
ready && 'is-ready', offline && 'is-offline', dead && 'is-dead', empty && 'is-empty', cls)} style=${`--seat-hue:${hue}`}>
<div class="avatar__img">
${empty ? html`<${Icon} name="plus" class="avatar__empty" />`
: imgOk ? html`<img src=${src} alt="" draggable=${false} onError=${() => setBadSrc(src)} />`
: bot ? html`<${Icon} name="robot" class="avatar__bot" />`
: html`<span class="avatar__glyph">${glyph}</span>`}
</div>
${host ? html`<span class="avatar__badge avatar__badge--host" title="创建者"><${Icon} name="crown" /></span>` : null}
${self ? html`<span class="avatar__badge avatar__badge--self" title="你"><${Icon} name="user" /></span>` : null}
${bot && !empty ? html`<span class="avatar__tag">AI</span>` : null}
</div>`;
}
// ---- PhaseBanner -------------------------------------------------------------------------------
/**
* Chevron phase banner (full-width strip that wipes in horizontally).
* @param {{ title: any, sub?: any, micro?: string, tone?: 'mint'|'orange'|'red'|'gold'|'ice', mode?: 'overlay'|'inline',
* duration?: number, onDone?: Function, class?: string }} props
* mode 'overlay' = fixed over the screen; duration (ms) > 0 auto-hides then calls onDone. Re-key to replay.
*/
export function PhaseBanner({ title, sub, micro, tone = 'mint', mode = 'inline', duration = 0, onDone, class: cls }) {
const [leaving, setLeaving] = useState(false);
const [gone, setGone] = useState(false);
useEffect(() => {
if (!(duration > 0)) return undefined;
const t1 = setTimeout(() => setLeaving(true), duration);
const t2 = setTimeout(() => { setGone(true); onDone?.(); }, duration + 260);
return () => { clearTimeout(t1); clearTimeout(t2); };
}, [duration]);
if (gone) return null;
return html`<div class=${cx('pbanner', `pbanner--${tone}`, `pbanner--${mode}`, leaving && 'is-leaving', cls)} role="status" aria-live="polite">
<div class="pbanner__band">
<${Chevrons} count=${4} tone=${tone} class="pbanner__chev pbanner__chev--l" />
<div class="pbanner__text">
${micro ? html`<span class="pbanner__micro">${micro}</span>` : null}
<span class="pbanner__title">${title}</span>
${sub ? html`<span class="pbanner__sub">${sub}</span>` : null}
</div>
<${Chevrons} count=${4} tone=${tone} dir="left" class="pbanner__chev pbanner__chev--r" />
</div>
</div>`;
}
// ---- domain helpers ----------------------------------------------------------------------------
/**
* Stable 4-digit "博士 #1234" tag for a player id (FNV-1a hash; ids themselves are opaque strings).
* @param {any} id
* @returns {string}
*/
export function doctorNo(id) {
let h = 2166136261;
for (const ch of String(id ?? '')) {
h ^= ch.codePointAt(0);
h = Math.imul(h, 16777619) >>> 0;
}
return String(h % 10000).padStart(4, '0');
}
/**
* Latency pill ("58ms"), coloured by research 06 §3.4 tiers (<60 mint, <200 amber, else red).
* @param {{ ms?: number|null, online?: boolean, class?: string }} props
*/
export function PingPill({ ms, online = true, class: cls }) {
const ok = online && Number.isFinite(ms);
const tier = !ok ? 'off' : ms < 60 ? 'low' : ms < 200 ? 'medium' : 'high';
return html`<span class=${cx('ping', `ping--${tier}`, cls)} title=${ok ? `当前延迟 ${ms}ms` : '未连接'}>
<${Icon} name=${ok ? 'signal' : 'wifiOff'} class="ping__icon" />
<span class="ping__value">${ok ? Math.min(9999, Math.round(ms)) : '--'}</span><span class="ping__unit">ms</span>
</span>`;
}
/**
* Official difficulty glyph (data/assets.json `ui['modeIcon/mode_<difficulty>_icon']`: rook, rook +
* swords, castle + swords, castle + warning) — the SVG rook while assets are missing or fail to load.
* Rendered with class "icon" so it sizes like an <Icon/>.
* @param {{ difficulty: string, class?: string }} props
*/
export function DifficultyIcon({ difficulty, class: cls }) {
useData('assets');
const [badSrc, setBadSrc] = useState(null);
const ui = DIFFICULTY_NAMES[difficulty] ? data.get('assets')?.ui : null;
const src = ui && typeof ui === 'object' ? ui[`modeIcon/mode_${String(difficulty).toLowerCase()}_icon`] : null;
if (typeof src === 'string' && src && badSrc !== src) {
return html`<img class=${cx('icon', 'dicon', cls)} src=${src} alt="" draggable=${false} onError=${() => setBadSrc(src)} />`;
}
return html`<${Icon} name="rook" class=${cls} />`;
}
/**
* Difficulty tag: coloured glyph + name (标准/险境/绝境/终极模拟).
* @param {{ difficulty: string, size?: 'sm'|'md'|'lg', class?: string, code?: string }} props
*/
export function DifficultyTag({ difficulty, size = 'md', class: cls, code }) {
const name = DIFFICULTY_NAMES[difficulty] || difficulty || '—';
const color = DIFFICULTY_COLORS[difficulty] || 'var(--text-lo)';
return html`<span class=${cx('dtag', `dtag--${size}`, cls)} style=${`--d-color:${color}`}>
<${DifficultyIcon} difficulty=${difficulty} class="dtag__icon" />
<span class="dtag__name">${name}</span>
${code ? html`<span class="dtag__code">${code}</span>` : null}
</span>`;
}
/**
* Bracketed text input.
* @param {{ label?: any, micro?: string, value: string, onInput: (v: string) => void, onEnter?: Function,
* placeholder?: string, maxLength?: number, transform?: (v: string) => string, autoFocus?: boolean,
* disabled?: boolean, size?: 'md'|'lg'|'code', icon?: string, class?: string, inputRef?: any, name?: string }} props
*/
export function TextField({ label, micro, value, onInput, onEnter, placeholder, maxLength, transform, autoFocus, disabled, size = 'md', icon, class: cls, inputRef, name, hint, invalid }) {
const localRef = useRef(null);
const ref = inputRef || localRef;
const composing = useRef(false);
useEffect(() => {
if (autoFocus) setTimeout(() => ref.current?.focus(), 60);
}, []);
const handle = (e) => {
let v = e.currentTarget.value;
if (!composing.current && transform) {
const t = transform(v);
if (t !== v) { v = t; e.currentTarget.value = t; }
}
onInput?.(v);
};
const id = useMemo(() => `tf-${Math.random().toString(36).slice(2, 8)}`, []);
return html`<label class=${cx('field', `field--${size}`, disabled && 'is-disabled', invalid && 'is-invalid', cls)} for=${id}>
${label || micro ? html`<span class="field__label">${label}${micro ? html`<span class="micro">${micro}</span>` : null}</span>` : null}
<span class="field__box brackets">
${icon ? html`<${Icon} name=${icon} class="field__icon" />` : null}
<input id=${id} ref=${ref} class="field__input" name=${name} value=${value} placeholder=${placeholder}
maxLength=${maxLength} disabled=${disabled} autocomplete="off" spellcheck=${false}
onInput=${handle}
oncompositionstart=${() => { composing.current = true; }}
oncompositionend=${(e) => { composing.current = false; handle(e); }}
onKeyDown=${(e) => { if (e.key === 'Enter' && !e.isComposing && !composing.current) onEnter?.(e); }} />
</span>
${hint ? html`<span class="field__hint">${hint}</span>` : null}
</label>`;
}
/** Global overlay hosts for imperative dialogs and tooltips. Mount once near the root. */
export function UiHosts() {
return html`<${Fragment}><${DialogHost} /><${TooltipLayer} /><//>`;
}
+53
View File
@@ -0,0 +1,53 @@
// Connection banner (global chrome, mounted once by main.js): reconnecting / closed / rejected-hello states with
// the matching action, and "正在同步同盟状态…" while a resumed session waits for its room/match state. Outside a
// match it sits at the bottom centre; in a match (html.sp-in-match, set by the match screen) it moves under the top bar
// so it never covers the combat view switcher or the shop bar. While it shows, html.sp-conn moves the toasts below it
// (classes instead of CSS :has(), which Firefox ESR / Safari < 15.4 lack).
import { html, Button, Icon, useTicker } from './components.js';
import { net, CLIENT_ERR_TEXT } from '../net.js';
import { useStore, shallowEqual } from '../store.js';
import { useDocClass } from './device.js';
/** Whether the banner shows for this connection state (mirrors the early returns below). */
export function bannerVisible(conn, entered, restoring) {
if (!entered || !conn) return false;
if (conn.status === 'online') return !!restoring;
if (!conn.everOnline && (conn.status === 'connecting' || conn.status === 'handshaking' || conn.status === 'idle')) return false;
return true;
}
export function ConnectionBanner() {
const conn = useStore((s) => s.connection, shallowEqual);
const entered = useStore((s) => s.session.entered);
const restoring = useStore((s) => s.ui.restoring);
useTicker(conn.status === 'reconnecting' ? 500 : 0);
useDocClass('sp-conn', bannerVisible(conn, entered, restoring));
if (!entered) return null;
if (conn.status === 'online' && !restoring) return null;
if (conn.status === 'online' && restoring) {
return html`<div class="conn-banner" role="status"><${Icon} name="refresh" /><span>正在同步同盟状态…</span></div>`;
}
if (!conn.everOnline && (conn.status === 'connecting' || conn.status === 'handshaking' || conn.status === 'idle')) return null;
const secs = conn.retryAt ? Math.max(0, Math.ceil((conn.retryAt - Date.now()) / 1000)) : 0;
const replaced = conn.status === 'closed' && conn.lastError?.code === 'REPLACED';
const rejected = conn.status === 'connected' && !!conn.lastError; // hello refused (version, server full…)
const versionMismatch = rejected && conn.lastError.text === CLIENT_ERR_TEXT.VERSION;
// Short transitional states (a rename re-sends hello on the live socket) only show if they linger.
const transient = conn.status === 'connecting' || conn.status === 'handshaking' || (conn.status === 'connected' && !rejected);
const text = conn.status === 'reconnecting'
? '与服务器的连接已中断,正在重连'
: replaced ? '该身份已在其他页面登录'
: conn.status === 'closed' ? '连接已关闭'
: rejected ? conn.lastError.text : '正在连接服务器';
const action = conn.status === 'reconnecting' ? { label: '立即重连', run: () => net.retryNow() }
: conn.status === 'closed' ? { label: replaced ? '在此页面继续' : '重新连接', run: () => net.connect() }
: versionMismatch ? { label: '刷新页面', run: () => location.reload() }
: rejected ? { label: '重试', run: () => net.reconnectNow() } : null;
return html`<div class=${`conn-banner${transient ? ' conn-banner--soft' : ''}`} role="alert">
<${Icon} name="wifiOff" />
<span>${text}</span>
${conn.status === 'reconnecting' ? html`<span class="conn-banner__sub">第 ${conn.attempt} 次 · ${secs}s</span>` : null}
${action ? html`<${Button} size="sm" variant="secondary" icon="refresh" onClick=${action.run}>${action.label}<//>` : null}
</div>`;
}
+348
View File
@@ -0,0 +1,348 @@
// Detail panel (click / right-click a piece, shop card, bond member, battle unit or previewed enemy):
// operators — portrait, name, tier, elite, class/subclass and, right under them in the header's right column (no
// scrolling, user playtest #2 item 9), the unit's bonds (阵营 / 盟约: icon, name, member count / next threshold,
// reached tier, active state — tap one for its popup); then stats, range mini-map, skill (the one chosen in the loadout, DESIGN §16: icon, SP info, rich description,
// 已调配 when not the default), elite module (模组: official type icon from the local-client art, else its letter), talents, 特质 (garrison), equipped items; items — icon, tier,
// effect; tokens; enemies — stats, rank, faction tags, abilities. Selling / destroying is the underframe's job in the
// match (research 09 §5, ui/underframe.js): the panel's own 出售 / 销毁 buttons only render for callers that pass
// `editable` + handlers. `side` 'right' docks the panel at the right edge (the game screen picks the side away from a
// selected unit's underframe, gameLogic panelSide).
import { html, Icon, TierChip, MicroLabel, Button, confirmDialog } from './components.js';
import { Img, RichText, UnitThumb, BondGlyph, GIcon } from './gameComponents.js';
import { attackInterval, rangeGridBox, fmtNum, tileKey, chessLoadout, nextThreshold, bondTier } from './gameLogic.js';
import { chessPortraitUrl, skillIconUrl, skillRecordIconUrl, profIconUrl, subProfIconUrl, itemIconUrl, enemyIconUrl, tokenAvatarUrl, factionIconUrl, uiUrl, moduleTypeIconUrl } from './assetUrls.js';
import { data } from '../data.js';
import { attackRangeGrid } from '../../../shared/loadoutRecord.js';
import { moduleBadge } from './loadoutModel.js';
const cx = (...p) => p.flat().filter(Boolean).join(' ');
const PROF_NAME = { PIONEER: '先锋', WARRIOR: '近卫', TANK: '重装', SNIPER: '狙击', CASTER: '术师', MEDIC: '医疗', SUPPORT: '辅助', SPECIAL: '特种', TOKEN: '召唤物' };
const SP_TYPE = { INCREASE_WITH_TIME: '自动回复', INCREASE_WHEN_ATTACK: '攻击回复', INCREASE_WHEN_TAKEN_DAMAGE: '受击回复', ON_DEPLOY: '被动' };
const SKILL_TYPE = { MANUAL: '自动触发', AUTO: '自动触发', PASSIVE: '被动' };
const EVENT_ICON = { IN_BATTLE: 's_icon_battle', SERVER_GAIN: 's_icon_bond', SERVER_PREP_START: 's_icon_support', SERVER_PREP_FIN: 's_icon_support', SERVER_CHESS_SOLD: 's_icon_gold', SERVER_PRICE: 's_icon_gold', SERVER_REFRESH_SHOP: 's_icon_gold' };
const RANK = { NORMAL: '普通', ELITE: '精英', BOSS: '领袖' };
const DMG = { phys: '物理', arts: '法术', heal: '治疗', true: '真实', none: '无' };
/** Mini range map. */
export function RangeGrid({ grid, class: cls }) {
const box = rangeGridBox(grid);
if (!box.cells.size) return html`<span class="t-dim">—</span>`;
const cells = [];
for (let r = box.r0; r > box.r0 - box.rows; r--) {
for (let c = box.c0; c < box.c0 + box.cols; c++) {
const self = r === 0 && c === 0;
cells.push(html`<i key=${`${r},${c}`} class=${cx(box.cells.has(tileKey(r, c)) && 'on', self && 'self')}></i>`);
}
}
return html`<div class=${cx('rgrid', cls)} style=${`grid-template-columns:repeat(${box.cols}, var(--rg))`} aria-label="攻击范围">${cells}</div>`;
}
function Stat({ k, v, sub }) {
return html`<div class="dstat"><span class="dstat__k">${k}</span><b class="dstat__v num">${v}</b>${sub ? html`<small>${sub}</small>` : null}</div>`;
}
function Section({ title, micro, children, class: cls }) {
return html`<section class=${cx('dsec', cls)}>
<h4 class="dsec__title">${title}${micro ? html`<${MicroLabel}>${micro}</${MicroLabel}>` : null}</h4>
${children}
</section>`;
}
function ItemRow({ itemId }) {
const it = data.lookup('items', itemId);
return html`<div class="ditem">
<${UnitThumb} kind="item" id=${itemId} size="sm" />
<div class="ditem__text"><b>${it?.name || itemId}</b><${RichText} text=${it?.descRaw || it?.desc || ''} class="ditem__desc" /></div>
</div>`;
}
/**
* The unit's bonds right under the header: icon, name, the player's member count / next threshold, reached tier.
* @param {{ bondIds: string[], bonds?: any[], onBond?: (bondId: string) => void }} props
*/
export function BondChips({ bondIds, bonds = [], onBond = null }) {
const ids = Array.isArray(bondIds) ? bondIds.filter((b) => typeof b === 'string') : [];
if (!ids.length) return null;
const mine = new Map((Array.isArray(bonds) ? bonds : []).filter((b) => b && typeof b.bondId === 'string').map((b) => [b.bondId, b]));
return html`<div class="dbonds dbonds--top" role="list" aria-label="所属盟约">
${ids.map((id) => {
const rec = data.lookup('bonds', id);
const e = mine.get(id) || null;
const th = Array.isArray(e?.thresholds) && e.thresholds.length ? e.thresholds : Array.isArray(rec?.thresholds) ? rec.thresholds : [];
const count = Number.isFinite(e?.count) ? e.count : 0;
const tier = Number.isFinite(e?.tier) ? e.tier : bondTier(count, th, rec?.maxCount ?? null);
const active = e ? !!e.active : tier > 0;
const next = nextThreshold(count, th);
const cap = next ?? th[th.length - 1] ?? null;
const label = `${rec?.name || id}:在场 ${count}${cap != null ? `/${cap}` : ''}${active ? `,已激活 ${tier} 阶` : ',未激活'}`;
const body = html`
<${BondGlyph} bondId=${id} class="dbond__icon" />
<span class="dbond__name">${rec?.name || id}</span>
<span class=${cx('dbond__count', 'num', next == null && count > 0 && 'is-max')}>${count}${cap != null ? html`<small>/${cap}</small>` : null}</span>
${th.length ? html`<span class="dbond__tiers" aria-hidden="true">${th.map((_, i) => html`<i key=${i} class=${i < tier ? 'on' : ''}></i>`)}</span>` : null}`;
return onBond
? html`<button key=${id} type="button" role="listitem" class=${cx('dbond', active && 'is-active', rec?.isCore && 'is-core')} title=${label} aria-label=${label}
data-bond=${id} onClick=${() => onBond(id)}>${body}</button>`
: html`<span key=${id} role="listitem" class=${cx('dbond', active && 'is-active', rec?.isCore && 'is-core')} title=${label} data-bond=${id}>${body}</span>`;
})}
</div>`;
}
/**
* 特性 text of the record the unit fights with (DESIGN §16: `lo.record` = the chosen module's traitOverride, or the
* no-module traitBase for 不装备 — data `trait` is the default module's).
*/
function traitText(c, golden, lo) {
const t = (lo?.record || c).trait || {};
const base = t.descRaw || t.desc || '';
if (!golden || lo?.record !== c) return base;
return t.moduleDescRaw || base;
}
function ChessDetail({ chess, piece, unit, snapHp, editable, onSell, bonds, loadout, onBond }) {
const m = data.get('assets');
const lo = chessLoadout(chess, loadout, (id) => data.lookup('chess', id));
const c = chess;
// stats / talents the unit fights with: the chosen module's (or none — statsBase) for an elite (DESIGN §16)
const fr = lo?.record || c;
const s = fr.stats || {};
const golden = !!(c.isGolden || piece?.golden);
const interval = attackInterval(s.bat, s.aspd);
const sk = lo?.skill || c.skill || null;
// a chosen skill the manifest has no icon for (only the default skills' icons are fetched): its slot letter
const skIcon = sk && lo && !lo.defaultSkill ? skillRecordIconUrl(m, sk, { empty: false }) : skillIconUrl(m, c);
const skSlot = sk && Number.isInteger(sk.index) ? `S${sk.index + 1}` : null;
const garrison = Array.isArray(c.garrisonIds) && c.garrisonIds[0] ? data.lookup('garrisons', c.garrisonIds[0]) : null;
const items = Array.isArray(piece?.items) ? piece.items : [];
const sell = c.sellPrice ?? 1;
return html`
<div class="dhead">
<div class=${cx('dhead__art', golden && 'is-golden', `dhead__art--t${c.tier}`)}>
<${Img} src=${chessPortraitUrl(m, c)} fallback=${html`<${UnitThumb} kind="chess" id=${c.chessId} size="lg" />`} />
</div>
<div class="dhead__info">
<div class="dhead__chips">
<${TierChip} tier=${c.tier} golden=${golden} size="lg" />
${golden ? html`<span class="dtag-elite">精锐</span>` : null}
${piece?.kind === 'token' ? html`<span class="dtag-token">召唤物</span>` : null}
</div>
<h3 class="dhead__name">${c.name}</h3>
<span class="dhead__en">${c.appellation || ''}</span>
<div class="dhead__class">
<${Img} src=${profIconUrl(m, c.profession)} class="dhead__prof" />
<span>${PROF_NAME[c.profession] || c.profession || ''}</span>
<i class="sep"></i>
<${Img} src=${subProfIconUrl(m, c)} class="dhead__sub" />
<span>${c.subProfessionName || ''}</span>
<span class="dhead__pos">${c.position === 'MELEE' ? '近战位' : '远程位'}</span>
</div>
${snapHp ? html`<div class="dhp"><i style=${`width:${Math.max(0, Math.min(100, (snapHp.hp / Math.max(1, snapHp.max)) * 100))}%`}></i><span class="num">${fmtNum(snapHp.hp)} / ${fmtNum(snapHp.max)}</span></div>` : null}
<${BondChips} bondIds=${c.bonds} bonds=${bonds} onBond=${onBond} />
</div>
</div>
${c.trait?.desc ? html`<p class="dtrait"><${Icon} name="info" /><${RichText} text=${traitText(c, golden, lo)} /></p>` : null}
<div class="dstats-wrap">
<div class="dstats">
<${Stat} k="生命上限" v=${fmtNum(s.maxHp)} />
<${Stat} k="攻击" v=${fmtNum(s.atk)} />
<${Stat} k="防御" v=${fmtNum(s.def)} />
<${Stat} k="法术抗性" v=${s.res ?? 0} />
<${Stat} k="攻击间隔" v=${interval ? `${interval.toFixed(2)}s` : '—'} />
<${Stat} k="阻挡数" v=${s.blockCnt ?? '—'} />
<${Stat} k="部署费用" v=${s.cost ?? '—'} />
<${Stat} k="再部署" v=${s.respawnTime != null ? `${s.respawnTime}s` : '—'} />
</div>
<div class="drange"><span class="dstat__k">攻击范围</span><${RangeGrid} grid=${attackRangeGrid(fr) || c.rangeGrid} /></div>
</div>
${sk ? html`<${Section} title="技能" micro="SKILL" class="dsec--skill">
<div class="dskill" data-skill=${sk.skillId || ''}>
<${Img} src=${skIcon} class="dskill__icon" fallback=${html`<span class="dskill__icon dskill__icon--empty">${skSlot ? html`<b class="num">${skSlot}</b>` : null}</span>`} />
<div class="dskill__meta">
<b class="dskill__name">${skSlot && (lo?.choices || 0) > 1 ? html`<span class="dskill__slot num" title=${`技能 ${skSlot}`}>${skSlot}</span>` : null}${sk.name}${lo && !lo.defaultSkill ? html`<span class="dtag-loadout" title="干员调配中选择的技能">已调配</span>` : null}</b>
<div class="dskill__tags">
<span class="dsp dsp--${sk.spType === 'INCREASE_WHEN_ATTACK' ? 'atk' : sk.spType === 'INCREASE_WHEN_TAKEN_DAMAGE' ? 'def' : 'time'}">${SP_TYPE[sk.spType] || '技力'}</span>
<span class="dsp dsp--trig">${SKILL_TYPE[sk.skillType] || '自动触发'}</span>
${sk.spType !== 'ON_DEPLOY' && sk.skillType !== 'PASSIVE' ? html`<span class="dsp__num"><${GIcon} name="bolt" />初始 <b class="num">${sk.initSp ?? 0}</b> · 消耗 <b class="num">${sk.spCost ?? 0}</b></span>` : null}
${sk.duration > 0 ? html`<span class="dsp__num">持续 <b class="num">${sk.duration}</b>s</span>` : null}
${sk.maxChargeTime > 1 ? html`<span class="dsp__num">充能 <b class="num">${sk.maxChargeTime}</b></span>` : null}
</div>
</div>
</div>
<${RichText} as="p" text=${sk.descRaw || sk.desc} class="dtext" />
<//>` : null}
${golden && lo?.module ? html`<${Section} title="模组" micro="MODULE" class="dsec--module">
<div class=${cx('dmodule', lo.module.none && 'is-none')} data-module=${lo.module.id}>
${!lo.module.none && lo.module.typeName ? html`<span class="dmodule__icon" data-type=${lo.module.typeName}>
<${Img} src=${moduleTypeIconUrl(data.get('local'), lo.module.typeName)} fallback=${html`<b class="num">${moduleBadge(lo.module)}</b>`} /></span>` : null}
<b class="dmodule__name">${lo.module.name}</b>
${lo.module.typeName ? html`<span class="dmodule__type">${lo.module.typeName}</span>` : null}
${!lo.defaultModule ? html`<span class="dtag-loadout" title="干员调配中选择的模组">已调配</span>` : null}
</div>
<//>` : null}
${Array.isArray(fr.talents) && fr.talents.some((t) => t && t.name && !t.hidden) ? html`<${Section} title="天赋" micro="TALENT">
${fr.talents.filter((t) => t && t.name && !t.hidden).map((t, i) => html`<div key=${i} class="dtalent"><b>${t.name}</b><${RichText} text=${t.descRaw || t.desc} class="dtext" /></div>`)}
<//>` : null}
${garrison ? html`<${Section} title="特质" micro="GARRISON">
<div class="dgarrison">
<span class="dgarrison__type">
<${Img} src=${uiUrl(m, `garrisonTypeIcon/${EVENT_ICON[garrison.eventType] || 's_icon_bond'}`)} class="dgarrison__icon" />
${garrison.eventTypeDesc || ''}
</span>
<${RichText} as="p" text=${garrison.descRaw || garrison.desc} class="dtext" />
</div>
<//>` : null}
${piece?.kind === 'chess' ? html`<${Section} title="装备" micro=${`EQUIP ${items.length}/2`}>
${items.length ? items.map((it) => html`<${ItemRow} key=${it.uid} itemId=${it.id} />`) : html`<p class="t-dim dempty">拖拽装备至该干员以配发(最多 2 件)</p>`}
<//>` : null}
${piece && editable && piece.kind !== 'item' ? html`<div class="dactions">
<${Button} variant="amber" icon="close" class="dpanel__sell" onClick=${() => onSell(piece, c)}>出售<span class="dsell num">+${sell}</span><//>
</div>` : null}`;
}
function ItemDetail({ item, piece, editable, onDestroy }) {
const m = data.get('assets');
return html`
<div class="dhead dhead--item">
<div class=${cx('dhead__icon', item.isGolden && 'is-golden')}><${Img} src=${itemIconUrl(m, item)} fallback=${html`<${GIcon} name="bolt" />`} /></div>
<div class="dhead__info">
<div class="dhead__chips"><${TierChip} tier=${item.tier} golden=${item.isGolden} size="lg" />${item.isGolden ? html`<span class="dtag-elite">进阶</span>` : null}
<span class="dtag-kind">${item.itemType === 'MAGIC' ? '奇术' : '装备'}</span></div>
<h3 class="dhead__name">${item.name}</h3>
${item.flavor ? html`<span class="dhead__flavor">${item.flavor}</span>` : null}
</div>
</div>
<${Section} title="效果" micro="EFFECT"><${RichText} as="p" text=${item.descRaw || item.desc} class="dtext" /><//>
${item.itemType === 'MAGIC'
? html`<p class="dhint"><${Icon} name="info" />将其拖拽至战场上的格子使用</p>`
: html`<p class="dhint"><${Icon} name="info" />拖拽至干员身上进行配发(每名干员最多 2 件,配发后无法取下)${item.mergeable ? ';2 件相同装备自动合成进阶装备' : ''}</p>`}
${piece && editable ? html`<div class="dactions"><${Button} variant="danger" onClick=${() => onDestroy(piece, item)}>销毁道具<//></div>` : null}`;
}
function EnemyDetail({ enemy, snapHp, count }) {
const m = data.get('assets');
const s = enemy.stats || {};
const types = Array.isArray(enemy.acTypes) ? enemy.acTypes : enemy.acType ? [enemy.acType] : [];
const factions = data.get('factions')?.types || {};
const imm = Object.entries(s.immunities || {}).filter(([, v]) => v).map(([k]) => ({ stun: '晕眩', silence: '沉默', sleep: '沉睡', frozen: '冻结', levitate: '浮空' }[k] || k));
const interval = attackInterval(s.bat, s.aspd);
return html`
<div class="dhead dhead--enemy">
<div class=${cx('dhead__icon', 'dhead__icon--enemy', enemy.rank === 'BOSS' && 'is-boss', enemy.rank === 'ELITE' && 'is-elite')}>
<${Img} src=${enemyIconUrl(m, enemy.key)} fallback=${html`<${GIcon} name="skull" />`} />
</div>
<div class="dhead__info">
<div class="dhead__chips">
<span class=${cx('drank', `drank--${(enemy.rank || 'NORMAL').toLowerCase()}`)}>${RANK[enemy.rank] || '普通'}</span>
<span class="dtag-kind">${s.motion === 'FLY' ? '空中' : '地面'}</span>
${count ? html`<span class="dtag-kind num">×${count}</span>` : null}
</div>
<h3 class="dhead__name">${enemy.name}</h3>
<div class="dfactions">${types.map((t) => html`<span key=${t} class="dfaction"><${Img} src=${factionIconUrl(m, factions[t]?.icon)} />${factions[t]?.name || t}</span>`)}</div>
${snapHp ? html`<div class="dhp dhp--enemy"><i style=${`width:${Math.max(0, Math.min(100, (snapHp.hp / Math.max(1, snapHp.max)) * 100))}%`}></i><span class="num">${fmtNum(snapHp.hp)} / ${fmtNum(snapHp.max)}</span></div>` : null}
</div>
</div>
<div class="dstats">
<${Stat} k="生命上限" v=${fmtNum(s.maxHp)} />
<${Stat} k="攻击" v=${fmtNum(s.atk)} sub=${DMG[s.dmgType] || ''} />
<${Stat} k="防御" v=${fmtNum(s.def)} />
<${Stat} k="法术抗性" v=${s.res ?? 0} />
<${Stat} k="移动速度" v=${s.moveSpeed ?? '—'} />
<${Stat} k="攻击间隔" v=${interval ? `${interval.toFixed(1)}s` : '—'} />
<${Stat} k="攻击范围" v=${s.rangeRadius > 0 ? s.rangeRadius : '近战'} />
<${Stat} k="目标价值" v=${s.lpr ?? 1} />
</div>
${imm.length ? html`<p class="dhint"><${Icon} name="shield" />免疫:${imm.join('、')}</p>` : null}
${Array.isArray(enemy.abilities) && enemy.abilities.length ? html`<${Section} title="能力" micro="ABILITIES">
<ul class="dabil">${enemy.abilities.map((a, i) => html`<li key=${i}><${RichText} text=${typeof a === 'string' ? a : a.textRaw || a.text} /></li>`)}</ul>
<//>` : enemy.descRaw || enemy.desc ? html`<${Section} title="说明"><${RichText} as="p" text=${enemy.descRaw || enemy.desc} class="dtext" /><//>` : null}`;
}
function TokenDetail({ token, piece }) {
const m = data.get('assets');
const s = token.stats || {};
return html`
<div class="dhead dhead--item">
<div class="dhead__icon"><${Img} src=${tokenAvatarUrl(m, token.tokenId)} fallback=${html`<${GIcon} name="target" />`} /></div>
<div class="dhead__info">
<div class="dhead__chips"><span class="dtag-token">召唤物</span>${piece?.count > 1 ? html`<span class="dtag-kind num">×${piece.count}</span>` : null}</div>
<h3 class="dhead__name">${token.name}</h3>
</div>
</div>
<div class="dstats">
<${Stat} k="生命上限" v=${fmtNum(s.maxHp)} /><${Stat} k="攻击" v=${fmtNum(s.atk)} />
<${Stat} k="防御" v=${fmtNum(s.def)} /><${Stat} k="阻挡数" v=${s.blockCnt ?? '—'} />
</div>
${token.descRaw || token.desc ? html`<${Section} title="说明"><${RichText} as="p" text=${token.descRaw || token.desc} class="dtext" /><//>` : null}`;
}
/**
* Resolve what a detail target shows.
* @param {{ kind:'piece'|'chess'|'item'|'enemy'|'unit'|'token', id?:string, uid?:number, unit?:any, count?:number }} target
* @param {Map<number, any>} pieces indexPieces(priv)
*/
export function resolveDetail(target, pieces) {
if (!target) return null;
if (target.kind === 'piece') {
const e = pieces?.get(target.uid);
if (!e) return null;
const p = e.piece;
if (p.kind === 'item') { const it = data.lookup('items', p.id); return it ? { type: 'item', item: it, piece: p } : null; }
if (p.kind === 'token') { const t = data.lookup('tokens', p.id); return t ? { type: 'token', token: t, piece: p } : null; }
const c = data.lookup('chess', p.id);
return c ? { type: 'chess', chess: c, piece: p } : null;
}
if (target.kind === 'chess') { const c = data.lookup('chess', target.id); return c ? { type: 'chess', chess: c } : null; }
if (target.kind === 'item') { const it = data.lookup('items', target.id); return it ? { type: 'item', item: it } : null; }
if (target.kind === 'enemy') { const en = data.lookup('enemies', target.id); return en ? { type: 'enemy', enemy: en, count: target.count } : null; }
if (target.kind === 'token') { const t = data.lookup('tokens', target.id); return t ? { type: 'token', token: t } : null; }
if (target.kind === 'unit') {
const u = target.unit || {};
const own = Number.isInteger(u.uid) ? pieces?.get(u.uid) : null;
if (u.side === 'enemy') { const en = data.lookup('enemies', u.defId); return en ? { type: 'enemy', enemy: en, unitId: u.id } : null; }
const c = data.lookup('chess', u.defId);
if (c) return { type: 'chess', chess: c, piece: own?.piece || null, unitId: u.id };
const t = data.lookup('tokens', u.defId);
if (t) return { type: 'token', token: t, unitId: u.id };
const en = data.lookup('enemies', u.defId);
return en ? { type: 'enemy', enemy: en, unitId: u.id } : null;
}
return null;
}
/**
* The panel.
* @param {{ detail:any, editable:boolean, snapHp?:{hp:number,max:number}|null, onClose:Function, onSell:(piece:any)=>void, onDestroy:(piece:any)=>void,
* bonds?: any[], loadout?: any, onBond?: (bondId:string)=>void, side?: 'left'|'right', shopOpen?: boolean }} props
* bonds: the owner's m.private.bonds (counts / tiers of the bond chips); loadout: m.private.loadout (DESIGN §16) for
* the player's own operators and shop cards; a teammate's unit gets its owner's choice (gameLogic unitLoadout); null
* = the defaults
*/
export function DetailPanel({ detail, editable, snapHp, onClose, onSell, onDestroy, bonds = [], loadout = null, onBond = null, side = 'left', shopOpen = false }) {
if (!detail) return null;
const sellIt = async (piece, chess) => {
const golden = piece.golden || chess?.isGolden;
if (golden) {
const ok = await confirmDialog({ title: '出售精锐干员', text: `确定要出售精锐干员「${chess?.name || ''}」吗?出售后获得 ${chess?.sellPrice ?? 1} 资金。`, okText: '出售', danger: true });
if (!ok) return;
}
onSell(piece);
};
const destroyIt = async (piece, item) => {
const ok = await confirmDialog({ title: '销毁道具', text: `道具无法出售。确定要销毁「${item?.name || ''}」吗?`, okText: '销毁', danger: true });
if (ok) onDestroy(piece);
};
return html`<aside class=${cx('dpanel', 'brackets', `dpanel--${detail.type}`, side === 'right' && 'dpanel--right', side === 'right' && shopOpen && 'is-shop')} role="dialog" aria-label="详情"
data-side=${side === 'right' ? 'right' : 'left'}>
<button type="button" class="dpanel__close" aria-label="关闭" onClick=${onClose}><${Icon} name="close" /></button>
<div class="dpanel__scroll">
${detail.type === 'chess' ? html`<${ChessDetail} chess=${detail.chess} piece=${detail.piece} snapHp=${snapHp} editable=${editable} onSell=${sellIt}
bonds=${bonds} loadout=${loadout} onBond=${onBond} />` : null}
${detail.type === 'item' ? html`<${ItemDetail} item=${detail.item} piece=${detail.piece} editable=${editable} onDestroy=${destroyIt} />` : null}
${detail.type === 'enemy' ? html`<${EnemyDetail} enemy=${detail.enemy} snapHp=${snapHp} count=${detail.count} />` : null}
${detail.type === 'token' ? html`<${TokenDetail} token=${detail.token} piece=${detail.piece} />` : null}
</div>
</aside>`;
}
+257
View File
@@ -0,0 +1,257 @@
// Multi-device support (desktop Chrome / Edge / Firefox / Safari, iPad and Android tablets in landscape, phones in
// landscape ≥ 640×360 CSS px). Everything is feature-detected — never UA-sniffed:
//
// detectFeatures() { touch, coarse, hover, fullscreen, standalone, reducedMotion, importMaps, … } of this browser
// installDeviceSupport() once at boot (main.js): <html> classes (sp-touch / sp-coarse / sp-hover / sp-fs /
// sp-standalone / sp-reduced-motion / sp-no-hover / sp-rotatable), kept current when an input device or a
// preference changes; blocks page zoom gestures (iOS Safari ignores user-scalable=no:
// gesturestart / gesturechange are cancelled, ctrl+wheel / trackpad pinch too; touch-action in
// css/devices.css handles the rest); re-fires `resize` once the viewport
// settles after a rotation (iOS reports the old size for a few frames) so the field view and
// the rem scale re-layout; keeps --sp-vh (the real visible height) for the CSS.
// fullscreen { supported(), active(), toggle(), exit() }: Fullscreen API with the webkit-prefixed
// variant (iPadOS / older Safari); iPhone Safari has no element fullscreen → unsupported and the
// button hides. Android: after entering, the orientation is locked to landscape when allowed.
// FullscreenButton HUD / title button (hidden where unsupported), follows fullscreenchange.
// long-press a still touch of LONG_PRESS_MS on a DOM control → a synthetic `contextmenu` (= detail) when
// the browser sends none (iOS Safari); when a handler took it (preventDefault: detail card,
// tooltip) the click of the release is swallowed — otherwise the slow tap stays a tap.
// reducedMotion() prefers-reduced-motion (the CSS side lives in css/theme.css + css/devices.css).
// CSS counterpart: public/css/devices.css (safe-area insets, touch-action, overscroll, tap-target expansion).
import { useEffect, useState } from '../../vendor/hooks.module.js';
import { html, Icon } from './components.js';
/** A touch held this long without moving opens the detail (contextmenu) on DOM controls. */
export const LONG_PRESS_MS = 520;
const LONG_PRESS_SLOP = 10;
const mq = (win, q) => { try { return !!win.matchMedia?.(q)?.matches; } catch { return false; } };
/**
* Whether the SCREEN (not the viewport) is in landscape: a split-view iPad or a narrow desktop window has a portrait
* viewport on a landscape screen — rotating would not help there. screen.orientation (Chrome, Firefox, Safari 16.4+),
* else window.orientation (older iOS); iOS reports screen.width/height in portrait always, so no size guess there.
* @param {any} [win]
* @returns {boolean|null} null when unknown
*/
export function screenLandscape(win = globalThis) {
try {
const t = win.screen?.orientation?.type;
if (typeof t === 'string' && t) return t.startsWith('landscape');
} catch { /* ignore */ }
const o = Number(win.orientation);
if (win.orientation != null && Number.isFinite(o)) return Math.abs(o) === 90;
return null;
}
/**
* What this browser / device can do.
* @param {any} [win]
*/
export function detectFeatures(win = globalThis) {
const doc = win.document;
const nav = win.navigator || {};
const touchPoints = Number(nav.maxTouchPoints) || 0;
const coarse = mq(win, '(any-pointer: coarse)') || mq(win, '(pointer: coarse)');
const fine = mq(win, '(any-pointer: fine)');
const hover = mq(win, '(any-hover: hover)') || mq(win, '(hover: hover)');
const el = doc?.documentElement;
const fsEnabled = !!(doc && (doc.fullscreenEnabled || doc.webkitFullscreenEnabled))
&& !!(el && (typeof el.requestFullscreen === 'function' || typeof el.webkitRequestFullscreen === 'function'));
let importMaps = false;
try { importMaps = typeof win.HTMLScriptElement?.supports === 'function' && win.HTMLScriptElement.supports('importmap'); } catch { importMaps = false; }
return {
touch: touchPoints > 0 || coarse || 'ontouchstart' in (win || {}),
coarse,
fine,
hover,
fullscreen: fsEnabled,
standalone: mq(win, '(display-mode: standalone)') || mq(win, '(display-mode: fullscreen)') || nav.standalone === true,
reducedMotion: mq(win, '(prefers-reduced-motion: reduce)'),
screenLandscape: screenLandscape(win),
importMaps,
broadcastChannel: typeof win.BroadcastChannel === 'function',
pointerEvents: typeof win.PointerEvent === 'function',
gestureEvents: typeof win.GestureEvent === 'function', // WebKit only (pinch zoom has to be cancelled by hand)
visualViewport: !!win.visualViewport,
};
}
/** The classes installDeviceSupport puts on <html> for a feature set (pure; tested). */
export function featureClasses(f) {
return {
'sp-touch': !!f.touch,
'sp-coarse': !!f.coarse,
'sp-hover': !!f.hover,
'sp-no-hover': !f.hover,
'sp-fs': !!f.fullscreen,
'sp-standalone': !!f.standalone,
'sp-reduced-motion': !!f.reducedMotion,
// the rotate hint (css/theme.css) is for a device you can turn: a touch screen whose screen is not in landscape —
// never a desktop browser window that happens to be narrow, nor a split-view iPad
'sp-rotatable': !!f.coarse && f.screenLandscape !== true,
};
}
/** prefers-reduced-motion: reduce */
export function reducedMotion(win = globalThis) { return mq(win, '(prefers-reduced-motion: reduce)'); }
/**
* Keep a class on <html> while a component is mounted and `on` holds (replaces CSS :has(), which Firefox ESR 115 and
* Safari < 15.4 lack): the match screen sets sp-in-match, the connection banner sp-conn.
* @param {string} name
* @param {boolean} [on]
*/
export function useDocClass(name, on = true) {
useEffect(() => {
const el = globalThis.document?.documentElement;
if (!el || !on) return undefined;
el.classList.add(name);
return () => el.classList.remove(name);
}, [name, !!on]);
}
// ---- fullscreen ----------------------------------------------------------------------------------------------------
export const fullscreen = {
supported(win = globalThis) { return detectFeatures(win).fullscreen; },
active(win = globalThis) { const d = win.document; return !!(d && (d.fullscreenElement || d.webkitFullscreenElement)); },
async enter(win = globalThis) {
const el = win.document?.documentElement;
if (!el) return false;
try {
if (typeof el.requestFullscreen === 'function') await el.requestFullscreen({ navigationUI: 'hide' });
else if (typeof el.webkitRequestFullscreen === 'function') el.webkitRequestFullscreen();
else return false;
} catch { return false; }
// Android Chrome: a landscape game may lock the orientation while fullscreen (refused elsewhere — ignored)
try { await win.screen?.orientation?.lock?.('landscape'); } catch { /* not allowed here */ }
return true;
},
async exit(win = globalThis) {
const d = win.document;
try {
if (d?.fullscreenElement && typeof d.exitFullscreen === 'function') await d.exitFullscreen();
else if (d?.webkitFullscreenElement && typeof d.webkitExitFullscreen === 'function') d.webkitExitFullscreen();
} catch { return false; }
try { win.screen?.orientation?.unlock?.(); } catch { /* ignore */ }
return true;
},
toggle(win = globalThis) { return this.active(win) ? this.exit(win) : this.enter(win); },
};
/** Fullscreen toggle (hidden where the browser has no element fullscreen, e.g. iPhone Safari). */
export function FullscreenButton({ class: cls = '' }) {
const [on, setOn] = useState(() => fullscreen.active());
const [ok] = useState(() => fullscreen.supported());
useEffect(() => {
const d = globalThis.document;
if (!d) return undefined;
const upd = () => setOn(fullscreen.active());
d.addEventListener('fullscreenchange', upd);
d.addEventListener('webkitfullscreenchange', upd);
return () => { d.removeEventListener('fullscreenchange', upd); d.removeEventListener('webkitfullscreenchange', upd); };
}, []);
if (!ok) return null;
const label = on ? '退出全屏' : '全屏';
return html`<button type="button" class=${`fsbtn tapx ${cls}`} aria-label=${label} title=${label} aria-pressed=${on ? 'true' : 'false'}
onClick=${() => fullscreen.toggle()}>
<${Icon} name=${on ? 'collapse' : 'expand'} />
</button>`;
}
// ---- boot-time installation ----------------------------------------------------------------------------------------
let installed = null;
/**
* Install the device handling on this page (idempotent). Returns a disposer (tests).
* @param {any} [win]
*/
export function installDeviceSupport(win = globalThis) {
if (installed) return installed;
const doc = win.document;
if (!doc?.documentElement) return () => {};
const root = doc.documentElement;
const offs = [];
const on = (target, ev, fn, opts) => { if (!target?.addEventListener) return; target.addEventListener(ev, fn, opts); offs.push(() => target.removeEventListener(ev, fn, opts)); };
const apply = () => {
const cls = featureClasses(detectFeatures(win));
for (const [k, v] of Object.entries(cls)) root.classList.toggle(k, v);
};
apply();
for (const q of ['(any-pointer: coarse)', '(pointer: coarse)', '(any-hover: hover)', '(prefers-reduced-motion: reduce)', '(display-mode: standalone)']) {
let m = null;
try { m = win.matchMedia?.(q); } catch { m = null; }
if (!m) continue;
if (typeof m.addEventListener === 'function') on(m, 'change', apply);
else if (typeof m.addListener === 'function') { m.addListener(apply); offs.push(() => m.removeListener(apply)); } // Safari < 14
}
// visible height (the URL bar of mobile browsers comes and goes): --sp-vh = 1 % of the real visible height
const setVh = () => {
const h = win.visualViewport?.height || win.innerHeight || 0;
if (h > 0) root.style.setProperty('--sp-vh', `${h / 100}px`);
};
setVh();
on(win, 'resize', setVh, { passive: true });
on(win.visualViewport, 'resize', setVh, { passive: true });
// rotation: the new viewport size settles a few frames late on iOS → one more resize after it settles
let rotTimer = 0;
const onRotate = () => {
apply();
clearTimeout(rotTimer);
rotTimer = setTimeout(() => { apply(); setVh(); try { win.dispatchEvent(new win.Event('resize')); } catch { /* ignore */ } }, 350);
};
on(win, 'orientationchange', onRotate);
on(win.screen?.orientation, 'change', onRotate);
// no page zoom while playing (iOS Safari ignores user-scalable=no): pinch gestures and ctrl+wheel are cancelled
const cancel = (e) => { if (e.cancelable) e.preventDefault(); };
on(doc, 'gesturestart', cancel, { passive: false });
on(doc, 'gesturechange', cancel, { passive: false });
on(doc, 'gestureend', cancel, { passive: false });
on(doc, 'wheel', (e) => { if (e.ctrlKey && e.cancelable) e.preventDefault(); }, { passive: false });
// (double-tap zoom and two-finger pinch on Chromium / Firefox / iOS 13+: `touch-action` in css/devices.css — no
// scroll-blocking touchmove listener, so lists keep scrolling smoothly)
// long-press = detail on DOM controls: Android fires `contextmenu` on a long press by itself, iOS Safari never does.
// A touch held still for LONG_PRESS_MS without a native contextmenu gets a synthetic one (the shop cards, reward cards
// and the fallback board open their detail cards on it), and the click the release would cause is swallowed. The field
// canvas handles its own long-press (render/drag.js) and is skipped.
const lp = { timer: 0, x: 0, y: 0, target: null, native: false, fired: false, swallowUntil: 0 };
const lpCancel = () => { clearTimeout(lp.timer); lp.timer = 0; lp.target = null; };
on(doc, 'pointerdown', (e) => {
lpCancel();
lp.fired = false;
if (e.pointerType !== 'touch' || !(e.target instanceof win.Element)) return;
if (e.target.closest('canvas, .gm__field, .fwheel, .ewheel__viewport, input, textarea, select, [contenteditable="true"]')) return;
lp.x = e.clientX; lp.y = e.clientY; lp.target = e.target; lp.native = false;
lp.timer = setTimeout(() => {
const t = lp.target;
lp.timer = 0;
if (!t || lp.native || !t.isConnected) return;
// Only a HANDLED long press (a detail card / a tooltip took it: the handler called preventDefault) swallows the
// release. A slow tap on a control without a long-press meaning (准备就绪, 撤退, 出售 …) stays a tap — iOS Safari
// still sends its click, and swallowing it made those buttons ignore any tap held ≥ LONG_PRESS_MS.
try {
const ev = new win.MouseEvent('contextmenu', { bubbles: true, cancelable: true, clientX: lp.x, clientY: lp.y, button: 2, buttons: 0 });
lp.fired = t.dispatchEvent(ev) === false || ev.defaultPrevented === true;
} catch { lp.fired = false; }
}, LONG_PRESS_MS);
}, { passive: true, capture: true });
on(doc, 'pointermove', (e) => { if (lp.timer && Math.hypot(e.clientX - lp.x, e.clientY - lp.y) > LONG_PRESS_SLOP) lpCancel(); }, { passive: true, capture: true });
// the release of a long press (however long it was held) is not a tap: the click that follows it is swallowed
on(doc, 'pointerup', () => { if (lp.fired) { lp.fired = false; lp.swallowUntil = Date.now() + 700; } lpCancel(); }, { passive: true, capture: true });
on(doc, 'pointercancel', () => { lp.fired = false; lpCancel(); }, { passive: true, capture: true });
on(doc, 'contextmenu', (e) => { if (e.isTrusted) lp.native = true; }, { capture: true });
on(doc, 'click', (e) => {
if (lp.swallowUntil && Date.now() < lp.swallowUntil) { lp.swallowUntil = 0; e.preventDefault(); e.stopPropagation(); }
}, { capture: true });
installed = () => { for (const off of offs.splice(0)) { try { off(); } catch { /* ignore */ } } clearTimeout(rotTimer); installed = null; };
return installed;
}
+28
View File
@@ -0,0 +1,28 @@
// Active effects list (m.private.effects: band / 机变 / team / item / garrison effects with counters):
// compact icon column at the right edge, rich-text tooltip per effect.
import { html, Tooltip, MicroLabel } from './components.js';
import { Img, RichText, GIcon } from './gameComponents.js';
import { effectIconUrl } from './assetUrls.js';
import { data } from '../data.js';
const KIND = { band: '策略', choice: '机变', team: '团队增益', item: '道具', garrison: '特质' };
/** @param {{ effects: any[] }} props */
export function EffectsList({ effects }) {
const list = (Array.isArray(effects) ? effects : []).filter((e) => e && (e.name || e.desc));
if (!list.length) return null;
const m = data.get('assets');
return html`<div class="effects" aria-label="生效中的效果">
<${MicroLabel}>EFFECTS</${MicroLabel}>
${list.slice(0, 10).map((e, i) => html`<${Tooltip} key=${e.id ?? i} placement="bottom" text=${html`<div class="efftip">
<b>${e.name || '效果'}</b><span class="efftip__kind">${KIND[e.iconKind] || ''}${e.counter != null ? ` · ${e.counter}` : ''}</span>
<${RichText} as="p" text=${e.desc || ''} />
</div>`}>
<span class=${`effect effect--${e.iconKind || 'x'}`}>
<${Img} src=${effectIconUrl(m, e)} fallback=${html`<${GIcon} name="bolt" />`} />
${e.counter != null && e.counter !== '' ? html`<b class="effect__n num">${e.counter}</b>` : null}
</span>
<//>`)}
</div>`;
}
+318
View File
@@ -0,0 +1,318 @@
// Emotes (交流, research 09 §4): the 36 official in-match emotes of 盟约 — 6 themes × 6 (shared/constants.js
// 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.
// 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
// bubble mounted late resumes its timeline instead of restarting it.
// EmoteWheel bottom-left 交流 button (official emoji_btn / emoji_btn_disable) + panel (emoji_bkg): one page per theme
// with 3×2 cells (emoji_cell_bkg), pager dots, swipe / drag / arrow keys / wheel to change the page (the
// wheel turns one page per gesture, a plain vertical mouse wheel included, like Unity's ScrollRect). The
// panel reopens on the theme of the last emote sent (localStorage). Sending closes the panel and greys
// the button for the 1 s cooldown (chatCD), counted from the send so a remount keeps it.
// Official UI sprites come from the local ui/battle extraction when present; CSS draws look-alikes otherwise.
// Styles: public/css/emotes.css (injected on first use when the page does not link it).
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 { loadPref, savePref } from '../store.js';
const cx = (...p) => p.flat().filter(Boolean).join(' ');
export const EMOTE_CSS_HREF = '/css/emotes.css';
const PREF_THEME = 'emoteTheme';
const SWIPE_PX = 40; // horizontal drag distance that turns the page
const DRAG_SLOP_PX = 8; // below this a press is a tap, not a drag
export const WHEEL_STEP_PX = 60; // wheel / trackpad scroll that turns the page
export const WHEEL_GAP_MS = 180; // a pause this long ends a wheel gesture (a trackpad's momentum tail included)
// Time of the last send, shared by every wheel on the page: the 1 s cooldown survives a remount of the wheel (the
// server enforces chatCD too and silently drops an emote sent early).
let lastSentAt = -Infinity;
/** Link public/css/emotes.css once when the host page does not (dev harnesses); no-op outside a browser. */
export function ensureEmoteCss(doc = globalThis.document) {
if (!doc?.head || typeof doc.querySelector !== 'function') return false;
if (doc.querySelector(`link[rel="stylesheet"][href$="${EMOTE_CSS_HREF}"]`)) return false;
const link = doc.createElement('link');
link.rel = 'stylesheet';
link.href = EMOTE_CSS_HREF;
doc.head.appendChild(link);
return true;
}
/**
* 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).
* @param {string} id official emoji id
*/
export function emoteArtUrl(id) {
const e = emoteInfo(id);
return e ? localAsset(emoteArtGroup(id), e.picId) : null;
}
/** 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);
/** Index of a theme in the wheel (0 when unknown). */
export function themeIndex(themeId) {
const i = EMOTE_THEMES.findIndex((t) => t.themeId === themeId);
return i < 0 ? 0 : i;
}
/** Wheel page to open on: the theme of the last emote sent (remembered in localStorage), else the first. */
export function lastThemeIndex() {
const v = loadPref(PREF_THEME, null);
return typeof v === 'string' ? themeIndex(v) : 0;
}
/** Remember the theme of a sent emote. */
export function rememberTheme(themeId) {
if (EMOTE_THEMES.some((t) => t.themeId === themeId)) savePref(PREF_THEME, themeId);
}
/** Clamp a page index into the wheel. */
export const clampPage = (i) => Math.max(0, Math.min(EMOTE_THEMES.length - 1, Math.trunc(Number(i)) || 0));
/**
* Page change for a finished horizontal drag / swipe: -1 (previous), +1 (next) or 0.
* @param {number} dx pointer travel in px (negative = leftwards = next page)
* @param {number} [threshold]
*/
export const swipeStep = (dx, threshold = SWIPE_PX) => (dx <= -threshold ? 1 : dx >= threshold ? -1 : 0);
/**
* Scroll distance of a wheel event along the pager, in px. Like Unity's horizontal-only ScrollRect, a mostly vertical
* wheel (a plain mouse wheel) drives the horizontal pager too: down / right = next page.
* @param {{ deltaX?: number, deltaY?: number, deltaMode?: number }} e
* @param {number} [pagePx] px per "page" delta (deltaMode 2)
*/
export function wheelDelta(e, pagePx = 300) {
const x = Number(e?.deltaX) || 0;
const y = Number(e?.deltaY) || 0;
const d = Math.abs(x) >= Math.abs(y) ? x : y;
const unit = e?.deltaMode === 1 ? 16 : e?.deltaMode === 2 ? pagePx : 1; // lines / pages → px
return d * unit;
}
/**
* Wheel / trackpad paging, at most one page per gesture. A gesture is a run of wheel events less than WHEEL_GAP_MS
* apart; once it has turned the page the rest of it (the momentum tail of a trackpad swipe) is ignored, so one swipe
* never skips several themes.
* @param {{ x: number, t: number, spent: boolean }} acc gesture state (mutated)
* @param {number} d scroll distance of this event (wheelDelta)
* @param {number} now event time (ms)
* @returns {-1|0|1} page change
*/
export function wheelStep(acc, d, now) {
if (!(now - acc.t < WHEEL_GAP_MS) || now < acc.t) { acc.x = 0; acc.spent = false; } // a new gesture
acc.t = now;
if (acc.spent || !d) return 0;
acc.x += d;
if (Math.abs(acc.x) < WHEEL_STEP_PX) return 0;
acc.spent = true;
return acc.x > 0 ? 1 : -1;
}
/**
* Cooldown left (ms) of the 交流 button after a send at `sentAt` (chatCD).
* @param {number} sentAt
* @param {number} now
* @param {number} [cooldownMs]
*/
export function cooldownLeft(sentAt, now, cooldownMs = EMOTE_COOLDOWN_MS) {
const left = sentAt + cooldownMs - now;
return left > 0 && left <= cooldownMs ? left : 0; // a clock that went backwards never locks the button
}
/**
* Age (ms) of a bubble received at `at` — so a bubble mounted late (the team panel re-rendered from scratch) resumes
* its animation instead of popping in again for a full chatTime. 0 when unknown.
* @param {number|undefined} at receipt time (Date.now() clock)
* @param {number} now
* @param {number} ttl
*/
export function bubbleAge(at, now, ttl = EMOTE_BUBBLE_MS) {
const age = Number.isFinite(at) && Number.isFinite(now) ? now - at : 0;
return age > 0 ? Math.min(age, Math.max(0, ttl)) : 0;
}
/** Neutral stand-in for a missing emote picture (a glyph, never text). */
function EmoteGlyph({ class: cls }) {
return html`<span class=${cx('eart', 'eart--glyph', cls)} aria-hidden="true"><${GIcon} name="emote" /></span>`;
}
/**
* Official emote picture; 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)} />`;
}
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>`;
return html`<${EmoteGlyph} class=${cls} />`;
}
/**
* Pop bubble (picture only) beside the sender's avatar. Unknown ids show the neutral glyph. Key it by the emote's seq
* (a newer emote replaces the bubble and pops again).
* @param {{ id: string, class?: string, ttl?: number, at?: number }} props ttl = display time in ms (fade-out at the
* end); at = when the emote arrived (Date.now() clock): a bubble mounted after that resumes its pop / fade timeline
* instead of restarting it.
*/
export function EmoteBubble({ id, class: cls, ttl = EMOTE_BUBBLE_MS, at }) {
useData('local');
useEffect(() => { ensureEmoteCss(); }, []);
const life = Math.max(300, Number(ttl) || EMOTE_BUBBLE_MS);
const [age] = useState(() => bubbleAge(at, Date.now(), life)); // fixed at mount: the CSS animation runs from there
const bg = emoteUiSprite('emoji_bubble_bkg');
const e = emoteInfo(id);
const style = [`--ebubble-ttl:${life}ms`, age && `--ebubble-age:${Math.round(age)}ms`, bg && `--ebubble-bg:url("${bg}")`].filter(Boolean).join(';');
return html`<div class=${cx('ebubble', bg && 'has-sprite', cls)} style=${style} role="img"
aria-label=${e ? e.label : '表情'} data-emote=${e ? e.id : ''}>
<span class="ebubble__icon"><${EmoteArt} id=${id} /></span>
</div>`;
}
/**
* 交流 button + emote panel.
* @param {{ onSend: (id:string)=>void, open: boolean, onToggle: (open:boolean)=>void, disabled?: boolean,
* cooldownMs?: number }} props
*/
export function EmoteWheel({ onSend, open, onToggle, disabled = false, cooldownMs = EMOTE_COOLDOWN_MS }) {
useData('local');
useEffect(() => { ensureEmoteCss(); }, []);
const [page, setPage] = useState(lastThemeIndex);
const [dir, setDir] = useState(0); // direction of the last page change (slide-in animation)
const [dx, setDx] = useState(0); // live drag offset (px)
const [cooling, setCooling] = useState(() => cooldownLeft(lastSentAt, Date.now(), cooldownMs) > 0);
const drag = useRef(null); // { id, x0, moved }
const swallowClick = useRef(false);
const wheelAcc = useRef({ x: 0, t: -Infinity, spent: false });
const live = useRef({});
live.current = { page, onToggle };
const go = (to) => {
const next = clampPage(to);
if (next === live.current.page) return;
setDir(next > live.current.page ? 1 : -1);
setPage(next);
live.current.page = next; // several key presses before the next render still step one page each
};
// reopen on the last used theme
useEffect(() => { if (open) { setPage(lastThemeIndex()); setDir(0); setDx(0); } }, [open]);
// cooldown: the button is greyed for chatCD after a send (from the send time, so a remounted wheel keeps it)
useEffect(() => {
if (!cooling) return undefined;
const t = setTimeout(() => setCooling(false), cooldownLeft(lastSentAt, Date.now(), cooldownMs));
return () => clearTimeout(t);
}, [cooling]);
// outside press closes; ← / → change the page
useEffect(() => {
if (!open) return undefined;
const onDown = (e) => { if (!(e.target instanceof Element) || !e.target.closest('.ewheel')) live.current.onToggle(false); };
const onKey = (e) => {
if (e.ctrlKey || e.metaKey || e.altKey) return;
const t = e.target;
if (t instanceof Element && t.closest('input, textarea, select, [contenteditable="true"]')) return;
if (e.key === 'ArrowLeft' || e.key === 'ArrowRight') {
e.preventDefault();
go(live.current.page + (e.key === 'ArrowRight' ? 1 : -1));
}
};
window.addEventListener('pointerdown', onDown, true);
window.addEventListener('keydown', onKey);
return () => { window.removeEventListener('pointerdown', onDown, true); window.removeEventListener('keydown', onKey); };
}, [open]);
const send = (id) => {
if (swallowClick.current) { swallowClick.current = false; return; }
const e = emoteInfo(id);
const now = Date.now();
if (!e || cooling || disabled || cooldownLeft(lastSentAt, now, cooldownMs) > 0) return;
lastSentAt = now;
rememberTheme(e.themeId);
setCooling(true);
onSend(id);
onToggle(false);
};
// swipe / drag (touch, pen and mouse)
const onPointerDown = (e) => {
if (e.button != null && e.button !== 0) return;
drag.current = { id: e.pointerId, x0: e.clientX, moved: false };
swallowClick.current = false;
};
const onPointerMove = (e) => {
const d = drag.current;
if (!d || d.id !== e.pointerId) return;
if (e.pointerType === 'mouse' && !(e.buttons & 1)) { drag.current = null; setDx(0); return; } // released outside
const off = e.clientX - d.x0;
if (!d.moved && Math.abs(off) < DRAG_SLOP_PX) return;
if (!d.moved) { d.moved = true; try { e.currentTarget.setPointerCapture(e.pointerId); } catch { /* ignore */ } }
const edge = (off > 0 && live.current.page === 0) || (off < 0 && live.current.page === EMOTE_THEMES.length - 1);
setDx(edge ? off / 3 : off); // rubber band at the first / last page
};
const endDrag = (e, cancelled) => {
const d = drag.current;
if (!d || d.id !== e.pointerId) return;
drag.current = null;
if (!d.moved) return;
swallowClick.current = true; // the click that follows a drag is not a send
setTimeout(() => { swallowClick.current = false; }, 0);
setDx(0);
if (!cancelled) { const step = swipeStep(e.clientX - d.x0); if (step) go(live.current.page + step); }
};
const onWheel = (e) => {
const d = wheelDelta(e, e.currentTarget?.clientWidth || 300);
if (!d) return;
e.preventDefault();
const step = wheelStep(wheelAcc.current, d, performance.now());
if (step) go(live.current.page + step);
};
const theme = EMOTE_THEMES[clampPage(page)];
const btnSprite = emoteUiSprite(disabled || cooling ? 'emoji_btn_disable' : 'emoji_btn');
const panelBg = emoteUiSprite('emoji_bkg');
const cellBg = emoteUiSprite('emoji_cell_bkg');
const panelStyle = [panelBg && `--ewheel-bg:url("${panelBg}")`, cellBg && `--ewheel-cell:url("${cellBg}")`].filter(Boolean).join(';');
return html`<div class="ewheel">
<button type="button" class=${cx('ewheel__btn', btnSprite && 'has-sprite', open && 'is-on', cooling && 'is-cooling')}
style=${btnSprite ? `--ewheel-btn:url("${btnSprite}")` : ''} onClick=${() => onToggle(!open)}
aria-expanded=${open ? 'true' : 'false'} aria-haspopup="dialog" disabled=${disabled || cooling}>
${btnSprite ? null : html`<${GIcon} name="emote" />`}<span class="ewheel__label">交流</span>
</button>
${open ? html`<div class=${cx('ewheel__panel', panelBg && 'has-sprite', cellBg && 'has-cell')} style=${panelStyle} role="dialog" aria-label="交流">
<div class="ewheel__viewport" onPointerDown=${onPointerDown} onPointerMove=${onPointerMove}
onPointerUp=${(e) => endDrag(e, false)} onPointerCancel=${(e) => endDrag(e, true)} onWheel=${onWheel}>
<div key=${theme.themeId} class=${cx('ewheel__page', dir > 0 && 'is-from-right', dir < 0 && 'is-from-left', dx !== 0 && 'is-dragging')}
style=${dx ? `transform:translateX(${dx}px)` : ''} role="group" aria-label=${theme.name} data-theme=${theme.themeId}>
${theme.emotes.map((e) => html`<button key=${e.id} type="button" class="ewheel__item" data-emote=${e.id} aria-label=${e.label}
disabled=${cooling || disabled} onClick=${() => send(e.id)}>
<${EmoteArt} id=${e.id} />
</button>`)}
</div>
</div>
<button type="button" class="ewheel__nav is-prev" aria-label="上一组表情" disabled=${page <= 0} onClick=${() => go(page - 1)}><${GIcon} name="chevronLeft" /></button>
<button type="button" class="ewheel__nav is-next" aria-label="下一组表情" disabled=${page >= EMOTE_THEMES.length - 1} onClick=${() => go(page + 1)}><${GIcon} name="chevronRight" /></button>
<div class="ewheel__dots" role="tablist" aria-label="表情主题">
${EMOTE_THEMES.map((t, i) => html`<button key=${t.themeId} type="button" role="tab" class=${cx('ewheel__dot', i === page && 'is-on')}
aria-selected=${i === page ? 'true' : 'false'} aria-label=${`${t.name} ${i + 1}/${EMOTE_THEMES.length}`} onClick=${() => go(i)}></button>`)}
</div>
</div>` : null}
</div>`;
}
+100
View File
@@ -0,0 +1,100 @@
// 本局信息 dialog under the top bar (opened by the left 🔍, official btn_check_player / autochess_hud_player_info_dialog,
// research 09 §2.1) with two tabs:
// 本局信息 — own strategy, stage, disabled bonds with banned-member counts and the banned operator list (default);
// 敌方情报 — the secondary enemy list: m.private.nextEnemies (icons, names, counts, tags, 特训 faction tags) + this
// match's factions and the enemy leader on boss rounds. The primary enemy preview is the pen on the board
// (the right 🔍▶▶ pans the camera there; tapping an enemy in the pen opens its detail card) — research 09 §6.2.
import { html, Icon, Tabs, MicroLabel } from './components.js';
import { Img, UnitThumb, BondGlyph, BandIcon, RichText, GIcon } from './gameComponents.js';
import { groupEnemies, factionTypes, bannedPerBond, disabledBondSets, briefingBondTip } from './gameLogic.js';
import { factionIconUrl } from './assetUrls.js';
import { data } from '../data.js';
const cx = (...p) => p.flat().filter(Boolean).join(' ');
const TAG = { boss: '领袖', bounty: '悬赏', escort: '护卫' };
function FactionChips({ types }) {
const f = data.get('factions')?.types || {};
const m = data.get('assets');
return html`<div class="fchips">${types.map((t) => html`<span key=${t} class="fchip" title=${f[t]?.desc || ''}>
<${Img} src=${factionIconUrl(m, f[t]?.icon)} class="fchip__icon" /><span>${f[t]?.name || t}</span></span>`)}</div>`;
}
function EnemiesTab({ pub, priv, onEnemy }) {
const rows = groupEnemies(priv?.nextEnemies, (k) => data.lookup('enemies', k));
const types = factionTypes(pub?.factions);
const f = data.get('factions')?.types || {};
const total = rows.reduce((s, r) => s + r.count, 0);
const boss = pub?.bossId ? data.lookup('bosses', pub.bossId) : null;
return html`<div class="edrawer__body">
<div class="edrawer__sum">
<span>第 <b class="num">${pub?.round ?? '-'}</b> 回合 · 即将迎击 <b class="num t-orange">${total}</b> 名敌人</span>
${types.length ? html`<${FactionChips} types=${types} />` : null}
</div>
${rows.length ? html`<div class="elist">
${rows.map((r) => html`<button key=${`${r.enemyKey}|${r.tag}`} type="button" class=${cx('erow', r.tag && `erow--${r.tag}`, `erow--${r.rank.toLowerCase()}`)}
onClick=${() => onEnemy(r.enemyKey, r.count)}>
<${UnitThumb} kind="enemy" id=${r.enemyKey} size="md" />
<span class="erow__text">
<b class="erow__name">${r.name}</b>
<span class="erow__tags">
${r.tag ? html`<span class=${`etag etag--${r.tag}`}>${TAG[r.tag] || r.tag}</span>` : null}
${r.rank === 'ELITE' ? html`<span class="etag etag--elite">精英</span>` : null}
${r.fly ? html`<span class="etag etag--fly">空中</span>` : null}
${r.acTypes.map((t) => html`<span key=${t} class="etag etag--faction">${(f[t]?.name || t).replace('特训敌人·', '')}</span>`)}
</span>
</span>
<b class="erow__count num">×${r.count}</b>
</button>`)}
</div>` : html`<p class="edrawer__empty"><${GIcon} name="target" />暂无敌方情报</p>`}
${boss ? html`<div class="eboss">
<${UnitThumb} kind="enemy" id=${boss.enemyKey} size="md" />
<div><${MicroLabel} tone="gold">ENEMY LEADER // 敌方领袖</${MicroLabel}><b>${boss.name}</b>
<span class="t-lo">第 ${data.get('config')?.modes?.[pub.modeId]?.bossRound ?? pub.lastRound ?? 14} 回合 · 最终攻势</span></div>
</div>` : null}
</div>`;
}
function InfoTab({ pub, priv, onChess }) {
const sets = disabledBondSets(pub, data.get('config')?.modes?.[pub?.modeId]?.inactiveBondIds);
const disabled = new Set([...sets.drawn, ...sets.off]);
const stateOf = (id) => (sets.off.has(id) ? 'off' : sets.drawn.has(id) ? 'drawn' : null);
const bonds = data.list('bonds');
const banned = Array.isArray(pub?.bannedChess) ? pub.bannedChess : [];
const perBond = bannedPerBond(bonds, banned);
const band = priv?.bandId ? data.lookup('bands', priv.bandId) : null;
const stage = pub?.stageId ? data.lookup('stages', pub.stageId) : null;
const withBans = bonds.filter((b) => disabled.has(b.bondId) || (perBond.get(b.bondId) || 0) > 0)
.sort((a, b) => (disabled.has(b.bondId) - disabled.has(a.bondId)) || ((perBond.get(b.bondId) || 0) - (perBond.get(a.bondId) || 0)));
return html`<div class="edrawer__body">
${band ? html`<div class="iband">
<${BandIcon} bandId=${band.bandId} size="md" />
<div><${MicroLabel} tone="mint">STRATEGY // 我的策略</${MicroLabel}><b>${band.name} <small class="t-lo">${band.effectName}</small></b>
<${RichText} text=${band.descRaw || band.desc} class="iband__desc" /></div>
</div>` : null}
${stage ? html`<p class="istage"><${Icon} name="rook" />战场:<b>${stage.name || stage.id}</b></p>` : null}
<h4 class="ihead">本局禁用干员情况 <small class="num">${banned.length}</small></h4>
<div class="ibonds">
${withBans.length ? withBans.map((b) => html`<span key=${b.bondId} class=${cx('ibond', disabled.has(b.bondId) && 'is-off')} title=${briefingBondTip(b.name, stateOf(b.bondId), perBond.get(b.bondId) || 0)}>
<${BondGlyph} bondId=${b.bondId} /><span>${b.name}</span>
${perBond.get(b.bondId) ? html`<span class="ibond__ban num" title="该盟约中被禁用的干员数"><${Icon} name="user" />${perBond.get(b.bondId)}</span>` : null}
</span>`) : html`<span class="t-dim">本局没有禁用盟约</span>`}
</div>
${banned.length ? html`<div class="ibanned">${banned.map((id) => html`<button key=${id} type="button" class="ibanned__one" onClick=${() => onChess(id)}>
<${UnitThumb} kind="chess" id=${id} size="sm" dim=${true} /></button>`)}</div>` : null}
</div>`;
}
/**
* @param {{ tab:'enemies'|'info', onTab:(t:string)=>void, pub:any, priv:any, onClose:Function, onEnemy:(key:string, count:number)=>void, onChess:(id:string)=>void }} props
*/
export function EnemyDrawer({ tab, onTab, pub, priv, onClose, onEnemy, onChess }) {
return html`<div class="edrawer brackets" role="dialog" aria-label=${tab === 'info' ? '本局信息' : '敌方情报'}>
<div class="edrawer__top">
<${Tabs} size="sm" value=${tab} onChange=${onTab} items=${[{ id: 'info', label: '本局信息' }, { id: 'enemies', label: '敌方情报' }]} />
<button type="button" class="edrawer__close tapx" aria-label="关闭" onClick=${onClose}><${Icon} name="close" /></button>
</div>
${tab === 'info' ? html`<${InfoTab} pub=${pub} priv=${priv} onChess=${onChess} />` : html`<${EnemiesTab} pub=${pub} priv=${priv} onEnemy=${onEnemy} />`}
</div>`;
}
+135
View File
@@ -0,0 +1,135 @@
// Equip-replace dialog (research 04 §"Replacement" / 09 §1.2 UseEquipUp { charChessInstId, equipChessInstId,
// isChangeEquip, unloadInstId }; PRTS 帮助 "达到上限强行佩戴会改为替换装备"): an item dropped on an operator whose two
// equipment slots are used opens this dialog. It shows the operator, the incoming item and the two equipped items
// (icon, tier, name, effect); the player picks the one to replace — it is DESTROYED — and confirms: g.equip
// {itemUid, targetUid, replaceUid}. 取消 / ✕ / Esc / the backdrop change nothing (no intent is sent).
// Official look from the local ui/battle sprites (equip_replace_bg, _opiton_bg, _opition_select_01/02_bg, _arrow_line /
// _arrow_head, _avatart_bg / _frame, _round, _close, _comfirm_icon, _replace_icon), CSS look-alikes without them
// (css/screens/game-panels.css .eqr).
//
// replaceRequest(ctx, intent, getChess, getItem) → { targetUid, targetId, targetName, item: {uid,id,name}, options }
// | null (pure; the data the dialog shows — null = no dialog)
// replaceIntent(intent, replaceUid) → { t: 'g.equip', fields: { itemUid, targetUid, replaceUid } }
// <EquipReplaceDialog request onConfirm(replaceUid) onCancel />
import { useEffect, useState } from '../../vendor/hooks.module.js';
import { html, Modal, Button, TierChip, MicroLabel } from './components.js';
import { UnitThumb, RichText, GIcon } from './gameComponents.js';
import { localAsset } from '../data.js';
const cx = (...p) => p.flat().filter(Boolean).join(' ');
const isObj = (v) => !!v && typeof v === 'object';
/**
* What the replace dialog shows for a g.equip drop intent that needs a replacement (`intent.confirmReplace`).
* @param {{ pieces: Map<number, any> }} ctx placementContext
* @param {{ t: string, fields: { itemUid: number, targetUid: number }, confirmReplace?: boolean }|null} intent gameLogic dropIntent
* @param {(id: string) => any} [getChess]
* @param {(id: string) => any} [getItem]
*/
export function replaceRequest(ctx, intent, getChess = () => null, getItem = () => null) {
if (!intent || intent.t !== 'g.equip' || !intent.confirmReplace || !isObj(intent.fields)) return null;
const target = ctx?.pieces?.get(intent.fields.targetUid)?.piece;
const item = ctx?.pieces?.get(intent.fields.itemUid)?.piece;
if (!isObj(target) || target.kind !== 'chess' || !isObj(item) || item.kind !== 'item') return null;
const equipped = (Array.isArray(target.items) ? target.items : []).filter((it) => isObj(it) && Number.isInteger(it.uid));
if (equipped.length < 2) return null;
const rec = (id) => getItem(id) || null;
return {
targetUid: target.uid,
targetId: target.id,
targetName: getChess(target.id)?.name || '该干员',
golden: !!target.golden,
item: { uid: item.uid, id: item.id, name: rec(item.id)?.name || '装备' },
options: equipped.map((it) => ({ uid: it.uid, id: it.id, name: rec(it.id)?.name || it.id })),
};
}
/**
* The g.equip intent that replaces `replaceUid` (one of the target's equipped items, or the intent is unchanged).
* @param {{ t: string, fields: { itemUid: number, targetUid: number } }} intent
* @param {number} replaceUid
*/
export function replaceIntent(intent, replaceUid) {
const f = intent?.fields || {};
const fields = { itemUid: f.itemUid, targetUid: f.targetUid };
if (Number.isInteger(replaceUid)) fields.replaceUid = replaceUid;
return { t: 'g.equip', fields };
}
/** One official sprite as a CSS custom property (empty when the local-client art is not installed). */
const spriteVar = (name, sprite) => {
const url = localAsset('ui/battle', sprite);
return url ? `${name}:url("${url}");` : '';
};
/** An item card of the dialog (the incoming item on top, the two equipped options below). */
function ItemCard({ id, item, rec, selected = false, option = false, onPick }) {
const tier = rec?.tier;
const body = html`
<span class="eqr__icon">
<${UnitThumb} kind="item" id=${id} size="md" showTier=${false} />
</span>
<span class="eqr__meta">
<span class="eqr__chips">${tier ? html`<${TierChip} tier=${tier} golden=${!!rec?.isGolden} size="sm" />` : null}
${rec?.isGolden ? html`<span class="eqr__elite">进阶</span>` : null}</span>
<b class="eqr__name">${rec?.name || item?.name || id}</b>
</span>
<${RichText} as="p" class="eqr__desc" text=${rec?.descRaw || rec?.desc || ''} />`;
if (!option) return html`<div class="eqr__card eqr__card--new">${body}</div>`;
return html`<button type="button" role="radio" aria-checked=${selected ? 'true' : 'false'} data-uid=${item.uid}
class=${cx('eqr__card', 'eqr__opt', selected && 'is-sel')} onClick=${() => onPick(item.uid)}>
${body}
<span class="eqr__flag" aria-hidden=${selected ? 'false' : 'true'}>
<${GIcon} name="trash" class="eqr__flag-icon" /><span>将被销毁</span>
</span>
</button>`;
}
/**
* The dialog.
* @param {{ request: ReturnType<typeof replaceRequest>, getItem?: (id: string) => any, busy?: boolean,
* onConfirm: (replaceUid: number) => void, onCancel: () => void }} props
*/
export function EquipReplaceDialog({ request, getItem = () => null, busy = false, onConfirm, onCancel }) {
const [sel, setSel] = useState(null);
const key = request ? `${request.targetUid}:${request.item.uid}` : '';
useEffect(() => { setSel(null); }, [key]);
if (!request) return null;
const chosen = request.options.find((o) => o.uid === sel) || null;
const style = [
spriteVar('--eqr-bg', 'equip_replace_bg'), spriteVar('--eqr-opt', 'equip_replace_opiton_bg'),
spriteVar('--eqr-sel', 'equip_replace_opition_select_02_bg'), spriteVar('--eqr-selbar', 'equip_replace_opition_select_01_bg'),
spriteVar('--eqr-round', 'equip_replace_round'), spriteVar('--eqr-ava', 'equip_replace_avatart_bg'),
spriteVar('--eqr-avaf', 'equip_replace_avatart_frame'), spriteVar('--eqr-aline', 'equip_replace_arrow_line'),
spriteVar('--eqr-ahead', 'equip_replace_arrow_head'),
].join('');
const confirmIcon = localAsset('ui/battle', 'equip_replace_comfirm_icon');
const swapIcon = localAsset('ui/battle', 'equip_replace_replace_icon');
return html`<div class="eqr-host" style=${style} onKeyDown=${(e) => { if (e.key === 'Enter' && chosen && !busy) { e.preventDefault(); onConfirm(chosen.uid); } }}>
<${Modal} open=${true} tone="red" class="eqr" width="min(7.6rem, 96vw)" micro="EQUIPMENT · REPLACE" title="替换装备" onClose=${onCancel}
actions=${html`
<${Button} variant="secondary" icon="close" class="eqr__cancel" onClick=${onCancel}>取消<//>
<${Button} variant="danger" class="eqr__ok" disabled=${!chosen} loading=${busy} data-autofocus onClick=${() => chosen && onConfirm(chosen.uid)}>
${confirmIcon ? html`<img class="eqr__ok-icon" src=${confirmIcon} alt="" draggable=${false} />` : null}确认替换<//>`}>
<p class="eqr__lead">「<b>${request.targetName}</b>」的装备栏已满(2/2)。选择一件装备进行替换,<b class="t-red">被替换的装备将被销毁</b>。</p>
<div class="eqr__top">
<div class="eqr__op" title=${request.targetName}>
<span class="eqr__ava"><${UnitThumb} kind="chess" id=${request.targetId} golden=${request.golden} size="md" /></span>
<span class="eqr__opname">${request.targetName}</span>
</div>
<span class="eqr__swap" aria-hidden="true">${swapIcon ? html`<img src=${swapIcon} alt="" draggable=${false} />` : html`<${GIcon} name="refresh" />`}</span>
<div class="eqr__incoming">
<${MicroLabel} tone="mint">新装备</${MicroLabel}>
<${ItemCard} id=${request.item.id} item=${request.item} rec=${getItem(request.item.id)} />
</div>
</div>
<div class="eqr__arrow" aria-hidden="true"><i class="eqr__aline"></i><i class="eqr__ahead eqr__ahead--l"></i><i class="eqr__ahead eqr__ahead--r"></i></div>
<div class="eqr__opts" role="radiogroup" aria-label="选择被替换的装备">
${request.options.map((o) => html`<${ItemCard} key=${o.uid} id=${o.id} item=${o} rec=${getItem(o.id)} option=${true}
selected=${o.uid === sel} onPick=${(uid) => setSel((s) => (s === uid ? null : uid))} />`)}
</div>
<p class="eqr__hint">${chosen ? html`将销毁「<b>${chosen.name}</b>」并配发「<b>${request.item.name}</b>」` : '点击选择要替换的装备'}</p>
<//>
</div>`;
}
+239
View File
@@ -0,0 +1,239 @@
// Facing (direction) rules of the prep board — pure logic, no DOM (research 09 §1.2, DESIGN §3 "Facing (corrected)").
//
// Every board piece has a direction `dir ∈ UP|RIGHT|DOWN|LEFT`, chosen with the standard Arknights 4-direction deploy
// wheel after a legal drop on a board tile (also the piece's own tile: re-orienting in place). Range grids are
// `[dRow, dCol]` offsets relative to facing RIGHT (row 0 = the bottom row, nearest the camera) and rotate as
// RIGHT (dr, dc) · UP (dc, −dr) · LEFT (−dr, −dc) · DOWN (−dc, dr).
// Screen space of the wheel: +x right, +y down; the camera looks "up" the board, so screen-up = towards higher rows =
// UP. A swipe shorter than the dead-zone (~0.5 tile) chooses nothing (release there = cancel). On the mirrored right
// half of the Final Assault prep a screen direction maps to its board direction through `boardDir(dir, mirror)`.
//
// Also here: which drops enter the direction step, which range grid previews, and the underframe (selection diamond)
// actions of a tapped piece — 撤退 / 出售 +N on a board operator, 撤退 on a board summon, 出售 +N on a bench operator,
// 销毁 on items and Arts (research 09 §5 / §6.5).
import { GEO } from '../../../shared/constants.js';
import { attackRangeGrid } from '../../../shared/loadoutRecord.js';
export const DIRS = Object.freeze(['UP', 'RIGHT', 'DOWN', 'LEFT']);
export const DEFAULT_DIR = 'RIGHT';
/** Dead-zone radius of the wheel, in tiles (research 09 §1.2: ~0.5 tile). */
export const DEAD_ZONE_TILES = 0.5;
/** Row / column unit step of a direction (row 0 = bottom). */
export const DIR_VEC = Object.freeze({
UP: Object.freeze({ dr: 1, dc: 0 }),
RIGHT: Object.freeze({ dr: 0, dc: 1 }),
DOWN: Object.freeze({ dr: -1, dc: 0 }),
LEFT: Object.freeze({ dr: 0, dc: -1 }),
});
/** Chinese label (for aria / tooltips). */
export const DIR_LABEL = Object.freeze({ UP: '上', RIGHT: '右', DOWN: '下', LEFT: '左' });
/** Normalise any direction spelling ('up', 'Right', 1/−1 facing) to UP|RIGHT|DOWN|LEFT; unknown → `fallback`. */
export function normDir(d, fallback = DEFAULT_DIR) {
if (typeof d === 'string') {
const u = d.trim().toUpperCase();
if (DIRS.includes(u)) return u;
}
if (d === -1) return 'LEFT';
if (d === 1) return 'RIGHT';
return fallback;
}
/** Rotate a range offset given relative to facing RIGHT (DESIGN §3). */
export function rotateOffset(dr, dc, dir) {
const z = (v) => (v === 0 ? 0 : v); // no −0
switch (normDir(dir)) {
case 'UP': return [z(dc), z(-dr)];
case 'LEFT': return [z(-dr), z(-dc)];
case 'DOWN': return [z(-dc), z(dr)];
default: return [z(dr), z(dc)];
}
}
/**
* Absolute board tiles of a range grid for a piece standing on (row, col) facing `dir` (clipped to the stage grid,
* deduplicated). `extend` adds columns along +dCol before rotating (rangeExtend, DESIGN §3).
* @param {Array<[number, number]>} grid
* @returns {Array<[number, number]>}
*/
export function rangeTiles(grid, row, col, dir, { extend = 0, rows = GEO.ROWS, cols = GEO.COLS } = {}) {
const out = [];
const seen = new Set();
const list = Array.isArray(grid) ? grid : [];
const ext = Number.isInteger(extend) && extend > 0 ? extend : 0;
const push = (dr, dc) => {
const [rr, rc] = rotateOffset(dr, dc, dir);
const r = row + rr, c = col + rc;
if (r < 0 || r >= rows || c < 0 || c >= cols) return;
const k = `${r},${c}`;
if (seen.has(k)) return;
seen.add(k);
out.push([r, c]);
};
for (const g of list) {
if (!Array.isArray(g) || !Number.isFinite(g[0]) || !Number.isFinite(g[1])) continue;
push(g[0], g[1]);
}
if (ext) {
// rangeExtend: every row of the grid grows by `ext` columns at its far (+dCol) end
const rowsOf = new Map();
for (const g of list) if (Array.isArray(g) && Number.isFinite(g[0]) && Number.isFinite(g[1])) rowsOf.set(g[0], Math.max(rowsOf.get(g[0]) ?? -Infinity, g[1]));
for (const [dr, far] of rowsOf) for (let k = 1; k <= ext; k++) push(dr, far + k);
}
return out;
}
/**
* Direction of a swipe from the wheel centre. `dx`, `dy` in screen px (+y down); `dead` = dead-zone radius in px.
* The dominant axis decides (ties go to the horizontal axis, where the lanes are).
* @returns {'UP'|'RIGHT'|'DOWN'|'LEFT'|null}
*/
export function dirFromDelta(dx, dy, dead = 0) {
if (!Number.isFinite(dx) || !Number.isFinite(dy)) return null;
if (Math.hypot(dx, dy) <= Math.max(0, dead)) return null;
if (Math.abs(dx) >= Math.abs(dy)) return dx >= 0 ? 'RIGHT' : 'LEFT';
return dy < 0 ? 'UP' : 'DOWN';
}
const MIRROR = Object.freeze({ UP: 'UP', RIGHT: 'LEFT', DOWN: 'DOWN', LEFT: 'RIGHT' });
/**
* Board direction of a direction picked on screen (the wheel's chevrons / arrow keys are screen-oriented). On the
* right half of the Final Assault prep the board is drawn mirrored (research 09 §1.2: col c → 20 − c, RIGHT ↔ LEFT;
* render/prepfield.js), so a swipe to the screen's right means board LEFT there; UP / DOWN are unchanged. The mapping
* is its own inverse: `boardDir(boardDir(d, m), m) === d` (also the screen chevron of a board direction).
* @param {'UP'|'RIGHT'|'DOWN'|'LEFT'|null} dir
* @param {boolean} mirror `tileScreen(row, col).mirror` / `view.prepField().mirror` of the render view
*/
export function boardDir(dir, mirror = false) {
if (dir == null) return null;
const d = normDir(dir, null);
if (!d) return null;
return mirror ? MIRROR[d] : d;
}
/** Whether the view draws the prep board mirrored (right-hand Final Assault prep); false for views without the hook. */
export function viewMirrored(view) {
const raw = view && (view.raw || view);
try { return !!(raw && typeof raw.prepField === 'function' && raw.prepField()?.mirror); } catch { return false; }
}
/** Direction of an arrow key (keyboard alternative of the swipe). */
export function dirFromKey(key) {
return { ArrowUp: 'UP', ArrowRight: 'RIGHT', ArrowDown: 'DOWN', ArrowLeft: 'LEFT' }[key] || null;
}
const isObj = (v) => !!v && typeof v === 'object';
/** The piece's stored facing (m.private board pieces carry `dir`; absent ⇒ RIGHT, the server default). */
export const pieceDir = (piece) => normDir(isObj(piece) ? piece.dir : null);
/**
* Range grid previewed by the wheel / on a selected unit: the operator's own grid (heal range for medics — the data
* grid is the one the unit uses; an elite's equipped "攻击范围扩大" module grid, DESIGN §16 — `chessRecord` resolves
* the player's loadout), a summon's grid, an Art's grid (画卷 `1-1`). Null when there is nothing to show.
* @param {{ getChess:(id:string)=>any, getToken:(id:string)=>any, getItem:(id:string)=>any, chessRecord?:(rec:any)=>any }} lookups
*/
export function previewGrid(lookups, piece) {
if (!isObj(piece) || !lookups) return null;
let rec = null;
if (piece.kind === 'chess') {
rec = lookups.getChess?.(piece.id);
if (rec && typeof lookups.chessRecord === 'function') { try { rec = lookups.chessRecord(rec) || rec; } catch { /* the data record */ } }
} else if (piece.kind === 'token') rec = lookups.getToken?.(piece.id);
else if (piece.kind === 'item') { rec = lookups.getItem?.(piece.id); if (rec?.itemType !== 'MAGIC') return null; }
const grid = !rec ? null : piece.kind === 'chess' ? attackRangeGrid(rec) : (Array.isArray(rec.rangeGrid) ? rec.rangeGrid : null);
return grid && grid.length ? grid : null;
}
/** Whether an Art is placed with a direction (its range reaches beyond its own tile, e.g. 画卷 `[[0,0],[0,1]]`). */
export function artHasFacing(item) {
const g = Array.isArray(item?.rangeGrid) ? item.rangeGrid : [];
return g.some((x) => Array.isArray(x) && (x[0] !== 0 || x[1] !== 0));
}
/**
* Whether dropping `uid` on `target` enters the direction step (instead of sending the intent at once): operators and
* summons dropped on a board tile (move, swap or their own tile), and directional Arts used on a tile.
* @param {{ pieces: Map<number, any>, getItem?: (id:string)=>any }} ctx placementContext (gameLogic.js)
* @param {number} uid
* @param {any} target
* @param {{ ok: boolean, action?: string }} res canPlace(ctx, uid, target)
*/
export function needsFacing(ctx, uid, target, res) {
if (!res || !res.ok || !isObj(target) || target.area !== 'board') return false;
const piece = ctx?.pieces?.get(uid)?.piece;
if (!piece) return false;
if (piece.kind === 'chess' || piece.kind === 'token') return res.action === 'move' || res.action === 'swap' || res.action === 'orient';
if (piece.kind === 'item' && res.action === 'art') return artHasFacing(ctx.getItem?.(piece.id));
return false;
}
/**
* The intent that commits a direction step. g.move carries the direction twice: top-level `dir` (the protocol field)
* and `to.dir`, which server/match/PlayerState.move reads when the top-level one does not reach it — Match.handle
* currently forwards only `msg.to` (`ps.move(msg.uid, msg.to)`), and without the copy every placement was stored
* facing RIGHT and the model snapped back after the wheel. g.art has no such fallback (see the review report).
* @returns {{ t: 'g.move', fields: { uid, to: { area, row, col, dir }, dir } } | { t: 'g.art', fields: { itemUid, row, col, dir } }}
*/
export function facingIntent(piece, target, dir) {
const d = normDir(dir);
if (piece?.kind === 'item') return { t: 'g.art', fields: { itemUid: piece.uid, row: target.row, col: target.col, dir: d } };
return { t: 'g.move', fields: { uid: piece.uid, to: { area: 'board', row: target.row, col: target.col, dir: d }, dir: d } };
}
/**
* Whether 销毁 may be offered for item `uid`: a loose item in the hand or the temp slots. Items equipped on an operator
* (a piece's `items`, or an entry some other index marks `equipped`) are locked — g.destroy would be refused.
* @param {{ pieces: Map<number, any> }} ctx placementContext / indexPieces view
* @param {number} uid
*/
export function itemDestroyable(ctx, uid) {
const e = ctx?.pieces?.get(uid);
if (!e || e.piece?.kind !== 'item' || e.equipped) return false;
if (e.area !== 'hand' && e.area !== 'temp') return false;
for (const o of ctx.pieces.values()) {
if (o.piece?.kind === 'chess' && Array.isArray(o.piece.items) && o.piece.items.some((it) => it && it.uid === uid)) return false;
}
return true;
}
/**
* Underframe (selection diamond) of a tapped own piece (research 09 §1.2 / §5): the buttons it offers.
* board operator → 撤退 (to the bench) + 出售 +N · board summon → 撤退 · bench / temp operator → 出售 +N ·
* item or Art (bench / temp) → 销毁 · bench summon → nothing.
* @param {{ pieces: Map<number, any>, getChess?: (id:string)=>any }} ctx
* @returns {{ retreat: boolean, sell: number|null, destroy: boolean } | null} sell = the price shown ("+N")
*/
export function underframeActions(ctx, uid) {
const e = ctx?.pieces?.get(uid);
if (!e) return null;
const p = e.piece;
const onBoard = e.area === 'board';
// only a loose item (bench / temp) can be destroyed: an equipped one is locked (the server refuses g.destroy —
// research 04 §2; replacing it is the equip-replace dialog's job, g.equip replaceUid)
if (p.kind === 'item') return itemDestroyable(ctx, uid) ? { retreat: false, sell: null, destroy: true } : null;
if (p.kind === 'token') return onBoard ? { retreat: true, sell: null, destroy: false } : null;
if (p.kind !== 'chess') return null;
const price = ctx.getChess?.(p.id)?.sellPrice;
return { retreat: onBoard, sell: Number.isFinite(price) ? price : 1, destroy: false };
}
/**
* Where 撤退 puts a board piece: the first free bench slot (a summon goes back onto its stack — any slot works; an
* operator whose own summon stacks sit on the bench frees them). Null when the bench is full.
* @param {{ pieces: Map<number, any>, handAt: Map<number, any> }} ctx
*/
export function retreatSlot(ctx, uid) {
const e = ctx?.pieces?.get(uid);
if (!e || e.area !== 'board') return null;
for (let i = 0; i < GEO.HAND_SIZE; i++) if (!ctx.handAt?.has(i)) return { area: 'hand', idx: i };
if (e.piece.kind === 'token') return { area: 'hand', idx: 0 };
for (const [i, h] of ctx.handAt || []) {
if (h?.piece?.kind === 'token' && h.piece.ownerUid === e.piece.uid) return { area: 'hand', idx: i };
}
return null;
}
+383
View File
@@ -0,0 +1,383 @@
// Direction step of a board placement — the standard Arknights 4-direction deploy wheel (research 09 §1.2 / §6.1 5).
//
// After a legal drop of an operator / summon on a board tile (also its own tile: re-orient in place) — or of a
// directional Art (画卷) — the piece stays on the tile as a ghost and this overlay opens:
// * a white diamond centred on the tile, ~3 tiles across its diagonal, a smaller inner diamond (the centre
// dead-zone, ~0.5 tile) and 4 chevrons (UP / RIGHT / DOWN / LEFT);
// * a red "✕ 点击取消" tag on the diamond's upper-left edge;
// * press and drag from the centre: past the dead-zone that side's chevron lights and the unit's range grid, rotated
// to that direction (DESIGN §3), shows as orange striped tiles (view.highlightTiles group 'facing' under the units
// + a striped SVG layer — slotted between the 3D board canvas and the Pixi canvas when the 3D board is on, so the
// units stand on the stripes; an overlay otherwise); outside the centre the tooltip "拖回中心区域取消" shows;
// * on the mirrored right half of the Final Assault prep a screen direction maps to its board direction (RIGHT ↔
// LEFT, facing.js boardDir): the chevrons follow the finger, the range / model / intent use the board direction;
// * release outside the centre commits (g.move {uid, to, dir} / g.art {…, dir}); release inside the centre, a tap
// on ✕, a press outside the diamond or Esc cancels (the piece returns to where it came from). Arrow keys preview a
// direction and Enter commits it (keyboard alternative). Mouse, pen and touch all use pointer events.
//
// View bridge: the field view (render/app.js or the DOM fallback) may implement `tileScreen(row, col)`,
// `holdPiece(uid, tile|null)` and `setPieceDir(uid, dir)` (render/app.js does, DESIGN §9); a view without them falls
// back to the engine's dev hooks (`view.debug.cam / tiles / views / app`) for the same effect (projection of tile corners, pinning
// the dropped piece's view on the target tile, re-orienting its model). All of it is guarded: a missing hook only
// loses the visual, never the interaction.
import { useEffect, useLayoutEffect, useRef, useState } from '../../vendor/hooks.module.js';
import { html } from './components.js';
import { LocalSprite } from './gameComponents.js';
import { DIRS, DIR_LABEL, DEAD_ZONE_TILES, dirFromDelta, dirFromKey, rangeTiles, normDir, boardDir, viewMirrored } from './facing.js';
const cx = (...p) => p.flat().filter(Boolean).join(' ');
const rawOf = (view) => (view && view.raw) || view || null;
/** Range highlight of the wheel / a selected unit (orange, drawn under the units by the render engine). */
export const FACING_STYLE = Object.freeze({ group: 'facing', color: 0xff9c33, fill: 0.36, line: 1 });
// ---- view bridge ---------------------------------------------------------------------------------------------------
/**
* Screen geometry of a tile's top face, in client px: centre, px per tile at that depth, the 4 corners.
* @returns {{ x: number, y: number, s: number, poly: Array<[number, number]> } | null}
*/
export function tileScreen(view, row, col) {
const raw = rawOf(view);
if (!raw || !Number.isInteger(row) || !Number.isInteger(col)) return null;
try {
if (typeof raw.tileScreen === 'function') {
const r = raw.tileScreen(row, col);
if (r && Number.isFinite(r.x) && Number.isFinite(r.y)) return r;
}
const dbg = raw.debug;
const cam = dbg && dbg.cam;
const canvas = dbg && dbg.app && dbg.app.view;
if (cam && typeof cam.project === 'function' && canvas && typeof canvas.getBoundingClientRect === 'function') {
const z = dbg.tiles && typeof dbg.tiles.heightAt === 'function' ? dbg.tiles.heightAt(row, col) : 0;
const cr = canvas.getBoundingClientRect();
const c = cam.project(col, row, z);
const corner = (dx, dy) => { const p = cam.project(col + dx, row + dy, z); return [cr.left + p.x, cr.top + p.y]; };
return { x: cr.left + c.x, y: cr.top + c.y, s: c.s, poly: [corner(-0.5, 0.5), corner(0.5, 0.5), corner(0.5, -0.5), corner(-0.5, -0.5)] };
}
} catch { /* no geometry: the caller hides its visuals */ }
return null;
}
/** Per-view pins of the engine fallback (dev hooks): uid → { row, col }. */
const pins = new WeakMap();
function pinLoop(raw) {
const st = pins.get(raw);
if (!st || st.raf) return;
const step = () => {
st.raf = 0;
if (!st.map.size) return;
try {
const views = raw.debug && raw.debug.views;
const heightAt = raw.debug && raw.debug.tiles && raw.debug.tiles.heightAt ? (r, c) => raw.debug.tiles.heightAt(r, c) : () => 0;
for (const [uid, t] of st.map) {
const v = views && views.get('p:' + uid);
if (!v || v.destroyed) continue;
const w = { x: t.col, y: t.row, z: heightAt(t.row, t.col) };
v._home = w;
if (!v._tween || v._tween.tx !== w.x || v._tween.ty !== w.y) {
if (Math.abs(v.x - w.x) + Math.abs(v.y - w.y) > 1e-3) v._tween = { fx: v.x, fy: v.y, fz: v.z, tx: w.x, ty: w.y, tz: w.z, t: 0 };
else v._tween = null;
}
v.lift = 0;
}
} catch { /* view torn down */ }
st.raf = requestAnimationFrame(step);
};
st.raf = requestAnimationFrame(step);
}
/**
* Keep a dropped piece standing on `tile` while its direction is being chosen / the move is in flight (`tile` null
* releases it; the caller re-applies the prep state so the piece settles where the server says).
*/
export function holdPiece(view, uid, tile) {
const raw = rawOf(view);
if (!raw || !Number.isInteger(uid)) return;
try {
if (typeof raw.holdPiece === 'function') { raw.holdPiece(uid, tile || null); return; }
} catch { return; }
if (!raw.debug || !raw.debug.views) return;
let st = pins.get(raw);
if (!st) { st = { map: new Map(), raf: 0 }; pins.set(raw, st); }
if (tile && Number.isInteger(tile.row) && Number.isInteger(tile.col)) {
st.map.set(uid, { row: tile.row, col: tile.col });
pinLoop(raw);
} else {
st.map.delete(uid);
if (!st.map.size && st.raf) { cancelAnimationFrame(st.raf); st.raf = 0; }
}
}
/** Show a piece facing `dir` (live wheel preview, or the stored m.private `dir`). */
export function setPieceDir(view, uid, dir) {
const raw = rawOf(view);
if (!raw || !Number.isInteger(uid)) return;
try {
if (typeof raw.setPieceDir === 'function') { raw.setPieceDir(uid, normDir(dir)); return; }
const v = raw.debug && raw.debug.views && raw.debug.views.get('p:' + uid);
if (v && typeof v.setDir === 'function') v.setDir(normDir(dir));
} catch { /* cosmetic only */ }
}
/**
* Apply the stored facing of every own piece (board: `piece.dir`, default RIGHT; bench: RIGHT) to the view, except
* `skip` (the piece whose direction the wheel is previewing).
*/
export function syncPieceDirs(view, priv, skip = null) {
if (!view || !priv) return;
for (const p of Array.isArray(priv.board) ? priv.board : []) if (p && p.uid !== skip) setPieceDir(view, p.uid, p.dir);
for (const list of [priv.hand, priv.temp]) for (const p of Array.isArray(list) ? list : []) if (p && p.uid !== skip && p.kind !== 'item') setPieceDir(view, p.uid, 'RIGHT');
}
/** Light the rotated range of a piece on (row, col) facing `dir` (no grid / dir clears the style's group). */
export function showRange(view, grid, row, col, dir, style = FACING_STYLE) {
if (!view || typeof view.highlightTiles !== 'function') return [];
const tiles = grid && dir ? rangeTiles(grid, row, col, dir) : [];
try { view.highlightTiles(tiles, style); } catch { /* cosmetic */ }
return tiles;
}
/** Follow a tile's screen position (the camera may still be moving, the window may resize). */
export function useTileScreen(view, row, col) {
const [g, setG] = useState(() => tileScreen(view, row, col));
const last = useRef('');
useEffect(() => {
let raf = 0;
let alive = true;
// no tile (nothing selected): nothing to follow
if (!view || !Number.isInteger(row) || !Number.isInteger(col)) { last.current = ''; setG(null); return undefined; }
const tick = () => {
if (!alive) return;
const t = tileScreen(view, row, col);
const key = t ? `${t.x.toFixed(1)},${t.y.toFixed(1)},${t.s.toFixed(2)}` : '';
if (key !== last.current) { last.current = key; setG(t); }
raf = requestAnimationFrame(tick);
};
tick();
return () => { alive = false; cancelAnimationFrame(raf); };
}, [view, row, col]);
return g;
}
// ---- the wheel -------------------------------------------------------------------------------------------------------
/** Chevron centre (viewBox units, diamond half-diagonal = 100) and rotation per direction. */
const CHEV = { UP: [0, -64, -90], RIGHT: [64, 0, 0], DOWN: [0, 64, 90], LEFT: [-64, 0, 180] };
function Chevron({ dir, on }) {
const [x, y, rot] = CHEV[dir];
return html`<g class=${cx('fwheel__chev', on && 'is-on')} transform=${`translate(${x} ${y}) rotate(${rot})`}>
<path d="M-9 -15 L3 0 L-9 15 L-3 15 L9 0 L-3 -15 Z" />
</g>`;
}
/**
* The direction wheel overlay.
* @param {{ view: any, row: number, col: number, grid: Array<[number, number]>|null, name?: string,
* onPreview?: (dir: string|null) => void, onCommit: (dir: string) => void, onCancel: () => void }} props
*/
export function FacingWheel({ view, row, col, grid, name = '', onPreview, onCommit, onCancel }) {
const g = useTileScreen(view, row, col);
const [dir, setDir] = useState(null);
const [drag, setDrag] = useState(null); // { id } while a pointer is down
const live = useRef({});
const s = g && g.s > 0 ? g.s : 64;
const half = s * 1.5; // half-diagonal: ~3 tiles across
const dead = s * DEAD_ZONE_TILES; // centre dead-zone radius
// `dir` is the SCREEN direction (chevrons, swipe, arrow keys). On the mirrored right half of the Final Assault prep
// (research 09 §1.2: col c → 20 − c, RIGHT ↔ LEFT) the board direction differs: the range, the model preview and
// the committed g.move / g.art use the BOARD direction (render/prepfield.js maps it back for display).
const mirror = viewMirrored(view) || !!(g && g.mirror);
const bdir = boardDir(dir, mirror);
live.current = { g, half, dead, dir, bdir, mirror, onCommit, onCancel, onPreview };
// preview: model / wedge direction and the rotated range under the units
const tiles = useRangeHighlight(view, grid, row, col, bdir);
useEffect(() => { try { onPreview?.(bdir); } catch { /* ignore */ } }, [bdir]);
useEffect(() => () => { showRange(view, null, row, col, null); }, []);
const choose = (clientX, clientY) => {
const L = live.current;
if (!L.g) return null;
const d = dirFromDelta(clientX - L.g.x, clientY - L.g.y, L.dead);
setDir(d);
return d;
};
const inside = (clientX, clientY) => {
const L = live.current;
if (!L.g) return true;
return Math.abs(clientX - L.g.x) + Math.abs(clientY - L.g.y) <= L.half * 1.18;
};
const onDown = (e) => {
if (e.button != null && e.button !== 0) { e.preventDefault(); return; }
if (e.target && e.target.closest && e.target.closest('.fwheel__cancel')) return;
e.preventDefault();
if (!inside(e.clientX, e.clientY)) { onCancel(); return; }
try { e.currentTarget.setPointerCapture(e.pointerId); } catch { /* ignore */ }
setDrag({ id: e.pointerId });
choose(e.clientX, e.clientY);
};
const onMove = (e) => {
if (!drag || e.pointerId !== drag.id) return;
choose(e.clientX, e.clientY);
};
const onUp = (e) => {
if (!drag || e.pointerId !== drag.id) return;
setDrag(null);
const d = choose(e.clientX, e.clientY);
if (d) onCommit(boardDir(d, live.current.mirror)); else onCancel();
};
const onPointerCancel = () => { setDrag(null); setDir(null); };
// keyboard: arrows preview, Enter commits, Esc cancels (capture: the game's own shortcuts must not see them)
useEffect(() => {
const onKey = (e) => {
const L = live.current;
if (e.key === 'Escape') { e.preventDefault(); e.stopImmediatePropagation(); L.onCancel(); return; }
const k = dirFromKey(e.key);
if (k) { e.preventDefault(); e.stopImmediatePropagation(); setDir(k); return; }
if (e.key === 'Enter' && L.dir) { e.preventDefault(); e.stopImmediatePropagation(); L.onCommit(L.bdir); return; }
if (e.key === ' ' || /^Key[RFD]$/.test(e.code || '')) { e.preventDefault(); e.stopImmediatePropagation(); } // no ready / shop while choosing
};
window.addEventListener('keydown', onKey, true);
return () => window.removeEventListener('keydown', onKey, true);
}, []);
const outside = !!drag && !!dir;
const box = half * 2;
return html`<div class=${cx('fwheel', drag && 'is-pressed', dir && `is-${dir.toLowerCase()}`)} role="dialog" aria-label=${`选择${name ? `「${name}」的` : ''}朝向`}
onPointerDown=${onDown} onPointerMove=${onMove} onPointerUp=${onUp} onPointerCancel=${onPointerCancel}
onContextMenu=${(e) => { e.preventDefault(); onCancel(); }}>
${g ? html`<${Stripes} tiles=${tiles} view=${view} row=${row} col=${col} />` : null}
${g ? html`<div class="fwheel__dia" style=${`left:${g.x}px;top:${g.y}px;width:${box}px;height:${box}px`}>
<svg class="fwheel__svg" viewBox="-110 -110 220 220" aria-hidden="true">
<path class="fwheel__outer" d="M0 -100 L100 0 L0 100 L-100 0 Z" />
<path class=${cx('fwheel__quad', dir === 'UP' && 'is-on')} d="M0 -100 L50 -50 L0 0 L-50 -50 Z" />
<path class=${cx('fwheel__quad', dir === 'RIGHT' && 'is-on')} d="M100 0 L50 50 L0 0 L50 -50 Z" />
<path class=${cx('fwheel__quad', dir === 'DOWN' && 'is-on')} d="M0 100 L-50 50 L0 0 L50 50 Z" />
<path class=${cx('fwheel__quad', dir === 'LEFT' && 'is-on')} d="M-100 0 L-50 -50 L0 0 L-50 50 Z" />
<path class="fwheel__inner" d=${`M0 ${-DEAD_ZONE_TILES / 1.5 * 100} L${DEAD_ZONE_TILES / 1.5 * 100} 0 L0 ${DEAD_ZONE_TILES / 1.5 * 100} L${-DEAD_ZONE_TILES / 1.5 * 100} 0 Z`} />
${DIRS.map((d) => html`<${Chevron} key=${d} dir=${d} on=${dir === d} />`)}
</svg>
<button type="button" class="fwheel__cancel" onPointerDown=${(e) => e.stopPropagation()}
onClick=${(e) => { e.stopPropagation(); onCancel(); }} aria-label="点击取消">
<${LocalSprite} name="cancel_icon" class="fwheel__x" fallback=${html`<span class="fwheel__x fwheel__x--txt">✕</span>`} />
<span>点击取消</span>
</button>
${outside ? html`<span class="fwheel__tip" role="status">拖回中心区域取消</span>` : null}
<span class="fwheel__sr" aria-live="polite">${dir ? `朝向:${DIR_LABEL[dir]}` : ''}</span>
</div>` : null}
</div>`;
}
/** Keep the view's 'facing' highlight group on the rotated range; returns the tiles. */
function useRangeHighlight(view, grid, row, col, dir) {
const [tiles, setTiles] = useState([]);
useLayoutEffect(() => { setTiles(showRange(view, grid, row, col, dir)); }, [view, grid, row, col, dir]);
return tiles;
}
/**
* Where the striped range layer goes (client geometry is shared): with the 3D board on, UNDER the transparent Pixi
* canvas and above the three.js board canvas — the units stand on the stripes like in the original. Otherwise (2D
* board: the Pixi canvas is opaque; DOM fallback) the stripes are an overlay above the field and skip the piece's
* own tile. Returns null for the overlay case.
* @returns {{ host: HTMLElement, before: HTMLElement } | null}
*/
export function stripeMount(view) {
const raw = rawOf(view);
try {
const dbg = raw && raw.debug;
const canvas = dbg && dbg.app && dbg.app.view;
const host = canvas && canvas.parentElement;
if (!host || !dbg.board3d || typeof host.insertBefore !== 'function') return null;
return { host, before: canvas };
} catch { return null; }
}
const SVG_NS = 'http://www.w3.org/2000/svg';
let stripeSeq = 0;
/** Screen polygons (client px) of range tiles, as SVG `points` strings, offset by (ox, oy). */
export function stripePolys(view, tiles, ox = 0, oy = 0) {
const out = [];
for (const [r, c] of tiles) {
const t = tileScreen(view, r, c);
if (t && Array.isArray(t.poly) && t.poly.length >= 3) out.push({ k: `${r},${c}`, pts: t.poly.map((p) => `${(p[0] - ox).toFixed(1)},${(p[1] - oy).toFixed(1)}`).join(' ') });
}
return out;
}
/**
* Striped orange range tiles (the official look), re-projected every frame (the camera may still move). The solid
* orange fill is the render view's highlight group under the units; these are the light stripes on top of it.
*/
function Stripes({ tiles, view, row, col }) {
const [, setN] = useState(0);
const mount = stripeMount(view);
const key = tiles.map((t) => t.join(',')).join(';');
// under the units (3D board): an imperatively managed <svg> between the two canvases
useEffect(() => {
if (!mount || !tiles.length) return undefined;
const id = `fwheel-stripe-u${++stripeSeq}`;
const svg = document.createElementNS(SVG_NS, 'svg');
svg.setAttribute('class', 'fwheel__stripes fwheel__stripes--under');
svg.setAttribute('aria-hidden', 'true');
Object.assign(svg.style, { position: 'absolute', left: '0', top: '0', width: '100%', height: '100%', zIndex: '0', pointerEvents: 'none', overflow: 'visible' });
svg.innerHTML = `<defs><pattern id="${id}" width="10" height="10" patternUnits="userSpaceOnUse" patternTransform="rotate(45)">`
+ '<rect width="3.5" height="10" fill="rgba(255,178,70,.5)" /></pattern></defs><g></g>';
const g = svg.lastChild;
mount.host.insertBefore(svg, mount.before);
let raf = 0;
let last = '';
const tick = () => {
const hr = mount.host.getBoundingClientRect();
const polys = stripePolys(view, tiles, hr.left, hr.top);
const sig = polys.map((p) => p.pts).join('|');
if (sig !== last) {
last = sig;
g.replaceChildren(...polys.map((p) => {
const el = document.createElementNS(SVG_NS, 'polygon');
el.setAttribute('points', p.pts);
el.setAttribute('fill', `url(#${id})`);
el.setAttribute('stroke', 'rgba(255,176,70,.9)');
el.setAttribute('stroke-width', '1.5');
return el;
}));
}
raf = requestAnimationFrame(tick);
};
tick();
return () => { cancelAnimationFrame(raf); svg.remove(); };
}, [key, view, !!mount]);
// overlay (2D board / fallback): never over the piece's own tile, so the unit being placed stays visible
const over = mount ? [] : tiles.filter(([r, c]) => r !== row || c !== col);
const overKey = over.map((t) => t.join(',')).join(';');
useEffect(() => {
if (!over.length) return undefined;
let raf = 0;
let last = '';
const tick = () => {
const t = tileScreen(view, over[0][0], over[0][1]);
const k = t ? `${t.x.toFixed(1)},${t.y.toFixed(1)}` : '';
if (k !== last) { last = k; setN((n) => n + 1); }
raf = requestAnimationFrame(tick);
};
raf = requestAnimationFrame(tick);
return () => cancelAnimationFrame(raf);
}, [overKey, view]);
if (!over.length) return null;
const polys = stripePolys(view, over);
return html`<svg class="fwheel__stripes" aria-hidden="true">
<defs>
<pattern id="fwheel-stripe" width="10" height="10" patternUnits="userSpaceOnUse" patternTransform="rotate(45)">
<rect width="3.5" height="10" fill="rgba(255,178,70,.42)" />
</pattern>
</defs>
${polys.map((p) => html`<polygon key=${p.k} points=${p.pts} fill="url(#fwheel-stripe)" stroke="rgba(255,176,70,.9)" stroke-width="1.5" />`)}
</svg>`;
}
+507
View File
@@ -0,0 +1,507 @@
// DOM fallback for the Pixi field view (DESIGN §9 API). Used when public/js/render/app.js is missing or
// fails to initialise, so the match stays playable: a flat tile board + 整备区, pieces as avatars with
// tier chips, pointer drag & drop (same events as the render engine), and a simple battle view (units as
// avatars with HP/SP bars, interpolated by CSS transitions, optional damage numbers).
//
// API (same as createFieldView): setStage, setCamera, setPrep, enterBattle, pushSnapshot, pushEvents,
// highlightTiles, on, off, pieceScreenRect, resize, destroy (+ setSettings), plus the direction-step hooks of
// ui/facingWheel.js: tileScreen(row, col) (client geometry of a tile), holdPiece(uid, tile|null) (a dropped piece
// stays drawn on the target tile while its direction is chosen) and setPieceDir(uid, dir) (the facing wedge of a
// board piece). highlightTiles accepts `{ group }` styles: an empty list with a group clears only that group.
// setCamera('pen') shows the enemy preview pen (research 09 §2): the coming round's enemies (m.private.nextEnemies, or the
// scouted teammate's m.field { prep: true, nextEnemies }) placed
// like the official client and exactly where the render engine puts them (gameLogic penPlacement = render/pen.js
// layoutPen: rows 14–18 × cols 7–13, upper gate rows 17–18, lower gate 14–15, row 16 empty, ≤ 50 models, ≤ 3 per tile);
// tapping one emits pieceClick { enemyKey, preview: true, unit: { side: 'enemy', defId } }. setCamera('bossPrep') (the
// Final Assault prep) keeps the own board here — every coordinate the UI exchanges is a board coordinate anyway.
import { render } from '../../vendor/preact.module.js';
import { html, TierChip } from './components.js';
import { GEO } from '../../../shared/constants.js';
import { chessAvatarUrl, itemIconUrl, tokenAvatarUrl, enemyIconUrl } from './assetUrls.js';
import { tileKey, hasFlag, UF, penPlacement, PEN } from './gameLogic.js';
const DRAG_PX = 6;
const DMG_TTL = 900;
const cx = (...p) => p.flat().filter(Boolean).join(' ');
/** Avatar URL for a UnitInfo `avatar` asset id (chars / E2 / tokens / enemies). */
function unitArt(m, info) {
const id = info?.avatar || info?.defId;
if (!m || !id) return null;
if (info.side === 'enemy' || String(id).startsWith('enemy_')) return enemyIconUrl(m, id);
if (String(id).startsWith('token_')) return tokenAvatarUrl(m, id);
const chars = m.chars || {};
if (chars[id]?.avatar) return chars[id].avatar;
if (String(id).endsWith('_2') && chars[id.slice(0, -2)]) return chars[id.slice(0, -2)].avatarE2 || chars[id.slice(0, -2)].avatar;
return null;
}
/**
* Create the DOM fallback view.
* @param {HTMLElement} host
* @param {{ data: any, assets?: any, settings?: any }} opts
*/
export function createFallbackView(host, opts = {}) {
const dataStore = opts.data;
const m = () => opts.assets || dataStore?.get?.('assets') || null;
const lookup = (file, id) => dataStore?.lookup?.(file, id) ?? null;
const listeners = new Map();
const root = document.createElement('div');
root.className = 'ff';
host.appendChild(root);
const st = {
stage: null, camera: 'prep', rect: { ...GEO.NORMAL_RECT }, side: 'L',
mode: 'prep', priv: null, editable: false, canPlace: null,
field: null, units: new Map(), snapUnits: new Map(), highlight: new Map(), hlGroups: new Map(), held: new Map(), dirs: new Map(),
drag: null, hoverTarget: null, floats: [], settings: { damageNumbers: true, ...(opts.settings || {}) },
size: { w: 0, h: 0 }, destroyed: false, pen: [],
};
const emit = (type, payload) => {
for (const fn of [...(listeners.get(type) || [])]) {
try { fn(payload); } catch (err) { console.error('[fallback-field] listener failed', err); }
}
};
let raf = 0;
const schedule = () => {
if (raf || st.destroyed) return;
raf = requestAnimationFrame(() => { raf = 0; paint(); });
};
// ---- geometry ----------------------------------------------------------------------------------------
function layout() {
const w = st.size.w || host.clientWidth || 1;
const h = st.size.h || host.clientHeight || 1;
const rem = parseFloat(getComputedStyle(document.documentElement).fontSize) || 100;
const r = st.rect;
const cols = r.c1 - r.c0 + 1;
const prep = st.mode === 'prep';
const hasTemp = prep && Array.isArray(st.priv?.temp) && st.priv.temp.some(Boolean);
const rows = r.r1 - r.r0 + 1 + (prep ? 2.6 + (hasTemp ? 1.2 : 0) : 0); // + gap + hand (+ temp) rows
// keep clear of the HUD: top bar + bond strip above, shop bar (prep) / switcher (combat) below
const top0 = rem * 2.0;
const bottom0 = h - rem * (prep ? 3.3 : 1.1);
const availW = w * (prep ? 0.58 : 0.8);
const availH = Math.max(rem, bottom0 - top0);
const tile = Math.max(24, Math.floor(Math.min(availW / cols, availH / rows)));
const bw = tile * cols;
const bh = tile * rows;
const left = Math.round(w * (prep ? 0.44 : 0.5) - bw / 2);
const top = Math.round(top0 + (availH - bh) / 2);
return { tile, left, top, cols, rows: r.r1 - r.r0 + 1, bw, bh };
}
const tilePos = (L, row, col) => ({ x: (col - st.rect.c0) * L.tile, y: (st.rect.r1 - row) * L.tile });
const handPos = (L, idx) => ({ x: idx * L.tile, y: (L.rows + 0.6) * L.tile });
const tempPos = (L, idx) => ({ x: (GEO.TEMP_C0 + idx) * L.tile, y: (L.rows + 1.72) * L.tile });
// ---- pieces (prep) ---------------------------------------------------------------------------------------
function pieceArt(p) {
const mm = m();
if (p.kind === 'item') return itemIconUrl(mm, lookup('items', p.id));
if (p.kind === 'token') return tokenAvatarUrl(mm, p.id);
return chessAvatarUrl(mm, lookup('chess', p.id));
}
function pieceName(p) {
if (p.kind === 'item') return lookup('items', p.id)?.name || '道具';
if (p.kind === 'token') return lookup('tokens', p.id)?.name || '召唤物';
return lookup('chess', p.id)?.name || '干员';
}
function Piece({ p, x, y, L, area }) {
const src = pieceArt(p);
const dragging = st.drag?.uid === p.uid && st.drag.moved;
const golden = !!p.golden;
const tier = p.tier || lookup('chess', p.id)?.tier || lookup('items', p.id)?.tier;
const dir = st.dirs.get(p.uid) || (typeof p.dir === 'string' ? p.dir.toUpperCase() : 'RIGHT');
const wedge = (area === 'board' || st.held.has(p.uid)) && p.kind !== 'item';
return html`<div class=${cx('ff-piece', `ff-piece--${p.kind}`, golden && 'is-golden', dragging && 'is-lifted', st.editable && 'is-draggable', area === 'temp' && 'is-temp', wedge && `ff-dir--${dir.toLowerCase()}`)}
data-uid=${p.uid} style=${`transform:translate(${x}px,${y}px);width:${L.tile}px;height:${L.tile}px`}
onPointerDown=${(e) => onDown(e, p)} onContextMenu=${(e) => { e.preventDefault(); emit('pieceClick', { uid: p.uid, button: 2, clientX: e.clientX, clientY: e.clientY }); }}
onPointerEnter=${() => emit('pieceHover', { uid: p.uid })} onPointerLeave=${() => emit('pieceHover', { uid: null })}
title=${pieceName(p)}>
<div class="ff-piece__art">${src ? html`<img src=${src} alt="" draggable=${false} />` : html`<span>${[...pieceName(p)][0]}</span>`}</div>
${tier && p.kind !== 'token' ? html`<${TierChip} tier=${tier} golden=${golden} size="sm" class="ff-piece__tier" />` : null}
${p.kind === 'chess' && Array.isArray(p.items) && p.items.length ? html`<div class="ff-piece__items">${p.items.slice(0, 2).map((it) => html`<i key=${it.uid}></i>`)}</div>` : null}
${p.kind === 'token' && p.count > 1 ? html`<b class="ff-piece__count">×${p.count}</b>` : null}
${wedge ? html`<i class="ff-piece__dir" aria-hidden="true"></i>` : null}
</div>`;
}
function onDown(e, p) {
if (e.button !== 0) return;
e.preventDefault();
st.drag = { uid: p.uid, sx: e.clientX, sy: e.clientY, x: e.clientX, y: e.clientY, moved: false, pointerId: e.pointerId, piece: p };
window.addEventListener('pointermove', onMove);
window.addEventListener('pointerup', onUp);
window.addEventListener('pointercancel', onCancel);
}
function targetAt(clientX, clientY) {
const els = document.elementsFromPoint(clientX, clientY);
for (const el of els) {
if (!root.contains(el)) continue;
const t = el.closest?.('[data-drop]');
if (!t) continue;
if (t.dataset.drop === 'board') return { area: 'board', row: Number(t.dataset.row), col: Number(t.dataset.col) };
if (t.dataset.drop === 'hand') return { area: 'hand', idx: Number(t.dataset.idx) };
}
return { area: 'outside', clientX, clientY };
}
function onMove(e) {
const d = st.drag;
if (!d) return;
d.x = e.clientX; d.y = e.clientY;
if (!d.moved && Math.hypot(d.x - d.sx, d.y - d.sy) > DRAG_PX) {
if (!st.editable) return;
d.moved = true;
emit('pieceDragStart', { uid: d.uid });
}
if (d.moved) {
const t = targetAt(e.clientX, e.clientY);
st.hoverTarget = t;
emit('tileHover', t.area === 'board' ? { row: t.row, col: t.col } : null);
schedule();
}
}
function endDrag() {
window.removeEventListener('pointermove', onMove);
window.removeEventListener('pointerup', onUp);
window.removeEventListener('pointercancel', onCancel);
}
function onUp(e) {
const d = st.drag;
endDrag();
st.drag = null;
st.hoverTarget = null;
if (!d) return;
if (!d.moved) emit('pieceClick', { uid: d.uid, button: 0, clientX: e.clientX, clientY: e.clientY });
else emit('pieceDrop', { uid: d.uid, target: targetAt(e.clientX, e.clientY) });
schedule();
}
function onCancel() {
const d = st.drag;
endDrag();
st.drag = null;
st.hoverTarget = null;
if (d?.moved) emit('pieceDrop', { uid: d.uid, target: { area: 'outside', clientX: -1, clientY: -1 } });
schedule();
}
// ---- tiles ----------------------------------------------------------------------------------------------
function tileClass(row, col) {
const rows = st.stage?.rows;
const glyph = Array.isArray(rows) && typeof rows[row] === 'string' ? rows[row][col] : 'r';
const t = st.stage?.tiles?.[glyph];
if (glyph === 'S') return 'gate';
if (glyph === 'E') return 'goal';
if (!t) return 'void';
if (t.tileKey === 'tile_forbidden') return 'void';
if (t.height === 'HIGH') return t.buildable === 'NONE' ? 'void' : 'high';
if (t.special === 'mire' || glyph === 'm') return 'mire';
if (glyph === 'g') return 'smog';
if (glyph === 'd') return 'deep';
if (glyph === 'i') return 'infect';
if (t.buildable === 'NONE') return 'lane';
return 'floor';
}
function dropState(target) {
if (!st.drag?.moved || !target || typeof st.canPlace !== 'function') return null;
try { return st.canPlace(st.drag.uid, target) ? 'legal' : 'illegal'; } catch { return null; }
}
// ---- battle ---------------------------------------------------------------------------------------------------
function BattleUnit({ id, L }) {
const info = st.units.get(id);
const s = st.snapUnits.get(id);
if (!info || !s) return null;
const [, x, y, hp, maxHp, sp, spMax, flags, anim] = s;
const px = (x - st.rect.c0 + 0.5) * L.tile;
const py = (st.rect.r1 - y + 0.5) * L.tile;
const enemy = info.side === 'enemy';
const size = info.boss ? L.tile * 1.3 : L.tile * 0.86;
const src = unitArt(m(), info);
const dead = anim === 4;
const hpPct = maxHp > 0 ? Math.max(0, Math.min(100, (hp / maxHp) * 100)) : 0;
const spPct = spMax > 0 ? Math.max(0, Math.min(100, (sp / spMax) * 100)) : 0;
return html`<div class=${cx('ff-unit', enemy ? 'is-enemy' : 'is-ally', info.golden && 'is-golden', info.boss && 'is-boss', dead && 'is-dead',
hasFlag(flags, UF.SKILL) && 'is-skill', hasFlag(flags, UF.STUNNED | UF.FROZEN | UF.SLEEP) && 'is-stunned', hasFlag(flags, UF.FLYING) && 'is-fly')}
style=${`transform:translate(${px - size / 2}px,${py - size / 2}px);width:${size}px;height:${size}px`}
onPointerDown=${(e) => { if (e.button === 0) emit('pieceClick', { unitId: id, uid: info.uid ?? null, unit: info, button: 0, clientX: e.clientX, clientY: e.clientY }); }}
onContextMenu=${(e) => { e.preventDefault(); emit('pieceClick', { unitId: id, uid: info.uid ?? null, unit: info, button: 2, clientX: e.clientX, clientY: e.clientY }); }}>
<div class="ff-unit__art">${src ? html`<img src=${src} alt="" draggable=${false} />` : html`<span>${[...(info.name || '?')][0]}</span>`}</div>
<div class="ff-unit__bars"><i class="hp" style=${`width:${hpPct}%`}></i>${!enemy && spMax > 0 ? html`<i class="sp" style=${`width:${spPct}%`}></i>` : null}</div>
</div>`;
}
// ---- enemy preview pen (setCamera('pen')) ------------------------------------------------------------------------
function penView() {
const w = st.size.w || host.clientWidth || 1;
const h = st.size.h || host.clientHeight || 1;
const rem = parseFloat(getComputedStyle(document.documentElement).fontSize) || 100;
const cols = PEN.c1 - PEN.c0 + 1;
const rows = PEN.r1 - PEN.r0 + 1;
const top0 = rem * 1.3;
const tile = Math.max(28, Math.floor(Math.min((w * 0.62) / cols, (h - top0 - rem * 0.5) / rows)));
const bw = tile * cols;
const left = Math.round(w / 2 - bw / 2);
const top = Math.round(top0 + (h - top0 - rem * 0.5 - tile * rows) / 2);
const models = penPlacement(st.pen, { stage: st.stage });
const cells = [];
for (let r = PEN.r1; r >= PEN.r0; r--) {
for (let c = PEN.c0; c <= PEN.c1; c++) {
// the pen's own start tiles (15,7) / (18,7) are its gates (research 09 §2.1)
const gate = c === PEN.c0 && (r === PEN.anchors.upper[0] || r === PEN.anchors.lower[0]);
const cls = gate ? 'gate' : r === PEN.emptyRow ? 'void' : 'floor';
cells.push(html`<div key=${`${r},${c}`} class=${cx('ff-tile', `ff-tile--${cls}`)}
style=${`transform:translate(${(c - PEN.c0) * tile}px,${(PEN.r1 - r) * tile}px);width:${tile}px;height:${tile}px`}></div>`);
}
}
const size = tile * 0.62;
const off = [[0, 0], [-0.22, 0.14], [0.22, 0.14], [-0.2, -0.16], [0.2, -0.16]];
const figs = models.map((mdl) => {
const o = off[mdl.slot % off.length];
const x = (mdl.col - PEN.c0 + 0.5 + o[0]) * tile - size / 2;
const y = (PEN.r1 - mdl.row + 0.5 + o[1]) * tile - size / 2;
const src = unitArt(m(), { side: 'enemy', defId: mdl.enemyKey, avatar: mdl.enemyKey });
const name = lookup('enemies', mdl.enemyKey)?.name || mdl.enemyKey;
const tap = (e) => { if (e.button == null || e.button === 0) emit('pieceClick', { enemyKey: mdl.enemyKey, preview: true, unit: { side: 'enemy', defId: mdl.enemyKey, name }, button: 0, clientX: e.clientX, clientY: e.clientY }); };
return html`<button key=${mdl.k} type="button" class=${cx('ff-unit', 'is-enemy', 'ff-pen__enemy', (mdl.elite || mdl.boss) && 'is-boss', mdl.fly && 'is-fly')}
data-enemy=${mdl.enemyKey} title=${name} aria-label=${name}
style=${`transform:translate(${x}px,${y}px);width:${size}px;height:${size}px;z-index:${10 + mdl.slot}`} onClick=${tap}>
<div class="ff-unit__art">${src ? html`<img src=${src} alt="" draggable=${false} />` : html`<span>${[...name][0]}</span>`}</div>
</button>`;
});
return html`<div class="ff-board ff-board--pen" style=${`left:${left}px;top:${top}px;width:${bw}px;height:${tile * rows}px;--tile:${tile}px`}>
${cells}${figs}
${models.length ? null : html`<p class="ff-pen__empty">暂无敌方情报</p>`}
</div>`;
}
// ---- paint -----------------------------------------------------------------------------------------------------
function paint() {
if (st.destroyed) return;
const L = layout();
const r = st.rect;
const tiles = [];
for (let row = r.r1; row >= r.r0; row--) {
for (let col = r.c0; col <= r.c1; col++) {
const p = tilePos(L, row, col);
const k = tileKey(row, col);
let hl = st.highlight.get(k);
for (const g of st.hlGroups.values()) if (g.tiles.has(k)) hl = g.name;
const hov = st.hoverTarget?.area === 'board' && st.hoverTarget.row === row && st.hoverTarget.col === col ? dropState(st.hoverTarget) : null;
tiles.push(html`<div key=${k} class=${cx('ff-tile', `ff-tile--${tileClass(row, col)}`, hl && `is-${hl}`, hov && `is-hover-${hov}`)}
data-drop="board" data-row=${row} data-col=${col}
style=${`transform:translate(${p.x}px,${p.y}px);width:${L.tile}px;height:${L.tile}px`}></div>`);
}
}
const pieces = [];
const hand = [];
const now = performance.now();
if (st.mode === 'prep' && st.priv) {
const heldPos = (p) => { const t = st.held.get(p.uid); return t ? tilePos(L, t.row, t.col) : null; };
for (const p of Array.isArray(st.priv.board) ? st.priv.board : []) {
if (!p || !Number.isInteger(p.row)) continue;
const pos = heldPos(p) || tilePos(L, p.row, p.col);
pieces.push(html`<${Piece} key=${p.uid} p=${p} x=${pos.x} y=${pos.y} L=${L} area="board" />`);
}
for (let i = 0; i < GEO.HAND_SIZE; i++) {
const pos = handPos(L, i);
const hov = st.hoverTarget?.area === 'hand' && st.hoverTarget.idx === i ? dropState(st.hoverTarget) : null;
hand.push(html`<div key=${`h${i}`} class=${cx('ff-slot', hov && `is-hover-${hov}`)} data-drop="hand" data-idx=${i}
style=${`transform:translate(${pos.x}px,${pos.y}px);width:${L.tile}px;height:${L.tile}px`}><span class="num">${i + 1}</span></div>`);
const p = st.priv.hand?.[i];
if (p) { const hp = heldPos(p) || pos; pieces.push(html`<${Piece} key=${p.uid} p=${p} x=${hp.x} y=${hp.y} L=${L} area="hand" />`); }
}
const temp = Array.isArray(st.priv.temp) ? st.priv.temp : [];
if (temp.some(Boolean)) {
for (let i = 0; i < GEO.TEMP_SIZE; i++) {
const pos = tempPos(L, i);
hand.push(html`<div key=${`t${i}`} class="ff-slot ff-slot--temp" style=${`transform:translate(${pos.x}px,${pos.y}px);width:${L.tile}px;height:${L.tile}px`}></div>`);
const p = temp[i];
if (p) { const hp = heldPos(p) || pos; pieces.push(html`<${Piece} key=${p.uid} p=${p} x=${hp.x} y=${hp.y} L=${L} area="temp" />`); }
}
}
}
const units = st.mode === 'battle' ? [...st.snapUnits.keys()].map((id) => html`<${BattleUnit} key=${id} id=${id} L=${L} />`) : null;
st.floats = st.floats.filter((f) => now - f.at < DMG_TTL);
const floats = st.mode === 'battle' && st.settings.damageNumbers ? st.floats.map((f) => {
const s = st.snapUnits.get(f.id);
if (!s) return null;
const px = (s[1] - r.c0 + 0.5) * L.tile;
const py = (r.r1 - s[2] + 0.5) * L.tile - L.tile * 0.4;
return html`<span key=${f.key} class=${`ff-float ff-float--${f.type}`} style=${`left:${px}px;top:${py}px`}>${f.text}</span>`;
}) : null;
const d = st.drag;
const ghost = d?.moved ? (() => {
const hr = root.getBoundingClientRect();
const src = pieceArt(d.piece);
return html`<div class=${cx('ff-ghost', d.piece.golden && 'is-golden')} style=${`left:${d.x - hr.left - L.tile / 2}px;top:${d.y - hr.top - L.tile / 2}px;width:${L.tile}px;height:${L.tile}px`}>
${src ? html`<img src=${src} alt="" />` : null}</div>`;
})() : null;
if (st.camera === 'pen') { render(html`${penView()}<div class="ff-badge">SIMPLIFIED VIEW</div>`, root); return; }
render(html`<div class=${cx('ff-board', `ff-board--${st.mode}`, `ff-cam--${st.camera}`)} style=${`left:${L.left}px;top:${L.top}px;width:${L.bw}px;height:${L.bh}px;--tile:${L.tile}px`}>
${st.mode === 'prep' ? html`<div class="ff-hand-label" style=${`top:${(L.rows + 0.18) * L.tile}px`}><span>整备区</span><i></i></div>` : null}
${tiles}${hand}${units}${pieces}${floats}
</div>${ghost}
<div class="ff-badge">SIMPLIFIED VIEW</div>`, root);
if (st.floats.length) schedule();
}
// ---- public API -------------------------------------------------------------------------------------------------
const ro = typeof ResizeObserver !== 'undefined' ? new ResizeObserver(() => view.resize()) : null;
ro?.observe(host);
const view = {
kind: 'fallback',
setStage(stage) { st.stage = stage || null; schedule(); },
setCamera(kind, o = {}) {
st.camera = kind || 'prep';
if (kind === 'pen') { schedule(); return; } // the board keeps its rect for the way back
if (o && o.rect && Number.isFinite(o.rect.r0)) st.rect = { ...o.rect };
else if (kind === 'prep' || kind === 'bossPrep' || kind === 'normal') st.rect = { ...GEO.NORMAL_RECT };
if (kind === 'bossPrep') st.camera = 'prep';
else if (kind === 'unite') st.rect = { ...GEO.UNITE_RECT };
else if (kind === 'boss') st.rect = { ...GEO.BOSS_RECT };
st.side = o?.side || 'L';
schedule();
},
setPrep(priv, o = {}) {
st.mode = 'prep';
st.priv = priv || null;
st.pen = Array.isArray(priv?.nextEnemies) ? priv.nextEnemies : [];
st.editable = !!o.editable;
st.canPlace = typeof o.canPlace === 'function' ? o.canPlace : null;
if (st.camera !== 'prep' && st.camera !== 'pen') { st.camera = 'prep'; st.rect = { ...GEO.NORMAL_RECT }; }
if (!st.editable && st.drag) { endDrag(); st.drag = null; st.hoverTarget = null; }
schedule();
},
enterBattle(field) {
st.mode = 'battle';
st.field = field || null;
// a scouted teammate's prep board brings their pen; a real battle empties it
st.pen = field?.prep && Array.isArray(field.nextEnemies) ? field.nextEnemies : [];
st.units.clear();
st.snapUnits.clear();
st.floats = [];
if (field?.rect && Number.isFinite(field.rect.r0)) st.rect = { ...field.rect };
for (const u of Array.isArray(field?.units) ? field.units : []) if (u && u.id != null) {
st.units.set(u.id, u);
st.snapUnits.set(u.id, [u.id, u.x, u.y, u.maxHp, u.maxHp, 0, 0, 0, 0]);
}
schedule();
},
pushSnapshot(snap) {
if (st.mode !== 'battle' || !snap || !Array.isArray(snap.units)) return;
if (st.field && snap.fieldId && st.field.fieldId && snap.fieldId !== st.field.fieldId) return;
const seen = new Set();
for (const t of snap.units) {
if (!Array.isArray(t) || t.length < 9) continue;
seen.add(t[0]);
st.snapUnits.set(t[0], t);
}
for (const id of [...st.snapUnits.keys()]) if (!seen.has(id)) st.snapUnits.delete(id);
schedule();
},
pushEvents(payload) {
const ev = Array.isArray(payload) ? payload : payload && Array.isArray(payload.ev) ? payload.ev : null;
if (!ev) return;
if (!Array.isArray(payload) && st.field?.fieldId && payload.fieldId && payload.fieldId !== st.field.fieldId) return;
const now = performance.now();
for (const e of ev) {
if (!Array.isArray(e)) continue;
if (e[0] === 'spawn' && e[1] && e[1].id != null) {
st.units.set(e[1].id, e[1]);
if (!st.snapUnits.has(e[1].id)) st.snapUnits.set(e[1].id, [e[1].id, e[1].x, e[1].y, e[1].maxHp, e[1].maxHp, 0, 0, 0, 6]);
} else if ((e[0] === 'dmg' || e[0] === 'heal') && st.settings.damageNumbers && st.floats.length < 60) {
const amount = Math.round(Number(e[2]) || 0);
if (amount <= 0) continue;
const type = e[0] === 'heal' ? 'heal' : (e[3] === 'arts' ? 'arts' : e[3] === 'true' ? 'true' : 'phys');
st.floats.push({ key: `${now}:${Math.random()}`, id: e[1], text: e[0] === 'heal' ? `+${amount}` : String(amount), type, at: now });
}
}
schedule();
},
highlightTiles(tiles, style) {
if (!tiles || !style) { st.highlight.clear(); st.hlGroups.clear(); schedule(); return; }
if (typeof style === 'object' && style.group) {
// a named group (direction wheel / selected unit range): replaced as a whole, drawn as a range tile
const set = new Set();
for (const t of Array.isArray(tiles) ? tiles : []) {
const row = Array.isArray(t) ? t[0] : t?.row;
const col = Array.isArray(t) ? t[1] : t?.col;
if (Number.isInteger(row) && Number.isInteger(col)) set.add(tileKey(row, col));
}
if (set.size) st.hlGroups.set(style.group, { name: 'range', tiles: set }); else st.hlGroups.delete(style.group);
schedule();
return;
}
for (const t of Array.isArray(tiles) ? tiles : []) {
const row = Array.isArray(t) ? t[0] : t?.row;
const col = Array.isArray(t) ? t[1] : t?.col;
if (Number.isInteger(row) && Number.isInteger(col)) st.highlight.set(tileKey(row, col), style);
}
schedule();
},
setSettings(s) { st.settings = { ...st.settings, ...(s || {}) }; schedule(); },
on(type, fn) {
if (typeof fn !== 'function') return () => {};
if (!listeners.has(type)) listeners.set(type, new Set());
listeners.get(type).add(fn);
return () => listeners.get(type)?.delete(fn);
},
off(type, fn) { listeners.get(type)?.delete(fn); },
pieceScreenRect(uid) {
const el = root.querySelector(`[data-uid="${Number(uid)}"]`);
return el ? el.getBoundingClientRect() : null;
},
/** Client geometry of a tile: centre, px per tile, corners (board tiles and 整备区 slots of the prep layout). */
tileScreen(row, col) {
const L = layout();
const br = root.querySelector('.ff-board')?.getBoundingClientRect();
if (!br) return null;
let p = null;
if (row === GEO.HAND_ROW && col >= 0 && col < GEO.HAND_SIZE && st.mode === 'prep') p = handPos(L, col);
else if (row === GEO.TEMP_ROW && col >= GEO.TEMP_C0 && col < GEO.TEMP_C0 + GEO.TEMP_SIZE && st.mode === 'prep') p = tempPos(L, col - GEO.TEMP_C0);
else if (row >= st.rect.r0 && row <= st.rect.r1 && col >= st.rect.c0 && col <= st.rect.c1) p = tilePos(L, row, col);
if (!p) return null;
const x0 = br.left + p.x, y0 = br.top + p.y, s = L.tile;
return { x: x0 + s / 2, y: y0 + s / 2, s, poly: [[x0, y0], [x0 + s, y0], [x0 + s, y0 + s], [x0, y0 + s]] };
},
holdPiece(uid, tile) {
if (tile && Number.isInteger(tile.row) && Number.isInteger(tile.col)) st.held.set(uid, { row: tile.row, col: tile.col });
else st.held.delete(uid);
schedule();
},
setPieceDir(uid, dir) {
const d = typeof dir === 'string' ? dir.toUpperCase() : null;
if (d && ['UP', 'RIGHT', 'DOWN', 'LEFT'].includes(d)) st.dirs.set(uid, d); else st.dirs.delete(uid);
schedule();
},
resize() {
st.size = { w: host.clientWidth, h: host.clientHeight };
schedule();
},
destroy() {
if (st.destroyed) return;
st.destroyed = true;
endDrag();
ro?.disconnect();
if (raf) cancelAnimationFrame(raf);
try { render(null, root); } catch { /* ignore */ }
root.remove();
listeners.clear();
},
};
view.resize();
return view;
}
+140
View File
@@ -0,0 +1,140 @@
// Field view loader: mounts the Pixi render engine (public/js/render/app.js → createFieldView, DESIGN §9)
// into a host element, falling back to the DOM view (fallbackField.js) when the engine is missing, times
// out or throws. Every call into the view goes through a guard so a render bug can never crash the HUD.
//
// `?render=fallback` (or globalThis.__SP_RENDER__ = 'fallback') skips the engine (dev / mock harness);
// `?render=engine` never falls back silently (errors are logged and the fallback still mounts).
// relative, like every other client module (the import map only resolves to this same URL; no bare specifier here)
import { useEffect, useRef, useState } from '../../vendor/hooks.module.js';
import { createFallbackView } from './fallbackField.js';
import { data } from '../data.js';
import { audio } from '../audio.js';
import { settingsStore } from './settings.js';
const LOAD_TIMEOUT_MS = 12000;
const METHODS = ['setStage', 'setCamera', 'setPrep', 'enterBattle', 'pushSnapshot', 'pushEvents', 'highlightTiles', 'on', 'resize', 'destroy'];
// direction-step hooks (ui/facingWheel.js): optional — the wheel falls back to the engine's dev hooks when absent;
// setPen (enemy preview pen list), prepField ({ kind, side, mirror } of the Final Assault prep), stripesUnder (the view
// stripes range previews under the units itself) — render/app.js; the DOM fallback lacks them (→ null)
const OPTIONAL = ['pieceScreenRect', 'setSettings', 'off', 'tileScreen', 'holdPiece', 'setPieceDir', 'setPen', 'prepField', 'stripesUnder'];
/**
* Camera padding (px) that keeps the field clear of the DOM HUD (top bar + bond strip, team panel, shop bar /
* combat switcher, effects column). Sizes follow the rem scale of css/theme.css.
* The Final Assault prep ('bossPrep') has the prep HUD (shop bar below) on the boss field: prep padding; the enemy pen
* ('pen') is viewed with the shop collapsed.
* @param {string} kind 'prep'|'bossPrep'|'pen'|'normal'|'unite'|'boss'|'hidden'
* @param {{ width: number, height: number }} size
*/
export function hudPadding(kind, size) {
let rem = 100;
try { rem = parseFloat(getComputedStyle(document.documentElement).fontSize) || 100; } catch { /* ignore */ }
const h = size?.height || 1080;
const clampH = (v) => Math.min(v, h * 0.4);
if (kind === 'prep' || kind === 'bossPrep') return { top: clampH(rem * 1.95), bottom: clampH(rem * 3.2), left: rem * 2.25, right: rem * 0.9 };
if (kind === 'boss' || kind === 'hidden' || kind === 'unite') return { top: clampH(rem * 1.9), bottom: clampH(rem * 0.95), left: rem * 2.1, right: rem * 0.95 };
return { top: clampH(rem * 1.95), bottom: clampH(rem * 1.0), left: rem * 2.25, right: rem * 1.05 };
}
function renderPref() {
try {
const q = new URLSearchParams(globalThis.location?.search || '').get('render');
if (q === 'fallback' || q === 'engine') return q;
} catch { /* ignore */ }
return globalThis.__SP_RENDER__ === 'fallback' ? 'fallback' : 'auto';
}
function withTimeout(p, ms, what) {
return Promise.race([p, new Promise((_, rej) => setTimeout(() => rej(new Error(`${what} timed out`)), ms))]);
}
/**
* Wrap a view so no method can throw into the UI (each failing method is logged once).
* @param {any} view
* @param {'engine'|'fallback'} kind
*/
export function guardView(view, kind) {
const warned = new Set();
const safe = { kind, raw: view };
for (const name of [...METHODS, ...OPTIONAL]) {
safe[name] = (...args) => {
const fn = view && view[name];
if (typeof fn !== 'function') return name === 'on' ? () => {} : null;
try {
const out = fn.apply(view, args);
if (out && typeof out.then === 'function') out.catch((err) => { if (!warned.has(name)) { warned.add(name); console.warn(`[field] ${name} failed`, err); } });
if (name === 'on') return typeof out === 'function' ? out : () => { try { view.off?.(args[0], args[1]); } catch { /* ignore */ } };
return out;
} catch (err) {
if (!warned.has(name)) { warned.add(name); console.warn(`[field] ${kind} view ${name} failed`, err); }
return name === 'on' ? () => {} : null;
}
};
}
return safe;
}
/**
* Create a field view in `host`: the render engine when available, else the DOM fallback.
* @param {HTMLElement} host
* @returns {Promise<ReturnType<typeof guardView>>}
*/
export async function mountFieldView(host) {
const pref = renderPref();
const opts = { data, assets: data.get('assets'), audio, settings: settingsStore.get(), padding: hudPadding };
if (pref !== 'fallback') {
try {
// the shared asset store (public/js/assets.js) keeps its Spine cache across remounts (next match, reconnect)
const am = await withTimeout(import('../assets.js'), LOAD_TIMEOUT_MS, 'asset store import').catch(() => null);
if (am?.assets && typeof am.assets.ready === 'function') opts.assets = am.assets;
const mod = await withTimeout(import('../render/app.js'), LOAD_TIMEOUT_MS, 'render engine import');
if (typeof mod?.createFieldView !== 'function') throw new Error('createFieldView missing');
const view = await withTimeout(Promise.resolve(mod.createFieldView(host, opts)), LOAD_TIMEOUT_MS, 'createFieldView');
const missing = METHODS.filter((k) => typeof view?.[k] !== 'function');
if (missing.length) {
try { view?.destroy?.(); } catch { /* ignore */ }
throw new Error(`view lacks ${missing.join(', ')}`);
}
return guardView(view, 'engine');
} catch (err) {
console.warn('[field] render engine unavailable, using the simplified view:', err?.message || err);
// anything the engine left behind in the host goes
try { while (host.firstChild) host.removeChild(host.firstChild); } catch { /* ignore */ }
}
}
return guardView(createFallbackView(host, { ...opts, assets: data.get('assets') }), 'fallback');
}
/**
* Preact hook: mount a field view into `hostRef` once; returns { view, kind } (view null while loading).
* @param {{ current: HTMLElement|null }} hostRef
*/
export function useFieldView(hostRef) {
const [state, setState] = useState({ view: null, kind: 'loading' });
const viewRef = useRef(null);
useEffect(() => {
let dead = false;
const host = hostRef.current;
if (!host) return undefined;
mountFieldView(host).then((view) => {
if (dead) { view.destroy(); return; }
viewRef.current = view;
globalThis.__SP_VIEW__ = view; // dev / E2E introspection (view.raw.stats?.())
setState({ view, kind: view.kind });
}, (err) => console.error('[field] mount failed', err));
const unsub = settingsStore.subscribe((s) => viewRef.current?.setSettings?.(s));
const onResize = () => viewRef.current?.resize();
window.addEventListener('resize', onResize);
return () => {
dead = true;
unsub();
window.removeEventListener('resize', onResize);
// the dev hook must not keep the destroyed view — and through its host the whole detached match screen — alive
if (viewRef.current && globalThis.__SP_VIEW__ === viewRef.current) globalThis.__SP_VIEW__ = null;
viewRef.current?.destroy();
viewRef.current = null;
};
}, []);
return state;
}
+74
View File
@@ -0,0 +1,74 @@
// In-match intents (`g.*`, DESIGN §8.2) with uniform error handling: every request goes through
// `act()`, which plays a UI sound on success, shows the ERR_TEXT toast on failure and never throws.
// The UI never mutates state optimistically — it waits for the next m.private / m.public push.
// `net.request` is looked up at call time so the dev mock harness can stub it.
import { net } from '../net.js';
import { toastError } from './toasts.js';
import { audio } from '../audio.js';
const SUCCESS_SFX = {
'g.buy': 'buy', 'g.sell': 'sell', 'g.refresh': 'refresh', 'g.freeze': 'freeze', 'g.levelUp': 'levelup',
'g.move': 'drop', 'g.equip': 'equip', 'g.art': 'artPlace', 'g.reward': 'pick', 'g.choice': 'pick',
'g.band': 'confirm', 'g.bandSkip': 'back', 'g.infoReady': 'ready', 'g.emote': 'emote', 'g.destroy': 'back',
};
let inflight = 0;
const busyListeners = new Set();
const emitBusy = () => { for (const fn of [...busyListeners]) { try { fn(inflight); } catch { /* ignore */ } } };
/** Subscribe to the number of in-flight intents (for subtle busy indicators). */
export function onBusy(fn) { busyListeners.add(fn); return () => busyListeners.delete(fn); }
/**
* Send an intent. Resolves true on `ok`, false on error (already toasted).
* @param {string} t
* @param {object} [fields]
* @param {{ sfx?: string|false, quiet?: boolean }} [opts]
* @returns {Promise<boolean>}
*/
export async function act(t, fields = {}, opts = {}) {
inflight += 1;
emitBusy();
try {
await net.request(t, fields);
const s = opts.sfx === undefined ? SUCCESS_SFX[t] : opts.sfx;
if (s) audio.sfx(s);
return true;
} catch (err) {
if (!opts.quiet) {
toastError(err);
audio.sfx('error', { volume: 0.6 });
}
return false;
} finally {
inflight = Math.max(0, inflight - 1);
emitBusy();
}
}
export const actions = {
infoReady: () => act('g.infoReady'),
band: (bandId) => act('g.band', { bandId }),
bandSkip: () => act('g.bandSkip'),
buy: (slot) => act('g.buy', { slot }),
refresh: () => act('g.refresh'),
freeze: () => act('g.freeze'),
levelUp: () => act('g.levelUp'),
sell: (uid) => act('g.sell', { uid }),
// `dir` (UP|RIGHT|DOWN|LEFT): the direction chosen with the deploy wheel (research 09 §6.1 — g.move / g.art carry it;
// the server defaults to RIGHT when absent)
move: (uid, to, dir) => act('g.move', dir ? { uid, to, dir } : { uid, to }),
// `replaceUid`: the equipped item the replace dialog picked (ui/equipReplace.js) — it is destroyed; absent ⇒ none needed
equip: (itemUid, targetUid, replaceUid) => act('g.equip', Number.isInteger(replaceUid) ? { itemUid, targetUid, replaceUid } : { itemUid, targetUid }),
art: (itemUid, row, col, dir) => act('g.art', dir ? { itemUid, row, col, dir } : { itemUid, row, col }),
destroy: (uid) => act('g.destroy', { uid }),
reward: (idx) => act('g.reward', { idx }),
choice: (idx) => act('g.choice', { idx }),
ready: (ready) => act('g.ready', { ready }, { sfx: ready ? 'ready' : 'back' }),
emote: (id) => act('g.emote', { id }, { quiet: true }),
watch: (fieldId) => act('g.watch', { fieldId }, { sfx: 'tab' }),
autoplay: (on) => act('g.autoplay', { on }),
// solo battles only (ui/matchStatus.js pauseAvailable): m.public.paused follows
pause: (on) => act('g.pause', { on: !!on }, { sfx: on ? 'click' : 'confirm' }),
};
+225
View File
@@ -0,0 +1,225 @@
// Shared in-match building blocks (Preact + htm): data hook, images with fallbacks, rich text,
// unit thumbnails, LP tower, coin badge, official UI sprites. Styles: css/screens/game*.css.
import { useState, useMemo } from '../../vendor/hooks.module.js';
import { html, Icon, TierChip, Tooltip } from './components.js';
import { data, useData, localAsset } from '../data.js';
import { parseRichText, rtClassName } from './richText.js';
import {
uiUrl, chessAvatarUrl, chessPortraitUrl, itemIconUrl, tokenAvatarUrl, enemyIconUrl, bondIconUrl, bandIconUrl,
} from './assetUrls.js';
const cx = (...p) => p.flat().filter(Boolean).join(' ');
/** Data files the in-match screens use. */
export const GAME_FILES = ['config', 'assets', 'chess', 'bonds', 'items', 'bands', 'enemies', 'bosses', 'stages', 'tokens',
'choices', 'effects', 'garrisons', 'factions', 'local'];
/**
* Load every in-match data file; returns lookups (sync, null until loaded).
* @returns {{ ready: boolean, m: any, config: any, chess: (id:string)=>any, bond: (id:string)=>any, item: (id:string)=>any,
* band: (id:string)=>any, enemy: (k:string)=>any, boss: (id:string)=>any, stage: (id:string)=>any, token: (id:string)=>any,
* effect: (id:string)=>any, garrison: (id:string)=>any, factions: any, choices: any, list: (name:string)=>any[] }}
*/
export function useGameData() {
const ready = useData(...GAME_FILES);
return useMemo(() => makeLookups(ready), [ready]);
}
/** Non-hook lookups (for event handlers). */
export function makeLookups(ready = true) {
return {
ready,
m: data.get('assets'),
config: data.get('config'),
chess: (id) => data.lookup('chess', id),
bond: (id) => data.lookup('bonds', id),
item: (id) => data.lookup('items', id),
band: (id) => data.lookup('bands', id),
enemy: (k) => data.lookup('enemies', k),
boss: (id) => data.lookup('bosses', id),
stage: (id) => data.lookup('stages', id),
token: (id) => data.lookup('tokens', id),
effect: (id) => data.lookup('effects', id),
garrison: (id) => data.lookup('garrisons', id),
factions: data.get('factions'),
choices: data.get('choices'),
list: (name) => data.list(name),
};
}
/**
* <img> that swaps to a fallback node when the URL is missing or fails.
* @param {{ src?: string|null, class?: string, alt?: string, fallback?: any, style?: string }} props
*/
export function Img({ src, class: cls, alt = '', fallback = null, style }) {
const [bad, setBad] = useState(null);
if (!src || bad === src) return fallback;
return html`<img class=${cls} src=${src} alt=${alt} draggable=${false} loading="lazy" style=${style} onError=${() => setBad(src)} />`;
}
/** Official UI sprite by 'group/key' with a fallback. */
export function Sprite({ k, class: cls, fallback = null }) {
return html`<${Img} src=${uiUrl(data.get('assets'), k)} class=${cx('sprite', cls)} fallback=${fallback} />`;
}
/**
* Official sprite from the local-client extraction (data/local-assets.json, DESIGN §13) with a fallback node when the
* manifest / entry is missing or the image fails.
* @param {{ group?: string, name: string, class?: string, fallback?: any }} props
*/
export function LocalSprite({ group = 'ui/battle', name, class: cls, fallback = null }) {
return html`<${Img} src=${localAsset(group, name)} class=${cx('lsprite', cls)} fallback=${fallback} />`;
}
/**
* Rich text (official markup) → spans. Newlines become <br>.
* @param {{ text: any, class?: string, as?: string }} props
*/
export function RichText({ text, class: cls, as = 'span' }) {
const segs = useMemo(() => parseRichText(text), [text]);
const Tag = as;
return html`<${Tag} class=${cx('rt', cls)}>${segs.map((s, i) => (s.br
? html`<br key=${i} />`
: s.cls.length || s.term
? html`<span key=${i} class=${cx(s.cls.map((c) => rtClassName(c)), s.term && 'rt-term')}>${s.text}</span>`
: s.text))}<//>`;
}
/** Golden (elite) chess? */
export const isGoldenPiece = (piece, chess) => !!(piece?.golden || chess?.isGolden || (typeof piece?.id === 'string' && /_b$/.test(piece.id) && piece.kind !== 'item'));
/**
* Square unit thumbnail for a piece / chess / item / token / enemy: art + tier chip + elite frame.
* @param {{ kind?: 'chess'|'item'|'token'|'enemy', id: string, golden?: boolean, size?: 'xs'|'sm'|'md'|'lg', tier?: number,
* showTier?: boolean, class?: string, dim?: boolean, badge?: any, title?: string }} props
*/
export function UnitThumb({ kind = 'chess', id, golden, size = 'md', tier, showTier = true, class: cls, dim = false, badge = null, title }) {
const m = data.get('assets');
let src = null;
let name = '';
let t = tier;
if (kind === 'chess') {
const c = data.lookup('chess', id);
src = chessAvatarUrl(m, c);
name = c?.name || '';
t = t ?? c?.tier;
golden = golden ?? !!c?.isGolden;
} else if (kind === 'item') {
const it = data.lookup('items', id);
src = itemIconUrl(m, it);
name = it?.name || '';
t = t ?? it?.tier;
golden = golden ?? !!it?.isGolden;
} else if (kind === 'token') {
const tk = data.lookup('tokens', id);
src = tokenAvatarUrl(m, id);
name = tk?.name || '';
} else if (kind === 'enemy') {
const e = data.lookup('enemies', id);
src = enemyIconUrl(m, id);
name = e?.name || '';
}
const glyph = [...(name || '?')][0] || '?';
return html`<span class=${cx('uthumb', `uthumb--${size}`, `uthumb--${kind}`, golden && 'is-golden', dim && 'is-dim', t && `uthumb--t${Math.max(1, Math.min(6, t | 0))}`, cls)}
title=${title ?? name}>
<span class="uthumb__art">
<${Img} src=${src} fallback=${html`<span class="uthumb__glyph">${glyph}</span>`} />
</span>
${showTier && t && kind !== 'enemy' && kind !== 'token' ? html`<${TierChip} tier=${t} golden=${golden} size="sm" class="uthumb__tier" />` : null}
${kind === 'token' ? html`<span class="uthumb__tag">召唤</span>` : null}
${badge}
</span>`;
}
/** Thumbnail for an m.private Piece. */
export function PieceThumb({ piece, size = 'md', class: cls, badge }) {
if (!piece) return null;
return html`<${UnitThumb} kind=${piece.kind === 'item' ? 'item' : piece.kind === 'token' ? 'token' : 'chess'} id=${piece.id}
golden=${!!piece.golden} tier=${piece.tier} size=${size} class=${cls} badge=${badge} />`;
}
/** Extra 24×24 glyphs used by the in-match HUD (original shapes). */
const GLYPHS = {
gear: 'M10.3 2h3.4l.5 2.6c.6.2 1.2.5 1.7.9l2.5-.9 1.7 2.9-2 1.8c.1.6.1 1.2 0 1.8l2 1.8-1.7 2.9-2.5-.9c-.5.4-1.1.7-1.7.9l-.5 2.6h-3.4l-.5-2.6c-.6-.2-1.2-.5-1.7-.9l-2.5.9L2 14.9l2-1.8a6 6 0 0 1 0-1.8L2 9.5l1.7-2.9 2.5.9c.5-.4 1.1-.7 1.7-.9zM12 8.8a3.2 3.2 0 1 0 0 6.4 3.2 3.2 0 0 0 0-6.4z',
eye: 'M12 5c5 0 9 4.5 10 7-1 2.5-5 7-10 7S3 14.5 2 12c1-2.5 5-7 10-7zm0 3a4 4 0 1 0 0 8 4 4 0 0 0 0-8zm0 2a2 2 0 1 1 0 4 2 2 0 0 1 0-4z',
emote: 'M8.2 2h7.6L22 8.2v7.6L15.8 22H8.2L2 15.8V8.2zM8.5 8.5v3h2v-3zm5 0v3h2v-3zM7.8 14.2a5.5 5.5 0 0 0 8.4 0l-1.5-1.3a3.5 3.5 0 0 1-5.4 0z',
dp: 'M13 2 4 13.5h6.2L9 22l11-12.5h-6.4z',
skull: 'M12 2a8.5 8.5 0 0 0-5 15.4V21h3v-2h1v2h2v-2h1v2h3v-3.6A8.5 8.5 0 0 0 12 2zM8.5 10a2 2 0 1 1 0 4 2 2 0 0 1 0-4zm7 0a2 2 0 1 1 0 4 2 2 0 0 1 0-4z',
bolt: 'M13 2 4 14h7l-1 8 9-12h-7z',
sell: 'M12 2 3 7v10l9 5 9-5V7zm-1 5h2v2.1c1.3.3 2.3 1.2 2.4 2.4h-2c-.1-.4-.6-.7-1.4-.7-.9 0-1.3.3-1.3.7 0 .4.3.6 1.6.9 1.9.4 3.2 1 3.2 2.6 0 1.2-1 2.1-2.5 2.4V19h-2v-1.6c-1.4-.3-2.5-1.2-2.6-2.6h2c.1.5.7.9 1.6.9 1 0 1.5-.3 1.5-.8 0-.4-.4-.6-1.8-.9-1.8-.4-3-1-3-2.5 0-1.2 1-2 2.3-2.3z',
trash: 'M9 2h6l1 2h4v2H4V4h4zM5 7h14l-1.2 15H6.2zm4 3v9h2v-9zm4 0v9h2v-9z',
back: 'M10 5 3 12l7 7 1.4-1.4L6.8 13H21v-2H6.8l4.6-4.6z',
flag: 'M5 2h2v20H5zm3 1h11l-2.5 4.5L19 12H8z',
target: 'M11 2h2v3.1A7 7 0 0 1 18.9 11H22v2h-3.1A7 7 0 0 1 13 18.9V22h-2v-3.1A7 7 0 0 1 5.1 13H2v-2h3.1A7 7 0 0 1 11 5.1zm1 5a5 5 0 1 0 0 10 5 5 0 0 0 0-10zm0 3a2 2 0 1 1 0 4 2 2 0 0 1 0-4z',
};
/** Extra HUD glyph. */
export function GIcon({ name, class: cls, title }) {
const d = GLYPHS[name];
if (!d) return html`<${Icon} name=${name} class=${cls} title=${title} />`;
return html`<svg class=${cx('icon', cls)} viewBox="0 0 24 24" width="1em" height="1em" aria-hidden=${title ? undefined : 'true'} focusable="false">
${title ? html`<title>${title}</title>` : null}<path d=${d} fill-rule="evenodd" /></svg>`;
}
/** Green LP tower + number. */
export function LpTower({ value, size = 'md', class: cls, tone }) {
return html`<span class=${cx('lp', `lp--${size}`, tone && `lp--${tone}`, cls)} title="目标生命值">
<${Sprite} k="hudPanel/icon_hp" class="lp__icon" fallback=${html`<${Icon} name="rook" class="lp__icon" />`} />
<b class="num">${Number.isFinite(value) ? value : '--'}</b>
</span>`;
}
/** Coin glyph (three stacked gold triangles). */
export function CoinGlyph({ class: cls }) {
return html`<svg class=${cx('coin-glyph', cls)} viewBox="0 0 24 24" aria-hidden="true">
<path d="M12 3.5 16.2 10H7.8z" /><path d="M7 11.5 11.2 18H2.8z" /><path d="M17 11.5 21.2 18h-8.4z" />
</svg>`;
}
/** Bond glyph image (white mask tinted by CSS) or first-letter fallback. */
export function BondGlyph({ bondId, class: cls }) {
const b = data.lookup('bonds', bondId);
const src = bondIconUrl(data.get('assets'), bondId);
return html`<span class=${cx('bglyph', cls)} title=${b?.name || bondId}>
<${Img} src=${src} fallback=${html`<span class="bglyph__txt">${[...(b?.name || '?')][0]}</span>`} />
</span>`;
}
/** Band (strategy) icon. */
export function BandIcon({ bandId, class: cls, size = 'md' }) {
const b = data.lookup('bands', bandId);
return html`<span class=${cx('bandicon', `bandicon--${size}`, cls)} title=${b?.name || ''}>
<${Img} src=${bandIconUrl(data.get('assets'), bandId)} fallback=${html`<span class="bandicon__txt">${[...(b?.name || '?')][0]}</span>`} />
</span>`;
}
/** Item (equipment) icon with tier chip. */
export function ItemIcon({ itemId, class: cls, size = 'md', showTier = true }) {
return html`<${UnitThumb} kind="item" id=${itemId} size=${size} class=${cls} showTier=${showTier} />`;
}
/** Tooltip-wrapped help text with rich markup. */
export function RichTip({ text, children, placement = 'top' }) {
return html`<${Tooltip} text=${text ? html`<${RichText} text=${text} />` : null} placement=${placement}>${children}<//>`;
}
/**
* Seat-coloured player avatar: band icon when a band is picked, else glyph/robot.
* @param {{ player: any, size?: 'sm'|'md', self?: boolean, class?: string }} props
*/
export function PlayerAvatar({ player, size = 'md', self = false, class: cls }) {
const src = player?.bandId ? bandIconUrl(data.get('assets'), player.bandId) : null;
const glyph = [...(player?.name || '').trim()][0] || '?';
const dead = player?.alive === false || player?.status === 'dead';
const left = player?.status === 'left';
const hue = [162, 196, 38, 280][((player?.seat | 0) % 4 + 4) % 4];
return html`<span class=${cx('pavatar', `pavatar--${size}`, self && 'is-self', dead && 'is-dead', left && 'is-left', player?.isBot && 'is-bot',
player?.connected === false && !player?.isBot && 'is-offline', cls)} style=${`--seat-hue:${hue}`}>
<span class="pavatar__img">
<${Img} src=${src} fallback=${player?.isBot ? html`<${Icon} name="robot" class="pavatar__bot" />` : html`<span class="pavatar__glyph">${glyph}</span>`} />
</span>
${dead ? html`<span class="pavatar__x" aria-label="已淘汰"><${Icon} name="close" /></span>` : null}
${left ? html`<span class="pavatar__door" aria-label="已离开"><${Icon} name="exit" /></span>` : null}
</span>`;
}
File diff suppressed because it is too large. Load diff
+168
View File
@@ -0,0 +1,168 @@
// 玩法说明 (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?)`; <GuideHost/> is mounted once by main.js
// (and by the dev mock harness); <GuideButton/> 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.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`<div class="guide__tips">
<${MicroLabel} tone="mint">TIPS // 模拟要点</${MicroLabel}>
<ol>${tips.map((t, i) => html`<li key=${i}>${t}</li>`)}</ol>
${!tips.length ? html`<p class="t-lo">暂无说明内容</p>` : null}
</div>`;
}
/** 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`<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">
<div class="guide__titles">
<${MicroLabel} tone="mint">HOW TO PLAY // STRONGHOLD PROTOCOL</${MicroLabel}>
<h2 class="guide__title">玩法说明</h2>
</div>
${n ? html`<nav class="guide__chapters" aria-label="章节">
${GUIDE_CHAPTERS.map((ch, ci) => {
const at = firstOf(ci);
if (at < 0) return null;
return html`<button key=${ch.id} type="button" class=${cx('guide__chapter', cur?.chapter === ci && 'is-on')} onClick=${() => go(at)}>
<span class="guide__chname">${ch.name}</span><span class="guide__chmicro">${ch.micro}</span>
</button>`;
})}
</nav>` : null}
<button type="button" class="guide__close" aria-label="关闭" title="关闭 (Esc)" onClick=${closeGuide}><${Icon} name="close" /></button>
</header>
${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}
</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>`}
${n ? html`<footer class="guide__foot">
<div class="guide__label">
<span class="guide__chtag">${chapter?.name || ''}</span>
<b class="guide__ptitle">${cur?.title || ''}</b>
</div>
<div class="guide__dots" role="tablist" aria-label="页码">
${pages.map((p, k) => html`<button key=${p.key} type="button" role="tab" aria-selected=${k === i ? 'true' : 'false'} title=${p.title}
class=${cx('guide__dot', k === i && 'is-on', k > 0 && pages[k - 1].chapter !== p.chapter && 'is-first')} onClick=${() => go(k)}></button>`)}
</div>
<span class="guide__count num">${i + 1} / ${n}</span>
</footer>` : null}
</div>
</div>`;
}
+227
View File
@@ -0,0 +1,227 @@
// In-match top bar (research 06 §11.1): exit + ping (left); round box, phase capsule (prep label /
// kills n/m / boss HP bar), LP tower, match-info and enemy-preview buttons (centre, bracket frame);
// 7-segment countdown with gauge and the 准备就绪 toggle (right).
// The two 🔍 buttons follow the official HUD bindings (research 09 §2.1 / §6.2 item 3):
// left btn_check_player — mint [🔍] opens the 本局信息 dialog (ui/enemyDrawer.js; its 敌方情报 tab is the secondary
// enemy list); in the pen view it becomes [🔍◀◀] (btn_check_player_back) and returns the camera;
// right btn_check_enemy — amber [🔍▶▶] pans the camera to the enemy preview pen (休整期 only); in the pen view (and
// outside prep) it is the grey [🔍] (btn_check_enemy_unfold) — pressing it in the pen returns too.
// Official sprites (local ui/battle extraction) when installed, CSS look-alikes otherwise.
// Boss rounds (最终攻势 / 隐秘核心): the countdown is the level's 120 s maxPlayTime (m.public.deadline, gauge total from
// gameLogic phaseTotalSeconds) and the red DOT overtime warning under it keys off m.public.overtimeAt (ui/matchStatus.js
// overtimeState): "NN 秒后全队生命值开始流失" once the level time ran out, then a live "生命值 −1/秒" indicator while the
// merged team LP drains (the LP tower turns red). Solo battles: a pause / resume button beside the countdown (g.pause);
// while m.public.paused every clock here is frozen at the pause moment (`frozenAt`).
import { useRef } from '../../vendor/hooks.module.js';
import { PHASE } from '../../../shared/constants.js';
import { html, Button, Icon, PingPill, Countdown, Tooltip, MicroLabel, DifficultyTag, useTicker } from './components.js';
import { Sprite, LpTower, GIcon, LocalSprite } from './gameComponents.js';
import { localAsset } from '../data.js';
import { serverNow } from '../store.js';
import { isCombatPhase, isBossPhase, prepCapsuleLabel, bossFrac, fmtNum, shopBlockReason } from './gameLogic.js';
import { overtimeState, overtimeDrainPerSec, remainAt } from './matchStatus.js';
const cx = (...p) => p.flat().filter(Boolean).join(' ');
/**
* Phase capsule: prep label, kills n/m (combat/unite), kills + boss HP bar (boss rounds).
* @param {{ pub:any, hud:any }} props
*/
export function PhaseCapsule({ pub, hud }) {
const phase = pub?.phase;
if (isBossPhase(phase)) {
const boss = pub.bossHp || hud?.boss || null;
const frac = bossFrac(boss);
return html`<div class="capsule capsule--boss" role="status">
<${Sprite} k="hudPanel/icon_boss" class="capsule__icon" fallback=${html`<${GIcon} name="skull" class="capsule__icon" />`} />
${hud?.total != null ? html`<span class="capsule__kills num"><b>${hud.killed ?? 0}</b>/${hud.total}</span>` : null}
<div class="bossbar" title=${boss ? `${fmtNum(boss.hp)} / ${fmtNum(boss.max)}` : '敌方领袖'}>
<div class="bossbar__fill" style=${`width:${frac == null ? 100 : frac * 100}%`}></div>
<span class="bossbar__txt num">${frac == null ? '敌方领袖' : `${(frac * 100).toFixed(frac < 0.1 ? 1 : 0)}%`}</span>
</div>
</div>`;
}
if (isCombatPhase(phase) || (phase === PHASE.SETTLE && hud?.total != null)) {
return html`<div class=${cx('capsule', 'capsule--combat', phase === PHASE.UNITE && 'capsule--unite')} role="status">
<${Sprite} k=${phase === PHASE.UNITE ? 'hudPanel/icon_coop' : 'hudPanel/icon_battle'} class="capsule__icon"
fallback=${html`<${Icon} name="sword" class="capsule__icon" />`} />
<span class="capsule__kills num"><b>${hud?.killed ?? 0}</b>/${hud?.total ?? '--'}</span>
${phase === PHASE.UNITE ? html`<span class="capsule__tag">联防</span>` : null}
</div>`;
}
return html`<div class="capsule capsule--prep" role="status">
<${Sprite} k="hudPanel/icon_rest" class="capsule__icon" fallback=${html`<${Icon} name="rook" class="capsule__icon" />`} />
<span class="capsule__label">${prepCapsuleLabel(phase)}</span>
</div>`;
}
/**
* Ready toggle (PREP only). Disabled with a reason while the temp hand is non-empty.
* @param {{ priv:any, onToggle:(ready:boolean)=>void, busy?:boolean, readyCount?:number, total?:number }} props
*/
export function ReadyToggle({ priv, onToggle, busy, readyCount, total }) {
const ready = !!priv?.ready;
const reason = !ready ? shopBlockReason('ready', { priv, editable: true }) : null;
const btn = html`<button type="button" class=${cx('readybtn', 'tapx', ready && 'is-on', busy && 'is-busy')} disabled=${!!reason || busy}
aria-pressed=${ready ? 'true' : 'false'} onClick=${() => onToggle(!ready)}>
<span class="readybtn__box">${ready ? html`<${Icon} name="check" />` : null}</span>
<span class="readybtn__label">${ready ? '取消准备' : '准备就绪'}</span>
<kbd class="readybtn__key">Space</kbd>
</button>`;
return html`<div class="readywrap">
${reason ? html`<${Tooltip} text=${reason} placement="bottom">${btn}<//>` : btn}
${Number.isFinite(total) && total > 1 ? html`<span class="readywrap__count">已就绪 <b class="num">${readyCount}</b>/<span class="num">${total}</span></span>` : null}
</div>`;
}
/**
* Sprite + look of the two 🔍 HUD buttons for a state (pure; tested in test/ui/uiFixes.test.js).
* @param {{ pen: boolean, penAvail: boolean, infoOpen: boolean }} st
* @returns {{ left: { sprite: string, back: boolean, label: string, tip: string }, right: { sprite: string, grey: boolean, label: string, tip: string } }}
*/
export function checkButtons({ pen, penAvail, infoOpen }) {
const left = pen
? { sprite: 'btn_check_player_back', back: true, label: '返回', tip: '返回战场' }
: { sprite: infoOpen ? 'btn_check_player_unfold' : 'btn_check_player_normal', back: false, label: '本局信息', tip: '本局信息(策略 / 禁用盟约 / 干员)' };
const right = pen
? { sprite: 'btn_check_enemy_unfold', grey: true, label: '返回', tip: '返回' }
: penAvail
? { sprite: 'btn_check_enemy', grey: false, label: '敌方情报', tip: '查看即将迎击的敌方单位' }
: { sprite: 'btn_check_enemy_unfold', grey: true, label: '敌方情报', tip: '休整期可以查看即将迎击的敌方单位' };
return { left, right };
}
/** One official 🔍 button: the sprite when installed, a CSS look-alike (icon + chevrons) otherwise. */
function CheckBtn({ sprite, cls, label, chev, on, disabled, onClick, testid }) {
const url = localAsset('ui/battle', sprite);
return html`<button type="button" class=${cx(cls, 'tapx', url && 'has-sprite', chev && 'is-wide', on && 'is-on')}
style=${url ? `--chk-sprite:url("${url}")` : ''} aria-label=${label} aria-disabled=${disabled ? 'true' : 'false'}
data-sprite=${sprite} data-testid=${testid} onClick=${onClick}>
${url ? null : html`<${Icon} name="search" />${chev ? html`<span class="enemybtn__chev">${chev}</span>` : null}`}
</button>`;
}
/**
* The red overtime (DOT) warning of a boss round (pure view of ui/matchStatus.js overtimeState).
* @param {{ ot: ReturnType<typeof overtimeState> }} props
*/
export function OvertimeWarning({ ot }) {
if (!ot) return null;
if (ot.state === 'pending') {
return html`<div class="otwarn otwarn--pending" role="alert" data-state="pending">
<${LocalSprite} name="icon_warn" class="otwarn__icon" fallback=${html`<${Icon} name="warn" class="otwarn__icon" />`} />
<span class="otwarn__tag">DOT</span>
<span class="otwarn__txt"><b class="num">${ot.secs}</b> 秒后全队生命值开始流失</span>
</div>`;
}
return html`<div class="otwarn otwarn--drain" role="alert" data-state="drain">
<span class="otwarn__blood">
<${LocalSprite} name="blood_icon" class="otwarn__icon" fallback=${html`<${Icon} name="rook" class="otwarn__icon" />`} />
</span>
<span class="otwarn__tag">DOT</span>
<span class="otwarn__txt">超时 · 生命值 <b class="num">−${ot.perSec}</b>/秒</span>
${ot.lost > 0 ? html`<span class="otwarn__lost">已流失 <b class="num">${ot.lost}</b></span>` : null}
<span key=${ot.secs} class="otwarn__tick num" aria-hidden="true">−${ot.perSec}</span>
</div>`;
}
/** Two-bar pause glyph (the official battle pause button). */
function PauseGlyph() {
return html`<svg class="pausebtn__glyph" viewBox="0 0 24 24" aria-hidden="true"><path d="M6 4h4.5v16H6zm7.5 0H18v16h-4.5z" /></svg>`;
}
/**
* Solo pause / resume control (g.pause): pressed while paused.
* @param {{ paused: boolean, busy?: boolean, onToggle: () => void }} props
*/
export function PauseButton({ paused, busy = false, onToggle }) {
const label = paused ? '继续作战' : '暂停';
return html`<${Tooltip} text=${paused ? '继续作战(Space)' : '暂停作战(Space)'} placement="bottom">
<button type="button" class=${cx('pausebtn', 'tapx', paused && 'is-on', busy && 'is-busy')} aria-pressed=${paused ? 'true' : 'false'}
aria-label=${label} disabled=${busy} data-testid="pause" onClick=${() => onToggle?.()}>
${paused ? html`<${Icon} name="play" class="pausebtn__glyph" />` : html`<${PauseGlyph} />`}
</button>
<//>`;
}
/**
* Top bar.
* @param {{ pub:any, priv:any, conn:any, hud:any, total:number|null, drawer:string|null, onExit:Function, onDrawer:(tab:string)=>void,
* onReady:(r:boolean)=>void, readyBusy?:boolean, readyCount?:number, playerCount?:number,
* pen?:boolean, penAvail?:boolean, onPen?:(on:boolean)=>void, config?: any, frozenAt?: number|null,
* pause?: { show: boolean, paused: boolean, busy?: boolean, onToggle: () => void } | null }} props
* frozenAt: the server time every clock shows while the solo match is paused (null = live)
*/
export function TopBar({ pub, priv, conn, hud, total, drawer, onExit, onDrawer, onReady, readyBusy, readyCount, playerCount, pen = false, penAvail = false, onPen = () => {},
config = null, frozenAt = null, pause = null }) {
const phase = pub?.phase;
const boss = isBossPhase(phase);
const lp = boss && Number.isFinite(pub?.teamLp) ? pub.teamLp : Number.isFinite(priv?.lp) ? priv.lp : null;
const hidden = phase === PHASE.HIDDEN_CORE || (Number.isFinite(pub?.lastRound) && pub.round > pub.lastRound);
const roundText = hidden ? '??' : pub?.round > 0 ? String(pub.round) : '--';
const showReady = phase === PHASE.PREP && priv?.alive !== false;
// boss rounds: the overtime warning follows the clock (4 Hz while live; frozen while paused)
const otLive = boss && Number(pub?.overtimeAt) > 0;
useTicker(otLive && frozenAt == null ? 250 : 0);
const now = Number.isFinite(frozenAt) ? frozenAt : serverNow();
const ot = otLive ? overtimeState(pub, now, { perSec: overtimeDrainPerSec(config) }) : null;
const draining = ot?.state === 'drain';
const lowLp = (Number.isFinite(lp) && lp <= 5) || draining;
const frozenSecs = Number.isFinite(frozenAt) ? remainAt(pub?.deadline, frozenAt) : null;
const btn = checkButtons({ pen, penAvail, infoOpen: !!drawer });
const onLeft = () => (pen ? onPen(false) : onDrawer('info'));
const onRight = () => (pen ? onPen(false) : penAvail ? onPen(true) : null);
return html`<header class=${cx('gtop', pen && 'is-pen')}>
<div class="gtop__left">
<${Button} variant="danger" size="lg" square=${true} icon="exit" onClick=${onExit} aria-label="离开" title="离开 / 暂离" class="gtop__exit tapx" />
<div class="gtop__meta">
<${PingPill} ms=${conn?.ping} online=${conn?.status === 'online'} />
${pub?.difficulty ? html`<${DifficultyTag} difficulty=${pub.difficulty} size="sm" />` : null}
</div>
</div>
<div class="gtop__center brackets">
<${Tooltip} text=${btn.left.tip} placement="bottom">
<${CheckBtn} sprite=${btn.left.sprite} cls=${cx('gtop__iconbtn', btn.left.back && 'is-back')} label=${btn.left.label}
chev=${btn.left.back ? '◀◀' : null} on=${!!drawer && !pen} onClick=${onLeft} testid="check-player" />
<//>
<div class="roundbox">
<span class="roundbox__label">回合</span>
<b class="roundbox__num num">${roundText}</b>
</div>
<${PhaseCapsule} pub=${pub} hud=${hud} />
<${LpTower} value=${lp} size="lg" tone=${lowLp ? 'danger' : boss ? 'team' : null} />
<${Tooltip} text=${btn.right.tip} placement="bottom">
<${CheckBtn} sprite=${btn.right.sprite} cls=${cx('enemybtn', btn.right.grey && 'is-grey')} label=${btn.right.label}
chev=${btn.right.grey ? null : '▶▶'} disabled=${btn.right.grey && !pen} onClick=${onRight} testid="check-enemy" />
<//>
</div>
<div class="gtop__right">
<div class="gtop__clock">
${frozenSecs != null
? html`<${Countdown} seconds=${frozenSecs} total=${total ?? undefined} size="md" label="PAUSED" />`
: html`<${Countdown} deadline=${pub?.deadline} total=${total ?? undefined} size="md" />`}
${pause && (pause.show || pause.paused) ? html`<${PauseButton} paused=${!!pause.paused} busy=${pause.busy} onToggle=${pause.onToggle} />` : null}
</div>
<${OvertimeWarning} ot=${ot} />
${showReady ? html`<${ReadyToggle} priv=${priv} onToggle=${onReady} busy=${readyBusy} readyCount=${readyCount} total=${playerCount} />` : null}
</div>
</header>`;
}
/** DP counter shown at the right edge during combat. */
export function DpCounter({ dp }) {
if (!Number.isFinite(dp)) return null;
return html`<div class="dpbox" title="部署费用(再部署消耗)">
<${GIcon} name="dp" class="dpbox__icon" /><b class="num">${Math.floor(dp)}</b><${MicroLabel}>COST</${MicroLabel}>
</div>`;
}
/** Hook: stable callback ref helper. */
export function useLatest(v) {
const r = useRef(v);
r.current = v;
return r;
}
+302
View File
@@ -0,0 +1,302 @@
// Operator loadout model (DESIGN §16) — pure logic of the 干员调配 screen (screens/loadout.js), shared with the sync
// (ui/loadoutSync.js) and usable by the in-match UI (shop cards / detail panel: `effectiveChoice`, `selectedSkill`).
//
// The per-browser loadout is `{ [baseChessId]: { skill?: skillIndex, module?: uniEquipId | 'none' } }`, persisted in
// localStorage (`sp.pref.loadout` = { v: 1, entries }) and sent with C2S `room.loadout { entries }`. Only choices that
// differ from the chess's defaults are kept. The legal choices come from data/chess.json (DESIGN §16): every chess has
// `skills[]` (SkillRecord at its own skill level — the normal chess Lv4, the elite Lv7) and elites `modules[]`
// (ModuleRecord: uniEquipId, name, typeName, attr, traitOverride, talentChanges, isDefault) — while the data lacks
// them only the default skill / module is offered. The option rules are shared with the server
// (shared/protocol.js loadoutOptions / checkLoadout), so a sanitised loadout is always accepted.
import { loadoutOptions, checkLoadout, resolveLoadout, MODULE_NONE, LOADOUT_LIMITS } from '../../../shared/protocol.js';
export { MODULE_NONE };
/** localStorage key (store.js loadPref/savePref prefix `sp.pref.`) and format version. */
export const LOADOUT_PREF = 'loadout';
export const LOADOUT_VERSION = 1;
export const PROF_ORDER = ['PIONEER', 'WARRIOR', 'TANK', 'SNIPER', 'CASTER', 'MEDIC', 'SUPPORT', 'SPECIAL'];
export const PROF_NAME = Object.freeze({ PIONEER: '先锋', WARRIOR: '近卫', TANK: '重装', SNIPER: '狙击', CASTER: '术师', MEDIC: '医疗', SUPPORT: '辅助', SPECIAL: '特种' });
export const SP_TYPE = Object.freeze({ INCREASE_WITH_TIME: '自动回复', INCREASE_WHEN_ATTACK: '攻击回复', INCREASE_WHEN_TAKEN_DAMAGE: '受击回复', ON_DEPLOY: '被动', 8: '被动' });
/** Module attribute keys (ModuleRecord.attr / battle_equip attributeBlackboard) → label + unit. */
export const ATTR_LABEL = Object.freeze({
maxHp: ['生命上限', ''], max_hp: ['生命上限', ''], atk: ['攻击力', ''], def: ['防御力', ''], res: ['法术抗性', ''],
magic_resistance: ['法术抗性', ''], aspd: ['攻击速度', ''], attack_speed: ['攻击速度', ''], cost: ['部署费用', ''],
blockCnt: ['阻挡数', ''], block_cnt: ['阻挡数', ''], respawnTime: ['再部署时间', '秒'], respawn_time: ['再部署时间', '秒'],
baseAttackTime: ['攻击间隔', '秒'], base_attack_time: ['攻击间隔', '秒'], moveSpeed: ['移动速度', ''], hpRecoveryPerSec: ['每秒回复', ''],
});
const isObj = (v) => !!v && typeof v === 'object' && !Array.isArray(v);
const isInt = (v) => Number.isInteger(v);
// ---- storage -----------------------------------------------------------------------------------------------------
/**
* Parse a stored loadout (any junk → {}): keeps structurally valid entries only (ids, skill ints, module ids).
* @param {any} raw `{ v, entries }` (or a bare entries map from an older build)
* @returns {Record<string, { skill?: number, module?: string }>}
*/
export function parseStored(raw) {
const src = isObj(raw) && isObj(raw.entries) ? raw.entries : isObj(raw) && raw.v == null ? raw : null;
const out = {};
if (!src) return out;
for (const [id, e] of Object.entries(src)) {
if (Object.keys(out).length >= LOADOUT_LIMITS.entries) break;
if (!/^[A-Za-z0-9_\-.:]{1,64}$/.test(id) || !isObj(e)) continue;
const x = {};
if (isInt(e.skill) && e.skill >= 0 && e.skill <= LOADOUT_LIMITS.skillIndex) x.skill = e.skill;
if (typeof e.module === 'string' && /^[A-Za-z0-9_\-.:]{1,64}$/.test(e.module)) x.module = e.module;
if (Object.keys(x).length) out[id] = x;
}
return out;
}
/** Serialised form for localStorage. */
export const toStored = (entries) => ({ v: LOADOUT_VERSION, entries: entries || {} });
// ---- options & choices ---------------------------------------------------------------------------------------------
/**
* The chess records of one loadout slot.
* @param {string} baseId normal chess id
* @param {(id: string) => any} getChess
* @returns {{ base: any, golden: any }}
*/
export function recordsOf(baseId, getChess) {
const base = getChess(baseId) || null;
const golden = base && base.goldenId ? getChess(base.goldenId) || null : null;
return { base, golden };
}
/** SkillRecord of a chess record by skill index (data `skills[]`, else the default `skill`). */
export function skillRecord(chess, index) {
if (!chess) return null;
if (Array.isArray(chess.skills)) {
const s = chess.skills.find((x) => x && x.index === index);
if (s) return s;
}
return chess.skill && chess.skill.index === index ? chess.skill : null;
}
/** ModuleRecord of an elite by uniEquipId (data `modules[]`, else a minimal record from the default `module`). */
export function moduleRecord(golden, id) {
if (!golden || !id || id === MODULE_NONE) return null;
if (Array.isArray(golden.modules)) {
const m = golden.modules.find((x) => x && x.uniEquipId === id);
if (m) return m;
}
const d = golden.module;
return d && d.id === id ? { uniEquipId: d.id, name: d.name, typeName: d.type, isDefault: true, attr: null, traitOverride: null, talentChanges: [] } : null;
}
/**
* Everything the screen shows for one chess: its skill options (normal Lv4 + elite Lv7 records) and module options.
* @param {any} base normal chess record
* @param {any} golden elite record (or null)
*/
export function chessOptions(base, golden) {
const opt = loadoutOptions(base, golden);
const skills = opt.skills.map((index) => ({
index,
normal: skillRecord(base, index),
elite: skillRecord(golden, index),
isDefault: index === opt.defaultSkill,
}));
const modules = opt.modules.map((id) => ({
id,
rec: moduleRecord(golden, id),
isDefault: id === opt.defaultModule,
}));
return { ...opt, skillOptions: skills, moduleOptions: modules };
}
/**
* The effective choice of a chess under a stored loadout (defaults for missing / unavailable choices).
* @returns {{ skill: number|null, module: string|null, changed: boolean }}
*/
export function effectiveChoice(entries, base, golden) {
const opt = loadoutOptions(base, golden);
const e = base && entries && Object.hasOwn(entries, base.chessId) ? entries[base.chessId] : null;
const skill = e && opt.skills.includes(e.skill) ? e.skill : opt.defaultSkill;
const module = golden ? (e && opt.modules.includes(e.module) ? e.module : opt.defaultModule) : null;
return { skill, module, changed: skill !== opt.defaultSkill || module !== opt.defaultModule };
}
/**
* Set (part of) one chess's choice; an entry equal to the defaults is removed. Returns a new entries map.
* @param {Record<string, any>} entries
* @param {any} base @param {any} golden
* @param {{ skill?: number, module?: string }} patch
*/
export function setChoice(entries, base, golden, patch) {
if (!base) return entries;
const opt = loadoutOptions(base, golden);
const cur = effectiveChoice(entries, base, golden);
const skill = patch && patch.skill !== undefined && opt.skills.includes(patch.skill) ? patch.skill : cur.skill;
const module = golden && patch && patch.module !== undefined && opt.modules.includes(patch.module) ? patch.module : cur.module;
const out = { ...(entries || {}) };
delete out[base.chessId];
const e = {};
if (skill !== opt.defaultSkill && skill != null) e.skill = skill;
if (golden && module !== opt.defaultModule && module != null) e.module = module;
if (Object.keys(e).length) out[base.chessId] = e;
return out;
}
/** Remove one chess's entry (恢复默认). */
export function resetChoice(entries, baseId) {
if (!entries || !Object.hasOwn(entries, baseId)) return entries;
const out = { ...entries };
delete out[baseId];
return out;
}
/**
* The entries to send (`room.loadout.entries`): every stored entry that is still legal for the loaded data, the
* rest dropped one by one (a stale browser loadout never gets the whole message refused). Defaults are dropped.
* @param {Record<string, any>} entries
* @param {(id: string) => any} getChess
* @returns {Record<string, { skill?: number, module?: string }>}
*/
export function sanitizeEntries(entries, getChess) {
const out = {};
for (const [id, e] of Object.entries(entries || {})) {
if (Object.keys(out).length >= LOADOUT_LIMITS.entries) break;
const one = {};
if (isInt(e?.skill)) one.skill = e.skill;
if (typeof e?.module === 'string') one.module = e.module;
if (!Object.keys(one).length) continue;
const res = checkLoadout({ [id]: one }, getChess);
if (!res.ok) {
// keep the part that is still legal (e.g. the skill when a module disappeared)
for (const k of ['skill', 'module']) {
if (one[k] === undefined) continue;
const r = checkLoadout({ [id]: { [k]: one[k] } }, getChess);
if (r.ok && r.loadout[id]) out[id] = { ...(out[id] || {}), [k]: one[k] };
}
continue;
}
if (res.loadout[id]) out[id] = one;
}
return out;
}
/** Selected SkillRecord of a board / shop chess under a loadout (the elite gets its Lv7 record). */
export function selectedSkill(loadout, chess, getChess) {
const r = resolveLoadout(loadout, chess, getChess);
return skillRecord(chess, r.skillIndex) || chess?.skill || null;
}
/** Selected ModuleRecord of an elite under a loadout (null: none / normal chess). */
export function selectedModule(loadout, chess, getChess) {
if (!chess || !chess.isGolden) return null;
const r = resolveLoadout(loadout, chess, getChess);
return moduleRecord(chess, r.moduleId);
}
// ---- roster & filters ------------------------------------------------------------------------------------------------
/**
* Visible normal chess (the loadout slots), in shop order: tier, then shopSortId.
* @param {any[]} list data.list('chess')
*/
/** Whether a chess record is a loadout slot (a visible normal chess — what the server's checkLoadout accepts). */
export const isLoadoutSlot = (c) => !!c && !c.isGolden && c.visible !== false && !c.isHidden && !c.isDiy && (!c.baseId || c.baseId === c.chessId);
export function rosterOf(list) {
return (Array.isArray(list) ? list : [])
.filter(isLoadoutSlot)
.sort((a, b) => (a.tier ?? 0) - (b.tier ?? 0) || (a.shopSortId ?? 0) - (b.shopSortId ?? 0) || String(a.chessId).localeCompare(String(b.chessId)));
}
/**
* Apply the screen's filters.
* @param {any[]} roster rosterOf(...)
* @param {{ tier?: number|null, prof?: string|null, bond?: string|null, query?: string, changedOnly?: boolean }} f
* @param {Record<string, any>} entries stored loadout (for changedOnly)
* @param {(id: string) => any} getChess
* @param {(id: string) => any} [getBond] bond lookup (the search also matches bond names)
*/
export function filterRoster(roster, f = {}, entries = {}, getChess = () => null, getBond = () => null) {
const q = String(f.query || '').trim().toLowerCase();
return roster.filter((c) => {
if (f.tier && c.tier !== f.tier) return false;
if (f.prof && c.profession !== f.prof) return false;
if (f.bond && !(Array.isArray(c.bonds) && c.bonds.includes(f.bond))) return false;
if (f.changedOnly) {
const golden = c.goldenId ? getChess(c.goldenId) : null;
if (!effectiveChoice(entries, c, golden).changed) return false;
}
if (q) {
const hay = [c.name, c.appellation, c.subProfessionName, PROF_NAME[c.profession], ...(c.bonds || []).map((b) => getBond(b)?.name)]
.filter(Boolean).join(' ').toLowerCase();
if (!hay.includes(q)) return false;
}
return true;
});
}
/** Number of chess whose choice differs from the defaults (only loadout slots of the loaded data count: an entry of a
* retired / hidden chess is never sent nor applied). */
export function changedCount(entries, getChess) {
let n = 0;
for (const id of Object.keys(entries || {})) {
const { base, golden } = recordsOf(id, getChess);
if (isLoadoutSlot(base) && base.chessId === id && effectiveChoice(entries, base, golden).changed) n++;
}
return n;
}
// ---- display helpers -----------------------------------------------------------------------------------------------------
/** "S2" style label of a skill index. */
export const skillLabel = (index) => (isInt(index) ? `S${index + 1}` : '—');
/** Short type badge of a module ("MAR-X" → "X", "ISW-α" → "α"); 'none' → "—". */
export function moduleBadge(rec, id = null) {
if (!rec) return id === MODULE_NONE || id == null ? '—' : '?';
const t = String(rec.typeName || rec.type || '');
const m = t.match(/-([^-\s]+)$/);
return m ? m[1] : t.slice(-1) || '?';
}
/**
* Module stat bonus as display rows (non-zero entries only).
* @param {Record<string, number> | null | undefined} attr
* @returns {Array<{ key: string, label: string, text: string, positive: boolean }>}
*/
export function attrRows(attr) {
const out = [];
if (!isObj(attr)) return out;
for (const [k, v] of Object.entries(attr)) {
if (typeof v !== 'number' || !Number.isFinite(v) || v === 0) continue;
const [label, unit] = ATTR_LABEL[k] || [k, ''];
const n = Math.abs(v) < 10 && !Number.isInteger(v) ? Number(v.toFixed(2)) : Math.round(v);
out.push({ key: k, label, text: `${v > 0 ? '+' : ''}${n}${unit}`, positive: k === 'cost' || k === 'respawnTime' || k === 'respawn_time' || k === 'baseAttackTime' || k === 'base_attack_time' ? v < 0 : v > 0 });
}
return out;
}
/**
* Tags of a SkillRecord: SP recovery, SP numbers, duration / ammo, charges.
* @returns {{ sp: string, spKind: 'time'|'atk'|'def'|'passive', init: number|null, cost: number|null, duration: string|null, charges: number|null, passive: boolean }}
*/
export function skillTags(rec) {
if (!rec) return { sp: '—', spKind: 'time', init: null, cost: null, duration: null, charges: null, passive: false };
const passive = rec.skillType === 'PASSIVE' || rec.spType === 'ON_DEPLOY' || rec.spType === 8;
const spKind = passive ? 'passive' : rec.spType === 'INCREASE_WHEN_ATTACK' ? 'atk' : rec.spType === 'INCREASE_WHEN_TAKEN_DAMAGE' ? 'def' : 'time';
let duration = null;
if (rec.durationType === 'AMMO') duration = '弹药';
else if (Number(rec.duration) > 0) duration = `${Number(rec.duration)}秒`;
return {
sp: SP_TYPE[rec.spType] || (passive ? '被动' : '技力'),
spKind,
init: passive ? null : Number.isFinite(rec.initSp) ? rec.initSp : 0,
cost: passive ? null : Number.isFinite(rec.spCost) ? rec.spCost : 0,
duration,
charges: Number(rec.maxChargeTime) > 1 ? Number(rec.maxChargeTime) : null,
passive,
};
}
+149
View File
@@ -0,0 +1,149 @@
// Operator loadout state + server sync (DESIGN §16).
//
// `loadoutStore` holds the per-browser loadout (`entries`, persisted in localStorage through store.js savePref) and the
// 干员调配 screen state (open / origin / selection / filters). `installLoadoutSync()` (called once by main.js) keeps the
// server's copy current: after every `welcome` (new or resumed session — the server keeps it on the session and on the
// seat, so joining a room needs no resend) and after every edit (debounced; a pending edit goes out at once when the
// overlay closes), it sends `room.loadout { entries }` with
// the entries sanitised against the loaded data/chess.json (ui/loadoutModel.js sanitizeEntries: a stale entry is
// dropped, never the whole loadout). Replies: RATE → retried later; WRONG_PHASE / ROOM_STARTED → the running match has
// locked its loadout (it applies to the next match, the server stored it) — not an error for the player; anything else
// is logged. `sync.state` ∈ 'idle' | 'pending' | 'sending' | 'synced' | 'locked' | 'error' is mirrored into the store
// for the screen's status line.
import { createStore, loadPref, savePref } from '../store.js';
import { data } from '../data.js';
import { LOADOUT_PREF, parseStored, toStored, sanitizeEntries } from './loadoutModel.js';
import { toast } from './toasts.js';
export const SYNC_DEBOUNCE_MS = 500;
export const RETRY_MS = 1500;
function readStored() {
try { return parseStored(loadPref(LOADOUT_PREF, null)); } catch { return {}; }
}
/** Loadout + screen state (separate from the app store: it must survive room / match resets). */
export const loadoutStore = createStore({
entries: readStored(),
open: false,
from: null, // 'lobby' | 'room' | 'briefing'
sel: null, // selected base chess id
filters: { tier: null, prof: null, bond: null, query: '', changedOnly: false },
sync: 'idle',
});
/** Replace the stored entries (persisted at once; the sync picks the change up). */
export function setEntries(entries) {
const next = entries && typeof entries === 'object' ? entries : {};
savePref(LOADOUT_PREF, toStored(next));
loadoutStore.set({ entries: next });
}
/** Open the 干員调配 screen. @param {'lobby'|'room'|'briefing'} from @param {string|null} [sel] */
export function openLoadout(from = 'lobby', sel = null) {
data.load('chess');
data.load('bonds');
data.load('assets');
data.load('local');
loadoutStore.set({ open: true, from, ...(sel ? { sel } : {}) });
}
export const closeLoadout = () => loadoutStore.set({ open: false });
/**
* Wire the sync once. Dependencies are injectable for tests.
* @param {{ net: any, getChessReady?: () => Promise<any>, lookupChess?: (id: string) => any,
* timers?: { setTimeout: Function, clearTimeout: Function }, target?: ReturnType<typeof createStore> }} deps
* @returns {{ flush: () => Promise<void>, dispose: () => void }}
*/
export function installLoadoutSync({ net, getChessReady, lookupChess, timers, target = loadoutStore, notify } = {}) {
const T = timers || { setTimeout: (fn, ms) => globalThis.setTimeout(fn, ms), clearTimeout: (id) => globalThis.clearTimeout(id) };
const ready = getChessReady || (() => data.load('chess'));
const lookup = lookupChess || ((id) => data.lookup('chess', id));
const tell = notify || ((text) => toast(text, 'warn'));
let timer = null;
let seq = 0; // room.loadout requests sent (the reply of an older one never overrides a newer one's state)
let pendingJson = null; // JSON of the newest request still awaiting its reply
let lastSent = null; // JSON of the last entries the server accepted (on this session)
let edited = false; // an edit is waiting to be sent (a lock refusal is then worth telling the player)
let disposed = false;
const setState = (sync) => { if (target.get().sync !== sync) target.set({ sync }); };
const schedule = (ms = SYNC_DEBOUNCE_MS) => {
if (disposed) return;
T.clearTimeout(timer);
setState('pending');
timer = T.setTimeout(() => { timer = null; void flush(); }, ms);
};
// Review fix: a send is never held back behind one still in flight. The socket is ordered and the server applies
// room.loadout frames in order, so the newest entries always win; holding the edit until the previous reply arrived let
// a click right after closing the overlay (准备就绪 → INFO_CHECK ends) overtake it, and the edit silently missed the match.
async function flush() {
if (disposed) return;
if (net.status !== 'online') { setState('idle'); return; } // the next welcome resends
try {
const current = target.get().entries;
// an empty loadout needs no data (nothing to sanitise): a player who never opened 干员调配 does not download
// chess.json in the lobby just for this
const empty = !current || Object.keys(current).length === 0;
const loaded = empty ? true : await ready();
if (disposed) return;
// never sanitise against missing data: every entry would be dropped and the server's copy cleared
if (loaded == null) { setState('error'); return; }
const entries = empty ? {} : sanitizeEntries(target.get().entries, lookup);
const json = JSON.stringify(entries);
if (json === pendingJson) return; // the same content is already on its way
if (json === lastSent && pendingJson == null) { edited = false; setState('synced'); return; }
const my = ++seq;
const wasEdit = edited;
edited = false;
pendingJson = json;
setState('sending');
try {
await net.request('room.loadout', { entries });
if (my !== seq) return;
pendingJson = null;
lastSent = json;
setState('synced');
} catch (err) {
if (my !== seq) return;
pendingJson = null;
const code = err && err.code;
if (code === 'WRONG_PHASE' || code === 'ROOM_STARTED') {
// the server stored it for the next match; the running one keeps the loadout it locked
lastSent = json;
setState('locked');
if (wasEdit) tell('本局的干员调配已锁定,修改将在下一局生效');
} else if (code === 'RATE' || code === 'TIMEOUT' || code === 'OFFLINE') { edited = edited || wasEdit; schedule(RETRY_MS); }
else { console.warn('[loadout] room.loadout refused', code, err && err.detail); setState('error'); }
}
} catch (e) {
console.warn('[loadout] sync failed', e);
setState('error');
}
}
const offWelcome = net.on('welcome', () => { lastSent = null; pendingJson = null; seq++; schedule(50); });
const offStore = target.subscribe((s, prev) => {
if (s.entries !== prev.entries) { edited = true; schedule(); }
// closing the overlay sends a pending edit at once (review fix): the player's next click — 准备就绪 in the solo
// briefing, 开始模拟 in the room — must not overtake the debounced room.loadout (the match locks its loadout when
// INFO_CHECK ends, so a late edit would silently only apply to the next match). Same socket ⇒ ordered.
if (prev.open && !s.open && timer != null) { T.clearTimeout(timer); timer = null; void flush(); }
});
// a match leaving INFO_CHECK locks the loadout; a new match (the room back in LOBBY / a new INFO_CHECK) accepts it again
const offRoom = net.on('room.state', (msg) => { if (msg && !msg.inMatch && target.get().sync === 'locked') { lastSent = null; schedule(); } });
return {
flush,
dispose() {
disposed = true;
T.clearTimeout(timer);
offWelcome?.();
offStore?.();
offRoom?.();
},
};
}
+126
View File
@@ -0,0 +1,126 @@
// Chrome shared by every in-match screen: the step header of the pre-game screens (exit + ping +
// difficulty | "1/2 确认本局信息" | countdown) and the exit flow (confirm with 暂离 / 放弃, AI 托管 overlay).
//
// 暂离 (co-op): turns on AI 托管 (g.autoplay on) so the seat keeps playing; the overlay's 返回模拟 turns it
// off. 放弃模拟: g.leave then room.leave (the platform treats it as a quit), back to the lobby.
import { useState } from '../../vendor/hooks.module.js';
import { html, Button, Modal, PingPill, DifficultyTag, Countdown, MicroLabel } from './components.js';
import { actions } from './gameActions.js';
import { toastError } from './toasts.js';
import { net } from '../net.js';
import { store, useStore, shallowEqual, emptyMatch, createStore } from '../store.js';
import { GIcon } from './gameComponents.js';
import { GuideButton } from './guide.js';
/**
* Exit dialog lines. 放弃模拟 (g.leave → room.leave) ends the run on the server as 'abandoned' and returns to the
* lobby straight away — no settlement screen follows, so the solo text must not promise one.
*/
export const EXIT_TEXT = Object.freeze({
soloRest: '独立模拟的休整期没有时间限制,你可以随时继续。',
soloQuit: '放弃模拟将立即结束本局并返回大厅,本局进度不会保留,也不会进行结算。',
});
/** Local "暂离 / AI 托管" flag (the server keeps no per-client autoplay view). */
export const awayStore = createStore({ away: false });
/**
* Whether a store change ends the match the 暂离 flag belongs to: its state was cleared (result → 返回同盟, 放弃模拟, a
* new match starting, the room closing, a new server session) or its settlement arrived. The next match must never
* open under the previous one's "AI 托管中" overlay (the server starts every match with autoplay off).
* @param {any} s store state
* @param {any} prev previous store state
*/
export const awayEnds = (s, prev) => (!!prev?.match?.public && !s?.match?.public) || (!!s?.match?.result && !prev?.match?.result);
store.subscribe((s, prev) => { if (awayStore.get().away && awayEnds(s, prev)) awayStore.set({ away: false }); });
/**
* Leave the match for good (g.leave → room.leave), then drop local room/match state.
* @returns {Promise<void>}
*/
export async function quitMatch() {
try {
try { await net.request('g.leave', {}); } catch (err) {
if (err?.code !== 'NOT_IN_ROOM' && err?.code !== 'WRONG_PHASE' && err?.code !== 'OFFLINE') throw err;
}
try { await net.request('room.leave', {}); } catch (err) { if (err?.code !== 'NOT_IN_ROOM' && err?.code !== 'OFFLINE') throw err; }
} catch (err) {
toastError(err);
} finally {
store.set({ room: null, match: emptyMatch() });
}
}
/**
* Exit confirmation modal.
* @param {{ open: boolean, onClose: Function, solo: boolean, onAway?: Function }} props
*/
export function ExitModal({ open, onClose, solo, onAway }) {
const [busy, setBusy] = useState(null);
const quit = async () => {
setBusy('quit');
await quitMatch();
setBusy(null);
onClose();
};
const away = async () => {
setBusy('away');
const ok = await actions.autoplay(true);
setBusy(null);
onClose();
if (ok) { awayStore.set({ away: true }); onAway?.(); }
};
return html`<${Modal} open=${open} onClose=${onClose} tone="red" title="离开模拟" micro="LEAVE SIMULATION" width="6.8rem"
actions=${html`
<${Button} variant="secondary" onClick=${onClose}>取消<//>
${!solo ? html`<${Button} variant="ice" icon="robot" loading=${busy === 'away'} onClick=${away}>暂离(AI 托管)<//>` : null}
<${Button} variant="danger" icon="exit" loading=${busy === 'quit'} onClick=${quit}>放弃模拟<//>`}>
<div class="exitm">
${solo
? html`<p>${EXIT_TEXT.soloRest}</p><p class="t-lo">${EXIT_TEXT.soloQuit}</p>`
: html`<p><b class="t-ice">暂离</b>:由 AI 托管你的席位(自动部署、准备与选择),随时可以返回。</p>
<p><b class="t-red">放弃模拟</b>:离开后无法返回本局,你的干员将回到共享卡池。</p>`}
</div>
<//>`;
}
/** Full-screen "AI 托管中" overlay with 返回模拟. */
export function AwayOverlay({ onBack = () => {} }) {
const [busy, setBusy] = useState(false);
return html`<div class="awayov" role="dialog" aria-label="AI 托管中">
<div class="awayov__box brackets">
<${GIcon} name="robot" class="awayov__icon" />
<${MicroLabel} tone="mint">AUTOPILOT // AI 托管</${MicroLabel}>
<h2>AI 托管中</h2>
<p class="t-lo">AI 正在代为操作你的席位</p>
<${Button} variant="primary" size="lg" icon="play" loading=${busy} onClick=${async () => { setBusy(true); const ok = await actions.autoplay(false); setBusy(false); if (ok) { awayStore.set({ away: false }); onBack(); } }}>返回模拟<//>
</div>
</div>`;
}
/**
* Pre-game step header.
* @param {{ step: number, of: number, title: string, micro: string, pub: any, total?: number|null, onExit: Function }} props
*/
export function StepHeader({ step, of, title, micro, pub, total, onExit }) {
const conn = useStore((s) => s.connection, shallowEqual);
return html`<header class="stephead">
<div class="stephead__left">
<${Button} variant="danger" size="lg" square=${true} icon="exit" onClick=${onExit} aria-label="离开" title="离开" />
<div class="stephead__meta">
<${PingPill} ms=${conn.ping} online=${conn.status === 'online'} />
${pub?.difficulty ? html`<${DifficultyTag} difficulty=${pub.difficulty} />` : null}
</div>
</div>
<div class="stephead__center">
<${MicroLabel} tone="mint">${micro}</${MicroLabel}>
<h1 class="stephead__title"><b class="num">${step}</b><span class="num stephead__of">/${of}</span>${title}</h1>
</div>
<div class="stephead__right">
<${GuideButton} class="stephead__guide" variant="secondary" />
<${Countdown} deadline=${pub?.deadline} total=${total ?? undefined} size="md" />
</div>
</header>`;
}
+108
View File
@@ -0,0 +1,108 @@
// Pure match-status helpers of the in-match HUD (no DOM, no Preact — unit-tested in test/ui/leftovers.test.js):
//
// * Boss-round clock (最终攻势 / 隐秘核心, research 00-INDEX §5, 01 §10): m.public.deadline is the level's
// maxPlayTime countdown (120 real s, config modes[…].rounds[r].levelMaxPlayTime) — "计时结束后战斗仍然会继续" —
// and m.public.overtimeAt is when the overtime drain starts (bossTurnHpReduceTime, 150 real s): from then on the
// merged team LP loses config.bossOvertimeDrainPerSec (1) per whole real second (server gamedata.js
// bossOvertimeDue: the first point at overtimeAt + 1 s). `overtimeState` says what the red DOT warning shows.
// * Solo pause (official 独立模拟 battles): C2S g.pause {on}; the server says so in m.public.paused (optionally
// m.public.pausedAt, ms epoch). Only solo battles offer it (`pauseAvailable`); while paused every HUD clock is
// frozen at the pause moment (`frozenNow`).
import { PHASE } from '../../../shared/constants.js';
const isObj = (v) => !!v && typeof v === 'object';
const BOSS_PHASES = new Set([PHASE.FINAL_ASSAULT, PHASE.HIDDEN_CORE]);
/** Battle phases a solo player may pause (a solo run has no 联防). */
const PAUSE_PHASES = new Set([PHASE.COMBAT, PHASE.FINAL_ASSAULT, PHASE.HIDDEN_CORE]);
/** Level time of a boss round when the config does not say (research 01 §3: maxPlayTime 120 s). */
export const BOSS_LEVEL_SECONDS = 120;
/** The pending overtime warning shows this many seconds before the drain even while the level countdown still runs. */
export const OVERTIME_WARN_BEFORE = 30;
/**
* The level's countdown length (s) of a boss round — the gauge total of the 120 s countdown.
* @param {any} pub m.public
* @param {any} config data/config.json
* @returns {number|null} null outside boss rounds
*/
export function bossLevelSeconds(pub, config) {
if (!isObj(pub) || !BOSS_PHASES.has(pub.phase)) return null;
const mode = isObj(config?.modes) ? config.modes[pub.modeId] : null;
const v = mode?.rounds?.[String(pub.round)]?.levelMaxPlayTime;
return Number.isFinite(v) && v > 0 ? v : BOSS_LEVEL_SECONDS;
}
/** Team LP lost per real second of overtime (config.bossOvertimeDrainPerSec, default 1). */
export function overtimeDrainPerSec(config) {
const v = config?.bossOvertimeDrainPerSec;
return Number.isFinite(v) && v >= 0 ? v : 1;
}
/**
* What the red overtime (DOT) warning of a boss round shows at `now` (server-corrected ms epoch):
* null nothing (not a boss round, no overtimeAt, or the drain is still far away)
* { state: 'pending', secs } the drain starts in `secs` s (shown once the level countdown ran out, or
* within OVERTIME_WARN_BEFORE s of the drain)
* { state: 'drain', secs, lost, perSec } draining for `secs` whole seconds: `lost` LP so far, `perSec` LP/s
* @param {any} pub m.public
* @param {number} now
* @param {{ perSec?: number, warnBefore?: number }} [o]
*/
export function overtimeState(pub, now, { perSec = 1, warnBefore = OVERTIME_WARN_BEFORE } = {}) {
if (!isObj(pub) || !BOSS_PHASES.has(pub.phase) || !Number.isFinite(now)) return null;
const at = Number(pub.overtimeAt);
if (!(Number.isFinite(at) && at > 0)) return null;
if (now < at) {
const secs = Math.ceil((at - now) / 1000);
const levelOver = Number.isFinite(pub.deadline) && pub.deadline > 0 && now >= pub.deadline;
if (!levelOver && secs > warnBefore) return null;
return { state: 'pending', secs };
}
const secs = Math.floor((now - at) / 1000);
const rate = Number.isFinite(perSec) && perSec >= 0 ? perSec : 1;
return { state: 'drain', secs, lost: secs * rate, perSec: rate };
}
/**
* Whether the solo pause control is offered: solo runs only (hidden in co-op), while the player's own battle runs
* (server Match.setPause: WRONG_PHASE once no battle is live). While paused the control stays (to resume).
* @param {any} pub m.public
* @param {{ solo?: boolean, alive?: boolean, done?: boolean }} [o] done: the own battle is over
*/
export function pauseAvailable(pub, { solo = false, alive = true, done = false } = {}) {
if (!solo || !isObj(pub) || alive === false) return false;
if (done && !pub.paused) return false;
return PAUSE_PHASES.has(pub.phase);
}
/** Whether m.public says the (solo) match is paused. */
export const isPaused = (pub) => isObj(pub) && !!pub.paused;
/** The pause intent (C2S g.pause {on}). */
export const pauseIntent = (on) => ({ t: 'g.pause', fields: { on: !!on } });
/**
* The clock every HUD countdown reads: the live server time, or — while paused — the moment the pause began
* (m.public.pausedAt when the server sends it, else the first time this client saw `paused`).
* @param {any} pub m.public
* @param {number} now live server-corrected ms epoch
* @param {number|null} seenAt when this client first saw the pause (ms epoch), or null
*/
export function frozenNow(pub, now, seenAt = null) {
if (!isPaused(pub)) return now;
const at = Number(pub.pausedAt);
if (Number.isFinite(at) && at > 0) return Math.min(now, at);
return Number.isFinite(seenAt) ? Math.min(now, seenAt) : now;
}
/**
* Seconds left on a deadline at `now` (ceil, ≥ 0), or null without a deadline.
* @param {number|null|undefined} deadline ms epoch
* @param {number} now ms epoch
*/
export function remainAt(deadline, now) {
if (!(Number.isFinite(deadline) && deadline > 0) || !Number.isFinite(now)) return null;
return Math.max(0, Math.ceil((deadline - now) / 1000));
}
+17
View File
@@ -0,0 +1,17 @@
// Merge reward reminder (research 00 §3): the reward cards themselves live in the shop bar (shopBar.js RewardCards —
// after a promotion they replace the bar's operator cards, like the original). When the player puts the pick off
// (稍后选择) this pill above the bar brings the cards back; it disappears once the server clears `shop.rewardOffer`
// (picked, or the round ended).
import { html, Icon, TierChip } from './components.js';
/**
* @param {{ priv:any, minimized:boolean, onMinimize:(m:boolean)=>void }} props
*/
export function RewardOverlay({ priv, minimized, onMinimize }) {
const offer = priv?.shop?.rewardOffer;
if (!offer || !Array.isArray(offer.slots) || !offer.slots.length || !minimized) return null;
return html`<button type="button" class="rewardpill" onClick=${() => onMinimize(false)}>
<${Icon} name="crown" /><span>晋升奖励待选择</span><${TierChip} tier=${offer.tier || 1} size="sm" />
</button>`;
}
+182
View File
@@ -0,0 +1,182 @@
// Rich-text description formatting (pure, no DOM / Preact — unit-tested in Node).
//
// Official descriptions (data/*.json `descRaw`, DATA.md §0) carry Arknights markup:
// <@ba.vup>+15%</> styled span (class `ba.vup`: value-up highlight)
// <$ba.stun>晕眩</> term span (a status keyword with a glossary entry)
// <@autochess.dgreen>3</> autochess-specific colours
// Tags nest. Anything else that looks like a tag — `<获得时>`, `<在场6名不同【炎】干员>` — is literal
// text and is kept verbatim (its closing `>` included). `\n` (real newline or the two-char escape)
// is a line break.
//
// API:
// parseRichText(src) → Seg[] where Seg = { text, cls: string[], term: boolean } | { br: true }
// richTextPlain(src) → string (markup stripped, newlines kept)
// rtClassName(cls) → CSS class for an official style id ('ba.vup' → 'rt-vup')
// formatBondEffect(bond, layers)→ bond `effectDescRaw` with its `{i:fmt}` placeholders resolved
// formatPlaceholder(value, fmt) → '15%' / '3' for the official number formats ('0%', '0.0%', '0', '0.0')
// fillPlaceholders(src, values) → replace `{i:fmt}` / `{i}` by values[i] (unknown indexes kept)
/** Official style id → our CSS modifier (css/screens/game-panels.css `.rt-*`). */
const STYLE_CLASS = {
'ba.vup': 'rt-vup',
'ba.vdown': 'rt-vdown',
'ba.rem': 'rt-rem',
'ba.acrem': 'rt-note',
'ba.kw': 'rt-kw',
'ba.talpu': 'rt-vup',
'autochess.gray': 'rt-note',
'autochess.dgreen': 'rt-mint',
'autochess.green': 'rt-mint',
'autochess.red': 'rt-vdown',
'autochess.yellow': 'rt-rem',
'eb.key': 'rt-kw',
'eb.danger': 'rt-vdown',
};
/**
* CSS class for an official style id. Term tags (`$…`) map to `rt-term`, unknown styles to `rt-hl`.
* @param {string} cls style id without the sigil, e.g. 'ba.vup'
* @param {boolean} [term]
* @returns {string}
*/
export function rtClassName(cls, term = false) {
if (term) return 'rt-term';
return STYLE_CLASS[cls] || 'rt-hl';
}
const TAG_OPEN = /^<([@$])([A-Za-z0-9_.\-]{1,48})>/;
const TAG_CLOSE = '</>';
/**
* Parse official rich text into flat styled segments.
* @param {any} src
* @returns {Array<{ text: string, cls: string[], term: boolean } | { br: true }>}
*/
export function parseRichText(src) {
if (src == null) return [];
const s = String(src).replace(/\\n/g, '\n').replace(/\r\n?/g, '\n');
/** @type {Array<{ cls: string, term: boolean }>} */
const stack = [];
const out = [];
let buf = '';
const flush = () => {
if (!buf) return;
const cls = stack.map((t) => t.cls);
const term = stack.some((t) => t.term);
const prev = out[out.length - 1];
// merge with the previous segment when styles are identical
if (prev && !prev.br && prev.term === term && prev.cls.length === cls.length && prev.cls.every((c, i) => c === cls[i])) {
prev.text += buf;
} else {
out.push({ text: buf, cls, term });
}
buf = '';
};
let i = 0;
while (i < s.length) {
const ch = s[i];
if (ch === '\n') {
flush();
out.push({ br: true });
i += 1;
continue;
}
if (ch === '<') {
if (s.startsWith(TAG_CLOSE, i)) {
if (stack.length) {
flush();
stack.pop();
} else {
buf += TAG_CLOSE; // stray closer: keep literally
}
i += TAG_CLOSE.length;
continue;
}
const m = TAG_OPEN.exec(s.slice(i, i + 52));
if (m) {
flush();
stack.push({ cls: m[2], term: m[1] === '$' });
i += m[0].length;
continue;
}
}
buf += ch;
i += 1;
}
flush();
return out;
}
/**
* Markup-free text (newlines kept).
* @param {any} src
* @returns {string}
*/
export function richTextPlain(src) {
return parseRichText(src).map((seg) => (seg.br ? '\n' : seg.text)).join('');
}
/**
* Format a number with an official format string.
* '0%' → rounded percent, '0.0%' → 1 decimal percent, '0' → integer, '0.0' → 1 decimal. Unknown → trimmed number.
* @param {number} value
* @param {string} [fmt]
* @returns {string}
*/
export function formatPlaceholder(value, fmt = '0') {
const v = Number(value);
if (!Number.isFinite(v)) return '?';
const f = typeof fmt === 'string' ? fmt : '0';
const pct = f.endsWith('%');
const core = pct ? f.slice(0, -1) : f;
const dot = core.indexOf('.');
const decimals = dot >= 0 ? Math.min(4, core.length - dot - 1) : 0;
const n = pct ? v * 100 : v;
const fixed = n.toFixed(decimals);
// avoid "-0"
const clean = Number(fixed) === 0 ? (0).toFixed(decimals) : fixed;
return pct ? `${clean}%` : clean;
}
/**
* Replace `{i:fmt}` / `{i}` placeholders with values (unknown indexes are kept as-is).
* @param {string} src
* @param {Array<number|string|null|undefined>} values
* @param {string[]} [formats] optional per-index format override
* @returns {string}
*/
export function fillPlaceholders(src, values, formats = []) {
if (src == null) return '';
return String(src).replace(/\{(\d{1,2})(?::([^{}]{1,12}))?\}/g, (whole, idx, fmt) => {
const i = Number(idx);
const v = Array.isArray(values) ? values[i] : undefined;
if (v == null) return whole;
if (typeof v === 'string') return v;
return formatPlaceholder(v, formats[i] || fmt || '0');
});
}
/**
* The bond's in-battle effect text with its layer-dependent numbers resolved (DATA.md §3:
* `{i:fmt}` = `bb[base] + bb[perStack] × layers`).
* @param {any} bond bonds.json record
* @param {number} [layers]
* @returns {string} rich text (markup kept)
*/
export function formatBondEffect(bond, layers = 0) {
if (!bond || typeof bond !== 'object') return '';
const src = bond.effectDescRaw || bond.effectDesc || '';
const params = Array.isArray(bond.effectDescParams) ? bond.effectDescParams : [];
const bb = bond.bb && typeof bond.bb === 'object' ? bond.bb : {};
const L = Number.isFinite(Number(layers)) ? Math.max(0, Number(layers)) : 0;
const values = [];
const formats = [];
for (const p of params) {
if (!p || !Number.isInteger(p.index)) continue;
const base = Number(bb[p.base]) || 0;
const per = Number(bb[p.perStack]) || 0;
values[p.index] = base + per * L;
formats[p.index] = p.format || '0';
}
return fillPlaceholders(src, values, formats);
}
+79
View File
@@ -0,0 +1,79 @@
// Player settings (BGM/SFX volume, mute, damage numbers, render quality): a tiny observable store
// persisted in localStorage (`sp.pref.settings`), applied to the audio manager on every change, plus
// the settings modal.
import { useState } from '../../vendor/hooks.module.js';
import { html, Modal, Button, Icon, MicroLabel } from './components.js';
import { createStore, useStore, loadPref, savePref } from '../store.js';
import { sanitizeSettings } from './gameLogic.js';
import { audio } from '../audio.js';
import { openGuide } from './guide.js';
import { detectFeatures } from './device.js';
/** Settings store: { bgm, sfx, muted, damageNumbers, quality }. */
export const settingsStore = createStore(sanitizeSettings(loadPref('settings', null)));
settingsStore.subscribe((s) => {
savePref('settings', sanitizeSettings(s));
audio.setVolumes(s);
});
audio.setVolumes(settingsStore.get());
/** @param {Partial<ReturnType<typeof sanitizeSettings>>} patch */
export function updateSettings(patch) {
settingsStore.set(sanitizeSettings({ ...settingsStore.get(), ...patch }));
}
/** Preact hook: current settings. */
export const useSettings = () => useStore((s) => s, Object.is, settingsStore);
function Slider({ label, micro, value, onInput, icon }) {
const pct = Math.round(value * 100);
return html`<label class="set-row">
<span class="set-row__label"><${Icon} name=${icon} />${label}<${MicroLabel}>${micro}<//></span>
<input class="set-range" type="range" min="0" max="100" step="5" value=${pct} style=${`--pct:${pct}%`}
onInput=${(e) => onInput(Number(e.currentTarget.value) / 100)} />
<span class="set-row__val num">${pct}</span>
</label>`;
}
function Toggle({ label, micro, value, onChange }) {
return html`<div class="set-row">
<span class="set-row__label">${label}<${MicroLabel}>${micro}<//></span>
<button type="button" class=${`set-toggle${value ? ' is-on' : ''}`} role="switch" aria-checked=${value ? 'true' : 'false'}
onClick=${() => onChange(!value)}><i></i><span>${value ? '开启' : '关闭'}</span></button>
</div>`;
}
const QUALITY = [['high', '高'], ['medium', '中'], ['low', '低']];
/**
* Settings modal.
* @param {{ open: boolean, onClose: Function }} props
*/
export function SettingsModal({ open, onClose }) {
const s = useSettings();
const [tested, setTested] = useState(false);
const [touchUi] = useState(() => detectFeatures().coarse && !detectFeatures().fine);
return html`<${Modal} open=${open} onClose=${onClose} title="设置" micro="SETTINGS" width="7.4rem"
actions=${html`<${Button} variant="secondary" icon="book" class="set-guide" onClick=${() => openGuide(0)}>玩法说明<//>
<${Button} variant="primary" icon="check" onClick=${onClose}>完成<//>`}>
<div class="set-list">
<${Slider} label="背景音乐" micro="BGM" icon="play" value=${s.bgm} onInput=${(v) => updateSettings({ bgm: v })} />
<${Slider} label="音效" micro="SFX" icon="signal" value=${s.sfx}
onInput=${(v) => { updateSettings({ sfx: v }); if (!tested) { setTested(true); setTimeout(() => setTested(false), 400); audio.sfx('click'); } }} />
<${Toggle} label="静音" micro="MUTE" value=${s.muted} onChange=${(v) => updateSettings({ muted: v })} />
<${Toggle} label="显示伤害数字" micro="DAMAGE NUMBERS" value=${s.damageNumbers} onChange=${(v) => updateSettings({ damageNumbers: v })} />
<div class="set-row">
<span class="set-row__label">画面质量<${MicroLabel}>QUALITY<//></span>
<div class="set-seg" role="radiogroup">
${QUALITY.map(([id, label]) => html`<button key=${id} type="button" role="radio" aria-checked=${s.quality === id ? 'true' : 'false'}
class=${s.quality === id ? 'is-on' : ''} onClick=${() => updateSettings({ quality: id })}>${label}</button>`)}
</div>
</div>
${touchUi
? html`<p class="set-hint">触屏操作:点击单位选中(撤退 / 出售)· 长按单位或卡牌查看详情 · 拖动部署后滑动选择朝向</p>`
: html`<p class="set-hint">快捷键:<kbd>R</kbd> 刷新 · <kbd>F</kbd> 冻结 · <kbd>D</kbd> 升级 · <kbd>Space</kbd> 准备就绪 · <kbd>Esc</kbd> 关闭弹窗 · 右键查看详情</p>`}
</div>
<//>`;
}
+304
View File
@@ -0,0 +1,304 @@
// Bottom shop bar (research 06 §11.2 / D3): LEVEL card (upgrade price hex, 升级), 3–5 operator cards
// (tier chip, price hex with discount/markup colours, portrait, bonds, class, frozen overlay, sold state,
// merge progress), the item card, the funds card with ✕ 收起; above it 剩余可放置角色, 冻结/解冻 and 刷新.
// Buying and upgrading take two taps (research 09 §5 / §6.5, official `EventOnFirstClick` → `EventOnConfirm` /
// `EventOnUpgrade`): the first tap selects a card — it lifts and enlarges, its detail opens and it shows 确认购买
// (无法购买 + the reason when it cannot be bought) — and a second tap on the same card buys it; the LEVEL card's first
// tap arms 确认升级 and the second upgrades (已满级 at level 6). A tap anywhere else, a shop change or the end of the
// editable phase disarms. The D / R / F keys stay one-press shortcuts. There is no drag-to-sell zone: selling is the
// 出售 +N button of a tapped unit's underframe (ui/underframe.js). After a promotion the operator cards are replaced by
// the 晋升奖励 cards (3 free operators, pick 1 — also two taps) until picked or put off (稍后 → rewardOverlay.js pill).
// Every operator card shows the skill it will fight with — the player's 干员调配 loadout (m.private.loadout, DESIGN
// §16): the skill icon above the name, mint-framed with 已调配 in its title when it is not the default skill (and the
// module type of an elite card, with its official type icon when the local-client art has it).
import { useEffect, useState } from '../../vendor/hooks.module.js';
import { html, Icon, HexBadge, TierChip, Tooltip, MicroLabel } from './components.js';
import { Img, BondGlyph, CoinGlyph, GIcon, RichText } from './gameComponents.js';
import { priceTone, mergeProgress, shopBlockReason, chessLoadout } from './gameLogic.js';
import { chessPortraitUrl, itemIconUrl, profIconUrl, uiUrl, skillIconUrl, skillRecordIconUrl, moduleTypeIconUrl } from './assetUrls.js';
import { data } from '../data.js';
const cx = (...p) => p.flat().filter(Boolean).join(' ');
function PriceHex({ slot, free, poor = false }) {
const tone = priceTone(slot);
const price = Number(slot?.price) || 0;
if (free || price === 0) return html`<span class="scard__free">FREE</span>`;
// like the original (img_bg_price_not_enough) the price turns grey while the funds don't cover it
return html`<${HexBadge} value=${price} tone=${poor ? 'dark' : tone} size="md" class=${cx('scard__price', poor && 'is-poor')}
title=${poor ? '资金不足' : tone === 'discount' ? `折扣价(原价 ${slot.basePrice})` : tone === 'premium' ? `加价(原价 ${slot.basePrice})` : '价格'} />`;
}
/** Data lookups for shopBlockReason (full-hand purchases that complete a merge stay allowed). */
const LOOKUPS = { getChess: (id) => data.lookup('chess', id), getItem: (id) => data.lookup('items', id) };
/** The armed (first-tapped) card's confirm strip: 确认购买 / 确认选择, or 无法购买 + why. */
function ArmedTag({ reason, free }) {
if (reason) return html`<span class="scard__confirm is-no" role="status"><b>无法购买</b><small>${reason}</small></span>`;
return html`<span class="scard__confirm" role="status"><b>${free ? '确认选择' : '确认购买'}</b><small>再次点击</small></span>`;
}
/**
* Operator card (shop and merge reward). `armed`: first tap done (second tap = `onBuy(idx)`).
* @param {{ slot:any, idx:number, priv:any, frozen?:boolean, reason?:string|null, free?:boolean, armed?:boolean,
* onTap?:(idx:number)=>void, onBuy:Function, onDetail:Function }} props
*/
export function ChessCard({ slot, idx, priv, frozen = false, reason = null, free = false, armed = false, onTap = null, onBuy, onDetail }) {
const c = data.lookup('chess', slot.id);
const m = data.get('assets');
const tier = c?.tier ?? 1;
const prog = mergeProgress(priv, slot.id, (id) => data.lookup('chess', id));
const willMerge = prog.copies + 1 >= prog.need && prog.copies > 0;
const bonds = Array.isArray(c?.bonds) ? c.bonds : [];
const disabled = !!reason;
const lo = c ? chessLoadout(c, priv?.loadout, LOOKUPS.getChess) : null;
const tap = () => { if (onTap) onTap(idx); else if (!disabled) onBuy(idx); };
const card = html`<button type="button" class=${cx('scard', `scard--t${tier}`, frozen && 'is-frozen', disabled && 'is-disabled', willMerge && 'is-merge', armed && 'is-armed')}
onClick=${tap} onContextMenu=${(e) => { e.preventDefault(); onDetail(slot.id); }}
aria-label=${`${c?.name || '干员'},价格 ${slot.price}${armed ? (disabled ? ',无法购买' : ',再次点击确认') : ''}`} aria-disabled=${disabled && !onTap ? 'true' : 'false'}
aria-pressed=${onTap ? String(!!armed) : undefined}>
<span class="scard__bg" aria-hidden="true"></span>
<span class="scard__water" aria-hidden="true">${bonds[0] ? html`<${BondGlyph} bondId=${bonds[0]} />` : null}</span>
<${Img} src=${chessPortraitUrl(m, c)} class="scard__art" />
<span class="scard__top">
<${TierChip} tier=${tier} size="md" />
<${PriceHex} slot=${slot} free=${free} poor=${reason === '资金不足'} />
${prog.copies > 0 ? html`<span class="scard__pips" title=${`已拥有 ${prog.copies}/${prog.need}`}>
${Array.from({ length: prog.need }, (_, i) => html`<i key=${i} class=${i < prog.copies ? 'on' : ''}></i>`)}
</span>` : null}
</span>
<span class="scard__body">
${lo?.skill ? html`<${SkillBadge} chess=${c} lo=${lo} />` : null}
<span class="scard__name">${c?.name || slot.id}</span>
<span class="scard__bonds">
${bonds.slice(0, 3).map((b) => html`<span key=${b} class="scard__bond"><${BondGlyph} bondId=${b} /><span>${data.lookup('bonds', b)?.name || b}</span></span>`)}
</span>
<span class="scard__class">
<${Img} src=${profIconUrl(m, c?.profession)} class="scard__prof" />
<span>${c?.subProfessionName || ''}</span>
</span>
</span>
${willMerge ? html`<span class="scard__mergetag">可晋升</span>` : null}
${frozen ? html`<span class="scard__ice" aria-hidden="true"><${Icon} name="snow" /></span>` : null}
${armed ? html`<${ArmedTag} reason=${reason} free=${free} />` : null}
<span class="scard__detail" role="button" tabindex="-1" aria-label="详情" title="查看详情"
onClick=${(e) => { e.stopPropagation(); onDetail(slot.id); }}><${Icon} name="info" /></span>
</button>`;
return reason && reason !== '已售出' && !armed ? html`<${Tooltip} text=${reason} block=${true} class="scard-wrap">${card}<//>` : card;
}
/** The loadout's skill (icon; name + 已调配 in the tooltip) and an elite's module type, on an operator card. */
function SkillBadge({ chess, lo }) {
const m = data.get('assets');
const custom = !lo.defaultSkill;
// a chosen skill without an icon in the manifest: its slot letter (S1–S3) instead of the blank skill sprite
const src = custom ? skillRecordIconUrl(m, lo.skill, { empty: false }) : skillIconUrl(m, chess);
const slot = Number.isInteger(lo.skill.index) ? `S${lo.skill.index + 1}` : null;
const mod = lo.module && !lo.module.none ? lo.module : null;
const tip = `技能${slot ? ` ${slot}` : ''}:${lo.skill.name || ''}${custom ? '(已调配)' : ''}${mod ? ` · 模组:${mod.name}` : lo.module?.none ? ' · 未装备模组' : ''}`;
return html`<span class=${cx('scard__skill', custom && 'is-custom')} title=${tip} aria-label=${tip} data-skill=${lo.skill.skillId || ''}>
<${Img} src=${src} fallback=${slot ? html`<span class="scard__sglyph num">${slot}</span>` : html`<${GIcon} name="bolt" />`} />
${mod && mod.typeName ? html`<span class="scard__mod" data-type=${mod.typeName}><${Img} src=${moduleTypeIconUrl(data.get('local'), mod.typeName)} class="scard__modicon" />${mod.typeName}</span>` : null}
</span>`;
}
function ItemCard({ slot, idx, frozen, reason, armed = false, onTap = null, onBuy, onDetail }) {
const it = data.lookup('items', slot.id);
const m = data.get('assets');
const disabled = !!reason;
const tap = () => { if (onTap) onTap(idx); else if (!disabled) onBuy(idx); };
const card = html`<button type="button" class=${cx('scard', 'scard--item', frozen && 'is-frozen', disabled && 'is-disabled', armed && 'is-armed')}
onClick=${tap} onContextMenu=${(e) => { e.preventDefault(); onDetail(slot.id, 'item'); }}
aria-label=${`${it?.name || '装备'},价格 ${slot.price}${armed ? (disabled ? ',无法购买' : ',再次点击确认') : ''}`}
aria-pressed=${onTap ? String(!!armed) : undefined}>
<span class="scard__bg" aria-hidden="true"></span>
<span class="scard__top">
<${TierChip} tier=${it?.tier ?? 1} size="md" />
<${PriceHex} slot=${slot} poor=${reason === '资金不足'} />
</span>
<span class="scard__itemart"><${Img} src=${itemIconUrl(m, it)} fallback=${html`<${GIcon} name="bolt" />`} /></span>
<span class="scard__body">
<span class="scard__name">${it?.name || slot.id}</span>
<span class="scard__idesc"><${RichText} text=${it?.descRaw || it?.desc || ''} /></span>
</span>
${frozen ? html`<span class="scard__ice" aria-hidden="true"><${Icon} name="snow" /></span>` : null}
${armed ? html`<${ArmedTag} reason=${reason} />` : null}
<span class="scard__detail" role="button" tabindex="-1" aria-label="详情" onClick=${(e) => { e.stopPropagation(); onDetail(slot.id, 'item'); }}><${Icon} name="info" /></span>
</button>`;
return reason && reason !== '已售出' && !armed ? html`<${Tooltip} text=${reason} block=${true} class="scard-wrap">${card}<//>` : card;
}
function SoldCard({ item = false }) {
return html`<div class=${cx('scard', 'scard--sold', item && 'scard--item')} aria-label="已售出">
<span class="scard__soldtxt"><${MicroLabel}>SOLD OUT</${MicroLabel}><span>${item ? '已购买' : '已招募'}</span></span>
</div>`;
}
function LevelCard({ shop, reason, armed = false, onTap }) {
const lv = shop?.level ?? 1;
const max = lv >= (shop?.maxLevel ?? 6);
const price = shop?.upgradePrice ?? 0;
return html`<button type="button" class=${cx('lvcard', max && 'is-max', reason && 'is-disabled', armed && 'is-armed')} onClick=${() => !reason && onTap()}
title=${reason || (armed ? `再次点击确认升级(${price} 资金)` : `升级调度中心(${price} 资金) · D`)} aria-disabled=${reason ? 'true' : 'false'}
aria-pressed=${String(!!armed)}>
${!max ? html`<${HexBadge} value=${price} tone=${reason && reason !== '调度中心已达最高等级' ? 'dark' : 'gold'} size="md" class="lvcard__price" />` : null}
<span class="lvcard__frame">
<span class="lvcard__micro">LEVEL</span>
<b class="lvcard__num num">${lv}</b>
</span>
<span class="lvcard__label">${max ? '已满级' : armed ? '确认升级' : '升级'}</span>
<kbd class="lvcard__key">D</kbd>
</button>`;
}
/** Key of an armed shop card: kind + slot index + the id it showed (a reroll / sale changes it ⇒ disarm). */
export const armKey = (kind, idx, slot) => `${kind}:${idx}:${slot?.id ?? ''}`;
/**
* Two-tap state of the bar (first tap arms, second confirms). Disarms on a tap outside the shop / detail panel, on
* Escape, when the armed card changes or disappears, and when the bar stops being editable.
* @param {{ editable: boolean, keys: Set<string> }} o the keys that are currently valid
*/
export function useTwoTap({ editable, keys }) {
const [armed, setArmed] = useState(null);
const valid = armed == null || (editable && keys.has(armed));
useEffect(() => { if (!valid) setArmed(null); }, [valid]);
useEffect(() => {
if (armed == null) return undefined;
const onDown = (e) => {
const t = e.target;
if (t && t.closest && t.closest('.scard, .lvcard, .dpanel, .modal')) return;
setArmed(null);
};
const onKey = (e) => { if (e.key === 'Escape') setArmed(null); };
window.addEventListener('pointerdown', onDown, true);
window.addEventListener('keydown', onKey);
return () => { window.removeEventListener('pointerdown', onDown, true); window.removeEventListener('keydown', onKey); };
}, [armed]);
return [valid ? armed : null, setArmed];
}
/**
* Promotion reward (research 00 §3): after a merge the operator cards of the bar are replaced by 3 free operators of
* tier min(level+1, 6); pick 1 (g.reward idx) or put it off (稍后选择 → the normal shop + a reminder pill).
*/
function RewardCards({ offer, priv, editable, onPick, onDetail, onLater, armed, onTap }) {
return html`<div class="shopbar__reward" role="group" aria-label="晋升奖励">
<div class="rwtag">
<${Icon} name="crown" class="rwtag__icon" />
<b class="rwtag__title">晋升奖励</b>
<span class="rwtag__micro">PROMOTION</span>
<span class="rwtag__sub">免费选择 1 名</span>
<button type="button" class="rwtag__later" onClick=${onLater} title="稍后选择(回合结束后消失)"><${Icon} name="minus" />稍后</button>
</div>
<div class="shopbar__rwcards">
${offer.slots.map((s, i) => (s && !s.sold
? html`<${ChessCard} key=${`rw${i}:${s.id}`} slot=${{ ...s, price: 0 }} idx=${i} priv=${priv} free=${true}
armed=${armed === armKey('r', i, s)} onTap=${(idx) => onTap('r', idx, s, 'chess', shopBlockReason('reward', { priv, editable, slot: s, ...LOOKUPS }), onPick)}
reason=${shopBlockReason('reward', { priv, editable, slot: s, ...LOOKUPS })} onBuy=${onPick} onDetail=${onDetail} />`
: html`<div key=${`rw${i}`} class="scard scard--sold"><span class="scard__soldtxt"><span>已选择</span></span></div>`))}
</div>
</div>`;
}
/**
* The bar.
* @param {{ priv:any, editable:boolean, collapsed:boolean, onCollapse:(c:boolean)=>void,
* onBuy:(i:number)=>void, onLevel:Function, onRefresh:Function, onFreeze:Function, onDetail:(id:string, kind?:string)=>void,
* onDetailClose?: () => void, onRefuse?: (reason: string) => void, barRef:any,
* reward?: any, onReward?: (idx:number)=>void, onRewardLater?: Function }} props
*/
export function ShopBar({ priv, editable, collapsed, onCollapse, onBuy, onLevel, onRefresh, onFreeze, onDetail, onDetailClose, onRefuse, barRef,
reward = null, onReward, onRewardLater }) {
const shop = priv?.shop || {};
const slots = Array.isArray(shop.slots) ? shop.slots : [];
const chessSlots = slots.map((s, i) => ({ s, i })).filter(({ s }) => !s || s.kind !== 'item');
const itemSlots = slots.map((s, i) => ({ s, i })).filter(({ s }) => s && s.kind === 'item');
const frozen = !!shop.frozen;
const funds = Number(priv?.funds) || 0;
const remaining = Math.max(0, (priv?.deployCap ?? 8) - (priv?.deployCount ?? 0));
const lvReason = shopBlockReason('levelUp', { priv, editable });
const refReason = shopBlockReason('refresh', { priv, editable });
const frzReason = editable ? null : shopBlockReason('freeze', { priv, editable });
const free = Number(shop.freeRefreshes) || 0;
const showReward = !!(reward && Array.isArray(reward.slots) && reward.slots.length);
// two-tap: the keys that may stay armed right now
const keys = new Set();
if (!collapsed) {
if (!lvReason) keys.add('lv');
slots.forEach((s, i) => { if (s && !s.sold) keys.add(armKey(s.kind === 'item' ? 'i' : 'c', i, s)); });
if (showReward) reward.slots.forEach((s, i) => { if (s && !s.sold) keys.add(armKey('r', i, s)); });
}
const [armed, setArmed] = useTwoTap({ editable, keys });
/** First tap arms + opens the detail; the second buys (or says why it can't). */
const tapCard = (kind, idx, slot, detailKind, reason, buy) => {
const key = armKey(kind, idx, slot);
if (armed !== key) { setArmed(key); onDetail(slot.id, detailKind); return; }
if (reason) { onRefuse?.(reason); return; }
setArmed(null);
onDetailClose?.();
buy(idx);
};
const tapLevel = () => {
if (armed !== 'lv') { setArmed('lv'); return; }
setArmed(null);
onLevel();
};
if (collapsed) {
return html`<div class="shopbar-tab" ref=${barRef}>
<div class="shopbar-tab__funds"><${CoinGlyph} /><b class="num">${funds}</b></div>
<button type="button" class="shopbar-tab__btn" onClick=${() => onCollapse(false)}><${Icon} name="chevronLeft" />展开商店</button>
</div>`;
}
return html`<section class=${cx('shopbar', frozen && 'is-frozen', !editable && 'is-locked', showReward && 'has-reward', armed && 'has-armed')} ref=${barRef} aria-label="调度中心">
<div class="shopbar__tools">
<span class="shopbar__remain">剩余可放置角色:<b class=${cx('num', remaining === 0 && 't-orange')}>${remaining}</b></span>
<button type="button" class=${cx('toolbtn', 'toolbtn--ice', frozen && 'is-on')} disabled=${!!frzReason} onClick=${onFreeze}
title=${frzReason || (frozen ? '解冻商店 · F' : '冻结商店(下回合保留) · F')}>
<${Img} src=${uiUrl(data.get('assets'), frozen ? 'shopPanel/frozen_icon2' : 'shopPanel/frozen_icon')} class="toolbtn__img" fallback=${html`<${Icon} name="snow" />`} />
<span>${frozen ? '解冻' : '冻结'}</span><kbd>F</kbd>
</button>
<button type="button" class="toolbtn toolbtn--amber" disabled=${!!refReason} onClick=${onRefresh} title=${refReason || '刷新商店 · R'}>
<${Img} src=${uiUrl(data.get('assets'), 'shopPanel/refresh_icon')} class="toolbtn__img" fallback=${html`<${Icon} name="refresh" />`} />
<span>刷新</span>
${free > 0 ? html`<span class="toolbtn__free">免费 ×${free}</span>` : html`<${HexBadge} value=${shop.refreshPrice ?? 1} tone=${refReason ? 'dark' : 'gold'} size="sm" />`}
<kbd>R</kbd>
</button>
</div>
<div class="shopbar__row">
<${LevelCard} shop=${shop} reason=${lvReason} armed=${armed === 'lv'} onTap=${tapLevel} />
${showReward ? html`<${RewardCards} offer=${reward} priv=${priv} editable=${editable} onPick=${onReward} onDetail=${onDetail} onLater=${onRewardLater}
armed=${armed} onTap=${tapCard} />`
: html`<div class="shopbar__cards">
${chessSlots.map(({ s, i }) => {
if (!s || s.sold) return html`<${SoldCard} key=${`s${i}`} />`;
const reason = shopBlockReason('buy', { priv, editable, slot: s, ...LOOKUPS });
return html`<${ChessCard} key=${`c${i}:${s.id}`} slot=${s} idx=${i} priv=${priv} frozen=${frozen} onBuy=${onBuy} onDetail=${onDetail}
reason=${reason} armed=${armed === armKey('c', i, s)} onTap=${editable ? (idx) => tapCard('c', idx, s, 'chess', reason, onBuy) : null} />`;
})}
</div>`}
<div class="shopbar__item">
${itemSlots.length
? itemSlots.map(({ s, i }) => {
if (s.sold) return html`<${SoldCard} key=${`is${i}`} item=${true} />`;
const reason = shopBlockReason('buy', { priv, editable, slot: s, ...LOOKUPS });
return html`<${ItemCard} key=${`i${i}:${s.id}`} slot=${s} idx=${i} frozen=${frozen} onBuy=${onBuy} onDetail=${onDetail} reason=${reason}
armed=${armed === armKey('i', i, s)} onTap=${editable ? (idx) => tapCard('i', idx, s, 'item', reason, onBuy) : null} />`;
})
: html`<${SoldCard} item=${true} />`}
</div>
<div class="funds">
<div class="funds__hex">
<${CoinGlyph} class="funds__coin" />
<b class="funds__num num">${funds}</b>
</div>
<span class="funds__label">目前资金</span>
<button type="button" class="funds__collapse" onClick=${() => onCollapse(true)}><${Icon} name="close" />收起</button>
</div>
</div>
</section>`;
}
+79
View File
@@ -0,0 +1,79 @@
// Left team panel (research 06 §11.1, research 09 §3.1): one row per seat — avatar (band icon once picked), name, LP
// tower, status glyph (… acting / ✓ ready / ⌛ deciding / ⚔ combat / door left / ✕ dead), AI badge, "you" marker, the
// field being watched (eye badge), and emote bubbles.
// Observing (client-side combat, `observe` prop — the official flow): tapping a teammate's avatar expands a mint
// "前往查看" button under the row (when that teammate can be observed now; otherwise the reason is toasted through
// onWatch); while observing, the own row shows a "返回战场" button. Without `observe` (server-run combat) a click
// watches that player's field at once.
import { useEffect, useState } from '../../vendor/hooks.module.js';
import { html, Icon, Tooltip } from './components.js';
import { PlayerAvatar, LpTower, GIcon, LocalSprite } from './gameComponents.js';
import { EmoteBubble } from './emotes.js';
import { STATUS_META, sortedPlayers } from './gameLogic.js';
import { localAsset } from '../data.js';
const cx = (...p) => p.flat().filter(Boolean).join(' ');
/** Official status glyphs (ui/battle) per m.public players[].status; CSS glyph fallback. */
const STATUS_SPRITE = { ready: 'icon_ready', deciding: 'icon_waiting', done: 'icon_complete', dead: 'icon_dead' };
/**
* @param {{ pub:any, myId:string, watching:string|null, bubbles: Map<string,{id:string,seq:number}>, onWatch:(p:any)=>void,
* compact?: boolean, teamLp?: number|null,
* observe?: null | { canObserve: (p:any) => { fieldId?: string, reason?: string|null, back?: boolean }, observing: boolean, onBack: () => void } }} props
*/
export function TeamPanel({ pub, myId, watching, bubbles, onWatch, compact = false, observe = null }) {
const [openPid, setOpenPid] = useState(null);
const phaseKey = `${pub?.phase}:${pub?.round}`;
useEffect(() => { setOpenPid(null); }, [phaseKey, watching, observe?.observing]);
const players = sortedPlayers(pub);
if (!players.length) return null;
const click = (p, self) => {
if (!observe) { onWatch(p); return; }
if (self) { if (observe.observing) observe.onBack(); setOpenPid(null); return; }
const t = observe.canObserve(p) || {};
if (!t.fieldId) { setOpenPid(null); onWatch(p); return; } // the game screen toasts the reason
setOpenPid((cur) => (cur === p.playerId ? null : p.playerId));
};
return html`<aside class=${cx('team', compact && 'team--compact')} aria-label="同盟成员">
${players.map((p) => {
const self = p.playerId === myId;
const status = p.alive === false ? 'dead' : p.status;
const meta = STATUS_META[status] || STATUS_META.acting;
const watched = watching && (watching === p.fieldId || watching === `n:${p.playerId}`);
const bubble = bubbles?.get(p.playerId);
const offline = p.connected === false && !p.isBot;
const open = !!observe && openPid === p.playerId && !self;
const back = !!observe && self && observe.observing;
const title = observe ? (self ? (observe.observing ? '返回战场' : '你自己') : `查看 ${p.name} 的战场`) : (self ? '查看自己的阵地' : `查看 ${p.name} 的阵地`);
return html`<div key=${p.playerId} class=${cx('team__row', self && 'is-self', watched && 'is-watched', p.alive === false && 'is-dead', open && 'is-open')}>
<button type="button" class="team__btn" onClick=${() => click(p, self)} title=${title} aria-expanded=${observe && !self ? String(open) : undefined}>
<${PlayerAvatar} player=${p} self=${self} />
<span class="team__seat num">P${(p.seat ?? 0) + 1}</span>
${p.isBot ? html`<span class="team__ai">AI</span>` : null}
${self ? html`<span class="team__you"><${Icon} name="user" /></span>` : null}
</button>
<div class="team__info">
<span class="team__name">${p.name || '博士'}</span>
<div class="team__line">
<${LpTower} value=${p.lp} size="sm" tone=${Number.isFinite(p.lp) && p.lp <= 5 ? 'danger' : null} />
<${Tooltip} text=${offline ? '连接已断开' : meta.text} placement="right">
<span class=${cx('team__status', `is-${meta.tone}`, offline && 'is-offline', (offline || STATUS_SPRITE[status]) && localAsset('ui/battle', offline ? 'icon_lost_connect' : STATUS_SPRITE[status]) && 'has-sprite')} aria-label=${meta.text}>
${offline ? html`<${LocalSprite} name="icon_lost_connect" fallback=${html`<${Icon} name="wifiOff" />`} />`
: STATUS_SPRITE[status] ? html`<${LocalSprite} name=${STATUS_SPRITE[status]} fallback=${html`<${GIcon} name=${meta.glyph} />`} />`
: html`<${GIcon} name=${meta.glyph} />`}
</span>
<//>
${watched && !self ? html`<span class="team__eye" title="正在查看"><${GIcon} name="eye" /></span>` : null}
</div>
${open ? html`<button type="button" class="btn btn--primary btn--sm team__ob"
onClick=${() => { setOpenPid(null); onWatch(p); }}><span class="btn__label">前往查看</span></button>` : null}
${back ? html`<button type="button" class="btn btn--secondary btn--sm team__back"
onClick=${() => observe.onBack()}><span class="btn__label">返回战场</span></button>` : null}
</div>
${bubble ? html`<${EmoteBubble} key=${bubble.seq} id=${bubble.id} class="team__bubble" />` : null}
</div>`;
})}
</aside>`;
}
+51
View File
@@ -0,0 +1,51 @@
// Broadcast ticker (m.ticker): a strip under the top bar; each line slides in from the right, stays
// TICKER_MS and leaves; queued lines play in order (only the newest QUEUE_MAX are kept).
import { useEffect, useRef, useState } from '../../vendor/hooks.module.js';
import { html } from './components.js';
import { GIcon, RichText } from './gameComponents.js';
import { useStore } from '../store.js';
import { audio } from '../audio.js';
const TICKER_MS = 5200;
const QUEUE_MAX = 4;
/** Ticker strip bound to store.ticker. */
export function Ticker() {
const items = useStore((s) => s.ticker);
const seen = useRef(null);
const queue = useRef([]);
const [cur, setCur] = useState(null);
const timer = useRef(null);
useEffect(() => {
const list = Array.isArray(items) ? items : [];
if (seen.current == null) {
// lines received before this screen mounted are history, not news
seen.current = list.length ? list[list.length - 1].id : 0;
return;
}
const fresh = list.filter((t) => t && t.id > seen.current && Date.now() - t.at < 30000);
if (!fresh.length) return;
seen.current = fresh[fresh.length - 1].id;
queue.current = [...queue.current, ...fresh].slice(-QUEUE_MAX);
if (!cur) next();
}, [items]);
useEffect(() => () => clearTimeout(timer.current), []);
function next() {
const it = queue.current.shift() || null;
setCur(it);
if (it) {
audio.sfx('broadcast', { volume: 0.5 });
clearTimeout(timer.current);
timer.current = setTimeout(next, TICKER_MS);
}
}
if (!cur) return null;
return html`<div class="ticker" role="status" aria-live="polite">
<div key=${cur.id} class="ticker__line"><${GIcon} name="flag" class="ticker__icon" /><${RichText} text=${cur.text} /></div>
</div>`;
}
+136
View File
@@ -0,0 +1,136 @@
// Global toast notifications (top-centre stack).
//
// Imperative API usable from anywhere: `toast(text, kind)`, `toastError(err)`; render <ToastHost/>
// once near the app root. Identical messages shown within a short window are merged (with a ×N
// counter) instead of stacking, and the stack is capped so an error storm cannot flood the screen.
import { h } from '../../vendor/preact.module.js';
import { useEffect, useReducer } from '../../vendor/hooks.module.js';
import htm from '../../vendor/htm.module.js';
import { ERR_TEXT } from '../../../shared/constants.js';
// no shared static vnodes (see components.js hFresh: htm's static cache would retain unmounted DOM)
function hFresh(type, props, ...children) {
if (this && typeof this === 'object') this[0] = 3;
return h(type, props, ...children);
}
const html = htm.bind(hFresh);
export const TOAST_KINDS = ['info', 'success', 'warn', 'error'];
const MAX_TOASTS = 4;
const DEFAULT_MS = { info: 2600, success: 2400, warn: 3200, error: 3600 };
const MERGE_WINDOW_MS = 2500;
let seq = 0;
/** @type {{id:number, text:string, kind:string, count:number, at:number, ttl:number, leaving:boolean}[]} */
let toasts = [];
const listeners = new Set();
const timers = new Map();
const emit = () => { for (const fn of [...listeners]) { try { fn(toasts); } catch (err) { console.error(err); } } };
function schedule(t) {
clearTimeout(timers.get(t.id));
timers.set(t.id, setTimeout(() => dismissToast(t.id), t.ttl));
}
/**
* Show a toast.
* @param {string} text message (plain text)
* @param {'info'|'success'|'warn'|'error'} [kind]
* @param {{ ttl?: number }} [opts]
* @returns {number} toast id
*/
export function toast(text, kind = 'info', opts = {}) {
const k = TOAST_KINDS.includes(kind) ? kind : 'info';
const msg = String(text ?? '').slice(0, 200).trim();
if (!msg) return -1;
const now = Date.now();
const ttl = Number.isFinite(opts.ttl) && opts.ttl > 0 ? opts.ttl : DEFAULT_MS[k];
const same = toasts.find((t) => !t.leaving && t.text === msg && t.kind === k && now - t.at < MERGE_WINDOW_MS + t.ttl);
if (same) {
toasts = toasts.map((t) => (t === same ? { ...t, count: t.count + 1, at: now, ttl } : t));
schedule(toasts.find((t) => t.id === same.id));
emit();
return same.id;
}
const t = { id: ++seq, text: msg, kind: k, count: 1, at: now, ttl, leaving: false };
toasts = [...toasts, t];
// Cap the stack: drop the oldest immediately.
while (toasts.length > MAX_TOASTS) {
const old = toasts[0];
clearTimeout(timers.get(old.id));
timers.delete(old.id);
toasts = toasts.slice(1);
}
schedule(t);
emit();
return t.id;
}
/**
* Dismiss a toast (plays the exit animation, then removes it).
* @param {number} id
*/
export function dismissToast(id) {
const t = toasts.find((x) => x.id === id);
if (!t) return;
clearTimeout(timers.get(id));
if (t.leaving) return;
toasts = toasts.map((x) => (x.id === id ? { ...x, leaving: true } : x));
emit();
timers.set(id, setTimeout(() => {
timers.delete(id);
toasts = toasts.filter((x) => x.id !== id);
emit();
}, 200));
}
/**
* Text for an error-ish value: NetError / {code,msg} / Error / string.
* @param {any} err
* @returns {string}
*/
export function describeError(err) {
if (!err) return '发生未知错误';
if (typeof err === 'string') return ERR_TEXT[err] || err;
if (err.code && ERR_TEXT[err.code]) return ERR_TEXT[err.code];
if (typeof err.message === 'string' && err.message) return err.message;
if (typeof err.msg === 'string' && err.msg) return err.msg;
return '发生未知错误';
}
/**
* Show an error toast for a failed request / error push.
* @param {any} err
* @returns {number}
*/
export function toastError(err) {
return toast(describeError(err), 'error');
}
const ICONS = {
info: 'M12 2a10 10 0 1 0 0 20 10 10 0 0 0 0-20Zm1 15h-2v-6h2v6Zm0-8h-2V7h2v2Z',
success: 'M9.5 16.2 5.3 12l-1.4 1.4 5.6 5.6L20.1 8.4 18.7 7z',
warn: 'M1 21h22L12 2 1 21Zm12-3h-2v-2h2v2Zm0-4h-2v-4h2v4Z',
error: 'M12 2a10 10 0 1 0 0 20 10 10 0 0 0 0-20Zm5 13.6L15.6 17 12 13.4 8.4 17 7 15.6 10.6 12 7 8.4 8.4 7 12 10.6 15.6 7 17 8.4 13.4 12 17 15.6Z',
};
/** Renders the toast stack. Mount once. */
export function ToastHost() {
const [, force] = useReducer((c) => c + 1, 0);
useEffect(() => {
listeners.add(force);
return () => listeners.delete(force);
}, []);
return html`
<div class="toast-host" role="status" aria-live="polite">
${toasts.map((t) => html`
<div key=${t.id} class=${`toast toast--${t.kind}${t.leaving ? ' is-leaving' : ''}`}
onClick=${() => dismissToast(t.id)}>
<svg class="toast__icon" viewBox="0 0 24 24" aria-hidden="true"><path d=${ICONS[t.kind]} /></svg>
<span class="toast__text">${t.text}</span>
${t.count > 1 ? html`<span class="toast__count">×${t.count}</span>` : null}
</div>`)}
</div>`;
}
+102
View File
@@ -0,0 +1,102 @@
// Underframe — the selection diamond of a tapped own piece (research 09 §1.2 "Tap a deployed unit", §5, §6.5;
// official `act2autochess_panel_character_menu`: `util_btn` icon_sell + price "+1", funcId autochessSale; the
// destroy variant `_underFramePanelBtnParamDestroyPos` with icon_destory).
//
// board operator → 撤退 (upper-left: back to the bench) + 出售 +N (upper-right)
// board summon → 撤退
// bench operator → 出售 +N
// item / Art → 销毁
// The diamond frames the piece's tile; only its buttons take pointer events, so the unit under it can still be
// dragged (moving it — or dropping it back on its own tile to re-orient it with the wheel). The game screen shows the
// unit's range tiles (rotated to its facing) and the detail card beside it while it is selected (on the side away
// from the unit, `underframeRect` / gameLogic `panelSide`), and the underframe sits above every HUD panel, so its
// buttons are never covered.
//
// Button look (user playtest #2 item 7): the official sprites icon_sell / icon_destory are WHITE octagon plates with
// the glyph cut out — tinted in the client. Drawn as is they read as a plain white block, so each button is a coloured
// octagon plate: the sprite is used as a CSS mask over the plate colour (出售 amber like every money action, 销毁 /
// 撤退 red) on a dark backing that shows through the glyph cut-out. Without the local sprites the same plate carries
// the built-in glyph.
import { html, HexBadge } from './components.js';
import { GIcon } from './gameComponents.js';
import { useTileScreen } from './facingWheel.js';
import { localAsset } from '../data.js';
const cx = (...p) => p.flat().filter(Boolean).join(' ');
/** Runner glyph of 撤退 (a figure leaving through a door; original shape). */
function RetreatGlyph() {
return html`<svg class="uframe__glyph" viewBox="0 0 24 24" aria-hidden="true">
<path d="M13.5 3.2a2 2 0 1 1 0 4 2 2 0 0 1 0-4zM9.6 8.3l3.7-.6 2.2 3.3 2.7 1-.6 1.8-3.4-1.2-.9-1.3-.8 3.2 2.6 2.6V22h-2v-4.7l-2.4-2.3-.9 3.6-4.4-1 .5-1.9 2.4.5 1.6-6.6-1 .3-1.4 2.8-1.8-.9 1.9-3.6z" />
<path d="M2 5h5v2H4v10h3v2H2z" opacity=".7" />
</svg>`;
}
/**
* A tinted octagon plate: the official sprite (white plate, glyph cut out) as a mask over the tone colour, or the
* built-in glyph on the same plate when the local sprite is not installed.
* @param {{ sprite: string, glyph: string, tone: 'sell'|'destroy' }} props
*/
export function PlateIcon({ sprite, glyph, tone }) {
const url = localAsset('ui/battle', sprite);
return html`<span class=${cx('uframe__plate', `uframe__plate--${tone}`, url ? 'has-mask' : 'is-glyph')}
style=${url ? `--uf-mask:url("${url}")` : ''} data-sprite=${url ? sprite : null} aria-hidden="true">
${url ? null : html`<${GIcon} name=${glyph} class="uframe__pglyph" />`}
</span>`;
}
/**
* Client-px rect the underframe of a tile covers: the diamond plus its buttons (plates, labels, the +N price) — the
* geometry of the css below (.uframe__btn--retreat / --sell: .56rem plates at 25 % / 75 % across, 25 % down, shifted
* −80 % / −20 % horizontally and −90 % vertically). Used for the detail panel placement.
* @param {{ x: number, y: number, s: number }|null} g view.tileScreen(row, col)
* @param {number} [rem] root font size (px)
* @returns {{ left: number, right: number, top: number, bottom: number }|null}
*/
export function underframeRect(g, rem = 100) {
if (!g || !Number.isFinite(g.x) || !Number.isFinite(g.y)) return null;
const s = g.s > 0 ? g.s : 64;
const half = s * 1.05;
const P = rem * 0.56; // plate
const H = P + rem * 0.26; // plate + label
const q = half / 2; // 25 % / 75 % of the diamond box, from its centre
const btnTop = g.y - q - 0.9 * H;
const left = Math.min(g.x - half, g.x - q - 0.8 * P);
const right = Math.max(g.x + half, g.x + q - 0.2 * P + P + rem * 0.14);
return { left, right, top: Math.min(g.y - half, btnTop - rem * 0.08), bottom: g.y + half };
}
/**
* @param {{ view: any, uid?: number|null, row: number, col: number, actions: { retreat: boolean, sell: number|null, destroy: boolean },
* name?: string, busy?: boolean, onRetreat?: () => void, onSell?: () => void, onDestroy?: () => void }} props
*/
export function Underframe({ view, uid = null, row, col, actions, name = '', busy = false, onRetreat, onSell, onDestroy }) {
const g = useTileScreen(view, row, col);
if (!g || !actions) return null;
const s = g.s > 0 ? g.s : 64;
const half = s * 1.05;
const stop = (e) => e.stopPropagation();
return html`<div class="uframe" data-uid=${uid} style=${`left:${g.x}px;top:${g.y}px;width:${half * 2}px;height:${half * 2}px`} role="group"
aria-label=${`${name || '单位'} 操作`}>
<svg class="uframe__dia" viewBox="-110 -110 220 220" aria-hidden="true">
<path class="uframe__outer" d="M0 -100 L100 0 L0 100 L-100 0 Z" />
<path class="uframe__corner" d="M-100 0 L-86 -14 M-100 0 L-86 14 M100 0 L86 -14 M100 0 L86 14 M0 -100 L-14 -86 M0 -100 L14 -86 M0 100 L-14 86 M0 100 L14 86" />
</svg>
${actions.retreat ? html`<button type="button" class="uframe__btn uframe__btn--retreat" disabled=${busy} onPointerDown=${stop}
onClick=${(e) => { stop(e); onRetreat?.(); }} title="撤退至整备区" aria-label="撤退">
<${RetreatGlyph} /><span class="uframe__label">撤退</span>
</button>` : null}
${actions.sell != null ? html`<button type="button" class="uframe__btn uframe__btn--sell" disabled=${busy} onPointerDown=${stop}
onClick=${(e) => { stop(e); onSell?.(); }} title=${`出售(+${actions.sell} 资金)`} aria-label=${`出售,获得 ${actions.sell} 资金`}>
<${PlateIcon} sprite="icon_sell" glyph="sell" tone="sell" />
<span class="uframe__label">出售</span>
<${HexBadge} value=${`+${actions.sell}`} tone="gold" size="sm" class="uframe__price" />
</button>` : null}
${actions.destroy ? html`<button type="button" class=${cx('uframe__btn', 'uframe__btn--destroy')} disabled=${busy} onPointerDown=${stop}
onClick=${(e) => { stop(e); onDestroy?.(); }} title="销毁道具" aria-label="销毁">
<${PlateIcon} sprite="icon_destory" glyph="trash" tone="destroy" />
<span class="uframe__label">销毁</span>
</button>` : null}
</div>`;
}