engine moved to a separate node.js module
This commit is contained in:
@@ -0,0 +1 @@
|
|||||||
|
{"sessionId":"d232f3c1-b7d4-4616-b6d5-259dc502a2e7","pid":83268,"procStart":"134326544516295730","acquiredAt":1788511492089}
|
||||||
@@ -1,43 +1,245 @@
|
|||||||
# Граф ресурсов — тиковая симуляция (Vite + React + TypeScript)
|
# Resource Graph Engine — документация движка
|
||||||
|
|
||||||
## Запуск
|
Этот файл описывает **логику симуляции** (пакет `src/engine`, `src/types`, `src/utils`), а не конкретную игру. Он написан как справочник для разработки НА БАЗЕ этого движка (в том числе с помощью Claude) — здесь зафиксированы точные контракты, инварианты и неочевидные архитектурные решения, которые нужно знать, чтобы не сломать существующую логику при добавлении нового контента.
|
||||||
|
|
||||||
```bash
|
Слой представления (`src/components`, `src/ui`, `App.tsx`) — это ОДНА конкретная демо-визуализация (React + SVG + автолэйаут ELK). Движок от неё не зависит: `Game`/`Behaviour`/сериализация не импортируют ничего из React/DOM. Полностью описан отдельным разделом в конце.
|
||||||
npm install
|
|
||||||
npm run dev
|
## Ключевые понятия
|
||||||
|
|
||||||
|
| Термин | Где определён | Смысл |
|
||||||
|
|---|---|---|
|
||||||
|
| `Resource` | `src/types/resource.ts` | Строковый union-тип названий ресурсов (`'tree' \| 'wood' \| 'stone'` на момент написания). Добавление нового ресурса — это просто расширение union'а. |
|
||||||
|
| `Unit` | `src/types/unit.ts` | Узел мира: `{ id, name, behaviour }`. `id` — идентификатор *слота в мире* (для UI/сохранения расположения), `name` — отображаемое имя. |
|
||||||
|
| `Behaviour` | `src/engine/behaviours/base.ts` | Абстрактный класс — фактическая симулируемая сущность. **У `Behaviour` есть СВОЙ `id`, отдельный от `Unit.id`.** Связи (`inputLinks`) ссылаются на `Behaviour.id`, а не на `Unit.id`. |
|
||||||
|
| `Game` | `src/engine/game.ts` | Контейнер: `time: Decimal` + `nodes: Unit[]`, метод `tick(deltaTime)` — единственная точка входа симуляции. |
|
||||||
|
|
||||||
|
Важный нюанс, который легко перепутать: в `prefabs/game.ts` узел с `Unit.id = 'sawmill'` содержит `Behaviour` с `id: 'sawmill.1'`; связи других узлов указывают `target: 'sawmill.1'` (id поведения), не `'sawmill'`.
|
||||||
|
|
||||||
|
Все числа в симуляции — `Decimal` из [`break_eternity.js`](https://github.com/Patashu/break_eternity.js) (для больших чисел инкремental-игр), а не `number`. Публичные API принимают `DecimalSource = Decimal | number | string`. Только шаг тика (`deltaTime` в `Game.tick`/`Behaviour.tick`) — обычный `number`.
|
||||||
|
|
||||||
|
## Модель поведения (`Behaviour`)
|
||||||
|
|
||||||
|
```ts
|
||||||
|
abstract class Behaviour {
|
||||||
|
readonly id: string;
|
||||||
|
readonly kind: string; // 'converter' | 'storage' | ...
|
||||||
|
readonly inputLinks: BehaviourLink[]; // BehaviourLink = { target: string, resource: Resource }
|
||||||
|
|
||||||
|
abstract get inputs(): Resource[]; // какие ресурсы принимает (для сокетов/UI)
|
||||||
|
abstract get outputs(): Resource[]; // какие ресурсы отдаёт (для сокетов/UI)
|
||||||
|
|
||||||
|
abstract take(resource, amount): Decimal; // публичный: забрать (ограничено наличием)
|
||||||
|
protected abstract give(resource, amount): void; // защищённый: положить (без ограничений)
|
||||||
|
abstract getStorage(): Partial<Resources>; // снимок текущих запасов
|
||||||
|
abstract reset(): void; // обнулить запасы (не связи/id)
|
||||||
|
|
||||||
|
abstract tick(
|
||||||
|
deltaTime: number,
|
||||||
|
resolve: (id: string) => Behaviour | undefined,
|
||||||
|
fairShare: (sourceId: string, resource: Resource) => Decimal,
|
||||||
|
): void;
|
||||||
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
Откроется адрес вида `http://localhost:5173`.
|
**`take()` — единственный публичный способ отобрать ресурс.** Всегда возвращает `min(amount, реально_доступное)`, никогда не уходит в минус. `give()` защищённый — положить ресурс в поведение может только оно само (изнутри `tick()`, после `take()` у источника). Это значит: **толкать (push) ресурс в чужое поведение нельзя** — можно только объявить связь (`inputLinks`) и на своём тике *вытягивать (pull)* у источника.
|
||||||
|
|
||||||
## Сборка
|
### Односвязность входа (важное ограничение)
|
||||||
|
|
||||||
```bash
|
`inputLinks` — плоский список `{target, resource}`. Оба текущих поведения ищут связь через `inputLinks.find(l => l.resource === X)` — **берётся первая** подходящая по ресурсу связь. Следствие: **один вход поведения может брать один и тот же ресурс только из ОДНОГО источника.** Если нужно смешивать один и тот же ресурс из нескольких источников в одном поведении — текущий код этого не поддерживает, нужно расширять `find` на что-то вроде списка/суммирования. Fan-*out* (один источник → много потребителей одного ресурса) поддерживается полностью, см. ниже.
|
||||||
npm run build # tsc -b && vite build -> dist/
|
|
||||||
npm run preview # локальный просмотр собранного dist/
|
### Порядок связи `resource`
|
||||||
|
|
||||||
|
`BehaviourLink.resource` используется дважды одним и тем же значением: и как ключ в собственных `inputRates`/`acceptsResources` этого поведения, и как аргумент `source.take(link.resource, ...)`. **Переименования ресурса через связь нет** — если A принимает `wood` от B, у B тоже должно быть `wood` (не `plank`, не что-то ещё).
|
||||||
|
|
||||||
|
## Тик игры (`Game.tick(deltaTime)`)
|
||||||
|
|
||||||
|
```ts
|
||||||
|
tick(deltaTime: number) {
|
||||||
|
time += deltaTime;
|
||||||
|
|
||||||
|
// 1. Топологический порядок: источник тикает раньше потребителя.
|
||||||
|
for (const behaviour of orderByDependency(nodes)) {
|
||||||
|
behaviour.tick(deltaTime, resolve, fairShare);
|
||||||
|
snapshots[behaviour.id] = behaviour.getStorage(); // фиксируем "после этого тика"
|
||||||
|
}
|
||||||
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
## Структура
|
1. **Топологическая сортировка** (`src/utils/behaviourSort.ts`, DFS по `inputLinks`) гарантирует, что производитель тикает раньше своего потребителя — благодаря этому ресурс, произведённый в этом тике, может быть **потреблён по всей цепочке в тот же самый тик**, а не с задержкой в один тик.
|
||||||
|
- **Циклические связи**: если граф содержит цикл (A тянет у B, B тянет у A), DFS обрывает цикл в первом же уже-посещённом узле и просто использует порядок обхода как есть — без ошибки, но без гарантии полной синхронности: одно из рёбер цикла всегда будет видеть состояние источника с *предыдущего* тика, а не текущего. На практике для DAG (обычный ресурсный граф без циклов) это не проявляется.
|
||||||
|
|
||||||
- `src/engine.ts` — ядро симуляции: типы (`SimNode`, `SimEdge`, `Behavior`,
|
2. **`resolve(id)`** — поиск `Behaviour` по его `id` (не `Unit.id`!) среди всех узлов игры.
|
||||||
`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`.
|
|
||||||
|
|
||||||
## Модель
|
3. **Честное разделение (`fairShare`)** решает проблему конкуренции: если несколько поведений связаны с одним и тем же (`source`, `resource`), при заборе ресурса "кто первый в порядке тика — тот и съел всё" было бы недетерминированным-по-смыслу (хотя технически воспроизводимым) багом. Вместо этого:
|
||||||
|
- Перед началом тика подсчитывается **`claimantCounts`** — сколько *разных* поведений ссылаются на каждую пару (`target`, `resource`). Это статическая информация (связи не меняются после создания), пересчитывается заново каждый тик, но не может измениться в рамках одной игры без ручного редактирования `inputLinks`.
|
||||||
|
- После того как поведение отработало свой `tick()`, его текущий запас (`getStorage()`) сохраняется в `snapshots` — этот снимок **фиксирован до конца тика**, даже когда несколько потребителей после этого будут его "есть".
|
||||||
|
- `fairShare(sourceId, resource)` возвращает:
|
||||||
|
- `Decimal.dInf` (без ограничения), если претендент один или ни одного;
|
||||||
|
- `snapshot[resource] / claimantCount`, если претендентов несколько.
|
||||||
|
- Каждый потребитель обязан ограничивать свой запрос к источнику через `min(желаемое, fairShare(...))`, ПЕРЕД вызовом `source.take(...)`.
|
||||||
|
- **Это буквальное равное деление, не max-min fairness.** Если один потребитель хочет меньше своей доли — остаток НЕ перераспределяется другим в этот же тик, а просто остаётся у источника до следующего. Пример (один источник даёт 10, два потребителя хотят 10 и 3): первый получит ровно 5 (свою долю), второй — все свои 3; 2 единицы останутся у источника.
|
||||||
|
- **Реализация нового `Behaviour` обязана сама вызывать `fairShare` и ограничивать запрос** — это не гарантируется автоматически на уровне `take()`.
|
||||||
|
|
||||||
- **Storage** — пассивный буфер с ёмкостью.
|
## `Converter` (`src/engine/behaviours/converter.ts`)
|
||||||
- **Converter** — единственный активный узел: раз в `period` тиков атомарно
|
|
||||||
списывает `inputs` и начисляет `outputs` в собственном буфере.
|
|
||||||
`source`/`consumer` — его частные случаи с пустым списком входов/выходов.
|
|
||||||
- **Edge** — перенос ресурса между буферами узлов; конкуренция при
|
|
||||||
fan-in/fan-out разрешается по `priority` (выше — раньше), порядок
|
|
||||||
обработки узлов/рёбер фиксирован — тик полностью детерминирован.
|
|
||||||
|
|
||||||
Reset обнуляет буферы и таймеры конвертеров, настройки узлов/связей не трогает.
|
Узел с рецептом: раз в `cycleLength` секунд (по игровому времени, не по количеству тиков) атомарно списывает `inputRates` и начисляет `outputRates` в собственный буфер (`storage`).
|
||||||
|
|
||||||
|
Поля: `cycleTime: Decimal` (прогресс внутри текущего цикла, мутируется), `cycleLength: Decimal`, `inputRates: Partial<Resources>`, `outputRates: Partial<Resources>` (оба — ставки *за один цикл*, не в секунду).
|
||||||
|
|
||||||
|
### Алгоритм `tick(deltaTime, resolve, fairShare)`
|
||||||
|
|
||||||
|
```
|
||||||
|
idealCycleTime = cycleTime + deltaTime
|
||||||
|
reserveCycles = ceil(idealCycleTime / cycleLength) // сколько циклов "хочется" покрыть запасом наперёд
|
||||||
|
newCycleTime = idealCycleTime
|
||||||
|
|
||||||
|
для каждого (resource, rate) из inputRates (rate != 0):
|
||||||
|
target = rate * reserveCycles // сколько нужно НАКОПИТЬ, чтобы не тормозить
|
||||||
|
shortfall = max(0, target - storage[resource])
|
||||||
|
если shortfall > 0 И есть связь на resource:
|
||||||
|
source = resolve(link.target)
|
||||||
|
request = min(shortfall, fairShare(link.target, link.resource))
|
||||||
|
give(resource, source.take(link.resource, request)) // забираем ОДНИМ куском, не по чуть-чуть
|
||||||
|
|
||||||
|
// прогресс не может опережать САМЫЙ дефицитный ингредиент:
|
||||||
|
newCycleTime = min(newCycleTime, storage[resource] * cycleLength / rate)
|
||||||
|
|
||||||
|
cycleTime = newCycleTime
|
||||||
|
affordableCycles = floor(cycleTime / cycleLength)
|
||||||
|
если affordableCycles > 0:
|
||||||
|
списать affordableCycles * inputRates[*] из storage
|
||||||
|
начислить affordableCycles * outputRates[*] в storage
|
||||||
|
cycleTime -= affordableCycles * cycleLength
|
||||||
|
```
|
||||||
|
|
||||||
|
Три свойства, которые важно понимать при отладке/расширении:
|
||||||
|
|
||||||
|
1. **"Забрать всё сразу, а не по чуть-чуть".** `reserveCycles` округляется ВВЕРХ (`ceil`), а не пропорционально прошедшей доле цикла. Это специально: как только у источника появляется достаточно ресурса, конвертер выгребает его одним рывком (весь нужный на текущий+грядущий циклы объём), а не тянет дробно по мере хода времени. Визуально в UI это выглядит так, будто буфер конвертера "телепортируется" с 0 сразу на полную ставку в момент, когда источник произвёл ресурс.
|
||||||
|
|
||||||
|
2. **`cycleTime` не может "врать".** Прогресс `newCycleTime` ограничивается снизу для КАЖДОГО входного ресурса формулой `have * cycleLength / rate` — то есть таймер физически не может показывать больше прогресса, чем оправдано самым дефицитным ингредиентом. Если ресурсов не хватает, `cycleTime` просто перестаёт расти (стоит на месте) до следующего тика, когда источник, возможно, произведёт ещё. **Никакого "долга" не копится**: если конвертер голодал 50 тиков подряд, при появлении ресурса он не обработает мгновенно 50 циклов — `cycleTime` всё это время не рос, а как только ресурс появился, продвинется ровно настолько, насколько хватит того, что реально забрано.
|
||||||
|
|
||||||
|
3. **Один вызов `tick()` может закрыть НЕСКОЛЬКО циклов сразу**, если `deltaTime` большой (например, при офлайн-прогрессе одним большим тиком) и ресурсов хватает — `affordableCycles` не ограничен единицей. Это специально сделано так, чтобы `game.tick(1000)` (один большой шаг) и 10 000×`game.tick(0.1)` (много маленьких) давали **математически тот же результат** (с точностью до погрешности накопления `number` при многократном сложении `deltaTime`, см. раздел "Подводные камни"). Из этого следует практическая рекомендация: **офлайн-прогресс лучше считать одним большим `tick()`**, а не циклом из множества маленьких — так быстрее (не зависит от числа "прошедших" циклов) и точнее.
|
||||||
|
|
||||||
|
Пустые `inputRates`/`outputRates` — нормальный случай: чистый источник (пустые входы) или чистый потребитель/поглотитель (пустые выходы).
|
||||||
|
|
||||||
|
## `Storage` (`src/engine/behaviours/storage.ts`)
|
||||||
|
|
||||||
|
Пассивный буфер **без собственной скорости**: и приём, и выдача ресурса ограничены только противоположной стороной.
|
||||||
|
|
||||||
|
Поля: `capacity: Decimal | undefined` (лимит, **на каждый ресурс отдельно**, не общий на всё хранилище), `acceptsResources: Resource[]` (какие ресурсы вообще может держать), `items: Partial<Resources>`.
|
||||||
|
|
||||||
|
### Алгоритм `tick(deltaTime, resolve, fairShare)`
|
||||||
|
|
||||||
|
`deltaTime` не используется — скорость не зависит от времени.
|
||||||
|
|
||||||
|
```
|
||||||
|
для каждого resource из acceptsResources:
|
||||||
|
если нет связи на этот resource — пропустить
|
||||||
|
source = resolve(link.target)
|
||||||
|
room = capacity ? max(0, capacity - items[resource]) : Infinity
|
||||||
|
request = min(room, fairShare(link.target, link.resource))
|
||||||
|
give(resource, source.take(link.resource, request))
|
||||||
|
```
|
||||||
|
|
||||||
|
То есть на каждом тике `Storage` пытается забрать у источника **столько, сколько есть места** (или сколько источник готов дать — что меньше), одним куском, без ограничения по скорости. С другой стороны, любой узел, который свяжется со `Storage` как со своим источником, вызовет `storage.take()`, которое отдаст **сколько попросят, ограничено только фактическим наличием** — тоже без собственного лимита скорости `Storage`.
|
||||||
|
|
||||||
|
`capacity` не задан (`undefined`) → ёмкость бесконечна для всех принимаемых ресурсов.
|
||||||
|
|
||||||
|
## Данные и сериализация (`src/types/serialization.ts`, `src/engine/saveLoad.ts`)
|
||||||
|
|
||||||
|
```ts
|
||||||
|
type SerializedGame = {
|
||||||
|
time: DecimalSource;
|
||||||
|
nodes: SerializedNode[]; // { id, name, behaviour }
|
||||||
|
}
|
||||||
|
|
||||||
|
type SerializedBehaviour = {
|
||||||
|
id: string;
|
||||||
|
inputLinks: { pins: { target: string, resource: Resource }[] };
|
||||||
|
} & (SerializedConverter | SerializedStorage);
|
||||||
|
|
||||||
|
type SerializedConverter = {
|
||||||
|
kind: 'converter';
|
||||||
|
cycleTime?: DecimalSource; // по умолчанию 0
|
||||||
|
storage?: Partial<Record<Resource, DecimalSource>>; // по умолчанию {}
|
||||||
|
rules: {
|
||||||
|
cycleLength: DecimalSource;
|
||||||
|
inputRates?: Partial<Record<Resource, DecimalSource>>;
|
||||||
|
outputRates?: Partial<Record<Resource, DecimalSource>>;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type SerializedStorage = {
|
||||||
|
kind: 'storage';
|
||||||
|
capacity?: DecimalSource; // по умолчанию бесконечность
|
||||||
|
acceptsResources: Resource[];
|
||||||
|
items: Partial<Record<Resource, DecimalSource>>;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Пример из `src/prefabs/game.ts` (полный рабочий граф — лес → лесопилка → два склада, второй склад забирает камень с той же связи, что и первый, демонстрируя `fairShare`):
|
||||||
|
|
||||||
|
```ts
|
||||||
|
{
|
||||||
|
id: 'forest', name: 'Лес',
|
||||||
|
behaviour: {
|
||||||
|
id: 'forest.1', kind: 'converter',
|
||||||
|
inputLinks: { pins: [] },
|
||||||
|
rules: { cycleLength: 1, outputRates: { tree: 1, stone: 0.1 } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'sawmill', name: 'Лесопилка',
|
||||||
|
behaviour: {
|
||||||
|
id: 'sawmill.1', kind: 'converter',
|
||||||
|
inputLinks: { pins: [{ resource: 'tree', target: 'forest.1' }] },
|
||||||
|
rules: { cycleLength: 1.8, inputRates: { tree: 1 }, outputRates: { wood: 2 } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'shop', name: 'Магазин',
|
||||||
|
behaviour: {
|
||||||
|
id: 'shop.1', kind: 'storage',
|
||||||
|
inputLinks: { pins: [
|
||||||
|
{ resource: 'wood', target: 'sawmill.1' },
|
||||||
|
{ resource: 'stone', target: 'forest.1' }, // делит 'stone' с shop2 через fairShare
|
||||||
|
] },
|
||||||
|
acceptsResources: ['wood', 'stone'],
|
||||||
|
items: {},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
```
|
||||||
|
|
||||||
|
API верхнего уровня: `serializeBehaviour`/`deserializeBehaviour` (диспетчер по `kind`), `serialize`/`deserialize` (весь `Game`), `serializeToString`/`deserializeFromString` (JSON), `save`/`load` (localStorage, ключ `` `${saveParams.path}_incgane` `` — `saveParams.path` можно менять, чтобы держать несколько независимых сохранений/слотов).
|
||||||
|
|
||||||
|
`Game.reset()` обнуляет `time` и вызывает `reset()` у каждого поведения (обнуляет только запасы — `cycleTime`, связи, ставки и id не трогает). `Game.clone()` — неглубокая копия с новым `Object.create`, нужна исключительно чтобы React увидел изменение ссылки после мутирующего `tick()`/`reset()`.
|
||||||
|
|
||||||
|
## Как расширять движок
|
||||||
|
|
||||||
|
### Добавить новый ресурс
|
||||||
|
1. Добавить строку в union `Resource` (`src/types/resource.ts`).
|
||||||
|
2. Добавить запись в `ResourceDescriptions` (`src/ui/resource.ts`) — `label`/`color`/`icon` для UI. Это **не часть движка**, но без неё UI упадёт при попытке отрисовать сокет/буфер с новым ресурсом (обращение по ключу без проверки).
|
||||||
|
|
||||||
|
### Добавить новый тип `Behaviour`
|
||||||
|
1. Отнаследоваться от `Behaviour` (`src/engine/behaviours/base.ts`), реализовать все абстрактные члены: `inputs`, `outputs`, `take`, `give`, `getStorage`, `reset`, `tick`.
|
||||||
|
2. В `tick()` — если поведение вообще что-то тянет через `inputLinks`, обязательно ограничивать запрос через переданный `fairShare(sourceId, resource)` (см. `Converter`/`Storage` как образец), иначе честное деление между несколькими потребителями не будет работать для этого нового типа.
|
||||||
|
3. Добавить новый `kind`-литерал в тип `SerializedBehaviour` (объединение в `src/types/serialization.ts`) и обработать его в обоих `switch` в `src/engine/saveLoad.ts` (`serializeBehaviour` и `deserializeBehaviour`) — если забыть один из двух, поведение либо не сохранится, либо не восстановится при загрузке, без ошибки компиляции (проверьте оба места вручную).
|
||||||
|
4. Если нужна собственная визуализация внутри карточки (как `ConverterCardInfo` для прогресс-бара конвертера) — расширить `BehaviourCard.tsx` условием по `behaviour.kind`, аналогично существующему `behaviour.kind === 'converter' && <ConverterCardInfo .../>`.
|
||||||
|
|
||||||
|
## Подводные камни
|
||||||
|
|
||||||
|
- **`Decimal` не бесконечно точен для обычных чисел.** Для величин масштаба `layer === 0` (не гипер-больших чисел) `break_eternity.js` внутри использует обычный JS `double` — привычные артефакты вроде `0.1 + 0.1 + 0.1 !== 0.3` воспроизводятся и здесь. Смена `number` → `Decimal`/строка это не лечит. Для отображения использовать `.toStringWithDecimalPlaces(n)`, а не сырой `.toString()`.
|
||||||
|
- **Много маленьких тиков ≠ побитово один большой тик**, только математически эквивалентны — расхождение исключительно из-за многократного накопления `deltaTime` (см. выше), не из-за самого алгоритма `Converter`/`Storage`. Для офлайн-прогресса предпочитать один большой `tick(суммарное_время)`.
|
||||||
|
- **`fairShare` не защищает от общего перерасхода**, если реализация нового `Behaviour` игнорирует его и вызывает `take()` напрямую без ограничения — на это нет проверки времени компиляции.
|
||||||
|
- **Порядок узлов в `nodes` влияет на результат только в графах с циклами** (см. раздел про топосортировку); для обычного ациклического графа результат от порядка не зависит.
|
||||||
|
- Тип `Resources = Record<Resource, Decimal>` (не `Partial`), но фактически везде в рантайме используется как `Partial<Resources>` — обращение по ключу может вернуть `undefined`, поэтому по всему движку принят паттерн `storage[resource] ?? Decimal.dZero`.
|
||||||
|
|
||||||
|
## Слой представления (не часть движка, но идёт в комплекте)
|
||||||
|
|
||||||
|
- `src/ui/graph.ts` — раскладка графа на плоскости через `elkjs` (слоистый layout, направление `LEFT`), асинхронно (`build2dNodes`).
|
||||||
|
- `src/ui/resource.ts` — `ResourceDescriptions`: цвет/иконка/подпись на ресурс, для сокетов и буферов.
|
||||||
|
- `src/components/GameCanvas.tsx` — измеряет реальные центры карточек/сокетов через DOM (`getBoundingClientRect`) после раскладки, чтобы линии связей (`LinkLine.tsx`) рисовались точно между сокетами, а не между условными координатами узла.
|
||||||
|
- `src/components/BehaviourCard.tsx` — карточка узла: сокеты, буферы (`getDisplayInfo().storages`), для конвертера — прогресс-бар (`ConverterCardInfo.tsx`, использует `cycleTime.div(cycleLength)`), плюс отладочный YAML-дамп `info.raw`.
|
||||||
|
- `src/App.tsx` — хранит единственный `Game` в `useState`, персистит в localStorage при каждом изменении, `handleTick` вызывает `game.tick(1/10)` + `setGame(game.clone())` (тик размером `0.1` игровой секунды на клик).
|
||||||
|
|
||||||
|
Ничего в `src/engine`/`src/types`/`src/utils` не импортирует из `src/components`/`src/ui`/React — движок можно использовать (или перенести под другой рендер) без этого слоя.
|
||||||
|
|||||||
Generated
+18
-2
@@ -1,13 +1,14 @@
|
|||||||
{
|
{
|
||||||
"name": "resource-graph-sim",
|
"name": "@incgame/resource-graph-sim",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "resource-graph-sim",
|
"name": "@incgame/resource-graph-sim",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@incgame/engine": "file:../engine",
|
||||||
"break_eternity.js": "^2.1.3",
|
"break_eternity.js": "^2.1.3",
|
||||||
"elkjs": "^0.12.0",
|
"elkjs": "^0.12.0",
|
||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
@@ -22,6 +23,17 @@
|
|||||||
"vite": "^5.4.10"
|
"vite": "^5.4.10"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"../engine": {
|
||||||
|
"name": "@incgame/engine",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"break_eternity.js": "^2.1.3"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"typescript": "^5.6.3"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@babel/code-frame": {
|
"node_modules/@babel/code-frame": {
|
||||||
"version": "7.29.7",
|
"version": "7.29.7",
|
||||||
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz",
|
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz",
|
||||||
@@ -695,6 +707,10 @@
|
|||||||
"node": ">=12"
|
"node": ">=12"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@incgame/engine": {
|
||||||
|
"resolved": "../engine",
|
||||||
|
"link": true
|
||||||
|
},
|
||||||
"node_modules/@jridgewell/gen-mapping": {
|
"node_modules/@jridgewell/gen-mapping": {
|
||||||
"version": "0.3.13",
|
"version": "0.3.13",
|
||||||
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
|
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
|
||||||
|
|||||||
+3
-2
@@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"name": "resource-graph-sim",
|
"name": "@incgame/resource-graph-sim",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
@@ -13,7 +13,8 @@
|
|||||||
"elkjs": "^0.12.0",
|
"elkjs": "^0.12.0",
|
||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
"react-dom": "^18.3.1",
|
"react-dom": "^18.3.1",
|
||||||
"yaml": "^2.9.0"
|
"yaml": "^2.9.0",
|
||||||
|
"@incgame/engine": "file:../engine"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/react": "^18.3.12",
|
"@types/react": "^18.3.12",
|
||||||
|
|||||||
+3
-3
@@ -1,9 +1,9 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import Topbar from './components/Topbar';
|
import Topbar from './components/Topbar';
|
||||||
import { GameCanvas } from './components/GameCanvas';
|
import { GameCanvas } from './components/GameCanvas';
|
||||||
import { deserialize, load, save } from './engine/saveLoad';
|
import { load, save } from './saveLoad';
|
||||||
import { defaultGame } from './prefabs/game';
|
import { defaultGame } from './prefabs/game';
|
||||||
import { Game } from './engine/game';
|
import { Game, deserializeGame } from '@incgame/engine';
|
||||||
|
|
||||||
export type Selection = { kind: 'node'; id: string } | null;
|
export type Selection = { kind: 'node'; id: string } | null;
|
||||||
|
|
||||||
@@ -37,7 +37,7 @@ export default function App() {
|
|||||||
if (!ok)
|
if (!ok)
|
||||||
return;
|
return;
|
||||||
|
|
||||||
setGame(deserialize(defaultGame()));
|
setGame(deserializeGame(defaultGame()));
|
||||||
}
|
}
|
||||||
|
|
||||||
// function handleAddNode(kind: NodeKind) {
|
// function handleAddNode(kind: NodeKind) {
|
||||||
|
|||||||
@@ -1,11 +1,8 @@
|
|||||||
import { forwardRef } from 'react';
|
import { forwardRef } from 'react';
|
||||||
import { Resource } from '../types';
|
|
||||||
import { GraphNode } from '../ui/graph';
|
import { GraphNode } from '../ui/graph';
|
||||||
import { ResourceDescriptions } from '../ui/resource';
|
import { ResourceDescriptions } from '../ui/resource';
|
||||||
import { Game } from '../engine/game';
|
|
||||||
import { ConverterCardInfo } from './ConverterCardInfo';
|
import { ConverterCardInfo } from './ConverterCardInfo';
|
||||||
import { Converter } from '../engine/behaviours/converter';
|
import { Game, Behaviour, Converter, Resource } from '@incgame/engine';
|
||||||
import { Behaviour } from '../engine/behaviours/base';
|
|
||||||
import * as yaml from 'yaml';
|
import * as yaml from 'yaml';
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { Converter } from "../engine/behaviours/converter";
|
import { Converter } from "@incgame/engine";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
converter: Converter;
|
converter: Converter;
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { BehaviourCard } from './BehaviourCard';
|
|||||||
import { build2dNodes, GraphNode } from '../ui/graph';
|
import { build2dNodes, GraphNode } from '../ui/graph';
|
||||||
import { useEffect, useLayoutEffect, useRef, useState } from 'react';
|
import { useEffect, useLayoutEffect, useRef, useState } from 'react';
|
||||||
import { LinkLine } from './LinkLine';
|
import { LinkLine } from './LinkLine';
|
||||||
import { Game } from '../engine/game';
|
import { Game } from '@incgame/engine';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
game: Game;
|
game: Game;
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import { Game } from "../engine/game";
|
import { Game, Resource } from "@incgame/engine";
|
||||||
import { Resource } from "../types";
|
|
||||||
import { GraphNode } from "../ui/graph";
|
import { GraphNode } from "../ui/graph";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
|
|||||||
@@ -1,242 +0,0 @@
|
|||||||
// 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 },
|
|
||||||
],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1,49 +0,0 @@
|
|||||||
import Decimal, { DecimalSource } from "break_eternity.js";
|
|
||||||
import { BehaviourDisplayInfo, BehaviourLink, BehaviourSocket, Resource, SerializedBehaviour } from "../../types";
|
|
||||||
|
|
||||||
export abstract class Behaviour {
|
|
||||||
public readonly id: string;
|
|
||||||
public readonly kind: string;
|
|
||||||
public readonly inputLinks: BehaviourLink[];
|
|
||||||
|
|
||||||
constructor(source: SerializedBehaviour) {
|
|
||||||
this.id = source.id;
|
|
||||||
this.kind = source.kind;
|
|
||||||
this.inputLinks = source.inputLinks.pins;
|
|
||||||
}
|
|
||||||
|
|
||||||
public getDisplayInfo(): BehaviourDisplayInfo {
|
|
||||||
return {
|
|
||||||
storages: {},
|
|
||||||
sockets: [
|
|
||||||
...this.inputs.map((input, index) => ({
|
|
||||||
type: 'input',
|
|
||||||
index,
|
|
||||||
resource: input,
|
|
||||||
} satisfies BehaviourSocket)),
|
|
||||||
...this.outputs.map((output, index) => ({
|
|
||||||
type: 'output',
|
|
||||||
index,
|
|
||||||
resource: output,
|
|
||||||
} satisfies BehaviourSocket)),
|
|
||||||
],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
public abstract get inputs(): Resource[];
|
|
||||||
public abstract get outputs(): Resource[];
|
|
||||||
|
|
||||||
/** Withdraws up to `amount` of `resource` from this behaviour's storage, returning how much was actually taken. */
|
|
||||||
public abstract take(resource: Resource, amount: DecimalSource): Decimal;
|
|
||||||
|
|
||||||
protected abstract give(resource: Resource, amount: DecimalSource): void;
|
|
||||||
|
|
||||||
abstract reset(): void;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Advances this behaviour by `deltaTime`. Must pull whatever inputs it needs (via `resolve`)
|
|
||||||
* before advancing its own cycle, so that a cycle only completes once enough input was actually
|
|
||||||
* available — otherwise it stalls and tries again on a later tick.
|
|
||||||
*/
|
|
||||||
abstract tick(deltaTime: number, resolve: (id: string) => Behaviour | undefined): void;
|
|
||||||
}
|
|
||||||
@@ -1,118 +0,0 @@
|
|||||||
import Decimal, { DecimalSource } from "break_eternity.js";
|
|
||||||
import { BehaviourDisplayInfo, Resource, Resources, SerializedBehaviour, SerializedConverter } from "../../types";
|
|
||||||
import { Behaviour } from "./base";
|
|
||||||
|
|
||||||
export class Converter extends Behaviour {
|
|
||||||
protected readonly storage: Partial<Resources>;
|
|
||||||
public cycleTime: Decimal;
|
|
||||||
public readonly cycleLength: Decimal;
|
|
||||||
public readonly inputRates: Partial<Resources>;
|
|
||||||
public readonly outputRates: Partial<Resources>;
|
|
||||||
|
|
||||||
constructor(source: SerializedBehaviour & SerializedConverter) {
|
|
||||||
if (source.kind !== 'converter')
|
|
||||||
throw new Error('Invalid source');
|
|
||||||
super(source);
|
|
||||||
|
|
||||||
this.cycleTime = new Decimal(source.cycleTime);
|
|
||||||
this.cycleLength = new Decimal(source.rules.cycleLength);
|
|
||||||
|
|
||||||
this.storage = {};
|
|
||||||
for (const [res, amount] of Object.entries(source.storage ?? {}))
|
|
||||||
this.storage[res as Resource] = new Decimal(amount);
|
|
||||||
|
|
||||||
this.inputRates = {};
|
|
||||||
for (const [res, rate] of Object.entries(source.rules.inputRates ?? {}))
|
|
||||||
this.inputRates[res as Resource] = new Decimal(rate);
|
|
||||||
|
|
||||||
this.outputRates = {};
|
|
||||||
for (const [res, rate] of Object.entries(source.rules.outputRates ?? {}))
|
|
||||||
this.outputRates[res as Resource] = new Decimal(rate);
|
|
||||||
}
|
|
||||||
|
|
||||||
public get inputs(): Resource[] {
|
|
||||||
return Object.keys(this.inputRates) as Resource[];
|
|
||||||
}
|
|
||||||
|
|
||||||
public get outputs(): Resource[] {
|
|
||||||
return Object.keys(this.outputRates) as Resource[];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A converter grabs a full batch of each input the moment enough is available — reserving
|
|
||||||
* ahead for however many cycles (including the one currently underway) the elapsed time could
|
|
||||||
* reach, rather than trickling resources in proportionally as time passes. If the source can't
|
|
||||||
* supply the full batch, `cycleTime` only advances as far as what was actually secured
|
|
||||||
* justifies: a converter can't be "cooking" a batch it doesn't have the ingredients for, so it
|
|
||||||
* stalls proportionally to whatever is actually missing rather than free-running on elapsed
|
|
||||||
* time alone.
|
|
||||||
*/
|
|
||||||
public tick(deltaTime: number, resolve: (id: string) => Behaviour | undefined): void {
|
|
||||||
const idealCycleTime = this.cycleTime.add(deltaTime);
|
|
||||||
const reserveCycles = idealCycleTime.div(this.cycleLength).ceil();
|
|
||||||
let newCycleTime = idealCycleTime;
|
|
||||||
|
|
||||||
for (const [res, rate] of Object.entries(this.inputRates) as [Resource, Decimal][]) {
|
|
||||||
if (rate.eq(0))
|
|
||||||
continue;
|
|
||||||
|
|
||||||
const target = rate.mul(reserveCycles);
|
|
||||||
const have = this.storage[res] ?? Decimal.dZero;
|
|
||||||
const shortfall = target.sub(have).max(0);
|
|
||||||
if (shortfall.gt(0)) {
|
|
||||||
const link = this.inputLinks.find((l) => l.resource === res);
|
|
||||||
if (link) {
|
|
||||||
const source = resolve(link.target);
|
|
||||||
if (source)
|
|
||||||
this.give(res, source.take(link.resource, shortfall));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const haveNow = this.storage[res] ?? Decimal.dZero;
|
|
||||||
newCycleTime = newCycleTime.min(haveNow.mul(this.cycleLength).div(rate));
|
|
||||||
}
|
|
||||||
|
|
||||||
this.cycleTime = newCycleTime;
|
|
||||||
const affordableCycles = this.cycleTime.div(this.cycleLength).floor();
|
|
||||||
|
|
||||||
if (affordableCycles.gt(0)) {
|
|
||||||
for (const [res, rate] of Object.entries(this.inputRates) as [Resource, Decimal][])
|
|
||||||
this.take(res, rate.mul(affordableCycles));
|
|
||||||
for (const [res, rate] of Object.entries(this.outputRates) as [Resource, Decimal][])
|
|
||||||
this.give(res, rate.mul(affordableCycles));
|
|
||||||
this.cycleTime = this.cycleTime.sub(affordableCycles.mul(this.cycleLength));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public take(resource: Resource, amount: DecimalSource): Decimal {
|
|
||||||
const available = this.storage[resource] ?? Decimal.dZero;
|
|
||||||
const taken = available.min(amount);
|
|
||||||
this.storage[resource] = available.sub(taken);
|
|
||||||
return taken;
|
|
||||||
}
|
|
||||||
|
|
||||||
protected give(resource: Resource, amount: DecimalSource): void {
|
|
||||||
this.storage[resource] = (this.storage[resource] ?? Decimal.dZero).add(amount);
|
|
||||||
}
|
|
||||||
|
|
||||||
public getStorage(): Partial<Resources> {
|
|
||||||
return { ...this.storage };
|
|
||||||
}
|
|
||||||
|
|
||||||
public getDisplayInfo(): BehaviourDisplayInfo {
|
|
||||||
const info = super.getDisplayInfo();
|
|
||||||
info.storages['Буфер'] = { ...this.storage } as Resources;
|
|
||||||
return {
|
|
||||||
...info,
|
|
||||||
raw: {
|
|
||||||
inputRates: this.inputRates,
|
|
||||||
outputRates: this.outputRates,
|
|
||||||
},
|
|
||||||
} as any;
|
|
||||||
}
|
|
||||||
|
|
||||||
public reset() {
|
|
||||||
for (const res of Object.keys(this.storage))
|
|
||||||
delete (this.storage[res as Resource]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,78 +0,0 @@
|
|||||||
import Decimal, { DecimalSource } from "break_eternity.js";
|
|
||||||
import { BehaviourDisplayInfo, Resource, Resources, SerializedBehaviour, SerializedStorage } from "../../types";
|
|
||||||
import { Behaviour } from "./base";
|
|
||||||
|
|
||||||
export class Storage extends Behaviour {
|
|
||||||
public readonly capacity: Decimal | undefined;
|
|
||||||
public readonly acceptsResources: Resource[];
|
|
||||||
public readonly items: Partial<Resources>;
|
|
||||||
|
|
||||||
constructor(source: SerializedBehaviour & SerializedStorage) {
|
|
||||||
if (source.kind !== 'storage')
|
|
||||||
throw new Error('Invalid source');
|
|
||||||
super(source);
|
|
||||||
|
|
||||||
this.capacity = source.capacity ? new Decimal(source.capacity) : undefined;
|
|
||||||
this.acceptsResources = source.acceptsResources;
|
|
||||||
this.items = {};
|
|
||||||
for (const [res, amount] of Object.entries(source.items ?? {}))
|
|
||||||
this.items[res as Resource] = new Decimal(amount);
|
|
||||||
}
|
|
||||||
|
|
||||||
public get inputs(): Resource[] {
|
|
||||||
return this.acceptsResources;
|
|
||||||
}
|
|
||||||
|
|
||||||
public get outputs(): Resource[] {
|
|
||||||
return this.acceptsResources;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A storage has no rate of its own — it pulls as much as its linked source is willing to give
|
|
||||||
* (up to whatever room its own capacity leaves), and later hands out as much as whoever calls
|
|
||||||
* `take` asks for (up to whatever it actually has). The speed in either direction is entirely
|
|
||||||
* up to the counterpart, not this behaviour.
|
|
||||||
*/
|
|
||||||
public tick(_deltaTime: number, resolve: (id: string) => Behaviour | undefined): void {
|
|
||||||
for (const resource of this.acceptsResources) {
|
|
||||||
const link = this.inputLinks.find((l) => l.resource === resource);
|
|
||||||
if (!link)
|
|
||||||
continue;
|
|
||||||
|
|
||||||
const source = resolve(link.target);
|
|
||||||
if (!source)
|
|
||||||
continue;
|
|
||||||
|
|
||||||
const have = this.items[resource] ?? Decimal.dZero;
|
|
||||||
const room = this.capacity ? this.capacity.sub(have).max(0) : Decimal.dInf;
|
|
||||||
if (room.gt(0))
|
|
||||||
this.give(resource, source.take(link.resource, room));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public take(resource: Resource, amount: DecimalSource): Decimal {
|
|
||||||
const available = this.items[resource] ?? Decimal.dZero;
|
|
||||||
const taken = available.min(amount);
|
|
||||||
this.items[resource] = available.sub(taken);
|
|
||||||
return taken;
|
|
||||||
}
|
|
||||||
|
|
||||||
protected give(resource: Resource, amount: DecimalSource): void {
|
|
||||||
this.items[resource] = (this.items[resource] ?? Decimal.dZero).add(amount);
|
|
||||||
}
|
|
||||||
|
|
||||||
public getStorage(): Partial<Resources> {
|
|
||||||
return { ...this.items };
|
|
||||||
}
|
|
||||||
|
|
||||||
public getDisplayInfo(): BehaviourDisplayInfo {
|
|
||||||
const info = super.getDisplayInfo();
|
|
||||||
info.storages['Хранилище'] = { ...this.items } as Resources;
|
|
||||||
return info;
|
|
||||||
}
|
|
||||||
|
|
||||||
public reset() {
|
|
||||||
for (const res of Object.keys(this.items))
|
|
||||||
delete (this.items[res as Resource]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,42 +0,0 @@
|
|||||||
import Decimal from "break_eternity.js";
|
|
||||||
import { deserializeBehaviour } from "./saveLoad";
|
|
||||||
import { SerializedGame } from "../types";
|
|
||||||
import { Unit } from '../types';
|
|
||||||
import { orderByDependency } from "../utils/behaviourSort";
|
|
||||||
|
|
||||||
export class Game {
|
|
||||||
public time: Decimal;
|
|
||||||
public readonly nodes: Unit[];
|
|
||||||
|
|
||||||
constructor(source: SerializedGame) {
|
|
||||||
this.time = new Decimal(source.time);
|
|
||||||
this.nodes = source.nodes.map((node) => ({
|
|
||||||
id: node.id,
|
|
||||||
name: node.name,
|
|
||||||
behaviour: deserializeBehaviour(node.behaviour),
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
|
|
||||||
public tick(deltaTime: number) {
|
|
||||||
this.time = this.time.add(deltaTime);
|
|
||||||
|
|
||||||
const behavioursById = new Map(this.nodes.map((node) => [node.behaviour.id, node.behaviour]));
|
|
||||||
const resolve = (id: string) => behavioursById.get(id);
|
|
||||||
|
|
||||||
// Producers must tick (and so fill their storage) before whatever pulls from them,
|
|
||||||
// so resources can flow all the way down a chain within a single tick.
|
|
||||||
for (const behaviour of orderByDependency(this.nodes.map((node) => node.behaviour)))
|
|
||||||
behaviour.tick(deltaTime, resolve);
|
|
||||||
}
|
|
||||||
|
|
||||||
public reset() {
|
|
||||||
this.time = Decimal.dZero;
|
|
||||||
for (const node of this.nodes)
|
|
||||||
node.behaviour.reset();
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Shallow copy with a new identity, so React sees a change after in-place mutations like `tick`. */
|
|
||||||
public clone(): Game {
|
|
||||||
return Object.assign(Object.create(Game.prototype), this);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,97 +0,0 @@
|
|||||||
import { defaultGame } from "../prefabs/game";
|
|
||||||
import { SerializedBehaviour, SerializedConverter, SerializedGame, SerializedStorage } from "../types";
|
|
||||||
import { Behaviour } from "./behaviours/base";
|
|
||||||
import { Converter } from "./behaviours/converter";
|
|
||||||
import { Storage } from "./behaviours/storage";
|
|
||||||
import { Game } from "./game";
|
|
||||||
|
|
||||||
export const saveParams = {
|
|
||||||
path: 'default',
|
|
||||||
}
|
|
||||||
|
|
||||||
export function serializeBehaviour(behaviour: Behaviour): SerializedBehaviour {
|
|
||||||
const base = {
|
|
||||||
id: behaviour.id,
|
|
||||||
kind: behaviour.kind,
|
|
||||||
inputLinks: { pins: behaviour.inputLinks },
|
|
||||||
};
|
|
||||||
|
|
||||||
switch (behaviour.kind) {
|
|
||||||
case 'converter': {
|
|
||||||
const converter = behaviour as Converter;
|
|
||||||
return {
|
|
||||||
...base,
|
|
||||||
cycleTime: converter.cycleTime,
|
|
||||||
storage: converter.getStorage(),
|
|
||||||
rules: {
|
|
||||||
cycleLength: converter.cycleLength,
|
|
||||||
inputRates: converter.inputRates,
|
|
||||||
outputRates: converter.outputRates,
|
|
||||||
}
|
|
||||||
} as (SerializedBehaviour & SerializedConverter);;
|
|
||||||
}
|
|
||||||
case 'storage':
|
|
||||||
const storage = behaviour as Storage;
|
|
||||||
return {
|
|
||||||
...base,
|
|
||||||
capacity: storage.capacity,
|
|
||||||
acceptsResources: storage.acceptsResources,
|
|
||||||
items: storage.getStorage(),
|
|
||||||
} as (SerializedBehaviour & SerializedStorage);
|
|
||||||
default:
|
|
||||||
throw new Error('Unknown behaviour');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function deserializeBehaviour(value: SerializedBehaviour): Behaviour {
|
|
||||||
switch (value.kind) {
|
|
||||||
case 'converter':
|
|
||||||
return new Converter(value);
|
|
||||||
case 'storage':
|
|
||||||
return new Storage(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(`${saveParams.path}_${KEY_GAME}`, serializeToString(game));
|
|
||||||
}
|
|
||||||
|
|
||||||
export function load(): Game {
|
|
||||||
try {
|
|
||||||
const raw = localStorage.getItem(`${saveParams.path}_${KEY_GAME}`);
|
|
||||||
if (raw)
|
|
||||||
return deserializeFromString(raw);
|
|
||||||
} catch (e) {
|
|
||||||
console.warn('Не удалось прочитать игру из localStorage', e);
|
|
||||||
}
|
|
||||||
const fresh = deserialize(defaultGame());
|
|
||||||
save(fresh);
|
|
||||||
return fresh;
|
|
||||||
}
|
|
||||||
+19
-1
@@ -1,4 +1,4 @@
|
|||||||
import { SerializedGame } from "../types";
|
import { SerializedGame } from "@incgame/engine";
|
||||||
|
|
||||||
export const defaultGame: () => SerializedGame = () => ({
|
export const defaultGame: () => SerializedGame = () => ({
|
||||||
time: 0,
|
time: 0,
|
||||||
@@ -59,5 +59,23 @@ export const defaultGame: () => SerializedGame = () => ({
|
|||||||
items: {},
|
items: {},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: 'shop2',
|
||||||
|
name: 'Магазин 2',
|
||||||
|
behaviour: {
|
||||||
|
id: 'shop.2',
|
||||||
|
kind: 'storage',
|
||||||
|
inputLinks: {
|
||||||
|
pins: [
|
||||||
|
{
|
||||||
|
resource: 'stone',
|
||||||
|
target: 'forest.1',
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
acceptsResources: ['stone'],
|
||||||
|
items: {},
|
||||||
|
},
|
||||||
|
},
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import { defaultGame } from "./prefabs/game";
|
||||||
|
import { Game, serializeGameToString, deserializeGame, deserializeGameFromString } from '@incgame/engine';
|
||||||
|
|
||||||
|
export const saveParams = {
|
||||||
|
path: 'default',
|
||||||
|
}
|
||||||
|
|
||||||
|
const KEY_GAME = 'incgame';
|
||||||
|
|
||||||
|
export function save(game: Game) {
|
||||||
|
localStorage.setItem(`${saveParams.path}_${KEY_GAME}`, serializeGameToString(game));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function load(): Game {
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem(`${saveParams.path}_${KEY_GAME}`);
|
||||||
|
if (raw)
|
||||||
|
return deserializeGameFromString(raw);
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('Не удалось прочитать игру из localStorage', e);
|
||||||
|
}
|
||||||
|
const fresh = deserializeGame(defaultGame());
|
||||||
|
save(fresh);
|
||||||
|
return fresh;
|
||||||
|
}
|
||||||
@@ -1,23 +0,0 @@
|
|||||||
import { Resource, Resources } from "./resource";
|
|
||||||
|
|
||||||
export type BehaviourResourcePin = {
|
|
||||||
resource: Resource;
|
|
||||||
}
|
|
||||||
|
|
||||||
export type BehaviourSocket = {
|
|
||||||
type: 'input' | 'output';
|
|
||||||
index: number; // within its type
|
|
||||||
resource: Resource;
|
|
||||||
}
|
|
||||||
|
|
||||||
export type BehaviourLink = {
|
|
||||||
target: string,
|
|
||||||
resource: Resource;
|
|
||||||
}
|
|
||||||
|
|
||||||
export type BehaviourDisplayInfo = {
|
|
||||||
storages: Record<string, Resources>;
|
|
||||||
sockets: BehaviourSocket[];
|
|
||||||
|
|
||||||
[x: string]: unknown;
|
|
||||||
}
|
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
export * from './behaviour';
|
|
||||||
export * from './unit';
|
|
||||||
export * from './resource';
|
|
||||||
export * from './serialization';
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
import Decimal from "break_eternity.js";
|
|
||||||
|
|
||||||
export type Resource = 'tree' | 'wood' | 'stone';
|
|
||||||
|
|
||||||
export type Resources = Record<Resource, Decimal>;
|
|
||||||
@@ -1,46 +0,0 @@
|
|||||||
import { Unit } from './unit';
|
|
||||||
import { DecimalSource } from "break_eternity.js";
|
|
||||||
import { Resource } from "./resource";
|
|
||||||
|
|
||||||
export type SerializedInputLink = {
|
|
||||||
target: SerializedBehaviour['id'],
|
|
||||||
resource: Resource,
|
|
||||||
}
|
|
||||||
|
|
||||||
export type SerializedInputLinks = {
|
|
||||||
pins: SerializedInputLink[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export type SerializedConverter = {
|
|
||||||
kind: 'converter';
|
|
||||||
cycleTime?: DecimalSource;
|
|
||||||
storage?: Partial<Record<Resource, DecimalSource>>;
|
|
||||||
rules: {
|
|
||||||
cycleLength: DecimalSource;
|
|
||||||
inputRates?: Partial<Record<Resource, DecimalSource>>;
|
|
||||||
outputRates?: Partial<Record<Resource, DecimalSource>>;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export type SerializedStorage = {
|
|
||||||
kind: 'storage',
|
|
||||||
capacity?: DecimalSource;
|
|
||||||
acceptsResources: Resource[];
|
|
||||||
items: Partial<Record<Resource, DecimalSource>>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export type SerializedBehaviour = {
|
|
||||||
id: string;
|
|
||||||
inputLinks: SerializedInputLinks;
|
|
||||||
} & (SerializedConverter | SerializedStorage);
|
|
||||||
|
|
||||||
export type SerializedNode = {
|
|
||||||
id: Unit['id'],
|
|
||||||
name: Unit['name'],
|
|
||||||
behaviour: SerializedBehaviour,
|
|
||||||
}
|
|
||||||
|
|
||||||
export type SerializedGame = {
|
|
||||||
time: DecimalSource;
|
|
||||||
nodes: SerializedNode[];
|
|
||||||
}
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
import { Behaviour } from "../engine/behaviours/base";
|
|
||||||
|
|
||||||
export type Unit = {
|
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
behaviour: Behaviour;
|
|
||||||
}
|
|
||||||
+1
-2
@@ -1,6 +1,5 @@
|
|||||||
import ELK, { ElkNode } from "elkjs/lib/elk.bundled.js";
|
import ELK, { ElkNode } from "elkjs/lib/elk.bundled.js";
|
||||||
import { Unit } from "../types";
|
import { Unit, Behaviour } from "@incgame/engine";
|
||||||
import { Behaviour } from "../engine/behaviours/base";
|
|
||||||
|
|
||||||
const elk = new ELK();
|
const elk = new ELK();
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -1,4 +1,4 @@
|
|||||||
import { Resource } from "../types";
|
import { Resource } from "@incgame/engine";
|
||||||
|
|
||||||
export type ResourceDescription = {
|
export type ResourceDescription = {
|
||||||
id: Resource;
|
id: Resource;
|
||||||
|
|||||||
@@ -1,25 +0,0 @@
|
|||||||
import { Behaviour } from "../engine/behaviours/base";
|
|
||||||
|
|
||||||
/** Orders behaviours so that whatever a behaviour's inputs link to comes before it (a dependency cycle just falls back to encounter order). */
|
|
||||||
export function orderByDependency(behaviours: Behaviour[]): Behaviour[] {
|
|
||||||
const byId = new Map(behaviours.map((behaviour) => [behaviour.id, behaviour]));
|
|
||||||
const visited = new Set<string>();
|
|
||||||
const order: Behaviour[] = [];
|
|
||||||
|
|
||||||
function visit(behaviour: Behaviour) {
|
|
||||||
if (visited.has(behaviour.id))
|
|
||||||
return;
|
|
||||||
visited.add(behaviour.id);
|
|
||||||
for (const inputLink of behaviour.inputLinks) {
|
|
||||||
const dependency = byId.get(inputLink.target);
|
|
||||||
if (dependency)
|
|
||||||
visit(dependency);
|
|
||||||
}
|
|
||||||
order.push(behaviour);
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const behaviour of behaviours)
|
|
||||||
visit(behaviour);
|
|
||||||
|
|
||||||
return order;
|
|
||||||
}
|
|
||||||
@@ -1,6 +1,29 @@
|
|||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
import path from 'node:path';
|
||||||
import { defineConfig } from 'vite';
|
import { defineConfig } from 'vite';
|
||||||
import react from '@vitejs/plugin-react';
|
import react from '@vitejs/plugin-react';
|
||||||
|
|
||||||
|
const rootDir = path.dirname(fileURLToPath(import.meta.url));
|
||||||
|
const engineDir = path.resolve(rootDir, '../engine');
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
plugins: [react()],
|
plugins: [react()],
|
||||||
|
resolve: {
|
||||||
|
// Follow the node_modules symlink to the real sibling directory, so Vite's dev server
|
||||||
|
// watches and transforms the actual engine source files (not a frozen pre-bundled copy).
|
||||||
|
preserveSymlinks: false,
|
||||||
|
},
|
||||||
|
optimizeDeps: {
|
||||||
|
// Treat @incgame/engine as first-party source, not a vendor dependency to pre-bundle once
|
||||||
|
// and cache — otherwise edits to the engine package wouldn't trigger HMR until a full
|
||||||
|
// dev-server restart.
|
||||||
|
exclude: ['@incgame/engine'],
|
||||||
|
},
|
||||||
|
server: {
|
||||||
|
fs: {
|
||||||
|
// The engine package lives outside this project's root (a sibling directory reached
|
||||||
|
// through a node_modules symlink); allow Vite to serve/watch files from there too.
|
||||||
|
allow: [rootDir, engineDir],
|
||||||
|
},
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user