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