feedback3: merge W3D — 折桠's skill idle (community report #23), the ammo bar's real total (#35), unit sounds at the official chance and volume (#30), shots from the hands to the chest (GitHub #61), stuck model loads restarted (GitHub #68), the boss leader shown in prep with its hit tiles (#12)
This commit is contained in:
commit
a57c2a85e2
26 files changed
+1105
-89
No files matched your search
+103
-28
@@ -19,6 +19,9 @@
|
||||
// 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 })`.
|
||||
// A load that may never settle (GitHub #68: begun in a hidden tab, still pending when the tab came back) is started again
|
||||
// by its view (`spine.restart(entry)`, RefLru.restart); such a fresh load — and any load after a failure — drops PIXI's
|
||||
// still-pending requests of that skeleton first (forgetPendingSpine), so it does not join them.
|
||||
//
|
||||
// Pure at import time: no PIXI, no DOM access until a loader actually runs (Node tests import this file).
|
||||
//
|
||||
@@ -237,7 +240,11 @@ export function unitSfxUrl(m, id, kind, skillIndex) {
|
||||
* 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 — 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).
|
||||
* retrying a failed or timed-out model: render/units.js). `restart(key, arg)` starts a load still in flight again (GitHub
|
||||
* #68: one begun in a hidden tab may never settle): a new attempt of the same entry — its refs and the promise its
|
||||
* holders wait on stay —, the earlier attempt is abandoned (its concurrency slot freed, a late result ignored: PIXI shares
|
||||
* its resources by URL, so nothing is unloaded), the first attempt to succeed settles the entry and only the newest
|
||||
* attempt's failure fails it. A load after a failure and a restart are `fresh` (`load(key, arg, { fresh: true })`).
|
||||
* 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 and no scene holds the cache (`hold()`) for `quietDelay` ms (no scene on screen; the instant zero
|
||||
@@ -305,29 +312,14 @@ export class RefLru {
|
||||
*/
|
||||
acquire(key, arg, opts) {
|
||||
let e = this.map.get(key);
|
||||
if (e && e.state === 'failed' && (this.now() - e.failedAt > this.failTtl || (opts && opts.retry && e.refs === 0))) { this.map.delete(key); e = null; }
|
||||
let fresh = false;
|
||||
if (e && e.state === 'failed' && (this.now() - e.failedAt > this.failTtl || (opts && opts.retry && e.refs === 0))) { this.map.delete(key); e = null; fresh = true; }
|
||||
if (!e) {
|
||||
e = { key, promise: null, value: null, state: 'loading', refs: 0, used: ++this._tick, weight: 0, idleSince: null };
|
||||
e = { key, promise: null, value: null, state: 'loading', refs: 0, used: ++this._tick, weight: 0, idleSince: null, attempt: 0, abort: null, settle: null };
|
||||
this.map.set(key, e);
|
||||
// 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) { 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._requestEvict();
|
||||
return v;
|
||||
},
|
||||
(err) => {
|
||||
e.state = 'failed'; e.error = err; e.failedAt = this.now();
|
||||
throw err;
|
||||
},
|
||||
);
|
||||
e.promise = new Promise((resolve, reject) => { e.settle = { resolve, reject }; });
|
||||
e.promise.catch(() => {});
|
||||
this._attempt(e, key, arg, fresh);
|
||||
}
|
||||
e.refs++;
|
||||
this._refs++;
|
||||
@@ -337,6 +329,51 @@ export class RefLru {
|
||||
return e.promise;
|
||||
}
|
||||
|
||||
/**
|
||||
* Start the load of `key` again while it is still in flight (see the header); false when it is not loading (ready,
|
||||
* failed or unknown).
|
||||
*/
|
||||
restart(key, arg) {
|
||||
const e = this.map.get(key);
|
||||
if (!e || e.state !== 'loading' || !e.settle) return false;
|
||||
this._attempt(e, key, arg, true);
|
||||
return true;
|
||||
}
|
||||
|
||||
/** One load attempt of entry `e` (see the header): the first success settles it, only the newest attempt's failure. */
|
||||
_attempt(e, key, arg, fresh) {
|
||||
const n = ++e.attempt;
|
||||
if (e.abort) { const abort = e.abort; e.abort = null; abort(); }
|
||||
// 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);
|
||||
const go = () => this._load(key, arg, fresh ? { fresh: true } : undefined);
|
||||
return pending ? pending.then(go) : go();
|
||||
};
|
||||
const run = () => (n === e.attempt
|
||||
? this._withTimeout(load(), key, (abort) => { if (n === e.attempt) e.abort = abort; })
|
||||
: Promise.reject(new Error(`load superseded: ${key}`)));
|
||||
this._schedule(run).then(
|
||||
(v) => {
|
||||
const s = e.settle;
|
||||
if (!s) return; // settled by another attempt: a late duplicate (PIXI shares its resources by URL — keep them)
|
||||
e.settle = null; e.abort = null;
|
||||
if (this.map.get(key) !== e) { this._unloadNow(key, v, e); s.resolve(v); return; }
|
||||
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._requestEvict();
|
||||
s.resolve(v);
|
||||
},
|
||||
(err) => {
|
||||
const s = e.settle;
|
||||
if (!s || n !== e.attempt) return; // an abandoned attempt: the newest one decides
|
||||
e.settle = null; e.abort = null;
|
||||
e.state = 'failed'; e.error = err; e.failedAt = this.now();
|
||||
s.reject(err);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/** Drop one reference (never below 0). */
|
||||
release(key) {
|
||||
const e = this.map.get(key);
|
||||
@@ -477,11 +514,14 @@ export class RefLru {
|
||||
});
|
||||
}
|
||||
|
||||
_withTimeout(p, key) {
|
||||
if (!(this.timeout > 0)) return p;
|
||||
/** `p` bounded by the timeout; `onAbort(abort)` receives a function that rejects it at once (a restart). */
|
||||
_withTimeout(p, key, onAbort) {
|
||||
if (!(this.timeout > 0) && !onAbort) return p;
|
||||
return new Promise((resolve, reject) => {
|
||||
const t = setTimeout(() => reject(new Error(`load timeout: ${key}`)), this.timeout);
|
||||
p.then((v) => { clearTimeout(t); resolve(v); }, (err) => { clearTimeout(t); reject(err); });
|
||||
const t = this.timeout > 0 ? setTimeout(() => reject(new Error(`load timeout: ${key}`)), this.timeout) : null;
|
||||
const stop = () => { if (t !== null) clearTimeout(t); };
|
||||
if (onAbort) onAbort(() => { stop(); reject(new Error(`load restarted: ${key}`)); });
|
||||
p.then((v) => { stop(); resolve(v); }, (err) => { stop(); reject(err); });
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -506,15 +546,40 @@ export function loadImageElement(url) {
|
||||
}
|
||||
|
||||
/** Default Spine loader: PIXI.Assets.load(skel) → spineData (needs globalThis.PIXI + PIXI.spine). */
|
||||
export async function loadSpineData(entry) {
|
||||
export async function loadSpineData(entry, opts) {
|
||||
const PIXI = globalThis.PIXI;
|
||||
if (!PIXI || !PIXI.Assets || !PIXI.spine) throw new Error('PIXI / pixi-spine not loaded');
|
||||
if (opts && opts.fresh) forgetPendingSpine(entry, opts.keep);
|
||||
const res = await PIXI.Assets.load(entry.skel);
|
||||
const data = res && (res.spineData || res);
|
||||
if (!data || !Array.isArray(data.animations)) throw new Error(`bad spine data: ${entry.skel}`);
|
||||
return data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Forget PIXI's requests of a skeleton that never finished loading — its .skel is not in `PIXI.Assets.cache` — so a fresh
|
||||
* load asks for them again instead of joining them (GitHub #68: a request begun in a hidden tab may never settle, and
|
||||
* `PIXI.Assets.load` of the same URL hands out the same pending promise): the .skel's and, unless the atlas finished
|
||||
* (then its pages did too), the .atlas's and its pages' entries of the loader's promise cache. Pages in `keep` (another
|
||||
* cached skeleton's) stay. Returns how many were dropped; best effort (0 without that cache).
|
||||
* @param {object} entry manifest spine entry { skel, atlas?, textures? }
|
||||
* @param {Set<string>} [keep]
|
||||
*/
|
||||
export function forgetPendingSpine(entry, keep) {
|
||||
const PIXI = globalThis.PIXI;
|
||||
const cache = PIXI && PIXI.Assets && PIXI.Assets.loader && PIXI.Assets.loader.promiseCache;
|
||||
const skel = entry && entry.skel;
|
||||
if (!skel || !cache || typeof cache !== 'object') return 0;
|
||||
const done = (u) => { try { return !!PIXI.Assets.cache?.has?.(u); } catch { return true; } };
|
||||
if (done(skel)) return 0;
|
||||
const atlas = (typeof entry.atlas === 'string' && entry.atlas) || skel.replace(/\.skel$/, '.atlas');
|
||||
const urls = (done(atlas) ? [skel] : [skel, atlas, ...spinePages(entry)]).filter((u) => !(keep && keep.has(u)));
|
||||
let n = 0;
|
||||
// the loader keys its cache by the resolved URL: the manifest path itself, or that path made absolute
|
||||
for (const key of Object.keys(cache)) if (urls.some((u) => key === u || (u.startsWith('/') && key.endsWith(u)))) { delete cache[key]; n++; }
|
||||
return n;
|
||||
}
|
||||
|
||||
/**
|
||||
* Estimated memory (bytes) held by a parsed skeleton (pixi-spine 3.8 SkeletonData): its typed/plain arrays (mesh
|
||||
* vertices/UVs/triangles, timeline frames/curves, deform frames, draw orders) plus a flat cost per object. A parsed
|
||||
@@ -722,7 +787,15 @@ export function createAssets(options) {
|
||||
|
||||
const spineEntries = new Map(); // skel URL → manifest entry (for the atlas / page URLs on unload)
|
||||
const spine = new RefLru({
|
||||
load: (key, entry) => { spineEntries.set(key, entry); return (opts.loadSpine || loadSpineData)(entry); },
|
||||
load: (key, entry, o) => {
|
||||
spineEntries.set(key, entry);
|
||||
if (!(o && o.fresh)) return (opts.loadSpine || loadSpineData)(entry);
|
||||
// a fresh load (a restart, or after a failure): not joined to a request that may never settle — but the pages
|
||||
// another cached skeleton uses stay (forgetPendingSpine `keep`)
|
||||
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);
|
||||
return (opts.loadSpine || loadSpineData)(entry, { fresh: true, keep });
|
||||
},
|
||||
unload: (key, value, rec) => {
|
||||
// a late value of an entry that was dropped while loading, with the key already loading/cached again: the
|
||||
// PIXI caches are shared by URL, so unloading would pull the resources out from under the new entry
|
||||
@@ -848,10 +921,12 @@ export function createAssets(options) {
|
||||
},
|
||||
/**
|
||||
* 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).
|
||||
* restart(entry) → bool (a load still in flight starts again: RefLru.restart); release(entry); peek(entry);
|
||||
* hold() → release function (a scene that will need its skeletons again: RefLru).
|
||||
*/
|
||||
spine: {
|
||||
acquire: (entry, o) => (validSpine(entry) ? spine.acquire(entry.skel, entry, o) : Promise.reject(new Error('no spine entry'))),
|
||||
restart: (entry) => (validSpine(entry) ? spine.restart(entry.skel, entry) : false),
|
||||
release: (entry) => { if (entry && entry.skel) spine.release(entry.skel); },
|
||||
peek: (entry) => (entry && entry.skel ? spine.peek(entry.skel) : null),
|
||||
hold: () => spine.hold(),
|
||||
|
||||
+36
-4
@@ -4,7 +4,7 @@
|
||||
// bgm { lobby, prep, combat, boss: { intro?, loop } }, bossBgm { [bossId]: { intro?, loop } },
|
||||
// sfx.ui { click, buy, sell, refresh, freeze, levelup, merge, equip, ready, timer, yourTurn, … },
|
||||
// sfx.battle { deploy, tokenDeploy, charDie, tokenDie?, enemyDie, enemyHit, heal, killCoin, … },
|
||||
// sfx.units { [charId|tokenId|enemyId]: { attack?, hit?, skill?, die?, born? } }.
|
||||
// sfx.units { [charId|tokenId|enemyId]: { attack?, hit?, skill?, die?, born?, mix?: { [role]: { p?, vol? } } } }.
|
||||
//
|
||||
// - The AudioContext is created on the first user gesture (pointerdown/keydown/touchend), so browsers
|
||||
// never block or warn; everything requested before that is remembered (BGM) or dropped (SFX).
|
||||
@@ -22,6 +22,10 @@
|
||||
// sound of that target. An operator's attack / hit sound that is a skill-mode file of its own (official names end in
|
||||
// `_n` for the normal attack, `_d` / `_h` / `_s` for its skill modes — the manifest picked 纯烬艾雅法拉's S3 impact
|
||||
// p_imp_gtshpbrnch_s as her `hit`) never plays for a normal attack (normalAttackSfx).
|
||||
// - The official bank mix of a unit's own attack / hit / die / born sound (`mix`, tools/assets/audio.mjs bankMix; community
|
||||
// report #30): it plays with chance `p` — 猎狗pro / 深池侦察犬's attack bank is 80 % silence, so they bark on about one
|
||||
// attack in five (never replaced by the generic enemy sound) — at its base gain × `vol`, capped at 1: an official volume
|
||||
// below 1 is quieter (妖怪's 0.7), none is louder than before (unitGain).
|
||||
// - Deaths/deployments follow the official per-class defaults (unitSoundClass): only operators play the
|
||||
// operator-knocked-down sound; summons use the token sounds; a summon used up by its own effect (fx `consumed`,
|
||||
// 香槟炸弹) plays its impact sound instead of a death sound.
|
||||
@@ -163,6 +167,26 @@ export function normalAttackSfx(defId, url) {
|
||||
return typeof url === 'string' && !(typeof defId === 'string' && defId.startsWith('char_') && SKILL_MODE_FILE.test(url));
|
||||
}
|
||||
|
||||
/**
|
||||
* Gain of a unit's own sound with its manifest mix (sfx.units[id].mix[role]: the official bank's volume): `base` × `vol`,
|
||||
* never above `base` (a bank louder than 1 plays as before — community report #30 asked for quieter, not louder).
|
||||
* @param {number} base the role's base gain (attack / hit 0.55, die / born / skill 0.8)
|
||||
* @param {{ vol?: number }|null|undefined} mix
|
||||
*/
|
||||
export function unitGain(base, mix) {
|
||||
const v = mix && Number(mix.vol);
|
||||
return Number.isFinite(v) && v >= 0 ? base * Math.min(1, v) : base;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a unit's own sound plays this time: its official bank's chance `mix.p` (sounds with a file over all the weights;
|
||||
* 猎狗pro's attack bank 20 of 100). `roll` ∈ [0, 1).
|
||||
*/
|
||||
export function unitSoundPlays(mix, roll) {
|
||||
const p = mix && Number(mix.p);
|
||||
return !(Number.isFinite(p) && p >= 0 && p < 1) || roll < p;
|
||||
}
|
||||
|
||||
/** Concurrency + cooldown gate for battle SFX. Pure (time is passed in). */
|
||||
/** Gestures that may unlock audio: iOS Safari only accepts touchend / click / keydown; pointerdown covers the rest. */
|
||||
const UNLOCK_EVENTS = ['pointerdown', 'touchend', 'click', 'keydown'];
|
||||
@@ -236,6 +260,7 @@ export class AudioManager {
|
||||
*/
|
||||
constructor(opts = {}) {
|
||||
this.getManifest = typeof opts.getManifest === 'function' ? opts.getManifest : () => null;
|
||||
this.random = typeof opts.random === 'function' ? opts.random : Math.random; // a unit sound's chance (mix.p)
|
||||
this.win = opts.win ?? (typeof window !== 'undefined' ? window : null);
|
||||
this.ctx = null;
|
||||
this.master = null;
|
||||
@@ -570,7 +595,10 @@ export class AudioManager {
|
||||
const url = typeof own === 'string' ? own : u?.[kind];
|
||||
if (typeof url !== 'string') return false;
|
||||
if ((kind === 'attack' || kind === 'hit') && !normalAttackSfx(defId, url)) return false;
|
||||
this._play(url, { volume: kind === 'attack' || kind === 'hit' ? 0.55 : 0.8, limited: true, unitKey: `${unitId}:${kind}` });
|
||||
// the official bank's mix (header): a silent roll still counts as the unit's own sound (no generic fallback)
|
||||
const mix = kind === 'skill' ? null : u?.mix?.[kind];
|
||||
if (!unitSoundPlays(mix, this.random())) return true;
|
||||
this._play(url, { volume: unitGain(kind === 'attack' || kind === 'hit' ? 0.55 : 0.8, mix), limited: true, unitKey: `${unitId}:${kind}` });
|
||||
return true;
|
||||
} catch { return false; }
|
||||
}
|
||||
@@ -637,7 +665,9 @@ export class AudioManager {
|
||||
const url = deathSfxUrl(m, u, { consumed, reason: typeof e[2] === 'string' ? e[2] : null });
|
||||
if (!url) continue;
|
||||
const own = url === m?.audio?.sfx?.units?.[u.def]?.die;
|
||||
this._playUnitUrl(url, own ? `${e[1]}:die` : `die:${e[1]}`, own ? 0.8 : 0.7);
|
||||
const mix = own ? m.audio.sfx.units[u.def].mix?.die : null;
|
||||
if (!unitSoundPlays(mix, this.random())) continue;
|
||||
this._playUnitUrl(url, own ? `${e[1]}:die` : `die:${e[1]}`, own ? unitGain(0.8, mix) : 0.7);
|
||||
} else if (kind === 'deploy') {
|
||||
const u = this.units.get(e[1]);
|
||||
if (!u || u.side === 'enemy') continue;
|
||||
@@ -645,7 +675,9 @@ export class AudioManager {
|
||||
const url = deploySfxUrl(m, u);
|
||||
if (!url) continue;
|
||||
const own = url === m?.audio?.sfx?.units?.[u.def]?.born;
|
||||
this._playUnitUrl(url, own ? `${e[1]}:born` : 'deploy', own ? 0.8 : 0.5);
|
||||
const mix = own ? m.audio.sfx.units[u.def].mix?.born : null;
|
||||
if (!unitSoundPlays(mix, this.random())) continue;
|
||||
this._playUnitUrl(url, own ? `${e[1]}:born` : 'deploy', own ? unitGain(0.8, mix) : 0.5);
|
||||
} else if (kind === 'fx') {
|
||||
// a summon used up by its own effect (香槟炸弹 exploding: `consumed`): its impact sound now, no death sound
|
||||
const ex = e[4];
|
||||
|
||||
+86
-5
@@ -65,6 +65,12 @@
|
||||
// looked wrong — after 返回战场 and in every battle): the prep, battle, 联防 and boss cameras neither build its 3D
|
||||
// area nor draw its 2D rows (`boardArea` / `bandFor`), and its figures hide (`penShown`, kept for the way back); a
|
||||
// flight to / from the pen shows both while it lasts.
|
||||
// The round's leader (community report #12, owner's decision 2026-10-04; research 09 §2.2: in the official Final Assault
|
||||
// prep image the boss stands on the boss field with its HP bar): a nextEnemies entry with its spawn tile `start`
|
||||
// (render/prepfield.js leaderStand) stands there — idle, with its (full) HP bar, facing the player's half, a tap shows its
|
||||
// details like a pen enemy — instead of in the pen, shown by the boss-field prep camera only (`leaderShown`); while an
|
||||
// operator's range preview shows (a RANGE_GROUPS highlight: the direction wheel's 'facing', a selected piece's range) its
|
||||
// hit tiles (the sim's hit rectangle, render/pick.js hitTiles) are lit in red beside it (LEADER_HIT_STYLE). Display only.
|
||||
// Final Assault / Hidden Core prep (research 09 §1.2, render/prepfield.js): `view.setCamera('bossPrep', { side })`
|
||||
// (or 'prep' with a boss-row rect) shows the prep pieces on the player's half of the boss field — board rows 9–12 →
|
||||
// boss rows 2–5, bench 7 → 0, temp 8 → 1, side 'R' mirrored col c → 20 − c with RIGHT ↔ LEFT. Every public
|
||||
@@ -115,8 +121,8 @@ import { loadThree, loadBoardPack, webgl2Available, boardArtListed } from './boa
|
||||
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 { pickOnTile, pickBattle, hitRectAt } from './pick.js';
|
||||
import { IDENTITY, bossPrepField, tilesToDisp, leaderStand } from './prepfield.js';
|
||||
import { pickOnTile, pickBattle, hitRectAt, hitTiles } from './pick.js';
|
||||
import { promotionsOf } from './promote.js';
|
||||
|
||||
const VENDOR = { pixi: '/vendor/pixi.min.js', spine: '/vendor/pixi-spine.js' };
|
||||
@@ -131,6 +137,12 @@ const BOARD3D_RETRY_MS = [1200, 4000, 12000];
|
||||
const BOARD3D_STABLE_MS = 10000;
|
||||
/** Highlight groups that show a unit's range: never drawn on bench / temp pads (they are not part of any battle). */
|
||||
const RANGE_GROUPS = new Set(['facing', 'range', 'rangeStand', 'select', 'sel', 'selRange']);
|
||||
/**
|
||||
* The round leader's hit tiles, lit beside an operator's range preview in the Final Assault / Hidden Core prep (see the
|
||||
* header; community report #12 "boss受击范围可以像官方原版那样用红色"). [ASSUMED] the red and its strength: the players' request,
|
||||
* no source shows the official colour; the range preview stays orange.
|
||||
*/
|
||||
export const LEADER_HIT_STYLE = Object.freeze({ group: 'leaderHit', color: 0xff3b30, fill: 0.3, line: 0.95 });
|
||||
/** 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;
|
||||
@@ -241,6 +253,8 @@ export function fieldRows(kind) {
|
||||
|
||||
/** Are the pen's figures shown for a view kind (a camera flight shows them when either end is the pen)? */
|
||||
export const penShown = (vk, prevVk = null) => vk === 'pen' || prevVk === 'pen';
|
||||
/** Is the prep's leader on the boss field shown for a view kind (a flight shows it when either end is the boss-field prep)? */
|
||||
export const leaderShown = (vk, prevVk = null) => vk === 'bossPrep' || prevVk === 'bossPrep';
|
||||
|
||||
/**
|
||||
* 'die' reason of an operator that enters the battle already knocked out — a 联防 helper's operator down at the end of
|
||||
@@ -513,6 +527,8 @@ export async function createFieldView(host, options = {}) {
|
||||
let penList = null;
|
||||
let ownPen = null; // the own m.private.nextEnemies (fallback composition of a scouted teammate's pen)
|
||||
let camBeforePen = null; // { kind, opts } the camera the pen returns to
|
||||
let leader = null; // { key, view, stand, area } the round leader standing on the boss field in the prep (setLeader)
|
||||
let leaderHidden = true; // shown only by the boss-field prep camera (leaderShown)
|
||||
|
||||
const heightAt = (r, c) => (tiles ? tiles.heightAt(r, c) : 0);
|
||||
const ctx = {
|
||||
@@ -739,6 +755,7 @@ export async function createFieldView(host, options = {}) {
|
||||
cam = target; camFrom = camTo = null;
|
||||
tiles.setView(band, focus, field);
|
||||
setPenHidden(!penShown(vk));
|
||||
setLeaderHidden(!leaderShown(vk));
|
||||
pendingView = null;
|
||||
} else {
|
||||
camFrom = cam.clone();
|
||||
@@ -748,7 +765,8 @@ export async function createFieldView(host, options = {}) {
|
||||
tiles.setView([Math.min(prevBand[0], band[0]), Math.max(prevBand[1], band[1])], focus, [Math.min(prevField[0], field[0]), Math.max(prevField[1], field[1])]);
|
||||
board3d?.setArea(unionAreas(boardArea(prevView), boardArea(vk)));
|
||||
setPenHidden(!penShown(vk, prevView));
|
||||
pendingView = { band, focus, field, area: boardArea(vk), pen: penShown(vk) };
|
||||
setLeaderHidden(!leaderShown(vk, prevView));
|
||||
pendingView = { band, focus, field, area: boardArea(vk), pen: penShown(vk), leader: leaderShown(vk) };
|
||||
}
|
||||
return true;
|
||||
}
|
||||
@@ -768,6 +786,8 @@ export async function createFieldView(host, options = {}) {
|
||||
if (e.area === 'board' && v && typeof v.setDir === 'function') v.setDir(prepXf.dirToDisp(pieceDirOf(e.piece) || 'RIGHT'));
|
||||
}
|
||||
for (const [group, req] of hlReq) drawHighlight(req.tiles, req.style, group);
|
||||
faceLeader();
|
||||
syncLeaderHits();
|
||||
}
|
||||
|
||||
function stepCamera(now) {
|
||||
@@ -782,6 +802,7 @@ export async function createFieldView(host, options = {}) {
|
||||
tiles.setView(pendingView.band, pendingView.focus, pendingView.field);
|
||||
board3d?.setArea(pendingView.area);
|
||||
setPenHidden(!pendingView.pen);
|
||||
setLeaderHidden(!pendingView.leader);
|
||||
pendingView = null;
|
||||
tiles.project(cam, true);
|
||||
}
|
||||
@@ -1024,7 +1045,10 @@ export async function createFieldView(host, options = {}) {
|
||||
penSig = sig;
|
||||
penList = sig ? list : null;
|
||||
if (!sig) return;
|
||||
const pen = layoutPen(list, { stage: stageRec });
|
||||
// the leader with a spawn tile stands on the boss field (setLeader), not in the pen
|
||||
const stand = leaderStand(list, (k) => data.enemy(k)?.hitArea ?? null, hitTiles);
|
||||
setLeader(stand);
|
||||
const pen = layoutPen(stand ? list.filter((e) => e !== stand.entry) : list, { stage: stageRec });
|
||||
for (const f of pen.figures) {
|
||||
const rec = data.enemy(f.enemyKey);
|
||||
const rank = rec?.rank;
|
||||
@@ -1062,6 +1086,57 @@ export async function createFieldView(host, options = {}) {
|
||||
penViews.clear();
|
||||
penSig = null;
|
||||
penList = null;
|
||||
clearLeader();
|
||||
}
|
||||
|
||||
/**
|
||||
* The round's leader standing on the boss field in the Final Assault / Hidden Core prep (`stand`: prepfield.js
|
||||
* leaderStand; see the header) or none. Kept while the same leader stands on the same tile.
|
||||
*/
|
||||
function setLeader(stand) {
|
||||
const key = stand ? `${stand.entry.enemyKey}@${stand.row},${stand.col}` : null;
|
||||
if ((leader ? leader.key : null) === key) return;
|
||||
clearLeader();
|
||||
if (!stand) return;
|
||||
const k = stand.entry.enemyKey;
|
||||
const rec = data.enemy(k);
|
||||
// not a prep view: a boss shows its HP bar (full: the round has not begun)
|
||||
const v = new UnitView(ctx, {
|
||||
id: 'leader:' + k, kind: 'enemy', side: 'enemy', defId: k, enemyKey: k, preview: true, name: rec?.name || k, tier: 3, golden: false,
|
||||
spine: rec?.spine || k, avatar: rec?.iconId || rec?.avatar || k, x: stand.col, y: stand.row, facing: -1, maxHp: 1, boss: true,
|
||||
});
|
||||
v.setWorld(stand.col, stand.row, heightAt(stand.row, stand.col));
|
||||
v.fadeIn = 0;
|
||||
leader = { key, view: v, stand, area: rec?.hitArea ?? null };
|
||||
faceLeader();
|
||||
if (leaderHidden) hidePenView(v, true);
|
||||
syncLeaderHits();
|
||||
}
|
||||
/** The leader faces the player's half of the boss field (left half → left) [ASSUMED look]. */
|
||||
function faceLeader() { if (leader) leader.view.visFacing = prepXf.side === 'R' ? 1 : -1; }
|
||||
function clearLeader() {
|
||||
if (!leader) return;
|
||||
try { leader.view.destroy(); } catch { /* ignore */ }
|
||||
leader = null;
|
||||
syncLeaderHits();
|
||||
}
|
||||
function setLeaderHidden(hidden) {
|
||||
leaderHidden = !!hidden;
|
||||
if (leader) hidePenView(leader.view, leaderHidden);
|
||||
syncLeaderHits();
|
||||
}
|
||||
/** The leader's hit tiles in red while an operator's range preview shows in the boss-field prep (LEADER_HIT_STYLE). */
|
||||
function syncLeaderHits() {
|
||||
if (!tiles) return;
|
||||
const on = !!leader && !leaderHidden && mode === 'prep' && prepXf.kind === 'bossPrep' && [...hlReq.keys()].some((g) => RANGE_GROUPS.has(g));
|
||||
if (on) tiles.setHighlights(leader.stand.tiles, LEADER_HIT_STYLE, LEADER_HIT_STYLE.group);
|
||||
else tiles.clearHighlights(LEADER_HIT_STYLE.group);
|
||||
}
|
||||
/** The leader under a canvas point (its drawn body or hit area, like a battle enemy), or null. */
|
||||
function leaderAt(x, y) {
|
||||
if (!leader || leaderHidden) return null;
|
||||
const u = pickUnitOf(leader.view, true, leader.area);
|
||||
return u && pickBattle([u], groundTile(x, y), x, y) ? leader.view : null;
|
||||
}
|
||||
|
||||
/** The pen enemy under a canvas point: a figure on the tile under it (render/pick.js; ≤ 3 idle per pen tile). */
|
||||
@@ -1149,6 +1224,7 @@ export async function createFieldView(host, options = {}) {
|
||||
function clearHl(group) {
|
||||
if (group) hlReq.delete(group); else hlReq.clear();
|
||||
tiles.clearHighlights(group);
|
||||
syncLeaderHits();
|
||||
}
|
||||
|
||||
function legalTiles(piece) {
|
||||
@@ -1274,6 +1350,7 @@ export async function createFieldView(host, options = {}) {
|
||||
return;
|
||||
}
|
||||
if (drag.pointerDown(ev)) { try { canvas.setPointerCapture(e.pointerId); } catch { /* ignore */ } return; }
|
||||
if (mode === 'prep') { const lv = leaderAt(ev.x, ev.y); if (lv) { emitPenClick(lv, e); return; } }
|
||||
if (penViews.size && mode === 'prep') {
|
||||
const pv = penUnitAt(ev.x, ev.y);
|
||||
if (pv) emitPenClick(pv, e);
|
||||
@@ -1699,6 +1776,7 @@ export async function createFieldView(host, options = {}) {
|
||||
if (v.remove) { dropView(key); if (mode === 'battle') gone.add(key); }
|
||||
}
|
||||
if (!penHidden) for (const v of penViews.values()) v.update(dt, cam, clock);
|
||||
if (leader && !leaderHidden) leader.view.update(dt, cam, clock);
|
||||
tiles.update(dt);
|
||||
fx.update(dt);
|
||||
impostors.flush();
|
||||
@@ -1750,6 +1828,7 @@ export async function createFieldView(host, options = {}) {
|
||||
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?.();
|
||||
leader?.view.retryAssets?.();
|
||||
}
|
||||
const offAssets = typeof assets.onChange === 'function' ? assets.onChange(onAssets) : null;
|
||||
const onVisible = () => {
|
||||
@@ -1757,6 +1836,7 @@ export async function createFieldView(host, options = {}) {
|
||||
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?.();
|
||||
leader?.view.retryAssets?.();
|
||||
};
|
||||
globalThis.document?.addEventListener?.('visibilitychange', onVisible);
|
||||
|
||||
@@ -1790,6 +1870,7 @@ export async function createFieldView(host, options = {}) {
|
||||
const key = hlKey(st);
|
||||
if (Array.isArray(tilesList) && tilesList.length) hlReq.set(key, { tiles: tilesList, style: st }); else hlReq.delete(key);
|
||||
drawHighlight(tilesList, st, key);
|
||||
syncLeaderHits();
|
||||
return true;
|
||||
},
|
||||
on(name, fn) {
|
||||
@@ -1914,7 +1995,7 @@ 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); },
|
||||
app, get cam() { return cam; }, get board3d() { return board3d; }, tiles, views, penViews, get leader() { return leader; }, interp, fx, ctx, drag, get camKind() { return viewKind(camKind, camOpts); },
|
||||
promotions,
|
||||
// picking (render/pick.js) at canvas px: the prep piece / battle view / pen view there, the ground tile under it
|
||||
pick: { pieceAt, battleUnitAt, penUnitAt, groundTile },
|
||||
|
||||
+43
-17
@@ -120,8 +120,24 @@ const ANCHOR_SNAP = 0.75;
|
||||
const SKILL_GOLD = 0xffd45a;
|
||||
const NO_OPTS = Object.freeze({});
|
||||
const clamp = (v, lo, hi) => (v < lo ? lo : v > hi ? hi : v);
|
||||
/** World height of a unit's chest (shots start / aim there) and just above its feet (where shells land). */
|
||||
const chestZ = (v) => (v.z || 0) + (v.hover || 0) + (v._headTiles || 1.2) * 0.45;
|
||||
/**
|
||||
* Shot heights (GitHub #61): where a shot or beam leaves a unit (`launch`, ≈ its hands) and where shots, beams and lock
|
||||
* marks meet a unit (`aim`, ≈ its chest), as shares of the drawn model's height above the feet (`_headTiles`). The
|
||||
* models are upright screen billboards, so these are heights on screen, solved into world heights through the camera
|
||||
* (projection.js liftFor, `bodyZ`): 0.45 × the head height taken as a WORLD height drew at ≈ 18 % of the model under the
|
||||
* 30° pitch — shots left the hips and aimed at the targets' hips. [ASSUMED] the shares (a battle chibi's hands / chest;
|
||||
* the data has no official muzzle points).
|
||||
*/
|
||||
export const SHOT_HEIGHT = Object.freeze({ launch: 0.45, aim: 0.5 });
|
||||
/** World height of the point `frac` of a unit's drawn model height above its feet (SHOT_HEIGHT), seen through `cam`. */
|
||||
export function bodyZ(cam, v, frac) {
|
||||
const z0 = (v.z || 0) + (v.hover || 0);
|
||||
const h = (v._headTiles || 1.2) * frac;
|
||||
if (!cam || typeof cam.liftFor !== 'function') return z0 + h;
|
||||
const dz = cam.liftFor(v.x, v.y, z0, h * cam.scaleAt(v.x, v.y, z0));
|
||||
return Number.isFinite(dz) ? z0 + dz : z0 + h;
|
||||
}
|
||||
/** World height just above a unit's feet (where shells land). */
|
||||
const feetZ = (v) => (v.z || 0) + (v.hover || 0) + 0.2;
|
||||
/** Cheap fingerprint of a camera's framing (the damage-number layout cache is reused only while it is unchanged). */
|
||||
const camKey = (c) => (c ? c.tx + c.ty * 1e3 + c.tz * 1e6 + c.tilt * 7.13 + c.dist * 1e4 + c.scale * 3.7e-2 + c.cx * 1.1e-5 + c.cy * 1.3e-8 : 0);
|
||||
@@ -458,6 +474,13 @@ export class FxSystem {
|
||||
return this._proj(view.x, view.y, z, out);
|
||||
}
|
||||
|
||||
/** Screen point `frac` of a unit's drawn model height above its feet (SHOT_HEIGHT: beams, a mortar's muzzle). */
|
||||
_bodyPt(view, frac, out = this._p) {
|
||||
const p = this._proj(view.x, view.y, (view.z || 0) + (view.hover || 0), out);
|
||||
p.y -= (view._headTiles || 1.2) * frac * p.s;
|
||||
return p;
|
||||
}
|
||||
|
||||
/** `n` sparks flying out of a screen point (halved at quality 'low'); o: speed, up, g, life, size, tex. */
|
||||
burst(x, y, s, n, tint, o = NO_OPTS) {
|
||||
const q = this.quality === 'low' ? Math.ceil(n / 2) : n;
|
||||
@@ -477,7 +500,7 @@ export class FxSystem {
|
||||
attack(src, tgt, kind) {
|
||||
if (!src) return;
|
||||
// chain: the source is the previous target of the bounce (sim ai.js), so the arc hops unit to unit
|
||||
if (kind === 'chain' || kind === 'chainHeal') { if (tgt && tgt !== src) this._beam(src, tgt, kind === 'chainHeal' ? 0x7dffa8 : 0xc9a2ff); return; }
|
||||
if (kind === 'chain' || kind === 'chainHeal') { if (tgt && tgt !== src) this._beam(src, tgt, kind === 'chainHeal' ? 0x7dffa8 : 0xc9a2ff, 0.22, 1, true); return; }
|
||||
if (kind === 'beam') { if (tgt && tgt !== src) this._beam(src, tgt, src.isEnemy ? 0xff7a5a : 0xffe6a8, 0.18, 0.15); return; }
|
||||
const spec = PROJ[kind];
|
||||
if (!spec || !tgt) {
|
||||
@@ -485,14 +508,15 @@ export class FxSystem {
|
||||
return;
|
||||
}
|
||||
const pr = this._takeProj();
|
||||
const cam = this.ctx.cam();
|
||||
const dx = tgt.x - src.x, dy = tgt.y - src.y;
|
||||
const dist = Math.hypot(dx, dy);
|
||||
const ux = dist > 1e-6 ? dx / dist : (src.facing || 1) >= 0 ? 1 : -1, uy = dist > 1e-6 ? dy / dist : 0;
|
||||
const hand = Math.min(0.28, dist * 0.3); // the weapon is in front of the body
|
||||
const look = spec.look;
|
||||
pr.kind = kind; pr.spec = spec; pr.src = src; pr.tgt = tgt; pr.rise = 0;
|
||||
pr.x0 = src.x + ux * hand; pr.y0 = src.y + uy * hand; pr.z0 = chestZ(src);
|
||||
pr.tx = tgt.x; pr.ty = tgt.y; pr.tz = look === 'shell' ? feetZ(tgt) : chestZ(tgt);
|
||||
pr.x0 = src.x + ux * hand; pr.y0 = src.y + uy * hand; pr.z0 = bodyZ(cam, src, SHOT_HEIGHT.launch);
|
||||
pr.tx = tgt.x; pr.ty = tgt.y; pr.tz = look === 'shell' ? feetZ(tgt) : bodyZ(cam, tgt, SHOT_HEIGHT.aim);
|
||||
pr.t = 0; pr.fade = 0; pr.hit = false; pr.emit = Math.random(); pr.ang = Math.atan2(-uy, ux); // ≈ on screen (rows run up)
|
||||
pr.dur = clamp(dist / projSpeed(kind) / this._ts(), 0.04, 1.5);
|
||||
pr.arc = spec.arc ? spec.arc * clamp(0.45 + dist * 0.18, 0.6, 1.8) : 0;
|
||||
@@ -587,7 +611,7 @@ export class FxSystem {
|
||||
const spec = pr.spec, look = spec.look;
|
||||
pr.t += dt;
|
||||
const tg = pr.tgt;
|
||||
if (tg && !tg.destroyed && tg.alive !== false) { pr.tx = tg.x; pr.ty = tg.y; pr.tz = look === 'shell' ? feetZ(tg) : chestZ(tg); }
|
||||
if (tg && !tg.destroyed && tg.alive !== false) { pr.tx = tg.x; pr.ty = tg.y; pr.tz = look === 'shell' ? feetZ(tg) : bodyZ(cam, tg, SHOT_HEIGHT.aim); }
|
||||
const k = Math.min(1, pr.t / pr.dur);
|
||||
if (k >= 1 && !pr.hit) { pr.hit = true; pr.fade = 0; this._impact(pr, cam); }
|
||||
let fk = 0;
|
||||
@@ -654,12 +678,12 @@ export class FxSystem {
|
||||
let gx, gy, gz;
|
||||
if (pr.phase === 0) {
|
||||
const tg = pr.tgt;
|
||||
if (tg && !tg.destroyed && tg.alive !== false) { pr.tx = tg.x; pr.ty = tg.y; pr.tz = chestZ(tg); }
|
||||
if (tg && !tg.destroyed && tg.alive !== false) { pr.tx = tg.x; pr.ty = tg.y; pr.tz = bodyZ(cam, tg, SHOT_HEIGHT.aim); }
|
||||
gx = pr.tx; gy = pr.ty; gz = pr.tz;
|
||||
} else {
|
||||
const sv = pr.src;
|
||||
if (!sv || sv.destroyed || sv.alive === false) return false;
|
||||
gx = sv.x; gy = sv.y; gz = chestZ(sv);
|
||||
gx = sv.x; gy = sv.y; gz = bodyZ(cam, sv, SHOT_HEIGHT.launch);
|
||||
}
|
||||
const dx = gx - pr.bx, dy = gy - pr.by, dz = gz - pr.bz;
|
||||
const d = Math.hypot(dx, dy, dz);
|
||||
@@ -864,7 +888,7 @@ export class FxSystem {
|
||||
const pr = this._takeProj();
|
||||
const gz = this._groundZ(x, y);
|
||||
pr.kind = 'bombardShell'; pr.spec = BOMBARD_SHELL; pr.src = src; pr.tgt = null;
|
||||
pr.x0 = src ? src.x : x; pr.y0 = src ? src.y : y; pr.z0 = src ? chestZ(src) : gz + 0.5;
|
||||
pr.x0 = src ? src.x : x; pr.y0 = src ? src.y : y; pr.z0 = src ? bodyZ(this.ctx.cam(), src, SHOT_HEIGHT.launch) : gz + 0.5;
|
||||
pr.tx = x; pr.ty = y; pr.tz = gz;
|
||||
pr.t = 0; pr.dur = clamp(flight, 0.1, 4); pr.fade = 0; pr.hit = false; pr.emit = 0; pr.arc = 0; pr.ang = Math.PI / 2;
|
||||
pr.rise = pr.dur >= 0.45 ? SHELL_RISE : 0;
|
||||
@@ -879,7 +903,7 @@ export class FxSystem {
|
||||
this.ring(x, y, gz, rr * 0.96, rr, 0xff7a4a, pr.dur, 'ring', 'pulse');
|
||||
if (src && this.rich) {
|
||||
// the shot leaves her upwards: a muzzle flash and a streak climbing out of sight
|
||||
const p = this._chest(src, this._g);
|
||||
const p = this._bodyPt(src, SHOT_HEIGHT.launch, this._g);
|
||||
const s = p.s;
|
||||
this.particle('muzzle', p.x, p.y, { tint: 0xffc27a, life: 0.1, s0: (s / 64) * 0.6, s1: (s / 64) * 0.8, a0: 1, a1: 0, rot: -Math.PI / 2, anchorX: 0.19 });
|
||||
this.particle('glow', p.x, p.y, { tint: 0xffb35c, life: 0.14, s0: (s / 128) * 0.5, s1: (s / 128) * 0.9, a0: 0.9, a1: 0 });
|
||||
@@ -907,10 +931,11 @@ export class FxSystem {
|
||||
if (best && bd <= r + 1.5) this._releaseLock(best);
|
||||
}
|
||||
|
||||
_beam(a, b, color, dur = 0.22, jitter = 1) {
|
||||
this.beamList.push({ a, b, color, t: 0, dur, jitter, seed: Math.random() * 1000 });
|
||||
/** A beam from view `a` to view `b`; `chain`: a bounce, `a` is the previous target (it leaves from its chest). */
|
||||
_beam(a, b, color, dur = 0.22, jitter = 1, chain = false) {
|
||||
this.beamList.push({ a, b, color, t: 0, dur, jitter, chain, seed: Math.random() * 1000 });
|
||||
if (this.beamList.length > 40) this.beamList.shift();
|
||||
const q = this._chest(b, this._g);
|
||||
const q = this._bodyPt(b, SHOT_HEIGHT.aim, this._g);
|
||||
this.particle('flare', q.x, q.y, { tint: color, life: 0.16, s0: (q.s / 128) * 0.7, s1: (q.s / 128) * 0.25, a0: 1, a1: 0, rot: Math.random() });
|
||||
}
|
||||
|
||||
@@ -922,8 +947,9 @@ export class FxSystem {
|
||||
for (const bm of this.beamList) {
|
||||
bm.t += dt;
|
||||
if (bm.t >= bm.dur || !bm.a || !bm.b) continue;
|
||||
this._chest(bm.a, p); const px = p.x, py = p.y, s = p.s;
|
||||
this._chest(bm.b, q);
|
||||
// a chain bounce leaves the previous target where the shot met it (its chest), a beam leaves the shooter's hands
|
||||
this._bodyPt(bm.a, bm.chain ? SHOT_HEIGHT.aim : SHOT_HEIGHT.launch, p); const px = p.x, py = p.y, s = p.s;
|
||||
this._bodyPt(bm.b, SHOT_HEIGHT.aim, q);
|
||||
const k = 1 - bm.t / bm.dur;
|
||||
const segs = 7;
|
||||
// soft glow, coloured body, white-hot core
|
||||
@@ -1136,7 +1162,7 @@ export class FxSystem {
|
||||
}
|
||||
if (L.out >= 0) { L.out += dt; if (L.out >= LOCK_FADE) { this._freeLock(L); continue; } }
|
||||
const v = L.view;
|
||||
if (v && !v.destroyed && v.alive !== false) { L.x = v.x; L.y = v.y; L.z = chestZ(v); }
|
||||
if (v && !v.destroyed && v.alive !== false) { L.x = v.x; L.y = v.y; L.z = bodyZ(cam, v, SHOT_HEIGHT.aim); }
|
||||
const p = cam.project(L.x, L.y, L.z, this._p);
|
||||
const s = p.s;
|
||||
const out = L.out >= 0 ? L.out / LOCK_FADE : 0;
|
||||
@@ -1803,7 +1829,7 @@ export class FxSystem {
|
||||
// `id` is always the locked enemy: the reticle sticks to its view even a little off the event's spot
|
||||
const lv = at.v || this._viewOf(ex.id);
|
||||
this._touchLocks(ex.src ?? null);
|
||||
this._lock(lv, ex.src ?? null, lv ? lv.x : at.x, lv ? lv.y : at.y, lv ? chestZ(lv) : at.z + 0.55);
|
||||
this._lock(lv, ex.src ?? null, lv ? lv.x : at.x, lv ? lv.y : at.y, lv ? bodyZ(cam, lv, SHOT_HEIGHT.aim) : at.z + 0.55);
|
||||
break;
|
||||
}
|
||||
const v = at.v;
|
||||
|
||||
@@ -38,6 +38,21 @@ export function hitRectAt(x, y, a) {
|
||||
const cx = x + (Number(a.dx) || 0), cy = y + (Number(a.dy) || 0);
|
||||
return { x0: cx - a.w / 2, x1: cx + a.w / 2, y0: cy - a.h / 2, y1: cy + a.h / 2 };
|
||||
}
|
||||
/**
|
||||
* Board tiles ([[row, col]]) a unit at (x, y) is hit on — as server/sim/body.js bodyKeys: a huge unit (`hitArea`) every tile
|
||||
* its rectangle overlaps (open intervals: a tile only touched along an edge is not), a point unit the tile of its
|
||||
* position; clipped to the rows × cols grid (19 × 21). The Final Assault prep draws a leader's (render/app.js, report #12).
|
||||
*/
|
||||
export function hitTiles(x, y, a, rows = 19, cols = 21) {
|
||||
if (!Number.isFinite(x) || !Number.isFinite(y)) return [];
|
||||
const R = hitRectAt(x, y, a);
|
||||
if (!R) { const r = Math.round(y), c = Math.round(x); return r >= 0 && r < rows && c >= 0 && c < cols ? [[r, c]] : []; }
|
||||
const r0 = Math.max(0, Math.floor(R.y0 + 0.5 + EPS)), r1 = Math.min(rows - 1, Math.ceil(R.y1 - 0.5 - EPS));
|
||||
const c0 = Math.max(0, Math.floor(R.x0 + 0.5 + EPS)), c1 = Math.min(cols - 1, Math.ceil(R.x1 - 0.5 - EPS));
|
||||
const out = [];
|
||||
for (let r = r0; r <= r1; r++) for (let c = c0; c <= c1; c++) out.push([r, c]);
|
||||
return out;
|
||||
}
|
||||
const inArea = (A, g) => !!A && !!g && g.x >= A.x0 && g.x <= A.x1 && g.y >= A.y0 && g.y <= A.y1;
|
||||
/** Screen point inside a huge body's upright box: `hw` tiles (its px per tile) either side of its x, feet up to head. */
|
||||
const inBox = (b, px, py) => !!b && b.hw > 0 && b.s > 0 && Math.abs(px - b.x) <= b.hw * b.s
|
||||
|
||||
@@ -9,6 +9,11 @@
|
||||
//
|
||||
// The view keeps every public coordinate in BOARD space (the server's, g.move targets, canPlace, highlightTiles,
|
||||
// tileScreen, holdPiece, setPieceDir): only what is drawn and picked goes through this transform. Pure functions.
|
||||
//
|
||||
// The round's leader (community report #12, owner's decision 2026-10-04: research 09 §2.2's "the leader stands by its
|
||||
// spawn point" over research 08 §4.1's pen): `leaderStand` finds it in m.private.nextEnemies (an entry with `start`, the
|
||||
// leader's spawn tile on the boss field, server/match/waves.js previewOf) — the Final Assault prep shows it standing
|
||||
// there instead of in the pen, and lights its hit tiles in red beside an operator's orange range preview (render/app.js).
|
||||
|
||||
/** Board row → display (boss-field) row: row − 7 (server/match/board.js BOARD_ROWS_ABOVE_BOSS is +7, the inverse). */
|
||||
export const BOSS_ROW_SHIFT = -7;
|
||||
@@ -69,3 +74,26 @@ export function tilesToDisp(xf, tiles) {
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* The leader standing on the boss field in its prep (see the header): the first `nextEnemies` entry flagged `boss` with
|
||||
* a spawn tile `start` ([row, col], boss-field rows 0–5) → { entry, row, col, tiles } — `tiles` its hit tiles there
|
||||
* (render/pick.js hitTiles: the sim's hit rectangle, data/enemies.json `hitArea` through `hitAreaOf(enemyKey)`; a point
|
||||
* leader its own tile) — or null.
|
||||
* @param {Array<object>|null} list m.private.nextEnemies
|
||||
* @param {(enemyKey: string) => any} hitAreaOf
|
||||
* @param {(x: number, y: number, a: any) => number[][]} hitTilesOf render/pick.js hitTiles
|
||||
*/
|
||||
export function leaderStand(list, hitAreaOf, hitTilesOf) {
|
||||
if (!Array.isArray(list)) return null;
|
||||
const entry = list.find((e) => e && e.boss && typeof e.enemyKey === 'string' && Array.isArray(e.start)
|
||||
&& Number.isInteger(e.start[0]) && Number.isInteger(e.start[1]) && e.start[0] >= 0 && e.start[0] <= BOSS_DISP_MAX_ROW
|
||||
&& e.start[1] >= 0 && e.start[1] <= MAX_COL);
|
||||
if (!entry) return null;
|
||||
const [row, col] = entry.start;
|
||||
let area = null;
|
||||
try { area = hitAreaOf ? hitAreaOf(entry.enemyKey) : null; } catch { area = null; }
|
||||
const tiles = (typeof hitTilesOf === 'function' ? hitTilesOf(col, row, area) : [[row, col]])
|
||||
.filter(([r, c]) => r >= 0 && r <= BOSS_DISP_MAX_ROW && c >= 0 && c <= MAX_COL);
|
||||
return { entry, row, col, tiles };
|
||||
}
|
||||
@@ -199,6 +199,24 @@ export class Camera {
|
||||
/** CSS px per world unit at a world point. */
|
||||
scaleAt(x, y, z = 0) { return this._k / this.depthOf(x, y, z); }
|
||||
|
||||
/**
|
||||
* World height above (x, y, z) whose projection lies `px` CSS px above the projection of (x, y, z) — exact for this
|
||||
* camera. A unit's model is an upright screen billboard, so a point at a given drawn height on it (render/fx.js
|
||||
* SHOT_HEIGHT, GitHub #61) is this lift, not that many tiles: under the 30° pitch a vertical world height shows at
|
||||
* about half its length.
|
||||
*/
|
||||
liftFor(x, y, z, px) {
|
||||
const vy = y - this._py, vz = z - this._pz;
|
||||
let depth = vy * this._s - vz * this._c;
|
||||
if (!(depth > MIN_DEPTH)) depth = MIN_DEPTH;
|
||||
const up = vy * this._c + vz * this._s;
|
||||
// screen y = cy − k·up/depth; raising z by h: up += h·sin, depth −= h·cos — solve for the wanted up/depth ratio
|
||||
const ratio = up / depth + px / this._k;
|
||||
const den = this._s + ratio * this._c;
|
||||
if (!(den > 1e-6)) return px / Math.max(1e-6, (this._k / depth) * Math.max(this._s, 0.1));
|
||||
return (ratio * depth - up) / den;
|
||||
}
|
||||
|
||||
/**
|
||||
* Inverse projection onto the horizontal plane at height `z`. Returns `{ x, y }` (written into `out`) or
|
||||
* null when the view ray does not hit the plane in front of the camera.
|
||||
|
||||
@@ -13,7 +13,10 @@
|
||||
// GitHub #58): every attack plays the clip once — at its own speed, faster
|
||||
// only when the attacks come quicker than the clip —, then base (Move while
|
||||
// the sim walks it: it stands for that clip, server/sim/ai.js attackStand)
|
||||
// setSkill(on) skill begin→loop while active (skill idle replaces idle), end on stop
|
||||
// setSkill(on) skill begin, then — when the skill has an idle clip of its own (skill.idle,
|
||||
// not its loop) — that idle between attacks, its loop (the skill's attack clip)
|
||||
// only on attacks (community report #23: 折桠's S2 jump attack looped with no
|
||||
// enemy engaged); a skill without one keeps its loop as the stance; end on stop
|
||||
// deploy() 'Start' once, then base
|
||||
// die() die clip once (callers fade out afterwards); a skeleton without one holds its
|
||||
// idle clip's first frame (GitHub issue #25: the attack loop went on)
|
||||
@@ -24,7 +27,9 @@
|
||||
// 重生), landing in `roles`
|
||||
// update(dt) advances the skeleton (autoUpdate is off: one clock for everything)
|
||||
// Attack mode lasts until ~1.4 attack intervals without a new attack (a `once` cast and every attack of a
|
||||
// `clipPerAttack` actor: to the end of its clip), then the end clip (if any) and base.
|
||||
// `clipPerAttack` actor: to the end of its clip), then the end clip (if any) and base — except the attacks of a skill
|
||||
// with its own idle clip, which go straight back to that idle: the skill's end clip closes the skill, not each spell of
|
||||
// attacks while it runs.
|
||||
|
||||
const clampN = (v, lo, hi) => (v < lo ? lo : v > hi ? hi : v);
|
||||
|
||||
@@ -327,6 +332,17 @@ export class SpineActor {
|
||||
return !!sk && sk.loop === this.roles.idle;
|
||||
}
|
||||
|
||||
/**
|
||||
* The running skill's own idle clip when its attacks are its loop clip (anims skill.idle ≠ skill.loop: 折桠's
|
||||
* Skill_2_Idle beside the Skill_2_Loop jump attack, 史尔特尔's Skill_3_Idle, 耀骑士临光's Skill_3_Idle …): the pose
|
||||
* between its attacks. null otherwise — no skill on, no idle clip, or the loop is that idle (蕾缪安's S2 / S3).
|
||||
*/
|
||||
_skillIdle() {
|
||||
const sk = this.roles.skill;
|
||||
if (!this.skillOn || !sk || sk.idle === sk.loop || !this.has(sk.idle)) return null;
|
||||
return this._attackClip() === sk ? sk.idle : null;
|
||||
}
|
||||
|
||||
_hitTime(anim, dur) {
|
||||
const hits = this.entry.hits && this.entry.hits[anim];
|
||||
if (Array.isArray(hits) && hits.length && Number.isFinite(hits[0])) return clampN(hits[0], 0, dur);
|
||||
@@ -345,7 +361,9 @@ export class SpineActor {
|
||||
this.mode = 'skillBegin';
|
||||
this._play(sk.begin, false, { mix: 0.08 });
|
||||
this.skillBeginUntil = this.clock + this.dur(sk.begin);
|
||||
if (this.has(sk.loop)) this._queue(sk.loop, true);
|
||||
// then the skill's own idle until an attack plays its loop (community report #23); without one, the loop
|
||||
const next = this._skillIdle() || (this.has(sk.loop) ? sk.loop : null);
|
||||
if (next) this._queue(next, true);
|
||||
} else if (this.mode === 'base') this._play(this._baseName(), true);
|
||||
} else if (!on && sk) {
|
||||
if (this.has(sk.end)) {
|
||||
@@ -429,7 +447,9 @@ export class SpineActor {
|
||||
this.mode = 'base';
|
||||
this.wound = false;
|
||||
const clip = this._attackClip() || this.roles.attack;
|
||||
if (clip && this.has(clip.end)) { this._play(clip.end, false); this._queue(this._baseName(), true); }
|
||||
// a skill with its own idle: back to that idle (its end clip is for the end of the skill, setSkill(false))
|
||||
const toSkillIdle = clip === this.roles.skill && !!this._skillIdle();
|
||||
if (clip && this.has(clip.end) && !toSkillIdle) { this._play(clip.end, false); this._queue(this._baseName(), true); }
|
||||
else this._play(this._baseName(), true, { mix: 0.15 });
|
||||
}
|
||||
break;
|
||||
|
||||
@@ -8,7 +8,9 @@
|
||||
// (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
|
||||
// after a reload whose manifest was slow or failed, every operator stayed the image-less placeholder for good); a load
|
||||
// begun while the tab was hidden and still in flight SPINE_STUCK_MS after it is shown again is started again
|
||||
// (assets.js spine.restart: GitHub #68, such a load may never settle — its view stayed the placeholder). 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
|
||||
@@ -107,6 +109,13 @@ const PIC_WAIT_MS = 400;
|
||||
* 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]);
|
||||
/**
|
||||
* A Spine load begun while the tab was hidden and still in flight this long (ms, real time) after the tab is shown again
|
||||
* is started again (`retryAssets` arms it, `update` fires it; GitHub #68: such a load — a request the hidden tab left
|
||||
* hanging — may never settle, and the view stayed the placeholder). A load that finishes within it is never doubled.
|
||||
* [ASSUMED] the length: a healthy load finishes within a few seconds once the tab is visible.
|
||||
*/
|
||||
export const SPINE_STUCK_MS = 5000;
|
||||
|
||||
/** Heights above this count as standing on a raised top (bench pads are the lowest raised tiles, 0.16). */
|
||||
const RAISED_Z = 0.12;
|
||||
@@ -365,6 +374,8 @@ export class UnitView {
|
||||
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._spineHidden = false; // the load in flight began while the tab was hidden (SPINE_STUCK_MS)
|
||||
this._stuckAt = 0; // when that load is started again unless it settled (ms; 0 = none)
|
||||
this.baseTint = 0xffffff; // the drawn model's own tint (ALIAS_TINT), under the status tints
|
||||
|
||||
this.hud = new P.Container();
|
||||
@@ -431,12 +442,16 @@ export class UnitView {
|
||||
* 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.
|
||||
* every view when a manifest arrives (assets.js onChange) and when the tab is shown again. A load still in flight that
|
||||
* began while the tab was hidden gets SPINE_STUCK_MS from now (the tab visible) to finish, then `update` starts it again
|
||||
* (GitHub #68). 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); }
|
||||
if (this.actor) return;
|
||||
if (!this._spineBusy) { this._retryAt = 0; this._loadSpine(true); return; }
|
||||
if (this._spineHidden && !this._stuckAt && !globalThis.document?.hidden) this._stuckAt = nowMs() + SPINE_STUCK_MS;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -451,8 +466,10 @@ export class UnitView {
|
||||
this.entry = entry;
|
||||
const req = this._spineReq = (this._spineReq || 0) + 1;
|
||||
this._spineBusy = true;
|
||||
this._spineHidden = !!globalThis.document?.hidden;
|
||||
this._stuckAt = 0;
|
||||
a.spine.acquire(entry, retry ? { retry: true } : undefined).then((data) => {
|
||||
if (req === this._spineReq) this._spineBusy = false;
|
||||
if (req === this._spineReq) { this._spineBusy = false; this._stuckAt = 0; }
|
||||
if (this.destroyed || req !== this._spineReq) { this._releaseEntry(entry); return; }
|
||||
this._spineTries = 0;
|
||||
this._retryAt = 0;
|
||||
@@ -496,7 +513,7 @@ export class UnitView {
|
||||
if (deployed != null) { this.actor.deploy(); if (deployed > 0) this.actor.update(deployed); }
|
||||
}
|
||||
}, () => {
|
||||
if (req === this._spineReq) this._spineBusy = false;
|
||||
if (req === this._spineReq) { this._spineBusy = false; this._stuckAt = 0; }
|
||||
this._releaseEntry(entry);
|
||||
if (this.destroyed || req !== this._spineReq) return;
|
||||
if (entry.fallback) { this._acquireSpine(entry.fallback, id, retry); return; }
|
||||
@@ -850,6 +867,11 @@ export class UnitView {
|
||||
if (this._modelDirty) { this._modelDirty = false; this._syncModel(); }
|
||||
// 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); }
|
||||
// a load begun in a hidden tab and still in flight SPINE_STUCK_MS after it came back: started again (GitHub #68)
|
||||
if (this._stuckAt && nowMs() >= this._stuckAt) {
|
||||
this._stuckAt = 0;
|
||||
if (!this.actor && this._spineBusy && this.entry) { this._spineHidden = false; this.ctx.assets?.spine?.restart?.(this.entry); }
|
||||
}
|
||||
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);
|
||||
@@ -876,8 +898,9 @@ export class UnitView {
|
||||
if (this.dimmed) alpha *= 0.35;
|
||||
this.alpha = alpha;
|
||||
|
||||
// body placement
|
||||
const lungeK = this.lunge > 0 ? Math.sin(this.lunge * Math.PI) * 0.12 : 0;
|
||||
// body placement — the 0.12-tile jolt toward the target on an attack stands in for the avatar diamond's missing attack
|
||||
// clip; a Spine model plays its own and keeps its place (GitHub #61: every ranged attack shoved the model aside)
|
||||
const lungeK = this.lunge > 0 && !(this.actor && this.spineReady) ? Math.sin(this.lunge * Math.PI) * 0.12 : 0;
|
||||
this.lunge = Math.max(0, this.lunge - dt * 5);
|
||||
const lx = this.lungeDir.x * lungeK, ly = this.lungeDir.y * lungeK;
|
||||
let bx = p.x, by = p.y;
|
||||
|
||||
Reference in new issue
Block a user