mirror of
https://github.com/isledecomp/isle.pizza.git
synced 2026-03-01 14:27: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'],
|
['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 => {
|
||||||
|
|||||||
@ -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.
|
||||||
|
|||||||
@ -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);
|
||||||
|
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user