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:
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user