feat: improve planty ux
This commit is contained in:
@@ -1,3 +1,4 @@
|
||||
import { animate, lerp } from '$lib/helpers';
|
||||
import type { NodeInstance, Socket } from '@nodarium/types';
|
||||
import { getContext, setContext } from 'svelte';
|
||||
import { SvelteMap, SvelteSet } from 'svelte/reactivity';
|
||||
@@ -124,6 +125,9 @@ export class GraphState {
|
||||
activeNodeId = $state(-1);
|
||||
selectedNodes = new SvelteSet<number>();
|
||||
activeSocket = $state<Socket | null>(null);
|
||||
safePadding = $state<{ left?: number; right?: number; bottom?: number; top?: number } | null>(
|
||||
null
|
||||
);
|
||||
hoveredSocket = $state<Socket | null>(null);
|
||||
possibleSockets = $state<Socket[]>([]);
|
||||
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() {
|
||||
if (!this.clipboard) return;
|
||||
|
||||
|
||||
@@ -19,10 +19,10 @@
|
||||
|
||||
const {
|
||||
keymap,
|
||||
addMenuPadding
|
||||
safePadding
|
||||
}: {
|
||||
keymap: ReturnType<typeof createKeyMap>;
|
||||
addMenuPadding?: { left?: number; right?: number; bottom?: number; top?: number };
|
||||
safePadding?: { left?: number; right?: number; bottom?: number; top?: number };
|
||||
} = $props();
|
||||
|
||||
const graph = getGraphManager();
|
||||
@@ -172,10 +172,10 @@
|
||||
{#if graphState.addMenuPosition}
|
||||
<AddMenu
|
||||
onnode={handleNodeCreation}
|
||||
paddingTop={addMenuPadding?.top}
|
||||
paddingRight={addMenuPadding?.right}
|
||||
paddingBottom={addMenuPadding?.bottom}
|
||||
paddingLeft={addMenuPadding?.left}
|
||||
paddingTop={safePadding?.top}
|
||||
paddingRight={safePadding?.right}
|
||||
paddingBottom={safePadding?.bottom}
|
||||
paddingLeft={safePadding?.left}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
showHelp?: boolean;
|
||||
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;
|
||||
onresult?: (result: unknown) => void;
|
||||
@@ -27,7 +27,7 @@
|
||||
let {
|
||||
graph,
|
||||
registry,
|
||||
addMenuPadding,
|
||||
safePadding,
|
||||
settings = $bindable(),
|
||||
activeNode = $bindable(),
|
||||
backgroundType = $bindable('grid'),
|
||||
@@ -44,29 +44,32 @@
|
||||
export const manager = new GraphManager(registry);
|
||||
setGraphManager(manager);
|
||||
|
||||
const graphState = new GraphState(manager);
|
||||
export const state = new GraphState(manager);
|
||||
$effect(() => {
|
||||
graphState.backgroundType = backgroundType;
|
||||
graphState.snapToGrid = snapToGrid;
|
||||
graphState.showHelp = showHelp;
|
||||
if (safePadding) {
|
||||
state.safePadding = safePadding;
|
||||
}
|
||||
state.backgroundType = backgroundType;
|
||||
state.snapToGrid = snapToGrid;
|
||||
state.showHelp = showHelp;
|
||||
});
|
||||
|
||||
setGraphState(graphState);
|
||||
setGraphState(state);
|
||||
|
||||
setupKeymaps(keymap, manager, graphState);
|
||||
setupKeymaps(keymap, manager, state);
|
||||
|
||||
$effect(() => {
|
||||
if (graphState.activeNodeId !== -1) {
|
||||
activeNode = manager.getNode(graphState.activeNodeId);
|
||||
if (state.activeNodeId !== -1) {
|
||||
activeNode = manager.getNode(state.activeNodeId);
|
||||
} else if (activeNode) {
|
||||
activeNode = undefined;
|
||||
}
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
if (!graphState.addMenuPosition) {
|
||||
graphState.edgeEndPosition = null;
|
||||
graphState.activeSocket = null;
|
||||
if (!state.addMenuPosition) {
|
||||
state.edgeEndPosition = null;
|
||||
state.activeSocket = null;
|
||||
}
|
||||
});
|
||||
|
||||
@@ -86,4 +89,4 @@
|
||||
});
|
||||
</script>
|
||||
|
||||
<GraphEl {keymap} {addMenuPadding} />
|
||||
<GraphEl {keymap} {safePadding} />
|
||||
|
||||
@@ -67,28 +67,7 @@ export function setupKeymaps(keymap: Keymap, graph: GraphManager, graphState: Gr
|
||||
description: 'Center camera',
|
||||
callback: () => {
|
||||
if (!graphState.isBodyFocused()) return;
|
||||
|
||||
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;
|
||||
});
|
||||
graphState.centerNode(graph.getNode(graphState.activeNodeId));
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -24,6 +24,10 @@
|
||||
let geometryPool: ReturnType<typeof createGeometryPool>;
|
||||
let instancePool: ReturnType<typeof createInstancedGeometryPool>;
|
||||
|
||||
export function invalidate() {
|
||||
sceneComponent?.invalidate();
|
||||
}
|
||||
|
||||
export function updateGeometries(inputs: Int32Array[], group: Group) {
|
||||
geometryPool = geometryPool || createGeometryPool(group, material);
|
||||
instancePool = instancePool || createInstancedGeometryPool(group, material);
|
||||
|
||||
@@ -24,11 +24,10 @@ export const tutorialConfig: PlantyConfig = {
|
||||
|
||||
tour_canvas: {
|
||||
position: 'bottom-left',
|
||||
hook: 'setup-default',
|
||||
action: 'setup-default',
|
||||
highlight: { selector: '.graph-wrapper', padding: 12 },
|
||||
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.',
|
||||
waitFor: 'click',
|
||||
next: 'tour_viewer'
|
||||
},
|
||||
|
||||
@@ -37,7 +36,6 @@ export const tutorialConfig: PlantyConfig = {
|
||||
highlight: { selector: '.cell:first-child', padding: 8 },
|
||||
text:
|
||||
'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'
|
||||
},
|
||||
|
||||
@@ -46,16 +44,14 @@ export const tutorialConfig: PlantyConfig = {
|
||||
highlight: { selector: '.graph-wrapper', padding: 12 },
|
||||
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.',
|
||||
waitFor: 'click',
|
||||
next: 'start_building'
|
||||
},
|
||||
|
||||
start_building: {
|
||||
position: 'center',
|
||||
hook: 'load-tutorial-template',
|
||||
action: 'load-tutorial-template',
|
||||
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.",
|
||||
waitFor: 'click',
|
||||
next: 'add_stem_node'
|
||||
},
|
||||
|
||||
@@ -64,7 +60,6 @@ export const tutorialConfig: PlantyConfig = {
|
||||
highlight: { selector: '.graph-wrapper', padding: 12 },
|
||||
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.",
|
||||
waitFor: 'click',
|
||||
next: 'add_noise_node'
|
||||
},
|
||||
|
||||
@@ -73,7 +68,6 @@ export const tutorialConfig: PlantyConfig = {
|
||||
highlight: { selector: '.graph-wrapper', padding: 12 },
|
||||
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.',
|
||||
waitFor: 'click',
|
||||
next: 'add_random_node'
|
||||
},
|
||||
|
||||
@@ -82,7 +76,6 @@ export const tutorialConfig: PlantyConfig = {
|
||||
highlight: { selector: '.graph-wrapper', padding: 12 },
|
||||
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.",
|
||||
waitFor: 'click',
|
||||
next: 'prompt_regenerate'
|
||||
},
|
||||
|
||||
@@ -91,7 +84,6 @@ export const tutorialConfig: PlantyConfig = {
|
||||
highlight: { selector: '.graph-wrapper', padding: 12 },
|
||||
text:
|
||||
'Now press **R** to regenerate. Each press gives the Random node a new value — your plant changes every run!',
|
||||
waitFor: 'click',
|
||||
next: 'tour_sidebar'
|
||||
},
|
||||
|
||||
@@ -100,16 +92,13 @@ export const tutorialConfig: PlantyConfig = {
|
||||
highlight: { selector: '.tabs', padding: 4 },
|
||||
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.',
|
||||
waitFor: 'click',
|
||||
next: 'save_project'
|
||||
},
|
||||
|
||||
save_project: {
|
||||
position: 'right',
|
||||
highlight: { selector: '.tabs', padding: 4 },
|
||||
text:
|
||||
'Your work is saved in the **Projects panel** — rename it, create new projects, or switch between them anytime.',
|
||||
waitFor: 'click',
|
||||
next: 'congrats'
|
||||
},
|
||||
|
||||
@@ -129,7 +118,7 @@ export const tutorialConfig: PlantyConfig = {
|
||||
|
||||
tour_canvas_nerd: {
|
||||
position: 'bottom-left',
|
||||
hook: 'setup-default',
|
||||
action: 'setup-default',
|
||||
highlight: { selector: '.graph-wrapper', padding: 12 },
|
||||
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.",
|
||||
@@ -144,7 +133,6 @@ export const tutorialConfig: PlantyConfig = {
|
||||
}
|
||||
}
|
||||
],
|
||||
waitFor: 'click',
|
||||
next: 'tour_viewer_nerd'
|
||||
},
|
||||
|
||||
@@ -153,15 +141,13 @@ export const tutorialConfig: PlantyConfig = {
|
||||
highlight: { selector: '.cell:first-child', padding: 8 },
|
||||
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.',
|
||||
waitFor: 'click',
|
||||
next: 'tour_runtime_nerd'
|
||||
},
|
||||
|
||||
tour_runtime_nerd: {
|
||||
position: 'right',
|
||||
position: 'bottom-right',
|
||||
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.',
|
||||
waitFor: 'click',
|
||||
next: 'start_building'
|
||||
},
|
||||
|
||||
@@ -171,7 +157,6 @@ export const tutorialConfig: PlantyConfig = {
|
||||
position: 'bottom-right',
|
||||
text:
|
||||
'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'
|
||||
},
|
||||
|
||||
@@ -191,7 +176,6 @@ export const tutorialConfig: PlantyConfig = {
|
||||
highlight: { selector: '.graph-wrapper', padding: 12 },
|
||||
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',
|
||||
waitFor: 'click',
|
||||
next: 'params_tip'
|
||||
},
|
||||
|
||||
@@ -210,7 +194,6 @@ export const tutorialConfig: PlantyConfig = {
|
||||
position: 'bottom-right',
|
||||
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.',
|
||||
waitFor: 'click',
|
||||
next: 'debug_done'
|
||||
},
|
||||
|
||||
@@ -228,7 +211,6 @@ export const tutorialConfig: PlantyConfig = {
|
||||
position: 'bottom-right',
|
||||
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',
|
||||
waitFor: 'click',
|
||||
next: 'shortcuts_done'
|
||||
},
|
||||
|
||||
@@ -247,7 +229,6 @@ export const tutorialConfig: PlantyConfig = {
|
||||
position: 'right',
|
||||
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',
|
||||
waitFor: 'click',
|
||||
next: 'congrats'
|
||||
},
|
||||
|
||||
|
||||
@@ -168,7 +168,7 @@
|
||||
<Planty
|
||||
bind:this={planty}
|
||||
config={tutorialConfig}
|
||||
hooks={{
|
||||
actions={{
|
||||
'setup-default': () => {
|
||||
const ts = new Date().toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit' });
|
||||
pm.handleCreateProject(
|
||||
@@ -183,6 +183,48 @@
|
||||
g.meta = { ...pm.graph.meta };
|
||||
pm.graph = 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:export_tour': () => panelState.setActivePanel('exports'),
|
||||
@@ -210,7 +252,7 @@
|
||||
graph={pm.graph}
|
||||
bind:this={graphInterface}
|
||||
registry={nodeRegistry}
|
||||
addMenuPadding={{ right: sidebarOpen ? 330 : undefined }}
|
||||
safePadding={{ right: sidebarOpen ? 330 : undefined }}
|
||||
backgroundType={appSettings.value.nodeInterface.backgroundType}
|
||||
snapToGrid={appSettings.value.nodeInterface.snapToGrid}
|
||||
bind:activeNode
|
||||
@@ -245,7 +287,7 @@
|
||||
<Panel id="exports" title="Exporter" icon="i-[tabler--package-export]">
|
||||
<ExportSettings {scene} />
|
||||
</Panel>
|
||||
{#if false}
|
||||
{#if 0 > 1}
|
||||
<Panel
|
||||
id="node-store"
|
||||
title="Node Store"
|
||||
|
||||
@@ -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 ?? []));
|
||||
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}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 */
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
<script lang="ts">
|
||||
import '@nodarium/ui/app.css';
|
||||
import './layout.css';
|
||||
|
||||
const { children } = $props();
|
||||
</script>
|
||||
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
|
||||
Reference in New Issue
Block a user