diff --git a/docs/design/client.md b/docs/design/client.md index 44b812de..63765d80 100644 --- a/docs/design/client.md +++ b/docs/design/client.md @@ -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. --- diff --git a/public/js/audio.js b/public/js/audio.js index f7a8784a..72d2ce26 100644 --- a/public/js/audio.js +++ b/public/js/audio.js @@ -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) diff --git a/public/js/screens/game.js b/public/js/screens/game.js index 52a79eb0..5a406697 100644 --- a/public/js/screens/game.js +++ b/public/js/screens/game.js @@ -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 diff --git a/public/js/screens/game/early.js b/public/js/screens/game/early.js index 62f3c282..b5daedd3 100644 --- a/public/js/screens/game/early.js +++ b/public/js/screens/game/early.js @@ -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') : []); diff --git a/test/match/feedback1-runner-forms.test.js b/test/match/feedback1-runner-forms.test.js index 4d6336c8..fa4825ab 100644 --- a/test/match/feedback1-runner-forms.test.js +++ b/test/match/feedback1-runner-forms.test.js @@ -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 diff --git a/test/ui/audio.test.js b/test/ui/audio.test.js index addb8c96..d931f2f2 100644 --- a/test/ui/audio.test.js +++ b/test/ui/audio.test.js @@ -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