diff --git a/src/App.tsx b/src/App.tsx index 5c35709..b9db7ce 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -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; diff --git a/src/components/GameCanvas.tsx b/src/components/GameCanvas.tsx index 3e55e64..eb48078 100644 --- a/src/components/GameCanvas.tsx +++ b/src/components/GameCanvas.tsx @@ -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; diff --git a/src/components/LinkLine.tsx b/src/components/LinkLine.tsx index 9cfe657..8684480 100644 --- a/src/components/LinkLine.tsx +++ b/src/components/LinkLine.tsx @@ -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 = { diff --git a/src/components/NodeCard.tsx b/src/components/NodeCard.tsx index 4f1bccf..936e2c6 100644 --- a/src/components/NodeCard.tsx +++ b/src/components/NodeCard.tsx @@ -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; diff --git a/src/components/Sidebar.tsx b/src/components/Sidebar.tsx deleted file mode 100644 index 016ebf0..0000000 --- a/src/components/Sidebar.tsx +++ /dev/null @@ -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) => void; - onUpdateNodeBehavior: (id: string, behavior: Behavior) => void; - onUpdateEdge: (id: string, patch: Partial) => 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) { - const kind = e.target.value as NodeKind | ''; - if (kind) props.onAddNode(kind); - e.target.value = ''; - } - - return ( -
-
-

Узлы

-
    - {topology.nodes.map((n) => ( -
  • props.onSelectNode(n.id)} - > - {n.label} ({n.id}) -
  • - ))} -
- -
- -
-

Связи

-
    - {topology.edges.map((e) => ( -
  • props.onSelectEdge(e.id)} - > - {e.from} → {e.to} [{e.resource}] -
  • - ))} -
- -
- -
-

Редактор

- -
-
- ); -} diff --git a/src/engine/behaviours/base.ts b/src/engine/behaviours/base.ts new file mode 100644 index 0000000..341ee10 --- /dev/null +++ b/src/engine/behaviours/base.ts @@ -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; +} diff --git a/src/engine/functions/index.ts b/src/engine/behaviours/converter.ts similarity index 57% rename from src/engine/functions/index.ts rename to src/engine/behaviours/converter.ts index 306e865..3e9641d 100644 --- a/src/engine/functions/index.ts +++ b/src/engine/behaviours/converter.ts @@ -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; - 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; @@ -102,7 +9,7 @@ export class Converter extends Behaviour { public readonly inputRates: Partial; public readonly outputRates: Partial; - 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; diff --git a/src/engine/game.ts b/src/engine/game.ts new file mode 100644 index 0000000..502d1b5 --- /dev/null +++ b/src/engine/game.ts @@ -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); + } +} diff --git a/src/engine/saveLoad.ts b/src/engine/saveLoad.ts index da85763..f631827 100644 --- a/src/engine/saveLoad.ts +++ b/src/engine/saveLoad.ts @@ -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'); } diff --git a/src/types/behaviour.ts b/src/types/behaviour.ts new file mode 100644 index 0000000..fc7fe79 --- /dev/null +++ b/src/types/behaviour.ts @@ -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; + sockets: BehaviourSocket[]; + links: BehaviourLink[]; + + [x: string]: unknown; +} diff --git a/src/types/index.ts b/src/types/index.ts index 14dac7d..498ba07 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -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; - -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(); - 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>; - rules: { - cycleLength: DecimalSource; - inputRates?: Partial>; - outputRates?: Partial>; - } -} - -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[]; -} \ No newline at end of file +export * from './behaviour'; +export * from './node'; +export * from './resource'; +export * from './serialization'; diff --git a/src/types/node.ts b/src/types/node.ts new file mode 100644 index 0000000..6b6a063 --- /dev/null +++ b/src/types/node.ts @@ -0,0 +1,7 @@ +import { Behaviour } from "../engine/behaviours/base"; + +export type Node = { + id: string; + name: string; + behaviour: Behaviour; +} diff --git a/src/types/resource.ts b/src/types/resource.ts new file mode 100644 index 0000000..557baef --- /dev/null +++ b/src/types/resource.ts @@ -0,0 +1,5 @@ +import Decimal from "break_eternity.js"; + +export type Resource = 'wood' | 'plank' | 'stone'; + +export type Resources = Record; diff --git a/src/types/serialization.ts b/src/types/serialization.ts new file mode 100644 index 0000000..4ea0382 --- /dev/null +++ b/src/types/serialization.ts @@ -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>; + rules: { + cycleLength: DecimalSource; + inputRates?: Partial>; + outputRates?: Partial>; + } +} + +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[]; +} diff --git a/src/utils/behaviourSort.ts b/src/utils/behaviourSort.ts new file mode 100644 index 0000000..faa61a2 --- /dev/null +++ b/src/utils/behaviourSort.ts @@ -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(); + 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; +}