Compare commits

...
3 Commits
Author SHA1 Message Date
azykov@mail.ru b9c0c0ee28 break_eternity 2026-08-31 22:21:53 +03:00
azykov@mail.ru 90501176ef minor converter rates refactoring 2026-08-31 22:10:16 +03:00
azykov@mail.ru d96a6e2e92 basic logic 2026-08-31 22:01:35 +03:00
9 changed files with 105 additions and 95 deletions
+7
View File
@@ -8,6 +8,7 @@
"name": "resource-graph-sim", "name": "resource-graph-sim",
"version": "0.1.0", "version": "0.1.0",
"dependencies": { "dependencies": {
"break_eternity.js": "^2.1.3",
"elkjs": "^0.12.0", "elkjs": "^0.12.0",
"react": "^18.3.1", "react": "^18.3.1",
"react-dom": "^18.3.1" "react-dom": "^18.3.1"
@@ -1231,6 +1232,12 @@
"node": ">=6.0.0" "node": ">=6.0.0"
} }
}, },
"node_modules/break_eternity.js": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/break_eternity.js/-/break_eternity.js-2.1.3.tgz",
"integrity": "sha512-4tg4j0wc0lhaYAnOHubN5mAyHbhMfUI7adQLO8l/loKqtylZ/kHWp8WYqG2EC0TinSesKvpCi3XeVFcKRUBJsQ==",
"license": "MIT"
},
"node_modules/browserslist": { "node_modules/browserslist": {
"version": "4.28.8", "version": "4.28.8",
"resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.8.tgz", "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.8.tgz",
+1
View File
@@ -9,6 +9,7 @@
"preview": "vite preview" "preview": "vite preview"
}, },
"dependencies": { "dependencies": {
"break_eternity.js": "^2.1.3",
"elkjs": "^0.12.0", "elkjs": "^0.12.0",
"react": "^18.3.1", "react": "^18.3.1",
"react-dom": "^18.3.1" "react-dom": "^18.3.1"
+4 -10
View File
@@ -15,13 +15,8 @@ export default function App() {
// useEffect(() => saveTickCount(tickCount), [tickCount]); // useEffect(() => saveTickCount(tickCount), [tickCount]);
function handleTick() { function handleTick() {
game.tick(1.0 / 60); game.tick(1.0 / 10);
// const nextTopology = cloneJSON(game); setGame(game.clone());
// const nextBuffers = cloneJSON(buffers);
// tick(nextTopology, nextBuffers);
// setGame(nextTopology);
// setBuffers(nextBuffers);
// setTickCount((n) => n + 1);
} }
function handleReset() { function handleReset() {
@@ -31,8 +26,8 @@ export default function App() {
if (!ok) if (!ok)
return; return;
game.time = 0; game.reset();
save(game); setGame(game.clone());
} }
function handleInitialize() { function handleInitialize() {
@@ -43,7 +38,6 @@ export default function App() {
return; return;
setGame(deserialize(defaultGame())); setGame(deserialize(defaultGame()));
save(game);
} }
// function handleAddNode(kind: NodeKind) { // function handleAddNode(kind: NodeKind) {
+3 -2
View File
@@ -59,11 +59,11 @@ const NodeCard = forwardRef<HTMLDivElement, Props>(function NodeCard({ node2d, s
<div className="node__kind">{node.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"> <div className="node__resources" key={name}>
<div>{name}</div> <div>{name}</div>
{ {
Object.entries(storage) Object.entries(storage)
.map(([rt, v]) => `${ResourceDescriptions[rt as Resource].icon}${v}`).join(' ') .map(([rt, v]) => `${ResourceDescriptions[rt as Resource].icon}${v.toStringWithDecimalPlaces(4)}`).join(' ')
} }
</div> </div>
)) ))
@@ -73,6 +73,7 @@ const NodeCard = forwardRef<HTMLDivElement, Props>(function NodeCard({ node2d, s
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: '50%', whiteSpace: 'pre'}}>{JSON.stringify(info, undefined, 4)}</div>
</div> </div>
); );
}); });
+4 -2
View File
@@ -1,5 +1,7 @@
import type Decimal from 'break_eternity.js';
interface Props { interface Props {
time: number; time: Decimal;
onTick: () => void; onTick: () => void;
onReset: () => void; onReset: () => void;
onInitialize: () => void; onInitialize: () => void;
@@ -13,7 +15,7 @@ export default function Topbar({ time, onTick, onReset, onInitialize }: Props) {
<span>детерминированная тиковая симуляция</span> <span>детерминированная тиковая симуляция</span>
</h1> </h1>
<div className="tick-counter"> <div className="tick-counter">
Время <b>{time}</b> Время <b>{time.toStringWithDecimalPlaces(4)}</b>
</div> </div>
<div className="spacer" /> <div className="spacer" />
<button className="btn" onClick={onInitialize}> <button className="btn" onClick={onInitialize}>
+52 -19
View File
@@ -1,4 +1,5 @@
import { Resource, Resources, SerializedConverter, SerializedNodeBehaviour as SerializedBehaviour, SerializedNodeBehaviour, SerializedConverterOutput, SerializedConverterInput } from "../../types"; import Decimal, { DecimalSource } from "break_eternity.js";
import { Resource, Resources, SerializedConverter, SerializedNodeBehaviour as SerializedBehaviour, SerializedNodeBehaviour } from "../../types";
export type BehaviourResourcePin = { export type BehaviourResourcePin = {
resource: Resource; resource: Resource;
@@ -41,13 +42,14 @@ export abstract class Behaviour {
public readonly kind: string; public readonly kind: string;
public readonly inputs: BehaviourInputs; public readonly inputs: BehaviourInputs;
public readonly outputs: BehaviourOutputs; public readonly outputs: BehaviourOutputs;
protected readonly storage: Partial<Resources> = {};
constructor(source: SerializedBehaviour) { constructor(source: SerializedBehaviour) {
this.id = source.id; this.id = source.id;
this.kind = source.kind; this.kind = source.kind;
this.outputs = { this.outputs = {
pins: source.outputs.pins.map((input) => ({ pins: source.outputs.pins.map((output) => ({
resource: input.resource, resource: output.resource,
})), })),
} }
this.inputs = { this.inputs = {
@@ -58,34 +60,69 @@ export abstract class Behaviour {
} }
} }
public reset() {
for (const res of Object.keys(this.storage))
delete (this.storage[res as Resource]);
}
abstract getDisplayInfo(): BehaviourDisplayInfo; abstract getDisplayInfo(): BehaviourDisplayInfo;
abstract tick(deltaTime: number): void; /** Withdraws up to `amount` of `resource` from this behaviour's storage, returning how much was actually taken. */
} public take(resource: Resource, amount: DecimalSource): Decimal {
const available = this.storage[resource] ?? Decimal.dZero;
const taken = available.min(amount);
this.storage[resource] = available.sub(taken);
return taken;
}
export type ResourceUsageRule = { protected give(resource: Resource, amount: DecimalSource): void {
resource: Resource; this.storage[resource] = (this.storage[resource] ?? Decimal.dZero).add(amount);
speed: number; // per second }
abstract produce(deltaTime: number): void;
abstract pull(deltaTime: number, resolve: (id: string) => Behaviour | undefined): void;
} }
export class Converter extends Behaviour { export class Converter extends Behaviour {
public outputRules: ResourceUsageRule[] = []; public readonly inputRates: Partial<Resources>;
rules: { inputs: SerializedConverterInput[]; outputs: SerializedConverterOutput[]; }; public readonly outputRates: Partial<Resources>;
constructor(source: SerializedNodeBehaviour & SerializedConverter) { constructor(source: SerializedNodeBehaviour & SerializedConverter) {
if (source.kind !== 'converter') if (source.kind !== 'converter')
throw new Error('Invalid source'); throw new Error('Invalid source');
super(source); super(source);
this.rules = { this.inputRates = {};
inputs: source.inputs.pins, for (const pin of source.inputs.pins)
outputs: source.outputs.pins, this.inputRates[pin.resource] = new Decimal(pin.rate);
};
this.outputRates = {};
for (const pin of source.outputs.pins)
this.outputRates[pin.resource] = new Decimal(pin.rate);
}
/** Fills this converter's own storage from its outputs' production rates. */
public produce(deltaTime: number): void {
for (const output of this.outputs.pins)
this.give(output.resource, (this.outputRates[output.resource] ?? Decimal.dZero).mul(deltaTime));
}
/** Pulls resources into this converter's storage from whatever each input pin is linked to. */
public pull(deltaTime: number, resolve: (id: string) => Behaviour | undefined): void {
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 taken = source.take(input.link.resource, rate.mul(deltaTime));
this.give(input.resource, taken);
}
} }
public getDisplayInfo(): BehaviourDisplayInfo { public getDisplayInfo(): BehaviourDisplayInfo {
return { return {
storages: {}, storages: { 'Буфер': { ...this.storage } as Resources },
links: this.inputs.pins.map((input) => input.link), links: this.inputs.pins.map((input) => input.link),
sockets: [ sockets: [
...this.inputs.pins.map((input, index) => ({ ...this.inputs.pins.map((input, index) => ({
@@ -101,10 +138,6 @@ export class Converter extends Behaviour {
], ],
} }
} }
tick(deltaTime: number): void {
}
} }
// export class Storage extends Behaviour { // export class Storage extends Behaviour {
+9 -7
View File
@@ -1,26 +1,28 @@
import { defaultGame } from "../prefabs/game"; import { defaultGame } from "../prefabs/game";
import { Game, SerializedGame, SerializedNodeBehaviour } from "../types"; import { Game, SerializedGame, SerializedNodeBehaviour } from "../types";
import { Behaviour, behaviours } from "./functions"; import { Behaviour, behaviours, Converter } from "./functions";
export function serializeBehaviour(behaviour: Behaviour): SerializedNodeBehaviour { export function serializeBehaviour(behaviour: Behaviour): SerializedNodeBehaviour {
switch (behaviour.kind) { switch (behaviour.kind) {
case 'converter': case 'converter': {
const converter = behaviour as Converter;
return { return {
id: behaviour.id, id: converter.id,
kind: 'converter', kind: 'converter',
inputs: { inputs: {
pins: behaviour.inputs.pins.map((pin) => ({ pins: converter.inputs.pins.map((pin) => ({
...pin, ...pin,
rate: 11, rate: converter.inputRates[pin.resource] ?? 0,
})), })),
}, },
outputs: { outputs: {
pins: behaviour.outputs.pins.map((pin) => ({ pins: converter.outputs.pins.map((pin) => ({
...pin, ...pin,
rate: 11, rate: converter.outputRates[pin.resource] ?? 0,
})), })),
}, },
}; };
}
default: default:
throw new Error('Unknown behaviour'); throw new Error('Unknown behaviour');
} }
-47
View File
@@ -1,47 +0,0 @@
import type { Topology, Buffers } from './engine';
import { defaultTopology, resetBuffers } from './engine';
const KEY_TOPOLOGY = 'incsim.topology.v1';
const KEY_BUFFERS = 'incsim.buffers.v1';
const KEY_TICKS = 'incsim.tickcount.v1';
export function loadTopology(): Topology {
try {
const raw = localStorage.getItem(KEY_TOPOLOGY);
if (raw) return JSON.parse(raw) as Topology;
} catch (e) {
console.warn('Не удалось прочитать игру из localStorage', e);
}
const fresh = defaultTopology();
saveTopology(fresh);
return fresh;
}
export function saveTopology(topology: Topology): void {
localStorage.setItem(KEY_TOPOLOGY, JSON.stringify(topology));
}
export function loadBuffers(topology: Topology): Buffers {
try {
const raw = localStorage.getItem(KEY_BUFFERS);
if (raw) return JSON.parse(raw) as Buffers;
} catch (e) {
console.warn('Не удалось прочитать буферы из localStorage', e);
}
const fresh = resetBuffers(topology);
saveBuffers(fresh);
return fresh;
}
export function saveBuffers(buffers: Buffers): void {
localStorage.setItem(KEY_BUFFERS, JSON.stringify(buffers));
}
export function loadTickCount(): number {
const raw = localStorage.getItem(KEY_TICKS);
return raw ? Number(raw) || 0 : 0;
}
export function saveTickCount(n: number): void {
localStorage.setItem(KEY_TICKS, String(n));
}
+25 -8
View File
@@ -1,9 +1,10 @@
import Decimal, { DecimalSource } from "break_eternity.js";
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' | 'stone'; export type Resource = 'wood' | 'plank' | 'stone';
export type Resources = Record<Resource, number>; export type Resources = Record<Resource, Decimal>;
export type Node = { export type Node = {
id: string; id: string;
@@ -12,11 +13,11 @@ export type Node = {
} }
export class Game { export class Game {
public time: number; public time: Decimal;
public readonly nodes: Node[]; public readonly nodes: Node[];
constructor(source: SerializedGame) { constructor(source: SerializedGame) {
this.time = source.time; this.time = new Decimal(source.time);
this.nodes = source.nodes.map((node) => ({ this.nodes = source.nodes.map((node) => ({
id: node.id, id: node.id,
name: node.name, name: node.name,
@@ -25,15 +26,31 @@ export class Game {
} }
public tick(deltaTime: number) { public tick(deltaTime: number) {
this.time += deltaTime; this.time = this.time.add(deltaTime);
const behavioursById = new Map(this.nodes.map((node) => [node.behaviour.id, node.behaviour]));
for (const node of this.nodes) for (const node of this.nodes)
node.behaviour.tick(deltaTime); node.behaviour.produce(deltaTime);
for (const node of this.nodes)
node.behaviour.pull(deltaTime, (id) => behavioursById.get(id));
}
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 SerializedConverterInput = { export type SerializedConverterInput = {
resource: Resource; resource: Resource;
rate: number; rate: DecimalSource;
link: { link: {
target: SerializedNodeBehaviour['id'], target: SerializedNodeBehaviour['id'],
resource: Resource, resource: Resource,
@@ -42,7 +59,7 @@ export type SerializedConverterInput = {
export type SerializedConverterOutput = { export type SerializedConverterOutput = {
resource: Resource; resource: Resource;
rate: number; rate: DecimalSource;
} }
export type SerializedConverterInputs = { export type SerializedConverterInputs = {
@@ -74,6 +91,6 @@ export type SerializedNode = {
} }
export type SerializedGame = { export type SerializedGame = {
time: number; time: DecimalSource;
nodes: SerializedNode[]; nodes: SerializedNode[];
} }