Compare commits

..

12 Commits

Author SHA1 Message Date
c2e291c940 add requirements for construction percentage 2026-04-20 21:39:16 +02:00
2991a9584f show ports 2026-04-20 21:37:41 +02:00
50d505c886 requirements for output port indicators 2026-04-20 21:27:16 +02:00
46188bd9ca fix belts were not drawn 2026-04-20 21:18:14 +02:00
df5a10d81d fix viewport height 2026-04-20 21:12:32 +02:00
a0de7a9f56 coordinate system fix and build fix 2026-04-20 21:06:06 +02:00
828326635a fix cmake 2026-04-20 21:05:30 +02:00
94123e93d6 implement ui 2026-04-20 20:33:37 +02:00
498b97db20 implement waves 2026-04-20 14:10:01 +02:00
65de4ddc5c implement ship behaviors 2026-04-20 08:29:53 +02:00
8b84297b41 update architecture.md so that we can use QVector2D in the lib 2026-04-20 07:34:32 +02:00
411be72a5c implement scrap and ship skeleton 2026-04-20 07:32:18 +02:00
43 changed files with 5999 additions and 41 deletions

View File

@@ -14,7 +14,7 @@ This document captures the architectural decisions for the project. It is a comp
A strict separation between the game simulation and the Qt Widgets UI. A strict separation between the game simulation and the Qt Widgets UI.
- The **simulation** is a pure C++ library that depends only on Qt Core (QPoint, QVector2D, QRect, etc., as required by the coding guidelines), toml++, and tinyexpr. It contains no QtWidgets, no painting, and no QApplication. - The **simulation** is a pure C++ library that depends only on Qt Core and Qt Gui (QPoint, QVector2D, QRect, etc., as required by the coding guidelines), toml++, and tinyexpr. It contains no QtWidgets, no painting, and no QApplication. Note: in Qt 5, vector math types such as QVector2D live in Qt::Gui rather than Qt::Core, so the lib links both.
- The **UI** reads simulation state and renders it. It owns all widgets, painting, and input handling, and drives the simulation via a small command interface (place building, demolish, clear belt tiles, change recipe, set game speed, etc.). - The **UI** reads simulation state and renders it. It owns all widgets, painting, and input handling, and drives the simulation via a small command interface (place building, demolish, clear belt tiles, change recipe, set game speed, etc.).
This split is enforced at the CMake target level (see below). Tests link only against the simulation library and run without a display server. This split is enforced at the CMake target level (see below). Tests link only against the simulation library and run without a display server.
@@ -97,7 +97,7 @@ Within a single simulation tick, subsystems run in this fixed order. The order i
Three product targets plus tests: Three product targets plus tests:
- `lib/` — simulation + config. Depends on Qt Core, toml++, tinyexpr. No QtWidgets. - `lib/` — simulation + config. Depends on Qt Core + Qt Gui, toml++, tinyexpr. No QtWidgets.
- `ui/` — QtWidgets + `QOpenGLWidget` code: header bar, game world view, selected building panel, build button grid. Depends on `lib` and on Qt's OpenGL widgets module. - `ui/` — QtWidgets + `QOpenGLWidget` code: header bar, game world view, selected building panel, build button grid. Depends on `lib` and on Qt's OpenGL widgets module.
- `app/` — thin `main()` that creates the simulation, the UI, and wires them together. Depends on `ui`. - `app/` — thin `main()` that creates the simulation, the UI, and wires them together. Depends on `ui`.
- `tests/` — Catch2 tests. Links only against `lib`. - `tests/` — Catch2 tests. Links only against `lib`.

View File

@@ -10,9 +10,9 @@ Cross-references: `architecture.md` (design), `requirements.md` (REQ-* citations
| 2 | Simulation shell + TickDriver + entity id allocator + event queues | ✅ done | | 2 | Simulation shell + TickDriver + entity id allocator + event queues | ✅ done |
| 3 | Belt subsystem (placement, port interface, per-tile v1, splitter routing, clearTiles, visual iteration) | ✅ done | | 3 | Belt subsystem (placement, port interface, per-tile v1, splitter routing, clearTiles, visual iteration) | ✅ done |
| 4 | Buildings + placement + belt↔building transport | ✅ done | | 4 | Buildings + placement + belt↔building transport | ✅ done |
| 5 | Scrap + ships skeleton (data + spawning, no AI) | ⬜ next | | 5 | Scrap + ships skeleton (data + spawning, no AI) | ✅ done |
| 6 | Ship behavior systems + movement arbitration | | | 6 | Ship behavior systems + movement arbitration | ✅ done |
| 7 | Waves, threat accumulation, combat resolution, deaths & loot | | | 7 | Waves, threat accumulation, combat resolution, deaths & loot | ✅ done |
| 8 | UI layer (GameWorldView, visuals.toml, panels, build/demolish, speed controls) | ⬜ | | 8 | UI layer (GameWorldView, visuals.toml, panels, build/demolish, speed controls) | ⬜ |
Tick order reference (architecture.md §Tick Order): Tick order reference (architecture.md §Tick Order):

View File

@@ -28,7 +28,7 @@ Output port indicators are not building tiles themselves. A building may have mo
## Game World ## 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-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 20×20 pixels. Items on belts are 10×10 pixels (half a tile), so each belt tile holds at most 2 items. - 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, so each belt tile holds at most 2 items.
- REQ-GW-BELT-SPEED: Items on belts move at `world.toml [world].belt_speed_tiles_per_second` tiles per second (default 2). - 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`. - REQ-GW-HEIGHT: The world height (in tiles) is read from `world.toml [world].height_tiles`.
- REQ-GW-REGIONS: The world is divided into horizontal regions whose widths (in tiles) are read from `world.toml [regions]`: - REQ-GW-REGIONS: The world is divided into horizontal regions whose widths (in tiles) are read from `world.toml [regions]`:
@@ -175,6 +175,8 @@ The screen is divided into three vertical sections:
### Game World ### Game World
- REQ-UI-SCROLL: The player scrolls the view horizontally across the scrollable area by pressing A (scroll left) and D (scroll right). - REQ-UI-SCROLL: The player scrolls the view horizontally across the scrollable area by pressing A (scroll left) and D (scroll right).
- REQ-UI-CONSTRUCTION-PROGRESS: Construction sites display the building's glyph centered on the footprint (same as an operational building). Below the glyph — or centered on the footprint if the building has no glyph — a construction progress percentage is shown (integer, e.g. `42%`), increasing from 0% to 100% as construction completes.
- REQ-UI-PORT-GLYPH: Every output port of every building is indicated by a directional glyph drawn on the port's tile. The glyph is a `>` rotated to face the port's exit direction (`>` for East, `^` for North, `<` for West, `v` for South). It is drawn at the midpoint between the tile center and the tile edge that the port exits through (i.e. halfway from center toward the exit edge). The indicator is rendered for all building states: operational buildings, construction sites, and the builder-mode ghost. Buildings with multiple output ports (e.g. splitters) show one indicator per port.
- REQ-UI-NO-ZOOM: The view has a fixed zoom level; the player cannot zoom in or out. - REQ-UI-NO-ZOOM: The view has a fixed zoom level; the player cannot zoom in or out.
- REQ-UI-BLUEPRINT-TOAST: When a blueprint is unlocked or leveled up (REQ-DEF-BLUEPRINT-DROP), a transient notification toast appears in the top-right corner of the game world view for 4 seconds and then fades out. `<Ship Name>` in the text below is the blueprint's `ships.toml [ship.blueprint].display_name`. Toast text: - REQ-UI-BLUEPRINT-TOAST: When a blueprint is unlocked or leveled up (REQ-DEF-BLUEPRINT-DROP), a transient notification toast appears in the top-right corner of the game world view for 4 seconds and then fades out. `<Ship Name>` in the text below is the blueprint's `ships.toml [ship.blueprint].display_name`. Toast text:
- **New unlock**: `Blueprint unlocked: <Ship Name>` - **New unlock**: `Blueprint unlocked: <Ship Name>`

View File

@@ -55,7 +55,7 @@ set_property(TARGET ${TARGET_LIB_NAME} PROPERTY INCLUDE_DIRECTORIES
"${TARGET_LIB_INCLUDE_DIRS}" "${TARGET_LIB_INCLUDE_DIRS}"
"${LIB_INCLUDE_PATH}" "${LIB_INCLUDE_PATH}"
) )
target_link_libraries(${TARGET_LIB_NAME} Qt5::Core) target_link_libraries(${TARGET_LIB_NAME} Qt5::Core Qt5::Gui)
target_compile_definitions(${TARGET_LIB_NAME} PRIVATE TOML_FLOAT_CHARCONV=0) target_compile_definitions(${TARGET_LIB_NAME} PRIVATE TOML_FLOAT_CHARCONV=0)
set(CMAKE_AUTOMOC OFF) set(CMAKE_AUTOMOC OFF)
@@ -71,16 +71,44 @@ unset(SRCS)
# ============================================================ # ============================================================
# ui — QtWidgets + QOpenGLWidget # ui — QtWidgets + QOpenGLWidget
# Depends on lib. No sources yet; declared as INTERFACE library. # Depends on lib.
# When UI source files are added under src/ui/, change this to
# a regular static library and enable AUTOMOC on the target.
# ============================================================ # ============================================================
set(HDRS)
set(SRCS)
add_subdirectory(ui) add_subdirectory(ui)
add_library(${TARGET_UI_NAME} INTERFACE) set(CMAKE_AUTOMOC ON)
target_link_libraries(${TARGET_UI_NAME} INTERFACE set(RELATIVE_HDRS)
foreach (_file ${HDRS})
file(RELATIVE_PATH _relPath "${SRC_DIR}" "${_file}")
list(APPEND RELATIVE_HDRS "${_relPath}")
endforeach()
set(RELATIVE_SRCS)
foreach (_file ${SRCS})
file(RELATIVE_PATH _relPath "${SRC_DIR}" "${_file}")
list(APPEND RELATIVE_SRCS "${_relPath}")
endforeach()
add_files(UI_FILES ${RELATIVE_HDRS} ${RELATIVE_SRCS})
add_library(${TARGET_UI_NAME} STATIC ${UI_FILES})
create_source_groups(${UI_FILES})
set_target_properties(${TARGET_UI_NAME} PROPERTIES
AUTOMOC ON
CXX_STANDARD 17
)
target_include_directories(${TARGET_UI_NAME} PUBLIC
"${TARGET_UI_INCLUDE_DIRS}"
"${TARGET_LIB_INCLUDE_DIRS}"
"${LIB_INCLUDE_PATH}"
)
target_link_libraries(${TARGET_UI_NAME}
${TARGET_LIB_NAME} ${TARGET_LIB_NAME}
Qt5::Widgets Qt5::Widgets
${OPENGL_LIBRARIES} ${OPENGL_LIBRARIES}
@@ -88,12 +116,15 @@ target_link_libraries(${TARGET_UI_NAME} INTERFACE
Qt5::Multimedia Qt5::Multimedia
Qt5::Charts Qt5::Charts
) )
target_compile_definitions(${TARGET_UI_NAME} PRIVATE TOML_FLOAT_CHARCONV=0)
target_include_directories(${TARGET_UI_NAME} INTERFACE set(CMAKE_AUTOMOC OFF)
"${TARGET_UI_INCLUDE_DIRS}"
"${TARGET_LIB_INCLUDE_DIRS}" unset(UI_FILES)
"${LIB_INCLUDE_PATH}" unset(RELATIVE_HDRS)
) unset(RELATIVE_SRCS)
unset(HDRS)
unset(SRCS)
# ============================================================ # ============================================================
@@ -145,6 +176,9 @@ set_property(TARGET ${TARGET_APP_NAME} PROPERTY CXX_STANDARD 17)
set_target_properties(${TARGET_APP_NAME} PROPERTIES set_target_properties(${TARGET_APP_NAME} PROPERTIES
VS_DEBUGGER_WORKING_DIRECTORY "${OUTPUT_ROOT_PATH}/$(Configuration)/app/" VS_DEBUGGER_WORKING_DIRECTORY "${OUTPUT_ROOT_PATH}/$(Configuration)/app/"
) )
target_compile_definitions(${TARGET_APP_NAME} PRIVATE
DOTA_FACTORY_CONFIG_DIR="${CMAKE_SOURCE_DIR}/bin/config"
)
target_link_libraries(${TARGET_APP_NAME} ${TARGET_UI_NAME}) target_link_libraries(${TARGET_APP_NAME} ${TARGET_UI_NAME})
unset(APP_FILES) unset(APP_FILES)

View File

@@ -12,5 +12,3 @@ SET(SRCS
PARENT_SCOPE PARENT_SCOPE
) )

View File

@@ -3,9 +3,13 @@
#include <QApplication> #include <QApplication>
#include <QDir> #include <QDir>
#include "ConfigLoader.h"
#include "ConsoleLogger.h" #include "ConsoleLogger.h"
#include "logging.h" #include "logging.h"
#include "LogManager.h" #include "LogManager.h"
#include "MainWindow.h"
#include "Simulation.h"
#include "VisualsLoader.h"
int main(int argc, char *argv[]) int main(int argc, char *argv[])
{ {
@@ -28,6 +32,13 @@ int main(int argc, char *argv[])
QDir().mkdir(dataDir.dirName()); QDir().mkdir(dataDir.dirName());
} }
GameConfig config = ConfigLoader::loadFromDirectory(DOTA_FACTORY_CONFIG_DIR);
VisualsConfig visuals = VisualsLoader::load(std::string(DOTA_FACTORY_CONFIG_DIR) + "/visuals.toml");
std::unique_ptr<Simulation> sim = std::make_unique<Simulation>(config);
MainWindow window(sim.get(), &config, &visuals);
window.show();
const int ret = application.exec(); const int ret = application.exec();
return ret; return ret;

View File

@@ -52,6 +52,17 @@ struct ConstructionSite
Tick completesAt = 0; // 0 = queued but not yet started Tick completesAt = 0; // 0 = queued but not yet started
}; };
// Weapon state for stationary structures (defence stations).
// Distinct from Ship::Weapon; stations have no movement intent.
struct StationWeapon
{
float damage;
float range;
float fireRateHz;
float cooldownTicks;
std::optional<EntityId> currentTarget;
};
// A fully constructed, operational building. // A fully constructed, operational building.
struct Building struct Building
{ {
@@ -73,4 +84,7 @@ struct Building
std::vector<Port> outputPorts; std::vector<Port> outputPorts;
std::vector<Port> inputPorts; // perimeter tiles (minus output-port tiles), std::vector<Port> inputPorts; // perimeter tiles (minus output-port tiles),
// direction pointing INTO building // direction pointing INTO building
// Set only for defence stations; nullopt for all other building types.
std::optional<StationWeapon> weapon;
}; };

View File

@@ -1,6 +1,7 @@
#include "BuildingSystem.h" #include "BuildingSystem.h"
#include <cassert> #include <cassert>
#include <limits>
#include <random> #include <random>
#include <set> #include <set>
@@ -172,7 +173,7 @@ EntityId BuildingSystem::place(BuildingType type, QPoint anchor,
{ {
m_belts.placeBelt(anchor, rotation); m_belts.placeBelt(anchor, rotation);
m_tileOccupancy[{anchor.x(), anchor.y()}] = id; m_tileOccupancy[{anchor.x(), anchor.y()}] = id;
m_beltEntities[id] = BeltEntry{anchor, BuildingType::Belt}; m_beltEntities[id] = BeltEntry{anchor, BuildingType::Belt, rotation, rotation};
return id; return id;
} }
@@ -186,7 +187,7 @@ EntityId BuildingSystem::place(BuildingType type, QPoint anchor,
const Rotation outB = mask.outputPorts[1].direction; const Rotation outB = mask.outputPorts[1].direction;
m_belts.placeSplitter(anchor, outA, outB); m_belts.placeSplitter(anchor, outA, outB);
m_tileOccupancy[{anchor.x(), anchor.y()}] = id; m_tileOccupancy[{anchor.x(), anchor.y()}] = id;
m_beltEntities[id] = BeltEntry{anchor, BuildingType::Splitter}; m_beltEntities[id] = BeltEntry{anchor, BuildingType::Splitter, outA, outB};
return id; return id;
} }
@@ -655,7 +656,169 @@ std::vector<ConstructionSite> BuildingSystem::allSites() const
m_constructionQueue.end()); m_constructionQueue.end());
} }
std::vector<BuildingSystem::BeltTileInfo> BuildingSystem::allBeltTiles() const
{
std::vector<BeltTileInfo> result;
result.reserve(m_beltEntities.size());
for (const std::map<EntityId, BeltEntry>::value_type& kv : m_beltEntities)
{
BeltTileInfo info;
info.id = kv.first;
info.tile = kv.second.tile;
info.type = kv.second.type;
info.directionA = kv.second.directionA;
info.directionB = kv.second.directionB;
result.push_back(info);
}
return result;
}
bool BuildingSystem::isTileOccupied(QPoint tile) const bool BuildingSystem::isTileOccupied(QPoint tile) const
{ {
return m_tileOccupancy.count({tile.x(), tile.y()}) > 0; return m_tileOccupancy.count({tile.x(), tile.y()}) > 0;
} }
const Building* BuildingSystem::findNearestBuilding(QVector2D worldPos,
BuildingType type) const
{
const Building* best = nullptr;
float bestDist = std::numeric_limits<float>::max();
for (const Building& b : m_buildings)
{
if (b.type != type)
{
continue;
}
QVector2D center(b.anchor.x() + b.footprint.width() / 2.0f,
b.anchor.y() + b.footprint.height() / 2.0f);
float dist = (center - worldPos).length();
if (dist < bestDist)
{
bestDist = dist;
best = &b;
}
}
return best;
}
bool BuildingSystem::deliverScrapToSalvageBay(EntityId bayId)
{
Building* bay = nullptr;
for (Building& b : m_buildings)
{
if (b.id == bayId)
{
bay = &b;
break;
}
}
if (!bay || bay->type != BuildingType::SalvageBay)
{
return false;
}
if (static_cast<int>(bay->outputBuffer.items.size()) >= bay->outputBuffer.capacity)
{
return false;
}
bay->outputBuffer.items.push_back(Item{ItemType{"scrap"}});
return true;
}
void BuildingSystem::healBuilding(EntityId id, float amount)
{
for (Building& b : m_buildings)
{
if (b.id == id)
{
b.hp = std::min(b.hp + amount, b.maxHp);
return;
}
}
}
void BuildingSystem::damageBuilding(EntityId id, float amount)
{
for (Building& b : m_buildings)
{
if (b.id == id)
{
b.hp -= amount;
return;
}
}
}
EntityId BuildingSystem::placeImmediate(BuildingType type,
const std::vector<std::string>& surfaceMask,
QPoint anchor, Rotation rotation,
float hp, float maxHp)
{
const EntityId id = m_allocateId();
const ParsedSurfaceMask mask = parseSurfaceMask(surfaceMask, rotation);
Building building;
building.id = id;
building.anchor = anchor;
building.footprint = mask.footprint;
building.rotation = rotation;
building.type = type;
building.hp = hp;
building.maxHp = maxHp;
for (const QPoint& cell : mask.bodyCells)
{
const QPoint absCell = anchor + cell;
building.bodyCells.push_back(absCell);
m_tileOccupancy[{absCell.x(), absCell.y()}] = id;
}
for (const Port& port : mask.outputPorts)
{
Port absPort;
absPort.tile = anchor + port.tile;
absPort.direction = port.direction;
building.outputPorts.push_back(absPort);
}
building.inputPorts = computeInputPorts(building);
m_buildings.push_back(std::move(building));
return id;
}
bool BuildingSystem::removeBuilding(EntityId id)
{
for (std::vector<Building>::iterator it = m_buildings.begin();
it != m_buildings.end();
++it)
{
if (it->id == id)
{
for (const QPoint& cell : it->bodyCells)
{
m_tileOccupancy.erase({cell.x(), cell.y()});
}
m_buildings.erase(it);
return true;
}
}
return false;
}
void BuildingSystem::initStationWeapon(EntityId id, const StationWeapon& weapon)
{
for (Building& b : m_buildings)
{
if (b.id == id)
{
b.weapon = weapon;
return;
}
}
}
void BuildingSystem::forEachBuilding(std::function<void(Building&)> fn)
{
for (Building& b : m_buildings)
{
fn(b);
}
}

View File

@@ -10,6 +10,7 @@
#include <vector> #include <vector>
#include <QPoint> #include <QPoint>
#include <QVector2D>
#include "BeltSystem.h" #include "BeltSystem.h"
#include "Building.h" #include "Building.h"
@@ -54,17 +55,60 @@ public:
void tickBeltPush(); void tickBeltPush();
// -- Queries ------------------------------------------------------------- // -- Queries -------------------------------------------------------------
struct BeltTileInfo
{
EntityId id;
QPoint tile;
BuildingType type; // Belt or Splitter
Rotation directionA; // Belt: its direction; Splitter: first output
Rotation directionB; // Splitter: second output; Belt: same as directionA
};
const Building* findBuilding(EntityId id) const; const Building* findBuilding(EntityId id) const;
const ConstructionSite* findSite(EntityId id) const; const ConstructionSite* findSite(EntityId id) const;
std::vector<Building> allBuildings() const; std::vector<Building> allBuildings() const;
std::vector<ConstructionSite> allSites() const; std::vector<ConstructionSite> allSites() const;
std::vector<BeltTileInfo> allBeltTiles() const;
bool isTileOccupied(QPoint tile) const; bool isTileOccupied(QPoint tile) const;
// Find nearest operational building of the given type; nullptr if none.
const Building* findNearestBuilding(QVector2D worldPos, BuildingType type) const;
// Place one "scrap" item into a SalvageBay's output buffer.
// Returns false if bay not found, wrong type, or output buffer is full.
bool deliverScrapToSalvageBay(EntityId bayId);
// Increase a building's HP by amount, clamped to maxHp.
void healBuilding(EntityId id, float amount);
// Reduce a building's HP by amount; hp may go below 0 (step 9 processes deaths).
void damageBuilding(EntityId id, float amount);
// Bypass the construction queue and create a fully-operational Building
// immediately. Used for pre-placed structures (HQ, defence stations).
// surfaceMask comes from the relevant config struct.
EntityId placeImmediate(BuildingType type,
const std::vector<std::string>& surfaceMask,
QPoint anchor, Rotation rotation,
float hp, float maxHp);
// Remove an operational building by id without refund (used for deaths).
// Returns true if found and removed.
bool removeBuilding(EntityId id);
// Set the weapon component on an already-placed defence station.
void initStationWeapon(EntityId id, const StationWeapon& weapon);
// Mutable iteration over all operational buildings (used by CombatSystem).
void forEachBuilding(std::function<void(Building&)> fn);
private: private:
struct BeltEntry struct BeltEntry
{ {
QPoint tile; QPoint tile;
BuildingType type; // Belt or Splitter BuildingType type; // Belt or Splitter
Rotation directionA; // Belt: its direction; Splitter: first output
Rotation directionB; // Splitter: second output; Belt: same as directionA
}; };
const BuildingDef* findBuildingDef(BuildingType type) const; const BuildingDef* findBuildingDef(BuildingType type) const;

View File

@@ -5,6 +5,12 @@ SET(HDRS
${CMAKE_CURRENT_SOURCE_DIR}/BeltSystem.h ${CMAKE_CURRENT_SOURCE_DIR}/BeltSystem.h
${CMAKE_CURRENT_SOURCE_DIR}/Building.h ${CMAKE_CURRENT_SOURCE_DIR}/Building.h
${CMAKE_CURRENT_SOURCE_DIR}/BuildingSystem.h ${CMAKE_CURRENT_SOURCE_DIR}/BuildingSystem.h
${CMAKE_CURRENT_SOURCE_DIR}/Scrap.h
${CMAKE_CURRENT_SOURCE_DIR}/Ship.h
${CMAKE_CURRENT_SOURCE_DIR}/ShipSystem.h
${CMAKE_CURRENT_SOURCE_DIR}/ScrapSystem.h
${CMAKE_CURRENT_SOURCE_DIR}/WaveSystem.h
${CMAKE_CURRENT_SOURCE_DIR}/CombatSystem.h
PARENT_SCOPE PARENT_SCOPE
) )
@@ -14,6 +20,10 @@ SET(SRCS
${CMAKE_CURRENT_SOURCE_DIR}/TickDriver.cpp ${CMAKE_CURRENT_SOURCE_DIR}/TickDriver.cpp
${CMAKE_CURRENT_SOURCE_DIR}/BeltSystem.cpp ${CMAKE_CURRENT_SOURCE_DIR}/BeltSystem.cpp
${CMAKE_CURRENT_SOURCE_DIR}/BuildingSystem.cpp ${CMAKE_CURRENT_SOURCE_DIR}/BuildingSystem.cpp
${CMAKE_CURRENT_SOURCE_DIR}/ShipSystem.cpp
${CMAKE_CURRENT_SOURCE_DIR}/ScrapSystem.cpp
${CMAKE_CURRENT_SOURCE_DIR}/WaveSystem.cpp
${CMAKE_CURRENT_SOURCE_DIR}/CombatSystem.cpp
PARENT_SCOPE PARENT_SCOPE
) )

View File

@@ -0,0 +1,246 @@
#include "CombatSystem.h"
#include "BuildingSystem.h"
#include "BuildingType.h"
#include "Ship.h"
#include "ShipSystem.h"
CombatSystem::CombatSystem(const GameConfig& config)
: m_config(config)
{
}
void CombatSystem::tick(Tick currentTick,
ShipSystem& ships,
BuildingSystem& buildings,
std::vector<FireEvent>& outFireEvents)
{
// Ships: iterate and resolve weapon for each combat ship.
ships.forEach([&](Ship& ship)
{
resolveShipWeapon(ship, currentTick, ships, buildings, outFireEvents);
});
// Defence stations: acquire targets and fire.
buildings.forEachBuilding([&](Building& building)
{
if (building.type == BuildingType::PlayerDefenceStation ||
building.type == BuildingType::EnemyDefenceStation)
{
resolveStationWeapon(building, currentTick, ships, buildings, outFireEvents);
}
});
}
void CombatSystem::resolveShipWeapon(Ship& ship, Tick currentTick,
ShipSystem& ships,
BuildingSystem& buildings,
std::vector<FireEvent>& out)
{
if (!ship.weapon || !ship.threatResponse ||
!ship.threatResponse->currentTarget)
{
return;
}
Weapon& w = *ship.weapon;
// Decrement cooldown toward zero.
if (w.cooldownTicks > 0.0f)
{
w.cooldownTicks -= 1.0f;
}
if (w.cooldownTicks > 0.0f)
{
return;
}
const EntityId targetId = *ship.threatResponse->currentTarget;
const std::optional<QVector2D> tPos = targetPosition(targetId, ships, buildings);
if (!tPos)
{
ship.threatResponse->currentTarget = std::nullopt;
return;
}
const float dist = (ship.position - *tPos).length();
if (dist > w.range)
{
return;
}
// Apply damage to the correct pool.
if (ships.findShip(targetId))
{
ships.damageShip(targetId, w.damage);
}
else
{
buildings.damageBuilding(targetId, w.damage);
}
FireEvent evt;
evt.shooter = ship.id;
evt.target = targetId;
evt.emittedAt = currentTick;
out.push_back(evt);
w.cooldownTicks = static_cast<float>(kTickRateHz) / w.fireRateHz;
}
void CombatSystem::resolveStationWeapon(Building& station, Tick currentTick,
ShipSystem& ships,
BuildingSystem& buildings,
std::vector<FireEvent>& out)
{
if (!station.weapon)
{
return;
}
StationWeapon& w = *station.weapon;
const bool stationIsEnemy = (station.type == BuildingType::EnemyDefenceStation);
const QVector2D stationCenter(
station.anchor.x() + station.footprint.width() / 2.0f,
station.anchor.y() + station.footprint.height() / 2.0f);
// Validate or clear existing target.
if (w.currentTarget)
{
const std::optional<QVector2D> tPos =
targetPosition(*w.currentTarget, ships, buildings);
if (!tPos || (stationCenter - *tPos).length() > w.range)
{
w.currentTarget = std::nullopt;
}
}
// Acquire a new target if needed.
if (!w.currentTarget)
{
w.currentTarget = acquireStationTarget(station, stationIsEnemy,
ships, buildings);
}
if (!w.currentTarget)
{
return;
}
// Decrement cooldown.
if (w.cooldownTicks > 0.0f)
{
w.cooldownTicks -= 1.0f;
}
if (w.cooldownTicks > 0.0f)
{
return;
}
const EntityId targetId = *w.currentTarget;
const std::optional<QVector2D> tPos = targetPosition(targetId, ships, buildings);
if (!tPos)
{
w.currentTarget = std::nullopt;
return;
}
if ((stationCenter - *tPos).length() > w.range)
{
return;
}
if (ships.findShip(targetId))
{
ships.damageShip(targetId, w.damage);
}
else
{
buildings.damageBuilding(targetId, w.damage);
}
FireEvent evt;
evt.shooter = station.id;
evt.target = targetId;
evt.emittedAt = currentTick;
out.push_back(evt);
w.cooldownTicks = static_cast<float>(kTickRateHz) / w.fireRateHz;
}
std::optional<EntityId> CombatSystem::acquireStationTarget(
const Building& station, bool stationIsEnemy,
const ShipSystem& ships,
const BuildingSystem& buildings) const
{
const QVector2D stationCenter(
station.anchor.x() + station.footprint.width() / 2.0f,
station.anchor.y() + station.footprint.height() / 2.0f);
const float range = station.weapon->range;
std::optional<EntityId> best;
float bestDist = range;
// Scan ships for valid targets.
for (const Ship& candidate : ships.allShips())
{
const bool isValidTarget = stationIsEnemy ? !candidate.isEnemy
: candidate.isEnemy;
if (!isValidTarget)
{
continue;
}
const float dist = (candidate.position - stationCenter).length();
if (dist < bestDist)
{
bestDist = dist;
best = candidate.id;
}
}
// Enemy stations also target player buildings (HQ, PlayerDefenceStation).
if (stationIsEnemy)
{
for (const Building& b : buildings.allBuildings())
{
if (b.type != BuildingType::Hq &&
b.type != BuildingType::PlayerDefenceStation)
{
continue;
}
const QVector2D bCenter(b.anchor.x() + b.footprint.width() / 2.0f,
b.anchor.y() + b.footprint.height() / 2.0f);
const float dist = (bCenter - stationCenter).length();
if (dist < bestDist)
{
bestDist = dist;
best = b.id;
}
}
}
return best;
}
std::optional<QVector2D> CombatSystem::targetPosition(
EntityId id,
const ShipSystem& ships,
const BuildingSystem& buildings) const
{
const Ship* ship = ships.findShip(id);
if (ship)
{
return ship->position;
}
const Building* bld = buildings.findBuilding(id);
if (bld)
{
return QVector2D(bld->anchor.x() + bld->footprint.width() / 2.0f,
bld->anchor.y() + bld->footprint.height() / 2.0f);
}
return std::nullopt;
}

View File

@@ -0,0 +1,61 @@
#pragma once
#include <optional>
#include <vector>
#include <QVector2D>
#include "Building.h"
#include "EntityId.h"
#include "FireEvent.h"
#include "GameConfig.h"
#include "Ship.h"
#include "Tick.h"
class BuildingSystem;
class ShipSystem;
// Resolves all weapon fire for ships and defence stations (tick-order step 8).
// REQ-SHP-FIRING, REQ-DEF-PLAYER-FIRE, REQ-DEF-ENEMY-FIRE.
class CombatSystem
{
public:
explicit CombatSystem(const GameConfig& config);
// Advance weapon cooldowns, acquire targets for stations, fire when ready,
// apply damage, and append FireEvents. Damage is applied immediately via
// ShipSystem::damageShip and BuildingSystem::damageBuilding; step 9
// removes entities whose HP dropped to zero or below.
void tick(Tick currentTick,
ShipSystem& ships,
BuildingSystem& buildings,
std::vector<FireEvent>& outFireEvents);
private:
// Process one ship's weapon for this tick.
void resolveShipWeapon(Ship& ship, Tick currentTick,
ShipSystem& ships,
BuildingSystem& buildings,
std::vector<FireEvent>& out);
// Process one defence-station's weapon for this tick.
void resolveStationWeapon(Building& station, Tick currentTick,
ShipSystem& ships,
BuildingSystem& buildings,
std::vector<FireEvent>& out);
// Find the nearest valid target for a defence station within its range.
// Enemy stations target player ships + HQ + PlayerDefenceStation.
// Player stations target enemy ships only.
std::optional<EntityId> acquireStationTarget(
const Building& station, bool stationIsEnemy,
const ShipSystem& ships,
const BuildingSystem& buildings) const;
// Return the world position of the entity, or nullopt if it no longer exists.
std::optional<QVector2D> targetPosition(EntityId id,
const ShipSystem& ships,
const BuildingSystem& buildings) const;
const GameConfig& m_config;
};

14
src/lib/sim/Scrap.h Normal file
View File

@@ -0,0 +1,14 @@
#pragma once
#include <QVector2D>
#include "EntityId.h"
#include "Tick.h"
struct Scrap
{
EntityId id;
QVector2D position;
int amount;
Tick despawnAt;
};

View File

@@ -0,0 +1,59 @@
#include "ScrapSystem.h"
#include <algorithm>
#include <optional>
ScrapSystem::ScrapSystem(std::function<EntityId()> allocateId)
: m_allocateId(std::move(allocateId))
{
}
EntityId ScrapSystem::spawn(QVector2D position, int amount, Tick despawnAt)
{
Scrap s;
s.id = m_allocateId();
s.position = position;
s.amount = amount;
s.despawnAt = despawnAt;
m_scraps.push_back(s);
return s.id;
}
void ScrapSystem::tickDespawn(Tick currentTick)
{
m_scraps.erase(
std::remove_if(m_scraps.begin(), m_scraps.end(),
[currentTick](const Scrap& s) { return s.despawnAt <= currentTick; }),
m_scraps.end());
}
const Scrap* ScrapSystem::findScrap(EntityId id) const
{
for (const Scrap& s : m_scraps)
{
if (s.id == id)
{
return &s;
}
}
return nullptr;
}
std::optional<Scrap> ScrapSystem::consume(EntityId id)
{
for (auto it = m_scraps.begin(); it != m_scraps.end(); ++it)
{
if (it->id == id)
{
Scrap result = *it;
m_scraps.erase(it);
return result;
}
}
return std::nullopt;
}
std::vector<Scrap> ScrapSystem::allScraps() const
{
return m_scraps;
}

28
src/lib/sim/ScrapSystem.h Normal file
View File

@@ -0,0 +1,28 @@
#pragma once
#include <functional>
#include <optional>
#include <vector>
#include <QVector2D>
#include "EntityId.h"
#include "Scrap.h"
#include "Tick.h"
class ScrapSystem
{
public:
explicit ScrapSystem(std::function<EntityId()> allocateId);
EntityId spawn(QVector2D position, int amount, Tick despawnAt);
void tickDespawn(Tick currentTick);
std::optional<Scrap> consume(EntityId id); // removes and returns scrap, or nullopt
const Scrap* findScrap(EntityId id) const;
std::vector<Scrap> allScraps() const;
private:
std::function<EntityId()> m_allocateId;
std::vector<Scrap> m_scraps;
};

93
src/lib/sim/Ship.h Normal file
View File

@@ -0,0 +1,93 @@
#pragma once
#include <optional>
#include <string>
#include <QVector2D>
#include "EntityId.h"
#include "MovementIntent.h"
// ---------------------------------------------------------------------------
// Hardware components — derived from config at spawn, stored on ship
// ---------------------------------------------------------------------------
struct Weapon
{
float damage;
float range;
float fireRateHz;
float cooldownTicks;
std::optional<EntityId> currentTarget;
};
struct SalvageCargo
{
int capacity;
int current;
float collectionRange; // copy of ShipDef.salvage.collectionRange (tile units)
};
struct RepairTool
{
float ratePerTick;
float range;
std::optional<EntityId> currentTarget;
};
// ---------------------------------------------------------------------------
// Behavior components — AI state consumed by step-6 behavior systems
// ---------------------------------------------------------------------------
struct ThreatResponse
{
float engagementRange;
std::optional<EntityId> currentTarget;
};
struct ScrapCollector
{
std::optional<QVector2D> scrapTarget;
EntityId deliveryBay; // kInvalidEntityId until assigned at a salvage bay
};
struct RepairBehavior
{
std::optional<EntityId> currentTarget;
};
struct HomeReturn
{
float retreatHpFraction;
QVector2D homePos;
};
// ---------------------------------------------------------------------------
// Ship
// ---------------------------------------------------------------------------
struct Ship
{
EntityId id;
QVector2D position;
QVector2D velocity;
float hp;
float maxHp;
float speedPerTick; // pre-evaluated from speedFormula / kTickRateHz
int level;
std::string blueprintId;
bool isEnemy = false; // true for enemy-faction ships (used by behavior systems)
std::optional<Weapon> weapon;
std::optional<SalvageCargo> cargo;
std::optional<RepairTool> repairTool;
std::optional<ThreatResponse> threatResponse;
std::optional<ScrapCollector> scrapCollector;
std::optional<RepairBehavior> repairBehavior;
std::optional<HomeReturn> homeReturn;
// Cleared at the start of the behavior step each tick; the highest-priority
// write from behavior systems wins (architecture.md §Movement Arbitration).
MovementIntent intent;
};

665
src/lib/sim/ShipSystem.cpp Normal file
View File

@@ -0,0 +1,665 @@
#include "ShipSystem.h"
#include <algorithm>
#include <cassert>
#include <limits>
#include "Building.h"
#include "BuildingSystem.h"
#include "BuildingType.h"
#include "Scrap.h"
#include "ScrapSystem.h"
#include "Tick.h"
ShipSystem::ShipSystem(const GameConfig& config,
std::function<EntityId()> allocateId)
: m_config(config)
, m_allocateId(std::move(allocateId))
{
}
const ShipDef* ShipSystem::findShipDef(const std::string& blueprintId) const
{
for (const ShipDef& def : m_config.ships.ships)
{
if (def.id == blueprintId)
{
return &def;
}
}
return nullptr;
}
EntityId ShipSystem::spawn(const std::string& blueprintId, int level, QVector2D position,
bool isEnemy)
{
const ShipDef* def = findShipDef(blueprintId);
assert(def != nullptr);
const double x = static_cast<double>(level);
Ship ship;
ship.id = m_allocateId();
ship.position = position;
ship.velocity = QVector2D(0.0f, 0.0f);
ship.maxHp = static_cast<float>(def->health.hpFormula.evaluate(x));
ship.hp = ship.maxHp;
ship.speedPerTick = static_cast<float>(
def->movement.speedFormula.evaluate(x))
/ static_cast<float>(kTickRateHz);
ship.level = level;
ship.blueprintId = blueprintId;
ship.isEnemy = isEnemy;
ship.intent = MovementIntent{0, QVector2D(0.0f, 0.0f)};
if (def->combat)
{
Weapon w;
w.damage = static_cast<float>(def->combat->damageFormula.evaluate(x));
w.range = static_cast<float>(def->combat->attackRangeFormula.evaluate(x));
w.fireRateHz = static_cast<float>(def->combat->attackRateFormula.evaluate(x));
w.cooldownTicks = 0.0f;
ship.weapon = w;
ThreatResponse tr;
tr.engagementRange = w.range;
ship.threatResponse = tr;
}
if (def->salvage)
{
SalvageCargo cargo;
cargo.capacity = def->salvage->cargoCapacity;
cargo.current = 0;
cargo.collectionRange = static_cast<float>(def->salvage->collectionRange);
ship.cargo = cargo;
ScrapCollector sc;
sc.scrapTarget = std::nullopt;
sc.deliveryBay = kInvalidEntityId;
ship.scrapCollector = sc;
}
if (def->repair)
{
RepairTool rt;
rt.ratePerTick = static_cast<float>(def->repair->repairRateFormula.evaluate(x));
rt.range = static_cast<float>(def->repair->repairRangeFormula.evaluate(x));
ship.repairTool = rt;
RepairBehavior rb;
ship.repairBehavior = rb;
}
m_ships.push_back(ship);
return ship.id;
}
void ShipSystem::despawn(EntityId id)
{
m_ships.erase(
std::remove_if(m_ships.begin(), m_ships.end(),
[id](const Ship& s) { return s.id == id; }),
m_ships.end());
}
const Ship* ShipSystem::findShip(EntityId id) const
{
for (const Ship& s : m_ships)
{
if (s.id == id)
{
return &s;
}
}
return nullptr;
}
std::vector<Ship> ShipSystem::allShips() const
{
return m_ships;
}
void ShipSystem::forEach(std::function<void(Ship&)> fn)
{
for (Ship& s : m_ships)
{
fn(s);
}
}
// ---------------------------------------------------------------------------
// Private helpers
// ---------------------------------------------------------------------------
static QVector2D buildingCenter(const Building& b)
{
return QVector2D(b.anchor.x() + b.footprint.width() / 2.0f,
b.anchor.y() + b.footprint.height() / 2.0f);
}
bool ShipSystem::isTargetValid(EntityId id, float range, const Ship& ship,
const BuildingSystem& buildings) const
{
if (id == kInvalidEntityId)
{
return false;
}
// Check ship pool first.
const Ship* target = findShip(id);
if (target)
{
return (target->position - ship.position).length() <= range;
}
// Check building pool (HQ and defence stations are targetable).
const Building* bld = buildings.findBuilding(id);
if (bld)
{
return (buildingCenter(*bld) - ship.position).length() <= range;
}
return false;
}
bool ShipSystem::healShip(EntityId id, float amount)
{
for (Ship& s : m_ships)
{
if (s.id == id)
{
s.hp = std::min(s.hp + amount, s.maxHp);
return true;
}
}
return false;
}
bool ShipSystem::damageShip(EntityId id, float amount)
{
for (Ship& s : m_ships)
{
if (s.id == id)
{
s.hp -= amount;
return true;
}
}
return false;
}
// ---------------------------------------------------------------------------
// clearMovementIntents
// ---------------------------------------------------------------------------
void ShipSystem::clearMovementIntents()
{
for (Ship& s : m_ships)
{
s.intent = MovementIntent{0, QVector2D(0.0f, 0.0f)};
}
}
// ---------------------------------------------------------------------------
// tickHomeReturn (priority 4)
// ---------------------------------------------------------------------------
void ShipSystem::tickHomeReturn()
{
for (Ship& s : m_ships)
{
if (!s.homeReturn)
{
continue;
}
if (s.hp / s.maxHp < s.homeReturn->retreatHpFraction)
{
if (4 > s.intent.priority)
{
s.intent = MovementIntent{4, s.homeReturn->homePos};
}
}
}
}
// ---------------------------------------------------------------------------
// tickThreatResponse (priority 3)
// ---------------------------------------------------------------------------
void ShipSystem::tickThreatResponse(const BuildingSystem& buildings)
{
// Snapshot all buildings once (used for enemy targeting).
const std::vector<Building> allBuildings = buildings.allBuildings();
for (Ship& s : m_ships)
{
if (!s.threatResponse)
{
continue;
}
const float range = s.threatResponse->engagementRange;
if (!s.isEnemy)
{
// Player combat ship: target nearest enemy ship.
if (!isTargetValid(s.threatResponse->currentTarget.value_or(kInvalidEntityId),
range, s, buildings))
{
s.threatResponse->currentTarget = std::nullopt;
float bestDist = range;
for (const Ship& candidate : m_ships)
{
if (!candidate.isEnemy)
{
continue;
}
float dist = (candidate.position - s.position).length();
if (dist < bestDist)
{
bestDist = dist;
s.threatResponse->currentTarget = candidate.id;
}
}
}
if (s.threatResponse->currentTarget)
{
const Ship* target = findShip(*s.threatResponse->currentTarget);
if (target && 3 > s.intent.priority)
{
s.intent = MovementIntent{3, target->position};
}
}
else
{
// No target: patrol rightward (aggressive).
if (3 > s.intent.priority)
{
s.intent = MovementIntent{3, QVector2D(s.position.x() + 1000.0f,
s.position.y())};
}
}
}
else
{
// Enemy ship: target nearest player ship or player building.
if (!isTargetValid(s.threatResponse->currentTarget.value_or(kInvalidEntityId),
range, s, buildings))
{
s.threatResponse->currentTarget = std::nullopt;
float bestDist = range;
for (const Ship& candidate : m_ships)
{
if (candidate.isEnemy)
{
continue;
}
float dist = (candidate.position - s.position).length();
if (dist < bestDist)
{
bestDist = dist;
s.threatResponse->currentTarget = candidate.id;
}
}
for (const Building& b : allBuildings)
{
if (b.type != BuildingType::PlayerDefenceStation
&& b.type != BuildingType::Hq)
{
continue;
}
float dist = (buildingCenter(b) - s.position).length();
if (dist < bestDist)
{
bestDist = dist;
s.threatResponse->currentTarget = b.id;
}
}
}
if (s.threatResponse->currentTarget)
{
// Move toward target (building or ship).
QVector2D dest;
const Ship* tShip = findShip(*s.threatResponse->currentTarget);
if (tShip)
{
dest = tShip->position;
}
else
{
const Building* tBld = buildings.findBuilding(
*s.threatResponse->currentTarget);
dest = tBld ? buildingCenter(*tBld) : s.position;
}
if (3 > s.intent.priority)
{
s.intent = MovementIntent{3, dest};
}
}
else
{
// No target: move toward asteroid (leftward).
if (3 > s.intent.priority)
{
s.intent = MovementIntent{3, QVector2D(-10000.0f, s.position.y())};
}
}
}
}
}
// ---------------------------------------------------------------------------
// tickRepairBehavior (priority 2)
// ---------------------------------------------------------------------------
void ShipSystem::tickRepairBehavior(BuildingSystem& buildings)
{
const std::vector<Building> allBuildings = buildings.allBuildings();
const float kPatrolRange = 3.0f; // scan radius relative to repair range
for (Ship& s : m_ships)
{
if (!s.repairBehavior || !s.repairTool)
{
continue;
}
const float repairRange = s.repairTool->range;
// Check for nearby enemies; if present, retreat.
bool enemyNearby = false;
for (const Ship& candidate : m_ships)
{
if (candidate.isEnemy
&& (candidate.position - s.position).length() <= repairRange)
{
enemyNearby = true;
break;
}
}
if (enemyNearby)
{
if (2 > s.intent.priority)
{
s.intent = MovementIntent{2, QVector2D(-10000.0f, s.position.y())};
}
continue;
}
// Validate current repair target.
EntityId currentId = s.repairBehavior->currentTarget.value_or(kInvalidEntityId);
bool targetValid = false;
if (currentId != kInvalidEntityId)
{
const Ship* tShip = findShip(currentId);
if (tShip && !tShip->isEnemy && tShip->hp < tShip->maxHp)
{
targetValid = true;
}
else
{
const Building* tBld = buildings.findBuilding(currentId);
if (tBld && tBld->type == BuildingType::PlayerDefenceStation
&& tBld->hp < tBld->maxHp)
{
targetValid = true;
}
}
}
if (!targetValid)
{
s.repairBehavior->currentTarget = std::nullopt;
currentId = kInvalidEntityId;
float bestDist = repairRange * kPatrolRange;
for (const Ship& candidate : m_ships)
{
if (candidate.isEnemy || candidate.id == s.id
|| candidate.hp >= candidate.maxHp)
{
continue;
}
float dist = (candidate.position - s.position).length();
if (dist < bestDist)
{
bestDist = dist;
s.repairBehavior->currentTarget = candidate.id;
}
}
for (const Building& b : allBuildings)
{
if (b.type != BuildingType::PlayerDefenceStation
|| b.hp >= b.maxHp)
{
continue;
}
float dist = (buildingCenter(b) - s.position).length();
if (dist < bestDist)
{
bestDist = dist;
s.repairBehavior->currentTarget = b.id;
}
}
currentId = s.repairBehavior->currentTarget.value_or(kInvalidEntityId);
}
if (currentId == kInvalidEntityId)
{
// No target: patrol rightward.
if (2 > s.intent.priority)
{
s.intent = MovementIntent{2, QVector2D(s.position.x() + 1000.0f,
s.position.y())};
}
continue;
}
// Compute target position and whether we are in repair range.
QVector2D targetPos;
bool isShipTarget = false;
const Ship* tShip = findShip(currentId);
if (tShip)
{
targetPos = tShip->position;
isShipTarget = true;
}
else
{
const Building* tBld = buildings.findBuilding(currentId);
targetPos = tBld ? buildingCenter(*tBld) : s.position;
}
float distToTarget = (targetPos - s.position).length();
if (distToTarget <= repairRange)
{
if (isShipTarget)
{
healShip(currentId, s.repairTool->ratePerTick);
}
else
{
buildings.healBuilding(currentId, s.repairTool->ratePerTick);
}
}
if (2 > s.intent.priority)
{
s.intent = MovementIntent{2, targetPos};
}
}
}
// ---------------------------------------------------------------------------
// tickScrapCollector (priority 1)
// ---------------------------------------------------------------------------
void ShipSystem::tickScrapCollector(ScrapSystem& scraps, const BuildingSystem& buildings)
{
for (Ship& s : m_ships)
{
if (!s.scrapCollector || !s.cargo)
{
continue;
}
const float collectRange = s.cargo->collectionRange;
// Assign delivery bay if not yet set.
if (s.scrapCollector->deliveryBay == kInvalidEntityId)
{
const Building* bay = buildings.findNearestBuilding(s.position,
BuildingType::SalvageBay);
if (bay)
{
s.scrapCollector->deliveryBay = bay->id;
}
}
const EntityId bayId = s.scrapCollector->deliveryBay;
// Compute bay position for movement.
QVector2D bayPos = s.position;
if (bayId != kInvalidEntityId)
{
const Building* bay = buildings.findBuilding(bayId);
if (bay)
{
bayPos = buildingCenter(*bay);
}
}
const bool cargoFull = (s.cargo->current >= s.cargo->capacity);
if (cargoFull)
{
// Return to bay and deliver.
if (1 > s.intent.priority)
{
s.intent = MovementIntent{1, bayPos};
}
if (bayId != kInvalidEntityId
&& (s.position - bayPos).length() <= 1.0f)
{
// Deliver one item per tick.
if (const_cast<BuildingSystem&>(buildings).deliverScrapToSalvageBay(bayId))
{
--s.cargo->current;
}
}
continue;
}
// Retreat from enemies when not carrying cargo.
bool retreating = false;
if (s.cargo->current == 0)
{
for (const Ship& candidate : m_ships)
{
if (candidate.isEnemy
&& (candidate.position - s.position).length() <= collectRange)
{
if (1 > s.intent.priority)
{
s.intent = MovementIntent{1,
QVector2D(-10000.0f, s.position.y())};
}
retreating = true;
break;
}
}
}
if (retreating)
{
continue;
}
// Collect scrap if within range.
for (const Scrap& sc : scraps.allScraps())
{
if ((sc.position - s.position).length() <= collectRange)
{
if (scraps.consume(sc.id))
{
++s.cargo->current;
s.scrapCollector->scrapTarget = std::nullopt;
}
break;
}
}
if (s.scrapCollector->scrapTarget)
{
// Move toward known scrap target.
if (1 > s.intent.priority)
{
s.intent = MovementIntent{1, *s.scrapCollector->scrapTarget};
}
}
else
{
// Scan for nearest scrap within sensor range.
const float sensorRange = collectRange * 5.0f;
float bestDist = sensorRange;
std::optional<QVector2D> bestPos;
for (const Scrap& sc : scraps.allScraps())
{
float dist = (sc.position - s.position).length();
if (dist < bestDist)
{
bestDist = dist;
bestPos = sc.position;
}
}
if (bestPos)
{
s.scrapCollector->scrapTarget = bestPos;
if (1 > s.intent.priority)
{
s.intent = MovementIntent{1, *bestPos};
}
}
else
{
// No scrap in range: patrol rightward.
if (1 > s.intent.priority)
{
s.intent = MovementIntent{1, QVector2D(s.position.x() + 1000.0f,
s.position.y())};
}
}
}
}
}
// ---------------------------------------------------------------------------
// tickMovement (tick-order step 10)
// ---------------------------------------------------------------------------
void ShipSystem::tickMovement()
{
for (Ship& s : m_ships)
{
if (s.intent.priority == 0)
{
s.velocity = QVector2D(0.0f, 0.0f);
continue;
}
QVector2D delta = s.intent.target - s.position;
float dist = delta.length();
if (dist <= s.speedPerTick)
{
s.position = s.intent.target;
s.velocity = QVector2D(0.0f, 0.0f);
}
else
{
s.velocity = delta.normalized() * s.speedPerTick;
s.position += s.velocity;
}
}
}

69
src/lib/sim/ShipSystem.h Normal file
View File

@@ -0,0 +1,69 @@
#pragma once
#include <functional>
#include <vector>
#include <QVector2D>
#include "EntityId.h"
#include "GameConfig.h"
#include "Ship.h"
class BuildingSystem;
class ScrapSystem;
class ShipSystem
{
public:
ShipSystem(const GameConfig& config,
std::function<EntityId()> allocateId);
// isEnemy defaults to false; set true for enemy-faction ships (step 7 wave spawning).
EntityId spawn(const std::string& blueprintId, int level, QVector2D position,
bool isEnemy = false);
void despawn(EntityId id);
const Ship* findShip(EntityId id) const;
std::vector<Ship> allShips() const;
void forEach(std::function<void(Ship&)> fn);
// -- Behavior tick methods (tick-order step 7) ---------------------------
// Reset all movement intents to priority 0 before behavior systems run.
void clearMovementIntents();
// Priority 4: low-HP ships retreat to homePos.
void tickHomeReturn();
// Priority 3: combat ships acquire targets and advance toward them.
void tickThreatResponse(const BuildingSystem& buildings);
// Priority 2: repair ships find and heal damaged friendly ships/stations.
void tickRepairBehavior(BuildingSystem& buildings);
// Priority 1: salvage ships collect scrap and deliver it.
void tickScrapCollector(ScrapSystem& scraps, const BuildingSystem& buildings);
// -- Movement (tick-order step 10) ---------------------------------------
void tickMovement();
// Reduce ship HP by amount. Does not remove the ship; step 9 handles death.
// Returns false if ship not found.
bool damageShip(EntityId id, float amount);
private:
const ShipDef* findShipDef(const std::string& blueprintId) const;
// True if the entity identified by id is alive and within range of ship.
// Searches both the ship list and (for buildings) the supplied BuildingSystem.
bool isTargetValid(EntityId id, float range, const Ship& ship,
const BuildingSystem& buildings) const;
// Heal the ship with the given id by amount, clamped to maxHp.
// Returns false if the ship is not found.
bool healShip(EntityId id, float amount);
const GameConfig& m_config;
std::function<EntityId()> m_allocateId;
std::vector<Ship> m_ships;
};

View File

@@ -1,6 +1,13 @@
#include "Simulation.h" #include "Simulation.h"
#include <cassert>
#include "BuildingSystem.h" #include "BuildingSystem.h"
#include "CombatSystem.h"
#include "ScrapSystem.h"
#include "ShipSystem.h"
#include "SurfaceMask.h"
#include "WaveSystem.h"
Simulation::Simulation(const GameConfig& config, unsigned int seed) Simulation::Simulation(const GameConfig& config, unsigned int seed)
: m_config(config) : m_config(config)
@@ -8,29 +15,320 @@ Simulation::Simulation(const GameConfig& config, unsigned int seed)
, m_currentTick(0) , m_currentTick(0)
, m_nextId(1) , m_nextId(1)
, m_buildingBlocksStock(config.world.startingBuildingBlocks) , m_buildingBlocksStock(config.world.startingBuildingBlocks)
, m_gameOver(false)
, m_hqId(kInvalidEntityId)
, m_playerStation1Id(kInvalidEntityId)
, m_playerStation2Id(kInvalidEntityId)
, m_beltSystem(config.world.beltSpeedTilesPerSecond) , m_beltSystem(config.world.beltSpeedTilesPerSecond)
{ {
m_currentEnemyStationIds[0] = kInvalidEntityId;
m_currentEnemyStationIds[1] = kInvalidEntityId;
m_buildingSystem = std::make_unique<BuildingSystem>( m_buildingSystem = std::make_unique<BuildingSystem>(
config, config,
m_beltSystem, m_beltSystem,
[this]() { return allocateId(); }, [this]() { return allocateId(); },
[this](int amount) { m_buildingBlocksStock += amount; }, [this](int amount) { m_buildingBlocksStock += amount; },
m_rng); m_rng);
m_shipSystem = std::make_unique<ShipSystem>(config, [this]() { return allocateId(); });
m_scrapSystem = std::make_unique<ScrapSystem>([this]() { return allocateId(); });
m_waveSystem = std::make_unique<WaveSystem>(config, m_rng);
m_combatSystem = std::make_unique<CombatSystem>(config);
// Initialize blueprint unlock state.
for (const ShipDef& def : config.ships.ships)
{
BlueprintState state;
state.unlocked = def.availableFromStart;
state.level = def.availableFromStart ? def.blueprint.playerProductionLevel : 0;
m_blueprintLevels[def.id] = state;
}
placeInitialStructures();
} }
Simulation::~Simulation() = default; Simulation::~Simulation() = default;
// ---------------------------------------------------------------------------
// tick
// ---------------------------------------------------------------------------
void Simulation::tick() void Simulation::tick()
{ {
// Step 1: wave scheduler
m_waveSystem->tickWaveScheduler(m_currentTick, *m_shipSystem,
m_config.world.heightTiles);
// Step 2: threat accumulation
m_waveSystem->tickThreatAccumulation(m_currentTick);
// Construction + production pipeline
m_buildingSystem->tickConstruction(m_currentTick); m_buildingSystem->tickConstruction(m_currentTick);
m_buildingSystem->tickBeltPull(); // tick order step 3 m_buildingSystem->tickBeltPull(); // step 3
m_buildingSystem->tickProduction(m_currentTick); // step 4 m_buildingSystem->tickProduction(m_currentTick); // step 4
m_buildingSystem->tickBeltPush(); // step 5 m_buildingSystem->tickBeltPush(); // step 5
m_beltSystem.tick(); // step 6 m_beltSystem.tick(); // step 6
// Step 7: ship behavior systems (movement arbitration via intent priority)
m_shipSystem->clearMovementIntents();
m_shipSystem->tickHomeReturn(); // priority 4
m_shipSystem->tickThreatResponse(*m_buildingSystem); // priority 3
m_shipSystem->tickRepairBehavior(*m_buildingSystem); // priority 2
m_shipSystem->tickScrapCollector(*m_scrapSystem, *m_buildingSystem); // priority 1
// Step 8: combat resolution
m_combatSystem->tick(m_currentTick, *m_shipSystem,
*m_buildingSystem, m_fireEvents);
// Step 9: deaths & loot
if (!m_gameOver)
{
tickDeathsAndLoot();
}
// Step 10: advance ship positions
m_shipSystem->tickMovement();
// Step 11: scrap despawn
m_scrapSystem->tickDespawn(m_currentTick);
++m_currentTick; ++m_currentTick;
} }
// ---------------------------------------------------------------------------
// Pre-placement
// ---------------------------------------------------------------------------
void Simulation::placeInitialStructures()
{
// HQ — right edge of asteroid (rightmost asteroid tile is x = -1).
const ParsedSurfaceMask hqParsed =
parseSurfaceMask(m_config.stations.hq.surfaceMask, Rotation::East);
const int hqAnchorX = -hqParsed.footprint.width();
const int hqAnchorY =
(m_config.world.heightTiles - hqParsed.footprint.height()) / 2;
const float hqHp =
static_cast<float>(m_config.stations.hq.hpFormula.evaluate(0.0));
m_hqId = m_buildingSystem->placeImmediate(
BuildingType::Hq,
m_config.stations.hq.surfaceMask,
QPoint(hqAnchorX, hqAnchorY),
Rotation::East, hqHp, hqHp);
// Player defence stations — right edge of player buffer zone.
const ParsedSurfaceMask psParsed =
parseSurfaceMask(m_config.stations.playerStation.surfaceMask, Rotation::East);
const int psAnchorX =
m_config.world.regions.playerBufferWidth - psParsed.footprint.width();
const double psLevel = static_cast<double>(m_config.stations.playerStation.level);
const float psHp = static_cast<float>(
m_config.stations.playerStation.hpFormula.evaluate(psLevel));
StationWeapon psWeapon;
psWeapon.damage = static_cast<float>(
m_config.stations.playerStation.damageFormula.evaluate(psLevel));
psWeapon.range = static_cast<float>(
m_config.stations.playerStation.rangeFormula.evaluate(psLevel));
psWeapon.fireRateHz = static_cast<float>(
m_config.stations.playerStation.fireRateFormula.evaluate(psLevel));
psWeapon.cooldownTicks = 0.0f;
const int ps1Y = m_config.world.heightTiles / 4;
const int ps2Y = 3 * m_config.world.heightTiles / 4;
m_playerStation1Id = m_buildingSystem->placeImmediate(
BuildingType::PlayerDefenceStation,
m_config.stations.playerStation.surfaceMask,
QPoint(psAnchorX, ps1Y), Rotation::East, psHp, psHp);
m_buildingSystem->initStationWeapon(m_playerStation1Id, psWeapon);
m_playerStation2Id = m_buildingSystem->placeImmediate(
BuildingType::PlayerDefenceStation,
m_config.stations.playerStation.surfaceMask,
QPoint(psAnchorX, ps2Y), Rotation::East, psHp, psHp);
m_buildingSystem->initStationWeapon(m_playerStation2Id, psWeapon);
// Enemy defence stations — generation 0 (initial set).
placeEnemyStationSet(0);
}
void Simulation::placeEnemyStationSet(int generation)
{
const ParsedSurfaceMask esParsed =
parseSurfaceMask(m_config.stations.enemyStation.surfaceMask, Rotation::East);
// Right edge of contest zone, shifted right by (generation * pushExpandColumns).
const int rightEdgeX = m_config.world.regions.playerBufferWidth
+ m_config.world.regions.contestZoneWidth
+ generation * m_config.world.push.pushExpandColumns;
const int anchorX = rightEdgeX - esParsed.footprint.width();
const double genD = static_cast<double>(generation);
const float esHp = static_cast<float>(
m_config.stations.enemyStation.hpFormula.evaluate(genD));
StationWeapon esWeapon;
esWeapon.damage = static_cast<float>(
m_config.stations.enemyStation.damageFormula.evaluate(genD));
esWeapon.range = static_cast<float>(
m_config.stations.enemyStation.rangeFormula.evaluate(genD));
esWeapon.fireRateHz = static_cast<float>(
m_config.stations.enemyStation.fireRateFormula.evaluate(genD));
esWeapon.cooldownTicks = 0.0f;
const int y1 = m_config.world.heightTiles / 4;
const int y2 = 3 * m_config.world.heightTiles / 4;
const EntityId id1 = m_buildingSystem->placeImmediate(
BuildingType::EnemyDefenceStation,
m_config.stations.enemyStation.surfaceMask,
QPoint(anchorX, y1), Rotation::East, esHp, esHp);
m_buildingSystem->initStationWeapon(id1, esWeapon);
const EntityId id2 = m_buildingSystem->placeImmediate(
BuildingType::EnemyDefenceStation,
m_config.stations.enemyStation.surfaceMask,
QPoint(anchorX, y2), Rotation::East, esHp, esHp);
m_buildingSystem->initStationWeapon(id2, esWeapon);
m_currentEnemyStationIds[0] = id1;
m_currentEnemyStationIds[1] = id2;
}
// ---------------------------------------------------------------------------
// Deaths & loot (tick step 9)
// ---------------------------------------------------------------------------
void Simulation::tickDeathsAndLoot()
{
// --- Dead ships ---
std::vector<EntityId> deadShipIds;
m_shipSystem->forEach([&deadShipIds](Ship& s)
{
if (s.hp <= 0.0f)
{
deadShipIds.push_back(s.id);
}
});
for (EntityId deadId : deadShipIds)
{
const Ship* s = m_shipSystem->findShip(deadId);
if (!s)
{
continue;
}
// Look up scrap drop amount from config.
for (const ShipDef& def : m_config.ships.ships)
{
if (def.id == s->blueprintId && def.loot.scrapDrop > 0)
{
const Tick despawnAt = m_currentTick
+ secondsToTicks(m_config.world.scrapDespawnSeconds);
m_scrapSystem->spawn(s->position, def.loot.scrapDrop, despawnAt);
break;
}
}
m_shipSystem->despawn(deadId);
}
// --- Dead buildings (HQ, player/enemy defence stations) ---
std::vector<EntityId> deadBuildingIds;
for (const Building& b : m_buildingSystem->allBuildings())
{
if (b.hp <= 0.0f &&
(b.type == BuildingType::Hq ||
b.type == BuildingType::PlayerDefenceStation ||
b.type == BuildingType::EnemyDefenceStation))
{
deadBuildingIds.push_back(b.id);
}
}
for (EntityId deadId : deadBuildingIds)
{
const Building* b = m_buildingSystem->findBuilding(deadId);
if (!b)
{
continue;
}
if (b->type == BuildingType::Hq)
{
m_gameOver = true;
}
else
{
const QVector2D center(
b->anchor.x() + b->footprint.width() / 2.0f,
b->anchor.y() + b->footprint.height() / 2.0f);
const Tick despawnAt = m_currentTick
+ secondsToTicks(m_config.world.scrapDespawnSeconds);
int scrap = 0;
if (b->type == BuildingType::PlayerDefenceStation)
{
const double lv = static_cast<double>(
m_config.stations.playerStation.level);
scrap = static_cast<int>(
m_config.stations.playerStation.scrapDropFormula.evaluate(lv));
}
else if (b->type == BuildingType::EnemyDefenceStation)
{
const double genD = static_cast<double>(m_waveSystem->generation());
scrap = static_cast<int>(
m_config.stations.enemyStation.scrapDropFormula.evaluate(genD));
}
if (scrap > 0)
{
m_scrapSystem->spawn(center, scrap, despawnAt);
}
}
m_buildingSystem->removeBuilding(deadId);
}
// --- Push check: if both current enemy stations are gone, trigger push ---
const bool es0Gone =
(m_buildingSystem->findBuilding(m_currentEnemyStationIds[0]) == nullptr);
const bool es1Gone =
(m_buildingSystem->findBuilding(m_currentEnemyStationIds[1]) == nullptr);
if (es0Gone && es1Gone &&
m_currentEnemyStationIds[0] != kInvalidEntityId)
{
m_waveSystem->applyPush();
placeEnemyStationSet(m_waveSystem->generation());
awardBlueprintDrop();
}
}
void Simulation::awardBlueprintDrop()
{
std::vector<std::string> ids;
ids.reserve(m_config.ships.ships.size());
for (const ShipDef& def : m_config.ships.ships)
{
ids.push_back(def.id);
}
std::uniform_int_distribution<int> dist(0, static_cast<int>(ids.size()) - 1);
const std::string chosen = ids[static_cast<std::size_t>(dist(m_rng))];
BlueprintState& state = m_blueprintLevels.at(chosen);
const bool wasNew = !state.unlocked;
state.unlocked = true;
state.level += 1;
BlueprintDropEvent evt;
evt.blueprintId = chosen;
evt.newLevel = state.level;
evt.wasNewUnlock = wasNew;
m_blueprintDropEvents.push_back(evt);
}
// ---------------------------------------------------------------------------
// Drains
// ---------------------------------------------------------------------------
std::vector<FireEvent> Simulation::drainFireEvents() std::vector<FireEvent> Simulation::drainFireEvents()
{ {
std::vector<FireEvent> result; std::vector<FireEvent> result;
@@ -45,6 +343,10 @@ std::vector<BlueprintDropEvent> Simulation::drainBlueprintDropEvents()
return result; return result;
} }
// ---------------------------------------------------------------------------
// Accessors
// ---------------------------------------------------------------------------
Tick Simulation::currentTick() const Tick Simulation::currentTick() const
{ {
return m_currentTick; return m_currentTick;
@@ -55,6 +357,57 @@ int Simulation::buildingBlocksStock() const
return m_buildingBlocksStock; return m_buildingBlocksStock;
} }
bool Simulation::isGameOver() const
{
return m_gameOver;
}
double Simulation::threatLevel() const
{
return m_waveSystem->threatLevel();
}
int Simulation::blueprintLevel(const std::string& shipId) const
{
const std::map<std::string, BlueprintState>::const_iterator it =
m_blueprintLevels.find(shipId);
if (it == m_blueprintLevels.end())
{
return 0;
}
return it->second.level;
}
bool Simulation::isBlueprintUnlocked(const std::string& shipId) const
{
const std::map<std::string, BlueprintState>::const_iterator it =
m_blueprintLevels.find(shipId);
if (it == m_blueprintLevels.end())
{
return false;
}
return it->second.unlocked;
}
EntityId Simulation::tryPlaceBuilding(BuildingType type, QPoint anchor, Rotation rotation)
{
int cost = 0;
for (const BuildingDef& def : m_config.buildings.buildings)
{
if (def.type == type)
{
cost = def.cost;
break;
}
}
if (m_buildingBlocksStock < cost)
{
return kInvalidEntityId;
}
m_buildingBlocksStock -= cost;
return m_buildingSystem->place(type, anchor, rotation, m_currentTick);
}
BuildingSystem& Simulation::buildings() BuildingSystem& Simulation::buildings()
{ {
return *m_buildingSystem; return *m_buildingSystem;
@@ -75,6 +428,26 @@ const BeltSystem& Simulation::belts() const
return m_beltSystem; return m_beltSystem;
} }
ShipSystem& Simulation::ships()
{
return *m_shipSystem;
}
const ShipSystem& Simulation::ships() const
{
return *m_shipSystem;
}
ScrapSystem& Simulation::scraps()
{
return *m_scrapSystem;
}
const ScrapSystem& Simulation::scraps() const
{
return *m_scrapSystem;
}
EntityId Simulation::allocateId() EntityId Simulation::allocateId()
{ {
return m_nextId++; return m_nextId++;

View File

@@ -1,17 +1,27 @@
#pragma once #pragma once
#include <map>
#include <memory> #include <memory>
#include <random> #include <random>
#include <string>
#include <vector> #include <vector>
#include <QPoint>
#include "BeltSystem.h" #include "BeltSystem.h"
#include "BlueprintDropEvent.h" #include "BlueprintDropEvent.h"
#include "BuildingType.h"
#include "EntityId.h" #include "EntityId.h"
#include "FireEvent.h" #include "FireEvent.h"
#include "GameConfig.h" #include "GameConfig.h"
#include "Rotation.h"
#include "Tick.h" #include "Tick.h"
class BuildingSystem; class BuildingSystem;
class CombatSystem;
class ShipSystem;
class ScrapSystem;
class WaveSystem;
class Simulation class Simulation
{ {
@@ -29,26 +39,72 @@ public:
// Returns all blueprint drop events since the last drain. // Returns all blueprint drop events since the last drain.
std::vector<BlueprintDropEvent> drainBlueprintDropEvents(); std::vector<BlueprintDropEvent> drainBlueprintDropEvents();
Tick currentTick() const; Tick currentTick() const;
int buildingBlocksStock() const; int buildingBlocksStock() const;
bool isGameOver() const;
double threatLevel() const;
// Blueprint state queries.
int blueprintLevel(const std::string& shipId) const;
bool isBlueprintUnlocked(const std::string& shipId) const;
// Checks affordability, deducts building blocks, and places the building.
// Returns the new entity id, or kInvalidEntityId if blocks are insufficient.
EntityId tryPlaceBuilding(BuildingType type, QPoint anchor, Rotation rotation);
BuildingSystem& buildings(); BuildingSystem& buildings();
const BuildingSystem& buildings() const; const BuildingSystem& buildings() const;
BeltSystem& belts(); BeltSystem& belts();
const BeltSystem& belts() const; const BeltSystem& belts() const;
ShipSystem& ships();
const ShipSystem& ships() const;
ScrapSystem& scraps();
const ScrapSystem& scraps() const;
private: private:
EntityId allocateId(); // Strictly increasing; never returns kInvalidEntityId. EntityId allocateId(); // Strictly increasing; never returns kInvalidEntityId.
// Populate HQ, player defence stations, and the first enemy station set.
void placeInitialStructures();
// Place two enemy defence stations for the given generation level.
// Stores their IDs in m_currentEnemyStationIds.
void placeEnemyStationSet(int generation);
// Tick step 9: remove dead ships and buildings, drop scrap, handle push.
void tickDeathsAndLoot();
// Award a random blueprint drop (REQ-DEF-BLUEPRINT-DROP) and emit the event.
void awardBlueprintDrop();
const GameConfig& m_config; const GameConfig& m_config;
std::mt19937 m_rng; std::mt19937 m_rng;
Tick m_currentTick; Tick m_currentTick;
EntityId m_nextId; EntityId m_nextId;
int m_buildingBlocksStock; int m_buildingBlocksStock;
bool m_gameOver = false;
// Pre-placed structure IDs.
EntityId m_hqId;
EntityId m_playerStation1Id;
EntityId m_playerStation2Id;
EntityId m_currentEnemyStationIds[2];
// Blueprint unlock state (REQ-DEF-BLUEPRINT-DROP).
struct BlueprintState
{
bool unlocked;
int level;
};
std::map<std::string, BlueprintState> m_blueprintLevels;
BeltSystem m_beltSystem; BeltSystem m_beltSystem;
std::unique_ptr<BuildingSystem> m_buildingSystem; std::unique_ptr<BuildingSystem> m_buildingSystem;
std::unique_ptr<ShipSystem> m_shipSystem;
std::unique_ptr<ScrapSystem> m_scrapSystem;
std::unique_ptr<WaveSystem> m_waveSystem;
std::unique_ptr<CombatSystem> m_combatSystem;
std::vector<FireEvent> m_fireEvents; std::vector<FireEvent> m_fireEvents;
std::vector<BlueprintDropEvent> m_blueprintDropEvents; std::vector<BlueprintDropEvent> m_blueprintDropEvents;

191
src/lib/sim/WaveSystem.cpp Normal file
View File

@@ -0,0 +1,191 @@
#include "WaveSystem.h"
#include <algorithm>
#include "ShipSystem.h"
WaveSystem::WaveSystem(const GameConfig& config, std::mt19937& rng)
: m_config(config)
, m_rng(rng)
{
// Draw the initial inter-wave gap (REQ-WAV-GAP, REQ-WAV-GRACE-PERIOD).
m_nextEventTick = drawGapTicks();
}
void WaveSystem::tickWaveScheduler(Tick currentTick, ShipSystem& ships,
int worldHeightTiles)
{
if (!m_waveActive)
{
if (currentTick < m_nextEventTick)
{
return;
}
// Gap expired: compose the next wave.
m_pendingSpawns = composeWave(currentTick, worldHeightTiles);
m_waveActive = true;
}
// Spawn any ships whose scheduled tick has arrived.
std::vector<SpawnEntry> remaining;
remaining.reserve(m_pendingSpawns.size());
for (const SpawnEntry& entry : m_pendingSpawns)
{
if (currentTick >= entry.spawnAt)
{
ships.spawn(entry.blueprintId, entry.level, entry.position,
/*isEnemy=*/true);
}
else
{
remaining.push_back(entry);
}
}
m_pendingSpawns = std::move(remaining);
if (m_pendingSpawns.empty())
{
m_waveActive = false;
m_nextEventTick = currentTick + drawGapTicks();
}
}
void WaveSystem::tickThreatAccumulation(Tick currentTick)
{
const double elapsedSeconds = static_cast<double>(currentTick) * kTickDurationSeconds;
const double rate = m_config.world.waves.threatRateFormula.evaluate(elapsedSeconds);
if (rate > 0.0)
{
m_threatLevel += rate * m_pushScalingMultiplier * kTickDurationSeconds;
}
}
void WaveSystem::applyPush()
{
m_pushScalingMultiplier *= m_config.world.push.scalingFactor;
++m_generation;
}
double WaveSystem::threatLevel() const
{
return m_threatLevel;
}
int WaveSystem::generation() const
{
return m_generation;
}
// ---------------------------------------------------------------------------
// Private helpers
// ---------------------------------------------------------------------------
std::vector<WaveSystem::SpawnEntry> WaveSystem::composeWave(Tick currentTick,
int worldHeightTiles)
{
const double elapsedSeconds = static_cast<double>(currentTick) * kTickDurationSeconds;
const int shipLevel = std::max(1, static_cast<int>(
m_config.world.waves.shipLevelFormula.evaluate(elapsedSeconds)));
// Build eligible ship list with their costs at the current level.
struct EligibleShip
{
std::string blueprintId;
double cost;
};
std::vector<EligibleShip> eligible;
for (const ShipDef& def : m_config.ships.ships)
{
const double cost = def.threat.costFormula.evaluate(static_cast<double>(shipLevel));
if (cost > 0.0)
{
EligibleShip es;
es.blueprintId = def.id;
es.cost = cost;
eligible.push_back(es);
}
}
if (eligible.empty())
{
return {};
}
// Take the current threat level as the wave budget and reset it.
// Unspent budget is re-added after composition (carry-over).
double budget = m_threatLevel;
m_threatLevel = 0.0;
// Enemy spawn buffer X range for the current generation.
const float leftX = static_cast<float>(
m_config.world.regions.playerBufferWidth
+ m_config.world.regions.contestZoneWidth
+ m_generation * m_config.world.push.pushExpandColumns);
const float rightX = leftX + static_cast<float>(m_config.world.regions.enemyBufferWidth) - 1.0f;
std::uniform_real_distribution<float> xDist(leftX, rightX);
std::uniform_int_distribution<int> yDist(0, worldHeightTiles - 1);
std::vector<SpawnEntry> picked;
while (true)
{
// Collect indices of ships whose cost fits the remaining budget.
std::vector<std::size_t> fitting;
for (std::size_t i = 0; i < eligible.size(); ++i)
{
if (eligible[i].cost <= budget)
{
fitting.push_back(i);
}
}
if (fitting.empty())
{
break;
}
std::uniform_int_distribution<int> pick(0, static_cast<int>(fitting.size()) - 1);
const std::size_t chosenIdx = fitting[static_cast<std::size_t>(pick(m_rng))];
const EligibleShip& chosen = eligible[chosenIdx];
budget -= chosen.cost;
SpawnEntry entry;
entry.blueprintId = chosen.blueprintId;
entry.level = shipLevel;
entry.spawnAt = 0; // set below after all picks are done
entry.position = QVector2D(xDist(m_rng),
static_cast<float>(yDist(m_rng)) + 0.5f);
picked.push_back(entry);
}
// Carry leftover budget forward to the next wave.
m_threatLevel += budget;
// Spread spawn times evenly across spawnDurationSeconds.
const int count = static_cast<int>(picked.size());
if (count == 1)
{
picked[0].spawnAt = currentTick;
}
else if (count > 1)
{
const Tick spawnDurationTicks =
secondsToTicks(m_config.world.waves.spawnDurationSeconds);
for (int i = 0; i < count; ++i)
{
picked[static_cast<std::size_t>(i)].spawnAt =
currentTick + static_cast<Tick>(i) * spawnDurationTicks / (count - 1);
}
}
return picked;
}
Tick WaveSystem::drawGapTicks()
{
const Tick minTicks = secondsToTicks(m_config.world.waves.gapMinSeconds);
const Tick maxTicks = secondsToTicks(m_config.world.waves.gapMaxSeconds);
std::uniform_int_distribution<Tick> dist(minTicks, maxTicks);
return dist(m_rng);
}

67
src/lib/sim/WaveSystem.h Normal file
View File

@@ -0,0 +1,67 @@
#pragma once
#include <random>
#include <string>
#include <vector>
#include <QVector2D>
#include "GameConfig.h"
#include "Tick.h"
class ShipSystem;
// Manages wave scheduling (tick-order step 1) and threat-level accumulation
// (tick-order step 2). REQ-WAV-*.
class WaveSystem
{
public:
WaveSystem(const GameConfig& config, std::mt19937& rng);
// Tick step 1: start a new wave when the current gap has expired; spawn
// any ships in the pending list whose scheduled tick has arrived.
void tickWaveScheduler(Tick currentTick, ShipSystem& ships,
int worldHeightTiles);
// Tick step 2: accumulate threat from the rate formula, scaled by the
// current push multiplier (REQ-WAV-THREAT-RATE, REQ-PSH-ACCUMULATION).
void tickThreatAccumulation(Tick currentTick);
// Called by Simulation (tick step 9) when the current enemy-station set
// is fully destroyed: multiplies the push scaling and increments generation.
void applyPush();
double threatLevel() const;
// Current enemy-station generation level (0 for initial set,
// incremented by 1 after each push — REQ-PSH-STATION-STATS).
int generation() const;
private:
struct SpawnEntry
{
std::string blueprintId;
int level;
Tick spawnAt;
QVector2D position;
};
// Compose the next wave from the current threat budget, returning timed
// spawn entries spread across spawnDurationSeconds. Leaves any unspent
// budget in m_threatLevel (carry-over, REQ-WAV-TRIGGER).
std::vector<SpawnEntry> composeWave(Tick currentTick, int worldHeightTiles);
// Draw a random gap duration in ticks from [gapMin, gapMax].
Tick drawGapTicks();
const GameConfig& m_config;
std::mt19937& m_rng;
double m_threatLevel = 0.0;
double m_pushScalingMultiplier = 1.0;
int m_generation = 0;
bool m_waveActive = false;
Tick m_nextEventTick = 0; // absolute tick when the current gap expires
std::vector<SpawnEntry> m_pendingSpawns;
};

View File

@@ -0,0 +1,440 @@
#include "catch.hpp"
#include <random>
#include <QVector2D>
#include "BeltSystem.h"
#include "Building.h"
#include "BuildingSystem.h"
#include "BuildingType.h"
#include "ConfigLoader.h"
#include "Rotation.h"
#include "Scrap.h"
#include "ScrapSystem.h"
#include "Ship.h"
#include "ShipSystem.h"
#include "Tick.h"
// ---------------------------------------------------------------------------
// Fixture
// ---------------------------------------------------------------------------
static GameConfig loadConfig()
{
return ConfigLoader::loadFromDirectory(DOTA_FACTORY_CONFIG_DIR);
}
struct Fixture
{
GameConfig cfg;
BeltSystem belts;
EntityId nextId;
int stock;
std::mt19937 rng;
BuildingSystem buildings;
ShipSystem ships;
ScrapSystem scraps;
Tick tick;
explicit Fixture()
: cfg(loadConfig())
, belts(cfg.world.beltSpeedTilesPerSecond)
, nextId(1)
, stock(0)
, rng(42)
, buildings(cfg, belts,
[this]() { return nextId++; },
[this](int n) { stock += n; },
rng)
, ships(cfg, [this]() { return nextId++; })
, scraps([this]() { return nextId++; })
, tick(0)
{
}
// Run one full behavior+movement tick (steps 7 and 10).
void runBehaviorTick()
{
ships.clearMovementIntents();
ships.tickHomeReturn();
ships.tickThreatResponse(buildings);
ships.tickRepairBehavior(buildings);
ships.tickScrapCollector(scraps, buildings);
ships.tickMovement();
++tick;
}
};
// ---------------------------------------------------------------------------
// clearMovementIntents
// ---------------------------------------------------------------------------
TEST_CASE("BehaviorSystem: clearMovementIntents resets all ships to priority 0",
"[behavior]")
{
Fixture f;
const EntityId id = f.ships.spawn("interceptor", 1, QVector2D(0.0f, 0.0f));
// Manually write a non-zero intent.
f.ships.forEach([](Ship& s) {
s.intent = MovementIntent{3, QVector2D(10.0f, 0.0f)};
});
f.ships.clearMovementIntents();
const Ship* s = f.ships.findShip(id);
REQUIRE(s != nullptr);
REQUIRE(s->intent.priority == 0);
}
// ---------------------------------------------------------------------------
// tickMovement
// ---------------------------------------------------------------------------
TEST_CASE("BehaviorSystem: tickMovement advances ship by speedPerTick toward target",
"[behavior]")
{
Fixture f;
const EntityId id = f.ships.spawn("interceptor", 1, QVector2D(0.0f, 0.0f));
const float speed = f.ships.findShip(id)->speedPerTick;
const QVector2D target(100.0f, 0.0f);
f.ships.forEach([&target](Ship& s) {
s.intent = MovementIntent{1, target};
});
f.ships.tickMovement();
const Ship* s = f.ships.findShip(id);
REQUIRE(s->position.x() == Approx(speed));
REQUIRE(s->position.y() == Approx(0.0f));
}
TEST_CASE("BehaviorSystem: tickMovement stops exactly at target without overshoot",
"[behavior]")
{
Fixture f;
const EntityId id = f.ships.spawn("interceptor", 1, QVector2D(0.0f, 0.0f));
// Place target closer than one tick's travel.
const float speed = f.ships.findShip(id)->speedPerTick;
const QVector2D target(speed * 0.5f, 0.0f);
f.ships.forEach([&target](Ship& s) {
s.intent = MovementIntent{1, target};
});
f.ships.tickMovement();
const Ship* s = f.ships.findShip(id);
REQUIRE(s->position.x() == Approx(target.x()));
REQUIRE(s->position.y() == Approx(target.y()));
}
// ---------------------------------------------------------------------------
// tickHomeReturn
// ---------------------------------------------------------------------------
TEST_CASE("BehaviorSystem: tickHomeReturn does nothing when HP is above threshold",
"[behavior]")
{
Fixture f;
const EntityId id = f.ships.spawn("interceptor", 1, QVector2D(0.0f, 0.0f));
f.ships.forEach([](Ship& s) {
s.homeReturn = HomeReturn{0.3f, QVector2D(-10.0f, 0.0f)};
s.hp = s.maxHp; // full HP — above threshold
});
f.ships.clearMovementIntents();
f.ships.tickHomeReturn();
REQUIRE(f.ships.findShip(id)->intent.priority == 0);
}
TEST_CASE("BehaviorSystem: tickHomeReturn writes priority-4 intent toward homePos when HP is low",
"[behavior]")
{
Fixture f;
const EntityId id = f.ships.spawn("interceptor", 1, QVector2D(0.0f, 0.0f));
const QVector2D homePos(-10.0f, 0.0f);
f.ships.forEach([&homePos](Ship& s) {
s.homeReturn = HomeReturn{0.5f, homePos};
s.hp = s.maxHp * 0.2f; // below 50% threshold
});
f.ships.clearMovementIntents();
f.ships.tickHomeReturn();
const Ship* s = f.ships.findShip(id);
REQUIRE(s->intent.priority == 4);
REQUIRE(s->intent.target.x() == Approx(homePos.x()));
}
TEST_CASE("BehaviorSystem: tickHomeReturn priority-4 beats tickThreatResponse priority-3",
"[behavior]")
{
Fixture f;
// Player ship with both homeReturn (low HP) and an enemy in range.
const EntityId playerId = f.ships.spawn("interceptor", 1, QVector2D(0.0f, 0.0f));
f.ships.spawn("interceptor", 1, QVector2D(5.0f, 0.0f), /*isEnemy=*/true);
const QVector2D homePos(-50.0f, 0.0f);
f.ships.forEach([&homePos, playerId](Ship& s) {
if (s.id == playerId)
{
s.homeReturn = HomeReturn{0.5f, homePos};
s.hp = s.maxHp * 0.1f;
}
});
f.ships.clearMovementIntents();
f.ships.tickHomeReturn();
f.ships.tickThreatResponse(f.buildings);
const Ship* s = f.ships.findShip(playerId);
REQUIRE(s->intent.priority == 4);
REQUIRE(s->intent.target.x() == Approx(homePos.x()));
}
// ---------------------------------------------------------------------------
// tickThreatResponse — player ships
// ---------------------------------------------------------------------------
TEST_CASE("BehaviorSystem: player combat ship acquires nearest enemy ship in range",
"[behavior]")
{
Fixture f;
const EntityId playerId = f.ships.spawn("interceptor", 1, QVector2D(0.0f, 0.0f));
// Spawn enemy within attack range (150 tile units).
const EntityId enemyId = f.ships.spawn("interceptor", 1, QVector2D(10.0f, 0.0f),
/*isEnemy=*/true);
f.ships.clearMovementIntents();
f.ships.tickThreatResponse(f.buildings);
const Ship* player = f.ships.findShip(playerId);
REQUIRE(player->threatResponse.has_value());
REQUIRE(player->threatResponse->currentTarget.has_value());
REQUIRE(*player->threatResponse->currentTarget == enemyId);
}
TEST_CASE("BehaviorSystem: player combat ship does not target friendly ships",
"[behavior]")
{
Fixture f;
const EntityId id1 = f.ships.spawn("interceptor", 1, QVector2D(0.0f, 0.0f));
f.ships.spawn("interceptor", 1, QVector2D(5.0f, 0.0f)); // also player (isEnemy=false)
f.ships.clearMovementIntents();
f.ships.tickThreatResponse(f.buildings);
const Ship* s = f.ships.findShip(id1);
REQUIRE(s->threatResponse.has_value());
REQUIRE_FALSE(s->threatResponse->currentTarget.has_value());
}
TEST_CASE("BehaviorSystem: player combat ship ignores enemy beyond engagement range",
"[behavior]")
{
Fixture f;
const EntityId playerId = f.ships.spawn("interceptor", 1, QVector2D(0.0f, 0.0f));
// Place enemy far beyond engagement range (150 tile units).
f.ships.spawn("interceptor", 1, QVector2D(500.0f, 0.0f), /*isEnemy=*/true);
f.ships.clearMovementIntents();
f.ships.tickThreatResponse(f.buildings);
const Ship* s = f.ships.findShip(playerId);
REQUIRE_FALSE(s->threatResponse->currentTarget.has_value());
}
// ---------------------------------------------------------------------------
// tickThreatResponse — enemy ships
// ---------------------------------------------------------------------------
TEST_CASE("BehaviorSystem: enemy ship acquires nearest player ship in range",
"[behavior]")
{
Fixture f;
const EntityId playerId = f.ships.spawn("interceptor", 1, QVector2D(0.0f, 0.0f));
const EntityId enemyId = f.ships.spawn("interceptor", 1, QVector2D(10.0f, 0.0f),
/*isEnemy=*/true);
f.ships.clearMovementIntents();
f.ships.tickThreatResponse(f.buildings);
const Ship* enemy = f.ships.findShip(enemyId);
REQUIRE(enemy->threatResponse.has_value());
REQUIRE(enemy->threatResponse->currentTarget.has_value());
REQUIRE(*enemy->threatResponse->currentTarget == playerId);
}
TEST_CASE("BehaviorSystem: enemy ship with no target writes leftward movement intent",
"[behavior]")
{
Fixture f;
const EntityId enemyId = f.ships.spawn("interceptor", 1, QVector2D(100.0f, 0.0f),
/*isEnemy=*/true);
f.ships.clearMovementIntents();
f.ships.tickThreatResponse(f.buildings);
const Ship* enemy = f.ships.findShip(enemyId);
REQUIRE(enemy->intent.priority == 3);
REQUIRE(enemy->intent.target.x() < 0.0f); // moving leftward (toward asteroid)
}
// ---------------------------------------------------------------------------
// tickRepairBehavior
// ---------------------------------------------------------------------------
TEST_CASE("BehaviorSystem: repair ship writes intent toward damaged friendly ship",
"[behavior]")
{
Fixture f;
const EntityId repairId = f.ships.spawn("repair_ship", 1, QVector2D(0.0f, 0.0f));
const EntityId friendlyId = f.ships.spawn("interceptor", 1, QVector2D(5.0f, 0.0f));
// Damage the friendly ship.
f.ships.forEach([friendlyId](Ship& s) {
if (s.id == friendlyId)
{
s.hp = s.maxHp * 0.5f;
}
});
f.ships.clearMovementIntents();
f.ships.tickRepairBehavior(f.buildings);
const Ship* repair = f.ships.findShip(repairId);
REQUIRE(repair->intent.priority == 2);
REQUIRE(repair->intent.target.x() == Approx(5.0f));
}
TEST_CASE("BehaviorSystem: repair ship heals damaged ally within repair range",
"[behavior]")
{
Fixture f;
// Repair range = 80 tile units; place ships close together.
const EntityId repairId = f.ships.spawn("repair_ship", 1, QVector2D(0.0f, 0.0f));
const EntityId friendlyId = f.ships.spawn("interceptor", 1, QVector2D(1.0f, 0.0f));
const float initialHp = f.ships.findShip(friendlyId)->maxHp * 0.5f;
f.ships.forEach([friendlyId, initialHp](Ship& s) {
if (s.id == friendlyId)
{
s.hp = initialHp;
}
});
f.ships.clearMovementIntents();
f.ships.tickRepairBehavior(f.buildings);
// repair_rate_formula = "5 + x" at x=1 → 6; hp should have increased.
const Ship* friendly = f.ships.findShip(friendlyId);
REQUIRE(friendly->hp > initialHp);
}
TEST_CASE("BehaviorSystem: repair ship does not heal above maxHp", "[behavior]")
{
Fixture f;
const EntityId repairId = f.ships.spawn("repair_ship", 1, QVector2D(0.0f, 0.0f));
const EntityId friendlyId = f.ships.spawn("interceptor", 1, QVector2D(1.0f, 0.0f));
// Nearly full HP — one repair tick must not exceed maxHp.
f.ships.forEach([friendlyId](Ship& s) {
if (s.id == friendlyId)
{
s.hp = s.maxHp - 0.001f;
}
});
for (int i = 0; i < 5; ++i)
{
f.ships.clearMovementIntents();
f.ships.tickRepairBehavior(f.buildings);
}
const Ship* friendly = f.ships.findShip(friendlyId);
REQUIRE(friendly->hp <= friendly->maxHp);
REQUIRE(friendly->hp == Approx(friendly->maxHp));
}
// ---------------------------------------------------------------------------
// tickScrapCollector
// ---------------------------------------------------------------------------
TEST_CASE("BehaviorSystem: salvage ship writes intent toward nearest scrap", "[behavior]")
{
Fixture f;
const EntityId shipId = f.ships.spawn("salvage_ship", 1, QVector2D(0.0f, 0.0f));
// Scrap beyond collectionRange (50) but within sensorRange (250).
const QVector2D scrapPos(100.0f, 0.0f);
const Tick farFuture = 100000;
f.scraps.spawn(scrapPos, 1, farFuture);
f.ships.clearMovementIntents();
f.ships.tickScrapCollector(f.scraps, f.buildings);
const Ship* s = f.ships.findShip(shipId);
REQUIRE(s->intent.priority == 1);
REQUIRE(s->intent.target.x() == Approx(scrapPos.x()));
}
TEST_CASE("BehaviorSystem: salvage ship collects scrap on arrival", "[behavior]")
{
Fixture f;
// Place scrap exactly at ship position so it is within collectionRange immediately.
const EntityId shipId = f.ships.spawn("salvage_ship", 1, QVector2D(0.0f, 0.0f));
const Tick farFuture = 100000;
const EntityId scrapId = f.scraps.spawn(QVector2D(0.0f, 0.0f), 1, farFuture);
f.ships.clearMovementIntents();
f.ships.tickScrapCollector(f.scraps, f.buildings);
const Ship* s = f.ships.findShip(shipId);
REQUIRE(s->cargo->current == 1);
REQUIRE(f.scraps.findScrap(scrapId) == nullptr); // consumed
}
TEST_CASE("BehaviorSystem: full-cargo salvage ship moves toward SalvageBay", "[behavior]")
{
Fixture f;
// Place a SalvageBay building so the ship has somewhere to deliver.
// The SalvageBay occupies asteroid tiles (x < 0 convention); use negative coords.
// We bypass construction time by ticking until it is operational.
const EntityId bayId = f.buildings.place(BuildingType::SalvageBay,
QPoint(-4, 0), Rotation::East, 0);
Tick tick = 0;
// SalvageBay construction_time_seconds = 15 → 450 ticks; run 500 to be safe.
for (int i = 0; i < 500; ++i)
{
f.buildings.tickConstruction(tick++);
if (f.buildings.findBuilding(bayId) != nullptr)
{
break;
}
}
REQUIRE(f.buildings.findBuilding(bayId) != nullptr);
// Spawn salvage ship and fill its cargo.
const EntityId shipId = f.ships.spawn("salvage_ship", 1, QVector2D(5.0f, 0.0f));
f.ships.forEach([](Ship& s) {
if (s.cargo)
{
s.cargo->current = s.cargo->capacity; // full cargo
}
});
f.ships.clearMovementIntents();
f.ships.tickScrapCollector(f.scraps, f.buildings);
// Intent should point toward the bay (x < 0 area), not rightward.
const Ship* s = f.ships.findShip(shipId);
REQUIRE(s->intent.priority == 1);
REQUIRE(s->intent.target.x() < s->position.x());
}

View File

@@ -9,4 +9,9 @@ add_files(
BeltSystemTest.cpp BeltSystemTest.cpp
SurfaceMaskTest.cpp SurfaceMaskTest.cpp
BuildingTest.cpp BuildingTest.cpp
ShipTest.cpp
ScrapTest.cpp
BehaviorSystemTest.cpp
WaveSystemTest.cpp
CombatSystemTest.cpp
) )

View File

@@ -0,0 +1,351 @@
#include "catch.hpp"
#include <random>
#include "BeltSystem.h"
#include "Building.h"
#include "BuildingSystem.h"
#include "BuildingType.h"
#include "CombatSystem.h"
#include "ConfigLoader.h"
#include "FireEvent.h"
#include "ScrapSystem.h"
#include "Ship.h"
#include "ShipSystem.h"
#include "Simulation.h"
#include "Tick.h"
static GameConfig loadConfig()
{
return ConfigLoader::loadFromDirectory(DOTA_FACTORY_CONFIG_DIR);
}
// Find the first ShipDef with a combat component.
static const ShipDef* findCombatShip(const GameConfig& cfg)
{
for (const ShipDef& def : cfg.ships.ships)
{
if (def.combat)
{
return &def;
}
}
return nullptr;
}
// ---------------------------------------------------------------------------
// Ship weapon firing
// ---------------------------------------------------------------------------
TEST_CASE("CombatSystem: ship fires when cooldown=0 and target in range", "[combat]")
{
const GameConfig cfg = loadConfig();
std::mt19937 rng(42);
const ShipDef* combatDef = findCombatShip(cfg);
REQUIRE(combatDef != nullptr);
BeltSystem belts(cfg.world.beltSpeedTilesPerSecond);
EntityId nextShipId = 1;
EntityId nextBldId = 100;
ShipSystem ships(cfg, [&nextShipId]() { return nextShipId++; });
BuildingSystem buildings(cfg, belts,
[&nextBldId]() { return nextBldId++; },
[](int){},
rng);
// Spawn an enemy combat ship close to the player side.
const EntityId enemyId = ships.spawn(combatDef->id, 1,
QVector2D(5.0f, 5.0f), /*isEnemy=*/true);
// Spawn a player combat ship in front of the enemy.
const EntityId playerId = ships.spawn(combatDef->id, 1,
QVector2D(4.0f, 5.0f), /*isEnemy=*/false);
// Wire the enemy's weapon target manually.
ships.forEach([&](Ship& s)
{
if (s.id == enemyId && s.weapon)
{
s.weapon->currentTarget = playerId;
s.weapon->cooldownTicks = 0.0f;
if (s.threatResponse)
{
s.threatResponse->currentTarget = playerId;
}
}
});
// Record player HP before combat.
float hpBefore = 0.0f;
for (const Ship& s : ships.allShips())
{
if (s.id == playerId) { hpBefore = s.hp; }
}
CombatSystem combat(cfg);
std::vector<FireEvent> events;
combat.tick(0, ships, buildings, events);
float hpAfter = 0.0f;
for (const Ship& s : ships.allShips())
{
if (s.id == playerId) { hpAfter = s.hp; }
}
REQUIRE(hpAfter < hpBefore);
REQUIRE(events.size() >= 1);
}
TEST_CASE("CombatSystem: cooldown prevents firing before it expires", "[combat]")
{
const GameConfig cfg = loadConfig();
std::mt19937 rng(42);
const ShipDef* combatDef = findCombatShip(cfg);
REQUIRE(combatDef != nullptr);
BeltSystem belts(cfg.world.beltSpeedTilesPerSecond);
EntityId nextShipId = 1;
EntityId nextBldId = 100;
ShipSystem ships(cfg, [&nextShipId]() { return nextShipId++; });
BuildingSystem buildings(cfg, belts,
[&nextBldId]() { return nextBldId++; },
[](int){},
rng);
const EntityId enemyId = ships.spawn(combatDef->id, 1, QVector2D(5.0f, 5.0f), true);
const EntityId playerId = ships.spawn(combatDef->id, 1, QVector2D(4.0f, 5.0f), false);
// Set cooldown to 3 so it won't fire on tick 0 or 1 or 2.
ships.forEach([&](Ship& s)
{
if (s.id == enemyId && s.weapon)
{
s.weapon->currentTarget = playerId;
s.weapon->cooldownTicks = 3.0f;
if (s.threatResponse)
{
s.threatResponse->currentTarget = playerId;
}
}
});
CombatSystem combat(cfg);
std::vector<FireEvent> events;
// Ticks 0 and 1: cooldown still > 0 after decrement → no fire.
combat.tick(0, ships, buildings, events);
combat.tick(1, ships, buildings, events);
REQUIRE(events.empty());
// Tick 2: cooldown reaches 0 → fires.
combat.tick(2, ships, buildings, events);
REQUIRE(events.size() == 1);
}
TEST_CASE("CombatSystem: no fire when target is out of range", "[combat]")
{
const GameConfig cfg = loadConfig();
std::mt19937 rng(42);
const ShipDef* combatDef = findCombatShip(cfg);
REQUIRE(combatDef != nullptr);
BeltSystem belts(cfg.world.beltSpeedTilesPerSecond);
EntityId nextShipId = 1;
EntityId nextBldId = 100;
ShipSystem ships(cfg, [&nextShipId]() { return nextShipId++; });
BuildingSystem buildings(cfg, belts,
[&nextBldId]() { return nextBldId++; },
[](int){},
rng);
const EntityId enemyId = ships.spawn(combatDef->id, 1, QVector2D(0.0f, 0.0f), true);
const EntityId playerId = ships.spawn(combatDef->id, 1, QVector2D(500.0f, 0.0f), false);
ships.forEach([&](Ship& s)
{
if (s.id == enemyId && s.weapon)
{
s.weapon->currentTarget = playerId;
s.weapon->cooldownTicks = 0.0f;
if (s.threatResponse)
{
s.threatResponse->currentTarget = playerId;
}
}
});
CombatSystem combat(cfg);
std::vector<FireEvent> events;
combat.tick(0, ships, buildings, events);
REQUIRE(events.empty());
}
// ---------------------------------------------------------------------------
// Station weapon firing
// ---------------------------------------------------------------------------
TEST_CASE("CombatSystem: player station fires at enemy ship in range", "[combat]")
{
const GameConfig cfg = loadConfig();
Simulation sim(cfg, 42);
// Find the player defence station.
EntityId stationId = kInvalidEntityId;
for (const Building& b : sim.buildings().allBuildings())
{
if (b.type == BuildingType::PlayerDefenceStation)
{
stationId = b.id;
break;
}
}
REQUIRE(stationId != kInvalidEntityId);
// Place an enemy ship close to the player station.
QVector2D stationCenter;
for (const Building& b : sim.buildings().allBuildings())
{
if (b.id == stationId)
{
stationCenter = QVector2D(
b.anchor.x() + b.footprint.width() / 2.0f,
b.anchor.y() + b.footprint.height() / 2.0f);
break;
}
}
// Find a combat ship blueprint for the enemy.
const ShipDef* combatDef = findCombatShip(cfg);
REQUIRE(combatDef != nullptr);
const EntityId enemyId = sim.ships().spawn(
combatDef->id, 1,
QVector2D(stationCenter.x() + 1.0f, stationCenter.y()),
/*isEnemy=*/true);
// Tick to let station auto-acquire and fire.
sim.tick();
// Check that a fire event was emitted with stationId as shooter.
const std::vector<FireEvent> events = sim.drainFireEvents();
bool stationFired = false;
for (const FireEvent& e : events)
{
if (e.shooter == stationId) { stationFired = true; }
}
REQUIRE(stationFired);
}
TEST_CASE("CombatSystem: enemy station fires at player ship in range", "[combat]")
{
const GameConfig cfg = loadConfig();
Simulation sim(cfg, 42);
// Find the enemy defence station.
EntityId stationId = kInvalidEntityId;
QVector2D stationCenter;
for (const Building& b : sim.buildings().allBuildings())
{
if (b.type == BuildingType::EnemyDefenceStation)
{
stationId = b.id;
stationCenter = QVector2D(
b.anchor.x() + b.footprint.width() / 2.0f,
b.anchor.y() + b.footprint.height() / 2.0f);
break;
}
}
REQUIRE(stationId != kInvalidEntityId);
const ShipDef* combatDef = findCombatShip(cfg);
REQUIRE(combatDef != nullptr);
// Spawn a player ship right next to the enemy station.
sim.ships().spawn(
combatDef->id, 1,
QVector2D(stationCenter.x() - 1.0f, stationCenter.y()),
/*isEnemy=*/false);
sim.tick();
const std::vector<FireEvent> events = sim.drainFireEvents();
bool stationFired = false;
for (const FireEvent& e : events)
{
if (e.shooter == stationId) { stationFired = true; }
}
REQUIRE(stationFired);
}
// ---------------------------------------------------------------------------
// Deaths & loot (tick step 9)
// ---------------------------------------------------------------------------
TEST_CASE("CombatSystem: dead ship is removed after tick step 9", "[combat]")
{
const GameConfig cfg = loadConfig();
Simulation sim(cfg, 42);
const ShipDef* combatDef = findCombatShip(cfg);
REQUIRE(combatDef != nullptr);
const EntityId shipId = sim.ships().spawn(combatDef->id, 1,
QVector2D(10.0f, 10.0f));
// Set hp to lethal.
sim.ships().damageShip(shipId, 9999.0f);
sim.tick();
REQUIRE(sim.ships().findShip(shipId) == nullptr);
}
TEST_CASE("CombatSystem: scrap is spawned on ship death", "[combat]")
{
const GameConfig cfg = loadConfig();
Simulation sim(cfg, 42);
// Find a ship def that drops scrap.
const ShipDef* droppingDef = nullptr;
for (const ShipDef& def : cfg.ships.ships)
{
if (def.loot.scrapDrop > 0)
{
droppingDef = &def;
break;
}
}
REQUIRE(droppingDef != nullptr);
const EntityId shipId = sim.ships().spawn(droppingDef->id, 1,
QVector2D(10.0f, 10.0f));
sim.ships().damageShip(shipId, 9999.0f);
sim.tick();
// At least one scrap entity should now exist.
REQUIRE(!sim.scraps().allScraps().empty());
}
TEST_CASE("CombatSystem: HQ death sets game over", "[combat]")
{
const GameConfig cfg = loadConfig();
Simulation sim(cfg, 42);
sim.buildings().forEachBuilding([](Building& b)
{
if (b.type == BuildingType::Hq)
{
b.hp = -1.0f;
}
});
sim.tick();
REQUIRE(sim.isGameOver());
}

83
src/test/ScrapTest.cpp Normal file
View File

@@ -0,0 +1,83 @@
#include "catch.hpp"
#include <QVector2D>
#include "EntityId.h"
#include "Scrap.h"
#include "ScrapSystem.h"
// ---------------------------------------------------------------------------
// Spawn
// ---------------------------------------------------------------------------
TEST_CASE("ScrapSystem: spawn returns a findable scrap with correct fields", "[scrap]")
{
EntityId nextId = 1;
ScrapSystem ss([&nextId]() { return nextId++; });
const EntityId id = ss.spawn(QVector2D(3.0f, 4.0f), 5, 100);
const Scrap* s = ss.findScrap(id);
REQUIRE(s != nullptr);
REQUIRE(s->amount == 5);
REQUIRE(s->despawnAt == 100);
}
// ---------------------------------------------------------------------------
// Despawn timing
// ---------------------------------------------------------------------------
TEST_CASE("ScrapSystem: scrap still present one tick before despawnAt", "[scrap]")
{
EntityId nextId = 1;
ScrapSystem ss([&nextId]() { return nextId++; });
const EntityId id = ss.spawn(QVector2D(0.0f, 0.0f), 1, 50);
ss.tickDespawn(49);
REQUIRE(ss.findScrap(id) != nullptr);
}
TEST_CASE("ScrapSystem: scrap removed at despawnAt tick", "[scrap]")
{
EntityId nextId = 1;
ScrapSystem ss([&nextId]() { return nextId++; });
const EntityId id = ss.spawn(QVector2D(0.0f, 0.0f), 1, 50);
ss.tickDespawn(50);
REQUIRE(ss.findScrap(id) == nullptr);
}
// ---------------------------------------------------------------------------
// Selective removal
// ---------------------------------------------------------------------------
TEST_CASE("ScrapSystem: tickDespawn removes only expired scraps", "[scrap]")
{
EntityId nextId = 1;
ScrapSystem ss([&nextId]() { return nextId++; });
const EntityId earlyId = ss.spawn(QVector2D(0.0f, 0.0f), 1, 30);
const EntityId lateId = ss.spawn(QVector2D(1.0f, 0.0f), 2, 60);
ss.tickDespawn(30);
REQUIRE(ss.findScrap(earlyId) == nullptr);
REQUIRE(ss.findScrap(lateId) != nullptr);
}
// ---------------------------------------------------------------------------
// Entity ids
// ---------------------------------------------------------------------------
TEST_CASE("ScrapSystem: spawned scraps receive strictly increasing entity ids", "[scrap]")
{
EntityId nextId = 1;
ScrapSystem ss([&nextId]() { return nextId++; });
const EntityId id1 = ss.spawn(QVector2D(0.0f, 0.0f), 1, 100);
const EntityId id2 = ss.spawn(QVector2D(1.0f, 0.0f), 2, 200);
REQUIRE(id2 > id1);
}

194
src/test/ShipTest.cpp Normal file
View File

@@ -0,0 +1,194 @@
#include "catch.hpp"
#include <cmath>
#include <functional>
#include <string>
#include <vector>
#include <QVector2D>
#include "ConfigLoader.h"
#include "EntityId.h"
#include "Ship.h"
#include "ShipSystem.h"
#include "Tick.h"
static GameConfig loadConfig()
{
return ConfigLoader::loadFromDirectory(DOTA_FACTORY_CONFIG_DIR);
}
// ---------------------------------------------------------------------------
// Combat ship
// ---------------------------------------------------------------------------
TEST_CASE("ShipSystem: interceptor spawn has weapon and threatResponse, no cargo or repair",
"[ship]")
{
const GameConfig cfg = loadConfig();
EntityId nextId = 1;
ShipSystem ss(cfg, [&nextId]() { return nextId++; });
const EntityId id = ss.spawn("interceptor", 1, QVector2D(0.0f, 0.0f));
const Ship* ship = ss.findShip(id);
REQUIRE(ship != nullptr);
REQUIRE(ship->weapon.has_value());
REQUIRE(ship->threatResponse.has_value());
REQUIRE_FALSE(ship->cargo.has_value());
REQUIRE_FALSE(ship->repairTool.has_value());
REQUIRE_FALSE(ship->repairBehavior.has_value());
REQUIRE_FALSE(ship->scrapCollector.has_value());
}
TEST_CASE("ShipSystem: interceptor level 1 stats match config formulas", "[ship]")
{
const GameConfig cfg = loadConfig();
EntityId nextId = 1;
ShipSystem ss(cfg, [&nextId]() { return nextId++; });
const EntityId id = ss.spawn("interceptor", 1, QVector2D(0.0f, 0.0f));
const Ship* ship = ss.findShip(id);
REQUIRE(ship != nullptr);
// hp_formula = "40 + 5*x" at x=1 → 45
REQUIRE(ship->maxHp == Approx(45.0f));
REQUIRE(ship->hp == Approx(45.0f));
// damage_formula = "10 + 2*x" at x=1 → 12
REQUIRE(ship->weapon->damage == Approx(12.0f));
// attack_range_formula = "150"
REQUIRE(ship->weapon->range == Approx(150.0f));
// threatResponse.engagementRange mirrors weapon range
REQUIRE(ship->threatResponse->engagementRange == Approx(150.0f));
// cooldownTicks starts at 0
REQUIRE(ship->weapon->cooldownTicks == Approx(0.0f));
}
TEST_CASE("ShipSystem: interceptor level 5 hp matches formula", "[ship]")
{
const GameConfig cfg = loadConfig();
EntityId nextId = 1;
ShipSystem ss(cfg, [&nextId]() { return nextId++; });
const EntityId id = ss.spawn("interceptor", 5, QVector2D(0.0f, 0.0f));
const Ship* ship = ss.findShip(id);
// hp_formula = "40 + 5*x" at x=5 → 65
REQUIRE(ship->maxHp == Approx(65.0f));
}
TEST_CASE("ShipSystem: interceptor level 0 speedPerTick matches formula / kTickRateHz", "[ship]")
{
const GameConfig cfg = loadConfig();
EntityId nextId = 1;
ShipSystem ss(cfg, [&nextId]() { return nextId++; });
const EntityId id = ss.spawn("interceptor", 0, QVector2D(0.0f, 0.0f));
const Ship* ship = ss.findShip(id);
// speed_formula = "200 + 5*x" at x=0 → 200; speedPerTick = 200/30
const float expected = 200.0f / static_cast<float>(kTickRateHz);
REQUIRE(ship->speedPerTick == Approx(expected));
}
// ---------------------------------------------------------------------------
// Salvage ship
// ---------------------------------------------------------------------------
TEST_CASE("ShipSystem: salvage_ship spawn has cargo and scrapCollector, no weapon",
"[ship]")
{
const GameConfig cfg = loadConfig();
EntityId nextId = 1;
ShipSystem ss(cfg, [&nextId]() { return nextId++; });
const EntityId id = ss.spawn("salvage_ship", 1, QVector2D(0.0f, 0.0f));
const Ship* ship = ss.findShip(id);
REQUIRE(ship != nullptr);
REQUIRE(ship->cargo.has_value());
REQUIRE(ship->scrapCollector.has_value());
REQUIRE_FALSE(ship->weapon.has_value());
REQUIRE_FALSE(ship->repairTool.has_value());
}
TEST_CASE("ShipSystem: salvage_ship cargo capacity matches config", "[ship]")
{
const GameConfig cfg = loadConfig();
EntityId nextId = 1;
ShipSystem ss(cfg, [&nextId]() { return nextId++; });
const EntityId id = ss.spawn("salvage_ship", 1, QVector2D(0.0f, 0.0f));
const Ship* ship = ss.findShip(id);
// cargo_capacity = 10
REQUIRE(ship->cargo->capacity == 10);
REQUIRE(ship->cargo->current == 0);
REQUIRE(ship->scrapCollector->deliveryBay == kInvalidEntityId);
REQUIRE_FALSE(ship->scrapCollector->scrapTarget.has_value());
}
// ---------------------------------------------------------------------------
// Repair ship
// ---------------------------------------------------------------------------
TEST_CASE("ShipSystem: repair_ship spawn has repairTool and repairBehavior, no weapon",
"[ship]")
{
const GameConfig cfg = loadConfig();
EntityId nextId = 1;
ShipSystem ss(cfg, [&nextId]() { return nextId++; });
const EntityId id = ss.spawn("repair_ship", 1, QVector2D(0.0f, 0.0f));
const Ship* ship = ss.findShip(id);
REQUIRE(ship != nullptr);
REQUIRE(ship->repairTool.has_value());
REQUIRE(ship->repairBehavior.has_value());
REQUIRE_FALSE(ship->weapon.has_value());
REQUIRE_FALSE(ship->cargo.has_value());
}
TEST_CASE("ShipSystem: repair_ship level 1 repair stats match config formulas", "[ship]")
{
const GameConfig cfg = loadConfig();
EntityId nextId = 1;
ShipSystem ss(cfg, [&nextId]() { return nextId++; });
const EntityId id = ss.spawn("repair_ship", 1, QVector2D(0.0f, 0.0f));
const Ship* ship = ss.findShip(id);
// repair_rate_formula = "5 + x" at x=1 → 6
REQUIRE(ship->repairTool->ratePerTick == Approx(6.0f));
// repair_range_formula = "80"
REQUIRE(ship->repairTool->range == Approx(80.0f));
}
// ---------------------------------------------------------------------------
// Entity ids and removal
// ---------------------------------------------------------------------------
TEST_CASE("ShipSystem: spawned ships receive strictly increasing entity ids", "[ship]")
{
const GameConfig cfg = loadConfig();
EntityId nextId = 1;
ShipSystem ss(cfg, [&nextId]() { return nextId++; });
const EntityId id1 = ss.spawn("interceptor", 1, QVector2D(0.0f, 0.0f));
const EntityId id2 = ss.spawn("salvage_ship", 1, QVector2D(1.0f, 0.0f));
REQUIRE(id2 > id1);
}
TEST_CASE("ShipSystem: despawn removes the ship", "[ship]")
{
const GameConfig cfg = loadConfig();
EntityId nextId = 1;
ShipSystem ss(cfg, [&nextId]() { return nextId++; });
const EntityId id = ss.spawn("interceptor", 1, QVector2D(0.0f, 0.0f));
REQUIRE(ss.findShip(id) != nullptr);
ss.despawn(id);
REQUIRE(ss.findShip(id) == nullptr);
}

View File

@@ -1,17 +1,23 @@
#include "catch.hpp" #include "catch.hpp"
#include "ConfigLoader.h"
#include "GameConfig.h" #include "GameConfig.h"
#include "Simulation.h" #include "Simulation.h"
#include "Tick.h" #include "Tick.h"
#include "TickDriver.h" #include "TickDriver.h"
static GameConfig loadConfig()
{
return ConfigLoader::loadFromDirectory(DOTA_FACTORY_CONFIG_DIR);
}
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Simulation // Simulation
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
TEST_CASE("Simulation::currentTick starts at 0", "[simulation]") TEST_CASE("Simulation::currentTick starts at 0", "[simulation]")
{ {
const GameConfig config; const GameConfig config = loadConfig();
const Simulation sim(config); const Simulation sim(config);
REQUIRE(sim.currentTick() == 0); REQUIRE(sim.currentTick() == 0);
@@ -19,7 +25,7 @@ TEST_CASE("Simulation::currentTick starts at 0", "[simulation]")
TEST_CASE("Simulation::tick increments currentTick by 1", "[simulation]") TEST_CASE("Simulation::tick increments currentTick by 1", "[simulation]")
{ {
const GameConfig config; const GameConfig config = loadConfig();
Simulation sim(config); Simulation sim(config);
sim.tick(); sim.tick();
@@ -29,7 +35,7 @@ TEST_CASE("Simulation::tick increments currentTick by 1", "[simulation]")
TEST_CASE("Simulation::tick 10 times yields currentTick == 10", "[simulation]") TEST_CASE("Simulation::tick 10 times yields currentTick == 10", "[simulation]")
{ {
const GameConfig config; const GameConfig config = loadConfig();
Simulation sim(config); Simulation sim(config);
for (int i = 0; i < 10; ++i) for (int i = 0; i < 10; ++i)
@@ -42,7 +48,7 @@ TEST_CASE("Simulation::tick 10 times yields currentTick == 10", "[simulation]")
TEST_CASE("Simulation::drainFireEvents returns empty initially", "[simulation]") TEST_CASE("Simulation::drainFireEvents returns empty initially", "[simulation]")
{ {
const GameConfig config; const GameConfig config = loadConfig();
Simulation sim(config); Simulation sim(config);
REQUIRE(sim.drainFireEvents().empty()); REQUIRE(sim.drainFireEvents().empty());
@@ -50,7 +56,7 @@ TEST_CASE("Simulation::drainFireEvents returns empty initially", "[simulation]")
TEST_CASE("Simulation::drainFireEvents clears queue on drain", "[simulation]") TEST_CASE("Simulation::drainFireEvents clears queue on drain", "[simulation]")
{ {
const GameConfig config; const GameConfig config = loadConfig();
Simulation sim(config); Simulation sim(config);
// First drain: empty. // First drain: empty.
@@ -62,7 +68,7 @@ TEST_CASE("Simulation::drainFireEvents clears queue on drain", "[simulation]")
TEST_CASE("Simulation::drainBlueprintDropEvents returns empty initially", "[simulation]") TEST_CASE("Simulation::drainBlueprintDropEvents returns empty initially", "[simulation]")
{ {
const GameConfig config; const GameConfig config = loadConfig();
Simulation sim(config); Simulation sim(config);
REQUIRE(sim.drainBlueprintDropEvents().empty()); REQUIRE(sim.drainBlueprintDropEvents().empty());

357
src/test/WaveSystemTest.cpp Normal file
View File

@@ -0,0 +1,357 @@
#include "catch.hpp"
#include <random>
#include "Building.h"
#include "BuildingSystem.h"
#include "BuildingType.h"
#include "ConfigLoader.h"
#include "Rotation.h"
#include "Ship.h"
#include "ShipSystem.h"
#include "Simulation.h"
#include "Tick.h"
#include "WaveSystem.h"
static GameConfig loadConfig()
{
return ConfigLoader::loadFromDirectory(DOTA_FACTORY_CONFIG_DIR);
}
// ---------------------------------------------------------------------------
// Threat accumulation
// ---------------------------------------------------------------------------
TEST_CASE("WaveSystem: threat stays 0 for first 30 game-seconds", "[wave]")
{
const GameConfig cfg = loadConfig();
std::mt19937 rng(42);
WaveSystem ws(cfg, rng);
// threat_rate_formula = "1*x - 30", which is <= 0 for x <= 30.
const int ticks30s = static_cast<int>(secondsToTicks(30.0));
for (int i = 0; i < ticks30s; ++i)
{
ws.tickThreatAccumulation(static_cast<Tick>(i));
}
REQUIRE(ws.threatLevel() == Approx(0.0));
}
TEST_CASE("WaveSystem: threat accumulates after 30 game-seconds", "[wave]")
{
const GameConfig cfg = loadConfig();
std::mt19937 rng(42);
WaveSystem ws(cfg, rng);
// Run 31 seconds worth of ticks.
const int ticks31s = static_cast<int>(secondsToTicks(31.0));
for (int i = 0; i < ticks31s; ++i)
{
ws.tickThreatAccumulation(static_cast<Tick>(i));
}
REQUIRE(ws.threatLevel() > 0.0);
}
TEST_CASE("WaveSystem: applyPush increases threat accumulation rate", "[wave]")
{
const GameConfig cfg = loadConfig();
std::mt19937 rng(42);
WaveSystem ws(cfg, rng);
// Accumulate for 1 tick past the 30s mark to get a baseline rate.
const Tick baseTick = secondsToTicks(31.0);
ws.tickThreatAccumulation(baseTick);
const double levelBefore = ws.threatLevel();
// Apply push: multiplier should increase.
ws.applyPush();
WaveSystem ws2(cfg, rng);
ws2.tickThreatAccumulation(baseTick);
// After the push the same tick adds more threat.
ws.tickThreatAccumulation(baseTick + 1);
ws2.tickThreatAccumulation(baseTick + 1);
// ws has the push multiplier applied; ws2 does not.
REQUIRE(ws.threatLevel() > ws2.threatLevel());
}
TEST_CASE("WaveSystem: generation starts at 0 and increments on push", "[wave]")
{
const GameConfig cfg = loadConfig();
std::mt19937 rng(42);
WaveSystem ws(cfg, rng);
REQUIRE(ws.generation() == 0);
ws.applyPush();
REQUIRE(ws.generation() == 1);
ws.applyPush();
REQUIRE(ws.generation() == 2);
}
// ---------------------------------------------------------------------------
// Pre-placed structures
// ---------------------------------------------------------------------------
TEST_CASE("WaveSystem: Simulation pre-places HQ + 2 player + 2 enemy stations", "[wave]")
{
const GameConfig cfg = loadConfig();
const Simulation sim(cfg, 42);
int hqCount = 0;
int playerCount = 0;
int enemyCount = 0;
for (const Building& b : sim.buildings().allBuildings())
{
if (b.type == BuildingType::Hq) { ++hqCount; }
else if (b.type == BuildingType::PlayerDefenceStation) { ++playerCount; }
else if (b.type == BuildingType::EnemyDefenceStation) { ++enemyCount; }
}
REQUIRE(hqCount == 1);
REQUIRE(playerCount == 2);
REQUIRE(enemyCount == 2);
}
TEST_CASE("WaveSystem: HQ has correct initial HP from config", "[wave]")
{
const GameConfig cfg = loadConfig();
const Simulation sim(cfg, 42);
const float expectedHp =
static_cast<float>(cfg.stations.hq.hpFormula.evaluate(0.0));
bool found = false;
float actualHp = 0.0f;
for (const Building& b : sim.buildings().allBuildings())
{
if (b.type == BuildingType::Hq)
{
found = true;
actualHp = b.hp;
break;
}
}
REQUIRE(found);
REQUIRE(actualHp == Approx(expectedHp));
}
TEST_CASE("WaveSystem: HQ anchor is at asteroid right edge", "[wave]")
{
const GameConfig cfg = loadConfig();
const Simulation sim(cfg, 42);
for (const Building& b : sim.buildings().allBuildings())
{
if (b.type != BuildingType::Hq) { continue; }
// Rightmost body cell must be at x = -1 (asteroid right edge).
int maxX = std::numeric_limits<int>::min();
for (const QPoint& cell : b.bodyCells)
{
if (cell.x() > maxX) { maxX = cell.x(); }
}
REQUIRE(maxX == -1);
}
}
TEST_CASE("WaveSystem: player stations have weapon set", "[wave]")
{
const GameConfig cfg = loadConfig();
const Simulation sim(cfg, 42);
int armedPlayerStations = 0;
for (const Building& b : sim.buildings().allBuildings())
{
if (b.type == BuildingType::PlayerDefenceStation && b.weapon)
{
++armedPlayerStations;
REQUIRE(b.weapon->damage > 0.0f);
REQUIRE(b.weapon->range > 0.0f);
REQUIRE(b.weapon->fireRateHz > 0.0f);
}
}
REQUIRE(armedPlayerStations == 2);
}
TEST_CASE("WaveSystem: enemy stations have weapon set", "[wave]")
{
const GameConfig cfg = loadConfig();
const Simulation sim(cfg, 42);
int armedEnemyStations = 0;
for (const Building& b : sim.buildings().allBuildings())
{
if (b.type == BuildingType::EnemyDefenceStation && b.weapon)
{
++armedEnemyStations;
REQUIRE(b.weapon->damage > 0.0f);
REQUIRE(b.weapon->range > 0.0f);
REQUIRE(b.weapon->fireRateHz > 0.0f);
}
}
REQUIRE(armedEnemyStations == 2);
}
// ---------------------------------------------------------------------------
// Wave spawning
// ---------------------------------------------------------------------------
TEST_CASE("WaveSystem: enemy ships spawn after the initial gap elapses", "[wave]")
{
const GameConfig cfg = loadConfig();
Simulation sim(cfg, 42);
// The maximum gap is gapMaxSeconds = 45s → 1350 ticks.
// Run 1500 ticks to guarantee at least one wave has triggered.
const int limit = static_cast<int>(secondsToTicks(50.0));
for (int i = 0; i < limit; ++i)
{
sim.tick();
}
bool foundEnemyShip = false;
for (const Ship& s : sim.ships().allShips())
{
if (s.isEnemy)
{
foundEnemyShip = true;
break;
}
}
REQUIRE(foundEnemyShip);
}
TEST_CASE("WaveSystem: only eligible ships (cost > 0) appear in waves", "[wave]")
{
const GameConfig cfg = loadConfig();
Simulation sim(cfg, 42);
// Run long enough for several waves.
const int limit = static_cast<int>(secondsToTicks(120.0));
for (int i = 0; i < limit; ++i)
{
sim.tick();
}
for (const Ship& s : sim.ships().allShips())
{
if (!s.isEnemy) { continue; }
// salvage_ship and repair_ship have cost_formula = "0" and must not spawn.
REQUIRE(s.blueprintId != "salvage_ship");
REQUIRE(s.blueprintId != "repair_ship");
}
}
// ---------------------------------------------------------------------------
// Push
// ---------------------------------------------------------------------------
TEST_CASE("WaveSystem: destroying both enemy stations triggers a push", "[wave]")
{
const GameConfig cfg = loadConfig();
Simulation sim(cfg, 42);
// Damage both enemy stations to 0.
sim.buildings().forEachBuilding([](Building& b)
{
if (b.type == BuildingType::EnemyDefenceStation)
{
b.hp = -1.0f;
}
});
sim.tick();
// After push: should have 2 new enemy stations.
int enemyCount = 0;
for (const Building& b : sim.buildings().allBuildings())
{
if (b.type == BuildingType::EnemyDefenceStation) { ++enemyCount; }
}
REQUIRE(enemyCount == 2);
}
TEST_CASE("WaveSystem: push emits exactly one BlueprintDropEvent", "[wave]")
{
const GameConfig cfg = loadConfig();
Simulation sim(cfg, 42);
sim.buildings().forEachBuilding([](Building& b)
{
if (b.type == BuildingType::EnemyDefenceStation)
{
b.hp = -1.0f;
}
});
sim.tick();
const std::vector<BlueprintDropEvent> events = sim.drainBlueprintDropEvents();
REQUIRE(events.size() == 1);
}
TEST_CASE("WaveSystem: push blueprint drop awards a known ship id", "[wave]")
{
const GameConfig cfg = loadConfig();
Simulation sim(cfg, 42);
sim.buildings().forEachBuilding([](Building& b)
{
if (b.type == BuildingType::EnemyDefenceStation)
{
b.hp = -1.0f;
}
});
sim.tick();
const std::vector<BlueprintDropEvent> events = sim.drainBlueprintDropEvents();
REQUIRE(events.size() == 1);
bool validId = false;
for (const ShipDef& def : cfg.ships.ships)
{
if (def.id == events[0].blueprintId)
{
validId = true;
break;
}
}
REQUIRE(validId);
REQUIRE(events[0].newLevel >= 1);
}
TEST_CASE("WaveSystem: push places new enemy stations further right", "[wave]")
{
const GameConfig cfg = loadConfig();
Simulation sim(cfg, 42);
// Record the X position of the initial enemy stations.
int initialX = std::numeric_limits<int>::min();
for (const Building& b : sim.buildings().allBuildings())
{
if (b.type == BuildingType::EnemyDefenceStation)
{
if (b.anchor.x() > initialX) { initialX = b.anchor.x(); }
}
}
sim.buildings().forEachBuilding([](Building& b)
{
if (b.type == BuildingType::EnemyDefenceStation) { b.hp = -1.0f; }
});
sim.tick();
int newX = std::numeric_limits<int>::min();
for (const Building& b : sim.buildings().allBuildings())
{
if (b.type == BuildingType::EnemyDefenceStation)
{
if (b.anchor.x() > newX) { newX = b.anchor.x(); }
}
}
REQUIRE(newX > initialX);
}

129
src/ui/BuildButtonGrid.cpp Normal file
View File

@@ -0,0 +1,129 @@
#include "BuildButtonGrid.h"
#include <cctype>
#include <string>
#include <QGridLayout>
#include <QPushButton>
#include <QSignalMapper>
#include "BuildingType.h"
namespace
{
QString displayName(const std::string& id)
{
QString result;
bool nextUpper = true;
for (char c : id)
{
if (c == '_')
{
result += ' ';
nextUpper = true;
}
else if (nextUpper)
{
result += static_cast<char>(std::toupper(static_cast<unsigned char>(c)));
nextUpper = false;
}
else
{
result += c;
}
}
return result;
}
} // namespace
BuildButtonGrid::BuildButtonGrid(const GameConfig* config, QWidget* parent)
: QWidget(parent)
, m_config(config)
, m_activeIndex(-1)
{
QGridLayout* layout = new QGridLayout(this);
layout->setSpacing(4);
layout->setContentsMargins(4, 4, 4, 4);
QSignalMapper* mapper = new QSignalMapper(this);
int col = 0;
int row = 0;
const int kCols = 3;
for (const BuildingDef& def : config->buildings.buildings)
{
if (!def.playerPlaceable)
{
continue;
}
m_types.push_back(def.type);
m_costs[def.type] = def.cost;
const QString label = displayName(def.id)
+ "\n" + QString::number(def.cost) + " Blocks";
QPushButton* btn = new QPushButton(label, this);
btn->setCheckable(true);
btn->setFixedHeight(48);
layout->addWidget(btn, row, col);
const int idx = static_cast<int>(m_buttons.size());
m_buttons.push_back(btn);
mapper->setMapping(btn, idx);
connect(btn, SIGNAL(clicked()), mapper, SLOT(map()));
++col;
if (col >= kCols)
{
col = 0;
++row;
}
}
connect(mapper, SIGNAL(mapped(int)), this, SLOT(onBuildButton(int)));
}
void BuildButtonGrid::updateAffordability(int buildingBlocks)
{
for (std::size_t i = 0; i < m_buttons.size(); ++i)
{
const BuildingType type = m_types[i];
const std::map<BuildingType, int>::const_iterator it = m_costs.find(type);
const int cost = (it != m_costs.end()) ? it->second : 0;
m_buttons[i]->setEnabled(buildingBlocks >= cost || m_activeIndex == static_cast<int>(i));
}
}
void BuildButtonGrid::clearActiveButton()
{
if (m_activeIndex >= 0 && m_activeIndex < static_cast<int>(m_buttons.size()))
{
m_buttons[static_cast<std::size_t>(m_activeIndex)]->setChecked(false);
}
m_activeIndex = -1;
}
void BuildButtonGrid::onBuildButton(int index)
{
if (index < 0 || index >= static_cast<int>(m_buttons.size()))
{
return;
}
if (m_activeIndex == index)
{
clearActiveButton();
emit builderModeExited();
return;
}
if (m_activeIndex >= 0 && m_activeIndex < static_cast<int>(m_buttons.size()))
{
m_buttons[static_cast<std::size_t>(m_activeIndex)]->setChecked(false);
}
m_activeIndex = index;
m_buttons[static_cast<std::size_t>(index)]->setChecked(true);
emit buildingTypeSelected(m_types[static_cast<std::size_t>(index)]);
}

36
src/ui/BuildButtonGrid.h Normal file
View File

@@ -0,0 +1,36 @@
#pragma once
#include <map>
#include <vector>
#include <QWidget>
#include "BuildingType.h"
#include "GameConfig.h"
class QPushButton;
class BuildButtonGrid : public QWidget
{
Q_OBJECT
public:
BuildButtonGrid(const GameConfig* config, QWidget* parent = nullptr);
void updateAffordability(int buildingBlocks);
void clearActiveButton();
signals:
void buildingTypeSelected(BuildingType type);
void builderModeExited();
private slots:
void onBuildButton(int index);
private:
const GameConfig* m_config;
std::vector<BuildingType> m_types;
std::vector<QPushButton*> m_buttons;
std::map<BuildingType, int> m_costs;
int m_activeIndex;
};

View File

@@ -1,7 +1,22 @@
# UI source files are listed here as they are added. SET(HDRS
# Append headers and sources to HDRS and SRCS in PARENT_SCOPE, ${HDRS}
# following the same pattern used by src/lib/. ${CMAKE_CURRENT_SOURCE_DIR}/VisualsConfig.h
# ${CMAKE_CURRENT_SOURCE_DIR}/VisualsLoader.h
# When this directory has actual sources, the parent CMakeLists.txt ${CMAKE_CURRENT_SOURCE_DIR}/MainWindow.h
# must be updated to convert DotaFactory_ui from an INTERFACE library ${CMAKE_CURRENT_SOURCE_DIR}/GameWorldView.h
# to a regular static library (and enable AUTOMOC on it). ${CMAKE_CURRENT_SOURCE_DIR}/HeaderBar.h
${CMAKE_CURRENT_SOURCE_DIR}/BuildButtonGrid.h
${CMAKE_CURRENT_SOURCE_DIR}/SelectedBuildingPanel.h
PARENT_SCOPE
)
SET(SRCS
${SRCS}
${CMAKE_CURRENT_SOURCE_DIR}/VisualsLoader.cpp
${CMAKE_CURRENT_SOURCE_DIR}/MainWindow.cpp
${CMAKE_CURRENT_SOURCE_DIR}/GameWorldView.cpp
${CMAKE_CURRENT_SOURCE_DIR}/HeaderBar.cpp
${CMAKE_CURRENT_SOURCE_DIR}/BuildButtonGrid.cpp
${CMAKE_CURRENT_SOURCE_DIR}/SelectedBuildingPanel.cpp
PARENT_SCOPE
)

1077
src/ui/GameWorldView.cpp Normal file

File diff suppressed because it is too large Load Diff

151
src/ui/GameWorldView.h Normal file
View File

@@ -0,0 +1,151 @@
#pragma once
#include <optional>
#include <set>
#include <vector>
#include <QElapsedTimer>
#include <QOpenGLWidget>
#include <QPoint>
#include <QRectF>
#include <QTimer>
#include <QVector2D>
#include "BlueprintDropEvent.h"
#include "BuildingType.h"
#include "EntityId.h"
#include "FireEvent.h"
#include "GameConfig.h"
#include "Rotation.h"
#include "Tick.h"
#include "TickDriver.h"
#include "VisualsConfig.h"
class Simulation;
class QPainter;
struct QPointCompare
{
bool operator()(const QPoint& a, const QPoint& b) const
{
if (a.x() != b.x()) { return a.x() < b.x(); }
return a.y() < b.y();
}
};
class GameWorldView : public QOpenGLWidget
{
Q_OBJECT
public:
GameWorldView(Simulation* sim, const GameConfig* config,
const VisualsConfig* visuals, QWidget* parent = nullptr);
signals:
void selectionChanged(const std::vector<EntityId>& ids);
void stateUpdated(Tick tick, int blocks, double speed);
void gameOver();
void builderModeExited();
public slots:
void enterBuilderMode(BuildingType type);
void exitBuilderMode();
void setGameSpeed(double multiplier);
protected:
void initializeGL() override;
void paintGL() override;
void keyPressEvent(QKeyEvent* event) override;
void keyReleaseEvent(QKeyEvent* event) override;
void mousePressEvent(QMouseEvent* event) override;
void mouseMoveEvent(QMouseEvent* event) override;
void mouseReleaseEvent(QMouseEvent* event) override;
private slots:
void onFrame();
private:
void drawTiles(QPainter& painter);
void drawBuildings(QPainter& painter);
void drawBeltItems(QPainter& painter);
void drawScrap(QPainter& painter);
void drawShips(QPainter& painter);
void drawBeams(QPainter& painter);
void drawOverlays(QPainter& painter);
void drawScreenSpace(QPainter& painter);
float tilePx() const;
float viewportWidthTiles() const;
QPointF worldToWidget(QVector2D worldPos) const;
QPointF tileToWidget(QPoint tile) const;
QPoint widgetToTile(QPoint widgetPt) const;
QRectF tileRect(QPoint tile) const;
QRect viewportRect() const;
float asteroidLeftEdge() const;
float enemyStationRightEdge() const;
void clampScroll();
bool isValidPlacement(BuildingType type, QPoint anchor, Rotation rot) const;
const BuildingDef* findBuildingDef(BuildingType type) const;
EntityId buildingAtTile(QPoint tile) const;
void drawPortGlyph(QPainter& painter, QPoint bodyTile,
Rotation direction, const QColor& color);
std::optional<QVector2D> entityPosition(EntityId id) const;
void stepSpeed(int delta);
void placeAtTile(QPoint tile);
struct ActiveBeam
{
FireEvent event;
qint64 emittedWallMs;
};
struct ToastEntry
{
QString text;
qint64 createdWallMs;
};
static constexpr qint64 kBeamLifetimeMs = 300;
static constexpr qint64 kToastLifetimeMs = 4000;
static constexpr qint64 kToastFadeStartMs = 3500;
static constexpr float kScrollSpeedTilesPerSec = 10.0f;
Simulation* m_sim;
const GameConfig* m_config;
const VisualsConfig* m_visuals;
TickDriver m_tickDriver;
QElapsedTimer m_frameTimer;
qint64 m_wallMs;
double m_gameSpeedMultiplier;
double m_prevNonZeroSpeed;
float m_scrollXTiles;
QTimer* m_renderTimer;
std::vector<ActiveBeam> m_activeBeams;
std::vector<ToastEntry> m_toasts;
std::optional<BuildingType> m_builderType;
Rotation m_ghostRotation;
QPoint m_ghostTile;
bool m_ghostValid;
std::set<QPoint, QPointCompare> m_beltDragTiles;
bool m_dragging;
bool m_demolishMode;
EntityId m_demolishHoverId;
std::vector<EntityId> m_selectedIds;
bool m_boxSelecting;
QPoint m_boxStartTile;
QPoint m_boxCurrentTile;
bool m_scrollLeft;
bool m_scrollRight;
bool m_gameOverShown;
};

72
src/ui/HeaderBar.cpp Normal file
View File

@@ -0,0 +1,72 @@
#include "HeaderBar.h"
#include <cmath>
#include <string>
#include <QHBoxLayout>
#include <QLabel>
#include <QPushButton>
#include <QSignalMapper>
#include "Tick.h"
const double HeaderBar::kSpeeds[] = { 0.0, 0.5, 1.0, 2.0, 4.0 };
const int HeaderBar::kSpeedCount = 5;
HeaderBar::HeaderBar(QWidget* parent)
: QWidget(parent)
{
QHBoxLayout* layout = new QHBoxLayout(this);
layout->setContentsMargins(8, 4, 8, 4);
layout->setSpacing(8);
m_timeLabel = new QLabel("00:00", this);
m_blocksLabel = new QLabel("Blocks: 0", this);
layout->addWidget(m_timeLabel);
layout->addWidget(m_blocksLabel);
layout->addStretch();
const char* labels[] = { "0x", "0.5x", "1x", "2x", "4x" };
QSignalMapper* mapper = new QSignalMapper(this);
for (int i = 0; i < kSpeedCount; ++i)
{
QPushButton* btn = new QPushButton(labels[i], this);
btn->setCheckable(true);
btn->setChecked(i == 2);
layout->addWidget(btn);
m_speedButtons.push_back(btn);
mapper->setMapping(btn, i);
connect(btn, SIGNAL(clicked()), mapper, SLOT(map()));
}
connect(mapper, SIGNAL(mapped(int)), this, SLOT(onSpeedButton(int)));
setFixedHeight(sizeHint().height());
}
void HeaderBar::onStateUpdated(Tick tick, int buildingBlocks, double gameSpeed)
{
const int totalSeconds = static_cast<int>(ticksToSeconds(tick));
const int minutes = totalSeconds / 60;
const int seconds = totalSeconds % 60;
m_timeLabel->setText(
QString("%1:%2")
.arg(minutes, 2, 10, QChar('0'))
.arg(seconds, 2, 10, QChar('0')));
m_blocksLabel->setText(QString("Blocks: %1").arg(buildingBlocks));
for (int i = 0; i < kSpeedCount; ++i)
{
const bool active = (std::abs(kSpeeds[i] - gameSpeed) < 0.001);
m_speedButtons[static_cast<std::size_t>(i)]->setChecked(active);
}
}
void HeaderBar::onSpeedButton(int index)
{
if (index >= 0 && index < kSpeedCount)
{
emit speedChanged(kSpeeds[index]);
}
}

35
src/ui/HeaderBar.h Normal file
View File

@@ -0,0 +1,35 @@
#pragma once
#include <vector>
#include <QWidget>
#include "Tick.h"
class QLabel;
class QPushButton;
class HeaderBar : public QWidget
{
Q_OBJECT
public:
explicit HeaderBar(QWidget* parent = nullptr);
public slots:
void onStateUpdated(Tick tick, int buildingBlocks, double gameSpeed);
signals:
void speedChanged(double multiplier);
private slots:
void onSpeedButton(int index);
private:
QLabel* m_timeLabel;
QLabel* m_blocksLabel;
std::vector<QPushButton*> m_speedButtons;
static const double kSpeeds[];
static const int kSpeedCount;
};

109
src/ui/MainWindow.cpp Normal file
View File

@@ -0,0 +1,109 @@
#include "MainWindow.h"
#include <QHBoxLayout>
#include <QMessageBox>
#include <QResizeEvent>
#include <QVBoxLayout>
#include "BuildButtonGrid.h"
#include "GameWorldView.h"
#include "HeaderBar.h"
#include "SelectedBuildingPanel.h"
#include "Simulation.h"
#include "Tick.h"
MainWindow::MainWindow(Simulation* sim, const GameConfig* config,
const VisualsConfig* visuals, QWidget* parent)
: QWidget(parent)
, m_sim(sim)
{
setWindowTitle("Dota Factory");
resize(1280, 768);
m_headerBar = new HeaderBar(this);
m_gameWorldView = new GameWorldView(sim, config, visuals, this);
m_bottomPanel = new QWidget(this);
QHBoxLayout* bottomLayout = new QHBoxLayout(m_bottomPanel);
bottomLayout->setContentsMargins(0, 0, 0, 0);
bottomLayout->setSpacing(0);
m_selectedBuildingPanel = new SelectedBuildingPanel(sim, config, m_bottomPanel);
m_buildButtonGrid = new BuildButtonGrid(config, m_bottomPanel);
bottomLayout->addWidget(m_selectedBuildingPanel, 1);
bottomLayout->addWidget(m_buildButtonGrid, 1);
// Signals: game world → other panels
connect(m_gameWorldView, SIGNAL(selectionChanged(std::vector<EntityId>)),
m_selectedBuildingPanel, SLOT(onSelectionChanged(std::vector<EntityId>)));
connect(m_gameWorldView, SIGNAL(stateUpdated(Tick, int, double)),
m_headerBar, SLOT(onStateUpdated(Tick, int, double)));
connect(m_gameWorldView, SIGNAL(stateUpdated(Tick, int, double)),
this, SLOT(onStateUpdated(Tick, int, double))); // for affordability
connect(m_gameWorldView, SIGNAL(gameOver()),
this, SLOT(onGameOver()));
// Signals: build grid → game world
connect(m_buildButtonGrid, SIGNAL(buildingTypeSelected(BuildingType)),
m_gameWorldView, SLOT(enterBuilderMode(BuildingType)));
connect(m_buildButtonGrid, SIGNAL(builderModeExited()),
m_gameWorldView, SLOT(exitBuilderMode()));
connect(m_gameWorldView, SIGNAL(builderModeExited()),
m_buildButtonGrid, SLOT(clearActiveButton()));
// Signals: header bar → game world
connect(m_headerBar, SIGNAL(speedChanged(double)),
m_gameWorldView, SLOT(setGameSpeed(double)));
m_gameWorldView->setFocus();
}
void MainWindow::resizeEvent(QResizeEvent* event)
{
QWidget::resizeEvent(event);
layoutPanels();
}
void MainWindow::layoutPanels()
{
const int totalW = width();
const int totalH = height();
const int headerH = m_headerBar->sizeHint().height();
if (headerH <= 0) { return; }
const int remaining = totalH - headerH;
const int gameH = remaining * 70 / 100;
const int panelH = remaining - gameH;
m_headerBar->setGeometry(0, 0, totalW, headerH);
m_gameWorldView->setGeometry(0, headerH, totalW, gameH);
m_bottomPanel->setGeometry(0, headerH + gameH, totalW, panelH);
}
void MainWindow::onStateUpdated(Tick /*tick*/, int blocks, double /*speed*/)
{
m_buildButtonGrid->updateAffordability(blocks);
}
void MainWindow::onGameOver()
{
const Tick tick = m_sim->currentTick();
const int totalSeconds = static_cast<int>(ticksToSeconds(tick));
const int minutes = totalSeconds / 60;
const int seconds = totalSeconds % 60;
QMessageBox box(this);
box.setWindowTitle("Game Over");
box.setText(QString("HQ destroyed!\nSurvival time: %1:%2")
.arg(minutes, 2, 10, QChar('0'))
.arg(seconds, 2, 10, QChar('0')));
box.addButton("Quit", QMessageBox::RejectRole);
box.exec();
close();
}

40
src/ui/MainWindow.h Normal file
View File

@@ -0,0 +1,40 @@
#pragma once
#include <QWidget>
#include "GameConfig.h"
#include "Tick.h"
#include "VisualsConfig.h"
class Simulation;
class GameWorldView;
class HeaderBar;
class SelectedBuildingPanel;
class BuildButtonGrid;
class QResizeEvent;
class MainWindow : public QWidget
{
Q_OBJECT
public:
MainWindow(Simulation* sim, const GameConfig* config,
const VisualsConfig* visuals, QWidget* parent = nullptr);
protected:
void resizeEvent(QResizeEvent* event) override;
private slots:
void onGameOver();
void onStateUpdated(Tick tick, int blocks, double speed);
private:
void layoutPanels();
Simulation* m_sim;
GameWorldView* m_gameWorldView;
HeaderBar* m_headerBar;
SelectedBuildingPanel* m_selectedBuildingPanel;
BuildButtonGrid* m_buildButtonGrid;
QWidget* m_bottomPanel;
};

View File

@@ -0,0 +1,289 @@
#include "SelectedBuildingPanel.h"
#include <cctype>
#include <map>
#include <string>
#include <QComboBox>
#include <QLabel>
#include <QPushButton>
#include <QVBoxLayout>
#include "Building.h"
#include "BuildingSystem.h"
#include "BuildingType.h"
#include "Simulation.h"
namespace
{
QString buildingTypeName(BuildingType type)
{
const std::string id = buildingTypeId(type);
QString result;
bool nextUpper = true;
for (char c : id)
{
if (c == '_')
{
result += ' ';
nextUpper = true;
}
else if (nextUpper)
{
result += static_cast<char>(std::toupper(static_cast<unsigned char>(c)));
nextUpper = false;
}
else
{
result += c;
}
}
return result;
}
bool isProductionBuilding(BuildingType type)
{
return type == BuildingType::Miner
|| type == BuildingType::Smelter
|| type == BuildingType::Assembler
|| type == BuildingType::ReprocessingPlant
|| type == BuildingType::Shipyard;
}
bool isBeltLike(BuildingType type)
{
return type == BuildingType::Belt || type == BuildingType::Splitter;
}
} // namespace
SelectedBuildingPanel::SelectedBuildingPanel(Simulation* sim,
const GameConfig* config,
QWidget* parent)
: QWidget(parent)
, m_sim(sim)
, m_config(config)
, m_singleId(kInvalidEntityId)
{
m_layout = new QVBoxLayout(this);
m_layout->setContentsMargins(8, 8, 8, 8);
m_layout->setSpacing(4);
m_layout->setAlignment(Qt::AlignTop);
m_titleLabel = new QLabel(this);
m_recipeCombo = new QComboBox(this);
m_clearBeltBtn = new QPushButton("Clear Items", this);
m_buffersLabel = new QLabel(this);
m_buffersLabel->setWordWrap(true);
m_layout->addWidget(m_titleLabel);
m_layout->addWidget(m_recipeCombo);
m_layout->addWidget(m_clearBeltBtn);
m_layout->addWidget(m_buffersLabel);
connect(m_recipeCombo, SIGNAL(currentIndexChanged(int)),
this, SLOT(onRecipeChanged(int)));
connect(m_clearBeltBtn, SIGNAL(clicked()),
this, SLOT(onClearBelt()));
buildEmpty();
}
void SelectedBuildingPanel::onSelectionChanged(const std::vector<EntityId>& ids)
{
m_selection = ids;
rebuild();
}
void SelectedBuildingPanel::rebuild()
{
if (m_selection.empty())
{
buildEmpty();
}
else if (m_selection.size() == 1)
{
buildSingle(m_selection[0]);
}
else
{
buildMulti(m_selection);
}
}
void SelectedBuildingPanel::buildEmpty()
{
m_singleId = kInvalidEntityId;
m_titleLabel->hide();
m_recipeCombo->hide();
m_clearBeltBtn->hide();
m_buffersLabel->hide();
}
void SelectedBuildingPanel::buildSingle(EntityId id)
{
m_singleId = id;
const Building* b = m_sim->buildings().findBuilding(id);
if (!b)
{
buildEmpty();
return;
}
m_titleLabel->setText(buildingTypeName(b->type));
m_titleLabel->show();
m_buffersLabel->show();
if (isProductionBuilding(b->type))
{
m_recipeCombo->blockSignals(true);
m_recipeCombo->clear();
m_recipeCombo->addItem("(none)", QString());
if (b->type == BuildingType::Shipyard)
{
for (const ShipDef& def : m_config->ships.ships)
{
if (m_sim->isBlueprintUnlocked(def.id))
{
m_recipeCombo->addItem(
QString::fromStdString(def.id),
QString::fromStdString(def.id));
}
}
}
else
{
for (const RecipeDef& recipe : m_config->recipes.recipes)
{
if (recipe.building == b->type)
{
m_recipeCombo->addItem(
QString::fromStdString(recipe.id),
QString::fromStdString(recipe.id));
}
}
}
const int currentIdx = m_recipeCombo->findData(QString::fromStdString(b->recipeId));
m_recipeCombo->setCurrentIndex(currentIdx >= 0 ? currentIdx : 0);
m_recipeCombo->blockSignals(false);
m_recipeCombo->show();
}
else
{
m_recipeCombo->hide();
}
if (isBeltLike(b->type))
{
m_clearBeltBtn->show();
}
else
{
m_clearBeltBtn->hide();
}
QString bufText;
if (!b->inputBuffer.counts.empty())
{
bufText += "Input: ";
for (const std::pair<const ItemType, int>& entry : b->inputBuffer.counts)
{
const std::map<ItemType, int>::const_iterator cap =
b->inputBuffer.caps.find(entry.first);
const int capVal = (cap != b->inputBuffer.caps.end()) ? cap->second : 0;
bufText += QString::fromStdString(entry.first.id)
+ ": " + QString::number(entry.second)
+ "/" + QString::number(capVal) + " ";
}
bufText += "\n";
}
if (!b->outputBuffer.items.empty())
{
std::map<std::string, int> outCounts;
for (const Item& item : b->outputBuffer.items)
{
outCounts[item.type.id]++;
}
bufText += "Output(" + QString::number(static_cast<int>(b->outputBuffer.items.size()))
+ "/" + QString::number(b->outputBuffer.capacity) + "): ";
for (const std::pair<const std::string, int>& entry : outCounts)
{
bufText += QString::fromStdString(entry.first)
+ ":" + QString::number(entry.second) + " ";
}
}
m_buffersLabel->setText(bufText);
}
void SelectedBuildingPanel::buildMulti(const std::vector<EntityId>& ids)
{
m_singleId = kInvalidEntityId;
m_recipeCombo->hide();
m_clearBeltBtn->hide();
m_buffersLabel->hide();
std::map<BuildingType, int> counts;
for (EntityId id : ids)
{
const Building* b = m_sim->buildings().findBuilding(id);
if (b)
{
counts[b->type]++;
}
}
bool hasBelt = false;
QString text;
for (const std::pair<const BuildingType, int>& entry : counts)
{
text += buildingTypeName(entry.first) + ": "
+ QString::number(entry.second) + "\n";
if (isBeltLike(entry.first))
{
hasBelt = true;
}
}
m_titleLabel->setText(text.trimmed());
m_titleLabel->show();
if (hasBelt)
{
m_clearBeltBtn->show();
}
}
void SelectedBuildingPanel::onRecipeChanged(int comboIndex)
{
if (m_singleId == kInvalidEntityId)
{
return;
}
const QString recipeId = m_recipeCombo->itemData(comboIndex).toString();
m_sim->buildings().setRecipe(m_singleId, recipeId.toStdString());
}
void SelectedBuildingPanel::onClearBelt()
{
std::vector<QPoint> tiles;
for (EntityId id : m_selection)
{
const Building* b = m_sim->buildings().findBuilding(id);
if (b && isBeltLike(b->type))
{
for (const QPoint& cell : b->bodyCells)
{
tiles.push_back(cell);
}
}
}
if (!tiles.empty())
{
m_sim->belts().clearTiles(tiles);
}
}

View File

@@ -0,0 +1,51 @@
#pragma once
#include <string>
#include <vector>
#include <QWidget>
#include "EntityId.h"
#include "GameConfig.h"
class Simulation;
class QLabel;
class QComboBox;
class QPushButton;
class QVBoxLayout;
class SelectedBuildingPanel : public QWidget
{
Q_OBJECT
public:
SelectedBuildingPanel(Simulation* sim, const GameConfig* config,
QWidget* parent = nullptr);
public slots:
void onSelectionChanged(const std::vector<EntityId>& ids);
private slots:
void onRecipeChanged(int comboIndex);
void onClearBelt();
private:
void rebuild();
void clearContent();
void buildEmpty();
void buildSingle(EntityId id);
void buildMulti(const std::vector<EntityId>& ids);
Simulation* m_sim;
const GameConfig* m_config;
std::vector<EntityId> m_selection;
QVBoxLayout* m_layout;
QLabel* m_titleLabel;
QComboBox* m_recipeCombo;
QPushButton* m_clearBeltBtn;
QLabel* m_buffersLabel;
EntityId m_singleId;
std::string m_currentRecipeId;
};

78
src/ui/VisualsConfig.h Normal file
View File

@@ -0,0 +1,78 @@
#pragma once
#include <map>
#include <string>
#include <QColor>
#include <QString>
#include "BuildingType.h"
struct TileVisuals
{
QColor fill;
};
struct BuildingVisuals
{
QColor fill;
QColor outline;
QString glyph;
};
struct ItemVisuals
{
QColor fill;
QColor outline;
};
struct ShipVisuals
{
QColor fill;
QColor outline;
};
struct BeamVisuals
{
QColor color;
int widthPx;
};
struct OverlayVisuals
{
QColor ghostValid;
QColor ghostInvalid;
QColor demolishTint;
QColor selectionRect;
QColor tileHighlight;
QColor selectedOutline;
};
struct ToastVisuals
{
QColor bg;
QColor fg;
int fontSize;
};
enum class ShipRole
{
PlayerCombat,
Salvage,
Repair,
Enemy,
};
struct VisualsConfig
{
TileVisuals asteroid;
TileVisuals space;
std::map<BuildingType, BuildingVisuals> buildings;
std::map<std::string, ItemVisuals> items;
std::map<ShipRole, ShipVisuals> ships;
BeamVisuals beams;
OverlayVisuals overlays;
ToastVisuals toast;
};

231
src/ui/VisualsLoader.cpp Normal file
View File

@@ -0,0 +1,231 @@
#include "VisualsLoader.h"
#include <sstream>
#include <stdexcept>
#include <string>
#include <QColor>
#include "toml.hpp"
#include "BuildingType.h"
namespace
{
std::runtime_error makeError(const std::string& section, const std::string& why)
{
return std::runtime_error("visuals.toml: [" + section + "] " + why);
}
QColor parseColor(const std::string& s, const std::string& ctx)
{
if (s.empty() || s[0] != '#')
{
throw std::runtime_error("visuals.toml: invalid color '" + s + "' in " + ctx);
}
if (s.size() == 9)
{
bool ok1 = true, ok2 = true, ok3 = true, ok4 = true;
int r = QString::fromStdString(s.substr(1, 2)).toInt(&ok1, 16);
int g = QString::fromStdString(s.substr(3, 2)).toInt(&ok2, 16);
int b = QString::fromStdString(s.substr(5, 2)).toInt(&ok3, 16);
int a = QString::fromStdString(s.substr(7, 2)).toInt(&ok4, 16);
if (!ok1 || !ok2 || !ok3 || !ok4)
{
throw std::runtime_error("visuals.toml: malformed color '" + s + "' in " + ctx);
}
return QColor(r, g, b, a);
}
QColor c(QString::fromStdString(s));
if (!c.isValid())
{
throw std::runtime_error("visuals.toml: invalid color '" + s + "' in " + ctx);
}
return c;
}
std::string requireString(toml::table& tbl, const std::string& key,
const std::string& ctx)
{
const std::optional<std::string> v = tbl[key].value<std::string>();
if (!v)
{
throw makeError(ctx, "missing or invalid string '" + key + "'");
}
return *v;
}
int requireInt(toml::table& tbl, const std::string& key, const std::string& ctx)
{
const std::optional<int64_t> v = tbl[key].value<int64_t>();
if (!v)
{
throw makeError(ctx, "missing or invalid integer '" + key + "'");
}
return static_cast<int>(*v);
}
toml::table& requireSubtable(toml::table& tbl, const std::string& key,
const std::string& ctx)
{
toml::table* sub = tbl[key].as_table();
if (sub == nullptr)
{
throw makeError(ctx, "missing section '" + key + "'");
}
return *sub;
}
TileVisuals parseTile(toml::table& tbl, const std::string& ctx)
{
TileVisuals v;
v.fill = parseColor(requireString(tbl, "fill", ctx), ctx + ".fill");
return v;
}
BuildingVisuals parseBuilding(toml::table& tbl, const std::string& ctx)
{
BuildingVisuals v;
v.fill = parseColor(requireString(tbl, "fill", ctx), ctx + ".fill");
v.outline = parseColor(requireString(tbl, "outline", ctx), ctx + ".outline");
v.glyph = QString::fromStdString(requireString(tbl, "glyph", ctx));
return v;
}
ItemVisuals parseItem(toml::table& tbl, const std::string& ctx)
{
ItemVisuals v;
v.fill = parseColor(requireString(tbl, "fill", ctx), ctx + ".fill");
v.outline = parseColor(requireString(tbl, "outline", ctx), ctx + ".outline");
return v;
}
ShipVisuals parseShip(toml::table& tbl, const std::string& ctx)
{
ShipVisuals v;
v.fill = parseColor(requireString(tbl, "fill", ctx), ctx + ".fill");
v.outline = parseColor(requireString(tbl, "outline", ctx), ctx + ".outline");
return v;
}
struct BuildingEntry
{
const char* key;
BuildingType type;
};
const BuildingEntry kBuildingEntries[] = {
{ "hq", BuildingType::Hq },
{ "miner", BuildingType::Miner },
{ "smelter", BuildingType::Smelter },
{ "assembler", BuildingType::Assembler },
{ "reprocessing_plant",BuildingType::ReprocessingPlant },
{ "shipyard", BuildingType::Shipyard },
{ "salvage_bay", BuildingType::SalvageBay },
{ "belt", BuildingType::Belt },
{ "splitter", BuildingType::Splitter },
};
} // namespace
VisualsConfig VisualsLoader::load(const std::string& path)
{
toml::table tbl;
try
{
tbl = toml::parse_file(path);
}
catch (const toml::parse_error& e)
{
std::ostringstream oss;
oss << "visuals.toml: parse error: " << e.description()
<< " at " << e.source().begin;
throw std::runtime_error(oss.str());
}
VisualsConfig cfg;
// Tiles
{
toml::table& tiles = requireSubtable(tbl, "tiles", "root");
cfg.asteroid = parseTile(requireSubtable(tiles, "asteroid", "tiles"), "tiles.asteroid");
cfg.space = parseTile(requireSubtable(tiles, "space", "tiles"), "tiles.space");
}
// Buildings ([buildings.*] sections)
{
toml::table& bldgs = requireSubtable(tbl, "buildings", "root");
for (const BuildingEntry& entry : kBuildingEntries)
{
std::string ctx = std::string("buildings.") + entry.key;
cfg.buildings[entry.type] = parseBuilding(
requireSubtable(bldgs, entry.key, "buildings"), ctx);
}
}
// Stations ([stations.*] → mapped as PlayerDefenceStation / EnemyDefenceStation)
{
toml::table& stns = requireSubtable(tbl, "stations", "root");
cfg.buildings[BuildingType::PlayerDefenceStation] = parseBuilding(
requireSubtable(stns, "player", "stations"), "stations.player");
cfg.buildings[BuildingType::EnemyDefenceStation] = parseBuilding(
requireSubtable(stns, "enemy", "stations"), "stations.enemy");
}
// Items (iterate all keys in [items])
{
toml::table& items = requireSubtable(tbl, "items", "root");
for (toml::table::iterator it = items.begin(); it != items.end(); ++it)
{
std::string itemId = std::string(it->first.str());
toml::table* sub = it->second.as_table();
if (sub == nullptr)
{
throw std::runtime_error("visuals.toml: items." + itemId + " is not a table");
}
cfg.items[itemId] = parseItem(*sub, "items." + itemId);
}
}
// Ships
{
toml::table& ships = requireSubtable(tbl, "ships", "root");
cfg.ships[ShipRole::PlayerCombat] = parseShip(
requireSubtable(ships, "player_combat", "ships"), "ships.player_combat");
cfg.ships[ShipRole::Salvage] = parseShip(
requireSubtable(ships, "salvage", "ships"), "ships.salvage");
cfg.ships[ShipRole::Repair] = parseShip(
requireSubtable(ships, "repair", "ships"), "ships.repair");
cfg.ships[ShipRole::Enemy] = parseShip(
requireSubtable(ships, "enemy", "ships"), "ships.enemy");
}
// Beams
{
toml::table& beams = requireSubtable(tbl, "beams", "root");
cfg.beams.color = parseColor(requireString(beams, "color", "beams"), "beams.color");
cfg.beams.widthPx = requireInt(beams, "width_px", "beams");
}
// Overlays
{
toml::table& ov = requireSubtable(tbl, "overlays", "root");
cfg.overlays.ghostValid = parseColor(requireString(ov, "ghost_valid", "overlays"), "overlays.ghost_valid");
cfg.overlays.ghostInvalid = parseColor(requireString(ov, "ghost_invalid", "overlays"), "overlays.ghost_invalid");
cfg.overlays.demolishTint = parseColor(requireString(ov, "demolish_tint", "overlays"), "overlays.demolish_tint");
cfg.overlays.selectionRect = parseColor(requireString(ov, "selection_rect", "overlays"), "overlays.selection_rect");
cfg.overlays.tileHighlight = parseColor(requireString(ov, "tile_highlight", "overlays"), "overlays.tile_highlight");
cfg.overlays.selectedOutline = parseColor(requireString(ov, "selected_outline", "overlays"), "overlays.selected_outline");
}
// Toast
{
toml::table& t = requireSubtable(tbl, "toast", "root");
cfg.toast.bg = parseColor(requireString(t, "bg", "toast"), "toast.bg");
cfg.toast.fg = parseColor(requireString(t, "fg", "toast"), "toast.fg");
cfg.toast.fontSize = requireInt(t, "font_size", "toast");
}
return cfg;
}

11
src/ui/VisualsLoader.h Normal file
View File

@@ -0,0 +1,11 @@
#pragma once
#include <string>
#include "VisualsConfig.h"
class VisualsLoader
{
public:
static VisualsConfig load(const std::string& path);
};