From 3116ac1c7f90d2293cae553a10bb71140dadeabd Mon Sep 17 00:00:00 2001 From: Christian Semmler Date: Sat, 14 Feb 2026 10:35:52 -0800 Subject: [PATCH] Buildings editor (#26) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Building editor Add a Buildings tab to the Save Game Editor that lets users browse all 16 buildings, preview them in 3D, and customize their properties (sound, move, mood, variant) by clicking, matching the original game behavior per character. - Parse 16 buildings + nextVariant from save files instead of skipping - Add serializer methods to patch building fields in-place - Create BuildingRenderer (extends AnimatedRenderer) for 3D preview with click animations from SNDANIM.SI - Create BuildingEditor component with per-character click behavior (Pepper: variants, Mama: sounds, Papa: moves, Laura: moods) - Extract 18 building animations and 2 building sounds into asset bundle - Fix centerAndScaleModel to account for scale in position offset * Add Building Editor to February 2026 changelog * DRY up renderer hierarchy: extract shared logic into base classes Move duplicated animation tree utilities (findAnimatedNode, evaluateNodeChain, findNodePath, evaluateLocalTransform), click animation (queueClickAnimation, playQueuedAnimation, buildRotationTracks), and raycast hit testing (getClickedMesh) from PlantRenderer and BuildingRenderer into AnimatedRenderer. Add loadTextures() and createMeshMaterial() helpers to BaseRenderer, replacing identical texture-loading loops and material-creation code across all four renderers. PlantRenderer: 279 → 73 lines (-74%) BuildingRenderer: 245 → 57 lines (-77%) --- scripts/generate-save-editor-assets.js | 52 +++- src/core/formats/SaveGameParser.js | 23 +- src/core/formats/SaveGameSerializer.js | 36 +++ src/core/rendering/ActorRenderer.js | 42 +-- src/core/rendering/AnimatedRenderer.js | 180 +++++++++++ src/core/rendering/BaseRenderer.js | 50 +++- src/core/rendering/BuildingRenderer.js | 56 ++++ src/core/rendering/PlantRenderer.js | 234 +-------------- src/core/rendering/VehiclePartRenderer.js | 7 +- src/core/savegame/buildingConstants.js | 69 +++++ src/core/savegame/index.js | 35 +++ src/lib/ReadMePage.svelte | 1 + src/lib/SaveEditorPage.svelte | 13 +- src/lib/save-editor/BuildingEditor.svelte | 347 ++++++++++++++++++++++ 14 files changed, 870 insertions(+), 275 deletions(-) create mode 100644 src/core/rendering/BuildingRenderer.js create mode 100644 src/core/savegame/buildingConstants.js create mode 100644 src/lib/save-editor/BuildingEditor.svelte diff --git a/scripts/generate-save-editor-assets.js b/scripts/generate-save-editor-assets.js index d8b2a85..a51bb96 100644 --- a/scripts/generate-save-editor-assets.js +++ b/scripts/generate-save-editor-assets.js @@ -145,6 +145,36 @@ const PLANT_ANIMATIONS = [ ['PlantAnimP2', 41, 294, '5ddaff70e2b57fdb294769eaa14e42a0'], ]; +// Building animations from SNDANIM.SI (objectId = g_buildingAnimationId[idx] + move) +// [name, objectId, size, md5] +const BUILDING_ANIMATIONS = [ + ['BuildingAnim9_0', 70, 452, '1292875d15dec79d2fe719f08e6f4b25'], + ['BuildingAnim9_1', 71, 356, '0285456907450609d820b0bbd923f3b8'], + ['BuildingAnim9_2', 72, 900, '91f87ce4bcb5d02854d0aa23929a4233'], + ['BuildingAnim10_0', 73, 502, 'a8c2a3b47aaf7f01ec831b6af2924c0d'], + ['BuildingAnim10_1', 74, 370, '8d39ae6fd092cf1586234e80ebe27815'], + ['BuildingAnim10_2', 75, 894, '0c59954cf2be82b4221ffb26dbc40d5c'], + ['BuildingAnim11_0', 76, 494, '926437967083a0eca288f7b3beba5c98'], + ['BuildingAnim11_1', 77, 334, '6899b0fe1510db35a76d04e677f415c1'], + ['BuildingAnim11_2', 78, 722, '98fc44fb00024c459e6e0808906f0f95'], + ['BuildingAnim12_0', 79, 932, '71e06ffe92b26fe6ceb139f04c8f9556'], + ['BuildingAnim12_1', 80, 916, '1fc7a77bff41496a7ca3eadf0681544e'], + ['BuildingAnim12_2', 81, 868, '085ca884f316c3436b7fdf9fc2505e57'], + ['BuildingAnim13_0', 82, 1024, '855b50251602ce8196bd4cc30f1ce1fa'], + ['BuildingAnim13_1', 83, 876, '3893d16e60f7dad4c724c18fdecaf49c'], + ['BuildingAnim13_2', 84, 888, '4681ff613c88b07c3a14c2e5b27edf21'], + ['BuildingAnim14_0', 85, 972, 'a73d9da4e1c7c2d586d22997b9781fe3'], + ['BuildingAnim14_1', 86, 948, 'ee469b2326c7d9e2f3b1fff6177842be'], + ['BuildingAnim14_2', 87, 868, '693423f24d6f371d522076ecf4c589d5'], +]; + +// Building sounds from SNDANIM.SI (objectId = sound + 60, sounds 4-5 only) +// [name, objectId, size, md5] +const BUILDING_SOUNDS = [ + ['BuildingSound4', 64, 8215, '3066d58d6b26db751d0d0ded1055d886'], + ['BuildingSound5', 65, 11534, '91379f36012f600a4b7432e003e16c3a'], +]; + // Plant sounds from SNDANIM.SI (objectId = sound + 56, sounds 3-7) // [name, objectId, size, md5] const PLANT_SOUNDS = [ @@ -351,7 +381,7 @@ async function main() { console.log(` ${clickFound}/${CLICK_ANIMATIONS.length} click animations found\n`); // --- Sounds (in SNDANIM.SI) --- - const allSounds = [...CLICK_SOUNDS, ...MOOD_SOUNDS, ...PLANT_SOUNDS]; + const allSounds = [...CLICK_SOUNDS, ...MOOD_SOUNDS, ...PLANT_SOUNDS, ...BUILDING_SOUNDS]; const soundObjectIds = new Set(allSounds.map(([, objectId]) => objectId)); const soundRanges = findMxChByObjectId(sndanimSI, soundObjectIds); @@ -387,6 +417,24 @@ async function main() { } console.log(` ${plantAnimFound}/${PLANT_ANIMATIONS.length} plant animations found\n`); + // --- Building Animations (in SNDANIM.SI) --- + const buildingAnimObjectIds = new Set(BUILDING_ANIMATIONS.map(([, objectId]) => objectId)); + const buildingAnimRanges = findMxChByObjectId(sndanimSI, buildingAnimObjectIds); + + let buildingAnimFound = 0; + for (const [name, objectId, size, expectedMd5] of BUILDING_ANIMATIONS) { + const data = extractAndVerify(sndanimSI, buildingAnimRanges.get(objectId), size, expectedMd5); + if (data) { + fragments.push({ type: 'animations', name, data }); + buildingAnimFound++; + found++; + } else { + console.error(` FAILED: ${name} (objectId ${objectId})`); + failed++; + } + } + console.log(` ${buildingAnimFound}/${BUILDING_ANIMATIONS.length} building animations found\n`); + // --- Textures (across Build SI files) --- const texBySI = new Map(); for (const entry of TEXTURES) { @@ -465,7 +513,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 + ${PLANT_ANIMATIONS.length} plant 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 + ${BUILDING_ANIMATIONS.length} building 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 37e2f4d..8880396 100644 --- a/src/core/formats/SaveGameParser.js +++ b/src/core/formats/SaveGameParser.js @@ -150,10 +150,25 @@ export class SaveGameParser { } /** - * Skip building manager data (16 buildings * 10 bytes = 160 bytes + 1 byte variant) + * Parse building manager data (16 buildings * 10 bytes = 160 bytes + 1 byte variant) + * Each building: sound(U32) + move(U32) + mood(U8) + counter(S8) */ - skipBuildings() { - this.reader.skip(16 * 10 + 1); + parseBuildings() { + this.parsed.buildingsOffset = this.reader.tell(); + const buildings = []; + + for (let i = 0; i < 16; i++) { + buildings.push({ + sound: this.reader.readU32(), + move: this.reader.readU32(), + mood: this.reader.readU8(), + counter: this.reader.readS8() + }); + } + + this.parsed.buildings = buildings; + this.parsed.nextVariantOffset = this.reader.tell(); + this.parsed.nextVariant = this.reader.readU8(); } /** @@ -441,7 +456,7 @@ export class SaveGameParser { this.parseVariables(); this.parseCharacters(); this.parsePlants(); - this.skipBuildings(); + this.parseBuildings(); this.parseGameStates(); return this.parsed; diff --git a/src/core/formats/SaveGameSerializer.js b/src/core/formats/SaveGameSerializer.js index eed0b0b..cd50891 100644 --- a/src/core/formats/SaveGameSerializer.js +++ b/src/core/formats/SaveGameSerializer.js @@ -7,6 +7,7 @@ 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'; +import { BuildingFieldOffsets, BUILDING_RECORD_SIZE } from '../savegame/buildingConstants.js'; /** * Offsets for header fields @@ -475,6 +476,41 @@ export class SaveGameSerializer { return workingBuffer; } + /** + * Update a building field in the save file + * @param {number} buildingIndex - Building index (0-15) + * @param {string} field - Field name from BuildingFieldOffsets + * @param {number} value - New value + * @returns {ArrayBuffer} - Modified buffer + */ + updateBuilding(buildingIndex, field, value) { + const workingBuffer = this.createCopy(); + const view = new DataView(workingBuffer); + const offset = this.parsed.buildingsOffset + (buildingIndex * BUILDING_RECORD_SIZE) + BuildingFieldOffsets[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; + } + + /** + * Update the nextVariant field in the save file + * @param {number} value - New variant value (0-4) + * @returns {ArrayBuffer} - Modified buffer + */ + updateNextVariant(value) { + const workingBuffer = this.createCopy(); + const view = new DataView(workingBuffer); + view.setUint8(this.parsed.nextVariantOffset, 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 8c1ddb9..a7ec5be 100644 --- a/src/core/rendering/ActorRenderer.js +++ b/src/core/rendering/ActorRenderer.js @@ -109,21 +109,9 @@ export class ActorRenderer extends AnimatedRenderer { const actorInfo = ActorInfoInit[actorIndex]; const charState = characters[actorIndex]; - // Build texture lookup - for (const tex of globalTextures) { - if (tex.name) { - this.textures.set(tex.name.toLowerCase(), this.createTexture(tex)); - } - } - - // Merge vehicle textures (if present) - if (vehicleInfo && vehicleTextures) { - for (const tex of vehicleTextures) { - if (tex.name && !this.textures.has(tex.name.toLowerCase())) { - this.textures.set(tex.name.toLowerCase(), this.createTexture(tex)); - } - } - } + // Build texture lookup (vehicle textures don't overwrite global ones) + this.loadTextures(globalTextures); + if (vehicleInfo) this.loadTextures(vehicleTextures, false); this.modelGroup = new THREE.Group(); this.partGroups = []; @@ -321,24 +309,7 @@ export class ActorRenderer extends AnimatedRenderer { 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 || { r: 128, g: 128, b: 128 }; - material = new THREE.MeshLambertMaterial({ - color: new THREE.Color(meshColor.r / 255, meshColor.g / 255, meshColor.b / 255), - side: THREE.DoubleSide - }); - } - - this.vehicleGroup.add(new THREE.Mesh(geometry, material)); + this.vehicleGroup.add(new THREE.Mesh(geometry, this.createMeshMaterial(mesh))); } } @@ -369,12 +340,13 @@ export class ActorRenderer extends AnimatedRenderer { const center = box.getCenter(new THREE.Vector3()); const size = box.getSize(new THREE.Vector3()); - this.modelGroup.position.sub(center); - const maxDim = Math.max(size.x, size.y, size.z); if (maxDim > 0) { const scale = scaleFactor / maxDim; this.modelGroup.scale.setScalar(scale); + this.modelGroup.position.copy(center).multiplyScalar(-scale); + } else { + this.modelGroup.position.sub(center); } } diff --git a/src/core/rendering/AnimatedRenderer.js b/src/core/rendering/AnimatedRenderer.js index a26e3d1..345a8aa 100644 --- a/src/core/rendering/AnimatedRenderer.js +++ b/src/core/rendering/AnimatedRenderer.js @@ -16,6 +16,7 @@ export class AnimatedRenderer extends BaseRenderer { this.currentAction = null; this.animationCache = new Map(); this.raycaster = new THREE.Raycaster(); + this._queuedClickAnim = null; } // ─── Animation Utilities ───────────────────────────────────────── @@ -138,6 +139,185 @@ export class AnimatedRenderer extends BaseRenderer { return { before, after: keys[idx] || null }; } + // ─── Click Animation ───────────────────────────────────────────── + + /** + * Queue a click animation by name. Subclasses may override to + * accept domain-specific arguments and construct the name. + * @param {string} animName - Animation asset name + */ + queueClickAnimation(animName) { + this._queuedClickAnim = animName; + } + + /** + * Play the queued click animation (one-shot), then resume auto-rotate. + */ + async playQueuedAnimation() { + if (!this._queuedClickAnim || !this.modelGroup) return; + + const animName = this._queuedClickAnim; + this._queuedClickAnim = null; + + try { + const animData = await this.fetchAnimationByName(animName); + if (!animData || !this.modelGroup) return; + + const tracks = this.buildRotationTracks(animData); + if (tracks.length === 0) return; + + this.stopAnimation(); + + const clip = new THREE.AnimationClip('clickAnim', -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 + } + } + + // ─── Raycast Hit Testing ───────────────────────────────────────── + + /** + * Check if any mesh in the model 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; + } + + // ─── Simple Animation Tree Utilities ───────────────────────────── + + /** + * Build rotation-only keyframe tracks by finding the deepest animated + * node and evaluating the composed transform chain at each keyframe time. + * Used by plant and building animations (single-group models). + */ + buildRotationTracks(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); + + const targetNode = this.findAnimatedNode(animData.rootNode); + if (!targetNode) return []; + + const quatValues = []; + const timesSec = []; + + for (const time of times) { + const mat = this.evaluateNodeChain(animData.rootNode, targetNode, 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); + } + + return [ + new THREE.QuaternionKeyframeTrack('.quaternion', timesSec, quatValues) + ]; + } + + /** + * Find the deepest node in the animation tree that has keyframe data. + */ + findAnimatedNode(node) { + for (const child of node.children) { + const found = this.findAnimatedNode(child); + if (found) return found; + } + const d = node.data; + if (d.translationKeys.length > 0 || d.rotationKeys.length > 0 || d.scaleKeys.length > 0) { + return node; + } + return null; + } + + /** + * Evaluate the composed transform matrix from root down to targetNode. + */ + evaluateNodeChain(node, targetNode, time) { + const path = []; + if (!this.findNodePath(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; + } + + /** + * Build the path from current node to target via depth-first search. + */ + findNodePath(current, target, path) { + path.push(current); + if (current === target) return true; + for (const child of current.children) { + if (this.findNodePath(child, target, path)) return true; + } + path.pop(); + return false; + } + + /** + * Evaluate the local transform matrix for an animation node at a given time. + */ + 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; + } + // ─── Scene Management ──────────────────────────────────────────── clearModel() { diff --git a/src/core/rendering/BaseRenderer.js b/src/core/rendering/BaseRenderer.js index 4cb55ad..c16823c 100644 --- a/src/core/rendering/BaseRenderer.js +++ b/src/core/rendering/BaseRenderer.js @@ -109,6 +109,48 @@ export class BaseRenderer { return texture; } + /** + * Build the texture lookup map from an array of texture data objects. + * @param {Array} textures - Texture data with name, width, height, palette, pixels + * @param {boolean} overwrite - If false, skip textures already in the map + */ + loadTextures(textures, overwrite = true) { + if (!textures) return; + for (const tex of textures) { + if (!tex.name) continue; + const key = tex.name.toLowerCase(); + if (overwrite || !this.textures.has(key)) { + this.textures.set(key, this.createTexture(tex)); + } + } + } + + /** + * Create a material for a mesh using its texture or color properties. + * @param {object} mesh - Mesh with properties (textureName, color) + * @param {THREE.Color} [fallbackColor] - Color when mesh has no texture or color + */ + createMeshMaterial(mesh, fallbackColor = null) { + const meshTexName = mesh.properties?.textureName?.toLowerCase(); + if (meshTexName && this.textures.has(meshTexName)) { + return new THREE.MeshLambertMaterial({ + map: this.textures.get(meshTexName), + side: THREE.DoubleSide, + color: 0xffffff + }); + } + + const meshColor = mesh.properties?.color; + const color = meshColor + ? new THREE.Color(meshColor.r / 255, meshColor.g / 255, meshColor.b / 255) + : (fallbackColor || new THREE.Color(0.5, 0.5, 0.5)); + + return new THREE.MeshLambertMaterial({ + color, + side: THREE.DoubleSide + }); + } + /** * Create a single geometry from mesh data */ @@ -186,12 +228,16 @@ export class BaseRenderer { const center = box.getCenter(new THREE.Vector3()); const size = box.getSize(new THREE.Vector3()); - this.modelGroup.position.sub(center); - const maxDim = Math.max(size.x, size.y, size.z); if (maxDim > 0) { const scale = scaleFactor / maxDim; this.modelGroup.scale.setScalar(scale); + // Position must account for scale: Three.js applies scale before + // translation, so vertex v maps to (position + scale * v). + // To center: position = -center * scale → v maps to scale*(v - center). + this.modelGroup.position.copy(center).multiplyScalar(-scale); + } else { + this.modelGroup.position.sub(center); } } diff --git a/src/core/rendering/BuildingRenderer.js b/src/core/rendering/BuildingRenderer.js new file mode 100644 index 0000000..7ce0e20 --- /dev/null +++ b/src/core/rendering/BuildingRenderer.js @@ -0,0 +1,56 @@ +import * as THREE from 'three'; +import { LegoColors } from '../savegame/constants.js'; +import { AnimatedRenderer } from './AnimatedRenderer.js'; + +/** + * Renderer for LEGO Island buildings. Buildings are WDB models with + * hierarchical ROIs (potentially multi-part like policsta, jail). + */ +export class BuildingRenderer extends AnimatedRenderer { + constructor(canvas) { + super(canvas); + + this.camera.position.set(2.5, 2.0, 4.0); + this.camera.lookAt(0, -0.3, 0); + + this.setupControls(new THREE.Vector3(0, -0.3, 0)); + } + + /** + * Load a building model from pre-collected ROIs. + * @param {Array} rois - Array of { name, lods } from WDB model + * @param {Array} textures - Texture list from the model + globals + */ + loadBuilding(rois, textures) { + this.clearModel(); + + if (!rois || rois.length === 0) return; + + this.loadTextures(textures); + + this.modelGroup = new THREE.Group(); + + const colorEntry = LegoColors['lego white'] || { r: 255, g: 255, b: 255 }; + const fallbackColor = new THREE.Color(colorEntry.r / 255, colorEntry.g / 255, colorEntry.b / 255); + + for (const roi of rois) { + const lods = roi.lods || []; + if (lods.length === 0) continue; + + const lod = lods[lods.length - 1]; // Highest quality + for (const mesh of lod.meshes) { + const geometry = this.createGeometry(mesh, lod); + if (!geometry) continue; + this.modelGroup.add(new THREE.Mesh(geometry, this.createMeshMaterial(mesh, fallbackColor))); + } + } + + this.centerAndScaleModel(2.5); + this.scene.add(this.modelGroup); + this.renderer.render(this.scene, this.camera); + } + + queueClickAnimation(buildingIndex, move) { + super.queueClickAnimation(`BuildingAnim${buildingIndex}_${move}`); + } +} diff --git a/src/core/rendering/PlantRenderer.js b/src/core/rendering/PlantRenderer.js index 0ac3c0a..7ca289b 100644 --- a/src/core/rendering/PlantRenderer.js +++ b/src/core/rendering/PlantRenderer.js @@ -9,14 +9,8 @@ const PLANT_COLOR_MAP = ['lego white', 'lego black', 'lego yellow', 'lego red', // 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 -]; +// Per-variant scale factors +const VARIANT_SCALE = [1.6, 1.8, 1.6, 2.0]; /** * Renderer for LEGO Island plants. Much simpler than ActorRenderer — @@ -25,7 +19,6 @@ const VARIANT_DISPLAY = [ 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); @@ -46,241 +39,34 @@ export class PlantRenderer extends AnimatedRenderer { 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.loadTextures(textures); this.modelGroup = new THREE.Group(); const lods = partData.lods || []; if (lods.length === 0) return; + const colorName = PLANT_COLOR_MAP[color] || 'lego green'; + const colorEntry = LegoColors[colorName] || LegoColors['lego green']; + const fallbackColor = new THREE.Color(colorEntry.r / 255, colorEntry.g / 255, colorEntry.b / 255); + 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)); + this.modelGroup.add(new THREE.Mesh(geometry, this.createMeshMaterial(mesh, fallbackColor))); } - const [scaleFactor, yOffset] = VARIANT_DISPLAY[variant] || [2.0, 0]; - this.centerAndScaleModel(scaleFactor); - this.modelGroup.position.y += yOffset; + this.centerAndScaleModel(VARIANT_SCALE[variant] ?? 2.0); 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; + super.queueClickAnimation(`PlantAnim${suffix}${move}`); } } diff --git a/src/core/rendering/VehiclePartRenderer.js b/src/core/rendering/VehiclePartRenderer.js index 39283d9..31fbb9b 100644 --- a/src/core/rendering/VehiclePartRenderer.js +++ b/src/core/rendering/VehiclePartRenderer.js @@ -42,12 +42,7 @@ export class VehiclePartRenderer extends BaseRenderer { this.colorableMeshes = []; this.partsMap = partsMap; - // Build texture lookup map (case-insensitive) - for (const tex of textureList) { - if (tex.name) { - this.textures.set(tex.name.toLowerCase(), this.createTexture(tex)); - } - } + this.loadTextures(textureList); const legoColor = LegoColors[colorName] || LegoColors['lego red']; const threeLegoColor = new THREE.Color(legoColor.r / 255, legoColor.g / 255, legoColor.b / 255); diff --git a/src/core/savegame/buildingConstants.js b/src/core/savegame/buildingConstants.js new file mode 100644 index 0000000..d2ea73e --- /dev/null +++ b/src/core/savegame/buildingConstants.js @@ -0,0 +1,69 @@ +/** + * Building data constants ported from LEGO1 source: + * isle/LEGO1/lego/legoomni/src/common/legobuildingmanager.cpp + * isle/LEGO1/lego/legoomni/include/legobuildingmanager.h + */ + +export const BUILDING_COUNT = 16; +export const BUILDING_RECORD_SIZE = 10; // sound(4) + move(4) + mood(1) + counter(1) +export const NEXT_VARIANT_SIZE = 1; + +// Field byte offsets within a 10-byte building record +export const BuildingFieldOffsets = Object.freeze({ + sound: 0, // U32 LE + move: 4, // U32 LE + mood: 8, // U8 + counter: 9 // S8 +}); + +// LegoBuildingInfo feature flags (from legobuildingmanager.h enum) +export const BuildingFlags = Object.freeze({ + c_hasVariants: 0x01, + c_hasSounds: 0x02, + c_hasMoves: 0x04, + c_hasMoods: 0x08 +}); + +export const MAX_SOUND = 6; +export const MAX_MOVE = Object.freeze([0, 0, 0, 0, 0, 0, 0, 0, 0, 3, 3, 3, 3, 3, 3, 0]); +export const MAX_MOOD = 4; +export const MAX_VARIANT = 5; + +export const BUILDING_SOUND_OFFSET = 60; +export const BUILDING_MOOD_SOUND_OFFSET = 66; + +export const BuildingVariants = Object.freeze(['haus1', 'haus4', 'haus5', 'haus6', 'haus7']); +export const HAUS1_INDEX = 12; + +// g_buildingAnimationId[16] — base animation objectId per building +export const BuildingAnimationId = Object.freeze([ + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0x46, 0x49, 0x4c, 0x4f, 0x52, 0x55, 0 +]); + +// g_buildingInfoInit[16] — default values for all 16 buildings. +// Names are the m_variant field from legobuildingmanager.cpp (entity lookup names). +export const BuildingInfoInit = Object.freeze([ + /* 0 */ { name: 'infocen', sound: 4, move: 0, mood: 1, counter: -1, flags: 0x00 }, + /* 1 */ { name: 'policsta', sound: 4, move: 0, mood: 1, counter: -1, flags: 0x10 }, + /* 2 */ { name: 'Jail', sound: 4, move: 0, mood: 1, counter: -1, flags: 0x10 }, + /* 3 */ { name: 'races', sound: 4, move: 0, mood: 1, counter: -1, flags: 0x10 }, + /* 4 */ { name: 'medcntr', sound: 4, move: 0, mood: 1, counter: -1, flags: 0x10 }, + /* 5 */ { name: 'gas', sound: 4, move: 0, mood: 1, counter: -1, flags: 0x10 }, + /* 6 */ { name: 'beach', sound: 4, move: 0, mood: 1, counter: -1, flags: 0x10 }, + /* 7 */ { name: 'racef', sound: 4, move: 0, mood: 1, counter: -1, flags: 0x10 }, + /* 8 */ { name: 'racej', sound: 4, move: 0, mood: 1, counter: -1, flags: 0x10 }, + /* 9 */ { name: 'Store', sound: 4, move: 0, mood: 1, counter: -1, flags: 0x3e }, + /* 10 */ { name: 'Bank', sound: 4, move: 0, mood: 1, counter: -1, flags: 0x3e }, + /* 11 */ { name: 'Post', sound: 4, move: 0, mood: 1, counter: -1, flags: 0x3e }, + /* 12 */ { name: 'haus1', sound: 4, move: 0, mood: 1, counter: -1, flags: 0x3f }, + /* 13 */ { name: 'haus2', sound: 4, move: 0, mood: 1, counter: -1, flags: 0x3e }, + /* 14 */ { name: 'haus3', sound: 4, move: 0, mood: 1, counter: -1, flags: 0x3e }, + /* 15 */ { name: 'Pizza', sound: 4, move: 0, mood: 1, counter: -1, flags: 0x10 } +]); + +export const BuildingDisplayNames = Object.freeze([ + 'Information Center', 'Police Station', 'Jail', 'Race Stands', + 'Hospital', 'Gas Station', 'Beach House', 'Race Finish', + 'Race Tracks', 'Store', 'Bank', 'Post Office', + 'House 1', 'House 2', 'House 3', 'Pizzeria' +]); diff --git a/src/core/savegame/index.js b/src/core/savegame/index.js index 810e8f4..d283b8c 100644 --- a/src/core/savegame/index.js +++ b/src/core/savegame/index.js @@ -95,6 +95,10 @@ export async function listSaveSlots() { charactersOffset: null, plants: null, plantsOffset: null, + buildings: null, + buildingsOffset: null, + nextVariant: null, + nextVariantOffset: null, playerName: null, buffer: null }; @@ -112,6 +116,10 @@ export async function listSaveSlots() { slot.charactersOffset = parsed.charactersOffset || null; slot.plants = parsed.plants || null; slot.plantsOffset = parsed.plantsOffset || null; + slot.buildings = parsed.buildings || null; + slot.buildingsOffset = parsed.buildingsOffset || null; + slot.nextVariant = parsed.nextVariant ?? null; + slot.nextVariantOffset = parsed.nextVariantOffset || null; slot.buffer = buffer; // Try to get player name @@ -180,6 +188,10 @@ export async function loadSaveSlot(slotNumber) { charactersOffset: parsed.charactersOffset || null, plants: parsed.plants || null, plantsOffset: parsed.plantsOffset || null, + buildings: parsed.buildings || null, + buildingsOffset: parsed.buildingsOffset || null, + nextVariant: parsed.nextVariant ?? null, + nextVariantOffset: parsed.nextVariantOffset || null, playerName, buffer }; @@ -276,6 +288,29 @@ export async function updateSaveSlot(slotNumber, updates) { } } + // Apply building update(s) + if (updates.building) { + const entries = Array.isArray(updates.building) ? updates.building : [updates.building]; + for (const { buildingIndex, field, value } of entries) { + const buildingSerializer = createSerializer(newBuffer); + const result = buildingSerializer.updateBuilding(buildingIndex, field, value); + if (result) { + newBuffer = result; + modified = true; + } + } + } + + // Apply nextVariant update + if (updates.nextVariant !== undefined) { + const variantSerializer = createSerializer(newBuffer); + const result = variantSerializer.updateNextVariant(updates.nextVariant); + if (result) { + newBuffer = result; + modified = true; + } + } + // Apply texture update if (updates.texture) { const { textureName, textureData } = updates.texture; diff --git a/src/lib/ReadMePage.svelte b/src/lib/ReadMePage.svelte index 6b9a8f9..636554a 100644 --- a/src/lib/ReadMePage.svelte +++ b/src/lib/ReadMePage.svelte @@ -40,6 +40,7 @@ { type: 'New', text: 'Vehicle Texture Editor lets you customize vehicle textures with default presets or your own uploaded images' }, { type: 'New', text: 'Actor Editor with animated 3D character preview — customize hats, colors, moods, sounds, and moves for all 66 game actors' }, { type: 'New', text: 'Plant Editor lets you browse and customize all 81 island plants — change variants, colors, moods, sounds, and moves with click interactions that match the original in-game behavior per character' }, + { type: 'New', text: 'Building Editor lets you browse and customize all island buildings — change variants, sounds, and moves with a 3D preview' }, { type: 'New', text: 'Vehicle rendering in Actor Editor — toggle to see actors with their assigned vehicles' }, { type: 'New', text: 'Click animations and sound effects in Actor and Plant Editors matching the original game behavior' }, { type: 'New', text: 'Drag-to-orbit, zoom, and pan controls on all 3D previews (vehicle, actor, plant, and score cube editors)' }, diff --git a/src/lib/SaveEditorPage.svelte b/src/lib/SaveEditorPage.svelte index 2fb01ce..da40ec5 100644 --- a/src/lib/SaveEditorPage.svelte +++ b/src/lib/SaveEditorPage.svelte @@ -8,6 +8,7 @@ import VehicleEditor from './save-editor/VehicleEditor.svelte'; import ActorEditor from './save-editor/ActorEditor.svelte'; import PlantEditor from './save-editor/PlantEditor.svelte'; + import BuildingEditor from './save-editor/BuildingEditor.svelte'; import { fetchBitmapAsURL } from '../core/assetLoader.js'; import { saveEditorState, currentPage } from '../stores.js'; import { listSaveSlots, updateSaveSlot, updatePlayerName } from '../core/savegame/index.js'; @@ -28,7 +29,8 @@ { id: 'island', label: 'Island', firstSection: 'skycolor' }, { id: 'vehicles', label: 'Vehicles', firstSection: null }, { id: 'actors', label: 'Actors', firstSection: null }, - { id: 'plants', label: 'Plants', firstSection: null } + { id: 'plants', label: 'Plants', firstSection: null }, + { id: 'buildings', label: 'Buildings', firstSection: null } ]; // Reset state when navigating to this page @@ -181,7 +183,7 @@ if (updated) { slots = slots.map(s => s.slotNumber === selectedSlot - ? { ...s, variables: updated.variables, act1State: updated.act1State, characters: updated.characters, plants: updated.plants } + ? { ...s, variables: updated.variables, act1State: updated.act1State, characters: updated.characters, plants: updated.plants, buildings: updated.buildings, buildingsOffset: updated.buildingsOffset, nextVariant: updated.nextVariant, nextVariantOffset: updated.nextVariantOffset } : s ); } @@ -486,6 +488,13 @@ {/if} + + +
+ {#if $currentPage === 'save-editor'} + + {/if} +
{/if} diff --git a/src/lib/save-editor/BuildingEditor.svelte b/src/lib/save-editor/BuildingEditor.svelte new file mode 100644 index 0000000..10e87ac --- /dev/null +++ b/src/lib/save-editor/BuildingEditor.svelte @@ -0,0 +1,347 @@ + + + renderer?.resetView()}> +
+ + + {#if loading} +
+
+
+ {:else if error} +
{error}
+ {/if} +
+ +
+
+ +
+ {buildingIndex + 1} / {BUILDING_COUNT} + {displayName}{variantLabel ? ` (${variantLabel})` : ''} +
+ +
+
+ +
+ {#if !isDefault && !loading && !error} + + {/if} +
+