diff --git a/src/components/Factory/Canvas/Edges/ConnectionLine.tsx b/src/components/Factory/Canvas/Edges/ConnectionLine.tsx new file mode 100644 index 000000000..a01034261 --- /dev/null +++ b/src/components/Factory/Canvas/Edges/ConnectionLine.tsx @@ -0,0 +1,100 @@ +import { + BaseEdge, + type ConnectionLineComponent, + type ConnectionLineComponentProps, + getBezierPath, + type Handle, + type Node, +} from "@xyflow/react"; + +import { extractResource } from "../../utils/string"; + +export const ConnectionLine: ConnectionLineComponent = ({ + fromX, + fromY, + fromPosition, + toX, + toY, + toPosition, + connectionStatus, + fromHandle, + toHandle, + fromNode, + toNode, +}: ConnectionLineComponentProps) => { + const [path] = getBezierPath({ + sourceX: fromX, + sourceY: fromY, + sourcePosition: fromPosition, + targetX: toX, + targetY: toY, + targetPosition: toPosition, + }); + + let color; + + switch (connectionStatus) { + case "valid": + color = "green"; + break; + case "invalid": + color = "red"; + break; + default: + color = "gray"; + } + + if ( + toNode && + toHandle && + isInvalidConnection({ fromNode, toNode, fromHandle, toHandle }) + ) { + color = "red"; + } + + const id = `connection-${fromX}-${fromY}-${toX}-${toY}`; + + return ( + + ); +}; + +const isInvalidConnection = ({ + fromNode, + toNode, + fromHandle, + toHandle, +}: { + fromNode: Node; + toNode: Node; + fromHandle: Handle; + toHandle: Handle; +}) => { + const fromResource = extractResource(fromHandle.id); + const toResource = extractResource(toHandle?.id); + + if (!fromResource) { + return true; + } + + if (fromResource === "any" || toResource === "any") { + return false; + } + + if (fromResource !== toResource) { + return true; + } + + if (fromNode.id === toNode?.id) { + return true; + } + + return false; +}; diff --git a/src/components/Factory/Canvas/GameCanvas.tsx b/src/components/Factory/Canvas/GameCanvas.tsx index 69d90e370..3b52e7c45 100644 --- a/src/components/Factory/Canvas/GameCanvas.tsx +++ b/src/components/Factory/Canvas/GameCanvas.tsx @@ -16,10 +16,11 @@ import { useEffect, useState } from "react"; import { BlockStack } from "@/components/ui/layout"; -import { RESOURCES } from "../data/resources"; import { setup } from "../data/setup"; -import type { Building } from "../types/buildings"; -import { isResourceType } from "../types/resources"; +import { extractResource } from "../utils/string"; +import { createOnConnect } from "./callbacks/onConnect"; +import { createOnDrop } from "./callbacks/onDrop"; +import { ConnectionLine } from "./Edges/ConnectionLine"; import ResourceEdge from "./Edges/ResourceEdge"; import BuildingNode from "./Nodes/Building"; @@ -31,8 +32,6 @@ const edgeTypes: Record> = { resourceEdge: ResourceEdge, }; -let nodeIdCounter = 0; - const GameCanvas = ({ children, ...rest }: ReactFlowProps) => { const [nodes, setNodes, onNodesChange] = useNodesState([]); const [edges, setEdges, onEdgesChange] = useEdgesState([]); @@ -48,34 +47,32 @@ const GameCanvas = ({ children, ...rest }: ReactFlowProps) => { instance.fitView({ maxZoom: 1, padding: 0.2 }); }; - const onConnect = (connection: Connection) => { - if (connection.source === connection.target) return; + const onConnect = createOnConnect(setEdges); + const onDrop = createOnDrop(reactFlowInstance, setNodes); - const sourceResource = connection.sourceHandle?.split("-").pop(); - const targetResource = connection.targetHandle?.split("-").pop(); + const isValidConnection = (connection: Connection | Edge) => { + if (connection.source === connection.target) return false; - const resourceName = sourceResource ?? targetResource; + const sourceResource = extractResource(connection.sourceHandle); + const targetResource = extractResource(connection.targetHandle); - if (!isResourceType(resourceName)) { - console.error("Invalid resource type:", resourceName); - return; + if ( + sourceResource !== "any" && + targetResource !== "any" && + sourceResource !== targetResource + ) { + return false; } - const newEdge: Edge = { - ...connection, - id: `${connection.source}-${connection.sourceHandle}-${connection.target}-${connection.targetHandle}`, - type: "resourceEdge", - data: { ...RESOURCES[resourceName] }, - animated: true, - }; - - setEdges((eds) => [ - ...eds, - { - ...connection, - ...newEdge, - } as Edge, - ]); + const hasExistingConnection = edges.some( + (edge) => + (edge.source === connection.source && + edge.sourceHandle === connection.sourceHandle) || + (edge.target === connection.target && + edge.targetHandle === connection.targetHandle), + ); + + return !hasExistingConnection; }; const onNodesDelete = (deleted: Node[]) => { @@ -91,48 +88,6 @@ const GameCanvas = ({ children, ...rest }: ReactFlowProps) => { event.dataTransfer.dropEffect = "move"; }; - const onDrop = (event: DragEvent) => { - event.preventDefault(); - - if (!reactFlowInstance) return; - - const buildingData = event.dataTransfer.getData("application/reactflow"); - if (!buildingData) return; - - try { - const { building } = JSON.parse(buildingData) as { building: Building }; - - const position = reactFlowInstance.screenToFlowPosition({ - x: event.clientX, - y: event.clientY, - }); - - const offsetData = event.dataTransfer.getData("DragStart.offset"); - if (offsetData) { - const { offsetX, offsetY } = JSON.parse(offsetData); - position.x -= offsetX; - position.y -= offsetY; - } - - const newNode: Node = { - id: `${building.id}-${nodeIdCounter++}`, - type: "building", - position, - data: { - ...building, - label: building.name, - }, - draggable: true, - deletable: true, - selectable: true, - }; - - setNodes((nds) => [...nds, newNode]); - } catch (error) { - console.error("Failed to drop building:", error); - } - }; - return ( { edgeTypes={edgeTypes} minZoom={0.1} maxZoom={2} + connectionLineComponent={ConnectionLine} + isValidConnection={isValidConnection} deleteKeyCode={["Delete", "Backspace"]} proOptions={{ hideAttribution: true }} fitView diff --git a/src/components/Factory/Canvas/Handles/BuildingInput.tsx b/src/components/Factory/Canvas/Handles/BuildingInput.tsx index 1800c7d3b..730e105ac 100644 --- a/src/components/Factory/Canvas/Handles/BuildingInput.tsx +++ b/src/components/Factory/Canvas/Handles/BuildingInput.tsx @@ -15,15 +15,22 @@ import { type BuildingInput as BuildingInputConfig, } from "../../types/buildings"; import { isLightColor } from "../../utils/color"; +import { layoutHandleAtPosition } from "./utils"; const BuildingInput = ({ building, input, selected, + index, + groupIndex, + totalInGroup, }: { building: Building; input: BuildingInputConfig; selected?: boolean; + index: number; + groupIndex: number; + totalInGroup: number; }) => { const { resource, position } = input; @@ -31,7 +38,7 @@ const BuildingInput = ({ diff --git a/src/components/Factory/Canvas/Handles/BuildingOutput.tsx b/src/components/Factory/Canvas/Handles/BuildingOutput.tsx index 954ac1bdd..0d2d92036 100644 --- a/src/components/Factory/Canvas/Handles/BuildingOutput.tsx +++ b/src/components/Factory/Canvas/Handles/BuildingOutput.tsx @@ -15,15 +15,22 @@ import { type BuildingOutput as BuildingOutputConfig, } from "../../types/buildings"; import { isLightColor } from "../../utils/color"; +import { layoutHandleAtPosition } from "./utils"; const BuildingOutput = ({ building, output, selected, + index, + groupIndex, + totalInGroup, }: { building: Building; output: BuildingOutputConfig; selected?: boolean; + index: number; + groupIndex: number; + totalInGroup: number; }) => { const { resource, position } = output; @@ -31,7 +38,7 @@ const BuildingOutput = ({ diff --git a/src/components/Factory/Canvas/Handles/utils.ts b/src/components/Factory/Canvas/Handles/utils.ts new file mode 100644 index 000000000..f6ac8c930 --- /dev/null +++ b/src/components/Factory/Canvas/Handles/utils.ts @@ -0,0 +1,29 @@ +import { Position } from "@xyflow/react"; + +export const layoutHandleAtPosition = ({ + position, + groupIndex, + totalInGroup, +}: { + position: Position; + groupIndex: number; + totalInGroup: number; +}) => { + if (totalInGroup === 1) { + return {}; + } + + const spacing = 100 / (totalInGroup + 1); + const offset = spacing * (groupIndex + 1); + + switch (position) { + case Position.Top: + case Position.Bottom: + return { left: `${offset}%` }; + case Position.Left: + case Position.Right: + return { top: `${offset}%` }; + default: + return {}; + } +}; diff --git a/src/components/Factory/Canvas/Nodes/Building.tsx b/src/components/Factory/Canvas/Nodes/Building.tsx index 3d4239add..825f79d69 100644 --- a/src/components/Factory/Canvas/Nodes/Building.tsx +++ b/src/components/Factory/Canvas/Nodes/Building.tsx @@ -1,12 +1,39 @@ -import type { NodeProps } from "@xyflow/react"; +import { + type NodeProps, + useReactFlow, + useUpdateNodeInternals, +} from "@xyflow/react"; +import { useEffect, useMemo } from "react"; import { cn } from "@/lib/utils"; import { isBuildingData } from "../../types/buildings"; +import { rotateBuilding } from "../../utils/rotation"; import BuildingInput from "../Handles/BuildingInput"; import BuildingOutput from "../Handles/BuildingOutput"; -const Building = ({ data, selected }: NodeProps) => { +const Building = ({ id, data, selected }: NodeProps) => { + const { updateNodeData } = useReactFlow(); + const updateNodeInternals = useUpdateNodeInternals(); + + useEffect(() => { + if (!selected) return; + + const handleKeyPress = (event: KeyboardEvent) => { + if (!isBuildingData(data)) return; + + if (event.key === "r" || event.key === "R") { + event.preventDefault(); + const rotatedData = rotateBuilding(data); + updateNodeData(id, { ...rotatedData }); + updateNodeInternals(id); + } + }; + + window.addEventListener("keydown", handleKeyPress); + return () => window.removeEventListener("keydown", handleKeyPress); + }, [selected, id, data, updateNodeData, updateNodeInternals]); + if (!isBuildingData(data)) { return (
@@ -18,18 +45,47 @@ const Building = ({ data, selected }: NodeProps) => { const { icon, name, description, color, inputs = [], outputs = [] } = data; + // Calculate position counts + const inputCounts = useMemo(() => { + const counts: Record = {}; + inputs.forEach((input) => { + counts[input.position] = (counts[input.position] || 0) + 1; + }); + return counts; + }, [inputs]); + + const outputCounts = useMemo(() => { + const counts: Record = {}; + outputs.forEach((output) => { + counts[output.position] = (counts[output.position] || 0) + 1; + }); + return counts; + }, [outputs]); + + // Track index at each position + const inputIndexAtPosition: Record = {}; + const outputIndexAtPosition: Record = {}; + return (
- {inputs.map((input, index) => ( - - ))} + {inputs.map((input, globalIndex) => { + const posIndex = inputIndexAtPosition[input.position] || 0; + inputIndexAtPosition[input.position] = posIndex + 1; + + return ( + + ); + })}
{
- {outputs.map((output, index) => ( - - ))} + {outputs.map((output, globalIndex) => { + const posIndex = outputIndexAtPosition[output.position] || 0; + outputIndexAtPosition[output.position] = posIndex + 1; + + return ( + + ); + })}
); }; diff --git a/src/components/Factory/Canvas/callbacks/onConnect.ts b/src/components/Factory/Canvas/callbacks/onConnect.ts new file mode 100644 index 000000000..2cd2cfb59 --- /dev/null +++ b/src/components/Factory/Canvas/callbacks/onConnect.ts @@ -0,0 +1,53 @@ +import type { Connection, Edge } from "@xyflow/react"; + +import { RESOURCES } from "../../data/resources"; +import { extractResource } from "../../utils/string"; + +export const createOnConnect = ( + setEdges: (update: Edge[] | ((edges: Edge[]) => Edge[])) => void, +) => { + return (connection: Connection) => { + if (connection.source === connection.target) return; + + const sourceResource = extractResource(connection.sourceHandle); + const targetResource = extractResource(connection.targetHandle); + + if ( + sourceResource !== "any" && + targetResource !== "any" && + sourceResource !== targetResource + ) { + return; + } + + const edgeResource = sourceResource ?? targetResource; + + if (!edgeResource) { + console.error("Invalid resource type:", edgeResource); + return; + } + + const newEdge: Edge = { + ...connection, + id: `${connection.source}-${connection.sourceHandle}-${connection.target}-${connection.targetHandle}`, + type: "resourceEdge", + data: { ...RESOURCES[edgeResource] }, + animated: true, + }; + + setEdges((eds) => { + // Remove any existing edges from the same source handle OR to the same target handle + const filteredEdges = eds.filter( + (edge) => + !( + (edge.source === connection.source && + edge.sourceHandle === connection.sourceHandle) || + (edge.target === connection.target && + edge.targetHandle === connection.targetHandle) + ), + ); + + return [...filteredEdges, newEdge]; + }); + }; +}; diff --git a/src/components/Factory/Canvas/callbacks/onDrop.ts b/src/components/Factory/Canvas/callbacks/onDrop.ts new file mode 100644 index 000000000..9683ddf30 --- /dev/null +++ b/src/components/Factory/Canvas/callbacks/onDrop.ts @@ -0,0 +1,53 @@ +import type { Node, ReactFlowInstance } from "@xyflow/react"; +import type { DragEvent } from "react"; + +import type { Building } from "../../types/buildings"; + +let nodeIdCounter = 0; + +export const createOnDrop = ( + reactFlowInstance: ReactFlowInstance | undefined, + setNodes: (update: Node[] | ((nodes: Node[]) => Node[])) => void, +) => { + return (event: DragEvent) => { + event.preventDefault(); + + if (!reactFlowInstance) return; + + const buildingData = event.dataTransfer.getData("application/reactflow"); + if (!buildingData) return; + + try { + const { building } = JSON.parse(buildingData) as { building: Building }; + + const position = reactFlowInstance.screenToFlowPosition({ + x: event.clientX, + y: event.clientY, + }); + + const offsetData = event.dataTransfer.getData("DragStart.offset"); + if (offsetData) { + const { offsetX, offsetY } = JSON.parse(offsetData); + position.x -= offsetX; + position.y -= offsetY; + } + + const newNode: Node = { + id: `${building.id}-${nodeIdCounter++}`, + type: "building", + position, + data: { + ...building, + label: building.name, + }, + draggable: true, + deletable: true, + selectable: true, + }; + + setNodes((nds) => [...nds, newNode]); + } catch (error) { + console.error("Failed to drop building:", error); + } + }; +}; diff --git a/src/components/Factory/data/buildings.ts b/src/components/Factory/data/buildings.ts index 0864e8f92..ebdf40414 100644 --- a/src/components/Factory/data/buildings.ts +++ b/src/components/Factory/data/buildings.ts @@ -44,4 +44,17 @@ export const BUILDINGS: Building[] = [ color: "#228B22", outputs: [{ resource: "wheat", position: Position.Right }], }, + { + id: "sawmill", + name: "Sawmill", + icon: "🏭", + description: "Turns wood into planks", + cost: 0, + color: "#D2691E", + inputs: [ + { resource: "wood", position: Position.Left }, + { resource: "wood", position: Position.Left }, + ], + outputs: [{ resource: "planks", position: Position.Right }], + }, ]; diff --git a/src/components/Factory/utils/rotation.ts b/src/components/Factory/utils/rotation.ts new file mode 100644 index 000000000..dc19ba303 --- /dev/null +++ b/src/components/Factory/utils/rotation.ts @@ -0,0 +1,41 @@ +import { Position } from "@xyflow/react"; + +import type { + Building, + BuildingInput, + BuildingOutput, +} from "../types/buildings"; + +// Rotate position clockwise +const rotatePosition = (position: Position): Position => { + switch (position) { + case Position.Top: + return Position.Right; + case Position.Right: + return Position.Bottom; + case Position.Bottom: + return Position.Left; + case Position.Left: + return Position.Top; + default: + return position; + } +}; + +export const rotateBuilding = (building: Building): Building => { + const rotatedInputs = building.inputs?.map((input: BuildingInput) => ({ + ...input, + position: rotatePosition(input.position), + })); + + const rotatedOutputs = building.outputs?.map((output: BuildingOutput) => ({ + ...output, + position: rotatePosition(output.position), + })); + + return { + ...building, + inputs: rotatedInputs, + outputs: rotatedOutputs, + }; +}; diff --git a/src/components/Factory/utils/string.ts b/src/components/Factory/utils/string.ts new file mode 100644 index 000000000..d381f1768 --- /dev/null +++ b/src/components/Factory/utils/string.ts @@ -0,0 +1,18 @@ +import { isResourceType, type ResourceType } from "../types/resources"; + +export const extractResource = ( + string: string | null | undefined, +): ResourceType | null => { + if (!string) return null; + const match = string.match(/resource:([^-]+)/); + if (!match) return null; + + const resource = match[1]; + + if (!isResourceType(resource)) { + console.error("Invalid resource type in string:", string); + return null; + } + + return resource; +};