Playtest #3 fixes: lost models, picking, live LP, temp overflow, 蕾缪安 S3, 回环射手, FX, detail card, spinners
- #1 lost operator models: the Spine store no longer hands out a skeleton whose unload is in flight (PIXI.Assets.unload is async); eviction runs 1 s after a release and the empty-scene budget only after 3 s - #7 picking: one shared rule (render/pick.js, pixel probe where bodies overlap) for prep/battle clicks, hover, the enemy pen and equipment drops; the drag ghost stands on its drop tile; hover probes capped at one per frame - #2 the top-bar LP drops live as the own battle's enemies enter the blue gate (−N, 联防中), teammates' pending LP in the team panel - #3 temp overflow pieces that arrive after a prep deadline survive through the next prep (tempDue); temp-row warning and ready-button reason - #4 蕾缪安 S3: locks every 0.5 s, then one shell every 0.3 s in lock order (PRTS notes), bombardShell fx - #5 回环射手: real boomerang (out 15, back 3.75 tiles/s, attack only when every boomerang is caught) - #6 visible projectiles, muzzle flashes, slashes, skill bursts and auras at unchanged frame cost - #8 特质 right under the detail card header, with its official type icon - #9 no spinning busy cursors / spinners next to texts; game data warmed before the match - docs, recordings and tests updated (2414 unit tests, all browser suites green)
This commit is contained in:
1 parent
01a44cd288
commit
0aba602ecf
72 files changed
+5132
-571
No files matched your search
+109
-36
@@ -1,5 +1,6 @@
|
||||
// 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.
|
||||
// Spine loader (LRU + refcount + memory budget + timeout + concurrency cap) used by the battlefield renderer. A
|
||||
// skeleton is never handed out while its unload is in flight (RefLru; user playtest #3 item 1: invisible models).
|
||||
//
|
||||
// Pure at import time: no PIXI, no DOM access until a loader actually runs (Node tests import this file).
|
||||
//
|
||||
@@ -178,17 +179,25 @@ export function unitSfxUrl(m, id, kind, skillIndex) {
|
||||
|
||||
/**
|
||||
* 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).
|
||||
* (`record` is the cache record the value belonged to) and may return a promise that settles once it is freed.
|
||||
* 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.
|
||||
* least recently used first, while their total weight exceeds `maxIdleWeight` — or `quietWeight` once nothing at all
|
||||
* has been referenced for `quietDelay` ms (no scene on screen; the instant zero in the middle of a scene switch is not
|
||||
* quiet) — 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.
|
||||
* `evictDelay` > 0: a release (or a finished load) schedules the eviction pass that much later instead of running it
|
||||
* at once, so a scene rebuilt in one go (every view destroyed, then the next scene's views built) never drops a value
|
||||
* it takes again.
|
||||
* A value whose unload is still in flight is never handed out again: a new load of that key waits for the unload to
|
||||
* settle (user playtest #3 item 1 — PIXI.Assets keeps an unloading skeleton in its loader cache until a microtask
|
||||
* later, so loading the same URL meanwhile returned the doomed skeleton, whose textures the unload then destroyed:
|
||||
* invisible operator models).
|
||||
*/
|
||||
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 } = {}) {
|
||||
weigh = null, maxIdleWeight = Infinity, quietWeight, idleGrace = 0, quietDelay = 0, evictDelay = 0, timers } = {}) {
|
||||
if (typeof load !== 'function') throw new TypeError('RefLru: load required');
|
||||
this._load = load;
|
||||
this._unload = unload;
|
||||
@@ -202,13 +211,19 @@ export class RefLru {
|
||||
this.maxIdleWeight = budget(maxIdleWeight, Infinity);
|
||||
this.quietWeight = budget(quietWeight, this.maxIdleWeight);
|
||||
this.idleGrace = budget(idleGrace, 0);
|
||||
this.quietDelay = budget(quietDelay, 0);
|
||||
this.evictDelay = budget(evictDelay, 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
|
||||
this._sweep = null; // { timer, at } pending eviction pass (weight sweep / deferred eviction)
|
||||
/** @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._refs = 0; // Σ refs of every entry
|
||||
this._quietAt = this.now(); // since when nothing has been referenced (null while something is)
|
||||
/** @type {Map<string, Promise<void>>} key → unload still in flight (settles, never rejects) */
|
||||
this._unloading = new Map();
|
||||
this._active = 0;
|
||||
this._queue = [];
|
||||
this._tick = 0;
|
||||
@@ -230,12 +245,17 @@ export class RefLru {
|
||||
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(
|
||||
// an unload of this key still in flight: load again only once it has settled (never the doomed value)
|
||||
const load = () => {
|
||||
const pending = this._unloading.get(key);
|
||||
return pending ? pending.then(() => this._load(key, arg)) : this._load(key, arg);
|
||||
};
|
||||
e.promise = this._schedule(() => this._withTimeout(load(), key)).then(
|
||||
(v) => {
|
||||
if (this.map.get(key) !== e) { try { this._unload(key, v, e); } catch { /* ignore */ } return v; }
|
||||
if (this.map.get(key) !== e) { this._unloadNow(key, v, e); 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();
|
||||
this._requestEvict();
|
||||
return v;
|
||||
},
|
||||
(err) => {
|
||||
@@ -246,6 +266,8 @@ export class RefLru {
|
||||
e.promise.catch(() => {});
|
||||
}
|
||||
e.refs++;
|
||||
this._refs++;
|
||||
this._quietAt = null;
|
||||
e.idleSince = null;
|
||||
e.used = ++this._tick;
|
||||
return e.promise;
|
||||
@@ -255,14 +277,27 @@ export class RefLru {
|
||||
release(key) {
|
||||
const e = this.map.get(key);
|
||||
if (!e) return;
|
||||
if (e.refs > 0 && --e.refs === 0) e.idleSince = this.now();
|
||||
if (e.refs > 0) {
|
||||
if (--e.refs === 0) e.idleSince = this.now();
|
||||
if (--this._refs <= 0) { this._refs = 0; this._quietAt = this.now(); }
|
||||
}
|
||||
e.used = ++this._tick;
|
||||
this._evict();
|
||||
this._requestEvict();
|
||||
}
|
||||
|
||||
/** Is an unload of `key` still in flight (a new load of it waits for it)? */
|
||||
unloading(key) { return this._unloading.has(key); }
|
||||
|
||||
/** Evict now, or (evictDelay > 0) in one pass a moment later. */
|
||||
_requestEvict() {
|
||||
if (this.evictDelay > 0) this._sweepIn(this.evictDelay);
|
||||
else 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).
|
||||
* grace while the idle weight is over budget (`maxIdleWeight`, or `quietWeight` once nothing has been referenced
|
||||
* for `quietDelay` ms).
|
||||
*/
|
||||
_evict() {
|
||||
if (this.map.size > this.max) {
|
||||
@@ -273,33 +308,49 @@ export class RefLru {
|
||||
}
|
||||
}
|
||||
if (!this._weigh) return;
|
||||
let refs = 0, idleWeight = 0;
|
||||
let 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 budget = this.maxIdleWeight;
|
||||
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 (this._refs === 0) {
|
||||
const quietFor = now - (this._quietAt ?? now);
|
||||
if (quietFor >= this.quietDelay) budget = this.quietWeight;
|
||||
else if (idleWeight > this.quietWeight) wait = this.quietDelay - quietFor; // re-check once the quiet has lasted
|
||||
}
|
||||
if (idleWeight > budget && wait < Infinity) this._sweepIn(wait);
|
||||
if (idleWeight > budget) {
|
||||
idle.sort((a, b) => a.used - b.used);
|
||||
let graceWait = Infinity;
|
||||
for (const e of idle) {
|
||||
if (idleWeight <= budget) break;
|
||||
const age = now - (e.idleSince ?? now);
|
||||
if (age < this.idleGrace) { graceWait = Math.min(graceWait, this.idleGrace - age); continue; }
|
||||
idleWeight -= e.weight;
|
||||
this._drop(e);
|
||||
}
|
||||
if (idleWeight > budget) wait = Math.min(wait, graceWait);
|
||||
}
|
||||
if (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 */ } }
|
||||
if (e.state === 'ready') this._unloadNow(e.key, e.value, e);
|
||||
}
|
||||
|
||||
/** Re-run eviction in `ms` (keeps the earliest pending sweep). */
|
||||
/** Run the unload of a value; while a returned promise is pending, loads of that key wait (see the header). */
|
||||
_unloadNow(key, value, rec) {
|
||||
let p = null;
|
||||
try { p = this._unload(key, value, rec); } catch { p = null; }
|
||||
if (!p || typeof p.then !== 'function') return;
|
||||
const done = Promise.resolve(p).then(() => {}, () => {}).then(() => { if (this._unloading.get(key) === done) this._unloading.delete(key); });
|
||||
this._unloading.set(key, done);
|
||||
}
|
||||
|
||||
/** Re-run eviction in `ms` (keeps the earliest pending pass). */
|
||||
_sweepIn(ms) {
|
||||
const at = this.now() + ms;
|
||||
if (this._sweep && this._sweep.at <= at) return;
|
||||
@@ -313,7 +364,9 @@ export class RefLru {
|
||||
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 */ } }
|
||||
this._refs = 0;
|
||||
this._quietAt = this.now();
|
||||
for (const e of all) if (e.state === 'ready') this._unloadNow(e.key, e.value, e);
|
||||
}
|
||||
|
||||
stats() {
|
||||
@@ -324,7 +377,7 @@ export class RefLru {
|
||||
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 };
|
||||
return { size: this.map.size, ready, loading, failed, refs, weight, idleWeight, active: this._active, queued: this._queue.length, unloading: this._unloading.size };
|
||||
}
|
||||
|
||||
_schedule(fn) {
|
||||
@@ -434,26 +487,32 @@ export function spinePages(entry) {
|
||||
* 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.
|
||||
* The unloads are asynchronous (PIXI frees an asset a microtask after the call, and until then `PIXI.Assets.load` of
|
||||
* the same URL hands out that doomed asset): the returned promise settles (never rejects) once every one has run, and
|
||||
* the spine LRU makes a new load of the skeleton wait for it (RefLru header).
|
||||
* @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
|
||||
* @returns {Promise<void>|undefined}
|
||||
*/
|
||||
export function unloadSpineData(entry, _value, keep) {
|
||||
const PIXI = globalThis.PIXI;
|
||||
const skel = entry && entry.skel;
|
||||
if (!skel || !PIXI?.Assets?.unload) return;
|
||||
if (!skel || !PIXI?.Assets?.unload) return undefined;
|
||||
const atlas = (typeof entry.atlas === 'string' && entry.atlas) || skel.replace(/\.skel$/, '.atlas');
|
||||
const loader = PIXI.Assets.loader;
|
||||
const pending = [];
|
||||
const unload = (url, direct) => {
|
||||
try {
|
||||
const p = direct && loader && typeof loader.unload === 'function' ? loader.unload(url) : PIXI.Assets.unload(url);
|
||||
Promise.resolve(p).catch(() => {});
|
||||
pending.push(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);
|
||||
return Promise.all(pending).then(() => {});
|
||||
}
|
||||
|
||||
// ---- store ---------------------------------------------------------------------------------------------------
|
||||
@@ -462,13 +521,22 @@ export function unloadSpineData(entry, _value, keep) {
|
||||
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;
|
||||
/**
|
||||
* Spine LRU eviction runs this long after a release, in one pass: a scene switch (battle → prep destroys every battle
|
||||
* view, then builds the prep views; prep → battle builds the battle views over the next frames) takes its models back
|
||||
* before anything is dropped (user playtest #3 item 1: the bench models were unloaded and re-loaded in one go).
|
||||
*/
|
||||
export const SPINE_EVICT_DELAY_MS = 1000;
|
||||
/** Nothing referenced for this long = no scene on screen (lobby / room): the idle skeletons may all go. */
|
||||
export const SPINE_QUIET_DELAY_MS = 3000;
|
||||
|
||||
/**
|
||||
* 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]
|
||||
* loadSpine?: (entry) => Promise<any>, unloadSpine?: (entry, value, keepPages:Set<string>) => (Promise<void>|void),
|
||||
* spineMax?: number, spineTimeout?: number, spineWeigh?: (key, spineData) => number, spineIdleBytes?: number,
|
||||
* spineQuietBytes?: number, spineIdleGrace?: number, spineEvictDelay?: number, spineQuietDelay?: number,
|
||||
* spineTimers?: { set, clear }, spineNow?: () => number }} [opts]
|
||||
*/
|
||||
export function createAssets(options) {
|
||||
const opts = options && typeof options === 'object' ? options : {};
|
||||
@@ -512,17 +580,22 @@ export function createAssets(options) {
|
||||
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);
|
||||
// the promise (when the unloader returns one) holds back a new load of this skeleton until it has settled
|
||||
return (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
|
||||
// them once no scene has referenced any for SPINE_QUIET_DELAY_MS (lobby / room), each after a grace that covers a
|
||||
// prep ⇄ battle switch; evictions run SPINE_EVICT_DELAY_MS after the release that allows them (a scene switch
|
||||
// takes its models back first)
|
||||
weigh: opts.spineWeigh || ((key, value) => spineDataWeight(value)),
|
||||
maxIdleWeight: opts.spineIdleBytes ?? SPINE_IDLE_BYTES,
|
||||
quietWeight: opts.spineQuietBytes ?? 0,
|
||||
idleGrace: opts.spineIdleGrace ?? SPINE_IDLE_GRACE_MS,
|
||||
quietDelay: opts.spineQuietDelay ?? SPINE_QUIET_DELAY_MS,
|
||||
evictDelay: opts.spineEvictDelay ?? SPINE_EVICT_DELAY_MS,
|
||||
timers: opts.spineTimers,
|
||||
now: opts.spineNow,
|
||||
});
|
||||
|
||||
@@ -20,13 +20,17 @@
|
||||
// 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.
|
||||
// Live leaks (user playtest #3 item 2): every normal field simulated here keeps its counted leaks so far — the settle
|
||||
// rule's count (leaked entries with counted !== false, /sim/spec.js battleProgress) — and publishes them as
|
||||
// state().leaks { [fieldId]: n } whenever one changes, authoritative or display replica alike; the top bar shows the
|
||||
// own field's min(lpCapPerRound, n) as the LP about to be lost (ui/hud.js liveLp).
|
||||
//
|
||||
// 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.state() → { battleId, fieldId, kind, authoritative, watch, done, own, members, loading, paused, leaks } | null
|
||||
// battleRunner.stats() → { ticks, stepMs, avgTickMs, maxFrameMs, catchups, errors, battles }
|
||||
//
|
||||
// createBattleRunner(deps) builds an instance with injectable net / store / clock / frame scheduler / sim loader
|
||||
@@ -133,6 +137,8 @@ export function createBattleRunner(deps) {
|
||||
let lastPool = null;
|
||||
/** solo pause: the runner clock's instant when m.public.paused turned true (null while running) */
|
||||
let pausedAt = null;
|
||||
/** a normal field's leak count changed since the last publishState() */
|
||||
let leaksDirty = false;
|
||||
const stats = { ticks: 0, stepMs: 0, maxFrameMs: 0, catchups: 0, errors: 0, battles: 0, frames: 0 };
|
||||
|
||||
const hidden = () => !!(doc && doc.hidden);
|
||||
@@ -147,21 +153,48 @@ export function createBattleRunner(deps) {
|
||||
return simP;
|
||||
}
|
||||
|
||||
/** Counted leaks so far of every normal field simulated here: { [fieldId]: n } (user playtest #3 item 2). */
|
||||
function leakMap() {
|
||||
const out = {};
|
||||
for (const e of entries.values()) if (e.kind === 'normal' && e.fieldId) out[e.fieldId] = e.leaks;
|
||||
return out;
|
||||
}
|
||||
|
||||
function state() {
|
||||
const e = cur;
|
||||
if (!e) return loading ? { loading: true, battleId: loading.battleId, fieldId: loading.fieldId, kind: loading.kind } : null;
|
||||
if (!e) return loading ? { loading: true, battleId: loading.battleId, fieldId: loading.fieldId, kind: loading.kind, leaks: leakMap() } : 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,
|
||||
leaks: leakMap(),
|
||||
};
|
||||
}
|
||||
|
||||
function publishState() {
|
||||
leaksDirty = false;
|
||||
const s = state();
|
||||
try { store.patch('match', { battle: s }); } catch { /* store without a match slice */ }
|
||||
emit('state', s);
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-count an entry's leaks when its battle recorded a new one (Battle.leakedCount) or ended (timeout leaks, and the
|
||||
* final result): the settle rule's count, leaked entries with counted !== false (/sim/spec.js battleProgress).
|
||||
*/
|
||||
function noteLeaks(e) {
|
||||
if (e.kind !== 'normal') return;
|
||||
const b = e.battle;
|
||||
const mark = `${Number(b.leakedCount) || 0}:${b.finished ? 1 : 0}`;
|
||||
if (mark === e.leakMark) return;
|
||||
e.leakMark = mark;
|
||||
let n = e.leaks;
|
||||
try { n = Math.max(0, Math.trunc(Number(e.sim.spec.battleProgress(b).leaks) || 0)); } catch { /* keep the last count */ }
|
||||
if (n !== e.leaks) { e.leaks = n; leaksDirty = true; }
|
||||
}
|
||||
|
||||
/** Publish the state when a leak count changed since the last publish. */
|
||||
function flushLeaks() { if (leaksDirty) publishState(); }
|
||||
|
||||
/** 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));
|
||||
|
||||
@@ -225,6 +258,7 @@ export function createBattleRunner(deps) {
|
||||
function finished(e) {
|
||||
if (e.done) return;
|
||||
e.done = true;
|
||||
noteLeaks(e);
|
||||
if (e.authoritative && !e.resultSent && net) {
|
||||
progress(e, true);
|
||||
e.resultSent = true;
|
||||
@@ -240,6 +274,7 @@ export function createBattleRunner(deps) {
|
||||
}
|
||||
}
|
||||
if (e === cur) publishState();
|
||||
else flushLeaks();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -302,6 +337,7 @@ export function createBattleRunner(deps) {
|
||||
if (dt > stats.maxFrameMs) stats.maxFrameMs = dt;
|
||||
if (render) emitFrame(e, catchingUp);
|
||||
else { try { e.battle.drainEvents(); } catch { /* ignore */ } }
|
||||
noteLeaks(e);
|
||||
progress(e);
|
||||
if (e.battle.finished) finished(e);
|
||||
}
|
||||
@@ -313,6 +349,7 @@ export function createBattleRunner(deps) {
|
||||
stats.frames++;
|
||||
const t = clock();
|
||||
for (const e of [...entries.values()]) if (running(e)) advance(e, t, e === cur);
|
||||
flushLeaks();
|
||||
schedule();
|
||||
}
|
||||
|
||||
@@ -320,6 +357,7 @@ export function createBattleRunner(deps) {
|
||||
// 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);
|
||||
flushLeaks();
|
||||
}
|
||||
|
||||
function schedule() {
|
||||
@@ -411,6 +449,8 @@ export function createBattleRunner(deps) {
|
||||
t0: clock() - ((Number(msg.elapsed) || 0) / speed) * 1000, lastProgressAt: -Infinity, done: false, resultSent: false,
|
||||
result: null, delivery: null,
|
||||
meter: sim.spec.attachLpMeter(battle),
|
||||
// counted leaks so far (normal fields; noteLeaks) and the Battle state they were counted at
|
||||
leaks: 0, leakMark: '',
|
||||
};
|
||||
if (lastPool && battle.sharedBoss && typeof battle.sharedBoss.sync === 'function') {
|
||||
battle.sharedBoss.sync(lastPool.hp, lastPool.acked ? lastPool.acked[e.fieldId] : undefined);
|
||||
@@ -420,17 +460,19 @@ export function createBattleRunner(deps) {
|
||||
const n = Math.min(PREPARE_SLICE, targetTick(e, clock()) - battle.tickCount);
|
||||
stepEntry(e, n);
|
||||
try { battle.drainEvents(); } catch { /* ignore */ }
|
||||
noteLeaks(e);
|
||||
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(); }
|
||||
if (e.authoritative) { entries.set(e.battleId, e); evict(); if (e.leaks) leaksDirty = true; schedule(); }
|
||||
return;
|
||||
}
|
||||
}
|
||||
entries.set(e.battleId, e);
|
||||
evict();
|
||||
loading = null;
|
||||
noteLeaks(e);
|
||||
show(e);
|
||||
if (battle.finished) finished(e);
|
||||
}
|
||||
@@ -459,6 +501,7 @@ export function createBattleRunner(deps) {
|
||||
}
|
||||
if (e === cur) emitFrame(e, false);
|
||||
finished(e);
|
||||
flushLeaks();
|
||||
schedule();
|
||||
}
|
||||
|
||||
|
||||
+50
-16
@@ -1,8 +1,12 @@
|
||||
// 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.
|
||||
// Files are fetched on first use and cached for the page's lifetime (the Promise is shared, so concurrent callers
|
||||
// trigger a single request): every text of the game (operators, skills, bonds, items, enemies …) is static data loaded
|
||||
// once — nothing is fetched from the server during a match. main.js warms the in-match files (gameComponents
|
||||
// GAME_FILES) in the background once the player is in a room, and the match screen waits for them, so no text of the
|
||||
// match UI ever appears late. A transient failure (network error, HTTP 5xx) is retried twice (RETRY_DELAYS_MS); files
|
||||
// that stay unavailable (404, repeated failures, 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 / …),
|
||||
@@ -70,13 +74,31 @@ export function buildIndex(name, json) {
|
||||
return map;
|
||||
}
|
||||
|
||||
/** Waits (ms) before retrying a data file whose download failed transiently (network error, HTTP 5xx / 408 / 429). */
|
||||
export const RETRY_DELAYS_MS = Object.freeze([600, 2000]);
|
||||
|
||||
/**
|
||||
* A failed load worth retrying: the request itself failed (network error) or the server answered 5xx / 408 / 429 — not a
|
||||
* definite 4xx (the file is not there) nor a delivered file that is not valid JSON (`badJson`).
|
||||
*/
|
||||
const transientFailure = (err) => {
|
||||
if (!err || err.badJson) return false;
|
||||
const s = err.status;
|
||||
return !(Number.isInteger(s) && s >= 400 && s < 500 && s !== 408 && s !== 429);
|
||||
};
|
||||
|
||||
/**
|
||||
* Create a data store bound to a fetch implementation (injectable for tests).
|
||||
* @param {{ fetch?: typeof fetch, base?: string }} [opts]
|
||||
* A file is downloaded once per page (the texts of the game are static data, never fetched again during a match —
|
||||
* user playtest #3 item 9); a transient failure is retried (RETRY_DELAYS_MS) while the file stays 'loading', so a
|
||||
* network hiccup does not leave the texts of a whole session missing.
|
||||
* @param {{ fetch?: typeof fetch, base?: string, retryDelays?: number[], wait?: (ms: number) => Promise<void> }} [opts]
|
||||
*/
|
||||
export function createDataStore(opts = {}) {
|
||||
const base = opts.base ?? '/data/';
|
||||
const doFetch = opts.fetch || ((...a) => globalThis.fetch(...a));
|
||||
const retryDelays = Array.isArray(opts.retryDelays) ? opts.retryDelays : RETRY_DELAYS_MS;
|
||||
const wait = opts.wait || ((ms) => new Promise((resolve) => setTimeout(resolve, ms)));
|
||||
/** @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();
|
||||
@@ -96,19 +118,31 @@ export function createDataStore(opts = {}) {
|
||||
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`);
|
||||
for (let attempt = 0; ; attempt++) {
|
||||
try {
|
||||
const res = await doFetch(urlFor(name), { cache: 'no-cache' });
|
||||
if (!res || !res.ok) throw Object.assign(new Error(`HTTP ${res ? res.status : '???'}`), { status: res ? res.status : null });
|
||||
let json;
|
||||
try { json = await res.json(); } catch (err) { throw Object.assign(err instanceof Error ? err : new Error(String(err)), { badJson: true }); }
|
||||
entry.value = json;
|
||||
entry.status = 'ready';
|
||||
break;
|
||||
} catch (err) {
|
||||
// a transient failure is tried again (still 'loading'), unless the load was superseded meanwhile
|
||||
if (transientFailure(err) && attempt < retryDelays.length && entries.get(name) === entry) {
|
||||
await wait(retryDelays[attempt]);
|
||||
if (entries.get(name) === entry) continue;
|
||||
entry.status = 'missing'; // superseded by invalidate() meanwhile: the new load reports for itself
|
||||
break;
|
||||
}
|
||||
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';
|
||||
break;
|
||||
}
|
||||
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);
|
||||
|
||||
@@ -21,6 +21,8 @@
|
||||
// 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).
|
||||
// Game data: every text of the game is static data (/data/*.json) downloaded once per page; the in-match files are
|
||||
// warmed in the background as soon as the player is in a room (warmGameData), before the match needs them.
|
||||
|
||||
// Polyfills first (older Safari / Firefox ESR): every module evaluated after this one sees them.
|
||||
import './ui/compat.js';
|
||||
@@ -32,6 +34,7 @@ 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 { GAME_FILES } from './ui/gameComponents.js';
|
||||
import { TitleScreen, sanitizeName } from './screens/title.js';
|
||||
import { LobbyScreen, rememberRoom, parseRoomParam } from './screens/lobby.js';
|
||||
import { RoomScreen } from './screens/room.js';
|
||||
@@ -223,9 +226,24 @@ function wireNet() {
|
||||
// Entering (title → lobby) while already online also needs the deep-link join.
|
||||
store.subscribe((s, prev) => {
|
||||
if (s.session.entered && !prev.session.entered) schedulePendingJoin();
|
||||
// in a room (co-op or solo, also a resumed one) a match is near: its data starts downloading
|
||||
if (s.room && !prev.room) warmGameData();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Download every data file of the match UI (gameComponents GAME_FILES: operators, skills, bonds, items, enemies, 特质 …)
|
||||
* in the background once the player is in a room — a match is near (the lobby alone never downloads them). The game's
|
||||
* texts are static data loaded once per page — never fetched during a match — and the match screen waits for these
|
||||
* files, so with them warmed it opens at once and no text ever appears late (user playtest #3 item 9). Idempotent (the
|
||||
* data store shares each file's promise).
|
||||
*/
|
||||
function warmGameData() {
|
||||
const go = () => { data.loadAll(GAME_FILES).catch(() => {}); };
|
||||
if (typeof globalThis.requestIdleCallback === 'function') globalThis.requestIdleCallback(go, { timeout: 2500 });
|
||||
else setTimeout(go, 600);
|
||||
}
|
||||
|
||||
// ---- UI chrome (the connection banner lives in ui/connBanner.js) -----------------------------------
|
||||
|
||||
function ScreenCrashed({ error, reset }) {
|
||||
|
||||
+154
-38
@@ -14,11 +14,22 @@
|
||||
// view.highlightTiles(tiles, style) [[r,c]] | [{row,col}]; style 'legal'|'illegal'|'range'|'rangeStand'|
|
||||
// 'hover'|'target'|{color,fill,line,group}; highlightTiles(null) clears all
|
||||
// view.on(name, fn) → unsubscribe ; view.off(name, fn)
|
||||
// pieceDragStart { uid, piece, from } · pieceDrop { uid, piece, from, target } · pieceDragEnd {uid, dropped}
|
||||
// pieceDragStart { uid, piece, from } · pieceDrop { uid, piece, from, target, over } · pieceDragEnd {uid, dropped, over}
|
||||
// pieceDragOver { uid, over, clientX, clientY } — while an item is dragged: `over` = uid of the own operator /
|
||||
// summon under the dragged plate (null: none), sent when it changes, BEFORE the drag asks canPlace
|
||||
// pieceClick { uid, piece, button, detail, clientX, clientY } (battle units: { unitId, uid, unit, … })
|
||||
// pieceDetail (right-click / long-press) · pieceHover { uid } | { uid: null } (battle: + unitId, unit)
|
||||
// tileHover { row, col, area, idx } | null
|
||||
// view.pieceScreenRect(uid) → { left, top, right, bottom, width, height, x, y } (client px) | null
|
||||
// tileHover { row, col, area, idx } | null (while dragging: the drop target, the tile under the ghost's feet)
|
||||
// view.pieceScreenRect(uid) → { left, top, right, bottom, width, height, x, y, shape } (client px) | null;
|
||||
// `shape` = the body for render/pick.js (ui/gameLogic.js pickPieceAt)
|
||||
// Picking (user playtest #3 item 7): every "which unit is under the pointer" — prep press / drag start / detail
|
||||
// (pieceAt), battle clicks and hover (battleUnitAt), the pen (penUnitAt), the operator under a dragged item
|
||||
// (pieceUnder) — is render/pick.js pickBody over the views' drawn bodies (UnitView.pickShape) and the tile under the
|
||||
// pointer, with a pixel probe (drawnAt: a view rendered alone into a 1×1 target) wherever bodies overlap: the front-most
|
||||
// unit whose model draws the pixel under the pointer wins; elsewhere the face / torso of a unit is its own, then the
|
||||
// unit standing on the pressed tile. A dragged
|
||||
// piece's ghost stands on its drop point — the pointer (mouse / pen) or TOUCH_LIFT_TILES above the finger (touch) —
|
||||
// and the drop target is the tile under it (render/drag.js dropPoint).
|
||||
// Direction step (ui/facingWheel.js, research 09 §1.2):
|
||||
// view.tileScreen(row, col) → { x, y, s, poly: [[x,y]×4] } (client px: tile-top centre, px per tile, corners) | null
|
||||
// view.holdPiece(uid, {row,col} | null) keep a dropped prep piece standing on a tile while its direction is
|
||||
@@ -84,6 +95,7 @@ import { BoardScene } from './board3d/scene.js';
|
||||
import { AREAS, areaFor, unionAreas } from './board3d/layout.js';
|
||||
import { layoutPen, penSignature } from './pen.js';
|
||||
import { IDENTITY, bossPrepField, tilesToDisp } from './prepfield.js';
|
||||
import { pickBody } from './pick.js';
|
||||
|
||||
const VENDOR = { pixi: '/vendor/pixi.min.js', spine: '/vendor/pixi-spine.js' };
|
||||
const PIECE_DIRS = new Set(['UP', 'RIGHT', 'DOWN', 'LEFT']);
|
||||
@@ -100,6 +112,21 @@ const RANGE_GROUPS = new Set(['facing', 'range', 'rangeStand', 'select', 'sel',
|
||||
/** atk projectile kinds whose first id is the previous bounce target (sim ai.js), not the attacker. */
|
||||
const CHAIN_KINDS = new Set(['chain', 'chainHeal']);
|
||||
const DROP_PENDING_MS = 1300;
|
||||
/** A touch drag holds its ghost — and so its drop point — this many tiles above the finger (neither is hidden). */
|
||||
export const TOUCH_LIFT_TILES = 0.6;
|
||||
|
||||
/** The display tile a view stands on (its drawn position). */
|
||||
const viewTile = (v) => ({ row: Math.round(v.y), col: Math.round(v.x) });
|
||||
/** A view's body for render/pick.js, or null when it cannot be picked (gone, faded out, dead). */
|
||||
function pickShapeOf(v, tile) {
|
||||
if (!v || v.destroyed || typeof v.pickShape !== 'function' || v.alive === false) return null;
|
||||
if (Number.isFinite(v.alpha) && v.alpha < 0.05) return null;
|
||||
const b = v.pickShape();
|
||||
if (!b) return null;
|
||||
b.tile = tile === undefined ? viewTile(v) : tile;
|
||||
b.ref = v;
|
||||
return b;
|
||||
}
|
||||
|
||||
let pixiPromise = null;
|
||||
|
||||
@@ -894,16 +921,13 @@ export async function createFieldView(host, options = {}) {
|
||||
penList = null;
|
||||
}
|
||||
|
||||
/** Front-most pen enemy under a canvas point. */
|
||||
/** The pen enemy under a canvas point (render/pick.js; the figures idle on their pen tiles). */
|
||||
function penUnitAt(x, y) {
|
||||
if (penHidden) return null;
|
||||
let best = null, bestZ = -Infinity;
|
||||
for (const v of penViews.values()) {
|
||||
if (!v.hitTest(x, y)) continue;
|
||||
const z = v.root ? v.root.zIndex : 0;
|
||||
if (z > bestZ) { bestZ = z; best = v; }
|
||||
}
|
||||
return best;
|
||||
const bodies = [];
|
||||
for (const v of penViews.values()) { const b = pickShapeOf(v); if (b) bodies.push(b); }
|
||||
const hit = pickBody(bodies, x, y, groundTile(x, y), probeAt(x, y));
|
||||
return hit ? hit.ref : null;
|
||||
}
|
||||
|
||||
function emitPenClick(v, e) {
|
||||
@@ -913,27 +937,100 @@ export async function createFieldView(host, options = {}) {
|
||||
|
||||
// ---- drag & pointer ----------------------------------------------------------------------------------------
|
||||
|
||||
function pieceAt(x, y) {
|
||||
if (mode !== 'prep') return null;
|
||||
let best = null, bestZ = -Infinity;
|
||||
/** The display tile under a canvas point (raised tops first), or null off the grid. */
|
||||
function groundTile(x, y) {
|
||||
const t = pickTile(cam, x, y, heightAt, tiles.levels);
|
||||
return t ? { row: t.row, col: t.col } : null;
|
||||
}
|
||||
|
||||
// pixel probe of render/pick.js: one view's display tree rendered alone into a 1×1 target over the canvas pixel
|
||||
let probeRT = null;
|
||||
let probeM = null;
|
||||
/**
|
||||
* Does view `v` draw an opaque pixel at canvas point (x, y) — as last rendered (its world transforms of the last
|
||||
* frame: what the player sees)? null when it cannot tell (no WebGL readback, lost context…). Only asked where two or
|
||||
* more bodies overlap (render/pick.js probeBodies), i.e. a few 1-px renders per press / hover move there.
|
||||
*/
|
||||
function drawnAt(v, x, y) {
|
||||
const root = v && v.root;
|
||||
if (!root || root.destroyed) return false;
|
||||
if (!root.visible || !(root.worldAlpha > 0.02)) return false;
|
||||
try {
|
||||
const R = app.renderer;
|
||||
if (!R || !R.extract || typeof R.extract.pixels !== 'function' || !P.RenderTexture || !P.Matrix) return null;
|
||||
if (!probeRT) probeRT = P.RenderTexture.create({ width: 1, height: 1, resolution: 1 });
|
||||
if (!probeM) probeM = new P.Matrix();
|
||||
probeM.set(1, 0, 0, 1, 0.5 - x, 0.5 - y); // the pixel centred on the point
|
||||
R.render(root, { renderTexture: probeRT, clear: true, transform: probeM, skipUpdateTransform: true });
|
||||
const px = R.extract.pixels(probeRT);
|
||||
return px && px.length >= 4 ? px[3] > 40 : null;
|
||||
} catch { return null; }
|
||||
}
|
||||
const probeAt = (x, y) => (b) => drawnAt(b.ref, x, y);
|
||||
// Hover picks (mouse moves over the field, item drags) probe at most once per frame: every readback waits for the
|
||||
// GPU and a mouse reports up to 1000 moves per second, so a second hover pick in the same frame gets the shape rule
|
||||
// alone (it only moves a highlight). Presses, releases and drops always probe.
|
||||
let hoverProbeFrame = -1;
|
||||
const hoverProbeAt = (x, y) => {
|
||||
if (hoverProbeFrame === frameNo) return null;
|
||||
hoverProbeFrame = frameNo;
|
||||
return probeAt(x, y);
|
||||
};
|
||||
|
||||
/** The prep piece entry under a canvas point (render/pick.js), among the entries `accept` lets through. */
|
||||
function prepEntryAt(x, y, accept = null, hover = false) {
|
||||
const bodies = [];
|
||||
for (const e of prepPieces) {
|
||||
const v = views.get(e.key);
|
||||
if (!v || !v.hitTest(x, y)) continue;
|
||||
const z = v.root.zIndex;
|
||||
if (z > bestZ) { bestZ = z; best = e; }
|
||||
}
|
||||
if (!best) {
|
||||
// fall back to the tile under the pointer
|
||||
const t = pickBoardTile(x, y);
|
||||
if (t) best = prepPieces.find((e) => { const w = pieceTile(e); return w && w.row === t.row && w.col === t.col; }) || null;
|
||||
if (accept && !accept(e)) continue;
|
||||
const b = pickShapeOf(views.get(e.key));
|
||||
if (b) { b.entry = e; bodies.push(b); }
|
||||
}
|
||||
const hit = pickBody(bodies, x, y, groundTile(x, y), hover ? hoverProbeAt(x, y) : probeAt(x, y));
|
||||
return hit ? hit.entry : null;
|
||||
}
|
||||
|
||||
function pieceAt(x, y, hover = false) {
|
||||
if (mode !== 'prep') return null;
|
||||
const best = prepEntryAt(x, y, null, hover);
|
||||
if (!best) return null;
|
||||
return { uid: best.uid, kind: best.piece.kind, id: best.piece.id, area: best.area, idx: best.idx, row: best.row, col: best.col, piece: best.piece, draggable: true };
|
||||
}
|
||||
|
||||
/** uid of the own operator / summon under a canvas point (not `exceptUid`): where a dragged item would go. */
|
||||
function pieceUnder(x, y, exceptUid, hover = false) {
|
||||
if (mode !== 'prep') return null;
|
||||
const e = prepEntryAt(x, y, (en) => en.uid !== exceptUid && (en.piece.kind === 'chess' || en.piece.kind === 'token'), hover);
|
||||
return e ? e.uid : null;
|
||||
}
|
||||
|
||||
// the current drag's drop point and the own unit under it (items): { uid, over, x, y }
|
||||
let dragHit = null;
|
||||
/**
|
||||
* Drop point of a dragged piece (render/drag.js dropPoint): the pointer, or TOUCH_LIFT_TILES above a finger. For an
|
||||
* item it also finds the operator under the plate and tells the UI (pieceDragOver) before the drag asks canPlace
|
||||
* (a move is a hover pick; the release (`final`) always probes).
|
||||
*/
|
||||
function dropPoint(e, piece, final = false) {
|
||||
let x = e.x, y = e.y;
|
||||
if (e.pointerType === 'touch') {
|
||||
const g = cam.unproject(x, y, 0);
|
||||
y -= TOUCH_LIFT_TILES * (g ? cam.scaleAt(g.x, g.y, 0) : cam.scale);
|
||||
}
|
||||
const uid = piece ? piece.uid : null;
|
||||
const over = piece && piece.kind === 'item' ? pieceUnder(x, y, uid, !final) : null;
|
||||
const changed = !dragHit || dragHit.uid !== uid || dragHit.over !== over;
|
||||
dragHit = { uid, over, x, y };
|
||||
if (changed && piece && piece.kind === 'item') {
|
||||
const r = canvas.getBoundingClientRect();
|
||||
emit('pieceDragOver', { uid, over, clientX: r.left + x, clientY: r.top + y });
|
||||
}
|
||||
return { x, y };
|
||||
}
|
||||
|
||||
const drag = createDragController({
|
||||
hitPiece: (x, y) => pieceAt(x, y),
|
||||
hitPiece: (x, y, hover) => pieceAt(x, y, !!hover),
|
||||
pickTile: (x, y) => pickBoardTile(x, y),
|
||||
dropPoint: (e, piece, final) => dropPoint(e, piece, !!final),
|
||||
isOverCanvas: (cx, cy) => {
|
||||
try { const el = document.elementFromPoint(cx, cy); return !el || el === canvas; } catch { return true; }
|
||||
},
|
||||
@@ -947,8 +1044,10 @@ export async function createFieldView(host, options = {}) {
|
||||
|
||||
function onDragEvent(name, payload) {
|
||||
const out = payload && payload.piece ? { ...payload, piece: publicPiece(payload.piece) } : payload;
|
||||
// the own unit under a dropped item (render/pick.js at the drop point; null for units / nothing there)
|
||||
if ((name === 'pieceDrop' || name === 'pieceDragEnd') && out && typeof out === 'object') out.over = dragHit && dragHit.uid === payload.uid ? dragHit.over : null;
|
||||
switch (name) {
|
||||
case 'pieceDragStart': startDragVisual(payload); break;
|
||||
case 'pieceDragStart': dragHit = null; startDragVisual(payload); break;
|
||||
case 'pieceDragMove': moveDragVisual(payload); return; // internal
|
||||
case 'pieceDrop': dropDragVisual(payload); break;
|
||||
case 'pieceDragEnd': if (!payload.dropped) endDragVisual(true); else clearHl('dragTarget'); break;
|
||||
@@ -1012,12 +1111,15 @@ export async function createFieldView(host, options = {}) {
|
||||
function moveDragVisual(p) {
|
||||
if (!dragState) return;
|
||||
const v = dragState.view;
|
||||
const zPlane = 0.1;
|
||||
const w = cam.unproject(p.x, p.y + cam.scaleAt(v.x, v.y, 0) * 0.45, zPlane);
|
||||
// the ghost stands (an item: floats centred) on its drop point — the tile the drop goes to is the one under it
|
||||
const hx = Number.isFinite(p.hx) ? p.hx : p.x, hy = Number.isFinite(p.hy) ? p.hy : p.y;
|
||||
const t = pickTile(cam, hx, hy, heightAt, tiles.levels);
|
||||
const z = t ? heightAt(t.row, t.col) : 0;
|
||||
const w = cam.unproject(hx, hy, z);
|
||||
if (w) {
|
||||
v.x = Math.max(-1, Math.min(21, w.x));
|
||||
v.y = Math.max(-1, Math.min(19, w.y));
|
||||
v.z = zPlane;
|
||||
v.z = z;
|
||||
}
|
||||
if (p.target && p.target.area !== 'temp') {
|
||||
const r = p.target.area === 'board' ? p.target.row : GEO.HAND_ROW;
|
||||
@@ -1065,15 +1167,16 @@ export async function createFieldView(host, options = {}) {
|
||||
return { pointerId: e.pointerId, pointerType: e.pointerType, button: e.button, x: p.x, y: p.y, clientX: e.clientX, clientY: e.clientY };
|
||||
}
|
||||
|
||||
function battleUnitAt(x, y) {
|
||||
let best = null, bestZ = -Infinity;
|
||||
/** The battle unit under a canvas point (render/pick.js; allies stand on their tiles, enemies walk: no tile). */
|
||||
function battleUnitAt(x, y, hover = false) {
|
||||
const bodies = [];
|
||||
for (const v of views.values()) {
|
||||
if (!v.hitTest || !v.alive || !v.info || v.info.kind === 'device') continue;
|
||||
if (!v.hitTest(x, y)) continue;
|
||||
const z = v.root ? v.root.zIndex : 0;
|
||||
if (z > bestZ) { bestZ = z; best = v; }
|
||||
if (!v.info || v.info.kind === 'device') continue;
|
||||
const b = pickShapeOf(v, v.info.side === 'ally' ? viewTile(v) : null);
|
||||
if (b) bodies.push(b);
|
||||
}
|
||||
return best;
|
||||
const hit = pickBody(bodies, x, y, groundTile(x, y), hover ? hoverProbeAt(x, y) : probeAt(x, y));
|
||||
return hit ? hit.ref : null;
|
||||
}
|
||||
|
||||
const onPointerDown = (e) => {
|
||||
@@ -1103,7 +1206,7 @@ export async function createFieldView(host, options = {}) {
|
||||
const ev = evPayload(e);
|
||||
if (mode === 'battle') {
|
||||
if (e.pointerType === 'touch') return;
|
||||
const v = battleUnitAt(ev.x, ev.y);
|
||||
const v = battleUnitAt(ev.x, ev.y, true);
|
||||
if (v !== hoverUnit) {
|
||||
hoverUnit = v;
|
||||
const info = v ? infos.get(v.id) || v.info : null;
|
||||
@@ -1551,7 +1654,14 @@ export async function createFieldView(host, options = {}) {
|
||||
const b = v.bounds();
|
||||
const r = canvas.getBoundingClientRect();
|
||||
const left = r.left + b.x, top = r.top + b.y;
|
||||
return { left, top, right: left + b.width, bottom: top + b.height, width: b.width, height: b.height, x: left, y: top };
|
||||
// the drawn body in client px (render/pick.js, ui/gameLogic.js pickPieceAt)
|
||||
let shape = null;
|
||||
if (typeof v.pickShape === 'function') {
|
||||
const s = v.pickShape();
|
||||
const hud = s.hud ? { x0: s.hud.x0 + r.left, y0: s.hud.y0 + r.top, x1: s.hud.x1 + r.left, y1: s.hud.y1 + r.top } : null;
|
||||
shape = { ...s, x: s.x + r.left, y: s.y + r.top, hud };
|
||||
}
|
||||
return { left, top, right: left + b.width, bottom: top + b.height, width: b.width, height: b.height, x: left, y: top, shape };
|
||||
},
|
||||
/**
|
||||
* Screen geometry of a BOARD-space tile (in the Final Assault prep: where that board tile is drawn on the boss
|
||||
@@ -1636,6 +1746,8 @@ export async function createFieldView(host, options = {}) {
|
||||
try { fx.destroy(); } catch { /* ignore */ }
|
||||
try { tiles.destroy(); } catch { /* ignore */ }
|
||||
try { impostors.destroy(); } catch { /* ignore */ }
|
||||
try { probeRT?.destroy(true); } catch { /* ignore */ }
|
||||
probeRT = null;
|
||||
listeners.clear();
|
||||
try { board3d?.destroy(); } catch { /* ignore */ }
|
||||
board3d = null;
|
||||
@@ -1654,7 +1766,11 @@ export async function createFieldView(host, options = {}) {
|
||||
},
|
||||
get mode() { return mode; },
|
||||
/** Dev hooks (demo / tests). */
|
||||
debug: { app, get cam() { return cam; }, get board3d() { return board3d; }, tiles, views, penViews, interp, fx, ctx, drag, get camKind() { return viewKind(camKind, camOpts); } },
|
||||
debug: {
|
||||
app, get cam() { return cam; }, get board3d() { return board3d; }, tiles, views, penViews, interp, fx, ctx, drag, get camKind() { return viewKind(camKind, camOpts); },
|
||||
// picking (render/pick.js) at canvas px: the prep piece / battle view / pen view / own unit under a dragged item
|
||||
pick: { pieceAt, battleUnitAt, penUnitAt, pieceUnder, groundTile, drawnAt }, get dragHit() { return dragHit; },
|
||||
},
|
||||
};
|
||||
return view;
|
||||
}
|
||||
+31
-10
@@ -9,14 +9,20 @@
|
||||
// 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").
|
||||
// While dragging, the target is the tile under the dragged ghost's feet: `dropPoint(e, piece)` (app.js) gives that
|
||||
// canvas point — the pointer itself (mouse / pen), a little above the finger (touch) — and the ghost is drawn standing
|
||||
// on it, so what the player sees is where the piece lands (user playtest #3 item 7); whether DOM UI covers the drop is
|
||||
// tested there too (a lifted touch ghost stands on the bench while the finger is on the shop bar below it). Hover and
|
||||
// presses use the pointer.
|
||||
//
|
||||
// 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)
|
||||
// tileHover { row, col, area, idx } | null (hover / drag target 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)
|
||||
// pieceDragMove { uid, piece, x, y, hx, hy, clientX, clientY, target, legal } (for the ghost; high frequency;
|
||||
// hx, hy = the drop point)
|
||||
// pieceDrop { uid, piece, from, target }
|
||||
// pieceDragEnd { uid, piece, dropped, cancelled }
|
||||
|
||||
@@ -90,8 +96,11 @@ 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
|
||||
* hitPiece: (x, y, hover) => (object|null), // piece under canvas point (render/pick.js rule); piece.uid required;
|
||||
* // hover = a mouse move (not a press): may skip the pixel probe
|
||||
* pickTile: (x, y) => ({row, col}|null), // grid tile under canvas point
|
||||
* dropPoint?: (e, piece, final) => ({x, y}|null), // canvas point of the dragged ghost's feet (default: the pointer);
|
||||
* // final = the release (a move may skip the pixel probe)
|
||||
* isOverCanvas?: (clientX, clientY) => boolean, // false when DOM UI covers the point (default true)
|
||||
* canPlace?: (piece, row, col, target) => boolean,
|
||||
* emit: (name, payload) => void,
|
||||
@@ -112,8 +121,14 @@ export function createDragController(hooks) {
|
||||
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 pieceAt = (x, y, hover = false) => { try { return h.hitPiece(x, y, hover); } catch { return null; } };
|
||||
const overCanvas = (cx, cy) => { try { return h.isOverCanvas ? h.isOverCanvas(cx, cy) !== false : true; } catch { return true; } };
|
||||
/** The drop point of a dragged piece (its ghost's feet) for pointer event `e`. */
|
||||
const dropAt = (e, piece, final = false) => {
|
||||
let p = null;
|
||||
try { p = typeof h.dropPoint === 'function' ? h.dropPoint(e, piece, final) : null; } catch { p = null; }
|
||||
return p && Number.isFinite(p.x) && Number.isFinite(p.y) ? p : { x: e.x, y: e.y };
|
||||
};
|
||||
|
||||
function setHoverTile(tile) {
|
||||
const slot = tile ? tileSlot(tile) : null;
|
||||
@@ -140,13 +155,18 @@ export function createDragController(hooks) {
|
||||
moveDrag(e);
|
||||
}
|
||||
|
||||
/** Is the drop point (not the finger) on the canvas? A touch ghost is lifted over the board while the finger may be
|
||||
* on the DOM below it (the shop bar under the bench on a phone): what counts is where the piece is drawn. */
|
||||
const dropOverCanvas = (e, pt) => overCanvas(e.clientX + (pt.x - e.x), e.clientY + (pt.y - e.y));
|
||||
|
||||
function moveDrag(e) {
|
||||
const tile = tileAt(e.x, e.y);
|
||||
const over = overCanvas(e.clientX, e.clientY);
|
||||
const pt = dropAt(e, st.piece);
|
||||
const tile = tileAt(pt.x, pt.y);
|
||||
const over = dropOverCanvas(e, pt);
|
||||
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 });
|
||||
emit('pieceDragMove', { uid: st.piece.uid, piece: st.piece, x: e.x, y: e.y, hx: pt.x, hy: pt.y, clientX: e.clientX, clientY: e.clientY, target: slot, legal });
|
||||
}
|
||||
|
||||
function endDrag(e, cancelled) {
|
||||
@@ -155,8 +175,9 @@ export function createDragController(hooks) {
|
||||
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 });
|
||||
const pt = dropAt(e, s.piece, true);
|
||||
const tile = tileAt(pt.x, pt.y);
|
||||
const d = resolveDrop({ piece: s.piece, from: s.from, tile, overCanvas: dropOverCanvas(e, pt), 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 });
|
||||
@@ -205,7 +226,7 @@ export function createDragController(hooks) {
|
||||
pointerMove(e) {
|
||||
if (!st) {
|
||||
if (e.pointerType === 'touch') return;
|
||||
setHoverPiece(pieceAt(e.x, e.y), e);
|
||||
setHoverPiece(pieceAt(e.x, e.y, true), e);
|
||||
setHoverTile(tileAt(e.x, e.y));
|
||||
return;
|
||||
}
|
||||
|
||||
+821
-152
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,224 @@
|
||||
// render/pick.js — which unit the pointer is on (user playtest #3 item 7: "clicking a unit on the map often selects
|
||||
// the one a row below it; dropping equipment too"). Pure screen-space geometry (no PIXI, no DOM), the ONE rule behind
|
||||
// every picking path: prep press / drag start / right-click detail and equipment dropped on an operator
|
||||
// (render/app.js pieceAt / pieceUnder, and ui/gameLogic.js pickPieceAt over view.pieceScreenRect shapes), battle
|
||||
// clicks and hover (battleUnitAt) and the enemy preview pen (penUnitAt).
|
||||
//
|
||||
// Why "the front-most rectangle under the pointer" failed: a chibi is an upright billboard ~1.27 tiles tall (median
|
||||
// of the 138 official operators' Front / Back skeletons in their idle pose, p10–p90 1.17–1.41) standing on its tile,
|
||||
// while the rows of the official 30° cameras are only ~0.84 (prep) – 0.91 (battle) of a tile apart on screen. The unit
|
||||
// in front covers the legs / feet of the unit behind it and its head sits over that unit's tile; its 0.7-tile-wide
|
||||
// rectangle also covered the ground and the legs showing beside its head (a head is only ~0.5 tile wide), and a
|
||||
// pressed tile was only looked at when no rectangle at all was hit — so a press on the lower part of a unit, on its
|
||||
// feet or on its tile went to the unit in front of it: the one a row lower on screen.
|
||||
//
|
||||
// Body of a unit (all px on one screen; `x`, `y` = its feet, `s` = px per tile at the feet, `h` = model height in
|
||||
// tiles, `w` = width factor (1 for operators; enemies scale with their size), `flip` = +1 facing right / −1 left). The
|
||||
// measured opaque profile of the idle chibis is ~±0.3 tile wide through the body (the back side a little wider: hair,
|
||||
// capes) and ~±0.2 at the crown; a taller model is taller hair / ears / hats — its face stays ~0.86 tile up:
|
||||
// outline head ellipse (centre h − 0.33·w above the feet, radii 0.27·w × 0.33·w) ∪ body box (0.08 below the feet …
|
||||
// the shoulders at min(0.62·h, 0.78·w); −0.34·w … +0.28·w facing right) ∪ the unit's HUD (tier chip /
|
||||
// bars) — what is drawn
|
||||
// core face (ellipse 0.21·w × 0.15·w centred min(0.86·w, h − 0.36·w) above the feet) ∪ torso / legs (±0.2·w, from
|
||||
// 0.1 above the feet to the shoulders) — what a player aims at
|
||||
// 'diamond' (the avatar diamond shown until the Spine model is there): the rhombus of side `d` tiles above the feet
|
||||
// 'plate' (hand items): a square of half-size `r` tiles centred on (x, y)
|
||||
// Rule (pickBody):
|
||||
// 1. the pointer is on units' cores → the front-most of them (drawn on top: the face of the unit in front is its own;
|
||||
// the torso of the unit behind is its own above that face)
|
||||
// 2. else a unit stands on the tile under the pointer → that unit (its feet, its tile — also where the hair or a
|
||||
// weapon of the unit in front hangs over it)
|
||||
// 3. else the pointer is on outlines only (hair, hands, feet, weapon edges) → the unit whose core is nearest (px / its
|
||||
// own scale); ties → the front-most
|
||||
// The feet of a unit hidden behind the face of the unit in front are not pickable (nothing of them shows there); its
|
||||
// tile beside that face and everything of it above that face are.
|
||||
//
|
||||
// Pixel probe (optional `probe(body)` → true / false / null, render/app.js drawnAt): the shapes above are a model of
|
||||
// the drawn chibi, and where the bodies of two units overlap the model cannot tell whose arm, hair or weapon is drawn
|
||||
// on top. Measured on the mock match against the rendered pixels (1920×1080, every opaque unit pixel sampled, the
|
||||
// front-most drawn unit = the truth): the rule alone put 7 % of the prep presses (18 % in battle) on the wrong unit —
|
||||
// mostly the unit BEHIND: the hair crown and the sides (arms, clothes, weapons) of the unit in front lie over the legs /
|
||||
// tile of the one behind — against 2 % / 11 % for the old front-most rectangle; with the probe 0.7 % / 1.7 % (the rest:
|
||||
// anti-aliased edges). So wherever the probe can change the answer (two or more bodies within their outline +
|
||||
// PROBE_MARGIN of the pointer, or one the rule did not pick), the front-most body whose model really draws the pixel
|
||||
// under the pointer wins — what the player sees. Only when none of them draws there (a gap, the bare tile) or the probe
|
||||
// cannot tell (null) does the rule above decide.
|
||||
|
||||
/** Median model height of the official chibis (tiles above the feet), when the model's own is not known. */
|
||||
export const BODY_H = 1.27;
|
||||
/** Shape constants in tiles (see the header). */
|
||||
export const BODY = Object.freeze({
|
||||
headRx: 0.27, headRy: 0.33, faceRx: 0.21, faceRy: 0.15, face: 0.86, faceTop: 0.36, back: 0.34, front: 0.28,
|
||||
below: 0.08, shoulder: 0.62, neck: 0.78, torso: 0.2, torsoFrom: 0.1, lean: 0.03,
|
||||
});
|
||||
|
||||
const clamp = (v, lo, hi) => (v < lo ? lo : v > hi ? hi : v);
|
||||
const inRect = (r, px, py) => !!r && px >= r.x0 && px <= r.x1 && py >= r.y0 && py <= r.y1;
|
||||
const inEllipse = (e, px, py) => ((px - e.cx) / e.rx) ** 2 + ((py - e.cy) / e.ry) ** 2 <= 1;
|
||||
const rectDist = (r, px, py) => Math.hypot(Math.max(r.x0 - px, 0, px - r.x1), Math.max(r.y0 - py, 0, py - r.y1));
|
||||
function ellipseDist(e, px, py) {
|
||||
const k = Math.hypot((px - e.cx) / e.rx, (py - e.cy) / e.ry);
|
||||
return k > 1 ? (k - 1) * Math.min(e.rx, e.ry) : 0;
|
||||
}
|
||||
/** Rhombus |dx|/a + |dy|/a ≤ k around (cx, cy). */
|
||||
const inRhombus = (d, px, py, k = 1) => Math.abs(px - d.cx) / d.a + Math.abs(py - d.cy) / d.a <= k;
|
||||
|
||||
/**
|
||||
* @typedef {{ x: number, y: number, s: number, h?: number, w?: number, flip?: number, kind?: 'chibi'|'diamond'|'plate',
|
||||
* d?: number, r?: number, depth?: number, hud?: { x0: number, y0: number, x1: number, y1: number } | null,
|
||||
* tile?: { row: number, col: number } | null }} PickBody
|
||||
* `depth`: draw order (larger = nearer the camera, drawn on top); `tile`: the tile it stands on (null: moving units)
|
||||
*/
|
||||
|
||||
/**
|
||||
* Screen geometry of a body: `{ outline: [parts], core: [parts] }`, parts `{ rect }` / `{ ellipse }` / `{ rhombus }`.
|
||||
* @param {PickBody} b
|
||||
*/
|
||||
export function bodyParts(b) {
|
||||
const s = b.s, x = b.x, y = b.y;
|
||||
const hud = b.hud && [b.hud.x0, b.hud.y0, b.hud.x1, b.hud.y1].every(Number.isFinite) ? { rect: b.hud } : null;
|
||||
if (b.kind === 'plate') {
|
||||
const a = (Number(b.r) > 0 ? b.r : 0.31) * s;
|
||||
return { outline: [{ rect: { x0: x - a, x1: x + a, y0: y - a, y1: y + a } }], core: [{ rect: { x0: x - a * 0.7, x1: x + a * 0.7, y0: y - a * 0.7, y1: y + a * 0.7 } }] };
|
||||
}
|
||||
if (b.kind === 'diamond') {
|
||||
const half = ((Number(b.d) > 0 ? b.d : 0.78) / 2) * s;
|
||||
const rh = { cx: x, cy: y - BODY.below * s - half, a: half };
|
||||
return { outline: [{ rhombus: rh, k: 1 }, ...(hud ? [hud] : [])], core: [{ rhombus: rh, k: 0.7 }] };
|
||||
}
|
||||
const w = Number(b.w) > 0 ? b.w : 1;
|
||||
const h = clamp(Number(b.h) > 0 ? b.h : BODY_H, 0.3, 4);
|
||||
const flip = b.flip === -1 ? -1 : 1;
|
||||
const cx = x - BODY.lean * flip * w * s;
|
||||
const head = { cx, cy: y - (h - BODY.headRy * w) * s, rx: BODY.headRx * w * s, ry: BODY.headRy * w * s };
|
||||
const face = { cx, cy: y - Math.min(BODY.face * w, h - BODY.faceTop * w) * s, rx: BODY.faceRx * w * s, ry: BODY.faceRy * w * s };
|
||||
const top = y - Math.min(BODY.shoulder * h, BODY.neck * w) * s;
|
||||
const box = { x0: x - (flip > 0 ? BODY.back : BODY.front) * w * s, x1: x + (flip > 0 ? BODY.front : BODY.back) * w * s, y0: top, y1: y + BODY.below * s };
|
||||
const torso = { x0: cx - BODY.torso * w * s, x1: cx + BODY.torso * w * s, y0: top, y1: y - BODY.torsoFrom * Math.min(1, h) * s };
|
||||
// the HUD (tier chip / bars) is drawn above every unit, but over the unit behind: an outline, never a core
|
||||
return { outline: [{ ellipse: head }, { rect: box }, ...(hud ? [hud] : [])], core: [{ ellipse: face }, { rect: torso }] };
|
||||
}
|
||||
|
||||
const partHit = (p, px, py) => (p.rect ? inRect(p.rect, px, py) : p.ellipse ? inEllipse(p.ellipse, px, py) : inRhombus(p.rhombus, px, py, p.k));
|
||||
function partDist(p, px, py) {
|
||||
if (p.rect) return rectDist(p.rect, px, py);
|
||||
if (p.ellipse) return ellipseDist(p.ellipse, px, py);
|
||||
const r = p.rhombus, k = (Math.abs(px - r.cx) + Math.abs(py - r.cy)) / r.a;
|
||||
return k > p.k ? (k - p.k) * r.a * Math.SQRT1_2 : 0;
|
||||
}
|
||||
|
||||
/** 2 = on the body's core, 1 = on its outline only, 0 = off it. */
|
||||
export function hitBody(b, px, py) {
|
||||
if (!b || !(b.s > 0) || !Number.isFinite(b.x) || !Number.isFinite(b.y)) return 0;
|
||||
// cheap reject (battle hover tests every unit on every pointer move): far outside the body and its HUD
|
||||
if (b.kind !== 'plate' && !(b.hud && inRect(b.hud, px, py))) {
|
||||
const w = Number(b.w) > 0 ? b.w : 1, reach = Math.max(BODY.back * w, (Number(b.d) || 0) / 2) * b.s + 1;
|
||||
const top = b.y - (b.kind === 'diamond' ? (Number(b.d) || 0.78) + BODY.below : clamp(Number(b.h) > 0 ? b.h : BODY_H, 0.3, 4)) * b.s - 1;
|
||||
if (Math.abs(px - b.x) > reach || py < top || py > b.y + BODY.below * b.s + 1) return 0;
|
||||
}
|
||||
const parts = bodyParts(b);
|
||||
if (parts.core.some((p) => partHit(p, px, py))) return 2;
|
||||
return parts.outline.some((p) => partHit(p, px, py)) ? 1 : 0;
|
||||
}
|
||||
|
||||
/** Distance (px) from a point to the body's core, divided by the body's scale (0 on the core). */
|
||||
export function coreDistance(b, px, py) {
|
||||
const parts = bodyParts(b);
|
||||
let d = Infinity;
|
||||
for (const p of parts.core) d = Math.min(d, partDist(p, px, py));
|
||||
return d / b.s;
|
||||
}
|
||||
|
||||
/** The screen bounding box of a body's outline (px): `{ x, y, width, height }`. */
|
||||
export function bodyBounds(b) {
|
||||
const parts = bodyParts(b);
|
||||
let x0 = Infinity, y0 = Infinity, x1 = -Infinity, y1 = -Infinity;
|
||||
for (const p of [...parts.outline, ...parts.core]) {
|
||||
if (p.rect) { x0 = Math.min(x0, p.rect.x0); x1 = Math.max(x1, p.rect.x1); y0 = Math.min(y0, p.rect.y0); y1 = Math.max(y1, p.rect.y1); }
|
||||
else if (p.ellipse) { const e = p.ellipse; x0 = Math.min(x0, e.cx - e.rx); x1 = Math.max(x1, e.cx + e.rx); y0 = Math.min(y0, e.cy - e.ry); y1 = Math.max(y1, e.cy + e.ry); }
|
||||
else { const r = p.rhombus, a = r.a * p.k; x0 = Math.min(x0, r.cx - a); x1 = Math.max(x1, r.cx + a); y0 = Math.min(y0, r.cy - a); y1 = Math.max(y1, r.cy + a); }
|
||||
}
|
||||
if (!Number.isFinite(x0)) return { x: b.x, y: b.y, width: 0, height: 0 };
|
||||
return { x: x0, y: y0, width: x1 - x0, height: y1 - y0 };
|
||||
}
|
||||
|
||||
const depthOf = (b) => (Number.isFinite(b.depth) ? b.depth : 0);
|
||||
|
||||
/** Margin (tiles) around a body's outline in which the pixel probe still looks (weapons, capes, hair, big enemies past
|
||||
* the modelled outline; 0.25 left 4 % of the battle presses wrong, 0.5 1.7 %). */
|
||||
export const PROBE_MARGIN = 0.5;
|
||||
/** At most this many overlapping bodies are probed (front-most first). */
|
||||
const PROBE_MAX = 6;
|
||||
|
||||
/** Bodies whose outline (+ PROBE_MARGIN) contains the point, front-most first. */
|
||||
export function probeCandidates(bodies, px, py) {
|
||||
const out = [];
|
||||
for (const b of bodies) {
|
||||
if (!b || !(b.s > 0) || !Number.isFinite(b.x) || !Number.isFinite(b.y)) continue;
|
||||
const r = bodyBounds({ ...b, hud: null }), m = PROBE_MARGIN * b.s;
|
||||
if (px >= r.x - m && px <= r.x + r.width + m && py >= r.y - m && py <= r.y + r.height + m) out.push(b);
|
||||
}
|
||||
return out.sort((a, b) => depthOf(b) - depthOf(a));
|
||||
}
|
||||
|
||||
/**
|
||||
* Pixel probe step of pickBody: the front-most body around the point whose model draws the pixel under it. Asked only
|
||||
* when it can change the answer: two or more bodies there, or one that the shape rule did not pick (`rule`: e.g. its
|
||||
* arm over the tile of a unit standing behind it). null when none of them draws there or the probe cannot tell for one
|
||||
* in front of the first that does (then the shape rule decides).
|
||||
*/
|
||||
function probeBodies(bodies, px, py, probe, rule) {
|
||||
const cand = probeCandidates(bodies, px, py);
|
||||
if (!cand.length || (cand.length === 1 && cand[0] === rule)) return null;
|
||||
for (const b of cand.slice(0, PROBE_MAX)) {
|
||||
let d = null;
|
||||
try { d = probe(b); } catch { d = null; }
|
||||
if (d === true) return b;
|
||||
if (d !== false) return null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
const onTile = (b, t) => !!t && !!b.tile && b.tile.row === t.row && b.tile.col === t.col;
|
||||
function nearest(list, px, py) {
|
||||
let best = null, bd = Infinity;
|
||||
for (const b of list) {
|
||||
const d = coreDistance(b, px, py);
|
||||
if (d < bd - 1e-9 || (Math.abs(d - bd) <= 1e-9 && best && depthOf(b) > depthOf(best))) { best = b; bd = d; }
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
/**
|
||||
* The body the pointer is on (see the header for the rule), or null.
|
||||
* @param {Array<PickBody|null>} bodies
|
||||
* @param {number} px
|
||||
* @param {number} py
|
||||
* @param {{ row: number, col: number } | null} [ground] the tile under the pointer (same space as the bodies' `tile`)
|
||||
* @param {((body: PickBody) => (boolean|null)) | null} [probe] does this body's model draw the pixel under the pointer
|
||||
* (null: cannot tell)? See the header.
|
||||
* @returns {PickBody|null} one of `bodies`
|
||||
*/
|
||||
export function pickBody(bodies, px, py, ground = null, probe = null) {
|
||||
if (!Number.isFinite(px) || !Number.isFinite(py) || !Array.isArray(bodies)) return null;
|
||||
const rule = ruleBody(bodies, px, py, ground);
|
||||
if (typeof probe === 'function') {
|
||||
const drawn = probeBodies(bodies, px, py, probe, rule);
|
||||
if (drawn) return drawn;
|
||||
}
|
||||
return rule;
|
||||
}
|
||||
|
||||
/** The shape rule of pickBody (the header's steps 1–3). */
|
||||
function ruleBody(bodies, px, py, ground) {
|
||||
let core = null;
|
||||
const rims = [];
|
||||
for (const b of bodies) {
|
||||
const hit = hitBody(b, px, py);
|
||||
if (hit === 2) { if (!core || depthOf(b) > depthOf(core)) core = b; }
|
||||
else if (hit === 1) rims.push(b);
|
||||
}
|
||||
if (core) return core;
|
||||
const standing = ground ? bodies.filter((b) => b && b.s > 0 && onTile(b, ground)) : [];
|
||||
if (standing.length) return nearest(standing, px, py);
|
||||
return rims.length ? nearest(rims, px, py) : null;
|
||||
}
|
||||
@@ -88,15 +88,29 @@ export function dmgStyleKey(type) {
|
||||
/** 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). */
|
||||
/**
|
||||
* Projectile visuals per b.ev 'atk' projKind (sim: none | arrow | bolt | bomb | lob | orb | drone | enemy | boomerang;
|
||||
* chain / chainHeal / beam are beams). Drawn by render/fx.js:
|
||||
* look tracer (bullet streak + muzzle flash) | orb (glowing ball, particle trail) | shell (lobbed on an arc with a
|
||||
* ground shadow, smoke trail) | dart (small fast bolt) | boomerang (spins to the target and back to the thrower)
|
||||
* speed tiles per game second — a copy of the sim's PROJECTILE_SPEEDS (server/sim/constants.js; `back` of the
|
||||
* boomerang = BOOMERANG_RETURN_SPEED, PRTS 跃跃: out 15, back 3.75). fx.js prefers the sim's own values, loaded
|
||||
* from /sim/constants.js, so the visual flight ends with the sim's hit; this copy stands in until then (and in
|
||||
* Node). test/render/fxproj.test.js keeps the two equal.
|
||||
* sizes in tiles (× the camera's px per tile at the shot): `len` trail length, `width` trail thickness, `head` glow
|
||||
* diameter; `arc` peak height of a lob over a 3-tile throw (scales with the range)
|
||||
* colours `tint` hot core, `glow` halo, `trail` trail particles, `muzzle` flash at the shooter
|
||||
* hit arrival burst (fx.js _impact): spark | arts | heal | boom | splash | zap | enemy
|
||||
*/
|
||||
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 },
|
||||
arrow: { look: 'tracer', speed: 14, tint: 0xfff6dc, glow: 0xffc45a, len: 1.05, width: 0.2, head: 0.36, muzzle: 0xffd27a, hit: 'spark' },
|
||||
bolt: { look: 'orb', speed: 11, tint: 0xf4e2ff, glow: 0xb36bff, len: 0.6, width: 0.34, head: 0.52, trail: 0xa35cff, muzzle: 0xc77dff, hit: 'arts' },
|
||||
orb: { look: 'orb', speed: 10, tint: 0xeafff0, glow: 0x3fe07a, len: 0.55, width: 0.3, head: 0.48, trail: 0x62f08a, muzzle: 0x62f08a, hit: 'heal' },
|
||||
bomb: { look: 'shell', speed: 8, tint: 0xffeed0, glow: 0xff8a3d, len: 0.85, width: 0.3, head: 0.56, trail: 0xff9c4a, arc: 1.1, smoke: 0x2e2824, muzzle: 0xffb35c, hit: 'boom' },
|
||||
lob: { look: 'shell', speed: 8, tint: 0xfff4dc, glow: 0xffb04a, len: 0.7, width: 0.26, head: 0.5, trail: 0xffc27a, arc: 1.4, smoke: 0x3a322c, muzzle: 0xffc27a, hit: 'splash' },
|
||||
drone: { look: 'dart', speed: 16, tint: 0xe4fbff, glow: 0x57c9ff, len: 0.7, width: 0.16, head: 0.3, trail: 0x57c9ff, hit: 'zap' },
|
||||
enemy: { look: 'orb', speed: 10, tint: 0xffe2da, glow: 0xff3b30, len: 0.55, width: 0.28, head: 0.44, trail: 0xff4a3a, muzzle: 0xff6a5a, hit: 'enemy' },
|
||||
boomerang: { look: 'boomerang', speed: 15, back: 3.75, tint: 0xfff4d6, glow: 0x9ff0dc, len: 0.4, width: 0.3, head: 0.5, trail: 0x9ff0dc, hit: 'spark' },
|
||||
});
|
||||
|
||||
/** Status keys (b.ev 'status' + UF flags) → icon atlas key (render/textures.js) and colour. */
|
||||
|
||||
+160
-50
@@ -600,11 +600,21 @@ export function groundTexture(img = null, crop = null) {
|
||||
// =============================================================================================================
|
||||
// FX atlas
|
||||
|
||||
const FX_SIZE = 512;
|
||||
const FX_W = 1024, FX_H = 512;
|
||||
/** Status icons: one 32 px row at the bottom of the FX atlas (frames `st_<key>`). */
|
||||
const STATUS_ROW_Y = 448;
|
||||
let _fx = null;
|
||||
|
||||
/**
|
||||
* FX atlas frames — name: [x, y, w, h, draw(ctx, x, y, w, h)]. Every frame owns its cell: no two frames share a pixel
|
||||
* (the light pillar once ran into the status-icon row, so every skill / deploy pillar carried the 'silence' and 'slow'
|
||||
* icons at its foot) and each drawing stays off its cell's outer pixel (mipmapped sampling of a small sprite must not
|
||||
* pull in a neighbour). Frame sizes are part of the API — sprites are scaled by them: glow / soft / ring / hex / shock /
|
||||
* reticle / smoke / flare 128², spark / plus / orb / chevron / boomerang / muzzle 64², coin 48², dot / shard / square
|
||||
* 32², streak / tracer / bolt 128×32 (head at the right), slash 128×64 (arc bulging up), pillar 64×256 (foot at the
|
||||
* bottom).
|
||||
*/
|
||||
const FX_DRAW = {
|
||||
// name: [x, y, w, h, draw(ctx, x, y, w, h)]
|
||||
glow: [0, 0, 128, 128, (c, x, y, w) => { radial(c, x + w / 2, y + w / 2, w / 2, [[0, 'rgba(255,255,255,1)'], [0.25, 'rgba(255,255,255,0.6)'], [1, 'rgba(255,255,255,0)']]); }],
|
||||
soft: [128, 0, 128, 128, (c, x, y, w) => { radial(c, x + w / 2, y + w / 2, w / 2, [[0, 'rgba(255,255,255,0.8)'], [1, 'rgba(255,255,255,0)']]); }],
|
||||
ring: [256, 0, 128, 128, (c, x, y, w) => {
|
||||
@@ -625,68 +635,154 @@ const FX_DRAW = {
|
||||
c.stroke(); c.globalAlpha = 1;
|
||||
for (let k = 0; k < 6; k++) { const a = (k * Math.PI) / 3; c.fillStyle = '#fff'; c.fillRect(cx + Math.cos(a) * 60 - 3, cy + Math.sin(a) * 60 - 3, 6, 6); }
|
||||
}],
|
||||
spark: [0, 128, 64, 64, (c, x, y, w) => {
|
||||
// a thin sharp ring with a faint wake inside: shockwaves (skill bursts, explosions, the bombard warning)
|
||||
shock: [512, 0, 128, 128, (c, x, y, w) => {
|
||||
radial(c, x + w / 2, y + w / 2, w / 2 - 1, [[0, 'rgba(255,255,255,0)'], [0.52, 'rgba(255,255,255,0)'], [0.8, 'rgba(255,255,255,0.16)'],
|
||||
[0.9, 'rgba(255,255,255,1)'], [0.96, 'rgba(255,255,255,0.3)'], [1, 'rgba(255,255,255,0)']]);
|
||||
}],
|
||||
// lock-on reticle: circle, cross ticks, corner brackets, centre dot (蕾缪安 locks, marks)
|
||||
reticle: [640, 0, 128, 128, (c, x, y, w) => {
|
||||
const cx = x + w / 2, cy = y + w / 2;
|
||||
c.strokeStyle = '#fff'; c.lineCap = 'round';
|
||||
c.lineWidth = 5; c.beginPath(); c.arc(cx, cy, 36, 0, Math.PI * 2); c.stroke();
|
||||
c.lineWidth = 2; c.globalAlpha = 0.55; c.beginPath(); c.arc(cx, cy, 27, 0, Math.PI * 2); c.stroke(); c.globalAlpha = 1;
|
||||
c.lineWidth = 5;
|
||||
for (let k = 0; k < 4; k++) {
|
||||
const a = (k * Math.PI) / 2, ca = Math.cos(a), sa = Math.sin(a);
|
||||
c.beginPath(); c.moveTo(cx + ca * 22, cy + sa * 22); c.lineTo(cx + ca * 50, cy + sa * 50); c.stroke();
|
||||
}
|
||||
c.lineWidth = 4;
|
||||
for (let k = 0; k < 4; k++) {
|
||||
const sx = k & 1 ? 1 : -1, sy = k & 2 ? 1 : -1, bx = cx + sx * 52, by = cy + sy * 52;
|
||||
c.beginPath(); c.moveTo(bx - sx * 14, by); c.lineTo(bx, by); c.lineTo(bx, by - sy * 14); c.stroke();
|
||||
}
|
||||
c.fillStyle = '#fff'; c.beginPath(); c.arc(cx, cy, 4, 0, Math.PI * 2); c.fill();
|
||||
}],
|
||||
smoke: [768, 0, 128, 128, (c, x, y) => {
|
||||
const r = rng(77);
|
||||
for (let i = 0; i < 14; i++) radial(c, x + 30 + r() * 68, y + 30 + r() * 68, 18 + r() * 22, [[0, 'rgba(255,255,255,0.22)'], [1, 'rgba(255,255,255,0)']]);
|
||||
}],
|
||||
// star flare: hot centre, four long and four short rays (skill flash, explosion core, impacts)
|
||||
flare: [896, 0, 128, 128, (c, x, y, w) => {
|
||||
const cx = x + w / 2, cy = y + w / 2;
|
||||
radial(c, cx, cy, w / 2 - 1, [[0, 'rgba(255,255,255,0.95)'], [0.12, 'rgba(255,255,255,0.55)'], [0.4, 'rgba(255,255,255,0.1)'], [1, 'rgba(255,255,255,0)']]);
|
||||
const ray = (a, len, half) => {
|
||||
c.save(); c.translate(cx, cy); c.rotate(a);
|
||||
const g = c.createLinearGradient(0, 0, len, 0);
|
||||
g.addColorStop(0, 'rgba(255,255,255,1)'); g.addColorStop(1, 'rgba(255,255,255,0)');
|
||||
c.fillStyle = g;
|
||||
c.beginPath(); c.moveTo(0, -half); c.lineTo(len, 0); c.lineTo(0, half); c.closePath(); c.fill();
|
||||
c.restore();
|
||||
};
|
||||
for (let k = 0; k < 4; k++) ray((k * Math.PI) / 2, 60, 5);
|
||||
for (let k = 0; k < 4; k++) ray(Math.PI / 4 + (k * Math.PI) / 2, 30, 3);
|
||||
}],
|
||||
pillar: [0, 128, 64, 256, (c, x, y, w, h) => {
|
||||
const g = c.createLinearGradient(x, 0, x + w, 0);
|
||||
g.addColorStop(0, 'rgba(255,255,255,0)'); g.addColorStop(0.5, 'rgba(255,255,255,1)'); g.addColorStop(1, 'rgba(255,255,255,0)');
|
||||
c.fillStyle = g; c.fillRect(x, y + 1, w, h - 2);
|
||||
const v = c.createLinearGradient(0, y, 0, y + h);
|
||||
v.addColorStop(0, 'rgba(0,0,0,1)'); v.addColorStop(0.35, 'rgba(0,0,0,0)'); v.addColorStop(1, 'rgba(0,0,0,0)');
|
||||
c.globalCompositeOperation = 'destination-out'; c.fillStyle = v; c.fillRect(x, y, w, h); c.globalCompositeOperation = 'source-over';
|
||||
}],
|
||||
spark: [64, 128, 64, 64, (c, x, y, w) => {
|
||||
const cx = x + w / 2, cy = y + w / 2;
|
||||
radial(c, cx, cy, w / 2, [[0, 'rgba(255,255,255,0.9)'], [0.2, 'rgba(255,255,255,0.35)'], [1, 'rgba(255,255,255,0)']]);
|
||||
c.fillStyle = 'rgba(255,255,255,1)';
|
||||
c.beginPath(); c.moveTo(cx, y + 2); c.lineTo(cx + 3, cy - 3); c.lineTo(x + w - 2, cy); c.lineTo(cx + 3, cy + 3); c.lineTo(cx, y + w - 2);
|
||||
c.lineTo(cx - 3, cy + 3); c.lineTo(x + 2, cy); c.lineTo(cx - 3, cy - 3); c.closePath(); c.fill();
|
||||
}],
|
||||
plus: [64, 128, 64, 64, (c, x, y, w) => {
|
||||
plus: [128, 128, 64, 64, (c, x, y, w) => {
|
||||
const cx = x + w / 2, cy = y + w / 2;
|
||||
radial(c, cx, cy, w / 2, [[0, 'rgba(255,255,255,0.5)'], [1, 'rgba(255,255,255,0)']]);
|
||||
c.fillStyle = '#fff';
|
||||
c.fillRect(cx - 5, cy - 18, 10, 36); c.fillRect(cx - 18, cy - 5, 36, 10);
|
||||
}],
|
||||
orb: [128, 128, 64, 64, (c, x, y, w) => {
|
||||
orb: [192, 128, 64, 64, (c, x, y, w) => {
|
||||
radial(c, x + w / 2, y + w / 2, w / 2, [[0, 'rgba(255,255,255,1)'], [0.3, 'rgba(255,255,255,0.85)'], [0.55, 'rgba(255,255,255,0.3)'], [1, 'rgba(255,255,255,0)']]);
|
||||
}],
|
||||
dot: [192, 128, 32, 32, (c, x, y, w) => { radial(c, x + w / 2, y + w / 2, w / 2, [[0, 'rgba(255,255,255,1)'], [0.5, 'rgba(255,255,255,0.7)'], [1, 'rgba(255,255,255,0)']]); }],
|
||||
shard: [224, 128, 32, 32, (c, x, y, w) => {
|
||||
c.fillStyle = '#fff'; c.beginPath(); c.moveTo(x + w / 2, y + 1); c.lineTo(x + w - 8, y + w / 2); c.lineTo(x + w / 2, y + w - 1); c.lineTo(x + 8, y + w / 2); c.closePath(); c.fill();
|
||||
}],
|
||||
streak: [0, 192, 128, 32, (c, x, y, w, h) => {
|
||||
const g = c.createLinearGradient(x, 0, x + w, 0);
|
||||
g.addColorStop(0, 'rgba(255,255,255,0)'); g.addColorStop(0.7, 'rgba(255,255,255,0.7)'); g.addColorStop(1, 'rgba(255,255,255,1)');
|
||||
c.fillStyle = g;
|
||||
c.beginPath(); c.moveTo(x, y + h / 2 - 1); c.lineTo(x + w - 10, y + h / 2 - 5); c.lineTo(x + w, y + h / 2); c.lineTo(x + w - 10, y + h / 2 + 5); c.lineTo(x, y + h / 2 + 1); c.closePath(); c.fill();
|
||||
}],
|
||||
slash: [128, 192, 128, 64, (c, x, y, w, h) => {
|
||||
c.save(); c.beginPath(); c.rect(x, y, w, h); c.clip();
|
||||
const g = c.createLinearGradient(x, y, x + w, y);
|
||||
g.addColorStop(0, 'rgba(255,255,255,0)'); g.addColorStop(0.5, 'rgba(255,255,255,1)'); g.addColorStop(1, 'rgba(255,255,255,0)');
|
||||
c.fillStyle = g;
|
||||
c.beginPath(); c.ellipse(x + w / 2, y + h * 1.3, w * 0.48, h * 1.05, 0, Math.PI, 0); c.ellipse(x + w / 2, y + h * 1.45, w * 0.44, h * 1.05, 0, 0, Math.PI, true); c.fill();
|
||||
c.restore();
|
||||
}],
|
||||
pillar: [256, 128, 64, 256, (c, x, y, w, h) => {
|
||||
const g = c.createLinearGradient(x, 0, x + w, 0);
|
||||
g.addColorStop(0, 'rgba(255,255,255,0)'); g.addColorStop(0.5, 'rgba(255,255,255,1)'); g.addColorStop(1, 'rgba(255,255,255,0)');
|
||||
c.fillStyle = g; c.fillRect(x, y, w, h);
|
||||
const v = c.createLinearGradient(0, y, 0, y + h);
|
||||
v.addColorStop(0, 'rgba(0,0,0,1)'); v.addColorStop(0.35, 'rgba(0,0,0,0)'); v.addColorStop(1, 'rgba(0,0,0,0)');
|
||||
c.globalCompositeOperation = 'destination-out'; c.fillStyle = v; c.fillRect(x, y, w, h); c.globalCompositeOperation = 'source-over';
|
||||
}],
|
||||
smoke: [320, 128, 128, 128, (c, x, y, w) => {
|
||||
const r = rng(77);
|
||||
for (let i = 0; i < 14; i++) radial(c, x + 30 + r() * 68, y + 30 + r() * 68, 18 + r() * 22, [[0, 'rgba(255,255,255,0.22)'], [1, 'rgba(255,255,255,0)']]);
|
||||
}],
|
||||
chevron: [448, 128, 64, 64, (c, x, y, w) => {
|
||||
chevron: [256, 128, 64, 64, (c, x, y) => {
|
||||
c.fillStyle = '#fff';
|
||||
c.beginPath(); c.moveTo(x + 14, y + 8); c.lineTo(x + 30, y + 8); c.lineTo(x + 52, y + 32); c.lineTo(x + 30, y + 56); c.lineTo(x + 14, y + 56); c.lineTo(x + 36, y + 32); c.closePath(); c.fill();
|
||||
}],
|
||||
bolt: [0, 256, 128, 32, (c, x, y, w, h) => {
|
||||
const g = c.createLinearGradient(0, y, 0, y + h);
|
||||
g.addColorStop(0, 'rgba(255,255,255,0)'); g.addColorStop(0.5, 'rgba(255,255,255,1)'); g.addColorStop(1, 'rgba(255,255,255,0)');
|
||||
c.fillStyle = g; c.fillRect(x, y, w, h);
|
||||
// 回环射手 boomerang: two curved arms meeting at an elbow, centred on the cell (it spins about the centre)
|
||||
boomerang: [320, 128, 64, 64, (c, x, y, w) => {
|
||||
c.save(); c.translate(x + w / 2, y + w / 2 + 3);
|
||||
c.beginPath();
|
||||
c.moveTo(0, -21);
|
||||
c.quadraticCurveTo(19, -17, 27, 14); c.quadraticCurveTo(21, 18, 14, 11); c.quadraticCurveTo(8, -5, 0, -8);
|
||||
c.quadraticCurveTo(-8, -5, -14, 11); c.quadraticCurveTo(-21, 18, -27, 14); c.quadraticCurveTo(-19, -17, 0, -21);
|
||||
c.closePath();
|
||||
c.fillStyle = 'rgba(255,255,255,0.9)'; c.fill();
|
||||
c.lineWidth = 2.5; c.lineJoin = 'round'; c.strokeStyle = '#fff'; c.stroke();
|
||||
c.restore();
|
||||
}],
|
||||
square: [128, 256, 32, 32, (c, x, y, w) => { c.fillStyle = '#fff'; c.fillRect(x + 1, y + 1, w - 2, w - 2); }],
|
||||
coin: [160, 256, 48, 48, (c, x, y, w) => {
|
||||
// muzzle flash pointing right (+x) from a hot spot at (12, 32): sprite anchor (0.19, 0.5), rotated to the shot
|
||||
muzzle: [384, 128, 64, 64, (c, x, y, w, h) => {
|
||||
const cx = x + 12, cy = y + h / 2;
|
||||
radial(c, cx, cy, 11, [[0, 'rgba(255,255,255,1)'], [0.5, 'rgba(255,255,255,0.6)'], [1, 'rgba(255,255,255,0)']]);
|
||||
const g = c.createLinearGradient(cx, 0, x + w - 2, 0);
|
||||
g.addColorStop(0, 'rgba(255,255,255,0.95)'); g.addColorStop(1, 'rgba(255,255,255,0)');
|
||||
c.fillStyle = g;
|
||||
c.beginPath(); c.moveTo(cx, cy - 7); c.quadraticCurveTo(cx + 26, cy - 12, x + w - 2, cy); c.quadraticCurveTo(cx + 26, cy + 12, cx, cy + 7); c.closePath(); c.fill();
|
||||
c.beginPath(); c.moveTo(cx + 2, cy - 3); c.lineTo(cx + 18, cy - 19); c.lineTo(cx + 9, cy); c.closePath(); c.fill();
|
||||
c.beginPath(); c.moveTo(cx + 2, cy + 3); c.lineTo(cx + 18, cy + 19); c.lineTo(cx + 9, cy); c.closePath(); c.fill();
|
||||
}],
|
||||
coin: [448, 128, 48, 48, (c, x, y, w) => {
|
||||
const cx = x + w / 2, cy = y + w / 2;
|
||||
c.fillStyle = '#ffc600'; c.beginPath(); c.arc(cx, cy, 20, 0, Math.PI * 2); c.fill();
|
||||
c.fillStyle = '#fff2a8'; c.beginPath(); c.arc(cx - 4, cy - 4, 11, 0, Math.PI * 2); c.fill();
|
||||
c.fillStyle = '#c98f00'; c.font = 'bold 22px sans-serif'; c.textAlign = 'center'; c.textBaseline = 'middle'; c.fillText('$', cx, cy + 1);
|
||||
}],
|
||||
dot: [512, 128, 32, 32, (c, x, y, w) => { radial(c, x + w / 2, y + w / 2, w / 2, [[0, 'rgba(255,255,255,1)'], [0.5, 'rgba(255,255,255,0.7)'], [1, 'rgba(255,255,255,0)']]); }],
|
||||
shard: [544, 128, 32, 32, (c, x, y, w) => {
|
||||
c.fillStyle = '#fff'; c.beginPath(); c.moveTo(x + w / 2, y + 1); c.lineTo(x + w - 8, y + w / 2); c.lineTo(x + w / 2, y + w - 1); c.lineTo(x + 8, y + w / 2); c.closePath(); c.fill();
|
||||
}],
|
||||
square: [576, 128, 32, 32, (c, x, y, w) => { c.fillStyle = '#fff'; c.fillRect(x + 1, y + 1, w - 2, w - 2); }],
|
||||
// melee swing: a thick crescent (arc bulging up), hot in the middle, tips fading, a bright rim
|
||||
slash: [640, 128, 128, 64, (c, x, y, w, h) => {
|
||||
const cx = x + w / 2;
|
||||
const g = c.createLinearGradient(x, 0, x + w, 0);
|
||||
g.addColorStop(0, 'rgba(255,255,255,0)'); g.addColorStop(0.28, 'rgba(255,255,255,0.8)'); g.addColorStop(0.55, 'rgba(255,255,255,1)');
|
||||
g.addColorStop(0.82, 'rgba(255,255,255,0.7)'); g.addColorStop(1, 'rgba(255,255,255,0)');
|
||||
c.save(); c.beginPath(); c.rect(x + 1, y + 1, w - 2, h - 2); c.clip();
|
||||
c.beginPath();
|
||||
c.ellipse(cx, y + h * 1.2, w * 0.47, h, 0, Math.PI, 0);
|
||||
c.ellipse(cx, y + h * 1.42, w * 0.43, h, 0, 0, Math.PI, true);
|
||||
c.closePath();
|
||||
c.globalAlpha = 0.85; c.fillStyle = g; c.fill(); c.globalAlpha = 1;
|
||||
c.lineWidth = 3; c.strokeStyle = g;
|
||||
c.beginPath(); c.ellipse(cx, y + h * 1.2, w * 0.47, h, 0, Math.PI * 1.06, Math.PI * 1.94); c.stroke();
|
||||
c.restore();
|
||||
}],
|
||||
// soft trail (orbs, shells, dashes): tapered band with a soft cross-section, transparent tail at the left
|
||||
streak: [768, 128, 128, 32, (c, x, y, w, h) => {
|
||||
const cy = y + h / 2;
|
||||
const v = c.createLinearGradient(0, cy - 10, 0, cy + 10);
|
||||
v.addColorStop(0, 'rgba(255,255,255,0)'); v.addColorStop(0.5, 'rgba(255,255,255,0.95)'); v.addColorStop(1, 'rgba(255,255,255,0)');
|
||||
c.fillStyle = v;
|
||||
c.beginPath(); c.moveTo(x + 1, cy - 2); c.lineTo(x + w - 14, cy - 10); c.quadraticCurveTo(x + w - 1, cy, x + w - 14, cy + 10); c.lineTo(x + 1, cy + 2); c.closePath(); c.fill();
|
||||
c.fillStyle = '#fff';
|
||||
c.beginPath(); c.moveTo(x + 1, cy - 0.5); c.lineTo(x + w - 10, cy - 2.5); c.lineTo(x + w - 4, cy); c.lineTo(x + w - 10, cy + 2.5); c.lineTo(x + 1, cy + 0.5); c.closePath(); c.fill();
|
||||
fadeTail(c, x, y, w, h, 0.6);
|
||||
}],
|
||||
// bullet tracer: glow band + hot core line + bright head at the right
|
||||
tracer: [768, 160, 128, 32, (c, x, y, w, h) => {
|
||||
const cy = y + h / 2;
|
||||
const v = c.createLinearGradient(0, y + 1, 0, y + h - 1);
|
||||
v.addColorStop(0, 'rgba(255,255,255,0)'); v.addColorStop(0.32, 'rgba(255,255,255,0.22)'); v.addColorStop(0.5, 'rgba(255,255,255,0.75)');
|
||||
v.addColorStop(0.68, 'rgba(255,255,255,0.22)'); v.addColorStop(1, 'rgba(255,255,255,0)');
|
||||
c.fillStyle = v; c.fillRect(x + 1, y + 1, w - 10, h - 2);
|
||||
c.fillStyle = '#fff';
|
||||
c.beginPath(); c.moveTo(x + 1, cy - 0.8); c.lineTo(x + w - 10, cy - 2.6); c.lineTo(x + w - 10, cy + 2.6); c.lineTo(x + 1, cy + 0.8); c.closePath(); c.fill();
|
||||
fadeTail(c, x, y, w, h, 0.45);
|
||||
radial(c, x + w - 10, cy, 9, [[0, 'rgba(255,255,255,1)'], [0.45, 'rgba(255,255,255,0.75)'], [1, 'rgba(255,255,255,0)']]);
|
||||
}],
|
||||
bolt: [896, 128, 128, 32, (c, x, y, w, h) => {
|
||||
const g = c.createLinearGradient(0, y, 0, y + h);
|
||||
g.addColorStop(0, 'rgba(255,255,255,0)'); g.addColorStop(0.5, 'rgba(255,255,255,1)'); g.addColorStop(1, 'rgba(255,255,255,0)');
|
||||
c.fillStyle = g; c.fillRect(x + 1, y, w - 2, h);
|
||||
}],
|
||||
};
|
||||
|
||||
function radial(c, cx, cy, r, stops) {
|
||||
@@ -696,27 +792,41 @@ function radial(c, cx, cy, r, stops) {
|
||||
c.fillRect(cx - r, cy - r, r * 2, r * 2);
|
||||
}
|
||||
|
||||
/** Fade a horizontal frame towards its tail (left edge transparent → `mid` alpha at 55 % → opaque head at the right). */
|
||||
function fadeTail(c, x, y, w, h, mid) {
|
||||
c.globalCompositeOperation = 'destination-in';
|
||||
const g = c.createLinearGradient(x, 0, x + w - 8, 0);
|
||||
g.addColorStop(0, 'rgba(255,255,255,0)'); g.addColorStop(0.55, `rgba(255,255,255,${mid})`); g.addColorStop(1, 'rgba(255,255,255,1)');
|
||||
c.fillStyle = g; c.fillRect(x, y, w, h);
|
||||
c.globalCompositeOperation = 'source-over';
|
||||
}
|
||||
|
||||
/** The FX atlas layout (no canvas needed): `{ size: [w, h], frames: { name: [x, y, w, h] } }` incl. `st_<key>` icons. */
|
||||
export function fxFrames() {
|
||||
const frames = {};
|
||||
for (const [name, [x, y, w, h]] of Object.entries(FX_DRAW)) frames[name] = [x, y, w, h];
|
||||
STATUS_KEYS.forEach((k, i) => { frames['st_' + k] = [(i % 32) * 32, STATUS_ROW_Y + Math.floor(i / 32) * 32, 32, 32]; });
|
||||
return { size: [FX_W, FX_H], frames };
|
||||
}
|
||||
|
||||
/** FX atlas: `{ base, tex: { [name]: PIXI.Texture } }` (all frames share one base texture). */
|
||||
export function fxAtlas() {
|
||||
if (_fx) return _fx;
|
||||
const P = PIXI();
|
||||
const canvas = makeCanvas(FX_SIZE, FX_SIZE);
|
||||
const canvas = makeCanvas(FX_W, FX_H);
|
||||
const ctx = canvas.getContext('2d');
|
||||
const frames = {};
|
||||
const { frames } = fxFrames();
|
||||
for (const [name, [x, y, w, h, draw]] of Object.entries(FX_DRAW)) {
|
||||
ctx.save(); ctx.beginPath(); ctx.rect(x, y, w, h); ctx.clip();
|
||||
try { draw(ctx, x, y, w, h); } catch { /* keep blank */ }
|
||||
ctx.restore();
|
||||
frames[name] = [x, y, w, h];
|
||||
}
|
||||
// status icons row (32 px each) at y = 320
|
||||
STATUS_KEYS.forEach((k, i) => {
|
||||
const x = (i % 16) * 32, y = 320 + Math.floor(i / 16) * 32;
|
||||
for (const k of STATUS_KEYS) {
|
||||
const [x, y] = frames['st_' + k];
|
||||
ctx.save(); ctx.beginPath(); ctx.rect(x, y, 32, 32); ctx.clip();
|
||||
try { drawStatusIcon(ctx, k, x, y, 32); } catch { /* ignore */ }
|
||||
ctx.restore();
|
||||
frames['st_' + k] = [x, y, 32, 32];
|
||||
});
|
||||
}
|
||||
const base = P.BaseTexture.from(canvas, { mipmap: P.MIPMAP_MODES.ON });
|
||||
const tex = {};
|
||||
for (const [name, [x, y, w, h]] of Object.entries(frames)) tex[name] = new P.Texture(base, new P.Rectangle(x, y, w, h));
|
||||
|
||||
+67
-12
@@ -22,12 +22,18 @@
|
||||
// pen: idle loops only) animates through the impostor atlas every 3rd frame; under the view's adaptive load level
|
||||
// (`ctx.loadLevel()` 1–3, app.js) small / far units (< ~56 px per tile) animate every 2nd frame, and from level 2 on
|
||||
// every unit does.
|
||||
//
|
||||
// Picking (user playtest #3 item 7): `pickShape()` describes the body as drawn — feet, px per tile, the model's own
|
||||
// height (measured once from the posed skeleton's bounds; the median chibi until then), facing, draw order and the
|
||||
// HUD it shows (tier chip / bars) — for the shared rule of render/pick.js; `bounds()` / `hitTest()` use the same shape.
|
||||
// A dragged (lifted) item plate is drawn centred on its ground point, i.e. on the pointer (render/app.js).
|
||||
|
||||
import { UF, ANIM } from '../../../shared/constants.js';
|
||||
import { SpineActor } from './spine.js';
|
||||
import { diamondTexture, shadowTexture, fxAtlas, tierChip, statusTexture, itemTexture } from './textures.js';
|
||||
import { COLORS, TIER_COLORS, ENEMY_FRAME, UNIT, statusIconKey } from './style.js';
|
||||
import { drawCrate, rowDepthKey, ROW_KEY, deviceBoxOf, DEVICE_BOX } from './tiles.js';
|
||||
import { BODY_H, bodyBounds, hitBody } from './pick.js';
|
||||
|
||||
const clamp = (v, lo, hi) => (v < lo ? lo : v > hi ? hi : v);
|
||||
const DIRS = ['UP', 'RIGHT', 'DOWN', 'LEFT'];
|
||||
@@ -137,6 +143,9 @@ export class UnitView {
|
||||
this.shake = 0;
|
||||
this.screen = { x: 0, y: 0, s: 1, top: 0 };
|
||||
this.destroyed = false;
|
||||
this._bodyH = null; // model height (tiles above the feet), measured once per Spine model (pickShape)
|
||||
this._hudBox = null; // screen rect of the HUD shown this frame (tier chip / bars), or null
|
||||
this._flip = this.visFacing; // drawn facing (±1) of the model
|
||||
|
||||
// --- display objects
|
||||
this.shadow = new P.Sprite(ctx.shadowTex || shadowTexture());
|
||||
@@ -397,6 +406,7 @@ export class UnitView {
|
||||
this.imp = null;
|
||||
}
|
||||
this._box = null;
|
||||
this._bodyH = null; // the next model is measured again
|
||||
const old = this.actor;
|
||||
this.actor = null;
|
||||
this.spineReady = false;
|
||||
@@ -514,6 +524,7 @@ export class UnitView {
|
||||
// off-screen: nothing to animate or draw (bounds / hit-testing still follow `screen`)
|
||||
if (this._cull(bx, by, s, dt)) return;
|
||||
const flip = this.isEnemy ? (ENEMY_MODEL_FACES_LEFT ? -this.visFacing : this.visFacing) : this.visFacing;
|
||||
this._flip = flip;
|
||||
|
||||
// shadow (on a raised top it is drawn with that block row, else in the shadow layer under everything)
|
||||
placeOnGround(this.ctx, this.shadow, this.ctx.layers.shadow, this.y, this.z);
|
||||
@@ -558,6 +569,8 @@ export class UnitView {
|
||||
this.actor.update(animDt);
|
||||
if (this._tint !== tint) { this._tint = tint; this.actor.spine.tint = tint; }
|
||||
}
|
||||
// the model's own height for picking: once, from its first resting pose (not a deploy / skill clip)
|
||||
if (this._bodyH == null && this.actor.clock > 0 && this.actor.mode === 'base') this._bodyH = this._measureBody();
|
||||
}
|
||||
// the diamond is only needed while no model shows (a cross-fade keeps whatever diamond was already up)
|
||||
if (!spineShown) this._ensurePicture();
|
||||
@@ -693,6 +706,16 @@ export class UnitView {
|
||||
if (prep) this.chip.position.set(x, y - this.chip.height / 2 + 2);
|
||||
else this.chip.position.set(x0 - this.chip.width / 2 - 1, cy + (showSp ? spH / 2 : 0));
|
||||
}
|
||||
// the HUD's screen rect (drawn above every unit: a press on a unit's chip / bars is on that unit, render/pick.js)
|
||||
let hx0 = Infinity, hy0 = Infinity, hx1 = -Infinity, hy1 = -Infinity;
|
||||
const hudAdd = (ax, ay, bx2, by2) => { hx0 = Math.min(hx0, ax); hy0 = Math.min(hy0, ay); hx1 = Math.max(hx1, bx2); hy1 = Math.max(hy1, by2); };
|
||||
if (showHp) hudAdd(x0 - 1, cy - bh / 2 - 1, x0 + bw + 1, cy + bh / 2 + 1);
|
||||
if (showSp) hudAdd(x0 - 1, cy, x0 + bw + 1, this._spY + spH / 2 + 1);
|
||||
if (this.chip && this.chip.visible) {
|
||||
const cw = this.chip.width / 2, ch = this.chip.height / 2;
|
||||
hudAdd(this.chip.position.x - cw, this.chip.position.y - ch, this.chip.position.x + cw, this.chip.position.y + ch);
|
||||
}
|
||||
this._hudBox = hx1 > hx0 && hy1 > hy0 && this.hud.alpha > 0.05 ? { x0: hx0, y0: hy0, x1: hx1, y1: hy1 } : null;
|
||||
// status icons row above the bars
|
||||
const icons = this._iconKeys();
|
||||
const isz = clamp(s * 0.26, 12, 26);
|
||||
@@ -858,18 +881,39 @@ export class UnitView {
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Canvas-space bounds (CSS px) of the body. */
|
||||
/** Model height (tiles above the feet) of the posed Spine model: its local bounds' top, clamped per kind. */
|
||||
_measureBody() {
|
||||
let b = null;
|
||||
try { b = this.actor.spine.getLocalBounds(); } catch { b = null; }
|
||||
const top = b && Number.isFinite(b.y) && b.height > 1 ? -b.y * UNIT.modelScale : NaN;
|
||||
const [lo, hi] = this.isEnemy ? (this.isBoss ? [0.6, 3.2] : [0.45, 2.4]) : [1.0, 1.55];
|
||||
if (Number.isFinite(top) && top > 0.2) return clamp(top, lo, hi);
|
||||
return this.isEnemy ? clamp(this._headTiles || BODY_H, lo, hi) : BODY_H;
|
||||
}
|
||||
|
||||
/**
|
||||
* The body as drawn, for render/pick.js (canvas CSS px): the Spine chibi (feet, px per tile, its height — measured,
|
||||
* else the median chibi / the enemy's bounds —, width factor, facing, draw order, HUD) or the avatar diamond shown
|
||||
* until the model is there.
|
||||
* @returns {import('./pick.js').PickBody}
|
||||
*/
|
||||
pickShape() {
|
||||
const sc = this.screen;
|
||||
const depth = this.root && !this.root.destroyed ? this.root.zIndex : 0;
|
||||
const hud = this.culled ? null : this._hudBox; // a culled unit's HUD box is where it was last drawn: stale
|
||||
if (!(this.actor && this.spineReady)) return { kind: 'diamond', x: sc.x, y: sc.y, s: sc.s || 1, d: UNIT.diamond * (this.isBoss ? 1.5 : 1), depth, hud };
|
||||
const h = this._bodyH || (this.isEnemy ? clamp(this._headTiles || BODY_H, 0.45, this.isBoss ? 3.2 : 2.4) : BODY_H);
|
||||
return { kind: 'chibi', x: sc.x, y: sc.y, s: sc.s || 1, h, w: this.isEnemy ? clamp(h / 1.2, 0.75, 2.2) : 1, flip: this._flip === -1 ? -1 : 1, depth, hud };
|
||||
}
|
||||
|
||||
/** Canvas-space bounds (CSS px) of the drawn body (the outline of pickShape, without the HUD). */
|
||||
bounds() {
|
||||
const s = this.screen.s || 1;
|
||||
const h = (this._headTiles || UNIT.headroom) * s;
|
||||
const w = s * 0.7;
|
||||
return { x: this.screen.x - w / 2, y: this.screen.y - h, width: w, height: h + s * 0.1 };
|
||||
return bodyBounds({ ...this.pickShape(), hud: null });
|
||||
}
|
||||
|
||||
hitTest(x, y) {
|
||||
if (!this.alive || this.alpha < 0.05) return false;
|
||||
const b = this.bounds();
|
||||
return x >= b.x && x <= b.x + b.width && y >= b.y && y <= b.y + b.height;
|
||||
return hitBody(this.pickShape(), x, y) > 0;
|
||||
}
|
||||
|
||||
setHover(on) { this.hovered = !!on; }
|
||||
@@ -921,7 +965,10 @@ function defaultInterval(ctx, info) {
|
||||
|
||||
// =============================================================================================================
|
||||
|
||||
/** Hand item: icon plate floating above its slot. */
|
||||
/**
|
||||
* Hand item: icon plate floating above its slot. While dragged (`lift` > 0) the plate is centred on its ground point —
|
||||
* the pointer (render/app.js moveDragVisual) — so the item lands, and equips, where it is drawn.
|
||||
*/
|
||||
export class ItemView {
|
||||
constructor(ctx, info) {
|
||||
const P = ctx.P;
|
||||
@@ -946,21 +993,29 @@ export class ItemView {
|
||||
}
|
||||
setWorld(x, y, z = 0) { this.x = x; this.y = y; this.z = z; }
|
||||
update(dt, cam, t) {
|
||||
const p = cam.project(this.x, this.y, this.z + this.lift + 0.12 + Math.sin(t * 2 + this.bob) * 0.03, this.screen);
|
||||
const lifted = this.lift > 0;
|
||||
const p = cam.project(this.x, this.y, lifted ? this.z : this.z + this.lift + 0.12 + Math.sin(t * 2 + this.bob) * 0.03, this.screen);
|
||||
this.root.position.set(p.x, p.y);
|
||||
this.root.zIndex = unitDepthKey(cam, this.x, this.y, this.lift);
|
||||
const size = p.s * 0.62;
|
||||
this.plate.scale.set(size / 128);
|
||||
this.plate.anchor.set(0.5, lifted ? 0.5 : 1);
|
||||
this.root.alpha = this.dimmed ? 0.35 : 1;
|
||||
placeOnGround(this.ctx, this.shadow, this.ctx.layers.shadow, this.y, this.z);
|
||||
const sh = cam.project(this.x, this.y, this.z, SH_P);
|
||||
this.shadow.position.set(sh.x, sh.y);
|
||||
this.shadow.scale.set((p.s * 0.55) / this.shadow.texture.width);
|
||||
this.shadow.alpha = 0.35;
|
||||
this.screen.top = p.y - size;
|
||||
this.screen.top = lifted ? p.y - size / 2 : p.y - size;
|
||||
}
|
||||
bounds() { const s = this.screen.s; const w = s * 0.62; return { x: this.screen.x - w / 2, y: this.screen.y - w, width: w, height: w }; }
|
||||
hitTest(x, y) { const b = this.bounds(); return x >= b.x && x <= b.x + b.width && y >= b.y && y <= b.y + b.height; }
|
||||
/** The plate on screen for render/pick.js: a square centred on the icon. */
|
||||
pickShape() {
|
||||
const s = this.screen.s || 1;
|
||||
const cy = this.lift > 0 ? this.screen.y : this.screen.y - s * 0.31;
|
||||
return { kind: 'plate', x: this.screen.x, y: cy, s, r: 0.31, depth: this.root && !this.root.destroyed ? this.root.zIndex : 0, hud: null };
|
||||
}
|
||||
bounds() { return bodyBounds(this.pickShape()); }
|
||||
hitTest(x, y) { return hitBody(this.pickShape(), x, y) > 0; }
|
||||
setHover() {}
|
||||
destroy() { if (this.destroyed) return; this.destroyed = true; this.shadow.destroy(); this.root.destroy({ children: true }); }
|
||||
}
|
||||
|
||||
+60
-15
@@ -35,13 +35,17 @@
|
||||
// Solo battles (ui/matchStatus.js pauseAvailable): the top bar's pause button / Space send g.pause {on}; m.public.paused
|
||||
// shows the paused overlay (继续作战 / 放弃模拟) and freezes the HUD clocks. Boss rounds: the top bar shows the level's
|
||||
// 120 s countdown and the red DOT overtime warning (m.public.overtimeAt).
|
||||
// User playtest #3: the own LP drops live while the own battle's enemies enter the blue gate (item 2: the runner's
|
||||
// state().leaks → ui/hud.js liveLp → top bar + own team row; teammates' rows from m.public players[].pendingLp); while
|
||||
// the temp overflow row (临时整备区) holds pieces it is framed and labelled on the board (ui/underframe.js
|
||||
// TempRowNotice) and 准备就绪 / Space say why they are refused (item 3).
|
||||
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from '../../vendor/hooks.module.js';
|
||||
import { PHASE, GEO } from '../../../shared/constants.js';
|
||||
import { html, Spinner, PhaseBanner, Icon, Button, MicroLabel, confirmDialog, useTicker } from '../ui/components.js';
|
||||
import { useGameData, GIcon } from '../ui/gameComponents.js';
|
||||
import { useFieldView } from '../ui/fieldHost.js';
|
||||
import { TopBar } from '../ui/hud.js';
|
||||
import { TopBar, liveLp, ownLeaks, tempInfo, tempReadyReason } from '../ui/hud.js';
|
||||
import { BondStrip, BondPopup } from '../ui/bondStrip.js';
|
||||
import { TeamPanel } from '../ui/teamPanel.js';
|
||||
import { ShopBar } from '../ui/shopBar.js';
|
||||
@@ -58,7 +62,7 @@ import { ExitModal, AwayOverlay, awayStore } from '../ui/matchChrome.js';
|
||||
import { openGuide } from '../ui/guide.js';
|
||||
import { actions } from '../ui/gameActions.js';
|
||||
import { FacingWheel, holdPiece, setPieceDir, syncPieceDirs, showRange, useTileScreen } from '../ui/facingWheel.js';
|
||||
import { Underframe, underframeRect } from '../ui/underframe.js';
|
||||
import { Underframe, underframeRect, TempRowNotice } from '../ui/underframe.js';
|
||||
import { needsFacing, facingIntent, previewGrid, pieceDir, underframeActions, retreatSlot, itemDestroyable } from '../ui/facing.js';
|
||||
import { EquipReplaceDialog, replaceRequest, replaceIntent } from '../ui/equipReplace.js';
|
||||
import { pauseAvailable, isPaused, frozenNow } from '../ui/matchStatus.js';
|
||||
@@ -205,6 +209,18 @@ function MatchScreen() {
|
||||
if (!paused) pauseSeenRef.current = null;
|
||||
else if (pauseSeenRef.current == null) pauseSeenRef.current = serverNow();
|
||||
const frozenAt = paused ? frozenNow(pub, serverNow(), pauseSeenRef.current) : null;
|
||||
// live LP (user playtest #3 item 2): the own normal battle's counted leaks — the local runner's count (authoritative
|
||||
// or display replica) and the server's m.public players[].pendingLp, whichever is further (both only grow during a
|
||||
// round; a display replica stands still while the player watches a teammate's field, the server's count then moves
|
||||
// on) — cost min(lpCapPerRound, n) at settlement; shown at once until the settled m.private lands (ui/hud.js liveLp
|
||||
// keeps the base between renders)
|
||||
const lpBaseRef = useRef(null);
|
||||
const localLeaks = battleState && battleState.leaks ? battleState.leaks[ownFieldId(myId)] : undefined;
|
||||
const liveLpNow = liveLp(lpBaseRef.current, {
|
||||
phase, round: pub?.round, lp: priv?.lp, statsLeaks: priv?.stats?.leaks, alive,
|
||||
leaks: ownLeaks(localLeaks, meP?.pendingLp), cap: gd.config?.lpCapPerRound,
|
||||
});
|
||||
lpBaseRef.current = liveLpNow.base;
|
||||
|
||||
// latest values for event handlers bound once
|
||||
const live = useRef({});
|
||||
@@ -574,7 +590,9 @@ function MatchScreen() {
|
||||
useEffect(() => {
|
||||
if (!view) return undefined;
|
||||
let moveOff = null;
|
||||
const ptr = { x: NaN, y: NaN, released: false, tile: null, unit: null };
|
||||
// `over`: the render engine's own pick of the unit under the dragged item (pieceDragOver / pieceDrop `over`, the
|
||||
// shared rule of render/pick.js at the plate) — undefined while the view does not report it (DOM fallback)
|
||||
const ptr = { x: NaN, y: NaN, released: false, tile: null, unit: null, over: undefined };
|
||||
const insideBar = (x, y) => {
|
||||
const el = barRef.current;
|
||||
if (!el || !Number.isFinite(x)) return false;
|
||||
@@ -582,8 +600,11 @@ function MatchScreen() {
|
||||
return x >= r.left && x <= r.right && y >= r.top && y <= r.bottom;
|
||||
};
|
||||
const lookups = { getChess: gd.chess, getToken: gd.token, getItem: gd.item, chessRecord: (rec) => chessLoadout(rec, live.current.priv?.loadout ?? null, gd.chess)?.record };
|
||||
// own operator / summon whose sprite is under the pointer (not the dragged piece)
|
||||
// own operator / summon under the dragged item (not the dragged piece): the engine's report, else the pieces'
|
||||
// screen shapes at the pointer (ui/gameLogic.js pickPieceAt — the same rule; the tile under the pointer decides
|
||||
// where no face / torso is hit)
|
||||
const unitUnderPointer = (uid) => {
|
||||
if (ptr.over !== undefined) return ptr.over === uid ? null : ptr.over;
|
||||
const L = live.current;
|
||||
if (typeof view.pieceScreenRect !== 'function' || !L.placeCtx || !Number.isFinite(ptr.x)) return null;
|
||||
const rects = [];
|
||||
@@ -591,9 +612,10 @@ function MatchScreen() {
|
||||
if (en.piece.uid === uid || (en.area !== 'board' && en.area !== 'hand') || (en.piece.kind !== 'chess' && en.piece.kind !== 'token')) continue;
|
||||
let r = null;
|
||||
try { r = view.pieceScreenRect(en.piece.uid); } catch { r = null; }
|
||||
if (r) rects.push({ uid: en.piece.uid, left: r.left, top: r.top, right: r.right, bottom: r.bottom });
|
||||
if (r) rects.push({ uid: en.piece.uid, left: r.left, top: r.top, right: r.right, bottom: r.bottom, shape: r.shape || null, tile: pieceTile(en) });
|
||||
}
|
||||
return pickPieceAt(rects, ptr.x, ptr.y);
|
||||
const at = ptr.tile && Number.isInteger(ptr.tile.row) ? { row: ptr.tile.row, col: ptr.tile.col } : null;
|
||||
return pickPieceAt(rects, ptr.x, ptr.y, at);
|
||||
};
|
||||
const retarget = (uid, target) => {
|
||||
const L = live.current;
|
||||
@@ -601,6 +623,13 @@ function MatchScreen() {
|
||||
return equipRetarget(L.placeCtx, uid, target, unitUnderPointer(uid));
|
||||
};
|
||||
retargetRef.current = retarget;
|
||||
/** Light the tile of the operator a dragged item would equip (the drop target highlight follows the plate). */
|
||||
const markItemTarget = (uid) => {
|
||||
const t = retarget(uid, null);
|
||||
const unit = t && t.area === 'board' ? [[t.row, t.col]] : t && t.area === 'hand' ? [[GEO.HAND_ROW, t.idx]] : [];
|
||||
const key = JSON.stringify(unit);
|
||||
if (key !== ptr.unit) { ptr.unit = key; view.highlightTiles(unit, 'target'); }
|
||||
};
|
||||
const runIntent = async (intent) => {
|
||||
const L = live.current;
|
||||
if (intent.confirmReplace) {
|
||||
@@ -625,6 +654,7 @@ function MatchScreen() {
|
||||
};
|
||||
const endDrag = () => {
|
||||
moveOff?.(); moveOff = null;
|
||||
ptr.over = undefined; // (a drop / drag end hands its own `over` in again)
|
||||
setDrag(null);
|
||||
view.highlightTiles(null, null);
|
||||
};
|
||||
@@ -650,16 +680,12 @@ function MatchScreen() {
|
||||
const { legal } = boardTargets(L.placeCtx, entry.piece.uid);
|
||||
view.highlightTiles(null, null);
|
||||
view.highlightTiles(legal, 'legal');
|
||||
ptr.released = false; ptr.tile = null; ptr.unit = null;
|
||||
ptr.released = false; ptr.tile = null; ptr.unit = null; ptr.over = undefined;
|
||||
const isItem = entry.piece.kind === 'item';
|
||||
// capture phase: the pointer position is current before the drag controller (canvas listener) asks canPlace
|
||||
const onMove = (ev) => {
|
||||
ptr.x = ev.clientX; ptr.y = ev.clientY;
|
||||
if (!isItem) return;
|
||||
const t = retarget(entry.piece.uid, null);
|
||||
const unit = t && t.area === 'board' ? [[t.row, t.col]] : t && t.area === 'hand' ? [[GEO.HAND_ROW, t.idx]] : [];
|
||||
const key = JSON.stringify(unit);
|
||||
if (key !== ptr.unit) { ptr.unit = key; view.highlightTiles(unit, 'target'); }
|
||||
if (isItem && ptr.over === undefined) markItemTarget(entry.piece.uid); // the engine reports via pieceDragOver
|
||||
};
|
||||
const onUp = (ev) => { ptr.x = ev.clientX; ptr.y = ev.clientY; ptr.released = true; };
|
||||
window.addEventListener('pointermove', onMove, { passive: true, capture: true });
|
||||
@@ -670,12 +696,19 @@ function MatchScreen() {
|
||||
};
|
||||
}),
|
||||
view.on('tileHover', (t) => { ptr.tile = t && typeof t === 'object' ? t : null; }),
|
||||
// the render engine's pick of the unit under a dragged item (sent before the drag asks canPlace)
|
||||
view.on('pieceDragOver', (e) => {
|
||||
if (!e || !live.current.placeCtx?.pieces.get(e.uid)) return;
|
||||
ptr.over = Number.isInteger(e.over) ? e.over : null;
|
||||
markItemTarget(e.uid);
|
||||
}),
|
||||
view.on('pieceDrop', async (e) => {
|
||||
endDrag();
|
||||
const L = live.current;
|
||||
if (!e || !L.editable) return;
|
||||
const entry = L.placeCtx?.pieces.get(e.uid);
|
||||
if (!entry) return;
|
||||
if ('over' in e) ptr.over = Number.isInteger(e.over) ? e.over : null;
|
||||
const t = retarget(e.uid, e.target || {});
|
||||
if (t.area === 'outside') return; // no drag-to-sell (research 09 §5): the piece goes back
|
||||
const res = canPlace(L.placeCtx, e.uid, t);
|
||||
@@ -701,6 +734,7 @@ function MatchScreen() {
|
||||
if (onOwnSlot) return; // put back where it was
|
||||
// released where the drag controller found no target: equipment over an operator's sprite still equips it,
|
||||
// anything else says why it was refused
|
||||
if ('over' in e) ptr.over = Number.isInteger(e.over) ? e.over : null;
|
||||
const t = retarget(e.uid, null);
|
||||
if (t) {
|
||||
const res = canPlace(L.placeCtx, e.uid, t);
|
||||
@@ -917,7 +951,9 @@ function MatchScreen() {
|
||||
e.preventDefault(); // a focused HUD button must not also activate (Space) — see shortcutFor
|
||||
if (act === 'ready' && e.target instanceof HTMLElement && e.target.closest('button, [role="button"]')) e.target.blur();
|
||||
if (act === 'ready') {
|
||||
if (!L.priv.ready && shopBlockReason('ready', { priv: L.priv, editable: true })) { audio.sfx('error', { volume: 0.5 }); return; }
|
||||
const refused = !L.priv.ready ? shopBlockReason('ready', { priv: L.priv, editable: true }) : null;
|
||||
// the temp overflow row blocks it: say why (the button shows it too — user playtest #3 item 3)
|
||||
if (refused) { audio.sfx('error', { volume: 0.5 }); toast(tempReadyReason(L.priv) || refused, 'warn'); return; }
|
||||
toggleReady(!L.priv.ready);
|
||||
return;
|
||||
}
|
||||
@@ -958,6 +994,11 @@ function MatchScreen() {
|
||||
const watchingNow = combat ? (watching || field?.fieldId || home) : watching;
|
||||
const shopOpen = showShop && !collapsed;
|
||||
const ufShown = !!(selEntry && editable && !facing && !drag && showPrep && ufGeo);
|
||||
// the temp overflow row holds pieces (user playtest #3 item 3): framed and labelled on the own prep board
|
||||
const temp = tempInfo(priv);
|
||||
const tempNotice = temp.count > 0 && !!view && viewKind !== 'loading' && showPrep && alive && !pen && !sp;
|
||||
// the ready button shows why it is refused under it (ui/hud.js ReadyToggle): the effects column moves down a line
|
||||
const readyWhy = phase === PHASE.PREP && alive && !priv?.ready && temp.count > 0;
|
||||
// the frame the panels are laid out in: the HUD layer (client px; inside the safe-area insets of a notched phone),
|
||||
// and the root font size (1rem)
|
||||
const panelFrame = () => {
|
||||
@@ -994,18 +1035,21 @@ function MatchScreen() {
|
||||
return priv?.loadout ?? null; // own pieces, shop / reward / bond-member cards
|
||||
})();
|
||||
|
||||
return html`<div class=${cx('screen', 'gm', `gm--${mode}`, drag && 'is-dragging', collapsed && 'is-collapsed', sp && 'has-sp', pen && 'is-pen')}
|
||||
return html`<div class=${cx('screen', 'gm', `gm--${mode}`, drag && 'is-dragging', collapsed && 'is-collapsed', sp && 'has-sp', pen && 'is-pen', readyWhy && 'has-readywhy')}
|
||||
data-camera=${pen ? 'pen' : camKind}>
|
||||
<div class="gm__field" ref=${hostRef} onContextMenu=${(e) => e.preventDefault()}></div>
|
||||
${viewKind === 'loading' ? html`<div class="gm__loading"><${Spinner} label="LOADING FIELD" /></div>` : null}
|
||||
<div class="gm__vignette" aria-hidden="true"></div>
|
||||
${tempNotice ? html`<${TempRowNotice} view=${view} count=${temp.count} items=${temp.items} label=${!drag && !facing}
|
||||
ready=${phase === PHASE.PREP && !!priv?.ready} />` : null}
|
||||
|
||||
<div class="gm__hud" ref=${hudElRef}>
|
||||
<${TopBar} pub=${pub} priv=${priv} conn=${conn} hud=${hud} total=${total} drawer=${drawer}
|
||||
onExit=${() => setExitOpen(true)} onDrawer=${(t) => setDrawer((d) => (d ? null : t))} onReady=${toggleReady}
|
||||
readyBusy=${readyBusy} readyCount=${readyCount} playerCount=${solo ? 1 : aliveCount}
|
||||
pen=${pen} penAvail=${penAvail} onPen=${togglePen} config=${gd.config} frozenAt=${frozenAt}
|
||||
pause=${canPause || paused ? { show: canPause, paused, busy: pauseBusy, onToggle: () => togglePause(!paused) } : null} />
|
||||
pause=${canPause || paused ? { show: canPause, paused, busy: pauseBusy, onToggle: () => togglePause(!paused) } : null}
|
||||
live=${liveLpNow} />
|
||||
|
||||
<div class="gm__bonds">
|
||||
<${BondStrip} bonds=${stripBonds} layersDisabled=${layersDisabled} openId=${bondOpen} owner=${watchedP ? watchedP.name : null}
|
||||
@@ -1013,6 +1057,7 @@ function MatchScreen() {
|
||||
</div>
|
||||
|
||||
<${TeamPanel} pub=${pub} myId=${myId} watching=${watchingNow} bubbles=${bubbles} onWatch=${watchPlayer}
|
||||
self=${Number.isFinite(priv?.lp) ? { lp: priv.lp, pending: liveLpNow.pending, unite: liveLpNow.unite } : null}
|
||||
observe=${cc ? { canObserve: (p) => observeTarget(p, pub, myId, { observing: watchingOther, ownDone: localDone }), observing: watchingOther, onBack: backHome } : null} />
|
||||
|
||||
<div class="gm__effects"><${EffectsList} effects=${priv?.effects} /></div>
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
// 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 { html, Icon, MicroLabel, Button, TierChip, TextField, Countdown, Spinner, 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';
|
||||
@@ -362,7 +362,7 @@ function LoadoutScreen({ st }) {
|
||||
</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')}>
|
||||
${!ready ? html`<div class="lo-loading"><${Spinner} size="sm" />正在载入干员数据(打开页面后仅载入一次)…</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}>
|
||||
|
||||
+4
-2
@@ -7,8 +7,10 @@
|
||||
// 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`).
|
||||
// watch, done, own, members, loading, speed, paused, leaks } | null — `paused`: the solo pause holds
|
||||
// every local clock; `leaks`: { [fieldId]: counted leaks so far } of every normal field simulated
|
||||
// locally (the live LP of ui/hud.js liveLp, user playtest #3 item 2). 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
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
// 机变 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.
|
||||
// avatar badge. Clicking an available card on your turn sends g.choice; while it is in flight the card shows a
|
||||
// "选择中" strip with a sweeping bar (never a spinner over its text — user playtest #3 item 9), dropped as soon as the
|
||||
// pick shows in m.public (spBusy itself resets when the request settles, ≤ 8 s, or the phase moves on).
|
||||
|
||||
import { html, Icon, TierChip, Countdown, MicroLabel, Spinner } from './components.js';
|
||||
import { html, Icon, TierChip, Countdown, MicroLabel } from './components.js';
|
||||
import { Img, RichText, PlayerAvatar, GIcon } from './gameComponents.js';
|
||||
import { itemIconUrl, enemyIconUrl, uiUrl } from './assetUrls.js';
|
||||
import { richTextPlain } from './richText.js';
|
||||
@@ -47,6 +49,15 @@ export function resolveSpCard(card, family) {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a card shows the in-flight pick (g.choice sent, no answer yet): only until the pick lands in m.public — the
|
||||
* player's pick is known or the card is taken — so it can never outlive the request's effect.
|
||||
* @param {number|null} busyIdx @param {{ idx: number, takenBy?: string|null }} card @param {number|null|undefined} mine
|
||||
*/
|
||||
export function pickBusy(busyIdx, card, mine) {
|
||||
return busyIdx != null && !!card && busyIdx === card.idx && mine == null && !card.takenBy;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {{ pub:any, sp:any, myId:string, solo:boolean, onPick:(idx:number)=>void, busyIdx?:number|null, total?:number|null }} props
|
||||
*/
|
||||
@@ -94,7 +105,9 @@ export function ChoiceOverlay({ pub, sp, myId, solo, onPick, busyIdx = null, tot
|
||||
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')}
|
||||
const busy = pickBusy(busyIdx, card, mine);
|
||||
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', busy && 'is-busy')}
|
||||
aria-busy=${busy ? 'true' : undefined}
|
||||
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}
|
||||
@@ -108,7 +121,7 @@ export function ChoiceOverlay({ pub, sp, myId, solo, onPick, busyIdx = null, tot
|
||||
</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}
|
||||
${busy ? html`<span class="spcard__busy" role="status">选择中</span>` : null}
|
||||
</button>`;
|
||||
})}
|
||||
</div>
|
||||
|
||||
@@ -111,7 +111,9 @@ export function Chevrons({ count = 3, dir = 'right', tone = 'mint', animated = t
|
||||
// ---- Button ------------------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Button.
|
||||
* Button. `loading` (a request in flight): disabled and dimmed, and after a short delay a thin bar sweeps along its
|
||||
* bottom edge (css .btn__busy) — never a spinning ring or a wait cursor, which read as "this text is still loading from
|
||||
* the server" (user playtest #3 item 9); the icon stays.
|
||||
* @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
|
||||
@@ -126,9 +128,10 @@ export function Button({
|
||||
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}
|
||||
${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}
|
||||
${loading ? html`<span class="btn__busy" aria-hidden="true"></span>` : null}
|
||||
</button>`;
|
||||
}
|
||||
|
||||
|
||||
+64
-31
@@ -1,8 +1,11 @@
|
||||
// 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,
|
||||
// reached tier, active state — tap one for its popup); right under the header the operator's own effect (特质 —
|
||||
// garrison: type chip (its official type icon + eventTypeDesc such as 整备能力, garrisonTypeIconKey) + description,
|
||||
// compact, visible without scrolling: user playtest #3 items 8 / 9), the class trait (特性), 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), equipped items, talents (CHESS_SECTIONS); 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
|
||||
@@ -21,7 +24,8 @@ 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' };
|
||||
/** Fallback type icon by trigger, for a garrison record without its official `eventTypeIcon` (garrisonTypeIconKey). */
|
||||
const EVENT_ICON = { IN_BATTLE: 's_icon_battle', SERVER_GAIN: 's_icon_bond', SERVER_PREP_START: 's_icon_bond', SERVER_PREP_FIN: 's_icon_bond', 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: '无' };
|
||||
|
||||
@@ -40,7 +44,7 @@ export function RangeGrid({ grid, class: cls }) {
|
||||
}
|
||||
|
||||
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>`;
|
||||
return html`<div class="dstat"><span class="dstat__k">${k}</span><span class="dstat__row"><b class="dstat__v num">${v}</b>${sub ? html`<small>${sub}</small>` : null}</span></div>`;
|
||||
}
|
||||
|
||||
function Section({ title, micro, children, class: cls }) {
|
||||
@@ -101,7 +105,41 @@ function traitText(c, golden, lo) {
|
||||
return t.moduleDescRaw || base;
|
||||
}
|
||||
|
||||
function ChessDetail({ chess, piece, unit, snapHp, editable, onSell, bonds, loadout, onBond }) {
|
||||
/**
|
||||
* Order of an operator card's blocks (user playtest #3 item 8): the operator's own effect (特质 — garrison: its trigger
|
||||
* such as 休整期结束时 and what it does) right under the header, visible without scrolling; the class trait (特性) and
|
||||
* the stats next; then the skill, the elite's module, the equipped items (the player's own build) and the talents.
|
||||
*/
|
||||
export const CHESS_SECTIONS = Object.freeze(['head', 'garrison', 'trait', 'stats', 'skill', 'module', 'equip', 'talents', 'actions']);
|
||||
|
||||
/**
|
||||
* Sprite key (ui `garrisonTypeIcon/…`, small variant) of a 特质's type chip: the garrison's own official
|
||||
* `eventTypeIcon` — icon_battle 作战能力 / icon_gold 整备能力 / icon_bond 持续叠加·单次叠加 / icon_support 特异化, the
|
||||
* icon that goes with its `eventTypeDesc` — never a guess from the trigger (that put the spoked 特异化 glyph, which reads
|
||||
* like a loading spinner, before the text of every <休整期开始时 / 结束时> 特质: user playtest #3 item 9).
|
||||
* @param {{ eventTypeIcon?: string|null, eventType?: string }|null} garrison
|
||||
*/
|
||||
export function garrisonTypeIconKey(garrison) {
|
||||
const k = garrison && typeof garrison.eventTypeIcon === 'string' ? garrison.eventTypeIcon : '';
|
||||
if (/^icon_[a-z]+$/.test(k)) return `s_${k}`;
|
||||
return EVENT_ICON[garrison?.eventType] || 's_icon_bond';
|
||||
}
|
||||
|
||||
/** The operator's own effect (特质, garrisons.json): trigger chip + description, compact. */
|
||||
function GarrisonBlock({ garrison, m }) {
|
||||
return html`<section class="dgarrison" aria-label="特质" data-garrison=${garrison.garrisonId || ''}>
|
||||
<div class="dgarrison__head">
|
||||
<span class="dgarrison__k">特质</span>
|
||||
<span class="dgarrison__type">
|
||||
<${Img} src=${uiUrl(m, `garrisonTypeIcon/${garrisonTypeIconKey(garrison)}`)} class="dgarrison__icon" />
|
||||
${garrison.eventTypeDesc || ''}
|
||||
</span>
|
||||
</div>
|
||||
<${RichText} as="p" text=${garrison.descRaw || garrison.desc} class="dgarrison__text" />
|
||||
</section>`;
|
||||
}
|
||||
|
||||
export 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;
|
||||
@@ -117,8 +155,9 @@ function ChessDetail({ chess, piece, unit, snapHp, editable, onSell, bonds, load
|
||||
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">
|
||||
const blocks = {};
|
||||
blocks.head = html`
|
||||
<div key="head" 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>
|
||||
@@ -141,9 +180,11 @@ function ChessDetail({ chess, piece, unit, snapHp, editable, onSell, bonds, load
|
||||
${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>`;
|
||||
blocks.garrison = garrison ? html`<${GarrisonBlock} key="garrison" garrison=${garrison} m=${m} />` : null;
|
||||
blocks.trait = c.trait?.desc ? html`<p key="trait" class="dtrait"><${Icon} name="info" /><${RichText} text=${traitText(c, golden, lo)} /></p>` : null;
|
||||
blocks.stats = html`
|
||||
<div key="stats" class="dstats-wrap">
|
||||
<div class="dstats">
|
||||
<${Stat} k="生命上限" v=${fmtNum(s.maxHp)} />
|
||||
<${Stat} k="攻击" v=${fmtNum(s.atk)} />
|
||||
@@ -155,8 +196,8 @@ function ChessDetail({ chess, piece, unit, snapHp, editable, onSell, bonds, load
|
||||
<${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>`;
|
||||
blocks.skill = sk ? html`<${Section} key="skill" 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">
|
||||
@@ -171,8 +212,8 @@ function ChessDetail({ chess, piece, unit, snapHp, editable, onSell, bonds, load
|
||||
</div>
|
||||
</div>
|
||||
<${RichText} as="p" text=${sk.descRaw || sk.desc} class="dtext" />
|
||||
<//>` : null}
|
||||
${golden && lo?.module ? html`<${Section} title="模组" micro="MODULE" class="dsec--module">
|
||||
<//>` : null;
|
||||
blocks.module = golden && lo?.module ? html`<${Section} key="module" 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}
|
||||
@@ -180,25 +221,17 @@ function ChessDetail({ chess, piece, unit, snapHp, editable, onSell, bonds, load
|
||||
${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`}>
|
||||
<//>` : null;
|
||||
blocks.equip = piece?.kind === 'chess' ? html`<${Section} key="equip" title="装备" micro=${`EQUIP ${items.length}/2`} class="dsec--equip">
|
||||
${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">
|
||||
<//>` : null;
|
||||
blocks.talents = Array.isArray(fr.talents) && fr.talents.some((t) => t && t.name && !t.hidden) ? html`<${Section} key="talents" title="天赋" micro="TALENT" class="dsec--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;
|
||||
blocks.actions = piece && editable && piece.kind !== 'item' ? html`<div key="actions" class="dactions">
|
||||
<${Button} variant="amber" icon="close" class="dpanel__sell" onClick=${() => onSell(piece, c)}>出售<span class="dsell num">+${sell}</span><//>
|
||||
</div>` : null}`;
|
||||
</div>` : null;
|
||||
return CHESS_SECTIONS.map((k) => blocks[k]).filter(Boolean);
|
||||
}
|
||||
|
||||
function ItemDetail({ item, piece, editable, onDestroy }) {
|
||||
|
||||
@@ -162,11 +162,21 @@ export function GIcon({ name, class: cls, title }) {
|
||||
${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="目标生命值">
|
||||
/**
|
||||
* Green LP tower + number. `pending` (> 0): the loss the running battle's leaks will cost at settlement (user
|
||||
* playtest #3 item 2) — the number shows value − pending in red followed by a −N tick that pops again on every change
|
||||
* (keyed); `note` a small tag after it (联防中); `tip` the title text.
|
||||
* @param {{ value: any, size?: 'sm'|'md'|'lg', class?: string, tone?: string|null, pending?: number, note?: string|null, tip?: string|null }} props
|
||||
*/
|
||||
export function LpTower({ value, size = 'md', class: cls, tone, pending = 0, note = null, tip = null }) {
|
||||
const ok = Number.isFinite(value);
|
||||
const p = ok && Number(pending) > 0 ? Math.min(value, Math.trunc(Number(pending))) : 0;
|
||||
return html`<span class=${cx('lp', `lp--${size}`, tone && `lp--${tone}`, p > 0 && 'is-pending', cls)} title=${tip || '目标生命值'}
|
||||
data-pending=${p > 0 ? p : null}>
|
||||
<${Sprite} k="hudPanel/icon_hp" class="lp__icon" fallback=${html`<${Icon} name="rook" class="lp__icon" />`} />
|
||||
<b class="num">${Number.isFinite(value) ? value : '--'}</b>
|
||||
<b class="num lp__val">${ok ? Math.max(0, value - p) : '--'}</b>
|
||||
${p > 0 ? html`<span key=${p} class="lp__pend num" aria-label=${`结算时扣除 ${p}`}>−${p}</span>` : null}
|
||||
${note ? html`<span class="lp__note">${note}</span>` : null}
|
||||
</span>`;
|
||||
}
|
||||
|
||||
|
||||
@@ -18,6 +18,7 @@ import { GEO, PHASE, UF } from '../../../shared/constants.js';
|
||||
import { resolveLoadout, loadoutOptions, MODULE_NONE } from '../../../shared/protocol.js';
|
||||
import { resolveRecordLoadout, loadoutRecord } from '../../../shared/loadoutRecord.js';
|
||||
import { layoutPen } from '../render/pen.js';
|
||||
import { pickBody } from '../render/pick.js';
|
||||
import { bossLevelSeconds } from './matchStatus.js';
|
||||
|
||||
// ---- small helpers -------------------------------------------------------------------------------
|
||||
@@ -871,17 +872,31 @@ export function dropIntent(ctx, uid, target) {
|
||||
}
|
||||
|
||||
/**
|
||||
* The front-most piece whose screen rect contains (x, y): the one standing nearest the camera (largest bottom).
|
||||
* @param {Array<{uid:number, left:number, top:number, right:number, bottom:number}|null>} rects
|
||||
* The piece under a screen point (user playtest #3 item 7). Rects of the render engine carry the drawn body
|
||||
* (`shape`, view.pieceScreenRect) and are picked by the shared rule of render/pick.js — the face / torso of a unit is
|
||||
* its own; elsewhere the piece standing on the tile under the pointer (`tile`, same space as the rects' `tile`) wins.
|
||||
* Plain rects (the DOM fallback) go to the one whose centre is nearest relative to its size (a unit standing in front
|
||||
* no longer takes the whole overlap), ties → the front-most (largest bottom).
|
||||
* @param {Array<{uid:number, left:number, top:number, right:number, bottom:number, shape?:object, tile?:{row:number,col:number}}|null>} rects
|
||||
* @param {number} x
|
||||
* @param {number} y
|
||||
* @param {{row:number, col:number}|null} [tile] the tile under the point
|
||||
* @returns {number|null} uid
|
||||
*/
|
||||
export function pickPieceAt(rects, x, y) {
|
||||
export function pickPieceAt(rects, x, y, tile = null) {
|
||||
if (!Number.isFinite(x) || !Number.isFinite(y)) return null;
|
||||
let best = null;
|
||||
for (const r of Array.isArray(rects) ? rects : []) {
|
||||
if (!isObj(r) || !Number.isInteger(r.uid)) continue;
|
||||
const list = (Array.isArray(rects) ? rects : []).filter((r) => isObj(r) && Number.isInteger(r.uid));
|
||||
const shaped = list.filter((r) => isObj(r.shape) && r.shape.s > 0);
|
||||
if (shaped.length && shaped.length === list.length) {
|
||||
const hit = pickBody(shaped.map((r) => ({ ...r.shape, tile: r.tile || null, uid: r.uid })), x, y, tile);
|
||||
return hit ? hit.uid : null;
|
||||
}
|
||||
let best = null, bestD = Infinity;
|
||||
for (const r of list) {
|
||||
if (x < r.left || x > r.right || y < r.top || y > r.bottom) continue;
|
||||
if (!best || r.bottom > best.bottom) best = r;
|
||||
const hw = Math.max(1, (r.right - r.left) / 2), hh = Math.max(1, (r.bottom - r.top) / 2);
|
||||
const d = Math.hypot((x - (r.left + r.right) / 2) / hw, (y - (r.top + r.bottom) / 2) / hh);
|
||||
if (d < bestD - 1e-9 || (Math.abs(d - bestD) <= 1e-9 && r.bottom > best.bottom)) { best = r; bestD = d; }
|
||||
}
|
||||
return best ? best.uid : null;
|
||||
}
|
||||
|
||||
+107
-7
@@ -12,6 +12,11 @@
|
||||
// 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`).
|
||||
// Normal rounds (user playtest #3 item 2): the LP tower drops live as the own battle's enemies enter the blue gate —
|
||||
// lp − min(lpCapPerRound, counted leaks) in red with a −N tick, 联防中 while a 联防 may still save part of it (liveLp;
|
||||
// the leaks come from the local battle runner, else m.public players[].pendingLp).
|
||||
// 准备就绪 is refused while the temp overflow row (临时整备区) holds pieces: the reason shows under the button
|
||||
// (user playtest #3 item 3; the row's own label is ui/underframe.js TempRowNotice).
|
||||
|
||||
import { useRef } from '../../vendor/hooks.module.js';
|
||||
import { PHASE } from '../../../shared/constants.js';
|
||||
@@ -56,21 +61,110 @@ export function PhaseCapsule({ pub, hud }) {
|
||||
</div>`;
|
||||
}
|
||||
|
||||
// ---- live LP of the own battle (user playtest #3 item 2) --------------------------------------------------------
|
||||
|
||||
/** Phases whose leaks are still to be charged at settlement (normal rounds: the own battle, then 联防). */
|
||||
const LEAK_PHASES = new Set([PHASE.COMBAT, PHASE.UNITE]);
|
||||
|
||||
/**
|
||||
* Ready toggle (PREP only). Disabled with a reason while the temp hand is non-empty.
|
||||
* LP a round's leaks cost at settlement — the server rule (server/match/Match.js settle): min(lpCapPerRound, counted
|
||||
* leaks), never negative.
|
||||
* @param {number} leaks counted leaks so far
|
||||
* @param {number} [cap] data/config.json lpCapPerRound
|
||||
*/
|
||||
export function pendingLoss(leaks, cap = 10) {
|
||||
const n = Math.max(0, Math.trunc(Number(leaks) || 0));
|
||||
const c = Number(cap) > 0 ? Math.trunc(Number(cap)) : 10;
|
||||
return Math.min(c, n);
|
||||
}
|
||||
|
||||
/**
|
||||
* Counted leaks of the own battle so far from its two sources: the local runner's count (state().leaks of the own
|
||||
* field — authoritative or a display replica) and the server's m.public players[].pendingLp (the authority's b.progress,
|
||||
* ~1 Hz; the recorded result in 联防). Both only grow during a round, so the further one wins: a display replica stands
|
||||
* still while the player watches a teammate's field (the server's count moves on), the server's lags the local one by
|
||||
* up to a second. Missing / invalid values count as 0.
|
||||
* @param {any} local @param {any} server
|
||||
*/
|
||||
export function ownLeaks(local, server) {
|
||||
const n = (v) => (Number.isFinite(v) && v > 0 ? Math.trunc(v) : 0);
|
||||
return Math.max(n(local), n(server));
|
||||
}
|
||||
|
||||
/**
|
||||
* The own LP while a normal round's battle runs: the loss its counted leaks will cost is shown at once (red, −N)
|
||||
* instead of only at settlement. `base` — kept by the caller between renders — is the settled m.private state the
|
||||
* pending loss applies to: { round, lp, statsLeaks } as first seen in the round's COMBAT / 联防. The pending part is
|
||||
* dropped as soon as the settlement lands — m.private lp or stats.leaks changed (Match.flush sends m.private before the
|
||||
* SETTLE m.public) — or the phase leaves COMBAT / UNITE or the round changes, so the loss is never subtracted twice.
|
||||
* 联防 (UNITE): a leaker finally loses min(cap, the survivors of the 联防 battle that came from them) — never more than
|
||||
* its own battle's count, which stays on show marked `unite` (联防中: teammates may still save part of it) until the
|
||||
* settlement lands. Boss rounds are not handled here (the merged team LP moves live through b.pool / m.public.teamLp).
|
||||
* @param {{ round: any, lp: number, statsLeaks: number|null } | null} base
|
||||
* @param {{ phase: string, round: any, lp: any, statsLeaks?: any, leaks?: any, cap?: number, alive?: boolean }} s
|
||||
* @returns {{ base: { round: any, lp: number, statsLeaks: number|null } | null, pending: number, shown: number|null, unite: boolean }}
|
||||
*/
|
||||
export function liveLp(base, { phase, round, lp, statsLeaks = null, leaks = 0, cap = 10, alive = true }) {
|
||||
if (!Number.isFinite(lp)) return { base: null, pending: 0, shown: null, unite: false };
|
||||
if (!LEAK_PHASES.has(phase) || alive === false) return { base: null, pending: 0, shown: lp, unite: false };
|
||||
const sl = Number.isFinite(statsLeaks) ? statsLeaks : null;
|
||||
const b = base && base.round === round ? base : { round, lp, statsLeaks: sl };
|
||||
const landed = lp !== b.lp || (sl != null && b.statsLeaks != null && sl !== b.statsLeaks);
|
||||
const pending = landed ? 0 : Math.min(lp, pendingLoss(leaks, cap));
|
||||
return { base: b, pending, shown: lp - pending, unite: phase === PHASE.UNITE && pending > 0 };
|
||||
}
|
||||
|
||||
/**
|
||||
* Tooltip of an LP tower with a pending loss (null without one).
|
||||
* @param {number} lp settled LP @param {number} pending @param {{ unite?: boolean, cap?: number }} [opts]
|
||||
*/
|
||||
export function pendingTip(lp, pending, { unite = false, cap = 10 } = {}) {
|
||||
if (!(pending > 0)) return null;
|
||||
return unite
|
||||
? `目标生命值 ${lp}:联防中,队友正在迎战你漏过的敌人,结算时按联防后剩余的敌人扣除(至多 ${pending} 点)`
|
||||
: `目标生命值 ${lp}:本回合已有 ${pending >= cap ? `${cap} 个以上` : `${pending} 个`}敌人进入蓝门,结算时扣除 ${pending} 点(每回合至多 ${cap} 点)`;
|
||||
}
|
||||
|
||||
// ---- temp overflow row (临时整备区, user playtest #3 item 3) ---------------------------------------------------
|
||||
|
||||
/**
|
||||
* Pieces waiting in the temp overflow row (m.private temp): how many, and how many are items.
|
||||
* @param {any} priv m.private
|
||||
* @returns {{ count: number, items: number }}
|
||||
*/
|
||||
export function tempInfo(priv) {
|
||||
const list = (Array.isArray(priv?.temp) ? priv.temp : []).filter((p) => p && typeof p === 'object');
|
||||
return { count: list.length, items: list.filter((p) => p.kind === 'item').length };
|
||||
}
|
||||
|
||||
/** What the temp row asks of the player (the ready button's reason, the row's label). */
|
||||
export const TEMP_RULE = '放入整备区或战场、配发或使用后才能准备就绪;休整期结束时仍留在临时整备区的单位将被销毁';
|
||||
|
||||
/** Why 准备就绪 is refused while the temp row holds pieces (null when it is empty). */
|
||||
export function tempReadyReason(priv) {
|
||||
const t = tempInfo(priv);
|
||||
return t.count ? `临时整备区还有 ${t.count} 个单位:${TEMP_RULE}` : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ready toggle (PREP only). Disabled while the temp row holds pieces — the reason shows under it (not only on hover):
|
||||
* "临时整备区 N 个单位待处理" (user playtest #3 item 3).
|
||||
* @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 temp = tempInfo(priv);
|
||||
const reason = !ready ? tempReadyReason(priv) || 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)}>
|
||||
aria-pressed=${ready ? 'true' : 'false'} aria-describedby=${!ready && temp.count ? 'readywrap-why' : undefined} 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}
|
||||
${!ready && temp.count ? html`<span class="readywrap__why" id="readywrap-why" role="status" data-testid="ready-why">
|
||||
<${Icon} name="warn" /><span>临时整备区 <b class="num">${temp.count}</b> 个单位待处理</span></span>` : null}
|
||||
${Number.isFinite(total) && total > 1 ? html`<span class="readywrap__count">已就绪 <b class="num">${readyCount}</b>/<span class="num">${total}</span></span>` : null}
|
||||
</div>`;
|
||||
}
|
||||
@@ -150,14 +244,18 @@ export function PauseButton({ paused, busy = false, onToggle }) {
|
||||
* @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
|
||||
* pause?: { show: boolean, paused: boolean, busy?: boolean, onToggle: () => void } | null,
|
||||
* live?: { pending: number, unite: boolean } | null }} props
|
||||
* frozenAt: the server time every clock shows while the solo match is paused (null = live)
|
||||
* live: the own battle's pending LP loss (liveLp): the tower shows lp − pending in red with a −N tick, 联防中 during 联防
|
||||
*/
|
||||
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 }) {
|
||||
config = null, frozenAt = null, pause = null, live = 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;
|
||||
// normal rounds: the leaks of the own battle so far (boss rounds: the team LP above already moves live)
|
||||
const pending = !boss && Number.isFinite(lp) && live && live.pending > 0 ? Math.min(lp, live.pending) : 0;
|
||||
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;
|
||||
@@ -167,7 +265,8 @@ export function TopBar({ pub, priv, conn, hud, total, drawer, onExit, onDrawer,
|
||||
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 lowLp = (Number.isFinite(lp) && lp - pending <= 5) || draining;
|
||||
const cap = Number(config?.lpCapPerRound) > 0 ? Number(config.lpCapPerRound) : 10;
|
||||
const frozenSecs = Number.isFinite(frozenAt) ? remainAt(pub?.deadline, frozenAt) : null;
|
||||
const btn = checkButtons({ pen, penAvail, infoOpen: !!drawer });
|
||||
const onLeft = () => (pen ? onPen(false) : onDrawer('info'));
|
||||
@@ -191,7 +290,8 @@ export function TopBar({ pub, priv, conn, hud, total, drawer, onExit, onDrawer,
|
||||
<b class="roundbox__num num">${roundText}</b>
|
||||
</div>
|
||||
<${PhaseCapsule} pub=${pub} hud=${hud} />
|
||||
<${LpTower} value=${lp} size="lg" tone=${lowLp ? 'danger' : boss ? 'team' : null} />
|
||||
<${LpTower} value=${lp} size="lg" tone=${lowLp ? 'danger' : boss ? 'team' : null} pending=${pending}
|
||||
note=${pending > 0 && live?.unite ? '联防中' : null} tip=${pendingTip(lp, pending, { unite: !!live?.unite, cap })} />
|
||||
<${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" />
|
||||
|
||||
@@ -5,8 +5,12 @@
|
||||
// "前往查看" 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.
|
||||
// Live LP (user playtest #3 item 2): during a normal round's battle each row's tower shows lp − the loss that player's
|
||||
// leaks so far will cost (red, −N): the own row the top bar's live value (`self`, ui/hud.js liveLp), a teammate's row
|
||||
// m.public players[].pendingLp (server/match/Match.js, ~1 Hz).
|
||||
|
||||
import { useEffect, useState } from '../../vendor/hooks.module.js';
|
||||
import { PHASE } from '../../../shared/constants.js';
|
||||
import { html, Icon, Tooltip } from './components.js';
|
||||
import { PlayerAvatar, LpTower, GIcon, LocalSprite } from './gameComponents.js';
|
||||
import { EmoteBubble } from './emotes.js';
|
||||
@@ -18,12 +22,29 @@ 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' };
|
||||
|
||||
/**
|
||||
* A row's LP tower: the settled LP and the pending loss of the round's battle (0 outside COMBAT / UNITE). The own row
|
||||
* takes the top bar's live value when there is one (its m.private lp: the same number as the top bar).
|
||||
* @param {any} p m.public players[] entry @param {any} pub m.public
|
||||
* @param {{ lp?: number|null, pending: number, unite: boolean } | null} [self] the own live value (only for the own row)
|
||||
* @returns {{ lp: number|null, pending: number, unite: boolean }}
|
||||
*/
|
||||
export function rowLp(p, pub, self = null) {
|
||||
const pubLp = Number.isFinite(p?.lp) ? p.lp : null;
|
||||
if ((pub?.phase !== PHASE.COMBAT && pub?.phase !== PHASE.UNITE) || p?.alive === false) return { lp: pubLp, pending: 0, unite: false };
|
||||
const lp = self && Number.isFinite(self.lp) ? self.lp : pubLp;
|
||||
if (lp == null) return { lp, pending: 0, unite: false };
|
||||
const raw = self ? self.pending : p.pendingLp;
|
||||
const pending = Math.min(lp, Math.max(0, Math.trunc(Number(raw) || 0)));
|
||||
return { lp, pending, unite: pending > 0 && (self ? !!self.unite : pub.phase === PHASE.UNITE) };
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {{ pub:any, myId:string, watching:string|null, bubbles: Map<string,{id:string,seq:number}>, onWatch:(p:any)=>void,
|
||||
* compact?: boolean, teamLp?: number|null,
|
||||
* compact?: boolean, teamLp?: number|null, self?: { lp?: number|null, pending: number, unite: boolean } | 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 }) {
|
||||
export function TeamPanel({ pub, myId, watching, bubbles, onWatch, compact = false, observe = null, self: selfLive = null }) {
|
||||
const [openPid, setOpenPid] = useState(null);
|
||||
const phaseKey = `${pub?.phase}:${pub?.round}`;
|
||||
useEffect(() => { setOpenPid(null); }, [phaseKey, watching, observe?.observing]);
|
||||
@@ -47,6 +68,7 @@ export function TeamPanel({ pub, myId, watching, bubbles, onWatch, compact = fal
|
||||
const open = !!observe && openPid === p.playerId && !self;
|
||||
const back = !!observe && self && observe.observing;
|
||||
const title = observe ? (self ? (observe.observing ? '返回战场' : '你自己') : `查看 ${p.name} 的战场`) : (self ? '查看自己的阵地' : `查看 ${p.name} 的阵地`);
|
||||
const lp = rowLp(p, pub, self ? selfLive : null);
|
||||
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} />
|
||||
@@ -57,7 +79,8 @@ export function TeamPanel({ pub, myId, watching, bubbles, onWatch, compact = fal
|
||||
<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} />
|
||||
<${LpTower} value=${lp.lp} size="sm" tone=${Number.isFinite(lp.lp) && lp.lp - lp.pending <= 5 ? 'danger' : null} pending=${lp.pending}
|
||||
tip=${lp.pending > 0 ? `目标生命值 ${lp.lp},${lp.unite ? '联防中,' : ''}结算时扣除${lp.unite ? '至多' : ''} ${lp.pending} 点` : 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" />`} />`
|
||||
|
||||
@@ -17,11 +17,18 @@
|
||||
// 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.
|
||||
//
|
||||
// TempRowNotice (user playtest #3 item 3) — the other board-anchored overlay of the prep: while the temp overflow row
|
||||
// (临时整备区, row 8, the 5 pads in front of the bench) holds pieces, a dashed red frame around that row and a label at
|
||||
// its end say that they block 准备就绪 and are destroyed when the prep ends unless moved to the bench, equipped or used.
|
||||
// Placed through view.tileScreen (useTileScreen, so it follows the camera — the Final Assault prep too); it never takes
|
||||
// the pointer, so the pieces under it stay draggable.
|
||||
|
||||
import { html, HexBadge } from './components.js';
|
||||
import { html, HexBadge, Icon } from './components.js';
|
||||
import { GIcon } from './gameComponents.js';
|
||||
import { useTileScreen } from './facingWheel.js';
|
||||
import { localAsset } from '../data.js';
|
||||
import { GEO } from '../../../shared/constants.js';
|
||||
|
||||
const cx = (...p) => p.flat().filter(Boolean).join(' ');
|
||||
|
||||
@@ -100,3 +107,68 @@ export function Underframe({ view, uid = null, row, col, actions, name = '', bus
|
||||
</button>` : null}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
// ---- the temp overflow row (临时整备区) ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Screen frame of the temp row from its two end tiles (view.tileScreen of cols TEMP_C0 and TEMP_C0 + TEMP_SIZE − 1): the
|
||||
* quad [back-left, back-right, front-right, front-left] (back = the far edge, higher on screen — also when the Final
|
||||
* Assault prep mirrors the board), its bounds, and the side of the label: left of the row, or right of it when the
|
||||
* left has no room for `labelW` px. Pure (test/ui/playtest3.test.js).
|
||||
* @param {{ poly: Array<[number, number]> } | null} a @param {{ poly: Array<[number, number]> } | null} b
|
||||
* @param {{ labelW?: number, gap?: number, vw?: number }} [opts]
|
||||
* @returns {{ quad: Array<[number, number]>, left: number, right: number, top: number, bottom: number, y: number, side: 'left'|'right' } | null}
|
||||
*/
|
||||
export function tempRowFrame(a, b, { labelW = 0, gap = 8, vw = Infinity } = {}) {
|
||||
const ok = (t) => t && Array.isArray(t.poly) && t.poly.length >= 4 && t.poly.every((p) => Array.isArray(p) && Number.isFinite(p[0]) && Number.isFinite(p[1]));
|
||||
if (!ok(a) || !ok(b)) return null;
|
||||
const backs = [a.poly[0], a.poly[1], b.poly[0], b.poly[1]];
|
||||
const fronts = [a.poly[2], a.poly[3], b.poly[2], b.poly[3]];
|
||||
const minX = (pts) => pts.reduce((m, p) => (p[0] < m[0] ? p : m));
|
||||
const maxX = (pts) => pts.reduce((m, p) => (p[0] > m[0] ? p : m));
|
||||
const quad = [minX(backs), maxX(backs), maxX(fronts), minX(fronts)];
|
||||
const xs = quad.map((p) => p[0]);
|
||||
const ys = quad.map((p) => p[1]);
|
||||
const left = Math.min(...xs), right = Math.max(...xs), top = Math.min(...ys), bottom = Math.max(...ys);
|
||||
const side = left - gap - labelW >= 0 || right + gap + labelW > vw ? 'left' : 'right';
|
||||
return { quad, left, right, top, bottom, y: (top + bottom) / 2, side };
|
||||
}
|
||||
|
||||
/**
|
||||
* What the temp row's label says will happen to its pieces (server/match/PlayerState.js tempDue): a piece is resolved
|
||||
* at the end of the first prep in which the player can act on it. Not ready (or outside PREP): at the end of this / the
|
||||
* coming prep, and 准备就绪 waits for the row to be cleared. Ready in PREP: Ready is refused while the row holds pieces,
|
||||
* so whatever lies there arrived after it — kept through the NEXT prep (cancelling Ready makes it due at this one).
|
||||
* @param {boolean} ready m.private ready (during PREP)
|
||||
*/
|
||||
export function tempRowRule(ready) {
|
||||
return ready
|
||||
? '已准备就绪后进入的单位保留到下个休整期,届时仍在此处的将被销毁(取消准备则在本休整期结束时销毁)'
|
||||
: '放入整备区或战场、配发或使用后才能准备;休整期结束时仍在此处的将被销毁';
|
||||
}
|
||||
|
||||
/**
|
||||
* The temp row's notice: dashed red frame + label (count, what to do, what happens at the end of the prep — tempRowRule).
|
||||
* `label` false (a piece is being dragged / placed): the frame only, nothing over the pieces the player is moving.
|
||||
* @param {{ view: any, count: number, items?: number, label?: boolean, ready?: boolean }} props
|
||||
*/
|
||||
export function TempRowNotice({ view, count, items = 0, label = true, ready = false }) {
|
||||
const a = useTileScreen(view, GEO.TEMP_ROW, GEO.TEMP_C0);
|
||||
const b = useTileScreen(view, GEO.TEMP_ROW, GEO.TEMP_C0 + GEO.TEMP_SIZE - 1);
|
||||
let rem = 100;
|
||||
try { rem = parseFloat(getComputedStyle(document.documentElement).fontSize) || 100; } catch { /* default */ }
|
||||
const vw = globalThis.innerWidth || 1920;
|
||||
const f = count > 0 ? tempRowFrame(a, b, { labelW: rem * 2.9, gap: rem * 0.1, vw }) : null;
|
||||
if (!f) return null;
|
||||
const pts = f.quad.map((p) => `${p[0].toFixed(1)},${p[1].toFixed(1)}`).join(' ');
|
||||
const pad = rem * 0.1;
|
||||
const style = f.side === 'left' ? `left:${(f.left - pad).toFixed(1)}px;top:${f.y.toFixed(1)}px` : `left:${(f.right + pad).toFixed(1)}px;top:${f.y.toFixed(1)}px`;
|
||||
const what = items > 0 && items === count ? '件道具' : '个单位';
|
||||
return html`<div class="tempnote" aria-hidden="false" data-testid="temp-notice">
|
||||
<svg class="tempnote__frame" aria-hidden="true"><polygon points=${pts} /></svg>
|
||||
${label ? html`<div class=${`tempnote__label is-${f.side}`} style=${style} role="status">
|
||||
<b class="tempnote__title"><${Icon} name="warn" />临时整备区 <span class="num">${count}</span> ${what}待处理</b>
|
||||
<span class="tempnote__rule">${tempRowRule(ready)}</span>
|
||||
</div>` : null}
|
||||
</div>`;
|
||||
}
|
||||
Reference in new issue
Block a user