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:
1 parent
d76c5da7dd
commit
8e17a575cc
6 files changed
+193
-16
No files matched your search
+1
-1
@@ -1 +1 @@
|
||||
{"version":1,"hash":"375e961f7569","generator":"tools/fetch-assets.mjs","stats":{"files":4014,"bytes":282091345,"chars":138,"charsWithBack":135,"enemies":252,"enemiesWithSpine":251,"tokens":20,"tokensWithSpine":13,"spineModels":529,"bonds":23,"items":59,"bands":40,"skills":317,"ui":601,"sfxUnits":382},"chars":{"char_1012_skadi2":{"avatar":"/assets/char/avatar/char_1012_skadi2.png","avatarE2":"/assets/char/avatar/char_1012_skadi2_2.png","portrait":"/assets/char/portrait/char_1012_skadi2_1.png","portraitE2":"/assets/char/portrait/char_1012_skadi2_2.png","spine":{"front":{"skel":"/assets/spine/op/char_1012_skadi2/front/char_1012_skadi2.skel","atlas":"/assets/spine/op/char_1012_skadi2/front/char_1012_skadi2.atlas","textures":["/assets/spine/op/char_1012_skadi2/front/char_1012_skadi2.png"],"pma":false,"anims":{"idle":"Idle","deploy":"Start","attack":{"begin":null,"loop":"Skill_1_Loop","end":null,"via":"skill"},"attackDown":null,"skill":{"begin":"Skill_3_Begin","loop":"Skill_3_Loop","end":null,"index":2,"idle":null},"die":"Die","move":null,"stun":{"begin":"Stun_Begin","loop":"Stun","end":null},"skills":{"0":{"begin":"Skill_1_Begin","loop":"Skill_1_Loop","end":null,"index":0,"idle":null},"1":{"begin":"Skill_2_Begin","loop":"Skill_2_Loop","end":null,"index":1,"idle":null},"2":{"begin":"Skill_3_Begin","loop":"Skill_3_Loop","end":null,"index":2,"idle":null}}},"animations":{"Default":0,"Die":1,"Idle":8.767,"Skill_1_Begin":3.2,"Skill_1_Loop":8.767,"Skill_2_Begin":3.933,"Skill_2_Loop":3.333,"Skill_3_Begin":4.4,"Skill_3_Loop":8.767,"Start":1,"Stun":2,"Stun_Begin":1},"events":["OnAttack","OnStart"],"hits":{},"bounds":{"x":-190.586,"y":-25.785,"width":393.516,"height":443.426}}}},"char_1014_nearl2":{"avatar":"/assets/char/avatar/char_1014_nearl2.png","avatarE2":"/assets/char/avatar/char_1014_nearl2_2.png","portrait":"/assets/char/portrait/char_1014_nearl2_1.png","portraitE2":"/assets/char/portrait/char_1014_nearl2_2.png","spine":{"front":{"skel":"/assets/spine/op/char_1014_nearl2/front/char_1014_nearl2.skel","atlas":"/assets/spine/op/char_1014_nearl2/front/char_1014_nearl2.atlas","textures":["/assets/spine/op/char_1014_nearl2/front/char_1014_nearl2.png"],"pma":false,"anims":{"idle":"Idle","deploy":"Start","attack":{"begin":"Attack_Begin","loop":"Attack_Loop","end":"Attack_End"},"attackDown":null,"skill":{"begin":null,"loop":"Skill_3","end":null,"index":2,"idle":"Skill_3_Idle"},"die":"Die","move":null,"stun":null,"skills":{"0":{"begin":"Skill_1_Begin","loop":"Skill_1_Loop","end":null,"index":0,"idle":"Skill_1_Idle"},"1":{"begin":null,"loop":"Skill_2_Die","end":null,"index":1,"idle":null},"2":{"begin":null,"loop":"Skill_3","end":null,"index":2,"idle":"Skill_3_Idle"}}},"animations":{"Attack_Begin":0.1,"Attack_End":0.167,"Attack_Loop":1.5,"Default":0,"Die":1,"Idle":2.667,"Skill_3":1.5,"Skill_1_Begin":0.1,"Skill_1_Idle":1,"Skill_1_Loop":1.5,"Skill_1_Loop_Down":1.5,"Skill_2_Die":1.167,"Skill_3_Down":1.5,"Skill_3_Idle":1.333,"Start":1},"events":["OnAttack","OnStart"],"hits":{"Attack_Loop":[0.433],"Skill_3":[0.5],"Skill_1_Loop":[0.467],"Skill_1_Loop_Down":[0.467],"Skill_3_Down":[0.5]},"bounds":{"x":-400.77,"y":-461.007,"width":720.545,"height":872.242}},"back":{"skel":"/assets/spine/op/char_1014_nearl2/back/char_1014_nearl2.skel","atlas":"/assets/spine/op/char_1014_nearl2/back/char_1014_nearl2.atlas","textures":["/assets/spine/op/char_1014_nearl2/back/char_1014_nearl2.png"],"pma":false,"anims":{"idle":"Idle","deploy":"Start","attack":{"begin":"Attack_Begin","loop":"Attack_Loop","end":"Attack_End"},"attackDown":null,"skill":{"begin":null,"loop":"Skill_3","end":null,"index":2,"idle":"Skill_3_Idle"},"die":null,"move":null,"stun":null,"skills":{"0":{"begin":"Skill_1_Begin","loop":"Skill_1_Loop","end":null,"index":0,"idle":"Skill_1_Idle"},"1":{"begin":"Attack_Begin","loop":"Attack_Loop","end":"Attack_End","via":"attack","index":1,"idle":null},"2":{"begin":null,"loop":"Skill_3","end":null,"index":2,"idle":"Skill_3_Idle"}}},"animations":{"Attack_Begin":0.1,"Attack_End":0.167,"Attack_Loop":1.5,"Default":0,"Idle":2.667,"Skill_1_Begin":0.1,"Skill_1_Idle":1,"Skill_1_Loop":1.5,"Skill_3":1.5,"Skill_3_Idle":1.333,"Start":1},"events":["OnAttack","OnStart"],"hits":{"Attack_Loop":[0.433],"Skill_1_Loop":[0.467],"Skill_3":[0.5]},"bounds":null}}},"char_1016_agoat2":{"avatar":"/assets/char/avatar/char_1016_agoat2.png","avatarE2":"/assets/char/avatar/char_1016_agoat2_2.png","portrait":"/assets/char/portrait/char_1016_agoat2_1.png","portraitE2":"/assets/char/portrait/char_1016_agoat2_2.png","spine":{"front":{"skel":"/assets/spine/op/char_1016_agoat2/front/char_1016_agoat2.skel","atlas":"/assets/spine/op/char_1016_agoat2/front/char_1016_agoat2.atlas","textures":["/assets/spine/op/char_1016_agoat2/front/char_1016_agoat2.png"],"pma":false,"anims":{"idle":"Idle","deploy":"Start","attack":{"begin":null,"loop":"Attack","end":null},"attackDown":null,"skill":{"begin":"Skill_3_Begin","loop":"Skill_3_Loop","end":"Skill_3_End","index":2,"idle":"Skill_3_Idle"},"diLine truncated
|
||||
{"version":1,"hash":"7f118cf0f7bb","generator":"tools/fetch-assets.mjs","stats":{"files":4014,"bytes":282091345,"chars":138,"charsWithBack":135,"enemies":252,"enemiesWithSpine":251,"tokens":20,"tokensWithSpine":13,"spineModels":529,"bonds":23,"items":59,"bands":40,"skills":317,"ui":601,"sfxUnits":382},"chars":{"char_1012_skadi2":{"avatar":"/assets/char/avatar/char_1012_skadi2.png","avatarE2":"/assets/char/avatar/char_1012_skadi2_2.png","portrait":"/assets/char/portrait/char_1012_skadi2_1.png","portraitE2":"/assets/char/portrait/char_1012_skadi2_2.png","spine":{"front":{"skel":"/assets/spine/op/char_1012_skadi2/front/char_1012_skadi2.skel","atlas":"/assets/spine/op/char_1012_skadi2/front/char_1012_skadi2.atlas","textures":["/assets/spine/op/char_1012_skadi2/front/char_1012_skadi2.png"],"pma":false,"anims":{"idle":"Idle","deploy":"Start","attack":{"begin":null,"loop":"Skill_1_Loop","end":null,"via":"skill"},"attackDown":null,"skill":{"begin":"Skill_3_Begin","loop":"Skill_3_Loop","end":null,"index":2,"idle":null},"die":"Die","move":null,"stun":{"begin":"Stun_Begin","loop":"Stun","end":null},"skills":{"0":{"begin":"Skill_1_Begin","loop":"Skill_1_Loop","end":null,"index":0,"idle":null},"1":{"begin":"Skill_2_Begin","loop":"Skill_2_Loop","end":null,"index":1,"idle":null},"2":{"begin":"Skill_3_Begin","loop":"Skill_3_Loop","end":null,"index":2,"idle":null}}},"animations":{"Default":0,"Die":1,"Idle":8.767,"Skill_1_Begin":3.2,"Skill_1_Loop":8.767,"Skill_2_Begin":3.933,"Skill_2_Loop":3.333,"Skill_3_Begin":4.4,"Skill_3_Loop":8.767,"Start":1,"Stun":2,"Stun_Begin":1},"events":["OnAttack","OnStart"],"hits":{},"bounds":{"x":-190.586,"y":-25.785,"width":393.516,"height":443.426}}}},"char_1014_nearl2":{"avatar":"/assets/char/avatar/char_1014_nearl2.png","avatarE2":"/assets/char/avatar/char_1014_nearl2_2.png","portrait":"/assets/char/portrait/char_1014_nearl2_1.png","portraitE2":"/assets/char/portrait/char_1014_nearl2_2.png","spine":{"front":{"skel":"/assets/spine/op/char_1014_nearl2/front/char_1014_nearl2.skel","atlas":"/assets/spine/op/char_1014_nearl2/front/char_1014_nearl2.atlas","textures":["/assets/spine/op/char_1014_nearl2/front/char_1014_nearl2.png"],"pma":false,"anims":{"idle":"Idle","deploy":"Start","attack":{"begin":"Attack_Begin","loop":"Attack_Loop","end":"Attack_End"},"attackDown":null,"skill":{"begin":null,"loop":"Skill_3","end":null,"index":2,"idle":"Skill_3_Idle"},"die":"Die","move":null,"stun":null,"skills":{"0":{"begin":"Skill_1_Begin","loop":"Skill_1_Loop","end":null,"index":0,"idle":"Skill_1_Idle"},"1":{"begin":null,"loop":"Skill_2_Die","end":null,"index":1,"idle":null},"2":{"begin":null,"loop":"Skill_3","end":null,"index":2,"idle":"Skill_3_Idle"}}},"animations":{"Attack_Begin":0.1,"Attack_End":0.167,"Attack_Loop":1.5,"Default":0,"Die":1,"Idle":2.667,"Skill_3":1.5,"Skill_1_Begin":0.1,"Skill_1_Idle":1,"Skill_1_Loop":1.5,"Skill_1_Loop_Down":1.5,"Skill_2_Die":1.167,"Skill_3_Down":1.5,"Skill_3_Idle":1.333,"Start":1},"events":["OnAttack","OnStart"],"hits":{"Attack_Loop":[0.433],"Skill_3":[0.5],"Skill_1_Loop":[0.467],"Skill_1_Loop_Down":[0.467],"Skill_3_Down":[0.5]},"bounds":{"x":-400.77,"y":-461.007,"width":720.545,"height":872.242}},"back":{"skel":"/assets/spine/op/char_1014_nearl2/back/char_1014_nearl2.skel","atlas":"/assets/spine/op/char_1014_nearl2/back/char_1014_nearl2.atlas","textures":["/assets/spine/op/char_1014_nearl2/back/char_1014_nearl2.png"],"pma":false,"anims":{"idle":"Idle","deploy":"Start","attack":{"begin":"Attack_Begin","loop":"Attack_Loop","end":"Attack_End"},"attackDown":null,"skill":{"begin":null,"loop":"Skill_3","end":null,"index":2,"idle":"Skill_3_Idle"},"die":null,"move":null,"stun":null,"skills":{"0":{"begin":"Skill_1_Begin","loop":"Skill_1_Loop","end":null,"index":0,"idle":"Skill_1_Idle"},"1":{"begin":"Attack_Begin","loop":"Attack_Loop","end":"Attack_End","via":"attack","index":1,"idle":null},"2":{"begin":null,"loop":"Skill_3","end":null,"index":2,"idle":"Skill_3_Idle"}}},"animations":{"Attack_Begin":0.1,"Attack_End":0.167,"Attack_Loop":1.5,"Default":0,"Idle":2.667,"Skill_1_Begin":0.1,"Skill_1_Idle":1,"Skill_1_Loop":1.5,"Skill_3":1.5,"Skill_3_Idle":1.333,"Start":1},"events":["OnAttack","OnStart"],"hits":{"Attack_Loop":[0.433],"Skill_1_Loop":[0.467],"Skill_3":[0.5]},"bounds":null}}},"char_1016_agoat2":{"avatar":"/assets/char/avatar/char_1016_agoat2.png","avatarE2":"/assets/char/avatar/char_1016_agoat2_2.png","portrait":"/assets/char/portrait/char_1016_agoat2_1.png","portraitE2":"/assets/char/portrait/char_1016_agoat2_2.png","spine":{"front":{"skel":"/assets/spine/op/char_1016_agoat2/front/char_1016_agoat2.skel","atlas":"/assets/spine/op/char_1016_agoat2/front/char_1016_agoat2.atlas","textures":["/assets/spine/op/char_1016_agoat2/front/char_1016_agoat2.png"],"pma":false,"anims":{"idle":"Idle","deploy":"Start","attack":{"begin":null,"loop":"Attack","end":null},"attackDown":null,"skill":{"begin":"Skill_3_Begin","loop":"Skill_3_Loop","end":"Skill_3_End","index":2,"idle":"Skill_3_Idle"},"diLine truncated
|
||||
+6
-1
@@ -164,9 +164,14 @@ All paths are URL paths relative to the site root, for example `/assets/char/ava
|
||||
disconnect, settlementSucceed, settlementFail, settlementTeam, settlementBossSign,
|
||||
goodEvaluation, load, start, matchSucceed, matchFail, matchCancel, joinRoom },
|
||||
battle: { deploy, tokenDeploy, charDie, enemyDie, enemyDieHeavy, enemyHit, heal, win, lose, killCoin },
|
||||
units: { [charId|tokenId|enemyId]: { attack?, hit?, skill?, skills?: {[skillIndex]: url}, die?, born? } }
|
||||
units: { [charId|tokenId|enemyId]: { attack?, hit?, skill?, skills?: {[skillIndex]: url}, die?, born?,
|
||||
mix?: { [attack|hit|die|born]: { p?, vol? } } } }
|
||||
}
|
||||
},
|
||||
// units' mix (tools/assets/audio.mjs bankMix; community report #30): the official bank of a role's sound — `p` = the weight
|
||||
// of its sounds that have a file over all weights (an empty asset is a chance of silence: 猎狗pro / 深池侦察犬 0.2), `vol` =
|
||||
// the played file's volume (妖怪 0.7); only values other than 1. public/js/audio.js plays the role with chance p at its
|
||||
// base gain × min(1, vol)
|
||||
// units' attack / hit (tools/assets/audio.mjs pickUnitSfx): operators get normal-mode banks only — the plain
|
||||
// `attack` / `combat` ability first, never a bank holding a skill-mode file (`_d` / `_h` / `_s`; the normal attack's end
|
||||
// in `_n`) — with their own projectile banks (ON_PROJECTILE_BORN / _HIT.projectile_chr_<name>) as fallbacks
|
||||
|
||||
+36
-4
@@ -4,7 +4,7 @@
|
||||
// bgm { lobby, prep, combat, boss: { intro?, loop } }, bossBgm { [bossId]: { intro?, loop } },
|
||||
// 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? } }.
|
||||
// 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).
|
||||
@@ -22,6 +22,10 @@
|
||||
// sound of that target. An operator's attack / hit sound that is a skill-mode file of its own (official names end in
|
||||
// `_n` for the normal attack, `_d` / `_h` / `_s` for its skill modes — the manifest picked 纯烬艾雅法拉's S3 impact
|
||||
// p_imp_gtshpbrnch_s as her `hit`) never plays for a normal attack (normalAttackSfx).
|
||||
// - The official bank mix of a unit's own attack / hit / die / born sound (`mix`, tools/assets/audio.mjs bankMix; community
|
||||
// report #30): it plays with chance `p` — 猎狗pro / 深池侦察犬's attack bank is 80 % silence, so they bark on about one
|
||||
// attack in five (never replaced by the generic enemy sound) — at its base gain × `vol`, capped at 1: an official volume
|
||||
// below 1 is quieter (妖怪's 0.7), none is louder than before (unitGain).
|
||||
// - Deaths/deployments follow the official per-class defaults (unitSoundClass): only operators play the
|
||||
// operator-knocked-down sound; summons use the token sounds; a summon used up by its own effect (fx `consumed`,
|
||||
// 香槟炸弹) plays its impact sound instead of a death sound.
|
||||
@@ -163,6 +167,26 @@ export function normalAttackSfx(defId, url) {
|
||||
return typeof url === 'string' && !(typeof defId === 'string' && defId.startsWith('char_') && SKILL_MODE_FILE.test(url));
|
||||
}
|
||||
|
||||
/**
|
||||
* Gain of a unit's own sound with its manifest mix (sfx.units[id].mix[role]: the official bank's volume): `base` × `vol`,
|
||||
* never above `base` (a bank louder than 1 plays as before — community report #30 asked for quieter, not louder).
|
||||
* @param {number} base the role's base gain (attack / hit 0.55, die / born / skill 0.8)
|
||||
* @param {{ vol?: number }|null|undefined} mix
|
||||
*/
|
||||
export function unitGain(base, mix) {
|
||||
const v = mix && Number(mix.vol);
|
||||
return Number.isFinite(v) && v >= 0 ? base * Math.min(1, v) : base;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a unit's own sound plays this time: its official bank's chance `mix.p` (sounds with a file over all the weights;
|
||||
* 猎狗pro's attack bank 20 of 100). `roll` ∈ [0, 1).
|
||||
*/
|
||||
export function unitSoundPlays(mix, roll) {
|
||||
const p = mix && Number(mix.p);
|
||||
return !(Number.isFinite(p) && p >= 0 && p < 1) || roll < p;
|
||||
}
|
||||
|
||||
/** 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'];
|
||||
@@ -236,6 +260,7 @@ export class AudioManager {
|
||||
*/
|
||||
constructor(opts = {}) {
|
||||
this.getManifest = typeof opts.getManifest === 'function' ? opts.getManifest : () => null;
|
||||
this.random = typeof opts.random === 'function' ? opts.random : Math.random; // a unit sound's chance (mix.p)
|
||||
this.win = opts.win ?? (typeof window !== 'undefined' ? window : null);
|
||||
this.ctx = null;
|
||||
this.master = null;
|
||||
@@ -570,7 +595,10 @@ export class AudioManager {
|
||||
const url = typeof own === 'string' ? own : u?.[kind];
|
||||
if (typeof url !== 'string') return false;
|
||||
if ((kind === 'attack' || kind === 'hit') && !normalAttackSfx(defId, url)) return false;
|
||||
this._play(url, { volume: kind === 'attack' || kind === 'hit' ? 0.55 : 0.8, limited: true, unitKey: `${unitId}:${kind}` });
|
||||
// the official bank's mix (header): a silent roll still counts as the unit's own sound (no generic fallback)
|
||||
const mix = kind === 'skill' ? null : u?.mix?.[kind];
|
||||
if (!unitSoundPlays(mix, this.random())) return true;
|
||||
this._play(url, { volume: unitGain(kind === 'attack' || kind === 'hit' ? 0.55 : 0.8, mix), limited: true, unitKey: `${unitId}:${kind}` });
|
||||
return true;
|
||||
} catch { return false; }
|
||||
}
|
||||
@@ -637,7 +665,9 @@ export class AudioManager {
|
||||
const url = deathSfxUrl(m, u, { consumed, reason: typeof e[2] === 'string' ? e[2] : null });
|
||||
if (!url) continue;
|
||||
const own = url === m?.audio?.sfx?.units?.[u.def]?.die;
|
||||
this._playUnitUrl(url, own ? `${e[1]}:die` : `die:${e[1]}`, own ? 0.8 : 0.7);
|
||||
const mix = own ? m.audio.sfx.units[u.def].mix?.die : null;
|
||||
if (!unitSoundPlays(mix, this.random())) continue;
|
||||
this._playUnitUrl(url, own ? `${e[1]}:die` : `die:${e[1]}`, own ? unitGain(0.8, mix) : 0.7);
|
||||
} else if (kind === 'deploy') {
|
||||
const u = this.units.get(e[1]);
|
||||
if (!u || u.side === 'enemy') continue;
|
||||
@@ -645,7 +675,9 @@ export class AudioManager {
|
||||
const url = deploySfxUrl(m, u);
|
||||
if (!url) continue;
|
||||
const own = url === m?.audio?.sfx?.units?.[u.def]?.born;
|
||||
this._playUnitUrl(url, own ? `${e[1]}:born` : 'deploy', own ? 0.8 : 0.5);
|
||||
const mix = own ? m.audio.sfx.units[u.def].mix?.born : null;
|
||||
if (!unitSoundPlays(mix, this.random())) continue;
|
||||
this._playUnitUrl(url, own ? `${e[1]}:born` : 'deploy', own ? unitGain(0.8, mix) : 0.5);
|
||||
} else if (kind === 'fx') {
|
||||
// a summon used up by its own effect (香槟炸弹 exploding: `consumed`): its impact sound now, no death sound
|
||||
const ex = e[4];
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
// test/ui/feedback3-audio-mix.test.js — community report #30 (0.1.3): "狗、飞机敌人音效声音过大". The official attack bank of
|
||||
// 猎狗pro / 深池侦察犬 is 80 % silence (an empty asset weighs 80, e_atk_gopro_* 20), but the asset tool kept only the file
|
||||
// paths, so the client barked on every attack at a fixed 0.55; 妖怪's attack bank is official at volume 0.7. The bank's
|
||||
// play chance and volume now travel as sfx.units[id].mix (tools/assets/audio.mjs bankMix → plan.mjs) and audio.js plays a
|
||||
// unit's own sound with chance `p` at its base gain × min(1, vol) — never louder than before.
|
||||
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync, existsSync } from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { AudioManager, unitGain, unitSoundPlays } from '../../public/js/audio.js';
|
||||
import { indexAudio, pickUnitSfx, bankMix } from '../../tools/assets/audio.mjs';
|
||||
|
||||
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..');
|
||||
const manifest = JSON.parse(readFileSync(path.join(ROOT, 'data', 'assets.json'), 'utf8'));
|
||||
const UNITS = manifest.audio.sfx.units;
|
||||
const OFFICIAL = path.join(ROOT, '.cache', 'gamedata', 'excel', 'audio_data.json');
|
||||
|
||||
/** An audio manager with a context, recording what it would play: [{ url, volume, unitKey }]. */
|
||||
function manager(rolls) {
|
||||
const a = new AudioManager({ win: null, getManifest: () => manifest, random: () => rolls.shift() ?? 0 });
|
||||
a.ctx = {};
|
||||
a.played = [];
|
||||
a._play = (url, o) => { a.played.push({ url, volume: o.volume, unitKey: o.unitKey }); };
|
||||
return a;
|
||||
}
|
||||
|
||||
test('the bank mix: an empty asset is a chance of silence, the played file\'s volume (bankMix, indexAudio().mixOf)', () => {
|
||||
const gopro = [
|
||||
{ asset: '', weight: 80, minVolume: 1, maxVolume: 1 },
|
||||
{ asset: 'Audio/Sound_Beta_2/Enemy/e_atk/e_atk_gopro_1', weight: 10, minVolume: 1, maxVolume: 1 },
|
||||
{ asset: 'Audio/Sound_Beta_2/Enemy/e_atk/e_atk_gopro_2', weight: 10, minVolume: 1, maxVolume: 1 },
|
||||
];
|
||||
assert.deepEqual(bankMix(gopro, 'enemy/e_atk/e_atk_gopro_1.mp3'), { p: 0.2 });
|
||||
assert.deepEqual(bankMix([{ asset: 'Audio/Sound_Beta_2/Enemy/e_atk/e_atk_vehicle_n', weight: 1, minVolume: 0.7, maxVolume: 0.7 }], 'enemy/e_atk/e_atk_vehicle_n.mp3'), { vol: 0.7 });
|
||||
assert.equal(bankMix([{ asset: 'Audio/Sound_Beta_2/P/x', weight: 1, minVolume: 1, maxVolume: 1 }], 'p/x.mp3'), null, 'the default: nothing stored');
|
||||
const idx = indexAudio({ soundFXBanks: [
|
||||
{ name: 'battle.ON_ABILITY_ON.enemy_1000_gopro_2.combat', sounds: gopro },
|
||||
{ name: 'battle.ON_ABILITY_HIT.enemy_1000_gopro_2.combat', sounds: [{ asset: 'Audio/Sound_Beta_2/Enemy/e_imp/e_imp_general_w', weight: 1, minVolume: 1, maxVolume: 1 }] },
|
||||
] });
|
||||
const sfx = pickUnitSfx(idx.unitBanks.get('enemy_1000_gopro_2'));
|
||||
assert.deepEqual(sfx.attack, ['enemy/e_atk/e_atk_gopro_1.mp3', 'enemy/e_atk/e_atk_gopro_2.mp3']);
|
||||
assert.deepEqual(idx.mixOf(sfx.attack), { p: 0.2 }, 'the picked list carries its bank\'s mix');
|
||||
assert.equal(idx.mixOf(sfx.hit), null);
|
||||
assert.equal(idx.mixOf(['enemy/e_atk/e_atk_gopro_1.mp3']), null, 'only the bank\'s own list');
|
||||
});
|
||||
|
||||
test('data/assets.json carries the official mix: dogs 20 %, 妖怪 / 威龙 0.7 (and matches audio_data.json when cached)', () => {
|
||||
for (const id of ['enemy_1000_gopro_2', 'enemy_1165_duhond', 'enemy_1165_duhond_2']) assert.deepEqual(UNITS[id].mix, { attack: { p: 0.2 } }, id);
|
||||
for (const id of ['enemy_1005_yokai_2', 'enemy_1005_yokai_3']) assert.deepEqual(UNITS[id].mix, { attack: { vol: 0.7 } }, id);
|
||||
assert.equal(UNITS.char_002_amiya?.mix, undefined, 'default banks store nothing');
|
||||
if (!existsSync(OFFICIAL)) return;
|
||||
const banks = new Map(JSON.parse(readFileSync(OFFICIAL, 'utf8')).soundFXBanks.map((b) => [b.name, b]));
|
||||
const dog = banks.get('battle.ON_ABILITY_ON.enemy_1165_duhond.combat').sounds;
|
||||
assert.equal(dog.filter((s) => !s.asset).reduce((n, s) => n + s.weight, 0) / dog.reduce((n, s) => n + s.weight, 0), 0.8, 'official: 80 % silence');
|
||||
assert.equal(banks.get('battle.ON_ABILITY_ON.enemy_1005_yokai_2.attack').sounds[0].maxVolume, 0.7);
|
||||
});
|
||||
|
||||
test('a dog barks only on its chance and never falls back to the generic enemy sound; at most the old gain', () => {
|
||||
assert.equal(unitSoundPlays({ p: 0.2 }, 0.19), true);
|
||||
assert.equal(unitSoundPlays({ p: 0.2 }, 0.2), false);
|
||||
assert.equal(unitSoundPlays(null, 0.99), true);
|
||||
const a = manager([0.5, 0.9, 0.1, 0.3, 0.05]);
|
||||
a.setFieldUnits([{ id: 1, side: 'enemy', kind: 'enemy', spine: 'enemy_1000_gopro_2' }, { id: 2, side: 'ally', kind: 'op', spine: 'char_002_amiya' }]);
|
||||
for (let i = 0; i < 5; i++) { a.limiter = new a.limiter.constructor(); a.handleBattleEvents([['atk', 1, 2, 'none']]); }
|
||||
const barks = a.played.filter((p) => p.url === UNITS.enemy_1000_gopro_2.attack);
|
||||
assert.equal(barks.length, 2, 'rolls 0.1 and 0.05 of 0.5 / 0.9 / 0.1 / 0.3 / 0.05');
|
||||
for (const b of barks) assert.equal(b.volume, 0.55, 'official volume 1: the same gain as before');
|
||||
assert.equal(a.played.length, 2, 'the silent rolls play nothing — not the generic enemyHit either');
|
||||
});
|
||||
|
||||
test('妖怪 plays at 0.55 × 0.7; a bank louder than 1 stays at the old gain; die / born use the mix too', () => {
|
||||
assert.equal(unitGain(0.55, { vol: 0.7 }), 0.55 * 0.7);
|
||||
assert.equal(unitGain(0.55, { vol: 2 }), 0.55, 'never louder than before');
|
||||
assert.equal(unitGain(0.8, null), 0.8);
|
||||
const a = manager([]);
|
||||
assert.equal(a.unit('enemy_1005_yokai_2', 'attack', 7), true);
|
||||
assert.ok(Math.abs(a.played[0].volume - 0.385) < 1e-9);
|
||||
assert.equal(a.unit('char_1028_texas2', 'hit', 8), true);
|
||||
assert.equal(a.played[1].volume, 0.55, 'official 2: unchanged');
|
||||
a.setFieldUnits([{ id: 3, side: 'ally', kind: 'op', spine: 'char_2026_yu', defId: 'chess_char_x' }]);
|
||||
a.handleBattleEvents([['deploy', 3]]);
|
||||
const born = a.played.find((p) => p.url === UNITS.char_2026_yu.born);
|
||||
assert.ok(born && Math.abs(born.volume - 0.8 * 0.75) < 1e-9, '余 ON_UNIT_BORN at 0.75');
|
||||
});
|
||||
+41
-2
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user