mirror of
https://github.com/isledecomp/isle.pizza.git
synced 2026-03-01 06:17:38 +00:00
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
This commit is contained in:
parent
1d18779689
commit
fcd95d2ee0
@ -145,6 +145,36 @@ const PLANT_ANIMATIONS = [
|
|||||||
['PlantAnimP2', 41, 294, '5ddaff70e2b57fdb294769eaa14e42a0'],
|
['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)
|
// Plant sounds from SNDANIM.SI (objectId = sound + 56, sounds 3-7)
|
||||||
// [name, objectId, size, md5]
|
// [name, objectId, size, md5]
|
||||||
const PLANT_SOUNDS = [
|
const PLANT_SOUNDS = [
|
||||||
@ -351,7 +381,7 @@ async function main() {
|
|||||||
console.log(` ${clickFound}/${CLICK_ANIMATIONS.length} click animations found\n`);
|
console.log(` ${clickFound}/${CLICK_ANIMATIONS.length} click animations found\n`);
|
||||||
|
|
||||||
// --- Sounds (in SNDANIM.SI) ---
|
// --- 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 soundObjectIds = new Set(allSounds.map(([, objectId]) => objectId));
|
||||||
const soundRanges = findMxChByObjectId(sndanimSI, soundObjectIds);
|
const soundRanges = findMxChByObjectId(sndanimSI, soundObjectIds);
|
||||||
|
|
||||||
@ -387,6 +417,24 @@ async function main() {
|
|||||||
}
|
}
|
||||||
console.log(` ${plantAnimFound}/${PLANT_ANIMATIONS.length} plant animations found\n`);
|
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) ---
|
// --- Textures (across Build SI files) ---
|
||||||
const texBySI = new Map();
|
const texBySI = new Map();
|
||||||
for (const entry of TEXTURES) {
|
for (const entry of TEXTURES) {
|
||||||
@ -465,7 +513,7 @@ async function main() {
|
|||||||
await fs.writeFile(BIN_PATH, bundle);
|
await fs.writeFile(BIN_PATH, bundle);
|
||||||
console.log(`Wrote ${BIN_PATH} (${(bundle.length / 1024).toFixed(1)} KB, ${Object.keys(index).length} entries)`);
|
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 => {
|
main().catch(err => {
|
||||||
|
|||||||
@ -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() {
|
parseBuildings() {
|
||||||
this.reader.skip(16 * 10 + 1);
|
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.parseVariables();
|
||||||
this.parseCharacters();
|
this.parseCharacters();
|
||||||
this.parsePlants();
|
this.parsePlants();
|
||||||
this.skipBuildings();
|
this.parseBuildings();
|
||||||
this.parseGameStates();
|
this.parseGameStates();
|
||||||
|
|
||||||
return this.parsed;
|
return this.parsed;
|
||||||
|
|||||||
@ -7,6 +7,7 @@ import { BinaryWriter } from './BinaryWriter.js';
|
|||||||
import { GameStateTypes, GameStateSizes, Actor, Act1TextureOrder } from '../savegame/constants.js';
|
import { GameStateTypes, GameStateSizes, Actor, Act1TextureOrder } from '../savegame/constants.js';
|
||||||
import { CharacterFieldOffsets, CHARACTER_RECORD_SIZE } from '../savegame/actorConstants.js';
|
import { CharacterFieldOffsets, CHARACTER_RECORD_SIZE } from '../savegame/actorConstants.js';
|
||||||
import { PlantFieldOffsets, PLANT_RECORD_SIZE } from '../savegame/plantConstants.js';
|
import { PlantFieldOffsets, PLANT_RECORD_SIZE } from '../savegame/plantConstants.js';
|
||||||
|
import { BuildingFieldOffsets, BUILDING_RECORD_SIZE } from '../savegame/buildingConstants.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Offsets for header fields
|
* Offsets for header fields
|
||||||
@ -475,6 +476,41 @@ export class SaveGameSerializer {
|
|||||||
return workingBuffer;
|
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
|
* Get the byte offset for a mission score
|
||||||
* @param {string} missionType
|
* @param {string} missionType
|
||||||
|
|||||||
@ -369,12 +369,13 @@ export class ActorRenderer extends AnimatedRenderer {
|
|||||||
const center = box.getCenter(new THREE.Vector3());
|
const center = box.getCenter(new THREE.Vector3());
|
||||||
const size = box.getSize(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);
|
const maxDim = Math.max(size.x, size.y, size.z);
|
||||||
if (maxDim > 0) {
|
if (maxDim > 0) {
|
||||||
const scale = scaleFactor / maxDim;
|
const scale = scaleFactor / maxDim;
|
||||||
this.modelGroup.scale.setScalar(scale);
|
this.modelGroup.scale.setScalar(scale);
|
||||||
|
this.modelGroup.position.copy(center).multiplyScalar(-scale);
|
||||||
|
} else {
|
||||||
|
this.modelGroup.position.sub(center);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -186,12 +186,16 @@ export class BaseRenderer {
|
|||||||
const center = box.getCenter(new THREE.Vector3());
|
const center = box.getCenter(new THREE.Vector3());
|
||||||
const size = box.getSize(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);
|
const maxDim = Math.max(size.x, size.y, size.z);
|
||||||
if (maxDim > 0) {
|
if (maxDim > 0) {
|
||||||
const scale = scaleFactor / maxDim;
|
const scale = scaleFactor / maxDim;
|
||||||
this.modelGroup.scale.setScalar(scale);
|
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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
245
src/core/rendering/BuildingRenderer.js
Normal file
245
src/core/rendering/BuildingRenderer.js
Normal file
@ -0,0 +1,245 @@
|
|||||||
|
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._queuedClickAnim = null;
|
||||||
|
|
||||||
|
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;
|
||||||
|
|
||||||
|
// Build texture lookup
|
||||||
|
if (textures) {
|
||||||
|
for (const tex of textures) {
|
||||||
|
if (tex.name) {
|
||||||
|
this.textures.set(tex.name.toLowerCase(), this.createTexture(tex));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this.modelGroup = new THREE.Group();
|
||||||
|
|
||||||
|
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;
|
||||||
|
|
||||||
|
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 {
|
||||||
|
const colorEntry = LegoColors['lego white'] || { r: 255, g: 255, b: 255 };
|
||||||
|
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.centerAndScaleModel(2.5);
|
||||||
|
this.scene.add(this.modelGroup);
|
||||||
|
this.renderer.render(this.scene, this.camera);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if the building 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} buildingIndex - Building index (9-14)
|
||||||
|
* @param {number} move - The building's move value
|
||||||
|
*/
|
||||||
|
queueClickAnimation(buildingIndex, move) {
|
||||||
|
this._queuedClickAnim = { buildingIndex, move };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Play a queued click animation if available.
|
||||||
|
*/
|
||||||
|
async playQueuedAnimation() {
|
||||||
|
if (!this._queuedClickAnim || !this.modelGroup) return;
|
||||||
|
|
||||||
|
const { buildingIndex, move } = this._queuedClickAnim;
|
||||||
|
this._queuedClickAnim = null;
|
||||||
|
|
||||||
|
const animName = `BuildingAnim${buildingIndex}_${move}`;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const animData = await this.fetchAnimationByName(animName);
|
||||||
|
if (!animData || !this.modelGroup) return;
|
||||||
|
|
||||||
|
const tracks = this.buildBuildingTracks(animData);
|
||||||
|
if (tracks.length === 0) return;
|
||||||
|
|
||||||
|
this.stopAnimation();
|
||||||
|
|
||||||
|
const clip = new THREE.AnimationClip('buildingClick', -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 building. Same approach as PlantRenderer.
|
||||||
|
*/
|
||||||
|
buildBuildingTracks(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 buildingNode = this.findAnimatedNode(animData.rootNode);
|
||||||
|
if (!buildingNode) return [];
|
||||||
|
|
||||||
|
const quatValues = [];
|
||||||
|
const timesSec = [];
|
||||||
|
|
||||||
|
for (const time of times) {
|
||||||
|
const mat = this.evaluateNodeChain(animData.rootNode, buildingNode, 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)
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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
|
// Animation suffix per variant: flower→F, tree→T, bush→B, palm→P
|
||||||
const VARIANT_ANIM_SUFFIX = ['F', 'T', 'B', 'P'];
|
const VARIANT_ANIM_SUFFIX = ['F', 'T', 'B', 'P'];
|
||||||
|
|
||||||
// Per-variant display adjustments: [scaleFactor, yOffset]
|
// Per-variant scale factors
|
||||||
// Flower is tall/wide → zoom out + shift down; others shift up to sit in frame
|
const VARIANT_SCALE = [1.6, 1.8, 1.6, 2.0];
|
||||||
const VARIANT_DISPLAY = [
|
|
||||||
[1.6, -0.1], // Flower: smaller, shifted slightly down
|
|
||||||
[1.8, 0.6], // Tree: slightly smaller, shifted up
|
|
||||||
[1.6, 1.4], // Bush: smaller, shifted well up
|
|
||||||
[2.0, 1.1], // Palm: shifted well up
|
|
||||||
];
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Renderer for LEGO Island plants. Much simpler than ActorRenderer —
|
* Renderer for LEGO Island plants. Much simpler than ActorRenderer —
|
||||||
@ -98,9 +92,7 @@ export class PlantRenderer extends AnimatedRenderer {
|
|||||||
this.modelGroup.add(new THREE.Mesh(geometry, material));
|
this.modelGroup.add(new THREE.Mesh(geometry, material));
|
||||||
}
|
}
|
||||||
|
|
||||||
const [scaleFactor, yOffset] = VARIANT_DISPLAY[variant] || [2.0, 0];
|
this.centerAndScaleModel(VARIANT_SCALE[variant] ?? 2.0);
|
||||||
this.centerAndScaleModel(scaleFactor);
|
|
||||||
this.modelGroup.position.y += yOffset;
|
|
||||||
this.scene.add(this.modelGroup);
|
this.scene.add(this.modelGroup);
|
||||||
this.renderer.render(this.scene, this.camera);
|
this.renderer.render(this.scene, this.camera);
|
||||||
}
|
}
|
||||||
|
|||||||
69
src/core/savegame/buildingConstants.js
Normal file
69
src/core/savegame/buildingConstants.js
Normal 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'
|
||||||
|
]);
|
||||||
@ -95,6 +95,10 @@ export async function listSaveSlots() {
|
|||||||
charactersOffset: null,
|
charactersOffset: null,
|
||||||
plants: null,
|
plants: null,
|
||||||
plantsOffset: null,
|
plantsOffset: null,
|
||||||
|
buildings: null,
|
||||||
|
buildingsOffset: null,
|
||||||
|
nextVariant: null,
|
||||||
|
nextVariantOffset: null,
|
||||||
playerName: null,
|
playerName: null,
|
||||||
buffer: null
|
buffer: null
|
||||||
};
|
};
|
||||||
@ -112,6 +116,10 @@ export async function listSaveSlots() {
|
|||||||
slot.charactersOffset = parsed.charactersOffset || null;
|
slot.charactersOffset = parsed.charactersOffset || null;
|
||||||
slot.plants = parsed.plants || null;
|
slot.plants = parsed.plants || null;
|
||||||
slot.plantsOffset = parsed.plantsOffset || 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;
|
slot.buffer = buffer;
|
||||||
|
|
||||||
// Try to get player name
|
// Try to get player name
|
||||||
@ -180,6 +188,10 @@ export async function loadSaveSlot(slotNumber) {
|
|||||||
charactersOffset: parsed.charactersOffset || null,
|
charactersOffset: parsed.charactersOffset || null,
|
||||||
plants: parsed.plants || null,
|
plants: parsed.plants || null,
|
||||||
plantsOffset: parsed.plantsOffset || null,
|
plantsOffset: parsed.plantsOffset || null,
|
||||||
|
buildings: parsed.buildings || null,
|
||||||
|
buildingsOffset: parsed.buildingsOffset || null,
|
||||||
|
nextVariant: parsed.nextVariant ?? null,
|
||||||
|
nextVariantOffset: parsed.nextVariantOffset || null,
|
||||||
playerName,
|
playerName,
|
||||||
buffer
|
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
|
// Apply texture update
|
||||||
if (updates.texture) {
|
if (updates.texture) {
|
||||||
const { textureName, textureData } = updates.texture;
|
const { textureName, textureData } = updates.texture;
|
||||||
|
|||||||
@ -8,6 +8,7 @@
|
|||||||
import VehicleEditor from './save-editor/VehicleEditor.svelte';
|
import VehicleEditor from './save-editor/VehicleEditor.svelte';
|
||||||
import ActorEditor from './save-editor/ActorEditor.svelte';
|
import ActorEditor from './save-editor/ActorEditor.svelte';
|
||||||
import PlantEditor from './save-editor/PlantEditor.svelte';
|
import PlantEditor from './save-editor/PlantEditor.svelte';
|
||||||
|
import BuildingEditor from './save-editor/BuildingEditor.svelte';
|
||||||
import { fetchBitmapAsURL } from '../core/assetLoader.js';
|
import { fetchBitmapAsURL } from '../core/assetLoader.js';
|
||||||
import { saveEditorState, currentPage } from '../stores.js';
|
import { saveEditorState, currentPage } from '../stores.js';
|
||||||
import { listSaveSlots, updateSaveSlot, updatePlayerName } from '../core/savegame/index.js';
|
import { listSaveSlots, updateSaveSlot, updatePlayerName } from '../core/savegame/index.js';
|
||||||
@ -28,7 +29,8 @@
|
|||||||
{ id: 'island', label: 'Island', firstSection: 'skycolor' },
|
{ id: 'island', label: 'Island', firstSection: 'skycolor' },
|
||||||
{ id: 'vehicles', label: 'Vehicles', firstSection: null },
|
{ id: 'vehicles', label: 'Vehicles', firstSection: null },
|
||||||
{ id: 'actors', label: 'Actors', 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
|
// Reset state when navigating to this page
|
||||||
@ -181,7 +183,7 @@
|
|||||||
if (updated) {
|
if (updated) {
|
||||||
slots = slots.map(s =>
|
slots = slots.map(s =>
|
||||||
s.slotNumber === selectedSlot
|
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
|
: s
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -486,6 +488,13 @@
|
|||||||
<PlantEditor slot={currentSlot} onUpdate={handleVariableUpdate} />
|
<PlantEditor slot={currentSlot} onUpdate={handleVariableUpdate} />
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Buildings Tab -->
|
||||||
|
<div class:hidden={activeTab !== 'buildings'}>
|
||||||
|
{#if $currentPage === 'save-editor'}
|
||||||
|
<BuildingEditor slot={currentSlot} onUpdate={handleVariableUpdate} />
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
347
src/lib/save-editor/BuildingEditor.svelte
Normal file
347
src/lib/save-editor/BuildingEditor.svelte
Normal 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>
|
||||||
Loading…
Reference in New Issue
Block a user