mirror of
https://github.com/isledecomp/isle.pizza.git
synced 2026-03-01 06:17:38 +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
359 lines
14 KiB
Svelte
359 lines
14 KiB
Svelte
<script>
|
|
import { onMount, onDestroy } from 'svelte';
|
|
import { ActorRenderer } from '../../core/rendering/ActorRenderer.js';
|
|
import { WdbParser, buildGlobalPartsMap, buildPartsMap, resolveLods } from '../../core/formats/WdbParser.js';
|
|
import { ActorInfoInit, ActorPart, ActorDisplayNames, ActorVehicles, VehicleDisplayNames } from '../../core/savegame/actorConstants.js';
|
|
import { Actor } from '../../core/savegame/constants.js';
|
|
import { createSoundPlayer } from '../../core/audio.js';
|
|
import NavButton from '../NavButton.svelte';
|
|
import ResetButton from '../ResetButton.svelte';
|
|
import EditorTooltip from '../EditorTooltip.svelte';
|
|
import './editor-common.css';
|
|
|
|
export let slot;
|
|
export let onUpdate = () => {};
|
|
|
|
let canvas;
|
|
let renderer = null;
|
|
let loading = true;
|
|
let error = null;
|
|
|
|
// Cached WDB data
|
|
let globalPartsMap = null;
|
|
let globalTextures = null;
|
|
let vehiclePartsMap = null;
|
|
let vehicleTextures = null;
|
|
|
|
let actorIndex = 0;
|
|
let loadedActorKey = null;
|
|
let showVehicle = false;
|
|
|
|
const soundPlayer = createSoundPlayer();
|
|
|
|
$: actorInfo = ActorInfoInit[actorIndex];
|
|
$: actorName = ActorDisplayNames[actorIndex] || actorInfo?.name || 'Unknown';
|
|
$: charState = slot?.characters?.[actorIndex];
|
|
$: vehicleInfo = ActorVehicles[actorIndex] || null;
|
|
$: vehicleName = vehicleInfo ? VehicleDisplayNames[vehicleInfo.vehicleModel] : null;
|
|
|
|
$: isDefault = actorInfo && charState &&
|
|
charState.sound === actorInfo.sound &&
|
|
charState.move === actorInfo.move &&
|
|
charState.mood === actorInfo.mood &&
|
|
charState.hatPartNameIndex === actorInfo.parts[1].partNameIndex &&
|
|
charState.hatNameIndex === actorInfo.parts[1].nameIndex &&
|
|
charState.infogronNameIndex === actorInfo.parts[2].nameIndex &&
|
|
charState.armlftNameIndex === actorInfo.parts[4].nameIndex &&
|
|
charState.armrtNameIndex === actorInfo.parts[5].nameIndex &&
|
|
charState.leglftNameIndex === actorInfo.parts[8].nameIndex &&
|
|
charState.legrtNameIndex === actorInfo.parts[9].nameIndex;
|
|
|
|
function actorKey(slotNumber, idx, cs, vehicle) {
|
|
return `${slotNumber}-${idx}-${cs.hatPartNameIndex}-${cs.hatNameIndex}-${cs.infogronNameIndex}-${cs.armlftNameIndex}-${cs.armrtNameIndex}-${cs.leglftNameIndex}-${cs.legrtNameIndex}-${cs.mood}-${vehicle}`;
|
|
}
|
|
|
|
onMount(async () => {
|
|
try {
|
|
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();
|
|
const wdbParser = new WdbParser(buffer);
|
|
const wdbData = wdbParser.parse();
|
|
|
|
if (wdbData.globalParts) {
|
|
globalPartsMap = buildGlobalPartsMap(wdbData.globalParts);
|
|
// Merge global textures (chest/face textures) with global parts textures (hat textures etc.)
|
|
globalTextures = [
|
|
...(wdbData.globalTextures || []),
|
|
...(wdbData.globalParts.textures || [])
|
|
];
|
|
} else {
|
|
throw new Error('No global parts found in WORLD.WDB');
|
|
}
|
|
|
|
// Collect vehicle model names needed
|
|
const neededVehicles = new Set();
|
|
for (const v of Object.values(ActorVehicles)) {
|
|
neededVehicles.add(v.vehicleModel.toLowerCase());
|
|
}
|
|
|
|
// Vehicle geometries are stored as models (not parts) in WDB worlds.
|
|
// Scan worlds for matching model entries, parse model data, and
|
|
// collect all ROIs (root + children) with resolved LODs.
|
|
const vModelsMap = new Map();
|
|
const vTextures = [];
|
|
for (const world of wdbData.worlds) {
|
|
let worldPartsMap = null;
|
|
for (const model of world.models) {
|
|
const modelKey = model.name.toLowerCase();
|
|
if (!neededVehicles.has(modelKey) || vModelsMap.has(modelKey)) continue;
|
|
const modelData = wdbParser.parseModelData(model.dataOffset);
|
|
const roi = modelData.roi;
|
|
if (!roi) continue;
|
|
|
|
// Build world parts map lazily (needed for shared LOD resolution)
|
|
if (!worldPartsMap) {
|
|
worldPartsMap = buildPartsMap(wdbParser, world.parts);
|
|
}
|
|
|
|
// Collect all renderable ROIs (root + children recursively)
|
|
const rois = [];
|
|
const collectRois = (node) => {
|
|
const lods = resolveLods(node, worldPartsMap);
|
|
if (lods.length > 0) {
|
|
rois.push({ name: node.name, lods });
|
|
}
|
|
for (const child of node.children || []) {
|
|
collectRois(child);
|
|
}
|
|
};
|
|
collectRois(roi);
|
|
|
|
if (rois.length > 0) {
|
|
vModelsMap.set(modelKey, rois);
|
|
}
|
|
if (modelData.textures) {
|
|
vTextures.push(...modelData.textures);
|
|
}
|
|
}
|
|
}
|
|
vehiclePartsMap = vModelsMap;
|
|
vehicleTextures = vTextures;
|
|
|
|
renderer = new ActorRenderer(canvas);
|
|
loadCurrentActor();
|
|
renderer.start();
|
|
loading = false;
|
|
} catch (e) {
|
|
console.error('ActorEditor initialization error:', e);
|
|
error = e.message;
|
|
loading = false;
|
|
}
|
|
});
|
|
|
|
onDestroy(() => {
|
|
renderer?.dispose();
|
|
soundPlayer.dispose();
|
|
});
|
|
|
|
// Reload actor when index, character state, or vehicle toggle changes
|
|
$: if (renderer && !loading && actorInfo && charState) {
|
|
if (actorKey(slot?.slotNumber, actorIndex, charState, showVehicle) !== loadedActorKey) {
|
|
loadCurrentActor();
|
|
}
|
|
}
|
|
|
|
function loadCurrentActor() {
|
|
if (!renderer || !globalPartsMap || !slot?.characters) return;
|
|
|
|
const activeVehicle = showVehicle ? vehicleInfo : null;
|
|
renderer.loadActor(
|
|
actorIndex, slot.characters, globalPartsMap, globalTextures,
|
|
activeVehicle ? vehiclePartsMap : null,
|
|
activeVehicle ? vehicleTextures : null,
|
|
activeVehicle
|
|
);
|
|
loadedActorKey = actorKey(slot?.slotNumber, actorIndex, slot.characters[actorIndex], showVehicle);
|
|
}
|
|
|
|
function prevActor() {
|
|
actorIndex = actorIndex > 0 ? actorIndex - 1 : ActorInfoInit.length - 1;
|
|
showVehicle = false;
|
|
loadedActorKey = null;
|
|
}
|
|
|
|
function nextActor() {
|
|
actorIndex = actorIndex < ActorInfoInit.length - 1 ? actorIndex + 1 : 0;
|
|
showVehicle = false;
|
|
loadedActorKey = null;
|
|
}
|
|
|
|
function handleCanvasClick(event) {
|
|
if (!renderer || !slot?.characters || !charState) return;
|
|
if (renderer.wasDragged()) return;
|
|
|
|
const playerId = slot.header?.actorId;
|
|
let acted = false;
|
|
let clickMove = charState.move;
|
|
|
|
switch (playerId) {
|
|
case Actor.PEPPER: switchVariant(); acted = true; break;
|
|
case Actor.MAMA: switchSound(); acted = true; break;
|
|
case Actor.PAPA: clickMove = switchMove(); acted = true; break;
|
|
case Actor.NICK: acted = switchColor(event); break;
|
|
case Actor.LAURA: switchMood(); acted = true; break;
|
|
}
|
|
|
|
if (!acted) return;
|
|
|
|
// Play click sound (Mama plays the *new* sound after cycling)
|
|
const soundIdx = playerId === Actor.MAMA
|
|
? (charState.sound + 1) % 9
|
|
: charState.sound;
|
|
soundPlayer.play(`ClickSound${soundIdx}`);
|
|
|
|
// Laura additionally plays a mood sound
|
|
if (playerId === Actor.LAURA) {
|
|
soundPlayer.play(`MoodSound${(charState.mood + 1) % 4}`);
|
|
}
|
|
|
|
// Queue click animation — consumed by loadAnimationForActor
|
|
renderer.queueClickAnimation(clickMove);
|
|
|
|
// Sound/move changes don't affect the actorKey, so the reactive block
|
|
// won't trigger a model reload. Play the click animation directly.
|
|
// For visual changes (hat/color/mood), the reactive block will call
|
|
// loadCurrentActor → loadAnimationForActor, which consumes the queue.
|
|
if (playerId === Actor.MAMA || playerId === Actor.PAPA) {
|
|
renderer.loadAnimationForActor(actorIndex, charState.mood);
|
|
}
|
|
}
|
|
|
|
function switchVariant() {
|
|
const part = actorInfo.parts[ActorPart.INFOHAT];
|
|
if (!part.partNameIndices) return;
|
|
|
|
const maxIdx = part.partNameIndices.length;
|
|
const nextIdx = (charState.hatPartNameIndex + 1) % maxIdx;
|
|
|
|
onUpdate({
|
|
character: { characterIndex: actorIndex, field: 'hatPartNameIndex', value: nextIdx }
|
|
});
|
|
}
|
|
|
|
function switchSound() {
|
|
const nextSound = (charState.sound + 1) % 9;
|
|
onUpdate({
|
|
character: { characterIndex: actorIndex, field: 'sound', value: nextSound }
|
|
});
|
|
}
|
|
|
|
function switchMove() {
|
|
const nextMove = (charState.move + 1) % 4;
|
|
onUpdate({
|
|
character: { characterIndex: actorIndex, field: 'move', value: nextMove }
|
|
});
|
|
return nextMove;
|
|
}
|
|
|
|
function switchColor(event) {
|
|
let partIdx = renderer.getClickedPart(event);
|
|
if (partIdx < 0) return false;
|
|
|
|
// Remap clicked part to the part that owns its color
|
|
// (matches SwitchColor in legocharactermanager.cpp)
|
|
if (partIdx === ActorPart.CLAWLFT) partIdx = ActorPart.ARMLFT;
|
|
else if (partIdx === ActorPart.CLAWRT) partIdx = ActorPart.ARMRT;
|
|
else if (partIdx === ActorPart.HEAD) partIdx = ActorPart.INFOHAT;
|
|
else if (partIdx === ActorPart.BODY) partIdx = ActorPart.INFOGRON;
|
|
|
|
// Map part index to the save field
|
|
const fieldMap = {
|
|
[ActorPart.INFOHAT]: 'hatNameIndex',
|
|
[ActorPart.INFOGRON]: 'infogronNameIndex',
|
|
[ActorPart.ARMLFT]: 'armlftNameIndex',
|
|
[ActorPart.ARMRT]: 'armrtNameIndex',
|
|
[ActorPart.LEGLFT]: 'leglftNameIndex',
|
|
[ActorPart.LEGRT]: 'legrtNameIndex'
|
|
};
|
|
|
|
const field = fieldMap[partIdx];
|
|
if (!field) return false;
|
|
|
|
const part = actorInfo.parts[partIdx];
|
|
if (!part.nameIndices) return false;
|
|
|
|
const currentIdx = charState[field] ?? part.nameIndex;
|
|
const maxIdx = part.nameIndices.length;
|
|
const nextIdx = (currentIdx + 1) % maxIdx;
|
|
|
|
onUpdate({
|
|
character: { characterIndex: actorIndex, field, value: nextIdx }
|
|
});
|
|
return true;
|
|
}
|
|
|
|
function switchMood() {
|
|
const nextMood = (charState.mood + 1) % 4;
|
|
onUpdate({
|
|
character: { characterIndex: actorIndex, field: 'mood', value: nextMood }
|
|
});
|
|
}
|
|
|
|
function resetActor() {
|
|
const i = actorIndex;
|
|
const p = actorInfo.parts;
|
|
onUpdate({
|
|
character: [
|
|
{ characterIndex: i, field: 'sound', value: actorInfo.sound },
|
|
{ characterIndex: i, field: 'move', value: actorInfo.move },
|
|
{ characterIndex: i, field: 'mood', value: actorInfo.mood },
|
|
{ characterIndex: i, field: 'hatPartNameIndex', value: p[1].partNameIndex },
|
|
{ characterIndex: i, field: 'hatNameIndex', value: p[1].nameIndex },
|
|
{ characterIndex: i, field: 'infogronNameIndex', value: p[2].nameIndex },
|
|
{ characterIndex: i, field: 'armlftNameIndex', value: p[4].nameIndex },
|
|
{ characterIndex: i, field: 'armrtNameIndex', value: p[5].nameIndex },
|
|
{ characterIndex: i, field: 'leglftNameIndex', value: p[8].nameIndex },
|
|
{ characterIndex: i, field: 'legrtNameIndex', value: p[9].nameIndex }
|
|
]
|
|
});
|
|
}
|
|
</script>
|
|
|
|
<EditorTooltip text="Click to customize based on your current character. Navigate between all 66 game actors using the arrows. Changes are automatically saved." onResetCamera={() => renderer?.resetView()}>
|
|
<div class="preview-container">
|
|
<canvas
|
|
bind:this={canvas}
|
|
width="190"
|
|
height="190"
|
|
class:hidden={loading || error}
|
|
onclick={handleCanvasClick}
|
|
role="button"
|
|
tabindex="0"
|
|
aria-label="Customize actor"
|
|
></canvas>
|
|
|
|
{#if loading}
|
|
<div class="preview-overlay">
|
|
<div class="spinner"></div>
|
|
</div>
|
|
{:else if error}
|
|
<div class="preview-overlay error">{error}</div>
|
|
{/if}
|
|
</div>
|
|
|
|
<div class="part-nav-wrapper">
|
|
<div class="part-nav">
|
|
<NavButton direction="left" onclick={prevActor} />
|
|
<div class="part-info">
|
|
<span class="nav-index">{actorIndex + 1} / {ActorInfoInit.length}</span>
|
|
<span class="nav-name">{actorName}</span>
|
|
</div>
|
|
<NavButton direction="right" onclick={nextActor} />
|
|
</div>
|
|
{#if vehicleInfo}
|
|
<button
|
|
type="button"
|
|
class="side-btn"
|
|
class:active={showVehicle}
|
|
onclick={() => { showVehicle = !showVehicle; }}
|
|
title={showVehicle ? 'Show without vehicle' : `Show with ${vehicleName}`}
|
|
>
|
|
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
|
|
<path d="M4 12.5a1.5 1.5 0 1 1 0-3 1.5 1.5 0 0 1 0 3zm8 0a1.5 1.5 0 1 1 0-3 1.5 1.5 0 0 1 0 3zM5.5 11h5V9.5L13 8l-1-3H4L2.5 8l2.5 1.5V11h.5z"/>
|
|
</svg>
|
|
</button>
|
|
{/if}
|
|
</div>
|
|
|
|
<div class="reset-container">
|
|
{#if !isDefault && !loading && !error}
|
|
<ResetButton onclick={resetActor} />
|
|
{/if}
|
|
</div>
|
|
</EditorTooltip>
|
|
|