Combat: MC-4 combo-chain melee as the primary verb (DR-030)

Melee combo (left-click / pad-West) becomes the player's primary verb; the ranged projectile is demoted to right-click / pad-left-trigger. Predicted, owner-replicated combo Step (path-dependent -> [GhostField] anchor + absolute-write idempotency, NOT derived like the dash), server-only cleave mirroring ProjectileDamageSystem (SourceTick-stamped DamageEvent + KnockbackState), dash-cancellable movement-commit, 9 live TuningConfig knobs, and swing juice scaling with the combo step. The MC-6 archetype byte is deferred (the melee is its own verb). See DR-030.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-10 17:22:57 -07:00
parent 08f16b689f
commit 3409c53148
13 changed files with 430 additions and 10 deletions
@@ -0,0 +1,39 @@
using Unity.Mathematics;
namespace ProjectM.Simulation
{
/// <summary>
/// Pure, deterministic cone hit-test for the MC-4 melee cleave. Shares the EXACT range + bearing predicate with
/// <see cref="AutoTarget"/> (planar XZ distance gate + <c>dot(bearing, facing) &gt;= cos(halfAngle)</c>) but as a
/// per-candidate boolean, so the cleave can collect ALL enemies in the cone — <see cref="AutoTarget.Resolve"/> is a
/// single-winner reducer and cannot be reused for collect-all (MC-4 review REUSE-1). Burst-safe, allocation-free,
/// no wall-clock / no randomness; intended to run server-side inside the predicted <c>MeleeComboSystem</c>.
/// </summary>
public static class MeleeConeMath
{
/// <summary>
/// True iff <paramref name="targetPos"/> lies within <paramref name="range"/> (planar XZ) of
/// <paramref name="from"/> AND its bearing from <paramref name="from"/> is within the cone half-angle of
/// <paramref name="facingDir"/> (<paramref name="cosHalfAngle"/> = cos(halfAngle)). A coincident
/// (zero-distance) target is excluded (undefined bearing). <paramref name="facingDir"/> must be
/// caller-normalized; a zero-length facing or a non-positive range returns false.
/// </summary>
public static bool InCone(float3 from, float2 facingDir, float range, float cosHalfAngle, float3 targetPos)
{
if (range <= 0f || math.lengthsq(facingDir) < 1e-6f)
return false;
float3 offset = targetPos - from;
float2 planar = new float2(offset.x, offset.z);
float distSq = math.lengthsq(planar);
if (distSq < 1e-6f)
return false; // coincident -> undefined bearing
if (distSq > range * range)
return false; // out of range
float2 bearing = planar * math.rsqrt(distSq); // normalized planar bearing
return math.dot(bearing, facingDir) >= cosHalfAngle;
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 4f2769f11116e304d8674e254ef9bd75
@@ -5,14 +5,14 @@ using Unity.NetCode;
namespace ProjectM.Simulation
{
/// <summary>
/// MC-0 — live-tunable dash/Charger feel knobs (a per-world singleton). UNCONDITIONAL type (no #if) so the
/// MC-0 — live-tunable dash/Charger/melee feel knobs (a per-world singleton). UNCONDITIONAL type (no #if) so the
/// reflection-built RpcCollection hash matches across release/dev peers — only the dev SYSTEMS that create,
/// mutate, broadcast and receive it are <c>#if UNITY_EDITOR</c>. Consumers (DashSystem, EnemyAISystem) read it
/// via <c>TryGetSingleton</c> and FALL BACK to <see cref="Defaults"/> when it is absent (release builds +
/// EditMode worlds), so behaviour is identical to the old baked consts when no dev singleton exists. NOT a
/// <c>[GhostField]</c> (no ghost-hash change / re-bake); the server broadcasts it to clients via
/// <see cref="DebugTuningReport"/> so a PREDICTING client's DashSystem stays in sync (an MPPM thin client with no
/// overlay learns tuned values ONLY through this broadcast).
/// mutate, broadcast and receive it are <c>#if UNITY_EDITOR</c>. Consumers (DashSystem, EnemyAISystem,
/// MeleeComboSystem) read it via <c>TryGetSingleton</c> and FALL BACK to <see cref="Defaults"/> when it is absent
/// (release builds + EditMode worlds), so behaviour is identical to the old baked consts when no dev singleton
/// exists. NOT a <c>[GhostField]</c> (no ghost-hash change / re-bake); the server broadcasts it to clients via
/// <see cref="DebugTuningReport"/> so a PREDICTING client's DashSystem/MeleeComboSystem stays in sync (an MPPM thin
/// client with no overlay learns tuned values ONLY through this broadcast).
/// <para>
/// Values match the historical baked consts (DashSystem / EnemyAISystem / <see cref="Tuning.AttackWindupTicks"/>);
/// <c>TuningConfigTests</c> pins <see cref="Defaults"/> to them so the promote-to-singleton refactor is
@@ -34,6 +34,18 @@ namespace ProjectM.Simulation
public float ChargerWhiffStaggerTicks;
public float GruntWindupTicks;
// MC-4 melee combo (the primary verb). The whole per-step SHAPE derives from these few LIVE scalars + one
// finisher multiplier (no per-step authored blob — keeps the combo fully live-tunable; MC-4 review F5/BURST-1).
public float MeleeDamage;
public float MeleeRange;
public float MeleeConeHalfAngleRad;
public float MeleeRecoverTicks;
public float MeleeChainGraceTicks;
public float MeleeSwingMoveScale;
public float MeleeKnockbackSpeed;
public float MeleeFinisherMult;
public float MeleeComboLength;
/// <summary>The baked feel defaults == the pre-MC-0 consts. Single source of truth for the fallback path.</summary>
public static TuningConfig Defaults() => new TuningConfig
{
@@ -47,6 +59,15 @@ namespace ProjectM.Simulation
ChargerLungeDurationTicks = 18f,
ChargerWhiffStaggerTicks = 36f,
GruntWindupTicks = Tuning.AttackWindupTicks, // canonical Grunt-windup source (TelegraphTests couples to it)
MeleeDamage = 18f,
MeleeRange = 2.6f,
MeleeConeHalfAngleRad = 0.9f, // ~51.5 deg half-angle (~103 deg cleave arc)
MeleeRecoverTicks = 16f, // light-swing lock/recovery (~0.27s)
MeleeChainGraceTicks = 18f, // window after the lock to chain the next hit (~0.3s)
MeleeSwingMoveScale = 0.35f, // movement-commit while swinging (0..1)
MeleeKnockbackSpeed = 6f,
MeleeFinisherMult = 1.8f, // finisher (last hit) scales dmg/range/recover/knockback
MeleeComboLength = 3f, // light, light, finisher
};
/// <summary>Clamp a knob to its safe floor: tick knobs &gt;= 1, value knobs &gt;= 0. Used by every write path
@@ -59,6 +80,12 @@ namespace ProjectM.Simulation
case TuningKnob.DashDistance:
case TuningKnob.DashSharpness:
case TuningKnob.ChargerLungeSpeed:
case TuningKnob.MeleeDamage:
case TuningKnob.MeleeRange:
case TuningKnob.MeleeConeHalfAngleRad:
case TuningKnob.MeleeSwingMoveScale:
case TuningKnob.MeleeKnockbackSpeed:
case TuningKnob.MeleeFinisherMult:
return math.max(0f, value);
// tick knobs: >= 1 (a 0 tick count is degenerate; a 0 i-frame window divides-by-zero in DashSystem)
default:
@@ -82,6 +109,15 @@ namespace ProjectM.Simulation
case TuningKnob.ChargerLungeDurationTicks: c.ChargerLungeDurationTicks = value; break;
case TuningKnob.ChargerWhiffStaggerTicks: c.ChargerWhiffStaggerTicks = value; break;
case TuningKnob.GruntWindupTicks: c.GruntWindupTicks = value; break;
case TuningKnob.MeleeDamage: c.MeleeDamage = value; break;
case TuningKnob.MeleeRange: c.MeleeRange = value; break;
case TuningKnob.MeleeConeHalfAngleRad: c.MeleeConeHalfAngleRad = value; break;
case TuningKnob.MeleeRecoverTicks: c.MeleeRecoverTicks = value; break;
case TuningKnob.MeleeChainGraceTicks: c.MeleeChainGraceTicks = value; break;
case TuningKnob.MeleeSwingMoveScale: c.MeleeSwingMoveScale = value; break;
case TuningKnob.MeleeKnockbackSpeed: c.MeleeKnockbackSpeed = value; break;
case TuningKnob.MeleeFinisherMult: c.MeleeFinisherMult = value; break;
case TuningKnob.MeleeComboLength: c.MeleeComboLength = value; break;
// unknown index -> no-op (matches the no-default switch convention in DebugCommandReceiveSystem)
}
}
@@ -101,6 +137,15 @@ namespace ProjectM.Simulation
case TuningKnob.ChargerLungeDurationTicks: return c.ChargerLungeDurationTicks;
case TuningKnob.ChargerWhiffStaggerTicks: return c.ChargerWhiffStaggerTicks;
case TuningKnob.GruntWindupTicks: return c.GruntWindupTicks;
case TuningKnob.MeleeDamage: return c.MeleeDamage;
case TuningKnob.MeleeRange: return c.MeleeRange;
case TuningKnob.MeleeConeHalfAngleRad: return c.MeleeConeHalfAngleRad;
case TuningKnob.MeleeRecoverTicks: return c.MeleeRecoverTicks;
case TuningKnob.MeleeChainGraceTicks: return c.MeleeChainGraceTicks;
case TuningKnob.MeleeSwingMoveScale: return c.MeleeSwingMoveScale;
case TuningKnob.MeleeKnockbackSpeed: return c.MeleeKnockbackSpeed;
case TuningKnob.MeleeFinisherMult: return c.MeleeFinisherMult;
case TuningKnob.MeleeComboLength: return c.MeleeComboLength;
default: return 0f;
}
}
@@ -118,6 +163,15 @@ namespace ProjectM.Simulation
ChargerLungeDurationTicks = c.ChargerLungeDurationTicks,
ChargerWhiffStaggerTicks = c.ChargerWhiffStaggerTicks,
GruntWindupTicks = c.GruntWindupTicks,
MeleeDamage = c.MeleeDamage,
MeleeRange = c.MeleeRange,
MeleeConeHalfAngleRad = c.MeleeConeHalfAngleRad,
MeleeRecoverTicks = c.MeleeRecoverTicks,
MeleeChainGraceTicks = c.MeleeChainGraceTicks,
MeleeSwingMoveScale = c.MeleeSwingMoveScale,
MeleeKnockbackSpeed = c.MeleeKnockbackSpeed,
MeleeFinisherMult = c.MeleeFinisherMult,
MeleeComboLength = c.MeleeComboLength,
};
/// <summary>Reconstruct the full config from a wire snapshot (FULL state, not a delta).</summary>
@@ -133,6 +187,15 @@ namespace ProjectM.Simulation
ChargerLungeDurationTicks = r.ChargerLungeDurationTicks,
ChargerWhiffStaggerTicks = r.ChargerWhiffStaggerTicks,
GruntWindupTicks = r.GruntWindupTicks,
MeleeDamage = r.MeleeDamage,
MeleeRange = r.MeleeRange,
MeleeConeHalfAngleRad = r.MeleeConeHalfAngleRad,
MeleeRecoverTicks = r.MeleeRecoverTicks,
MeleeChainGraceTicks = r.MeleeChainGraceTicks,
MeleeSwingMoveScale = r.MeleeSwingMoveScale,
MeleeKnockbackSpeed = r.MeleeKnockbackSpeed,
MeleeFinisherMult = r.MeleeFinisherMult,
MeleeComboLength = r.MeleeComboLength,
};
}
@@ -149,9 +212,18 @@ namespace ProjectM.Simulation
public const byte ChargerLungeDurationTicks = 7;
public const byte ChargerWhiffStaggerTicks = 8;
public const byte GruntWindupTicks = 9;
public const byte MeleeDamage = 10;
public const byte MeleeRange = 11;
public const byte MeleeConeHalfAngleRad = 12;
public const byte MeleeRecoverTicks = 13;
public const byte MeleeChainGraceTicks = 14;
public const byte MeleeSwingMoveScale = 15;
public const byte MeleeKnockbackSpeed = 16;
public const byte MeleeFinisherMult = 17;
public const byte MeleeComboLength = 18;
/// <summary>Knob count (overlay iteration bound).</summary>
public const byte Count = 10;
public const byte Count = 19;
}
/// <summary>
@@ -172,5 +244,14 @@ namespace ProjectM.Simulation
public float ChargerLungeDurationTicks;
public float ChargerWhiffStaggerTicks;
public float GruntWindupTicks;
public float MeleeDamage;
public float MeleeRange;
public float MeleeConeHalfAngleRad;
public float MeleeRecoverTicks;
public float MeleeChainGraceTicks;
public float MeleeSwingMoveScale;
public float MeleeKnockbackSpeed;
public float MeleeFinisherMult;
public float MeleeComboLength;
}
}
@@ -0,0 +1,32 @@
using Unity.Entities;
using Unity.NetCode;
namespace ProjectM.Simulation
{
/// <summary>
/// MC-4 — predicted, owner-replicated melee combo state on the player (the PRIMARY offense verb). UNLIKE the dash
/// (DashState is non-replicated because a dash is a STATELESS-per-tick decision), the combo <see cref="Step"/> is
/// PATH-DEPENDENT — which chain link you are on depends on the sequence + timing of prior presses — and the bounded
/// input command buffer cannot reconstruct it across a reconnect / long rollback (MC-4 review PRED-1 / ROLLBACK-1).
/// So the minimal anchor is replicated as owner-predicted <c>[GhostField]</c>s: a rollback restores the
/// authoritative combo position, then MeleeComboSystem re-simulates forward with ABSOLUTE-WRITE windows (never an
/// in-place prev+1 of a non-restored field — the DashSystem idempotency idiom). The derived chain deadline
/// (LockUntilTick + grace) is NOT stored. All ticks routed through <c>TickUtil.NonZero</c>; compared via
/// <see cref="NetworkTick"/> only. Baked all-zero (idle). This is the player ghost's only net-new replicated melee
/// state (one re-bake; in-family with DashCooldown/AbilityCooldown).
/// </summary>
public struct MeleeCombo : IComponentData
{
/// <summary>Current/last swing index: 0 = idle, 1..N = chain link. Owner-predicted so a rollback restores the
/// authoritative combo position (the only legitimately path-dependent value).</summary>
[GhostField] public byte Step;
/// <summary>Raw ServerTick the current swing started (NonZero). Inclusive lower bound of the movement-commit
/// window; also the juice edge signal and the (instant) damage frame.</summary>
[GhostField] public uint SwingStartTick;
/// <summary>Raw ServerTick the swing's movement-commit / recovery lock ends (NonZero). Gates the next press
/// (locked while now &lt; this) and anchors the chain window [LockUntilTick, LockUntilTick + grace).</summary>
[GhostField] public uint LockUntilTick;
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 0dfbfd9f7db58e84e8bc59402cebaafb
@@ -0,0 +1,205 @@
using Unity.Burst;
using Unity.Collections;
using Unity.Entities;
using Unity.Mathematics;
using Unity.NetCode;
using Unity.Transforms;
namespace ProjectM.Simulation
{
/// <summary>One server-side cleave queued from a player's swing-start this tick, resolved after the player loop so
/// the living-enemy snapshot is gathered ONCE and ONLY when a swing actually fired (never on the client, never on an
/// idle tick). Blittable (Burst-friendly).</summary>
struct PendingCleave
{
public float3 From;
public float2 Face;
public float Damage;
public float Range;
public float KnockSpeed;
public int OwnerId;
public uint Stamp;
public uint KnockUntil;
}
/// <summary>
/// MC-4 — the predicted melee combo (Hades-style 2-3 hit chain; the player's PRIMARY verb). On a fresh
/// <see cref="PlayerInput.Attack"/> press (not locked, not mid-dash) it ADVANCES <see cref="MeleeCombo.Step"/>
/// (chain if re-pressed inside [LockUntilTick, LockUntilTick + grace), else reset to 1) and opens a movement-commit
/// lock; the finisher (Step == ComboLength) hits bigger. The Step/SwingStartTick/LockUntilTick anchor is
/// owner-replicated (<see cref="MeleeCombo"/> [GhostField]s) so a rollback restores the authoritative combo
/// position; every write is an ABSOLUTE function of (restored Step, tick) — re-running a tick re-derives identical
/// state (the DashSystem idempotency idiom; never an in-place prev+1 of a non-restored field — MC-4 review PRED-1).
/// <para>
/// Runs in <see cref="PredictedSimulationSystemGroup"/> AFTER <see cref="PlayerControlSystem"/> (it scales the
/// MoveVelocity that system wrote) and BEFORE <see cref="DashSystem"/> (a dash OVERRIDES the swing's movement =
/// dash-cancel) — and so before HealthApplyDamageSystem ([UpdateAfter(DashSystem)]) which drains the cleave's
/// DamageEvent the same tick. Movement-commit re-applies every predicted pass, lower-bounded on SwingStartTick (a
/// re-simulated pre-swing tick must NOT inherit the scale — the DashSystem inDashWindow fix). DAMAGE is SERVER-ONLY
/// (enemies are interpolated ghosts; the client never predicts enemy health) and mirrors ProjectileDamageSystem:
/// queue each swing, then collect ALL living enemies in the per-step cone (<see cref="MeleeConeMath"/>) ONCE, append
/// a SourceTick-stamped DamageEvent + stamp KnockbackState. All ticks via TickUtil.NonZero, compared with
/// NetworkTick only; feel knobs live in <see cref="TuningConfig"/> (MC-0, fallback to Defaults()).
/// </para>
/// </summary>
[UpdateInGroup(typeof(PredictedSimulationSystemGroup))]
[UpdateAfter(typeof(PlayerControlSystem))]
[UpdateBefore(typeof(DashSystem))]
[BurstCompile]
public partial struct MeleeComboSystem : ISystem
{
ComponentLookup<KnockbackState> m_KnockbackLookup;
[BurstCompile]
public void OnCreate(ref SystemState state)
{
m_KnockbackLookup = state.GetComponentLookup<KnockbackState>(isReadOnly: false);
state.RequireForUpdate<NetworkTime>();
}
[BurstCompile]
public void OnUpdate(ref SystemState state)
{
if (!SystemAPI.TryGetSingleton<NetworkTime>(out var netTime) || !netTime.ServerTick.IsValid)
return;
var serverTick = netTime.ServerTick;
uint now = serverTick.TickIndexForValidTick;
var t = SystemAPI.TryGetSingleton<TuningConfig>(out var tc) ? tc : TuningConfig.Defaults();
float baseDamage = math.max(0f, t.MeleeDamage);
float baseRange = math.max(0f, t.MeleeRange);
float cosHalf = math.cos(math.max(0f, t.MeleeConeHalfAngleRad));
uint recoverTicks = (uint)math.max(1f, t.MeleeRecoverTicks);
uint graceTicks = (uint)math.max(1f, t.MeleeChainGraceTicks);
float moveScale = math.max(0f, t.MeleeSwingMoveScale);
float knockSpeed = math.max(0f, t.MeleeKnockbackSpeed);
float finisherMult = math.max(1f, t.MeleeFinisherMult);
byte comboLen = (byte)math.clamp((int)t.MeleeComboLength, 1, 3);
uint stamp = TickUtil.NonZero(now);
uint knockUntil = TickUtil.NonZero(now + (uint)math.max(1, Tuning.KnockbackDurationTicks));
bool isServer = state.WorldUnmanaged.IsServer();
// Server-only queue of cleaves to resolve after the player loop (so enemies are gathered ONCE, and only
// when at least one swing actually started — no per-tick enemy gather on idle/client ticks).
var cleaves = isServer ? new NativeList<PendingCleave>(Allocator.Temp) : default;
foreach (var (mc, control, input, facing, xform, owner, ds) in
SystemAPI.Query<RefRW<MeleeCombo>, RefRW<CharacterControl>, RefRO<PlayerInput>,
RefRO<PlayerFacing>, RefRO<LocalTransform>, RefRO<GhostOwner>, RefRO<DashState>>()
.WithAll<Simulate>().WithDisabled<Dead>())
{
// A dash window (i-frame OR recovery) active = dash owns movement + blocks a swing start (dash-cancel).
bool dashActive = ds.ValueRO.StartTick != 0u
&& !new NetworkTick(ds.ValueRO.StartTick).IsNewerThan(serverTick)
&& ds.ValueRO.RecoverUntilTick != 0u
&& new NetworkTick(ds.ValueRO.RecoverUntilTick).IsNewerThan(serverTick);
// Locked: mid swing / recovery (now < LockUntilTick).
bool locked = mc.ValueRO.LockUntilTick != 0u
&& new NetworkTick(mc.ValueRO.LockUntilTick).IsNewerThan(serverTick);
// --- ADVANCE (idempotent ABSOLUTE writes; dash wins same-tick ties via !Dash.IsSet) ---
bool swingStarted = false;
byte swingStep = 0;
if (input.ValueRO.Attack.IsSet && !locked && !dashActive && !input.ValueRO.Dash.IsSet)
{
bool inChainWindow = false;
if (mc.ValueRO.LockUntilTick != 0u)
{
var deadline = new NetworkTick(TickUtil.NonZero(mc.ValueRO.LockUntilTick + graceTicks));
inChainWindow = deadline.IsValid && deadline.IsNewerThan(serverTick); // now < lock+grace (now >= lock since !locked)
}
byte prev = mc.ValueRO.Step;
swingStep = (inChainWindow && prev >= 1 && prev < comboLen) ? (byte)(prev + 1) : (byte)1;
bool isFin = swingStep >= comboLen;
uint stepRecover = isFin ? (uint)math.max(1f, math.round(recoverTicks * finisherMult)) : recoverTicks;
mc.ValueRW.Step = swingStep;
mc.ValueRW.SwingStartTick = TickUtil.NonZero(now);
mc.ValueRW.LockUntilTick = TickUtil.NonZero(now + stepRecover);
swingStarted = true;
}
// --- MOVEMENT COMMIT (every pass; lower-bounded on SwingStartTick; DashSystem overrides later) ---
bool inSwingWindow = mc.ValueRO.SwingStartTick != 0u
&& !new NetworkTick(mc.ValueRO.SwingStartTick).IsNewerThan(serverTick)
&& mc.ValueRO.LockUntilTick != 0u
&& new NetworkTick(mc.ValueRO.LockUntilTick).IsNewerThan(serverTick);
if (inSwingWindow && !dashActive)
control.ValueRW.MoveVelocity *= moveScale;
// --- SERVER: queue the cleave (resolved after the loop). Damage/cone are SERVER-authoritative. ---
if (swingStarted && isServer)
{
bool isFin = swingStep >= comboLen;
float2 face = facing.ValueRO.Direction;
face = math.lengthsq(face) < 1e-6f ? new float2(0f, 1f) : math.normalize(face);
cleaves.Add(new PendingCleave
{
From = xform.ValueRO.Position,
Face = face,
Damage = isFin ? baseDamage * finisherMult : baseDamage,
Range = isFin ? baseRange * finisherMult : baseRange,
KnockSpeed = isFin ? knockSpeed * finisherMult : knockSpeed,
OwnerId = owner.ValueRO.NetworkId,
Stamp = stamp,
KnockUntil = knockUntil,
});
}
}
// Resolve queued cleaves SERVER-ONLY and only when one actually fired: gather living enemies ONCE, then
// append a SourceTick-stamped DamageEvent + stamp KnockbackState for each enemy in each swing's cone.
if (isServer && cleaves.IsCreated && cleaves.Length > 0)
{
var enemyEntities = new NativeList<Entity>(Allocator.Temp);
var enemyPositions = new NativeList<float3>(Allocator.Temp);
foreach (var (xform, health, enemyEntity) in
SystemAPI.Query<RefRO<LocalTransform>, RefRO<Health>>()
.WithAny<EnemyTag, TrainingDummyTag>()
.WithEntityAccess())
{
if (health.ValueRO.Current <= 0f)
continue; // skip already-dead enemies (about to despawn)
enemyEntities.Add(enemyEntity);
enemyPositions.Add(xform.ValueRO.Position);
}
m_KnockbackLookup.Update(ref state);
var ecb = new EntityCommandBuffer(Allocator.Temp);
for (int s = 0; s < cleaves.Length; s++)
{
var c = cleaves[s];
for (int i = 0; i < enemyEntities.Length; i++)
{
if (!MeleeConeMath.InCone(c.From, c.Face, c.Range, cosHalf, enemyPositions[i]))
continue;
var target = enemyEntities[i];
ecb.AppendToBuffer(target, new DamageEvent
{
Amount = c.Damage,
SourceNetworkId = c.OwnerId,
SourceTick = c.Stamp,
});
if (c.KnockSpeed > 0f && m_KnockbackLookup.HasComponent(target))
{
float3 d3 = enemyPositions[i] - c.From;
float2 kdir = new float2(d3.x, d3.z);
kdir = math.lengthsq(kdir) > 1e-6f ? math.normalize(kdir) : c.Face;
m_KnockbackLookup[target] = new KnockbackState { Dir = kdir, Speed = c.KnockSpeed, UntilTick = c.KnockUntil };
}
}
}
ecb.Playback(state.EntityManager);
ecb.Dispose();
enemyEntities.Dispose();
enemyPositions.Dispose();
}
if (cleaves.IsCreated)
cleaves.Dispose();
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 815ef85fd906e3640b787de314638027
@@ -42,6 +42,9 @@ namespace ProjectM.Simulation
// no stale i-frames / stuck-fast on respawn (DashSystem skips dead players via .WithDisabled<Dead>()).
if (SystemAPI.HasComponent<DashState>(entity))
SystemAPI.SetComponent(entity, default(DashState));
// MC-4: clear any in-flight combo so a death mid-combo leaves no stale lock/step on respawn.
if (SystemAPI.HasComponent<MeleeCombo>(entity))
SystemAPI.SetComponent(entity, default(MeleeCombo));
if (SystemAPI.HasComponent<CharacterComponent>(entity))
{
var cc = SystemAPI.GetComponent<CharacterComponent>(entity);
@@ -25,6 +25,10 @@ namespace ProjectM.Simulation
/// so one press dashes exactly once; read by the predicted DashSystem (MC-1).</summary>
[GhostField] public InputEvent Dash;
/// <summary>Melee combo attack (MC-4) - the player's PRIMARY verb. InputEvent twin of Fire/Dash: one press =
/// one swing attempt across the frame->tick->rollback boundary; read by the predicted MeleeComboSystem.</summary>
[GhostField] public InputEvent Attack;
/// <summary>Active input scheme this tick (<see cref="InputSchemeId"/>: 0 = mouse/keyboard, 1 = gamepad).
/// The server reads it so the auto-target assist applies only to gamepad shots; precise mouse aim is left
/// exact. A byte (not an enum): it is compared inside the Burst-compiled <c>AbilityFireSystem</c>.</summary>
@@ -35,7 +39,7 @@ namespace ProjectM.Simulation
var s = new FixedString512Bytes();
s.Append(Move.x); s.Append(','); s.Append(Move.y); s.Append(';');
s.Append(Aim.x); s.Append(','); s.Append(Aim.y); s.Append(';');
s.Append(Fire.Count); s.Append(';'); s.Append(Scheme); s.Append(';'); s.Append(Dash.Count);
s.Append(Fire.Count); s.Append(';'); s.Append(Scheme); s.Append(';'); s.Append(Dash.Count); s.Append(';'); s.Append(Attack.Count);
return s;
}
}