Attack Boon Changes

This commit is contained in:
2026-07-13 18:30:41 -07:00
parent 972e0d5b4f
commit 24800f4bcb
34 changed files with 1306 additions and 112 deletions
@@ -0,0 +1,139 @@
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 (&lt;~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;
}
}
}