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:
2026-07-20 21:03:18 -07:00
parent 2c187a17a9
commit 7b65912f30
22 changed files with 1327 additions and 112 deletions
@@ -1,7 +1,7 @@
fileFormatVersion: 2 fileFormatVersion: 2
guid: f3800f1bd189abc469fcc7c374371a91 guid: f3800f1bd189abc469fcc7c374371a91
ModelImporter: ModelImporter:
serializedVersion: 22200 serializedVersion: 24501
internalIDToNameTable: [] internalIDToNameTable: []
externalObjects: {} externalObjects: {}
materials: materials:
@@ -9,6 +9,7 @@ ModelImporter:
materialName: 0 materialName: 0
materialSearch: 1 materialSearch: 1
materialLocation: 1 materialLocation: 1
searchTexturesGlobally: 1
animations: animations:
legacyGenerateAnimations: 4 legacyGenerateAnimations: 4
bakeSimulation: 0 bakeSimulation: 0
@@ -16,8 +17,6 @@ ModelImporter:
optimizeGameObjects: 0 optimizeGameObjects: 0
removeConstantScaleCurves: 0 removeConstantScaleCurves: 0
motionNodeName: motionNodeName:
rigImportErrors:
rigImportWarnings:
animationImportErrors: animationImportErrors:
animationImportWarnings: animationImportWarnings:
animationRetargetingWarnings: animationRetargetingWarnings:
@@ -580,6 +579,35 @@ ModelImporter:
maskType: 1 maskType: 1
maskSource: {fileID: 31900000, guid: ebc8800d7aef22a4aa95591cd452157d, type: 2} maskSource: {fileID: 31900000, guid: ebc8800d7aef22a4aa95591cd452157d, type: 2}
additiveReferencePoseFrame: 1 additiveReferencePoseFrame: 1
- serializedVersion: 16
name: A_Attack_HeavyCombo01A_Slam_Sword
takeName: A_Attack_HeavyCombo01A_Sword
internalID: -1787871631005901691
firstFrame: 14.42
lastFrame: 62
wrapMode: 0
orientationOffsetY: 0
level: 0
cycleOffset: 0
loop: 0
hasAdditiveReferencePose: 0
loopTime: 0
loopBlend: 0
loopBlendOrientation: 0
loopBlendPositionY: 0
loopBlendPositionXZ: 0
keepOriginalOrientation: 0
keepOriginalPositionY: 1
keepOriginalPositionXZ: 0
heightFromFeet: 0
mirror: 0
bodyMask: 01000000010000000100000001000000010000000100000001000000010000000100000001000000010000000100000001000000
curves: []
events: []
transformMask: []
maskType: 3
maskSource: {instanceID: 0}
additiveReferencePoseFrame: 0
isReadable: 0 isReadable: 0
meshes: meshes:
lODScreenPercentages: [] lODScreenPercentages: []
@@ -606,6 +634,11 @@ ModelImporter:
maxBonesPerVertex: 4 maxBonesPerVertex: 4
minBoneWeight: 0.001 minBoneWeight: 0.001
optimizeBones: 0 optimizeBones: 0
generateMeshLods: 0
meshLodGenerationFlags: 0
maximumMeshLod: -1
importUVs: -1
importVertexColors: 1
meshOptimizationFlags: -1 meshOptimizationFlags: -1
indexFormat: 0 indexFormat: 0
secondaryUVAngleDistortion: 8 secondaryUVAngleDistortion: 8
@@ -625,6 +658,7 @@ ModelImporter:
legacyComputeAllNormalsFromSmoothingGroupsWhenMeshHasBlendShapes: 0 legacyComputeAllNormalsFromSmoothingGroupsWhenMeshHasBlendShapes: 0
blendShapeNormalImportMode: 1 blendShapeNormalImportMode: 1
normalSmoothingSource: 0 normalSmoothingSource: 0
calculateBlendshapeNormalsDeltaFromImportedNormals: 1
referencedClips: [] referencedClips: []
importAnimation: 1 importAnimation: 1
humanDescription: humanDescription:
@@ -1,7 +1,7 @@
fileFormatVersion: 2 fileFormatVersion: 2
guid: f574f64a099779248beea90d27b2dde0 guid: f574f64a099779248beea90d27b2dde0
ModelImporter: ModelImporter:
serializedVersion: 22200 serializedVersion: 24501
internalIDToNameTable: [] internalIDToNameTable: []
externalObjects: {} externalObjects: {}
materials: materials:
@@ -9,6 +9,7 @@ ModelImporter:
materialName: 0 materialName: 0
materialSearch: 1 materialSearch: 1
materialLocation: 1 materialLocation: 1
searchTexturesGlobally: 1
animations: animations:
legacyGenerateAnimations: 4 legacyGenerateAnimations: 4
bakeSimulation: 0 bakeSimulation: 0
@@ -16,8 +17,6 @@ ModelImporter:
optimizeGameObjects: 0 optimizeGameObjects: 0
removeConstantScaleCurves: 0 removeConstantScaleCurves: 0
motionNodeName: motionNodeName:
rigImportErrors:
rigImportWarnings:
animationImportErrors: animationImportErrors:
animationImportWarnings: animationImportWarnings:
animationRetargetingWarnings: animationRetargetingWarnings:
@@ -44,7 +43,7 @@ ModelImporter:
cycleOffset: 0 cycleOffset: 0
loop: 0 loop: 0
hasAdditiveReferencePose: 0 hasAdditiveReferencePose: 0
loopTime: 0 loopTime: 1
loopBlend: 0 loopBlend: 0
loopBlendOrientation: 1 loopBlendOrientation: 1
loopBlendPositionY: 1 loopBlendPositionY: 1
@@ -195,6 +194,11 @@ ModelImporter:
maxBonesPerVertex: 4 maxBonesPerVertex: 4
minBoneWeight: 0.001 minBoneWeight: 0.001
optimizeBones: 0 optimizeBones: 0
generateMeshLods: 0
meshLodGenerationFlags: 0
maximumMeshLod: -1
importUVs: -1
importVertexColors: 1
meshOptimizationFlags: -1 meshOptimizationFlags: -1
indexFormat: 0 indexFormat: 0
secondaryUVAngleDistortion: 8 secondaryUVAngleDistortion: 8
@@ -214,6 +218,7 @@ ModelImporter:
legacyComputeAllNormalsFromSmoothingGroupsWhenMeshHasBlendShapes: 0 legacyComputeAllNormalsFromSmoothingGroupsWhenMeshHasBlendShapes: 0
blendShapeNormalImportMode: 1 blendShapeNormalImportMode: 1
normalSmoothingSource: 0 normalSmoothingSource: 0
calculateBlendshapeNormalsDeltaFromImportedNormals: 1
referencedClips: [] referencedClips: []
importAnimation: 1 importAnimation: 1
humanDescription: humanDescription:
@@ -42,7 +42,7 @@ AnimatorStateTransition:
m_Mute: 0 m_Mute: 0
m_IsExit: 0 m_IsExit: 0
serializedVersion: 3 serializedVersion: 3
m_TransitionDuration: 0.03 m_TransitionDuration: 0.06
m_TransitionOffset: 0 m_TransitionOffset: 0
m_ExitTime: 0.75 m_ExitTime: 0.75
m_HasExitTime: 0 m_HasExitTime: 0
@@ -58,7 +58,7 @@ AnimatorState:
m_PrefabInstance: {fileID: 0} m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0} m_PrefabAsset: {fileID: 0}
m_Name: SpecialSlam m_Name: SpecialSlam
m_Speed: 1 m_Speed: 1.5
m_CycleOffset: 0 m_CycleOffset: 0
m_Transitions: m_Transitions:
- {fileID: -1718360560449278314} - {fileID: -1718360560449278314}
@@ -71,7 +71,7 @@ AnimatorState:
m_MirrorParameterActive: 0 m_MirrorParameterActive: 0
m_CycleOffsetParameterActive: 0 m_CycleOffsetParameterActive: 0
m_TimeParameterActive: 0 m_TimeParameterActive: 0
m_Motion: {fileID: -623603649343465320, guid: da76a86fe339c6444a0c1c040a9b5105, type: 3} m_Motion: {fileID: -1787871631005901691, guid: f3800f1bd189abc469fcc7c374371a91, type: 3}
m_Tag: m_Tag:
m_SpeedParameter: m_SpeedParameter:
m_MirrorParameter: m_MirrorParameter:
@@ -124,7 +124,7 @@ AnimatorStateTransition:
m_Mute: 0 m_Mute: 0
m_IsExit: 0 m_IsExit: 0
serializedVersion: 3 serializedVersion: 3
m_TransitionDuration: 0.04 m_TransitionDuration: 0.07
m_TransitionOffset: 0 m_TransitionOffset: 0
m_ExitTime: 0.75 m_ExitTime: 0.75
m_HasExitTime: 0 m_HasExitTime: 0
@@ -149,7 +149,7 @@ AnimatorStateTransition:
m_Mute: 0 m_Mute: 0
m_IsExit: 1 m_IsExit: 1
serializedVersion: 3 serializedVersion: 3
m_TransitionDuration: 0.12 m_TransitionDuration: 0.15
m_TransitionOffset: 0 m_TransitionOffset: 0
m_ExitTime: 0.9 m_ExitTime: 0.9
m_HasExitTime: 0 m_HasExitTime: 0
@@ -202,7 +202,7 @@ AnimatorStateTransition:
m_Mute: 0 m_Mute: 0
m_IsExit: 0 m_IsExit: 0
serializedVersion: 3 serializedVersion: 3
m_TransitionDuration: 0.03 m_TransitionDuration: 0.06
m_TransitionOffset: 0 m_TransitionOffset: 0
m_ExitTime: 0.75 m_ExitTime: 0.75
m_HasExitTime: 0 m_HasExitTime: 0
@@ -365,7 +365,7 @@ AnimatorStateTransition:
m_Mute: 0 m_Mute: 0
m_IsExit: 1 m_IsExit: 1
serializedVersion: 3 serializedVersion: 3
m_TransitionDuration: 0.12 m_TransitionDuration: 0.15
m_TransitionOffset: 0 m_TransitionOffset: 0
m_ExitTime: 0.9 m_ExitTime: 0.9
m_HasExitTime: 0 m_HasExitTime: 0
@@ -390,7 +390,7 @@ AnimatorStateTransition:
m_Mute: 0 m_Mute: 0
m_IsExit: 1 m_IsExit: 1
serializedVersion: 3 serializedVersion: 3
m_TransitionDuration: 0.12 m_TransitionDuration: 0.15
m_TransitionOffset: 0 m_TransitionOffset: 0
m_ExitTime: 0.9 m_ExitTime: 0.9
m_HasExitTime: 0 m_HasExitTime: 0
@@ -633,7 +633,7 @@ AnimatorState:
m_PrefabInstance: {fileID: 0} m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0} m_PrefabAsset: {fileID: 0}
m_Name: Swing3 m_Name: Swing3
m_Speed: 1 m_Speed: 1.1
m_CycleOffset: 0 m_CycleOffset: 0
m_Transitions: m_Transitions:
- {fileID: 7246856984932168808} - {fileID: 7246856984932168808}
@@ -646,7 +646,7 @@ AnimatorState:
m_MirrorParameterActive: 0 m_MirrorParameterActive: 0
m_CycleOffsetParameterActive: 0 m_CycleOffsetParameterActive: 0
m_TimeParameterActive: 0 m_TimeParameterActive: 0
m_Motion: {fileID: -623603649343465320, guid: 594271330704f684e85012825546489b, type: 3} m_Motion: {fileID: -8890989884591690541, guid: c9e0b6900401fcb42980df96b84db76c, type: 3}
m_Tag: m_Tag:
m_SpeedParameter: m_SpeedParameter:
m_MirrorParameter: m_MirrorParameter:
@@ -727,6 +727,12 @@ AnimatorController:
m_DefaultInt: 0 m_DefaultInt: 0
m_DefaultBool: 0 m_DefaultBool: 0
m_Controller: {fileID: 9100000} 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: m_AnimatorLayers:
- serializedVersion: 5 - serializedVersion: 5
m_Name: Base Layer m_Name: Base Layer
@@ -786,7 +792,7 @@ AnimatorState:
m_MirrorParameterActive: 0 m_MirrorParameterActive: 0
m_CycleOffsetParameterActive: 0 m_CycleOffsetParameterActive: 0
m_TimeParameterActive: 0 m_TimeParameterActive: 0
m_Motion: {fileID: -623603649343465320, guid: 42d545cebb3c8b54ebedba77f9435c49, type: 3} m_Motion: {fileID: 5800342443961765143, guid: 95697c3218923dd43aa160c110bfbc79, type: 3}
m_Tag: m_Tag:
m_SpeedParameter: m_SpeedParameter:
m_MirrorParameter: m_MirrorParameter:
@@ -868,7 +874,7 @@ AnimatorStateTransition:
m_Mute: 0 m_Mute: 0
m_IsExit: 0 m_IsExit: 0
serializedVersion: 3 serializedVersion: 3
m_TransitionDuration: 0.03 m_TransitionDuration: 0.06
m_TransitionOffset: 0 m_TransitionOffset: 0
m_ExitTime: 0.75 m_ExitTime: 0.75
m_HasExitTime: 0 m_HasExitTime: 0
@@ -896,12 +902,37 @@ AnimatorState:
m_MirrorParameterActive: 0 m_MirrorParameterActive: 0
m_CycleOffsetParameterActive: 0 m_CycleOffsetParameterActive: 0
m_TimeParameterActive: 0 m_TimeParameterActive: 0
m_Motion: {fileID: -623603649343465320, guid: 65d8851d683f4784b9f64c2f37c952c7, type: 3} m_Motion: {fileID: -1444214504022159124, guid: a0b8f6f252ea03c46b36b357f5ea81e5, type: 3}
m_Tag: m_Tag:
m_SpeedParameter: m_SpeedParameter:
m_MirrorParameter: m_MirrorParameter:
m_CycleOffsetParameter: m_CycleOffsetParameter:
m_TimeParameter: 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 --- !u!1101 &5014557051428573475
AnimatorStateTransition: AnimatorStateTransition:
m_ObjectHideFlags: 1 m_ObjectHideFlags: 1
@@ -963,6 +994,9 @@ AnimatorStateMachine:
- serializedVersion: 1 - serializedVersion: 1
m_State: {fileID: -8219464881866229938} m_State: {fileID: -8219464881866229938}
m_Position: {x: 515, y: 585, z: 0} 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_ChildStateMachines: []
m_AnyStateTransitions: m_AnyStateTransitions:
- {fileID: 5014557051428573475} - {fileID: 5014557051428573475}
@@ -980,6 +1014,34 @@ AnimatorStateMachine:
m_ExitPosition: {x: 800, y: 120, z: 0} m_ExitPosition: {x: 800, y: 120, z: 0}
m_ParentStateMachinePosition: {x: 800, y: 20, z: 0} m_ParentStateMachinePosition: {x: 800, y: 20, z: 0}
m_DefaultState: {fileID: 8302574255206078140} 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 --- !u!1102 &5736576975501273399
AnimatorState: AnimatorState:
serializedVersion: 6 serializedVersion: 6
@@ -988,7 +1050,7 @@ AnimatorState:
m_PrefabInstance: {fileID: 0} m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0} m_PrefabAsset: {fileID: 0}
m_Name: Swing1 m_Name: Swing1
m_Speed: 1 m_Speed: 1.25
m_CycleOffset: 0 m_CycleOffset: 0
m_Transitions: m_Transitions:
- {fileID: -1792799526728587842} - {fileID: -1792799526728587842}
@@ -1001,12 +1063,37 @@ AnimatorState:
m_MirrorParameterActive: 0 m_MirrorParameterActive: 0
m_CycleOffsetParameterActive: 0 m_CycleOffsetParameterActive: 0
m_TimeParameterActive: 0 m_TimeParameterActive: 0
m_Motion: {fileID: -623603649343465320, guid: dd80b656157b2b441b2466c7646c8441, type: 3} m_Motion: {fileID: -7717634560727564301, guid: 9f773338b2a96174ebe9b63870034ca3, type: 3}
m_Tag: m_Tag:
m_SpeedParameter: m_SpeedParameter:
m_MirrorParameter: m_MirrorParameter:
m_CycleOffsetParameter: m_CycleOffsetParameter:
m_TimeParameter: 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 --- !u!1107 &6065214076707455157
AnimatorStateMachine: AnimatorStateMachine:
serializedVersion: 7 serializedVersion: 7
@@ -1046,7 +1133,7 @@ AnimatorStateTransition:
m_Mute: 0 m_Mute: 0
m_IsExit: 1 m_IsExit: 1
serializedVersion: 3 serializedVersion: 3
m_TransitionDuration: 0.12 m_TransitionDuration: 0.15
m_TransitionOffset: 0 m_TransitionOffset: 0
m_ExitTime: 0.9 m_ExitTime: 0.9
m_HasExitTime: 0 m_HasExitTime: 0
@@ -1066,6 +1153,7 @@ AnimatorState:
m_CycleOffset: 0 m_CycleOffset: 0
m_Transitions: m_Transitions:
- {fileID: -1505565898738729052} - {fileID: -1505565898738729052}
- {fileID: 9125527510138141906}
m_StateMachineBehaviours: [] m_StateMachineBehaviours: []
m_Position: {x: 50, y: 50, z: 0} m_Position: {x: 50, y: 50, z: 0}
m_IKOnFeet: 0 m_IKOnFeet: 0
@@ -1081,6 +1169,31 @@ AnimatorState:
m_MirrorParameter: m_MirrorParameter:
m_CycleOffsetParameter: m_CycleOffsetParameter:
m_TimeParameter: 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 --- !u!1102 &9217853296350750828
AnimatorState: AnimatorState:
serializedVersion: 6 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:
+94
View File
@@ -833,6 +833,7 @@ Transform:
- {fileID: 100450397525093210} - {fileID: 100450397525093210}
- {fileID: 795319999662639885} - {fileID: 795319999662639885}
- {fileID: 1063840860590240591} - {fileID: 1063840860590240591}
- {fileID: 136525282400229282}
m_Father: {fileID: 0} m_Father: {fileID: 0}
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0} m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
--- !u!114 &8290370249712404227 --- !u!114 &8290370249712404227
@@ -2948,6 +2949,99 @@ MeshRenderer:
m_SortingOrder: 0 m_SortingOrder: 0
m_MaskInteraction: 0 m_MaskInteraction: 0
m_AdditionalVertexStreams: {fileID: 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 --- !u!1 &7432474647424217847
GameObject: GameObject:
m_ObjectHideFlags: 0 m_ObjectHideFlags: 0
@@ -80,6 +80,7 @@ namespace ProjectM.Authoring
AddComponent<BlinkState>(entity); // LANTERN Blink: predicted blink window (Movement-archetype Spark), baked idle 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. // MC-4 melee combo: predicted, owner-replicated combo anchor (Step/SwingStartTick/LockUntilTick), baked idle/zero.
AddComponent<MeleeCombo>(entity); 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; // Death gate (enableable, derived from Health by PlayerDeathStateSystem) baked DISABLED = alive;
// plus the server-only respawn timer. // 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> /// 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); } 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> /// <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]; 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); } 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> /// <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() protected override void OnCreate()
{ {
@@ -80,6 +86,8 @@ namespace ProjectM.Client
bool fire = FireFrames > 0; bool fire = FireFrames > 0;
if (FireFrames > 0) FireFrames--; if (FireFrames > 0) FireFrames--;
bool attack = AttackFrames > 0;
if (AttackFrames > 0) AttackFrames--;
// LANTERN 4-socket kit: per-socket fire holds, decremented once per frame. // LANTERN 4-socket kit: per-socket fire holds, decremented once per frame.
bool s0 = SocketFrames[0] > 0; if (SocketFrames[0] > 0) SocketFrames[0]--; bool s0 = SocketFrames[0] > 0; if (SocketFrames[0] > 0) SocketFrames[0]--;
bool s1 = SocketFrames[1] > 0; if (SocketFrames[1] > 0) SocketFrames[1]--; bool s1 = SocketFrames[1] > 0; if (SocketFrames[1] > 0) SocketFrames[1]--;
@@ -99,6 +107,8 @@ namespace ProjectM.Client
input.ValueRW.Scheme = Scheme; input.ValueRW.Scheme = Scheme;
if (fire) if (fire)
input.ValueRW.Fire.Set(); input.ValueRW.Fire.Set();
if (attack)
input.ValueRW.Attack.Set();
if (s0) input.ValueRW.Socket0.Set(); if (s0) input.ValueRW.Socket0.Set();
if (s1) input.ValueRW.Socket1.Set(); if (s1) input.ValueRW.Socket1.Set();
if (s2) input.ValueRW.Socket2.Set(); if (s2) input.ValueRW.Socket2.Set();
@@ -64,6 +64,9 @@ namespace ProjectM.Client
bool _slashActive; bool _slashActive;
float _slashRange, _slashHalf; // live cone geometry re-sampled each frame for the per-frame sweep rebuild 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) 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) 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 // 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); Object.Destroy(_fxRoot.gameObject);
if (_slashMesh != null) Object.Destroy(_slashMesh); if (_slashMesh != null) Object.Destroy(_slashMesh);
if (_slashMat != null) Object.Destroy(_slashMat); if (_slashMat != null) Object.Destroy(_slashMat);
if (_smearMesh != null) Object.Destroy(_smearMesh);
if (_smearMat != null) Object.Destroy(_smearMat);
foreach (var kv in _remoteSlashes) foreach (var kv in _remoteSlashes)
{ {
@@ -441,45 +446,44 @@ namespace ProjectM.Client
PrototypeCameraRig.AddShake(0.04f * step); PrototypeCameraRig.AddShake(0.04f * step);
int comboLen = SystemAPI.TryGetSingleton<TuningConfig>(out var tcfg) ? (int)math.clamp((int)tcfg.MeleeComboLength, 1, 3) : 3; int comboLen = SystemAPI.TryGetSingleton<TuningConfig>(out var tcfg) ? (int)math.clamp((int)tcfg.MeleeComboLength, 1, 3) : 3;
bool finisher = step >= comboLen; 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; float slashHalf = tcfg.MeleeConeHalfAngleRad > 0f ? tcfg.MeleeConeHalfAngleRad : 0.9f;
// Slice-2 deferred reach fix: the SERVER folds per-player StatModifiers into melee range // 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)) if (EntityManager.HasBuffer<StatModifier>(_localPlayer))
slashRange = math.max(0f, StatMath.Apply(slashRange, StatTarget.MeleeRange, slashRange = math.max(0f, StatMath.Apply(slashRange, StatTarget.MeleeRange,
EntityManager.GetBuffer<StatModifier>(_localPlayer, true))); EntityManager.GetBuffer<StatModifier>(_localPlayer, true)));
if (finisher) slashRange *= tcfg.MeleeFinisherMult > 0f ? tcfg.MeleeFinisherMult : 1.8f; if (finisher) slashRange *= tcfg.MeleeFinisherRangeMult > 0f ? tcfg.MeleeFinisherRangeMult : 1.25f; // 07-20 G2.2: REACH-only finisher mult
// MC-4 connect-vs-whiff: client-side cone overlap over the cached enemy snapshot gives an IMMEDIATE // 07-20 G2.4 (review C13): the sweep-reveal (60% of life) completes AT the LIVE contact tick, so
// "you bit" read (the authoritative server damage spark/number arrives a few ticks later). // the drawn edge and the damage moment stay one object under knob tuning.
bool connected = false; Vector3 nearestHit = (Vector3)localPos; float ndist = float.MaxValue; uint contactTicks = MeleeTiming.ContactTicks((byte)step, math.max(0f, tcfg.MeleeContactTicks));
float cosHalf = Mathf.Cos(slashHalf); float arcLife = Mathf.Max(finisher ? 0.5f : 0.34f, (contactTicks / 60f) / 0.6f);
float2 fdir = new float2(face.x, face.z); TriggerSlash((Vector3)localPos, new float2(face.x, face.z), slashRange, slashHalf, step, comboLen, false, arcLife);
foreach (var kv in _cache) // 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; _pendingConnectTick = 0u;
if (MeleeConeMath.InCone(localPos, fdir, slashRange, cosHalf, kv.Value.Pos)) EvaluateMeleeConnect(localPos, step, comboLen); // knob 0 = legacy same-tick connect
{
float d2 = math.distancesq(localPos, kv.Value.Pos);
if (d2 < ndist) { ndist = d2; nearestHit = (Vector3)kv.Value.Pos; connected = true; }
} }
} else
TriggerSlash((Vector3)localPos, new float2(face.x, face.z), slashRange, slashHalf, step, comboLen, connected); // sweeps + ramps + brightens on connect
if (connected)
{ {
Burst(_hitFx, cfg != null ? cfg.Hit : null, nearestHit + Vector3.up * 0.7f, FeelConfig.HitBurstCount); _pendingConnectTick = TickUtil.NonZero(mc.SwingStartTick + contactTicks);
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 (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; _lastLocalSwingTick = mc.SwingStartTick;
_swingTickInit = true; _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.shadowCastingMode = UnityEngine.Rendering.ShadowCastingMode.Off;
_slashMr.receiveShadows = false; _slashMr.receiveShadows = false;
_slashMr.enabled = 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. // 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. // `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; const int seg = 16;
float r1 = Mathf.Max(0.4f, range); float r1 = Mathf.Max(0.4f, range);
float r0 = r1 * 0.45f; float r0 = r1 * innerFrac;
float aStart = sweepSign >= 0 ? -halfAngle : halfAngle; // trailing edge float aStart = sweepSign >= 0 ? -halfAngle : halfAngle; // trailing edge
float aFull = sweepSign >= 0 ? halfAngle : -halfAngle; // far 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 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 verts = new Vector3[(seg + 1) * 2];
var cols = new Color[(seg + 1) * 2]; var cols = new Color[(seg + 1) * 2];
var uvs = new Vector2[(seg + 1) * 2]; var uvs = new Vector2[(seg + 1) * 2];
var tris = new int[seg * 6]; var tris = new int[seg * 6];
for (int i = 0; i <= seg; i++) for (int i = 0; i <= seg; i++)
{ {
float a = Mathf.Lerp(aStart, aEnd, i / (float)seg); float a = Mathf.Lerp(aBase, aEnd, i / (float)seg);
float sx = Mathf.Sin(a), cz = Mathf.Cos(a); float sx = Mathf.Sin(a), cz = Mathf.Cos(a);
verts[i * 2] = new Vector3(sx * r0, 0f, cz * r0); verts[i * 2] = new Vector3(sx * r0, y, cz * r0);
verts[i * 2 + 1] = new Vector3(sx * r1, 0f, cz * r1); 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) 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 cols[i * 2 + 1] = new Color(1f, 1f, 1f, 0f); // outer rim fades out
uvs[i * 2] = new Vector2(0.5f, 0.5f); uvs[i * 2] = new Vector2(0.5f, 0.5f);
uvs[i * 2 + 1] = new Vector2(0.5f, 0.5f); uvs[i * 2 + 1] = new Vector2(0.5f, 0.5f);
@@ -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 // 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. // 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). // 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; if (!FeelConfig.HitStopFreezeEnabled) return;
double now = SystemAPI.Time.ElapsedTime; double now = SystemAPI.Time.ElapsedTime;
if (now - _lastHoldTime < 0.22) return; if (now - _lastHoldTime < 0.22) return;
_lastHoldTime = now; _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; if (_slashMr == null || _slashMat == null) return;
bool finisher = step >= comboLen; 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). // 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; float t = comboLen > 1 ? math.saturate((step - 1) / (float)(comboLen - 1)) : 1f;
_slashTint = finisher _slashTint = finisher
? new Color(3.4f, 2.4f, 0.7f) // finisher: warm HDR flash ? new Color(1.7f, 1.25f, 0.5f) // finisher: lamp-warm accent (07-19 retone)
: Color.Lerp(new Color(1.2f, 1.9f, 2.6f), new Color(1.9f, 2.8f, 3.4f), t); // cool, brighter per step : 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.6f; // brighter arc on a confirmed bite (the immediate "you hit" read) if (connected) _slashTint *= 1.4f; // brighter arc on a confirmed bite (the immediate "you hit" read)
_slashLife = finisher ? 0.28f : Mathf.Lerp(0.15f, 0.20f, t); _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; _slashAge = 0f;
_slashActive = true; _slashActive = true;
_slashMat.color = _slashTint; _slashMat.color = _slashTint;
_slashMr.enabled = true; _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) void UpdateSlash(float dt)
@@ -885,13 +965,26 @@ void TriggerSlash(Vector3 pos, float2 facing, float range, float halfAngle, int
if (!_slashActive || _slashMr == null) return; if (!_slashActive || _slashMr == null) return;
_slashAge += dt; _slashAge += dt;
float u = _slashAge / Mathf.Max(1e-4f, _slashLife); float u = _slashAge / Mathf.Max(1e-4f, _slashLife);
if (u >= 1f) { _slashActive = false; _slashMr.enabled = false; return; } if (u >= 1f) { _slashActive = false; _slashMr.enabled = false; 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 // 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. // travelling through the cleave), then hold + fade — instead of popping the whole cone at once.
float reveal = Mathf.Clamp01(u / 0.6f); float reveal = Mathf.Clamp01(u / 0.6f);
BuildSlashInto(_slashMesh, _slashRange, _slashHalf, reveal, _slashSweepSign); BuildSlashInto(_slashMesh, _slashRange, _slashHalf, reveal, _slashSweepSign);
var c = _slashTint; c.a = u < 0.6f ? 1f : 1f - (u - 0.6f) / 0.4f; _slashMat.color = c; 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 // 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; 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 baseRange = tcfg.MeleeRange > 0f ? tcfg.MeleeRange : 2.6f;
float baseHalf = tcfg.MeleeConeHalfAngleRad > 0f ? tcfg.MeleeConeHalfAngleRad : 0.9f; 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(); _remoteSeen.Clear();
foreach (var (xf, facing, mc, entity) in 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.Half = baseHalf;
rs.SweepSign = (step % 2 == 0) ? -1 : 1; rs.SweepSign = (step % 2 == 0) ? -1 : 1;
rs.Tint = FeelConfig.RemoteSlashColor * (finisher ? 1.5f : 1f); 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.Age = 0f;
rs.Active = true; rs.Active = true;
BuildSlashInto(rs.Mesh, rs.Range, rs.Half, 0f, rs.SweepSign); 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); float reveal = Mathf.Clamp01(u / 0.6f);
BuildSlashInto(rs.Mesh, rs.Range, rs.Half, reveal, rs.SweepSign); 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; 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> /// <summary>Shoulder-lamp beam range (m).</summary>
public static float ShoulderLampRange; 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> /// <summary>Master gate for gamepad rumble (no-op on KBM).</summary>
public static bool RumbleEnabled; public static bool RumbleEnabled;
@@ -276,6 +286,10 @@ namespace ProjectM.Client
RunNaturalSpeed = 2.6f; // measured: A_Run_FwdStrafeF_RootMotion_Masc averageSpeed (run ring) 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) ShoulderLampIntensity = 8f; // 07-16e: the suit lamp actually lights (3 got lost in the murk ambient)
ShoulderLampRange = 12f; 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; 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 PlayerHurtFlashColor = new Color(2.6f, 0.55f, 0.4f, 1f); // C2: hot red-orange body flash when the player is hit
BodyFlashDurationSec = 0.16f; BodyFlashDurationSec = 0.16f;
RemoteSwingEnabled = true; 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; StrikeBeepEnabled = true;
StrikeBeepVolume = 0.40f; StrikeBeepVolume = 0.40f;
StrikeBeepLeadTicks = 8; 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_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_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 // 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 // (MeleeRecoverTicks ~30) so a CHAINED swing re-pulses the bool false->true and re-triggers the Any State
// transition per hit. ~0.22s @ 60Hz. Presentation-only. // 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 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). // 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; 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. // 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; 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). // 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. // 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, comboStep = k_ComboStep, isCone = k_IsCone, abilityDb = abilityBlob,
serverTick = serverTick, attackTicks = k_AttackAnimTicks, serverTick = serverTick, attackTicks = k_AttackAnimTicks,
bank = k_Bank, bankValue = _bankSmoothed, bank = k_Bank, bankValue = _bankSmoothed,
inCombat = k_InCombat, combatIdleTicks = combatIdleTicks,
strideScale = k_StrideScale, trudgeNaturalSpeed = math.max(0.5f, FeelConfig.TrudgeNaturalSpeed), strideScale = k_StrideScale, trudgeNaturalSpeed = math.max(0.5f, FeelConfig.TrudgeNaturalSpeed),
runNaturalSpeed = math.max(0.5f, FeelConfig.RunNaturalSpeed), runNaturalSpeed = math.max(0.5f, FeelConfig.RunNaturalSpeed),
}; };
@@ -131,6 +135,7 @@ static readonly FastAnimatorParameter k_ComboStep = new FastAnimatorParameter(
dt = dt, dt = dt,
strideScale = k_StrideScale, trudgeNaturalSpeed = math.max(0.5f, FeelConfig.TrudgeNaturalSpeed), strideScale = k_StrideScale, trudgeNaturalSpeed = math.max(0.5f, FeelConfig.TrudgeNaturalSpeed),
runNaturalSpeed = math.max(0.5f, FeelConfig.RunNaturalSpeed), runNaturalSpeed = math.max(0.5f, FeelConfig.RunNaturalSpeed),
inCombat = k_InCombat, combatIdleTicks = combatIdleTicks,
prevPos = _prevPos, prevPos = _prevPos,
seen = seen, seen = seen,
}; };
@@ -162,6 +167,8 @@ static readonly FastAnimatorParameter k_ComboStep = new FastAnimatorParameter(
{ {
public FastAnimatorParameter bank; public FastAnimatorParameter bank;
public float bankValue; public float bankValue;
public FastAnimatorParameter inCombat;
public uint combatIdleTicks;
public FastAnimatorParameter strideScale; public FastAnimatorParameter strideScale;
public float trudgeNaturalSpeed; public float trudgeNaturalSpeed;
public float runNaturalSpeed; 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(comboStep)) a.SetParameterValue(comboStep, (int)melee.Step);
if (a.HasParameter(isCone)) a.SetParameterValue(isCone, coneL); 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 float runNaturalSpeed;
public NativeParallelHashMap<Entity, float3> prevPos; public NativeParallelHashMap<Entity, float3> prevPos;
public NativeParallelHashSet<Entity> seen; public NativeParallelHashSet<Entity> seen;
public FastAnimatorParameter inCombat;
public uint combatIdleTicks;
void Execute( void Execute(
Entity e, 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)); 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(comboStep)) a.SetParameterValue(comboStep, (int)melee.Step);
if (a.HasParameter(isCone)) a.SetParameterValue(isCone, coneR); 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; foreach (var c in sm.states) if (c.state.name == name) return c.state;
return null; 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) &lt; CastFacingTicks (26) and &lt; 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 MeleeFinisherMult;
public float MeleeComboLength; 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 // 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. // preferred targets); a closer player 'in the way' still wins. Read server-side by EnemyAISystem.
public float StructureAggroWeight; public float StructureAggroWeight;
@@ -76,15 +84,18 @@ namespace ProjectM.Simulation
ChargerLungeDurationTicks = 18f, ChargerLungeDurationTicks = 18f,
ChargerWhiffStaggerTicks = 36f, ChargerWhiffStaggerTicks = 36f,
GruntWindupTicks = Tuning.AttackWindupTicks, // canonical Grunt-windup source (TelegraphTests couples to it) GruntWindupTicks = Tuning.AttackWindupTicks, // canonical Grunt-windup source (TelegraphTests couples to it)
MeleeDamage = 18f, MeleeDamage = 30f, // 07-19 heavy-weapon retime: bigger hit, slower cadence (DPS ~held: 18/0.27s -> 30/0.50s)
MeleeRange = 2.6f, 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) MeleeConeHalfAngleRad = 0.9f, // ~51.5 deg half-angle (~103 deg cleave arc)
MeleeRecoverTicks = 16f, // light-swing lock/recovery (~0.27s) MeleeRecoverTicks = 30f, // heavy-swing lock/recovery (~0.50s; 07-19 weighty-melee retime)
MeleeChainGraceTicks = 18f, // window after the lock to chain the next hit (~0.3s) MeleeChainGraceTicks = 24f, // window after the lock to chain the next hit (~0.4s)
MeleeSwingMoveScale = 0.35f, // movement-commit while swinging (0..1) MeleeSwingMoveScale = 0.35f, // movement-commit while swinging (0..1)
MeleeKnockbackSpeed = 6f, 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 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 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 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 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.CastTurnRateDeg:
case TuningKnob.MoveSharpness: case TuningKnob.MoveSharpness:
case TuningKnob.SeparationMaxSpeed: 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); return math.max(0f, value);
// tick knobs: >= 1 (a 0 tick count is degenerate; a 0 i-frame window divides-by-zero in DashSystem). // 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. // 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.TurnRateDeg: c.TurnRateDeg = value; break;
case TuningKnob.CastTurnRateDeg: c.CastTurnRateDeg = value; break; case TuningKnob.CastTurnRateDeg: c.CastTurnRateDeg = value; break;
case TuningKnob.MoveSharpness: c.MoveSharpness = 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) // 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.TurnRateDeg: return c.TurnRateDeg;
case TuningKnob.CastTurnRateDeg: return c.CastTurnRateDeg; case TuningKnob.CastTurnRateDeg: return c.CastTurnRateDeg;
case TuningKnob.MoveSharpness: return c.MoveSharpness; 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; default: return 0f;
} }
@@ -223,6 +243,9 @@ namespace ProjectM.Simulation
TurnRateDeg = c.TurnRateDeg, TurnRateDeg = c.TurnRateDeg,
CastTurnRateDeg = c.CastTurnRateDeg, CastTurnRateDeg = c.CastTurnRateDeg,
MoveSharpness = c.MoveSharpness, MoveSharpness = c.MoveSharpness,
MeleeBufferTicks = c.MeleeBufferTicks,
MeleeFinisherRangeMult = c.MeleeFinisherRangeMult,
MeleeContactTicks = c.MeleeContactTicks,
}; };
@@ -254,6 +277,9 @@ namespace ProjectM.Simulation
TurnRateDeg = r.TurnRateDeg, TurnRateDeg = r.TurnRateDeg,
CastTurnRateDeg = r.CastTurnRateDeg, CastTurnRateDeg = r.CastTurnRateDeg,
MoveSharpness = r.MoveSharpness, 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 TurnRateDeg = 26;
public const byte CastTurnRateDeg = 27; public const byte CastTurnRateDeg = 27;
public const byte MoveSharpness = 28; 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> /// <summary>Knob count (overlay iteration bound).</summary>
public const byte Count = 29; public const byte Count = 32;
} }
/// <summary> /// <summary>
@@ -327,6 +356,9 @@ namespace ProjectM.Simulation
public float TurnRateDeg; public float TurnRateDeg;
public float CastTurnRateDeg; public float CastTurnRateDeg;
public float MoveSharpness; 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. // 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 DefaultSharpness = CharacterComponent.DefaultGroundedSharpness; // CharacterComponent.GetDefault() base
const float SimTickRate = 60f; 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] [BurstCompile]
public void OnUpdate(ref SystemState state) public void OnUpdate(ref SystemState state)
{ {
@@ -50,19 +59,31 @@ namespace ProjectM.Simulation
float dashSharpness = t.DashSharpness; float dashSharpness = t.DashSharpness;
// 07-15 underwater feel: the restore target honors the MoveSharpness dev-override (0 = authored const). // 07-15 underwater feel: the restore target honors the MoveSharpness dev-override (0 = authored const).
float baseSharpness = t.MoveSharpness > 0f ? t.MoveSharpness : DefaultSharpness; 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>, SystemAPI.Query<RefRW<DashState>, RefRW<DashCooldown>, RefRW<CharacterControl>,
RefRW<CharacterComponent>, RefRO<PlayerInput>, RefRO<PlayerFacing>>() 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) --- // --- START (idempotent: fresh press + cooldown ready + not already mid-dash) ---
bool ready = cd.ValueRO.NextTick == 0u bool ready = cd.ValueRO.NextTick == 0u
|| !new NetworkTick(cd.ValueRO.NextTick).IsNewerThan(serverTick); || !new NetworkTick(cd.ValueRO.NextTick).IsNewerThan(serverTick);
bool inWindow = ds.ValueRO.RecoverUntilTick != 0u bool inWindow = ds.ValueRO.RecoverUntilTick != 0u
&& new NetworkTick(ds.ValueRO.RecoverUntilTick).IsNewerThan(serverTick); && 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 // 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 — // 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 /// <summary>Raw ServerTick the swing's movement-commit / recovery lock ends (NonZero). Gates the next press
/// (locked while now &lt; this) and anchors the chain window [LockUntilTick, LockUntilTick + grace).</summary> /// (locked while now &lt; this) and anchors the chain window [LockUntilTick, LockUntilTick + grace).</summary>
[GhostField] public uint LockUntilTick; [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<InventorySlot> m_InvLookup;
BufferLookup<StatModifier> m_StatModLookup; BufferLookup<StatModifier> m_StatModLookup;
ComponentLookup<BoonEffects> m_BoonEffectsLookup; // Phase 1.7 (player query is at the 7-type cap -> lookup) 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> /// <summary>Phase 1.7 Detonating Finisher blast radius (planar, tunable).</summary>
const float k_DetonateRadius = 3.5f; 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] [BurstCompile]
public void OnCreate(ref SystemState state) public void OnCreate(ref SystemState state)
{ {
@@ -72,6 +100,7 @@ namespace ProjectM.Simulation
m_InvLookup = state.GetBufferLookup<InventorySlot>(isReadOnly: false); m_InvLookup = state.GetBufferLookup<InventorySlot>(isReadOnly: false);
m_StatModLookup = state.GetBufferLookup<StatModifier>(isReadOnly: true); m_StatModLookup = state.GetBufferLookup<StatModifier>(isReadOnly: true);
m_BoonEffectsLookup = state.GetComponentLookup<BoonEffects>(isReadOnly: true); m_BoonEffectsLookup = state.GetComponentLookup<BoonEffects>(isReadOnly: true);
m_PendingLookup = state.GetComponentLookup<MeleeCleavePending>(isReadOnly: false);
state.RequireForUpdate<NetworkTime>(); state.RequireForUpdate<NetworkTime>();
} }
@@ -92,6 +121,9 @@ namespace ProjectM.Simulation
float moveScale = math.max(0f, t.MeleeSwingMoveScale); float moveScale = math.max(0f, t.MeleeSwingMoveScale);
float knockSpeed = math.max(0f, t.MeleeKnockbackSpeed); float knockSpeed = math.max(0f, t.MeleeKnockbackSpeed);
float finisherMult = math.max(1f, t.MeleeFinisherMult); 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); byte comboLen = (byte)math.clamp((int)t.MeleeComboLength, 1, 3);
uint stamp = TickUtil.NonZero(now); uint stamp = TickUtil.NonZero(now);
uint knockUntil = TickUtil.NonZero(now + (uint)math.max(1, Tuning.KnockbackDurationTicks)); 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; var cleaves = isServer ? new NativeList<PendingCleave>(Allocator.Temp) : default;
m_StatModLookup.Update(ref state); m_StatModLookup.Update(ref state);
m_BoonEffectsLookup.Update(ref state); // Phase 1.7: per-player boon flags (read inside the player loop) 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 foreach (var (mc, control, input, facing, xform, owner, ds, entity) in
SystemAPI.Query<RefRW<MeleeCombo>, RefRW<CharacterControl>, RefRO<PlayerInput>, SystemAPI.Query<RefRW<MeleeCombo>, RefRW<CharacterControl>, RefRO<PlayerInput>,
@@ -119,10 +152,48 @@ namespace ProjectM.Simulation
bool locked = mc.ValueRO.LockUntilTick != 0u bool locked = mc.ValueRO.LockUntilTick != 0u
&& new NetworkTick(mc.ValueRO.LockUntilTick).IsNewerThan(serverTick); && 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; bool swingStarted = false;
byte swingStep = 0; 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; bool inChainWindow = false;
if (mc.ValueRO.LockUntilTick != 0u) if (mc.ValueRO.LockUntilTick != 0u)
@@ -139,6 +210,7 @@ namespace ProjectM.Simulation
mc.ValueRW.Step = swingStep; mc.ValueRW.Step = swingStep;
mc.ValueRW.SwingStartTick = TickUtil.NonZero(now); mc.ValueRW.SwingStartTick = TickUtil.NonZero(now);
mc.ValueRW.LockUntilTick = TickUtil.NonZero(now + stepRecover); mc.ValueRW.LockUntilTick = TickUtil.NonZero(now + stepRecover);
mc.ValueRW.BufferedAttackTick = 0u; // consumed (or superseded by a fresh press)
swingStarted = true; swingStarted = true;
} }
@@ -150,33 +222,33 @@ namespace ProjectM.Simulation
if (inSwingWindow && !dashActive) if (inSwingWindow && !dashActive)
control.ValueRW.MoveVelocity *= moveScale; 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) if (swingStarted && isServer)
{ {
bool isFin = swingStep >= comboLen; uint ct = MeleeTiming.ContactTicks(swingStep, contactKnob);
// Slice 2: fold the player's class/run StatModifiers onto the live-tunable melee base so the if (ct > 0u && hasPending)
// 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
{ {
From = xform.ValueRO.Position, // FLUSH a still-armed older cleave first (pathological knob combos can push contact past the
Face = face, // recover lock -- fire it early rather than lose it; review wf_000bc247 C0/C12).
Damage = isFin ? pDamage * finisherMult : pDamage, var pend = m_PendingLookup[entity];
Range = isFin ? pRange * finisherMult : pRange, if (pend.ResolveTick != 0u)
KnockSpeed = isFin ? knockSpeed * finisherMult : knockSpeed, cleaves.Add(BuildCleave(pend.Step, comboLen, baseDamage, baseRange, knockSpeed,
OwnerId = owner.ValueRO.NetworkId, finisherMult, finisherRangeMult, stamp, knockUntil, xform.ValueRO.Position,
Stamp = stamp, input.ValueRO.Aim, facing.ValueRO.Direction, owner.ValueRO.NetworkId,
KnockUntil = knockUntil, m_BoonEffectsLookup.HasComponent(entity) ? m_BoonEffectsLookup[entity].Flags : (byte)0,
IsFinisher = isFin, m_StatModLookup.HasBuffer(entity), m_StatModLookup.HasBuffer(entity) ? m_StatModLookup[entity] : default));
Detonate = (bflags & BoonFlag.FinisherDetonate) != 0, m_PendingLookup[entity] = new MeleeCleavePending { ResolveTick = TickUtil.NonZero(now + ct), Step = swingStep };
Pull = (bflags & BoonFlag.KnockToPull) != 0, }
}); 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 /// <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> /// 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] [BurstCompile]
public void OnUpdate(ref SystemState state) public void OnUpdate(ref SystemState state)
@@ -34,6 +34,17 @@ namespace ProjectM.Tests
group.SortSystems(); group.SortSystems();
world.SetTime(new TimeData(elapsedTime: 0f, deltaTime: 1f / 60f)); world.SetTime(new TimeData(elapsedTime: 0f, deltaTime: 1f / 60f));
SetServerTick(world, tick); 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); return (world, group);
} }
@@ -41,6 +52,7 @@ namespace ProjectM.Tests
{ {
var e = em.CreateEntity(); var e = em.CreateEntity();
em.AddComponentData(e, new MeleeCombo()); 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 CharacterControl { MoveVelocity = float3.zero });
em.AddComponentData(e, new PlayerInput()); em.AddComponentData(e, new PlayerInput());
em.AddComponentData(e, new PlayerFacing { Direction = facing }); em.AddComponentData(e, new PlayerFacing { Direction = facing });
@@ -71,6 +83,15 @@ namespace ProjectM.Tests
em.SetComponentData(player, pi); 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) ---- // ---- state machine (plain world) ----
[Test] [Test]
@@ -359,9 +380,11 @@ namespace ProjectM.Tests
using (world) using (world)
{ {
var em = world.EntityManager; 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; cfg.MeleeComboLength = 2f;
em.SetComponentData(em.CreateEntity(typeof(TuningConfig)), cfg); em.SetComponentData(cfgEnt, cfg);
var p = MakePlayer(em, new float2(0, 1)); var p = MakePlayer(em, new float2(0, 1));
em.SetComponentData(p, new MeleeCombo { Step = 1, SwingStartTick = 100, LockUntilTick = 116 }); // chain 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");
}
} }
} }
}