Combat: melee swing animation + live range slash-arc VFX (MC-4 polish)

Rukhanka swing animation: PlayerRigTools builds a procedural Root-bone PlayerMeleeSwing.anim and adds an IsAttacking param + MeleeSwing state to AC_PlayerTopDown (mirroring the enemy attack recipe -- no authored Synty Generic melee clip exists). PlayerAnimationDriveSystem pulses IsAttacking from the replicated MeleeCombo swing window (local + remote, NetworkTick wrap-safe, re-triggers per chained hit). CombatFeedbackSystem flashes a procedural cone slash-arc mesh matching the LIVE cleave range + half-angle on each swing (finisher wider/warmer) -- the arc IS the range telegraph. Addresses 'range isn't clear + no animation'.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-10 17:45:33 -07:00
parent 5b0af63a3b
commit 352bf3322d
7 changed files with 521 additions and 9 deletions
@@ -54,6 +54,12 @@ namespace ProjectM.Client
ParticleSystem _muzzleFx;
ParticleSystem _dashFx;
ParticleSystem _swingFx;
Mesh _slashMesh;
MeshRenderer _slashMr;
Material _slashMat;
Color _slashTint;
float _slashAge, _slashLife;
bool _slashActive;
AudioClip _hitClip;
AudioClip _deathClip;
AudioClip _fireClip;
@@ -95,6 +101,7 @@ namespace ProjectM.Client
_muzzleFx = MakeBurst("Muzzle", mat, new Color(0.6f, 2.4f, 3.2f), 0.12f, 5f, 0.20f, 128);
_dashFx = MakeBurst("DashWhoosh", mat, new Color(0.7f, 2.6f, 3.0f), 0.16f, 4f, 0.30f, 256);
_swingFx = MakeBurst("MeleeSwing", mat, new Color(3.0f, 2.6f, 0.9f), 0.14f, 6f, 0.28f, 256);
BuildSlash();
for (int i = 0; i < NumberPoolSize; i++)
_numbers.Add(CreateNumber());
@@ -104,6 +111,8 @@ namespace ProjectM.Client
{
if (_fxRoot != null)
Object.Destroy(_fxRoot.gameObject);
if (_slashMesh != null) Object.Destroy(_slashMesh);
if (_slashMat != null) Object.Destroy(_slashMat);
}
protected override void OnUpdate()
@@ -268,7 +277,12 @@ namespace ProjectM.Client
PlayClip(_swingClip, (Vector3)localPos, 0.45f);
PrototypeCameraRig.AddShake(0.04f * step);
int comboLen = SystemAPI.TryGetSingleton<TuningConfig>(out var tcfg) ? (int)math.clamp((int)tcfg.MeleeComboLength, 1, 3) : 3;
if (step >= comboLen) PrototypeCameraRig.PunchFov(FeelConfig.DashFovKick * 0.6f, FeelConfig.HitStopDurationMs); // finisher pop keyed off the live combo length (MC-4 review)
bool finisher = step >= comboLen;
float slashRange = tcfg.MeleeRange > 0f ? tcfg.MeleeRange : 2.6f;
float slashHalf = tcfg.MeleeConeHalfAngleRad > 0f ? tcfg.MeleeConeHalfAngleRad : 0.9f;
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)
if (finisher) PrototypeCameraRig.PunchFov(FeelConfig.DashFovKick * 0.6f, FeelConfig.HitStopDurationMs);
}
_lastLocalSwingTick = mc.SwingStartTick;
_swingTickInit = true;
@@ -277,6 +291,7 @@ namespace ProjectM.Client
UpdateProjectileTrails(cfg);
PruneVfx();
AnimateNumbers(dt, cam);
UpdateSlash(dt);
}
// ---- Authored VFX (GabrielAguiar prefabs via VFXConfig); fall back to the procedural burst ----
@@ -542,6 +557,87 @@ namespace ProjectM.Client
return ps;
}
void BuildSlash()
{
var go = new GameObject("MeleeSlashArc");
go.transform.SetParent(_fxRoot, false);
_slashMesh = new Mesh { name = "MeleeSlashArc" };
var mf = go.AddComponent<MeshFilter>();
mf.sharedMesh = _slashMesh;
_slashMr = go.AddComponent<MeshRenderer>();
_slashMat = MakeParticleMaterial();
_slashMat.name = "MeleeSlashArc";
_slashMr.sharedMaterial = _slashMat;
_slashMr.shadowCastingMode = UnityEngine.Rendering.ShadowCastingMode.Off;
_slashMr.receiveShadows = false;
_slashMr.enabled = false;
}
// Rebuild the crescent (inner->outer arc) for the LIVE cone half-angle + range, in local +Z-forward space.
void BuildSlashMesh(float range, float halfAngle)
{
const int seg = 16;
float r1 = Mathf.Max(0.4f, range);
float r0 = r1 * 0.45f;
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(-halfAngle, halfAngle, 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);
float across = 1f - Mathf.Abs(i / (float)seg * 2f - 1f); // 0 at edges, 1 at centre
cols[i * 2] = new Color(1f, 1f, 1f, 0.55f * (0.4f + 0.6f * across)); // inner brighter
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);
}
for (int i = 0; i < seg; i++)
{
int b = i * 2;
tris[i * 6 + 0] = b; tris[i * 6 + 1] = b + 1; tris[i * 6 + 2] = b + 2;
tris[i * 6 + 3] = b + 1; tris[i * 6 + 4] = b + 3; tris[i * 6 + 5] = b + 2;
}
_slashMesh.Clear();
_slashMesh.vertices = verts;
_slashMesh.colors = cols;
_slashMesh.uv = uvs;
_slashMesh.triangles = tris;
_slashMesh.RecalculateBounds();
}
// Flash a cone-shaped slash matching the LIVE melee range + half-angle, oriented along facing. The arc IS the
// range telegraph (MC-4 visual clarity): the player sees exactly how far + how wide the cleave reaches.
void TriggerSlash(Vector3 pos, float2 facing, float range, float halfAngle, bool finisher)
{
if (_slashMr == null || _slashMat == null) return;
BuildSlashMesh(range, halfAngle);
Vector3 f = math.lengthsq(facing) > 1e-6f ? new Vector3(facing.x, 0f, facing.y).normalized : Vector3.forward;
var tr = _slashMr.transform;
tr.position = pos + Vector3.up * 0.12f;
tr.rotation = Quaternion.LookRotation(f, Vector3.up);
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)
_slashLife = finisher ? 0.26f : 0.17f;
_slashAge = 0f;
_slashActive = true;
_slashMat.color = _slashTint;
_slashMr.enabled = true;
}
void UpdateSlash(float dt)
{
if (!_slashActive || _slashMr == null) return;
_slashAge += dt;
float u = _slashAge / Mathf.Max(1e-4f, _slashLife);
if (u >= 1f) { _slashActive = false; _slashMr.enabled = false; return; }
var c = _slashTint; c.a = 1f - u; _slashMat.color = c;
_slashMr.transform.localScale = Vector3.one * (1f + u * 0.12f);
}
static void EmitAt(ParticleSystem ps, Vector3 pos, int count)
{
if (ps == null) return;
@@ -6,7 +6,7 @@ using Unity.Burst;
using Unity.Collections;
using Unity.Entities;
using Unity.Mathematics;
using Unity.NetCode; // GhostOwnerIsLocal
using Unity.NetCode; // GhostOwnerIsLocal, NetworkTime, NetworkTick
using Unity.Transforms; // LocalTransform
using Unity.CharacterController; // KinematicCharacterBody
@@ -20,13 +20,17 @@ namespace ProjectM.Client
/// deliberate, documented exception to the project's "all juice = PresentationSystemGroup" rule -- the
/// params MUST be set before Rukhanka's same-frame controller eval (see DR-022).
///
/// Drives MoveX/MoveZ/Speed/IsDead from locomotion + IsDead, plus IsAttacking (MC-4) pulsed from the
/// replicated <see cref="MeleeCombo"/> swing window so the AC_PlayerTopDown MeleeSwing state plays per swing.
///
/// Two paths:
/// LOCAL (owner-predicted, GhostOwnerIsLocal ENABLED): realized CC RelativeVelocity (wall-aware).
/// REMOTE (interpolated, GhostOwnerIsLocal DISABLED): KinematicCharacterBody is NOT a [GhostField] and
/// the CC processor is owner-only, so RelativeVelocity stays baked-zero on remotes -> derive
/// planar velocity from replicated LocalTransform.Position deltas. PlayerFacing.Direction is a
/// [GhostField] (valid on remotes); EffectiveCharacterStats.MoveSpeed is derived locally each
/// tick by StatRecomputeSystem (present on remotes). Cache prevPos per Entity, prune each frame.
/// tick by StatRecomputeSystem (present on remotes). MeleeCombo replicates so teammates' swings
/// animate too. Cache prevPos per Entity, prune each frame.
/// </summary>
[WorldSystemFilter(WorldSystemFilterFlags.LocalSimulation | WorldSystemFilterFlags.ClientSimulation)]
[UpdateBefore(typeof(RukhankaAnimationSystemGroup))]
@@ -35,10 +39,16 @@ namespace ProjectM.Client
{
// Perfect-hash keys, built once (managed string ctor). Names MUST match AC_PlayerTopDown.controller
// parameter names exactly. Immutable readonly hashes -> domain-reload safe.
static readonly FastAnimatorParameter k_MoveX = new FastAnimatorParameter("MoveX");
static readonly FastAnimatorParameter k_MoveZ = new FastAnimatorParameter("MoveZ");
static readonly FastAnimatorParameter k_Speed = new FastAnimatorParameter("Speed");
static readonly FastAnimatorParameter k_IsDead = new FastAnimatorParameter("IsDead");
static readonly FastAnimatorParameter k_MoveX = new FastAnimatorParameter("MoveX");
static readonly FastAnimatorParameter k_MoveZ = new FastAnimatorParameter("MoveZ");
static readonly FastAnimatorParameter k_Speed = new FastAnimatorParameter("Speed");
static readonly FastAnimatorParameter k_IsDead = new FastAnimatorParameter("IsDead");
static readonly FastAnimatorParameter k_IsAttacking = new FastAnimatorParameter("IsAttacking");
// 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.
const uint k_AttackAnimTicks = 13;
// Remote prevPos cache (per ghost Entity). Pruned every frame (a vanished remote = a despawn).
NativeParallelHashMap<Entity, float3> _prevPos;
@@ -58,10 +68,14 @@ namespace ProjectM.Client
float dt = SystemAPI.Time.DeltaTime; // wall-frame delta is correct for presentation
if (dt < 1e-5f) dt = 1e-5f;
// Current authoritative tick for the swing-window check (default = invalid -> IsAttacking stays false).
NetworkTick serverTick = SystemAPI.TryGetSingleton<NetworkTime>(out var nt) ? nt.ServerTick : default;
// --- LOCAL owner (CC velocity) ---
var localJob = new LocalDriveJob
{
moveX = k_MoveX, moveZ = k_MoveZ, speed = k_Speed, isDead = k_IsDead,
isAttacking = k_IsAttacking, serverTick = serverTick, attackTicks = k_AttackAnimTicks,
};
Dependency = localJob.ScheduleParallel(Dependency);
@@ -70,6 +84,7 @@ namespace ProjectM.Client
var remoteJob = new RemoteDriveJob
{
moveX = k_MoveX, moveZ = k_MoveZ, speed = k_Speed, isDead = k_IsDead,
isAttacking = k_IsAttacking, serverTick = serverTick, attackTicks = k_AttackAnimTicks,
dt = dt,
prevPos = _prevPos,
seen = seen,
@@ -88,6 +103,16 @@ namespace ProjectM.Client
if (!seen.Contains(keys[i])) _prevPos.Remove(keys[i]);
}
// True while now is within [SwingStartTick, SwingStartTick + animTicks) -- a per-swing pulse that re-triggers
// on each chained swing. NetworkTick arithmetic (wrap-safe). Presentation-only, Burst-safe.
static bool SwingActive(in MeleeCombo mc, NetworkTick serverTick, uint animTicks)
{
if (mc.SwingStartTick == 0u || !serverTick.IsValid) return false;
var start = new NetworkTick(mc.SwingStartTick);
var end = new NetworkTick(TickUtil.NonZero(mc.SwingStartTick + animTicks));
return start.IsValid && end.IsValid && !start.IsNewerThan(serverTick) && end.IsNewerThan(serverTick);
}
// LOCAL: GhostOwnerIsLocal ENABLED -> exactly the owned player. WithPresent<Dead> so alive
// (Dead-disabled) players are visited. NOTE: GhostOwnerIsLocal as a WithAll filter respects the
// enable bit; do NOT take it as an `in` parameter (that matches on presence -> drives remotes too).
@@ -96,7 +121,9 @@ namespace ProjectM.Client
[WithPresent(typeof(Dead))]
partial struct LocalDriveJob : IJobEntity
{
public FastAnimatorParameter moveX, moveZ, speed, isDead;
public FastAnimatorParameter moveX, moveZ, speed, isDead, isAttacking;
public NetworkTick serverTick;
public uint attackTicks;
void Execute(
AnimatorControllerParameterIndexTableComponent indexTable,
@@ -104,11 +131,13 @@ namespace ProjectM.Client
in PlayerFacing facing,
in EffectiveCharacterStats stats,
in KinematicCharacterBody body,
in MeleeCombo melee,
EnabledRefRO<Dead> dead)
{
var a = new AnimatorParametersAspect(parametersArr, indexTable);
float3 p = AnimParamMath.LocomotionParams(body.RelativeVelocity, facing.Direction, stats.MoveSpeed);
Write(ref a, p, dead.ValueRO, moveX, moveZ, speed, isDead);
if (a.HasParameter(isAttacking)) a.SetParameterValue(isAttacking, SwingActive(melee, serverTick, attackTicks));
}
}
@@ -119,7 +148,9 @@ namespace ProjectM.Client
[WithPresent(typeof(Dead))]
partial struct RemoteDriveJob : IJobEntity
{
public FastAnimatorParameter moveX, moveZ, speed, isDead;
public FastAnimatorParameter moveX, moveZ, speed, isDead, isAttacking;
public NetworkTick serverTick;
public uint attackTicks;
public float dt;
public NativeParallelHashMap<Entity, float3> prevPos;
public NativeParallelHashSet<Entity> seen;
@@ -131,6 +162,7 @@ namespace ProjectM.Client
in LocalTransform xform,
in PlayerFacing facing,
in EffectiveCharacterStats stats,
in MeleeCombo melee,
EnabledRefRO<Dead> dead)
{
seen.Add(e);
@@ -143,6 +175,7 @@ namespace ProjectM.Client
var a = new AnimatorParametersAspect(parametersArr, indexTable);
float3 p = AnimParamMath.LocomotionParams(vel, facing.Direction, stats.MoveSpeed);
Write(ref a, p, dead.ValueRO, moveX, moveZ, speed, isDead);
if (a.HasParameter(isAttacking)) a.SetParameterValue(isAttacking, SwingActive(melee, serverTick, attackTicks));
}
}