Buildings editor (#26)
Some checks failed
Build / build (push) Has been cancelled

* 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%)
This commit is contained in:
Christian Semmler 2026-02-14 10:35:52 -08:00 committed by GitHub
parent 1d18779689
commit 3116ac1c7f
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
14 changed files with 870 additions and 275 deletions

View File

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

View File

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

View File

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

View File

@ -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);
}
}

View File

@ -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() {

View File

@ -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);
}
}

View File

@ -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}`);
}
}

View File

@ -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}`);
}
}

View File

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

View File

@ -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'
]);

View File

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

View File

@ -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)' },

View File

@ -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 @@
<PlantEditor slot={currentSlot} onUpdate={handleVariableUpdate} />
{/if}
</div>
<!-- Buildings Tab -->
<div class:hidden={activeTab !== 'buildings'}>
{#if $currentPage === 'save-editor'}
<BuildingEditor slot={currentSlot} onUpdate={handleVariableUpdate} />
{/if}
</div>
</div>
{/if}
</div>

View File

@ -0,0 +1,347 @@
<script>
import { onMount, onDestroy } from 'svelte';
import { BuildingRenderer } from '../../core/rendering/BuildingRenderer.js';
import { WdbParser, buildPartsMap, buildGlobalPartsMap, resolveLods } from '../../core/formats/WdbParser.js';
import {
BuildingInfoInit, BuildingDisplayNames, BuildingVariants,
BUILDING_COUNT, MAX_SOUND, MAX_MOVE, MAX_MOOD, MAX_VARIANT,
BUILDING_SOUND_OFFSET, BUILDING_MOOD_SOUND_OFFSET,
BuildingFlags, BuildingAnimationId, HAUS1_INDEX
} from '../../core/savegame/buildingConstants.js';
import { Actor } from '../../core/savegame/constants.js';
import { createSoundPlayer } from '../../core/audio.js';
import NavButton from '../NavButton.svelte';
import ResetButton from '../ResetButton.svelte';
import EditorTooltip from '../EditorTooltip.svelte';
import './editor-common.css';
export let slot;
export let onUpdate = () => {};
let canvas;
let renderer = null;
let loading = true;
let error = null;
// Cached WDB data: modelName -> { rois, textures }
let buildingModelsMap = null;
let buildingIndex = 0;
let loadedBuildingKey = null;
const soundPlayer = createSoundPlayer();
$: buildingState = slot?.buildings?.[buildingIndex];
$: displayName = BuildingDisplayNames[buildingIndex] || 'Unknown';
$: buildingInfo = BuildingInfoInit[buildingIndex];
$: hasCustomization = buildingInfo && (buildingInfo.flags & (BuildingFlags.c_hasSounds | BuildingFlags.c_hasMoves | BuildingFlags.c_hasMoods | BuildingFlags.c_hasVariants)) !== 0;
// For haus1, show the current variant name
$: variantLabel = buildingIndex === HAUS1_INDEX && slot?.nextVariant !== null && slot?.nextVariant !== undefined
? BuildingVariants[slot.nextVariant] || ''
: '';
$: isDefault = buildingState && (() => {
const def = BuildingInfoInit[buildingIndex];
if (!def) return true;
const fieldsMatch = buildingState.sound === def.sound &&
buildingState.move === def.move &&
buildingState.mood === def.mood &&
buildingState.counter === def.counter;
if (buildingIndex === HAUS1_INDEX) {
return fieldsMatch && (slot?.nextVariant === 0);
}
return fieldsMatch;
})();
function buildingKey(slotNumber, idx, nextVariant) {
const nv = idx === HAUS1_INDEX ? nextVariant : 0;
return `${slotNumber}-${idx}-${nv}`;
}
onMount(async () => {
try {
const response = await fetch('/LEGO/data/WORLD.WDB');
if (!response.ok) {
throw new Error(`Failed to load WORLD.WDB: ${response.status}`);
}
const buffer = await response.arrayBuffer();
const wdbParser = new WdbParser(buffer);
const wdbData = wdbParser.parse();
// Collect all building model names we need
const neededModels = new Set();
for (const info of BuildingInfoInit) {
neededModels.add(info.name.toLowerCase());
}
// Also need haus variant models
for (const v of BuildingVariants) {
neededModels.add(v.toLowerCase());
}
// Global textures (needed for some building meshes)
const globalTextures = [
...(wdbData.globalTextures || []),
...(wdbData.globalParts?.textures || [])
];
// Scan worlds for building models, like ActorEditor does for vehicles.
// Buildings live in multiple worlds: Isle, ACT1, ACT2, ACT3, etc.
// Prefer Isle world, fall back to ACT1 for buildings not in Isle.
const modelsMap = new Map();
for (const world of wdbData.worlds) {
let worldPartsMap = null;
for (const model of world.models) {
const modelKey = model.name.toLowerCase();
if (!neededModels.has(modelKey) || modelsMap.has(modelKey)) continue;
const modelData = wdbParser.parseModelData(model.dataOffset);
const roi = modelData.roi;
if (!roi) continue;
if (!worldPartsMap) {
worldPartsMap = buildPartsMap(wdbParser, world.parts);
}
// Collect all renderable ROIs (root + children recursively)
const rois = [];
const collectRois = (node) => {
const lods = resolveLods(node, worldPartsMap);
if (lods.length > 0) {
rois.push({ name: node.name, lods });
}
for (const child of node.children || []) {
collectRois(child);
}
};
collectRois(roi);
if (rois.length > 0) {
// Merge model-specific textures with globals
const textures = [
...globalTextures,
...(modelData.textures || [])
];
modelsMap.set(modelKey, { rois, textures });
}
}
}
buildingModelsMap = modelsMap;
renderer = new BuildingRenderer(canvas);
loadCurrentBuilding();
renderer.start();
loading = false;
} catch (e) {
console.error('BuildingEditor initialization error:', e);
error = e.message;
loading = false;
}
});
onDestroy(() => {
renderer?.dispose();
soundPlayer.dispose();
});
// Reload building when index or variant changes (not sound/move/mood)
$: if (renderer && !loading && buildingState) {
if (buildingKey(slot?.slotNumber, buildingIndex, slot?.nextVariant) !== loadedBuildingKey) {
loadCurrentBuilding();
}
}
function loadCurrentBuilding() {
if (!renderer || !buildingModelsMap || !buildingState) return;
// For haus1, use the variant name from BuildingVariants
let modelName = buildingInfo.name.toLowerCase();
if (buildingIndex === HAUS1_INDEX && slot?.nextVariant !== null && slot?.nextVariant !== undefined) {
modelName = (BuildingVariants[slot.nextVariant] || buildingInfo.name).toLowerCase();
}
const modelData = buildingModelsMap.get(modelName);
if (modelData) {
renderer.loadBuilding(modelData.rois, modelData.textures);
} else {
renderer.clearModel();
}
loadedBuildingKey = buildingKey(slot?.slotNumber, buildingIndex, slot?.nextVariant);
// Play queued click animation if any
renderer.playQueuedAnimation();
}
function prevBuilding() {
buildingIndex = buildingIndex > 0 ? buildingIndex - 1 : BUILDING_COUNT - 1;
loadedBuildingKey = null;
}
function nextBuilding() {
buildingIndex = buildingIndex < BUILDING_COUNT - 1 ? buildingIndex + 1 : 0;
loadedBuildingKey = null;
}
function handleCanvasClick(event) {
if (!renderer || !slot?.buildings || !buildingState) return;
if (renderer.wasDragged()) return;
const playerId = slot.header?.actorId;
if (!hasCustomization) return;
const flags = buildingInfo.flags;
const canSound = (flags & BuildingFlags.c_hasSounds) !== 0;
const canMove = (flags & BuildingFlags.c_hasMoves) !== 0;
// Perform the character-specific switch operation.
// In the game, ClickSound + ClickAnimation always run after
// Switch* regardless of whether the switch changed anything.
switch (playerId) {
case Actor.PEPPER: switchVariant(); break;
case Actor.MAMA: switchSound(); break;
case Actor.PAPA: switchMove(); break;
case Actor.NICK: break; // Buildings don't support color
case Actor.LAURA: switchMood(); break;
}
// ClickSound — plays if building has c_hasSounds.
// SwitchMood calls ClickSound(TRUE) then ClickSound(FALSE) — both.
if (canSound) {
if (playerId === Actor.LAURA) {
// ClickSound(TRUE): mood-based sound (objectId = newMood + 66)
const newMood = (buildingState.mood + 1) % MAX_MOOD;
soundPlayer.play(`MoodSound${newMood}`);
}
// ClickSound(FALSE): regular click sound (objectId = sound + 60)
const soundIdx = playerId === Actor.MAMA
? (buildingState.sound + 1) % MAX_SOUND
: buildingState.sound;
const soundObjectId = soundIdx + BUILDING_SOUND_OFFSET;
// objectIds 60-63 = PlantSound4-7, 64-65 = BuildingSound4-5
if (soundObjectId <= 63) {
soundPlayer.play(`PlantSound${soundIdx + 4}`);
} else {
soundPlayer.play(`BuildingSound${soundIdx}`);
}
}
// ClickAnimation — plays if building has c_hasMoves
if (canMove && BuildingAnimationId[buildingIndex] > 0) {
const move = playerId === Actor.PAPA
? (buildingState.move + 1) % MAX_MOVE[buildingIndex]
: buildingState.move;
renderer.queueClickAnimation(buildingIndex, move);
// Model only reloads for Pepper variant switch on haus1;
// in all other cases play animation directly
if (playerId !== Actor.PEPPER || buildingIndex !== HAUS1_INDEX) {
renderer.playQueuedAnimation();
}
}
}
function switchVariant() {
// Only haus1 (index 12) supports variants
if (buildingIndex !== HAUS1_INDEX) return false;
if (!(buildingInfo.flags & BuildingFlags.c_hasVariants)) return false;
const nextVar = ((slot?.nextVariant ?? 0) + 1) % MAX_VARIANT;
onUpdate({ nextVariant: nextVar });
return true;
}
function switchSound() {
if (!(buildingInfo.flags & BuildingFlags.c_hasSounds)) return false;
const nextSound = (buildingState.sound + 1) % MAX_SOUND;
onUpdate({
building: { buildingIndex, field: 'sound', value: nextSound }
});
return true;
}
function switchMove() {
if (!(buildingInfo.flags & BuildingFlags.c_hasMoves)) return false;
if (MAX_MOVE[buildingIndex] === 0) return false;
const nextMove = (buildingState.move + 1) % MAX_MOVE[buildingIndex];
onUpdate({
building: { buildingIndex, field: 'move', value: nextMove }
});
return true;
}
function switchMood() {
if (!(buildingInfo.flags & BuildingFlags.c_hasMoods)) return false;
const nextMood = (buildingState.mood + 1) % MAX_MOOD;
onUpdate({
building: { buildingIndex, field: 'mood', value: nextMood }
});
return true;
}
function resetBuilding() {
const def = BuildingInfoInit[buildingIndex];
if (!def) return;
const updates = {
building: [
{ buildingIndex, field: 'sound', value: def.sound },
{ buildingIndex, field: 'move', value: def.move },
{ buildingIndex, field: 'mood', value: def.mood },
{ buildingIndex, field: 'counter', value: def.counter }
]
};
if (buildingIndex === HAUS1_INDEX) {
updates.nextVariant = 0;
}
onUpdate(updates);
}
</script>
<EditorTooltip text="Click to customize based on your current character. Navigate between all 16 buildings using the arrows. Changes are automatically saved." onResetCamera={() => renderer?.resetView()}>
<div class="preview-container">
<canvas
bind:this={canvas}
width="190"
height="190"
class:hidden={loading || error}
onclick={handleCanvasClick}
role="button"
tabindex="0"
aria-label="Customize building"
></canvas>
{#if loading}
<div class="preview-overlay">
<div class="spinner"></div>
</div>
{:else if error}
<div class="preview-overlay error">{error}</div>
{/if}
</div>
<div class="part-nav-wrapper">
<div class="part-nav">
<NavButton direction="left" onclick={prevBuilding} />
<div class="part-info">
<span class="nav-index">{buildingIndex + 1} / {BUILDING_COUNT}</span>
<span class="nav-name">{displayName}{variantLabel ? ` (${variantLabel})` : ''}</span>
</div>
<NavButton direction="right" onclick={nextBuilding} />
</div>
</div>
<div class="reset-container">
{#if !isDefault && !loading && !error}
<ResetButton onclick={resetBuilding} />
{/if}
</div>
</EditorTooltip>