56cf60cce3
Adds the server-authoritative mechanics for three new enemy archetypes on top of the Grunt/Charger base, plus the weighted wave-composition that introduces them: - Spitter: a ranged Husk variant (SpitterState) that holds a preferred range-band (advance/retreat/hold via EnemyAIMath.BandVelocity) and fires a telegraphed, dodgeable EnemyProjectile. New server EnemyProjectileMoveSystem (integrate + store LastStep) + EnemyProjectileDamageSystem (region-filtered swept hit-test rebuilt from LastStep — DR-018 anti-tunnelling; players use HitRadius, structures a const radius; at-most-once destroy). Concurrent-spit soft cap, soft-fail retry. - Swarmer: marker tag + deterministic cluster spawn (1 slot = 1 pack; EnemyAIMath.ClusterOffset), MaxAlive counts ENTITIES so a pack defers if it won't fit. - 4-type weighted mix: MixBands -> ZoneEnemyMath.WaveSlots/KindForSlot/ PackSizeForSlot drives both the expedition director and (fork-4a) the base siege, with a mandatory MaxAlive cap. Legacy WaveSize/IsChargerSlot kept + parity-tested. - Discriminator stays component-presence (no enum in Bursted systems): query- partition guards keep each enemy moved by exactly one EnemyAISystem pass (sole-Position-writer). EnemyTelegraph.IsCharger -> Kind byte for the client cue. New authoring (Spitter/Swarmer/EnemyProjectile) + expanded director authorings with tunable mix/cluster defaults. 13 new EditMode tests (mix composition + legacy parity, band/cluster math, projectile move + cross-region + swept anti-tunnelling regressions); full suite green before commit. Dormant until the prefab/subscene wiring lands (next): the new systems guard on TryGetSingleton/RequireForUpdate, so with no prefabs wired the new types stay inert. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
168 lines
8.6 KiB
C#
168 lines
8.6 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 <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; }
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Region-scoped variant of <see cref="PickWeightedNearest(float3,NativeList{float3},NativeList{float3},float,out bool,out int)"/>:
|
|
/// considers ONLY targets whose stored region byte equals <paramref name="region"/>, so a Husk seeks within
|
|
/// its own region only (an expedition Husk never paths across the 1000-unit gap to a base player/structure,
|
|
/// and a base Husk never to an expedition target). <paramref name="playerRegions"/> /
|
|
/// <paramref name="structureRegions"/> are parallel to the position lists; the returned <paramref name="index"/>
|
|
/// maps to the FULL list so the caller's by-index entity lookup stays valid. Pure, Burst-safe (byte compares).
|
|
/// </summary>
|
|
public static void PickWeightedNearest(float3 from, NativeList<float3> playerPositions,
|
|
NativeList<byte> playerRegions, NativeList<float3> structurePositions, NativeList<byte> structureRegions,
|
|
byte region, float structureWeight, out bool isStructure, out int index)
|
|
{
|
|
isStructure = false;
|
|
index = -1;
|
|
float bestSq = float.MaxValue;
|
|
for (int i = 0; i < playerPositions.Length; i++)
|
|
{
|
|
if (playerRegions[i] != region) continue;
|
|
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++)
|
|
{
|
|
if (structureRegions[i] != region) continue;
|
|
float sq = math.lengthsq(structurePositions[i].xz - from.xz) * wsq;
|
|
if (sq < bestSq) { bestSq = sq; index = i; isStructure = true; }
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// MC-2 Spitter range-band velocity (planar XZ): ADVANCE toward <paramref name="to"/> at
|
|
/// <paramref name="speed"/> when farther than <paramref name="preferred"/> + <paramref name="tolerance"/>,
|
|
/// RETREAT directly away when closer than <paramref name="preferred"/> - <paramref name="tolerance"/>, and
|
|
/// HOLD (zero) inside the dead-zone band. Y forced to 0. Returns zero when the points coincide. Pure /
|
|
/// Burst-safe / EditMode-testable; keeps the Spitter at its firing distance instead of closing to melee.
|
|
/// </summary>
|
|
public static float3 BandVelocity(float3 from, float3 to, float speed, float preferred, float tolerance)
|
|
{
|
|
float3 d = to - from;
|
|
d.y = 0f;
|
|
float distSq = math.lengthsq(d);
|
|
if (distSq < 1e-8f)
|
|
return float3.zero;
|
|
float dist = math.sqrt(distSq);
|
|
float3 dir = d / dist;
|
|
float tol = math.max(0f, tolerance);
|
|
if (dist > preferred + tol)
|
|
return dir * speed; // too far -> close in
|
|
if (dist < preferred - tol)
|
|
return -dir * speed; // too close -> back off
|
|
return float3.zero; // in band -> hold and fire
|
|
}
|
|
|
|
/// <summary>
|
|
/// Deterministic tight-cluster offset for swarmer <paramref name="index"/> of a pack of
|
|
/// <paramref name="packSize"/> around <paramref name="center"/> at <paramref name="tightRadius"/> (reuses the
|
|
/// <see cref="RingPosition"/> even-angle math at a small radius). A single swarmer (packSize<=1) spawns at
|
|
/// the centre. Stable per index so a replayed pack lands identically. Pure.
|
|
/// </summary>
|
|
public static float3 ClusterOffset(float3 center, int index, int packSize, float tightRadius)
|
|
{
|
|
if (packSize <= 1)
|
|
return center;
|
|
return RingPosition(center, index, packSize, tightRadius);
|
|
}
|
|
}
|
|
}
|