Compare commits

..

9 Commits

35 changed files with 1354 additions and 162 deletions

View File

@@ -2,7 +2,7 @@
id = "interceptor"
available_from_start = true
[ship.blueprint]
[ship.schematic]
materials = [{item = "iron_ingot", amount = 3}, {item = "circuit_board", amount = 1}]
player_production_level = 3
production_time_seconds = 10
@@ -29,7 +29,7 @@ scrap_drop = 2
id = "destroyer"
available_from_start = true
[ship.blueprint]
[ship.schematic]
materials = [{item = "iron_ingot", amount = 5}, {item = "circuit_board", amount = 2}]
player_production_level = 5
production_time_seconds = 20
@@ -56,7 +56,7 @@ scrap_drop = 4
id = "salvage_ship"
available_from_start = true
[ship.blueprint]
[ship.schematic]
materials = [{item = "iron_ingot", amount = 4}]
player_production_level = 3
production_time_seconds = 10
@@ -82,7 +82,7 @@ scrap_drop = 2
id = "repair_ship"
available_from_start = false
[ship.blueprint]
[ship.schematic]
materials = [{item = "iron_ingot", amount = 4}, {item = "circuit_board", amount = 2}]
player_production_level = 3
production_time_seconds = 15

View File

@@ -142,7 +142,7 @@ outline = "#201a14"
# Ships
#
# Ships are drawn as oriented triangles/arrows. Color is keyed to role, not
# blueprint (architecture.md, "Layer Order").
# schematic (architecture.md, "Layer Order").
# -----------------------------------------------------------------------------
[ships.player_combat]
@@ -185,7 +185,7 @@ tile_highlight = "#ffffff22" # tile under cursor
selected_outline = "#ffff00" # outline drawn around currently-selected building(s)
# -----------------------------------------------------------------------------
# Blueprint-drop toasts (REQ-UI-BLUEPRINT-TOAST)
# Schematic-drop toasts (REQ-UI-SCHEMATIC-TOAST)
# -----------------------------------------------------------------------------
[toast]

View File

@@ -60,11 +60,11 @@ Simulation types shared across subsystems:
- `Port``struct Port { QPoint tile; Rotation direction; }`. Identifies a belt-adjacent cell and the direction of flow across that cell.
- `MovementIntent``struct MovementIntent { int priority; QVector2D target; }`. Priority follows the order declared under Movement Arbitration. Cleared at the start of each tick; the highest-priority write wins; `tickMovement` reads the winner.
- `FireEvent``struct FireEvent { EntityId shooter; EntityId target; Tick emittedAt; }`. Transient record emitted each time a weapon fires (REQ-SHP-FIRING, REQ-SHP-FIRING-BEAM). Buffered in a sim-owned queue and drained by the renderer; see Sim → UI Events.
- `BlueprintDropEvent``struct BlueprintDropEvent { ShipBlueprintId blueprint; int newLevel; bool wasNewUnlock; }`. Emitted when a destroyed enemy-defence-station set awards a blueprint (REQ-DEF-BLUEPRINT-DROP). The UI renders a toast (REQ-UI-BLUEPRINT-TOAST); `wasNewUnlock` chooses between the "unlocked" and "level → N" wording.
- `SchematicDropEvent``struct SchematicDropEvent { ShipSchematicId schematic; int newLevel; bool wasNewUnlock; }`. Emitted when a destroyed enemy-defence-station set awards a schematic (REQ-DEF-SCHEMATIC-DROP). The UI renders a toast (REQ-UI-SCHEMATIC-TOAST); `wasNewUnlock` chooses between the "unlocked" and "level → N" wording.
## Sim → UI Events
The sim owns a small set of per-frame event queues that the UI drains on each render. These carry one-shot signals that are not derivable from persistent state — currently weapon fires (REQ-SHP-FIRING-BEAM) and blueprint drops (REQ-UI-BLUEPRINT-TOAST). Additional event types can be added here later (e.g., building-complete, unit-death flashes) without changing the pattern.
The sim owns a small set of per-frame event queues that the UI drains on each render. These carry one-shot signals that are not derivable from persistent state — currently weapon fires (REQ-SHP-FIRING-BEAM) and schematic drops (REQ-UI-SCHEMATIC-TOAST). Additional event types can be added here later (e.g., building-complete, unit-death flashes) without changing the pattern.
Implementation: a plain `std::vector<FireEvent>` owned by `Simulation`, one vector per event type. Combat resolution (tick-order step 8) appends to it. The UI calls `simulation.drainFireEvents()` once per rendered frame, which returns the accumulated vector by move and clears the internal one. Beams are tracked by the renderer for 0.3 s of wall time (9 ticks at 30 Hz) using the events' `emittedAt` tick, then discarded. If either the shooter or target entity is gone when the renderer looks them up, the beam is dropped early.
@@ -89,7 +89,7 @@ Within a single simulation tick, subsystems run in this fixed order. The order i
6. **Belt tick** — advance items along belt tiles; apply splitter routing (REQ-BLD-SPLITTER).
7. **Ship behavior systems** — clear `MovementIntent` on each ship, then run `tickThreatResponse`, `tickScrapCollector`, `tickRepairBehavior`, `tickHomeReturn` in any order (arbitration is via intent priority).
8. **Combat resolution** — ships and defence stations acquire targets, fire, apply damage; queue deaths. Each fire appends a `FireEvent` to the sim's fire-event queue (REQ-SHP-FIRING-BEAM).
9. **Deaths & loot** — process queued deaths: drop scrap (REQ-RES-SCRAP-DROP); if a full enemy-defence-station set was destroyed this tick, award one blueprint (REQ-DEF-BLUEPRINT-DROP) and append a `BlueprintDropEvent`; remove entities.
9. **Deaths & loot** — process queued deaths: drop scrap (REQ-RES-SCRAP-DROP); if a full enemy-defence-station set was destroyed this tick, award one schematic (REQ-DEF-SCHEMATIC-DROP) and append a `SchematicDropEvent`; remove entities.
10. **`tickMovement`** — advance ship positions based on final `MovementIntent`.
11. **Scrap despawn** — decrement scrap timers; remove expired scrap (REQ-RES-SCRAP-DROP).
@@ -219,7 +219,7 @@ struct Ship {
float hp;
float maxHp;
int level;
ShipBlueprintId blueprint;
ShipSchematicId schematic;
// Capabilities
std::optional<Weapon> weapon;
@@ -280,7 +280,7 @@ The game world is rendered by a single `GameWorldView` widget that inherits `QOp
### 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 `drainFireEvents()` / `drainBlueprintDropEvents()` / `forEachVisualItem()` APIs already support a clean snapshot-and-render split; a single mutex at the sim boundary would suffice.
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 `drainFireEvents()` / `drainSchematicDropEvents()` / `forEachVisualItem()` APIs already support a clean snapshot-and-render split; a single mutex at the sim boundary would suffice.
### Layer Order (back to front)
@@ -291,7 +291,7 @@ Sim and UI run on the same thread for v1. `paintEvent` reads sim state directly
5. **Ships** — colored arrows oriented by velocity; color keyed to role (player combat / salvage / repair / enemy).
6. **Laser beams** — lines derived from live `FireEvent`s kept by the renderer for 0.3 s (REQ-SHP-FIRING-BEAM).
7. **Build overlays** — ghost in builder mode (REQ-BLD-GHOST), demolish-mode tint, tile highlight under cursor, box-drag selection rectangle.
8. **Screen-space UI**blueprint toasts (REQ-UI-BLUEPRINT-TOAST) and any other screen-anchored elements, drawn after resetting the world-space transform.
8. **Screen-space UI**schematic toasts (REQ-UI-SCHEMATIC-TOAST) and any other screen-anchored elements, drawn after resetting the world-space transform.
### Coordinates and Scrolling

View File

@@ -44,15 +44,15 @@ Two sources feed the same production tree:
| **Smelter** | 2×2 | Converts ore or scrap into basic materials. No recipe selection needed. |
| **Assembler** | 3×3 | Player selects a recipe. Produces building blocks or intermediate/advanced products. |
| **Reprocessing Plant** | 3×3 | Converts scrap into higher-level products with fixed probabilities per product type. |
| **Shipyard** | 4×2 | Player selects a blueprint. Produces that ship type when required materials are available. |
| **Shipyard** | 4×2 | Player selects a schematic. Produces that ship type when required materials are available. |
| **Belt** | 1×1 | Transports materials. Comes in straight and curved variants. |
| **Splitter** | 1×1 | Splits a belt's flow into two outputs. |
## Ships & Shipyards
- Shipyards are built at the asteroid's right edge.
- The player clicks a shipyard to assign a blueprint; the shipyard then automatically produces that ship type whenever the required materials are available.
- New blueprints are unlocked as loot from destroyed enemy defence stations.
- The player clicks a shipyard to assign a schematic; the shipyard then automatically produces that ship type whenever the required materials are available.
- New schematics are unlocked as loot from destroyed enemy defence stations.
- Ships are fully autonomous. Known roles:
- **Combat ships** — travel right and engage enemies.
- **Salvage ships** — fly out, collect scrap from destroyed enemies, and return; vulnerable while operating.
@@ -70,7 +70,7 @@ Two sources feed the same production tree:
- The player is not forced to push; purely defensive play is valid.
- Destroying enemy defence stations applies the push scaling multiplier to all future waves, extends the scrollable area, and places a new (stronger) set of stations at the new boundary.
- Destroyed enemy defence stations drop ship blueprints.
- Destroyed enemy defence stations drop ship schematics.
## Starting Conditions & Game Over

View File

@@ -7,7 +7,7 @@ Config files use the TOML format. The following config files drive game paramete
- **world.toml** — world dimensions, region widths, expansion amounts, building refund percentage, wave timing, enemy ship level formula, belt speed, starting building blocks.
- **buildings.toml** — building block cost and construction time per building type.
- **recipes.toml** — crafting recipes: inputs, outputs, quantities, durations, and reprocessing plant probabilities.
- **ships.toml** — per blueprint: a human-readable display name (used in toasts and UI), ship stats (HP, speed, damage, attack range, attack rate, sensor range) as formulas of ship level, required build materials, threat cost formula, player production level, and whether the blueprint is available from game start.
- **ships.toml** — per schematic: a human-readable display name (used in toasts and UI), ship stats (HP, speed, damage, attack range, attack rate, sensor range) as formulas of ship level, required build materials, threat cost formula, player production level, and whether the schematic is available from game start.
- **stations.toml** — HP, damage, range, fire rate, and scrap drop for player and enemy defence stations, defined as formulas of station level.
- **visuals.toml** — rendering-only config (not game parameters): fill and outline colors and glyphs for every building type, item type, ship role, and station type; beam color and width; overlay and toast colors. Loaded by the UI at startup; the simulation does not read it.
@@ -60,7 +60,7 @@ Output port indicators are not building tiles themselves. A building may have mo
- 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-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-VALID: A placement position is valid only if (a) every footprint cell in the rotated `surface_mask` is satisfied by the underlying terrain — `A` cells coincide with asteroid tiles, `S` cells coincide with space tiles — and (b) no footprint cell overlaps an existing placed building or construction site. Affordability is not re-checked at placement time: builder mode cannot be entered when the player cannot afford the building (REQ-UI-BUILD-DISABLED), so once in builder mode the only placement validity concerns are terrain and overlap. The ghost (REQ-BLD-GHOST) is rendered in a distinct "invalid" color when the current cursor position fails either condition.
- REQ-BLD-PLACE-VALID: A placement position is valid only if (a) every footprint cell in the rotated `surface_mask` is satisfied by the underlying terrain — `A` cells coincide with asteroid tiles, `S` cells coincide with space tiles — (b) no footprint cell overlaps an existing placed building or construction site, and (c) the player has enough building blocks to afford the building. The ghost (REQ-BLD-GHOST) is rendered in a distinct "invalid" color when the current cursor position fails any of these conditions.
- REQ-BLD-BELT-DRAG: For belts, the player can click and drag across multiple tiles to place a construction site on each tile in one gesture.
- REQ-BLD-DEMOLISH: The player can demolish a placed factory building. Demolition returns `world.toml [world].refund_percentage` percent of the original building block cost (default 75%) to the global stock. Exception: if the building is still in the construction queue (not yet fully built, including the one currently being constructed), it is removed from the queue and the **full** building block cost is refunded. The HQ and player defence stations cannot be demolished.
@@ -70,7 +70,7 @@ Output port indicators are not building tiles themselves. A building may have mo
- REQ-BLD-SMELTER: **Smelter** (2×2): Converts ore or scrap into basic materials. No recipe selection required. Inputs, outputs, and rates are defined in `recipes.toml [[recipe]]` entries with `building = "smelter"`.
- REQ-BLD-ASSEMBLER: **Assembler** (3×3): The player selects a recipe from the config-defined crafting tree. Produces the selected output item at the rate defined in the corresponding `recipes.toml [[recipe]]` entry with `building = "assembler"`.
- REQ-BLD-REPROCESSING: **Reprocessing Plant** (3×3): Consumes scrap per cycle and produces exactly one higher-level intermediate product per cycle via weighted random pick. The input quantity, possible output items, per-output weights, and amounts are defined in `recipes.toml [[recipe]]` entries with `building = "reprocessing_plant"` (`inputs`, `outputs[].item`, `outputs[].amount`, `outputs[].weight`). Weights are normalized at load time; their sum does not need to equal 1. The output is rolled at cycle start (see REQ-MAT-CYCLE). The output buffer holds at most one cycle's output — see REQ-MAT-OUTPUT-BUFFER-REPROCESSING.
- REQ-BLD-SHIPYARD: **Shipyard** (4×2): The player selects a blueprint. When all required materials (`[ship.blueprint].materials`) are present in its input buffer, the shipyard consumes them and begins a production cycle lasting `[ship.blueprint].production_time_seconds` seconds (read from `ships.toml`). One ship of that type is spawned at `ships.toml [ship.blueprint].player_production_level` (initial value 5, incremented by duplicate blueprint drops per REQ-DEF-BLUEPRINT-DROP) when the cycle completes. The shipyard cannot start a new cycle while one is in progress.
- REQ-BLD-SHIPYARD: **Shipyard** (4×2): The player selects a schematic. When all required materials (`[ship.schematic].materials`) are present in its input buffer, the shipyard consumes them and begins a production cycle lasting `[ship.schematic].production_time_seconds` seconds (read from `ships.toml`). One ship of that type is spawned at `ships.toml [ship.schematic].player_production_level` (initial value 5, incremented by duplicate schematic drops per REQ-DEF-SCHEMATIC-DROP) when the cycle completes. The shipyard cannot start a new cycle while one is in progress.
- REQ-BLD-SALVAGE-BAY: **Salvage Bay** (3×2): A dedicated drop-off point for salvage ships. Scrap delivered here is placed onto connected output belts.
- REQ-BLD-BELT: **Belt** (1×1): Transports items. A belt tile has one direction (N, S, E, W) set at placement (modified by rotation). Curved belts are auto-derived: when a belt tile's outgoing direction leads into another belt whose direction is orthogonal, the downstream belt is rendered and behaves as a curve. Belt speed is defined in `world.toml [world].belt_speed_tiles_per_second` (REQ-GW-BELT-SPEED).
- REQ-BLD-SPLITTER: **Splitter** (1×1): Distributes incoming items between two output directions. Each output can optionally have a filter (a list of item types), configurable via the selected building panel. Routing rules:
@@ -94,8 +94,8 @@ Output port indicators are not building tiles themselves. A building may have mo
- REQ-MAT-BELT-ONLY: Materials are transported exclusively via belts, splitters, and tunnels.
- REQ-MAT-INPUT-PORTS: A building accepts items from any adjacent belt tile on any edge of its footprint (excluding cells occupied by output port(s)) whose direction points toward the building, provided the item is an input required by the currently selected recipe and the matching per-material input buffer has free space.
- REQ-MAT-OUTPUT-PORT: Each building has one or more fixed output port(s) defined by its surface_mask (direction determined by rotation). Produced items are placed onto the belt at the output port tile regardless of that belt's direction.
- REQ-MAT-INPUT-BUFFER: Each building has one input buffer per required input material. Each per-material buffer holds up to twice that material's per-cycle requirement. When the player selects a new recipe or blueprint, all items in all input buffers are cleared.
- REQ-MAT-OUTPUT-BUFFER: Each building has an output buffer that holds up to twice the quantity produced by one production cycle. If the output buffer is full, production stops until space is available. When the player selects a new recipe or blueprint, all items in the output buffer are cleared (relevant when the adjacent belt is jammed and items have accumulated).
- REQ-MAT-INPUT-BUFFER: Each building has one input buffer per required input material. Each per-material buffer holds up to twice that material's per-cycle requirement. When the player selects a new recipe or schematic, all items in all input buffers are cleared.
- REQ-MAT-OUTPUT-BUFFER: Each building has an output buffer that holds up to twice the quantity produced by one production cycle. If the output buffer is full, production stops until space is available. When the player selects a new recipe or schematic, all items in the output buffer are cleared (relevant when the adjacent belt is jammed and items have accumulated).
- REQ-MAT-OUTPUT-BUFFER-REPROCESSING: Exception to REQ-MAT-OUTPUT-BUFFER — the Reprocessing Plant's output buffer holds at most one cycle's output. This prevents exploits where the player stalls the output belt to force the plant to reroll.
- REQ-MAT-CYCLE: Production cycle lifecycle. When a building is idle, it attempts to start a new cycle: (a) all required inputs must be present in the per-material input buffers, and (b) the cycle's output must fit in the output buffer. For the Reprocessing Plant, the output is picked at cycle start (weighted pick); the cycle only starts if that chosen output fits. On cycle start, inputs are consumed immediately and the production timer begins. On cycle completion, the (already-decided) output is deposited into the output buffer and the building returns to idle.
- REQ-MAT-GLOBAL-STOCK: The building blocks stock is the only global inventory. All other materials exist only in building buffers or on belt tiles.
@@ -108,7 +108,7 @@ Output port indicators are not building tiles themselves. A building may have mo
## Ships
- REQ-SHP-AUTONOMOUS: Ships are produced by shipyards and are fully autonomous once produced.
- REQ-SHP-STATS: All ship stats are defined as formulas of ship level in `ships.toml`: HP (`[ship.health].hp_formula`), speed (`[ship.movement].speed_formula`), damage (`[ship.combat].damage_formula`), attack range (`[ship.combat].attack_range_formula`), attack rate (`[ship.combat].attack_rate_formula`), sensor range (`[ship.sensors].range_formula`). Required build materials (`[ship.blueprint].materials`) and availability from game start (`[[ship]].available_from_start`) are also defined there.
- REQ-SHP-STATS: All ship stats are defined as formulas of ship level in `ships.toml`: HP (`[ship.health].hp_formula`), speed (`[ship.movement].speed_formula`), damage (`[ship.combat].damage_formula`), attack range (`[ship.combat].attack_range_formula`), attack rate (`[ship.combat].attack_rate_formula`), sensor range (`[ship.sensors].range_formula`). Required build materials (`[ship.schematic].materials`) and availability from game start (`[[ship]].available_from_start`) are also defined there.
- REQ-SHP-SPAWN-PLAYER: A ship produced by a shipyard spawns centered on the shipyard's output port tile.
- REQ-SHP-SPAWN-ENEMY: Enemy ships spawn at a uniformly random position within the current enemy buffer zone — random X across the buffer's width and random Y across the world height.
- REQ-SHP-MOVEMENT: Ships move in straight lines toward their current destination at the speed defined by their speed formula. Ship position refers to the ship's center for all range, sensor, and attack checks.
@@ -123,7 +123,7 @@ Output port indicators are not building tiles themselves. A building may have mo
- REQ-SHP-REPAIR: **Repair ships** (player) — patrol by moving forward (rightward, away from the asteroid) while searching sensor range. If a damaged player defence station or player ship enters sensor range, move to it and repair. If an enemy ship enters sensor range while not currently repairing, turn back (move toward the asteroid) until the enemy is no longer in sensor range, then resume patrol. The player can configure the target priority per shipyard:
- Defence stations first / ships first / nearest target.
- REQ-SHP-ENEMY-AI: **Enemy ships** — engage the closest valid target (player defence station, HQ, or player ship) within their sensor range. If no target is in sensor range, they move toward the asteroid (leftward in world coordinates).
- REQ-SHP-BLUEPRINTS: The player selects a blueprint per shipyard by clicking it. New blueprints are unlocked automatically when an enemy defence station set is destroyed (REQ-DEF-BLUEPRINT-DROP) — there is no physical loot to collect.
- REQ-SHP-SCHEMATICS: The player selects a schematic per shipyard by clicking it. New schematics are unlocked automatically when an enemy defence station set is destroyed (REQ-DEF-SCHEMATIC-DROP) — there is no physical loot to collect.
## Defence Stations
@@ -133,14 +133,14 @@ Output port indicators are not building tiles themselves. A building may have mo
- REQ-DEF-ENEMY-PLACEMENT: 2 enemy defence stations are placed at the right boundary of the scrollable area at game start, and again each time a new set is spawned after a push. Stats scale with the station level (REQ-PSH-STATION-STATS).
- REQ-DEF-ENEMY-FIRE: Enemy defence stations automatically fire at player ships within range.
- REQ-DEF-NO-CROSSFIRE: Enemy and player defence stations are never in each other's firing range.
- REQ-DEF-PUSH: When both enemy defence stations in a set are destroyed, the push scaling multiplier is applied (REQ-PSH-ACCUMULATION), the scrollable area is extended (REQ-GW-PUSH-EXPAND), a new set of enemy defence stations is placed at the new boundary, and exactly one blueprint drop is awarded for the destroyed set (REQ-DEF-BLUEPRINT-DROP).
- REQ-DEF-BLUEPRINT-DROP: Each destroyed set of enemy defence stations awards exactly one blueprint drop (not one per station). The drop is automatic — no physical item to collect. A blueprint is chosen uniformly at random from all blueprints defined in `ships.toml`. If the player does not yet have that blueprint, it is unlocked. If the player already has it, the blueprint's `[ship.blueprint].player_production_level` is incremented by 1 — so subsequent ships of that type are produced at a higher level. The player is notified via a toast (REQ-UI-BLUEPRINT-TOAST).
- REQ-DEF-PUSH: When both enemy defence stations in a set are destroyed, the push scaling multiplier is applied (REQ-PSH-ACCUMULATION), the scrollable area is extended (REQ-GW-PUSH-EXPAND), a new set of enemy defence stations is placed at the new boundary, and exactly one schematic drop is awarded for the destroyed set (REQ-DEF-SCHEMATIC-DROP).
- REQ-DEF-SCHEMATIC-DROP: Each destroyed set of enemy defence stations awards exactly one schematic drop (not one per station). The drop is automatic — no physical item to collect. A schematic is chosen uniformly at random from all schematics defined in `ships.toml`. If the player does not yet have that schematic, it is unlocked. If the player already has it, the schematic's `[ship.schematic].player_production_level` is incremented by 1 — so subsequent ships of that type are produced at a higher level. The player is notified via a toast (REQ-UI-SCHEMATIC-TOAST).
## Threat Level & Enemy Waves
- 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-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-TRIGGER: When the gap expires, a wave is triggered. Ships are selected one at a time: from all schematics 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 schematic 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-SPAWN-DURATION: Ships in a wave are spawned one at a time over `world.toml [waves].spawn_duration_seconds`.
@@ -167,16 +167,17 @@ The screen is divided into three vertical sections:
| |
| Game World (70%) |
| |
+-------------------------+------------------------+
| Selected Building Panel | Build Button Grid |
| (left) | (right) |
+-------------------------+------------------------+
+-----------------+-----------------+--------------+
| Selected | Build Button | Blueprint |
| Building Panel | Grid | Panel |
| (left) | (center) | (right) |
+-----------------+-----------------+--------------+
```
- REQ-UI-HEADER: The header bar spans the full width above the game world and always shows the elapsed survival time and the current global building blocks stock on the left, and game speed controls on the right.
- REQ-UI-SPEED: The game speed controls in the header bar are buttons for 0×, 0.5×, 1×, 2×, and 4× speed. The currently active speed is shown as selected. All game simulation (production, movement, threat accumulation, wave timing) scales with the selected speed. 0× pauses the game.
- REQ-UI-WORLD-HEIGHT: The game world view occupies 70% of the remaining screen height below the header bar.
- REQ-UI-PANEL-HEIGHT: The UI panel occupies the remaining 30% of the screen height, split horizontally into a selected building panel (left) and a build button grid (right).
- REQ-UI-PANEL-HEIGHT: The UI panel occupies the remaining 30% of the screen height, split horizontally into a selected building panel (left), a build button grid (center), and a blueprint panel (right).
### Game World
@@ -184,8 +185,8 @@ The screen is divided into three vertical sections:
- REQ-UI-CONSTRUCTION-PROGRESS: Construction sites display the building's glyph centered on the footprint (same as an operational building). Below the glyph — or centered on the footprint if the building has no glyph — a construction progress percentage is shown (integer, e.g. `42%`), increasing from 0% to 100% as construction completes.
- REQ-UI-PORT-GLYPH: Every output port of every building is indicated by a directional glyph drawn on the port's tile. The glyph is a `>` rotated to face the port's exit direction (`>` for East, `^` for North, `<` for West, `v` for South). It is drawn at the midpoint between the tile center and the tile edge that the port exits through (i.e. halfway from center toward the exit edge). The indicator is rendered for all building states: operational buildings, construction sites, and the builder-mode ghost. Buildings with multiple output ports (e.g. splitters) show one indicator per port.
- REQ-UI-NO-ZOOM: The view has a fixed zoom level; the player cannot zoom in or out.
- REQ-UI-BLUEPRINT-TOAST: When a blueprint is unlocked or leveled up (REQ-DEF-BLUEPRINT-DROP), a transient notification toast appears in the top-right corner of the game world view for 4 seconds and then fades out. `<Ship Name>` in the text below is the blueprint's `ships.toml [ship.blueprint].display_name`. Toast text:
- **New unlock**: `Blueprint unlocked: <Ship Name>`
- REQ-UI-SCHEMATIC-TOAST: When a schematic is unlocked or leveled up (REQ-DEF-SCHEMATIC-DROP), a transient notification toast appears in the top-right corner of the game world view for 4 seconds and then fades out. `<Ship Name>` in the text below is the schematic's `ships.toml [ship.schematic].display_name`. Toast text:
- **New unlock**: `Schematic unlocked: <Ship Name>`
- **Level-up (duplicate drop)**: `<Ship Name> production level → N` (where N is the new level).
If multiple toasts arrive in close succession, they stack vertically in a queue (most recent at the top) and each fades out independently after its own 4-second lifetime.
@@ -208,11 +209,11 @@ The screen is divided into three vertical sections:
### Selected Building Panel
- 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. 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-PRODUCTION-PROGRESS: For buildings that produce items or ships (miner, smelter, assembler, reprocessing plant, shipyard), the selected building panel also shows: (a) the cycle time of the currently selected recipe or blueprint in seconds, and (b) the completion percentage of the active production cycle as an integer (e.g. `42%`), or the text `idle` when no production cycle is active. When no recipe or blueprint is selected, neither the cycle time nor the progress indicator is shown.
- REQ-UI-SINGLE-SELECTION: When one building is selected, the panel shows: building name, current recipe or schematic 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-PRODUCTION-PROGRESS: For buildings that produce items or ships (miner, smelter, assembler, reprocessing plant, shipyard), the selected building panel also shows: (a) the cycle time of the currently selected recipe or schematic in seconds, and (b) the completion percentage of the active production cycle as an integer (e.g. `42%`), or the text `idle` when no production cycle is active. When no recipe or schematic is selected, neither the cycle time nor the progress indicator is shown.
- 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-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, schematic, ship stance, and target priority configuration for a selected building is shown and changed inline within this panel.
- REQ-UI-BELT-CLEAR: When one or more belt, splitter, tunnel entry, or tunnel exit tiles are selected, the panel shows a "Clear" button that removes all items from the selected tiles. Clearing a tunnel entry or exit also discards all items currently in transit through that tunnel (REQ-BLD-TUNNEL-TRANSIT). This can be used to resolve stalled belts, splitters, and tunnels.
### Build Button Grid
@@ -221,3 +222,19 @@ The screen is divided into three vertical sections:
- 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-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.
### Blueprint Panel
- REQ-UI-BLUEPRINT-PANEL: The blueprint panel is shown to the right of the build button grid. It contains, from top to bottom: a "Create Blueprint" button, a "Delete Blueprint" button, and a list of blueprint buttons (one per saved blueprint, in creation order).
- REQ-UI-BLUEPRINT-CREATE: The "Create Blueprint" button is enabled only when at least one player-placeable building (i.e. a building with a button in the build button grid) is currently selected; non-player-placeable buildings (HQ, defence stations) in the selection do not count toward this condition. When clicked, a modal dialog appears prompting the player to enter a name. The dialog has Confirm and Cancel buttons. Clicking Cancel closes the dialog with no effect. Clicking Confirm with a non-empty name creates a blueprint from the current selection, silently excluding any non-player-placeable buildings, and appends its button to the blueprint list.
- REQ-UI-BLUEPRINT-STORAGE: A blueprint stores its name and, for each building in the selection, the building type, its rotation, its tile offset (integer dx, dy) from the center of the bounding box of all selected buildings' footprints, and — where applicable — the selected recipe ID (miners and assemblers) or schematic ID (shipyards) at the time of capture. If no recipe or schematic was selected at capture time, none is stored. This structure maps directly to a TOML representation (e.g. one `[[building]]` array entry per constituent building).
- REQ-UI-BLUEPRINT-BUTTON: Each blueprint button displays the blueprint name and, below it, the total building block cost of the blueprint (sum of the individual costs of all constituent buildings). A blueprint button is disabled when the player cannot afford the total cost. Clicking an enabled blueprint button enters blueprint placement mode for that blueprint.
- REQ-UI-BLUEPRINT-MODE: In blueprint placement mode a ghost is rendered for every building in the blueprint at the position determined by its stored tile offset from the bounding-box center, which is anchored to the tile under the cursor. Each ghost is rendered individually as valid or invalid, applying REQ-BLD-PLACE-VALID conditions (a) and (b) per building (the other ghosts in the same blueprint do not count as existing buildings for the overlap check). Pressing Q/E rotates the entire constellation 90° counter-clockwise / clockwise: each building's tile offset is rotated around the bounding-box center and each building's own rotation is updated, consistent with REQ-BLD-ROTATE. Blueprint placement mode is exited by right-clicking in the game world. Clicking a different blueprint button exits the current mode and enters blueprint placement mode for the newly clicked blueprint.
- REQ-UI-BLUEPRINT-PLACE: Left-clicking in blueprint placement mode places the blueprint if (a) every building in the constellation satisfies REQ-BLD-PLACE-VALID conditions (a) and (b) at its resolved tile, and (b) the player has enough building blocks to afford the total cost. If both conditions are met, a construction site is added to the build queue for each building in the blueprint and the full total cost is deducted from the global building blocks stock in one transaction. If a recipe ID is stored for a building, it is applied to the construction site immediately. If a schematic ID is stored, it is applied only if that schematic is currently unlocked; if it is not unlocked, the shipyard's schematic is left unset. After a successful placement the game remains in blueprint placement mode, allowing the player to place the same blueprint again immediately.
- REQ-UI-BLUEPRINT-DELETE: Clicking the "Delete Blueprint" button toggles delete mode on and off. While delete mode is active the button is shown in a visually active/pressed state. Clicking any blueprint button while delete mode is active removes that blueprint from the list and automatically exits delete mode.

View File

@@ -358,17 +358,17 @@ ShipsConfig ConfigLoader::loadShips(const std::string& path)
def.id = requireString(mt["id"], file, elemPath + ".id");
def.availableFromStart = requireBool(mt["available_from_start"], file, elemPath + ".available_from_start");
// Blueprint
// Schematic
{
const std::string bpPath = elemPath + ".blueprint";
const toml::table& bpTable = requireTable(mt["blueprint"], file, bpPath);
const std::string bpPath = elemPath + ".schematic";
const toml::table& bpTable = requireTable(mt["schematic"], file, bpPath);
toml::table& bpMt = const_cast<toml::table&>(bpTable);
const toml::array& materials = requireArray(bpMt["materials"], file, bpPath + ".materials");
def.blueprint.materials = parseIngredients(materials, file, bpPath + ".materials");
def.blueprint.playerProductionLevel = static_cast<int>(requireInt(
def.schematic.materials = parseIngredients(materials, file, bpPath + ".materials");
def.schematic.playerProductionLevel = static_cast<int>(requireInt(
bpMt["player_production_level"], file, bpPath + ".player_production_level"));
def.blueprint.productionTimeSeconds = requireDouble(
def.schematic.productionTimeSeconds = requireDouble(
bpMt["production_time_seconds"], file, bpPath + ".production_time_seconds");
}

View File

@@ -7,9 +7,9 @@
#include "Formula.h"
#include "RecipesConfig.h" // for RecipeIngredient
// Build materials and initial per-blueprint production level
// (REQ-BLD-SHIPYARD, REQ-DEF-BLUEPRINT-DROP).
struct ShipBlueprint
// Build materials and initial per-schematic production level
// (REQ-BLD-SHIPYARD, REQ-DEF-SCHEMATIC-DROP).
struct ShipSchematic
{
std::vector<RecipeIngredient> materials;
int playerProductionLevel;
@@ -65,7 +65,7 @@ struct ShipDef
std::string id;
bool availableFromStart;
ShipBlueprint blueprint;
ShipSchematic schematic;
ShipThreat threat;
ShipHealth health;
ShipMovement movement;

24
src/lib/core/Blueprint.h Normal file
View File

@@ -0,0 +1,24 @@
#pragma once
#include <string>
#include <vector>
#include <QPoint>
#include <QString>
#include "BuildingType.h"
#include "Rotation.h"
struct BlueprintBuilding
{
BuildingType type;
Rotation rotation;
QPoint offset; // tile offset from bounding-box center (floor for even sizes)
std::string recipeId; // empty = none selected
};
struct Blueprint
{
QString name;
std::vector<BlueprintBuilding> buildings;
};

View File

@@ -1,14 +0,0 @@
#pragma once
#include <string>
// Emitted in tick step 9 (Deaths & loot) when a destroyed enemy-defence-station
// set awards a blueprint (REQ-DEF-BLUEPRINT-DROP). The UI renders a toast
// (REQ-UI-BLUEPRINT-TOAST); wasNewUnlock chooses between the "unlocked" and
// "level -> N" wording.
struct BlueprintDropEvent
{
std::string blueprintId; // matches ShipDef::id in the config.
int newLevel;
bool wasNewUnlock;
};

View File

@@ -4,12 +4,13 @@ SET(HDRS
${CMAKE_CURRENT_SOURCE_DIR}/EntityId.h
${CMAKE_CURRENT_SOURCE_DIR}/Rotation.h
${CMAKE_CURRENT_SOURCE_DIR}/BuildingType.h
${CMAKE_CURRENT_SOURCE_DIR}/Blueprint.h
${CMAKE_CURRENT_SOURCE_DIR}/ItemType.h
${CMAKE_CURRENT_SOURCE_DIR}/Item.h
${CMAKE_CURRENT_SOURCE_DIR}/Port.h
${CMAKE_CURRENT_SOURCE_DIR}/MovementIntent.h
${CMAKE_CURRENT_SOURCE_DIR}/FireEvent.h
${CMAKE_CURRENT_SOURCE_DIR}/BlueprintDropEvent.h
${CMAKE_CURRENT_SOURCE_DIR}/SchematicDropEvent.h
PARENT_SCOPE
)

View File

@@ -0,0 +1,14 @@
#pragma once
#include <string>
// Emitted in tick step 9 (Deaths & loot) when a destroyed enemy-defence-station
// set awards a schematic (REQ-DEF-SCHEMATIC-DROP). The UI renders a toast
// (REQ-UI-SCHEMATIC-TOAST); wasNewUnlock chooses between the "unlocked" and
// "level -> N" wording.
struct SchematicDropEvent
{
std::string schematicId; // matches ShipDef::id in the config.
int newLevel;
bool wasNewUnlock;
};

View File

@@ -111,7 +111,7 @@ void BuildingSystem::initShipyardBuffers(Building& b) const
{
return;
}
for (const RecipeIngredient& ing : def->blueprint.materials)
for (const RecipeIngredient& ing : def->schematic.materials)
{
const ItemType type{ing.item};
b.inputBuffer.counts[type] = 0;
@@ -666,7 +666,7 @@ void BuildingSystem::tickShipyardProduction(Tick currentTick)
// Idle: check if all materials are available to start a new cycle.
bool inputsOk = true;
for (const RecipeIngredient& ing : shipDef->blueprint.materials)
for (const RecipeIngredient& ing : shipDef->schematic.materials)
{
const ItemType type{ing.item};
const std::map<ItemType, int>::const_iterator it =
@@ -684,7 +684,7 @@ void BuildingSystem::tickShipyardProduction(Tick currentTick)
}
// Consume materials and start the production cycle.
for (const RecipeIngredient& ing : shipDef->blueprint.materials)
for (const RecipeIngredient& ing : shipDef->schematic.materials)
{
building.inputBuffer.counts[ItemType{ing.item}] -= ing.amount;
}
@@ -692,7 +692,7 @@ void BuildingSystem::tickShipyardProduction(Tick currentTick)
Production prod;
prod.recipeId = building.recipeId;
prod.completesAt = currentTick
+ secondsToTicks(shipDef->blueprint.productionTimeSeconds);
+ secondsToTicks(shipDef->schematic.productionTimeSeconds);
building.production = std::move(prod);
}
}

View File

@@ -46,7 +46,7 @@ public:
// unknown ids.
int demolish(EntityId id);
// Set the recipe (or blueprint id for shipyard) on a building or queued
// Set the recipe (or schematic id for shipyard) on a building or queued
// construction site. Clears both buffers on an operational building.
void setRecipe(EntityId id, const std::string& recipeId);

View File

@@ -75,7 +75,7 @@ struct Ship
float maxHp;
float speedPerTick; // pre-evaluated from speedFormula / kTickRateHz
int level;
std::string blueprintId;
std::string schematicId;
bool isEnemy = false; // true for enemy-faction ships (used by behavior systems)

View File

@@ -18,11 +18,11 @@ ShipSystem::ShipSystem(const GameConfig& config,
{
}
const ShipDef* ShipSystem::findShipDef(const std::string& blueprintId) const
const ShipDef* ShipSystem::findShipDef(const std::string& schematicId) const
{
for (const ShipDef& def : m_config.ships.ships)
{
if (def.id == blueprintId)
if (def.id == schematicId)
{
return &def;
}
@@ -30,10 +30,10 @@ const ShipDef* ShipSystem::findShipDef(const std::string& blueprintId) const
return nullptr;
}
EntityId ShipSystem::spawn(const std::string& blueprintId, int level, QVector2D position,
EntityId ShipSystem::spawn(const std::string& schematicId, int level, QVector2D position,
bool isEnemy)
{
const ShipDef* def = findShipDef(blueprintId);
const ShipDef* def = findShipDef(schematicId);
assert(def != nullptr);
const double x = static_cast<double>(level);
@@ -48,7 +48,7 @@ EntityId ShipSystem::spawn(const std::string& blueprintId, int level, QVector2D
def->movement.speedFormula.evaluate(x))
/ static_cast<float>(kTickRateHz);
ship.level = level;
ship.blueprintId = blueprintId;
ship.schematicId = schematicId;
ship.isEnemy = isEnemy;
ship.intent = MovementIntent{0, QVector2D(0.0f, 0.0f)};

View File

@@ -19,7 +19,7 @@ public:
std::function<EntityId()> allocateId);
// isEnemy defaults to false; set true for enemy-faction ships (step 7 wave spawning).
EntityId spawn(const std::string& blueprintId, int level, QVector2D position,
EntityId spawn(const std::string& schematicId, int level, QVector2D position,
bool isEnemy = false);
void despawn(EntityId id);
@@ -52,7 +52,7 @@ public:
bool damageShip(EntityId id, float amount);
private:
const ShipDef* findShipDef(const std::string& blueprintId) const;
const ShipDef* findShipDef(const std::string& schematicId) const;
// True if the entity identified by id is alive and within range of ship.
// Searches both the ship list and (for buildings) the supplied BuildingSystem.

View File

@@ -30,9 +30,9 @@ Simulation::Simulation(GameConfig config, unsigned int seed)
[this]() { return allocateId(); },
[this](int amount) { m_buildingBlocksStock += amount; },
[this](const std::string& id, QVector2D pos) {
const std::map<std::string, BlueprintState>::const_iterator it =
m_blueprintLevels.find(id);
if (it == m_blueprintLevels.end() || !it->second.unlocked)
const std::map<std::string, SchematicState>::const_iterator it =
m_schematicLevels.find(id);
if (it == m_schematicLevels.end() || !it->second.unlocked)
{
return;
}
@@ -44,13 +44,13 @@ Simulation::Simulation(GameConfig config, unsigned int seed)
m_waveSystem = std::make_unique<WaveSystem>(m_config, m_rng);
m_combatSystem = std::make_unique<CombatSystem>(m_config);
// Initialize blueprint unlock state.
// Initialize schematic unlock state.
for (const ShipDef& def : m_config.ships.ships)
{
BlueprintState state;
SchematicState state;
state.unlocked = def.availableFromStart;
state.level = def.availableFromStart ? def.blueprint.playerProductionLevel : 0;
m_blueprintLevels[def.id] = state;
state.level = def.availableFromStart ? def.schematic.playerProductionLevel : 0;
m_schematicLevels[def.id] = state;
}
placeInitialStructures();
@@ -82,7 +82,7 @@ void Simulation::reset(unsigned int seed)
m_currentEnemyStationIds[0] = kInvalidEntityId;
m_currentEnemyStationIds[1] = kInvalidEntityId;
m_fireEvents.clear();
m_blueprintDropEvents.clear();
m_schematicDropEvents.clear();
m_beltSystem = BeltSystem(m_config.world.beltSpeedTilesPerSecond);
m_buildingSystem = std::make_unique<BuildingSystem>(
@@ -91,9 +91,9 @@ void Simulation::reset(unsigned int seed)
[this]() { return allocateId(); },
[this](int amount) { m_buildingBlocksStock += amount; },
[this](const std::string& id, QVector2D pos) {
const std::map<std::string, BlueprintState>::const_iterator it =
m_blueprintLevels.find(id);
if (it == m_blueprintLevels.end() || !it->second.unlocked)
const std::map<std::string, SchematicState>::const_iterator it =
m_schematicLevels.find(id);
if (it == m_schematicLevels.end() || !it->second.unlocked)
{
return;
}
@@ -105,13 +105,13 @@ void Simulation::reset(unsigned int seed)
m_waveSystem = std::make_unique<WaveSystem>(m_config, m_rng);
m_combatSystem = std::make_unique<CombatSystem>(m_config);
m_blueprintLevels.clear();
m_schematicLevels.clear();
for (const ShipDef& def : m_config.ships.ships)
{
BlueprintState state;
SchematicState state;
state.unlocked = def.availableFromStart;
state.level = def.availableFromStart ? def.blueprint.playerProductionLevel : 0;
m_blueprintLevels[def.id] = state;
state.level = def.availableFromStart ? def.schematic.playerProductionLevel : 0;
m_schematicLevels[def.id] = state;
}
placeInitialStructures();
@@ -290,7 +290,7 @@ void Simulation::tickDeathsAndLoot()
// Look up scrap drop amount from config.
for (const ShipDef& def : m_config.ships.ships)
{
if (def.id == s->blueprintId && def.loot.scrapDrop > 0)
if (def.id == s->schematicId && def.loot.scrapDrop > 0)
{
const Tick despawnAt = m_currentTick
+ secondsToTicks(m_config.world.scrapDespawnSeconds);
@@ -366,11 +366,11 @@ void Simulation::tickDeathsAndLoot()
{
m_waveSystem->applyPush();
placeEnemyStationSet(m_waveSystem->generation());
awardBlueprintDrop();
awardSchematicDrop();
}
}
void Simulation::awardBlueprintDrop()
void Simulation::awardSchematicDrop()
{
std::vector<std::string> ids;
ids.reserve(m_config.ships.ships.size());
@@ -382,16 +382,16 @@ void Simulation::awardBlueprintDrop()
std::uniform_int_distribution<int> dist(0, static_cast<int>(ids.size()) - 1);
const std::string chosen = ids[static_cast<std::size_t>(dist(m_rng))];
BlueprintState& state = m_blueprintLevels.at(chosen);
SchematicState& state = m_schematicLevels.at(chosen);
const bool wasNew = !state.unlocked;
state.unlocked = true;
state.level += 1;
BlueprintDropEvent evt;
evt.blueprintId = chosen;
SchematicDropEvent evt;
evt.schematicId = chosen;
evt.newLevel = state.level;
evt.wasNewUnlock = wasNew;
m_blueprintDropEvents.push_back(evt);
m_schematicDropEvents.push_back(evt);
}
// ---------------------------------------------------------------------------
@@ -405,10 +405,10 @@ std::vector<FireEvent> Simulation::drainFireEvents()
return result;
}
std::vector<BlueprintDropEvent> Simulation::drainBlueprintDropEvents()
std::vector<SchematicDropEvent> Simulation::drainSchematicDropEvents()
{
std::vector<BlueprintDropEvent> result;
result.swap(m_blueprintDropEvents);
std::vector<SchematicDropEvent> result;
result.swap(m_schematicDropEvents);
return result;
}
@@ -436,22 +436,22 @@ double Simulation::threatLevel() const
return m_waveSystem->threatLevel();
}
int Simulation::blueprintLevel(const std::string& shipId) const
int Simulation::schematicLevel(const std::string& shipId) const
{
const std::map<std::string, BlueprintState>::const_iterator it =
m_blueprintLevels.find(shipId);
if (it == m_blueprintLevels.end())
const std::map<std::string, SchematicState>::const_iterator it =
m_schematicLevels.find(shipId);
if (it == m_schematicLevels.end())
{
return 0;
}
return it->second.level;
}
bool Simulation::isBlueprintUnlocked(const std::string& shipId) const
bool Simulation::isSchematicUnlocked(const std::string& shipId) const
{
const std::map<std::string, BlueprintState>::const_iterator it =
m_blueprintLevels.find(shipId);
if (it == m_blueprintLevels.end())
const std::map<std::string, SchematicState>::const_iterator it =
m_schematicLevels.find(shipId);
if (it == m_schematicLevels.end())
{
return false;
}

View File

@@ -9,7 +9,7 @@
#include <QPoint>
#include "BeltSystem.h"
#include "BlueprintDropEvent.h"
#include "SchematicDropEvent.h"
#include "BuildingType.h"
#include "EntityId.h"
#include "FireEvent.h"
@@ -44,17 +44,17 @@ public:
// internal queue. Call once per rendered frame (REQ-SHP-FIRING-BEAM).
std::vector<FireEvent> drainFireEvents();
// Returns all blueprint drop events since the last drain.
std::vector<BlueprintDropEvent> drainBlueprintDropEvents();
// Returns all schematic drop events since the last drain.
std::vector<SchematicDropEvent> drainSchematicDropEvents();
Tick currentTick() const;
int buildingBlocksStock() const;
bool isGameOver() const;
double threatLevel() const;
// Blueprint state queries.
int blueprintLevel(const std::string& shipId) const;
bool isBlueprintUnlocked(const std::string& shipId) const;
// Schematic state queries.
int schematicLevel(const std::string& shipId) const;
bool isSchematicUnlocked(const std::string& shipId) const;
// Checks affordability, deducts building blocks, and places the building.
// Returns the new entity id, or kInvalidEntityId if blocks are insufficient.
@@ -85,8 +85,8 @@ private:
// Tick step 9: remove dead ships and buildings, drop scrap, handle push.
void tickDeathsAndLoot();
// Award a random blueprint drop (REQ-DEF-BLUEPRINT-DROP) and emit the event.
void awardBlueprintDrop();
// Award a random schematic drop (REQ-DEF-SCHEMATIC-DROP) and emit the event.
void awardSchematicDrop();
GameConfig m_config;
std::mt19937 m_rng;
@@ -102,13 +102,13 @@ private:
EntityId m_playerStation2Id;
EntityId m_currentEnemyStationIds[2];
// Blueprint unlock state (REQ-DEF-BLUEPRINT-DROP).
struct BlueprintState
// Schematic unlock state (REQ-DEF-SCHEMATIC-DROP).
struct SchematicState
{
bool unlocked;
int level;
};
std::map<std::string, BlueprintState> m_blueprintLevels;
std::map<std::string, SchematicState> m_schematicLevels;
BeltSystem m_beltSystem;
std::unique_ptr<BuildingSystem> m_buildingSystem;
@@ -118,5 +118,5 @@ private:
std::unique_ptr<CombatSystem> m_combatSystem;
std::vector<FireEvent> m_fireEvents;
std::vector<BlueprintDropEvent> m_blueprintDropEvents;
std::vector<SchematicDropEvent> m_schematicDropEvents;
};

View File

@@ -33,7 +33,7 @@ void WaveSystem::tickWaveScheduler(Tick currentTick, ShipSystem& ships,
{
if (currentTick >= entry.spawnAt)
{
ships.spawn(entry.blueprintId, entry.level, entry.position,
ships.spawn(entry.schematicId, entry.level, entry.position,
/*isEnemy=*/true);
}
else
@@ -90,7 +90,7 @@ std::vector<WaveSystem::SpawnEntry> WaveSystem::composeWave(Tick currentTick,
// Build eligible ship list with their costs at the current level.
struct EligibleShip
{
std::string blueprintId;
std::string schematicId;
double cost;
};
std::vector<EligibleShip> eligible;
@@ -100,7 +100,7 @@ std::vector<WaveSystem::SpawnEntry> WaveSystem::composeWave(Tick currentTick,
if (cost > 0.0)
{
EligibleShip es;
es.blueprintId = def.id;
es.schematicId = def.id;
es.cost = cost;
eligible.push_back(es);
}
@@ -151,7 +151,7 @@ std::vector<WaveSystem::SpawnEntry> WaveSystem::composeWave(Tick currentTick,
budget -= chosen.cost;
SpawnEntry entry;
entry.blueprintId = chosen.blueprintId;
entry.schematicId = chosen.schematicId;
entry.level = shipLevel;
entry.spawnAt = 0; // set below after all picks are done
entry.position = QVector2D(xDist(m_rng),

View File

@@ -40,7 +40,7 @@ public:
private:
struct SpawnEntry
{
std::string blueprintId;
std::string schematicId;
int level;
Tick spawnAt;
QVector2D position;

667
src/test/BlueprintTest.cpp Normal file
View File

@@ -0,0 +1,667 @@
#include "catch.hpp"
#include <algorithm>
#include <climits>
#include <vector>
#include <QPoint>
#include "Blueprint.h"
#include "Building.h"
#include "BuildingsConfig.h"
#include "BuildingSystem.h"
#include "BuildingType.h"
#include "ConfigLoader.h"
#include "EntityId.h"
#include "Rotation.h"
#include "Simulation.h"
#include "SurfaceMask.h"
#include "Tick.h"
// ---------------------------------------------------------------------------
// Helpers that mirror the production implementations under test.
// ---------------------------------------------------------------------------
// Mirror of the CW / CCW offset transforms in GameWorldView::keyPressEvent.
static QPoint rotateCW(QPoint p) { return QPoint(-p.y(), p.x()); }
static QPoint rotateCCW(QPoint p) { return QPoint( p.y(), -p.x()); }
// Mirror of the Rotation cycling in GameWorldView (anonymous-namespace helpers).
static Rotation rotCW(Rotation r)
{
switch (r)
{
case Rotation::North: return Rotation::East;
case Rotation::East: return Rotation::South;
case Rotation::South: return Rotation::West;
case Rotation::West: return Rotation::North;
}
return Rotation::East;
}
static Rotation rotCCW(Rotation r)
{
switch (r)
{
case Rotation::North: return Rotation::West;
case Rotation::East: return Rotation::North;
case Rotation::South: return Rotation::East;
case Rotation::West: return Rotation::South;
}
return Rotation::East;
}
// Mirror of BlueprintPanel::createBlueprintFromSelection: given per-building
// (anchor, bodyCells, type, rotation), compute Blueprint with floor-division
// bounding-box center and per-building tile offsets.
struct BuildingSpec
{
QPoint anchor;
std::vector<QPoint> bodyCells;
BuildingType type;
Rotation rotation;
std::string recipeId; // empty = none
};
static Blueprint buildBlueprint(const std::vector<BuildingSpec>& specs)
{
int minX = INT_MAX, maxX = INT_MIN;
int minY = INT_MAX, maxY = INT_MIN;
for (const BuildingSpec& s : specs)
{
for (const QPoint& cell : s.bodyCells)
{
minX = std::min(minX, cell.x());
maxX = std::max(maxX, cell.x());
minY = std::min(minY, cell.y());
maxY = std::max(maxY, cell.y());
}
}
const QPoint center((minX + maxX) / 2, (minY + maxY) / 2);
Blueprint bp;
for (const BuildingSpec& s : specs)
{
BlueprintBuilding bb;
bb.type = s.type;
bb.rotation = s.rotation;
bb.offset = s.anchor - center;
bb.recipeId = s.recipeId;
bp.buildings.push_back(bb);
}
return bp;
}
// Apply one CW/CCW rotation to every building in the constellation, mirroring
// the corrected Q / E handling in GameWorldView::keyPressEvent.
// Each building's anchor is recomputed from the rotated body cells so that
// multi-tile footprints stay correctly aligned after rotation.
static void applyRotationCW(Blueprint& bp, const GameConfig& cfg)
{
for (BlueprintBuilding& bb : bp.buildings)
{
const BuildingDef* def = nullptr;
for (const BuildingDef& d : cfg.buildings.buildings)
{
if (d.type == bb.type) { def = &d; break; }
}
if (!def) { continue; }
const ParsedSurfaceMask mask = parseSurfaceMask(def->surfaceMask, bb.rotation);
int minX = INT_MAX, minY = INT_MAX;
for (const QPoint& cell : mask.bodyCells)
{
const QPoint abs = bb.offset + cell;
minX = std::min(minX, -abs.y());
minY = std::min(minY, abs.x());
}
bb.offset = QPoint(minX, minY);
bb.rotation = rotCW(bb.rotation);
}
}
static void applyRotationCCW(Blueprint& bp, const GameConfig& cfg)
{
for (BlueprintBuilding& bb : bp.buildings)
{
const BuildingDef* def = nullptr;
for (const BuildingDef& d : cfg.buildings.buildings)
{
if (d.type == bb.type) { def = &d; break; }
}
if (!def) { continue; }
const ParsedSurfaceMask mask = parseSurfaceMask(def->surfaceMask, bb.rotation);
int minX = INT_MAX, minY = INT_MAX;
for (const QPoint& cell : mask.bodyCells)
{
const QPoint abs = bb.offset + cell;
minX = std::min(minX, abs.y());
minY = std::min(minY, -abs.x());
}
bb.offset = QPoint(minX, minY);
bb.rotation = rotCCW(bb.rotation);
}
}
static GameConfig loadConfig()
{
return ConfigLoader::loadFromDirectory(DOTA_FACTORY_CONFIG_DIR);
}
// Mirrors BlueprintPanel::createBlueprintFromSelection's player-placeable filter:
// building types absent from buildings.toml (HQ, stations) or with playerPlaceable=false
// are silently excluded before the bounding-box center and offsets are computed.
static Blueprint buildBlueprintFiltered(const std::vector<BuildingSpec>& specs,
const GameConfig& cfg)
{
std::vector<BuildingSpec> filtered;
for (const BuildingSpec& s : specs)
{
for (const BuildingDef& def : cfg.buildings.buildings)
{
if (def.type == s.type)
{
if (def.playerPlaceable) { filtered.push_back(s); }
break;
}
}
// If the type is not in buildings (e.g. Hq, defence stations), it is skipped.
}
return buildBlueprint(filtered);
}
// ---------------------------------------------------------------------------
// Offset computation
// ---------------------------------------------------------------------------
TEST_CASE("Blueprint: single 1x1 building gets zero offset", "[blueprint]")
{
// Body = one tile at (-5, 3). Center = (-5, 3). Offset = (0, 0).
const BuildingSpec spec{ QPoint(-5, 3), {QPoint(-5, 3)}, BuildingType::Belt, Rotation::East };
const Blueprint bp = buildBlueprint({ spec });
REQUIRE(bp.buildings.size() == 1);
REQUIRE(bp.buildings[0].offset == QPoint(0, 0));
}
TEST_CASE("Blueprint: two 1x1 buildings with odd span get symmetric offsets", "[blueprint]")
{
// Anchors at (-6, 0) and (-4, 0). bboxX = [-6, -4], center.x = -5.
// Offsets: -6 - (-5) = -1, -4 - (-5) = +1.
const BuildingSpec left { QPoint(-6, 0), {QPoint(-6, 0)}, BuildingType::Belt, Rotation::East };
const BuildingSpec right{ QPoint(-4, 0), {QPoint(-4, 0)}, BuildingType::Belt, Rotation::East };
const Blueprint bp = buildBlueprint({ left, right });
REQUIRE(bp.buildings[0].offset == QPoint(-1, 0));
REQUIRE(bp.buildings[1].offset == QPoint( 1, 0));
}
TEST_CASE("Blueprint: even span uses C++ integer truncation for center", "[blueprint]")
{
// Anchors at (-5, 0) and (-4, 0). bboxX = [-5, -4], sum = -9.
// center.x = -9 / 2 = -4 (C++ truncates toward zero, i.e. rounds up for negatives).
// Offsets: -5 - (-4) = -1, -4 - (-4) = 0.
const BuildingSpec left { QPoint(-5, 0), {QPoint(-5, 0)}, BuildingType::Belt, Rotation::East };
const BuildingSpec right{ QPoint(-4, 0), {QPoint(-4, 0)}, BuildingType::Belt, Rotation::East };
const Blueprint bp = buildBlueprint({ left, right });
REQUIRE(bp.buildings[0].offset == QPoint(-1, 0));
REQUIRE(bp.buildings[1].offset == QPoint( 0, 0));
}
TEST_CASE("Blueprint: bounding box is computed from body cells, not anchors alone", "[blueprint]")
{
// A 2x1 building: anchor (-6, 0), body cells (-6, 0) and (-5, 0).
// A 1x1 building: anchor (-3, 0), body cell (-3, 0).
// All body cells: {(-6,0),(-5,0),(-3,0)}. bboxX = [-6, -3], sum = -9.
// center.x = -9 / 2 = -4 (C++ truncation).
// Wide building anchor offset: -6 - (-4) = -2.
// Small building anchor offset: -3 - (-4) = +1.
const BuildingSpec wide{
QPoint(-6, 0),
{ QPoint(-6, 0), QPoint(-5, 0) },
BuildingType::Belt,
Rotation::East
};
const BuildingSpec small{
QPoint(-3, 0),
{ QPoint(-3, 0) },
BuildingType::Belt,
Rotation::East
};
const Blueprint bp = buildBlueprint({ wide, small });
REQUIRE(bp.buildings[0].offset == QPoint(-2, 0));
REQUIRE(bp.buildings[1].offset == QPoint( 1, 0));
}
TEST_CASE("Blueprint: 2-D bounding box with buildings on both axes", "[blueprint]")
{
// Four 1x1 buildings at corners of a 4x2 rectangle (-7,0),(-5,0),(-7,2),(-5,2).
// bboxX = [-7, -5], center.x = -6. bboxY = [0, 2], center.y = 1.
// Offsets: (-1,-1), (1,-1), (-1,1), (1,1).
const auto belt = BuildingType::Belt;
const auto east = Rotation::East;
std::vector<BuildingSpec> specs = {
{ QPoint(-7, 0), {QPoint(-7, 0)}, belt, east },
{ QPoint(-5, 0), {QPoint(-5, 0)}, belt, east },
{ QPoint(-7, 2), {QPoint(-7, 2)}, belt, east },
{ QPoint(-5, 2), {QPoint(-5, 2)}, belt, east },
};
const Blueprint bp = buildBlueprint(specs);
REQUIRE(bp.buildings[0].offset == QPoint(-1, -1));
REQUIRE(bp.buildings[1].offset == QPoint( 1, -1));
REQUIRE(bp.buildings[2].offset == QPoint(-1, 1));
REQUIRE(bp.buildings[3].offset == QPoint( 1, 1));
}
// ---------------------------------------------------------------------------
// Offset rotation math
// ---------------------------------------------------------------------------
TEST_CASE("Blueprint: CW rotation in screen space maps right→down, down→left", "[blueprint]")
{
// Screen space has Y growing downward. CW means: right→down, down→left, left→up, up→right.
REQUIRE(rotateCW(QPoint( 1, 0)) == QPoint( 0, 1));
REQUIRE(rotateCW(QPoint( 0, 1)) == QPoint(-1, 0));
REQUIRE(rotateCW(QPoint(-1, 0)) == QPoint( 0, -1));
REQUIRE(rotateCW(QPoint( 0, -1)) == QPoint( 1, 0));
}
TEST_CASE("Blueprint: CCW rotation is the inverse of CW rotation", "[blueprint]")
{
REQUIRE(rotateCCW(QPoint( 1, 0)) == QPoint( 0, -1));
REQUIRE(rotateCCW(QPoint( 0, -1)) == QPoint(-1, 0));
REQUIRE(rotateCCW(QPoint(-1, 0)) == QPoint( 0, 1));
REQUIRE(rotateCCW(QPoint( 0, 1)) == QPoint( 1, 0));
}
TEST_CASE("Blueprint: four CW rotations restore any offset to its original", "[blueprint]")
{
const QPoint original(-3, 5);
QPoint p = original;
for (int i = 0; i < 4; ++i) { p = rotateCW(p); }
REQUIRE(p == original);
}
TEST_CASE("Blueprint: CW followed by CCW is identity", "[blueprint]")
{
const QPoint original(2, -7);
REQUIRE(rotateCCW(rotateCW(original)) == original);
REQUIRE(rotateCW(rotateCCW(original)) == original);
}
TEST_CASE("Blueprint: non-axis-aligned offset rotates correctly", "[blueprint]")
{
// (2, 3) → CW → (-3, 2) → CW → (-2, -3) → CW → (3, -2) → CW → (2, 3)
QPoint p(2, 3);
p = rotateCW(p); REQUIRE(p == QPoint(-3, 2));
p = rotateCW(p); REQUIRE(p == QPoint(-2, -3));
p = rotateCW(p); REQUIRE(p == QPoint( 3, -2));
p = rotateCW(p); REQUIRE(p == QPoint( 2, 3));
}
// ---------------------------------------------------------------------------
// Constellation rotation: offsets AND building rotations both update
// ---------------------------------------------------------------------------
TEST_CASE("Blueprint: CW constellation rotation updates offset and building rotation", "[blueprint]")
{
const GameConfig cfg = loadConfig();
// Building one tile to the right, facing East.
Blueprint bp;
bp.name = "test";
BlueprintBuilding bb;
bb.type = BuildingType::Belt;
bb.rotation = Rotation::East;
bb.offset = QPoint(1, 0);
bp.buildings.push_back(bb);
applyRotationCW(bp, cfg);
// Offset: right → down.
REQUIRE(bp.buildings[0].offset == QPoint(0, 1));
// Building rotation: East → South.
REQUIRE(bp.buildings[0].rotation == Rotation::South);
}
TEST_CASE("Blueprint: CCW constellation rotation updates offset and building rotation", "[blueprint]")
{
const GameConfig cfg = loadConfig();
Blueprint bp;
bp.name = "test";
BlueprintBuilding bb;
bb.type = BuildingType::Belt;
bb.rotation = Rotation::East;
bb.offset = QPoint(1, 0);
bp.buildings.push_back(bb);
applyRotationCCW(bp, cfg);
// Offset: right → up.
REQUIRE(bp.buildings[0].offset == QPoint(0, -1));
// Building rotation: East → North.
REQUIRE(bp.buildings[0].rotation == Rotation::North);
}
TEST_CASE("Blueprint: four CW rotations restore offset and building rotation", "[blueprint]")
{
const GameConfig cfg = loadConfig();
Blueprint bp;
bp.name = "test";
BlueprintBuilding bb;
bb.type = BuildingType::Belt;
bb.rotation = Rotation::East;
bb.offset = QPoint(2, -1);
bp.buildings.push_back(bb);
const QPoint originalOffset = bb.offset;
const Rotation originalRotation = bb.rotation;
for (int i = 0; i < 4; ++i) { applyRotationCW(bp, cfg); }
REQUIRE(bp.buildings[0].offset == originalOffset);
REQUIRE(bp.buildings[0].rotation == originalRotation);
}
TEST_CASE("Blueprint: multi-building constellation rotates symmetrically CW", "[blueprint]")
{
const GameConfig cfg = loadConfig();
// Two buildings left and right of center; after CW they should be above and below.
Blueprint bp;
bp.name = "test";
BlueprintBuilding left, right;
left.type = right.type = BuildingType::Belt;
left.rotation = right.rotation = Rotation::East;
left.offset = QPoint(-1, 0);
right.offset = QPoint( 1, 0);
bp.buildings = { left, right };
applyRotationCW(bp, cfg);
// left (-1, 0) → CW → (0, -1) (above center)
// right ( 1, 0) → CW → (0, 1) (below center)
REQUIRE(bp.buildings[0].offset == QPoint(0, -1));
REQUIRE(bp.buildings[1].offset == QPoint(0, 1));
REQUIRE(bp.buildings[0].rotation == Rotation::South);
REQUIRE(bp.buildings[1].rotation == Rotation::South);
}
// ---------------------------------------------------------------------------
// Regression: multi-tile building rotation (the anchor-offset-only bug)
// ---------------------------------------------------------------------------
TEST_CASE("Blueprint: CW rotation keeps belt adjacent to miner output port", "[blueprint]")
{
const GameConfig cfg = loadConfig();
// East miner: anchor (0,0), body cells (0,0),(1,0),(0,1).
// Output port indicator '>' at (1,1) → port tile (1,1), direction East.
// Belt placed at (1,1) — the port exit tile.
// Constellation body cells: {(0,0),(1,0),(0,1),(1,1)}.
// Integer center: ((0+1)/2, (0+1)/2) = (0,0).
// Miner offset (0,0), belt offset (1,1).
Blueprint bp;
bp.name = "test";
BlueprintBuilding miner;
miner.type = BuildingType::Miner;
miner.rotation = Rotation::East;
miner.offset = QPoint(0, 0);
BlueprintBuilding belt;
belt.type = BuildingType::Belt;
belt.rotation = Rotation::East;
belt.offset = QPoint(1, 1); // port exit tile of East miner relative to its anchor
bp.buildings = { miner, belt };
applyRotationCW(bp, cfg);
// Miner body cells (0,0),(1,0),(0,1) rotated CW: (0,0),(0,1),(-1,0).
// New miner anchor = min(-1..0, 0..1) = (-1, 0).
REQUIRE(bp.buildings[0].offset == QPoint(-1, 0));
REQUIRE(bp.buildings[0].rotation == Rotation::South);
// Belt body cell (1,1) rotated CW: (-1, 1). New belt anchor = (-1, 1).
REQUIRE(bp.buildings[1].offset == QPoint(-1, 1));
REQUIRE(bp.buildings[1].rotation == Rotation::South);
// South miner port tile relative to its anchor is (0,1) (the 'v' indicator in ["AA","vA"]).
// Miner anchor in constellation = (-1,0). Port exit = (-1,0)+(0,1) = (-1,1) = belt anchor. ✓
REQUIRE(bp.buildings[1].offset == bp.buildings[0].offset + QPoint(0, 1));
}
TEST_CASE("Blueprint: CCW rotation keeps belt adjacent to miner output port", "[blueprint]")
{
const GameConfig cfg = loadConfig();
Blueprint bp;
bp.name = "test";
BlueprintBuilding miner;
miner.type = BuildingType::Miner;
miner.rotation = Rotation::East;
miner.offset = QPoint(0, 0);
BlueprintBuilding belt;
belt.type = BuildingType::Belt;
belt.rotation = Rotation::East;
belt.offset = QPoint(1, 1);
bp.buildings = { miner, belt };
applyRotationCCW(bp, cfg);
// Miner body cells (0,0),(1,0),(0,1) rotated CCW: (0,0),(0,-1),(1,0).
// New miner anchor = min(0..1, -1..0) = (0, -1).
REQUIRE(bp.buildings[0].offset == QPoint(0, -1));
REQUIRE(bp.buildings[0].rotation == Rotation::North);
// Belt body cell (1,1) rotated CCW: (1,-1). New belt anchor = (1,-1).
REQUIRE(bp.buildings[1].offset == QPoint(1, -1));
REQUIRE(bp.buildings[1].rotation == Rotation::North);
// North miner port tile relative to its anchor is (1,0) (the '^' indicator in ["A^","AA"]).
// Miner anchor in constellation = (0,-1). Port exit = (0,-1)+(1,0) = (1,-1) = belt anchor. ✓
REQUIRE(bp.buildings[1].offset == bp.buildings[0].offset + QPoint(1, 0));
}
// ---------------------------------------------------------------------------
// Player-placeable filter in blueprint creation
// ---------------------------------------------------------------------------
TEST_CASE("Blueprint creation: non-player-placeable building alone yields empty blueprint",
"[blueprint]")
{
const GameConfig cfg = loadConfig();
// Hq has no entry in buildings.toml, so it is treated as non-player-placeable.
const BuildingSpec hq{ QPoint(-5, 0), {QPoint(-5, 0)}, BuildingType::Hq, Rotation::East };
const Blueprint bp = buildBlueprintFiltered({ hq }, cfg);
REQUIRE(bp.buildings.empty());
}
TEST_CASE("Blueprint creation: mixed selection keeps only player-placeable buildings",
"[blueprint]")
{
const GameConfig cfg = loadConfig();
const BuildingSpec belt{ QPoint(-5, 0), {QPoint(-5, 0)}, BuildingType::Belt, Rotation::East };
const BuildingSpec hq { QPoint(-3, 0), {QPoint(-3, 0)}, BuildingType::Hq, Rotation::East };
const Blueprint bp = buildBlueprintFiltered({ belt, hq }, cfg);
REQUIRE(bp.buildings.size() == 1);
REQUIRE(bp.buildings[0].type == BuildingType::Belt);
}
TEST_CASE("Blueprint creation: bounding box ignores non-player-placeable buildings",
"[blueprint]")
{
const GameConfig cfg = loadConfig();
// Belt at (-5, 0). HQ at (-3, 0) — excluded from the blueprint.
// If HQ were included: bboxX = [-5, -3], center.x = -4, belt offset = -1.
// With HQ excluded: bboxX = [-5, -5], center.x = -5, belt offset = 0.
const BuildingSpec belt{ QPoint(-5, 0), {QPoint(-5, 0)}, BuildingType::Belt, Rotation::East };
const BuildingSpec hq { QPoint(-3, 0), {QPoint(-3, 0)}, BuildingType::Hq, Rotation::East };
const Blueprint bp = buildBlueprintFiltered({ belt, hq }, cfg);
REQUIRE(bp.buildings.size() == 1);
REQUIRE(bp.buildings[0].offset == QPoint(0, 0));
}
// ---------------------------------------------------------------------------
// Simulation-level: blueprint placement places buildings at correct tiles
// ---------------------------------------------------------------------------
TEST_CASE("Blueprint placement: buildings land at anchor + offset from cursor", "[blueprint]")
{
// Simulate placing a two-belt blueprint with offsets (-1, 0) and (+1, 0)
// at cursor tile (-5, 0). Expected anchors: (-6, 0) and (-4, 0).
// (Belt surface_mask ["A>"] — body at relative (0,0), port at (1,0).)
Simulation sim(loadConfig());
const QPoint cursor(-5, 0);
const QPoint offsetA(-1, 0);
const QPoint offsetB( 1, 0);
const EntityId idA = sim.tryPlaceBuilding(
BuildingType::Belt, cursor + offsetA, Rotation::East);
const EntityId idB = sim.tryPlaceBuilding(
BuildingType::Belt, cursor + offsetB, Rotation::East);
REQUIRE(idA != kInvalidEntityId);
REQUIRE(idB != kInvalidEntityId);
REQUIRE(sim.buildings().isTileOccupied(cursor + offsetA)); // (-6, 0)
REQUIRE(sim.buildings().isTileOccupied(cursor + offsetB)); // (-4, 0)
REQUIRE_FALSE(sim.buildings().isTileOccupied(cursor)); // center not occupied
}
TEST_CASE("Blueprint placement: cost is deducted for each building in sequence", "[blueprint]")
{
Simulation sim(loadConfig());
// Find belt cost from config (belt cost = 2 in test config).
int beltCost = 0;
for (const BuildingDef& def : sim.config().buildings.buildings)
{
if (def.type == BuildingType::Belt) { beltCost = def.cost; break; }
}
REQUIRE(beltCost > 0);
const int startBlocks = sim.buildingBlocksStock();
REQUIRE(startBlocks >= 2 * beltCost); // test config has enough starting blocks
sim.tryPlaceBuilding(BuildingType::Belt, QPoint(-6, 0), Rotation::East);
REQUIRE(sim.buildingBlocksStock() == startBlocks - beltCost);
sim.tryPlaceBuilding(BuildingType::Belt, QPoint(-4, 0), Rotation::East);
REQUIRE(sim.buildingBlocksStock() == startBlocks - 2 * beltCost);
}
TEST_CASE("Blueprint placement: insufficient blocks returns kInvalidEntityId and deducts nothing",
"[blueprint]")
{
Simulation sim(loadConfig());
// Find miner cost (15 in test config) — expensive enough to exhaust a small stock.
int minerCost = 0;
for (const BuildingDef& def : sim.config().buildings.buildings)
{
if (def.type == BuildingType::Miner) { minerCost = def.cost; break; }
}
REQUIRE(minerCost > 0);
// Drain the stock by placing miners until we no longer have enough.
// Non-overlapping columns: miner body is 2 wide, so step by 2.
int col = -2;
while (sim.buildingBlocksStock() >= minerCost)
{
sim.tryPlaceBuilding(BuildingType::Miner, QPoint(col, 0), Rotation::East);
col -= 2;
}
const int blocksBeforeAttempt = sim.buildingBlocksStock();
const EntityId id = sim.tryPlaceBuilding(
BuildingType::Miner, QPoint(col - 2, 0), Rotation::East);
// Placement must fail and leave the stock unchanged.
REQUIRE(id == kInvalidEntityId);
REQUIRE(sim.buildingBlocksStock() == blocksBeforeAttempt);
}
// ---------------------------------------------------------------------------
// Recipe / schematic capture and re-application
// ---------------------------------------------------------------------------
TEST_CASE("Blueprint: recipeId is stored in BlueprintBuilding", "[blueprint]")
{
const BuildingSpec spec{
QPoint(-5, 0), {QPoint(-5, 0)}, BuildingType::Miner, Rotation::East,
"mine_iron_ore"
};
const Blueprint bp = buildBlueprint({ spec });
REQUIRE(bp.buildings.size() == 1);
REQUIRE(bp.buildings[0].recipeId == "mine_iron_ore");
}
TEST_CASE("Blueprint: building with no recipe has empty recipeId", "[blueprint]")
{
const BuildingSpec spec{
QPoint(-5, 0), {QPoint(-5, 0)}, BuildingType::Belt, Rotation::East
// recipeId defaults to ""
};
const Blueprint bp = buildBlueprint({ spec });
REQUIRE(bp.buildings[0].recipeId.empty());
}
TEST_CASE("Blueprint placement: setRecipe on construction site stores recipe", "[blueprint]")
{
Simulation sim(loadConfig());
// Miner body cells: (0,0),(1,0),(0,1) — all at x < 0, valid for asteroid.
const EntityId id = sim.tryPlaceBuilding(BuildingType::Miner, QPoint(-2, 0), Rotation::East);
REQUIRE(id != kInvalidEntityId);
sim.buildings().setRecipe(id, "mine_iron_ore");
const ConstructionSite* site = sim.buildings().findSite(id);
REQUIRE(site != nullptr);
REQUIRE(site->recipeId == "mine_iron_ore");
}
TEST_CASE("Blueprint placement: recipe transfers to building after construction completes",
"[blueprint]")
{
Simulation sim(loadConfig());
const EntityId id = sim.tryPlaceBuilding(BuildingType::Miner, QPoint(-2, 0), Rotation::East);
REQUIRE(id != kInvalidEntityId);
sim.buildings().setRecipe(id, "mine_copper_ore");
// Miner construction_time_seconds = 10 → completesAt = secondsToTicks(10) = 300.
// Run 301 ticks (0..300) to process the completion tick.
for (int i = 0; i <= static_cast<int>(secondsToTicks(10.0)); ++i)
{
sim.tick();
}
const Building* b = sim.buildings().findBuilding(id);
REQUIRE(b != nullptr);
REQUIRE(b->recipeId == "mine_copper_ore");
}
TEST_CASE("Blueprint placement: interceptor schematic is unlocked at game start", "[blueprint]")
{
// "interceptor" has available_from_start = true in the test config.
// This confirms the guard in placeBlueprintAtTile passes for start-unlocked schematics.
Simulation sim(loadConfig());
REQUIRE(sim.isSchematicUnlocked("interceptor"));
}
TEST_CASE("Blueprint placement: repair_ship schematic is locked at game start", "[blueprint]")
{
// "repair_ship" has available_from_start = false in the test config.
// This confirms the guard in placeBlueprintAtTile blocks locked schematics,
// leaving the shipyard's schematic unset.
Simulation sim(loadConfig());
REQUIRE_FALSE(sim.isSchematicUnlocked("repair_ship"));
}

View File

@@ -15,4 +15,5 @@ add_files(
WaveSystemTest.cpp
CombatSystemTest.cpp
ShipyardTest.cpp
BlueprintTest.cpp
)

View File

@@ -221,7 +221,7 @@ TEST_CASE("CombatSystem: player station fires at enemy ship in range", "[combat]
}
}
// Find a combat ship blueprint for the enemy.
// Find a combat ship schematic for the enemy.
const ShipDef* combatDef = findCombatShip(sim.config());
REQUIRE(combatDef != nullptr);

View File

@@ -17,11 +17,11 @@ static GameConfig loadConfig()
return ConfigLoader::loadFromDirectory(DOTA_FACTORY_CONFIG_DIR);
}
static const ShipDef* findAvailableBlueprint(const GameConfig& cfg)
static const ShipDef* findAvailableSchematic(const GameConfig& cfg)
{
for (const ShipDef& def : cfg.ships.ships)
{
if (def.availableFromStart && !def.blueprint.materials.empty())
if (def.availableFromStart && !def.schematic.materials.empty())
{
return &def;
}
@@ -59,7 +59,7 @@ static void fillMaterials(Simulation& sim, EntityId yardId, const ShipDef& def)
{
return;
}
for (const RecipeIngredient& ing : def.blueprint.materials)
for (const RecipeIngredient& ing : def.schematic.materials)
{
b.inputBuffer.counts[ItemType{ing.item}] = ing.amount;
}
@@ -75,7 +75,7 @@ TEST_CASE("Shipyard: spawns a player ship after production cycle completes",
{
Simulation sim(loadConfig(), 42);
const ShipDef* def = findAvailableBlueprint(sim.config());
const ShipDef* def = findAvailableSchematic(sim.config());
REQUIRE(def != nullptr);
const BuildingDef* yardDef = findShipyardDef(sim.config());
REQUIRE(yardDef != nullptr);
@@ -93,7 +93,7 @@ TEST_CASE("Shipyard: spawns a player ship after production cycle completes",
REQUIRE(static_cast<int>(sim.ships().allShips().size()) == shipsBefore);
// Tick until the cycle completes.
const Tick cycleTicks = secondsToTicks(def->blueprint.productionTimeSeconds);
const Tick cycleTicks = secondsToTicks(def->schematic.productionTimeSeconds);
for (Tick i = 1; i < cycleTicks; ++i)
{
sim.tick();
@@ -107,7 +107,7 @@ TEST_CASE("Shipyard: spawns a player ship after production cycle completes",
bool foundPlayerShip = false;
for (const Ship& ship : sim.ships().allShips())
{
if (!ship.isEnemy && ship.blueprintId == def->id)
if (!ship.isEnemy && ship.schematicId == def->id)
{
foundPlayerShip = true;
break;
@@ -116,7 +116,7 @@ TEST_CASE("Shipyard: spawns a player ship after production cycle completes",
REQUIRE(foundPlayerShip);
}
TEST_CASE("Shipyard: does not spawn without a blueprint set", "[shipyard]")
TEST_CASE("Shipyard: does not spawn without a schematic set", "[shipyard]")
{
Simulation sim(loadConfig(), 42);
@@ -136,7 +136,7 @@ TEST_CASE("Shipyard: does not spawn with insufficient materials", "[shipyard]")
{
Simulation sim(loadConfig(), 42);
const ShipDef* def = findAvailableBlueprint(sim.config());
const ShipDef* def = findAvailableSchematic(sim.config());
REQUIRE(def != nullptr);
const BuildingDef* yardDef = findShipyardDef(sim.config());
REQUIRE(yardDef != nullptr);
@@ -147,7 +147,7 @@ TEST_CASE("Shipyard: does not spawn with insufficient materials", "[shipyard]")
sim.buildings().setRecipe(yardId, def->id);
// Materials remain at zero (default after setRecipe); no cycle starts.
const Tick cycleTicks = secondsToTicks(def->blueprint.productionTimeSeconds);
const Tick cycleTicks = secondsToTicks(def->schematic.productionTimeSeconds);
for (Tick i = 0; i <= cycleTicks; ++i)
{
sim.tick();
@@ -160,7 +160,7 @@ TEST_CASE("Shipyard: spawns a second ship after materials replenished", "[shipya
{
Simulation sim(loadConfig(), 42);
const ShipDef* def = findAvailableBlueprint(sim.config());
const ShipDef* def = findAvailableSchematic(sim.config());
REQUIRE(def != nullptr);
const BuildingDef* yardDef = findShipyardDef(sim.config());
REQUIRE(yardDef != nullptr);
@@ -168,7 +168,7 @@ TEST_CASE("Shipyard: spawns a second ship after materials replenished", "[shipya
const EntityId yardId = placeShipyard(sim, *yardDef);
sim.buildings().setRecipe(yardId, def->id);
const Tick cycleTicks = secondsToTicks(def->blueprint.productionTimeSeconds);
const Tick cycleTicks = secondsToTicks(def->schematic.productionTimeSeconds);
// First cycle: capture count immediately after the spawn tick.
fillMaterials(sim, yardId, *def);

View File

@@ -61,11 +61,11 @@ TEST_CASE("Simulation::drainFireEvents clears queue on drain", "[simulation]")
REQUIRE(sim.drainFireEvents().empty());
}
TEST_CASE("Simulation::drainBlueprintDropEvents returns empty initially", "[simulation]")
TEST_CASE("Simulation::drainSchematicDropEvents returns empty initially", "[simulation]")
{
Simulation sim(loadConfig());
REQUIRE(sim.drainBlueprintDropEvents().empty());
REQUIRE(sim.drainSchematicDropEvents().empty());
}
// ---------------------------------------------------------------------------

View File

@@ -233,8 +233,8 @@ TEST_CASE("WaveSystem: only eligible ships (cost > 0) appear in waves", "[wave]"
{
if (!s.isEnemy) { continue; }
// salvage_ship and repair_ship have cost_formula = "0" and must not spawn.
REQUIRE(s.blueprintId != "salvage_ship");
REQUIRE(s.blueprintId != "repair_ship");
REQUIRE(s.schematicId != "salvage_ship");
REQUIRE(s.schematicId != "repair_ship");
}
}
@@ -266,7 +266,7 @@ TEST_CASE("WaveSystem: destroying both enemy stations triggers a push", "[wave]"
REQUIRE(enemyCount == 2);
}
TEST_CASE("WaveSystem: push emits exactly one BlueprintDropEvent", "[wave]")
TEST_CASE("WaveSystem: push emits exactly one SchematicDropEvent", "[wave]")
{
Simulation sim(loadConfig(), 42);
@@ -280,11 +280,11 @@ TEST_CASE("WaveSystem: push emits exactly one BlueprintDropEvent", "[wave]")
sim.tick();
const std::vector<BlueprintDropEvent> events = sim.drainBlueprintDropEvents();
const std::vector<SchematicDropEvent> events = sim.drainSchematicDropEvents();
REQUIRE(events.size() == 1);
}
TEST_CASE("WaveSystem: push blueprint drop awards a known ship id", "[wave]")
TEST_CASE("WaveSystem: push schematic drop awards a known ship id", "[wave]")
{
Simulation sim(loadConfig(), 42);
@@ -297,13 +297,13 @@ TEST_CASE("WaveSystem: push blueprint drop awards a known ship id", "[wave]")
});
sim.tick();
const std::vector<BlueprintDropEvent> events = sim.drainBlueprintDropEvents();
const std::vector<SchematicDropEvent> events = sim.drainSchematicDropEvents();
REQUIRE(events.size() == 1);
bool validId = false;
for (const ShipDef& def : sim.config().ships.ships)
{
if (def.id == events[0].blueprintId)
if (def.id == events[0].schematicId)
{
validId = true;
break;

View File

@@ -2,7 +2,7 @@
id = "interceptor"
available_from_start = true
[ship.blueprint]
[ship.schematic]
materials = [{item = "iron_ingot", amount = 3}, {item = "circuit_board", amount = 1}]
player_production_level = 3
production_time_seconds = 10
@@ -29,7 +29,7 @@ scrap_drop = 2
id = "destroyer"
available_from_start = true
[ship.blueprint]
[ship.schematic]
materials = [{item = "iron_ingot", amount = 5}, {item = "circuit_board", amount = 2}]
player_production_level = 5
production_time_seconds = 20
@@ -56,7 +56,7 @@ scrap_drop = 4
id = "salvage_ship"
available_from_start = true
[ship.blueprint]
[ship.schematic]
materials = [{item = "iron_ingot", amount = 4}]
player_production_level = 3
production_time_seconds = 10
@@ -82,7 +82,7 @@ scrap_drop = 2
id = "repair_ship"
available_from_start = false
[ship.blueprint]
[ship.schematic]
materials = [{item = "iron_ingot", amount = 4}, {item = "circuit_board", amount = 2}]
player_production_level = 3
production_time_seconds = 15

268
src/ui/BlueprintPanel.cpp Normal file
View File

@@ -0,0 +1,268 @@
#include "BlueprintPanel.h"
#include <algorithm>
#include <climits>
#include <QInputDialog>
#include <QPushButton>
#include <QScrollArea>
#include <QVBoxLayout>
#include "Building.h"
#include "BuildingSystem.h"
#include "Simulation.h"
BlueprintPanel::BlueprintPanel(Simulation* sim, const GameConfig* config, QWidget* parent)
: QWidget(parent)
, m_sim(sim)
, m_config(config)
, m_currentBlocks(0)
, m_deleteMode(false)
, m_activeIndex(-1)
{
QVBoxLayout* layout = new QVBoxLayout(this);
layout->setContentsMargins(4, 4, 4, 4);
layout->setSpacing(4);
m_createBtn = new QPushButton("Create Blueprint", this);
m_createBtn->setFixedHeight(48);
m_createBtn->setEnabled(false);
layout->addWidget(m_createBtn);
m_deleteBtn = new QPushButton("Delete Blueprint", this);
m_deleteBtn->setFixedHeight(48);
m_deleteBtn->setCheckable(true);
layout->addWidget(m_deleteBtn);
QScrollArea* scrollArea = new QScrollArea(this);
scrollArea->setWidgetResizable(true);
scrollArea->setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff);
m_buttonsContainer = new QWidget(scrollArea);
m_buttonsLayout = new QVBoxLayout(m_buttonsContainer);
m_buttonsLayout->setContentsMargins(0, 0, 0, 0);
m_buttonsLayout->setSpacing(4);
m_buttonsLayout->addStretch();
scrollArea->setWidget(m_buttonsContainer);
layout->addWidget(scrollArea, 1);
connect(m_createBtn, &QPushButton::clicked, this, &BlueprintPanel::onCreateClicked);
connect(m_deleteBtn, &QPushButton::clicked, this, &BlueprintPanel::onDeleteClicked);
}
void BlueprintPanel::onSelectionChanged(const std::vector<EntityId>& ids)
{
m_selectedIds = ids;
refreshButtonStates();
}
void BlueprintPanel::onStateUpdated(Tick /*tick*/, int blocks, double /*speed*/)
{
m_currentBlocks = blocks;
refreshButtonStates();
}
void BlueprintPanel::clearActiveBlueprintButton()
{
if (m_activeIndex >= 0 && m_activeIndex < static_cast<int>(m_blueprintButtons.size()))
{
m_blueprintButtons[static_cast<std::size_t>(m_activeIndex)]->setChecked(false);
}
m_activeIndex = -1;
refreshButtonStates();
}
void BlueprintPanel::onCreateClicked()
{
if (m_selectedIds.empty()) { return; }
Blueprint bp = createBlueprintFromSelection();
if (bp.buildings.empty()) { return; }
bool ok = false;
const QString name = QInputDialog::getText(
this, "Create Blueprint", "Blueprint name:", QLineEdit::Normal, QString(), &ok);
if (!ok || name.trimmed().isEmpty()) { return; }
bp.name = name.trimmed();
m_blueprints.push_back(bp);
rebuildButtons();
}
void BlueprintPanel::onDeleteClicked()
{
if (!m_deleteMode)
{
m_deleteMode = true;
m_deleteBtn->setChecked(true);
if (m_activeIndex >= 0)
{
clearActiveBlueprintButton();
emit exitBlueprintModeRequested();
}
}
else
{
m_deleteMode = false;
m_deleteBtn->setChecked(false);
}
}
void BlueprintPanel::onBlueprintButtonClicked(int index)
{
if (index < 0 || index >= static_cast<int>(m_blueprints.size())) { return; }
if (m_deleteMode)
{
m_blueprints.erase(m_blueprints.begin() + index);
m_deleteMode = false;
m_deleteBtn->setChecked(false);
m_activeIndex = -1;
rebuildButtons();
return;
}
if (m_activeIndex == index)
{
clearActiveBlueprintButton();
emit exitBlueprintModeRequested();
return;
}
if (m_activeIndex >= 0 && m_activeIndex < static_cast<int>(m_blueprintButtons.size()))
{
m_blueprintButtons[static_cast<std::size_t>(m_activeIndex)]->setChecked(false);
}
m_activeIndex = index;
m_blueprintButtons[static_cast<std::size_t>(index)]->setChecked(true);
emit blueprintPlacementRequested(m_blueprints[static_cast<std::size_t>(index)]);
}
Blueprint BlueprintPanel::createBlueprintFromSelection() const
{
struct Entry
{
const Building* building;
};
std::vector<Entry> entries;
entries.reserve(m_selectedIds.size());
for (const EntityId id : m_selectedIds)
{
const Building* b = m_sim->buildings().findBuilding(id);
if (!b) { continue; }
const bool placeable = [&]() {
for (const BuildingDef& def : m_config->buildings.buildings)
{
if (def.type == b->type) { return def.playerPlaceable; }
}
return false;
}();
if (placeable) { entries.push_back({ b }); }
}
if (entries.empty()) { return Blueprint{}; }
int minX = INT_MAX, maxX = INT_MIN;
int minY = INT_MAX, maxY = INT_MIN;
for (const Entry& e : entries)
{
for (const QPoint& cell : e.building->bodyCells)
{
minX = std::min(minX, cell.x());
maxX = std::max(maxX, cell.x());
minY = std::min(minY, cell.y());
maxY = std::max(maxY, cell.y());
}
}
const QPoint center((minX + maxX) / 2, (minY + maxY) / 2);
Blueprint bp;
bp.buildings.reserve(entries.size());
for (const Entry& e : entries)
{
BlueprintBuilding bb;
bb.type = e.building->type;
bb.rotation = e.building->rotation;
bb.offset = e.building->anchor - center;
bb.recipeId = e.building->recipeId;
bp.buildings.push_back(bb);
}
return bp;
}
int BlueprintPanel::computeBlueprintCost(const Blueprint& bp) const
{
int total = 0;
for (const BlueprintBuilding& bb : bp.buildings)
{
for (const BuildingDef& def : m_config->buildings.buildings)
{
if (def.type == bb.type)
{
total += def.cost;
break;
}
}
}
return total;
}
void BlueprintPanel::rebuildButtons()
{
for (QPushButton* btn : m_blueprintButtons)
{
m_buttonsLayout->removeWidget(btn);
delete btn;
}
m_blueprintButtons.clear();
for (int i = 0; i < static_cast<int>(m_blueprints.size()); ++i)
{
const Blueprint& bp = m_blueprints[static_cast<std::size_t>(i)];
const int cost = computeBlueprintCost(bp);
const QString label = bp.name + "\n" + QString::number(cost) + " Blocks";
QPushButton* btn = new QPushButton(label, m_buttonsContainer);
btn->setCheckable(true);
btn->setFixedHeight(48);
m_buttonsLayout->insertWidget(i, btn);
const int capturedIndex = i;
connect(btn, &QPushButton::clicked, this, [this, capturedIndex]() {
onBlueprintButtonClicked(capturedIndex);
});
m_blueprintButtons.push_back(btn);
}
refreshButtonStates();
}
void BlueprintPanel::refreshButtonStates()
{
const bool anyPlaceable = [&]() {
for (const EntityId id : m_selectedIds)
{
const Building* b = m_sim->buildings().findBuilding(id);
if (!b) { continue; }
for (const BuildingDef& def : m_config->buildings.buildings)
{
if (def.type == b->type) { return def.playerPlaceable; }
}
}
return false;
}();
m_createBtn->setEnabled(anyPlaceable);
for (int i = 0; i < static_cast<int>(m_blueprintButtons.size()); ++i)
{
const int cost = computeBlueprintCost(m_blueprints[static_cast<std::size_t>(i)]);
const bool canAfford = m_currentBlocks >= cost;
m_blueprintButtons[static_cast<std::size_t>(i)]->setEnabled(
canAfford || m_activeIndex == i);
}
}

56
src/ui/BlueprintPanel.h Normal file
View File

@@ -0,0 +1,56 @@
#pragma once
#include <vector>
#include <QWidget>
#include "Blueprint.h"
#include "EntityId.h"
#include "GameConfig.h"
#include "Tick.h"
class Simulation;
class QPushButton;
class QScrollArea;
class QVBoxLayout;
class BlueprintPanel : public QWidget
{
Q_OBJECT
public:
BlueprintPanel(Simulation* sim, const GameConfig* config, QWidget* parent = nullptr);
public slots:
void onSelectionChanged(const std::vector<EntityId>& ids);
void onStateUpdated(Tick tick, int blocks, double speed);
void clearActiveBlueprintButton();
signals:
void blueprintPlacementRequested(Blueprint blueprint);
void exitBlueprintModeRequested();
private slots:
void onCreateClicked();
void onDeleteClicked();
void onBlueprintButtonClicked(int index);
private:
Blueprint createBlueprintFromSelection() const;
int computeBlueprintCost(const Blueprint& bp) const;
void rebuildButtons();
void refreshButtonStates();
Simulation* m_sim;
const GameConfig* m_config;
std::vector<EntityId> m_selectedIds;
int m_currentBlocks;
bool m_deleteMode;
int m_activeIndex;
std::vector<Blueprint> m_blueprints;
std::vector<QPushButton*> m_blueprintButtons;
QPushButton* m_createBtn;
QPushButton* m_deleteBtn;
QWidget* m_buttonsContainer;
QVBoxLayout* m_buttonsLayout;
};

View File

@@ -7,6 +7,7 @@ SET(HDRS
${CMAKE_CURRENT_SOURCE_DIR}/HeaderBar.h
${CMAKE_CURRENT_SOURCE_DIR}/BuildButtonGrid.h
${CMAKE_CURRENT_SOURCE_DIR}/SelectedBuildingPanel.h
${CMAKE_CURRENT_SOURCE_DIR}/BlueprintPanel.h
PARENT_SCOPE
)
@@ -18,5 +19,6 @@ SET(SRCS
${CMAKE_CURRENT_SOURCE_DIR}/HeaderBar.cpp
${CMAKE_CURRENT_SOURCE_DIR}/BuildButtonGrid.cpp
${CMAKE_CURRENT_SOURCE_DIR}/SelectedBuildingPanel.cpp
${CMAKE_CURRENT_SOURCE_DIR}/BlueprintPanel.cpp
PARENT_SCOPE
)

View File

@@ -2,6 +2,7 @@
#include <algorithm>
#include <cctype>
#include <climits>
#include <cmath>
#include <map>
#include <string>
@@ -163,17 +164,17 @@ void GameWorldView::onFrame()
}
}
// Drain blueprint drop events → toasts
// Drain schematic drop events → toasts
{
const std::vector<BlueprintDropEvent> drops =
m_sim->drainBlueprintDropEvents();
for (const BlueprintDropEvent& ev : drops)
const std::vector<SchematicDropEvent> drops =
m_sim->drainSchematicDropEvents();
for (const SchematicDropEvent& ev : drops)
{
const QString shipName = toDisplayName(ev.blueprintId);
const QString shipName = toDisplayName(ev.schematicId);
ToastEntry toast;
if (ev.wasNewUnlock)
{
toast.text = "Blueprint unlocked: " + shipName;
toast.text = "Schematic unlocked: " + shipName;
}
else
{
@@ -452,6 +453,42 @@ void GameWorldView::stepSpeed(int delta)
setGameSpeed(kSpeeds[next]);
}
void GameWorldView::placeBlueprintAtTile(QPoint center)
{
const Blueprint& bp = *m_blueprintMode;
for (const BlueprintBuilding& bb : bp.buildings)
{
if (!isValidPlacement(bb.type, center + bb.offset, bb.rotation)) { return; }
}
int totalCost = 0;
for (const BlueprintBuilding& bb : bp.buildings)
{
const BuildingDef* def = findBuildingDef(bb.type);
if (def) { totalCost += def->cost; }
}
if (m_sim->buildingBlocksStock() < totalCost) { return; }
for (const BlueprintBuilding& bb : bp.buildings)
{
const EntityId id = m_sim->tryPlaceBuilding(bb.type, center + bb.offset, bb.rotation);
if (id == kInvalidEntityId || bb.recipeId.empty()) { continue; }
if (bb.type == BuildingType::Shipyard)
{
if (m_sim->isSchematicUnlocked(bb.recipeId))
{
m_sim->buildings().setRecipe(id, bb.recipeId);
}
}
else
{
m_sim->buildings().setRecipe(id, bb.recipeId);
}
}
}
void GameWorldView::placeAtTile(QPoint tile)
{
if (!m_builderType.has_value())
@@ -776,6 +813,32 @@ void GameWorldView::drawOverlays(QPainter& painter)
}
}
// Blueprint placement ghost
if (m_blueprintMode.has_value())
{
for (const BlueprintBuilding& bb : m_blueprintMode->buildings)
{
const QPoint anchor = m_blueprintGhostTile + bb.offset;
const bool valid = isValidPlacement(bb.type, anchor, bb.rotation);
const QColor& ghostColor = valid
? m_visuals->overlays.ghostValid
: m_visuals->overlays.ghostInvalid;
const BuildingDef* def = findBuildingDef(bb.type);
if (!def) { continue; }
const ParsedSurfaceMask parsed = parseSurfaceMask(def->surfaceMask, bb.rotation);
for (const QPoint& cell : parsed.bodyCells)
{
painter.fillRect(tileRect(anchor + cell), ghostColor);
}
for (const Port& port : parsed.outputPorts)
{
drawPortGlyph(painter,
anchor + portBodyTile(port.tile, port.direction),
port.direction, Qt::white);
}
}
}
// Demolish hover tint
if (m_demolishMode && m_demolishHoverId != kInvalidEntityId)
{
@@ -883,6 +946,24 @@ void GameWorldView::keyPressEvent(QKeyEvent* event)
m_ghostRotation = rotateClockwise(m_ghostRotation);
m_ghostValid = isValidPlacement(*m_builderType, m_ghostTile, m_ghostRotation);
}
else if (m_blueprintMode.has_value())
{
for (BlueprintBuilding& bb : m_blueprintMode->buildings)
{
const BuildingDef* def = findBuildingDef(bb.type);
if (!def) { continue; }
const ParsedSurfaceMask mask = parseSurfaceMask(def->surfaceMask, bb.rotation);
int minX = INT_MAX, minY = INT_MAX;
for (const QPoint& cell : mask.bodyCells)
{
const QPoint abs = bb.offset + cell;
minX = std::min(minX, -abs.y());
minY = std::min(minY, abs.x());
}
bb.offset = QPoint(minX, minY);
bb.rotation = rotateClockwise(bb.rotation);
}
}
break;
case Qt::Key_Q:
if (m_builderType.has_value())
@@ -890,6 +971,24 @@ void GameWorldView::keyPressEvent(QKeyEvent* event)
m_ghostRotation = rotateCounterClockwise(m_ghostRotation);
m_ghostValid = isValidPlacement(*m_builderType, m_ghostTile, m_ghostRotation);
}
else if (m_blueprintMode.has_value())
{
for (BlueprintBuilding& bb : m_blueprintMode->buildings)
{
const BuildingDef* def = findBuildingDef(bb.type);
if (!def) { continue; }
const ParsedSurfaceMask mask = parseSurfaceMask(def->surfaceMask, bb.rotation);
int minX = INT_MAX, minY = INT_MAX;
for (const QPoint& cell : mask.bodyCells)
{
const QPoint abs = bb.offset + cell;
minX = std::min(minX, abs.y());
minY = std::min(minY, -abs.x());
}
bb.offset = QPoint(minX, minY);
bb.rotation = rotateCounterClockwise(bb.rotation);
}
}
break;
case Qt::Key_Escape:
emit escapeMenuRequested();
@@ -919,9 +1018,10 @@ void GameWorldView::mousePressEvent(QMouseEvent* event)
{
if (event->button() != Qt::LeftButton)
{
if (event->button() == Qt::RightButton && m_builderType.has_value())
if (event->button() == Qt::RightButton)
{
exitBuilderMode();
if (m_builderType.has_value()) { exitBuilderMode(); }
else if (m_blueprintMode.has_value()) { exitBlueprintMode(); }
}
return;
}
@@ -942,6 +1042,10 @@ void GameWorldView::mousePressEvent(QMouseEvent* event)
placeAtTile(tile);
}
}
else if (m_blueprintMode.has_value())
{
placeBlueprintAtTile(tile);
}
else if (m_demolishMode)
{
EntityId hovered = buildingAtTile(tile);
@@ -1012,6 +1116,10 @@ void GameWorldView::mouseMoveEvent(QMouseEvent* event)
placeAtTile(tile);
}
}
else if (m_blueprintMode.has_value())
{
m_blueprintGhostTile = tile;
}
else if (m_demolishMode)
{
m_demolishHoverId = buildingAtTile(tile);
@@ -1089,6 +1197,7 @@ void GameWorldView::toggleDemolishMode()
else
{
if (m_builderType.has_value()) { exitBuilderMode(); }
if (m_blueprintMode.has_value()) { exitBlueprintMode(); }
m_demolishMode = true;
}
emit demolishModeChanged(m_demolishMode);
@@ -1100,9 +1209,25 @@ void GameWorldView::enterBuilderMode(BuildingType type)
m_ghostRotation = Rotation::East;
m_ghostValid = false;
m_demolishMode = false;
m_blueprintMode.reset();
emit demolishModeChanged(false);
}
void GameWorldView::enterBlueprintMode(Blueprint blueprint)
{
if (m_builderType.has_value()) { exitBuilderMode(); }
m_demolishMode = false;
emit demolishModeChanged(false);
m_blueprintGhostTile = m_ghostTile;
m_blueprintMode = std::move(blueprint);
}
void GameWorldView::exitBlueprintMode()
{
m_blueprintMode.reset();
emit blueprintModeExited();
}
void GameWorldView::exitBuilderMode()
{
m_builderType.reset();
@@ -1127,6 +1252,7 @@ void GameWorldView::setGameSpeed(double multiplier)
void GameWorldView::resetForNewGame()
{
exitBuilderMode();
exitBlueprintMode();
m_activeBeams.clear();
m_toasts.clear();
m_ghostRotation = Rotation::East;

View File

@@ -11,7 +11,8 @@
#include <QTimer>
#include <QVector2D>
#include "BlueprintDropEvent.h"
#include "Blueprint.h"
#include "SchematicDropEvent.h"
#include "BuildingType.h"
#include "EntityId.h"
#include "FireEvent.h"
@@ -46,6 +47,7 @@ signals:
void stateUpdated(Tick tick, int blocks, double speed);
void gameOver();
void builderModeExited();
void blueprintModeExited();
void escapeMenuRequested();
void demolishModeChanged(bool active);
@@ -55,6 +57,8 @@ public:
public slots:
void enterBuilderMode(BuildingType type);
void exitBuilderMode();
void enterBlueprintMode(Blueprint blueprint);
void exitBlueprintMode();
void toggleDemolishMode();
void setGameSpeed(double multiplier);
void resetForNewGame();
@@ -101,6 +105,8 @@ private:
void drawPortGlyph(QPainter& painter, QPoint bodyTile,
Rotation direction, const QColor& color);
void placeBlueprintAtTile(QPoint center);
std::optional<QVector2D> entityPosition(EntityId id) const;
void stepSpeed(int delta);
void placeAtTile(QPoint tile);
@@ -145,6 +151,9 @@ private:
std::set<QPoint, QPointCompare> m_beltDragTiles;
bool m_dragging;
std::optional<Blueprint> m_blueprintMode;
QPoint m_blueprintGhostTile;
bool m_demolishMode;
EntityId m_demolishHoverId;

View File

@@ -7,6 +7,7 @@
#include <QResizeEvent>
#include <QVBoxLayout>
#include "BlueprintPanel.h"
#include "BuildButtonGrid.h"
#include "ConfigLoader.h"
#include "GameWorldView.h"
@@ -36,9 +37,11 @@ MainWindow::MainWindow(Simulation* sim, const std::string& configDir, QWidget* p
m_selectedBuildingPanel = new SelectedBuildingPanel(sim, &sim->config(), m_bottomPanel);
m_buildButtonGrid = new BuildButtonGrid(&sim->config(), m_bottomPanel);
m_blueprintPanel = new BlueprintPanel(sim, &sim->config(), m_bottomPanel);
bottomLayout->addWidget(m_selectedBuildingPanel, 1);
bottomLayout->addWidget(m_buildButtonGrid, 1);
bottomLayout->addWidget(m_blueprintPanel, 1);
// Signals: game world → other panels
connect(m_gameWorldView, &GameWorldView::selectionChanged,
@@ -75,6 +78,22 @@ MainWindow::MainWindow(Simulation* sim, const std::string& configDir, QWidget* p
connect(m_gameWorldView, &GameWorldView::demolishModeChanged,
m_buildButtonGrid, &BuildButtonGrid::setDemolishModeActive);
// Signals: blueprint panel ↔ game world
connect(m_gameWorldView, &GameWorldView::selectionChanged,
m_blueprintPanel, &BlueprintPanel::onSelectionChanged);
connect(m_gameWorldView, &GameWorldView::stateUpdated,
m_blueprintPanel, &BlueprintPanel::onStateUpdated);
connect(m_blueprintPanel, &BlueprintPanel::blueprintPlacementRequested,
m_gameWorldView, &GameWorldView::enterBlueprintMode);
connect(m_blueprintPanel, &BlueprintPanel::exitBlueprintModeRequested,
m_gameWorldView, &GameWorldView::exitBlueprintMode);
connect(m_gameWorldView, &GameWorldView::blueprintModeExited,
m_blueprintPanel, &BlueprintPanel::clearActiveBlueprintButton);
// Signals: header bar → game world
connect(m_headerBar, &HeaderBar::speedChanged,
m_gameWorldView, &GameWorldView::setGameSpeed);

View File

@@ -12,6 +12,7 @@ class GameWorldView;
class HeaderBar;
class SelectedBuildingPanel;
class BuildButtonGrid;
class BlueprintPanel;
class QResizeEvent;
class MainWindow : public QWidget
@@ -39,5 +40,6 @@ private:
HeaderBar* m_headerBar;
SelectedBuildingPanel* m_selectedBuildingPanel;
BuildButtonGrid* m_buildButtonGrid;
BlueprintPanel* m_blueprintPanel;
QWidget* m_bottomPanel;
};

View File

@@ -187,7 +187,7 @@ void SelectedBuildingPanel::buildSingle(EntityId id)
{
for (const ShipDef& def : m_config->ships.ships)
{
if (m_sim->isBlueprintUnlocked(def.id))
if (m_sim->isSchematicUnlocked(def.id))
{
m_recipeCombo->addItem(
QString::fromStdString(def.id),
@@ -267,7 +267,7 @@ void SelectedBuildingPanel::refreshBuffers(const Building* b)
}
else if (shipDef)
{
for (const RecipeIngredient& mat : shipDef->blueprint.materials)
for (const RecipeIngredient& mat : shipDef->schematic.materials)
{
if (mat.item == entry.first.id) { perCycle = mat.amount; break; }
}
@@ -320,7 +320,7 @@ void SelectedBuildingPanel::refreshBuffers(const Building* b)
{
const double durationSeconds = recipe
? recipe->durationSeconds
: shipDef->blueprint.productionTimeSeconds;
: shipDef->schematic.productionTimeSeconds;
bufText += QString("Cycle: %1 s\n").arg(durationSeconds, 0, 'f', 1);