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/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/PlantRenderer.js b/src/core/rendering/PlantRenderer.js
new file mode 100644
index 0000000..488571e
--- /dev/null
+++ b/src/core/rendering/PlantRenderer.js
@@ -0,0 +1,399 @@
+import * as THREE from 'three';
+import { PlantLodNames } from '../savegame/plantConstants.js';
+import { LegoColors } from '../savegame/constants.js';
+import { parseAnimation } from '../formats/AnimationParser.js';
+import { fetchAnimation } from '../assetLoader.js';
+import { BaseRenderer } from './BaseRenderer.js';
+
+// 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 BaseRenderer {
+ constructor(canvas) {
+ super(canvas);
+ this.clock = new THREE.Clock();
+ this.mixer = null;
+ this.currentAction = null;
+ this.animationCache = new Map();
+ this._queuedClickAnim = null;
+
+ this.camera.position.set(1.5, 1.2, 2.5);
+ this.camera.lookAt(0, 0.2, 0);
+
+ this.setupControls(new THREE.Vector3(0, 0.2, 0));
+
+ this.raycaster = new THREE.Raycaster();
+ }
+
+ /**
+ * 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
+ this.textures.clear();
+ 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);
+ 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);
+ if (vertex) {
+ mat.elements[12] += vertex.x;
+ mat.elements[13] += vertex.y;
+ mat.elements[14] += vertex.z;
+ }
+ }
+
+ return mat;
+ }
+
+ collectKeyframeTimes(node, timesSet) {
+ const data = node.data;
+ for (const key of data.translationKeys) timesSet.add(key.time);
+ for (const key of data.rotationKeys) timesSet.add(key.time);
+ for (const key of data.scaleKeys) timesSet.add(key.time);
+ for (const child of node.children) {
+ this.collectKeyframeTimes(child, timesSet);
+ }
+ }
+
+ evaluateRotation(keys, time) {
+ const { before, after } = this.getBeforeAndAfter(keys, time);
+ const toQuat = (key) => new THREE.Quaternion(-key.x, key.y, key.z, key.w);
+
+ if (!after) {
+ if (before.flags & 0x01) {
+ return new THREE.Matrix4().makeRotationFromQuaternion(toQuat(before));
+ }
+ return new THREE.Matrix4();
+ }
+
+ if ((before.flags & 0x01) || (after.flags & 0x01)) {
+ const beforeQ = toQuat(before);
+ if (after.flags & 0x04) {
+ return new THREE.Matrix4().makeRotationFromQuaternion(beforeQ);
+ }
+ let afterQ = toQuat(after);
+ if (after.flags & 0x02) {
+ afterQ.set(-afterQ.x, -afterQ.y, -afterQ.z, -afterQ.w);
+ }
+ const t = (time - before.time) / (after.time - before.time);
+ const result = new THREE.Quaternion().slerpQuaternions(beforeQ, afterQ, t);
+ return new THREE.Matrix4().makeRotationFromQuaternion(result);
+ }
+
+ return new THREE.Matrix4();
+ }
+
+ interpolateVertex(keys, time) {
+ const { before, after } = this.getBeforeAndAfter(keys, time);
+ const toVec = (key) => new THREE.Vector3(-key.x, key.y, key.z);
+
+ if (!after) return toVec(before);
+
+ const t = (time - before.time) / (after.time - before.time);
+ return new THREE.Vector3().lerpVectors(toVec(before), toVec(after), t);
+ }
+
+ getBeforeAndAfter(keys, time) {
+ let idx = keys.findIndex(k => k.time > time);
+ if (idx < 0) idx = keys.length;
+ const before = keys[Math.max(0, idx - 1)];
+ return { before, after: keys[idx] || null };
+ }
+
+ async fetchAnimationByName(animName) {
+ if (this.animationCache.has(animName)) {
+ return this.animationCache.get(animName);
+ }
+ const buffer = await fetchAnimation(animName);
+ if (!buffer) return null;
+ const animData = parseAnimation(buffer);
+ this.animationCache.set(animName, animData);
+ return animData;
+ }
+
+ stopAnimation() {
+ if (this.currentAction) {
+ this.currentAction.stop();
+ this.currentAction = null;
+ }
+ if (this.mixer) {
+ this.mixer.stopAllAction();
+ this.mixer = null;
+ }
+ }
+
+ // ─── Scene Management ────────────────────────────────────────────
+
+ clearModel() {
+ this.stopAnimation();
+ super.clearModel();
+ }
+
+ start() {
+ this.animating = true;
+ this.clock.start();
+ this.animate();
+ }
+
+ updateAnimation() {
+ const delta = this.clock.getDelta();
+ if (this.mixer) {
+ this.mixer.update(delta);
+ }
+ this.controls?.update();
+ }
+
+ dispose() {
+ this.stopAnimation();
+ super.dispose();
+ this.animationCache.clear();
+ }
+}
diff --git a/src/core/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..1cec2d5
--- /dev/null
+++ b/src/core/savegame/plantConstants.js
@@ -0,0 +1,149 @@
+/**
+ * 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_plantAnimationId[4] — base objectId for animations per variant
+export const PLANT_ANIM_IDS = Object.freeze([30, 33, 36, 39]);
+
+// g_plantSoundIdOffset — base objectId for click sounds (actual = sound + 56)
+export const PLANT_SOUND_OFFSET = 56;
+
+/**
+ * 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/SaveEditorPage.svelte b/src/lib/SaveEditorPage.svelte
index b1ff811..daecc00 100644
--- a/src/lib/SaveEditorPage.svelte
+++ b/src/lib/SaveEditorPage.svelte
@@ -7,6 +7,7 @@
import LightPositionEditor from './save-editor/LightPositionEditor.svelte';
import VehicleEditor from './save-editor/VehicleEditor.svelte';
import ActorEditor from './save-editor/ActorEditor.svelte';
+ import PlantEditor from './save-editor/PlantEditor.svelte';
import { fetchBitmapAsURL } from '../core/assetLoader.js';
import { saveEditorState, currentPage } from '../stores.js';
import { listSaveSlots, updateSaveSlot, updatePlayerName } from '../core/savegame/index.js';
@@ -26,7 +27,8 @@
{ id: 'scores', label: 'Scores', firstSection: null },
{ id: 'island', label: 'Island', firstSection: 'skycolor' },
{ id: 'vehicles', label: 'Vehicles', firstSection: null },
- { id: 'actors', label: 'Actors', firstSection: null }
+ { id: 'actors', label: 'Actors', firstSection: null },
+ { id: 'plants', label: 'Plants', firstSection: null }
];
// Reset state when navigating to this page
@@ -139,7 +141,7 @@
if (updated) {
slots = slots.map(s =>
s.slotNumber === selectedSlot
- ? { ...s, variables: updated.variables, act1State: updated.act1State, characters: updated.characters }
+ ? { ...s, variables: updated.variables, act1State: updated.act1State, characters: updated.characters, plants: updated.plants }
: s
);
}
@@ -435,6 +437,13 @@