initial commit

This commit is contained in:
azykov@mail.ru
2026-08-31 20:54:53 +03:00
commit f76370151e
27 changed files with 3473 additions and 0 deletions
+4
View File
@@ -0,0 +1,4 @@
node_modules
dist
*.tsbuildinfo
.DS_Store
+43
View File
@@ -0,0 +1,43 @@
# Граф ресурсов — тиковая симуляция (Vite + React + TypeScript)
## Запуск
```bash
npm install
npm run dev
```
Откроется адрес вида `http://localhost:5173`.
## Сборка
```bash
npm run build # tsc -b && vite build -> dist/
npm run preview # локальный просмотр собранного dist/
```
## Структура
- `src/engine.ts` — ядро симуляции: типы (`SimNode`, `SimEdge`, `Behavior`,
`Topology`, `Buffers`), функция `tick()` (один детерминированный тик) и
`resetBuffers()`. Модуль не зависит от React/DOM — логика переносима на
другой стек (Rust/C#/GDScript) по этой же спецификации.
- `src/storage.ts` — чтение/запись localStorage (`incsim.topology.v1`,
`incsim.buffers.v1`, `incsim.tickcount.v1`).
- `src/App.tsx` — состояние приложения (topology/buffers/tickCount/selected),
все обработчики мутаций, эффекты персистентности.
- `src/components/` — `Topbar`, `WorldCanvas` (холст с узлами и SVG-связями),
`NodeCard` (перетаскиваемая карточка узла), `EdgeLines` (SVG-стрелки),
`Sidebar`, `NodeEditor`, `EdgeEditor`, `RecipeEditor`.
## Модель
- **Storage** — пассивный буфер с ёмкостью.
- **Converter** — единственный активный узел: раз в `period` тиков атомарно
списывает `inputs` и начисляет `outputs` в собственном буфере.
`source`/`consumer` — его частные случаи с пустым списком входов/выходов.
- **Edge** — перенос ресурса между буферами узлов; конкуренция при
fan-in/fan-out разрешается по `priority` (выше — раньше), порядок
обработки узлов/рёбер фиксирован — тик полностью детерминирован.
Reset обнуляет буферы и таймеры конвертеров, настройки узлов/связей не трогает.
+12
View File
@@ -0,0 +1,12 @@
<!doctype html>
<html lang="ru">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Граф ресурсов — тиковая симуляция</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+1790
View File
File diff suppressed because it is too large Load Diff
+24
View File
@@ -0,0 +1,24 @@
{
"name": "resource-graph-sim",
"private": true,
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"preview": "vite preview"
},
"dependencies": {
"ngraph.forcelayout": "^3.3.1",
"ngraph.graph": "^20.1.2",
"react": "^18.3.1",
"react-dom": "^18.3.1"
},
"devDependencies": {
"@types/react": "^18.3.12",
"@types/react-dom": "^18.3.1",
"@vitejs/plugin-react": "^4.3.3",
"typescript": "^5.6.3",
"vite": "^5.4.10"
}
}
+153
View File
@@ -0,0 +1,153 @@
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';
export type Selection = { kind: 'node'; id: string } | null;
export default function App() {
const [game, setGame] = useState<Game>(() => load());
const [selected, setSelected] = useState<Selection>(null);
useEffect(() => save(game), [game]);
// useEffect(() => saveTickCount(tickCount), [tickCount]);
function handleTick() {
game.tick(1.0 / 60);
// const nextTopology = cloneJSON(game);
// const nextBuffers = cloneJSON(buffers);
// tick(nextTopology, nextBuffers);
// setGame(nextTopology);
// setBuffers(nextBuffers);
// setTickCount((n) => n + 1);
}
function handleReset() {
const ok = window.confirm(
'Обнулить все значения и таймеры? Настройки узлов и связей не изменятся.'
);
if (!ok)
return;
game.time = 0;
save(game);
}
function handleInitialize() {
const ok = window.confirm(
'Удалить все объекты и начать игру с начала?'
);
if (!ok)
return;
setGame(deserialize(defaultGame()));
save(game);
}
// function handleAddNode(kind: NodeKind) {
// const idNum = game.nextId + 1;
// const id = `${kind}-${idNum}`;
// const node: SimNode = {
// id,
// label: kindLabel(kind),
// x: 60 + (game.nodes.length * 25) % 600,
// y: 60 + (game.nodes.length * 47) % 400,
// capacity: kind === 'storage' ? 100 : null,
// behavior: BEHAVIOR_DEFAULTS[kind],
// };
// setGame((prev) => ({ ...prev, nextId: idNum, nodes: [...prev.nodes, node] }));
// setBuffers((prev) => ({ ...prev, [id]: {} }));
// setSelected({ kind: 'node', id });
// }
// function handleRemoveNode(id: string) {
// setGame((prev) => ({
// ...prev,
// nodes: prev.nodes.filter((n) => n.id !== id),
// edges: prev.edges.filter((e) => e.from !== id && e.to !== id),
// }));
// setBuffers((prev) => {
// const next = { ...prev };
// delete next[id];
// return next;
// });
// setSelected((s) => (s && s.id === id ? null : s));
// }
// function handleAddEdge() {
// if (game.nodes.length < 2) return;
// const idNum = game.nextId + 1;
// const id = `e-${idNum}`;
// const edge: SimEdge = {
// id,
// from: game.nodes[0].id,
// to: game.nodes[1].id,
// resource: 'wood',
// rate: 1,
// priority: 0,
// };
// setGame((prev) => ({ ...prev, nextId: idNum, edges: [...prev.edges, edge] }));
// setSelected({ kind: 'edge', id });
// }
// function handleRemoveEdge(id: string) {
// setGame((prev) => ({ ...prev, edges: prev.edges.filter((e) => e.id !== id) }));
// setSelected((s) => (s && s.id === id ? null : s));
// }
// function handleUpdateNode(id: string, patch: Partial<SimNode>) {
// setGame((prev) => ({
// ...prev,
// nodes: prev.nodes.map((n) => (n.id === id ? { ...n, ...patch } : n)),
// }));
// }
// function handleUpdateNodeBehavior(id: string, behavior: Behavior) {
// handleUpdateNode(id, { behavior });
// }
// function handleUpdateEdge(id: string, patch: Partial<SimEdge>) {
// setGame((prev) => ({
// ...prev,
// edges: prev.edges.map((e) => (e.id === id ? { ...e, ...patch } : e)),
// }));
// }
// function handleDragNode(id: string, x: number, y: number) {
// handleUpdateNode(id, { x, y });
// }
return (
<div id="app">
<Topbar
time={game.time}
onTick={handleTick}
onReset={handleReset}
onInitialize={handleInitialize}
/>
<div className="main">
<GameCanvas
game={game}
selected={selected}
onSelectNode={(id) => setSelected({ kind: 'node', id })}
/>
{/* <Sidebar
game={game}
selected={selected}
onSelectNode={(id) => setSelected({ kind: 'node', id })}
onSelectEdge={(id) => setSelected({ kind: 'edge', id })}
onAddNode={handleAddNode}
onAddEdge={handleAddEdge}
onRemoveNode={handleRemoveNode}
onRemoveEdge={handleRemoveEdge}
onUpdateNode={handleUpdateNode}
onUpdateNodeBehavior={handleUpdateNodeBehavior}
onUpdateEdge={handleUpdateEdge}
/> */}
</div>
</div>
);
}
+97
View File
@@ -0,0 +1,97 @@
import type { Selection } from '../App';
import NodeCard from './NodeCard';
import { Game } from '../types';
import { build2dNodes, Node2D } from '../ui/graph';
import { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
import { NodeLine } from './NodeLine';
interface Props {
game: Game;
selected: Selection;
onSelectNode: (id: string) => void;
}
export const GameCanvas = function ({
game,
selected,
onSelectNode,
}: Props) {
const canvasRef = useRef<HTMLDivElement>(null);
const nodeElsRef = useRef<Map<string, HTMLDivElement>>(new Map());
const [size, setSize] = useState({ width: 0, height: 0 });
const [centers, setCenters] = useState<Record<string, { x: number, y: number }>>({});
useEffect(() => {
const el = canvasRef.current;
if (!el) return;
const observer = new ResizeObserver(([entry]) => {
const { width, height } = entry.contentRect;
setSize({ width, height });
});
observer.observe(el);
return () => observer.disconnect();
}, []);
const nodes2d = useMemo(
() => build2dNodes(game.nodes, { width: size.width, height: size.height, padding: 40 }),
[game.nodes, size.width, size.height],
);
useLayoutEffect(() => {
const worldEl = canvasRef.current;
if (!worldEl) return;
const worldRect = worldEl.getBoundingClientRect();
const next: Record<string, { x: number, y: number }> = {};
for (const node2d of nodes2d) {
const el = nodeElsRef.current.get(node2d.id);
if (!el) continue;
const rect = el.getBoundingClientRect();
next[node2d.id] = {
x: rect.left - worldRect.left + rect.width / 2,
y: rect.top - worldRect.top + rect.height / 2,
};
}
setCenters(next);
}, [nodes2d]);
function withCenter(node2d: Node2D): Node2D {
const center = centers[node2d.id];
return center ? { ...node2d, position: center } : node2d;
}
return (
<div className="world-wrap">
<div id="world" ref={canvasRef}>
{
nodes2d.map((node2d) =>
node2d.node.behaviour.getDisplayInfo().links.map((link, idx) => {
const target = nodes2d.find((n2d) => n2d.id === link.target);
return target && (
<NodeLine
game={game}
key={`${node2d.id}.${idx}`}
from={withCenter(node2d)}
to={withCenter(target)}
/>
);
}))
}
{
nodes2d.map((node2d) => (
<NodeCard
ref={(el) => {
if (el) nodeElsRef.current.set(node2d.id, el);
else nodeElsRef.current.delete(node2d.id);
}}
game={game}
key={node2d.id}
node2d={node2d}
selected={selected?.kind === 'node' && selected.id === node2d.id}
onSelect={() => onSelectNode(node2d.id)}
/>
))}
</div>
</div>
);
}
+51
View File
@@ -0,0 +1,51 @@
import { forwardRef } from 'react';
import { Game, Resource } from '../types';
import { Node2D } from '../ui/graph';
import { ResourceDescriptions } from '../ui/resource';
interface Props {
game: Game;
node2d: Node2D;
selected: boolean;
onSelect: () => void;
}
const NodeCard = forwardRef<HTMLDivElement, Props>(function NodeCard({ node2d, selected, onSelect }, ref) {
const node = node2d.node;
function handleClick() {
onSelect();
}
const info = node.behaviour.getDisplayInfo();
return (
<div
ref={ref}
className={`node node--${node.behaviour.kind}${selected ? ' is-selected' : ''}`}
style={{ left: node2d.position.x, top: node2d.position.y, width: 100 }}
onClick={handleClick}
>
<div className="node__id">{node.id}</div>
<div className="node__title">{node.name}</div>
<div className="node__kind">{node.behaviour.kind}</div>
{
Object.entries(info.storages).map(([name, storage]) => (
<div className="node__resources">
<div>{name}</div>
{
Object.entries(storage)
.map(([rt, v]) => `${ResourceDescriptions[rt as Resource].icon}${v}`).join(' ')
}
</div>
))
}
<div className="node__links">
{
info.links.map((link) => <div>{link.target}/{link.resource}</div>)
}
</div>
</div>
);
});
export default NodeCard;
+39
View File
@@ -0,0 +1,39 @@
import { Game } from "../types";
import { Node2D } from "../ui/graph";
type Props = {
game: Game,
from: Node2D,
to: Node2D,
}
export const NodeLine = function ({ from, to }: Props) {
const markerId = `node-line-arrow-${from.id}-${to.id}`.replace(/[^a-zA-Z0-9_-]/g, '_');
return (
<svg style={{zIndex: 1000}}>
<defs>
<marker
id={markerId}
viewBox="0 0 10 10"
refX="9"
refY="5"
markerWidth="6"
markerHeight="6"
orient="auto-start-reverse"
>
<path d="M0,0 L10,5 L0,10 z" fill="white" />
</marker>
</defs>
<line
x1={from.position.x}
y1={from.position.y}
x2={to.position.x}
y2={to.position.y}
stroke="white"
strokeWidth={2}
markerStart={`url(#${markerId})`}
/>
</svg>
);
};
+111
View File
@@ -0,0 +1,111 @@
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>
);
}
+30
View File
@@ -0,0 +1,30 @@
interface Props {
time: number;
onTick: () => void;
onReset: () => void;
onInitialize: () => void;
}
export default function Topbar({ time, onTick, onReset, onInitialize }: Props) {
return (
<div className="topbar">
<h1 className="topbar__title">
Граф ресурсов
<span>детерминированная тиковая симуляция</span>
</h1>
<div className="tick-counter">
Время <b>{time}</b>
</div>
<div className="spacer" />
<button className="btn" onClick={onInitialize}>
Initialize
</button>
<button className="btn" onClick={onReset}>
Reset
</button>
<button className="btn btn--primary" onClick={onTick}>
Tick ▶
</button>
</div>
);
}
+242
View File
@@ -0,0 +1,242 @@
// for Claude: ignore this file
/**
* engine.ts — ядро симуляции.
*
* - Storage — пассивный буфер с ёмкостью, сам ничего не делает.
* - Converter — единственный активный узел: раз в `period` тиков атомарно
* списывает `inputs` и начисляет `outputs` в СВОИХ ЖЕ буферах.
* source / consumer — частные случаи converter с пустым списком
* входов или выходов соответственно.
* - Edge (транспорт) — переносит ресурс между буферами двух узлов.
* Порядок обработки рёбер фиксирован (priority desc, id asc) —
* разрешает конкуренцию при fan-in/fan-out детерминированно.
*
* Модуль не содержит DOM/React-зависимостей — чистые данные и функции,
* поэтому логику легко перенести на другой язык/платформу по этой же
* спецификации (см. обсуждение — Rust/C#/GDScript как нативные цели).
*/
export type ResourceType = 'wood' | 'iron' | 'stone' | 'gold' | 'knowledge';
export interface ResourceMeta {
id: ResourceType;
label: string;
color: string;
icon: string;
}
export const RESOURCES: ResourceMeta[] = [
{ id: 'wood', label: 'Дерево', color: '#b98249', icon: '🌲' },
{ id: 'iron', label: 'Железо', color: '#9aa7b0', icon: '⛓' },
{ id: 'stone', label: 'Камень', color: '#8a8a8a', icon: '🪨' },
{ id: 'gold', label: 'Золото', color: '#d9b64e', icon: '💰' },
{ id: 'knowledge', label: 'Знание', color: '#7d8bf0', icon: '📖' },
];
export type NodeKind = 'source' | 'storage' | 'converter' | 'consumer';
export interface RecipeLine {
resource: ResourceType;
amount: number;
}
export type Behavior =
| { kind: 'storage' }
| { kind: 'source'; resource: ResourceType; rate: number }
| { kind: 'consumer'; resource: ResourceType; rate: number }
| {
kind: 'converter';
period: number;
timer: number;
inputs: RecipeLine[];
outputs: RecipeLine[];
};
export interface SimNode {
label: string;
x: number;
y: number;
actor: Actor;
}
export type Actor = {
id: string;
behavior: Behavior;
}
export interface SimEdge {
id: string;
from: string;
to: string;
resource: ResourceType;
rate: number;
priority: number;
}
export interface Topology {
nextId: number;
nodes: SimNode[];
edges: SimEdge[];
}
/** buffers[nodeId][resource] = amount */
export type Buffers = Record<string, Partial<Record<ResourceType, number>>>;
export const NODE_W = 150;
export const NODE_H = 74;
export function kindLabel(kind: NodeKind): string {
return { source: 'Источник', storage: 'Склад', converter: 'Конвертер', consumer: 'Потребитель' }[kind];
}
/** Глубокая копия чисто-JSON-данных (topology/buffers сериализуемы, structuredClone не требуется). */
export function cloneJSON<T>(value: T): T {
return JSON.parse(JSON.stringify(value)) as T;
}
function getBuf(buffers: Buffers, nodeId: string, r: ResourceType): number {
return buffers[nodeId]?.[r] ?? 0;
}
function setBuf(buffers: Buffers, nodeId: string, r: ResourceType, v: number): void {
if (!buffers[nodeId]) buffers[nodeId] = {};
buffers[nodeId][r] = v;
}
function capOf(node: SimNode): number {
return node.capacity === null || node.capacity === undefined ? Infinity : node.capacity;
}
/**
* Выполняет ровно один детерминированный тик (мутирует topology.*.timer и buffers).
* Порядок фаз фиксирован и не зависит от порядка вставки узлов/рёбер в массивах.
*/
export function tick(topology: Topology, buffers: Buffers): void {
const nodesById = new Map(topology.nodes.map((n) => [n.id, n]));
const sortedNodes = [...topology.nodes].sort((a, b) => a.id.localeCompare(b.id));
// Фаза 1: источники — генерация "из ничего" в собственный буфер.
for (const node of sortedNodes) {
if (node.behavior.kind === 'source') {
const { resource, rate } = node.behavior;
const cur = getBuf(buffers, node.id, resource);
setBuf(buffers, node.id, resource, Math.min(capOf(node), cur + rate));
}
}
// Фаза 2: конвертеры. Blocking: при нехватке сырья таймер не сбрасывается,
// попытка повторяется каждый следующий тик.
for (const node of sortedNodes) {
if (node.behavior.kind === 'converter') {
const b = node.behavior;
b.timer += 1;
if (b.timer >= b.period) {
const canRun = b.inputs.every((line) => getBuf(buffers, node.id, line.resource) >= line.amount);
if (canRun) {
for (const line of b.inputs) {
setBuf(buffers, node.id, line.resource, getBuf(buffers, node.id, line.resource) - line.amount);
}
for (const line of b.outputs) {
const next = getBuf(buffers, node.id, line.resource) + line.amount;
setBuf(buffers, node.id, line.resource, Math.min(capOf(node), next));
}
b.timer = 0;
}
}
}
}
// Фаза 3: транспорт по рёбрам, порядок (priority desc, id asc).
const sortedEdges = [...topology.edges].sort(
(a, b) => b.priority - a.priority || a.id.localeCompare(b.id)
);
for (const edge of sortedEdges) {
const fromNode = nodesById.get(edge.from);
const toNode = nodesById.get(edge.to);
if (!fromNode || !toNode) continue;
const available = getBuf(buffers, edge.from, edge.resource);
const freeSpace = capOf(toNode) - getBuf(buffers, edge.to, edge.resource);
const amount = Math.max(0, Math.min(edge.rate, available, freeSpace));
if (amount > 0) {
setBuf(buffers, edge.from, edge.resource, available - amount);
setBuf(buffers, edge.to, edge.resource, getBuf(buffers, edge.to, edge.resource) + amount);
}
}
// Фаза 4: потребители — безвозвратное поглощение.
for (const node of sortedNodes) {
if (node.behavior.kind === 'consumer') {
const { resource, rate } = node.behavior;
const cur = getBuf(buffers, node.id, resource);
const amount = Math.min(cur, rate);
setBuf(buffers, node.id, resource, cur - amount);
}
}
}
/** Обнуляет все буферы и таймеры конвертеров. Настройки узлов/связей не трогает. */
export function resetBuffers(topology: Topology): Buffers {
const buffers: Buffers = {};
for (const node of topology.nodes) {
buffers[node.id] = {};
if (node.behavior.kind === 'converter') node.behavior.timer = 0;
}
return buffers;
}
export function defaultTopology(): Topology {
return {
nextId: 100,
nodes: [
{ id: 'src-wood', label: 'Лесопилка', x: 40, y: 40, capacity: null,
behavior: { kind: 'source', resource: 'wood', rate: 5 } },
{ id: 'src-stone', label: 'Каменоломня', x: 40, y: 180, capacity: null,
behavior: { kind: 'source', resource: 'stone', rate: 3 } },
{ id: 'src-iron', label: 'Рудник', x: 40, y: 320, capacity: null,
behavior: { kind: 'source', resource: 'iron', rate: 2 } },
{ id: 'store-wood', label: 'Склад дерева', x: 300, y: 40, capacity: 100,
behavior: { kind: 'storage' } },
{ id: 'store-stone', label: 'Склад камня', x: 300, y: 180, capacity: 100,
behavior: { kind: 'storage' } },
{ id: 'store-iron', label: 'Склад железа', x: 300, y: 320, capacity: 100,
behavior: { kind: 'storage' } },
{ id: 'conv-craft', label: 'Мастерская', x: 560, y: 110, capacity: 50,
behavior: { kind: 'converter', period: 2, timer: 0,
inputs: [{ resource: 'wood', amount: 3 }, { resource: 'stone', amount: 2 }],
outputs: [{ resource: 'gold', amount: 1 }] } },
{ id: 'conv-forge', label: 'Кузница', x: 560, y: 320, capacity: 50,
behavior: { kind: 'converter', period: 2, timer: 0,
inputs: [{ resource: 'iron', amount: 2 }, { resource: 'wood', amount: 1 }],
outputs: [{ resource: 'gold', amount: 2 }] } },
{ id: 'vault', label: 'Хранилище золота', x: 820, y: 210, capacity: 200,
behavior: { kind: 'storage' } },
{ id: 'conv-research', label: 'Лаборатория', x: 1060, y: 210, capacity: 30,
behavior: { kind: 'converter', period: 3, timer: 0,
inputs: [{ resource: 'gold', amount: 5 }],
outputs: [{ resource: 'knowledge', amount: 1 }] } },
{ id: 'store-knowledge', label: 'Архив знаний', x: 1300, y: 210, capacity: null,
behavior: { kind: 'storage' } },
],
edges: [
{ id: 'e1', from: 'src-wood', to: 'store-wood', resource: 'wood', rate: 5, priority: 0 },
{ id: 'e2', from: 'src-stone', to: 'store-stone', resource: 'stone', rate: 3, priority: 0 },
{ id: 'e3', from: 'src-iron', to: 'store-iron', resource: 'iron', rate: 2, priority: 0 },
{ id: 'e4', from: 'store-wood', to: 'conv-craft', resource: 'wood', rate: 3, priority: 1 },
{ id: 'e5', from: 'store-stone', to: 'conv-craft', resource: 'stone', rate: 2, priority: 1 },
{ id: 'e6', from: 'store-wood', to: 'conv-forge', resource: 'wood', rate: 1, priority: 0 },
{ id: 'e7', from: 'store-iron', to: 'conv-forge', resource: 'iron', rate: 2, priority: 1 },
{ id: 'e8', from: 'conv-craft', to: 'vault', resource: 'gold', rate: 5, priority: 0 },
{ id: 'e9', from: 'conv-forge', to: 'vault', resource: 'gold', rate: 5, priority: 0 },
{ id: 'e10', from: 'vault', to: 'conv-research', resource: 'gold', rate: 5, priority: 0 },
{ id: 'e11', from: 'conv-research', to: 'store-knowledge', resource: 'knowledge', rate: 5, priority: 0 },
],
};
}
+98
View File
@@ -0,0 +1,98 @@
import { Resource, Resources, SerializedConverter, SerializedNodeBehaviour as SerializedBehaviour, SerializedNodeBehaviour, SerializedConverterOutput, SerializedConverterInput } from "../../types";
export type BehaviourResourcePin = {
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>;
links: BehaviourLink[];
}
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((input) => ({
resource: input.resource,
})),
}
this.inputs = {
pins: source.inputs.pins.map((input) => ({
resource: input.resource,
link: input.link,
})),
}
}
abstract getDisplayInfo(): BehaviourDisplayInfo;
abstract tick(deltaTime: number): void;
}
export type ResourceUsageRule = {
resource: Resource;
speed: number; // per tick
}
export class Converter extends Behaviour {
public outputRules: ResourceUsageRule[] = [];
rules: { inputs: SerializedConverterInput[]; outputs: SerializedConverterOutput[]; };
constructor(source: SerializedNodeBehaviour & SerializedConverter) {
if (source.kind !== 'converter')
throw new Error('Invalid source');
super(source);
this.rules = {
inputs: source.inputs.pins,
outputs: source.outputs.pins,
};
}
public getDisplayInfo(): BehaviourDisplayInfo {
return {
storages: {},
links: this.inputs.pins.map((input) => input.link),
}
}
tick(deltaTime: number): void {
}
}
// export class Storage extends Behaviour {
// public static type: string = 'storage';
// }
export const behaviours = {
'converter': Converter,
// 'storage': ,
} satisfies Record<string, { new(source: never): Behaviour }>;
View File
+78
View File
@@ -0,0 +1,78 @@
import { defaultGame } from "../prefabs/game";
import { Game, SerializedGame, SerializedNodeBehaviour } from "../types";
import { Behaviour, behaviours } from "./functions";
export function serializeBehaviour(behaviour: Behaviour): SerializedNodeBehaviour {
switch (behaviour.kind) {
case 'converter':
return {
id: behaviour.id,
kind: 'converter',
inputs: {
pins: behaviour.inputs.pins.map((pin) => ({
...pin,
rate: 11,
})),
},
outputs: {
pins: behaviour.outputs.pins.map((pin) => ({
...pin,
rate: 11,
})),
},
};
default:
throw new Error('Unknown behaviour');
}
}
export function deserializeBehaviour(value: SerializedNodeBehaviour): Behaviour {
switch (value.kind) {
case 'converter':
return new (behaviours.converter)(value);
default:
throw new Error('Unknown behaviour');
}
}
export function serialize(game: Game): SerializedGame {
return {
time: game.time,
nodes: game.nodes.map((node) => ({
id: node.id,
name: node.name,
behaviour: serializeBehaviour(node.behaviour),
})),
};
}
export function deserialize(value: SerializedGame): Game {
return new Game(value);
}
export function serializeToString(game: Game): string {
return JSON.stringify(serialize(game));
}
export function deserializeFromString(value: string): Game {
return deserialize(JSON.parse(value));
}
const KEY_GAME = 'incgane';
export function save(game: Game) {
localStorage.setItem(KEY_GAME, serializeToString(game));
}
export function load(): Game {
try {
const raw = localStorage.getItem(KEY_GAME);
if (raw)
return deserializeFromString(raw);
} catch (e) {
console.warn('Не удалось прочитать игру из localStorage', e);
}
const fresh = deserialize(defaultGame());
save(fresh);
return fresh;
}
+354
View File
@@ -0,0 +1,354 @@
@import url('https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@500;600;700&family=IBM+Plex+Mono:wght@400;500;600&display=swap');
:root {
--bg: #201f1c;
--panel: #29271f;
--panel-2: #322f26;
--line: #4a4536;
--grid-line: rgba(196, 178, 138, 0.08);
--ink: #e9e2d2;
--ink-dim: #a89f8a;
--accent-source: #7fae6a;
--accent-storage: #a89f8a;
--accent-converter: #d9a441;
--accent-consumer: #c15a4e;
--edge-color: #8a8a8a;
--danger: #c15a4e;
--mono: 'IBM Plex Mono', ui-monospace, monospace;
--display: 'Space Grotesk', ui-sans-serif, sans-serif;
}
* { box-sizing: border-box; }
html, body, #root {
margin: 0;
height: 100%;
background: var(--bg);
color: var(--ink);
font-family: var(--mono);
}
#app {
display: flex;
flex-direction: column;
height: 100vh;
}
/* ---------------------------------------------------------- top bar --- */
.topbar {
display: flex;
align-items: center;
gap: 20px;
padding: 12px 20px;
background: var(--panel);
border-bottom: 1px solid var(--line);
flex-shrink: 0;
}
.topbar__title {
font-family: var(--display);
font-weight: 700;
font-size: 15px;
letter-spacing: 0.06em;
text-transform: uppercase;
color: var(--ink);
margin: 0;
border-right: 1px solid var(--line);
padding-right: 16px;
}
.topbar__title span {
display: block;
font-family: var(--mono);
font-weight: 400;
font-size: 10px;
letter-spacing: 0.04em;
color: var(--ink-dim);
text-transform: none;
margin-top: 2px;
}
.tick-counter {
font-family: var(--mono);
font-size: 12px;
color: var(--ink-dim);
background: var(--panel-2);
border: 1px solid var(--line);
padding: 6px 12px;
border-radius: 3px;
}
.tick-counter b {
color: var(--accent-converter);
font-size: 14px;
}
.spacer { flex: 1; }
.btn {
font-family: var(--mono);
font-size: 12px;
font-weight: 500;
letter-spacing: 0.03em;
border: 1px solid var(--line);
background: var(--panel-2);
color: var(--ink);
padding: 9px 16px;
border-radius: 3px;
cursor: pointer;
transition: border-color 0.12s, transform 0.05s;
}
.btn:hover { border-color: var(--ink-dim); }
.btn:active { transform: translateY(1px); }
.btn:focus-visible { outline: 2px solid var(--accent-converter); outline-offset: 2px; }
.btn--primary {
background: var(--accent-converter);
border-color: var(--accent-converter);
color: #201f1c;
font-weight: 600;
}
.btn--primary:hover { border-color: #f0c169; }
.btn--danger {
background: transparent;
border-color: var(--danger);
color: var(--danger);
margin-top: 14px;
width: 100%;
}
.btn--danger:hover { background: rgba(193, 90, 78, 0.12); }
.btn--ghost {
background: transparent;
border-style: dashed;
color: var(--ink-dim);
width: 100%;
margin-top: 4px;
}
.btn--icon {
padding: 4px 8px;
font-size: 11px;
}
select {
font-family: var(--mono);
font-size: 12px;
background: var(--panel-2);
color: var(--ink);
border: 1px solid var(--line);
border-radius: 3px;
padding: 8px 10px;
}
/* --------------------------------------------------------------- main --- */
.main {
flex: 1;
display: flex;
min-height: 0;
}
/* ------------------------------------------------------------- world --- */
.world-wrap {
flex: 1;
overflow: auto;
position: relative;
background-color: var(--bg);
background-image:
linear-gradient(var(--grid-line) 1px, transparent 1px),
linear-gradient(90deg, var(--grid-line) 1px, transparent 1px);
background-size: 24px 24px;
}
#world {
position: relative;
width: 1600px;
height: 900px;
}
#world > svg {
position: absolute;
top: 0; left: 0;
width: 1600px;
height: 900px;
pointer-events: none;
}
#world > svg line { pointer-events: stroke; }
.node {
position: absolute;
background: var(--panel);
border: 1px solid var(--line);
border-left: 4px solid var(--accent-storage);
border-radius: 4px;
padding: 8px 10px;
cursor: grab;
user-select: none;
box-shadow: 0 2px 6px rgba(0,0,0,0.35);
touch-action: none;
}
.node:active { cursor: grabbing; }
.node.is-selected {
border-left-width: 4px;
outline: 1.5px dashed var(--ink);
outline-offset: 3px;
}
.node--source { border-left-color: var(--accent-source); }
.node--storage { border-left-color: var(--accent-storage); }
.node--converter { border-left-color: var(--accent-converter); }
.node--consumer { border-left-color: var(--accent-consumer); }
.node__title {
font-family: var(--display);
font-weight: 600;
font-size: 12.5px;
color: var(--ink);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.node__kind {
font-size: 9px;
letter-spacing: 0.05em;
text-transform: uppercase;
color: var(--ink-dim);
margin: 2px 0 6px;
}
.node__resources {
font-size: 11px;
background: #171613;
border: 1px solid var(--line);
border-radius: 3px;
padding: 4px 6px;
letter-spacing: 0.02em;
white-space: nowrap;
}
/* ------------------------------------------------------------ sidebar --- */
.sidebar {
width: 340px;
flex-shrink: 0;
background: var(--panel);
border-left: 1px solid var(--line);
display: flex;
flex-direction: column;
overflow-y: auto;
}
.sidebar__section {
padding: 14px 16px;
border-bottom: 1px solid var(--line);
}
.sidebar__section--grow {
flex: 1;
border-bottom: none;
}
.sidebar__section h4 {
font-family: var(--display);
font-size: 11px;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--ink-dim);
margin: 0 0 8px;
}
.sidebar__list {
list-style: none;
margin: 0;
padding: 0;
max-height: 140px;
overflow-y: auto;
}
.sidebar__list li {
padding: 6px 8px;
font-size: 11.5px;
border-radius: 3px;
cursor: pointer;
color: var(--ink-dim);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.sidebar__list li:hover { background: var(--panel-2); color: var(--ink); }
.sidebar__list li.is-active { background: var(--panel-2); color: var(--accent-converter); }
#sidebar-content {
padding: 16px 0 0;
flex: 1;
overflow-y: auto;
}
#sidebar-content h3 {
font-family: var(--display);
font-size: 13px;
color: var(--ink);
margin: 0 0 14px;
word-break: break-all;
}
.hint {
color: var(--ink-dim);
font-size: 11.5px;
line-height: 1.5;
}
.field {
display: flex;
flex-direction: column;
gap: 5px;
margin-bottom: 12px;
font-size: 11px;
color: var(--ink-dim);
}
.field input, .field select {
font-family: var(--mono);
font-size: 12px;
background: var(--panel-2);
color: var(--ink);
border: 1px solid var(--line);
border-radius: 3px;
padding: 7px 9px;
width: 100%;
}
.field input:focus-visible, .field select:focus-visible {
outline: 2px solid var(--accent-converter);
outline-offset: 1px;
}
.coord-row { display: flex; gap: 8px; }
.coord-row input { width: 50%; }
.recipe-box {
border: 1px solid var(--line);
border-radius: 4px;
padding: 10px;
margin-bottom: 12px;
background: var(--panel-2);
}
.recipe-box__title {
font-size: 10.5px;
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--ink-dim);
margin-bottom: 8px;
}
.recipe-row {
display: flex;
gap: 6px;
margin-bottom: 6px;
align-items: center;
}
.recipe-row select { flex: 1.4; }
.recipe-row input { flex: 1; }
::-webkit-scrollbar { width: 9px; height: 9px; }
::-webkit-scrollbar-track { background: var(--bg); }
::-webkit-scrollbar-thumb { background: var(--line); border-radius: 5px; }
+10
View File
@@ -0,0 +1,10 @@
import React from 'react';
import ReactDOM from 'react-dom/client';
import App from './App';
import './index.css';
ReactDOM.createRoot(document.getElementById('root') as HTMLElement).render(
<React.StrictMode>
<App />
</React.StrictMode>
);
+71
View File
@@ -0,0 +1,71 @@
import { SerializedGame } from "../types";
export const defaultGame: () => SerializedGame = () => ({
time: 0,
nodes: [
{
id: 'forest',
name: 'Forest',
behaviour: {
id: 'forest.1',
kind: 'converter',
inputs: {
pins: [],
},
outputs: {
pins: [
{
resource: 'wood',
rate: 1,
},
],
},
},
},
{
id: 'sawmill',
name: 'Sawmill',
behaviour: {
id: 'sawmill.1',
kind: 'converter',
inputs: {
pins: [
{
resource: 'wood',
rate: 0.5,
link: { target: 'forest.1', resource: 'wood' },
},
]
},
outputs: {
pins: [
{
resource: 'plank',
rate: 1,
},
],
},
},
},
{
id: 'burner',
name: 'Burner',
behaviour: {
id: 'burner.1',
kind: 'converter',
inputs: {
pins: [
{
resource: 'plank',
rate: 1,
link: { target: 'sawmill.1', resource: 'plank' },
},
],
},
outputs: {
pins: [],
},
},
},
],
});
+47
View File
@@ -0,0 +1,47 @@
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));
}
+79
View File
@@ -0,0 +1,79 @@
import { Behaviour } from "../engine/functions";
import { deserializeBehaviour } from "../engine/saveLoad";
export type Resource = 'wood' | 'plank';
export type Resources = Record<Resource, number>;
export type Node = {
id: string;
name: string;
behaviour: Behaviour;
}
export class Game {
public time: number;
public readonly nodes: Node[];
constructor(source: SerializedGame) {
this.time = source.time;
this.nodes = source.nodes.map((node) => ({
id: node.id,
name: node.name,
behaviour: deserializeBehaviour(node.behaviour),
}));
}
public tick(deltaTime: number) {
this.time += deltaTime;
for (const node of this.nodes)
node.behaviour.tick(deltaTime);
}
}
export type SerializedConverterInput = {
resource: Resource;
rate: number;
link: {
target: SerializedNodeBehaviour['id'],
resource: Resource,
}
}
export type SerializedConverterOutput = {
resource: Resource;
rate: number;
}
export type SerializedConverterInputs = {
pins: SerializedConverterInput[];
}
export type SerializedConverterOutputs = {
pins: SerializedConverterOutput[];
}
export type SerializedConverter = {
kind: 'converter';
}
export type SerializedStorage = {
kind: 'storage',
}
export type SerializedNodeBehaviour = {
id: string;
inputs: SerializedConverterInputs;
outputs: SerializedConverterOutputs;
} & (SerializedConverter | SerializedStorage);
export type SerializedNode = {
id: Node['id'],
name: Node['name'],
behaviour: SerializedNodeBehaviour,
}
export type SerializedGame = {
time: number;
nodes: SerializedNode[];
}
+79
View File
@@ -0,0 +1,79 @@
import createGraph, { Graph } from "ngraph.graph";
import createLayout from "ngraph.forcelayout";
import { Node } from "../types";
const ITERATION_COUNT = 10;
export type Node2D = {
id: Node['id'];
position: { x: number, y: number };
node: Node;
}
export function build2dNodes(nodes: Node[], area: { width: number, height: number, padding: number }): Node2D[] {
const graph = createGraph();
for (const node of nodes) {
graph.addNode(node.behaviour.id, { node });
}
for (const node of nodes) {
for (const link of node.behaviour.getDisplayInfo().links)
graph.addLink(node.behaviour.id, link.target);
}
return buildGraph2dNodes(graph, area);
}
function buildGraph2dNodes(graph: Graph, area: { width: number, height: number, padding: number }): Node2D[] {
const layout = createLayout(
graph,
{
springLength: 300,
// springTransform: (link, spring) => {
// const from = graph.getNode(link.fromId).data.radius;
// const to = graph.getNode(link.toId).data.radius;
// spring.length = from + to + 10; // отступ 10px
// },
},
);
function clampToBounds() {
const minX = area.padding;
const maxX = area.width - area.padding;
const minY = area.padding;
const maxY = area.height - area.padding;
layout.forEachBody((body) => {
if (body.pos.x < minX) { body.pos.x = minX; body.velocity.x = 0; }
if (body.pos.x > maxX) { body.pos.x = maxX; body.velocity.x = 0; }
if (body.pos.y < minY) { body.pos.y = minY; body.velocity.y = 0; }
if (body.pos.y > maxY) { body.pos.y = maxY; body.velocity.y = 0; }
});
}
for (var i = 0; i < ITERATION_COUNT; ++i) {
layout.step();
// clampToBounds();
}
// const rect = layout.getGraphRect();
// const W = area.width, H = 600, PADDING = 40;
// const scaleX = (area.width - area.padding * 2) / (rect.x2 - rect.x1);
// const scaleY = (area.height - area.padding * 2) / (rect.y2 - rect.y1);
// const scale = Math.min(scaleX, scaleY); // сохраняем пропорции
// function toCanvas(pos: { x: number; y: number }) {
// return {
// x: area.padding + (pos.x - rect.x1) * scale,
// y: area.padding + (pos.y - rect.y1) * scale,
// };
// }
const nodes: Node2D[] = [];
layout.forEachBody((body, nodeId) => {
nodes.push({
id: nodeId.toString(),
position: { x: body.pos.x + area.width / 2, y: body.pos.y + area.height / 2 },
node: graph.getNode(nodeId)?.data.node,
});
});
return nodes;
}
View File
+17
View File
@@ -0,0 +1,17 @@
import { Resource } from "../types";
export type ResourceDescription = {
id: Resource;
label: string;
color: string;
icon: string;
}
export const ResourceDescriptions: Record<ResourceDescription['id'], ResourceDescription> = {
wood: { id: 'wood', label: 'Дерево', color: '#b98249', icon: '🌲' },
plank: { id: 'plank', label: 'Доска', color: '#c9c209', icon: 'P' },
// iron: { id: 'iron', label: 'Железо', color: '#9aa7b0', icon: '⛓' },
// stone: { id: 'stone', label: 'Камень', color: '#8a8a8a', icon: '🪨' },
// gold: { id: 'gold', label: 'Золото', color: '#d9b64e', icon: '💰' },
// knowledge: { id: 'knowledge', label: 'Знание', color: '#7d8bf0', icon: '📖' },
};
+20
View File
@@ -0,0 +1,20 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true
},
"include": ["src"]
}
+7
View File
@@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}
+11
View File
@@ -0,0 +1,11 @@
{
"compilerOptions": {
"composite": true,
"skipLibCheck": true,
"module": "ESNext",
"moduleResolution": "bundler",
"allowSyntheticDefaultImports": true,
"strict": true
},
"include": ["vite.config.ts"]
}
+6
View File
@@ -0,0 +1,6 @@
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
export default defineConfig({
plugins: [react()],
});