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

@@ -1,3 +1,4 @@
import { animate, lerp } from '$lib/helpers';
import type { NodeInstance, Socket } from '@nodarium/types'; import type { NodeInstance, Socket } from '@nodarium/types';
import { getContext, setContext } from 'svelte'; import { getContext, setContext } from 'svelte';
import { SvelteMap, SvelteSet } from 'svelte/reactivity'; import { SvelteMap, SvelteSet } from 'svelte/reactivity';
@@ -124,6 +125,9 @@ export class GraphState {
activeNodeId = $state(-1); activeNodeId = $state(-1);
selectedNodes = new SvelteSet<number>(); selectedNodes = new SvelteSet<number>();
activeSocket = $state<Socket | null>(null); activeSocket = $state<Socket | null>(null);
safePadding = $state<{ left?: number; right?: number; bottom?: number; top?: number } | null>(
null
);
hoveredSocket = $state<Socket | null>(null); hoveredSocket = $state<Socket | null>(null);
possibleSockets = $state<Socket[]>([]); possibleSockets = $state<Socket[]>([]);
possibleSocketIds = $derived( possibleSocketIds = $derived(
@@ -236,6 +240,37 @@ export class GraphState {
}; };
} }
centerNode(node?: NodeInstance) {
const average = [0, 0, 4];
if (node) {
average[0] = node.position[0] + (this.safePadding?.right || 0) / 10;
average[1] = node.position[1];
average[2] = 10;
} else {
for (const node of this.graph.nodes.values()) {
average[0] += node.position[0];
average[1] += node.position[1];
}
average[0] = (average[0] / this.graph.nodes.size)
+ (this.safePadding?.right || 0) / (average[2] * 2);
average[1] /= this.graph.nodes.size;
}
const camX = this.cameraPosition[0];
const camY = this.cameraPosition[1];
const camZ = this.cameraPosition[2];
const ease = (t: number) => (t < 0.5 ? 2 * t * t : -1 + (4 - 2 * t) * t);
const easeZoom = (t: number) => t * t * (3 - 2 * t);
animate(500, (a: number) => {
this.cameraPosition[0] = lerp(camX, average[0], ease(a));
this.cameraPosition[1] = lerp(camY, average[1], ease(a));
this.cameraPosition[2] = lerp(camZ, average[2], easeZoom(a));
if (this.mouseDown) return false;
});
}
pasteNodes() { pasteNodes() {
if (!this.clipboard) return; if (!this.clipboard) return;

View File

@@ -19,10 +19,10 @@
const { const {
keymap, keymap,
addMenuPadding safePadding
}: { }: {
keymap: ReturnType<typeof createKeyMap>; keymap: ReturnType<typeof createKeyMap>;
addMenuPadding?: { left?: number; right?: number; bottom?: number; top?: number }; safePadding?: { left?: number; right?: number; bottom?: number; top?: number };
} = $props(); } = $props();
const graph = getGraphManager(); const graph = getGraphManager();
@@ -172,10 +172,10 @@
{#if graphState.addMenuPosition} {#if graphState.addMenuPosition}
<AddMenu <AddMenu
onnode={handleNodeCreation} onnode={handleNodeCreation}
paddingTop={addMenuPadding?.top} paddingTop={safePadding?.top}
paddingRight={addMenuPadding?.right} paddingRight={safePadding?.right}
paddingBottom={addMenuPadding?.bottom} paddingBottom={safePadding?.bottom}
paddingLeft={addMenuPadding?.left} paddingLeft={safePadding?.left}
/> />
{/if} {/if}

View File

@@ -18,7 +18,7 @@
showHelp?: boolean; showHelp?: boolean;
settingTypes?: Record<string, unknown>; settingTypes?: Record<string, unknown>;
addMenuPadding?: { left?: number; right?: number; bottom?: number; top?: number }; safePadding?: { left?: number; right?: number; bottom?: number; top?: number };
onsave?: (save: Graph) => void; onsave?: (save: Graph) => void;
onresult?: (result: unknown) => void; onresult?: (result: unknown) => void;
@@ -27,7 +27,7 @@
let { let {
graph, graph,
registry, registry,
addMenuPadding, safePadding,
settings = $bindable(), settings = $bindable(),
activeNode = $bindable(), activeNode = $bindable(),
backgroundType = $bindable('grid'), backgroundType = $bindable('grid'),
@@ -44,29 +44,32 @@
export const manager = new GraphManager(registry); export const manager = new GraphManager(registry);
setGraphManager(manager); setGraphManager(manager);
const graphState = new GraphState(manager); export const state = new GraphState(manager);
$effect(() => { $effect(() => {
graphState.backgroundType = backgroundType; if (safePadding) {
graphState.snapToGrid = snapToGrid; state.safePadding = safePadding;
graphState.showHelp = showHelp; }
state.backgroundType = backgroundType;
state.snapToGrid = snapToGrid;
state.showHelp = showHelp;
}); });
setGraphState(graphState); setGraphState(state);
setupKeymaps(keymap, manager, graphState); setupKeymaps(keymap, manager, state);
$effect(() => { $effect(() => {
if (graphState.activeNodeId !== -1) { if (state.activeNodeId !== -1) {
activeNode = manager.getNode(graphState.activeNodeId); activeNode = manager.getNode(state.activeNodeId);
} else if (activeNode) { } else if (activeNode) {
activeNode = undefined; activeNode = undefined;
} }
}); });
$effect(() => { $effect(() => {
if (!graphState.addMenuPosition) { if (!state.addMenuPosition) {
graphState.edgeEndPosition = null; state.edgeEndPosition = null;
graphState.activeSocket = null; state.activeSocket = null;
} }
}); });
@@ -86,4 +89,4 @@
}); });
</script> </script>
<GraphEl {keymap} {addMenuPadding} /> <GraphEl {keymap} {safePadding} />

View File

@@ -67,28 +67,7 @@ export function setupKeymaps(keymap: Keymap, graph: GraphManager, graphState: Gr
description: 'Center camera', description: 'Center camera',
callback: () => { callback: () => {
if (!graphState.isBodyFocused()) return; if (!graphState.isBodyFocused()) return;
graphState.centerNode(graph.getNode(graphState.activeNodeId));
const average = [0, 0];
for (const node of graph.nodes.values()) {
average[0] += node.position[0];
average[1] += node.position[1];
}
average[0] = (average[0] / graph.nodes.size) + 10;
average[1] /= graph.nodes.size;
const camX = graphState.cameraPosition[0];
const camY = graphState.cameraPosition[1];
const camZ = graphState.cameraPosition[2];
const ease = (t: number) => (t < 0.5 ? 2 * t * t : -1 + (4 - 2 * t) * t);
const easeZoom = (t: number) => t * t * (3 - 2 * t);
animate(500, (a: number) => {
graphState.cameraPosition[0] = lerp(camX, average[0], ease(a));
graphState.cameraPosition[1] = lerp(camY, average[1], ease(a));
graphState.cameraPosition[2] = lerp(camZ, 4, easeZoom(a));
if (graphState.mouseDown) return false;
});
} }
}); });

View File

@@ -24,6 +24,10 @@
let geometryPool: ReturnType<typeof createGeometryPool>; let geometryPool: ReturnType<typeof createGeometryPool>;
let instancePool: ReturnType<typeof createInstancedGeometryPool>; let instancePool: ReturnType<typeof createInstancedGeometryPool>;
export function invalidate() {
sceneComponent?.invalidate();
}
export function updateGeometries(inputs: Int32Array[], group: Group) { export function updateGeometries(inputs: Int32Array[], group: Group) {
geometryPool = geometryPool || createGeometryPool(group, material); geometryPool = geometryPool || createGeometryPool(group, material);
instancePool = instancePool || createInstancedGeometryPool(group, material); instancePool = instancePool || createInstancedGeometryPool(group, material);

View File

@@ -24,11 +24,10 @@ export const tutorialConfig: PlantyConfig = {
tour_canvas: { tour_canvas: {
position: 'bottom-left', position: 'bottom-left',
hook: 'setup-default', action: 'setup-default',
highlight: { selector: '.graph-wrapper', padding: 12 }, highlight: { selector: '.graph-wrapper', padding: 12 },
text: text:
'This is the **graph canvas**. Nodes connect together to build a plant — the 3D model updates automatically whenever you make a change.\nEach node does one specific job: grow stems, add noise, produce output.', 'This is the **graph canvas**. Nodes connect together to build a plant — the 3D model updates automatically whenever you make a change.\nEach node does one specific job: grow stems, add noise, produce output.',
waitFor: 'click',
next: 'tour_viewer' next: 'tour_viewer'
}, },
@@ -37,7 +36,6 @@ export const tutorialConfig: PlantyConfig = {
highlight: { selector: '.cell:first-child', padding: 8 }, highlight: { selector: '.cell:first-child', padding: 8 },
text: text:
'This is the **3D viewer** — the live preview of your plant.\nLeft-click to rotate · right-click to pan · scroll to zoom.', 'This is the **3D viewer** — the live preview of your plant.\nLeft-click to rotate · right-click to pan · scroll to zoom.',
waitFor: 'click',
next: 'try_params' next: 'try_params'
}, },
@@ -46,16 +44,14 @@ export const tutorialConfig: PlantyConfig = {
highlight: { selector: '.graph-wrapper', padding: 12 }, highlight: { selector: '.graph-wrapper', padding: 12 },
text: text:
'Click a node to select it. Its settings appear **directly on the node** — try changing a value and watch the plant update.\nThe sidebar shows extra hidden settings for the selected node.', 'Click a node to select it. Its settings appear **directly on the node** — try changing a value and watch the plant update.\nThe sidebar shows extra hidden settings for the selected node.',
waitFor: 'click',
next: 'start_building' next: 'start_building'
}, },
start_building: { start_building: {
position: 'center', position: 'center',
hook: 'load-tutorial-template', action: 'load-tutorial-template',
text: text:
"Now let's build your own plant from scratch!\nI've loaded a blank project — it only has an **Output** node. Your goal: connect nodes to make a plant.", "Now let's build your own plant from scratch!\nI've loaded a blank project — it only has an **Output** node. Your goal: connect nodes to make a plant.",
waitFor: 'click',
next: 'add_stem_node' next: 'add_stem_node'
}, },
@@ -64,7 +60,6 @@ export const tutorialConfig: PlantyConfig = {
highlight: { selector: '.graph-wrapper', padding: 12 }, highlight: { selector: '.graph-wrapper', padding: 12 },
text: text:
"Open the **Add Menu** with **Shift+A** or **right-click** on the canvas.\nAdd a **Stem** node, then connect its output socket to the Output node's input.", "Open the **Add Menu** with **Shift+A** or **right-click** on the canvas.\nAdd a **Stem** node, then connect its output socket to the Output node's input.",
waitFor: 'click',
next: 'add_noise_node' next: 'add_noise_node'
}, },
@@ -73,7 +68,6 @@ export const tutorialConfig: PlantyConfig = {
highlight: { selector: '.graph-wrapper', padding: 12 }, highlight: { selector: '.graph-wrapper', padding: 12 },
text: text:
'Add a **Noise** node the same way.\nConnect: Stem → Noise input, then Noise output → Output.\nThis makes the stems grow in organic, curved shapes.', 'Add a **Noise** node the same way.\nConnect: Stem → Noise input, then Noise output → Output.\nThis makes the stems grow in organic, curved shapes.',
waitFor: 'click',
next: 'add_random_node' next: 'add_random_node'
}, },
@@ -82,7 +76,6 @@ export const tutorialConfig: PlantyConfig = {
highlight: { selector: '.graph-wrapper', padding: 12 }, highlight: { selector: '.graph-wrapper', padding: 12 },
text: text:
"Let's add some randomness! Add a **Random** node and connect its output to the **thickness** or **length** input of the Stem node.\nThe default min/max range is small — **Ctrl+drag** any number field to exceed its normal limits.", "Let's add some randomness! Add a **Random** node and connect its output to the **thickness** or **length** input of the Stem node.\nThe default min/max range is small — **Ctrl+drag** any number field to exceed its normal limits.",
waitFor: 'click',
next: 'prompt_regenerate' next: 'prompt_regenerate'
}, },
@@ -91,7 +84,6 @@ export const tutorialConfig: PlantyConfig = {
highlight: { selector: '.graph-wrapper', padding: 12 }, highlight: { selector: '.graph-wrapper', padding: 12 },
text: text:
'Now press **R** to regenerate. Each press gives the Random node a new value — your plant changes every run!', 'Now press **R** to regenerate. Each press gives the Random node a new value — your plant changes every run!',
waitFor: 'click',
next: 'tour_sidebar' next: 'tour_sidebar'
}, },
@@ -100,16 +92,13 @@ export const tutorialConfig: PlantyConfig = {
highlight: { selector: '.tabs', padding: 4 }, highlight: { selector: '.tabs', padding: 4 },
text: text:
'The **sidebar** holds all your tools:\n⚙ Settings · ⌨️ Shortcuts · 📦 Export · 📁 Projects · 📊 Graph Settings\nEnable **Advanced Mode** in Settings to unlock performance and benchmark panels.', 'The **sidebar** holds all your tools:\n⚙ Settings · ⌨️ Shortcuts · 📦 Export · 📁 Projects · 📊 Graph Settings\nEnable **Advanced Mode** in Settings to unlock performance and benchmark panels.',
waitFor: 'click',
next: 'save_project' next: 'save_project'
}, },
save_project: { save_project: {
position: 'right', position: 'right',
highlight: { selector: '.tabs', padding: 4 },
text: text:
'Your work is saved in the **Projects panel** — rename it, create new projects, or switch between them anytime.', 'Your work is saved in the **Projects panel** — rename it, create new projects, or switch between them anytime.',
waitFor: 'click',
next: 'congrats' next: 'congrats'
}, },
@@ -129,7 +118,7 @@ export const tutorialConfig: PlantyConfig = {
tour_canvas_nerd: { tour_canvas_nerd: {
position: 'bottom-left', position: 'bottom-left',
hook: 'setup-default', action: 'setup-default',
highlight: { selector: '.graph-wrapper', padding: 12 }, highlight: { selector: '.graph-wrapper', padding: 12 },
text: text:
"The **graph canvas** renders a directed acyclic graph. Each node is an individual **WASM module** executed in isolation — inputs in, output out. The 3D model updates automatically on every change.\nI've loaded a starter graph so you can see it in action.", "The **graph canvas** renders a directed acyclic graph. Each node is an individual **WASM module** executed in isolation — inputs in, output out. The 3D model updates automatically on every change.\nI've loaded a starter graph so you can see it in action.",
@@ -144,7 +133,6 @@ export const tutorialConfig: PlantyConfig = {
} }
} }
], ],
waitFor: 'click',
next: 'tour_viewer_nerd' next: 'tour_viewer_nerd'
}, },
@@ -153,15 +141,13 @@ export const tutorialConfig: PlantyConfig = {
highlight: { selector: '.cell:first-child', padding: 8 }, highlight: { selector: '.cell:first-child', padding: 8 },
text: text:
'The **3D viewer** uses `@threlte/core` (Svelte + Three.js). Mesh data streams from WASM execution results. OrbitControls: left-drag rotate, right-drag pan, scroll zoom.', 'The **3D viewer** uses `@threlte/core` (Svelte + Three.js). Mesh data streams from WASM execution results. OrbitControls: left-drag rotate, right-drag pan, scroll zoom.',
waitFor: 'click',
next: 'tour_runtime_nerd' next: 'tour_runtime_nerd'
}, },
tour_runtime_nerd: { tour_runtime_nerd: {
position: 'right', position: 'bottom-right',
text: text:
'By default, nodes execute in a **WebWorker** for better performance. You can switch to main-thread execution by disabling **Debug → Execute in WebWorker** in Settings.\nEnable **Advanced Mode** to unlock the Performance and Benchmark panels.', 'By default, nodes execute in a **WebWorker** for better performance. You can switch to main-thread execution by disabling **Debug → Execute in WebWorker** in Settings.\nEnable **Advanced Mode** to unlock the Performance and Benchmark panels.',
waitFor: 'click',
next: 'start_building' next: 'start_building'
}, },
@@ -171,7 +157,6 @@ export const tutorialConfig: PlantyConfig = {
position: 'bottom-right', position: 'bottom-right',
text: text:
'Node sockets are **type-checked**. The coloured dots tell you what kind of data flows through:\n🔵 `number` · 🟢 `vec3` · 🟣 `shape` · ⚪ `*` (wildcard)', 'Node sockets are **type-checked**. The coloured dots tell you what kind of data flows through:\n🔵 `number` · 🟢 `vec3` · 🟣 `shape` · ⚪ `*` (wildcard)',
waitFor: 'click',
next: 'connections_rules' next: 'connections_rules'
}, },
@@ -191,7 +176,6 @@ export const tutorialConfig: PlantyConfig = {
highlight: { selector: '.graph-wrapper', padding: 12 }, highlight: { selector: '.graph-wrapper', padding: 12 },
text: text:
'Click any node to select it. Basic settings are shown **on the node itself**.\nThe sidebar under *Graph Settings → Active Node* shows the full list:\n**Number** — drag or type · **Vec3** — X/Y/Z · **Select** — dropdown · **Color** — picker', 'Click any node to select it. Basic settings are shown **on the node itself**.\nThe sidebar under *Graph Settings → Active Node* shows the full list:\n**Number** — drag or type · **Vec3** — X/Y/Z · **Select** — dropdown · **Color** — picker',
waitFor: 'click',
next: 'params_tip' next: 'params_tip'
}, },
@@ -210,7 +194,6 @@ export const tutorialConfig: PlantyConfig = {
position: 'bottom-right', position: 'bottom-right',
text: text:
'Add a **Debug node** from the Add Menu (Shift+A or right-click). It accepts `*` wildcard inputs — connect any socket to inspect the data flowing through.\nEnable **Advanced Mode** in Settings to also see Performance and Graph Source panels.', 'Add a **Debug node** from the Add Menu (Shift+A or right-click). It accepts `*` wildcard inputs — connect any socket to inspect the data flowing through.\nEnable **Advanced Mode** in Settings to also see Performance and Graph Source panels.',
waitFor: 'click',
next: 'debug_done' next: 'debug_done'
}, },
@@ -228,7 +211,6 @@ export const tutorialConfig: PlantyConfig = {
position: 'bottom-right', position: 'bottom-right',
text: text:
'**Essential shortcuts:**\n`R` — Regenerate\n`Shift+A` / right-click — Add node\n`f` — Smart-connect selected nodes\n`.` — Center camera\n`Ctrl+Z` / `Ctrl+Y` — Undo / Redo\n`Delete` — Remove selected · `Ctrl+Delete` — Remove and restore edges', '**Essential shortcuts:**\n`R` — Regenerate\n`Shift+A` / right-click — Add node\n`f` — Smart-connect selected nodes\n`.` — Center camera\n`Ctrl+Z` / `Ctrl+Y` — Undo / Redo\n`Delete` — Remove selected · `Ctrl+Delete` — Remove and restore edges',
waitFor: 'click',
next: 'shortcuts_done' next: 'shortcuts_done'
}, },
@@ -247,7 +229,6 @@ export const tutorialConfig: PlantyConfig = {
position: 'right', position: 'right',
text: text:
'Export your 3D model from the **📦 Export** panel:\n**GLB** — standard for 3D apps (Blender, Three.js)\n**OBJ** — legacy format · **STL** — 3D printing · **PNG** — screenshot', 'Export your 3D model from the **📦 Export** panel:\n**GLB** — standard for 3D apps (Blender, Three.js)\n**OBJ** — legacy format · **STL** — 3D printing · **PNG** — screenshot',
waitFor: 'click',
next: 'congrats' next: 'congrats'
}, },

View File

@@ -168,7 +168,7 @@
<Planty <Planty
bind:this={planty} bind:this={planty}
config={tutorialConfig} config={tutorialConfig}
hooks={{ actions={{
'setup-default': () => { 'setup-default': () => {
const ts = new Date().toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit' }); const ts = new Date().toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit' });
pm.handleCreateProject( pm.handleCreateProject(
@@ -183,6 +183,48 @@
g.meta = { ...pm.graph.meta }; g.meta = { ...pm.graph.meta };
pm.graph = g; pm.graph = g;
pm.saveGraph(g); pm.saveGraph(g);
graphInterface.state.centerNode(graphInterface.manager.getAllNodes()[0]);
}
}}
hooks={{
'action:add_stem_node': (cb) => {
const unsub = manager.on('save', () => {
const allNodes = graphInterface.manager.getAllNodes();
const stemNode = allNodes.find(n => n.type === 'max/plantarium/stem');
if (stemNode && graphInterface.manager.edges.length) {
unsub();
(cb as () => void)();
}
});
},
'action:add_noise_node': (cb) => {
const unsub = manager.on('save', () => {
const allNodes = graphInterface.manager.getAllNodes();
const noiseNode = allNodes.find(n => n.type === 'max/plantarium/noise');
if (noiseNode && graphInterface.manager.edges.length > 1) {
unsub();
(cb as () => void)();
}
});
},
'action:add_random_node': (cb) => {
const unsub = manager.on('save', () => {
const allNodes = graphInterface.manager.getAllNodes();
const noiseNode = allNodes.find(n => n.type === 'max/plantarium/random');
if (noiseNode && graphInterface.manager.edges.length > 2) {
unsub();
(cb as () => void)();
}
});
},
'action:prompt_regenerate': (cb) => {
function handleKeydown(e: KeyboardEvent) {
if (e.key === 'r') {
window.removeEventListener('keydown', handleKeydown);
(cb as () => void)();
}
}
window.addEventListener('keydown', handleKeydown);
}, },
'before:save_project': () => panelState.setActivePanel('projects'), 'before:save_project': () => panelState.setActivePanel('projects'),
'before:export_tour': () => panelState.setActivePanel('exports'), 'before:export_tour': () => panelState.setActivePanel('exports'),
@@ -210,7 +252,7 @@
graph={pm.graph} graph={pm.graph}
bind:this={graphInterface} bind:this={graphInterface}
registry={nodeRegistry} registry={nodeRegistry}
addMenuPadding={{ right: sidebarOpen ? 330 : undefined }} safePadding={{ right: sidebarOpen ? 330 : undefined }}
backgroundType={appSettings.value.nodeInterface.backgroundType} backgroundType={appSettings.value.nodeInterface.backgroundType}
snapToGrid={appSettings.value.nodeInterface.snapToGrid} snapToGrid={appSettings.value.nodeInterface.snapToGrid}
bind:activeNode bind:activeNode
@@ -245,7 +287,7 @@
<Panel id="exports" title="Exporter" icon="i-[tabler--package-export]"> <Panel id="exports" title="Exporter" icon="i-[tabler--package-export]">
<ExportSettings {scene} /> <ExportSettings {scene} />
</Panel> </Panel>
{#if false} {#if 0 > 1}
<Panel <Panel
id="node-store" id="node-store"
title="Node Store" title="Node Store"

View File

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

View File

@@ -69,6 +69,12 @@
cursorY = e.clientY; 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) { function pupilOffset(cx: number, cy: number, eyeSvgX: number, eyeSvgY: number, maxPx = 2.8) {
const ex = x + eyeSvgX; const ex = x + eyeSvgX;
const ey = y + eyeSvgY; const ey = y + eyeSvgY;

View File

@@ -22,12 +22,9 @@ export interface DialogNode {
position?: AvatarPosition; position?: AvatarPosition;
highlight?: HighlightTarget; highlight?: HighlightTarget;
/** App hook to call on entering this node */ /** App hook to call on entering this node */
hook?: string; action?: string;
hookArgs?: unknown[];
next?: string | null; next?: string | null;
choices?: Choice[]; 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 */ /** Called (and awaited) just before the avatar starts moving to this node */
before?: StepCallback; before?: StepCallback;
/** Called (and awaited) just before the user leaves this node */ /** Called (and awaited) just before the user leaves this node */

View File

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

View File

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