feedback7 R22-HUD: a skill cue that reaches the client before its unit is held and sounds once when the unit is tracked — the first cast of a deploy-time skill was silent (PR #292 by @LimitlessHPPK, without handing the whole early buffer to the sound); golden: unchanged

Seen (Grok's review, reproduced here with the real sim): the sim's deploy emits a deployment-activated skill's
['skill', id, 1] before the unit's ['spawn', unitInfo] (宴: three events earlier) and audio.js dropped a skill event of an
unknown unit, so that one cast made no sound while every later one did.

- audio.js: pendingSkill — a 'skill' ON for an unknown unit is held (one per unit, 64 at most, the oldest go first);
  _track plays it once when the unit's spawn arrives; a skill OFF is no cast and is not held; setFieldUnits drops the holds.
  Changed from the PR: setFieldUnits clears the holds BEFORE it tracks the new field's units (the PR cleared after, so a hold
  left over from the previous field would have sounded for a unit of the new field that shares its id — ids are per battle).
- screens/game.js: NOT the PR's change. The pre-entry buffer (keepEarly: every spawn, die, deploy, status and skill since the
  field's m.field, up to 1500 of them) is handed to the sound as its 'spawn' tuples only (new early.js audioEarly): the whole
  list would play every old death, deploy line and cast at once on entering. The live path (onEv) already forwards the full
  list, so the hold alone fixes the live silent first cast; a deploy-tick cast that falls into the short pre-entry gap
  stays unheard (a deliberate gap — replaying "only the casts before their spawn" would still sound a minute-old cast when a
  field was buffered while another one was on screen).
- Tests: test/ui/audio.test.js (3: the held cast in one batch and across batches, once, no hold for a skill OFF; a hold does
  not outlive its field and is bounded; 宴's real deploy: the sim's cast comes before the spawn and sounds exactly once — all
  fail on the parent) and test/match/feedback1-runner-forms.test.js (the audio gets the spawn tuples only: silent replay that
  still teaches the units, the next live cast sounds; the whole list would have sounded old cues). Docs: design/client.md.
- Golden: unchanged (client audio only).
This commit is contained in:
LimitlessHPPK authored and sganggs committed 2026-10-08 19:22:08 +08:00
1 parent 030d0a2938
commit 3484178e2e
6 files changed
+170 -4

No files matched your search

+1 -1
View File
@@ -48,7 +48,7 @@ A bond the mode never activates (标准: official `inactiveBondIdList`) reads
Interactions (research 09 §1.2 / §5, which supersede the first draft): shop card, upgrade and 机变 card are two-tap (select → confirm, §18.2) — a tap anywhere on the card, its confirm strip included, is the card's tap (shop cards have no ⓘ corner; the armed bar sits above the detail card; a shop card that cannot be bought right now opens its detail on tap, §20.7); a press / drop goes by the tile under the pointer (§18.1); drag bench↔board — a legal board drop (also onto the piece's own tile) opens the 4-direction deploy wheel (swipe UP/RIGHT/DOWN/LEFT with the rotated range striped orange; release in the centre, ✕ 点击取消 or Esc cancels); tap a unit's tile ⇒ underframe with **出售 +N** (and **撤退** on the board; items/Arts in the hand / temp: **销毁** — equipped items are locked, §6.2) plus its rotated range; no drag-to-sell; drag an item onto a unit's tile ⇒ equip that unit (both slots used ⇒ the replace dialog `ui/equipReplace.js` shows the incoming and the two equipped items; the player picks the one to replace — it is destroyed — `g.equip replaceUid`; cancel changes nothing); Arts are dropped on a tile and get the direction step; right-click or long-press ⇒ detail (or a tap on a shop card while the bar is locked); keyboard shortcuts — the defaults, which each player rebinds in 设置 → 快捷键 (`ui/gameLogic/shortcuts.js`, saved with the settings; §25.15) — `R` refresh, `F` freeze, `D` level-up, `Q` retreat / `X` sell the selected operator, `Space` ready (in a solo battle: pause / resume, §14 Solo pause); `Esc` closes (fixed); none acts while a text field has the focus or a dialog is open. All actions optimistic-free: UI waits for server state (latency is small).
Audio: autochess BGM per phase, UI SFX (buy/sell/refresh/level/merge/ready/timer), per-unit attack/hit SFX (throttled; an impact plays for a hostile attack's phys / arts / true damage only, never an operator's skill-mode file for a normal attack, ≤ 2 copies of one battle sound — §18.4), boss music in R14/15.
Audio: autochess BGM per phase, UI SFX (buy/sell/refresh/level/merge/ready/timer), per-unit attack/hit SFX (throttled; an impact plays for a hostile attack's phys / arts / true damage only, never an operator's skill-mode file for a normal attack, ≤ 2 copies of one battle sound — §18.4; a skill cue that reaches the client before its unit — the sim emits a deploy-tick cast ahead of the unit's `['spawn']` — is held and sounds once when the unit is tracked, `AudioManager.pendingSkill`, GitHub PR #292 by @LimitlessHPPK; entering a field late hands the sound the buffered `spawn` tuples only, `screens/game/early.js audioEarly`, so no old death, deploy or cast replays), boss music in R14/15.
---
+23 -1
View File
@@ -25,7 +25,10 @@
// channel over — the official scheduling of `audio_data.json battleVoice.voiceTypeOptions`.
// - Battle SFX from `b.ev` tuples (`handleBattleEvents`): at most MAX_VOICES concurrent unit sounds, at most
// MAX_PER_URL overlapping copies of one sound (the official banks' maxSoundAllowed 2), a per-unit cooldown and a
// per-URL minimum gap (SfxLimiter), so a 60-unit fight stays listenable.
// per-URL minimum gap (SfxLimiter), so a 60-unit fight stays listenable. A `['skill', id, 1]` that arrives before its
// unit is known is held and played once the unit is tracked (`pendingSkill`): a unit that casts inside its own deploy
// tick emits that first cue before its `['spawn', unitInfo]`, and dropping it left the one cast silent while every
// later one played (GitHub PR #292 by @LimitlessHPPK).
// - Impact sounds (user playtest #4 item 6): a 'dmg' plays the `hit` sound of the unit whose hostile attack ('atk' on a
// unit of the other side) aimed at the target — once, within IMPACT_WINDOW_MS, and only for phys / arts / true damage.
// A heal "attack" ('atk' of a healer on an ally, chain heals) never makes the healer the author of the next damage
@@ -76,6 +79,8 @@ const CHAIN_KINDS = new Set(['chain', 'chainHeal']);
const IMPACT_TYPES = new Set(['phys', 'arts', 'true']);
/** A 'dmg' later than this (real ms) after the attack aimed at the target is not that attack's impact. */
const IMPACT_WINDOW_MS = 2500;
/** Casts held for a unit that is not tracked yet (`pendingSkill`): a hold per unit of a field, never more than this. */
const PENDING_SKILL_MAX = 64;
/** Official operator sound files of a skill mode: `…_d` / `…_h` / `…_s` (+ digits) — the normal attack's end in `_n`. */
const SKILL_MODE_FILE = /_(d|h|s)\d*\.mp3$/i;
@@ -463,6 +468,7 @@ export class AudioManager {
this.bgm = null; // { key, loopUrl, nodes: [{src, gain}], gain }
this.bgmToken = 0;
this.units = new Map(); // battle unit id → defId
this.pendingSkill = new Map(); // unit id → the 'skill' tuple that arrived before the unit was known (see _track)
this.lastAttacker = new Map(); // target id → { def, at } of the hostile attack last aimed at it (its impact sound)
this.consumed = new Set(); // summons used up by their own effect (香槟炸弹 exploded): no death sound
this.installed = false;
@@ -912,6 +918,9 @@ export class AudioManager {
/** Reset the unit map for a new field (m.field.units = UnitInfo[]). */
setFieldUnits(units) {
this.units.clear();
// unit ids belong to one battle: a cast still held for a unit of the field left must not sound for a unit of the new
// one that happens to share its id
this.pendingSkill.clear();
this.lastAttacker.clear();
this.consumed.clear();
// a new field is a new battle: the first operator deployed says 行动出发 again and no cooldown carries over
@@ -926,6 +935,12 @@ export class AudioManager {
// official class sounds (operator vs summon vs device)
this.units.set(u.id, { def: u.spine || u.defId, defId: u.defId ?? null, kind: u.kind ?? null, side: u.side, boss: !!u.boss,
skillIndex: Number.isInteger(u.skillIndex) ? u.skillIndex : null });
// a cast that arrived before this unit was known (handleBattleEvents): it sounds now, once
const held = this.pendingSkill.get(u.id);
if (held) {
this.pendingSkill.delete(u.id);
this.handleBattleEvents([held]);
}
}
/** Play a resolved battle sound for a unit event, limited like unit sounds. */
@@ -970,6 +985,13 @@ export class AudioManager {
const n = Number.isInteger(u.skillIndex) ? Math.min(4, u.skillIndex + 1) : 1;
this.voice(u.def, `skill${n}`, { unitKey: e[1] });
}
} else {
// The unit is not tracked yet. The sim emits the cast of a deployment that fires inside its own first tick
// (`initSp` already at `spCost`, 宴's deploy-timed skill) BEFORE the unit's `['spawn', unitInfo]` — the same
// batch or the next one — so the cue waits here and `_track` plays it when the spawn arrives. Only a skill
// turning ON is a cast; a hold for a unit that never appears goes with the field (`setFieldUnits`).
if (this.pendingSkill.size >= PENDING_SKILL_MAX) this.pendingSkill.delete(this.pendingSkill.keys().next().value);
this.pendingSkill.set(e[1], e);
}
} else if (kind === 'engage') {
// 行动开始: the first attack a unit makes on an enemy (the sim's ENGAGE, official ENCOUNTER_ENEMY, 3 s apart)
+2 -2
View File
@@ -115,7 +115,7 @@ import { audio, resultSpeaker, resultVoiceSlot } from '../audio.js';
import { useDocClass, FullscreenButton } from '../ui/device.js';
// MatchEnded, PausedOverlay, the highlight styles and keepEarly live in ./game/*.js.
import { HUD_HZ_MS, MERGE_HL, SEL_RANGE, cx } from './game/marks.js';
import { keepEarly } from './game/early.js';
import { keepEarly, audioEarly } from './game/early.js';
import { MatchEnded, PausedOverlay } from './game/overlays.js';
import { t, tParts } from '../../../shared/i18n.js';
@@ -434,7 +434,7 @@ function MatchScreen() {
if (early && early.length) {
// replay state-bearing events only (a burst of stale hit sparks / damage numbers would look wrong)
view.pushEvents(early);
audio.handleBattleEvents(early.filter((e) => e[0] === 'spawn'));
audio.handleBattleEvents(audioEarly(early)); // (the 'spawn' tuples only: the sound would replay every old death, deploy and cast)
}
if (!earlySnap && (field.prep || !combat) && Array.isArray(field.units)) {
// prep scouting: no battle snapshots follow. A later m.field for this board (the teammate moved) re-enters
+9
View File
@@ -7,3 +7,12 @@ export const STATE_EV = new Set(['spawn', 'die', 'deploy', 'status', 'skill']);
/** Event tuples replayed when a field is entered late: the state-bearing kinds and the fx that change an enemy's model
* form (shared/protocol.js fxForm — the field meta's UnitInfo `form` predates them). */
export const keepEarly = (e) => Array.isArray(e) && (STATE_EV.has(e[0]) || fxForm(e) !== undefined);
/**
* What the SOUND gets of that replay: the 'spawn' tuples alone, which teach the audio who is on the field. The buffered
* list is every spawn, die, deploy, status and skill since the field's m.field — up to 1500 of them, while the view was not
* showing it — so handing it to the audio would play the old deaths, deploy lines and skill cues all at once on entering
* (GitHub PR #292 by @LimitlessHPPK handed over the whole list; its hold of a cast that arrives before its unit,
* audio.js `pendingSkill`, is what the live path needs, and `onEv` already forwards the full list).
*/
export const audioEarly = (early) => (Array.isArray(early) ? early.filter((e) => Array.isArray(e) && e[0] === 'spawn') : []);
+52
View File
@@ -17,6 +17,8 @@ import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { createBattleRunner, keepsState, compactHeld, HELD_MAX, EV_SLICE } from '../../public/js/battle/runner.js';
import { SnapshotBuffer } from '../../public/js/render/interp.js';
import { keepEarly, audioEarly } from '../../public/js/screens/game/early.js';
import { AudioManager } from '../../public/js/audio.js';
import { createStore, initialState } from '../../public/js/store.js';
import * as specMod from '../../server/sim/spec.js';
import { DataSource } from '../../server/sim/simdata.js';
@@ -243,6 +245,56 @@ test('screens/game.js buffers the form fx with the state-bearing events it repla
assert.match(src, /lastFieldRef\.current = field\.fieldId;\s+reentryRef\.current = null;/);
});
// GitHub PR #292 (by @LimitlessHPPK) wanted the whole pre-entry buffer handed to the sound as well, so that a deploy-tick cast
// ahead of its unit's spawn would be heard. The review kept the hold of such a cast (audio.js `pendingSkill`, which the live
// path needs: `onEv` already forwards every list) and refused the replay: the buffer is every spawn, die, deploy, status and
// skill since the field's m.field (up to 1500 of them), so the sound would play the field's old deaths, deploy lines and casts
// all at once on entering.
test('entering a field late: the view replays every buffered state event, the sound only the spawn tuples that teach it who is there', () => {
const src = readFileSync(path.join(ROOT, 'public/js/screens/game.js'), 'utf8');
assert.match(src, /view\.pushEvents\(early\);/, 'the view gets the buffered list');
assert.match(src, /audio\.handleBattleEvents\(audioEarly\(early\)\);/, 'the sound gets audioEarly of it');
assert.ok(!/audio\.handleBattleEvents\(early\)/.test(src), 'never the whole list');
const stream = [
['atk', 1, 2, 'none'], // stale cosmetics: dropped by keepEarly
['dmg', 2, 40, 'phys'],
['skill', 1, 1], // a deploy-tick cast, ahead of its unit's spawn
['spawn', { id: 1, side: 'ally', kind: 'op', spine: 'char_a', skillIndex: 2 }],
['spawn', { id: 2, side: 'enemy', kind: 'enemy', spine: 'enemy_x' }],
['deploy', 1],
['skill', 1, 0],
['fx', 'form', 2, 3, { id: 2, form: 'translator_youling' }],
['die', 2, 'killed'],
];
const early = stream.filter(keepEarly);
assert.deepEqual(early.map((e) => e[0]), ['skill', 'spawn', 'spawn', 'deploy', 'skill', 'fx', 'die'], 'the pre-entry buffer keeps every state-bearing kind');
assert.deepEqual(audioEarly(early).map((e) => e[0]), ['spawn', 'spawn'], 'the sound is told who is on the field, nothing else');
assert.deepEqual(audioEarly(null), [], 'no buffer, nothing');
// through a real AudioManager: the replay makes no sound at all (no old death, deploy line or cast) but tracks the units,
// so the first live cast after entering sounds
const vm = { audio: { sfx: { ui: {}, battle: {}, units: { char_a: { skill: '/s/a_skill.mp3', born: '/s/a_born.mp3' }, enemy_x: { die: '/s/x_die.mp3' } } }, voice: {} } };
const a = new AudioManager({ win: null, getManifest: () => vm });
a.ctx = {};
const played = [];
a._play = (url) => { played.push(url); };
a.voice = () => { played.push('voice'); return true; };
a.setFieldUnits([]);
a.handleBattleEvents(audioEarly(early));
assert.deepEqual(played, [], 'the replay is silent');
assert.equal(a.units.size, 2, 'and has taught the sound both units');
a.handleBattleEvents([['skill', 1, 1]]);
assert.ok(played.includes('/s/a_skill.mp3'), 'the next live cast of a known unit sounds');
// (the contrast: the whole list would have played the buffered death, the deploy and the cast on entering)
const b = new AudioManager({ win: null, getManifest: () => vm });
b.ctx = {};
const heard = [];
b._play = (url) => { heard.push(url); };
b.voice = () => true;
b.setFieldUnits([]);
b.handleBattleEvents(early);
assert.ok(heard.length >= 2, `the whole list would sound ${heard.length} old cues at once`);
});
/**
* The view's side too (render/app.js): the runner's frames go into the render engine's SnapshotBuffer, whose clock
* follows the snapshots, and processEvents takes the due events with the 1.5 game s stale-cosmetic window — a form fx
+83
View File
@@ -577,6 +577,89 @@ describe('AudioManager', () => {
} finally { globalThis.fetch = origFetch; }
}
});
// 「技能音效有时候不触发」 (PR #292 by @LimitlessHPPK): the sim emits the cast of a deployment that fires inside its own first
// tick (`initSp` already at `spCost`, 宴's deploy-timed skill, 银灰 S3 真银斩 …) before the unit's `['spawn', unitInfo]` — the
// same batch, or the next — so `handleBattleEvents` used to skip the cue for good: that first cast was silent and every
// later one played. The cue is held and answered once, when the unit is tracked (`_track`); it never outlives its field.
test('a cast that arrives before its unit is known is held and plays once when the unit is tracked', () => {
const vm = { audio: { sfx: { ui: {}, battle: {}, units: { char_a: { skill: '/s/a_skill.mp3' } } },
voice: { char_a: { skill3: '/v/a_s3.mp3' } } } };
const a = new AudioManager({ win: null, getManifest: () => vm });
a.ctx = {}; // unlocked: every path below is the real one
const played = [], voiced = [];
a._play = (url) => { played.push(url); }; // the cue itself, and the 作战中 line that goes with it
a.voice = (charId, slot, o) => { voiced.push([charId, slot, o.unitKey]); return true; };
/** UnitInfo of the operator `char_a` (spine = the sfx.units key; skillIndex picks its 作战中N slot). */
const info = (id, skillIndex) => ({ id, side: 'ally', spine: 'char_a', kind: 'op', skillIndex });
// (1) the sim's order inside one batch: the cast, then the unit's spawn — the cast waits, then sounds once
a.handleBattleEvents([['skill', 8, 1]]);
assert.deepEqual(played, [], 'nothing to play yet: the unit is unknown');
a.handleBattleEvents([['spawn', info(8, 2)]]);
assert.deepEqual(played, ['/s/a_skill.mp3'], 'the held cast plays as soon as the unit is tracked');
assert.deepEqual(voiced, [['char_a', 'skill3', 8]], 'with the 作战中N line its skillIndex picks');
a.handleBattleEvents([['skill', 7, 1], ['atk', 7, 8, 'none'], ['spawn', info(7, 0)]]);
assert.equal(played.length, 2, 'the same batch: the cast waits for the spawn of its own batch');
assert.deepEqual(voiced.at(-1), ['char_a', 'skill1', 7]);
// (2) once: a unit that is told about again (a repeated spawn) never replays it; a later cast plays directly
a.handleBattleEvents([['spawn', info(8, 2)], ['spawn', info(7, 0)]]);
assert.equal(played.length, 2, 'no second cue for the held cast');
a.handleBattleEvents([['skill', 8, 1]]);
assert.equal(played.length, 3, 'a later cast of a known unit plays through');
assert.deepEqual(voiced.at(-1), ['char_a', 'skill3', 8], 'and sounds the same as the replayed one');
// (3) skill off (`['skill', id, 0]`) is no cast: nothing is held for it
a.handleBattleEvents([['skill', 9, 0]]);
a.handleBattleEvents([['spawn', info(9, 0)]]);
assert.equal(played.length, 3, 'the off event holds nothing');
assert.equal(a.pendingSkill.size, 0);
});
test('a held cast does not outlive its field: a unit of the next field that shares its id does not sound it', () => {
const vm = { audio: { sfx: { ui: {}, battle: {}, units: { char_a: { skill: '/s/a_skill.mp3' } } }, voice: {} } };
const a = new AudioManager({ win: null, getManifest: () => vm });
a.ctx = {};
const played = [];
a._play = (url) => { played.push(url); };
a.voice = () => true;
const info = (id) => ({ id, side: 'ally', spine: 'char_a', kind: 'op' });
a.handleBattleEvents([['skill', 9, 1]]); // a cast whose unit never appeared on this field
assert.equal(a.pendingSkill.size, 1);
a.setFieldUnits([info(9)]); // another battle: unit ids are per battle, 9 is a different unit
assert.equal(a.pendingSkill.size, 0, 'the field change drops the hold');
a.handleBattleEvents([['spawn', info(9)]]);
assert.deepEqual(played, [], 'the old field\'s cast never sounds in the new one');
// the hold is bounded: casts of units that never appear cannot pile up
for (let id = 100; id < 400; id++) a.handleBattleEvents([['skill', id, 1]]);
assert.ok(a.pendingSkill.size <= 64, `${a.pendingSkill.size} holds`);
assert.ok(a.pendingSkill.has(399) && !a.pendingSkill.has(100), 'the oldest holds go first');
});
test('a real battle: the deploy-tick cast of a deployment-activated skill is heard, once', () => {
// The reported shape in 0.2.0's own sim: 宴's kit activates its skill on deployment (activateOnDeploy, PR #109), and
// `sim/battle/deploy.js` runs `skill.reset()` (→ `activate` → `['skill', id, 1]`) BEFORE it emits
// `['spawn', unitInfo]` — three events earlier in 宴's case (a status and an fx come between). Fed to the audio as
// the socket delivers it, that cast used to be dropped for good.
const chessId = 'chess_char_1_18_a'; // 宴 (char_337_utage): a deploy-timed skill with a skill sound
const h = makeBattle({ seed: 7, autoFinish: false, timeLimit: 5, units: [{ chessId, row: 9, col: 5 }] });
h.step(3);
const ev = h.events;
const iCast = ev.findIndex((e) => e[0] === 'skill' && e[2]);
const iSpawn = ev.findIndex((e) => e[0] === 'spawn');
assert.ok(iCast >= 0 && iSpawn > iCast, `the sim emits the deploy-tick cast before the unit info (${iCast} < ${iSpawn})`);
// the cast's sound, resolved as `unit()` does (the equipped skill's own ON_SKILL_START file, else its `skill`)
const info = ev[iSpawn][1];
const rec = manifest.audio.sfx.units[info.spine];
const url = (Number.isInteger(info.skillIndex) && rec?.skills ? rec.skills[info.skillIndex] : null) ?? rec?.skill;
assert.ok(typeof url === 'string', `前提:${info.spine} 有技能音效`);
const played = [];
const a = new AudioManager({ win: null, getManifest: () => manifest });
a.ctx = {};
a._play = (u) => { played.push(u); };
a.voice = () => true;
a.handleBattleEvents(ev);
assert.equal(played.filter((u) => u === url).length, 1, 'the cast of the deployment is heard, exactly once');
});
});
// user playtest #4 item 6: 纯烬艾雅法拉's skill sound rang outside her skill — her manifest `hit` is her S3 impact