Add save game editor (#12)
Some checks are pending
Build / build (push) Waiting to run

* WIP

* stuff

* WIP: Interactive 3D score cube for save editor

* Conditionally render ScoreCube to properly clean up WebGL canvas

Only mount the ScoreCube component when on the save-editor page.
This ensures onDestroy is called when navigating away, properly
disposing of the WebGL renderer and removing the canvas from DOM.

* Refactor: Consolidate formats and genericize WDB rendering

- Move parsing/serialization code to src/core/formats/:
  - BinaryReader.js, BinaryWriter.js (shared utilities)
  - SaveGameParser.js, SaveGameSerializer.js
  - PlayersParser.js, PlayersSerializer.js
  - Create formats/index.js as barrel export

- Extract generic WdbModelRenderer from ScoreCubeRenderer:
  - WdbModelRenderer handles D3DRM geometry and paletted textures
  - ScoreCubeRenderer extends it with score-specific logic
  - Prepares for rendering other WDB models in the future

- Keep savegame/constants.js for domain-specific constants
- savegame/index.js remains as high-level API facade

* Save editor UI improvements

- Make save slot cards fixed width (85px) to prevent resizing with name length
- Make save slot cards more compact (smaller icons, padding, font)
- Remove Act selection from Character section
- Remove box-shadow from selected character to fix collapsed section bleed

* Improve score cube lighting to match in-game appearance

- Use flat, even lighting (high ambient + soft front light)
- Remove harsh directional shadows from edges
- Adjust camera position slightly for better framing

* Add spacing below score cube canvas

* Add spinning loader while loading score cube

* Update three.js to 0.182.0 and fix npm audit issues

- Update three.js from 0.170.0 to 0.182.0
- Fix npm audit vulnerabilities (devalue, lodash, svelte)
- Remaining vulns are in dev dependencies (vite, workbox-cli)

* Fix score cube overflow on mobile

Add max-width constraints to prevent the score cube from expanding
its container on narrow viewports while preserving its natural
200x200 size on desktop.

* Add save slot carousel and improve empty states

- Add reusable Carousel component with arrow navigation, drag-to-scroll,
  and click-to-scroll-into-view functionality
- Replace static save slot list with horizontal carousel
- Add empty state with image when no save files exist
- Add prompt state when saves exist but none is selected
- Reset selected slot when entering Save Editor page

* Add February 2026 changelog entry for Save Editor

* Add missing January 2026 changelog entries for Safari and mobile fixes

* Remove unused mission images

* Refactor opfs.js to reduce code duplication

- Consolidate getFileHandle to use getOpfsRoot internally
- Add writeTextFile helper that uses writeBinaryFile
- Extract showToast helper for toast notifications
- Simplify saveConfig to use writeTextFile instead of duplicate worker
- Simplify fileExists and readBinaryFile to use getFileHandle

* Remove unused ScoreColorButton component

* Remove unused UI components
This commit is contained in:
Christian Semmler 2026-01-31 15:28:16 -08:00 committed by GitHub
parent 8f374561e5
commit 64be72194e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
39 changed files with 3723 additions and 162 deletions

44
package-lock.json generated
View File

@ -7,6 +7,9 @@
"": { "": {
"name": "isle.pizza", "name": "isle.pizza",
"version": "1.0.0", "version": "1.0.0",
"dependencies": {
"three": "^0.182.0"
},
"devDependencies": { "devDependencies": {
"@sveltejs/vite-plugin-svelte": "^4.0.0", "@sveltejs/vite-plugin-svelte": "^4.0.0",
"svelte": "^5.0.0", "svelte": "^5.0.0",
@ -3583,10 +3586,11 @@
} }
}, },
"node_modules/devalue": { "node_modules/devalue": {
"version": "5.6.1", "version": "5.6.2",
"resolved": "https://registry.npmjs.org/devalue/-/devalue-5.6.1.tgz", "resolved": "https://registry.npmjs.org/devalue/-/devalue-5.6.2.tgz",
"integrity": "sha512-jDwizj+IlEZBunHcOuuFVBnIMPAEHvTsJj0BcIp94xYguLRVBcXO853px/MyIJvbVzWdsGvrRweIUWJw8hBP7A==", "integrity": "sha512-nPRkjWzzDQlsejL1WVifk5rvcFi/y1onBRxjaFMjZeR9mFpqu2gmAZ9xUB9/IEanEP/vBtGeGganC/GO1fmufg==",
"dev": true "dev": true,
"license": "MIT"
}, },
"node_modules/dot-prop": { "node_modules/dot-prop": {
"version": "9.0.0", "version": "9.0.0",
@ -3876,10 +3880,11 @@
"dev": true "dev": true
}, },
"node_modules/esrap": { "node_modules/esrap": {
"version": "2.2.1", "version": "2.2.2",
"resolved": "https://registry.npmjs.org/esrap/-/esrap-2.2.1.tgz", "resolved": "https://registry.npmjs.org/esrap/-/esrap-2.2.2.tgz",
"integrity": "sha512-GiYWG34AN/4CUyaWAgunGt0Rxvr1PTMlGC0vvEov/uOQYWne2bpN03Um+k8jT+q3op33mKouP2zeJ6OlM+qeUg==", "integrity": "sha512-zA6497ha+qKvoWIK+WM9NAh5ni17sKZKhbS5B3PoYbBvaYHZWoS33zmFybmyqpn07RLUxSmn+RCls2/XF+d0oQ==",
"dev": true, "dev": true,
"license": "MIT",
"dependencies": { "dependencies": {
"@jridgewell/sourcemap-codec": "^1.4.15" "@jridgewell/sourcemap-codec": "^1.4.15"
} }
@ -5192,10 +5197,11 @@
} }
}, },
"node_modules/lodash": { "node_modules/lodash": {
"version": "4.17.21", "version": "4.17.23",
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.23.tgz",
"integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==", "integrity": "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==",
"dev": true "dev": true,
"license": "MIT"
}, },
"node_modules/lodash.debounce": { "node_modules/lodash.debounce": {
"version": "4.0.8", "version": "4.0.8",
@ -6715,10 +6721,11 @@
} }
}, },
"node_modules/svelte": { "node_modules/svelte": {
"version": "5.46.1", "version": "5.49.1",
"resolved": "https://registry.npmjs.org/svelte/-/svelte-5.46.1.tgz", "resolved": "https://registry.npmjs.org/svelte/-/svelte-5.49.1.tgz",
"integrity": "sha512-ynjfCHD3nP2el70kN5Pmg37sSi0EjOm9FgHYQdC4giWG/hzO3AatzXXJJgP305uIhGQxSufJLuYWtkY8uK/8RA==", "integrity": "sha512-jj95WnbKbXsXXngYj28a4zx8jeZx50CN/J4r0CEeax2pbfdsETv/J1K8V9Hbu3DCXnpHz5qAikICuxEooi7eNQ==",
"dev": true, "dev": true,
"license": "MIT",
"dependencies": { "dependencies": {
"@jridgewell/remapping": "^2.3.4", "@jridgewell/remapping": "^2.3.4",
"@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/sourcemap-codec": "^1.5.0",
@ -6728,9 +6735,9 @@
"aria-query": "^5.3.1", "aria-query": "^5.3.1",
"axobject-query": "^4.1.0", "axobject-query": "^4.1.0",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"devalue": "^5.5.0", "devalue": "^5.6.2",
"esm-env": "^1.2.1", "esm-env": "^1.2.1",
"esrap": "^2.2.1", "esrap": "^2.2.2",
"is-reference": "^3.0.3", "is-reference": "^3.0.3",
"locate-character": "^3.0.0", "locate-character": "^3.0.0",
"magic-string": "^0.30.11", "magic-string": "^0.30.11",
@ -6879,6 +6886,11 @@
"node": ">=10" "node": ">=10"
} }
}, },
"node_modules/three": {
"version": "0.182.0",
"resolved": "https://registry.npmjs.org/three/-/three-0.182.0.tgz",
"integrity": "sha512-GbHabT+Irv+ihI1/f5kIIsZ+Ef9Sl5A1Y7imvS5RQjWgtTPfPnZ43JmlYI7NtCRDK9zir20lQpfg8/9Yd02OvQ=="
},
"node_modules/through": { "node_modules/through": {
"version": "2.3.8", "version": "2.3.8",
"resolved": "https://registry.npmjs.org/through/-/through-2.3.8.tgz", "resolved": "https://registry.npmjs.org/through/-/through-2.3.8.tgz",

View File

@ -10,6 +10,9 @@
"preview": "vite preview", "preview": "vite preview",
"prepare:assets": "node scripts/prepare.js" "prepare:assets": "node scripts/prepare.js"
}, },
"dependencies": {
"three": "^0.182.0"
},
"devDependencies": { "devDependencies": {
"@sveltejs/vite-plugin-svelte": "^4.0.0", "@sveltejs/vite-plugin-svelte": "^4.0.0",
"svelte": "^5.0.0", "svelte": "^5.0.0",

BIN
public/laura-selected.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 918 B

BIN
public/laura.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1018 B

BIN
public/mama-selected.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 964 B

BIN
public/mama.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1016 B

BIN
public/nick-selected.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 876 B

BIN
public/nick.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 986 B

BIN
public/papa-selected.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 948 B

BIN
public/papa.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 980 B

BIN
public/pepper-selected.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 844 B

BIN
public/pepper.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 946 B

BIN
public/save.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

View File

@ -8,6 +8,7 @@
import ReadMePage from './lib/ReadMePage.svelte'; import ReadMePage from './lib/ReadMePage.svelte';
import ConfigurePage from './lib/ConfigurePage.svelte'; import ConfigurePage from './lib/ConfigurePage.svelte';
import FreeStuffPage from './lib/FreeStuffPage.svelte'; import FreeStuffPage from './lib/FreeStuffPage.svelte';
import SaveEditorPage from './lib/SaveEditorPage.svelte';
import UpdatePopup from './lib/UpdatePopup.svelte'; import UpdatePopup from './lib/UpdatePopup.svelte';
import GoodbyePopup from './lib/GoodbyePopup.svelte'; import GoodbyePopup from './lib/GoodbyePopup.svelte';
import ConfigToast from './lib/ConfigToast.svelte'; import ConfigToast from './lib/ConfigToast.svelte';
@ -76,6 +77,9 @@
<div class="page-wrapper" class:active={$currentPage === 'free-stuff'}> <div class="page-wrapper" class:active={$currentPage === 'free-stuff'}>
<FreeStuffPage /> <FreeStuffPage />
</div> </div>
<div class="page-wrapper" class:active={$currentPage === 'save-editor'}>
<SaveEditorPage />
</div>
<div class="footer-disclaimer"> <div class="footer-disclaimer">
<p>LEGO® and LEGO Island™ are trademarks of The LEGO Group.</p> <p>LEGO® and LEGO Island™ are trademarks of The LEGO Group.</p>

View File

@ -412,7 +412,8 @@ body {
text-decoration: underline; text-decoration: underline;
} }
#configure-page .page-inner-content.config-layout { #configure-page .page-inner-content.config-layout,
#save-editor .page-inner-content.config-layout {
display: flex; display: flex;
background-color: var(--color-bg-input); background-color: var(--color-bg-input);
border: 1px solid #303030; border: 1px solid #303030;
@ -563,6 +564,27 @@ body {
content: ''; content: '';
} }
.config-card-header.nav-link {
text-decoration: none;
display: flex;
box-sizing: border-box;
}
.config-card-header.nav-link:hover,
.config-card-header.nav-link:focus,
.config-card-header.nav-link:active {
text-decoration: none;
}
.config-card-header.nav-link::after {
content: '→';
transition: color 0.2s ease;
}
.config-card-header.nav-link:hover::after {
color: var(--color-text-light);
}
.config-card-content { .config-card-content {
display: grid; display: grid;
grid-template-rows: 0fr; grid-template-rows: 0fr;
@ -1388,7 +1410,8 @@ select:focus {
display: none; display: none;
} }
#configure-page .page-inner-content.config-layout { #configure-page .page-inner-content.config-layout,
#save-editor .page-inner-content.config-layout {
background-color: transparent; background-color: transparent;
border: none; border: none;
padding: 0; padding: 0;

View File

@ -0,0 +1,164 @@
/**
* Little-endian binary reader for parsing save game files
*/
export class BinaryReader {
/**
* @param {ArrayBuffer} buffer - The binary data to read
*/
constructor(buffer) {
this.buffer = buffer;
this.view = new DataView(buffer);
this.offset = 0;
}
/**
* Read signed 8-bit integer
* @returns {number}
*/
readS8() {
const value = this.view.getInt8(this.offset);
this.offset += 1;
return value;
}
/**
* Read unsigned 8-bit integer
* @returns {number}
*/
readU8() {
const value = this.view.getUint8(this.offset);
this.offset += 1;
return value;
}
/**
* Read signed 16-bit integer (little-endian)
* @returns {number}
*/
readS16() {
const value = this.view.getInt16(this.offset, true);
this.offset += 2;
return value;
}
/**
* Read unsigned 16-bit integer (little-endian)
* @returns {number}
*/
readU16() {
const value = this.view.getUint16(this.offset, true);
this.offset += 2;
return value;
}
/**
* Read signed 32-bit integer (little-endian)
* @returns {number}
*/
readS32() {
const value = this.view.getInt32(this.offset, true);
this.offset += 4;
return value;
}
/**
* Read unsigned 32-bit integer (little-endian)
* @returns {number}
*/
readU32() {
const value = this.view.getUint32(this.offset, true);
this.offset += 4;
return value;
}
/**
* Read 32-bit float (little-endian)
* @returns {number}
*/
readF32() {
const value = this.view.getFloat32(this.offset, true);
this.offset += 4;
return value;
}
/**
* Read ASCII string of specified length
* @param {number} length - Number of bytes to read
* @returns {string}
*/
readString(length) {
const bytes = new Uint8Array(this.buffer, this.offset, length);
this.offset += length;
return String.fromCharCode(...bytes);
}
/**
* Read length-prefixed string (U8 length prefix)
* @returns {string}
*/
readLengthPrefixedString() {
const length = this.readU8();
return this.readString(length);
}
/**
* Read length-prefixed string (S16 length prefix)
* @returns {string}
*/
readLengthPrefixedStringS16() {
const length = this.readS16();
if (length <= 0) return '';
return this.readString(length);
}
/**
* Seek to absolute position
* @param {number} offset
*/
seek(offset) {
this.offset = offset;
}
/**
* Skip bytes relative to current position
* @param {number} bytes
*/
skip(bytes) {
this.offset += bytes;
}
/**
* Get current position
* @returns {number}
*/
tell() {
return this.offset;
}
/**
* Get remaining bytes
* @returns {number}
*/
remaining() {
return this.buffer.byteLength - this.offset;
}
/**
* Check if at end of buffer
* @returns {boolean}
*/
eof() {
return this.offset >= this.buffer.byteLength;
}
/**
* Slice out a portion of the buffer
* @param {number} length
* @returns {ArrayBuffer}
*/
slice(length) {
const sliced = this.buffer.slice(this.offset, this.offset + length);
this.offset += length;
return sliced;
}
}

View File

@ -0,0 +1,172 @@
/**
* Little-endian binary writer for serializing save game files
*/
export class BinaryWriter {
/**
* @param {number} initialSize - Initial buffer size (will grow as needed)
*/
constructor(initialSize = 1024) {
this.buffer = new ArrayBuffer(initialSize);
this.view = new DataView(this.buffer);
this.offset = 0;
}
/**
* Ensure buffer has capacity for additional bytes
* @param {number} additionalBytes
*/
ensureCapacity(additionalBytes) {
const required = this.offset + additionalBytes;
if (required > this.buffer.byteLength) {
const newSize = Math.max(required, this.buffer.byteLength * 2);
const newBuffer = new ArrayBuffer(newSize);
new Uint8Array(newBuffer).set(new Uint8Array(this.buffer));
this.buffer = newBuffer;
this.view = new DataView(this.buffer);
}
}
/**
* Write signed 8-bit integer
* @param {number} value
*/
writeS8(value) {
this.ensureCapacity(1);
this.view.setInt8(this.offset, value);
this.offset += 1;
}
/**
* Write unsigned 8-bit integer
* @param {number} value
*/
writeU8(value) {
this.ensureCapacity(1);
this.view.setUint8(this.offset, value);
this.offset += 1;
}
/**
* Write signed 16-bit integer (little-endian)
* @param {number} value
*/
writeS16(value) {
this.ensureCapacity(2);
this.view.setInt16(this.offset, value, true);
this.offset += 2;
}
/**
* Write unsigned 16-bit integer (little-endian)
* @param {number} value
*/
writeU16(value) {
this.ensureCapacity(2);
this.view.setUint16(this.offset, value, true);
this.offset += 2;
}
/**
* Write signed 32-bit integer (little-endian)
* @param {number} value
*/
writeS32(value) {
this.ensureCapacity(4);
this.view.setInt32(this.offset, value, true);
this.offset += 4;
}
/**
* Write unsigned 32-bit integer (little-endian)
* @param {number} value
*/
writeU32(value) {
this.ensureCapacity(4);
this.view.setUint32(this.offset, value, true);
this.offset += 4;
}
/**
* Write 32-bit float (little-endian)
* @param {number} value
*/
writeF32(value) {
this.ensureCapacity(4);
this.view.setFloat32(this.offset, value, true);
this.offset += 4;
}
/**
* Write ASCII string (no length prefix)
* @param {string} str
*/
writeString(str) {
this.ensureCapacity(str.length);
for (let i = 0; i < str.length; i++) {
this.view.setUint8(this.offset + i, str.charCodeAt(i));
}
this.offset += str.length;
}
/**
* Write length-prefixed string (U8 length prefix)
* @param {string} str
*/
writeLengthPrefixedString(str) {
this.writeU8(str.length);
this.writeString(str);
}
/**
* Write length-prefixed string (S16 length prefix)
* @param {string} str
*/
writeLengthPrefixedStringS16(str) {
this.writeS16(str.length);
this.writeString(str);
}
/**
* Write raw bytes
* @param {Uint8Array|ArrayBuffer} data
*/
writeBytes(data) {
const bytes = data instanceof ArrayBuffer ? new Uint8Array(data) : data;
this.ensureCapacity(bytes.length);
new Uint8Array(this.buffer, this.offset, bytes.length).set(bytes);
this.offset += bytes.length;
}
/**
* Seek to absolute position
* @param {number} offset
*/
seek(offset) {
this.ensureCapacity(offset - this.offset);
this.offset = offset;
}
/**
* Get current position
* @returns {number}
*/
tell() {
return this.offset;
}
/**
* Get the final buffer (trimmed to actual size)
* @returns {ArrayBuffer}
*/
toArrayBuffer() {
return this.buffer.slice(0, this.offset);
}
/**
* Get the final buffer as Uint8Array
* @returns {Uint8Array}
*/
toUint8Array() {
return new Uint8Array(this.buffer, 0, this.offset);
}
}

View File

@ -0,0 +1,54 @@
/**
* Parser for Players.gsi file - player profile names
*/
import { BinaryReader } from './BinaryReader.js';
import { LetterIndex } from '../savegame/constants.js';
/**
* @typedef {Object} PlayerEntry
* @property {number[]} letters - Array of 7 letter indices
* @property {string} name - Decoded player name
*/
/**
* Parser for Players.gsi file
*/
export class PlayersParser {
/**
* @param {ArrayBuffer} buffer - Raw file contents
*/
constructor(buffer) {
this.reader = new BinaryReader(buffer);
}
/**
* Parse the Players.gsi file
* @returns {{ count: number, players: PlayerEntry[] }}
*/
parse() {
const count = this.reader.readS16();
const players = [];
for (let i = 0; i < count; i++) {
const letters = [];
for (let j = 0; j < 7; j++) {
letters.push(this.reader.readS16());
}
const name = LetterIndex.decode(letters);
players.push({ letters, name });
}
return { count, players };
}
}
/**
* Parse Players.gsi buffer and return player names
* @param {ArrayBuffer} buffer - Raw file contents
* @returns {{ count: number, players: PlayerEntry[] }}
*/
export function parsePlayers(buffer) {
const parser = new PlayersParser(buffer);
return parser.parse();
}

View File

@ -0,0 +1,62 @@
/**
* Serializer for Players.gsi file - updating player names
*/
import { BinaryWriter } from './BinaryWriter.js';
import { LetterIndex } from '../savegame/constants.js';
/**
* Serializer for Players.gsi file
*/
export class PlayersSerializer {
/**
* @param {{ count: number, players: Array<{ letters: number[], name: string }> }} playersData
*/
constructor(playersData) {
this.data = playersData;
}
/**
* Update a player's name at the given index
* @param {number} index - Player index
* @param {string} newName - New name (max 7 characters, A-Z only)
*/
updateName(index, newName) {
if (index < 0 || index >= this.data.players.length) {
throw new Error('Invalid player index');
}
// Encode the new name to letter indices
const letters = LetterIndex.encode(newName);
this.data.players[index].letters = letters;
this.data.players[index].name = LetterIndex.decode(letters);
}
/**
* Serialize the players data to binary format
* @returns {ArrayBuffer}
*/
serialize() {
const writer = new BinaryWriter(256);
// Write count
writer.writeS16(this.data.count);
// Write each player entry (7 x S16 letters each = 14 bytes)
for (const player of this.data.players) {
for (const letter of player.letters) {
writer.writeS16(letter);
}
}
return writer.toArrayBuffer();
}
}
/**
* Create a serializer for players data
* @param {{ count: number, players: Array<{ letters: number[], name: string }> }} playersData
* @returns {PlayersSerializer}
*/
export function createPlayersSerializer(playersData) {
return new PlayersSerializer(playersData);
}

View File

@ -0,0 +1,396 @@
/**
* Parser for G0-G9.GS save game files
*/
import { BinaryReader } from './BinaryReader.js';
import { SAVEGAME_VERSION, GameStateTypes, GameStateSizes, Actor } from '../savegame/constants.js';
/**
* @typedef {Object} SaveGameHeader
* @property {number} version - File version (must be 0x1000c)
* @property {number} playerId - Player profile ID
* @property {number} currentAct - Current act (0-2)
* @property {number} actorId - Current character (1-5)
*/
/**
* @typedef {Object} MissionScores
* @property {Object|null} pizza - Pizza delivery mission scores
* @property {Object|null} carRace - Car race mission scores
* @property {Object|null} jetskiRace - Jetski race mission scores
* @property {Object|null} towTrack - Tow track mission scores
* @property {Object|null} ambulance - Ambulance mission scores
*/
/**
* @typedef {Object} GameStateLocation
* @property {string} name - State name
* @property {number} nameOffset - Offset of name length field
* @property {number} dataOffset - Offset of state data
* @property {number} dataSize - Size of state data
*/
/**
* Parser for G0-G9.GS save game files
*/
export class SaveGameParser {
/**
* @param {ArrayBuffer} buffer - Raw file contents
*/
constructor(buffer) {
this.buffer = buffer;
this.reader = new BinaryReader(buffer);
this.parsed = {
header: null,
missions: null,
stateLocations: [],
stateCountOffset: null,
stateCount: 0,
statesEndOffset: null // Where to insert new states (before previous_area)
};
}
/**
* Parse the save file header (9 bytes)
* @returns {SaveGameHeader}
*/
parseHeader() {
this.reader.seek(0);
const version = this.reader.readS32();
if (version !== SAVEGAME_VERSION) {
throw new Error(`Invalid save file version: 0x${version.toString(16)} (expected 0x${SAVEGAME_VERSION.toString(16)})`);
}
const playerId = this.reader.readS16();
const currentAct = this.reader.readU16();
const actorId = this.reader.readU8();
this.parsed.header = { version, playerId, currentAct, actorId };
return this.parsed.header;
}
/**
* Skip over the variables section
* Must be called after parseHeader()
*/
skipVariables() {
while (true) {
const nameLength = this.reader.readU8();
const name = this.reader.readString(nameLength);
if (name === 'END_OF_VARIABLES') {
break;
}
const valueLength = this.reader.readU8();
this.reader.skip(valueLength);
}
}
/**
* Skip character manager data (66 characters * 16 bytes = 1056 bytes)
*/
skipCharacters() {
this.reader.skip(66 * 16);
}
/**
* Skip plant manager data (81 plants * 12 bytes = 972 bytes)
*/
skipPlants() {
this.reader.skip(81 * 12);
}
/**
* Skip building manager data (16 buildings * 10 bytes = 160 bytes + 1 byte variant)
*/
skipBuildings() {
this.reader.skip(16 * 10 + 1);
}
/**
* Parse game states to extract mission scores
* Records state locations for later patching
* @returns {MissionScores}
*/
parseGameStates() {
this.parsed.stateCountOffset = this.reader.tell();
const stateCount = this.reader.readS16();
this.parsed.stateCount = stateCount;
const missions = {
pizza: null,
carRace: null,
jetskiRace: null,
towTrack: null,
ambulance: null
};
for (let i = 0; i < stateCount; i++) {
const nameOffset = this.reader.tell();
const nameLength = this.reader.readS16();
const name = this.reader.readString(nameLength);
const dataOffset = this.reader.tell();
// Record location for all states
let dataSize = 0;
switch (name) {
case GameStateTypes.PIZZA_MISSION:
missions.pizza = this.parsePizzaMissionState();
dataSize = 40;
break;
case GameStateTypes.CAR_RACE:
missions.carRace = this.parseRaceState();
dataSize = 25;
break;
case GameStateTypes.JETSKI_RACE:
missions.jetskiRace = this.parseRaceState();
dataSize = 25;
break;
case GameStateTypes.TOW_TRACK:
missions.towTrack = this.parseScoreMissionState();
dataSize = 20;
break;
case GameStateTypes.AMBULANCE:
missions.ambulance = this.parseScoreMissionState();
dataSize = 20;
break;
default:
// Skip other state types
dataSize = this.skipGameStateData(name);
break;
}
this.parsed.stateLocations.push({
name,
nameOffset,
dataOffset,
dataSize
});
}
// Record where states end (before previous_area field)
this.parsed.statesEndOffset = this.reader.tell();
this.parsed.missions = missions;
return missions;
}
/**
* Parse pizza mission state (40 bytes - 5 actors * 8 bytes each)
* Each entry: unk(2) + counter(2) + score(2) + hiScore(2)
* @returns {Object}
*/
parsePizzaMissionState() {
const result = {
scores: {},
highScores: {}
};
for (let actor = Actor.PEPPER; actor <= Actor.LAURA; actor++) {
this.reader.skip(2); // unk0x06
this.reader.skip(2); // counter
result.scores[actor] = this.reader.readS16();
result.highScores[actor] = this.reader.readS16();
}
return result;
}
/**
* Parse score mission state (tow track, ambulance - 20 bytes)
* 5 scores followed by 5 high scores, all S16
* @returns {Object}
*/
parseScoreMissionState() {
const result = {
scores: {},
highScores: {}
};
// Read 5 scores
for (let actor = Actor.PEPPER; actor <= Actor.LAURA; actor++) {
result.scores[actor] = this.reader.readS16();
}
// Read 5 high scores
for (let actor = Actor.PEPPER; actor <= Actor.LAURA; actor++) {
result.highScores[actor] = this.reader.readS16();
}
return result;
}
/**
* Parse race state (jetski, car - 25 bytes)
* 5 entries of 5 bytes each: id(1) + lastScore(2) + highScore(2)
* @returns {Object}
*/
parseRaceState() {
const result = {
scores: {},
highScores: {}
};
for (let i = 0; i < 5; i++) {
const actorId = this.reader.readU8();
const score = this.reader.readS16();
const highScore = this.reader.readS16();
result.scores[actorId] = score;
result.highScores[actorId] = highScore;
}
return result;
}
/**
* Skip game state data based on state name
* @param {string} name - State name
* @returns {number} - Number of bytes skipped
*/
skipGameStateData(name) {
// Check fixed-size states first
const fixedSize = GameStateSizes[name];
if (fixedSize) {
this.reader.skip(fixedSize);
return fixedSize;
}
// Variable-length states
if (name === 'AnimState') {
return this.skipAnimState();
}
if (name === 'Act1State') {
return this.skipAct1State();
}
// Unknown state - this shouldn't happen with valid save files
console.warn(`Unknown game state type: ${name}`);
return 0;
}
/**
* Skip AnimState (variable length)
* @returns {number} - Number of bytes skipped
*/
skipAnimState() {
const startOffset = this.reader.tell();
this.reader.skip(4); // extra_character_id
const animCount = this.reader.readU32();
this.reader.skip(animCount * 2); // anim_indices (U16 each)
const locationFlagsCount = this.reader.readU32();
this.reader.skip(locationFlagsCount); // location_flags (U8 each)
return this.reader.tell() - startOffset;
}
/**
* Skip Act1State (variable length with conditional textures)
* @returns {number} - Number of bytes skipped
*/
skipAct1State() {
const startOffset = this.reader.tell();
// Read 7 named planes
const planeNameLengths = [];
for (let i = 0; i < 7; i++) {
const nameLength = this.reader.readS16();
planeNameLengths.push(nameLength);
if (nameLength > 0) {
this.reader.skip(nameLength); // name
}
this.reader.skip(36); // position(12) + direction(12) + up(12)
}
// Conditional textures based on which planes have names
const helicopterHasName = planeNameLengths[3] > 0;
const jetskiHasName = planeNameLengths[4] > 0;
const dunebuggyHasName = planeNameLengths[5] > 0;
const racecarHasName = planeNameLengths[6] > 0;
if (helicopterHasName) {
for (let i = 0; i < 3; i++) {
this.skipAct1Texture();
}
}
if (jetskiHasName) {
for (let i = 0; i < 2; i++) {
this.skipAct1Texture();
}
}
if (dunebuggyHasName) {
this.skipAct1Texture();
}
if (racecarHasName) {
for (let i = 0; i < 3; i++) {
this.skipAct1Texture();
}
}
// Final fields
this.reader.skip(2); // cpt_click_dialogue_next_index (S16)
this.reader.skip(1); // played_exit_explanation (U8)
return this.reader.tell() - startOffset;
}
/**
* Skip a single Act1 texture
*/
skipAct1Texture() {
const nameLength = this.reader.readS16();
if (nameLength > 0) {
this.reader.skip(nameLength); // name
}
const width = this.reader.readU32();
const height = this.reader.readU32();
const paletteCount = this.reader.readU32();
this.reader.skip(paletteCount * 3); // palette (RGB triplets)
this.reader.skip(width * height); // bitmap data
}
/**
* Get location of a specific game state
* @param {string} stateName - State name to find
* @returns {GameStateLocation|null}
*/
getStateLocation(stateName) {
return this.parsed.stateLocations.find(loc => loc.name === stateName) || null;
}
/**
* Full parse for the save editor (header + missions)
* @returns {{ header: SaveGameHeader, missions: MissionScores, stateLocations: GameStateLocation[] }}
*/
parse() {
this.parseHeader();
this.skipVariables();
this.skipCharacters();
this.skipPlants();
this.skipBuildings();
this.parseGameStates();
return this.parsed;
}
}
/**
* Parse a save game buffer
* @param {ArrayBuffer} buffer - Raw file contents
* @returns {{ header: SaveGameHeader, missions: MissionScores, stateLocations: GameStateLocation[] }}
*/
export function parseSaveGame(buffer) {
const parser = new SaveGameParser(buffer);
return parser.parse();
}

View File

@ -0,0 +1,329 @@
/**
* 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 { GameStateTypes, GameStateSizes, Actor } from '../savegame/constants.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 view = new DataView(workingBuffer);
// Calculate offset based on mission type
let offset;
if (missionType === 'pizza') {
// Pizza: 5 actors * 8 bytes (unk(2) + counter(2) + score(2) + hiScore(2))
const actorIndex = actorId - Actor.PEPPER; // 0-4
const entryOffset = stateLocation.dataOffset + (actorIndex * 8);
if (scoreType === 'score') {
offset = entryOffset + 4; // Skip unk + counter
} else {
offset = entryOffset + 6; // Skip unk + counter + score
}
} else if (missionType === 'carRace' || missionType === 'jetskiRace') {
// Race: 5 actors * 5 bytes (id(1) + lastScore(2) + highScore(2))
const actorIndex = actorId - Actor.PEPPER;
const entryOffset = stateLocation.dataOffset + (actorIndex * 5);
if (scoreType === 'score') {
offset = entryOffset + 1; // Skip id
} else {
offset = entryOffset + 3; // Skip id + lastScore
}
} else if (missionType === 'towTrack' || missionType === 'ambulance') {
// Score mission: 5 scores then 5 high scores (all S16)
const actorIndex = actorId - Actor.PEPPER;
if (scoreType === 'score') {
offset = stateLocation.dataOffset + (actorIndex * 2);
} else {
offset = stateLocation.dataOffset + 10 + (actorIndex * 2); // Skip 5 scores
}
}
if (offset !== undefined) {
view.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;
}
/**
* 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);
}

View File

@ -0,0 +1,439 @@
import { BinaryReader } from './BinaryReader.js';
/**
* Parser for LEGO Island WORLD.WDB files
* Based on wdb.ksy specification and source code analysis
*/
export class WdbParser {
constructor(buffer) {
this.reader = new BinaryReader(buffer);
this.buffer = buffer;
}
/**
* Parse the WDB file structure
* @returns {{ worlds: Array, globalTexturesSize: number, globalPartsSize: number }}
*/
parse() {
const numWorlds = this.reader.readS32();
const worlds = [];
for (let i = 0; i < numWorlds; i++) {
worlds.push(this.parseWorldEntry());
}
const globalTexturesSize = this.reader.readU32();
// Skip global textures for now - BIGCUBE.GIF is in model_data
this.reader.skip(globalTexturesSize);
const globalPartsSize = this.reader.readU32();
// Skip global parts
this.reader.skip(globalPartsSize);
return { worlds, globalTexturesSize, globalPartsSize };
}
parseWorldEntry() {
const nameLen = this.reader.readS32();
const name = this.reader.readString(nameLen).replace(/\0/g, '');
// Parse parts (skip for now)
const numParts = this.reader.readS32();
for (let i = 0; i < numParts; i++) {
this.skipPartReference();
}
// Parse models
const numModels = this.reader.readS32();
const models = [];
for (let i = 0; i < numModels; i++) {
models.push(this.parseModelEntry());
}
return { name, numParts, models };
}
skipPartReference() {
const nameLen = this.reader.readU32();
this.reader.skip(nameLen); // name
this.reader.skip(4); // data_length
this.reader.skip(4); // data_offset
}
parseModelEntry() {
const nameLen = this.reader.readU32();
const name = this.reader.readString(nameLen).replace(/\0/g, '');
const dataLength = this.reader.readU32();
const dataOffset = this.reader.readU32();
const presenterLen = this.reader.readU32();
const presenter = this.reader.readString(presenterLen).replace(/\0/g, '');
const location = this.readVertex3();
const direction = this.readVertex3();
const up = this.readVertex3();
const visible = this.reader.readU8();
return { name, dataLength, dataOffset, presenter, location, direction, up, visible };
}
readVertex3() {
return {
x: this.reader.readF32(),
y: this.reader.readF32(),
z: this.reader.readF32()
};
}
/**
* Read string and strip null terminators
*/
readCleanString(length) {
return this.reader.readString(length).replace(/\0/g, '');
}
/**
* Parse model_data blob at specified offset
* @param {number} offset - Absolute file offset
* @returns {{ version: number, anim: object, roi: object, textures: Array }}
*/
parseModelData(offset) {
this.reader.seek(offset);
const version = this.reader.readU32();
if (version !== 19) {
throw new Error(`Unexpected model version: ${version}, expected 19`);
}
const textureInfoOffset = this.reader.readU32();
const numRois = this.reader.readU32();
// Parse animation data
const anim = this.parseModelAnim();
// Parse ROI hierarchy
const roi = this.parseRoi();
// Parse textures at textureInfoOffset
this.reader.seek(offset + textureInfoOffset);
const textures = this.parseTextureInfo();
return { version, anim, roi, textures };
}
parseModelAnim() {
const numActors = this.reader.readU32();
const actors = [];
for (let i = 0; i < numActors; i++) {
const nameLen = this.reader.readU32();
if (nameLen > 0) {
const name = this.readCleanString(nameLen);
const actorType = this.reader.readU32();
actors.push({ name, actorType });
}
}
const duration = this.reader.readS32();
const rootNode = this.parseAnimTreeNode();
return { actors, duration, rootNode };
}
parseAnimTreeNode() {
const data = this.parseAnimNodeData();
const numChildren = this.reader.readU32();
const children = [];
for (let i = 0; i < numChildren; i++) {
children.push(this.parseAnimTreeNode());
}
return { data, children };
}
parseAnimNodeData() {
const nameLen = this.reader.readU32();
const name = nameLen > 0 ? this.readCleanString(nameLen) : '';
// Translation keys
const numTranslationKeys = this.reader.readU16();
const translationKeys = [];
for (let i = 0; i < numTranslationKeys; i++) {
translationKeys.push(this.parseTranslationKey());
}
// Rotation keys
const numRotationKeys = this.reader.readU16();
const rotationKeys = [];
for (let i = 0; i < numRotationKeys; i++) {
rotationKeys.push(this.parseRotationKey());
}
// Scale keys
const numScaleKeys = this.reader.readU16();
const scaleKeys = [];
for (let i = 0; i < numScaleKeys; i++) {
scaleKeys.push(this.parseScaleKey());
}
// Morph keys
const numMorphKeys = this.reader.readU16();
const morphKeys = [];
for (let i = 0; i < numMorphKeys; i++) {
morphKeys.push(this.parseMorphKey());
}
return { name, translationKeys, rotationKeys, scaleKeys, morphKeys };
}
parseAnimKey() {
const timeAndFlags = this.reader.readS32();
const time = timeAndFlags & 0xFFFFFF;
const flags = (timeAndFlags >> 24) & 0xFF;
return { time, flags };
}
parseTranslationKey() {
const key = this.parseAnimKey();
const x = this.reader.readF32();
const y = this.reader.readF32();
const z = this.reader.readF32();
return { ...key, x, y, z };
}
parseRotationKey() {
const key = this.parseAnimKey();
const angle = this.reader.readF32(); // w component
const x = this.reader.readF32();
const y = this.reader.readF32();
const z = this.reader.readF32();
return { ...key, angle, x, y, z };
}
parseScaleKey() {
const key = this.parseAnimKey();
const x = this.reader.readF32();
const y = this.reader.readF32();
const z = this.reader.readF32();
return { ...key, x, y, z };
}
parseMorphKey() {
const key = this.parseAnimKey();
const visible = this.reader.readU8();
return { ...key, visible };
}
parseRoi() {
const nameLen = this.reader.readU32();
const name = this.reader.readString(nameLen).replace(/\0/g, '');
// Bounding sphere
const boundingSphere = {
center: this.readVertex3(),
radius: this.reader.readF32()
};
// Bounding box
const boundingBox = {
min: this.readVertex3(),
max: this.readVertex3()
};
// Texture name (for color/material reference)
const textureNameLen = this.reader.readU32();
const textureName = textureNameLen > 0 ? this.readCleanString(textureNameLen) : null;
// Shared LOD list flag
const sharedLodList = this.reader.readU8();
let lods = [];
if (sharedLodList === 0) {
const numLods = this.reader.readU32();
if (numLods > 0) {
const nextRoiOffset = this.reader.readU32();
for (let i = 0; i < numLods; i++) {
lods.push(this.parseLod());
}
}
}
// Children
const numChildren = this.reader.readU32();
const children = [];
for (let i = 0; i < numChildren; i++) {
children.push(this.parseRoi());
}
return { name, boundingSphere, boundingBox, textureName, lods, children };
}
parseLod() {
const flags = this.reader.readU32();
const numMeshes = this.reader.readU32();
if (numMeshes === 0) {
return { flags, numMeshes, vertices: [], normals: [], textureVertices: [], meshes: [] };
}
// Packed vertex/normal counts
const vertexNormalCounts = this.reader.readU32();
const vertexCount = vertexNormalCounts & 0xFFFF;
const normalCount = (vertexNormalCounts >> 17) & 0x7FFF;
const numTextureVertices = this.reader.readS32();
// Read vertices
const vertices = [];
for (let i = 0; i < vertexCount; i++) {
vertices.push(this.readVertex3());
}
// Read normals
const normals = [];
for (let i = 0; i < normalCount; i++) {
normals.push(this.readVertex3());
}
// Read texture vertices (UVs)
const textureVertices = [];
for (let i = 0; i < numTextureVertices; i++) {
textureVertices.push({
u: this.reader.readF32(),
v: this.reader.readF32()
});
}
// Read meshes
const meshes = [];
for (let i = 0; i < numMeshes; i++) {
meshes.push(this.parseMesh());
}
return { flags, numMeshes, vertexCount, normalCount, vertices, normals, textureVertices, meshes };
}
parseMesh() {
const numPolygons = this.reader.readU16();
const numVertices = this.reader.readU16();
// Polygon indices (vertex/normal pairs)
const polygonIndices = [];
for (let i = 0; i < numPolygons; i++) {
polygonIndices.push({
a: this.reader.readU32(),
b: this.reader.readU32(),
c: this.reader.readU32()
});
}
// Texture indices
const numTextureIndices = this.reader.readU32();
const textureIndices = [];
if (numTextureIndices > 0) {
for (let i = 0; i < numPolygons; i++) {
textureIndices.push({
a: this.reader.readU32(),
b: this.reader.readU32(),
c: this.reader.readU32()
});
}
}
// Mesh properties
const properties = this.parseMeshProperties();
return { numPolygons, numVertices, polygonIndices, textureIndices, properties };
}
parseMeshProperties() {
const color = {
r: this.reader.readU8(),
g: this.reader.readU8(),
b: this.reader.readU8()
};
const alpha = this.reader.readF32();
const shading = this.reader.readU8();
const unknown0x0d = this.reader.readU8();
const unknown0x20 = this.reader.readU8();
const useAlias = this.reader.readU8();
const textureNameLen = this.reader.readU32();
const textureName = textureNameLen > 0 ? this.readCleanString(textureNameLen) : null;
const materialNameLen = this.reader.readU32();
const materialName = materialNameLen > 0 ? this.readCleanString(materialNameLen) : null;
return { color, alpha, shading, useAlias, textureName, materialName };
}
parseTextureInfo() {
const numTextures = this.reader.readU32();
const skipTextures = this.reader.readU32();
const textures = [];
for (let i = 0; i < numTextures; i++) {
const nameLen = this.reader.readU32();
let name = this.readCleanString(nameLen).toLowerCase();
// Handle '^' prefix (hi-res/lo-res pair)
let hasHighRes = false;
if (name.startsWith('^')) {
name = name.substring(1);
hasHighRes = true;
// Read hi-res texture
const hiRes = this.parseLegoImage();
// Skip lo-res texture
this.skipLegoImage();
textures.push({ name, ...hiRes });
} else {
textures.push({ name, ...this.parseLegoImage() });
}
}
return textures;
}
parseLegoImage() {
const width = this.reader.readU32();
const height = this.reader.readU32();
const paletteSize = this.reader.readU32();
const palette = [];
for (let i = 0; i < paletteSize; i++) {
palette.push({
r: this.reader.readU8(),
g: this.reader.readU8(),
b: this.reader.readU8()
});
}
const pixels = new Uint8Array(this.reader.slice(width * height));
return { width, height, paletteSize, palette, pixels };
}
skipLegoImage() {
const width = this.reader.readU32();
const height = this.reader.readU32();
const paletteSize = this.reader.readU32();
this.reader.skip(paletteSize * 3); // palette
this.reader.skip(width * height); // pixels
}
}
/**
* Helper to find an ROI by name in a hierarchy
* @param {object} roi - Root ROI
* @param {string} name - Name to find (case-insensitive)
* @returns {object|null}
*/
export function findRoi(roi, name) {
if (roi.name.toLowerCase() === name.toLowerCase()) {
return roi;
}
for (const child of roi.children || []) {
const found = findRoi(child, name);
if (found) return found;
}
return null;
}

18
src/core/formats/index.js Normal file
View File

@ -0,0 +1,18 @@
/**
* File Format Parsers and Serializers
*/
// Binary utilities
export { BinaryReader } from './BinaryReader.js';
export { BinaryWriter } from './BinaryWriter.js';
// WDB format
export { WdbParser, findRoi } from './WdbParser.js';
// Save game format
export { SaveGameParser, parseSaveGame } from './SaveGameParser.js';
export { SaveGameSerializer, createSerializer } from './SaveGameSerializer.js';
// Players format
export { PlayersParser, parsePlayers } from './PlayersParser.js';
export { PlayersSerializer, createPlayersSerializer } from './PlayersSerializer.js';

View File

@ -1,21 +1,188 @@
// OPFS Config Manager - handles saving/loading configuration via Origin Private File System // OPFS Config Manager - handles saving/loading configuration via Origin Private File System
import { configToastVisible } from '../stores.js'; import { configToastVisible, configToastMessage } from '../stores.js';
const CONFIG_FILE = 'isle.ini'; const CONFIG_FILE = 'isle.ini';
let toastTimeout = null; let toastTimeout = null;
export async function getFileHandle() { // ============================================================================
// Core OPFS Operations
// ============================================================================
/**
* Get OPFS root directory
* @returns {Promise<FileSystemDirectoryHandle|null>}
*/
export async function getOpfsRoot() {
try { try {
const root = await navigator.storage.getDirectory(); return await navigator.storage.getDirectory();
return await root.getFileHandle(CONFIG_FILE, { create: true });
} catch (e) { } catch (e) {
console.error("OPFS not available or permission denied.", e); console.error("OPFS not available or permission denied.", e);
return null; return null;
} }
} }
/**
* Get a file handle from OPFS
* @param {string} filename - Name of the file
* @param {boolean} create - Whether to create the file if it doesn't exist
* @returns {Promise<FileSystemFileHandle|null>}
*/
export async function getFileHandle(filename = CONFIG_FILE, create = true) {
try {
const root = await getOpfsRoot();
if (!root) return null;
return await root.getFileHandle(filename, { create });
} catch (e) {
if (e.name === 'NotFoundError') return null;
console.error("Failed to get file handle:", e);
return null;
}
}
/**
* Check if a file exists in OPFS
* @param {string} filename - Name of the file
* @returns {Promise<boolean>}
*/
export async function fileExists(filename) {
const handle = await getFileHandle(filename, false);
return handle !== null;
}
/**
* Read a binary file from OPFS
* @param {string} filename - Name of the file
* @returns {Promise<ArrayBuffer|null>} - File contents or null if not found
*/
export async function readBinaryFile(filename) {
try {
const handle = await getFileHandle(filename, false);
if (!handle) return null;
const file = await handle.getFile();
return await file.arrayBuffer();
} catch (e) {
console.error('Failed to read binary file:', e);
return null;
}
}
/**
* Write a binary file to OPFS using Web Worker pattern (Safari-compatible)
* @param {string} filename - Name of the file
* @param {ArrayBuffer|Uint8Array} data - Binary data to write
* @param {boolean} silent - If true, don't show toast notification
* @param {string} toastMsg - Custom toast message (default: 'Settings saved')
* @returns {Promise<boolean>} - True if successful
*/
export async function writeBinaryFile(filename, data, silent = false, toastMsg = 'Settings saved') {
const workerCode = `
self.onmessage = async (e) => {
try {
const root = await navigator.storage.getDirectory();
const handle = await root.getFileHandle(e.data.filename, { create: true });
const accessHandle = await handle.createSyncAccessHandle();
const bytes = new Uint8Array(e.data.buffer);
accessHandle.truncate(0);
accessHandle.write(bytes, { at: 0 });
accessHandle.flush();
accessHandle.close();
self.postMessage({ status: 'success', message: 'File saved: ' + e.data.filename });
} catch (err) {
self.postMessage({ status: 'error', message: 'Failed to save file: ' + err.message });
}
};
`;
const blob = new Blob([workerCode], { type: 'application/javascript' });
const workerUrl = URL.createObjectURL(blob);
const worker = new Worker(workerUrl);
return new Promise((resolve) => {
worker.postMessage({ filename, buffer: data });
worker.onmessage = (e) => {
console.log(e.data.message);
URL.revokeObjectURL(workerUrl);
worker.terminate();
if (e.data.status === 'success') {
if (!silent) {
showToast(toastMsg);
}
resolve(true);
} else {
resolve(false);
}
};
worker.onerror = (e) => {
console.error('An error occurred in the file-saving worker:', e.message);
URL.revokeObjectURL(workerUrl);
worker.terminate();
resolve(false);
};
});
}
/**
* Write a text file to OPFS
* @param {string} filename - Name of the file
* @param {string} content - Text content to write
* @param {boolean} silent - If true, don't show toast notification
* @param {string} toastMsg - Custom toast message
* @returns {Promise<boolean>} - True if successful
*/
export async function writeTextFile(filename, content, silent = false, toastMsg = 'Settings saved') {
const encoder = new TextEncoder();
const data = encoder.encode(content);
return writeBinaryFile(filename, data.buffer, silent, toastMsg);
}
/**
* List all files in OPFS matching a pattern
* @param {RegExp} pattern - Regular expression to match filenames
* @returns {Promise<string[]>} - Array of matching filenames
*/
export async function listFiles(pattern) {
try {
const root = await getOpfsRoot();
if (!root) return [];
const files = [];
for await (const entry of root.values()) {
if (entry.kind === 'file' && pattern.test(entry.name)) {
files.push(entry.name);
}
}
return files;
} catch (e) {
console.error('Failed to list files:', e);
return [];
}
}
/**
* Show a toast notification
* @param {string} message - Message to display
*/
function showToast(message) {
if (toastTimeout) {
clearTimeout(toastTimeout);
}
configToastMessage.set(message);
configToastVisible.set(true);
toastTimeout = setTimeout(() => configToastVisible.set(false), 2000);
}
// ============================================================================
// Config File Operations
// ============================================================================
export async function loadConfig(form) { export async function loadConfig(form) {
const handle = await getFileHandle(); const handle = await getFileHandle(CONFIG_FILE, true);
if (!handle) return null; if (!handle) return null;
try { try {
@ -142,56 +309,5 @@ export async function saveConfig(form, getSiFiles, silent = false) {
iniContent += `directives=${directives.join(",\\\n")}\n`; iniContent += `directives=${directives.join(",\\\n")}\n`;
} }
// Use inline Web Worker for Safari compatibility return writeTextFile(CONFIG_FILE, iniContent, silent);
const workerCode = `
self.onmessage = async (e) => {
if (e.data.action === 'save') {
try {
const root = await navigator.storage.getDirectory();
const handle = await root.getFileHandle(e.data.filePath, { create: true });
const accessHandle = await handle.createSyncAccessHandle();
const encoder = new TextEncoder();
const encodedData = encoder.encode(e.data.content);
accessHandle.truncate(0);
accessHandle.write(encodedData, { at: 0 });
accessHandle.flush();
accessHandle.close();
self.postMessage({ status: 'success', message: 'Config saved to ' + e.data.filePath });
} catch (err) {
self.postMessage({ status: 'error', message: 'Failed to save config: ' + err.message });
}
}
};
`;
const blob = new Blob([workerCode], { type: 'application/javascript' });
const workerUrl = URL.createObjectURL(blob);
const worker = new Worker(workerUrl);
worker.postMessage({
action: 'save',
content: iniContent,
filePath: CONFIG_FILE
});
worker.onmessage = (e) => {
console.log(e.data.message);
URL.revokeObjectURL(workerUrl);
worker.terminate();
if (e.data.status === 'success' && !silent) {
if (toastTimeout) {
clearTimeout(toastTimeout);
}
configToastVisible.set(true);
toastTimeout = setTimeout(() => configToastVisible.set(false), 2000);
}
};
worker.onerror = (e) => {
console.error('An error occurred in the config-saving worker:', e.message);
URL.revokeObjectURL(workerUrl);
worker.terminate();
};
} }

View File

@ -0,0 +1,84 @@
import { WdbModelRenderer } from './WdbModelRenderer.js';
/**
* Specialized renderer for the LEGO Island score cube
* Extends WdbModelRenderer with score-specific functionality
*/
export class ScoreCubeRenderer extends WdbModelRenderer {
// Score grid layout constants (from score.cpp)
static AREA_Y_OFFSETS = [0x2b, 0x57, 0x80, 0xab, 0xd6]; // per actor row
static AREA_HEIGHTS = [0x2a, 0x27, 0x29, 0x29, 0x2a];
static AREA_X_OFFSETS = [0x2f, 0x56, 0x81, 0xaa, 0xd4]; // per activity column
static AREA_WIDTHS = [0x25, 0x29, 0x27, 0x28, 0x28];
static COLOR_INDICES = [0x11, 0x0f, 0x08, 0x05]; // grey, yellow, blue, red
/**
* Update score colors on texture
* Score layout on cube (left to right, top to bottom):
* - Activities (columns): carRace, jetskiRace, pizza, towTrack, ambulance (0-4)
* - Actors (rows): pepper, mama, papa, nick, laura (0-4)
* @param {Array<Array<number>>} scores - 2D array [actor][activity] with values 0-3
*/
updateScores(scores) {
if (!this.textureCanvas || !this.baseImageData || !this.palette) return;
const ctx = this.textureCanvas.getContext('2d');
ctx.putImageData(this.baseImageData, 0, 0);
for (let actor = 0; actor < 5; actor++) {
for (let activity = 0; activity < 5; activity++) {
const score = scores?.[actor]?.[activity] ?? 0;
const clampedScore = Math.max(0, Math.min(3, score));
const colorIdx = ScoreCubeRenderer.COLOR_INDICES[clampedScore];
const color = this.palette[colorIdx];
if (color) {
ctx.fillStyle = `rgb(${color.r}, ${color.g}, ${color.b})`;
ctx.fillRect(
ScoreCubeRenderer.AREA_X_OFFSETS[activity],
ScoreCubeRenderer.AREA_Y_OFFSETS[actor],
ScoreCubeRenderer.AREA_WIDTHS[activity],
ScoreCubeRenderer.AREA_HEIGHTS[actor]
);
}
}
}
if (this.texture) {
this.texture.needsUpdate = true;
}
}
/**
* Raycast to find clicked score cell
* @param {MouseEvent} event - Click event
* @returns {{ actor: number, activity: number } | null}
*/
raycast(event) {
const hit = this.raycastUV(event);
if (!hit) return null;
return this.uvToScoreCell(hit.x, hit.y);
}
/**
* Convert texture pixel coordinates to score cell
* @param {number} x - X coordinate (0-256)
* @param {number} y - Y coordinate (0-256)
* @returns {{ actor: number, activity: number } | null}
*/
uvToScoreCell(x, y) {
for (let activity = 0; activity < 5; activity++) {
for (let actor = 0; actor < 5; actor++) {
if (
x >= ScoreCubeRenderer.AREA_X_OFFSETS[activity] &&
x < ScoreCubeRenderer.AREA_X_OFFSETS[activity] + ScoreCubeRenderer.AREA_WIDTHS[activity] &&
y >= ScoreCubeRenderer.AREA_Y_OFFSETS[actor] &&
y < ScoreCubeRenderer.AREA_Y_OFFSETS[actor] + ScoreCubeRenderer.AREA_HEIGHTS[actor]
) {
return { actor, activity };
}
}
}
return null;
}
}

View File

@ -0,0 +1,303 @@
import * as THREE from 'three';
/**
* Generic Three.js renderer for LEGO Island WDB models
* Handles D3DRM packed vertex format and paletted textures
*/
export class WdbModelRenderer {
constructor(canvas) {
this.canvas = canvas;
this.animating = false;
this.modelGroup = null;
this.texturedMesh = null;
this.texture = null;
this.textureCanvas = null;
this.baseImageData = null;
this.palette = null;
this.scene = new THREE.Scene();
this.camera = new THREE.PerspectiveCamera(45, 1, 0.1, 100);
this.camera.position.set(0, 0.2, 7);
this.renderer = new THREE.WebGLRenderer({
canvas,
antialias: true,
alpha: true
});
this.renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
this.renderer.setClearColor(0x000000, 0);
this.setupLighting();
}
/**
* Setup scene lighting - override to customize
*/
setupLighting() {
// Flat, even lighting similar to in-game
const ambient = new THREE.AmbientLight(0xffffff, 1.5);
this.scene.add(ambient);
// Soft front light
const frontLight = new THREE.DirectionalLight(0xffffff, 0.3);
frontLight.position.set(0, 0, 5);
this.scene.add(frontLight);
}
/**
* Load model geometry and texture from parsed WDB data
* @param {object} roiData - Parsed ROI data with lods
* @param {object} textureData - Parsed texture with palette and pixels
*/
loadModel(roiData, textureData) {
this.palette = textureData.palette;
this.modelGroup = new THREE.Group();
const { texturedGeometry, nonTexturedGeometries } = this.createGeometries(roiData);
this.textureCanvas = this.createTextureCanvas(textureData);
this.texture = new THREE.CanvasTexture(this.textureCanvas);
this.texture.minFilter = THREE.LinearFilter;
this.texture.magFilter = THREE.LinearFilter;
if (texturedGeometry) {
const texturedMaterial = new THREE.MeshStandardMaterial({
map: this.texture,
side: THREE.DoubleSide,
roughness: 0.8,
metalness: 0.1
});
this.texturedMesh = new THREE.Mesh(texturedGeometry, texturedMaterial);
this.modelGroup.add(this.texturedMesh);
}
for (const { geometry, color } of nonTexturedGeometries) {
const material = new THREE.MeshStandardMaterial({
color: new THREE.Color(color.r / 255, color.g / 255, color.b / 255),
side: THREE.DoubleSide,
roughness: 0.8,
metalness: 0.1
});
const mesh = new THREE.Mesh(geometry, material);
this.modelGroup.add(mesh);
}
this.scene.add(this.modelGroup);
this.renderer.render(this.scene, this.camera);
}
/**
* Create Three.js BufferGeometries from ROI LOD data
*
* D3DRM packed polygon index format (32-bit):
* - Bits 0-15: vertex index (16 bits) into positions array, OR destination index when reusing
* - Bits 16-30: normal index into normals array
* - Bit 31: "create new vertex" flag - when set, create a new mesh vertex;
* when clear, bits 0-15 is the INDEX into the created mesh vertices array
*
* @param {object} roiData - ROI with lods array
* @returns {{ texturedGeometry: THREE.BufferGeometry|null, nonTexturedGeometries: Array }}
*/
createGeometries(roiData) {
if (!roiData.lods || roiData.lods.length === 0) {
console.warn('ROI has no LODs');
return { texturedGeometry: null, nonTexturedGeometries: [] };
}
const lod = roiData.lods[0];
let texturedGeometry = null;
const nonTexturedGeometries = [];
for (const mesh of lod.meshes) {
const hasTexture = mesh.textureIndices && mesh.textureIndices.length > 0;
// Flatten polygon indices
const vertexIndicesPacked = [];
for (const poly of mesh.polygonIndices) {
vertexIndicesPacked.push(poly.a, poly.b, poly.c);
}
// Flatten texture indices if present
const textureIndicesFlat = [];
if (hasTexture) {
for (const texPoly of mesh.textureIndices) {
textureIndicesFlat.push(texPoly.a, texPoly.b, texPoly.c);
}
}
// Build mesh vertices following brickolini-island logic
const meshVertices = [];
const meshNormals = [];
const meshUvs = [];
const indices = [];
for (let i = 0; i < vertexIndicesPacked.length; i++) {
const packed = vertexIndicesPacked[i];
if ((packed & 0x80000000) !== 0) {
// Create flag is set - create new mesh vertex
indices.push(meshVertices.length);
const gv = packed & 0xFFFF; // Vertex index (16 bits)
const v = lod.vertices[gv] || { x: 0, y: 0, z: 0 };
// Negate X for coordinate system conversion
meshVertices.push([-v.x, v.y, v.z]);
const gn = (packed >>> 16) & 0x7fff; // Normal index (15 bits)
const n = lod.normals[gn] || { x: 0, y: 1, z: 0 };
meshNormals.push([-n.x, n.y, n.z]);
if (hasTexture && lod.textureVertices.length > 0) {
const tex = textureIndicesFlat[i];
const uv = lod.textureVertices[tex] || { u: 0, v: 0 };
meshUvs.push([uv.u, 1 - uv.v]);
}
} else {
// Create flag NOT set - reuse existing mesh vertex by index
indices.push(packed & 0xFFFF);
}
}
// Reverse face winding (swap indices 0 and 2 of each triangle)
for (let i = 0; i < indices.length; i += 3) {
const temp = indices[i];
indices[i] = indices[i + 2];
indices[i + 2] = temp;
}
// Create geometry
const geometry = new THREE.BufferGeometry();
const vertices = meshVertices.flat();
const normals = meshNormals.flat();
geometry.setAttribute('position', new THREE.Float32BufferAttribute(vertices, 3));
geometry.setAttribute('normal', new THREE.Float32BufferAttribute(normals, 3));
geometry.setIndex(indices);
if (hasTexture) {
const uvs = meshUvs.flat();
geometry.setAttribute('uv', new THREE.Float32BufferAttribute(uvs, 2));
texturedGeometry = geometry;
} else {
const color = mesh.properties?.color || { r: 128, g: 128, b: 128 };
nonTexturedGeometries.push({ geometry, color });
}
}
return { texturedGeometry, nonTexturedGeometries };
}
/**
* Create canvas texture from paletted LEGO texture data
* @param {object} textureData - { width, height, palette, pixels }
* @returns {HTMLCanvasElement}
*/
createTextureCanvas(textureData) {
const canvas = document.createElement('canvas');
canvas.width = textureData.width;
canvas.height = textureData.height;
const ctx = canvas.getContext('2d');
const imageData = ctx.createImageData(textureData.width, textureData.height);
for (let i = 0; i < textureData.pixels.length; i++) {
const colorIdx = textureData.pixels[i];
const color = textureData.palette[colorIdx] || { r: 0, g: 0, b: 0 };
imageData.data[i * 4 + 0] = color.r;
imageData.data[i * 4 + 1] = color.g;
imageData.data[i * 4 + 2] = color.b;
imageData.data[i * 4 + 3] = 255;
}
ctx.putImageData(imageData, 0, 0);
this.baseImageData = ctx.getImageData(0, 0, textureData.width, textureData.height);
return canvas;
}
/**
* Raycast and return UV coordinates of hit on textured mesh
* @param {MouseEvent} event - Mouse event
* @returns {{ uv: THREE.Vector2, x: number, y: number } | null}
*/
raycastUV(event) {
if (!this.texturedMesh) return null;
const rect = this.canvas.getBoundingClientRect();
const mouse = new THREE.Vector2(
((event.clientX - rect.left) / rect.width) * 2 - 1,
-((event.clientY - rect.top) / rect.height) * 2 + 1
);
const raycaster = new THREE.Raycaster();
raycaster.setFromCamera(mouse, this.camera);
const intersects = raycaster.intersectObject(this.texturedMesh);
if (intersects.length > 0 && intersects[0].uv) {
const uv = intersects[0].uv;
const x = uv.x * this.textureCanvas.width;
const y = (1 - uv.y) * this.textureCanvas.height;
return { uv, x, y };
}
return null;
}
/**
* Start animation loop
*/
start() {
this.animating = true;
this.animate();
}
/**
* Stop animation loop
*/
stop() {
this.animating = false;
}
/**
* Animation loop - override to customize animation
*/
animate = () => {
if (!this.animating) return;
requestAnimationFrame(this.animate);
if (this.modelGroup) {
this.modelGroup.rotation.y += 0.008;
}
this.renderer.render(this.scene, this.camera);
}
/**
* Resize renderer to match canvas size
* @param {number} width
* @param {number} height
*/
resize(width, height) {
this.camera.aspect = width / height;
this.camera.updateProjectionMatrix();
this.renderer.setSize(width, height, false);
}
/**
* Clean up resources
*/
dispose() {
this.animating = false;
if (this.modelGroup) {
this.modelGroup.traverse((child) => {
if (child instanceof THREE.Mesh) {
child.geometry?.dispose();
child.material?.dispose();
}
});
this.scene.remove(this.modelGroup);
}
this.texture?.dispose();
this.renderer?.dispose();
}
}

View File

@ -0,0 +1,191 @@
/**
* Constants and enums from KSY save file specifications
*/
// Save game file version (must match for valid saves)
export const SAVEGAME_VERSION = 0x1000c; // 65548
// Actor enum - playable characters
export const Actor = Object.freeze({
NONE: 0,
PEPPER: 1,
MAMA: 2,
PAPA: 3,
NICK: 4,
LAURA: 5
});
// Actor display names
export const ActorNames = Object.freeze({
0: 'None',
1: 'Pepper',
2: 'Mama',
3: 'Papa',
4: 'Nick',
5: 'Laura'
});
// Act enum
export const Act = Object.freeze({
ACT1: 0,
ACT2: 1,
ACT3: 2
});
// Score color enum - used for mission scores
export const ScoreColor = Object.freeze({
GREY: 0,
YELLOW: 1,
BLUE: 2,
RED: 3
});
// Score color to CSS color mapping
export const ScoreColorCSS = Object.freeze({
0: '#808080', // grey
1: '#FFD700', // yellow (gold)
2: '#4169E1', // blue (royal blue)
3: '#DC143C' // red (crimson)
});
// Score color display names
export const ScoreColorNames = Object.freeze({
0: 'Grey',
1: 'Yellow',
2: 'Blue',
3: 'Red'
});
// Mission types that have scores
export const MissionTypes = Object.freeze({
PIZZA: 'pizza',
CAR_RACE: 'carRace',
JETSKI_RACE: 'jetskiRace',
TOW_TRACK: 'towTrack',
AMBULANCE: 'ambulance'
});
// Mission display names
export const MissionNames = Object.freeze({
pizza: 'Pizza Delivery',
carRace: 'Car Race',
jetskiRace: 'Jetski Race',
towTrack: 'Tow Track',
ambulance: 'Ambulance'
});
// Game state type names for polymorphic parsing
export const GameStateTypes = Object.freeze({
PIZZERIA: 'PizzeriaState',
PIZZA_MISSION: 'PizzaMissionState',
TOW_TRACK: 'TowTrackMissionState',
AMBULANCE: 'AmbulanceMissionState',
HOSPITAL: 'HospitalState',
GAS_STATION: 'GasStationState',
POLICE: 'PoliceState',
JETSKI_RACE: 'JetskiRaceState',
CAR_RACE: 'CarRaceState',
JETSKI_BUILD: 'LegoJetskiBuildState',
COPTER_BUILD: 'LegoCopterBuildState',
DUNE_CAR_BUILD: 'LegoDuneCarBuildState',
RACE_CAR_BUILD: 'LegoRaceCarBuildState',
ANIM: 'AnimState',
ACT1: 'Act1State'
});
// Fixed sizes for known game state types (for skipping)
export const GameStateSizes = Object.freeze({
'PizzeriaState': 10,
'PizzaMissionState': 40,
'TowTrackMissionState': 20,
'AmbulanceMissionState': 20,
'HospitalState': 12,
'GasStationState': 10,
'PoliceState': 4,
'JetskiRaceState': 25,
'CarRaceState': 25,
'LegoJetskiBuildState': 4,
'LegoCopterBuildState': 4,
'LegoDuneCarBuildState': 4,
'LegoRaceCarBuildState': 4
// AnimState and Act1State are variable length
});
// Letter index utilities for username encoding/decoding
export const LetterIndex = {
/**
* Convert letter index to character
* @param {number} index - Letter index from save file
* @returns {string} - Character or empty string
*/
toChar(index) {
// Standard alphabet A-Z
if (index >= 0 && index <= 25) {
return String.fromCharCode(65 + index); // A = 65 in ASCII
}
// International characters (simplified)
if (index === 29) return 'A'; // Originally international chars
if (index === 30) return 'O';
if (index === 31) return 'S';
if (index === 32) return 'U';
// Empty/unused position
if (index === -1 || index === 0xFFFF) return '';
return '?';
},
/**
* Convert character to letter index
* @param {string} char - Single character
* @returns {number} - Letter index or -1 for invalid/empty
*/
fromChar(char) {
if (!char) return -1;
const upper = char.toUpperCase();
const code = upper.charCodeAt(0);
// A-Z
if (code >= 65 && code <= 90) {
return code - 65;
}
return -1;
},
/**
* Decode array of letter indices to string
* @param {number[]} letters - Array of 7 letter indices
* @returns {string} - Decoded player name
*/
decode(letters) {
return letters.map(l => this.toChar(l)).join('').trim();
},
/**
* Encode string to array of 7 letter indices
* @param {string} name - Player name (max 7 chars)
* @returns {number[]} - Array of 7 letter indices
*/
encode(name) {
const letters = [];
const upper = name.toUpperCase().slice(0, 7);
for (let i = 0; i < 7; i++) {
if (i < upper.length) {
letters.push(this.fromChar(upper[i]));
} else {
letters.push(-1); // Empty position
}
}
return letters;
}
};
// Save file names
export const PLAYERS_FILE = 'Players.gsi';
export const HISTORY_FILE = 'History.gsi';
/**
* Get save slot filename
* @param {number} slot - Slot number 0-9
* @returns {string} - Filename like "G0.GS"
*/
export function getSaveFileName(slot) {
return `G${slot}.GS`;
}

280
src/core/savegame/index.js Normal file
View File

@ -0,0 +1,280 @@
/**
* Save Game Editor - Public API
*
* High-level functions for managing LEGO Island save files
*/
// Re-export format utilities (from formats/)
export { BinaryReader, BinaryWriter } from '../formats/index.js';
export { SaveGameParser, parseSaveGame } from '../formats/index.js';
export { SaveGameSerializer, createSerializer } from '../formats/index.js';
export { PlayersParser, parsePlayers } from '../formats/index.js';
export { PlayersSerializer, createPlayersSerializer } from '../formats/index.js';
export * from './constants.js';
// Import dependencies
import { readBinaryFile, writeBinaryFile, fileExists, listFiles } from '../opfs.js';
import { parseSaveGame, parsePlayers, createSerializer, createPlayersSerializer } from '../formats/index.js';
import { getSaveFileName, PLAYERS_FILE, Actor, ActorNames } from './constants.js';
/**
* @typedef {Object} SaveSlot
* @property {number} slotNumber - Slot number (0-9)
* @property {boolean} exists - Whether the save file exists
* @property {string} fileName - Save file name
* @property {Object|null} header - Parsed header data
* @property {Object|null} missions - Mission scores
* @property {string|null} playerName - Player name from Players.gsi
* @property {ArrayBuffer|null} buffer - Raw file buffer (for editing)
*/
/**
* Cache for player names (avoids re-parsing Players.gsi)
*/
let playersCache = null;
/**
* Load and cache player names from Players.gsi
* @returns {Promise<{ count: number, players: Array<{ letters: number[], name: string }> }|null>}
*/
async function loadPlayers() {
if (playersCache) return playersCache;
const buffer = await readBinaryFile(PLAYERS_FILE);
if (!buffer) return null;
playersCache = parsePlayers(buffer);
return playersCache;
}
/**
* Clear the players cache (call when Players.gsi changes)
*/
export function clearPlayersCache() {
playersCache = null;
}
/**
* Get player name by index
* @param {number} index - Player index in Players.gsi
* @returns {Promise<string|null>}
*/
async function getPlayerName(index) {
const players = await loadPlayers();
if (!players || index < 0 || index >= players.players.length) {
return null;
}
return players.players[index].name;
}
/**
* List all save slots (0-9) with basic info
* @returns {Promise<SaveSlot[]>}
*/
export async function listSaveSlots() {
const slots = [];
// First load players to resolve names
await loadPlayers();
for (let i = 0; i < 10; i++) {
const fileName = getSaveFileName(i);
const exists = await fileExists(fileName);
const slot = {
slotNumber: i,
exists,
fileName,
header: null,
missions: null,
playerName: null,
buffer: null
};
if (exists) {
try {
const buffer = await readBinaryFile(fileName);
if (buffer) {
const parsed = parseSaveGame(buffer);
slot.header = parsed.header;
slot.missions = parsed.missions;
slot.buffer = buffer;
// Try to get player name
// The playerId in the save file corresponds to an index in Players.gsi
// Actually, we need to match by looking at order - player names are stored
// in order of most recently played, so we just use the index
if (playersCache && playersCache.players.length > 0) {
// Find player by matching the slot with Players.gsi order
// For now, just use the first player if available
// In reality, we'd need to match by player_id
const playerIndex = i < playersCache.players.length ? i : 0;
if (playersCache.players[playerIndex]) {
slot.playerName = playersCache.players[playerIndex].name;
}
}
}
} catch (e) {
console.error(`Failed to parse save slot ${i}:`, e);
}
}
slots.push(slot);
}
return slots;
}
/**
* Load a specific save slot with full details
* @param {number} slotNumber - Slot number (0-9)
* @returns {Promise<SaveSlot|null>}
*/
export async function loadSaveSlot(slotNumber) {
if (slotNumber < 0 || slotNumber > 9) {
throw new Error('Invalid slot number');
}
const fileName = getSaveFileName(slotNumber);
const buffer = await readBinaryFile(fileName);
if (!buffer) {
return null;
}
const parsed = parseSaveGame(buffer);
const players = await loadPlayers();
let playerName = null;
if (players && players.players.length > 0) {
// Use slot index as a simple mapping
const playerIndex = slotNumber < players.players.length ? slotNumber : 0;
if (players.players[playerIndex]) {
playerName = players.players[playerIndex].name;
}
}
return {
slotNumber,
exists: true,
fileName,
header: parsed.header,
missions: parsed.missions,
playerName,
buffer
};
}
/**
* Save changes to a save slot
* @param {number} slotNumber - Slot number (0-9)
* @param {ArrayBuffer} buffer - Modified buffer to save
* @param {boolean} [silent=false] - If true, don't show toast
* @returns {Promise<boolean>}
*/
export async function saveSaveSlot(slotNumber, buffer, silent = false) {
if (slotNumber < 0 || slotNumber > 9) {
throw new Error('Invalid slot number');
}
const fileName = getSaveFileName(slotNumber);
return await writeBinaryFile(fileName, buffer, silent, 'Save updated');
}
/**
* Update a save slot with specific changes
* @param {number} slotNumber - Slot number (0-9)
* @param {Object} updates - Updates to apply
* @param {Object} [updates.header] - Header updates (currentAct, actorId)
* @param {Object} [updates.missionScore] - Single mission score update
* @returns {Promise<SaveSlot|null>} - Updated slot data
*/
export async function updateSaveSlot(slotNumber, updates) {
const slot = await loadSaveSlot(slotNumber);
if (!slot || !slot.buffer) {
console.error('Failed to load save slot');
return null;
}
const serializer = createSerializer(slot.buffer);
let newBuffer = slot.buffer;
let modified = false;
// Apply header updates
if (updates.header) {
newBuffer = serializer.updateHeader(updates.header);
modified = true;
}
// Apply mission score update
if (updates.missionScore) {
const { missionType, actorId, scoreType, value } = updates.missionScore;
// Create new serializer with current buffer state
const scoreSerializer = createSerializer(newBuffer);
// updateMissionScore will add missing states automatically
const result = scoreSerializer.updateMissionScore(missionType, actorId, scoreType, value);
if (result) {
newBuffer = result;
modified = true;
}
}
// Only save if something was actually modified
if (!modified) {
return slot;
}
// Save the modified buffer
const success = await saveSaveSlot(slotNumber, newBuffer);
if (!success) {
console.error('Failed to save slot');
return null;
}
// Re-load and return updated slot
return await loadSaveSlot(slotNumber);
}
/**
* Get all existing save slots (convenience function)
* @returns {Promise<SaveSlot[]>}
*/
export async function getExistingSaveSlots() {
const allSlots = await listSaveSlots();
return allSlots.filter(slot => slot.exists);
}
/**
* Update a player's name in Players.gsi
* @param {number} playerIndex - Index in Players.gsi (0-8)
* @param {string} newName - New name (max 7 chars, A-Z only)
* @returns {Promise<boolean>} - True if successful
*/
export async function updatePlayerName(playerIndex, newName) {
// Load current players data
const players = await loadPlayers();
if (!players) {
console.error('Failed to load Players.gsi');
return false;
}
if (playerIndex < 0 || playerIndex >= players.players.length) {
console.error('Invalid player index');
return false;
}
// Create serializer and update name
const serializer = createPlayersSerializer(players);
serializer.updateName(playerIndex, newName);
// Serialize and save
const buffer = serializer.serialize();
const success = await writeBinaryFile(PLAYERS_FILE, buffer, false, 'Save updated');
if (success) {
// Clear cache so next load gets fresh data
clearPlayersCache();
}
return success;
}

184
src/lib/Carousel.svelte Normal file
View File

@ -0,0 +1,184 @@
<script>
import { onMount } from 'svelte';
export let gap = 10;
let trackRef;
let canScrollLeft = false;
let canScrollRight = false;
let isDragging = false;
let dragStartX = 0;
let scrollStartLeft = 0;
// Exposed so parent can check if a drag occurred (to prevent click handling)
export let hasDragged = false;
function updateArrows() {
if (!trackRef) return;
const { scrollLeft, scrollWidth, clientWidth } = trackRef;
canScrollLeft = scrollLeft > 0;
canScrollRight = scrollLeft + clientWidth < scrollWidth - 1;
}
function scrollLeft() {
trackRef?.scrollBy({ left: -200, behavior: 'smooth' });
}
function scrollRight() {
trackRef?.scrollBy({ left: 200, behavior: 'smooth' });
}
function handleMouseDown(e) {
if (e.button !== 0) return;
isDragging = true;
hasDragged = false;
dragStartX = e.pageX;
scrollStartLeft = trackRef.scrollLeft;
trackRef.style.scrollBehavior = 'auto';
}
function handleMouseMove(e) {
if (!isDragging) return;
e.preventDefault();
const dx = e.pageX - dragStartX;
if (Math.abs(dx) > 5) {
hasDragged = true;
}
trackRef.scrollLeft = scrollStartLeft - dx;
}
function handleMouseUp() {
if (!isDragging) return;
isDragging = false;
trackRef.style.scrollBehavior = 'smooth';
}
function handleClick(e) {
// Find the direct child element that was clicked
const clickedCard = e.target.closest('.carousel-track > *');
if (!clickedCard || hasDragged) return;
const trackRect = trackRef.getBoundingClientRect();
const cardRect = clickedCard.getBoundingClientRect();
// Check if card is fully visible
const isFullyVisible = cardRect.left >= trackRect.left && cardRect.right <= trackRect.right;
if (!isFullyVisible) {
// Scroll to bring card into view
const scrollLeft = cardRect.left < trackRect.left
? trackRef.scrollLeft - (trackRect.left - cardRect.left)
: trackRef.scrollLeft + (cardRect.right - trackRect.right);
trackRef.scrollTo({ left: scrollLeft, behavior: 'smooth' });
}
}
onMount(() => {
updateArrows();
const resizeObserver = new ResizeObserver(updateArrows);
resizeObserver.observe(trackRef);
return () => resizeObserver.disconnect();
});
</script>
<div class="carousel">
<button
type="button"
class="carousel-arrow carousel-arrow-left"
class:disabled={!canScrollLeft}
onclick={scrollLeft}
aria-label="Scroll left"
>
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
<path d="M10 12L6 8l4-4" stroke="currentColor" stroke-width="2" fill="none" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
</button>
<!-- svelte-ignore a11y_click_events_have_key_events a11y_no_static_element_interactions a11y_no_noninteractive_element_interactions -->
<div
class="carousel-track"
class:dragging={isDragging}
style="gap: {gap}px"
bind:this={trackRef}
role="group"
onscroll={updateArrows}
onclick={handleClick}
onmousedown={handleMouseDown}
onmousemove={handleMouseMove}
onmouseup={handleMouseUp}
onmouseleave={handleMouseUp}
>
<slot />
</div>
<button
type="button"
class="carousel-arrow carousel-arrow-right"
class:disabled={!canScrollRight}
onclick={scrollRight}
aria-label="Scroll right"
>
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
<path d="M6 4l4 4-4 4" stroke="currentColor" stroke-width="2" fill="none" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
</button>
</div>
<style>
.carousel {
display: flex;
align-items: center;
gap: 8px;
contain: inline-size;
}
.carousel-track {
display: flex;
overflow-x: auto;
scrollbar-width: none;
-webkit-overflow-scrolling: touch;
scroll-behavior: smooth;
flex: 1;
min-width: 0;
}
.carousel-track::-webkit-scrollbar {
display: none;
}
.carousel-track.dragging {
cursor: grabbing;
user-select: none;
}
.carousel-track:not(.dragging) {
cursor: grab;
}
.carousel-arrow {
display: flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
background: var(--gradient-panel);
border: 1px solid var(--color-border-medium);
border-radius: 50%;
color: var(--color-text-light);
cursor: pointer;
flex-shrink: 0;
transition: all 0.2s ease;
}
.carousel-arrow:hover:not(.disabled) {
border-color: var(--color-border-light);
background: var(--gradient-hover);
}
.carousel-arrow.disabled {
opacity: 0.3;
pointer-events: none;
cursor: default;
}
</style>

View File

@ -1,15 +0,0 @@
<script>
export let title;
export let sectionId;
export let isOpen = false;
export let onToggle = () => {};
</script>
<div class="config-section-card">
<button type="button" class="config-card-header" onclick={() => onToggle(sectionId)}>
{title}
</button>
<div class="config-card-content" class:open={isOpen}>
<slot></slot>
</div>
</div>

View File

@ -1,7 +1,7 @@
<script> <script>
import { configToastVisible } from '../stores.js'; import { configToastVisible, configToastMessage } from '../stores.js';
</script> </script>
<div id="config-toast" class="config-toast" class:show={$configToastVisible}> <div id="config-toast" class="config-toast" class:show={$configToastVisible}>
Settings saved {$configToastMessage}
</div> </div>

View File

@ -33,10 +33,15 @@
]; ];
const changelogItems = [ const changelogItems = [
{ id: 'cl0', title: 'February 2026', items: [
{ type: 'New', text: 'Save Editor lets you view and modify save files — change your player name, character, and high scores directly from the browser' }
]},
{ id: 'cl1', title: 'January 2026', items: [ { id: 'cl1', title: 'January 2026', items: [
{ type: 'New', text: 'Debug menu for developers and power users. Tap the LEGO Island logo 5 times to unlock OGEL mode and access debug features like teleporting to locations, switching acts, and playing animations' }, { type: 'New', text: 'Debug menu for developers and power users. Tap the LEGO Island logo 5 times to unlock OGEL mode and access debug features like teleporting to locations, switching acts, and playing animations' },
{ type: 'Improved', text: 'Configure page redesigned with tabbed navigation, collapsible sections, quick presets (Classic/Modern Mode), and modern toggle switches' }, { type: 'Improved', text: 'Configure page redesigned with tabbed navigation, collapsible sections, quick presets (Classic/Modern Mode), and modern toggle switches' },
{ type: 'Improved', text: 'Read Me page reorganized into tabs (About, System, FAQ, Changelog, Manual) with the original instruction manual now viewable in-browser' } { type: 'Improved', text: 'Read Me page reorganized into tabs (About, System, FAQ, Changelog, Manual) with the original instruction manual now viewable in-browser' },
{ type: 'Fixed', text: 'Safari audio not playing on first toggle' },
{ type: 'Fixed', text: 'Tooltips not working correctly on mobile devices' }
]}, ]},
{ id: 'cl2', title: 'December 2025', items: [ { id: 'cl2', title: 'December 2025', items: [
{ type: 'New', text: '"Active in Background" option keeps the game running when the tab loses focus' }, { type: 'New', text: '"Active in Background" option keeps the game running when the tab loses focus' },

View File

@ -0,0 +1,528 @@
<script>
import { onMount } from 'svelte';
import BackButton from './BackButton.svelte';
import Carousel from './Carousel.svelte';
import MissionScoresEditor from './save-editor/MissionScoresEditor.svelte';
import { saveEditorState, currentPage } from '../stores.js';
import { listSaveSlots, updateSaveSlot, updatePlayerName } from '../core/savegame/index.js';
import { Actor, ActorNames } from '../core/savegame/constants.js';
let loading = true;
let error = null;
let slots = [];
let selectedSlot = null;
// Tab state
let activeTab = 'player';
let openSection = 'name';
const saveTabs = [
{ id: 'player', label: 'Player', firstSection: 'name' },
{ id: 'scores', label: 'Scores', firstSection: null }
];
// Reset state when navigating to this page
$: if ($currentPage === 'save-editor') {
selectedSlot = null;
activeTab = 'player';
openSection = 'name';
}
// Name editing state (7 characters)
let nameSlots = ['', '', '', '', '', '', ''];
let slotRefs = [];
// Character/Act state
let currentAct = 0;
let actorId = 1;
// Character icons mapping
const characterIcons = {
[Actor.PEPPER]: { normal: 'pepper.webp', selected: 'pepper-selected.webp' },
[Actor.MAMA]: { normal: 'mama.webp', selected: 'mama-selected.webp' },
[Actor.PAPA]: { normal: 'papa.webp', selected: 'papa-selected.webp' },
[Actor.NICK]: { normal: 'nick.webp', selected: 'nick-selected.webp' },
[Actor.LAURA]: { normal: 'laura.webp', selected: 'laura-selected.webp' }
};
// Carousel state (bound from Carousel component)
let carouselHasDragged = false;
onMount(async () => {
await loadSlots();
});
async function loadSlots() {
loading = true;
error = null;
try {
slots = await listSaveSlots();
saveEditorState.update(s => ({ ...s, slots, loading: false, error: null }));
} catch (e) {
error = e.message;
saveEditorState.update(s => ({ ...s, loading: false, error: e.message }));
} finally {
loading = false;
}
}
function handleSlotSelect(slotNumber) {
if (carouselHasDragged) return;
selectedSlot = slotNumber;
saveEditorState.update(s => ({ ...s, selectedSlot: slotNumber }));
}
async function handleHeaderUpdate(updates) {
if (selectedSlot === null) return;
try {
const updated = await updateSaveSlot(selectedSlot, { header: updates });
if (updated) {
slots = slots.map(s =>
s.slotNumber === selectedSlot
? { ...s, header: updated.header }
: s
);
}
} catch (e) {
console.error('Failed to update save:', e);
}
}
async function handleMissionUpdate(missionUpdate) {
if (selectedSlot === null) return;
try {
const updated = await updateSaveSlot(selectedSlot, { missionScore: missionUpdate });
if (updated) {
slots = slots.map(s =>
s.slotNumber === selectedSlot
? { ...s, missions: updated.missions }
: s
);
}
} catch (e) {
console.error('Failed to update mission score:', e);
}
}
// Tab/section functions
function switchTab(tab) {
activeTab = tab.id;
openSection = tab.firstSection;
}
function toggleSection(sectionId) {
openSection = openSection === sectionId ? null : sectionId;
}
// Name editing functions
async function saveNameFromSlots() {
const newName = nameSlots.join('').replace(/[^A-Z]/g, '');
if (newName.length === 0) return;
const success = await updatePlayerName(currentSlot.slotNumber, newName);
if (success) {
slots = slots.map(s =>
s.slotNumber === selectedSlot
? { ...s, playerName: newName }
: s
);
}
}
function handleSlotBeforeInput(index, e) {
if (!e.data) return;
const char = e.data.toUpperCase();
if (!/^[A-Z]$/.test(char)) {
e.preventDefault();
return;
}
e.preventDefault();
nameSlots[index] = char;
nameSlots = [...nameSlots];
saveNameFromSlots();
if (index < 6) {
slotRefs[index + 1]?.focus();
}
}
function getFilledCount() {
return nameSlots.filter(c => c !== '').length;
}
function handleSlotKeydown(index, e) {
if (e.key === 'Backspace') {
if (nameSlots[index] === '' && index > 0) {
if (getFilledCount() > 1 || nameSlots[index - 1] === '') {
slotRefs[index - 1]?.focus();
if (nameSlots[index - 1] !== '' && getFilledCount() > 1) {
nameSlots[index - 1] = '';
nameSlots = [...nameSlots];
saveNameFromSlots();
}
}
e.preventDefault();
} else if (nameSlots[index] !== '') {
if (getFilledCount() > 1) {
nameSlots[index] = '';
nameSlots = [...nameSlots];
saveNameFromSlots();
}
e.preventDefault();
}
} else if (e.key === 'ArrowLeft' && index > 0) {
slotRefs[index - 1]?.focus();
e.preventDefault();
} else if (e.key === 'ArrowRight' && index < 6) {
slotRefs[index + 1]?.focus();
e.preventDefault();
} else if (e.key === 'Delete') {
if (getFilledCount() > 1 && nameSlots[index] !== '') {
nameSlots[index] = '';
nameSlots = [...nameSlots];
saveNameFromSlots();
}
e.preventDefault();
}
}
function handleSlotFocus(e) {
e.target.select();
}
// Character handler
function handleActorSelect(id) {
actorId = id;
handleHeaderUpdate({ actorId: id });
}
// Actor options (ordered: Mama, Papa, Pepper, Nick, Laura)
const actorOptions = [
{ id: Actor.MAMA, name: ActorNames[Actor.MAMA] },
{ id: Actor.PAPA, name: ActorNames[Actor.PAPA] },
{ id: Actor.PEPPER, name: ActorNames[Actor.PEPPER] },
{ id: Actor.NICK, name: ActorNames[Actor.NICK] },
{ id: Actor.LAURA, name: ActorNames[Actor.LAURA] }
];
$: existingSlots = slots.filter(s => s.exists);
$: currentSlot = slots.find(s => s.slotNumber === selectedSlot);
// Update local state when currentSlot changes
$: if (currentSlot?.header) {
currentAct = currentSlot.header.currentAct;
actorId = currentSlot.header.actorId;
}
// Update name slots when player name changes
$: if (currentSlot?.playerName) {
const name = currentSlot.playerName.toUpperCase();
nameSlots = Array.from({ length: 7 }, (_, i) => name[i] || '');
}
</script>
<div id="save-editor" class="page-content">
<BackButton />
<div class="page-inner-content config-layout">
<div class="config-art-panel">
<img src="save.webp" alt="LEGO Island Save Editor">
</div>
<div class="config-main">
{#if loading || error || existingSlots.length > 0}
<Carousel bind:hasDragged={carouselHasDragged}>
{#if loading}
<span class="save-status-text">Loading save files...</span>
{:else if error}
<span class="save-status-text error">{error}</span>
{:else}
{#each existingSlots as slot}
<button
type="button"
class="save-slot-card"
class:selected={selectedSlot === slot.slotNumber}
onclick={() => handleSlotSelect(slot.slotNumber)}
>
<img
src={characterIcons[slot.header?.actorId]?.selected || 'pepper-selected.webp'}
alt={ActorNames[slot.header?.actorId] || 'Character'}
class="slot-character-icon"
draggable="false"
/>
<span class="slot-name">{slot.playerName}</span>
</button>
{/each}
{/if}
</Carousel>
{/if}
{#if !loading && !error && existingSlots.length === 0}
<div class="no-saves-state">
<img src="callfail.webp" alt="" class="no-saves-image" />
<span class="no-saves-title">No save files found</span>
<p class="no-saves-description">
Start playing LEGO Island and your save will appear here automatically.
</p>
</div>
{:else if !loading && !error && existingSlots.length > 0 && !currentSlot}
<div class="no-saves-state">
<img src="register.webp" alt="" class="no-saves-image" />
<span class="no-saves-title">Select a save file above</span>
<p class="no-saves-description">
Choose a save slot to view and edit your player name, character, and high scores.
</p>
</div>
{/if}
{#if currentSlot && currentSlot.exists}
<div class="config-tabs">
<div class="config-tab-buttons">
{#each saveTabs as tab}
<button
class="config-tab-btn"
class:active={activeTab === tab.id}
onclick={() => switchTab(tab)}
>
{tab.label}
</button>
{/each}
</div>
<!-- Player Tab -->
<div class:hidden={activeTab !== 'player'}>
<div class="config-section-card">
<button type="button" class="config-card-header" onclick={() => toggleSection('name')}>
Name
</button>
<div class="config-card-content" class:open={openSection === 'name'}>
<div class="section-inner">
<div class="name-slots">
{#each nameSlots as char, i}
<input
type="text"
class="name-slot"
value={char}
maxlength="1"
onbeforeinput={(e) => handleSlotBeforeInput(i, e)}
onkeydown={(e) => handleSlotKeydown(i, e)}
onfocus={handleSlotFocus}
bind:this={slotRefs[i]}
/>
{/each}
</div>
</div>
</div>
</div>
<div class="config-section-card">
<button type="button" class="config-card-header" onclick={() => toggleSection('character')}>
Character
</button>
<div class="config-card-content" class:open={openSection === 'character'}>
<div class="section-inner">
<div class="character-icons">
{#each actorOptions as actor}
<button
type="button"
class="character-icon-btn"
class:selected={actorId === actor.id}
onclick={() => handleActorSelect(actor.id)}
title={actor.name}
>
<img
src={actorId === actor.id
? characterIcons[actor.id].selected
: characterIcons[actor.id].normal}
alt={actor.name}
/>
</button>
{/each}
</div>
</div>
</div>
</div>
</div>
<!-- Scores Tab -->
<div class:hidden={activeTab !== 'scores'}>
<MissionScoresEditor
slot={currentSlot}
onUpdate={handleMissionUpdate}
/>
</div>
</div>
{/if}
</div>
</div>
</div>
<style>
:global(#save-editor > .page-inner-content > .config-main > .carousel) {
margin-bottom: 15px;
}
.save-status-text {
color: var(--color-text-muted);
font-size: 0.9em;
padding: 8px 0;
}
.save-status-text.error {
color: #ff6b6b;
}
.no-saves-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
text-align: center;
padding: 48px 24px;
flex: 1;
}
.no-saves-image {
width: 100px;
height: auto;
image-rendering: pixelated;
margin-bottom: 16px;
border-radius: 8px;
}
.no-saves-title {
color: var(--color-text-light);
font-size: 1.1em;
font-weight: bold;
margin-bottom: 8px;
}
.no-saves-description {
color: var(--color-text-muted);
font-size: 0.9em;
line-height: 1.5;
max-width: 280px;
margin: 0;
}
.save-slot-card {
display: flex;
flex-direction: column;
align-items: center;
gap: 4px;
padding: 6px 8px;
min-width: 85px;
background: var(--gradient-panel);
border: 2px solid var(--color-border-medium);
border-radius: 6px;
cursor: pointer;
transition: all 0.2s ease;
}
.save-slot-card:hover {
border-color: var(--color-border-light);
background: var(--gradient-hover);
}
.save-slot-card.selected {
border-color: var(--color-primary);
box-shadow: 0 0 8px var(--color-primary-glow);
}
.slot-character-icon {
width: 32px;
height: 37px;
image-rendering: pixelated;
}
.slot-name {
color: var(--color-text-light);
font-size: 0.75em;
font-weight: bold;
}
.section-inner {
padding-top: 4px;
}
.name-slots {
display: flex;
gap: 4px;
}
.name-slot {
width: 36px;
height: 36px;
text-align: center;
font-size: 1em;
font-weight: bold;
font-family: inherit;
text-transform: uppercase;
background: var(--color-bg-input);
border: 1px solid var(--color-border-medium);
border-radius: 4px;
color: var(--color-text-light);
padding: 0;
flex-shrink: 0;
}
.name-slot:focus {
outline: none;
border-color: var(--color-primary);
box-shadow: 0 0 0 2px rgba(255, 204, 0, 0.2);
}
.name-slot:hover {
border-color: var(--color-border-light);
}
.character-icons {
display: flex;
gap: 4px;
}
.character-icon-btn {
padding: 4px;
background: var(--color-bg-input);
border: 2px solid var(--color-border-medium);
border-radius: 6px;
cursor: pointer;
transition: all 0.2s ease;
}
.character-icon-btn:hover {
border-color: var(--color-border-light);
}
.character-icon-btn.selected {
border-color: var(--color-primary);
}
.character-icon-btn img {
width: 40px;
height: 46px;
display: block;
image-rendering: pixelated;
}
@media (max-width: 400px) {
.name-slot {
width: 32px;
height: 32px;
font-size: 0.9em;
}
.character-icon-btn img {
width: 32px;
height: 37px;
}
.slot-character-icon {
width: 32px;
height: 37px;
}
}
</style>

View File

@ -1,18 +0,0 @@
<script>
export let tabs = [];
export let activeTab = '';
export let onTabChange = () => {};
export let buttonClass = 'tab-btn';
</script>
<div class="tab-buttons">
{#each tabs as tab}
<button
class={buttonClass}
class:active={activeTab === tab.id}
onclick={() => onTabChange(tab)}
>
{tab.label}
</button>
{/each}
</div>

View File

@ -1,49 +0,0 @@
<script>
export let id;
export let name;
export let label;
export let checked = false;
export let disabled = false;
export let badge = '';
export let tooltip = '';
export let notIni = false;
export let onchange = undefined;
</script>
{#if tooltip}
<div class="toggle-switch">
<label>
<input
type="checkbox"
{id}
{name}
{checked}
{disabled}
data-not-ini={notIni || undefined}
{onchange}
>
<span class="toggle-slider"></span>
<span class="toggle-label">
{label}
{#if badge}
<span class="toggle-badge">{badge}</span>
{/if}
</span>
</label>
<span class="tooltip-trigger">?<span class="tooltip-content">{tooltip}</span></span>
</div>
{:else}
<label class="toggle-switch">
<input
type="checkbox"
{id}
{name}
{checked}
{disabled}
data-not-ini={notIni || undefined}
{onchange}
>
<span class="toggle-slider"></span>
<span class="toggle-label">{label}</span>
</label>
{/if}

View File

@ -1,6 +1,6 @@
<script> <script>
import ImageButton from '../ImageButton.svelte'; import ImageButton from '../ImageButton.svelte';
import { installState, swRegistration } from '../../stores.js'; import { installState, swRegistration, currentPage } from '../../stores.js';
export let opfsDisabled; export let opfsDisabled;
export let openSection; export let openSection;
@ -10,6 +10,12 @@
export let handleUninstall; export let handleUninstall;
$: progressAngle = ($installState.progress / 100) * 360; $: progressAngle = ($installState.progress / 100) * 360;
function navigateToSaveEditor(e) {
e.preventDefault();
history.pushState({ page: 'save-editor' }, '', '#save-editor');
currentPage.set('save-editor');
}
</script> </script>
<div class="config-tab-panel active" id="config-tab-extras"> <div class="config-tab-panel active" id="config-tab-extras">
@ -76,4 +82,7 @@
</div> </div>
</div> </div>
</div> </div>
<div class="config-section-card">
<a href="#save-editor" class="config-card-header nav-link" onclick={navigateToSaveEditor}>Save Editor</a>
</div>
</div> </div>

View File

@ -0,0 +1,31 @@
<script>
import { currentPage } from '../../stores.js';
import ScoreCube from './ScoreCube.svelte';
export let slot;
export let onUpdate = () => {};
// Reactive mission data - re-evaluated when slot changes
$: missionData = slot?.missions || {};
function handleCubeUpdate(update) {
onUpdate(update);
}
</script>
<div class="scores-editor">
{#if $currentPage === 'save-editor'}
<ScoreCube
missions={missionData}
onUpdate={handleCubeUpdate}
/>
{/if}
</div>
<style>
.scores-editor {
display: flex;
justify-content: center;
max-width: 100%;
}
</style>

View File

@ -0,0 +1,226 @@
<script>
import { onMount, onDestroy } from 'svelte';
import { ScoreCubeRenderer } from '../../core/rendering/ScoreCubeRenderer.js';
import { WdbParser, findRoi } from '../../core/formats/WdbParser.js';
export let missions = {};
export let onUpdate = () => {};
let canvas;
let renderer = null;
let loading = true;
let error = null;
// Activity keys in order matching cube layout (left to right)
// Car Race, Jetski Race, Pizza, Tow Track, Ambulance
const activities = ['carRace', 'jetskiRace', 'pizza', 'towTrack', 'ambulance'];
onMount(async () => {
try {
// Load and parse WDB
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 parser = new WdbParser(buffer);
const wdb = parser.parse();
console.log('Parsed worlds:', wdb.worlds.map(w => w.name));
// Find ICUBE world and scormain model
const icubeWorld = wdb.worlds.find(w => w.name === 'ICUBE');
if (!icubeWorld) {
throw new Error('ICUBE world not found in WDB');
}
console.log('ICUBE models:', icubeWorld.models.map(m => m.name));
const scormainModel = icubeWorld.models.find(m =>
m.name.toLowerCase().includes('scormain')
);
if (!scormainModel) {
throw new Error('scormain model not found in ICUBE world');
}
console.log('scormain model:', scormainModel);
// Parse the model_data blob
const modelData = parser.parseModelData(scormainModel.dataOffset);
console.log('Model data ROI:', modelData.roi?.name);
console.log('Model data textures:', modelData.textures?.map(t => t.name));
// Find scorcube ROI
const scorcubeRoi = findRoi(modelData.roi, 'scorcube');
if (!scorcubeRoi) {
throw new Error('scorcube ROI not found');
}
console.log('scorcube ROI:', scorcubeRoi.name, 'lods:', scorcubeRoi.lods?.length);
// Find bigcube texture
const bigcubeTexture = modelData.textures.find(t =>
t.name.toLowerCase() === 'bigcube.gif'
);
if (!bigcubeTexture) {
throw new Error('bigcube.gif texture not found');
}
console.log('bigcube texture:', bigcubeTexture.width, 'x', bigcubeTexture.height);
// Initialize renderer
renderer = new ScoreCubeRenderer(canvas);
renderer.loadModel(scorcubeRoi, bigcubeTexture);
renderer.updateScores(convertScores(missions));
renderer.start();
loading = false;
} catch (e) {
console.error('ScoreCube initialization error:', e);
error = e.message;
loading = false;
}
});
onDestroy(() => {
renderer?.dispose();
});
// Reactive score updates
$: if (renderer && !loading) {
renderer.updateScores(convertScores(missions));
}
/**
* Convert mission data format to 2D score array
* Input: { pizza: { highScores: { 1: val, 2: val, ... } }, ... }
* Output: [[actor0 scores], [actor1 scores], ...]
*/
function convertScores(missionData) {
const result = [];
for (let actor = 0; actor < 5; actor++) {
result[actor] = [];
for (let activity = 0; activity < 5; activity++) {
const missionKey = activities[activity];
// Actor IDs are 1-indexed in the save data
result[actor][activity] = missionData?.[missionKey]?.highScores?.[actor + 1] ?? 0;
}
}
return result;
}
function handleClick(event) {
if (!renderer || loading) return;
const hit = renderer.raycast(event);
if (hit) {
const missionKey = activities[hit.activity];
const actorId = hit.actor + 1; // Convert to 1-indexed
// Get current score and cycle to next
const currentScore = missions?.[missionKey]?.highScores?.[actorId] ?? 0;
const newScore = (currentScore + 1) % 4;
onUpdate({
missionType: missionKey,
actorId,
scoreType: 'highScore',
value: newScore
});
}
}
</script>
<div class="score-cube-container">
<div class="score-cube-header">
<span class="tooltip-trigger">?
<span class="tooltip-content">Click on the cube to cycle high scores. Changes are automatically saved.</span>
</span>
</div>
<canvas
bind:this={canvas}
width="200"
height="200"
onclick={handleClick}
class:hidden={loading || error}
role="button"
tabindex="0"
aria-label="Score cube - click to edit scores"
></canvas>
{#if loading}
<div class="overlay">
<div class="spinner"></div>
</div>
{:else if error}
<div class="overlay error">Error: {error}</div>
{/if}
</div>
<style>
.score-cube-container {
display: flex;
flex-direction: column;
align-items: center;
position: relative;
min-width: 0;
}
.score-cube-header {
align-self: flex-end;
margin-bottom: 4px;
}
canvas {
cursor: pointer;
border-radius: 8px;
margin-bottom: 12px;
max-width: 100%;
}
canvas.hidden {
visibility: hidden;
position: absolute;
}
canvas:focus {
outline: none;
}
.overlay {
display: flex;
align-items: center;
justify-content: center;
width: 200px;
height: 200px;
max-width: 100%;
padding-top: 20px;
color: var(--color-text-muted, #888);
font-size: 0.9em;
}
.overlay.error {
background: var(--color-bg-secondary, #1a1a2e);
border-radius: 8px;
color: var(--color-error, #e74c3c);
padding: 16px;
text-align: center;
}
.spinner {
width: 50px;
height: 50px;
border-radius: 50%;
background:
radial-gradient(transparent 55%, transparent 56%),
conic-gradient(var(--color-primary, #FFD700) 0deg 90deg, var(--color-border-dark, #333) 90deg 360deg);
animation: spin 1s linear infinite;
}
@keyframes spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
</style>

View File

@ -7,7 +7,8 @@ function getInitialPage() {
const pageMap = { const pageMap = {
'#read-me': 'read-me', '#read-me': 'read-me',
'#configure': 'configure', '#configure': 'configure',
'#free-stuff': 'free-stuff' '#free-stuff': 'free-stuff',
'#save-editor': 'save-editor'
}; };
return pageMap[hash] || 'main'; return pageMap[hash] || 'main';
} }
@ -35,6 +36,7 @@ export const installState = writable({
// Config toast // Config toast
export const configToastVisible = writable(false); export const configToastVisible = writable(false);
export const configToastMessage = writable('Settings saved');
// Debug UI visible (set when game reaches intro animation) // Debug UI visible (set when game reaches intro animation)
export const debugUIVisible = writable(false); export const debugUIVisible = writable(false);
@ -44,3 +46,11 @@ export const gameRunning = writable(false);
// Service worker registration // Service worker registration
export const swRegistration = writable(null); export const swRegistration = writable(null);
// Save editor state
export const saveEditorState = writable({
slots: [], // Array of SaveSlot objects
selectedSlot: null, // Currently selected slot number
loading: true,
error: null
});