feedback3 WD: unit sounds keep the official bank's play chance and volume — dogs bark on 20 % of attacks, 妖怪 at 0.7, never louder (community report #30)

This commit is contained in:
sganggs committed 2026-10-04 17:23:42 +08:00
1 parent d76c5da7dd
commit 8e17a575cc
6 files changed
+193 -16

No files matched your search

+41 -2
View File
@@ -10,6 +10,9 @@
// names used by public/js/audio.js, plus a few generic UI sounds where the
// autochess mode has no dedicated bank (buy/refresh/error/pick/drop).
// Every sound is addressed by its path under sound_beta_2 (lower-case, .mp3).
// - A bank's mix (bankMix, community report #30): the official banks weigh their sounds, and an empty asset is a chance
// of silence (猎狗 / 深池侦察犬 bark on 20 of 100 attacks), and give each sound a volume; `indexAudio().mixOf(paths)`
// returns { p?, vol? } of the bank a picked path list came from (plan.mjs writes it as sfx.units[id].mix).
const PREFIX_RE = /^audio\/sound_beta_2\//i;
@@ -25,20 +28,55 @@ export function assetToPath(asset) {
return p.endsWith('.mp3') ? p : p + '.mp3';
}
const round3 = (v) => Math.round(v * 1000) / 1000;
/**
* The official play chance and volume of a bank, for the file the client plays (`path`, the first of its list):
* `p` = the weight of the sounds that have a file over all the weights (an empty asset is a chance of silence),
* `vol` = that file's volume (the mean of minVolume / maxVolume). Only what differs from 1 is returned; null when
* neither does (the default: every attack plays its sound at the base gain).
* @param {Array<{ asset?: string, weight?: number, minVolume?: number, maxVolume?: number }>} sounds
* @param {string} path the played file (assetToPath form)
* @returns {{ p?: number, vol?: number } | null}
*/
export function bankMix(sounds, path) {
let total = 0, real = 0, vol = 1, found = false;
for (const s of Array.isArray(sounds) ? sounds : []) {
const w = Number(s && s.weight);
const wt = Number.isFinite(w) && w > 0 ? w : 0;
total += wt;
const p = assetToPath(s && s.asset);
if (!p) continue;
real += wt;
if (!found && p === path) {
found = true;
const lo = Number(s.minVolume), hi = Number(s.maxVolume);
if (Number.isFinite(lo) && Number.isFinite(hi) && lo >= 0 && hi >= 0) vol = round3((lo + hi) / 2);
}
}
const out = {};
if (total > 0 && real < total) out.p = round3(real / total);
if (vol !== 1) out.vol = vol;
return Object.keys(out).length ? out : null;
}
/**
* Index an audio_data.json object.
* @param {any} audioData parsed excel/audio_data.json
* @returns {{ bank: (name:string)=>string[], bgm: (name:string)=>({intro:string|null, loop:string}|null),
* unitBanks: Map<string, Map<string, string[]>>, skillBanks: Map<string, Map<string,string[]>> }}
* unitBanks: Map<string, Map<string, string[]>>, skillBanks: Map<string, Map<string,string[]>>,
* mixOf: (paths: string[]|null|undefined) => ({ p?: number, vol?: number }|null) }}
*/
export function indexAudio(audioData) {
const banks = new Map();
const mixes = new WeakMap(); // a bank's path list (the very array every lookup hands out) → bankMix
for (const b of Array.isArray(audioData?.soundFXBanks) ? audioData.soundFXBanks : []) {
if (!b || typeof b.name !== 'string') continue;
const paths = (Array.isArray(b.sounds) ? b.sounds : []).map((s) => assetToPath(s?.asset)).filter(Boolean);
if (!banks.has(b.name)) banks.set(b.name, []);
const list = banks.get(b.name);
for (const p of paths) if (!list.includes(p)) list.push(p);
if (list.length && !mixes.has(list)) { const m = bankMix(b.sounds, list[0]); if (m) mixes.set(list, m); }
}
const alias = audioData?.bankAlias && typeof audioData.bankAlias === 'object' ? audioData.bankAlias : {};
const bank = (name, depth = 0) => {
@@ -77,7 +115,8 @@ export function indexAudio(audioData) {
};
for (const [name, paths] of banks) if (paths.length) addUnit(name, paths);
for (const name of Object.keys(alias)) if (!banks.has(name)) { const p = bank(name); if (p.length) addUnit(name, p); }
return { bank, bgm, unitBanks, skillBanks };
const mixOf = (paths) => (paths && typeof paths === 'object' && mixes.get(paths)) || null;
return { bank, bgm, unitBanks, skillBanks, mixOf };
}
/** Sort key for ability sub-keys: plain first, then numeric suffixes ascending. */
+23 -8
View File
@@ -129,6 +129,23 @@ function soundLeaf(paths, sub = 'sfx', max = 4) {
return leaf((paths || []).slice(0, max).map((p) => soundAlt(p, sub)));
}
/**
* A unit's SFX roles (pickUnitSfx) → { roles: { attack?, hit?, die?, born? } sound leaves, mix: { [role]: { p?, vol? } }
* | null } — the official play chance / volume of each role's bank (audio.mjs bankMix; community report #30: 猎狗's
* attack bank is 80 % silence). The caller stores `mix` last in the unit's entry (sfx.units[id].mix).
*/
function unitSounds(audio, sfx) {
const roles = {};
let mix = null;
for (const r of ['attack', 'hit', 'die', 'born']) {
if (!sfx[r]) continue;
roles[r] = soundLeaf(sfx[r]);
const m = typeof audio.mixOf === 'function' ? audio.mixOf(sfx[r]) : null;
if (m) (mix || (mix = {}))[r] = m;
}
return { roles, mix };
}
/** Expected bytes for a 07 {url, bytes} record when it matches `url`. */
function bytesOf(rec, url) {
return rec && typeof rec === 'object' && rec.url === url && Number.isInteger(rec.bytes) ? rec.bytes : undefined;
@@ -286,8 +303,7 @@ export function buildPlan({ assets07, ops03, enemies05, maps05, audio, modelsDat
const short = id.replace(/^char_\d+_/, '');
const sfx = pickUnitSfx(audio.unitBanks.get(id), { operator: true, projectile: {
born: audio.bank(`battle.ON_PROJECTILE_BORN.projectile_chr_${short}`), hit: audio.bank(`battle.ON_PROJECTILE_HIT.projectile_chr_${short}`) } });
const u = {};
for (const r of ['attack', 'hit', 'die', 'born']) if (sfx[r]) u[r] = soundLeaf(sfx[r]);
const { roles: u, mix } = unitSounds(audio, sfx);
const skillSfx = {};
for (const i of idx) {
const s = (o.skills || []).find((k) => k.index === i);
@@ -301,6 +317,7 @@ export function buildPlan({ assets07, ops03, enemies05, maps05, audio, modelsDat
const primarySkill = skillSfx[String(idx[0])];
if (primarySkill) u.skill = primarySkill;
if (Object.keys(skillSfx).length > 1) u.skills = skillSfx;
if (mix) u.mix = mix;
if (Object.keys(u).length) unitsSfx[id] = u;
}
@@ -334,9 +351,8 @@ export function buildPlan({ assets07, ops03, enemies05, maps05, audio, modelsDat
}
entry.spine = model;
tokens[id] = entry;
const sfx = pickUnitSfx(audio.unitBanks.get(id));
const u = {};
for (const r of ['attack', 'hit', 'die', 'born']) if (sfx[r]) u[r] = soundLeaf(sfx[r]);
const { roles: u, mix } = unitSounds(audio, pickUnitSfx(audio.unitBanks.get(id)));
if (mix) u.mix = mix;
if (Object.keys(u).length) unitsSfx[id] = u;
}
@@ -406,9 +422,8 @@ export function buildPlan({ assets07, ops03, enemies05, maps05, audio, modelsDat
if (banks?.size) break;
if (other) banks = audio.unitBanks.get(other);
}
const sfx = pickUnitSfx(banks);
const u = {};
for (const r of ['attack', 'hit', 'die', 'born']) if (sfx[r]) u[r] = soundLeaf(sfx[r]);
const { roles: u, mix } = unitSounds(audio, pickUnitSfx(banks));
if (mix) u.mix = mix;
if (Object.keys(u).length) unitsSfx[id] = u;
}