The leak alarm: an enemy entering the blue gate plays the official stage sound (ON_ENEMY_REACHED_EXIT), one alarm per burst (#134)

Battle.leak emits its own 'leak' event, which no sound handled, so every leak was silent; the client now plays battle.ON_ENEMY_REACHED_EXIT (b_ui_alarmenter) with the bank's own one-at-a-time rule, leaks closer than the sound's length sharing one alarm. The manifest gains only audio.sfx.battle.leak. Contributed by @Convey123.
This commit is contained in:
Cartethyia authored and GitHub committed 2026-10-06 14:06:43 +08:00
1 parent a9dfd17bee
commit d582925816
6 files changed
+86 -2

No files matched your search

+1 -1
View File
@@ -1 +1 @@
{"version":1,"hash":"9e240a72da75","generator":"tools/fetch-assets.mjs","stats":{"files":5699,"bytes":331609355,"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,"voiceChars":120},"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":"SLine truncated
{"version":1,"hash":"2249e4a0918a","generator":"tools/fetch-assets.mjs","stats":{"files":5700,"bytes":331632595,"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,"voiceChars":120},"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":"SLine truncated
+1 -1
View File
@@ -182,7 +182,7 @@ All paths are URL paths relative to the site root, for example `/assets/char/ava
defenceStart, defenceUnite, battleOverReduce, battleOverNoReduce, battleOverNormal, goFirst,
disconnect, settlementSucceed, settlementFail, settlementTeam, settlementBossSign,
goodEvaluation, load, start, matchSucceed, matchFail, matchCancel, joinRoom },
battle: { deploy, tokenDeploy, charDie, enemyDie, enemyDieHeavy, enemyHit, heal, win, lose, killCoin },
battle: { deploy, tokenDeploy, charDie, enemyDie, enemyDieHeavy, enemyHit, heal, leak, win, lose, killCoin },
units: { [charId|tokenId|enemyId]: { attack?, hit?, skill?, skills?: {[skillIndex]: url}, die?, born?,
mix?: { [attack|hit|die|born]: { p?, vol? } } } }
}
+1
View File
@@ -88,6 +88,7 @@
- 联防中击杀悬赏敌人,资金归击杀者。
- 联防后仍然存活的敌人,按原主人扣 LP;没人完美作战时直接扣。
- 漏怪的玩家在顶栏中间(击倒数旁)看到橙色的「×N」标签:你漏过、仍在联防战场上的敌人数(含还没出场的,**不设上限**),队友每击倒一个就减一(会分裂或召唤的敌人被击倒时,分裂出的敌人也算你的,数字会相应增加);目标生命值同时显示为扣除 min(N, 10) 后的数值。左侧队伍栏里漏怪队友的行也显示 ×N(与战场上的击倒同步更新;鼠标悬停可看到是谁漏过的敌人)。
- **有敌人走进蓝门就会响一声**:不等它真的扣到目标生命值——一回合里超过 10 点上限的那部分、数据里标明不计数目的敌人(BOSS 部件之类),以及联防里漏过去、随后被队友清掉的那几只,目标生命值都不会再扣,但这一声照样会响。播的是**原版关卡里怪进蓝门那一声**(官方音效表里的 `ON_ENEMY_REACHED_EXIT`,任何一关都是它)。你正在看哪个战场就听哪个战场的,所以看队友的战场、以及漏怪后观看联防时同样听得到。这一声 1.44 秒,我们按官方的「同时只允许一个」做成 **1.5 秒内共用一声**:一波打穿防线只响一遍(不会叠成一片),过一会儿再漏会再响。
- 每个普通回合每人**最多扣 10 点 LP**。
## 6. 盟约
+19
View File
@@ -53,6 +53,15 @@ const MAX_VOICES = 8;
const UNIT_COOLDOWN_MS = 160;
const URL_GAP_MS = 45;
const MAX_PER_URL = 2;
/**
* 漏怪: the original Arknights exit alarm (`sfx.battle.leak`, `battle/b_ui/b_ui_alarmenter`) runs **1.44 s**, and the
* official bank is a one-shot: `battle.ON_ENEMY_REACHED_EXIT` carries `maxSoundAllowed: 1` with `popOldest: true` on the
* `Battle_UI_Important` mixer, i.e. **never two at once** (a new escape replaces the one still ringing). We keep the
* "never two at once" half and leave the rest of the cue alone: leaks closer together than the cue is long are the same
* disaster and share one alarm, so a line that breaks costs one clear ring per 1.5 s instead of a stutter of restarts.
* (The SFX limiter still applies on top.)
*/
const LEAK_SFX_GAP_MS = 1500;
const BUFFER_CACHE = 180;
/** Decoded-PCM budget of the buffer cache beside its entry count: a voice line decodes to 0.4–1.3 MB (see _buffer). */
const BUFFER_BYTES = 64 * 1024 * 1024;
@@ -971,6 +980,16 @@ export class AudioManager {
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 === 'leak') {
// 漏怪: an enemy reached its goal (Battle.leak emits the sim's own EV.LEAK — it is NOT a `die`, so until now a
// leak was completely silent, for the player's own field and for a 联防 the helpers could not hold alike).
// The cue is the ORIGINAL Arknights stage alarm — the one an enemy entering the exit plays in any normal
// stage (manifest `sfx.battle.leak`, bank battle.ON_ENEMY_REACHED_EXIT, file b_ui_alarmenter).
// `LEAK_SFX_GAP_MS` keeps it to one alarm at a time (the official bank's own maxSoundAllowed 1).
if (now - (this.lastLeakSfxAt ?? -Infinity) < LEAK_SFX_GAP_MS) continue;
if (typeof this.getManifest()?.audio?.sfx?.battle?.leak !== 'string') continue;
this.lastLeakSfxAt = now;
this.battle('leak', { unitKey: 'leak', volume: 0.85 });
} else if (kind === 'deploy') {
const u = this.units.get(e[1]);
if (!u || u.side === 'enemy') continue;
+59
View File
@@ -650,3 +650,62 @@ describe('impact sounds (user playtest #4 item 6)', () => {
} finally { globalThis.performance = perf; restore(); }
});
});
// =====================================================================================================================
// 漏怪 sound (user request "接下来加漏怪的音效", then "应该是原版明日方舟关卡中的怪进蓝门的音效"). The sim emits
// `['leak', id]` when an enemy reaches its goal (Battle.leak) — NOT a `die` — so until now an escape was completely
// silent, for the player's own field and for a 联防 the helpers could not hold alike.
//
// The cue is the ORIGINAL Arknights stage alarm an enemy entering the exit plays in any normal stage: the manifest's
// `sfx.battle.leak`, bank `battle.ON_ENEMY_REACHED_EXIT`, file `Battle/b_ui/b_ui_alarmenter`. (The autochess banks
// have nothing named for an escape — all 13,948 SFX banks searched — but the stage itself does.) The official bank is
// a one-shot: `maxSoundAllowed: 1` with `popOldest: true` on the `Battle_UI_Important` mixer.
describe('漏怪 sound', () => {
async function rig() {
const fw = fakeWindow();
const urls = [];
const origFetch = globalThis.fetch;
globalThis.fetch = async (u) => { urls.push(u); return { ok: true, arrayBuffer: async () => new ArrayBuffer(8) }; };
const a = new AudioManager({ win: fw.win, getManifest: () => manifest });
a.install();
fw.fire('pointerdown');
const settle = () => new Promise((r) => setTimeout(r, 10));
return { a, fw, urls, settle, restore: () => { globalThis.fetch = origFetch; } };
}
test('an escaped enemy plays the original stage exit alarm — and no death sound (a leak is not a `die`)', async () => {
const { a, urls, settle, restore } = await rig();
try {
const url = manifest.audio.sfx.battle.leak;
assert.ok(url, '前提:清单里有 sfx.battle.leak');
assert.match(url, /b_ui_alarmenter\.mp3$/, '就是原版关卡里怪进蓝门那一声');
a.handleBattleEvents([['leak', 7]]);
await settle();
assert.ok(asked(urls, url), `漏怪 plays ${url}`);
assert.equal(askedCount(urls, manifest.audio.sfx.battle.enemyDie), 0, 'a leak is not a death — no death sound');
} finally { restore(); }
});
test('leaks of one disaster are ONE alarm (the cue is 1.44 s long), a later one rings again', async () => {
const { a, fw, urls, settle, restore } = await rig();
try {
const url = manifest.audio.sfx.battle.leak;
a.handleBattleEvents([['leak', 1]]);
await settle();
assert.equal(askedCount(urls, url), 1, 'the first escape rings');
// the plays themselves, not the fetches: the buffer is cached after the first one
const before = fw.made.started;
// six more at once — a wiped board, or a 联防 the helpers could not hold
a.handleBattleEvents([['leak', 2], ['leak', 3], ['leak', 4], ['leak', 5], ['leak', 6], ['leak', 7]]);
await settle();
assert.equal(fw.made.started - before, 0, 'one disaster never stacks alarms (the official bank allows 1)');
// a genuine later leak is a new disaster and rings again, once the cue (1.44 s) has finished
await new Promise((r) => setTimeout(r, 1600));
a.handleBattleEvents([['leak', 8]]);
await settle();
assert.equal(fw.made.started - before, 1, 'a later leak rings again');
assert.ok(a.limiter.active <= a.limiter.maxVoices);
} finally { restore(); }
});
});
+5
View File
@@ -252,6 +252,11 @@ export const BATTLE_SFX = Object.freeze({
enemyDieHeavy: { path: 'battle/b_enemy/b_enemy_dead_h.mp3' },
enemyHit: { path: 'enemy/e_imp/e_imp_general_w.mp3' },
heal: { bank: 'battle.ON_MODIFIER_HEAL' },
// 漏怪: an enemy reached the exit. This is the ORIGINAL Arknights stage cue (`battle.ON_ENEMY_REACHED_EXIT` ->
// `Battle/b_ui/b_ui_alarmenter`), not an autochess one — the mode itself has no bank named for an escape (all 13,948
// SFX banks of audio_data.json searched). The official treats it as a one-shot alarm: `maxSoundAllowed: 1` with
// `popOldest: true` on the `Battle_UI_Important` mixer, so a new escape replaces the one still playing.
leak: { bank: 'battle.ON_ENEMY_REACHED_EXIT' },
win: { path: 'battle/b_ui/b_ui_win.mp3' },
lose: { path: 'battle/b_ui/b_ui_lose.mp3' },
killCoin: { bank: 'battle.ON_CUSTOM_TRIGGER.autochess_kill_gain_coin' },