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:
2026-07-20 21:03:18 -07:00
parent 2c187a17a9
commit 7b65912f30
22 changed files with 1327 additions and 112 deletions
@@ -51,10 +51,11 @@ namespace ProjectM.Client
static readonly FastAnimatorParameter k_ComboStep = new FastAnimatorParameter("ComboStep"); // replicated MeleeCombo.Step -> per-step swing clips (Swing1/2/3)
static readonly FastAnimatorParameter k_IsCone = new FastAnimatorParameter("IsCone"); // ability archetype (replicated AbilityRef + blob): Cone -> two-hand slam, else the shot
static readonly FastAnimatorParameter k_InCombat = new FastAnimatorParameter("InCombat"); // 07-18: Menacing01 combat idle -- swing recency (FeelConfig.CombatIdleHoldSec)
// Ticks after a swing-start that IsAttacking stays true (drives the MeleeSwing state). Kept < the swing lock
// (MeleeRecoverTicks ~16) so a CHAINED swing re-pulses the bool false->true and re-triggers the Any State
// transition per hit. ~0.22s @ 60Hz. Presentation-only.
// (MeleeRecoverTicks ~30) so a CHAINED swing re-pulses the bool false->true and re-triggers the Any State
// transition per hit. ~0.43s @ 60Hz (07-19 heavy retime). Presentation-only.
const uint k_AttackAnimTicks = PlayerAimSystem.CastFacingTicks; // structurally tied (07-16 review): body-yaw cast-turn and the swing anim pulse must agree
// Remote prevPos cache (per ghost Entity). Pruned every frame (a vanished remote = a despawn).
@@ -85,6 +86,8 @@ static readonly FastAnimatorParameter k_ComboStep = new FastAnimatorParameter(
NetworkTick serverTick = SystemAPI.TryGetSingleton<NetworkTime>(out var nt) ? nt.ServerTick : default;
// Ability blob for the per-class special read (Cone -> slam anim); default(BlobAssetReference) if absent.
var abilityBlob = SystemAPI.TryGetSingleton<AbilityDatabase>(out var adbSingleton) ? adbSingleton.Value : default;
// 07-18 combat idle: swing-recency window (the same wrap-safe SwingActive predicate as the anim pulse, wider). 60Hz sim ticks.
uint combatIdleTicks = (uint)math.max(1f, FeelConfig.CombatIdleHoldSec * 60f);
// 07-16 banking: signed facing turn rate -> Bank (-1..1, + = bank right, leaning INTO the turn).
// Sampled main-thread (a single local player), smoothed so the additive pose eases in and out.
@@ -115,6 +118,7 @@ static readonly FastAnimatorParameter k_ComboStep = new FastAnimatorParameter(
comboStep = k_ComboStep, isCone = k_IsCone, abilityDb = abilityBlob,
serverTick = serverTick, attackTicks = k_AttackAnimTicks,
bank = k_Bank, bankValue = _bankSmoothed,
inCombat = k_InCombat, combatIdleTicks = combatIdleTicks,
strideScale = k_StrideScale, trudgeNaturalSpeed = math.max(0.5f, FeelConfig.TrudgeNaturalSpeed),
runNaturalSpeed = math.max(0.5f, FeelConfig.RunNaturalSpeed),
};
@@ -131,6 +135,7 @@ static readonly FastAnimatorParameter k_ComboStep = new FastAnimatorParameter(
dt = dt,
strideScale = k_StrideScale, trudgeNaturalSpeed = math.max(0.5f, FeelConfig.TrudgeNaturalSpeed),
runNaturalSpeed = math.max(0.5f, FeelConfig.RunNaturalSpeed),
inCombat = k_InCombat, combatIdleTicks = combatIdleTicks,
prevPos = _prevPos,
seen = seen,
};
@@ -162,6 +167,8 @@ static readonly FastAnimatorParameter k_ComboStep = new FastAnimatorParameter(
{
public FastAnimatorParameter bank;
public float bankValue;
public FastAnimatorParameter inCombat;
public uint combatIdleTicks;
public FastAnimatorParameter strideScale;
public float trudgeNaturalSpeed;
public float runNaturalSpeed;
@@ -205,6 +212,7 @@ public FastAnimatorParameter moveX, moveZ, speed, isDead, isAttacking, isFiring,
if (a.HasParameter(comboStep)) a.SetParameterValue(comboStep, (int)melee.Step);
if (a.HasParameter(isCone)) a.SetParameterValue(isCone, coneL);
if (a.HasParameter(inCombat)) a.SetParameterValue(inCombat, TickWindowMath.SwingActive(melee, serverTick, combatIdleTicks));
}
}
@@ -225,6 +233,8 @@ public FastAnimatorParameter moveX, moveZ, speed, isDead, isAttacking, isFiring,
public float runNaturalSpeed;
public NativeParallelHashMap<Entity, float3> prevPos;
public NativeParallelHashSet<Entity> seen;
public FastAnimatorParameter inCombat;
public uint combatIdleTicks;
void Execute(
Entity e,
@@ -257,6 +267,7 @@ public FastAnimatorParameter moveX, moveZ, speed, isDead, isAttacking, isFiring,
a.SetParameterValue(strideScale, StrideScaleValue(math.length(vel.xz), stats.MoveSpeed, trudgeNaturalSpeed, runNaturalSpeed));
if (a.HasParameter(comboStep)) a.SetParameterValue(comboStep, (int)melee.Step);
if (a.HasParameter(isCone)) a.SetParameterValue(isCone, coneR);
if (a.HasParameter(inCombat)) a.SetParameterValue(inCombat, TickWindowMath.SwingActive(melee, serverTick, combatIdleTicks));
}
}