mirror of
https://github.com/isledecomp/isle.pizza.git
synced 2026-03-01 06:17:38 +00:00
WIP
This commit is contained in:
parent
5ce1b1ed69
commit
f2b3469530
@ -8,6 +8,7 @@
|
||||
import ReadMePage from './lib/ReadMePage.svelte';
|
||||
import ConfigurePage from './lib/ConfigurePage.svelte';
|
||||
import FreeStuffPage from './lib/FreeStuffPage.svelte';
|
||||
import SaveEditorPage from './lib/SaveEditorPage.svelte';
|
||||
import UpdatePopup from './lib/UpdatePopup.svelte';
|
||||
import GoodbyePopup from './lib/GoodbyePopup.svelte';
|
||||
import ConfigToast from './lib/ConfigToast.svelte';
|
||||
@ -76,6 +77,9 @@
|
||||
<div class="page-wrapper" class:active={$currentPage === 'free-stuff'}>
|
||||
<FreeStuffPage />
|
||||
</div>
|
||||
<div class="page-wrapper" class:active={$currentPage === 'save-editor'}>
|
||||
<SaveEditorPage />
|
||||
</div>
|
||||
|
||||
<div class="footer-disclaimer">
|
||||
<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: '−';
|
||||
}
|
||||
|
||||
.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 {
|
||||
display: grid;
|
||||
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
|
||||
import { configToastVisible } from '../stores.js';
|
||||
import { configToastVisible, configToastMessage } from '../stores.js';
|
||||
|
||||
const CONFIG_FILE = 'isle.ini';
|
||||
let toastTimeout = null;
|
||||
@ -195,3 +195,148 @@ export async function saveConfig(form, getSiFiles, silent = false) {
|
||||
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>
|
||||
import { configToastVisible } from '../stores.js';
|
||||
import { configToastVisible, configToastMessage } from '../stores.js';
|
||||
</script>
|
||||
|
||||
<div id="config-toast" class="config-toast" class:show={$configToastVisible}>
|
||||
Settings saved
|
||||
{$configToastMessage}
|
||||
</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>
|
||||
import ImageButton from '../ImageButton.svelte';
|
||||
import { installState, swRegistration } from '../../stores.js';
|
||||
import { installState, swRegistration, currentPage } from '../../stores.js';
|
||||
|
||||
export let opfsDisabled;
|
||||
export let openSection;
|
||||
@ -10,6 +10,12 @@
|
||||
export let handleUninstall;
|
||||
|
||||
$: progressAngle = ($installState.progress / 100) * 360;
|
||||
|
||||
function navigateToSaveEditor(e) {
|
||||
e.preventDefault();
|
||||
history.pushState({ page: 'save-editor' }, '', '#save-editor');
|
||||
currentPage.set('save-editor');
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="config-tab-panel active" id="config-tab-extras">
|
||||
@ -76,4 +82,7 @@
|
||||
</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>
|
||||
|
||||
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 = {
|
||||
'#read-me': 'read-me',
|
||||
'#configure': 'configure',
|
||||
'#free-stuff': 'free-stuff'
|
||||
'#free-stuff': 'free-stuff',
|
||||
'#save-editor': 'save-editor'
|
||||
};
|
||||
return pageMap[hash] || 'main';
|
||||
}
|
||||
@ -35,6 +36,7 @@ export const installState = writable({
|
||||
|
||||
// Config toast
|
||||
export const configToastVisible = writable(false);
|
||||
export const configToastMessage = writable('Settings saved');
|
||||
|
||||
// Debug UI visible (set when game reaches intro animation)
|
||||
export const debugUIVisible = writable(false);
|
||||
@ -44,3 +46,11 @@ export const gameRunning = writable(false);
|
||||
|
||||
// Service worker registration
|
||||
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