mirror of
https://github.com/isledecomp/isle.pizza.git
synced 2026-02-28 22:07:39 +00:00
Some checks failed
Build / build (push) Has been cancelled
* Building editor Add a Buildings tab to the Save Game Editor that lets users browse all 16 buildings, preview them in 3D, and customize their properties (sound, move, mood, variant) by clicking, matching the original game behavior per character. - Parse 16 buildings + nextVariant from save files instead of skipping - Add serializer methods to patch building fields in-place - Create BuildingRenderer (extends AnimatedRenderer) for 3D preview with click animations from SNDANIM.SI - Create BuildingEditor component with per-character click behavior (Pepper: variants, Mama: sounds, Papa: moves, Laura: moods) - Extract 18 building animations and 2 building sounds into asset bundle - Fix centerAndScaleModel to account for scale in position offset * Add Building Editor to February 2026 changelog * DRY up renderer hierarchy: extract shared logic into base classes Move duplicated animation tree utilities (findAnimatedNode, evaluateNodeChain, findNodePath, evaluateLocalTransform), click animation (queueClickAnimation, playQueuedAnimation, buildRotationTracks), and raycast hit testing (getClickedMesh) from PlantRenderer and BuildingRenderer into AnimatedRenderer. Add loadTextures() and createMeshMaterial() helpers to BaseRenderer, replacing identical texture-loading loops and material-creation code across all four renderers. PlantRenderer: 279 → 73 lines (-74%) BuildingRenderer: 245 → 57 lines (-77%)
556 lines
20 KiB
JavaScript
556 lines
20 KiB
JavaScript
/**
|
|
* Serializer for modifying save game files
|
|
* Uses a "patch in place" approach - copies the original buffer and modifies specific bytes
|
|
*/
|
|
import { SaveGameParser } from './SaveGameParser.js';
|
|
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
|
|
*/
|
|
const HEADER_OFFSETS = {
|
|
VERSION: 0, // S32, 4 bytes
|
|
PLAYER_ID: 4, // S16, 2 bytes
|
|
CURRENT_ACT: 6, // U16, 2 bytes
|
|
ACTOR_ID: 8 // U8, 1 byte
|
|
};
|
|
|
|
/**
|
|
* Map mission type to state name
|
|
*/
|
|
const MISSION_STATE_MAP = {
|
|
pizza: GameStateTypes.PIZZA_MISSION,
|
|
carRace: GameStateTypes.CAR_RACE,
|
|
jetskiRace: GameStateTypes.JETSKI_RACE,
|
|
towTrack: GameStateTypes.TOW_TRACK,
|
|
ambulance: GameStateTypes.AMBULANCE
|
|
};
|
|
|
|
/**
|
|
* Serializer for modifying save game files
|
|
*/
|
|
export class SaveGameSerializer {
|
|
/**
|
|
* @param {ArrayBuffer} originalBuffer - Original file data
|
|
*/
|
|
constructor(originalBuffer) {
|
|
this.original = originalBuffer;
|
|
// Parse to find state locations
|
|
this.parser = new SaveGameParser(originalBuffer);
|
|
this.parsed = this.parser.parse();
|
|
}
|
|
|
|
/**
|
|
* Create a copy of the original buffer for modification
|
|
* @returns {ArrayBuffer}
|
|
*/
|
|
createCopy() {
|
|
return this.original.slice(0);
|
|
}
|
|
|
|
/**
|
|
* Update header fields (act and actor)
|
|
* @param {Object} updates - Fields to update
|
|
* @param {number} [updates.currentAct] - New act (0-2)
|
|
* @param {number} [updates.actorId] - New actor ID (1-5)
|
|
* @returns {ArrayBuffer} - Modified buffer
|
|
*/
|
|
updateHeader(updates) {
|
|
const copy = this.createCopy();
|
|
const view = new DataView(copy);
|
|
|
|
if ('currentAct' in updates && updates.currentAct !== undefined) {
|
|
view.setUint16(HEADER_OFFSETS.CURRENT_ACT, updates.currentAct, true);
|
|
}
|
|
|
|
if ('actorId' in updates && updates.actorId !== undefined) {
|
|
view.setUint8(HEADER_OFFSETS.ACTOR_ID, updates.actorId);
|
|
}
|
|
|
|
return copy;
|
|
}
|
|
|
|
/**
|
|
* Create default state data for a mission type
|
|
* @param {string} stateName - State name (e.g., 'CarRaceState')
|
|
* @returns {Uint8Array} - State data with default values (all zeros)
|
|
*/
|
|
createDefaultStateData(stateName) {
|
|
const size = GameStateSizes[stateName];
|
|
if (!size) {
|
|
throw new Error(`Unknown state size for: ${stateName}`);
|
|
}
|
|
|
|
const data = new Uint8Array(size);
|
|
|
|
// For race states, we need to set the actor IDs
|
|
if (stateName === GameStateTypes.CAR_RACE || stateName === GameStateTypes.JETSKI_RACE) {
|
|
// 5 entries of 5 bytes: id(1) + score(2) + hiScore(2)
|
|
for (let i = 0; i < 5; i++) {
|
|
data[i * 5] = Actor.PEPPER + i; // Set actor ID
|
|
}
|
|
}
|
|
|
|
return data;
|
|
}
|
|
|
|
/**
|
|
* Add a missing state to the save file
|
|
* @param {ArrayBuffer} buffer - Current buffer
|
|
* @param {string} stateName - State name to add
|
|
* @returns {ArrayBuffer} - New buffer with state added
|
|
*/
|
|
addMissingState(buffer, stateName) {
|
|
const stateData = this.createDefaultStateData(stateName);
|
|
const nameBytes = new TextEncoder().encode(stateName);
|
|
|
|
// Calculate new state size: name length (2) + name + data
|
|
const stateSize = 2 + nameBytes.length + stateData.length;
|
|
|
|
// Insert position is where states end (before previous_area field)
|
|
const insertOffset = this.parsed.statesEndOffset;
|
|
|
|
// Create new buffer with space for the new state
|
|
const newBuffer = new ArrayBuffer(buffer.byteLength + stateSize);
|
|
const newView = new DataView(newBuffer);
|
|
const newArray = new Uint8Array(newBuffer);
|
|
const oldArray = new Uint8Array(buffer);
|
|
|
|
// Copy everything up to insert point
|
|
newArray.set(oldArray.slice(0, insertOffset));
|
|
|
|
// Write new state at insert point
|
|
let offset = insertOffset;
|
|
|
|
// Write name length (S16)
|
|
newView.setInt16(offset, nameBytes.length, true);
|
|
offset += 2;
|
|
|
|
// Write name
|
|
newArray.set(nameBytes, offset);
|
|
offset += nameBytes.length;
|
|
|
|
// Write state data
|
|
newArray.set(stateData, offset);
|
|
offset += stateData.length;
|
|
|
|
// Copy the rest of the original buffer (previous_area field)
|
|
newArray.set(oldArray.slice(insertOffset), offset);
|
|
|
|
// Increment state count
|
|
const stateCountOffset = this.parsed.stateCountOffset;
|
|
const oldCount = new DataView(buffer).getInt16(stateCountOffset, true);
|
|
newView.setInt16(stateCountOffset, oldCount + 1, true);
|
|
|
|
// Update parsed data to include the new state
|
|
const dataOffset = insertOffset + 2 + nameBytes.length;
|
|
this.parsed.stateLocations.push({
|
|
name: stateName,
|
|
nameOffset: insertOffset,
|
|
dataOffset: dataOffset,
|
|
dataSize: stateData.length
|
|
});
|
|
this.parsed.stateCount++;
|
|
this.parsed.statesEndOffset = insertOffset + stateSize;
|
|
|
|
// Update original reference for subsequent operations
|
|
this.original = newBuffer;
|
|
|
|
return newBuffer;
|
|
}
|
|
|
|
/**
|
|
* Ensure a mission state exists in the buffer, adding it if missing
|
|
* @param {ArrayBuffer} buffer - Current buffer
|
|
* @param {string} missionType - Mission type
|
|
* @returns {ArrayBuffer} - Buffer with state present
|
|
*/
|
|
ensureMissionState(buffer, missionType) {
|
|
const stateName = MISSION_STATE_MAP[missionType];
|
|
if (!stateName) {
|
|
throw new Error(`Unknown mission type: ${missionType}`);
|
|
}
|
|
|
|
const stateLocation = this.parsed.stateLocations.find(loc => loc.name === stateName);
|
|
if (stateLocation) {
|
|
return buffer; // State already exists
|
|
}
|
|
|
|
return this.addMissingState(buffer, stateName);
|
|
}
|
|
|
|
/**
|
|
* Update mission scores in the save file
|
|
* @param {string} missionType - Mission type (pizza, carRace, jetskiRace, towTrack, ambulance)
|
|
* @param {number} actorId - Actor ID (1-5)
|
|
* @param {'score'|'highScore'} scoreType - Which score to update
|
|
* @param {number} value - New score color value (0-3)
|
|
* @param {ArrayBuffer} [buffer] - Optional buffer to use (for chaining operations)
|
|
* @returns {ArrayBuffer} - Modified buffer
|
|
*/
|
|
updateMissionScore(missionType, actorId, scoreType, value, buffer = null) {
|
|
const stateName = MISSION_STATE_MAP[missionType];
|
|
if (!stateName) {
|
|
console.error(`Unknown mission type: ${missionType}`);
|
|
return null;
|
|
}
|
|
|
|
// Use provided buffer or create a copy
|
|
let workingBuffer = buffer || this.createCopy();
|
|
|
|
// Ensure the state exists
|
|
workingBuffer = this.ensureMissionState(workingBuffer, missionType);
|
|
|
|
const stateLocation = this.parsed.stateLocations.find(loc => loc.name === stateName);
|
|
if (!stateLocation) {
|
|
console.error(`Failed to find or create state: ${stateName}`);
|
|
return null;
|
|
}
|
|
|
|
const offset = this.getMissionScoreOffset(missionType, actorId, scoreType);
|
|
if (offset !== null) {
|
|
new DataView(workingBuffer).setInt16(offset, value, true);
|
|
}
|
|
|
|
return workingBuffer;
|
|
}
|
|
|
|
/**
|
|
* Apply multiple updates at once
|
|
* @param {Object} updates - Updates to apply
|
|
* @param {Object} [updates.header] - Header updates
|
|
* @param {Array} [updates.missions] - Mission score updates
|
|
* @returns {ArrayBuffer} - Modified buffer
|
|
*/
|
|
applyUpdates(updates) {
|
|
let buffer = this.original;
|
|
|
|
// Apply header updates first
|
|
if (updates.header) {
|
|
const view = new DataView(buffer = buffer.slice(0));
|
|
|
|
if ('currentAct' in updates.header) {
|
|
view.setUint16(HEADER_OFFSETS.CURRENT_ACT, updates.header.currentAct, true);
|
|
}
|
|
if ('actorId' in updates.header) {
|
|
view.setUint8(HEADER_OFFSETS.ACTOR_ID, updates.header.actorId);
|
|
}
|
|
}
|
|
|
|
// Apply mission updates
|
|
if (updates.missions && updates.missions.length > 0) {
|
|
for (const mission of updates.missions) {
|
|
buffer = this.updateMissionScore(
|
|
mission.missionType,
|
|
mission.actorId,
|
|
mission.scoreType,
|
|
mission.value,
|
|
buffer
|
|
);
|
|
}
|
|
}
|
|
|
|
return buffer;
|
|
}
|
|
|
|
/**
|
|
* Update a variable value in the save file
|
|
* @param {string} name - Variable name
|
|
* @param {string} newValue - New value string
|
|
* @param {ArrayBuffer} [buffer] - Optional buffer to use (for chaining operations)
|
|
* @returns {ArrayBuffer|null} - Modified buffer or null on error
|
|
*/
|
|
updateVariable(name, newValue, buffer = null) {
|
|
const varInfo = this.parsed.variables.get(name);
|
|
if (!varInfo) {
|
|
console.error(`Variable not found: ${name}`);
|
|
return null;
|
|
}
|
|
|
|
const workingBuffer = buffer || this.createCopy();
|
|
const array = new Uint8Array(workingBuffer);
|
|
const encoder = new TextEncoder();
|
|
|
|
const oldLength = varInfo.valueLength;
|
|
const newLength = newValue.length;
|
|
|
|
if (oldLength === newLength) {
|
|
// In-place update - same length, just replace bytes
|
|
const valueBytes = encoder.encode(newValue);
|
|
array.set(valueBytes, varInfo.valueOffset + 1); // +1 for length byte
|
|
return workingBuffer;
|
|
} else {
|
|
// Different length - need to rebuild buffer
|
|
const oldArray = new Uint8Array(workingBuffer);
|
|
const sizeDiff = newLength - oldLength;
|
|
const newBuffer = new ArrayBuffer(workingBuffer.byteLength + sizeDiff);
|
|
const newArray = new Uint8Array(newBuffer);
|
|
|
|
// Copy everything up to the value (including the length byte position)
|
|
const valueDataStart = varInfo.valueOffset + 1; // After length byte
|
|
newArray.set(oldArray.slice(0, varInfo.valueOffset));
|
|
|
|
// Write new length byte
|
|
newArray[varInfo.valueOffset] = newLength;
|
|
|
|
// Write new value
|
|
const valueBytes = encoder.encode(newValue);
|
|
newArray.set(valueBytes, valueDataStart);
|
|
|
|
// Copy everything after the old value
|
|
const afterOldValue = valueDataStart + oldLength;
|
|
newArray.set(oldArray.slice(afterOldValue), valueDataStart + newLength);
|
|
|
|
return newBuffer;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Update an Act1State texture in the save file
|
|
* @param {string} textureName - Texture name (e.g. 'chwind.gif')
|
|
* @param {{ palette: Array<{r,g,b}>, pixels: Uint8Array, width: number, height: number }} newTextureData
|
|
* @param {ArrayBuffer} [buffer] - Optional buffer to use
|
|
* @returns {ArrayBuffer|null} - Modified buffer or null on error
|
|
*/
|
|
updateAct1Texture(textureName, newTextureData, buffer = null) {
|
|
const workingBuffer = buffer || this.createCopy();
|
|
|
|
// Re-parse to get fresh Act1State from the working buffer
|
|
const freshParser = new SaveGameParser(workingBuffer);
|
|
const freshParsed = freshParser.parse();
|
|
const act1State = freshParsed.act1State;
|
|
|
|
if (!act1State) {
|
|
console.error('Act1State not found in save file');
|
|
return null;
|
|
}
|
|
|
|
const act1Location = freshParsed.stateLocations.find(loc => loc.name === 'Act1State');
|
|
if (!act1Location) {
|
|
console.error('Act1State location not found');
|
|
return null;
|
|
}
|
|
|
|
const targetKey = textureName.toLowerCase();
|
|
if (!act1State.textures.has(targetKey)) {
|
|
console.error(`Texture not found in Act1State: ${textureName}`);
|
|
return null;
|
|
}
|
|
|
|
// Replace texture data, preserving original name
|
|
const oldTex = act1State.textures.get(targetKey);
|
|
act1State.textures.set(targetKey, {
|
|
name: oldTex.name,
|
|
width: newTextureData.width,
|
|
height: newTextureData.height,
|
|
paletteSize: newTextureData.palette.length,
|
|
palette: newTextureData.palette,
|
|
pixels: newTextureData.pixels
|
|
});
|
|
|
|
return this._rebuildAct1State(workingBuffer, act1Location, act1State);
|
|
}
|
|
|
|
/**
|
|
* Rebuild the full buffer with updated Act1State
|
|
* @private
|
|
*/
|
|
_rebuildAct1State(sourceBuffer, act1Location, act1State) {
|
|
const writer = new BinaryWriter(sourceBuffer.byteLength + 4096);
|
|
const srcArray = new Uint8Array(sourceBuffer);
|
|
|
|
// Write 7 planes
|
|
let readOffset = act1Location.dataOffset;
|
|
for (const plane of act1State.planes) {
|
|
writer.writeS16(plane.nameLength);
|
|
readOffset += 2;
|
|
if (plane.nameLength > 0) {
|
|
writer.writeString(plane.name);
|
|
readOffset += plane.nameLength;
|
|
}
|
|
// Copy 36 bytes of position/direction/up from source
|
|
writer.writeBytes(srcArray.slice(readOffset, readOffset + 36));
|
|
readOffset += 36;
|
|
}
|
|
|
|
// Write conditional textures in correct order
|
|
const vehicleOrder = ['helicopter', 'jetski', 'dunebuggy', 'racecar'];
|
|
const planeIndices = [3, 4, 5, 6];
|
|
|
|
for (let v = 0; v < vehicleOrder.length; v++) {
|
|
const vehicleName = vehicleOrder[v];
|
|
const planeIdx = planeIndices[v];
|
|
if (act1State.planes[planeIdx].nameLength <= 0) continue;
|
|
|
|
const textureNames = Act1TextureOrder[vehicleName];
|
|
for (const texName of textureNames) {
|
|
const texKey = texName.toLowerCase();
|
|
const tex = act1State.textures.get(texKey);
|
|
if (!tex) continue;
|
|
|
|
writer.writeS16(tex.name.length);
|
|
writer.writeString(tex.name);
|
|
writer.writeU32(tex.width);
|
|
writer.writeU32(tex.height);
|
|
writer.writeU32(tex.paletteSize);
|
|
for (const color of tex.palette) {
|
|
writer.writeU8(color.r);
|
|
writer.writeU8(color.g);
|
|
writer.writeU8(color.b);
|
|
}
|
|
writer.writeBytes(tex.pixels);
|
|
}
|
|
}
|
|
|
|
// Write final fields
|
|
writer.writeS16(act1State.cptClickDialogueNextIndex);
|
|
writer.writeU8(act1State.playedExitExplanation);
|
|
|
|
const newAct1Data = writer.toUint8Array();
|
|
const oldAct1Size = act1Location.dataSize;
|
|
const newAct1Size = newAct1Data.length;
|
|
const sizeDiff = newAct1Size - oldAct1Size;
|
|
|
|
// Build final buffer
|
|
const newBuffer = new ArrayBuffer(sourceBuffer.byteLength + sizeDiff);
|
|
const newArray = new Uint8Array(newBuffer);
|
|
|
|
// Copy everything before Act1State data
|
|
newArray.set(srcArray.slice(0, act1Location.dataOffset));
|
|
|
|
// Write new Act1State data
|
|
newArray.set(newAct1Data, act1Location.dataOffset);
|
|
|
|
// Copy everything after old Act1State data
|
|
const afterOld = act1Location.dataOffset + oldAct1Size;
|
|
newArray.set(srcArray.slice(afterOld), act1Location.dataOffset + newAct1Size);
|
|
|
|
return newBuffer;
|
|
}
|
|
|
|
/**
|
|
* Update a character field in the save file
|
|
* @param {number} characterIndex - Character index (0-65)
|
|
* @param {string} field - Field name from CharacterFieldOffsets
|
|
* @param {number} value - New value
|
|
* @returns {ArrayBuffer} - Modified buffer
|
|
*/
|
|
updateCharacter(characterIndex, field, value) {
|
|
const workingBuffer = this.createCopy();
|
|
const view = new DataView(workingBuffer);
|
|
const offset = this.parsed.charactersOffset + (characterIndex * CHARACTER_RECORD_SIZE) + CharacterFieldOffsets[field];
|
|
|
|
if (field === 'sound' || field === 'move') {
|
|
view.setInt32(offset, value, true);
|
|
} else {
|
|
view.setUint8(offset, value);
|
|
}
|
|
|
|
return workingBuffer;
|
|
}
|
|
|
|
/**
|
|
* Update a plant field in the save file
|
|
* @param {number} plantIndex - Plant index (0-80)
|
|
* @param {string} field - Field name from PlantFieldOffsets
|
|
* @param {number} value - New value
|
|
* @returns {ArrayBuffer} - Modified buffer
|
|
*/
|
|
updatePlant(plantIndex, field, value) {
|
|
const workingBuffer = this.createCopy();
|
|
const view = new DataView(workingBuffer);
|
|
const offset = this.parsed.plantsOffset + (plantIndex * PLANT_RECORD_SIZE) + PlantFieldOffsets[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 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
|
|
* @param {number} actorId
|
|
* @param {'score'|'highScore'} scoreType
|
|
* @returns {number|null}
|
|
*/
|
|
getMissionScoreOffset(missionType, actorId, scoreType) {
|
|
const stateName = MISSION_STATE_MAP[missionType];
|
|
if (!stateName) return null;
|
|
|
|
const stateLocation = this.parsed.stateLocations.find(loc => loc.name === stateName);
|
|
if (!stateLocation) return null;
|
|
|
|
const actorIndex = actorId - Actor.PEPPER;
|
|
|
|
if (missionType === 'pizza') {
|
|
const entryOffset = stateLocation.dataOffset + (actorIndex * 8);
|
|
return scoreType === 'score' ? entryOffset + 4 : entryOffset + 6;
|
|
} else if (missionType === 'carRace' || missionType === 'jetskiRace') {
|
|
const entryOffset = stateLocation.dataOffset + (actorIndex * 5);
|
|
return scoreType === 'score' ? entryOffset + 1 : entryOffset + 3;
|
|
} else if (missionType === 'towTrack' || missionType === 'ambulance') {
|
|
if (scoreType === 'score') {
|
|
return stateLocation.dataOffset + (actorIndex * 2);
|
|
} else {
|
|
return stateLocation.dataOffset + 10 + (actorIndex * 2);
|
|
}
|
|
}
|
|
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Create a serializer for a save game buffer
|
|
* @param {ArrayBuffer} buffer
|
|
* @returns {SaveGameSerializer}
|
|
*/
|
|
export function createSerializer(buffer) {
|
|
return new SaveGameSerializer(buffer);
|
|
}
|