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
@@ -64,6 +64,9 @@ namespace ProjectM.Client
bool _slashActive;
float _slashRange, _slashHalf; // live cone geometry re-sampled each frame for the per-frame sweep rebuild
int _slashSweepSign = 1; // alternate sweep direction per combo step (reads as alternating strikes)
Mesh _smearMesh; MeshRenderer _smearMr; Material _smearMat; // 07-20 G2.3: blade-smear ribbon (leading-edge band at blade height)
uint _pendingConnectTick; // 07-20 G2.1 (review C14): the local swing's CONTACT tick; connect cues fire THEN (0 = none)
int _pendingConnectStep;
double _lastHoldTime; // C4: last hit-stop hold time (throttle so a horde wipe doesn't stutter)
// Remote teammates' melee cleave arcs (deferred-items pass, co-op): one pooled slash renderer per remote
@@ -150,6 +153,8 @@ namespace ProjectM.Client
Object.Destroy(_fxRoot.gameObject);
if (_slashMesh != null) Object.Destroy(_slashMesh);
if (_slashMat != null) Object.Destroy(_slashMat);
if (_smearMesh != null) Object.Destroy(_smearMesh);
if (_smearMat != null) Object.Destroy(_smearMat);
foreach (var kv in _remoteSlashes)
{
@@ -441,45 +446,44 @@ namespace ProjectM.Client
PrototypeCameraRig.AddShake(0.04f * step);
int comboLen = SystemAPI.TryGetSingleton<TuningConfig>(out var tcfg) ? (int)math.clamp((int)tcfg.MeleeComboLength, 1, 3) : 3;
bool finisher = step >= comboLen;
float slashRange = tcfg.MeleeRange > 0f ? tcfg.MeleeRange : 2.6f;
float slashRange = tcfg.MeleeRange > 0f ? tcfg.MeleeRange : 2.2f;
float slashHalf = tcfg.MeleeConeHalfAngleRad > 0f ? tcfg.MeleeConeHalfAngleRad : 0.9f;
// Slice-2 deferred reach fix: the SERVER folds per-player StatModifiers into melee range
// (class seed +0.8, boons, meta tiers) the arc must sweep the REAL reach, not the base.
// (class seed +0.8, boons, meta tiers) -- the arc must sweep the REAL reach, not the base.
if (EntityManager.HasBuffer<StatModifier>(_localPlayer))
slashRange = math.max(0f, StatMath.Apply(slashRange, StatTarget.MeleeRange,
EntityManager.GetBuffer<StatModifier>(_localPlayer, true)));
if (finisher) slashRange *= tcfg.MeleeFinisherMult > 0f ? tcfg.MeleeFinisherMult : 1.8f;
// MC-4 connect-vs-whiff: client-side cone overlap over the cached enemy snapshot gives an IMMEDIATE
// "you bit" read (the authoritative server damage spark/number arrives a few ticks later).
bool connected = false; Vector3 nearestHit = (Vector3)localPos; float ndist = float.MaxValue;
float cosHalf = Mathf.Cos(slashHalf);
float2 fdir = new float2(face.x, face.z);
foreach (var kv in _cache)
if (finisher) slashRange *= tcfg.MeleeFinisherRangeMult > 0f ? tcfg.MeleeFinisherRangeMult : 1.25f; // 07-20 G2.2: REACH-only finisher mult
// 07-20 G2.4 (review C13): the sweep-reveal (60% of life) completes AT the LIVE contact tick, so
// the drawn edge and the damage moment stay one object under knob tuning.
uint contactTicks = MeleeTiming.ContactTicks((byte)step, math.max(0f, tcfg.MeleeContactTicks));
float arcLife = Mathf.Max(finisher ? 0.5f : 0.34f, (contactTicks / 60f) / 0.6f);
TriggerSlash((Vector3)localPos, new float2(face.x, face.z), slashRange, slashHalf, step, comboLen, false, arcLife);
// 07-20 G2.1/G5 (review C14): the CONNECT package (bite burst/thunk/kick/rumble + the finisher
// hold) fires at the CONTACT tick -- not here at the swing edge, where damage doesn't exist yet.
_pendingConnectStep = step;
if (contactTicks == 0u)
{
if (!kv.Value.IsEnemy) continue;
if (MeleeConeMath.InCone(localPos, fdir, slashRange, cosHalf, kv.Value.Pos))
{
float d2 = math.distancesq(localPos, kv.Value.Pos);
if (d2 < ndist) { ndist = d2; nearestHit = (Vector3)kv.Value.Pos; connected = true; }
}
_pendingConnectTick = 0u;
EvaluateMeleeConnect(localPos, step, comboLen); // knob 0 = legacy same-tick connect
}
TriggerSlash((Vector3)localPos, new float2(face.x, face.z), slashRange, slashHalf, step, comboLen, connected); // sweeps + ramps + brightens on connect
if (connected)
else
{
Burst(_hitFx, cfg != null ? cfg.Hit : null, nearestHit + Vector3.up * 0.7f, FeelConfig.HitBurstCount);
PlayClip(_meleeConnectClip, nearestHit, FeelConfig.MeleeConnectVolume);
PrototypeCameraRig.PunchFov(FeelConfig.MeleeConnectFovKick, FeelConfig.HitStopDurationMs);
if (FeelConfig.RumbleEnabled && AimPresentation.Scheme == 1)
RumbleUtil.Pulse(FeelConfig.RumbleHit * 0.6f, FeelConfig.RumbleHit, FeelConfig.RumbleDurationSec);
_pendingConnectTick = TickUtil.NonZero(mc.SwingStartTick + contactTicks);
}
if (finisher)
{
PrototypeCameraRig.PunchFov(FeelConfig.DashFovKick * 0.6f, FeelConfig.HitStopDurationMs);
TryHold(); // C4: a beat of crunch on the combo finisher (the deliberate payoff hit)
}
}
_lastLocalSwingTick = mc.SwingStartTick;
_swingTickInit = true;
// 07-20: fire the deferred connect package when the blade lands (contact tick reached; wrap-safe).
if (_pendingConnectTick != 0u
&& SystemAPI.TryGetSingleton<NetworkTime>(out var meleeNt) && meleeNt.ServerTick.IsValid
&& !new NetworkTick(_pendingConnectTick).IsNewerThan(meleeNt.ServerTick))
{
int cLen = SystemAPI.TryGetSingleton<TuningConfig>(out var ct2) ? (int)math.clamp((int)ct2.MeleeComboLength, 1, 3) : 3;
EvaluateMeleeConnect(localPos, _pendingConnectStep, cLen);
_pendingConnectTick = 0u;
}
}
@@ -799,30 +803,51 @@ namespace ProjectM.Client
_slashMr.shadowCastingMode = UnityEngine.Rendering.ShadowCastingMode.Off;
_slashMr.receiveShadows = false;
_slashMr.enabled = false;
// 07-20 G2.3: the blade-smear ribbon -- a narrow band at blade height tracking the arc's leading edge
// (child of the arc GO so the transform follows for free). Geometry via BuildSlashInto's angular-window
// mode (review C18: one shared builder, no drift from arc retones).
var smearGo = new GameObject("MeleeSmearRibbon");
smearGo.transform.SetParent(go.transform, false);
_smearMesh = new Mesh { name = "MeleeSmearRibbon" };
smearGo.AddComponent<MeshFilter>().sharedMesh = _smearMesh;
_smearMr = smearGo.AddComponent<MeshRenderer>();
_smearMat = MakeParticleMaterial();
_smearMat.name = "MeleeSmearRibbon";
_smearMr.sharedMaterial = _smearMat;
_smearMr.shadowCastingMode = UnityEngine.Rendering.ShadowCastingMode.Off;
_smearMr.receiveShadows = false;
_smearMr.enabled = false;
}
// Rebuild the crescent (inner->outer arc) for the LIVE cone half-angle + range, in local +Z-forward space.
// `reveal` (0..1) sweeps the arc open from one edge (sweepSign) toward the other so the cleave reads directional.
void BuildSlashInto(Mesh mesh, float range, float halfAngle, float reveal, int sweepSign)
// 07-20 G2.3 extension: innerFrac/y/angularWindowRad let the SAME builder cut the blade-smear ribbon
// (a narrow band trailing the leading edge at blade height) -- defaults reproduce the classic ground arc.
void BuildSlashInto(Mesh mesh, float range, float halfAngle, float reveal, int sweepSign,
float innerFrac = 0.45f, float y = 0f, float angularWindowRad = 0f)
{
const int seg = 16;
float r1 = Mathf.Max(0.4f, range);
float r0 = r1 * 0.45f;
float r0 = r1 * innerFrac;
float aStart = sweepSign >= 0 ? -halfAngle : halfAngle; // trailing edge
float aFull = sweepSign >= 0 ? halfAngle : -halfAngle; // far edge
float aEnd = Mathf.Lerp(aStart, aFull, Mathf.Clamp01(reveal)); // current leading edge of the sweep
float aBase = aStart;
if (angularWindowRad > 0f) // smear mode: only the trailing band behind the leading edge
aBase = sweepSign >= 0 ? Mathf.Max(aStart, aEnd - angularWindowRad) : Mathf.Min(aStart, aEnd + angularWindowRad);
var verts = new Vector3[(seg + 1) * 2];
var cols = new Color[(seg + 1) * 2];
var uvs = new Vector2[(seg + 1) * 2];
var tris = new int[seg * 6];
for (int i = 0; i <= seg; i++)
{
float a = Mathf.Lerp(aStart, aEnd, i / (float)seg);
float a = Mathf.Lerp(aBase, aEnd, i / (float)seg);
float sx = Mathf.Sin(a), cz = Mathf.Cos(a);
verts[i * 2] = new Vector3(sx * r0, 0f, cz * r0);
verts[i * 2 + 1] = new Vector3(sx * r1, 0f, cz * r1);
verts[i * 2] = new Vector3(sx * r0, y, cz * r0);
verts[i * 2 + 1] = new Vector3(sx * r1, y, cz * r1);
float lead = i / (float)seg; // 0 trailing -> 1 leading edge (brightest at the travelling blade)
cols[i * 2] = new Color(1f, 1f, 1f, 0.55f * (0.2f + 0.8f * lead)); // inner, brightest at the leading edge
cols[i * 2] = new Color(1f, 1f, 1f, 0.35f * (0.2f + 0.8f * lead)); // 07-19 retone: a wake, not a laser // inner, brightest at the leading edge
cols[i * 2 + 1] = new Color(1f, 1f, 1f, 0f); // outer rim fades out
uvs[i * 2] = new Vector2(0.5f, 0.5f);
uvs[i * 2 + 1] = new Vector2(0.5f, 0.5f);
@@ -845,17 +870,68 @@ namespace ProjectM.Client
// the range telegraph (MC-4 clarity) AND now SWEEPS across + ramps per combo step so the swing reads as a
// directional, escalating cleave rather than a static flash.
// C4: fire a brief presentation-only hit-stop hold, throttled (never Time.timeScale; the sim keeps ticking).
void TryHold()
void TryHold(int frames = 0) // 07-20 G5 (review C17): ONE hold path, per-verb frames (0 = the light default)
{
if (!FeelConfig.HitStopFreezeEnabled) return;
double now = SystemAPI.Time.ElapsedTime;
if (now - _lastHoldTime < 0.22) return;
_lastHoldTime = now;
PrototypeCameraRig.Hold(FeelConfig.HitStopMaxFrames);
PrototypeCameraRig.Hold(frames > 0 ? frames : FeelConfig.HitStopMaxFrames);
}
// 07-20 G2.1/G5 (review C14): the melee CONNECT package, fired when the blade actually LANDS. Recomputes
// aim + reach LIVE at the contact tick (the cast-turn kept steering since the swing edge) over the cached
// enemy snapshot; brightens the still-sweeping arc on a bite; the finisher hold is the G5 heavy beat.
void EvaluateMeleeConnect(float3 localPos, int step, int comboLen)
{
if (_localPlayer == Entity.Null || !EntityManager.Exists(_localPlayer)) return;
var cfg = VFXConfig.Instance;
bool finisher = step >= comboLen;
float range = 2.2f, half = 0.9f, finRange = 1.25f;
if (SystemAPI.TryGetSingleton<TuningConfig>(out var tcfg))
{
if (tcfg.MeleeRange > 0f) range = tcfg.MeleeRange;
if (tcfg.MeleeConeHalfAngleRad > 0f) half = tcfg.MeleeConeHalfAngleRad;
if (tcfg.MeleeFinisherRangeMult > 0f) finRange = tcfg.MeleeFinisherRangeMult;
}
if (EntityManager.HasBuffer<StatModifier>(_localPlayer))
range = math.max(0f, StatMath.Apply(range, StatTarget.MeleeRange,
EntityManager.GetBuffer<StatModifier>(_localPlayer, true)));
if (finisher) range *= finRange;
float2 fdir = new float2(0f, 1f);
if (EntityManager.HasComponent<PlayerFacing>(_localPlayer) && EntityManager.HasComponent<PlayerInput>(_localPlayer))
fdir = FacingMath.ResolveAim(
EntityManager.GetComponentData<PlayerInput>(_localPlayer).Aim,
EntityManager.GetComponentData<PlayerFacing>(_localPlayer).Direction);
bool connected = false; Vector3 nearestHit = (Vector3)localPos; float ndist = float.MaxValue;
float cosHalf = Mathf.Cos(half);
foreach (var kv in _cache)
{
if (!kv.Value.IsEnemy) continue;
if (MeleeConeMath.InCone(localPos, fdir, range, cosHalf, kv.Value.Pos))
{
float d2 = math.distancesq(localPos, kv.Value.Pos);
if (d2 < ndist) { ndist = d2; nearestHit = (Vector3)kv.Value.Pos; connected = true; }
}
}
if (connected)
{
Burst(_hitFx, cfg != null ? cfg.Hit : null, nearestHit + Vector3.up * 0.7f, FeelConfig.HitBurstCount);
PlayClip(_meleeConnectClip, nearestHit, FeelConfig.MeleeConnectVolume);
PrototypeCameraRig.PunchFov(FeelConfig.MeleeConnectFovKick, FeelConfig.HitStopDurationMs);
if (FeelConfig.RumbleEnabled && AimPresentation.Scheme == 1)
RumbleUtil.Pulse(FeelConfig.RumbleHit * 0.6f, FeelConfig.RumbleHit, FeelConfig.RumbleDurationSec);
if (_slashActive) _slashTint *= 1.4f; // the bite brighten, now AT the bite (UpdateSlash re-reads the tint)
}
if (finisher)
{
PrototypeCameraRig.PunchFov(FeelConfig.DashFovKick * 0.6f, FeelConfig.HitStopDurationMs);
TryHold(FeelConfig.FinisherHoldFrames); // G5: the heavy payoff beat, at contact
}
}
void TriggerSlash(Vector3 pos, float2 facing, float range, float halfAngle, int step, int comboLen, bool connected)
void TriggerSlash(Vector3 pos, float2 facing, float range, float halfAngle, int step, int comboLen, bool connected, float lifeOverride = 0f)
{
if (_slashMr == null || _slashMat == null) return;
bool finisher = step >= comboLen;
@@ -870,14 +946,18 @@ void TriggerSlash(Vector3 pos, float2 facing, float range, float halfAngle, int
// Per-step ramp so the chain visibly builds to the finisher (the steps were byte-identical before).
float t = comboLen > 1 ? math.saturate((step - 1) / (float)(comboLen - 1)) : 1f;
_slashTint = finisher
? new Color(3.4f, 2.4f, 0.7f) // finisher: warm HDR flash
: Color.Lerp(new Color(1.2f, 1.9f, 2.6f), new Color(1.9f, 2.8f, 3.4f), t); // cool, brighter per step
if (connected) _slashTint *= 1.6f; // brighter arc on a confirmed bite (the immediate "you hit" read)
_slashLife = finisher ? 0.28f : Mathf.Lerp(0.15f, 0.20f, t);
? new Color(1.7f, 1.25f, 0.5f) // finisher: lamp-warm accent (07-19 retone)
: Color.Lerp(new Color(0.5f, 0.9f, 1.05f), new Color(0.8f, 1.3f, 1.4f), t); // dim bioluminescent teal, brighter per step
if (connected) _slashTint *= 1.4f; // brighter arc on a confirmed bite (the immediate "you hit" read)
_slashTint *= Mathf.Max(0f, FeelConfig.MeleeArcIntensity); // 07-19 LANTERN retone: live dimmer knob
_slashLife = lifeOverride > 0f ? lifeOverride : (finisher ? 0.50f : Mathf.Lerp(0.30f, 0.38f, t)); // 07-20: melee passes a knob-derived life (reveal ends AT contact)
_slashAge = 0f;
_slashActive = true;
_slashMat.color = _slashTint;
_slashMr.enabled = true;
// 07-19 vibe: a few slow bubbles shed along the cleave path (the murk answers the swing).
if (_bubbleFx != null && FeelConfig.MeleeArcBubbles > 0)
EmitColored(_bubbleFx, pos + f * (range * 0.55f) + Vector3.up * 0.35f, FeelConfig.MeleeArcBubbles, new Color(0.75f, 0.95f, 1f, 0.8f));
}
void UpdateSlash(float dt)
@@ -885,13 +965,26 @@ void TriggerSlash(Vector3 pos, float2 facing, float range, float halfAngle, int
if (!_slashActive || _slashMr == null) return;
_slashAge += dt;
float u = _slashAge / Mathf.Max(1e-4f, _slashLife);
if (u >= 1f) { _slashActive = false; _slashMr.enabled = false; return; }
if (u >= 1f) { _slashActive = false; _slashMr.enabled = false; if (_smearMr != null) _smearMr.enabled = false; return; }
// MC-4 clarity: SWEEP the crescent open across the arc over the first ~60% of life (reads as a blade
// travelling through the cleave), then hold + fade — instead of popping the whole cone at once.
float reveal = Mathf.Clamp01(u / 0.6f);
BuildSlashInto(_slashMesh, _slashRange, _slashHalf, reveal, _slashSweepSign);
var c = _slashTint; c.a = u < 0.6f ? 1f : 1f - (u - 0.6f) / 0.4f; _slashMat.color = c;
_slashMr.transform.localScale = Vector3.one * (1f + u * 0.10f);
_slashMr.transform.localScale = Vector3.one * (1f + u * 0.06f); // 07-19: subtler drift
// 07-20 G2.3: the blade-smear ribbon rides the arc's leading edge at blade height while the sweep runs
// (the reach trick: the swept edge reads FROM THE WEAPON, not as a floor decal alone).
bool smearShow = _smearMr != null && u < 0.75f;
if (_smearMr != null) _smearMr.enabled = smearShow;
if (smearShow)
{
BuildSlashInto(_smearMesh, _slashRange, _slashHalf, reveal, _slashSweepSign,
innerFrac: 0.7f, y: 0.95f, angularWindowRad: 0.25f); // thin band = streak, not a pane
var sc = _slashTint * 0.8f;
sc.a = (u < 0.6f ? 0.7f : 0.7f * (1f - (u - 0.6f) / 0.15f));
_smearMat.color = sc;
}
}
// Remote teammates' melee cleave arcs (deferred-items pass, co-op readability): the local player's swing
@@ -907,7 +1000,8 @@ void TriggerSlash(Vector3 pos, float2 facing, float range, float halfAngle, int
int comboLen = SystemAPI.TryGetSingleton<TuningConfig>(out var tcfg) ? (int)math.clamp((int)tcfg.MeleeComboLength, 1, 3) : 3;
float baseRange = tcfg.MeleeRange > 0f ? tcfg.MeleeRange : 2.6f;
float baseHalf = tcfg.MeleeConeHalfAngleRad > 0f ? tcfg.MeleeConeHalfAngleRad : 0.9f;
float finisherMult = tcfg.MeleeFinisherMult > 0f ? tcfg.MeleeFinisherMult : 1.8f;
float finisherMult = tcfg.MeleeFinisherRangeMult > 0f ? tcfg.MeleeFinisherRangeMult : 1.25f; // 07-20 G2.2: REACH-only finisher mult
float remoteContactKnob = math.max(0f, tcfg.MeleeContactTicks); // 07-20 G2.4: remote sweeps track contact too
_remoteSeen.Clear();
foreach (var (xf, facing, mc, entity) in
@@ -930,7 +1024,8 @@ void TriggerSlash(Vector3 pos, float2 facing, float range, float halfAngle, int
rs.Half = baseHalf;
rs.SweepSign = (step % 2 == 0) ? -1 : 1;
rs.Tint = FeelConfig.RemoteSlashColor * (finisher ? 1.5f : 1f);
rs.Life = finisher ? 0.26f : 0.18f;
rs.Life = Mathf.Max(finisher ? 0.50f : 0.34f,
(MeleeTiming.ContactTicks((byte)step, remoteContactKnob) / 60f) / 0.6f); // 07-20: reveal ends AT contact (knob-aware, review C13)
rs.Age = 0f;
rs.Active = true;
BuildSlashInto(rs.Mesh, rs.Range, rs.Half, 0f, rs.SweepSign);
@@ -955,7 +1050,7 @@ void TriggerSlash(Vector3 pos, float2 facing, float range, float halfAngle, int
float reveal = Mathf.Clamp01(u / 0.6f);
BuildSlashInto(rs.Mesh, rs.Range, rs.Half, reveal, rs.SweepSign);
var c = rs.Tint; c.a = u < 0.6f ? 1f : 1f - (u - 0.6f) / 0.4f; rs.Mat.color = c;
tr.localScale = Vector3.one * (1f + u * 0.10f);
tr.localScale = Vector3.one * (1f + u * 0.06f);
}
}
}