Melee clarity: the swing-arc now SWEEPS across + ramps per combo step (reads as a directional escalating cleave)

Operator: "the melee ability needs visual clarity, on what it does." The cleave is instant (kept — operator chose
instant + VFX), and the arc already matched the exact cone range/half-angle — but it popped whole then faded
(read as a flash) and all 3 combo steps looked byte-identical.

Client-only, observe-only (PresentationSystemGroup), no sim/netcode change, rollback-safe by construction:
- SWEEP: BuildSlashMesh takes a reveal fraction + sweep sign; UpdateSlash rebuilds the crescent each frame so the
  blade wipes across the arc over the first ~60% of life, then holds + fades. Leading edge is brightest. Sweep
  direction alternates per combo step -> reads as alternating strikes.
- PER-STEP RAMP: TriggerSlash takes step + comboLen; tint/brightness/life ramp per link so the chain visibly
  builds to the warm-HDR finisher (steps were indistinguishable before). Facing is already snapped at the swing
  edge.

Compiles clean, no runtime exceptions (per-frame 33-vert rebuild is negligible). The on-screen feel is the
operator's eyes. Deferred to a feel follow-up (they share the enemy-cache / damage-edge code path): connect-vs-
whiff flash, co-op remote-swing rendering, and enemy hit-flash. Investigation: wf_c6c87dc5-9c3 (melee lane).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-25 23:13:17 -07:00
parent 09183cc139
commit ca38c2b16d
@@ -60,6 +60,8 @@ namespace ProjectM.Client
Color _slashTint; Color _slashTint;
float _slashAge, _slashLife; float _slashAge, _slashLife;
bool _slashActive; 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)
Material _dangerMat; Material _dangerMat;
readonly Dictionary<Entity, GameObject> _dangerZones = new(); readonly Dictionary<Entity, GameObject> _dangerZones = new();
readonly HashSet<Entity> _dangerSeen = new(); readonly HashSet<Entity> _dangerSeen = new();
@@ -329,7 +331,7 @@ namespace ProjectM.Client
float slashRange = tcfg.MeleeRange > 0f ? tcfg.MeleeRange : 2.6f; float slashRange = tcfg.MeleeRange > 0f ? tcfg.MeleeRange : 2.6f;
float slashHalf = tcfg.MeleeConeHalfAngleRad > 0f ? tcfg.MeleeConeHalfAngleRad : 0.9f; float slashHalf = tcfg.MeleeConeHalfAngleRad > 0f ? tcfg.MeleeConeHalfAngleRad : 0.9f;
if (finisher) slashRange *= tcfg.MeleeFinisherMult > 0f ? tcfg.MeleeFinisherMult : 1.8f; if (finisher) slashRange *= tcfg.MeleeFinisherMult > 0f ? tcfg.MeleeFinisherMult : 1.8f;
TriggerSlash((Vector3)localPos, new float2(face.x, face.z), slashRange, slashHalf, finisher); // the arc IS the range telegraph (MC-4 visual clarity) TriggerSlash((Vector3)localPos, new float2(face.x, face.z), slashRange, slashHalf, step, comboLen); // arc sweeps + ramps per step (MC-4 clarity)
if (finisher) PrototypeCameraRig.PunchFov(FeelConfig.DashFovKick * 0.6f, FeelConfig.HitStopDurationMs); if (finisher) PrototypeCameraRig.PunchFov(FeelConfig.DashFovKick * 0.6f, FeelConfig.HitStopDurationMs);
} }
_lastLocalSwingTick = mc.SwingStartTick; _lastLocalSwingTick = mc.SwingStartTick;
@@ -624,24 +626,28 @@ namespace ProjectM.Client
} }
// Rebuild the crescent (inner->outer arc) for the LIVE cone half-angle + range, in local +Z-forward space. // Rebuild the crescent (inner->outer arc) for the LIVE cone half-angle + range, in local +Z-forward space.
void BuildSlashMesh(float range, float halfAngle) // `reveal` (0..1) sweeps the arc open from one edge (sweepSign) toward the other so the cleave reads directional.
void BuildSlashMesh(float range, float halfAngle, float reveal, int sweepSign)
{ {
const int seg = 16; const int seg = 16;
float r1 = Mathf.Max(0.4f, range); float r1 = Mathf.Max(0.4f, range);
float r0 = r1 * 0.45f; float r0 = r1 * 0.45f;
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
var verts = new Vector3[(seg + 1) * 2]; var verts = new Vector3[(seg + 1) * 2];
var cols = new Color[(seg + 1) * 2]; var cols = new Color[(seg + 1) * 2];
var uvs = new Vector2[(seg + 1) * 2]; var uvs = new Vector2[(seg + 1) * 2];
var tris = new int[seg * 6]; var tris = new int[seg * 6];
for (int i = 0; i <= seg; i++) for (int i = 0; i <= seg; i++)
{ {
float a = Mathf.Lerp(-halfAngle, halfAngle, i / (float)seg); float a = Mathf.Lerp(aStart, aEnd, i / (float)seg);
float sx = Mathf.Sin(a), cz = Mathf.Cos(a); float sx = Mathf.Sin(a), cz = Mathf.Cos(a);
verts[i * 2] = new Vector3(sx * r0, 0f, cz * r0); verts[i * 2] = new Vector3(sx * r0, 0f, cz * r0);
verts[i * 2 + 1] = new Vector3(sx * r1, 0f, cz * r1); verts[i * 2 + 1] = new Vector3(sx * r1, 0f, cz * r1);
float across = 1f - Mathf.Abs(i / (float)seg * 2f - 1f); // 0 at edges, 1 at centre 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.4f + 0.6f * across)); // inner brighter cols[i * 2] = new Color(1f, 1f, 1f, 0.55f * (0.2f + 0.8f * lead)); // inner, brightest at the leading edge
cols[i * 2 + 1] = new Color(1f, 1f, 1f, 0f); // outer rim fades out 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] = new Vector2(0.5f, 0.5f);
uvs[i * 2 + 1] = new Vector2(0.5f, 0.5f); uvs[i * 2 + 1] = new Vector2(0.5f, 0.5f);
} }
@@ -659,19 +665,27 @@ namespace ProjectM.Client
_slashMesh.RecalculateBounds(); _slashMesh.RecalculateBounds();
} }
// Flash a cone-shaped slash matching the LIVE melee range + half-angle, oriented along facing. The arc IS the // Trigger a cone-shaped slash matching the LIVE melee range + half-angle, oriented along facing. The arc IS
// range telegraph (MC-4 visual clarity): the player sees exactly how far + how wide the cleave reaches. // the range telegraph (MC-4 clarity) AND now SWEEPS across + ramps per combo step so the swing reads as a
void TriggerSlash(Vector3 pos, float2 facing, float range, float halfAngle, bool finisher) // directional, escalating cleave rather than a static flash.
void TriggerSlash(Vector3 pos, float2 facing, float range, float halfAngle, int step, int comboLen)
{ {
if (_slashMr == null || _slashMat == null) return; if (_slashMr == null || _slashMat == null) return;
BuildSlashMesh(range, halfAngle); bool finisher = step >= comboLen;
_slashRange = range; _slashHalf = halfAngle;
_slashSweepSign = (step % 2 == 0) ? -1 : 1; // alternate L->R / R->L per swing -> reads as alternating strikes
BuildSlashMesh(range, halfAngle, 0f, _slashSweepSign); // start closed; UpdateSlash sweeps it open
Vector3 f = math.lengthsq(facing) > 1e-6f ? new Vector3(facing.x, 0f, facing.y).normalized : Vector3.forward; Vector3 f = math.lengthsq(facing) > 1e-6f ? new Vector3(facing.x, 0f, facing.y).normalized : Vector3.forward;
var tr = _slashMr.transform; var tr = _slashMr.transform;
tr.position = pos + Vector3.up * 0.12f; tr.position = pos + Vector3.up * 0.12f;
tr.rotation = Quaternion.LookRotation(f, Vector3.up); tr.rotation = Quaternion.LookRotation(f, Vector3.up);
tr.localScale = Vector3.one; tr.localScale = Vector3.one;
_slashTint = finisher ? new Color(3.2f, 2.3f, 0.7f) : new Color(1.6f, 2.4f, 3.2f); // finisher warm / light cool (HDR -> bloom) // Per-step ramp so the chain visibly builds to the finisher (the steps were byte-identical before).
_slashLife = finisher ? 0.26f : 0.17f; 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
_slashLife = finisher ? 0.28f : Mathf.Lerp(0.15f, 0.20f, t);
_slashAge = 0f; _slashAge = 0f;
_slashActive = true; _slashActive = true;
_slashMat.color = _slashTint; _slashMat.color = _slashTint;
@@ -684,8 +698,12 @@ namespace ProjectM.Client
_slashAge += dt; _slashAge += dt;
float u = _slashAge / Mathf.Max(1e-4f, _slashLife); 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; return; }
var c = _slashTint; c.a = 1f - u; _slashMat.color = c; // MC-4 clarity: SWEEP the crescent open across the arc over the first ~60% of life (reads as a blade
_slashMr.transform.localScale = Vector3.one * (1f + u * 0.12f); // travelling through the cleave), then hold + fade — instead of popping the whole cone at once.
float reveal = Mathf.Clamp01(u / 0.6f);
BuildSlashMesh(_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);
} }
// Enemy attack TELEGRAPH (MC-4 clarity): while an enemy's AttackWindup counts down, paint a red ground danger // Enemy attack TELEGRAPH (MC-4 clarity): while an enemy's AttackWindup counts down, paint a red ground danger