make ships claim targets

This commit is contained in:
2026-06-16 21:08:36 +02:00
parent 4153b7e2f5
commit ac97652c60
14 changed files with 268 additions and 31 deletions

View File

@@ -297,6 +297,10 @@ WorldConfig ConfigLoader::loadWorld(const std::string& path)
throw makeError(file, "waves", "gap_min_seconds > gap_max_seconds");
}
cfg.targeting.targetScoreFormula = requireFormula(tbl["targeting"]["target_score_formula"], file, "targeting.target_score_formula");
cfg.targeting.overclaimPenaltyFormula = requireFormula(tbl["targeting"]["overclaim_penalty_formula"], file, "targeting.overclaim_penalty_formula");
cfg.targeting.hysteresis = requireDouble(tbl["targeting"]["target_hysteresis"], file, "targeting.target_hysteresis");
return cfg;
}

View File

@@ -4,6 +4,14 @@
#include "tinyexpr.h"
namespace
{
// tinyexpr has no built-in min/max; expose them so config formulas can
// clamp (e.g. a floored overclaim penalty "max(0.5, 1 - 0.1*x)").
double formulaMin(double a, double b) { return a < b ? a : b; }
double formulaMax(double a, double b) { return a > b ? a : b; }
}
Formula::Formula(Formula&& other) noexcept
: m_source(std::move(other.m_source))
, m_x(std::move(other.m_x))
@@ -37,11 +45,14 @@ Formula Formula::compile(const std::string& source)
result.m_x = std::make_unique<double>(0.0);
const te_variable variables[] = {
{ "x", result.m_x.get(), 0, nullptr },
{ "x", result.m_x.get(), TE_VARIABLE, nullptr },
{ "min", reinterpret_cast<const void*>(&formulaMin), TE_FUNCTION2 | TE_FLAG_PURE, nullptr },
{ "max", reinterpret_cast<const void*>(&formulaMax), TE_FUNCTION2 | TE_FLAG_PURE, nullptr },
};
int errorPos = 0;
result.m_expr = te_compile(result.m_source.c_str(), variables, 1, &errorPos);
const int variableCount = static_cast<int>(sizeof(variables) / sizeof(variables[0]));
result.m_expr = te_compile(result.m_source.c_str(), variables, variableCount, &errorPos);
if (result.m_expr == nullptr)
{
@@ -66,3 +77,4 @@ double Formula::evaluate(double x) const
*m_x = x;
return te_eval(m_expr);
}

View File

@@ -39,6 +39,14 @@ struct WorldWaves
double bossQuietAfterSeconds; // suppress normal waves this long after boss (REQ-WAV-QUIET)
};
// Ship target selection (claim-aware scoring).
struct WorldTargeting
{
Formula targetScoreFormula; // x = distance / max weapon range; higher = better
Formula overclaimPenaltyFormula; // x = competing claim count; factor in [0,1]
double hysteresis; // fractional margin a challenger must beat the current target by
};
struct WorldConfig
{
int heightTiles; // REQ-GW-HEIGHT
@@ -56,4 +64,5 @@ struct WorldConfig
WorldExpansion expansion;
WorldPush push;
WorldWaves waves;
WorldTargeting targeting;
};

View File

@@ -2,6 +2,8 @@
#include <limits>
#include "GameConfig.h"
#include "AdvanceBehavior.h"
#include "AttackBehavior.h"
#include "BehaviorKind.h"
@@ -34,6 +36,11 @@ namespace
}
}
AiSystem::AiSystem(const GameConfig& config)
: m_attackEvaluator(config.world.targeting)
{
}
void AiSystem::tick(EntityAdmin& admin, const BuildingSystem& buildings,
const ScrapSystem& scraps)
{

View File

@@ -18,6 +18,7 @@
class BuildingSystem;
class EntityAdmin;
class ScrapSystem;
struct GameConfig;
// Orchestrates ship-behavior decision-making in three batched phases:
// 1. evaluators score each behavior and set its target data,
@@ -29,6 +30,8 @@ class ScrapSystem;
class AiSystem
{
public:
explicit AiSystem(const GameConfig& config);
void tick(EntityAdmin& admin, const BuildingSystem& buildings, const ScrapSystem& scraps);
private:

View File

@@ -1,5 +1,7 @@
#include "AttackEvaluator.h"
#include <algorithm>
#include <unordered_map>
#include <vector>
#include <QVector2D>
@@ -10,57 +12,131 @@
#include "EntityAdmin.h"
#include "FactionComponent.h"
#include "HealthComponent.h"
#include "ModuleOwnerComponent.h"
#include "PositionComponent.h"
#include "SensorRangeComponent.h"
#include "tracing.h"
#include "WeaponComponent.h"
#include "WorldConfig.h"
AttackEvaluator::AttackEvaluator(const WorldTargeting& targeting)
: m_targeting(&targeting)
{
}
void AttackEvaluator::evaluate(EntityAdmin& admin)
{
TRACE();
const std::vector<CombatantInfo> combatants = buildCombatants(admin);
// Pass A: the maximum weapon range per ship, used to normalise target
// distance. Ships without a weapon fall back to their sensor range below.
std::unordered_map<entt::entity, float> maxWeaponRange_tiles;
admin.forEach<WeaponComponent, ModuleOwnerComponent>(
[&maxWeaponRange_tiles](entt::entity /*we*/, const WeaponComponent& weapon,
const ModuleOwnerComponent& owner)
{
float& best = maxWeaponRange_tiles[owner.owner];
best = std::max(best, weapon.range_tiles);
});
// Pass B: claim counts, taken from every ship's current target before any
// target is reassigned this tick. Each ship reads the previous tick's claim
// state and excludes its own contribution when scoring its current target.
std::unordered_map<entt::entity, int> claimsByTarget;
admin.forEach<AttackBehavior>(
[&claimsByTarget, &admin](entt::entity /*e*/, const AttackBehavior& attack)
{
if (attack.currentTarget && admin.isValid(*attack.currentTarget))
{
++claimsByTarget[*attack.currentTarget];
}
});
// Pass C: per-ship target selection.
admin.forEach<AttackBehavior, PositionComponent, FactionComponent,
SensorRangeComponent, HealthComponent>(
[&](entt::entity e, AttackBehavior& attack, const PositionComponent& pos,
const FactionComponent& faction, const SensorRangeComponent& sensor,
const HealthComponent& health)
{
const float range = sensor.value_tiles;
const float sensorRange_tiles = sensor.value_tiles;
// Validate current target: still valid, still in range.
bool targetValid = false;
// Distance normaliser: max weapon range, or sensor range if unarmed.
float weaponRange_tiles = sensorRange_tiles;
const auto weaponRangeIt = maxWeaponRange_tiles.find(e);
if (weaponRangeIt != maxWeaponRange_tiles.end() && weaponRangeIt->second > 0.0f)
{
weaponRange_tiles = weaponRangeIt->second;
}
// Scores a single candidate: base desirability from distance, reduced
// by the overclaim penalty. selfClaimed subtracts this ship's own claim
// so it does not penalise the target it already holds.
const auto scoreOf =
[&](const QVector2D& candidatePos, entt::entity candidate) -> float
{
const float dist = (candidatePos - pos.value).length();
const float x = dist / weaponRange_tiles;
float base = static_cast<float>(m_targeting->targetScoreFormula.evaluate(x));
base = std::max(base, 0.0f);
int claims = 0;
const auto claimIt = claimsByTarget.find(candidate);
if (claimIt != claimsByTarget.end()) { claims = claimIt->second; }
if (attack.currentTarget && candidate == *attack.currentTarget) { --claims; }
float penalty = static_cast<float>(
m_targeting->overclaimPenaltyFormula.evaluate(claims));
penalty = std::clamp(penalty, 0.0f, 1.0f);
return base * penalty;
};
// Find the best candidate among in-range enemies.
std::optional<entt::entity> bestTarget;
float bestScore = 0.0f;
for (const CombatantInfo& c : combatants)
{
if (c.entity == e) { continue; }
const bool isValidTarget = faction.isEnemy ? !c.isEnemy : c.isEnemy;
if (!isValidTarget) { continue; }
const float dist = (c.position - pos.value).length();
if (dist > sensorRange_tiles) { continue; }
const float score = scoreOf(c.position, c.entity);
if (!bestTarget || score > bestScore)
{
bestScore = score;
bestTarget = c.entity;
}
}
// Hysteresis: keep the current target if it is still valid and in
// range, unless a challenger beats its score by more than the margin.
bool keptCurrent = false;
if (attack.currentTarget)
{
const entt::entity t = *attack.currentTarget;
if (admin.isValid(t) && admin.hasAll<PositionComponent>(t))
{
const float dist =
(admin.get<PositionComponent>(t).value - pos.value).length();
if (dist <= range) { targetValid = true; }
}
}
// Acquire nearest valid target if needed.
if (!targetValid)
{
attack.currentTarget = std::nullopt;
float bestDist = range;
for (const CombatantInfo& c : combatants)
{
if (c.entity == e) { continue; }
const bool isValidTarget =
faction.isEnemy ? !c.isEnemy : c.isEnemy;
if (!isValidTarget) { continue; }
const float dist = (c.position - pos.value).length();
if (dist < bestDist)
const QVector2D targetPos = admin.get<PositionComponent>(t).value;
const float dist = (targetPos - pos.value).length();
if (dist <= sensorRange_tiles)
{
bestDist = dist;
attack.currentTarget = c.entity;
const float currentScore = scoreOf(targetPos, t);
const float margin = 1.0f + static_cast<float>(m_targeting->hysteresis);
if (!bestTarget || bestScore <= currentScore * margin)
{
keptCurrent = true;
}
}
}
}
if (!keptCurrent) { attack.currentTarget = bestTarget; }
const bool healthy =
(health.maxHp > 0.0f)
&& (health.hp / health.maxHp >= BehaviorScores::kLowHpFraction);

View File

@@ -1,11 +1,22 @@
#pragma once
class EntityAdmin;
struct WorldTargeting;
// Acquires/validates a combat target for ships with weapons. Scores high only
// when the ship's health is not low and a valid target is within sensor range.
//
// Target choice is claim-aware: each tick the desirability of every candidate is
// scored from a configurable distance formula and reduced by a soft overclaim
// penalty that scales with how many other ships already target it, spreading
// ships across enemies instead of dogpiling the nearest one.
class AttackEvaluator
{
public:
explicit AttackEvaluator(const WorldTargeting& targeting);
void evaluate(EntityAdmin& admin);
private:
const WorldTargeting* m_targeting;
};

View File

@@ -66,7 +66,7 @@ Simulation::Simulation(GameConfig config, unsigned int seed)
[this](const std::string& itemId) -> bool { return isItemUnlocked(itemId); },
m_rng);
m_shipSystem = std::make_unique<ShipSystem>(m_config, m_admin);
m_aiSystem = std::make_unique<AiSystem>();
m_aiSystem = std::make_unique<AiSystem>(m_config);
m_movementIntentSystem = std::make_unique<MovementIntentSystem>();
m_dynamicBodySystem = std::make_unique<DynamicBodySystem>();
m_scrapSystem = std::make_unique<ScrapSystem>(m_admin);
@@ -169,7 +169,7 @@ void Simulation::reset(unsigned int seed)
[this](const std::string& itemId) -> bool { return isItemUnlocked(itemId); },
m_rng);
m_shipSystem = std::make_unique<ShipSystem>(m_config, m_admin);
m_aiSystem = std::make_unique<AiSystem>();
m_aiSystem = std::make_unique<AiSystem>(m_config);
m_movementIntentSystem = std::make_unique<MovementIntentSystem>();
m_dynamicBodySystem = std::make_unique<DynamicBodySystem>();
m_scrapSystem = std::make_unique<ScrapSystem>(m_admin);