140 lines
6.4 KiB
C#
140 lines
6.4 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>
|
|
/// Phase 1.7 "Blade Dash" boon (<see cref="BoonFlag.DashTrail"/>): while a player is inside its dash blink window,
|
|
/// living enemies within <see cref="k_Radius"/> of the player take damage — one hit per enemy per dash. SERVER-ONLY
|
|
/// (enemies are interpolated ghosts the client never predicts — mirrors the melee cleave / cone / projectile-damage
|
|
/// pattern), inside the predicted group after <see cref="DashSystem"/> (dash state committed) and before
|
|
/// <c>HealthApplyDamageSystem</c> (the DamageEvent drains the same tick). Enemies carry no <c>DashState</c>, so the
|
|
/// dash-i-frame negation branch in HealthApplyDamageSystem is skipped — harmless.
|
|
///
|
|
/// Dedup is keyed to <see cref="DashState.StartTick"/> (which is <c>TickUtil.NonZero(now)</c> on every dash and has
|
|
/// NO reliable clear edge on a release server): <see cref="DashTrailState.Hit"/> is cleared whenever the current
|
|
/// StartTick differs from <see cref="DashTrailState.LastStartTick"/>. Server-only ⇒ no rollback, so persisting the
|
|
/// accumulator across ticks is safe. A per-tick radius test (run every blink tick) approximates the swept path; the
|
|
/// per-tick dash step (<~0.6u) is well inside the radius, so a thin enemy is not tunnelled. Hit-set overflow stops
|
|
/// adding (a possible re-hit on a very crowded dash — accepted v1 cap).
|
|
/// </summary>
|
|
[BurstCompile]
|
|
[WorldSystemFilter(WorldSystemFilterFlags.ServerSimulation)]
|
|
[UpdateInGroup(typeof(PredictedSimulationSystemGroup))]
|
|
[UpdateAfter(typeof(DashSystem))]
|
|
[UpdateBefore(typeof(HealthApplyDamageSystem))]
|
|
public partial struct DashTrailDamageSystem : ISystem
|
|
{
|
|
const float k_Radius = 1.6f; // planar hit radius around the dashing player (tunable)
|
|
const float k_Damage = 12f; // per-enemy damage for a dash pass (tunable)
|
|
|
|
[BurstCompile]
|
|
public void OnCreate(ref SystemState state)
|
|
{
|
|
state.RequireForUpdate<NetworkTime>();
|
|
state.RequireForUpdate<DashTrailState>();
|
|
}
|
|
|
|
[BurstCompile]
|
|
public void OnUpdate(ref SystemState state)
|
|
{
|
|
var nt = SystemAPI.GetSingleton<NetworkTime>();
|
|
var serverTick = nt.ServerTick;
|
|
if (!serverTick.IsValid)
|
|
return;
|
|
|
|
// Snapshot living enemies once (positions + radii + entities), stable query order.
|
|
var enemyEntities = new NativeList<Entity>(Allocator.Temp);
|
|
var enemyPositions = new NativeList<float3>(Allocator.Temp);
|
|
var enemyRadii = new NativeList<float>(Allocator.Temp);
|
|
foreach (var (tx, hr, hp, te) in
|
|
SystemAPI.Query<RefRO<LocalTransform>, RefRO<HitRadius>, RefRO<Health>>()
|
|
.WithAll<EnemyTag>().WithNone<Dying>().WithEntityAccess())
|
|
{
|
|
if (hp.ValueRO.Current <= 0f) continue;
|
|
enemyEntities.Add(te);
|
|
enemyPositions.Add(tx.ValueRO.Position);
|
|
enemyRadii.Add(hr.ValueRO.Value);
|
|
}
|
|
|
|
if (enemyEntities.Length == 0)
|
|
{
|
|
enemyEntities.Dispose(); enemyPositions.Dispose(); enemyRadii.Dispose();
|
|
return;
|
|
}
|
|
|
|
uint stamp = TickUtil.NonZero(serverTick.TickIndexForValidTick);
|
|
var ecb = new EntityCommandBuffer(Allocator.Temp);
|
|
|
|
foreach (var (xform, dash, trail, owner, fx) in
|
|
SystemAPI.Query<RefRO<LocalTransform>, RefRO<DashState>, RefRW<DashTrailState>,
|
|
RefRO<GhostOwner>, RefRO<BoonEffects>>()
|
|
.WithAll<PlayerTag, Simulate>())
|
|
{
|
|
if ((fx.ValueRO.Flags & BoonFlag.DashTrail) == 0)
|
|
continue;
|
|
|
|
uint startRaw = dash.ValueRO.StartTick;
|
|
if (startRaw == 0u)
|
|
continue; // never dashed
|
|
|
|
// Inside the blink (i-frame) window [StartTick, IFrameUntilTick)?
|
|
var startTick = new NetworkTick(startRaw);
|
|
var untilTick = new NetworkTick(dash.ValueRO.IFrameUntilTick);
|
|
bool dashing = startTick.IsValid && untilTick.IsValid
|
|
&& !startTick.IsNewerThan(serverTick) && untilTick.IsNewerThan(serverTick);
|
|
if (!dashing)
|
|
continue;
|
|
|
|
// New dash → reset the per-dash hit set (StartTick changes every dash; no reliable DashState clear).
|
|
if (trail.ValueRO.LastStartTick != startRaw)
|
|
{
|
|
trail.ValueRW.Hit.Clear();
|
|
trail.ValueRW.LastStartTick = startRaw;
|
|
}
|
|
|
|
float3 p = xform.ValueRO.Position;
|
|
int ownerId = owner.ValueRO.NetworkId;
|
|
for (int i = 0; i < enemyEntities.Length; i++)
|
|
{
|
|
var enemy = enemyEntities[i];
|
|
if (HitContains(trail.ValueRO, enemy))
|
|
continue;
|
|
float2 d = new float2(enemyPositions[i].x - p.x, enemyPositions[i].z - p.z);
|
|
float reach = k_Radius + enemyRadii[i];
|
|
if (math.lengthsq(d) > reach * reach)
|
|
continue;
|
|
|
|
if (trail.ValueRO.Hit.Length >= trail.ValueRO.Hit.Capacity) break; // hit-cap: never damage an enemy we can't record (else re-hit every tick)
|
|
ecb.AppendToBuffer(enemy, new DamageEvent
|
|
{
|
|
Amount = k_Damage,
|
|
SourceNetworkId = ownerId, // a real player id (legit Charger whiff-punish credit)
|
|
SourceTick = stamp,
|
|
});
|
|
if (trail.ValueRO.Hit.Length < trail.ValueRO.Hit.Capacity)
|
|
trail.ValueRW.Hit.Add(enemy);
|
|
}
|
|
}
|
|
|
|
ecb.Playback(state.EntityManager);
|
|
ecb.Dispose();
|
|
enemyEntities.Dispose();
|
|
enemyPositions.Dispose();
|
|
enemyRadii.Dispose();
|
|
}
|
|
|
|
static bool HitContains(in DashTrailState trail, Entity e)
|
|
{
|
|
for (int i = 0; i < trail.Hit.Length; i++)
|
|
if (trail.Hit[i] == e) return true;
|
|
return false;
|
|
}
|
|
}
|
|
}
|