feat: improve planty ux

This commit is contained in:
2026-04-20 21:23:55 +02:00
parent 7ebb1297ac
commit 58d39cd101
12 changed files with 141 additions and 98 deletions

View File

@@ -9,11 +9,12 @@
interface Props {
config: PlantyConfig;
hooks?: Record<string, PlantyHook>;
actions?: Record<string, PlantyHook>;
onStepChange?: (nodeId: string, node: DialogNode) => void;
onComplete?: () => void;
}
let { config, hooks = {}, onStepChange, onComplete }: Props = $props();
let { config, actions = {}, hooks = {}, onStepChange, onComplete }: Props = $props();
const AVATAR_SIZE = 80;
const SCREEN_PADDING = 20;
@@ -22,6 +23,7 @@
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');
@@ -30,6 +32,9 @@
// ── 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
@@ -125,32 +130,31 @@
if (node.position) {
mood = 'moving';
const pos = resolvePosition(node.position);
const hasChanges = pos.x !== avatarX || pos.y !== avatarY;
avatarX = pos.x;
avatarY = pos.y;
await _wait(900);
if (hasChanges) await _wait(900);
}
mood = 'talking';
bubbleVisible = true;
// App hook
if (node.hook && hooks[node.hook]) {
const result = await hooks[node.hook](...(node.hookArgs ?? []));
if (node.action && actions[node.action]) {
const result = await actions[node.action]();
if (typeof result === 'function') actionCleanup = result as () => void;
}
// Auto-advance
if (typeof node.waitFor === 'number') {
autoAdvanceTimer = setTimeout(() => next(), node.waitFor);
}
if (node.waitFor === 'action') {
const actionHook = hooks[`action:${id}`];
if (actionHook) {
const advance = () => next();
const result = await actionHook(advance, ...(node.hookArgs ?? []));
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);
}
@@ -176,11 +180,12 @@
{#if isActive}
<div class="pointer-events-none fixed inset-0 z-99999">
<span>{currentNodeId}</span>
{#if highlight}
<Highlight selector={highlight.selector} hookName={highlight.hookName} {hooks} />
{/if}
<PlantyAvatar bind:x={avatarX} bind:y={avatarY} {mood} />
<PlantyAvatar bind:this={avatar} bind:x={avatarX} bind:y={avatarY} {mood} />
{#if showBubble && currentNode}
<SpeechBubble
@@ -188,7 +193,7 @@
{avatarX}
{avatarY}
choices={currentNode.choices || []}
showNext={currentNode.waitFor === 'click'}
showNext={nextNode !== null}
{stepIndex}
{totalSteps}
onNext={next}

View File

@@ -69,6 +69,12 @@
cursorY = e.clientY;
}
export function flash(flashMood: Mood, duration = 500) {
const prev = displayMood;
mood = flashMood;
setTimeout(() => (mood = prev), duration);
}
function pupilOffset(cx: number, cy: number, eyeSvgX: number, eyeSvgY: number, maxPx = 2.8) {
const ex = x + eyeSvgX;
const ey = y + eyeSvgY;

View File

@@ -22,12 +22,9 @@ export interface DialogNode {
position?: AvatarPosition;
highlight?: HighlightTarget;
/** App hook to call on entering this node */
hook?: string;
hookArgs?: unknown[];
action?: string;
next?: string | null;
choices?: Choice[];
/** 'click' = wait for user click, number = auto-advance after N ms, 'action' = wait for hook to call advance() */
waitFor?: 'click' | 'action' | number;
/** Called (and awaited) just before the avatar starts moving to this node */
before?: StepCallback;
/** Called (and awaited) just before the user leaves this node */

View File

@@ -1,7 +1,6 @@
<script lang="ts">
import '@nodarium/ui/app.css';
import './layout.css';
const { children } = $props();
</script>

View File

@@ -21,14 +21,12 @@
"type": "step",
"position": "bottom-right",
"text": "This is a WebAssembly-powered node graph. Each node is a compiled .wasm module executed in a sandboxed runtime.",
"waitFor": "click",
"next": "highlight_graph_nerd"
},
"intro_simple": {
"type": "step",
"position": "bottom-right",
"text": "Think of this like a recipe card — each block does one thing, and you connect them to build a plant!",
"waitFor": "click",
"next": "highlight_graph_simple"
},
@@ -37,7 +35,6 @@
"position": "bottom-left",
"highlight": { "selector": "#graph-canvas", "padding": 12 },
"text": "The graph canvas renders edges as Bézier curves. Node execution is topologically sorted before each WASM call.",
"waitFor": "click",
"next": "highlight_sidebar_nerd"
},
"highlight_graph_simple": {
@@ -45,7 +42,6 @@
"position": "bottom-left",
"highlight": { "selector": "#graph-canvas", "padding": 12 },
"text": "This is the main canvas — drag nodes around and connect them to create your plant!",
"waitFor": "click",
"next": "highlight_sidebar_simple"
},
@@ -54,7 +50,6 @@
"position": "bottom-right",
"highlight": { "selector": "#sidebar", "padding": 8 },
"text": "The sidebar exposes node parameters, export settings, and the raw graph JSON for debugging.",
"waitFor": "click",
"next": "tip_nerd"
},
"highlight_sidebar_simple": {
@@ -62,7 +57,6 @@
"position": "bottom-right",
"highlight": { "selector": "#sidebar", "padding": 8 },
"text": "The sidebar lets you tweak settings and export your creation.",
"waitFor": "click",
"next": "tip_simple"
},
@@ -70,14 +64,12 @@
"type": "step",
"position": "center",
"text": "Press Space or double-click the canvas to open node search. Nodes are fetched from the WASM registry at runtime.",
"waitFor": "click",
"next": "done_nerd"
},
"tip_simple": {
"type": "step",
"position": "center",
"text": "Press Space anywhere on the canvas to add a new block — try it!",
"waitFor": "click",
"next": "done_simple"
},