feat: add era-based camp soundscapes
This commit is contained in:
@@ -14,6 +14,21 @@ License reference: https://pixabay.com/service/license-summary/
|
||||
| `src/assets/audio/bgm/militia-theme.mp3` | Orchestra, Heroic, Warrior music | NR-Music | https://pixabay.com/music/adventure-short-heroic-orchestral-loop-541095/ |
|
||||
| `src/assets/audio/bgm/battle-prep.mp3` | Epic orchestral, War, Orchestral music | Sonican | https://pixabay.com/music/main-title-epic-for-war-orchestral-loop-364200/ |
|
||||
|
||||
## Project-original Camp Soundscapes
|
||||
|
||||
The following files are generated deterministically by `scripts/generate-camp-audio.mjs`. They use only synthesized oscillators and seeded procedural noise; no external samples or copyrighted recordings are included.
|
||||
|
||||
| Project file | Role |
|
||||
| --- | --- |
|
||||
| `src/assets/audio/bgm/camp-rally.mp3` | Yellow Turban and anti-Dong Zhuo camp resolve theme |
|
||||
| `src/assets/audio/bgm/camp-wayfarer.mp3` | Xuzhou, wandering, and Wolong camp theme |
|
||||
| `src/assets/audio/bgm/camp-grand-strategy.mp3` | Red Cliffs through Yiling grand-strategy camp theme |
|
||||
| `src/assets/audio/bgm/camp-frontier.mp3` | Nanzhong and Northern Expeditions frontier camp theme |
|
||||
| `src/assets/audio/ambience/campfire-ambience.mp3` | Campfire bed for early and wandering camps |
|
||||
| `src/assets/audio/ambience/river-night-ambience.mp3` | River-night bed for Red Cliffs, Jingzhou, and Yiling camps |
|
||||
| `src/assets/audio/ambience/mountain-wind-ambience.mp3` | Mountain-wind bed for Wolong, Hanzhong, and Northern camps |
|
||||
| `src/assets/audio/ambience/nanzhong-night-ambience.mp3` | Forest-night bed for Nanzhong camps |
|
||||
|
||||
## Sound Effects
|
||||
|
||||
| Project file | Pixabay title | Creator | Source |
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
"debug": "vite --host 0.0.0.0 --debug",
|
||||
"build": "tsc && vite build",
|
||||
"generate:audio": "node scripts/generate-bgm.mjs",
|
||||
"generate:camp-audio": "node scripts/generate-camp-audio.mjs",
|
||||
"generate:sfx": "node scripts/generate-sfx.mjs",
|
||||
"audit:equipment-icons": "node scripts/audit-equipment-icon-quality.mjs",
|
||||
"audit:visual-assets": "node scripts/audit-visual-asset-quality.mjs",
|
||||
@@ -21,6 +22,8 @@
|
||||
"verify:sortie-synergy": "node scripts/verify-sortie-synergy.mjs",
|
||||
"verify:camp-rewards": "node scripts/verify-camp-reward-data.mjs",
|
||||
"verify:camp-skins": "node scripts/verify-camp-skin-data.mjs",
|
||||
"verify:camp-soundscapes": "node scripts/verify-camp-soundscape-data.mjs",
|
||||
"verify:sound-director": "node scripts/verify-sound-director.mjs",
|
||||
"verify:campaign-recruits": "node scripts/verify-campaign-recruit-data.mjs",
|
||||
"verify:equipment-catalog": "node scripts/verify-equipment-catalog-data.mjs",
|
||||
"verify:inventory-rewards": "node scripts/verify-inventory-reward-data.mjs",
|
||||
|
||||
746
scripts/generate-camp-audio.mjs
Normal file
746
scripts/generate-camp-audio.mjs
Normal file
@@ -0,0 +1,746 @@
|
||||
import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { basename, join, resolve, sep } from 'node:path';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
|
||||
const sampleRate = 44100;
|
||||
const channels = 2;
|
||||
const loopTailSeconds = 2;
|
||||
const bgmOutputDir = join('src', 'assets', 'audio', 'bgm');
|
||||
const ambienceOutputDir = join('src', 'assets', 'audio', 'ambience');
|
||||
const ffmpeg = process.env.FFMPEG_PATH?.trim() || 'ffmpeg';
|
||||
|
||||
const musicTracks = [
|
||||
{
|
||||
name: 'camp-rally',
|
||||
title: 'Camp Rally',
|
||||
tempo: 76,
|
||||
bars: 16,
|
||||
root: 50,
|
||||
progression: [[0, 7, 12], [5, 12, 16], [7, 14, 19], [2, 9, 14]],
|
||||
melody: [12, 14, 16, 19, 21, 19, 16, 14, 12, 14, 19, 21, 19, 16, 14, 12],
|
||||
mood: 'rally',
|
||||
stringGain: 0.02,
|
||||
lowGain: 0.024,
|
||||
pluckGain: 0.014,
|
||||
leadGain: 0.018,
|
||||
drumGain: 0.025,
|
||||
pluckBeats: [0.12, 0.62, 1.12, 1.62, 2.12, 2.62, 3.12, 3.62],
|
||||
drumBeats: [0, 2, 2.5]
|
||||
},
|
||||
{
|
||||
name: 'camp-wayfarer',
|
||||
title: 'Camp Wayfarer',
|
||||
tempo: 64,
|
||||
bars: 16,
|
||||
root: 45,
|
||||
progression: [[0, 7, 10], [-2, 5, 10], [-5, 2, 7], [0, 5, 10]],
|
||||
melody: [12, 15, 17, 19, 17, 15, 12, 10, 7, 10, 12, 15, 12, 10, 7, 5],
|
||||
mood: 'wayfarer',
|
||||
stringGain: 0.017,
|
||||
lowGain: 0.016,
|
||||
pluckGain: 0.01,
|
||||
leadGain: 0.014,
|
||||
drumGain: 0.012,
|
||||
pluckBeats: [0.18, 2.18],
|
||||
drumBeats: []
|
||||
},
|
||||
{
|
||||
name: 'camp-grand-strategy',
|
||||
title: 'Camp Grand Strategy',
|
||||
tempo: 70,
|
||||
bars: 16,
|
||||
root: 43,
|
||||
progression: [[0, 7, 12], [5, 12, 17], [2, 9, 14], [-2, 5, 10]],
|
||||
melody: [12, 14, 19, 21, 19, 16, 14, 12, 14, 16, 19, 23, 21, 19, 16, 14],
|
||||
mood: 'grand',
|
||||
stringGain: 0.022,
|
||||
lowGain: 0.025,
|
||||
pluckGain: 0.015,
|
||||
leadGain: 0.019,
|
||||
drumGain: 0.028,
|
||||
pluckBeats: [0.12, 0.78, 1.45, 2.12, 2.78, 3.45],
|
||||
drumBeats: [0, 2, 3.5]
|
||||
},
|
||||
{
|
||||
name: 'camp-frontier',
|
||||
title: 'Camp Frontier',
|
||||
tempo: 66,
|
||||
bars: 16,
|
||||
root: 47,
|
||||
progression: [[0, 7, 10], [-2, 5, 10], [3, 10, 15], [0, 5, 12]],
|
||||
melody: [12, 15, 17, 19, 22, 19, 17, 15, 12, 10, 12, 15, 17, 15, 12, 10],
|
||||
mood: 'frontier',
|
||||
stringGain: 0.018,
|
||||
lowGain: 0.017,
|
||||
pluckGain: 0.013,
|
||||
leadGain: 0.016,
|
||||
drumGain: 0.02,
|
||||
pluckBeats: [0.18, 0.92, 1.68, 2.42, 3.18],
|
||||
drumBeats: [0, 1.5, 3]
|
||||
}
|
||||
];
|
||||
|
||||
const ambienceTracks = [
|
||||
{ name: 'campfire-ambience', title: 'Campfire Ambience', duration: 24, seed: 0x31a9f27d, kind: 'campfire' },
|
||||
{ name: 'river-night-ambience', title: 'River Night Ambience', duration: 32, seed: 0x6c4d813b, kind: 'river' },
|
||||
{ name: 'mountain-wind-ambience', title: 'Mountain Wind Ambience', duration: 36, seed: 0x52f0a7c1, kind: 'wind' },
|
||||
{ name: 'nanzhong-night-ambience', title: 'Nanzhong Night Ambience', duration: 32, seed: 0x18bd4e63, kind: 'nanzhong' }
|
||||
];
|
||||
|
||||
assertFfmpeg();
|
||||
mkdirSync(bgmOutputDir, { recursive: true });
|
||||
mkdirSync(ambienceOutputDir, { recursive: true });
|
||||
|
||||
const temporaryRoot = resolve(tmpdir());
|
||||
const temporaryDir = mkdtempSync(join(temporaryRoot, 'heros-camp-audio-'));
|
||||
|
||||
try {
|
||||
for (const track of musicTracks) {
|
||||
const render = buildMusicTrack(track);
|
||||
prepareMaster(render.buffer, 68, 0.62, true);
|
||||
const loopBuffer = wrapTailIntoStart(render.buffer, render.loopDuration, loopTailSeconds);
|
||||
renderMp3(loopBuffer, track, bgmOutputDir, 192, -16, -1.5, 'music');
|
||||
}
|
||||
|
||||
for (const track of ambienceTracks) {
|
||||
const render = buildAmbienceTrack(track);
|
||||
prepareMaster(render.buffer, 45, 0.54, false);
|
||||
const loopBuffer = wrapTailIntoStart(render.buffer, render.loopDuration, loopTailSeconds);
|
||||
renderMp3(loopBuffer, track, ambienceOutputDir, 128, -24, -3, 'ambience');
|
||||
}
|
||||
} finally {
|
||||
const resolvedTemporaryDir = resolve(temporaryDir);
|
||||
const safeTemporaryPrefix = `${temporaryRoot}${sep}`;
|
||||
if (resolvedTemporaryDir.startsWith(safeTemporaryPrefix) && basename(resolvedTemporaryDir).startsWith('heros-camp-audio-')) {
|
||||
rmSync(resolvedTemporaryDir, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
function buildMusicTrack(track) {
|
||||
const beat = 60 / track.tempo;
|
||||
const barDuration = beat * 4;
|
||||
const loopDuration = barDuration * track.bars;
|
||||
const renderBuffer = createBuffer(loopDuration + loopTailSeconds);
|
||||
|
||||
for (let barIndex = 0; barIndex < track.bars; barIndex += 1) {
|
||||
const start = barIndex * barDuration;
|
||||
const chord = track.progression[barIndex % track.progression.length];
|
||||
const dynamics = musicDynamics(track.mood, barIndex, track.bars);
|
||||
|
||||
addStringBed(renderBuffer, start, barDuration + 0.9, track, chord, dynamics);
|
||||
addLowPulse(renderBuffer, start, beat, track, chord, dynamics);
|
||||
addPluckedFigure(renderBuffer, start, beat, track, chord, barIndex, dynamics);
|
||||
addLeadNote(renderBuffer, start, beat, track, barIndex, dynamics);
|
||||
addDrumPattern(renderBuffer, start, beat, track, barIndex, dynamics);
|
||||
if ((track.mood === 'rally' || track.mood === 'grand') && (barIndex + 1) % 8 === 0) {
|
||||
addGong(renderBuffer, start + beat * 3.35, track.mood === 'grand' ? 0.012 : 0.01);
|
||||
}
|
||||
if (track.mood === 'wayfarer' && (barIndex === 3 || barIndex === 11)) {
|
||||
addBell(renderBuffer, start + beat * 3.1, 0.008);
|
||||
}
|
||||
}
|
||||
|
||||
return { buffer: renderBuffer, loopDuration };
|
||||
}
|
||||
|
||||
function musicDynamics(mood, barIndex, bars) {
|
||||
if (mood === 'grand') {
|
||||
const rise = Math.min(1, barIndex / Math.max(1, bars - 5));
|
||||
const release = barIndex >= bars - 4 ? (bars - barIndex) / 4 : 1;
|
||||
return (0.84 + rise * 0.16) * Math.max(0.88, release);
|
||||
}
|
||||
return 0.88 + Math.sin((barIndex / bars) * Math.PI) * 0.12;
|
||||
}
|
||||
|
||||
function addStringBed(buffer, start, duration, track, chord, dynamics) {
|
||||
const notes = [
|
||||
{ midi: track.root + chord[0] + 12, pan: -0.28, gain: 0.78 },
|
||||
{ midi: track.root + chord[1] + 12, pan: 0.28, gain: 0.66 },
|
||||
{ midi: track.root + chord[2] + 12, pan: -0.02, gain: 0.54 },
|
||||
{ midi: track.root + chord[1] + 24, pan: 0.18, gain: 0.23 }
|
||||
];
|
||||
|
||||
notes.forEach((note, index) => addTone(buffer, {
|
||||
start: start + index * 0.04,
|
||||
duration,
|
||||
frequency: midiToFrequency(note.midi),
|
||||
gain: track.stringGain * note.gain * dynamics,
|
||||
pan: note.pan,
|
||||
attack: track.mood === 'grand' ? 0.8 : 1.15,
|
||||
release: 1.2,
|
||||
wave: stringWave
|
||||
}));
|
||||
}
|
||||
|
||||
function addLowPulse(buffer, start, beat, track, chord, dynamics) {
|
||||
const pulses = track.mood === 'wayfarer' ? [0] : [0, 2];
|
||||
pulses.forEach((beatOffset) => addTone(buffer, {
|
||||
start: start + beat * beatOffset,
|
||||
duration: beat * (track.mood === 'wayfarer' ? 2.4 : 1.45),
|
||||
frequency: midiToFrequency(Math.max(38, track.root + chord[0])),
|
||||
gain: track.lowGain * dynamics,
|
||||
pan: -0.08,
|
||||
attack: 0.06,
|
||||
release: 0.62,
|
||||
wave: celloWave
|
||||
}));
|
||||
}
|
||||
|
||||
function addPluckedFigure(buffer, start, beat, track, chord, barIndex, dynamics) {
|
||||
track.pluckBeats.forEach((beatOffset, index) => {
|
||||
const melodicOffset = track.melody[(barIndex + index) % track.melody.length];
|
||||
const chordLift = chord[index % chord.length] % 5;
|
||||
addTone(buffer, {
|
||||
start: start + beat * beatOffset,
|
||||
duration: beat * 0.66,
|
||||
frequency: midiToFrequency(track.root + melodicOffset + 12 + chordLift),
|
||||
gain: track.pluckGain * dynamics,
|
||||
pan: index % 2 === 0 ? -0.36 : 0.36,
|
||||
attack: 0.008,
|
||||
release: 0.2,
|
||||
wave: pluckWave,
|
||||
exponentialDecay: track.mood === 'wayfarer' ? 5.2 : 6.3
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function addLeadNote(buffer, start, beat, track, barIndex, dynamics) {
|
||||
const noteOffset = track.melody[barIndex % track.melody.length];
|
||||
const leadStart = track.mood === 'wayfarer' ? 1.78 : track.mood === 'frontier' ? 1.35 : 1.18;
|
||||
const leadDuration = track.mood === 'wayfarer' ? beat * 1.65 : beat * 1.2;
|
||||
addTone(buffer, {
|
||||
start: start + beat * leadStart,
|
||||
duration: leadDuration,
|
||||
frequency: midiToFrequency(track.root + noteOffset + 12),
|
||||
gain: track.leadGain * dynamics,
|
||||
pan: track.mood === 'frontier' ? -0.08 : 0.12,
|
||||
attack: 0.12,
|
||||
release: 0.48,
|
||||
wave: reedWave
|
||||
});
|
||||
}
|
||||
|
||||
function addDrumPattern(buffer, start, beat, track, barIndex, dynamics) {
|
||||
const beats = track.mood === 'wayfarer' && (barIndex === 7 || barIndex === 15)
|
||||
? [0]
|
||||
: track.drumBeats;
|
||||
beats.forEach((beatOffset, index) => addTone(buffer, {
|
||||
start: start + beat * beatOffset,
|
||||
duration: beat * 0.48,
|
||||
frequency: index === 0 ? 88 : track.mood === 'frontier' ? 112 : 122,
|
||||
gain: track.drumGain * dynamics * (index === 0 ? 1 : 0.68),
|
||||
pan: index % 2 === 0 ? -0.04 : 0.04,
|
||||
attack: 0.004,
|
||||
release: 0.12,
|
||||
wave: drumWave,
|
||||
exponentialDecay: 8.8
|
||||
}));
|
||||
|
||||
if (track.mood === 'frontier') {
|
||||
addTone(buffer, {
|
||||
start,
|
||||
duration: beat * 0.7,
|
||||
frequency: 66,
|
||||
gain: 0.026 * dynamics,
|
||||
pan: 0,
|
||||
attack: 0.004,
|
||||
release: 0.18,
|
||||
wave: drumWave,
|
||||
exponentialDecay: 7.4
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function addGong(buffer, start, gain) {
|
||||
[
|
||||
{ ratio: 1, amount: 0.72, pan: -0.05 },
|
||||
{ ratio: 1.48, amount: 0.42, pan: 0.08 },
|
||||
{ ratio: 2.07, amount: 0.24, pan: 0.2 }
|
||||
].forEach(({ ratio, amount, pan }) => addTone(buffer, {
|
||||
start,
|
||||
duration: 2.2,
|
||||
frequency: 164 * ratio,
|
||||
gain: gain * amount,
|
||||
pan,
|
||||
attack: 0.008,
|
||||
release: 0.7,
|
||||
wave: gongWave,
|
||||
exponentialDecay: 1.7
|
||||
}));
|
||||
}
|
||||
|
||||
function addBell(buffer, start, gain) {
|
||||
[1, 2.02, 3.94].forEach((ratio, index) => addTone(buffer, {
|
||||
start,
|
||||
duration: 2.1,
|
||||
frequency: 520 * ratio,
|
||||
gain: gain / (index + 1),
|
||||
pan: index % 2 === 0 ? -0.18 : 0.18,
|
||||
attack: 0.004,
|
||||
release: 0.6,
|
||||
wave: sineWave,
|
||||
exponentialDecay: 2.4
|
||||
}));
|
||||
}
|
||||
|
||||
function buildAmbienceTrack(track) {
|
||||
const renderBuffer = createBuffer(track.duration + loopTailSeconds);
|
||||
if (track.kind === 'campfire') {
|
||||
synthesizeCampfire(renderBuffer, track);
|
||||
} else if (track.kind === 'river') {
|
||||
synthesizeRiverNight(renderBuffer, track);
|
||||
} else if (track.kind === 'wind') {
|
||||
synthesizeMountainWind(renderBuffer, track);
|
||||
} else {
|
||||
synthesizeNanzhongNight(renderBuffer, track);
|
||||
}
|
||||
return { buffer: renderBuffer, loopDuration: track.duration };
|
||||
}
|
||||
|
||||
function synthesizeCampfire(buffer, track) {
|
||||
const random = createRandom(track.seed);
|
||||
let leftState = 0;
|
||||
let rightState = 0;
|
||||
const alpha = onePoleAlpha(2100);
|
||||
for (let index = 0; index < buffer.left.length; index += 1) {
|
||||
leftState += alpha * ((random() * 2 - 1) - leftState);
|
||||
rightState += alpha * ((random() * 2 - 1) - rightState);
|
||||
const t = index / sampleRate;
|
||||
const breathe = 0.7 + Math.sin(2 * Math.PI * 0.19 * t) * 0.16 + Math.sin(2 * Math.PI * 0.43 * t) * 0.08;
|
||||
buffer.left[index] += leftState * 0.035 * breathe + Math.sin(2 * Math.PI * 78 * t) * 0.003;
|
||||
buffer.right[index] += rightState * 0.033 * breathe + Math.sin(2 * Math.PI * 81 * t) * 0.0025;
|
||||
}
|
||||
|
||||
const crackleCount = Math.floor((track.duration + loopTailSeconds) * 3.1);
|
||||
for (let event = 0; event < crackleCount; event += 1) {
|
||||
const start = random() * (track.duration + loopTailSeconds - 0.08);
|
||||
addNoiseBurst(buffer, random, start, 0.018 + random() * 0.045, 0.07 + random() * 0.1, random() * 1.4 - 0.7, 44 + random() * 34);
|
||||
}
|
||||
}
|
||||
|
||||
function synthesizeRiverNight(buffer, track) {
|
||||
const random = createRandom(track.seed);
|
||||
let lowLeft = 0;
|
||||
let lowRight = 0;
|
||||
let previousLeft = 0;
|
||||
let previousRight = 0;
|
||||
const lowAlpha = onePoleAlpha(2200);
|
||||
const highAlpha = highPassAlpha(90);
|
||||
for (let index = 0; index < buffer.left.length; index += 1) {
|
||||
const t = index / sampleRate;
|
||||
const inputLeft = random() * 2 - 1;
|
||||
const inputRight = random() * 2 - 1;
|
||||
lowLeft += lowAlpha * (inputLeft - lowLeft);
|
||||
lowRight += lowAlpha * (inputRight - lowRight);
|
||||
const highLeft = highAlpha * ((buffer.left[index - 1] ?? 0) + lowLeft - previousLeft);
|
||||
const highRight = highAlpha * ((buffer.right[index - 1] ?? 0) + lowRight - previousRight);
|
||||
previousLeft = lowLeft;
|
||||
previousRight = lowRight;
|
||||
const swell = 0.72 + Math.sin(2 * Math.PI * 0.07 * t) * 0.18 + Math.sin(2 * Math.PI * 0.13 * t + 1.1) * 0.09;
|
||||
buffer.left[index] = highLeft * 0.055 * swell;
|
||||
buffer.right[index] = highRight * 0.055 * (1.46 - swell * 0.56);
|
||||
}
|
||||
|
||||
for (let event = 0; event < 12; event += 1) {
|
||||
const start = random() * (track.duration + loopTailSeconds - 0.5);
|
||||
addTone(buffer, {
|
||||
start,
|
||||
duration: 0.24 + random() * 0.18,
|
||||
frequency: 820 + random() * 620,
|
||||
gain: 0.008 + random() * 0.006,
|
||||
pan: random() * 1.4 - 0.7,
|
||||
attack: 0.006,
|
||||
release: 0.16,
|
||||
wave: dropletWave,
|
||||
exponentialDecay: 7.2
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function synthesizeMountainWind(buffer, track) {
|
||||
const random = createRandom(track.seed);
|
||||
let lowLeft = 0;
|
||||
let lowRight = 0;
|
||||
let highLeft = 0;
|
||||
let highRight = 0;
|
||||
let previousLowLeft = 0;
|
||||
let previousLowRight = 0;
|
||||
const lowAlpha = onePoleAlpha(1400);
|
||||
const highAlpha = highPassAlpha(180);
|
||||
for (let index = 0; index < buffer.left.length; index += 1) {
|
||||
const t = index / sampleRate;
|
||||
lowLeft += lowAlpha * ((random() * 2 - 1) - lowLeft);
|
||||
lowRight += lowAlpha * ((random() * 2 - 1) - lowRight);
|
||||
highLeft = highAlpha * (highLeft + lowLeft - previousLowLeft);
|
||||
highRight = highAlpha * (highRight + lowRight - previousLowRight);
|
||||
previousLowLeft = lowLeft;
|
||||
previousLowRight = lowRight;
|
||||
const gust = 0.56 + Math.sin(2 * Math.PI * 0.035 * t) * 0.2 + Math.sin(2 * Math.PI * 0.08 * t + 1.7) * 0.16 + Math.sin(2 * Math.PI * 0.17 * t) * 0.06;
|
||||
const whistle = Math.sin(2 * Math.PI * (510 + Math.sin(2 * Math.PI * 0.041 * t) * 90) * t) * Math.max(0, gust - 0.65) * 0.004;
|
||||
buffer.left[index] = highLeft * 0.05 * gust + whistle;
|
||||
buffer.right[index] = highRight * 0.048 * (gust * 0.92 + 0.08) + whistle * 0.72;
|
||||
}
|
||||
}
|
||||
|
||||
function synthesizeNanzhongNight(buffer, track) {
|
||||
const random = createRandom(track.seed);
|
||||
let forestLeft = 0;
|
||||
let forestRight = 0;
|
||||
const alpha = onePoleAlpha(1250);
|
||||
for (let index = 0; index < buffer.left.length; index += 1) {
|
||||
const t = index / sampleRate;
|
||||
forestLeft += alpha * ((random() * 2 - 1) - forestLeft);
|
||||
forestRight += alpha * ((random() * 2 - 1) - forestRight);
|
||||
const pulse = 0.68 + Math.sin(2 * Math.PI * 0.09 * t) * 0.16;
|
||||
buffer.left[index] = forestLeft * 0.038 * pulse;
|
||||
buffer.right[index] = forestRight * 0.036 * (1.24 - pulse * 0.36);
|
||||
}
|
||||
|
||||
for (let cluster = 0; cluster < 24; cluster += 1) {
|
||||
const clusterStart = random() * (track.duration + loopTailSeconds - 0.8);
|
||||
const chirps = 2 + Math.floor(random() * 4);
|
||||
for (let chirp = 0; chirp < chirps; chirp += 1) {
|
||||
addTone(buffer, {
|
||||
start: clusterStart + chirp * (0.055 + random() * 0.045),
|
||||
duration: 0.045 + random() * 0.04,
|
||||
frequency: 2800 + random() * 2400,
|
||||
gain: 0.006 + random() * 0.005,
|
||||
pan: random() * 1.6 - 0.8,
|
||||
attack: 0.003,
|
||||
release: 0.025,
|
||||
wave: chirpWave,
|
||||
exponentialDecay: 18
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
for (let frog = 0; frog < 9; frog += 1) {
|
||||
addTone(buffer, {
|
||||
start: random() * (track.duration + loopTailSeconds - 0.6),
|
||||
duration: 0.28 + random() * 0.24,
|
||||
frequency: 140 + random() * 70,
|
||||
gain: 0.008,
|
||||
pan: random() * 1.2 - 0.6,
|
||||
attack: 0.02,
|
||||
release: 0.18,
|
||||
wave: frogWave,
|
||||
exponentialDecay: 4.8
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function addNoiseBurst(buffer, random, start, duration, gain, pan, decay) {
|
||||
const startIndex = Math.floor(start * sampleRate);
|
||||
const count = Math.floor(duration * sampleRate);
|
||||
const leftGain = Math.cos((pan + 1) * Math.PI * 0.25);
|
||||
const rightGain = Math.sin((pan + 1) * Math.PI * 0.25);
|
||||
for (let offset = 0; offset < count; offset += 1) {
|
||||
const index = startIndex + offset;
|
||||
if (index >= buffer.left.length) {
|
||||
break;
|
||||
}
|
||||
const t = offset / sampleRate;
|
||||
const sample = (random() * 2 - 1) * gain * Math.exp(-t * decay);
|
||||
buffer.left[index] += sample * leftGain;
|
||||
buffer.right[index] += sample * rightGain;
|
||||
}
|
||||
}
|
||||
|
||||
function createBuffer(duration) {
|
||||
const length = Math.ceil(duration * sampleRate);
|
||||
return { left: new Float32Array(length), right: new Float32Array(length) };
|
||||
}
|
||||
|
||||
function wrapTailIntoStart(buffer, loopDuration, tailSeconds) {
|
||||
const loopLength = Math.floor(loopDuration * sampleRate);
|
||||
const tailLength = Math.min(Math.floor(tailSeconds * sampleRate), buffer.left.length - loopLength, loopLength);
|
||||
const output = { left: buffer.left.slice(0, loopLength), right: buffer.right.slice(0, loopLength) };
|
||||
for (let index = 0; index < tailLength; index += 1) {
|
||||
const progress = index / Math.max(1, tailLength - 1);
|
||||
const tailGain = Math.cos(progress * Math.PI * 0.5);
|
||||
const startGain = Math.sin(progress * Math.PI * 0.5);
|
||||
output.left[index] = buffer.left[loopLength + index] * tailGain + output.left[index] * startGain;
|
||||
output.right[index] = buffer.right[loopLength + index] * tailGain + output.right[index] * startGain;
|
||||
}
|
||||
return output;
|
||||
}
|
||||
|
||||
function addTone(buffer, options) {
|
||||
const startIndex = Math.max(0, Math.floor(options.start * sampleRate));
|
||||
const count = Math.max(1, Math.floor(options.duration * sampleRate));
|
||||
const leftGain = Math.cos((options.pan + 1) * Math.PI * 0.25);
|
||||
const rightGain = Math.sin((options.pan + 1) * Math.PI * 0.25);
|
||||
for (let offset = 0; offset < count; offset += 1) {
|
||||
const index = startIndex + offset;
|
||||
if (index >= buffer.left.length) {
|
||||
break;
|
||||
}
|
||||
const t = offset / sampleRate;
|
||||
const envelopeValue = options.exponentialDecay
|
||||
? Math.min(1, t / Math.max(0.001, options.attack)) * Math.exp(-t * options.exponentialDecay)
|
||||
: envelope(t, options.duration, options.attack, options.release);
|
||||
const phase = 2 * Math.PI * options.frequency * t;
|
||||
const sample = options.wave(phase, t) * options.gain * envelopeValue;
|
||||
buffer.left[index] += sample * leftGain;
|
||||
buffer.right[index] += sample * rightGain;
|
||||
}
|
||||
}
|
||||
|
||||
function envelope(t, duration, attack, release) {
|
||||
const attackLevel = Math.sin(Math.min(1, t / Math.max(0.001, attack)) * Math.PI * 0.5);
|
||||
const releaseLevel = Math.min(1, Math.max(0, duration - t) / Math.max(0.001, release));
|
||||
return Math.min(attackLevel, releaseLevel);
|
||||
}
|
||||
|
||||
function stringWave(phase, t) {
|
||||
const bow = 1 + Math.sin(2 * Math.PI * 0.72 * t) * 0.018;
|
||||
return Math.sin(phase * bow) * 0.7 + Math.sin(phase * 2) * 0.13 + Math.sin(phase * 3) * 0.045;
|
||||
}
|
||||
|
||||
function celloWave(phase) {
|
||||
return Math.sin(phase) * 0.78 + Math.sin(phase * 2) * 0.09 + Math.sin(phase * 0.5) * 0.04;
|
||||
}
|
||||
|
||||
function reedWave(phase, t) {
|
||||
const vibrato = Math.sin(2 * Math.PI * 4.5 * t) * 0.006;
|
||||
return Math.sin(phase + vibrato) * 0.86 + Math.sin(phase * 2) * 0.045;
|
||||
}
|
||||
|
||||
function pluckWave(phase) {
|
||||
return Math.sin(phase) * 0.82 + Math.sin(phase * 2) * 0.12 + Math.sin(phase * 3) * 0.035;
|
||||
}
|
||||
|
||||
function drumWave(phase, t) {
|
||||
const pitchDrop = 1 - Math.min(1, t * 5.8) * 0.48;
|
||||
return Math.sin(phase * pitchDrop) * 0.92 + Math.sin(phase * 2.02) * 0.06;
|
||||
}
|
||||
|
||||
function gongWave(phase) {
|
||||
return Math.sin(phase) * 0.76 + Math.sin(phase * 1.48) * 0.17 + Math.sin(phase * 2.07) * 0.09;
|
||||
}
|
||||
|
||||
function sineWave(phase) {
|
||||
return Math.sin(phase);
|
||||
}
|
||||
|
||||
function dropletWave(phase, t) {
|
||||
return Math.sin(phase * (1 + t * 0.22)) * 0.88 + Math.sin(phase * 2.2) * 0.08;
|
||||
}
|
||||
|
||||
function chirpWave(phase, t) {
|
||||
return Math.sin(phase * (1 + t * 4.8));
|
||||
}
|
||||
|
||||
function frogWave(phase, t) {
|
||||
return Math.sin(phase) * 0.72 + Math.sin(phase * 0.5) * 0.18 + Math.sin(phase * (1.5 + t * 0.4)) * 0.08;
|
||||
}
|
||||
|
||||
function midiToFrequency(midi) {
|
||||
return 440 * 2 ** ((midi - 69) / 12);
|
||||
}
|
||||
|
||||
function onePoleAlpha(cutoff) {
|
||||
return 1 - Math.exp((-2 * Math.PI * cutoff) / sampleRate);
|
||||
}
|
||||
|
||||
function highPassAlpha(cutoff) {
|
||||
const rc = 1 / (2 * Math.PI * cutoff);
|
||||
const dt = 1 / sampleRate;
|
||||
return rc / (rc + dt);
|
||||
}
|
||||
|
||||
function createRandom(seed) {
|
||||
let state = seed >>> 0;
|
||||
return () => {
|
||||
state ^= state << 13;
|
||||
state ^= state >>> 17;
|
||||
state ^= state << 5;
|
||||
return (state >>> 0) / 0x100000000;
|
||||
};
|
||||
}
|
||||
|
||||
function prepareMaster(buffer, highPassCutoff, targetPeak, room) {
|
||||
highPass(buffer.left, highPassCutoff);
|
||||
highPass(buffer.right, highPassCutoff);
|
||||
if (room) {
|
||||
applyRoom(buffer);
|
||||
}
|
||||
normalize(buffer, targetPeak);
|
||||
softLimit(buffer);
|
||||
}
|
||||
|
||||
function highPass(samples, cutoff) {
|
||||
const alpha = highPassAlpha(cutoff);
|
||||
let previousOutput = 0;
|
||||
let previousInput = samples[0] ?? 0;
|
||||
for (let index = 0; index < samples.length; index += 1) {
|
||||
const input = samples[index];
|
||||
const output = alpha * (previousOutput + input - previousInput);
|
||||
samples[index] = output;
|
||||
previousOutput = output;
|
||||
previousInput = input;
|
||||
}
|
||||
}
|
||||
|
||||
function applyRoom(buffer) {
|
||||
const originalLeft = Float32Array.from(buffer.left);
|
||||
const originalRight = Float32Array.from(buffer.right);
|
||||
[
|
||||
{ delay: 0.075, gain: 0.07 },
|
||||
{ delay: 0.132, gain: 0.045 }
|
||||
].forEach(({ delay, gain }) => {
|
||||
const samples = Math.floor(sampleRate * delay);
|
||||
for (let index = samples; index < buffer.left.length; index += 1) {
|
||||
buffer.left[index] += originalRight[index - samples] * gain;
|
||||
buffer.right[index] += originalLeft[index - samples] * gain;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function normalize(buffer, targetPeak) {
|
||||
let peak = 0;
|
||||
for (let index = 0; index < buffer.left.length; index += 1) {
|
||||
peak = Math.max(peak, Math.abs(buffer.left[index]), Math.abs(buffer.right[index]));
|
||||
}
|
||||
if (peak <= 0) {
|
||||
return;
|
||||
}
|
||||
const scale = targetPeak / peak;
|
||||
for (let index = 0; index < buffer.left.length; index += 1) {
|
||||
buffer.left[index] *= scale;
|
||||
buffer.right[index] *= scale;
|
||||
}
|
||||
}
|
||||
|
||||
function softLimit(buffer) {
|
||||
const divisor = Math.tanh(1.04);
|
||||
for (let index = 0; index < buffer.left.length; index += 1) {
|
||||
buffer.left[index] = Math.tanh(buffer.left[index] * 1.04) / divisor;
|
||||
buffer.right[index] = Math.tanh(buffer.right[index] * 1.04) / divisor;
|
||||
}
|
||||
}
|
||||
|
||||
function renderMp3(buffer, track, outputDir, bitrateKbps, loudness, truePeak, kind) {
|
||||
const wavPath = join(temporaryDir, `${track.name}.wav`);
|
||||
const outputPath = join(outputDir, `${track.name}.mp3`);
|
||||
writeFileSync(wavPath, encodeWav(buffer));
|
||||
const result = spawnSync(ffmpeg, [
|
||||
'-y', '-hide_banner', '-loglevel', 'error',
|
||||
'-i', wavPath,
|
||||
'-af', `loudnorm=I=${loudness}:LRA=6:TP=${truePeak}`,
|
||||
'-c:a', 'libmp3lame',
|
||||
'-b:a', `${bitrateKbps}k`,
|
||||
'-ar', String(sampleRate),
|
||||
'-ac', String(channels),
|
||||
'-write_xing', '1',
|
||||
'-metadata', `title=${track.title}`,
|
||||
'-metadata', 'artist=Heros Web Procedural Ensemble',
|
||||
'-metadata', 'comment=Project-original deterministic synthesis; no sampled source audio.',
|
||||
outputPath
|
||||
], { encoding: 'utf8' });
|
||||
|
||||
if (result.status !== 0) {
|
||||
throw new Error(`ffmpeg failed for ${track.name}: ${result.stderr || result.stdout}`);
|
||||
}
|
||||
const seam = inspectEncodedLoopSeam(outputPath, track.name, kind);
|
||||
console.log(
|
||||
`Generated ${outputPath} (seam ${seam.boundaryJump.toFixed(4)} / ${seam.absoluteLimit.toFixed(4)}, ` +
|
||||
`peak ${(seam.peakRatio * 100).toFixed(1)}%)`
|
||||
);
|
||||
}
|
||||
|
||||
function inspectEncodedLoopSeam(outputPath, trackName, kind) {
|
||||
const result = spawnSync(ffmpeg, [
|
||||
'-hide_banner', '-loglevel', 'error',
|
||||
'-i', outputPath,
|
||||
'-f', 'f32le',
|
||||
'-c:a', 'pcm_f32le',
|
||||
'-ar', String(sampleRate),
|
||||
'-ac', String(channels),
|
||||
'pipe:1'
|
||||
], { maxBuffer: 96 * 1024 * 1024 });
|
||||
|
||||
if (result.status !== 0 || !Buffer.isBuffer(result.stdout)) {
|
||||
throw new Error(`ffmpeg could not decode ${trackName} for loop seam verification: ${result.stderr?.toString() || ''}`);
|
||||
}
|
||||
|
||||
const bytesPerFrame = channels * 4;
|
||||
const frameCount = Math.floor(result.stdout.length / bytesPerFrame);
|
||||
if (frameCount < 2) {
|
||||
throw new Error(`${trackName} did not decode enough samples for loop seam verification.`);
|
||||
}
|
||||
|
||||
const sample = (frame, channel) => result.stdout.readFloatLE(frame * bytesPerFrame + channel * 4);
|
||||
let signalPeak = 0;
|
||||
let deltaSquaredSum = 0;
|
||||
let deltaCount = 0;
|
||||
for (let channel = 0; channel < channels; channel += 1) {
|
||||
signalPeak = Math.max(signalPeak, Math.abs(sample(0, channel)));
|
||||
}
|
||||
for (let frame = 1; frame < frameCount; frame += 1) {
|
||||
for (let channel = 0; channel < channels; channel += 1) {
|
||||
const current = sample(frame, channel);
|
||||
const delta = current - sample(frame - 1, channel);
|
||||
signalPeak = Math.max(signalPeak, Math.abs(current));
|
||||
deltaSquaredSum += delta * delta;
|
||||
deltaCount += 1;
|
||||
}
|
||||
}
|
||||
|
||||
let boundaryJump = 0;
|
||||
for (let channel = 0; channel < channels; channel += 1) {
|
||||
boundaryJump = Math.max(boundaryJump, Math.abs(sample(0, channel) - sample(frameCount - 1, channel)));
|
||||
}
|
||||
const deltaRms = Math.sqrt(deltaSquaredSum / Math.max(1, deltaCount));
|
||||
const absoluteLimit = kind === 'music'
|
||||
? Math.min(0.04, Math.max(0.02, deltaRms * 4))
|
||||
: Math.min(0.12, Math.max(0.04, deltaRms * 4));
|
||||
const peakRatioLimit = kind === 'music' ? 0.1 : 0.75;
|
||||
const peakRatio = boundaryJump / Math.max(signalPeak, Number.EPSILON);
|
||||
if (
|
||||
!Number.isFinite(boundaryJump) ||
|
||||
boundaryJump > absoluteLimit ||
|
||||
peakRatio > peakRatioLimit
|
||||
) {
|
||||
throw new Error(
|
||||
`${trackName} loop seam is too abrupt after MP3 encoding: jump ${boundaryJump.toFixed(6)}, ` +
|
||||
`limit ${absoluteLimit.toFixed(6)}, peak ratio ${(peakRatio * 100).toFixed(2)}%.`
|
||||
);
|
||||
}
|
||||
|
||||
return { boundaryJump, absoluteLimit, peakRatio };
|
||||
}
|
||||
|
||||
function encodeWav(buffer) {
|
||||
const bytesPerSample = 2;
|
||||
const frameCount = buffer.left.length;
|
||||
const dataSize = frameCount * channels * bytesPerSample;
|
||||
const wav = Buffer.alloc(44 + dataSize);
|
||||
wav.write('RIFF', 0);
|
||||
wav.writeUInt32LE(36 + dataSize, 4);
|
||||
wav.write('WAVE', 8);
|
||||
wav.write('fmt ', 12);
|
||||
wav.writeUInt32LE(16, 16);
|
||||
wav.writeUInt16LE(1, 20);
|
||||
wav.writeUInt16LE(channels, 22);
|
||||
wav.writeUInt32LE(sampleRate, 24);
|
||||
wav.writeUInt32LE(sampleRate * channels * bytesPerSample, 28);
|
||||
wav.writeUInt16LE(channels * bytesPerSample, 32);
|
||||
wav.writeUInt16LE(16, 34);
|
||||
wav.write('data', 36);
|
||||
wav.writeUInt32LE(dataSize, 40);
|
||||
for (let index = 0; index < frameCount; index += 1) {
|
||||
const offset = 44 + index * channels * bytesPerSample;
|
||||
wav.writeInt16LE(Math.round(Math.max(-1, Math.min(1, buffer.left[index])) * 32767), offset);
|
||||
wav.writeInt16LE(Math.round(Math.max(-1, Math.min(1, buffer.right[index])) * 32767), offset + bytesPerSample);
|
||||
}
|
||||
return wav;
|
||||
}
|
||||
|
||||
function assertFfmpeg() {
|
||||
const result = spawnSync(ffmpeg, ['-version'], { encoding: 'utf8' });
|
||||
if (result.status !== 0) {
|
||||
throw new Error(`ffmpeg is required to generate camp audio. Set FFMPEG_PATH when it is not on PATH. ${result.stderr || ''}`);
|
||||
}
|
||||
}
|
||||
@@ -19,16 +19,17 @@ const server = await createServer({
|
||||
});
|
||||
|
||||
try {
|
||||
const { musicTracks, effectTracks } = await server.ssrLoadModule('/src/game/audio/audioAssets.ts');
|
||||
const { musicTracks, ambienceTracks, effectTracks } = await server.ssrLoadModule('/src/game/audio/audioAssets.ts');
|
||||
const scenarioModule = await server.ssrLoadModule('/src/game/data/scenario.ts');
|
||||
const { battleScenarios } = await server.ssrLoadModule('/src/game/data/battles.ts');
|
||||
const errors = [];
|
||||
|
||||
validateTrackMap(errors, 'music', musicTracks, 'bgm');
|
||||
validateTrackMap(errors, 'ambience', ambienceTracks, 'ambience');
|
||||
validateTrackMap(errors, 'effect', effectTracks, 'sfx');
|
||||
validateAudioDirectoryCoverage(errors, musicTracks, effectTracks);
|
||||
validateAudioDirectoryCoverage(errors, musicTracks, ambienceTracks, effectTracks);
|
||||
validateStoryBgmReferences(errors, scenarioModule, battleScenarios, musicTracks);
|
||||
validateLiteralAudioCalls(errors, musicTracks, effectTracks);
|
||||
validateLiteralAudioCalls(errors, musicTracks, ambienceTracks, effectTracks);
|
||||
|
||||
if (errors.length) {
|
||||
console.error(`Audio asset data verification failed with ${errors.length} issue(s):`);
|
||||
@@ -39,7 +40,8 @@ try {
|
||||
process.exitCode = 1;
|
||||
} else {
|
||||
console.log(
|
||||
`Verified ${Object.keys(musicTracks).length} music tracks, ${Object.keys(effectTracks).length} effect tracks, ` +
|
||||
`Verified ${Object.keys(musicTracks).length} music tracks, ${Object.keys(ambienceTracks).length} ambience tracks, ` +
|
||||
`${Object.keys(effectTracks).length} effect tracks, ` +
|
||||
`${collectStoryBgmReferences(scenarioModule, battleScenarios).length} story BGM references, and literal audio calls.`
|
||||
);
|
||||
}
|
||||
@@ -64,8 +66,9 @@ function validateTrackMap(errors, label, tracks, expectedFolder) {
|
||||
});
|
||||
}
|
||||
|
||||
function validateAudioDirectoryCoverage(errors, musicTracks, effectTracks) {
|
||||
function validateAudioDirectoryCoverage(errors, musicTracks, ambienceTracks, effectTracks) {
|
||||
const registeredMusicPaths = new Set(Object.values(musicTracks).map(assetUrlToPath).filter(Boolean));
|
||||
const registeredAmbiencePaths = new Set(Object.values(ambienceTracks).map(assetUrlToPath).filter(Boolean));
|
||||
const registeredEffectPaths = new Set(Object.values(effectTracks).map(assetUrlToPath).filter(Boolean));
|
||||
|
||||
listAudioFiles(join(audioRoot, 'bgm')).forEach((path) => {
|
||||
@@ -73,6 +76,11 @@ function validateAudioDirectoryCoverage(errors, musicTracks, effectTracks) {
|
||||
errors.push(`BGM file "${path}" exists but is not registered in musicTracks`);
|
||||
}
|
||||
});
|
||||
listAudioFiles(join(audioRoot, 'ambience')).forEach((path) => {
|
||||
if (!registeredAmbiencePaths.has(path)) {
|
||||
errors.push(`Ambience file "${path}" exists but is not registered in ambienceTracks`);
|
||||
}
|
||||
});
|
||||
listAudioFiles(join(audioRoot, 'sfx')).forEach((path) => {
|
||||
if (!registeredEffectPaths.has(path)) {
|
||||
errors.push(`SFX file "${path}" exists but is not registered in effectTracks`);
|
||||
@@ -89,8 +97,9 @@ function validateStoryBgmReferences(errors, scenarioModule, battleScenarios, mus
|
||||
});
|
||||
}
|
||||
|
||||
function validateLiteralAudioCalls(errors, musicTracks, effectTracks) {
|
||||
function validateLiteralAudioCalls(errors, musicTracks, ambienceTracks, effectTracks) {
|
||||
const musicKeys = new Set(Object.keys(musicTracks));
|
||||
const ambienceKeys = new Set(Object.keys(ambienceTracks));
|
||||
const effectKeys = new Set(Object.keys(effectTracks));
|
||||
|
||||
sourceFilesToScan.forEach((path) => {
|
||||
@@ -100,6 +109,11 @@ function validateLiteralAudioCalls(errors, musicTracks, effectTracks) {
|
||||
errors.push(`${path}:${lineForIndex(source, index)}: playMusic references unknown track "${key}"`);
|
||||
}
|
||||
});
|
||||
collectLiteralCallKeys(source, 'playAmbience').forEach(({ key, index }) => {
|
||||
if (!ambienceKeys.has(key)) {
|
||||
errors.push(`${path}:${lineForIndex(source, index)}: playAmbience references unknown track "${key}"`);
|
||||
}
|
||||
});
|
||||
collectLiteralCallKeys(source, 'playEffect').forEach(({ key, index }) => {
|
||||
if (!effectKeys.has(key)) {
|
||||
errors.push(`${path}:${lineForIndex(source, index)}: playEffect references unknown track "${key}"`);
|
||||
|
||||
297
scripts/verify-camp-soundscape-data.mjs
Normal file
297
scripts/verify-camp-soundscape-data.mjs
Normal file
@@ -0,0 +1,297 @@
|
||||
import { createServer } from 'vite';
|
||||
import { createHash } from 'node:crypto';
|
||||
import { readFileSync, statSync } from 'node:fs';
|
||||
import { sep } from 'node:path';
|
||||
|
||||
const expectedBattleArcs = [
|
||||
{ skinId: 'yellow-turban', firstBattleOrdinal: 1, lastBattleOrdinal: 4 },
|
||||
{ skinId: 'anti-dong', firstBattleOrdinal: 5, lastBattleOrdinal: 6 },
|
||||
{ skinId: 'xuzhou', firstBattleOrdinal: 7, lastBattleOrdinal: 9 },
|
||||
{ skinId: 'wandering', firstBattleOrdinal: 10, lastBattleOrdinal: 15 },
|
||||
{ skinId: 'wolong', firstBattleOrdinal: 16, lastBattleOrdinal: 18 },
|
||||
{ skinId: 'red-cliffs', firstBattleOrdinal: 19, lastBattleOrdinal: 22 },
|
||||
{ skinId: 'jing-yi', firstBattleOrdinal: 23, lastBattleOrdinal: 34 },
|
||||
{ skinId: 'hanzhong-shuhan', firstBattleOrdinal: 35, lastBattleOrdinal: 37 },
|
||||
{ skinId: 'jingzhou-crisis', firstBattleOrdinal: 38, lastBattleOrdinal: 44 },
|
||||
{ skinId: 'yiling-baidi', firstBattleOrdinal: 45, lastBattleOrdinal: 46 },
|
||||
{ skinId: 'nanzhong', firstBattleOrdinal: 47, lastBattleOrdinal: 54 },
|
||||
{ skinId: 'northern', firstBattleOrdinal: 55, lastBattleOrdinal: 66 }
|
||||
];
|
||||
|
||||
const expectedMusicGroups = {
|
||||
rally: 'camp-rally',
|
||||
wayfarer: 'camp-wayfarer',
|
||||
'grand-strategy': 'camp-grand-strategy',
|
||||
frontier: 'camp-frontier'
|
||||
};
|
||||
|
||||
const expectedAmbiences = {
|
||||
campfire: 'campfire-ambience',
|
||||
'river-night': 'river-night-ambience',
|
||||
'mountain-wind': 'mountain-wind-ambience',
|
||||
'nanzhong-night': 'nanzhong-night-ambience'
|
||||
};
|
||||
|
||||
const expectedMapping = {
|
||||
'yellow-turban': { musicGroupId: 'rally', ambienceId: 'campfire' },
|
||||
'anti-dong': { musicGroupId: 'rally', ambienceId: 'campfire' },
|
||||
xuzhou: { musicGroupId: 'wayfarer', ambienceId: 'campfire' },
|
||||
wandering: { musicGroupId: 'wayfarer', ambienceId: 'campfire' },
|
||||
wolong: { musicGroupId: 'wayfarer', ambienceId: 'mountain-wind' },
|
||||
'red-cliffs': { musicGroupId: 'grand-strategy', ambienceId: 'river-night' },
|
||||
'jing-yi': { musicGroupId: 'grand-strategy', ambienceId: 'river-night' },
|
||||
'hanzhong-shuhan': { musicGroupId: 'grand-strategy', ambienceId: 'mountain-wind' },
|
||||
'jingzhou-crisis': { musicGroupId: 'grand-strategy', ambienceId: 'river-night' },
|
||||
'yiling-baidi': { musicGroupId: 'grand-strategy', ambienceId: 'river-night' },
|
||||
nanzhong: { musicGroupId: 'frontier', ambienceId: 'nanzhong-night' },
|
||||
northern: { musicGroupId: 'frontier', ambienceId: 'mountain-wind' }
|
||||
};
|
||||
|
||||
const expectedSpecialSteps = {
|
||||
'hanzhong-king-camp': 'hanzhong-shuhan',
|
||||
'shu-han-foundation-camp': 'hanzhong-shuhan',
|
||||
'baidi-entrustment-camp': 'yiling-baidi',
|
||||
'northern-campaign-prep-camp': 'northern',
|
||||
'ending-complete': 'northern'
|
||||
};
|
||||
|
||||
const errors = [];
|
||||
const server = await createServer({
|
||||
logLevel: 'error',
|
||||
server: { middlewareMode: true },
|
||||
appType: 'custom'
|
||||
});
|
||||
|
||||
try {
|
||||
const { campMusicGroups, campAmbienceDefinitions, campSoundscapeMapping, getCampSoundscape } =
|
||||
await server.ssrLoadModule('/src/game/data/campSoundscapes.ts');
|
||||
const { selectCampSkin } = await server.ssrLoadModule('/src/game/data/campSkins.ts');
|
||||
const { battleScenarios } = await server.ssrLoadModule('/src/game/data/battles.ts');
|
||||
const { musicTracks, ambienceTracks } = await server.ssrLoadModule('/src/game/audio/audioAssets.ts');
|
||||
|
||||
const expectedSkinIds = Object.keys(expectedMapping);
|
||||
const musicEntries = Object.entries(campMusicGroups);
|
||||
const ambienceEntries = Object.entries(campAmbienceDefinitions);
|
||||
const mappingEntries = Object.entries(campSoundscapeMapping);
|
||||
const battleIds = Object.keys(battleScenarios);
|
||||
|
||||
assertEqual(expectedSkinIds.length, 12, 'expected camp skin count');
|
||||
assertEqual(mappingEntries.length, 12, 'camp soundscape mapping count');
|
||||
assertEqual(musicEntries.length, 4, 'camp music group count');
|
||||
assertEqual(ambienceEntries.length, 4, 'camp ambience definition count');
|
||||
assertEqual(battleIds.length, 66, 'battle count used by camp soundscapes');
|
||||
|
||||
validateDefinitions(musicEntries, expectedMusicGroups, musicTracks, 'music group', 0.05, 0.4);
|
||||
validateDefinitions(ambienceEntries, expectedAmbiences, ambienceTracks, 'ambience', 0.03, 0.3);
|
||||
validateCampAudioAssets(
|
||||
musicEntries.map(([, definition]) => ({ key: definition.trackKey, url: musicTracks[definition.trackKey], kind: 'music' })),
|
||||
ambienceEntries.map(([, definition]) => ({ key: definition.trackKey, url: ambienceTracks[definition.trackKey], kind: 'ambience' }))
|
||||
);
|
||||
|
||||
expectedSkinIds.forEach((skinId) => {
|
||||
const actualMapping = campSoundscapeMapping[skinId];
|
||||
const expected = expectedMapping[skinId];
|
||||
assert(Boolean(actualMapping), `${skinId}: missing soundscape mapping`);
|
||||
if (!actualMapping) {
|
||||
return;
|
||||
}
|
||||
|
||||
assertEqual(actualMapping.musicGroupId, expected.musicGroupId, `${skinId}: music group`);
|
||||
assertEqual(actualMapping.ambienceId, expected.ambienceId, `${skinId}: ambience`);
|
||||
assert(Boolean(campMusicGroups[actualMapping.musicGroupId]), `${skinId}: unknown music group`);
|
||||
assert(Boolean(campAmbienceDefinitions[actualMapping.ambienceId]), `${skinId}: unknown ambience`);
|
||||
|
||||
const soundscape = getCampSoundscape(skinId);
|
||||
assertEqual(soundscape.skinId, skinId, `${skinId}: resolved skin id`);
|
||||
assertEqual(soundscape.music, campMusicGroups[expected.musicGroupId], `${skinId}: resolved music definition`);
|
||||
assertEqual(soundscape.ambience, campAmbienceDefinitions[expected.ambienceId], `${skinId}: resolved ambience definition`);
|
||||
});
|
||||
|
||||
mappingEntries.forEach(([skinId]) => {
|
||||
assert(expectedSkinIds.includes(skinId), `unexpected camp soundscape mapping "${skinId}"`);
|
||||
});
|
||||
|
||||
battleIds.forEach((battleId, index) => {
|
||||
const battleOrdinal = index + 1;
|
||||
const expectedSkinId = expectedSkinForOrdinal(battleOrdinal);
|
||||
const selection = selectCampSkin({ nextBattleId: battleId });
|
||||
assertEqual(selection.skin?.id, expectedSkinId, `${battleId}: selected camp skin`);
|
||||
assertSelectionSoundscape(selection, `${battleId}: battle ${battleOrdinal}`, getCampSoundscape);
|
||||
});
|
||||
|
||||
Object.entries(expectedSpecialSteps).forEach(([campaignStep, expectedSkinId]) => {
|
||||
const selection = selectCampSkin({
|
||||
campaignStep,
|
||||
nextBattleId: battleIds[0],
|
||||
currentBattleId: battleIds.at(-1)
|
||||
});
|
||||
assertEqual(selection.source, 'special-step', `${campaignStep}: selection source`);
|
||||
assertEqual(selection.skin?.id, expectedSkinId, `${campaignStep}: selected camp skin`);
|
||||
assertSelectionSoundscape(selection, `${campaignStep}: special override`, getCampSoundscape);
|
||||
});
|
||||
|
||||
if (errors.length > 0) {
|
||||
throw new Error(`Camp soundscape data verification failed:\n${errors.map((error) => `- ${error}`).join('\n')}`);
|
||||
}
|
||||
|
||||
console.log(
|
||||
`Verified ${mappingEntries.length} camp soundscape mappings, ${musicEntries.length} music groups, ` +
|
||||
`${ambienceEntries.length} ambiences, ${battleIds.length} battle selections, and ` +
|
||||
`${Object.keys(expectedSpecialSteps).length} special-step overrides.`
|
||||
);
|
||||
} finally {
|
||||
await server.close();
|
||||
}
|
||||
|
||||
function validateDefinitions(entries, expectedDefinitions, registeredTracks, context, minimumVolume, maximumVolume) {
|
||||
const expectedIds = Object.keys(expectedDefinitions);
|
||||
const registeredKeys = new Set(Object.keys(registeredTracks));
|
||||
|
||||
entries.forEach(([id, definition]) => {
|
||||
assert(expectedIds.includes(id), `unexpected camp ${context} "${id}"`);
|
||||
assertEqual(definition.id, id, `${context} ${id}: id`);
|
||||
assertEqual(definition.trackKey, expectedDefinitions[id], `${context} ${id}: track key`);
|
||||
assertNonEmptyString(definition.label, `${context} ${id}: label`);
|
||||
assert(
|
||||
Number.isFinite(definition.volume) &&
|
||||
definition.volume >= minimumVolume &&
|
||||
definition.volume <= maximumVolume,
|
||||
`${context} ${id}: volume must be between ${minimumVolume} and ${maximumVolume}`
|
||||
);
|
||||
assert(registeredKeys.has(definition.trackKey), `${context} ${id}: unregistered track "${definition.trackKey}"`);
|
||||
assertNonEmptyString(registeredTracks[definition.trackKey], `${context} ${id}: registered track URL`);
|
||||
});
|
||||
|
||||
expectedIds.forEach((id) => {
|
||||
assert(Boolean(entries.find(([actualId]) => actualId === id)), `missing camp ${context} "${id}"`);
|
||||
});
|
||||
assertEqual(new Set(entries.map(([, definition]) => definition.trackKey)).size, entries.length, `${context} track keys`);
|
||||
}
|
||||
|
||||
function validateCampAudioAssets(musicAssets, ambienceAssets) {
|
||||
const assets = [...musicAssets, ...ambienceAssets];
|
||||
const hashes = new Set();
|
||||
let totalBytes = 0;
|
||||
|
||||
assets.forEach((asset) => {
|
||||
const path = assetUrlToPath(asset.url);
|
||||
assert(Boolean(path), `${asset.kind} ${asset.key}: URL does not point at src/assets/audio`);
|
||||
if (!path) {
|
||||
return;
|
||||
}
|
||||
|
||||
let bytes;
|
||||
let size;
|
||||
try {
|
||||
bytes = readFileSync(path);
|
||||
size = statSync(path).size;
|
||||
} catch (error) {
|
||||
errors.push(`${asset.kind} ${asset.key}: cannot read ${path}: ${error.message}`);
|
||||
return;
|
||||
}
|
||||
|
||||
totalBytes += size;
|
||||
const hash = createHash('sha256').update(bytes).digest('hex');
|
||||
assert(!hashes.has(hash), `${asset.kind} ${asset.key}: audio content duplicates another camp asset`);
|
||||
hashes.add(hash);
|
||||
|
||||
const metadata = readMp3Metadata(bytes);
|
||||
assert(Boolean(metadata), `${asset.kind} ${asset.key}: missing readable MPEG-1 Layer III frame`);
|
||||
if (!metadata) {
|
||||
return;
|
||||
}
|
||||
|
||||
assertEqual(metadata.sampleRate, 44100, `${asset.kind} ${asset.key}: sample rate`);
|
||||
assertEqual(metadata.channels, 2, `${asset.kind} ${asset.key}: channel count`);
|
||||
if (asset.kind === 'music') {
|
||||
assertEqual(metadata.bitrateKbps, 192, `music ${asset.key}: bitrate`);
|
||||
assert(size >= 256 * 1024 && size <= 2 * 1024 * 1024, `music ${asset.key}: size must be 256 KiB to 2 MiB`);
|
||||
assert(metadata.durationSeconds >= 45 && metadata.durationSeconds <= 70, `music ${asset.key}: duration must be 45 to 70 seconds`);
|
||||
} else {
|
||||
assertEqual(metadata.bitrateKbps, 128, `ambience ${asset.key}: bitrate`);
|
||||
assert(size >= 64 * 1024 && size <= 1024 * 1024, `ambience ${asset.key}: size must be 64 KiB to 1 MiB`);
|
||||
assert(metadata.durationSeconds >= 20 && metadata.durationSeconds <= 45, `ambience ${asset.key}: duration must be 20 to 45 seconds`);
|
||||
}
|
||||
});
|
||||
|
||||
assert(totalBytes <= 8 * 1024 * 1024, `camp audio assets must stay at or below 8 MiB; received ${totalBytes} bytes`);
|
||||
}
|
||||
|
||||
function assetUrlToPath(url) {
|
||||
const marker = 'src/assets/audio/';
|
||||
const index = typeof url === 'string' ? url.indexOf(marker) : -1;
|
||||
return index >= 0 ? url.slice(index).replaceAll('/', sep) : undefined;
|
||||
}
|
||||
|
||||
function readMp3Metadata(bytes) {
|
||||
let searchOffset = 0;
|
||||
if (bytes.subarray(0, 3).toString('ascii') === 'ID3' && bytes.length >= 10) {
|
||||
const tagSize = ((bytes[6] & 0x7f) << 21) | ((bytes[7] & 0x7f) << 14) | ((bytes[8] & 0x7f) << 7) | (bytes[9] & 0x7f);
|
||||
searchOffset = 10 + tagSize;
|
||||
}
|
||||
|
||||
const bitrateTable = [0, 32, 40, 48, 56, 64, 80, 96, 112, 128, 160, 192, 224, 256, 320, 0];
|
||||
const sampleRateTable = [44100, 48000, 32000, 0];
|
||||
const searchLimit = Math.min(bytes.length - 4, searchOffset + 65536);
|
||||
for (let offset = searchOffset; offset <= searchLimit; offset += 1) {
|
||||
if (bytes[offset] !== 0xff || (bytes[offset + 1] & 0xe0) !== 0xe0) {
|
||||
continue;
|
||||
}
|
||||
const versionBits = (bytes[offset + 1] >> 3) & 0x03;
|
||||
const layerBits = (bytes[offset + 1] >> 1) & 0x03;
|
||||
if (versionBits !== 0x03 || layerBits !== 0x01) {
|
||||
continue;
|
||||
}
|
||||
const bitrateKbps = bitrateTable[(bytes[offset + 2] >> 4) & 0x0f];
|
||||
const sampleRate = sampleRateTable[(bytes[offset + 2] >> 2) & 0x03];
|
||||
if (!bitrateKbps || !sampleRate) {
|
||||
continue;
|
||||
}
|
||||
const channelMode = (bytes[offset + 3] >> 6) & 0x03;
|
||||
return {
|
||||
bitrateKbps,
|
||||
sampleRate,
|
||||
channels: channelMode === 0x03 ? 1 : 2,
|
||||
durationSeconds: ((bytes.length - searchOffset) * 8) / (bitrateKbps * 1000)
|
||||
};
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function assertSelectionSoundscape(selection, context, getCampSoundscape) {
|
||||
const skinId = selection.skin?.id;
|
||||
assert(Boolean(skinId), `${context}: selection has no skin`);
|
||||
if (!skinId || !expectedMapping[skinId]) {
|
||||
return;
|
||||
}
|
||||
|
||||
const expected = expectedMapping[skinId];
|
||||
const soundscape = getCampSoundscape(skinId);
|
||||
assertEqual(soundscape.music.id, expected.musicGroupId, `${context}: music group`);
|
||||
assertEqual(soundscape.ambience.id, expected.ambienceId, `${context}: ambience`);
|
||||
}
|
||||
|
||||
function expectedSkinForOrdinal(battleOrdinal) {
|
||||
return expectedBattleArcs.find(
|
||||
({ firstBattleOrdinal, lastBattleOrdinal }) =>
|
||||
battleOrdinal >= firstBattleOrdinal && battleOrdinal <= lastBattleOrdinal
|
||||
)?.skinId;
|
||||
}
|
||||
|
||||
function assertNonEmptyString(value, context) {
|
||||
assert(typeof value === 'string' && value.trim().length > 0, `${context} must be a non-empty string`);
|
||||
}
|
||||
|
||||
function assertEqual(actual, expected, context) {
|
||||
assert(Object.is(actual, expected), `${context}: expected ${format(expected)}, received ${format(actual)}`);
|
||||
}
|
||||
|
||||
function assert(condition, message) {
|
||||
if (!condition) {
|
||||
errors.push(message);
|
||||
}
|
||||
}
|
||||
|
||||
function format(value) {
|
||||
return value === undefined ? 'undefined' : JSON.stringify(value);
|
||||
}
|
||||
@@ -2020,6 +2020,7 @@ try {
|
||||
await clickLegacyUi(page, 738, 642);
|
||||
await waitForCampAfterBattleResult(page);
|
||||
await waitForCampSkinTransition(page, 'yellow-turban');
|
||||
await waitForCampSoundscapeTransition(page, 'camp-rally', 'campfire-ambience');
|
||||
await page.screenshot({ path: `${screenshotDir}/rc-first-camp.png`, fullPage: true });
|
||||
await assertCanvasPainted(page, 'first camp');
|
||||
|
||||
@@ -2037,6 +2038,12 @@ try {
|
||||
});
|
||||
assert(firstCampProbe.state?.campaign?.step === 'first-camp', `Expected victory to return to first camp: ${JSON.stringify(firstCampProbe.state)}`);
|
||||
assertCampSkinState(firstCampProbe.state, 'yellow-turban', 'first camp');
|
||||
assertCampSoundscapeState(firstCampProbe.state, {
|
||||
musicKey: 'camp-rally',
|
||||
ambienceKey: 'campfire-ambience',
|
||||
musicVolume: 0.22,
|
||||
ambienceVolume: 0.12
|
||||
}, 'first camp');
|
||||
assert(
|
||||
firstCampProbe.state?.campRoster?.pageCount === 1 &&
|
||||
firstCampProbe.state.campRoster.totalCount === 3 &&
|
||||
@@ -2075,7 +2082,10 @@ try {
|
||||
firstCampProbe.state?.sortieDeploymentPreview?.map((slot) => `${slot.x},${slot.y}`),
|
||||
`Expected first camp deployment preview to avoid duplicate tiles: ${JSON.stringify(firstCampProbe.state?.sortieDeploymentPreview)}`
|
||||
);
|
||||
assert(firstCampProbe.summary?.includes('보유 군자금'), `Expected camp summary to show held gold: ${JSON.stringify(firstCampProbe)}`);
|
||||
assert(
|
||||
firstCampProbe.summary?.includes('군자금') && !firstCampProbe.summary.includes('\n'),
|
||||
`Expected the compact camp summary to show held gold on one line: ${JSON.stringify(firstCampProbe)}`
|
||||
);
|
||||
assert(firstCampProbe.texts.some((text) => text.includes('전투 보상')), `Expected camp report to show battle reward: ${JSON.stringify(firstCampProbe.texts)}`);
|
||||
assert(
|
||||
firstCampProbe.texts.some((text) => text.includes('명령 기록') && text.includes('재정비')),
|
||||
@@ -2565,6 +2575,16 @@ try {
|
||||
undefined,
|
||||
{ timeout: 30000 }
|
||||
);
|
||||
const firstCampTabAudioState = await page.evaluate(() => window.__HEROS_DEBUG__?.camp()?.audio);
|
||||
assert(
|
||||
firstCampTabAudioState?.music?.currentKey === firstCampProbe.state?.audio?.music?.currentKey &&
|
||||
firstCampTabAudioState?.ambience?.currentKey === firstCampProbe.state?.audio?.ambience?.currentKey &&
|
||||
firstCampTabAudioState?.music?.elementRevision === firstCampProbe.state?.audio?.music?.elementRevision &&
|
||||
firstCampTabAudioState?.ambience?.elementRevision === firstCampProbe.state?.audio?.ambience?.elementRevision &&
|
||||
firstCampTabAudioState?.music?.transition?.revision === firstCampProbe.state?.audio?.music?.transition?.revision &&
|
||||
firstCampTabAudioState?.ambience?.transition?.revision === firstCampProbe.state?.audio?.ambience?.transition?.revision,
|
||||
`Expected camp tab changes to keep the existing soundscape elements: ${JSON.stringify({ before: firstCampProbe.state?.audio, after: firstCampTabAudioState })}`
|
||||
);
|
||||
const firstCampUseSupplyPoint = await readCampSupplyUseControlPoint(page, '콩');
|
||||
await page.mouse.click(firstCampUseSupplyPoint.x, firstCampUseSupplyPoint.y);
|
||||
await page.waitForTimeout(120);
|
||||
@@ -5212,9 +5232,16 @@ try {
|
||||
await clickLegacyUi(page, 962, 310);
|
||||
await waitForCamp(page);
|
||||
await waitForCampSkinTransition(page, 'northern');
|
||||
await waitForCampSoundscapeTransition(page, 'camp-frontier', 'mountain-wind-ambience');
|
||||
const finalCampState = await page.evaluate(() => window.__HEROS_DEBUG__?.camp());
|
||||
assert(finalCampState?.campaign?.step === 'sixty-sixth-camp', `Expected final camp save to continue: ${JSON.stringify(finalCampState)}`);
|
||||
assertCampSkinState(finalCampState, 'northern', 'final camp');
|
||||
assertCampSoundscapeState(finalCampState, {
|
||||
musicKey: 'camp-frontier',
|
||||
ambienceKey: 'mountain-wind-ambience',
|
||||
musicVolume: 0.2,
|
||||
ambienceVolume: 0.09
|
||||
}, 'final camp');
|
||||
assert(finalCampState.sortieHasBattle === false, `Expected final camp to expose no playable sortie target: ${JSON.stringify(finalCampState)}`);
|
||||
assert(finalCampState.stagedSortiePrep === false, `Expected the non-battle final camp to avoid the three-stage sortie flow: ${JSON.stringify(finalCampState)}`);
|
||||
assert(finalCampState.nextSortieBattleId === null, `Expected final camp to route to ending instead of a battle: ${JSON.stringify(finalCampState)}`);
|
||||
@@ -7080,6 +7107,26 @@ async function waitForCampSkinTransition(page, expectedSkinId) {
|
||||
}));
|
||||
}
|
||||
|
||||
async function waitForCampSoundscapeTransition(page, expectedMusicKey, expectedAmbienceKey) {
|
||||
await page.waitForFunction(
|
||||
({ musicKey, ambienceKey }) => {
|
||||
const audio = window.__HEROS_DEBUG__?.camp?.()?.audio;
|
||||
const channels = [audio?.music, audio?.ambience];
|
||||
return (
|
||||
audio?.music?.currentKey === musicKey &&
|
||||
audio?.ambience?.currentKey === ambienceKey &&
|
||||
channels.every((channel) =>
|
||||
channel?.transition?.active === false &&
|
||||
channel.transition.revision > 0 &&
|
||||
channel.transition.completedRevision === channel.transition.revision
|
||||
)
|
||||
);
|
||||
},
|
||||
{ musicKey: expectedMusicKey, ambienceKey: expectedAmbienceKey },
|
||||
{ timeout: 30000 }
|
||||
);
|
||||
}
|
||||
|
||||
async function waitForCampVisualFrame(page, expectedPage) {
|
||||
await page.waitForFunction(
|
||||
(pageNumber) => window.__HEROS_DEBUG__?.camp()?.campRoster?.page === pageNumber,
|
||||
@@ -8491,6 +8538,42 @@ function assertCampSkinState(state, expectedSkinId, label) {
|
||||
);
|
||||
}
|
||||
|
||||
function assertCampSoundscapeState(state, expected, label) {
|
||||
const soundscape = state?.campSoundscape;
|
||||
const audio = state?.audio;
|
||||
|
||||
assert(
|
||||
soundscape?.skinId === state?.campSkin?.id &&
|
||||
soundscape.musicKey === expected.musicKey &&
|
||||
soundscape.ambienceKey === expected.ambienceKey &&
|
||||
Math.abs(soundscape.musicVolume - expected.musicVolume) <= 0.001 &&
|
||||
Math.abs(soundscape.ambienceVolume - expected.ambienceVolume) <= 0.001,
|
||||
`Expected ${label} to select its era soundscape profile: ${JSON.stringify(soundscape)}`
|
||||
);
|
||||
|
||||
for (const [channelName, expectedKey, expectedVolume, minFadeMs, maxFadeMs] of [
|
||||
['music', expected.musicKey, expected.musicVolume, 700, 1100],
|
||||
['ambience', expected.ambienceKey, expected.ambienceVolume, 1000, 1400]
|
||||
]) {
|
||||
const channel = audio?.[channelName];
|
||||
assert(
|
||||
channel?.currentKey === expectedKey &&
|
||||
channel.pendingKey === null &&
|
||||
channel.hasPendingRequest === false &&
|
||||
channel.activeElementCount === 1 &&
|
||||
channel.elementRevision > 0 &&
|
||||
Math.abs(channel.targetVolume - expectedVolume) <= 0.001 &&
|
||||
channel.transition?.active === false &&
|
||||
channel.transition.revision > 0 &&
|
||||
channel.transition.completedRevision === channel.transition.revision &&
|
||||
channel.transition.last?.toKey === expectedKey &&
|
||||
channel.transition.last.durationMs >= minFadeMs &&
|
||||
channel.transition.last.durationMs <= maxFadeMs,
|
||||
`Expected ${label} ${channelName} channel to finish one clean crossfade: ${JSON.stringify(channel)}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function isVisibleCampSkinView(view) {
|
||||
return Boolean(
|
||||
view?.visible === true &&
|
||||
|
||||
@@ -140,6 +140,18 @@ try {
|
||||
});
|
||||
const elementsDuringCrossfade = audioHarness.activeInstances();
|
||||
assert.equal(elementsDuringCrossfade.length, 4, 'two channels should each retain one outgoing element during a crossfade');
|
||||
assertClose(
|
||||
audioHarness.byOriginalSrc('/audio/music-a.ogg').volume,
|
||||
0.18,
|
||||
'outgoing music should keep its settled volume at crossfade start'
|
||||
);
|
||||
assertClose(
|
||||
audioHarness.byOriginalSrc('/audio/ambience-a.ogg').volume,
|
||||
0.05,
|
||||
'outgoing ambience should keep its settled volume at crossfade start'
|
||||
);
|
||||
assertClose(audioHarness.byOriginalSrc('/audio/music-b.ogg').volume, 0, 'incoming music should start silent');
|
||||
assertClose(audioHarness.byOriginalSrc('/audio/ambience-b.ogg').volume, 0, 'incoming ambience should start silent');
|
||||
|
||||
director.setMuted(true);
|
||||
assert(elementsDuringCrossfade.every((element) => element.muted), 'mute should cover current and outgoing elements');
|
||||
|
||||
@@ -8,6 +8,7 @@ const checks = [
|
||||
'scripts/verify-battle-scenario-data.mjs',
|
||||
'scripts/verify-camp-reward-data.mjs',
|
||||
'scripts/verify-camp-skin-data.mjs',
|
||||
'scripts/verify-camp-soundscape-data.mjs',
|
||||
'scripts/verify-campaign-recruit-data.mjs',
|
||||
'scripts/verify-equipment-catalog-data.mjs',
|
||||
'scripts/verify-inventory-reward-data.mjs',
|
||||
@@ -16,7 +17,8 @@ const checks = [
|
||||
'scripts/verify-visual-asset-data.mjs',
|
||||
'scripts/verify-unit-sprite-asset-data.mjs',
|
||||
'scripts/verify-battle-map-asset-data.mjs',
|
||||
'scripts/verify-audio-asset-data.mjs'
|
||||
'scripts/verify-audio-asset-data.mjs',
|
||||
'scripts/verify-sound-director.mjs'
|
||||
];
|
||||
|
||||
for (const scriptPath of checks) {
|
||||
|
||||
BIN
src/assets/audio/ambience/campfire-ambience.mp3
Normal file
BIN
src/assets/audio/ambience/campfire-ambience.mp3
Normal file
Binary file not shown.
BIN
src/assets/audio/ambience/mountain-wind-ambience.mp3
Normal file
BIN
src/assets/audio/ambience/mountain-wind-ambience.mp3
Normal file
Binary file not shown.
BIN
src/assets/audio/ambience/nanzhong-night-ambience.mp3
Normal file
BIN
src/assets/audio/ambience/nanzhong-night-ambience.mp3
Normal file
Binary file not shown.
BIN
src/assets/audio/ambience/river-night-ambience.mp3
Normal file
BIN
src/assets/audio/ambience/river-night-ambience.mp3
Normal file
Binary file not shown.
BIN
src/assets/audio/bgm/camp-frontier.mp3
Normal file
BIN
src/assets/audio/bgm/camp-frontier.mp3
Normal file
Binary file not shown.
BIN
src/assets/audio/bgm/camp-grand-strategy.mp3
Normal file
BIN
src/assets/audio/bgm/camp-grand-strategy.mp3
Normal file
Binary file not shown.
BIN
src/assets/audio/bgm/camp-rally.mp3
Normal file
BIN
src/assets/audio/bgm/camp-rally.mp3
Normal file
Binary file not shown.
BIN
src/assets/audio/bgm/camp-wayfarer.mp3
Normal file
BIN
src/assets/audio/bgm/camp-wayfarer.mp3
Normal file
Binary file not shown.
@@ -380,9 +380,6 @@ export class SoundDirector {
|
||||
|
||||
private setLoopTargetVolume(channel: LoopChannel, volume: number) {
|
||||
channel.targetVolume = Number.isFinite(volume) ? Math.min(1, Math.max(0, volume)) : channel.defaultVolume;
|
||||
if (channel.current && !channel.transition.active) {
|
||||
channel.current.volume = channel.targetVolume;
|
||||
}
|
||||
}
|
||||
|
||||
private requestLoopChannel(channel: LoopChannel, key?: string) {
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
import battlePrepUrl from '../../assets/audio/bgm/battle-prep.mp3';
|
||||
import campFrontierUrl from '../../assets/audio/bgm/camp-frontier.mp3';
|
||||
import campGrandStrategyUrl from '../../assets/audio/bgm/camp-grand-strategy.mp3';
|
||||
import campRallyUrl from '../../assets/audio/bgm/camp-rally.mp3';
|
||||
import campWayfarerUrl from '../../assets/audio/bgm/camp-wayfarer.mp3';
|
||||
import militiaThemeUrl from '../../assets/audio/bgm/militia-theme.mp3';
|
||||
import oathThemeUrl from '../../assets/audio/bgm/oath-theme.mp3';
|
||||
import storyDarkUrl from '../../assets/audio/bgm/story-dark.mp3';
|
||||
@@ -15,13 +19,28 @@ import strategyCastUrl from '../../assets/audio/sfx/strategy-cast.mp3';
|
||||
import swordSlashUrl from '../../assets/audio/sfx/sword-slash.mp3';
|
||||
import uiSelectUrl from '../../assets/audio/sfx/ui-select.mp3';
|
||||
import victoryFanfareUrl from '../../assets/audio/sfx/victory-fanfare.wav';
|
||||
import campfireAmbienceUrl from '../../assets/audio/ambience/campfire-ambience.mp3';
|
||||
import mountainWindAmbienceUrl from '../../assets/audio/ambience/mountain-wind-ambience.mp3';
|
||||
import nanzhongNightAmbienceUrl from '../../assets/audio/ambience/nanzhong-night-ambience.mp3';
|
||||
import riverNightAmbienceUrl from '../../assets/audio/ambience/river-night-ambience.mp3';
|
||||
|
||||
export const musicTracks = {
|
||||
'title-theme': titleThemeUrl,
|
||||
'story-dark': storyDarkUrl,
|
||||
'oath-theme': oathThemeUrl,
|
||||
'militia-theme': militiaThemeUrl,
|
||||
'battle-prep': battlePrepUrl
|
||||
'battle-prep': battlePrepUrl,
|
||||
'camp-rally': campRallyUrl,
|
||||
'camp-wayfarer': campWayfarerUrl,
|
||||
'camp-grand-strategy': campGrandStrategyUrl,
|
||||
'camp-frontier': campFrontierUrl
|
||||
} as const;
|
||||
|
||||
export const ambienceTracks = {
|
||||
'campfire-ambience': campfireAmbienceUrl,
|
||||
'river-night-ambience': riverNightAmbienceUrl,
|
||||
'mountain-wind-ambience': mountainWindAmbienceUrl,
|
||||
'nanzhong-night-ambience': nanzhongNightAmbienceUrl
|
||||
} as const;
|
||||
|
||||
export const effectTracks = {
|
||||
@@ -40,4 +59,5 @@ export const effectTracks = {
|
||||
} as const;
|
||||
|
||||
export type MusicTrackKey = keyof typeof musicTracks;
|
||||
export type AmbienceTrackKey = keyof typeof ambienceTracks;
|
||||
export type EffectTrackKey = keyof typeof effectTracks;
|
||||
|
||||
119
src/game/data/campSoundscapes.ts
Normal file
119
src/game/data/campSoundscapes.ts
Normal file
@@ -0,0 +1,119 @@
|
||||
import type { AmbienceTrackKey, MusicTrackKey } from '../audio/audioAssets';
|
||||
import type { CampSkinId } from './campSkins';
|
||||
|
||||
export type CampMusicGroupId = 'rally' | 'wayfarer' | 'grand-strategy' | 'frontier';
|
||||
|
||||
export type CampAmbienceId = 'campfire' | 'river-night' | 'mountain-wind' | 'nanzhong-night';
|
||||
|
||||
export type CampMusicTrackKey = Extract<
|
||||
MusicTrackKey,
|
||||
'camp-rally' | 'camp-wayfarer' | 'camp-grand-strategy' | 'camp-frontier'
|
||||
>;
|
||||
|
||||
export type CampAmbienceTrackKey = Extract<
|
||||
AmbienceTrackKey,
|
||||
'campfire-ambience' | 'river-night-ambience' | 'mountain-wind-ambience' | 'nanzhong-night-ambience'
|
||||
>;
|
||||
|
||||
export type CampMusicGroupDefinition = {
|
||||
id: CampMusicGroupId;
|
||||
trackKey: CampMusicTrackKey;
|
||||
label: string;
|
||||
volume: number;
|
||||
};
|
||||
|
||||
export type CampAmbienceDefinition = {
|
||||
id: CampAmbienceId;
|
||||
trackKey: CampAmbienceTrackKey;
|
||||
label: string;
|
||||
volume: number;
|
||||
};
|
||||
|
||||
export type CampSoundscapeMapping = {
|
||||
musicGroupId: CampMusicGroupId;
|
||||
ambienceId: CampAmbienceId;
|
||||
};
|
||||
|
||||
export type CampSoundscape = {
|
||||
skinId: CampSkinId;
|
||||
music: CampMusicGroupDefinition;
|
||||
ambience: CampAmbienceDefinition;
|
||||
};
|
||||
|
||||
export const campMusicGroups: Record<CampMusicGroupId, CampMusicGroupDefinition> = {
|
||||
rally: {
|
||||
id: 'rally',
|
||||
trackKey: 'camp-rally',
|
||||
label: '결의와 출정',
|
||||
volume: 0.22
|
||||
},
|
||||
wayfarer: {
|
||||
id: 'wayfarer',
|
||||
trackKey: 'camp-wayfarer',
|
||||
label: '유랑의 밤',
|
||||
volume: 0.18
|
||||
},
|
||||
'grand-strategy': {
|
||||
id: 'grand-strategy',
|
||||
trackKey: 'camp-grand-strategy',
|
||||
label: '대업의 계책',
|
||||
volume: 0.2
|
||||
},
|
||||
frontier: {
|
||||
id: 'frontier',
|
||||
trackKey: 'camp-frontier',
|
||||
label: '변경과 북벌',
|
||||
volume: 0.2
|
||||
}
|
||||
};
|
||||
|
||||
export const campAmbienceDefinitions: Record<CampAmbienceId, CampAmbienceDefinition> = {
|
||||
campfire: {
|
||||
id: 'campfire',
|
||||
trackKey: 'campfire-ambience',
|
||||
label: '장작불',
|
||||
volume: 0.12
|
||||
},
|
||||
'river-night': {
|
||||
id: 'river-night',
|
||||
trackKey: 'river-night-ambience',
|
||||
label: '밤의 강물',
|
||||
volume: 0.1
|
||||
},
|
||||
'mountain-wind': {
|
||||
id: 'mountain-wind',
|
||||
trackKey: 'mountain-wind-ambience',
|
||||
label: '산바람',
|
||||
volume: 0.09
|
||||
},
|
||||
'nanzhong-night': {
|
||||
id: 'nanzhong-night',
|
||||
trackKey: 'nanzhong-night-ambience',
|
||||
label: '남중의 밤',
|
||||
volume: 0.11
|
||||
}
|
||||
};
|
||||
|
||||
export const campSoundscapeMapping: Record<CampSkinId, CampSoundscapeMapping> = {
|
||||
'yellow-turban': { musicGroupId: 'rally', ambienceId: 'campfire' },
|
||||
'anti-dong': { musicGroupId: 'rally', ambienceId: 'campfire' },
|
||||
xuzhou: { musicGroupId: 'wayfarer', ambienceId: 'campfire' },
|
||||
wandering: { musicGroupId: 'wayfarer', ambienceId: 'campfire' },
|
||||
wolong: { musicGroupId: 'wayfarer', ambienceId: 'mountain-wind' },
|
||||
'red-cliffs': { musicGroupId: 'grand-strategy', ambienceId: 'river-night' },
|
||||
'jing-yi': { musicGroupId: 'grand-strategy', ambienceId: 'river-night' },
|
||||
'hanzhong-shuhan': { musicGroupId: 'grand-strategy', ambienceId: 'mountain-wind' },
|
||||
'jingzhou-crisis': { musicGroupId: 'grand-strategy', ambienceId: 'river-night' },
|
||||
'yiling-baidi': { musicGroupId: 'grand-strategy', ambienceId: 'river-night' },
|
||||
nanzhong: { musicGroupId: 'frontier', ambienceId: 'nanzhong-night' },
|
||||
northern: { musicGroupId: 'frontier', ambienceId: 'mountain-wind' }
|
||||
};
|
||||
|
||||
export function getCampSoundscape(skinId: CampSkinId): CampSoundscape {
|
||||
const mapping = campSoundscapeMapping[skinId];
|
||||
return {
|
||||
skinId,
|
||||
music: campMusicGroups[mapping.musicGroupId],
|
||||
ambience: campAmbienceDefinitions[mapping.ambienceId]
|
||||
};
|
||||
}
|
||||
@@ -16,6 +16,7 @@ import { compareUnitStrategyCoverage, getUnitStrategyCoverage, getUnitStrategyNa
|
||||
import { getUnitClass, terrainRules, type TerrainType, type UnitClassKey } from '../data/battleRules';
|
||||
import { defaultBattleScenario, getBattleScenario, type BattleScenarioDefinition, type BattleScenarioId } from '../data/battles';
|
||||
import { getSortieFlow } from '../data/campaignFlow';
|
||||
import { getCampSoundscape, type CampSoundscape } from '../data/campSoundscapes';
|
||||
import {
|
||||
campaignPortraitKeysByUnitId,
|
||||
portraitAssetEntriesForKey,
|
||||
@@ -11070,6 +11071,7 @@ export class CampScene extends Phaser.Scene {
|
||||
private campSkinSeal?: Phaser.GameObjects.Arc;
|
||||
private campSkinBadge?: Phaser.GameObjects.Rectangle;
|
||||
private campSkinSummary?: Phaser.GameObjects.Text;
|
||||
private campSoundscape?: CampSoundscape;
|
||||
private campSkinRenderedTextureKey?: string;
|
||||
private campSkinUsedFallback = false;
|
||||
private campSkinTransitionRevision = 0;
|
||||
@@ -11206,6 +11208,7 @@ export class CampScene extends Phaser.Scene {
|
||||
nextBattleId: sortieFlow.nextBattleId,
|
||||
currentBattleId: this.currentCampBattleId()
|
||||
});
|
||||
this.campSoundscape = getCampSoundscape(this.campSkinSelection.skin.id);
|
||||
this.campSkinBackdrop = undefined;
|
||||
this.campSkinWash = undefined;
|
||||
this.campSkinVignette = undefined;
|
||||
@@ -11223,7 +11226,12 @@ export class CampScene extends Phaser.Scene {
|
||||
this.sortieItemAssignments = this.normalizedSortieItemAssignments(this.campaign.sortieItemAssignments);
|
||||
this.selectedDialogueId = this.availableCampDialogues()[0]?.id ?? campDialogues[0].id;
|
||||
this.selectedVisitId = this.availableCampVisits()[0]?.id ?? campVisits[0].id;
|
||||
soundDirector.playMusic('militia-theme');
|
||||
soundDirector.playSoundscape({
|
||||
musicKey: this.campSoundscape.music.trackKey,
|
||||
ambienceKey: this.campSoundscape.ambience.trackKey,
|
||||
musicVolume: this.campSoundscape.music.volume,
|
||||
ambienceVolume: this.campSoundscape.ambience.volume
|
||||
});
|
||||
this.input.keyboard?.on('keydown-ESC', () => this.handleEscapeKey());
|
||||
this.input.keyboard?.on('keydown-ENTER', (event: KeyboardEvent) => this.handleSortieEnterKey(event));
|
||||
this.input.keyboard?.on('keydown', (event: KeyboardEvent) => this.handleSaveSlotKey(event));
|
||||
@@ -21673,6 +21681,20 @@ export class CampScene extends Phaser.Scene {
|
||||
campaignProgress: this.campaignTimelineProgress(),
|
||||
campBattleId: this.currentCampBattleId(),
|
||||
campTitle: this.currentCampTitle(),
|
||||
campSoundscape: this.campSoundscape
|
||||
? {
|
||||
skinId: this.campSoundscape.skinId,
|
||||
musicGroupId: this.campSoundscape.music.id,
|
||||
musicKey: this.campSoundscape.music.trackKey,
|
||||
musicLabel: this.campSoundscape.music.label,
|
||||
musicVolume: this.campSoundscape.music.volume,
|
||||
ambienceId: this.campSoundscape.ambience.id,
|
||||
ambienceKey: this.campSoundscape.ambience.trackKey,
|
||||
ambienceLabel: this.campSoundscape.ambience.label,
|
||||
ambienceVolume: this.campSoundscape.ambience.volume
|
||||
}
|
||||
: null,
|
||||
audio: soundDirector.getDebugState(),
|
||||
campSkin: this.campSkinSelection
|
||||
? {
|
||||
id: this.campSkinSelection.skin.id,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import Phaser from 'phaser';
|
||||
import { effectTracks, musicTracks } from './game/audio/audioAssets';
|
||||
import { ambienceTracks, effectTracks, musicTracks } from './game/audio/audioAssets';
|
||||
import { soundDirector } from './game/audio/SoundDirector';
|
||||
import { BootScene } from './game/scenes/BootScene';
|
||||
import { startLazySceneFromGame } from './game/scenes/lazyScenes';
|
||||
@@ -26,6 +26,7 @@ type DebugCampScene = Phaser.Scene & {
|
||||
type HerosDebugApi = {
|
||||
game: Phaser.Game;
|
||||
activeScenes: () => string[];
|
||||
audio: () => ReturnType<typeof soundDirector.getDebugState>;
|
||||
battle: () => unknown;
|
||||
camp: () => unknown;
|
||||
goToBattle: (battleId?: string) => Promise<void>;
|
||||
@@ -57,6 +58,7 @@ const config: Phaser.Types.Core.GameConfig = {
|
||||
};
|
||||
|
||||
soundDirector.registerMusicTracks(musicTracks);
|
||||
soundDirector.registerAmbienceTracks(ambienceTracks);
|
||||
soundDirector.registerEffectTracks(effectTracks);
|
||||
|
||||
const game = new Phaser.Game(config);
|
||||
@@ -76,6 +78,7 @@ function createDebugApi(game: Phaser.Game): HerosDebugApi {
|
||||
return {
|
||||
game,
|
||||
activeScenes: () => game.scene.getScenes(true).map((activeScene) => activeScene.scene.key),
|
||||
audio: () => soundDirector.getDebugState(),
|
||||
battle: () => battleScene()?.getDebugState?.() ?? { active: false, reason: 'BattleScene is not active yet.' },
|
||||
camp: () => campScene()?.getDebugState?.() ?? { active: false, reason: 'CampScene is not active yet.' },
|
||||
goToBattle: (battleId) => startLazySceneFromGame(game, 'BattleScene', battleId ? { battleId } : undefined),
|
||||
|
||||
Reference in New Issue
Block a user