From 352bf3322dcd152b9019fe183b000098b14f23f6 Mon Sep 17 00:00:00 2001 From: Luis Gonzalez Date: Wed, 10 Jun 2026 17:45:33 -0700 Subject: [PATCH] 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) --- .../Animation/AC_PlayerTopDown.controller | 87 ++++++++ .../_Project/Animation/PlayerMeleeSwing.anim | 201 ++++++++++++++++++ .../Animation/PlayerMeleeSwing.anim.meta | 8 + .../Presentation/CombatFeedbackSystem.cs | 98 ++++++++- .../PlayerAnimationDriveSystem.cs | 49 ++++- .../_Project/Scripts/Editor/PlayerRigTools.cs | 85 ++++++++ .../Scripts/Editor/PlayerRigTools.cs.meta | 2 + 7 files changed, 521 insertions(+), 9 deletions(-) create mode 100644 Assets/_Project/Animation/PlayerMeleeSwing.anim create mode 100644 Assets/_Project/Animation/PlayerMeleeSwing.anim.meta create mode 100644 Assets/_Project/Scripts/Editor/PlayerRigTools.cs create mode 100644 Assets/_Project/Scripts/Editor/PlayerRigTools.cs.meta diff --git a/Assets/_Project/Animation/AC_PlayerTopDown.controller b/Assets/_Project/Animation/AC_PlayerTopDown.controller index b1ac684c2..c8254075b 100644 --- a/Assets/_Project/Animation/AC_PlayerTopDown.controller +++ b/Assets/_Project/Animation/AC_PlayerTopDown.controller @@ -27,6 +27,31 @@ AnimatorState: m_MirrorParameter: m_CycleOffsetParameter: m_TimeParameter: +--- !u!1101 &-7833896337878999491 +AnimatorStateTransition: + m_ObjectHideFlags: 1 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_Name: + m_Conditions: + - m_ConditionMode: 1 + m_ConditionEvent: IsAttacking + m_EventTreshold: 0 + m_DstStateMachine: {fileID: 0} + m_DstState: {fileID: 4550302468649108848} + m_Solo: 0 + m_Mute: 0 + m_IsExit: 0 + serializedVersion: 3 + m_TransitionDuration: 0.05 + m_TransitionOffset: 0 + m_ExitTime: 0.75 + m_HasExitTime: 0 + m_HasFixedDuration: 1 + m_InterruptionSource: 0 + m_OrderedInterruption: 1 + m_CanTransitionToSelf: 0 --- !u!1101 &-1505565898738729052 AnimatorStateTransition: m_ObjectHideFlags: 1 @@ -172,6 +197,12 @@ AnimatorController: m_DefaultInt: 0 m_DefaultBool: 0 m_Controller: {fileID: 9100000} + - m_Name: IsAttacking + m_Type: 4 + m_DefaultFloat: 0 + m_DefaultInt: 0 + m_DefaultBool: 0 + m_Controller: {fileID: 9100000} m_AnimatorLayers: - serializedVersion: 5 m_Name: Base Layer @@ -237,6 +268,33 @@ AnimatorState: m_MirrorParameter: m_CycleOffsetParameter: m_TimeParameter: +--- !u!1102 &4550302468649108848 +AnimatorState: + serializedVersion: 6 + m_ObjectHideFlags: 1 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_Name: MeleeSwing + m_Speed: 1 + m_CycleOffset: 0 + m_Transitions: + - {fileID: 8638400773933187460} + m_StateMachineBehaviours: [] + m_Position: {x: 50, y: 50, z: 0} + m_IKOnFeet: 0 + m_WriteDefaultValues: 1 + m_Mirror: 0 + m_SpeedParameterActive: 0 + m_MirrorParameterActive: 0 + m_CycleOffsetParameterActive: 0 + m_TimeParameterActive: 0 + m_Motion: {fileID: 7400000, guid: c819793eb6a73914b9875b1fe58b8626, type: 2} + m_Tag: + m_SpeedParameter: + m_MirrorParameter: + m_CycleOffsetParameter: + m_TimeParameter: --- !u!1101 &5014557051428573475 AnimatorStateTransition: m_ObjectHideFlags: 1 @@ -280,9 +338,13 @@ AnimatorStateMachine: - serializedVersion: 1 m_State: {fileID: 1998880016702917871} m_Position: {x: 270, y: 130, z: 0} + - serializedVersion: 1 + m_State: {fileID: 4550302468649108848} + m_Position: {x: 305, y: 195, z: 0} m_ChildStateMachines: [] m_AnyStateTransitions: - {fileID: 5014557051428573475} + - {fileID: -7833896337878999491} m_EntryTransitions: [] m_StateMachineTransitions: {} m_StateMachineBehaviours: [] @@ -318,3 +380,28 @@ AnimatorState: m_MirrorParameter: m_CycleOffsetParameter: m_TimeParameter: +--- !u!1101 &8638400773933187460 +AnimatorStateTransition: + m_ObjectHideFlags: 1 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_Name: + m_Conditions: + - m_ConditionMode: 2 + m_ConditionEvent: IsAttacking + m_EventTreshold: 0 + m_DstStateMachine: {fileID: 0} + m_DstState: {fileID: 0} + m_Solo: 0 + m_Mute: 0 + m_IsExit: 1 + serializedVersion: 3 + m_TransitionDuration: 0.1 + m_TransitionOffset: 0 + m_ExitTime: 0.9 + m_HasExitTime: 0 + m_HasFixedDuration: 1 + m_InterruptionSource: 0 + m_OrderedInterruption: 1 + m_CanTransitionToSelf: 1 diff --git a/Assets/_Project/Animation/PlayerMeleeSwing.anim b/Assets/_Project/Animation/PlayerMeleeSwing.anim new file mode 100644 index 000000000..4c06781fb --- /dev/null +++ b/Assets/_Project/Animation/PlayerMeleeSwing.anim @@ -0,0 +1,201 @@ +%YAML 1.1 +%TAG !u! tag:unity3d.com,2011: +--- !u!74 &7400000 +AnimationClip: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_Name: PlayerMeleeSwing + serializedVersion: 7 + m_Legacy: 0 + m_Compressed: 0 + m_UseHighQualityCurve: 1 + m_RotationCurves: [] + m_CompressedRotationCurves: [] + m_EulerCurves: + - curve: + serializedVersion: 2 + m_Curve: + - serializedVersion: 3 + time: 0 + value: {x: 0, y: 0, z: 0} + inSlope: {x: 0, y: 0, z: 0} + outSlope: {x: 0, y: 0, z: 0} + tangentMode: 0 + weightedMode: 0 + inWeight: {x: 0, y: 0, z: 0.33333334} + outWeight: {x: 0, y: 0, z: 0.33333334} + - serializedVersion: 3 + time: 0.06 + value: {x: 6.297375, y: -22, z: 0} + inSlope: {x: 167.93002, y: 0, z: 0} + outSlope: {x: 167.93002, y: 0, z: 0} + tangentMode: 0 + weightedMode: 0 + inWeight: {x: 0.33333334, y: 0, z: 0.33333334} + outWeight: {x: 0.33333334, y: 0, z: 0.33333334} + - serializedVersion: 3 + time: 0.14 + value: {x: 16, y: 28.176, z: 0} + inSlope: {x: 0, y: 537.60004, z: 0} + outSlope: {x: 0, y: 537.60004, z: 0} + tangentMode: 0 + weightedMode: 0 + inWeight: {x: 0, y: 0.33333334, z: 0.33333334} + outWeight: {x: 0, y: 0.33333334, z: 0.33333334} + - serializedVersion: 3 + time: 0.16 + value: {x: 15.4513035, y: 34, z: 0} + inSlope: {x: -52.67489, y: 0, z: 0} + outSlope: {x: -52.67489, y: 0, z: 0} + tangentMode: 0 + weightedMode: 0 + inWeight: {x: 0.33333334, y: 0, z: 0.33333334} + outWeight: {x: 0.33333334, y: 0, z: 0.33333334} + - serializedVersion: 3 + time: 0.32 + value: {x: 0, y: 0, z: 0} + inSlope: {x: 0, y: 0, z: 0} + outSlope: {x: 0, y: 0, z: 0} + tangentMode: 0 + weightedMode: 0 + inWeight: {x: 0, y: 0, z: 0.33333334} + outWeight: {x: 0, y: 0, z: 0.33333334} + m_PreInfinity: 2 + m_PostInfinity: 2 + m_RotationOrder: 4 + path: Root + m_PositionCurves: [] + m_ScaleCurves: [] + m_FloatCurves: [] + m_PPtrCurves: [] + m_SampleRate: 30 + m_WrapMode: 0 + m_Bounds: + m_Center: {x: 0, y: 0, z: 0} + m_Extent: {x: 0, y: 0, z: 0} + m_ClipBindingConstant: + genericBindings: + - serializedVersion: 2 + path: 3066451557 + attribute: 4 + script: {fileID: 0} + typeID: 4 + customType: 4 + isPPtrCurve: 0 + isIntCurve: 0 + isSerializeReferenceCurve: 0 + pptrCurveMapping: [] + m_AnimationClipSettings: + serializedVersion: 2 + m_AdditiveReferencePoseClip: {fileID: 0} + m_AdditiveReferencePoseTime: 0 + m_StartTime: 0 + m_StopTime: 0.32 + m_OrientationOffsetY: 0 + m_Level: 0 + m_CycleOffset: 0 + m_HasAdditiveReferencePose: 0 + m_LoopTime: 0 + m_LoopBlend: 0 + m_LoopBlendOrientation: 0 + m_LoopBlendPositionY: 0 + m_LoopBlendPositionXZ: 0 + m_KeepOriginalOrientation: 0 + m_KeepOriginalPositionY: 1 + m_KeepOriginalPositionXZ: 0 + m_HeightFromFeet: 0 + m_Mirror: 0 + m_EditorCurves: + - serializedVersion: 2 + curve: + serializedVersion: 2 + m_Curve: + - serializedVersion: 3 + time: 0 + value: 0 + inSlope: 0 + outSlope: 0 + tangentMode: 0 + weightedMode: 0 + inWeight: 0 + outWeight: 0 + - serializedVersion: 3 + time: 0.06 + value: -22 + inSlope: 0 + outSlope: 0 + tangentMode: 0 + weightedMode: 0 + inWeight: 0 + outWeight: 0 + - serializedVersion: 3 + time: 0.16 + value: 34 + inSlope: 0 + outSlope: 0 + tangentMode: 0 + weightedMode: 0 + inWeight: 0 + outWeight: 0 + - serializedVersion: 3 + time: 0.32 + value: 0 + inSlope: 0 + outSlope: 0 + tangentMode: 0 + weightedMode: 0 + inWeight: 0 + outWeight: 0 + m_PreInfinity: 2 + m_PostInfinity: 2 + m_RotationOrder: 4 + attribute: localEulerAnglesRaw.y + path: Root + classID: 4 + script: {fileID: 0} + flags: 16 + - serializedVersion: 2 + curve: + serializedVersion: 2 + m_Curve: + - serializedVersion: 3 + time: 0 + value: 0 + inSlope: 0 + outSlope: 0 + tangentMode: 0 + weightedMode: 0 + inWeight: 0 + outWeight: 0 + - serializedVersion: 3 + time: 0.14 + value: 16 + inSlope: 0 + outSlope: 0 + tangentMode: 0 + weightedMode: 0 + inWeight: 0 + outWeight: 0 + - serializedVersion: 3 + time: 0.32 + value: 0 + inSlope: 0 + outSlope: 0 + tangentMode: 0 + weightedMode: 0 + inWeight: 0 + outWeight: 0 + m_PreInfinity: 2 + m_PostInfinity: 2 + m_RotationOrder: 4 + attribute: localEulerAnglesRaw.x + path: Root + classID: 4 + script: {fileID: 0} + flags: 16 + m_EulerEditorCurves: [] + m_HasGenericRootTransform: 0 + m_HasMotionFloatCurves: 0 + m_Events: [] diff --git a/Assets/_Project/Animation/PlayerMeleeSwing.anim.meta b/Assets/_Project/Animation/PlayerMeleeSwing.anim.meta new file mode 100644 index 000000000..0ab6505e9 --- /dev/null +++ b/Assets/_Project/Animation/PlayerMeleeSwing.anim.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: c819793eb6a73914b9875b1fe58b8626 +NativeFormatImporter: + externalObjects: {} + mainObjectFileID: 7400000 + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/_Project/Scripts/Client/Presentation/CombatFeedbackSystem.cs b/Assets/_Project/Scripts/Client/Presentation/CombatFeedbackSystem.cs index f0dccc5dc..cb13555ac 100644 --- a/Assets/_Project/Scripts/Client/Presentation/CombatFeedbackSystem.cs +++ b/Assets/_Project/Scripts/Client/Presentation/CombatFeedbackSystem.cs @@ -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(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(); + mf.sharedMesh = _slashMesh; + _slashMr = go.AddComponent(); + _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; diff --git a/Assets/_Project/Scripts/Client/Presentation/PlayerAnimationDriveSystem.cs b/Assets/_Project/Scripts/Client/Presentation/PlayerAnimationDriveSystem.cs index 0e97a40c4..e54034307 100644 --- a/Assets/_Project/Scripts/Client/Presentation/PlayerAnimationDriveSystem.cs +++ b/Assets/_Project/Scripts/Client/Presentation/PlayerAnimationDriveSystem.cs @@ -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 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. /// [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 _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(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 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) { 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 prevPos; public NativeParallelHashSet seen; @@ -131,6 +162,7 @@ namespace ProjectM.Client in LocalTransform xform, in PlayerFacing facing, in EffectiveCharacterStats stats, + in MeleeCombo melee, EnabledRefRO 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)); } } diff --git a/Assets/_Project/Scripts/Editor/PlayerRigTools.cs b/Assets/_Project/Scripts/Editor/PlayerRigTools.cs new file mode 100644 index 000000000..3118a9e48 --- /dev/null +++ b/Assets/_Project/Scripts/Editor/PlayerRigTools.cs @@ -0,0 +1,85 @@ +using UnityEditor; +using UnityEditor.Animations; +using UnityEngine; + +namespace ProjectM.EditorTools +{ + /// + /// MC-4 — builds the player's procedural MELEE SWING clip and adds the IsAttacking param + MeleeSwing state to + /// AC_PlayerTopDown. Mirrors EnemyRigTools' attack recipe (a Root-bone procedural clip — no authored Synty + /// Generic melee-swing clip exists; the slash-arc VFX in CombatFeedbackSystem carries the blade read). The clip + /// keys ONLY Root rotation (a yaw twist into the swing + a slight forward pitch), leaving the rig's authored + /// Root Y offset (feet-on-ground) untouched. PlayerAnimationDriveSystem drives IsAttacking from MeleeCombo. + /// Idempotent / re-runnable (menu: ProjectM/Animation). + /// + public static class PlayerRigTools + { + const string SwingClip = "Assets/_Project/Animation/PlayerMeleeSwing.anim"; + const string PlayerController = "Assets/_Project/Animation/AC_PlayerTopDown.controller"; + + [MenuItem("ProjectM/Animation/Player - Build Melee Swing")] + public static void BuildPlayerMeleeSwing() + { + // Horizontal-cleave read: yaw the Root (wind back -> swing across -> recover) + a slight forward pitch. + // Root is the top of the Synty Generic rig; locomotion clips don't key Root, so no conflict and it + // returns to the authored pose on exit. Rotation-only (no position) so the baked Root Y offset stays. + var clip = new AnimationClip { frameRate = 30f }; + var yaw = new AnimationCurve( + new Keyframe(0f, 0f), new Keyframe(0.06f, -22f), new Keyframe(0.16f, 34f), new Keyframe(0.32f, 0f)); + var pitch = new AnimationCurve( + new Keyframe(0f, 0f), new Keyframe(0.14f, 16f), new Keyframe(0.32f, 0f)); + AnimationUtility.SetEditorCurve(clip, EditorCurveBinding.FloatCurve("Root", typeof(Transform), "localEulerAnglesRaw.y"), yaw); + AnimationUtility.SetEditorCurve(clip, EditorCurveBinding.FloatCurve("Root", typeof(Transform), "localEulerAnglesRaw.x"), pitch); + var s = AnimationUtility.GetAnimationClipSettings(clip); + s.loopTime = false; + AnimationUtility.SetAnimationClipSettings(clip, s); + AssetDatabase.DeleteAsset(SwingClip); + AssetDatabase.CreateAsset(clip, SwingClip); + AssetDatabase.SaveAssets(); + + var ac = AssetDatabase.LoadAssetAtPath(PlayerController); + if (ac == null) { Debug.LogError($"[PlayerRigTools] Controller missing: {PlayerController}"); return; } + + if (!HasParam(ac, "IsAttacking")) + ac.AddParameter("IsAttacking", AnimatorControllerParameterType.Bool); + + var sm = ac.layers[0].stateMachine; + var swingClipAsset = AssetDatabase.LoadAssetAtPath(SwingClip); + + var swing = FindState(sm, "MeleeSwing"); + if (swing == null) + { + swing = sm.AddState("MeleeSwing"); + swing.writeDefaultValues = true; + + var toSwing = sm.AddAnyStateTransition(swing); + toSwing.hasExitTime = false; + toSwing.duration = 0.05f; + toSwing.canTransitionToSelf = false; + toSwing.AddCondition(AnimatorConditionMode.If, 0f, "IsAttacking"); + + var fromSwing = swing.AddExitTransition(); + fromSwing.hasExitTime = false; + fromSwing.duration = 0.10f; + fromSwing.AddCondition(AnimatorConditionMode.IfNot, 0f, "IsAttacking"); + } + swing.motion = swingClipAsset; // (re)point at the freshly-built clip + + EditorUtility.SetDirty(ac); + AssetDatabase.SaveAssets(); + Debug.Log("[PlayerRigTools] AC_PlayerTopDown: IsAttacking + MeleeSwing state built; PlayerMeleeSwing.anim created."); + } + + static bool HasParam(AnimatorController ac, string name) + { + foreach (var p in ac.parameters) if (p.name == name) return true; + return false; + } + + static AnimatorState FindState(AnimatorStateMachine sm, string name) + { + foreach (var c in sm.states) if (c.state.name == name) return c.state; + return null; + } + } +} diff --git a/Assets/_Project/Scripts/Editor/PlayerRigTools.cs.meta b/Assets/_Project/Scripts/Editor/PlayerRigTools.cs.meta new file mode 100644 index 000000000..5a277dab4 --- /dev/null +++ b/Assets/_Project/Scripts/Editor/PlayerRigTools.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 8727eff958d5ae94c9cf4acbfd2b0220 \ No newline at end of file