isle.pizza/src/lib/save-editor/ActorEditor.svelte
Christian Semmler fe87b3d99f
Add click animations to actor editor
Play a one-shot gesture animation when clicking an actor, matching the
in-game LegoEntity::ClickAnimation behavior (objectId = m_move + 10).
After the click animation finishes, the walking loop resumes. Adds the
4 click animations from SNDANIM.SI to the asset manifest and extends
ActorRenderer with queue-based click animation playback. Also fixes
treadmill XZ stripping for click animations where actor_01 is nested
under wrapper nodes.
2026-02-13 14:28:35 -08:00

355 lines
12 KiB
Svelte

<script>
import { onMount, onDestroy } from 'svelte';
import { ActorRenderer } from '../../core/rendering/ActorRenderer.js';
import { WdbParser, buildGlobalPartsMap } from '../../core/formats/WdbParser.js';
import { ActorInfoInit, ActorPart, ActorDisplayNames } from '../../core/savegame/actorConstants.js';
import { Actor } from '../../core/savegame/constants.js';
import NavButton from '../NavButton.svelte';
import ResetButton from '../ResetButton.svelte';
import EditorTooltip from '../EditorTooltip.svelte';
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 actorIndex = 0;
let loadedActorKey = null;
$: actorInfo = ActorInfoInit[actorIndex];
$: actorName = ActorDisplayNames[actorIndex] || actorInfo?.name || 'Unknown';
$: charState = slot?.characters?.[actorIndex];
$: 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) {
return `${slotNumber}-${idx}-${cs.hatPartNameIndex}-${cs.hatNameIndex}-${cs.infogronNameIndex}-${cs.armlftNameIndex}-${cs.armrtNameIndex}-${cs.leglftNameIndex}-${cs.legrtNameIndex}-${cs.mood}`;
}
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');
}
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();
});
// Reload actor when index or character state changes
$: if (renderer && !loading && actorInfo && charState) {
if (actorKey(slot?.slotNumber, actorIndex, charState) !== loadedActorKey) {
loadCurrentActor();
}
}
function loadCurrentActor() {
if (!renderer || !globalPartsMap || !slot?.characters) return;
renderer.loadActor(actorIndex, slot.characters, globalPartsMap, globalTextures);
loadedActorKey = actorKey(slot?.slotNumber, actorIndex, slot.characters[actorIndex]);
}
function prevActor() {
actorIndex = actorIndex > 0 ? actorIndex - 1 : ActorInfoInit.length - 1;
loadedActorKey = null;
}
function nextActor() {
actorIndex = actorIndex < ActorInfoInit.length - 1 ? actorIndex + 1 : 0;
loadedActorKey = null;
}
function handleCanvasClick(event) {
if (!renderer || !slot?.characters || !charState) 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;
// 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.">
<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="actor-index">{actorIndex + 1} / {ActorInfoInit.length}</span>
<span class="actor-name">{actorName}</span>
</div>
<NavButton direction="right" onclick={nextActor} />
</div>
</div>
<div class="reset-container">
{#if !isDefault && !loading && !error}
<ResetButton onclick={resetActor} />
{/if}
</div>
</EditorTooltip>
<style>
.preview-container {
position: relative;
}
canvas {
display: block;
border-radius: 8px;
cursor: pointer;
max-width: 100%;
}
canvas:focus {
outline: none;
}
canvas.hidden {
visibility: hidden;
}
.preview-overlay {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
background: var(--color-bg-input);
border-radius: 8px;
}
.preview-overlay.error {
color: var(--color-error, #e74c3c);
font-size: 0.75em;
padding: 12px;
text-align: center;
}
.spinner {
width: 32px;
height: 32px;
border-radius: 50%;
background:
radial-gradient(transparent 55%, transparent 56%),
conic-gradient(var(--color-primary, #FFD700) 0deg 90deg, var(--color-border-dark, #333) 90deg 360deg);
animation: spin 1s linear infinite;
}
@keyframes spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
.part-nav-wrapper {
margin-top: 10px;
}
.part-nav {
display: flex;
align-items: center;
gap: 12px;
}
.part-info {
text-align: center;
min-width: 150px;
}
.actor-index {
display: block;
font-size: 0.7em;
color: var(--color-text-muted);
text-transform: uppercase;
letter-spacing: 0.5px;
}
.actor-name {
display: block;
font-size: 0.9em;
color: var(--color-text-light);
}
.reset-container {
height: 1.6em;
}
</style>