feat: new blueprint node types for data flow and logic
New node types:
- GameData: reads runtime state (structure health, deliveries, resources, etc.)
- Flag: read/write persistent flags by name
- Compare: A [op] B → boolean (>, <, ==, !=, >=, <=)
- Math: A [op] B → number (+, -, *, /)
- Format: template string with {0}, {1} placeholders → string
- Logic: A [op] B → boolean (AND, OR, XOR)
- NOT: boolean → inverted boolean
- Branch: flow in + boolean condition → true/false flow out
- Sequence: flow in → 3 flow outs (executes in order)
Toolbar updated with categorized buttons (Story, Data, Logic).
MiniMap color-coded for all 18 node types.
NodeEditDialog handles all new types with operator/field selectors.
This commit is contained in:
parent
9a7462e5f7
commit
06fb1ede75
11 changed files with 515 additions and 120 deletions
23
src/components/admin/narrative-flow/BranchNode.tsx
Normal file
23
src/components/admin/narrative-flow/BranchNode.tsx
Normal file
|
|
@ -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 (
|
||||||
|
<div className="bg-rose-950/80 border border-rose-500/50 rounded-lg shadow-md min-w-36">
|
||||||
|
<NodeHandles nodeType="branch" />
|
||||||
|
<div className="flex items-center gap-2 px-3 py-2 border-b border-rose-500/30">
|
||||||
|
<GitBranchIcon className="size-4 text-rose-400 shrink-0" />
|
||||||
|
<span className="text-sm font-medium text-rose-200 flex-1">
|
||||||
|
Branch
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="px-3 py-2 flex justify-between text-xs">
|
||||||
|
<span className="text-emerald-400">true</span>
|
||||||
|
<span className="text-red-400">false</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
25
src/components/admin/narrative-flow/CompareNode.tsx
Normal file
25
src/components/admin/narrative-flow/CompareNode.tsx
Normal file
|
|
@ -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 (
|
||||||
|
<div className="bg-slate-950/80 border border-slate-500/50 rounded-lg shadow-md min-w-32">
|
||||||
|
<NodeHandles nodeType="compare" />
|
||||||
|
<div className="flex items-center gap-2 px-3 py-2 border-b border-slate-500/30">
|
||||||
|
<EqualIcon className="size-4 text-slate-400 shrink-0" />
|
||||||
|
<span className="text-sm font-medium text-slate-200 flex-1">
|
||||||
|
Compare
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="px-3 py-2 text-center text-lg font-mono text-slate-300">
|
||||||
|
{op}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
24
src/components/admin/narrative-flow/FlagNode.tsx
Normal file
24
src/components/admin/narrative-flow/FlagNode.tsx
Normal file
|
|
@ -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 (
|
||||||
|
<div className="bg-orange-950/80 border border-orange-500/50 rounded-lg shadow-md min-w-40">
|
||||||
|
<NodeHandles nodeType="flag" />
|
||||||
|
<div className="flex items-center gap-2 px-3 py-2 border-b border-orange-500/30">
|
||||||
|
<FlagIcon className="size-4 text-orange-400 shrink-0" />
|
||||||
|
<span className="text-sm font-medium text-orange-200 flex-1 truncate">
|
||||||
|
Flag
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="px-3 py-2 text-xs text-orange-300/80 truncate">
|
||||||
|
{d.flagName || "Unnamed flag"}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
24
src/components/admin/narrative-flow/FormatNode.tsx
Normal file
24
src/components/admin/narrative-flow/FormatNode.tsx
Normal file
|
|
@ -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 (
|
||||||
|
<div className="bg-sky-950/80 border border-sky-500/50 rounded-lg shadow-md min-w-40">
|
||||||
|
<NodeHandles nodeType="format" />
|
||||||
|
<div className="flex items-center gap-2 px-3 py-2 border-b border-sky-500/30">
|
||||||
|
<AlignLeftIcon className="size-4 text-sky-400 shrink-0" />
|
||||||
|
<span className="text-sm font-medium text-sky-200 flex-1">
|
||||||
|
Format
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="px-3 py-2 text-xs text-sky-300/80 font-mono truncate">
|
||||||
|
{d.template || "{0} {1}"}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
36
src/components/admin/narrative-flow/GameDataNode.tsx
Normal file
36
src/components/admin/narrative-flow/GameDataNode.tsx
Normal file
|
|
@ -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 (
|
||||||
|
<div className="bg-indigo-950/80 border border-indigo-500/50 rounded-lg shadow-md min-w-44">
|
||||||
|
<NodeHandles nodeType="gameData" />
|
||||||
|
<div className="flex items-center gap-2 px-3 py-2 border-b border-indigo-500/30">
|
||||||
|
<DatabaseIcon className="size-4 text-indigo-400 shrink-0" />
|
||||||
|
<span className="text-sm font-medium text-indigo-200 flex-1 truncate">
|
||||||
|
Game Data
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="px-3 py-2 text-xs text-indigo-300/80">
|
||||||
|
{fieldLabel}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
25
src/components/admin/narrative-flow/LogicNode.tsx
Normal file
25
src/components/admin/narrative-flow/LogicNode.tsx
Normal file
|
|
@ -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 (
|
||||||
|
<div className="bg-lime-950/80 border border-lime-500/50 rounded-lg shadow-md min-w-32">
|
||||||
|
<NodeHandles nodeType="logic" />
|
||||||
|
<div className="flex items-center gap-2 px-3 py-2 border-b border-lime-500/30">
|
||||||
|
<BinaryIcon className="size-4 text-lime-400 shrink-0" />
|
||||||
|
<span className="text-sm font-medium text-lime-200 flex-1">
|
||||||
|
Logic
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="px-3 py-2 text-center text-sm font-mono text-lime-300">
|
||||||
|
{op}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
25
src/components/admin/narrative-flow/MathNode.tsx
Normal file
25
src/components/admin/narrative-flow/MathNode.tsx
Normal file
|
|
@ -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 (
|
||||||
|
<div className="bg-violet-950/80 border border-violet-500/50 rounded-lg shadow-md min-w-32">
|
||||||
|
<NodeHandles nodeType="math" />
|
||||||
|
<div className="flex items-center gap-2 px-3 py-2 border-b border-violet-500/30">
|
||||||
|
<CalculatorIcon className="size-4 text-violet-400 shrink-0" />
|
||||||
|
<span className="text-sm font-medium text-violet-200 flex-1">
|
||||||
|
Math
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="px-3 py-2 text-center text-lg font-mono text-violet-300">
|
||||||
|
{op}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -28,6 +28,15 @@ import { StringNode } from "./StringNode";
|
||||||
import { RandomNode } from "./RandomNode";
|
import { RandomNode } from "./RandomNode";
|
||||||
import { BooleanNode } from "./BooleanNode";
|
import { BooleanNode } from "./BooleanNode";
|
||||||
import { DateNode } from "./DateNode";
|
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 { NodeEditDialog } from "./NodeEditDialog";
|
||||||
import { NODE_SOCKETS, type SocketType } from "./types";
|
import { NODE_SOCKETS, type SocketType } from "./types";
|
||||||
|
|
||||||
|
|
@ -41,6 +50,15 @@ const nodeTypes = {
|
||||||
random: RandomNode,
|
random: RandomNode,
|
||||||
boolean: BooleanNode,
|
boolean: BooleanNode,
|
||||||
date: DateNode,
|
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[] };
|
type FlowData = { nodes: Node[]; edges: Edge[] };
|
||||||
|
|
@ -169,32 +187,44 @@ export function NarrativeFlowEditor() {
|
||||||
| "string"
|
| "string"
|
||||||
| "random"
|
| "random"
|
||||||
| "boolean"
|
| "boolean"
|
||||||
| "date",
|
| "date"
|
||||||
|
| "gameData"
|
||||||
|
| "flag"
|
||||||
|
| "compare"
|
||||||
|
| "math"
|
||||||
|
| "format"
|
||||||
|
| "logic"
|
||||||
|
| "not"
|
||||||
|
| "branch"
|
||||||
|
| "sequence",
|
||||||
) => {
|
) => {
|
||||||
const id = crypto.randomUUID();
|
const id = crypto.randomUUID();
|
||||||
const pos = { x: Math.random() * 400, y: Math.random() * 300 };
|
const pos = { x: Math.random() * 400, y: Math.random() * 300 };
|
||||||
|
const defaults: Record<string, Record<string, unknown>> = {
|
||||||
|
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 = {
|
const newNode: Node = {
|
||||||
id,
|
id,
|
||||||
type,
|
type,
|
||||||
position: pos,
|
position: pos,
|
||||||
data:
|
data: defaults[type] ?? {},
|
||||||
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: "" },
|
|
||||||
};
|
};
|
||||||
setNodes((nds) => [...nds, newNode]);
|
setNodes((nds) => [...nds, newNode]);
|
||||||
},
|
},
|
||||||
|
|
@ -202,67 +232,36 @@ export function NarrativeFlowEditor() {
|
||||||
);
|
);
|
||||||
|
|
||||||
const buttonClass =
|
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 = (
|
const toolbarButtons = (
|
||||||
<div className="flex items-center gap-2 px-2 py-1.5 bg-zinc-900 rounded-t-md border border-zinc-700">
|
<div className="flex flex-wrap items-center gap-1 px-2 py-1.5 bg-zinc-900 rounded-t-md border border-zinc-700">
|
||||||
<button
|
<span className="text-[9px] uppercase tracking-wider text-zinc-500 font-semibold mr-1">Story</span>
|
||||||
className={`${buttonClass} bg-blue-950 border-blue-700 text-blue-200 hover:bg-blue-900`}
|
<button className={`${buttonClass} bg-blue-950 border-blue-700 text-blue-200 hover:bg-blue-900`} onClick={() => addNode("narrativeBeat")}>Beat</button>
|
||||||
onClick={() => addNode("narrativeBeat")}
|
<button className={`${buttonClass} bg-amber-950 border-amber-700 text-amber-200 hover:bg-amber-900`} onClick={() => addNode("choice")}>Choice</button>
|
||||||
>
|
<button className={`${buttonClass} bg-emerald-950 border-emerald-700 text-emerald-200 hover:bg-emerald-900`} onClick={() => addNode("outcome")}>Outcome</button>
|
||||||
+ Beat
|
<button className={`${buttonClass} bg-yellow-950 border-yellow-700 text-yellow-200 hover:bg-yellow-900`} onClick={() => addNode("wait")}>Wait</button>
|
||||||
</button>
|
|
||||||
<button
|
<span className="text-[9px] uppercase tracking-wider text-zinc-500 font-semibold ml-2 mr-1">Data</span>
|
||||||
className={`${buttonClass} bg-amber-950 border-amber-700 text-amber-200 hover:bg-amber-900`}
|
<button className={`${buttonClass} bg-purple-950 border-purple-700 text-purple-200 hover:bg-purple-900`} onClick={() => addNode("number")}>Number</button>
|
||||||
onClick={() => addNode("choice")}
|
<button className={`${buttonClass} bg-teal-950 border-teal-700 text-teal-200 hover:bg-teal-900`} onClick={() => addNode("string")}>String</button>
|
||||||
>
|
<button className={`${buttonClass} bg-emerald-950/30 border-emerald-700/50 text-emerald-200 hover:bg-emerald-900/40`} onClick={() => addNode("boolean")}>Bool</button>
|
||||||
+ Choice
|
<button className={`${buttonClass} bg-cyan-950 border-cyan-700 text-cyan-200 hover:bg-cyan-900`} onClick={() => addNode("date")}>Date</button>
|
||||||
</button>
|
<button className={`${buttonClass} bg-pink-950 border-pink-700 text-pink-200 hover:bg-pink-900`} onClick={() => addNode("random")}>Random</button>
|
||||||
<button
|
<button className={`${buttonClass} bg-indigo-950 border-indigo-700 text-indigo-200 hover:bg-indigo-900`} onClick={() => addNode("gameData")}>GameData</button>
|
||||||
className={`${buttonClass} bg-emerald-950 border-emerald-700 text-emerald-200 hover:bg-emerald-900`}
|
<button className={`${buttonClass} bg-orange-950 border-orange-700 text-orange-200 hover:bg-orange-900`} onClick={() => addNode("flag")}>Flag</button>
|
||||||
onClick={() => addNode("outcome")}
|
|
||||||
>
|
<span className="text-[9px] uppercase tracking-wider text-zinc-500 font-semibold ml-2 mr-1">Logic</span>
|
||||||
+ Outcome
|
<button className={`${buttonClass} bg-slate-950 border-slate-700 text-slate-200 hover:bg-slate-900`} onClick={() => addNode("compare")}>Compare</button>
|
||||||
</button>
|
<button className={`${buttonClass} bg-violet-950 border-violet-700 text-violet-200 hover:bg-violet-900`} onClick={() => addNode("math")}>Math</button>
|
||||||
<button
|
<button className={`${buttonClass} bg-sky-950 border-sky-700 text-sky-200 hover:bg-sky-900`} onClick={() => addNode("format")}>Format</button>
|
||||||
className={`${buttonClass} bg-yellow-950 border-yellow-700 text-yellow-200 hover:bg-yellow-900`}
|
<button className={`${buttonClass} bg-lime-950 border-lime-700 text-lime-200 hover:bg-lime-900`} onClick={() => addNode("logic")}>Logic</button>
|
||||||
onClick={() => addNode("wait")}
|
<button className={`${buttonClass} bg-red-950 border-red-700 text-red-200 hover:bg-red-900`} onClick={() => addNode("not")}>NOT</button>
|
||||||
>
|
<button className={`${buttonClass} bg-rose-950 border-rose-700 text-rose-200 hover:bg-rose-900`} onClick={() => addNode("branch")}>Branch</button>
|
||||||
+ Wait
|
<button className={`${buttonClass} bg-fuchsia-950 border-fuchsia-700 text-fuchsia-200 hover:bg-fuchsia-900`} onClick={() => addNode("sequence")}>Seq</button>
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
className={`${buttonClass} bg-purple-950 border-purple-700 text-purple-200 hover:bg-purple-900`}
|
|
||||||
onClick={() => addNode("number")}
|
|
||||||
>
|
|
||||||
+ Number
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
className={`${buttonClass} bg-teal-950 border-teal-700 text-teal-200 hover:bg-teal-900`}
|
|
||||||
onClick={() => addNode("string")}
|
|
||||||
>
|
|
||||||
+ String
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
className={`${buttonClass} bg-pink-950 border-pink-700 text-pink-200 hover:bg-pink-900`}
|
|
||||||
onClick={() => addNode("random")}
|
|
||||||
>
|
|
||||||
+ Random
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
className={`${buttonClass} bg-emerald-950/30 border-emerald-700/50 text-emerald-200 hover:bg-emerald-900/40`}
|
|
||||||
onClick={() => addNode("boolean")}
|
|
||||||
>
|
|
||||||
+ Boolean
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
className={`${buttonClass} bg-cyan-950 border-cyan-700 text-cyan-200 hover:bg-cyan-900`}
|
|
||||||
onClick={() => addNode("date")}
|
|
||||||
>
|
|
||||||
+ Date
|
|
||||||
</button>
|
|
||||||
<span className="ml-auto text-[10px] text-zinc-500">
|
<span className="ml-auto text-[10px] text-zinc-500">
|
||||||
{nodes.length} node{nodes.length !== 1 ? "s" : ""}, {edges.length} edge
|
{nodes.length} node{nodes.length !== 1 ? "s" : ""}, {edges.length} edge{edges.length !== 1 ? "s" : ""}
|
||||||
{edges.length !== 1 ? "s" : ""}
|
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
@ -293,28 +292,27 @@ export function NarrativeFlowEditor() {
|
||||||
className="!border-zinc-700"
|
className="!border-zinc-700"
|
||||||
style={{ background: "#18181b" }}
|
style={{ background: "#18181b" }}
|
||||||
nodeColor={(n) => {
|
nodeColor={(n) => {
|
||||||
switch (n.type) {
|
const colors: Record<string, string> = {
|
||||||
case "choice":
|
narrativeBeat: "#3b82f6",
|
||||||
return "#d97706"; // amber
|
choice: "#d97706",
|
||||||
case "outcome":
|
outcome: "#10b981",
|
||||||
return "#10b981"; // emerald
|
wait: "#facc15",
|
||||||
case "narrativeBeat":
|
number: "#a855f7",
|
||||||
return "#3b82f6"; // blue
|
string: "#14b8a6",
|
||||||
case "wait":
|
random: "#ec4899",
|
||||||
return "#facc15"; // yellow
|
boolean: "#34d399",
|
||||||
case "number":
|
date: "#06b6d4",
|
||||||
return "#a855f7"; // purple
|
gameData: "#6366f1",
|
||||||
case "string":
|
flag: "#f97316",
|
||||||
return "#14b8a6"; // teal
|
compare: "#64748b",
|
||||||
case "random":
|
math: "#8b5cf6",
|
||||||
return "#ec4899"; // pink
|
format: "#0ea5e9",
|
||||||
case "boolean":
|
logic: "#84cc16",
|
||||||
return "#34d399"; // light green
|
not: "#ef4444",
|
||||||
case "date":
|
branch: "#f43f5e",
|
||||||
return "#06b6d4"; // cyan
|
sequence: "#d946ef",
|
||||||
default:
|
};
|
||||||
return "#6366f1"; // indigo
|
return colors[n.type as string] ?? "#6366f1";
|
||||||
}
|
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</ReactFlow>
|
</ReactFlow>
|
||||||
|
|
|
||||||
|
|
@ -423,38 +423,207 @@ export const NodeEditDialog: FC<NodeEditDialogProps> = ({
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Data Field</Label>
|
||||||
|
<div className="grid grid-cols-2 gap-1.5">
|
||||||
|
{GAME_DATA_FIELDS.map((f) => (
|
||||||
|
<Button
|
||||||
|
key={f.value}
|
||||||
|
variant={nodeData.field === f.value ? "default" : "outline"}
|
||||||
|
size="sm"
|
||||||
|
onClick={() => updateField("field", f.value)}
|
||||||
|
className={
|
||||||
|
nodeData.field === f.value
|
||||||
|
? "bg-indigo-600 hover:bg-indigo-700 text-white"
|
||||||
|
: "dark:border-zinc-700 dark:text-zinc-300"
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{f.label}
|
||||||
|
</Button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
case "flag": {
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="flag-name">Flag Name</Label>
|
||||||
|
<Input
|
||||||
|
id="flag-name"
|
||||||
|
value={(nodeData.flagName as string) ?? ""}
|
||||||
|
onChange={(e) => 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"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
case "compare": {
|
||||||
|
const operators = [">", "<", "==", "!=", ">=", "<="];
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Operator</Label>
|
||||||
|
<div className="flex gap-1.5">
|
||||||
|
{operators.map((op) => (
|
||||||
|
<Button
|
||||||
|
key={op}
|
||||||
|
variant={nodeData.operator === op ? "default" : "outline"}
|
||||||
|
size="sm"
|
||||||
|
onClick={() => updateField("operator", op)}
|
||||||
|
className={
|
||||||
|
nodeData.operator === op
|
||||||
|
? "bg-slate-600 hover:bg-slate-700 text-white"
|
||||||
|
: "dark:border-zinc-700 dark:text-zinc-300"
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{op}
|
||||||
|
</Button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
case "math": {
|
||||||
|
const operators = ["+", "-", "*", "/"];
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Operator</Label>
|
||||||
|
<div className="flex gap-1.5">
|
||||||
|
{operators.map((op) => (
|
||||||
|
<Button
|
||||||
|
key={op}
|
||||||
|
variant={nodeData.operator === op ? "default" : "outline"}
|
||||||
|
size="sm"
|
||||||
|
onClick={() => updateField("operator", op)}
|
||||||
|
className={
|
||||||
|
nodeData.operator === op
|
||||||
|
? "bg-violet-600 hover:bg-violet-700 text-white"
|
||||||
|
: "dark:border-zinc-700 dark:text-zinc-300"
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{op}
|
||||||
|
</Button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
case "format": {
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="format-template">Template</Label>
|
||||||
|
<Input
|
||||||
|
id="format-template"
|
||||||
|
value={(nodeData.template as string) ?? ""}
|
||||||
|
onChange={(e) => 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"
|
||||||
|
/>
|
||||||
|
<p className="text-[10px] text-muted-foreground">
|
||||||
|
Use {"{0}"}, {"{1}"}, etc. for connected data inputs.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
case "logic": {
|
||||||
|
const operators = ["AND", "OR", "XOR"];
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Operator</Label>
|
||||||
|
<div className="flex gap-1.5">
|
||||||
|
{operators.map((op) => (
|
||||||
|
<Button
|
||||||
|
key={op}
|
||||||
|
variant={nodeData.operator === op ? "default" : "outline"}
|
||||||
|
size="sm"
|
||||||
|
onClick={() => updateField("operator", op)}
|
||||||
|
className={
|
||||||
|
nodeData.operator === op
|
||||||
|
? "bg-lime-600 hover:bg-lime-700 text-white"
|
||||||
|
: "dark:border-zinc-700 dark:text-zinc-300"
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{op}
|
||||||
|
</Button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
case "not":
|
||||||
|
case "branch":
|
||||||
|
case "sequence": {
|
||||||
|
return (
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
No configurable properties.
|
||||||
|
</p>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
default:
|
default:
|
||||||
return <p className="text-sm text-muted-foreground">Unknown node type.</p>;
|
return <p className="text-sm text-muted-foreground">Unknown node type.</p>;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const typeLabel =
|
const typeLabel: Record<string, string> = {
|
||||||
nodeType === "narrativeBeat"
|
narrativeBeat: "Beat",
|
||||||
? "Beat"
|
choice: "Choice",
|
||||||
: nodeType === "choice"
|
outcome: "Outcome",
|
||||||
? "Choice"
|
wait: "Wait",
|
||||||
: nodeType === "outcome"
|
number: "Number",
|
||||||
? "Outcome"
|
string: "String",
|
||||||
: nodeType === "wait"
|
random: "Random",
|
||||||
? "Wait"
|
boolean: "Boolean",
|
||||||
: nodeType === "number"
|
date: "Date",
|
||||||
? "Number"
|
gameData: "Game Data",
|
||||||
: nodeType === "string"
|
flag: "Flag",
|
||||||
? "String"
|
compare: "Compare",
|
||||||
: nodeType === "random"
|
math: "Math",
|
||||||
? "Random"
|
format: "Format",
|
||||||
: nodeType === "boolean"
|
logic: "Logic",
|
||||||
? "Boolean"
|
not: "NOT",
|
||||||
: nodeType === "date"
|
branch: "Branch",
|
||||||
? "Date"
|
sequence: "Sequence",
|
||||||
: "Node";
|
};
|
||||||
|
const label = typeLabel[nodeType] ?? "Node";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
<DialogContent className="max-w-lg dark:bg-zinc-900 dark:text-zinc-100 dark:border-zinc-700 bg-white text-zinc-900" showCloseButton={false}>
|
<DialogContent className="max-w-lg dark:bg-zinc-900 dark:text-zinc-100 dark:border-zinc-700 bg-white text-zinc-900" showCloseButton={false}>
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle className="flex items-center justify-between">
|
<DialogTitle className="flex items-center justify-between">
|
||||||
<span>Edit {typeLabel}</span>
|
<span>Edit {label}</span>
|
||||||
<span
|
<span
|
||||||
onClick={() => onOpenChange(false)}
|
onClick={() => 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"
|
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"
|
||||||
|
|
|
||||||
22
src/components/admin/narrative-flow/NotNode.tsx
Normal file
22
src/components/admin/narrative-flow/NotNode.tsx
Normal file
|
|
@ -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 (
|
||||||
|
<div className="bg-red-950/80 border border-red-500/50 rounded-lg shadow-md min-w-28">
|
||||||
|
<NodeHandles nodeType="not" />
|
||||||
|
<div className="flex items-center gap-2 px-3 py-2 border-b border-red-500/30">
|
||||||
|
<ToggleLeftIcon className="size-4 text-red-400 shrink-0" />
|
||||||
|
<span className="text-sm font-medium text-red-200 flex-1">
|
||||||
|
NOT
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="px-3 py-2 text-xs text-red-300/80">
|
||||||
|
Inverts boolean
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
24
src/components/admin/narrative-flow/SequenceNode.tsx
Normal file
24
src/components/admin/narrative-flow/SequenceNode.tsx
Normal file
|
|
@ -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 (
|
||||||
|
<div className="bg-fuchsia-950/80 border border-fuchsia-500/50 rounded-lg shadow-md min-w-32">
|
||||||
|
<NodeHandles nodeType="sequence" />
|
||||||
|
<div className="flex items-center gap-2 px-3 py-2 border-b border-fuchsia-500/30">
|
||||||
|
<ListOrderedIcon className="size-4 text-fuchsia-400 shrink-0" />
|
||||||
|
<span className="text-sm font-medium text-fuchsia-200 flex-1">
|
||||||
|
Sequence
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="px-3 py-2 flex justify-between text-xs text-fuchsia-300/80">
|
||||||
|
<span>1</span>
|
||||||
|
<span>2</span>
|
||||||
|
<span>3</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue