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 @@