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} +
+