Files
Project-M/Assets/_Project/Scripts/Simulation/Combat/EnemyAIMath.cs
T
kronic 73cfe2943d EB-1: machines can die - structures get HP, Husks raze them, wounded base persists
Structures (Turret/Wall/Pylon) reuse the combat spine: authoring bakes Health(GhostField)+DamageEvent buffer+a Destructible tag (no HitRadius -> no friendly projectile fire; no EffectiveCharacterStats -> clamp-to-0). HealthApplyDamageSystem destroys a Destructible at 0 (occupancy auto-frees). EnemyAISystem fortress-targets the weighted-nearest of players+structures via the shared EnemyAIMath.PickWeightedNearest (StructureAggroWeight TuningConfig knob, <1 prefers structures, squared factor; snapshot above the early-return so an undefended base is razed). Persistence v3: per-structure HP threaded through 5 sites (SaveData/PendingStructure/scan-guarded/BaseRestore same-ECB born-correct/WorldLauncher via SaveApply.ToPending); SaveService floor-gate [2,3] loads old saves. Loss feedback: proximity-gated StructureFeedbackSystem; CombatFeedbackSystem suppressed for structures. Pre-code review caught the DamageEvent-buffer crash blocker + 8 majors; post-code review clean. See DR-032.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-11 23:53:34 -07:00

100 lines
4.7 KiB
C#

using Unity.Collections;
using Unity.Mathematics;
namespace ProjectM.Simulation
{
/// <summary>
/// Pure, deterministic Husk AI math — no RNG, no wall-clock — so server simulation stays reproducible and
/// the helpers are EditMode-unit-testable without an ECS world (mirrors <see cref="PlayerSpawnMath"/> /
/// <c>StatMath</c>).
/// </summary>
public static class EnemyAIMath
{
/// <summary>
/// Planar (XZ) seek velocity from <paramref name="from"/> toward <paramref name="to"/> at
/// <paramref name="speed"/>. Y is forced to 0 (top-down plane). Returns zero once within
/// <paramref name="stopDistance"/> (so the Husk halts at strike range instead of jittering through the
/// target) or when the two points coincide.
/// </summary>
public static float3 SeekVelocity(float3 from, float3 to, float speed, float stopDistance)
{
float3 d = to - from;
d.y = 0f;
float distSq = math.lengthsq(d);
if (distSq <= stopDistance * stopDistance || distSq < 1e-8f)
return float3.zero;
return math.normalize(d) * speed;
}
/// <summary>
/// True when <paramref name="to"/> is within <paramref name="range"/> of <paramref name="from"/> on the
/// XZ plane.
/// </summary>
public static bool InAttackRange(float3 from, float3 to, float range)
{
float3 d = to - from;
d.y = 0f;
return math.lengthsq(d) <= range * range;
}
/// <summary>
/// Projects a planar movement <paramref name="vel"/> onto a wall plane defined by <paramref name="surfaceNormal"/>
/// (collide-and-slide): removes the component of <paramref name="vel"/> that pushes into the surface so the
/// mover glances along the wall instead of stopping dead. Both inputs are flattened to the XZ plane (top-down).
/// Returns <paramref name="vel"/> unchanged when the normal is degenerate.
/// </summary>
public static float3 SlideVelocity(float3 vel, float3 surfaceNormal)
{
surfaceNormal.y = 0f;
float len = math.length(surfaceNormal);
if (len < 1e-6f)
return vel;
float3 n = surfaceNormal / len;
float3 slid = vel - math.dot(vel, n) * n;
slid.y = 0f;
return slid;
}
/// <summary>
/// Deterministic planar ring position around <paramref name="center"/> for spawn
/// <paramref name="index"/>: evenly spaced over <paramref name="slots"/> angles at
/// <paramref name="radius"/>. Stable per index so a replayed spawn lands identically.
/// </summary>
public static float3 RingPosition(float3 center, int index, int slots, float radius)
{
if (slots < 1)
slots = 1;
int slot = ((index % slots) + slots) % slots;
float angle = (2f * math.PI * slot) / slots;
math.sincos(angle, out float s, out float c);
return center + new float3(c * radius, 0f, s * radius);
}
/// <summary>
/// EB-1 fortress aggro: pick a Husk's target as the weighted-nearest of the living players (weight 1) and
/// the live structures (a SQUARED <paramref name="structureWeight"/> applied to structure distance, so &lt;1
/// makes structures preferred while a sufficiently-closer player 'in the way' still wins). Planar XZ,
/// deterministic (no RNG/wall-clock). Sets <paramref name="index"/> = -1 when there are no targets. Pure so
/// both the Grunt and Charger passes select IDENTICALLY and it is EditMode-unit-testable.
/// </summary>
public static void PickWeightedNearest(float3 from, NativeList<float3> playerPositions,
NativeList<float3> structurePositions, float structureWeight, out bool isStructure, out int index)
{
isStructure = false;
index = -1;
float bestSq = float.MaxValue;
for (int i = 0; i < playerPositions.Length; i++)
{
float sq = math.lengthsq(playerPositions[i].xz - from.xz);
if (sq < bestSq) { bestSq = sq; index = i; isStructure = false; }
}
float w = math.max(0f, structureWeight);
float wsq = w * w; // applied to SQUARED distance so the weight scales true distance
for (int i = 0; i < structurePositions.Length; i++)
{
float sq = math.lengthsq(structurePositions[i].xz - from.xz) * wsq;
if (sq < bestSq) { bestSq = sq; index = i; isStructure = true; }
}
}
}
}