e32dadbc66
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>
138 lines
6.8 KiB
C#
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();
|
|
}
|
|
}
|
|
}
|