using Unity.Collections; using Unity.Mathematics; namespace ProjectM.Simulation { /// /// 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 / /// StatMath). /// public static class EnemyAIMath { /// /// Planar (XZ) seek velocity from toward at /// . Y is forced to 0 (top-down plane). Returns zero once within /// (so the Husk halts at strike range instead of jittering through the /// target) or when the two points coincide. /// 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; } /// /// True when is within of on the /// XZ plane. /// public static bool InAttackRange(float3 from, float3 to, float range) { float3 d = to - from; d.y = 0f; return math.lengthsq(d) <= range * range; } /// /// Projects a planar movement onto a wall plane defined by /// (collide-and-slide): removes the component of 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 unchanged when the normal is degenerate. /// 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; } /// /// Deterministic planar ring position around for spawn /// : evenly spaced over angles at /// . Stable per index so a replayed spawn lands identically. /// 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); } /// /// 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 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 = -1 when there are no targets. Pure so /// both the Grunt and Charger passes select IDENTICALLY and it is EditMode-unit-testable. /// public static void PickWeightedNearest(float3 from, NativeList playerPositions, NativeList 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; } } } /// /// Region-scoped variant of : /// considers ONLY targets whose stored region byte equals , 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). / /// are parallel to the position lists; the returned /// maps to the FULL list so the caller's by-index entity lookup stays valid. Pure, Burst-safe (byte compares). /// public static void PickWeightedNearest(float3 from, NativeList playerPositions, NativeList playerRegions, NativeList structurePositions, NativeList 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; } } } /// /// MC-2 Spitter range-band velocity (planar XZ): ADVANCE toward at /// when farther than + , /// RETREAT directly away when closer than - , 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. /// 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 } /// /// Deterministic tight-cluster offset for swarmer of a pack of /// around at (reuses the /// 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. /// public static float3 ClusterOffset(float3 center, int index, int packSize, float tightRadius) { if (packSize <= 1) return center; return RingPosition(center, index, packSize, tightRadius); } } }