isle.pizza/src/lib/save-editor/VehicleEditor.svelte
Christian Semmler 1d18779689
Plant editor (#25)
* Plant editor

Add Plants tab to the save editor for browsing and editing all 81 plants.
Click-to-customize based on selected character matches the original game
behavior (Pepper→variant, Mama→sound, Papa→move, Nick→color, Laura→mood).
Includes 3D preview with per-variant display tuning, click animations,
sound playback, and reset to defaults.

* Refactor shared animation code into AnimatedRenderer base class

Extract duplicated animation infrastructure (clock, mixer, animation
caching, raycaster, keyframe interpolation) from ActorRenderer and
PlantRenderer into a new AnimatedRenderer intermediate class. Extract
identical sound player code from both editors into createSoundPlayer()
utility. Fix PlantRenderer interpolateVertex bug where scale keys had
X incorrectly negated. Remove dead PLANT_ANIM_IDS export and redundant
textures.clear() calls.

* Extract shared editor CSS and fix vehicle nav spacing

Move duplicated preview, spinner, navigation, and side-button styles
from VehicleEditor, ActorEditor, and PlantEditor into a shared
editor-common.css. Standardize class names (nav-index, nav-name,
side-btn) and fix VehicleEditor part-info min-width (100px → 150px)
to match the other editors.

* Add carousel tabs and selection-based nav to save editor

Wrap save editor tab buttons in a Carousel to prevent overflow on
desktop. Carousel nav buttons now cycle through the selected item
(save slot or tab) instead of scrolling, with auto-scroll-into-view.
On mobile, tabs reflow with flex-wrap as before.

* Update February changelog with plant editor and carousel navigation
2026-02-14 18:22:28 +01:00

395 lines
14 KiB
Svelte

<script>
import { onMount, onDestroy } from 'svelte';
import { VehiclePartRenderer } from '../../core/rendering/VehiclePartRenderer.js';
import { WdbParser, findRoi, buildPartsMap } from '../../core/formats/WdbParser.js';
import {
LegoColorNames,
VehicleWorlds,
VehicleModels,
VehicleNames,
VehiclePartColors,
TexturedParts,
Act1PlaneIndices
} from '../../core/savegame/constants.js';
import { squareTexture } from '../../core/savegame/imageQuantizer.js';
import { parseTex } from '../../core/formats/TexParser.js';
import { fetchTexture } from '../../core/assetLoader.js';
import NavButton from '../NavButton.svelte';
import ResetButton from '../ResetButton.svelte';
import EditorTooltip from '../EditorTooltip.svelte';
import TexturePickerModal from './TexturePickerModal.svelte';
import './editor-common.css';
export let slot;
export let onUpdate = () => {};
// Build flat list of all parts across all vehicles
const vehicleList = ['dunebuggy', 'helicopter', 'jetski', 'racecar'];
const allParts = vehicleList.flatMap(v =>
(VehiclePartColors[v] || []).map(p => ({ ...p, vehicle: v }))
);
let canvas;
let renderer = null;
let loading = true;
let error = null;
let partError = null;
// Cached WDB data
let wdbParser = null;
let wdbData = null;
let globalIndex = 0;
// Track current loaded part to avoid redundant reloads
let loadedPartKey = null;
// Texture modal state
let showTextureModal = false;
let texturePalette = null;
let wdbTexture = null;
let preloadedDefaults = null;
// Current part info from flat list
$: currentEntry = allParts[globalIndex];
$: vehicle = currentEntry?.vehicle || 'dunebuggy';
$: currentPart = currentEntry;
// Get current color from slot variables
$: currentColorValue = currentPart
? slot?.variables?.get(currentPart.variable)?.value || currentPart.defaultColor
: 'lego red';
// Check if current color differs from default
$: isDefaultColor = currentPart && currentColorValue === currentPart.defaultColor;
// Texture info for current part (if it's a textured part)
$: textureInfo = currentPart ? TexturedParts[currentPart.part] || null : null;
// Check if vehicle has a plane in Act1State (vehicle is placed in world)
$: vehicleHasPlane = (() => {
if (!textureInfo || !slot?.act1State) return false;
const planeIdx = Act1PlaneIndices[textureInfo.vehicle];
return planeIdx !== undefined && slot.act1State.planes[planeIdx]?.nameLength > 0;
})();
// Can edit texture: part has texture info AND vehicle plane exists in Act1State
$: canEditTexture = textureInfo && vehicleHasPlane;
onMount(async () => {
try {
// Load and parse WDB once
const response = await fetch('/LEGO/data/WORLD.WDB');
if (!response.ok) {
throw new Error(`Failed to load WORLD.WDB: ${response.status}`);
}
const buffer = await response.arrayBuffer();
wdbParser = new WdbParser(buffer);
wdbData = wdbParser.parse();
// Initialize renderer
renderer = new VehiclePartRenderer(canvas);
// Load initial part
await loadCurrentPart();
renderer.start();
loading = false;
} catch (e) {
console.error('VehiclePartColorEditor initialization error:', e);
error = e.message;
loading = false;
}
});
onDestroy(() => {
renderer?.dispose();
});
// Reload part when index or slot changes
$: if (renderer && !loading && currentPart) {
const partKey = `${slot?.slotNumber}-${vehicle}-${globalIndex}`;
if (partKey !== loadedPartKey) {
loadCurrentPart();
}
}
// Check if current texture matches the WDB default.
// Declared after the loadCurrentPart block: Svelte 5 runs legacy $: effects
// in source order, and wdbTexture must be set before this evaluates.
function isTextureDefault(info, wdbTex, act1Textures) {
if (!info || !wdbTex || !act1Textures) return true;
const act1Tex = act1Textures.get(info.textureName.toLowerCase());
if (!act1Tex) return true;
if (act1Tex.pixels.length !== wdbTex.pixels.length) return false;
for (let i = 0; i < act1Tex.pixels.length; i++) {
if (act1Tex.pixels[i] !== wdbTex.pixels[i]) return false;
}
return true;
}
$: isDefaultTexture = isTextureDefault(textureInfo, wdbTexture, slot?.act1State?.textures);
// Update color when variable changes (without reloading geometry)
$: if (renderer && !loading && currentColorValue && loadedPartKey) {
renderer.updateColor(currentColorValue);
}
async function loadCurrentPart() {
if (!wdbData || !wdbParser || !currentPart || !renderer) return;
partError = null;
const partKey = `${slot?.slotNumber}-${vehicle}-${globalIndex}`;
try {
const worldName = VehicleWorlds[vehicle];
const modelName = VehicleModels[vehicle];
// Find the vehicle world
const world = wdbData.worlds.find(w => w.name === worldName);
if (!world) {
partError = `World ${worldName} not found`;
return;
}
// Find the vehicle model
const model = world.models.find(m =>
m.name.toLowerCase() === modelName.toLowerCase()
);
if (!model) {
partError = `Model ${modelName} not found`;
return;
}
// Parse model data
const modelData = wdbParser.parseModelData(model.dataOffset);
// Find the part ROI
const partRoi = findRoi(modelData.roi, currentPart.part);
if (!partRoi) {
partError = `Part not found`;
return;
}
// Build parts map for shared LOD resolution
const partsMap = buildPartsMap(wdbParser, world.parts);
// Build texture list, merging Act1State texture if available
let textures = modelData.textures || [];
if (textureInfo && slot?.act1State?.textures) {
const texKey = textureInfo.textureName.toLowerCase();
const act1Tex = slot.act1State.textures.get(texKey);
if (act1Tex) {
const existingIdx = textures.findIndex(t => t.name?.toLowerCase() === texKey);
if (existingIdx >= 0) {
textures = [...textures];
textures[existingIdx] = { ...act1Tex, name: texKey };
} else {
textures = [...textures, { ...act1Tex, name: texKey }];
}
}
}
// Extract palette from the WDB texture (the ground truth) for the
// texture picker modal. The game's LoadBits() only overwrites pixel
// data on the DirectDraw surface — the palette always stays from the
// original WDB load. So custom pixel indices must reference THIS palette.
if (textureInfo) {
const texKey = textureInfo.textureName.toLowerCase();
const wdbTex = (modelData.textures || []).find(t => t.name === texKey);
if (wdbTex) {
texturePalette = wdbTex.palette;
wdbTexture = squareTexture(wdbTex);
} else {
wdbTexture = null;
}
} else {
wdbTexture = null;
}
// Load part with current color, textures, and parts map for shared LOD lookup
renderer.loadPartWithColor(partRoi, currentColorValue, textures, partsMap)
loadedPartKey = partKey;
// Preload default .tex files in background for the texture picker
if (textureInfo) {
preloadDefaultTextures(textureInfo);
} else {
preloadedDefaults = null;
}
} catch (e) {
console.error('Failed to load part:', e);
partError = e.message;
}
}
async function preloadDefaultTextures(info) {
const results = await Promise.all(info.texFiles.map(async (texFile) => {
const buffer = await fetchTexture(texFile);
if (!buffer) return null;
const parsed = parseTex(buffer);
if (parsed.textures.length > 0) {
return { name: texFile, ...parsed.textures[0] };
}
return null;
}));
// Only apply if textureInfo hasn't changed since we started
if (textureInfo === info) {
preloadedDefaults = results.filter(Boolean);
}
}
function prevPart() {
globalIndex = globalIndex > 0 ? globalIndex - 1 : allParts.length - 1;
loadedPartKey = null;
}
function nextPart() {
globalIndex = globalIndex < allParts.length - 1 ? globalIndex + 1 : 0;
loadedPartKey = null;
}
function cycleColor() {
if (!currentPart || partError) return;
if (renderer?.wasDragged()) return;
// Find current color index and cycle to next
const currentIdx = LegoColorNames.indexOf(currentColorValue);
const nextIdx = (currentIdx + 1) % LegoColorNames.length;
const nextColor = LegoColorNames[nextIdx];
onUpdate({
variable: {
name: currentPart.variable,
value: nextColor
}
});
}
function resetColor() {
if (!currentPart) return;
const update = {
variable: {
name: currentPart.variable,
value: currentPart.defaultColor
}
};
// Reset texture to WDB default (equivalent to WriteDefaultTexture in the game).
// wdbTexture is already squared when cached in loadCurrentPart().
if (canEditTexture && wdbTexture && renderer) {
const texKey = textureInfo.textureName.toLowerCase();
renderer.updateTexture(texKey, wdbTexture);
update.texture = {
textureName: textureInfo.textureName,
textureData: wdbTexture
};
}
onUpdate(update);
}
function openTexturePicker() {
if (!canEditTexture) return;
showTextureModal = true;
}
function handleTextureSelect(textureData) {
if (!textureInfo || !renderer) return;
const texKey = textureInfo.textureName.toLowerCase();
// Square the texture for game compatibility — the game's DirectDraw
// surfaces are always square, and LoadBits() expects matching dimensions.
// No-op if already square.
const saveData = squareTexture(textureData);
// Update preview immediately
renderer.updateTexture(texKey, saveData);
// Save to file
onUpdate({
texture: {
textureName: textureInfo.textureName,
textureData: saveData
}
});
showTextureModal = false;
}
</script>
<EditorTooltip text="Click on the part to cycle through colors. Use the texture button to customize textures on supported parts (vehicle must be fully built first). Changes are automatically saved." onResetCamera={() => renderer?.resetView()}>
<!-- 3D Preview (clickable to cycle color) -->
<div class="preview-container">
<canvas
bind:this={canvas}
width="190"
height="190"
class:hidden={loading || error}
onclick={cycleColor}
role="button"
tabindex="0"
aria-label="Click to change color"
></canvas>
{#if loading}
<div class="preview-overlay">
<div class="spinner"></div>
</div>
{:else if error}
<div class="preview-overlay error">{error}</div>
{:else if partError}
<div class="preview-overlay error">{partError}</div>
{/if}
</div>
<!-- Part navigation below canvas -->
<div class="part-nav-wrapper">
<div class="part-nav">
<NavButton direction="left" onclick={prevPart} />
<div class="part-info">
<span class="nav-index">{VehicleNames[vehicle]}</span>
<span class="nav-name">{currentPart?.label || 'Unknown'}</span>
</div>
<NavButton direction="right" onclick={nextPart} />
</div>
{#if textureInfo}
<button
type="button"
class="side-btn"
class:disabled={!canEditTexture}
onclick={openTexturePicker}
disabled={!canEditTexture}
title={canEditTexture ? 'Edit texture' : 'Vehicle not placed in world'}
>
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
<path d="M14.5 2.5a1.5 1.5 0 0 0-3 0v1h-2v-1a1.5 1.5 0 0 0-3 0v1h-2v-1a1.5 1.5 0 0 0-3 0v2h1v2h-1v2h1v2h-1v2a1.5 1.5 0 0 0 3 0v-1h2v1a1.5 1.5 0 0 0 3 0v-1h2v1a1.5 1.5 0 0 0 3 0v-2h-1v-2h1v-2h-1v-2h1v-2zm-3 2h-2v2h2v-2zm-5 0h-2v2h2v-2zm0 4h-2v2h2v-2zm5 0h-2v2h2v-2z"/>
</svg>
</button>
{/if}
</div>
<div class="reset-container">
{#if (!isDefaultColor || !isDefaultTexture) && !loading && !error && !partError}
<ResetButton onclick={resetColor} />
{/if}
</div>
</EditorTooltip>
{#if showTextureModal && textureInfo}
<TexturePickerModal
{textureInfo}
palette={texturePalette}
defaults={preloadedDefaults}
onSelect={handleTextureSelect}
onClose={() => showTextureModal = false}
/>
{/if}