define ship roles via added modules and allow multiple weapons

This commit is contained in:
2026-06-01 22:57:53 +02:00
parent f363f7a67c
commit 9d0a60a93b
31 changed files with 873 additions and 407 deletions

View File

@@ -7,8 +7,13 @@
#include <utility>
#include <vector>
#include <QPoint>
#include "toml.hpp"
#include "Rotation.h"
#include "ShipLayout.h"
namespace
{
@@ -207,6 +212,42 @@ toml::table parseFile(const std::string& path, const std::string& file)
}
}
Rotation parseRotationString(const std::string& s)
{
if (s == "east") { return Rotation::East; }
if (s == "south") { return Rotation::South; }
if (s == "west") { return Rotation::West; }
return Rotation::North;
}
std::vector<PlacedModule> parsePlacedModules(const toml::array& arr,
const std::string& file,
const std::string& path)
{
std::vector<PlacedModule> result;
result.reserve(arr.size());
for (std::size_t i = 0; i < arr.size(); ++i)
{
const std::string elemPath = path + "[" + std::to_string(i) + "]";
const toml::table* t = arr[i].as_table();
if (t == nullptr) { continue; }
toml::table& mt = const_cast<toml::table&>(*t);
const std::optional<std::string> type = mt["type"].value<std::string>();
const std::optional<int64_t> x = mt["x"].value<int64_t>();
const std::optional<int64_t> y = mt["y"].value<int64_t>();
const std::optional<std::string> rot = mt["rotation"].value<std::string>();
if (!type || !x || !y || !rot) { continue; }
PlacedModule pm;
pm.moduleId = *type;
pm.position = QPoint(static_cast<int>(*x), static_cast<int>(*y));
pm.rotation = parseRotationString(*rot);
result.push_back(std::move(pm));
}
return result;
}
} // namespace
@@ -418,43 +459,13 @@ ShipsConfig ConfigLoader::loadShips(const std::string& path)
def.loot.scrapDrop = static_cast<int>(requireInt(lMt["scrap_drop"], file, lPath + ".scrap_drop"));
}
// Optional: combat
if (mt.contains("combat"))
// Optional: default_modules (REQ-WAV-DEFAULT-MODULES)
if (mt.contains("default_modules"))
{
const std::string cPath = elemPath + ".combat";
const toml::table& cTable = requireTable(mt["combat"], file, cPath);
toml::table& cMt = const_cast<toml::table&>(cTable);
ShipCombat combat {
requireFormula(cMt["damage_formula"], file, cPath + ".damage_formula"),
requireFormula(cMt["attack_range_formula"], file, cPath + ".attack_range_formula"),
requireFormula(cMt["attack_rate_formula"], file, cPath + ".attack_rate_formula"),
};
def.combat = std::move(combat);
}
// Optional: salvage
if (mt.contains("salvage"))
{
const std::string sPath = elemPath + ".salvage";
const toml::table& sTable = requireTable(mt["salvage"], file, sPath);
toml::table& sMt = const_cast<toml::table&>(sTable);
ShipSalvage salvage;
salvage.collectionRange = requireDouble(sMt["collection_range"], file, sPath + ".collection_range");
salvage.cargoCapacity = static_cast<int>(requireInt(sMt["cargo_capacity"], file, sPath + ".cargo_capacity"));
def.salvage = salvage;
}
// Optional: repair
if (mt.contains("repair"))
{
const std::string rPath = elemPath + ".repair";
const toml::table& rTable = requireTable(mt["repair"], file, rPath);
toml::table& rMt = const_cast<toml::table&>(rTable);
ShipRepair repair {
requireFormula(rMt["repair_rate_formula"], file, rPath + ".repair_rate_formula"),
requireFormula(rMt["repair_range_formula"], file, rPath + ".repair_range_formula"),
};
def.repair = std::move(repair);
const toml::array& modArr = requireArray(mt["default_modules"], file,
elemPath + ".default_modules");
def.defaultModules = parsePlacedModules(modArr, file,
elemPath + ".default_modules");
}
cfg.ships.push_back(std::move(def));
@@ -514,9 +525,11 @@ static const StatEntry kKnownStats[] = {
{"health", "hp"},
{"movement", "speed"},
{"sensor", "sensor_range"},
{"combat", "damage"},
{"combat", "attack_range"},
{"combat", "attack_rate"},
{"weapon", "damage"},
{"weapon", "attack_range"},
{"weapon", "attack_rate"},
{"salvage", "collection_range"},
{"salvage", "cargo_capacity"},
{"repair", "repair_rate"},
{"repair", "repair_range"},
};
@@ -597,6 +610,60 @@ ModulesConfig ConfigLoader::loadModules(const std::string& path)
}
}
// Weapon capability section: [module.weapon] with base stat formulas
if (mt.contains("weapon"))
{
const std::string wPath = elemPath + ".weapon";
const toml::table& wTable = requireTable(mt["weapon"], file, wPath);
toml::table& wMt = const_cast<toml::table&>(wTable);
if (wMt.contains("damage_formula") || wMt.contains("attack_range_formula")
|| wMt.contains("attack_rate_formula"))
{
ModuleWeaponCapability cap;
cap.damageFormula = requireFormula(wMt["damage_formula"],
file, wPath + ".damage_formula");
cap.attackRangeFormula = requireFormula(wMt["attack_range_formula"],
file, wPath + ".attack_range_formula");
cap.attackRateFormula = requireFormula(wMt["attack_rate_formula"],
file, wPath + ".attack_rate_formula");
def.weaponCapability = std::move(cap);
}
}
// Salvage capability section: [module.salvage] with base stat formulas
if (mt.contains("salvage"))
{
const std::string sPath = elemPath + ".salvage";
const toml::table& sTable = requireTable(mt["salvage"], file, sPath);
toml::table& sMt = const_cast<toml::table&>(sTable);
if (sMt.contains("collection_range_formula") || sMt.contains("cargo_capacity_formula"))
{
ModuleSalvageCapability cap;
cap.collectionRangeFormula = requireFormula(sMt["collection_range_formula"],
file, sPath + ".collection_range_formula");
cap.cargoCapacityFormula = requireFormula(sMt["cargo_capacity_formula"],
file, sPath + ".cargo_capacity_formula");
def.salvageCapability = std::move(cap);
}
}
// Repair capability section: [module.repair] with base stat formulas
if (mt.contains("repair"))
{
const std::string rPath = elemPath + ".repair";
const toml::table& rTable = requireTable(mt["repair"], file, rPath);
toml::table& rMt = const_cast<toml::table&>(rTable);
if (rMt.contains("repair_rate_formula") || rMt.contains("repair_range_formula"))
{
ModuleRepairCapability cap;
cap.repairRateFormula = requireFormula(rMt["repair_rate_formula"],
file, rPath + ".repair_rate_formula");
cap.repairRangeFormula = requireFormula(rMt["repair_range_formula"],
file, rPath + ".repair_range_formula");
def.repairCapability = std::move(cap);
}
}
cfg.modules.push_back(std::move(def));
}

View File

@@ -1,5 +1,6 @@
#pragma once
#include <optional>
#include <string>
#include <vector>
@@ -15,6 +16,26 @@ struct ModuleStatModifier
Formula formula;
};
// Capability sections — present when the module grants that capability.
struct ModuleWeaponCapability
{
Formula damageFormula;
Formula attackRangeFormula;
Formula attackRateFormula;
};
struct ModuleSalvageCapability
{
Formula collectionRangeFormula;
Formula cargoCapacityFormula;
};
struct ModuleRepairCapability
{
Formula repairRateFormula;
Formula repairRangeFormula;
};
struct ModuleDef
{
std::string id;
@@ -26,6 +47,10 @@ struct ModuleDef
std::string fillColor;
std::string glyph;
std::vector<ModuleStatModifier> statModifiers;
std::optional<ModuleWeaponCapability> weaponCapability;
std::optional<ModuleSalvageCapability> salvageCapability;
std::optional<ModuleRepairCapability> repairCapability;
};
struct ModulesConfig

View File

@@ -1,11 +1,11 @@
#pragma once
#include <optional>
#include <string>
#include <vector>
#include "Formula.h"
#include "RecipesConfig.h" // for RecipeIngredient
#include "ShipLayout.h" // for PlacedModule
// Build materials and initial per-schematic production level
// (REQ-BLD-SHIPYARD, REQ-DEF-SCHEMATIC-DROP).
@@ -42,27 +42,6 @@ struct ShipSensor
Formula sensorRangeFormula; // REQ-SHP-SENSOR, REQ-SHP-STATS
};
struct ShipCombat
{
Formula damageFormula;
Formula attackRangeFormula;
Formula attackRateFormula; // shots per second
};
// Optional; present only on salvage ships (REQ-SHP-SALVAGE).
struct ShipSalvage
{
double collectionRange;
int cargoCapacity;
};
// Optional; present only on repair ships (REQ-SHP-REPAIR).
struct ShipRepair
{
Formula repairRateFormula;
Formula repairRangeFormula;
};
// Scrap dropped on destruction (REQ-RES-SCRAP-DROP).
struct ShipLoot
{
@@ -82,12 +61,8 @@ struct ShipDef
ShipSensor sensor;
ShipLoot loot;
// Role-specific sections. A ship is a combat ship if combat is present,
// a salvage ship if salvage is present, etc. A ship may have multiple
// of these set (hybrid ships) once the behavior systems support it.
std::optional<ShipCombat> combat;
std::optional<ShipSalvage> salvage;
std::optional<ShipRepair> repair;
// Module layout used for enemy wave ships (REQ-WAV-DEFAULT-MODULES).
std::vector<PlacedModule> defaultModules;
};
struct ShipsConfig

View File

@@ -18,6 +18,11 @@ entt::entity EntityAdmin::createEntity()
return m_registry.create();
}
entt::entity EntityAdmin::createModuleEntity()
{
return m_registry.create();
}
bool EntityAdmin::isValid(entt::entity entity) const
{
return m_registry.valid(entity);

View File

@@ -66,6 +66,10 @@ public:
entt::entity spawnHqProxy(QVector2D position, float hp, float maxHp);
// Creates a bare entity for module child entities (weapons, salvage, repair).
// Caller is responsible for attaching all required components.
entt::entity createModuleEntity();
private:
entt::entity createEntity();

View File

@@ -0,0 +1,9 @@
#pragma once
#include "entt/entity/entity.hpp"
// Links a capability module child entity back to its owner ship or station.
struct ModuleOwnerComponent
{
entt::entity owner;
};

View File

@@ -7,4 +7,5 @@
struct RepairBehaviorComponent
{
std::optional<entt::entity> currentTarget;
float maxRepairRange = 0.0f;
};

View File

@@ -9,5 +9,6 @@
struct SalvageBehaviorComponent
{
std::optional<QVector2D> scrapTarget;
BuildingId deliveryBay; // kInvalidBuildingId until assigned at a salvage bay
BuildingId deliveryBay; // kInvalidBuildingId until assigned at a salvage bay
float maxCollectionRange = 0.0f;
};

View File

@@ -1,6 +1,7 @@
#include "AiSystem.h"
#include <optional>
#include <unordered_map>
#include <vector>
#include <QVector2D>
@@ -14,6 +15,7 @@
#include "HealthComponent.h"
#include "HomeReturnBehaviorComponent.h"
#include "HqProxyComponent.h"
#include "ModuleOwnerComponent.h"
#include "MovementIntentComponent.h"
#include "PositionComponent.h"
#include "RallyBehaviorComponent.h"
@@ -224,13 +226,13 @@ void AiSystem::tickRepairBehavior(EntityAdmin& admin, BuildingSystem& buildings)
}
});
admin.forEach<RepairBehaviorComponent, RepairToolComponent, PositionComponent,
admin.forEach<RepairBehaviorComponent, PositionComponent,
FactionComponent, SensorRangeComponent, MovementIntentComponent>(
[&](entt::entity e, RepairBehaviorComponent& rb, RepairToolComponent& rt,
[&](entt::entity e, RepairBehaviorComponent& rb,
PositionComponent& pos, FactionComponent& /*faction*/,
SensorRangeComponent& sensor, MovementIntentComponent& intent)
{
const float repairRange = rt.range;
const float repairRange = rb.maxRepairRange;
// Flee if enemy nearby.
bool enemyNearby = false;
@@ -303,17 +305,6 @@ void AiSystem::tickRepairBehavior(EntityAdmin& admin, BuildingSystem& buildings)
targetPos = admin.get<PositionComponent>(target).value;
}
const float distToTarget = (targetPos - pos.value).length();
if (distToTarget <= repairRange)
{
if (admin.isValid(target) && admin.hasAll<HealthComponent>(target))
{
HealthComponent& targetHealth = admin.get<HealthComponent>(target);
targetHealth.hp = std::min(targetHealth.hp + rt.ratePerTick,
targetHealth.maxHp);
}
}
if (2 > intent.priority)
{
intent = MovementIntentComponent{2, targetPos};
@@ -321,6 +312,33 @@ void AiSystem::tickRepairBehavior(EntityAdmin& admin, BuildingSystem& buildings)
});
}
// ---------------------------------------------------------------------------
// tickRepairTools
// ---------------------------------------------------------------------------
void AiSystem::tickRepairTools(EntityAdmin& admin)
{
admin.forEach<RepairToolComponent, ModuleOwnerComponent>(
[&](entt::entity /*e*/, RepairToolComponent& rt, const ModuleOwnerComponent& owner)
{
if (!admin.hasAll<RepairBehaviorComponent>(owner.owner)) { return; }
const RepairBehaviorComponent& rb =
admin.get<RepairBehaviorComponent>(owner.owner);
if (!rb.currentTarget) { return; }
const entt::entity target = *rb.currentTarget;
if (!admin.isValid(target) || !admin.hasAll<HealthComponent>(target)) { return; }
const PositionComponent& ownerPos = admin.get<PositionComponent>(owner.owner);
const PositionComponent& targetPos = admin.get<PositionComponent>(target);
const float dist = (targetPos.value - ownerPos.value).length();
if (dist > rt.range) { return; }
HealthComponent& targetHealth = admin.get<HealthComponent>(target);
targetHealth.hp = std::min(targetHealth.hp + rt.ratePerTick, targetHealth.maxHp);
});
}
// ---------------------------------------------------------------------------
// tickSalvageBehavior (priority 1)
// ---------------------------------------------------------------------------
@@ -344,15 +362,31 @@ void AiSystem::tickSalvageBehavior(EntityAdmin& admin, ScrapSystem& scraps,
}
});
// Aggregate cargo across all salvage-module children per owning ship.
struct AggregatedCargo
{
int totalCurrent = 0;
int totalCapacity = 0;
};
std::unordered_map<entt::entity, AggregatedCargo> cargoByShip;
admin.forEach<SalvageCargoComponent, ModuleOwnerComponent>(
[&](entt::entity /*ce*/, const SalvageCargoComponent& c, const ModuleOwnerComponent& o)
{
AggregatedCargo& agg = cargoByShip[o.owner];
agg.totalCurrent += c.current;
agg.totalCapacity += c.capacity;
});
const std::vector<ScrapInfo> allScrap = scraps.allScrapInfo();
admin.forEach<SalvageBehaviorComponent, SalvageCargoComponent, PositionComponent,
admin.forEach<SalvageBehaviorComponent, PositionComponent,
SensorRangeComponent, MovementIntentComponent>(
[&](entt::entity /*e*/, SalvageBehaviorComponent& salvageBehavior,
SalvageCargoComponent& cargo, PositionComponent& pos,
[&](entt::entity e, SalvageBehaviorComponent& salvageBehavior,
PositionComponent& pos,
SensorRangeComponent& sensor, MovementIntentComponent& intent)
{
const float collectRange = cargo.collectionRange;
const float collectRange = salvageBehavior.maxCollectionRange;
const AggregatedCargo& cargoState = cargoByShip[e];
// Assign nearest SalvageBay if needed.
if (salvageBehavior.deliveryBay == kInvalidBuildingId)
@@ -378,7 +412,8 @@ void AiSystem::tickSalvageBehavior(EntityAdmin& admin, ScrapSystem& scraps,
}
}
const bool cargoFull = (cargo.current >= cargo.capacity);
const bool cargoFull = (cargoState.totalCurrent >= cargoState.totalCapacity
&& cargoState.totalCapacity > 0);
if (cargoFull)
{
@@ -389,17 +424,26 @@ void AiSystem::tickSalvageBehavior(EntityAdmin& admin, ScrapSystem& scraps,
if (bayId != kInvalidBuildingId
&& (pos.value - bayPos).length() <= 1.0f)
{
if (buildings.deliverScrapToSalvageBay(bayId))
{
--cargo.current;
}
// Decrement first non-empty salvage child.
bool delivered = false;
admin.forEach<SalvageCargoComponent, ModuleOwnerComponent>(
[&](entt::entity /*ce*/, SalvageCargoComponent& c,
const ModuleOwnerComponent& o)
{
if (delivered || o.owner != e || c.current <= 0) { return; }
if (buildings.deliverScrapToSalvageBay(bayId))
{
--c.current;
delivered = true;
}
});
}
return;
}
// Retreat if enemy near and cargo empty.
bool retreating = false;
if (cargo.current == 0)
if (cargoState.totalCurrent == 0)
{
for (const EnemyShipPos& enemy : enemyShips)
{
@@ -417,16 +461,24 @@ void AiSystem::tickSalvageBehavior(EntityAdmin& admin, ScrapSystem& scraps,
}
if (retreating) { return; }
// Collect nearby scrap.
// Collect nearby scrap — increment first non-full salvage child.
for (const ScrapInfo& si : allScrap)
{
if ((si.position - pos.value).length() <= collectRange)
{
if (scraps.consume(si.entity))
{
++cargo.current;
salvageBehavior.scrapTarget = std::nullopt;
}
bool collected = false;
admin.forEach<SalvageCargoComponent, ModuleOwnerComponent>(
[&](entt::entity /*ce*/, SalvageCargoComponent& c,
const ModuleOwnerComponent& o)
{
if (collected || o.owner != e || c.current >= c.capacity) { return; }
if (scraps.consume(si.entity))
{
++c.current;
salvageBehavior.scrapTarget = std::nullopt;
collected = true;
}
});
break;
}
}

View File

@@ -10,5 +10,6 @@ public:
void tickHomeReturnBehavior(EntityAdmin& admin);
void tickThreatResponseBehavior(EntityAdmin& admin, const BuildingSystem& buildings);
void tickRepairBehavior(EntityAdmin& admin, BuildingSystem& buildings);
void tickRepairTools(EntityAdmin& admin);
void tickSalvageBehavior(EntityAdmin& admin, ScrapSystem& scraps, BuildingSystem& buildings);
};

View File

@@ -2,8 +2,8 @@
#include "EntityAdmin.h"
#include "FactionComponent.h"
#include "StationBodyComponent.h"
#include "HealthComponent.h"
#include "ModuleOwnerComponent.h"
#include "PositionComponent.h"
#include "SensorRangeComponent.h"
#include "ShipIdentityComponent.h"
@@ -22,24 +22,18 @@ void CombatSystem::tick(Tick currentTick,
BuildingSystem& /*buildings*/,
std::vector<FireEvent>& outFireEvents)
{
// Ship weapons.
admin.forEach<WeaponComponent, ThreatResponseBehaviorComponent,
PositionComponent, FactionComponent>(
[&](entt::entity e, WeaponComponent& weapon,
ThreatResponseBehaviorComponent& threatResponseBehavior,
PositionComponent& pos, FactionComponent& faction)
// All weapons (ships and stations) are child entities linked via ModuleOwnerComponent.
admin.forEach<WeaponComponent, ModuleOwnerComponent>(
[&](entt::entity /*e*/, WeaponComponent& weapon, const ModuleOwnerComponent& owner)
{
weapon.currentTarget = threatResponseBehavior.currentTarget;
resolveWeapon(e, weapon, pos, faction, currentTick, admin, outFireEvents);
});
// Station weapons (entities with StationBodyComponent; ships are excluded because
// they lack that component and are already handled by the ship loop above).
admin.forEach<WeaponComponent, PositionComponent, FactionComponent, StationBodyComponent>(
[&](entt::entity e, WeaponComponent& weapon, PositionComponent& pos,
FactionComponent& faction, const StationBodyComponent& /*sb*/)
{
resolveWeapon(e, weapon, pos, faction, currentTick, admin, outFireEvents);
if (admin.hasAll<ThreatResponseBehaviorComponent>(owner.owner))
{
weapon.currentTarget =
admin.get<ThreatResponseBehaviorComponent>(owner.owner).currentTarget;
}
const PositionComponent& pos = admin.get<PositionComponent>(owner.owner);
const FactionComponent& faction = admin.get<FactionComponent>(owner.owner);
resolveWeapon(owner.owner, weapon, pos, faction, currentTick, admin, outFireEvents);
});
}

View File

@@ -3,11 +3,13 @@
#include <cassert>
#include <map>
#include <utility>
#include <vector>
#include "DynamicBodyComponent.h"
#include "EntityAdmin.h"
#include "FactionComponent.h"
#include "HealthComponent.h"
#include "ModuleOwnerComponent.h"
#include "ModulesConfig.h"
#include "MovementIntentComponent.h"
#include "RallyBehaviorComponent.h"
@@ -85,17 +87,182 @@ entt::entity ShipSystem::spawn(const std::string& schematicId, int level,
angularAccelPerTick, maxRotationSpeedPerTick, sensorRange,
level, schematicId, isEnemy);
// Optional components based on ship role.
if (def->combat)
{
WeaponComponent w;
w.damage = static_cast<float>(def->combat->damageFormula.evaluate(x));
w.range = static_cast<float>(def->combat->attackRangeFormula.evaluate(x));
w.fireRateHz = static_cast<float>(def->combat->attackRateFormula.evaluate(x));
w.cooldownTicks = 0.0f;
w.currentTarget = std::nullopt;
m_admin.addComponent<WeaponComponent>(entity, w);
// Determine module list: configured layout takes precedence over default.
const std::vector<PlacedModule>& modules =
layout.has_value() ? layout->placedModules : def->defaultModules;
// --- Pass 1: create capability child entities ----------------------------
std::vector<entt::entity> weaponChildren;
std::vector<entt::entity> salvageChildren;
std::vector<entt::entity> repairChildren;
for (const PlacedModule& pm : modules)
{
const ModuleDef* modDef = findModuleDef(pm.moduleId);
if (!modDef) { continue; }
const double mx = static_cast<double>(modDef->playerProductionLevel);
if (modDef->weaponCapability)
{
WeaponComponent w;
w.damage = static_cast<float>(
modDef->weaponCapability->damageFormula.evaluate(mx));
w.range = static_cast<float>(
modDef->weaponCapability->attackRangeFormula.evaluate(mx));
w.fireRateHz = static_cast<float>(
modDef->weaponCapability->attackRateFormula.evaluate(mx));
w.cooldownTicks = 0.0f;
w.currentTarget = std::nullopt;
entt::entity child = m_admin.createModuleEntity();
m_admin.addComponent<WeaponComponent>(child, w);
m_admin.addComponent<ModuleOwnerComponent>(child, ModuleOwnerComponent{entity});
weaponChildren.push_back(child);
}
if (modDef->salvageCapability)
{
SalvageCargoComponent cargo;
cargo.capacity = static_cast<int>(
modDef->salvageCapability->cargoCapacityFormula.evaluate(mx));
cargo.current = 0;
cargo.collectionRange = static_cast<float>(
modDef->salvageCapability->collectionRangeFormula.evaluate(mx));
entt::entity child = m_admin.createModuleEntity();
m_admin.addComponent<SalvageCargoComponent>(child, cargo);
m_admin.addComponent<ModuleOwnerComponent>(child, ModuleOwnerComponent{entity});
salvageChildren.push_back(child);
}
if (modDef->repairCapability)
{
RepairToolComponent rt;
rt.ratePerTick = static_cast<float>(
modDef->repairCapability->repairRateFormula.evaluate(mx))
/ static_cast<float>(kTickRateHz);
rt.range = static_cast<float>(
modDef->repairCapability->repairRangeFormula.evaluate(mx));
rt.currentTarget = std::nullopt;
entt::entity child = m_admin.createModuleEntity();
m_admin.addComponent<RepairToolComponent>(child, rt);
m_admin.addComponent<ModuleOwnerComponent>(child, ModuleOwnerComponent{entity});
repairChildren.push_back(child);
}
}
// --- Pass 2: apply passive stat modifiers --------------------------------
// Accumulate hull-level modifiers.
std::map<std::string, std::pair<double, double>> hullMods;
// Per-capability-type modifier accumulators (applied to each child).
std::map<std::string, std::pair<double, double>> weaponMods;
std::map<std::string, std::pair<double, double>> salvageMods;
std::map<std::string, std::pair<double, double>> repairMods;
for (const PlacedModule& pm : modules)
{
const ModuleDef* modDef = findModuleDef(pm.moduleId);
if (!modDef) { continue; }
const double mx = static_cast<double>(modDef->playerProductionLevel);
for (const ModuleStatModifier& sm : modDef->statModifiers)
{
const double val = sm.formula.evaluate(mx);
// Route modifier to the correct accumulator by stat category.
// weapon/salvage/repair stats go to the corresponding child map;
// hull stats (hp, speed, sensor_range, …) go to hullMods.
const bool isWeaponStat = (sm.stat == "damage"
|| sm.stat == "attack_range"
|| sm.stat == "attack_rate");
const bool isSalvageStat = (sm.stat == "collection_range"
|| sm.stat == "cargo_capacity");
const bool isRepairStat = (sm.stat == "repair_rate"
|| sm.stat == "repair_range");
std::map<std::string, std::pair<double, double>>* target = &hullMods;
if (isWeaponStat) { target = &weaponMods; }
if (isSalvageStat) { target = &salvageMods; }
if (isRepairStat) { target = &repairMods; }
std::pair<double, double>& acc = (*target)[sm.stat];
if (sm.modifierType == "multiplicative")
{
acc.first += (val - 1.0);
}
else
{
acc.second += val;
}
}
}
// Helper: apply a modifier map to a float stat.
auto applyMod = [](float& stat, const std::string& name,
const std::map<std::string, std::pair<double, double>>& mods)
{
const auto it = mods.find(name);
if (it != mods.end())
{
stat = static_cast<float>(
static_cast<double>(stat) * (1.0 + it->second.first)
+ it->second.second);
}
};
// Apply hull modifiers.
{
HealthComponent& health = m_admin.get<HealthComponent>(entity);
DynamicBodyComponent& dynamics = m_admin.get<DynamicBodyComponent>(entity);
SensorRangeComponent& sensor = m_admin.get<SensorRangeComponent>(entity);
applyMod(health.maxHp, "hp", hullMods);
health.hp = health.maxHp;
applyMod(dynamics.maxSpeedPerTick, "speed", hullMods);
applyMod(dynamics.mainAccelerationPerTick, "main_acceleration", hullMods);
applyMod(dynamics.maneuveringAccelerationPerTick, "maneuvering_acceleration", hullMods);
applyMod(dynamics.angularAccelerationPerTick, "angular_acceleration", hullMods);
applyMod(dynamics.maxRotationSpeedPerTick, "max_rotation_speed", hullMods);
applyMod(sensor.value, "sensor_range", hullMods);
}
// Apply weapon modifiers to each weapon child.
for (entt::entity child : weaponChildren)
{
WeaponComponent& w = m_admin.get<WeaponComponent>(child);
applyMod(w.damage, "damage", weaponMods);
applyMod(w.range, "attack_range", weaponMods);
applyMod(w.fireRateHz, "attack_rate", weaponMods);
}
// Apply salvage modifiers to each salvage child.
for (entt::entity child : salvageChildren)
{
SalvageCargoComponent& c = m_admin.get<SalvageCargoComponent>(child);
float fRange = c.collectionRange;
float fCapacity = static_cast<float>(c.capacity);
applyMod(fRange, "collection_range", salvageMods);
applyMod(fCapacity, "cargo_capacity", salvageMods);
c.collectionRange = fRange;
c.capacity = static_cast<int>(fCapacity);
}
// Apply repair modifiers to each repair child.
for (entt::entity child : repairChildren)
{
RepairToolComponent& rt = m_admin.get<RepairToolComponent>(child);
applyMod(rt.ratePerTick, "repair_rate", repairMods);
applyMod(rt.range, "repair_range", repairMods);
}
// --- Pass 3: attach behavior components based on capability presence -----
if (!weaponChildren.empty())
{
m_admin.addComponent<ThreatResponseBehaviorComponent>(
entity, ThreatResponseBehaviorComponent{});
@@ -106,95 +273,35 @@ entt::entity ShipSystem::spawn(const std::string& schematicId, int level,
}
}
if (def->salvage)
if (!salvageChildren.empty())
{
SalvageCargoComponent cargo;
cargo.capacity = def->salvage->cargoCapacity;
cargo.current = 0;
cargo.collectionRange = static_cast<float>(def->salvage->collectionRange);
m_admin.addComponent<SalvageCargoComponent>(entity, cargo);
float maxCollRange = 0.0f;
for (entt::entity child : salvageChildren)
{
const float r = m_admin.get<SalvageCargoComponent>(child).collectionRange;
if (r > maxCollRange) { maxCollRange = r; }
}
SalvageBehaviorComponent salvageBehavior;
salvageBehavior.scrapTarget = std::nullopt;
salvageBehavior.deliveryBay = kInvalidBuildingId;
m_admin.addComponent<SalvageBehaviorComponent>(entity, salvageBehavior);
SalvageBehaviorComponent sb;
sb.scrapTarget = std::nullopt;
sb.deliveryBay = kInvalidBuildingId;
sb.maxCollectionRange = maxCollRange;
m_admin.addComponent<SalvageBehaviorComponent>(entity, sb);
}
if (def->repair)
if (!repairChildren.empty())
{
RepairToolComponent rt;
rt.ratePerTick = static_cast<float>(def->repair->repairRateFormula.evaluate(x));
rt.range = static_cast<float>(def->repair->repairRangeFormula.evaluate(x));
rt.currentTarget = std::nullopt;
m_admin.addComponent<RepairToolComponent>(entity, rt);
m_admin.addComponent<RepairBehaviorComponent>(entity, RepairBehaviorComponent{});
}
// 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)
float maxRepairRange = 0.0f;
for (entt::entity child : repairChildren)
{
const ModuleDef* modDef = findModuleDef(pm.moduleId);
if (!modDef)
{
continue;
}
for (const ModuleStatModifier& sm : modDef->statModifiers)
{
const double val = sm.formula.evaluate(
static_cast<double>(modDef->playerProductionLevel));
std::pair<double, double>& acc = mods[sm.stat];
if (sm.modifierType == "multiplicative")
{
acc.first += (val - 1.0);
}
else
{
acc.second += val;
}
}
const float r = m_admin.get<RepairToolComponent>(child).range;
if (r > maxRepairRange) { maxRepairRange = r; }
}
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);
}
};
HealthComponent& health = m_admin.get<HealthComponent>(entity);
DynamicBodyComponent& dynamics = m_admin.get<DynamicBodyComponent>(entity);
SensorRangeComponent& sensor = m_admin.get<SensorRangeComponent>(entity);
applyMod(health.maxHp, "hp");
health.hp = health.maxHp;
applyMod(dynamics.maxSpeedPerTick, "speed");
applyMod(dynamics.mainAccelerationPerTick, "main_acceleration");
applyMod(dynamics.maneuveringAccelerationPerTick, "maneuvering_acceleration");
applyMod(dynamics.angularAccelerationPerTick, "angular_acceleration");
applyMod(dynamics.maxRotationSpeedPerTick, "max_rotation_speed");
applyMod(sensor.value, "sensor_range");
if (m_admin.hasAll<WeaponComponent>(entity))
{
WeaponComponent& weapon = m_admin.get<WeaponComponent>(entity);
applyMod(weapon.damage, "damage");
applyMod(weapon.range, "attack_range");
applyMod(weapon.fireRateHz, "attack_rate");
}
if (m_admin.hasAll<RepairToolComponent>(entity))
{
RepairToolComponent& repairTool = m_admin.get<RepairToolComponent>(entity);
applyMod(repairTool.ratePerTick, "repair_rate");
applyMod(repairTool.range, "repair_range");
}
RepairBehaviorComponent rb;
rb.currentTarget = std::nullopt;
rb.maxRepairRange = maxRepairRange;
m_admin.addComponent<RepairBehaviorComponent>(entity, rb);
}
return entity;
@@ -202,6 +309,13 @@ entt::entity ShipSystem::spawn(const std::string& schematicId, int level,
void ShipSystem::despawn(entt::entity entity)
{
std::vector<entt::entity> children;
m_admin.forEach<ModuleOwnerComponent>(
[&](entt::entity e, const ModuleOwnerComponent& o)
{
if (o.owner == entity) { children.push_back(e); }
});
for (entt::entity child : children) { m_admin.destroy(child); }
m_admin.destroy(entity);
}

View File

@@ -8,6 +8,7 @@
#include "DynamicBodySystem.h"
#include "FactionComponent.h"
#include "HealthComponent.h"
#include "ModuleOwnerComponent.h"
#include "MovementIntentSystem.h"
#include "PositionComponent.h"
#include "ScrapSystem.h"
@@ -173,6 +174,7 @@ void Simulation::tick()
m_aiSystem->tickHomeReturnBehavior(m_admin); // priority 4
m_aiSystem->tickThreatResponseBehavior(m_admin, *m_buildingSystem); // priority 3
m_aiSystem->tickRepairBehavior(m_admin, *m_buildingSystem); // priority 2
m_aiSystem->tickRepairTools(m_admin);
m_aiSystem->tickSalvageBehavior(m_admin, *m_scrapSystem, *m_buildingSystem); // priority 1
// Step 8: combat resolution
@@ -255,7 +257,12 @@ void Simulation::placeInitialStructures()
}
m_playerStation1Entity = m_admin.spawnStation(
anchor, psParsed.footprint, absCells, psHp, psHp, false);
m_admin.addComponent<WeaponComponent>(m_playerStation1Entity, psWeapon);
{
entt::entity wChild = m_admin.createModuleEntity();
m_admin.addComponent<WeaponComponent>(wChild, psWeapon);
m_admin.addComponent<ModuleOwnerComponent>(wChild,
ModuleOwnerComponent{m_playerStation1Entity});
}
m_buildingSystem->registerTileOccupancy(absCells, allocateBuildingId());
}
{
@@ -267,7 +274,12 @@ void Simulation::placeInitialStructures()
}
m_playerStation2Entity = m_admin.spawnStation(
anchor, psParsed.footprint, absCells, psHp, psHp, false);
m_admin.addComponent<WeaponComponent>(m_playerStation2Entity, psWeapon);
{
entt::entity wChild = m_admin.createModuleEntity();
m_admin.addComponent<WeaponComponent>(wChild, psWeapon);
m_admin.addComponent<ModuleOwnerComponent>(wChild,
ModuleOwnerComponent{m_playerStation2Entity});
}
m_buildingSystem->registerTileOccupancy(absCells, allocateBuildingId());
}
@@ -316,7 +328,12 @@ void Simulation::placeEnemyStationSet(int generation)
}
m_currentEnemyStationEntities[0] = m_admin.spawnStation(
anchor, esParsed.footprint, absCells, esHp, esHp, true);
m_admin.addComponent<WeaponComponent>(m_currentEnemyStationEntities[0], esWeapon);
{
entt::entity wChild = m_admin.createModuleEntity();
m_admin.addComponent<WeaponComponent>(wChild, esWeapon);
m_admin.addComponent<ModuleOwnerComponent>(wChild,
ModuleOwnerComponent{m_currentEnemyStationEntities[0]});
}
m_buildingSystem->registerTileOccupancy(absCells, allocateBuildingId());
}
{
@@ -328,7 +345,12 @@ void Simulation::placeEnemyStationSet(int generation)
}
m_currentEnemyStationEntities[1] = m_admin.spawnStation(
anchor, esParsed.footprint, absCells, esHp, esHp, true);
m_admin.addComponent<WeaponComponent>(m_currentEnemyStationEntities[1], esWeapon);
{
entt::entity wChild = m_admin.createModuleEntity();
m_admin.addComponent<WeaponComponent>(wChild, esWeapon);
m_admin.addComponent<ModuleOwnerComponent>(wChild,
ModuleOwnerComponent{m_currentEnemyStationEntities[1]});
}
m_buildingSystem->registerTileOccupancy(absCells, allocateBuildingId());
}
}
@@ -407,6 +429,15 @@ void Simulation::tickDeathsAndLoot()
m_scrapSystem->spawn(pos.value, scrap, despawnAt);
}
m_buildingSystem->unregisterTileOccupancy(sb.bodyCells);
{
std::vector<entt::entity> stationChildren;
m_admin.forEach<ModuleOwnerComponent>(
[&](entt::entity ce, const ModuleOwnerComponent& o)
{
if (o.owner == deadEntity) { stationChildren.push_back(ce); }
});
for (entt::entity ce : stationChildren) { m_admin.destroy(ce); }
}
m_admin.destroy(deadEntity);
}

View File

@@ -34,7 +34,7 @@ void WaveSystem::tickWaveScheduler(Tick currentTick, ShipSystem& ships,
if (currentTick >= entry.spawnAt)
{
ships.spawn(entry.schematicId, entry.level, entry.position,
/*isEnemy=*/true);
/*isEnemy=*/true, entry.layout);
}
else
{
@@ -90,8 +90,9 @@ std::vector<WaveSystem::SpawnEntry> WaveSystem::composeWave(Tick currentTick,
// Build eligible ship list with their costs at the current level.
struct EligibleShip
{
std::string schematicId;
double cost;
std::string schematicId;
double cost;
std::vector<PlacedModule> defaultModules;
};
std::vector<EligibleShip> eligible;
for (const ShipDef& def : m_config.ships.ships)
@@ -100,8 +101,9 @@ std::vector<WaveSystem::SpawnEntry> WaveSystem::composeWave(Tick currentTick,
if (cost > 0.0)
{
EligibleShip es;
es.schematicId = def.id;
es.cost = cost;
es.schematicId = def.id;
es.cost = cost;
es.defaultModules = def.defaultModules;
eligible.push_back(es);
}
}
@@ -151,11 +153,12 @@ std::vector<WaveSystem::SpawnEntry> WaveSystem::composeWave(Tick currentTick,
budget -= chosen.cost;
SpawnEntry entry;
entry.schematicId = chosen.schematicId;
entry.level = shipLevel;
entry.spawnAt = 0; // set below after all picks are done
entry.position = QVector2D(xDist(m_rng),
static_cast<float>(yDist(m_rng)) + 0.5f);
entry.schematicId = chosen.schematicId;
entry.level = shipLevel;
entry.spawnAt = 0; // set below after all picks are done
entry.position = QVector2D(xDist(m_rng),
static_cast<float>(yDist(m_rng)) + 0.5f);
entry.layout.placedModules = chosen.defaultModules;
picked.push_back(entry);
}

View File

@@ -7,6 +7,7 @@
#include <QVector2D>
#include "GameConfig.h"
#include "ShipLayout.h"
#include "Tick.h"
class ShipSystem;
@@ -40,10 +41,11 @@ public:
private:
struct SpawnEntry
{
std::string schematicId;
int level;
Tick spawnAt;
QVector2D position;
std::string schematicId;
int level;
Tick spawnAt;
QVector2D position;
ShipLayoutConfig layout;
};
// Compose the next wave from the current threat budget, returning timed