mirror of
https://github.com/isledecomp/isle.pizza.git
synced 2026-02-28 22:07:39 +00:00
* 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
395 lines
14 KiB
Svelte
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}
|
|
|