implement ship modules

This commit is contained in:
2026-05-18 08:49:51 +02:00
parent b59e392461
commit d08bf5d37b
33 changed files with 1911 additions and 56 deletions

View File

@@ -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

View File

@@ -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<toml::table&>(*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<int>(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.<category>] 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<toml::table&>(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;
}

View File

@@ -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);
};

View File

@@ -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;
};

View File

@@ -0,0 +1,34 @@
#pragma once
#include <string>
#include <vector>
#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<std::string> surfaceMask;
std::vector<RecipeIngredient> materials;
int playerProductionLevel;
double productionTimeSeconds;
double threatCost;
std::string fillColor;
std::string glyph;
std::vector<ModuleStatModifier> statModifiers;
};
struct ModulesConfig
{
std::vector<ModuleDef> modules;
};

View File

@@ -69,6 +69,7 @@ struct ShipDef
{
std::string id;
bool availableFromStart;
std::vector<std::string> layout;
ShipSchematic schematic;
ShipThreat threat;

View File

@@ -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<ShipLayoutConfig> shipLayout;
};
// Weapon state for stationary structures (defence stations).
@@ -85,6 +87,9 @@ struct Building
std::vector<Port> inputPorts; // perimeter tiles (minus output-port tiles),
// direction pointing INTO building
// Module layout for shipyards (REQ-MOD-LAYOUT).
std::optional<ShipLayoutConfig> shipLayout;
// Set only for defence stations; nullopt for all other building types.
std::optional<StationWeapon> weapon;
};

View File

@@ -11,7 +11,8 @@ BuildingSystem::BuildingSystem(const GameConfig& config,
BeltSystem& belts,
std::function<EntityId()> allocateId,
std::function<void(int)> addBuildingBlocks,
std::function<void(const std::string&, QVector2D)> spawnShip,
std::function<void(const std::string&, QVector2D,
const std::optional<ShipLayoutConfig>&)> 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<Port> 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<std::string, int> 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<const std::string, int>& req : requiredMaterials)
{
const ItemType type{req.first};
const std::map<ItemType, int>::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<const std::string, int>& 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);
}
}

View File

@@ -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<EntityId()> allocateId,
std::function<void(int)> addBuildingBlocks,
std::function<void(const std::string&, QVector2D)> spawnShip,
std::function<void(const std::string&, QVector2D,
const std::optional<ShipLayoutConfig>&)> 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<Port> computeInputPorts(const Building& b) const;
@@ -130,7 +138,8 @@ private:
BeltSystem& m_belts;
std::function<EntityId()> m_allocateId;
std::function<void(int)> m_addBuildingBlocks;
std::function<void(const std::string&, QVector2D)> m_spawnShip;
std::function<void(const std::string&, QVector2D,
const std::optional<ShipLayoutConfig>&)> m_spawnShip;
std::mt19937& m_rng;
std::vector<Building> m_buildings;

View File

@@ -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

22
src/lib/sim/ShipLayout.h Normal file
View File

@@ -0,0 +1,22 @@
#pragma once
#include <string>
#include <vector>
#include <QPoint>
#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<PlacedModule> placedModules;
};

View File

@@ -3,10 +3,13 @@
#include <algorithm>
#include <cassert>
#include <limits>
#include <map>
#include <utility>
#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<ShipLayoutConfig>& 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<std::string, std::pair<double, double>> 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<double>(modDef->playerProductionLevel));
std::pair<double, double>& 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<std::string, std::pair<double, double>>::const_iterator it =
mods.find(name);
if (it != mods.end())
{
stat = static_cast<float>(
static_cast<double>(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;
}

View File

@@ -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<ShipLayoutConfig>& 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.

View File

@@ -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<ShipLayoutConfig>& layout) {
const std::map<std::string, SchematicState>::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<ShipSystem>(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<ShipLayoutConfig>& layout) {
const std::map<std::string, SchematicState>::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<ShipSystem>(m_config, [this]() { return allocateId(); });