卫戍协议:盟约 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:
commit
01a44cd288
408 files changed
+308986
No files matched your search
@@ -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();
|
||||
@@ -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 () => {};
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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
|
||||
@@ -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';
|
||||
});
|
||||
}
|
||||
@@ -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 = '启动失败,请刷新页面重试';
|
||||
});
|
||||
@@ -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
@@ -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];
|
||||
}
|
||||
@@ -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 } };
|
||||
}
|
||||
@@ -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; }
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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]) };
|
||||
}
|
||||
@@ -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; }
|
||||
@@ -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
@@ -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 */ }
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 }),
|
||||
];
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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>`;
|
||||
}
|
||||
@@ -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
@@ -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>`;
|
||||
}
|
||||
@@ -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>`;
|
||||
}
|
||||
@@ -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>`;
|
||||
}
|
||||
|
||||
@@ -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>`;
|
||||
}
|
||||
@@ -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>`;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>`;
|
||||
}
|
||||
@@ -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>`;
|
||||
}
|
||||
@@ -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>`;
|
||||
}
|
||||
@@ -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();
|
||||
@@ -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} /><//>`;
|
||||
}
|
||||
@@ -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>`;
|
||||
}
|
||||
@@ -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>`;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>`;
|
||||
}
|
||||
@@ -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>`;
|
||||
}
|
||||
@@ -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>`;
|
||||
}
|
||||
@@ -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>`;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>`;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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' }),
|
||||
};
|
||||
@@ -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
@@ -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>`;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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?.();
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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>`;
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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>`;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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>
|
||||
<//>`;
|
||||
}
|
||||
@@ -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>`;
|
||||
}
|
||||
@@ -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>`;
|
||||
}
|
||||
@@ -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>`;
|
||||
}
|
||||
@@ -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>`;
|
||||
}
|
||||
@@ -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>`;
|
||||
}
|
||||
Reference in new issue
Block a user