From 1d18779689ca60ea712868ce5c6ee709d0134b02 Mon Sep 17 00:00:00 2001 From: Christian Semmler Date: Sat, 14 Feb 2026 09:22:28 -0800 Subject: [PATCH] Plant editor (#25) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Plant editor Add Plants tab to the save editor for browsing and editing all 81 plants. Click-to-customize based on selected character matches the original game behavior (Pepper→variant, Mama→sound, Papa→move, Nick→color, Laura→mood). Includes 3D preview with per-variant display tuning, click animations, sound playback, and reset to defaults. * 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. * Extract shared editor CSS and fix vehicle nav spacing Move duplicated preview, spinner, navigation, and side-button styles from VehicleEditor, ActorEditor, and PlantEditor into a shared editor-common.css. Standardize class names (nav-index, nav-name, side-btn) and fix VehicleEditor part-info min-width (100px → 150px) to match the other editors. * Add carousel tabs and selection-based nav to save editor Wrap save editor tab buttons in a Carousel to prevent overflow on desktop. Carousel nav buttons now cycle through the selected item (save slot or tab) instead of scrolling, with auto-scroll-into-view. On mobile, tabs reflow with flex-wrap as before. * Update February changelog with plant editor and carousel navigation --- scripts/generate-save-editor-assets.js | 49 +++- src/app.css | 2 + src/core/audio.js | 53 +++- src/core/formats/SaveGameParser.js | 23 +- src/core/formats/SaveGameSerializer.js | 24 ++ src/core/rendering/ActorRenderer.js | 157 +----------- src/core/rendering/AnimatedRenderer.js | 167 +++++++++++++ src/core/rendering/PlantRenderer.js | 286 +++++++++++++++++++++ src/core/rendering/VehiclePartRenderer.js | 1 - src/core/savegame/index.js | 19 ++ src/core/savegame/plantConstants.js | 146 +++++++++++ src/lib/Carousel.svelte | 67 +++-- src/lib/ReadMePage.svelte | 6 +- src/lib/SaveEditorPage.svelte | 113 ++++++++- src/lib/save-editor/ActorEditor.svelte | 172 +------------ src/lib/save-editor/PlantEditor.svelte | 289 ++++++++++++++++++++++ src/lib/save-editor/VehicleEditor.svelte | 131 +--------- src/lib/save-editor/editor-common.css | 129 ++++++++++ 18 files changed, 1344 insertions(+), 490 deletions(-) create mode 100644 src/core/rendering/AnimatedRenderer.js create mode 100644 src/core/rendering/PlantRenderer.js create mode 100644 src/core/savegame/plantConstants.js create mode 100644 src/lib/save-editor/PlantEditor.svelte create mode 100644 src/lib/save-editor/editor-common.css diff --git a/scripts/generate-save-editor-assets.js b/scripts/generate-save-editor-assets.js index 2f5b932..d8b2a85 100644 --- a/scripts/generate-save-editor-assets.js +++ b/scripts/generate-save-editor-assets.js @@ -128,6 +128,33 @@ const CLICK_SOUNDS = [ ['ClickSound8', 58, 7344, '7bbc41251b750835989cb3b35c8546a4'], ]; +// Plant animations from SNDANIM.SI (objectId = g_plantAnimationId[variant] + move) +// [name, objectId, size, md5] +const PLANT_ANIMATIONS = [ + ['PlantAnimF0', 30, 911, 'cbc2f4d870099238a79130268e48f981'], + ['PlantAnimF1', 31, 539, '1df6d082935ffa780f5867d0018870a1'], + ['PlantAnimF2', 32, 451, 'f541f69207d849179704c55956bbf883'], + ['PlantAnimT0', 33, 1719, 'ac41608766049001502a70f655cdf731'], + ['PlantAnimT1', 34, 1022, '778c0c7fb646d85a2e056f430f21562f'], + ['PlantAnimT2', 35, 794, '89f16250457fdd3a732fdd6030d92e2c'], + ['PlantAnimB0', 36, 1066, 'c00ca3e2566846d94ce75ff7700f5a5b'], + ['PlantAnimB1', 37, 850, '97d86074a3aa606e1fe3f3bd01690ae7'], + ['PlantAnimB2', 38, 502, '7f08bf6093478c653ff82d058d86f900'], + ['PlantAnimP0', 39, 978, '4f9af3721ba3a49e478da5566a4923de'], + ['PlantAnimP1', 40, 682, '41d0ca14af41cc4cd7f737d7b0e74ef2'], + ['PlantAnimP2', 41, 294, '5ddaff70e2b57fdb294769eaa14e42a0'], +]; + +// Plant sounds from SNDANIM.SI (objectId = sound + 56, sounds 3-7) +// [name, objectId, size, md5] +const PLANT_SOUNDS = [ + ['PlantSound3', 59, 12184, '31a837c2420056e0a4f431d06801e746'], + ['PlantSound4', 60, 10409, 'd8e8eb75668c57fcb45ba7a75e4612e5'], + ['PlantSound5', 61, 12107, 'd60acd5c0962e15cc7c25de95553357f'], + ['PlantSound6', 62, 15900, 'acfba6e91b047a43b673b0e2087bd3f5'], + ['PlantSound7', 63, 11545, '53cfd93d7e81c85d5c39b4af624bc370'], +]; + // Mood sounds from SNDANIM.SI (objectId = m_mood + 66) // [name, objectId, size, md5] const MOOD_SOUNDS = [ @@ -324,7 +351,7 @@ async function main() { console.log(` ${clickFound}/${CLICK_ANIMATIONS.length} click animations found\n`); // --- Sounds (in SNDANIM.SI) --- - const allSounds = [...CLICK_SOUNDS, ...MOOD_SOUNDS]; + const allSounds = [...CLICK_SOUNDS, ...MOOD_SOUNDS, ...PLANT_SOUNDS]; const soundObjectIds = new Set(allSounds.map(([, objectId]) => objectId)); const soundRanges = findMxChByObjectId(sndanimSI, soundObjectIds); @@ -342,6 +369,24 @@ async function main() { } console.log(` ${soundFound}/${allSounds.length} sounds found\n`); + // --- Plant Animations (in SNDANIM.SI) --- + const plantAnimObjectIds = new Set(PLANT_ANIMATIONS.map(([, objectId]) => objectId)); + const plantAnimRanges = findMxChByObjectId(sndanimSI, plantAnimObjectIds); + + let plantAnimFound = 0; + for (const [name, objectId, size, expectedMd5] of PLANT_ANIMATIONS) { + const data = extractAndVerify(sndanimSI, plantAnimRanges.get(objectId), size, expectedMd5); + if (data) { + fragments.push({ type: 'animations', name, data }); + plantAnimFound++; + found++; + } else { + console.error(` FAILED: ${name} (objectId ${objectId})`); + failed++; + } + } + console.log(` ${plantAnimFound}/${PLANT_ANIMATIONS.length} plant animations found\n`); + // --- Textures (across Build SI files) --- const texBySI = new Map(); for (const entry of TEXTURES) { @@ -420,7 +465,7 @@ async function main() { await fs.writeFile(BIN_PATH, bundle); console.log(`Wrote ${BIN_PATH} (${(bundle.length / 1024).toFixed(1)} KB, ${Object.keys(index).length} entries)`); - console.log(`Total: ${found} assets (${ANIMATIONS.length} walking + ${CLICK_ANIMATIONS.length} click animations, ${allSounds.length} sounds, ${TEXTURES.length} textures, ${BITMAPS.length} bitmaps)`); + console.log(`Total: ${found} assets (${ANIMATIONS.length} walking + ${CLICK_ANIMATIONS.length} click + ${PLANT_ANIMATIONS.length} plant animations, ${allSounds.length} sounds, ${TEXTURES.length} textures, ${BITMAPS.length} bitmaps)`); } main().catch(err => { diff --git a/src/app.css b/src/app.css index e84f320..5b1152d 100644 --- a/src/app.css +++ b/src/app.css @@ -505,6 +505,8 @@ body { font-weight: bold; cursor: pointer; transition: all 0.2s ease; + white-space: nowrap; + flex-shrink: 0; } .config-tab-btn:hover { 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/formats/SaveGameParser.js b/src/core/formats/SaveGameParser.js index 494f219..37e2f4d 100644 --- a/src/core/formats/SaveGameParser.js +++ b/src/core/formats/SaveGameParser.js @@ -128,10 +128,25 @@ export class SaveGameParser { } /** - * Skip plant manager data (81 plants * 12 bytes = 972 bytes) + * Parse plant manager data (81 plants * 12 bytes = 972 bytes) + * Each plant: variant(U8) + sound(U32LE) + move(U32LE) + mood(U8) + color(U8) + counter(S8) */ - skipPlants() { - this.reader.skip(81 * 12); + parsePlants() { + this.parsed.plantsOffset = this.reader.tell(); + const plants = []; + + for (let i = 0; i < 81; i++) { + plants.push({ + variant: this.reader.readU8(), + sound: this.reader.readU32(), + move: this.reader.readU32(), + mood: this.reader.readU8(), + color: this.reader.readU8(), + counter: this.reader.readS8() + }); + } + + this.parsed.plants = plants; } /** @@ -425,7 +440,7 @@ export class SaveGameParser { this.parseHeader(); this.parseVariables(); this.parseCharacters(); - this.skipPlants(); + this.parsePlants(); this.skipBuildings(); this.parseGameStates(); diff --git a/src/core/formats/SaveGameSerializer.js b/src/core/formats/SaveGameSerializer.js index 5c0aa5c..eed0b0b 100644 --- a/src/core/formats/SaveGameSerializer.js +++ b/src/core/formats/SaveGameSerializer.js @@ -6,6 +6,7 @@ import { SaveGameParser } from './SaveGameParser.js'; import { BinaryWriter } from './BinaryWriter.js'; import { GameStateTypes, GameStateSizes, Actor, Act1TextureOrder } from '../savegame/constants.js'; import { CharacterFieldOffsets, CHARACTER_RECORD_SIZE } from '../savegame/actorConstants.js'; +import { PlantFieldOffsets, PLANT_RECORD_SIZE } from '../savegame/plantConstants.js'; /** * Offsets for header fields @@ -451,6 +452,29 @@ export class SaveGameSerializer { return workingBuffer; } + /** + * Update a plant field in the save file + * @param {number} plantIndex - Plant index (0-80) + * @param {string} field - Field name from PlantFieldOffsets + * @param {number} value - New value + * @returns {ArrayBuffer} - Modified buffer + */ + updatePlant(plantIndex, field, value) { + const workingBuffer = this.createCopy(); + const view = new DataView(workingBuffer); + const offset = this.parsed.plantsOffset + (plantIndex * PLANT_RECORD_SIZE) + PlantFieldOffsets[field]; + + if (field === 'sound' || field === 'move') { + view.setUint32(offset, value, true); + } else if (field === 'counter') { + view.setInt8(offset, value); + } else { + view.setUint8(offset, value); + } + + return workingBuffer; + } + /** * Get the byte offset for a mission score * @param {string} missionType 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 new file mode 100644 index 0000000..0ac3c0a --- /dev/null +++ b/src/core/rendering/PlantRenderer.js @@ -0,0 +1,286 @@ +import * as THREE from 'three'; +import { PlantLodNames } from '../savegame/plantConstants.js'; +import { LegoColors } from '../savegame/constants.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']; + +// Animation suffix per variant: flower→F, tree→T, bush→B, palm→P +const VARIANT_ANIM_SUFFIX = ['F', 'T', 'B', 'P']; + +// Per-variant display adjustments: [scaleFactor, yOffset] +// Flower is tall/wide → zoom out + shift down; others shift up to sit in frame +const VARIANT_DISPLAY = [ + [1.6, -0.1], // Flower: smaller, shifted slightly down + [1.8, 0.6], // Tree: slightly smaller, shifted up + [1.6, 1.4], // Bush: smaller, shifted well up + [2.0, 1.1], // Palm: shifted well up +]; + +/** + * Renderer for LEGO Island plants. Much simpler than ActorRenderer — + * single model group, no multi-part assembly. + */ +export class PlantRenderer extends AnimatedRenderer { + constructor(canvas) { + super(canvas); + 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)); + } + + /** + * Load a plant model. + * @param {number} variant - Plant variant (0-3) + * @param {number} color - Plant color (0-4) + * @param {Map} partsMap - Name→part lookup from WDB + * @param {Array} textures - Texture list from WDB + */ + loadPlant(variant, color, partsMap, textures) { + this.clearModel(); + + const lodName = PlantLodNames[variant]?.[color]; + if (!lodName) return; + + // Find the part data (case-insensitive) + const partData = partsMap.get(lodName.toLowerCase()); + if (!partData) return; + + // Build texture lookup + if (textures) { + for (const tex of textures) { + if (tex.name) { + this.textures.set(tex.name.toLowerCase(), this.createTexture(tex)); + } + } + } + + this.modelGroup = new THREE.Group(); + + const lods = partData.lods || []; + if (lods.length === 0) return; + + const lod = lods[lods.length - 1]; // Highest quality + for (const mesh of lod.meshes) { + const geometry = this.createGeometry(mesh, lod); + if (!geometry) continue; + + let material; + const meshTexName = mesh.properties?.textureName?.toLowerCase(); + if (meshTexName && this.textures.has(meshTexName)) { + material = new THREE.MeshLambertMaterial({ + map: this.textures.get(meshTexName), + side: THREE.DoubleSide, + color: 0xffffff + }); + } else { + const meshColor = mesh.properties?.color; + if (meshColor) { + material = new THREE.MeshLambertMaterial({ + color: new THREE.Color(meshColor.r / 255, meshColor.g / 255, meshColor.b / 255), + side: THREE.DoubleSide + }); + } else { + // Fallback to plant color + const colorName = PLANT_COLOR_MAP[color] || 'lego green'; + const colorEntry = LegoColors[colorName] || LegoColors['lego green']; + material = new THREE.MeshLambertMaterial({ + color: new THREE.Color(colorEntry.r / 255, colorEntry.g / 255, colorEntry.b / 255), + side: THREE.DoubleSide + }); + } + } + + this.modelGroup.add(new THREE.Mesh(geometry, material)); + } + + const [scaleFactor, yOffset] = VARIANT_DISPLAY[variant] || [2.0, 0]; + this.centerAndScaleModel(scaleFactor); + this.modelGroup.position.y += yOffset; + this.scene.add(this.modelGroup); + this.renderer.render(this.scene, this.camera); + } + + /** + * Check if the plant mesh was clicked. + * @returns {boolean} True if any mesh was hit + */ + getClickedMesh(mouseEvent) { + if (!this.modelGroup) return false; + + const rect = this.canvas.getBoundingClientRect(); + const mouse = new THREE.Vector2( + ((mouseEvent.clientX - rect.left) / rect.width) * 2 - 1, + -((mouseEvent.clientY - rect.top) / rect.height) * 2 + 1 + ); + + this.raycaster.setFromCamera(mouse, this.camera); + + const meshes = []; + this.modelGroup.traverse((child) => { + if (child instanceof THREE.Mesh) meshes.push(child); + }); + + return this.raycaster.intersectObjects(meshes).length > 0; + } + + // ─── Animation System ──────────────────────────────────────────── + + /** + * Queue a click animation to play. + * @param {number} variant - Plant variant (0-3) + * @param {number} move - The plant's move value + */ + queueClickAnimation(variant, move) { + this._queuedClickAnim = { variant, move }; + } + + /** + * Play a queued click animation if available. + * Called after model reload or directly for non-visual changes. + */ + async playQueuedAnimation() { + if (!this._queuedClickAnim || !this.modelGroup) return; + + const { variant, move } = this._queuedClickAnim; + this._queuedClickAnim = null; + + const suffix = VARIANT_ANIM_SUFFIX[variant]; + const animName = `PlantAnim${suffix}${move}`; + + try { + const animData = await this.fetchAnimationByName(animName); + if (!animData || !this.modelGroup) return; + + const tracks = this.buildPlantTracks(animData); + if (tracks.length === 0) return; + + this.stopAnimation(); + + const clip = new THREE.AnimationClip('plantClick', -1, tracks); + this.mixer = new THREE.AnimationMixer(this.modelGroup); + const action = this.mixer.clipAction(clip); + action.setLoop(THREE.LoopOnce); + action.clampWhenFinished = false; + this.currentAction = action; + action.play(); + + this.mixer.addEventListener('finished', () => { + this.stopAnimation(); + this.controls.autoRotate = true; + }); + } catch (e) { + // Animation unavailable — ignore + } + } + + /** + * Build animation tracks for a plant. Maps animation tree nodes + * to the model group (the entire plant is a single group). + */ + buildPlantTracks(animData) { + const duration = animData.duration; + const timesSet = new Set([0]); + this.collectKeyframeTimes(animData.rootNode, timesSet); + const times = [...timesSet].filter(t => t <= duration).sort((a, b) => a - b); + + // Find the deepest non-root node that has animation data — + // map it to our modelGroup + const plantNode = this.findPlantNode(animData.rootNode); + if (!plantNode) return []; + + const quatValues = []; + const timesSec = []; + + for (const time of times) { + const mat = this.evaluateNodeChain(animData.rootNode, plantNode, time); + const position = new THREE.Vector3(); + const quaternion = new THREE.Quaternion(); + const scale = new THREE.Vector3(); + mat.decompose(position, quaternion, scale); + + timesSec.push(time / 1000); + quatValues.push(quaternion.x, quaternion.y, quaternion.z, quaternion.w); + } + + // Only emit rotation tracks — position tracks would override the + // centering applied by centerAndScaleModel() since the animation + // uses the game's world-space coordinates. + return [ + new THREE.QuaternionKeyframeTrack('.quaternion', timesSec, quatValues) + ]; + } + + /** + * Find the first leaf/deepest node with animation data in the tree. + */ + findPlantNode(node) { + // Depth-first: prefer children + for (const child of node.children) { + const found = this.findPlantNode(child); + if (found) return found; + } + // If this node has actual keyframe data, use it + const d = node.data; + if (d.translationKeys.length > 0 || d.rotationKeys.length > 0 || d.scaleKeys.length > 0) { + return node; + } + return null; + } + + /** + * Evaluate the composed matrix from root down to targetNode at a given time. + */ + evaluateNodeChain(node, targetNode, time) { + const path = []; + if (!this.findPath(node, targetNode, path)) { + return new THREE.Matrix4(); + } + + let mat = new THREE.Matrix4(); + for (const n of path) { + const local = this.evaluateLocalTransform(n.data, time); + mat.multiply(local); + } + return mat; + } + + findPath(current, target, path) { + path.push(current); + if (current === target) return true; + for (const child of current.children) { + if (this.findPath(child, target, path)) return true; + } + path.pop(); + return false; + } + + evaluateLocalTransform(data, time) { + let mat = new THREE.Matrix4(); + + if (data.scaleKeys.length > 0) { + const scale = this.interpolateVertex(data.scaleKeys, time, false); + if (scale) mat.scale(scale); + } + + if (data.rotationKeys.length > 0) { + const rotMat = this.evaluateRotation(data.rotationKeys, time); + mat = rotMat.multiply(mat); + } + + if (data.translationKeys.length > 0) { + const vertex = this.interpolateVertex(data.translationKeys, time, true); + if (vertex) { + mat.elements[12] += vertex.x; + mat.elements[13] += vertex.y; + mat.elements[14] += vertex.z; + } + } + + return mat; + } +} 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/index.js b/src/core/savegame/index.js index 8dd334c..810e8f4 100644 --- a/src/core/savegame/index.js +++ b/src/core/savegame/index.js @@ -93,6 +93,8 @@ export async function listSaveSlots() { act1State: null, characters: null, charactersOffset: null, + plants: null, + plantsOffset: null, playerName: null, buffer: null }; @@ -108,6 +110,8 @@ export async function listSaveSlots() { slot.act1State = parsed.act1State || null; slot.characters = parsed.characters || null; slot.charactersOffset = parsed.charactersOffset || null; + slot.plants = parsed.plants || null; + slot.plantsOffset = parsed.plantsOffset || null; slot.buffer = buffer; // Try to get player name @@ -174,6 +178,8 @@ export async function loadSaveSlot(slotNumber) { act1State: parsed.act1State || null, characters: parsed.characters || null, charactersOffset: parsed.charactersOffset || null, + plants: parsed.plants || null, + plantsOffset: parsed.plantsOffset || null, playerName, buffer }; @@ -257,6 +263,19 @@ export async function updateSaveSlot(slotNumber, updates) { } } + // Apply plant update(s) + if (updates.plant) { + const entries = Array.isArray(updates.plant) ? updates.plant : [updates.plant]; + for (const { plantIndex, field, value } of entries) { + const plantSerializer = createSerializer(newBuffer); + const result = plantSerializer.updatePlant(plantIndex, field, value); + if (result) { + newBuffer = result; + modified = true; + } + } + } + // Apply texture update if (updates.texture) { const { textureName, textureData } = updates.texture; diff --git a/src/core/savegame/plantConstants.js b/src/core/savegame/plantConstants.js new file mode 100644 index 0000000..57c2945 --- /dev/null +++ b/src/core/savegame/plantConstants.js @@ -0,0 +1,146 @@ +/** + * Plant data constants ported from LEGO1 source: + * isle/LEGO1/lego/legoomni/src/common/legoplants.cpp + * isle/LEGO1/lego/legoomni/src/common/legoplantmanager.cpp + * isle/LEGO1/lego/legoomni/include/legoplants.h + */ + +// LegoPlantInfo::Variant enum +export const PlantVariant = Object.freeze({ + FLOWER: 0, + TREE: 1, + BUSH: 2, + PALM: 3 +}); + +// LegoPlantInfo::Color enum +export const PlantColor = Object.freeze({ + WHITE: 0, + BLACK: 1, + YELLOW: 2, + RED: 3, + GREEN: 4 +}); + +export const PlantVariantNames = Object.freeze(['Flower', 'Tree', 'Bush', 'Palm']); +export const PlantColorNames = Object.freeze(['White', 'Black', 'Yellow', 'Red', 'Green']); + +// g_plantLodNames[4][5] — LOD model name indexed by [variant][color] +export const PlantLodNames = Object.freeze([ + ['flwrwht', 'flwrblk', 'flwryel', 'flwrred', 'flwrgrn'], // flower + ['treewht', 'treeblk', 'treeyel', 'treered', 'tree'], // tree + ['bushwht', 'bushblk', 'bushyel', 'bushred', 'bush'], // bush + ['palmwht', 'palmblk', 'palmyel', 'palmred', 'palm'] // palm +]); + +export const PLANT_COUNT = 81; +export const PLANT_RECORD_SIZE = 12; // variant(1) + sound(4) + move(4) + mood(1) + color(1) + counter(1) + +// Field byte offsets within a 12-byte plant record +export const PlantFieldOffsets = Object.freeze({ + variant: 0, // U8 + sound: 1, // U32 LE + move: 5, // U32 LE + mood: 9, // U8 + color: 10, // U8 + counter: 11 // S8 +}); + +// Max values for cycling (exclusive upper bounds) +export const MAX_SOUND = 8; +export const MAX_MOVE = Object.freeze([3, 3, 3, 3]); // per variant +export const MAX_MOOD = 4; +export const MAX_COLOR = 5; +export const MAX_VARIANT = 4; + +// g_plantSoundIdOffset — base objectId for click sounds (actual = sound + 56) +export const PLANT_SOUND_OFFSET = 56; + +/** + * g_plantInfoInit[81] — default values for all 81 plants. + * All entries share: sound=3, move=0, mood=1, counter=-1. + * Only variant and color differ per plant. + */ +export const PlantInfoInit = Object.freeze([ + /* 0 */ { variant: 0, sound: 3, move: 0, mood: 1, color: 3, counter: -1 }, + /* 1 */ { variant: 0, sound: 3, move: 0, mood: 1, color: 3, counter: -1 }, + /* 2 */ { variant: 0, sound: 3, move: 0, mood: 1, color: 3, counter: -1 }, + /* 3 */ { variant: 0, sound: 3, move: 0, mood: 1, color: 3, counter: -1 }, + /* 4 */ { variant: 0, sound: 3, move: 0, mood: 1, color: 2, counter: -1 }, + /* 5 */ { variant: 0, sound: 3, move: 0, mood: 1, color: 2, counter: -1 }, + /* 6 */ { variant: 0, sound: 3, move: 0, mood: 1, color: 2, counter: -1 }, + /* 7 */ { variant: 0, sound: 3, move: 0, mood: 1, color: 2, counter: -1 }, + /* 8 */ { variant: 0, sound: 3, move: 0, mood: 1, color: 3, counter: -1 }, + /* 9 */ { variant: 0, sound: 3, move: 0, mood: 1, color: 3, counter: -1 }, + /* 10 */ { variant: 0, sound: 3, move: 0, mood: 1, color: 2, counter: -1 }, + /* 11 */ { variant: 0, sound: 3, move: 0, mood: 1, color: 2, counter: -1 }, + /* 12 */ { variant: 3, sound: 3, move: 0, mood: 1, color: 4, counter: -1 }, + /* 13 */ { variant: 3, sound: 3, move: 0, mood: 1, color: 4, counter: -1 }, + /* 14 */ { variant: 3, sound: 3, move: 0, mood: 1, color: 4, counter: -1 }, + /* 15 */ { variant: 3, sound: 3, move: 0, mood: 1, color: 4, counter: -1 }, + /* 16 */ { variant: 2, sound: 3, move: 0, mood: 1, color: 4, counter: -1 }, + /* 17 */ { variant: 2, sound: 3, move: 0, mood: 1, color: 4, counter: -1 }, + /* 18 */ { variant: 2, sound: 3, move: 0, mood: 1, color: 4, counter: -1 }, + /* 19 */ { variant: 2, sound: 3, move: 0, mood: 1, color: 4, counter: -1 }, + /* 20 */ { variant: 1, sound: 3, move: 0, mood: 1, color: 4, counter: -1 }, + /* 21 */ { variant: 1, sound: 3, move: 0, mood: 1, color: 4, counter: -1 }, + /* 22 */ { variant: 1, sound: 3, move: 0, mood: 1, color: 4, counter: -1 }, + /* 23 */ { variant: 1, sound: 3, move: 0, mood: 1, color: 4, counter: -1 }, + /* 24 */ { variant: 3, sound: 3, move: 0, mood: 1, color: 4, counter: -1 }, + /* 25 */ { variant: 0, sound: 3, move: 0, mood: 1, color: 3, counter: -1 }, + /* 26 */ { variant: 0, sound: 3, move: 0, mood: 1, color: 3, counter: -1 }, + /* 27 */ { variant: 0, sound: 3, move: 0, mood: 1, color: 3, counter: -1 }, + /* 28 */ { variant: 0, sound: 3, move: 0, mood: 1, color: 3, counter: -1 }, + /* 29 */ { variant: 0, sound: 3, move: 0, mood: 1, color: 3, counter: -1 }, + /* 30 */ { variant: 0, sound: 3, move: 0, mood: 1, color: 3, counter: -1 }, + /* 31 */ { variant: 0, sound: 3, move: 0, mood: 1, color: 3, counter: -1 }, + /* 32 */ { variant: 0, sound: 3, move: 0, mood: 1, color: 3, counter: -1 }, + /* 33 */ { variant: 0, sound: 3, move: 0, mood: 1, color: 3, counter: -1 }, + /* 34 */ { variant: 0, sound: 3, move: 0, mood: 1, color: 3, counter: -1 }, + /* 35 */ { variant: 0, sound: 3, move: 0, mood: 1, color: 3, counter: -1 }, + /* 36 */ { variant: 0, sound: 3, move: 0, mood: 1, color: 3, counter: -1 }, + /* 37 */ { variant: 0, sound: 3, move: 0, mood: 1, color: 3, counter: -1 }, + /* 38 */ { variant: 0, sound: 3, move: 0, mood: 1, color: 2, counter: -1 }, + /* 39 */ { variant: 0, sound: 3, move: 0, mood: 1, color: 2, counter: -1 }, + /* 40 */ { variant: 0, sound: 3, move: 0, mood: 1, color: 2, counter: -1 }, + /* 41 */ { variant: 0, sound: 3, move: 0, mood: 1, color: 2, counter: -1 }, + /* 42 */ { variant: 0, sound: 3, move: 0, mood: 1, color: 2, counter: -1 }, + /* 43 */ { variant: 0, sound: 3, move: 0, mood: 1, color: 2, counter: -1 }, + /* 44 */ { variant: 0, sound: 3, move: 0, mood: 1, color: 2, counter: -1 }, + /* 45 */ { variant: 0, sound: 3, move: 0, mood: 1, color: 2, counter: -1 }, + /* 46 */ { variant: 0, sound: 3, move: 0, mood: 1, color: 2, counter: -1 }, + /* 47 */ { variant: 0, sound: 3, move: 0, mood: 1, color: 2, counter: -1 }, + /* 48 */ { variant: 0, sound: 3, move: 0, mood: 1, color: 2, counter: -1 }, + /* 49 */ { variant: 0, sound: 3, move: 0, mood: 1, color: 2, counter: -1 }, + /* 50 */ { variant: 3, sound: 3, move: 0, mood: 1, color: 4, counter: -1 }, + /* 51 */ { variant: 3, sound: 3, move: 0, mood: 1, color: 4, counter: -1 }, + /* 52 */ { variant: 3, sound: 3, move: 0, mood: 1, color: 4, counter: -1 }, + /* 53 */ { variant: 3, sound: 3, move: 0, mood: 1, color: 4, counter: -1 }, + /* 54 */ { variant: 3, sound: 3, move: 0, mood: 1, color: 4, counter: -1 }, + /* 55 */ { variant: 3, sound: 3, move: 0, mood: 1, color: 4, counter: -1 }, + /* 56 */ { variant: 3, sound: 3, move: 0, mood: 1, color: 4, counter: -1 }, + /* 57 */ { variant: 3, sound: 3, move: 0, mood: 1, color: 4, counter: -1 }, + /* 58 */ { variant: 3, sound: 3, move: 0, mood: 1, color: 4, counter: -1 }, + /* 59 */ { variant: 3, sound: 3, move: 0, mood: 1, color: 4, counter: -1 }, + /* 60 */ { variant: 3, sound: 3, move: 0, mood: 1, color: 4, counter: -1 }, + /* 61 */ { variant: 3, sound: 3, move: 0, mood: 1, color: 4, counter: -1 }, + /* 62 */ { variant: 3, sound: 3, move: 0, mood: 1, color: 4, counter: -1 }, + /* 63 */ { variant: 3, sound: 3, move: 0, mood: 1, color: 4, counter: -1 }, + /* 64 */ { variant: 3, sound: 3, move: 0, mood: 1, color: 4, counter: -1 }, + /* 65 */ { variant: 3, sound: 3, move: 0, mood: 1, color: 4, counter: -1 }, + /* 66 */ { variant: 3, sound: 3, move: 0, mood: 1, color: 4, counter: -1 }, + /* 67 */ { variant: 3, sound: 3, move: 0, mood: 1, color: 4, counter: -1 }, + /* 68 */ { variant: 3, sound: 3, move: 0, mood: 1, color: 4, counter: -1 }, + /* 69 */ { variant: 1, sound: 3, move: 0, mood: 1, color: 4, counter: -1 }, + /* 70 */ { variant: 1, sound: 3, move: 0, mood: 1, color: 4, counter: -1 }, + /* 71 */ { variant: 1, sound: 3, move: 0, mood: 1, color: 4, counter: -1 }, + /* 72 */ { variant: 1, sound: 3, move: 0, mood: 1, color: 4, counter: -1 }, + /* 73 */ { variant: 1, sound: 3, move: 0, mood: 1, color: 4, counter: -1 }, + /* 74 */ { variant: 1, sound: 3, move: 0, mood: 1, color: 4, counter: -1 }, + /* 75 */ { variant: 0, sound: 3, move: 0, mood: 1, color: 3, counter: -1 }, + /* 76 */ { variant: 0, sound: 3, move: 0, mood: 1, color: 2, counter: -1 }, + /* 77 */ { variant: 0, sound: 3, move: 0, mood: 1, color: 3, counter: -1 }, + /* 78 */ { variant: 0, sound: 3, move: 0, mood: 1, color: 2, counter: -1 }, + /* 79 */ { variant: 0, sound: 3, move: 0, mood: 1, color: 2, counter: -1 }, + /* 80 */ { variant: 0, sound: 3, move: 0, mood: 1, color: 3, counter: -1 } +]); diff --git a/src/lib/Carousel.svelte b/src/lib/Carousel.svelte index 9e03675..d313045 100644 --- a/src/lib/Carousel.svelte +++ b/src/lib/Carousel.svelte @@ -4,6 +4,13 @@ export let gap = 10; + // Optional selection-based navigation. When provided, nav buttons + // change the selected item instead of scrolling. + export let onPrev = null; + export let onNext = null; + export let hasPrev = undefined; + export let hasNext = undefined; + let trackRef; let canScrollLeft = false; let canScrollRight = false; @@ -14,6 +21,9 @@ // Exposed so parent can check if a drag occurred (to prevent click handling) export let hasDragged = false; + $: leftDisabled = hasPrev !== undefined ? !hasPrev : !canScrollLeft; + $: rightDisabled = hasNext !== undefined ? !hasNext : !canScrollRight; + function updateArrows() { if (!trackRef) return; const { scrollLeft, scrollWidth, clientWidth } = trackRef; @@ -21,12 +31,40 @@ canScrollRight = scrollLeft + clientWidth < scrollWidth - 1; } - function scrollLeft() { - trackRef?.scrollBy({ left: -200, behavior: 'smooth' }); + function handleLeft() { + if (onPrev) { + onPrev(); + } else { + trackRef?.scrollBy({ left: -200, behavior: 'smooth' }); + } } - function scrollRight() { - trackRef?.scrollBy({ left: 200, behavior: 'smooth' }); + function handleRight() { + if (onNext) { + onNext(); + } else { + trackRef?.scrollBy({ left: 200, behavior: 'smooth' }); + } + } + + function scrollChildIntoView(child) { + const trackRect = trackRef.getBoundingClientRect(); + const childRect = child.getBoundingClientRect(); + const isFullyVisible = childRect.left >= trackRect.left && childRect.right <= trackRect.right; + + if (!isFullyVisible) { + const scrollTarget = childRect.left < trackRect.left + ? trackRef.scrollLeft - (trackRect.left - childRect.left) + : trackRef.scrollLeft + (childRect.right - trackRect.right); + trackRef.scrollTo({ left: scrollTarget, behavior: 'smooth' }); + } + } + + /** Scroll the nth child (0-indexed) into view */ + export function scrollToIndex(index) { + if (!trackRef) return; + const child = trackRef.children[index]; + if (child) scrollChildIntoView(child); } function handleMouseDown(e) { @@ -55,24 +93,9 @@ } function handleClick(e) { - // Find the direct child element that was clicked const clickedCard = e.target.closest('.carousel-track > *'); if (!clickedCard || hasDragged) return; - - const trackRect = trackRef.getBoundingClientRect(); - const cardRect = clickedCard.getBoundingClientRect(); - - // Check if card is fully visible - const isFullyVisible = cardRect.left >= trackRect.left && cardRect.right <= trackRect.right; - - if (!isFullyVisible) { - // Scroll to bring card into view - const scrollLeft = cardRect.left < trackRect.left - ? trackRef.scrollLeft - (trackRect.left - cardRect.left) - : trackRef.scrollLeft + (cardRect.right - trackRect.right); - - trackRef.scrollTo({ left: scrollLeft, behavior: 'smooth' }); - } + scrollChildIntoView(clickedCard); } onMount(() => { @@ -86,7 +109,7 @@ diff --git a/src/lib/save-editor/PlantEditor.svelte b/src/lib/save-editor/PlantEditor.svelte new file mode 100644 index 0000000..099f151 --- /dev/null +++ b/src/lib/save-editor/PlantEditor.svelte @@ -0,0 +1,289 @@ + + + renderer?.resetView()}> +
+ + + {#if loading} +
+
+
+ {:else if error} +
{error}
+ {/if} +
+ +
+
+ +
+ {plantIndex + 1} / {PLANT_COUNT} + {colorName} {variantName} +
+ +
+
+ +
+ {#if !isDefault && !loading && !error} + + {/if} +
+
+ diff --git a/src/lib/save-editor/VehicleEditor.svelte b/src/lib/save-editor/VehicleEditor.svelte index 2c7ede1..bc2f2ac 100644 --- a/src/lib/save-editor/VehicleEditor.svelte +++ b/src/lib/save-editor/VehicleEditor.svelte @@ -18,6 +18,7 @@ import ResetButton from '../ResetButton.svelte'; import EditorTooltip from '../EditorTooltip.svelte'; import TexturePickerModal from './TexturePickerModal.svelte'; + import './editor-common.css'; export let slot; export let onUpdate = () => {}; @@ -353,15 +354,15 @@
- {VehicleNames[vehicle]} - {currentPart?.label || 'Unknown'} + {VehicleNames[vehicle]} + {currentPart?.label || 'Unknown'}
{#if textureInfo}