add architecture description file
This commit is contained in:
203
docs/architecture.md
Normal file
203
docs/architecture.md
Normal file
@@ -0,0 +1,203 @@
|
||||
# Architecture
|
||||
|
||||
This document captures the architectural decisions for the project. It is a complement to `requirements.md`; it explains *how* the game is built, not *what* it does.
|
||||
|
||||
## Goals
|
||||
|
||||
- Keep the simulation testable with Catch2 in a headless environment.
|
||||
- Keep game speed (0×–4×) and pause trivial to implement (REQ-UI-SPEED).
|
||||
- Keep config-driven balancing (formulas, recipes, station and ship stats) fast to iterate on.
|
||||
- Make the belt subsystem replaceable without touching the rest of the game.
|
||||
- Let ship behavior grow (hybrid ships, new capabilities) without rewriting existing code.
|
||||
|
||||
## Simulation / Presentation Split
|
||||
|
||||
A strict separation between the game simulation and the Qt Widgets UI.
|
||||
|
||||
- The **simulation** is a pure C++ library that depends only on Qt Core (QPoint, QVector3D, QRect, etc., as required by the coding guidelines), toml++, and tinyexpr. It contains no QtWidgets, no painting, and no QApplication.
|
||||
- The **UI** reads simulation state and renders it. It owns all widgets, painting, and input handling, and drives the simulation via a small command interface (place building, demolish, clear belt tiles, change recipe, set game speed, etc.).
|
||||
|
||||
This split is enforced at the CMake target level (see below). Tests link only against the simulation library and run without a display server.
|
||||
|
||||
## Fixed-Timestep Tick-Based Simulation
|
||||
|
||||
The simulation advances in discrete ticks. All game quantities — production timers, belt item progress, threat accumulation, wave timers, ship cooldowns — are measured in ticks, not wall-clock seconds.
|
||||
|
||||
- Tick rate: fixed (30 or 60 Hz, TBD; choose once and document here).
|
||||
- The outer loop advances `N` ticks per wall-clock frame, where `N` is derived from the selected game speed:
|
||||
- 0× → 0 ticks/frame (pause)
|
||||
- 0.5× → one tick every two frames
|
||||
- 1× → one tick/frame
|
||||
- 2× → two ticks/frame
|
||||
- 4× → four ticks/frame
|
||||
- Config-level durations given in seconds (recipe durations, wave gap ranges, scrap despawn, etc.) are converted to ticks at config-load time.
|
||||
|
||||
Consequences: determinism, replayability, and the time-scale feature falls out for free.
|
||||
|
||||
## Config Loading
|
||||
|
||||
Config files (`world.toml`, `buildings.toml`, `recipes.toml`, `ships.toml`, `stations.toml`) are loaded once at startup.
|
||||
|
||||
- `toml++` parses the files into strongly-typed config structs.
|
||||
- Formula strings (e.g., threat accumulation, enemy ship level as a function of `t`, per-ship stats as functions of `level`, station stats as functions of generation) are compiled once via `tinyexpr` at load time and stored as callable objects. They are never re-parsed during simulation.
|
||||
- Configs are immutable after load. Any formula that fails to parse, or any required field that is missing or malformed, aborts startup with a clear error message — never mid-game.
|
||||
|
||||
## CMake Target Layout
|
||||
|
||||
Three product targets plus tests:
|
||||
|
||||
- `lib/` — simulation + config. Depends on Qt Core, toml++, tinyexpr. No QtWidgets.
|
||||
- `ui/` — QtWidgets code: header bar, game world view, selected building panel, build button grid. Depends on `lib`.
|
||||
- `app/` — thin `main()` that creates the simulation, the UI, and wires them together. Depends on `ui`.
|
||||
- `tests/` — Catch2 tests. Links only against `lib`.
|
||||
|
||||
Directory discipline inside `lib/` keeps the internal sim/config seam clear; sim code must not reach into config parsing and vice versa.
|
||||
|
||||
## Belt Subsystem
|
||||
|
||||
Belts and splitters are their own specialized subsystem. Belt items are **not** entities — they are transient data flowing through the belt representation. They do not have identities that persist across ticks.
|
||||
|
||||
### Public Interface
|
||||
|
||||
Narrow and representation-agnostic:
|
||||
|
||||
```cpp
|
||||
class BeltSystem {
|
||||
public:
|
||||
bool tryPutItem(Port port, Item item);
|
||||
std::optional<Item> tryTakeItem(Port port);
|
||||
void clearTiles(const std::vector<QPoint>& tiles); // REQ-UI-BELT-CLEAR
|
||||
void tick();
|
||||
void forEachVisualItem(QRect viewportTiles,
|
||||
std::function<void(VisualItem)> visit) const;
|
||||
};
|
||||
|
||||
struct VisualItem {
|
||||
ItemType type;
|
||||
QPointF worldPos; // in tile units, fractional
|
||||
};
|
||||
```
|
||||
|
||||
Buildings interact with belts only through port-level push and pull. Rendering reads only through `forEachVisualItem`. No other system ever asks "what is on tile X".
|
||||
|
||||
### Implementation Strategy
|
||||
|
||||
- v1: per-tile representation. Each belt tile stores up to 2 items with a progress value in `[0, 1]` along the tile's belt direction. Sufficient for the scale this game targets.
|
||||
- v2 (optional, only if v1 profiles poorly): Factorio-style belt-segment compression. Because the public interface never exposes tile-level item identity, migration is internal to the subsystem.
|
||||
|
||||
### Rendering Note
|
||||
|
||||
Rendering does not cache item identities across frames. Each frame calls `forEachVisualItem` and paints whatever it yields. This is correct because items can merge onto the same tile, splitters can reroute, and `clearTiles` wipes items without warning.
|
||||
|
||||
If a smoother animation than the tick rate is ever needed, tick interpolation can be added inside `forEachVisualItem` later. Not needed initially.
|
||||
|
||||
## Buildings
|
||||
|
||||
Buildings are plain structs with a fixed type determined at construction.
|
||||
|
||||
```cpp
|
||||
struct Building {
|
||||
QPoint tile;
|
||||
QSize footprint;
|
||||
Rotation rotation;
|
||||
BuildingType type;
|
||||
InputBuffer inputBuffer;
|
||||
OutputBuffer outputBuffer;
|
||||
// Production timer and current recipe, where applicable.
|
||||
std::optional<Production> production;
|
||||
};
|
||||
```
|
||||
|
||||
- The uniform "input buffer → production timer → output buffer" pattern across miner, smelter, assembler, reprocessing plant, and shipyard is driven by the recipe config, not by a class hierarchy.
|
||||
- Belts and splitters are separate types owned by the belt subsystem, not general `Building` instances.
|
||||
- No ECS for buildings. A miner is never also an assembler; there is no composition benefit to decomposing buildings into components.
|
||||
|
||||
## Ships
|
||||
|
||||
Ships follow a component-composition model using `std::optional<Component>` members. Each orthogonal capability is a component; each behavior is also a component, ticked by its own system. A ship's "role" is just which components it has — not a class or an enum.
|
||||
|
||||
### Capability Components
|
||||
|
||||
```cpp
|
||||
struct Weapon { float damage; float range; float fireRateHz; float cooldownTicks;
|
||||
std::optional<EntityId> currentTarget; };
|
||||
struct SalvageCargo { int capacity; int current; };
|
||||
struct RepairTool { float ratePerTick; std::optional<EntityId> currentTarget; };
|
||||
```
|
||||
|
||||
### Behavior Components
|
||||
|
||||
Behaviors are decomposed, not bundled into per-role monolithic AIs. This is the critical modeling choice: adding a capability (e.g., putting a `Weapon` on a repair ship) must not require rewriting AI code.
|
||||
|
||||
```cpp
|
||||
struct ThreatResponse { float engagementRange; CombatStance stance;
|
||||
CombatTargetPriority priority;
|
||||
std::optional<EntityId> currentTarget; };
|
||||
struct ScrapCollector { std::optional<QVector3D> scrapTarget; EntityId deliveryBay; };
|
||||
struct RepairBehavior { RepairTargetPriority priority;
|
||||
std::optional<EntityId> currentTarget; };
|
||||
struct HomeReturn { float retreatHpFraction; QVector3D homePos; };
|
||||
```
|
||||
|
||||
### Ship
|
||||
|
||||
```cpp
|
||||
struct Ship {
|
||||
EntityId id;
|
||||
QVector3D position;
|
||||
QVector3D velocity;
|
||||
float hp;
|
||||
float maxHp;
|
||||
int level;
|
||||
ShipBlueprintId blueprint;
|
||||
|
||||
// Capabilities
|
||||
std::optional<Weapon> weapon;
|
||||
std::optional<SalvageCargo> cargo;
|
||||
std::optional<RepairTool> repairTool;
|
||||
|
||||
// Behaviors
|
||||
std::optional<ThreatResponse> threatResponse;
|
||||
std::optional<ScrapCollector> scrapCollector;
|
||||
std::optional<RepairBehavior> repairBehavior;
|
||||
std::optional<HomeReturn> homeReturn;
|
||||
|
||||
// Written by behavior systems, read by movement.
|
||||
MovementIntent intent;
|
||||
};
|
||||
```
|
||||
|
||||
### Systems
|
||||
|
||||
Each behavior has its own tick system. A system iterates a flat `std::vector<Ship>` and skips ships that do not have the relevant components.
|
||||
|
||||
- `tickThreatResponse` — requires `threatResponse` + `weapon`. Acquires target, fires, manages cooldown.
|
||||
- `tickScrapCollector` — requires `scrapCollector` + `cargo`. Flies to scrap, picks up, returns to delivery bay.
|
||||
- `tickRepairBehavior` — requires `repairBehavior` + `repairTool`. Finds damaged target, moves to range, repairs.
|
||||
- `tickHomeReturn` — requires `homeReturn`. Overrides movement if hp drops below threshold.
|
||||
- `tickMovement` — reads `intent`, advances `position`.
|
||||
|
||||
### Movement Arbitration
|
||||
|
||||
When multiple behaviors want to drive movement, a fixed global priority resolves the conflict. Each behavior system writes a `MovementIntent` carrying its priority; a higher-priority write overwrites a lower-priority one. `tickMovement` reads the final winner.
|
||||
|
||||
Initial priority order (subject to tuning):
|
||||
|
||||
```
|
||||
HomeReturn > ThreatResponse > RepairBehavior > ScrapCollector
|
||||
```
|
||||
|
||||
`tickMovement` runs last. Intents are cleared at the start of each tick.
|
||||
|
||||
### Why Not ECS
|
||||
|
||||
EnTT would be a reasonable fit for ships specifically, but at the scale of this game (low hundreds of ships) the iteration-speed benefit is not decisive. The `std::optional<Component>` pattern gives the same modeling expressiveness with zero dependencies and no learning curve. Migration to EnTT is mechanical (`std::optional<Weapon> weapon` becomes `registry.emplace<Weapon>(entity, ...)`) if it ever becomes warranted.
|
||||
|
||||
Buildings and the belt subsystem stay outside any entity model regardless of what ships do — they are the wrong shape for ECS.
|
||||
|
||||
## Testing
|
||||
|
||||
- Catch2 tests link against `lib` only. No QApplication, no display.
|
||||
- The tick-based, deterministic simulation is directly testable: construct a world, step N ticks, assert state.
|
||||
- Config loading is tested with small fixture TOML files.
|
||||
- Formula parsing failures must be covered (malformed input produces a clear error, does not silently default).
|
||||
Reference in New Issue
Block a user