Add click sound playback to actor editor

Plays the character's click sound (m_sound + 50) and, for Laura's
SwitchMood, an additional mood sound (m_mood + 66) from SNDANIM.SI,
matching the original game behavior. Sounds are fetched on demand,
decoded as WAV, and cached as AudioBuffers.
This commit is contained in:
Christian Semmler 2026-02-13 15:42:42 -08:00
parent 6cfe385070
commit 6b06c45f3a
No known key found for this signature in database
GPG Key ID: 086DAA1360BEEE5C
3 changed files with 133 additions and 2 deletions

View File

@ -114,6 +114,29 @@ const CLICK_ANIMATIONS = [
['ClickAnim3', 13, 4218, 'e25f074d7012f89868011dc2bd5c0586'], ['ClickAnim3', 13, 4218, 'e25f074d7012f89868011dc2bd5c0586'],
]; ];
// Click sounds from SNDANIM.SI (objectId = m_sound + 50)
// [name, objectId, size, md5]
const CLICK_SOUNDS = [
['ClickSound0', 50, 10078, '928eeb70f8dadbc400f5c150727fde69'],
['ClickSound1', 51, 15988, '9c8aa04b0e4683976c3f2c2be868b37e'],
['ClickSound2', 52, 4114, 'a94a6dc7ae24fc42b1b9be962bbf3bf1'],
['ClickSound3', 53, 7741, '96bd26dc212ffd31da365ea1d088bfa3'],
['ClickSound4', 54, 23705, 'ca79cc736729c12aed6da018725fb0e3'],
['ClickSound5', 55, 24179, 'b7c97cb776f0afbba40f2e21fc0b309d'],
['ClickSound6', 56, 17675, 'b69b07bba21c6667d0af651c89828815'],
['ClickSound7', 57, 18953, '65d9cc0d09e3bfb831cee014a84085f7'],
['ClickSound8', 58, 7344, '7bbc41251b750835989cb3b35c8546a4'],
];
// Mood sounds from SNDANIM.SI (objectId = m_mood + 66)
// [name, objectId, size, md5]
const MOOD_SOUNDS = [
['MoodSound0', 66, 11534, '91379f36012f600a4b7432e003e16c3a'],
['MoodSound1', 67, 11534, '91379f36012f600a4b7432e003e16c3a'],
['MoodSound2', 68, 11534, '91379f36012f600a4b7432e003e16c3a'],
['MoodSound3', 69, 11534, '91379f36012f600a4b7432e003e16c3a'],
];
// [name, siFile, objectId, size, md5] // [name, siFile, objectId, size, md5]
const TEXTURES = [ const TEXTURES = [
['CHJETL1', 'Scripts/Build/COPTER.SI', 112, 4235, 'af5010e9de08240c1ff7ad08ae90087e'], ['CHJETL1', 'Scripts/Build/COPTER.SI', 112, 4235, 'af5010e9de08240c1ff7ad08ae90087e'],
@ -266,7 +289,7 @@ function verifyRanges(siBuf, ranges, size, expectedMd5) {
async function main() { async function main() {
console.log('Generating asset range manifest...\n'); console.log('Generating asset range manifest...\n');
const manifest = { animations: {}, textures: {}, bitmaps: {} }; const manifest = { animations: {}, sounds: {}, textures: {}, bitmaps: {} };
let found = 0; let found = 0;
let failed = 0; let failed = 0;
@ -310,6 +333,25 @@ async function main() {
} }
console.log(` ${clickFound}/${CLICK_ANIMATIONS.length} click animations found\n`); console.log(` ${clickFound}/${CLICK_ANIMATIONS.length} click animations found\n`);
// --- Sounds (in SNDANIM.SI) ---
const allSounds = [...CLICK_SOUNDS, ...MOOD_SOUNDS];
const soundObjectIds = new Set(allSounds.map(([, objectId]) => objectId));
const soundRanges = findMxChByObjectId(sndanimSI, soundObjectIds);
let soundFound = 0;
for (const [name, objectId, size, expectedMd5] of allSounds) {
const result = verifyRanges(sndanimSI, soundRanges.get(objectId), size, expectedMd5);
if (result) {
manifest.sounds[name] = formatResult('Scripts/SNDANIM.SI', result);
soundFound++;
found++;
} else {
console.error(` FAILED: ${name} (objectId ${objectId})`);
failed++;
}
}
console.log(` ${soundFound}/${allSounds.length} sounds found\n`);
// --- Textures (across Build SI files) --- // --- Textures (across Build SI files) ---
// Group textures by SI file so we scan each file once // Group textures by SI file so we scan each file once
const texBySI = new Map(); const texBySI = new Map();
@ -375,7 +417,7 @@ async function main() {
await fs.writeFile(OUTPUT_PATH, JSON.stringify(manifest)); await fs.writeFile(OUTPUT_PATH, JSON.stringify(manifest));
console.log(`Wrote ${OUTPUT_PATH}`); console.log(`Wrote ${OUTPUT_PATH}`);
console.log(`Total: ${found} assets (${ANIMATIONS.length} walking + ${CLICK_ANIMATIONS.length} click animations, ${TEXTURES.length} textures, ${BITMAPS.length} bitmaps)`); console.log(`Total: ${found} assets (${ANIMATIONS.length} walking + ${CLICK_ANIMATIONS.length} click animations, ${allSounds.length} sounds, ${TEXTURES.length} textures, ${BITMAPS.length} bitmaps)`);
} }
main().catch(err => { main().catch(err => {

View File

@ -58,6 +58,49 @@ export async function fetchBitmap(name) {
return fetchEntry(entry); return fetchEntry(entry);
} }
async function fetchSound(name) {
const m = await loadManifest();
const entry = m.sounds[name];
if (!entry) return null;
return fetchEntry(entry);
}
/**
* Build a WAV file from raw MxCh sound data.
* Layout: bytes 0-15 = PCMWAVEFORMAT, 16-19 = m_dataSize, 20-23 = m_flags, 24+ = PCM data.
* Uses actual available size since sector interleaving may clip the last chunk.
*/
function buildWav(buffer) {
const dataSize = buffer.byteLength - 24;
const wavSize = 44 + dataSize;
const wav = new ArrayBuffer(wavSize);
const view = new DataView(wav);
const bytes = new Uint8Array(wav);
// RIFF header
bytes.set([0x52, 0x49, 0x46, 0x46]); // "RIFF"
view.setUint32(4, wavSize - 8, true);
bytes.set([0x57, 0x41, 0x56, 0x45], 8); // "WAVE"
// fmt chunk — copy PCMWAVEFORMAT (16 bytes) directly from source header
bytes.set([0x66, 0x6D, 0x74, 0x20], 12); // "fmt "
view.setUint32(16, 16, true);
bytes.set(new Uint8Array(buffer, 0, 16), 20);
// data chunk
bytes.set([0x64, 0x61, 0x74, 0x61], 36); // "data"
view.setUint32(40, dataSize, true);
bytes.set(new Uint8Array(buffer, 24, dataSize), 44);
return wav;
}
export async function fetchSoundAsWav(name) {
const buffer = await fetchSound(name);
if (!buffer) return null;
return buildWav(buffer);
}
/** /**
* Decode a raw Windows DIB (no BM file header) into RGBA ImageData. * Decode a raw Windows DIB (no BM file header) into RGBA ImageData.
* Supports 8-bit indexed color only. * Supports 8-bit indexed color only.

View File

@ -4,6 +4,7 @@
import { WdbParser, buildGlobalPartsMap, buildPartsMap, resolveLods } from '../../core/formats/WdbParser.js'; import { WdbParser, buildGlobalPartsMap, buildPartsMap, resolveLods } from '../../core/formats/WdbParser.js';
import { ActorInfoInit, ActorPart, ActorDisplayNames, ActorVehicles, VehicleDisplayNames } from '../../core/savegame/actorConstants.js'; import { ActorInfoInit, ActorPart, ActorDisplayNames, ActorVehicles, VehicleDisplayNames } from '../../core/savegame/actorConstants.js';
import { Actor } from '../../core/savegame/constants.js'; import { Actor } from '../../core/savegame/constants.js';
import { fetchSoundAsWav } from '../../core/assetLoader.js';
import NavButton from '../NavButton.svelte'; import NavButton from '../NavButton.svelte';
import ResetButton from '../ResetButton.svelte'; import ResetButton from '../ResetButton.svelte';
import EditorTooltip from '../EditorTooltip.svelte'; import EditorTooltip from '../EditorTooltip.svelte';
@ -26,6 +27,39 @@
let loadedActorKey = null; let loadedActorKey = null;
let showVehicle = false; let showVehicle = false;
let audioContext = null;
let gainNode = null;
const soundCache = new Map();
async function playSound(name) {
try {
if (!audioContext) {
audioContext = new AudioContext();
gainNode = audioContext.createGain();
gainNode.gain.value = 0.3;
gainNode.connect(audioContext.destination);
}
if (audioContext.state === 'suspended') {
await audioContext.resume();
}
let audioBuffer = soundCache.get(name);
if (!audioBuffer) {
const wav = await fetchSoundAsWav(name);
if (!wav) return;
audioBuffer = await audioContext.decodeAudioData(wav);
soundCache.set(name, audioBuffer);
}
const source = audioContext.createBufferSource();
source.buffer = audioBuffer;
source.connect(gainNode);
source.start();
} catch (e) {
console.error(`Failed to play sound ${name}:`, e);
}
}
$: actorInfo = ActorInfoInit[actorIndex]; $: actorInfo = ActorInfoInit[actorIndex];
$: actorName = ActorDisplayNames[actorIndex] || actorInfo?.name || 'Unknown'; $: actorName = ActorDisplayNames[actorIndex] || actorInfo?.name || 'Unknown';
$: charState = slot?.characters?.[actorIndex]; $: charState = slot?.characters?.[actorIndex];
@ -132,6 +166,7 @@
onDestroy(() => { onDestroy(() => {
renderer?.dispose(); renderer?.dispose();
audioContext?.close();
}); });
// Reload actor when index, character state, or vehicle toggle changes // Reload actor when index, character state, or vehicle toggle changes
@ -183,6 +218,17 @@
if (!acted) return; if (!acted) return;
// Play click sound (Mama plays the *new* sound after cycling)
const soundIdx = playerId === Actor.MAMA
? (charState.sound + 1) % 9
: charState.sound;
playSound(`ClickSound${soundIdx}`);
// Laura additionally plays a mood sound
if (playerId === Actor.LAURA) {
playSound(`MoodSound${(charState.mood + 1) % 4}`);
}
// Queue click animation — consumed by loadAnimationForActor // Queue click animation — consumed by loadAnimationForActor
renderer.queueClickAnimation(clickMove); renderer.queueClickAnimation(clickMove);