initial commit
This commit is contained in:
@@ -0,0 +1,4 @@
|
||||
node_modules
|
||||
dist
|
||||
*.tsbuildinfo
|
||||
.DS_Store
|
||||
@@ -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
@@ -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>
|
||||
Generated
+1790
File diff suppressed because it is too large
Load Diff
@@ -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
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 },
|
||||
],
|
||||
};
|
||||
}
|
||||
@@ -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 }>;
|
||||
@@ -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
@@ -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; }
|
||||
@@ -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>
|
||||
);
|
||||
@@ -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: [],
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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[];
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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: '📖' },
|
||||
};
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"files": [],
|
||||
"references": [
|
||||
{ "path": "./tsconfig.app.json" },
|
||||
{ "path": "./tsconfig.node.json" }
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"skipLibCheck": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"strict": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import { defineConfig } from 'vite';
|
||||
import react from '@vitejs/plugin-react';
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
});
|
||||
Reference in New Issue
Block a user