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>
136 lines
6.6 KiB
C#
136 lines
6.6 KiB
C#
using ProjectM.Simulation;
|
|
using Unity.Burst;
|
|
using Unity.Collections;
|
|
using Unity.Entities;
|
|
using Unity.Mathematics;
|
|
using Unity.NetCode;
|
|
using Unity.Transforms;
|
|
|
|
namespace ProjectM.Server
|
|
{
|
|
/// <summary>
|
|
/// MC-2 — resolves hostile Spitter projectiles against PLAYERS + STRUCTURES (never other enemies — only
|
|
/// PlayerTag / PlacedStructure are snapshotted, so a spit can't friendly-fire the Husks), server-only in the
|
|
/// plain <see cref="SimulationSystemGroup"/> after <see cref="EnemyProjectileMoveSystem"/> (post-move position).
|
|
/// SWEPT planar hit-test (the DR-018 anti-tunnelling discipline): the travel segment is rebuilt from the STORED
|
|
/// <see cref="EnemyProjectile.LastStep"/> (cur - Direction*LastStep), NEVER a fresh delta. REGION-FILTERED: a
|
|
/// target whose <see cref="RegionTag"/>.Region != the spit's Region is skipped — relevancy hides cross-region
|
|
/// ghosts from CLIENTS, but the server world holds base + expedition players 1000u apart, so server damage needs
|
|
/// its own guard (the missing-filter blocker the design review caught). On a hit it appends
|
|
/// DamageEvent{SourceNetworkId=-1, SourceTick=now} (drained the FOLLOWING tick by the predicted
|
|
/// <c>HealthApplyDamageSystem</c> — appending from the predicted loop would double-apply on rollback; SourceTick
|
|
/// makes the dash i-frame negation correct across the 1-tick gap, so dash-through-spit works for free) and
|
|
/// destroys the spit at-most-once; a spit past its Range expires.
|
|
/// </summary>
|
|
[BurstCompile]
|
|
[WorldSystemFilter(WorldSystemFilterFlags.ServerSimulation)]
|
|
[UpdateInGroup(typeof(SimulationSystemGroup))]
|
|
[UpdateAfter(typeof(EnemyProjectileMoveSystem))]
|
|
public partial struct EnemyProjectileDamageSystem : ISystem
|
|
{
|
|
/// <summary>Extra forgiveness for the spit's own size, added to a target's hit radius.</summary>
|
|
const float k_ProjectileRadius = 0.2f;
|
|
|
|
/// <summary>Hit radius used for structures, which (by design) bake no HitRadius (so player shots never hit them).</summary>
|
|
const float k_StructureRadius = 1.0f;
|
|
|
|
[BurstCompile]
|
|
public void OnCreate(ref SystemState state)
|
|
{
|
|
state.RequireForUpdate<NetworkTime>();
|
|
state.RequireForUpdate<EnemyProjectile>();
|
|
}
|
|
|
|
[BurstCompile]
|
|
public void OnUpdate(ref SystemState state)
|
|
{
|
|
uint now = SystemAPI.GetSingleton<NetworkTime>().ServerTick.TickIndexForValidTick;
|
|
var ecb = new EntityCommandBuffer(Allocator.Temp);
|
|
|
|
// Snapshot valid targets once (stable query order). PLAYERS carry HitRadius (PlayerAuthoring);
|
|
// STRUCTURES deliberately do NOT (so player projectiles never friendly-fire the base) -> a constant.
|
|
var targetEntities = new NativeList<Entity>(Allocator.Temp);
|
|
var targetPositions = new NativeList<float3>(Allocator.Temp);
|
|
var targetRadii = new NativeList<float>(Allocator.Temp);
|
|
var targetRegions = new NativeList<byte>(Allocator.Temp);
|
|
|
|
foreach (var (xform, hitRadius, health, region, e) in
|
|
SystemAPI.Query<RefRO<LocalTransform>, RefRO<HitRadius>, RefRO<Health>, RefRO<RegionTag>>()
|
|
.WithAll<PlayerTag>().WithEntityAccess())
|
|
{
|
|
if (health.ValueRO.Current <= 0f) continue; // don't hit a corpse
|
|
targetEntities.Add(e);
|
|
targetPositions.Add(xform.ValueRO.Position);
|
|
targetRadii.Add(hitRadius.ValueRO.Value);
|
|
targetRegions.Add(region.ValueRO.Region);
|
|
}
|
|
foreach (var (xform, health, region, e) in
|
|
SystemAPI.Query<RefRO<LocalTransform>, RefRO<Health>, RefRO<RegionTag>>()
|
|
.WithAll<PlacedStructure>().WithEntityAccess())
|
|
{
|
|
if (health.ValueRO.Current <= 0f) continue; // skip a structure pending destroy this tick
|
|
targetEntities.Add(e);
|
|
targetPositions.Add(xform.ValueRO.Position);
|
|
targetRadii.Add(k_StructureRadius);
|
|
targetRegions.Add(region.ValueRO.Region);
|
|
}
|
|
|
|
var destroyed = new NativeHashSet<Entity>(16, Allocator.Temp);
|
|
foreach (var (xform, proj, projEntity) in
|
|
SystemAPI.Query<RefRO<LocalTransform>, RefRO<EnemyProjectile>>().WithEntityAccess())
|
|
{
|
|
float3 cur = xform.ValueRO.Position;
|
|
float2 segEnd = new float2(cur.x, cur.z);
|
|
float2 dir = proj.ValueRO.Direction;
|
|
float2 segStart = segEnd - dir * proj.ValueRO.LastStep; // stored move-step, never a fresh dt
|
|
float2 seg = segEnd - segStart;
|
|
float segLenSq = math.lengthsq(seg);
|
|
byte projRegion = proj.ValueRO.Region;
|
|
|
|
int bestIdx = -1;
|
|
float bestT = float.MaxValue;
|
|
for (int i = 0; i < targetEntities.Length; i++)
|
|
{
|
|
if (targetRegions[i] != projRegion) continue; // server-side damage region guard
|
|
float2 tp = new float2(targetPositions[i].x, targetPositions[i].z);
|
|
float t = segLenSq > 1e-8f
|
|
? math.saturate(math.dot(tp - segStart, seg) / segLenSq)
|
|
: 0f;
|
|
float2 closest = segStart + t * seg;
|
|
float hitDist = targetRadii[i] + k_ProjectileRadius;
|
|
if (math.distancesq(tp, closest) <= hitDist * hitDist && t < bestT)
|
|
{
|
|
bestT = t;
|
|
bestIdx = i;
|
|
}
|
|
}
|
|
|
|
if (bestIdx >= 0)
|
|
{
|
|
ecb.AppendToBuffer(targetEntities[bestIdx], new DamageEvent
|
|
{
|
|
Amount = proj.ValueRO.Damage,
|
|
SourceNetworkId = -1, // hostile environment, not a player
|
|
SourceTick = TickUtil.NonZero(now),
|
|
});
|
|
if (destroyed.Add(projEntity))
|
|
ecb.DestroyEntity(projEntity);
|
|
continue;
|
|
}
|
|
|
|
if (proj.ValueRO.DistanceTravelled >= proj.ValueRO.Range && destroyed.Add(projEntity))
|
|
ecb.DestroyEntity(projEntity);
|
|
}
|
|
|
|
ecb.Playback(state.EntityManager);
|
|
|
|
ecb.Dispose();
|
|
destroyed.Dispose();
|
|
targetEntities.Dispose();
|
|
targetPositions.Dispose();
|
|
targetRadii.Dispose();
|
|
targetRegions.Dispose();
|
|
}
|
|
}
|
|
}
|