mirror of
https://github.com/isledecomp/isle.pizza.git
synced 2026-02-28 13:57:38 +00:00
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
This commit is contained in:
parent
9872902e4d
commit
1d18779689
@ -128,6 +128,33 @@ const CLICK_SOUNDS = [
|
|||||||
['ClickSound8', 58, 7344, '7bbc41251b750835989cb3b35c8546a4'],
|
['ClickSound8', 58, 7344, '7bbc41251b750835989cb3b35c8546a4'],
|
||||||
];
|
];
|
||||||
|
|
||||||
|
// Plant animations from SNDANIM.SI (objectId = g_plantAnimationId[variant] + move)
|
||||||
|
// [name, objectId, size, md5]
|
||||||
|
const PLANT_ANIMATIONS = [
|
||||||
|
['PlantAnimF0', 30, 911, 'cbc2f4d870099238a79130268e48f981'],
|
||||||
|
['PlantAnimF1', 31, 539, '1df6d082935ffa780f5867d0018870a1'],
|
||||||
|
['PlantAnimF2', 32, 451, 'f541f69207d849179704c55956bbf883'],
|
||||||
|
['PlantAnimT0', 33, 1719, 'ac41608766049001502a70f655cdf731'],
|
||||||
|
['PlantAnimT1', 34, 1022, '778c0c7fb646d85a2e056f430f21562f'],
|
||||||
|
['PlantAnimT2', 35, 794, '89f16250457fdd3a732fdd6030d92e2c'],
|
||||||
|
['PlantAnimB0', 36, 1066, 'c00ca3e2566846d94ce75ff7700f5a5b'],
|
||||||
|
['PlantAnimB1', 37, 850, '97d86074a3aa606e1fe3f3bd01690ae7'],
|
||||||
|
['PlantAnimB2', 38, 502, '7f08bf6093478c653ff82d058d86f900'],
|
||||||
|
['PlantAnimP0', 39, 978, '4f9af3721ba3a49e478da5566a4923de'],
|
||||||
|
['PlantAnimP1', 40, 682, '41d0ca14af41cc4cd7f737d7b0e74ef2'],
|
||||||
|
['PlantAnimP2', 41, 294, '5ddaff70e2b57fdb294769eaa14e42a0'],
|
||||||
|
];
|
||||||
|
|
||||||
|
// Plant sounds from SNDANIM.SI (objectId = sound + 56, sounds 3-7)
|
||||||
|
// [name, objectId, size, md5]
|
||||||
|
const PLANT_SOUNDS = [
|
||||||
|
['PlantSound3', 59, 12184, '31a837c2420056e0a4f431d06801e746'],
|
||||||
|
['PlantSound4', 60, 10409, 'd8e8eb75668c57fcb45ba7a75e4612e5'],
|
||||||
|
['PlantSound5', 61, 12107, 'd60acd5c0962e15cc7c25de95553357f'],
|
||||||
|
['PlantSound6', 62, 15900, 'acfba6e91b047a43b673b0e2087bd3f5'],
|
||||||
|
['PlantSound7', 63, 11545, '53cfd93d7e81c85d5c39b4af624bc370'],
|
||||||
|
];
|
||||||
|
|
||||||
// Mood sounds from SNDANIM.SI (objectId = m_mood + 66)
|
// Mood sounds from SNDANIM.SI (objectId = m_mood + 66)
|
||||||
// [name, objectId, size, md5]
|
// [name, objectId, size, md5]
|
||||||
const MOOD_SOUNDS = [
|
const MOOD_SOUNDS = [
|
||||||
@ -324,7 +351,7 @@ async function main() {
|
|||||||
console.log(` ${clickFound}/${CLICK_ANIMATIONS.length} click animations found\n`);
|
console.log(` ${clickFound}/${CLICK_ANIMATIONS.length} click animations found\n`);
|
||||||
|
|
||||||
// --- Sounds (in SNDANIM.SI) ---
|
// --- Sounds (in SNDANIM.SI) ---
|
||||||
const allSounds = [...CLICK_SOUNDS, ...MOOD_SOUNDS];
|
const allSounds = [...CLICK_SOUNDS, ...MOOD_SOUNDS, ...PLANT_SOUNDS];
|
||||||
const soundObjectIds = new Set(allSounds.map(([, objectId]) => objectId));
|
const soundObjectIds = new Set(allSounds.map(([, objectId]) => objectId));
|
||||||
const soundRanges = findMxChByObjectId(sndanimSI, soundObjectIds);
|
const soundRanges = findMxChByObjectId(sndanimSI, soundObjectIds);
|
||||||
|
|
||||||
@ -342,6 +369,24 @@ async function main() {
|
|||||||
}
|
}
|
||||||
console.log(` ${soundFound}/${allSounds.length} sounds found\n`);
|
console.log(` ${soundFound}/${allSounds.length} sounds found\n`);
|
||||||
|
|
||||||
|
// --- Plant Animations (in SNDANIM.SI) ---
|
||||||
|
const plantAnimObjectIds = new Set(PLANT_ANIMATIONS.map(([, objectId]) => objectId));
|
||||||
|
const plantAnimRanges = findMxChByObjectId(sndanimSI, plantAnimObjectIds);
|
||||||
|
|
||||||
|
let plantAnimFound = 0;
|
||||||
|
for (const [name, objectId, size, expectedMd5] of PLANT_ANIMATIONS) {
|
||||||
|
const data = extractAndVerify(sndanimSI, plantAnimRanges.get(objectId), size, expectedMd5);
|
||||||
|
if (data) {
|
||||||
|
fragments.push({ type: 'animations', name, data });
|
||||||
|
plantAnimFound++;
|
||||||
|
found++;
|
||||||
|
} else {
|
||||||
|
console.error(` FAILED: ${name} (objectId ${objectId})`);
|
||||||
|
failed++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
console.log(` ${plantAnimFound}/${PLANT_ANIMATIONS.length} plant animations found\n`);
|
||||||
|
|
||||||
// --- Textures (across Build SI files) ---
|
// --- Textures (across Build SI files) ---
|
||||||
const texBySI = new Map();
|
const texBySI = new Map();
|
||||||
for (const entry of TEXTURES) {
|
for (const entry of TEXTURES) {
|
||||||
@ -420,7 +465,7 @@ async function main() {
|
|||||||
await fs.writeFile(BIN_PATH, bundle);
|
await fs.writeFile(BIN_PATH, bundle);
|
||||||
console.log(`Wrote ${BIN_PATH} (${(bundle.length / 1024).toFixed(1)} KB, ${Object.keys(index).length} entries)`);
|
console.log(`Wrote ${BIN_PATH} (${(bundle.length / 1024).toFixed(1)} KB, ${Object.keys(index).length} entries)`);
|
||||||
|
|
||||||
console.log(`Total: ${found} assets (${ANIMATIONS.length} walking + ${CLICK_ANIMATIONS.length} click animations, ${allSounds.length} sounds, ${TEXTURES.length} textures, ${BITMAPS.length} bitmaps)`);
|
console.log(`Total: ${found} assets (${ANIMATIONS.length} walking + ${CLICK_ANIMATIONS.length} click + ${PLANT_ANIMATIONS.length} plant animations, ${allSounds.length} sounds, ${TEXTURES.length} textures, ${BITMAPS.length} bitmaps)`);
|
||||||
}
|
}
|
||||||
|
|
||||||
main().catch(err => {
|
main().catch(err => {
|
||||||
|
|||||||
@ -505,6 +505,8 @@ body {
|
|||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: all 0.2s ease;
|
transition: all 0.2s ease;
|
||||||
|
white-space: nowrap;
|
||||||
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.config-tab-btn:hover {
|
.config-tab-btn:hover {
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
// Audio utilities for install-audio element
|
// Audio utilities
|
||||||
import { soundEnabled } from '../stores.js';
|
import { soundEnabled } from '../stores.js';
|
||||||
|
import { fetchSoundAsWav } from './assetLoader.js';
|
||||||
|
|
||||||
export function getInstallAudio() {
|
export function getInstallAudio() {
|
||||||
return document.getElementById('install-audio');
|
return document.getElementById('install-audio');
|
||||||
@ -38,3 +39,53 @@ export function toggleInstallAudio() {
|
|||||||
pauseInstallAudio();
|
pauseInstallAudio();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a reusable sound player for game asset sounds.
|
||||||
|
* Uses Web Audio API with caching and configurable volume.
|
||||||
|
* @param {number} volume - Gain value (0-1), default 0.3
|
||||||
|
* @returns {{ play: (name: string) => Promise<void>, dispose: () => void }}
|
||||||
|
*/
|
||||||
|
export function createSoundPlayer(volume = 0.3) {
|
||||||
|
let audioContext = null;
|
||||||
|
let gainNode = null;
|
||||||
|
const cache = new Map();
|
||||||
|
|
||||||
|
async function play(name) {
|
||||||
|
try {
|
||||||
|
if (!audioContext) {
|
||||||
|
audioContext = new AudioContext();
|
||||||
|
gainNode = audioContext.createGain();
|
||||||
|
gainNode.gain.value = volume;
|
||||||
|
gainNode.connect(audioContext.destination);
|
||||||
|
}
|
||||||
|
if (audioContext.state === 'suspended') {
|
||||||
|
await audioContext.resume();
|
||||||
|
}
|
||||||
|
|
||||||
|
let audioBuffer = cache.get(name);
|
||||||
|
if (!audioBuffer) {
|
||||||
|
const wav = await fetchSoundAsWav(name);
|
||||||
|
if (!wav) return;
|
||||||
|
audioBuffer = await audioContext.decodeAudioData(wav);
|
||||||
|
cache.set(name, audioBuffer);
|
||||||
|
}
|
||||||
|
|
||||||
|
const source = audioContext.createBufferSource();
|
||||||
|
source.buffer = audioBuffer;
|
||||||
|
source.connect(gainNode);
|
||||||
|
source.start();
|
||||||
|
} catch (e) {
|
||||||
|
console.error(`Failed to play sound ${name}:`, e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function dispose() {
|
||||||
|
audioContext?.close();
|
||||||
|
audioContext = null;
|
||||||
|
gainNode = null;
|
||||||
|
cache.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
return { play, dispose };
|
||||||
|
}
|
||||||
|
|||||||
@ -128,10 +128,25 @@ export class SaveGameParser {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Skip plant manager data (81 plants * 12 bytes = 972 bytes)
|
* Parse plant manager data (81 plants * 12 bytes = 972 bytes)
|
||||||
|
* Each plant: variant(U8) + sound(U32LE) + move(U32LE) + mood(U8) + color(U8) + counter(S8)
|
||||||
*/
|
*/
|
||||||
skipPlants() {
|
parsePlants() {
|
||||||
this.reader.skip(81 * 12);
|
this.parsed.plantsOffset = this.reader.tell();
|
||||||
|
const plants = [];
|
||||||
|
|
||||||
|
for (let i = 0; i < 81; i++) {
|
||||||
|
plants.push({
|
||||||
|
variant: this.reader.readU8(),
|
||||||
|
sound: this.reader.readU32(),
|
||||||
|
move: this.reader.readU32(),
|
||||||
|
mood: this.reader.readU8(),
|
||||||
|
color: this.reader.readU8(),
|
||||||
|
counter: this.reader.readS8()
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
this.parsed.plants = plants;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -425,7 +440,7 @@ export class SaveGameParser {
|
|||||||
this.parseHeader();
|
this.parseHeader();
|
||||||
this.parseVariables();
|
this.parseVariables();
|
||||||
this.parseCharacters();
|
this.parseCharacters();
|
||||||
this.skipPlants();
|
this.parsePlants();
|
||||||
this.skipBuildings();
|
this.skipBuildings();
|
||||||
this.parseGameStates();
|
this.parseGameStates();
|
||||||
|
|
||||||
|
|||||||
@ -6,6 +6,7 @@ import { SaveGameParser } from './SaveGameParser.js';
|
|||||||
import { BinaryWriter } from './BinaryWriter.js';
|
import { BinaryWriter } from './BinaryWriter.js';
|
||||||
import { GameStateTypes, GameStateSizes, Actor, Act1TextureOrder } from '../savegame/constants.js';
|
import { GameStateTypes, GameStateSizes, Actor, Act1TextureOrder } from '../savegame/constants.js';
|
||||||
import { CharacterFieldOffsets, CHARACTER_RECORD_SIZE } from '../savegame/actorConstants.js';
|
import { CharacterFieldOffsets, CHARACTER_RECORD_SIZE } from '../savegame/actorConstants.js';
|
||||||
|
import { PlantFieldOffsets, PLANT_RECORD_SIZE } from '../savegame/plantConstants.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Offsets for header fields
|
* Offsets for header fields
|
||||||
@ -451,6 +452,29 @@ export class SaveGameSerializer {
|
|||||||
return workingBuffer;
|
return workingBuffer;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update a plant field in the save file
|
||||||
|
* @param {number} plantIndex - Plant index (0-80)
|
||||||
|
* @param {string} field - Field name from PlantFieldOffsets
|
||||||
|
* @param {number} value - New value
|
||||||
|
* @returns {ArrayBuffer} - Modified buffer
|
||||||
|
*/
|
||||||
|
updatePlant(plantIndex, field, value) {
|
||||||
|
const workingBuffer = this.createCopy();
|
||||||
|
const view = new DataView(workingBuffer);
|
||||||
|
const offset = this.parsed.plantsOffset + (plantIndex * PLANT_RECORD_SIZE) + PlantFieldOffsets[field];
|
||||||
|
|
||||||
|
if (field === 'sound' || field === 'move') {
|
||||||
|
view.setUint32(offset, value, true);
|
||||||
|
} else if (field === 'counter') {
|
||||||
|
view.setInt8(offset, value);
|
||||||
|
} else {
|
||||||
|
view.setUint8(offset, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
return workingBuffer;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get the byte offset for a mission score
|
* Get the byte offset for a mission score
|
||||||
* @param {string} missionType
|
* @param {string} missionType
|
||||||
|
|||||||
@ -1,9 +1,7 @@
|
|||||||
import * as THREE from 'three';
|
import * as THREE from 'three';
|
||||||
import { ActorLODs, ActorLODFlags, ActorInfoInit } from '../savegame/actorConstants.js';
|
import { ActorLODs, ActorLODFlags, ActorInfoInit } from '../savegame/actorConstants.js';
|
||||||
import { LegoColors } from '../savegame/constants.js';
|
import { LegoColors } from '../savegame/constants.js';
|
||||||
import { parseAnimation } from '../formats/AnimationParser.js';
|
import { AnimatedRenderer } from './AnimatedRenderer.js';
|
||||||
import { fetchAnimation } from '../assetLoader.js';
|
|
||||||
import { BaseRenderer } from './BaseRenderer.js';
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Map actor index to animation suffix index (from g_characters[].m_unk0x16).
|
* Map actor index to animation suffix index (from g_characters[].m_unk0x16).
|
||||||
@ -81,14 +79,10 @@ const PART_NAME_TO_ANIM_NODE = {
|
|||||||
* Renderer for full LEGO characters assembled from WDB global parts.
|
* Renderer for full LEGO characters assembled from WDB global parts.
|
||||||
* Mirrors the game's LegoCharacterManager::CreateActorROI logic.
|
* Mirrors the game's LegoCharacterManager::CreateActorROI logic.
|
||||||
*/
|
*/
|
||||||
export class ActorRenderer extends BaseRenderer {
|
export class ActorRenderer extends AnimatedRenderer {
|
||||||
constructor(canvas) {
|
constructor(canvas) {
|
||||||
super(canvas);
|
super(canvas);
|
||||||
this.partGroups = []; // 10 part groups for click targeting
|
this.partGroups = []; // 10 part groups for click targeting
|
||||||
this.clock = new THREE.Clock();
|
|
||||||
this.mixer = null;
|
|
||||||
this.currentAction = null;
|
|
||||||
this.animationCache = new Map(); // suffix → parsed animation data
|
|
||||||
this._queuedClickMove = null; // queued click animation move index (0-3)
|
this._queuedClickMove = null; // queued click animation move index (0-3)
|
||||||
|
|
||||||
this.camera.position.set(2, 0.8, 3.5);
|
this.camera.position.set(2, 0.8, 3.5);
|
||||||
@ -97,8 +91,6 @@ export class ActorRenderer extends BaseRenderer {
|
|||||||
this.setupControls(new THREE.Vector3(0, 0.2, 0));
|
this.setupControls(new THREE.Vector3(0, 0.2, 0));
|
||||||
this.controls.autoRotate = false;
|
this.controls.autoRotate = false;
|
||||||
this._initialAutoRotate = false;
|
this._initialAutoRotate = false;
|
||||||
|
|
||||||
this.raycaster = new THREE.Raycaster();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -118,7 +110,6 @@ export class ActorRenderer extends BaseRenderer {
|
|||||||
const charState = characters[actorIndex];
|
const charState = characters[actorIndex];
|
||||||
|
|
||||||
// Build texture lookup
|
// Build texture lookup
|
||||||
this.textures.clear();
|
|
||||||
for (const tex of globalTextures) {
|
for (const tex of globalTextures) {
|
||||||
if (tex.name) {
|
if (tex.name) {
|
||||||
this.textures.set(tex.name.toLowerCase(), this.createTexture(tex));
|
this.textures.set(tex.name.toLowerCase(), this.createTexture(tex));
|
||||||
@ -603,21 +594,6 @@ export class ActorRenderer extends BaseRenderer {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Fetch and parse an animation file by name (e.g. "CNs001xx"), with caching.
|
|
||||||
*/
|
|
||||||
async fetchAnimationByName(animName) {
|
|
||||||
if (this.animationCache.has(animName)) {
|
|
||||||
return this.animationCache.get(animName);
|
|
||||||
}
|
|
||||||
|
|
||||||
const buffer = await fetchAnimation(animName);
|
|
||||||
if (!buffer) return null;
|
|
||||||
const animData = parseAnimation(buffer);
|
|
||||||
this.animationCache.set(animName, animData);
|
|
||||||
return animData;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Build world-space keyframe tracks by evaluating the animation tree
|
* Build world-space keyframe tracks by evaluating the animation tree
|
||||||
* hierarchically. At each unique keyframe time, walks the tree composing
|
* hierarchically. At each unique keyframe time, walks the tree composing
|
||||||
@ -655,20 +631,6 @@ export class ActorRenderer extends BaseRenderer {
|
|||||||
return tracks;
|
return tracks;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Recursively collect all unique keyframe times from the animation tree.
|
|
||||||
*/
|
|
||||||
collectKeyframeTimes(node, timesSet) {
|
|
||||||
const data = node.data;
|
|
||||||
for (const key of data.translationKeys) timesSet.add(key.time);
|
|
||||||
for (const key of data.rotationKeys) timesSet.add(key.time);
|
|
||||||
for (const key of data.scaleKeys) timesSet.add(key.time);
|
|
||||||
for (const key of data.morphKeys) timesSet.add(key.time);
|
|
||||||
for (const child of node.children) {
|
|
||||||
this.collectKeyframeTimes(child, timesSet);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Evaluate a single animation node at a given time, composing its local
|
* Evaluate a single animation node at a given time, composing its local
|
||||||
* transform with the parent's world matrix. If the node maps to a part
|
* transform with the parent's world matrix. If the node maps to a part
|
||||||
@ -748,88 +710,6 @@ export class ActorRenderer extends BaseRenderer {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Evaluate rotation keyframes at a given time.
|
|
||||||
* Handles slerp interpolation between keyframes with flag-based control.
|
|
||||||
* Coordinate conversion: game (w,x,y,z) → Three.js with X negated.
|
|
||||||
*/
|
|
||||||
evaluateRotation(keys, time) {
|
|
||||||
const { before, after } = this.getBeforeAndAfter(keys, time);
|
|
||||||
|
|
||||||
const toQuat = (key) => new THREE.Quaternion(-key.x, key.y, key.z, key.w);
|
|
||||||
|
|
||||||
if (!after) {
|
|
||||||
if (before.flags & 0x01) {
|
|
||||||
return new THREE.Matrix4().makeRotationFromQuaternion(toQuat(before));
|
|
||||||
}
|
|
||||||
return new THREE.Matrix4();
|
|
||||||
}
|
|
||||||
|
|
||||||
if ((before.flags & 0x01) || (after.flags & 0x01)) {
|
|
||||||
const beforeQ = toQuat(before);
|
|
||||||
|
|
||||||
// Flag 0x04: skip interpolation, use before value
|
|
||||||
if (after.flags & 0x04) {
|
|
||||||
return new THREE.Matrix4().makeRotationFromQuaternion(beforeQ);
|
|
||||||
}
|
|
||||||
|
|
||||||
let afterQ = toQuat(after);
|
|
||||||
// Flag 0x02: negate the after quaternion before slerp
|
|
||||||
if (after.flags & 0x02) {
|
|
||||||
afterQ.set(-afterQ.x, -afterQ.y, -afterQ.z, -afterQ.w);
|
|
||||||
}
|
|
||||||
|
|
||||||
const t = (time - before.time) / (after.time - before.time);
|
|
||||||
const result = new THREE.Quaternion().slerpQuaternions(beforeQ, afterQ, t);
|
|
||||||
return new THREE.Matrix4().makeRotationFromQuaternion(result);
|
|
||||||
}
|
|
||||||
|
|
||||||
return new THREE.Matrix4();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Interpolate translation or scale keyframes at a given time.
|
|
||||||
* For translation: negates X for coordinate system conversion.
|
|
||||||
* For scale: no negation.
|
|
||||||
*/
|
|
||||||
interpolateVertex(keys, time, isTranslation) {
|
|
||||||
const { before, after } = this.getBeforeAndAfter(keys, time);
|
|
||||||
|
|
||||||
const toVec = (key) => isTranslation
|
|
||||||
? new THREE.Vector3(-key.x, key.y, key.z)
|
|
||||||
: new THREE.Vector3(key.x, key.y, key.z);
|
|
||||||
|
|
||||||
if (!after) {
|
|
||||||
if (isTranslation && !(before.flags & 0x01)) {
|
|
||||||
// Check if vertex is non-zero (matching reference behavior)
|
|
||||||
if (Math.abs(before.x) < 1e-5 && Math.abs(before.y) < 1e-5 && Math.abs(before.z) < 1e-5) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return toVec(before);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isTranslation && !(before.flags & 0x01) && !(after.flags & 0x01)) {
|
|
||||||
// Both inactive — check if vertices are non-zero
|
|
||||||
const bNonZero = Math.abs(before.x) > 1e-5 || Math.abs(before.y) > 1e-5 || Math.abs(before.z) > 1e-5;
|
|
||||||
const aNonZero = Math.abs(after.x) > 1e-5 || Math.abs(after.y) > 1e-5 || Math.abs(after.z) > 1e-5;
|
|
||||||
if (!bNonZero && !aNonZero) return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const t = (time - before.time) / (after.time - before.time);
|
|
||||||
return new THREE.Vector3().lerpVectors(toVec(before), toVec(after), t);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Find the keyframes immediately before and after the given time.
|
|
||||||
*/
|
|
||||||
getBeforeAndAfter(keys, time) {
|
|
||||||
let idx = keys.findIndex(k => k.time > time);
|
|
||||||
if (idx < 0) idx = keys.length;
|
|
||||||
const before = keys[Math.max(0, idx - 1)];
|
|
||||||
return { before, after: keys[idx] || null };
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Evaluate visibility from morph keys at a given time.
|
* Evaluate visibility from morph keys at a given time.
|
||||||
* Matches game's GetVisibility: returns true (visible) by default,
|
* Matches game's GetVisibility: returns true (visible) by default,
|
||||||
@ -859,45 +739,12 @@ export class ActorRenderer extends BaseRenderer {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
stopAnimation() {
|
|
||||||
if (this.currentAction) {
|
|
||||||
this.currentAction.stop();
|
|
||||||
this.currentAction = null;
|
|
||||||
}
|
|
||||||
if (this.mixer) {
|
|
||||||
this.mixer.stopAllAction();
|
|
||||||
this.mixer = null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Scene Management ────────────────────────────────────────────
|
// ─── Scene Management ────────────────────────────────────────────
|
||||||
|
|
||||||
clearModel() {
|
clearModel() {
|
||||||
this.stopAnimation();
|
|
||||||
super.clearModel();
|
super.clearModel();
|
||||||
this.partGroups = [];
|
this.partGroups = [];
|
||||||
this.vehicleGroup = null;
|
this.vehicleGroup = null;
|
||||||
this.vehicleInfo = null;
|
this.vehicleInfo = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
start() {
|
|
||||||
this.animating = true;
|
|
||||||
this.clock.start();
|
|
||||||
this.animate();
|
|
||||||
}
|
|
||||||
|
|
||||||
updateAnimation() {
|
|
||||||
const delta = this.clock.getDelta();
|
|
||||||
|
|
||||||
if (this.mixer) {
|
|
||||||
this.mixer.update(delta);
|
|
||||||
}
|
|
||||||
this.controls?.update();
|
|
||||||
}
|
|
||||||
|
|
||||||
dispose() {
|
|
||||||
this.stopAnimation();
|
|
||||||
super.dispose();
|
|
||||||
this.animationCache.clear();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
167
src/core/rendering/AnimatedRenderer.js
Normal file
167
src/core/rendering/AnimatedRenderer.js
Normal file
@ -0,0 +1,167 @@
|
|||||||
|
import * as THREE from 'three';
|
||||||
|
import { parseAnimation } from '../formats/AnimationParser.js';
|
||||||
|
import { fetchAnimation } from '../assetLoader.js';
|
||||||
|
import { BaseRenderer } from './BaseRenderer.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Intermediate renderer for LEGO models with animation support.
|
||||||
|
* Extends BaseRenderer with clock-driven animation loop, AnimationMixer
|
||||||
|
* management, animation caching, raycasting, and shared keyframe utilities.
|
||||||
|
*/
|
||||||
|
export class AnimatedRenderer extends BaseRenderer {
|
||||||
|
constructor(canvas) {
|
||||||
|
super(canvas);
|
||||||
|
this.clock = new THREE.Clock();
|
||||||
|
this.mixer = null;
|
||||||
|
this.currentAction = null;
|
||||||
|
this.animationCache = new Map();
|
||||||
|
this.raycaster = new THREE.Raycaster();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Animation Utilities ─────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch and parse an animation file by name, with caching.
|
||||||
|
*/
|
||||||
|
async fetchAnimationByName(animName) {
|
||||||
|
if (this.animationCache.has(animName)) {
|
||||||
|
return this.animationCache.get(animName);
|
||||||
|
}
|
||||||
|
const buffer = await fetchAnimation(animName);
|
||||||
|
if (!buffer) return null;
|
||||||
|
const animData = parseAnimation(buffer);
|
||||||
|
this.animationCache.set(animName, animData);
|
||||||
|
return animData;
|
||||||
|
}
|
||||||
|
|
||||||
|
stopAnimation() {
|
||||||
|
if (this.currentAction) {
|
||||||
|
this.currentAction.stop();
|
||||||
|
this.currentAction = null;
|
||||||
|
}
|
||||||
|
if (this.mixer) {
|
||||||
|
this.mixer.stopAllAction();
|
||||||
|
this.mixer = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Recursively collect all unique keyframe times from the animation tree.
|
||||||
|
*/
|
||||||
|
collectKeyframeTimes(node, timesSet) {
|
||||||
|
const data = node.data;
|
||||||
|
for (const key of data.translationKeys) timesSet.add(key.time);
|
||||||
|
for (const key of data.rotationKeys) timesSet.add(key.time);
|
||||||
|
for (const key of data.scaleKeys) timesSet.add(key.time);
|
||||||
|
for (const key of data.morphKeys) timesSet.add(key.time);
|
||||||
|
for (const child of node.children) {
|
||||||
|
this.collectKeyframeTimes(child, timesSet);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Evaluate rotation keyframes at a given time.
|
||||||
|
* Handles slerp interpolation between keyframes with flag-based control.
|
||||||
|
* Coordinate conversion: game (w,x,y,z) -> Three.js with X negated.
|
||||||
|
*/
|
||||||
|
evaluateRotation(keys, time) {
|
||||||
|
const { before, after } = this.getBeforeAndAfter(keys, time);
|
||||||
|
const toQuat = (key) => new THREE.Quaternion(-key.x, key.y, key.z, key.w);
|
||||||
|
|
||||||
|
if (!after) {
|
||||||
|
if (before.flags & 0x01) {
|
||||||
|
return new THREE.Matrix4().makeRotationFromQuaternion(toQuat(before));
|
||||||
|
}
|
||||||
|
return new THREE.Matrix4();
|
||||||
|
}
|
||||||
|
|
||||||
|
if ((before.flags & 0x01) || (after.flags & 0x01)) {
|
||||||
|
const beforeQ = toQuat(before);
|
||||||
|
|
||||||
|
// Flag 0x04: skip interpolation, use before value
|
||||||
|
if (after.flags & 0x04) {
|
||||||
|
return new THREE.Matrix4().makeRotationFromQuaternion(beforeQ);
|
||||||
|
}
|
||||||
|
|
||||||
|
let afterQ = toQuat(after);
|
||||||
|
// Flag 0x02: negate the after quaternion before slerp
|
||||||
|
if (after.flags & 0x02) {
|
||||||
|
afterQ.set(-afterQ.x, -afterQ.y, -afterQ.z, -afterQ.w);
|
||||||
|
}
|
||||||
|
|
||||||
|
const t = (time - before.time) / (after.time - before.time);
|
||||||
|
const result = new THREE.Quaternion().slerpQuaternions(beforeQ, afterQ, t);
|
||||||
|
return new THREE.Matrix4().makeRotationFromQuaternion(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
return new THREE.Matrix4();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Interpolate translation or scale keyframes at a given time.
|
||||||
|
* For translation: negates X for coordinate system conversion.
|
||||||
|
* For scale: no negation.
|
||||||
|
*/
|
||||||
|
interpolateVertex(keys, time, isTranslation) {
|
||||||
|
const { before, after } = this.getBeforeAndAfter(keys, time);
|
||||||
|
|
||||||
|
const toVec = (key) => isTranslation
|
||||||
|
? new THREE.Vector3(-key.x, key.y, key.z)
|
||||||
|
: new THREE.Vector3(key.x, key.y, key.z);
|
||||||
|
|
||||||
|
if (!after) {
|
||||||
|
if (isTranslation && !(before.flags & 0x01)) {
|
||||||
|
if (Math.abs(before.x) < 1e-5 && Math.abs(before.y) < 1e-5 && Math.abs(before.z) < 1e-5) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return toVec(before);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isTranslation && !(before.flags & 0x01) && !(after.flags & 0x01)) {
|
||||||
|
const bNonZero = Math.abs(before.x) > 1e-5 || Math.abs(before.y) > 1e-5 || Math.abs(before.z) > 1e-5;
|
||||||
|
const aNonZero = Math.abs(after.x) > 1e-5 || Math.abs(after.y) > 1e-5 || Math.abs(after.z) > 1e-5;
|
||||||
|
if (!bNonZero && !aNonZero) return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const t = (time - before.time) / (after.time - before.time);
|
||||||
|
return new THREE.Vector3().lerpVectors(toVec(before), toVec(after), t);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find the keyframes immediately before and after the given time.
|
||||||
|
*/
|
||||||
|
getBeforeAndAfter(keys, time) {
|
||||||
|
let idx = keys.findIndex(k => k.time > time);
|
||||||
|
if (idx < 0) idx = keys.length;
|
||||||
|
const before = keys[Math.max(0, idx - 1)];
|
||||||
|
return { before, after: keys[idx] || null };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Scene Management ────────────────────────────────────────────
|
||||||
|
|
||||||
|
clearModel() {
|
||||||
|
this.stopAnimation();
|
||||||
|
super.clearModel();
|
||||||
|
}
|
||||||
|
|
||||||
|
start() {
|
||||||
|
this.animating = true;
|
||||||
|
this.clock.start();
|
||||||
|
this.animate();
|
||||||
|
}
|
||||||
|
|
||||||
|
updateAnimation() {
|
||||||
|
const delta = this.clock.getDelta();
|
||||||
|
if (this.mixer) {
|
||||||
|
this.mixer.update(delta);
|
||||||
|
}
|
||||||
|
this.controls?.update();
|
||||||
|
}
|
||||||
|
|
||||||
|
dispose() {
|
||||||
|
this.stopAnimation();
|
||||||
|
super.dispose();
|
||||||
|
this.animationCache.clear();
|
||||||
|
}
|
||||||
|
}
|
||||||
286
src/core/rendering/PlantRenderer.js
Normal file
286
src/core/rendering/PlantRenderer.js
Normal file
@ -0,0 +1,286 @@
|
|||||||
|
import * as THREE from 'three';
|
||||||
|
import { PlantLodNames } from '../savegame/plantConstants.js';
|
||||||
|
import { LegoColors } from '../savegame/constants.js';
|
||||||
|
import { AnimatedRenderer } from './AnimatedRenderer.js';
|
||||||
|
|
||||||
|
// Plant color → LEGO color mapping for fallback materials
|
||||||
|
const PLANT_COLOR_MAP = ['lego white', 'lego black', 'lego yellow', 'lego red', 'lego green'];
|
||||||
|
|
||||||
|
// Animation suffix per variant: flower→F, tree→T, bush→B, palm→P
|
||||||
|
const VARIANT_ANIM_SUFFIX = ['F', 'T', 'B', 'P'];
|
||||||
|
|
||||||
|
// Per-variant display adjustments: [scaleFactor, yOffset]
|
||||||
|
// Flower is tall/wide → zoom out + shift down; others shift up to sit in frame
|
||||||
|
const VARIANT_DISPLAY = [
|
||||||
|
[1.6, -0.1], // Flower: smaller, shifted slightly down
|
||||||
|
[1.8, 0.6], // Tree: slightly smaller, shifted up
|
||||||
|
[1.6, 1.4], // Bush: smaller, shifted well up
|
||||||
|
[2.0, 1.1], // Palm: shifted well up
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Renderer for LEGO Island plants. Much simpler than ActorRenderer —
|
||||||
|
* single model group, no multi-part assembly.
|
||||||
|
*/
|
||||||
|
export class PlantRenderer extends AnimatedRenderer {
|
||||||
|
constructor(canvas) {
|
||||||
|
super(canvas);
|
||||||
|
this._queuedClickAnim = null;
|
||||||
|
|
||||||
|
this.camera.position.set(1.5, 1.2, 2.5);
|
||||||
|
this.camera.lookAt(0, 0.2, 0);
|
||||||
|
|
||||||
|
this.setupControls(new THREE.Vector3(0, 0.2, 0));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load a plant model.
|
||||||
|
* @param {number} variant - Plant variant (0-3)
|
||||||
|
* @param {number} color - Plant color (0-4)
|
||||||
|
* @param {Map} partsMap - Name→part lookup from WDB
|
||||||
|
* @param {Array} textures - Texture list from WDB
|
||||||
|
*/
|
||||||
|
loadPlant(variant, color, partsMap, textures) {
|
||||||
|
this.clearModel();
|
||||||
|
|
||||||
|
const lodName = PlantLodNames[variant]?.[color];
|
||||||
|
if (!lodName) return;
|
||||||
|
|
||||||
|
// Find the part data (case-insensitive)
|
||||||
|
const partData = partsMap.get(lodName.toLowerCase());
|
||||||
|
if (!partData) return;
|
||||||
|
|
||||||
|
// Build texture lookup
|
||||||
|
if (textures) {
|
||||||
|
for (const tex of textures) {
|
||||||
|
if (tex.name) {
|
||||||
|
this.textures.set(tex.name.toLowerCase(), this.createTexture(tex));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this.modelGroup = new THREE.Group();
|
||||||
|
|
||||||
|
const lods = partData.lods || [];
|
||||||
|
if (lods.length === 0) return;
|
||||||
|
|
||||||
|
const lod = lods[lods.length - 1]; // Highest quality
|
||||||
|
for (const mesh of lod.meshes) {
|
||||||
|
const geometry = this.createGeometry(mesh, lod);
|
||||||
|
if (!geometry) continue;
|
||||||
|
|
||||||
|
let material;
|
||||||
|
const meshTexName = mesh.properties?.textureName?.toLowerCase();
|
||||||
|
if (meshTexName && this.textures.has(meshTexName)) {
|
||||||
|
material = new THREE.MeshLambertMaterial({
|
||||||
|
map: this.textures.get(meshTexName),
|
||||||
|
side: THREE.DoubleSide,
|
||||||
|
color: 0xffffff
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
const meshColor = mesh.properties?.color;
|
||||||
|
if (meshColor) {
|
||||||
|
material = new THREE.MeshLambertMaterial({
|
||||||
|
color: new THREE.Color(meshColor.r / 255, meshColor.g / 255, meshColor.b / 255),
|
||||||
|
side: THREE.DoubleSide
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// Fallback to plant color
|
||||||
|
const colorName = PLANT_COLOR_MAP[color] || 'lego green';
|
||||||
|
const colorEntry = LegoColors[colorName] || LegoColors['lego green'];
|
||||||
|
material = new THREE.MeshLambertMaterial({
|
||||||
|
color: new THREE.Color(colorEntry.r / 255, colorEntry.g / 255, colorEntry.b / 255),
|
||||||
|
side: THREE.DoubleSide
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this.modelGroup.add(new THREE.Mesh(geometry, material));
|
||||||
|
}
|
||||||
|
|
||||||
|
const [scaleFactor, yOffset] = VARIANT_DISPLAY[variant] || [2.0, 0];
|
||||||
|
this.centerAndScaleModel(scaleFactor);
|
||||||
|
this.modelGroup.position.y += yOffset;
|
||||||
|
this.scene.add(this.modelGroup);
|
||||||
|
this.renderer.render(this.scene, this.camera);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if the plant mesh was clicked.
|
||||||
|
* @returns {boolean} True if any mesh was hit
|
||||||
|
*/
|
||||||
|
getClickedMesh(mouseEvent) {
|
||||||
|
if (!this.modelGroup) return false;
|
||||||
|
|
||||||
|
const rect = this.canvas.getBoundingClientRect();
|
||||||
|
const mouse = new THREE.Vector2(
|
||||||
|
((mouseEvent.clientX - rect.left) / rect.width) * 2 - 1,
|
||||||
|
-((mouseEvent.clientY - rect.top) / rect.height) * 2 + 1
|
||||||
|
);
|
||||||
|
|
||||||
|
this.raycaster.setFromCamera(mouse, this.camera);
|
||||||
|
|
||||||
|
const meshes = [];
|
||||||
|
this.modelGroup.traverse((child) => {
|
||||||
|
if (child instanceof THREE.Mesh) meshes.push(child);
|
||||||
|
});
|
||||||
|
|
||||||
|
return this.raycaster.intersectObjects(meshes).length > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Animation System ────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Queue a click animation to play.
|
||||||
|
* @param {number} variant - Plant variant (0-3)
|
||||||
|
* @param {number} move - The plant's move value
|
||||||
|
*/
|
||||||
|
queueClickAnimation(variant, move) {
|
||||||
|
this._queuedClickAnim = { variant, move };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Play a queued click animation if available.
|
||||||
|
* Called after model reload or directly for non-visual changes.
|
||||||
|
*/
|
||||||
|
async playQueuedAnimation() {
|
||||||
|
if (!this._queuedClickAnim || !this.modelGroup) return;
|
||||||
|
|
||||||
|
const { variant, move } = this._queuedClickAnim;
|
||||||
|
this._queuedClickAnim = null;
|
||||||
|
|
||||||
|
const suffix = VARIANT_ANIM_SUFFIX[variant];
|
||||||
|
const animName = `PlantAnim${suffix}${move}`;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const animData = await this.fetchAnimationByName(animName);
|
||||||
|
if (!animData || !this.modelGroup) return;
|
||||||
|
|
||||||
|
const tracks = this.buildPlantTracks(animData);
|
||||||
|
if (tracks.length === 0) return;
|
||||||
|
|
||||||
|
this.stopAnimation();
|
||||||
|
|
||||||
|
const clip = new THREE.AnimationClip('plantClick', -1, tracks);
|
||||||
|
this.mixer = new THREE.AnimationMixer(this.modelGroup);
|
||||||
|
const action = this.mixer.clipAction(clip);
|
||||||
|
action.setLoop(THREE.LoopOnce);
|
||||||
|
action.clampWhenFinished = false;
|
||||||
|
this.currentAction = action;
|
||||||
|
action.play();
|
||||||
|
|
||||||
|
this.mixer.addEventListener('finished', () => {
|
||||||
|
this.stopAnimation();
|
||||||
|
this.controls.autoRotate = true;
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
// Animation unavailable — ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build animation tracks for a plant. Maps animation tree nodes
|
||||||
|
* to the model group (the entire plant is a single group).
|
||||||
|
*/
|
||||||
|
buildPlantTracks(animData) {
|
||||||
|
const duration = animData.duration;
|
||||||
|
const timesSet = new Set([0]);
|
||||||
|
this.collectKeyframeTimes(animData.rootNode, timesSet);
|
||||||
|
const times = [...timesSet].filter(t => t <= duration).sort((a, b) => a - b);
|
||||||
|
|
||||||
|
// Find the deepest non-root node that has animation data —
|
||||||
|
// map it to our modelGroup
|
||||||
|
const plantNode = this.findPlantNode(animData.rootNode);
|
||||||
|
if (!plantNode) return [];
|
||||||
|
|
||||||
|
const quatValues = [];
|
||||||
|
const timesSec = [];
|
||||||
|
|
||||||
|
for (const time of times) {
|
||||||
|
const mat = this.evaluateNodeChain(animData.rootNode, plantNode, time);
|
||||||
|
const position = new THREE.Vector3();
|
||||||
|
const quaternion = new THREE.Quaternion();
|
||||||
|
const scale = new THREE.Vector3();
|
||||||
|
mat.decompose(position, quaternion, scale);
|
||||||
|
|
||||||
|
timesSec.push(time / 1000);
|
||||||
|
quatValues.push(quaternion.x, quaternion.y, quaternion.z, quaternion.w);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only emit rotation tracks — position tracks would override the
|
||||||
|
// centering applied by centerAndScaleModel() since the animation
|
||||||
|
// uses the game's world-space coordinates.
|
||||||
|
return [
|
||||||
|
new THREE.QuaternionKeyframeTrack('.quaternion', timesSec, quatValues)
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find the first leaf/deepest node with animation data in the tree.
|
||||||
|
*/
|
||||||
|
findPlantNode(node) {
|
||||||
|
// Depth-first: prefer children
|
||||||
|
for (const child of node.children) {
|
||||||
|
const found = this.findPlantNode(child);
|
||||||
|
if (found) return found;
|
||||||
|
}
|
||||||
|
// If this node has actual keyframe data, use it
|
||||||
|
const d = node.data;
|
||||||
|
if (d.translationKeys.length > 0 || d.rotationKeys.length > 0 || d.scaleKeys.length > 0) {
|
||||||
|
return node;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Evaluate the composed matrix from root down to targetNode at a given time.
|
||||||
|
*/
|
||||||
|
evaluateNodeChain(node, targetNode, time) {
|
||||||
|
const path = [];
|
||||||
|
if (!this.findPath(node, targetNode, path)) {
|
||||||
|
return new THREE.Matrix4();
|
||||||
|
}
|
||||||
|
|
||||||
|
let mat = new THREE.Matrix4();
|
||||||
|
for (const n of path) {
|
||||||
|
const local = this.evaluateLocalTransform(n.data, time);
|
||||||
|
mat.multiply(local);
|
||||||
|
}
|
||||||
|
return mat;
|
||||||
|
}
|
||||||
|
|
||||||
|
findPath(current, target, path) {
|
||||||
|
path.push(current);
|
||||||
|
if (current === target) return true;
|
||||||
|
for (const child of current.children) {
|
||||||
|
if (this.findPath(child, target, path)) return true;
|
||||||
|
}
|
||||||
|
path.pop();
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
evaluateLocalTransform(data, time) {
|
||||||
|
let mat = new THREE.Matrix4();
|
||||||
|
|
||||||
|
if (data.scaleKeys.length > 0) {
|
||||||
|
const scale = this.interpolateVertex(data.scaleKeys, time, false);
|
||||||
|
if (scale) mat.scale(scale);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data.rotationKeys.length > 0) {
|
||||||
|
const rotMat = this.evaluateRotation(data.rotationKeys, time);
|
||||||
|
mat = rotMat.multiply(mat);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data.translationKeys.length > 0) {
|
||||||
|
const vertex = this.interpolateVertex(data.translationKeys, time, true);
|
||||||
|
if (vertex) {
|
||||||
|
mat.elements[12] += vertex.x;
|
||||||
|
mat.elements[13] += vertex.y;
|
||||||
|
mat.elements[14] += vertex.z;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return mat;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -43,7 +43,6 @@ export class VehiclePartRenderer extends BaseRenderer {
|
|||||||
this.partsMap = partsMap;
|
this.partsMap = partsMap;
|
||||||
|
|
||||||
// Build texture lookup map (case-insensitive)
|
// Build texture lookup map (case-insensitive)
|
||||||
this.textures.clear();
|
|
||||||
for (const tex of textureList) {
|
for (const tex of textureList) {
|
||||||
if (tex.name) {
|
if (tex.name) {
|
||||||
this.textures.set(tex.name.toLowerCase(), this.createTexture(tex));
|
this.textures.set(tex.name.toLowerCase(), this.createTexture(tex));
|
||||||
|
|||||||
@ -93,6 +93,8 @@ export async function listSaveSlots() {
|
|||||||
act1State: null,
|
act1State: null,
|
||||||
characters: null,
|
characters: null,
|
||||||
charactersOffset: null,
|
charactersOffset: null,
|
||||||
|
plants: null,
|
||||||
|
plantsOffset: null,
|
||||||
playerName: null,
|
playerName: null,
|
||||||
buffer: null
|
buffer: null
|
||||||
};
|
};
|
||||||
@ -108,6 +110,8 @@ export async function listSaveSlots() {
|
|||||||
slot.act1State = parsed.act1State || null;
|
slot.act1State = parsed.act1State || null;
|
||||||
slot.characters = parsed.characters || null;
|
slot.characters = parsed.characters || null;
|
||||||
slot.charactersOffset = parsed.charactersOffset || null;
|
slot.charactersOffset = parsed.charactersOffset || null;
|
||||||
|
slot.plants = parsed.plants || null;
|
||||||
|
slot.plantsOffset = parsed.plantsOffset || null;
|
||||||
slot.buffer = buffer;
|
slot.buffer = buffer;
|
||||||
|
|
||||||
// Try to get player name
|
// Try to get player name
|
||||||
@ -174,6 +178,8 @@ export async function loadSaveSlot(slotNumber) {
|
|||||||
act1State: parsed.act1State || null,
|
act1State: parsed.act1State || null,
|
||||||
characters: parsed.characters || null,
|
characters: parsed.characters || null,
|
||||||
charactersOffset: parsed.charactersOffset || null,
|
charactersOffset: parsed.charactersOffset || null,
|
||||||
|
plants: parsed.plants || null,
|
||||||
|
plantsOffset: parsed.plantsOffset || null,
|
||||||
playerName,
|
playerName,
|
||||||
buffer
|
buffer
|
||||||
};
|
};
|
||||||
@ -257,6 +263,19 @@ export async function updateSaveSlot(slotNumber, updates) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Apply plant update(s)
|
||||||
|
if (updates.plant) {
|
||||||
|
const entries = Array.isArray(updates.plant) ? updates.plant : [updates.plant];
|
||||||
|
for (const { plantIndex, field, value } of entries) {
|
||||||
|
const plantSerializer = createSerializer(newBuffer);
|
||||||
|
const result = plantSerializer.updatePlant(plantIndex, field, value);
|
||||||
|
if (result) {
|
||||||
|
newBuffer = result;
|
||||||
|
modified = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Apply texture update
|
// Apply texture update
|
||||||
if (updates.texture) {
|
if (updates.texture) {
|
||||||
const { textureName, textureData } = updates.texture;
|
const { textureName, textureData } = updates.texture;
|
||||||
|
|||||||
146
src/core/savegame/plantConstants.js
Normal file
146
src/core/savegame/plantConstants.js
Normal file
@ -0,0 +1,146 @@
|
|||||||
|
/**
|
||||||
|
* Plant data constants ported from LEGO1 source:
|
||||||
|
* isle/LEGO1/lego/legoomni/src/common/legoplants.cpp
|
||||||
|
* isle/LEGO1/lego/legoomni/src/common/legoplantmanager.cpp
|
||||||
|
* isle/LEGO1/lego/legoomni/include/legoplants.h
|
||||||
|
*/
|
||||||
|
|
||||||
|
// LegoPlantInfo::Variant enum
|
||||||
|
export const PlantVariant = Object.freeze({
|
||||||
|
FLOWER: 0,
|
||||||
|
TREE: 1,
|
||||||
|
BUSH: 2,
|
||||||
|
PALM: 3
|
||||||
|
});
|
||||||
|
|
||||||
|
// LegoPlantInfo::Color enum
|
||||||
|
export const PlantColor = Object.freeze({
|
||||||
|
WHITE: 0,
|
||||||
|
BLACK: 1,
|
||||||
|
YELLOW: 2,
|
||||||
|
RED: 3,
|
||||||
|
GREEN: 4
|
||||||
|
});
|
||||||
|
|
||||||
|
export const PlantVariantNames = Object.freeze(['Flower', 'Tree', 'Bush', 'Palm']);
|
||||||
|
export const PlantColorNames = Object.freeze(['White', 'Black', 'Yellow', 'Red', 'Green']);
|
||||||
|
|
||||||
|
// g_plantLodNames[4][5] — LOD model name indexed by [variant][color]
|
||||||
|
export const PlantLodNames = Object.freeze([
|
||||||
|
['flwrwht', 'flwrblk', 'flwryel', 'flwrred', 'flwrgrn'], // flower
|
||||||
|
['treewht', 'treeblk', 'treeyel', 'treered', 'tree'], // tree
|
||||||
|
['bushwht', 'bushblk', 'bushyel', 'bushred', 'bush'], // bush
|
||||||
|
['palmwht', 'palmblk', 'palmyel', 'palmred', 'palm'] // palm
|
||||||
|
]);
|
||||||
|
|
||||||
|
export const PLANT_COUNT = 81;
|
||||||
|
export const PLANT_RECORD_SIZE = 12; // variant(1) + sound(4) + move(4) + mood(1) + color(1) + counter(1)
|
||||||
|
|
||||||
|
// Field byte offsets within a 12-byte plant record
|
||||||
|
export const PlantFieldOffsets = Object.freeze({
|
||||||
|
variant: 0, // U8
|
||||||
|
sound: 1, // U32 LE
|
||||||
|
move: 5, // U32 LE
|
||||||
|
mood: 9, // U8
|
||||||
|
color: 10, // U8
|
||||||
|
counter: 11 // S8
|
||||||
|
});
|
||||||
|
|
||||||
|
// Max values for cycling (exclusive upper bounds)
|
||||||
|
export const MAX_SOUND = 8;
|
||||||
|
export const MAX_MOVE = Object.freeze([3, 3, 3, 3]); // per variant
|
||||||
|
export const MAX_MOOD = 4;
|
||||||
|
export const MAX_COLOR = 5;
|
||||||
|
export const MAX_VARIANT = 4;
|
||||||
|
|
||||||
|
// g_plantSoundIdOffset — base objectId for click sounds (actual = sound + 56)
|
||||||
|
export const PLANT_SOUND_OFFSET = 56;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* g_plantInfoInit[81] — default values for all 81 plants.
|
||||||
|
* All entries share: sound=3, move=0, mood=1, counter=-1.
|
||||||
|
* Only variant and color differ per plant.
|
||||||
|
*/
|
||||||
|
export const PlantInfoInit = Object.freeze([
|
||||||
|
/* 0 */ { variant: 0, sound: 3, move: 0, mood: 1, color: 3, counter: -1 },
|
||||||
|
/* 1 */ { variant: 0, sound: 3, move: 0, mood: 1, color: 3, counter: -1 },
|
||||||
|
/* 2 */ { variant: 0, sound: 3, move: 0, mood: 1, color: 3, counter: -1 },
|
||||||
|
/* 3 */ { variant: 0, sound: 3, move: 0, mood: 1, color: 3, counter: -1 },
|
||||||
|
/* 4 */ { variant: 0, sound: 3, move: 0, mood: 1, color: 2, counter: -1 },
|
||||||
|
/* 5 */ { variant: 0, sound: 3, move: 0, mood: 1, color: 2, counter: -1 },
|
||||||
|
/* 6 */ { variant: 0, sound: 3, move: 0, mood: 1, color: 2, counter: -1 },
|
||||||
|
/* 7 */ { variant: 0, sound: 3, move: 0, mood: 1, color: 2, counter: -1 },
|
||||||
|
/* 8 */ { variant: 0, sound: 3, move: 0, mood: 1, color: 3, counter: -1 },
|
||||||
|
/* 9 */ { variant: 0, sound: 3, move: 0, mood: 1, color: 3, counter: -1 },
|
||||||
|
/* 10 */ { variant: 0, sound: 3, move: 0, mood: 1, color: 2, counter: -1 },
|
||||||
|
/* 11 */ { variant: 0, sound: 3, move: 0, mood: 1, color: 2, counter: -1 },
|
||||||
|
/* 12 */ { variant: 3, sound: 3, move: 0, mood: 1, color: 4, counter: -1 },
|
||||||
|
/* 13 */ { variant: 3, sound: 3, move: 0, mood: 1, color: 4, counter: -1 },
|
||||||
|
/* 14 */ { variant: 3, sound: 3, move: 0, mood: 1, color: 4, counter: -1 },
|
||||||
|
/* 15 */ { variant: 3, sound: 3, move: 0, mood: 1, color: 4, counter: -1 },
|
||||||
|
/* 16 */ { variant: 2, sound: 3, move: 0, mood: 1, color: 4, counter: -1 },
|
||||||
|
/* 17 */ { variant: 2, sound: 3, move: 0, mood: 1, color: 4, counter: -1 },
|
||||||
|
/* 18 */ { variant: 2, sound: 3, move: 0, mood: 1, color: 4, counter: -1 },
|
||||||
|
/* 19 */ { variant: 2, sound: 3, move: 0, mood: 1, color: 4, counter: -1 },
|
||||||
|
/* 20 */ { variant: 1, sound: 3, move: 0, mood: 1, color: 4, counter: -1 },
|
||||||
|
/* 21 */ { variant: 1, sound: 3, move: 0, mood: 1, color: 4, counter: -1 },
|
||||||
|
/* 22 */ { variant: 1, sound: 3, move: 0, mood: 1, color: 4, counter: -1 },
|
||||||
|
/* 23 */ { variant: 1, sound: 3, move: 0, mood: 1, color: 4, counter: -1 },
|
||||||
|
/* 24 */ { variant: 3, sound: 3, move: 0, mood: 1, color: 4, counter: -1 },
|
||||||
|
/* 25 */ { variant: 0, sound: 3, move: 0, mood: 1, color: 3, counter: -1 },
|
||||||
|
/* 26 */ { variant: 0, sound: 3, move: 0, mood: 1, color: 3, counter: -1 },
|
||||||
|
/* 27 */ { variant: 0, sound: 3, move: 0, mood: 1, color: 3, counter: -1 },
|
||||||
|
/* 28 */ { variant: 0, sound: 3, move: 0, mood: 1, color: 3, counter: -1 },
|
||||||
|
/* 29 */ { variant: 0, sound: 3, move: 0, mood: 1, color: 3, counter: -1 },
|
||||||
|
/* 30 */ { variant: 0, sound: 3, move: 0, mood: 1, color: 3, counter: -1 },
|
||||||
|
/* 31 */ { variant: 0, sound: 3, move: 0, mood: 1, color: 3, counter: -1 },
|
||||||
|
/* 32 */ { variant: 0, sound: 3, move: 0, mood: 1, color: 3, counter: -1 },
|
||||||
|
/* 33 */ { variant: 0, sound: 3, move: 0, mood: 1, color: 3, counter: -1 },
|
||||||
|
/* 34 */ { variant: 0, sound: 3, move: 0, mood: 1, color: 3, counter: -1 },
|
||||||
|
/* 35 */ { variant: 0, sound: 3, move: 0, mood: 1, color: 3, counter: -1 },
|
||||||
|
/* 36 */ { variant: 0, sound: 3, move: 0, mood: 1, color: 3, counter: -1 },
|
||||||
|
/* 37 */ { variant: 0, sound: 3, move: 0, mood: 1, color: 3, counter: -1 },
|
||||||
|
/* 38 */ { variant: 0, sound: 3, move: 0, mood: 1, color: 2, counter: -1 },
|
||||||
|
/* 39 */ { variant: 0, sound: 3, move: 0, mood: 1, color: 2, counter: -1 },
|
||||||
|
/* 40 */ { variant: 0, sound: 3, move: 0, mood: 1, color: 2, counter: -1 },
|
||||||
|
/* 41 */ { variant: 0, sound: 3, move: 0, mood: 1, color: 2, counter: -1 },
|
||||||
|
/* 42 */ { variant: 0, sound: 3, move: 0, mood: 1, color: 2, counter: -1 },
|
||||||
|
/* 43 */ { variant: 0, sound: 3, move: 0, mood: 1, color: 2, counter: -1 },
|
||||||
|
/* 44 */ { variant: 0, sound: 3, move: 0, mood: 1, color: 2, counter: -1 },
|
||||||
|
/* 45 */ { variant: 0, sound: 3, move: 0, mood: 1, color: 2, counter: -1 },
|
||||||
|
/* 46 */ { variant: 0, sound: 3, move: 0, mood: 1, color: 2, counter: -1 },
|
||||||
|
/* 47 */ { variant: 0, sound: 3, move: 0, mood: 1, color: 2, counter: -1 },
|
||||||
|
/* 48 */ { variant: 0, sound: 3, move: 0, mood: 1, color: 2, counter: -1 },
|
||||||
|
/* 49 */ { variant: 0, sound: 3, move: 0, mood: 1, color: 2, counter: -1 },
|
||||||
|
/* 50 */ { variant: 3, sound: 3, move: 0, mood: 1, color: 4, counter: -1 },
|
||||||
|
/* 51 */ { variant: 3, sound: 3, move: 0, mood: 1, color: 4, counter: -1 },
|
||||||
|
/* 52 */ { variant: 3, sound: 3, move: 0, mood: 1, color: 4, counter: -1 },
|
||||||
|
/* 53 */ { variant: 3, sound: 3, move: 0, mood: 1, color: 4, counter: -1 },
|
||||||
|
/* 54 */ { variant: 3, sound: 3, move: 0, mood: 1, color: 4, counter: -1 },
|
||||||
|
/* 55 */ { variant: 3, sound: 3, move: 0, mood: 1, color: 4, counter: -1 },
|
||||||
|
/* 56 */ { variant: 3, sound: 3, move: 0, mood: 1, color: 4, counter: -1 },
|
||||||
|
/* 57 */ { variant: 3, sound: 3, move: 0, mood: 1, color: 4, counter: -1 },
|
||||||
|
/* 58 */ { variant: 3, sound: 3, move: 0, mood: 1, color: 4, counter: -1 },
|
||||||
|
/* 59 */ { variant: 3, sound: 3, move: 0, mood: 1, color: 4, counter: -1 },
|
||||||
|
/* 60 */ { variant: 3, sound: 3, move: 0, mood: 1, color: 4, counter: -1 },
|
||||||
|
/* 61 */ { variant: 3, sound: 3, move: 0, mood: 1, color: 4, counter: -1 },
|
||||||
|
/* 62 */ { variant: 3, sound: 3, move: 0, mood: 1, color: 4, counter: -1 },
|
||||||
|
/* 63 */ { variant: 3, sound: 3, move: 0, mood: 1, color: 4, counter: -1 },
|
||||||
|
/* 64 */ { variant: 3, sound: 3, move: 0, mood: 1, color: 4, counter: -1 },
|
||||||
|
/* 65 */ { variant: 3, sound: 3, move: 0, mood: 1, color: 4, counter: -1 },
|
||||||
|
/* 66 */ { variant: 3, sound: 3, move: 0, mood: 1, color: 4, counter: -1 },
|
||||||
|
/* 67 */ { variant: 3, sound: 3, move: 0, mood: 1, color: 4, counter: -1 },
|
||||||
|
/* 68 */ { variant: 3, sound: 3, move: 0, mood: 1, color: 4, counter: -1 },
|
||||||
|
/* 69 */ { variant: 1, sound: 3, move: 0, mood: 1, color: 4, counter: -1 },
|
||||||
|
/* 70 */ { variant: 1, sound: 3, move: 0, mood: 1, color: 4, counter: -1 },
|
||||||
|
/* 71 */ { variant: 1, sound: 3, move: 0, mood: 1, color: 4, counter: -1 },
|
||||||
|
/* 72 */ { variant: 1, sound: 3, move: 0, mood: 1, color: 4, counter: -1 },
|
||||||
|
/* 73 */ { variant: 1, sound: 3, move: 0, mood: 1, color: 4, counter: -1 },
|
||||||
|
/* 74 */ { variant: 1, sound: 3, move: 0, mood: 1, color: 4, counter: -1 },
|
||||||
|
/* 75 */ { variant: 0, sound: 3, move: 0, mood: 1, color: 3, counter: -1 },
|
||||||
|
/* 76 */ { variant: 0, sound: 3, move: 0, mood: 1, color: 2, counter: -1 },
|
||||||
|
/* 77 */ { variant: 0, sound: 3, move: 0, mood: 1, color: 3, counter: -1 },
|
||||||
|
/* 78 */ { variant: 0, sound: 3, move: 0, mood: 1, color: 2, counter: -1 },
|
||||||
|
/* 79 */ { variant: 0, sound: 3, move: 0, mood: 1, color: 2, counter: -1 },
|
||||||
|
/* 80 */ { variant: 0, sound: 3, move: 0, mood: 1, color: 3, counter: -1 }
|
||||||
|
]);
|
||||||
@ -4,6 +4,13 @@
|
|||||||
|
|
||||||
export let gap = 10;
|
export let gap = 10;
|
||||||
|
|
||||||
|
// Optional selection-based navigation. When provided, nav buttons
|
||||||
|
// change the selected item instead of scrolling.
|
||||||
|
export let onPrev = null;
|
||||||
|
export let onNext = null;
|
||||||
|
export let hasPrev = undefined;
|
||||||
|
export let hasNext = undefined;
|
||||||
|
|
||||||
let trackRef;
|
let trackRef;
|
||||||
let canScrollLeft = false;
|
let canScrollLeft = false;
|
||||||
let canScrollRight = false;
|
let canScrollRight = false;
|
||||||
@ -14,6 +21,9 @@
|
|||||||
// Exposed so parent can check if a drag occurred (to prevent click handling)
|
// Exposed so parent can check if a drag occurred (to prevent click handling)
|
||||||
export let hasDragged = false;
|
export let hasDragged = false;
|
||||||
|
|
||||||
|
$: leftDisabled = hasPrev !== undefined ? !hasPrev : !canScrollLeft;
|
||||||
|
$: rightDisabled = hasNext !== undefined ? !hasNext : !canScrollRight;
|
||||||
|
|
||||||
function updateArrows() {
|
function updateArrows() {
|
||||||
if (!trackRef) return;
|
if (!trackRef) return;
|
||||||
const { scrollLeft, scrollWidth, clientWidth } = trackRef;
|
const { scrollLeft, scrollWidth, clientWidth } = trackRef;
|
||||||
@ -21,12 +31,40 @@
|
|||||||
canScrollRight = scrollLeft + clientWidth < scrollWidth - 1;
|
canScrollRight = scrollLeft + clientWidth < scrollWidth - 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
function scrollLeft() {
|
function handleLeft() {
|
||||||
trackRef?.scrollBy({ left: -200, behavior: 'smooth' });
|
if (onPrev) {
|
||||||
|
onPrev();
|
||||||
|
} else {
|
||||||
|
trackRef?.scrollBy({ left: -200, behavior: 'smooth' });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function scrollRight() {
|
function handleRight() {
|
||||||
trackRef?.scrollBy({ left: 200, behavior: 'smooth' });
|
if (onNext) {
|
||||||
|
onNext();
|
||||||
|
} else {
|
||||||
|
trackRef?.scrollBy({ left: 200, behavior: 'smooth' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function scrollChildIntoView(child) {
|
||||||
|
const trackRect = trackRef.getBoundingClientRect();
|
||||||
|
const childRect = child.getBoundingClientRect();
|
||||||
|
const isFullyVisible = childRect.left >= trackRect.left && childRect.right <= trackRect.right;
|
||||||
|
|
||||||
|
if (!isFullyVisible) {
|
||||||
|
const scrollTarget = childRect.left < trackRect.left
|
||||||
|
? trackRef.scrollLeft - (trackRect.left - childRect.left)
|
||||||
|
: trackRef.scrollLeft + (childRect.right - trackRect.right);
|
||||||
|
trackRef.scrollTo({ left: scrollTarget, behavior: 'smooth' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Scroll the nth child (0-indexed) into view */
|
||||||
|
export function scrollToIndex(index) {
|
||||||
|
if (!trackRef) return;
|
||||||
|
const child = trackRef.children[index];
|
||||||
|
if (child) scrollChildIntoView(child);
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleMouseDown(e) {
|
function handleMouseDown(e) {
|
||||||
@ -55,24 +93,9 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function handleClick(e) {
|
function handleClick(e) {
|
||||||
// Find the direct child element that was clicked
|
|
||||||
const clickedCard = e.target.closest('.carousel-track > *');
|
const clickedCard = e.target.closest('.carousel-track > *');
|
||||||
if (!clickedCard || hasDragged) return;
|
if (!clickedCard || hasDragged) return;
|
||||||
|
scrollChildIntoView(clickedCard);
|
||||||
const trackRect = trackRef.getBoundingClientRect();
|
|
||||||
const cardRect = clickedCard.getBoundingClientRect();
|
|
||||||
|
|
||||||
// Check if card is fully visible
|
|
||||||
const isFullyVisible = cardRect.left >= trackRect.left && cardRect.right <= trackRect.right;
|
|
||||||
|
|
||||||
if (!isFullyVisible) {
|
|
||||||
// Scroll to bring card into view
|
|
||||||
const scrollLeft = cardRect.left < trackRect.left
|
|
||||||
? trackRef.scrollLeft - (trackRect.left - cardRect.left)
|
|
||||||
: trackRef.scrollLeft + (cardRect.right - trackRect.right);
|
|
||||||
|
|
||||||
trackRef.scrollTo({ left: scrollLeft, behavior: 'smooth' });
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
@ -86,7 +109,7 @@
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="carousel">
|
<div class="carousel">
|
||||||
<NavButton direction="left" onclick={scrollLeft} disabled={!canScrollLeft} />
|
<NavButton direction="left" onclick={handleLeft} disabled={leftDisabled} />
|
||||||
<!-- svelte-ignore a11y_click_events_have_key_events a11y_no_static_element_interactions a11y_no_noninteractive_element_interactions -->
|
<!-- svelte-ignore a11y_click_events_have_key_events a11y_no_static_element_interactions a11y_no_noninteractive_element_interactions -->
|
||||||
<div
|
<div
|
||||||
class="carousel-track"
|
class="carousel-track"
|
||||||
@ -103,7 +126,7 @@
|
|||||||
>
|
>
|
||||||
<slot />
|
<slot />
|
||||||
</div>
|
</div>
|
||||||
<NavButton direction="right" onclick={scrollRight} disabled={!canScrollRight} />
|
<NavButton direction="right" onclick={handleRight} disabled={rightDisabled} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
|
|||||||
@ -39,10 +39,12 @@
|
|||||||
{ type: 'New', text: 'Vehicle Part Editor enables modifying vehicle parts and colors with a 3D preview' },
|
{ type: 'New', text: 'Vehicle Part Editor enables modifying vehicle parts and colors with a 3D preview' },
|
||||||
{ type: 'New', text: 'Vehicle Texture Editor lets you customize vehicle textures with default presets or your own uploaded images' },
|
{ type: 'New', text: 'Vehicle Texture Editor lets you customize vehicle textures with default presets or your own uploaded images' },
|
||||||
{ type: 'New', text: 'Actor Editor with animated 3D character preview — customize hats, colors, moods, sounds, and moves for all 66 game actors' },
|
{ type: 'New', text: 'Actor Editor with animated 3D character preview — customize hats, colors, moods, sounds, and moves for all 66 game actors' },
|
||||||
|
{ type: 'New', text: 'Plant Editor lets you browse and customize all 81 island plants — change variants, colors, moods, sounds, and moves with click interactions that match the original in-game behavior per character' },
|
||||||
{ type: 'New', text: 'Vehicle rendering in Actor Editor — toggle to see actors with their assigned vehicles' },
|
{ type: 'New', text: 'Vehicle rendering in Actor Editor — toggle to see actors with their assigned vehicles' },
|
||||||
{ type: 'New', text: 'Click animations and sound effects in Actor Editor matching the original game behavior' },
|
{ type: 'New', text: 'Click animations and sound effects in Actor and Plant Editors matching the original game behavior' },
|
||||||
{ type: 'New', text: 'Drag-to-orbit, zoom, and pan controls on all 3D previews (vehicle, actor, and score cube editors)' },
|
{ type: 'New', text: 'Drag-to-orbit, zoom, and pan controls on all 3D previews (vehicle, actor, plant, and score cube editors)' },
|
||||||
{ type: 'New', text: 'Camera reset button on 3D editors to restore the default view' },
|
{ type: 'New', text: 'Camera reset button on 3D editors to restore the default view' },
|
||||||
|
{ type: 'Improved', text: 'Save Editor tabs now use a carousel with arrow navigation for easier browsing on small screens' },
|
||||||
{ type: 'Fixed', text: 'Sticky hover highlights on touch devices for editor buttons' }
|
{ type: 'Fixed', text: 'Sticky hover highlights on touch devices for editor buttons' }
|
||||||
]},
|
]},
|
||||||
{ id: 'cl1', title: 'January 2026', items: [
|
{ id: 'cl1', title: 'January 2026', items: [
|
||||||
|
|||||||
@ -7,6 +7,7 @@
|
|||||||
import LightPositionEditor from './save-editor/LightPositionEditor.svelte';
|
import LightPositionEditor from './save-editor/LightPositionEditor.svelte';
|
||||||
import VehicleEditor from './save-editor/VehicleEditor.svelte';
|
import VehicleEditor from './save-editor/VehicleEditor.svelte';
|
||||||
import ActorEditor from './save-editor/ActorEditor.svelte';
|
import ActorEditor from './save-editor/ActorEditor.svelte';
|
||||||
|
import PlantEditor from './save-editor/PlantEditor.svelte';
|
||||||
import { fetchBitmapAsURL } from '../core/assetLoader.js';
|
import { fetchBitmapAsURL } from '../core/assetLoader.js';
|
||||||
import { saveEditorState, currentPage } from '../stores.js';
|
import { saveEditorState, currentPage } from '../stores.js';
|
||||||
import { listSaveSlots, updateSaveSlot, updatePlayerName } from '../core/savegame/index.js';
|
import { listSaveSlots, updateSaveSlot, updatePlayerName } from '../core/savegame/index.js';
|
||||||
@ -26,7 +27,8 @@
|
|||||||
{ id: 'scores', label: 'Scores', firstSection: null },
|
{ id: 'scores', label: 'Scores', firstSection: null },
|
||||||
{ id: 'island', label: 'Island', firstSection: 'skycolor' },
|
{ id: 'island', label: 'Island', firstSection: 'skycolor' },
|
||||||
{ id: 'vehicles', label: 'Vehicles', firstSection: null },
|
{ id: 'vehicles', label: 'Vehicles', firstSection: null },
|
||||||
{ id: 'actors', label: 'Actors', firstSection: null }
|
{ id: 'actors', label: 'Actors', firstSection: null },
|
||||||
|
{ id: 'plants', label: 'Plants', firstSection: null }
|
||||||
];
|
];
|
||||||
|
|
||||||
// Reset state when navigating to this page
|
// Reset state when navigating to this page
|
||||||
@ -51,6 +53,46 @@
|
|||||||
|
|
||||||
// Carousel state (bound from Carousel component)
|
// Carousel state (bound from Carousel component)
|
||||||
let carouselHasDragged = false;
|
let carouselHasDragged = false;
|
||||||
|
let slotCarousel;
|
||||||
|
let tabCarousel;
|
||||||
|
|
||||||
|
// Slot carousel navigation
|
||||||
|
$: selectedSlotIndex = existingSlots.findIndex(s => s.slotNumber === selectedSlot);
|
||||||
|
$: hasSlotPrev = selectedSlotIndex > 0;
|
||||||
|
$: hasSlotNext = selectedSlotIndex >= 0 && selectedSlotIndex < existingSlots.length - 1;
|
||||||
|
|
||||||
|
function selectPrevSlot() {
|
||||||
|
if (!hasSlotPrev) return;
|
||||||
|
const newIdx = selectedSlotIndex - 1;
|
||||||
|
handleSlotSelect(existingSlots[newIdx].slotNumber);
|
||||||
|
slotCarousel?.scrollToIndex(newIdx);
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectNextSlot() {
|
||||||
|
if (!hasSlotNext) return;
|
||||||
|
const newIdx = selectedSlotIndex + 1;
|
||||||
|
handleSlotSelect(existingSlots[newIdx].slotNumber);
|
||||||
|
slotCarousel?.scrollToIndex(newIdx);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tab carousel navigation
|
||||||
|
$: activeTabIndex = saveTabs.findIndex(t => t.id === activeTab);
|
||||||
|
$: hasTabPrev = activeTabIndex > 0;
|
||||||
|
$: hasTabNext = activeTabIndex < saveTabs.length - 1;
|
||||||
|
|
||||||
|
function selectPrevTab() {
|
||||||
|
if (!hasTabPrev) return;
|
||||||
|
const newIdx = activeTabIndex - 1;
|
||||||
|
switchTab(saveTabs[newIdx]);
|
||||||
|
tabCarousel?.scrollToIndex(newIdx);
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectNextTab() {
|
||||||
|
if (!hasTabNext) return;
|
||||||
|
const newIdx = activeTabIndex + 1;
|
||||||
|
switchTab(saveTabs[newIdx]);
|
||||||
|
tabCarousel?.scrollToIndex(newIdx);
|
||||||
|
}
|
||||||
|
|
||||||
onMount(async () => {
|
onMount(async () => {
|
||||||
await loadSlots();
|
await loadSlots();
|
||||||
@ -139,7 +181,7 @@
|
|||||||
if (updated) {
|
if (updated) {
|
||||||
slots = slots.map(s =>
|
slots = slots.map(s =>
|
||||||
s.slotNumber === selectedSlot
|
s.slotNumber === selectedSlot
|
||||||
? { ...s, variables: updated.variables, act1State: updated.act1State, characters: updated.characters }
|
? { ...s, variables: updated.variables, act1State: updated.act1State, characters: updated.characters, plants: updated.plants }
|
||||||
: s
|
: s
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -276,7 +318,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="config-main">
|
<div class="config-main">
|
||||||
{#if loading || error || existingSlots.length > 0}
|
{#if loading || error || existingSlots.length > 0}
|
||||||
<Carousel bind:hasDragged={carouselHasDragged}>
|
<Carousel bind:this={slotCarousel} bind:hasDragged={carouselHasDragged} onPrev={selectPrevSlot} onNext={selectNextSlot} hasPrev={hasSlotPrev} hasNext={hasSlotNext}>
|
||||||
{#if loading}
|
{#if loading}
|
||||||
<span class="save-status-text">Loading save files...</span>
|
<span class="save-status-text">Loading save files...</span>
|
||||||
{:else if error}
|
{:else if error}
|
||||||
@ -324,16 +366,18 @@
|
|||||||
|
|
||||||
{#if currentSlot && currentSlot.exists}
|
{#if currentSlot && currentSlot.exists}
|
||||||
<div class="config-tabs">
|
<div class="config-tabs">
|
||||||
<div class="config-tab-buttons">
|
<div class="config-tab-buttons tab-carousel-wrapper">
|
||||||
{#each saveTabs as tab}
|
<Carousel bind:this={tabCarousel} gap={5} onPrev={selectPrevTab} onNext={selectNextTab} hasPrev={hasTabPrev} hasNext={hasTabNext}>
|
||||||
<button
|
{#each saveTabs as tab}
|
||||||
class="config-tab-btn"
|
<button
|
||||||
class:active={activeTab === tab.id}
|
class="config-tab-btn"
|
||||||
onclick={() => switchTab(tab)}
|
class:active={activeTab === tab.id}
|
||||||
>
|
onclick={() => switchTab(tab)}
|
||||||
{tab.label}
|
>
|
||||||
</button>
|
{tab.label}
|
||||||
{/each}
|
</button>
|
||||||
|
{/each}
|
||||||
|
</Carousel>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Player Tab -->
|
<!-- Player Tab -->
|
||||||
@ -435,6 +479,13 @@
|
|||||||
<ActorEditor slot={currentSlot} onUpdate={handleVariableUpdate} />
|
<ActorEditor slot={currentSlot} onUpdate={handleVariableUpdate} />
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Plants Tab -->
|
||||||
|
<div class:hidden={activeTab !== 'plants'}>
|
||||||
|
{#if $currentPage === 'save-editor'}
|
||||||
|
<PlantEditor slot={currentSlot} onUpdate={handleVariableUpdate} />
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
@ -598,6 +649,42 @@
|
|||||||
image-rendering: pixelated;
|
image-rendering: pixelated;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.tab-carousel-wrapper {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-carousel-wrapper :global(.carousel) {
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-carousel-wrapper :global(.carousel-track) {
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-carousel-wrapper :global(.carousel-track.dragging) {
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.tab-carousel-wrapper {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-carousel-wrapper :global(.carousel) {
|
||||||
|
display: contents;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-carousel-wrapper :global(.nav-btn) {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-carousel-wrapper :global(.carousel-track) {
|
||||||
|
display: contents;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@media (max-width: 400px) {
|
@media (max-width: 400px) {
|
||||||
.name-slot {
|
.name-slot {
|
||||||
width: 32px;
|
width: 32px;
|
||||||
|
|||||||
@ -4,10 +4,11 @@
|
|||||||
import { WdbParser, buildGlobalPartsMap, buildPartsMap, resolveLods } from '../../core/formats/WdbParser.js';
|
import { WdbParser, buildGlobalPartsMap, buildPartsMap, resolveLods } from '../../core/formats/WdbParser.js';
|
||||||
import { ActorInfoInit, ActorPart, ActorDisplayNames, ActorVehicles, VehicleDisplayNames } from '../../core/savegame/actorConstants.js';
|
import { ActorInfoInit, ActorPart, ActorDisplayNames, ActorVehicles, VehicleDisplayNames } from '../../core/savegame/actorConstants.js';
|
||||||
import { Actor } from '../../core/savegame/constants.js';
|
import { Actor } from '../../core/savegame/constants.js';
|
||||||
import { fetchSoundAsWav } from '../../core/assetLoader.js';
|
import { createSoundPlayer } from '../../core/audio.js';
|
||||||
import NavButton from '../NavButton.svelte';
|
import NavButton from '../NavButton.svelte';
|
||||||
import ResetButton from '../ResetButton.svelte';
|
import ResetButton from '../ResetButton.svelte';
|
||||||
import EditorTooltip from '../EditorTooltip.svelte';
|
import EditorTooltip from '../EditorTooltip.svelte';
|
||||||
|
import './editor-common.css';
|
||||||
|
|
||||||
export let slot;
|
export let slot;
|
||||||
export let onUpdate = () => {};
|
export let onUpdate = () => {};
|
||||||
@ -27,38 +28,7 @@
|
|||||||
let loadedActorKey = null;
|
let loadedActorKey = null;
|
||||||
let showVehicle = false;
|
let showVehicle = false;
|
||||||
|
|
||||||
let audioContext = null;
|
const soundPlayer = createSoundPlayer();
|
||||||
let gainNode = null;
|
|
||||||
const soundCache = new Map();
|
|
||||||
|
|
||||||
async function playSound(name) {
|
|
||||||
try {
|
|
||||||
if (!audioContext) {
|
|
||||||
audioContext = new AudioContext();
|
|
||||||
gainNode = audioContext.createGain();
|
|
||||||
gainNode.gain.value = 0.3;
|
|
||||||
gainNode.connect(audioContext.destination);
|
|
||||||
}
|
|
||||||
if (audioContext.state === 'suspended') {
|
|
||||||
await audioContext.resume();
|
|
||||||
}
|
|
||||||
|
|
||||||
let audioBuffer = soundCache.get(name);
|
|
||||||
if (!audioBuffer) {
|
|
||||||
const wav = await fetchSoundAsWav(name);
|
|
||||||
if (!wav) return;
|
|
||||||
audioBuffer = await audioContext.decodeAudioData(wav);
|
|
||||||
soundCache.set(name, audioBuffer);
|
|
||||||
}
|
|
||||||
|
|
||||||
const source = audioContext.createBufferSource();
|
|
||||||
source.buffer = audioBuffer;
|
|
||||||
source.connect(gainNode);
|
|
||||||
source.start();
|
|
||||||
} catch (e) {
|
|
||||||
console.error(`Failed to play sound ${name}:`, e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
$: actorInfo = ActorInfoInit[actorIndex];
|
$: actorInfo = ActorInfoInit[actorIndex];
|
||||||
$: actorName = ActorDisplayNames[actorIndex] || actorInfo?.name || 'Unknown';
|
$: actorName = ActorDisplayNames[actorIndex] || actorInfo?.name || 'Unknown';
|
||||||
@ -166,7 +136,7 @@
|
|||||||
|
|
||||||
onDestroy(() => {
|
onDestroy(() => {
|
||||||
renderer?.dispose();
|
renderer?.dispose();
|
||||||
audioContext?.close();
|
soundPlayer.dispose();
|
||||||
});
|
});
|
||||||
|
|
||||||
// Reload actor when index, character state, or vehicle toggle changes
|
// Reload actor when index, character state, or vehicle toggle changes
|
||||||
@ -223,11 +193,11 @@
|
|||||||
const soundIdx = playerId === Actor.MAMA
|
const soundIdx = playerId === Actor.MAMA
|
||||||
? (charState.sound + 1) % 9
|
? (charState.sound + 1) % 9
|
||||||
: charState.sound;
|
: charState.sound;
|
||||||
playSound(`ClickSound${soundIdx}`);
|
soundPlayer.play(`ClickSound${soundIdx}`);
|
||||||
|
|
||||||
// Laura additionally plays a mood sound
|
// Laura additionally plays a mood sound
|
||||||
if (playerId === Actor.LAURA) {
|
if (playerId === Actor.LAURA) {
|
||||||
playSound(`MoodSound${(charState.mood + 1) % 4}`);
|
soundPlayer.play(`MoodSound${(charState.mood + 1) % 4}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Queue click animation — consumed by loadAnimationForActor
|
// Queue click animation — consumed by loadAnimationForActor
|
||||||
@ -359,15 +329,15 @@
|
|||||||
<div class="part-nav">
|
<div class="part-nav">
|
||||||
<NavButton direction="left" onclick={prevActor} />
|
<NavButton direction="left" onclick={prevActor} />
|
||||||
<div class="part-info">
|
<div class="part-info">
|
||||||
<span class="actor-index">{actorIndex + 1} / {ActorInfoInit.length}</span>
|
<span class="nav-index">{actorIndex + 1} / {ActorInfoInit.length}</span>
|
||||||
<span class="actor-name">{actorName}</span>
|
<span class="nav-name">{actorName}</span>
|
||||||
</div>
|
</div>
|
||||||
<NavButton direction="right" onclick={nextActor} />
|
<NavButton direction="right" onclick={nextActor} />
|
||||||
</div>
|
</div>
|
||||||
{#if vehicleInfo}
|
{#if vehicleInfo}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="vehicle-toggle-btn"
|
class="side-btn"
|
||||||
class:active={showVehicle}
|
class:active={showVehicle}
|
||||||
onclick={() => { showVehicle = !showVehicle; }}
|
onclick={() => { showVehicle = !showVehicle; }}
|
||||||
title={showVehicle ? 'Show without vehicle' : `Show with ${vehicleName}`}
|
title={showVehicle ? 'Show without vehicle' : `Show with ${vehicleName}`}
|
||||||
@ -386,127 +356,3 @@
|
|||||||
</div>
|
</div>
|
||||||
</EditorTooltip>
|
</EditorTooltip>
|
||||||
|
|
||||||
<style>
|
|
||||||
.preview-container {
|
|
||||||
position: relative;
|
|
||||||
}
|
|
||||||
|
|
||||||
canvas {
|
|
||||||
display: block;
|
|
||||||
border-radius: 8px;
|
|
||||||
cursor: grab;
|
|
||||||
max-width: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
canvas:active {
|
|
||||||
cursor: grabbing;
|
|
||||||
}
|
|
||||||
|
|
||||||
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 {
|
|
||||||
position: relative;
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
|
|
||||||
.vehicle-toggle-btn {
|
|
||||||
position: absolute;
|
|
||||||
right: -36px;
|
|
||||||
top: 50%;
|
|
||||||
transform: translateY(-50%);
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
width: 28px;
|
|
||||||
height: 28px;
|
|
||||||
padding: 0;
|
|
||||||
background: var(--color-bg-input);
|
|
||||||
border: 1px solid var(--color-border-medium);
|
|
||||||
border-radius: 6px;
|
|
||||||
color: var(--color-text-light);
|
|
||||||
cursor: pointer;
|
|
||||||
transition: all 0.2s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.vehicle-toggle-btn.active {
|
|
||||||
border-color: var(--color-primary);
|
|
||||||
color: var(--color-primary);
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (hover: hover) {
|
|
||||||
.vehicle-toggle-btn:hover {
|
|
||||||
border-color: var(--color-primary);
|
|
||||||
color: var(--color-primary);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.reset-container {
|
|
||||||
height: 1.6em;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
|
|||||||
289
src/lib/save-editor/PlantEditor.svelte
Normal file
289
src/lib/save-editor/PlantEditor.svelte
Normal file
@ -0,0 +1,289 @@
|
|||||||
|
<script>
|
||||||
|
import { onMount, onDestroy } from 'svelte';
|
||||||
|
import { PlantRenderer } from '../../core/rendering/PlantRenderer.js';
|
||||||
|
import { WdbParser, buildGlobalPartsMap, buildPartsMap } from '../../core/formats/WdbParser.js';
|
||||||
|
import {
|
||||||
|
PlantInfoInit, PlantVariantNames, PlantColorNames,
|
||||||
|
PLANT_COUNT, MAX_SOUND, MAX_MOVE, MAX_MOOD, MAX_COLOR, MAX_VARIANT,
|
||||||
|
PLANT_SOUND_OFFSET
|
||||||
|
} from '../../core/savegame/plantConstants.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 plantPartsMap = null;
|
||||||
|
let plantTextures = null;
|
||||||
|
|
||||||
|
let plantIndex = 0;
|
||||||
|
let loadedPlantKey = null;
|
||||||
|
|
||||||
|
const soundPlayer = createSoundPlayer();
|
||||||
|
|
||||||
|
$: plantState = slot?.plants?.[plantIndex];
|
||||||
|
$: variantName = plantState ? PlantVariantNames[plantState.variant] || 'Unknown' : '';
|
||||||
|
$: colorName = plantState ? PlantColorNames[plantState.color] || 'Unknown' : '';
|
||||||
|
|
||||||
|
$: isDefault = plantState && (() => {
|
||||||
|
const def = PlantInfoInit[plantIndex];
|
||||||
|
return def &&
|
||||||
|
plantState.variant === def.variant &&
|
||||||
|
plantState.sound === def.sound &&
|
||||||
|
plantState.move === def.move &&
|
||||||
|
plantState.mood === def.mood &&
|
||||||
|
plantState.color === def.color &&
|
||||||
|
plantState.counter === def.counter;
|
||||||
|
})();
|
||||||
|
|
||||||
|
function plantKey(slotNumber, idx, ps) {
|
||||||
|
if (!ps) return '';
|
||||||
|
return `${slotNumber}-${idx}-${ps.variant}-${ps.color}-${ps.mood}-${ps.counter}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
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();
|
||||||
|
|
||||||
|
// Plant LODs are stored in ISLE world parts and/or global parts
|
||||||
|
const partsMap = new Map();
|
||||||
|
|
||||||
|
// Add global parts first
|
||||||
|
if (wdbData.globalParts) {
|
||||||
|
const globalMap = buildGlobalPartsMap(wdbData.globalParts);
|
||||||
|
for (const [k, v] of globalMap) {
|
||||||
|
partsMap.set(k, v);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Scan ISLE world parts for plant LODs
|
||||||
|
for (const world of wdbData.worlds) {
|
||||||
|
if (world.name.toUpperCase() === 'ISLE') {
|
||||||
|
const worldMap = buildPartsMap(wdbParser, world.parts);
|
||||||
|
for (const [k, v] of worldMap) {
|
||||||
|
if (!partsMap.has(k)) {
|
||||||
|
partsMap.set(k, v);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
plantPartsMap = partsMap;
|
||||||
|
|
||||||
|
// Collect textures from global parts
|
||||||
|
plantTextures = [
|
||||||
|
...(wdbData.globalTextures || []),
|
||||||
|
...(wdbData.globalParts?.textures || [])
|
||||||
|
];
|
||||||
|
|
||||||
|
renderer = new PlantRenderer(canvas);
|
||||||
|
loadCurrentPlant();
|
||||||
|
renderer.start();
|
||||||
|
loading = false;
|
||||||
|
} catch (e) {
|
||||||
|
console.error('PlantEditor initialization error:', e);
|
||||||
|
error = e.message;
|
||||||
|
loading = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
onDestroy(() => {
|
||||||
|
renderer?.dispose();
|
||||||
|
soundPlayer.dispose();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Reload plant when index or state changes
|
||||||
|
$: if (renderer && !loading && plantState) {
|
||||||
|
if (plantKey(slot?.slotNumber, plantIndex, plantState) !== loadedPlantKey) {
|
||||||
|
loadCurrentPlant();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadCurrentPlant() {
|
||||||
|
if (!renderer || !plantPartsMap || !plantState) return;
|
||||||
|
|
||||||
|
renderer.loadPlant(plantState.variant, plantState.color, plantPartsMap, plantTextures);
|
||||||
|
loadedPlantKey = plantKey(slot?.slotNumber, plantIndex, plantState);
|
||||||
|
|
||||||
|
// Play queued click animation if any
|
||||||
|
renderer.playQueuedAnimation();
|
||||||
|
}
|
||||||
|
|
||||||
|
function prevPlant() {
|
||||||
|
plantIndex = plantIndex > 0 ? plantIndex - 1 : PLANT_COUNT - 1;
|
||||||
|
loadedPlantKey = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function nextPlant() {
|
||||||
|
plantIndex = plantIndex < PLANT_COUNT - 1 ? plantIndex + 1 : 0;
|
||||||
|
loadedPlantKey = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleCanvasClick(event) {
|
||||||
|
if (!renderer || !slot?.plants || !plantState) return;
|
||||||
|
if (renderer.wasDragged()) return;
|
||||||
|
|
||||||
|
const playerId = slot.header?.actorId;
|
||||||
|
let acted = false;
|
||||||
|
|
||||||
|
switch (playerId) {
|
||||||
|
case Actor.PEPPER: switchVariant(); acted = true; break;
|
||||||
|
case Actor.MAMA: switchSound(); acted = true; break;
|
||||||
|
case Actor.PAPA: switchMove(); acted = true; break;
|
||||||
|
case Actor.NICK: switchColor(); acted = true; break;
|
||||||
|
case Actor.LAURA: switchMood(); acted = true; break;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!acted) return;
|
||||||
|
|
||||||
|
// Play click sound: objectId = sound + 56
|
||||||
|
const soundIdx = playerId === Actor.MAMA
|
||||||
|
? (plantState.sound + 1) % MAX_SOUND
|
||||||
|
: plantState.sound;
|
||||||
|
const soundObjectId = soundIdx + PLANT_SOUND_OFFSET;
|
||||||
|
// ClickSound6/7/8 cover objectIds 56-58, PlantSound3-7 cover 59-63
|
||||||
|
if (soundObjectId <= 58) {
|
||||||
|
soundPlayer.play(`ClickSound${soundObjectId - 50}`);
|
||||||
|
} else {
|
||||||
|
soundPlayer.play(`PlantSound${soundIdx}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Laura additionally plays a mood sound
|
||||||
|
if (playerId === Actor.LAURA) {
|
||||||
|
soundPlayer.play(`MoodSound${((plantState.mood + 1) % MAX_MOOD) & 1}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Queue click animation for visual changes
|
||||||
|
const variant = playerId === Actor.PEPPER
|
||||||
|
? (plantState.variant + 1) % MAX_VARIANT
|
||||||
|
: plantState.variant;
|
||||||
|
const move = playerId === Actor.PAPA
|
||||||
|
? (plantState.move + 1) % MAX_MOVE[plantState.variant]
|
||||||
|
: plantState.move;
|
||||||
|
renderer.queueClickAnimation(variant, move);
|
||||||
|
|
||||||
|
// For sound/move changes, play animation directly since model won't reload
|
||||||
|
if (playerId === Actor.MAMA || playerId === Actor.PAPA) {
|
||||||
|
renderer.playQueuedAnimation();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function switchVariant() {
|
||||||
|
const nextVariant = (plantState.variant + 1) % MAX_VARIANT;
|
||||||
|
// Clamp move if it exceeds the new variant's max
|
||||||
|
const clampedMove = plantState.move >= MAX_MOVE[nextVariant]
|
||||||
|
? MAX_MOVE[nextVariant] - 1
|
||||||
|
: plantState.move;
|
||||||
|
|
||||||
|
const updates = [
|
||||||
|
{ plantIndex, field: 'variant', value: nextVariant }
|
||||||
|
];
|
||||||
|
if (clampedMove !== plantState.move) {
|
||||||
|
updates.push({ plantIndex, field: 'move', value: clampedMove });
|
||||||
|
}
|
||||||
|
onUpdate({ plant: updates.length === 1 ? updates[0] : updates });
|
||||||
|
}
|
||||||
|
|
||||||
|
function switchSound() {
|
||||||
|
const nextSound = (plantState.sound + 1) % MAX_SOUND;
|
||||||
|
onUpdate({
|
||||||
|
plant: { plantIndex, field: 'sound', value: nextSound }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function switchMove() {
|
||||||
|
const nextMove = (plantState.move + 1) % MAX_MOVE[plantState.variant];
|
||||||
|
onUpdate({
|
||||||
|
plant: { plantIndex, field: 'move', value: nextMove }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function switchColor() {
|
||||||
|
const nextColor = (plantState.color + 1) % MAX_COLOR;
|
||||||
|
onUpdate({
|
||||||
|
plant: { plantIndex, field: 'color', value: nextColor }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function switchMood() {
|
||||||
|
const nextMood = (plantState.mood + 1) % MAX_MOOD;
|
||||||
|
onUpdate({
|
||||||
|
plant: { plantIndex, field: 'mood', value: nextMood }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetPlant() {
|
||||||
|
const def = PlantInfoInit[plantIndex];
|
||||||
|
if (!def) return;
|
||||||
|
|
||||||
|
onUpdate({
|
||||||
|
plant: [
|
||||||
|
{ plantIndex, field: 'variant', value: def.variant },
|
||||||
|
{ plantIndex, field: 'sound', value: def.sound },
|
||||||
|
{ plantIndex, field: 'move', value: def.move },
|
||||||
|
{ plantIndex, field: 'mood', value: def.mood },
|
||||||
|
{ plantIndex, field: 'color', value: def.color },
|
||||||
|
{ plantIndex, field: 'counter', value: def.counter }
|
||||||
|
]
|
||||||
|
});
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<EditorTooltip text="Click to customize based on your current character. Navigate between all 81 plants 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 plant"
|
||||||
|
></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={prevPlant} />
|
||||||
|
<div class="part-info">
|
||||||
|
<span class="nav-index">{plantIndex + 1} / {PLANT_COUNT}</span>
|
||||||
|
<span class="nav-name">{colorName} {variantName}</span>
|
||||||
|
</div>
|
||||||
|
<NavButton direction="right" onclick={nextPlant} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="reset-container">
|
||||||
|
{#if !isDefault && !loading && !error}
|
||||||
|
<ResetButton onclick={resetPlant} />
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</EditorTooltip>
|
||||||
|
|
||||||
@ -18,6 +18,7 @@
|
|||||||
import ResetButton from '../ResetButton.svelte';
|
import ResetButton from '../ResetButton.svelte';
|
||||||
import EditorTooltip from '../EditorTooltip.svelte';
|
import EditorTooltip from '../EditorTooltip.svelte';
|
||||||
import TexturePickerModal from './TexturePickerModal.svelte';
|
import TexturePickerModal from './TexturePickerModal.svelte';
|
||||||
|
import './editor-common.css';
|
||||||
|
|
||||||
export let slot;
|
export let slot;
|
||||||
export let onUpdate = () => {};
|
export let onUpdate = () => {};
|
||||||
@ -353,15 +354,15 @@
|
|||||||
<div class="part-nav">
|
<div class="part-nav">
|
||||||
<NavButton direction="left" onclick={prevPart} />
|
<NavButton direction="left" onclick={prevPart} />
|
||||||
<div class="part-info">
|
<div class="part-info">
|
||||||
<span class="vehicle-name">{VehicleNames[vehicle]}</span>
|
<span class="nav-index">{VehicleNames[vehicle]}</span>
|
||||||
<span class="part-name">{currentPart?.label || 'Unknown'}</span>
|
<span class="nav-name">{currentPart?.label || 'Unknown'}</span>
|
||||||
</div>
|
</div>
|
||||||
<NavButton direction="right" onclick={nextPart} />
|
<NavButton direction="right" onclick={nextPart} />
|
||||||
</div>
|
</div>
|
||||||
{#if textureInfo}
|
{#if textureInfo}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="texture-btn"
|
class="side-btn"
|
||||||
class:disabled={!canEditTexture}
|
class:disabled={!canEditTexture}
|
||||||
onclick={openTexturePicker}
|
onclick={openTexturePicker}
|
||||||
disabled={!canEditTexture}
|
disabled={!canEditTexture}
|
||||||
@ -391,127 +392,3 @@
|
|||||||
/>
|
/>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<style>
|
|
||||||
.preview-container {
|
|
||||||
position: relative;
|
|
||||||
}
|
|
||||||
|
|
||||||
canvas {
|
|
||||||
display: block;
|
|
||||||
border-radius: 8px;
|
|
||||||
cursor: grab;
|
|
||||||
max-width: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
canvas:active {
|
|
||||||
cursor: grabbing;
|
|
||||||
}
|
|
||||||
|
|
||||||
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 {
|
|
||||||
position: relative;
|
|
||||||
margin-top: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.part-nav {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.part-info {
|
|
||||||
text-align: center;
|
|
||||||
min-width: 100px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.vehicle-name {
|
|
||||||
display: block;
|
|
||||||
font-size: 0.7em;
|
|
||||||
color: var(--color-text-muted);
|
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: 0.5px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.part-name {
|
|
||||||
display: block;
|
|
||||||
font-size: 0.9em;
|
|
||||||
color: var(--color-text-light);
|
|
||||||
}
|
|
||||||
|
|
||||||
.reset-container {
|
|
||||||
height: 1.6em;
|
|
||||||
}
|
|
||||||
|
|
||||||
.texture-btn {
|
|
||||||
position: absolute;
|
|
||||||
right: -36px;
|
|
||||||
top: 50%;
|
|
||||||
transform: translateY(-50%);
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
width: 28px;
|
|
||||||
height: 28px;
|
|
||||||
padding: 0;
|
|
||||||
background: var(--color-bg-input);
|
|
||||||
border: 1px solid var(--color-border-medium);
|
|
||||||
border-radius: 6px;
|
|
||||||
color: var(--color-text-light);
|
|
||||||
cursor: pointer;
|
|
||||||
transition: all 0.2s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (hover: hover) {
|
|
||||||
.texture-btn:hover:not(.disabled) {
|
|
||||||
border-color: var(--color-primary);
|
|
||||||
color: var(--color-primary);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.texture-btn.disabled {
|
|
||||||
opacity: 0.35;
|
|
||||||
cursor: not-allowed;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
|
|||||||
129
src/lib/save-editor/editor-common.css
Normal file
129
src/lib/save-editor/editor-common.css
Normal file
@ -0,0 +1,129 @@
|
|||||||
|
/* Shared styles for VehicleEditor, ActorEditor, PlantEditor */
|
||||||
|
|
||||||
|
.preview-container {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-container canvas {
|
||||||
|
display: block;
|
||||||
|
border-radius: 8px;
|
||||||
|
cursor: grab;
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-container canvas:active {
|
||||||
|
cursor: grabbing;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-container canvas:focus {
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-container 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: editor-spin 1s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes editor-spin {
|
||||||
|
from { transform: rotate(0deg); }
|
||||||
|
to { transform: rotate(360deg); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.part-nav-wrapper {
|
||||||
|
position: relative;
|
||||||
|
margin-top: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.part-nav {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.part-info {
|
||||||
|
text-align: center;
|
||||||
|
min-width: 150px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-index {
|
||||||
|
display: block;
|
||||||
|
font-size: 0.7em;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-name {
|
||||||
|
display: block;
|
||||||
|
font-size: 0.9em;
|
||||||
|
color: var(--color-text-light);
|
||||||
|
}
|
||||||
|
|
||||||
|
.reset-container {
|
||||||
|
height: 1.6em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.side-btn {
|
||||||
|
position: absolute;
|
||||||
|
right: -36px;
|
||||||
|
top: 50%;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
padding: 0;
|
||||||
|
background: var(--color-bg-input);
|
||||||
|
border: 1px solid var(--color-border-medium);
|
||||||
|
border-radius: 6px;
|
||||||
|
color: var(--color-text-light);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (hover: hover) {
|
||||||
|
.side-btn:hover:not(.disabled) {
|
||||||
|
border-color: var(--color-primary);
|
||||||
|
color: var(--color-primary);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.side-btn.disabled {
|
||||||
|
opacity: 0.35;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.side-btn.active {
|
||||||
|
border-color: var(--color-primary);
|
||||||
|
color: var(--color-primary);
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user