Melee feel overhaul: SwordCombat anims at heavy cadence, salvage axe, damage-at-contact + buffer + dash law (guidelines forks 1-6)
07-18/19/20 arc: LightCombo01/HeavyCombo01 clips onto Swing1-3/Slam at ~natural speed (26-tick window, 30-tick recover, DPS-held damage retune), Menacing01 combat idle (InCombat), SM_Wep_Axe_Large_01 rigid-skinned to Hand_R at x1.25, LANTERN FX retone + blade-smear ribbon. Forks 1-6 per Combat_Attack_Feel_Guidelines (design review wf_000bc247): cleave resolves at the CONTACT tick (MeleeCleavePending schedule-and-consume, knob 31, 0=legacy; connect cues moved to contact), MeleeRange 2.2 + reach-only finisher mult 1.25 (knob 30), BufferedAttackTick GhostField input buffer (knob 29, unlock-edge validity), dash refused pre-contact (lookup-based). Tests: MeleeComboTests pin legacy knobs; +3 fork tests (414 green); live server proof: HP drop exactly at swing+16 under tick batching. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -35,6 +35,15 @@ namespace ProjectM.Simulation
|
||||
const float DefaultSharpness = CharacterComponent.DefaultGroundedSharpness; // CharacterComponent.GetDefault() base
|
||||
const float SimTickRate = 60f;
|
||||
|
||||
// 07-20 G7 dash law (design review wf_000bc247 C4): a LOOKUP, not a query add -- melee-less entities
|
||||
// (dash test harnesses) must keep dashing; only entities that carry MeleeCombo get the commitment rule.
|
||||
ComponentLookup<MeleeCombo> m_MeleeLookup;
|
||||
|
||||
public void OnCreate(ref SystemState state)
|
||||
{
|
||||
m_MeleeLookup = state.GetComponentLookup<MeleeCombo>(isReadOnly: true);
|
||||
}
|
||||
|
||||
[BurstCompile]
|
||||
public void OnUpdate(ref SystemState state)
|
||||
{
|
||||
@@ -50,19 +59,31 @@ namespace ProjectM.Simulation
|
||||
float dashSharpness = t.DashSharpness;
|
||||
// 07-15 underwater feel: the restore target honors the MoveSharpness dev-override (0 = authored const).
|
||||
float baseSharpness = t.MoveSharpness > 0f ? t.MoveSharpness : DefaultSharpness;
|
||||
float contactKnob = math.max(0f, t.MeleeContactTicks); // 07-20 G7: pre-contact swing = dash refused
|
||||
m_MeleeLookup.Update(ref state);
|
||||
|
||||
|
||||
foreach (var (ds, cd, control, character, input, facing) in
|
||||
foreach (var (ds, cd, control, character, input, facing, entity) in
|
||||
SystemAPI.Query<RefRW<DashState>, RefRW<DashCooldown>, RefRW<CharacterControl>,
|
||||
RefRW<CharacterComponent>, RefRO<PlayerInput>, RefRO<PlayerFacing>>()
|
||||
.WithAll<Simulate>().WithDisabled<Dead>())
|
||||
.WithAll<Simulate>().WithDisabled<Dead>().WithEntityAccess())
|
||||
{
|
||||
// --- START (idempotent: fresh press + cooldown ready + not already mid-dash) ---
|
||||
bool ready = cd.ValueRO.NextTick == 0u
|
||||
|| !new NetworkTick(cd.ValueRO.NextTick).IsNewerThan(serverTick);
|
||||
bool inWindow = ds.ValueRO.RecoverUntilTick != 0u
|
||||
&& new NetworkTick(ds.ValueRO.RecoverUntilTick).IsNewerThan(serverTick);
|
||||
if (input.ValueRO.Dash.IsSet && ready && !inWindow)
|
||||
// 07-20 G7 (the Bathynaut COMMITS): a dash start is refused while a swing is PRE-CONTACT -- the
|
||||
// blade hasn't landed; post-contact recovery dash-cancel stays allowed. Same wrap-safe window
|
||||
// family as the anim pulse (TickWindowMath.SwingActive over the per-step contact ticks);
|
||||
// contact knob 0 -> zero-length window -> no refusal (legacy). Deterministic both worlds.
|
||||
bool preContactCommit = false;
|
||||
if (m_MeleeLookup.HasComponent(entity))
|
||||
{
|
||||
var mcRO = m_MeleeLookup[entity];
|
||||
preContactCommit = TickWindowMath.SwingActive(mcRO, serverTick, MeleeTiming.ContactTicks(mcRO.Step, contactKnob));
|
||||
}
|
||||
if (input.ValueRO.Dash.IsSet && ready && !inWindow && !preContactCommit)
|
||||
{
|
||||
// C1 + 07-15 fork: dash toward MOVEMENT input when moving (a panic 'dash away' still works); else
|
||||
// toward the CURSOR aim (stationary aim-and-dash keeps today's KBM feel under move-facing —
|
||||
|
||||
@@ -28,5 +28,25 @@ namespace ProjectM.Simulation
|
||||
/// <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;
|
||||
|
||||
/// <summary>07-20 (guidelines G7 input buffer): raw ServerTick of an Attack press that landed during the
|
||||
/// swing lock's LAST MeleeBufferTicks (NonZero; 0 = empty). Consumed (zeroed) when it starts the chained
|
||||
/// swing; validity is anchored to the UNLOCK edge, not the press tick, so server tick-batching cannot
|
||||
/// drop an edge press (design review wf_000bc247 C1). Predicted + rollback-restored like its siblings.</summary>
|
||||
[GhostField] public uint BufferedAttackTick;
|
||||
}
|
||||
|
||||
/// <summary>07-20 (guidelines G2.1 damage-at-contact): SERVER-ONLY scheduled cleave — the AttackWindup
|
||||
/// schedule-and-consume idiom, entity-local (design review wf_000bc247 C11: no container, no disposal, no
|
||||
/// staleness window). Stamped when a swing starts (ResolveTick = start + MeleeTiming.ContactTicks), fired on
|
||||
/// a wrap-safe elapsed compare (tick-batch-proof), CONSUMED by zeroing. A still-armed pending is FLUSHED
|
||||
/// (fired early) before a chained swing overwrites it, so no knob combination can lose a cleave (C0/C12).
|
||||
/// Not replicated; the client's contact cues are presentation-side (CombatFeedbackSystem). Baked zeroed.</summary>
|
||||
public struct MeleeCleavePending : IComponentData
|
||||
{
|
||||
/// <summary>NonZero tick the armed swing's blade lands; 0 = nothing pending.</summary>
|
||||
public uint ResolveTick;
|
||||
/// <summary>Combo step of the ARMED swing (finisher scaling resolved against the live combo length).</summary>
|
||||
public byte Step;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -58,10 +58,38 @@ namespace ProjectM.Simulation
|
||||
BufferLookup<InventorySlot> m_InvLookup;
|
||||
BufferLookup<StatModifier> m_StatModLookup;
|
||||
ComponentLookup<BoonEffects> m_BoonEffectsLookup; // Phase 1.7 (player query is at the 7-type cap -> lookup)
|
||||
ComponentLookup<MeleeCleavePending> m_PendingLookup; // 07-20 G2.1 scheduled cleave (query at cap -> lookup)
|
||||
|
||||
/// <summary>Phase 1.7 Detonating Finisher blast radius (planar, tunable).</summary>
|
||||
const float k_DetonateRadius = 3.5f;
|
||||
|
||||
/// <summary>07-20 G2.1: build a cleave payload from LIVE player state at the moment it FIRES (contact tick,
|
||||
/// flush, or legacy same-tick) -- the cast-turn keeps steering the cone until the blade lands. Static with
|
||||
/// explicit params (no captures) so Burst lowering stays trivial. Reach uses the RANGE-only finisher mult
|
||||
/// (G2.2); damage/knockback keep the classic finisher mult.</summary>
|
||||
static PendingCleave BuildCleave(byte step, byte comboLen, float baseDamage, float baseRange, float knockSpeed,
|
||||
float finisherMult, float finisherRangeMult, uint stamp, uint knockUntil, float3 from, float2 aim,
|
||||
float2 facingDir, int ownerId, byte bflags, bool hasMods, DynamicBuffer<StatModifier> mods)
|
||||
{
|
||||
bool fin = step >= comboLen;
|
||||
float d = math.max(0f, hasMods ? StatMath.Apply(baseDamage, StatTarget.MeleeDamage, mods) : baseDamage);
|
||||
float r = math.max(0f, hasMods ? StatMath.Apply(baseRange, StatTarget.MeleeRange, mods) : baseRange);
|
||||
return new PendingCleave
|
||||
{
|
||||
From = from,
|
||||
Face = FacingMath.ResolveAim(aim, facingDir),
|
||||
Damage = fin ? d * finisherMult : d,
|
||||
Range = fin ? r * finisherRangeMult : r,
|
||||
KnockSpeed = fin ? knockSpeed * finisherMult : knockSpeed,
|
||||
OwnerId = ownerId,
|
||||
Stamp = stamp,
|
||||
KnockUntil = knockUntil,
|
||||
IsFinisher = fin,
|
||||
Detonate = (bflags & BoonFlag.FinisherDetonate) != 0,
|
||||
Pull = (bflags & BoonFlag.KnockToPull) != 0,
|
||||
};
|
||||
}
|
||||
|
||||
[BurstCompile]
|
||||
public void OnCreate(ref SystemState state)
|
||||
{
|
||||
@@ -72,6 +100,7 @@ namespace ProjectM.Simulation
|
||||
m_InvLookup = state.GetBufferLookup<InventorySlot>(isReadOnly: false);
|
||||
m_StatModLookup = state.GetBufferLookup<StatModifier>(isReadOnly: true);
|
||||
m_BoonEffectsLookup = state.GetComponentLookup<BoonEffects>(isReadOnly: true);
|
||||
m_PendingLookup = state.GetComponentLookup<MeleeCleavePending>(isReadOnly: false);
|
||||
state.RequireForUpdate<NetworkTime>();
|
||||
}
|
||||
|
||||
@@ -92,6 +121,9 @@ namespace ProjectM.Simulation
|
||||
float moveScale = math.max(0f, t.MeleeSwingMoveScale);
|
||||
float knockSpeed = math.max(0f, t.MeleeKnockbackSpeed);
|
||||
float finisherMult = math.max(1f, t.MeleeFinisherMult);
|
||||
float finisherRangeMult = math.max(0f, t.MeleeFinisherRangeMult); // 07-20 G2.2: REACH-only finisher scaling
|
||||
float bufferKnob = math.max(0f, t.MeleeBufferTicks); // 07-20 G7: 0 = buffer off
|
||||
float contactKnob = math.max(0f, t.MeleeContactTicks); // 07-20 G2.1: 0 = immediate (legacy)
|
||||
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));
|
||||
@@ -103,6 +135,7 @@ namespace ProjectM.Simulation
|
||||
var cleaves = isServer ? new NativeList<PendingCleave>(Allocator.Temp) : default;
|
||||
m_StatModLookup.Update(ref state);
|
||||
m_BoonEffectsLookup.Update(ref state); // Phase 1.7: per-player boon flags (read inside the player loop)
|
||||
m_PendingLookup.Update(ref state); // 07-20 G2.1: scheduled cleave slots (server-only writes)
|
||||
|
||||
foreach (var (mc, control, input, facing, xform, owner, ds, entity) in
|
||||
SystemAPI.Query<RefRW<MeleeCombo>, RefRW<CharacterControl>, RefRO<PlayerInput>,
|
||||
@@ -119,10 +152,48 @@ namespace ProjectM.Simulation
|
||||
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) ---
|
||||
// --- 07-20 G2.1: fire a DUE scheduled cleave BEFORE anything can overwrite the swing this tick
|
||||
// (wrap-safe elapsed compare = tick-batch-proof; consumed by zeroing -- the AttackWindup idiom,
|
||||
// design review wf_000bc247 C11). Server-only; the pending slot never replicates.
|
||||
bool hasPending = m_PendingLookup.HasComponent(entity);
|
||||
if (isServer && hasPending)
|
||||
{
|
||||
var pend = m_PendingLookup[entity];
|
||||
if (pend.ResolveTick != 0u && !new NetworkTick(pend.ResolveTick).IsNewerThan(serverTick))
|
||||
{
|
||||
cleaves.Add(BuildCleave(pend.Step, comboLen, baseDamage, baseRange, knockSpeed,
|
||||
finisherMult, finisherRangeMult, stamp, knockUntil, xform.ValueRO.Position,
|
||||
input.ValueRO.Aim, facing.ValueRO.Direction, owner.ValueRO.NetworkId,
|
||||
m_BoonEffectsLookup.HasComponent(entity) ? m_BoonEffectsLookup[entity].Flags : (byte)0,
|
||||
m_StatModLookup.HasBuffer(entity), m_StatModLookup.HasBuffer(entity) ? m_StatModLookup[entity] : default));
|
||||
m_PendingLookup[entity] = default;
|
||||
}
|
||||
}
|
||||
|
||||
// --- 07-20 G7 buffer: a press in the lock's LAST MeleeBufferTicks is remembered; validity anchors
|
||||
// to the UNLOCK edge so server tick-batching can't drop an edge press (review wf_000bc247 C1). ---
|
||||
uint bufTicks = (uint)bufferKnob;
|
||||
if (bufTicks > 0u && input.ValueRO.Attack.IsSet && locked)
|
||||
{
|
||||
var lockEnd = new NetworkTick(mc.ValueRO.LockUntilTick);
|
||||
if (lockEnd.IsValid && lockEnd.TicksSince(serverTick) <= (int)bufTicks)
|
||||
mc.ValueRW.BufferedAttackTick = TickUtil.NonZero(now);
|
||||
}
|
||||
bool buffered = false;
|
||||
if (!locked && mc.ValueRO.BufferedAttackTick != 0u)
|
||||
{
|
||||
var lockEnd = new NetworkTick(mc.ValueRO.LockUntilTick);
|
||||
if (bufTicks > 0u && lockEnd.IsValid && serverTick.TicksSince(lockEnd) <= (int)MeleeTiming.BatchSlackTicks)
|
||||
buffered = true;
|
||||
else
|
||||
mc.ValueRW.BufferedAttackTick = 0u; // stale (missed the slack / buffer tuned off) -- drop
|
||||
}
|
||||
|
||||
// --- ADVANCE (idempotent ABSOLUTE writes; dash wins same-tick ties via !Dash.IsSet; a buffered
|
||||
// press counts as a press -- G7) ---
|
||||
bool swingStarted = false;
|
||||
byte swingStep = 0;
|
||||
if (input.ValueRO.Attack.IsSet && !locked && !dashActive && !input.ValueRO.Dash.IsSet)
|
||||
if ((input.ValueRO.Attack.IsSet || buffered) && !locked && !dashActive && !input.ValueRO.Dash.IsSet)
|
||||
{
|
||||
bool inChainWindow = false;
|
||||
if (mc.ValueRO.LockUntilTick != 0u)
|
||||
@@ -139,6 +210,7 @@ namespace ProjectM.Simulation
|
||||
mc.ValueRW.Step = swingStep;
|
||||
mc.ValueRW.SwingStartTick = TickUtil.NonZero(now);
|
||||
mc.ValueRW.LockUntilTick = TickUtil.NonZero(now + stepRecover);
|
||||
mc.ValueRW.BufferedAttackTick = 0u; // consumed (or superseded by a fresh press)
|
||||
swingStarted = true;
|
||||
}
|
||||
|
||||
@@ -150,33 +222,33 @@ namespace ProjectM.Simulation
|
||||
if (inSwingWindow && !dashActive)
|
||||
control.ValueRW.MoveVelocity *= moveScale;
|
||||
|
||||
// --- SERVER: queue the cleave (resolved after the loop). Damage/cone are SERVER-authoritative. ---
|
||||
// --- SERVER: schedule the cleave at the CONTACT tick (07-20 G2.1) -- or fire same-tick when the
|
||||
// knob is 0 (legacy) / the entity has no pending slot (plain test worlds). Damage stays SERVER-only;
|
||||
// payload is built from LIVE state at the moment it fires (the cast-turn steers until the blade lands).
|
||||
if (swingStarted && isServer)
|
||||
{
|
||||
bool isFin = swingStep >= comboLen;
|
||||
// Slice 2: fold the player's class/run StatModifiers onto the live-tunable melee base so the
|
||||
// PRIMARY verb scales with class identity (Warrior +MeleeDamage/+reach) + run augments.
|
||||
byte bflags = m_BoonEffectsLookup.HasComponent(entity) ? m_BoonEffectsLookup[entity].Flags : (byte)0;
|
||||
bool hasMods = m_StatModLookup.HasBuffer(entity);
|
||||
float pDamage = math.max(0f, hasMods ? StatMath.Apply(baseDamage, StatTarget.MeleeDamage, m_StatModLookup[entity]) : baseDamage);
|
||||
float pRange = math.max(0f, hasMods ? StatMath.Apply(baseRange, StatTarget.MeleeRange, m_StatModLookup[entity]) : baseRange);
|
||||
// Manual-aim cleave (07-15): the cone follows the CURRENT tick's replicated Aim (SoD skillshot
|
||||
// grammar) — PlayerFacing is body-yaw only; the facing fallback covers a resting gamepad stick.
|
||||
float2 face = FacingMath.ResolveAim(input.ValueRO.Aim, facing.ValueRO.Direction);
|
||||
cleaves.Add(new PendingCleave
|
||||
uint ct = MeleeTiming.ContactTicks(swingStep, contactKnob);
|
||||
if (ct > 0u && hasPending)
|
||||
{
|
||||
From = xform.ValueRO.Position,
|
||||
Face = face,
|
||||
Damage = isFin ? pDamage * finisherMult : pDamage,
|
||||
Range = isFin ? pRange * finisherMult : pRange,
|
||||
KnockSpeed = isFin ? knockSpeed * finisherMult : knockSpeed,
|
||||
OwnerId = owner.ValueRO.NetworkId,
|
||||
Stamp = stamp,
|
||||
KnockUntil = knockUntil,
|
||||
IsFinisher = isFin,
|
||||
Detonate = (bflags & BoonFlag.FinisherDetonate) != 0,
|
||||
Pull = (bflags & BoonFlag.KnockToPull) != 0,
|
||||
});
|
||||
// FLUSH a still-armed older cleave first (pathological knob combos can push contact past the
|
||||
// recover lock -- fire it early rather than lose it; review wf_000bc247 C0/C12).
|
||||
var pend = m_PendingLookup[entity];
|
||||
if (pend.ResolveTick != 0u)
|
||||
cleaves.Add(BuildCleave(pend.Step, comboLen, baseDamage, baseRange, knockSpeed,
|
||||
finisherMult, finisherRangeMult, stamp, knockUntil, xform.ValueRO.Position,
|
||||
input.ValueRO.Aim, facing.ValueRO.Direction, owner.ValueRO.NetworkId,
|
||||
m_BoonEffectsLookup.HasComponent(entity) ? m_BoonEffectsLookup[entity].Flags : (byte)0,
|
||||
m_StatModLookup.HasBuffer(entity), m_StatModLookup.HasBuffer(entity) ? m_StatModLookup[entity] : default));
|
||||
m_PendingLookup[entity] = new MeleeCleavePending { ResolveTick = TickUtil.NonZero(now + ct), Step = swingStep };
|
||||
}
|
||||
else
|
||||
{
|
||||
cleaves.Add(BuildCleave(swingStep, comboLen, baseDamage, baseRange, knockSpeed,
|
||||
finisherMult, finisherRangeMult, stamp, knockUntil, xform.ValueRO.Position,
|
||||
input.ValueRO.Aim, facing.ValueRO.Direction, owner.ValueRO.NetworkId,
|
||||
m_BoonEffectsLookup.HasComponent(entity) ? m_BoonEffectsLookup[entity].Flags : (byte)0,
|
||||
m_StatModLookup.HasBuffer(entity), m_StatModLookup.HasBuffer(entity) ? m_StatModLookup[entity] : default));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -33,7 +33,7 @@ namespace ProjectM.Simulation
|
||||
|
||||
/// <summary>Ticks facing stays aimed after a swing/fire starts (~0.22s @60Hz — matches the anim pulse
|
||||
/// k_AttackAnimTicks so body yaw and the swing animation agree).</summary>
|
||||
public const uint CastFacingTicks = 13;
|
||||
public const uint CastFacingTicks = 26; // 07-19 heavy-weapon retime: cast-turn + swing-anim window tracks the full ~0.43s swing (was 13; MUST stay < MeleeRecoverTicks so the anim pulse re-triggers between chained swings)
|
||||
|
||||
[BurstCompile]
|
||||
public void OnUpdate(ref SystemState state)
|
||||
|
||||
Reference in New Issue
Block a user