diff --git a/src/components/admin/narrative-flow/BooleanNode.tsx b/src/components/admin/narrative-flow/BooleanNode.tsx index c30948c..58aa751 100644 --- a/src/components/admin/narrative-flow/BooleanNode.tsx +++ b/src/components/admin/narrative-flow/BooleanNode.tsx @@ -1,14 +1,15 @@ "use client"; -import { Handle, Position, type NodeProps } from "@xyflow/react"; +import { type NodeProps } from "@xyflow/react"; import { CheckCircleIcon } from "lucide-react"; +import { NodeHandles } from "./NodeHandles"; export function BooleanNode({ data }: NodeProps) { const d = data as { value?: boolean; description?: string }; return (
- +
@@ -18,7 +19,6 @@ export function BooleanNode({ data }: NodeProps) {
{d.description || "Boolean flag"}
-
); } diff --git a/src/components/admin/narrative-flow/ChoiceNode.tsx b/src/components/admin/narrative-flow/ChoiceNode.tsx index 8e618e9..8e98e32 100644 --- a/src/components/admin/narrative-flow/ChoiceNode.tsx +++ b/src/components/admin/narrative-flow/ChoiceNode.tsx @@ -1,16 +1,16 @@ "use client"; -import { Handle, Position, type NodeProps } from "@xyflow/react"; +import { type NodeProps } from "@xyflow/react"; import { PencilIcon, WaypointsIcon } from "lucide-react"; +import { NodeHandles } from "./NodeHandles"; export function ChoiceNode({ data }: NodeProps) { const d = data as { question?: string; options?: Array<{ id: string; label: string }> }; - const options = d.options ?? []; return (
- +
@@ -25,16 +25,10 @@ export function ChoiceNode({ data }: NodeProps) { options.map((opt) => (
{opt.label} -
)) )} diff --git a/src/components/admin/narrative-flow/DateNode.tsx b/src/components/admin/narrative-flow/DateNode.tsx index 2a3801a..b268c93 100644 --- a/src/components/admin/narrative-flow/DateNode.tsx +++ b/src/components/admin/narrative-flow/DateNode.tsx @@ -1,14 +1,15 @@ "use client"; -import { Handle, Position, type NodeProps } from "@xyflow/react"; +import { type NodeProps } from "@xyflow/react"; import { CalendarIcon } from "lucide-react"; +import { NodeHandles } from "./NodeHandles"; export function DateNode({ data }: NodeProps) { const d = data as { value?: string; description?: string }; return (
- +
@@ -18,7 +19,6 @@ export function DateNode({ data }: NodeProps) {
{d.description || "Date value"}
-
); } diff --git a/src/components/admin/narrative-flow/NarrativeBeatNode.tsx b/src/components/admin/narrative-flow/NarrativeBeatNode.tsx index 91ce415..50d6bfd 100644 --- a/src/components/admin/narrative-flow/NarrativeBeatNode.tsx +++ b/src/components/admin/narrative-flow/NarrativeBeatNode.tsx @@ -1,14 +1,15 @@ "use client"; -import { Handle, Position, type NodeProps } from "@xyflow/react"; +import { type NodeProps } from "@xyflow/react"; import { BookOpenTextIcon, PencilIcon } from "lucide-react"; +import { NodeHandles } from "./NodeHandles"; export function NarrativeBeatNode({ data }: NodeProps) { const d = data as { title?: string; text?: string }; return (
- +
@@ -19,7 +20,6 @@ export function NarrativeBeatNode({ data }: NodeProps) {
{d.text || "No description."}
-
); } diff --git a/src/components/admin/narrative-flow/NarrativeFlowEditor.tsx b/src/components/admin/narrative-flow/NarrativeFlowEditor.tsx index c61c6f7..98caa53 100644 --- a/src/components/admin/narrative-flow/NarrativeFlowEditor.tsx +++ b/src/components/admin/narrative-flow/NarrativeFlowEditor.tsx @@ -29,6 +29,7 @@ import { RandomNode } from "./RandomNode"; import { BooleanNode } from "./BooleanNode"; import { DateNode } from "./DateNode"; import { NodeEditDialog } from "./NodeEditDialog"; +import { NODE_SOCKETS, type SocketType } from "./types"; const nodeTypes = { narrativeBeat: NarrativeBeatNode, @@ -85,6 +86,29 @@ export function NarrativeFlowEditor() { [setEdges], ); + const isValidConnection = useCallback( + (connection: Edge | Connection) => { + if (!connection.source || !connection.target || !connection.sourceHandle || !connection.targetHandle) { + return false; + } + const sourceNode = nodes.find((n) => n.id === connection.source); + const targetNode = nodes.find((n) => n.id === connection.target); + if (!sourceNode || !targetNode) return false; + + const sourceSockets = NODE_SOCKETS[sourceNode.type as string]; + const targetSockets = NODE_SOCKETS[targetNode.type as string]; + if (!sourceSockets || !targetSockets) return false; + + const sourceSocket = sourceSockets.outputs.find((s) => s.id === connection.sourceHandle); + const targetSocket = targetSockets.inputs.find((s) => s.id === connection.targetHandle); + if (!sourceSocket || !targetSocket) return false; + + if (sourceSocket.type === "any" || targetSocket.type === "any") return true; + return sourceSocket.type === targetSocket.type; + }, + [nodes], + ); + const onNodeClick = useCallback((_e: React.MouseEvent, node: Node) => { setEditingNodeId(node.id); }, []); @@ -253,6 +277,7 @@ export function NarrativeFlowEditor() { onNodesChange={onNodesChange} onEdgesChange={onEdgesChange} onConnect={onConnect} + isValidConnection={isValidConnection} onNodeClick={onNodeClick} onNodeContextMenu={onNodeContextMenu} onEdgeContextMenu={onEdgeContextMenu} diff --git a/src/components/admin/narrative-flow/NodeHandles.tsx b/src/components/admin/narrative-flow/NodeHandles.tsx new file mode 100644 index 0000000..8fe699e --- /dev/null +++ b/src/components/admin/narrative-flow/NodeHandles.tsx @@ -0,0 +1,52 @@ +"use client"; + +import { TypedHandle } from "./TypedHandle"; +import { NODE_SOCKETS } from "./types"; + +interface NodeHandlesProps { + nodeType: string; + dynamicOutputs?: Array<{ id: string; label: string }>; +} + +export function NodeHandles({ nodeType, dynamicOutputs }: NodeHandlesProps) { + const config = NODE_SOCKETS[nodeType]; + if (!config) return null; + + const inputs = config.inputs; + const outputs = dynamicOutputs + ? dynamicOutputs.map((o) => ({ ...o, type: "flow" as const })) + : config.outputs; + + const maxSlots = Math.max(inputs.length, outputs.length, 1); + + return ( + <> + {inputs.map((socket, i) => { + const topPercent = maxSlots === 1 ? 50 : (i / (maxSlots - 1)) * 80 + 10; + return ( + + ); + })} + {outputs.map((socket, i) => { + const topPercent = maxSlots === 1 ? 50 : (i / (maxSlots - 1)) * 80 + 10; + return ( + + ); + })} + + ); +} diff --git a/src/components/admin/narrative-flow/NumberNode.tsx b/src/components/admin/narrative-flow/NumberNode.tsx index 6247638..219bda2 100644 --- a/src/components/admin/narrative-flow/NumberNode.tsx +++ b/src/components/admin/narrative-flow/NumberNode.tsx @@ -1,14 +1,15 @@ "use client"; -import { Handle, Position, type NodeProps } from "@xyflow/react"; +import { type NodeProps } from "@xyflow/react"; import { HashIcon } from "lucide-react"; +import { NodeHandles } from "./NodeHandles"; export function NumberNode({ data }: NodeProps) { const d = data as { value?: number; description?: string }; return (
- +
@@ -18,7 +19,6 @@ export function NumberNode({ data }: NodeProps) {
{d.description || "Numeric value"}
-
); } diff --git a/src/components/admin/narrative-flow/OutcomeNode.tsx b/src/components/admin/narrative-flow/OutcomeNode.tsx index a5b8cd1..6a13baa 100644 --- a/src/components/admin/narrative-flow/OutcomeNode.tsx +++ b/src/components/admin/narrative-flow/OutcomeNode.tsx @@ -1,7 +1,8 @@ "use client"; -import { Handle, Position, type NodeProps } from "@xyflow/react"; +import { type NodeProps } from "@xyflow/react"; import { FlagIcon, PencilIcon } from "lucide-react"; +import { NodeHandles } from "./NodeHandles"; export function OutcomeNode({ data }: NodeProps) { const d = data as { @@ -9,12 +10,11 @@ export function OutcomeNode({ data }: NodeProps) { text?: string; effects?: Array<{ type: string; value: string }>; }; - const effects = d.effects ?? []; return (
- +
@@ -33,10 +33,7 @@ export function OutcomeNode({ data }: NodeProps) { None ) : ( effects.map((eff, i) => ( -
+
{eff.type} : {eff.value} diff --git a/src/components/admin/narrative-flow/RandomNode.tsx b/src/components/admin/narrative-flow/RandomNode.tsx index b560456..5e37c0b 100644 --- a/src/components/admin/narrative-flow/RandomNode.tsx +++ b/src/components/admin/narrative-flow/RandomNode.tsx @@ -1,14 +1,15 @@ "use client"; -import { Handle, Position, type NodeProps } from "@xyflow/react"; +import { type NodeProps } from "@xyflow/react"; import { ShuffleIcon } from "lucide-react"; +import { NodeHandles } from "./NodeHandles"; export function RandomNode({ data }: NodeProps) { const d = data as { min?: number; max?: number; description?: string }; return (
- +
@@ -18,7 +19,6 @@ export function RandomNode({ data }: NodeProps) {
{d.description || "Random number generator"}
-
); } diff --git a/src/components/admin/narrative-flow/StringNode.tsx b/src/components/admin/narrative-flow/StringNode.tsx index c906479..aa4d6d8 100644 --- a/src/components/admin/narrative-flow/StringNode.tsx +++ b/src/components/admin/narrative-flow/StringNode.tsx @@ -1,14 +1,15 @@ "use client"; -import { Handle, Position, type NodeProps } from "@xyflow/react"; +import { type NodeProps } from "@xyflow/react"; import { TypeIcon } from "lucide-react"; +import { NodeHandles } from "./NodeHandles"; export function StringNode({ data }: NodeProps) { const d = data as { value?: string; description?: string }; return (
- +
@@ -18,7 +19,6 @@ export function StringNode({ data }: NodeProps) {
{d.description || "String value"}
-
); } diff --git a/src/components/admin/narrative-flow/TypedHandle.tsx b/src/components/admin/narrative-flow/TypedHandle.tsx new file mode 100644 index 0000000..8a3d602 --- /dev/null +++ b/src/components/admin/narrative-flow/TypedHandle.tsx @@ -0,0 +1,59 @@ +"use client"; + +import { Handle, Position, type HandleProps } from "@xyflow/react"; +import { SOCKET_COLORS, type SocketType } from "./types"; + +interface TypedHandleProps { + id: string; + socketType: SocketType; + label?: string; + isInput: boolean; + style?: React.CSSProperties; +} + +export function TypedHandle({ + id, + socketType, + label, + isInput, + style, +}: TypedHandleProps) { + const color = SOCKET_COLORS[socketType]; + const isFlow = socketType === "flow"; + + return ( +
+ + {label && ( + + {label} + + )} +
+ ); +} diff --git a/src/components/admin/narrative-flow/WaitNode.tsx b/src/components/admin/narrative-flow/WaitNode.tsx index 0fa546c..869d48b 100644 --- a/src/components/admin/narrative-flow/WaitNode.tsx +++ b/src/components/admin/narrative-flow/WaitNode.tsx @@ -1,14 +1,15 @@ "use client"; -import { Handle, Position, type NodeProps } from "@xyflow/react"; +import { type NodeProps } from "@xyflow/react"; import { TimerIcon } from "lucide-react"; +import { NodeHandles } from "./NodeHandles"; export function WaitNode({ data }: NodeProps) { const d = data as { ticks?: number; input?: unknown }; return (
- +
@@ -16,9 +17,10 @@ export function WaitNode({ data }: NodeProps) {
- {d.input !== undefined ? `Input: ${String(d.input).substring(0, 50)}${String(d.input).length > 50 ? "..." : ""}` : "No input"} + {d.input !== undefined + ? `Input: ${String(d.input).substring(0, 50)}${String(d.input).length > 50 ? "..." : ""}` + : "No input"}
-
); } diff --git a/src/components/admin/narrative-flow/types.ts b/src/components/admin/narrative-flow/types.ts new file mode 100644 index 0000000..c085399 --- /dev/null +++ b/src/components/admin/narrative-flow/types.ts @@ -0,0 +1,143 @@ +export type SocketType = "flow" | "number" | "string" | "boolean" | "any"; + +export interface SocketDef { + id: string; + type: SocketType; + label: string; +} + +export const SOCKET_COLORS: Record = { + flow: "#e4e4e7", + number: "#a855f7", + string: "#14b8a6", + boolean: "#22c55e", + any: "#6366f1", +}; + +export interface NodeSocketConfig { + inputs: SocketDef[]; + outputs: SocketDef[]; +} + +export const NODE_SOCKETS: Record = { + narrativeBeat: { + inputs: [ + { id: "flow-in", type: "flow", label: "" }, + { id: "text-var", type: "any", label: "text" }, + ], + outputs: [{ id: "flow-out", type: "flow", label: "" }], + }, + choice: { + inputs: [ + { id: "flow-in", type: "flow", label: "" }, + { id: "question-var", type: "string", label: "question" }, + ], + outputs: [{ id: "flow-out", type: "flow", label: "" }], + }, + outcome: { + inputs: [ + { id: "flow-in", type: "flow", label: "" }, + { id: "trigger", type: "boolean", label: "trigger" }, + ], + outputs: [], + }, + wait: { + inputs: [ + { id: "flow-in", type: "flow", label: "" }, + { id: "ticks", type: "number", label: "ticks" }, + ], + outputs: [{ id: "flow-out", type: "flow", label: "" }], + }, + number: { + inputs: [], + outputs: [{ id: "value", type: "number", label: "out" }], + }, + string: { + inputs: [], + outputs: [{ id: "value", type: "string", label: "out" }], + }, + random: { + inputs: [ + { id: "min", type: "number", label: "min" }, + { id: "max", type: "number", label: "max" }, + ], + outputs: [{ id: "value", type: "number", label: "out" }], + }, + boolean: { + inputs: [], + outputs: [{ id: "value", type: "boolean", label: "out" }], + }, + date: { + inputs: [], + outputs: [{ id: "value", type: "string", label: "out" }], + }, + gameData: { + inputs: [], + outputs: [ + { id: "number-out", type: "number", label: "num" }, + { id: "string-out", type: "string", label: "str" }, + { id: "boolean-out", type: "boolean", label: "bool" }, + ], + }, + flag: { + inputs: [ + { id: "flow-in", type: "flow", label: "" }, + { id: "set-value", type: "boolean", label: "set" }, + ], + outputs: [ + { id: "flow-out", type: "flow", label: "" }, + { id: "value", type: "boolean", label: "out" }, + ], + }, + compare: { + inputs: [ + { id: "a", type: "number", label: "A" }, + { id: "b", type: "number", label: "B" }, + ], + outputs: [{ id: "result", type: "boolean", label: "out" }], + }, + math: { + inputs: [ + { id: "a", type: "number", label: "A" }, + { id: "b", type: "number", label: "B" }, + ], + outputs: [{ id: "result", type: "number", label: "out" }], + }, + format: { + inputs: [ + { id: "template", type: "string", label: "tmpl" }, + { id: "arg-0", type: "any", label: "{0}" }, + { id: "arg-1", type: "any", label: "{1}" }, + ], + outputs: [{ id: "result", type: "string", label: "out" }], + }, + logic: { + inputs: [ + { id: "a", type: "boolean", label: "A" }, + { id: "b", type: "boolean", label: "B" }, + ], + outputs: [{ id: "result", type: "boolean", label: "out" }], + }, + not: { + inputs: [{ id: "value", type: "boolean", label: "in" }], + outputs: [{ id: "result", type: "boolean", label: "out" }], + }, + branch: { + inputs: [ + { id: "flow-in", type: "flow", label: "" }, + { id: "condition", type: "boolean", label: "cond" }, + ], + outputs: [ + { id: "flow-true", type: "flow", label: "true" }, + { id: "flow-false", type: "flow", label: "false" }, + ], + }, + sequence: { + inputs: [{ id: "flow-in", type: "flow", label: "" }], + outputs: [ + { id: "flow-1", type: "flow", label: "1" }, + { id: "flow-2", type: "flow", label: "2" }, + { id: "flow-3", type: "flow", label: "3" }, + ], + }, +};