mirror of
https://github.com/isledecomp/isle.pizza.git
synced 2026-02-28 13:57:38 +00:00
* Plant editor Add Plants tab to the save editor for browsing and editing all 81 plants. Click-to-customize based on selected character matches the original game behavior (Pepper→variant, Mama→sound, Papa→move, Nick→color, Laura→mood). Includes 3D preview with per-variant display tuning, click animations, sound playback, and reset to defaults. * Refactor shared animation code into AnimatedRenderer base class Extract duplicated animation infrastructure (clock, mixer, animation caching, raycaster, keyframe interpolation) from ActorRenderer and PlantRenderer into a new AnimatedRenderer intermediate class. Extract identical sound player code from both editors into createSoundPlayer() utility. Fix PlantRenderer interpolateVertex bug where scale keys had X incorrectly negated. Remove dead PLANT_ANIM_IDS export and redundant textures.clear() calls. * Extract shared editor CSS and fix vehicle nav spacing Move duplicated preview, spinner, navigation, and side-button styles from VehicleEditor, ActorEditor, and PlantEditor into a shared editor-common.css. Standardize class names (nav-index, nav-name, side-btn) and fix VehicleEditor part-info min-width (100px → 150px) to match the other editors. * Add carousel tabs and selection-based nav to save editor Wrap save editor tab buttons in a Carousel to prevent overflow on desktop. Carousel nav buttons now cycle through the selected item (save slot or tab) instead of scrolling, with auto-scroll-into-view. On mobile, tabs reflow with flex-wrap as before. * Update February changelog with plant editor and carousel navigation
163 lines
4.5 KiB
Svelte
163 lines
4.5 KiB
Svelte
<script>
|
|
import { onMount } from 'svelte';
|
|
import NavButton from './NavButton.svelte';
|
|
|
|
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 canScrollLeft = false;
|
|
let canScrollRight = false;
|
|
let isDragging = false;
|
|
let dragStartX = 0;
|
|
let scrollStartLeft = 0;
|
|
|
|
// Exposed so parent can check if a drag occurred (to prevent click handling)
|
|
export let hasDragged = false;
|
|
|
|
$: leftDisabled = hasPrev !== undefined ? !hasPrev : !canScrollLeft;
|
|
$: rightDisabled = hasNext !== undefined ? !hasNext : !canScrollRight;
|
|
|
|
function updateArrows() {
|
|
if (!trackRef) return;
|
|
const { scrollLeft, scrollWidth, clientWidth } = trackRef;
|
|
canScrollLeft = scrollLeft > 0;
|
|
canScrollRight = scrollLeft + clientWidth < scrollWidth - 1;
|
|
}
|
|
|
|
function handleLeft() {
|
|
if (onPrev) {
|
|
onPrev();
|
|
} else {
|
|
trackRef?.scrollBy({ left: -200, behavior: 'smooth' });
|
|
}
|
|
}
|
|
|
|
function handleRight() {
|
|
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) {
|
|
if (e.button !== 0) return;
|
|
isDragging = true;
|
|
hasDragged = false;
|
|
dragStartX = e.pageX;
|
|
scrollStartLeft = trackRef.scrollLeft;
|
|
trackRef.style.scrollBehavior = 'auto';
|
|
}
|
|
|
|
function handleMouseMove(e) {
|
|
if (!isDragging) return;
|
|
e.preventDefault();
|
|
const dx = e.pageX - dragStartX;
|
|
if (Math.abs(dx) > 5) {
|
|
hasDragged = true;
|
|
}
|
|
trackRef.scrollLeft = scrollStartLeft - dx;
|
|
}
|
|
|
|
function handleMouseUp() {
|
|
if (!isDragging) return;
|
|
isDragging = false;
|
|
trackRef.style.scrollBehavior = 'smooth';
|
|
}
|
|
|
|
function handleClick(e) {
|
|
const clickedCard = e.target.closest('.carousel-track > *');
|
|
if (!clickedCard || hasDragged) return;
|
|
scrollChildIntoView(clickedCard);
|
|
}
|
|
|
|
onMount(() => {
|
|
updateArrows();
|
|
|
|
const resizeObserver = new ResizeObserver(updateArrows);
|
|
resizeObserver.observe(trackRef);
|
|
|
|
return () => resizeObserver.disconnect();
|
|
});
|
|
</script>
|
|
|
|
<div class="carousel">
|
|
<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 -->
|
|
<div
|
|
class="carousel-track"
|
|
class:dragging={isDragging}
|
|
style="gap: {gap}px"
|
|
bind:this={trackRef}
|
|
role="group"
|
|
onscroll={updateArrows}
|
|
onclick={handleClick}
|
|
onmousedown={handleMouseDown}
|
|
onmousemove={handleMouseMove}
|
|
onmouseup={handleMouseUp}
|
|
onmouseleave={handleMouseUp}
|
|
>
|
|
<slot />
|
|
</div>
|
|
<NavButton direction="right" onclick={handleRight} disabled={rightDisabled} />
|
|
</div>
|
|
|
|
<style>
|
|
.carousel {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 8px;
|
|
contain: inline-size;
|
|
}
|
|
|
|
.carousel-track {
|
|
display: flex;
|
|
overflow-x: auto;
|
|
scrollbar-width: none;
|
|
-webkit-overflow-scrolling: touch;
|
|
scroll-behavior: smooth;
|
|
flex: 1;
|
|
min-width: 0;
|
|
}
|
|
|
|
.carousel-track::-webkit-scrollbar {
|
|
display: none;
|
|
}
|
|
|
|
.carousel-track.dragging {
|
|
cursor: grabbing;
|
|
user-select: none;
|
|
}
|
|
|
|
.carousel-track:not(.dragging) {
|
|
cursor: grab;
|
|
}
|
|
</style>
|