Compare commits

...

12 Commits

12 changed files with 312 additions and 465 deletions

View File

@@ -1,410 +0,0 @@
# Implementation Plan — Steps 4 through 8
Cross-references: `architecture.md` (design), `requirements.md` (REQ-* citations).
## Status
| Step | Scope | State |
|------|-------|-------|
| 1 | Config loading (Formula, ConfigLoader, all config structs) | ✅ done |
| 2 | Simulation shell + TickDriver + entity id allocator + event queues | ✅ done |
| 3 | Belt subsystem (placement, port interface, per-tile v1, splitter routing, clearTiles, visual iteration) | ✅ done |
| 4 | Buildings + placement + belt↔building transport | ✅ done |
| 5 | Scrap + ships skeleton (data + spawning, no AI) | ✅ done |
| 6 | Ship behavior systems + movement arbitration | ✅ done |
| 7 | Waves, threat accumulation, combat resolution, deaths & loot | ✅ done |
| 8 | UI layer (GameWorldView, visuals.toml, panels, build/demolish, speed controls) | ⬜ |
Tick order reference (architecture.md §Tick Order):
1. Wave scheduler — step 7
2. Threat accumulation — step 7
3. Belt→building pull — step 4
4. Building production — step 4
5. Building→belt push — step 4
6. Belt tick — step 3 ✅
7. Ship behavior systems — step 6
8. Combat resolution — step 7
9. Deaths & loot — step 7
10. `tickMovement` — step 6
11. Scrap despawn — step 5
Each new subsystem slots into `Simulation::tick()` in this exact order.
---
## Step 4 — Buildings + placement + belt↔building transport
Covers REQ-BLD-*, REQ-MAT-*. Introduces the first stateful gameplay loop: miners pull nothing, produce ore, push onto belts; smelters pull ore, produce ingots; etc.
### New types (`src/lib/sim/`)
```cpp
struct InputBuffer {
std::map<ItemType, int> counts;
std::map<ItemType, int> caps; // per-material; = 2× per-cycle requirement
};
struct OutputBuffer {
std::vector<Item> items;
int capacity; // 2× per-cycle output; 1× for ReprocessingPlant
};
struct Production {
std::string recipeId;
Tick completesAt;
std::vector<Item> chosenOutputs; // resolved at cycle start for reprocessing
};
struct Building {
EntityId id;
QPoint tile; // origin of footprint (top-left)
QSize footprint;
Rotation rotation;
BuildingType type;
float hp;
float maxHp;
std::string recipeId; // current recipe; empty = none selected
InputBuffer inputBuffer;
OutputBuffer outputBuffer;
std::optional<Production> production;
};
```
### Surface-mask parsing (new utility in `src/lib/config/`)
```cpp
struct ParsedSurfaceMask {
QSize footprint;
std::vector<QPoint> bodyCells; // relative to tile origin
std::vector<Port> outputPorts; // tile = adjacent cell OUTSIDE footprint
// direction = away from building
std::vector<QPoint> shipDockCells; // 'S' cells — for salvage bay / shipyard
};
ParsedSurfaceMask parseSurfaceMask(const std::vector<std::string>& rows,
Rotation rotation);
```
Conventions (inferred from `buildings.toml`):
- `A` = body cell
- `S` = ship dock cell (part of footprint; shipyard/salvage bay)
- `>`, `<`, `^`, `v` = direction marker on cell ADJACENT to body, NOT part of footprint
- space = empty within bounding box
- Rotation transforms the grid 90°/180°/270° around the mask origin
### Placement + BuildingSystem
Either a new `BuildingSystem` class in `src/lib/sim/` or methods on `Simulation`. Recommended: a `BuildingSystem` owned by `Simulation`, mirroring `BeltSystem`'s pattern.
```cpp
class BuildingSystem {
public:
BuildingSystem(const GameConfig& config, BeltSystem& belts,
std::function<EntityId()> allocateId,
std::mt19937& rng);
// Placement (called by UI commands in Step 8)
EntityId place(BuildingType type, QPoint tile, Rotation rotation);
void demolish(EntityId id);
void setRecipe(EntityId id, const std::string& recipeId);
// Tick hooks — called from Simulation::tick() in the correct order
void tickBeltPull(); // step 3
void tickProduction(); // step 4
void tickBeltPush(); // step 5
// Queries (for UI)
const Building* find(EntityId id) const;
std::vector<Building> all() const; // for rendering
};
```
Belts and splitters are registered with `BeltSystem` directly from `BuildingSystem::place` when type == Belt or Splitter — these don't get `Building` instances (architecture.md §Buildings).
### Production cycle (REQ-MAT-CYCLE)
In `tickProduction`:
```
for each building with recipeId set:
if building has active production:
if currentTick >= production.completesAt:
deposit chosenOutputs into outputBuffer
clear production
continue
// idle: try to start a new cycle
recipe = config.findRecipe(recipeId)
if inputs available in buffers AND outputs fit in outputBuffer:
consume inputs
if reprocessing: roll chosenOutputs via discrete_distribution on probabilities
else: chosenOutputs = recipe.outputs (expanded by amounts)
// re-check fit for reprocessing (chosen output must fit)
production = {recipeId, currentTick + secondsToTicks(recipe.durationSeconds), chosenOutputs}
```
Reprocessing uses `Simulation`'s `std::mt19937` + `std::discrete_distribution<>`. Do NOT use the legacy `WeightedRandomGenerator` (uses `auto` and float precision).
### Belt↔building interaction
`tickBeltPull` (step 3): for each building with `recipeId`, walk its footprint's edges; for each adjacent tile, construct `Port{adjTile, directionFromBeltToBuilding}` and call `belts.tryTakeItem(port)`. Accept if the item matches a required input AND the per-material buffer has space.
`tickBeltPush` (step 5): for each output port on each building with items in outputBuffer, call `belts.tryPutItem(port, item)`. On success, remove from buffer.
### Files
**New:**
- `src/lib/sim/Building.h`
- `src/lib/sim/BuildingSystem.h` / `.cpp`
- `src/lib/config/SurfaceMask.h` / `.cpp`
- `src/test/BuildingTest.cpp`
- `src/test/SurfaceMaskTest.cpp`
**Modified:**
- `src/lib/sim/Simulation.h` / `.cpp` — own `BeltSystem` + `BuildingSystem`; call their tick hooks in order
- `src/lib/sim/CMakeLists.txt`
- `src/lib/config/CMakeLists.txt`
- `src/test/CMakeLists.txt`
### Tests
- **Surface mask:** all four rotations of miner, smelter, splitter; output ports land on correct adjacent cells
- **Placement:** place miner, verify footprint occupies expected tiles; demolish removes it
- **Belt registration:** placing a Belt calls `BeltSystem::placeBelt`; demolishing calls `removeTile`
- **Miner cycle:** miner with `mine_iron_ore` recipe deposits iron_ore into outputBuffer after recipe duration ticks
- **Smelter cycle:** feed iron_ore into input buffer, 2 ore → 1 ingot in output after duration
- **Output buffer cap:** buffer fills to 2×, production stalls
- **Reprocessing cap:** buffer holds exactly 1× (REQ-MAT-OUTPUT-BUFFER-REPROCESSING)
- **Reprocessing RNG:** seed-deterministic weighted output pick; N trials match expected distribution within tolerance
- **Belt pull:** belt adjacent to smelter input edge delivers ore; smelter input buffer increments
- **Belt push:** miner outputBuffer drains onto adjacent belt each tick when space available
- **Recipe change:** `setRecipe` clears input + output buffers (REQ-MAT-INPUT-BUFFER, REQ-MAT-OUTPUT-BUFFER)
---
## Step 5 — Scrap + ships skeleton
Data structures + spawning only. No AI yet. Covers REQ-RES-SCRAP-DROP, REQ-SHP-STATS, REQ-BLD-SHIPYARD scaffolding.
### New types
```cpp
struct Scrap {
EntityId id;
QVector2D position; // tile units; ship-center convention
int amount;
Tick despawnAt;
};
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; };
struct ThreatResponse { float engagementRange; /* CombatStance, CombatTargetPriority */
std::optional<EntityId> currentTarget; };
struct ScrapCollector { std::optional<QVector2D> scrapTarget; EntityId deliveryBay; };
struct RepairBehavior { /* RepairTargetPriority */ std::optional<EntityId> currentTarget; };
struct HomeReturn { float retreatHpFraction; QVector2D homePos; };
struct Ship {
EntityId id;
QVector2D position;
QVector2D velocity;
float hp;
float maxHp;
int level;
std::string blueprintId; // matches ShipDef::id
std::optional<Weapon> weapon;
std::optional<SalvageCargo> cargo;
std::optional<RepairTool> repairTool;
std::optional<ThreatResponse> threatResponse;
std::optional<ScrapCollector> scrapCollector;
std::optional<RepairBehavior> repairBehavior;
std::optional<HomeReturn> homeReturn;
MovementIntent intent;
};
```
### `ShipSystem` / `ScrapSystem`
Small classes owned by `Simulation`:
- `ShipSystem::spawn(ShipDef, level, QVector2D position)` — builds a Ship from the config by evaluating per-role formulas at `level`; components present iff corresponding `ShipDef` sections are present
- `ShipSystem::forEach(…)` — for Step 6 behavior systems to iterate
- `ScrapSystem::spawn(QVector2D position, int amount)` — tick step 9 caller
- `ScrapSystem::tickDespawn()` — step 11
Still no AI tick hooks; `Simulation::tick()` gains step 11 only.
### Tests
- **Ship spawn:** combat ship has Weapon + ThreatResponse; salvage ship has SalvageCargo + ScrapCollector; stats evaluated from formulas at given level
- **Component absence:** salvage ship has no Weapon; combat ship has no SalvageCargo
- **Scrap spawn + despawn:** scrap created with `despawnAt = currentTick + secondsToTicks(world.scrapDespawnSeconds)`; after that many ticks `tickDespawn` removes it
- **Entity ids:** spawned ships/scrap receive strictly increasing ids from `Simulation::allocateId` (needs to be exposed to `ShipSystem`/`ScrapSystem` via constructor callback)
### Files
New: `Scrap.h`, `Ship.h`, `ShipSystem.h/.cpp`, `ScrapSystem.h/.cpp`, `ShipTest.cpp`, `ScrapTest.cpp`.
Modified: `Simulation.*`, `src/lib/sim/CMakeLists.txt`, `src/test/CMakeLists.txt`.
---
## Step 6 — Ship behavior systems + movement arbitration
All four behaviors + `tickMovement`, one at a time with focused tests. Movement intent priority (architecture.md §Movement Arbitration):
```
HomeReturn > ThreatResponse > RepairBehavior > ScrapCollector
priorities: 4 3 2 1
```
Behaviors write `MovementIntent{priority, target}` on the ship; higher priority overwrites lower. `MovementIntent` is cleared at the start of the ship behavior step.
### Sub-steps (independent commits recommended)
**6a. `tickHomeReturn`** — if `hp/maxHp < retreatHpFraction`, write intent toward `homePos` with priority 4.
**6b. `tickThreatResponse`** — acquire enemy target within `engagementRange` if none; hold existing target if still valid. If target in weapon range, fire (emit FireEvent, apply damage to target's hp, start cooldown — stays in Step 7 combat resolution if we want to centralize damage; for modularity, fire here). Else write intent toward target, priority 3.
**6c. `tickRepairBehavior`** — find damaged friendly target; move toward if out of repair range, repair if in range. Priority 2.
**6d. `tickScrapCollector`** — if cargo full, intent = `deliveryBay.tile`; else find nearest scrap, intent = scrap.position. On arrival, consume scrap (calls into `ScrapSystem`), increment cargo. Priority 1.
**6e. `tickMovement`** — for each ship with an intent, advance position toward `intent.target` by `speedPerTick` (from ShipDef speed formula). No pathfinding v1 — straight line.
### Design decision: combat resolution split
Two options for where fire/damage happens:
- (A) Inside `tickThreatResponse` — simpler, atomic
- (B) In a separate `tickCombatResolution` step 8 — matches architecture.md exactly
Recommend (B) for fidelity to architecture.md. `tickThreatResponse` only sets target + writes movement intent. Step 7 runs combat resolution across ships + stations uniformly.
### Tests
- Intent priority: ship with low hp + weapon + enemy in range routes to homePos, not enemy
- Target acquisition: closest enemy within engagementRange; unchanged while still valid
- Repair ship finds damaged ally, moves in, repairs
- Salvage ship picks up scrap, returns when cargo full, cargo empties at delivery bay
- Movement: ship travels exactly `speed × secondsToTicks(duration)` tiles over N ticks
---
## Step 7 — Waves + threat + combat + deaths & loot
Fills tick steps 1, 2, 8, 9. Covers REQ-WAV-*, REQ-SHP-FIRING-*, REQ-DEF-*, REQ-PSH-*, REQ-RES-SCRAP-DROP.
### Tick step 1 — Wave scheduler
```
- advance m_waveTimer by 1 tick
- if between waves: at wave trigger (random gap within world.waves.gap_min/max_seconds),
compute wave composition by drawing ship picks up to threat budget
(REQ-WAV-TRIGGER, REQ-WAV-THREAT-COST) using world.waves.threat_rate_formula
- schedule spawn times across spawn_duration_seconds
- spawn any enemy ships whose scheduled tick has arrived
```
Ships eligible for waves: those with `threat.costFormula(elapsedSeconds) > 0`.
### Tick step 2 — Threat accumulation
`m_threatLevel += max(0.0, world.waves.threatRateFormula.evaluate(elapsedSeconds)) × kTickDurationSeconds`.
### Tick step 8 — Combat resolution
Unified across ships + defence stations (player + enemy). Each shooter has {damage, range, fireRateHz, cooldown, currentTarget}. If target in range and cooldown ≤ 0:
- apply damage to target's hp
- emit `FireEvent{shooter.id, target.id, currentTick}` into `Simulation::m_fireEvents`
- set cooldown = `kTickRateHz / fireRateHz`
Stations fire per REQ-DEF-PLAYER-FIRE and REQ-PSH-STATION-FIRE; stats from config formulas at their level / generation.
### Tick step 9 — Deaths & loot
- For each entity with hp ≤ 0: drop scrap at position (REQ-RES-SCRAP-DROP); amount from ShipDef.loot.scrapDrop or station scrap formula
- Track enemy defence station "sets": if a full set destroyed this tick, award player one blueprint (REQ-DEF-BLUEPRINT-DROP); emit `BlueprintDropEvent`
- Remove dead entities (ships, scrap, buildings)
### Push mechanic (REQ-PSH-*)
When enemy wave progresses beyond contest zone: `world.push` expansion triggers, enemy defence station set spawns at new front, scaling_factor applied to formulas. This may belong in a dedicated `PushSystem` or fold into the wave scheduler. Decide at implementation time.
### Files
New: `WaveSystem.h/.cpp`, `CombatSystem.h/.cpp`, maybe `PushSystem.h/.cpp`, corresponding `*Test.cpp`.
Modified: `Simulation.*` to wire in tick steps 1, 2, 8, 9; `ShipSystem` to expose iteration; `BuildingSystem` to expose defence stations for combat.
### Tests
- Threat accumulates per second from the formula
- Wave spawn count matches threat budget / ship cost
- Fire event emitted + drainable + cleared
- Shooter on cooldown does not fire
- Ship at hp ≤ 0 drops scrap; scrap amount matches ShipDef
- Full enemy station set destroyed → BlueprintDropEvent with correct newLevel / wasNewUnlock
- Damage to HQ decrements HQ hp — game-over condition emitted when hp ≤ 0 (if we model it that way)
---
## Step 8 — UI layer
Big step. Break into sub-phases to keep each commit reviewable.
### 8a. Visuals config + window scaffolding
- New `visuals.toml` (REQ-UI, architecture.md §Rendering → Visual Parameters) — per-type fill/outline/glyph entries
- `src/ui/VisualsConfig.h/.cpp`, `src/ui/VisualsLoader.h/.cpp` — fail-fast on missing entries for any known sim id
- Main window widget: header bar + central game view + right-hand selected-building panel (QDockWidget or split layout)
- Wire `QApplication` + `Simulation + TickDriver` into `main.cpp` replacing the current stub
- Sim + UI share one thread; paintEvent reads sim state directly (no locks — architecture.md §Threading)
### 8b. GameWorldView (render only, no input)
- `QOpenGLWidget` subclass with `QPainter` drawing
- `QTimer` @ 60 Hz → `update()` + advances sim via `TickDriver::advance(elapsedMs, gameSpeedMultiplier)` → calls `sim.tick()` N times
- Layer order per architecture.md §Layer Order (tiles → buildings → belt items → scrap → ships → beams → overlays → screen-space)
- Scroll via `scrollXTiles` float, A/D keyboard input, clamped per REQ-GW-SCROLL-LIMIT
- Mouse→world conversion: `worldX = mouseX / 20 + scrollXTiles`
- Beam renderer: keeps `FireEvent`s for 0.3 s wall time (9 ticks @ 30 Hz), drops if either end entity is gone
- Blueprint toasts: keeps `BlueprintDropEvent`s for configured toast duration
### 8c. Input → sim commands
- Tile click: select building / select belt tiles (box drag)
- Builder mode: open from build button grid; shows ghost on cursor; click places construction site (REQ-BLD-PLACE); drag-to-place for belts (REQ-BLD-DRAG)
- Demolish mode: click building → demolish (confirm), returns refund (REQ-BLD-DEMOLISH)
- Selected-building panel: recipe picker, clear-belt button (REQ-UI-BELT-CLEAR), splitter filter config, demolish button
- Speed controls: 0 / 0.5× / 1× / 2× / 4× (REQ-UI-SPEED) — bound to spacebar pause + number keys
### 8d. Header bar + polish
- Resource counters (building blocks, blueprint collection)
- Threat meter
- Wave countdown
- FPS / speed indicator
- Minor polish: hover highlights, keyboard shortcuts, tooltip on build buttons
### Files
New: `src/ui/` populated — `MainWindow.*`, `GameWorldView.*`, `HeaderBar.*`, `BuildButtonGrid.*`, `SelectedBuildingPanel.*`, `VisualsConfig.*`, `VisualsLoader.*`, `Toast.*`, etc.
Modified: `src/ui/CMakeLists.txt` — flip from INTERFACE library to regular static library; enable AUTOMOC; add `Q_OBJECT` macros where needed. `src/app/main.cpp` — construct sim + main window.
### Tests
UI code is largely visual; prioritize:
- Visuals loader fail-fast on missing entries
- Simulation + TickDriver integration test: at 1×, 60 render frames produce ~30 sim ticks (approximately — tolerate ±1 for accumulator residue)
- Manual smoke test checklist (in-repo markdown) for builder mode, demolish, recipe change, clear belt, speed toggling
---
## Things to revisit as needed
- **Pathfinding for ships:** straight-line in v1 is fine given open space; only revisit if enemy defence stations create obstacles
- **Belt segment compression (v2):** only if v1 per-tile profiling is bad
- **Worker thread for sim:** only if paint stalls become visible; `drain*` APIs already support it
- **ECS migration for ships:** only if component iteration becomes a bottleneck
- **Belt curves rendering:** derive from consecutive belt tile directions; sim logic is unaffected

View File

@@ -54,7 +54,7 @@ Output port indicators are not building tiles themselves. A building may have mo
- REQ-BLD-COST: The player places buildings from a build menu. Placement costs building blocks from the global stock. The cost per building type is read from `buildings.toml [[building]].cost`. - REQ-BLD-COST: The player places buildings from a build menu. Placement costs building blocks from the global stock. The cost per building type is read from `buildings.toml [[building]].cost`.
- REQ-BLD-QUEUE: Placed buildings enter a construction queue and are built one at a time. Each building takes a duration defined in `buildings.toml [[building]].construction_time_seconds` to construct. - REQ-BLD-QUEUE: Placed buildings enter a construction queue and are built one at a time. Each building takes a duration defined in `buildings.toml [[building]].construction_time_seconds` to construct.
- REQ-BLD-ASTEROID-ONLY: Buildings can only be placed on asteroid tiles (per surface_mask; tiles marked `S` may extend into space). - REQ-BLD-ASTEROID-ONLY: Buildings can only be placed on asteroid tiles (per surface_mask; tiles marked `S` may extend into space).
- REQ-BLD-BUILDER-MODE: Clicking a build button activates builder mode for that building type. Builder mode is exited by pressing Escape, right-clicking in the game world, or clicking the same build button again. - REQ-BLD-BUILDER-MODE: Clicking a build button activates builder mode for that building type. Builder mode is exited by right-clicking in the game world or clicking the same build button again.
- REQ-BLD-GHOST: While in builder mode, a ghost of the building is rendered at the tile under the cursor, showing where it would be placed. - REQ-BLD-GHOST: While in builder mode, a ghost of the building is rendered at the tile under the cursor, showing where it would be placed.
- REQ-BLD-ROTATE: While in builder mode, pressing E rotates the ghost 90° clockwise and Q rotates it 90° counter-clockwise. Rotation affects the direction of the output port. - REQ-BLD-ROTATE: While in builder mode, pressing E rotates the ghost 90° clockwise and Q rotates it 90° counter-clockwise. Rotation affects the direction of the output port.
- REQ-BLD-PLACE: Clicking a valid tile in builder mode places a construction site and adds it to the build queue, consuming building blocks from the global stock. - REQ-BLD-PLACE: Clicking a valid tile in builder mode places a construction site and adds it to the build queue, consuming building blocks from the global stock.
@@ -91,10 +91,8 @@ Output port indicators are not building tiles themselves. A building may have mo
## Resources ## Resources
- REQ-RES-ORE: Ore is extracted by miners and smelted into basic materials by smelters.
- REQ-RES-SCRAP-DROP: Destroyed ships (both player and enemy) and destroyed defence stations (both player and enemy) drop scrap at their location. The scrap amount per ship is defined in `ships.toml [ship.loot].scrap_drop`; for stations it is defined as `stations.toml [player_station].scrap_drop_formula` and `[enemy_station].scrap_drop_formula`. Scrap despawns after `world.toml [world].scrap_despawn_seconds` seconds if not collected. - REQ-RES-SCRAP-DROP: Destroyed ships (both player and enemy) and destroyed defence stations (both player and enemy) drop scrap at their location. The scrap amount per ship is defined in `ships.toml [ship.loot].scrap_drop`; for stations it is defined as `stations.toml [player_station].scrap_drop_formula` and `[enemy_station].scrap_drop_formula`. Scrap despawns after `world.toml [world].scrap_despawn_seconds` seconds if not collected.
- REQ-RES-SCRAP-COLLECT: Scrap is collected by salvage ships and delivered to a Salvage Bay on the asteroid. From there it can be fed via belt into a smelter (same output as ore) or a Reprocessing Plant. - REQ-RES-SCRAP-COLLECT: Scrap is collected by salvage ships and delivered to a Salvage Bay on the asteroid. From there it can be fed via belt into a smelter (same output as ore) or a Reprocessing Plant.
- REQ-RES-BUILDING-BLOCKS: Building blocks are produced by an assembler recipe and are the only globally pooled resource. They are added to the global stock when delivered to the HQ via belt.
## Ships ## Ships
@@ -131,17 +129,14 @@ Output port indicators are not building tiles themselves. A building may have mo
- REQ-WAV-THREAT-RATE: A global **threat level** accumulates continuously over time. The rate of increase per second is determined by `world.toml [waves].threat_rate_formula` where x is elapsed game time in seconds, clamped to a minimum of 0 (negative formula values are treated as 0). Example: `1*x - 30` yields 0 threat/s for x ≤ 30s and increases linearly after that. - REQ-WAV-THREAT-RATE: A global **threat level** accumulates continuously over time. The rate of increase per second is determined by `world.toml [waves].threat_rate_formula` where x is elapsed game time in seconds, clamped to a minimum of 0 (negative formula values are treated as 0). Example: `1*x - 30` yields 0 threat/s for x ≤ 30s and increases linearly after that.
- REQ-WAV-GAP: At game start and immediately after each wave is triggered, a random inter-wave gap is drawn uniformly from [`world.toml [waves].gap_min_seconds`, `gap_max_seconds`]. - REQ-WAV-GAP: At game start and immediately after each wave is triggered, a random inter-wave gap is drawn uniformly from [`world.toml [waves].gap_min_seconds`, `gap_max_seconds`].
- REQ-WAV-TRIGGER: When the gap expires, a wave is triggered. Ships are selected one at a time: from all blueprints whose `threat.cost_formula` evaluates to > 0 at the current enemy ship level, uniformly randomly pick one whose cost fits the remaining threat budget. Repeat until no eligible blueprint fits. Any remaining threat carries over to the next wave. A longer gap results in a larger wave. - REQ-WAV-TRIGGER: When the gap expires, a wave is triggered. Ships are selected one at a time: from all blueprints whose `threat.cost_formula` evaluates to > 0 at the current enemy ship level, uniformly randomly pick one whose cost fits the remaining threat budget. Repeat until no eligible blueprint fits. Any remaining threat carries over to the next wave. A longer gap results in a larger wave. Because enemy ship level increases with time (REQ-WAV-SHIP-LEVEL), threat cost per ship rises naturally over the course of the game.
- REQ-WAV-SHIP-LEVEL: Each wave's enemy ships are assigned a level determined by `world.toml [waves].ship_level_formula` where x is elapsed game time in seconds. This is the sole mechanism by which individual enemy ships become stronger over time. Wave *size* grows separately via threat accumulation (REQ-WAV-THREAT-RATE) and push scaling (REQ-PSH-ACCUMULATION). Per-ship stats and threat cost are computed from the ship level via the formulas in `ships.toml` (see REQ-SHP-STATS). - REQ-WAV-SHIP-LEVEL: Each wave's enemy ships are assigned a level determined by `world.toml [waves].ship_level_formula` where x is elapsed game time in seconds. This is the sole mechanism by which individual enemy ships become stronger over time. Wave *size* grows separately via threat accumulation (REQ-WAV-THREAT-RATE) and push scaling (REQ-PSH-ACCUMULATION). Per-ship stats and threat cost are computed from the ship level via the formulas in `ships.toml` (see REQ-SHP-STATS).
- REQ-WAV-THREAT-COST: Each ship type has a threat cost defined as `ships.toml [ship.threat].cost_formula`. Ships are selected one at a time per REQ-WAV-TRIGGER until no eligible blueprint's cost fits the remaining threat budget. Because enemy ship level increases with time, threat cost per ship rises naturally over the course of the game.
- REQ-WAV-SPAWN-DURATION: Ships in a wave are spawned one at a time over `world.toml [waves].spawn_duration_seconds`. - REQ-WAV-SPAWN-DURATION: Ships in a wave are spawned one at a time over `world.toml [waves].spawn_duration_seconds`.
- REQ-WAV-GRACE-PERIOD: The grace period before the first wave is implicit: threat accumulates from t=0 but the first wave does not trigger until the initial gap (drawn at game start) has elapsed.
## Push Scaling ## Push Scaling
- REQ-PSH-ACCUMULATION: Each time the player destroys a set of enemy defence stations, `world.toml [push].scaling_factor` is multiplied permanently into the threat level accumulation rate. This causes all subsequent waves to be larger. - REQ-PSH-ACCUMULATION: Each time the player destroys a set of enemy defence stations, `world.toml [push].scaling_factor` is multiplied permanently into the threat level accumulation rate. Scaling factors stack multiplicatively with each other and with the time-based threat formula, causing all subsequent waves to be larger.
- REQ-PSH-STATION-STATS: Enemy defence station stats are each defined as formulas in `stations.toml [enemy_station]`: `hp_formula`, `damage_formula`, `range_formula`, `fire_rate_formula`, `scrap_drop_formula`, where x is the station level — an integer starting at 0 for the initial set and incrementing by 1 each time a new set is placed. - REQ-PSH-STATION-STATS: Enemy defence station stats are each defined as formulas in `stations.toml [enemy_station]`: `hp_formula`, `damage_formula`, `range_formula`, `fire_rate_formula`, `scrap_drop_formula`, where x is the station level — an integer starting at 0 for the initial set and incrementing by 1 each time a new set is placed.
- REQ-PSH-STACKING: Push scaling factors stack multiplicatively with each other and with the time-based threat formula.
## Asteroid Expansion ## Asteroid Expansion
@@ -187,14 +182,22 @@ The screen is divided into three vertical sections:
- **Space** — toggles pause. Pressing Space pauses (sets speed to 0×) and stores the previously selected non-zero speed; pressing Space again restores that speed. - **Space** — toggles pause. Pressing Space pauses (sets speed to 0×) and stores the previously selected non-zero speed; pressing Space again restores that speed.
- **W** — increases game speed by one step in the sequence 0×, 0.5×, 1×, 2×, 4× (no wrap-around past 4×). - **W** — increases game speed by one step in the sequence 0×, 0.5×, 1×, 2×, 4× (no wrap-around past 4×).
- **S** — decreases game speed by one step in the same sequence (no wrap-around past 0×). - **S** — decreases game speed by one step in the same sequence (no wrap-around past 0×).
- **Backspace** — activates demolish mode; Escape or Backspace again exits it. - **Backspace** — activates demolish mode; Backspace again exits it. (See also REQ-UI-DEMOLISH-BUTTON for the equivalent button.)
- **Q / E** — in builder mode, rotate the ghost counter-clockwise / clockwise (REQ-BLD-ROTATE). - **Q / E** — in builder mode, rotate the ghost counter-clockwise / clockwise (REQ-BLD-ROTATE).
- **Escape** — exits builder mode or demolish mode. - **Escape** — opens the escape menu (REQ-UI-GAME-MENU).
### Escape Menu
- REQ-UI-GAME-MENU: Pressing Escape at any time opens the escape menu as a modal dialog and pauses the simulation (sets speed to 0×). On close, the simulation speed is restored to what it was before the menu was opened — so if the game was already paused, it remains paused. The menu contains three buttons:
- **Continue** — closes the menu and returns to the game.
- **Restart** — resets the simulation to its initial state and closes the menu at 1× speed.
- **Quit** — closes the application.
Pressing Escape while the escape menu is open is equivalent to clicking Continue.
### Selected Building Panel ### Selected Building Panel
- REQ-UI-EMPTY-SELECTION: When no building is selected, the panel is empty. - REQ-UI-EMPTY-SELECTION: When no building is selected, the panel is empty.
- REQ-UI-SINGLE-SELECTION: When one building is selected, the panel shows: building name, current recipe or blueprint selection, input buffer contents, and output buffer contents. - REQ-UI-SINGLE-SELECTION: When one building is selected, the panel shows: building name, current recipe or blueprint selection, input buffer contents, and output buffer contents. Buffer counts are displayed as `a/b` where `a` is the current item count and `b` is the per-cycle amount (items consumed per run for inputs; items produced per run for outputs).
- REQ-UI-MULTI-SELECT: The player selects multiple buildings by box-drag or by Ctrl+clicking individual buildings to add or remove them from the selection. - REQ-UI-MULTI-SELECT: The player selects multiple buildings by box-drag or by Ctrl+clicking individual buildings to add or remove them from the selection.
- REQ-UI-MULTI-SELECTION: When multiple buildings are selected, the panel shows how many of each building type are selected. No per-building detail is shown. - REQ-UI-MULTI-SELECTION: When multiple buildings are selected, the panel shows how many of each building type are selected. No per-building detail is shown.
- REQ-UI-CONFIG-INLINE: Recipe, blueprint, ship stance, and target priority configuration for a selected building is shown and changed inline within this panel. - REQ-UI-CONFIG-INLINE: Recipe, blueprint, ship stance, and target priority configuration for a selected building is shown and changed inline within this panel.
@@ -205,3 +208,4 @@ The screen is divided into three vertical sections:
- REQ-UI-BUILD-GRID: All placeable building types are shown as a flat grid of buttons with no grouping. - REQ-UI-BUILD-GRID: All placeable building types are shown as a flat grid of buttons with no grouping.
- REQ-UI-BUILD-COST: Each button caption shows the building name and its building block cost, e.g. "Belt: 2 Blocks". - REQ-UI-BUILD-COST: Each button caption shows the building name and its building block cost, e.g. "Belt: 2 Blocks".
- REQ-UI-BUILD-DISABLED: Buttons for buildings the player cannot currently afford are shown as disabled. - REQ-UI-BUILD-DISABLED: Buttons for buildings the player cannot currently afford are shown as disabled.
- REQ-UI-DEMOLISH-BUTTON: A dedicated **Demolish** button is shown in the build button grid. Clicking it toggles demolish mode on and off, equivalent to pressing Backspace (REQ-UI-HOTKEYS). The button is shown in a visually active/pressed state while demolish mode is active.

View File

@@ -119,19 +119,13 @@ std::optional<Item> BeltSystem::tryTakeItem(Port port)
} }
BeltTile& bt = it->second; BeltTile& bt = it->second;
if (bt.front) if (bt.front && bt.front->progress >= 1.0)
{ {
const Item taken = bt.front->item; const Item taken = bt.front->item;
bt.front = bt.back; bt.front = bt.back;
bt.back = std::nullopt; bt.back = std::nullopt;
return taken; return taken;
} }
if (bt.back)
{
const Item taken = bt.back->item;
bt.back = std::nullopt;
return taken;
}
return std::nullopt; return std::nullopt;
} }
@@ -149,14 +143,10 @@ std::optional<ItemType> BeltSystem::peekItem(Port port) const
} }
const BeltTile& bt = it->second; const BeltTile& bt = it->second;
if (bt.front) if (bt.front && bt.front->progress >= 1.0)
{ {
return bt.front->item.type; return bt.front->item.type;
} }
if (bt.back)
{
return bt.back->item.type;
}
return std::nullopt; return std::nullopt;
} }

View File

@@ -49,6 +49,45 @@ Simulation::Simulation(const GameConfig& config, unsigned int seed)
Simulation::~Simulation() = default; Simulation::~Simulation() = default;
void Simulation::reset(unsigned int seed)
{
m_rng.seed(seed);
m_currentTick = 0;
m_nextId = 1;
m_buildingBlocksStock = m_config.world.startingBuildingBlocks;
m_gameOver = false;
m_hqId = kInvalidEntityId;
m_playerStation1Id = kInvalidEntityId;
m_playerStation2Id = kInvalidEntityId;
m_currentEnemyStationIds[0] = kInvalidEntityId;
m_currentEnemyStationIds[1] = kInvalidEntityId;
m_fireEvents.clear();
m_blueprintDropEvents.clear();
m_beltSystem = BeltSystem(m_config.world.beltSpeedTilesPerSecond);
m_buildingSystem = std::make_unique<BuildingSystem>(
m_config,
m_beltSystem,
[this]() { return allocateId(); },
[this](int amount) { m_buildingBlocksStock += amount; },
m_rng);
m_shipSystem = std::make_unique<ShipSystem>(m_config, [this]() { return allocateId(); });
m_scrapSystem = std::make_unique<ScrapSystem>([this]() { return allocateId(); });
m_waveSystem = std::make_unique<WaveSystem>(m_config, m_rng);
m_combatSystem = std::make_unique<CombatSystem>(m_config);
m_blueprintLevels.clear();
for (const ShipDef& def : m_config.ships.ships)
{
BlueprintState state;
state.unlocked = def.availableFromStart;
state.level = def.availableFromStart ? def.blueprint.playerProductionLevel : 0;
m_blueprintLevels[def.id] = state;
}
placeInitialStructures();
}
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// tick // tick
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------

View File

@@ -29,6 +29,9 @@ public:
explicit Simulation(const GameConfig& config, unsigned int seed = 0); explicit Simulation(const GameConfig& config, unsigned int seed = 0);
~Simulation(); ~Simulation();
// Reinitializes all simulation state as if constructed fresh.
void reset(unsigned int seed = 0);
// Advances the simulation by one tick. Tick order per architecture.md §Tick Order. // Advances the simulation by one tick. Tick order per architecture.md §Tick Order.
void tick(); void tick();

View File

@@ -101,12 +101,13 @@ TEST_CASE("BeltSystem: third tryPutItem on full tile returns false", "[belt]")
// tryTakeItem // tryTakeItem
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
TEST_CASE("BeltSystem: tryTakeItem returns placed item", "[belt]") TEST_CASE("BeltSystem: tryTakeItem returns placed item after reaching output edge", "[belt]")
{ {
BeltSystem bs(kFastBeltSpeed); BeltSystem bs(kFastBeltSpeed);
const QPoint tile(0, 0); const QPoint tile(0, 0);
bs.placeBelt(tile, Rotation::East); bs.placeBelt(tile, Rotation::East);
bs.tryPutItem(eastPort(tile), makeItem("iron_ore")); bs.tryPutItem(eastPort(tile), makeItem("iron_ore"));
bs.tick(); // advance to output edge
const std::optional<Item> taken = bs.tryTakeItem(eastPort(tile)); const std::optional<Item> taken = bs.tryTakeItem(eastPort(tile));
@@ -114,7 +115,23 @@ TEST_CASE("BeltSystem: tryTakeItem returns placed item", "[belt]")
REQUIRE(taken->type.id == "iron_ore"); REQUIRE(taken->type.id == "iron_ore");
} }
TEST_CASE("BeltSystem: tryTakeItem with two items returns both in sequence", "[belt]") TEST_CASE("BeltSystem: tryTakeItem requires item to reach output edge before yielding", "[belt]")
{
BeltSystem bs(kFastBeltSpeed);
const QPoint tile(0, 0);
bs.placeBelt(tile, Rotation::East);
bs.tryPutItem(eastPort(tile), makeItem("iron_ore"));
// Item placed but not yet at output edge — must not be available.
REQUIRE_FALSE(bs.tryTakeItem(eastPort(tile)).has_value());
REQUIRE_FALSE(bs.peekItem(eastPort(tile)).has_value());
// After one tick the item has reached progress 1.0 and is available.
bs.tick();
REQUIRE(bs.tryTakeItem(eastPort(tile)).has_value());
}
TEST_CASE("BeltSystem: tryTakeItem with two items returns both after each reaches output edge", "[belt]")
{ {
BeltSystem bs(kFastBeltSpeed); BeltSystem bs(kFastBeltSpeed);
const QPoint tile(0, 0); const QPoint tile(0, 0);
@@ -122,15 +139,16 @@ TEST_CASE("BeltSystem: tryTakeItem with two items returns both in sequence", "[b
bs.tryPutItem(eastPort(tile), makeItem("first")); bs.tryPutItem(eastPort(tile), makeItem("first"));
bs.tryPutItem(eastPort(tile), makeItem("second")); bs.tryPutItem(eastPort(tile), makeItem("second"));
// First take returns front item (first placed, higher progress). // Front item reaches output edge after one tick.
bs.tick();
const std::optional<Item> taken1 = bs.tryTakeItem(eastPort(tile)); const std::optional<Item> taken1 = bs.tryTakeItem(eastPort(tile));
REQUIRE(taken1.has_value()); REQUIRE(taken1.has_value());
// Second take returns the remaining item. // Back item (now promoted to front) needs another tick to reach output edge.
bs.tick();
const std::optional<Item> taken2 = bs.tryTakeItem(eastPort(tile)); const std::optional<Item> taken2 = bs.tryTakeItem(eastPort(tile));
REQUIRE(taken2.has_value()); REQUIRE(taken2.has_value());
// Tile is now empty.
REQUIRE_FALSE(bs.tryTakeItem(eastPort(tile)).has_value()); REQUIRE_FALSE(bs.tryTakeItem(eastPort(tile)).has_value());
} }
@@ -156,7 +174,7 @@ TEST_CASE("BeltSystem: tryTakeItem returns nullopt on direction mismatch", "[bel
// tick() — item advancement // tick() — item advancement
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
TEST_CASE("BeltSystem: one tick moves item from tile A to tile B in a 2-tile chain", "[belt]") TEST_CASE("BeltSystem: item transfers from tile A to tile B and becomes available after two ticks", "[belt]")
{ {
BeltSystem bs(kFastBeltSpeed); BeltSystem bs(kFastBeltSpeed);
const QPoint tileA(0, 0); const QPoint tileA(0, 0);
@@ -165,9 +183,9 @@ TEST_CASE("BeltSystem: one tick moves item from tile A to tile B in a 2-tile cha
bs.placeBelt(tileB, Rotation::East); bs.placeBelt(tileB, Rotation::East);
bs.tryPutItem(eastPort(tileA), makeItem("iron_ore")); bs.tryPutItem(eastPort(tileA), makeItem("iron_ore"));
bs.tick(); bs.tick(); // item reaches output edge of A, moves to B at progress 0
bs.tick(); // item reaches output edge of B
// Item should have moved to tileB.
REQUIRE_FALSE(bs.tryTakeItem(eastPort(tileA)).has_value()); REQUIRE_FALSE(bs.tryTakeItem(eastPort(tileA)).has_value());
const std::optional<Item> inB = bs.tryTakeItem(eastPort(tileB)); const std::optional<Item> inB = bs.tryTakeItem(eastPort(tileB));
REQUIRE(inB.has_value()); REQUIRE(inB.has_value());
@@ -187,7 +205,7 @@ TEST_CASE("BeltSystem: item stays at progress 1.0 when next tile is absent", "[b
REQUIRE(bs.tryTakeItem(eastPort(tileA)).has_value()); REQUIRE(bs.tryTakeItem(eastPort(tileA)).has_value());
} }
TEST_CASE("BeltSystem: item traverses 3-tile chain in 2 ticks", "[belt]") TEST_CASE("BeltSystem: item traverses 3-tile chain in 3 ticks (one per tile)", "[belt]")
{ {
BeltSystem bs(kFastBeltSpeed); BeltSystem bs(kFastBeltSpeed);
const QPoint tileA(0, 0); const QPoint tileA(0, 0);
@@ -198,8 +216,9 @@ TEST_CASE("BeltSystem: item traverses 3-tile chain in 2 ticks", "[belt]")
bs.placeBelt(tileC, Rotation::East); bs.placeBelt(tileC, Rotation::East);
bs.tryPutItem(eastPort(tileA), makeItem("iron_ore")); bs.tryPutItem(eastPort(tileA), makeItem("iron_ore"));
bs.tick(); // A -> B bs.tick(); // A output edge → moves to B at progress 0
bs.tick(); // B -> C bs.tick(); // B output edge → moves to C at progress 0
bs.tick(); // C output edge → available for pickup
REQUIRE_FALSE(bs.tryTakeItem(eastPort(tileA)).has_value()); REQUIRE_FALSE(bs.tryTakeItem(eastPort(tileA)).has_value());
REQUIRE_FALSE(bs.tryTakeItem(eastPort(tileB)).has_value()); REQUIRE_FALSE(bs.tryTakeItem(eastPort(tileB)).has_value());

View File

@@ -396,6 +396,10 @@ EntityId GameWorldView::buildingAtTile(QPoint tile) const
if (cell == tile) { return b.id; } if (cell == tile) { return b.id; }
} }
} }
for (const BuildingSystem::BeltTileInfo& info : m_sim->buildings().allBeltTiles())
{
if (info.tile == tile) { return info.id; }
}
return kInvalidEntityId; return kInvalidEntityId;
} }
@@ -617,6 +621,36 @@ void GameWorldView::drawBuildings(QPainter& painter)
const BuildingDef* siteDef = findBuildingDef(s.type); const BuildingDef* siteDef = findBuildingDef(s.type);
if (siteDef) if (siteDef)
{ {
// Glyph + progress percentage
const Tick durationTicks = secondsToTicks(siteDef->constructionTimeSeconds);
int pct = 0;
if (s.completesAt > 0 && durationTicks > 0)
{
const Tick elapsed = m_sim->currentTick()
- (s.completesAt - durationTicks);
pct = static_cast<int>(
std::max(Tick(0), std::min(durationTicks, elapsed))
* 100 / durationTicks);
}
const QString pctText = QString::number(pct) + "%";
painter.setPen(bv.outline);
if (!bv.glyph.isEmpty())
{
const QRectF topHalf(bboxRect.x(), bboxRect.y(),
bboxRect.width(), bboxRect.height() * 0.5);
const QRectF botHalf(bboxRect.x(),
bboxRect.y() + bboxRect.height() * 0.5,
bboxRect.width(), bboxRect.height() * 0.5);
painter.drawText(topHalf, Qt::AlignCenter, bv.glyph);
painter.drawText(botHalf, Qt::AlignCenter, pctText);
}
else
{
painter.drawText(bboxRect, Qt::AlignCenter, pctText);
}
// Port glyphs
const ParsedSurfaceMask siteMask = const ParsedSurfaceMask siteMask =
parseSurfaceMask(siteDef->surfaceMask, s.rotation); parseSurfaceMask(siteDef->surfaceMask, s.rotation);
for (const Port& port : siteMask.outputPorts) for (const Port& port : siteMask.outputPorts)
@@ -748,6 +782,17 @@ void GameWorldView::drawOverlays(QPainter& painter)
painter.fillRect(tileRect(cell), m_visuals->overlays.demolishTint); painter.fillRect(tileRect(cell), m_visuals->overlays.demolishTint);
} }
} }
else
{
for (const BuildingSystem::BeltTileInfo& info : m_sim->buildings().allBeltTiles())
{
if (info.id == m_demolishHoverId)
{
painter.fillRect(tileRect(info.tile), m_visuals->overlays.demolishTint);
break;
}
}
}
} }
// Box-select rectangle // Box-select rectangle
@@ -853,15 +898,7 @@ void GameWorldView::keyPressEvent(QKeyEvent* event)
} }
break; break;
case Qt::Key_Escape: case Qt::Key_Escape:
if (m_builderType.has_value()) emit escapeMenuRequested();
{
exitBuilderMode();
}
else if (m_demolishMode)
{
m_demolishMode = false;
m_demolishHoverId = kInvalidEntityId;
}
break; break;
case Qt::Key_Backspace: case Qt::Key_Backspace:
if (m_demolishMode) if (m_demolishMode)
@@ -926,8 +963,9 @@ void GameWorldView::mousePressEvent(QMouseEvent* event)
if (hovered != kInvalidEntityId) if (hovered != kInvalidEntityId)
{ {
const Building* b = m_sim->buildings().findBuilding(hovered); const Building* b = m_sim->buildings().findBuilding(hovered);
if (b && b->type != BuildingType::Hq const bool protected_ = b && (b->type == BuildingType::Hq
&& b->type != BuildingType::PlayerDefenceStation) || b->type == BuildingType::PlayerDefenceStation);
if (!protected_)
{ {
m_sim->buildings().demolish(hovered); m_sim->buildings().demolish(hovered);
m_demolishHoverId = kInvalidEntityId; m_demolishHoverId = kInvalidEntityId;
@@ -1068,6 +1106,11 @@ void GameWorldView::exitBuilderMode()
emit builderModeExited(); emit builderModeExited();
} }
double GameWorldView::gameSpeed() const
{
return m_gameSpeedMultiplier;
}
void GameWorldView::setGameSpeed(double multiplier) void GameWorldView::setGameSpeed(double multiplier)
{ {
m_gameSpeedMultiplier = multiplier; m_gameSpeedMultiplier = multiplier;
@@ -1075,3 +1118,24 @@ void GameWorldView::setGameSpeed(double multiplier)
m_sim->buildingBlocksStock(), m_sim->buildingBlocksStock(),
m_gameSpeedMultiplier); m_gameSpeedMultiplier);
} }
void GameWorldView::resetForNewGame()
{
exitBuilderMode();
m_activeBeams.clear();
m_toasts.clear();
m_ghostRotation = Rotation::East;
m_ghostValid = false;
m_demolishMode = false;
m_demolishHoverId = kInvalidEntityId;
m_selectedIds.clear();
m_boxSelecting = false;
m_scrollXTiles = 0.0f;
m_scrollLeft = false;
m_scrollRight = false;
m_gameOverShown = false;
m_gameSpeedMultiplier = 1.0;
m_prevNonZeroSpeed = 1.0;
emit selectionChanged({});
update();
}

View File

@@ -46,11 +46,16 @@ signals:
void stateUpdated(Tick tick, int blocks, double speed); void stateUpdated(Tick tick, int blocks, double speed);
void gameOver(); void gameOver();
void builderModeExited(); void builderModeExited();
void escapeMenuRequested();
public:
double gameSpeed() const;
public slots: public slots:
void enterBuilderMode(BuildingType type); void enterBuilderMode(BuildingType type);
void exitBuilderMode(); void exitBuilderMode();
void setGameSpeed(double multiplier); void setGameSpeed(double multiplier);
void resetForNewGame();
protected: protected:
void initializeGL() override; void initializeGL() override;

View File

@@ -2,6 +2,7 @@
#include <QHBoxLayout> #include <QHBoxLayout>
#include <QMessageBox> #include <QMessageBox>
#include <QPushButton>
#include <QResizeEvent> #include <QResizeEvent>
#include <QVBoxLayout> #include <QVBoxLayout>
@@ -45,9 +46,15 @@ MainWindow::MainWindow(Simulation* sim, const GameConfig* config,
connect(m_gameWorldView, SIGNAL(stateUpdated(Tick, int, double)), connect(m_gameWorldView, SIGNAL(stateUpdated(Tick, int, double)),
this, SLOT(onStateUpdated(Tick, int, double))); // for affordability this, SLOT(onStateUpdated(Tick, int, double))); // for affordability
connect(m_gameWorldView, SIGNAL(stateUpdated(Tick, int, double)),
m_selectedBuildingPanel, SLOT(onStateUpdated(Tick, int, double)));
connect(m_gameWorldView, SIGNAL(gameOver()), connect(m_gameWorldView, SIGNAL(gameOver()),
this, SLOT(onGameOver())); this, SLOT(onGameOver()));
connect(m_gameWorldView, SIGNAL(escapeMenuRequested()),
this, SLOT(onEscapeMenuRequested()));
// Signals: build grid → game world // Signals: build grid → game world
connect(m_buildButtonGrid, SIGNAL(buildingTypeSelected(BuildingType)), connect(m_buildButtonGrid, SIGNAL(buildingTypeSelected(BuildingType)),
m_gameWorldView, SLOT(enterBuilderMode(BuildingType))); m_gameWorldView, SLOT(enterBuilderMode(BuildingType)));
@@ -91,6 +98,35 @@ void MainWindow::onStateUpdated(Tick /*tick*/, int blocks, double /*speed*/)
m_buildButtonGrid->updateAffordability(blocks); m_buildButtonGrid->updateAffordability(blocks);
} }
void MainWindow::onEscapeMenuRequested()
{
const double prevSpeed = m_gameWorldView->gameSpeed();
m_gameWorldView->setGameSpeed(0.0);
QMessageBox box(this);
box.setWindowTitle("Paused");
QPushButton* continueBtn = box.addButton("Continue", QMessageBox::AcceptRole);
QPushButton* restartBtn = box.addButton("Restart", QMessageBox::ResetRole);
QPushButton* quitBtn = box.addButton("Quit", QMessageBox::DestructiveRole);
box.setEscapeButton(continueBtn);
box.exec();
QAbstractButton* clicked = box.clickedButton();
if (clicked == restartBtn)
{
m_sim->reset();
m_gameWorldView->resetForNewGame();
}
else if (clicked == quitBtn)
{
close();
}
else
{
m_gameWorldView->setGameSpeed(prevSpeed);
}
}
void MainWindow::onGameOver() void MainWindow::onGameOver()
{ {
const Tick tick = m_sim->currentTick(); const Tick tick = m_sim->currentTick();
@@ -103,7 +139,17 @@ void MainWindow::onGameOver()
box.setText(QString("HQ destroyed!\nSurvival time: %1:%2") box.setText(QString("HQ destroyed!\nSurvival time: %1:%2")
.arg(minutes, 2, 10, QChar('0')) .arg(minutes, 2, 10, QChar('0'))
.arg(seconds, 2, 10, QChar('0'))); .arg(seconds, 2, 10, QChar('0')));
QPushButton* restartBtn = box.addButton("Restart", QMessageBox::AcceptRole);
box.addButton("Quit", QMessageBox::RejectRole); box.addButton("Quit", QMessageBox::RejectRole);
box.exec(); box.exec();
close();
if (box.clickedButton() == restartBtn)
{
m_sim->reset();
m_gameWorldView->resetForNewGame();
}
else
{
close();
}
} }

View File

@@ -27,6 +27,7 @@ protected:
private slots: private slots:
void onGameOver(); void onGameOver();
void onStateUpdated(Tick tick, int blocks, double speed); void onStateUpdated(Tick tick, int blocks, double speed);
void onEscapeMenuRequested();
private: private:
void layoutPanels(); void layoutPanels();

View File

@@ -188,39 +188,117 @@ void SelectedBuildingPanel::buildSingle(EntityId id)
m_clearBeltBtn->hide(); m_clearBeltBtn->hide();
} }
refreshBuffers(b);
}
void SelectedBuildingPanel::refreshBuffers(const Building* b)
{
const RecipeDef* recipe = findRecipe(b);
const ShipDef* shipDef = (b->type == BuildingType::Shipyard)
? findShipDef(b->recipeId)
: nullptr;
QString bufText; QString bufText;
if (!b->inputBuffer.counts.empty()) if (!b->inputBuffer.counts.empty())
{ {
bufText += "Input: "; bufText += "Input: ";
for (const std::pair<const ItemType, int>& entry : b->inputBuffer.counts) for (const std::pair<const ItemType, int>& entry : b->inputBuffer.counts)
{ {
const std::map<ItemType, int>::const_iterator cap = int perCycle = 0;
b->inputBuffer.caps.find(entry.first); if (recipe)
const int capVal = (cap != b->inputBuffer.caps.end()) ? cap->second : 0; {
for (const RecipeIngredient& ing : recipe->inputs)
{
if (ing.item == entry.first.id) { perCycle = ing.amount; break; }
}
}
else if (shipDef)
{
for (const RecipeIngredient& mat : shipDef->blueprint.materials)
{
if (mat.item == entry.first.id) { perCycle = mat.amount; break; }
}
}
bufText += QString::fromStdString(entry.first.id) bufText += QString::fromStdString(entry.first.id)
+ ": " + QString::number(entry.second) + ": " + QString::number(entry.second);
+ "/" + QString::number(capVal) + " "; if (perCycle > 0)
{
bufText += "/" + QString::number(perCycle);
}
bufText += " ";
} }
bufText += "\n"; bufText += "\n";
} }
if (!b->outputBuffer.items.empty())
if (recipe && !recipe->outputs.empty())
{ {
std::map<std::string, int> outCounts; std::map<std::string, int> outCounts;
for (const Item& item : b->outputBuffer.items) for (const Item& item : b->outputBuffer.items)
{ {
outCounts[item.type.id]++; outCounts[item.type.id]++;
} }
bufText += "Output(" + QString::number(static_cast<int>(b->outputBuffer.items.size())) bufText += "Output: ";
+ "/" + QString::number(b->outputBuffer.capacity) + "): "; for (const RecipeOutput& out : recipe->outputs)
{
const std::map<std::string, int>::const_iterator it =
outCounts.find(out.item);
const int count = (it != outCounts.end()) ? it->second : 0;
bufText += QString::fromStdString(out.item)
+ ": " + QString::number(count)
+ "/" + QString::number(out.amount) + " ";
}
}
else if (!b->outputBuffer.items.empty())
{
std::map<std::string, int> outCounts;
for (const Item& item : b->outputBuffer.items)
{
outCounts[item.type.id]++;
}
bufText += "Output: ";
for (const std::pair<const std::string, int>& entry : outCounts) for (const std::pair<const std::string, int>& entry : outCounts)
{ {
bufText += QString::fromStdString(entry.first) bufText += QString::fromStdString(entry.first)
+ ":" + QString::number(entry.second) + " "; + ": " + QString::number(entry.second) + " ";
} }
} }
m_buffersLabel->setText(bufText); m_buffersLabel->setText(bufText);
} }
const RecipeDef* SelectedBuildingPanel::findRecipe(const Building* b) const
{
if (b->recipeId.empty()) { return nullptr; }
for (const RecipeDef& r : m_config->recipes.recipes)
{
if (r.id == b->recipeId && r.building == b->type) { return &r; }
}
return nullptr;
}
const ShipDef* SelectedBuildingPanel::findShipDef(const std::string& id) const
{
if (id.empty()) { return nullptr; }
for (const ShipDef& s : m_config->ships.ships)
{
if (s.id == id) { return &s; }
}
return nullptr;
}
void SelectedBuildingPanel::onStateUpdated(Tick /*tick*/, int /*blocks*/, double /*speed*/)
{
if (m_singleId == kInvalidEntityId) { return; }
const Building* b = m_sim->buildings().findBuilding(m_singleId);
if (!b)
{
buildEmpty();
return;
}
refreshBuffers(b);
}
void SelectedBuildingPanel::buildMulti(const std::vector<EntityId>& ids) void SelectedBuildingPanel::buildMulti(const std::vector<EntityId>& ids)
{ {
m_singleId = kInvalidEntityId; m_singleId = kInvalidEntityId;

View File

@@ -5,8 +5,12 @@
#include <QWidget> #include <QWidget>
#include "Building.h"
#include "EntityId.h" #include "EntityId.h"
#include "GameConfig.h" #include "GameConfig.h"
#include "RecipesConfig.h"
#include "ShipsConfig.h"
#include "Tick.h"
class Simulation; class Simulation;
class QLabel; class QLabel;
@@ -24,6 +28,7 @@ public:
public slots: public slots:
void onSelectionChanged(const std::vector<EntityId>& ids); void onSelectionChanged(const std::vector<EntityId>& ids);
void onStateUpdated(Tick tick, int blocks, double speed);
private slots: private slots:
void onRecipeChanged(int comboIndex); void onRecipeChanged(int comboIndex);
@@ -35,6 +40,9 @@ private:
void buildEmpty(); void buildEmpty();
void buildSingle(EntityId id); void buildSingle(EntityId id);
void buildMulti(const std::vector<EntityId>& ids); void buildMulti(const std::vector<EntityId>& ids);
void refreshBuffers(const Building* b);
const RecipeDef* findRecipe(const Building* b) const;
const ShipDef* findShipDef(const std::string& id) const;
Simulation* m_sim; Simulation* m_sim;
const GameConfig* m_config; const GameConfig* m_config;