feedback5: merge feedback4 (beb0296) — PR #73 (operator battle voices) ported to the split layout (the voice setting into ui/gameLogic/settings.js, the gameLogic.js shim unchanged; onResult and DetailPanel voice stay in screens/game.js, which needed its store import back; the sim's 'engage' in server/sim/ai.js performAttack, still the one battle/combat.js and devices.js call; plan.mjs / fetch-assets.mjs merged with --add-only, extraOperators and moduleTypes; the 干员语音 slider through t() with English); golden: roster / bonds / fields / standins / diy differ only in events.engage, matches unchanged
Ported by hand: - public/js/ui/gameLogic.js is a re-export shim on feedback5: #73's DEFAULT_SETTINGS / sanitizeSettings `voice` hunk went into public/js/ui/gameLogic/settings.js. - public/js/screens/game.js: MatchScreen still lives there, so #73's onResult (battleRunner 'result' → resultSpeaker / resultVoiceSlot) and `voice=${combat}` on the DetailPanel auto-merged in place. feedback5's game.js no longer imported `store` (the split moved its other users), so onResult's store.get() would have thrown into its try/catch and the 结算 line would never have played: `store` is imported again. `localAsset` stays dropped. - public/js/ui/settings.js (i18n phase 1): the slider is `label=${t('干员语音')}`, public/i18n/en.json "干员语音": "Operator Voices" (next to Music / Sound Effects). - tools/assets/plan.mjs: buildPlan takes #73's charword / voiceLang / voiceSlots and feedback5's extraOperators / moduleTypes; the template has `modules` and audio { bgm, bossBgm, voice, sfx }. `chars` now includes the 自选 picks, so the voice loop's comment says so (the next `npm run assets` plans their lines too). - tools/fetch-assets.mjs: parseArgs keeps --add-only beside --voice-lang / --voice-all. - data/assets.json: feedback5's manifest plus #73's audio.voice (120 operators, after bossBgm, the plan order); stats files +1680 and bytes +42 041 118 as measured on feedback4, voiceChars 120; the hash is recomputed with the generator's contentHash. No other entry changed. - docs/ASSETS.md: the stats line lists modules and voiceChars. DESIGN §21.30 stays where feedback4 put it; SIM.md's 'engage' line and docs-consistency's §21.30 test auto-merged (game.js / detailPanel.js still hold the pinned code). - Auto-merged and checked: audio.js, battle/runner.js emit('result'), components.js mic icon, detailPanel.js voice prop, shared/protocol.js EV.ENGAGE, server/sim/ai.js, tools/assets/audio.mjs / cache.mjs, #73's tests. Where #73 met feedback5 work: - 补位: a chess fielded as its stand-in shows the stand-in in the detail card, and audio.js keys battle lines by UnitInfo spine (the stand-in's model). The 选中干员 line now takes detail.standIn.charId first, so the stand-in speaks, not the operator it replaces (test/ui/standin-ui.test.js pins it). A 自选 record is already the pick's. - The sim's 'engage': stand-in and 自选 units attack through the same performAttack, so it fires for them as well. Golden regenerated: every moved scenario differs only in events.engage (the new event's count; no gameplay change), proven by a field-path diff against feedback5: - roster: all 49; bonds: all 46; fields: all 22; standins: all 10 (the stand-ins engage). - diy: 101 of 109. Unmoved: diy-010/011/012/092/093 (闪灵, 夜莺, 凯尔希 squads: they only heal, and a heal never engages) and diy-077 (薇薇安娜), diy-080/081 (赫德雷), whose picks make no attack in those battles (already so on feedback5). - matches: unchanged.
This commit is contained in:
commit
c5e8d13636
29 files changed
+1059
-278
No files matched your search
+288
-13
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -103,12 +103,12 @@ import { BriefingScreen } from './briefing.js';
|
||||
import { BandDraftScreen } from './bandDraft.js';
|
||||
import { ResultScreen } from './result.js';
|
||||
import { net } from '../net.js';
|
||||
import { useStore, shallowEqual, serverNow, isSpectating } from '../store.js';
|
||||
import { store, useStore, shallowEqual, serverNow, isSpectating } from '../store.js';
|
||||
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, getMode } from '../data.js';
|
||||
import { audio } from '../audio.js';
|
||||
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';
|
||||
@@ -466,8 +466,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]);
|
||||
|
||||
@@ -1286,7 +1308,7 @@ function MatchScreen() {
|
||||
onClose=${() => setBondOpen(null)} onMember=${(id, items, standInFor) => setDetail({ kind: 'chess', id, owner: bondPop.ownerId, items: items || null, standInFor: standInFor || 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}
|
||||
|
||||
@@ -67,6 +67,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' },
|
||||
|
||||
@@ -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, diyToken } from './gameComponents.js';
|
||||
import { attackInterval, rangeGridBox, fmtNum, tileKey, chessLoadout, nextThreshold, bondTier, briefingBondTip, pieceBondIds, grantedBonds, morphPairings, ownStandIn, standInOf, standInLoadout, standInLabel, standInTip, standInForText, ownDiyRecord, ownDiyPick, diyRecordFor, pickGetter } from './gameLogic.js';
|
||||
@@ -41,6 +42,7 @@ import { attackRangeGrid } from '../../../shared/loadoutRecord.js';
|
||||
import { SKILL_SUMMON_START_DEPLOY } from '../../../shared/constants.js';
|
||||
import { moduleBadge } from './loadoutModel.js';
|
||||
import { t } from '../../../shared/i18n.js';
|
||||
import { audio } from '../audio.js';
|
||||
|
||||
const cx = (...p) => p.flat().filter(Boolean).join(' ');
|
||||
|
||||
@@ -695,10 +697,21 @@ export function resolveDetail(target, pieces, { priv = null, backups = data.get(
|
||||
* = 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;
|
||||
// 0.2.0 补位: a chess fielded as its stand-in is spoken for by the stand-in (the operator on the field, whose model, name
|
||||
// and battle lines the card and audio.js show), never by the operator it replaces; a 自选 record is already the pick's
|
||||
const selectChar = voice && detail?.type === 'chess' ? detail.standIn?.charId || 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; }
|
||||
|
||||
@@ -5,13 +5,13 @@ import { clamp, isObj } from './shared.js';
|
||||
|
||||
// ---- 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 : {};
|
||||
@@ -19,6 +19,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,
|
||||
|
||||
@@ -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 — which also holds the language switch (ui/lang.js; kept apart in `sp.pref.lang`).
|
||||
|
||||
@@ -12,7 +12,7 @@ import { detectFeatures } from './device.js';
|
||||
import { LangToggle } from './lang.js';
|
||||
import { t, tc, N_ } from '../../../shared/i18n.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) => {
|
||||
@@ -69,6 +69,7 @@ export function SettingsModal({ open, onClose }) {
|
||||
<${LangToggle} class="set-lang" />
|
||||
</div>
|
||||
<${Slider} label=${t('背景音乐')} micro="BGM" icon="play" value=${s.bgm} onInput=${(v) => updateSettings({ bgm: v })} />
|
||||
<${Slider} label=${t('干员语音')} micro="VOICE" icon="mic" value=${s.voice} onInput=${(v) => updateSettings({ voice: v })} />
|
||||
<${Slider} label=${t('音效')} 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=${t('静音')} micro="MUTE" value=${s.muted} onChange=${(v) => updateSettings({ muted: v })} />
|
||||
|
||||
Reference in new issue
Block a user