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:
1 parent
030d0a2938
commit
3484178e2e
6 files changed
+170
-4
No files matched your search
+23
-1
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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') : []);
|
||||
Reference in new issue
Block a user