* 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
44
package-lock.json
generated
@ -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",
|
||||||
|
|||||||
@ -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
|
After Width: | Height: | Size: 918 B |
BIN
public/laura.webp
Normal file
|
After Width: | Height: | Size: 1018 B |
BIN
public/mama-selected.webp
Normal file
|
After Width: | Height: | Size: 964 B |
BIN
public/mama.webp
Normal file
|
After Width: | Height: | Size: 1016 B |
BIN
public/nick-selected.webp
Normal file
|
After Width: | Height: | Size: 876 B |
BIN
public/nick.webp
Normal file
|
After Width: | Height: | Size: 986 B |
BIN
public/papa-selected.webp
Normal file
|
After Width: | Height: | Size: 948 B |
BIN
public/papa.webp
Normal file
|
After Width: | Height: | Size: 980 B |
BIN
public/pepper-selected.webp
Normal file
|
After Width: | Height: | Size: 844 B |
BIN
public/pepper.webp
Normal file
|
After Width: | Height: | Size: 946 B |
BIN
public/save.webp
Normal file
|
After Width: | Height: | Size: 20 KiB |
@ -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>
|
||||||
|
|||||||
27
src/app.css
@ -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;
|
||||||
|
|||||||
164
src/core/formats/BinaryReader.js
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
172
src/core/formats/BinaryWriter.js
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
54
src/core/formats/PlayersParser.js
Normal 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();
|
||||||
|
}
|
||||||
62
src/core/formats/PlayersSerializer.js
Normal 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);
|
||||||
|
}
|
||||||
396
src/core/formats/SaveGameParser.js
Normal 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();
|
||||||
|
}
|
||||||
329
src/core/formats/SaveGameSerializer.js
Normal 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);
|
||||||
|
}
|
||||||
439
src/core/formats/WdbParser.js
Normal 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
@ -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';
|
||||||
230
src/core/opfs.js
@ -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();
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|||||||
84
src/core/rendering/ScoreCubeRenderer.js
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
303
src/core/rendering/WdbModelRenderer.js
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
191
src/core/savegame/constants.js
Normal 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
@ -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
@ -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>
|
||||||
@ -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>
|
|
||||||
@ -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>
|
||||||
|
|||||||
@ -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' },
|
||||||
|
|||||||
528
src/lib/SaveEditorPage.svelte
Normal 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>
|
||||||
@ -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>
|
|
||||||
@ -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}
|
|
||||||
@ -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>
|
||||||
|
|||||||
31
src/lib/save-editor/MissionScoresEditor.svelte
Normal 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>
|
||||||
226
src/lib/save-editor/ScoreCube.svelte
Normal 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>
|
||||||
@ -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
|
||||||
|
});
|
||||||
|
|||||||