432 lines
35 KiB
Markdown
432 lines
35 KiB
Markdown
# 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 and Qt Gui (QPoint, QVector2D, QRect, etc., as required by the coding guidelines), toml++, and tinyexpr. It contains no QtWidgets, no painting, and no QApplication. Note: in Qt 5, vector math types such as QVector2D live in Qt::Gui rather than Qt::Core, so the lib links both.
|
||
- 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, deconstruct, 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 at 30 Hz; `tickDurationMs = 1000 / 30 ≈ 33.33`.
|
||
- Ticks are driven by an accumulator that is independent of the render rate. Each render frame, the driver adds `elapsedWallMs × gameSpeedMultiplier` to an accumulator and flushes one `tick()` per `tickDurationMs` of accumulated time (so multiple sim ticks may run between frames at high speeds, or a frame may run no ticks at low speeds). `gameSpeedMultiplier` ∈ {0, 0.5, 1, 2, 4} per REQ-UI-SPEED; 0× freezes the accumulator (pause). The concrete driver lives in the Rendering section.
|
||
- Config-level durations given in seconds (recipe durations, wave gap ranges, debris despawn, etc.) are converted to ticks at config-load time.
|
||
|
||
Consequences: determinism, replayability, and the time-scale feature fall out for free. The simulation advances the same number of ticks over the same amount of game-time regardless of whether the game renders at 60 FPS, 30 FPS, or a stuttery mix.
|
||
|
||
## 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.
|
||
- The UI layer loads its own TOML file, `visuals.toml`, using the same toml++-based pattern and the same immutability / fail-fast rule (see Rendering → Visual Parameters). The simulation never reads it.
|
||
|
||
## Coordinate System
|
||
|
||
See REQ-GW-COORDS for the authoritative tile-coordinate convention. This section captures the programming-level conventions that follow from it.
|
||
|
||
- Tile coordinates are `QPoint(x, y)`. Origin `(0, 0)` is the first space tile (just right of the asteroid's right edge at game start). X grows right; Y grows down.
|
||
- Asteroid tiles have `x < 0`. Asteroid left-expansions add tiles at increasingly negative X; the origin never shifts, so existing tile coordinates remain stable across expansions.
|
||
- Continuous world positions (ship centers, debris, projectiles) use `QVector2D` in tile units — one tile = 1.0 world unit. A ship center at `QVector2D(-3.5, 4.0)` sits at the center of the tile 3.5 tiles left of the asteroid's right edge and 4 tiles down from the top.
|
||
- Rendering multiplies world units by the tile size in pixels (20) at draw time.
|
||
- Ship position always refers to the ship's center — this is the point used for sensor, attack-range, and hit-detection checks.
|
||
|
||
## Core Types
|
||
|
||
Simulation types shared across subsystems:
|
||
|
||
- `EntityId` — strictly increasing integer handle, allocated centrally by the simulation. Assigned to every targetable entity: ships, debris, **and** buildings (including HQ and defence stations). Buildings additionally retain their anchor tile for spatial lookups and placement; the `EntityId` is the canonical reference used by ship-component target fields (`Weapon.currentTarget`, `RepairTool.currentTarget`, `AttackBehavior.currentTarget`, etc.), so a combat ship can target either another ship or a defence station uniformly.
|
||
- `Rotation` — enum `{ North, East, South, West }`. The rotation applied to a building's surface_mask when placed.
|
||
- `BuildingType` — enum covering every building type in requirements.md (Miner, Smelter, Assembler, ReprocessingPlant, Shipyard, SalvageBay, Belt, Splitter, Hq, PlayerDefenceStation, EnemyDefenceStation). `Belt` and `Splitter` share the enum for cost, construction, placement, and `visuals.toml` lookup, but their runtime data lives inside the belt subsystem rather than in `Building` instances (see Belt Subsystem).
|
||
- `ItemType` — tagged id of every transportable material (ores, ingots, intermediates, building_blocks, scrap).
|
||
- `Item` — `struct Item { ItemType type; }`. Items on belts have no persistent identity across ticks.
|
||
- `Port` — `struct Port { QPoint tile; Rotation direction; }`. Identifies a belt-adjacent cell and the direction of flow across that cell.
|
||
- `MovementIntent` — `struct MovementIntent { bool active; QVector2D target; }`. Written by the winning behavior's executor (see Movement Arbitration). Cleared (`active = false`) at the start of each tick; `tickMovement` brakes when inactive, otherwise drives toward `target`.
|
||
- `BeamFiredEvent` — `struct BeamFiredEvent : public Event { BeamKind kind; entt::entity shooter; entt::entity target; Tick emittedAt; }`. Transient record emitted each time a weapon fires, a repair tool starts a heal cycle, or a salvage module starts a collection cycle (REQ-SHP-FIRING, REQ-SHP-FIRING-BEAM). `BeamKind` (`Weapon`/`Repair`/`Salvage`) selects the beam color. Buffered in a sim-owned vector during the tick, then drained and re-emitted via EventManager by the UI frame handler; see Sim → UI Events.
|
||
- `SchematicChoiceOption` — `struct SchematicChoiceOption { string schematicId; SchematicType type; string displayName; bool isNewUnlock; int targetLevel; }`. Describes one option in the schematic choice dialog (REQ-DEF-SCHEMATIC-DROP). Up to three are generated when an enemy station set is destroyed. `SchematicType` is `Ship`, `Module`, or `Recipe`.
|
||
- `SchematicChoicesAvailableEvent` — EventManager event carrying a `vector<SchematicChoiceOption>`. Sent by the UI each frame when pending choices are detected; handled by `MainWindow` which opens the schematic choice dialog.
|
||
|
||
## Event System
|
||
|
||
All inter-component communication — both sim→UI and UI→UI — uses a unified `EventManager`/`EventHandler` system. No custom Qt signals/slots are used for inter-widget communication.
|
||
|
||
### EventManager
|
||
|
||
`EventManager` is a singleton (`EventManager::getInstance()`) that routes events to registered handlers.
|
||
|
||
- `sendEventImmediately(shared_ptr<Event>)` — synchronous dispatch to all handlers of the event's type.
|
||
- `addEvent(shared_ptr<Event>)` — queues the event for later batch processing.
|
||
- `processEvents()` — drains the queue, dispatching each event to its handlers.
|
||
|
||
The EventManager is thread-safe (mutex-guarded).
|
||
|
||
### EventHandler
|
||
|
||
`EventHandler<T>` is a CRTP-style template that a class inherits to receive events of type `T`. It provides `registerForEvent()` / `unregisterForEvent()` and requires an override of `handleEvent(shared_ptr<const T>)`.
|
||
|
||
`CombinedEventHandler<Ts...>` is a variadic template for classes that handle multiple event types. It provides `registerForEvents()` / `unregisterForEvents()` and requires one `handleEvent` override per type.
|
||
|
||
### Sim → UI Events
|
||
|
||
The simulation layer stays free of EventManager — it uses a plain `std::vector<BeamFiredEvent>` internally (owned by `Simulation`, filled by the combat, repair, and salvage systems). This preserves determinism, tick-order fidelity, and headless testability (Catch2 tests read the queue directly via `drainBeamFiredEvents()` after `tick()`).
|
||
|
||
The UI frame handler (`GameWorldView::onFrame` / `ArenaView::onFrame`) bridges the gap: each frame it calls `simulation.drainBeamFiredEvents()`, then re-emits each `BeamFiredEvent` via `EventManager::sendEventImmediately()`. Subscribers (the same view's `handleEvent(BeamFiredEvent)`) create `ActiveBeam` records tracked for 0.3 s of wall time, then discarded. If either the shooter or target entity is gone when the renderer looks them up, the beam is dropped early.
|
||
|
||
Schematic drops: when an enemy station set is destroyed, the simulation generates up to 3 `SchematicChoiceOption` entries and stores them as pending state. The UI polls `hasSchematicChoicesPending()` each frame and, when true, sends a `SchematicChoicesAvailableEvent` via EventManager. `MainWindow` handles this event by pausing the game and opening a modal `SchematicChoiceDialog`. The player's selection is fed back via `applySchematicChoice(index)`.
|
||
|
||
### UI Events
|
||
|
||
All UI interactions — building selection, builder/blueprint mode transitions, speed changes, deconstruct mode, escape menu, layout dialog requests — are communicated via EventManager events rather than Qt signals/slots. Each event is a small struct inheriting `Event` (e.g., `SelectionChangedEvent`, `BuildingTypeSelectedEvent`, `SpeedChangeRequestedEvent`). Widgets register as `CombinedEventHandler` for the events they care about and emit events via `EventManager::sendEventImmediately()`.
|
||
|
||
Bidirectional interactions use separate request/notification event types to avoid infinite recursion (e.g., `ExitBuilderModeRequestedEvent` from `BuildButtonBar` → `GameWorldView`, vs. `BuilderModeExitedEvent` from `GameWorldView` → `BuildButtonBar`).
|
||
|
||
### Reading Simulation State
|
||
|
||
The simulation is the single source of truth for every game value (building block stock, expansion cost, threat level, tick, etc.). A UI widget that needs such a value holds the `Simulation*` it was constructed with and **pulls the value on demand** via the corresponding getter (e.g., `m_sim->getBuildingBlocksStock()`), rather than caching its own copy.
|
||
|
||
State-change events (e.g., `BuildingBlocksChangedEvent`) are treated as *refresh signals*, not as carriers of truth: a widget subscribes to the event to learn *when* the value changed and then re-reads it from the simulation to learn *what* it now is. The value carried in the event payload is not authoritative and should not be stored. This keeps a single copy of each value and avoids stale-cache bugs (a widget acting on a value that has since moved on because nothing refreshed its local copy).
|
||
|
||
## Tick Order
|
||
|
||
Within a single simulation tick, subsystems run in this fixed order. The order is load-bearing for determinism and for avoiding one-tick-delay artifacts (e.g., items landing on a belt but not advancing in the same tick).
|
||
|
||
1. **Wave scheduler** — advance wave timer; on trigger, compute wave composition per REQ-WAV-TRIGGER and schedule spawn times across REQ-WAV-SPAWN-DURATION; spawn any enemy ships whose scheduled time has arrived this tick.
|
||
2. **Threat accumulation** — add `max(0, threat_rate_formula(t))` × tick_dt to threat level (REQ-WAV-THREAT-RATE).
|
||
3. **Belt → building pull** — buildings drain eligible items from adjacent belt tiles into per-material input buffers (REQ-MAT-INPUT-PORTS).
|
||
4. **Building production** — advance production timers; start new cycles when inputs and output-buffer space permit (REQ-MAT-CYCLE); on completion, deposit output.
|
||
5. **Building → belt push** — buildings push items from output buffer onto the belt tile at their output port (REQ-MAT-OUTPUT-PORT).
|
||
6. **Belt tick** — advance items along belt tiles; apply splitter routing (REQ-BLD-SPLITTER).
|
||
7. **Ship behavior systems** — clear `MovementIntent` on each ship, then the `AiSystem` runs three batched phases: every behavior **evaluator** scores its behavior and sets its target data; a **selection** pass records the highest-scoring behavior per ship in `SelectedBehaviorComponent`; each behavior **executor** runs for the winner, writing `MovementIntent` and preferred module targets. The module systems then perform world mutation: `SalvagerSystem` (scrap collection/delivery) and `RepairSystem` (healing). See Movement Arbitration.
|
||
8. **Combat resolution** — ships and defence stations validate/acquire targets, fire, apply damage; queue deaths. Each fire appends a `BeamFiredEvent` to the sim's beam-fired-event queue (REQ-SHP-FIRING-BEAM). The repair and salvage module systems (tick step 7d) append their own `BeamFiredEvent`s to the same queue when they start a cycle.
|
||
9. **Deaths & loot** — process queued deaths: drop debris (REQ-RES-DEBRIS-DROP); if a full enemy-defence-station set was destroyed this tick, generate up to 3 schematic choice options (REQ-DEF-SCHEMATIC-DROP) stored as pending state for the UI to present; remove entities.
|
||
10. **`tickMovement`** — advance ship positions based on final `MovementIntent`.
|
||
11. **Debris despawn** — decrement debris timers; remove expired debris (REQ-RES-DEBRIS-DROP).
|
||
|
||
## CMake Target Layout
|
||
|
||
Three product targets plus tests:
|
||
|
||
- `lib/` — simulation + config. Depends on Qt Core + Qt Gui, toml++, tinyexpr. No QtWidgets.
|
||
- `ui/` — QtWidgets + `QOpenGLWidget` code: header bar, game world view, selection panel, build button bar. Depends on `lib` and on Qt's OpenGL widgets module.
|
||
- `ui/selection/` — the selection panel's contents. `SelectionPanel` itself only arbitrates between the two selection categories, picks a card from the catalog (`SelectionContentFactory`), and hosts one at a time; each kind of selection has its own `SelectionContent` subclass assembled from shared parts (REQ-UI-SELECTION-CARD, REQ-UI-SELECTION-CONTENT).
|
||
- `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
|
||
|
||
`BeltSystem.h` is authoritative. The surface is wider than the original design sketch — 15 public methods in five groups, not the 5-method port interface this section used to describe:
|
||
|
||
```cpp
|
||
class BeltSystem {
|
||
public:
|
||
// Placement — belts/splitters/tunnels are Buildings for cost and
|
||
// construction, so BuildingSystem registers and unregisters their tiles.
|
||
void placeBelt(QPoint tile, Rotation direction);
|
||
void placeTunnelEntry(QPoint tile, Rotation direction, int maxDistance);
|
||
void placeTunnelExit(QPoint tile, Rotation direction);
|
||
void placeSplitter(QPoint tile, Rotation outputA, Rotation outputB);
|
||
void removeTile(QPoint tile);
|
||
|
||
// Splitter filter configuration (REQ-BLD-SPLITTER). A splitter's filters
|
||
// live here, not on Building, so callers that re-register a tile must
|
||
// carry them across (see BuildingSystem::reregisterBeltTile).
|
||
void setSplitterFilters(QPoint tile, const std::vector<ItemType>& filterA,
|
||
const std::vector<ItemType>& filterB);
|
||
std::optional<SplitterInfo> getSplitterInfo(QPoint tile) const;
|
||
|
||
// Port interface (buildings <-> belts)
|
||
bool tryPutItem(QPoint tile, Item item, Rotation fromDir = Rotation::West);
|
||
std::optional<Item> tryTakeItem(Port port);
|
||
std::optional<ItemType> peekItem(Port port) const;
|
||
double getProgressPerTick_tpt() const; // shared so building output items
|
||
// travel at belt speed (REQ-MAT-OUTPUT-EMERGE)
|
||
|
||
// Maintenance
|
||
void clearTiles(const std::vector<QPoint>& tiles); // REQ-UI-BELT-CLEAR
|
||
void tick();
|
||
|
||
// Rendering
|
||
void forEachVisualItem(QRect viewportTiles,
|
||
std::function<void(VisualItem)> visit) const;
|
||
|
||
// Determinism (docs/replay_design.md)
|
||
void appendChecksum(Hasher& hasher) const;
|
||
};
|
||
|
||
struct VisualItem {
|
||
ItemType type;
|
||
QPointF worldPos; // in tile units, fractional
|
||
};
|
||
```
|
||
|
||
Item *transport* is still reached only through push and pull: `tryPutItem` / `tryTakeItem` move items, `peekItem` reveals the leading item's type but never an identity, and rendering reads only through `forEachVisualItem`. The growth is in tile **topology** — placement, removal and splitter filters — which `BuildingSystem` drives because belts are `Building`s for cost, construction and deconstruction. That coupling is real and is not going away.
|
||
|
||
### 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. The migration argument still holds for the item representation, since no method exposes tile-level item identity — but a v2 would have to keep the placement and splitter-filter methods working per tile, which is a stronger constraint than this section originally implied.
|
||
|
||
### 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 {
|
||
EntityId id;
|
||
QPoint tile;
|
||
QSize footprint;
|
||
Rotation rotation;
|
||
BuildingType type;
|
||
float hp; // relevant for HQ and defence stations; ignored otherwise.
|
||
float maxHp;
|
||
InputBuffer inputBuffer;
|
||
OutputBuffer outputBuffer;
|
||
// Production timer, the recipe currently running, and — for reprocessing
|
||
// plants — the output item picked at cycle start (REQ-MAT-CYCLE).
|
||
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.
|
||
|
||
## Debris
|
||
|
||
Debris — the salvageable object dropped by destroyed ships and defence stations — is the
|
||
only non-ship, non-building entity in the simulation. Each piece carries a scrap amount:
|
||
|
||
```cpp
|
||
struct Debris {
|
||
EntityId id;
|
||
QVector2D position; // world units, tile-fractional; ship-center convention
|
||
int amount; // scrap the piece still holds
|
||
Tick despawnAt; // absolute tick at which the debris is removed
|
||
};
|
||
```
|
||
|
||
Created in tick step 9 (Deaths & loot) per REQ-RES-DEBRIS-DROP, drained one scrap per cycle by salvage ships in tick step 7 (SalvagerSystem), and removed in tick step 11 when the current tick reaches `despawnAt`.
|
||
|
||
## 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 repairAmountHp; int repairIntervalTicks; int cooldownTicksRemaining;
|
||
float range; 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. Each behavior is a small component carrying its own target data plus a `float score` written by its evaluator each tick.
|
||
|
||
```cpp
|
||
struct AdvanceBehavior { float score; }; // baseline fallback, all ships
|
||
struct RallyBehavior { QVector2D rallyPoint; float score; }; // player combat ships
|
||
struct RetreatBehavior { float retreatHpFraction; QVector2D retreatPoint; // player ships
|
||
float score; };
|
||
struct AttackBehavior { std::optional<EntityId> currentTarget; float score; };
|
||
struct RepairBehavior { std::optional<EntityId> currentTarget;
|
||
float maxRepairRange_tiles; float score; };
|
||
struct SalvageScrapBehavior { std::optional<QVector2D> debrisTarget;
|
||
float maxCollectionRange_tiles; float score; };
|
||
struct DeliverScrapBehavior { BuildingId deliveryBay; float score; };
|
||
struct SelectedBehaviorComponent { BehaviorKind winner; float bestScore; }; // selection result
|
||
```
|
||
|
||
### Ship
|
||
|
||
```cpp
|
||
struct Ship {
|
||
EntityId id;
|
||
QVector2D position;
|
||
QVector2D velocity;
|
||
float hp;
|
||
float maxHp;
|
||
int level;
|
||
ShipSchematicId schematic;
|
||
|
||
// Capabilities
|
||
std::optional<Weapon> weapon;
|
||
std::optional<SalvageCargo> cargo;
|
||
std::optional<RepairTool> repairTool;
|
||
|
||
// Behaviors (attached per capability; AdvanceBehavior + SelectedBehaviorComponent
|
||
// on every ship, RetreatBehavior on player ships, etc.)
|
||
std::optional<AttackBehavior> attackBehavior;
|
||
std::optional<SalvageScrapBehavior> salvageScrapBehavior;
|
||
std::optional<DeliverScrapBehavior> deliverScrapBehavior;
|
||
std::optional<RepairBehavior> repairBehavior;
|
||
|
||
// Written by the winning behavior's executor, read by movement.
|
||
MovementIntent intent;
|
||
};
|
||
```
|
||
|
||
### Systems
|
||
|
||
Each behavior is split into a stateless **evaluator** and **executor** class (one per behavior, e.g. `AttackEvaluator`/`AttackExecutor`), orchestrated by `AiSystem`. Evaluators and executors only read/write behavior components and module target fields — they never mutate the game world. World mutation lives in dedicated module systems that run every tick, independent of which behavior won:
|
||
|
||
- `CombatSystem` — validates each weapon's executor-set target, falls back to nearest-target acquisition, fires, applies damage.
|
||
- `SalvagerSystem` — collects scrap into cargo and delivers full cargo at a `SalvageBay`.
|
||
- `RepairSystem` — validates each repair tool's target, falls back to nearest damaged friendly, applies healing.
|
||
- `MovementIntentSystem` (`tickMovement`) — reads `MovementIntent`, advances `position`; brakes when inactive.
|
||
|
||
### Movement Arbitration
|
||
|
||
Arbitration is **score-based**, not fixed-priority. In a single tick `AiSystem` runs three phases:
|
||
|
||
1. **Evaluate** — every behavior's evaluator iterates the ships that have its component, sets its target data, and writes a `float score` (see `BehaviorScores.h`). An evaluator returns an inactive score when its behavior does not apply.
|
||
2. **Select** — `selectWinningBehaviors` resets each `SelectedBehaviorComponent`, then compares every behavior's score per ship, recording the highest as `winner`. Behaviors are considered highest-band first so a strict `>` breaks ties toward the more urgent behavior.
|
||
3. **Execute** — each behavior's executor runs only for ships where it is the `winner`, writing the single `MovementIntent` and any preferred module targets.
|
||
|
||
`AdvanceBehavior` is present on every ship with the lowest score, guaranteeing a winner. The resulting band order:
|
||
|
||
```
|
||
Retreat > Attack / Repair / SalvageScrap / DeliverScrap > Rally > Advance
|
||
```
|
||
|
||
`MovementIntent` is cleared (inactive) at the start of each tick; `tickMovement` runs last.
|
||
|
||
### 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.
|
||
|
||
## Rendering
|
||
|
||
The game world is drawn into a single `GameWorldView` widget that inherits `QOpenGLWidget` and uses `QPainter` for all drawing. This gives the same imperative paint API as a plain `QWidget` with GPU acceleration, comfortably handling the expected scale (hundreds of ships, thousands of belt items) without blocking the main thread on CPU rasterization.
|
||
|
||
The drawing itself lives in `WorldRenderer`, not in the widget. `paintGL` is a call sequence: build the frame's `WorldCoordinates`, hand the renderer a `WorldRenderFrame`, then draw the screen-anchored chrome. The split is the world-space / screen-space line, and it is exact: the renderer draws everything positioned in tiles, while everything positioned in pixels — the pause and deconstruct vignettes, the replay overlay, the debug stats panel — stays with the widget. A useful consequence is that the renderer draws no translatable text at all (its text is config-driven glyphs, ASCII port arrows, and numbers), so it needs no `tr()` and no tie to the meta-object system.
|
||
|
||
`WorldRenderFrame` is what makes the renderer independent of the widget. The renderer reads the simulation directly, but everything else it draws is interaction state the widget owns — the selection, the active build mode, live beams, the box-select rectangle. Those are gathered into the frame each `paintGL` and passed by reference, so the renderer keeps no copy that a later click could invalidate. The renderer knows nothing about input: the widget resolves clicks and hit-tests, and the renderer only draws the result.
|
||
|
||
### Render Loop
|
||
|
||
- A `QTimer` in `GameWorldView` fires at 60 Hz and calls `update()`, requesting a repaint. Render rate is fixed at 60 FPS regardless of game speed.
|
||
- The sim advances independently via an accumulator-based driver:
|
||
- Each frame, compute `accumulator += elapsedWallMs * gameSpeedMultiplier`.
|
||
- While `accumulator >= tickDurationMs` (= 1000/30 ≈ 33.33 ms), advance the sim by one tick and subtract.
|
||
- 0× clamps the multiplier to 0 (pause). 0.5× / 2× / 4× scale accumulation directly.
|
||
- This decouples render rate (60 FPS) from sim rate (30 Hz, see Fixed-Timestep Tick-Based Simulation above) and keeps all game speeds correct across variable frame timing.
|
||
|
||
### Threading
|
||
|
||
Sim and UI run on the same thread for v1. `paintEvent` reads sim state directly without locks. If profiling later justifies moving the sim to a worker thread, the pull-style `drainBeamFiredEvents()` / `getPendingSchematicChoices()` / `applySchematicChoice()` / `forEachVisualItem()` APIs already support a clean snapshot-and-render split; a single mutex at the sim boundary would suffice. The `ArenaSimulation` used by the balancing tool runs headlessly on a worker thread; fire events accumulate in its internal vector and are only drained when `ArenaView` drives `tickOnce()` on the main thread during interactive inspection.
|
||
|
||
### Layer Order (back to front)
|
||
|
||
1. **Tile background** — asteroid tiles and space tiles within the viewport.
|
||
2. **Buildings** — factory buildings, HQ, player and enemy defence stations.
|
||
3. **Belt items** — 10×10 colored squares emitted by `BeltSystem::forEachVisualItem`.
|
||
4. **Scrap** — glyphs at world positions.
|
||
5. **Ships** — colored arrows oriented by velocity; color keyed to role (player combat / salvage / repair / enemy).
|
||
6. **Laser beams** — lines derived from live `BeamFiredEvent`s kept by the renderer for 0.3 s, colored per `BeamKind` (weapon/repair/salvage) (REQ-SHP-FIRING-BEAM).
|
||
7. **Build overlays** — ghost in builder mode (REQ-BLD-GHOST), deconstruct-mode tint, tile highlight under cursor, box-drag selection rectangle.
|
||
8. **Screen-space UI** — screen-anchored elements, drawn after resetting the world-space transform.
|
||
|
||
### Coordinates and Scrolling
|
||
|
||
- The horizontal view position lives in `WorldCamera` (`lib/core/`) as a continuous view-center X in tiles. A / D input pans it smoothly (REQ-UI-SCROLL) at a position-dependent speed (REQ-UI-SCROLL-SPEED). The camera works purely in world units — tiles and tiles/second, never pixels — which is what keeps it independent of `WorldCoordinates`; the two meet only where `GameWorldView` feeds `getViewCenterXTiles()` into the transform.
|
||
- The camera takes no simulation dependency. Its pan limits move with asteroid expansion and with pushes, so `GameWorldView` reads them from the sim each frame and passes them in as `ScrollBounds`; the camera clamps on every `advance()`, not only when panning, so the view follows the bounds inward when they shrink. Pan *intent* is likewise passed in as a `PanDirection` rather than read from key state, so the camera is unaffected if controls later become rebindable. Both properties are what make it a plain value with unit tests (`WorldCameraTest`) — notably over the two-ramp pan-speed curve, whose overlapping-band and zero-width-band cases are otherwise easy to break unnoticed.
|
||
- The world↔widget transform itself lives in `WorldCoordinates` (`lib/core/`), not in the view. It is an immutable value, built through one of two named factories that differ only in how `tilePx` and the left edge are derived; everything downstream is shared. `scrolling(...)` is the game world: `tilePx` makes the world height fill the viewport (REQ-GW-TILE-SIZE) and the view pans horizontally. `fitToWorld(...)` is the balancing tool's arena: a fixed world shown whole, so `tilePx` is the tighter of the two axis fits and there is no scroll. Being a plain value with no Qt Widgets dependency, it is unit-tested (`WorldCoordinatesTest`) even though the widgets around it are not.
|
||
- `GameWorldView::getCoordinates()` and `ArenaView::getCoordinates()` each build one per frame in `paintGL` and per event in the mouse handlers, and pass it down: every world-space `draw<X>` takes a `const WorldCoordinates&`, while the screen-space draws (vignette borders, replay overlay, debug text) take none. The snapshot is deliberately never cached in a member — a resize or a scroll would silently invalidate it.
|
||
- Conversions are per-call arithmetic rather than a `painter.translate`, because hit-testing needs the inverse (`widgetToWorld` / `widgetToTile`, flooring for a tile) as often as drawing needs the forward direction. Asteroid tiles (`x < 0`) need no special casing — they share the coordinate system with space tiles, which is why the flooring must not be truncation.
|
||
|
||
### Culling
|
||
|
||
The renderer iterates only entities and tiles whose world X lies within the visible viewport. In particular, everything at or past the rightmost enemy defence station (i.e., the current enemy buffer zone) is culled — consistent with REQ-GW-SCROLL-LIMIT.
|
||
|
||
### Visual Parameters
|
||
|
||
Shapes are hardcoded in the renderer — a building is a rectangle per footprint tile, a ship is an oriented arrow/triangle, a belt item is a 10×10 square, scrap is a small circle, a beam is a line. These structural choices live in the `draw<X>(painter, entity)` functions of the UI and are not expected to change frequently.
|
||
|
||
The few shapes the game view and the balancing tool's arena view draw *identically* — the ship body, the health bar, the debris marker, the sensor-range circle — live in `ui/WorldPrimitives` as free functions over explicit values. The arena exists to eyeball combat, so it only works while a ship there looks like a ship in the game; keeping these in one place means a retuned ship shape cannot silently stop applying to the tool that measures it. The balancing target does not link the `ui` library, so it compiles that file into itself, the same way it already does for `VisualsLoader` and `ShipStatsPanel` (see `balancing/CMakeLists.txt`). Everything the two views draw differently — selection highlights, beams, target lines, and all of the factory — stays with each view; the shared set is deliberately not grown beyond shapes that are genuinely the same.
|
||
|
||
Colors, outline widths, glyph text, and tile tints live in a separate config file, `visuals.toml`, loaded once by the UI at startup using the same pattern and lifetime as the sim config files (see Config Loading). The file is UI-scoped: the sim does not read it and does not depend on it.
|
||
|
||
Sketch of `visuals.toml`:
|
||
|
||
```toml
|
||
[tiles]
|
||
asteroid = { fill = "#4a4038" }
|
||
space = { fill = "#0a0a15" }
|
||
|
||
[buildings.miner]
|
||
fill = "#6b4a2c"
|
||
outline = "#ffffff"
|
||
glyph = "M"
|
||
|
||
# ... one [buildings.<type>] section per BuildingType
|
||
# ... one [stations.<player|enemy>] section
|
||
# ... one [items.<item_type>] section per ItemType
|
||
|
||
[ships.player_combat] { fill = "#3366ff", outline = "#ffffff" }
|
||
[ships.salvage] { fill = "#33cc66", outline = "#ffffff" }
|
||
[ships.repair] { fill = "#66ccff", outline = "#ffffff" }
|
||
[ships.enemy] { fill = "#cc3333", outline = "#ffffff" }
|
||
|
||
[beams]
|
||
color = "#ff6600"
|
||
width_px = 2
|
||
|
||
[overlays]
|
||
ghost_valid = "#ffffff44"
|
||
ghost_invalid = "#ff000044"
|
||
deconstruct_tint = "#ff000033"
|
||
selection_rect = "#00ff00"
|
||
|
||
[toast]
|
||
bg = "#000000cc"
|
||
fg = "#ffffff"
|
||
font_size = 14
|
||
```
|
||
|
||
Key names mirror sim identifiers (`buildings.miner` ↔ `BuildingType::Miner`, `items.<x>` ↔ `ItemType`). The UI builds a lookup indexed by the sim's enum or string id; a missing or malformed entry aborts startup with a clear error, same as sim configs. Adding a new `BuildingType` or `ItemType` to the sim requires adding a matching `visuals.toml` entry — the fail-on-missing rule catches the omission at startup rather than silently rendering invisible entities.
|
||
|
||
### Animation
|
||
|
||
v1 uses no sprite atlases, no anti-aliasing, and no tick-to-tick interpolation (ships snap to their 30 Hz positions). This is not an architectural constraint — sprite atlases, AA, and interpolation are all incremental upgrades behind the same `draw<X>` functions and the `visuals.toml` schema.
|
||
|
||
## 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).
|