Refactor shared animation code into AnimatedRenderer base class

Extract duplicated animation infrastructure (clock, mixer, animation
caching, raycaster, keyframe interpolation) from ActorRenderer and
PlantRenderer into a new AnimatedRenderer intermediate class. Extract
identical sound player code from both editors into createSoundPlayer()
utility. Fix PlantRenderer interpolateVertex bug where scale keys had
X incorrectly negated. Remove dead PLANT_ANIM_IDS export and redundant
textures.clear() calls.
This commit is contained in:
Christian Semmler 2026-02-14 08:48:54 -08:00
parent 00e3e587e4
commit 02949aab96
No known key found for this signature in database
GPG Key ID: 086DAA1360BEEE5C
8 changed files with 237 additions and 351 deletions

View File

@ -1,5 +1,6 @@
// Audio utilities for install-audio element // Audio utilities
import { soundEnabled } from '../stores.js'; import { soundEnabled } from '../stores.js';
import { fetchSoundAsWav } from './assetLoader.js';
export function getInstallAudio() { export function getInstallAudio() {
return document.getElementById('install-audio'); return document.getElementById('install-audio');
@ -38,3 +39,53 @@ export function toggleInstallAudio() {
pauseInstallAudio(); 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<void>, 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 };
}

View File

@ -1,9 +1,7 @@
import * as THREE from 'three'; import * as THREE from 'three';
import { ActorLODs, ActorLODFlags, ActorInfoInit } from '../savegame/actorConstants.js'; import { ActorLODs, ActorLODFlags, ActorInfoInit } from '../savegame/actorConstants.js';
import { LegoColors } from '../savegame/constants.js'; import { LegoColors } from '../savegame/constants.js';
import { parseAnimation } from '../formats/AnimationParser.js'; import { AnimatedRenderer } from './AnimatedRenderer.js';
import { fetchAnimation } from '../assetLoader.js';
import { BaseRenderer } from './BaseRenderer.js';
/** /**
* Map actor index to animation suffix index (from g_characters[].m_unk0x16). * 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. * Renderer for full LEGO characters assembled from WDB global parts.
* Mirrors the game's LegoCharacterManager::CreateActorROI logic. * Mirrors the game's LegoCharacterManager::CreateActorROI logic.
*/ */
export class ActorRenderer extends BaseRenderer { export class ActorRenderer extends AnimatedRenderer {
constructor(canvas) { constructor(canvas) {
super(canvas); super(canvas);
this.partGroups = []; // 10 part groups for click targeting 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._queuedClickMove = null; // queued click animation move index (0-3)
this.camera.position.set(2, 0.8, 3.5); 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.setupControls(new THREE.Vector3(0, 0.2, 0));
this.controls.autoRotate = false; this.controls.autoRotate = false;
this._initialAutoRotate = false; this._initialAutoRotate = false;
this.raycaster = new THREE.Raycaster();
} }
/** /**
@ -118,7 +110,6 @@ export class ActorRenderer extends BaseRenderer {
const charState = characters[actorIndex]; const charState = characters[actorIndex];
// Build texture lookup // Build texture lookup
this.textures.clear();
for (const tex of globalTextures) { for (const tex of globalTextures) {
if (tex.name) { if (tex.name) {
this.textures.set(tex.name.toLowerCase(), this.createTexture(tex)); 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 * Build world-space keyframe tracks by evaluating the animation tree
* hierarchically. At each unique keyframe time, walks the tree composing * hierarchically. At each unique keyframe time, walks the tree composing
@ -655,20 +631,6 @@ export class ActorRenderer extends BaseRenderer {
return tracks; 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 * 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 * 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. * Evaluate visibility from morph keys at a given time.
* Matches game's GetVisibility: returns true (visible) by default, * 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 ──────────────────────────────────────────── // ─── Scene Management ────────────────────────────────────────────
clearModel() { clearModel() {
this.stopAnimation();
super.clearModel(); super.clearModel();
this.partGroups = []; this.partGroups = [];
this.vehicleGroup = null; this.vehicleGroup = null;
this.vehicleInfo = 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();
}
} }

View File

@ -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();
}
}

View File

@ -1,9 +1,7 @@
import * as THREE from 'three'; import * as THREE from 'three';
import { PlantLodNames } from '../savegame/plantConstants.js'; import { PlantLodNames } from '../savegame/plantConstants.js';
import { LegoColors } from '../savegame/constants.js'; import { LegoColors } from '../savegame/constants.js';
import { parseAnimation } from '../formats/AnimationParser.js'; import { AnimatedRenderer } from './AnimatedRenderer.js';
import { fetchAnimation } from '../assetLoader.js';
import { BaseRenderer } from './BaseRenderer.js';
// Plant color → LEGO color mapping for fallback materials // Plant color → LEGO color mapping for fallback materials
const PLANT_COLOR_MAP = ['lego white', 'lego black', 'lego yellow', 'lego red', 'lego green']; 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 * Renderer for LEGO Island plants. Much simpler than ActorRenderer
* single model group, no multi-part assembly. * single model group, no multi-part assembly.
*/ */
export class PlantRenderer extends BaseRenderer { export class PlantRenderer extends AnimatedRenderer {
constructor(canvas) { constructor(canvas) {
super(canvas); super(canvas);
this.clock = new THREE.Clock();
this.mixer = null;
this.currentAction = null;
this.animationCache = new Map();
this._queuedClickAnim = null; this._queuedClickAnim = null;
this.camera.position.set(1.5, 1.2, 2.5); this.camera.position.set(1.5, 1.2, 2.5);
this.camera.lookAt(0, 0.2, 0); this.camera.lookAt(0, 0.2, 0);
this.setupControls(new THREE.Vector3(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; if (!partData) return;
// Build texture lookup // Build texture lookup
this.textures.clear();
if (textures) { if (textures) {
for (const tex of textures) { for (const tex of textures) {
if (tex.name) { if (tex.name) {
@ -272,7 +263,7 @@ export class PlantRenderer extends BaseRenderer {
let mat = new THREE.Matrix4(); let mat = new THREE.Matrix4();
if (data.scaleKeys.length > 0) { 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); if (scale) mat.scale(scale);
} }
@ -282,7 +273,7 @@ export class PlantRenderer extends BaseRenderer {
} }
if (data.translationKeys.length > 0) { if (data.translationKeys.length > 0) {
const vertex = this.interpolateVertex(data.translationKeys, time); const vertex = this.interpolateVertex(data.translationKeys, time, true);
if (vertex) { if (vertex) {
mat.elements[12] += vertex.x; mat.elements[12] += vertex.x;
mat.elements[13] += vertex.y; mat.elements[13] += vertex.y;
@ -292,108 +283,4 @@ export class PlantRenderer extends BaseRenderer {
return mat; 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();
}
} }

View File

@ -43,7 +43,6 @@ export class VehiclePartRenderer extends BaseRenderer {
this.partsMap = partsMap; this.partsMap = partsMap;
// Build texture lookup map (case-insensitive) // Build texture lookup map (case-insensitive)
this.textures.clear();
for (const tex of textureList) { for (const tex of textureList) {
if (tex.name) { if (tex.name) {
this.textures.set(tex.name.toLowerCase(), this.createTexture(tex)); this.textures.set(tex.name.toLowerCase(), this.createTexture(tex));

View File

@ -53,9 +53,6 @@ export const MAX_MOOD = 4;
export const MAX_COLOR = 5; export const MAX_COLOR = 5;
export const MAX_VARIANT = 4; 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) // g_plantSoundIdOffset — base objectId for click sounds (actual = sound + 56)
export const PLANT_SOUND_OFFSET = 56; export const PLANT_SOUND_OFFSET = 56;

View File

@ -4,7 +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 { createSoundPlayer } from '../../core/audio.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';
@ -27,38 +27,7 @@
let loadedActorKey = null; let loadedActorKey = null;
let showVehicle = false; let showVehicle = false;
let audioContext = null; const soundPlayer = createSoundPlayer();
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';
@ -166,7 +135,7 @@
onDestroy(() => { onDestroy(() => {
renderer?.dispose(); renderer?.dispose();
audioContext?.close(); soundPlayer.dispose();
}); });
// Reload actor when index, character state, or vehicle toggle changes // Reload actor when index, character state, or vehicle toggle changes
@ -223,11 +192,11 @@
const soundIdx = playerId === Actor.MAMA const soundIdx = playerId === Actor.MAMA
? (charState.sound + 1) % 9 ? (charState.sound + 1) % 9
: charState.sound; : charState.sound;
playSound(`ClickSound${soundIdx}`); soundPlayer.play(`ClickSound${soundIdx}`);
// Laura additionally plays a mood sound // Laura additionally plays a mood sound
if (playerId === Actor.LAURA) { if (playerId === Actor.LAURA) {
playSound(`MoodSound${(charState.mood + 1) % 4}`); soundPlayer.play(`MoodSound${(charState.mood + 1) % 4}`);
} }
// Queue click animation — consumed by loadAnimationForActor // Queue click animation — consumed by loadAnimationForActor

View File

@ -3,12 +3,12 @@
import { PlantRenderer } from '../../core/rendering/PlantRenderer.js'; import { PlantRenderer } from '../../core/rendering/PlantRenderer.js';
import { WdbParser, buildGlobalPartsMap, buildPartsMap } from '../../core/formats/WdbParser.js'; import { WdbParser, buildGlobalPartsMap, buildPartsMap } from '../../core/formats/WdbParser.js';
import { import {
PlantInfoInit, PlantLodNames, PlantVariantNames, PlantColorNames, PlantInfoInit, PlantVariantNames, PlantColorNames,
PLANT_COUNT, MAX_SOUND, MAX_MOVE, MAX_MOOD, MAX_COLOR, MAX_VARIANT, PLANT_COUNT, MAX_SOUND, MAX_MOVE, MAX_MOOD, MAX_COLOR, MAX_VARIANT,
PLANT_SOUND_OFFSET PLANT_SOUND_OFFSET
} from '../../core/savegame/plantConstants.js'; } from '../../core/savegame/plantConstants.js';
import { Actor } from '../../core/savegame/constants.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 NavButton from '../NavButton.svelte';
import ResetButton from '../ResetButton.svelte'; import ResetButton from '../ResetButton.svelte';
import EditorTooltip from '../EditorTooltip.svelte'; import EditorTooltip from '../EditorTooltip.svelte';
@ -28,38 +28,7 @@
let plantIndex = 0; let plantIndex = 0;
let loadedPlantKey = null; let loadedPlantKey = null;
let audioContext = null; const soundPlayer = createSoundPlayer();
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);
}
}
$: plantState = slot?.plants?.[plantIndex]; $: plantState = slot?.plants?.[plantIndex];
$: variantName = plantState ? PlantVariantNames[plantState.variant] || 'Unknown' : ''; $: variantName = plantState ? PlantVariantNames[plantState.variant] || 'Unknown' : '';
@ -137,7 +106,7 @@
onDestroy(() => { onDestroy(() => {
renderer?.dispose(); renderer?.dispose();
audioContext?.close(); soundPlayer.dispose();
}); });
// Reload plant when index or state changes // Reload plant when index or state changes
@ -191,14 +160,14 @@
const soundObjectId = soundIdx + PLANT_SOUND_OFFSET; const soundObjectId = soundIdx + PLANT_SOUND_OFFSET;
// ClickSound6/7/8 cover objectIds 56-58, PlantSound3-7 cover 59-63 // ClickSound6/7/8 cover objectIds 56-58, PlantSound3-7 cover 59-63
if (soundObjectId <= 58) { if (soundObjectId <= 58) {
playSound(`ClickSound${soundObjectId - 50}`); soundPlayer.play(`ClickSound${soundObjectId - 50}`);
} else { } else {
playSound(`PlantSound${soundIdx}`); soundPlayer.play(`PlantSound${soundIdx}`);
} }
// Laura additionally plays a mood sound // Laura additionally plays a mood sound
if (playerId === Actor.LAURA) { 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 // Queue click animation for visual changes