feedback1 final: merge the client fixes for GitHub issues #5 (prep camera follows the folded shop) and #8 item 5 (operators no longer stay placeholders)
Conflicts resolved keeping both sides: - CHANGELOG 0.1.1 界面: the three gaps lines, then the issue #5 and #8 lines. - DESIGN: the branch's "§21.26 Client fixes for GitHub issues #5 and #8" follows the gaps' §21.26 as §21.27; its references in §9 (setCamera `shop`, the model fallback), §17.1 (the quiet budget) and §19.8 (the collapsed shop) now point at §21.27.
This commit is contained in:
commit
c61167e3aa
14 files changed
+1089
-101
No files matched your search
+177
-37
@@ -2,10 +2,30 @@
|
||||
// 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).
|
||||
//
|
||||
// Manifest (public issue #8 item 5: after a page reload every operator was drawn as the image-less placeholder): seeded
|
||||
// from the game data's copy when there is one (`seed`, ui/fieldHost.js), else fetched by `ready()` with retries; a
|
||||
// failure is never kept as an empty manifest (the next `ready()` and a bounded background backoff fetch again), and
|
||||
// `onChange` listeners hear when it arrives late (render/app.js re-resolves its views' models then).
|
||||
//
|
||||
// Spine memory policy (phones have little memory; a parsed skeleton holds ~0.1–12 MB, `spineDataWeight`):
|
||||
// * a skeleton a view references is never evicted;
|
||||
// * idle (unreferenced) skeletons are kept up to SPINE_IDLE_BYTES while a scene is on screen, each safe for
|
||||
// SPINE_IDLE_GRACE_MS after its release (a prep ⇄ battle switch takes its models back); beyond the budget the least
|
||||
// recently used go, in one pass SPINE_EVICT_DELAY_MS after the release;
|
||||
// * quiet — no scene on screen (lobby, room, result: nothing referenced and no scene holding the cache for
|
||||
// SPINE_QUIET_DELAY_MS) — every idle skeleton goes after its grace: the memory goes back to the phone;
|
||||
// * a match view in battle mode holds the cache (`spine.hold()`, render/app.js): its battle views are built in
|
||||
// animation frames, which a hidden tab does not run, so a battle that began in a background tab referenced nothing
|
||||
// and the quiet budget emptied the cache ≈ 18 s later — back in the tab every unit was an avatar diamond until its
|
||||
// model downloaded again (public issue #8 item 5). The idle budget still applies while it holds.
|
||||
// A model that fails or times out is retried by its view (bounded, render/units.js), `acquire(entry, { retry: true })`.
|
||||
//
|
||||
// Pure at import time: no PIXI, no DOM access until a loader actually runs (Node tests import this file).
|
||||
//
|
||||
// import { assets } from './assets.js'; // browser singleton (manifest fetched on first use)
|
||||
// await assets.ready(); // manifest loaded (or failed → every helper returns null)
|
||||
// import { assets } from './assets.js'; // browser singleton (manifest fetched on first use, or seeded)
|
||||
// await assets.ready(); // manifest loaded (or failed for now → every helper returns null)
|
||||
// assets.seed(data.get('assets')); // adopt an already loaded copy of /data/assets.json
|
||||
// assets.onChange((what) => …); // 'manifest' | 'local' arrived (late)
|
||||
// assets.avatar('char_002_amiya', { e2: true }) // URL or null
|
||||
// const data = await assets.spine.acquire(entry); // spineData (PIXI.spine) — throws on failure/timeout
|
||||
// assets.spine.release(entry);
|
||||
@@ -215,12 +235,17 @@ 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) 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.
|
||||
* Failures are remembered for `failTtl` ms (so a missing model is not refetched every frame) and rethrown — except for
|
||||
* `acquire(key, arg, { retry: true })`, which drops a remembered failure nobody references and loads again (a view
|
||||
* retrying a failed or timed-out model: render/units.js).
|
||||
* 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` 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.
|
||||
* has been referenced and no scene holds the cache (`hold()`) 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.
|
||||
* `hold()` → release function: a scene that will reference its values again although it references none right now
|
||||
* (render/app.js in battle mode: the battle's views are built in animation frames, which a hidden tab does not run)
|
||||
* keeps the quiet budget off while it holds; the `maxIdleWeight` budget still applies.
|
||||
* `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.
|
||||
@@ -255,7 +280,8 @@ export class RefLru {
|
||||
/** @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)
|
||||
this._holds = 0; // scenes holding the cache (hold()): never quiet meanwhile
|
||||
this._quietAt = this.now(); // since when nothing has been referenced nor held (null while something is)
|
||||
/** @type {Map<string, Promise<void>>} key → unload still in flight (settles, never rejects) */
|
||||
this._unloading = new Map();
|
||||
this._active = 0;
|
||||
@@ -272,10 +298,14 @@ export class RefLru {
|
||||
has(key) { return this.map.has(key); }
|
||||
get size() { return this.map.size; }
|
||||
|
||||
/** Acquire a reference; resolves to the value (or rejects). Always pair with release(key). */
|
||||
acquire(key, arg) {
|
||||
/**
|
||||
* Acquire a reference; resolves to the value (or rejects). Always pair with release(key). `opts.retry`: a remembered
|
||||
* failure that nobody references any more is dropped and the value loaded again (a failure still referenced — a
|
||||
* caller that has not released it yet — is shared: dropping it would make that release hit the new entry).
|
||||
*/
|
||||
acquire(key, arg, opts) {
|
||||
let e = this.map.get(key);
|
||||
if (e && e.state === 'failed' && this.now() - e.failedAt > this.failTtl) { this.map.delete(key); e = null; }
|
||||
if (e && e.state === 'failed' && (this.now() - e.failedAt > this.failTtl || (opts && opts.retry && e.refs === 0))) { this.map.delete(key); e = null; }
|
||||
if (!e) {
|
||||
e = { key, promise: null, value: null, state: 'loading', refs: 0, used: ++this._tick, weight: 0, idleSince: null };
|
||||
this.map.set(key, e);
|
||||
@@ -313,12 +343,29 @@ export class RefLru {
|
||||
if (!e) return;
|
||||
if (e.refs > 0) {
|
||||
if (--e.refs === 0) e.idleSince = this.now();
|
||||
if (--this._refs <= 0) { this._refs = 0; this._quietAt = this.now(); }
|
||||
if (--this._refs <= 0) { this._refs = 0; if (this._holds === 0) this._quietAt = this.now(); }
|
||||
}
|
||||
e.used = ++this._tick;
|
||||
this._requestEvict();
|
||||
}
|
||||
|
||||
/**
|
||||
* Hold the cache for a scene that will reference its values again (see the header): no quiet budget until the
|
||||
* returned function is called (once; later calls do nothing). The quiet delay then counts from the release.
|
||||
* @returns {() => void}
|
||||
*/
|
||||
hold() {
|
||||
this._holds++;
|
||||
this._quietAt = null;
|
||||
let held = true;
|
||||
return () => {
|
||||
if (!held) return;
|
||||
held = false;
|
||||
if (--this._holds <= 0) { this._holds = 0; if (this._refs === 0) this._quietAt = this.now(); }
|
||||
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); }
|
||||
|
||||
@@ -350,7 +397,7 @@ export class RefLru {
|
||||
const now = this.now();
|
||||
let budget = this.maxIdleWeight;
|
||||
let wait = Infinity;
|
||||
if (this._refs === 0) {
|
||||
if (this._refs === 0 && this._holds === 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
|
||||
@@ -393,13 +440,13 @@ export class RefLru {
|
||||
this._sweep = { timer, at };
|
||||
}
|
||||
|
||||
/** Unload everything (refs ignored). */
|
||||
/** Unload everything (refs ignored; a scene's hold stays). */
|
||||
clear() {
|
||||
if (this._sweep) { this._timers.clear(this._sweep.timer); this._sweep = null; }
|
||||
const all = [...this.map.values()];
|
||||
this.map.clear();
|
||||
this._refs = 0;
|
||||
this._quietAt = this.now();
|
||||
this._quietAt = this._holds > 0 ? null : this.now();
|
||||
for (const e of all) if (e.state === 'ready') this._unloadNow(e.key, e.value, e);
|
||||
}
|
||||
|
||||
@@ -411,7 +458,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, unloading: this._unloading.size };
|
||||
return { size: this.map.size, ready, loading, failed, refs, holds: this._holds, weight, idleWeight, active: this._active, queued: this._queue.length, unloading: this._unloading.size };
|
||||
}
|
||||
|
||||
_schedule(fn) {
|
||||
@@ -561,16 +608,38 @@ export const SPINE_IDLE_GRACE_MS = 15000;
|
||||
* 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. */
|
||||
/** Nothing referenced (and no scene holding the cache) for this long = no scene on screen (lobby / room): the idle skeletons may all go. */
|
||||
export const SPINE_QUIET_DELAY_MS = 3000;
|
||||
/** Waits (ms) before the manifest is fetched again within one `ready()` after a transient failure (like data.js). */
|
||||
export const MANIFEST_RETRY_MS = Object.freeze([600, 2000]);
|
||||
/** Waits (ms) before the store tries again by itself after a `ready()` whose fetches all failed transiently. */
|
||||
export const MANIFEST_BACKOFF_MS = Object.freeze([5000, 15000, 30000, 60000]);
|
||||
|
||||
/** A fetch failure worth retrying (data.js): network error, HTTP 5xx / 408 / 429 — not a 404 or a body that is no JSON. */
|
||||
const transientFetch = (err) => {
|
||||
if (!err || err.badJson) return false;
|
||||
const s = err.status;
|
||||
return !(Number.isInteger(s) && s >= 400 && s < 500 && s !== 408 && s !== 429);
|
||||
};
|
||||
|
||||
/**
|
||||
* Create an asset store.
|
||||
* @param {{ url?: string, fetch?: typeof fetch, manifest?: object, loadImage?: (url) => Promise<any>,
|
||||
*
|
||||
* Manifest (public issue #8 item 5: after a page reload — a phone browser discarding a background tab, Chrome's Memory
|
||||
* Saver — every operator was drawn as the image-less placeholder): the game data already holds /data/assets.json (the
|
||||
* match screen waits for it, ui/gameComponents.js GAME_FILES), so `seed(manifest)` adopts that copy (ui/fieldHost.js) and
|
||||
* the store fetches nothing. Fetched by `ready()` (no seed yet): a transient failure is retried after MANIFEST_RETRY_MS;
|
||||
* when every try fails `ready()` resolves with an empty object — every helper returns null meanwhile, the views draw
|
||||
* their fallbacks — but the failure is never kept: the next `ready()` fetches again and the store tries again by itself
|
||||
* after MANIFEST_BACKOFF_MS (bounded; a 404 or a body that is no JSON only on the next `ready()`). `onChange(fn)` is told
|
||||
* when the manifest (`'manifest'`) or the optional local-client manifest (`'local'`) arrives, so views built without it
|
||||
* resolve their models then (render/app.js → UnitView.retryAssets).
|
||||
* @param {{ url?: string, fetch?: typeof fetch, manifest?: object, localManifest?: object, loadImage?: (url) => Promise<any>,
|
||||
* 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]
|
||||
* spineTimers?: { set, clear }, spineNow?: () => number, retryDelays?: number[], backoff?: number[],
|
||||
* wait?: (ms: number) => Promise<void>, timers?: { set, clear } }} [opts]
|
||||
*/
|
||||
export function createAssets(options) {
|
||||
const opts = options && typeof options === 'object' ? options : {};
|
||||
@@ -583,20 +652,68 @@ export function createAssets(options) {
|
||||
let readyPromise = manifest ? Promise.resolve(manifest) : null;
|
||||
const loadImage = opts.loadImage || loadImageElement;
|
||||
const images = new Map(); // url → { promise, value }
|
||||
const retryDelays = Array.isArray(opts.retryDelays) ? opts.retryDelays : MANIFEST_RETRY_MS;
|
||||
const backoff = Array.isArray(opts.backoff) ? opts.backoff : MANIFEST_BACKOFF_MS;
|
||||
const wait = opts.wait || ((ms) => new Promise((resolve) => setTimeout(resolve, ms)));
|
||||
const timers = opts.timers && typeof opts.timers.set === 'function' ? opts.timers : {
|
||||
set: (fn, ms) => { const t = setTimeout(fn, ms); t?.unref?.(); return t; },
|
||||
clear: (t) => clearTimeout(t),
|
||||
};
|
||||
const listeners = new Set();
|
||||
let warned = false;
|
||||
let backoffN = 0; // background tries used since the last success
|
||||
let backoffTimer = null;
|
||||
|
||||
const notify = (what) => {
|
||||
for (const fn of [...listeners]) { try { fn(what); } catch (err) { console.warn('[assets] change listener failed', err); } }
|
||||
};
|
||||
|
||||
/** Adopt a manifest (fetched or seeded): resolves `ready()`, stops the background tries, tells the listeners. */
|
||||
function adopt(m) {
|
||||
if (manifest || !isObj(m)) return false;
|
||||
manifest = m;
|
||||
readyPromise = Promise.resolve(m);
|
||||
backoffN = 0;
|
||||
if (backoffTimer != null) { timers.clear(backoffTimer); backoffTimer = null; }
|
||||
notify('manifest');
|
||||
return true;
|
||||
}
|
||||
|
||||
/** One fetch of the manifest → the JSON object, or throws (err.status / err.badJson as data.js). */
|
||||
async function fetchOnce() {
|
||||
const res = await doFetch(url, { 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 }); }
|
||||
if (!isObj(json)) throw Object.assign(new Error('not a manifest'), { badJson: true });
|
||||
return json;
|
||||
}
|
||||
|
||||
function scheduleBackoff() {
|
||||
if (manifest || backoffTimer != null || backoffN >= backoff.length) return;
|
||||
const ms = backoff[backoffN++];
|
||||
backoffTimer = timers.set(() => { backoffTimer = null; if (!manifest) ready(); }, ms);
|
||||
}
|
||||
|
||||
function ready() {
|
||||
if (manifest) return Promise.resolve(manifest);
|
||||
if (!readyPromise) {
|
||||
readyPromise = (async () => {
|
||||
try {
|
||||
const res = await doFetch(url, { cache: 'no-cache' });
|
||||
if (!res || !res.ok) throw new Error(`HTTP ${res ? res.status : '???'}`);
|
||||
const json = await res.json();
|
||||
manifest = isObj(json) ? json : {};
|
||||
} catch (err) {
|
||||
console.warn(`[assets] ${url} unavailable (${err?.message || err}); using fallbacks`);
|
||||
manifest = {};
|
||||
const p = readyPromise = (async () => {
|
||||
for (let attempt = 0; ; attempt++) {
|
||||
try {
|
||||
const json = await fetchOnce();
|
||||
adopt(json);
|
||||
return manifest;
|
||||
} catch (err) {
|
||||
if (manifest) return manifest; // seeded meanwhile
|
||||
if (transientFetch(err) && attempt < retryDelays.length) { await wait(retryDelays[attempt]); if (manifest) return manifest; continue; }
|
||||
if (!warned) { warned = true; console.warn(`[assets] ${url} unavailable (${err?.message || err}); using fallbacks until it loads`); }
|
||||
// not kept: the next ready() fetches again; the store tries again by itself after a transient failure
|
||||
if (readyPromise === p) readyPromise = null;
|
||||
if (transientFetch(err)) scheduleBackoff();
|
||||
return {};
|
||||
}
|
||||
}
|
||||
return manifest;
|
||||
})();
|
||||
}
|
||||
return readyPromise;
|
||||
@@ -621,9 +738,9 @@ export function createAssets(options) {
|
||||
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 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)
|
||||
// them once no scene has referenced or held any for SPINE_QUIET_DELAY_MS (lobby / room / result; a field view in
|
||||
// battle mode holds the cache — see the header), 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,
|
||||
@@ -650,16 +767,16 @@ export function createAssets(options) {
|
||||
return e.promise;
|
||||
}
|
||||
|
||||
/** Optional local-client art manifest (null when absent: every consumer falls back). Fetched once. */
|
||||
/** Optional local-client art manifest (null when absent: every consumer falls back). Fetched once (or seeded). */
|
||||
function local() {
|
||||
if (!localPromise) {
|
||||
localPromise = (async () => {
|
||||
try {
|
||||
const res = await doFetch(localUrl, { cache: 'no-cache' });
|
||||
if (!res || !res.ok) return null;
|
||||
if (!res || !res.ok) return localManifest;
|
||||
const json = await res.json();
|
||||
localManifest = isObj(json) && isObj(json.groups) ? json : null;
|
||||
} catch { localManifest = null; }
|
||||
if (!localManifest && isObj(json) && isObj(json.groups)) { localManifest = json; notify('local'); }
|
||||
} catch { /* optional art: absent */ }
|
||||
return localManifest;
|
||||
})();
|
||||
}
|
||||
@@ -670,6 +787,25 @@ export function createAssets(options) {
|
||||
return {
|
||||
ready,
|
||||
local,
|
||||
/**
|
||||
* Adopt the game data's copy of /data/assets.json (data.js 'assets', already loaded by the match screen) unless a
|
||||
* manifest is loaded: no second download, nothing to wait for. Returns whether it was adopted.
|
||||
*/
|
||||
seed: (mf) => adopt(mf),
|
||||
/** The same for the optional local-client manifest (data.js 'local'; `{ groups }`), unless one is known. */
|
||||
seedLocal(lm) {
|
||||
if (localManifest || !isObj(lm) || !isObj(lm.groups)) return false;
|
||||
localManifest = lm;
|
||||
localPromise = Promise.resolve(lm);
|
||||
notify('local');
|
||||
return true;
|
||||
},
|
||||
/** `fn('manifest' | 'local')` when a manifest arrives (seeded or fetched, also late); returns the unsubscribe. */
|
||||
onChange(fn) {
|
||||
if (typeof fn !== 'function') return () => {};
|
||||
listeners.add(fn);
|
||||
return () => listeners.delete(fn);
|
||||
},
|
||||
localUrl: (group, name) => localAssetUrl(localManifest, group, name),
|
||||
get manifest() { return manifest; },
|
||||
get loaded() { return !!manifest; },
|
||||
@@ -709,11 +845,15 @@ export function createAssets(options) {
|
||||
})));
|
||||
return { ok, failed: list.length - ok, total: list.length };
|
||||
},
|
||||
/** Spine data LRU: acquire(entry) → Promise<spineData>; release(entry); peek(entry). */
|
||||
/**
|
||||
* Spine data LRU: acquire(entry, { retry }) → Promise<spineData> (`retry`: load again after a remembered failure);
|
||||
* release(entry); peek(entry); hold() → release function (a scene that will need its skeletons again: RefLru).
|
||||
*/
|
||||
spine: {
|
||||
acquire: (entry) => (validSpine(entry) ? spine.acquire(entry.skel, entry) : Promise.reject(new Error('no spine entry'))),
|
||||
acquire: (entry, o) => (validSpine(entry) ? spine.acquire(entry.skel, entry, o) : Promise.reject(new Error('no spine entry'))),
|
||||
release: (entry) => { if (entry && entry.skel) spine.release(entry.skel); },
|
||||
peek: (entry) => (entry && entry.skel ? spine.peek(entry.skel) : null),
|
||||
hold: () => spine.hold(),
|
||||
stats: () => spine.stats(),
|
||||
clear: () => spine.clear(),
|
||||
cache: spine,
|
||||
|
||||
+85
-18
@@ -3,11 +3,14 @@
|
||||
//
|
||||
// const view = await createFieldView(host, { data, assets, audio, settings, padding, hud })
|
||||
// view.setStage(stage) procedural tiles + devices (data/stages.json entry)
|
||||
// view.setCamera(kind, { rect, side, padding, instant }) 'prep'|'normal'|'unite'|'boss'('hidden'); animated
|
||||
// view.setCamera(kind, { rect, side, padding, instant, shop }) 'prep'|'normal'|'unite'|'boss'('hidden'); animated
|
||||
// — the prep cameras (own board / Final Assault half) keep the bench
|
||||
// and the field clear of `hud(kind, size)` = { top, bottom } px of
|
||||
// DOM HUD along the top / bottom edge (projection.js clearHud; user
|
||||
// playtest #5 item 9: the shop bar covered the bench on phones)
|
||||
// and the field clear of `hud(kind, size, { shop })` = { top, bottom } px
|
||||
// of DOM HUD along the top / bottom edge (projection.js clearHud; user
|
||||
// playtest #5 item 9: the shop bar covered the bench on phones); `shop:
|
||||
// false` = the folded shop: the official shop-collapsed prep camera
|
||||
// (left_prepare / *_boss_prepare) clear of the folded shop's band (public
|
||||
// issue #5: folding the shop did not grow the board)
|
||||
// view.setPrep(privateState, { editable, canPlace }) hand/temp/board pieces; editable enables drag & drop; a
|
||||
// new board piece flashes (fx.deploy); a merge's elite — on the tile of
|
||||
// the deployed copy it replaced, or on its bench slot — gets the
|
||||
@@ -80,9 +83,12 @@
|
||||
// context fall back to the 2D atlas board. `opts.board`: 'auto' (default) | '3d' | '2d'; `?board=2d|3d` in the URL
|
||||
// overrides (dev; '3d' also accepts a slow / software GPU). stats().board3d → { on, calls, triangles, cpuMs }.
|
||||
// three.js is only downloaded when the local-art manifest lists the board atlas. That manifest is awaited with the asset
|
||||
// manifest (≤ 4 s), so an enemy whose model only the local client has draws it (assets.js spineEntry). The built 3D
|
||||
// area, the drawn 2D rows and the lit rect follow `viewKind` (a 'prep' camera on the boss rows = the Final Assault
|
||||
// prep = the boss field).
|
||||
// manifest (≤ 4 s; the game seeds the store with its own copies, ui/fieldHost.js seedAssets), so an enemy whose model
|
||||
// only the local client has draws it (assets.js spineEntry). A manifest that arrives later (assets.js onChange) makes
|
||||
// every view re-resolve its picture and model (UnitView.retryAssets), and so does showing the tab again; battle mode
|
||||
// holds the Spine cache so a battle begun in a hidden tab keeps its models (holdScene; public issue #8 item 5). The
|
||||
// built 3D area, the drawn 2D rows and the lit rect follow `viewKind` (a 'prep' camera on the boss rows = the Final
|
||||
// Assault prep = the boss field).
|
||||
// Battle device boxes (`ctx.createBox`) follow the board layer (switchableBox), so a 3D ⇄ 2D switch keeps crates.
|
||||
// Crowds and clipped Spine skeletons render through the shared impostor atlas (render/impostor.js), flushed once per
|
||||
// frame before the main pass.
|
||||
@@ -374,7 +380,7 @@ export function releaseGl(renderer) {
|
||||
* @param {HTMLElement} host
|
||||
* @param {{ data?: any, assets?: any, audio?: any, settings?: { damageNumbers?: boolean, quality?: string },
|
||||
* padding?: object|((kind:string, size:{width:number,height:number}) => object),
|
||||
* hud?: {top:number,bottom:number}|((kind:'prep'|'bossPrep', size:{width:number,height:number}) => {top:number,bottom:number}|null) }} [opts]
|
||||
* hud?: {top:number,bottom:number}|((kind:'prep'|'bossPrep', size:{width:number,height:number}, o:{shop:boolean}) => {top:number,bottom:number}|null) }} [opts]
|
||||
*/
|
||||
export async function createFieldView(host, options = {}) {
|
||||
if (!host || typeof host.appendChild !== 'function') throw new TypeError('createFieldView: host element required');
|
||||
@@ -443,13 +449,18 @@ export async function createFieldView(host, options = {}) {
|
||||
bgMountains2.alpha = 1;
|
||||
const bgVignette = new P.Sprite(bg.vignette);
|
||||
backdrop.addChild(bgGrad, bgMountains, bgMountains2, bgGrid, bgVignette);
|
||||
const mountainUrl = assets.ui ? assets.ui('entry/bg_mountains_tiled') : null;
|
||||
if (mountainUrl && assets.image) {
|
||||
let mountainsAsked = false;
|
||||
/** The backdrop's mountain silhouette (optional art; asked again when the manifest arrives late). */
|
||||
function loadMountains() {
|
||||
const mountainUrl = !mountainsAsked && assets.ui ? assets.ui('entry/bg_mountains_tiled') : null;
|
||||
if (!mountainUrl || !assets.image) return;
|
||||
mountainsAsked = true;
|
||||
assets.image(mountainUrl).then((img) => {
|
||||
if (!img || destroyed) return;
|
||||
try { const mt = silhouetteTexture(img); bgMountains.texture = mt; bgMountains2.texture = mt; } catch { /* optional art */ }
|
||||
}, () => {});
|
||||
}
|
||||
loadMountains();
|
||||
|
||||
// ---- state ----------------------------------------------------------------------------------------------
|
||||
const listeners = new Map();
|
||||
@@ -472,6 +483,7 @@ export async function createFieldView(host, options = {}) {
|
||||
let promoBase = [];
|
||||
const promotions = []; // the last merges cued by setPrep (fx.promote): { uid, id, area, row, col, idx, copies } — dev / tests
|
||||
let battleMeta = null;
|
||||
let sceneHold = null; // release function of the Spine cache hold of battle mode (holdScene)
|
||||
const infos = new Map(); // battle unit id → UnitInfo
|
||||
// battle ids whose view finished its death / leak fade: a snapshot may still list them for a moment (the sim keeps
|
||||
// dead units for DIE_ANIM_TIME), which must not bring the view back; a spawn / deploy / live sample clears it
|
||||
@@ -625,15 +637,25 @@ export async function createFieldView(host, options = {}) {
|
||||
const ready = Promise.all([threePromise, packPromise]).then(([THREE, pack]) => (THREE && pack ? enable3d(THREE, pack) : false), () => false);
|
||||
await withTimeout(ready, 6000);
|
||||
}
|
||||
// the official soft shadow sprite replaces the procedural one once loaded (may already be cached)
|
||||
const shadowUrl = assets.ui ? assets.ui('battle/sprite_shadow') : null;
|
||||
if (shadowUrl) {
|
||||
// the official soft shadow sprite replaces the procedural one once loaded (may already be cached; asked again when the
|
||||
// manifest arrives late)
|
||||
let shadowAsked = false;
|
||||
function loadShadow() {
|
||||
const shadowUrl = !shadowAsked && assets.ui ? assets.ui('battle/sprite_shadow') : null;
|
||||
if (!shadowUrl) return;
|
||||
shadowAsked = true;
|
||||
try {
|
||||
const t = P.Texture.from(shadowUrl);
|
||||
const use = () => { if (destroyed) return; ctx.shadowTex = t; for (const v of views.values()) if (v.shadow && !v.destroyed) v.shadow.texture = t; };
|
||||
const use = () => {
|
||||
if (destroyed) return;
|
||||
ctx.shadowTex = t;
|
||||
for (const v of views.values()) if (v.shadow && !v.destroyed) v.shadow.texture = t;
|
||||
for (const v of penViews.values()) if (v.shadow && !v.destroyed) v.shadow.texture = t;
|
||||
};
|
||||
if (t.baseTexture.valid) use(); else t.baseTexture.once('loaded', use);
|
||||
} catch { /* optional */ }
|
||||
}
|
||||
loadShadow();
|
||||
ensureDamageFonts();
|
||||
// web fonts may land after the first chips were drawn
|
||||
if (document.fonts?.ready) document.fonts.ready.then(() => { if (!destroyed) refreshTierChips(); }).catch(() => {});
|
||||
@@ -650,10 +672,11 @@ export async function createFieldView(host, options = {}) {
|
||||
}
|
||||
|
||||
// the HUD bands the prep cameras keep the bench / field clear of (projection.js clearHud; user playtest #5 item 9):
|
||||
// `opts.hud` = (kind, size) => { top, bottom } | null, or a fixed object; none → the plain official framing
|
||||
function hudBands(kind, sz) {
|
||||
// `opts.hud` = (kind, size, { shop }) => { top, bottom } | null, or a fixed object; none → the plain official framing.
|
||||
// `shop: false` asks for the folded shop's band (the camera request's `shop: false`, public issue #5)
|
||||
function hudBands(kind, sz, o) {
|
||||
if (kind !== 'prep' && kind !== 'bossPrep') return null;
|
||||
if (typeof opts.hud === 'function') { try { return opts.hud(kind, sz) || null; } catch { return null; } }
|
||||
if (typeof opts.hud === 'function') { try { return opts.hud(kind, sz, o) || null; } catch { return null; } }
|
||||
return opts.hud && typeof opts.hud === 'object' ? opts.hud : null;
|
||||
}
|
||||
|
||||
@@ -673,10 +696,12 @@ export async function createFieldView(host, options = {}) {
|
||||
if (k === 'prep') rect = rect ? { ...rect, r0: Math.min(rect.r0, GEO.HAND_ROW) } : null;
|
||||
// official configBlackBoard framing (render/projection.js presetCamera); the padding only matters for the
|
||||
// fitted fallback (custom rects, portrait viewports); a prep camera keeps the bench and the field clear of the HUD
|
||||
// — `shop: false` (the folded shop, public issue #5) = the official shop-collapsed camera, clear of the folded
|
||||
// shop's HUD band (re-evaluated on resize: camOpts keep the flag)
|
||||
const vk = viewKind(kind, o); // (a 'prep' camera on the boss rows = the Final Assault prep)
|
||||
return presetCamera(k, { width: sz.width, height: sz.height, padding: o.padding || defaultPadding(k, sz) }, {
|
||||
rect, side: o.side, half: !!o.half, shop: o.shop, fit: !!o.fit, config: stageRec?.config || null,
|
||||
hud: hudBands(vk, sz),
|
||||
hud: hudBands(vk, sz, { shop: o.shop !== false }),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -944,6 +969,7 @@ export async function createFieldView(host, options = {}) {
|
||||
}
|
||||
v._home = w;
|
||||
v.dimmed = false;
|
||||
if (info.kind === 'item' && v.setIcon) v.setIcon(info.icon); // an icon the manifest named late (onAssets)
|
||||
if (v.setCount) v.setCount(e.piece.kind === 'token' ? e.piece.count : 0);
|
||||
if (v.setItems) v.setItems(Array.isArray(e.piece.items) ? e.piece.items.map((it) => { const r = data.item(it?.id); return assets.itemIcon ? assets.itemIcon(r ? { trapId: r.trapId, iconId: r.iconId } : it?.id) : null; }) : []);
|
||||
v._showFacing = e.area === 'board';
|
||||
@@ -953,6 +979,7 @@ export async function createFieldView(host, options = {}) {
|
||||
for (const k of [...views.keys()]) if (String(k).startsWith('p:') && !seen.has(Number(String(k).slice(2)))) dropView(k);
|
||||
prepPieces = list.filter((e) => e.key && views.has(e.key));
|
||||
if (dragState && !views.has(dragState.key)) { drag.reset(); endDragVisual(false); }
|
||||
holdScene(false); // the prep pieces reference their models now (a battle's hold ends here)
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -1285,8 +1312,23 @@ export async function createFieldView(host, options = {}) {
|
||||
|
||||
// ---- battle ---------------------------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Battle mode holds the Spine cache (assets.js `spine.hold()`, its memory policy): the battle's views are built in
|
||||
* animation frames, which a hidden tab does not run — a battle that began in a background tab referenced no skeleton, and
|
||||
* the quiet budget emptied the cache ≈ 18 s later, so back in the tab every unit was an avatar diamond until its model
|
||||
* downloaded again (public issue #8 item 5). Released once the prep pieces reference their models (setPrep) and on
|
||||
* destroy; the idle budget still applies meanwhile.
|
||||
*/
|
||||
function holdScene(on) {
|
||||
if (on) { if (!sceneHold && typeof assets.spine?.hold === 'function') sceneHold = assets.spine.hold(); return; }
|
||||
const release = sceneHold;
|
||||
sceneHold = null;
|
||||
if (release) { try { release(); } catch { /* ignore */ } }
|
||||
}
|
||||
|
||||
function enterBattle(meta) {
|
||||
if (destroyed || !meta || typeof meta !== 'object') return false;
|
||||
holdScene(true); // before the prep views go: the cache is never "quiet" across the switch
|
||||
drag.reset();
|
||||
endDragVisual(false);
|
||||
clearViews();
|
||||
@@ -1695,6 +1737,28 @@ export async function createFieldView(host, options = {}) {
|
||||
let lastDpr = globalThis.devicePixelRatio || 1;
|
||||
layoutBackdrop();
|
||||
|
||||
// ---- late assets, hidden tabs (public issue #8 item 5: operators drawn as image-less placeholders) ----------------------
|
||||
// The asset manifest (or the local-client one) arrived after views were built — a reload whose manifest fetch was slow or
|
||||
// failed (assets.js onChange): every view re-resolves what it could not draw (UnitView.retryAssets) and the prep pieces
|
||||
// re-read their item icons. The tab shown again: a manifest still missing is asked for again and views without a model
|
||||
// load it again at once (their bounded retries never run while hidden: no frames).
|
||||
function onAssets() {
|
||||
if (destroyed) return;
|
||||
loadShadow();
|
||||
loadMountains();
|
||||
if (mode === 'prep' && lastPrep) setPrep(lastPrep.ps, lastPrep.o);
|
||||
for (const v of views.values()) v.retryAssets?.();
|
||||
for (const v of penViews.values()) v.retryAssets?.();
|
||||
}
|
||||
const offAssets = typeof assets.onChange === 'function' ? assets.onChange(onAssets) : null;
|
||||
const onVisible = () => {
|
||||
if (destroyed || globalThis.document?.visibilityState !== 'visible') return;
|
||||
if (assets.loaded === false && typeof assets.ready === 'function') assets.ready();
|
||||
for (const v of views.values()) v.retryAssets?.();
|
||||
for (const v of penViews.values()) v.retryAssets?.();
|
||||
};
|
||||
globalThis.document?.addEventListener?.('visibilitychange', onVisible);
|
||||
|
||||
// ---- public API ---------------------------------------------------------------------------------------------
|
||||
|
||||
const view = {
|
||||
@@ -1810,6 +1874,8 @@ export async function createFieldView(host, options = {}) {
|
||||
if (destroyed) return;
|
||||
destroyed = true;
|
||||
try { ro?.disconnect(); } catch { /* ignore */ }
|
||||
try { offAssets?.(); } catch { /* ignore */ }
|
||||
globalThis.document?.removeEventListener?.('visibilitychange', onVisible);
|
||||
canvas.removeEventListener('pointerdown', onPointerDown);
|
||||
canvas.removeEventListener('pointermove', onPointerMove);
|
||||
canvas.removeEventListener('pointerup', onPointerUp);
|
||||
@@ -1824,6 +1890,7 @@ export async function createFieldView(host, options = {}) {
|
||||
drag.reset();
|
||||
for (const k of [...views.keys()]) dropView(k);
|
||||
clearPen();
|
||||
holdScene(false); // no scene any more: the quiet budget may free the skeletons (lobby / room / result)
|
||||
try { fx.destroy(); } catch { /* ignore */ }
|
||||
try { tiles.destroy(); } catch { /* ignore */ }
|
||||
try { impostors.destroy(); } catch { /* ignore */ }
|
||||
|
||||
+56
-15
@@ -3,11 +3,13 @@
|
||||
// UnitView = shadow sprite (shadow layer) + body container (depth-sorted unit layer: elite aura, Spine actor or
|
||||
// the avatar-in-rarity-diamond fallback) + HUD container (bar layer: HP bar with delayed "ghost" damage, SP bar
|
||||
// with ready glow / draining skill bar, tier chip, status icons, blocked marker). The fallback shows at once and
|
||||
// cross-fades to the Spine model when it has loaded; missing or failed models keep the fallback forever (the
|
||||
// game never blocks on Spine) — except an optional local-client model (assets.js spineEntry `fallback`, DESIGN §13:
|
||||
// 灼热源石虫 / 炽焰源石虫), which falls back to the web model first; that web alias (the plain 源石虫) is drawn tinted
|
||||
// toward the slug's own colours (ALIAS_TINT). Every bar is a tinted Texture.WHITE sprite, so HUDs batch into few draw
|
||||
// calls.
|
||||
// cross-fades to the Spine model when it has loaded (the game never blocks on Spine); an optional local-client model
|
||||
// (assets.js spineEntry `fallback`, DESIGN §13: 灼热源石虫 / 炽焰源石虫) falls back to the web model first; that web alias
|
||||
// (the plain 源石虫) is drawn tinted toward the slug's own colours (ALIAS_TINT). A model that failed or timed out is
|
||||
// loaded again after SPINE_RETRY_MS (bounded), and `retryAssets()` re-resolves a view's picture and model when the
|
||||
// asset manifest arrives after the view was built or the tab is shown again (render/app.js; public issue #8 item 5:
|
||||
// after a reload whose manifest was slow or failed, every operator stayed the image-less placeholder for good). Every
|
||||
// bar is a tinted Texture.WHITE sprite, so HUDs batch into few draw calls.
|
||||
//
|
||||
// Placement: feet anchored at world (x, y, z); scale = camera px-per-tile at the feet × UNIT.modelScale, so
|
||||
// chibis shrink with distance like the original. An enemy's model is also scaled by its official prefab factor
|
||||
@@ -84,6 +86,12 @@ export const DIR_STEP = Object.freeze({ UP: [0, 1], RIGHT: [1, 0], DOWN: [0, -1]
|
||||
const nowMs = () => (globalThis.performance ? globalThis.performance.now() : Date.now());
|
||||
/** How long a view waits for its avatar before showing the image-less placeholder diamond. */
|
||||
const PIC_WAIT_MS = 400;
|
||||
/**
|
||||
* Waits (ms, real time) before a view loads its Spine model again after the load failed or timed out (assets.js, 20 s)
|
||||
* — bounded: after the last one the view keeps its diamond until `retryAssets` (a manifest that arrived late, the tab
|
||||
* shown again). Counted from the failure; a hidden tab runs no frames, so nothing is retried while hidden.
|
||||
*/
|
||||
export const SPINE_RETRY_MS = Object.freeze([2000, 6000, 15000, 30000]);
|
||||
|
||||
/** Heights above this count as standing on a raised top (bench pads are the lowest raised tiles, 0.16). */
|
||||
const RAISED_Z = 0.12;
|
||||
@@ -317,6 +325,9 @@ export class UnitView {
|
||||
this.body.addChild(this.fallback);
|
||||
this.actor = null;
|
||||
this.spineReady = false;
|
||||
this._spineBusy = false; // a Spine load of this view is in flight
|
||||
this._spineTries = 0; // failed loads since the last model (SPINE_RETRY_MS)
|
||||
this._retryAt = 0; // when the next retry is due (ms, performance clock; 0 = none)
|
||||
this.baseTint = 0xffffff; // the drawn model's own tint (ALIAS_TINT), under the status tints
|
||||
|
||||
this.hud = new P.Container();
|
||||
@@ -365,7 +376,8 @@ export class UnitView {
|
||||
pic.shown = want;
|
||||
}
|
||||
|
||||
_loadSpine() {
|
||||
/** @param {boolean} [retry] load again even after a remembered failure (assets.js acquire `{ retry }`) */
|
||||
_loadSpine(retry = false) {
|
||||
const a = this.ctx.assets;
|
||||
if (!a || !a.spineEntry || !a.spine) return;
|
||||
const id = this.info.spine || this.info.defId;
|
||||
@@ -374,21 +386,38 @@ export class UnitView {
|
||||
const entry = id ? a.spineEntry(id, { back }) : null;
|
||||
if (!entry || this.ctx.settings?.quality === 'low' && this.isEnemy && !this.isBoss && this.ctx.crowded?.()) return;
|
||||
this.entryBack = back;
|
||||
this._acquireSpine(entry, id);
|
||||
this._acquireSpine(entry, id, retry);
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-resolve what this view could not draw yet (public issue #8 item 5): the picture when it has none (no avatar URL —
|
||||
* the asset manifest arrived after the view was built — or the image failed) and, at once, the Spine model when none
|
||||
* is shown and none is loading (no manifest entry then, or a load that failed / timed out). render/app.js calls it for
|
||||
* every view when a manifest arrives (assets.js onChange) and when the tab is shown again. Nothing to do otherwise.
|
||||
*/
|
||||
retryAssets() {
|
||||
if (this.destroyed) return;
|
||||
if (!this._pic || this._pic.state === 'none') this._loadPicture();
|
||||
if (!this.actor && !this._spineBusy) { this._retryAt = 0; this._loadSpine(true); }
|
||||
}
|
||||
|
||||
/**
|
||||
* Load `entry` and show it. Every acquire is paired with exactly one release: a superseded / failed / post-destroy
|
||||
* load releases its own entry; the displayed model's entry (`_actorEntry`) is released when that model is replaced or
|
||||
* destroyed. A model that fails to load keeps the fallback diamond — unless the entry names a `fallback` (an optional
|
||||
* local-client model, assets.js spineEntry: DESIGN §13), which is loaded in its place.
|
||||
* destroyed. A model that fails to load falls back to the entry's `fallback` (an optional local-client model,
|
||||
* assets.js spineEntry: DESIGN §13) when it names one; otherwise the view keeps its fallback diamond and — while no
|
||||
* model is shown — loads again after SPINE_RETRY_MS (a timed-out or failed download used to stay a diamond for good).
|
||||
*/
|
||||
_acquireSpine(entry, id) {
|
||||
_acquireSpine(entry, id, retry = false) {
|
||||
const a = this.ctx.assets;
|
||||
this.entry = entry;
|
||||
const req = this._spineReq = (this._spineReq || 0) + 1;
|
||||
a.spine.acquire(entry).then((data) => {
|
||||
this._spineBusy = true;
|
||||
a.spine.acquire(entry, retry ? { retry: true } : undefined).then((data) => {
|
||||
if (req === this._spineReq) this._spineBusy = false;
|
||||
if (this.destroyed || req !== this._spineReq) { this._releaseEntry(entry); return; }
|
||||
this._spineTries = 0;
|
||||
this._retryAt = 0;
|
||||
let actor = null;
|
||||
try {
|
||||
actor = new SpineActor(data, entry);
|
||||
@@ -421,9 +450,12 @@ export class UnitView {
|
||||
this.actor.setBase(this._baseFromAnim());
|
||||
}
|
||||
}, () => {
|
||||
if (req === this._spineReq) this._spineBusy = false;
|
||||
this._releaseEntry(entry);
|
||||
if (entry.fallback && !this.destroyed && req === this._spineReq) this._acquireSpine(entry.fallback, id);
|
||||
// else keep the fallback diamond
|
||||
if (this.destroyed || req !== this._spineReq) return;
|
||||
if (entry.fallback) { this._acquireSpine(entry.fallback, id, retry); return; }
|
||||
// keep the fallback diamond (or the model already shown: a failed Front ⇄ Back swap) — and try again later
|
||||
if (!this.actor && this._spineTries < SPINE_RETRY_MS.length) this._retryAt = nowMs() + SPINE_RETRY_MS[this._spineTries++];
|
||||
});
|
||||
}
|
||||
|
||||
@@ -729,6 +761,8 @@ export class UnitView {
|
||||
update(dt, cam, t) {
|
||||
if (this.destroyed) return;
|
||||
const P = this.P;
|
||||
// a failed / timed-out model load is tried again once its wait is over (SPINE_RETRY_MS; frames only: never hidden)
|
||||
if (this._retryAt && nowMs() >= this._retryAt) { this._retryAt = 0; if (!this.actor && !this._spineBusy) this._loadSpine(true); }
|
||||
if (this.zTarget != null && this.z !== this.zTarget) {
|
||||
const d = this.zTarget - this.z;
|
||||
this.z = Math.abs(d) < 1e-3 ? this.zTarget : this.z + d * Math.min(1, dt * 12);
|
||||
@@ -1297,10 +1331,17 @@ export class ItemView {
|
||||
this.plate = new P.Sprite(itemTexture(String(info.defId || 'item'), null, info.color || 0x9aa5a0));
|
||||
this.plate.anchor.set(0.5, 1);
|
||||
this.root.addChild(this.plate);
|
||||
const url = info.icon;
|
||||
if (url && ctx.assets?.image) ctx.assets.image(url).then((img) => { if (!this.destroyed && img) this.plate.texture = itemTexture(String(info.defId), img, info.color || 0x9aa5a0); }, () => {});
|
||||
this.info.icon = null;
|
||||
this.setIcon(info.icon);
|
||||
this.hud = null;
|
||||
}
|
||||
/** Show the item's icon (an URL; the plain plate until it loads) — again when the asset manifest named it late. */
|
||||
setIcon(url) {
|
||||
if (this.destroyed || !url || url === this.info.icon) return;
|
||||
this.info.icon = url;
|
||||
const a = this.ctx.assets;
|
||||
if (a?.image) a.image(url).then((img) => { if (!this.destroyed && img && this.info.icon === url) this.plate.texture = itemTexture(String(this.info.defId), img, this.info.color || 0x9aa5a0); }, () => {});
|
||||
}
|
||||
setWorld(x, y, z = 0) { this.x = x; this.y = y; this.z = z; }
|
||||
update(dt, cam, t) {
|
||||
const lifted = this.lift > 0;
|
||||
|
||||
@@ -29,7 +29,10 @@
|
||||
// comes back as it was. Tapping an enemy in the pen (view pieceClick with `enemyKey` / a `preview` enemy unit) opens its
|
||||
// detail card; the old enemy list lives on as the 敌方情报 tab of the 本局信息 dialog (left 🔍).
|
||||
// Every camera request of this screen goes through setCam (it remembers the camera the pen returns to);
|
||||
// `.gm[data-camera]` mirrors the current camera kind (E2E / styling).
|
||||
// `.gm[data-camera]` mirrors the current camera kind (E2E / styling). Folding the shop bar (收起) on the own prep board
|
||||
// flies the camera to the official shop-collapsed framing and unfolding back to the shop camera (public issue #5:
|
||||
// gameLogic prepCameraFor / foldCamera — never over the pen, a teammate's board or a battle; deferred while a piece is
|
||||
// dragged or its direction is chosen).
|
||||
// Equipment dropped on an operator whose two slots are used opens the equip-replace dialog (ui/equipReplace.js): the
|
||||
// player picks the equipped item to destroy → g.equip {itemUid, targetUid, replaceUid}; 取消 sends nothing. 销毁 is only
|
||||
// offered for loose items (equipped ones are locked, ui/facing.js itemDestroyable).
|
||||
@@ -88,7 +91,8 @@ import {
|
||||
phaseMode, phaseBanner, isCombatPhase, showDeadPill, isBossPhase, placementContext, canPlace, boardTargets, dropIntent,
|
||||
snapHud, activeBubbles, shortcutFor, shortcutBlocked, closesOnFieldPress, phaseTotalSeconds, homeFieldId, ownFieldId, normalizeSp, sortedPlayers,
|
||||
countdownState, shopBlockReason, stageOverrides, effectiveStage, watchTarget, dropFailureReason,
|
||||
previewEnemyKey, prepCamera, deployFieldOf, panelSide, panelSlots, bondPopupPlace, chessLoadout, unitLoadout, mergeTarget, modeOffBonds,
|
||||
previewEnemyKey, prepCamera, prepCameraFor, foldCamera, deployFieldOf, panelSide, panelSlots, bondPopupPlace, chessLoadout, unitLoadout,
|
||||
mergeTarget, modeOffBonds,
|
||||
} from '../ui/gameLogic.js';
|
||||
import { toast } from '../ui/toasts.js';
|
||||
import { BriefingScreen } from './briefing.js';
|
||||
@@ -225,6 +229,7 @@ function MatchScreen() {
|
||||
const home = homeFieldId(pub, myId);
|
||||
const watchingOther = !!watching && watching !== home && watching !== ownFieldId(myId);
|
||||
const editable = phase === PHASE.PREP && !!priv && alive && !priv.ready && !watchingOther;
|
||||
const showShop = !!priv && alive && (phase === PHASE.PREP || phase === PHASE.SP_DRAFT || phase === PHASE.ROUND_START) && !watchingOther;
|
||||
const layersDisabled = phase === PHASE.UNITE || isBossPhase(phase);
|
||||
const sp = phase === PHASE.SP_DRAFT ? normalizeSp(pub?.sp, players) : null;
|
||||
const total = phaseTotalSeconds(pub, gd.config, myId);
|
||||
@@ -274,6 +279,9 @@ function MatchScreen() {
|
||||
const prepCamSeen = useRef(prepCamKey); // the prep camera last requested
|
||||
const camRef = useRef({ kind: 'prep', opts: { rect: { ...GEO.NORMAL_RECT }, side: 'L' } });
|
||||
const penRef = useRef({ on: false, collapsed: false });
|
||||
// the shop bar is shown folded (收起; the pen folds it for itself: the player's own state is the one it returns to) —
|
||||
// the own prep board then takes the official shop-collapsed camera (public issue #5, gameLogic prepCameraFor)
|
||||
const shopFolded = showShop && (pen ? penRef.current.collapsed : collapsed);
|
||||
const cancelFacingRef = useRef(() => {});
|
||||
const setCam = useCallback((kind, opts) => {
|
||||
camRef.current = { kind, opts: opts || {} };
|
||||
@@ -360,7 +368,8 @@ function MatchScreen() {
|
||||
// the battle we just left (or whatever was stored before mount) must not be re-entered next combat;
|
||||
// an m.field that arrives during prep (the upcoming battle) is a new object and will be entered
|
||||
staleFieldRef.current = field;
|
||||
setCam(prepCam.kind, prepCam.opts);
|
||||
const pc = prepCameraFor(pub, myId, shopFolded);
|
||||
setCam(pc.kind, pc.opts);
|
||||
prepCamSeen.current = prepCamKey;
|
||||
viewModeRef.current = 'prep';
|
||||
lastFieldRef.current = null;
|
||||
@@ -483,9 +492,23 @@ function MatchScreen() {
|
||||
if (!view || viewModeRef.current !== 'prep' || !showPrep) return;
|
||||
if (live.current.facing) cancelFacingRef.current();
|
||||
setSel(null);
|
||||
setCam(prepCam.kind, prepCam.opts);
|
||||
const pc = prepCameraFor(pub, myId, shopFolded);
|
||||
setCam(pc.kind, pc.opts);
|
||||
}, [view, prepCamKey, showPrep]);
|
||||
|
||||
// the shop bar folded (收起) or unfolded on the own prep board (public issue #5: the board did not grow, while the
|
||||
// battle and scouting views had no bar to make room for): the official shop-collapsed camera or the shop camera
|
||||
// again, flown like any camera change. Never over the pen or a teammate's board or in battle; deferred while a piece
|
||||
// is dragged or its direction is chosen (the drop target and the wheel sit on tiles of the camera in use) — this
|
||||
// effect runs again when that ends (gameLogic foldCamera). Picking reads the camera of every frame (render/pick.js).
|
||||
useEffect(() => {
|
||||
const next = foldCamera({
|
||||
pub, myId, folded: shopFolded, ownPrep: !!view && viewModeRef.current === 'prep' && showPrep,
|
||||
pen, busy: !!drag || !!facing, current: camRef.current,
|
||||
});
|
||||
if (next) setCam(next.kind, next.opts);
|
||||
}, [view, shopFolded, showPrep, pen, !!drag, !!facing, prepCamKey]);
|
||||
|
||||
// 联防 / 最终攻势: the ‹ › pill moves the camera between the field's halves and 全景 (research 09 §3.1)
|
||||
useEffect(() => {
|
||||
if (!view || !field || !field.local || !isCombatPhase(phase)) return;
|
||||
@@ -1050,7 +1073,6 @@ function MatchScreen() {
|
||||
// ---- render ---------------------------------------------------------------------------------------------------
|
||||
const readyCount = players.filter((p) => p.ready || p.status === 'ready').length;
|
||||
const aliveCount = players.filter((p) => p.alive !== false && p.status !== 'left').length;
|
||||
const showShop = !!priv && alive && (phase === PHASE.PREP || phase === PHASE.SP_DRAFT || phase === PHASE.ROUND_START) && !watchingOther;
|
||||
// the own battle is over: the server says so (status done) or — client-side combat — the local simulation just ended
|
||||
const localDone = cc && !!battleState && battleState.own && !battleState.watch && battleState.done && battleState.fieldId === ownFieldId(myId);
|
||||
// (client-side combat: only in 各自行动 — 联防 observers just watch the 联防 field, research 09 §3.1)
|
||||
|
||||
+88
-18
@@ -1,6 +1,8 @@
|
||||
// Field view loader: mounts the Pixi render engine (public/js/render/app.js → createFieldView, DESIGN §9)
|
||||
// into a host element, falling back to the DOM view (fallbackField.js) when the engine is missing, times
|
||||
// out or throws. Every call into the view goes through a guard so a render bug can never crash the HUD.
|
||||
// The shared asset store gets the game data's copy of the asset manifest (`seedAssets`, public issue #8 item 5), and the
|
||||
// prep cameras the HUD bands they keep clear (`hudBands`; the folded shop's band: public issue #5).
|
||||
//
|
||||
// `?render=fallback` (or globalThis.__SP_RENDER__ = 'fallback') skips the engine (dev / mock harness);
|
||||
// `?render=engine` never falls back silently (errors are logged and the fallback still mounts).
|
||||
@@ -43,41 +45,85 @@ export function hudPadding(kind, size) {
|
||||
* 2.15rem measured) and the shop bar's top edge above the viewport's bottom (css/screens/game-shop.css .shopbar
|
||||
* bottom .2rem + .shopbar__row padding .1rem ×2 + card height 2.24rem, plus its 2 px + 1 px borders). The shop bar
|
||||
* sits on the viewport's bottom edge even on a notched phone (css/devices.css, DESIGN §18.1).
|
||||
* The folded shop (public issue #5): the tab's top edge (.shopbar-tab bottom .2rem + padding .08rem ×2 + its .44rem
|
||||
* button, plus its 2 px + 1 px borders; it sits inside the HUD layer, above the bottom safe-area inset) and the corner
|
||||
* buttons' top edge (css/screens/game.css .gm__corner bottom .24rem + a .56rem row — the fallback when the corner
|
||||
* cannot be measured: on phones its buttons grow to 34 px and below 768 CSS px of width they wrap into two rows,
|
||||
* css/devices.css).
|
||||
*/
|
||||
export const HUD_REM = Object.freeze({ bondStripBottom: 2.16, shopBarTop: 2.64, shopBarBorderPx: 3 });
|
||||
export const HUD_REM = Object.freeze({
|
||||
bondStripBottom: 2.16, shopBarTop: 2.64, shopBarBorderPx: 3, shopTabTop: 0.8, shopTabBorderPx: 3, cornerTop: 0.8,
|
||||
});
|
||||
|
||||
/**
|
||||
* CSS px of HUD along the top edge (top bar + bond strip) and the bottom edge (the shop bar) of the viewport during
|
||||
* prep — the own board ('prep') or the Final Assault half ('bossPrep'); null for every other camera. The prep camera
|
||||
* keeps the bench / temp rows and the field's back row clear of them (render/projection.js clearHud; user playtest
|
||||
* #5 item 9: the rem floor of 40 px makes the HUD relatively taller on phones in landscape and the shop bar covered
|
||||
* the bench). The prep camera is the shop camera whether or not the bar is collapsed, so the band assumes the bar —
|
||||
* also for an eliminated player's own board (no shop bar: the band only costs size there, while a camera following
|
||||
* the bar's presence would have to re-frame whenever it appears, e.g. when the private state arrives after the prep
|
||||
* camera was set). Scouting a teammate's board uses the 'normal' camera: no band. An armed shop card (two-tap buy,
|
||||
* css/screens/game-shop.css .scard.is-armed) rises 4 px above the bar's top on a phone and covers the bench pads'
|
||||
* near corners by ≈ 3 px while it stays armed — less than under the unchanged official camera at 1920×1080 (13 px
|
||||
* above the bar, ≈ 11 px over the pads).
|
||||
* CSS px of HUD along the top edge (top bar + bond strip) and the bottom edge of the viewport during prep — the own
|
||||
* board ('prep') or the Final Assault half ('bossPrep'); null for every other camera. The prep camera keeps the bench /
|
||||
* temp rows and the field's back row clear of them (render/projection.js clearHud; user playtest #5 item 9: the rem
|
||||
* floor of 40 px makes the HUD relatively taller on phones in landscape and the shop bar covered the bench).
|
||||
* The bottom band is the shop bar's — also for an eliminated player's own board (no shop bar: the band only costs size
|
||||
* there, while a camera following the bar's presence would have to re-frame whenever it appears, e.g. when the private
|
||||
* state arrives after the prep camera was set) — unless `opts.shop === false`: the player folded the shop (收起) and the
|
||||
* own prep board moved to the official shop-collapsed camera (public issue #5: the board did not grow; screens/game.js
|
||||
* asks for it only while a folded bar is shown). Its band is the higher of the folded tab (bottom right) and the corner
|
||||
* buttons' hit areas (交流 / ⚙ / 📖 / ⛶, bottom left, measured by `cornerBand`: on a narrow phone two rows of 34 px buttons
|
||||
* with 44 px touch areas) — under the official collapsed camera the bench's left pads reach under the corner, and with
|
||||
* the tab alone it would cover bench pad 0 at the user's 756×366 Android and the near edge of pads 0–2 at 844×390; both
|
||||
* are 1-D bands like the bar's, so every bench pad stays fully pressable. Scouting a
|
||||
* teammate's board uses the 'normal' camera: no band. An armed shop card (two-tap buy, css/screens/game-shop.css
|
||||
* .scard.is-armed) rises 4 px above the bar's top on a phone and covers the bench pads' near corners by ≈ 3 px while it
|
||||
* stays armed — less than under the unchanged official camera at 1920×1080 (13 px above the bar, ≈ 11 px over the pads).
|
||||
* @param {string} kind
|
||||
* @param {{ width: number, height: number }} size
|
||||
* @param {{ shop?: boolean }} [opts] `shop: false` = the folded shop's band
|
||||
* @returns {{ top: number, bottom: number }|null}
|
||||
*/
|
||||
export function hudBands(kind, size) {
|
||||
export function hudBands(kind, size, opts) {
|
||||
if (kind !== 'prep' && kind !== 'bossPrep') return null;
|
||||
const folded = !!opts && opts.shop === false;
|
||||
const h = size?.height || 1080;
|
||||
let rem = 100;
|
||||
let safeTop = 0;
|
||||
let safeBottom = 0;
|
||||
let corner = 0;
|
||||
try {
|
||||
rem = parseFloat(getComputedStyle(document.documentElement).fontSize) || 100;
|
||||
// the HUD layer starts below the top safe-area inset (css/devices.css .gm__hud)
|
||||
safeTop = Math.max(0, document.querySelector('.gm__hud')?.getBoundingClientRect().top || 0);
|
||||
// the HUD layer starts below the top safe-area inset and ends above the bottom one (css/devices.css .gm__hud)
|
||||
const hud = document.querySelector('.gm__hud')?.getBoundingClientRect();
|
||||
safeTop = Math.max(0, hud?.top || 0);
|
||||
if (folded) {
|
||||
if (hud && hud.bottom > 0) safeBottom = Math.max(0, h - hud.bottom);
|
||||
corner = cornerBand(h);
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
const h = size?.height || 1080;
|
||||
const bottom = folded
|
||||
? Math.max(safeBottom + rem * HUD_REM.shopTabTop + HUD_REM.shopTabBorderPx, corner || safeBottom + rem * HUD_REM.cornerTop)
|
||||
: rem * HUD_REM.shopBarTop + HUD_REM.shopBarBorderPx;
|
||||
return {
|
||||
top: Math.min(h * 0.4, safeTop + rem * HUD_REM.bondStripBottom),
|
||||
bottom: Math.min(h * 0.4, rem * HUD_REM.shopBarTop + HUD_REM.shopBarBorderPx),
|
||||
bottom: Math.min(h * 0.4, bottom),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* CSS px from the viewport's bottom edge (`h` high) up to the top of the corner buttons' hit areas (交流 / ⚙ / 📖 / ⛶,
|
||||
* .gm__corner), 0 when they are not on the page. On a touch screen every one of them takes taps from an invisible area
|
||||
* of at least --tap-min (44 px) centred on it (css/devices.css touch targets), 5 px above a 34 px phone button — a bench
|
||||
* pad edge there is not pressable.
|
||||
* @param {number} h
|
||||
*/
|
||||
function cornerBand(h) {
|
||||
const btns = document.querySelectorAll('.gm__corner .gm__gear, .gm__corner .ewheel__btn');
|
||||
if (!btns || !btns.length) return 0;
|
||||
const root = document.documentElement;
|
||||
const tap = root?.classList?.contains('sp-coarse') ? parseFloat(getComputedStyle(root).getPropertyValue('--tap-min')) || 0 : 0;
|
||||
let top = Infinity;
|
||||
for (const b of btns) {
|
||||
const r = b.getBoundingClientRect();
|
||||
if (r && r.height > 0) top = Math.min(top, r.top + r.height / 2 - Math.max(r.height, tap) / 2);
|
||||
}
|
||||
return top < Infinity ? Math.max(0, h - top) : 0;
|
||||
}
|
||||
|
||||
function renderPref() {
|
||||
try {
|
||||
const q = new URLSearchParams(globalThis.location?.search || '').get('render');
|
||||
@@ -116,6 +162,27 @@ export function guardView(view, kind) {
|
||||
return safe;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hand the game data's copies of /data/assets.json and /data/local-assets.json (data.js 'assets' / 'local': the match
|
||||
* screen waits for them, gameComponents GAME_FILES) to the asset store, now or when they land: it then never downloads
|
||||
* the manifest a second time — after a page reload (a phone browser discarding a background tab, Chrome's Memory Saver)
|
||||
* that second download was the one createFieldView waited ≤ 4 s for, and a slow or failed one left every operator the
|
||||
* image-less placeholder (public issue #8 item 5). Without a copy the store fetches and retries by itself.
|
||||
* @param {{ seed?: (m: any) => boolean, seedLocal?: (m: any) => boolean }} store public/js/assets.js store
|
||||
*/
|
||||
export function seedAssets(store) {
|
||||
if (!store) return;
|
||||
const give = (name, fn) => {
|
||||
if (typeof fn !== 'function') return;
|
||||
const now = data.get(name);
|
||||
if (now) { fn(now); return; }
|
||||
// still loading (or failed): adopt it if it lands; a later success of the store's own fetch makes this a no-op
|
||||
Promise.resolve(data.load(name)).then((m) => { if (m) fn(m); }, () => {});
|
||||
};
|
||||
give('assets', store.seed?.bind(store));
|
||||
give('local', store.seedLocal?.bind(store));
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a field view in `host`: the render engine when available, else the DOM fallback.
|
||||
* @param {HTMLElement} host
|
||||
@@ -128,7 +195,10 @@ export async function mountFieldView(host) {
|
||||
try {
|
||||
// the shared asset store (public/js/assets.js) keeps its Spine cache across remounts (next match, reconnect)
|
||||
const am = await withTimeout(import('../assets.js'), LOAD_TIMEOUT_MS, 'asset store import').catch(() => null);
|
||||
if (am?.assets && typeof am.assets.ready === 'function') opts.assets = am.assets;
|
||||
if (am?.assets && typeof am.assets.ready === 'function') {
|
||||
opts.assets = am.assets;
|
||||
seedAssets(am.assets);
|
||||
}
|
||||
const mod = await withTimeout(import('../render/app.js'), LOAD_TIMEOUT_MS, 'render engine import');
|
||||
if (typeof mod?.createFieldView !== 'function') throw new Error('createFieldView missing');
|
||||
const view = await withTimeout(Promise.resolve(mod.createFieldView(host, opts)), LOAD_TIMEOUT_MS, 'createFieldView');
|
||||
|
||||
@@ -91,6 +91,37 @@ export function prepCamera(pub, myId) {
|
||||
return { kind: 'bossPrep', opts: { side: idx % 2 === 1 ? 'R' : 'L' } };
|
||||
}
|
||||
|
||||
/**
|
||||
* The own prep board's camera request with the shop bar's state (public issue #5 "准备阶段,收起商店界面时,界面并不会进行
|
||||
* 缩放"): `prepCamera` plus `shop: !folded` — a folded shop (收起) takes the official shop-collapsed camera
|
||||
* (configBlackBoard left_prepare_camera_param / *_boss_prepare_*, render/projection.js presetCamera `shop: false`) and
|
||||
* the folded shop's HUD band (ui/fieldHost.js hudBands), an open one the shop camera. `folded` = the shop bar is shown
|
||||
* and folded (an eliminated player's board, which shows no bar, keeps the shop camera).
|
||||
* @returns {{ kind: 'prep'|'bossPrep', opts: { rect?: object, side: 'L'|'R', shop: boolean } }}
|
||||
*/
|
||||
export function prepCameraFor(pub, myId, folded = false) {
|
||||
const c = prepCamera(pub, myId);
|
||||
return { kind: c.kind, opts: { ...c.opts, shop: !folded } };
|
||||
}
|
||||
|
||||
/**
|
||||
* The camera request a fold / unfold of the shop bar needs right now (public issue #5), or null: only while the own prep
|
||||
* board is on screen (`ownPrep`: prep, not a teammate's scouted board, not a battle), never over the enemy pen (`pen`: it
|
||||
* folds the shop itself and returns to the camera it left), deferred while a piece is dragged or its direction is chosen
|
||||
* (`busy`: the drop target and the wheel sit on tiles of the camera in use — the caller asks again once that ends), and
|
||||
* nothing when the current request (`current` { kind, opts }) already has that shop state.
|
||||
* @param {{ pub: any, myId: string|null, ownPrep: boolean, folded: boolean, pen?: boolean, busy?: boolean,
|
||||
* current?: { kind: string, opts?: { shop?: boolean } }|null }} s
|
||||
* @returns {{ kind: 'prep'|'bossPrep', opts: object }|null}
|
||||
*/
|
||||
export function foldCamera(s) {
|
||||
if (!s || !s.ownPrep || s.pen || s.busy) return null;
|
||||
const want = prepCameraFor(s.pub, s.myId, !!s.folded);
|
||||
const cur = s.current;
|
||||
if (cur && cur.kind === want.kind && (cur.opts?.shop !== false) === want.opts.shop) return null;
|
||||
return want;
|
||||
}
|
||||
|
||||
/**
|
||||
* The field the own pieces are deployed on (server/match/Match.js deployFieldOf): 'bossL' / 'bossR' in the prep of a
|
||||
* boss round (the same pairing as `prepCamera`), else 'normal'. Placement legality (`placementContext` → `deployMap`
|
||||
|
||||
Reference in new issue
Block a user