classes refactoring, converter input pull amount fix

This commit is contained in:
azykov@mail.ru
2026-09-03 11:12:49 +03:00
parent 4f0ae5452f
commit 52ec3ec7de
12 changed files with 133 additions and 107 deletions
@@ -1,66 +1,68 @@
import { forwardRef } from 'react'; import { forwardRef } from 'react';
import { Resource } from '../types'; import { Resource } from '../types';
import { Node2D } from '../ui/graph'; import { GraphNode } from '../ui/graph';
import { ResourceDescriptions } from '../ui/resource'; import { ResourceDescriptions } from '../ui/resource';
import { Game } from '../engine/game'; import { Game } from '../engine/game';
import { ConverterCardInfo } from './ConverterCardInfo';
import { Converter } from '../engine/behaviours/converter';
import { Behaviour } from '../engine/behaviours/base';
interface Props { interface Props {
game: Game; game: Game;
node2d: Node2D; position: GraphNode['position'];
behaviour: Behaviour,
selected: boolean; selected: boolean;
onSelect: () => void; onSelect: () => void;
onSocketRef?: (type: 'input' | 'output', index: number, el: HTMLDivElement | null) => void; onSocketRef?: (type: 'input' | 'output', index: number, el: HTMLDivElement | null) => void;
} }
const NodeCard = forwardRef<HTMLDivElement, Props>(function NodeCard({ node2d, selected, onSelect, onSocketRef }, ref) { export const BehaviourCard = forwardRef<HTMLDivElement, Props>(function NodeCard({ position, behaviour, selected, onSelect, onSocketRef }, ref) {
const node = node2d.node;
function handleClick() { function handleClick() {
onSelect(); onSelect();
} }
const info = node.behaviour.getDisplayInfo(); const info = behaviour.getDisplayInfo();
const inputSockets = info.sockets.filter((s) => s.type === 'input'); const inputSockets = info.sockets.filter((s) => s.type === 'input');
const outputSockets = info.sockets.filter((s) => s.type === 'output'); const outputSockets = info.sockets.filter((s) => s.type === 'output');
return ( return (
<div <div
ref={ref} ref={ref}
className={`node node--${node.behaviour.kind}${selected ? ' is-selected' : ''}`} className={`node node--${behaviour.kind}${selected ? ' is-selected' : ''}`}
style={{ left: node2d.position.x, top: node2d.position.y }} style={{ left: position.x, top: position.y }}
onClick={handleClick} onClick={handleClick}
> >
<div className="node__sockets node__sockets--input"> <div className="unit__sockets unit__sockets--input">
{ {
inputSockets.map((socket) => ( inputSockets.map((socket) => (
<div <div
key={`input.${socket.index}`} key={`input.${socket.index}`}
ref={(el) => onSocketRef?.('input', socket.index, el)} ref={(el) => onSocketRef?.('input', socket.index, el)}
className="node__socket" className="unit__socket"
style={{ background: ResourceDescriptions[socket.resource].color }} style={{ background: ResourceDescriptions[socket.resource].color }}
title={`${ResourceDescriptions[socket.resource].label} (вход)`} title={`${ResourceDescriptions[socket.resource].label} (вход)`}
/> />
)) ))
} }
</div> </div>
<div className="node__sockets node__sockets--output"> <div className="unit__sockets unit__sockets--output">
{ {
outputSockets.map((socket) => ( outputSockets.map((socket) => (
<div <div
key={`output.${socket.index}`} key={`output.${socket.index}`}
ref={(el) => onSocketRef?.('output', socket.index, el)} ref={(el) => onSocketRef?.('output', socket.index, el)}
className="node__socket" className="unit__socket"
style={{ background: ResourceDescriptions[socket.resource].color }} style={{ background: ResourceDescriptions[socket.resource].color }}
title={`${ResourceDescriptions[socket.resource].label} (выход)`} title={`${ResourceDescriptions[socket.resource].label} (выход)`}
/> />
)) ))
} }
</div> </div>
<div className="node__id">#{node.id}</div> <div className="unit__id">#{behaviour.id}</div>
<div className="node__title">{node.name}</div> <div className="unit__kind">{behaviour.kind}</div>
<div className="node__kind">{node.behaviour.kind}</div>
{ {
Object.entries(info.storages).map(([name, storage]) => ( Object.entries(info.storages).map(([name, storage]) => (
<div className="node__resources" key={name}> <div className="unit__resources" key={name}>
<div>{name}</div> <div>{name}</div>
{ {
Object.entries(storage) Object.entries(storage)
@@ -69,14 +71,15 @@ const NodeCard = forwardRef<HTMLDivElement, Props>(function NodeCard({ node2d, s
</div> </div>
)) ))
} }
<div className="node__links"> <div className="unit__links">
{ {
info.links.map((link) => <div key={`${link.target}/${link.resource}`}>→{link.target}/{link.resource}</div>) info.links.map((link) => <div key={`${link.target}/${link.resource}`}>→{link.target}/{link.resource}</div>)
} }
</div> </div>
<div className="node__debug" style={{fontSize: '8px', whiteSpace: 'pre'}}>{JSON.stringify(info.raw, undefined, 4)}</div> {
behaviour.kind === 'coverter' && <ConverterCardInfo converter={behaviour as Converter} />
}
<div className="unit__debug" style={{ fontSize: '8px', whiteSpace: 'pre' }}>{JSON.stringify(info.raw, undefined, 4)}</div>
</div> </div>
); );
}); });
export default NodeCard;
+14
View File
@@ -0,0 +1,14 @@
import { Converter } from "../engine/behaviours/converter";
type Props = {
converter: Converter;
}
export const ConverterCardInfo = function ({ converter }: Props) {
return <>
<div className="unit__converter__progress">
<div className="unit__converter__progress_bar" style={{ width: `${converter.cycleTime.div(converter.cycleLength).toFixed(2)}%` }} />
<div className="unit__converter__progress_info">{converter.cycleTime.toFixed(4)} of {converter.cycleLength.toFixed(4)}</div>
</div>
</>
}
+31 -30
View File
@@ -1,6 +1,6 @@
import type { Selection } from '../App'; import type { Selection } from '../App';
import NodeCard from './NodeCard'; import { BehaviourCard } from './BehaviourCard';
import { build2dNodes, Node2D } from '../ui/graph'; import { build2dNodes, GraphNode } from '../ui/graph';
import { useEffect, useLayoutEffect, useRef, useState } from 'react'; import { useEffect, useLayoutEffect, useRef, useState } from 'react';
import { LinkLine } from './LinkLine'; import { LinkLine } from './LinkLine';
import { Game } from '../engine/game'; import { Game } from '../engine/game';
@@ -35,7 +35,7 @@ export const GameCanvas = function ({
return () => observer.disconnect(); return () => observer.disconnect();
}, []); }, []);
const [nodes2d, setNodes2d] = useState<Node2D[]>([]); const [graphNodes, setNodes2d] = useState<GraphNode[]>([]);
useEffect(() => { useEffect(() => {
let cancelled = false; let cancelled = false;
@@ -61,70 +61,71 @@ export const GameCanvas = function ({
const nextCenters: Record<string, { x: number, y: number }> = {}; const nextCenters: Record<string, { x: number, y: number }> = {};
const nextSocketCenters: Record<string, { x: number, y: number }> = {}; const nextSocketCenters: Record<string, { x: number, y: number }> = {};
for (const node2d of nodes2d) { for (const graphNode of graphNodes) {
const el = nodeElsRef.current.get(node2d.id); const el = nodeElsRef.current.get(graphNode.entity.id);
if (el) nextCenters[node2d.id] = centerOf(el); if (el) nextCenters[graphNode.entity.id] = centerOf(el);
for (const socket of node2d.node.behaviour.getDisplayInfo().sockets) { for (const socket of graphNode.entity.getDisplayInfo().sockets) {
const socketKey = `${node2d.id}.${socket.type}.${socket.index}`; const socketKey = `${graphNode.entity.id}.${socket.type}.${socket.index}`;
const socketEl = socketElsRef.current.get(socketKey); const socketEl = socketElsRef.current.get(socketKey);
if (socketEl) nextSocketCenters[socketKey] = centerOf(socketEl); if (socketEl) nextSocketCenters[socketKey] = centerOf(socketEl);
} }
} }
setCenters(nextCenters); setCenters(nextCenters);
setSocketCenters(nextSocketCenters); setSocketCenters(nextSocketCenters);
}, [nodes2d]); }, [graphNodes]);
function withCenter(node2d: Node2D): Node2D { function withCenter(graphNode: GraphNode): GraphNode {
const center = centers[node2d.id]; const center = centers[graphNode.entity.id];
return center ? { ...node2d, position: center } : node2d; return center ? { ...graphNode, position: center } : graphNode;
} }
function withSocket(node2d: Node2D, type: 'input' | 'output', resource: string): Node2D { function withSocket(graphNode: GraphNode, type: 'input' | 'output', resource: string): GraphNode {
const socket = node2d.node.behaviour.getDisplayInfo().sockets const socket = graphNode.entity.getDisplayInfo().sockets
.find((s) => s.type === type && s.resource === resource); .find((s) => s.type === type && s.resource === resource);
const center = socket && socketCenters[`${node2d.id}.${type}.${socket.index}`]; const center = socket && socketCenters[`${graphNode.entity.id}.${type}.${socket.index}`];
return center ? { ...node2d, position: center } : withCenter(node2d); return center ? { ...graphNode, position: center } : withCenter(graphNode);
} }
return ( return (
<div className="world-wrap"> <div className="world-wrap">
<div id="world" ref={canvasRef}> <div id="world" ref={canvasRef}>
{ {
nodes2d.map((node2d) => graphNodes.map((graphNode) =>
node2d.node.behaviour.getDisplayInfo().links.map((link, idx) => { graphNode.entity.getDisplayInfo().links.map((link, idx) => {
const target = nodes2d.find((n2d) => n2d.id === link.target); const target = graphNodes.find((gn) => gn.entity.id === link.target);
return target && ( return target && (
<LinkLine <LinkLine
game={game} game={game}
key={`${node2d.id}.${idx}`} key={`${graphNode.entity.id}.${idx}`}
from={{ node: withSocket(node2d, 'input', link.resource), resource: link.resource }} from={{ node: withSocket(graphNode, 'input', link.resource), resource: link.resource }}
to={{ node: withSocket(target, 'output', link.resource), resource: link.resource }} to={{ node: withSocket(target, 'output', link.resource), resource: link.resource }}
/> />
); );
})) }))
} }
{ {
nodes2d.map((node2d) => ( graphNodes.map((graphNode) => (
<NodeCard <BehaviourCard
ref={(el) => { ref={(el) => {
if (el) if (el)
nodeElsRef.current.set(node2d.id, el); nodeElsRef.current.set(graphNode.entity.id, el);
else else
nodeElsRef.current.delete(node2d.id); nodeElsRef.current.delete(graphNode.entity.id);
}} }}
onSocketRef={(type, index, el) => { onSocketRef={(type, index, el) => {
const socketKey = `${node2d.id}.${type}.${index}`; const socketKey = `${graphNode.entity.id}.${type}.${index}`;
if (el) if (el)
socketElsRef.current.set(socketKey, el); socketElsRef.current.set(socketKey, el);
else else
socketElsRef.current.delete(socketKey); socketElsRef.current.delete(socketKey);
}} }}
game={game} game={game}
key={node2d.id} key={graphNode.entity.id}
node2d={node2d} position={graphNode.position}
selected={selected?.kind === 'node' && selected.id === node2d.id} behaviour={graphNode.entity}
onSelect={() => onSelectNode(node2d.id)} selected={selected?.kind === 'node' && selected.id === graphNode.entity.id}
onSelect={() => onSelectNode(graphNode.entity.id)}
/> />
))} ))}
</div> </div>
+6 -6
View File
@@ -1,18 +1,18 @@
import { Game } from "../engine/game"; import { Game } from "../engine/game";
import { Resource } from "../types"; import { Resource } from "../types";
import { Node2D } from "../ui/graph"; import { GraphNode } from "../ui/graph";
type Props = { type Props = {
game: Game, game: Game,
from: { node: Node2D, resource: Resource }, from: { node: GraphNode, resource: Resource },
to: { node: Node2D, resource: Resource }, to: { node: GraphNode, resource: Resource },
} }
export const LinkLine = function ({ from, to }: Props) { 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 fromSocket = from.node.entity.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 toSocket = to.node.entity.getDisplayInfo().sockets.find((s) => s.type === 'output' && s.resource == to.resource);
if (!fromSocket || !toSocket) if (!fromSocket || !toSocket)
return null; return null;
+24 -21
View File
@@ -31,37 +31,40 @@ export class Converter extends Behaviour {
} }
/** /**
* Advances time, pulls as much input as is available toward however many cycles that time * A converter grabs a full batch of each input the moment enough is available — reserving
* would allow, then completes only as many full cycles as the input actually on hand supports. * ahead for however many cycles (including the one currently underway) the elapsed time could
* Any leftover cycle time is kept for later — a starved converter simply stalls until its * reach, rather than trickling resources in proportionally as time passes. If the source can't
* inputs catch up, and then bursts through the backlog once they do. * 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 { public tick(deltaTime: number, resolve: (id: string) => Behaviour | undefined): void {
this.cycleTime = this.cycleTime.add(deltaTime); const idealCycleTime = this.cycleTime.add(deltaTime);
const wantedCycles = this.cycleTime.div(this.cycleLength).floor(); 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) { for (const input of this.inputs.pins) {
const rate = this.inputRates[input.resource] ?? Decimal.dZero; const rate = this.inputRates[input.resource] ?? Decimal.dZero;
if (rate.eq(0)) if (rate.eq(0))
continue; continue;
const target = rate.mul(reserveCycles);
const have = this.storage[input.resource] ?? Decimal.dZero; 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)) { if (affordableCycles.gt(0)) {
for (const input of this.inputs.pins) for (const input of this.inputs.pins)
this.take(input.resource, (this.inputRates[input.resource] ?? Decimal.dZero).mul(affordableCycles)); this.take(input.resource, (this.inputRates[input.resource] ?? Decimal.dZero).mul(affordableCycles));
+2 -2
View File
@@ -1,12 +1,12 @@
import Decimal from "break_eternity.js"; import Decimal from "break_eternity.js";
import { deserializeBehaviour } from "./saveLoad"; import { deserializeBehaviour } from "./saveLoad";
import { SerializedGame } from "../types"; import { SerializedGame } from "../types";
import { Node } from '../types'; import { Unit } from '../types';
import { orderByDependency } from "../utils/behaviourSort"; import { orderByDependency } from "../utils/behaviourSort";
export class Game { export class Game {
public time: Decimal; public time: Decimal;
public readonly nodes: Node[]; public readonly nodes: Unit[];
constructor(source: SerializedGame) { constructor(source: SerializedGame) {
this.time = new Decimal(source.time); this.time = new Decimal(source.time);
+6 -2
View File
@@ -4,6 +4,10 @@ import { Behaviour } from "./behaviours/base";
import { Converter } from "./behaviours/converter"; import { Converter } from "./behaviours/converter";
import { Game } from "./game"; import { Game } from "./game";
export const saveParams = {
path: 'default',
}
export function serializeBehaviour(behaviour: Behaviour): SerializedBehaviour { export function serializeBehaviour(behaviour: Behaviour): SerializedBehaviour {
switch (behaviour.kind) { switch (behaviour.kind) {
case 'converter': { case 'converter': {
@@ -72,12 +76,12 @@ export function deserializeFromString(value: string): Game {
const KEY_GAME = 'incgane'; const KEY_GAME = 'incgane';
export function save(game: Game) { export function save(game: Game) {
localStorage.setItem(KEY_GAME, serializeToString(game)); localStorage.setItem(`${saveParams.path}_${KEY_GAME}`, serializeToString(game));
} }
export function load(): Game { export function load(): Game {
try { try {
const raw = localStorage.getItem(KEY_GAME); const raw = localStorage.getItem(`${saveParams.path}_${KEY_GAME}`);
if (raw) if (raw)
return deserializeFromString(raw); return deserializeFromString(raw);
} catch (e) { } catch (e) {
+9 -9
View File
@@ -202,7 +202,7 @@ select {
.node--converter { border-left-color: var(--accent-converter); } .node--converter { border-left-color: var(--accent-converter); }
.node--consumer { border-left-color: var(--accent-consumer); } .node--consumer { border-left-color: var(--accent-consumer); }
.node__id { .unit__id {
font-family: var(--display); font-family: var(--display);
font-weight: 200; font-weight: 200;
font-size: 10px; font-size: 10px;
@@ -212,7 +212,7 @@ select {
text-overflow: ellipsis; text-overflow: ellipsis;
} }
.node__title { .unit__title {
font-family: var(--display); font-family: var(--display);
font-weight: 600; font-weight: 600;
font-size: 12.5px; font-size: 12.5px;
@@ -222,7 +222,7 @@ select {
text-overflow: ellipsis; text-overflow: ellipsis;
} }
.node__kind { .unit__kind {
font-size: 9px; font-size: 9px;
letter-spacing: 0.05em; letter-spacing: 0.05em;
text-transform: uppercase; text-transform: uppercase;
@@ -230,7 +230,7 @@ select {
margin: 2px 0 6px; margin: 2px 0 6px;
} }
.node__resources { .unit__resources {
font-size: 11px; font-size: 11px;
background: #171613; background: #171613;
border: 1px solid var(--line); border: 1px solid var(--line);
@@ -240,7 +240,7 @@ select {
white-space: nowrap; white-space: nowrap;
} }
.node__links { .unit__links {
font-size: 11px; font-size: 11px;
background: #171613; background: #171613;
border: 1px solid var(--line); border: 1px solid var(--line);
@@ -250,7 +250,7 @@ select {
white-space: nowrap; white-space: nowrap;
} }
.node__sockets { .unit__sockets {
position: absolute; position: absolute;
top: 0; top: 0;
bottom: 0; bottom: 0;
@@ -260,10 +260,10 @@ select {
justify-content: center; justify-content: center;
gap: 6px; gap: 6px;
} }
.node__sockets--input { left: -5px; } .unit__sockets--input { left: -5px; }
.node__sockets--output { right: -5px; } .unit__sockets--output { right: -5px; }
.node__socket { .unit__socket {
width: 10px; width: 10px;
height: 10px; height: 10px;
border-radius: 50%; border-radius: 50%;
+1 -1
View File
@@ -1,4 +1,4 @@
export * from './behaviour'; export * from './behaviour';
export * from './node'; export * from './unit';
export * from './resource'; export * from './resource';
export * from './serialization'; export * from './serialization';
+3 -3
View File
@@ -1,4 +1,4 @@
import { Node } from './node'; import { Unit } from './unit';
import { DecimalSource } from "break_eternity.js"; import { DecimalSource } from "break_eternity.js";
import { Resource } from "./resource"; import { Resource } from "./resource";
@@ -44,8 +44,8 @@ export type SerializedBehaviour = {
} & (SerializedConverter | SerializedStorage); } & (SerializedConverter | SerializedStorage);
export type SerializedNode = { export type SerializedNode = {
id: Node['id'], id: Unit['id'],
name: Node['name'], name: Unit['name'],
behaviour: SerializedBehaviour, behaviour: SerializedBehaviour,
} }
+1 -1
View File
@@ -1,6 +1,6 @@
import { Behaviour } from "../engine/behaviours/base"; import { Behaviour } from "../engine/behaviours/base";
export type Node = { export type Unit = {
id: string; id: string;
name: string; name: string;
behaviour: Behaviour; behaviour: Behaviour;
+14 -13
View File
@@ -1,19 +1,20 @@
import ELK, { ElkNode } from "elkjs/lib/elk.bundled.js"; 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 elk = new ELK();
const NODE_WIDTH = 100; const UNIT_WIDTH = 100;
const NODE_HEIGHT = 90; const NODE_HEIGHT = 90;
export type Node2D = { export type GraphNode = {
id: Node['id'];
position: { x: number, y: number }; 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<Node2D[]> { export async function build2dNodes(units: Unit[], area: { width: number, height: number, padding: number }): Promise<GraphNode[]> {
const nodesById = new Map(nodes.map((node) => [node.behaviour.id, node])); const entitiesById = new Map(units.map((unit) => [unit.behaviour.id, unit.behaviour]));
const graph: ElkNode = { const graph: ElkNode = {
id: "root", id: "root",
@@ -23,12 +24,12 @@ export async function build2dNodes(nodes: Node[], area: { width: number, height:
"elk.spacing.nodeNode": "60", "elk.spacing.nodeNode": "60",
"elk.layered.spacing.nodeNodeBetweenLayers": "100", "elk.layered.spacing.nodeNodeBetweenLayers": "100",
}, },
children: nodes.map((node) => ({ children: units.map((unit) => ({
id: node.behaviour.id, id: unit.behaviour.id,
width: NODE_WIDTH, width: UNIT_WIDTH,
height: NODE_HEIGHT, height: NODE_HEIGHT,
})), })),
edges: nodes.flatMap((node) => edges: units.flatMap((node) =>
node.behaviour.getDisplayInfo().links.map((link, idx) => ({ node.behaviour.getDisplayInfo().links.map((link, idx) => ({
id: `${node.behaviour.id}->${link.target}.${idx}`, id: `${node.behaviour.id}->${link.target}.${idx}`,
sources: [node.behaviour.id], sources: [node.behaviour.id],
@@ -40,11 +41,11 @@ export async function build2dNodes(nodes: Node[], area: { width: number, height:
const layout = await elk.layout(graph); const layout = await elk.layout(graph);
return (layout.children ?? []).map((child) => ({ return (layout.children ?? []).map((child) => ({
id: child.id,
position: { position: {
x: (child.x ?? 0) + area.padding, x: (child.x ?? 0) + area.padding,
y: (child.y ?? 0) + area.padding, y: (child.y ?? 0) + area.padding,
}, },
node: nodesById.get(child.id)!, kind: 'behaviour',
entity: entitiesById.get(child.id)!,
})); }));
} }