diff --git a/src/core/audio.js b/src/core/audio.js index cd68588..1cf44be 100644 --- a/src/core/audio.js +++ b/src/core/audio.js @@ -1,5 +1,6 @@ -// Audio utilities for install-audio element +// Audio utilities import { soundEnabled } from '../stores.js'; +import { fetchSoundAsWav } from './assetLoader.js'; export function getInstallAudio() { return document.getElementById('install-audio'); @@ -38,3 +39,53 @@ export function toggleInstallAudio() { pauseInstallAudio(); } } + +/** + * Create a reusable sound player for game asset sounds. + * Uses Web Audio API with caching and configurable volume. + * @param {number} volume - Gain value (0-1), default 0.3 + * @returns {{ play: (name: string) => Promise, dispose: () => void }} + */ +export function createSoundPlayer(volume = 0.3) { + let audioContext = null; + let gainNode = null; + const cache = new Map(); + + async function play(name) { + try { + if (!audioContext) { + audioContext = new AudioContext(); + gainNode = audioContext.createGain(); + gainNode.gain.value = volume; + gainNode.connect(audioContext.destination); + } + if (audioContext.state === 'suspended') { + await audioContext.resume(); + } + + let audioBuffer = cache.get(name); + if (!audioBuffer) { + const wav = await fetchSoundAsWav(name); + if (!wav) return; + audioBuffer = await audioContext.decodeAudioData(wav); + cache.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); + } + } + + function dispose() { + audioContext?.close(); + audioContext = null; + gainNode = null; + cache.clear(); + } + + return { play, dispose }; +} diff --git a/src/core/rendering/ActorRenderer.js b/src/core/rendering/ActorRenderer.js index 34de012..8c1ddb9 100644 --- a/src/core/rendering/ActorRenderer.js +++ b/src/core/rendering/ActorRenderer.js @@ -1,9 +1,7 @@ import * as THREE from 'three'; import { ActorLODs, ActorLODFlags, ActorInfoInit } from '../savegame/actorConstants.js'; import { LegoColors } from '../savegame/constants.js'; -import { parseAnimation } from '../formats/AnimationParser.js'; -import { fetchAnimation } from '../assetLoader.js'; -import { BaseRenderer } from './BaseRenderer.js'; +import { AnimatedRenderer } from './AnimatedRenderer.js'; /** * Map actor index to animation suffix index (from g_characters[].m_unk0x16). @@ -81,14 +79,10 @@ const PART_NAME_TO_ANIM_NODE = { * Renderer for full LEGO characters assembled from WDB global parts. * Mirrors the game's LegoCharacterManager::CreateActorROI logic. */ -export class ActorRenderer extends BaseRenderer { +export class ActorRenderer extends AnimatedRenderer { constructor(canvas) { super(canvas); this.partGroups = []; // 10 part groups for click targeting - this.clock = new THREE.Clock(); - this.mixer = null; - this.currentAction = null; - this.animationCache = new Map(); // suffix → parsed animation data this._queuedClickMove = null; // queued click animation move index (0-3) this.camera.position.set(2, 0.8, 3.5); @@ -97,8 +91,6 @@ export class ActorRenderer extends BaseRenderer { this.setupControls(new THREE.Vector3(0, 0.2, 0)); this.controls.autoRotate = false; this._initialAutoRotate = false; - - this.raycaster = new THREE.Raycaster(); } /** @@ -118,7 +110,6 @@ export class ActorRenderer extends BaseRenderer { const charState = characters[actorIndex]; // Build texture lookup - this.textures.clear(); for (const tex of globalTextures) { if (tex.name) { this.textures.set(tex.name.toLowerCase(), this.createTexture(tex)); @@ -603,21 +594,6 @@ export class ActorRenderer extends BaseRenderer { } } - /** - * Fetch and parse an animation file by name (e.g. "CNs001xx"), with caching. - */ - async fetchAnimationByName(animName) { - if (this.animationCache.has(animName)) { - return this.animationCache.get(animName); - } - - const buffer = await fetchAnimation(animName); - if (!buffer) return null; - const animData = parseAnimation(buffer); - this.animationCache.set(animName, animData); - return animData; - } - /** * Build world-space keyframe tracks by evaluating the animation tree * hierarchically. At each unique keyframe time, walks the tree composing @@ -655,20 +631,6 @@ export class ActorRenderer extends BaseRenderer { return tracks; } - /** - * Recursively collect all unique keyframe times from the animation tree. - */ - collectKeyframeTimes(node, timesSet) { - const data = node.data; - for (const key of data.translationKeys) timesSet.add(key.time); - for (const key of data.rotationKeys) timesSet.add(key.time); - for (const key of data.scaleKeys) timesSet.add(key.time); - for (const key of data.morphKeys) timesSet.add(key.time); - for (const child of node.children) { - this.collectKeyframeTimes(child, timesSet); - } - } - /** * Evaluate a single animation node at a given time, composing its local * transform with the parent's world matrix. If the node maps to a part @@ -748,88 +710,6 @@ export class ActorRenderer extends BaseRenderer { } } - /** - * Evaluate rotation keyframes at a given time. - * Handles slerp interpolation between keyframes with flag-based control. - * Coordinate conversion: game (w,x,y,z) → Three.js with X negated. - */ - evaluateRotation(keys, time) { - const { before, after } = this.getBeforeAndAfter(keys, time); - - const toQuat = (key) => new THREE.Quaternion(-key.x, key.y, key.z, key.w); - - if (!after) { - if (before.flags & 0x01) { - return new THREE.Matrix4().makeRotationFromQuaternion(toQuat(before)); - } - return new THREE.Matrix4(); - } - - if ((before.flags & 0x01) || (after.flags & 0x01)) { - const beforeQ = toQuat(before); - - // Flag 0x04: skip interpolation, use before value - if (after.flags & 0x04) { - return new THREE.Matrix4().makeRotationFromQuaternion(beforeQ); - } - - let afterQ = toQuat(after); - // Flag 0x02: negate the after quaternion before slerp - if (after.flags & 0x02) { - afterQ.set(-afterQ.x, -afterQ.y, -afterQ.z, -afterQ.w); - } - - const t = (time - before.time) / (after.time - before.time); - const result = new THREE.Quaternion().slerpQuaternions(beforeQ, afterQ, t); - return new THREE.Matrix4().makeRotationFromQuaternion(result); - } - - return new THREE.Matrix4(); - } - - /** - * Interpolate translation or scale keyframes at a given time. - * For translation: negates X for coordinate system conversion. - * For scale: no negation. - */ - interpolateVertex(keys, time, isTranslation) { - const { before, after } = this.getBeforeAndAfter(keys, time); - - const toVec = (key) => isTranslation - ? new THREE.Vector3(-key.x, key.y, key.z) - : new THREE.Vector3(key.x, key.y, key.z); - - if (!after) { - if (isTranslation && !(before.flags & 0x01)) { - // Check if vertex is non-zero (matching reference behavior) - if (Math.abs(before.x) < 1e-5 && Math.abs(before.y) < 1e-5 && Math.abs(before.z) < 1e-5) { - return null; - } - } - return toVec(before); - } - - if (isTranslation && !(before.flags & 0x01) && !(after.flags & 0x01)) { - // Both inactive — check if vertices are non-zero - const bNonZero = Math.abs(before.x) > 1e-5 || Math.abs(before.y) > 1e-5 || Math.abs(before.z) > 1e-5; - const aNonZero = Math.abs(after.x) > 1e-5 || Math.abs(after.y) > 1e-5 || Math.abs(after.z) > 1e-5; - if (!bNonZero && !aNonZero) return null; - } - - const t = (time - before.time) / (after.time - before.time); - return new THREE.Vector3().lerpVectors(toVec(before), toVec(after), t); - } - - /** - * Find the keyframes immediately before and after the given time. - */ - getBeforeAndAfter(keys, time) { - let idx = keys.findIndex(k => k.time > time); - if (idx < 0) idx = keys.length; - const before = keys[Math.max(0, idx - 1)]; - return { before, after: keys[idx] || null }; - } - /** * Evaluate visibility from morph keys at a given time. * Matches game's GetVisibility: returns true (visible) by default, @@ -859,45 +739,12 @@ export class ActorRenderer extends BaseRenderer { } } - stopAnimation() { - if (this.currentAction) { - this.currentAction.stop(); - this.currentAction = null; - } - if (this.mixer) { - this.mixer.stopAllAction(); - this.mixer = null; - } - } - // ─── Scene Management ──────────────────────────────────────────── clearModel() { - this.stopAnimation(); super.clearModel(); this.partGroups = []; this.vehicleGroup = null; this.vehicleInfo = null; } - - start() { - this.animating = true; - this.clock.start(); - this.animate(); - } - - updateAnimation() { - const delta = this.clock.getDelta(); - - if (this.mixer) { - this.mixer.update(delta); - } - this.controls?.update(); - } - - dispose() { - this.stopAnimation(); - super.dispose(); - this.animationCache.clear(); - } } diff --git a/src/core/rendering/AnimatedRenderer.js b/src/core/rendering/AnimatedRenderer.js new file mode 100644 index 0000000..a26e3d1 --- /dev/null +++ b/src/core/rendering/AnimatedRenderer.js @@ -0,0 +1,167 @@ +import * as THREE from 'three'; +import { parseAnimation } from '../formats/AnimationParser.js'; +import { fetchAnimation } from '../assetLoader.js'; +import { BaseRenderer } from './BaseRenderer.js'; + +/** + * Intermediate renderer for LEGO models with animation support. + * Extends BaseRenderer with clock-driven animation loop, AnimationMixer + * management, animation caching, raycasting, and shared keyframe utilities. + */ +export class AnimatedRenderer extends BaseRenderer { + constructor(canvas) { + super(canvas); + this.clock = new THREE.Clock(); + this.mixer = null; + this.currentAction = null; + this.animationCache = new Map(); + this.raycaster = new THREE.Raycaster(); + } + + // ─── Animation Utilities ───────────────────────────────────────── + + /** + * Fetch and parse an animation file by name, with caching. + */ + async fetchAnimationByName(animName) { + if (this.animationCache.has(animName)) { + return this.animationCache.get(animName); + } + const buffer = await fetchAnimation(animName); + if (!buffer) return null; + const animData = parseAnimation(buffer); + this.animationCache.set(animName, animData); + return animData; + } + + stopAnimation() { + if (this.currentAction) { + this.currentAction.stop(); + this.currentAction = null; + } + if (this.mixer) { + this.mixer.stopAllAction(); + this.mixer = null; + } + } + + /** + * Recursively collect all unique keyframe times from the animation tree. + */ + collectKeyframeTimes(node, timesSet) { + const data = node.data; + for (const key of data.translationKeys) timesSet.add(key.time); + for (const key of data.rotationKeys) timesSet.add(key.time); + for (const key of data.scaleKeys) timesSet.add(key.time); + for (const key of data.morphKeys) timesSet.add(key.time); + for (const child of node.children) { + this.collectKeyframeTimes(child, timesSet); + } + } + + /** + * Evaluate rotation keyframes at a given time. + * Handles slerp interpolation between keyframes with flag-based control. + * Coordinate conversion: game (w,x,y,z) -> Three.js with X negated. + */ + evaluateRotation(keys, time) { + const { before, after } = this.getBeforeAndAfter(keys, time); + const toQuat = (key) => new THREE.Quaternion(-key.x, key.y, key.z, key.w); + + if (!after) { + if (before.flags & 0x01) { + return new THREE.Matrix4().makeRotationFromQuaternion(toQuat(before)); + } + return new THREE.Matrix4(); + } + + if ((before.flags & 0x01) || (after.flags & 0x01)) { + const beforeQ = toQuat(before); + + // Flag 0x04: skip interpolation, use before value + if (after.flags & 0x04) { + return new THREE.Matrix4().makeRotationFromQuaternion(beforeQ); + } + + let afterQ = toQuat(after); + // Flag 0x02: negate the after quaternion before slerp + if (after.flags & 0x02) { + afterQ.set(-afterQ.x, -afterQ.y, -afterQ.z, -afterQ.w); + } + + const t = (time - before.time) / (after.time - before.time); + const result = new THREE.Quaternion().slerpQuaternions(beforeQ, afterQ, t); + return new THREE.Matrix4().makeRotationFromQuaternion(result); + } + + return new THREE.Matrix4(); + } + + /** + * Interpolate translation or scale keyframes at a given time. + * For translation: negates X for coordinate system conversion. + * For scale: no negation. + */ + interpolateVertex(keys, time, isTranslation) { + const { before, after } = this.getBeforeAndAfter(keys, time); + + const toVec = (key) => isTranslation + ? new THREE.Vector3(-key.x, key.y, key.z) + : new THREE.Vector3(key.x, key.y, key.z); + + if (!after) { + if (isTranslation && !(before.flags & 0x01)) { + if (Math.abs(before.x) < 1e-5 && Math.abs(before.y) < 1e-5 && Math.abs(before.z) < 1e-5) { + return null; + } + } + return toVec(before); + } + + if (isTranslation && !(before.flags & 0x01) && !(after.flags & 0x01)) { + const bNonZero = Math.abs(before.x) > 1e-5 || Math.abs(before.y) > 1e-5 || Math.abs(before.z) > 1e-5; + const aNonZero = Math.abs(after.x) > 1e-5 || Math.abs(after.y) > 1e-5 || Math.abs(after.z) > 1e-5; + if (!bNonZero && !aNonZero) return null; + } + + const t = (time - before.time) / (after.time - before.time); + return new THREE.Vector3().lerpVectors(toVec(before), toVec(after), t); + } + + /** + * Find the keyframes immediately before and after the given time. + */ + getBeforeAndAfter(keys, time) { + let idx = keys.findIndex(k => k.time > time); + if (idx < 0) idx = keys.length; + const before = keys[Math.max(0, idx - 1)]; + return { before, after: keys[idx] || null }; + } + + // ─── Scene Management ──────────────────────────────────────────── + + clearModel() { + this.stopAnimation(); + super.clearModel(); + } + + start() { + this.animating = true; + this.clock.start(); + this.animate(); + } + + updateAnimation() { + const delta = this.clock.getDelta(); + if (this.mixer) { + this.mixer.update(delta); + } + this.controls?.update(); + } + + dispose() { + this.stopAnimation(); + super.dispose(); + this.animationCache.clear(); + } +} diff --git a/src/core/rendering/PlantRenderer.js b/src/core/rendering/PlantRenderer.js index 488571e..0ac3c0a 100644 --- a/src/core/rendering/PlantRenderer.js +++ b/src/core/rendering/PlantRenderer.js @@ -1,9 +1,7 @@ import * as THREE from 'three'; import { PlantLodNames } from '../savegame/plantConstants.js'; import { LegoColors } from '../savegame/constants.js'; -import { parseAnimation } from '../formats/AnimationParser.js'; -import { fetchAnimation } from '../assetLoader.js'; -import { BaseRenderer } from './BaseRenderer.js'; +import { AnimatedRenderer } from './AnimatedRenderer.js'; // Plant color → LEGO color mapping for fallback materials const PLANT_COLOR_MAP = ['lego white', 'lego black', 'lego yellow', 'lego red', 'lego green']; @@ -24,21 +22,15 @@ const VARIANT_DISPLAY = [ * Renderer for LEGO Island plants. Much simpler than ActorRenderer — * single model group, no multi-part assembly. */ -export class PlantRenderer extends BaseRenderer { +export class PlantRenderer extends AnimatedRenderer { constructor(canvas) { super(canvas); - this.clock = new THREE.Clock(); - this.mixer = null; - this.currentAction = null; - this.animationCache = new Map(); this._queuedClickAnim = null; this.camera.position.set(1.5, 1.2, 2.5); this.camera.lookAt(0, 0.2, 0); this.setupControls(new THREE.Vector3(0, 0.2, 0)); - - this.raycaster = new THREE.Raycaster(); } /** @@ -59,7 +51,6 @@ export class PlantRenderer extends BaseRenderer { if (!partData) return; // Build texture lookup - this.textures.clear(); if (textures) { for (const tex of textures) { if (tex.name) { @@ -272,7 +263,7 @@ export class PlantRenderer extends BaseRenderer { let mat = new THREE.Matrix4(); if (data.scaleKeys.length > 0) { - const scale = this.interpolateVertex(data.scaleKeys, time); + const scale = this.interpolateVertex(data.scaleKeys, time, false); if (scale) mat.scale(scale); } @@ -282,7 +273,7 @@ export class PlantRenderer extends BaseRenderer { } if (data.translationKeys.length > 0) { - const vertex = this.interpolateVertex(data.translationKeys, time); + const vertex = this.interpolateVertex(data.translationKeys, time, true); if (vertex) { mat.elements[12] += vertex.x; mat.elements[13] += vertex.y; @@ -292,108 +283,4 @@ export class PlantRenderer extends BaseRenderer { return mat; } - - collectKeyframeTimes(node, timesSet) { - const data = node.data; - for (const key of data.translationKeys) timesSet.add(key.time); - for (const key of data.rotationKeys) timesSet.add(key.time); - for (const key of data.scaleKeys) timesSet.add(key.time); - for (const child of node.children) { - this.collectKeyframeTimes(child, timesSet); - } - } - - evaluateRotation(keys, time) { - const { before, after } = this.getBeforeAndAfter(keys, time); - const toQuat = (key) => new THREE.Quaternion(-key.x, key.y, key.z, key.w); - - if (!after) { - if (before.flags & 0x01) { - return new THREE.Matrix4().makeRotationFromQuaternion(toQuat(before)); - } - return new THREE.Matrix4(); - } - - if ((before.flags & 0x01) || (after.flags & 0x01)) { - const beforeQ = toQuat(before); - if (after.flags & 0x04) { - return new THREE.Matrix4().makeRotationFromQuaternion(beforeQ); - } - let afterQ = toQuat(after); - if (after.flags & 0x02) { - afterQ.set(-afterQ.x, -afterQ.y, -afterQ.z, -afterQ.w); - } - const t = (time - before.time) / (after.time - before.time); - const result = new THREE.Quaternion().slerpQuaternions(beforeQ, afterQ, t); - return new THREE.Matrix4().makeRotationFromQuaternion(result); - } - - return new THREE.Matrix4(); - } - - interpolateVertex(keys, time) { - const { before, after } = this.getBeforeAndAfter(keys, time); - const toVec = (key) => new THREE.Vector3(-key.x, key.y, key.z); - - if (!after) return toVec(before); - - const t = (time - before.time) / (after.time - before.time); - return new THREE.Vector3().lerpVectors(toVec(before), toVec(after), t); - } - - getBeforeAndAfter(keys, time) { - let idx = keys.findIndex(k => k.time > time); - if (idx < 0) idx = keys.length; - const before = keys[Math.max(0, idx - 1)]; - return { before, after: keys[idx] || null }; - } - - async fetchAnimationByName(animName) { - if (this.animationCache.has(animName)) { - return this.animationCache.get(animName); - } - const buffer = await fetchAnimation(animName); - if (!buffer) return null; - const animData = parseAnimation(buffer); - this.animationCache.set(animName, animData); - return animData; - } - - stopAnimation() { - if (this.currentAction) { - this.currentAction.stop(); - this.currentAction = null; - } - if (this.mixer) { - this.mixer.stopAllAction(); - this.mixer = null; - } - } - - // ─── Scene Management ──────────────────────────────────────────── - - clearModel() { - this.stopAnimation(); - super.clearModel(); - } - - start() { - this.animating = true; - this.clock.start(); - this.animate(); - } - - updateAnimation() { - const delta = this.clock.getDelta(); - if (this.mixer) { - this.mixer.update(delta); - } - this.controls?.update(); - } - - dispose() { - this.stopAnimation(); - super.dispose(); - this.animationCache.clear(); - } } diff --git a/src/core/rendering/VehiclePartRenderer.js b/src/core/rendering/VehiclePartRenderer.js index 36f4293..39283d9 100644 --- a/src/core/rendering/VehiclePartRenderer.js +++ b/src/core/rendering/VehiclePartRenderer.js @@ -43,7 +43,6 @@ export class VehiclePartRenderer extends BaseRenderer { this.partsMap = partsMap; // Build texture lookup map (case-insensitive) - this.textures.clear(); for (const tex of textureList) { if (tex.name) { this.textures.set(tex.name.toLowerCase(), this.createTexture(tex)); diff --git a/src/core/savegame/plantConstants.js b/src/core/savegame/plantConstants.js index 1cec2d5..57c2945 100644 --- a/src/core/savegame/plantConstants.js +++ b/src/core/savegame/plantConstants.js @@ -53,9 +53,6 @@ export const MAX_MOOD = 4; export const MAX_COLOR = 5; export const MAX_VARIANT = 4; -// g_plantAnimationId[4] — base objectId for animations per variant -export const PLANT_ANIM_IDS = Object.freeze([30, 33, 36, 39]); - // g_plantSoundIdOffset — base objectId for click sounds (actual = sound + 56) export const PLANT_SOUND_OFFSET = 56; diff --git a/src/lib/save-editor/ActorEditor.svelte b/src/lib/save-editor/ActorEditor.svelte index 40804f7..a943fe6 100644 --- a/src/lib/save-editor/ActorEditor.svelte +++ b/src/lib/save-editor/ActorEditor.svelte @@ -4,7 +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 { createSoundPlayer } from '../../core/audio.js'; import NavButton from '../NavButton.svelte'; import ResetButton from '../ResetButton.svelte'; import EditorTooltip from '../EditorTooltip.svelte'; @@ -27,38 +27,7 @@ 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); - } - } + const soundPlayer = createSoundPlayer(); $: actorInfo = ActorInfoInit[actorIndex]; $: actorName = ActorDisplayNames[actorIndex] || actorInfo?.name || 'Unknown'; @@ -166,7 +135,7 @@ onDestroy(() => { renderer?.dispose(); - audioContext?.close(); + soundPlayer.dispose(); }); // Reload actor when index, character state, or vehicle toggle changes @@ -223,11 +192,11 @@ const soundIdx = playerId === Actor.MAMA ? (charState.sound + 1) % 9 : charState.sound; - playSound(`ClickSound${soundIdx}`); + soundPlayer.play(`ClickSound${soundIdx}`); // Laura additionally plays a mood sound if (playerId === Actor.LAURA) { - playSound(`MoodSound${(charState.mood + 1) % 4}`); + soundPlayer.play(`MoodSound${(charState.mood + 1) % 4}`); } // Queue click animation — consumed by loadAnimationForActor diff --git a/src/lib/save-editor/PlantEditor.svelte b/src/lib/save-editor/PlantEditor.svelte index d7e6196..ed57acf 100644 --- a/src/lib/save-editor/PlantEditor.svelte +++ b/src/lib/save-editor/PlantEditor.svelte @@ -3,12 +3,12 @@ import { PlantRenderer } from '../../core/rendering/PlantRenderer.js'; import { WdbParser, buildGlobalPartsMap, buildPartsMap } from '../../core/formats/WdbParser.js'; import { - PlantInfoInit, PlantLodNames, PlantVariantNames, PlantColorNames, + PlantInfoInit, PlantVariantNames, PlantColorNames, PLANT_COUNT, MAX_SOUND, MAX_MOVE, MAX_MOOD, MAX_COLOR, MAX_VARIANT, PLANT_SOUND_OFFSET } from '../../core/savegame/plantConstants.js'; import { Actor } from '../../core/savegame/constants.js'; - import { fetchSoundAsWav } from '../../core/assetLoader.js'; + import { createSoundPlayer } from '../../core/audio.js'; import NavButton from '../NavButton.svelte'; import ResetButton from '../ResetButton.svelte'; import EditorTooltip from '../EditorTooltip.svelte'; @@ -28,38 +28,7 @@ let plantIndex = 0; let loadedPlantKey = null; - 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); - } - } + const soundPlayer = createSoundPlayer(); $: plantState = slot?.plants?.[plantIndex]; $: variantName = plantState ? PlantVariantNames[plantState.variant] || 'Unknown' : ''; @@ -137,7 +106,7 @@ onDestroy(() => { renderer?.dispose(); - audioContext?.close(); + soundPlayer.dispose(); }); // Reload plant when index or state changes @@ -191,14 +160,14 @@ const soundObjectId = soundIdx + PLANT_SOUND_OFFSET; // ClickSound6/7/8 cover objectIds 56-58, PlantSound3-7 cover 59-63 if (soundObjectId <= 58) { - playSound(`ClickSound${soundObjectId - 50}`); + soundPlayer.play(`ClickSound${soundObjectId - 50}`); } else { - playSound(`PlantSound${soundIdx}`); + soundPlayer.play(`PlantSound${soundIdx}`); } // Laura additionally plays a mood sound if (playerId === Actor.LAURA) { - playSound(`MoodSound${((plantState.mood + 1) % MAX_MOOD) & 1}`); + soundPlayer.play(`MoodSound${((plantState.mood + 1) % MAX_MOOD) & 1}`); } // Queue click animation for visual changes