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:
sganggs committed 2026-10-04 18:10:01 +08:00
commit a57c2a85e2
26 files changed
+1105 -89

No files matched your search

+1 -1
View File
@@ -1 +1 @@
{"version":1,"hash":"375e961f7569","generator":"tools/fetch-assets.mjs","stats":{"files":4014,"bytes":282091345,"chars":138,"charsWithBack":135,"enemies":252,"enemiesWithSpine":251,"tokens":20,"tokensWithSpine":13,"spineModels":529,"bonds":23,"items":59,"bands":40,"skills":317,"ui":601,"sfxUnits":382},"chars":{"char_1012_skadi2":{"avatar":"/assets/char/avatar/char_1012_skadi2.png","avatarE2":"/assets/char/avatar/char_1012_skadi2_2.png","portrait":"/assets/char/portrait/char_1012_skadi2_1.png","portraitE2":"/assets/char/portrait/char_1012_skadi2_2.png","spine":{"front":{"skel":"/assets/spine/op/char_1012_skadi2/front/char_1012_skadi2.skel","atlas":"/assets/spine/op/char_1012_skadi2/front/char_1012_skadi2.atlas","textures":["/assets/spine/op/char_1012_skadi2/front/char_1012_skadi2.png"],"pma":false,"anims":{"idle":"Idle","deploy":"Start","attack":{"begin":null,"loop":"Skill_1_Loop","end":null,"via":"skill"},"attackDown":null,"skill":{"begin":"Skill_3_Begin","loop":"Skill_3_Loop","end":null,"index":2,"idle":null},"die":"Die","move":null,"stun":{"begin":"Stun_Begin","loop":"Stun","end":null},"skills":{"0":{"begin":"Skill_1_Begin","loop":"Skill_1_Loop","end":null,"index":0,"idle":null},"1":{"begin":"Skill_2_Begin","loop":"Skill_2_Loop","end":null,"index":1,"idle":null},"2":{"begin":"Skill_3_Begin","loop":"Skill_3_Loop","end":null,"index":2,"idle":null}}},"animations":{"Default":0,"Die":1,"Idle":8.767,"Skill_1_Begin":3.2,"Skill_1_Loop":8.767,"Skill_2_Begin":3.933,"Skill_2_Loop":3.333,"Skill_3_Begin":4.4,"Skill_3_Loop":8.767,"Start":1,"Stun":2,"Stun_Begin":1},"events":["OnAttack","OnStart"],"hits":{},"bounds":{"x":-190.586,"y":-25.785,"width":393.516,"height":443.426}}}},"char_1014_nearl2":{"avatar":"/assets/char/avatar/char_1014_nearl2.png","avatarE2":"/assets/char/avatar/char_1014_nearl2_2.png","portrait":"/assets/char/portrait/char_1014_nearl2_1.png","portraitE2":"/assets/char/portrait/char_1014_nearl2_2.png","spine":{"front":{"skel":"/assets/spine/op/char_1014_nearl2/front/char_1014_nearl2.skel","atlas":"/assets/spine/op/char_1014_nearl2/front/char_1014_nearl2.atlas","textures":["/assets/spine/op/char_1014_nearl2/front/char_1014_nearl2.png"],"pma":false,"anims":{"idle":"Idle","deploy":"Start","attack":{"begin":"Attack_Begin","loop":"Attack_Loop","end":"Attack_End"},"attackDown":null,"skill":{"begin":null,"loop":"Skill_3","end":null,"index":2,"idle":"Skill_3_Idle"},"die":"Die","move":null,"stun":null,"skills":{"0":{"begin":"Skill_1_Begin","loop":"Skill_1_Loop","end":null,"index":0,"idle":"Skill_1_Idle"},"1":{"begin":null,"loop":"Skill_2_Die","end":null,"index":1,"idle":null},"2":{"begin":null,"loop":"Skill_3","end":null,"index":2,"idle":"Skill_3_Idle"}}},"animations":{"Attack_Begin":0.1,"Attack_End":0.167,"Attack_Loop":1.5,"Default":0,"Die":1,"Idle":2.667,"Skill_3":1.5,"Skill_1_Begin":0.1,"Skill_1_Idle":1,"Skill_1_Loop":1.5,"Skill_1_Loop_Down":1.5,"Skill_2_Die":1.167,"Skill_3_Down":1.5,"Skill_3_Idle":1.333,"Start":1},"events":["OnAttack","OnStart"],"hits":{"Attack_Loop":[0.433],"Skill_3":[0.5],"Skill_1_Loop":[0.467],"Skill_1_Loop_Down":[0.467],"Skill_3_Down":[0.5]},"bounds":{"x":-400.77,"y":-461.007,"width":720.545,"height":872.242}},"back":{"skel":"/assets/spine/op/char_1014_nearl2/back/char_1014_nearl2.skel","atlas":"/assets/spine/op/char_1014_nearl2/back/char_1014_nearl2.atlas","textures":["/assets/spine/op/char_1014_nearl2/back/char_1014_nearl2.png"],"pma":false,"anims":{"idle":"Idle","deploy":"Start","attack":{"begin":"Attack_Begin","loop":"Attack_Loop","end":"Attack_End"},"attackDown":null,"skill":{"begin":null,"loop":"Skill_3","end":null,"index":2,"idle":"Skill_3_Idle"},"die":null,"move":null,"stun":null,"skills":{"0":{"begin":"Skill_1_Begin","loop":"Skill_1_Loop","end":null,"index":0,"idle":"Skill_1_Idle"},"1":{"begin":"Attack_Begin","loop":"Attack_Loop","end":"Attack_End","via":"attack","index":1,"idle":null},"2":{"begin":null,"loop":"Skill_3","end":null,"index":2,"idle":"Skill_3_Idle"}}},"animations":{"Attack_Begin":0.1,"Attack_End":0.167,"Attack_Loop":1.5,"Default":0,"Idle":2.667,"Skill_1_Begin":0.1,"Skill_1_Idle":1,"Skill_1_Loop":1.5,"Skill_3":1.5,"Skill_3_Idle":1.333,"Start":1},"events":["OnAttack","OnStart"],"hits":{"Attack_Loop":[0.433],"Skill_1_Loop":[0.467],"Skill_3":[0.5]},"bounds":null}}},"char_1016_agoat2":{"avatar":"/assets/char/avatar/char_1016_agoat2.png","avatarE2":"/assets/char/avatar/char_1016_agoat2_2.png","portrait":"/assets/char/portrait/char_1016_agoat2_1.png","portraitE2":"/assets/char/portrait/char_1016_agoat2_2.png","spine":{"front":{"skel":"/assets/spine/op/char_1016_agoat2/front/char_1016_agoat2.skel","atlas":"/assets/spine/op/char_1016_agoat2/front/char_1016_agoat2.atlas","textures":["/assets/spine/op/char_1016_agoat2/front/char_1016_agoat2.png"],"pma":false,"anims":{"idle":"Idle","deploy":"Start","attack":{"begin":null,"loop":"Attack","end":null},"attackDown":null,"skill":{"begin":"Skill_3_Begin","loop":"Skill_3_Loop","end":"Skill_3_End","index":2,"idle":"Skill_3_Idle"},"diLine truncated
{"version":1,"hash":"7f118cf0f7bb","generator":"tools/fetch-assets.mjs","stats":{"files":4014,"bytes":282091345,"chars":138,"charsWithBack":135,"enemies":252,"enemiesWithSpine":251,"tokens":20,"tokensWithSpine":13,"spineModels":529,"bonds":23,"items":59,"bands":40,"skills":317,"ui":601,"sfxUnits":382},"chars":{"char_1012_skadi2":{"avatar":"/assets/char/avatar/char_1012_skadi2.png","avatarE2":"/assets/char/avatar/char_1012_skadi2_2.png","portrait":"/assets/char/portrait/char_1012_skadi2_1.png","portraitE2":"/assets/char/portrait/char_1012_skadi2_2.png","spine":{"front":{"skel":"/assets/spine/op/char_1012_skadi2/front/char_1012_skadi2.skel","atlas":"/assets/spine/op/char_1012_skadi2/front/char_1012_skadi2.atlas","textures":["/assets/spine/op/char_1012_skadi2/front/char_1012_skadi2.png"],"pma":false,"anims":{"idle":"Idle","deploy":"Start","attack":{"begin":null,"loop":"Skill_1_Loop","end":null,"via":"skill"},"attackDown":null,"skill":{"begin":"Skill_3_Begin","loop":"Skill_3_Loop","end":null,"index":2,"idle":null},"die":"Die","move":null,"stun":{"begin":"Stun_Begin","loop":"Stun","end":null},"skills":{"0":{"begin":"Skill_1_Begin","loop":"Skill_1_Loop","end":null,"index":0,"idle":null},"1":{"begin":"Skill_2_Begin","loop":"Skill_2_Loop","end":null,"index":1,"idle":null},"2":{"begin":"Skill_3_Begin","loop":"Skill_3_Loop","end":null,"index":2,"idle":null}}},"animations":{"Default":0,"Die":1,"Idle":8.767,"Skill_1_Begin":3.2,"Skill_1_Loop":8.767,"Skill_2_Begin":3.933,"Skill_2_Loop":3.333,"Skill_3_Begin":4.4,"Skill_3_Loop":8.767,"Start":1,"Stun":2,"Stun_Begin":1},"events":["OnAttack","OnStart"],"hits":{},"bounds":{"x":-190.586,"y":-25.785,"width":393.516,"height":443.426}}}},"char_1014_nearl2":{"avatar":"/assets/char/avatar/char_1014_nearl2.png","avatarE2":"/assets/char/avatar/char_1014_nearl2_2.png","portrait":"/assets/char/portrait/char_1014_nearl2_1.png","portraitE2":"/assets/char/portrait/char_1014_nearl2_2.png","spine":{"front":{"skel":"/assets/spine/op/char_1014_nearl2/front/char_1014_nearl2.skel","atlas":"/assets/spine/op/char_1014_nearl2/front/char_1014_nearl2.atlas","textures":["/assets/spine/op/char_1014_nearl2/front/char_1014_nearl2.png"],"pma":false,"anims":{"idle":"Idle","deploy":"Start","attack":{"begin":"Attack_Begin","loop":"Attack_Loop","end":"Attack_End"},"attackDown":null,"skill":{"begin":null,"loop":"Skill_3","end":null,"index":2,"idle":"Skill_3_Idle"},"die":"Die","move":null,"stun":null,"skills":{"0":{"begin":"Skill_1_Begin","loop":"Skill_1_Loop","end":null,"index":0,"idle":"Skill_1_Idle"},"1":{"begin":null,"loop":"Skill_2_Die","end":null,"index":1,"idle":null},"2":{"begin":null,"loop":"Skill_3","end":null,"index":2,"idle":"Skill_3_Idle"}}},"animations":{"Attack_Begin":0.1,"Attack_End":0.167,"Attack_Loop":1.5,"Default":0,"Die":1,"Idle":2.667,"Skill_3":1.5,"Skill_1_Begin":0.1,"Skill_1_Idle":1,"Skill_1_Loop":1.5,"Skill_1_Loop_Down":1.5,"Skill_2_Die":1.167,"Skill_3_Down":1.5,"Skill_3_Idle":1.333,"Start":1},"events":["OnAttack","OnStart"],"hits":{"Attack_Loop":[0.433],"Skill_3":[0.5],"Skill_1_Loop":[0.467],"Skill_1_Loop_Down":[0.467],"Skill_3_Down":[0.5]},"bounds":{"x":-400.77,"y":-461.007,"width":720.545,"height":872.242}},"back":{"skel":"/assets/spine/op/char_1014_nearl2/back/char_1014_nearl2.skel","atlas":"/assets/spine/op/char_1014_nearl2/back/char_1014_nearl2.atlas","textures":["/assets/spine/op/char_1014_nearl2/back/char_1014_nearl2.png"],"pma":false,"anims":{"idle":"Idle","deploy":"Start","attack":{"begin":"Attack_Begin","loop":"Attack_Loop","end":"Attack_End"},"attackDown":null,"skill":{"begin":null,"loop":"Skill_3","end":null,"index":2,"idle":"Skill_3_Idle"},"die":null,"move":null,"stun":null,"skills":{"0":{"begin":"Skill_1_Begin","loop":"Skill_1_Loop","end":null,"index":0,"idle":"Skill_1_Idle"},"1":{"begin":"Attack_Begin","loop":"Attack_Loop","end":"Attack_End","via":"attack","index":1,"idle":null},"2":{"begin":null,"loop":"Skill_3","end":null,"index":2,"idle":"Skill_3_Idle"}}},"animations":{"Attack_Begin":0.1,"Attack_End":0.167,"Attack_Loop":1.5,"Default":0,"Idle":2.667,"Skill_1_Begin":0.1,"Skill_1_Idle":1,"Skill_1_Loop":1.5,"Skill_3":1.5,"Skill_3_Idle":1.333,"Start":1},"events":["OnAttack","OnStart"],"hits":{"Attack_Loop":[0.433],"Skill_1_Loop":[0.467],"Skill_3":[0.5]},"bounds":null}}},"char_1016_agoat2":{"avatar":"/assets/char/avatar/char_1016_agoat2.png","avatarE2":"/assets/char/avatar/char_1016_agoat2_2.png","portrait":"/assets/char/portrait/char_1016_agoat2_1.png","portraitE2":"/assets/char/portrait/char_1016_agoat2_2.png","spine":{"front":{"skel":"/assets/spine/op/char_1016_agoat2/front/char_1016_agoat2.skel","atlas":"/assets/spine/op/char_1016_agoat2/front/char_1016_agoat2.atlas","textures":["/assets/spine/op/char_1016_agoat2/front/char_1016_agoat2.png"],"pma":false,"anims":{"idle":"Idle","deploy":"Start","attack":{"begin":null,"loop":"Attack","end":null},"attackDown":null,"skill":{"begin":"Skill_3_Begin","loop":"Skill_3_Loop","end":"Skill_3_End","index":2,"idle":"Skill_3_Idle"},"diLine truncated
+6 -1
View File
@@ -164,9 +164,14 @@ All paths are URL paths relative to the site root, for example `/assets/char/ava
disconnect, settlementSucceed, settlementFail, settlementTeam, settlementBossSign,
goodEvaluation, load, start, matchSucceed, matchFail, matchCancel, joinRoom },
battle: { deploy, tokenDeploy, charDie, enemyDie, enemyDieHeavy, enemyHit, heal, win, lose, killCoin },
units: { [charId|tokenId|enemyId]: { attack?, hit?, skill?, skills?: {[skillIndex]: url}, die?, born? } }
units: { [charId|tokenId|enemyId]: { attack?, hit?, skill?, skills?: {[skillIndex]: url}, die?, born?,
mix?: { [attack|hit|die|born]: { p?, vol? } } } }
}
},
// units' mix (tools/assets/audio.mjs bankMix; community report #30): the official bank of a role's sound — `p` = the weight
// of its sounds that have a file over all weights (an empty asset is a chance of silence: 猎狗pro / 深池侦察犬 0.2), `vol` =
// the played file's volume (妖怪 0.7); only values other than 1. public/js/audio.js plays the role with chance p at its
// base gain × min(1, vol)
// units' attack / hit (tools/assets/audio.mjs pickUnitSfx): operators get normal-mode banks only — the plain
// `attack` / `combat` ability first, never a bank holding a skill-mode file (`_d` / `_h` / `_s`; the normal attack's end
// in `_n`) — with their own projectile banks (ON_PROJECTILE_BORN / _HIT.projectile_chr_<name>) as fallbacks
+1 -1
View File
@@ -442,7 +442,7 @@ S→C:
deployCap, deployCount,
bonds: [ { bondId, count, active, tier, layers, harmony? /* 调和's +1 is in count, §21.26 */, thresholds, countsHand } ], // sorted: active first, then layers desc
effects: [ { id, name, desc, iconKind: 'band'|'choice'|'team'|'item'|'garrison', iconId, counter?, counterText? /* a bounty: 还剩 N 场作战, §20.6 */ } ],
nextEnemies: [ { enemyKey, count, tag } ], // preview of the upcoming round's wave (after waves.js generation)
nextEnemies: [ { enemyKey, count, tag, start? } ], // preview of the upcoming round's wave (after waves.js generation); `start` = a leader's spawn tile [row, col] on the boss field (the boss-field prep shows it standing there, community report #12)
stats: { dmgDealt, kills, leaks, gold, refreshes, merges } }
Piece = { uid, kind: 'chess'|'item'|'token', id, golden: bool, tier, items: [ { uid, id } ] /* chess only */, count /* token stacks */, ownerUid /* token */ }
```
+2 -1
View File
@@ -696,7 +696,8 @@ overtime drain starts; `deadline` = the level's 120 s countdown), `unite { helpe
PREP ready/acting · COMBAT/boss combat/done · UNITE helping/done · others done · `left` / `dead` override.
`m.private` = DESIGN §8.3 exactly (sent per player whenever it changed). `nextEnemies` = the current round's wave
(+ the player's bounty enemies, tag `bounty`; boss rounds: the player's boss field, tag `boss`, + its bounties).
(+ the player's bounty enemies, tag `bounty`; boss rounds: the player's boss field, tag `boss` — the leader's entry with its
spawn tile `start`, where the boss-field prep shows it —, + its bounties).
Bond layers in the views (DESIGN §20.15): from the end of COMBAT (`_finishCombat`, every normal result in) until SETTLE,
`m.private bonds` and `m.public players[].bonds` add the finished battle's IN_BATTLE gains (`PlayerState.pendingLayerGains`
+5 -3
View File
@@ -722,7 +722,7 @@ registration order. `battle.off(handle)` / `battle.off(name, fn)` / `battle.offO
| `dollSwap` | `{ unit, form }` | a 傀儡师 starts a switch — to its <替身> (`form` `'doll'`) or back to its <本体> (`null`); not when it is knocked out as the 替身 (不屈 rolls on it: "切换<替身>与<本体>时") |
| `kill` | `{ killer, victim }` | victim HP reached 0 (a handler may revive by restoring HP) |
| `death` | `{ unit, reason:'killed'|'leak'|'retreat'|'merchant'|'expired'|'forcedExit', killer }` | unit removed (`'forcedExit'`: an operator entering 联防 knocked out, §1.1) |
| `skillStart` / `skillEnd` | `{ unit, skill, reason }` | mutate `skill.ammoLeft` / `skill.timeLeft` in skillStart |
| `skillStart` / `skillEnd` | `{ unit, skill, reason }` | mutate `skill.ammoLeft` / `skill.timeLeft` in skillStart (bullets added there raise `skill.ammoMax`, the ammo bar's full mark) |
| `ammoUsed` | `{ unit, left, skill }` | per ammo consumed |
| `spGain` | `{ unit, amount, reason:'time'|'attack'|'hurt'|'init'|…, skill }` | mutable `amount` (time gains fire every tick) |
| `beforeStatus` | `{ source, target, status, duration, value, cancel }` | set `cancel` (e.g. 浓缩嗅盐) |
@@ -834,7 +834,8 @@ or guard with a per-unit flag while dealing it. When the guard trips, the logged
free heals).
- Runtime helpers on `unit.skill`: `activate(reason, {free})`, `end(reason)`, `stop()`, `addAmmo(n)`, `extend(s)`,
`addCharge(n)`, `gainSp(n, reason)`, `addTriggerRange(fn)`; fields `sp, spCost (= floor(base×spCostMul + spCostFlat)), spCostMul, charges,
maxCharges, active, timeLeft, ammoLeft, activations, kind, rule, bb`.
maxCharges, active, timeLeft, ammoLeft, ammoMax, activations, kind, rule, bb` (`ammoMax`: the most bullets the running ammo skill
has held — set at activation, raised by skillStart additions and `addAmmo` above it; 0 when inactive).
### 7.2 Kits
@@ -1156,7 +1157,8 @@ Unknown subprofessions fall back to the profession default (test `professions.te
## 9. Wire format (snapshot.js, DESIGN §8.2)
- `snapshot()` → `{ fieldId, t, units: [[id, x, y, hp, maxHp, sp, spMax, flags, anim]], dp, killed, total, dps?, boss?, down?, elem? }`.
`sp/spMax` show remaining duration/ammo as a draining bar while a timed skill is active. Units in DIE state stay 0.8 s.
`sp/spMax` show remaining duration/ammo as a draining bar while a timed skill is active (ammo: `ammoLeft / ammoMax`, the
activation's real total — 拉特兰's and 逃犯引渡手续's extra bullets included, community report #35). Units in DIE state stay 0.8 s.
`down: [[id, respawnAt, respawnTime, state, row, col]]` (only when non-empty) = operators lying down waiting to redeploy
(`Battle.isDown(u)`: reason `'killed'` or `FORCED_EXIT` (§1.1 carryState `down`), or a forced exit `'retreat'` /
`'merchant'` (GitHub #60) — every removal but the 突袭 `'raid'` —, not removed for good, deployed at least once, a finite
+103 -28
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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;
+15
View File
@@ -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
+28
View File
@@ -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 };
}
+18
View File
@@ -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.
+24 -4
View File
@@ -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;
+30 -7
View File
@@ -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;
+5 -2
View File
@@ -562,9 +562,11 @@ export function withBounties(gd, round, wave, bounties, playerId, { side = null
/**
* nextEnemies preview (m.private.nextEnemies): one entry per spawn action (research 08 §4.2 input), in spawn order:
* `{ enemyKey, count, gate: 'upper'|'lower', t, fly, elite, boss, source: 'wave'|'bounty', tag }` — `tag` 'boss' /
* `{ enemyKey, count, gate: 'upper'|'lower', t, fly, elite, boss, source: 'wave'|'bounty', tag, start? }` — `tag` 'boss' /
* 'bounty' / null kept for the drawer's grouping; Σ count = the enemies of the round (HUD). Boss parts are omitted.
* The client places the models in the gate's pen zone (≤ 50 shown, elites and bosses always).
* The client places the models in the gate's pen zone (≤ 50 shown, elites and bosses always) — except a leader: its
* entry carries its spawn tile `start` ([row, col] on the boss field) and the Final Assault / Hidden Core prep shows it
* standing there (render/app.js, community report #12; display only).
*/
export function previewOf(spawns) {
const out = [];
@@ -583,6 +585,7 @@ export function previewOf(spawns) {
boss: tag === 'boss',
source: tag === 'bounty' ? 'bounty' : 'wave',
tag,
...(tag === 'boss' && Array.isArray(pv.start) && pv.start.length >= 2 && pv.start.every(Number.isFinite) ? { start: [pv.start[0], pv.start[1]] } : {}),
_seq: seq++,
});
}
+13 -1
View File
@@ -98,6 +98,7 @@ export class SkillRuntime {
this.active = false;
this.timeLeft = 0;
this.ammoLeft = 0;
this.ammoMax = 0; // ammo kind: the most bullets this activation held (snapshot's draining bar)
this.pending = false; // instant/charges: next attack uses spec.attack
this.activations = 0;
this.lastStart = -Infinity;
@@ -195,6 +196,7 @@ export class SkillRuntime {
this.pending = false;
this.timeLeft = 0;
this.ammoLeft = 0;
this.ammoMax = 0;
this.charges = 0;
this.sp = 0;
this._trigKeys = null;
@@ -429,6 +431,7 @@ export class SkillRuntime {
this.active = true;
this.timeLeft = this.kind === 'duration' ? Math.max(0.01, this.duration) : (this.kind === 'ammo' && this.duration > 0 ? this.duration : Infinity);
this.ammoLeft = this.kind === 'ammo' ? Math.max(1, this.ammo) : 0;
this.ammoMax = this.ammoLeft;
this._applyMods();
} else {
// instant / charges
@@ -441,6 +444,9 @@ export class SkillRuntime {
u.skillAnimUntil = b.time + 0.5;
this._call('onStart', { reason });
if (b._hooks.skillStart) b.emit('skillStart', { unit: u, skill: this, reason });
// bullets added in skillStart (拉特兰's ×(1.05 + 0.015 × layers), 逃犯引渡手续, talents): the bar's full mark
// (community report #35: the extra bullets sat above a full bar until fewer than the base count were left)
if (this.active && this.ammoLeft > this.ammoMax) this.ammoMax = this.ammoLeft;
if (!this.isTimed && !this.pending) this.end('instant');
return true;
}
@@ -487,6 +493,7 @@ export class SkillRuntime {
this.pending = false;
this.timeLeft = 0;
this.ammoLeft = 0;
this.ammoMax = 0;
const n = this.activations;
this._call('onEnd', { reason });
if (!this.active && this.activations === n) this._removeMods();
@@ -509,7 +516,12 @@ export class SkillRuntime {
// ---- helpers for content -------------------------------------------------------------------------------
// (non-finite arguments are ignored: a NaN timer/ammo count would keep the skill active forever)
addAmmo(n) { if (this.active && this.kind === 'ammo' && Number.isFinite(n)) this.ammoLeft += n; }
// (bullets added above the activation's most so far raise the bar's full mark: the bar drains one bullet at a time)
addAmmo(n) {
if (!this.active || this.kind !== 'ammo' || !Number.isFinite(n)) return;
this.ammoLeft += n;
if (this.ammoLeft > this.ammoMax) this.ammoMax = this.ammoLeft;
}
extend(seconds) { if (this.active && Number.isFinite(this.timeLeft) && Number.isFinite(seconds)) this.timeLeft += seconds; }
addCharge(n = 1) { if (!Number.isFinite(n)) return; this.charges = Math.max(0, Math.min(this.maxCharges, this.charges + n)); if (this.charges >= this.maxCharges) this.sp = this.spCost; }
stop() { this.end('stopped'); }
+3 -2
View File
@@ -92,8 +92,9 @@ export function unitTuple(u, t) {
const spMax = sk && !sk.noSkill ? sk.spCost : 0;
let sp = sk && !sk.noSkill ? sk.sp : 0;
if (sk && sk.active && sk.isTimed) {
// show remaining duration/ammo as a draining bar
if (sk.kind === 'ammo') sp = spMax * (sk.ammoLeft / Math.max(1, sk.ammo));
// show remaining duration/ammo as a draining bar — ammo out of the activation's real total (base + bullets added:
// 拉特兰, 逃犯引渡手续, refills; community report #35), so every bullet shortens it
if (sk.kind === 'ammo') sp = spMax * (sk.ammoLeft / Math.max(1, sk.ammoMax || sk.ammo || 0, sk.ammoLeft));
else if (Number.isFinite(sk.timeLeft) && sk.duration > 0) sp = spMax * (sk.timeLeft / sk.duration);
}
// hp is rounded up (a living unit never shows 0) but never above the rounded max HP
+95
View File
@@ -0,0 +1,95 @@
// test/render/feedback3-leader-prep.test.js — community report #12 (owner's decision 2026-10-04): in the Final Assault /
// Hidden Core prep the round's leader stands on the boss field (it used to wait in the off-screen pen: research 08's +13
// preview row put it in the pen's upper zone), and while an operator's orange range preview shows, the leader's hit
// tiles — the sim's hit rectangle — are lit in red [ASSUMED colour]. The match layer's preview carries the leader's spawn
// tile (server/match/waves.js previewOf `start`); render/prepfield.js leaderStand + render/pick.js hitTiles (= the sim's
// body.js bodyKeys) give the stand; render/app.js draws it (wiring: test/render/fieldview.browser.test.js). Display only.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { GameData } from '../../server/match/gamedata.js';
import { setupMatchWaves, buildBossWave, previewOf } from '../../server/match/waves.js';
import { createRng } from '../../server/sim/rng.js';
import { bodyKeys, normHitArea } from '../../server/sim/body.js';
import { COLS } from '../../server/sim/constants.js';
import { DATA } from '../match/harness.js';
import { leaderStand } from '../../public/js/render/prepfield.js';
import { hitTiles } from '../../public/js/render/pick.js';
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..');
const read = (p) => readFileSync(path.join(ROOT, p), 'utf8');
test('the preview of a boss round carries the leader\'s spawn tile; other entries never do', () => {
const p = previewOf([
{ enemyKey: 'L', count: 1, time: 0, tag: 'boss', preview: { gate: 'upper', boss: true, start: [3, 10] } },
{ enemyKey: 'a', count: 2, time: 1, preview: { gate: 'lower', start: [3, 2] } },
]);
assert.deepEqual(p[0].start, [3, 10]);
assert.equal('start' in p[1], false, 'escorts stay in the pen');
const gd = new GameData(DATA, 'mode_multi_abyss');
const setup = setupMatchWaves(gd, createRng(1));
for (const bossId of Object.keys(gd.mode.bossWeights)) {
for (const solo of [false, true]) {
const w = buildBossWave(gd, createRng(1), setup.factions, 14, { bossId, solo });
const boss = previewOf(w.spawns).filter((e) => e.boss);
assert.ok(boss.length >= 1, `${w.templateId}: a leader`);
const route = DATA.waves[w.templateId].routes[w.spawns.find((s) => s.tag === 'boss').routeIndex];
for (const e of boss) assert.deepEqual(e.start, route.start.slice(0, 2), `${w.templateId}: the leader's route start`);
}
}
});
test('leaderStand: every leader of the data stands on its spawn tile with the sim\'s hit tiles (bodyKeys)', () => {
const seen = new Set();
for (const [id, tpl] of Object.entries(DATA.waves)) {
if (!tpl || (tpl.kind !== 'boss' && tpl.kind !== 'hidden')) continue;
for (const s of tpl.spawns.filter((x) => x.tag === 'boss')) {
const start = tpl.routes[s.routeIndex].start.slice(0, 2);
const st = leaderStand([{ enemyKey: 'escort', count: 3 }, { enemyKey: s.key, count: 1, boss: true, start }], (k) => DATA.enemies[k]?.hitArea ?? null, hitTiles);
assert.ok(st, id);
assert.deepEqual([st.row, st.col], start);
const sim = bodyKeys({ x: start[1], y: start[0], hitArea: normHitArea(DATA.enemies[s.key].hitArea) }).map((k) => [Math.floor(k / COLS), k % COLS]);
assert.deepEqual(st.tiles, sim, `${id} ${s.key}: the sim's hit tiles`);
seen.add(`${s.key} ${st.tiles.length}`);
}
}
// 胄 / 管 / 昆图斯 / 阿利斯泰尔 / 萨米的意志: 5 × 3 (4.95 × 2.95, up 1); 铳 / 卢西恩 are point units: their tile
assert.ok(seen.has('enemy_9013_acstmk 15') && seen.has('enemy_9032_aclionk 15') && seen.has('enemy_9017_achunt 1'), [...seen].join(', '));
const chou = leaderStand([{ enemyKey: 'enemy_9013_acstmk', boss: true, start: [3, 10] }], (k) => DATA.enemies[k].hitArea, hitTiles);
assert.deepEqual([...new Set(chou.tiles.map(([r]) => r))], [3, 4, 5]);
assert.deepEqual([...new Set(chou.tiles.map(([, c]) => c))], [8, 9, 10, 11, 12]);
assert.equal(leaderStand([{ enemyKey: 'x', boss: true }], () => null, hitTiles), null, 'no spawn tile: it stays in the pen');
assert.equal(leaderStand([{ enemyKey: 'x', boss: false, start: [3, 10] }], () => null, hitTiles), null);
assert.equal(leaderStand(null), null);
});
test('render/app.js: the leader leaves the pen for the boss field, red hit tiles beside a range preview (source checks)', async () => {
const { LEADER_HIT_STYLE, leaderShown } = await import('../../public/js/render/app.js');
assert.equal(LEADER_HIT_STYLE.group, 'leaderHit');
assert.ok((LEADER_HIT_STYLE.color >> 16) > 0xd0 && ((LEADER_HIT_STYLE.color >> 8) & 0xff) < 0x60, 'red');
assert.equal(leaderShown('bossPrep'), true);
assert.equal(leaderShown('prep'), false);
assert.equal(leaderShown('pen', 'bossPrep'), true, 'a flight from the boss-field prep');
const app = read('public/js/render/app.js');
assert.match(app, /const pen = layoutPen\(stand \? list\.filter\(\(e\) => e !== stand\.entry\) : list, \{ stage: stageRec \}\);/);
assert.match(app, /\[\.\.\.hlReq\.keys\(\)\]\.some\(\(g\) => RANGE_GROUPS\.has\(g\)\)/);
});
test('the leader\'s view (render/app.js setLeader: a boss, not a prep view) shows its full HP bar, like the official prep image', async () => {
const { installFakePixi, fakeViewCtx } = await import('./fakepixi.js');
const { presetCamera } = await import('../../public/js/render/projection.js');
const fake = installFakePixi();
try {
const { UnitView } = await import('../../public/js/render/units.js');
const cam = presetCamera('bossPrep', { width: 1600, height: 900 });
const v = new UnitView(fakeViewCtx(fake.P, { cam: () => cam }), { id: 'leader:x', kind: 'enemy', side: 'enemy', defId: 'enemy_9013_acstmk', spine: 'enemy_9013_acstmk', tier: 3, x: 10, y: 3, facing: -1, maxHp: 1, boss: true });
v.update(1 / 60, cam, 0);
assert.equal(v.hpFill.visible, true, 'HP bar shown');
assert.ok(Math.abs(v.hpFill.width - v.hpBg.width + 2) < 1e-6, 'full');
assert.equal(v.spFill.visible, false);
v.destroy();
} finally { fake.restore(); }
});
+91
View File
@@ -0,0 +1,91 @@
// test/render/feedback3-shot-height.test.js — GitHub #61 (0.1.3): shots left an operator at its hips and aimed at the
// target's hips, and every attack shoved the whole model 0.12 tiles toward its target. render/fx.js took 0.45 × the
// head height as a WORLD height, which the 30° camera pitch draws at ≈ 18 % of the (upright, screen-space) model; the
// attack jolt of render/units.js was meant for the avatar diamond only. Now launch / aim points are heights on screen
// (fx.js SHOT_HEIGHT, solved through projection.js Camera.liftFor) and a shown Spine model keeps its place.
import { test, before, after } from 'node:test';
import assert from 'node:assert/strict';
import { installFakePixi, fakeViewCtx } from './fakepixi.js';
import { presetCamera } from '../../public/js/render/projection.js';
let fake, FX, UnitView;
before(async () => {
fake = installFakePixi();
FX = await import('../../public/js/render/fx.js');
({ UnitView } = await import('../../public/js/render/units.js'));
});
after(() => fake.restore());
const cam = presetCamera('normal', { width: 1600, height: 900 });
const close = (a, b, eps, msg) => assert.ok(Math.abs(a - b) <= eps, `${msg}: ${a} vs ${b}`);
test('Camera.liftFor: the world height that draws `px` above a point — exact on every preset camera', () => {
const fields = { normal: [[2, 9, 0], [10, 11, 0.4], [9, 12, 0]], prep: [[2, 7, 0.16], [8, 12, 0]], boss: [[3, 0, 0], [10, 3, 0], [18, 5, 0.4]], unite: [[2, 9, 0], [18, 12, 0]] };
for (const [kind, pts] of Object.entries(fields)) {
const c = presetCamera(kind, { width: 1280, height: 720 });
for (const [x, y, z] of pts) {
for (const px of [0, 20, 64, 140]) {
const h = c.liftFor(x, y, z, px);
close(c.project(x, y, z).y - c.project(x, y, z + h).y, px, 1e-6, `${kind} (${x},${y},${z}) +${px}px`);
}
}
}
const s = cam.scaleAt(5, 10, 0);
assert.ok(cam.liftFor(5, 10, 0, s) > 1.8, 'one tile of screen height is ≈ 2 world tiles under the 30° pitch');
});
/** A unit view as the FX system reads it. */
const unit = (id, x, y, o = {}) => ({ id, x, y, z: 0, hover: 0, _headTiles: 1.18, alive: true, destroyed: false, isEnemy: false, info: { defId: 'char_x' }, onHit() {}, ...o });
function makeFx() {
const ctx = fakeViewCtx(fake.P);
return new FX.FxSystem({
P: fake.P, layers: ctx.layers, cam: () => cam, heightAt: () => 0, settings: { quality: 'high', damageNumbers: true },
timeScale: () => 2, loadLevel: () => 0, subProfOf: () => null, view: () => null, screenSize: () => ({ width: 1600, height: 900 }), fieldTop: () => 120,
});
}
/** Share of a unit's drawn model height (above its feet, on screen) of world point (x, y, z). */
const share = (v, x, y, z) => { const f = cam.project(x, y, v.z); return (f.y - cam.project(x, y, z).y) / (v._headTiles * f.s); };
test('an arrow leaves the shooter at its hands and flies at the target\'s chest (SHOT_HEIGHT), not at the hips', () => {
assert.deepEqual({ ...FX.SHOT_HEIGHT }, { launch: 0.45, aim: 0.5 });
const fx = makeFx();
const src = unit(1, 3, 10), tgt = unit(2, 7, 11, { isEnemy: true, _headTiles: 1.6 });
fx.attack(src, tgt, 'arrow');
const pr = fx.projs[fx.projs.length - 1];
close(share(src, pr.x0, pr.y0, pr.z0), FX.SHOT_HEIGHT.launch, 0.03, 'launch share of the model height');
close(share(tgt, pr.tx, pr.ty, pr.tz), FX.SHOT_HEIGHT.aim, 1e-6, 'aim share of the target\'s model');
// the old rule: 0.45 × head height as a world height → ≈ 18–23 % of the model
assert.ok(share(src, src.x, src.y, 0.45 * src._headTiles) < 0.25, 'the hip height it used to start from');
// a raised / flying unit: measured from where it is drawn
const fly = unit(3, 6, 9, { z: 0.4, hover: 0.32 });
assert.ok(Math.abs(FX.bodyZ(cam, fly, 0.5) - 0.72 - cam.liftFor(6, 9, 0.72, 0.5 * 1.18 * cam.scaleAt(6, 9, 0.72))) < 1e-9);
});
test('the attack jolt only moves the avatar diamond: a shown Spine model keeps its place on an attack', async () => {
const entry = { skel: '/s/x.skel', atlas: '/s/x.atlas', textures: ['/s/x.png'], anims: { idle: 'Idle', attack: { begin: null, loop: 'Attack', end: null } }, animations: { Idle: 1, Attack: 1 } };
let ready = false;
const assets = { picture: () => null, image: async () => null, spineEntry: () => entry,
spine: { acquire: () => (ready ? Promise.resolve({ animations: [{ name: 'Idle' }, { name: 'Attack' }] }) : new Promise(() => {})), release() {} } };
const make = () => new UnitView(fakeViewCtx(fake.P, { assets, cam: () => cam }), { id: 1, side: 'ally', kind: 'op', defId: 'c', spine: 'c', tier: 3, x: 4, y: 10, maxHp: 1000, dir: 'RIGHT' });
const target = { x: 8, y: 12 };
const feet = cam.project(4, 10, 0);
// no model (still loading): the diamond jolts toward the target
const d = make();
d.update(1 / 60, cam, 0);
d.onAttack(target, 1, 'arrow');
d.update(0.1, cam, 0.1); d.update(0.01, cam, 0.11); // (the jolt peaks a tenth of a second in)
assert.ok(Math.hypot(d.root.position.x - feet.x, d.root.position.y - feet.y) > 1, 'the avatar diamond lunges');
// a Spine model shown: no jolt
ready = true;
const v = make();
await new Promise((r) => setImmediate(r)); await new Promise((r) => setImmediate(r));
assert.ok(v.actor && v.spineReady);
v.update(1 / 60, cam, 0);
v.onAttack(target, 1, 'arrow');
for (let i = 0; i < 4; i++) {
v.update(0.05, cam, 0.05 * i);
close(v.root.position.x, feet.x, 1e-6, 'x'); close(v.root.position.y, feet.y, 1e-6, 'y');
}
assert.equal(v.actor.current, 'Attack', 'the model shows the attack with its own clip');
});
+91
View File
@@ -0,0 +1,91 @@
// test/render/feedback3-skill-idle.test.js — community report #23 (0.1.3): "折娅开了技能就会不停做跳起来打人的动作,哪怕没有接敌".
// 折桠's S2 model (char_4207_branch) has Skill_2_Begin, Skill_2_Loop (the jump attack, OnAttack at 0.8 s), Skill_2_End
// and Skill_2_Idle; the actor queued the loop after the begin clip, so it jumped with nobody to hit (the sim made no
// attack). Now a skill with an idle clip of its own (anims skill.idle ≠ skill.loop) stands in that idle between attacks,
// plays the loop only on attacks, and goes back to the idle after a spell of attacks (its End clip only when the skill
// ends). Skills whose loop IS their idle (蕾缪安 S3) and skills without an idle (宴) are unchanged. Real manifest entries,
// headless fake PIXI (test/render/fakepixi.js).
import { test, before, after } from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { installFakePixi } from './fakepixi.js';
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..');
const assets = JSON.parse(readFileSync(path.join(ROOT, 'data/assets.json'), 'utf8'));
let fake, SpineActor;
before(async () => {
fake = installFakePixi();
({ SpineActor } = await import('../../public/js/render/spine.js'));
});
after(() => fake.restore());
/** An actor of a real operator's Front model with its equipped skill, recording every clip set / queued. */
function actor(charId, skillIndex) {
const entry = assets.chars[charId].spine.front;
const a = new SpineActor({ animations: Object.keys(entry.animations).map((name) => ({ name })) }, entry);
a.setSkillIndex(skillIndex);
a.log = [];
const st = a.spine.state;
const set = st.setAnimation, add = st.addAnimation;
st.setAnimation = (i, name, loop) => { a.log.push(['set', name, !!loop]); return set(i, name, loop); };
st.addAnimation = (i, name, loop) => { a.log.push(['queue', name, !!loop]); return add(i, name, loop); };
return a;
}
const run = (a, s, dt = 1 / 60) => { for (let t = 0; t < s - 1e-9; t += dt) a.update(dt); };
const names = (a) => a.log.map(([, n]) => n);
test('折桠 S2: begin, then Skill_2_Idle — the jump attack only on attacks, back to the idle after them, End only when the skill ends', () => {
const a = actor('char_4207_branch', 1);
assert.deepEqual([a.roles.skill.loop, a.roles.skill.idle], ['Skill_2_Loop', 'Skill_2_Idle'], 'the manifest roles');
a.setSkill(true);
assert.deepEqual(a.log, [['set', 'Skill_2_Begin', false], ['queue', 'Skill_2_Idle', true]], 'no Skill_2_Loop queued after the begin clip');
run(a, 3);
assert.equal(a.mode, 'base');
assert.ok(!names(a).includes('Skill_2_Loop'), 'no attack: no jump attack');
// she is hit, an enemy stands on her tile: attacks every 1.5 s
a.log.length = 0;
a.attack(1.5);
assert.equal(a.current, 'Skill_2_Loop', 'the attack plays the skill loop');
run(a, 1.5); a.attack(1.5);
run(a, 1.5 * 1.4 + 0.1);
assert.equal(a.mode, 'base');
assert.equal(a.current, 'Skill_2_Idle', 'the enemy is gone: back to the skill idle');
assert.ok(!names(a).includes('Skill_2_End'), 'the skill End clip is not played between attacks');
a.setSkill(false);
assert.equal(a.current, 'Skill_2_End', 'the skill ends on its End clip');
run(a, 0.2);
assert.equal(a.current, 'Idle');
});
test('the other skills with an own idle clip follow the same rule (史尔特尔 S3 begin → Skill_3_Idle); 耀骑士临光 S3 (no begin) already idled', () => {
const s = actor('char_350_surtr', 2);
s.setSkill(true);
assert.deepEqual(s.log, [['set', 'Skill_3_Begin', false], ['queue', 'Skill_3_Idle', true]]);
s.attack(1.25);
assert.equal(s.current, 'Skill_3_Loop');
const n = actor('char_1014_nearl2', 2);
n.setSkill(true);
assert.deepEqual(n.log, [['set', 'Skill_3_Idle', true]], 'unchanged: the skill idle at once');
n.attack(1.2);
assert.equal(n.current, 'Skill_3');
run(n, 1.2 * 1.4 + 0.1);
assert.equal(n.current, 'Skill_3_Idle');
});
test('unchanged: a loop that is the skill idle (蕾缪安 S3) and a skill without an idle clip (宴: its loop stays the stance)', () => {
const l = actor('char_4193_lemuen', 2);
l.setSkill(true);
assert.deepEqual(l.log, [['set', 'Skill_3_Begin', false], ['queue', 'Skill_3_Idle', true]]);
const u = actor('char_337_utage', 1);
assert.equal(u.roles.skill.idle, null);
u.setSkill(true);
assert.deepEqual(u.log, [['set', 'Skill_Start', false], ['queue', 'Skill_Loop', true]], 'begin → loop, as before');
u.log.length = 0;
u.attack(1.2);
run(u, 1.2 * 1.4 + 0.1);
assert.deepEqual(names(u).slice(-2), ['Skill_End', 'Idle'], 'its spell of attacks still ends as before');
});
+148
View File
@@ -0,0 +1,148 @@
// test/render/feedback3-stuck-load.test.js — GitHub #68 (0.1.3): placeholder models stayed after switching back to a
// tab that had been hidden. A Spine load begun while the tab was hidden can stay pending (a request the background tab
// left hanging); UnitView.retryAssets — called when the tab is shown — only reloaded views with no load in flight, and
// every retry joined PIXI's same pending request. Now such a load gets SPINE_STUCK_MS after the tab is visible, then it
// is started again in place (RefLru.restart: refs and the waiting promise kept, the first success wins, the abandoned
// attempt's slot freed), and a fresh load forgets PIXI's pending requests of that skeleton (forgetPendingSpine).
// Headless fake PIXI (test/render/fakepixi.js).
import { test, describe, before, after } from 'node:test';
import assert from 'node:assert/strict';
import { installFakePixi, fakeViewCtx } from './fakepixi.js';
import { presetCamera } from '../../public/js/render/projection.js';
import { createAssets, RefLru, forgetPendingSpine } from '../../public/js/assets.js';
const tick = () => new Promise((r) => setImmediate(r));
const never = () => new Promise(() => {});
const SP = (id) => ({ skel: `/assets/spine/${id}.skel`, atlas: `/assets/spine/${id}.atlas`, textures: [`/assets/spine/${id}.png`], anims: { idle: 'Idle' }, animations: { Idle: 1 } });
const M = { chars: { char_002_amiya: { avatar: '/a/amiya.png', spine: { front: SP('amiya_f') } } }, ui: {} };
describe('RefLru.restart', () => {
test('a load in flight starts again in place: the holders\' promise settles with the new attempt, refs kept, fresh', async () => {
const calls = [];
let resolveOld;
const lru = new RefLru({ timeout: 0, load: (k, arg, o) => { calls.push(o?.fresh ? 'fresh' : 'plain'); return calls.length === 1 ? new Promise((r) => { resolveOld = r; }) : Promise.resolve(`${k}#2`); }, unload: () => { throw new Error('nothing to unload'); } });
const p = lru.acquire('k');
assert.equal(lru.restart('k'), true);
assert.equal(await p, 'k#2', 'the waiting holder gets the restarted load');
assert.deepEqual(calls, ['plain', 'fresh']);
assert.equal(lru.map.get('k').refs, 1);
resolveOld('k#1'); // the abandoned request settles late: ignored, nothing unloaded
await tick();
assert.equal(lru.peek('k'), 'k#2');
assert.equal(lru.restart('k'), false, 'ready: nothing to restart');
assert.equal(lru.restart('nope'), false);
});
test('the abandoned attempt frees its concurrency slot; only the newest attempt\'s failure fails the entry', async () => {
const started = [];
const lru = new RefLru({ timeout: 0, concurrency: 1, load: (k) => { started.push(k); return k === 'a' && started.filter((x) => x === 'a').length === 1 ? never() : Promise.resolve(k); } });
const pa = lru.acquire('a');
const pb = lru.acquire('b');
await tick();
assert.deepEqual(started, ['a'], 'b waits behind the hung load of a');
lru.restart('a');
assert.equal(await pb, 'b', 'the hung attempt\'s slot is freed at once');
assert.equal(await pa, 'a');
// failures: an abandoned attempt failing changes nothing, the newest one failing fails the entry
let n = 0, rejectOld;
const f = new RefLru({ timeout: 0, load: () => (++n === 1 ? new Promise((_, rej) => { rejectOld = rej; }) : Promise.reject(new Error('second'))) });
const pf = f.acquire('x');
f.restart('x');
await assert.rejects(pf, /second/);
rejectOld(new Error('first'));
await tick();
assert.equal(f.map.get('x').error.message, 'second');
});
});
test('forgetPendingSpine: PIXI\'s pending requests of an unfinished skeleton go, finished ones and kept pages stay', () => {
const prev = globalThis.PIXI;
const e = SP('x');
const page = e.textures[0];
const setup = (done) => {
const promiseCache = { [e.skel]: {}, [e.atlas]: {}, [page]: {}, other: {} };
globalThis.PIXI = { Assets: { loader: { promiseCache }, cache: new Map(done.map((u) => [u, {}])) } };
return promiseCache;
};
try {
let c = setup([]);
assert.equal(forgetPendingSpine(e), 3);
assert.deepEqual(Object.keys(c), ['other']);
c = setup([e.atlas]);
assert.equal(forgetPendingSpine(e), 1, 'the atlas (and so its pages) finished: only the .skel');
assert.ok(c[e.atlas] && c[page]);
c = setup([e.skel]);
assert.equal(forgetPendingSpine(e), 0, 'loaded: nothing pending');
c = setup([]);
forgetPendingSpine(e, new Set([page]));
assert.ok(c[page], 'a page another cached skeleton uses stays');
c = setup([]);
c[`http://127.0.0.1:8080${e.atlas}`] = {};
assert.equal(forgetPendingSpine(e), 4, 'an absolute key of the same path too');
globalThis.PIXI = {};
assert.equal(forgetPendingSpine(e), 0, 'no loader cache: nothing');
} finally { globalThis.PIXI = prev; }
});
describe('views restart a load begun in a hidden tab (render/units.js)', () => {
let fake, UnitView, SPINE_STUCK_MS;
before(async () => {
fake = installFakePixi();
({ UnitView, SPINE_STUCK_MS } = await import('../../public/js/render/units.js'));
});
after(() => fake.restore());
const cam = () => presetCamera('prep', { width: 1280, height: 720 });
const unit = (assets) => new UnitView(fakeViewCtx(fake.P, { assets, cam }), { id: 1, side: 'ally', kind: 'chess', defId: 'char_002_amiya', spine: 'char_002_amiya', avatar: 'char_002_amiya', tier: 3, x: 5, y: 12, maxHp: 1000 }, { prep: true });
const hidden = (on) => { globalThis.document.hidden = on; globalThis.document.visibilityState = on ? 'hidden' : 'visible'; };
test('pending when the tab is shown: SPINE_STUCK_MS later it is loaded again (fresh) and the model shows', async () => {
assert.equal(SPINE_STUCK_MS, 5000);
const calls = [];
const a = createAssets({ manifest: M, spineTimeout: 0, loadImage: async () => null, unloadSpine: () => {},
loadSpine: (e, o) => { calls.push(o?.fresh ? 'fresh' : 'plain'); return calls.length === 1 ? never() : Promise.resolve({ animations: [{ name: 'Idle' }] }); } });
hidden(true);
const v = unit(a);
await tick();
assert.equal(v._spineBusy, true);
v.retryAssets(); // the manifest arriving while still hidden arms nothing
assert.equal(v._stuckAt, 0);
hidden(false);
v.retryAssets(); // render/app.js visibilitychange
assert.ok(v._stuckAt > 0, 'armed: SPINE_STUCK_MS to finish');
v.update(1 / 60, cam(), 0);
await tick();
assert.deepEqual(calls, ['plain'], 'not before its time: a load about to succeed is not doubled');
v._stuckAt = 1; // due
v.update(1 / 60, cam(), 0);
await tick(); await tick();
assert.deepEqual(calls, ['plain', 'fresh'], 'started again, not joined to the pending request');
assert.ok(v.actor && v.spineReady, 'the model is shown');
assert.equal(a.spine.stats().refs, 1, 'one acquire, one reference');
v.destroy();
assert.equal(a.spine.stats().refs, 0);
});
test('a load that finishes within the grace is never doubled; one begun while visible is left to its timeout', async () => {
let resolve;
const calls = [];
const a = createAssets({ manifest: M, spineTimeout: 0, loadImage: async () => null, unloadSpine: () => {},
loadSpine: () => { calls.push(1); return new Promise((r) => { resolve = r; }); } });
hidden(true);
const v = unit(a);
hidden(false);
v.retryAssets();
assert.ok(v._stuckAt > 0);
resolve({ animations: [{ name: 'Idle' }] });
await tick(); await tick();
assert.ok(v.actor);
assert.equal(v._stuckAt, 0, 'settled: disarmed');
v.update(1 / 60, cam(), 0);
assert.equal(calls.length, 1);
v.destroy();
const w = unit(createAssets({ manifest: M, spineTimeout: 0, loadImage: async () => null, loadSpine: () => never() }));
w.retryAssets();
assert.equal(w._stuckAt, 0, 'begun while visible: the 20 s timeout and the bounded retries handle it');
w.destroy();
});
});
+50 -2
View File
@@ -1,9 +1,10 @@
// test/render/fieldview.browser.test.js — field-view features in headless Chrome through the dev demo
// (public/dev/render-demo.html): the enemy preview pen (research 09 §2.2 / 08 §4.2) on the 3D and the 2D board and
// the 0.25 s pan to it and back, the Final Assault prep on the player's half of the boss field (research 09 §1.2:
// drop targets / directions stay in board space, the right half mirrored), damage numbers that never touch, the
// drop targets / directions stay in board space, the right half mirrored) with the round's leader on its spawn tile and
// its red hit tiles beside a range preview (community report #12), damage numbers that never touch, the
// automatic rebuild of a 3D board whose WebGL context was lost, and frame rates on a 4× throttled CPU.
// Screenshots → test/e2e/out/pen-*.png, fa-prep-*.png, dmgnum-*.png; perf → test/e2e/out/fieldview-perf.json.
// Screenshots → test/e2e/out/pen-*.png, fa-prep-*.png, fa-leader-*.png, dmgnum-*.png; perf → test/e2e/out/fieldview-perf.json.
//
// Opt-in (starts Chrome): RENDER_E2E=1 node --test test/render/fieldview.browser.test.js
// Run browser test files one at a time. Chrome path: $CHROME_PATH or the macOS default.
@@ -226,6 +227,53 @@ describe('field view features in headless Chrome', { skip }, () => {
});
}
for (const side of ['L', 'R']) {
test(`Final Assault prep (${side}): the leader stands on its spawn tile, out of the pen; a range preview lights its hit tiles in red (report #12)`, async () => {
const { page, problems } = await open(`scene=prep&fa=${side}&stage=act2autochess_m02`, 1920, 1080);
await wait(1500);
const r = await page.evaluate(async () => {
const v = window.__demo.view, d = v.debug;
// m.private as the server sends it in a boss round's prep: nextEnemies with the leader's spawn tile (waves.js previewOf)
const st = JSON.parse(JSON.stringify(window.__demo.scene.state));
st.nextEnemies = [
{ enemyKey: 'enemy_9013_acstmk', count: 1, gate: 'upper', t: 0, fly: false, elite: true, boss: true, source: 'wave', tag: 'boss', start: [3, 10] },
{ enemyKey: 'enemy_1007_slime', count: 3, gate: 'lower', t: 2, fly: false, elite: false, boss: false, source: 'wave', tag: null },
];
v.setPrep(st, { editable: true });
await new Promise((res) => setTimeout(res, 2000));
const L = d.leader;
const hl = () => (d.tiles.highlights.get('leaderHit')?.tiles || []).map((t) => t.join(','));
const out = { has: !!L, x: L?.view.x, y: L?.view.y, shown: !!L?.view.root.visible, facing: L?.view.visFacing, spine: !!L?.view.actor,
pen: [...d.penViews.values()].map((p) => p.info.defId), before: hl() };
v.highlightTiles([[10, 5], [10, 6], [10, 7]], { group: 'facing', color: 0xff9c33, fill: 0.36, line: 1 });
out.during = hl();
out.range = (d.tiles.highlights.get('facing')?.tiles || []).length;
return out;
});
await wait(300);
await page.screenshot({ path: path.join(OUT, `fa-leader-${side}.png`) });
const r2 = await page.evaluate(() => {
const v = window.__demo.view, d = v.debug;
v.highlightTiles([], { group: 'facing' });
const after = (d.tiles.highlights.get('leaderHit')?.tiles || []).length;
v.setCamera('prep', { instant: true });
return { after, hiddenOnBoard: !d.leader.view.root.visible };
});
await page.close();
assert.deepEqual(problems, []);
assert.deepEqual([r.has, r.x, r.y, r.shown], [true, 10, 3, true], 'on its spawn tile (3, 10), shown by the boss-field prep camera');
assert.equal(r.facing, side === 'R' ? 1 : -1, 'facing the player\'s half');
assert.ok(r.spine, 'its Spine model');
assert.ok(!r.pen.includes('enemy_9013_acstmk') && r.pen.length === 3, `not in the pen: ${r.pen}`);
assert.deepEqual(r.before, [], 'no red tiles without a range preview');
assert.equal(r.range, 3);
assert.equal(r.during.length, 15, `the 5 × 3 hit tiles: ${r.during}`);
assert.ok(['3,8', '3,12', '5,8', '5,12', '4,10'].every((t) => r.during.includes(t)));
assert.equal(r2.after, 0, 'gone with the range preview');
assert.equal(r2.hiddenOnBoard, true, 'the own-board camera does not show it');
});
}
test('damage numbers: a boss and a knot of minions under rapid mixed hits never show touching numbers', async () => {
const { page, problems } = await open('scene=numbers', 1920, 1080);
await wait(2500);
+61
View File
@@ -0,0 +1,61 @@
// test/sim/feedback3-ammo-bar.test.js — community report #35 (0.1.3): "拉特兰盟约叠层之后蕾缪安3技能锁定敌人消耗的子弹不会实时让技力条一格
// 一格减少,而是最后突然消耗". 拉特兰's skillStart multiplies the bullets (×(1.05 + 0.015 × layers), floored) and 逃犯引渡手续 adds
// one, but the snapshot drew ammoLeft / skill.ammo (the base 5) and the client clamps at 1: the bar stayed full until
// fewer than 5 bullets were left. Now the draining bar is ammoLeft / ammoMax — the most bullets this activation held
// (set at activation, raised by skillStart additions and addAmmo) — so it starts full and every bullet shortens it.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { makeBattle, enemyRec, checkInvariants } from '../helpers/battleHarness.js';
import { unitTuple } from '../../server/sim/snapshot.js';
const DEFS = { enemies: { enemy_dummy: enemyRec({ key: 'enemy_dummy', hp: 1e7, speed: 0 }) } };
const bar = (u, t) => { const [, , , , , sp, spMax] = unitTuple(u, t); return sp / spMax; };
test('蕾缪安 S3 with 30 拉特兰 layers: the bar starts full and loses one bullet\'s share per lock (base 5 + extras)', () => {
const seen = [];
const h = makeBattle({
defs: DEFS, timeLimit: 200, seed: 7,
units: [{ chessId: 'chess_char_6_01_a', row: 10, col: 3 }],
enemies: [{ key: 'enemy_dummy', pos: [10, 5] }],
bonds: { lateranoShip: { count: 3, active: true, tier: 1, layers: 30 } },
setup(b) {
b.on('ammoUsed', ({ unit, left, skill }) => seen.push({ left, max: skill.ammoMax, bar: bar(unit, b.time) }), { priority: -999 });
},
});
const u = h.unit('chess_char_6_01_a');
assert.equal(u.skill.ammo, 5, 'S3: 5 bullets (attack@trigger_time)');
assert.ok(h.runUntil(() => u.skill.active, 80));
assert.ok(h.runUntil(() => !u.skill.active, 30));
assert.ok(seen.length >= 7, `${seen.length} bullets: 5 × 1.5 floored, + 逃犯引渡手续`);
const full = seen[0].left + 1;
for (const s of seen) assert.equal(s.max, full, 'the full mark is the activation\'s real total');
seen.forEach((s, i) => assert.ok(Math.abs(s.bar - (full - 1 - i) / full) < 0.01, `bullet ${i + 1}: bar ${s.bar.toFixed(3)} = ${full - 1 - i}/${full}`));
assert.ok(seen[0].bar < 1 - 0.1, 'the first lock already shortens the bar (it used to stay full until < 5 were left)');
assert.equal(u.skill.ammoMax, 0, 'reset when the skill ends');
checkInvariants(h.b);
});
test('the full mark follows bullets added mid-skill only above it; without extras the bar is unchanged (n / base)', () => {
const h = makeBattle({
defs: DEFS, timeLimit: 200, seed: 7,
units: [{ chessId: 'chess_char_6_01_a', row: 10, col: 3 }],
enemies: [{ key: 'enemy_dummy', pos: [10, 5] }],
});
const u = h.unit('chess_char_6_01_a');
h.step(1);
u.skill.activate('test', { free: true });
const sk = u.skill;
assert.equal(sk.ammoMax, sk.ammoLeft);
const base = sk.ammoMax;
assert.ok(Math.abs(bar(u, h.b.time) - 1) < 1e-9, 'full at the start');
sk.ammoLeft -= 2; // two locks
assert.ok(Math.abs(bar(u, h.b.time) - (base - 2) / base) < 0.01);
sk.addAmmo(1); // a refill under the mark: the mark stays
assert.equal(sk.ammoMax, base);
sk.addAmmo(3); // above it: the mark rises, the bar is full again
assert.equal(sk.ammoMax, base + 2);
assert.ok(Math.abs(bar(u, h.b.time) - 1) < 1e-9);
sk.end('test');
assert.equal(sk.ammoMax, 0);
});
+86
View File
@@ -0,0 +1,86 @@
// test/ui/feedback3-audio-mix.test.js — community report #30 (0.1.3): "狗、飞机敌人音效声音过大". The official attack bank of
// 猎狗pro / 深池侦察犬 is 80 % silence (an empty asset weighs 80, e_atk_gopro_* 20), but the asset tool kept only the file
// paths, so the client barked on every attack at a fixed 0.55; 妖怪's attack bank is official at volume 0.7. The bank's
// play chance and volume now travel as sfx.units[id].mix (tools/assets/audio.mjs bankMix → plan.mjs) and audio.js plays a
// unit's own sound with chance `p` at its base gain × min(1, vol) — never louder than before.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync, existsSync } from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { AudioManager, unitGain, unitSoundPlays } from '../../public/js/audio.js';
import { indexAudio, pickUnitSfx, bankMix } from '../../tools/assets/audio.mjs';
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..');
const manifest = JSON.parse(readFileSync(path.join(ROOT, 'data', 'assets.json'), 'utf8'));
const UNITS = manifest.audio.sfx.units;
const OFFICIAL = path.join(ROOT, '.cache', 'gamedata', 'excel', 'audio_data.json');
/** An audio manager with a context, recording what it would play: [{ url, volume, unitKey }]. */
function manager(rolls) {
const a = new AudioManager({ win: null, getManifest: () => manifest, random: () => rolls.shift() ?? 0 });
a.ctx = {};
a.played = [];
a._play = (url, o) => { a.played.push({ url, volume: o.volume, unitKey: o.unitKey }); };
return a;
}
test('the bank mix: an empty asset is a chance of silence, the played file\'s volume (bankMix, indexAudio().mixOf)', () => {
const gopro = [
{ asset: '', weight: 80, minVolume: 1, maxVolume: 1 },
{ asset: 'Audio/Sound_Beta_2/Enemy/e_atk/e_atk_gopro_1', weight: 10, minVolume: 1, maxVolume: 1 },
{ asset: 'Audio/Sound_Beta_2/Enemy/e_atk/e_atk_gopro_2', weight: 10, minVolume: 1, maxVolume: 1 },
];
assert.deepEqual(bankMix(gopro, 'enemy/e_atk/e_atk_gopro_1.mp3'), { p: 0.2 });
assert.deepEqual(bankMix([{ asset: 'Audio/Sound_Beta_2/Enemy/e_atk/e_atk_vehicle_n', weight: 1, minVolume: 0.7, maxVolume: 0.7 }], 'enemy/e_atk/e_atk_vehicle_n.mp3'), { vol: 0.7 });
assert.equal(bankMix([{ asset: 'Audio/Sound_Beta_2/P/x', weight: 1, minVolume: 1, maxVolume: 1 }], 'p/x.mp3'), null, 'the default: nothing stored');
const idx = indexAudio({ soundFXBanks: [
{ name: 'battle.ON_ABILITY_ON.enemy_1000_gopro_2.combat', sounds: gopro },
{ name: 'battle.ON_ABILITY_HIT.enemy_1000_gopro_2.combat', sounds: [{ asset: 'Audio/Sound_Beta_2/Enemy/e_imp/e_imp_general_w', weight: 1, minVolume: 1, maxVolume: 1 }] },
] });
const sfx = pickUnitSfx(idx.unitBanks.get('enemy_1000_gopro_2'));
assert.deepEqual(sfx.attack, ['enemy/e_atk/e_atk_gopro_1.mp3', 'enemy/e_atk/e_atk_gopro_2.mp3']);
assert.deepEqual(idx.mixOf(sfx.attack), { p: 0.2 }, 'the picked list carries its bank\'s mix');
assert.equal(idx.mixOf(sfx.hit), null);
assert.equal(idx.mixOf(['enemy/e_atk/e_atk_gopro_1.mp3']), null, 'only the bank\'s own list');
});
test('data/assets.json carries the official mix: dogs 20 %, 妖怪 / 威龙 0.7 (and matches audio_data.json when cached)', () => {
for (const id of ['enemy_1000_gopro_2', 'enemy_1165_duhond', 'enemy_1165_duhond_2']) assert.deepEqual(UNITS[id].mix, { attack: { p: 0.2 } }, id);
for (const id of ['enemy_1005_yokai_2', 'enemy_1005_yokai_3']) assert.deepEqual(UNITS[id].mix, { attack: { vol: 0.7 } }, id);
assert.equal(UNITS.char_002_amiya?.mix, undefined, 'default banks store nothing');
if (!existsSync(OFFICIAL)) return;
const banks = new Map(JSON.parse(readFileSync(OFFICIAL, 'utf8')).soundFXBanks.map((b) => [b.name, b]));
const dog = banks.get('battle.ON_ABILITY_ON.enemy_1165_duhond.combat').sounds;
assert.equal(dog.filter((s) => !s.asset).reduce((n, s) => n + s.weight, 0) / dog.reduce((n, s) => n + s.weight, 0), 0.8, 'official: 80 % silence');
assert.equal(banks.get('battle.ON_ABILITY_ON.enemy_1005_yokai_2.attack').sounds[0].maxVolume, 0.7);
});
test('a dog barks only on its chance and never falls back to the generic enemy sound; at most the old gain', () => {
assert.equal(unitSoundPlays({ p: 0.2 }, 0.19), true);
assert.equal(unitSoundPlays({ p: 0.2 }, 0.2), false);
assert.equal(unitSoundPlays(null, 0.99), true);
const a = manager([0.5, 0.9, 0.1, 0.3, 0.05]);
a.setFieldUnits([{ id: 1, side: 'enemy', kind: 'enemy', spine: 'enemy_1000_gopro_2' }, { id: 2, side: 'ally', kind: 'op', spine: 'char_002_amiya' }]);
for (let i = 0; i < 5; i++) { a.limiter = new a.limiter.constructor(); a.handleBattleEvents([['atk', 1, 2, 'none']]); }
const barks = a.played.filter((p) => p.url === UNITS.enemy_1000_gopro_2.attack);
assert.equal(barks.length, 2, 'rolls 0.1 and 0.05 of 0.5 / 0.9 / 0.1 / 0.3 / 0.05');
for (const b of barks) assert.equal(b.volume, 0.55, 'official volume 1: the same gain as before');
assert.equal(a.played.length, 2, 'the silent rolls play nothing — not the generic enemyHit either');
});
test('妖怪 plays at 0.55 × 0.7; a bank louder than 1 stays at the old gain; die / born use the mix too', () => {
assert.equal(unitGain(0.55, { vol: 0.7 }), 0.55 * 0.7);
assert.equal(unitGain(0.55, { vol: 2 }), 0.55, 'never louder than before');
assert.equal(unitGain(0.8, null), 0.8);
const a = manager([]);
assert.equal(a.unit('enemy_1005_yokai_2', 'attack', 7), true);
assert.ok(Math.abs(a.played[0].volume - 0.385) < 1e-9);
assert.equal(a.unit('char_1028_texas2', 'hit', 8), true);
assert.equal(a.played[1].volume, 0.55, 'official 2: unchanged');
a.setFieldUnits([{ id: 3, side: 'ally', kind: 'op', spine: 'char_2026_yu', defId: 'chess_char_x' }]);
a.handleBattleEvents([['deploy', 3]]);
const born = a.played.find((p) => p.url === UNITS.char_2026_yu.born);
assert.ok(born && Math.abs(born.volume - 0.8 * 0.75) < 1e-9, '余 ON_UNIT_BORN at 0.75');
});
+41 -2
View File
@@ -10,6 +10,9 @@
// names used by public/js/audio.js, plus a few generic UI sounds where the
// autochess mode has no dedicated bank (buy/refresh/error/pick/drop).
// Every sound is addressed by its path under sound_beta_2 (lower-case, .mp3).
// - A bank's mix (bankMix, community report #30): the official banks weigh their sounds, and an empty asset is a chance
// of silence (猎狗 / 深池侦察犬 bark on 20 of 100 attacks), and give each sound a volume; `indexAudio().mixOf(paths)`
// returns { p?, vol? } of the bank a picked path list came from (plan.mjs writes it as sfx.units[id].mix).
const PREFIX_RE = /^audio\/sound_beta_2\//i;
@@ -25,20 +28,55 @@ export function assetToPath(asset) {
return p.endsWith('.mp3') ? p : p + '.mp3';
}
const round3 = (v) => Math.round(v * 1000) / 1000;
/**
* The official play chance and volume of a bank, for the file the client plays (`path`, the first of its list):
* `p` = the weight of the sounds that have a file over all the weights (an empty asset is a chance of silence),
* `vol` = that file's volume (the mean of minVolume / maxVolume). Only what differs from 1 is returned; null when
* neither does (the default: every attack plays its sound at the base gain).
* @param {Array<{ asset?: string, weight?: number, minVolume?: number, maxVolume?: number }>} sounds
* @param {string} path the played file (assetToPath form)
* @returns {{ p?: number, vol?: number } | null}
*/
export function bankMix(sounds, path) {
let total = 0, real = 0, vol = 1, found = false;
for (const s of Array.isArray(sounds) ? sounds : []) {
const w = Number(s && s.weight);
const wt = Number.isFinite(w) && w > 0 ? w : 0;
total += wt;
const p = assetToPath(s && s.asset);
if (!p) continue;
real += wt;
if (!found && p === path) {
found = true;
const lo = Number(s.minVolume), hi = Number(s.maxVolume);
if (Number.isFinite(lo) && Number.isFinite(hi) && lo >= 0 && hi >= 0) vol = round3((lo + hi) / 2);
}
}
const out = {};
if (total > 0 && real < total) out.p = round3(real / total);
if (vol !== 1) out.vol = vol;
return Object.keys(out).length ? out : null;
}
/**
* Index an audio_data.json object.
* @param {any} audioData parsed excel/audio_data.json
* @returns {{ bank: (name:string)=>string[], bgm: (name:string)=>({intro:string|null, loop:string}|null),
* unitBanks: Map<string, Map<string, string[]>>, skillBanks: Map<string, Map<string,string[]>> }}
* unitBanks: Map<string, Map<string, string[]>>, skillBanks: Map<string, Map<string,string[]>>,
* mixOf: (paths: string[]|null|undefined) => ({ p?: number, vol?: number }|null) }}
*/
export function indexAudio(audioData) {
const banks = new Map();
const mixes = new WeakMap(); // a bank's path list (the very array every lookup hands out) → bankMix
for (const b of Array.isArray(audioData?.soundFXBanks) ? audioData.soundFXBanks : []) {
if (!b || typeof b.name !== 'string') continue;
const paths = (Array.isArray(b.sounds) ? b.sounds : []).map((s) => assetToPath(s?.asset)).filter(Boolean);
if (!banks.has(b.name)) banks.set(b.name, []);
const list = banks.get(b.name);
for (const p of paths) if (!list.includes(p)) list.push(p);
if (list.length && !mixes.has(list)) { const m = bankMix(b.sounds, list[0]); if (m) mixes.set(list, m); }
}
const alias = audioData?.bankAlias && typeof audioData.bankAlias === 'object' ? audioData.bankAlias : {};
const bank = (name, depth = 0) => {
@@ -77,7 +115,8 @@ export function indexAudio(audioData) {
};
for (const [name, paths] of banks) if (paths.length) addUnit(name, paths);
for (const name of Object.keys(alias)) if (!banks.has(name)) { const p = bank(name); if (p.length) addUnit(name, p); }
return { bank, bgm, unitBanks, skillBanks };
const mixOf = (paths) => (paths && typeof paths === 'object' && mixes.get(paths)) || null;
return { bank, bgm, unitBanks, skillBanks, mixOf };
}
/** Sort key for ability sub-keys: plain first, then numeric suffixes ascending. */
+23 -8
View File
@@ -129,6 +129,23 @@ function soundLeaf(paths, sub = 'sfx', max = 4) {
return leaf((paths || []).slice(0, max).map((p) => soundAlt(p, sub)));
}
/**
* A unit's SFX roles (pickUnitSfx) → { roles: { attack?, hit?, die?, born? } sound leaves, mix: { [role]: { p?, vol? } }
* | null } — the official play chance / volume of each role's bank (audio.mjs bankMix; community report #30: 猎狗's
* attack bank is 80 % silence). The caller stores `mix` last in the unit's entry (sfx.units[id].mix).
*/
function unitSounds(audio, sfx) {
const roles = {};
let mix = null;
for (const r of ['attack', 'hit', 'die', 'born']) {
if (!sfx[r]) continue;
roles[r] = soundLeaf(sfx[r]);
const m = typeof audio.mixOf === 'function' ? audio.mixOf(sfx[r]) : null;
if (m) (mix || (mix = {}))[r] = m;
}
return { roles, mix };
}
/** Expected bytes for a 07 {url, bytes} record when it matches `url`. */
function bytesOf(rec, url) {
return rec && typeof rec === 'object' && rec.url === url && Number.isInteger(rec.bytes) ? rec.bytes : undefined;
@@ -286,8 +303,7 @@ export function buildPlan({ assets07, ops03, enemies05, maps05, audio, modelsDat
const short = id.replace(/^char_\d+_/, '');
const sfx = pickUnitSfx(audio.unitBanks.get(id), { operator: true, projectile: {
born: audio.bank(`battle.ON_PROJECTILE_BORN.projectile_chr_${short}`), hit: audio.bank(`battle.ON_PROJECTILE_HIT.projectile_chr_${short}`) } });
const u = {};
for (const r of ['attack', 'hit', 'die', 'born']) if (sfx[r]) u[r] = soundLeaf(sfx[r]);
const { roles: u, mix } = unitSounds(audio, sfx);
const skillSfx = {};
for (const i of idx) {
const s = (o.skills || []).find((k) => k.index === i);
@@ -301,6 +317,7 @@ export function buildPlan({ assets07, ops03, enemies05, maps05, audio, modelsDat
const primarySkill = skillSfx[String(idx[0])];
if (primarySkill) u.skill = primarySkill;
if (Object.keys(skillSfx).length > 1) u.skills = skillSfx;
if (mix) u.mix = mix;
if (Object.keys(u).length) unitsSfx[id] = u;
}
@@ -334,9 +351,8 @@ export function buildPlan({ assets07, ops03, enemies05, maps05, audio, modelsDat
}
entry.spine = model;
tokens[id] = entry;
const sfx = pickUnitSfx(audio.unitBanks.get(id));
const u = {};
for (const r of ['attack', 'hit', 'die', 'born']) if (sfx[r]) u[r] = soundLeaf(sfx[r]);
const { roles: u, mix } = unitSounds(audio, pickUnitSfx(audio.unitBanks.get(id)));
if (mix) u.mix = mix;
if (Object.keys(u).length) unitsSfx[id] = u;
}
@@ -406,9 +422,8 @@ export function buildPlan({ assets07, ops03, enemies05, maps05, audio, modelsDat
if (banks?.size) break;
if (other) banks = audio.unitBanks.get(other);
}
const sfx = pickUnitSfx(banks);
const u = {};
for (const r of ['attack', 'hit', 'die', 'born']) if (sfx[r]) u[r] = soundLeaf(sfx[r]);
const { roles: u, mix } = unitSounds(audio, pickUnitSfx(banks));
if (mix) u.mix = mix;
if (Object.keys(u).length) unitsSfx[id] = u;
}