diff --git a/src/components/admin/narrative-flow/BranchNode.tsx b/src/components/admin/narrative-flow/BranchNode.tsx new file mode 100644 index 0000000..f2842ed --- /dev/null +++ b/src/components/admin/narrative-flow/BranchNode.tsx @@ -0,0 +1,23 @@ +"use client"; + +import { type NodeProps } from "@xyflow/react"; +import { GitBranchIcon } from "lucide-react"; +import { NodeHandles } from "./NodeHandles"; + +export function BranchNode(_props: NodeProps) { + return ( +
+ +
+ + + Branch + +
+
+ true + false +
+
+ ); +} diff --git a/src/components/admin/narrative-flow/CompareNode.tsx b/src/components/admin/narrative-flow/CompareNode.tsx new file mode 100644 index 0000000..42d1f75 --- /dev/null +++ b/src/components/admin/narrative-flow/CompareNode.tsx @@ -0,0 +1,25 @@ +"use client"; + +import { type NodeProps } from "@xyflow/react"; +import { EqualIcon } from "lucide-react"; +import { NodeHandles } from "./NodeHandles"; + +export function CompareNode({ data }: NodeProps) { + const d = data as { operator?: string }; + const op = d.operator ?? ">"; + + return ( +
+ +
+ + + Compare + +
+
+ {op} +
+
+ ); +} diff --git a/src/components/admin/narrative-flow/FlagNode.tsx b/src/components/admin/narrative-flow/FlagNode.tsx new file mode 100644 index 0000000..3335c36 --- /dev/null +++ b/src/components/admin/narrative-flow/FlagNode.tsx @@ -0,0 +1,24 @@ +"use client"; + +import { type NodeProps } from "@xyflow/react"; +import { FlagIcon } from "lucide-react"; +import { NodeHandles } from "./NodeHandles"; + +export function FlagNode({ data }: NodeProps) { + const d = data as { flagName?: string }; + + return ( +
+ +
+ + + Flag + +
+
+ {d.flagName || "Unnamed flag"} +
+
+ ); +} diff --git a/src/components/admin/narrative-flow/FormatNode.tsx b/src/components/admin/narrative-flow/FormatNode.tsx new file mode 100644 index 0000000..197e428 --- /dev/null +++ b/src/components/admin/narrative-flow/FormatNode.tsx @@ -0,0 +1,24 @@ +"use client"; + +import { type NodeProps } from "@xyflow/react"; +import { AlignLeftIcon } from "lucide-react"; +import { NodeHandles } from "./NodeHandles"; + +export function FormatNode({ data }: NodeProps) { + const d = data as { template?: string }; + + return ( +
+ +
+ + + Format + +
+
+ {d.template || "{0} {1}"} +
+
+ ); +} diff --git a/src/components/admin/narrative-flow/GameDataNode.tsx b/src/components/admin/narrative-flow/GameDataNode.tsx new file mode 100644 index 0000000..e9b4ac4 --- /dev/null +++ b/src/components/admin/narrative-flow/GameDataNode.tsx @@ -0,0 +1,36 @@ +"use client"; + +import { type NodeProps } from "@xyflow/react"; +import { DatabaseIcon } from "lucide-react"; +import { NodeHandles } from "./NodeHandles"; + +const GAME_DATA_FIELDS = [ + { value: "structure_health", label: "Structure Health" }, + { value: "structure_storage", label: "Structure Storage" }, + { value: "deliveries_pending", label: "Pending Deliveries" }, + { value: "deliveries_completed", label: "Completed Deliveries" }, + { value: "resources_total", label: "Total Resources" }, + { value: "faction_reputation", label: "Faction Reputation" }, + { value: "player_count", label: "Player Count" }, + { value: "tick_count", label: "Current Tick" }, +]; + +export function GameDataNode({ data }: NodeProps) { + const d = data as { field?: string }; + const fieldLabel = GAME_DATA_FIELDS.find((f) => f.value === d.field)?.label ?? "Select field"; + + return ( +
+ +
+ + + Game Data + +
+
+ {fieldLabel} +
+
+ ); +} diff --git a/src/components/admin/narrative-flow/LogicNode.tsx b/src/components/admin/narrative-flow/LogicNode.tsx new file mode 100644 index 0000000..556508f --- /dev/null +++ b/src/components/admin/narrative-flow/LogicNode.tsx @@ -0,0 +1,25 @@ +"use client"; + +import { type NodeProps } from "@xyflow/react"; +import { BinaryIcon } from "lucide-react"; +import { NodeHandles } from "./NodeHandles"; + +export function LogicNode({ data }: NodeProps) { + const d = data as { operator?: string }; + const op = d.operator ?? "AND"; + + return ( +
+ +
+ + + Logic + +
+
+ {op} +
+
+ ); +} diff --git a/src/components/admin/narrative-flow/MathNode.tsx b/src/components/admin/narrative-flow/MathNode.tsx new file mode 100644 index 0000000..2eb32da --- /dev/null +++ b/src/components/admin/narrative-flow/MathNode.tsx @@ -0,0 +1,25 @@ +"use client"; + +import { type NodeProps } from "@xyflow/react"; +import { CalculatorIcon } from "lucide-react"; +import { NodeHandles } from "./NodeHandles"; + +export function MathNode({ data }: NodeProps) { + const d = data as { operator?: string }; + const op = d.operator ?? "+"; + + return ( +
+ +
+ + + Math + +
+
+ {op} +
+
+ ); +} diff --git a/src/components/admin/narrative-flow/NarrativeFlowEditor.tsx b/src/components/admin/narrative-flow/NarrativeFlowEditor.tsx index 98caa53..fdbcf1a 100644 --- a/src/components/admin/narrative-flow/NarrativeFlowEditor.tsx +++ b/src/components/admin/narrative-flow/NarrativeFlowEditor.tsx @@ -28,6 +28,15 @@ import { StringNode } from "./StringNode"; import { RandomNode } from "./RandomNode"; import { BooleanNode } from "./BooleanNode"; import { DateNode } from "./DateNode"; +import { GameDataNode } from "./GameDataNode"; +import { FlagNode } from "./FlagNode"; +import { CompareNode } from "./CompareNode"; +import { MathNode } from "./MathNode"; +import { FormatNode } from "./FormatNode"; +import { LogicNode } from "./LogicNode"; +import { NotNode } from "./NotNode"; +import { BranchNode } from "./BranchNode"; +import { SequenceNode } from "./SequenceNode"; import { NodeEditDialog } from "./NodeEditDialog"; import { NODE_SOCKETS, type SocketType } from "./types"; @@ -41,6 +50,15 @@ const nodeTypes = { random: RandomNode, boolean: BooleanNode, date: DateNode, + gameData: GameDataNode, + flag: FlagNode, + compare: CompareNode, + math: MathNode, + format: FormatNode, + logic: LogicNode, + not: NotNode, + branch: BranchNode, + sequence: SequenceNode, }; type FlowData = { nodes: Node[]; edges: Edge[] }; @@ -169,32 +187,44 @@ export function NarrativeFlowEditor() { | "string" | "random" | "boolean" - | "date", + | "date" + | "gameData" + | "flag" + | "compare" + | "math" + | "format" + | "logic" + | "not" + | "branch" + | "sequence", ) => { const id = crypto.randomUUID(); const pos = { x: Math.random() * 400, y: Math.random() * 300 }; + const defaults: Record> = { + narrativeBeat: { title: "New beat", text: "" }, + choice: { question: "New choice?", options: [] }, + outcome: { title: "New outcome", text: "", effects: [] }, + wait: { ticks: 10 }, + number: { value: 0 }, + string: { value: "" }, + random: { min: 0, max: 100 }, + boolean: { value: false }, + date: { value: "" }, + gameData: { field: "structure_health" }, + flag: { flagName: "" }, + compare: { operator: ">" }, + math: { operator: "+" }, + format: { template: "{0}" }, + logic: { operator: "AND" }, + not: {}, + branch: {}, + sequence: {}, + }; const newNode: Node = { id, type, position: pos, - data: - type === "choice" - ? { question: "New choice?", options: [] } - : type === "outcome" - ? { title: "New outcome", text: "", effects: [] } - : type === "wait" - ? { ticks: 10 } - : type === "number" - ? { value: 0 } - : type === "string" - ? { value: "" } - : type === "random" - ? { min: 0, max: 100 } - : type === "boolean" - ? { value: false } - : type === "date" - ? { value: "" } - : { title: "New beat", text: "" }, + data: defaults[type] ?? {}, }; setNodes((nds) => [...nds, newNode]); }, @@ -202,67 +232,36 @@ export function NarrativeFlowEditor() { ); const buttonClass = - "px-3 py-1.5 text-xs font-medium rounded-md border transition-colors cursor-pointer"; + "px-2 py-1 text-[10px] font-medium rounded border transition-colors cursor-pointer"; const toolbarButtons = ( -
- - - - - - - - - +
+ Story + + + + + + Data + + + + + + + + + Logic + + + + + + + + - {nodes.length} node{nodes.length !== 1 ? "s" : ""}, {edges.length} edge - {edges.length !== 1 ? "s" : ""} + {nodes.length} node{nodes.length !== 1 ? "s" : ""}, {edges.length} edge{edges.length !== 1 ? "s" : ""}
); @@ -293,28 +292,27 @@ export function NarrativeFlowEditor() { className="!border-zinc-700" style={{ background: "#18181b" }} nodeColor={(n) => { - switch (n.type) { - case "choice": - return "#d97706"; // amber - case "outcome": - return "#10b981"; // emerald - case "narrativeBeat": - return "#3b82f6"; // blue - case "wait": - return "#facc15"; // yellow - case "number": - return "#a855f7"; // purple - case "string": - return "#14b8a6"; // teal - case "random": - return "#ec4899"; // pink - case "boolean": - return "#34d399"; // light green - case "date": - return "#06b6d4"; // cyan - default: - return "#6366f1"; // indigo - } + const colors: Record = { + narrativeBeat: "#3b82f6", + choice: "#d97706", + outcome: "#10b981", + wait: "#facc15", + number: "#a855f7", + string: "#14b8a6", + random: "#ec4899", + boolean: "#34d399", + date: "#06b6d4", + gameData: "#6366f1", + flag: "#f97316", + compare: "#64748b", + math: "#8b5cf6", + format: "#0ea5e9", + logic: "#84cc16", + not: "#ef4444", + branch: "#f43f5e", + sequence: "#d946ef", + }; + return colors[n.type as string] ?? "#6366f1"; }} /> diff --git a/src/components/admin/narrative-flow/NodeEditDialog.tsx b/src/components/admin/narrative-flow/NodeEditDialog.tsx index da9cac5..de8fadb 100644 --- a/src/components/admin/narrative-flow/NodeEditDialog.tsx +++ b/src/components/admin/narrative-flow/NodeEditDialog.tsx @@ -423,38 +423,207 @@ export const NodeEditDialog: FC = ({ ); } + case "gameData": { + const GAME_DATA_FIELDS = [ + { value: "structure_health", label: "Structure Health" }, + { value: "structure_storage", label: "Structure Storage" }, + { value: "deliveries_pending", label: "Pending Deliveries" }, + { value: "deliveries_completed", label: "Completed Deliveries" }, + { value: "resources_total", label: "Total Resources" }, + { value: "faction_reputation", label: "Faction Reputation" }, + { value: "player_count", label: "Player Count" }, + { value: "tick_count", label: "Current Tick" }, + ]; + return ( +
+
+ +
+ {GAME_DATA_FIELDS.map((f) => ( + + ))} +
+
+
+ ); + } + + case "flag": { + return ( +
+
+ + updateField("flagName", e.target.value)} + placeholder="e.g. quest_started, boss_defeated" + className="dark:bg-zinc-800 dark:border-zinc-700 dark:text-zinc-100" + /> +
+
+ ); + } + + case "compare": { + const operators = [">", "<", "==", "!=", ">=", "<="]; + return ( +
+
+ +
+ {operators.map((op) => ( + + ))} +
+
+
+ ); + } + + case "math": { + const operators = ["+", "-", "*", "/"]; + return ( +
+
+ +
+ {operators.map((op) => ( + + ))} +
+
+
+ ); + } + + case "format": { + return ( +
+
+ + updateField("template", e.target.value)} + placeholder="e.g. {0} items delivered to {1}" + className="dark:bg-zinc-800 dark:border-zinc-700 dark:text-zinc-100 font-mono" + /> +

+ Use {"{0}"}, {"{1}"}, etc. for connected data inputs. +

+
+
+ ); + } + + case "logic": { + const operators = ["AND", "OR", "XOR"]; + return ( +
+
+ +
+ {operators.map((op) => ( + + ))} +
+
+
+ ); + } + + case "not": + case "branch": + case "sequence": { + return ( +

+ No configurable properties. +

+ ); + } + default: return

Unknown node type.

; } }; - const typeLabel = - nodeType === "narrativeBeat" - ? "Beat" - : nodeType === "choice" - ? "Choice" - : nodeType === "outcome" - ? "Outcome" - : nodeType === "wait" - ? "Wait" - : nodeType === "number" - ? "Number" - : nodeType === "string" - ? "String" - : nodeType === "random" - ? "Random" - : nodeType === "boolean" - ? "Boolean" - : nodeType === "date" - ? "Date" - : "Node"; + const typeLabel: Record = { + narrativeBeat: "Beat", + choice: "Choice", + outcome: "Outcome", + wait: "Wait", + number: "Number", + string: "String", + random: "Random", + boolean: "Boolean", + date: "Date", + gameData: "Game Data", + flag: "Flag", + compare: "Compare", + math: "Math", + format: "Format", + logic: "Logic", + not: "NOT", + branch: "Branch", + sequence: "Sequence", + }; + const label = typeLabel[nodeType] ?? "Node"; return ( - Edit {typeLabel} + Edit {label} onOpenChange(false)} className="p-2 -m-2 hover:bg-zinc-100 dark:hover:bg-zinc-800 transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-zinc-400 rounded-full" diff --git a/src/components/admin/narrative-flow/NotNode.tsx b/src/components/admin/narrative-flow/NotNode.tsx new file mode 100644 index 0000000..b0dd839 --- /dev/null +++ b/src/components/admin/narrative-flow/NotNode.tsx @@ -0,0 +1,22 @@ +"use client"; + +import { type NodeProps } from "@xyflow/react"; +import { ToggleLeftIcon } from "lucide-react"; +import { NodeHandles } from "./NodeHandles"; + +export function NotNode(_props: NodeProps) { + return ( +
+ +
+ + + NOT + +
+
+ Inverts boolean +
+
+ ); +} diff --git a/src/components/admin/narrative-flow/SequenceNode.tsx b/src/components/admin/narrative-flow/SequenceNode.tsx new file mode 100644 index 0000000..ea0327a --- /dev/null +++ b/src/components/admin/narrative-flow/SequenceNode.tsx @@ -0,0 +1,24 @@ +"use client"; + +import { type NodeProps } from "@xyflow/react"; +import { ListOrderedIcon } from "lucide-react"; +import { NodeHandles } from "./NodeHandles"; + +export function SequenceNode(_props: NodeProps) { + return ( +
+ +
+ + + Sequence + +
+
+ 1 + 2 + 3 +
+
+ ); +}