isle.pizza/src/lib/SaveEditorPage.svelte
Christian Semmler cf8ccf5b85 Add vehicle texture editing
Parse and serialize Act1State textures in save files. Add a texture
picker modal with default presets (from .tex files) and custom uploads
persisted to IndexedDB per texture name. Quantize uploaded images
against the WDB palette and render texture changes in the 3D preview.
Support resetting textures to the WDB default.
2026-02-07 13:31:54 -08:00

589 lines
20 KiB
Svelte

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