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

@@ -3,6 +3,7 @@
#include <cmath>
#include <string>
#include <QPoint>
#include <QVector2D>
#include "BuildingId.h"
@@ -10,11 +11,14 @@
#include "DynamicBodyComponent.h"
#include "EntityAdmin.h"
#include "HealthComponent.h"
#include "ModuleOwnerComponent.h"
#include "RepairBehaviorComponent.h"
#include "RepairToolComponent.h"
#include "Rotation.h"
#include "SalvageBehaviorComponent.h"
#include "SalvageCargoComponent.h"
#include "SensorRangeComponent.h"
#include "ShipLayout.h"
#include "ShipSystem.h"
#include "Tick.h"
#include "ThreatResponseBehaviorComponent.h"
@@ -26,10 +30,58 @@ static GameConfig loadConfig()
}
// ---------------------------------------------------------------------------
// Combat ship
// Helpers
// ---------------------------------------------------------------------------
TEST_CASE("ShipSystem: interceptor spawn has weapon and threatResponse, no cargo or repair",
static entt::entity firstWeaponChild(EntityAdmin& admin, entt::entity ship)
{
entt::entity result = entt::null;
admin.forEach<WeaponComponent, ModuleOwnerComponent>(
[&](entt::entity ce, const WeaponComponent&, const ModuleOwnerComponent& o)
{
if (o.owner == ship && result == entt::null) { result = ce; }
});
return result;
}
static entt::entity firstSalvageChild(EntityAdmin& admin, entt::entity ship)
{
entt::entity result = entt::null;
admin.forEach<SalvageCargoComponent, ModuleOwnerComponent>(
[&](entt::entity ce, const SalvageCargoComponent&, const ModuleOwnerComponent& o)
{
if (o.owner == ship && result == entt::null) { result = ce; }
});
return result;
}
static entt::entity firstRepairChild(EntityAdmin& admin, entt::entity ship)
{
entt::entity result = entt::null;
admin.forEach<RepairToolComponent, ModuleOwnerComponent>(
[&](entt::entity ce, const RepairToolComponent&, const ModuleOwnerComponent& o)
{
if (o.owner == ship && result == entt::null) { result = ce; }
});
return result;
}
static ShipLayoutConfig makeSingleModuleLayout(const std::string& moduleId)
{
PlacedModule pm;
pm.moduleId = moduleId;
pm.position = QPoint(0, 0);
pm.rotation = Rotation::East;
ShipLayoutConfig layout;
layout.placedModules.push_back(pm);
return layout;
}
// ---------------------------------------------------------------------------
// Combat ship (interceptor has default_modules = [laser_cannon])
// ---------------------------------------------------------------------------
TEST_CASE("ShipSystem: interceptor spawn has weapon child and threatResponse, no cargo or repair",
"[ship]")
{
EntityAdmin admin;
@@ -39,10 +91,10 @@ TEST_CASE("ShipSystem: interceptor spawn has weapon and threatResponse, no cargo
const entt::entity e = ss.spawn("interceptor", 1, QVector2D(0.0f, 0.0f));
REQUIRE(admin.isValid(e));
REQUIRE(admin.hasAll<WeaponComponent>(e));
REQUIRE(admin.isValid(firstWeaponChild(admin, e)));
REQUIRE(admin.hasAll<ThreatResponseBehaviorComponent>(e));
REQUIRE_FALSE(admin.hasAll<SalvageCargoComponent>(e));
REQUIRE_FALSE(admin.hasAll<RepairToolComponent>(e));
REQUIRE_FALSE(admin.isValid(firstSalvageChild(admin, e)));
REQUIRE_FALSE(admin.isValid(firstRepairChild(admin, e)));
REQUIRE_FALSE(admin.hasAll<RepairBehaviorComponent>(e));
REQUIRE_FALSE(admin.hasAll<SalvageBehaviorComponent>(e));
}
@@ -58,14 +110,15 @@ TEST_CASE("ShipSystem: interceptor level 1 stats match config formulas", "[ship]
// hp_formula = "40 + 5*x" at x=1 → 45
REQUIRE(admin.get<HealthComponent>(e).maxHp == Approx(45.0f));
REQUIRE(admin.get<HealthComponent>(e).hp == Approx(45.0f));
// damage_formula = "10 + 2*x" at x=1 → 12
REQUIRE(admin.get<WeaponComponent>(e).damage == Approx(12.0f));
// attack_range_formula = "150"
REQUIRE(admin.get<WeaponComponent>(e).range == Approx(150.0f));
// sensor_range_formula = "200"
REQUIRE(admin.get<SensorRangeComponent>(e).value == Approx(200.0f));
// cooldownTicks starts at 0
REQUIRE(admin.get<WeaponComponent>(e).cooldownTicks == Approx(0.0f));
// laser_cannon: damage_formula = "2", attack_range_formula = "5"
const entt::entity wc = firstWeaponChild(admin, e);
REQUIRE(admin.isValid(wc));
REQUIRE(admin.get<WeaponComponent>(wc).damage == Approx(2.0f));
REQUIRE(admin.get<WeaponComponent>(wc).range == Approx(5.0f));
REQUIRE(admin.get<WeaponComponent>(wc).cooldownTicks == Approx(0.0f));
}
TEST_CASE("ShipSystem: interceptor level 5 hp matches formula", "[ship]")
@@ -94,22 +147,23 @@ TEST_CASE("ShipSystem: interceptor level 0 maxSpeedPerTick matches formula / kTi
}
// ---------------------------------------------------------------------------
// Salvage ship
// Salvage ship (spawned with salvage_bay_module layout)
// ---------------------------------------------------------------------------
TEST_CASE("ShipSystem: salvage_ship spawn has cargo and scrapCollector, no weapon",
TEST_CASE("ShipSystem: salvage_ship spawn with salvage module has cargo child and behavior, no weapon",
"[ship]")
{
EntityAdmin admin;
const GameConfig cfg = loadConfig();
ShipSystem ss(cfg, admin);
const entt::entity e = ss.spawn("salvage_ship", 1, QVector2D(0.0f, 0.0f));
const ShipLayoutConfig layout = makeSingleModuleLayout("salvage_bay_module");
const entt::entity e = ss.spawn("salvage_ship", 1, QVector2D(0.0f, 0.0f), false, layout);
REQUIRE(admin.hasAll<SalvageCargoComponent>(e));
REQUIRE(admin.isValid(firstSalvageChild(admin, e)));
REQUIRE(admin.hasAll<SalvageBehaviorComponent>(e));
REQUIRE_FALSE(admin.hasAll<WeaponComponent>(e));
REQUIRE_FALSE(admin.hasAll<RepairToolComponent>(e));
REQUIRE_FALSE(admin.isValid(firstWeaponChild(admin, e)));
REQUIRE_FALSE(admin.isValid(firstRepairChild(admin, e)));
}
TEST_CASE("ShipSystem: salvage_ship cargo capacity matches config", "[ship]")
@@ -118,32 +172,37 @@ TEST_CASE("ShipSystem: salvage_ship cargo capacity matches config", "[ship]")
const GameConfig cfg = loadConfig();
ShipSystem ss(cfg, admin);
const entt::entity e = ss.spawn("salvage_ship", 1, QVector2D(0.0f, 0.0f));
const ShipLayoutConfig layout = makeSingleModuleLayout("salvage_bay_module");
const entt::entity e = ss.spawn("salvage_ship", 1, QVector2D(0.0f, 0.0f), false, layout);
// cargo_capacity = 10
REQUIRE(admin.get<SalvageCargoComponent>(e).capacity == 10);
REQUIRE(admin.get<SalvageCargoComponent>(e).current == 0);
// salvage_bay_module: cargo_capacity_formula = "10", collection_range_formula = "50"
const entt::entity sc = firstSalvageChild(admin, e);
REQUIRE(admin.isValid(sc));
REQUIRE(admin.get<SalvageCargoComponent>(sc).capacity == 10);
REQUIRE(admin.get<SalvageCargoComponent>(sc).current == 0);
REQUIRE(admin.get<SalvageBehaviorComponent>(e).deliveryBay == kInvalidBuildingId);
REQUIRE_FALSE(admin.get<SalvageBehaviorComponent>(e).scrapTarget.has_value());
REQUIRE(admin.get<SalvageBehaviorComponent>(e).maxCollectionRange == Approx(50.0f));
}
// ---------------------------------------------------------------------------
// Repair ship
// Repair ship (spawned with repair_tool_module layout)
// ---------------------------------------------------------------------------
TEST_CASE("ShipSystem: repair_ship spawn has repairTool and repairBehavior, no weapon",
TEST_CASE("ShipSystem: repair_ship spawn with repair module has repair child and behavior, no weapon",
"[ship]")
{
EntityAdmin admin;
const GameConfig cfg = loadConfig();
ShipSystem ss(cfg, admin);
const entt::entity e = ss.spawn("repair_ship", 1, QVector2D(0.0f, 0.0f));
const ShipLayoutConfig layout = makeSingleModuleLayout("repair_tool_module");
const entt::entity e = ss.spawn("repair_ship", 1, QVector2D(0.0f, 0.0f), false, layout);
REQUIRE(admin.hasAll<RepairToolComponent>(e));
REQUIRE(admin.isValid(firstRepairChild(admin, e)));
REQUIRE(admin.hasAll<RepairBehaviorComponent>(e));
REQUIRE_FALSE(admin.hasAll<WeaponComponent>(e));
REQUIRE_FALSE(admin.hasAll<SalvageCargoComponent>(e));
REQUIRE_FALSE(admin.isValid(firstWeaponChild(admin, e)));
REQUIRE_FALSE(admin.isValid(firstSalvageChild(admin, e)));
}
TEST_CASE("ShipSystem: repair_ship level 1 repair stats match config formulas", "[ship]")
@@ -152,12 +211,17 @@ TEST_CASE("ShipSystem: repair_ship level 1 repair stats match config formulas",
const GameConfig cfg = loadConfig();
ShipSystem ss(cfg, admin);
const entt::entity e = ss.spawn("repair_ship", 1, QVector2D(0.0f, 0.0f));
const ShipLayoutConfig layout = makeSingleModuleLayout("repair_tool_module");
const entt::entity e = ss.spawn("repair_ship", 1, QVector2D(0.0f, 0.0f), false, layout);
// repair_rate_formula = "5 + x" at x=1 → 6
REQUIRE(admin.get<RepairToolComponent>(e).ratePerTick == Approx(6.0f));
// repair_tool_module: repair_rate_formula = "5 + x" at x=1 → 6 / kTickRateHz
const float expectedRate = 6.0f / static_cast<float>(kTickRateHz);
const entt::entity rc = firstRepairChild(admin, e);
REQUIRE(admin.isValid(rc));
REQUIRE(admin.get<RepairToolComponent>(rc).ratePerTick == Approx(expectedRate));
// repair_range_formula = "80"
REQUIRE(admin.get<RepairToolComponent>(e).range == Approx(80.0f));
REQUIRE(admin.get<RepairToolComponent>(rc).range == Approx(80.0f));
REQUIRE(admin.get<RepairBehaviorComponent>(e).maxRepairRange == Approx(80.0f));
}
// ---------------------------------------------------------------------------
@@ -171,22 +235,26 @@ TEST_CASE("ShipSystem: spawned ships are valid entities", "[ship]")
ShipSystem ss(cfg, admin);
const entt::entity e1 = ss.spawn("interceptor", 1, QVector2D(0.0f, 0.0f));
const entt::entity e2 = ss.spawn("salvage_ship", 1, QVector2D(1.0f, 0.0f));
const ShipLayoutConfig salvageLayout = makeSingleModuleLayout("salvage_bay_module");
const entt::entity e2 = ss.spawn("salvage_ship", 1, QVector2D(1.0f, 0.0f), false, salvageLayout);
REQUIRE(admin.isValid(e1));
REQUIRE(admin.isValid(e2));
REQUIRE(e1 != e2);
}
TEST_CASE("ShipSystem: despawn removes the ship", "[ship]")
TEST_CASE("ShipSystem: despawn removes the ship and its weapon children", "[ship]")
{
EntityAdmin admin;
const GameConfig cfg = loadConfig();
ShipSystem ss(cfg, admin);
const entt::entity e = ss.spawn("interceptor", 1, QVector2D(0.0f, 0.0f));
const entt::entity e = ss.spawn("interceptor", 1, QVector2D(0.0f, 0.0f));
const entt::entity wc = firstWeaponChild(admin, e);
REQUIRE(admin.isValid(e));
REQUIRE(admin.isValid(wc));
ss.despawn(e);
REQUIRE_FALSE(admin.isValid(e));
REQUIRE_FALSE(admin.isValid(wc));
}