Files
nodarium/packages/planty/src/lib/components/Planty.svelte
T
max 12572742eb
📊 Benchmark the Runtime / release (push) Successful in 1m4s
🚀 Lint & Test & Deploy / release (push) Successful in 3m52s
fix(planty): remove debug span
2026-04-21 01:01:37 +02:00

222 lines
7.3 KiB
Svelte

<script lang="ts">
import { DialogRunner } from '../dialog-runner.js';
import type { AvatarPosition, DialogNode, PlantyConfig, PlantyHook } from '../types.js';
import Highlight from './Highlight.svelte';
import PlantyAvatar from './PlantyAvatar.svelte';
import type { Mood } from './PlantyAvatar.svelte';
import SpeechBubble from './SpeechBubble.svelte';
interface Props {
config: PlantyConfig;
hooks?: Record<string, PlantyHook>;
actions?: Record<string, PlantyHook>;
onStepChange?: (nodeId: string, node: DialogNode) => void;
onComplete?: () => void;
}
let { config, actions = {}, hooks = {}, onStepChange, onComplete }: Props = $props();
const AVATAR_SIZE = 80;
const SCREEN_PADDING = 20;
// ── State ────────────────────────────────────────────────────────────
let isActive = $state(false);
let currentNodeId = $state<string | null>(null);
let bubbleVisible = $state(false);
let avatar = $state<PlantyAvatar>(null!);
let avatarX = $state(0);
let avatarY = $state(0);
let mood = $state<Mood>('idle');
let autoAdvanceTimer: ReturnType<typeof setTimeout> | null = null;
let actionCleanup: (() => void) | null = null;
// ── Derived ──────────────────────────────────────────────────────────
const runner = $derived(new DialogRunner(config));
const nextNode = $derived(
runner.getNextNode(currentNodeId ?? '')
);
const mainPath = $derived(runner.getMainPath());
const currentNode = $derived<DialogNode | null>(
currentNodeId ? runner.getNode(currentNodeId) : null
);
const showBubble = $derived(
isActive && bubbleVisible && currentNode !== null && !!currentNode.text
);
const highlight = $derived(currentNode?.highlight ?? null);
const stepIndex = $derived(currentNodeId ? mainPath.indexOf(currentNodeId) : -1);
const totalSteps = $derived(mainPath.length);
// ── Position helpers ─────────────────────────────────────────────────
function anchorToCoords(anchor: string): { x: number; y: number } {
const w = window.innerWidth;
const h = window.innerHeight;
switch (anchor) {
case 'top-left':
return { x: SCREEN_PADDING, y: SCREEN_PADDING };
case 'top-right':
return { x: w - AVATAR_SIZE - SCREEN_PADDING, y: SCREEN_PADDING };
case 'bottom-left':
return { x: SCREEN_PADDING, y: h - AVATAR_SIZE - SCREEN_PADDING };
case 'center':
return { x: (w - AVATAR_SIZE) / 2, y: (h - AVATAR_SIZE) / 2 };
case 'right':
return { x: w - AVATAR_SIZE - SCREEN_PADDING, y: (h - AVATAR_SIZE) / 2 };
case 'bottom-right':
default:
return { x: w - AVATAR_SIZE - SCREEN_PADDING, y: h - AVATAR_SIZE - SCREEN_PADDING };
}
}
function resolvePosition(pos: AvatarPosition): { x: number; y: number } {
return typeof pos === 'string' ? anchorToCoords(pos) : pos;
}
// ── Public API (exposed via bind:this) ───────────────────────────────
export function start() {
const defaultPos = config.avatar?.defaultPosition ?? 'bottom-right';
const pos = resolvePosition(defaultPos);
avatarX = pos.x;
avatarY = pos.y;
isActive = true;
const start = runner.getStartNode();
if (start) _enterNode(start.id, start.node);
}
export function stop() {
_clearAutoAdvance();
isActive = false;
bubbleVisible = false;
currentNodeId = null;
mood = 'idle';
onComplete?.();
}
export async function next() {
if (!currentNodeId) return;
await _runAfter(currentNodeId, currentNode);
const next = runner.getNextNode(currentNodeId);
if (next) _enterNode(next.id, next.node);
else stop();
}
export function registerHook(name: string, fn: PlantyHook) {
hooks = { ...hooks, [name]: fn };
}
// ── Internal ─────────────────────────────────────────────────────────
async function _runAfter(nodeId: string, node: DialogNode | null) {
if (!node) return;
if (actionCleanup) {
actionCleanup();
actionCleanup = null;
}
await node.after?.(nodeId, node);
await hooks[`after:${nodeId}`]?.(nodeId, node);
}
async function _enterNode(id: string, node: DialogNode) {
_clearAutoAdvance();
bubbleVisible = false;
currentNodeId = id;
onStepChange?.(id, node);
// Before hooks — run before movement starts
await node.before?.(id, node);
await hooks[`before:${id}`]?.(id, node);
// Fly to position first, then talk
if (node.position) {
mood = 'moving';
const pos = resolvePosition(node.position);
const hasChanges = pos.x !== avatarX || pos.y !== avatarY;
avatarX = pos.x;
avatarY = pos.y;
if (hasChanges) await _wait(900);
}
mood = 'talking';
bubbleVisible = true;
// App hook
if (node.action && actions[node.action]) {
const result = await actions[node.action]();
if (typeof result === 'function') actionCleanup = result as () => void;
}
const actionHook = hooks[`action:${id}`];
if (actionHook) {
const advance = () => {
avatar.flash('happy', 2000);
next();
};
const result = await actionHook(advance);
if (typeof result === 'function') actionCleanup = result as () => void;
}
if (!node.choices && !node.next) {
setTimeout(() => stop(), 3000);
}
// Stay in talking mood until the typewriter finishes (26 ms/char + buffer)
const talkMs = (node.text?.length ?? 0) * 26 + 200;
setTimeout(() => {
mood = 'idle';
}, talkMs);
}
function _wait(ms: number) {
return new Promise<void>((r) => setTimeout(r, ms));
}
function _clearAutoAdvance() {
if (autoAdvanceTimer !== null) {
clearTimeout(autoAdvanceTimer);
autoAdvanceTimer = null;
}
}
</script>
{#if isActive}
<div class="pointer-events-none fixed inset-0 z-99999">
{#if highlight}
<Highlight selector={highlight.selector} hookName={highlight.hookName} {hooks} />
{/if}
<PlantyAvatar bind:this={avatar} bind:x={avatarX} bind:y={avatarY} {mood} />
{#if showBubble && currentNode}
<SpeechBubble
text={currentNode.text ?? ''}
{avatarX}
{avatarY}
choices={currentNode.choices || []}
showNext={nextNode !== null}
{stepIndex}
{totalSteps}
onNext={next}
onClose={stop}
onChoose={async (choice) => {
await _runAfter(currentNodeId!, currentNode);
if (choice && choice.action) {
if (choice.action in actions) {
actions[choice.action]();
} else {
console.warn(`Planty: No action found for ${choice.action}`);
}
return;
}
if (!choice.next) {
stop();
return;
}
const n = runner.followChoice(choice);
if (n) _enterNode(n.id, n.node);
else stop();
}}
/>
{/if}
</div>
{/if}