mirror of
https://github.com/isledecomp/isle.pizza.git
synced 2026-03-01 14:27:38 +00:00
WIP
This commit is contained in:
parent
5ce1b1ed69
commit
f2b3469530
@ -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>
|
||||||
|
|||||||
21
src/app.css
21
src/app.css
@ -563,6 +563,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;
|
||||||
|
|||||||
147
src/core/opfs.js
147
src/core/opfs.js
@ -1,5 +1,5 @@
|
|||||||
// 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;
|
||||||
@ -195,3 +195,148 @@ export async function saveConfig(form, getSiFiles, silent = false) {
|
|||||||
worker.terminate();
|
worker.terminate();
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Binary File Operations for Save Game Editor
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get OPFS root directory
|
||||||
|
* @returns {Promise<FileSystemDirectoryHandle|null>}
|
||||||
|
*/
|
||||||
|
export async function getOpfsRoot() {
|
||||||
|
try {
|
||||||
|
return await navigator.storage.getDirectory();
|
||||||
|
} catch (e) {
|
||||||
|
console.error("OPFS not available or permission denied.", 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) {
|
||||||
|
try {
|
||||||
|
const root = await getOpfsRoot();
|
||||||
|
if (!root) return false;
|
||||||
|
await root.getFileHandle(filename, { create: false });
|
||||||
|
return true;
|
||||||
|
} catch (e) {
|
||||||
|
if (e.name === 'NotFoundError') return false;
|
||||||
|
console.error('Error checking file existence:', e);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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 root = await getOpfsRoot();
|
||||||
|
if (!root) return null;
|
||||||
|
|
||||||
|
const handle = await root.getFileHandle(filename, { create: false });
|
||||||
|
const file = await handle.getFile();
|
||||||
|
return await file.arrayBuffer();
|
||||||
|
} catch (e) {
|
||||||
|
if (e.name === 'NotFoundError') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
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} 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) {
|
||||||
|
if (toastTimeout) {
|
||||||
|
clearTimeout(toastTimeout);
|
||||||
|
}
|
||||||
|
configToastMessage.set(toastMsg);
|
||||||
|
configToastVisible.set(true);
|
||||||
|
toastTimeout = setTimeout(() => configToastVisible.set(false), 2000);
|
||||||
|
}
|
||||||
|
resolve(true);
|
||||||
|
} else {
|
||||||
|
resolve(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
worker.onerror = (e) => {
|
||||||
|
console.error('An error occurred in the binary-saving worker:', e.message);
|
||||||
|
URL.revokeObjectURL(workerUrl);
|
||||||
|
worker.terminate();
|
||||||
|
resolve(false);
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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 [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
164
src/core/savegame/BinaryReader.js
Normal file
164
src/core/savegame/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/savegame/BinaryWriter.js
Normal file
172
src/core/savegame/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/savegame/PlayersParser.js
Normal file
54
src/core/savegame/PlayersParser.js
Normal file
@ -0,0 +1,54 @@
|
|||||||
|
/**
|
||||||
|
* Parser for Players.gsi file - player profile names
|
||||||
|
*/
|
||||||
|
import { BinaryReader } from './BinaryReader.js';
|
||||||
|
import { LetterIndex } from './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/savegame/PlayersSerializer.js
Normal file
62
src/core/savegame/PlayersSerializer.js
Normal file
@ -0,0 +1,62 @@
|
|||||||
|
/**
|
||||||
|
* Serializer for Players.gsi file - updating player names
|
||||||
|
*/
|
||||||
|
import { BinaryWriter } from './BinaryWriter.js';
|
||||||
|
import { LetterIndex } from './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/savegame/SaveGameParser.js
Normal file
396
src/core/savegame/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 './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/savegame/SaveGameSerializer.js
Normal file
329
src/core/savegame/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 './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);
|
||||||
|
}
|
||||||
191
src/core/savegame/constants.js
Normal file
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`;
|
||||||
|
}
|
||||||
284
src/core/savegame/index.js
Normal file
284
src/core/savegame/index.js
Normal file
@ -0,0 +1,284 @@
|
|||||||
|
/**
|
||||||
|
* Save Game Editor - Public API
|
||||||
|
*
|
||||||
|
* High-level functions for managing LEGO Island save files
|
||||||
|
*/
|
||||||
|
|
||||||
|
// Re-export core utilities
|
||||||
|
export { BinaryReader } from './BinaryReader.js';
|
||||||
|
export { BinaryWriter } from './BinaryWriter.js';
|
||||||
|
export { SaveGameParser, parseSaveGame } from './SaveGameParser.js';
|
||||||
|
export { SaveGameSerializer, createSerializer } from './SaveGameSerializer.js';
|
||||||
|
export { PlayersParser, parsePlayers } from './PlayersParser.js';
|
||||||
|
export { PlayersSerializer, createPlayersSerializer } from './PlayersSerializer.js';
|
||||||
|
export * from './constants.js';
|
||||||
|
|
||||||
|
// Import dependencies
|
||||||
|
import { readBinaryFile, writeBinaryFile, fileExists, listFiles } from '../opfs.js';
|
||||||
|
import { parseSaveGame } from './SaveGameParser.js';
|
||||||
|
import { parsePlayers } from './PlayersParser.js';
|
||||||
|
import { createSerializer } from './SaveGameSerializer.js';
|
||||||
|
import { createPlayersSerializer } from './PlayersSerializer.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;
|
||||||
|
}
|
||||||
@ -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>
|
||||||
|
|||||||
168
src/lib/SaveEditorPage.svelte
Normal file
168
src/lib/SaveEditorPage.svelte
Normal file
@ -0,0 +1,168 @@
|
|||||||
|
<script>
|
||||||
|
import { onMount } from 'svelte';
|
||||||
|
import BackButton from './BackButton.svelte';
|
||||||
|
import SaveSlotList from './save-editor/SaveSlotList.svelte';
|
||||||
|
import PlayerInfoEditor from './save-editor/PlayerInfoEditor.svelte';
|
||||||
|
import MissionScoresEditor from './save-editor/MissionScoresEditor.svelte';
|
||||||
|
import { saveEditorState, configToastVisible } from '../stores.js';
|
||||||
|
import { listSaveSlots, updateSaveSlot } from '../core/savegame/index.js';
|
||||||
|
|
||||||
|
let loading = true;
|
||||||
|
let error = null;
|
||||||
|
let slots = [];
|
||||||
|
let selectedSlot = null;
|
||||||
|
|
||||||
|
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) {
|
||||||
|
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) {
|
||||||
|
// Update the slot in our list
|
||||||
|
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) {
|
||||||
|
// Update the slot in our list
|
||||||
|
slots = slots.map(s =>
|
||||||
|
s.slotNumber === selectedSlot
|
||||||
|
? { ...s, missions: updated.missions }
|
||||||
|
: s
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to update mission score:', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleNameUpdate(newName) {
|
||||||
|
// Update the slot's playerName in our list
|
||||||
|
slots = slots.map(s =>
|
||||||
|
s.slotNumber === selectedSlot
|
||||||
|
? { ...s, playerName: newName }
|
||||||
|
: s
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
$: existingSlots = slots.filter(s => s.exists);
|
||||||
|
$: currentSlot = slots.find(s => s.slotNumber === selectedSlot);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div id="save-editor-page" class="page-content">
|
||||||
|
<BackButton />
|
||||||
|
<div class="page-inner-content save-editor-layout">
|
||||||
|
<h1>Save Editor</h1>
|
||||||
|
|
||||||
|
{#if loading}
|
||||||
|
<div class="save-editor-status">
|
||||||
|
<p>Loading save files...</p>
|
||||||
|
</div>
|
||||||
|
{:else if error}
|
||||||
|
<div class="save-editor-status error">
|
||||||
|
<p>Error: {error}</p>
|
||||||
|
</div>
|
||||||
|
{:else if existingSlots.length === 0}
|
||||||
|
<div class="save-editor-status">
|
||||||
|
<p>No save files found. Start a new game to create a save file.</p>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<div class="save-editor-content">
|
||||||
|
<SaveSlotList
|
||||||
|
{slots}
|
||||||
|
{selectedSlot}
|
||||||
|
onSelect={handleSlotSelect}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{#if currentSlot && currentSlot.exists}
|
||||||
|
<div class="save-editor-details">
|
||||||
|
<PlayerInfoEditor
|
||||||
|
slot={currentSlot}
|
||||||
|
onUpdate={handleHeaderUpdate}
|
||||||
|
onNameUpdate={handleNameUpdate}
|
||||||
|
/>
|
||||||
|
<MissionScoresEditor
|
||||||
|
slot={currentSlot}
|
||||||
|
onUpdate={handleMissionUpdate}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.save-editor-layout {
|
||||||
|
max-width: 900px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.save-editor-layout h1 {
|
||||||
|
color: var(--color-primary);
|
||||||
|
font-size: 2em;
|
||||||
|
margin-bottom: 24px;
|
||||||
|
text-shadow: 2px 2px 4px rgba(0, 0, 0, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.save-editor-status {
|
||||||
|
text-align: center;
|
||||||
|
padding: 40px;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
background: var(--gradient-panel);
|
||||||
|
border: 1px solid var(--color-border-dark);
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.save-editor-status.error {
|
||||||
|
color: #ff6b6b;
|
||||||
|
border-color: #ff6b6b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.save-editor-content {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.save-editor-details {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@ -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>
|
||||||
|
|||||||
215
src/lib/save-editor/MissionScoresEditor.svelte
Normal file
215
src/lib/save-editor/MissionScoresEditor.svelte
Normal file
@ -0,0 +1,215 @@
|
|||||||
|
<script>
|
||||||
|
import ScoreColorButton from './ScoreColorButton.svelte';
|
||||||
|
import { Actor, ActorNames, MissionNames } from '../../core/savegame/constants.js';
|
||||||
|
|
||||||
|
export let slot;
|
||||||
|
export let onUpdate = () => {};
|
||||||
|
|
||||||
|
const missions = [
|
||||||
|
{ key: 'pizza', name: MissionNames.pizza },
|
||||||
|
{ key: 'carRace', name: MissionNames.carRace },
|
||||||
|
{ key: 'jetskiRace', name: MissionNames.jetskiRace },
|
||||||
|
{ key: 'towTrack', name: MissionNames.towTrack },
|
||||||
|
{ key: 'ambulance', name: MissionNames.ambulance }
|
||||||
|
];
|
||||||
|
|
||||||
|
const actors = [
|
||||||
|
{ id: Actor.PEPPER, name: ActorNames[Actor.PEPPER] },
|
||||||
|
{ id: Actor.MAMA, name: ActorNames[Actor.MAMA] },
|
||||||
|
{ id: Actor.PAPA, name: ActorNames[Actor.PAPA] },
|
||||||
|
{ id: Actor.NICK, name: ActorNames[Actor.NICK] },
|
||||||
|
{ id: Actor.LAURA, name: ActorNames[Actor.LAURA] }
|
||||||
|
];
|
||||||
|
|
||||||
|
// Reactive mission data - re-evaluated when slot changes
|
||||||
|
$: missionData = slot?.missions || {};
|
||||||
|
|
||||||
|
function handleScoreChange(missionKey, actorId, scoreType, newColor) {
|
||||||
|
onUpdate({
|
||||||
|
missionType: missionKey,
|
||||||
|
actorId,
|
||||||
|
scoreType,
|
||||||
|
value: newColor
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function getScore(missionKey, actorId, scoreType) {
|
||||||
|
const data = missionData[missionKey];
|
||||||
|
if (!data) return 0;
|
||||||
|
|
||||||
|
if (scoreType === 'score') {
|
||||||
|
return data.scores?.[actorId] ?? 0;
|
||||||
|
} else {
|
||||||
|
return data.highScores?.[actorId] ?? 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="mission-scores-editor">
|
||||||
|
<h3 class="section-title">Mission Scores</h3>
|
||||||
|
|
||||||
|
<div class="scores-table-wrapper">
|
||||||
|
{#key missionData}
|
||||||
|
<div class="scores-table">
|
||||||
|
<div class="scores-header">
|
||||||
|
<div class="mission-col"></div>
|
||||||
|
{#each actors as actor}
|
||||||
|
<div class="actor-col">{actor.name}</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#each missions as mission}
|
||||||
|
<div class="scores-row">
|
||||||
|
<div class="mission-col">{mission.name}</div>
|
||||||
|
{#each actors as actor}
|
||||||
|
<div class="actor-col">
|
||||||
|
<ScoreColorButton
|
||||||
|
color={getScore(mission.key, actor.id, 'score')}
|
||||||
|
onChange={(c) => handleScoreChange(mission.key, actor.id, 'score', c)}
|
||||||
|
title="Score"
|
||||||
|
/>
|
||||||
|
<ScoreColorButton
|
||||||
|
color={getScore(mission.key, actor.id, 'highScore')}
|
||||||
|
onChange={(c) => handleScoreChange(mission.key, actor.id, 'highScore', c)}
|
||||||
|
title="High Score"
|
||||||
|
isHighScore={true}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/key}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="scores-legend">
|
||||||
|
<span class="legend-item">
|
||||||
|
<span class="legend-color grey"></span> Grey
|
||||||
|
</span>
|
||||||
|
<span class="legend-item">
|
||||||
|
<span class="legend-color yellow"></span> Yellow
|
||||||
|
</span>
|
||||||
|
<span class="legend-item">
|
||||||
|
<span class="legend-color blue"></span> Blue
|
||||||
|
</span>
|
||||||
|
<span class="legend-item">
|
||||||
|
<span class="legend-color red"></span> Red
|
||||||
|
</span>
|
||||||
|
<span class="legend-divider">|</span>
|
||||||
|
<span class="legend-note">H = High Score</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.mission-scores-editor {
|
||||||
|
background: var(--gradient-panel);
|
||||||
|
border: 1px solid var(--color-border-dark);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-title {
|
||||||
|
color: var(--color-primary);
|
||||||
|
font-size: 1.1em;
|
||||||
|
margin: 0 0 16px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scores-table-wrapper {
|
||||||
|
overflow-x: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scores-table {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
min-width: 500px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scores-header,
|
||||||
|
.scores-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 120px repeat(5, 1fr);
|
||||||
|
gap: 8px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scores-header {
|
||||||
|
font-weight: bold;
|
||||||
|
color: var(--color-text-light);
|
||||||
|
font-size: 0.8em;
|
||||||
|
padding-bottom: 8px;
|
||||||
|
border-bottom: 1px solid var(--color-border-dark);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mission-col {
|
||||||
|
color: var(--color-text-medium);
|
||||||
|
font-size: 0.85em;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.actor-col {
|
||||||
|
display: flex;
|
||||||
|
gap: 4px;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scores-header .actor-col {
|
||||||
|
font-size: 0.75em;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scores-legend {
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
margin-top: 16px;
|
||||||
|
padding-top: 16px;
|
||||||
|
border-top: 1px solid var(--color-border-dark);
|
||||||
|
}
|
||||||
|
|
||||||
|
.legend-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
font-size: 0.8em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.legend-color {
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
border-radius: 3px;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.legend-color.grey { background: #808080; }
|
||||||
|
.legend-color.yellow { background: #FFD700; }
|
||||||
|
.legend-color.blue { background: #4169E1; }
|
||||||
|
.legend-color.red { background: #DC143C; }
|
||||||
|
|
||||||
|
.legend-divider {
|
||||||
|
color: var(--color-border-medium);
|
||||||
|
}
|
||||||
|
|
||||||
|
.legend-note {
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
font-size: 0.8em;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 600px) {
|
||||||
|
.scores-header,
|
||||||
|
.scores-row {
|
||||||
|
grid-template-columns: 90px repeat(5, 1fr);
|
||||||
|
}
|
||||||
|
|
||||||
|
.scores-header .actor-col {
|
||||||
|
font-size: 0.65em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mission-col {
|
||||||
|
font-size: 0.75em;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
298
src/lib/save-editor/PlayerInfoEditor.svelte
Normal file
298
src/lib/save-editor/PlayerInfoEditor.svelte
Normal file
@ -0,0 +1,298 @@
|
|||||||
|
<script>
|
||||||
|
import { Actor, ActorNames } from '../../core/savegame/constants.js';
|
||||||
|
import { updatePlayerName } from '../../core/savegame/index.js';
|
||||||
|
|
||||||
|
export let slot;
|
||||||
|
export let onUpdate = () => {};
|
||||||
|
export let onNameUpdate = () => {};
|
||||||
|
|
||||||
|
// Local state for form values
|
||||||
|
let currentAct = slot?.header?.currentAct ?? 0;
|
||||||
|
let actorId = slot?.header?.actorId ?? 1;
|
||||||
|
|
||||||
|
// Name slots (7 characters)
|
||||||
|
let nameSlots = ['', '', '', '', '', '', ''];
|
||||||
|
let slotRefs = [];
|
||||||
|
|
||||||
|
// Update local state when slot changes
|
||||||
|
$: if (slot?.header) {
|
||||||
|
currentAct = slot.header.currentAct;
|
||||||
|
actorId = slot.header.actorId;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update name slots when player name changes
|
||||||
|
$: if (slot?.playerName) {
|
||||||
|
const name = slot.playerName.toUpperCase();
|
||||||
|
nameSlots = Array.from({ length: 7 }, (_, i) => name[i] || '');
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleActChange(e) {
|
||||||
|
currentAct = parseInt(e.target.value);
|
||||||
|
onUpdate({ currentAct });
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleActorChange(e) {
|
||||||
|
actorId = parseInt(e.target.value);
|
||||||
|
onUpdate({ actorId });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveNameFromSlots() {
|
||||||
|
const newName = nameSlots.join('').replace(/[^A-Z]/g, '');
|
||||||
|
if (newName.length === 0) return;
|
||||||
|
|
||||||
|
const success = await updatePlayerName(slot.slotNumber, newName);
|
||||||
|
if (success) {
|
||||||
|
onNameUpdate(newName);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleSlotBeforeInput(index, e) {
|
||||||
|
// Allow deletions
|
||||||
|
if (!e.data) return;
|
||||||
|
|
||||||
|
// Only allow A-Z (case insensitive)
|
||||||
|
const char = e.data.toUpperCase();
|
||||||
|
if (!/^[A-Z]$/.test(char)) {
|
||||||
|
e.preventDefault();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prevent default and handle manually for better control
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
nameSlots[index] = char;
|
||||||
|
nameSlots = [...nameSlots];
|
||||||
|
|
||||||
|
// Auto-save
|
||||||
|
saveNameFromSlots();
|
||||||
|
|
||||||
|
// Move to next slot
|
||||||
|
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) {
|
||||||
|
// Move to previous slot and clear it (if not the last character)
|
||||||
|
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] !== '') {
|
||||||
|
// Clear current slot only if not the last character
|
||||||
|
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') {
|
||||||
|
// Only delete if not the last character
|
||||||
|
if (getFilledCount() > 1 && nameSlots[index] !== '') {
|
||||||
|
nameSlots[index] = '';
|
||||||
|
nameSlots = [...nameSlots];
|
||||||
|
saveNameFromSlots();
|
||||||
|
}
|
||||||
|
e.preventDefault();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleSlotFocus(e) {
|
||||||
|
e.target.select();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get actor options (excluding NONE)
|
||||||
|
const actorOptions = [
|
||||||
|
{ id: Actor.PEPPER, name: ActorNames[Actor.PEPPER] },
|
||||||
|
{ id: Actor.MAMA, name: ActorNames[Actor.MAMA] },
|
||||||
|
{ id: Actor.PAPA, name: ActorNames[Actor.PAPA] },
|
||||||
|
{ id: Actor.NICK, name: ActorNames[Actor.NICK] },
|
||||||
|
{ id: Actor.LAURA, name: ActorNames[Actor.LAURA] }
|
||||||
|
];
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="player-info-editor">
|
||||||
|
<h3 class="section-title">Player Info</h3>
|
||||||
|
|
||||||
|
<div class="info-row">
|
||||||
|
<span class="row-label">Name</span>
|
||||||
|
<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 class="info-row">
|
||||||
|
<label class="row-label" for="act-select">Act</label>
|
||||||
|
<div class="select-wrapper">
|
||||||
|
<select id="act-select" value={currentAct} onchange={handleActChange}>
|
||||||
|
<option value={0}>Act 1</option>
|
||||||
|
<option value={1}>Act 2</option>
|
||||||
|
<option value={2}>Act 3</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="info-row">
|
||||||
|
<label class="row-label" for="actor-select">Character</label>
|
||||||
|
<div class="select-wrapper">
|
||||||
|
<select id="actor-select" value={actorId} onchange={handleActorChange}>
|
||||||
|
{#each actorOptions as actor}
|
||||||
|
<option value={actor.id}>{actor.name}</option>
|
||||||
|
{/each}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.player-info-editor {
|
||||||
|
background: var(--gradient-panel);
|
||||||
|
border: 1px solid var(--color-border-dark);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 16px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-title {
|
||||||
|
color: var(--color-primary);
|
||||||
|
font-size: 1.1em;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.row-label {
|
||||||
|
color: var(--color-text-medium);
|
||||||
|
font-size: 0.9em;
|
||||||
|
min-width: 70px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.name-slots {
|
||||||
|
display: flex;
|
||||||
|
gap: 4px;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.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);
|
||||||
|
}
|
||||||
|
|
||||||
|
.select-wrapper {
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
flex: 1;
|
||||||
|
max-width: 200px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.select-wrapper::after {
|
||||||
|
content: '\25BC';
|
||||||
|
position: absolute;
|
||||||
|
right: 10px;
|
||||||
|
pointer-events: none;
|
||||||
|
color: var(--color-text-medium);
|
||||||
|
font-size: 0.7em;
|
||||||
|
}
|
||||||
|
|
||||||
|
select {
|
||||||
|
width: 100%;
|
||||||
|
background-color: var(--color-bg-input);
|
||||||
|
color: var(--color-text-light);
|
||||||
|
border: 1px solid var(--color-border-medium);
|
||||||
|
padding: 8px 30px 8px 12px;
|
||||||
|
border-radius: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
appearance: none;
|
||||||
|
font-size: 0.9em;
|
||||||
|
font-family: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
select:hover {
|
||||||
|
border-color: var(--color-border-light);
|
||||||
|
}
|
||||||
|
|
||||||
|
select:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: var(--color-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 400px) {
|
||||||
|
.info-row {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.row-label {
|
||||||
|
min-width: unset;
|
||||||
|
}
|
||||||
|
|
||||||
|
.select-wrapper {
|
||||||
|
max-width: none;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.name-slot {
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
font-size: 0.9em;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
77
src/lib/save-editor/SaveSlotCard.svelte
Normal file
77
src/lib/save-editor/SaveSlotCard.svelte
Normal file
@ -0,0 +1,77 @@
|
|||||||
|
<script>
|
||||||
|
import { ActorNames } from '../../core/savegame/constants.js';
|
||||||
|
|
||||||
|
export let slot;
|
||||||
|
export let selected = false;
|
||||||
|
export let onClick = () => {};
|
||||||
|
|
||||||
|
$: actorName = slot.header?.actorId ? ActorNames[slot.header.actorId] : 'None';
|
||||||
|
$: actNumber = (slot.header?.currentAct ?? 0) + 1;
|
||||||
|
$: playerName = slot.playerName || 'Unknown';
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<button
|
||||||
|
class="save-slot-card"
|
||||||
|
class:selected
|
||||||
|
onclick={onClick}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<div class="slot-header">
|
||||||
|
<span class="slot-number">Slot {slot.slotNumber}</span>
|
||||||
|
<span class="player-name">{playerName}</span>
|
||||||
|
</div>
|
||||||
|
<div class="slot-info">
|
||||||
|
<span class="actor">{actorName}</span>
|
||||||
|
<span class="act">Act {actNumber}</span>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.save-slot-card {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
padding: 12px 16px;
|
||||||
|
background: var(--color-bg-panel);
|
||||||
|
border: 2px solid var(--color-border-dark);
|
||||||
|
border-radius: 8px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
text-align: left;
|
||||||
|
font-family: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.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 10px var(--color-primary-glow);
|
||||||
|
}
|
||||||
|
|
||||||
|
.slot-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.slot-number {
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
font-size: 0.8em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.player-name {
|
||||||
|
color: var(--color-primary);
|
||||||
|
font-weight: bold;
|
||||||
|
font-size: 0.95em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.slot-info {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
color: var(--color-text-medium);
|
||||||
|
font-size: 0.85em;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
54
src/lib/save-editor/SaveSlotList.svelte
Normal file
54
src/lib/save-editor/SaveSlotList.svelte
Normal file
@ -0,0 +1,54 @@
|
|||||||
|
<script>
|
||||||
|
import SaveSlotCard from './SaveSlotCard.svelte';
|
||||||
|
|
||||||
|
export let slots = [];
|
||||||
|
export let selectedSlot = null;
|
||||||
|
export let onSelect = () => {};
|
||||||
|
|
||||||
|
$: existingSlots = slots.filter(s => s.exists);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="save-slot-list">
|
||||||
|
<h2 class="section-title">Save Slots</h2>
|
||||||
|
{#if existingSlots.length === 0}
|
||||||
|
<p class="no-slots">No save files found</p>
|
||||||
|
{:else}
|
||||||
|
<div class="slot-grid">
|
||||||
|
{#each existingSlots as slot}
|
||||||
|
<SaveSlotCard
|
||||||
|
{slot}
|
||||||
|
selected={selectedSlot === slot.slotNumber}
|
||||||
|
onClick={() => onSelect(slot.slotNumber)}
|
||||||
|
/>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.save-slot-list {
|
||||||
|
background: var(--gradient-panel);
|
||||||
|
border: 1px solid var(--color-border-dark);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-title {
|
||||||
|
color: var(--color-primary);
|
||||||
|
font-size: 1.1em;
|
||||||
|
margin: 0 0 16px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.slot-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.no-slots {
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
text-align: center;
|
||||||
|
padding: 20px;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
64
src/lib/save-editor/ScoreColorButton.svelte
Normal file
64
src/lib/save-editor/ScoreColorButton.svelte
Normal file
@ -0,0 +1,64 @@
|
|||||||
|
<script>
|
||||||
|
import { ScoreColor, ScoreColorCSS, ScoreColorNames } from '../../core/savegame/constants.js';
|
||||||
|
|
||||||
|
export let color = 0;
|
||||||
|
export let onChange = () => {};
|
||||||
|
export let title = '';
|
||||||
|
export let isHighScore = false;
|
||||||
|
|
||||||
|
function handleClick() {
|
||||||
|
// Cycle through colors: 0 -> 1 -> 2 -> 3 -> 0
|
||||||
|
const nextColor = (color + 1) % 4;
|
||||||
|
onChange(nextColor);
|
||||||
|
}
|
||||||
|
|
||||||
|
$: colorName = ScoreColorNames[color] || 'Unknown';
|
||||||
|
$: bgColor = ScoreColorCSS[color] || '#808080';
|
||||||
|
$: tooltipText = title ? `${title}: ${colorName}` : colorName;
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<button
|
||||||
|
class="score-color-button"
|
||||||
|
class:high-score={isHighScore}
|
||||||
|
style="background-color: {bgColor}"
|
||||||
|
onclick={handleClick}
|
||||||
|
title="{tooltipText} - Click to change"
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
{#if isHighScore}
|
||||||
|
<span class="high-score-indicator">H</span>
|
||||||
|
{/if}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.score-color-button {
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
border-radius: 4px;
|
||||||
|
border: 2px solid rgba(255, 255, 255, 0.3);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.15s ease;
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.score-color-button:hover {
|
||||||
|
transform: scale(1.15);
|
||||||
|
border-color: rgba(255, 255, 255, 0.6);
|
||||||
|
box-shadow: 0 0 8px rgba(255, 255, 255, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.score-color-button:active {
|
||||||
|
transform: scale(0.95);
|
||||||
|
}
|
||||||
|
|
||||||
|
.high-score-indicator {
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: bold;
|
||||||
|
color: rgba(0, 0, 0, 0.6);
|
||||||
|
text-shadow: 0 0 2px rgba(255, 255, 255, 0.5);
|
||||||
|
}
|
||||||
|
</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
|
||||||
|
});
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user