diff --git a/bin/app/data/config/modules.toml b/bin/app/data/config/modules.toml new file mode 100644 index 0000000..b2953f4 --- /dev/null +++ b/bin/app/data/config/modules.toml @@ -0,0 +1,51 @@ +[[module]] +id = "armor_plate" +surface_mask = ["OO", "OO"] +materials = [{item = "iron_ingot", amount = 2}] +player_production_level = 1 +production_time_seconds = 3 +threat_cost = 2.0 +fill_color = "#808080" +glyph = "A" + +[module.health] +multiplied_hp_formula = "1.0 + 0.2 * x" + +[[module]] +id = "sensor_booster" +surface_mask = ["O"] +materials = [{item = "circuit_board", amount = 1}] +player_production_level = 1 +production_time_seconds = 2 +threat_cost = 1.0 +fill_color = "#40A0FF" +glyph = "S" + +[module.sensor] +added_sensor_range_formula = "2 + x" + +[[module]] +id = "weapon_upgrade" +surface_mask = ["OO"] +materials = [{item = "iron_ingot", amount = 1}, {item = "circuit_board", amount = 1}] +player_production_level = 1 +production_time_seconds = 4 +threat_cost = 3.0 +fill_color = "#FF4040" +glyph = "W" + +[module.combat] +multiplied_damage_formula = "1.0 + 0.15 * x" + +[[module]] +id = "engine_booster" +surface_mask = ["O", "O"] +materials = [{item = "iron_ingot", amount = 2}] +player_production_level = 1 +production_time_seconds = 3 +threat_cost = 1.5 +fill_color = "#40FF80" +glyph = "E" + +[module.movement] +added_speed_formula = "0.5 * x" diff --git a/bin/app/data/config/ships.toml b/bin/app/data/config/ships.toml index f4490ac..3833d0e 100644 --- a/bin/app/data/config/ships.toml +++ b/bin/app/data/config/ships.toml @@ -1,6 +1,7 @@ [[ship]] id = "fighter" available_from_start = true +layout = ["XOX", "OOO", "XOX"] [ship.schematic] materials = [{item = "iron_ingot", amount = 3}, {item = "circuit_board", amount = 1}] @@ -32,6 +33,7 @@ scrap_drop = 2 [[ship]] id = "sniper" available_from_start = true +layout = ["XOOX", "OOOO", "XOOX"] [ship.schematic] materials = [{item = "iron_ingot", amount = 3}, {item = "circuit_board", amount = 1}] @@ -63,6 +65,7 @@ scrap_drop = 2 [[ship]] id = "gunship" available_from_start = true +layout = ["XOOOX", "OOOOO", "OOOOO", "XOOOX"] [ship.schematic] materials = [{item = "iron_ingot", amount = 3}, {item = "circuit_board", amount = 1}] @@ -94,6 +97,7 @@ scrap_drop = 2 [[ship]] id = "salvage_ship" available_from_start = true +layout = ["OOO", "OOO"] [ship.schematic] materials = [{item = "iron_ingot", amount = 4}] @@ -123,6 +127,7 @@ scrap_drop = 2 [[ship]] id = "repair_ship" available_from_start = false +layout = ["XOX", "OOO", "XOX"] [ship.schematic] materials = [{item = "iron_ingot", amount = 4}, {item = "circuit_board", amount = 2}] diff --git a/bin/test/data/config/modules.toml b/bin/test/data/config/modules.toml new file mode 100644 index 0000000..631295a --- /dev/null +++ b/bin/test/data/config/modules.toml @@ -0,0 +1,38 @@ +[[module]] +id = "armor_plate" +surface_mask = ["OO"] +materials = [{item = "iron_ingot", amount = 2}] +player_production_level = 1 +production_time_seconds = 3 +threat_cost = 2.0 +fill_color = "#808080" +glyph = "A" + +[module.health] +multiplied_hp_formula = "1.5" + +[[module]] +id = "sensor_booster" +surface_mask = ["O"] +materials = [{item = "circuit_board", amount = 1}] +player_production_level = 1 +production_time_seconds = 2 +threat_cost = 1.0 +fill_color = "#40A0FF" +glyph = "S" + +[module.sensor] +added_sensor_range_formula = "10" + +[[module]] +id = "weapon_upgrade" +surface_mask = ["O"] +materials = [{item = "iron_ingot", amount = 1}, {item = "circuit_board", amount = 1}] +player_production_level = 1 +production_time_seconds = 4 +threat_cost = 3.0 +fill_color = "#FF4040" +glyph = "W" + +[module.combat] +multiplied_damage_formula = "1.2" diff --git a/bin/test/data/config/ships.toml b/bin/test/data/config/ships.toml index d67c2a6..9ee38ec 100644 --- a/bin/test/data/config/ships.toml +++ b/bin/test/data/config/ships.toml @@ -1,6 +1,7 @@ [[ship]] id = "interceptor" available_from_start = true +layout = ["XOX", "OOO", "XOX"] [ship.schematic] materials = [{item = "iron_ingot", amount = 3}, {item = "circuit_board", amount = 1}] @@ -31,6 +32,7 @@ scrap_drop = 2 [[ship]] id = "destroyer" available_from_start = true +layout = ["XOOX", "OOOO", "XOOX"] [ship.schematic] materials = [{item = "iron_ingot", amount = 5}, {item = "circuit_board", amount = 2}] @@ -61,6 +63,7 @@ scrap_drop = 4 [[ship]] id = "salvage_ship" available_from_start = true +layout = ["OOO", "OOO"] [ship.schematic] materials = [{item = "iron_ingot", amount = 4}] @@ -90,6 +93,7 @@ scrap_drop = 2 [[ship]] id = "repair_ship" available_from_start = false +layout = ["XOX", "OOO", "XOX"] [ship.schematic] materials = [{item = "iron_ingot", amount = 4}, {item = "circuit_board", amount = 2}] diff --git a/src/lib/config/CMakeLists.txt b/src/lib/config/CMakeLists.txt index 389fe83..b161a94 100644 --- a/src/lib/config/CMakeLists.txt +++ b/src/lib/config/CMakeLists.txt @@ -7,6 +7,7 @@ SET(HDRS ${CMAKE_CURRENT_SOURCE_DIR}/ShipsConfig.h ${CMAKE_CURRENT_SOURCE_DIR}/StationsConfig.h ${CMAKE_CURRENT_SOURCE_DIR}/GameConfig.h + ${CMAKE_CURRENT_SOURCE_DIR}/ModulesConfig.h ${CMAKE_CURRENT_SOURCE_DIR}/ConfigLoader.h ${CMAKE_CURRENT_SOURCE_DIR}/SurfaceMask.h ${CMAKE_CURRENT_SOURCE_DIR}/BlueprintSerializer.h diff --git a/src/lib/config/ConfigLoader.cpp b/src/lib/config/ConfigLoader.cpp index 4e4b1a7..35561b2 100644 --- a/src/lib/config/ConfigLoader.cpp +++ b/src/lib/config/ConfigLoader.cpp @@ -358,6 +358,7 @@ ShipsConfig ConfigLoader::loadShips(const std::string& path) ShipDef def; def.id = requireString(mt["id"], file, elemPath + ".id"); def.availableFromStart = requireBool(mt["available_from_start"], file, elemPath + ".available_from_start"); + def.layout = requireStringArray(mt["layout"], file, elemPath + ".layout"); // Schematic { @@ -498,6 +499,106 @@ StationsConfig ConfigLoader::loadStations(const std::string& path) return cfg; } +// Known category→stat mappings for module stat modifier discovery. +struct StatEntry +{ + const char* category; + const char* stat; +}; + +static const StatEntry kKnownStats[] = { + {"health", "hp"}, + {"movement", "speed"}, + {"sensor", "sensor_range"}, + {"combat", "damage"}, + {"combat", "attack_range"}, + {"combat", "attack_rate"}, + {"repair", "repair_rate"}, + {"repair", "repair_range"}, +}; + +ModulesConfig ConfigLoader::loadModules(const std::string& path) +{ + const std::string file = "modules.toml"; + toml::table tbl = parseFile(path, file); + + ModulesConfig cfg; + + if (!tbl.contains("module")) + { + return cfg; + } + + const toml::array& arr = requireArray(tbl["module"], file, "module"); + + for (std::size_t i = 0; i < arr.size(); ++i) + { + const std::string elemPath = "module[" + std::to_string(i) + "]"; + const toml::table* st = arr[i].as_table(); + if (st == nullptr) + { + throw makeError(file, elemPath, "not a table"); + } + toml::table& mt = const_cast(*st); + + ModuleDef def; + def.id = requireString(mt["id"], file, elemPath + ".id"); + def.surfaceMask = requireStringArray(mt["surface_mask"], file, elemPath + ".surface_mask"); + def.playerProductionLevel = static_cast(requireInt( + mt["player_production_level"], file, elemPath + ".player_production_level")); + def.productionTimeSeconds = requireDouble( + mt["production_time_seconds"], file, elemPath + ".production_time_seconds"); + def.threatCost = requireDouble(mt["threat_cost"], file, elemPath + ".threat_cost"); + def.fillColor = requireString(mt["fill_color"], file, elemPath + ".fill_color"); + def.glyph = requireString(mt["glyph"], file, elemPath + ".glyph"); + + // Materials + { + const toml::array& materials = requireArray(mt["materials"], file, elemPath + ".materials"); + def.materials = parseIngredients(materials, file, elemPath + ".materials"); + } + + // Stat modifiers from [module.] sub-tables + for (const StatEntry& se : kKnownStats) + { + if (!mt.contains(se.category)) + { + continue; + } + const toml::table& catTable = requireTable(mt[se.category], file, + elemPath + "." + se.category); + toml::table& catMt = const_cast(catTable); + + const std::string addedKey = std::string("added_") + se.stat + "_formula"; + const std::string multipliedKey = std::string("multiplied_") + se.stat + "_formula"; + + if (catMt.contains(addedKey)) + { + ModuleStatModifier mod; + mod.stat = se.stat; + mod.modifierType = "additive"; + mod.formula = requireFormula(catMt[addedKey], file, + elemPath + "." + se.category + "." + addedKey); + def.statModifiers.push_back(std::move(mod)); + } + + if (catMt.contains(multipliedKey)) + { + ModuleStatModifier mod; + mod.stat = se.stat; + mod.modifierType = "multiplicative"; + mod.formula = requireFormula(catMt[multipliedKey], file, + elemPath + "." + se.category + "." + multipliedKey); + def.statModifiers.push_back(std::move(mod)); + } + } + + cfg.modules.push_back(std::move(def)); + } + + return cfg; +} + GameConfig ConfigLoader::loadFromDirectory(const std::string& configDir) { GameConfig cfg; @@ -506,5 +607,6 @@ GameConfig ConfigLoader::loadFromDirectory(const std::string& configDir) cfg.recipes = loadRecipes(configDir + "/recipes.toml"); cfg.ships = loadShips(configDir + "/ships.toml"); cfg.stations = loadStations(configDir + "/stations.toml"); + cfg.modules = loadModules(configDir + "/modules.toml"); return cfg; } diff --git a/src/lib/config/ConfigLoader.h b/src/lib/config/ConfigLoader.h index 7983f44..1c909ae 100644 --- a/src/lib/config/ConfigLoader.h +++ b/src/lib/config/ConfigLoader.h @@ -4,7 +4,7 @@ #include "GameConfig.h" -// Parses the five simulation TOML files from a directory and returns a fully +// Parses all simulation TOML files from a directory and returns a fully // populated, immutable GameConfig. Throws std::runtime_error on any parse or // validation failure; the exception message identifies the offending file, // field, or formula (see architecture.md "Config Loading"). @@ -21,4 +21,5 @@ public: static RecipesConfig loadRecipes(const std::string& path); static ShipsConfig loadShips(const std::string& path); static StationsConfig loadStations(const std::string& path); + static ModulesConfig loadModules(const std::string& path); }; diff --git a/src/lib/config/GameConfig.h b/src/lib/config/GameConfig.h index a37e1cf..1226b58 100644 --- a/src/lib/config/GameConfig.h +++ b/src/lib/config/GameConfig.h @@ -5,8 +5,9 @@ #include "RecipesConfig.h" #include "ShipsConfig.h" #include "StationsConfig.h" +#include "ModulesConfig.h" -// Aggregate of all five simulation config files. Loaded at startup and reloaded +// Aggregate of all simulation config files. Loaded at startup and reloaded // from disk on each game restart (REQ-CFG-RELOAD). See architecture.md "Config Loading". struct GameConfig { @@ -15,4 +16,5 @@ struct GameConfig RecipesConfig recipes; ShipsConfig ships; StationsConfig stations; + ModulesConfig modules; }; diff --git a/src/lib/config/ModulesConfig.h b/src/lib/config/ModulesConfig.h new file mode 100644 index 0000000..1ab912f --- /dev/null +++ b/src/lib/config/ModulesConfig.h @@ -0,0 +1,34 @@ +#pragma once + +#include +#include + +#include "Formula.h" +#include "RecipesConfig.h" + +// A single stat modifier contributed by a module instance. +// REQ-MOD-STAT-CALC: final = base * (1 + sum(m_i - 1)) + sum(additives). +struct ModuleStatModifier +{ + std::string stat; // e.g. "hp", "speed", "sensor_range" + std::string modifierType; // "additive" or "multiplicative" + Formula formula; +}; + +struct ModuleDef +{ + std::string id; + std::vector surfaceMask; + std::vector materials; + int playerProductionLevel; + double productionTimeSeconds; + double threatCost; + std::string fillColor; + std::string glyph; + std::vector statModifiers; +}; + +struct ModulesConfig +{ + std::vector modules; +}; diff --git a/src/lib/config/ShipsConfig.h b/src/lib/config/ShipsConfig.h index 7859223..8be4c82 100644 --- a/src/lib/config/ShipsConfig.h +++ b/src/lib/config/ShipsConfig.h @@ -69,6 +69,7 @@ struct ShipDef { std::string id; bool availableFromStart; + std::vector layout; ShipSchematic schematic; ShipThreat threat; diff --git a/src/lib/sim/Building.h b/src/lib/sim/Building.h index 43833d8..04ccf59 100644 --- a/src/lib/sim/Building.h +++ b/src/lib/sim/Building.h @@ -14,6 +14,7 @@ #include "ItemType.h" #include "Port.h" #include "Rotation.h" +#include "ShipLayout.h" #include "Tick.h" // Per-material input buffer for a production building. @@ -50,6 +51,7 @@ struct ConstructionSite BuildingType type = BuildingType::Miner; std::string recipeId; // may be configured before completion Tick completesAt = 0; // 0 = queued but not yet started + std::optional shipLayout; }; // Weapon state for stationary structures (defence stations). @@ -85,6 +87,9 @@ struct Building std::vector inputPorts; // perimeter tiles (minus output-port tiles), // direction pointing INTO building + // Module layout for shipyards (REQ-MOD-LAYOUT). + std::optional shipLayout; + // Set only for defence stations; nullopt for all other building types. std::optional weapon; }; diff --git a/src/lib/sim/BuildingSystem.cpp b/src/lib/sim/BuildingSystem.cpp index 5e855c7..8c1927c 100644 --- a/src/lib/sim/BuildingSystem.cpp +++ b/src/lib/sim/BuildingSystem.cpp @@ -11,7 +11,8 @@ BuildingSystem::BuildingSystem(const GameConfig& config, BeltSystem& belts, std::function allocateId, std::function addBuildingBlocks, - std::function spawnShip, + std::function&)> spawnShip, std::mt19937& rng) : m_config(config) , m_belts(belts) @@ -63,6 +64,18 @@ const ShipDef* BuildingSystem::findShipDef(const std::string& id) const return nullptr; } +const ModuleDef* BuildingSystem::findModuleDef(const std::string& id) const +{ + for (const ModuleDef& def : m_config.modules.modules) + { + if (def.id == id) + { + return &def; + } + } + return nullptr; +} + void BuildingSystem::initBuffers(Building& b, const RecipeDef& recipe) const { b.inputBuffer.counts.clear(); @@ -117,6 +130,23 @@ void BuildingSystem::initShipyardBuffers(Building& b) const b.inputBuffer.counts[type] = 0; b.inputBuffer.caps[type] = 2 * ing.amount; } + if (b.shipLayout.has_value()) + { + for (const PlacedModule& pm : b.shipLayout->placedModules) + { + const ModuleDef* modDef = findModuleDef(pm.moduleId); + if (!modDef) + { + continue; + } + for (const RecipeIngredient& ing : modDef->materials) + { + const ItemType type{ing.item}; + b.inputBuffer.counts.try_emplace(type, 0); + b.inputBuffer.caps[type] += 2 * ing.amount; + } + } + } } std::vector BuildingSystem::computeInputPorts(const Building& b) const @@ -303,6 +333,7 @@ void BuildingSystem::setRecipe(EntityId id, const std::string& recipeId) if (site.id == id) { site.recipeId = recipeId; + site.shipLayout = std::nullopt; return; } } @@ -313,6 +344,7 @@ void BuildingSystem::setRecipe(EntityId id, const std::string& recipeId) if (building.id == id) { building.recipeId = recipeId; + building.shipLayout = std::nullopt; building.inputBuffer.counts.clear(); building.inputBuffer.caps.clear(); building.outputBuffer.items.clear(); @@ -339,6 +371,39 @@ void BuildingSystem::setRecipe(EntityId id, const std::string& recipeId) } } +void BuildingSystem::setShipLayout(EntityId id, const ShipLayoutConfig& layout) +{ + for (ConstructionSite& site : m_constructionQueue) + { + if (site.id == id) + { + site.shipLayout = layout; + return; + } + } + + for (Building& building : m_buildings) + { + if (building.id == id) + { + if (building.production.has_value()) + { + building.production = std::nullopt; + } + building.shipLayout = layout; + building.inputBuffer.counts.clear(); + building.inputBuffer.caps.clear(); + building.outputBuffer.items.clear(); + building.outputBuffer.capacity = 0; + if (!building.recipeId.empty() && building.type == BuildingType::Shipyard) + { + initShipyardBuffers(building); + } + return; + } + } +} + // --------------------------------------------------------------------------- // Tick hooks // --------------------------------------------------------------------------- @@ -383,6 +448,7 @@ void BuildingSystem::tickConstruction(Tick currentTick) building.hp = 100.0f; building.maxHp = 100.0f; building.recipeId = front.recipeId; + building.shipLayout = front.shipLayout; for (const QPoint& cell : mask.bodyCells) { @@ -657,22 +723,44 @@ void BuildingSystem::tickShipyardProduction(Tick currentTick) { const Port& p = building.outputPorts[0]; const QVector2D spawnPos(p.tile.x() + 0.5f, p.tile.y() + 0.5f); - m_spawnShip(building.recipeId, spawnPos); + m_spawnShip(building.recipeId, spawnPos, building.shipLayout); } building.production = std::nullopt; } continue; } - // Idle: check if all materials are available to start a new cycle. - bool inputsOk = true; + // Build combined materials list (base + modules). + std::map requiredMaterials; for (const RecipeIngredient& ing : shipDef->schematic.materials) { - const ItemType type{ing.item}; + requiredMaterials[ing.item] += ing.amount; + } + if (building.shipLayout.has_value()) + { + for (const PlacedModule& pm : building.shipLayout->placedModules) + { + const ModuleDef* modDef = findModuleDef(pm.moduleId); + if (!modDef) + { + continue; + } + for (const RecipeIngredient& ing : modDef->materials) + { + requiredMaterials[ing.item] += ing.amount; + } + } + } + + // Idle: check if all combined materials are available. + bool inputsOk = true; + for (const std::pair& req : requiredMaterials) + { + const ItemType type{req.first}; const std::map::const_iterator it = building.inputBuffer.counts.find(type); const int have = (it != building.inputBuffer.counts.end()) ? it->second : 0; - if (have < ing.amount) + if (have < req.second) { inputsOk = false; break; @@ -683,16 +771,28 @@ void BuildingSystem::tickShipyardProduction(Tick currentTick) continue; } - // Consume materials and start the production cycle. - for (const RecipeIngredient& ing : shipDef->schematic.materials) + // Consume combined materials and start the production cycle. + for (const std::pair& req : requiredMaterials) { - building.inputBuffer.counts[ItemType{ing.item}] -= ing.amount; + building.inputBuffer.counts[ItemType{req.first}] -= req.second; + } + + double totalTime = shipDef->schematic.productionTimeSeconds; + if (building.shipLayout.has_value()) + { + for (const PlacedModule& pm : building.shipLayout->placedModules) + { + const ModuleDef* modDef = findModuleDef(pm.moduleId); + if (modDef) + { + totalTime += modDef->productionTimeSeconds; + } + } } Production prod; prod.recipeId = building.recipeId; - prod.completesAt = currentTick - + secondsToTicks(shipDef->schematic.productionTimeSeconds); + prod.completesAt = currentTick + secondsToTicks(totalTime); building.production = std::move(prod); } } diff --git a/src/lib/sim/BuildingSystem.h b/src/lib/sim/BuildingSystem.h index 9ceb2e1..b61f61a 100644 --- a/src/lib/sim/BuildingSystem.h +++ b/src/lib/sim/BuildingSystem.h @@ -18,6 +18,8 @@ #include "EntityId.h" #include "GameConfig.h" #include "Rotation.h" +#include "ModulesConfig.h" +#include "ShipLayout.h" #include "ShipsConfig.h" #include "Tick.h" @@ -32,7 +34,8 @@ public: BeltSystem& belts, std::function allocateId, std::function addBuildingBlocks, - std::function spawnShip, + std::function&)> spawnShip, std::mt19937& rng); // -- Placement / demolish ------------------------------------------------ @@ -50,6 +53,10 @@ public: // construction site. Clears both buffers on an operational building. void setRecipe(EntityId id, const std::string& recipeId); + // Set the module layout for a shipyard. Cancels in-progress production + // (materials discarded) and reinitializes input buffers (REQ-BLD-SHIPYARD). + void setShipLayout(EntityId id, const ShipLayoutConfig& layout); + // -- Tick hooks (called from Simulation::tick in the documented order) --- void tickConstruction(Tick currentTick); void tickBeltPull(); @@ -121,6 +128,7 @@ private: const BuildingDef* findBuildingDef(BuildingType type) const; const RecipeDef* findRecipe(const std::string& id, BuildingType type) const; const ShipDef* findShipDef(const std::string& id) const; + const ModuleDef* findModuleDef(const std::string& id) const; void initBuffers(Building& b, const RecipeDef& recipe) const; void initShipyardBuffers(Building& b) const; std::vector computeInputPorts(const Building& b) const; @@ -130,7 +138,8 @@ private: BeltSystem& m_belts; std::function m_allocateId; std::function m_addBuildingBlocks; - std::function m_spawnShip; + std::function&)> m_spawnShip; std::mt19937& m_rng; std::vector m_buildings; diff --git a/src/lib/sim/CMakeLists.txt b/src/lib/sim/CMakeLists.txt index d7f75a5..3671232 100644 --- a/src/lib/sim/CMakeLists.txt +++ b/src/lib/sim/CMakeLists.txt @@ -7,6 +7,7 @@ SET(HDRS ${CMAKE_CURRENT_SOURCE_DIR}/BuildingSystem.h ${CMAKE_CURRENT_SOURCE_DIR}/Scrap.h ${CMAKE_CURRENT_SOURCE_DIR}/Ship.h + ${CMAKE_CURRENT_SOURCE_DIR}/ShipLayout.h ${CMAKE_CURRENT_SOURCE_DIR}/ShipSystem.h ${CMAKE_CURRENT_SOURCE_DIR}/ScrapSystem.h ${CMAKE_CURRENT_SOURCE_DIR}/WaveSystem.h diff --git a/src/lib/sim/ShipLayout.h b/src/lib/sim/ShipLayout.h new file mode 100644 index 0000000..f616f5a --- /dev/null +++ b/src/lib/sim/ShipLayout.h @@ -0,0 +1,22 @@ +#pragma once + +#include +#include + +#include + +#include "Rotation.h" + +// A single module placed on a ship's layout grid (REQ-MOD-PLACEMENT). +struct PlacedModule +{ + std::string moduleId; + QPoint position; + Rotation rotation; +}; + +// The complete module configuration for a shipyard's current ship (REQ-MOD-CONFIG). +struct ShipLayoutConfig +{ + std::vector placedModules; +}; diff --git a/src/lib/sim/ShipSystem.cpp b/src/lib/sim/ShipSystem.cpp index 085cb1d..f520733 100644 --- a/src/lib/sim/ShipSystem.cpp +++ b/src/lib/sim/ShipSystem.cpp @@ -3,10 +3,13 @@ #include #include #include +#include +#include #include "Building.h" #include "BuildingSystem.h" #include "BuildingType.h" +#include "ModulesConfig.h" #include "Scrap.h" #include "ScrapSystem.h" #include "Tick.h" @@ -30,8 +33,21 @@ const ShipDef* ShipSystem::findShipDef(const std::string& schematicId) const return nullptr; } +const ModuleDef* ShipSystem::findModuleDef(const std::string& id) const +{ + for (const ModuleDef& def : m_config.modules.modules) + { + if (def.id == id) + { + return &def; + } + } + return nullptr; +} + EntityId ShipSystem::spawn(const std::string& schematicId, int level, QVector2D position, - bool isEnemy) + bool isEnemy, + const std::optional& layout) { const ShipDef* def = findShipDef(schematicId); assert(def != nullptr); @@ -95,6 +111,60 @@ EntityId ShipSystem::spawn(const std::string& schematicId, int level, QVector2D ship.repairBehavior = rb; } + // Apply module stat modifiers (REQ-MOD-STAT-CALC). + if (layout.has_value() && !layout->placedModules.empty()) + { + std::map> mods; + for (const PlacedModule& pm : layout->placedModules) + { + 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; + } + } + } + + 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); + } + }; + + applyMod(ship.maxHp, "hp"); + ship.hp = ship.maxHp; + applyMod(ship.speedPerTick, "speed"); + applyMod(ship.sensorRange, "sensor_range"); + if (ship.weapon.has_value()) + { + applyMod(ship.weapon->damage, "damage"); + applyMod(ship.weapon->range, "attack_range"); + applyMod(ship.weapon->fireRateHz, "attack_rate"); + } + if (ship.repairTool.has_value()) + { + applyMod(ship.repairTool->ratePerTick, "repair_rate"); + applyMod(ship.repairTool->range, "repair_range"); + } + } + m_ships.push_back(ship); return ship.id; } diff --git a/src/lib/sim/ShipSystem.h b/src/lib/sim/ShipSystem.h index 2966785..d6473b8 100644 --- a/src/lib/sim/ShipSystem.h +++ b/src/lib/sim/ShipSystem.h @@ -8,6 +8,7 @@ #include "EntityId.h" #include "GameConfig.h" #include "Ship.h" +#include "ShipLayout.h" class BuildingSystem; class ScrapSystem; @@ -20,7 +21,8 @@ public: // isEnemy defaults to false; set true for enemy-faction ships (step 7 wave spawning). EntityId spawn(const std::string& schematicId, int level, QVector2D position, - bool isEnemy = false); + bool isEnemy = false, + const std::optional& layout = std::nullopt); void despawn(EntityId id); const Ship* findShip(EntityId id) const; @@ -58,7 +60,8 @@ public: bool damageShip(EntityId id, float amount); private: - const ShipDef* findShipDef(const std::string& schematicId) const; + const ShipDef* findShipDef(const std::string& schematicId) const; + const ModuleDef* findModuleDef(const std::string& id) 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. diff --git a/src/lib/sim/Simulation.cpp b/src/lib/sim/Simulation.cpp index 97a0fa9..a92f3b4 100644 --- a/src/lib/sim/Simulation.cpp +++ b/src/lib/sim/Simulation.cpp @@ -30,14 +30,15 @@ Simulation::Simulation(GameConfig config, unsigned int seed) m_beltSystem, [this]() { return allocateId(); }, [this](int amount) { m_buildingBlocksStock += amount; }, - [this](const std::string& id, QVector2D pos) { + [this](const std::string& id, QVector2D pos, + const std::optional& layout) { const std::map::const_iterator it = m_schematicLevels.find(id); if (it == m_schematicLevels.end() || !it->second.unlocked) { return; } - m_shipSystem->spawn(id, it->second.level, pos, /*isEnemy=*/false); + m_shipSystem->spawn(id, it->second.level, pos, /*isEnemy=*/false, layout); }, m_rng); m_shipSystem = std::make_unique(m_config, [this]() { return allocateId(); }); @@ -92,14 +93,15 @@ void Simulation::reset(unsigned int seed) m_beltSystem, [this]() { return allocateId(); }, [this](int amount) { m_buildingBlocksStock += amount; }, - [this](const std::string& id, QVector2D pos) { + [this](const std::string& id, QVector2D pos, + const std::optional& layout) { const std::map::const_iterator it = m_schematicLevels.find(id); if (it == m_schematicLevels.end() || !it->second.unlocked) { return; } - m_shipSystem->spawn(id, it->second.level, pos, /*isEnemy=*/false); + m_shipSystem->spawn(id, it->second.level, pos, /*isEnemy=*/false, layout); }, m_rng); m_shipSystem = std::make_unique(m_config, [this]() { return allocateId(); }); diff --git a/src/test/BehaviorSystemTest.cpp b/src/test/BehaviorSystemTest.cpp index fa42d38..5594932 100644 --- a/src/test/BehaviorSystemTest.cpp +++ b/src/test/BehaviorSystemTest.cpp @@ -46,7 +46,7 @@ struct Fixture , buildings(cfg, belts, [this]() { return nextId++; }, [this](int n) { stock += n; }, - [](const std::string&, QVector2D) {}, + [](const std::string&, QVector2D, const std::optional&) {}, rng) , ships(cfg, [this]() { return nextId++; }) , scraps([this]() { return nextId++; }) diff --git a/src/test/BuildingTest.cpp b/src/test/BuildingTest.cpp index 089df37..d40806e 100644 --- a/src/test/BuildingTest.cpp +++ b/src/test/BuildingTest.cpp @@ -78,7 +78,7 @@ TEST_CASE("BuildingSystem: place miner occupies expected body tiles", "[building BuildingSystem bs(cfg, belts, [&nextId]() { return nextId++; }, [&stock](int n) { stock += n; }, - [](const std::string&, QVector2D) {}, + [](const std::string&, QVector2D, const std::optional&) {}, rng); const EntityId id = bs.place(BuildingType::Miner, QPoint(0, 0), Rotation::East, 0); @@ -103,7 +103,7 @@ TEST_CASE("BuildingSystem: placing a belt registers it with BeltSystem after con BuildingSystem bs(cfg, belts, [&nextId]() { return nextId++; }, [&stock](int n) { stock += n; }, - [](const std::string&, QVector2D) {}, + [](const std::string&, QVector2D, const std::optional&) {}, rng); bs.place(BuildingType::Belt, QPoint(5, 5), Rotation::East, 0); @@ -131,7 +131,7 @@ TEST_CASE("BuildingSystem: placed building enters construction queue", "[buildin BuildingSystem bs(cfg, belts, [&nextId]() { return nextId++; }, [&stock](int n) { stock += n; }, - [](const std::string&, QVector2D) {}, + [](const std::string&, QVector2D, const std::optional&) {}, rng); const EntityId id = bs.place(BuildingType::Miner, QPoint(0, 0), Rotation::East, 0); @@ -151,7 +151,7 @@ TEST_CASE("BuildingSystem: demolish frees tiles and returns refund", "[building] BuildingSystem bs(cfg, belts, [&nextId]() { return nextId++; }, [&stock](int n) { stock += n; }, - [](const std::string&, QVector2D) {}, + [](const std::string&, QVector2D, const std::optional&) {}, rng); const EntityId id = bs.place(BuildingType::Miner, QPoint(0, 0), Rotation::East, 0); @@ -184,7 +184,7 @@ TEST_CASE("BuildingSystem: first queued building starts construction immediately BuildingSystem bs(cfg, belts, [&nextId]() { return nextId++; }, [&stock](int n) { stock += n; }, - [](const std::string&, QVector2D) {}, + [](const std::string&, QVector2D, const std::optional&) {}, rng); bs.place(BuildingType::Miner, QPoint(0, 0), Rotation::East, 0); @@ -201,7 +201,7 @@ TEST_CASE("BuildingSystem: second queued building waits (completesAt == 0)", "[b BuildingSystem bs(cfg, belts, [&nextId]() { return nextId++; }, [&stock](int n) { stock += n; }, - [](const std::string&, QVector2D) {}, + [](const std::string&, QVector2D, const std::optional&) {}, rng); bs.place(BuildingType::Miner, QPoint(0, 0), Rotation::East, 0); @@ -222,7 +222,7 @@ TEST_CASE("BuildingSystem: construction completes after configured duration", "[ BuildingSystem bs(cfg, belts, [&nextId]() { return nextId++; }, [&stock](int n) { stock += n; }, - [](const std::string&, QVector2D) {}, + [](const std::string&, QVector2D, const std::optional&) {}, rng); const EntityId id = bs.place(BuildingType::Miner, QPoint(0, 0), Rotation::East, 0); @@ -246,7 +246,7 @@ TEST_CASE("BuildingSystem: second building starts after first completes", "[buil BuildingSystem bs(cfg, belts, [&nextId]() { return nextId++; }, [&stock](int n) { stock += n; }, - [](const std::string&, QVector2D) {}, + [](const std::string&, QVector2D, const std::optional&) {}, rng); bs.place(BuildingType::Miner, QPoint(0, 0), Rotation::East, 0); @@ -275,7 +275,7 @@ TEST_CASE("BuildingSystem: miner produces iron_ore after recipe duration", "[bui BuildingSystem bs(cfg, belts, [&nextId]() { return nextId++; }, [&stock](int n) { stock += n; }, - [](const std::string&, QVector2D) {}, + [](const std::string&, QVector2D, const std::optional&) {}, rng); const EntityId id = bs.place(BuildingType::Miner, QPoint(0, 0), Rotation::East, 0); @@ -304,7 +304,7 @@ TEST_CASE("BuildingSystem: miner output buffer stalls when full", "[building]") BuildingSystem bs(cfg, belts, [&nextId]() { return nextId++; }, [&stock](int n) { stock += n; }, - [](const std::string&, QVector2D) {}, + [](const std::string&, QVector2D, const std::optional&) {}, rng); const EntityId id = bs.place(BuildingType::Miner, QPoint(0, 0), Rotation::East, 0); @@ -343,7 +343,7 @@ TEST_CASE("BuildingSystem: smelter input buffer fills from adjacent west-flowing BuildingSystem bs(cfg, belts, [&nextId]() { return nextId++; }, [&stock](int n) { stock += n; }, - [](const std::string&, QVector2D) {}, + [](const std::string&, QVector2D, const std::optional&) {}, rng); // Smelter mask ["AA ","AA>"] → body (0,0),(1,0),(0,1),(1,1). @@ -384,7 +384,7 @@ TEST_CASE("BuildingSystem: miner output buffer drains onto adjacent belt", "[bui BuildingSystem bs(cfg, belts, [&nextId]() { return nextId++; }, [&stock](int n) { stock += n; }, - [](const std::string&, QVector2D) {}, + [](const std::string&, QVector2D, const std::optional&) {}, rng); const EntityId id = bs.place(BuildingType::Miner, QPoint(0, 0), Rotation::East, 0); @@ -423,7 +423,7 @@ TEST_CASE("BuildingSystem: setRecipe clears output buffer and active production" BuildingSystem bs(cfg, belts, [&nextId]() { return nextId++; }, [&stock](int n) { stock += n; }, - [](const std::string&, QVector2D) {}, + [](const std::string&, QVector2D, const std::optional&) {}, rng); const EntityId id = bs.place(BuildingType::Miner, QPoint(0, 0), Rotation::East, 0); @@ -463,7 +463,7 @@ TEST_CASE("BuildingSystem: reprocessing plant output buffer capacity equals max BuildingSystem bs(cfg, belts, [&nextId]() { return nextId++; }, [&stock](int n) { stock += n; }, - [](const std::string&, QVector2D) {}, + [](const std::string&, QVector2D, const std::optional&) {}, rng); const EntityId id = bs.place(BuildingType::ReprocessingPlant, @@ -493,7 +493,7 @@ TEST_CASE("BuildingSystem: reprocessing plant produces one cycle output then sta BuildingSystem bs(cfg, belts, [&nextId]() { return nextId++; }, [&stock](int n) { stock += n; }, - [](const std::string&, QVector2D) {}, + [](const std::string&, QVector2D, const std::optional&) {}, rng); const EntityId id = bs.place(BuildingType::ReprocessingPlant, @@ -551,7 +551,7 @@ TEST_CASE("BuildingSystem: findRotateInPlaceTarget returns nullopt when tile is BuildingSystem bs(cfg, belts, [&nextId]() { return nextId++; }, [&stock](int n) { stock += n; }, - [](const std::string&, QVector2D) {}, + [](const std::string&, QVector2D, const std::optional&) {}, rng); REQUIRE_FALSE( @@ -569,7 +569,7 @@ TEST_CASE("BuildingSystem: findRotateInPlaceTarget returns the site id for a que BuildingSystem bs(cfg, belts, [&nextId]() { return nextId++; }, [&stock](int n) { stock += n; }, - [](const std::string&, QVector2D) {}, + [](const std::string&, QVector2D, const std::optional&) {}, rng); const EntityId id = bs.place(BuildingType::Belt, QPoint(0, 0), Rotation::East, 0); @@ -591,7 +591,7 @@ TEST_CASE("BuildingSystem: findRotateInPlaceTarget returns the building id for a BuildingSystem bs(cfg, belts, [&nextId]() { return nextId++; }, [&stock](int n) { stock += n; }, - [](const std::string&, QVector2D) {}, + [](const std::string&, QVector2D, const std::optional&) {}, rng); const EntityId id = bs.place(BuildingType::Belt, QPoint(0, 0), Rotation::East, 0); @@ -617,7 +617,7 @@ TEST_CASE("BuildingSystem: findRotateInPlaceTarget returns nullopt when building BuildingSystem bs(cfg, belts, [&nextId]() { return nextId++; }, [&stock](int n) { stock += n; }, - [](const std::string&, QVector2D) {}, + [](const std::string&, QVector2D, const std::optional&) {}, rng); bs.place(BuildingType::Belt, QPoint(0, 0), Rotation::East, 0); @@ -638,7 +638,7 @@ TEST_CASE("BuildingSystem: findRotateInPlaceTarget returns nullopt when footprin BuildingSystem bs(cfg, belts, [&nextId]() { return nextId++; }, [&stock](int n) { stock += n; }, - [](const std::string&, QVector2D) {}, + [](const std::string&, QVector2D, const std::optional&) {}, rng); // Smelter at (0,0) occupies body tiles (0,0),(1,0),(0,1),(1,1). @@ -661,7 +661,7 @@ TEST_CASE("BuildingSystem: findRotateInPlaceTarget works for a symmetric multi-t BuildingSystem bs(cfg, belts, [&nextId]() { return nextId++; }, [&stock](int n) { stock += n; }, - [](const std::string&, QVector2D) {}, + [](const std::string&, QVector2D, const std::optional&) {}, rng); // Smelter is a fully filled 2×2 footprint — rotating the ghost produces the @@ -689,7 +689,7 @@ TEST_CASE("BuildingSystem: rotateInPlace updates the rotation field of a constru BuildingSystem bs(cfg, belts, [&nextId]() { return nextId++; }, [&stock](int n) { stock += n; }, - [](const std::string&, QVector2D) {}, + [](const std::string&, QVector2D, const std::optional&) {}, rng); const EntityId id = bs.place(BuildingType::Belt, QPoint(0, 0), Rotation::East, 0); @@ -711,7 +711,7 @@ TEST_CASE("BuildingSystem: rotateInPlace preserves the construction progress of BuildingSystem bs(cfg, belts, [&nextId]() { return nextId++; }, [&stock](int n) { stock += n; }, - [](const std::string&, QVector2D) {}, + [](const std::string&, QVector2D, const std::optional&) {}, rng); const EntityId id = bs.place(BuildingType::Belt, QPoint(0, 0), Rotation::East, 0); @@ -734,7 +734,7 @@ TEST_CASE("BuildingSystem: rotateInPlace updates rotation and output port direct BuildingSystem bs(cfg, belts, [&nextId]() { return nextId++; }, [&stock](int n) { stock += n; }, - [](const std::string&, QVector2D) {}, + [](const std::string&, QVector2D, const std::optional&) {}, rng); const EntityId id = bs.place(BuildingType::Belt, QPoint(0, 0), Rotation::East, 0); @@ -764,7 +764,7 @@ TEST_CASE("BuildingSystem: rotateInPlace re-registers a belt tile with BeltSyste BuildingSystem bs(cfg, belts, [&nextId]() { return nextId++; }, [&stock](int n) { stock += n; }, - [](const std::string&, QVector2D) {}, + [](const std::string&, QVector2D, const std::optional&) {}, rng); const EntityId id = bs.place(BuildingType::Belt, QPoint(0, 0), Rotation::East, 0); diff --git a/src/test/CMakeLists.txt b/src/test/CMakeLists.txt index dd2bd43..2282af5 100644 --- a/src/test/CMakeLists.txt +++ b/src/test/CMakeLists.txt @@ -17,4 +17,6 @@ add_files( ShipyardTest.cpp BlueprintTest.cpp BlueprintSerializerTest.cpp + ModuleConfigTest.cpp + ShipModuleTest.cpp ) diff --git a/src/test/CombatSystemTest.cpp b/src/test/CombatSystemTest.cpp index 8d79e62..2ff7bd2 100644 --- a/src/test/CombatSystemTest.cpp +++ b/src/test/CombatSystemTest.cpp @@ -52,7 +52,7 @@ TEST_CASE("CombatSystem: ship fires when cooldown=0 and target in range", "[comb BuildingSystem buildings(cfg, belts, [&nextBldId]() { return nextBldId++; }, [](int){}, - [](const std::string&, QVector2D) {}, + [](const std::string&, QVector2D, const std::optional&) {}, rng); // Spawn an enemy combat ship close to the player side. @@ -114,7 +114,7 @@ TEST_CASE("CombatSystem: cooldown prevents firing before it expires", "[combat]" BuildingSystem buildings(cfg, belts, [&nextBldId]() { return nextBldId++; }, [](int){}, - [](const std::string&, QVector2D) {}, + [](const std::string&, QVector2D, const std::optional&) {}, rng); const EntityId enemyId = ships.spawn(combatDef->id, 1, QVector2D(5.0f, 5.0f), true); @@ -163,7 +163,7 @@ TEST_CASE("CombatSystem: no fire when target is out of range", "[combat]") BuildingSystem buildings(cfg, belts, [&nextBldId]() { return nextBldId++; }, [](int){}, - [](const std::string&, QVector2D) {}, + [](const std::string&, QVector2D, const std::optional&) {}, rng); const EntityId enemyId = ships.spawn(combatDef->id, 1, QVector2D(0.0f, 0.0f), true); @@ -344,7 +344,7 @@ TEST_CASE("CombatSystem: damage not applied before impact tick", "[combat]") BuildingSystem buildings(cfg, belts, [&nextBldId]() { return nextBldId++; }, [](int){}, - [](const std::string&, QVector2D) {}, + [](const std::string&, QVector2D, const std::optional&) {}, rng); const EntityId enemyId = ships.spawn(combatDef->id, 1, QVector2D(5.0f, 5.0f), true); @@ -401,7 +401,7 @@ TEST_CASE("CombatSystem: damage applied exactly at impact tick", "[combat]") BuildingSystem buildings(cfg, belts, [&nextBldId]() { return nextBldId++; }, [](int){}, - [](const std::string&, QVector2D) {}, + [](const std::string&, QVector2D, const std::optional&) {}, rng); const EntityId enemyId = ships.spawn(combatDef->id, 1, QVector2D(5.0f, 5.0f), true); @@ -455,7 +455,7 @@ TEST_CASE("CombatSystem: damage silently dropped if target already dead", "[comb BuildingSystem buildings(cfg, belts, [&nextBldId]() { return nextBldId++; }, [](int){}, - [](const std::string&, QVector2D) {}, + [](const std::string&, QVector2D, const std::optional&) {}, rng); const EntityId enemyId = ships.spawn(combatDef->id, 1, QVector2D(5.0f, 5.0f), true); @@ -502,7 +502,7 @@ TEST_CASE("CombatSystem: damage still applied if shooter already dead", "[combat BuildingSystem buildings(cfg, belts, [&nextBldId]() { return nextBldId++; }, [](int){}, - [](const std::string&, QVector2D) {}, + [](const std::string&, QVector2D, const std::optional&) {}, rng); const EntityId enemyId = ships.spawn(combatDef->id, 1, QVector2D(5.0f, 5.0f), true); diff --git a/src/test/ModuleConfigTest.cpp b/src/test/ModuleConfigTest.cpp new file mode 100644 index 0000000..ca07faa --- /dev/null +++ b/src/test/ModuleConfigTest.cpp @@ -0,0 +1,57 @@ +#include "catch.hpp" + +#include "ConfigLoader.h" +#include "ModulesConfig.h" + +static GameConfig loadConfig() +{ + return ConfigLoader::loadFromDirectory(CONFIG_DIR); +} + +TEST_CASE("ConfigLoader: loadModules parses modules.toml", "[config][modules]") +{ + const GameConfig cfg = loadConfig(); + REQUIRE(cfg.modules.modules.size() >= 2); + + const ModuleDef& armor = cfg.modules.modules[0]; + CHECK(armor.id == "armor_plate"); + CHECK(armor.surfaceMask.size() == 1); + CHECK(armor.surfaceMask[0] == "OO"); + CHECK(armor.materials.size() == 1); + CHECK(armor.materials[0].item == "iron_ingot"); + CHECK(armor.materials[0].amount == 2); + CHECK(armor.playerProductionLevel == 1); + CHECK(armor.productionTimeSeconds == Approx(3.0)); + CHECK(armor.threatCost == Approx(2.0)); + CHECK(armor.fillColor == "#808080"); + CHECK(armor.glyph == "A"); + REQUIRE(armor.statModifiers.size() == 1); + CHECK(armor.statModifiers[0].stat == "hp"); + CHECK(armor.statModifiers[0].modifierType == "multiplicative"); + CHECK(armor.statModifiers[0].formula.evaluate(1.0) == Approx(1.5)); +} + +TEST_CASE("ConfigLoader: loadModules parses additive modifiers", "[config][modules]") +{ + const GameConfig cfg = loadConfig(); + REQUIRE(cfg.modules.modules.size() >= 2); + + const ModuleDef& sensor = cfg.modules.modules[1]; + CHECK(sensor.id == "sensor_booster"); + REQUIRE(sensor.statModifiers.size() == 1); + CHECK(sensor.statModifiers[0].stat == "sensor_range"); + CHECK(sensor.statModifiers[0].modifierType == "additive"); + CHECK(sensor.statModifiers[0].formula.evaluate(1.0) == Approx(10.0)); +} + +TEST_CASE("ConfigLoader: loadShips parses layout field", "[config][ships]") +{ + const GameConfig cfg = loadConfig(); + REQUIRE(!cfg.ships.ships.empty()); + + const ShipDef& ship = cfg.ships.ships[0]; + REQUIRE(!ship.layout.empty()); + CHECK(ship.layout[0] == "XOX"); + CHECK(ship.layout[1] == "OOO"); + CHECK(ship.layout[2] == "XOX"); +} diff --git a/src/test/ShipModuleTest.cpp b/src/test/ShipModuleTest.cpp new file mode 100644 index 0000000..b266098 --- /dev/null +++ b/src/test/ShipModuleTest.cpp @@ -0,0 +1,287 @@ +#include "catch.hpp" + +#include "Building.h" +#include "BuildingSystem.h" +#include "BuildingType.h" +#include "ConfigLoader.h" +#include "GameConfig.h" +#include "ItemType.h" +#include "ModulesConfig.h" +#include "Rotation.h" +#include "Ship.h" +#include "ShipLayout.h" +#include "ShipSystem.h" +#include "Simulation.h" +#include "Tick.h" + +static GameConfig loadConfig() +{ + return ConfigLoader::loadFromDirectory(CONFIG_DIR); +} + +static const ShipDef* findSchematic(const GameConfig& cfg, const std::string& id) +{ + for (const ShipDef& def : cfg.ships.ships) + { + if (def.id == id) + { + return &def; + } + } + return nullptr; +} + +static const BuildingDef* findShipyardDef(const GameConfig& cfg) +{ + for (const BuildingDef& def : cfg.buildings.buildings) + { + if (def.type == BuildingType::Shipyard) + { + return &def; + } + } + return nullptr; +} + +static EntityId placeShipyard(Simulation& sim, const BuildingDef& yardDef) +{ + return sim.buildings().placeImmediate( + BuildingType::Shipyard, + yardDef.surfaceMask, + QPoint(0, 0), + Rotation::East, + 100.0f, 100.0f); +} + +static void fillMaterials(Simulation& sim, EntityId yardId, + const ShipDef& def, + const ShipLayoutConfig& layout) +{ + sim.buildings().forEachBuilding([&](Building& b) { + if (b.id != yardId) + { + return; + } + for (const RecipeIngredient& ing : def.schematic.materials) + { + b.inputBuffer.counts[ItemType{ing.item}] = ing.amount; + } + for (const PlacedModule& pm : layout.placedModules) + { + for (const ModuleDef& modDef : sim.config().modules.modules) + { + if (modDef.id == pm.moduleId) + { + for (const RecipeIngredient& ing : modDef.materials) + { + b.inputBuffer.counts[ItemType{ing.item}] += ing.amount; + } + break; + } + } + } + }); +} + +// --------------------------------------------------------------------------- +// Ship stat modifiers +// --------------------------------------------------------------------------- + +TEST_CASE("Ship spawn: no modules leaves base stats unchanged", "[modules]") +{ + Simulation sim(loadConfig(), 42); + const ShipDef* def = findSchematic(sim.config(), "interceptor"); + REQUIRE(def != nullptr); + + const double x = static_cast(def->schematic.playerProductionLevel); + const float expectedHp = static_cast(def->health.hpFormula.evaluate(x)); + + const EntityId id = sim.ships().spawn("interceptor", + def->schematic.playerProductionLevel, + QVector2D(5.0f, 5.0f), false, std::nullopt); + + const Ship* ship = sim.ships().findShip(id); + REQUIRE(ship != nullptr); + CHECK(ship->maxHp == Approx(expectedHp)); +} + +TEST_CASE("Ship spawn: multiplicative HP module applies correctly", "[modules]") +{ + Simulation sim(loadConfig(), 42); + const ShipDef* def = findSchematic(sim.config(), "interceptor"); + REQUIRE(def != nullptr); + + const double x = static_cast(def->schematic.playerProductionLevel); + const float baseHp = static_cast(def->health.hpFormula.evaluate(x)); + + ShipLayoutConfig layout; + PlacedModule pm; + pm.moduleId = "armor_plate"; + pm.position = QPoint(0, 0); + pm.rotation = Rotation::East; + layout.placedModules.push_back(pm); + + const EntityId id = sim.ships().spawn("interceptor", + def->schematic.playerProductionLevel, + QVector2D(5.0f, 5.0f), false, layout); + + const Ship* ship = sim.ships().findShip(id); + REQUIRE(ship != nullptr); + // armor_plate has multiplied_hp_formula = "1.5" + // final = base * (1 + (1.5 - 1)) + 0 = base * 1.5 + CHECK(ship->maxHp == Approx(baseHp * 1.5f)); + CHECK(ship->hp == ship->maxHp); +} + +TEST_CASE("Ship spawn: additive sensor module applies correctly", "[modules]") +{ + Simulation sim(loadConfig(), 42); + const ShipDef* def = findSchematic(sim.config(), "interceptor"); + REQUIRE(def != nullptr); + + const double x = static_cast(def->schematic.playerProductionLevel); + const float baseRange = static_cast(def->sensor.sensorRangeFormula.evaluate(x)); + + ShipLayoutConfig layout; + PlacedModule pm; + pm.moduleId = "sensor_booster"; + pm.position = QPoint(0, 0); + pm.rotation = Rotation::East; + layout.placedModules.push_back(pm); + + const EntityId id = sim.ships().spawn("interceptor", + def->schematic.playerProductionLevel, + QVector2D(5.0f, 5.0f), false, layout); + + const Ship* ship = sim.ships().findShip(id); + REQUIRE(ship != nullptr); + // sensor_booster has added_sensor_range_formula = "10" + // final = base * 1.0 + 10 = base + 10 + CHECK(ship->sensorRange == Approx(baseRange + 10.0f)); +} + +TEST_CASE("Ship spawn: multiple modules stack correctly", "[modules]") +{ + Simulation sim(loadConfig(), 42); + const ShipDef* def = findSchematic(sim.config(), "interceptor"); + REQUIRE(def != nullptr); + + const double x = static_cast(def->schematic.playerProductionLevel); + const float baseHp = static_cast(def->health.hpFormula.evaluate(x)); + + ShipLayoutConfig layout; + for (int i = 0; i < 2; ++i) + { + PlacedModule pm; + pm.moduleId = "armor_plate"; + pm.position = QPoint(i * 2, 0); + pm.rotation = Rotation::East; + layout.placedModules.push_back(pm); + } + + const EntityId id = sim.ships().spawn("interceptor", + def->schematic.playerProductionLevel, + QVector2D(5.0f, 5.0f), false, layout); + + const Ship* ship = sim.ships().findShip(id); + REQUIRE(ship != nullptr); + // Two armor_plates: each 1.5 multiplier + // total_mult = 1 + (1.5 - 1) + (1.5 - 1) = 2.0 + // final = base * 2.0 + CHECK(ship->maxHp == Approx(baseHp * 2.0f)); +} + +// --------------------------------------------------------------------------- +// Shipyard module integration +// --------------------------------------------------------------------------- + +TEST_CASE("Shipyard: setShipLayout reinitializes buffers with module materials", + "[modules][shipyard]") +{ + Simulation sim(loadConfig(), 42); + const BuildingDef* yardDef = findShipyardDef(sim.config()); + REQUIRE(yardDef != nullptr); + + const EntityId yardId = placeShipyard(sim, *yardDef); + sim.buildings().setRecipe(yardId, "interceptor"); + + ShipLayoutConfig layout; + PlacedModule pm; + pm.moduleId = "armor_plate"; + pm.position = QPoint(0, 0); + pm.rotation = Rotation::East; + layout.placedModules.push_back(pm); + + sim.buildings().setShipLayout(yardId, layout); + + const Building* b = sim.buildings().findBuilding(yardId); + REQUIRE(b != nullptr); + // armor_plate needs 2 iron_ingot; interceptor needs 3 iron_ingot + 1 circuit_board + // Total iron_ingot = 5, buffer cap = 2 * 5 = 10 + CHECK(b->inputBuffer.caps.at(ItemType{"iron_ingot"}) == 10); + CHECK(b->inputBuffer.caps.at(ItemType{"circuit_board"}) == 2); +} + +TEST_CASE("Shipyard: setShipLayout cancels in-progress production", + "[modules][shipyard]") +{ + Simulation sim(loadConfig(), 42); + const ShipDef* def = findSchematic(sim.config(), "interceptor"); + REQUIRE(def != nullptr); + const BuildingDef* yardDef = findShipyardDef(sim.config()); + REQUIRE(yardDef != nullptr); + + const EntityId yardId = placeShipyard(sim, *yardDef); + sim.buildings().setRecipe(yardId, "interceptor"); + + // Fill materials and tick to start production. + ShipLayoutConfig emptyLayout; + fillMaterials(sim, yardId, *def, emptyLayout); + sim.tick(); + + const Building* b1 = sim.buildings().findBuilding(yardId); + REQUIRE(b1 != nullptr); + REQUIRE(b1->production.has_value()); + + // Now set a layout — should cancel production. + ShipLayoutConfig layout; + PlacedModule pm; + pm.moduleId = "sensor_booster"; + pm.position = QPoint(0, 0); + pm.rotation = Rotation::East; + layout.placedModules.push_back(pm); + + sim.buildings().setShipLayout(yardId, layout); + + const Building* b2 = sim.buildings().findBuilding(yardId); + REQUIRE(b2 != nullptr); + CHECK_FALSE(b2->production.has_value()); +} + +TEST_CASE("Shipyard: setRecipe clears ship layout", "[modules][shipyard]") +{ + Simulation sim(loadConfig(), 42); + const BuildingDef* yardDef = findShipyardDef(sim.config()); + REQUIRE(yardDef != nullptr); + + const EntityId yardId = placeShipyard(sim, *yardDef); + sim.buildings().setRecipe(yardId, "interceptor"); + + ShipLayoutConfig layout; + PlacedModule pm; + pm.moduleId = "armor_plate"; + pm.position = QPoint(0, 0); + pm.rotation = Rotation::East; + layout.placedModules.push_back(pm); + sim.buildings().setShipLayout(yardId, layout); + + const Building* b1 = sim.buildings().findBuilding(yardId); + REQUIRE(b1 != nullptr); + REQUIRE(b1->shipLayout.has_value()); + + sim.buildings().setRecipe(yardId, "destroyer"); + + const Building* b2 = sim.buildings().findBuilding(yardId); + REQUIRE(b2 != nullptr); + CHECK_FALSE(b2->shipLayout.has_value()); +} diff --git a/src/ui/CMakeLists.txt b/src/ui/CMakeLists.txt index 627ad93..3cc2d1d 100644 --- a/src/ui/CMakeLists.txt +++ b/src/ui/CMakeLists.txt @@ -8,6 +8,8 @@ SET(HDRS ${CMAKE_CURRENT_SOURCE_DIR}/BuildButtonGrid.h ${CMAKE_CURRENT_SOURCE_DIR}/SelectedBuildingPanel.h ${CMAKE_CURRENT_SOURCE_DIR}/BlueprintPanel.h + ${CMAKE_CURRENT_SOURCE_DIR}/ShipLayoutDialog.h + ${CMAKE_CURRENT_SOURCE_DIR}/ShipLayoutPreview.h PARENT_SCOPE ) @@ -20,5 +22,7 @@ SET(SRCS ${CMAKE_CURRENT_SOURCE_DIR}/BuildButtonGrid.cpp ${CMAKE_CURRENT_SOURCE_DIR}/SelectedBuildingPanel.cpp ${CMAKE_CURRENT_SOURCE_DIR}/BlueprintPanel.cpp + ${CMAKE_CURRENT_SOURCE_DIR}/ShipLayoutDialog.cpp + ${CMAKE_CURRENT_SOURCE_DIR}/ShipLayoutPreview.cpp PARENT_SCOPE ) diff --git a/src/ui/MainWindow.cpp b/src/ui/MainWindow.cpp index 224ba3a..0376491 100644 --- a/src/ui/MainWindow.cpp +++ b/src/ui/MainWindow.cpp @@ -9,10 +9,12 @@ #include "BlueprintPanel.h" #include "BuildButtonGrid.h" +#include "BuildingSystem.h" #include "ConfigLoader.h" #include "GameWorldView.h" #include "HeaderBar.h" #include "SelectedBuildingPanel.h" +#include "ShipLayoutDialog.h" #include "Simulation.h" #include "Tick.h" #include "VisualsLoader.h" @@ -62,6 +64,9 @@ MainWindow::MainWindow(Simulation* sim, const std::string& configDir, QWidget* p connect(m_gameWorldView, &GameWorldView::escapeMenuRequested, this, &MainWindow::onEscapeMenuRequested); + connect(m_selectedBuildingPanel, &SelectedBuildingPanel::layoutDialogRequested, + this, &MainWindow::onLayoutDialogRequested); + // Signals: build grid → game world connect(m_buildButtonGrid, &BuildButtonGrid::buildingTypeSelected, m_gameWorldView, &GameWorldView::enterBuilderMode); @@ -176,6 +181,33 @@ void MainWindow::onEscapeMenuRequested() } } +void MainWindow::onLayoutDialogRequested(EntityId shipyardId) +{ + const double prevSpeed = m_gameWorldView->gameSpeed(); + m_gameWorldView->setGameSpeed(0.0); + + const Building* b = m_sim->buildings().findBuilding(shipyardId); + if (!b) + { + m_gameWorldView->setGameSpeed(prevSpeed); + return; + } + + ShipLayoutConfig currentLayout; + if (b->shipLayout.has_value()) + { + currentLayout = *b->shipLayout; + } + + ShipLayoutDialog dialog(&m_sim->config(), b->recipeId, currentLayout, this); + if (dialog.exec() == QDialog::Accepted && dialog.result().has_value()) + { + m_sim->buildings().setShipLayout(shipyardId, *dialog.result()); + } + + m_gameWorldView->setGameSpeed(prevSpeed); +} + void MainWindow::onGameOver() { const Tick tick = m_sim->currentTick(); diff --git a/src/ui/MainWindow.h b/src/ui/MainWindow.h index d991c06..a1dc0cd 100644 --- a/src/ui/MainWindow.h +++ b/src/ui/MainWindow.h @@ -4,6 +4,7 @@ #include +#include "EntityId.h" #include "Tick.h" #include "VisualsConfig.h" @@ -29,6 +30,7 @@ private slots: void onGameOver(); void onStateUpdated(Tick tick, int blocks, double speed); void onEscapeMenuRequested(); + void onLayoutDialogRequested(EntityId shipyardId); private: void layoutPanels(); diff --git a/src/ui/SelectedBuildingPanel.cpp b/src/ui/SelectedBuildingPanel.cpp index 51e4e93..bcf22a8 100644 --- a/src/ui/SelectedBuildingPanel.cpp +++ b/src/ui/SelectedBuildingPanel.cpp @@ -17,7 +17,9 @@ #include "BuildingSystem.h" #include "BuildingType.h" #include "ItemType.h" +#include "ModulesConfig.h" #include "Rotation.h" +#include "ShipLayoutPreview.h" #include "Simulation.h" namespace @@ -99,6 +101,8 @@ SelectedBuildingPanel::SelectedBuildingPanel(Simulation* sim, m_filterAList = new QListWidget(this); m_filterBLabel = new QLabel(this); m_filterBList = new QListWidget(this); + m_layoutPreview = new ShipLayoutPreview(this); + m_configureLayoutBtn = new QPushButton("Configure Layout", this); m_buffersLabel = new QLabel(this); m_buffersLabel->setWordWrap(true); @@ -107,6 +111,8 @@ SelectedBuildingPanel::SelectedBuildingPanel(Simulation* sim, m_layout->addWidget(m_titleLabel); m_layout->addWidget(m_recipeCombo); + m_layout->addWidget(m_layoutPreview); + m_layout->addWidget(m_configureLayoutBtn); m_layout->addWidget(m_clearBeltBtn); m_layout->addWidget(m_filterALabel); m_layout->addWidget(m_filterAList); @@ -118,6 +124,12 @@ SelectedBuildingPanel::SelectedBuildingPanel(Simulation* sim, this, &SelectedBuildingPanel::onRecipeChanged); connect(m_clearBeltBtn, &QPushButton::clicked, this, &SelectedBuildingPanel::onClearBelt); + connect(m_configureLayoutBtn, &QPushButton::clicked, this, [this]() { + if (m_singleId != kInvalidEntityId) + { + emit layoutDialogRequested(m_singleId); + } + }); connect(m_filterAList, &QListWidget::itemChanged, this, &SelectedBuildingPanel::onSplitterFilterChanged); connect(m_filterBList, &QListWidget::itemChanged, @@ -153,6 +165,8 @@ void SelectedBuildingPanel::buildEmpty() m_singleId = kInvalidEntityId; m_titleLabel->hide(); m_recipeCombo->hide(); + m_layoutPreview->hide(); + m_configureLayoutBtn->hide(); m_clearBeltBtn->hide(); m_filterALabel->hide(); m_filterAList->hide(); @@ -248,10 +262,39 @@ void SelectedBuildingPanel::buildSingle(EntityId id) m_recipeCombo->setCurrentIndex(currentIdx >= 0 ? currentIdx : 0); m_recipeCombo->blockSignals(false); m_recipeCombo->show(); + + if (b->type == BuildingType::Shipyard && !b->recipeId.empty()) + { + const ShipDef* sDef = findShipDef(b->recipeId); + if (sDef && !sDef->layout.empty()) + { + ShipLayoutConfig layout; + if (b->shipLayout.has_value()) + { + layout = *b->shipLayout; + } + m_layoutPreview->setShipAndLayout( + sDef->layout, layout, &m_config->modules.modules); + m_layoutPreview->show(); + m_configureLayoutBtn->show(); + } + else + { + m_layoutPreview->hide(); + m_configureLayoutBtn->hide(); + } + } + else + { + m_layoutPreview->hide(); + m_configureLayoutBtn->hide(); + } } else { m_recipeCombo->hide(); + m_layoutPreview->hide(); + m_configureLayoutBtn->hide(); } if (isBeltLike(b->type)) @@ -307,6 +350,26 @@ void SelectedBuildingPanel::refreshBuffers(const Building* b) { if (mat.item == entry.first.id) { perCycle = mat.amount; break; } } + if (b->shipLayout.has_value()) + { + for (const PlacedModule& pm : b->shipLayout->placedModules) + { + for (const ModuleDef& modDef : m_config->modules.modules) + { + if (modDef.id == pm.moduleId) + { + for (const RecipeIngredient& ing : modDef.materials) + { + if (ing.item == entry.first.id) + { + perCycle += ing.amount; + } + } + break; + } + } + } + } } bufText += QString::fromStdString(entry.first.id) + ": " + QString::number(entry.second); @@ -354,10 +417,25 @@ void SelectedBuildingPanel::refreshBuffers(const Building* b) if (isProductionBuilding(b->type) && (recipe || shipDef)) { - const double durationSeconds = recipe + double durationSeconds = recipe ? recipe->durationSeconds : shipDef->schematic.productionTimeSeconds; + if (shipDef && b->shipLayout.has_value()) + { + for (const PlacedModule& pm : b->shipLayout->placedModules) + { + for (const ModuleDef& modDef : m_config->modules.modules) + { + if (modDef.id == pm.moduleId) + { + durationSeconds += modDef.productionTimeSeconds; + break; + } + } + } + } + bufText += QString("Cycle: %1 s\n").arg(durationSeconds, 0, 'f', 1); if (b->production.has_value()) @@ -377,6 +455,17 @@ void SelectedBuildingPanel::refreshBuffers(const Building* b) } m_buffersLabel->setText(bufText); + + if (b->type == BuildingType::Shipyard && shipDef && !shipDef->layout.empty()) + { + ShipLayoutConfig layout; + if (b->shipLayout.has_value()) + { + layout = *b->shipLayout; + } + m_layoutPreview->setShipAndLayout( + shipDef->layout, layout, &m_config->modules.modules); + } } const RecipeDef* SelectedBuildingPanel::findRecipe(const Building* b) const @@ -481,6 +570,7 @@ void SelectedBuildingPanel::onRecipeChanged(int comboIndex) } const QString recipeId = m_recipeCombo->itemData(comboIndex).toString(); m_sim->buildings().setRecipe(m_singleId, recipeId.toStdString()); + rebuild(); } void SelectedBuildingPanel::buildSplitterFilters(QPoint splitterTile) diff --git a/src/ui/SelectedBuildingPanel.h b/src/ui/SelectedBuildingPanel.h index 15391c9..acd489f 100644 --- a/src/ui/SelectedBuildingPanel.h +++ b/src/ui/SelectedBuildingPanel.h @@ -10,10 +10,12 @@ #include "EntityId.h" #include "GameConfig.h" #include "RecipesConfig.h" +#include "ShipLayout.h" #include "ShipsConfig.h" #include "Tick.h" class Simulation; +class ShipLayoutPreview; class QLabel; class QComboBox; class QListWidget; @@ -28,6 +30,9 @@ public: SelectedBuildingPanel(Simulation* sim, const GameConfig* config, QWidget* parent = nullptr); +signals: + void layoutDialogRequested(EntityId shipyardId); + public slots: void onSelectionChanged(const std::vector& ids); void onStateUpdated(Tick tick, int blocks, double speed); @@ -63,6 +68,9 @@ private: QListWidget* m_filterBList; QLabel* m_buffersLabel; + ShipLayoutPreview* m_layoutPreview; + QPushButton* m_configureLayoutBtn; + EntityId m_singleId; QPoint m_splitterTile; std::string m_currentRecipeId; diff --git a/src/ui/ShipLayoutDialog.cpp b/src/ui/ShipLayoutDialog.cpp new file mode 100644 index 0000000..2acdb64 --- /dev/null +++ b/src/ui/ShipLayoutDialog.cpp @@ -0,0 +1,616 @@ +#include "ShipLayoutDialog.h" + +#include + +#include +#include +#include +#include +#include +#include +#include +#include + +namespace +{ + +const int kCellSize = 32; + +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(std::toupper(static_cast(c))); + nextUpper = false; + } + else + { + result += c; + } + } + return result; +} + +std::vector rotateMaskCW(const std::vector& grid) +{ + if (grid.empty()) + { + return {}; + } + const int srcH = static_cast(grid.size()); + int srcW = 0; + for (const std::string& row : grid) + { + const int w = static_cast(row.size()); + if (w > srcW) + { + srcW = w; + } + } + const int dstW = srcH; + const int dstH = srcW; + std::vector dst(dstH, std::string(dstW, 'X')); + for (int row = 0; row < srcH; ++row) + { + for (int col = 0; col < srcW; ++col) + { + const char ch = (col < static_cast(grid[row].size())) + ? grid[row][col] + : 'X'; + dst[col][srcH - 1 - row] = ch; + } + } + return dst; +} + +} // namespace + + +// --------------------------------------------------------------------------- +// Grid rendering widget (nested inside dialog) +// --------------------------------------------------------------------------- + +class LayoutGridWidget : public QWidget +{ +public: + LayoutGridWidget(ShipLayoutDialog* dialog, QWidget* parent = nullptr) + : QWidget(parent) + , m_dialog(dialog) + { + setMouseTracking(true); + } + + void setGridData(const std::vector>* grid, + int rows, int cols, + const std::vector* placed, + const GameConfig* config) + { + m_grid = grid; + m_rows = rows; + m_cols = cols; + m_placed = placed; + m_config = config; + setFixedSize(cols * kCellSize + 1, rows * kCellSize + 1); + } + + void setGhostData(int moduleIndex, Rotation rotation) + { + m_ghostModuleIdx = moduleIndex; + m_ghostRotation = rotation; + } + +protected: + void paintEvent(QPaintEvent* /*event*/) override + { + if (!m_grid || m_rows == 0 || m_cols == 0) + { + return; + } + + QPainter painter(this); + painter.setRenderHint(QPainter::Antialiasing, false); + + for (int r = 0; r < m_rows; ++r) + { + for (int c = 0; c < m_cols; ++c) + { + const QRect cellRect(c * kCellSize, r * kCellSize, kCellSize, kCellSize); + const ShipLayoutDialog::CellInfo& cell = (*m_grid)[r][c]; + + if (!cell.buildable) + { + painter.fillRect(cellRect, QColor(30, 30, 30)); + } + else if (cell.moduleIndex >= 0) + { + const PlacedModule& pm = (*m_placed)[cell.moduleIndex]; + const ModuleDef* def = findModule(pm.moduleId); + QColor color(Qt::gray); + QString glyph; + if (def) + { + color = QColor(QString::fromStdString(def->fillColor)); + glyph = QString::fromStdString(def->glyph); + } + painter.fillRect(cellRect, color); + if (!glyph.isEmpty()) + { + painter.setPen(Qt::white); + painter.drawText(cellRect, Qt::AlignCenter, glyph); + } + } + else + { + painter.fillRect(cellRect, QColor(240, 240, 240)); + } + + painter.setPen(QColor(100, 100, 100)); + painter.drawRect(cellRect); + } + } + + // Draw ghost + if (m_ghostModuleIdx >= 0 && m_hoverCell.x() >= 0 && m_config) + { + const ModuleDef& def = m_config->modules.modules[m_ghostModuleIdx]; + const std::vector mask = rotateMask(def.surfaceMask, m_ghostRotation); + QColor ghostColor(QString::fromStdString(def.fillColor)); + ghostColor.setAlpha(100); + + for (int mr = 0; mr < static_cast(mask.size()); ++mr) + { + for (int mc = 0; mc < static_cast(mask[mr].size()); ++mc) + { + if (mask[mr][mc] != 'O') + { + continue; + } + const int gr = m_hoverCell.y() + mr; + const int gc = m_hoverCell.x() + mc; + if (gr >= 0 && gr < m_rows && gc >= 0 && gc < m_cols) + { + const QRect cellRect(gc * kCellSize, gr * kCellSize, + kCellSize, kCellSize); + painter.fillRect(cellRect, ghostColor); + } + } + } + } + } + + void mouseMoveEvent(QMouseEvent* event) override + { + const QPoint pos = event->pos(); + const QPoint cell(pos.x() / kCellSize, pos.y() / kCellSize); + if (cell != m_hoverCell) + { + m_hoverCell = cell; + update(); + } + } + + void mousePressEvent(QMouseEvent* event) override + { + if (event->button() != Qt::LeftButton) + { + return; + } + const QPoint pos = event->pos(); + const QPoint cell(pos.x() / kCellSize, pos.y() / kCellSize); + emit m_dialog->gridCellClicked(cell); + } + + void leaveEvent(QEvent* /*event*/) override + { + m_hoverCell = QPoint(-1, -1); + update(); + } + +private: + const ModuleDef* findModule(const std::string& id) const + { + if (!m_config) + { + return nullptr; + } + for (const ModuleDef& def : m_config->modules.modules) + { + if (def.id == id) + { + return &def; + } + } + return nullptr; + } + + std::vector rotateMask(const std::vector& mask, + Rotation rotation) const + { + int steps = 0; + switch (rotation) + { + case Rotation::East: steps = 0; break; + case Rotation::South: steps = 1; break; + case Rotation::West: steps = 2; break; + case Rotation::North: steps = 3; break; + } + std::vector result = mask; + for (int i = 0; i < steps; ++i) + { + result = rotateMaskCW(result); + } + return result; + } + + ShipLayoutDialog* m_dialog; + const std::vector>* m_grid = nullptr; + int m_rows = 0; + int m_cols = 0; + const std::vector* m_placed = nullptr; + const GameConfig* m_config = nullptr; + int m_ghostModuleIdx = -2; + Rotation m_ghostRotation = Rotation::East; + QPoint m_hoverCell = QPoint(-1, -1); +}; + + +// --------------------------------------------------------------------------- +// ShipLayoutDialog implementation +// --------------------------------------------------------------------------- + +ShipLayoutDialog::ShipLayoutDialog(const GameConfig* config, + const std::string& shipId, + const ShipLayoutConfig& currentLayout, + QWidget* parent) + : QDialog(parent) + , m_config(config) + , m_shipId(shipId) + , m_rows(0) + , m_cols(0) + , m_placedModules(currentLayout.placedModules) + , m_activeModuleIndex(-2) + , m_currentRotation(Rotation::East) + , m_removeButton(nullptr) + , m_gridWidget(nullptr) +{ + setWindowTitle("Configure Ship Layout"); + setModal(true); + + // Find the ship's layout grid. + for (const ShipDef& def : config->ships.ships) + { + if (def.id == shipId) + { + m_shipLayout = def.layout; + break; + } + } + + m_rows = static_cast(m_shipLayout.size()); + m_cols = 0; + for (const std::string& row : m_shipLayout) + { + const int w = static_cast(row.size()); + if (w > m_cols) + { + m_cols = w; + } + } + + // Initialize grid. + m_grid.assign(m_rows, std::vector(m_cols, {false, -1})); + for (int r = 0; r < m_rows; ++r) + { + for (int c = 0; c < static_cast(m_shipLayout[r].size()); ++c) + { + if (m_shipLayout[r][c] == 'O') + { + m_grid[r][c].buildable = true; + } + } + } + rebuildOccupancy(); + + // --- UI layout --- + QHBoxLayout* mainLayout = new QHBoxLayout(this); + + // Left: grid widget. + LayoutGridWidget* gridW = new LayoutGridWidget(this, this); + gridW->setGridData(&m_grid, m_rows, m_cols, &m_placedModules, m_config); + gridW->setGhostData(m_activeModuleIndex, m_currentRotation); + m_gridWidget = gridW; + mainLayout->addWidget(m_gridWidget); + + // Right: module buttons + confirm/cancel. + QVBoxLayout* rightLayout = new QVBoxLayout(); + + QGridLayout* buttonGrid = new QGridLayout(); + buttonGrid->setSpacing(4); + + QSignalMapper* mapper = new QSignalMapper(this); + int col = 0; + int row = 0; + const int kCols = 2; + + for (int i = 0; i < static_cast(config->modules.modules.size()); ++i) + { + const ModuleDef& def = config->modules.modules[i]; + const QString label = displayName(def.id) + + "\n" + QString::fromStdString(def.glyph); + QPushButton* btn = new QPushButton(label, this); + btn->setCheckable(true); + btn->setFixedHeight(48); + buttonGrid->addWidget(btn, row, col); + m_moduleButtons.push_back(btn); + + mapper->setMapping(btn, i); + connect(btn, &QPushButton::clicked, mapper, qOverload<>(&QSignalMapper::map)); + + ++col; + if (col >= kCols) + { + col = 0; + ++row; + } + } + connect(mapper, qOverload(&QSignalMapper::mapped), + this, &ShipLayoutDialog::onModuleButtonClicked); + + // Remove button. + m_removeButton = new QPushButton("Remove", this); + m_removeButton->setCheckable(true); + m_removeButton->setFixedHeight(48); + if (col > 0) + { + ++row; + } + buttonGrid->addWidget(m_removeButton, row, 0, 1, kCols); + connect(m_removeButton, &QPushButton::clicked, this, [this]() { + if (m_activeModuleIndex == -1) + { + m_activeModuleIndex = -2; + m_removeButton->setChecked(false); + } + else + { + for (QPushButton* btn : m_moduleButtons) + { + btn->setChecked(false); + } + m_activeModuleIndex = -1; + m_removeButton->setChecked(true); + } + updateGridWidget(); + }); + + rightLayout->addLayout(buttonGrid); + rightLayout->addStretch(); + + // Confirm / Cancel buttons. + QHBoxLayout* bottomBar = new QHBoxLayout(); + QPushButton* confirmBtn = new QPushButton("Confirm", this); + QPushButton* cancelBtn = new QPushButton("Cancel", this); + bottomBar->addWidget(confirmBtn); + bottomBar->addWidget(cancelBtn); + rightLayout->addLayout(bottomBar); + + connect(confirmBtn, &QPushButton::clicked, this, &ShipLayoutDialog::onConfirm); + connect(cancelBtn, &QPushButton::clicked, this, &ShipLayoutDialog::onCancel); + + mainLayout->addLayout(rightLayout); + + // Grid click handler. + connect(this, &ShipLayoutDialog::gridCellClicked, this, [this](QPoint cell) { + if (m_activeModuleIndex == -2) + { + return; + } + + if (m_activeModuleIndex == -1) + { + // Remove mode: find and remove module at cell. + if (cell.y() >= 0 && cell.y() < m_rows && cell.x() >= 0 && cell.x() < m_cols) + { + const int idx = m_grid[cell.y()][cell.x()].moduleIndex; + if (idx >= 0) + { + m_placedModules.erase(m_placedModules.begin() + idx); + rebuildOccupancy(); + updateGridWidget(); + } + } + return; + } + + // Place module. + const ModuleDef& def = m_config->modules.modules[m_activeModuleIndex]; + if (canPlaceModule(def, cell, m_currentRotation)) + { + PlacedModule pm; + pm.moduleId = def.id; + pm.position = cell; + pm.rotation = m_currentRotation; + m_placedModules.push_back(pm); + rebuildOccupancy(); + updateGridWidget(); + } + }); +} + +std::optional ShipLayoutDialog::result() const +{ + return m_result; +} + +void ShipLayoutDialog::keyPressEvent(QKeyEvent* event) +{ + if (event->key() == Qt::Key_Q) + { + // Rotate CCW = 3 CW steps. + switch (m_currentRotation) + { + case Rotation::East: m_currentRotation = Rotation::North; break; + case Rotation::North: m_currentRotation = Rotation::West; break; + case Rotation::West: m_currentRotation = Rotation::South; break; + case Rotation::South: m_currentRotation = Rotation::East; break; + } + updateGridWidget(); + } + else if (event->key() == Qt::Key_E) + { + // Rotate CW. + switch (m_currentRotation) + { + case Rotation::East: m_currentRotation = Rotation::South; break; + case Rotation::South: m_currentRotation = Rotation::West; break; + case Rotation::West: m_currentRotation = Rotation::North; break; + case Rotation::North: m_currentRotation = Rotation::East; break; + } + updateGridWidget(); + } + else + { + QDialog::keyPressEvent(event); + } +} + +void ShipLayoutDialog::onModuleButtonClicked(int index) +{ + if (m_activeModuleIndex == index) + { + m_moduleButtons[index]->setChecked(false); + m_activeModuleIndex = -2; + } + else + { + for (int i = 0; i < static_cast(m_moduleButtons.size()); ++i) + { + m_moduleButtons[i]->setChecked(i == index); + } + m_removeButton->setChecked(false); + m_activeModuleIndex = index; + } + updateGridWidget(); +} + +void ShipLayoutDialog::onConfirm() +{ + ShipLayoutConfig layout; + layout.placedModules = m_placedModules; + m_result = layout; + accept(); +} + +void ShipLayoutDialog::onCancel() +{ + m_result = std::nullopt; + reject(); +} + +void ShipLayoutDialog::rebuildOccupancy() +{ + for (int r = 0; r < m_rows; ++r) + { + for (int c = 0; c < m_cols; ++c) + { + m_grid[r][c].moduleIndex = -1; + } + } + + for (int i = 0; i < static_cast(m_placedModules.size()); ++i) + { + const PlacedModule& pm = m_placedModules[i]; + const ModuleDef* def = nullptr; + for (const ModuleDef& d : m_config->modules.modules) + { + if (d.id == pm.moduleId) { def = &d; break; } + } + if (!def) + { + continue; + } + const std::vector mask = rotatedMask(*def, pm.rotation); + for (int mr = 0; mr < static_cast(mask.size()); ++mr) + { + for (int mc = 0; mc < static_cast(mask[mr].size()); ++mc) + { + if (mask[mr][mc] != 'O') + { + continue; + } + const int gr = pm.position.y() + mr; + const int gc = pm.position.x() + mc; + if (gr >= 0 && gr < m_rows && gc >= 0 && gc < m_cols) + { + m_grid[gr][gc].moduleIndex = i; + } + } + } + } +} + +void ShipLayoutDialog::updateGridWidget() +{ + LayoutGridWidget* gridW = static_cast(m_gridWidget); + gridW->setGhostData(m_activeModuleIndex, m_currentRotation); + gridW->update(); +} + +bool ShipLayoutDialog::canPlaceModule(const ModuleDef& def, QPoint position, + Rotation rotation) const +{ + const std::vector mask = rotatedMask(def, rotation); + for (int mr = 0; mr < static_cast(mask.size()); ++mr) + { + for (int mc = 0; mc < static_cast(mask[mr].size()); ++mc) + { + if (mask[mr][mc] != 'O') + { + continue; + } + const int gr = position.y() + mr; + const int gc = position.x() + mc; + if (gr < 0 || gr >= m_rows || gc < 0 || gc >= m_cols) + { + return false; + } + if (!m_grid[gr][gc].buildable) + { + return false; + } + if (m_grid[gr][gc].moduleIndex >= 0) + { + return false; + } + } + } + return true; +} + +std::vector ShipLayoutDialog::rotatedMask(const ModuleDef& def, + Rotation rotation) const +{ + int steps = 0; + switch (rotation) + { + case Rotation::East: steps = 0; break; + case Rotation::South: steps = 1; break; + case Rotation::West: steps = 2; break; + case Rotation::North: steps = 3; break; + } + std::vector result = def.surfaceMask; + for (int i = 0; i < steps; ++i) + { + result = rotateMaskCW(result); + } + return result; +} diff --git a/src/ui/ShipLayoutDialog.h b/src/ui/ShipLayoutDialog.h new file mode 100644 index 0000000..5c92d3d --- /dev/null +++ b/src/ui/ShipLayoutDialog.h @@ -0,0 +1,70 @@ +#pragma once + +#include +#include +#include + +#include +#include + +#include "GameConfig.h" +#include "Rotation.h" +#include "ShipLayout.h" + +class QPushButton; + +class ShipLayoutDialog : public QDialog +{ + Q_OBJECT + +public: + ShipLayoutDialog(const GameConfig* config, + const std::string& shipId, + const ShipLayoutConfig& currentLayout, + QWidget* parent = nullptr); + + std::optional result() const; + +protected: + void keyPressEvent(QKeyEvent* event) override; + +signals: + void gridCellClicked(QPoint cell); + +private slots: + void onModuleButtonClicked(int index); + void onConfirm(); + void onCancel(); + +public: + struct CellInfo + { + bool buildable; + int moduleIndex; // -1 if empty + }; + +private: + + void rebuildOccupancy(); + void updateGridWidget(); + bool canPlaceModule(const ModuleDef& def, QPoint position, Rotation rotation) const; + std::vector rotatedMask(const ModuleDef& def, Rotation rotation) const; + + const GameConfig* m_config; + std::string m_shipId; + std::vector m_shipLayout; + int m_rows; + int m_cols; + + std::vector m_placedModules; + std::vector> m_grid; + + int m_activeModuleIndex; // -1 = remove mode, -2 = no selection + Rotation m_currentRotation; + + std::vector m_moduleButtons; + QPushButton* m_removeButton; + QWidget* m_gridWidget; + + std::optional m_result; +}; diff --git a/src/ui/ShipLayoutPreview.cpp b/src/ui/ShipLayoutPreview.cpp new file mode 100644 index 0000000..1474da0 --- /dev/null +++ b/src/ui/ShipLayoutPreview.cpp @@ -0,0 +1,198 @@ +#include "ShipLayoutPreview.h" + +#include +#include + +namespace +{ + +const int kCellSize = 8; + +const ModuleDef* findModuleDef(const std::vector& modules, + const std::string& id) +{ + for (const ModuleDef& def : modules) + { + if (def.id == id) + { + return &def; + } + } + return nullptr; +} + +std::vector rotateMaskCW(const std::vector& grid) +{ + if (grid.empty()) + { + return {}; + } + const int srcH = static_cast(grid.size()); + int srcW = 0; + for (const std::string& row : grid) + { + const int w = static_cast(row.size()); + if (w > srcW) + { + srcW = w; + } + } + const int dstW = srcH; + const int dstH = srcW; + std::vector dst(dstH, std::string(dstW, 'X')); + for (int row = 0; row < srcH; ++row) + { + for (int col = 0; col < srcW; ++col) + { + const char ch = (col < static_cast(grid[row].size())) + ? grid[row][col] + : 'X'; + dst[col][srcH - 1 - row] = ch; + } + } + return dst; +} + +std::vector rotateMask(const std::vector& mask, + Rotation rotation) +{ + int steps = 0; + switch (rotation) + { + case Rotation::East: steps = 0; break; + case Rotation::South: steps = 1; break; + case Rotation::West: steps = 2; break; + case Rotation::North: steps = 3; break; + } + std::vector result = mask; + for (int i = 0; i < steps; ++i) + { + result = rotateMaskCW(result); + } + return result; +} + +} // namespace + + +ShipLayoutPreview::ShipLayoutPreview(QWidget* parent) + : QWidget(parent) + , m_modules(nullptr) + , m_rows(0) + , m_cols(0) +{ +} + +void ShipLayoutPreview::clear() +{ + m_grid.clear(); + m_placedModules.clear(); + m_modules = nullptr; + m_rows = 0; + m_cols = 0; + setFixedSize(0, 0); + update(); +} + +void ShipLayoutPreview::setShipAndLayout(const std::vector& shipLayout, + const ShipLayoutConfig& layout, + const std::vector* modules) +{ + m_modules = modules; + m_placedModules = layout.placedModules; + m_rows = static_cast(shipLayout.size()); + m_cols = 0; + for (const std::string& row : shipLayout) + { + const int w = static_cast(row.size()); + if (w > m_cols) + { + m_cols = w; + } + } + + m_grid.assign(m_rows, std::vector(m_cols, {false, -1})); + for (int r = 0; r < m_rows; ++r) + { + for (int c = 0; c < static_cast(shipLayout[r].size()); ++c) + { + if (shipLayout[r][c] == 'O') + { + m_grid[r][c].buildable = true; + } + } + } + + for (int i = 0; i < static_cast(m_placedModules.size()); ++i) + { + const PlacedModule& pm = m_placedModules[i]; + const ModuleDef* def = findModuleDef(*m_modules, pm.moduleId); + if (!def) + { + continue; + } + const std::vector rotated = rotateMask(def->surfaceMask, pm.rotation); + for (int mr = 0; mr < static_cast(rotated.size()); ++mr) + { + for (int mc = 0; mc < static_cast(rotated[mr].size()); ++mc) + { + if (rotated[mr][mc] != 'O') + { + continue; + } + const int gr = pm.position.y() + mr; + const int gc = pm.position.x() + mc; + if (gr >= 0 && gr < m_rows && gc >= 0 && gc < m_cols) + { + m_grid[gr][gc].moduleIndex = i; + } + } + } + } + + setFixedSize(m_cols * kCellSize, m_rows * kCellSize); + update(); +} + +void ShipLayoutPreview::paintEvent(QPaintEvent* /*event*/) +{ + if (m_rows == 0 || m_cols == 0) + { + return; + } + + QPainter painter(this); + painter.setRenderHint(QPainter::Antialiasing, false); + + for (int r = 0; r < m_rows; ++r) + { + for (int c = 0; c < m_cols; ++c) + { + const QRect cellRect(c * kCellSize, r * kCellSize, kCellSize, kCellSize); + const CellInfo& cell = m_grid[r][c]; + + if (!cell.buildable) + { + painter.fillRect(cellRect, Qt::black); + } + else if (cell.moduleIndex >= 0) + { + const PlacedModule& pm = m_placedModules[cell.moduleIndex]; + const ModuleDef* def = findModuleDef(*m_modules, pm.moduleId); + QColor color(Qt::gray); + if (def) + { + color = QColor(QString::fromStdString(def->fillColor)); + } + painter.fillRect(cellRect, color); + } + else + { + painter.fillRect(cellRect, Qt::white); + } + + painter.setPen(QColor(128, 128, 128)); + painter.drawRect(cellRect); + } + } +} diff --git a/src/ui/ShipLayoutPreview.h b/src/ui/ShipLayoutPreview.h new file mode 100644 index 0000000..7e768fa --- /dev/null +++ b/src/ui/ShipLayoutPreview.h @@ -0,0 +1,38 @@ +#pragma once + +#include +#include + +#include + +#include "ModulesConfig.h" +#include "ShipLayout.h" + +class ShipLayoutPreview : public QWidget +{ + Q_OBJECT + +public: + explicit ShipLayoutPreview(QWidget* parent = nullptr); + + void setShipAndLayout(const std::vector& shipLayout, + const ShipLayoutConfig& layout, + const std::vector* modules); + void clear(); + +protected: + void paintEvent(QPaintEvent* event) override; + +private: + struct CellInfo + { + bool buildable; + int moduleIndex; // -1 if empty + }; + + std::vector> m_grid; + std::vector m_placedModules; + const std::vector* m_modules; + int m_rows; + int m_cols; +};