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
@@ -0,0 +1,28 @@
using Unity.Mathematics;
namespace ProjectM.Simulation
{
/// <summary>07-20 melee feel forks (guidelines G2.1): the per-step CONTACT tick — how many ticks after
/// SwingStartTick the blade actually lands. The server cleave resolves THEN (not at swing start), so the
/// damage cone is a true record of the visible hit. Fractions derive from the AC_PlayerTopDown clip contact
/// seconds at the wired state speeds (PlayerRigTools swaps table: A hit@0.267s, B hit@0.167s, C hit@0.333s
/// @60Hz): step2 = x0.625 (10t at the default), step3+ (finisher) = x1.25 (20t). Knob = TuningConfig
/// .MeleeContactTicks (default 16); <b>0 = IMMEDIATE</b> (legacy same-tick resolve; the mechanics tests pin 0).
/// COUPLING (documented, not clamped): keep contact(step3) &lt; CastFacingTicks (26) and &lt; MeleeRecoverTicks —
/// the cast-turn/anim window and chain cadence assume the hit lands inside both. The cleave itself is safe at
/// ANY knob value (MeleeComboSystem flushes a still-armed pending cleave before overwriting the swing).</summary>
public static class MeleeTiming
{
/// <summary>Server tick-batching slack: the buffered-attack validity window past the unlock edge must
/// cover Netcode's default MaxSimulationStepBatchSize (4) or an edge press drops under load (design
/// review wf_000bc247 C1).</summary>
public const uint BatchSlackTicks = 4;
public static uint ContactTicks(byte step, float contactKnob)
{
if (contactKnob <= 0f) return 0u; // 0 = immediate (legacy same-tick resolve)
float f = step == 2 ? 0.625f : step >= 3 ? 1.25f : 1f;
return (uint)math.max(0f, math.round(contactKnob * f));
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 4354c9c60079e0a44a0985ef7065d574
@@ -46,6 +46,14 @@ namespace ProjectM.Simulation
public float MeleeFinisherMult;
public float MeleeComboLength;
// 07-20 melee feel forks (guidelines G2/G7, design review wf_000bc247):
/// <summary>Finisher REACH multiplier (reach honesty G2.2) — MeleeFinisherMult keeps damage/recover/knockback only.</summary>
public float MeleeFinisherRangeMult;
/// <summary>Attack input-buffer window (ticks before unlock a press is remembered). 0 = off.</summary>
public float MeleeBufferTicks;
/// <summary>Base contact delay (ticks from swing start to the blade landing; per-step via MeleeTiming). 0 = IMMEDIATE (legacy same-tick cleave).</summary>
public float MeleeContactTicks;
// EB-1 fortress aggro: a <1 multiplier on a Husk's SQUARED distance to a structure (so structures are
// preferred targets); a closer player 'in the way' still wins. Read server-side by EnemyAISystem.
public float StructureAggroWeight;
@@ -76,15 +84,18 @@ namespace ProjectM.Simulation
ChargerLungeDurationTicks = 18f,
ChargerWhiffStaggerTicks = 36f,
GruntWindupTicks = Tuning.AttackWindupTicks, // canonical Grunt-windup source (TelegraphTests couples to it)
MeleeDamage = 18f,
MeleeRange = 2.6f,
MeleeDamage = 30f, // 07-19 heavy-weapon retime: bigger hit, slower cadence (DPS ~held: 18/0.27s -> 30/0.50s)
MeleeRange = 2.2f, // 07-20 G2.2 reach honesty: axe tip ~1.8m + a 0.4m player-favoring margin (was 2.6)
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)
MeleeRecoverTicks = 30f, // heavy-swing lock/recovery (~0.50s; 07-19 weighty-melee retime)
MeleeChainGraceTicks = 24f, // window after the lock to chain the next hit (~0.4s)
MeleeSwingMoveScale = 0.35f, // movement-commit while swinging (0..1)
MeleeKnockbackSpeed = 6f,
MeleeFinisherMult = 1.8f, // finisher (last hit) scales dmg/range/recover/knockback
MeleeFinisherMult = 1.5f, // finisher (last hit) scales dmg/range/recover/knockback (07-19: 1.8 -> 1.5, recover was ballooning at the heavy cadence)
MeleeComboLength = 3f, // light, light, finisher
MeleeFinisherRangeMult = 1.25f, // 07-20 G2.2: finisher REACH (damage/recover/knockback keep MeleeFinisherMult)
MeleeBufferTicks = 8f, // 07-20 G7: attack press remembered in the last ~0.13s of a lock (0 = off)
MeleeContactTicks = 16f, // 07-20 G2.1: blade lands ~0.27s after swing start (per-step via MeleeTiming; 0 = legacy immediate)
StructureAggroWeight = 0.7f, // EB-1: <1 prefers structures (fortress aggro); live-tunable
StaggerKnockbackSpeed = 7f, // B2 poise: kb.Speed >= this interrupts windups/lunges; below = nudge only
SeparationMaxSpeed = 3f, // B1: max separation push (units/s) so soft-collision can't fling
@@ -116,6 +127,9 @@ namespace ProjectM.Simulation
case TuningKnob.CastTurnRateDeg:
case TuningKnob.MoveSharpness:
case TuningKnob.SeparationMaxSpeed:
case TuningKnob.MeleeFinisherRangeMult:
case TuningKnob.MeleeBufferTicks: // 07-20: 0 = buffer OFF sentinel (must survive ClampKnob — review C10)
case TuningKnob.MeleeContactTicks: // 07-20: 0 = IMMEDIATE sentinel (legacy same-tick cleave)
return math.max(0f, value);
// tick knobs: >= 1 (a 0 tick count is degenerate; a 0 i-frame window divides-by-zero in DashSystem).
// FinalSiegeMultiplier also lands here on purpose — a final siege should never be < 1x a normal one.
@@ -155,6 +169,9 @@ namespace ProjectM.Simulation
case TuningKnob.TurnRateDeg: c.TurnRateDeg = value; break;
case TuningKnob.CastTurnRateDeg: c.CastTurnRateDeg = value; break;
case TuningKnob.MoveSharpness: c.MoveSharpness = value; break;
case TuningKnob.MeleeBufferTicks: c.MeleeBufferTicks = value; break;
case TuningKnob.MeleeFinisherRangeMult: c.MeleeFinisherRangeMult = value; break;
case TuningKnob.MeleeContactTicks: c.MeleeContactTicks = value; break;
// unknown index -> no-op (matches the no-default switch convention in DebugCommandReceiveSystem)
}
@@ -190,6 +207,9 @@ namespace ProjectM.Simulation
case TuningKnob.TurnRateDeg: return c.TurnRateDeg;
case TuningKnob.CastTurnRateDeg: return c.CastTurnRateDeg;
case TuningKnob.MoveSharpness: return c.MoveSharpness;
case TuningKnob.MeleeBufferTicks: return c.MeleeBufferTicks;
case TuningKnob.MeleeFinisherRangeMult: return c.MeleeFinisherRangeMult;
case TuningKnob.MeleeContactTicks: return c.MeleeContactTicks;
default: return 0f;
}
@@ -223,6 +243,9 @@ namespace ProjectM.Simulation
TurnRateDeg = c.TurnRateDeg,
CastTurnRateDeg = c.CastTurnRateDeg,
MoveSharpness = c.MoveSharpness,
MeleeBufferTicks = c.MeleeBufferTicks,
MeleeFinisherRangeMult = c.MeleeFinisherRangeMult,
MeleeContactTicks = c.MeleeContactTicks,
};
@@ -254,6 +277,9 @@ namespace ProjectM.Simulation
TurnRateDeg = r.TurnRateDeg,
CastTurnRateDeg = r.CastTurnRateDeg,
MoveSharpness = r.MoveSharpness,
MeleeBufferTicks = r.MeleeBufferTicks,
MeleeFinisherRangeMult = r.MeleeFinisherRangeMult,
MeleeContactTicks = r.MeleeContactTicks,
};
}
@@ -289,9 +315,12 @@ namespace ProjectM.Simulation
public const byte TurnRateDeg = 26;
public const byte CastTurnRateDeg = 27;
public const byte MoveSharpness = 28;
public const byte MeleeBufferTicks = 29; // 07-20 G7 (0 = off sentinel)
public const byte MeleeFinisherRangeMult = 30; // 07-20 G2.2
public const byte MeleeContactTicks = 31; // 07-20 G2.1 (0 = immediate sentinel)
/// <summary>Knob count (overlay iteration bound).</summary>
public const byte Count = 29;
public const byte Count = 32;
}
/// <summary>
@@ -327,6 +356,9 @@ namespace ProjectM.Simulation
public float TurnRateDeg;
public float CastTurnRateDeg;
public float MoveSharpness;
public float MeleeBufferTicks; // 07-20 melee feel forks (dev-protocol bump: rebuild both peers)
public float MeleeFinisherRangeMult;
public float MeleeContactTicks;
}
// NOTE: appending fields = a DEV-PROTOCOL BUMP (RpcCollection hash) — rebuild both peers together.
@@ -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 &lt; 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)