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 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;
|
||||||
|
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|
||||||
|
|||||||
@@ -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} />
|
||||||
|
|||||||
@@ -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;
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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'
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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 */
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -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"
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user