From fcd95d2ee006a5af5bd5497856c5215e5c6f2f86 Mon Sep 17 00:00:00 2001 From: Christian Semmler Date: Sat, 14 Feb 2026 10:19:12 -0800 Subject: [PATCH] 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 --- scripts/generate-save-editor-assets.js | 52 +++- src/core/formats/SaveGameParser.js | 23 +- src/core/formats/SaveGameSerializer.js | 36 +++ src/core/rendering/ActorRenderer.js | 5 +- src/core/rendering/BaseRenderer.js | 8 +- src/core/rendering/BuildingRenderer.js | 245 +++++++++++++++ src/core/rendering/PlantRenderer.js | 14 +- src/core/savegame/buildingConstants.js | 69 +++++ src/core/savegame/index.js | 35 +++ src/lib/SaveEditorPage.svelte | 13 +- src/lib/save-editor/BuildingEditor.svelte | 347 ++++++++++++++++++++++ 11 files changed, 824 insertions(+), 23 deletions(-) create mode 100644 src/core/rendering/BuildingRenderer.js create mode 100644 src/core/savegame/buildingConstants.js create mode 100644 src/lib/save-editor/BuildingEditor.svelte diff --git a/scripts/generate-save-editor-assets.js b/scripts/generate-save-editor-assets.js index d8b2a85..a51bb96 100644 --- a/scripts/generate-save-editor-assets.js +++ b/scripts/generate-save-editor-assets.js @@ -145,6 +145,36 @@ const PLANT_ANIMATIONS = [ ['PlantAnimP2', 41, 294, '5ddaff70e2b57fdb294769eaa14e42a0'], ]; +// Building animations from SNDANIM.SI (objectId = g_buildingAnimationId[idx] + move) +// [name, objectId, size, md5] +const BUILDING_ANIMATIONS = [ + ['BuildingAnim9_0', 70, 452, '1292875d15dec79d2fe719f08e6f4b25'], + ['BuildingAnim9_1', 71, 356, '0285456907450609d820b0bbd923f3b8'], + ['BuildingAnim9_2', 72, 900, '91f87ce4bcb5d02854d0aa23929a4233'], + ['BuildingAnim10_0', 73, 502, 'a8c2a3b47aaf7f01ec831b6af2924c0d'], + ['BuildingAnim10_1', 74, 370, '8d39ae6fd092cf1586234e80ebe27815'], + ['BuildingAnim10_2', 75, 894, '0c59954cf2be82b4221ffb26dbc40d5c'], + ['BuildingAnim11_0', 76, 494, '926437967083a0eca288f7b3beba5c98'], + ['BuildingAnim11_1', 77, 334, '6899b0fe1510db35a76d04e677f415c1'], + ['BuildingAnim11_2', 78, 722, '98fc44fb00024c459e6e0808906f0f95'], + ['BuildingAnim12_0', 79, 932, '71e06ffe92b26fe6ceb139f04c8f9556'], + ['BuildingAnim12_1', 80, 916, '1fc7a77bff41496a7ca3eadf0681544e'], + ['BuildingAnim12_2', 81, 868, '085ca884f316c3436b7fdf9fc2505e57'], + ['BuildingAnim13_0', 82, 1024, '855b50251602ce8196bd4cc30f1ce1fa'], + ['BuildingAnim13_1', 83, 876, '3893d16e60f7dad4c724c18fdecaf49c'], + ['BuildingAnim13_2', 84, 888, '4681ff613c88b07c3a14c2e5b27edf21'], + ['BuildingAnim14_0', 85, 972, 'a73d9da4e1c7c2d586d22997b9781fe3'], + ['BuildingAnim14_1', 86, 948, 'ee469b2326c7d9e2f3b1fff6177842be'], + ['BuildingAnim14_2', 87, 868, '693423f24d6f371d522076ecf4c589d5'], +]; + +// Building sounds from SNDANIM.SI (objectId = sound + 60, sounds 4-5 only) +// [name, objectId, size, md5] +const BUILDING_SOUNDS = [ + ['BuildingSound4', 64, 8215, '3066d58d6b26db751d0d0ded1055d886'], + ['BuildingSound5', 65, 11534, '91379f36012f600a4b7432e003e16c3a'], +]; + // Plant sounds from SNDANIM.SI (objectId = sound + 56, sounds 3-7) // [name, objectId, size, md5] const PLANT_SOUNDS = [ @@ -351,7 +381,7 @@ async function main() { console.log(` ${clickFound}/${CLICK_ANIMATIONS.length} click animations found\n`); // --- Sounds (in SNDANIM.SI) --- - const allSounds = [...CLICK_SOUNDS, ...MOOD_SOUNDS, ...PLANT_SOUNDS]; + const allSounds = [...CLICK_SOUNDS, ...MOOD_SOUNDS, ...PLANT_SOUNDS, ...BUILDING_SOUNDS]; const soundObjectIds = new Set(allSounds.map(([, objectId]) => objectId)); const soundRanges = findMxChByObjectId(sndanimSI, soundObjectIds); @@ -387,6 +417,24 @@ async function main() { } console.log(` ${plantAnimFound}/${PLANT_ANIMATIONS.length} plant animations found\n`); + // --- Building Animations (in SNDANIM.SI) --- + const buildingAnimObjectIds = new Set(BUILDING_ANIMATIONS.map(([, objectId]) => objectId)); + const buildingAnimRanges = findMxChByObjectId(sndanimSI, buildingAnimObjectIds); + + let buildingAnimFound = 0; + for (const [name, objectId, size, expectedMd5] of BUILDING_ANIMATIONS) { + const data = extractAndVerify(sndanimSI, buildingAnimRanges.get(objectId), size, expectedMd5); + if (data) { + fragments.push({ type: 'animations', name, data }); + buildingAnimFound++; + found++; + } else { + console.error(` FAILED: ${name} (objectId ${objectId})`); + failed++; + } + } + console.log(` ${buildingAnimFound}/${BUILDING_ANIMATIONS.length} building animations found\n`); + // --- Textures (across Build SI files) --- const texBySI = new Map(); for (const entry of TEXTURES) { @@ -465,7 +513,7 @@ async function main() { await fs.writeFile(BIN_PATH, bundle); console.log(`Wrote ${BIN_PATH} (${(bundle.length / 1024).toFixed(1)} KB, ${Object.keys(index).length} entries)`); - console.log(`Total: ${found} assets (${ANIMATIONS.length} walking + ${CLICK_ANIMATIONS.length} click + ${PLANT_ANIMATIONS.length} plant animations, ${allSounds.length} sounds, ${TEXTURES.length} textures, ${BITMAPS.length} bitmaps)`); + console.log(`Total: ${found} assets (${ANIMATIONS.length} walking + ${CLICK_ANIMATIONS.length} click + ${PLANT_ANIMATIONS.length} plant + ${BUILDING_ANIMATIONS.length} building animations, ${allSounds.length} sounds, ${TEXTURES.length} textures, ${BITMAPS.length} bitmaps)`); } main().catch(err => { diff --git a/src/core/formats/SaveGameParser.js b/src/core/formats/SaveGameParser.js index 37e2f4d..8880396 100644 --- a/src/core/formats/SaveGameParser.js +++ b/src/core/formats/SaveGameParser.js @@ -150,10 +150,25 @@ export class SaveGameParser { } /** - * Skip building manager data (16 buildings * 10 bytes = 160 bytes + 1 byte variant) + * Parse building manager data (16 buildings * 10 bytes = 160 bytes + 1 byte variant) + * Each building: sound(U32) + move(U32) + mood(U8) + counter(S8) */ - skipBuildings() { - this.reader.skip(16 * 10 + 1); + parseBuildings() { + this.parsed.buildingsOffset = this.reader.tell(); + const buildings = []; + + for (let i = 0; i < 16; i++) { + buildings.push({ + sound: this.reader.readU32(), + move: this.reader.readU32(), + mood: this.reader.readU8(), + counter: this.reader.readS8() + }); + } + + this.parsed.buildings = buildings; + this.parsed.nextVariantOffset = this.reader.tell(); + this.parsed.nextVariant = this.reader.readU8(); } /** @@ -441,7 +456,7 @@ export class SaveGameParser { this.parseVariables(); this.parseCharacters(); this.parsePlants(); - this.skipBuildings(); + this.parseBuildings(); this.parseGameStates(); return this.parsed; diff --git a/src/core/formats/SaveGameSerializer.js b/src/core/formats/SaveGameSerializer.js index eed0b0b..cd50891 100644 --- a/src/core/formats/SaveGameSerializer.js +++ b/src/core/formats/SaveGameSerializer.js @@ -7,6 +7,7 @@ import { BinaryWriter } from './BinaryWriter.js'; import { GameStateTypes, GameStateSizes, Actor, Act1TextureOrder } from '../savegame/constants.js'; import { CharacterFieldOffsets, CHARACTER_RECORD_SIZE } from '../savegame/actorConstants.js'; import { PlantFieldOffsets, PLANT_RECORD_SIZE } from '../savegame/plantConstants.js'; +import { BuildingFieldOffsets, BUILDING_RECORD_SIZE } from '../savegame/buildingConstants.js'; /** * Offsets for header fields @@ -475,6 +476,41 @@ export class SaveGameSerializer { return workingBuffer; } + /** + * Update a building field in the save file + * @param {number} buildingIndex - Building index (0-15) + * @param {string} field - Field name from BuildingFieldOffsets + * @param {number} value - New value + * @returns {ArrayBuffer} - Modified buffer + */ + updateBuilding(buildingIndex, field, value) { + const workingBuffer = this.createCopy(); + const view = new DataView(workingBuffer); + const offset = this.parsed.buildingsOffset + (buildingIndex * BUILDING_RECORD_SIZE) + BuildingFieldOffsets[field]; + + if (field === 'sound' || field === 'move') { + view.setUint32(offset, value, true); + } else if (field === 'counter') { + view.setInt8(offset, value); + } else { + view.setUint8(offset, value); + } + + return workingBuffer; + } + + /** + * Update the nextVariant field in the save file + * @param {number} value - New variant value (0-4) + * @returns {ArrayBuffer} - Modified buffer + */ + updateNextVariant(value) { + const workingBuffer = this.createCopy(); + const view = new DataView(workingBuffer); + view.setUint8(this.parsed.nextVariantOffset, value); + return workingBuffer; + } + /** * Get the byte offset for a mission score * @param {string} missionType diff --git a/src/core/rendering/ActorRenderer.js b/src/core/rendering/ActorRenderer.js index 8c1ddb9..71046ca 100644 --- a/src/core/rendering/ActorRenderer.js +++ b/src/core/rendering/ActorRenderer.js @@ -369,12 +369,13 @@ export class ActorRenderer extends AnimatedRenderer { const center = box.getCenter(new THREE.Vector3()); const size = box.getSize(new THREE.Vector3()); - this.modelGroup.position.sub(center); - const maxDim = Math.max(size.x, size.y, size.z); if (maxDim > 0) { const scale = scaleFactor / maxDim; this.modelGroup.scale.setScalar(scale); + this.modelGroup.position.copy(center).multiplyScalar(-scale); + } else { + this.modelGroup.position.sub(center); } } diff --git a/src/core/rendering/BaseRenderer.js b/src/core/rendering/BaseRenderer.js index 4cb55ad..a3398f9 100644 --- a/src/core/rendering/BaseRenderer.js +++ b/src/core/rendering/BaseRenderer.js @@ -186,12 +186,16 @@ export class BaseRenderer { const center = box.getCenter(new THREE.Vector3()); const size = box.getSize(new THREE.Vector3()); - this.modelGroup.position.sub(center); - const maxDim = Math.max(size.x, size.y, size.z); if (maxDim > 0) { const scale = scaleFactor / maxDim; this.modelGroup.scale.setScalar(scale); + // Position must account for scale: Three.js applies scale before + // translation, so vertex v maps to (position + scale * v). + // To center: position = -center * scale → v maps to scale*(v - center). + this.modelGroup.position.copy(center).multiplyScalar(-scale); + } else { + this.modelGroup.position.sub(center); } } diff --git a/src/core/rendering/BuildingRenderer.js b/src/core/rendering/BuildingRenderer.js new file mode 100644 index 0000000..13d2051 --- /dev/null +++ b/src/core/rendering/BuildingRenderer.js @@ -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; + } +} diff --git a/src/core/rendering/PlantRenderer.js b/src/core/rendering/PlantRenderer.js index 0ac3c0a..ff5e84a 100644 --- a/src/core/rendering/PlantRenderer.js +++ b/src/core/rendering/PlantRenderer.js @@ -9,14 +9,8 @@ const PLANT_COLOR_MAP = ['lego white', 'lego black', 'lego yellow', 'lego red', // Animation suffix per variant: flower→F, tree→T, bush→B, palm→P const VARIANT_ANIM_SUFFIX = ['F', 'T', 'B', 'P']; -// Per-variant display adjustments: [scaleFactor, yOffset] -// Flower is tall/wide → zoom out + shift down; others shift up to sit in frame -const VARIANT_DISPLAY = [ - [1.6, -0.1], // Flower: smaller, shifted slightly down - [1.8, 0.6], // Tree: slightly smaller, shifted up - [1.6, 1.4], // Bush: smaller, shifted well up - [2.0, 1.1], // Palm: shifted well up -]; +// Per-variant scale factors +const VARIANT_SCALE = [1.6, 1.8, 1.6, 2.0]; /** * Renderer for LEGO Island plants. Much simpler than ActorRenderer — @@ -98,9 +92,7 @@ export class PlantRenderer extends AnimatedRenderer { this.modelGroup.add(new THREE.Mesh(geometry, material)); } - 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); } diff --git a/src/core/savegame/buildingConstants.js b/src/core/savegame/buildingConstants.js new file mode 100644 index 0000000..d2ea73e --- /dev/null +++ b/src/core/savegame/buildingConstants.js @@ -0,0 +1,69 @@ +/** + * Building data constants ported from LEGO1 source: + * isle/LEGO1/lego/legoomni/src/common/legobuildingmanager.cpp + * isle/LEGO1/lego/legoomni/include/legobuildingmanager.h + */ + +export const BUILDING_COUNT = 16; +export const BUILDING_RECORD_SIZE = 10; // sound(4) + move(4) + mood(1) + counter(1) +export const NEXT_VARIANT_SIZE = 1; + +// Field byte offsets within a 10-byte building record +export const BuildingFieldOffsets = Object.freeze({ + sound: 0, // U32 LE + move: 4, // U32 LE + mood: 8, // U8 + counter: 9 // S8 +}); + +// LegoBuildingInfo feature flags (from legobuildingmanager.h enum) +export const BuildingFlags = Object.freeze({ + c_hasVariants: 0x01, + c_hasSounds: 0x02, + c_hasMoves: 0x04, + c_hasMoods: 0x08 +}); + +export const MAX_SOUND = 6; +export const MAX_MOVE = Object.freeze([0, 0, 0, 0, 0, 0, 0, 0, 0, 3, 3, 3, 3, 3, 3, 0]); +export const MAX_MOOD = 4; +export const MAX_VARIANT = 5; + +export const BUILDING_SOUND_OFFSET = 60; +export const BUILDING_MOOD_SOUND_OFFSET = 66; + +export const BuildingVariants = Object.freeze(['haus1', 'haus4', 'haus5', 'haus6', 'haus7']); +export const HAUS1_INDEX = 12; + +// g_buildingAnimationId[16] — base animation objectId per building +export const BuildingAnimationId = Object.freeze([ + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0x46, 0x49, 0x4c, 0x4f, 0x52, 0x55, 0 +]); + +// g_buildingInfoInit[16] — default values for all 16 buildings. +// Names are the m_variant field from legobuildingmanager.cpp (entity lookup names). +export const BuildingInfoInit = Object.freeze([ + /* 0 */ { name: 'infocen', sound: 4, move: 0, mood: 1, counter: -1, flags: 0x00 }, + /* 1 */ { name: 'policsta', sound: 4, move: 0, mood: 1, counter: -1, flags: 0x10 }, + /* 2 */ { name: 'Jail', sound: 4, move: 0, mood: 1, counter: -1, flags: 0x10 }, + /* 3 */ { name: 'races', sound: 4, move: 0, mood: 1, counter: -1, flags: 0x10 }, + /* 4 */ { name: 'medcntr', sound: 4, move: 0, mood: 1, counter: -1, flags: 0x10 }, + /* 5 */ { name: 'gas', sound: 4, move: 0, mood: 1, counter: -1, flags: 0x10 }, + /* 6 */ { name: 'beach', sound: 4, move: 0, mood: 1, counter: -1, flags: 0x10 }, + /* 7 */ { name: 'racef', sound: 4, move: 0, mood: 1, counter: -1, flags: 0x10 }, + /* 8 */ { name: 'racej', sound: 4, move: 0, mood: 1, counter: -1, flags: 0x10 }, + /* 9 */ { name: 'Store', sound: 4, move: 0, mood: 1, counter: -1, flags: 0x3e }, + /* 10 */ { name: 'Bank', sound: 4, move: 0, mood: 1, counter: -1, flags: 0x3e }, + /* 11 */ { name: 'Post', sound: 4, move: 0, mood: 1, counter: -1, flags: 0x3e }, + /* 12 */ { name: 'haus1', sound: 4, move: 0, mood: 1, counter: -1, flags: 0x3f }, + /* 13 */ { name: 'haus2', sound: 4, move: 0, mood: 1, counter: -1, flags: 0x3e }, + /* 14 */ { name: 'haus3', sound: 4, move: 0, mood: 1, counter: -1, flags: 0x3e }, + /* 15 */ { name: 'Pizza', sound: 4, move: 0, mood: 1, counter: -1, flags: 0x10 } +]); + +export const BuildingDisplayNames = Object.freeze([ + 'Information Center', 'Police Station', 'Jail', 'Race Stands', + 'Hospital', 'Gas Station', 'Beach House', 'Race Finish', + 'Race Tracks', 'Store', 'Bank', 'Post Office', + 'House 1', 'House 2', 'House 3', 'Pizzeria' +]); diff --git a/src/core/savegame/index.js b/src/core/savegame/index.js index 810e8f4..d283b8c 100644 --- a/src/core/savegame/index.js +++ b/src/core/savegame/index.js @@ -95,6 +95,10 @@ export async function listSaveSlots() { charactersOffset: null, plants: null, plantsOffset: null, + buildings: null, + buildingsOffset: null, + nextVariant: null, + nextVariantOffset: null, playerName: null, buffer: null }; @@ -112,6 +116,10 @@ export async function listSaveSlots() { slot.charactersOffset = parsed.charactersOffset || null; slot.plants = parsed.plants || null; slot.plantsOffset = parsed.plantsOffset || null; + slot.buildings = parsed.buildings || null; + slot.buildingsOffset = parsed.buildingsOffset || null; + slot.nextVariant = parsed.nextVariant ?? null; + slot.nextVariantOffset = parsed.nextVariantOffset || null; slot.buffer = buffer; // Try to get player name @@ -180,6 +188,10 @@ export async function loadSaveSlot(slotNumber) { charactersOffset: parsed.charactersOffset || null, plants: parsed.plants || null, plantsOffset: parsed.plantsOffset || null, + buildings: parsed.buildings || null, + buildingsOffset: parsed.buildingsOffset || null, + nextVariant: parsed.nextVariant ?? null, + nextVariantOffset: parsed.nextVariantOffset || null, playerName, buffer }; @@ -276,6 +288,29 @@ export async function updateSaveSlot(slotNumber, updates) { } } + // Apply building update(s) + if (updates.building) { + const entries = Array.isArray(updates.building) ? updates.building : [updates.building]; + for (const { buildingIndex, field, value } of entries) { + const buildingSerializer = createSerializer(newBuffer); + const result = buildingSerializer.updateBuilding(buildingIndex, field, value); + if (result) { + newBuffer = result; + modified = true; + } + } + } + + // Apply nextVariant update + if (updates.nextVariant !== undefined) { + const variantSerializer = createSerializer(newBuffer); + const result = variantSerializer.updateNextVariant(updates.nextVariant); + if (result) { + newBuffer = result; + modified = true; + } + } + // Apply texture update if (updates.texture) { const { textureName, textureData } = updates.texture; diff --git a/src/lib/SaveEditorPage.svelte b/src/lib/SaveEditorPage.svelte index 2fb01ce..da40ec5 100644 --- a/src/lib/SaveEditorPage.svelte +++ b/src/lib/SaveEditorPage.svelte @@ -8,6 +8,7 @@ import VehicleEditor from './save-editor/VehicleEditor.svelte'; import ActorEditor from './save-editor/ActorEditor.svelte'; import PlantEditor from './save-editor/PlantEditor.svelte'; + import BuildingEditor from './save-editor/BuildingEditor.svelte'; import { fetchBitmapAsURL } from '../core/assetLoader.js'; import { saveEditorState, currentPage } from '../stores.js'; import { listSaveSlots, updateSaveSlot, updatePlayerName } from '../core/savegame/index.js'; @@ -28,7 +29,8 @@ { id: 'island', label: 'Island', firstSection: 'skycolor' }, { id: 'vehicles', label: 'Vehicles', firstSection: null }, { id: 'actors', label: 'Actors', firstSection: null }, - { id: 'plants', label: 'Plants', firstSection: null } + { id: 'plants', label: 'Plants', firstSection: null }, + { id: 'buildings', label: 'Buildings', firstSection: null } ]; // Reset state when navigating to this page @@ -181,7 +183,7 @@ if (updated) { slots = slots.map(s => s.slotNumber === selectedSlot - ? { ...s, variables: updated.variables, act1State: updated.act1State, characters: updated.characters, plants: updated.plants } + ? { ...s, variables: updated.variables, act1State: updated.act1State, characters: updated.characters, plants: updated.plants, buildings: updated.buildings, buildingsOffset: updated.buildingsOffset, nextVariant: updated.nextVariant, nextVariantOffset: updated.nextVariantOffset } : s ); } @@ -486,6 +488,13 @@ {/if} + + +
+ {#if $currentPage === 'save-editor'} + + {/if} +
{/if} diff --git a/src/lib/save-editor/BuildingEditor.svelte b/src/lib/save-editor/BuildingEditor.svelte new file mode 100644 index 0000000..10e87ac --- /dev/null +++ b/src/lib/save-editor/BuildingEditor.svelte @@ -0,0 +1,347 @@ + + + renderer?.resetView()}> +
+ + + {#if loading} +
+
+
+ {:else if error} +
{error}
+ {/if} +
+ +
+
+ +
+ {buildingIndex + 1} / {BUILDING_COUNT} + {displayName}{variantLabel ? ` (${variantLabel})` : ''} +
+ +
+
+ +
+ {#if !isDefault && !loading && !error} + + {/if} +
+