mirror of
https://github.com/isledecomp/isle.pizza.git
synced 2026-03-01 06:17:38 +00:00
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:
parent
6cfe385070
commit
6b06c45f3a
@ -114,6 +114,29 @@ const CLICK_ANIMATIONS = [
|
||||
['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]
|
||||
const TEXTURES = [
|
||||
['CHJETL1', 'Scripts/Build/COPTER.SI', 112, 4235, 'af5010e9de08240c1ff7ad08ae90087e'],
|
||||
@ -266,7 +289,7 @@ function verifyRanges(siBuf, ranges, size, expectedMd5) {
|
||||
async function main() {
|
||||
console.log('Generating asset range manifest...\n');
|
||||
|
||||
const manifest = { animations: {}, textures: {}, bitmaps: {} };
|
||||
const manifest = { animations: {}, sounds: {}, textures: {}, bitmaps: {} };
|
||||
let found = 0;
|
||||
let failed = 0;
|
||||
|
||||
@ -310,6 +333,25 @@ async function main() {
|
||||
}
|
||||
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) ---
|
||||
// Group textures by SI file so we scan each file once
|
||||
const texBySI = new Map();
|
||||
@ -375,7 +417,7 @@ async function main() {
|
||||
|
||||
await fs.writeFile(OUTPUT_PATH, JSON.stringify(manifest));
|
||||
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 => {
|
||||
|
||||
@ -58,6 +58,49 @@ export async function fetchBitmap(name) {
|
||||
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.
|
||||
* Supports 8-bit indexed color only.
|
||||
|
||||
@ -4,6 +4,7 @@
|
||||
import { WdbParser, buildGlobalPartsMap, buildPartsMap, resolveLods } from '../../core/formats/WdbParser.js';
|
||||
import { ActorInfoInit, ActorPart, ActorDisplayNames, ActorVehicles, VehicleDisplayNames } from '../../core/savegame/actorConstants.js';
|
||||
import { Actor } from '../../core/savegame/constants.js';
|
||||
import { fetchSoundAsWav } from '../../core/assetLoader.js';
|
||||
import NavButton from '../NavButton.svelte';
|
||||
import ResetButton from '../ResetButton.svelte';
|
||||
import EditorTooltip from '../EditorTooltip.svelte';
|
||||
@ -26,6 +27,39 @@
|
||||
let loadedActorKey = null;
|
||||
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];
|
||||
$: actorName = ActorDisplayNames[actorIndex] || actorInfo?.name || 'Unknown';
|
||||
$: charState = slot?.characters?.[actorIndex];
|
||||
@ -132,6 +166,7 @@
|
||||
|
||||
onDestroy(() => {
|
||||
renderer?.dispose();
|
||||
audioContext?.close();
|
||||
});
|
||||
|
||||
// Reload actor when index, character state, or vehicle toggle changes
|
||||
@ -183,6 +218,17 @@
|
||||
|
||||
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
|
||||
renderer.queueClickAnimation(clickMove);
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user