project structure refactoring

This commit is contained in:
azykov@mail.ru
2026-09-03 10:39:29 +03:00
parent 4b4ab88918
commit 4f0ae5452f
15 changed files with 251 additions and 351 deletions
+1 -1
View File
@@ -1,9 +1,9 @@
import { useEffect, useState } from 'react';
import Topbar from './components/Topbar';
import { GameCanvas } from './components/GameCanvas';
import { Game } from './types';
import { deserialize, load, save } from './engine/saveLoad';
import { defaultGame } from './prefabs/game';
import { Game } from './engine/game';
export type Selection = { kind: 'node'; id: string } | null;
+1 -1
View File
@@ -1,9 +1,9 @@
import type { Selection } from '../App';
import NodeCard from './NodeCard';
import { Game } from '../types';
import { build2dNodes, Node2D } from '../ui/graph';
import { useEffect, useLayoutEffect, useRef, useState } from 'react';
import { LinkLine } from './LinkLine';
import { Game } from '../engine/game';
interface Props {
game: Game;
+2 -1
View File
@@ -1,4 +1,5 @@
import { Game, Resource } from "../types";
import { Game } from "../engine/game";
import { Resource } from "../types";
import { Node2D } from "../ui/graph";
type Props = {
+2 -1
View File
@@ -1,7 +1,8 @@
import { forwardRef } from 'react';
import { Game, Resource } from '../types';
import { Resource } from '../types';
import { Node2D } from '../ui/graph';
import { ResourceDescriptions } from '../ui/resource';
import { Game } from '../engine/game';
interface Props {
game: Game;
-111
View File
@@ -1,111 +0,0 @@
import type { ChangeEvent } from 'react';
import type { Topology, SimNode, SimEdge, NodeKind, Behavior } from '../engine';
import type { Selection } from '../App';
import NodeEditor from './NodeEditor';
import EdgeEditor from './EdgeEditor';
import { Game, SerializedNodeBehaviour } from '../types';
const BEHAVIOR_DEFAULTS: SerializedNodeBehaviour[] = [
{
kind: 'converter',
inputs: [],
outputs: [],
},
];
interface Props {
game: Game;
selected: Selection;
onSelectNode: (id: string) => void;
onSelectEdge: (id: string) => void;
onAddNode: (kind: NodeKind) => void;
onAddEdge: () => void;
onRemoveNode: (id: string) => void;
onRemoveEdge: (id: string) => void;
onUpdateNode: (id: string, patch: Partial<SimNode>) => void;
onUpdateNodeBehavior: (id: string, behavior: Behavior) => void;
onUpdateEdge: (id: string, patch: Partial<SimEdge>) => void;
}
export default function Sidebar(props: Props) {
const { game: topology, selected } = props;
const selectedNode = selected?.kind === 'node' ? topology.nodes.find((n) => n.id === selected.id) : undefined;
const selectedEdge = selected?.kind === 'edge' ? topology.edges.find((e) => e.id === selected.id) : undefined;
function handleAddNodeChange(e: ChangeEvent<HTMLSelectElement>) {
const kind = e.target.value as NodeKind | '';
if (kind) props.onAddNode(kind);
e.target.value = '';
}
return (
<div className="sidebar">
<div className="sidebar__section">
<h4>Узлы</h4>
<ul className="sidebar__list">
{topology.nodes.map((n) => (
<li
key={n.id}
className={selected?.kind === 'node' && selected.id === n.id ? 'is-active' : ''}
onClick={() => props.onSelectNode(n.id)}
>
{n.label} ({n.id})
</li>
))}
</ul>
<select value="" onChange={handleAddNodeChange}>
<option value="">+ добавить узел…</option>
<option value="source">Источник</option>
<option value="storage">Склад</option>
<option value="converter">Конвертер</option>
<option value="consumer">Потребитель</option>
</select>
</div>
<div className="sidebar__section">
<h4>Связи</h4>
<ul className="sidebar__list">
{topology.edges.map((e) => (
<li
key={e.id}
className={selected?.kind === 'edge' && selected.id === e.id ? 'is-active' : ''}
onClick={() => props.onSelectEdge(e.id)}
>
{e.from} → {e.to} [{e.resource}]
</li>
))}
</ul>
<button className="btn btn--ghost" style={{ marginTop: 8 }} onClick={props.onAddEdge}>
+ добавить связь
</button>
</div>
<div className="sidebar__section sidebar__section--grow">
<h4>Редактор</h4>
<div id="sidebar-content">
{!selected && (
<p className="hint">
Выберите узел или связь в списке слева либо кликните по объекту на схеме.
</p>
)}
{selectedNode && (
<NodeEditor
node={selectedNode}
onUpdate={(patch) => props.onUpdateNode(selectedNode.id, patch)}
onUpdateBehavior={(b) => props.onUpdateNodeBehavior(selectedNode.id, b)}
onRemove={() => props.onRemoveNode(selectedNode.id)}
/>
)}
{selectedEdge && (
<EdgeEditor
edge={selectedEdge}
nodes={topology.nodes}
onUpdate={(patch) => props.onUpdateEdge(selectedEdge.id, patch)}
onRemove={() => props.onRemoveEdge(selectedEdge.id)}
/>
)}
</div>
</div>
</div>
);
}
+58
View File
@@ -0,0 +1,58 @@
import Decimal, { DecimalSource } from "break_eternity.js";
import { BehaviourDisplayInfo, BehaviourInputs, BehaviourOutputs, BehaviourSocket, Resource, SerializedBehaviour } from "../../types";
export abstract class Behaviour {
public readonly id: string;
public readonly kind: string;
public readonly inputs: BehaviourInputs;
public readonly outputs: BehaviourOutputs;
constructor(source: SerializedBehaviour) {
this.id = source.id;
this.kind = source.kind;
this.outputs = {
pins: source.outputs.pins.map((output) => ({
resource: output.resource,
})),
}
this.inputs = {
pins: source.inputs.pins.map((input) => ({
resource: input.resource,
link: input.link,
})),
}
}
public getDisplayInfo(): BehaviourDisplayInfo {
return {
storages: {},
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)),
],
};
}
/** Withdraws up to `amount` of `resource` from this behaviour's storage, returning how much was actually taken. */
public abstract take(resource: Resource, amount: DecimalSource): Decimal;
protected abstract give(resource: Resource, amount: DecimalSource): void;
abstract reset(): void;
/**
* Advances this behaviour by `deltaTime`. Must pull whatever inputs it needs (via `resolve`)
* before advancing its own cycle, so that a cycle only completes once enough input was actually
* available — otherwise it stalls and tries again on a later tick.
*/
abstract tick(deltaTime: number, resolve: (id: string) => Behaviour | undefined): void;
}
@@ -1,99 +1,6 @@
import Decimal, { DecimalSource } from "break_eternity.js";
import { Resource, Resources, SerializedConverter, SerializedNodeBehaviour as SerializedBehaviour, SerializedNodeBehaviour } from "../../types";
export type BehaviourResourcePin = {
resource: Resource;
}
export type BehaviourSocket = {
type: 'input' | 'output';
index: number; // within its type
resource: Resource;
}
export type BehaviourLink = {
target: Behaviour['id'],
resource: Resource;
}
export type BehaviourInput = BehaviourResourcePin & {
link: BehaviourLink;
}
export type BehaviourInputs = {
pins: BehaviourInput[];
}
export type BehaviourOutput = BehaviourResourcePin & {
}
export type BehaviourOutputs = {
pins: BehaviourOutput[];
}
export type BehaviourDisplayInfo = {
storages: Record<string, Resources>;
sockets: BehaviourSocket[];
links: BehaviourLink[];
[x: string]: unknown;
}
export abstract class Behaviour {
public readonly id: string;
public readonly kind: string;
public readonly inputs: BehaviourInputs;
public readonly outputs: BehaviourOutputs;
constructor(source: SerializedBehaviour) {
this.id = source.id;
this.kind = source.kind;
this.outputs = {
pins: source.outputs.pins.map((output) => ({
resource: output.resource,
})),
}
this.inputs = {
pins: source.inputs.pins.map((input) => ({
resource: input.resource,
link: input.link,
})),
}
}
public getDisplayInfo(): BehaviourDisplayInfo {
return {
storages: {},
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)),
],
};
}
/** Withdraws up to `amount` of `resource` from this behaviour's storage, returning how much was actually taken. */
public abstract take(resource: Resource, amount: DecimalSource): Decimal;
protected abstract give(resource: Resource, amount: DecimalSource): void;
abstract reset(): void;
/**
* Advances this behaviour by `deltaTime`. Must pull whatever inputs it needs (via `resolve`)
* before advancing its own cycle, so that a cycle only completes once enough input was actually
* available — otherwise it stalls and tries again on a later tick.
*/
abstract tick(deltaTime: number, resolve: (id: string) => Behaviour | undefined): void;
}
import { BehaviourDisplayInfo, Resource, Resources, SerializedBehaviour, SerializedConverter } from "../../types";
import { Behaviour } from "./base";
export class Converter extends Behaviour {
protected readonly storage: Partial<Resources>;
@@ -102,7 +9,7 @@ export class Converter extends Behaviour {
public readonly inputRates: Partial<Resources>;
public readonly outputRates: Partial<Resources>;
constructor(source: SerializedNodeBehaviour & SerializedConverter) {
constructor(source: SerializedBehaviour & SerializedConverter) {
if (source.kind !== 'converter')
throw new Error('Invalid source');
super(source);
@@ -198,12 +105,3 @@ export class Converter extends Behaviour {
delete (this.storage[res as Resource]);
}
}
// export class Storage extends Behaviour {
// public static type: string = 'storage';
// }
export const behaviours = {
'converter': Converter,
// 'storage': ,
} satisfies Record<string, { new(source: never): Behaviour }>;
+42
View File
@@ -0,0 +1,42 @@
import Decimal from "break_eternity.js";
import { deserializeBehaviour } from "./saveLoad";
import { SerializedGame } from "../types";
import { Node } from '../types';
import { orderByDependency } from "../utils/behaviourSort";
export class Game {
public time: Decimal;
public readonly nodes: Node[];
constructor(source: SerializedGame) {
this.time = new Decimal(source.time);
this.nodes = source.nodes.map((node) => ({
id: node.id,
name: node.name,
behaviour: deserializeBehaviour(node.behaviour),
}));
}
public tick(deltaTime: number) {
this.time = this.time.add(deltaTime);
const behavioursById = new Map(this.nodes.map((node) => [node.behaviour.id, node.behaviour]));
const resolve = (id: string) => behavioursById.get(id);
// Producers must tick (and so fill their storage) before whatever pulls from them,
// so resources can flow all the way down a chain within a single tick.
for (const behaviour of orderByDependency(this.nodes.map((node) => node.behaviour)))
behaviour.tick(deltaTime, resolve);
}
public reset() {
this.time = Decimal.dZero;
for (const node of this.nodes)
node.behaviour.reset();
}
/** Shallow copy with a new identity, so React sees a change after in-place mutations like `tick`. */
public clone(): Game {
return Object.assign(Object.create(Game.prototype), this);
}
}
+7 -5
View File
@@ -1,8 +1,10 @@
import { defaultGame } from "../prefabs/game";
import { Game, SerializedGame, SerializedNodeBehaviour } from "../types";
import { Behaviour, behaviours, Converter } from "./functions";
import { SerializedBehaviour, SerializedGame } from "../types";
import { Behaviour } from "./behaviours/base";
import { Converter } from "./behaviours/converter";
import { Game } from "./game";
export function serializeBehaviour(behaviour: Behaviour): SerializedNodeBehaviour {
export function serializeBehaviour(behaviour: Behaviour): SerializedBehaviour {
switch (behaviour.kind) {
case 'converter': {
const converter = behaviour as Converter;
@@ -35,10 +37,10 @@ export function serializeBehaviour(behaviour: Behaviour): SerializedNodeBehaviou
}
}
export function deserializeBehaviour(value: SerializedNodeBehaviour): Behaviour {
export function deserializeBehaviour(value: SerializedBehaviour): Behaviour {
switch (value.kind) {
case 'converter':
return new (behaviours.converter)(value);
return new Converter(value);
default:
throw new Error('Unknown behaviour');
}
+39
View File
@@ -0,0 +1,39 @@
import { Resource, Resources } from "./resource";
export type BehaviourResourcePin = {
resource: Resource;
}
export type BehaviourSocket = {
type: 'input' | 'output';
index: number; // within its type
resource: Resource;
}
export type BehaviourLink = {
target: string,
resource: Resource;
}
export type BehaviourInput = BehaviourResourcePin & {
link: BehaviourLink;
}
export type BehaviourInputs = {
pins: BehaviourInput[];
}
export type BehaviourOutput = BehaviourResourcePin & {
}
export type BehaviourOutputs = {
pins: BehaviourOutput[];
}
export type BehaviourDisplayInfo = {
storages: Record<string, Resources>;
sockets: BehaviourSocket[];
links: BehaviourLink[];
[x: string]: unknown;
}
+4 -126
View File
@@ -1,126 +1,4 @@
import Decimal, { DecimalSource } from "break_eternity.js";
import { Behaviour } from "../engine/functions";
import { deserializeBehaviour } from "../engine/saveLoad";
export type Resource = 'wood' | 'plank' | 'stone';
export type Resources = Record<Resource, Decimal>;
export type Node = {
id: string;
name: string;
behaviour: Behaviour;
}
/** Orders behaviours so that whatever a behaviour's inputs link to comes before it (a dependency cycle just falls back to encounter order). */
function orderByDependency(behaviours: Behaviour[]): Behaviour[] {
const byId = new Map(behaviours.map((behaviour) => [behaviour.id, behaviour]));
const visited = new Set<string>();
const order: Behaviour[] = [];
function visit(behaviour: Behaviour) {
if (visited.has(behaviour.id))
return;
visited.add(behaviour.id);
for (const input of behaviour.inputs.pins) {
const dependency = byId.get(input.link.target);
if (dependency)
visit(dependency);
}
order.push(behaviour);
}
for (const behaviour of behaviours)
visit(behaviour);
return order;
}
export class Game {
public time: Decimal;
public readonly nodes: Node[];
constructor(source: SerializedGame) {
this.time = new Decimal(source.time);
this.nodes = source.nodes.map((node) => ({
id: node.id,
name: node.name,
behaviour: deserializeBehaviour(node.behaviour),
}));
}
public tick(deltaTime: number) {
this.time = this.time.add(deltaTime);
const behavioursById = new Map(this.nodes.map((node) => [node.behaviour.id, node.behaviour]));
const resolve = (id: string) => behavioursById.get(id);
// Producers must tick (and so fill their storage) before whatever pulls from them,
// so resources can flow all the way down a chain within a single tick.
for (const behaviour of orderByDependency(this.nodes.map((node) => node.behaviour)))
behaviour.tick(deltaTime, resolve);
}
public reset() {
this.time = Decimal.dZero;
for (const node of this.nodes)
node.behaviour.reset();
}
/** Shallow copy with a new identity, so React sees a change after in-place mutations like `tick`. */
public clone(): Game {
return Object.assign(Object.create(Game.prototype), this);
}
}
export type SerializedInput = {
resource: Resource;
link: {
target: SerializedNodeBehaviour['id'],
resource: Resource,
}
}
export type SerializedOutput = {
resource: Resource;
}
export type SerializedInputs = {
pins: SerializedInput[];
}
export type SerializedOutputs = {
pins: SerializedOutput[];
}
export type SerializedConverter = {
kind: 'converter';
cycleTime?: DecimalSource;
storage?: Partial<Record<Resource, DecimalSource>>;
rules: {
cycleLength: DecimalSource;
inputRates?: Partial<Record<Resource, DecimalSource>>;
outputRates?: Partial<Record<Resource, DecimalSource>>;
}
}
export type SerializedStorage = {
kind: 'storage',
}
export type SerializedNodeBehaviour = {
id: string;
inputs: SerializedInputs;
outputs: SerializedOutputs;
} & (SerializedConverter | SerializedStorage);
export type SerializedNode = {
id: Node['id'],
name: Node['name'],
behaviour: SerializedNodeBehaviour,
}
export type SerializedGame = {
time: DecimalSource;
nodes: SerializedNode[];
}
export * from './behaviour';
export * from './node';
export * from './resource';
export * from './serialization';
+7
View File
@@ -0,0 +1,7 @@
import { Behaviour } from "../engine/behaviours/base";
export type Node = {
id: string;
name: string;
behaviour: Behaviour;
}
+5
View File
@@ -0,0 +1,5 @@
import Decimal from "break_eternity.js";
export type Resource = 'wood' | 'plank' | 'stone';
export type Resources = Record<Resource, Decimal>;
+55
View File
@@ -0,0 +1,55 @@
import { Node } from './node';
import { DecimalSource } from "break_eternity.js";
import { Resource } from "./resource";
export type SerializedInput = {
resource: Resource;
link: {
target: SerializedBehaviour['id'],
resource: Resource,
}
}
export type SerializedOutput = {
resource: Resource;
}
export type SerializedInputs = {
pins: SerializedInput[];
}
export type SerializedOutputs = {
pins: SerializedOutput[];
}
export type SerializedConverter = {
kind: 'converter';
cycleTime?: DecimalSource;
storage?: Partial<Record<Resource, DecimalSource>>;
rules: {
cycleLength: DecimalSource;
inputRates?: Partial<Record<Resource, DecimalSource>>;
outputRates?: Partial<Record<Resource, DecimalSource>>;
}
}
export type SerializedStorage = {
kind: 'storage',
}
export type SerializedBehaviour = {
id: string;
inputs: SerializedInputs;
outputs: SerializedOutputs;
} & (SerializedConverter | SerializedStorage);
export type SerializedNode = {
id: Node['id'],
name: Node['name'],
behaviour: SerializedBehaviour,
}
export type SerializedGame = {
time: DecimalSource;
nodes: SerializedNode[];
}
+25
View File
@@ -0,0 +1,25 @@
import { Behaviour } from "../engine/behaviours/base";
/** Orders behaviours so that whatever a behaviour's inputs link to comes before it (a dependency cycle just falls back to encounter order). */
export function orderByDependency(behaviours: Behaviour[]): Behaviour[] {
const byId = new Map(behaviours.map((behaviour) => [behaviour.id, behaviour]));
const visited = new Set<string>();
const order: Behaviour[] = [];
function visit(behaviour: Behaviour) {
if (visited.has(behaviour.id))
return;
visited.add(behaviour.id);
for (const input of behaviour.inputs.pins) {
const dependency = byId.get(input.link.target);
if (dependency)
visit(dependency);
}
order.push(behaviour);
}
for (const behaviour of behaviours)
visit(behaviour);
return order;
}