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:
@@ -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 < 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;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user