From 52ec3ec7de9a3e458342611eca304131f9ab2544 Mon Sep 17 00:00:00 2001 From: "azykov@mail.ru" Date: Thu, 3 Sep 2026 11:12:49 +0300 Subject: [PATCH] classes refactoring, converter input pull amount fix --- .../{NodeCard.tsx => BehaviourCard.tsx} | 41 +++++++------ src/components/ConverterCardInfo.tsx | 14 +++++ src/components/GameCanvas.tsx | 61 ++++++++++--------- src/components/LinkLine.tsx | 12 ++-- src/engine/behaviours/converter.ts | 45 +++++++------- src/engine/game.ts | 4 +- src/engine/saveLoad.ts | 8 ++- src/index.css | 18 +++--- src/types/index.ts | 2 +- src/types/serialization.ts | 6 +- src/types/{node.ts => unit.ts} | 2 +- src/ui/graph.ts | 27 ++++---- 12 files changed, 133 insertions(+), 107 deletions(-) rename src/components/{NodeCard.tsx => BehaviourCard.tsx} (60%) create mode 100644 src/components/ConverterCardInfo.tsx rename src/types/{node.ts => unit.ts} (84%) diff --git a/src/components/NodeCard.tsx b/src/components/BehaviourCard.tsx similarity index 60% rename from src/components/NodeCard.tsx rename to src/components/BehaviourCard.tsx index 936e2c6..b9b15b7 100644 --- a/src/components/NodeCard.tsx +++ b/src/components/BehaviourCard.tsx @@ -1,66 +1,68 @@ import { forwardRef } from 'react'; import { Resource } from '../types'; -import { Node2D } from '../ui/graph'; +import { GraphNode } from '../ui/graph'; import { ResourceDescriptions } from '../ui/resource'; import { Game } from '../engine/game'; +import { ConverterCardInfo } from './ConverterCardInfo'; +import { Converter } from '../engine/behaviours/converter'; +import { Behaviour } from '../engine/behaviours/base'; interface Props { game: Game; - node2d: Node2D; + position: GraphNode['position']; + behaviour: Behaviour, selected: boolean; onSelect: () => void; onSocketRef?: (type: 'input' | 'output', index: number, el: HTMLDivElement | null) => void; } -const NodeCard = forwardRef(function NodeCard({ node2d, selected, onSelect, onSocketRef }, ref) { - const node = node2d.node; +export const BehaviourCard = forwardRef(function NodeCard({ position, behaviour, selected, onSelect, onSocketRef }, ref) { function handleClick() { onSelect(); } - const info = node.behaviour.getDisplayInfo(); + const info = behaviour.getDisplayInfo(); const inputSockets = info.sockets.filter((s) => s.type === 'input'); const outputSockets = info.sockets.filter((s) => s.type === 'output'); return (
-
+
{ inputSockets.map((socket) => (
onSocketRef?.('input', socket.index, el)} - className="node__socket" + className="unit__socket" style={{ background: ResourceDescriptions[socket.resource].color }} title={`${ResourceDescriptions[socket.resource].label} (вход)`} /> )) }
-
+
{ outputSockets.map((socket) => (
onSocketRef?.('output', socket.index, el)} - className="node__socket" + className="unit__socket" style={{ background: ResourceDescriptions[socket.resource].color }} title={`${ResourceDescriptions[socket.resource].label} (выход)`} /> )) }
-
#{node.id}
-
{node.name}
-
{node.behaviour.kind}
+
#{behaviour.id}
+
{behaviour.kind}
{ Object.entries(info.storages).map(([name, storage]) => ( -
+
{name}
{ Object.entries(storage) @@ -69,14 +71,15 @@ const NodeCard = forwardRef(function NodeCard({ node2d, s
)) } -
+
{ info.links.map((link) =>
→{link.target}/{link.resource}
) }
-
{JSON.stringify(info.raw, undefined, 4)}
+ { + behaviour.kind === 'coverter' && + } +
{JSON.stringify(info.raw, undefined, 4)}
); }); - -export default NodeCard; diff --git a/src/components/ConverterCardInfo.tsx b/src/components/ConverterCardInfo.tsx new file mode 100644 index 0000000..f319020 --- /dev/null +++ b/src/components/ConverterCardInfo.tsx @@ -0,0 +1,14 @@ +import { Converter } from "../engine/behaviours/converter"; + +type Props = { + converter: Converter; +} + +export const ConverterCardInfo = function ({ converter }: Props) { + return <> +
+
+
{converter.cycleTime.toFixed(4)} of {converter.cycleLength.toFixed(4)}
+
+ +} diff --git a/src/components/GameCanvas.tsx b/src/components/GameCanvas.tsx index eb48078..3257aeb 100644 --- a/src/components/GameCanvas.tsx +++ b/src/components/GameCanvas.tsx @@ -1,6 +1,6 @@ import type { Selection } from '../App'; -import NodeCard from './NodeCard'; -import { build2dNodes, Node2D } from '../ui/graph'; +import { BehaviourCard } from './BehaviourCard'; +import { build2dNodes, GraphNode } from '../ui/graph'; import { useEffect, useLayoutEffect, useRef, useState } from 'react'; import { LinkLine } from './LinkLine'; import { Game } from '../engine/game'; @@ -35,7 +35,7 @@ export const GameCanvas = function ({ return () => observer.disconnect(); }, []); - const [nodes2d, setNodes2d] = useState([]); + const [graphNodes, setNodes2d] = useState([]); useEffect(() => { let cancelled = false; @@ -61,70 +61,71 @@ export const GameCanvas = function ({ const nextCenters: Record = {}; const nextSocketCenters: Record = {}; - for (const node2d of nodes2d) { - const el = nodeElsRef.current.get(node2d.id); - if (el) nextCenters[node2d.id] = centerOf(el); + for (const graphNode of graphNodes) { + const el = nodeElsRef.current.get(graphNode.entity.id); + if (el) nextCenters[graphNode.entity.id] = centerOf(el); - for (const socket of node2d.node.behaviour.getDisplayInfo().sockets) { - const socketKey = `${node2d.id}.${socket.type}.${socket.index}`; + for (const socket of graphNode.entity.getDisplayInfo().sockets) { + const socketKey = `${graphNode.entity.id}.${socket.type}.${socket.index}`; const socketEl = socketElsRef.current.get(socketKey); if (socketEl) nextSocketCenters[socketKey] = centerOf(socketEl); } } setCenters(nextCenters); setSocketCenters(nextSocketCenters); - }, [nodes2d]); + }, [graphNodes]); - function withCenter(node2d: Node2D): Node2D { - const center = centers[node2d.id]; - return center ? { ...node2d, position: center } : node2d; + function withCenter(graphNode: GraphNode): GraphNode { + const center = centers[graphNode.entity.id]; + return center ? { ...graphNode, position: center } : graphNode; } - function withSocket(node2d: Node2D, type: 'input' | 'output', resource: string): Node2D { - const socket = node2d.node.behaviour.getDisplayInfo().sockets + function withSocket(graphNode: GraphNode, type: 'input' | 'output', resource: string): GraphNode { + const socket = graphNode.entity.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); + const center = socket && socketCenters[`${graphNode.entity.id}.${type}.${socket.index}`]; + return center ? { ...graphNode, position: center } : withCenter(graphNode); } return (
{ - nodes2d.map((node2d) => - node2d.node.behaviour.getDisplayInfo().links.map((link, idx) => { - const target = nodes2d.find((n2d) => n2d.id === link.target); + graphNodes.map((graphNode) => + graphNode.entity.getDisplayInfo().links.map((link, idx) => { + const target = graphNodes.find((gn) => gn.entity.id === link.target); return target && ( ); })) } { - nodes2d.map((node2d) => ( - ( + { if (el) - nodeElsRef.current.set(node2d.id, el); + nodeElsRef.current.set(graphNode.entity.id, el); else - nodeElsRef.current.delete(node2d.id); + nodeElsRef.current.delete(graphNode.entity.id); }} onSocketRef={(type, index, el) => { - const socketKey = `${node2d.id}.${type}.${index}`; + const socketKey = `${graphNode.entity.id}.${type}.${index}`; if (el) socketElsRef.current.set(socketKey, el); else socketElsRef.current.delete(socketKey); }} game={game} - key={node2d.id} - node2d={node2d} - selected={selected?.kind === 'node' && selected.id === node2d.id} - onSelect={() => onSelectNode(node2d.id)} + key={graphNode.entity.id} + position={graphNode.position} + behaviour={graphNode.entity} + selected={selected?.kind === 'node' && selected.id === graphNode.entity.id} + onSelect={() => onSelectNode(graphNode.entity.id)} /> ))}
diff --git a/src/components/LinkLine.tsx b/src/components/LinkLine.tsx index 8684480..9f37121 100644 --- a/src/components/LinkLine.tsx +++ b/src/components/LinkLine.tsx @@ -1,18 +1,18 @@ import { Game } from "../engine/game"; import { Resource } from "../types"; -import { Node2D } from "../ui/graph"; +import { GraphNode } from "../ui/graph"; type Props = { game: Game, - from: { node: Node2D, resource: Resource }, - to: { node: Node2D, resource: Resource }, + from: { node: GraphNode, resource: Resource }, + to: { node: GraphNode, 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 markerId = `node-line-arrow-${from.node.entity.id}-${to.node.entity.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); + const fromSocket = from.node.entity.getDisplayInfo().sockets.find((s) => s.type === 'input' && s.resource == from.resource); + const toSocket = to.node.entity.getDisplayInfo().sockets.find((s) => s.type === 'output' && s.resource == to.resource); if (!fromSocket || !toSocket) return null; diff --git a/src/engine/behaviours/converter.ts b/src/engine/behaviours/converter.ts index 3e9641d..c20b09b 100644 --- a/src/engine/behaviours/converter.ts +++ b/src/engine/behaviours/converter.ts @@ -31,37 +31,40 @@ export class Converter extends Behaviour { } /** - * Advances time, pulls as much input as is available toward however many cycles that time - * would allow, then completes only as many full cycles as the input actually on hand supports. - * Any leftover cycle time is kept for later — a starved converter simply stalls until its - * inputs catch up, and then bursts through the backlog once they do. + * A converter grabs a full batch of each input the moment enough is available — reserving + * ahead for however many cycles (including the one currently underway) the elapsed time could + * reach, rather than trickling resources in proportionally as time passes. If the source can't + * supply the full batch, `cycleTime` only advances as far as what was actually secured + * justifies: a converter can't be "cooking" a batch it doesn't have the ingredients for, so it + * stalls proportionally to whatever is actually missing rather than free-running on elapsed + * time alone. */ public tick(deltaTime: number, resolve: (id: string) => Behaviour | undefined): void { - this.cycleTime = this.cycleTime.add(deltaTime); - const wantedCycles = this.cycleTime.div(this.cycleLength).floor(); + const idealCycleTime = this.cycleTime.add(deltaTime); + const reserveCycles = idealCycleTime.div(this.cycleLength).ceil(); + let newCycleTime = idealCycleTime; - if (wantedCycles.gt(0)) { - for (const input of this.inputs.pins) { - const source = resolve(input.link.target); - if (!source) - continue; - const rate = this.inputRates[input.resource] ?? Decimal.dZero; - const have = this.storage[input.resource] ?? Decimal.dZero; - const stillNeeded = rate.mul(wantedCycles).sub(have).max(0); - if (stillNeeded.gt(0)) - this.give(input.resource, source.take(input.link.resource, stillNeeded)); - } - } - - let affordableCycles = wantedCycles; for (const input of this.inputs.pins) { const rate = this.inputRates[input.resource] ?? Decimal.dZero; if (rate.eq(0)) continue; + + const target = rate.mul(reserveCycles); const have = this.storage[input.resource] ?? Decimal.dZero; - affordableCycles = affordableCycles.min(have.div(rate).floor()); + const shortfall = target.sub(have).max(0); + if (shortfall.gt(0)) { + const source = resolve(input.link.target); + if (source) + this.give(input.resource, source.take(input.link.resource, shortfall)); + } + + const haveNow = this.storage[input.resource] ?? Decimal.dZero; + newCycleTime = newCycleTime.min(haveNow.mul(this.cycleLength).div(rate)); } + this.cycleTime = newCycleTime; + const affordableCycles = this.cycleTime.div(this.cycleLength).floor(); + if (affordableCycles.gt(0)) { for (const input of this.inputs.pins) this.take(input.resource, (this.inputRates[input.resource] ?? Decimal.dZero).mul(affordableCycles)); diff --git a/src/engine/game.ts b/src/engine/game.ts index 502d1b5..427f23c 100644 --- a/src/engine/game.ts +++ b/src/engine/game.ts @@ -1,12 +1,12 @@ import Decimal from "break_eternity.js"; import { deserializeBehaviour } from "./saveLoad"; import { SerializedGame } from "../types"; -import { Node } from '../types'; +import { Unit } from '../types'; import { orderByDependency } from "../utils/behaviourSort"; export class Game { public time: Decimal; - public readonly nodes: Node[]; + public readonly nodes: Unit[]; constructor(source: SerializedGame) { this.time = new Decimal(source.time); diff --git a/src/engine/saveLoad.ts b/src/engine/saveLoad.ts index f631827..4e8350e 100644 --- a/src/engine/saveLoad.ts +++ b/src/engine/saveLoad.ts @@ -4,6 +4,10 @@ import { Behaviour } from "./behaviours/base"; import { Converter } from "./behaviours/converter"; import { Game } from "./game"; +export const saveParams = { + path: 'default', +} + export function serializeBehaviour(behaviour: Behaviour): SerializedBehaviour { switch (behaviour.kind) { case 'converter': { @@ -72,12 +76,12 @@ export function deserializeFromString(value: string): Game { const KEY_GAME = 'incgane'; export function save(game: Game) { - localStorage.setItem(KEY_GAME, serializeToString(game)); + localStorage.setItem(`${saveParams.path}_${KEY_GAME}`, serializeToString(game)); } export function load(): Game { try { - const raw = localStorage.getItem(KEY_GAME); + const raw = localStorage.getItem(`${saveParams.path}_${KEY_GAME}`); if (raw) return deserializeFromString(raw); } catch (e) { diff --git a/src/index.css b/src/index.css index 727f75e..d711b21 100644 --- a/src/index.css +++ b/src/index.css @@ -202,7 +202,7 @@ select { .node--converter { border-left-color: var(--accent-converter); } .node--consumer { border-left-color: var(--accent-consumer); } -.node__id { +.unit__id { font-family: var(--display); font-weight: 200; font-size: 10px; @@ -212,7 +212,7 @@ select { text-overflow: ellipsis; } -.node__title { +.unit__title { font-family: var(--display); font-weight: 600; font-size: 12.5px; @@ -222,7 +222,7 @@ select { text-overflow: ellipsis; } -.node__kind { +.unit__kind { font-size: 9px; letter-spacing: 0.05em; text-transform: uppercase; @@ -230,7 +230,7 @@ select { margin: 2px 0 6px; } -.node__resources { +.unit__resources { font-size: 11px; background: #171613; border: 1px solid var(--line); @@ -240,7 +240,7 @@ select { white-space: nowrap; } -.node__links { +.unit__links { font-size: 11px; background: #171613; border: 1px solid var(--line); @@ -250,7 +250,7 @@ select { white-space: nowrap; } -.node__sockets { +.unit__sockets { position: absolute; top: 0; bottom: 0; @@ -260,10 +260,10 @@ select { justify-content: center; gap: 6px; } -.node__sockets--input { left: -5px; } -.node__sockets--output { right: -5px; } +.unit__sockets--input { left: -5px; } +.unit__sockets--output { right: -5px; } -.node__socket { +.unit__socket { width: 10px; height: 10px; border-radius: 50%; diff --git a/src/types/index.ts b/src/types/index.ts index 498ba07..06703a4 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -1,4 +1,4 @@ export * from './behaviour'; -export * from './node'; +export * from './unit'; export * from './resource'; export * from './serialization'; diff --git a/src/types/serialization.ts b/src/types/serialization.ts index 4ea0382..2ff79bf 100644 --- a/src/types/serialization.ts +++ b/src/types/serialization.ts @@ -1,4 +1,4 @@ -import { Node } from './node'; +import { Unit } from './unit'; import { DecimalSource } from "break_eternity.js"; import { Resource } from "./resource"; @@ -44,8 +44,8 @@ export type SerializedBehaviour = { } & (SerializedConverter | SerializedStorage); export type SerializedNode = { - id: Node['id'], - name: Node['name'], + id: Unit['id'], + name: Unit['name'], behaviour: SerializedBehaviour, } diff --git a/src/types/node.ts b/src/types/unit.ts similarity index 84% rename from src/types/node.ts rename to src/types/unit.ts index 6b6a063..2129fbb 100644 --- a/src/types/node.ts +++ b/src/types/unit.ts @@ -1,6 +1,6 @@ import { Behaviour } from "../engine/behaviours/base"; -export type Node = { +export type Unit = { id: string; name: string; behaviour: Behaviour; diff --git a/src/ui/graph.ts b/src/ui/graph.ts index 8f8b22f..24a6b05 100644 --- a/src/ui/graph.ts +++ b/src/ui/graph.ts @@ -1,19 +1,20 @@ import ELK, { ElkNode } from "elkjs/lib/elk.bundled.js"; -import { Node } from "../types"; +import { Unit } from "../types"; +import { Behaviour } from "../engine/behaviours/base"; const elk = new ELK(); -const NODE_WIDTH = 100; +const UNIT_WIDTH = 100; const NODE_HEIGHT = 90; -export type Node2D = { - id: Node['id']; +export type GraphNode = { position: { x: number, y: number }; - node: Node; + kind: 'behaviour'; + entity: Behaviour; } -export async function build2dNodes(nodes: Node[], area: { width: number, height: number, padding: number }): Promise { - const nodesById = new Map(nodes.map((node) => [node.behaviour.id, node])); +export async function build2dNodes(units: Unit[], area: { width: number, height: number, padding: number }): Promise { + const entitiesById = new Map(units.map((unit) => [unit.behaviour.id, unit.behaviour])); const graph: ElkNode = { id: "root", @@ -23,12 +24,12 @@ export async function build2dNodes(nodes: Node[], area: { width: number, height: "elk.spacing.nodeNode": "60", "elk.layered.spacing.nodeNodeBetweenLayers": "100", }, - children: nodes.map((node) => ({ - id: node.behaviour.id, - width: NODE_WIDTH, + children: units.map((unit) => ({ + id: unit.behaviour.id, + width: UNIT_WIDTH, height: NODE_HEIGHT, })), - edges: nodes.flatMap((node) => + edges: units.flatMap((node) => node.behaviour.getDisplayInfo().links.map((link, idx) => ({ id: `${node.behaviour.id}->${link.target}.${idx}`, sources: [node.behaviour.id], @@ -40,11 +41,11 @@ export async function build2dNodes(nodes: Node[], area: { width: number, height: const layout = await elk.layout(graph); return (layout.children ?? []).map((child) => ({ - id: child.id, position: { x: (child.x ?? 0) + area.padding, y: (child.y ?? 0) + area.padding, }, - node: nodesById.get(child.id)!, + kind: 'behaviour', + entity: entitiesById.get(child.id)!, })); }