graph representation rewritten using elkjs
This commit is contained in:
@@ -3,7 +3,7 @@ import NodeCard from './NodeCard';
|
|||||||
import { Game } from '../types';
|
import { Game } from '../types';
|
||||||
import { build2dNodes, Node2D } from '../ui/graph';
|
import { build2dNodes, Node2D } from '../ui/graph';
|
||||||
import { useEffect, useLayoutEffect, useRef, useState } from 'react';
|
import { useEffect, useLayoutEffect, useRef, useState } from 'react';
|
||||||
import { NodeLine } from './NodeLine';
|
import { LinkLine } from './LinkLine';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
game: Game;
|
game: Game;
|
||||||
@@ -19,8 +19,10 @@ export const GameCanvas = function ({
|
|||||||
|
|
||||||
const canvasRef = useRef<HTMLDivElement>(null);
|
const canvasRef = useRef<HTMLDivElement>(null);
|
||||||
const nodeElsRef = useRef<Map<string, HTMLDivElement>>(new Map());
|
const nodeElsRef = useRef<Map<string, HTMLDivElement>>(new Map());
|
||||||
|
const socketElsRef = useRef<Map<string, HTMLDivElement>>(new Map());
|
||||||
const [size, setSize] = useState({ width: 0, height: 0 });
|
const [size, setSize] = useState({ width: 0, height: 0 });
|
||||||
const [centers, setCenters] = useState<Record<string, { x: number, y: number }>>({});
|
const [centers, setCenters] = useState<Record<string, { x: number, y: number }>>({});
|
||||||
|
const [socketCenters, setSocketCenters] = useState<Record<string, { x: number, y: number }>>({});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const el = canvasRef.current;
|
const el = canvasRef.current;
|
||||||
@@ -48,17 +50,29 @@ export const GameCanvas = function ({
|
|||||||
const worldEl = canvasRef.current;
|
const worldEl = canvasRef.current;
|
||||||
if (!worldEl) return;
|
if (!worldEl) return;
|
||||||
const worldRect = worldEl.getBoundingClientRect();
|
const worldRect = worldEl.getBoundingClientRect();
|
||||||
const next: Record<string, { x: number, y: number }> = {};
|
|
||||||
for (const node2d of nodes2d) {
|
function centerOf(el: HTMLDivElement) {
|
||||||
const el = nodeElsRef.current.get(node2d.id);
|
|
||||||
if (!el) continue;
|
|
||||||
const rect = el.getBoundingClientRect();
|
const rect = el.getBoundingClientRect();
|
||||||
next[node2d.id] = {
|
return {
|
||||||
x: rect.left - worldRect.left + rect.width / 2,
|
x: rect.left - worldRect.left + rect.width / 2,
|
||||||
y: rect.top - worldRect.top + rect.height / 2,
|
y: rect.top - worldRect.top + rect.height / 2,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
setCenters(next);
|
|
||||||
|
const nextCenters: Record<string, { x: number, y: number }> = {};
|
||||||
|
const nextSocketCenters: Record<string, { x: number, y: number }> = {};
|
||||||
|
for (const node2d of nodes2d) {
|
||||||
|
const el = nodeElsRef.current.get(node2d.id);
|
||||||
|
if (el) nextCenters[node2d.id] = centerOf(el);
|
||||||
|
|
||||||
|
for (const socket of node2d.node.behaviour.getDisplayInfo().sockets) {
|
||||||
|
const socketKey = `${node2d.id}.${socket.type}.${socket.index}`;
|
||||||
|
const socketEl = socketElsRef.current.get(socketKey);
|
||||||
|
if (socketEl) nextSocketCenters[socketKey] = centerOf(socketEl);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setCenters(nextCenters);
|
||||||
|
setSocketCenters(nextSocketCenters);
|
||||||
}, [nodes2d]);
|
}, [nodes2d]);
|
||||||
|
|
||||||
function withCenter(node2d: Node2D): Node2D {
|
function withCenter(node2d: Node2D): Node2D {
|
||||||
@@ -66,6 +80,13 @@ export const GameCanvas = function ({
|
|||||||
return center ? { ...node2d, position: center } : node2d;
|
return center ? { ...node2d, position: center } : node2d;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function withSocket(node2d: Node2D, type: 'input' | 'output', resource: string): Node2D {
|
||||||
|
const socket = node2d.node.behaviour.getDisplayInfo().sockets
|
||||||
|
.find((s) => s.type === type && s.resource === resource);
|
||||||
|
const center = socket && socketCenters[`${node2d.id}.${type}.${socket.index}`];
|
||||||
|
return center ? { ...node2d, position: center } : withCenter(node2d);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="world-wrap">
|
<div className="world-wrap">
|
||||||
<div id="world" ref={canvasRef}>
|
<div id="world" ref={canvasRef}>
|
||||||
@@ -74,11 +95,11 @@ export const GameCanvas = function ({
|
|||||||
node2d.node.behaviour.getDisplayInfo().links.map((link, idx) => {
|
node2d.node.behaviour.getDisplayInfo().links.map((link, idx) => {
|
||||||
const target = nodes2d.find((n2d) => n2d.id === link.target);
|
const target = nodes2d.find((n2d) => n2d.id === link.target);
|
||||||
return target && (
|
return target && (
|
||||||
<NodeLine
|
<LinkLine
|
||||||
game={game}
|
game={game}
|
||||||
key={`${node2d.id}.${idx}`}
|
key={`${node2d.id}.${idx}`}
|
||||||
from={withCenter(node2d)}
|
from={{ node: withSocket(node2d, 'input', link.resource), resource: link.resource }}
|
||||||
to={withCenter(target)}
|
to={{ node: withSocket(target, 'output', link.resource), resource: link.resource }}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}))
|
}))
|
||||||
@@ -90,6 +111,11 @@ export const GameCanvas = function ({
|
|||||||
if (el) nodeElsRef.current.set(node2d.id, el);
|
if (el) nodeElsRef.current.set(node2d.id, el);
|
||||||
else nodeElsRef.current.delete(node2d.id);
|
else nodeElsRef.current.delete(node2d.id);
|
||||||
}}
|
}}
|
||||||
|
onSocketRef={(type, index, el) => {
|
||||||
|
const socketKey = `${node2d.id}.${type}.${index}`;
|
||||||
|
if (el) socketElsRef.current.set(socketKey, el);
|
||||||
|
else socketElsRef.current.delete(socketKey);
|
||||||
|
}}
|
||||||
game={game}
|
game={game}
|
||||||
key={node2d.id}
|
key={node2d.id}
|
||||||
node2d={node2d}
|
node2d={node2d}
|
||||||
|
|||||||
@@ -0,0 +1,45 @@
|
|||||||
|
import { Game, Resource } from "../types";
|
||||||
|
import { Node2D } from "../ui/graph";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
game: Game,
|
||||||
|
from: { node: Node2D, resource: Resource },
|
||||||
|
to: { node: Node2D, resource: Resource },
|
||||||
|
}
|
||||||
|
|
||||||
|
export const LinkLine = function ({ from, to }: Props) {
|
||||||
|
const markerId = `node-line-arrow-${from.node.id}-${to.node.id}`.replace(/[^a-zA-Z0-9_-]/g, '_');
|
||||||
|
|
||||||
|
const fromSocket = from.node.node.behaviour.getDisplayInfo().sockets.find((s) => s.type === 'input' && s.resource == from.resource);
|
||||||
|
const toSocket = to.node.node.behaviour.getDisplayInfo().sockets.find((s) => s.type === 'output' && s.resource == to.resource);
|
||||||
|
|
||||||
|
if (!fromSocket || !toSocket)
|
||||||
|
return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<svg style={{zIndex: 1000}}>
|
||||||
|
<defs>
|
||||||
|
<marker
|
||||||
|
id={markerId}
|
||||||
|
viewBox="0 0 10 10"
|
||||||
|
refX="9"
|
||||||
|
refY="5"
|
||||||
|
markerWidth="6"
|
||||||
|
markerHeight="6"
|
||||||
|
orient="auto-start-reverse"
|
||||||
|
>
|
||||||
|
<path d="M0,0 L10,5 L0,10 z" fill="white" />
|
||||||
|
</marker>
|
||||||
|
</defs>
|
||||||
|
<line
|
||||||
|
x1={from.node.position.x}
|
||||||
|
y1={from.node.position.y}
|
||||||
|
x2={to.node.position.x}
|
||||||
|
y2={to.node.position.y}
|
||||||
|
stroke="white"
|
||||||
|
strokeWidth={2}
|
||||||
|
markerStart={`url(#${markerId})`}
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -8,15 +8,18 @@ interface Props {
|
|||||||
node2d: Node2D;
|
node2d: Node2D;
|
||||||
selected: boolean;
|
selected: boolean;
|
||||||
onSelect: () => void;
|
onSelect: () => void;
|
||||||
|
onSocketRef?: (type: 'input' | 'output', index: number, el: HTMLDivElement | null) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const NodeCard = forwardRef<HTMLDivElement, Props>(function NodeCard({ node2d, selected, onSelect }, ref) {
|
const NodeCard = forwardRef<HTMLDivElement, Props>(function NodeCard({ node2d, selected, onSelect, onSocketRef }, ref) {
|
||||||
const node = node2d.node;
|
const node = node2d.node;
|
||||||
function handleClick() {
|
function handleClick() {
|
||||||
onSelect();
|
onSelect();
|
||||||
}
|
}
|
||||||
|
|
||||||
const info = node.behaviour.getDisplayInfo();
|
const info = node.behaviour.getDisplayInfo();
|
||||||
|
const inputSockets = info.sockets.filter((s) => s.type === 'input');
|
||||||
|
const outputSockets = info.sockets.filter((s) => s.type === 'output');
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@@ -25,6 +28,32 @@ const NodeCard = forwardRef<HTMLDivElement, Props>(function NodeCard({ node2d, s
|
|||||||
style={{ left: node2d.position.x, top: node2d.position.y, width: 100 }}
|
style={{ left: node2d.position.x, top: node2d.position.y, width: 100 }}
|
||||||
onClick={handleClick}
|
onClick={handleClick}
|
||||||
>
|
>
|
||||||
|
<div className="node__sockets node__sockets--input">
|
||||||
|
{
|
||||||
|
inputSockets.map((socket) => (
|
||||||
|
<div
|
||||||
|
key={`input.${socket.index}`}
|
||||||
|
ref={(el) => onSocketRef?.('input', socket.index, el)}
|
||||||
|
className="node__socket"
|
||||||
|
style={{ background: ResourceDescriptions[socket.resource].color }}
|
||||||
|
title={`${ResourceDescriptions[socket.resource].label} (вход)`}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
<div className="node__sockets node__sockets--output">
|
||||||
|
{
|
||||||
|
outputSockets.map((socket) => (
|
||||||
|
<div
|
||||||
|
key={`output.${socket.index}`}
|
||||||
|
ref={(el) => onSocketRef?.('output', socket.index, el)}
|
||||||
|
className="node__socket"
|
||||||
|
style={{ background: ResourceDescriptions[socket.resource].color }}
|
||||||
|
title={`${ResourceDescriptions[socket.resource].label} (выход)`}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
}
|
||||||
|
</div>
|
||||||
<div className="node__id">{node.id}</div>
|
<div className="node__id">{node.id}</div>
|
||||||
<div className="node__title">{node.name}</div>
|
<div className="node__title">{node.name}</div>
|
||||||
<div className="node__kind">{node.behaviour.kind}</div>
|
<div className="node__kind">{node.behaviour.kind}</div>
|
||||||
|
|||||||
@@ -1,39 +0,0 @@
|
|||||||
import { Game } from "../types";
|
|
||||||
import { Node2D } from "../ui/graph";
|
|
||||||
|
|
||||||
type Props = {
|
|
||||||
game: Game,
|
|
||||||
from: Node2D,
|
|
||||||
to: Node2D,
|
|
||||||
}
|
|
||||||
|
|
||||||
export const NodeLine = function ({ from, to }: Props) {
|
|
||||||
const markerId = `node-line-arrow-${from.id}-${to.id}`.replace(/[^a-zA-Z0-9_-]/g, '_');
|
|
||||||
|
|
||||||
return (
|
|
||||||
<svg style={{zIndex: 1000}}>
|
|
||||||
<defs>
|
|
||||||
<marker
|
|
||||||
id={markerId}
|
|
||||||
viewBox="0 0 10 10"
|
|
||||||
refX="9"
|
|
||||||
refY="5"
|
|
||||||
markerWidth="6"
|
|
||||||
markerHeight="6"
|
|
||||||
orient="auto-start-reverse"
|
|
||||||
>
|
|
||||||
<path d="M0,0 L10,5 L0,10 z" fill="white" />
|
|
||||||
</marker>
|
|
||||||
</defs>
|
|
||||||
<line
|
|
||||||
x1={from.position.x}
|
|
||||||
y1={from.position.y}
|
|
||||||
x2={to.position.x}
|
|
||||||
y2={to.position.y}
|
|
||||||
stroke="white"
|
|
||||||
strokeWidth={2}
|
|
||||||
markerStart={`url(#${markerId})`}
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -4,6 +4,12 @@ export type BehaviourResourcePin = {
|
|||||||
resource: Resource;
|
resource: Resource;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type BehaviourSocket = {
|
||||||
|
type: 'input' | 'output';
|
||||||
|
index: number; // within its type
|
||||||
|
resource: Resource;
|
||||||
|
}
|
||||||
|
|
||||||
export type BehaviourLink = {
|
export type BehaviourLink = {
|
||||||
target: Behaviour['id'],
|
target: Behaviour['id'],
|
||||||
resource: Resource;
|
resource: Resource;
|
||||||
@@ -26,6 +32,7 @@ export type BehaviourOutputs = {
|
|||||||
|
|
||||||
export type BehaviourDisplayInfo = {
|
export type BehaviourDisplayInfo = {
|
||||||
storages: Record<string, Resources>;
|
storages: Record<string, Resources>;
|
||||||
|
sockets: BehaviourSocket[];
|
||||||
links: BehaviourLink[];
|
links: BehaviourLink[];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -80,6 +87,18 @@ export class Converter extends Behaviour {
|
|||||||
return {
|
return {
|
||||||
storages: {},
|
storages: {},
|
||||||
links: this.inputs.pins.map((input) => input.link),
|
links: this.inputs.pins.map((input) => input.link),
|
||||||
|
sockets: [
|
||||||
|
...this.inputs.pins.map((input, index) => ({
|
||||||
|
type: 'input',
|
||||||
|
index,
|
||||||
|
resource: input.resource,
|
||||||
|
} satisfies BehaviourSocket)),
|
||||||
|
...this.outputs.pins.map((output, index) => ({
|
||||||
|
type: 'output',
|
||||||
|
index,
|
||||||
|
resource: output.resource,
|
||||||
|
} satisfies BehaviourSocket)),
|
||||||
|
],
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -230,6 +230,27 @@ select {
|
|||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.node__sockets {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
bottom: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
.node__sockets--input { left: -5px; }
|
||||||
|
.node__sockets--output { right: -5px; }
|
||||||
|
|
||||||
|
.node__socket {
|
||||||
|
width: 10px;
|
||||||
|
height: 10px;
|
||||||
|
border-radius: 50%;
|
||||||
|
border: 1.5px solid var(--panel);
|
||||||
|
box-shadow: 0 0 0 1px var(--edge-color);
|
||||||
|
}
|
||||||
|
|
||||||
/* ------------------------------------------------------------ sidebar --- */
|
/* ------------------------------------------------------------ sidebar --- */
|
||||||
|
|
||||||
.sidebar {
|
.sidebar {
|
||||||
|
|||||||
@@ -18,6 +18,10 @@ export const defaultGame: () => SerializedGame = () => ({
|
|||||||
resource: 'wood',
|
resource: 'wood',
|
||||||
rate: 1,
|
rate: 1,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
resource: 'stone',
|
||||||
|
rate: 0.1,
|
||||||
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
+1
-1
@@ -1,7 +1,7 @@
|
|||||||
import { Behaviour } from "../engine/functions";
|
import { Behaviour } from "../engine/functions";
|
||||||
import { deserializeBehaviour } from "../engine/saveLoad";
|
import { deserializeBehaviour } from "../engine/saveLoad";
|
||||||
|
|
||||||
export type Resource = 'wood' | 'plank';
|
export type Resource = 'wood' | 'plank' | 'stone';
|
||||||
|
|
||||||
export type Resources = Record<Resource, number>;
|
export type Resources = Record<Resource, number>;
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -19,7 +19,7 @@ export async function build2dNodes(nodes: Node[], area: { width: number, height:
|
|||||||
id: "root",
|
id: "root",
|
||||||
layoutOptions: {
|
layoutOptions: {
|
||||||
"elk.algorithm": "layered",
|
"elk.algorithm": "layered",
|
||||||
"elk.direction": "RIGHT",
|
"elk.direction": "LEFT",
|
||||||
"elk.spacing.nodeNode": "60",
|
"elk.spacing.nodeNode": "60",
|
||||||
"elk.layered.spacing.nodeNodeBetweenLayers": "100",
|
"elk.layered.spacing.nodeNodeBetweenLayers": "100",
|
||||||
},
|
},
|
||||||
|
|||||||
+1
-1
@@ -11,7 +11,7 @@ export const ResourceDescriptions: Record<ResourceDescription['id'], ResourceDes
|
|||||||
wood: { id: 'wood', label: 'Дерево', color: '#b98249', icon: '🌲' },
|
wood: { id: 'wood', label: 'Дерево', color: '#b98249', icon: '🌲' },
|
||||||
plank: { id: 'plank', label: 'Доска', color: '#c9c209', icon: 'P' },
|
plank: { id: 'plank', label: 'Доска', color: '#c9c209', icon: 'P' },
|
||||||
// iron: { id: 'iron', label: 'Железо', color: '#9aa7b0', icon: '⛓' },
|
// iron: { id: 'iron', label: 'Железо', color: '#9aa7b0', icon: '⛓' },
|
||||||
// stone: { id: 'stone', label: 'Камень', color: '#8a8a8a', icon: '🪨' },
|
stone: { id: 'stone', label: 'Камень', color: '#8a8a8a', icon: '🪨' },
|
||||||
// gold: { id: 'gold', label: 'Золото', color: '#d9b64e', icon: '💰' },
|
// gold: { id: 'gold', label: 'Золото', color: '#d9b64e', icon: '💰' },
|
||||||
// knowledge: { id: 'knowledge', label: 'Знание', color: '#7d8bf0', icon: '📖' },
|
// knowledge: { id: 'knowledge', label: 'Знание', color: '#7d8bf0', icon: '📖' },
|
||||||
};
|
};
|
||||||
Reference in New Issue
Block a user