From 9d0a60a93bb3a41b699792cf5923d98153c3213c Mon Sep 17 00:00:00 2001 From: mlangkabel Date: Mon, 1 Jun 2026 22:57:53 +0200 Subject: [PATCH] define ship roles via added modules and allow multiple weapons --- bin/app/data/config/modules.toml | 45 ++- bin/app/data/config/ships.toml | 26 +- bin/app/data/config/visuals.toml | 21 +- bin/balancing/data/balancing.toml | 12 +- bin/test/data/config/modules.toml | 45 ++- bin/test/data/config/ships.toml | 20 +- src/balancing/ArenaSimulation.cpp | 18 +- src/balancing/ArenaView.cpp | 22 +- src/lib/config/ConfigLoader.cpp | 145 ++++++--- src/lib/config/ModulesConfig.h | 25 ++ src/lib/config/ShipsConfig.h | 31 +- src/lib/core/EntityAdmin.cpp | 5 + src/lib/core/EntityAdmin.h | 4 + src/lib/ecs/component/ModuleOwnerComponent.h | 9 + .../ecs/component/RepairBehaviorComponent.h | 1 + .../ecs/component/SalvageBehaviorComponent.h | 3 +- src/lib/ecs/system/AiSystem.cpp | 112 +++++-- src/lib/ecs/system/AiSystem.h | 1 + src/lib/ecs/system/CombatSystem.cpp | 30 +- src/lib/ecs/system/ShipSystem.cpp | 296 ++++++++++++------ src/lib/sim/Simulation.cpp | 39 ++- src/lib/sim/WaveSystem.cpp | 23 +- src/lib/sim/WaveSystem.h | 10 +- src/test/BehaviorSystemTest.cpp | 79 ++++- src/test/CombatSystemTest.cpp | 29 +- src/test/ConfigLoaderTest.cpp | 12 +- src/test/ShipTest.cpp | 136 ++++++-- src/test/WaveSystemTest.cpp | 19 +- src/ui/GameWorldView.cpp | 31 +- src/ui/VisualsConfig.h | 10 +- src/ui/VisualsLoader.cpp | 21 +- 31 files changed, 873 insertions(+), 407 deletions(-) create mode 100644 src/lib/ecs/component/ModuleOwnerComponent.h diff --git a/bin/app/data/config/modules.toml b/bin/app/data/config/modules.toml index b2953f4..985be91 100644 --- a/bin/app/data/config/modules.toml +++ b/bin/app/data/config/modules.toml @@ -34,7 +34,7 @@ threat_cost = 3.0 fill_color = "#FF4040" glyph = "W" -[module.combat] +[module.weapon] multiplied_damage_formula = "1.0 + 0.15 * x" [[module]] @@ -49,3 +49,46 @@ glyph = "E" [module.movement] added_speed_formula = "0.5 * x" + +[[module]] +id = "laser_cannon" +surface_mask = ["O"] +materials = [{item = "iron_ingot", amount = 1}] +player_production_level = 1 +production_time_seconds = 5 +threat_cost = 5.0 +fill_color = "#FF8040" +glyph = "L" + +[module.weapon] +damage_formula = "2" +attack_range_formula = "5" +attack_rate_formula = "2.0" + +[[module]] +id = "salvage_bay_module" +surface_mask = ["OO"] +materials = [{item = "iron_ingot", amount = 2}] +player_production_level = 1 +production_time_seconds = 5 +threat_cost = 0.0 +fill_color = "#AACC44" +glyph = "Sv" + +[module.salvage] +collection_range_formula = "50" +cargo_capacity_formula = "10" + +[[module]] +id = "repair_tool_module" +surface_mask = ["O"] +materials = [{item = "circuit_board", amount = 2}] +player_production_level = 1 +production_time_seconds = 5 +threat_cost = 0.0 +fill_color = "#66CCFF" +glyph = "Rp" + +[module.repair] +repair_rate_formula = "5 + x" +repair_range_formula = "80" diff --git a/bin/app/data/config/ships.toml b/bin/app/data/config/ships.toml index ea37456..858c265 100644 --- a/bin/app/data/config/ships.toml +++ b/bin/app/data/config/ships.toml @@ -2,6 +2,7 @@ id = "fighter" available_from_start = true layout = ["XOX", "OOO", "XOX"] +default_modules = [{type = "laser_cannon", x = 1, y = 1, rotation = "east"}] [ship.schematic] materials = [{item = "iron_ingot", amount = 3}, {item = "circuit_board", amount = 1}] @@ -24,11 +25,6 @@ max_rotation_speed_formula = "6.28" [ship.sensor] sensor_range_formula = "15" -[ship.combat] -damage_formula = "2" -attack_range_formula = "5" -attack_rate_formula = "2.0" - [ship.loot] scrap_drop = 2 @@ -38,6 +34,7 @@ scrap_drop = 2 id = "sniper" available_from_start = true layout = ["XOOX", "OOOO", "XOOX"] +default_modules = [{type = "laser_cannon", x = 1, y = 1, rotation = "east"}] [ship.schematic] materials = [{item = "iron_ingot", amount = 3}, {item = "circuit_board", amount = 1}] @@ -60,11 +57,6 @@ max_rotation_speed_formula = "3.14" [ship.sensor] sensor_range_formula = "25" -[ship.combat] -damage_formula = "10" -attack_range_formula = "20" -attack_rate_formula = "0.5" - [ship.loot] scrap_drop = 2 @@ -74,6 +66,7 @@ scrap_drop = 2 id = "gunship" available_from_start = true layout = ["XOOOX", "OOOOO", "OOOOO", "XOOOX"] +default_modules = [{type = "laser_cannon", x = 1, y = 1, rotation = "east"}] [ship.schematic] materials = [{item = "iron_ingot", amount = 3}, {item = "circuit_board", amount = 1}] @@ -96,11 +89,6 @@ max_rotation_speed_formula = "3.14" [ship.sensor] sensor_range_formula = "20" -[ship.combat] -damage_formula = "1" -attack_range_formula = "10" -attack_rate_formula = "5" - [ship.loot] scrap_drop = 2 @@ -132,10 +120,6 @@ max_rotation_speed_formula = "6.28" [ship.sensor] sensor_range_formula = "250" -[ship.salvage] -collection_range = 50 -cargo_capacity = 10 - [ship.loot] scrap_drop = 2 @@ -166,9 +150,5 @@ max_rotation_speed_formula = "6.28" [ship.sensor] sensor_range_formula = "250" -[ship.repair] -repair_rate_formula = "5 + x" -repair_range_formula = "80" - [ship.loot] scrap_drop = 2 diff --git a/bin/app/data/config/visuals.toml b/bin/app/data/config/visuals.toml index b1b6351..c14fb83 100644 --- a/bin/app/data/config/visuals.toml +++ b/bin/app/data/config/visuals.toml @@ -141,26 +141,29 @@ outline = "#201a14" # ----------------------------------------------------------------------------- # Ships # -# Ships are drawn as oriented triangles/arrows. Color is keyed to role, not -# schematic (architecture.md, "Layer Order"). +# Ships are drawn as oriented triangles/arrows. Color is keyed to schematic id. # ----------------------------------------------------------------------------- -[ships.player_combat] +[ships.fighter] fill = "#3366ff" outline = "#ffffff" -[ships.salvage] +[ships.sniper] +fill = "#3366ff" +outline = "#ffffff" + +[ships.gunship] +fill = "#3366ff" +outline = "#ffffff" + +[ships.salvage_ship] fill = "#33cc66" outline = "#ffffff" -[ships.repair] +[ships.repair_ship] fill = "#66ccff" outline = "#ffffff" -[ships.enemy] -fill = "#cc3333" -outline = "#ffffff" - # ----------------------------------------------------------------------------- # Laser beams (REQ-SHP-FIRING-BEAM) # ----------------------------------------------------------------------------- diff --git a/bin/balancing/data/balancing.toml b/bin/balancing/data/balancing.toml index 7264064..122d0e1 100644 --- a/bin/balancing/data/balancing.toml +++ b/bin/balancing/data/balancing.toml @@ -12,8 +12,9 @@ enemy_buffer_width = 10 level = 1 count = 5 modules = [ + {type = "laser_cannon", x = 1, y = 1, rotation = "east"}, {type = "weapon_upgrade", x = 0, y = 1, rotation = "east"}, - {type = "sensor_booster", x = 1, y = 0, rotation = "east"}, + {type = "sensor_booster", x = 2, y = 1, rotation = "east"}, ] [[arena.team]] @@ -23,6 +24,7 @@ enemy_buffer_width = 10 level = 1 count = 1 modules = [ + {type = "laser_cannon", x = 1, y = 1, rotation = "east"}, {type = "armor_plate", x = 1, y = 0, rotation = "east"}, {type = "weapon_upgrade", x = 1, y = 2, rotation = "east"}, ] @@ -42,6 +44,7 @@ enemy_buffer_width = 10 level = 1 count = 1 modules = [ + {type = "laser_cannon", x = 1, y = 1, rotation = "east"}, {type = "armor_plate", x = 1, y = 0, rotation = "east"}, {type = "sensor_booster", x = 0, y = 1, rotation = "east"}, ] @@ -53,6 +56,7 @@ enemy_buffer_width = 10 level = 1 count = 1 modules = [ + {type = "laser_cannon", x = 2, y = 1, rotation = "east"}, {type = "armor_plate", x = 1, y = 0, rotation = "east"}, {type = "weapon_upgrade", x = 3, y = 1, rotation = "east"}, {type = "engine_booster", x = 0, y = 1, rotation = "east"}, @@ -73,6 +77,7 @@ enemy_buffer_width = 10 level = 1 count = 1 modules = [ + {type = "laser_cannon", x = 2, y = 2, rotation = "east"}, {type = "armor_plate", x = 1, y = 0, rotation = "east"}, {type = "weapon_upgrade", x = 3, y = 2, rotation = "east"}, {type = "engine_booster", x = 0, y = 1, rotation = "east"}, @@ -85,6 +90,7 @@ enemy_buffer_width = 10 level = 1 count = 5 modules = [ + {type = "laser_cannon", x = 1, y = 1, rotation = "east"}, {type = "engine_booster", x = 1, y = 0, rotation = "east"}, {type = "sensor_booster", x = 2, y = 1, rotation = "east"}, ] @@ -104,7 +110,8 @@ enemy_buffer_width = 15 level = 1 count = 3 modules = [ - {type = "weapon_upgrade", x = 1, y = 1, rotation = "east"}, + {type = "laser_cannon", x = 1, y = 1, rotation = "east"}, + {type = "weapon_upgrade", x = 2, y = 1, rotation = "east"}, {type = "sensor_booster", x = 1, y = 0, rotation = "east"}, ] [[arena.team.station]] @@ -125,5 +132,6 @@ enemy_buffer_width = 15 level = 1 count = 8 modules = [ + {type = "laser_cannon", x = 1, y = 1, rotation = "east"}, {type = "engine_booster", x = 1, y = 0, rotation = "east"}, ] diff --git a/bin/test/data/config/modules.toml b/bin/test/data/config/modules.toml index 631295a..c39863d 100644 --- a/bin/test/data/config/modules.toml +++ b/bin/test/data/config/modules.toml @@ -34,5 +34,48 @@ threat_cost = 3.0 fill_color = "#FF4040" glyph = "W" -[module.combat] +[module.weapon] multiplied_damage_formula = "1.2" + +[[module]] +id = "laser_cannon" +surface_mask = ["O"] +materials = [{item = "iron_ingot", amount = 1}] +player_production_level = 1 +production_time_seconds = 5 +threat_cost = 5.0 +fill_color = "#FF8040" +glyph = "L" + +[module.weapon] +damage_formula = "2" +attack_range_formula = "5" +attack_rate_formula = "2.0" + +[[module]] +id = "salvage_bay_module" +surface_mask = ["OO"] +materials = [{item = "iron_ingot", amount = 2}] +player_production_level = 1 +production_time_seconds = 5 +threat_cost = 0.0 +fill_color = "#AACC44" +glyph = "Sv" + +[module.salvage] +collection_range_formula = "50" +cargo_capacity_formula = "10" + +[[module]] +id = "repair_tool_module" +surface_mask = ["O"] +materials = [{item = "circuit_board", amount = 2}] +player_production_level = 1 +production_time_seconds = 5 +threat_cost = 0.0 +fill_color = "#66CCFF" +glyph = "Rp" + +[module.repair] +repair_rate_formula = "5 + x" +repair_range_formula = "80" diff --git a/bin/test/data/config/ships.toml b/bin/test/data/config/ships.toml index d9ec563..080c8ad 100644 --- a/bin/test/data/config/ships.toml +++ b/bin/test/data/config/ships.toml @@ -2,6 +2,7 @@ id = "interceptor" available_from_start = true layout = ["XOX", "OOO", "XOX"] +default_modules = [{type = "laser_cannon", x = 1, y = 1, rotation = "east"}] [ship.schematic] materials = [{item = "iron_ingot", amount = 3}, {item = "circuit_board", amount = 1}] @@ -24,11 +25,6 @@ max_rotation_speed_formula = "100000" [ship.sensor] sensor_range_formula = "200" -[ship.combat] -damage_formula = "10 + 2*x" -attack_range_formula = "150" -attack_rate_formula = "2.0" - [ship.loot] scrap_drop = 2 @@ -37,6 +33,7 @@ scrap_drop = 2 id = "destroyer" available_from_start = true layout = ["XOOX", "OOOO", "XOOX"] +default_modules = [{type = "laser_cannon", x = 1, y = 1, rotation = "east"}] [ship.schematic] materials = [{item = "iron_ingot", amount = 5}, {item = "circuit_board", amount = 2}] @@ -59,11 +56,6 @@ max_rotation_speed_formula = "100000" [ship.sensor] sensor_range_formula = "300" -[ship.combat] -damage_formula = "12 + 2*x" -attack_range_formula = "250" -attack_rate_formula = "1.0" - [ship.loot] scrap_drop = 4 @@ -94,10 +86,6 @@ max_rotation_speed_formula = "100000" [ship.sensor] sensor_range_formula = "250" -[ship.salvage] -collection_range = 50 -cargo_capacity = 10 - [ship.loot] scrap_drop = 2 @@ -128,9 +116,5 @@ max_rotation_speed_formula = "100000" [ship.sensor] sensor_range_formula = "250" -[ship.repair] -repair_rate_formula = "5 + x" -repair_range_formula = "80" - [ship.loot] scrap_drop = 2 diff --git a/src/balancing/ArenaSimulation.cpp b/src/balancing/ArenaSimulation.cpp index bf252c3..4927d3c 100644 --- a/src/balancing/ArenaSimulation.cpp +++ b/src/balancing/ArenaSimulation.cpp @@ -14,6 +14,7 @@ #include "EntityAdmin.h" #include "FactionComponent.h" #include "HealthComponent.h" +#include "ModuleOwnerComponent.h" #include "MovementIntentSystem.h" #include "PositionComponent.h" #include "ScrapSystem.h" @@ -159,7 +160,12 @@ void ArenaSimulation::placeStructures() } const entt::entity stationEntity = m_admin.spawnStation( anchor, parsed.footprint, absCells, hp, hp, isEnemy); - m_admin.addComponent(stationEntity, weapon); + { + entt::entity wChild = m_admin.createModuleEntity(); + m_admin.addComponent(wChild, weapon); + m_admin.addComponent(wChild, + ModuleOwnerComponent{stationEntity}); + } m_buildingSystem->registerTileOccupancy(absCells, allocateBuildingId()); }; @@ -247,6 +253,7 @@ void ArenaSimulation::tick() m_aiSystem->tickHomeReturnBehavior(m_admin); m_aiSystem->tickThreatResponseBehavior(m_admin, *m_buildingSystem); m_aiSystem->tickRepairBehavior(m_admin, *m_buildingSystem); + m_aiSystem->tickRepairTools(m_admin); m_aiSystem->tickSalvageBehavior(m_admin, *m_scrapSystem, *m_buildingSystem); // Combat resolution (tick step 8). @@ -320,6 +327,15 @@ void ArenaSimulation::tickDeaths() { const StationBodyComponent& sb = m_admin.get(deadEntity); m_buildingSystem->unregisterTileOccupancy(sb.bodyCells); + { + std::vector stationChildren; + m_admin.forEach( + [&](entt::entity ce, const ModuleOwnerComponent& o) + { + if (o.owner == deadEntity) { stationChildren.push_back(ce); } + }); + for (entt::entity ce : stationChildren) { m_admin.destroy(ce); } + } m_admin.destroy(deadEntity); } diff --git a/src/balancing/ArenaView.cpp b/src/balancing/ArenaView.cpp index 3b7c161..71198b3 100644 --- a/src/balancing/ArenaView.cpp +++ b/src/balancing/ArenaView.cpp @@ -14,23 +14,12 @@ #include "FactionComponent.h" #include "HealthComponent.h" #include "PositionComponent.h" -#include "RepairToolComponent.h" -#include "SalvageCargoComponent.h" #include "ScrapSystem.h" #include "ShipIdentityComponent.h" #include "StationBodyComponent.h" namespace { - -ShipRole shipRoleFromComponents(bool isEnemy, bool hasCargo, bool hasRepairTool) -{ - if (isEnemy) { return ShipRole::Enemy; } - if (hasCargo) { return ShipRole::Salvage; } - if (hasRepairTool) { return ShipRole::Repair; } - return ShipRole::PlayerCombat; -} - } // namespace @@ -322,15 +311,12 @@ void ArenaView::drawShips(QPainter& painter) { m_sim->admin().forEach( - [&](entt::entity e, const ShipIdentityComponent& /*si*/, + [&](entt::entity /*e*/, const ShipIdentityComponent& si, const PositionComponent& pos, const FacingComponent& facing, - const FactionComponent& fac) + const FactionComponent& /*fac*/) { - const bool hasCargo = m_sim->admin().hasAll(e); - const bool hasRepair = m_sim->admin().hasAll(e); - const ShipRole role = shipRoleFromComponents(fac.isEnemy, hasCargo, hasRepair); - const std::map::const_iterator it = - m_visuals->ships.find(role); + const std::map::const_iterator it = + m_visuals->ships.find(si.schematicId); if (it == m_visuals->ships.end()) { return; } const QPointF center = worldToWidget(pos.value); diff --git a/src/lib/config/ConfigLoader.cpp b/src/lib/config/ConfigLoader.cpp index 8835b9a..fc3837c 100644 --- a/src/lib/config/ConfigLoader.cpp +++ b/src/lib/config/ConfigLoader.cpp @@ -7,8 +7,13 @@ #include #include +#include + #include "toml.hpp" +#include "Rotation.h" +#include "ShipLayout.h" + namespace { @@ -207,6 +212,42 @@ toml::table parseFile(const std::string& path, const std::string& file) } } +Rotation parseRotationString(const std::string& s) +{ + if (s == "east") { return Rotation::East; } + if (s == "south") { return Rotation::South; } + if (s == "west") { return Rotation::West; } + return Rotation::North; +} + +std::vector parsePlacedModules(const toml::array& arr, + const std::string& file, + const std::string& path) +{ + std::vector result; + result.reserve(arr.size()); + for (std::size_t i = 0; i < arr.size(); ++i) + { + const std::string elemPath = path + "[" + std::to_string(i) + "]"; + const toml::table* t = arr[i].as_table(); + if (t == nullptr) { continue; } + toml::table& mt = const_cast(*t); + + const std::optional type = mt["type"].value(); + const std::optional x = mt["x"].value(); + const std::optional y = mt["y"].value(); + const std::optional rot = mt["rotation"].value(); + if (!type || !x || !y || !rot) { continue; } + + PlacedModule pm; + pm.moduleId = *type; + pm.position = QPoint(static_cast(*x), static_cast(*y)); + pm.rotation = parseRotationString(*rot); + result.push_back(std::move(pm)); + } + return result; +} + } // namespace @@ -418,43 +459,13 @@ ShipsConfig ConfigLoader::loadShips(const std::string& path) def.loot.scrapDrop = static_cast(requireInt(lMt["scrap_drop"], file, lPath + ".scrap_drop")); } - // Optional: combat - if (mt.contains("combat")) + // Optional: default_modules (REQ-WAV-DEFAULT-MODULES) + if (mt.contains("default_modules")) { - const std::string cPath = elemPath + ".combat"; - const toml::table& cTable = requireTable(mt["combat"], file, cPath); - toml::table& cMt = const_cast(cTable); - ShipCombat combat { - requireFormula(cMt["damage_formula"], file, cPath + ".damage_formula"), - requireFormula(cMt["attack_range_formula"], file, cPath + ".attack_range_formula"), - requireFormula(cMt["attack_rate_formula"], file, cPath + ".attack_rate_formula"), - }; - def.combat = std::move(combat); - } - - // Optional: salvage - if (mt.contains("salvage")) - { - const std::string sPath = elemPath + ".salvage"; - const toml::table& sTable = requireTable(mt["salvage"], file, sPath); - toml::table& sMt = const_cast(sTable); - ShipSalvage salvage; - salvage.collectionRange = requireDouble(sMt["collection_range"], file, sPath + ".collection_range"); - salvage.cargoCapacity = static_cast(requireInt(sMt["cargo_capacity"], file, sPath + ".cargo_capacity")); - def.salvage = salvage; - } - - // Optional: repair - if (mt.contains("repair")) - { - const std::string rPath = elemPath + ".repair"; - const toml::table& rTable = requireTable(mt["repair"], file, rPath); - toml::table& rMt = const_cast(rTable); - ShipRepair repair { - requireFormula(rMt["repair_rate_formula"], file, rPath + ".repair_rate_formula"), - requireFormula(rMt["repair_range_formula"], file, rPath + ".repair_range_formula"), - }; - def.repair = std::move(repair); + const toml::array& modArr = requireArray(mt["default_modules"], file, + elemPath + ".default_modules"); + def.defaultModules = parsePlacedModules(modArr, file, + elemPath + ".default_modules"); } cfg.ships.push_back(std::move(def)); @@ -514,9 +525,11 @@ static const StatEntry kKnownStats[] = { {"health", "hp"}, {"movement", "speed"}, {"sensor", "sensor_range"}, - {"combat", "damage"}, - {"combat", "attack_range"}, - {"combat", "attack_rate"}, + {"weapon", "damage"}, + {"weapon", "attack_range"}, + {"weapon", "attack_rate"}, + {"salvage", "collection_range"}, + {"salvage", "cargo_capacity"}, {"repair", "repair_rate"}, {"repair", "repair_range"}, }; @@ -597,6 +610,60 @@ ModulesConfig ConfigLoader::loadModules(const std::string& path) } } + // Weapon capability section: [module.weapon] with base stat formulas + if (mt.contains("weapon")) + { + const std::string wPath = elemPath + ".weapon"; + const toml::table& wTable = requireTable(mt["weapon"], file, wPath); + toml::table& wMt = const_cast(wTable); + if (wMt.contains("damage_formula") || wMt.contains("attack_range_formula") + || wMt.contains("attack_rate_formula")) + { + ModuleWeaponCapability cap; + cap.damageFormula = requireFormula(wMt["damage_formula"], + file, wPath + ".damage_formula"); + cap.attackRangeFormula = requireFormula(wMt["attack_range_formula"], + file, wPath + ".attack_range_formula"); + cap.attackRateFormula = requireFormula(wMt["attack_rate_formula"], + file, wPath + ".attack_rate_formula"); + def.weaponCapability = std::move(cap); + } + } + + // Salvage capability section: [module.salvage] with base stat formulas + if (mt.contains("salvage")) + { + const std::string sPath = elemPath + ".salvage"; + const toml::table& sTable = requireTable(mt["salvage"], file, sPath); + toml::table& sMt = const_cast(sTable); + if (sMt.contains("collection_range_formula") || sMt.contains("cargo_capacity_formula")) + { + ModuleSalvageCapability cap; + cap.collectionRangeFormula = requireFormula(sMt["collection_range_formula"], + file, sPath + ".collection_range_formula"); + cap.cargoCapacityFormula = requireFormula(sMt["cargo_capacity_formula"], + file, sPath + ".cargo_capacity_formula"); + def.salvageCapability = std::move(cap); + } + } + + // Repair capability section: [module.repair] with base stat formulas + if (mt.contains("repair")) + { + const std::string rPath = elemPath + ".repair"; + const toml::table& rTable = requireTable(mt["repair"], file, rPath); + toml::table& rMt = const_cast(rTable); + if (rMt.contains("repair_rate_formula") || rMt.contains("repair_range_formula")) + { + ModuleRepairCapability cap; + cap.repairRateFormula = requireFormula(rMt["repair_rate_formula"], + file, rPath + ".repair_rate_formula"); + cap.repairRangeFormula = requireFormula(rMt["repair_range_formula"], + file, rPath + ".repair_range_formula"); + def.repairCapability = std::move(cap); + } + } + cfg.modules.push_back(std::move(def)); } diff --git a/src/lib/config/ModulesConfig.h b/src/lib/config/ModulesConfig.h index 1ab912f..8181a6c 100644 --- a/src/lib/config/ModulesConfig.h +++ b/src/lib/config/ModulesConfig.h @@ -1,5 +1,6 @@ #pragma once +#include #include #include @@ -15,6 +16,26 @@ struct ModuleStatModifier Formula formula; }; +// Capability sections — present when the module grants that capability. +struct ModuleWeaponCapability +{ + Formula damageFormula; + Formula attackRangeFormula; + Formula attackRateFormula; +}; + +struct ModuleSalvageCapability +{ + Formula collectionRangeFormula; + Formula cargoCapacityFormula; +}; + +struct ModuleRepairCapability +{ + Formula repairRateFormula; + Formula repairRangeFormula; +}; + struct ModuleDef { std::string id; @@ -26,6 +47,10 @@ struct ModuleDef std::string fillColor; std::string glyph; std::vector statModifiers; + + std::optional weaponCapability; + std::optional salvageCapability; + std::optional repairCapability; }; struct ModulesConfig diff --git a/src/lib/config/ShipsConfig.h b/src/lib/config/ShipsConfig.h index c659c47..b157398 100644 --- a/src/lib/config/ShipsConfig.h +++ b/src/lib/config/ShipsConfig.h @@ -1,11 +1,11 @@ #pragma once -#include #include #include #include "Formula.h" #include "RecipesConfig.h" // for RecipeIngredient +#include "ShipLayout.h" // for PlacedModule // Build materials and initial per-schematic production level // (REQ-BLD-SHIPYARD, REQ-DEF-SCHEMATIC-DROP). @@ -42,27 +42,6 @@ struct ShipSensor Formula sensorRangeFormula; // REQ-SHP-SENSOR, REQ-SHP-STATS }; -struct ShipCombat -{ - Formula damageFormula; - Formula attackRangeFormula; - Formula attackRateFormula; // shots per second -}; - -// Optional; present only on salvage ships (REQ-SHP-SALVAGE). -struct ShipSalvage -{ - double collectionRange; - int cargoCapacity; -}; - -// Optional; present only on repair ships (REQ-SHP-REPAIR). -struct ShipRepair -{ - Formula repairRateFormula; - Formula repairRangeFormula; -}; - // Scrap dropped on destruction (REQ-RES-SCRAP-DROP). struct ShipLoot { @@ -82,12 +61,8 @@ struct ShipDef ShipSensor sensor; ShipLoot loot; - // Role-specific sections. A ship is a combat ship if combat is present, - // a salvage ship if salvage is present, etc. A ship may have multiple - // of these set (hybrid ships) once the behavior systems support it. - std::optional combat; - std::optional salvage; - std::optional repair; + // Module layout used for enemy wave ships (REQ-WAV-DEFAULT-MODULES). + std::vector defaultModules; }; struct ShipsConfig diff --git a/src/lib/core/EntityAdmin.cpp b/src/lib/core/EntityAdmin.cpp index 90e6fc1..699af94 100644 --- a/src/lib/core/EntityAdmin.cpp +++ b/src/lib/core/EntityAdmin.cpp @@ -18,6 +18,11 @@ entt::entity EntityAdmin::createEntity() return m_registry.create(); } +entt::entity EntityAdmin::createModuleEntity() +{ + return m_registry.create(); +} + bool EntityAdmin::isValid(entt::entity entity) const { return m_registry.valid(entity); diff --git a/src/lib/core/EntityAdmin.h b/src/lib/core/EntityAdmin.h index dc16e53..71d32aa 100644 --- a/src/lib/core/EntityAdmin.h +++ b/src/lib/core/EntityAdmin.h @@ -66,6 +66,10 @@ public: entt::entity spawnHqProxy(QVector2D position, float hp, float maxHp); + // Creates a bare entity for module child entities (weapons, salvage, repair). + // Caller is responsible for attaching all required components. + entt::entity createModuleEntity(); + private: entt::entity createEntity(); diff --git a/src/lib/ecs/component/ModuleOwnerComponent.h b/src/lib/ecs/component/ModuleOwnerComponent.h new file mode 100644 index 0000000..55d9edd --- /dev/null +++ b/src/lib/ecs/component/ModuleOwnerComponent.h @@ -0,0 +1,9 @@ +#pragma once + +#include "entt/entity/entity.hpp" + +// Links a capability module child entity back to its owner ship or station. +struct ModuleOwnerComponent +{ + entt::entity owner; +}; diff --git a/src/lib/ecs/component/RepairBehaviorComponent.h b/src/lib/ecs/component/RepairBehaviorComponent.h index ac24de8..a672c08 100644 --- a/src/lib/ecs/component/RepairBehaviorComponent.h +++ b/src/lib/ecs/component/RepairBehaviorComponent.h @@ -7,4 +7,5 @@ struct RepairBehaviorComponent { std::optional currentTarget; + float maxRepairRange = 0.0f; }; diff --git a/src/lib/ecs/component/SalvageBehaviorComponent.h b/src/lib/ecs/component/SalvageBehaviorComponent.h index 26639e8..32f486e 100644 --- a/src/lib/ecs/component/SalvageBehaviorComponent.h +++ b/src/lib/ecs/component/SalvageBehaviorComponent.h @@ -9,5 +9,6 @@ struct SalvageBehaviorComponent { std::optional scrapTarget; - BuildingId deliveryBay; // kInvalidBuildingId until assigned at a salvage bay + BuildingId deliveryBay; // kInvalidBuildingId until assigned at a salvage bay + float maxCollectionRange = 0.0f; }; diff --git a/src/lib/ecs/system/AiSystem.cpp b/src/lib/ecs/system/AiSystem.cpp index d71cfae..25383b3 100644 --- a/src/lib/ecs/system/AiSystem.cpp +++ b/src/lib/ecs/system/AiSystem.cpp @@ -1,6 +1,7 @@ #include "AiSystem.h" #include +#include #include #include @@ -14,6 +15,7 @@ #include "HealthComponent.h" #include "HomeReturnBehaviorComponent.h" #include "HqProxyComponent.h" +#include "ModuleOwnerComponent.h" #include "MovementIntentComponent.h" #include "PositionComponent.h" #include "RallyBehaviorComponent.h" @@ -224,13 +226,13 @@ void AiSystem::tickRepairBehavior(EntityAdmin& admin, BuildingSystem& buildings) } }); - admin.forEach( - [&](entt::entity e, RepairBehaviorComponent& rb, RepairToolComponent& rt, + [&](entt::entity e, RepairBehaviorComponent& rb, PositionComponent& pos, FactionComponent& /*faction*/, SensorRangeComponent& sensor, MovementIntentComponent& intent) { - const float repairRange = rt.range; + const float repairRange = rb.maxRepairRange; // Flee if enemy nearby. bool enemyNearby = false; @@ -303,17 +305,6 @@ void AiSystem::tickRepairBehavior(EntityAdmin& admin, BuildingSystem& buildings) targetPos = admin.get(target).value; } - const float distToTarget = (targetPos - pos.value).length(); - if (distToTarget <= repairRange) - { - if (admin.isValid(target) && admin.hasAll(target)) - { - HealthComponent& targetHealth = admin.get(target); - targetHealth.hp = std::min(targetHealth.hp + rt.ratePerTick, - targetHealth.maxHp); - } - } - if (2 > intent.priority) { intent = MovementIntentComponent{2, targetPos}; @@ -321,6 +312,33 @@ void AiSystem::tickRepairBehavior(EntityAdmin& admin, BuildingSystem& buildings) }); } +// --------------------------------------------------------------------------- +// tickRepairTools +// --------------------------------------------------------------------------- + +void AiSystem::tickRepairTools(EntityAdmin& admin) +{ + admin.forEach( + [&](entt::entity /*e*/, RepairToolComponent& rt, const ModuleOwnerComponent& owner) + { + if (!admin.hasAll(owner.owner)) { return; } + const RepairBehaviorComponent& rb = + admin.get(owner.owner); + if (!rb.currentTarget) { return; } + + const entt::entity target = *rb.currentTarget; + if (!admin.isValid(target) || !admin.hasAll(target)) { return; } + + const PositionComponent& ownerPos = admin.get(owner.owner); + const PositionComponent& targetPos = admin.get(target); + const float dist = (targetPos.value - ownerPos.value).length(); + if (dist > rt.range) { return; } + + HealthComponent& targetHealth = admin.get(target); + targetHealth.hp = std::min(targetHealth.hp + rt.ratePerTick, targetHealth.maxHp); + }); +} + // --------------------------------------------------------------------------- // tickSalvageBehavior (priority 1) // --------------------------------------------------------------------------- @@ -344,15 +362,31 @@ void AiSystem::tickSalvageBehavior(EntityAdmin& admin, ScrapSystem& scraps, } }); + // Aggregate cargo across all salvage-module children per owning ship. + struct AggregatedCargo + { + int totalCurrent = 0; + int totalCapacity = 0; + }; + std::unordered_map cargoByShip; + admin.forEach( + [&](entt::entity /*ce*/, const SalvageCargoComponent& c, const ModuleOwnerComponent& o) + { + AggregatedCargo& agg = cargoByShip[o.owner]; + agg.totalCurrent += c.current; + agg.totalCapacity += c.capacity; + }); + const std::vector allScrap = scraps.allScrapInfo(); - admin.forEach( - [&](entt::entity /*e*/, SalvageBehaviorComponent& salvageBehavior, - SalvageCargoComponent& cargo, PositionComponent& pos, + [&](entt::entity e, SalvageBehaviorComponent& salvageBehavior, + PositionComponent& pos, SensorRangeComponent& sensor, MovementIntentComponent& intent) { - const float collectRange = cargo.collectionRange; + const float collectRange = salvageBehavior.maxCollectionRange; + const AggregatedCargo& cargoState = cargoByShip[e]; // Assign nearest SalvageBay if needed. if (salvageBehavior.deliveryBay == kInvalidBuildingId) @@ -378,7 +412,8 @@ void AiSystem::tickSalvageBehavior(EntityAdmin& admin, ScrapSystem& scraps, } } - const bool cargoFull = (cargo.current >= cargo.capacity); + const bool cargoFull = (cargoState.totalCurrent >= cargoState.totalCapacity + && cargoState.totalCapacity > 0); if (cargoFull) { @@ -389,17 +424,26 @@ void AiSystem::tickSalvageBehavior(EntityAdmin& admin, ScrapSystem& scraps, if (bayId != kInvalidBuildingId && (pos.value - bayPos).length() <= 1.0f) { - if (buildings.deliverScrapToSalvageBay(bayId)) - { - --cargo.current; - } + // Decrement first non-empty salvage child. + bool delivered = false; + admin.forEach( + [&](entt::entity /*ce*/, SalvageCargoComponent& c, + const ModuleOwnerComponent& o) + { + if (delivered || o.owner != e || c.current <= 0) { return; } + if (buildings.deliverScrapToSalvageBay(bayId)) + { + --c.current; + delivered = true; + } + }); } return; } // Retreat if enemy near and cargo empty. bool retreating = false; - if (cargo.current == 0) + if (cargoState.totalCurrent == 0) { for (const EnemyShipPos& enemy : enemyShips) { @@ -417,16 +461,24 @@ void AiSystem::tickSalvageBehavior(EntityAdmin& admin, ScrapSystem& scraps, } if (retreating) { return; } - // Collect nearby scrap. + // Collect nearby scrap — increment first non-full salvage child. for (const ScrapInfo& si : allScrap) { if ((si.position - pos.value).length() <= collectRange) { - if (scraps.consume(si.entity)) - { - ++cargo.current; - salvageBehavior.scrapTarget = std::nullopt; - } + bool collected = false; + admin.forEach( + [&](entt::entity /*ce*/, SalvageCargoComponent& c, + const ModuleOwnerComponent& o) + { + if (collected || o.owner != e || c.current >= c.capacity) { return; } + if (scraps.consume(si.entity)) + { + ++c.current; + salvageBehavior.scrapTarget = std::nullopt; + collected = true; + } + }); break; } } diff --git a/src/lib/ecs/system/AiSystem.h b/src/lib/ecs/system/AiSystem.h index 64ef2a3..f1356d7 100644 --- a/src/lib/ecs/system/AiSystem.h +++ b/src/lib/ecs/system/AiSystem.h @@ -10,5 +10,6 @@ public: void tickHomeReturnBehavior(EntityAdmin& admin); void tickThreatResponseBehavior(EntityAdmin& admin, const BuildingSystem& buildings); void tickRepairBehavior(EntityAdmin& admin, BuildingSystem& buildings); + void tickRepairTools(EntityAdmin& admin); void tickSalvageBehavior(EntityAdmin& admin, ScrapSystem& scraps, BuildingSystem& buildings); }; diff --git a/src/lib/ecs/system/CombatSystem.cpp b/src/lib/ecs/system/CombatSystem.cpp index 4e6e276..bd42a8b 100644 --- a/src/lib/ecs/system/CombatSystem.cpp +++ b/src/lib/ecs/system/CombatSystem.cpp @@ -2,8 +2,8 @@ #include "EntityAdmin.h" #include "FactionComponent.h" -#include "StationBodyComponent.h" #include "HealthComponent.h" +#include "ModuleOwnerComponent.h" #include "PositionComponent.h" #include "SensorRangeComponent.h" #include "ShipIdentityComponent.h" @@ -22,24 +22,18 @@ void CombatSystem::tick(Tick currentTick, BuildingSystem& /*buildings*/, std::vector& outFireEvents) { - // Ship weapons. - admin.forEach( - [&](entt::entity e, WeaponComponent& weapon, - ThreatResponseBehaviorComponent& threatResponseBehavior, - PositionComponent& pos, FactionComponent& faction) + // All weapons (ships and stations) are child entities linked via ModuleOwnerComponent. + admin.forEach( + [&](entt::entity /*e*/, WeaponComponent& weapon, const ModuleOwnerComponent& owner) { - weapon.currentTarget = threatResponseBehavior.currentTarget; - resolveWeapon(e, weapon, pos, faction, currentTick, admin, outFireEvents); - }); - - // Station weapons (entities with StationBodyComponent; ships are excluded because - // they lack that component and are already handled by the ship loop above). - admin.forEach( - [&](entt::entity e, WeaponComponent& weapon, PositionComponent& pos, - FactionComponent& faction, const StationBodyComponent& /*sb*/) - { - resolveWeapon(e, weapon, pos, faction, currentTick, admin, outFireEvents); + if (admin.hasAll(owner.owner)) + { + weapon.currentTarget = + admin.get(owner.owner).currentTarget; + } + const PositionComponent& pos = admin.get(owner.owner); + const FactionComponent& faction = admin.get(owner.owner); + resolveWeapon(owner.owner, weapon, pos, faction, currentTick, admin, outFireEvents); }); } diff --git a/src/lib/ecs/system/ShipSystem.cpp b/src/lib/ecs/system/ShipSystem.cpp index 2082f6a..6749e2d 100644 --- a/src/lib/ecs/system/ShipSystem.cpp +++ b/src/lib/ecs/system/ShipSystem.cpp @@ -3,11 +3,13 @@ #include #include #include +#include #include "DynamicBodyComponent.h" #include "EntityAdmin.h" #include "FactionComponent.h" #include "HealthComponent.h" +#include "ModuleOwnerComponent.h" #include "ModulesConfig.h" #include "MovementIntentComponent.h" #include "RallyBehaviorComponent.h" @@ -85,17 +87,182 @@ entt::entity ShipSystem::spawn(const std::string& schematicId, int level, angularAccelPerTick, maxRotationSpeedPerTick, sensorRange, level, schematicId, isEnemy); - // Optional components based on ship role. - if (def->combat) - { - WeaponComponent w; - w.damage = static_cast(def->combat->damageFormula.evaluate(x)); - w.range = static_cast(def->combat->attackRangeFormula.evaluate(x)); - w.fireRateHz = static_cast(def->combat->attackRateFormula.evaluate(x)); - w.cooldownTicks = 0.0f; - w.currentTarget = std::nullopt; - m_admin.addComponent(entity, w); + // Determine module list: configured layout takes precedence over default. + const std::vector& modules = + layout.has_value() ? layout->placedModules : def->defaultModules; + // --- Pass 1: create capability child entities ---------------------------- + std::vector weaponChildren; + std::vector salvageChildren; + std::vector repairChildren; + + for (const PlacedModule& pm : modules) + { + const ModuleDef* modDef = findModuleDef(pm.moduleId); + if (!modDef) { continue; } + + const double mx = static_cast(modDef->playerProductionLevel); + + if (modDef->weaponCapability) + { + WeaponComponent w; + w.damage = static_cast( + modDef->weaponCapability->damageFormula.evaluate(mx)); + w.range = static_cast( + modDef->weaponCapability->attackRangeFormula.evaluate(mx)); + w.fireRateHz = static_cast( + modDef->weaponCapability->attackRateFormula.evaluate(mx)); + w.cooldownTicks = 0.0f; + w.currentTarget = std::nullopt; + + entt::entity child = m_admin.createModuleEntity(); + m_admin.addComponent(child, w); + m_admin.addComponent(child, ModuleOwnerComponent{entity}); + weaponChildren.push_back(child); + } + + if (modDef->salvageCapability) + { + SalvageCargoComponent cargo; + cargo.capacity = static_cast( + modDef->salvageCapability->cargoCapacityFormula.evaluate(mx)); + cargo.current = 0; + cargo.collectionRange = static_cast( + modDef->salvageCapability->collectionRangeFormula.evaluate(mx)); + + entt::entity child = m_admin.createModuleEntity(); + m_admin.addComponent(child, cargo); + m_admin.addComponent(child, ModuleOwnerComponent{entity}); + salvageChildren.push_back(child); + } + + if (modDef->repairCapability) + { + RepairToolComponent rt; + rt.ratePerTick = static_cast( + modDef->repairCapability->repairRateFormula.evaluate(mx)) + / static_cast(kTickRateHz); + rt.range = static_cast( + modDef->repairCapability->repairRangeFormula.evaluate(mx)); + rt.currentTarget = std::nullopt; + + entt::entity child = m_admin.createModuleEntity(); + m_admin.addComponent(child, rt); + m_admin.addComponent(child, ModuleOwnerComponent{entity}); + repairChildren.push_back(child); + } + } + + // --- Pass 2: apply passive stat modifiers -------------------------------- + + // Accumulate hull-level modifiers. + std::map> hullMods; + // Per-capability-type modifier accumulators (applied to each child). + std::map> weaponMods; + std::map> salvageMods; + std::map> repairMods; + + for (const PlacedModule& pm : modules) + { + const ModuleDef* modDef = findModuleDef(pm.moduleId); + if (!modDef) { continue; } + + const double mx = static_cast(modDef->playerProductionLevel); + + for (const ModuleStatModifier& sm : modDef->statModifiers) + { + const double val = sm.formula.evaluate(mx); + + // Route modifier to the correct accumulator by stat category. + // weapon/salvage/repair stats go to the corresponding child map; + // hull stats (hp, speed, sensor_range, …) go to hullMods. + const bool isWeaponStat = (sm.stat == "damage" + || sm.stat == "attack_range" + || sm.stat == "attack_rate"); + const bool isSalvageStat = (sm.stat == "collection_range" + || sm.stat == "cargo_capacity"); + const bool isRepairStat = (sm.stat == "repair_rate" + || sm.stat == "repair_range"); + + std::map>* target = &hullMods; + if (isWeaponStat) { target = &weaponMods; } + if (isSalvageStat) { target = &salvageMods; } + if (isRepairStat) { target = &repairMods; } + + std::pair& acc = (*target)[sm.stat]; + if (sm.modifierType == "multiplicative") + { + acc.first += (val - 1.0); + } + else + { + acc.second += val; + } + } + } + + // Helper: apply a modifier map to a float stat. + auto applyMod = [](float& stat, const std::string& name, + const std::map>& mods) + { + const auto it = mods.find(name); + if (it != mods.end()) + { + stat = static_cast( + static_cast(stat) * (1.0 + it->second.first) + + it->second.second); + } + }; + + // Apply hull modifiers. + { + HealthComponent& health = m_admin.get(entity); + DynamicBodyComponent& dynamics = m_admin.get(entity); + SensorRangeComponent& sensor = m_admin.get(entity); + + applyMod(health.maxHp, "hp", hullMods); + health.hp = health.maxHp; + applyMod(dynamics.maxSpeedPerTick, "speed", hullMods); + applyMod(dynamics.mainAccelerationPerTick, "main_acceleration", hullMods); + applyMod(dynamics.maneuveringAccelerationPerTick, "maneuvering_acceleration", hullMods); + applyMod(dynamics.angularAccelerationPerTick, "angular_acceleration", hullMods); + applyMod(dynamics.maxRotationSpeedPerTick, "max_rotation_speed", hullMods); + applyMod(sensor.value, "sensor_range", hullMods); + } + + // Apply weapon modifiers to each weapon child. + for (entt::entity child : weaponChildren) + { + WeaponComponent& w = m_admin.get(child); + applyMod(w.damage, "damage", weaponMods); + applyMod(w.range, "attack_range", weaponMods); + applyMod(w.fireRateHz, "attack_rate", weaponMods); + } + + // Apply salvage modifiers to each salvage child. + for (entt::entity child : salvageChildren) + { + SalvageCargoComponent& c = m_admin.get(child); + float fRange = c.collectionRange; + float fCapacity = static_cast(c.capacity); + applyMod(fRange, "collection_range", salvageMods); + applyMod(fCapacity, "cargo_capacity", salvageMods); + c.collectionRange = fRange; + c.capacity = static_cast(fCapacity); + } + + // Apply repair modifiers to each repair child. + for (entt::entity child : repairChildren) + { + RepairToolComponent& rt = m_admin.get(child); + applyMod(rt.ratePerTick, "repair_rate", repairMods); + applyMod(rt.range, "repair_range", repairMods); + } + + // --- Pass 3: attach behavior components based on capability presence ----- + + if (!weaponChildren.empty()) + { m_admin.addComponent( entity, ThreatResponseBehaviorComponent{}); @@ -106,95 +273,35 @@ entt::entity ShipSystem::spawn(const std::string& schematicId, int level, } } - if (def->salvage) + if (!salvageChildren.empty()) { - SalvageCargoComponent cargo; - cargo.capacity = def->salvage->cargoCapacity; - cargo.current = 0; - cargo.collectionRange = static_cast(def->salvage->collectionRange); - m_admin.addComponent(entity, cargo); + float maxCollRange = 0.0f; + for (entt::entity child : salvageChildren) + { + const float r = m_admin.get(child).collectionRange; + if (r > maxCollRange) { maxCollRange = r; } + } - SalvageBehaviorComponent salvageBehavior; - salvageBehavior.scrapTarget = std::nullopt; - salvageBehavior.deliveryBay = kInvalidBuildingId; - m_admin.addComponent(entity, salvageBehavior); + SalvageBehaviorComponent sb; + sb.scrapTarget = std::nullopt; + sb.deliveryBay = kInvalidBuildingId; + sb.maxCollectionRange = maxCollRange; + m_admin.addComponent(entity, sb); } - if (def->repair) + if (!repairChildren.empty()) { - RepairToolComponent rt; - rt.ratePerTick = static_cast(def->repair->repairRateFormula.evaluate(x)); - rt.range = static_cast(def->repair->repairRangeFormula.evaluate(x)); - rt.currentTarget = std::nullopt; - m_admin.addComponent(entity, rt); - - m_admin.addComponent(entity, RepairBehaviorComponent{}); - } - - // Apply module stat modifiers (REQ-MOD-STAT-CALC). - if (layout.has_value() && !layout->placedModules.empty()) - { - std::map> mods; - for (const PlacedModule& pm : layout->placedModules) + float maxRepairRange = 0.0f; + for (entt::entity child : repairChildren) { - const ModuleDef* modDef = findModuleDef(pm.moduleId); - if (!modDef) - { - continue; - } - for (const ModuleStatModifier& sm : modDef->statModifiers) - { - const double val = sm.formula.evaluate( - static_cast(modDef->playerProductionLevel)); - std::pair& acc = mods[sm.stat]; - if (sm.modifierType == "multiplicative") - { - acc.first += (val - 1.0); - } - else - { - acc.second += val; - } - } + const float r = m_admin.get(child).range; + if (r > maxRepairRange) { maxRepairRange = r; } } - auto applyMod = [&mods](float& stat, const std::string& name) { - const std::map>::const_iterator it = - mods.find(name); - if (it != mods.end()) - { - stat = static_cast( - static_cast(stat) * (1.0 + it->second.first) - + it->second.second); - } - }; - - HealthComponent& health = m_admin.get(entity); - DynamicBodyComponent& dynamics = m_admin.get(entity); - SensorRangeComponent& sensor = m_admin.get(entity); - - applyMod(health.maxHp, "hp"); - health.hp = health.maxHp; - applyMod(dynamics.maxSpeedPerTick, "speed"); - applyMod(dynamics.mainAccelerationPerTick, "main_acceleration"); - applyMod(dynamics.maneuveringAccelerationPerTick, "maneuvering_acceleration"); - applyMod(dynamics.angularAccelerationPerTick, "angular_acceleration"); - applyMod(dynamics.maxRotationSpeedPerTick, "max_rotation_speed"); - applyMod(sensor.value, "sensor_range"); - - if (m_admin.hasAll(entity)) - { - WeaponComponent& weapon = m_admin.get(entity); - applyMod(weapon.damage, "damage"); - applyMod(weapon.range, "attack_range"); - applyMod(weapon.fireRateHz, "attack_rate"); - } - if (m_admin.hasAll(entity)) - { - RepairToolComponent& repairTool = m_admin.get(entity); - applyMod(repairTool.ratePerTick, "repair_rate"); - applyMod(repairTool.range, "repair_range"); - } + RepairBehaviorComponent rb; + rb.currentTarget = std::nullopt; + rb.maxRepairRange = maxRepairRange; + m_admin.addComponent(entity, rb); } return entity; @@ -202,6 +309,13 @@ entt::entity ShipSystem::spawn(const std::string& schematicId, int level, void ShipSystem::despawn(entt::entity entity) { + std::vector children; + m_admin.forEach( + [&](entt::entity e, const ModuleOwnerComponent& o) + { + if (o.owner == entity) { children.push_back(e); } + }); + for (entt::entity child : children) { m_admin.destroy(child); } m_admin.destroy(entity); } diff --git a/src/lib/sim/Simulation.cpp b/src/lib/sim/Simulation.cpp index 98d0eb4..3c77810 100644 --- a/src/lib/sim/Simulation.cpp +++ b/src/lib/sim/Simulation.cpp @@ -8,6 +8,7 @@ #include "DynamicBodySystem.h" #include "FactionComponent.h" #include "HealthComponent.h" +#include "ModuleOwnerComponent.h" #include "MovementIntentSystem.h" #include "PositionComponent.h" #include "ScrapSystem.h" @@ -173,6 +174,7 @@ void Simulation::tick() m_aiSystem->tickHomeReturnBehavior(m_admin); // priority 4 m_aiSystem->tickThreatResponseBehavior(m_admin, *m_buildingSystem); // priority 3 m_aiSystem->tickRepairBehavior(m_admin, *m_buildingSystem); // priority 2 + m_aiSystem->tickRepairTools(m_admin); m_aiSystem->tickSalvageBehavior(m_admin, *m_scrapSystem, *m_buildingSystem); // priority 1 // Step 8: combat resolution @@ -255,7 +257,12 @@ void Simulation::placeInitialStructures() } m_playerStation1Entity = m_admin.spawnStation( anchor, psParsed.footprint, absCells, psHp, psHp, false); - m_admin.addComponent(m_playerStation1Entity, psWeapon); + { + entt::entity wChild = m_admin.createModuleEntity(); + m_admin.addComponent(wChild, psWeapon); + m_admin.addComponent(wChild, + ModuleOwnerComponent{m_playerStation1Entity}); + } m_buildingSystem->registerTileOccupancy(absCells, allocateBuildingId()); } { @@ -267,7 +274,12 @@ void Simulation::placeInitialStructures() } m_playerStation2Entity = m_admin.spawnStation( anchor, psParsed.footprint, absCells, psHp, psHp, false); - m_admin.addComponent(m_playerStation2Entity, psWeapon); + { + entt::entity wChild = m_admin.createModuleEntity(); + m_admin.addComponent(wChild, psWeapon); + m_admin.addComponent(wChild, + ModuleOwnerComponent{m_playerStation2Entity}); + } m_buildingSystem->registerTileOccupancy(absCells, allocateBuildingId()); } @@ -316,7 +328,12 @@ void Simulation::placeEnemyStationSet(int generation) } m_currentEnemyStationEntities[0] = m_admin.spawnStation( anchor, esParsed.footprint, absCells, esHp, esHp, true); - m_admin.addComponent(m_currentEnemyStationEntities[0], esWeapon); + { + entt::entity wChild = m_admin.createModuleEntity(); + m_admin.addComponent(wChild, esWeapon); + m_admin.addComponent(wChild, + ModuleOwnerComponent{m_currentEnemyStationEntities[0]}); + } m_buildingSystem->registerTileOccupancy(absCells, allocateBuildingId()); } { @@ -328,7 +345,12 @@ void Simulation::placeEnemyStationSet(int generation) } m_currentEnemyStationEntities[1] = m_admin.spawnStation( anchor, esParsed.footprint, absCells, esHp, esHp, true); - m_admin.addComponent(m_currentEnemyStationEntities[1], esWeapon); + { + entt::entity wChild = m_admin.createModuleEntity(); + m_admin.addComponent(wChild, esWeapon); + m_admin.addComponent(wChild, + ModuleOwnerComponent{m_currentEnemyStationEntities[1]}); + } m_buildingSystem->registerTileOccupancy(absCells, allocateBuildingId()); } } @@ -407,6 +429,15 @@ void Simulation::tickDeathsAndLoot() m_scrapSystem->spawn(pos.value, scrap, despawnAt); } m_buildingSystem->unregisterTileOccupancy(sb.bodyCells); + { + std::vector stationChildren; + m_admin.forEach( + [&](entt::entity ce, const ModuleOwnerComponent& o) + { + if (o.owner == deadEntity) { stationChildren.push_back(ce); } + }); + for (entt::entity ce : stationChildren) { m_admin.destroy(ce); } + } m_admin.destroy(deadEntity); } diff --git a/src/lib/sim/WaveSystem.cpp b/src/lib/sim/WaveSystem.cpp index 15dec05..97366c3 100644 --- a/src/lib/sim/WaveSystem.cpp +++ b/src/lib/sim/WaveSystem.cpp @@ -34,7 +34,7 @@ void WaveSystem::tickWaveScheduler(Tick currentTick, ShipSystem& ships, if (currentTick >= entry.spawnAt) { ships.spawn(entry.schematicId, entry.level, entry.position, - /*isEnemy=*/true); + /*isEnemy=*/true, entry.layout); } else { @@ -90,8 +90,9 @@ std::vector WaveSystem::composeWave(Tick currentTick, // Build eligible ship list with their costs at the current level. struct EligibleShip { - std::string schematicId; - double cost; + std::string schematicId; + double cost; + std::vector defaultModules; }; std::vector eligible; for (const ShipDef& def : m_config.ships.ships) @@ -100,8 +101,9 @@ std::vector WaveSystem::composeWave(Tick currentTick, if (cost > 0.0) { EligibleShip es; - es.schematicId = def.id; - es.cost = cost; + es.schematicId = def.id; + es.cost = cost; + es.defaultModules = def.defaultModules; eligible.push_back(es); } } @@ -151,11 +153,12 @@ std::vector WaveSystem::composeWave(Tick currentTick, budget -= chosen.cost; SpawnEntry entry; - entry.schematicId = chosen.schematicId; - entry.level = shipLevel; - entry.spawnAt = 0; // set below after all picks are done - entry.position = QVector2D(xDist(m_rng), - static_cast(yDist(m_rng)) + 0.5f); + entry.schematicId = chosen.schematicId; + entry.level = shipLevel; + entry.spawnAt = 0; // set below after all picks are done + entry.position = QVector2D(xDist(m_rng), + static_cast(yDist(m_rng)) + 0.5f); + entry.layout.placedModules = chosen.defaultModules; picked.push_back(entry); } diff --git a/src/lib/sim/WaveSystem.h b/src/lib/sim/WaveSystem.h index bf85891..cc961e3 100644 --- a/src/lib/sim/WaveSystem.h +++ b/src/lib/sim/WaveSystem.h @@ -7,6 +7,7 @@ #include #include "GameConfig.h" +#include "ShipLayout.h" #include "Tick.h" class ShipSystem; @@ -40,10 +41,11 @@ public: private: struct SpawnEntry { - std::string schematicId; - int level; - Tick spawnAt; - QVector2D position; + std::string schematicId; + int level; + Tick spawnAt; + QVector2D position; + ShipLayoutConfig layout; }; // Compose the next wave from the current threat budget, returning timed diff --git a/src/test/BehaviorSystemTest.cpp b/src/test/BehaviorSystemTest.cpp index d05cd70..1c08bdd 100644 --- a/src/test/BehaviorSystemTest.cpp +++ b/src/test/BehaviorSystemTest.cpp @@ -2,10 +2,13 @@ #include +#include #include #include "AiSystem.h" #include "BeltSystem.h" +#include "ModuleOwnerComponent.h" +#include "ShipLayout.h" #include "Building.h" #include "BuildingSystem.h" #include "BuildingType.h" @@ -81,6 +84,7 @@ struct Fixture ai.tickHomeReturnBehavior(admin); ai.tickThreatResponseBehavior(admin, buildings); ai.tickRepairBehavior(admin, buildings); + ai.tickRepairTools(admin); ai.tickSalvageBehavior(admin, scraps, buildings); movementIntent.tick(admin); dynamicBody.tick(admin); @@ -88,6 +92,28 @@ struct Fixture } }; +static ShipLayoutConfig makeSingleModuleLayout(const std::string& moduleId) +{ + PlacedModule pm; + pm.moduleId = moduleId; + pm.position = QPoint(1, 1); + pm.rotation = Rotation::East; + ShipLayoutConfig layout; + layout.placedModules.push_back(pm); + return layout; +} + +static entt::entity firstSalvageChild(EntityAdmin& admin, entt::entity ship) +{ + entt::entity result = entt::null; + admin.forEach( + [&](entt::entity ce, const SalvageCargoComponent&, const ModuleOwnerComponent& o) + { + if (o.owner == ship && result == entt::null) { result = ce; } + }); + return result; +} + // Helpers to read ECS data for a ship entity. static const MovementIntentComponent& intent(EntityAdmin& a, entt::entity e) { @@ -299,7 +325,9 @@ TEST_CASE("BehaviorSystem: repair ship writes intent toward damaged friendly shi "[behavior]") { Fixture f; - const entt::entity repairShip = f.ships.spawn("repair_ship", 1, QVector2D(0.0f, 0.0f)); + const ShipLayoutConfig repairLayout = makeSingleModuleLayout("repair_tool_module"); + const entt::entity repairShip = f.ships.spawn("repair_ship", 1, QVector2D(0.0f, 0.0f), + false, repairLayout); const entt::entity friendly = f.ships.spawn("interceptor", 1, QVector2D(5.0f, 0.0f)); f.admin.get(friendly).hp = f.admin.get(friendly).maxHp * 0.5f; @@ -315,7 +343,9 @@ TEST_CASE("BehaviorSystem: repair ship heals damaged ally within repair range", "[behavior]") { Fixture f; - const entt::entity repairShip = f.ships.spawn("repair_ship", 1, QVector2D(0.0f, 0.0f)); + const ShipLayoutConfig repairLayout = makeSingleModuleLayout("repair_tool_module"); + const entt::entity repairShip = f.ships.spawn("repair_ship", 1, QVector2D(0.0f, 0.0f), + false, repairLayout); const entt::entity friendly = f.ships.spawn("interceptor", 1, QVector2D(1.0f, 0.0f)); const float initialHp = f.admin.get(friendly).maxHp * 0.5f; @@ -323,6 +353,7 @@ TEST_CASE("BehaviorSystem: repair ship heals damaged ally within repair range", f.ships.clearMovementIntents(); f.ai.tickRepairBehavior(f.admin, f.buildings); + f.ai.tickRepairTools(f.admin); REQUIRE(health(f.admin, friendly).hp > initialHp); } @@ -330,7 +361,8 @@ TEST_CASE("BehaviorSystem: repair ship heals damaged ally within repair range", TEST_CASE("BehaviorSystem: repair ship does not heal above maxHp", "[behavior]") { Fixture f; - f.ships.spawn("repair_ship", 1, QVector2D(0.0f, 0.0f)); + const ShipLayoutConfig repairLayout = makeSingleModuleLayout("repair_tool_module"); + f.ships.spawn("repair_ship", 1, QVector2D(0.0f, 0.0f), false, repairLayout); const entt::entity friendly = f.ships.spawn("interceptor", 1, QVector2D(1.0f, 0.0f)); f.admin.get(friendly).hp = f.admin.get(friendly).maxHp - 0.001f; @@ -339,6 +371,7 @@ TEST_CASE("BehaviorSystem: repair ship does not heal above maxHp", "[behavior]") { f.ships.clearMovementIntents(); f.ai.tickRepairBehavior(f.admin, f.buildings); + f.ai.tickRepairTools(f.admin); } const HealthComponent& h = health(f.admin, friendly); @@ -353,7 +386,9 @@ TEST_CASE("BehaviorSystem: repair ship does not heal above maxHp", "[behavior]") TEST_CASE("BehaviorSystem: salvage ship writes intent toward nearest scrap", "[behavior]") { Fixture f; - const entt::entity ship = f.ships.spawn("salvage_ship", 1, QVector2D(0.0f, 0.0f)); + const ShipLayoutConfig salvageLayout = makeSingleModuleLayout("salvage_bay_module"); + const entt::entity ship = f.ships.spawn("salvage_ship", 1, QVector2D(0.0f, 0.0f), + false, salvageLayout); const QVector2D scrapPos(100.0f, 0.0f); f.scraps.spawn(scrapPos, 1, 100000); @@ -368,13 +403,17 @@ TEST_CASE("BehaviorSystem: salvage ship writes intent toward nearest scrap", "[b TEST_CASE("BehaviorSystem: salvage ship collects scrap on arrival", "[behavior]") { Fixture f; - const entt::entity ship = f.ships.spawn("salvage_ship", 1, QVector2D(0.0f, 0.0f)); + const ShipLayoutConfig salvageLayout = makeSingleModuleLayout("salvage_bay_module"); + const entt::entity ship = f.ships.spawn("salvage_ship", 1, QVector2D(0.0f, 0.0f), + false, salvageLayout); const entt::entity scrapEntity = f.scraps.spawn(QVector2D(0.0f, 0.0f), 1, 100000); f.ships.clearMovementIntents(); f.ai.tickSalvageBehavior(f.admin, f.scraps, f.buildings); - REQUIRE(f.admin.get(ship).current == 1); + const entt::entity sc = firstSalvageChild(f.admin, ship); + REQUIRE(f.admin.isValid(sc)); + REQUIRE(f.admin.get(sc).current == 1); REQUIRE_FALSE(f.admin.isValid(scrapEntity)); } @@ -395,9 +434,15 @@ TEST_CASE("BehaviorSystem: full-cargo salvage ship moves toward SalvageBay", "[b } REQUIRE(f.buildings.findBuilding(bayId) != nullptr); - const entt::entity ship = f.ships.spawn("salvage_ship", 1, QVector2D(5.0f, 0.0f)); - SalvageCargoComponent& cargo = f.admin.get(ship); - cargo.current = cargo.capacity; // full cargo + const ShipLayoutConfig salvageLayout = makeSingleModuleLayout("salvage_bay_module"); + const entt::entity ship = f.ships.spawn("salvage_ship", 1, QVector2D(5.0f, 0.0f), + false, salvageLayout); + { + const entt::entity sc = firstSalvageChild(f.admin, ship); + REQUIRE(f.admin.isValid(sc)); + SalvageCargoComponent& cargo = f.admin.get(sc); + cargo.current = cargo.capacity; // full cargo + } f.ships.clearMovementIntents(); f.ai.tickSalvageBehavior(f.admin, f.scraps, f.buildings); @@ -467,7 +512,9 @@ TEST_CASE("SensorRange: enemy ship ignores player just outside sensor range", "[ TEST_CASE("SensorRange: repair ship retreats from enemy within sensor range", "[sensor]") { Fixture f; - const entt::entity repairShip = f.ships.spawn("repair_ship", 1, QVector2D(0.0f, 0.0f)); + const ShipLayoutConfig repairLayout = makeSingleModuleLayout("repair_tool_module"); + const entt::entity repairShip = f.ships.spawn("repair_ship", 1, QVector2D(0.0f, 0.0f), + false, repairLayout); f.ships.spawn("interceptor", 1, QVector2D(200.0f, 0.0f), /*isEnemy=*/true); f.ships.clearMovementIntents(); @@ -480,7 +527,9 @@ TEST_CASE("SensorRange: repair ship retreats from enemy within sensor range", "[ TEST_CASE("SensorRange: repair ship does not retreat from enemy beyond sensor range", "[sensor]") { Fixture f; - const entt::entity repairShip = f.ships.spawn("repair_ship", 1, QVector2D(0.0f, 0.0f)); + const ShipLayoutConfig repairLayout = makeSingleModuleLayout("repair_tool_module"); + const entt::entity repairShip = f.ships.spawn("repair_ship", 1, QVector2D(0.0f, 0.0f), + false, repairLayout); f.ships.spawn("interceptor", 1, QVector2D(300.0f, 0.0f), /*isEnemy=*/true); f.ships.clearMovementIntents(); @@ -492,7 +541,9 @@ TEST_CASE("SensorRange: repair ship does not retreat from enemy beyond sensor ra TEST_CASE("SensorRange: repair ship does not acquire damaged ally beyond sensor range", "[sensor]") { Fixture f; - const entt::entity repairShip = f.ships.spawn("repair_ship", 1, QVector2D(0.0f, 0.0f)); + const ShipLayoutConfig repairLayout = makeSingleModuleLayout("repair_tool_module"); + const entt::entity repairShip = f.ships.spawn("repair_ship", 1, QVector2D(0.0f, 0.0f), + false, repairLayout); const entt::entity friendly = f.ships.spawn("interceptor", 1, QVector2D(300.0f, 0.0f)); f.admin.get(friendly).hp = f.admin.get(friendly).maxHp * 0.5f; @@ -509,7 +560,9 @@ TEST_CASE("SensorRange: repair ship does not acquire damaged ally beyond sensor TEST_CASE("SensorRange: salvage ship ignores scrap beyond sensor range", "[sensor]") { Fixture f; - const entt::entity ship = f.ships.spawn("salvage_ship", 1, QVector2D(0.0f, 0.0f)); + const ShipLayoutConfig salvageLayout = makeSingleModuleLayout("salvage_bay_module"); + const entt::entity ship = f.ships.spawn("salvage_ship", 1, QVector2D(0.0f, 0.0f), + false, salvageLayout); f.scraps.spawn(QVector2D(300.0f, 0.0f), 1, 100000); f.ships.clearMovementIntents(); diff --git a/src/test/CombatSystemTest.cpp b/src/test/CombatSystemTest.cpp index b83b799..879a022 100644 --- a/src/test/CombatSystemTest.cpp +++ b/src/test/CombatSystemTest.cpp @@ -13,6 +13,7 @@ #include "FireEvent.h" #include "HealthComponent.h" #include "HqProxyComponent.h" +#include "ModuleOwnerComponent.h" #include "ScrapSystem.h" #include "ShipSystem.h" #include "Simulation.h" @@ -30,7 +31,7 @@ static const ShipDef* findCombatShip(const GameConfig& cfg) { for (const ShipDef& def : cfg.ships.ships) { - if (def.combat) + if (!def.defaultModules.empty()) { return &def; } @@ -38,6 +39,17 @@ static const ShipDef* findCombatShip(const GameConfig& cfg) return nullptr; } +static entt::entity findWeaponChild(EntityAdmin& admin, entt::entity ship) +{ + entt::entity result = entt::null; + admin.forEach( + [&](entt::entity ce, const WeaponComponent&, const ModuleOwnerComponent& o) + { + if (o.owner == ship && result == entt::null) { result = ce; } + }); + return result; +} + // Helper fixture for unit tests that need ships + combat but not a full Simulation. struct CombatFixture { @@ -67,10 +79,13 @@ struct CombatFixture void wireEnemyTarget(entt::entity enemy, entt::entity playerTarget) { - if (admin.hasAll(enemy)) + // Set target on weapon child entity (CombatSystem syncs from ThreatResponse each tick, + // but also setting directly ensures the first tick fires without waiting for sync). + const entt::entity wc = findWeaponChild(admin, enemy); + if (wc != entt::null) { - admin.get(enemy).currentTarget = playerTarget; - admin.get(enemy).cooldownTicks = 0.0f; + admin.get(wc).currentTarget = playerTarget; + admin.get(wc).cooldownTicks = 0.0f; } if (admin.hasAll(enemy)) { @@ -113,7 +128,11 @@ TEST_CASE("CombatSystem: cooldown prevents firing before it expires", "[combat]" const entt::entity player = f.ships.spawn(combatDef->id, 1, QVector2D(4.0f, 5.0f), false); f.wireEnemyTarget(enemy, player); - f.admin.get(enemy).cooldownTicks = 3.0f; // override to 3 + { + const entt::entity wc = findWeaponChild(f.admin, enemy); + REQUIRE(f.admin.isValid(wc)); + f.admin.get(wc).cooldownTicks = 3.0f; // override to 3 + } auto enemyFiredIn = [&enemy](const std::vector& evts) { diff --git a/src/test/ConfigLoaderTest.cpp b/src/test/ConfigLoaderTest.cpp index 544b75b..3d523dc 100644 --- a/src/test/ConfigLoaderTest.cpp +++ b/src/test/ConfigLoaderTest.cpp @@ -108,23 +108,19 @@ TEST_CASE("ConfigLoader loads the committed bin/config/ configs end-to-end", "[c REQUIRE(ironIngotIt->outputs.size() == 1); REQUIRE_FALSE(ironIngotIt->outputs[0].probability.has_value()); - // ships.toml — combat ships have a combat section; salvage ships don't. + // ships.toml — combat ships have default_modules with a weapon; salvage ships don't. const auto interceptorIt = std::find_if( cfg.ships.ships.begin(), cfg.ships.ships.end(), [](const ShipDef& s) { return s.id == "interceptor"; }); REQUIRE(interceptorIt != cfg.ships.ships.end()); - REQUIRE(interceptorIt->combat.has_value()); - REQUIRE_FALSE(interceptorIt->salvage.has_value()); - REQUIRE_FALSE(interceptorIt->repair.has_value()); - REQUIRE(interceptorIt->combat->damageFormula.evaluate(5.0) == Approx(20.0)); // "10 + 2*x" + REQUIRE_FALSE(interceptorIt->defaultModules.empty()); + REQUIRE(interceptorIt->defaultModules[0].moduleId == "laser_cannon"); const auto salvageShipIt = std::find_if( cfg.ships.ships.begin(), cfg.ships.ships.end(), [](const ShipDef& s) { return s.id == "salvage_ship"; }); REQUIRE(salvageShipIt != cfg.ships.ships.end()); - REQUIRE_FALSE(salvageShipIt->combat.has_value()); - REQUIRE(salvageShipIt->salvage.has_value()); - REQUIRE(salvageShipIt->salvage->cargoCapacity == 10); + REQUIRE(salvageShipIt->defaultModules.empty()); // stations.toml REQUIRE(cfg.stations.playerStation.level == 5); diff --git a/src/test/ShipTest.cpp b/src/test/ShipTest.cpp index 4475719..a5e912d 100644 --- a/src/test/ShipTest.cpp +++ b/src/test/ShipTest.cpp @@ -3,6 +3,7 @@ #include #include +#include #include #include "BuildingId.h" @@ -10,11 +11,14 @@ #include "DynamicBodyComponent.h" #include "EntityAdmin.h" #include "HealthComponent.h" +#include "ModuleOwnerComponent.h" #include "RepairBehaviorComponent.h" #include "RepairToolComponent.h" +#include "Rotation.h" #include "SalvageBehaviorComponent.h" #include "SalvageCargoComponent.h" #include "SensorRangeComponent.h" +#include "ShipLayout.h" #include "ShipSystem.h" #include "Tick.h" #include "ThreatResponseBehaviorComponent.h" @@ -26,10 +30,58 @@ static GameConfig loadConfig() } // --------------------------------------------------------------------------- -// Combat ship +// Helpers // --------------------------------------------------------------------------- -TEST_CASE("ShipSystem: interceptor spawn has weapon and threatResponse, no cargo or repair", +static entt::entity firstWeaponChild(EntityAdmin& admin, entt::entity ship) +{ + entt::entity result = entt::null; + admin.forEach( + [&](entt::entity ce, const WeaponComponent&, const ModuleOwnerComponent& o) + { + if (o.owner == ship && result == entt::null) { result = ce; } + }); + return result; +} + +static entt::entity firstSalvageChild(EntityAdmin& admin, entt::entity ship) +{ + entt::entity result = entt::null; + admin.forEach( + [&](entt::entity ce, const SalvageCargoComponent&, const ModuleOwnerComponent& o) + { + if (o.owner == ship && result == entt::null) { result = ce; } + }); + return result; +} + +static entt::entity firstRepairChild(EntityAdmin& admin, entt::entity ship) +{ + entt::entity result = entt::null; + admin.forEach( + [&](entt::entity ce, const RepairToolComponent&, const ModuleOwnerComponent& o) + { + if (o.owner == ship && result == entt::null) { result = ce; } + }); + return result; +} + +static ShipLayoutConfig makeSingleModuleLayout(const std::string& moduleId) +{ + PlacedModule pm; + pm.moduleId = moduleId; + pm.position = QPoint(0, 0); + pm.rotation = Rotation::East; + ShipLayoutConfig layout; + layout.placedModules.push_back(pm); + return layout; +} + +// --------------------------------------------------------------------------- +// Combat ship (interceptor has default_modules = [laser_cannon]) +// --------------------------------------------------------------------------- + +TEST_CASE("ShipSystem: interceptor spawn has weapon child and threatResponse, no cargo or repair", "[ship]") { EntityAdmin admin; @@ -39,10 +91,10 @@ TEST_CASE("ShipSystem: interceptor spawn has weapon and threatResponse, no cargo const entt::entity e = ss.spawn("interceptor", 1, QVector2D(0.0f, 0.0f)); REQUIRE(admin.isValid(e)); - REQUIRE(admin.hasAll(e)); + REQUIRE(admin.isValid(firstWeaponChild(admin, e))); REQUIRE(admin.hasAll(e)); - REQUIRE_FALSE(admin.hasAll(e)); - REQUIRE_FALSE(admin.hasAll(e)); + REQUIRE_FALSE(admin.isValid(firstSalvageChild(admin, e))); + REQUIRE_FALSE(admin.isValid(firstRepairChild(admin, e))); REQUIRE_FALSE(admin.hasAll(e)); REQUIRE_FALSE(admin.hasAll(e)); } @@ -58,14 +110,15 @@ TEST_CASE("ShipSystem: interceptor level 1 stats match config formulas", "[ship] // hp_formula = "40 + 5*x" at x=1 → 45 REQUIRE(admin.get(e).maxHp == Approx(45.0f)); REQUIRE(admin.get(e).hp == Approx(45.0f)); - // damage_formula = "10 + 2*x" at x=1 → 12 - REQUIRE(admin.get(e).damage == Approx(12.0f)); - // attack_range_formula = "150" - REQUIRE(admin.get(e).range == Approx(150.0f)); // sensor_range_formula = "200" REQUIRE(admin.get(e).value == Approx(200.0f)); - // cooldownTicks starts at 0 - REQUIRE(admin.get(e).cooldownTicks == Approx(0.0f)); + + // laser_cannon: damage_formula = "2", attack_range_formula = "5" + const entt::entity wc = firstWeaponChild(admin, e); + REQUIRE(admin.isValid(wc)); + REQUIRE(admin.get(wc).damage == Approx(2.0f)); + REQUIRE(admin.get(wc).range == Approx(5.0f)); + REQUIRE(admin.get(wc).cooldownTicks == Approx(0.0f)); } TEST_CASE("ShipSystem: interceptor level 5 hp matches formula", "[ship]") @@ -94,22 +147,23 @@ TEST_CASE("ShipSystem: interceptor level 0 maxSpeedPerTick matches formula / kTi } // --------------------------------------------------------------------------- -// Salvage ship +// Salvage ship (spawned with salvage_bay_module layout) // --------------------------------------------------------------------------- -TEST_CASE("ShipSystem: salvage_ship spawn has cargo and scrapCollector, no weapon", +TEST_CASE("ShipSystem: salvage_ship spawn with salvage module has cargo child and behavior, no weapon", "[ship]") { EntityAdmin admin; const GameConfig cfg = loadConfig(); ShipSystem ss(cfg, admin); - const entt::entity e = ss.spawn("salvage_ship", 1, QVector2D(0.0f, 0.0f)); + const ShipLayoutConfig layout = makeSingleModuleLayout("salvage_bay_module"); + const entt::entity e = ss.spawn("salvage_ship", 1, QVector2D(0.0f, 0.0f), false, layout); - REQUIRE(admin.hasAll(e)); + REQUIRE(admin.isValid(firstSalvageChild(admin, e))); REQUIRE(admin.hasAll(e)); - REQUIRE_FALSE(admin.hasAll(e)); - REQUIRE_FALSE(admin.hasAll(e)); + REQUIRE_FALSE(admin.isValid(firstWeaponChild(admin, e))); + REQUIRE_FALSE(admin.isValid(firstRepairChild(admin, e))); } TEST_CASE("ShipSystem: salvage_ship cargo capacity matches config", "[ship]") @@ -118,32 +172,37 @@ TEST_CASE("ShipSystem: salvage_ship cargo capacity matches config", "[ship]") const GameConfig cfg = loadConfig(); ShipSystem ss(cfg, admin); - const entt::entity e = ss.spawn("salvage_ship", 1, QVector2D(0.0f, 0.0f)); + const ShipLayoutConfig layout = makeSingleModuleLayout("salvage_bay_module"); + const entt::entity e = ss.spawn("salvage_ship", 1, QVector2D(0.0f, 0.0f), false, layout); - // cargo_capacity = 10 - REQUIRE(admin.get(e).capacity == 10); - REQUIRE(admin.get(e).current == 0); + // salvage_bay_module: cargo_capacity_formula = "10", collection_range_formula = "50" + const entt::entity sc = firstSalvageChild(admin, e); + REQUIRE(admin.isValid(sc)); + REQUIRE(admin.get(sc).capacity == 10); + REQUIRE(admin.get(sc).current == 0); REQUIRE(admin.get(e).deliveryBay == kInvalidBuildingId); REQUIRE_FALSE(admin.get(e).scrapTarget.has_value()); + REQUIRE(admin.get(e).maxCollectionRange == Approx(50.0f)); } // --------------------------------------------------------------------------- -// Repair ship +// Repair ship (spawned with repair_tool_module layout) // --------------------------------------------------------------------------- -TEST_CASE("ShipSystem: repair_ship spawn has repairTool and repairBehavior, no weapon", +TEST_CASE("ShipSystem: repair_ship spawn with repair module has repair child and behavior, no weapon", "[ship]") { EntityAdmin admin; const GameConfig cfg = loadConfig(); ShipSystem ss(cfg, admin); - const entt::entity e = ss.spawn("repair_ship", 1, QVector2D(0.0f, 0.0f)); + const ShipLayoutConfig layout = makeSingleModuleLayout("repair_tool_module"); + const entt::entity e = ss.spawn("repair_ship", 1, QVector2D(0.0f, 0.0f), false, layout); - REQUIRE(admin.hasAll(e)); + REQUIRE(admin.isValid(firstRepairChild(admin, e))); REQUIRE(admin.hasAll(e)); - REQUIRE_FALSE(admin.hasAll(e)); - REQUIRE_FALSE(admin.hasAll(e)); + REQUIRE_FALSE(admin.isValid(firstWeaponChild(admin, e))); + REQUIRE_FALSE(admin.isValid(firstSalvageChild(admin, e))); } TEST_CASE("ShipSystem: repair_ship level 1 repair stats match config formulas", "[ship]") @@ -152,12 +211,17 @@ TEST_CASE("ShipSystem: repair_ship level 1 repair stats match config formulas", const GameConfig cfg = loadConfig(); ShipSystem ss(cfg, admin); - const entt::entity e = ss.spawn("repair_ship", 1, QVector2D(0.0f, 0.0f)); + const ShipLayoutConfig layout = makeSingleModuleLayout("repair_tool_module"); + const entt::entity e = ss.spawn("repair_ship", 1, QVector2D(0.0f, 0.0f), false, layout); - // repair_rate_formula = "5 + x" at x=1 → 6 - REQUIRE(admin.get(e).ratePerTick == Approx(6.0f)); + // repair_tool_module: repair_rate_formula = "5 + x" at x=1 → 6 / kTickRateHz + const float expectedRate = 6.0f / static_cast(kTickRateHz); + const entt::entity rc = firstRepairChild(admin, e); + REQUIRE(admin.isValid(rc)); + REQUIRE(admin.get(rc).ratePerTick == Approx(expectedRate)); // repair_range_formula = "80" - REQUIRE(admin.get(e).range == Approx(80.0f)); + REQUIRE(admin.get(rc).range == Approx(80.0f)); + REQUIRE(admin.get(e).maxRepairRange == Approx(80.0f)); } // --------------------------------------------------------------------------- @@ -171,22 +235,26 @@ TEST_CASE("ShipSystem: spawned ships are valid entities", "[ship]") ShipSystem ss(cfg, admin); const entt::entity e1 = ss.spawn("interceptor", 1, QVector2D(0.0f, 0.0f)); - const entt::entity e2 = ss.spawn("salvage_ship", 1, QVector2D(1.0f, 0.0f)); + const ShipLayoutConfig salvageLayout = makeSingleModuleLayout("salvage_bay_module"); + const entt::entity e2 = ss.spawn("salvage_ship", 1, QVector2D(1.0f, 0.0f), false, salvageLayout); REQUIRE(admin.isValid(e1)); REQUIRE(admin.isValid(e2)); REQUIRE(e1 != e2); } -TEST_CASE("ShipSystem: despawn removes the ship", "[ship]") +TEST_CASE("ShipSystem: despawn removes the ship and its weapon children", "[ship]") { EntityAdmin admin; const GameConfig cfg = loadConfig(); ShipSystem ss(cfg, admin); - const entt::entity e = ss.spawn("interceptor", 1, QVector2D(0.0f, 0.0f)); + const entt::entity e = ss.spawn("interceptor", 1, QVector2D(0.0f, 0.0f)); + const entt::entity wc = firstWeaponChild(admin, e); REQUIRE(admin.isValid(e)); + REQUIRE(admin.isValid(wc)); ss.despawn(e); REQUIRE_FALSE(admin.isValid(e)); + REQUIRE_FALSE(admin.isValid(wc)); } diff --git a/src/test/WaveSystemTest.cpp b/src/test/WaveSystemTest.cpp index e49847d..1b3f9fa 100644 --- a/src/test/WaveSystemTest.cpp +++ b/src/test/WaveSystemTest.cpp @@ -10,6 +10,7 @@ #include "FactionComponent.h" #include "HealthComponent.h" #include "HqProxyComponent.h" +#include "ModuleOwnerComponent.h" #include "Rotation.h" #include "ShipIdentityComponent.h" #include "ShipSystem.h" @@ -166,13 +167,14 @@ TEST_CASE("WaveSystem: HQ anchor is at asteroid right edge", "[wave]") TEST_CASE("WaveSystem: player stations have weapon set", "[wave]") { - const Simulation sim(loadConfig(), 42); + Simulation sim(loadConfig(), 42); int armedPlayerStations = 0; - sim.admin().forEach( - [&](entt::entity /*e*/, const StationBodyComponent& /*sb*/, const FactionComponent& f, - const WeaponComponent& w) + sim.admin().forEach( + [&](entt::entity /*e*/, const WeaponComponent& w, const ModuleOwnerComponent& mo) { + if (!sim.admin().hasAll(mo.owner)) { return; } + const FactionComponent& f = sim.admin().get(mo.owner); if (!f.isEnemy) { ++armedPlayerStations; @@ -186,13 +188,14 @@ TEST_CASE("WaveSystem: player stations have weapon set", "[wave]") TEST_CASE("WaveSystem: enemy stations have weapon set", "[wave]") { - const Simulation sim(loadConfig(), 42); + Simulation sim(loadConfig(), 42); int armedEnemyStations = 0; - sim.admin().forEach( - [&](entt::entity /*e*/, const StationBodyComponent& /*sb*/, const FactionComponent& f, - const WeaponComponent& w) + sim.admin().forEach( + [&](entt::entity /*e*/, const WeaponComponent& w, const ModuleOwnerComponent& mo) { + if (!sim.admin().hasAll(mo.owner)) { return; } + const FactionComponent& f = sim.admin().get(mo.owner); if (f.isEnemy) { ++armedEnemyStations; diff --git a/src/ui/GameWorldView.cpp b/src/ui/GameWorldView.cpp index b523d58..a3c06d1 100644 --- a/src/ui/GameWorldView.cpp +++ b/src/ui/GameWorldView.cpp @@ -24,8 +24,6 @@ #include "FactionComponent.h" #include "HealthComponent.h" #include "PositionComponent.h" -#include "RepairToolComponent.h" -#include "SalvageCargoComponent.h" #include "ScrapSystem.h" #include "SensorRangeComponent.h" #include "ShipIdentityComponent.h" @@ -62,13 +60,6 @@ Rotation rotateCounterClockwise(Rotation r) return Rotation::East; } -ShipRole shipRoleFromComponents(bool isEnemy, bool hasCargo, bool hasRepairTool) -{ - if (isEnemy) { return ShipRole::Enemy; } - if (hasCargo) { return ShipRole::Salvage; } - if (hasRepairTool){ return ShipRole::Repair; } - return ShipRole::PlayerCombat; -} QString toDisplayName(const std::string& id) { @@ -847,15 +838,12 @@ void GameWorldView::drawShips(QPainter& painter) { m_sim->admin().forEach( - [&](entt::entity e, const ShipIdentityComponent& /*si*/, + [&](entt::entity /*e*/, const ShipIdentityComponent& si, const PositionComponent& pos, const FacingComponent& facing, - const FactionComponent& fac) + const FactionComponent& /*fac*/) { - const bool hasCargo = m_sim->admin().hasAll(e); - const bool hasRepair = m_sim->admin().hasAll(e); - const ShipRole role = shipRoleFromComponents(fac.isEnemy, hasCargo, hasRepair); - const std::map::const_iterator it = - m_visuals->ships.find(role); + const std::map::const_iterator it = + m_visuals->ships.find(si.schematicId); if (it == m_visuals->ships.end()) { return; } const QPointF center = worldToWidget(pos.value); @@ -884,15 +872,12 @@ void GameWorldView::drawDebugSensorRanges(QPainter& painter) painter.setBrush(Qt::NoBrush); m_sim->admin().forEach( - [&](entt::entity e, const ShipIdentityComponent& /*si*/, + [&](entt::entity /*e*/, const ShipIdentityComponent& si, const PositionComponent& pos, const FacingComponent& /*facing*/, - const FactionComponent& fac, const SensorRangeComponent& sensor) + const FactionComponent& /*fac*/, const SensorRangeComponent& sensor) { - const bool hasCargo = m_sim->admin().hasAll(e); - const bool hasRepair = m_sim->admin().hasAll(e); - const ShipRole role = shipRoleFromComponents(fac.isEnemy, hasCargo, hasRepair); - const std::map::const_iterator it = - m_visuals->ships.find(role); + const std::map::const_iterator it = + m_visuals->ships.find(si.schematicId); if (it == m_visuals->ships.end()) { return; } const QPointF center = worldToWidget(pos.value); diff --git a/src/ui/VisualsConfig.h b/src/ui/VisualsConfig.h index bd693e1..4c0d352 100644 --- a/src/ui/VisualsConfig.h +++ b/src/ui/VisualsConfig.h @@ -55,14 +55,6 @@ struct ToastVisuals int fontSize; }; -enum class ShipRole -{ - PlayerCombat, - Salvage, - Repair, - Enemy, -}; - struct VisualsConfig { TileVisuals asteroid; @@ -70,7 +62,7 @@ struct VisualsConfig std::map buildings; std::map items; - std::map ships; + std::map ships; BeamVisuals beams; OverlayVisuals overlays; diff --git a/src/ui/VisualsLoader.cpp b/src/ui/VisualsLoader.cpp index def83f7..0be1f6f 100644 --- a/src/ui/VisualsLoader.cpp +++ b/src/ui/VisualsLoader.cpp @@ -190,17 +190,20 @@ VisualsConfig VisualsLoader::load(const std::string& path) } } - // Ships + // Ships (dynamic keys: each key is a schematic id) { 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"); + for (toml::table::iterator it = ships.begin(); it != ships.end(); ++it) + { + std::string schematicId = std::string(it->first.str()); + toml::table* sub = it->second.as_table(); + if (sub == nullptr) + { + throw std::runtime_error("visuals.toml: ships." + schematicId + + " is not a table"); + } + cfg.ships[schematicId] = parseShip(*sub, "ships." + schematicId); + } } // Beams