Operator battle voices: the official combat lines play by voice slot during battles, one at a time (#73)

Deploy, skill and result lines come from the operators' official voice slots; a newer line takes the channel over (stale callbacks of the replaced line no longer release it), and the result line is spoken by an operator of the player's own battle. Contributed by @Convey123.
This commit is contained in:
Cartethyia authored and GitHub committed 2026-10-06 01:44:46 +08:00
1 parent 8774379d3b
commit 54cdad2602
22 files changed
+823 -49

No files matched your search

+288 -13
View File
@@ -1,21 +1,28 @@
// Audio manager (Web Audio): BGM per phase, UI SFX, per-unit battle SFX. Never throws.
// Audio manager (Web Audio): BGM per phase, UI SFX, per-unit battle SFX, operator battle voice. Never throws.
//
// Sources: data/assets.json → audio (docs/ASSETS.md):
// bgm { lobby, prep, combat, combatAlts?: [ {intro?, loop}, … ], boss: { intro?, loop } },
// bossBgm { [bossId]: { intro?, loop } },
// voice { [charId]: { start, faceEnemy, select, place, skill1…skill4, squad, squadFirst, result*, gacha } },
// 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?, 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).
// - Channels: master → { bgm, sfx } gains; volumes from settings (0..1) + mute. Tab hidden ⇒ suspend.
// - Channels: master → { bgm, sfx, voice } gains; volumes from settings (0..1) + mute. Tab hidden ⇒ suspend.
// - BGM: `intro` then `loop` (1 s crossfade); switching tracks fades out/in (0.8 s). The same loop URL
// keeps playing across phases (prep and combat share a track).
// - 开战 BGM: `bgm.combatAlts` are the mode's own battle tracks (塞壬唱片 骑士之日 / 无畏者). The track is fixed per
// round, not drawn: 无畏者 through rounds 1–7 and 骑士之日 from round 8 on (`combatTrackFor`, the official
// schedule), so every client of a match hears the same one, a fight never switches track halfway through and the
// 联防 that follows a 作战 keeps its round's track.
// - 干员战斗语音 (`audio.voice`, user request): an operator says the official line of the moment it is in —
// 行动出发 start, 行动开始 faceEnemy, 选中干员 select, 部署 place, 作战中1-4 skillN and the settlement's
// 结算 result* (all battle-only; the 休整期 is silent). Voices are seconds long and a battle deploys eight
// operators, then fires dozens of skills, so they run on their own channel (own gain, settings 干员语音) through
// VoiceGate: one line at a time, a global gap, a per-unit per-slot cooldown, and a higher-priority line taking the
// 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.
@@ -47,8 +54,13 @@ const UNIT_COOLDOWN_MS = 160;
const URL_GAP_MS = 45;
const MAX_PER_URL = 2;
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;
const XFADE_S = 1;
const FADE_S = 0.8;
/** Voice: shortest gap between two lines, and the crossfade of a higher-priority line taking the channel (official 0.1 s). */
const VOICE_GAP_MS = 1200;
const VOICE_XFADE_S = 0.1;
/** 'atk' projectile kinds whose first id is the previous bounce target (sim ai.js), not the attacker. */
const CHAIN_KINDS = new Set(['chain', 'chainHeal']);
/** 'dmg' types that are an attack's impact (element gauge fills / 元素伤害 carry the element's name instead). */
@@ -224,6 +236,67 @@ export function unitSoundPlays(mix, roll) {
return !(Number.isFinite(p) && p >= 0 && p < 1) || roll < p;
}
/**
* Voice priorities — the official battle voice types (`audio_data.json battleVoice.voiceTypeOptions`) mapped onto the
* manifest's slots: BATTLE_START 100, BATTLE_FACE_ENEMY 90, SKILL_ACTIVE 70, PASSIVE_IMP 60, PASSIVE_NOR 50,
* PLACE_CHAR 20, FOCUS_CHAR 10. The settlement lines are no battle voice of the official scheduler: they sit at 85,
* above 作战中 (70) but below 接敌 (90), so a battle's last word is never cut off by an ordinary line. The four prep
* slots (部署 / 编入队伍 / 任命队长 / 干员报到) keep their levels although the 休整期 is silent (see the header).
*/
export const VOICE_PRIORITY = Object.freeze({
start: 100, faceEnemy: 90,
skill1: 70, skill2: 70, skill3: 70, skill4: 70,
resultFour: 85, resultThree: 85, resultTwo: 85, resultLose: 85,
gacha: 60, squadFirst: 45, squad: 30, place: 20, select: 10,
});
/** Per-unit per-slot cooldowns (ms): the official 10 s of the 作战中 (passive skill) lines, 3 s between 接敌 lines. */
export const VOICE_COOLDOWN_MS = Object.freeze({
start: 0, faceEnemy: 3000,
skill1: 10000, skill2: 10000, skill3: 10000, skill4: 10000,
resultFour: 0, resultThree: 0, resultTwo: 0, resultLose: 0,
gacha: 0, squadFirst: 0, squad: 0, place: 0, select: 1500,
});
/**
* The settlement slot of a finished 作战: 完美作战 ⇒ 3星结束行动 (绝境 / 终极 ⇒ 完成高难行动 instead), a leaked enemy
* ⇒ 非3星结束行动, nothing killed at all ⇒ 行动失败.
* @param {{perfect?:boolean, leaked?:number, killed?:number, total?:number, hard?:boolean}} [o]
* @returns {'resultFour'|'resultThree'|'resultTwo'|'resultLose'}
*/
export function resultVoiceSlot(o = {}) {
const leaked = Number.isFinite(o.leaked) ? o.leaked : 0;
const killed = Number.isFinite(o.killed) ? o.killed : 0;
const total = Number.isFinite(o.total) ? o.total : 0;
if (o.perfect) return o.hard ? 'resultFour' : 'resultThree';
if (total > 0 && killed <= 0) return 'resultLose';
if (leaked > 0) return 'resultTwo';
return o.hard ? 'resultFour' : 'resultThree';
}
/**
* Who says a battle's **result** line (结算): an operator of THAT battle's own field. Never the field the player happens
* to be looking at (review on #73): reading the tracked units of the field on screen made a teammate's operator say the
* viewer's 作战结束 line while the viewer was watching them.
* `pp` is that battle's own `perPlayer` entry (BattleResult, sim/Battle.js): `unitsEnd` lists what stood on its field
* when the battle ended, `defId` being an operator (`char_*`) or a summon piece (`token_*`, which does not talk).
* Survivors speak first — the line reports how the battle went, and a wiped-out squad is the only case where a fallen
* operator ends up saying it. Ties are drawn like every other unit sound.
* @param {{ unitsEnd?: Array<{ defId?: string|null, alive?: boolean }> } | null | undefined} pp that battle's perPlayer
* @param {() => number} [random]
* @returns {string|null} charId, or null when that battle fielded no operator at all
*/
export function resultSpeaker(pp, random = Math.random) {
const ops = [];
for (const u of Array.isArray(pp?.unitsEnd) ? pp.unitsEnd : []) {
if (u && typeof u.defId === 'string' && u.defId.startsWith('char_')) ops.push({ id: u.defId, alive: !!u.alive });
}
const standing = ops.filter((o) => o.alive);
const pool = standing.length ? standing : ops; // only a wiped-out squad is spoken for by a fallen operator
if (!pool.length) return null;
return pool[Math.min(pool.length - 1, Math.floor(random() * pool.length))].id;
}
/** 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'];
@@ -275,6 +348,63 @@ export class SfxLimiter {
}
}
/**
* Voice gate: one line at a time, a global gap between two lines, a per-unit per-slot cooldown, and takeover by a
* clearly more important line (the caller fades the playing one out first). Pure — the clock is passed in.
*/
export class VoiceGate {
/** @param {{ gapMs?: number, preemptMargin?: number, maxUnits?: number }} [o] */
constructor(o = {}) {
this.gapMs = Number.isFinite(o.gapMs) ? o.gapMs : VOICE_GAP_MS;
// a line only takes the channel over when its priority beats the playing one by this margin: an equal-priority
// line (two 作战中 of different operators) waits for its turn instead of cutting the other off
this.preemptMargin = Number.isFinite(o.preemptMargin) ? o.preemptMargin : 10;
this.maxUnits = Number.isFinite(o.maxUnits) ? o.maxUnits : 400;
this.playing = null; // { slot, pri } of the line on air
this.lastAt = -Infinity;
this.unitUntil = new Map();
}
/** Drop all state (a new field must not inherit the previous battle's cooldowns). */
reset() {
this.playing = null;
this.lastAt = -Infinity;
this.unitUntil.clear();
}
/**
* May a `slot` line from `unitKey` start now?
* @param {string} slot
* @param {string|number|null} unitKey the cooldown key (a battle unit id; null = no per-unit cooldown)
* @param {number} now ms
* @returns {'play'|'preempt'|'drop'}
*/
request(slot, unitKey, now) {
const pri = VOICE_PRIORITY[slot] ?? 0;
if (this.playing) {
if (pri < this.playing.pri + this.preemptMargin) return 'drop';
return 'preempt'; // a clearly more important line takes the channel
}
if (now - this.lastAt < this.gapMs) return 'drop';
if (unitKey != null && now < (this.unitUntil.get(`${unitKey}:${slot}`) || 0)) return 'drop';
return 'play';
}
/** Record a line that started (call right after request() answered play / preempt). */
start(slot, unitKey, now) {
this.playing = { slot, pri: VOICE_PRIORITY[slot] ?? 0 };
this.lastAt = now;
const cd = VOICE_COOLDOWN_MS[slot] ?? 0;
if (unitKey != null && cd > 0) {
if (this.unitUntil.size > this.maxUnits) this.unitUntil.clear();
this.unitUntil.set(`${unitKey}:${slot}`, now + cd);
}
}
/** The line ended (naturally, by takeover or by a stop). */
release() { this.playing = null; }
}
// ---- manager -----------------------------------------------------------------------------------------------
/**
@@ -303,10 +433,16 @@ export class AudioManager {
this.master = null;
this.bgmGain = null;
this.sfxGain = null;
this.volumes = { bgm: 0.6, sfx: 0.8, muted: false };
this.voiceGain = null;
this.volumes = { bgm: 0.6, sfx: 0.8, voice: 0.8, muted: false };
this.buffers = new Map(); // url → Promise<AudioBuffer|null> (insertion order = LRU)
this.bufBytes = new Map(); // url → decoded PCM bytes (the byte budget of the LRU, see _buffer)
this.warned = new Set();
this.limiter = new SfxLimiter();
this.voiceGate = new VoiceGate();
this.voiceNode = null; // { src, gain, url, token } of the line on air
this.voiceToken = 0;
this.startVoiceDone = false; // 行动出发 of this field (the first operator deployed says it)
this.uiVoices = 0;
this.wantBgm = null; // desired key (kept while locked)
this.bgm = null; // { key, loopUrl, nodes: [{src, gain}], gain }
@@ -359,8 +495,10 @@ export class AudioManager {
this.master = this.ctx.createGain();
this.bgmGain = this.ctx.createGain();
this.sfxGain = this.ctx.createGain();
this.voiceGain = this.ctx.createGain();
this.bgmGain.connect(this.master);
this.sfxGain.connect(this.master);
this.voiceGain.connect(this.master);
this.master.connect(this.ctx.destination);
// iOS / iPadOS: a call, Siri or another app's audio moves a running context to 'interrupted' (or 'suspended');
// a resume without a gesture may then be refused — listen for the next gesture again (dropped once it runs)
@@ -430,11 +568,16 @@ export class AudioManager {
/**
* Set channel volumes (0..1) and mute.
* @param {{ bgm?: number, sfx?: number, muted?: boolean }} v
* @param {{ bgm?: number, sfx?: number, voice?: number, muted?: boolean }} v
*/
setVolumes(v) {
const n = (x, d) => (Number.isFinite(x) ? Math.max(0, Math.min(1, x)) : d);
this.volumes = { bgm: n(v?.bgm, this.volumes.bgm), sfx: n(v?.sfx, this.volumes.sfx), muted: typeof v?.muted === 'boolean' ? v.muted : this.volumes.muted };
this.volumes = {
bgm: n(v?.bgm, this.volumes.bgm),
sfx: n(v?.sfx, this.volumes.sfx),
voice: n(v?.voice, this.volumes.voice),
muted: typeof v?.muted === 'boolean' ? v.muted : this.volumes.muted,
};
this._applyVolumes();
}
@@ -446,6 +589,8 @@ export class AudioManager {
// perceptual curve
this.bgmGain.gain.setTargetAtTime(this.volumes.bgm ** 2 * 0.55, t, 0.05);
this.sfxGain.gain.setTargetAtTime(this.volumes.sfx ** 2 * 0.9, t, 0.03);
// no 0.9: a voice line is already mastered as loud as the rest of the official mix (settings 干员语音 tunes it)
this.voiceGain.gain.setTargetAtTime(this.volumes.voice ** 2, t, 0.03);
} catch { /* ignore */ }
}
@@ -482,15 +627,35 @@ export class AudioManager {
}
})();
this.buffers.set(url, p);
while (this.buffers.size > BUFFER_CACHE) {
const first = this.buffers.keys().next().value;
// never evict the playing BGM
if (this.bgm && first === this.bgm.loopUrl) { const v = this.buffers.get(first); this.buffers.delete(first); this.buffers.set(first, v); break; }
this.buffers.delete(first);
}
p.then((buf) => {
if (!buf) return;
try {
this.bufBytes.set(url, (buf.length || 0) * (buf.numberOfChannels || 1) * 4);
this._trimBuffers();
} catch { /* ignore */ }
}, () => {});
this._trimBuffers();
return p;
}
/**
* Evict least-recently-used buffers until both the entry count and the decoded-PCM budget hold. The count alone is
* not enough once voice lines are in the cache: 180 of them are ~100 MB of PCM (a voice decodes to 0.4–1.3 MB).
*/
_trimBuffers() {
let bytes = 0;
for (const n of this.bufBytes.values()) bytes += n;
if (this.buffers.size <= BUFFER_CACHE && bytes <= BUFFER_BYTES) return;
for (const url of [...this.buffers.keys()]) {
if (this.buffers.size <= BUFFER_CACHE && bytes <= BUFFER_BYTES) break;
// never evict the playing BGM (a voice keeps its own reference to its buffer)
if (this.bgm && url === this.bgm.loopUrl) continue;
bytes -= this.bufBytes.get(url) || 0;
this.buffers.delete(url);
this.bufBytes.delete(url);
}
}
/** Preload a list of URLs (e.g. UI SFX) once unlocked. */
preload(urls) {
if (!this.ctx) return;
@@ -640,6 +805,93 @@ export class AudioManager {
} catch { return false; }
}
// ---- operator battle voice ----------------------------------------------------------------------------------
/**
* Play an operator's battle line (`audio.voice[charId][slot]`; a slot with several lines draws one at random).
* Only in battle: every caller is a running battle's own event stream or its settlement (user request — the 休整期
* is silent). The line must pass VoiceGate: one at a time, a global gap, a per-unit cooldown, higher priority wins.
* @param {string} charId e.g. 'char_263_skadi'
* @param {'start'|'faceEnemy'|'select'|'place'|'skill1'|'skill2'|'skill3'|'skill4'|'squad'|'squadFirst'
* |'resultFour'|'resultThree'|'resultTwo'|'resultLose'|'gacha'} slot
* @param {{ unitKey?: string|number|null, volume?: number }} [o] `unitKey` = the cooldown key (a battle unit id)
* @returns {boolean} whether such a line exists and started
*/
voice(charId, slot, o = {}) {
try {
if (!this.ctx || !this.voiceGain || this.volumes.muted || this.volumes.voice <= 0) return false;
if (typeof charId !== 'string' || typeof slot !== 'string') return false;
const line = this.getManifest()?.audio?.voice?.[charId]?.[slot];
const url = Array.isArray(line) ? line[Math.floor(Math.random() * line.length)] : line;
if (typeof url !== 'string' || !url) return false;
const now = typeof performance !== 'undefined' ? performance.now() : Date.now();
const verdict = this.voiceGate.request(slot, o.unitKey ?? null, now);
if (verdict === 'drop') return false;
if (verdict === 'preempt') this._stopVoice();
this.voiceGate.start(slot, o.unitKey ?? null, now);
const token = ++this.voiceToken;
this._playVoice(url, token, o.volume);
return true;
} catch (err) { this._warn('voice', err); return false; }
}
/** Fetch/decode and start one voice line through the voice channel. */
_playVoice(url, token, volume) {
// `token` is the line's own `voiceToken`. Every deferred step below — the decode, a failed fetch, `onended` and the
// safety timer — can land AFTER this line was taken over or stopped: `voiceToken` has moved on and the channel then
// belongs to the line that replaced it. So each step re-checks its token and, when it is stale, touches NOTHING:
// `_stopVoice` (takeover / stop) and `setFieldUnits` released the gate themselves. An unconditional release here let
// a stale callback free the channel the NEW line had just taken, and the next line walked in on top of it (review
// on #73).
this._buffer(url).then((buf) => {
if (token !== this.voiceToken) return; // taken over / stopped while it decoded: not ours to release
if (!buf || !this.ctx || !this.voiceGain) { this.voiceGate.release(); return; }
try {
const src = this.ctx.createBufferSource();
src.buffer = buf;
const gain = this.ctx.createGain();
gain.gain.value = Math.max(0, Math.min(1.5, Number.isFinite(volume) ? volume : 1));
src.connect(gain); gain.connect(this.voiceGain);
const node = { src, gain, url, token };
let done = false;
const end = () => {
if (done) return;
done = true;
// this line's own end (natural, or the safety timer): only the line that still owns the channel may free it.
// A stale end is the takeover's leftovers — `_stopVoice` already faded it out and released the gate.
if (token === this.voiceToken) {
if (this.voiceNode === node) this.voiceNode = null;
this.voiceGate.release();
}
try { gain.disconnect(); } catch { /* ignore */ }
};
src.onended = end;
setTimeout(end, (buf.duration + 0.3) * 1000); // safety if onended never fires
src.start();
this.voiceNode = node;
} catch (err) {
this._warn('voice-play', err);
if (token === this.voiceToken) this.voiceGate.release();
}
}, () => { if (token === this.voiceToken) this.voiceGate.release(); });
}
/** Fade the line on air out (a higher priority line is taking the channel over). */
_stopVoice() {
const cur = this.voiceNode;
this.voiceNode = null;
this.voiceToken += 1; // a line still decoding must not start afterwards
this.voiceGate.release();
if (!cur || !this.ctx) return;
try {
const t = this.ctx.currentTime;
cur.gain.gain.cancelScheduledValues(t);
cur.gain.gain.setValueAtTime(cur.gain.gain.value, t);
cur.gain.gain.linearRampToValueAtTime(0, t + VOICE_XFADE_S);
cur.src.stop(t + VOICE_XFADE_S + 0.02);
} catch { /* ignore */ }
}
// ---- battle events ------------------------------------------------------------------------------------------
/** Reset the unit map for a new field (m.field.units = UnitInfo[]). */
@@ -647,6 +899,9 @@ export class AudioManager {
this.units.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
this.startVoiceDone = false;
try { this.voiceGate.reset(); this._stopVoice(); } catch { /* ignore */ }
for (const u of Array.isArray(units) ? units : []) this._track(u);
}
@@ -693,7 +948,18 @@ export class AudioManager {
this.battle('heal', { unitKey: `heal:${e[1]}`, volume: 0.35 });
} else if (kind === 'skill' && e[2]) {
const u = this.units.get(e[1]);
if (u) this.unit(u.def, 'skill', e[1], u.skillIndex ?? undefined);
if (u) {
this.unit(u.def, 'skill', e[1], u.skillIndex ?? undefined);
// 作战中N: the equipped skill's own slot (0-based; 作战中4 is the fallback of a 4th slot)
if (unitSoundClass(u) === 'char') {
const n = Number.isInteger(u.skillIndex) ? Math.min(4, u.skillIndex + 1) : 1;
this.voice(u.def, `skill${n}`, { unitKey: e[1] });
}
}
} else if (kind === 'engage') {
// 行动开始: the first attack a unit makes on an enemy (the sim's ENGAGE, official ENCOUNTER_ENEMY, 3 s apart)
const u = this.units.get(e[1]);
if (u && unitSoundClass(u) === 'char') this.voice(u.def, 'faceEnemy', { unitKey: e[1] });
} else if (kind === 'die') {
const u = this.units.get(e[1]);
if (!u) continue;
@@ -710,6 +976,15 @@ export class AudioManager {
if (!u || u.side === 'enemy') continue;
const m = this.getManifest();
const url = deploySfxUrl(m, u);
// 行动出发 / 部署: the first operator of the battle says the battle-start line, the others their deploy line
// (a knocked-out operator redeploying in the same battle is one of the others; a summon says nothing).
// Kept ahead of the deploy-SFX guards below: the voice channel is independent of the unit sound's roll.
if (unitSoundClass(u) === 'char') {
if (!this.startVoiceDone) {
this.startVoiceDone = true;
if (!this.voice(u.def, 'start')) this.voice(u.def, 'place', { unitKey: e[1] });
} else this.voice(u.def, 'place', { unitKey: e[1] });
}
if (!url) continue;
const own = url === m?.audio?.sfx?.units?.[u.def]?.born;
const mix = own ? m.audio.sfx.units[u.def].mix?.born : null;
@@ -739,7 +1014,7 @@ export const audio = new AudioManager({ getManifest: () => manifestGetter() });
/**
* Wire the singleton to the app (called once by main.js): manifest source, settings and store-driven BGM.
* @param {{ getManifest: () => any, subscribe: (fn: (s:any, prev:any) => void) => () => void, getState: () => any,
* selectRoute: (s:any) => string, settings?: { bgm:number, sfx:number, muted:boolean } }} deps
* selectRoute: (s:any) => string, settings?: { bgm:number, sfx:number, voice:number, muted:boolean } }} deps
*/
export function installAudio(deps) {
try {
+3
View File
@@ -452,6 +452,9 @@ export function createBattleRunner(deps) {
} catch (err) { console.warn('[runner] result failed', err); }
if (result) {
e.result = result;
// the view answers with the settlement voice of this battle (screens/game.js → audio.voice result*): the
// compact result carries the leaks and the kill count the slot is picked from
emit('result', { fieldId: e.fieldId, battleId: e.battleId, own: !!e.own, result });
deliver(e);
}
}
+25 -3
View File
@@ -108,7 +108,7 @@ import { battleRunner } from '../battle/runner.js';
import { isClientCombat, observeTarget, teammateProgress, cameraLayers, layerCamera, sidesOf, resumedWatch } from '../battle/observe.js';
import { screenStrip, playerBonds, playerLayer, detailBondOwner, toggleBond, popupView } from '../ui/watchBonds.js';
import { data, localAsset, getMode } from '../data.js';
import { audio } from '../audio.js';
import { audio, resultSpeaker, resultVoiceSlot } from '../audio.js';
import { useDocClass, FullscreenButton } from '../ui/device.js';
const cx = (...p) => p.flat().filter(Boolean).join(' ');
@@ -493,8 +493,30 @@ function MatchScreen() {
view?.pushEvents(msg);
audio.handleBattleEvents(msg.ev);
};
// 干员语音 (结算): the own battle's result just came in — the operator's line depends on how it went
// (完美作战 ⇒ 3星结束行动, 绝境 / 终极 ⇒ 完成高难行动, 有漏怪 ⇒ 非3星结束行动, 一个没杀 ⇒ 行动失败)
const onResult = (msg) => {
try {
if (!msg || !msg.result) return;
const st = store.get();
const pid = st.me?.playerId;
const mine = (pid && msg.result.perPlayer && msg.result.perPlayer[pid]) || null;
const diff = st.match?.public?.difficulty;
// the speaker comes from THIS battle's own field (`mine.unitsEnd`), not from the field on screen: watching a
// teammate used to make THEIR operator say the viewer's line (review on #73)
const charId = resultSpeaker(mine);
if (!charId) return;
audio.voice(charId, resultVoiceSlot({
perfect: !!(mine?.perfect),
leaked: Array.isArray(mine?.leaked) ? mine.leaked.length : 0,
killed: mine?.killed ?? msg.result.killed,
total: mine?.total ?? msg.result.total,
hard: diff === 'HARD' || diff === 'ABYSS',
}));
} catch { /* ignore */ }
};
const offs = [net.on('m.field', onFieldMeta), net.on('b.snap', onSnap), net.on('b.ev', onEv)];
if (battleRunner) offs.push(battleRunner.on('field', onFieldMeta), battleRunner.on('snap', onSnap), battleRunner.on('ev', onEv));
if (battleRunner) offs.push(battleRunner.on('field', onFieldMeta), battleRunner.on('snap', onSnap), battleRunner.on('ev', onEv), battleRunner.on('result', onResult));
return () => { for (const off of offs) { try { off(); } catch { /* ignore */ } } clearTimeout(pending); };
}, [view]);
@@ -1311,7 +1333,7 @@ function MatchScreen() {
onClose=${() => setBondOpen(null)} onMember=${(id, items) => setDetail({ kind: 'chess', id, owner: bondPop.ownerId, items: items || null })} />` : null}
${resolved ? html`<${DetailPanel} detail=${resolved} snapHp=${snapHp} onClose=${() => { setDetail(null); setSel(null); }}
bonds=${detailBonds} offBonds=${offBonds} loadout=${detailLoadout} side=${dSide} shopOpen=${shopOpen} live=${liveStats}
bonds=${detailBonds} offBonds=${offBonds} loadout=${detailLoadout} side=${dSide} shopOpen=${shopOpen} live=${liveStats} voice=${combat}
onBond=${(id) => openBond(id, detailOwner, 'detail')} />` : null}
${selEntry && editable && !facing && !drag && showPrep ? html`<${Underframe} key=${sel.uid} view=${view} uid=${sel.uid}
+1
View File
@@ -66,6 +66,7 @@ export const ICONS = {
info: { d: 'M12 2a10 10 0 1 1 0 20 10 10 0 0 1 0-20zm-1 8v7h2v-7zm0-3v2h2V7z', eo: true },
warn: { d: 'M12 2 1 21h22zm-1 7v6h2V9zm0 7.5v2h2v-2z', eo: true },
play: { d: 'M7 4v16l13-8z' },
mic: { d: 'M12 14a3 3 0 0 0 3-3V5a3 3 0 0 0-6 0v6a3 3 0 0 0 3 3zm5-3a5 5 0 0 1-10 0H5a7 7 0 0 0 6 6.9V21H8v2h8v-2h-3v-3.1A7 7 0 0 0 19 11z' },
edit: { d: 'M3 17.2V21h3.8l11-11-3.8-3.8zM20.7 7.1a1 1 0 0 0 0-1.4l-2.4-2.4a1 1 0 0 0-1.4 0l-1.8 1.8 3.8 3.8z' },
sword: { d: 'M20 3h1v4L10.4 17.6l1.4 1.4-1.4 1.4-2.1-2.1-3.5 3.5-1.4-1.4 3.5-3.5L4.8 14.8l1.4-1.4 1.4 1.4L18 4z' },
shield: { d: 'M12 2 4 5v6c0 5 3.4 9.3 8 11 4.6-1.7 8-6 8-11V5z' },
+12 -1
View File
@@ -31,6 +31,7 @@
// The stats block (chessStatsBlock), the 特性 text (traitText) and the talent list (chessTalents) are exported: the 干员调配
// screen's 局内数值 section draws the same ones for the chosen skill / module, without a live entry (GitHub issue #64).
import { useEffect } from '../../vendor/hooks.module.js';
import { html, Icon, TierChip, MicroLabel, Button, confirmDialog, useTicker } from './components.js';
import { Img, RichText, UnitThumb, BondGlyph, GIcon } from './gameComponents.js';
import { attackInterval, rangeGridBox, fmtNum, tileKey, chessLoadout, nextThreshold, bondTier, briefingBondTip, pieceBondIds, grantedBonds, morphPairings } from './gameLogic.js';
@@ -40,6 +41,7 @@ import { data } from '../data.js';
import { attackRangeGrid } from '../../../shared/loadoutRecord.js';
import { SKILL_SUMMON_START_DEPLOY } from '../../../shared/constants.js';
import { moduleBadge } from './loadoutModel.js';
import { audio } from '../audio.js';
const cx = (...p) => p.flat().filter(Boolean).join(' ');
@@ -651,10 +653,19 @@ export function resolveDetail(target, pieces) {
* = the defaults
* live: the unit's live stats (unitStatsEntry + src 'battle' | 'prep') — an object, or a getter the panel re-reads 4×
* a second (the battle's own sim, battle/runner.js unitStats); null ⇒ the record's numbers
* voice: whether the panel may speak — 选中干员 (audio.voice 'select') plays only while a battle runs (user request:
* 整备期不播干员语音), so the game screen passes its combat flag
*/
export function DetailPanel({ detail, editable, snapHp, onClose, onSell, onDestroy, bonds = [], offBonds = null, loadout = null, onBond = null, side = 'left', shopOpen = false, live = null }) {
export function DetailPanel({ detail, editable, snapHp, onClose, onSell, onDestroy, bonds = [], offBonds = null, loadout = null, onBond = null, side = 'left', shopOpen = false, live = null, voice = false }) {
const getter = typeof live === 'function' ? live : null;
useTicker(detail && getter ? 250 : 0);
// 选中干员 voice (audio.voice 'select'): once per opened operator — the panel stays mounted while the target changes,
// so the key carries what identifies it (its chess record and its piece / battle unit id)
const selectKey = voice && detail?.type === 'chess' ? `${detail.chess?.chessId || ''}:${detail.unitId ?? detail.piece?.uid ?? ''}` : null;
const selectChar = voice && detail?.type === 'chess' ? detail.chess?.charId || null : null;
useEffect(() => {
if (selectKey && selectChar) audio.voice(selectChar, 'select');
}, [selectKey, selectChar]);
if (!detail) return null;
let liveNow = null;
try { liveNow = getter ? getter() : live && typeof live === 'object' ? live : null; } catch { liveNow = null; }
+3 -2
View File
@@ -1581,13 +1581,13 @@ export function shortcutBlocked(act, { modal = false, drawer = false } = {}) {
// ---- settings ------------------------------------------------------------------------------------------------------
export const DEFAULT_SETTINGS = Object.freeze({ bgm: 0.6, sfx: 0.8, muted: false, damageNumbers: true, quality: 'high' });
export const DEFAULT_SETTINGS = Object.freeze({ bgm: 0.6, sfx: 0.8, voice: 0.8, muted: false, damageNumbers: true, quality: 'high' });
const QUALITIES = ['high', 'medium', 'low'];
/**
* Sanitize persisted settings.
* @param {any} raw
* @returns {{ bgm: number, sfx: number, muted: boolean, damageNumbers: boolean, quality: 'high'|'medium'|'low' }}
* @returns {{ bgm: number, sfx: number, voice: number, muted: boolean, damageNumbers: boolean, quality: 'high'|'medium'|'low' }}
*/
export function sanitizeSettings(raw) {
const r = isObj(raw) ? raw : {};
@@ -1595,6 +1595,7 @@ export function sanitizeSettings(raw) {
return {
bgm: vol(r.bgm, DEFAULT_SETTINGS.bgm),
sfx: vol(r.sfx, DEFAULT_SETTINGS.sfx),
voice: vol(r.voice, DEFAULT_SETTINGS.voice),
muted: typeof r.muted === 'boolean' ? r.muted : DEFAULT_SETTINGS.muted,
damageNumbers: typeof r.damageNumbers === 'boolean' ? r.damageNumbers : DEFAULT_SETTINGS.damageNumbers,
quality: QUALITIES.includes(r.quality) ? r.quality : DEFAULT_SETTINGS.quality,
+3 -2
View File
@@ -1,4 +1,4 @@
// Player settings (BGM/SFX volume, mute, damage numbers, render quality): a tiny observable store
// Player settings (BGM/SFX/voice volume, mute, damage numbers, render quality): a tiny observable store
// persisted in localStorage (`sp.pref.settings`), applied to the audio manager on every change, plus
// the settings modal.
@@ -10,7 +10,7 @@ import { audio } from '../audio.js';
import { openGuide } from './guide.js';
import { detectFeatures } from './device.js';
/** Settings store: { bgm, sfx, muted, damageNumbers, quality }. */
/** Settings store: { bgm, sfx, voice, muted, damageNumbers, quality }. */
export const settingsStore = createStore(sanitizeSettings(loadPref('settings', null)));
settingsStore.subscribe((s) => {
@@ -60,6 +60,7 @@ export function SettingsModal({ open, onClose }) {
<${Button} variant="primary" icon="check" onClick=${onClose}>完成<//>`}>
<div class="set-list">
<${Slider} label="背景音乐" micro="BGM" icon="play" value=${s.bgm} onInput=${(v) => updateSettings({ bgm: v })} />
<${Slider} label="干员语音" micro="VOICE" icon="mic" value=${s.voice} onInput=${(v) => updateSettings({ voice: v })} />
<${Slider} label="音效" micro="SFX" icon="signal" value=${s.sfx}
onInput=${(v) => { updateSettings({ sfx: v }); if (!tested) { setTested(true); setTimeout(() => setTested(false), 400); audio.sfx('click'); } }} />
<${Toggle} label="静音" micro="MUTE" value=${s.muted} onChange=${(v) => updateSettings({ muted: v })} />