mirror of
https://github.com/isledecomp/isle.pizza.git
synced 2026-03-01 06:17:38 +00:00
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:
parent
00e3e587e4
commit
02949aab96
@ -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 };
|
||||||
|
}
|
||||||
|
|||||||
@ -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();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
167
src/core/rendering/AnimatedRenderer.js
Normal file
167
src/core/rendering/AnimatedRenderer.js
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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));
|
||||||
|
|||||||
@ -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;
|
||||||
|
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user