Further Tests & Progress
This commit is contained in:
@@ -0,0 +1,138 @@
|
||||
%YAML 1.1
|
||||
%TAG !u! tag:unity3d.com,2011:
|
||||
--- !u!21 &2100000
|
||||
Material:
|
||||
serializedVersion: 8
|
||||
m_ObjectHideFlags: 0
|
||||
m_CorrespondingSourceObject: {fileID: 0}
|
||||
m_PrefabInstance: {fileID: 0}
|
||||
m_PrefabAsset: {fileID: 0}
|
||||
m_Name: M_ResourceNode
|
||||
m_Shader: {fileID: 4800000, guid: 933532a4fcc9baf4fa0491de14d08ed7, type: 3}
|
||||
m_Parent: {fileID: 0}
|
||||
m_ModifiedSerializedProperties: 0
|
||||
m_ValidKeywords:
|
||||
- _EMISSION
|
||||
m_InvalidKeywords: []
|
||||
m_LightmapFlags: 1
|
||||
m_EnableInstancingVariants: 0
|
||||
m_DoubleSidedGI: 0
|
||||
m_CustomRenderQueue: -1
|
||||
stringTagMap:
|
||||
RenderType: Opaque
|
||||
disabledShaderPasses:
|
||||
- MOTIONVECTORS
|
||||
m_LockedProperties:
|
||||
m_SavedProperties:
|
||||
serializedVersion: 3
|
||||
m_TexEnvs:
|
||||
- _BaseMap:
|
||||
m_Texture: {fileID: 0}
|
||||
m_Scale: {x: 1, y: 1}
|
||||
m_Offset: {x: 0, y: 0}
|
||||
- _BumpMap:
|
||||
m_Texture: {fileID: 0}
|
||||
m_Scale: {x: 1, y: 1}
|
||||
m_Offset: {x: 0, y: 0}
|
||||
- _DetailAlbedoMap:
|
||||
m_Texture: {fileID: 0}
|
||||
m_Scale: {x: 1, y: 1}
|
||||
m_Offset: {x: 0, y: 0}
|
||||
- _DetailMask:
|
||||
m_Texture: {fileID: 0}
|
||||
m_Scale: {x: 1, y: 1}
|
||||
m_Offset: {x: 0, y: 0}
|
||||
- _DetailNormalMap:
|
||||
m_Texture: {fileID: 0}
|
||||
m_Scale: {x: 1, y: 1}
|
||||
m_Offset: {x: 0, y: 0}
|
||||
- _EmissionMap:
|
||||
m_Texture: {fileID: 0}
|
||||
m_Scale: {x: 1, y: 1}
|
||||
m_Offset: {x: 0, y: 0}
|
||||
- _MainTex:
|
||||
m_Texture: {fileID: 0}
|
||||
m_Scale: {x: 1, y: 1}
|
||||
m_Offset: {x: 0, y: 0}
|
||||
- _MetallicGlossMap:
|
||||
m_Texture: {fileID: 0}
|
||||
m_Scale: {x: 1, y: 1}
|
||||
m_Offset: {x: 0, y: 0}
|
||||
- _OcclusionMap:
|
||||
m_Texture: {fileID: 0}
|
||||
m_Scale: {x: 1, y: 1}
|
||||
m_Offset: {x: 0, y: 0}
|
||||
- _ParallaxMap:
|
||||
m_Texture: {fileID: 0}
|
||||
m_Scale: {x: 1, y: 1}
|
||||
m_Offset: {x: 0, y: 0}
|
||||
- _SpecGlossMap:
|
||||
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:
|
||||
- _AddPrecomputedVelocity: 0
|
||||
- _AlphaClip: 0
|
||||
- _AlphaToMask: 0
|
||||
- _Blend: 0
|
||||
- _BlendModePreserveSpecular: 1
|
||||
- _BumpScale: 1
|
||||
- _ClearCoatMask: 0
|
||||
- _ClearCoatSmoothness: 0
|
||||
- _Cull: 2
|
||||
- _Cutoff: 0.5
|
||||
- _DetailAlbedoMapScale: 1
|
||||
- _DetailNormalMapScale: 1
|
||||
- _DstBlend: 0
|
||||
- _DstBlendAlpha: 0
|
||||
- _EnvironmentReflections: 1
|
||||
- _GlossMapScale: 0
|
||||
- _Glossiness: 0
|
||||
- _GlossyReflections: 0
|
||||
- _Metallic: 0.1
|
||||
- _OcclusionStrength: 1
|
||||
- _Parallax: 0.005
|
||||
- _QueueOffset: 0
|
||||
- _ReceiveShadows: 1
|
||||
- _Smoothness: 0.45
|
||||
- _SmoothnessTextureChannel: 0
|
||||
- _SpecularHighlights: 1
|
||||
- _SrcBlend: 1
|
||||
- _SrcBlendAlpha: 1
|
||||
- _Surface: 0
|
||||
- _WorkflowMode: 1
|
||||
- _XRMotionVectorsPass: 1
|
||||
- _ZWrite: 1
|
||||
m_Colors:
|
||||
- _BaseColor: {r: 0.2, g: 0.15, b: 0.06, a: 1}
|
||||
- _Color: {r: 0.19999996, g: 0.14999998, b: 0.059999965, a: 1}
|
||||
- _EmissionColor: {r: 1.5, g: 1, b: 0.25, a: 1}
|
||||
- _SpecColor: {r: 0.19999996, g: 0.19999996, b: 0.19999996, a: 1}
|
||||
m_BuildTextureStacks: []
|
||||
m_AllowLocking: 1
|
||||
--- !u!114 &2397095980361476393
|
||||
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
|
||||
@@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: ec6276f885d7500448a10531a20bda9a
|
||||
NativeFormatImporter:
|
||||
externalObjects: {}
|
||||
mainObjectFileID: 2100000
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,138 @@
|
||||
%YAML 1.1
|
||||
%TAG !u! tag:unity3d.com,2011:
|
||||
--- !u!114 &-2255720116314610382
|
||||
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_Storage
|
||||
m_Shader: {fileID: 4800000, guid: 933532a4fcc9baf4fa0491de14d08ed7, type: 3}
|
||||
m_Parent: {fileID: 0}
|
||||
m_ModifiedSerializedProperties: 0
|
||||
m_ValidKeywords:
|
||||
- _EMISSION
|
||||
m_InvalidKeywords: []
|
||||
m_LightmapFlags: 1
|
||||
m_EnableInstancingVariants: 0
|
||||
m_DoubleSidedGI: 0
|
||||
m_CustomRenderQueue: -1
|
||||
stringTagMap:
|
||||
RenderType: Opaque
|
||||
disabledShaderPasses:
|
||||
- MOTIONVECTORS
|
||||
m_LockedProperties:
|
||||
m_SavedProperties:
|
||||
serializedVersion: 3
|
||||
m_TexEnvs:
|
||||
- _BaseMap:
|
||||
m_Texture: {fileID: 0}
|
||||
m_Scale: {x: 1, y: 1}
|
||||
m_Offset: {x: 0, y: 0}
|
||||
- _BumpMap:
|
||||
m_Texture: {fileID: 0}
|
||||
m_Scale: {x: 1, y: 1}
|
||||
m_Offset: {x: 0, y: 0}
|
||||
- _DetailAlbedoMap:
|
||||
m_Texture: {fileID: 0}
|
||||
m_Scale: {x: 1, y: 1}
|
||||
m_Offset: {x: 0, y: 0}
|
||||
- _DetailMask:
|
||||
m_Texture: {fileID: 0}
|
||||
m_Scale: {x: 1, y: 1}
|
||||
m_Offset: {x: 0, y: 0}
|
||||
- _DetailNormalMap:
|
||||
m_Texture: {fileID: 0}
|
||||
m_Scale: {x: 1, y: 1}
|
||||
m_Offset: {x: 0, y: 0}
|
||||
- _EmissionMap:
|
||||
m_Texture: {fileID: 0}
|
||||
m_Scale: {x: 1, y: 1}
|
||||
m_Offset: {x: 0, y: 0}
|
||||
- _MainTex:
|
||||
m_Texture: {fileID: 0}
|
||||
m_Scale: {x: 1, y: 1}
|
||||
m_Offset: {x: 0, y: 0}
|
||||
- _MetallicGlossMap:
|
||||
m_Texture: {fileID: 0}
|
||||
m_Scale: {x: 1, y: 1}
|
||||
m_Offset: {x: 0, y: 0}
|
||||
- _OcclusionMap:
|
||||
m_Texture: {fileID: 0}
|
||||
m_Scale: {x: 1, y: 1}
|
||||
m_Offset: {x: 0, y: 0}
|
||||
- _ParallaxMap:
|
||||
m_Texture: {fileID: 0}
|
||||
m_Scale: {x: 1, y: 1}
|
||||
m_Offset: {x: 0, y: 0}
|
||||
- _SpecGlossMap:
|
||||
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:
|
||||
- _AddPrecomputedVelocity: 0
|
||||
- _AlphaClip: 0
|
||||
- _AlphaToMask: 0
|
||||
- _Blend: 0
|
||||
- _BlendModePreserveSpecular: 1
|
||||
- _BumpScale: 1
|
||||
- _ClearCoatMask: 0
|
||||
- _ClearCoatSmoothness: 0
|
||||
- _Cull: 2
|
||||
- _Cutoff: 0.5
|
||||
- _DetailAlbedoMapScale: 1
|
||||
- _DetailNormalMapScale: 1
|
||||
- _DstBlend: 0
|
||||
- _DstBlendAlpha: 0
|
||||
- _EnvironmentReflections: 1
|
||||
- _GlossMapScale: 0
|
||||
- _Glossiness: 0
|
||||
- _GlossyReflections: 0
|
||||
- _Metallic: 0.2
|
||||
- _OcclusionStrength: 1
|
||||
- _Parallax: 0.005
|
||||
- _QueueOffset: 0
|
||||
- _ReceiveShadows: 1
|
||||
- _Smoothness: 0.6
|
||||
- _SmoothnessTextureChannel: 0
|
||||
- _SpecularHighlights: 1
|
||||
- _SrcBlend: 1
|
||||
- _SrcBlendAlpha: 1
|
||||
- _Surface: 0
|
||||
- _WorkflowMode: 1
|
||||
- _XRMotionVectorsPass: 1
|
||||
- _ZWrite: 1
|
||||
m_Colors:
|
||||
- _BaseColor: {r: 0.1, g: 0.12, b: 0.16, a: 1}
|
||||
- _Color: {r: 0.09999997, g: 0.11999995, b: 0.15999997, a: 1}
|
||||
- _EmissionColor: {r: 0.2, g: 0.55, b: 1.25, a: 1}
|
||||
- _SpecColor: {r: 0.19999996, g: 0.19999996, b: 0.19999996, a: 1}
|
||||
m_BuildTextureStacks: []
|
||||
m_AllowLocking: 1
|
||||
@@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 9190cd8b2a7600a49a543832e1e34071
|
||||
NativeFormatImporter:
|
||||
externalObjects: {}
|
||||
mainObjectFileID: 2100000
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,138 @@
|
||||
%YAML 1.1
|
||||
%TAG !u! tag:unity3d.com,2011:
|
||||
--- !u!21 &2100000
|
||||
Material:
|
||||
serializedVersion: 8
|
||||
m_ObjectHideFlags: 0
|
||||
m_CorrespondingSourceObject: {fileID: 0}
|
||||
m_PrefabInstance: {fileID: 0}
|
||||
m_PrefabAsset: {fileID: 0}
|
||||
m_Name: M_Turret
|
||||
m_Shader: {fileID: 4800000, guid: 933532a4fcc9baf4fa0491de14d08ed7, type: 3}
|
||||
m_Parent: {fileID: 0}
|
||||
m_ModifiedSerializedProperties: 0
|
||||
m_ValidKeywords:
|
||||
- _EMISSION
|
||||
m_InvalidKeywords: []
|
||||
m_LightmapFlags: 1
|
||||
m_EnableInstancingVariants: 0
|
||||
m_DoubleSidedGI: 0
|
||||
m_CustomRenderQueue: -1
|
||||
stringTagMap:
|
||||
RenderType: Opaque
|
||||
disabledShaderPasses:
|
||||
- MOTIONVECTORS
|
||||
m_LockedProperties:
|
||||
m_SavedProperties:
|
||||
serializedVersion: 3
|
||||
m_TexEnvs:
|
||||
- _BaseMap:
|
||||
m_Texture: {fileID: 0}
|
||||
m_Scale: {x: 1, y: 1}
|
||||
m_Offset: {x: 0, y: 0}
|
||||
- _BumpMap:
|
||||
m_Texture: {fileID: 0}
|
||||
m_Scale: {x: 1, y: 1}
|
||||
m_Offset: {x: 0, y: 0}
|
||||
- _DetailAlbedoMap:
|
||||
m_Texture: {fileID: 0}
|
||||
m_Scale: {x: 1, y: 1}
|
||||
m_Offset: {x: 0, y: 0}
|
||||
- _DetailMask:
|
||||
m_Texture: {fileID: 0}
|
||||
m_Scale: {x: 1, y: 1}
|
||||
m_Offset: {x: 0, y: 0}
|
||||
- _DetailNormalMap:
|
||||
m_Texture: {fileID: 0}
|
||||
m_Scale: {x: 1, y: 1}
|
||||
m_Offset: {x: 0, y: 0}
|
||||
- _EmissionMap:
|
||||
m_Texture: {fileID: 0}
|
||||
m_Scale: {x: 1, y: 1}
|
||||
m_Offset: {x: 0, y: 0}
|
||||
- _MainTex:
|
||||
m_Texture: {fileID: 0}
|
||||
m_Scale: {x: 1, y: 1}
|
||||
m_Offset: {x: 0, y: 0}
|
||||
- _MetallicGlossMap:
|
||||
m_Texture: {fileID: 0}
|
||||
m_Scale: {x: 1, y: 1}
|
||||
m_Offset: {x: 0, y: 0}
|
||||
- _OcclusionMap:
|
||||
m_Texture: {fileID: 0}
|
||||
m_Scale: {x: 1, y: 1}
|
||||
m_Offset: {x: 0, y: 0}
|
||||
- _ParallaxMap:
|
||||
m_Texture: {fileID: 0}
|
||||
m_Scale: {x: 1, y: 1}
|
||||
m_Offset: {x: 0, y: 0}
|
||||
- _SpecGlossMap:
|
||||
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:
|
||||
- _AddPrecomputedVelocity: 0
|
||||
- _AlphaClip: 0
|
||||
- _AlphaToMask: 0
|
||||
- _Blend: 0
|
||||
- _BlendModePreserveSpecular: 1
|
||||
- _BumpScale: 1
|
||||
- _ClearCoatMask: 0
|
||||
- _ClearCoatSmoothness: 0
|
||||
- _Cull: 2
|
||||
- _Cutoff: 0.5
|
||||
- _DetailAlbedoMapScale: 1
|
||||
- _DetailNormalMapScale: 1
|
||||
- _DstBlend: 0
|
||||
- _DstBlendAlpha: 0
|
||||
- _EnvironmentReflections: 1
|
||||
- _GlossMapScale: 0
|
||||
- _Glossiness: 0
|
||||
- _GlossyReflections: 0
|
||||
- _Metallic: 0.15
|
||||
- _OcclusionStrength: 1
|
||||
- _Parallax: 0.005
|
||||
- _QueueOffset: 0
|
||||
- _ReceiveShadows: 1
|
||||
- _Smoothness: 0.55
|
||||
- _SmoothnessTextureChannel: 0
|
||||
- _SpecularHighlights: 1
|
||||
- _SrcBlend: 1
|
||||
- _SrcBlendAlpha: 1
|
||||
- _Surface: 0
|
||||
- _WorkflowMode: 1
|
||||
- _XRMotionVectorsPass: 1
|
||||
- _ZWrite: 1
|
||||
m_Colors:
|
||||
- _BaseColor: {r: 0.12, g: 0.18, b: 0.13, a: 1}
|
||||
- _Color: {r: 0.11999995, g: 0.17999998, b: 0.12999997, a: 1}
|
||||
- _EmissionColor: {r: 0.25, g: 1.5, b: 0.6, a: 1}
|
||||
- _SpecColor: {r: 0.19999996, g: 0.19999996, b: 0.19999996, a: 1}
|
||||
m_BuildTextureStacks: []
|
||||
m_AllowLocking: 1
|
||||
--- !u!114 &4815079309525708667
|
||||
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
|
||||
@@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: c01dbe8a4e818c5469eceae8b286bf4d
|
||||
NativeFormatImporter:
|
||||
externalObjects: {}
|
||||
mainObjectFileID: 2100000
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -69,7 +69,7 @@ MeshRenderer:
|
||||
m_RenderingLayerMask: 1
|
||||
m_RendererPriority: 0
|
||||
m_Materials:
|
||||
- {fileID: 2100000, guid: ba23fa98368bb4a4997bfd08547c83ee, type: 2}
|
||||
- {fileID: 2100000, guid: ec6276f885d7500448a10531a20bda9a, type: 2}
|
||||
m_StaticBatchInfo:
|
||||
firstSubMesh: 0
|
||||
subMeshCount: 0
|
||||
|
||||
@@ -69,7 +69,7 @@ MeshRenderer:
|
||||
m_RenderingLayerMask: 1
|
||||
m_RendererPriority: 0
|
||||
m_Materials:
|
||||
- {fileID: 2100000, guid: 9f969dd6da75bcf40966c15b56b3e8a8, type: 2}
|
||||
- {fileID: 2100000, guid: 9190cd8b2a7600a49a543832e1e34071, type: 2}
|
||||
m_StaticBatchInfo:
|
||||
firstSubMesh: 0
|
||||
subMeshCount: 0
|
||||
|
||||
@@ -69,7 +69,7 @@ MeshRenderer:
|
||||
m_RenderingLayerMask: 1
|
||||
m_RendererPriority: 0
|
||||
m_Materials:
|
||||
- {fileID: 2100000, guid: 175e5bfb2ad02704caa3d1753aad499d, type: 2}
|
||||
- {fileID: 2100000, guid: c01dbe8a4e818c5469eceae8b286bf4d, type: 2}
|
||||
m_StaticBatchInfo:
|
||||
firstSubMesh: 0
|
||||
subMeshCount: 0
|
||||
|
||||
@@ -50,6 +50,8 @@ namespace ProjectM.Authoring
|
||||
AttackCooldownTicks = authoring.AttackCooldownTicks,
|
||||
});
|
||||
AddComponent(entity, new EnemyAttackCooldown { NextAttackTick = 0 });
|
||||
AddComponent<KnockbackState>(entity); // server-only recoil state (zero = not knocked)
|
||||
AddComponent<AttackWindup>(entity); // replicated telegraph signal (zero = not winding up)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -67,6 +67,8 @@ namespace ProjectM.Authoring
|
||||
|
||||
// Empty replicated modifier stack (grown by upgrades/pickups/debug hook, server-authoritative).
|
||||
AddBuffer<StatModifier>(entity);
|
||||
// Server-only expiry tracker for timed buffs (paired with a StatModifier by SourceId; not replicated).
|
||||
AddBuffer<TimedModifier>(entity);
|
||||
|
||||
// Combat: server-authoritative health (Current replicated for display), the player's
|
||||
// damageable hit radius, predicted cooldown state, and the per-tick damage inbox.
|
||||
|
||||
@@ -38,7 +38,10 @@ namespace ProjectM.Client
|
||||
float3 _lastKbmGroundPoint; // last valid cursor ground point (held when the projection misses)
|
||||
bool _haveKbmPoint; // true after the first valid KBM ground hit
|
||||
bool _cursorHidden; // tracks the applied Cursor.visible state (avoid per-frame churn)
|
||||
bool _cursorTouched; // we changed the OS cursor at least once -> restore on destroy
|
||||
bool _cursorTouched;
|
||||
GameObject _tether;
|
||||
LineRenderer _tetherLine;
|
||||
Material _tetherMat; // we changed the OS cursor at least once -> restore on destroy
|
||||
|
||||
protected override void OnStartRunning()
|
||||
{
|
||||
@@ -52,9 +55,12 @@ namespace ProjectM.Client
|
||||
|
||||
bool haveTarget = false;
|
||||
float3 ringPos = default;
|
||||
float3 lpPos = default;
|
||||
float2 lpFacing = default;
|
||||
|
||||
EntityManager.CompleteDependencyBeforeRO<LocalTransform>();
|
||||
EntityManager.CompleteDependencyBeforeRO<PlayerFacing>();
|
||||
EntityManager.CompleteDependencyBeforeRO<Health>();
|
||||
foreach (var (xform, facing) in
|
||||
SystemAPI.Query<RefRO<LocalTransform>, RefRO<PlayerFacing>>()
|
||||
.WithAll<GhostOwnerIsLocal, PlayerTag>())
|
||||
@@ -87,6 +93,8 @@ namespace ProjectM.Client
|
||||
}
|
||||
|
||||
ringPos.y += ReticleLiftY;
|
||||
lpPos = playerPos;
|
||||
lpFacing = facing.ValueRO.Direction;
|
||||
haveTarget = true;
|
||||
break;
|
||||
}
|
||||
@@ -97,6 +105,43 @@ namespace ProjectM.Client
|
||||
if (_reticle.activeSelf != haveTarget) _reticle.SetActive(haveTarget);
|
||||
}
|
||||
|
||||
// Lock-on tether (cosmetic aim HINT - the client computes nearest enemy itself; the server's actual
|
||||
// gamepad auto-target cone may differ, so divergence is acceptable, not a bug).
|
||||
bool tetherShown = false;
|
||||
if (_tetherLine != null && haveTarget && FeelConfig.LockOnEnabled
|
||||
&& (!FeelConfig.LockOnGamepadOnly || scheme == InputSchemeId.Gamepad))
|
||||
{
|
||||
float2 fdir = lpFacing;
|
||||
if (math.lengthsq(fdir) < 1e-6f) fdir = new float2(0f, 1f);
|
||||
fdir = math.normalize(fdir);
|
||||
float rangeSq = FeelConfig.LockOnRange * FeelConfig.LockOnRange;
|
||||
float cone = math.cos(math.radians(FeelConfig.LockOnArcDegrees));
|
||||
float bestSq = float.MaxValue;
|
||||
float3 bestPos = default;
|
||||
bool found = false;
|
||||
foreach (var (hx, hh) in
|
||||
SystemAPI.Query<RefRO<LocalTransform>, RefRO<Health>>().WithAll<EnemyTag>())
|
||||
{
|
||||
if (hh.ValueRO.Current <= 0f) continue;
|
||||
float3 hp = hx.ValueRO.Position;
|
||||
float2 to = hp.xz - lpPos.xz;
|
||||
float sq = math.lengthsq(to);
|
||||
if (sq > rangeSq || sq < 1e-6f) continue;
|
||||
if (math.dot(fdir, math.normalize(to)) < cone) continue;
|
||||
if (sq < bestSq) { bestSq = sq; bestPos = hp; found = true; }
|
||||
}
|
||||
if (found)
|
||||
{
|
||||
_tetherLine.startColor = FeelConfig.LockOnLineColor;
|
||||
_tetherLine.endColor = FeelConfig.LockOnLineColor;
|
||||
_tetherLine.widthMultiplier = FeelConfig.LockOnLineWidth;
|
||||
_tetherLine.SetPosition(0, (Vector3)ringPos);
|
||||
_tetherLine.SetPosition(1, new Vector3(bestPos.x, ringPos.y, bestPos.z));
|
||||
tetherShown = true;
|
||||
}
|
||||
}
|
||||
if (_tether != null && _tether.activeSelf != tetherShown) _tether.SetActive(tetherShown);
|
||||
|
||||
// Hide the OS cursor only while aiming AND focused; restore otherwise (focus loss / pre-spawn) so an
|
||||
// unfocused editor or a windowed session is never stranded with an invisible pointer.
|
||||
bool wantHidden = haveTarget && Application.isFocused;
|
||||
@@ -131,6 +176,8 @@ namespace ProjectM.Client
|
||||
if (_reticle != null) Object.Destroy(_reticle);
|
||||
if (_reticleMat != null) Object.Destroy(_reticleMat);
|
||||
if (_ringTex != null) Object.Destroy(_ringTex);
|
||||
if (_tether != null) Object.Destroy(_tether);
|
||||
if (_tetherMat != null) Object.Destroy(_tetherMat);
|
||||
}
|
||||
|
||||
void BuildReticle()
|
||||
@@ -154,6 +201,25 @@ namespace ProjectM.Client
|
||||
_reticle.transform.rotation = Quaternion.Euler(90f, 0f, 0f); // lay flat on the ground
|
||||
_reticle.transform.localScale = new Vector3(ReticleSize, ReticleSize, 1f);
|
||||
_reticle.SetActive(false);
|
||||
|
||||
// Lock-on tether line (persistent; built once, GC-clean). Own material so the ring texture doesn't tint it.
|
||||
var lineShader = Shader.Find("Sprites/Default");
|
||||
if (lineShader == null) lineShader = Shader.Find("Universal Render Pipeline/Particles/Unlit");
|
||||
_tetherMat = new Material(lineShader) { color = Color.white };
|
||||
_tether = new GameObject("~AimTether");
|
||||
_tetherLine = _tether.AddComponent<LineRenderer>();
|
||||
_tetherLine.material = _tetherMat;
|
||||
_tetherLine.positionCount = 2;
|
||||
_tetherLine.useWorldSpace = true;
|
||||
_tetherLine.numCapVertices = 2;
|
||||
_tetherLine.alignment = LineAlignment.View;
|
||||
_tetherLine.textureMode = LineTextureMode.Stretch;
|
||||
_tetherLine.shadowCastingMode = UnityEngine.Rendering.ShadowCastingMode.Off;
|
||||
_tetherLine.receiveShadows = false;
|
||||
_tetherLine.startColor = FeelConfig.LockOnLineColor;
|
||||
_tetherLine.endColor = FeelConfig.LockOnLineColor;
|
||||
_tetherLine.widthMultiplier = FeelConfig.LockOnLineWidth;
|
||||
_tether.SetActive(false);
|
||||
}
|
||||
|
||||
// ---- procedural ring texture (asset-free, like HudSystem's code-built uGUI) ----
|
||||
|
||||
@@ -0,0 +1,147 @@
|
||||
using ProjectM.Simulation;
|
||||
using Unity.Entities;
|
||||
using Unity.NetCode;
|
||||
using UnityEngine;
|
||||
|
||||
namespace ProjectM.Client
|
||||
{
|
||||
/// <summary>
|
||||
/// Client-only AMBIENT audio + cycle-phase stingers. A managed presentation <see cref="SystemBase"/>
|
||||
/// (<see cref="PresentationSystemGroup"/>, main thread, no Burst) that OBSERVES the replicated
|
||||
/// <see cref="CycleState"/> and never touches the simulation. On start it plays a low, seamless-looping
|
||||
/// procedural drone (asset-free, <c>AudioClip.Create</c> like <c>CombatFeedbackSystem.MakeClip</c>); each
|
||||
/// time the cycle phase changes it plays a short procedural stinger and eases the drone's intensity by phase
|
||||
/// (calmer at base, tenser during Defend / "wave incoming"). Lives only in the client world, so the server
|
||||
/// never creates audio and nothing here affects determinism. Volumes are deliberately conservative + tunable.
|
||||
/// </summary>
|
||||
[WorldSystemFilter(WorldSystemFilterFlags.ClientSimulation)]
|
||||
[UpdateInGroup(typeof(PresentationSystemGroup))]
|
||||
public partial class AmbientAudioSystem : SystemBase
|
||||
{
|
||||
AudioSource _ambient;
|
||||
AudioClip _ambientClip;
|
||||
AudioClip _stingExpedition;
|
||||
AudioClip _stingDefend;
|
||||
AudioClip _stingBuild;
|
||||
GameObject _root;
|
||||
|
||||
byte _lastPhase;
|
||||
bool _phaseInit;
|
||||
|
||||
const float AmbientBaseVolume = 0.10f; // low bed; Defend eases up to ~1.7x
|
||||
|
||||
protected override void OnCreate()
|
||||
{
|
||||
_ambientClip = MakeDrone();
|
||||
_stingExpedition = MakeSting(520f, 880f, 0.45f, 0.30f); // airy rising "deploy"
|
||||
_stingDefend = MakeSting(300f, 140f, 0.55f, 0.42f); // tense falling "wave incoming"
|
||||
_stingBuild = MakeSting(440f, 660f, 0.40f, 0.26f); // soft confirm
|
||||
}
|
||||
|
||||
protected override void OnStartRunning()
|
||||
{
|
||||
if (_root != null) return;
|
||||
_root = new GameObject("~AmbientAudio");
|
||||
_ambient = _root.AddComponent<AudioSource>();
|
||||
_ambient.clip = _ambientClip;
|
||||
_ambient.loop = true;
|
||||
_ambient.playOnAwake = false;
|
||||
_ambient.spatialBlend = 0f; // 2D bed
|
||||
_ambient.volume = AmbientBaseVolume;
|
||||
_ambient.Play();
|
||||
}
|
||||
|
||||
protected override void OnDestroy()
|
||||
{
|
||||
if (_root != null) Object.Destroy(_root);
|
||||
}
|
||||
|
||||
protected override void OnUpdate()
|
||||
{
|
||||
if (_ambient == null) return;
|
||||
if (!SystemAPI.TryGetSingleton<CycleState>(out var cyc)) return;
|
||||
|
||||
byte phase = cyc.Phase;
|
||||
if (!_phaseInit)
|
||||
{
|
||||
_lastPhase = phase; // adopt the current phase silently (no stinger on first observe)
|
||||
_phaseInit = true;
|
||||
}
|
||||
else if (phase != _lastPhase)
|
||||
{
|
||||
PlaySting(phase);
|
||||
_lastPhase = phase;
|
||||
}
|
||||
|
||||
// Ease the drone intensity toward the phase target (tenser during Defend).
|
||||
float target = phase == CyclePhase.Defend ? AmbientBaseVolume * 1.7f : AmbientBaseVolume;
|
||||
_ambient.volume = Mathf.MoveTowards(_ambient.volume, target, SystemAPI.Time.DeltaTime * 0.25f);
|
||||
}
|
||||
|
||||
void PlaySting(byte phase)
|
||||
{
|
||||
AudioClip clip = phase == CyclePhase.Defend ? _stingDefend
|
||||
: phase == CyclePhase.Build ? _stingBuild
|
||||
: _stingExpedition;
|
||||
if (clip != null && _ambient != null)
|
||||
_ambient.PlayOneShot(clip, 0.6f);
|
||||
}
|
||||
|
||||
// ---- Procedural audio (asset-free; mirrors CombatFeedbackSystem.MakeClip) ----
|
||||
|
||||
// A low, seamless-looping pad: each partial completes an integer number of cycles over the buffer
|
||||
// (freq snapped to k/duration) so the loop point has no click. A slow tremolo adds motion.
|
||||
static AudioClip MakeDrone()
|
||||
{
|
||||
const int rate = 44100;
|
||||
const float dur = 4f;
|
||||
int len = (int)(dur * rate);
|
||||
var clip = AudioClip.Create("ambient_drone", len, 1, rate, false);
|
||||
var data = new float[len];
|
||||
float f0 = Snap(55f, dur); // sub
|
||||
float f1 = Snap(110f, dur); // root
|
||||
float f2 = Snap(164.81f, dur); // fifth-ish
|
||||
float f3 = Snap(220f, dur);
|
||||
float trem = Snap(0.5f, dur); // slow amplitude wobble
|
||||
for (int i = 0; i < len; i++)
|
||||
{
|
||||
float t = i / (float)rate;
|
||||
float s = 0.50f * Mathf.Sin(2f * Mathf.PI * f0 * t)
|
||||
+ 0.35f * Mathf.Sin(2f * Mathf.PI * f1 * t)
|
||||
+ 0.18f * Mathf.Sin(2f * Mathf.PI * f2 * t)
|
||||
+ 0.10f * Mathf.Sin(2f * Mathf.PI * f3 * t);
|
||||
float amp = 0.75f + 0.25f * Mathf.Sin(2f * Mathf.PI * trem * t);
|
||||
data[i] = s * amp * 0.5f; // peak ~0.57, no clipping
|
||||
}
|
||||
clip.SetData(data, 0);
|
||||
return clip;
|
||||
}
|
||||
|
||||
// freq snapped so freq*dur is an integer -> the waveform closes seamlessly at the loop point.
|
||||
static float Snap(float freq, float dur)
|
||||
{
|
||||
float cycles = Mathf.Max(1f, Mathf.Round(freq * dur));
|
||||
return cycles / dur;
|
||||
}
|
||||
|
||||
// Short one-shot tone sweeping f0->f1 with an exponential decay envelope.
|
||||
static AudioClip MakeSting(float f0, float f1, float dur, float vol)
|
||||
{
|
||||
const int rate = 44100;
|
||||
int len = Mathf.Max(16, (int)(dur * rate));
|
||||
var clip = AudioClip.Create("sting", len, 1, rate, false);
|
||||
var data = new float[len];
|
||||
float phase = 0f;
|
||||
for (int i = 0; i < len; i++)
|
||||
{
|
||||
float t = i / (float)len;
|
||||
float env = Mathf.Exp(-3.5f * t);
|
||||
float freq = Mathf.Lerp(f0, f1, t);
|
||||
phase += 2f * Mathf.PI * freq / rate;
|
||||
data[i] = Mathf.Sin(phase) * env * vol;
|
||||
}
|
||||
clip.SetData(data, 0);
|
||||
return clip;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: a72ff134349646a408c917908bd6613c
|
||||
@@ -35,7 +35,7 @@ namespace ProjectM.Client
|
||||
[UpdateInGroup(typeof(PresentationSystemGroup))]
|
||||
public partial class CombatFeedbackSystem : SystemBase
|
||||
{
|
||||
struct FxCache { public float Hp; public float3 Pos; public bool IsEnemy; }
|
||||
struct FxCache { public float Hp; public float3 Pos; public bool IsEnemy; public uint Windup; }
|
||||
|
||||
readonly Dictionary<Entity, FxCache> _cache = new();
|
||||
readonly HashSet<Entity> _seen = new();
|
||||
@@ -55,6 +55,7 @@ namespace ProjectM.Client
|
||||
AudioClip _hitClip;
|
||||
AudioClip _deathClip;
|
||||
AudioClip _fireClip;
|
||||
AudioClip _telegraphClip;
|
||||
|
||||
Entity _localPlayer = Entity.Null;
|
||||
uint _lastLocalFireTick;
|
||||
@@ -70,6 +71,7 @@ namespace ProjectM.Client
|
||||
_hitClip = MakeClip("husk_hit", 640f, 180f, 0.10f, 0.5f, noise: true);
|
||||
_deathClip = MakeClip("husk_death", 320f, 50f, 0.34f, 0.55f, noise: false);
|
||||
_fireClip = MakeClip("fire", 880f, 1500f, 0.07f, 0.30f, noise: false);
|
||||
_telegraphClip = MakeClip("telegraph", 680f, 1020f, 0.12f, 0.35f, noise: false);
|
||||
}
|
||||
|
||||
protected override void OnStartRunning()
|
||||
@@ -101,6 +103,7 @@ namespace ProjectM.Client
|
||||
// Make sure predicted/physics jobs writing these are done before this main-thread read.
|
||||
EntityManager.CompleteDependencyBeforeRO<Health>();
|
||||
EntityManager.CompleteDependencyBeforeRO<LocalTransform>();
|
||||
EntityManager.CompleteDependencyBeforeRO<AttackWindup>();
|
||||
|
||||
// Resolve the local player (for hit colouring + fire feedback).
|
||||
_localPlayer = Entity.Null;
|
||||
@@ -121,28 +124,45 @@ namespace ProjectM.Client
|
||||
float cur = health.ValueRO.Current;
|
||||
float3 p = xf.ValueRO.Position;
|
||||
bool isEnemy = SystemAPI.HasComponent<EnemyTag>(entity);
|
||||
uint windup = isEnemy && SystemAPI.HasComponent<AttackWindup>(entity) ? SystemAPI.GetComponent<AttackWindup>(entity).WindUpUntilTick : 0u;
|
||||
bool isLocalPlayer = entity == _localPlayer;
|
||||
|
||||
if (_cache.TryGetValue(entity, out var prev))
|
||||
{
|
||||
if (isEnemy && windup != 0 && prev.Windup == 0)
|
||||
{
|
||||
// Attack telegraph: the wind-up just began -> warn the player ~0.3s before the strike lands.
|
||||
Burst(_hitFx, null, (Vector3)p + Vector3.up * 1.2f, 6);
|
||||
PlayClip(_telegraphClip, (Vector3)p, 0.5f);
|
||||
}
|
||||
|
||||
if (cur < prev.Hp - 0.001f)
|
||||
{
|
||||
SpawnNumber(prev.Hp - cur, (Vector3)p, isLocalPlayer, cam);
|
||||
Burst(_hitFx, cfg != null ? cfg.Hit : null, (Vector3)p + Vector3.up * 0.8f, 10);
|
||||
PlayClip(_hitClip, (Vector3)p, 0.7f);
|
||||
PrototypeCameraRig.AddShake(isLocalPlayer ? 0.32f : 0.10f);
|
||||
Burst(_hitFx, cfg != null ? cfg.Hit : null, (Vector3)p + Vector3.up * 0.8f, FeelConfig.HitBurstCount);
|
||||
PlayClip(_hitClip, (Vector3)p, FeelConfig.HitSfxVolume);
|
||||
PrototypeCameraRig.AddShake(isLocalPlayer ? FeelConfig.HitShakeLocal : FeelConfig.HitShakeRemote);
|
||||
if (isLocalPlayer) PrototypeCameraRig.PunchFov(FeelConfig.HitStopFovKick, FeelConfig.HitStopDurationMs);
|
||||
}
|
||||
|
||||
// Respawn recovery: the LOCAL player's Health rising from <=0 back to positive. No healing
|
||||
// mechanic exists, so a 0 -> positive edge is unambiguously a respawn (observer-only).
|
||||
if (isLocalPlayer && FeelConfig.RespawnShimmerEnabled && cur > prev.Hp + 0.001f && prev.Hp <= 0f)
|
||||
{
|
||||
Burst(_muzzleFx, null, (Vector3)p + Vector3.up * 0.6f, FeelConfig.RespawnShimmerBurst);
|
||||
PrototypeCameraRig.AddShake(FeelConfig.RespawnShimmerShake);
|
||||
}
|
||||
|
||||
// Player death (players don't despawn — they respawn; Husk death is handled on prune).
|
||||
if (!isEnemy && cur <= 0f && prev.Hp > 0f)
|
||||
{
|
||||
Burst(_deathFx, PlayerDeathPrefab(cfg), (Vector3)p + Vector3.up * 0.5f, 28);
|
||||
Burst(_deathFx, PlayerDeathPrefab(cfg), (Vector3)p + Vector3.up * 0.5f, FeelConfig.DeathBurstCount);
|
||||
PlayClip(_deathClip, (Vector3)p, 0.7f);
|
||||
PrototypeCameraRig.AddShake(isLocalPlayer ? 0.5f : 0.25f);
|
||||
PrototypeCameraRig.AddShake(isLocalPlayer ? FeelConfig.PlayerDeathShake : FeelConfig.RemotePlayerDeathShake);
|
||||
}
|
||||
}
|
||||
|
||||
_cache[entity] = new FxCache { Hp = cur, Pos = p, IsEnemy = isEnemy };
|
||||
_cache[entity] = new FxCache { Hp = cur, Pos = p, IsEnemy = isEnemy, Windup = windup };
|
||||
}
|
||||
|
||||
// Prune despawned ghosts. A Husk that vanished was killed -> death VFX at its last position.
|
||||
@@ -157,9 +177,10 @@ namespace ProjectM.Client
|
||||
var c = _cache[_stale[i]];
|
||||
if (c.IsEnemy)
|
||||
{
|
||||
Burst(_deathFx, cfg != null ? cfg.EnemyDeath : null, (Vector3)c.Pos + Vector3.up * 0.5f, 28);
|
||||
PlayClip(_deathClip, (Vector3)c.Pos, 0.65f);
|
||||
PrototypeCameraRig.AddShake(0.16f);
|
||||
Burst(_deathFx, cfg != null ? cfg.EnemyDeath : null, (Vector3)c.Pos + Vector3.up * 0.5f, Mathf.Max(1, Mathf.RoundToInt(FeelConfig.DeathBurstCount * FeelConfig.KillBurstScale)));
|
||||
PlayClip(_deathClip, (Vector3)c.Pos, FeelConfig.KillSfxVolume);
|
||||
PrototypeCameraRig.AddShake(FeelConfig.KillShake);
|
||||
PrototypeCameraRig.PunchFov(FeelConfig.KillFovKick, FeelConfig.HitStopDurationMs);
|
||||
}
|
||||
_cache.Remove(_stale[i]);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
using UnityEngine;
|
||||
|
||||
namespace ProjectM.Client
|
||||
{
|
||||
/// <summary>
|
||||
/// Live-tunable knobs for the client-only COMBAT-FEEL slice (Stage E). A static bridge — mirrors
|
||||
/// <see cref="AimPresentation"/> — so values can be poked at runtime via MCP <c>execute_code</c>
|
||||
/// (e.g. <c>ProjectM.Client.FeelConfig.HitShakeLocal = 0.4f;</c>) WITHOUT a recompile, for interactive
|
||||
/// tuning. Read ONLY by client-presentation systems (<see cref="CombatFeedbackSystem"/>,
|
||||
/// <see cref="AimReticleSystem"/>) and the <see cref="PrototypeCameraRig"/> MonoBehaviour — all non-Burst,
|
||||
/// main-thread. NEVER read these from a <c>[BurstCompile]</c> system (managed-static + Color/enum-in-Burst
|
||||
/// hazards); they are presentation-only and never touch the deterministic simulation.
|
||||
/// <para>
|
||||
/// Defaults match the values previously hardcoded in CombatFeedbackSystem so behaviour is byte-identical
|
||||
/// until a knob is poked. <see cref="ResetDefaults"/> re-stamps every field on play-enter via
|
||||
/// <c>[RuntimeInitializeOnLoadMethod(SubsystemRegistration)]</c> because statics survive fast-enter-playmode
|
||||
/// domain reloads — without it a poked value would leak across play-enters and flash stale feel (the exact
|
||||
/// bug <see cref="AimPresentation"/>'s reset prevents).
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public static class FeelConfig
|
||||
{
|
||||
// ---- Feature 1: hit camera punch + (camera-only) hit-stop ----
|
||||
/// <summary>Camera shake when the LOCAL player is hit (fed to PrototypeCameraRig.AddShake, clamp 0.8).</summary>
|
||||
public static float HitShakeLocal;
|
||||
/// <summary>Camera shake when a remote player / Husk is hit.</summary>
|
||||
public static float HitShakeRemote;
|
||||
/// <summary>Hit-spark particle burst count (procedural fallback path).</summary>
|
||||
public static int HitBurstCount;
|
||||
/// <summary>Hit SFX volume.</summary>
|
||||
public static float HitSfxVolume;
|
||||
/// <summary>Degrees of transient FOV "kick" on a LOCAL hit — the netcode-safe hit-stop (NEVER Time.timeScale). 0 = off.</summary>
|
||||
public static float HitStopFovKick;
|
||||
/// <summary>Milliseconds the FOV kick eases back to base.</summary>
|
||||
public static float HitStopDurationMs;
|
||||
|
||||
// ---- Feature 1/2: death camera punch ----
|
||||
/// <summary>Camera shake on LOCAL player death (loudest event by design).</summary>
|
||||
public static float PlayerDeathShake;
|
||||
/// <summary>Camera shake on a remote player's death.</summary>
|
||||
public static float RemotePlayerDeathShake;
|
||||
/// <summary>Base death-burst particle count (player death + Husk-death base).</summary>
|
||||
public static int DeathBurstCount;
|
||||
|
||||
// ---- Feature 2: kill-shot fanfare (Husk death) ----
|
||||
/// <summary>Camera shake on a Husk kill (nudged above a glancing hit, kept under PlayerDeathShake).</summary>
|
||||
public static float KillShake;
|
||||
/// <summary>Multiplier on DeathBurstCount for a Husk kill (result clamped by MaxActiveVfx).</summary>
|
||||
public static float KillBurstScale;
|
||||
/// <summary>Optional FOV kick on a kill (degrees). 0 = off.</summary>
|
||||
public static float KillFovKick;
|
||||
/// <summary>Husk-death SFX volume.</summary>
|
||||
public static float KillSfxVolume;
|
||||
|
||||
// ---- Feature 3: respawn shimmer / fade-in (local player recovery) ----
|
||||
/// <summary>Master gate for the local-player respawn shimmer.</summary>
|
||||
public static bool RespawnShimmerEnabled;
|
||||
/// <summary>Particle burst count for the recovery shimmer.</summary>
|
||||
public static int RespawnShimmerBurst;
|
||||
/// <summary>Light camera punch on recovery so respawn reads as "reinforcing".</summary>
|
||||
public static float RespawnShimmerShake;
|
||||
|
||||
// ---- Feature 4: reticle lock-on tether (cosmetic aim HINT) ----
|
||||
/// <summary>Master gate for the lock-on tether.</summary>
|
||||
public static bool LockOnEnabled;
|
||||
/// <summary>Show the tether only on the Gamepad scheme (mirrors the server's gamepad-only auto-target assist).</summary>
|
||||
public static bool LockOnGamepadOnly;
|
||||
/// <summary>Max world distance from the player to a tethered Husk.</summary>
|
||||
public static float LockOnRange;
|
||||
/// <summary>Forward half-arc (degrees) around PlayerFacing within which a Husk is eligible.</summary>
|
||||
public static float LockOnArcDegrees;
|
||||
/// <summary>Tether line tint (subtle highlight, not a laser).</summary>
|
||||
public static Color LockOnLineColor;
|
||||
/// <summary>Tether line width (world units).</summary>
|
||||
public static float LockOnLineWidth;
|
||||
|
||||
[RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.SubsystemRegistration)]
|
||||
public static void ResetDefaults()
|
||||
{
|
||||
// Feature 1
|
||||
HitShakeLocal = 0.32f;
|
||||
HitShakeRemote = 0.10f;
|
||||
HitBurstCount = 10;
|
||||
HitSfxVolume = 0.70f;
|
||||
HitStopFovKick = 1.5f;
|
||||
HitStopDurationMs = 90f;
|
||||
|
||||
// Feature 1/2 death
|
||||
PlayerDeathShake = 0.50f;
|
||||
RemotePlayerDeathShake = 0.25f;
|
||||
DeathBurstCount = 28;
|
||||
|
||||
// Feature 2 kill-shot
|
||||
KillShake = 0.20f;
|
||||
KillBurstScale = 1.5f;
|
||||
KillFovKick = 1.0f;
|
||||
KillSfxVolume = 0.75f;
|
||||
|
||||
// Feature 3 respawn
|
||||
RespawnShimmerEnabled = true;
|
||||
RespawnShimmerBurst = 24;
|
||||
RespawnShimmerShake = 0.12f;
|
||||
|
||||
// Feature 4 tether
|
||||
LockOnEnabled = true;
|
||||
LockOnGamepadOnly = true;
|
||||
LockOnRange = 9.0f;
|
||||
LockOnArcDegrees = 60f;
|
||||
LockOnLineColor = new Color(0.55f, 0.9f, 1f, 0.35f);
|
||||
LockOnLineWidth = 0.05f;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: be55ae1703fcd284bbc1acddb417133c
|
||||
@@ -60,7 +60,7 @@ namespace ProjectM.Client
|
||||
var endTick = new NetworkTick(cyc.PhaseEndTick);
|
||||
string detail;
|
||||
if (cyc.Phase == CyclePhase.Defend)
|
||||
detail = _huskQuery.CalculateEntityCount() + " HUSKS";
|
||||
detail = "WAVE " + cyc.WaveNumber + " - " + _huskQuery.CalculateEntityCount() + " HUSKS";
|
||||
else if (haveTick && cyc.PhaseEndTick != 0 && endTick.IsValid && endTick.IsNewerThan(nt.ServerTick))
|
||||
detail = (endTick.TicksSince(nt.ServerTick) / 60) + "s";
|
||||
else
|
||||
|
||||
@@ -33,6 +33,10 @@ namespace ProjectM.Client
|
||||
/// <summary>Local player's planar facing (XZ), published each client tick for the aim look-ahead.</summary>
|
||||
public static float2 TargetFacing;
|
||||
|
||||
/// <summary>Local player's planar MOVEMENT input (XZ), published each tick for the movement-based camera
|
||||
/// look-ahead. Leading toward MOVEMENT (not aim/facing) keeps a stationary aim from "swimming" as the camera pans.</summary>
|
||||
public static float2 TargetMoveDir;
|
||||
|
||||
/// <summary>Accumulated camera-shake amplitude (world units), decayed each LateUpdate. Driven by
|
||||
/// AddShake from the client combat-feedback layer (presentation only, netcode-safe - never the sim).</summary>
|
||||
static float s_shake;
|
||||
@@ -40,6 +44,20 @@ namespace ProjectM.Client
|
||||
/// <summary>Add a one-shot camera-shake impulse (clamped). Called by CombatFeedbackSystem on hits/deaths.</summary>
|
||||
public static void AddShake(float amount) => s_shake = Mathf.Min(s_shake + amount, 0.8f);
|
||||
|
||||
/// <summary>Transient additive FOV "kick" (degrees) - the netcode-safe hit-stop. Decayed each LateUpdate.</summary>
|
||||
static float s_fovKick;
|
||||
static float s_fovLambda = 30f; // ease-back rate; PunchFov sets it from the requested duration
|
||||
|
||||
/// <summary>Add a one-shot FOV kick eased back over <paramref name="durationMs"/>. Presentation only
|
||||
/// (NEVER Time.timeScale, which would desync the deterministic predicted sim).</summary>
|
||||
public static void PunchFov(float degrees, float durationMs)
|
||||
{
|
||||
if (degrees <= 0f) return;
|
||||
s_fovKick = Mathf.Max(s_fovKick, degrees);
|
||||
float durSec = Mathf.Max(0.02f, durationMs * 0.001f);
|
||||
s_fovLambda = 3f / durSec; // ~95% decayed after durSec (3 time constants)
|
||||
}
|
||||
|
||||
[Header("Angle (degrees)")]
|
||||
[Range(10f, 89f)] public float Pitch = 45f;
|
||||
[Range(-180f, 180f)] public float Yaw = 0f;
|
||||
@@ -60,7 +78,7 @@ namespace ProjectM.Client
|
||||
[Tooltip("What to frame before a local player exists (edit mode / pre-spawn).")]
|
||||
public Vector3 FallbackTarget = new Vector3(3f, 0f, 4f);
|
||||
|
||||
[Header("Aim look-ahead")]
|
||||
[Header("Movement look-ahead")]
|
||||
[Tooltip("Shift the framed point this many world units toward where the player is aiming (0 = off). " +
|
||||
"Leads the camera toward the cursor / stick so aiming feels grounded. Smoothed by FollowSharpness.")]
|
||||
[Range(0f, 8f)] public float AimLeadDistance = 2.5f;
|
||||
@@ -74,18 +92,22 @@ namespace ProjectM.Client
|
||||
if (_cam == null) _cam = GetComponent<Camera>();
|
||||
|
||||
_cam.orthographic = Orthographic;
|
||||
_cam.fieldOfView = FieldOfView;
|
||||
_cam.fieldOfView = FieldOfView + s_fovKick;
|
||||
if (s_fovKick > 0.001f)
|
||||
s_fovKick = Mathf.Lerp(s_fovKick, 0f, 1f - Mathf.Exp(-s_fovLambda * Time.deltaTime));
|
||||
else
|
||||
s_fovKick = 0f;
|
||||
_cam.orthographicSize = OrthoSize;
|
||||
|
||||
Vector3 target = HasTarget ? (Vector3)TargetWorldPos : FallbackTarget;
|
||||
target.y += TargetHeight;
|
||||
|
||||
// Aim look-ahead: lead the framed point toward the player's facing (cursor on KBM, stick on pad).
|
||||
// Driven off the replicated PlayerFacing (not the live cursor projection) so there is no feedback
|
||||
// loop with the reticle's camera-dependent ground projection. Smoothed by the FollowSharpness lerp.
|
||||
if (AimLeadDistance > 0f && HasTarget && math.lengthsq(TargetFacing) > 1e-6f)
|
||||
// Movement look-ahead: lead the framed point toward where the player is MOVING (not aiming).
|
||||
// Leading toward AIM coupled the camera to the cursor: turning to face a near-cursor panned the cam,
|
||||
// which re-projected the live mouse ray -> the aim swam (worst near the player). Smoothed by FollowSharpness.
|
||||
if (AimLeadDistance > 0f && HasTarget && math.lengthsq(TargetMoveDir) > 1e-6f)
|
||||
{
|
||||
float2 f = math.normalize(TargetFacing);
|
||||
float2 f = math.normalize(TargetMoveDir);
|
||||
target += new Vector3(f.x, 0f, f.y) * AimLeadDistance;
|
||||
}
|
||||
|
||||
@@ -115,11 +137,13 @@ namespace ProjectM.Client
|
||||
protected override void OnUpdate()
|
||||
{
|
||||
bool found = false;
|
||||
foreach (var (transform, facing) in SystemAPI.Query<RefRO<LocalTransform>, RefRO<PlayerFacing>>()
|
||||
foreach (var (transform, facing, input) in
|
||||
SystemAPI.Query<RefRO<LocalTransform>, RefRO<PlayerFacing>, RefRO<PlayerInput>>()
|
||||
.WithAll<GhostOwnerIsLocal, PlayerTag>())
|
||||
{
|
||||
PrototypeCameraRig.TargetWorldPos = transform.ValueRO.Position;
|
||||
PrototypeCameraRig.TargetFacing = facing.ValueRO.Direction;
|
||||
PrototypeCameraRig.TargetMoveDir = input.ValueRO.Move;
|
||||
found = true;
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -18,9 +18,9 @@ namespace ProjectM.Server
|
||||
[WorldSystemFilter(WorldSystemFilterFlags.ServerSimulation)]
|
||||
public partial struct AbilityUpgradeSystem : ISystem
|
||||
{
|
||||
const uint UpgradeSourceId = 0x00A0E711u; // distinct sentinel so the upgrade modifier is found + grown
|
||||
const float TierStep = 0.25f; // +25% damage per tier
|
||||
const int CostAmount = 20; // Aether per tier
|
||||
const uint UpgradeSourceId = Tuning.AbilityUpgradeSourceId; // distinct sentinel so the upgrade modifier is found + grown
|
||||
const float TierStep = Tuning.AbilityUpgradeTierStep; // +25% damage per tier
|
||||
const int CostAmount = Tuning.AbilityUpgradeCostAmount; // Aether per tier
|
||||
|
||||
[BurstCompile]
|
||||
public void OnCreate(ref SystemState state)
|
||||
|
||||
@@ -59,14 +59,32 @@ namespace ProjectM.Server
|
||||
|
||||
float dt = SystemAPI.Time.DeltaTime;
|
||||
var serverTick = SystemAPI.GetSingleton<NetworkTime>().ServerTick;
|
||||
uint now = serverTick.TickIndexForValidTick;
|
||||
var ecb = new EntityCommandBuffer(Allocator.Temp);
|
||||
|
||||
foreach (var (xform, stats, cooldown) in
|
||||
SystemAPI.Query<RefRW<LocalTransform>, RefRO<EnemyStats>, RefRW<EnemyAttackCooldown>>()
|
||||
foreach (var (xform, stats, cooldown, knockback, windup) in
|
||||
SystemAPI.Query<RefRW<LocalTransform>, RefRO<EnemyStats>, RefRW<EnemyAttackCooldown>,
|
||||
RefRW<KnockbackState>, RefRW<AttackWindup>>()
|
||||
.WithAll<EnemyTag>())
|
||||
{
|
||||
float3 pos = xform.ValueRO.Position;
|
||||
|
||||
// Knockback overrides seek/strike for its window — EnemyAISystem stays the SOLE writer of Position.
|
||||
var kb = knockback.ValueRO;
|
||||
if (kb.UntilTick != 0)
|
||||
{
|
||||
var kbTick = new NetworkTick(kb.UntilTick);
|
||||
if (kbTick.IsValid && kbTick.IsNewerThan(serverTick))
|
||||
{
|
||||
float3 kpos = pos + new float3(kb.Dir.x, 0f, kb.Dir.y) * (kb.Speed * dt);
|
||||
kpos.y = pos.y;
|
||||
xform.ValueRW.Position = kpos;
|
||||
windup.ValueRW.WindUpUntilTick = 0; // a recoiling Husk does not wind up
|
||||
continue; // recoiling: skip seek + strike this tick
|
||||
}
|
||||
knockback.ValueRW.UntilTick = 0; // window elapsed
|
||||
}
|
||||
|
||||
// Nearest living player (planar XZ).
|
||||
int best = -1;
|
||||
float bestSq = float.MaxValue;
|
||||
@@ -96,8 +114,35 @@ namespace ProjectM.Server
|
||||
if (math.lengthsq(toTarget) > 1e-6f)
|
||||
xform.ValueRW.Rotation = quaternion.LookRotationSafe(math.normalize(toTarget), math.up());
|
||||
|
||||
// Strike on contact once the cooldown has elapsed.
|
||||
if (EnemyAIMath.InAttackRange(pos, targetPos, stats.ValueRO.AttackRange))
|
||||
// Two-phase strike with a telegraph wind-up: commit a wind-up when first in-range + cooldown-ready,
|
||||
// then strike when it elapses. WindUpUntilTick is a [GhostField] so the client can cue the ~0.3s
|
||||
// tell; leaving range mid-windup cancels it. Tuning.AttackWindupTicks = 0/1 -> near-instant (legacy).
|
||||
bool inRange = EnemyAIMath.InAttackRange(pos, targetPos, stats.ValueRO.AttackRange);
|
||||
uint windRaw = windup.ValueRO.WindUpUntilTick;
|
||||
|
||||
if (windRaw != 0)
|
||||
{
|
||||
if (!inRange)
|
||||
{
|
||||
windup.ValueRW.WindUpUntilTick = 0; // target left range -> cancel the wind-up
|
||||
}
|
||||
else
|
||||
{
|
||||
var windTick = new NetworkTick(windRaw);
|
||||
if (!(windTick.IsValid && windTick.IsNewerThan(serverTick)))
|
||||
{
|
||||
ecb.AppendToBuffer(playerEntities[best], new DamageEvent
|
||||
{
|
||||
Amount = stats.ValueRO.AttackDamage,
|
||||
SourceNetworkId = -1, // environment / Husk, not a player
|
||||
});
|
||||
uint cooldownTicks = (uint)math.max(1, stats.ValueRO.AttackCooldownTicks);
|
||||
cooldown.ValueRW.NextAttackTick = TickUtil.NonZero(now + cooldownTicks);
|
||||
windup.ValueRW.WindUpUntilTick = 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
else if (inRange)
|
||||
{
|
||||
uint nextRaw = cooldown.ValueRO.NextAttackTick;
|
||||
bool ready = true;
|
||||
@@ -107,16 +152,10 @@ namespace ProjectM.Server
|
||||
if (nextTick.IsValid && nextTick.IsNewerThan(serverTick))
|
||||
ready = false;
|
||||
}
|
||||
|
||||
if (ready)
|
||||
{
|
||||
ecb.AppendToBuffer(playerEntities[best], new DamageEvent
|
||||
{
|
||||
Amount = stats.ValueRO.AttackDamage,
|
||||
SourceNetworkId = -1, // environment / Husk, not a player
|
||||
});
|
||||
uint cooldownTicks = (uint)math.max(1, stats.ValueRO.AttackCooldownTicks);
|
||||
cooldown.ValueRW.NextAttackTick = TickUtil.NonZero(serverTick.TickIndexForValidTick + cooldownTicks);
|
||||
uint windupTicks = (uint)math.max(1, Tuning.AttackWindupTicks);
|
||||
windup.ValueRW.WindUpUntilTick = TickUtil.NonZero(now + windupTicks);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -40,6 +40,9 @@ namespace ProjectM.Server
|
||||
/// <summary>Lookup used to read a target's owner so a projectile never hits its own caster.</summary>
|
||||
ComponentLookup<GhostOwner> m_GhostOwnerLookup;
|
||||
|
||||
/// <summary>RW lookup to stamp server-only knockback on a hit Husk (Husks bake KnockbackState; players/dummies don't).</summary>
|
||||
ComponentLookup<KnockbackState> m_KnockbackLookup;
|
||||
|
||||
/// <summary>Extra forgiveness added to a target's hit radius to approximate the projectile's own size.</summary>
|
||||
const float k_ProjectileRadius = 0.2f;
|
||||
|
||||
@@ -47,6 +50,7 @@ namespace ProjectM.Server
|
||||
public void OnCreate(ref SystemState state)
|
||||
{
|
||||
m_GhostOwnerLookup = state.GetComponentLookup<GhostOwner>(isReadOnly: true);
|
||||
m_KnockbackLookup = state.GetComponentLookup<KnockbackState>(isReadOnly: false);
|
||||
|
||||
// No projectiles → nothing to expire or hit-test; skip the tick (and its allocations) entirely.
|
||||
state.RequireForUpdate<Projectile>();
|
||||
@@ -56,6 +60,8 @@ namespace ProjectM.Server
|
||||
public void OnUpdate(ref SystemState state)
|
||||
{
|
||||
m_GhostOwnerLookup.Update(ref state);
|
||||
m_KnockbackLookup.Update(ref state);
|
||||
bool haveTick = SystemAPI.TryGetSingleton<NetworkTime>(out var nt);
|
||||
|
||||
float dt = SystemAPI.Time.DeltaTime;
|
||||
var ecb = new EntityCommandBuffer(Allocator.Temp);
|
||||
@@ -125,6 +131,16 @@ namespace ProjectM.Server
|
||||
Amount = proj.ValueRO.Damage,
|
||||
SourceNetworkId = projOwnerId,
|
||||
});
|
||||
var hitTarget = targetEntities[bestIdx];
|
||||
if (haveTick && Tuning.KnockbackSpeed > 0f && m_KnockbackLookup.HasComponent(hitTarget))
|
||||
{
|
||||
m_KnockbackLookup[hitTarget] = new KnockbackState
|
||||
{
|
||||
Dir = proj.ValueRO.Direction,
|
||||
Speed = Tuning.KnockbackSpeed,
|
||||
UntilTick = TickUtil.NonZero(nt.ServerTick.TickIndexForValidTick + (uint)math.max(1, Tuning.KnockbackDurationTicks)),
|
||||
};
|
||||
}
|
||||
ecb.DestroyEntity(projectileEntity);
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
using ProjectM.Simulation;
|
||||
using Unity.Burst;
|
||||
using Unity.Entities;
|
||||
using Unity.NetCode;
|
||||
|
||||
namespace ProjectM.Server
|
||||
{
|
||||
/// <summary>
|
||||
/// Server-authoritative expiry of TIMED <see cref="StatModifier"/>s. Each tick it walks every entity's
|
||||
/// server-only <see cref="TimedModifier"/> buffer; for any row whose <see cref="TimedModifier.UntilTick"/> has
|
||||
/// elapsed (wrap-safe <see cref="Unity.NetCode.NetworkTick.IsNewerThan"/> compare, never raw uint<) it removes
|
||||
/// the matching StatModifier(s) by SourceId and the timed row. The shortened StatModifier [GhostField] buffer
|
||||
/// auto-replicates (OwnerSendType.All), so StatRecomputeSystem reverts the effective stat on both worlds with no
|
||||
/// change. Runs in the plain server <see cref="SimulationSystemGroup"/> (NOT the predicted loop) so it is applied
|
||||
/// exactly once and never double-removed on rollback; a DynamicBuffer mutation is not a structural change, so it
|
||||
/// is safe to mutate the iterated entity's own buffers in place.
|
||||
/// </summary>
|
||||
[BurstCompile]
|
||||
[WorldSystemFilter(WorldSystemFilterFlags.ServerSimulation)]
|
||||
[UpdateInGroup(typeof(SimulationSystemGroup))]
|
||||
public partial struct TimedModifierExpirySystem : ISystem
|
||||
{
|
||||
[BurstCompile]
|
||||
public void OnCreate(ref SystemState state)
|
||||
{
|
||||
state.RequireForUpdate<NetworkTime>();
|
||||
state.RequireForUpdate(state.GetEntityQuery(ComponentType.ReadOnly<TimedModifier>()));
|
||||
}
|
||||
|
||||
[BurstCompile]
|
||||
public void OnUpdate(ref SystemState state)
|
||||
{
|
||||
var serverTick = SystemAPI.GetSingleton<NetworkTime>().ServerTick;
|
||||
if (!serverTick.IsValid)
|
||||
return;
|
||||
|
||||
foreach (var (timed, mods) in
|
||||
SystemAPI.Query<DynamicBuffer<TimedModifier>, DynamicBuffer<StatModifier>>())
|
||||
{
|
||||
for (int i = timed.Length - 1; i >= 0; i--)
|
||||
{
|
||||
uint until = timed[i].UntilTick;
|
||||
if (until == 0)
|
||||
continue; // inert (no expiry scheduled)
|
||||
|
||||
var untilTick = new NetworkTick(until);
|
||||
if (untilTick.IsValid && untilTick.IsNewerThan(serverTick))
|
||||
continue; // not yet due
|
||||
|
||||
TimedModifierUtil.RemoveBySourceId(mods, timed[i].SourceId);
|
||||
timed.RemoveAtSwapBack(i);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 94c6107954fa4d94f8ead51cfe4de3b7
|
||||
@@ -28,7 +28,7 @@ namespace ProjectM.Server
|
||||
[UpdateAfter(typeof(PredictedSimulationSystemGroup))]
|
||||
public partial struct ResourceHarvestSystem : ISystem
|
||||
{
|
||||
const float k_ProjectileRadius = 0.2f;
|
||||
const float k_ProjectileRadius = Tuning.HarvestProjectileRadius;
|
||||
|
||||
[BurstCompile]
|
||||
public void OnCreate(ref SystemState state)
|
||||
|
||||
@@ -82,6 +82,10 @@ namespace ProjectM.Server
|
||||
break;
|
||||
}
|
||||
|
||||
// Surface the live wave number on the replicated CycleState for the HUD (single writer).
|
||||
if (SystemAPI.TryGetSingleton<WaveState>(out var waveSync))
|
||||
cycle.WaveNumber = waveSync.WaveNumber;
|
||||
|
||||
SystemAPI.SetComponent(cycleEntity, cycle);
|
||||
SystemAPI.SetComponent(cycleEntity, runtime);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
using Unity.Entities;
|
||||
using Unity.NetCode;
|
||||
|
||||
namespace ProjectM.Simulation
|
||||
{
|
||||
/// <summary>
|
||||
/// Replicated Husk attack-telegraph signal. While <see cref="WindUpUntilTick"/> is non-zero the Husk is
|
||||
/// "winding up" to strike; EnemyAISystem sets it ~<see cref="Tuning.AttackWindupTicks"/> before the strike
|
||||
/// lands, and the strike fires when the tick elapses. This is a [GhostField] (the only replicated Husk field
|
||||
/// beyond the stock LocalTransform) so the CLIENT can play a ~0.3s pre-strike cue — the client has none of the
|
||||
/// server-only timing inputs (EnemyStats / EnemyAttackCooldown), so the wind-up MUST be replicated. A uint tick
|
||||
/// (not a [GhostEnabledBit]) so the cue can ramp/countdown and survive a missed snapshot (absolute, not an edge).
|
||||
/// </summary>
|
||||
public struct AttackWindup : IComponentData
|
||||
{
|
||||
/// <summary>Server tick the wind-up completes + the strike lands (0 = not winding up; scheduled via TickUtil.NonZero).</summary>
|
||||
[GhostField] public uint WindUpUntilTick;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: f2c9d899714758b4baefe6c1cbb3be0a
|
||||
@@ -0,0 +1,26 @@
|
||||
using Unity.Entities;
|
||||
using Unity.Mathematics;
|
||||
|
||||
namespace ProjectM.Simulation
|
||||
{
|
||||
/// <summary>
|
||||
/// SERVER-ONLY transient knockback on a Husk. While <see cref="UntilTick"/> has not elapsed, EnemyAISystem
|
||||
/// moves the Husk along <see cref="Dir"/> at <see cref="Speed"/> (REPLACING its seek) and suppresses its strike.
|
||||
/// Stamped by ProjectileDamageSystem on a hit (Dir = the projectile's heading). NOT a [GhostField] — the Husk's
|
||||
/// displaced position already replicates via the stock LocalTransform default variant, so knockback adds NO
|
||||
/// replicated surface (no ghost re-bake). EnemyAISystem must remain the SOLE writer of the Husk's Position, so
|
||||
/// knockback is applied INSIDE it (never a competing system). Force/duration live in <see cref="Tuning"/>
|
||||
/// (KnockbackSpeed = 0 disables knockback globally).
|
||||
/// </summary>
|
||||
public struct KnockbackState : IComponentData
|
||||
{
|
||||
/// <summary>Planar (XZ) knockback heading — the projectile's direction at impact.</summary>
|
||||
public float2 Dir;
|
||||
|
||||
/// <summary>Knockback speed (world units/sec) applied for the window; 0 = not knocked.</summary>
|
||||
public float Speed;
|
||||
|
||||
/// <summary>Server tick until which the knockback is active (0 = none; scheduled via TickUtil.NonZero).</summary>
|
||||
public uint UntilTick;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 9fa07e28f83ad6b43a8164b8c673a6b1
|
||||
@@ -0,0 +1,35 @@
|
||||
using Unity.Entities;
|
||||
|
||||
namespace ProjectM.Simulation
|
||||
{
|
||||
/// <summary>
|
||||
/// SERVER-ONLY expiry tracker paired with a <see cref="StatModifier"/> by <see cref="SourceId"/>. It is NOT a
|
||||
/// [GhostField] and lives in a SEPARATE buffer so the replicated <see cref="StatModifier"/> layout stays
|
||||
/// byte-identical — adding ANY member (even non-ghost) to a [GhostField] buffer element regenerates its
|
||||
/// serializer/stride/hash = an effective ghost re-bake. To grant a TIMED buff, append both a StatModifier and a
|
||||
/// TimedModifier sharing one unique SourceId; <c>TimedModifierExpirySystem</c> removes the matching StatModifier
|
||||
/// when <see cref="UntilTick"/> elapses, and that removal replicates for free via the StatModifier [GhostField]
|
||||
/// buffer (OwnerSendType.All), so StatRecomputeSystem reverts the effective stat on both worlds with no change.
|
||||
/// </summary>
|
||||
public struct TimedModifier : IBufferElementData
|
||||
{
|
||||
/// <summary>Matches the <see cref="StatModifier.SourceId"/> this row governs.</summary>
|
||||
public uint SourceId;
|
||||
|
||||
/// <summary>Server tick at which the paired StatModifier expires (0 = no expiry / inert; schedule via TickUtil.NonZero).</summary>
|
||||
public uint UntilTick;
|
||||
}
|
||||
|
||||
/// <summary>Pure helpers for removing modifiers by provenance (clear-by-type / timed expiry). Deterministic, no RNG/wall-clock.</summary>
|
||||
public static class TimedModifierUtil
|
||||
{
|
||||
/// <summary>Remove every <see cref="StatModifier"/> row whose SourceId matches (RemoveAtSwapBack). Returns the count removed.</summary>
|
||||
public static int RemoveBySourceId(DynamicBuffer<StatModifier> mods, uint sourceId)
|
||||
{
|
||||
int removed = 0;
|
||||
for (int j = mods.Length - 1; j >= 0; j--)
|
||||
if (mods[j].SourceId == sourceId) { mods.RemoveAtSwapBack(j); removed++; }
|
||||
return removed;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 67465323b013e6a4cb59519111b1b9e5
|
||||
@@ -0,0 +1,54 @@
|
||||
namespace ProjectM.Simulation
|
||||
{
|
||||
/// <summary>
|
||||
/// Central home for gameplay-balance constants that were previously buried as <c>private const</c>s
|
||||
/// inside individual systems, so designers have one searchable place to tune them. Burst-safe (compile-time
|
||||
/// <c>const</c>s only — they inline into the consuming systems with no runtime cost or managed reference).
|
||||
/// <para>
|
||||
/// Systems reference these via <c>Tuning.*</c> (wired in the 2026-06-04 polish pass, Stage C). When adding a
|
||||
/// new tunable value, prefer adding it here over a local private const UNLESS it already has an obvious,
|
||||
/// well-named public home (see the cross-references below) — duplicating a literal creates two sources of truth.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Values that already live in a clear, public, semantically-named home (NOT duplicated here):</b>
|
||||
/// <list type="bullet">
|
||||
/// <item><see cref="CyclePhase.ExpeditionTicks"/> / <see cref="CyclePhase.BuildTicks"/> — cycle phase durations.</item>
|
||||
/// <item><see cref="RegionMath.ExpeditionOffsetX"/> — base→expedition world-space offset.</item>
|
||||
/// <item>Per-ability/character stats — authored in ScriptableObjects, baked to the AbilityDatabase blob (M3).</item>
|
||||
/// </list>
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public static class Tuning
|
||||
{
|
||||
// ---- Ability damage upgrade (AbilityUpgradeSystem) ----
|
||||
|
||||
/// <summary>Distinct sentinel SourceId so the upgrade <c>StatModifier</c> is found + grown in place
|
||||
/// (replace-by-SourceId keeps the bounded modifier buffer from growing a row per upgrade).</summary>
|
||||
public const uint AbilityUpgradeSourceId = 0x00A0E711u;
|
||||
|
||||
/// <summary>Damage bonus added per upgrade tier (PercentAdd op): +25% per tier.</summary>
|
||||
public const float AbilityUpgradeTierStep = 0.25f;
|
||||
|
||||
/// <summary>Aether cost charged to the shared ledger per upgrade tier.</summary>
|
||||
public const int AbilityUpgradeCostAmount = 20;
|
||||
|
||||
// ---- Resource harvest (ResourceHarvestSystem) ----
|
||||
|
||||
/// <summary>Effective projectile radius used by the swept-segment node-hit test (added to the node's
|
||||
/// <c>HitRadius</c>). Tunnel-safe because the segment is reconstructed from <c>Projectile.LastStep</c>.</summary>
|
||||
public const float HarvestProjectileRadius = 0.2f;
|
||||
|
||||
// ---- Enemy knockback (ProjectileDamageSystem stamps on hit; EnemyAISystem applies + suppresses seek/strike) ----
|
||||
|
||||
/// <summary>Knockback speed (world units/sec) a Husk recoils at when shot; 0 disables knockback globally.</summary>
|
||||
public const float KnockbackSpeed = 8f;
|
||||
|
||||
/// <summary>Server ticks the knockback lasts (~60 ticks/sec).</summary>
|
||||
public const int KnockbackDurationTicks = 8;
|
||||
|
||||
// ---- Husk attack telegraph (EnemyAISystem 2-phase strike; client cue in CombatFeedbackSystem) ----
|
||||
|
||||
/// <summary>Wind-up ticks before a Husk strike lands (~0.3s @ 60 ticks/sec). 0/1 = near-instant (legacy behaviour).</summary>
|
||||
public const int AttackWindupTicks = 18;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: a78ec19017f42bd4a8a16bfa8a03886d
|
||||
@@ -21,6 +21,9 @@ namespace ProjectM.Simulation
|
||||
|
||||
/// <summary>Server tick the current timed phase ends (Expedition/Build only; 0 in Defend).</summary>
|
||||
[GhostField] public uint PhaseEndTick;
|
||||
|
||||
/// <summary>Live Husk wave number during Defend, synced from the server-only WaveState by CyclePhaseSystem so the replicated-state-only HUD can show it (holds the last wave number outside Defend; the HUD gates the display to the Defend phase).</summary>
|
||||
[GhostField] public int WaveNumber;
|
||||
}
|
||||
|
||||
/// <summary>Phase constants for <see cref="CycleState.Phase"/> (a byte, not an enum, for trivial Burst/serialization).</summary>
|
||||
|
||||
@@ -0,0 +1,151 @@
|
||||
using NUnit.Framework;
|
||||
using ProjectM.Server;
|
||||
using ProjectM.Simulation;
|
||||
using Unity.Core;
|
||||
using Unity.Entities;
|
||||
using Unity.Mathematics;
|
||||
using Unity.NetCode;
|
||||
using Unity.Transforms;
|
||||
|
||||
namespace ProjectM.Tests
|
||||
{
|
||||
/// <summary>
|
||||
/// Plain-Entities EditMode tests for the server-only <see cref="BuildPlaceSystem"/> — the RPC structure-placement
|
||||
/// handler. A bare world is seeded with StructureCatalog (+ a Turret entry referencing a Prefab-tagged prefab),
|
||||
/// BaseAnchor, ResourceLedger (+ Ore), NetworkTime, and synthetic BuildPlaceRequest + ReceiveRpcCommandRequest
|
||||
/// entities. The headline case is co-op atomicity: two same-tick requests for one cell must place EXACTLY one
|
||||
/// structure and withdraw the cost ONCE (the in-place commit). Also pins cost/plot validation and request cleanup.
|
||||
/// </summary>
|
||||
public class BuildPlaceSystemTests
|
||||
{
|
||||
static (World world, SimulationSystemGroup group) MakeWorld(string name, int oreCount)
|
||||
{
|
||||
var world = new World(name);
|
||||
var group = world.GetOrCreateSystemManaged<SimulationSystemGroup>();
|
||||
group.AddSystemToUpdateList(world.GetOrCreateSystem<BuildPlaceSystem>());
|
||||
group.SortSystems();
|
||||
world.SetTime(new TimeData(elapsedTime: 0f, deltaTime: 1f / 60f));
|
||||
var em = world.EntityManager;
|
||||
|
||||
var nt = em.CreateEntity(typeof(NetworkTime));
|
||||
em.SetComponentData(nt, new NetworkTime { ServerTick = new NetworkTick(300) });
|
||||
|
||||
var anchor = em.CreateEntity(typeof(BaseAnchor));
|
||||
em.SetComponentData(anchor, new BaseAnchor
|
||||
{
|
||||
AnchorPos = new float3(5, 0, 5),
|
||||
GridOrigin = new float3(0, 0, 0),
|
||||
CellSize = 2f,
|
||||
GridDims = new int2(5, 5),
|
||||
});
|
||||
|
||||
// Turret prefab: LocalTransform (looked up for placement) + PlacedStructure (SetComponent target on the
|
||||
// clone) + a real Prefab tag so it is excluded from the live-structure occupancy scan.
|
||||
var prefab = em.CreateEntity(typeof(LocalTransform), typeof(PlacedStructure));
|
||||
em.AddComponent<Prefab>(prefab);
|
||||
|
||||
var catalogE = em.CreateEntity(typeof(StructureCatalog));
|
||||
var catalog = em.AddBuffer<StructureCatalogEntry>(catalogE);
|
||||
catalog.Add(new StructureCatalogEntry
|
||||
{
|
||||
Type = StructureType.Turret, Prefab = prefab, CostResourceId = ResourceId.Ore, CostAmount = 10,
|
||||
});
|
||||
|
||||
var ledgerE = em.CreateEntity(typeof(ResourceLedger));
|
||||
var ledger = em.AddBuffer<StorageEntry>(ledgerE);
|
||||
ledger.Add(new StorageEntry { ItemId = ResourceId.Ore, Count = oreCount });
|
||||
|
||||
return (world, group);
|
||||
}
|
||||
|
||||
static void MakeBuildRequest(EntityManager em, byte type, int cellX, int cellZ)
|
||||
{
|
||||
var e = em.CreateEntity();
|
||||
em.AddComponentData(e, new BuildPlaceRequest { StructureType = type, CellX = cellX, CellZ = cellZ });
|
||||
em.AddComponentData(e, default(ReceiveRpcCommandRequest));
|
||||
}
|
||||
|
||||
static int StructureCount(EntityManager em)
|
||||
{
|
||||
using var q = em.CreateEntityQuery(typeof(PlacedStructure));
|
||||
return q.CalculateEntityCount();
|
||||
}
|
||||
|
||||
static int OreCount(EntityManager em)
|
||||
{
|
||||
using var q = em.CreateEntityQuery(typeof(ResourceLedger));
|
||||
var ledger = em.GetBuffer<StorageEntry>(q.GetSingletonEntity());
|
||||
for (int i = 0; i < ledger.Length; i++)
|
||||
if (ledger[i].ItemId == ResourceId.Ore) return ledger[i].Count;
|
||||
return 0;
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Valid_Request_Places_Structure_Withdraws_Cost_And_Destroys_Request()
|
||||
{
|
||||
var (world, group) = MakeWorld("BuildValid", oreCount: 50);
|
||||
using (world)
|
||||
{
|
||||
var em = world.EntityManager;
|
||||
MakeBuildRequest(em, StructureType.Turret, cellX: 1, cellZ: 1);
|
||||
|
||||
group.Update();
|
||||
|
||||
Assert.AreEqual(1, StructureCount(em), "A valid request places exactly one structure.");
|
||||
Assert.AreEqual(40, OreCount(em), "The build cost (10) is withdrawn from the ledger.");
|
||||
using var reqQ = em.CreateEntityQuery(typeof(BuildPlaceRequest));
|
||||
Assert.AreEqual(0, reqQ.CalculateEntityCount(), "The handled request is destroyed.");
|
||||
}
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Two_Same_Cell_Requests_Place_Only_One_And_Withdraw_Once()
|
||||
{
|
||||
var (world, group) = MakeWorld("BuildAtomic", oreCount: 50);
|
||||
using (world)
|
||||
{
|
||||
var em = world.EntityManager;
|
||||
MakeBuildRequest(em, StructureType.Turret, cellX: 1, cellZ: 1);
|
||||
MakeBuildRequest(em, StructureType.Turret, cellX: 1, cellZ: 1);
|
||||
|
||||
group.Update();
|
||||
|
||||
Assert.AreEqual(1, StructureCount(em),
|
||||
"Two same-tick requests for one cell place exactly one structure (co-op atomicity).");
|
||||
Assert.AreEqual(40, OreCount(em), "The cost is withdrawn exactly once, not twice.");
|
||||
}
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Insufficient_Resources_Places_Nothing()
|
||||
{
|
||||
var (world, group) = MakeWorld("BuildPoor", oreCount: 5);
|
||||
using (world)
|
||||
{
|
||||
var em = world.EntityManager;
|
||||
MakeBuildRequest(em, StructureType.Turret, cellX: 1, cellZ: 1);
|
||||
|
||||
group.Update();
|
||||
|
||||
Assert.AreEqual(0, StructureCount(em), "A request that can't afford the cost places nothing.");
|
||||
Assert.AreEqual(5, OreCount(em), "The ledger is untouched on an unaffordable request.");
|
||||
}
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Out_Of_Plot_Cell_Places_Nothing()
|
||||
{
|
||||
var (world, group) = MakeWorld("BuildOOB", oreCount: 50);
|
||||
using (world)
|
||||
{
|
||||
var em = world.EntityManager;
|
||||
MakeBuildRequest(em, StructureType.Turret, cellX: 99, cellZ: 99);
|
||||
|
||||
group.Update();
|
||||
|
||||
Assert.AreEqual(0, StructureCount(em), "An out-of-plot cell places nothing.");
|
||||
Assert.AreEqual(50, OreCount(em), "No cost is withdrawn for an illegal placement.");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 69248c6e19368b246a8aa8b151a8f7b0
|
||||
@@ -0,0 +1,138 @@
|
||||
using NUnit.Framework;
|
||||
using ProjectM.Server;
|
||||
using ProjectM.Simulation;
|
||||
using Unity.Core;
|
||||
using Unity.Entities;
|
||||
using Unity.NetCode;
|
||||
|
||||
namespace ProjectM.Tests
|
||||
{
|
||||
/// <summary>
|
||||
/// Plain-Entities EditMode tests for the server-only <see cref="CyclePhaseSystem"/> — the macro-loop director
|
||||
/// (Expedition → Defend → Build → next cycle). A bare world is seeded with a NetworkTime singleton and a cycle
|
||||
/// entity carrying CycleState + CycleRuntime (and optionally WaveState / GoalProgress). All timing is wrap-safe
|
||||
/// NetworkTick math; these tests pin each phase transition and the per-cycle goal-charge increment.
|
||||
/// </summary>
|
||||
public class CyclePhaseSystemTests
|
||||
{
|
||||
static (World world, SimulationSystemGroup group) MakeWorld(string name, uint serverTick)
|
||||
{
|
||||
var world = new World(name);
|
||||
var group = world.GetOrCreateSystemManaged<SimulationSystemGroup>();
|
||||
group.AddSystemToUpdateList(world.GetOrCreateSystem<CyclePhaseSystem>());
|
||||
group.SortSystems();
|
||||
world.SetTime(new TimeData(elapsedTime: 0f, deltaTime: 1f / 60f));
|
||||
var em = world.EntityManager;
|
||||
var nt = em.CreateEntity(typeof(NetworkTime));
|
||||
em.SetComponentData(nt, new NetworkTime { ServerTick = new NetworkTick(serverTick) });
|
||||
return (world, group);
|
||||
}
|
||||
|
||||
static Entity MakeCycle(EntityManager em, byte phase, uint phaseEndTick, int cycleNumber, int defendStartWave)
|
||||
{
|
||||
var e = em.CreateEntity(typeof(CycleState), typeof(CycleRuntime));
|
||||
em.SetComponentData(e, new CycleState { Phase = phase, PhaseEndTick = phaseEndTick, CycleNumber = cycleNumber });
|
||||
em.SetComponentData(e, new CycleRuntime { DefendStartWave = defendStartWave });
|
||||
return e;
|
||||
}
|
||||
|
||||
static void MakeWaveState(EntityManager em, int waveNumber, int remainingToSpawn)
|
||||
{
|
||||
var e = em.CreateEntity(typeof(WaveState));
|
||||
em.SetComponentData(e, new WaveState { WaveNumber = waveNumber, RemainingToSpawn = remainingToSpawn });
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Expedition_Enters_Defend_When_Timer_Due_Capturing_StartWave()
|
||||
{
|
||||
var (world, group) = MakeWorld("CycleExpToDefend", serverTick: 200);
|
||||
using (world)
|
||||
{
|
||||
var em = world.EntityManager;
|
||||
var cycle = MakeCycle(em, CyclePhase.Expedition, phaseEndTick: 100, cycleNumber: 1, defendStartWave: 0);
|
||||
MakeWaveState(em, waveNumber: 5, remainingToSpawn: 0);
|
||||
|
||||
group.Update();
|
||||
|
||||
var cs = em.GetComponentData<CycleState>(cycle);
|
||||
Assert.AreEqual(CyclePhase.Defend, cs.Phase, "An expired Expedition timer enters Defend.");
|
||||
Assert.AreEqual(0u, cs.PhaseEndTick, "Defend is wave-driven, so PhaseEndTick is cleared.");
|
||||
Assert.AreEqual(5, em.GetComponentData<CycleRuntime>(cycle).DefendStartWave,
|
||||
"DefendStartWave captures the current WaveState.WaveNumber.");
|
||||
}
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Expedition_Holds_While_Timer_Pending()
|
||||
{
|
||||
var (world, group) = MakeWorld("CycleExpHold", serverTick: 200);
|
||||
using (world)
|
||||
{
|
||||
var em = world.EntityManager;
|
||||
var cycle = MakeCycle(em, CyclePhase.Expedition, phaseEndTick: 5000, cycleNumber: 1, defendStartWave: 0);
|
||||
|
||||
group.Update();
|
||||
|
||||
Assert.AreEqual(CyclePhase.Expedition, em.GetComponentData<CycleState>(cycle).Phase,
|
||||
"Expedition holds until its timer is due.");
|
||||
}
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Defend_Enters_Build_When_Wave_Cleared()
|
||||
{
|
||||
var (world, group) = MakeWorld("CycleDefendToBuild", serverTick: 200);
|
||||
using (world)
|
||||
{
|
||||
var em = world.EntityManager;
|
||||
var cycle = MakeCycle(em, CyclePhase.Defend, phaseEndTick: 0, cycleNumber: 1, defendStartWave: 1);
|
||||
// Wave advanced past the captured start, fully spawned, and no Husks alive (none created).
|
||||
MakeWaveState(em, waveNumber: 2, remainingToSpawn: 0);
|
||||
|
||||
group.Update();
|
||||
|
||||
var cs = em.GetComponentData<CycleState>(cycle);
|
||||
Assert.AreEqual(CyclePhase.Build, cs.Phase, "A cleared Defend wave enters Build.");
|
||||
Assert.AreNotEqual(0u, cs.PhaseEndTick, "Build is timed, so a PhaseEndTick is stamped.");
|
||||
}
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Build_Enters_Expedition_Incrementing_Cycle_And_Goal()
|
||||
{
|
||||
var (world, group) = MakeWorld("CycleBuildToExp", serverTick: 200);
|
||||
using (world)
|
||||
{
|
||||
var em = world.EntityManager;
|
||||
var cycle = MakeCycle(em, CyclePhase.Build, phaseEndTick: 100, cycleNumber: 1, defendStartWave: 0);
|
||||
em.AddComponentData(cycle, new GoalProgress { Charge = 0, Target = 10 });
|
||||
|
||||
group.Update();
|
||||
|
||||
var cs = em.GetComponentData<CycleState>(cycle);
|
||||
Assert.AreEqual(CyclePhase.Expedition, cs.Phase, "An expired Build timer starts the next Expedition.");
|
||||
Assert.AreEqual(2, cs.CycleNumber, "CycleNumber increments on the new cycle.");
|
||||
Assert.AreEqual(1, em.GetComponentData<GoalProgress>(cycle).Charge,
|
||||
"One goal charge accrues per completed cycle (single writer).");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
[Test]
|
||||
public void WaveNumber_Is_Synced_From_WaveState_For_The_Hud()
|
||||
{
|
||||
var (world, group) = MakeWorld("CycleWaveSync", serverTick: 200);
|
||||
using (world)
|
||||
{
|
||||
var em = world.EntityManager;
|
||||
var cycle = MakeCycle(em, CyclePhase.Defend, phaseEndTick: 0, cycleNumber: 1, defendStartWave: 1);
|
||||
MakeWaveState(em, waveNumber: 4, remainingToSpawn: 2);
|
||||
|
||||
group.Update();
|
||||
|
||||
Assert.AreEqual(4, em.GetComponentData<CycleState>(cycle).WaveNumber,
|
||||
"CycleState.WaveNumber mirrors the server-only WaveState.WaveNumber so the replicated-state-only HUD can show it.");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: def6f8080b5a28d4eb9ee4781b283752
|
||||
@@ -0,0 +1,123 @@
|
||||
using NUnit.Framework;
|
||||
using ProjectM.Server;
|
||||
using ProjectM.Simulation;
|
||||
using Unity.Core;
|
||||
using Unity.Entities;
|
||||
using Unity.Mathematics;
|
||||
using Unity.Transforms;
|
||||
|
||||
namespace ProjectM.Tests
|
||||
{
|
||||
/// <summary>
|
||||
/// Plain-Entities EditMode tests for the server-only <see cref="ExpeditionGateSystem"/> (walk-in region
|
||||
/// transit). A bare world is seeded with an <c>ExpeditionGate</c> (+ LocalTransform) and a player
|
||||
/// (RegionTag + LocalTransform + PlayerTag). A player whose region matches the gate's FromRegion and who is
|
||||
/// within the gate radius is transited (RegionTag flipped + LocalTransform teleported to ArrivalPos).
|
||||
/// Returning to base during the Expedition phase caps the cycle phase timer. Pins the proximity gate, the
|
||||
/// region/radius guards, and the early-return phase cap.
|
||||
/// </summary>
|
||||
public class ExpeditionGateSystemTests
|
||||
{
|
||||
static (World world, SimulationSystemGroup group) MakeWorld(string name)
|
||||
{
|
||||
var world = new World(name);
|
||||
var group = world.GetOrCreateSystemManaged<SimulationSystemGroup>();
|
||||
group.AddSystemToUpdateList(world.GetOrCreateSystem<ExpeditionGateSystem>());
|
||||
group.SortSystems();
|
||||
world.SetTime(new TimeData(elapsedTime: 0f, deltaTime: 1f / 60f));
|
||||
return (world, group);
|
||||
}
|
||||
|
||||
static void MakeGate(EntityManager em, float3 pos, byte from, byte to, float radius, float3 arrival)
|
||||
{
|
||||
var e = em.CreateEntity();
|
||||
em.AddComponentData(e, LocalTransform.FromPosition(pos));
|
||||
em.AddComponentData(e, new ExpeditionGate { FromRegion = from, ToRegion = to, Radius = radius, ArrivalPos = arrival });
|
||||
}
|
||||
|
||||
static Entity MakePlayer(EntityManager em, float3 pos, byte region)
|
||||
{
|
||||
var e = em.CreateEntity();
|
||||
em.AddComponentData(e, LocalTransform.FromPosition(pos));
|
||||
em.AddComponentData(e, new RegionTag { Region = region });
|
||||
em.AddComponent<PlayerTag>(e);
|
||||
return e;
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Player_In_Gate_Radius_Is_Transited_And_Teleported()
|
||||
{
|
||||
var (world, group) = MakeWorld("GateTransitWorld");
|
||||
using (world)
|
||||
{
|
||||
var em = world.EntityManager;
|
||||
var arrival = new float3(1000, 1, 0);
|
||||
MakeGate(em, new float3(0, 1, 0), RegionId.Base, RegionId.Expedition, radius: 15f, arrival: arrival);
|
||||
var player = MakePlayer(em, new float3(5, 1, 0), RegionId.Base);
|
||||
|
||||
group.Update();
|
||||
|
||||
Assert.AreEqual(RegionId.Expedition, em.GetComponentData<RegionTag>(player).Region,
|
||||
"Region flips to the gate's ToRegion.");
|
||||
var p = em.GetComponentData<LocalTransform>(player).Position;
|
||||
Assert.AreEqual(1000f, p.x, 1e-3f, "Player is teleported to the gate's ArrivalPos (x).");
|
||||
Assert.AreEqual(0f, p.z, 1e-3f, "Player is teleported to the gate's ArrivalPos (z).");
|
||||
}
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Player_Outside_Radius_Is_Not_Transited()
|
||||
{
|
||||
var (world, group) = MakeWorld("GateNoTransitWorld");
|
||||
using (world)
|
||||
{
|
||||
var em = world.EntityManager;
|
||||
MakeGate(em, new float3(0, 1, 0), RegionId.Base, RegionId.Expedition, radius: 15f, arrival: new float3(1000, 1, 0));
|
||||
var player = MakePlayer(em, new float3(50, 1, 0), RegionId.Base);
|
||||
|
||||
group.Update();
|
||||
|
||||
Assert.AreEqual(RegionId.Base, em.GetComponentData<RegionTag>(player).Region,
|
||||
"A player beyond the gate radius stays in its region.");
|
||||
}
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Player_Wrong_Region_Is_Not_Transited()
|
||||
{
|
||||
var (world, group) = MakeWorld("GateWrongRegionWorld");
|
||||
using (world)
|
||||
{
|
||||
var em = world.EntityManager;
|
||||
// Gate only acts on players currently in the Base region.
|
||||
MakeGate(em, new float3(0, 1, 0), RegionId.Base, RegionId.Expedition, radius: 15f, arrival: new float3(1000, 1, 0));
|
||||
var player = MakePlayer(em, new float3(1, 1, 0), RegionId.Expedition);
|
||||
|
||||
group.Update();
|
||||
|
||||
Assert.AreEqual(RegionId.Expedition, em.GetComponentData<RegionTag>(player).Region,
|
||||
"A player whose region does not match FromRegion is ignored even inside the radius.");
|
||||
}
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Return_To_Base_During_Expedition_Caps_The_Phase_Timer()
|
||||
{
|
||||
var (world, group) = MakeWorld("GateReturnCapWorld");
|
||||
using (world)
|
||||
{
|
||||
var em = world.EntityManager;
|
||||
MakeGate(em, new float3(0, 1, 0), RegionId.Expedition, RegionId.Base, radius: 15f, arrival: new float3(0, 1, 0));
|
||||
MakePlayer(em, new float3(3, 1, 0), RegionId.Expedition);
|
||||
|
||||
var cycle = em.CreateEntity(typeof(CycleState));
|
||||
em.SetComponentData(cycle, new CycleState { Phase = CyclePhase.Expedition, PhaseEndTick = 5000, CycleNumber = 1 });
|
||||
|
||||
group.Update();
|
||||
|
||||
Assert.AreEqual(1u, em.GetComponentData<CycleState>(cycle).PhaseEndTick,
|
||||
"Returning to base mid-Expedition caps PhaseEndTick to 1 so Defend starts next tick.");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: dfddde749d3109843901804073127701
|
||||
@@ -0,0 +1,143 @@
|
||||
using NUnit.Framework;
|
||||
using ProjectM.Server;
|
||||
using ProjectM.Simulation;
|
||||
using Unity.Core;
|
||||
using Unity.Entities;
|
||||
using Unity.Mathematics;
|
||||
using Unity.NetCode;
|
||||
using Unity.Transforms;
|
||||
|
||||
namespace ProjectM.Tests
|
||||
{
|
||||
/// <summary>
|
||||
/// Plain-Entities EditMode tests for enemy KNOCKBACK (server-only, no re-bake). Two halves:
|
||||
/// ProjectileDamageSystem STAMPS a <see cref="KnockbackState"/> on a hit Husk (Dir = the projectile heading,
|
||||
/// UntilTick = now + Tuning.KnockbackDurationTicks); EnemyAISystem APPLIES it — moving the Husk along the
|
||||
/// knockback heading (overriding seek) and suppressing its strike for the window, then resuming seek. Both
|
||||
/// systems are server-only Burst ISystems; a NetworkTime singleton is seeded (TurretFireSystems pattern).
|
||||
/// Knockback is gated by Tuning.KnockbackSpeed (0 disables) — so ProjectileDamageSystem only stamps when > 0.
|
||||
/// </summary>
|
||||
public class KnockbackTests
|
||||
{
|
||||
static void SetServerTick(World world, uint tick)
|
||||
{
|
||||
var em = world.EntityManager;
|
||||
using var q = em.CreateEntityQuery(typeof(NetworkTime));
|
||||
Entity e = q.IsEmpty ? em.CreateEntity(typeof(NetworkTime)) : q.GetSingletonEntity();
|
||||
em.SetComponentData(e, new NetworkTime { ServerTick = new NetworkTick(tick) });
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void ProjectileDamage_Stamps_Knockback_On_Hit_Husk()
|
||||
{
|
||||
var world = new World("KnockbackStampWorld");
|
||||
var group = world.GetOrCreateSystemManaged<SimulationSystemGroup>();
|
||||
group.AddSystemToUpdateList(world.GetOrCreateSystem<ProjectileDamageSystem>());
|
||||
group.SortSystems();
|
||||
using (world)
|
||||
{
|
||||
var em = world.EntityManager;
|
||||
SetServerTick(world, 200);
|
||||
|
||||
var husk = em.CreateEntity();
|
||||
em.AddComponentData(husk, LocalTransform.FromPosition(new float3(0, 0, 5)));
|
||||
em.AddComponentData(husk, new HitRadius { Value = 0.8f });
|
||||
em.AddComponentData(husk, new Health { Current = 50f, Max = 50f });
|
||||
em.AddBuffer<DamageEvent>(husk);
|
||||
em.AddComponentData(husk, new KnockbackState()); // baked on real Husks
|
||||
|
||||
var proj = em.CreateEntity();
|
||||
em.AddComponentData(proj, LocalTransform.FromPosition(new float3(0, 0, 5)));
|
||||
em.AddComponentData(proj, new Projectile { Direction = new float2(0, 1), Speed = 10f, Damage = 20f, Range = 20f, DistanceTravelled = 5f });
|
||||
em.AddComponentData(proj, new GhostOwner { NetworkId = 1 });
|
||||
|
||||
world.SetTime(new TimeData(elapsedTime: 0.1f, deltaTime: 0.1f));
|
||||
group.Update();
|
||||
|
||||
Assert.AreEqual(1, em.GetBuffer<DamageEvent>(husk).Length, "The hit still deals damage.");
|
||||
Assert.IsFalse(em.Exists(proj), "The projectile is consumed on hit.");
|
||||
var kb = em.GetComponentData<KnockbackState>(husk);
|
||||
Assert.AreEqual(TickUtil.NonZero(200 + (uint)Tuning.KnockbackDurationTicks), kb.UntilTick,
|
||||
"Knockback is scheduled until now + KnockbackDurationTicks.");
|
||||
Assert.AreEqual(0f, kb.Dir.x, 1e-4f);
|
||||
Assert.AreEqual(1f, kb.Dir.y, 1e-4f, "Knockback heading matches the projectile direction.");
|
||||
Assert.AreEqual(Tuning.KnockbackSpeed, kb.Speed, 1e-4f);
|
||||
}
|
||||
}
|
||||
|
||||
static (World world, SimulationSystemGroup group) MakeAiWorld(string name, uint serverTick)
|
||||
{
|
||||
var world = new World(name);
|
||||
var group = world.GetOrCreateSystemManaged<SimulationSystemGroup>();
|
||||
group.AddSystemToUpdateList(world.GetOrCreateSystem<EnemyAISystem>());
|
||||
group.SortSystems();
|
||||
world.SetTime(new TimeData(elapsedTime: 0f, deltaTime: 1f / 60f));
|
||||
SetServerTick(world, serverTick);
|
||||
return (world, group);
|
||||
}
|
||||
|
||||
static Entity MakePlayer(EntityManager em, float3 pos)
|
||||
{
|
||||
var e = em.CreateEntity();
|
||||
em.AddComponentData(e, LocalTransform.FromPosition(pos));
|
||||
em.AddComponentData(e, new Health { Current = 100f, Max = 100f });
|
||||
em.AddComponent<PlayerTag>(e);
|
||||
em.AddBuffer<DamageEvent>(e);
|
||||
return e;
|
||||
}
|
||||
|
||||
static Entity MakeHusk(EntityManager em, float3 pos, KnockbackState kb)
|
||||
{
|
||||
var e = em.CreateEntity();
|
||||
em.AddComponentData(e, LocalTransform.FromPosition(pos));
|
||||
em.AddComponentData(e, new EnemyStats { MoveSpeed = 3f, AttackRange = 1.6f, AttackDamage = 8f, AttackCooldownTicks = 36 });
|
||||
em.AddComponentData(e, new EnemyAttackCooldown { NextAttackTick = 0 });
|
||||
em.AddComponentData(e, kb);
|
||||
em.AddComponentData(e, new AttackWindup());
|
||||
em.AddComponent<EnemyTag>(e);
|
||||
return e;
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Knockback_Overrides_Seek_Then_Resumes()
|
||||
{
|
||||
var (world, group) = MakeAiWorld("KnockbackAiWorld", 200);
|
||||
using (world)
|
||||
{
|
||||
var em = world.EntityManager;
|
||||
MakePlayer(em, new float3(10, 1, 0)); // player at +X
|
||||
var husk = MakeHusk(em, new float3(5, 1, 0),
|
||||
new KnockbackState { Dir = new float2(-1, 0), Speed = Tuning.KnockbackSpeed, UntilTick = TickUtil.NonZero(208) });
|
||||
|
||||
group.Update(); // tick 200: knocked -> moves -X (against the seek toward +X)
|
||||
float xKnocked = em.GetComponentData<LocalTransform>(husk).Position.x;
|
||||
Assert.Less(xKnocked, 5f, "While knocked the Husk moves along the knockback heading (-X), not toward the player (+X).");
|
||||
|
||||
SetServerTick(world, 208);
|
||||
group.Update(); // window elapsed -> seek resumes toward the player at +X
|
||||
float xResumed = em.GetComponentData<LocalTransform>(husk).Position.x;
|
||||
Assert.Greater(xResumed, xKnocked, "Once the knockback window elapses the Husk seeks back toward the player.");
|
||||
Assert.AreEqual(0u, em.GetComponentData<KnockbackState>(husk).UntilTick, "Knockback state is cleared after the window.");
|
||||
}
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Knocked_Husk_Does_Not_Strike()
|
||||
{
|
||||
var (world, group) = MakeAiWorld("KnockbackNoStrikeWorld", 200);
|
||||
using (world)
|
||||
{
|
||||
var em = world.EntityManager;
|
||||
var player = MakePlayer(em, new float3(10, 1, 0));
|
||||
// Husk inside AttackRange of the player, but knocked.
|
||||
MakeHusk(em, new float3(9, 1, 0),
|
||||
new KnockbackState { Dir = new float2(-1, 0), Speed = Tuning.KnockbackSpeed, UntilTick = TickUtil.NonZero(208) });
|
||||
|
||||
group.Update();
|
||||
|
||||
Assert.AreEqual(0, em.GetBuffer<DamageEvent>(player).Length,
|
||||
"A recoiling Husk does not strike even when inside AttackRange.");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 85b62d9117f60de448d7a515c0709a89
|
||||
@@ -0,0 +1,107 @@
|
||||
using NUnit.Framework;
|
||||
using ProjectM.Server;
|
||||
using ProjectM.Simulation;
|
||||
using Unity.Core;
|
||||
using Unity.Entities;
|
||||
using Unity.Mathematics;
|
||||
using Unity.NetCode;
|
||||
using Unity.Transforms;
|
||||
|
||||
namespace ProjectM.Tests
|
||||
{
|
||||
/// <summary>
|
||||
/// Plain-Entities EditMode tests for the server-only <see cref="PlayerRespawnSystem"/> — the authoritative
|
||||
/// death→respawn timer. A bare world is seeded with NetworkTime + PlayerSpawner singletons and a player
|
||||
/// (Health, RespawnState, RespawnInvuln, LocalTransform, GhostOwner, EffectiveCharacterStats, PlayerTag).
|
||||
/// Pins: a newly-dead player schedules a respawn tick; a due tick refills health + repositions + grants
|
||||
/// invuln + clears the schedule; an alive player clears any stale pending schedule.
|
||||
/// </summary>
|
||||
public class PlayerRespawnSystemTests
|
||||
{
|
||||
static (World world, SimulationSystemGroup group) MakeWorld(string name, uint serverTick)
|
||||
{
|
||||
var world = new World(name);
|
||||
var group = world.GetOrCreateSystemManaged<SimulationSystemGroup>();
|
||||
group.AddSystemToUpdateList(world.GetOrCreateSystem<PlayerRespawnSystem>());
|
||||
group.SortSystems();
|
||||
world.SetTime(new TimeData(elapsedTime: 0f, deltaTime: 1f / 60f));
|
||||
var em = world.EntityManager;
|
||||
var nt = em.CreateEntity(typeof(NetworkTime));
|
||||
em.SetComponentData(nt, new NetworkTime { ServerTick = new NetworkTick(serverTick) });
|
||||
var sp = em.CreateEntity(typeof(PlayerSpawner));
|
||||
em.SetComponentData(sp, new PlayerSpawner { SpawnPoint = new float3(0, 1, 0), SpawnRingRadius = 10f, RingSlots = 4 });
|
||||
return (world, group);
|
||||
}
|
||||
|
||||
static Entity MakePlayer(EntityManager em, float health, float maxHealth, uint respawnTick,
|
||||
int delayTicks, int invulnTicks, float3 pos, int networkId)
|
||||
{
|
||||
var e = em.CreateEntity();
|
||||
em.AddComponentData(e, new Health { Current = health, Max = maxHealth });
|
||||
em.AddComponentData(e, new RespawnState { RespawnTick = respawnTick, DelayTicks = delayTicks, InvulnTicks = invulnTicks });
|
||||
em.AddComponentData(e, new RespawnInvuln { UntilTick = 0 });
|
||||
em.AddComponentData(e, LocalTransform.FromPosition(pos));
|
||||
em.AddComponentData(e, new GhostOwner { NetworkId = networkId });
|
||||
em.AddComponentData(e, new EffectiveCharacterStats { MaxHealth = maxHealth });
|
||||
em.AddComponent<PlayerTag>(e);
|
||||
return e;
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Newly_Dead_Player_Schedules_Respawn_Tick()
|
||||
{
|
||||
var (world, group) = MakeWorld("RespawnSchedule", serverTick: 200);
|
||||
using (world)
|
||||
{
|
||||
var em = world.EntityManager;
|
||||
var player = MakePlayer(em, health: 0f, maxHealth: 100f, respawnTick: 0,
|
||||
delayTicks: 60, invulnTicks: 120, pos: new float3(5, 1, 5), networkId: 1);
|
||||
|
||||
group.Update();
|
||||
|
||||
Assert.AreEqual(RespawnMath.RespawnTick(200, 60), em.GetComponentData<RespawnState>(player).RespawnTick,
|
||||
"A newly-dead player schedules its respawn tick (now + delay).");
|
||||
Assert.AreEqual(0f, em.GetComponentData<Health>(player).Current, 1e-4f, "Still down until the tick is due.");
|
||||
}
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Due_Respawn_Restores_Health_Repositions_And_Grants_Invuln()
|
||||
{
|
||||
var (world, group) = MakeWorld("RespawnRecover", serverTick: 200);
|
||||
using (world)
|
||||
{
|
||||
var em = world.EntityManager;
|
||||
var player = MakePlayer(em, health: 0f, maxHealth: 100f, respawnTick: 160,
|
||||
delayTicks: 60, invulnTicks: 120, pos: new float3(999, 1, 999), networkId: 1);
|
||||
|
||||
group.Update();
|
||||
|
||||
Assert.AreEqual(100f, em.GetComponentData<Health>(player).Current, 1e-4f, "Health refills to the effective max.");
|
||||
Assert.AreEqual(320u, em.GetComponentData<RespawnInvuln>(player).UntilTick,
|
||||
"Post-respawn invulnerability is granted until now + InvulnTicks (200 + 120).");
|
||||
Assert.AreEqual(0u, em.GetComponentData<RespawnState>(player).RespawnTick, "The respawn schedule is cleared on recovery.");
|
||||
Assert.Less(em.GetComponentData<LocalTransform>(player).Position.x, 100f,
|
||||
"The player is teleported from its death spot back to the base spawn ring.");
|
||||
}
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Alive_Player_Clears_Stale_Pending_Respawn()
|
||||
{
|
||||
var (world, group) = MakeWorld("RespawnAliveClear", serverTick: 200);
|
||||
using (world)
|
||||
{
|
||||
var em = world.EntityManager;
|
||||
var player = MakePlayer(em, health: 50f, maxHealth: 100f, respawnTick: 999,
|
||||
delayTicks: 60, invulnTicks: 120, pos: new float3(5, 1, 5), networkId: 1);
|
||||
|
||||
group.Update();
|
||||
|
||||
Assert.AreEqual(0u, em.GetComponentData<RespawnState>(player).RespawnTick,
|
||||
"An alive player clears any stale pending respawn schedule.");
|
||||
Assert.AreEqual(50f, em.GetComponentData<Health>(player).Current, 1e-4f, "Alive health is untouched.");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 5be985c7449132943ad509f0426bd2cb
|
||||
@@ -0,0 +1,106 @@
|
||||
using NUnit.Framework;
|
||||
using ProjectM.Server;
|
||||
using ProjectM.Simulation;
|
||||
using Unity.Core;
|
||||
using Unity.Entities;
|
||||
using Unity.Mathematics;
|
||||
using Unity.NetCode;
|
||||
using Unity.Transforms;
|
||||
|
||||
namespace ProjectM.Tests
|
||||
{
|
||||
/// <summary>
|
||||
/// Plain-Entities EditMode tests for the server-only <see cref="RegionTransitSystem"/> — the RPC region-transit
|
||||
/// handler. A bare world is seeded with a BaseAnchor, a mock connection entity (NetworkId), a player
|
||||
/// (GhostOwner + RegionTag + LocalTransform + PlayerTag) and a RegionTransitRequest + ReceiveRpcCommandRequest
|
||||
/// whose SourceConnection points at the connection. Pins: a request from a resolvable connection flips the
|
||||
/// player's region + teleports it to the region origin; an unresolvable connection transits nobody; the request
|
||||
/// is consumed either way.
|
||||
/// </summary>
|
||||
public class RegionTransitSystemTests
|
||||
{
|
||||
static (World world, SimulationSystemGroup group) MakeWorld(string name)
|
||||
{
|
||||
var world = new World(name);
|
||||
var group = world.GetOrCreateSystemManaged<SimulationSystemGroup>();
|
||||
group.AddSystemToUpdateList(world.GetOrCreateSystem<RegionTransitSystem>());
|
||||
group.SortSystems();
|
||||
world.SetTime(new TimeData(elapsedTime: 0f, deltaTime: 1f / 60f));
|
||||
var em = world.EntityManager;
|
||||
var anchor = em.CreateEntity(typeof(BaseAnchor));
|
||||
em.SetComponentData(anchor, new BaseAnchor
|
||||
{
|
||||
AnchorPos = new float3(5, 0, 5),
|
||||
GridOrigin = new float3(0, 0, 0),
|
||||
CellSize = 2f,
|
||||
GridDims = new int2(5, 5),
|
||||
});
|
||||
return (world, group);
|
||||
}
|
||||
|
||||
static Entity MakeConnection(EntityManager em, int networkId)
|
||||
{
|
||||
var e = em.CreateEntity();
|
||||
em.AddComponentData(e, new NetworkId { Value = networkId });
|
||||
return e;
|
||||
}
|
||||
|
||||
static Entity MakePlayer(EntityManager em, int networkId, byte region, float3 pos)
|
||||
{
|
||||
var e = em.CreateEntity();
|
||||
em.AddComponentData(e, new GhostOwner { NetworkId = networkId });
|
||||
em.AddComponentData(e, new RegionTag { Region = region });
|
||||
em.AddComponentData(e, LocalTransform.FromPosition(pos));
|
||||
em.AddComponent<PlayerTag>(e);
|
||||
return e;
|
||||
}
|
||||
|
||||
static void MakeTransitRequest(EntityManager em, byte targetRegion, Entity sourceConnection)
|
||||
{
|
||||
var e = em.CreateEntity();
|
||||
em.AddComponentData(e, new RegionTransitRequest { TargetRegion = targetRegion });
|
||||
em.AddComponentData(e, new ReceiveRpcCommandRequest { SourceConnection = sourceConnection });
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Request_From_Known_Connection_Transits_Its_Player()
|
||||
{
|
||||
var (world, group) = MakeWorld("RegionTransitOk");
|
||||
using (world)
|
||||
{
|
||||
var em = world.EntityManager;
|
||||
var conn = MakeConnection(em, networkId: 1);
|
||||
var player = MakePlayer(em, networkId: 1, region: RegionId.Base, pos: new float3(5, 1, 5));
|
||||
MakeTransitRequest(em, RegionId.Expedition, conn);
|
||||
|
||||
group.Update();
|
||||
|
||||
Assert.AreEqual(RegionId.Expedition, em.GetComponentData<RegionTag>(player).Region,
|
||||
"The sender's player flips to the requested region.");
|
||||
Assert.AreEqual(1005f, em.GetComponentData<LocalTransform>(player).Position.x, 1e-2f,
|
||||
"The player teleports to the expedition region origin (base center + 1000 on X).");
|
||||
using var reqQ = em.CreateEntityQuery(typeof(RegionTransitRequest));
|
||||
Assert.AreEqual(0, reqQ.CalculateEntityCount(), "The handled request is destroyed.");
|
||||
}
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Request_From_Unresolvable_Connection_Transits_Nobody()
|
||||
{
|
||||
var (world, group) = MakeWorld("RegionTransitUnknown");
|
||||
using (world)
|
||||
{
|
||||
var em = world.EntityManager;
|
||||
var player = MakePlayer(em, networkId: 1, region: RegionId.Base, pos: new float3(5, 1, 5));
|
||||
MakeTransitRequest(em, RegionId.Expedition, Entity.Null); // no NetworkId on Entity.Null
|
||||
|
||||
group.Update();
|
||||
|
||||
Assert.AreEqual(RegionId.Base, em.GetComponentData<RegionTag>(player).Region,
|
||||
"A request whose connection can't be resolved transits nobody.");
|
||||
using var reqQ = em.CreateEntityQuery(typeof(RegionTransitRequest));
|
||||
Assert.AreEqual(0, reqQ.CalculateEntityCount(), "The request is still consumed.");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 727cdc6da6f5b8842ba5b311702e5224
|
||||
@@ -0,0 +1,117 @@
|
||||
using NUnit.Framework;
|
||||
using ProjectM.Server;
|
||||
using ProjectM.Simulation;
|
||||
using Unity.Core;
|
||||
using Unity.Entities;
|
||||
using Unity.Mathematics;
|
||||
using Unity.Transforms;
|
||||
|
||||
namespace ProjectM.Tests
|
||||
{
|
||||
/// <summary>
|
||||
/// Plain-Entities EditMode tests for the server-only <see cref="ResourceHarvestSystem"/> — the swept-segment
|
||||
/// harvest sweep that deposits a hit node's yield into the GLOBAL resource ledger. A bare world is seeded with
|
||||
/// a ResourceLedger singleton (+ StorageEntry buffer), resource nodes (LocalTransform + HitRadius +
|
||||
/// ResourceNode) and projectiles (LocalTransform + Projectile with a baked LastStep, since ProjectileMoveSystem
|
||||
/// is not in this world). Pins: a hit deposits + decrements Remaining + consumes the projectile; two same-tick
|
||||
/// hits over-harvest but destroy the node at most once (a double DestroyEntity would throw at playback); a miss
|
||||
/// leaves everything untouched and the projectile alive.
|
||||
/// </summary>
|
||||
public class ResourceHarvestSystemTests
|
||||
{
|
||||
static (World world, SimulationSystemGroup group, Entity ledger) MakeWorld(string name)
|
||||
{
|
||||
var world = new World(name);
|
||||
var group = world.GetOrCreateSystemManaged<SimulationSystemGroup>();
|
||||
group.AddSystemToUpdateList(world.GetOrCreateSystem<ResourceHarvestSystem>());
|
||||
group.SortSystems();
|
||||
world.SetTime(new TimeData(elapsedTime: 0f, deltaTime: 1f / 60f));
|
||||
var em = world.EntityManager;
|
||||
var ledger = em.CreateEntity(typeof(ResourceLedger));
|
||||
em.AddBuffer<StorageEntry>(ledger);
|
||||
return (world, group, ledger);
|
||||
}
|
||||
|
||||
static Entity MakeNode(EntityManager em, float3 pos, float hitRadius, byte resourceId, int remaining, float perHit)
|
||||
{
|
||||
var e = em.CreateEntity();
|
||||
em.AddComponentData(e, LocalTransform.FromPosition(pos));
|
||||
em.AddComponentData(e, new HitRadius { Value = hitRadius });
|
||||
em.AddComponentData(e, new ResourceNode { ResourceId = resourceId, Remaining = remaining, HarvestPerHit = perHit });
|
||||
return e;
|
||||
}
|
||||
|
||||
static Entity MakeProjectile(EntityManager em, float3 pos, float2 dir, float lastStep)
|
||||
{
|
||||
var e = em.CreateEntity();
|
||||
em.AddComponentData(e, LocalTransform.FromPosition(pos));
|
||||
em.AddComponentData(e, new Projectile { Direction = dir, LastStep = lastStep });
|
||||
return e;
|
||||
}
|
||||
|
||||
static int LedgerCount(EntityManager em, Entity ledger, ushort itemId)
|
||||
{
|
||||
var buf = em.GetBuffer<StorageEntry>(ledger);
|
||||
for (int i = 0; i < buf.Length; i++)
|
||||
if (buf[i].ItemId == itemId) return buf[i].Count;
|
||||
return 0;
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Hit_Deposits_To_Ledger_Decrements_Node_And_Consumes_Projectile()
|
||||
{
|
||||
var (world, group, ledger) = MakeWorld("HarvestHit");
|
||||
using (world)
|
||||
{
|
||||
var em = world.EntityManager;
|
||||
var node = MakeNode(em, new float3(10, 1, 10), hitRadius: 1f, resourceId: ResourceId.Aether, remaining: 100, perHit: 25f);
|
||||
var proj = MakeProjectile(em, new float3(10, 1, 10), new float2(1, 0), lastStep: 5f);
|
||||
|
||||
group.Update();
|
||||
|
||||
Assert.AreEqual(25, LedgerCount(em, ledger, ResourceId.Aether), "One hit deposits HarvestPerHit into the ledger.");
|
||||
Assert.AreEqual(75, em.GetComponentData<ResourceNode>(node).Remaining, "Node Remaining decrements by the harvested amount.");
|
||||
Assert.IsTrue(em.Exists(node), "A node with resource left survives.");
|
||||
Assert.IsFalse(em.Exists(proj), "The harvesting projectile is consumed.");
|
||||
}
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Two_Projectiles_Deplete_Node_But_Destroy_It_At_Most_Once()
|
||||
{
|
||||
var (world, group, ledger) = MakeWorld("HarvestDeplete");
|
||||
using (world)
|
||||
{
|
||||
var em = world.EntityManager;
|
||||
var node = MakeNode(em, new float3(10, 1, 10), hitRadius: 1f, resourceId: ResourceId.Aether, remaining: 40, perHit: 25f);
|
||||
var p1 = MakeProjectile(em, new float3(10, 1, 10), new float2(1, 0), lastStep: 5f);
|
||||
var p2 = MakeProjectile(em, new float3(10, 1, 10), new float2(0, 1), lastStep: 5f);
|
||||
|
||||
group.Update();
|
||||
|
||||
Assert.AreEqual(50, LedgerCount(em, ledger, ResourceId.Aether), "Both hits deposit, even though the second over-harvests.");
|
||||
Assert.IsFalse(em.Exists(node), "A depleted node is destroyed exactly once (a double destroy would throw at playback).");
|
||||
Assert.IsFalse(em.Exists(p1), "Both projectiles are consumed.");
|
||||
Assert.IsFalse(em.Exists(p2));
|
||||
}
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Missing_Projectile_Leaves_Node_And_Ledger_Untouched()
|
||||
{
|
||||
var (world, group, ledger) = MakeWorld("HarvestMiss");
|
||||
using (world)
|
||||
{
|
||||
var em = world.EntityManager;
|
||||
var node = MakeNode(em, new float3(10, 1, 10), hitRadius: 1f, resourceId: ResourceId.Aether, remaining: 100, perHit: 25f);
|
||||
var proj = MakeProjectile(em, new float3(50, 1, 50), new float2(1, 0), lastStep: 5f);
|
||||
|
||||
group.Update();
|
||||
|
||||
Assert.AreEqual(0, LedgerCount(em, ledger, ResourceId.Aether), "A miss deposits nothing.");
|
||||
Assert.AreEqual(100, em.GetComponentData<ResourceNode>(node).Remaining, "A miss leaves Remaining untouched.");
|
||||
Assert.IsTrue(em.Exists(proj), "A projectile that hits no node survives (no destroy-on-miss).");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 375130bf205b09744a1fc73efb304bfe
|
||||
@@ -0,0 +1,107 @@
|
||||
using NUnit.Framework;
|
||||
using ProjectM.Server;
|
||||
using ProjectM.Simulation;
|
||||
using Unity.Core;
|
||||
using Unity.Entities;
|
||||
using Unity.NetCode;
|
||||
|
||||
namespace ProjectM.Tests
|
||||
{
|
||||
/// <summary>
|
||||
/// Plain-Entities EditMode tests for the server-only <see cref="StorageOpReceiveSystem"/> — the RPC handler
|
||||
/// that applies deposit/withdraw ops to the shared storage container's replicated <c>StorageEntry</c> buffer.
|
||||
/// A bare world with a <c>SharedStorageContainer</c> singleton (carrying the buffer) plus synthetic
|
||||
/// <c>StorageOpRequest</c> + <c>ReceiveRpcCommandRequest</c> entities exercises the handler. The system plays
|
||||
/// its ECB back immediately (Temp allocator), so the handled request entity is destroyed within the single
|
||||
/// group update. Mirrors HealthApplyDamageSystemTests. Locks the deposit/withdraw/drop-row behaviour before
|
||||
/// the Stage-C const refactor and any later storage-model changes.
|
||||
/// </summary>
|
||||
public class StorageOpReceiveSystemTests
|
||||
{
|
||||
static (World world, SimulationSystemGroup group) MakeWorld(string name)
|
||||
{
|
||||
var world = new World(name);
|
||||
var group = world.GetOrCreateSystemManaged<SimulationSystemGroup>();
|
||||
group.AddSystemToUpdateList(world.GetOrCreateSystem<StorageOpReceiveSystem>());
|
||||
group.SortSystems();
|
||||
world.SetTime(new TimeData(elapsedTime: 0f, deltaTime: 1f / 60f));
|
||||
return (world, group);
|
||||
}
|
||||
|
||||
static Entity MakeContainer(EntityManager em, ushort itemId, int count)
|
||||
{
|
||||
var e = em.CreateEntity(typeof(SharedStorageContainer));
|
||||
var buf = em.AddBuffer<StorageEntry>(e);
|
||||
if (itemId != 0)
|
||||
buf.Add(new StorageEntry { ItemId = itemId, Count = count });
|
||||
return e;
|
||||
}
|
||||
|
||||
static void MakeRequest(EntityManager em, byte op, ushort itemId, int count)
|
||||
{
|
||||
var e = em.CreateEntity();
|
||||
em.AddComponentData(e, new StorageOpRequest { Op = op, ItemId = itemId, Count = count });
|
||||
em.AddComponentData(e, default(ReceiveRpcCommandRequest));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Withdraw_Decrements_Existing_Row_And_Destroys_Request()
|
||||
{
|
||||
var (world, group) = MakeWorld("StorageWithdrawWorld");
|
||||
using (world)
|
||||
{
|
||||
var em = world.EntityManager;
|
||||
var container = MakeContainer(em, itemId: 1, count: 100);
|
||||
MakeRequest(em, StorageOp.Withdraw, itemId: 1, count: 30);
|
||||
|
||||
group.Update();
|
||||
|
||||
var buf = em.GetBuffer<StorageEntry>(container);
|
||||
Assert.AreEqual(1, buf.Length, "A partial withdraw keeps the row.");
|
||||
Assert.AreEqual(70, buf[0].Count, "100 - 30 = 70 must remain.");
|
||||
|
||||
using var reqQuery = em.CreateEntityQuery(typeof(StorageOpRequest));
|
||||
Assert.AreEqual(0, reqQuery.CalculateEntityCount(),
|
||||
"The handled request entity must be destroyed by the system's ECB.");
|
||||
}
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Deposit_Of_New_Item_Appends_A_Row()
|
||||
{
|
||||
var (world, group) = MakeWorld("StorageDepositWorld");
|
||||
using (world)
|
||||
{
|
||||
var em = world.EntityManager;
|
||||
var container = MakeContainer(em, itemId: 1, count: 100);
|
||||
MakeRequest(em, StorageOp.Deposit, itemId: 2, count: 20);
|
||||
|
||||
group.Update();
|
||||
|
||||
var buf = em.GetBuffer<StorageEntry>(container);
|
||||
Assert.AreEqual(2, buf.Length, "Depositing a previously-absent item appends a second row.");
|
||||
int item2 = -1;
|
||||
for (int i = 0; i < buf.Length; i++)
|
||||
if (buf[i].ItemId == 2) item2 = buf[i].Count;
|
||||
Assert.AreEqual(20, item2, "The appended row carries the deposited count.");
|
||||
}
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Withdraw_Of_Full_Stack_Drops_The_Row()
|
||||
{
|
||||
var (world, group) = MakeWorld("StorageWithdrawZeroWorld");
|
||||
using (world)
|
||||
{
|
||||
var em = world.EntityManager;
|
||||
var container = MakeContainer(em, itemId: 1, count: 30);
|
||||
MakeRequest(em, StorageOp.Withdraw, itemId: 1, count: 30);
|
||||
|
||||
group.Update();
|
||||
|
||||
var buf = em.GetBuffer<StorageEntry>(container);
|
||||
Assert.AreEqual(0, buf.Length, "Withdrawing the whole stack drops the row entirely.");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 62eb6ac6dd96837468c04d1d89b39499
|
||||
@@ -0,0 +1,110 @@
|
||||
using NUnit.Framework;
|
||||
using ProjectM.Server;
|
||||
using ProjectM.Simulation;
|
||||
using Unity.Core;
|
||||
using Unity.Entities;
|
||||
using Unity.Mathematics;
|
||||
using Unity.NetCode;
|
||||
using Unity.Transforms;
|
||||
|
||||
namespace ProjectM.Tests
|
||||
{
|
||||
/// <summary>
|
||||
/// Plain-Entities EditMode tests for the Husk attack TELEGRAPH (the 2-phase strike in EnemyAISystem). The
|
||||
/// strike no longer fires instantly: when a Husk is first in-range + cooldown-ready it commits a wind-up
|
||||
/// (<see cref="AttackWindup.WindUpUntilTick"/> = now + Tuning.AttackWindupTicks, replicated so the client can
|
||||
/// cue it) and damages NOTHING; the strike lands only when the wind-up tick elapses, and leaving range
|
||||
/// mid-wind-up cancels it. Server timing is fully headless (the replication + client cue are the Play check).
|
||||
/// </summary>
|
||||
public class TelegraphTests
|
||||
{
|
||||
static void SetServerTick(World world, uint tick)
|
||||
{
|
||||
var em = world.EntityManager;
|
||||
using var q = em.CreateEntityQuery(typeof(NetworkTime));
|
||||
Entity e = q.IsEmpty ? em.CreateEntity(typeof(NetworkTime)) : q.GetSingletonEntity();
|
||||
em.SetComponentData(e, new NetworkTime { ServerTick = new NetworkTick(tick) });
|
||||
}
|
||||
|
||||
static (World world, SimulationSystemGroup group) MakeWorld(string name, uint serverTick)
|
||||
{
|
||||
var world = new World(name);
|
||||
var group = world.GetOrCreateSystemManaged<SimulationSystemGroup>();
|
||||
group.AddSystemToUpdateList(world.GetOrCreateSystem<EnemyAISystem>());
|
||||
group.SortSystems();
|
||||
world.SetTime(new TimeData(elapsedTime: 0f, deltaTime: 1f / 60f));
|
||||
SetServerTick(world, serverTick);
|
||||
return (world, group);
|
||||
}
|
||||
|
||||
static Entity MakePlayer(EntityManager em, float3 pos)
|
||||
{
|
||||
var e = em.CreateEntity();
|
||||
em.AddComponentData(e, LocalTransform.FromPosition(pos));
|
||||
em.AddComponentData(e, new Health { Current = 100f, Max = 100f });
|
||||
em.AddComponent<PlayerTag>(e);
|
||||
em.AddBuffer<DamageEvent>(e);
|
||||
return e;
|
||||
}
|
||||
|
||||
static Entity MakeHusk(EntityManager em, float3 pos)
|
||||
{
|
||||
var e = em.CreateEntity();
|
||||
em.AddComponentData(e, LocalTransform.FromPosition(pos));
|
||||
em.AddComponentData(e, new EnemyStats { MoveSpeed = 3f, AttackRange = 1.6f, AttackDamage = 8f, AttackCooldownTicks = 36 });
|
||||
em.AddComponentData(e, new EnemyAttackCooldown { NextAttackTick = 0 });
|
||||
em.AddComponentData(e, new KnockbackState());
|
||||
em.AddComponentData(e, new AttackWindup());
|
||||
em.AddComponent<EnemyTag>(e);
|
||||
return e;
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Husk_Winds_Up_First_Then_Strikes_At_Expiry()
|
||||
{
|
||||
var (world, group) = MakeWorld("TelegraphStrike", 200);
|
||||
using (world)
|
||||
{
|
||||
var em = world.EntityManager;
|
||||
var player = MakePlayer(em, new float3(10, 1, 0));
|
||||
var husk = MakeHusk(em, new float3(9, 1, 0)); // distance 1 < AttackRange 1.6 -> in range
|
||||
|
||||
group.Update(); // tick 200: begins the wind-up, deals NO damage yet
|
||||
uint expected = TickUtil.NonZero(200 + (uint)Tuning.AttackWindupTicks);
|
||||
Assert.AreEqual(expected, em.GetComponentData<AttackWindup>(husk).WindUpUntilTick,
|
||||
"An in-range, ready Husk commits a wind-up until now + AttackWindupTicks.");
|
||||
Assert.AreEqual(0, em.GetBuffer<DamageEvent>(player).Length, "No damage lands during the wind-up.");
|
||||
|
||||
SetServerTick(world, expected);
|
||||
group.Update(); // wind-up elapsed -> strike lands
|
||||
Assert.AreEqual(1, em.GetBuffer<DamageEvent>(player).Length, "The strike lands exactly when the wind-up elapses.");
|
||||
Assert.AreEqual(0u, em.GetComponentData<AttackWindup>(husk).WindUpUntilTick, "The wind-up resets after the strike.");
|
||||
Assert.AreNotEqual(0u, em.GetComponentData<EnemyAttackCooldown>(husk).NextAttackTick, "The strike cooldown is stamped.");
|
||||
}
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Leaving_Range_Mid_WindUp_Cancels_The_Strike()
|
||||
{
|
||||
var (world, group) = MakeWorld("TelegraphCancel", 200);
|
||||
using (world)
|
||||
{
|
||||
var em = world.EntityManager;
|
||||
var player = MakePlayer(em, new float3(10, 1, 0));
|
||||
var husk = MakeHusk(em, new float3(9, 1, 0));
|
||||
|
||||
group.Update(); // begins the wind-up
|
||||
uint windTick = em.GetComponentData<AttackWindup>(husk).WindUpUntilTick;
|
||||
Assert.AreNotEqual(0u, windTick);
|
||||
|
||||
// Player flees far out of range before the wind-up completes.
|
||||
em.SetComponentData(player, LocalTransform.FromPosition(new float3(60, 1, 0)));
|
||||
SetServerTick(world, windTick);
|
||||
group.Update();
|
||||
|
||||
Assert.AreEqual(0, em.GetBuffer<DamageEvent>(player).Length, "Leaving range mid-wind-up cancels the strike.");
|
||||
Assert.AreEqual(0u, em.GetComponentData<AttackWindup>(husk).WindUpUntilTick, "The cancelled wind-up is cleared.");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 4eceb2e9d3917444281e0513e22e34d0
|
||||
@@ -0,0 +1,134 @@
|
||||
using NUnit.Framework;
|
||||
using ProjectM.Server;
|
||||
using ProjectM.Simulation;
|
||||
using Unity.Core;
|
||||
using Unity.Entities;
|
||||
using Unity.NetCode;
|
||||
|
||||
namespace ProjectM.Tests
|
||||
{
|
||||
/// <summary>
|
||||
/// Plain-Entities EditMode tests for the server-only <see cref="TimedModifierExpirySystem"/> — timed/removable
|
||||
/// StatModifiers. A bare world is seeded with a NetworkTime singleton and a player carrying paired StatModifier
|
||||
/// + TimedModifier buffers (same SourceId). Pins: a modifier persists until its tick then is removed by SourceId
|
||||
/// (along with its tracker row); independent expiry of multiple timed mods; the clear-by-SourceId helper; and
|
||||
/// the TickUtil.NonZero guard so a wrap-tick expiry never collides with the 0 = "inert" sentinel. The
|
||||
/// replicated StatModifier layout is untouched (separate tracker buffer) so there is no ghost re-bake.
|
||||
/// </summary>
|
||||
public class TimedModifierExpirySystemTests
|
||||
{
|
||||
static (World world, SimulationSystemGroup group) MakeWorld(string name, uint serverTick)
|
||||
{
|
||||
var world = new World(name);
|
||||
var group = world.GetOrCreateSystemManaged<SimulationSystemGroup>();
|
||||
group.AddSystemToUpdateList(world.GetOrCreateSystem<TimedModifierExpirySystem>());
|
||||
group.SortSystems();
|
||||
world.SetTime(new TimeData(elapsedTime: 0f, deltaTime: 1f / 60f));
|
||||
SetServerTick(world, serverTick);
|
||||
return (world, group);
|
||||
}
|
||||
|
||||
static void SetServerTick(World world, uint tick)
|
||||
{
|
||||
var em = world.EntityManager;
|
||||
using var q = em.CreateEntityQuery(typeof(NetworkTime));
|
||||
Entity e = q.IsEmpty ? em.CreateEntity(typeof(NetworkTime)) : q.GetSingletonEntity();
|
||||
em.SetComponentData(e, new NetworkTime { ServerTick = new NetworkTick(tick) });
|
||||
}
|
||||
|
||||
static Entity MakePlayer(EntityManager em)
|
||||
{
|
||||
var e = em.CreateEntity();
|
||||
em.AddBuffer<StatModifier>(e);
|
||||
em.AddBuffer<TimedModifier>(e);
|
||||
return e;
|
||||
}
|
||||
|
||||
static void AddTimedMod(EntityManager em, Entity e, byte target, float value, uint sourceId, uint untilTick)
|
||||
{
|
||||
em.GetBuffer<StatModifier>(e).Add(new StatModifier { Target = target, Op = (byte)ModOp.Flat, Value = value, SourceId = sourceId });
|
||||
em.GetBuffer<TimedModifier>(e).Add(new TimedModifier { SourceId = sourceId, UntilTick = untilTick });
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Modifier_Persists_Before_Expiry_And_Is_Removed_After()
|
||||
{
|
||||
var (world, group) = MakeWorld("TimedExpire", serverTick: 200);
|
||||
using (world)
|
||||
{
|
||||
var em = world.EntityManager;
|
||||
var p = MakePlayer(em);
|
||||
AddTimedMod(em, p, (byte)StatTarget.Damage, 10f, sourceId: 0xABCDu, untilTick: 300);
|
||||
|
||||
group.Update(); // serverTick 200 < 300 -> not due
|
||||
Assert.AreEqual(1, em.GetBuffer<StatModifier>(p).Length, "Modifier persists before its expiry tick.");
|
||||
Assert.AreEqual(1, em.GetBuffer<TimedModifier>(p).Length);
|
||||
|
||||
SetServerTick(world, 300);
|
||||
group.Update(); // due (300 not newer than 300)
|
||||
Assert.AreEqual(0, em.GetBuffer<StatModifier>(p).Length, "Expired modifier is removed by SourceId.");
|
||||
Assert.AreEqual(0, em.GetBuffer<TimedModifier>(p).Length, "The timed-tracker row is removed too.");
|
||||
}
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Timed_Modifiers_Expire_Independently()
|
||||
{
|
||||
var (world, group) = MakeWorld("TimedIndependent", serverTick: 200);
|
||||
using (world)
|
||||
{
|
||||
var em = world.EntityManager;
|
||||
var p = MakePlayer(em);
|
||||
AddTimedMod(em, p, (byte)StatTarget.Damage, 10f, sourceId: 1u, untilTick: 250);
|
||||
AddTimedMod(em, p, (byte)StatTarget.MoveSpeed, 0.2f, sourceId: 2u, untilTick: 350);
|
||||
|
||||
SetServerTick(world, 250);
|
||||
group.Update();
|
||||
var mods = em.GetBuffer<StatModifier>(p);
|
||||
Assert.AreEqual(1, mods.Length, "Only the first timed modifier expires at tick 250.");
|
||||
Assert.AreEqual(2u, mods[0].SourceId, "The longer-lived modifier (SourceId 2) survives.");
|
||||
|
||||
SetServerTick(world, 350);
|
||||
group.Update();
|
||||
Assert.AreEqual(0, em.GetBuffer<StatModifier>(p).Length, "The second expires at its own tick.");
|
||||
}
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void RemoveBySourceId_Clears_On_Demand()
|
||||
{
|
||||
var (world, group) = MakeWorld("TimedClearByType", serverTick: 200);
|
||||
using (world)
|
||||
{
|
||||
var em = world.EntityManager;
|
||||
var p = MakePlayer(em);
|
||||
var b = em.GetBuffer<StatModifier>(p);
|
||||
b.Add(new StatModifier { Target = (byte)StatTarget.Damage, Op = (byte)ModOp.Flat, Value = 5f, SourceId = 7u });
|
||||
b.Add(new StatModifier { Target = (byte)StatTarget.Damage, Op = (byte)ModOp.Flat, Value = 9f, SourceId = 8u });
|
||||
|
||||
int removed = TimedModifierUtil.RemoveBySourceId(em.GetBuffer<StatModifier>(p), 7u);
|
||||
Assert.AreEqual(1, removed, "Clear-by-SourceId removes exactly the matching row.");
|
||||
var mods = em.GetBuffer<StatModifier>(p);
|
||||
Assert.AreEqual(1, mods.Length);
|
||||
Assert.AreEqual(8u, mods[0].SourceId, "The non-matching modifier is untouched.");
|
||||
}
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void NonZero_UntilTick_Never_Collides_With_The_Zero_Sentinel()
|
||||
{
|
||||
var (world, group) = MakeWorld("TimedWrap", serverTick: 1);
|
||||
using (world)
|
||||
{
|
||||
var em = world.EntityManager;
|
||||
var p = MakePlayer(em);
|
||||
uint until = TickUtil.NonZero(0u); // a grant/death exactly at tick 0 must not read as 'inert'
|
||||
Assert.AreNotEqual(0u, until, "TickUtil.NonZero coerces 0 -> 1 so a scheduled expiry is never the inert sentinel.");
|
||||
AddTimedMod(em, p, (byte)StatTarget.Damage, 3f, sourceId: 9u, untilTick: until);
|
||||
|
||||
group.Update(); // serverTick 1 >= until(1) -> due
|
||||
Assert.AreEqual(0, em.GetBuffer<StatModifier>(p).Length, "A modifier scheduled at the wrap sentinel still expires.");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 3714a9107b437a0419aec060006cec76
|
||||
@@ -0,0 +1,139 @@
|
||||
using NUnit.Framework;
|
||||
using ProjectM.Server;
|
||||
using ProjectM.Simulation;
|
||||
using Unity.Core;
|
||||
using Unity.Entities;
|
||||
using Unity.Mathematics;
|
||||
using Unity.NetCode;
|
||||
using Unity.Transforms;
|
||||
|
||||
namespace ProjectM.Tests
|
||||
{
|
||||
/// <summary>
|
||||
/// Plain-Entities EditMode tests for the server-only <see cref="TurretFireSystem"/> (hitscan defense turret).
|
||||
/// A bare world is seeded with a <c>NetworkTime</c> singleton, a turret (PlacedStructure + Turret + RegionTag
|
||||
/// + LocalTransform) and Husks (Health + EnemyTag + RegionTag + LocalTransform + a DamageEvent buffer). The
|
||||
/// system snapshots living Husks, fires at the nearest in-range one in its OWN region on the
|
||||
/// <c>PlacedStructure.NextTick</c> cooldown, and appends a <c>DamageEvent{SourceNetworkId=-1}</c>. These tests
|
||||
/// pin range filtering, region gating, and the wrap-safe cooldown.
|
||||
/// </summary>
|
||||
public class TurretFireSystemTests
|
||||
{
|
||||
static (World world, SimulationSystemGroup group) MakeWorld(string name, uint serverTick)
|
||||
{
|
||||
var world = new World(name);
|
||||
var group = world.GetOrCreateSystemManaged<SimulationSystemGroup>();
|
||||
group.AddSystemToUpdateList(world.GetOrCreateSystem<TurretFireSystem>());
|
||||
group.SortSystems();
|
||||
world.SetTime(new TimeData(elapsedTime: 0f, deltaTime: 1f / 60f));
|
||||
SetServerTick(world, serverTick);
|
||||
return (world, group);
|
||||
}
|
||||
|
||||
static void SetServerTick(World world, uint tick)
|
||||
{
|
||||
var em = world.EntityManager;
|
||||
using var q = em.CreateEntityQuery(typeof(NetworkTime));
|
||||
Entity e = q.IsEmpty ? em.CreateEntity(typeof(NetworkTime)) : q.GetSingletonEntity();
|
||||
em.SetComponentData(e, new NetworkTime { ServerTick = new NetworkTick(tick) });
|
||||
}
|
||||
|
||||
static Entity MakeTurret(EntityManager em, float3 pos, byte region, float range, int cooldown, float damage)
|
||||
{
|
||||
var e = em.CreateEntity();
|
||||
em.AddComponentData(e, LocalTransform.FromPosition(pos));
|
||||
em.AddComponentData(e, new RegionTag { Region = region });
|
||||
em.AddComponentData(e, new PlacedStructure { Type = StructureType.Turret, NextTick = 0 });
|
||||
em.AddComponentData(e, new Turret { Range = range, CooldownTicks = cooldown, Damage = damage });
|
||||
return e;
|
||||
}
|
||||
|
||||
static Entity MakeHusk(EntityManager em, float3 pos, byte region, float health)
|
||||
{
|
||||
var e = em.CreateEntity();
|
||||
em.AddComponentData(e, LocalTransform.FromPosition(pos));
|
||||
em.AddComponentData(e, new RegionTag { Region = region });
|
||||
em.AddComponentData(e, new Health { Current = health, Max = health });
|
||||
em.AddComponent<EnemyTag>(e);
|
||||
em.AddBuffer<DamageEvent>(e);
|
||||
return e;
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Turret_Fires_At_InRange_Husk_In_Its_Region()
|
||||
{
|
||||
var (world, group) = MakeWorld("TurretFireWorld", serverTick: 200);
|
||||
using (world)
|
||||
{
|
||||
var em = world.EntityManager;
|
||||
var turret = MakeTurret(em, new float3(5, 1, 5), RegionId.Base, range: 20f, cooldown: 30, damage: 10f);
|
||||
var husk = MakeHusk(em, new float3(10, 1, 10), RegionId.Base, health: 50f);
|
||||
|
||||
group.Update();
|
||||
|
||||
var dmg = em.GetBuffer<DamageEvent>(husk);
|
||||
Assert.AreEqual(1, dmg.Length, "An in-range, same-region Husk takes exactly one turret hit.");
|
||||
Assert.AreEqual(10f, dmg[0].Amount, 1e-4f, "DamageEvent carries the turret's damage.");
|
||||
Assert.AreEqual(-1, dmg[0].SourceNetworkId, "Turret damage uses the -1 (world) source id.");
|
||||
Assert.AreNotEqual(0u, em.GetComponentData<PlacedStructure>(turret).NextTick,
|
||||
"The cooldown tick is stamped after firing.");
|
||||
}
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Turret_Ignores_Husk_Out_Of_Range()
|
||||
{
|
||||
var (world, group) = MakeWorld("TurretRangeWorld", serverTick: 200);
|
||||
using (world)
|
||||
{
|
||||
var em = world.EntityManager;
|
||||
MakeTurret(em, new float3(0, 1, 0), RegionId.Base, range: 5f, cooldown: 30, damage: 10f);
|
||||
var husk = MakeHusk(em, new float3(100, 1, 0), RegionId.Base, health: 50f);
|
||||
|
||||
group.Update();
|
||||
|
||||
Assert.AreEqual(0, em.GetBuffer<DamageEvent>(husk).Length, "A Husk beyond Range takes no hit.");
|
||||
}
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Turret_Ignores_Husk_In_A_Different_Region()
|
||||
{
|
||||
var (world, group) = MakeWorld("TurretRegionWorld", serverTick: 200);
|
||||
using (world)
|
||||
{
|
||||
var em = world.EntityManager;
|
||||
MakeTurret(em, new float3(0, 1, 0), RegionId.Base, range: 50f, cooldown: 30, damage: 10f);
|
||||
var husk = MakeHusk(em, new float3(2, 1, 2), RegionId.Expedition, health: 50f);
|
||||
|
||||
group.Update();
|
||||
|
||||
Assert.AreEqual(0, em.GetBuffer<DamageEvent>(husk).Length,
|
||||
"A Husk in a different region is never targeted (region gating).");
|
||||
}
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Turret_Respects_Cooldown_Then_Fires_Again()
|
||||
{
|
||||
var (world, group) = MakeWorld("TurretCooldownWorld", serverTick: 200);
|
||||
using (world)
|
||||
{
|
||||
var em = world.EntityManager;
|
||||
MakeTurret(em, new float3(0, 1, 0), RegionId.Base, range: 50f, cooldown: 30, damage: 10f);
|
||||
var husk = MakeHusk(em, new float3(3, 1, 0), RegionId.Base, health: 5000f);
|
||||
|
||||
group.Update(); // fires at tick 200, NextTick -> 230
|
||||
Assert.AreEqual(1, em.GetBuffer<DamageEvent>(husk).Length, "Fires on the first ready tick.");
|
||||
|
||||
SetServerTick(world, 210);
|
||||
group.Update(); // 210 < 230 -> still cooling down
|
||||
Assert.AreEqual(1, em.GetBuffer<DamageEvent>(husk).Length, "No second shot while cooling down.");
|
||||
|
||||
SetServerTick(world, 240);
|
||||
group.Update(); // 240 >= 230 -> ready again
|
||||
Assert.AreEqual(2, em.GetBuffer<DamageEvent>(husk).Length, "Fires again once the cooldown elapses.");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: a86a8f9715eff4c45817718443589cdb
|
||||
@@ -0,0 +1,142 @@
|
||||
using NUnit.Framework;
|
||||
using ProjectM.Server;
|
||||
using ProjectM.Simulation;
|
||||
using Unity.Core;
|
||||
using Unity.Entities;
|
||||
using Unity.NetCode;
|
||||
using Unity.Transforms;
|
||||
|
||||
namespace ProjectM.Tests
|
||||
{
|
||||
/// <summary>
|
||||
/// Plain-Entities EditMode tests for the server-only <see cref="WaveSystem"/> (Husk wave/threat director).
|
||||
/// A bare world is seeded with NetworkTime + CycleState singletons and a director entity carrying
|
||||
/// WaveDirector + WaveState + a WaveEnemyPrefab buffer (whose prefab is a real <c>Prefab</c>-tagged entity so
|
||||
/// it is excluded from the alive-Husk query and Instantiate yields plain Husk instances). Pins: a due Lull
|
||||
/// starts the next (escalating) wave; Spawning emits one Husk per interval; the director is gated off outside
|
||||
/// Defend; a fully-spawned, cleared wave returns to Lull.
|
||||
/// </summary>
|
||||
public class WaveSystemTests
|
||||
{
|
||||
static (World world, SimulationSystemGroup group) MakeWorld(string name, uint serverTick, byte cyclePhase)
|
||||
{
|
||||
var world = new World(name);
|
||||
var group = world.GetOrCreateSystemManaged<SimulationSystemGroup>();
|
||||
group.AddSystemToUpdateList(world.GetOrCreateSystem<WaveSystem>());
|
||||
group.SortSystems();
|
||||
world.SetTime(new TimeData(elapsedTime: 0f, deltaTime: 1f / 60f));
|
||||
var em = world.EntityManager;
|
||||
var nt = em.CreateEntity(typeof(NetworkTime));
|
||||
em.SetComponentData(nt, new NetworkTime { ServerTick = new NetworkTick(serverTick) });
|
||||
var cyc = em.CreateEntity(typeof(CycleState));
|
||||
em.SetComponentData(cyc, new CycleState { Phase = cyclePhase });
|
||||
return (world, group);
|
||||
}
|
||||
|
||||
static Entity MakeHuskPrefab(EntityManager em)
|
||||
{
|
||||
var e = em.CreateEntity(typeof(LocalTransform), typeof(EnemyTag));
|
||||
em.AddComponent<Prefab>(e); // real prefab: excluded from EnemyTag queries; Instantiate strips the tag
|
||||
return e;
|
||||
}
|
||||
|
||||
static Entity MakeDirector(EntityManager em, Entity huskPrefab, byte phase, int waveNumber,
|
||||
uint nextActionTick, int remainingToSpawn, int spawnCounter)
|
||||
{
|
||||
var e = em.CreateEntity(typeof(WaveDirector), typeof(WaveState));
|
||||
em.SetComponentData(e, new WaveDirector
|
||||
{
|
||||
RingRadius = 10f, RingSlots = 12, BaseCount = 3, CountPerWave = 2,
|
||||
SpawnIntervalTicks = 10, LullTicks = 5,
|
||||
});
|
||||
em.SetComponentData(e, new WaveState
|
||||
{
|
||||
Phase = phase, WaveNumber = waveNumber, NextActionTick = nextActionTick,
|
||||
RemainingToSpawn = remainingToSpawn, SpawnCounter = spawnCounter,
|
||||
});
|
||||
var buf = em.AddBuffer<WaveEnemyPrefab>(e);
|
||||
buf.Add(new WaveEnemyPrefab { Prefab = huskPrefab });
|
||||
return e;
|
||||
}
|
||||
|
||||
static int HuskCount(EntityManager em)
|
||||
{
|
||||
using var q = em.CreateEntityQuery(typeof(EnemyTag));
|
||||
return q.CalculateEntityCount();
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Due_Lull_Starts_Wave_With_Escalating_Count()
|
||||
{
|
||||
var (world, group) = MakeWorld("WaveLullStart", serverTick: 100, cyclePhase: CyclePhase.Defend);
|
||||
using (world)
|
||||
{
|
||||
var em = world.EntityManager;
|
||||
var prefab = MakeHuskPrefab(em);
|
||||
var dir = MakeDirector(em, prefab, WavePhase.Lull, waveNumber: 0, nextActionTick: 100, remainingToSpawn: 0, spawnCounter: 0);
|
||||
|
||||
group.Update();
|
||||
|
||||
var w = em.GetComponentData<WaveState>(dir);
|
||||
Assert.AreEqual(WavePhase.Spawning, w.Phase, "A due lull starts spawning.");
|
||||
Assert.AreEqual(1, w.WaveNumber, "Wave number advances.");
|
||||
Assert.AreEqual(3, w.RemainingToSpawn, "Wave 1 count = BaseCount + (1-1)*CountPerWave = 3.");
|
||||
Assert.AreEqual(0, HuskCount(em), "No Husk is spawned on the lull->spawning transition tick itself.");
|
||||
}
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Spawning_Emits_One_Husk_And_Decrements_Remaining()
|
||||
{
|
||||
var (world, group) = MakeWorld("WaveSpawnOne", serverTick: 100, cyclePhase: CyclePhase.Defend);
|
||||
using (world)
|
||||
{
|
||||
var em = world.EntityManager;
|
||||
var prefab = MakeHuskPrefab(em);
|
||||
var dir = MakeDirector(em, prefab, WavePhase.Spawning, waveNumber: 1, nextActionTick: 100, remainingToSpawn: 3, spawnCounter: 0);
|
||||
|
||||
group.Update();
|
||||
|
||||
Assert.AreEqual(1, HuskCount(em), "One Husk spawns this interval.");
|
||||
var w = em.GetComponentData<WaveState>(dir);
|
||||
Assert.AreEqual(2, w.RemainingToSpawn, "RemainingToSpawn decrements.");
|
||||
Assert.AreEqual(1, w.SpawnCounter, "SpawnCounter advances (drives ring slot + round-robin).");
|
||||
}
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Director_Is_Gated_Off_Outside_Defend()
|
||||
{
|
||||
var (world, group) = MakeWorld("WaveGated", serverTick: 100, cyclePhase: CyclePhase.Expedition);
|
||||
using (world)
|
||||
{
|
||||
var em = world.EntityManager;
|
||||
var prefab = MakeHuskPrefab(em);
|
||||
var dir = MakeDirector(em, prefab, WavePhase.Lull, waveNumber: 0, nextActionTick: 100, remainingToSpawn: 0, spawnCounter: 0);
|
||||
|
||||
group.Update();
|
||||
|
||||
var w = em.GetComponentData<WaveState>(dir);
|
||||
Assert.AreEqual(WavePhase.Lull, w.Phase, "Outside Defend the director does not run.");
|
||||
Assert.AreEqual(0, w.WaveNumber, "Wave number stays put outside Defend.");
|
||||
}
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Fully_Spawned_Cleared_Wave_Returns_To_Lull()
|
||||
{
|
||||
var (world, group) = MakeWorld("WaveCleared", serverTick: 100, cyclePhase: CyclePhase.Defend);
|
||||
using (world)
|
||||
{
|
||||
var em = world.EntityManager;
|
||||
var prefab = MakeHuskPrefab(em);
|
||||
var dir = MakeDirector(em, prefab, WavePhase.Spawning, waveNumber: 1, nextActionTick: 100, remainingToSpawn: 0, spawnCounter: 3);
|
||||
|
||||
group.Update();
|
||||
|
||||
Assert.AreEqual(WavePhase.Lull, em.GetComponentData<WaveState>(dir).Phase,
|
||||
"A fully-spawned wave with no live Husks returns to Lull.");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: b4f0f87ab30c466459b587c756994096
|
||||
Reference in New Issue
Block a user