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:
@@ -42,7 +42,7 @@ AnimatorStateTransition:
|
||||
m_Mute: 0
|
||||
m_IsExit: 0
|
||||
serializedVersion: 3
|
||||
m_TransitionDuration: 0.03
|
||||
m_TransitionDuration: 0.06
|
||||
m_TransitionOffset: 0
|
||||
m_ExitTime: 0.75
|
||||
m_HasExitTime: 0
|
||||
@@ -58,7 +58,7 @@ AnimatorState:
|
||||
m_PrefabInstance: {fileID: 0}
|
||||
m_PrefabAsset: {fileID: 0}
|
||||
m_Name: SpecialSlam
|
||||
m_Speed: 1
|
||||
m_Speed: 1.5
|
||||
m_CycleOffset: 0
|
||||
m_Transitions:
|
||||
- {fileID: -1718360560449278314}
|
||||
@@ -71,7 +71,7 @@ AnimatorState:
|
||||
m_MirrorParameterActive: 0
|
||||
m_CycleOffsetParameterActive: 0
|
||||
m_TimeParameterActive: 0
|
||||
m_Motion: {fileID: -623603649343465320, guid: da76a86fe339c6444a0c1c040a9b5105, type: 3}
|
||||
m_Motion: {fileID: -1787871631005901691, guid: f3800f1bd189abc469fcc7c374371a91, type: 3}
|
||||
m_Tag:
|
||||
m_SpeedParameter:
|
||||
m_MirrorParameter:
|
||||
@@ -124,7 +124,7 @@ AnimatorStateTransition:
|
||||
m_Mute: 0
|
||||
m_IsExit: 0
|
||||
serializedVersion: 3
|
||||
m_TransitionDuration: 0.04
|
||||
m_TransitionDuration: 0.07
|
||||
m_TransitionOffset: 0
|
||||
m_ExitTime: 0.75
|
||||
m_HasExitTime: 0
|
||||
@@ -149,7 +149,7 @@ AnimatorStateTransition:
|
||||
m_Mute: 0
|
||||
m_IsExit: 1
|
||||
serializedVersion: 3
|
||||
m_TransitionDuration: 0.12
|
||||
m_TransitionDuration: 0.15
|
||||
m_TransitionOffset: 0
|
||||
m_ExitTime: 0.9
|
||||
m_HasExitTime: 0
|
||||
@@ -202,7 +202,7 @@ AnimatorStateTransition:
|
||||
m_Mute: 0
|
||||
m_IsExit: 0
|
||||
serializedVersion: 3
|
||||
m_TransitionDuration: 0.03
|
||||
m_TransitionDuration: 0.06
|
||||
m_TransitionOffset: 0
|
||||
m_ExitTime: 0.75
|
||||
m_HasExitTime: 0
|
||||
@@ -365,7 +365,7 @@ AnimatorStateTransition:
|
||||
m_Mute: 0
|
||||
m_IsExit: 1
|
||||
serializedVersion: 3
|
||||
m_TransitionDuration: 0.12
|
||||
m_TransitionDuration: 0.15
|
||||
m_TransitionOffset: 0
|
||||
m_ExitTime: 0.9
|
||||
m_HasExitTime: 0
|
||||
@@ -390,7 +390,7 @@ AnimatorStateTransition:
|
||||
m_Mute: 0
|
||||
m_IsExit: 1
|
||||
serializedVersion: 3
|
||||
m_TransitionDuration: 0.12
|
||||
m_TransitionDuration: 0.15
|
||||
m_TransitionOffset: 0
|
||||
m_ExitTime: 0.9
|
||||
m_HasExitTime: 0
|
||||
@@ -633,7 +633,7 @@ AnimatorState:
|
||||
m_PrefabInstance: {fileID: 0}
|
||||
m_PrefabAsset: {fileID: 0}
|
||||
m_Name: Swing3
|
||||
m_Speed: 1
|
||||
m_Speed: 1.1
|
||||
m_CycleOffset: 0
|
||||
m_Transitions:
|
||||
- {fileID: 7246856984932168808}
|
||||
@@ -646,7 +646,7 @@ AnimatorState:
|
||||
m_MirrorParameterActive: 0
|
||||
m_CycleOffsetParameterActive: 0
|
||||
m_TimeParameterActive: 0
|
||||
m_Motion: {fileID: -623603649343465320, guid: 594271330704f684e85012825546489b, type: 3}
|
||||
m_Motion: {fileID: -8890989884591690541, guid: c9e0b6900401fcb42980df96b84db76c, type: 3}
|
||||
m_Tag:
|
||||
m_SpeedParameter:
|
||||
m_MirrorParameter:
|
||||
@@ -727,6 +727,12 @@ AnimatorController:
|
||||
m_DefaultInt: 0
|
||||
m_DefaultBool: 0
|
||||
m_Controller: {fileID: 9100000}
|
||||
- m_Name: InCombat
|
||||
m_Type: 4
|
||||
m_DefaultFloat: 0
|
||||
m_DefaultInt: 0
|
||||
m_DefaultBool: 0
|
||||
m_Controller: {fileID: 9100000}
|
||||
m_AnimatorLayers:
|
||||
- serializedVersion: 5
|
||||
m_Name: Base Layer
|
||||
@@ -786,7 +792,7 @@ AnimatorState:
|
||||
m_MirrorParameterActive: 0
|
||||
m_CycleOffsetParameterActive: 0
|
||||
m_TimeParameterActive: 0
|
||||
m_Motion: {fileID: -623603649343465320, guid: 42d545cebb3c8b54ebedba77f9435c49, type: 3}
|
||||
m_Motion: {fileID: 5800342443961765143, guid: 95697c3218923dd43aa160c110bfbc79, type: 3}
|
||||
m_Tag:
|
||||
m_SpeedParameter:
|
||||
m_MirrorParameter:
|
||||
@@ -868,7 +874,7 @@ AnimatorStateTransition:
|
||||
m_Mute: 0
|
||||
m_IsExit: 0
|
||||
serializedVersion: 3
|
||||
m_TransitionDuration: 0.03
|
||||
m_TransitionDuration: 0.06
|
||||
m_TransitionOffset: 0
|
||||
m_ExitTime: 0.75
|
||||
m_HasExitTime: 0
|
||||
@@ -896,12 +902,37 @@ AnimatorState:
|
||||
m_MirrorParameterActive: 0
|
||||
m_CycleOffsetParameterActive: 0
|
||||
m_TimeParameterActive: 0
|
||||
m_Motion: {fileID: -623603649343465320, guid: 65d8851d683f4784b9f64c2f37c952c7, type: 3}
|
||||
m_Motion: {fileID: -1444214504022159124, guid: a0b8f6f252ea03c46b36b357f5ea81e5, type: 3}
|
||||
m_Tag:
|
||||
m_SpeedParameter:
|
||||
m_MirrorParameter:
|
||||
m_CycleOffsetParameter:
|
||||
m_TimeParameter:
|
||||
--- !u!1101 &3694240317838589182
|
||||
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: InCombat
|
||||
m_EventTreshold: 0
|
||||
m_DstStateMachine: {fileID: 0}
|
||||
m_DstState: {fileID: 8302574255206078140}
|
||||
m_Solo: 0
|
||||
m_Mute: 0
|
||||
m_IsExit: 0
|
||||
serializedVersion: 3
|
||||
m_TransitionDuration: 0.4
|
||||
m_TransitionOffset: 0
|
||||
m_ExitTime: 0.9
|
||||
m_HasExitTime: 0
|
||||
m_HasFixedDuration: 1
|
||||
m_InterruptionSource: 0
|
||||
m_OrderedInterruption: 1
|
||||
m_CanTransitionToSelf: 1
|
||||
--- !u!1101 &5014557051428573475
|
||||
AnimatorStateTransition:
|
||||
m_ObjectHideFlags: 1
|
||||
@@ -963,6 +994,9 @@ AnimatorStateMachine:
|
||||
- serializedVersion: 1
|
||||
m_State: {fileID: -8219464881866229938}
|
||||
m_Position: {x: 515, y: 585, z: 0}
|
||||
- serializedVersion: 1
|
||||
m_State: {fileID: 5692497731704029855}
|
||||
m_Position: {x: 550, y: 650, z: 0}
|
||||
m_ChildStateMachines: []
|
||||
m_AnyStateTransitions:
|
||||
- {fileID: 5014557051428573475}
|
||||
@@ -980,6 +1014,34 @@ AnimatorStateMachine:
|
||||
m_ExitPosition: {x: 800, y: 120, z: 0}
|
||||
m_ParentStateMachinePosition: {x: 800, y: 20, z: 0}
|
||||
m_DefaultState: {fileID: 8302574255206078140}
|
||||
--- !u!1102 &5692497731704029855
|
||||
AnimatorState:
|
||||
serializedVersion: 6
|
||||
m_ObjectHideFlags: 1
|
||||
m_CorrespondingSourceObject: {fileID: 0}
|
||||
m_PrefabInstance: {fileID: 0}
|
||||
m_PrefabAsset: {fileID: 0}
|
||||
m_Name: CombatIdle
|
||||
m_Speed: 1
|
||||
m_CycleOffset: 0
|
||||
m_Transitions:
|
||||
- {fileID: 3694240317838589182}
|
||||
- {fileID: 5811251164301445305}
|
||||
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: -3634702890504892199, guid: f574f64a099779248beea90d27b2dde0, type: 3}
|
||||
m_Tag:
|
||||
m_SpeedParameter:
|
||||
m_MirrorParameter:
|
||||
m_CycleOffsetParameter:
|
||||
m_TimeParameter:
|
||||
--- !u!1102 &5736576975501273399
|
||||
AnimatorState:
|
||||
serializedVersion: 6
|
||||
@@ -988,7 +1050,7 @@ AnimatorState:
|
||||
m_PrefabInstance: {fileID: 0}
|
||||
m_PrefabAsset: {fileID: 0}
|
||||
m_Name: Swing1
|
||||
m_Speed: 1
|
||||
m_Speed: 1.25
|
||||
m_CycleOffset: 0
|
||||
m_Transitions:
|
||||
- {fileID: -1792799526728587842}
|
||||
@@ -1001,12 +1063,37 @@ AnimatorState:
|
||||
m_MirrorParameterActive: 0
|
||||
m_CycleOffsetParameterActive: 0
|
||||
m_TimeParameterActive: 0
|
||||
m_Motion: {fileID: -623603649343465320, guid: dd80b656157b2b441b2466c7646c8441, type: 3}
|
||||
m_Motion: {fileID: -7717634560727564301, guid: 9f773338b2a96174ebe9b63870034ca3, type: 3}
|
||||
m_Tag:
|
||||
m_SpeedParameter:
|
||||
m_MirrorParameter:
|
||||
m_CycleOffsetParameter:
|
||||
m_TimeParameter:
|
||||
--- !u!1101 &5811251164301445305
|
||||
AnimatorStateTransition:
|
||||
m_ObjectHideFlags: 1
|
||||
m_CorrespondingSourceObject: {fileID: 0}
|
||||
m_PrefabInstance: {fileID: 0}
|
||||
m_PrefabAsset: {fileID: 0}
|
||||
m_Name:
|
||||
m_Conditions:
|
||||
- m_ConditionMode: 3
|
||||
m_ConditionEvent: Speed
|
||||
m_EventTreshold: 0.1
|
||||
m_DstStateMachine: {fileID: 0}
|
||||
m_DstState: {fileID: -8102868851794693864}
|
||||
m_Solo: 0
|
||||
m_Mute: 0
|
||||
m_IsExit: 0
|
||||
serializedVersion: 3
|
||||
m_TransitionDuration: 0.25
|
||||
m_TransitionOffset: 0
|
||||
m_ExitTime: 0.9
|
||||
m_HasExitTime: 0
|
||||
m_HasFixedDuration: 1
|
||||
m_InterruptionSource: 0
|
||||
m_OrderedInterruption: 1
|
||||
m_CanTransitionToSelf: 1
|
||||
--- !u!1107 &6065214076707455157
|
||||
AnimatorStateMachine:
|
||||
serializedVersion: 7
|
||||
@@ -1046,7 +1133,7 @@ AnimatorStateTransition:
|
||||
m_Mute: 0
|
||||
m_IsExit: 1
|
||||
serializedVersion: 3
|
||||
m_TransitionDuration: 0.12
|
||||
m_TransitionDuration: 0.15
|
||||
m_TransitionOffset: 0
|
||||
m_ExitTime: 0.9
|
||||
m_HasExitTime: 0
|
||||
@@ -1066,6 +1153,7 @@ AnimatorState:
|
||||
m_CycleOffset: 0
|
||||
m_Transitions:
|
||||
- {fileID: -1505565898738729052}
|
||||
- {fileID: 9125527510138141906}
|
||||
m_StateMachineBehaviours: []
|
||||
m_Position: {x: 50, y: 50, z: 0}
|
||||
m_IKOnFeet: 0
|
||||
@@ -1081,6 +1169,31 @@ AnimatorState:
|
||||
m_MirrorParameter:
|
||||
m_CycleOffsetParameter:
|
||||
m_TimeParameter:
|
||||
--- !u!1101 &9125527510138141906
|
||||
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: InCombat
|
||||
m_EventTreshold: 0
|
||||
m_DstStateMachine: {fileID: 0}
|
||||
m_DstState: {fileID: 5692497731704029855}
|
||||
m_Solo: 0
|
||||
m_Mute: 0
|
||||
m_IsExit: 0
|
||||
serializedVersion: 3
|
||||
m_TransitionDuration: 0.25
|
||||
m_TransitionOffset: 0
|
||||
m_ExitTime: 0.9
|
||||
m_HasExitTime: 0
|
||||
m_HasFixedDuration: 1
|
||||
m_InterruptionSource: 0
|
||||
m_OrderedInterruption: 1
|
||||
m_CanTransitionToSelf: 1
|
||||
--- !u!1102 &9217853296350750828
|
||||
AnimatorState:
|
||||
serializedVersion: 6
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 0d76db2ce815146419fd52c745104909
|
||||
NativeFormatImporter:
|
||||
externalObjects: {}
|
||||
mainObjectFileID: 4300000
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,77 @@
|
||||
%YAML 1.1
|
||||
%TAG !u! tag:unity3d.com,2011:
|
||||
--- !u!114 &-6263134222815092261
|
||||
MonoBehaviour:
|
||||
m_ObjectHideFlags: 11
|
||||
m_CorrespondingSourceObject: {fileID: 0}
|
||||
m_PrefabInstance: {fileID: 0}
|
||||
m_PrefabAsset: {fileID: 0}
|
||||
m_GameObject: {fileID: 0}
|
||||
m_Enabled: 1
|
||||
m_EditorHideFlags: 0
|
||||
m_Script: {fileID: 11500000, guid: d0353a89b1f911e48b9e16bdc9f2e058, type: 3}
|
||||
m_Name:
|
||||
m_EditorClassIdentifier: Unity.RenderPipelines.Universal.Editor::UnityEditor.Rendering.Universal.AssetVersion
|
||||
version: 10
|
||||
--- !u!21 &2100000
|
||||
Material:
|
||||
serializedVersion: 8
|
||||
m_ObjectHideFlags: 0
|
||||
m_CorrespondingSourceObject: {fileID: 0}
|
||||
m_PrefabInstance: {fileID: 0}
|
||||
m_PrefabAsset: {fileID: 0}
|
||||
m_Name: M_Weapon_Animated
|
||||
m_Shader: {fileID: -6465566751694194690, guid: 32ea7846f763cb340950fafed798ecf6, type: 3}
|
||||
m_Parent: {fileID: 0}
|
||||
m_ModifiedSerializedProperties: 0
|
||||
m_ValidKeywords:
|
||||
- _USE_VERTEX_COLOR
|
||||
m_InvalidKeywords: []
|
||||
m_LightmapFlags: 2
|
||||
m_EnableInstancingVariants: 0
|
||||
m_DoubleSidedGI: 0
|
||||
m_CustomRenderQueue: -1
|
||||
stringTagMap: {}
|
||||
disabledShaderPasses:
|
||||
- MOTIONVECTORS
|
||||
m_LockedProperties:
|
||||
m_SavedProperties:
|
||||
serializedVersion: 3
|
||||
m_TexEnvs:
|
||||
- _BaseColorMap:
|
||||
m_Texture: {fileID: 2800000, guid: 48dd9a7736c11b5448a74711310e2ee6, type: 3}
|
||||
m_Scale: {x: 1, y: 1}
|
||||
m_Offset: {x: 0, y: 0}
|
||||
- _MaskMap:
|
||||
m_Texture: {fileID: 0}
|
||||
m_Scale: {x: 1, y: 1}
|
||||
m_Offset: {x: 0, y: 0}
|
||||
- _NormalMap:
|
||||
m_Texture: {fileID: 0}
|
||||
m_Scale: {x: 1, y: 1}
|
||||
m_Offset: {x: 0, y: 0}
|
||||
- unity_Lightmaps:
|
||||
m_Texture: {fileID: 0}
|
||||
m_Scale: {x: 1, y: 1}
|
||||
m_Offset: {x: 0, y: 0}
|
||||
- unity_LightmapsInd:
|
||||
m_Texture: {fileID: 0}
|
||||
m_Scale: {x: 1, y: 1}
|
||||
m_Offset: {x: 0, y: 0}
|
||||
- unity_ShadowMasks:
|
||||
m_Texture: {fileID: 0}
|
||||
m_Scale: {x: 1, y: 1}
|
||||
m_Offset: {x: 0, y: 0}
|
||||
m_Ints: []
|
||||
m_Floats:
|
||||
- _DeformedMeshIndex: 0
|
||||
- _Metallic: 0
|
||||
- _QueueControl: 0
|
||||
- _QueueOffset: 0
|
||||
- _Smoothness: 0
|
||||
- _USE_VERTEX_COLOR: 1
|
||||
m_Colors:
|
||||
- _BaseColor: {r: 0.95, g: 0.9, b: 0.82, a: 1}
|
||||
- _DeformationParamsForMotionVectors: {r: 0, g: 0, b: 0, a: 0}
|
||||
m_BuildTextureStacks: []
|
||||
m_AllowLocking: 1
|
||||
@@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 53abed4a039a2944498dc0d43258baf5
|
||||
NativeFormatImporter:
|
||||
externalObjects: {}
|
||||
mainObjectFileID: 2100000
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -833,6 +833,7 @@ Transform:
|
||||
- {fileID: 100450397525093210}
|
||||
- {fileID: 795319999662639885}
|
||||
- {fileID: 1063840860590240591}
|
||||
- {fileID: 136525282400229282}
|
||||
m_Father: {fileID: 0}
|
||||
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
|
||||
--- !u!114 &8290370249712404227
|
||||
@@ -2948,6 +2949,99 @@ MeshRenderer:
|
||||
m_SortingOrder: 0
|
||||
m_MaskInteraction: 0
|
||||
m_AdditionalVertexStreams: {fileID: 0}
|
||||
--- !u!1 &7369922033552706299
|
||||
GameObject:
|
||||
m_ObjectHideFlags: 0
|
||||
m_CorrespondingSourceObject: {fileID: 0}
|
||||
m_PrefabInstance: {fileID: 0}
|
||||
m_PrefabAsset: {fileID: 0}
|
||||
serializedVersion: 6
|
||||
m_Component:
|
||||
- component: {fileID: 136525282400229282}
|
||||
- component: {fileID: 4540681531604194498}
|
||||
m_Layer: 0
|
||||
m_Name: SM_Wep_Melee
|
||||
m_TagString: Untagged
|
||||
m_Icon: {fileID: 0}
|
||||
m_NavMeshLayer: 0
|
||||
m_StaticEditorFlags: 0
|
||||
m_IsActive: 1
|
||||
--- !u!4 &136525282400229282
|
||||
Transform:
|
||||
m_ObjectHideFlags: 0
|
||||
m_CorrespondingSourceObject: {fileID: 0}
|
||||
m_PrefabInstance: {fileID: 0}
|
||||
m_PrefabAsset: {fileID: 0}
|
||||
m_GameObject: {fileID: 7369922033552706299}
|
||||
serializedVersion: 2
|
||||
m_LocalRotation: {x: 0, y: 0, z: 0, w: 1}
|
||||
m_LocalPosition: {x: 0, y: 0, z: 0}
|
||||
m_LocalScale: {x: 1, y: 1, z: 1}
|
||||
m_ConstrainProportionsScale: 0
|
||||
m_Children: []
|
||||
m_Father: {fileID: 194292233036670670}
|
||||
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
|
||||
--- !u!137 &4540681531604194498
|
||||
SkinnedMeshRenderer:
|
||||
m_ObjectHideFlags: 0
|
||||
m_CorrespondingSourceObject: {fileID: 0}
|
||||
m_PrefabInstance: {fileID: 0}
|
||||
m_PrefabAsset: {fileID: 0}
|
||||
m_GameObject: {fileID: 7369922033552706299}
|
||||
m_Enabled: 1
|
||||
m_CastShadows: 1
|
||||
m_ReceiveShadows: 1
|
||||
m_DynamicOccludee: 1
|
||||
m_StaticShadowCaster: 0
|
||||
m_MotionVectors: 1
|
||||
m_LightProbeUsage: 1
|
||||
m_ReflectionProbeUsage: 1
|
||||
m_RayTracingMode: 3
|
||||
m_RayTraceProcedural: 0
|
||||
m_RayTracingAccelStructBuildFlagsOverride: 0
|
||||
m_RayTracingAccelStructBuildFlags: 1
|
||||
m_SmallMeshCulling: 1
|
||||
m_ForceMeshLod: -1
|
||||
m_MeshLodSelectionBias: 0
|
||||
m_RenderingLayerMask: 1
|
||||
m_RendererPriority: 0
|
||||
m_Materials:
|
||||
- {fileID: 2100000, guid: 53abed4a039a2944498dc0d43258baf5, type: 2}
|
||||
m_StaticBatchInfo:
|
||||
firstSubMesh: 0
|
||||
subMeshCount: 0
|
||||
m_StaticBatchRoot: {fileID: 0}
|
||||
m_ProbeAnchor: {fileID: 0}
|
||||
m_LightProbeVolumeOverride: {fileID: 0}
|
||||
m_ScaleInLightmap: 1
|
||||
m_ReceiveGI: 1
|
||||
m_PreserveUVs: 0
|
||||
m_IgnoreNormalsForChartDetection: 0
|
||||
m_ImportantGI: 0
|
||||
m_StitchLightmapSeams: 1
|
||||
m_SelectedEditorRenderState: 3
|
||||
m_MinimumChartSize: 4
|
||||
m_AutoUVMaxDistance: 0.5
|
||||
m_AutoUVMaxAngle: 89
|
||||
m_LightmapParameters: {fileID: 0}
|
||||
m_GlobalIlluminationMeshLod: 0
|
||||
m_SortingLayerID: 0
|
||||
m_SortingLayer: 0
|
||||
m_SortingOrder: 0
|
||||
m_MaskInteraction: 0
|
||||
serializedVersion: 2
|
||||
m_Quality: 0
|
||||
m_UpdateWhenOffscreen: 0
|
||||
m_SkinnedMotionVectors: 1
|
||||
m_Mesh: {fileID: 4300000, guid: 0d76db2ce815146419fd52c745104909, type: 2}
|
||||
m_Bones:
|
||||
- {fileID: 7714500739160558408}
|
||||
m_BlendShapeWeights: []
|
||||
m_RootBone: {fileID: 7714500739160558408}
|
||||
m_AABB:
|
||||
m_Center: {x: 0.41444784, y: 0.060000006, z: 0.02000001}
|
||||
m_Extent: {x: 0.6914554, y: 0.071966305, z: 0.31731963}
|
||||
m_DirtyAABB: 0
|
||||
--- !u!1 &7432474647424217847
|
||||
GameObject:
|
||||
m_ObjectHideFlags: 0
|
||||
|
||||
@@ -80,6 +80,7 @@ namespace ProjectM.Authoring
|
||||
AddComponent<BlinkState>(entity); // LANTERN Blink: predicted blink window (Movement-archetype Spark), baked idle
|
||||
// MC-4 melee combo: predicted, owner-replicated combo anchor (Step/SwingStartTick/LockUntilTick), baked idle/zero.
|
||||
AddComponent<MeleeCombo>(entity);
|
||||
AddComponent<MeleeCleavePending>(entity); // 07-20 G2.1: server-only scheduled cleave (baked zeroed, not replicated)
|
||||
|
||||
// Death gate (enableable, derived from Health by PlayerDeathStateSystem) baked DISABLED = alive;
|
||||
// plus the server-only respawn timer.
|
||||
|
||||
@@ -53,6 +53,12 @@ namespace ProjectM.Client
|
||||
/// override). The ability cooldown still gates how many shots actually result.</summary>
|
||||
public static void Fire(int frames = 10) { Active = true; FireFrames = math.max(FireFrames, frames); }
|
||||
|
||||
/// <summary>Frames remaining to hold the melee Attack event (07-19: melee swing validation).</summary>
|
||||
public static int AttackFrames;
|
||||
|
||||
/// <summary>Convenience: hold the melee Attack for the next <paramref name="frames"/> frames (also enables override).</summary>
|
||||
public static void Attack(int frames = 10) { Active = true; AttackFrames = math.max(AttackFrames, frames); }
|
||||
|
||||
/// <summary>Per-socket fire holds (LANTERN 4-socket kit). Each held frame raises that socket's fire event.</summary>
|
||||
public static readonly int[] SocketFrames = new int[SocketId.Count];
|
||||
|
||||
@@ -66,7 +72,7 @@ namespace ProjectM.Client
|
||||
public static void SetAim(float x, float z) { Active = true; Aim = new float2(x, z); }
|
||||
|
||||
/// <summary>Convenience: stop overriding and clear all injected input.</summary>
|
||||
public static void Stop() { Active = false; Move = default; Aim = default; FireFrames = 0; for (int i = 0; i < SocketId.Count; i++) SocketFrames[i] = 0; }
|
||||
public static void Stop() { Active = false; Move = default; Aim = default; FireFrames = 0; AttackFrames = 0; for (int i = 0; i < SocketId.Count; i++) SocketFrames[i] = 0; }
|
||||
|
||||
protected override void OnCreate()
|
||||
{
|
||||
@@ -80,6 +86,8 @@ namespace ProjectM.Client
|
||||
|
||||
bool fire = FireFrames > 0;
|
||||
if (FireFrames > 0) FireFrames--;
|
||||
bool attack = AttackFrames > 0;
|
||||
if (AttackFrames > 0) AttackFrames--;
|
||||
// LANTERN 4-socket kit: per-socket fire holds, decremented once per frame.
|
||||
bool s0 = SocketFrames[0] > 0; if (SocketFrames[0] > 0) SocketFrames[0]--;
|
||||
bool s1 = SocketFrames[1] > 0; if (SocketFrames[1] > 0) SocketFrames[1]--;
|
||||
@@ -99,6 +107,8 @@ namespace ProjectM.Client
|
||||
input.ValueRW.Scheme = Scheme;
|
||||
if (fire)
|
||||
input.ValueRW.Fire.Set();
|
||||
if (attack)
|
||||
input.ValueRW.Attack.Set();
|
||||
if (s0) input.ValueRW.Socket0.Set();
|
||||
if (s1) input.ValueRW.Socket1.Set();
|
||||
if (s2) input.ValueRW.Socket2.Set();
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -153,6 +153,16 @@ namespace ProjectM.Client
|
||||
/// <summary>Shoulder-lamp beam range (m).</summary>
|
||||
public static float ShoulderLampRange;
|
||||
|
||||
/// <summary>Seconds the Menacing01 combat idle (CombatIdle state) holds after the last melee swing (0 = off).</summary>
|
||||
public static float CombatIdleHoldSec;
|
||||
|
||||
/// <summary>Melee slash-arc brightness multiplier (07-19 LANTERN retone: the WEAPON is the read, the arc is a water wake).</summary>
|
||||
public static float MeleeArcIntensity;
|
||||
/// <summary>Bubbles shed along a melee cleave (0 = off).</summary>
|
||||
public static int MeleeArcBubbles;
|
||||
/// <summary>Camera hold frames when the melee FINISHER lands (07-20 G5 impact ladder; light hits use HitStopMaxFrames).</summary>
|
||||
public static int FinisherHoldFrames;
|
||||
|
||||
|
||||
/// <summary>Master gate for gamepad rumble (no-op on KBM).</summary>
|
||||
public static bool RumbleEnabled;
|
||||
@@ -276,6 +286,10 @@ namespace ProjectM.Client
|
||||
RunNaturalSpeed = 2.6f; // measured: A_Run_FwdStrafeF_RootMotion_Masc averageSpeed (run ring)
|
||||
ShoulderLampIntensity = 8f; // 07-16e: the suit lamp actually lights (3 got lost in the murk ambient)
|
||||
ShoulderLampRange = 12f;
|
||||
CombatIdleHoldSec = 4f; // 07-18: Menacing01 combat-idle hold after the last swing
|
||||
MeleeArcIntensity = 1f; // 07-19: arc colors are pre-toned dim; this scales from there
|
||||
MeleeArcBubbles = 6;
|
||||
FinisherHoldFrames = 5; // 07-20 G5: the finisher's heavy beat (~83ms), capped well under the 8-frame ladder ceiling
|
||||
|
||||
|
||||
RumbleEnabled = true;
|
||||
@@ -289,7 +303,7 @@ namespace ProjectM.Client
|
||||
PlayerHurtFlashColor = new Color(2.6f, 0.55f, 0.4f, 1f); // C2: hot red-orange body flash when the player is hit
|
||||
BodyFlashDurationSec = 0.16f;
|
||||
RemoteSwingEnabled = true;
|
||||
RemoteSlashColor = new Color(1.4f, 2.2f, 2.8f, 1f); // cool teammate arc
|
||||
RemoteSlashColor = new Color(0.7f, 1.15f, 1.3f, 1f); // cool teammate arc (07-19 retone: dim bioluminescent wake)
|
||||
StrikeBeepEnabled = true;
|
||||
StrikeBeepVolume = 0.40f;
|
||||
StrikeBeepLeadTicks = 8;
|
||||
|
||||
@@ -51,10 +51,11 @@ namespace ProjectM.Client
|
||||
|
||||
static readonly FastAnimatorParameter k_ComboStep = new FastAnimatorParameter("ComboStep"); // replicated MeleeCombo.Step -> per-step swing clips (Swing1/2/3)
|
||||
static readonly FastAnimatorParameter k_IsCone = new FastAnimatorParameter("IsCone"); // ability archetype (replicated AbilityRef + blob): Cone -> two-hand slam, else the shot
|
||||
static readonly FastAnimatorParameter k_InCombat = new FastAnimatorParameter("InCombat"); // 07-18: Menacing01 combat idle -- swing recency (FeelConfig.CombatIdleHoldSec)
|
||||
|
||||
// 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.
|
||||
// (MeleeRecoverTicks ~30) so a CHAINED swing re-pulses the bool false->true and re-triggers the Any State
|
||||
// transition per hit. ~0.43s @ 60Hz (07-19 heavy retime). Presentation-only.
|
||||
const uint k_AttackAnimTicks = PlayerAimSystem.CastFacingTicks; // structurally tied (07-16 review): body-yaw cast-turn and the swing anim pulse must agree
|
||||
|
||||
// Remote prevPos cache (per ghost Entity). Pruned every frame (a vanished remote = a despawn).
|
||||
@@ -85,6 +86,8 @@ static readonly FastAnimatorParameter k_ComboStep = new FastAnimatorParameter(
|
||||
NetworkTick serverTick = SystemAPI.TryGetSingleton<NetworkTime>(out var nt) ? nt.ServerTick : default;
|
||||
// Ability blob for the per-class special read (Cone -> slam anim); default(BlobAssetReference) if absent.
|
||||
var abilityBlob = SystemAPI.TryGetSingleton<AbilityDatabase>(out var adbSingleton) ? adbSingleton.Value : default;
|
||||
// 07-18 combat idle: swing-recency window (the same wrap-safe SwingActive predicate as the anim pulse, wider). 60Hz sim ticks.
|
||||
uint combatIdleTicks = (uint)math.max(1f, FeelConfig.CombatIdleHoldSec * 60f);
|
||||
|
||||
// 07-16 banking: signed facing turn rate -> Bank (-1..1, + = bank right, leaning INTO the turn).
|
||||
// Sampled main-thread (a single local player), smoothed so the additive pose eases in and out.
|
||||
@@ -115,6 +118,7 @@ static readonly FastAnimatorParameter k_ComboStep = new FastAnimatorParameter(
|
||||
comboStep = k_ComboStep, isCone = k_IsCone, abilityDb = abilityBlob,
|
||||
serverTick = serverTick, attackTicks = k_AttackAnimTicks,
|
||||
bank = k_Bank, bankValue = _bankSmoothed,
|
||||
inCombat = k_InCombat, combatIdleTicks = combatIdleTicks,
|
||||
strideScale = k_StrideScale, trudgeNaturalSpeed = math.max(0.5f, FeelConfig.TrudgeNaturalSpeed),
|
||||
runNaturalSpeed = math.max(0.5f, FeelConfig.RunNaturalSpeed),
|
||||
};
|
||||
@@ -131,6 +135,7 @@ static readonly FastAnimatorParameter k_ComboStep = new FastAnimatorParameter(
|
||||
dt = dt,
|
||||
strideScale = k_StrideScale, trudgeNaturalSpeed = math.max(0.5f, FeelConfig.TrudgeNaturalSpeed),
|
||||
runNaturalSpeed = math.max(0.5f, FeelConfig.RunNaturalSpeed),
|
||||
inCombat = k_InCombat, combatIdleTicks = combatIdleTicks,
|
||||
prevPos = _prevPos,
|
||||
seen = seen,
|
||||
};
|
||||
@@ -162,6 +167,8 @@ static readonly FastAnimatorParameter k_ComboStep = new FastAnimatorParameter(
|
||||
{
|
||||
public FastAnimatorParameter bank;
|
||||
public float bankValue;
|
||||
public FastAnimatorParameter inCombat;
|
||||
public uint combatIdleTicks;
|
||||
public FastAnimatorParameter strideScale;
|
||||
public float trudgeNaturalSpeed;
|
||||
public float runNaturalSpeed;
|
||||
@@ -205,6 +212,7 @@ public FastAnimatorParameter moveX, moveZ, speed, isDead, isAttacking, isFiring,
|
||||
|
||||
if (a.HasParameter(comboStep)) a.SetParameterValue(comboStep, (int)melee.Step);
|
||||
if (a.HasParameter(isCone)) a.SetParameterValue(isCone, coneL);
|
||||
if (a.HasParameter(inCombat)) a.SetParameterValue(inCombat, TickWindowMath.SwingActive(melee, serverTick, combatIdleTicks));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -225,6 +233,8 @@ public FastAnimatorParameter moveX, moveZ, speed, isDead, isAttacking, isFiring,
|
||||
public float runNaturalSpeed;
|
||||
public NativeParallelHashMap<Entity, float3> prevPos;
|
||||
public NativeParallelHashSet<Entity> seen;
|
||||
public FastAnimatorParameter inCombat;
|
||||
public uint combatIdleTicks;
|
||||
|
||||
void Execute(
|
||||
Entity e,
|
||||
@@ -257,6 +267,7 @@ public FastAnimatorParameter moveX, moveZ, speed, isDead, isAttacking, isFiring,
|
||||
a.SetParameterValue(strideScale, StrideScaleValue(math.length(vel.xz), stats.MoveSpeed, trudgeNaturalSpeed, runNaturalSpeed));
|
||||
if (a.HasParameter(comboStep)) a.SetParameterValue(comboStep, (int)melee.Step);
|
||||
if (a.HasParameter(isCone)) a.SetParameterValue(isCone, coneR);
|
||||
if (a.HasParameter(inCombat)) a.SetParameterValue(inCombat, TickWindowMath.SwingActive(melee, serverTick, combatIdleTicks));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -604,5 +604,242 @@ namespace ProjectM.EditorTools
|
||||
foreach (var c in sm.states) if (c.state.name == name) return c.state;
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/// <summary>07-18 melee-anim pass: swap the attack states onto AnimationSwordCombat pack clips
|
||||
/// (LightCombo01 A/B/C -> Swing1/2/3, HeavyCombo01A -> SpecialSlam, Death_B_01 -> Death) and add the
|
||||
/// Menacing01 combat idle (CombatIdle state + InCombat param; PlayerAnimationDriveSystem holds it for
|
||||
/// FeelConfig.CombatIdleHoldSec after a swing). State speeds land each clip's HIT take inside the
|
||||
/// 13-tick swing-anim window (CastFacingTicks ~0.22s + 0.12s exit blend). Idempotent / re-runnable.</summary>
|
||||
[MenuItem("ProjectM/Animation/Player - Wire Sword Combat Attacks (LANTERN)")]
|
||||
public static void WireSwordCombatAttackClips()
|
||||
{
|
||||
var ac = AssetDatabase.LoadAssetAtPath<AnimatorController>(PlayerController);
|
||||
if (ac == null) { Debug.LogError($"[PlayerRigTools] Controller missing: {PlayerController}"); return; }
|
||||
|
||||
const string packDir = "Assets/Synty/AnimationSwordCombat/Animations/Polygon/";
|
||||
AnimationClip PackClip(string fbxSub, string clipName)
|
||||
{
|
||||
foreach (var o in AssetDatabase.LoadAllAssetsAtPath(packDir + fbxSub + ".fbx"))
|
||||
if (o is AnimationClip c && c.name == clipName) return c;
|
||||
Debug.LogError($"[PlayerRigTools] SwordCombat clip missing: {fbxSub} -> {clipName}");
|
||||
return null;
|
||||
}
|
||||
|
||||
// Menacing01: the pack take ships loopTime=false -- flip it on the pack importer once (idempotent).
|
||||
const string menacingFbx = "Idle/Menacing01/A_Idle_Menacing01_Sword";
|
||||
var menacing = PackClip(menacingFbx, "A_Idle_Menacing01_Sword");
|
||||
if (menacing != null && !menacing.isLooping)
|
||||
{
|
||||
var imp = AssetImporter.GetAtPath(packDir + menacingFbx + ".fbx") as ModelImporter;
|
||||
var takes = (imp.clipAnimations != null && imp.clipAnimations.Length > 0)
|
||||
? imp.clipAnimations : imp.defaultClipAnimations;
|
||||
foreach (var t in takes) t.loopTime = true;
|
||||
imp.clipAnimations = takes;
|
||||
imp.SaveAndReimport();
|
||||
menacing = PackClip(menacingFbx, "A_Idle_Menacing01_Sword");
|
||||
}
|
||||
|
||||
// 07-19 retime: a custom SLAM take on the HeavyCombo01A fbx -- the stock windup is ~1s of the 2s clip,
|
||||
// so the take starts ~22% in (leaves ~0.5s clip-time of windup before the hit; at speed 1.5 the strike
|
||||
// lands ~0.35s into the 26-tick window) instead of comic 5x compression. Idempotent by take name.
|
||||
{
|
||||
string fbx = packDir + "Attack/HeavyCombo01/A_Attack_HeavyCombo01A_Sword.fbx";
|
||||
var imp = AssetImporter.GetAtPath(fbx) as ModelImporter;
|
||||
var takes = (imp.clipAnimations != null && imp.clipAnimations.Length > 0) ? imp.clipAnimations : imp.defaultClipAnimations;
|
||||
bool hasSlam = false;
|
||||
ModelImporterClipAnimation full = null;
|
||||
foreach (var tk in takes)
|
||||
{
|
||||
if (tk.name == "A_Attack_HeavyCombo01A_Slam_Sword") hasSlam = true;
|
||||
if (tk.name == "A_Attack_HeavyCombo01A_Sword") full = tk;
|
||||
}
|
||||
if (!hasSlam && full != null)
|
||||
{
|
||||
var slam = imp.defaultClipAnimations[0]; // fresh instance (never mutate an existing take entry)
|
||||
slam.name = "A_Attack_HeavyCombo01A_Slam_Sword";
|
||||
slam.firstFrame = Mathf.Lerp(full.firstFrame, full.lastFrame, 0.22f);
|
||||
slam.lastFrame = full.lastFrame;
|
||||
slam.loopTime = false;
|
||||
var list = new System.Collections.Generic.List<ModelImporterClipAnimation>(takes) { slam };
|
||||
imp.clipAnimations = list.ToArray();
|
||||
imp.SaveAndReimport();
|
||||
}
|
||||
else if (full == null) Debug.LogError("[PlayerRigTools] HeavyCombo01A full take not found -- slam take skipped.");
|
||||
}
|
||||
|
||||
var sm = ac.layers[0].stateMachine;
|
||||
|
||||
// Attack/death states: full pack clip + a state speed that lands the HIT take inside the swing
|
||||
// window. Hit-start seconds measured off the pack takes (= the WindUp take length).
|
||||
var swaps = new (string state, string fbxSub, string clip, float speed)[]
|
||||
{
|
||||
("Swing1", "Attack/LightCombo01/A_Attack_LightCombo01A_Sword", "A_Attack_LightCombo01A_Sword", 1.25f), // hit @0.33s -> ~0.27s (07-19 retime: 26-tick window)
|
||||
("Swing2", "Attack/LightCombo01/A_Attack_LightCombo01B_Sword", "A_Attack_LightCombo01B_Sword", 1.0f), // hit @0.17s natural speed
|
||||
("Swing3", "Attack/LightCombo01/A_Attack_LightCombo01C_Sword", "A_Attack_LightCombo01C_Sword", 1.1f), // finisher: hit @0.33s, heaviest of the chain
|
||||
("SpecialSlam", "Attack/HeavyCombo01/A_Attack_HeavyCombo01A_Sword", "A_Attack_HeavyCombo01A_Slam_Sword", 1.5f), // custom take (skips ~2/3 of the windup); hit ~0.35s
|
||||
("Death", "Death/A_Death_B_01_Sword", "A_Death_B_01_Sword", 1.0f), // backward collapse, holds last frame
|
||||
};
|
||||
foreach (var s in swaps)
|
||||
{
|
||||
var st = FindState(sm, s.state);
|
||||
var clip = PackClip(s.fbxSub, s.clip);
|
||||
if (st == null || clip == null) { Debug.LogError($"[PlayerRigTools] Skipped {s.state} (state or clip missing)."); continue; }
|
||||
st.motion = clip;
|
||||
st.speed = s.speed;
|
||||
}
|
||||
|
||||
// 07-19 retime: soften the swing entries (0.03 read as a snap-cut) + exits, matching the heavy cadence.
|
||||
foreach (var tr in sm.anyStateTransitions)
|
||||
{
|
||||
if (tr.destinationState == null) continue;
|
||||
string dn = tr.destinationState.name;
|
||||
if (dn == "Swing1" || dn == "Swing2" || dn == "Swing3") tr.duration = 0.06f;
|
||||
else if (dn == "SpecialSlam") tr.duration = 0.07f;
|
||||
}
|
||||
foreach (var sn in new[] { "Swing1", "Swing2", "Swing3", "SpecialSlam" })
|
||||
{
|
||||
var st2 = FindState(sm, sn);
|
||||
if (st2 == null) continue;
|
||||
foreach (var tr in st2.transitions) if (tr.isExit) tr.duration = 0.15f;
|
||||
}
|
||||
|
||||
// InCombat param + the CombatIdle (Menacing01) state. Idle -> CombatIdle is appended AFTER the
|
||||
// existing Idle -> Locomotion transition, so movement keeps priority while InCombat holds.
|
||||
bool hasInCombat = false;
|
||||
foreach (var p in ac.parameters) if (p.name == "InCombat") hasInCombat = true;
|
||||
if (!hasInCombat) ac.AddParameter("InCombat", AnimatorControllerParameterType.Bool);
|
||||
|
||||
var idle = FindState(sm, "Idle");
|
||||
var loco = FindState(sm, "Locomotion");
|
||||
var combatIdle = FindState(sm, "CombatIdle");
|
||||
if (combatIdle == null && menacing != null && idle != null && loco != null)
|
||||
{
|
||||
combatIdle = sm.AddState("CombatIdle");
|
||||
var toCombat = idle.AddTransition(combatIdle);
|
||||
toCombat.hasExitTime = false; toCombat.duration = 0.25f;
|
||||
toCombat.AddCondition(AnimatorConditionMode.If, 0f, "InCombat");
|
||||
var toIdle = combatIdle.AddTransition(idle);
|
||||
toIdle.hasExitTime = false; toIdle.duration = 0.4f;
|
||||
toIdle.AddCondition(AnimatorConditionMode.IfNot, 0f, "InCombat");
|
||||
var toLoco = combatIdle.AddTransition(loco);
|
||||
toLoco.hasExitTime = false; toLoco.duration = 0.25f;
|
||||
toLoco.AddCondition(AnimatorConditionMode.Greater, 0.1f, "Speed");
|
||||
}
|
||||
if (combatIdle != null && menacing != null) { combatIdle.motion = menacing; combatIdle.speed = 1f; }
|
||||
|
||||
EditorUtility.SetDirty(ac);
|
||||
AssetDatabase.SaveAssets();
|
||||
Debug.Log("[PlayerRigTools] SwordCombat wired: Swing1/2/3 = LightCombo01 A/B/C, SpecialSlam = HeavyCombo01A, Death = Death_B_01, CombatIdle = Menacing01.");
|
||||
}
|
||||
|
||||
// 07-19 grip offset (Hand_R bone-local). Iterate these + re-run Attach Melee Weapon if the grip reads wrong.
|
||||
static readonly Vector3 WeaponGripPos = new Vector3(0f, 0.06f, 0.02f);
|
||||
static readonly Vector3 WeaponGripEuler = new Vector3(0f, 0f, -90f);
|
||||
const float WeaponScale = 1.25f; // 07-20 G2.3 (reach honesty): the Synty-proportion exaggeration that sells the 2.2m cone
|
||||
|
||||
/// <summary>07-19 melee feel: rigid-skin a bulky Synty salvage axe into the RIGHT hand (100% Hand_R;
|
||||
/// GraftSmr's rebase conventions: world-rest verts + rigid inverse bindpose; RecalculateTangents or
|
||||
/// Rukhanka/BRG registration fails and the whole rig vanishes). An SMR so Rukhanka + Entities-Graphics
|
||||
/// deform it with the rig like the suit kit. Idempotent / re-runnable.</summary>
|
||||
[MenuItem("ProjectM/Animation/Player - Attach Melee Weapon (LANTERN)")]
|
||||
public static void AttachMeleeWeapon()
|
||||
{
|
||||
const string weaponFbx = "Assets/Synty/PolygonDungeonRealms/Models/SM_Wep_Axe_Large_01.fbx";
|
||||
const string matPath = "Assets/_Project/Materials/M_Weapon_Animated.mat";
|
||||
const string bodyMat = "Assets/_Project/Materials/M_SuitFrame_Bathynaut_Animated.mat";
|
||||
const string output = "Assets/_Project/Prefabs/Player.prefab";
|
||||
const string childName = "SM_Wep_Melee";
|
||||
const string meshPath = "Assets/_Project/Art/Models/Rebased_SM_Wep_Melee.asset";
|
||||
|
||||
var src = AssetDatabase.LoadAssetAtPath<GameObject>(weaponFbx);
|
||||
if (src == null) { Debug.LogError($"[PlayerRigTools] Weapon fbx missing: {weaponFbx}"); return; }
|
||||
var srcMf = src.GetComponentInChildren<MeshFilter>(true);
|
||||
if (srcMf == null || srcMf.sharedMesh == null) { Debug.LogError("[PlayerRigTools] Weapon mesh missing."); return; }
|
||||
var srcMesh = srcMf.sharedMesh;
|
||||
|
||||
// Deformation-aware material: clone the suit's AnimatedLit material, swap the atlas to the weapon pack's.
|
||||
var mat = AssetDatabase.LoadAssetAtPath<Material>(matPath);
|
||||
if (mat == null)
|
||||
{
|
||||
var body = AssetDatabase.LoadAssetAtPath<Material>(bodyMat);
|
||||
if (body == null) { Debug.LogError($"[PlayerRigTools] Body material missing: {bodyMat}"); return; }
|
||||
mat = new Material(body) { name = "M_Weapon_Animated" };
|
||||
// EXPLICIT pack atlas (07-19 fix): the FBX's importer material carries NO texture, so a mainTexture
|
||||
// fallback silently keeps the cloned suit atlas (caught by the material-VALUES check, not the render).
|
||||
var atlas = AssetDatabase.LoadAssetAtPath<Texture>("Assets/Synty/PolygonDungeonRealms/Textures/Dungeons_2_Texture_01_A.png");
|
||||
if (atlas == null) { var srcMr = srcMf.GetComponent<MeshRenderer>(); atlas = srcMr != null && srcMr.sharedMaterial != null ? srcMr.sharedMaterial.mainTexture : null; }
|
||||
if (atlas != null && mat.HasProperty("_BaseColorMap")) mat.SetTexture("_BaseColorMap", atlas);
|
||||
else Debug.LogWarning("[PlayerRigTools] Weapon atlas not resolved -- material keeps the cloned suit atlas.");
|
||||
AssetDatabase.CreateAsset(mat, matPath);
|
||||
}
|
||||
|
||||
var root = PrefabUtility.LoadPrefabContents(output);
|
||||
try
|
||||
{
|
||||
Transform hand = null;
|
||||
foreach (var t in root.GetComponentsInChildren<Transform>(true)) if (t.name == "Hand_R") { hand = t; break; }
|
||||
if (hand == null) { Debug.LogError("[PlayerRigTools] Hand_R not found on Player.prefab."); return; }
|
||||
|
||||
// Rigid rest matrices (GraftSmr conventions: scale-stripped rest, feet-at-0 prefab space).
|
||||
var hm = hand.localToWorldMatrix;
|
||||
var handRigid = Matrix4x4.TRS((Vector3)hm.GetColumn(3), hm.rotation, Vector3.one);
|
||||
var grip = Matrix4x4.TRS(WeaponGripPos, Quaternion.Euler(WeaponGripEuler), Vector3.one * WeaponScale); // uniform scale: normals keep their directions
|
||||
var toWorld = handRigid * grip;
|
||||
|
||||
var verts = srcMesh.vertices;
|
||||
var norms = srcMesh.normals;
|
||||
var newVerts = new Vector3[verts.Length];
|
||||
var newNorms = new Vector3[norms.Length];
|
||||
for (int v = 0; v < verts.Length; v++)
|
||||
{
|
||||
newVerts[v] = toWorld.MultiplyPoint3x4(verts[v]);
|
||||
newNorms[v] = toWorld.rotation * norms[v];
|
||||
}
|
||||
var weights = new BoneWeight[verts.Length];
|
||||
for (int v = 0; v < verts.Length; v++) weights[v] = new BoneWeight { boneIndex0 = 0, weight0 = 1f };
|
||||
|
||||
var mesh = AssetDatabase.LoadAssetAtPath<Mesh>(meshPath);
|
||||
bool fresh = mesh == null;
|
||||
if (fresh) mesh = new Mesh(); else mesh.Clear();
|
||||
mesh.name = "Rebased_" + childName;
|
||||
mesh.vertices = newVerts;
|
||||
mesh.normals = newNorms;
|
||||
mesh.uv = srcMesh.uv;
|
||||
mesh.subMeshCount = srcMesh.subMeshCount;
|
||||
for (int s = 0; s < srcMesh.subMeshCount; s++) mesh.SetTriangles(srcMesh.GetTriangles(s), s);
|
||||
mesh.boneWeights = weights;
|
||||
mesh.bindposes = new[] { handRigid.inverse };
|
||||
mesh.RecalculateBounds();
|
||||
mesh.RecalculateTangents(); // Rukhanka/BRG registration fails on a tangent-less mesh
|
||||
if (fresh) AssetDatabase.CreateAsset(mesh, meshPath); else EditorUtility.SetDirty(mesh);
|
||||
|
||||
var old = root.transform.Find(childName);
|
||||
if (old != null) Object.DestroyImmediate(old.gameObject);
|
||||
var go = new GameObject(childName);
|
||||
go.transform.SetParent(root.transform, false);
|
||||
var smr = go.AddComponent<SkinnedMeshRenderer>();
|
||||
smr.sharedMesh = mesh;
|
||||
smr.bones = new[] { hand };
|
||||
smr.rootBone = hand;
|
||||
// Bounds in rootBone (hand) space = the grip-transformed source verts.
|
||||
var bmin = new Vector3(float.MaxValue, float.MaxValue, float.MaxValue);
|
||||
var bmax = new Vector3(float.MinValue, float.MinValue, float.MinValue);
|
||||
for (int v = 0; v < verts.Length; v++)
|
||||
{
|
||||
var p = grip.MultiplyPoint3x4(verts[v]);
|
||||
bmin = Vector3.Min(bmin, p); bmax = Vector3.Max(bmax, p);
|
||||
}
|
||||
var lb = new Bounds(); lb.SetMinMax(bmin, bmax);
|
||||
smr.localBounds = lb;
|
||||
smr.sharedMaterials = new[] { mat };
|
||||
|
||||
PrefabUtility.SaveAsPrefabAsset(root, output);
|
||||
}
|
||||
finally { PrefabUtility.UnloadPrefabContents(root); }
|
||||
AssetDatabase.SaveAssets();
|
||||
Debug.Log($"[PlayerRigTools] Melee weapon attached to Hand_R ({srcMesh.vertexCount}v, grip pos {WeaponGripPos} euler {WeaponGripEuler}).");
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
using Unity.Mathematics;
|
||||
|
||||
namespace ProjectM.Simulation
|
||||
{
|
||||
/// <summary>07-20 melee feel forks (guidelines G2.1): the per-step CONTACT tick — how many ticks after
|
||||
/// SwingStartTick the blade actually lands. The server cleave resolves THEN (not at swing start), so the
|
||||
/// damage cone is a true record of the visible hit. Fractions derive from the AC_PlayerTopDown clip contact
|
||||
/// seconds at the wired state speeds (PlayerRigTools swaps table: A hit@0.267s, B hit@0.167s, C hit@0.333s
|
||||
/// @60Hz): step2 = x0.625 (10t at the default), step3+ (finisher) = x1.25 (20t). Knob = TuningConfig
|
||||
/// .MeleeContactTicks (default 16); <b>0 = IMMEDIATE</b> (legacy same-tick resolve; the mechanics tests pin 0).
|
||||
/// COUPLING (documented, not clamped): keep contact(step3) < CastFacingTicks (26) and < MeleeRecoverTicks —
|
||||
/// the cast-turn/anim window and chain cadence assume the hit lands inside both. The cleave itself is safe at
|
||||
/// ANY knob value (MeleeComboSystem flushes a still-armed pending cleave before overwriting the swing).</summary>
|
||||
public static class MeleeTiming
|
||||
{
|
||||
/// <summary>Server tick-batching slack: the buffered-attack validity window past the unlock edge must
|
||||
/// cover Netcode's default MaxSimulationStepBatchSize (4) or an edge press drops under load (design
|
||||
/// review wf_000bc247 C1).</summary>
|
||||
public const uint BatchSlackTicks = 4;
|
||||
|
||||
public static uint ContactTicks(byte step, float contactKnob)
|
||||
{
|
||||
if (contactKnob <= 0f) return 0u; // 0 = immediate (legacy same-tick resolve)
|
||||
float f = step == 2 ? 0.625f : step >= 3 ? 1.25f : 1f;
|
||||
return (uint)math.max(0f, math.round(contactKnob * f));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 4354c9c60079e0a44a0985ef7065d574
|
||||
@@ -46,6 +46,14 @@ namespace ProjectM.Simulation
|
||||
public float MeleeFinisherMult;
|
||||
public float MeleeComboLength;
|
||||
|
||||
// 07-20 melee feel forks (guidelines G2/G7, design review wf_000bc247):
|
||||
/// <summary>Finisher REACH multiplier (reach honesty G2.2) — MeleeFinisherMult keeps damage/recover/knockback only.</summary>
|
||||
public float MeleeFinisherRangeMult;
|
||||
/// <summary>Attack input-buffer window (ticks before unlock a press is remembered). 0 = off.</summary>
|
||||
public float MeleeBufferTicks;
|
||||
/// <summary>Base contact delay (ticks from swing start to the blade landing; per-step via MeleeTiming). 0 = IMMEDIATE (legacy same-tick cleave).</summary>
|
||||
public float MeleeContactTicks;
|
||||
|
||||
// EB-1 fortress aggro: a <1 multiplier on a Husk's SQUARED distance to a structure (so structures are
|
||||
// preferred targets); a closer player 'in the way' still wins. Read server-side by EnemyAISystem.
|
||||
public float StructureAggroWeight;
|
||||
@@ -76,15 +84,18 @@ namespace ProjectM.Simulation
|
||||
ChargerLungeDurationTicks = 18f,
|
||||
ChargerWhiffStaggerTicks = 36f,
|
||||
GruntWindupTicks = Tuning.AttackWindupTicks, // canonical Grunt-windup source (TelegraphTests couples to it)
|
||||
MeleeDamage = 18f,
|
||||
MeleeRange = 2.6f,
|
||||
MeleeDamage = 30f, // 07-19 heavy-weapon retime: bigger hit, slower cadence (DPS ~held: 18/0.27s -> 30/0.50s)
|
||||
MeleeRange = 2.2f, // 07-20 G2.2 reach honesty: axe tip ~1.8m + a 0.4m player-favoring margin (was 2.6)
|
||||
MeleeConeHalfAngleRad = 0.9f, // ~51.5 deg half-angle (~103 deg cleave arc)
|
||||
MeleeRecoverTicks = 16f, // light-swing lock/recovery (~0.27s)
|
||||
MeleeChainGraceTicks = 18f, // window after the lock to chain the next hit (~0.3s)
|
||||
MeleeRecoverTicks = 30f, // heavy-swing lock/recovery (~0.50s; 07-19 weighty-melee retime)
|
||||
MeleeChainGraceTicks = 24f, // window after the lock to chain the next hit (~0.4s)
|
||||
MeleeSwingMoveScale = 0.35f, // movement-commit while swinging (0..1)
|
||||
MeleeKnockbackSpeed = 6f,
|
||||
MeleeFinisherMult = 1.8f, // finisher (last hit) scales dmg/range/recover/knockback
|
||||
MeleeFinisherMult = 1.5f, // finisher (last hit) scales dmg/range/recover/knockback (07-19: 1.8 -> 1.5, recover was ballooning at the heavy cadence)
|
||||
MeleeComboLength = 3f, // light, light, finisher
|
||||
MeleeFinisherRangeMult = 1.25f, // 07-20 G2.2: finisher REACH (damage/recover/knockback keep MeleeFinisherMult)
|
||||
MeleeBufferTicks = 8f, // 07-20 G7: attack press remembered in the last ~0.13s of a lock (0 = off)
|
||||
MeleeContactTicks = 16f, // 07-20 G2.1: blade lands ~0.27s after swing start (per-step via MeleeTiming; 0 = legacy immediate)
|
||||
StructureAggroWeight = 0.7f, // EB-1: <1 prefers structures (fortress aggro); live-tunable
|
||||
StaggerKnockbackSpeed = 7f, // B2 poise: kb.Speed >= this interrupts windups/lunges; below = nudge only
|
||||
SeparationMaxSpeed = 3f, // B1: max separation push (units/s) so soft-collision can't fling
|
||||
@@ -116,6 +127,9 @@ namespace ProjectM.Simulation
|
||||
case TuningKnob.CastTurnRateDeg:
|
||||
case TuningKnob.MoveSharpness:
|
||||
case TuningKnob.SeparationMaxSpeed:
|
||||
case TuningKnob.MeleeFinisherRangeMult:
|
||||
case TuningKnob.MeleeBufferTicks: // 07-20: 0 = buffer OFF sentinel (must survive ClampKnob — review C10)
|
||||
case TuningKnob.MeleeContactTicks: // 07-20: 0 = IMMEDIATE sentinel (legacy same-tick cleave)
|
||||
return math.max(0f, value);
|
||||
// tick knobs: >= 1 (a 0 tick count is degenerate; a 0 i-frame window divides-by-zero in DashSystem).
|
||||
// FinalSiegeMultiplier also lands here on purpose — a final siege should never be < 1x a normal one.
|
||||
@@ -155,6 +169,9 @@ namespace ProjectM.Simulation
|
||||
case TuningKnob.TurnRateDeg: c.TurnRateDeg = value; break;
|
||||
case TuningKnob.CastTurnRateDeg: c.CastTurnRateDeg = value; break;
|
||||
case TuningKnob.MoveSharpness: c.MoveSharpness = value; break;
|
||||
case TuningKnob.MeleeBufferTicks: c.MeleeBufferTicks = value; break;
|
||||
case TuningKnob.MeleeFinisherRangeMult: c.MeleeFinisherRangeMult = value; break;
|
||||
case TuningKnob.MeleeContactTicks: c.MeleeContactTicks = value; break;
|
||||
|
||||
// unknown index -> no-op (matches the no-default switch convention in DebugCommandReceiveSystem)
|
||||
}
|
||||
@@ -190,6 +207,9 @@ namespace ProjectM.Simulation
|
||||
case TuningKnob.TurnRateDeg: return c.TurnRateDeg;
|
||||
case TuningKnob.CastTurnRateDeg: return c.CastTurnRateDeg;
|
||||
case TuningKnob.MoveSharpness: return c.MoveSharpness;
|
||||
case TuningKnob.MeleeBufferTicks: return c.MeleeBufferTicks;
|
||||
case TuningKnob.MeleeFinisherRangeMult: return c.MeleeFinisherRangeMult;
|
||||
case TuningKnob.MeleeContactTicks: return c.MeleeContactTicks;
|
||||
|
||||
default: return 0f;
|
||||
}
|
||||
@@ -223,6 +243,9 @@ namespace ProjectM.Simulation
|
||||
TurnRateDeg = c.TurnRateDeg,
|
||||
CastTurnRateDeg = c.CastTurnRateDeg,
|
||||
MoveSharpness = c.MoveSharpness,
|
||||
MeleeBufferTicks = c.MeleeBufferTicks,
|
||||
MeleeFinisherRangeMult = c.MeleeFinisherRangeMult,
|
||||
MeleeContactTicks = c.MeleeContactTicks,
|
||||
|
||||
};
|
||||
|
||||
@@ -254,6 +277,9 @@ namespace ProjectM.Simulation
|
||||
TurnRateDeg = r.TurnRateDeg,
|
||||
CastTurnRateDeg = r.CastTurnRateDeg,
|
||||
MoveSharpness = r.MoveSharpness,
|
||||
MeleeBufferTicks = r.MeleeBufferTicks,
|
||||
MeleeFinisherRangeMult = r.MeleeFinisherRangeMult,
|
||||
MeleeContactTicks = r.MeleeContactTicks,
|
||||
|
||||
};
|
||||
}
|
||||
@@ -289,9 +315,12 @@ namespace ProjectM.Simulation
|
||||
public const byte TurnRateDeg = 26;
|
||||
public const byte CastTurnRateDeg = 27;
|
||||
public const byte MoveSharpness = 28;
|
||||
public const byte MeleeBufferTicks = 29; // 07-20 G7 (0 = off sentinel)
|
||||
public const byte MeleeFinisherRangeMult = 30; // 07-20 G2.2
|
||||
public const byte MeleeContactTicks = 31; // 07-20 G2.1 (0 = immediate sentinel)
|
||||
|
||||
/// <summary>Knob count (overlay iteration bound).</summary>
|
||||
public const byte Count = 29;
|
||||
public const byte Count = 32;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -327,6 +356,9 @@ namespace ProjectM.Simulation
|
||||
public float TurnRateDeg;
|
||||
public float CastTurnRateDeg;
|
||||
public float MoveSharpness;
|
||||
public float MeleeBufferTicks; // 07-20 melee feel forks (dev-protocol bump: rebuild both peers)
|
||||
public float MeleeFinisherRangeMult;
|
||||
public float MeleeContactTicks;
|
||||
}
|
||||
|
||||
// NOTE: appending fields = a DEV-PROTOCOL BUMP (RpcCollection hash) — rebuild both peers together.
|
||||
|
||||
@@ -35,6 +35,15 @@ namespace ProjectM.Simulation
|
||||
const float DefaultSharpness = CharacterComponent.DefaultGroundedSharpness; // CharacterComponent.GetDefault() base
|
||||
const float SimTickRate = 60f;
|
||||
|
||||
// 07-20 G7 dash law (design review wf_000bc247 C4): a LOOKUP, not a query add -- melee-less entities
|
||||
// (dash test harnesses) must keep dashing; only entities that carry MeleeCombo get the commitment rule.
|
||||
ComponentLookup<MeleeCombo> m_MeleeLookup;
|
||||
|
||||
public void OnCreate(ref SystemState state)
|
||||
{
|
||||
m_MeleeLookup = state.GetComponentLookup<MeleeCombo>(isReadOnly: true);
|
||||
}
|
||||
|
||||
[BurstCompile]
|
||||
public void OnUpdate(ref SystemState state)
|
||||
{
|
||||
@@ -50,19 +59,31 @@ namespace ProjectM.Simulation
|
||||
float dashSharpness = t.DashSharpness;
|
||||
// 07-15 underwater feel: the restore target honors the MoveSharpness dev-override (0 = authored const).
|
||||
float baseSharpness = t.MoveSharpness > 0f ? t.MoveSharpness : DefaultSharpness;
|
||||
float contactKnob = math.max(0f, t.MeleeContactTicks); // 07-20 G7: pre-contact swing = dash refused
|
||||
m_MeleeLookup.Update(ref state);
|
||||
|
||||
|
||||
foreach (var (ds, cd, control, character, input, facing) in
|
||||
foreach (var (ds, cd, control, character, input, facing, entity) in
|
||||
SystemAPI.Query<RefRW<DashState>, RefRW<DashCooldown>, RefRW<CharacterControl>,
|
||||
RefRW<CharacterComponent>, RefRO<PlayerInput>, RefRO<PlayerFacing>>()
|
||||
.WithAll<Simulate>().WithDisabled<Dead>())
|
||||
.WithAll<Simulate>().WithDisabled<Dead>().WithEntityAccess())
|
||||
{
|
||||
// --- START (idempotent: fresh press + cooldown ready + not already mid-dash) ---
|
||||
bool ready = cd.ValueRO.NextTick == 0u
|
||||
|| !new NetworkTick(cd.ValueRO.NextTick).IsNewerThan(serverTick);
|
||||
bool inWindow = ds.ValueRO.RecoverUntilTick != 0u
|
||||
&& new NetworkTick(ds.ValueRO.RecoverUntilTick).IsNewerThan(serverTick);
|
||||
if (input.ValueRO.Dash.IsSet && ready && !inWindow)
|
||||
// 07-20 G7 (the Bathynaut COMMITS): a dash start is refused while a swing is PRE-CONTACT -- the
|
||||
// blade hasn't landed; post-contact recovery dash-cancel stays allowed. Same wrap-safe window
|
||||
// family as the anim pulse (TickWindowMath.SwingActive over the per-step contact ticks);
|
||||
// contact knob 0 -> zero-length window -> no refusal (legacy). Deterministic both worlds.
|
||||
bool preContactCommit = false;
|
||||
if (m_MeleeLookup.HasComponent(entity))
|
||||
{
|
||||
var mcRO = m_MeleeLookup[entity];
|
||||
preContactCommit = TickWindowMath.SwingActive(mcRO, serverTick, MeleeTiming.ContactTicks(mcRO.Step, contactKnob));
|
||||
}
|
||||
if (input.ValueRO.Dash.IsSet && ready && !inWindow && !preContactCommit)
|
||||
{
|
||||
// C1 + 07-15 fork: dash toward MOVEMENT input when moving (a panic 'dash away' still works); else
|
||||
// toward the CURSOR aim (stationary aim-and-dash keeps today's KBM feel under move-facing —
|
||||
|
||||
@@ -28,5 +28,25 @@ namespace ProjectM.Simulation
|
||||
/// <summary>Raw ServerTick the swing's movement-commit / recovery lock ends (NonZero). Gates the next press
|
||||
/// (locked while now < this) and anchors the chain window [LockUntilTick, LockUntilTick + grace).</summary>
|
||||
[GhostField] public uint LockUntilTick;
|
||||
|
||||
/// <summary>07-20 (guidelines G7 input buffer): raw ServerTick of an Attack press that landed during the
|
||||
/// swing lock's LAST MeleeBufferTicks (NonZero; 0 = empty). Consumed (zeroed) when it starts the chained
|
||||
/// swing; validity is anchored to the UNLOCK edge, not the press tick, so server tick-batching cannot
|
||||
/// drop an edge press (design review wf_000bc247 C1). Predicted + rollback-restored like its siblings.</summary>
|
||||
[GhostField] public uint BufferedAttackTick;
|
||||
}
|
||||
|
||||
/// <summary>07-20 (guidelines G2.1 damage-at-contact): SERVER-ONLY scheduled cleave — the AttackWindup
|
||||
/// schedule-and-consume idiom, entity-local (design review wf_000bc247 C11: no container, no disposal, no
|
||||
/// staleness window). Stamped when a swing starts (ResolveTick = start + MeleeTiming.ContactTicks), fired on
|
||||
/// a wrap-safe elapsed compare (tick-batch-proof), CONSUMED by zeroing. A still-armed pending is FLUSHED
|
||||
/// (fired early) before a chained swing overwrites it, so no knob combination can lose a cleave (C0/C12).
|
||||
/// Not replicated; the client's contact cues are presentation-side (CombatFeedbackSystem). Baked zeroed.</summary>
|
||||
public struct MeleeCleavePending : IComponentData
|
||||
{
|
||||
/// <summary>NonZero tick the armed swing's blade lands; 0 = nothing pending.</summary>
|
||||
public uint ResolveTick;
|
||||
/// <summary>Combo step of the ARMED swing (finisher scaling resolved against the live combo length).</summary>
|
||||
public byte Step;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -58,10 +58,38 @@ namespace ProjectM.Simulation
|
||||
BufferLookup<InventorySlot> m_InvLookup;
|
||||
BufferLookup<StatModifier> m_StatModLookup;
|
||||
ComponentLookup<BoonEffects> m_BoonEffectsLookup; // Phase 1.7 (player query is at the 7-type cap -> lookup)
|
||||
ComponentLookup<MeleeCleavePending> m_PendingLookup; // 07-20 G2.1 scheduled cleave (query at cap -> lookup)
|
||||
|
||||
/// <summary>Phase 1.7 Detonating Finisher blast radius (planar, tunable).</summary>
|
||||
const float k_DetonateRadius = 3.5f;
|
||||
|
||||
/// <summary>07-20 G2.1: build a cleave payload from LIVE player state at the moment it FIRES (contact tick,
|
||||
/// flush, or legacy same-tick) -- the cast-turn keeps steering the cone until the blade lands. Static with
|
||||
/// explicit params (no captures) so Burst lowering stays trivial. Reach uses the RANGE-only finisher mult
|
||||
/// (G2.2); damage/knockback keep the classic finisher mult.</summary>
|
||||
static PendingCleave BuildCleave(byte step, byte comboLen, float baseDamage, float baseRange, float knockSpeed,
|
||||
float finisherMult, float finisherRangeMult, uint stamp, uint knockUntil, float3 from, float2 aim,
|
||||
float2 facingDir, int ownerId, byte bflags, bool hasMods, DynamicBuffer<StatModifier> mods)
|
||||
{
|
||||
bool fin = step >= comboLen;
|
||||
float d = math.max(0f, hasMods ? StatMath.Apply(baseDamage, StatTarget.MeleeDamage, mods) : baseDamage);
|
||||
float r = math.max(0f, hasMods ? StatMath.Apply(baseRange, StatTarget.MeleeRange, mods) : baseRange);
|
||||
return new PendingCleave
|
||||
{
|
||||
From = from,
|
||||
Face = FacingMath.ResolveAim(aim, facingDir),
|
||||
Damage = fin ? d * finisherMult : d,
|
||||
Range = fin ? r * finisherRangeMult : r,
|
||||
KnockSpeed = fin ? knockSpeed * finisherMult : knockSpeed,
|
||||
OwnerId = ownerId,
|
||||
Stamp = stamp,
|
||||
KnockUntil = knockUntil,
|
||||
IsFinisher = fin,
|
||||
Detonate = (bflags & BoonFlag.FinisherDetonate) != 0,
|
||||
Pull = (bflags & BoonFlag.KnockToPull) != 0,
|
||||
};
|
||||
}
|
||||
|
||||
[BurstCompile]
|
||||
public void OnCreate(ref SystemState state)
|
||||
{
|
||||
@@ -72,6 +100,7 @@ namespace ProjectM.Simulation
|
||||
m_InvLookup = state.GetBufferLookup<InventorySlot>(isReadOnly: false);
|
||||
m_StatModLookup = state.GetBufferLookup<StatModifier>(isReadOnly: true);
|
||||
m_BoonEffectsLookup = state.GetComponentLookup<BoonEffects>(isReadOnly: true);
|
||||
m_PendingLookup = state.GetComponentLookup<MeleeCleavePending>(isReadOnly: false);
|
||||
state.RequireForUpdate<NetworkTime>();
|
||||
}
|
||||
|
||||
@@ -92,6 +121,9 @@ namespace ProjectM.Simulation
|
||||
float moveScale = math.max(0f, t.MeleeSwingMoveScale);
|
||||
float knockSpeed = math.max(0f, t.MeleeKnockbackSpeed);
|
||||
float finisherMult = math.max(1f, t.MeleeFinisherMult);
|
||||
float finisherRangeMult = math.max(0f, t.MeleeFinisherRangeMult); // 07-20 G2.2: REACH-only finisher scaling
|
||||
float bufferKnob = math.max(0f, t.MeleeBufferTicks); // 07-20 G7: 0 = buffer off
|
||||
float contactKnob = math.max(0f, t.MeleeContactTicks); // 07-20 G2.1: 0 = immediate (legacy)
|
||||
byte comboLen = (byte)math.clamp((int)t.MeleeComboLength, 1, 3);
|
||||
uint stamp = TickUtil.NonZero(now);
|
||||
uint knockUntil = TickUtil.NonZero(now + (uint)math.max(1, Tuning.KnockbackDurationTicks));
|
||||
@@ -103,6 +135,7 @@ namespace ProjectM.Simulation
|
||||
var cleaves = isServer ? new NativeList<PendingCleave>(Allocator.Temp) : default;
|
||||
m_StatModLookup.Update(ref state);
|
||||
m_BoonEffectsLookup.Update(ref state); // Phase 1.7: per-player boon flags (read inside the player loop)
|
||||
m_PendingLookup.Update(ref state); // 07-20 G2.1: scheduled cleave slots (server-only writes)
|
||||
|
||||
foreach (var (mc, control, input, facing, xform, owner, ds, entity) in
|
||||
SystemAPI.Query<RefRW<MeleeCombo>, RefRW<CharacterControl>, RefRO<PlayerInput>,
|
||||
@@ -119,10 +152,48 @@ namespace ProjectM.Simulation
|
||||
bool locked = mc.ValueRO.LockUntilTick != 0u
|
||||
&& new NetworkTick(mc.ValueRO.LockUntilTick).IsNewerThan(serverTick);
|
||||
|
||||
// --- ADVANCE (idempotent ABSOLUTE writes; dash wins same-tick ties via !Dash.IsSet) ---
|
||||
// --- 07-20 G2.1: fire a DUE scheduled cleave BEFORE anything can overwrite the swing this tick
|
||||
// (wrap-safe elapsed compare = tick-batch-proof; consumed by zeroing -- the AttackWindup idiom,
|
||||
// design review wf_000bc247 C11). Server-only; the pending slot never replicates.
|
||||
bool hasPending = m_PendingLookup.HasComponent(entity);
|
||||
if (isServer && hasPending)
|
||||
{
|
||||
var pend = m_PendingLookup[entity];
|
||||
if (pend.ResolveTick != 0u && !new NetworkTick(pend.ResolveTick).IsNewerThan(serverTick))
|
||||
{
|
||||
cleaves.Add(BuildCleave(pend.Step, comboLen, baseDamage, baseRange, knockSpeed,
|
||||
finisherMult, finisherRangeMult, stamp, knockUntil, xform.ValueRO.Position,
|
||||
input.ValueRO.Aim, facing.ValueRO.Direction, owner.ValueRO.NetworkId,
|
||||
m_BoonEffectsLookup.HasComponent(entity) ? m_BoonEffectsLookup[entity].Flags : (byte)0,
|
||||
m_StatModLookup.HasBuffer(entity), m_StatModLookup.HasBuffer(entity) ? m_StatModLookup[entity] : default));
|
||||
m_PendingLookup[entity] = default;
|
||||
}
|
||||
}
|
||||
|
||||
// --- 07-20 G7 buffer: a press in the lock's LAST MeleeBufferTicks is remembered; validity anchors
|
||||
// to the UNLOCK edge so server tick-batching can't drop an edge press (review wf_000bc247 C1). ---
|
||||
uint bufTicks = (uint)bufferKnob;
|
||||
if (bufTicks > 0u && input.ValueRO.Attack.IsSet && locked)
|
||||
{
|
||||
var lockEnd = new NetworkTick(mc.ValueRO.LockUntilTick);
|
||||
if (lockEnd.IsValid && lockEnd.TicksSince(serverTick) <= (int)bufTicks)
|
||||
mc.ValueRW.BufferedAttackTick = TickUtil.NonZero(now);
|
||||
}
|
||||
bool buffered = false;
|
||||
if (!locked && mc.ValueRO.BufferedAttackTick != 0u)
|
||||
{
|
||||
var lockEnd = new NetworkTick(mc.ValueRO.LockUntilTick);
|
||||
if (bufTicks > 0u && lockEnd.IsValid && serverTick.TicksSince(lockEnd) <= (int)MeleeTiming.BatchSlackTicks)
|
||||
buffered = true;
|
||||
else
|
||||
mc.ValueRW.BufferedAttackTick = 0u; // stale (missed the slack / buffer tuned off) -- drop
|
||||
}
|
||||
|
||||
// --- ADVANCE (idempotent ABSOLUTE writes; dash wins same-tick ties via !Dash.IsSet; a buffered
|
||||
// press counts as a press -- G7) ---
|
||||
bool swingStarted = false;
|
||||
byte swingStep = 0;
|
||||
if (input.ValueRO.Attack.IsSet && !locked && !dashActive && !input.ValueRO.Dash.IsSet)
|
||||
if ((input.ValueRO.Attack.IsSet || buffered) && !locked && !dashActive && !input.ValueRO.Dash.IsSet)
|
||||
{
|
||||
bool inChainWindow = false;
|
||||
if (mc.ValueRO.LockUntilTick != 0u)
|
||||
@@ -139,6 +210,7 @@ namespace ProjectM.Simulation
|
||||
mc.ValueRW.Step = swingStep;
|
||||
mc.ValueRW.SwingStartTick = TickUtil.NonZero(now);
|
||||
mc.ValueRW.LockUntilTick = TickUtil.NonZero(now + stepRecover);
|
||||
mc.ValueRW.BufferedAttackTick = 0u; // consumed (or superseded by a fresh press)
|
||||
swingStarted = true;
|
||||
}
|
||||
|
||||
@@ -150,33 +222,33 @@ namespace ProjectM.Simulation
|
||||
if (inSwingWindow && !dashActive)
|
||||
control.ValueRW.MoveVelocity *= moveScale;
|
||||
|
||||
// --- SERVER: queue the cleave (resolved after the loop). Damage/cone are SERVER-authoritative. ---
|
||||
// --- SERVER: schedule the cleave at the CONTACT tick (07-20 G2.1) -- or fire same-tick when the
|
||||
// knob is 0 (legacy) / the entity has no pending slot (plain test worlds). Damage stays SERVER-only;
|
||||
// payload is built from LIVE state at the moment it fires (the cast-turn steers until the blade lands).
|
||||
if (swingStarted && isServer)
|
||||
{
|
||||
bool isFin = swingStep >= comboLen;
|
||||
// Slice 2: fold the player's class/run StatModifiers onto the live-tunable melee base so the
|
||||
// PRIMARY verb scales with class identity (Warrior +MeleeDamage/+reach) + run augments.
|
||||
byte bflags = m_BoonEffectsLookup.HasComponent(entity) ? m_BoonEffectsLookup[entity].Flags : (byte)0;
|
||||
bool hasMods = m_StatModLookup.HasBuffer(entity);
|
||||
float pDamage = math.max(0f, hasMods ? StatMath.Apply(baseDamage, StatTarget.MeleeDamage, m_StatModLookup[entity]) : baseDamage);
|
||||
float pRange = math.max(0f, hasMods ? StatMath.Apply(baseRange, StatTarget.MeleeRange, m_StatModLookup[entity]) : baseRange);
|
||||
// Manual-aim cleave (07-15): the cone follows the CURRENT tick's replicated Aim (SoD skillshot
|
||||
// grammar) — PlayerFacing is body-yaw only; the facing fallback covers a resting gamepad stick.
|
||||
float2 face = FacingMath.ResolveAim(input.ValueRO.Aim, facing.ValueRO.Direction);
|
||||
cleaves.Add(new PendingCleave
|
||||
uint ct = MeleeTiming.ContactTicks(swingStep, contactKnob);
|
||||
if (ct > 0u && hasPending)
|
||||
{
|
||||
From = xform.ValueRO.Position,
|
||||
Face = face,
|
||||
Damage = isFin ? pDamage * finisherMult : pDamage,
|
||||
Range = isFin ? pRange * finisherMult : pRange,
|
||||
KnockSpeed = isFin ? knockSpeed * finisherMult : knockSpeed,
|
||||
OwnerId = owner.ValueRO.NetworkId,
|
||||
Stamp = stamp,
|
||||
KnockUntil = knockUntil,
|
||||
IsFinisher = isFin,
|
||||
Detonate = (bflags & BoonFlag.FinisherDetonate) != 0,
|
||||
Pull = (bflags & BoonFlag.KnockToPull) != 0,
|
||||
});
|
||||
// FLUSH a still-armed older cleave first (pathological knob combos can push contact past the
|
||||
// recover lock -- fire it early rather than lose it; review wf_000bc247 C0/C12).
|
||||
var pend = m_PendingLookup[entity];
|
||||
if (pend.ResolveTick != 0u)
|
||||
cleaves.Add(BuildCleave(pend.Step, comboLen, baseDamage, baseRange, knockSpeed,
|
||||
finisherMult, finisherRangeMult, stamp, knockUntil, xform.ValueRO.Position,
|
||||
input.ValueRO.Aim, facing.ValueRO.Direction, owner.ValueRO.NetworkId,
|
||||
m_BoonEffectsLookup.HasComponent(entity) ? m_BoonEffectsLookup[entity].Flags : (byte)0,
|
||||
m_StatModLookup.HasBuffer(entity), m_StatModLookup.HasBuffer(entity) ? m_StatModLookup[entity] : default));
|
||||
m_PendingLookup[entity] = new MeleeCleavePending { ResolveTick = TickUtil.NonZero(now + ct), Step = swingStep };
|
||||
}
|
||||
else
|
||||
{
|
||||
cleaves.Add(BuildCleave(swingStep, comboLen, baseDamage, baseRange, knockSpeed,
|
||||
finisherMult, finisherRangeMult, stamp, knockUntil, xform.ValueRO.Position,
|
||||
input.ValueRO.Aim, facing.ValueRO.Direction, owner.ValueRO.NetworkId,
|
||||
m_BoonEffectsLookup.HasComponent(entity) ? m_BoonEffectsLookup[entity].Flags : (byte)0,
|
||||
m_StatModLookup.HasBuffer(entity), m_StatModLookup.HasBuffer(entity) ? m_StatModLookup[entity] : default));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -33,7 +33,7 @@ namespace ProjectM.Simulation
|
||||
|
||||
/// <summary>Ticks facing stays aimed after a swing/fire starts (~0.22s @60Hz — matches the anim pulse
|
||||
/// k_AttackAnimTicks so body yaw and the swing animation agree).</summary>
|
||||
public const uint CastFacingTicks = 13;
|
||||
public const uint CastFacingTicks = 26; // 07-19 heavy-weapon retime: cast-turn + swing-anim window tracks the full ~0.43s swing (was 13; MUST stay < MeleeRecoverTicks so the anim pulse re-triggers between chained swings)
|
||||
|
||||
[BurstCompile]
|
||||
public void OnUpdate(ref SystemState state)
|
||||
|
||||
@@ -34,6 +34,17 @@ namespace ProjectM.Tests
|
||||
group.SortSystems();
|
||||
world.SetTime(new TimeData(elapsedTime: 0f, deltaTime: 1f / 60f));
|
||||
SetServerTick(world, tick);
|
||||
// 07-19: PIN the melee knobs these mechanics tests were authored against. The LIVE feel defaults
|
||||
// (TuningConfig.Defaults()) are free to drift for game feel (heavy-weapon retime moved recover 16->30,
|
||||
// dmg 18->30, finisher 1.8->1.5); the chain/lock/cone MECHANICS under test are not.
|
||||
var pinned = TuningConfig.Defaults();
|
||||
pinned.MeleeDamage = 18f; pinned.MeleeRange = 2.6f; pinned.MeleeConeHalfAngleRad = 0.9f;
|
||||
pinned.MeleeRecoverTicks = 16f; pinned.MeleeChainGraceTicks = 18f; pinned.MeleeSwingMoveScale = 0.35f;
|
||||
pinned.MeleeKnockbackSpeed = 6f; pinned.MeleeFinisherMult = 1.8f; pinned.MeleeComboLength = 3f;
|
||||
// 07-20 forks pinned LEGACY: finisher reach = the old combined mult, buffer OFF, same-tick cleave
|
||||
// (contact 0 = the IMMEDIATE sentinel) -- new-behavior tests override per-test.
|
||||
pinned.MeleeFinisherRangeMult = 1.8f; pinned.MeleeBufferTicks = 0f; pinned.MeleeContactTicks = 0f;
|
||||
world.EntityManager.SetComponentData(world.EntityManager.CreateEntity(typeof(TuningConfig)), pinned);
|
||||
return (world, group);
|
||||
}
|
||||
|
||||
@@ -41,6 +52,7 @@ namespace ProjectM.Tests
|
||||
{
|
||||
var e = em.CreateEntity();
|
||||
em.AddComponentData(e, new MeleeCombo());
|
||||
em.AddComponent<MeleeCleavePending>(e); // 07-20 G2.1: the scheduled-cleave slot (baked zeroed on the real player)
|
||||
em.AddComponentData(e, new CharacterControl { MoveVelocity = float3.zero });
|
||||
em.AddComponentData(e, new PlayerInput());
|
||||
em.AddComponentData(e, new PlayerFacing { Direction = facing });
|
||||
@@ -71,6 +83,15 @@ namespace ProjectM.Tests
|
||||
em.SetComponentData(player, pi);
|
||||
}
|
||||
|
||||
/// <summary>07-20 (review C6): clear the held Attack event -- plain test worlds never clear InputEvents,
|
||||
/// and a still-set Attack silently re-chains at any unlock tick the test advances across.</summary>
|
||||
static void Release(EntityManager em, Entity player)
|
||||
{
|
||||
var pi = em.GetComponentData<PlayerInput>(player);
|
||||
pi.Attack = default;
|
||||
em.SetComponentData(player, pi);
|
||||
}
|
||||
|
||||
// ---- state machine (plain world) ----
|
||||
|
||||
[Test]
|
||||
@@ -359,9 +380,11 @@ namespace ProjectM.Tests
|
||||
using (world)
|
||||
{
|
||||
var em = world.EntityManager;
|
||||
var cfg = TuningConfig.Defaults();
|
||||
// Modify the MakeWorld-pinned singleton (a second TuningConfig entity would break TryGetSingleton).
|
||||
var cfgEnt = em.CreateEntityQuery(typeof(TuningConfig)).GetSingletonEntity();
|
||||
var cfg = em.GetComponentData<TuningConfig>(cfgEnt);
|
||||
cfg.MeleeComboLength = 2f;
|
||||
em.SetComponentData(em.CreateEntity(typeof(TuningConfig)), cfg);
|
||||
em.SetComponentData(cfgEnt, cfg);
|
||||
|
||||
var p = MakePlayer(em, new float2(0, 1));
|
||||
em.SetComponentData(p, new MeleeCombo { Step = 1, SwingStartTick = 100, LockUntilTick = 116 }); // chain
|
||||
@@ -488,5 +511,117 @@ namespace ProjectM.Tests
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
// ---- 07-20 melee feel forks (guidelines G2.1/G7; design review wf_000bc247) ----
|
||||
|
||||
[Test]
|
||||
public void Cleave_Resolves_At_The_Contact_Tick_Exactly_Once()
|
||||
{
|
||||
var (world, group) = MakeWorld("MeleeContactDelay", 100, server: true);
|
||||
using (world)
|
||||
{
|
||||
var em = world.EntityManager;
|
||||
// contact 16 / recover 24: the resolve tick (116) lands INSIDE the lock (124) so a held press
|
||||
// cannot re-chain at the observation tick (review C6's collision trap).
|
||||
var cfgEnt = em.CreateEntityQuery(typeof(TuningConfig)).GetSingletonEntity();
|
||||
var cfg = em.GetComponentData<TuningConfig>(cfgEnt);
|
||||
cfg.MeleeContactTicks = 16f; cfg.MeleeRecoverTicks = 24f;
|
||||
em.SetComponentData(cfgEnt, cfg);
|
||||
|
||||
var p = MakePlayer(em, new float2(0, 1));
|
||||
var enemy = MakeEnemy(em, new float3(0, 0, 2));
|
||||
Press(em, p);
|
||||
group.Update(); // tick 100: the swing starts; the cleave is SCHEDULED for 116
|
||||
Release(em, p);
|
||||
Assert.AreEqual(1, em.GetComponentData<MeleeCombo>(p).Step, "swing started");
|
||||
Assert.AreEqual(0, em.GetBuffer<DamageEvent>(enemy).Length, "no damage at the swing-start tick (the blade hasn't landed)");
|
||||
|
||||
SetServerTick(world, 108); group.Update();
|
||||
Assert.AreEqual(0, em.GetBuffer<DamageEvent>(enemy).Length, "no damage pre-contact");
|
||||
|
||||
SetServerTick(world, 116); group.Update(); // the blade lands
|
||||
var d = em.GetBuffer<DamageEvent>(enemy);
|
||||
Assert.AreEqual(1, d.Length, "exactly one cleave at the contact tick");
|
||||
Assert.AreEqual(116u, d[0].SourceTick, "damage is stamped with the CONTACT tick");
|
||||
|
||||
SetServerTick(world, 117); group.Update();
|
||||
Assert.AreEqual(1, em.GetBuffer<DamageEvent>(enemy).Length, "the pending cleave is consumed (no double resolve)");
|
||||
}
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Buffered_Press_Starts_The_Chained_Swing_At_Unlock()
|
||||
{
|
||||
var (world, group) = MakeWorld("MeleeBuffer", 100, server: true);
|
||||
using (world)
|
||||
{
|
||||
var em = world.EntityManager;
|
||||
var cfgEnt = em.CreateEntityQuery(typeof(TuningConfig)).GetSingletonEntity();
|
||||
var cfg = em.GetComponentData<TuningConfig>(cfgEnt);
|
||||
cfg.MeleeBufferTicks = 8f; // contact stays pinned 0 (legacy resolve; the buffer is what's under test)
|
||||
em.SetComponentData(cfgEnt, cfg);
|
||||
|
||||
var p = MakePlayer(em, new float2(0, 1));
|
||||
Press(em, p);
|
||||
group.Update(); // tick 100: swing 1, lock until 116 (pinned recover 16)
|
||||
Release(em, p);
|
||||
Assert.AreEqual(1, em.GetComponentData<MeleeCombo>(p).Step);
|
||||
|
||||
SetServerTick(world, 110); Press(em, p); group.Update(); // locked, 6 ticks remain (<= 8): buffered
|
||||
Release(em, p);
|
||||
Assert.AreNotEqual(0u, em.GetComponentData<MeleeCombo>(p).BufferedAttackTick, "the late press is remembered");
|
||||
Assert.AreEqual(1, em.GetComponentData<MeleeCombo>(p).Step, "still swing 1 (locked)");
|
||||
|
||||
SetServerTick(world, 116); group.Update(); // unlock: the buffer fires the chain WITHOUT a live press
|
||||
var mc = em.GetComponentData<MeleeCombo>(p);
|
||||
Assert.AreEqual(2, mc.Step, "the buffered press chains to swing 2 at the unlock tick");
|
||||
Assert.AreEqual(0u, mc.BufferedAttackTick, "the buffer is consumed");
|
||||
}
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Dash_Is_Refused_Pre_Contact_And_Allowed_Post_Contact()
|
||||
{
|
||||
var world = new World("MeleeDashLaw", WorldFlags.Game | WorldFlags.GameServer);
|
||||
using (world)
|
||||
{
|
||||
var group = world.GetOrCreateSystemManaged<SimulationSystemGroup>();
|
||||
group.AddSystemToUpdateList(world.GetOrCreateSystem<MeleeComboSystem>());
|
||||
group.AddSystemToUpdateList(world.GetOrCreateSystem<DashSystem>());
|
||||
group.SortSystems();
|
||||
world.SetTime(new TimeData(elapsedTime: 0f, deltaTime: 1f / 60f));
|
||||
SetServerTick(world, 100);
|
||||
var em = world.EntityManager;
|
||||
var pinned = TuningConfig.Defaults();
|
||||
pinned.MeleeDamage = 18f; pinned.MeleeRange = 2.6f; pinned.MeleeConeHalfAngleRad = 0.9f;
|
||||
pinned.MeleeRecoverTicks = 30f; pinned.MeleeChainGraceTicks = 18f; pinned.MeleeSwingMoveScale = 0.35f;
|
||||
pinned.MeleeKnockbackSpeed = 6f; pinned.MeleeFinisherMult = 1.8f; pinned.MeleeComboLength = 3f;
|
||||
pinned.MeleeFinisherRangeMult = 1.8f; pinned.MeleeBufferTicks = 0f;
|
||||
pinned.MeleeContactTicks = 16f; // the law under test needs a REAL pre-contact window
|
||||
em.SetComponentData(em.CreateEntity(typeof(TuningConfig)), pinned);
|
||||
|
||||
var p = MakePlayer(em, new float2(0, 1));
|
||||
em.AddComponentData(p, new DashCooldown { NextTick = 0 });
|
||||
em.AddComponentData(p, CharacterComponent.GetDefault());
|
||||
|
||||
Press(em, p);
|
||||
group.Update(); // tick 100: swing 1 starts (contact at 116)
|
||||
Release(em, p);
|
||||
Assert.AreEqual(1, em.GetComponentData<MeleeCombo>(p).Step, "swing started");
|
||||
|
||||
// pre-contact (tick 105): a dash press is REFUSED -- the Bathynaut commits (G7).
|
||||
SetServerTick(world, 105);
|
||||
var pi = em.GetComponentData<PlayerInput>(p); pi.Dash.Set(); em.SetComponentData(p, pi);
|
||||
group.Update();
|
||||
Assert.AreEqual(0u, em.GetComponentData<DashState>(p).StartTick, "dash refused while the blade is pre-contact");
|
||||
|
||||
// post-contact (tick 120, still inside the recover lock): the recovery dash-cancel opens.
|
||||
SetServerTick(world, 120);
|
||||
pi = em.GetComponentData<PlayerInput>(p); pi.Dash.Set(); em.SetComponentData(p, pi);
|
||||
group.Update();
|
||||
Assert.AreEqual(120u, em.GetComponentData<DashState>(p).StartTick, "post-contact recovery dash-cancel is allowed");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user