Compare commits
15 Commits
535d4f8f24
...
0.1.1
| Author | SHA1 | Date | |
|---|---|---|---|
| d465671cfc | |||
| 76812ba0c5 | |||
| 9c1e948ab4 | |||
| 23ff406101 | |||
| 2ea0cb815d | |||
| 876b344b20 | |||
| cd75492796 | |||
| 486296feee | |||
| c9f14970a1 | |||
| 6a8c456aa1 | |||
| af8a2224c0 | |||
| 4b149d97a7 | |||
| 6a8b6acd3b | |||
| 3ce6e6d599 | |||
| 80a2622267 |
@@ -349,6 +349,8 @@ selection_rect = "#00ff00" # box-drag selection rectangle (REQ-UI-MULTI-SELE
|
||||
tile_highlight = "#ffffff22" # tile under cursor
|
||||
selected_outline = "#ffff00" # outline drawn around currently-selected building(s)
|
||||
copy_config = "#33ccff66" # copy-settings eligible-target tint + copy/paste flash (REQ-BLD-COPY-CONFIG-FEEDBACK)
|
||||
locked_asteroid = "#0000007f" # tint over the asteroid left of the buildable edge (not yet unlocked by expansion)
|
||||
modal_dim = "#00000099" # semi-transparent black dim behind modal dialogs/menus (REQ-UI-MODAL-DIM)
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Schematic-drop toasts (REQ-UI-SCHEMATIC-TOAST)
|
||||
|
||||
@@ -21,8 +21,8 @@ enemy_buffer_width_tiles = 20
|
||||
[scroll]
|
||||
# View pan speed (REQ-UI-SCROLL-SPEED): slow near the asteroid, fast across the
|
||||
# contest zone, with a linear ramp of the given width straddling each boundary.
|
||||
pan_speed_slow_tiles_per_second = 8.0
|
||||
pan_speed_fast_tiles_per_second = 24.0
|
||||
pan_speed_slow_tiles_per_second = 16.0
|
||||
pan_speed_fast_tiles_per_second = 32.0
|
||||
pan_ramp_band_width_tiles = 16
|
||||
|
||||
[expansion]
|
||||
|
||||
@@ -68,7 +68,7 @@ Modules in `modules.toml` define a `surface_mask` — a list of strings that des
|
||||
## Game World
|
||||
|
||||
- REQ-GW-COORDS: Tile coordinates are integer `(x, y)`. The origin `(0, 0)` is the first column of space — the tile immediately to the right of the asteroid's right edge at game start, at the top of the world. X grows right; Y grows down. All asteroid tiles have `x < 0`; asteroid left-expansions add tiles at increasingly negative X. The origin never shifts.
|
||||
- REQ-GW-TILE-SIZE: Tiles are square. The tile size in pixels is derived automatically so that the world height (in tiles) exactly fills the game world view's height in pixels. Items on belts are rendered at half-tile size; when multiple items occupy the same tile they are spaced quarter-tile apart along the direction of travel and overlap, rendered in ascending order of progress — the least-progressed item is drawn first (bottom) and the furthest-progressed item is drawn last (on top).
|
||||
- REQ-GW-TILE-SIZE: Tiles are square. The tile size in pixels is derived automatically so that the world height (in tiles) exactly fills the game world view's height in pixels. Items on belts are rendered at half-tile size; when multiple items occupy the same tile they are spaced quarter-tile apart along the direction of travel and overlap, rendered in ascending order of progress — the least-progressed item is drawn first (bottom) and the furthest-progressed item is drawn last (on top). Items emerging from a building's output port are rendered by these same rules on that port's output belt (REQ-MAT-OUTPUT-EMERGE).
|
||||
- REQ-GW-BELT-CAPACITY: Belt tiles and tunnel entry/exit tiles each hold up to four items simultaneously, queued one behind the other in the direction of travel. Splitter tiles hold up to four items: two unassigned items (progress < 0.5, not yet routed to an output) and one item per output slot (progress ≥ 0.5, committed to a specific output direction). Output-slot items are rendered on top of unassigned items; when both output slots are occupied, their rendering order follows the clockwise port order starting from East.
|
||||
- REQ-GW-BELT-SPEED: Items on belts move at `world.toml [world].belt_speed_tiles_per_second` tiles per second (default 2).
|
||||
- REQ-GW-HEIGHT: The world height (in tiles) is read from `world.toml [world].height_tiles`.
|
||||
@@ -127,14 +127,14 @@ Modules in `modules.toml` define a `surface_mask` — a list of strings that des
|
||||
- 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 pool of eligible outputs is restricted to implicitly unlocked item types (REQ-LOCK-REPROCESSING-POOL). 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 schematic. When all required materials — the ship's base materials (`[ship.schematic].materials`) plus the materials of all modules in the configured layout (REQ-MOD-MATERIALS) — are present in its input buffer, the shipyard consumes them and begins a production cycle lasting the ship's base `[ship.schematic].production_time_seconds` plus the sum of production times contributed by all module instances in the configured layout (REQ-MOD-PRODUCTION-TIME). One ship of that type is spawned with the configured modules when the cycle completes. The shipyard cannot start a new cycle while one is in progress. If the player confirms a layout change (REQ-MOD-UI-DIALOG) while a production cycle is in progress, the current cycle is cancelled and all consumed materials are discarded; the shipyard returns to idle with the new layout configuration.
|
||||
- REQ-BLD-SALVAGE-BAY: **Salvage Bay** (3×2): A dedicated drop-off point for salvage ships. It has an output buffer whose holding capacity is defined by the `output_buffer_capacity` field of the `salvage_bay` entry in `buildings.toml` (rather than by a production cycle, since the Salvage Bay has no recipe). A ship at the bay hands over one unit of scrap per tick while the buffer has free space; a full buffer blocks further drop-off until space frees up (consistent with the buffer-full semantics of REQ-MAT-OUTPUT-BUFFER). Held scrap is pushed 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; only implicitly unlocked item types are available as filter options (REQ-LOCK-UI-SPLITTER). Routing rules:
|
||||
- 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). A belt accepts items only through a non-output edge (REQ-MAT-ACCEPT-DIR).
|
||||
- REQ-BLD-SPLITTER: **Splitter** (1×1): Distributes incoming items between two output directions. Incoming items are accepted only through the splitter's non-output edges (REQ-MAT-ACCEPT-DIR). Each output can optionally have a filter (a list of item types), configurable via the selected building panel; only implicitly unlocked item types are available as filter options (REQ-LOCK-UI-SPLITTER). Routing rules:
|
||||
- An item matching only one output's filter is routed to that output.
|
||||
- An item matching both outputs' filters is distributed by strict alternation between those outputs.
|
||||
- An item matching neither output's filter is routed to the unfiltered output. If both outputs have a filter and the item matches neither, the splitter stalls and moves no items until the situation is resolved.
|
||||
- If neither output has a filter, items are distributed by strict alternation.
|
||||
- In all alternation cases, if one output is blocked the item goes to the other output until it unblocks.
|
||||
- REQ-BLD-TUNNEL-ENTRY: **Tunnel Entry** (1×1): The sending end of a tunnel pair. The player sets a direction (N, S, E, W) at placement, rotatable with R/Shift+R. Items arriving from an adjacent belt tile whose direction points into the entry are forwarded through the tunnel to the paired Tunnel Exit (see REQ-BLD-TUNNEL-PAIR, REQ-BLD-TUNNEL-TRANSIT). If the entry is unpaired, or if the paired exit's output is blocked, the entry blocks like a full belt tile.
|
||||
- REQ-BLD-TUNNEL-ENTRY: **Tunnel Entry** (1×1): The sending end of a tunnel pair. The player sets a direction (N, S, E, W) at placement, rotatable with R/Shift+R. Items arriving from an adjacent belt tile on a non-output edge (i.e. not the mouth edge in the entry's facing direction — see REQ-MAT-ACCEPT-DIR) whose direction points into the entry are forwarded through the tunnel to the paired Tunnel Exit (see REQ-BLD-TUNNEL-PAIR, REQ-BLD-TUNNEL-TRANSIT). If the entry is unpaired, or if the paired exit's output is blocked, the entry blocks like a full belt tile.
|
||||
- REQ-BLD-TUNNEL-EXIT: **Tunnel Exit** (1×1): The receiving end of a tunnel pair. The player sets a direction at placement, rotatable with R/Shift+R. Items received from the paired Tunnel Entry emerge from the output side of the exit tile — the tile adjacent in the exit's facing direction — continuing in that direction. If the exit is unpaired or its output is blocked, it holds received items until they can advance.
|
||||
- REQ-BLD-TUNNEL-PAIR: **Tunnel pairing rules.** Pairing is re-evaluated for all Tunnel Entries whenever any Tunnel Entry or Tunnel Exit is placed or demolished.
|
||||
- A Tunnel Entry searches tile-by-tile in its facing direction for a partner. Any tunnel building (entry or exit) that faces a *different* direction is ignored and skipped. The search stops at the first tunnel building that faces the *same* direction as the searching entry.
|
||||
@@ -146,9 +146,27 @@ Modules in `modules.toml` define a `surface_mask` — a list of strings that des
|
||||
|
||||
## Material Transport & Buffers
|
||||
|
||||
- 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-BELT-ONLY: Materials are transported exclusively via belts, splitters, and tunnels, with one exception: two directly adjacent buildings whose output and input ports meet transfer items straight between them without an intervening transport tile (REQ-MAT-DIRECT-COUPLE).
|
||||
- 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. An accepted item does not enter the building instantly; it is removed from the belt and travels inward across the input port's footprint cell on that port's own input belt before being added to the buffer (REQ-MAT-INPUT-INTAKE).
|
||||
- REQ-MAT-INPUT-INTAKE: Accepted input items travel into a building as an animation rather than vanishing off the belt instantly — the input-side mirror of REQ-MAT-OUTPUT-EMERGE. Each input port has its own **input belt** — a virtual belt tile occupying the input port's footprint cell (the body cell the feeding belt points into), oriented in the port's inward flow direction, with progress 0.0 at the outer edge adjacent to the feeding belt and 0.5 at the tile centre. It reuses the belt subsystem: movement at belt speed (REQ-GW-BELT-SPEED), item rendering and spacing (REQ-GW-TILE-SIZE), and capacity/packing (REQ-GW-BELT-CAPACITY), but restricted to the 0.0→0.5 half of the tile. This applies to every building that pulls items from adjacent belts into an input buffer (Smelter, Assembler, Reprocessing Plant, Shipyard); a building may run several input belts at once when belts feed it from more than one side. The HQ is included with the one difference noted below.
|
||||
- **Acceptance & reservation.** The acceptance test of REQ-MAT-INPUT-PORTS is unchanged — an item is accepted only if it is a required input whose per-material input buffer has space — except that "has space" now counts both the items already buffered **and** the items of that material currently travelling on the building's input belts (reserved but not yet arrived), so the total (buffered + in-transit) never exceeds that material's buffer cap (REQ-MAT-INPUT-BUFFER). An item that fails this test is not placed on an input belt and stays on the feeding belt exactly as before, so items that are not required inputs never enter the building.
|
||||
- **Feeding.** An accepted item is removed from the feeding belt on the same tick it would have been taken without this animation, and placed on the input belt at progress 0.0, reserving a slot in its per-material buffer. (An input belt may also be fed directly by an adjacent producer's output belt rather than by a real belt — see REQ-MAT-DIRECT-COUPLE — with the same reservation and entry rules.) A new item is placed only when the input belt's entry slot at progress 0.0 is free (per REQ-GW-BELT-CAPACITY spacing — no in-transit item within a quarter tile of 0.0). The 0.0→0.5 span holds at most three in-transit items (progress 0.0, 0.25, 0.5); the reservation limit above may permit fewer.
|
||||
- **Travel & arrival.** An in-transit item advances from progress 0.0 to 0.5 at belt speed. On reaching progress 0.5 it leaves the input belt and is added to its per-material input buffer, turning its reservation into buffered stock; only then does it count toward starting a production cycle (REQ-MAT-CYCLE). Because the slot was reserved on entry, arrival always succeeds — there is no deadlock.
|
||||
- **Reservation may delay production.** A reserved item occupies buffer capacity for its whole 0.0→0.5 travel without yet being consumable, so an input-starved building may briefly wait for an in-transit item to arrive before it can start a cycle. This is accepted.
|
||||
- **Clearing.** Clearing the input buffers on a recipe or schematic change (REQ-MAT-INPUT-BUFFER) also discards any items currently travelling on the input belts and releases their reservations.
|
||||
- **HQ.** The HQ has no input buffer (REQ-HQ-BELT-INPUT); a building block accepted at an HQ input port travels its input belt the same way but reserves nothing, and is added to the global building blocks stock (REQ-MAT-GLOBAL-STOCK) on reaching progress 0.5.
|
||||
- **Intake rendering (no pop-out).** Mirror of the emergence rendering in REQ-MAT-OUTPUT-EMERGE: the building is rendered over the input belt, so an in-transit item is occluded while inside the footprint and is only visible as it crosses the outer edge — appearing to sink into the port. The portion inside the footprint is hidden, and the item disappears at the tile centre (progress 0.5) as it enters the buffer.
|
||||
- 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 do not appear on the outgoing belt instantly; each item leaves the building by first emerging across the output port tile on that port's own output belt and then transferring onto the adjacent real belt tile (REQ-MAT-OUTPUT-EMERGE). The adjacent belt's direction is otherwise unconstrained (it may flow away from the building or perpendicular to it), except that a belt oriented with its own output edge facing back into the building refuses the transfer and the item stays stuck at the port (REQ-MAT-ACCEPT-DIR, REQ-MAT-OUTPUT-EMERGE).
|
||||
- REQ-MAT-OUTPUT-EMERGE: Items emerge from a building output port as an animation rather than popping directly onto the outgoing belt. Each output port has its own **output belt** — a virtual belt tile occupying the output port tile, oriented in the port's facing direction, with progress 0.0 at the tile's inner edge and 1.0 at the outer (port) edge adjacent to the next real belt tile. It reuses the belt subsystem: movement at belt speed (REQ-GW-BELT-SPEED), item rendering and spacing (REQ-GW-TILE-SIZE), and capacity/packing (REQ-GW-BELT-CAPACITY), but restricted to the 0.5→1.0 half of the tile. This applies to every building that outputs items onto belts (Miner, Smelter, Assembler, Reprocessing Plant, Salvage Bay); it does not apply to the Shipyard, which spawns a ship rather than a belt item (REQ-SHP-SPAWN-PLAYER).
|
||||
- **Feeding.** While the output buffer (REQ-MAT-OUTPUT-BUFFER) holds an item that has not yet begun emerging and the output belt's entry slot at progress 0.5 is free (per REQ-GW-BELT-CAPACITY spacing — no emerging item within a quarter tile of progress 0.5), the next buffered item is placed on the output belt at progress 0.5. Because only the 0.5→1.0 span is used, the output belt holds at most three emerging items (progress 0.5, 0.75, 1.0); once that span is full the building places no further items on it even if the output buffer still holds more.
|
||||
- **Cosmetic hold.** An emerging item still counts as residing in the output buffer (REQ-MAT-GLOBAL-STOCK) for the whole animation; it only leaves the building when it transfers onto a real belt tile at progress 1.0. The output belt therefore adds no inventory capacity beyond the output buffer, and clearing the output buffer on a recipe or schematic change (REQ-MAT-OUTPUT-BUFFER) also removes any items currently emerging.
|
||||
- **Travel & handoff.** An emerging item advances from progress 0.5 to 1.0 at belt speed. At progress 1.0 it attempts to transfer onto the adjacent real belt tile using the normal belt hand-off and accept-direction rules (REQ-MAT-OUTPUT-PORT, REQ-MAT-ACCEPT-DIR): the transfer succeeds only if a transport tile exists there, is not oriented with its output edge facing back into the building, and has free space. On success the item leaves the output buffer and becomes an ordinary item on that belt tile. If instead the output port tile is a directly adjacent building's input edge, the item transfers straight into that building (REQ-MAT-DIRECT-COUPLE).
|
||||
- **Stuck items.** If there is no next real belt tile and no directly-coupled building (REQ-MAT-DIRECT-COUPLE), or the transfer is refused or blocked, the emerging item stops at progress 1.0 and is rendered there (still counted in the output buffer). Following items pile up behind it at progress 0.75 and 0.5 per the packing above, and once the 0.5→1.0 span is full no further items emerge until the front item transfers.
|
||||
- **Emergence rendering (no pop-in).** An emerging item must not simply appear at progress 0.5. The output port tile's building is rendered over the output belt, so an emerging item is occluded while inside the footprint and is revealed progressively as it slides past the port edge — appearing to physically emerge from the building. The portion of the item still within the output port tile is hidden; the portion past the outer edge is drawn.
|
||||
- REQ-MAT-DIRECT-COUPLE: **Direct port coupling.** Two directly adjacent buildings whose ports meet transfer items between them with no intervening transport tile. A direct coupling exists at a shared edge where a producer building's output port tile (the tile it pushes toward, REQ-MAT-OUTPUT-PORT) is a body cell of a consumer building, and the producer's output direction carries the item across that edge into the consumer through one of the consumer's input edges (any perimeter edge other than the consumer's own output port, per REQ-MAT-INPUT-PORTS). Over a direct coupling the two virtual belts chain end to end: an item that reaches progress 1.0 on the producer's output belt at the shared edge (REQ-MAT-OUTPUT-EMERGE) is handed, instead of onto a real belt tile, directly onto the consumer's input belt at progress 0.0 (REQ-MAT-INPUT-INTAKE) and continues inward to the consumer's buffer — so the item appears to slide continuously across the shared edge from one building into the next.
|
||||
- **Acceptance.** The hand-off obeys the consumer's normal input rules (REQ-MAT-INPUT-PORTS, REQ-MAT-INPUT-INTAKE): it succeeds only if the item is a required input of the consumer whose per-material buffer has space (reservation-aware — buffered + in-transit below the cap) and the consumer's input belt entry at progress 0.0 is free. On success the item leaves the producer's output buffer and reserves a slot in the consumer's input buffer, exactly as a belt-fed intake would. If the consumer does not accept the item — it is not one of its inputs, or the buffer is full, or the input-belt entry is occupied — the item stays stuck at the producer's output port at progress 1.0, exactly as when a downstream belt is blocked (REQ-MAT-OUTPUT-EMERGE stuck items).
|
||||
- **Scope.** Direct coupling is the only case in which materials move between buildings without a belt, splitter, or tunnel (REQ-MAT-BELT-ONLY); it bridges only two buildings that are directly adjacent with meeting output/input ports. Transport tiles feeding a building (belt, splitter, or tunnel exit) continue to work through the normal pull, and a producer still hands off to a transport tile placed in the gap as before; a single such tile between two buildings is unaffected by this requirement.
|
||||
- REQ-MAT-ACCEPT-DIR: A transport tile (belt, splitter, tunnel entry, or tunnel exit) accepts an incoming item only through a non-output edge; an item that would enter through one of the tile's output edges is refused. For a belt or a tunnel entry/exit the sole output edge is the one in its facing direction; for a splitter either of its two output directions is an output edge. This applies both to items pushed from an adjacent transport tile and to items deposited by a building's output port (REQ-MAT-OUTPUT-PORT).
|
||||
- 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.
|
||||
@@ -411,6 +429,7 @@ The screen is divided into two columns: a main column (75% width) containing the
|
||||
- REQ-UI-EXPAND-BUTTON: The header bar shows an asteroid expansion button captioned `Expand: <x> Blocks`, where `<x>` is the current expansion cost computed from `world.toml [expansion].cost_building_blocks_formula` at the current number of purchased expansions (REQ-EXP-COST). Clicking the button unlocks the next asteroid expansion (REQ-EXP-UNLOCK, REQ-GW-ASTEROID-EXPAND), spending that many building blocks from the global stock. The button is disabled when the player cannot currently afford the cost (consistent with REQ-UI-BUILD-DISABLED). The caption updates as the cost changes with each purchased expansion.
|
||||
- REQ-UI-WORLD-SIZE: The game world view occupies the full height below the header bar in the main column (75% of the screen width).
|
||||
- REQ-UI-PANEL-COLUMN: The side panel column occupies 25% of the screen width and the full screen height. It is divided into three equal-height panels stacked top to bottom: selected building panel (top), build button grid (middle), and blueprint panel (bottom).
|
||||
- REQ-UI-MODAL-DIM: While a modal dialog, menu, or full-screen state screen is open on top of the game, a transparent black overlay (a dim/scrim) is drawn over the **entire game window** — the header bar, the game world view, and the side panel column — behind that modal, so the game reads as inactive while the modal holds focus. The overlay is shown for every modal that auto-pauses the simulation — the escape menu (REQ-UI-GAME-MENU), the recipe/schematic selection dialog (REQ-UI-SELECT-BUTTON), the layout configuration dialog (REQ-MOD-UI-DIALOG), and the schematic choice dialog (REQ-DEF-SCHEMATIC-DROP) — as well as the game-over screen (REQ-HQ-GAME-OVER) and the win screen (REQ-WIN-SCREEN), which end rather than pause the game. When modals are nested (for example the Create Blueprint name dialog (REQ-MOD-UI-BLUEPRINT-CREATE) opened from the layout configuration dialog), only a single dim is shown over the game window; nested modals do not stack additional overlays. The dim color and opacity are read from `visuals.toml [overlays]` (a semi-transparent black modal-dim color), consistent with the other overlay colors. The overlay is presentation-only and has no effect on the simulation.
|
||||
|
||||
### Game World
|
||||
|
||||
@@ -472,6 +491,14 @@ The screen is divided into two columns: a main column (75% width) containing the
|
||||
- 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.
|
||||
- REQ-UI-ENTITY-CLICK-SELECT: The player can click any ship (player or enemy) or any defence station (player or enemy) in the game world to select it. Clicking a ship or defence station clears any existing selection and establishes a single-entity selection containing only that entity. Ships and defence stations cannot participate in multi-select together with buildings. Clicking a scrap pile instead establishes a scrap selection (REQ-UI-SCRAP-CLICK-SELECT). Clicking empty world space (no building, ship, defence station, or scrap pile) clears the selection.
|
||||
- REQ-UI-SHIP-STATS-PANEL: When a single ship is selected (REQ-UI-ENTITY-CLICK-SELECT), the selected building panel shows a **ship stats panel**. The panel structure mirrors REQ-MOD-UI-STATS-PANEL but reflects the ship's actual live state: stats are computed from its installed modules per REQ-MOD-STAT-CALC. The panel always shows all hull stats: HP (current / maximum), max linear speed, sensor range, main acceleration, maneuvering acceleration, angular acceleration, and max rotation speed. In addition, capability module summaries are shown conditioned on which module types are installed, using the same aggregation rules as REQ-MOD-UI-STATS-PANEL: weapons (combined DPS, maximum range), salvage (combined collection rate, maximum range), and repair (combined repair rate, maximum range), each section appearing only if at least one instance of that module type is installed. While debug draw mode is active (REQ-UI-DEBUG-DRAW), the panel additionally shows the ship's derived threat cost (REQ-MOD-THREAT).
|
||||
- REQ-UI-SHIP-BEHAVIOR: The ship stats panel (REQ-UI-SHIP-STATS-PANEL) additionally displays the selected ship's **current behavior** — a single label naming the top-priority behavior currently governing the ship's navigation, as resolved by the fixed-priority behavior arbitration. Only the winning behavior is named; lower-priority behaviors that are suppressed are not shown, and neither are the salvage/repair cycles that run regardless of the active behavior (REQ-SHP-SALVAGE, REQ-SHP-REPAIR). The label updates live as the ship's behavior changes, and it is always shown (independent of debug draw mode, unlike the threat-cost line of REQ-UI-SHIP-STATS-PANEL). This applies to both player and enemy ships (REQ-UI-ENTITY-CLICK-SELECT); enemy ships only ever show **Engaging** or **Advancing**. The behavior labels (all wrapped in `tr()`) are:
|
||||
- **Retreating** — the ship is retreating (REQ-SHP-RETREAT).
|
||||
- **Engaging** — the ship is engaging a combat target (player: REQ-SHP-COMBAT; enemy: REQ-SHP-ENEMY-AI).
|
||||
- **Salvaging** — the ship is executing salvage navigation: seeking scrap, collecting, or delivering to a Salvage Bay (REQ-SHP-SALVAGE).
|
||||
- **Repairing** — the ship is navigating to a repair target (REQ-SHP-REPAIR).
|
||||
- **Rallying** — the ship is moving to or orbiting the rally point (REQ-SHP-RALLY).
|
||||
- **Standby** — the ship is holding with its fleet (REQ-SHP-STANDBY).
|
||||
- **Advancing** — the ship is executing the baseline forward advance with no higher-priority behavior active (player: REQ-SHP-COMBAT advance toward the enemy; enemy: REQ-SHP-ENEMY-AI advance toward the asteroid).
|
||||
- REQ-UI-STATION-STATS-PANEL: When a single defence station is selected (REQ-UI-ENTITY-CLICK-SELECT), the selected building panel shows a **station stats panel** displaying the station's stats computed at its current level: HP (current / maximum), damage, range, and fire rate.
|
||||
- REQ-UI-SCRAP-CLICK-SELECT: The player can click any scrap pile (REQ-RES-SCRAP-DROP) in the game world to select it. Scrap forms its own selection category: clicking a scrap pile clears any existing selection and establishes a scrap selection containing only that pile. Scrap piles cannot participate in a selection together with buildings, ships, or defence stations. Hit-testing prioritizes actors over scrap: when a building, ship, or defence station lies under the cursor at the same point as a scrap pile, that object is selected in preference to the scrap; a scrap pile is selected only when no building, ship, or defence station is under the cursor. A selected scrap pile that despawns or is fully collected (REQ-RES-SCRAP-DROP) is removed from the selection; once the last selected pile is gone, the selection becomes empty (REQ-UI-EMPTY-SELECTION).
|
||||
- REQ-UI-SCRAP-MULTI-SELECT: Multiple scrap piles can be selected by box-drag or by Ctrl+clicking individual piles to add or remove them from the selection, mirroring building multi-select (REQ-UI-MULTI-SELECT). A scrap selection contains only scrap piles. Because scrap cannot mix with other object types (REQ-UI-SCRAP-CLICK-SELECT), Ctrl+clicking a scrap pile while a building, ship, or defence station selection is active first clears that selection and begins a scrap selection; conversely, selecting a building, ship, or defence station while a scrap selection is active clears the scrap selection. Box-drag disambiguation between buildings and scrap follows REQ-UI-MULTI-SELECT (a box covering any building selects buildings; a box covering scrap but no buildings selects the scrap).
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
#include "HealthComponent.h"
|
||||
#include "InspectWindowClosedEvent.h"
|
||||
#include "ModuleOwnerComponent.h"
|
||||
#include "SelectedBehaviorComponent.h"
|
||||
#include "ShipIdentityComponent.h"
|
||||
#include "ShipStatsCalculator.h"
|
||||
#include "ShipStatsPanel.h"
|
||||
@@ -278,6 +279,8 @@ void InspectWindow::handleEvent(std::shared_ptr<const EntitySelectedEvent> event
|
||||
|
||||
const ShipStats stats = buildShipStatsFromEntity(admin, entity);
|
||||
m_entityStatsPanel->refreshFromLive(stats, health.hp);
|
||||
m_entityStatsPanel->setBehavior(
|
||||
admin.get<SelectedBehaviorComponent>(entity).winner);
|
||||
m_entityStatsPanel->show();
|
||||
m_stationStatsLabel->hide();
|
||||
}
|
||||
@@ -355,6 +358,8 @@ void InspectWindow::refreshEntityStats()
|
||||
{
|
||||
const ShipStats stats = buildShipStatsFromEntity(admin, entity);
|
||||
m_entityStatsPanel->refreshFromLive(stats, health.hp);
|
||||
m_entityStatsPanel->setBehavior(
|
||||
admin.get<SelectedBehaviorComponent>(entity).winner);
|
||||
}
|
||||
else if (admin.hasAll<StationBodyComponent>(entity))
|
||||
{
|
||||
|
||||
45
src/lib/sim/BeltSlot.cpp
Normal file
45
src/lib/sim/BeltSlot.cpp
Normal file
@@ -0,0 +1,45 @@
|
||||
#include "BeltSlot.h"
|
||||
|
||||
#include <cstddef>
|
||||
|
||||
void advanceBeltSlots(std::vector<BeltItemSlot>& slots, double progressPerTick)
|
||||
{
|
||||
for (std::size_t i = 0; i < slots.size(); ++i)
|
||||
{
|
||||
slots[i].progress += progressPerTick;
|
||||
|
||||
// Absolute cap: slot i cannot exceed 1.0 - i * 0.25.
|
||||
const double absoluteCap = 1.0 - static_cast<double>(i) * 0.25;
|
||||
if (slots[i].progress > absoluteCap)
|
||||
{
|
||||
slots[i].progress = absoluteCap;
|
||||
}
|
||||
|
||||
// Gap constraint: must stay 0.25 behind the slot ahead.
|
||||
if (i > 0)
|
||||
{
|
||||
const double gapCap = slots[i - 1].progress - 0.25;
|
||||
if (slots[i].progress > gapCap)
|
||||
{
|
||||
slots[i].progress = (gapCap < 0.0 ? 0.0 : gapCap);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
QPointF beltSlotWorldPos(QPoint tile, Rotation dir, double progress)
|
||||
{
|
||||
// Map progress [0, 1] along the belt direction to a fractional tile-unit position.
|
||||
// Progress 0 = entered from opposite side; 1 = at output edge.
|
||||
const double baseX = tile.x() + 0.5;
|
||||
const double baseY = tile.y() + 0.5;
|
||||
|
||||
switch (dir)
|
||||
{
|
||||
case Rotation::North: return {baseX, baseY - (progress - 0.5)};
|
||||
case Rotation::East: return {baseX + (progress - 0.5), baseY};
|
||||
case Rotation::South: return {baseX, baseY + (progress - 0.5)};
|
||||
case Rotation::West: return {baseX - (progress - 0.5), baseY};
|
||||
}
|
||||
return {baseX, baseY};
|
||||
}
|
||||
30
src/lib/sim/BeltSlot.h
Normal file
30
src/lib/sim/BeltSlot.h
Normal file
@@ -0,0 +1,30 @@
|
||||
#pragma once
|
||||
|
||||
#include <vector>
|
||||
|
||||
#include <QPoint>
|
||||
#include <QPointF>
|
||||
|
||||
#include "Item.h"
|
||||
#include "Rotation.h"
|
||||
|
||||
// A single item on a belt-like lane: an item plus its fractional progress along
|
||||
// the lane's travel direction. Shared by BeltSystem's belt/tunnel tiles and by a
|
||||
// building's virtual output belt (REQ-MAT-OUTPUT-EMERGE) so the packing and
|
||||
// geometry live in exactly one place.
|
||||
struct BeltItemSlot
|
||||
{
|
||||
Item item;
|
||||
double progress; // [0.0, 1.0]: 0 = just entered, 1 = at output edge
|
||||
};
|
||||
|
||||
// Advances every slot in `slots` by `progressPerTick`, applying the standard belt
|
||||
// packing: the front (index 0) carries the highest progress; each following slot
|
||||
// stays at least 0.25 behind the slot ahead and is capped at 1.0 - i * 0.25.
|
||||
// `slots` must be ordered front (highest progress) first. This is the per-tile
|
||||
// advance shared by belts, tunnel entries, and tunnel exits.
|
||||
void advanceBeltSlots(std::vector<BeltItemSlot>& slots, double progressPerTick);
|
||||
|
||||
// World-space centre (in tile units) of a slot at `progress` on a lane occupying
|
||||
// `tile` and flowing in `dir`. Progress 0 = entry edge, 1 = output edge.
|
||||
QPointF beltSlotWorldPos(QPoint tile, Rotation dir, double progress);
|
||||
@@ -27,21 +27,54 @@ QPoint BeltSystem::adjacentTile(QPoint tile, Rotation dir)
|
||||
return tile;
|
||||
}
|
||||
|
||||
QPointF BeltSystem::slotWorldPos(QPoint tile, Rotation dir, double progress)
|
||||
Rotation BeltSystem::oppositeRotation(Rotation dir)
|
||||
{
|
||||
// Map progress [0, 1] along the belt direction to a fractional tile-unit position.
|
||||
// Progress 0 = entered from opposite side; 1 = at output edge.
|
||||
double baseX = tile.x() + 0.5;
|
||||
double baseY = tile.y() + 0.5;
|
||||
|
||||
switch (dir)
|
||||
{
|
||||
case Rotation::North: return {baseX, baseY - (progress - 0.5)};
|
||||
case Rotation::East: return {baseX + (progress - 0.5), baseY};
|
||||
case Rotation::South: return {baseX, baseY + (progress - 0.5)};
|
||||
case Rotation::West: return {baseX - (progress - 0.5), baseY};
|
||||
case Rotation::North: return Rotation::South;
|
||||
case Rotation::East: return Rotation::West;
|
||||
case Rotation::South: return Rotation::North;
|
||||
case Rotation::West: return Rotation::East;
|
||||
}
|
||||
return {baseX, baseY};
|
||||
return dir;
|
||||
}
|
||||
|
||||
bool BeltSystem::entersThroughOutputEdge(QPoint tile, Rotation travelDir) const
|
||||
{
|
||||
// An item travelling in travelDir crosses into the tile through the edge
|
||||
// opposite that direction. If that entry edge is one of the tile's output
|
||||
// edges, the tile must refuse the item (REQ-MAT-ACCEPT-DIR).
|
||||
const Rotation entryEdge = oppositeRotation(travelDir);
|
||||
|
||||
const std::map<std::pair<int, int>, BeltTile>::const_iterator beltIt =
|
||||
m_belts.find(key(tile));
|
||||
if (beltIt != m_belts.end())
|
||||
{
|
||||
return entryEdge == beltIt->second.direction;
|
||||
}
|
||||
|
||||
const std::map<std::pair<int, int>, SplitterTile>::const_iterator splIt =
|
||||
m_splitters.find(key(tile));
|
||||
if (splIt != m_splitters.end())
|
||||
{
|
||||
return entryEdge == splIt->second.outputA || entryEdge == splIt->second.outputB;
|
||||
}
|
||||
|
||||
const std::map<std::pair<int, int>, TunnelEntryTile>::const_iterator teIt =
|
||||
m_tunnelEntries.find(key(tile));
|
||||
if (teIt != m_tunnelEntries.end())
|
||||
{
|
||||
return entryEdge == teIt->second.direction;
|
||||
}
|
||||
|
||||
const std::map<std::pair<int, int>, TunnelExitTile>::const_iterator txIt =
|
||||
m_tunnelExits.find(key(tile));
|
||||
if (txIt != m_tunnelExits.end())
|
||||
{
|
||||
return entryEdge == txIt->second.direction;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -202,6 +235,12 @@ void BeltSystem::reevaluateTunnelPairing()
|
||||
|
||||
bool BeltSystem::tryPutItem(QPoint tile, Item item, Rotation fromDir)
|
||||
{
|
||||
// Refuse items that would enter through the tile's output edge (REQ-MAT-ACCEPT-DIR).
|
||||
if (entersThroughOutputEdge(tile, fromDir))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
const std::map<std::pair<int, int>, BeltTile>::iterator bIt = m_belts.find(key(tile));
|
||||
if (bIt != m_belts.end())
|
||||
{
|
||||
@@ -411,29 +450,7 @@ void BeltSystem::advanceProgress()
|
||||
for (std::map<std::pair<int, int>, BeltTile>::iterator it = m_belts.begin();
|
||||
it != m_belts.end(); ++it)
|
||||
{
|
||||
BeltTile& bt = it->second;
|
||||
|
||||
for (std::size_t i = 0; i < bt.itemSlots.size(); ++i)
|
||||
{
|
||||
bt.itemSlots[i].progress += m_progressPerTick_tpt;
|
||||
|
||||
// Absolute cap: slot i cannot exceed 1.0 - i * 0.25.
|
||||
const double absoluteCap = 1.0 - i * 0.25;
|
||||
if (bt.itemSlots[i].progress > absoluteCap)
|
||||
{
|
||||
bt.itemSlots[i].progress = absoluteCap;
|
||||
}
|
||||
|
||||
// Gap constraint: must stay 0.25 behind the slot ahead.
|
||||
if (i > 0)
|
||||
{
|
||||
const double gapCap = bt.itemSlots[i - 1].progress - 0.25;
|
||||
if (bt.itemSlots[i].progress > gapCap)
|
||||
{
|
||||
bt.itemSlots[i].progress = (gapCap < 0.0 ? 0.0 : gapCap);
|
||||
}
|
||||
}
|
||||
}
|
||||
advanceBeltSlots(it->second.itemSlots, m_progressPerTick_tpt);
|
||||
}
|
||||
|
||||
for (std::map<std::pair<int, int>, SplitterTile>::iterator it = m_splitters.begin();
|
||||
@@ -489,53 +506,13 @@ void BeltSystem::advanceTunnelProgress()
|
||||
for (std::map<std::pair<int, int>, TunnelEntryTile>::iterator it = m_tunnelEntries.begin();
|
||||
it != m_tunnelEntries.end(); ++it)
|
||||
{
|
||||
TunnelEntryTile& te = it->second;
|
||||
|
||||
for (std::size_t i = 0; i < te.itemSlots.size(); ++i)
|
||||
{
|
||||
te.itemSlots[i].progress += m_progressPerTick_tpt;
|
||||
|
||||
const double absoluteCap = 1.0 - i * 0.25;
|
||||
if (te.itemSlots[i].progress > absoluteCap)
|
||||
{
|
||||
te.itemSlots[i].progress = absoluteCap;
|
||||
}
|
||||
|
||||
if (i > 0)
|
||||
{
|
||||
const double gapCap = te.itemSlots[i - 1].progress - 0.25;
|
||||
if (te.itemSlots[i].progress > gapCap)
|
||||
{
|
||||
te.itemSlots[i].progress = (gapCap < 0.0 ? 0.0 : gapCap);
|
||||
}
|
||||
}
|
||||
}
|
||||
advanceBeltSlots(it->second.itemSlots, m_progressPerTick_tpt);
|
||||
}
|
||||
|
||||
for (std::map<std::pair<int, int>, TunnelExitTile>::iterator it = m_tunnelExits.begin();
|
||||
it != m_tunnelExits.end(); ++it)
|
||||
{
|
||||
TunnelExitTile& tx = it->second;
|
||||
|
||||
for (std::size_t i = 0; i < tx.itemSlots.size(); ++i)
|
||||
{
|
||||
tx.itemSlots[i].progress += m_progressPerTick_tpt;
|
||||
|
||||
const double absoluteCap = 1.0 - i * 0.25;
|
||||
if (tx.itemSlots[i].progress > absoluteCap)
|
||||
{
|
||||
tx.itemSlots[i].progress = absoluteCap;
|
||||
}
|
||||
|
||||
if (i > 0)
|
||||
{
|
||||
const double gapCap = tx.itemSlots[i - 1].progress - 0.25;
|
||||
if (tx.itemSlots[i].progress > gapCap)
|
||||
{
|
||||
tx.itemSlots[i].progress = (gapCap < 0.0 ? 0.0 : gapCap);
|
||||
}
|
||||
}
|
||||
}
|
||||
advanceBeltSlots(it->second.itemSlots, m_progressPerTick_tpt);
|
||||
}
|
||||
|
||||
for (TunnelLink& link : m_tunnelLinks)
|
||||
@@ -579,6 +556,13 @@ void BeltSystem::moveItemsToNextTile()
|
||||
const QPoint here = QPoint(it->first.first, it->first.second);
|
||||
const QPoint next = adjacentTile(here, bt.direction);
|
||||
|
||||
// Refuse to hand off into a downstream tile's output edge (REQ-MAT-ACCEPT-DIR);
|
||||
// the item stays blocked at progress 1.0.
|
||||
if (entersThroughOutputEdge(next, bt.direction))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
const std::map<std::pair<int, int>, BeltTile>::iterator nextBelt = m_belts.find(key(next));
|
||||
const std::map<std::pair<int, int>, SplitterTile>::iterator nextSplitter = m_splitters.find(key(next));
|
||||
|
||||
@@ -804,6 +788,12 @@ bool BeltSystem::tryPlaceOnBelt(QPoint tile, Item item)
|
||||
|
||||
bool BeltSystem::tryPushToTile(QPoint dest, Item item, Rotation fromDir)
|
||||
{
|
||||
// Refuse items that would enter through the tile's output edge (REQ-MAT-ACCEPT-DIR).
|
||||
if (entersThroughOutputEdge(dest, fromDir))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (tryPlaceOnBelt(dest, item))
|
||||
{
|
||||
return true;
|
||||
@@ -871,7 +861,7 @@ void BeltSystem::forEachVisualItem(QRect viewportTiles,
|
||||
{
|
||||
VisualItem vi;
|
||||
vi.type = bt.itemSlots[i].item.type;
|
||||
vi.worldPos = slotWorldPos(tile, bt.direction, bt.itemSlots[i].progress);
|
||||
vi.worldPos = beltSlotWorldPos(tile, bt.direction, bt.itemSlots[i].progress);
|
||||
visit(vi);
|
||||
}
|
||||
}
|
||||
@@ -891,7 +881,7 @@ void BeltSystem::forEachVisualItem(QRect viewportTiles,
|
||||
{
|
||||
VisualItem vi;
|
||||
vi.type = st.back[i].item.type;
|
||||
vi.worldPos = slotWorldPos(tile, st.backDir[i], st.back[i].progress);
|
||||
vi.worldPos = beltSlotWorldPos(tile, st.backDir[i], st.back[i].progress);
|
||||
visit(vi);
|
||||
}
|
||||
|
||||
@@ -917,7 +907,7 @@ void BeltSystem::forEachVisualItem(QRect viewportTiles,
|
||||
{
|
||||
VisualItem vi;
|
||||
vi.type = slot->item.type;
|
||||
vi.worldPos = slotWorldPos(tile, dir, slot->progress);
|
||||
vi.worldPos = beltSlotWorldPos(tile, dir, slot->progress);
|
||||
visit(vi);
|
||||
}
|
||||
};
|
||||
@@ -947,7 +937,7 @@ void BeltSystem::forEachVisualItem(QRect viewportTiles,
|
||||
{
|
||||
VisualItem vi;
|
||||
vi.type = te.itemSlots[i].item.type;
|
||||
vi.worldPos = slotWorldPos(tile, te.direction, te.itemSlots[i].progress);
|
||||
vi.worldPos = beltSlotWorldPos(tile, te.direction, te.itemSlots[i].progress);
|
||||
visit(vi);
|
||||
}
|
||||
}
|
||||
@@ -965,7 +955,7 @@ void BeltSystem::forEachVisualItem(QRect viewportTiles,
|
||||
{
|
||||
VisualItem vi;
|
||||
vi.type = tx.itemSlots[i].item.type;
|
||||
vi.worldPos = slotWorldPos(tile, tx.direction, tx.itemSlots[i].progress);
|
||||
vi.worldPos = beltSlotWorldPos(tile, tx.direction, tx.itemSlots[i].progress);
|
||||
visit(vi);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
#include <QPointF>
|
||||
#include <QRect>
|
||||
|
||||
#include "BeltSlot.h"
|
||||
#include "Item.h"
|
||||
#include "ItemType.h"
|
||||
#include "Port.h"
|
||||
@@ -74,8 +75,10 @@ public:
|
||||
// port.direction = direction items flow on that tile
|
||||
//
|
||||
// tryPutItem: place item onto tile.
|
||||
// Returns false if the tile is not a belt/splitter, or tile full.
|
||||
// fromDir: travel direction of the item (used for splitter animation).
|
||||
// Returns false if the tile is not a belt/splitter/tunnel entry, tile full,
|
||||
// or the item would enter through the tile's output edge (REQ-MAT-ACCEPT-DIR).
|
||||
// fromDir: travel direction of the item (used for splitter animation and for
|
||||
// the output-edge check).
|
||||
bool tryPutItem(QPoint tile, Item item, Rotation fromDir = Rotation::West);
|
||||
|
||||
// tryTakeItem: remove and return the leading item from port.tile.
|
||||
@@ -86,6 +89,11 @@ public:
|
||||
// Returns nullopt if tile is not a belt, direction mismatches, or tile empty.
|
||||
std::optional<ItemType> peekItem(Port port) const;
|
||||
|
||||
// Progress advanced per tick at the configured belt speed (tile fraction per
|
||||
// tick). Shared with building output belts so emerging items travel at exactly
|
||||
// the same speed as real belts (REQ-MAT-OUTPUT-EMERGE).
|
||||
double getProgressPerTick_tpt() const { return m_progressPerTick_tpt; }
|
||||
|
||||
// -- Maintenance ---------------------------------------------------------
|
||||
void clearTiles(const std::vector<QPoint>& tiles); // REQ-UI-BELT-CLEAR
|
||||
void tick();
|
||||
@@ -117,15 +125,12 @@ private:
|
||||
|
||||
static std::pair<int, int> key(QPoint tile);
|
||||
static QPoint adjacentTile(QPoint tile, Rotation dir);
|
||||
static Rotation oppositeRotation(Rotation dir);
|
||||
|
||||
// Returns the world-space centre of a slot given tile origin and progress.
|
||||
static QPointF slotWorldPos(QPoint tile, Rotation dir, double progress);
|
||||
|
||||
struct BeltItemSlot
|
||||
{
|
||||
Item item;
|
||||
double progress; // [0.0, 1.0]: 0 = just entered, 1 = at output edge
|
||||
};
|
||||
// True if an item travelling in travelDir would enter the transport tile at
|
||||
// `tile` through one of that tile's output edges (and must therefore be
|
||||
// refused). Returns false if no transport tile occupies `tile`.
|
||||
bool entersThroughOutputEdge(QPoint tile, Rotation travelDir) const;
|
||||
|
||||
struct BeltTile
|
||||
{
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
#include "BuildingId.h"
|
||||
|
||||
#include "entt/entity/entity.hpp"
|
||||
#include "BeltSlot.h"
|
||||
#include "Item.h"
|
||||
#include "ItemType.h"
|
||||
#include "Port.h"
|
||||
@@ -75,6 +76,51 @@ struct Building
|
||||
OutputBuffer outputBuffer;
|
||||
std::optional<Production> production;
|
||||
|
||||
// Items currently emerging from each output port on its virtual output belt
|
||||
// (REQ-MAT-OUTPUT-EMERGE); one lane per output port, parallel to outputPorts.
|
||||
// Each lane holds slots at progress [0.5, 1.0], front (highest progress) first.
|
||||
// An emerging item still counts as residing in the output buffer until it hands
|
||||
// off onto a real belt at progress 1.0.
|
||||
std::vector<std::vector<BeltItemSlot>> emergingItems;
|
||||
|
||||
// Total items held on the output side: buffered plus still-emerging. The
|
||||
// output-buffer capacity rule (REQ-MAT-OUTPUT-BUFFER) counts emerging items,
|
||||
// since they have not yet left the building.
|
||||
int outputItemCount() const
|
||||
{
|
||||
int count = static_cast<int>(outputBuffer.items.size());
|
||||
for (const std::vector<BeltItemSlot>& lane : emergingItems)
|
||||
{
|
||||
count += static_cast<int>(lane.size());
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
// Items currently travelling inward on each input port's virtual input belt
|
||||
// (REQ-MAT-INPUT-INTAKE); one lane per input port, parallel to inputPorts. Each
|
||||
// lane holds slots at progress [0.0, 0.5], front (highest progress) first. An
|
||||
// in-transit item has reserved a slot in its per-material input buffer but is
|
||||
// not yet consumable — it enters the buffer only on reaching progress 0.5.
|
||||
std::vector<std::vector<BeltItemSlot>> incomingItems;
|
||||
|
||||
// Buffered plus in-transit count of one input material. The acceptance/space
|
||||
// test (REQ-MAT-INPUT-PORTS, REQ-MAT-INPUT-INTAKE) counts in-transit items, so
|
||||
// buffered + reserved never exceeds the material's cap (REQ-MAT-INPUT-BUFFER).
|
||||
int pendingInputCount(const ItemType& type) const
|
||||
{
|
||||
int count = 0;
|
||||
const std::map<ItemType, int>::const_iterator it = inputBuffer.counts.find(type);
|
||||
if (it != inputBuffer.counts.end()) { count = it->second; }
|
||||
for (const std::vector<BeltItemSlot>& lane : incomingItems)
|
||||
{
|
||||
for (const BeltItemSlot& slot : lane)
|
||||
{
|
||||
if (slot.item.type == type) { ++count; }
|
||||
}
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
// Pre-computed from surface mask at placement; in absolute world coordinates.
|
||||
std::vector<QPoint> bodyCells;
|
||||
std::vector<Port> outputPorts;
|
||||
|
||||
@@ -20,6 +20,45 @@ bool isAutoRecipeBuildingType(BuildingType type)
|
||||
return type == BuildingType::Smelter
|
||||
|| type == BuildingType::ReprocessingPlant;
|
||||
}
|
||||
|
||||
// The building body tile that owns an output port, given the port's outside tile
|
||||
// (port.tile) and its facing direction. The virtual output belt occupies this tile
|
||||
// and flows toward port.tile (REQ-MAT-OUTPUT-EMERGE).
|
||||
QPoint outputBodyTile(QPoint portTile, Rotation direction)
|
||||
{
|
||||
switch (direction)
|
||||
{
|
||||
case Rotation::East: return portTile + QPoint(-1, 0);
|
||||
case Rotation::West: return portTile + QPoint( 1, 0);
|
||||
case Rotation::North: return portTile + QPoint( 0, 1);
|
||||
case Rotation::South: return portTile + QPoint( 0, -1);
|
||||
}
|
||||
return portTile;
|
||||
}
|
||||
|
||||
// The building body tile an input port feeds into, given the port's outside belt
|
||||
// tile (port.tile) and its inward flow direction. The virtual input belt occupies
|
||||
// this tile and flows from the outer edge (progress 0.0) to the centre (0.5)
|
||||
// (REQ-MAT-INPUT-INTAKE).
|
||||
QPoint inputBodyTile(QPoint portTile, Rotation inwardDirection)
|
||||
{
|
||||
switch (inwardDirection)
|
||||
{
|
||||
case Rotation::East: return portTile + QPoint( 1, 0);
|
||||
case Rotation::West: return portTile + QPoint(-1, 0);
|
||||
case Rotation::North: return portTile + QPoint( 0, -1);
|
||||
case Rotation::South: return portTile + QPoint( 0, 1);
|
||||
}
|
||||
return portTile;
|
||||
}
|
||||
|
||||
// An input belt accepts a new item at progress 0.0 only when it holds fewer than
|
||||
// three items and the entry slot is clear (nothing within a quarter tile of 0.0),
|
||||
// matching the belt packing used elsewhere (REQ-GW-BELT-CAPACITY).
|
||||
bool inputLaneEntryFree(const std::vector<BeltItemSlot>& lane)
|
||||
{
|
||||
return lane.size() < 3 && (lane.empty() || lane.back().progress >= 0.25);
|
||||
}
|
||||
} // namespace
|
||||
|
||||
BuildingSystem::BuildingSystem(const GameConfig& config,
|
||||
@@ -523,6 +562,12 @@ void BuildingSystem::setRecipe(BuildingId id, const std::string& recipeId)
|
||||
building.inputBuffer.caps.clear();
|
||||
building.outputBuffer.items.clear();
|
||||
building.outputBuffer.capacity = 0;
|
||||
// Emerging items are part of the output buffer, so clearing it on a
|
||||
// recipe change discards them too (REQ-MAT-OUTPUT-EMERGE); in-transit
|
||||
// input items are discarded and their reservations released
|
||||
// (REQ-MAT-INPUT-INTAKE).
|
||||
for (std::vector<BeltItemSlot>& lane : building.emergingItems) { lane.clear(); }
|
||||
for (std::vector<BeltItemSlot>& lane : building.incomingItems) { lane.clear(); }
|
||||
building.production = std::nullopt;
|
||||
|
||||
if (!recipeId.empty())
|
||||
@@ -569,6 +614,8 @@ void BuildingSystem::setShipLayout(BuildingId id, const ShipLayoutConfig& layout
|
||||
building.inputBuffer.caps.clear();
|
||||
building.outputBuffer.items.clear();
|
||||
building.outputBuffer.capacity = 0;
|
||||
for (std::vector<BeltItemSlot>& lane : building.emergingItems) { lane.clear(); }
|
||||
for (std::vector<BeltItemSlot>& lane : building.incomingItems) { lane.clear(); }
|
||||
if (!building.recipeId.empty() && building.type == BuildingType::Shipyard)
|
||||
{
|
||||
initShipyardBuffers(building);
|
||||
@@ -672,7 +719,9 @@ void BuildingSystem::tickConstruction(Tick currentTick)
|
||||
absPort.direction = port.direction;
|
||||
building.outputPorts.push_back(absPort);
|
||||
}
|
||||
building.emergingItems.resize(building.outputPorts.size());
|
||||
building.inputPorts = computeInputPorts(building);
|
||||
building.incomingItems.assign(building.inputPorts.size(), {});
|
||||
|
||||
if (building.type == BuildingType::SalvageBay)
|
||||
{
|
||||
@@ -745,85 +794,119 @@ void BuildingSystem::tickConstruction(Tick currentTick)
|
||||
void BuildingSystem::tickBeltPull()
|
||||
{
|
||||
TRACE();
|
||||
// Same per-tick step as the belts, so items travel inward at belt speed
|
||||
// (REQ-GW-BELT-SPEED, REQ-MAT-INPUT-INTAKE).
|
||||
const double progressPerTick = m_belts.getProgressPerTick_tpt();
|
||||
|
||||
for (Building& building : m_buildings)
|
||||
{
|
||||
// HQ: pull building_block items and add to global stock.
|
||||
if (building.type == BuildingType::Hq)
|
||||
const bool isHq = (building.type == BuildingType::Hq);
|
||||
|
||||
// 1. Advance every input belt and deliver arrivals (progress >= 0.5) into
|
||||
// the input buffer — or the global stock for the HQ. Runs for all
|
||||
// buildings so in-transit items keep moving even when feeding is gated
|
||||
// off, and arrivals become consumable before tickProduction (step 4).
|
||||
for (std::size_t i = 0; i < building.incomingItems.size(); ++i)
|
||||
{
|
||||
for (const Port& port : building.inputPorts)
|
||||
std::vector<BeltItemSlot>& lane = building.incomingItems[i];
|
||||
advanceBeltSlots(lane, progressPerTick);
|
||||
while (!lane.empty() && lane.front().progress >= 0.5)
|
||||
{
|
||||
const std::optional<ItemType> peeked = m_belts.peekItem(port);
|
||||
if (peeked && peeked->id == "building_block")
|
||||
const Item arrived = lane.front().item;
|
||||
lane.erase(lane.begin());
|
||||
if (isHq)
|
||||
{
|
||||
const std::optional<Item> taken = m_belts.tryTakeItem(port);
|
||||
if (taken)
|
||||
{
|
||||
m_addBuildingBlocks(1);
|
||||
}
|
||||
m_addBuildingBlocks(1);
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// Auto-recipe buildings (Smelter, Reprocessing Plant) accept any item
|
||||
// that is an input to one of their recipes; their caps already span the
|
||||
// union of those inputs (initAutoBuffers), so no recipe lookup is needed.
|
||||
if (!isAutoRecipeBuildingType(building.type))
|
||||
{
|
||||
if (building.recipeId.empty())
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (building.type != BuildingType::Shipyard)
|
||||
{
|
||||
const RecipeDef* recipe = findRecipe(building.recipeId, building.type);
|
||||
if (!recipe || recipe->inputs.empty())
|
||||
else
|
||||
{
|
||||
continue;
|
||||
building.inputBuffer.counts[arrived.type]++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const Port& port : building.inputPorts)
|
||||
// 2. Feed accepted items from adjacent belts onto the input belts at
|
||||
// progress 0.0. The acceptance rules — the HQ building-block case, the
|
||||
// required-input check, and the reservation — live in canAcceptInput so
|
||||
// direct coupling (REQ-MAT-DIRECT-COUPLE) shares them exactly.
|
||||
for (std::size_t i = 0; i < building.inputPorts.size(); ++i)
|
||||
{
|
||||
const std::optional<ItemType> peeked = m_belts.peekItem(port);
|
||||
if (!peeked)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
const ItemType& type = *peeked;
|
||||
|
||||
// Accept only if this type is a required input and buffer has space.
|
||||
const std::map<ItemType, int>::const_iterator capIt =
|
||||
building.inputBuffer.caps.find(type);
|
||||
if (capIt == building.inputBuffer.caps.end() || capIt->second == 0)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
const int current = [&]() -> int
|
||||
{
|
||||
const std::map<ItemType, int>::const_iterator it =
|
||||
building.inputBuffer.counts.find(type);
|
||||
return (it != building.inputBuffer.counts.end()) ? it->second : 0;
|
||||
}();
|
||||
|
||||
if (current >= capIt->second)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
const std::optional<Item> taken = m_belts.tryTakeItem(port);
|
||||
const std::optional<ItemType> peeked = m_belts.peekItem(building.inputPorts[i]);
|
||||
if (!peeked) { continue; }
|
||||
if (!canAcceptInput(building, i, *peeked)) { continue; }
|
||||
const std::optional<Item> taken = m_belts.tryTakeItem(building.inputPorts[i]);
|
||||
if (taken)
|
||||
{
|
||||
building.inputBuffer.counts[taken->type]++;
|
||||
depositToInputBelt(building, i, *taken);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
bool BuildingSystem::canAcceptInput(const Building& consumer,
|
||||
std::size_t inputPortIndex,
|
||||
const ItemType& type) const
|
||||
{
|
||||
if (inputPortIndex >= consumer.incomingItems.size()) { return false; }
|
||||
if (!inputLaneEntryFree(consumer.incomingItems[inputPortIndex])) { return false; }
|
||||
|
||||
// The HQ has no input buffer; it accepts building blocks into the global stock
|
||||
// (REQ-HQ-BELT-INPUT) with no reservation.
|
||||
if (consumer.type == BuildingType::Hq)
|
||||
{
|
||||
return type.id == "building_block";
|
||||
}
|
||||
|
||||
// Everyone else: the item must be a required input whose reservation-aware
|
||||
// buffer has room — buffered + in-transit below the cap (REQ-MAT-INPUT-INTAKE).
|
||||
const std::map<ItemType, int>::const_iterator capIt =
|
||||
consumer.inputBuffer.caps.find(type);
|
||||
if (capIt == consumer.inputBuffer.caps.end() || capIt->second == 0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
return consumer.pendingInputCount(type) < capIt->second;
|
||||
}
|
||||
|
||||
void BuildingSystem::depositToInputBelt(Building& consumer,
|
||||
std::size_t inputPortIndex,
|
||||
const Item& item)
|
||||
{
|
||||
consumer.incomingItems[inputPortIndex].push_back(BeltItemSlot{item, 0.0});
|
||||
}
|
||||
|
||||
bool BuildingSystem::tryDirectCoupleDeposit(BuildingId producerId,
|
||||
const Port& outputPort,
|
||||
const Item& item)
|
||||
{
|
||||
const std::map<std::pair<int, int>, BuildingId>::const_iterator occIt =
|
||||
m_tileOccupancy.find({outputPort.tile.x(), outputPort.tile.y()});
|
||||
if (occIt == m_tileOccupancy.end() || occIt->second == producerId)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
Building* consumer = findBuildingMutable(occIt->second);
|
||||
if (!consumer)
|
||||
{
|
||||
return false; // an unbuilt construction site, or not an operational building
|
||||
}
|
||||
|
||||
// The coupling is the consumer input port meeting this output port: same flow
|
||||
// direction, feeding the producer's output-port tile (REQ-MAT-DIRECT-COUPLE).
|
||||
for (std::size_t j = 0; j < consumer->inputPorts.size(); ++j)
|
||||
{
|
||||
const Port& in = consumer->inputPorts[j];
|
||||
if (in.direction != outputPort.direction) { continue; }
|
||||
if (inputBodyTile(in.tile, in.direction) != outputPort.tile) { continue; }
|
||||
|
||||
if (!canAcceptInput(*consumer, j, item.type)) { return false; }
|
||||
depositToInputBelt(*consumer, j, item);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
void BuildingSystem::tickProduction(Tick currentTick)
|
||||
{
|
||||
TRACE();
|
||||
@@ -929,8 +1012,9 @@ void BuildingSystem::tickProduction(Tick currentTick)
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Output buffer has space for chosen outputs?
|
||||
const int newSize = static_cast<int>(building.outputBuffer.items.size())
|
||||
// 3. Output buffer has space for chosen outputs? Emerging items still
|
||||
// count against the buffer (REQ-MAT-OUTPUT-EMERGE).
|
||||
const int newSize = building.outputItemCount()
|
||||
+ static_cast<int>(chosen.size());
|
||||
if (newSize > building.outputBuffer.capacity)
|
||||
{
|
||||
@@ -1064,31 +1148,97 @@ void BuildingSystem::tickShipyardProduction(Tick currentTick)
|
||||
}
|
||||
}
|
||||
|
||||
void BuildingSystem::tickBeltPush()
|
||||
void BuildingSystem::tickOutputBelts()
|
||||
{
|
||||
TRACE();
|
||||
// Use BeltSystem's own per-tick step so emerging items travel at exactly the
|
||||
// same speed as real belts (REQ-GW-BELT-SPEED, REQ-MAT-OUTPUT-EMERGE).
|
||||
const double progressPerTick = m_belts.getProgressPerTick_tpt();
|
||||
|
||||
for (Building& building : m_buildings)
|
||||
{
|
||||
if (building.outputBuffer.items.empty())
|
||||
for (std::size_t p = 0; p < building.outputPorts.size(); ++p)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
const Port& port = building.outputPorts[p];
|
||||
std::vector<BeltItemSlot>& lane = building.emergingItems[p];
|
||||
|
||||
for (const Port& outputPort : building.outputPorts)
|
||||
{
|
||||
if (building.outputBuffer.items.empty())
|
||||
// 1. Advance emerging items using the shared belt packing (progress
|
||||
// caps to 0.5 / 0.75 / 1.0 for up to three items).
|
||||
advanceBeltSlots(lane, progressPerTick);
|
||||
|
||||
// 2. Hand the front item off once it reaches the output edge (progress
|
||||
// 1.0): onto the adjacent real belt, or — if a building's input edge
|
||||
// meets this port — straight into that building (REQ-MAT-DIRECT-COUPLE).
|
||||
// On refusal (no belt/coupling, output-edge per REQ-MAT-ACCEPT-DIR, or
|
||||
// a full target) it stays stuck at 1.0.
|
||||
if (!lane.empty() && lane.front().progress >= 1.0)
|
||||
{
|
||||
break;
|
||||
const Item item = lane.front().item;
|
||||
if (m_belts.tryPutItem(port.tile, item, port.direction)
|
||||
|| tryDirectCoupleDeposit(building.id, port, item))
|
||||
{
|
||||
lane.erase(lane.begin());
|
||||
}
|
||||
}
|
||||
const Item item = building.outputBuffer.items.front();
|
||||
if (m_belts.tryPutItem(outputPort.tile, item, outputPort.direction))
|
||||
|
||||
// 3. Feed the next buffered item onto the lane at progress 0.5 when the
|
||||
// entry slot is free — the lane holds at most three items and a new
|
||||
// one needs a quarter-tile clearance ahead of 0.5.
|
||||
if (!building.outputBuffer.items.empty()
|
||||
&& lane.size() < 3
|
||||
&& (lane.empty() || lane.back().progress >= 0.75))
|
||||
{
|
||||
lane.push_back(BeltItemSlot{building.outputBuffer.items.front(), 0.5});
|
||||
building.outputBuffer.items.erase(building.outputBuffer.items.begin());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void BuildingSystem::forEachEmergingItem(
|
||||
const std::function<void(const ItemType&, QPointF)>& visit) const
|
||||
{
|
||||
for (const Building& building : m_buildings)
|
||||
{
|
||||
for (std::size_t p = 0; p < building.outputPorts.size(); ++p)
|
||||
{
|
||||
const Port& port = building.outputPorts[p];
|
||||
const QPoint bodyTile = outputBodyTile(port.tile, port.direction);
|
||||
const std::vector<BeltItemSlot>& lane = building.emergingItems[p];
|
||||
|
||||
// Render least-progressed first (bottom) → most-progressed last (top),
|
||||
// matching belt item ordering (REQ-GW-TILE-SIZE).
|
||||
for (int i = static_cast<int>(lane.size()) - 1; i >= 0; --i)
|
||||
{
|
||||
visit(lane[i].item.type,
|
||||
beltSlotWorldPos(bodyTile, port.direction, lane[i].progress));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void BuildingSystem::forEachIncomingItem(
|
||||
const std::function<void(const ItemType&, QPointF)>& visit) const
|
||||
{
|
||||
for (const Building& building : m_buildings)
|
||||
{
|
||||
for (std::size_t p = 0; p < building.inputPorts.size(); ++p)
|
||||
{
|
||||
const Port& port = building.inputPorts[p];
|
||||
const QPoint bodyTile = inputBodyTile(port.tile, port.direction);
|
||||
const std::vector<BeltItemSlot>& lane = building.incomingItems[p];
|
||||
|
||||
// Render least-progressed first (bottom) → most-progressed last (top),
|
||||
// matching belt item ordering (REQ-GW-TILE-SIZE).
|
||||
for (int i = static_cast<int>(lane.size()) - 1; i >= 0; --i)
|
||||
{
|
||||
visit(lane[i].item.type,
|
||||
beltSlotWorldPos(bodyTile, port.direction, lane[i].progress));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Queries
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -1105,6 +1255,18 @@ const Building* BuildingSystem::findBuilding(BuildingId id) const
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
Building* BuildingSystem::findBuildingMutable(BuildingId id)
|
||||
{
|
||||
for (Building& building : m_buildings)
|
||||
{
|
||||
if (building.id == id)
|
||||
{
|
||||
return &building;
|
||||
}
|
||||
}
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
const ConstructionSite* BuildingSystem::findSite(BuildingId id) const
|
||||
{
|
||||
for (const ConstructionSite& site : m_constructionQueue)
|
||||
@@ -1278,7 +1440,14 @@ void BuildingSystem::rotateInPlace(BuildingId id, Rotation newRotation)
|
||||
absPort.direction = port.direction;
|
||||
b.outputPorts.push_back(absPort);
|
||||
}
|
||||
// The output ports moved; discard any in-flight emerging items and re-size
|
||||
// the lanes to the new port set (REQ-MAT-OUTPUT-EMERGE).
|
||||
b.emergingItems.clear();
|
||||
b.emergingItems.resize(b.outputPorts.size());
|
||||
b.inputPorts = computeInputPorts(b);
|
||||
// Likewise discard in-transit input items and re-size the input belts to
|
||||
// the new port set (REQ-MAT-INPUT-INTAKE).
|
||||
b.incomingItems.assign(b.inputPorts.size(), {});
|
||||
|
||||
// Re-register with BeltSystem (items on tile are discarded).
|
||||
if (b.type == BuildingType::Belt)
|
||||
@@ -1347,7 +1516,9 @@ bool BuildingSystem::deliverScrapToSalvageBay(BuildingId bayId)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
if (static_cast<int>(bay->outputBuffer.items.size()) >= bay->outputBuffer.capacity)
|
||||
// Emerging scrap still counts against the bay's holding capacity
|
||||
// (REQ-MAT-OUTPUT-EMERGE).
|
||||
if (bay->outputItemCount() >= bay->outputBuffer.capacity)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
@@ -1382,7 +1553,9 @@ BuildingId BuildingSystem::placeImmediate(BuildingType type,
|
||||
absPort.direction = port.direction;
|
||||
building.outputPorts.push_back(absPort);
|
||||
}
|
||||
building.emergingItems.resize(building.outputPorts.size());
|
||||
building.inputPorts = computeInputPorts(building);
|
||||
building.incomingItems.assign(building.inputPorts.size(), {});
|
||||
|
||||
if (type == BuildingType::SalvageBay)
|
||||
{
|
||||
@@ -1488,6 +1661,26 @@ void BuildingSystem::appendChecksum(Hasher& hasher) const
|
||||
appendInputBuffer(hasher, b.inputBuffer);
|
||||
appendItems(hasher, b.outputBuffer.items);
|
||||
hasher.append(b.outputBuffer.capacity);
|
||||
hasher.append(b.emergingItems.size());
|
||||
for (const std::vector<BeltItemSlot>& lane : b.emergingItems)
|
||||
{
|
||||
hasher.append(lane.size());
|
||||
for (const BeltItemSlot& slot : lane)
|
||||
{
|
||||
hasher.append(slot.item.type.id);
|
||||
hasher.append(slot.progress);
|
||||
}
|
||||
}
|
||||
hasher.append(b.incomingItems.size());
|
||||
for (const std::vector<BeltItemSlot>& lane : b.incomingItems)
|
||||
{
|
||||
hasher.append(lane.size());
|
||||
for (const BeltItemSlot& slot : lane)
|
||||
{
|
||||
hasher.append(slot.item.type.id);
|
||||
hasher.append(slot.progress);
|
||||
}
|
||||
}
|
||||
hasher.append(b.production.has_value());
|
||||
if (b.production.has_value())
|
||||
{
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
#include <vector>
|
||||
|
||||
#include <QPoint>
|
||||
#include <QPointF>
|
||||
#include <QVector2D>
|
||||
|
||||
#include "BeltSystem.h"
|
||||
@@ -94,7 +95,10 @@ public:
|
||||
void tickBeltPull();
|
||||
void tickProduction(Tick currentTick);
|
||||
void tickShipyardProduction(Tick currentTick);
|
||||
void tickBeltPush();
|
||||
// Advances each building's virtual output belts, hands finished items off onto
|
||||
// the adjacent real belt, and feeds new buffered items into them
|
||||
// (REQ-MAT-OUTPUT-EMERGE).
|
||||
void tickOutputBelts();
|
||||
|
||||
// -- Queries -------------------------------------------------------------
|
||||
struct BeltTileInfo
|
||||
@@ -121,6 +125,19 @@ public:
|
||||
std::vector<BeltTileInfo> allBeltTiles() const;
|
||||
bool isTileOccupied(QPoint tile) const;
|
||||
|
||||
// Visits every item currently emerging from a building output port on its
|
||||
// virtual output belt (REQ-MAT-OUTPUT-EMERGE), passing the item type and its
|
||||
// world-space centre (in tile units). Least-progressed first (drawn bottom) so
|
||||
// callers can paint in visit order (REQ-GW-TILE-SIZE ordering).
|
||||
void forEachEmergingItem(
|
||||
const std::function<void(const ItemType&, QPointF)>& visit) const;
|
||||
|
||||
// Visits every item currently travelling inward on a building input port's
|
||||
// virtual input belt (REQ-MAT-INPUT-INTAKE), passing the item type and its
|
||||
// world-space centre (in tile units). Least-progressed first (drawn bottom).
|
||||
void forEachIncomingItem(
|
||||
const std::function<void(const ItemType&, QPointF)>& visit) const;
|
||||
|
||||
// Returns the entity id of the building or construction site whose footprint
|
||||
// exactly coincides with the ghost (type, anchor, rot) and is of the same
|
||||
// building type. Returns nullopt otherwise.
|
||||
@@ -164,6 +181,25 @@ public:
|
||||
void appendChecksum(Hasher& hasher) const;
|
||||
|
||||
private:
|
||||
Building* findBuildingMutable(BuildingId id);
|
||||
// True if the consumer would accept `type` at the given input port right now:
|
||||
// it is a required input (or a building block for the HQ), the reservation-aware
|
||||
// buffer has room, and the input belt entry is free (REQ-MAT-INPUT-INTAKE).
|
||||
bool canAcceptInput(const Building& consumer,
|
||||
std::size_t inputPortIndex,
|
||||
const ItemType& type) const;
|
||||
// Places an accepted item onto the consumer's input belt at progress 0.0,
|
||||
// reserving a per-material buffer slot (REQ-MAT-INPUT-INTAKE).
|
||||
void depositToInputBelt(Building& consumer,
|
||||
std::size_t inputPortIndex,
|
||||
const Item& item);
|
||||
// Attempts to hand an emerging output item straight into a directly adjacent
|
||||
// building whose input edge meets the producer's output port (REQ-MAT-DIRECT-COUPLE).
|
||||
// Returns true if the item was accepted onto the consumer's input belt.
|
||||
bool tryDirectCoupleDeposit(BuildingId producerId,
|
||||
const Port& outputPort,
|
||||
const Item& item);
|
||||
|
||||
const BuildingDef* findBuildingDef(BuildingType type) const;
|
||||
const RecipeDef* findRecipe(const std::string& id, BuildingType type) const;
|
||||
const ShipDef* findShipDef(const std::string& id) const;
|
||||
|
||||
@@ -8,6 +8,7 @@ SET(HDRS
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/ReplayReader.h
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/ReplayPlayer.h
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/TickDriver.h
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/BeltSlot.h
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/BeltSystem.h
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/Building.h
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/BuildingConfig.h
|
||||
@@ -31,6 +32,7 @@ SET(SRCS
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/ReplayReader.cpp
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/ReplayPlayer.cpp
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/TickDriver.cpp
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/BeltSlot.cpp
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/BeltSystem.cpp
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/BuildingConfig.cpp
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/BuildingSystem.cpp
|
||||
|
||||
@@ -315,7 +315,7 @@ void Simulation::tick()
|
||||
m_buildingSystem->tickBeltPull(); // step 3
|
||||
m_buildingSystem->tickProduction(m_currentTick); // step 4
|
||||
m_buildingSystem->tickShipyardProduction(m_currentTick); // step 4b
|
||||
m_buildingSystem->tickBeltPush(); // step 5
|
||||
m_buildingSystem->tickOutputBelts(); // step 5
|
||||
m_beltSystem.tick(); // step 6
|
||||
|
||||
// Step 7: ship behavior systems (movement arbitration via intent priority)
|
||||
|
||||
@@ -44,14 +44,14 @@ TEST_CASE("BeltSystem: tryPutItem succeeds on registered belt", "[belt]")
|
||||
const QPoint tile(0, 0);
|
||||
bs.placeBelt(tile, Rotation::East);
|
||||
|
||||
REQUIRE(bs.tryPutItem(tile, makeItem("iron_ore")));
|
||||
REQUIRE(bs.tryPutItem(tile, makeItem("iron_ore"), Rotation::East));
|
||||
}
|
||||
|
||||
TEST_CASE("BeltSystem: tryPutItem fails on unregistered tile", "[belt]")
|
||||
{
|
||||
BeltSystem bs(kFastBeltSpeed);
|
||||
|
||||
REQUIRE_FALSE(bs.tryPutItem(QPoint(0, 0), makeItem("iron_ore")));
|
||||
REQUIRE_FALSE(bs.tryPutItem(QPoint(0, 0), makeItem("iron_ore"), Rotation::East));
|
||||
}
|
||||
|
||||
TEST_CASE("BeltSystem: tryPutItem fails after removeTile", "[belt]")
|
||||
@@ -61,7 +61,7 @@ TEST_CASE("BeltSystem: tryPutItem fails after removeTile", "[belt]")
|
||||
bs.placeBelt(tile, Rotation::East);
|
||||
bs.removeTile(tile);
|
||||
|
||||
REQUIRE_FALSE(bs.tryPutItem(tile, makeItem("iron_ore")));
|
||||
REQUIRE_FALSE(bs.tryPutItem(tile, makeItem("iron_ore"), Rotation::East));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -74,10 +74,10 @@ TEST_CASE("BeltSystem: four items fit in one tile", "[belt]")
|
||||
const QPoint tile(0, 0);
|
||||
bs.placeBelt(tile, Rotation::East);
|
||||
|
||||
REQUIRE(bs.tryPutItem(tile, makeItem("a")));
|
||||
REQUIRE(bs.tryPutItem(tile, makeItem("b")));
|
||||
REQUIRE(bs.tryPutItem(tile, makeItem("c")));
|
||||
REQUIRE(bs.tryPutItem(tile, makeItem("d")));
|
||||
REQUIRE(bs.tryPutItem(tile, makeItem("a"), Rotation::East));
|
||||
REQUIRE(bs.tryPutItem(tile, makeItem("b"), Rotation::East));
|
||||
REQUIRE(bs.tryPutItem(tile, makeItem("c"), Rotation::East));
|
||||
REQUIRE(bs.tryPutItem(tile, makeItem("d"), Rotation::East));
|
||||
}
|
||||
|
||||
TEST_CASE("BeltSystem: fifth tryPutItem on full tile returns false", "[belt]")
|
||||
@@ -86,12 +86,12 @@ TEST_CASE("BeltSystem: fifth tryPutItem on full tile returns false", "[belt]")
|
||||
const QPoint tile(0, 0);
|
||||
bs.placeBelt(tile, Rotation::East);
|
||||
|
||||
bs.tryPutItem(tile, makeItem("a"));
|
||||
bs.tryPutItem(tile, makeItem("b"));
|
||||
bs.tryPutItem(tile, makeItem("c"));
|
||||
bs.tryPutItem(tile, makeItem("d"));
|
||||
bs.tryPutItem(tile, makeItem("a"), Rotation::East);
|
||||
bs.tryPutItem(tile, makeItem("b"), Rotation::East);
|
||||
bs.tryPutItem(tile, makeItem("c"), Rotation::East);
|
||||
bs.tryPutItem(tile, makeItem("d"), Rotation::East);
|
||||
|
||||
REQUIRE_FALSE(bs.tryPutItem(tile, makeItem("e")));
|
||||
REQUIRE_FALSE(bs.tryPutItem(tile, makeItem("e"), Rotation::East));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -103,7 +103,7 @@ TEST_CASE("BeltSystem: tryTakeItem returns placed item after reaching output edg
|
||||
BeltSystem bs(kFastBeltSpeed);
|
||||
const QPoint tile(0, 0);
|
||||
bs.placeBelt(tile, Rotation::East);
|
||||
bs.tryPutItem(tile, makeItem("iron_ore"));
|
||||
bs.tryPutItem(tile, makeItem("iron_ore"), Rotation::East);
|
||||
bs.tick(); // advance to output edge
|
||||
|
||||
const std::optional<Item> taken = bs.tryTakeItem(eastPort(tile));
|
||||
@@ -117,7 +117,7 @@ TEST_CASE("BeltSystem: tryTakeItem requires item to reach output edge before yie
|
||||
BeltSystem bs(kFastBeltSpeed);
|
||||
const QPoint tile(0, 0);
|
||||
bs.placeBelt(tile, Rotation::East);
|
||||
bs.tryPutItem(tile, makeItem("iron_ore"));
|
||||
bs.tryPutItem(tile, makeItem("iron_ore"), Rotation::East);
|
||||
|
||||
// Item placed but not yet at output edge — must not be available.
|
||||
REQUIRE_FALSE(bs.tryTakeItem(eastPort(tile)).has_value());
|
||||
@@ -133,8 +133,8 @@ TEST_CASE("BeltSystem: tryTakeItem with two items returns both after each reache
|
||||
BeltSystem bs(kFastBeltSpeed);
|
||||
const QPoint tile(0, 0);
|
||||
bs.placeBelt(tile, Rotation::East);
|
||||
bs.tryPutItem(tile, makeItem("first"));
|
||||
bs.tryPutItem(tile, makeItem("second"));
|
||||
bs.tryPutItem(tile, makeItem("first"), Rotation::East);
|
||||
bs.tryPutItem(tile, makeItem("second"), Rotation::East);
|
||||
|
||||
// Front item reaches output edge after one tick.
|
||||
bs.tick();
|
||||
@@ -169,7 +169,7 @@ TEST_CASE("BeltSystem: item transfers from tile A to tile B and becomes availabl
|
||||
bs.placeBelt(tileA, Rotation::East);
|
||||
bs.placeBelt(tileB, Rotation::East);
|
||||
|
||||
bs.tryPutItem(tileA, makeItem("iron_ore"));
|
||||
bs.tryPutItem(tileA, makeItem("iron_ore"), Rotation::East);
|
||||
bs.tick(); // item reaches output edge of A, moves to B at progress 0
|
||||
bs.tick(); // item reaches output edge of B
|
||||
|
||||
@@ -185,7 +185,7 @@ TEST_CASE("BeltSystem: item stays at progress 1.0 when next tile is absent", "[b
|
||||
const QPoint tileA(0, 0);
|
||||
bs.placeBelt(tileA, Rotation::East);
|
||||
|
||||
bs.tryPutItem(tileA, makeItem("iron_ore"));
|
||||
bs.tryPutItem(tileA, makeItem("iron_ore"), Rotation::East);
|
||||
bs.tick();
|
||||
|
||||
// Item should still be on tileA (no registered tile to the east).
|
||||
@@ -202,7 +202,7 @@ TEST_CASE("BeltSystem: item traverses 3-tile chain in 3 ticks (one per tile)", "
|
||||
bs.placeBelt(tileB, Rotation::East);
|
||||
bs.placeBelt(tileC, Rotation::East);
|
||||
|
||||
bs.tryPutItem(tileA, makeItem("iron_ore"));
|
||||
bs.tryPutItem(tileA, makeItem("iron_ore"), Rotation::East);
|
||||
bs.tick(); // A output edge → moves to B at progress 0
|
||||
bs.tick(); // B output edge → moves to C at progress 0
|
||||
bs.tick(); // C output edge → available for pickup
|
||||
@@ -221,13 +221,13 @@ TEST_CASE("BeltSystem: item stays blocked when next tile is full", "[belt]")
|
||||
bs.placeBelt(tileB, Rotation::East);
|
||||
|
||||
// Fill tileB to capacity.
|
||||
bs.tryPutItem(tileB, makeItem("b1"));
|
||||
bs.tryPutItem(tileB, makeItem("b2"));
|
||||
bs.tryPutItem(tileB, makeItem("b3"));
|
||||
bs.tryPutItem(tileB, makeItem("b4"));
|
||||
bs.tryPutItem(tileB, makeItem("b1"), Rotation::East);
|
||||
bs.tryPutItem(tileB, makeItem("b2"), Rotation::East);
|
||||
bs.tryPutItem(tileB, makeItem("b3"), Rotation::East);
|
||||
bs.tryPutItem(tileB, makeItem("b4"), Rotation::East);
|
||||
|
||||
// Place item in tileA — should be blocked.
|
||||
bs.tryPutItem(tileA, makeItem("a1"));
|
||||
bs.tryPutItem(tileA, makeItem("a1"), Rotation::East);
|
||||
bs.tick();
|
||||
|
||||
// Item in tileA must still be there.
|
||||
@@ -246,13 +246,13 @@ TEST_CASE("BeltSystem: belt second slot is capped at progress 0.75", "[belt]")
|
||||
bs.placeBelt(tile, Rotation::East);
|
||||
|
||||
// Advance front item to the output edge; it stays there (no next tile).
|
||||
bs.tryPutItem(tile, makeItem("front_item"));
|
||||
bs.tryPutItem(tile, makeItem("front_item"), Rotation::East);
|
||||
bs.tick(); // slot[0]: 0.4
|
||||
bs.tick(); // slot[0]: 0.8
|
||||
bs.tick(); // slot[0]: 1.0 (capped, stuck)
|
||||
|
||||
// Place second item; slot[0] is at 1.0.
|
||||
bs.tryPutItem(tile, makeItem("back_item"));
|
||||
bs.tryPutItem(tile, makeItem("back_item"), Rotation::East);
|
||||
bs.tick(); // slot[1]: 0.4
|
||||
bs.tick(); // slot[1] would reach 0.8 — capped at 0.75
|
||||
|
||||
@@ -272,8 +272,8 @@ TEST_CASE("BeltSystem: clearTiles removes all items from specified tiles", "[bel
|
||||
BeltSystem bs(kFastBeltSpeed);
|
||||
const QPoint tile(0, 0);
|
||||
bs.placeBelt(tile, Rotation::East);
|
||||
bs.tryPutItem(tile, makeItem("iron_ore"));
|
||||
bs.tryPutItem(tile, makeItem("copper_ore"));
|
||||
bs.tryPutItem(tile, makeItem("iron_ore"), Rotation::East);
|
||||
bs.tryPutItem(tile, makeItem("copper_ore"), Rotation::East);
|
||||
|
||||
bs.clearTiles({tile});
|
||||
|
||||
@@ -289,7 +289,7 @@ TEST_CASE("BeltSystem: forEachVisualItem visits items inside viewport", "[belt]"
|
||||
BeltSystem bs(kFastBeltSpeed);
|
||||
const QPoint tile(5, 5);
|
||||
bs.placeBelt(tile, Rotation::East);
|
||||
bs.tryPutItem(tile, makeItem("iron_ore"));
|
||||
bs.tryPutItem(tile, makeItem("iron_ore"), Rotation::East);
|
||||
|
||||
int count = 0;
|
||||
bs.forEachVisualItem(QRect(0, 0, 20, 20), [&count](VisualItem) { ++count; });
|
||||
@@ -302,7 +302,7 @@ TEST_CASE("BeltSystem: forEachVisualItem skips items outside viewport", "[belt]"
|
||||
BeltSystem bs(kFastBeltSpeed);
|
||||
const QPoint tile(50, 50);
|
||||
bs.placeBelt(tile, Rotation::East);
|
||||
bs.tryPutItem(tile, makeItem("iron_ore"));
|
||||
bs.tryPutItem(tile, makeItem("iron_ore"), Rotation::East);
|
||||
|
||||
int count = 0;
|
||||
bs.forEachVisualItem(QRect(0, 0, 20, 20), [&count](VisualItem) { ++count; });
|
||||
@@ -315,7 +315,7 @@ TEST_CASE("BeltSystem: forEachVisualItem reports correct ItemType", "[belt]")
|
||||
BeltSystem bs(kFastBeltSpeed);
|
||||
const QPoint tile(0, 0);
|
||||
bs.placeBelt(tile, Rotation::East);
|
||||
bs.tryPutItem(tile, makeItem("copper_ingot"));
|
||||
bs.tryPutItem(tile, makeItem("copper_ingot"), Rotation::East);
|
||||
|
||||
std::vector<ItemType> seen;
|
||||
bs.forEachVisualItem(QRect(-1, -1, 10, 10), [&seen](VisualItem vi)
|
||||
@@ -348,10 +348,10 @@ TEST_CASE("BeltSystem: splitter alternates between outputA and outputB", "[belt]
|
||||
bs.placeBelt(tileA, Rotation::North);
|
||||
bs.placeBelt(tileB, Rotation::South);
|
||||
|
||||
bs.tryPutItem(tileIn, makeItem("item1"));
|
||||
bs.tryPutItem(tileIn, makeItem("item1"), Rotation::East);
|
||||
bs.tick(); // item1: tileIn -> splitter back (progress 0)
|
||||
|
||||
bs.tryPutItem(tileIn, makeItem("item2"));
|
||||
bs.tryPutItem(tileIn, makeItem("item2"), Rotation::East);
|
||||
bs.tick(); // item1 back -> 0.5 -> frontA; item2 advances but back is occupied
|
||||
bs.tick(); // item1 frontA -> 1.0 -> tileA; item2 enters splitter back
|
||||
bs.tick(); // item2 back -> 0.5 -> frontB; item1 at tileA output edge
|
||||
@@ -388,7 +388,7 @@ TEST_CASE("BeltSystem: splitter routes to preferred output when item matches bot
|
||||
|
||||
bs.setSplitterFilters(tileSpl, {ItemType{"iron_ore"}}, {});
|
||||
|
||||
bs.tryPutItem(tileIn, makeItem("iron_ore"));
|
||||
bs.tryPutItem(tileIn, makeItem("iron_ore"), Rotation::East);
|
||||
bs.tick(); // tileIn -> splitter back
|
||||
bs.tick(); // back -> frontA (both match, alternation, preferred A)
|
||||
bs.tick(); // frontA -> tileA
|
||||
@@ -411,7 +411,7 @@ TEST_CASE("BeltSystem: splitter routes item to output A when only filter A match
|
||||
|
||||
bs.setSplitterFilters(tileSpl, {ItemType{"iron_ore"}}, {ItemType{"copper_ore"}});
|
||||
|
||||
bs.tryPutItem(tileIn, makeItem("iron_ore"));
|
||||
bs.tryPutItem(tileIn, makeItem("iron_ore"), Rotation::East);
|
||||
bs.tick(); // tileIn -> splitter back
|
||||
bs.tick(); // back -> frontA (exclusive match to A)
|
||||
bs.tick(); // frontA reaches 1.0; no downstream belt, waits for building pickup
|
||||
@@ -433,7 +433,7 @@ TEST_CASE("BeltSystem: splitter routes item to output B when only filter B match
|
||||
|
||||
bs.setSplitterFilters(tileSpl, {ItemType{"copper_ore"}}, {ItemType{"iron_ore"}});
|
||||
|
||||
bs.tryPutItem(tileIn, makeItem("iron_ore"));
|
||||
bs.tryPutItem(tileIn, makeItem("iron_ore"), Rotation::East);
|
||||
bs.tick();
|
||||
bs.tick();
|
||||
bs.tick();
|
||||
@@ -456,17 +456,17 @@ TEST_CASE("BeltSystem: splitter alternates A then B when item matches both expli
|
||||
bs.setSplitterFilters(tileSpl, {ItemType{"iron_ore"}}, {ItemType{"iron_ore"}});
|
||||
|
||||
// Item 1 → preferred A (nextOutputIsA=true initially).
|
||||
bs.tryPutItem(tileIn, makeItem("iron_ore"));
|
||||
bs.tryPutItem(tileIn, makeItem("iron_ore"), Rotation::East);
|
||||
bs.tick(); bs.tick(); bs.tick();
|
||||
REQUIRE(bs.tryTakeItem(Port{tileSpl, Rotation::North}).has_value());
|
||||
|
||||
// Item 2 → preferred B (nextOutputIsA toggled to false).
|
||||
bs.tryPutItem(tileIn, makeItem("iron_ore"));
|
||||
bs.tryPutItem(tileIn, makeItem("iron_ore"), Rotation::East);
|
||||
bs.tick(); bs.tick(); bs.tick();
|
||||
REQUIRE(bs.tryTakeItem(Port{tileSpl, Rotation::South}).has_value());
|
||||
|
||||
// Item 3 → preferred A again (nextOutputIsA toggled back to true).
|
||||
bs.tryPutItem(tileIn, makeItem("iron_ore"));
|
||||
bs.tryPutItem(tileIn, makeItem("iron_ore"), Rotation::East);
|
||||
bs.tick(); bs.tick(); bs.tick();
|
||||
REQUIRE(bs.peekItem(Port{tileSpl, Rotation::North}).has_value());
|
||||
REQUIRE_FALSE(bs.peekItem(Port{tileSpl, Rotation::South}).has_value());
|
||||
@@ -486,7 +486,7 @@ TEST_CASE("BeltSystem: splitter routes unmatched item to the unfiltered output",
|
||||
|
||||
bs.setSplitterFilters(tileSpl, {ItemType{"copper_ore"}}, {});
|
||||
|
||||
bs.tryPutItem(tileIn, makeItem("iron_ore"));
|
||||
bs.tryPutItem(tileIn, makeItem("iron_ore"), Rotation::East);
|
||||
bs.tick(); bs.tick(); bs.tick();
|
||||
|
||||
REQUIRE_FALSE(bs.peekItem(Port{tileSpl, Rotation::North}).has_value());
|
||||
@@ -506,7 +506,7 @@ TEST_CASE("BeltSystem: splitter stalls when item matches neither filter", "[belt
|
||||
|
||||
bs.setSplitterFilters(tileSpl, {ItemType{"copper_ore"}}, {ItemType{"iron_ingot"}});
|
||||
|
||||
bs.tryPutItem(tileIn, makeItem("iron_ore"));
|
||||
bs.tryPutItem(tileIn, makeItem("iron_ore"), Rotation::East);
|
||||
bs.tick(); // tileIn -> splitter back
|
||||
bs.tick(); // back reaches 0.5; routing fires but stalls (no filter match)
|
||||
bs.tick(); // back stays at 0.5; stall persists
|
||||
@@ -530,17 +530,17 @@ TEST_CASE("BeltSystem: splitter falls back to other output when preferred is blo
|
||||
bs.setSplitterFilters(tileSpl, {ItemType{"iron_ore"}}, {ItemType{"iron_ore"}});
|
||||
|
||||
// Item 1 → preferred A (nextOutputIsA=true → false after routing).
|
||||
bs.tryPutItem(tileIn, makeItem("iron_ore"));
|
||||
bs.tryPutItem(tileIn, makeItem("iron_ore"), Rotation::East);
|
||||
bs.tick(); bs.tick(); bs.tick(); // frontA = item1 at 1.0
|
||||
|
||||
// Item 2 → preferred B (nextOutputIsA=false → true after routing). Take item2 to free frontB.
|
||||
bs.tryPutItem(tileIn, makeItem("iron_ore"));
|
||||
bs.tryPutItem(tileIn, makeItem("iron_ore"), Rotation::East);
|
||||
bs.tick(); bs.tick(); bs.tick(); // frontB = item2 at 1.0
|
||||
REQUIRE(bs.tryTakeItem(Port{tileSpl, Rotation::South}).has_value());
|
||||
|
||||
// frontA still holds item1; nextOutputIsA=true (prefer A).
|
||||
// Item 3: both match, preferred A is occupied → fallback to B without toggling nextOutputIsA.
|
||||
bs.tryPutItem(tileIn, makeItem("iron_ore"));
|
||||
bs.tryPutItem(tileIn, makeItem("iron_ore"), Rotation::East);
|
||||
bs.tick(); bs.tick(); bs.tick(); // frontB = item3 at 1.0
|
||||
|
||||
REQUIRE(bs.peekItem(Port{tileSpl, Rotation::North}).has_value()); // item1 still in A
|
||||
@@ -549,7 +549,7 @@ TEST_CASE("BeltSystem: splitter falls back to other output when preferred is blo
|
||||
// nextOutputIsA was not toggled by the fallback: next item should still prefer A.
|
||||
REQUIRE(bs.tryTakeItem(Port{tileSpl, Rotation::North}).has_value()); // free frontA
|
||||
REQUIRE(bs.tryTakeItem(Port{tileSpl, Rotation::South}).has_value()); // free frontB
|
||||
bs.tryPutItem(tileIn, makeItem("iron_ore"));
|
||||
bs.tryPutItem(tileIn, makeItem("iron_ore"), Rotation::East);
|
||||
bs.tick(); bs.tick(); bs.tick();
|
||||
REQUIRE(bs.peekItem(Port{tileSpl, Rotation::North}).has_value()); // item4 → A (preferA still true)
|
||||
REQUIRE_FALSE(bs.peekItem(Port{tileSpl, Rotation::South}).has_value());
|
||||
@@ -591,14 +591,14 @@ TEST_CASE("BeltSystem: splitter fallback enters the open output at progress 0.75
|
||||
|
||||
// Permanently block output A: route one item to frontA where it sticks at 1.0
|
||||
// (North has no downstream tile, so it can never move out).
|
||||
bs.tryPutItem(tileSpl, makeItem("blockA"));
|
||||
bs.tryPutItem(tileSpl, makeItem("blockA"), Rotation::East);
|
||||
bs.tick(); // back: 0.25
|
||||
bs.tick(); // back: 0.5 -> frontA at 0.75 (preferred A), nextOutputIsA = false
|
||||
bs.tick(); bs.tick(); // frontA: 0.75 -> 1.0 (stuck, no North downstream)
|
||||
|
||||
// Cycle one item through B as the *preferred* output (also enters at 0.75) to
|
||||
// flip nextOutputIsA back to true and free frontB for the fallback case below.
|
||||
bs.tryPutItem(tileSpl, makeItem("toB_pref"));
|
||||
bs.tryPutItem(tileSpl, makeItem("toB_pref"), Rotation::East);
|
||||
bs.tick(); // back: 0.25
|
||||
bs.tick(); // back: 0.5 -> frontB at 0.75 (preferred B), nextOutputIsA = true
|
||||
REQUIRE(southProgressOf("toB_pref") == Approx(0.75));
|
||||
@@ -608,7 +608,7 @@ TEST_CASE("BeltSystem: splitter fallback enters the open output at progress 0.75
|
||||
|
||||
// Next item prefers A again (nextOutputIsA == true), but A is still blocked,
|
||||
// so it falls back to B — and must enter near the edge at progress 0.75.
|
||||
bs.tryPutItem(tileSpl, makeItem("toB_fallback"));
|
||||
bs.tryPutItem(tileSpl, makeItem("toB_fallback"), Rotation::East);
|
||||
bs.tick(); // back: 0.25
|
||||
bs.tick(); // back: 0.5 -> fallback routes to frontB at 0.75
|
||||
REQUIRE(southProgressOf("toB_fallback") == Approx(0.75));
|
||||
@@ -648,13 +648,13 @@ TEST_CASE("BeltSystem: splitter with an exclusive filter enters its only output
|
||||
};
|
||||
|
||||
// iron_ore matches filterA only -> sole eligible output A.
|
||||
bs.tryPutItem(tileSpl, makeItem("iron_ore"));
|
||||
bs.tryPutItem(tileSpl, makeItem("iron_ore"), Rotation::East);
|
||||
bs.tick(); // back: 0.25
|
||||
bs.tick(); // back: 0.5 -> routes to frontA at 0.75
|
||||
REQUIRE(progressOf("iron_ore", Rotation::North) == Approx(0.75));
|
||||
|
||||
// copper_ore matches filterB only -> sole eligible output B.
|
||||
bs.tryPutItem(tileSpl, makeItem("copper_ore"));
|
||||
bs.tryPutItem(tileSpl, makeItem("copper_ore"), Rotation::East);
|
||||
bs.tick(); // back: 0.25
|
||||
bs.tick(); // back: 0.5 -> routes to frontB at 0.75
|
||||
REQUIRE(progressOf("copper_ore", Rotation::South) == Approx(0.75));
|
||||
@@ -692,13 +692,13 @@ TEST_CASE("BeltSystem: splitter alternation enters the preferred output at progr
|
||||
};
|
||||
|
||||
// First item: preferred A (nextOutputIsA starts true) -> frontA at 0.75.
|
||||
bs.tryPutItem(tileSpl, makeItem("first"));
|
||||
bs.tryPutItem(tileSpl, makeItem("first"), Rotation::East);
|
||||
bs.tick(); // back: 0.25
|
||||
bs.tick(); // back: 0.5 -> routes to preferred frontA at 0.75, nextOutputIsA = false
|
||||
REQUIRE(progressOf("first", Rotation::North) == Approx(0.75));
|
||||
|
||||
// Second item: preference flipped, B is free -> frontB at 0.75.
|
||||
bs.tryPutItem(tileSpl, makeItem("second"));
|
||||
bs.tryPutItem(tileSpl, makeItem("second"), Rotation::East);
|
||||
bs.tick(); // back: 0.25 (first sticks at North 1.0, no downstream)
|
||||
bs.tick(); // back: 0.5 -> routes to preferred frontB at 0.75
|
||||
REQUIRE(progressOf("second", Rotation::South) == Approx(0.75));
|
||||
@@ -718,7 +718,7 @@ TEST_CASE("BeltSystem: splitter back slot is capped at 0.5 and waits before rout
|
||||
bs.placeBelt(tileIn, Rotation::East);
|
||||
bs.placeSplitter(tileSpl, Rotation::North, Rotation::South);
|
||||
|
||||
bs.tryPutItem(tileIn, makeItem("iron_ore"));
|
||||
bs.tryPutItem(tileIn, makeItem("iron_ore"), Rotation::East);
|
||||
bs.tick(); // item enters splitter back at progress 0; routing not yet triggered
|
||||
|
||||
// Back has not yet reached 0.5 — front slots empty, nothing available.
|
||||
@@ -743,7 +743,7 @@ TEST_CASE("BeltSystem: splitter delivers item directly to building input via try
|
||||
bs.placeSplitter(tileSpl, Rotation::North, Rotation::South);
|
||||
// No output belts — both outputs lead directly to building inputs.
|
||||
|
||||
bs.tryPutItem(tileIn, makeItem("iron_ore"));
|
||||
bs.tryPutItem(tileIn, makeItem("iron_ore"), Rotation::East);
|
||||
bs.tick(); // tileIn -> splitter back
|
||||
bs.tick(); // back -> frontA at progress 0
|
||||
bs.tick(); // frontA reaches 1.0; no downstream belt, item waits for building pickup
|
||||
@@ -766,7 +766,7 @@ TEST_CASE("BeltSystem: splitter accepts new items after building pulls from fron
|
||||
bs.placeBelt(tileIn, Rotation::East);
|
||||
bs.placeSplitter(tileSpl, Rotation::North, Rotation::South);
|
||||
|
||||
bs.tryPutItem(tileIn, makeItem("item1"));
|
||||
bs.tryPutItem(tileIn, makeItem("item1"), Rotation::East);
|
||||
bs.tick();
|
||||
bs.tick();
|
||||
bs.tick(); // item1 now in frontA at 1.0
|
||||
@@ -775,7 +775,7 @@ TEST_CASE("BeltSystem: splitter accepts new items after building pulls from fron
|
||||
REQUIRE(bs.tryTakeItem(Port{tileSpl, Rotation::North}).has_value());
|
||||
|
||||
// Feed item2; preferred is now South.
|
||||
bs.tryPutItem(tileIn, makeItem("item2"));
|
||||
bs.tryPutItem(tileIn, makeItem("item2"), Rotation::East);
|
||||
bs.tick();
|
||||
bs.tick();
|
||||
bs.tick(); // item2 now in frontB at 1.0
|
||||
@@ -812,21 +812,21 @@ TEST_CASE("BeltSystem: splitter alternates between two unregistered outputs (bui
|
||||
bs.placeSplitter(tileSpl, Rotation::North, Rotation::South);
|
||||
|
||||
// item1 → frontA (preferred, nextOutputIsA=true)
|
||||
bs.tryPutItem(tileIn, makeItem("item1"));
|
||||
bs.tryPutItem(tileIn, makeItem("item1"), Rotation::East);
|
||||
bs.tick();
|
||||
bs.tick();
|
||||
bs.tick();
|
||||
REQUIRE(bs.tryTakeItem(Port{tileSpl, Rotation::North}).has_value());
|
||||
|
||||
// item2 → frontB (preferred, nextOutputIsA now false)
|
||||
bs.tryPutItem(tileIn, makeItem("item2"));
|
||||
bs.tryPutItem(tileIn, makeItem("item2"), Rotation::East);
|
||||
bs.tick();
|
||||
bs.tick();
|
||||
bs.tick();
|
||||
REQUIRE(bs.tryTakeItem(Port{tileSpl, Rotation::South}).has_value());
|
||||
|
||||
// item3 → frontA again (nextOutputIsA toggled back to true)
|
||||
bs.tryPutItem(tileIn, makeItem("item3"));
|
||||
bs.tryPutItem(tileIn, makeItem("item3"), Rotation::East);
|
||||
bs.tick();
|
||||
bs.tick();
|
||||
bs.tick();
|
||||
@@ -847,7 +847,7 @@ TEST_CASE("BeltSystem: tunnel pairing — basic pair within max distance", "[bel
|
||||
bs.placeTunnelEntry(entry, Rotation::East, 10);
|
||||
bs.placeTunnelExit(exit, Rotation::East);
|
||||
|
||||
bs.tryPutItem(entry, makeItem("iron_ore"));
|
||||
bs.tryPutItem(entry, makeItem("iron_ore"), Rotation::East);
|
||||
|
||||
// With kFastBeltSpeed, items cross one tile per tick.
|
||||
// entry tile: 1 tick to reach front progress 1.0
|
||||
@@ -873,7 +873,7 @@ TEST_CASE("BeltSystem: tunnel pairing — wrong direction prevents pair", "[belt
|
||||
bs.placeTunnelEntry(QPoint(0, 0), Rotation::East, 10);
|
||||
bs.placeTunnelExit(QPoint(3, 0), Rotation::North);
|
||||
|
||||
bs.tryPutItem(QPoint(0, 0), makeItem("iron_ore"));
|
||||
bs.tryPutItem(QPoint(0, 0), makeItem("iron_ore"), Rotation::East);
|
||||
|
||||
for (int i = 0; i < 20; ++i)
|
||||
{
|
||||
@@ -891,7 +891,7 @@ TEST_CASE("BeltSystem: tunnel pairing — beyond max distance prevents pair", "[
|
||||
bs.placeTunnelEntry(QPoint(0, 0), Rotation::East, 2);
|
||||
bs.placeTunnelExit(QPoint(3, 0), Rotation::East);
|
||||
|
||||
bs.tryPutItem(QPoint(0, 0), makeItem("iron_ore"));
|
||||
bs.tryPutItem(QPoint(0, 0), makeItem("iron_ore"), Rotation::East);
|
||||
|
||||
for (int i = 0; i < 20; ++i)
|
||||
{
|
||||
@@ -912,7 +912,7 @@ TEST_CASE("BeltSystem: tunnel pairing — same-dir entry between blocks pairing"
|
||||
bs.placeTunnelExit(QPoint(4, 0), Rotation::East);
|
||||
|
||||
// Put item on Entry2 — should reach exit.
|
||||
bs.tryPutItem(QPoint(2, 0), makeItem("copper_ore"));
|
||||
bs.tryPutItem(QPoint(2, 0), makeItem("copper_ore"), Rotation::East);
|
||||
for (int i = 0; i < 20; ++i)
|
||||
{
|
||||
bs.tick();
|
||||
@@ -921,7 +921,7 @@ TEST_CASE("BeltSystem: tunnel pairing — same-dir entry between blocks pairing"
|
||||
bs.tryTakeItem(Port{QPoint(4, 0), Rotation::East});
|
||||
|
||||
// Put item on Entry1 — should NOT reach exit (Entry1 is unpaired).
|
||||
bs.tryPutItem(QPoint(0, 0), makeItem("iron_ore"));
|
||||
bs.tryPutItem(QPoint(0, 0), makeItem("iron_ore"), Rotation::East);
|
||||
for (int i = 0; i < 20; ++i)
|
||||
{
|
||||
bs.tick();
|
||||
@@ -939,7 +939,7 @@ TEST_CASE("BeltSystem: tunnel pairing — cross-dir entry between is ignored", "
|
||||
bs.placeTunnelEntry(QPoint(2, 0), Rotation::North, 10);
|
||||
bs.placeTunnelExit(QPoint(4, 0), Rotation::East);
|
||||
|
||||
bs.tryPutItem(QPoint(0, 0), makeItem("iron_ore"));
|
||||
bs.tryPutItem(QPoint(0, 0), makeItem("iron_ore"), Rotation::East);
|
||||
for (int i = 0; i < 20; ++i)
|
||||
{
|
||||
bs.tick();
|
||||
@@ -958,7 +958,7 @@ TEST_CASE("BeltSystem: unpaired entry blocks items at front", "[belt]")
|
||||
bs.placeTunnelEntry(QPoint(0, 0), Rotation::East, 10);
|
||||
// No exit placed — entry is unpaired.
|
||||
|
||||
bs.tryPutItem(QPoint(0, 0), makeItem("iron_ore"));
|
||||
bs.tryPutItem(QPoint(0, 0), makeItem("iron_ore"), Rotation::East);
|
||||
for (int i = 0; i < 10; ++i)
|
||||
{
|
||||
bs.tick();
|
||||
@@ -984,7 +984,7 @@ TEST_CASE("BeltSystem: demolish entry discards transit items", "[belt]")
|
||||
bs.placeTunnelEntry(entry, Rotation::East, 10);
|
||||
bs.placeTunnelExit(exit, Rotation::East);
|
||||
|
||||
bs.tryPutItem(entry, makeItem("iron_ore"));
|
||||
bs.tryPutItem(entry, makeItem("iron_ore"), Rotation::East);
|
||||
|
||||
// Advance just enough for item to enter transit but not reach exit.
|
||||
bs.tick(); // item enters entry front
|
||||
@@ -1010,7 +1010,7 @@ TEST_CASE("BeltSystem: clearTiles discards tunnel transit items", "[belt]")
|
||||
bs.placeTunnelEntry(entry, Rotation::East, 10);
|
||||
bs.placeTunnelExit(exit, Rotation::East);
|
||||
|
||||
bs.tryPutItem(entry, makeItem("iron_ore"));
|
||||
bs.tryPutItem(entry, makeItem("iron_ore"), Rotation::East);
|
||||
bs.tick();
|
||||
bs.tick();
|
||||
|
||||
@@ -1037,7 +1037,7 @@ TEST_CASE("BeltSystem: belt to entry to transit to exit to belt full chain", "[b
|
||||
bs.placeTunnelExit(exit, Rotation::East);
|
||||
bs.placeBelt(beltOut, Rotation::East);
|
||||
|
||||
bs.tryPutItem(beltIn, makeItem("iron_ore"));
|
||||
bs.tryPutItem(beltIn, makeItem("iron_ore"), Rotation::East);
|
||||
|
||||
for (int i = 0; i < 30; ++i)
|
||||
{
|
||||
@@ -1061,11 +1061,11 @@ TEST_CASE("BeltSystem: multiple items transit tunnel in order", "[belt]")
|
||||
bs.placeTunnelEntry(entry, Rotation::East, 10);
|
||||
bs.placeTunnelExit(exit, Rotation::East);
|
||||
|
||||
bs.tryPutItem(entry, makeItem("item1"));
|
||||
bs.tryPutItem(entry, makeItem("item1"), Rotation::East);
|
||||
bs.tick();
|
||||
bs.tick(); // item1 enters transit
|
||||
|
||||
bs.tryPutItem(entry, makeItem("item2"));
|
||||
bs.tryPutItem(entry, makeItem("item2"), Rotation::East);
|
||||
|
||||
for (int i = 0; i < 30; ++i)
|
||||
{
|
||||
@@ -1086,3 +1086,94 @@ TEST_CASE("BeltSystem: multiple items transit tunnel in order", "[belt]")
|
||||
REQUIRE(taken2.has_value());
|
||||
REQUIRE(taken2->type.id == "item2");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Output-edge rejection (REQ-MAT-ACCEPT-DIR)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
TEST_CASE("BeltSystem: belt refuses an item arriving through its output edge", "[belt]")
|
||||
{
|
||||
// Belt A flows East into belt B, but B flows West — so the hand-off would
|
||||
// enter B through its own (West) output edge and must be refused. Without the
|
||||
// guard the item would ping-pong between the two belts forever.
|
||||
BeltSystem bs(kFastBeltSpeed);
|
||||
const QPoint tileA(0, 0);
|
||||
const QPoint tileB(1, 0);
|
||||
bs.placeBelt(tileA, Rotation::East);
|
||||
bs.placeBelt(tileB, Rotation::West);
|
||||
|
||||
REQUIRE(bs.tryPutItem(tileA, makeItem("iron_ore"), Rotation::East));
|
||||
for (int i = 0; i < 5; ++i)
|
||||
{
|
||||
bs.tick();
|
||||
}
|
||||
|
||||
// B never accepts the item through its output edge...
|
||||
REQUIRE_FALSE(bs.tryTakeItem(Port{tileB, Rotation::West}).has_value());
|
||||
// ...and it stays blocked at A's output edge.
|
||||
REQUIRE(bs.tryTakeItem(eastPort(tileA)).has_value());
|
||||
}
|
||||
|
||||
TEST_CASE("BeltSystem: tryPutItem refuses a deposit onto a belt facing the source", "[belt]")
|
||||
{
|
||||
// A belt whose output edge faces back toward the depositing building must
|
||||
// refuse the item; feeding through a non-output edge still works.
|
||||
BeltSystem bs(kFastBeltSpeed);
|
||||
const QPoint tile(0, 0);
|
||||
bs.placeBelt(tile, Rotation::West);
|
||||
|
||||
// Item travelling East enters through the West (output) edge -> refused.
|
||||
REQUIRE_FALSE(bs.tryPutItem(tile, makeItem("iron_ore"), Rotation::East));
|
||||
// Item travelling West enters through the East (back) edge -> accepted.
|
||||
REQUIRE(bs.tryPutItem(tile, makeItem("iron_ore"), Rotation::West));
|
||||
}
|
||||
|
||||
TEST_CASE("BeltSystem: splitter refuses an item arriving through an output edge", "[belt]")
|
||||
{
|
||||
BeltSystem bs(kFastBeltSpeed);
|
||||
const QPoint tileSpl(1, 0);
|
||||
bs.placeSplitter(tileSpl, Rotation::North, Rotation::South);
|
||||
|
||||
// Entering through the North output edge (item travelling South) -> refused.
|
||||
REQUIRE_FALSE(bs.tryPutItem(tileSpl, makeItem("iron_ore"), Rotation::South));
|
||||
// Entering through the South output edge (item travelling North) -> refused.
|
||||
REQUIRE_FALSE(bs.tryPutItem(tileSpl, makeItem("iron_ore"), Rotation::North));
|
||||
// Entering through a non-output (West) edge (item travelling East) -> accepted.
|
||||
REQUIRE(bs.tryPutItem(tileSpl, makeItem("iron_ore"), Rotation::East));
|
||||
}
|
||||
|
||||
TEST_CASE("BeltSystem: tunnel entry refuses an item arriving through its mouth edge", "[belt]")
|
||||
{
|
||||
BeltSystem bs(kFastBeltSpeed);
|
||||
const QPoint entry(0, 0);
|
||||
bs.placeTunnelEntry(entry, Rotation::East, 10);
|
||||
|
||||
// Item travelling West enters through the East mouth (output) edge -> refused.
|
||||
REQUIRE_FALSE(bs.tryPutItem(entry, makeItem("iron_ore"), Rotation::West));
|
||||
// Item travelling East enters through the back (West) edge -> accepted.
|
||||
REQUIRE(bs.tryPutItem(entry, makeItem("iron_ore"), Rotation::East));
|
||||
}
|
||||
|
||||
TEST_CASE("BeltSystem: tunnel exit refuses an item pushed into its output edge", "[belt]")
|
||||
{
|
||||
// A splitter's East front sits next to a tunnel exit whose output faces West
|
||||
// (back toward the splitter). The front cannot hand off into the exit's
|
||||
// output edge, so the item stays on the splitter front.
|
||||
BeltSystem bs(kFastBeltSpeed);
|
||||
const QPoint tileSpl(1, 0);
|
||||
const QPoint exitTile(2, 0);
|
||||
bs.placeSplitter(tileSpl, Rotation::East, Rotation::South);
|
||||
bs.placeTunnelExit(exitTile, Rotation::West);
|
||||
|
||||
// Feed through the West edge (item travelling East); first item routes to the
|
||||
// East front (nextOutputIsA starts true).
|
||||
REQUIRE(bs.tryPutItem(tileSpl, makeItem("iron_ore"), Rotation::East));
|
||||
for (int i = 0; i < 5; ++i)
|
||||
{
|
||||
bs.tick();
|
||||
}
|
||||
|
||||
// The item is stuck on the splitter's East front; the exit never received it.
|
||||
REQUIRE(bs.peekItem(Port{tileSpl, Rotation::East}).has_value());
|
||||
REQUIRE_FALSE(bs.peekItem(Port{exitTile, Rotation::West}).has_value());
|
||||
}
|
||||
|
||||
@@ -58,12 +58,28 @@ static void runTicks(BuildingSystem& bs, BeltSystem& belts, int n, Tick& tick)
|
||||
bs.tickConstruction(tick);
|
||||
bs.tickBeltPull();
|
||||
bs.tickProduction(tick);
|
||||
bs.tickBeltPush();
|
||||
bs.tickOutputBelts();
|
||||
belts.tick();
|
||||
++tick;
|
||||
}
|
||||
}
|
||||
|
||||
// All items currently on a building's output side: buffered plus still-emerging on
|
||||
// the virtual output belts (REQ-MAT-OUTPUT-EMERGE). A produced item leaves the
|
||||
// output buffer the moment it starts emerging, so tests count both.
|
||||
static std::vector<Item> outputSideItems(const Building& b)
|
||||
{
|
||||
std::vector<Item> items = b.outputBuffer.items;
|
||||
for (const std::vector<BeltItemSlot>& lane : b.emergingItems)
|
||||
{
|
||||
for (const BeltItemSlot& slot : lane)
|
||||
{
|
||||
items.push_back(slot.item);
|
||||
}
|
||||
}
|
||||
return items;
|
||||
}
|
||||
|
||||
// Owns a BuildingSystem and its dependencies for placement-bounds tests.
|
||||
struct PlacementFixture
|
||||
{
|
||||
@@ -214,13 +230,13 @@ TEST_CASE("BuildingSystem: placing a belt registers it with BeltSystem after con
|
||||
bs.place(BuildingType::Belt, QPoint(5, 5), Rotation::East, 0);
|
||||
|
||||
// Belt is queued — not yet in BeltSystem.
|
||||
REQUIRE_FALSE(belts.tryPutItem(QPoint(5, 5), makeItem("iron_ore")));
|
||||
REQUIRE_FALSE(belts.tryPutItem(QPoint(5, 5), makeItem("iron_ore"), Rotation::East));
|
||||
|
||||
// Complete construction (1 s).
|
||||
Tick tick = 0;
|
||||
runTicks(bs, belts, static_cast<int>(secondsToTicks(1.0)) + 1, tick);
|
||||
|
||||
REQUIRE(belts.tryPutItem(QPoint(5, 5), makeItem("iron_ore")));
|
||||
REQUIRE(belts.tryPutItem(QPoint(5, 5), makeItem("iron_ore"), Rotation::East));
|
||||
REQUIRE(bs.allBuildings().size() == 1);
|
||||
REQUIRE(bs.allBuildings()[0].type == BuildingType::Belt);
|
||||
REQUIRE(bs.allBuildings()[0].anchor == QPoint(5, 5));
|
||||
@@ -402,8 +418,11 @@ TEST_CASE("BuildingSystem: miner produces iron_ore after recipe duration", "[bui
|
||||
|
||||
const Building* b = bs.findBuilding(id);
|
||||
REQUIRE(b != nullptr);
|
||||
REQUIRE_FALSE(b->outputBuffer.items.empty());
|
||||
REQUIRE(b->outputBuffer.items.front().type.id == "iron_ore");
|
||||
// No belt at the output port, so the produced item emerges and stays on the
|
||||
// building's virtual output belt (REQ-MAT-OUTPUT-EMERGE).
|
||||
const std::vector<Item> out = outputSideItems(*b);
|
||||
REQUIRE(out.size() == 1);
|
||||
REQUIRE(out.front().type.id == "iron_ore");
|
||||
}
|
||||
|
||||
TEST_CASE("BuildingSystem: miner output buffer stalls when full", "[building]")
|
||||
@@ -436,7 +455,9 @@ TEST_CASE("BuildingSystem: miner output buffer stalls when full", "[building]")
|
||||
|
||||
const Building* b = bs.findBuilding(id);
|
||||
REQUIRE(b != nullptr);
|
||||
REQUIRE(static_cast<int>(b->outputBuffer.items.size()) == 2);
|
||||
// Both produced items are held on the output side (buffer + emerging lane),
|
||||
// which is what the capacity rule counts (REQ-MAT-OUTPUT-EMERGE).
|
||||
REQUIRE(b->outputItemCount() == 2);
|
||||
REQUIRE_FALSE(b->production.has_value());
|
||||
}
|
||||
|
||||
@@ -515,7 +536,7 @@ TEST_CASE("BuildingSystem: activeProductionBuildingCount tracks production cycle
|
||||
|
||||
const Building* b = bs.findBuilding(id);
|
||||
REQUIRE(b != nullptr);
|
||||
REQUIRE(static_cast<int>(b->outputBuffer.items.size()) == 2);
|
||||
REQUIRE(b->outputItemCount() == 2);
|
||||
REQUIRE_FALSE(b->production.has_value());
|
||||
REQUIRE(bs.activeProductionBuildingCount() == 0);
|
||||
}
|
||||
@@ -559,10 +580,93 @@ TEST_CASE("BuildingSystem: smelter input buffer fills from adjacent west-flowing
|
||||
|
||||
const Building* b = bs.findBuilding(sid);
|
||||
REQUIRE(b != nullptr);
|
||||
const std::map<ItemType, int>::const_iterator it =
|
||||
// The item was accepted; it may still be travelling inward on the input belt,
|
||||
// so count buffered + in-transit (REQ-MAT-INPUT-INTAKE).
|
||||
REQUIRE(b->pendingInputCount(ItemType{"iron_ore"}) >= 1);
|
||||
}
|
||||
|
||||
// An accepted input item travels inward on its input belt before it becomes usable
|
||||
// stock: it is reserved (counts against the cap) on entry and only enters the
|
||||
// buffer on reaching the tile centre (REQ-MAT-INPUT-INTAKE).
|
||||
TEST_CASE("BuildingSystem: accepted input travels inward before entering the buffer",
|
||||
"[building]")
|
||||
{
|
||||
const GameConfig cfg = loadConfig();
|
||||
BeltSystem belts(static_cast<double>(kTickRateHz)); // fast belt: 1 tile/tick
|
||||
int stock = 0;
|
||||
std::mt19937 rng(0);
|
||||
BuildingId nextBuildingId = 1;
|
||||
BuildingSystem bs(cfg, belts,
|
||||
[&nextBuildingId]() { return nextBuildingId++; },
|
||||
[&stock](int n) { stock += n; },
|
||||
[](const std::string&, QVector2D, const std::optional<ShipLayoutConfig>&) {},
|
||||
[](const std::string&) -> bool { return true; },
|
||||
rng);
|
||||
|
||||
const BuildingId sid = bs.place(BuildingType::Smelter, QPoint(0, 0), Rotation::East, 0);
|
||||
Tick tick = 0;
|
||||
runTicks(bs, belts, static_cast<int>(secondsToTicks(15.0)) + 1, tick);
|
||||
|
||||
belts.placeBelt(QPoint(2, 0), Rotation::West);
|
||||
belts.tryPutItem(QPoint(2, 0), makeItem("iron_ore"));
|
||||
belts.tick();
|
||||
bs.tickBeltPull(); // accepts the item onto the input belt at progress 0.0
|
||||
|
||||
const Building* b = bs.findBuilding(sid);
|
||||
REQUIRE(b != nullptr);
|
||||
// Reserved but not yet consumable: nothing in the buffer, but it counts against
|
||||
// the cap via pendingInputCount.
|
||||
const std::map<ItemType, int>::const_iterator it0 =
|
||||
b->inputBuffer.counts.find(ItemType{"iron_ore"});
|
||||
REQUIRE(it != b->inputBuffer.counts.end());
|
||||
REQUIRE(it->second >= 1);
|
||||
REQUIRE((it0 == b->inputBuffer.counts.end() || it0->second == 0));
|
||||
REQUIRE(b->pendingInputCount(ItemType{"iron_ore"}) == 1);
|
||||
|
||||
// One more pull tick advances the input belt to the centre; the item arrives.
|
||||
bs.tickBeltPull();
|
||||
REQUIRE(b->inputBuffer.counts.at(ItemType{"iron_ore"}) == 1);
|
||||
REQUIRE(b->pendingInputCount(ItemType{"iron_ore"}) == 1);
|
||||
}
|
||||
|
||||
// The acceptance test counts in-transit items, so buffered + reserved never exceeds
|
||||
// the per-material cap; excess items stay on the belt (REQ-MAT-INPUT-INTAKE).
|
||||
TEST_CASE("BuildingSystem: input reservation caps buffered plus in-transit at the cap",
|
||||
"[building]")
|
||||
{
|
||||
const GameConfig cfg = loadConfig();
|
||||
BeltSystem belts(static_cast<double>(kTickRateHz)); // fast belt
|
||||
int stock = 0;
|
||||
std::mt19937 rng(0);
|
||||
BuildingId nextBuildingId = 1;
|
||||
BuildingSystem bs(cfg, belts,
|
||||
[&nextBuildingId]() { return nextBuildingId++; },
|
||||
[&stock](int n) { stock += n; },
|
||||
[](const std::string&, QVector2D, const std::optional<ShipLayoutConfig>&) {},
|
||||
[](const std::string&) -> bool { return true; },
|
||||
rng);
|
||||
|
||||
const BuildingId id = bs.place(BuildingType::ReprocessingPlant,
|
||||
QPoint(0, 0), Rotation::East, 0);
|
||||
Tick tick = 0;
|
||||
runTicks(bs, belts, static_cast<int>(secondsToTicks(25.0)) + 1, tick);
|
||||
|
||||
// Feed scrap via an input belt without ever running production (only pull), so
|
||||
// the buffer fills and stays full. Try to over-fill it well past the cap.
|
||||
belts.placeBelt(QPoint(-1, 0), Rotation::East);
|
||||
for (int i = 0; i < 20; ++i)
|
||||
{
|
||||
belts.tryPutItem(QPoint(-1, 0), makeItem("scrap"), Rotation::East);
|
||||
belts.tick();
|
||||
bs.tickBeltPull();
|
||||
}
|
||||
|
||||
const Building* b = bs.findBuilding(id);
|
||||
REQUIRE(b != nullptr);
|
||||
const int cap = b->inputBuffer.caps.at(ItemType{"scrap"});
|
||||
REQUIRE(cap > 0);
|
||||
// buffered + in-transit is capped; the plant never over-pulls.
|
||||
REQUIRE(b->pendingInputCount(ItemType{"scrap"}) == cap);
|
||||
// Excess scrap is left stuck on the feeding belt rather than silently dropped.
|
||||
REQUIRE(belts.peekItem(eastPort(QPoint(-1, 0))).has_value());
|
||||
}
|
||||
|
||||
// A smelter auto-selects the matching recipe for whatever it is fed, with no
|
||||
@@ -603,7 +707,7 @@ TEST_CASE("BuildingSystem: smelter auto-smelts ore without a recipe selection",
|
||||
const Building* b = bs.findBuilding(sid);
|
||||
REQUIRE(b != nullptr);
|
||||
bool hasIronIngot = false;
|
||||
for (const Item& item : b->outputBuffer.items)
|
||||
for (const Item& item : outputSideItems(*b))
|
||||
{
|
||||
if (item.type.id == "iron_ingot") { hasIronIngot = true; }
|
||||
}
|
||||
@@ -652,7 +756,7 @@ TEST_CASE("BuildingSystem: smelter runs a satisfiable recipe while an incomplete
|
||||
|
||||
// Copper was smelted; the lone iron_ore still waits for a second unit.
|
||||
bool hasCopperIngot = false;
|
||||
for (const Item& item : b->outputBuffer.items)
|
||||
for (const Item& item : outputSideItems(*b))
|
||||
{
|
||||
if (item.type.id == "copper_ingot") { hasCopperIngot = true; }
|
||||
}
|
||||
@@ -703,6 +807,84 @@ TEST_CASE("BuildingSystem: miner output buffer drains onto adjacent belt", "[bui
|
||||
REQUIRE(item->type.id == "iron_ore");
|
||||
}
|
||||
|
||||
// Two directly adjacent buildings whose ports meet transfer items with no belt in
|
||||
// between: a miner's iron_ore output feeds straight into a smelter, which smelts it
|
||||
// (REQ-MAT-DIRECT-COUPLE).
|
||||
TEST_CASE("BuildingSystem: output port couples directly into an adjacent input port",
|
||||
"[building]")
|
||||
{
|
||||
const GameConfig cfg = loadConfig();
|
||||
BeltSystem belts(cfg.world.beltSpeed_tps);
|
||||
int stock = 0;
|
||||
std::mt19937 rng(0);
|
||||
BuildingId nextBuildingId = 1;
|
||||
BuildingSystem bs(cfg, belts,
|
||||
[&nextBuildingId]() { return nextBuildingId++; },
|
||||
[&stock](int n) { stock += n; },
|
||||
[](const std::string&, QVector2D, const std::optional<ShipLayoutConfig>&) {},
|
||||
[](const std::string&) -> bool { return true; },
|
||||
rng);
|
||||
|
||||
// Miner at (0,0): body (0,0),(1,0),(0,1); output port tile (1,1) flowing East.
|
||||
const BuildingId minerId = bs.place(BuildingType::Miner, QPoint(0, 0), Rotation::East, 0);
|
||||
bs.setRecipe(minerId, "mine_iron_ore");
|
||||
// Smelter anchored at (1,1): body (1,1),(2,1),(1,2),(2,2). Its body cell (1,1) is
|
||||
// the miner's output-port tile, and its west input edge there faces East, so the
|
||||
// two ports meet — no belt placed anywhere.
|
||||
const BuildingId smelterId = bs.place(BuildingType::Smelter, QPoint(1, 1), Rotation::East, 0);
|
||||
|
||||
Tick tick = 0;
|
||||
// Smelter build (15s) + margin for coupling and a smelt cycle.
|
||||
runTicks(bs, belts, static_cast<int>(secondsToTicks(30.0)), tick);
|
||||
|
||||
const Building* smelter = bs.findBuilding(smelterId);
|
||||
REQUIRE(smelter != nullptr);
|
||||
// iron_ore reached the smelter over the direct coupling and was smelted.
|
||||
bool hasIronIngot = false;
|
||||
for (const Item& produced : outputSideItems(*smelter))
|
||||
{
|
||||
if (produced.type.id == "iron_ingot") { hasIronIngot = true; }
|
||||
}
|
||||
REQUIRE(hasIronIngot);
|
||||
}
|
||||
|
||||
// A producer coupled to a building that cannot accept its item delivers nothing; the
|
||||
// item stays stuck at the producer's output port (REQ-MAT-DIRECT-COUPLE acceptance).
|
||||
TEST_CASE("BuildingSystem: direct coupling to a non-consumer leaves the item stuck",
|
||||
"[building]")
|
||||
{
|
||||
const GameConfig cfg = loadConfig();
|
||||
BeltSystem belts(cfg.world.beltSpeed_tps);
|
||||
int stock = 0;
|
||||
std::mt19937 rng(0);
|
||||
BuildingId nextBuildingId = 1;
|
||||
BuildingSystem bs(cfg, belts,
|
||||
[&nextBuildingId]() { return nextBuildingId++; },
|
||||
[&stock](int n) { stock += n; },
|
||||
[](const std::string&, QVector2D, const std::optional<ShipLayoutConfig>&) {},
|
||||
[](const std::string&) -> bool { return true; },
|
||||
rng);
|
||||
|
||||
// Producing miner at (0,0), output port (1,1) East.
|
||||
const BuildingId minerId = bs.place(BuildingType::Miner, QPoint(0, 0), Rotation::East, 0);
|
||||
bs.setRecipe(minerId, "mine_iron_ore");
|
||||
// A second, idle miner anchored at (1,1) occupies the output-port tile but takes
|
||||
// no inputs, so it cannot accept the iron_ore.
|
||||
const BuildingId sinkId = bs.place(BuildingType::Miner, QPoint(1, 1), Rotation::East, 0);
|
||||
|
||||
Tick tick = 0;
|
||||
// Both miners build sequentially (10s each), then the producer runs and jams.
|
||||
runTicks(bs, belts, static_cast<int>(secondsToTicks(25.0)), tick);
|
||||
|
||||
const Building* miner = bs.findBuilding(minerId);
|
||||
const Building* sink = bs.findBuilding(sinkId);
|
||||
REQUIRE(miner != nullptr);
|
||||
REQUIRE(sink != nullptr);
|
||||
// Nothing was delivered, and the producer's output side has backed up to its cap.
|
||||
REQUIRE(sink->pendingInputCount(ItemType{"iron_ore"}) == 0);
|
||||
REQUIRE(miner->outputItemCount() == miner->outputBuffer.capacity);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// setRecipe clears buffers
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -734,13 +916,15 @@ TEST_CASE("BuildingSystem: setRecipe clears output buffer and active production"
|
||||
{
|
||||
const Building* b = bs.findBuilding(id);
|
||||
REQUIRE(b != nullptr);
|
||||
REQUIRE_FALSE(b->outputBuffer.items.empty());
|
||||
REQUIRE(b->outputItemCount() > 0);
|
||||
}
|
||||
|
||||
bs.setRecipe(id, "mine_copper_ore");
|
||||
|
||||
const Building* b = bs.findBuilding(id);
|
||||
REQUIRE(b->outputBuffer.items.empty());
|
||||
// Clearing the output buffer on a recipe change also discards emerging items
|
||||
// (REQ-MAT-OUTPUT-EMERGE).
|
||||
REQUIRE(b->outputItemCount() == 0);
|
||||
REQUIRE_FALSE(b->production.has_value());
|
||||
}
|
||||
|
||||
@@ -810,19 +994,17 @@ TEST_CASE("BuildingSystem: reprocessing plant produces one cycle output then sta
|
||||
belts.placeBelt(QPoint(-1, 0), Rotation::East);
|
||||
for (int i = 0; i < 5; ++i)
|
||||
{
|
||||
belts.tryPutItem(QPoint(-1, 0), makeItem("scrap"));
|
||||
belts.tryPutItem(QPoint(-1, 0), makeItem("scrap"), Rotation::East);
|
||||
belts.tick();
|
||||
bs.tickBeltPull();
|
||||
}
|
||||
|
||||
// Verify scrap is in input buffer.
|
||||
// Verify all five scrap were accepted; some may still be travelling inward on
|
||||
// the input belt (REQ-MAT-INPUT-INTAKE), so count buffered + in-transit.
|
||||
{
|
||||
const Building* b = bs.findBuilding(id);
|
||||
REQUIRE(b != nullptr);
|
||||
const std::map<ItemType, int>::const_iterator it =
|
||||
b->inputBuffer.counts.find(ItemType{"scrap"});
|
||||
REQUIRE(it != b->inputBuffer.counts.end());
|
||||
REQUIRE(it->second == 5);
|
||||
REQUIRE(b->pendingInputCount(ItemType{"scrap"}) == 5);
|
||||
}
|
||||
|
||||
// Run production cycle (3s = 90 ticks + 1 for the completion tick).
|
||||
|
||||
@@ -3,6 +3,7 @@ SET(HDRS
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/VisualsConfig.h
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/VisualsLoader.h
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/MainWindow.h
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/ModalDimOverlay.h
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/GameWorldView.h
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/HeaderBar.h
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/BuildButtonGrid.h
|
||||
@@ -21,6 +22,7 @@ SET(SRCS
|
||||
${SRCS}
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/VisualsLoader.cpp
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/MainWindow.cpp
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/ModalDimOverlay.cpp
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/GameWorldView.cpp
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/HeaderBar.cpp
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/BuildButtonGrid.cpp
|
||||
|
||||
@@ -15,11 +15,14 @@
|
||||
#include <QDir>
|
||||
#include <QFont>
|
||||
#include <QKeyEvent>
|
||||
#include <QLinearGradient>
|
||||
#include <QMessageBox>
|
||||
#include <QMouseEvent>
|
||||
#include <QPainter>
|
||||
#include <QPen>
|
||||
#include <QPolygonF>
|
||||
#include <QRadialGradient>
|
||||
#include <QRegion>
|
||||
#include <QStringList>
|
||||
#include <QTimer>
|
||||
|
||||
@@ -293,7 +296,7 @@ void GameWorldView::onFrame()
|
||||
// Apply held scroll
|
||||
{
|
||||
// Pan speed depends on where the view is centered (REQ-UI-SCROLL-SPEED).
|
||||
const float viewCenterX = m_scrollXTiles + viewportWidthTiles() / 2.0f;
|
||||
const float viewCenterX = m_scrollXTiles;
|
||||
const float delta = panSpeedTilesPerSecondAt(viewCenterX)
|
||||
* static_cast<float>(elapsed) / 1000.0f;
|
||||
const float scrollBefore = m_scrollXTiles;
|
||||
@@ -403,6 +406,11 @@ void GameWorldView::paintGL()
|
||||
|
||||
drawTiles(painter);
|
||||
drawBuildings(painter);
|
||||
// Port items are drawn over the buildings but clipped to a thin margin at each
|
||||
// machine's edges (see drawPortItems), so items appear to emerge from / sink
|
||||
// into the port and stay visible while crossing directly between two touching
|
||||
// buildings (REQ-MAT-OUTPUT-EMERGE, REQ-MAT-INPUT-INTAKE, REQ-MAT-DIRECT-COUPLE).
|
||||
drawPortItems(painter);
|
||||
drawCopyConfigFeedback(painter);
|
||||
drawStations(painter);
|
||||
drawBeltItems(painter);
|
||||
@@ -435,10 +443,15 @@ float GameWorldView::viewportWidthTiles() const
|
||||
return static_cast<float>(width()) / tilePx();
|
||||
}
|
||||
|
||||
float GameWorldView::viewLeftTiles() const
|
||||
{
|
||||
return m_scrollXTiles - viewportWidthTiles() / 2.0f;
|
||||
}
|
||||
|
||||
QPointF GameWorldView::worldToWidget(QVector2D worldPos) const
|
||||
{
|
||||
return QPointF(
|
||||
static_cast<qreal>((worldPos.x() - m_scrollXTiles) * tilePx()),
|
||||
static_cast<qreal>((worldPos.x() - viewLeftTiles()) * tilePx()),
|
||||
static_cast<qreal>(worldPos.y() * tilePx()));
|
||||
}
|
||||
|
||||
@@ -450,14 +463,14 @@ QPointF GameWorldView::tileToWidget(QPoint tile) const
|
||||
|
||||
QPoint GameWorldView::widgetToTile(QPoint widgetPt) const
|
||||
{
|
||||
const float wx = static_cast<float>(widgetPt.x()) / tilePx() + m_scrollXTiles;
|
||||
const float wx = static_cast<float>(widgetPt.x()) / tilePx() + viewLeftTiles();
|
||||
const float wy = static_cast<float>(widgetPt.y()) / tilePx();
|
||||
return QPoint(static_cast<int>(std::floor(wx)), static_cast<int>(std::floor(wy)));
|
||||
}
|
||||
|
||||
QVector2D GameWorldView::widgetToWorld(QPoint widgetPt) const
|
||||
{
|
||||
const float wx = static_cast<float>(widgetPt.x()) / tilePx() + m_scrollXTiles;
|
||||
const float wx = static_cast<float>(widgetPt.x()) / tilePx() + viewLeftTiles();
|
||||
const float wy = static_cast<float>(widgetPt.y()) / tilePx();
|
||||
return QVector2D(wx, wy);
|
||||
}
|
||||
@@ -471,9 +484,9 @@ QRectF GameWorldView::tileRect(QPoint tile) const
|
||||
|
||||
QRect GameWorldView::viewportRect() const
|
||||
{
|
||||
const int left = static_cast<int>(std::floor(m_scrollXTiles)) - 1;
|
||||
const int left = static_cast<int>(std::floor(viewLeftTiles())) - 1;
|
||||
const int top = 0;
|
||||
const int right = static_cast<int>(std::ceil(m_scrollXTiles + viewportWidthTiles())) + 1;
|
||||
const int right = static_cast<int>(std::ceil(viewLeftTiles() + viewportWidthTiles())) + 1;
|
||||
const int bottom = m_config->world.heightTiles;
|
||||
return QRect(left, top, right - left, bottom - top);
|
||||
}
|
||||
@@ -546,8 +559,11 @@ float GameWorldView::panSpeedTilesPerSecondAt(float viewCenterXTiles) const
|
||||
|
||||
void GameWorldView::clampScroll()
|
||||
{
|
||||
// m_scrollXTiles is the view center, so the pan limits are the edges themselves:
|
||||
// the view can pan left until the buildable/asteroid edge is centered, and right
|
||||
// until the enemy stations are centered (revealing a little space beyond them).
|
||||
const float leftBound = asteroidLeftEdge();
|
||||
const float rightBound = enemyStationRightEdge() - viewportWidthTiles();
|
||||
const float rightBound = enemyStationRightEdge();
|
||||
m_scrollXTiles = std::max(leftBound, std::min(m_scrollXTiles, rightBound));
|
||||
}
|
||||
|
||||
@@ -906,19 +922,29 @@ void GameWorldView::drawPortGlyph(QPainter& painter, QPoint bodyTile,
|
||||
|
||||
void GameWorldView::drawTiles(QPainter& painter)
|
||||
{
|
||||
const int leftTile = static_cast<int>(std::floor(m_scrollXTiles)) - 1;
|
||||
const int leftTile = static_cast<int>(std::floor(viewLeftTiles())) - 1;
|
||||
const int rightTile = leftTile + static_cast<int>(std::ceil(viewportWidthTiles())) + 2;
|
||||
const int bottomTile = m_config->world.heightTiles;
|
||||
|
||||
// Asteroid columns left of the buildable edge are not yet unlocked by
|
||||
// expansion; tint them so the player sees the reachable-but-locked area.
|
||||
const int buildableLeftX = -m_sim->currentAsteroidWidth_tiles();
|
||||
|
||||
painter.setPen(Qt::NoPen);
|
||||
for (int x = leftTile; x <= rightTile; ++x)
|
||||
{
|
||||
const QColor& fill = (x < 0)
|
||||
? m_visuals->asteroid.fill
|
||||
: m_visuals->space.fill;
|
||||
const bool locked = (x < buildableLeftX);
|
||||
for (int y = 0; y < bottomTile; ++y)
|
||||
{
|
||||
painter.fillRect(tileRect(QPoint(x, y)), fill);
|
||||
const QRectF rect = tileRect(QPoint(x, y));
|
||||
painter.fillRect(rect, fill);
|
||||
if (locked)
|
||||
{
|
||||
painter.fillRect(rect, m_visuals->overlays.lockedAsteroid);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1135,6 +1161,68 @@ void GameWorldView::drawCopyConfigFeedback(QPainter& painter)
|
||||
}
|
||||
}
|
||||
|
||||
void GameWorldView::drawPortItems(QPainter& painter)
|
||||
{
|
||||
const float halfPx = tilePx() * 0.5f * 0.5f;
|
||||
|
||||
// Port items are drawn over the buildings (drawBuildings runs first) but clipped
|
||||
// to a thin margin at each machine's edges: the clip region is the whole view
|
||||
// minus every machine's interior (its footprint inset by kPortMarginTiles). So a
|
||||
// transiting item shows only near the port edge — appearing to emerge from / sink
|
||||
// into the machine (REQ-MAT-OUTPUT-EMERGE, REQ-MAT-INPUT-INTAKE) and staying
|
||||
// visible in the ~2×margin band at a seam between two touching buildings
|
||||
// (REQ-MAT-DIRECT-COUPLE). Transport tiles are not machines and never occlude, so
|
||||
// items on belts stay fully visible.
|
||||
constexpr double kPortMarginTiles = 0.2;
|
||||
const double margin = kPortMarginTiles * static_cast<double>(tilePx());
|
||||
|
||||
QRegion clip(rect());
|
||||
for (const Building& b : m_sim->buildings().allBuildings())
|
||||
{
|
||||
if (b.type == BuildingType::Belt || b.type == BuildingType::Splitter
|
||||
|| b.type == BuildingType::TunnelEntry || b.type == BuildingType::TunnelExit)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
const std::set<QPoint, QPointCompare> cells(b.bodyCells.begin(), b.bodyCells.end());
|
||||
for (const QPoint& cell : b.bodyCells)
|
||||
{
|
||||
// Inset an edge only where the neighbouring cell is not part of the same
|
||||
// building, so interior cell seams stay filled (handles L-shaped footprints).
|
||||
const double l = cells.count(cell + QPoint(-1, 0)) ? 0.0 : margin;
|
||||
const double t = cells.count(cell + QPoint( 0, -1)) ? 0.0 : margin;
|
||||
const double r = cells.count(cell + QPoint( 1, 0)) ? 0.0 : margin;
|
||||
const double d = cells.count(cell + QPoint( 0, 1)) ? 0.0 : margin;
|
||||
clip = clip.subtracted(QRegion(tileRect(cell).adjusted(l, t, -r, -d).toRect()));
|
||||
}
|
||||
}
|
||||
|
||||
// Shared with belt items (REQ-GW-TILE-SIZE): a half-tile filled square + outline.
|
||||
const std::function<void(const ItemType&, QPointF)> drawItem =
|
||||
[&](const ItemType& type, QPointF worldPos)
|
||||
{
|
||||
const std::map<std::string, ItemVisuals>::const_iterator it =
|
||||
m_visuals->items.find(type.id);
|
||||
if (it == m_visuals->items.end()) { return; }
|
||||
|
||||
const QPointF center = worldToWidget(
|
||||
QVector2D(static_cast<float>(worldPos.x()),
|
||||
static_cast<float>(worldPos.y())));
|
||||
const QRectF itemRect(center.x() - halfPx, center.y() - halfPx,
|
||||
halfPx * 2, halfPx * 2);
|
||||
painter.fillRect(itemRect, it->second.fill);
|
||||
painter.setPen(QPen(it->second.outline, 1));
|
||||
painter.setBrush(Qt::NoBrush);
|
||||
painter.drawRect(itemRect);
|
||||
};
|
||||
|
||||
painter.save();
|
||||
painter.setClipRegion(clip);
|
||||
m_sim->buildings().forEachEmergingItem(drawItem);
|
||||
m_sim->buildings().forEachIncomingItem(drawItem);
|
||||
painter.restore();
|
||||
}
|
||||
|
||||
void GameWorldView::drawBeltItems(QPainter& painter)
|
||||
{
|
||||
const float halfPx = tilePx() * 0.5f * 0.5f;
|
||||
@@ -1401,6 +1489,9 @@ void GameWorldView::drawDebugOverlay(QPainter& painter)
|
||||
|
||||
void GameWorldView::drawBeams(QPainter& painter)
|
||||
{
|
||||
const QPainter::RenderHints savedHints = painter.renderHints();
|
||||
painter.setRenderHint(QPainter::Antialiasing, true);
|
||||
|
||||
for (const ActiveBeam& beam : m_activeBeams)
|
||||
{
|
||||
const std::optional<QVector2D> shooterPos = entityPosition(beam.event.shooter);
|
||||
@@ -1414,10 +1505,55 @@ void GameWorldView::drawBeams(QPainter& painter)
|
||||
case BeamKind::Repair: color = m_visuals->beams.repairColor; break;
|
||||
case BeamKind::Salvage: color = m_visuals->beams.salvageColor; break;
|
||||
}
|
||||
painter.setPen(QPen(color, m_visuals->beams.widthPx));
|
||||
painter.drawLine(worldToWidget(*shooterPos),
|
||||
worldToWidget(*targetPos + beam.targetOffset));
|
||||
|
||||
const QPointF s = worldToWidget(*shooterPos);
|
||||
const QPointF t = worldToWidget(*targetPos + beam.targetOffset);
|
||||
|
||||
// Unit direction/perpendicular of the beam in widget space. A degenerate
|
||||
// zero-length beam (shooter and target coincide) has no direction to
|
||||
// taper along, so skip it.
|
||||
const QVector2D delta(static_cast<float>(t.x() - s.x()),
|
||||
static_cast<float>(t.y() - s.y()));
|
||||
const float lengthPx = delta.length();
|
||||
if (lengthPx < 0.001f) { continue; }
|
||||
const QVector2D dir = delta / lengthPx;
|
||||
const QVector2D perp(-dir.y(), dir.x());
|
||||
|
||||
// Directional taper: draw the beam as a quad that is wide at the shooter
|
||||
// and narrows to a faint tip at the target, so it reads as an arrow
|
||||
// pointing away from whoever fired it. Without this, a beam strung
|
||||
// between two nearby ships is symmetric and gives no cue which end is the
|
||||
// source (the readability problem this addresses).
|
||||
const float widthPx = std::max(1.0f, static_cast<float>(m_visuals->beams.widthPx));
|
||||
const float baseHalf = widthPx * 1.f;
|
||||
const float tipHalf = widthPx * 0.35f;
|
||||
|
||||
QPolygonF quad;
|
||||
quad << QPointF(s.x() + static_cast<qreal>(perp.x() * baseHalf),
|
||||
s.y() + static_cast<qreal>(perp.y() * baseHalf))
|
||||
<< QPointF(s.x() - static_cast<qreal>(perp.x() * baseHalf),
|
||||
s.y() - static_cast<qreal>(perp.y() * baseHalf))
|
||||
<< QPointF(t.x() - static_cast<qreal>(perp.x() * tipHalf),
|
||||
t.y() - static_cast<qreal>(perp.y() * tipHalf))
|
||||
<< QPointF(t.x() + static_cast<qreal>(perp.x() * tipHalf),
|
||||
t.y() + static_cast<qreal>(perp.y() * tipHalf));
|
||||
|
||||
QColor bright = color;
|
||||
bright.setAlpha(255);
|
||||
QColor faint = color;
|
||||
faint.setAlpha(90);
|
||||
|
||||
QLinearGradient bodyGrad(s, t);
|
||||
bodyGrad.setColorAt(0.0, bright);
|
||||
bodyGrad.setColorAt(1.0, faint);
|
||||
|
||||
painter.setPen(Qt::NoPen);
|
||||
painter.setBrush(bodyGrad);
|
||||
painter.drawPolygon(quad);
|
||||
}
|
||||
|
||||
painter.setBrush(Qt::NoBrush);
|
||||
painter.setRenderHints(savedHints);
|
||||
}
|
||||
|
||||
void GameWorldView::drawOverlays(QPainter& painter)
|
||||
@@ -1756,7 +1892,7 @@ void GameWorldView::mousePressEvent(QMouseEvent* event)
|
||||
if (m_builderType.has_value())
|
||||
{
|
||||
const BuildingType type = *m_builderType;
|
||||
if (type == BuildingType::Belt || type == BuildingType::Splitter)
|
||||
if (type == BuildingType::Belt)
|
||||
{
|
||||
m_dragging = true;
|
||||
m_beltDragTiles.clear();
|
||||
|
||||
@@ -115,6 +115,7 @@ private:
|
||||
bool canAfford(BuildingType type) const;
|
||||
|
||||
void drawTiles(QPainter& painter);
|
||||
void drawPortItems(QPainter& painter);
|
||||
void drawBuildings(QPainter& painter);
|
||||
void drawSelectionHighlights(QPainter& painter);
|
||||
void drawCopyConfigFeedback(QPainter& painter);
|
||||
@@ -134,6 +135,9 @@ private:
|
||||
|
||||
float tilePx() const;
|
||||
float viewportWidthTiles() const;
|
||||
// World-X (tiles) at the left edge of the viewport. m_scrollXTiles stores the
|
||||
// view center; this derives the left edge the world<->widget conversions need.
|
||||
float viewLeftTiles() const;
|
||||
QPointF worldToWidget(QVector2D worldPos) const;
|
||||
QPointF tileToWidget(QPoint tile) const;
|
||||
QPoint widgetToTile(QPoint widgetPt) const;
|
||||
@@ -218,6 +222,7 @@ private:
|
||||
std::mt19937 m_rng;
|
||||
double m_gameSpeedMultiplier;
|
||||
double m_prevNonZeroSpeed;
|
||||
// World-X (tiles) at the center of the viewport (see viewLeftTiles()).
|
||||
float m_scrollXTiles;
|
||||
|
||||
QTimer* m_renderTimer;
|
||||
|
||||
@@ -74,6 +74,10 @@ MainWindow::MainWindow(Simulation* sim, const std::string& configDir,
|
||||
"SelectedBuildingPanel, BuildButtonGrid, BlueprintPanel {"
|
||||
" border: 1px solid palette(mid); }"));
|
||||
|
||||
// Created last so it stacks above the other children; covers the whole window and
|
||||
// dims the game behind modal dialogs/menus (REQ-UI-MODAL-DIM).
|
||||
m_dimOverlay = new ModalDimOverlay(m_visuals.overlays.modalDim, this);
|
||||
|
||||
m_gameWorldView->setFocus();
|
||||
|
||||
connect(qApp, &QApplication::focusChanged, this, [this](QWidget*, QWidget* newWidget) {
|
||||
@@ -144,6 +148,7 @@ void MainWindow::layoutPanels()
|
||||
m_headerBar->setGeometry(0, 0, mainW, headerH);
|
||||
m_gameWorldView->setGeometry(0, headerH, mainW, totalH - headerH);
|
||||
m_sidePanel->setGeometry(mainW, 0, sideW, totalH);
|
||||
m_dimOverlay->setGeometry(0, 0, totalW, totalH);
|
||||
}
|
||||
|
||||
void MainWindow::handleEvent(std::shared_ptr<const BuildingBlocksChangedEvent> event)
|
||||
@@ -156,6 +161,7 @@ void MainWindow::handleEvent(std::shared_ptr<const SchematicChoicesAvailableEven
|
||||
const double prevSpeed = m_gameWorldView->gameSpeed();
|
||||
m_gameWorldView->setGameSpeed(0.0);
|
||||
|
||||
ModalDimScope dim(*m_dimOverlay);
|
||||
SchematicChoiceDialog dialog(event->choices, m_sim->config().recipes, this);
|
||||
dialog.exec();
|
||||
|
||||
@@ -174,6 +180,7 @@ void MainWindow::handleEvent(std::shared_ptr<const EscapeMenuRequestedEvent> /*e
|
||||
const double prevSpeed = m_gameWorldView->gameSpeed();
|
||||
m_gameWorldView->setGameSpeed(0.0);
|
||||
|
||||
ModalDimScope dim(*m_dimOverlay);
|
||||
QMessageBox box(this);
|
||||
box.setWindowTitle(tr("Paused"));
|
||||
QPushButton* continueBtn = box.addButton(tr("Continue"), QMessageBox::AcceptRole);
|
||||
@@ -192,6 +199,7 @@ void MainWindow::handleEvent(std::shared_ptr<const EscapeMenuRequestedEvent> /*e
|
||||
ConfigLoader::loadFromDirectory(m_configDir));
|
||||
VisualsConfig newVisuals = VisualsLoader::load(m_configDir + "/visuals.toml");
|
||||
m_visuals = std::move(newVisuals);
|
||||
m_dimOverlay->setDimColor(m_visuals.overlays.modalDim);
|
||||
}
|
||||
catch (const std::exception& e)
|
||||
{
|
||||
@@ -236,6 +244,7 @@ void MainWindow::openShipLayoutDialog(BuildingId shipyardId,
|
||||
}
|
||||
}
|
||||
|
||||
ModalDimScope dim(*m_dimOverlay);
|
||||
ShipLayoutDialog dialog(&m_sim->config(), schematicId, currentLayout,
|
||||
m_layoutBlueprints,
|
||||
std::move(unlockedModuleIds),
|
||||
@@ -297,6 +306,10 @@ void MainWindow::handleEvent(std::shared_ptr<const RecipeSelectionRequestedEvent
|
||||
return;
|
||||
}
|
||||
|
||||
// Held across both the selection dialog and any auto-opened layout dialog so the
|
||||
// dim stays continuously visible through that sequence (REQ-UI-MODAL-DIM).
|
||||
ModalDimScope dim(*m_dimOverlay);
|
||||
|
||||
const BuildingType type = b ? b->type : s->type;
|
||||
// Captured as a copy: a queued command may drain during the modal dialog's
|
||||
// event loop and reallocate the building vectors, so b/s must not be
|
||||
@@ -348,6 +361,7 @@ void MainWindow::handleEvent(std::shared_ptr<const GameOverEvent> /*event*/)
|
||||
const int minutes = totalSeconds / 60;
|
||||
const int seconds = totalSeconds % 60;
|
||||
|
||||
ModalDimScope dim(*m_dimOverlay);
|
||||
QMessageBox box(this);
|
||||
box.setWindowTitle(tr("Game Over"));
|
||||
box.setText(tr("HQ destroyed!\nSurvival time: %1:%2")
|
||||
@@ -366,6 +380,7 @@ void MainWindow::handleEvent(std::shared_ptr<const GameOverEvent> /*event*/)
|
||||
ConfigLoader::loadFromDirectory(m_configDir));
|
||||
VisualsConfig newVisuals = VisualsLoader::load(m_configDir + "/visuals.toml");
|
||||
m_visuals = std::move(newVisuals);
|
||||
m_dimOverlay->setDimColor(m_visuals.overlays.modalDim);
|
||||
}
|
||||
catch (const std::exception& e)
|
||||
{
|
||||
@@ -393,6 +408,7 @@ void MainWindow::handleEvent(std::shared_ptr<const WinEvent> /*event*/)
|
||||
const int minutes = totalSeconds / 60;
|
||||
const int seconds = totalSeconds % 60;
|
||||
|
||||
ModalDimScope dim(*m_dimOverlay);
|
||||
QMessageBox box(this);
|
||||
box.setWindowTitle(tr("Won!"));
|
||||
box.setText(tr("You collected all artifacts!\nSurvival time: %1:%2")
|
||||
@@ -409,6 +425,7 @@ void MainWindow::handleEvent(std::shared_ptr<const WinEvent> /*event*/)
|
||||
GameConfig newConfig = ConfigLoader::loadFromDirectory(m_configDir);
|
||||
VisualsConfig newVisuals = VisualsLoader::load(m_configDir + "/visuals.toml");
|
||||
m_visuals = std::move(newVisuals);
|
||||
m_dimOverlay->setDimColor(m_visuals.overlays.modalDim);
|
||||
m_sim->reset(std::move(newConfig));
|
||||
}
|
||||
catch (const std::exception& e)
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
#include "EventHandler.h"
|
||||
#include "GameOverEvent.h"
|
||||
#include "LayoutDialogRequestedEvent.h"
|
||||
#include "ModalDimOverlay.h"
|
||||
#include "WinEvent.h"
|
||||
#include "RecipeSelectionRequestedEvent.h"
|
||||
#include "SchematicChoicesAvailableEvent.h"
|
||||
@@ -76,6 +77,7 @@ private:
|
||||
BuildButtonGrid* m_buildButtonGrid;
|
||||
BlueprintPanel* m_blueprintPanel;
|
||||
QWidget* m_sidePanel;
|
||||
ModalDimOverlay* m_dimOverlay = nullptr;
|
||||
|
||||
std::vector<ShipLayoutBlueprint> m_layoutBlueprints;
|
||||
std::shared_ptr<ParsedReplay> m_replay; // non-null => view-only playback
|
||||
|
||||
46
src/ui/ModalDimOverlay.cpp
Normal file
46
src/ui/ModalDimOverlay.cpp
Normal file
@@ -0,0 +1,46 @@
|
||||
#include "ModalDimOverlay.h"
|
||||
|
||||
#include <QPainter>
|
||||
|
||||
ModalDimOverlay::ModalDimOverlay(const QColor& dimColor, QWidget* parent)
|
||||
: QWidget(parent)
|
||||
, m_dimColor(dimColor)
|
||||
{
|
||||
setAttribute(Qt::WA_TransparentForMouseEvents, true);
|
||||
hide();
|
||||
}
|
||||
|
||||
void ModalDimOverlay::pushModal()
|
||||
{
|
||||
if (m_modalDepth++ == 0)
|
||||
{
|
||||
raise();
|
||||
show();
|
||||
// Force an immediate synchronous paint so the scrim is visible before the
|
||||
// caller enters a blocking dialog exec() (no undimmed frame flashes through).
|
||||
repaint();
|
||||
}
|
||||
}
|
||||
|
||||
void ModalDimOverlay::popModal()
|
||||
{
|
||||
if (m_modalDepth > 0 && --m_modalDepth == 0)
|
||||
{
|
||||
hide();
|
||||
}
|
||||
}
|
||||
|
||||
void ModalDimOverlay::setDimColor(const QColor& dimColor)
|
||||
{
|
||||
m_dimColor = dimColor;
|
||||
if (isVisible())
|
||||
{
|
||||
update();
|
||||
}
|
||||
}
|
||||
|
||||
void ModalDimOverlay::paintEvent(QPaintEvent* /*event*/)
|
||||
{
|
||||
QPainter painter(this);
|
||||
painter.fillRect(rect(), m_dimColor);
|
||||
}
|
||||
59
src/ui/ModalDimOverlay.h
Normal file
59
src/ui/ModalDimOverlay.h
Normal file
@@ -0,0 +1,59 @@
|
||||
#pragma once
|
||||
|
||||
#include <QColor>
|
||||
#include <QWidget>
|
||||
|
||||
class QPaintEvent;
|
||||
|
||||
// A window-wide, semi-transparent scrim drawn over the entire game window while a
|
||||
// modal dialog or menu is open, behind that modal (REQ-UI-MODAL-DIM). It is a child
|
||||
// of the main window covering its full rect and is transparent to mouse events, so it
|
||||
// only dims the game presentation and never intercepts input.
|
||||
//
|
||||
// Visibility is reference-counted via pushModal()/popModal() so that a single dim is
|
||||
// shown across nested or back-to-back modals (e.g. the recipe selection dialog that
|
||||
// immediately opens the layout dialog) rather than flickering or stacking overlays.
|
||||
class ModalDimOverlay : public QWidget
|
||||
{
|
||||
Q_OBJECT
|
||||
|
||||
public:
|
||||
ModalDimOverlay(const QColor& dimColor, QWidget* parent);
|
||||
|
||||
// Raise + show on the first active modal; hide when the last one closes.
|
||||
void pushModal();
|
||||
void popModal();
|
||||
|
||||
// Update the dim color (e.g. after a config reload on Restart, REQ-CFG-RELOAD).
|
||||
void setDimColor(const QColor& dimColor);
|
||||
|
||||
protected:
|
||||
void paintEvent(QPaintEvent* event) override;
|
||||
|
||||
private:
|
||||
QColor m_dimColor;
|
||||
int m_modalDepth = 0;
|
||||
};
|
||||
|
||||
// RAII guard: shows the dim overlay for the duration of a scope (typically around a
|
||||
// blocking dialog exec()) and hides it (via reference count) on scope exit.
|
||||
class ModalDimScope
|
||||
{
|
||||
public:
|
||||
explicit ModalDimScope(ModalDimOverlay& overlay)
|
||||
: m_overlay(overlay)
|
||||
{
|
||||
m_overlay.pushModal();
|
||||
}
|
||||
|
||||
~ModalDimScope()
|
||||
{
|
||||
m_overlay.popModal();
|
||||
}
|
||||
|
||||
ModalDimScope(const ModalDimScope&) = delete;
|
||||
ModalDimScope& operator=(const ModalDimScope&) = delete;
|
||||
|
||||
private:
|
||||
ModalDimOverlay& m_overlay;
|
||||
};
|
||||
@@ -44,6 +44,7 @@ QString shipTooltip(const ShipDef& def)
|
||||
{
|
||||
lines << itemLine(material.item, material.amount);
|
||||
}
|
||||
lines << QObject::tr(" + installed modules");
|
||||
}
|
||||
|
||||
lines << QObject::tr("Completion time: %1 s")
|
||||
|
||||
@@ -21,6 +21,7 @@
|
||||
#include "FactionComponent.h"
|
||||
#include "HealthComponent.h"
|
||||
#include "ModuleOwnerComponent.h"
|
||||
#include "SelectedBehaviorComponent.h"
|
||||
#include "ShipIdentityComponent.h"
|
||||
#include "ShipStatsCalculator.h"
|
||||
#include "ShipStatsPanel.h"
|
||||
@@ -47,6 +48,11 @@ namespace
|
||||
|
||||
QString buildingTypeName(BuildingType type)
|
||||
{
|
||||
if (type == BuildingType::Hq)
|
||||
{
|
||||
return QObject::tr("Player HQ");
|
||||
}
|
||||
|
||||
const std::string id = buildingTypeId(type);
|
||||
QString result;
|
||||
bool nextUpper = true;
|
||||
@@ -479,13 +485,24 @@ void SelectedBuildingPanel::refreshBuffers(const Building* b)
|
||||
bufText += "\n";
|
||||
}
|
||||
|
||||
// Count output-side items: buffered plus still-emerging on the output belts.
|
||||
// An emerging item still belongs to the output buffer (REQ-MAT-OUTPUT-EMERGE),
|
||||
// so it must be included here or it would vanish from the panel while animating.
|
||||
std::map<std::string, int> outCounts;
|
||||
for (const Item& item : b->outputBuffer.items)
|
||||
{
|
||||
outCounts[item.type.id]++;
|
||||
}
|
||||
for (const std::vector<BeltItemSlot>& lane : b->emergingItems)
|
||||
{
|
||||
for (const BeltItemSlot& slot : lane)
|
||||
{
|
||||
outCounts[slot.item.type.id]++;
|
||||
}
|
||||
}
|
||||
|
||||
if (recipe && !recipe->outputs.empty())
|
||||
{
|
||||
std::map<std::string, int> outCounts;
|
||||
for (const Item& item : b->outputBuffer.items)
|
||||
{
|
||||
outCounts[item.type.id]++;
|
||||
}
|
||||
bufText += tr("Output: ");
|
||||
for (const RecipeOutput& out : recipe->outputs)
|
||||
{
|
||||
@@ -497,13 +514,8 @@ void SelectedBuildingPanel::refreshBuffers(const Building* b)
|
||||
+ "/" + QString::number(out.amount) + " ";
|
||||
}
|
||||
}
|
||||
else if (!b->outputBuffer.items.empty())
|
||||
else if (!outCounts.empty())
|
||||
{
|
||||
std::map<std::string, int> outCounts;
|
||||
for (const Item& item : b->outputBuffer.items)
|
||||
{
|
||||
outCounts[item.type.id]++;
|
||||
}
|
||||
bufText += tr("Output: ");
|
||||
for (const std::pair<const std::string, int>& entry : outCounts)
|
||||
{
|
||||
@@ -942,6 +954,8 @@ void SelectedBuildingPanel::buildEntityShip(entt::entity entity)
|
||||
|
||||
const ShipStats stats = buildShipStatsFromEntity(admin, entity);
|
||||
m_entityStatsPanel->refreshFromLive(stats, health.hp);
|
||||
m_entityStatsPanel->setBehavior(
|
||||
admin.get<SelectedBehaviorComponent>(entity).winner);
|
||||
m_entityStatsPanel->setDebugDrawEnabled(m_debugDraw);
|
||||
|
||||
for (const ShipDef& def : m_config->ships.ships)
|
||||
@@ -965,7 +979,11 @@ void SelectedBuildingPanel::buildEntityStation(entt::entity entity)
|
||||
EntityAdmin& admin = m_sim->admin();
|
||||
const HealthComponent& health = admin.get<HealthComponent>(entity);
|
||||
|
||||
m_entityTitleLabel->setText(tr("Defence Station"));
|
||||
const bool isEnemy = admin.hasAll<FactionComponent>(entity)
|
||||
&& admin.get<FactionComponent>(entity).isEnemy;
|
||||
m_entityTitleLabel->setText(isEnemy
|
||||
? tr("Enemy Defence Station")
|
||||
: tr("Player Defence Station"));
|
||||
m_entityTitleLabel->show();
|
||||
|
||||
float totalDps = 0.0f;
|
||||
@@ -1021,6 +1039,8 @@ void SelectedBuildingPanel::refreshEntityStats()
|
||||
{
|
||||
const ShipStats stats = buildShipStatsFromEntity(admin, entity);
|
||||
m_entityStatsPanel->refreshFromLive(stats, health.hp);
|
||||
m_entityStatsPanel->setBehavior(
|
||||
admin.get<SelectedBehaviorComponent>(entity).winner);
|
||||
}
|
||||
else if (admin.hasAll<StationBodyComponent>(entity))
|
||||
{
|
||||
|
||||
@@ -107,6 +107,12 @@ ShipStatsPanel::ShipStatsPanel(const GameConfig* config, QWidget* parent)
|
||||
m_repairSection->setVisible(false);
|
||||
layout->addWidget(m_repairSection);
|
||||
|
||||
// Current behavior — live entities only; hidden in the static design
|
||||
// preview (REQ-UI-SHIP-BEHAVIOR).
|
||||
m_behaviorLabel = makeSectionHeader(QString(), this);
|
||||
m_behaviorLabel->setVisible(false);
|
||||
layout->addWidget(m_behaviorLabel);
|
||||
|
||||
// Threat cost — debug-only, initially hidden.
|
||||
m_threatCostLabel = makeStatLabel(this);
|
||||
m_threatCostLabel->setVisible(false);
|
||||
@@ -125,6 +131,9 @@ void ShipStatsPanel::refresh(const std::string& shipId,
|
||||
const double threat = calculateShipThreatCost(m_config->threatCosts, *m_config,
|
||||
shipId, modules);
|
||||
setThreatCost(threat);
|
||||
|
||||
// The static design preview has no live behavior to show.
|
||||
m_behaviorLabel->setVisible(false);
|
||||
}
|
||||
|
||||
void ShipStatsPanel::refreshFromLive(const ShipStats& stats, float currentHp)
|
||||
@@ -204,6 +213,32 @@ void ShipStatsPanel::applyStats(const ShipStats& stats, const QString& hpText)
|
||||
}
|
||||
}
|
||||
|
||||
void ShipStatsPanel::setBehavior(BehaviorKind kind)
|
||||
{
|
||||
QString label;
|
||||
switch (kind)
|
||||
{
|
||||
case BehaviorKind::Retreat: label = tr("Retreating"); break;
|
||||
case BehaviorKind::Attack: label = tr("Engaging"); break;
|
||||
case BehaviorKind::SalvageScrap:
|
||||
case BehaviorKind::DeliverScrap: label = tr("Salvaging"); break;
|
||||
case BehaviorKind::Repair: label = tr("Repairing"); break;
|
||||
case BehaviorKind::Rally: label = tr("Rallying"); break;
|
||||
case BehaviorKind::Standby: label = tr("Standby"); break;
|
||||
case BehaviorKind::Advance: label = tr("Advancing"); break;
|
||||
case BehaviorKind::None: break;
|
||||
}
|
||||
|
||||
if (label.isEmpty())
|
||||
{
|
||||
m_behaviorLabel->setVisible(false);
|
||||
return;
|
||||
}
|
||||
|
||||
m_behaviorLabel->setText(tr("Behavior: %1").arg(label));
|
||||
m_behaviorLabel->setVisible(true);
|
||||
}
|
||||
|
||||
void ShipStatsPanel::setThreatCost(double cost)
|
||||
{
|
||||
m_threatCostLabel->setText(tr("Threat Cost: %1").arg(cost, 0, 'f', 1));
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
|
||||
#include <QWidget>
|
||||
|
||||
#include "BehaviorKind.h"
|
||||
#include "ShipLayout.h"
|
||||
#include "ShipStatsCalculator.h"
|
||||
|
||||
@@ -24,6 +25,9 @@ public:
|
||||
|
||||
void refreshFromLive(const ShipStats& stats, float currentHp);
|
||||
|
||||
// Displays the ship's current top-priority behavior (REQ-UI-SHIP-BEHAVIOR).
|
||||
void setBehavior(BehaviorKind kind);
|
||||
|
||||
void setThreatCost(double cost);
|
||||
void setDebugDrawEnabled(bool enabled);
|
||||
|
||||
@@ -33,6 +37,7 @@ private:
|
||||
const GameConfig* m_config;
|
||||
bool m_debugDraw = false;
|
||||
|
||||
QLabel* m_behaviorLabel;
|
||||
QLabel* m_hpLabel;
|
||||
QLabel* m_speedLabel;
|
||||
QLabel* m_sensorRangeLabel;
|
||||
|
||||
@@ -49,6 +49,8 @@ struct OverlayVisuals
|
||||
QColor tileHighlight;
|
||||
QColor selectedOutline;
|
||||
QColor copyConfig;
|
||||
QColor lockedAsteroid;
|
||||
QColor modalDim;
|
||||
};
|
||||
|
||||
struct ToastVisuals
|
||||
|
||||
@@ -225,6 +225,8 @@ VisualsConfig VisualsLoader::load(const std::string& path)
|
||||
cfg.overlays.tileHighlight = parseColor(requireString(ov, "tile_highlight", "overlays"), "overlays.tile_highlight");
|
||||
cfg.overlays.selectedOutline = parseColor(requireString(ov, "selected_outline", "overlays"), "overlays.selected_outline");
|
||||
cfg.overlays.copyConfig = parseColor(requireString(ov, "copy_config", "overlays"), "overlays.copy_config");
|
||||
cfg.overlays.lockedAsteroid = parseColor(requireString(ov, "locked_asteroid", "overlays"), "overlays.locked_asteroid");
|
||||
cfg.overlays.modalDim = parseColor(requireString(ov, "modal_dim", "overlays"), "overlays.modal_dim");
|
||||
}
|
||||
|
||||
// Toast
|
||||
|
||||
Reference in New Issue
Block a user