Files
kronic e32dadbc66 Slice Combat Depth (MC-3 + wiring + review fixes): Spitter aim-line + player-hit punch, rigged enemies, in-band gate (DR-041)
Completes the Combat Depth slice on top of the MC-2 server spine (56cf60cce):

MC-3 impact juice (client, observe-only):
- 7 FeelConfig fields + ResetDefaults; magnitude-scaled player-dealt-hit camera
  PunchFov on the enemy-Health-decrease edge (camera-only hit-stop, never timeScale).
- Spitter Kind==2 aim-LANE telegraph (BuildLaneMesh) — reads baked SpitterState
  client-side, falls back to a fixed length. True freeze + material flash deferred.

Content / wiring:
- SpitterProjectilePrefabAuthoring (the SpitterProjectilePrefab singleton).
- Both directors rebuilt to a 4-entry KIND-INDEXED roster [Grunt,Charger,Spitter,
  Swarmer] + mix/MaxAlive config + the SpitterProjectileConfig singleton in the subscene.
- Real rigged models: EnemySpitter (re-skinned Kaiju, ranged poker) + EnemySwarmerUndead
  (Undead-Werewolf, fast/low-HP); grunt/charger keep Werewolf/ChargerMuscle. EnemySpit =
  ownerless interpolated ghost (no Health, no collider).

Post-impl adversarial review fixes (wf_febdcfdb-665):
- [MED] in-band fire gate: the Spitter committed its telegraph from ANY range (fired while
  advancing from far). Now commits only when sInBand || sCornered (gives CorneredRange a
  real read site) — a Spitter out-of-band holds fire and repositions.
- [LOW] EnemyProjectileDamageSystem early-returns on !ServerTick.IsValid (sibling parity).
- [LOW] EnemyAuthoring bake-time guard: errors if a prefab composes both Charger+Spitter
  (would match zero AI passes -> never move).
- [LOW] tests: Spitter brain fires from Expedition (kills the Base==0 region false-green);
  a direct partition-exclusion test replaces the order-masked claim; added out-of-band +
  cornered negative tests.

388/388 EditMode green + two Play smokes (clean boot, fire, swept-hit, region, server==
client; rigged Kaiju spitter bakes + fires with zero console errors). Accepted as-is
(documented in DR-041): global spit soft-cap, co-op punch attribution.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 21:08:59 -07:00

138 lines
6.8 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)
{
var serverTick = SystemAPI.GetSingleton<NetworkTime>().ServerTick;
if (!serverTick.IsValid) return; // mirror WaveSystem/ZoneEnemyDirectorSystem — never stamp SourceTick off an invalid tick
uint now = 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();
}
}
}