Import art/VFX asset packs + game-feel systems; normalize texture extensions to lowercase for LFS

Add BefourStudios SciFi environment packs, Gabriel Aguiar VFX, and the
ShaderCrew Toon Shader embedded packages, plus combat/enemy/wave/death
gameplay systems and supporting vault docs/screenshots.

Rename 11 vendor textures from uppercase .PNG/.HDR to lowercase so the
case-sensitive Git LFS filters (*.png/*.hdr) match on case-sensitive
filesystems (Linux CI, case-sensitive macOS), not just locally where
core.ignorecase=true masks the gap. Each .meta moved with its asset so
GUID references are preserved. All ~1000 binaries tracked via LFS.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-02 22:50:43 -07:00
parent dd0064c377
commit e362aaeb43
4830 changed files with 1293057 additions and 38 deletions
+4 -4
View File
@@ -114,12 +114,12 @@ Material:
- _GlossMapScale: 0
- _Glossiness: 0
- _GlossyReflections: 0
- _Metallic: 0
- _Metallic: 0.7
- _OcclusionStrength: 1
- _Parallax: 0.005
- _QueueOffset: 0
- _ReceiveShadows: 1
- _Smoothness: 0.5
- _Smoothness: 0.35
- _SmoothnessTextureChannel: 0
- _SpecularHighlights: 1
- _SrcBlend: 1
@@ -129,8 +129,8 @@ Material:
- _XRMotionVectorsPass: 1
- _ZWrite: 1
m_Colors:
- _BaseColor: {r: 1, g: 0.15, b: 0.15, a: 1}
- _Color: {r: 1, g: 0.14999998, b: 0.14999998, a: 1}
- _BaseColor: {r: 0.09, g: 0.1, b: 0.12, a: 1}
- _Color: {r: 0.089999996, g: 0.09999997, b: 0.11999995, a: 1}
- _EmissionColor: {r: 0, g: 0, b: 0, a: 1}
- _SpecColor: {r: 0.19999996, g: 0.19999996, b: 0.19999996, a: 1}
m_BuildTextureStacks: []
+2 -2
View File
@@ -129,8 +129,8 @@ Material:
- _XRMotionVectorsPass: 1
- _ZWrite: 1
m_Colors:
- _BaseColor: {r: 0.16, g: 0.16, b: 0.2, a: 1}
- _Color: {r: 0.15999997, g: 0.15999997, b: 0.19999996, a: 1}
- _BaseColor: {r: 0.075, g: 0.08, b: 0.095, a: 1}
- _Color: {r: 0.07499998, g: 0.079999976, b: 0.09499996, a: 1}
- _EmissionColor: {r: 0, g: 0, b: 0, a: 1}
- _SpecColor: {r: 0.19999996, g: 0.19999996, b: 0.19999996, a: 1}
m_BuildTextureStacks: []
+138
View File
@@ -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_Husk
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
- _OcclusionStrength: 1
- _Parallax: 0.005
- _QueueOffset: 0
- _ReceiveShadows: 1
- _Smoothness: 0.5
- _SmoothnessTextureChannel: 0
- _SpecularHighlights: 1
- _SrcBlend: 1
- _SrcBlendAlpha: 1
- _Surface: 0
- _WorkflowMode: 1
- _XRMotionVectorsPass: 1
- _ZWrite: 1
m_Colors:
- _BaseColor: {r: 0.45, g: 0.11, b: 0.08, a: 1}
- _Color: {r: 0.45, g: 0.10999996, b: 0.079999976, a: 1}
- _EmissionColor: {r: 3.4, g: 0.85, b: 0.22, a: 1}
- _SpecColor: {r: 0.19999996, g: 0.19999996, b: 0.19999996, a: 1}
m_BuildTextureStacks: []
m_AllowLocking: 1
--- !u!114 &6196935124946963334
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: fc4f991205d0aa347b65d89045f80c70
NativeFormatImporter:
externalObjects: {}
mainObjectFileID: 2100000
userData:
assetBundleName:
assetBundleVariant:
+138
View File
@@ -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_HuskBrute
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
- _OcclusionStrength: 1
- _Parallax: 0.005
- _QueueOffset: 0
- _ReceiveShadows: 1
- _Smoothness: 0.5
- _SmoothnessTextureChannel: 0
- _SpecularHighlights: 1
- _SrcBlend: 1
- _SrcBlendAlpha: 1
- _Surface: 0
- _WorkflowMode: 1
- _XRMotionVectorsPass: 1
- _ZWrite: 1
m_Colors:
- _BaseColor: {r: 0.4, g: 0.04, b: 0.04, a: 1}
- _Color: {r: 0.39999998, g: 0.04, b: 0.04, a: 1}
- _EmissionColor: {r: 3.2, g: 0.3, b: 0.15, a: 1}
- _SpecColor: {r: 0.19999996, g: 0.19999996, b: 0.19999996, a: 1}
m_BuildTextureStacks: []
m_AllowLocking: 1
--- !u!114 &3852622653826484855
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: 9335a56cb714259428014ed979262af7
NativeFormatImporter:
externalObjects: {}
mainObjectFileID: 2100000
userData:
assetBundleName:
assetBundleVariant:
+138
View File
@@ -0,0 +1,138 @@
%YAML 1.1
%TAG !u! tag:unity3d.com,2011:
--- !u!114 &-6649787456472139052
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_HuskSwarmer
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
- _OcclusionStrength: 1
- _Parallax: 0.005
- _QueueOffset: 0
- _ReceiveShadows: 1
- _Smoothness: 0.5
- _SmoothnessTextureChannel: 0
- _SpecularHighlights: 1
- _SrcBlend: 1
- _SrcBlendAlpha: 1
- _Surface: 0
- _WorkflowMode: 1
- _XRMotionVectorsPass: 1
- _ZWrite: 1
m_Colors:
- _BaseColor: {r: 0.5, g: 0.3, b: 0.05, a: 1}
- _Color: {r: 0.5, g: 0.29999998, b: 0.049999982, a: 1}
- _EmissionColor: {r: 3.6, g: 2, b: 0.2, 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: 41d8ba155b460ab4eacca90ee2ad64cd
NativeFormatImporter:
externalObjects: {}
mainObjectFileID: 2100000
userData:
assetBundleName:
assetBundleVariant:
+6 -5
View File
@@ -11,9 +11,10 @@ Material:
m_Shader: {fileID: 4800000, guid: 933532a4fcc9baf4fa0491de14d08ed7, type: 3}
m_Parent: {fileID: 0}
m_ModifiedSerializedProperties: 0
m_ValidKeywords: []
m_ValidKeywords:
- _EMISSION
m_InvalidKeywords: []
m_LightmapFlags: 4
m_LightmapFlags: 1
m_EnableInstancingVariants: 0
m_DoubleSidedGI: 0
m_CustomRenderQueue: -1
@@ -116,9 +117,9 @@ Material:
- _XRMotionVectorsPass: 1
- _ZWrite: 1
m_Colors:
- _BaseColor: {r: 0.1, g: 0.55, b: 1, a: 1}
- _Color: {r: 0.09999997, g: 0.54999995, b: 1, a: 1}
- _EmissionColor: {r: 0, g: 0, b: 0, a: 1}
- _BaseColor: {r: 0.1, g: 0.5, b: 0.58, a: 1}
- _Color: {r: 0.09999997, g: 0.5, b: 0.58, a: 1}
- _EmissionColor: {r: 0, g: 1.7, b: 2.2, a: 1}
- _SpecColor: {r: 0.19999996, g: 0.19999996, b: 0.19999996, a: 1}
m_BuildTextureStacks: []
m_AllowLocking: 1
+6 -5
View File
@@ -11,9 +11,10 @@ Material:
m_Shader: {fileID: 4800000, guid: 933532a4fcc9baf4fa0491de14d08ed7, type: 3}
m_Parent: {fileID: 0}
m_ModifiedSerializedProperties: 0
m_ValidKeywords: []
m_ValidKeywords:
- _EMISSION
m_InvalidKeywords: []
m_LightmapFlags: 4
m_LightmapFlags: 1
m_EnableInstancingVariants: 0
m_DoubleSidedGI: 0
m_CustomRenderQueue: -1
@@ -116,9 +117,9 @@ Material:
- _XRMotionVectorsPass: 1
- _ZWrite: 1
m_Colors:
- _BaseColor: {r: 1, g: 0.85, b: 0.1, a: 1}
- _Color: {r: 1, g: 0.85, b: 0.09999997, a: 1}
- _EmissionColor: {r: 0, g: 0, b: 0, a: 1}
- _BaseColor: {r: 0.85, g: 1, b: 1, a: 1}
- _Color: {r: 0.85, g: 1, b: 1, a: 1}
- _EmissionColor: {r: 1.4, g: 3.4, b: 3.8, a: 1}
- _SpecColor: {r: 0.19999996, g: 0.19999996, b: 0.19999996, a: 1}
m_BuildTextureStacks: []
m_AllowLocking: 1
+151
View File
@@ -0,0 +1,151 @@
%YAML 1.1
%TAG !u! tag:unity3d.com,2011:
--- !u!1 &3885353946372160549
GameObject:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
serializedVersion: 6
m_Component:
- component: {fileID: 3572766465862231365}
- component: {fileID: 3909651526955663392}
- component: {fileID: 3320445911748035220}
- component: {fileID: 9053853372340598254}
- component: {fileID: 6834786618115927220}
- component: {fileID: 2544095781123180609}
m_Layer: 0
m_Name: Enemy
m_TagString: Untagged
m_Icon: {fileID: 0}
m_NavMeshLayer: 0
m_StaticEditorFlags: 0
m_IsActive: 1
--- !u!4 &3572766465862231365
Transform:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 3885353946372160549}
serializedVersion: 2
m_LocalRotation: {x: 0, y: 0, z: 0, w: 1}
m_LocalPosition: {x: 0, y: 0, z: 0}
m_LocalScale: {x: 0.8, y: 0.8, z: 0.8}
m_ConstrainProportionsScale: 0
m_Children: []
m_Father: {fileID: 0}
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
--- !u!33 &3909651526955663392
MeshFilter:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 3885353946372160549}
m_Mesh: {fileID: 10207, guid: 0000000000000000e000000000000000, type: 0}
--- !u!23 &3320445911748035220
MeshRenderer:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 3885353946372160549}
m_Enabled: 1
m_CastShadows: 1
m_ReceiveShadows: 1
m_DynamicOccludee: 1
m_StaticShadowCaster: 0
m_MotionVectors: 1
m_LightProbeUsage: 1
m_ReflectionProbeUsage: 1
m_RayTracingMode: 2
m_RayTraceProcedural: 0
m_RayTracingAccelStructBuildFlagsOverride: 0
m_RayTracingAccelStructBuildFlags: 1
m_SmallMeshCulling: 1
m_ForceMeshLod: -1
m_MeshLodSelectionBias: 0
m_RenderingLayerMask: 1
m_RendererPriority: 0
m_Materials:
- {fileID: 2100000, guid: fc4f991205d0aa347b65d89045f80c70, type: 2}
m_StaticBatchInfo:
firstSubMesh: 0
subMeshCount: 0
m_StaticBatchRoot: {fileID: 0}
m_ProbeAnchor: {fileID: 0}
m_LightProbeVolumeOverride: {fileID: 0}
m_ScaleInLightmap: 1
m_ReceiveGI: 1
m_PreserveUVs: 1
m_IgnoreNormalsForChartDetection: 0
m_ImportantGI: 0
m_StitchLightmapSeams: 1
m_SelectedEditorRenderState: 3
m_MinimumChartSize: 4
m_AutoUVMaxDistance: 0.5
m_AutoUVMaxAngle: 89
m_LightmapParameters: {fileID: 0}
m_GlobalIlluminationMeshLod: 0
m_SortingLayerID: 0
m_SortingLayer: 0
m_SortingOrder: 0
m_MaskInteraction: 0
m_AdditionalVertexStreams: {fileID: 0}
--- !u!114 &9053853372340598254
MonoBehaviour:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 3885353946372160549}
m_Enabled: 1
m_EditorHideFlags: 0
m_Script: {fileID: 11500000, guid: c16549610bfe4458aa9389201d072bb6, type: 3}
m_Name:
m_EditorClassIdentifier: Unity.Entities.Hybrid::Unity.Entities.Hybrid.Baking.LinkedEntityGroupAuthoring
--- !u!114 &6834786618115927220
MonoBehaviour:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 3885353946372160549}
m_Enabled: 1
m_EditorHideFlags: 0
m_Script: {fileID: 11500000, guid: 7c79d771cedb4794bf100ce60df5f764, type: 3}
m_Name:
m_EditorClassIdentifier: Unity.NetCode.Authoring.Hybrid::Unity.NetCode.GhostAuthoringComponent
HasOwner: 0
SupportAutoCommandTarget: 1
TrackInterpolationDelay: 0
GhostGroup: 0
UsePreSerialization: 0
UseSingleBaseline: 0
RollbackPredictedSpawnedGhostState: 0
RollbackPredictionOnStructuralChanges: 1
DefaultGhostMode: 0
SupportedGhostModes: 3
OptimizationMode: 0
Importance: 1
MaxSendRate: 0
prefabId:
--- !u!114 &2544095781123180609
MonoBehaviour:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 3885353946372160549}
m_Enabled: 1
m_EditorHideFlags: 0
m_Script: {fileID: 11500000, guid: 6b014797e9092694b9568c5b66d34a55, type: 3}
m_Name:
m_EditorClassIdentifier: ProjectM.Authoring::ProjectM.Authoring.EnemyAuthoring
MaxHealth: 30
HitRadius: 0.7
MoveSpeed: 3
AttackRange: 1.6
AttackDamage: 5
AttackCooldownTicks: 48
@@ -0,0 +1,7 @@
fileFormatVersion: 2
guid: 84c92c40b6b720441ac3a78870c0bba4
PrefabImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
+151
View File
@@ -0,0 +1,151 @@
%YAML 1.1
%TAG !u! tag:unity3d.com,2011:
--- !u!1 &3885353946372160549
GameObject:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
serializedVersion: 6
m_Component:
- component: {fileID: 3572766465862231365}
- component: {fileID: 3909651526955663392}
- component: {fileID: 3320445911748035220}
- component: {fileID: 9053853372340598254}
- component: {fileID: 6834786618115927220}
- component: {fileID: 2544095781123180609}
m_Layer: 0
m_Name: EnemyBrute
m_TagString: Untagged
m_Icon: {fileID: 0}
m_NavMeshLayer: 0
m_StaticEditorFlags: 0
m_IsActive: 1
--- !u!4 &3572766465862231365
Transform:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 3885353946372160549}
serializedVersion: 2
m_LocalRotation: {x: 0, y: 0, z: 0, w: 1}
m_LocalPosition: {x: 0, y: 0, z: 0}
m_LocalScale: {x: 1.3, y: 1.3, z: 1.3}
m_ConstrainProportionsScale: 0
m_Children: []
m_Father: {fileID: 0}
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
--- !u!33 &3909651526955663392
MeshFilter:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 3885353946372160549}
m_Mesh: {fileID: 10207, guid: 0000000000000000e000000000000000, type: 0}
--- !u!23 &3320445911748035220
MeshRenderer:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 3885353946372160549}
m_Enabled: 1
m_CastShadows: 1
m_ReceiveShadows: 1
m_DynamicOccludee: 1
m_StaticShadowCaster: 0
m_MotionVectors: 1
m_LightProbeUsage: 1
m_ReflectionProbeUsage: 1
m_RayTracingMode: 2
m_RayTraceProcedural: 0
m_RayTracingAccelStructBuildFlagsOverride: 0
m_RayTracingAccelStructBuildFlags: 1
m_SmallMeshCulling: 1
m_ForceMeshLod: -1
m_MeshLodSelectionBias: 0
m_RenderingLayerMask: 1
m_RendererPriority: 0
m_Materials:
- {fileID: 2100000, guid: 9335a56cb714259428014ed979262af7, type: 2}
m_StaticBatchInfo:
firstSubMesh: 0
subMeshCount: 0
m_StaticBatchRoot: {fileID: 0}
m_ProbeAnchor: {fileID: 0}
m_LightProbeVolumeOverride: {fileID: 0}
m_ScaleInLightmap: 1
m_ReceiveGI: 1
m_PreserveUVs: 1
m_IgnoreNormalsForChartDetection: 0
m_ImportantGI: 0
m_StitchLightmapSeams: 1
m_SelectedEditorRenderState: 3
m_MinimumChartSize: 4
m_AutoUVMaxDistance: 0.5
m_AutoUVMaxAngle: 89
m_LightmapParameters: {fileID: 0}
m_GlobalIlluminationMeshLod: 0
m_SortingLayerID: 0
m_SortingLayer: 0
m_SortingOrder: 0
m_MaskInteraction: 0
m_AdditionalVertexStreams: {fileID: 0}
--- !u!114 &9053853372340598254
MonoBehaviour:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 3885353946372160549}
m_Enabled: 1
m_EditorHideFlags: 0
m_Script: {fileID: 11500000, guid: c16549610bfe4458aa9389201d072bb6, type: 3}
m_Name:
m_EditorClassIdentifier: Unity.Entities.Hybrid::Unity.Entities.Hybrid.Baking.LinkedEntityGroupAuthoring
--- !u!114 &6834786618115927220
MonoBehaviour:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 3885353946372160549}
m_Enabled: 1
m_EditorHideFlags: 0
m_Script: {fileID: 11500000, guid: 7c79d771cedb4794bf100ce60df5f764, type: 3}
m_Name:
m_EditorClassIdentifier: Unity.NetCode.Authoring.Hybrid::Unity.NetCode.GhostAuthoringComponent
HasOwner: 0
SupportAutoCommandTarget: 1
TrackInterpolationDelay: 0
GhostGroup: 0
UsePreSerialization: 0
UseSingleBaseline: 0
RollbackPredictedSpawnedGhostState: 0
RollbackPredictionOnStructuralChanges: 1
DefaultGhostMode: 0
SupportedGhostModes: 3
OptimizationMode: 0
Importance: 1
MaxSendRate: 0
prefabId:
--- !u!114 &2544095781123180609
MonoBehaviour:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 3885353946372160549}
m_Enabled: 1
m_EditorHideFlags: 0
m_Script: {fileID: 11500000, guid: 6b014797e9092694b9568c5b66d34a55, type: 3}
m_Name:
m_EditorClassIdentifier: ProjectM.Authoring::ProjectM.Authoring.EnemyAuthoring
MaxHealth: 80
HitRadius: 1
MoveSpeed: 2
AttackRange: 2
AttackDamage: 12
AttackCooldownTicks: 70
@@ -0,0 +1,7 @@
fileFormatVersion: 2
guid: 1bc4c3736da534a42a7040fcbe9c92d5
PrefabImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
+151
View File
@@ -0,0 +1,151 @@
%YAML 1.1
%TAG !u! tag:unity3d.com,2011:
--- !u!1 &3885353946372160549
GameObject:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
serializedVersion: 6
m_Component:
- component: {fileID: 3572766465862231365}
- component: {fileID: 3909651526955663392}
- component: {fileID: 3320445911748035220}
- component: {fileID: 9053853372340598254}
- component: {fileID: 6834786618115927220}
- component: {fileID: 2544095781123180609}
m_Layer: 0
m_Name: EnemySwarmer
m_TagString: Untagged
m_Icon: {fileID: 0}
m_NavMeshLayer: 0
m_StaticEditorFlags: 0
m_IsActive: 1
--- !u!4 &3572766465862231365
Transform:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 3885353946372160549}
serializedVersion: 2
m_LocalRotation: {x: 0, y: 0, z: 0, w: 1}
m_LocalPosition: {x: 0, y: 0, z: 0}
m_LocalScale: {x: 0.6, y: 0.6, z: 0.6}
m_ConstrainProportionsScale: 0
m_Children: []
m_Father: {fileID: 0}
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
--- !u!33 &3909651526955663392
MeshFilter:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 3885353946372160549}
m_Mesh: {fileID: 10207, guid: 0000000000000000e000000000000000, type: 0}
--- !u!23 &3320445911748035220
MeshRenderer:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 3885353946372160549}
m_Enabled: 1
m_CastShadows: 1
m_ReceiveShadows: 1
m_DynamicOccludee: 1
m_StaticShadowCaster: 0
m_MotionVectors: 1
m_LightProbeUsage: 1
m_ReflectionProbeUsage: 1
m_RayTracingMode: 2
m_RayTraceProcedural: 0
m_RayTracingAccelStructBuildFlagsOverride: 0
m_RayTracingAccelStructBuildFlags: 1
m_SmallMeshCulling: 1
m_ForceMeshLod: -1
m_MeshLodSelectionBias: 0
m_RenderingLayerMask: 1
m_RendererPriority: 0
m_Materials:
- {fileID: 2100000, guid: 41d8ba155b460ab4eacca90ee2ad64cd, type: 2}
m_StaticBatchInfo:
firstSubMesh: 0
subMeshCount: 0
m_StaticBatchRoot: {fileID: 0}
m_ProbeAnchor: {fileID: 0}
m_LightProbeVolumeOverride: {fileID: 0}
m_ScaleInLightmap: 1
m_ReceiveGI: 1
m_PreserveUVs: 1
m_IgnoreNormalsForChartDetection: 0
m_ImportantGI: 0
m_StitchLightmapSeams: 1
m_SelectedEditorRenderState: 3
m_MinimumChartSize: 4
m_AutoUVMaxDistance: 0.5
m_AutoUVMaxAngle: 89
m_LightmapParameters: {fileID: 0}
m_GlobalIlluminationMeshLod: 0
m_SortingLayerID: 0
m_SortingLayer: 0
m_SortingOrder: 0
m_MaskInteraction: 0
m_AdditionalVertexStreams: {fileID: 0}
--- !u!114 &9053853372340598254
MonoBehaviour:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 3885353946372160549}
m_Enabled: 1
m_EditorHideFlags: 0
m_Script: {fileID: 11500000, guid: c16549610bfe4458aa9389201d072bb6, type: 3}
m_Name:
m_EditorClassIdentifier: Unity.Entities.Hybrid::Unity.Entities.Hybrid.Baking.LinkedEntityGroupAuthoring
--- !u!114 &6834786618115927220
MonoBehaviour:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 3885353946372160549}
m_Enabled: 1
m_EditorHideFlags: 0
m_Script: {fileID: 11500000, guid: 7c79d771cedb4794bf100ce60df5f764, type: 3}
m_Name:
m_EditorClassIdentifier: Unity.NetCode.Authoring.Hybrid::Unity.NetCode.GhostAuthoringComponent
HasOwner: 0
SupportAutoCommandTarget: 1
TrackInterpolationDelay: 0
GhostGroup: 0
UsePreSerialization: 0
UseSingleBaseline: 0
RollbackPredictedSpawnedGhostState: 0
RollbackPredictionOnStructuralChanges: 1
DefaultGhostMode: 0
SupportedGhostModes: 3
OptimizationMode: 0
Importance: 1
MaxSendRate: 0
prefabId:
--- !u!114 &2544095781123180609
MonoBehaviour:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 3885353946372160549}
m_Enabled: 1
m_EditorHideFlags: 0
m_Script: {fileID: 11500000, guid: 6b014797e9092694b9568c5b66d34a55, type: 3}
m_Name:
m_EditorClassIdentifier: ProjectM.Authoring::ProjectM.Authoring.EnemyAuthoring
MaxHealth: 15
HitRadius: 0.5
MoveSpeed: 5
AttackRange: 1.4
AttackDamage: 3
AttackCooldownTicks: 40
@@ -0,0 +1,7 @@
fileFormatVersion: 2
guid: 9855c5a5d578bb74ba2fb0d41a73a3b9
PrefabImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
+8
View File
@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: d71458c96f70308418f19c85c7a21f30
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,15 @@
%YAML 1.1
%TAG !u! tag:unity3d.com,2011:
--- !u!114 &11400000
MonoBehaviour:
m_ObjectHideFlags: 0
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: d7fd9488000d3734a9e00ee676215985, type: 3}
m_Name: PostFX_DarkSciFi
m_EditorClassIdentifier: Unity.RenderPipelines.Core.Runtime::UnityEngine.Rendering.VolumeProfile
components: []
@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 669d41422347ed94785dd2515084828f
NativeFormatImporter:
externalObjects: {}
mainObjectFileID: 11400000
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,56 @@
using ProjectM.Simulation;
using Unity.Entities;
using UnityEngine;
namespace ProjectM.Authoring
{
/// <summary>
/// Authoring for the Husk enemy prefab. Bakes the gameplay components onto a prefab whose ghost setup
/// (GhostAuthoringComponent: interpolated, ownerless) is inherited by DUPLICATING an existing interpolated
/// ghost (UpgradePickup.prefab) — so the Husk replicates to all clients with no hand-written <c>[GhostField]</c>
/// (the stock LocalTransform variant carries its server-driven position). Damageable exactly like the training
/// dummy (Health/HitRadius/DamageEvent) plus the Husk AI tunables. <c>GetEntity(Dynamic)</c> gives a
/// runtime-mutable LocalTransform for server-authoritative movement and hit tests.
/// </summary>
public class EnemyAuthoring : MonoBehaviour
{
[Min(0f), Tooltip("Starting and maximum health for the Husk.")]
public float MaxHealth = 30f;
[Min(0f), Tooltip("World-unit radius used by the projectile hit test.")]
public float HitRadius = 0.7f;
[Min(0f), Tooltip("Planar seek speed toward the nearest player, units/second.")]
public float MoveSpeed = 3.5f;
[Min(0f), Tooltip("Centre-to-centre distance at which the Husk can strike a player.")]
public float AttackRange = 1.6f;
[Min(0f), Tooltip("Damage dealt per strike.")]
public float AttackDamage = 8f;
[Min(1), Tooltip("Simulation ticks between strikes (~60 ticks/sec).")]
public int AttackCooldownTicks = 36;
private class EnemyBaker : Baker<EnemyAuthoring>
{
public override void Bake(EnemyAuthoring authoring)
{
var entity = GetEntity(authoring, TransformUsageFlags.Dynamic);
AddComponent<EnemyTag>(entity);
AddComponent(entity, new Health { Current = authoring.MaxHealth, Max = authoring.MaxHealth });
AddComponent(entity, new HitRadius { Value = authoring.HitRadius });
AddBuffer<DamageEvent>(entity);
AddComponent(entity, new EnemyStats
{
MoveSpeed = authoring.MoveSpeed,
AttackRange = authoring.AttackRange,
AttackDamage = authoring.AttackDamage,
AttackCooldownTicks = authoring.AttackCooldownTicks,
});
AddComponent(entity, new EnemyAttackCooldown { NextAttackTick = 0 });
}
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 6b014797e9092694b9568c5b66d34a55
@@ -0,0 +1,63 @@
using ProjectM.Simulation;
using Unity.Entities;
using UnityEngine;
namespace ProjectM.Authoring
{
/// <summary>
/// Authoring for the Husk wave/threat director (singleton). Place on one GameObject in the gameplay subscene;
/// the server-only <c>WaveSystem</c> reads it. Bakes a <see cref="WaveDirector"/> config + a
/// <see cref="WaveEnemyPrefab"/> pool (the assigned Husk variant prefabs, spawned round-robin) + the initial
/// <see cref="WaveState"/>. The entity carries no transform; only the referenced prefabs need a runtime transform.
/// </summary>
public class WaveDirectorAuthoring : MonoBehaviour
{
[Tooltip("Husk variant prefabs to spawn (round-robin). Each must carry EnemyAuthoring + an interpolated GhostAuthoringComponent.")]
public GameObject[] EnemyPrefabs;
[Min(0f)] public float RingRadius = 16f;
[Min(1)] public int RingSlots = 10;
[Min(1), Tooltip("Husks in wave 1.")] public int BaseCount = 4;
[Min(0), Tooltip("Additional Husks per subsequent wave.")] public int CountPerWave = 2;
[Min(1), Tooltip("Ticks between individual spawns within a wave (~60/sec).")] public int SpawnIntervalTicks = 24;
[Min(1), Tooltip("Ticks of calm between waves (~60/sec).")] public int LullTicks = 240;
private class WaveDirectorBaker : Baker<WaveDirectorAuthoring>
{
public override void Bake(WaveDirectorAuthoring authoring)
{
var entity = GetEntity(authoring, TransformUsageFlags.None);
AddComponent(entity, new WaveDirector
{
RingRadius = authoring.RingRadius,
RingSlots = authoring.RingSlots,
BaseCount = authoring.BaseCount,
CountPerWave = authoring.CountPerWave,
SpawnIntervalTicks = authoring.SpawnIntervalTicks,
LullTicks = authoring.LullTicks,
});
var buffer = AddBuffer<WaveEnemyPrefab>(entity);
if (authoring.EnemyPrefabs != null)
{
foreach (var prefab in authoring.EnemyPrefabs)
{
if (prefab != null)
buffer.Add(new WaveEnemyPrefab { Prefab = GetEntity(prefab, TransformUsageFlags.Dynamic) });
}
}
AddComponent(entity, new WaveState
{
WaveNumber = 0,
Phase = WavePhase.Lull,
NextActionTick = 0,
RemainingToSpawn = 0,
SpawnCounter = 0,
});
}
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: f0c287a066ae43b42b2bf1e1ccaa48bc
@@ -28,6 +28,14 @@ namespace ProjectM.Authoring
/// <summary>Projectile hit-test radius for the player as a damageable target, in world units.</summary>
[Min(0f)] public float HitRadius = 0.6f;
[Min(1)]
[Tooltip("Ticks the player stays down before respawning at base (~60 ticks/sec).")]
public int RespawnDelayTicks = 180;
[Min(0)]
[Tooltip("Ticks of post-respawn damage immunity (~60 ticks/sec).")]
public int RespawnInvulnTicks = 120;
private class PlayerBaker : Baker<PlayerAuthoring>
{
public override void Bake(PlayerAuthoring authoring)
@@ -66,6 +74,13 @@ namespace ProjectM.Authoring
AddComponent(entity, new HitRadius { Value = authoring.HitRadius });
AddComponent<AbilityCooldown>(entity);
AddBuffer<DamageEvent>(entity);
// Death gate (enableable, derived from Health by PlayerDeathStateSystem) baked DISABLED = alive;
// plus the server-only respawn timer.
AddComponent<Dead>(entity);
SetComponentEnabled<Dead>(entity, false);
AddComponent(entity, new RespawnState { RespawnTick = 0, DelayTicks = authoring.RespawnDelayTicks, InvulnTicks = authoring.RespawnInvulnTicks });
AddComponent(entity, new RespawnInvuln { UntilTick = 0 });
}
}
}
@@ -0,0 +1,329 @@
using System.Collections.Generic;
using ProjectM.Simulation;
using Unity.Entities;
using Unity.Mathematics;
using Unity.NetCode;
using Unity.Transforms;
using UnityEngine;
namespace ProjectM.Client
{
/// <summary>
/// Client-only combat JUICE. A managed presentation system (SystemBase, main thread, NO Burst) in the
/// <see cref="PresentationSystemGroup"/> that REACTS to replicated state — it never runs simulation. Each frame
/// it edge-detects every damageable ghost's replicated <see cref="Health"/>: a decrease spawns a floating
/// damage number + a hit-spark burst + a hit SFX + camera shake; a Husk despawn (server-authoritative death)
/// spawns a death burst + death SFX; the local player crossing to 0 HP does the same. A local-player ability
/// fire (AbilityCooldown advancing) spawns a muzzle flash + zap. Everything derives from already-replicated
/// state, so it is correct without touching the prediction loop, and it lives only in the client world so the
/// server never instantiates GameObjects. SFX are generated procedurally; VFX use a small runtime pool — the
/// slice ships with NO binary audio/particle assets.
/// <para>
/// Per-entity last Health + position + isEnemy are cached in a managed dictionary (Entity is a stable client
/// key for a ghost's lifetime); stale keys are pruned each frame (a pruned Husk = a kill → death VFX at its
/// last position). Never destroys a ghost from the client — GhostDespawnSystem owns that off the snapshot
/// protocol; we only OBSERVE.
/// </para>
/// </summary>
[WorldSystemFilter(WorldSystemFilterFlags.ClientSimulation)]
[UpdateInGroup(typeof(PresentationSystemGroup))]
public partial class CombatFeedbackSystem : SystemBase
{
struct FxCache { public float Hp; public float3 Pos; public bool IsEnemy; }
readonly Dictionary<Entity, FxCache> _cache = new();
readonly HashSet<Entity> _seen = new();
readonly List<Entity> _stale = new();
readonly List<FloatingNumber> _numbers = new();
Transform _fxRoot;
ParticleSystem _hitFx;
ParticleSystem _deathFx;
ParticleSystem _muzzleFx;
AudioClip _hitClip;
AudioClip _deathClip;
AudioClip _fireClip;
Entity _localPlayer = Entity.Null;
uint _lastLocalFireTick;
bool _fireTickInit;
const int NumberPoolSize = 32;
protected override void OnCreate()
{
_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);
}
protected override void OnStartRunning()
{
if (_fxRoot != null) return;
_fxRoot = new GameObject("~CombatFeedbackFX").transform;
var mat = MakeParticleMaterial();
_hitFx = MakeBurst("HitSparks", mat, new Color(3f, 2.2f, 0.6f), 0.13f, 7f, 0.32f, 256);
_deathFx = MakeBurst("DeathBurst", mat, new Color(3.2f, 0.7f, 0.25f), 0.22f, 9f, 0.55f, 512);
_muzzleFx = MakeBurst("Muzzle", mat, new Color(0.6f, 2.4f, 3.2f), 0.12f, 5f, 0.20f, 128);
for (int i = 0; i < NumberPoolSize; i++)
_numbers.Add(CreateNumber());
}
protected override void OnDestroy()
{
if (_fxRoot != null)
Object.Destroy(_fxRoot.gameObject);
}
protected override void OnUpdate()
{
float dt = SystemAPI.Time.DeltaTime;
var cam = Camera.main;
// Make sure predicted/physics jobs writing these are done before this main-thread read.
EntityManager.CompleteDependencyBeforeRO<Health>();
EntityManager.CompleteDependencyBeforeRO<LocalTransform>();
// Resolve the local player (for hit colouring + fire feedback).
_localPlayer = Entity.Null;
float3 localPos = default;
foreach (var (xf, entity) in SystemAPI.Query<RefRO<LocalTransform>>()
.WithAll<GhostOwnerIsLocal, PlayerTag>().WithEntityAccess())
{
_localPlayer = entity;
localPos = xf.ValueRO.Position;
}
// Edge-detect Health on every damageable ghost (players + Husks).
_seen.Clear();
foreach (var (health, xf, entity) in
SystemAPI.Query<RefRO<Health>, RefRO<LocalTransform>>().WithEntityAccess())
{
_seen.Add(entity);
float cur = health.ValueRO.Current;
float3 p = xf.ValueRO.Position;
bool isEnemy = SystemAPI.HasComponent<EnemyTag>(entity);
bool isLocalPlayer = entity == _localPlayer;
if (_cache.TryGetValue(entity, out var prev))
{
if (cur < prev.Hp - 0.001f)
{
SpawnNumber(prev.Hp - cur, (Vector3)p, isLocalPlayer, cam);
EmitAt(_hitFx, (Vector3)p + Vector3.up * 0.8f, 10);
PlayClip(_hitClip, (Vector3)p, 0.7f);
PrototypeCameraRig.AddShake(isLocalPlayer ? 0.32f : 0.10f);
}
// Player death (players don't despawn — they respawn; Husk death is handled on prune).
if (!isEnemy && cur <= 0f && prev.Hp > 0f)
{
EmitAt(_deathFx, (Vector3)p + Vector3.up * 0.5f, 28);
PlayClip(_deathClip, (Vector3)p, 0.7f);
PrototypeCameraRig.AddShake(isLocalPlayer ? 0.5f : 0.25f);
}
}
_cache[entity] = new FxCache { Hp = cur, Pos = p, IsEnemy = isEnemy };
}
// Prune despawned ghosts. A Husk that vanished was killed -> death VFX at its last position.
if (_cache.Count != _seen.Count)
{
_stale.Clear();
foreach (var kv in _cache)
if (!_seen.Contains(kv.Key)) _stale.Add(kv.Key);
for (int i = 0; i < _stale.Count; i++)
{
var c = _cache[_stale[i]];
if (c.IsEnemy)
{
EmitAt(_deathFx, (Vector3)c.Pos + Vector3.up * 0.5f, 28);
PlayClip(_deathClip, (Vector3)c.Pos, 0.65f);
PrototypeCameraRig.AddShake(0.16f);
}
_cache.Remove(_stale[i]);
}
}
// Local-player fire feedback: AbilityCooldown.NextFireTick advances on each shot.
if (_localPlayer != Entity.Null && EntityManager.HasComponent<AbilityCooldown>(_localPlayer))
{
uint nextFire = EntityManager.GetComponentData<AbilityCooldown>(_localPlayer).NextFireTick;
if (_fireTickInit && nextFire != 0 && nextFire != _lastLocalFireTick)
{
EmitAt(_muzzleFx, (Vector3)localPos + Vector3.up * 0.9f, 8);
PlayClip(_fireClip, (Vector3)localPos, 0.5f);
}
_lastLocalFireTick = nextFire;
_fireTickInit = true;
}
AnimateNumbers(dt, cam);
}
// ---- Floating damage numbers (pooled, billboarded TextMesh) ----
class FloatingNumber
{
public TextMesh Tm;
public Transform Tr;
public float Age;
public float Life;
public Vector3 Vel;
public Color BaseColor;
public bool Active;
}
FloatingNumber CreateNumber()
{
var go = new GameObject("DamageNumber");
go.transform.SetParent(_fxRoot, false);
var tm = go.AddComponent<TextMesh>();
tm.characterSize = 0.12f;
tm.fontSize = 64;
tm.anchor = TextAnchor.MiddleCenter;
tm.alignment = TextAlignment.Center;
tm.color = Color.white;
go.SetActive(false);
return new FloatingNumber { Tm = tm, Tr = go.transform, Active = false };
}
void SpawnNumber(float amount, Vector3 worldPos, bool isLocalPlayer, Camera cam)
{
FloatingNumber fn = null;
for (int i = 0; i < _numbers.Count; i++)
if (!_numbers[i].Active) { fn = _numbers[i]; break; }
if (fn == null) return; // pool exhausted this frame: drop (cheap)
fn.Active = true;
fn.Age = 0f;
fn.Life = 0.7f;
fn.Tm.text = Mathf.Max(1, Mathf.RoundToInt(amount)).ToString();
fn.BaseColor = isLocalPlayer ? new Color(1f, 0.32f, 0.26f) : new Color(1f, 0.92f, 0.45f);
fn.Tm.color = fn.BaseColor;
fn.Tr.position = worldPos + Vector3.up * 1.4f + new Vector3(UnityEngine.Random.Range(-0.25f, 0.25f), 0f, 0f);
fn.Vel = new Vector3(0f, 2.2f, 0f);
fn.Tr.gameObject.SetActive(true);
if (cam != null) fn.Tr.rotation = cam.transform.rotation;
}
void AnimateNumbers(float dt, Camera cam)
{
for (int i = 0; i < _numbers.Count; i++)
{
var fn = _numbers[i];
if (!fn.Active) continue;
fn.Age += dt;
if (fn.Age >= fn.Life)
{
fn.Active = false;
fn.Tr.gameObject.SetActive(false);
continue;
}
fn.Vel.y -= 3.5f * dt; // ease the rise
fn.Tr.position += fn.Vel * dt;
if (cam != null) fn.Tr.rotation = cam.transform.rotation;
var c = fn.BaseColor;
c.a = 1f - (fn.Age / fn.Life);
fn.Tm.color = c;
}
}
// ---- Procedural SFX + pooled particle bursts ----
static AudioClip MakeClip(string name, float f0, float f1, float dur, float vol, bool noise)
{
const int rate = 44100;
int len = Mathf.Max(16, (int)(dur * rate));
var clip = AudioClip.Create(name, len, 1, rate, false);
var data = new float[len];
var rng = new System.Random(name.Length * 9973 + 7);
float phase = 0f;
for (int i = 0; i < len; i++)
{
float t = i / (float)len;
float env = Mathf.Exp(-5f * t);
float freq = Mathf.Lerp(f0, f1, t);
phase += 2f * Mathf.PI * freq / rate;
float s = noise ? (float)(rng.NextDouble() * 2.0 - 1.0) : Mathf.Sin(phase);
data[i] = s * env * vol;
}
clip.SetData(data, 0);
return clip;
}
static Material MakeParticleMaterial()
{
// Sprites/Default is an always-included, transparent, vertex-coloured shader — reliable for
// billboarded sparks; HDR start colours still push past the bloom threshold (Stage 5 look pass).
Shader sh = Shader.Find("Sprites/Default");
if (sh == null) sh = Shader.Find("Universal Render Pipeline/Particles/Unlit");
if (sh == null) sh = Shader.Find("Unlit/Color");
return new Material(sh) { name = "CombatFeedbackParticle" };
}
ParticleSystem MakeBurst(string name, Material mat, Color color, float size, float speed, float life, int max)
{
var go = new GameObject(name);
go.transform.SetParent(_fxRoot, false);
var ps = go.AddComponent<ParticleSystem>();
var main = ps.main;
main.loop = false;
main.playOnAwake = false;
// duration unused: manual Emit bursts with emission disabled
main.startLifetime = life;
main.startSpeed = speed;
main.startSize = size;
main.startColor = color;
main.maxParticles = max;
main.gravityModifier = 0f;
main.simulationSpace = ParticleSystemSimulationSpace.World;
var emission = ps.emission;
emission.enabled = false; // we Emit(count) manually
var shape = ps.shape;
shape.enabled = true;
shape.shapeType = ParticleSystemShapeType.Sphere;
shape.radius = 0.06f;
var colOverLife = ps.colorOverLifetime;
colOverLife.enabled = true;
var grad = new Gradient();
grad.SetKeys(
new[] { new GradientColorKey(Color.white, 0f), new GradientColorKey(Color.white, 1f) },
new[] { new GradientAlphaKey(1f, 0f), new GradientAlphaKey(0f, 1f) });
colOverLife.color = new ParticleSystem.MinMaxGradient(grad);
var sizeOverLife = ps.sizeOverLifetime;
sizeOverLife.enabled = true;
sizeOverLife.size = new ParticleSystem.MinMaxCurve(1f, AnimationCurve.Linear(0f, 1f, 1f, 0.2f));
var renderer = ps.GetComponent<ParticleSystemRenderer>();
renderer.material = mat;
renderer.renderMode = ParticleSystemRenderMode.Billboard;
return ps;
}
static void EmitAt(ParticleSystem ps, Vector3 pos, int count)
{
if (ps == null) return;
ps.transform.position = pos;
ps.Emit(count);
}
static void PlayClip(AudioClip clip, Vector3 pos, float vol)
{
if (clip == null) return;
AudioSource.PlayClipAtPoint(clip, pos, vol);
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 61d55913bf02dba4aadd8667876115f8
@@ -0,0 +1,215 @@
using ProjectM.Simulation;
using Unity.Entities;
using Unity.NetCode;
using UnityEngine;
using UnityEngine.UI;
namespace ProjectM.Client
{
/// <summary>
/// Client-only screen HUD. A managed presentation SystemBase (<see cref="PresentationSystemGroup"/>) that builds
/// a uGUI overlay canvas in code and drives it from the LOCAL player ghost each frame: a health bar
/// (Health / EffectiveCharacterStats.MaxHealth), an ability-cooldown bar (AbilityCooldown vs NetworkTime
/// ServerTick + EffectiveAbilityStats.CooldownTicks), a live Husk threat count, and a DOWNED/RESPAWNING overlay
/// (the derived <see cref="Dead"/> gate). Presentation only — no simulation, client world only. Bars are
/// RawImages over <c>Texture2D.whiteTexture</c> (always available; the fill width is the RectTransform's
/// anchorMax.x), so the HUD needs no sprite assets — only a resolved builtin font for the labels.
/// </summary>
[WorldSystemFilter(WorldSystemFilterFlags.ClientSimulation)]
[UpdateInGroup(typeof(PresentationSystemGroup))]
public partial class HudSystem : SystemBase
{
Canvas _canvas;
RectTransform _healthFill;
RectTransform _cooldownFill;
Text _healthText;
Text _threatText;
GameObject _respawnOverlay;
EntityQuery _huskQuery;
protected override void OnCreate()
{
_huskQuery = GetEntityQuery(ComponentType.ReadOnly<EnemyTag>());
}
protected override void OnStartRunning()
{
if (_canvas == null) BuildHud();
}
protected override void OnDestroy()
{
if (_canvas != null) Object.Destroy(_canvas.gameObject);
}
protected override void OnUpdate()
{
if (_canvas == null) return;
bool haveTick = SystemAPI.TryGetSingleton<NetworkTime>(out var nt);
bool found = false;
float hp = 0f, maxHp = 1f, cdFrac = 1f;
bool dead = false, shielded = false;
foreach (var (health, effChar, effAbility, cd, invuln, entity) in
SystemAPI.Query<RefRO<Health>, RefRO<EffectiveCharacterStats>, RefRO<EffectiveAbilityStats>,
RefRO<AbilityCooldown>, RefRO<RespawnInvuln>>()
.WithAll<GhostOwnerIsLocal, PlayerTag>().WithEntityAccess())
{
found = true;
hp = health.ValueRO.Current;
maxHp = effChar.ValueRO.MaxHealth > 0f ? effChar.ValueRO.MaxHealth : health.ValueRO.Max;
dead = SystemAPI.IsComponentEnabled<Dead>(entity);
// Cooldown fraction via wrap-safe NetworkTick compare (raw uint subtraction is unsafe across
// tick wraparound — the project convention, mirroring AbilityFireSystem/EnemyAISystem).
uint nextFire = cd.ValueRO.NextFireTick;
int cdTicks = effAbility.ValueRO.CooldownTicks;
var nextTick = new NetworkTick(nextFire);
cdFrac = (haveTick && nextFire != 0 && cdTicks > 0 && nextTick.IsValid && nextTick.IsNewerThan(nt.ServerTick))
? Mathf.Clamp01(1f - nextTick.TicksSince(nt.ServerTick) / (float)cdTicks)
: 1f;
uint invulnUntil = invuln.ValueRO.UntilTick;
var invulnTick = new NetworkTick(invulnUntil);
shielded = haveTick && invulnUntil != 0 && invulnTick.IsValid && invulnTick.IsNewerThan(nt.ServerTick);
break;
}
_canvas.enabled = found;
if (!found) return;
float frac = maxHp > 0f ? Mathf.Clamp01(hp / maxHp) : 0f;
SetFill(_healthFill, frac);
var hc = _healthFill.GetComponent<RawImage>();
if (hc != null)
hc.color = shielded
? new Color(0.45f, 0.85f, 1f)
: Color.Lerp(new Color(0.92f, 0.16f, 0.16f), new Color(0.25f, 0.9f, 0.5f), frac);
if (_healthText != null)
_healthText.text = Mathf.CeilToInt(Mathf.Max(0f, hp)) + " / " + Mathf.CeilToInt(maxHp) + (shielded ? " SHIELDED" : "");
SetFill(_cooldownFill, cdFrac);
if (_threatText != null)
_threatText.text = "HUSKS " + _huskQuery.CalculateEntityCount();
_respawnOverlay.SetActive(dead);
}
static void SetFill(RectTransform fill, float frac)
{
if (fill == null) return;
var max = fill.anchorMax;
max.x = Mathf.Clamp01(frac);
fill.anchorMax = max;
}
// ---- uGUI construction (code-built; no prefab/sprite assets) ----
void BuildHud()
{
var go = new GameObject("~HUD");
_canvas = go.AddComponent<Canvas>();
_canvas.renderMode = RenderMode.ScreenSpaceOverlay;
_canvas.sortingOrder = 100;
var scaler = go.AddComponent<CanvasScaler>();
scaler.uiScaleMode = CanvasScaler.ScaleMode.ScaleWithScreenSize;
scaler.referenceResolution = new Vector2(1920, 1080);
go.AddComponent<GraphicRaycaster>();
var font = GetFont();
// Health bar (bottom-left).
var hpBg = MakeBar("HealthBg", _canvas.transform, new Color(0f, 0f, 0f, 0.6f),
new Vector2(40, 46), new Vector2(440, 40));
_healthFill = MakeFill("HealthFill", hpBg, new Color(0.25f, 0.9f, 0.5f));
_healthText = MakeText("HealthText", hpBg, "100 / 100", 24, TextAnchor.MiddleCenter, Color.white, font);
// Cooldown bar (just above health).
var cdBg = MakeBar("CooldownBg", _canvas.transform, new Color(0f, 0f, 0f, 0.55f),
new Vector2(40, 92), new Vector2(440, 14));
_cooldownFill = MakeFill("CooldownFill", cdBg, new Color(0.4f, 0.8f, 1f));
// Threat count (top-right).
_threatText = MakeText("ThreatText", _canvas.transform, "HUSKS 0", 30, TextAnchor.UpperRight,
new Color(1f, 0.62f, 0.4f), font);
var trt = _threatText.rectTransform;
trt.anchorMin = new Vector2(1, 1); trt.anchorMax = new Vector2(1, 1); trt.pivot = new Vector2(1, 1);
trt.anchoredPosition = new Vector2(-40, -30); trt.sizeDelta = new Vector2(380, 50);
// Downed / respawning overlay (full screen, toggled by Dead).
_respawnOverlay = new GameObject("RespawnOverlay", typeof(RectTransform));
_respawnOverlay.transform.SetParent(_canvas.transform, false);
var ov = _respawnOverlay.AddComponent<RawImage>();
ov.texture = Texture2D.whiteTexture;
ov.color = new Color(0.35f, 0f, 0f, 0.35f);
ov.raycastTarget = false;
Stretch((RectTransform)_respawnOverlay.transform);
var rtext = MakeText("RespawnText", _respawnOverlay.transform, "DOWNED - RESPAWNING", 56,
TextAnchor.MiddleCenter, new Color(1f, 0.45f, 0.4f), font);
Stretch(rtext.rectTransform);
_respawnOverlay.SetActive(false);
}
static RectTransform MakeBar(string name, Transform parent, Color color, Vector2 anchoredPos, Vector2 size)
{
var go = new GameObject(name, typeof(RectTransform));
go.transform.SetParent(parent, false);
var img = go.AddComponent<RawImage>();
img.texture = Texture2D.whiteTexture;
img.color = color;
img.raycastTarget = false;
var rt = (RectTransform)go.transform;
rt.anchorMin = Vector2.zero; rt.anchorMax = Vector2.zero; rt.pivot = Vector2.zero;
rt.anchoredPosition = anchoredPos; rt.sizeDelta = size;
return rt;
}
static RectTransform MakeFill(string name, RectTransform parent, Color color)
{
var go = new GameObject(name, typeof(RectTransform));
go.transform.SetParent(parent, false);
var img = go.AddComponent<RawImage>();
img.texture = Texture2D.whiteTexture;
img.color = color;
img.raycastTarget = false;
var rt = (RectTransform)go.transform;
rt.anchorMin = Vector2.zero; rt.anchorMax = Vector2.one; rt.pivot = Vector2.zero;
rt.offsetMin = new Vector2(3, 3); rt.offsetMax = new Vector2(-3, -3);
return rt;
}
static Text MakeText(string name, Transform parent, string text, int size, TextAnchor anchor, Color color, Font font)
{
var go = new GameObject(name, typeof(RectTransform));
go.transform.SetParent(parent, false);
var t = go.AddComponent<Text>();
t.text = text;
t.font = font;
t.fontSize = size;
t.alignment = anchor;
t.color = color;
t.horizontalOverflow = HorizontalWrapMode.Overflow;
t.verticalOverflow = VerticalWrapMode.Overflow;
t.raycastTarget = false;
Stretch(t.rectTransform);
return t;
}
static void Stretch(RectTransform rt)
{
rt.anchorMin = Vector2.zero; rt.anchorMax = Vector2.one;
rt.offsetMin = Vector2.zero; rt.offsetMax = Vector2.zero;
}
static Font GetFont()
{
Font f = Resources.GetBuiltinResource<Font>("LegacyRuntime.ttf");
if (f == null) f = Resources.GetBuiltinResource<Font>("Arial.ttf");
if (f == null) f = Font.CreateDynamicFontFromOSFont(new[] { "Arial", "Liberation Sans", "DejaVu Sans" }, 28);
return f;
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 67696deb509086a408e38872c904a8e6
@@ -30,6 +30,13 @@ namespace ProjectM.Client
/// <summary>True while a locally-owned player exists to follow.</summary>
public static bool HasTarget;
/// <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;
/// <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);
[Header("Angle (degrees)")]
[Range(10f, 89f)] public float Pitch = 45f;
[Range(-180f, 180f)] public float Yaw = 0f;
@@ -69,7 +76,13 @@ namespace ProjectM.Client
Vector3 desired = target - (rot * Vector3.forward) * Distance;
float k = FollowSharpness <= 0f ? 1f : 1f - Mathf.Exp(-FollowSharpness * Time.deltaTime);
transform.SetPositionAndRotation(Vector3.Lerp(transform.position, desired, k), rot);
Vector3 basePos = Vector3.Lerp(transform.position, desired, k);
if (s_shake > 0.0001f)
{
basePos += UnityEngine.Random.insideUnitSphere * s_shake;
s_shake = Mathf.Lerp(s_shake, 0f, 1f - Mathf.Exp(-12f * Time.deltaTime));
}
transform.SetPositionAndRotation(basePos, rot);
}
}
@@ -11,7 +11,8 @@
"Unity.NetCode",
"Unity.Entities.Graphics",
"Unity.InputSystem",
"Unity.Networking.Transport"
"Unity.Networking.Transport",
"UnityEngine.UI"
],
"includePlatforms": [],
"excludePlatforms": [],
@@ -0,0 +1,131 @@
using ProjectM.Simulation;
using Unity.Burst;
using Unity.Collections;
using Unity.Entities;
using Unity.Mathematics;
using Unity.NetCode;
using Unity.Transforms;
namespace ProjectM.Server
{
/// <summary>
/// Server-authoritative Husk AI: each tick every Husk seeks the nearest LIVING player and strikes on
/// contact. Husks are OWNERLESS INTERPOLATED ghosts (not predicted), so this runs SERVER-ONLY in the plain
/// <see cref="SimulationSystemGroup"/> — writing <see cref="LocalTransform"/> directly (replicated to clients
/// by the stock LocalTransform default variant; no hand-written <c>[GhostField]</c>). Ordered
/// <c>[UpdateAfter(PredictedSimulationSystemGroup)]</c> (the predicted group is OrderFirst, so UpdateBefore is ignored) so a contact <see cref="DamageEvent"/> appended this
/// tick is drained the following tick by <see cref="HealthApplyDamageSystem"/> (which runs inside the predicted
/// group on the server). No <c>Simulate</c> filter: interpolated ghosts are not predicted and the server has
/// no rollback, so every Husk advances exactly once per tick. Movement/attack math is the pure, deterministic
/// <see cref="EnemyAIMath"/>; server fixed-step <c>SystemAPI.Time.DeltaTime</c> is correct here (not the
/// rollback loop). Structural-free: the only deferred op is appending to the player's DamageEvent buffer.
/// </summary>
[BurstCompile]
[WorldSystemFilter(WorldSystemFilterFlags.ServerSimulation)]
[UpdateInGroup(typeof(SimulationSystemGroup))]
[UpdateAfter(typeof(PredictedSimulationSystemGroup))]
public partial struct EnemyAISystem : ISystem
{
[BurstCompile]
public void OnCreate(ref SystemState state)
{
state.RequireForUpdate<NetworkTime>();
state.RequireForUpdate(state.GetEntityQuery(ComponentType.ReadOnly<EnemyTag>()));
}
[BurstCompile]
public void OnUpdate(ref SystemState state)
{
// Snapshot living player targets once this tick (stable query order).
var playerEntities = new NativeList<Entity>(Allocator.Temp);
var playerPositions = new NativeList<float3>(Allocator.Temp);
foreach (var (xform, health, entity) in
SystemAPI.Query<RefRO<LocalTransform>, RefRO<Health>>()
.WithAll<PlayerTag>()
.WithEntityAccess())
{
if (health.ValueRO.Current <= 0f)
continue; // don't chase or strike a corpse
playerEntities.Add(entity);
playerPositions.Add(xform.ValueRO.Position);
}
if (playerEntities.Length == 0)
{
playerEntities.Dispose();
playerPositions.Dispose();
return;
}
float dt = SystemAPI.Time.DeltaTime;
var serverTick = SystemAPI.GetSingleton<NetworkTime>().ServerTick;
var ecb = new EntityCommandBuffer(Allocator.Temp);
foreach (var (xform, stats, cooldown) in
SystemAPI.Query<RefRW<LocalTransform>, RefRO<EnemyStats>, RefRW<EnemyAttackCooldown>>()
.WithAll<EnemyTag>())
{
float3 pos = xform.ValueRO.Position;
// Nearest living player (planar XZ).
int best = -1;
float bestSq = float.MaxValue;
for (int i = 0; i < playerPositions.Length; i++)
{
float2 d = playerPositions[i].xz - pos.xz;
float sq = math.lengthsq(d);
if (sq < bestSq)
{
bestSq = sq;
best = i;
}
}
float3 targetPos = playerPositions[best];
// Seek: stop just inside strike range so the Husk holds position to attack.
float stopDistance = stats.ValueRO.AttackRange * 0.9f;
float3 vel = EnemyAIMath.SeekVelocity(pos, targetPos, stats.ValueRO.MoveSpeed, stopDistance);
float3 newPos = pos + vel * dt;
newPos.y = pos.y; // hold the movement plane
xform.ValueRW.Position = newPos;
// Face the target (planar) for presentation.
float3 toTarget = targetPos - pos;
toTarget.y = 0f;
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))
{
uint nextRaw = cooldown.ValueRO.NextAttackTick;
bool ready = true;
if (nextRaw != 0)
{
var nextTick = new NetworkTick(nextRaw);
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);
}
}
}
ecb.Playback(state.EntityManager);
ecb.Dispose();
playerEntities.Dispose();
playerPositions.Dispose();
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 5ab8a84c266f92b4c80b39ad9904a71d
@@ -32,6 +32,7 @@ namespace ProjectM.Server
public void OnUpdate(ref SystemState state)
{
var ecb = new EntityCommandBuffer(Allocator.Temp);
bool haveTick = SystemAPI.TryGetSingleton<NetworkTime>(out var netTime);
foreach (var (health, dmg, entity) in
SystemAPI.Query<RefRW<Health>, DynamicBuffer<DamageEvent>>()
@@ -40,6 +41,21 @@ namespace ProjectM.Server
if (dmg.Length == 0)
continue;
// Respawn invulnerability: a freshly-recovered player ignores damage for a window.
if (haveTick && netTime.ServerTick.IsValid && SystemAPI.HasComponent<RespawnInvuln>(entity))
{
uint until = SystemAPI.GetComponent<RespawnInvuln>(entity).UntilTick;
if (until != 0)
{
var untilTick = new NetworkTick(until);
if (untilTick.IsValid && untilTick.IsNewerThan(netTime.ServerTick))
{
dmg.Clear();
continue;
}
}
}
float total = 0f;
for (int i = 0; i < dmg.Length; i++)
total += dmg[i].Amount;
@@ -57,7 +73,7 @@ namespace ProjectM.Server
health.ValueRW.Current = newHp;
// Server-authoritative death: training dummies despawn; player death is deferred (clamp only).
if (health.ValueRO.Current <= 0f && SystemAPI.HasComponent<TrainingDummyTag>(entity))
if (health.ValueRO.Current <= 0f && (SystemAPI.HasComponent<TrainingDummyTag>(entity) || SystemAPI.HasComponent<EnemyTag>(entity)))
ecb.DestroyEntity(entity);
}
@@ -0,0 +1,80 @@
using ProjectM.Simulation;
using Unity.Burst;
using Unity.Entities;
using Unity.Mathematics;
using Unity.NetCode;
using Unity.Transforms;
namespace ProjectM.Server
{
/// <summary>
/// Server-authoritative player death→respawn timing. The "is dead" GATE is derived every predicted tick from
/// replicated Health by <see cref="PlayerDeathStateSystem"/> (so movement/aim/fire stop on both server and
/// owner-client); THIS system owns the timer + the authoritative recovery. Runs server-only in the plain
/// <see cref="SimulationSystemGroup"/> AFTER the predicted group, so it observes this tick's post-damage Health.
/// On first seeing Health&lt;=0 it schedules a respawn tick; once due it refills Health to the effective max and
/// repositions the player to its deterministic base spawn slot (<see cref="PlayerSpawnMath"/>). Health.Current
/// (GhostField) + LocalTransform replicate, so the recovery reaches clients and the derived Dead clears.
/// </summary>
[BurstCompile]
[WorldSystemFilter(WorldSystemFilterFlags.ServerSimulation)]
[UpdateInGroup(typeof(SimulationSystemGroup))]
[UpdateAfter(typeof(PredictedSimulationSystemGroup))]
public partial struct PlayerRespawnSystem : ISystem
{
[BurstCompile]
public void OnCreate(ref SystemState state)
{
state.RequireForUpdate<NetworkTime>();
state.RequireForUpdate<PlayerSpawner>();
}
[BurstCompile]
public void OnUpdate(ref SystemState state)
{
var serverTick = SystemAPI.GetSingleton<NetworkTime>().ServerTick;
if (!serverTick.IsValid)
return;
uint now = serverTick.TickIndexForValidTick;
var spawner = SystemAPI.GetSingleton<PlayerSpawner>();
float3 center = spawner.SpawnPoint;
if (SystemAPI.TryGetSingleton<BaseAnchor>(out var baseAnchor))
center = BaseGridMath.PlotCenter(baseAnchor);
foreach (var (health, respawn, invuln, xform, owner, eff) in
SystemAPI.Query<RefRW<Health>, RefRW<RespawnState>, RefRW<RespawnInvuln>, RefRW<LocalTransform>,
RefRO<GhostOwner>, RefRO<EffectiveCharacterStats>>()
.WithAll<PlayerTag>())
{
if (health.ValueRO.Current > 0f)
{
respawn.ValueRW.RespawnTick = 0; // alive: clear any pending schedule
continue;
}
// Dead this tick.
if (respawn.ValueRO.RespawnTick == 0)
{
// Just died: schedule the recovery.
respawn.ValueRW.RespawnTick = RespawnMath.RespawnTick(now, respawn.ValueRO.DelayTicks);
}
else if (RespawnMath.IsDue(now, respawn.ValueRO.RespawnTick))
{
// Recover: full health at the deterministic base spawn slot.
float maxHealth = eff.ValueRO.MaxHealth > 0f ? eff.ValueRO.MaxHealth : health.ValueRO.Max;
health.ValueRW.Current = maxHealth;
float3 pos = center + PlayerSpawnMath.SpawnOffset(
owner.ValueRO.NetworkId, spawner.SpawnRingRadius, spawner.RingSlots);
xform.ValueRW.Position = pos;
// Grant brief post-respawn damage immunity so the swarm can't instantly re-kill.
invuln.ValueRW.UntilTick = TickUtil.NonZero(now + (uint)math.max(0, respawn.ValueRO.InvulnTicks));
respawn.ValueRW.RespawnTick = 0;
}
}
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: bddd5fecdba7e6c45853f4360acdbe74
@@ -0,0 +1,106 @@
using ProjectM.Simulation;
using Unity.Burst;
using Unity.Collections;
using Unity.Entities;
using Unity.Mathematics;
using Unity.NetCode;
using Unity.Transforms;
namespace ProjectM.Server
{
/// <summary>
/// Server-only Husk wave/threat director: a state machine that escalates the swarm. In <c>Lull</c> it waits
/// until the lull timer expires, then starts the next wave (count = <c>BaseCount + (wave-1)*CountPerWave</c>). In
/// <c>Spawning</c> it spawns one Husk every <c>SpawnIntervalTicks</c> at a deterministic ring slot around the
/// <see cref="BaseAnchor"/>, round-robin over the <see cref="WaveEnemyPrefab"/> pool, until the wave is fully
/// spawned; then it waits for the field to be cleared (no live <see cref="EnemyTag"/>) before returning to
/// <c>Lull</c>. Plain <see cref="SimulationSystemGroup"/>, server-authoritative (Husks are interpolated ghosts).
/// Replaces the flat <c>EnemySpawnSystem</c> sustain. Tick gating uses the wrap-safe <see cref="NetworkTick"/>
/// compare + <see cref="TickUtil.NonZero"/>.
/// </summary>
[BurstCompile]
[WorldSystemFilter(WorldSystemFilterFlags.ServerSimulation)]
[UpdateInGroup(typeof(SimulationSystemGroup))]
public partial struct WaveSystem : ISystem
{
EntityQuery m_AliveHusks;
[BurstCompile]
public void OnCreate(ref SystemState state)
{
state.RequireForUpdate<WaveDirector>();
state.RequireForUpdate<WaveState>();
state.RequireForUpdate<NetworkTime>();
m_AliveHusks = state.GetEntityQuery(ComponentType.ReadOnly<EnemyTag>());
}
[BurstCompile]
public void OnUpdate(ref SystemState state)
{
var serverTick = SystemAPI.GetSingleton<NetworkTime>().ServerTick;
if (!serverTick.IsValid)
return;
uint now = serverTick.TickIndexForValidTick;
var director = SystemAPI.GetSingleton<WaveDirector>();
var directorEntity = SystemAPI.GetSingletonEntity<WaveDirector>();
var prefabs = SystemAPI.GetBuffer<WaveEnemyPrefab>(directorEntity);
if (prefabs.Length == 0)
return;
var wave = SystemAPI.GetComponent<WaveState>(directorEntity);
// Ring centre on the base plot when present.
float3 center = new float3(0f, 1f, 0f);
if (SystemAPI.TryGetSingleton<BaseAnchor>(out var baseAnchor))
center = BaseGridMath.PlotCenter(baseAnchor);
// Due when no action is scheduled yet (NextActionTick 0) or the scheduled tick is at/behind now.
bool dueNow = wave.NextActionTick == 0 || !new NetworkTick(wave.NextActionTick).IsNewerThan(serverTick);
if (wave.Phase == WavePhase.Lull)
{
if (dueNow)
{
// Start the next (bigger) wave.
wave.WaveNumber += 1;
wave.RemainingToSpawn =
math.max(1, director.BaseCount + (wave.WaveNumber - 1) * director.CountPerWave);
wave.Phase = WavePhase.Spawning;
wave.NextActionTick = TickUtil.NonZero(now); // spawn the first Husk this tick
}
}
else // Spawning
{
if (wave.RemainingToSpawn > 0)
{
if (dueNow)
{
int slots = math.max(1, director.RingSlots);
int prefabIdx = wave.SpawnCounter % prefabs.Length;
float3 pos = EnemyAIMath.RingPosition(center, wave.SpawnCounter, slots, director.RingRadius);
pos.y = center.y;
var ecb = new EntityCommandBuffer(Allocator.Temp);
var husk = ecb.Instantiate(prefabs[prefabIdx].Prefab);
ecb.SetComponent(husk, LocalTransform.FromPosition(pos));
ecb.Playback(state.EntityManager);
ecb.Dispose();
wave.SpawnCounter += 1;
wave.RemainingToSpawn -= 1;
wave.NextActionTick = TickUtil.NonZero(now + (uint)math.max(1, director.SpawnIntervalTicks));
}
}
else if (m_AliveHusks.CalculateEntityCount() == 0)
{
// Wave cleared: calm before the next.
wave.Phase = WavePhase.Lull;
wave.NextActionTick = TickUtil.NonZero(now + (uint)math.max(1, director.LullTicks));
}
}
SystemAPI.SetComponent(directorEntity, wave);
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: e9cc4f5328b0b794f965c102019f888a
@@ -68,7 +68,7 @@ namespace ProjectM.Simulation
if (isServer)
{
foreach (var dummyTransform in
SystemAPI.Query<RefRO<LocalTransform>>().WithAll<TrainingDummyTag>())
SystemAPI.Query<RefRO<LocalTransform>>().WithAny<TrainingDummyTag, EnemyTag>())
{
candidatePositions.Add(dummyTransform.ValueRO.Position);
}
@@ -81,7 +81,7 @@ namespace ProjectM.Simulation
SystemAPI.Query<RefRO<PlayerInput>, RefRO<PlayerFacing>, RefRO<LocalTransform>,
RefRO<EffectiveAbilityStats>, RefRO<AbilityRef>, RefRW<AbilityCooldown>,
RefRO<GhostOwner>>()
.WithAll<Simulate>()
.WithAll<Simulate>().WithDisabled<Dead>()
.WithEntityAccess())
{
// The InputEvent on the component carries the per-tick delta: set => fired this tick.
@@ -162,7 +162,7 @@ namespace ProjectM.Simulation
// Earliest raw tick the player may fire again. Clamp cooldown to >= 1 tick.
uint cooldownTicks = (uint)math.max(1, eff.ValueRO.CooldownTicks);
cd.ValueRW.NextFireTick = serverTick.TickIndexForValidTick + cooldownTicks;
cd.ValueRW.NextFireTick = TickUtil.NonZero(serverTick.TickIndexForValidTick + cooldownTicks);
}
ecb.Playback(state.EntityManager);
@@ -0,0 +1,54 @@
using Unity.Mathematics;
namespace ProjectM.Simulation
{
/// <summary>
/// Pure, deterministic Husk AI math — no RNG, no wall-clock — so server simulation stays reproducible and
/// the helpers are EditMode-unit-testable without an ECS world (mirrors <see cref="PlayerSpawnMath"/> /
/// <c>StatMath</c>).
/// </summary>
public static class EnemyAIMath
{
/// <summary>
/// Planar (XZ) seek velocity from <paramref name="from"/> toward <paramref name="to"/> at
/// <paramref name="speed"/>. Y is forced to 0 (top-down plane). Returns zero once within
/// <paramref name="stopDistance"/> (so the Husk halts at strike range instead of jittering through the
/// target) or when the two points coincide.
/// </summary>
public static float3 SeekVelocity(float3 from, float3 to, float speed, float stopDistance)
{
float3 d = to - from;
d.y = 0f;
float distSq = math.lengthsq(d);
if (distSq <= stopDistance * stopDistance || distSq < 1e-8f)
return float3.zero;
return math.normalize(d) * speed;
}
/// <summary>
/// True when <paramref name="to"/> is within <paramref name="range"/> of <paramref name="from"/> on the
/// XZ plane.
/// </summary>
public static bool InAttackRange(float3 from, float3 to, float range)
{
float3 d = to - from;
d.y = 0f;
return math.lengthsq(d) <= range * range;
}
/// <summary>
/// Deterministic planar ring position around <paramref name="center"/> for spawn
/// <paramref name="index"/>: evenly spaced over <paramref name="slots"/> angles at
/// <paramref name="radius"/>. Stable per index so a replayed spawn lands identically.
/// </summary>
public static float3 RingPosition(float3 center, int index, int slots, float radius)
{
if (slots < 1)
slots = 1;
int slot = ((index % slots) + slots) % slots;
float angle = (2f * math.PI * slot) / slots;
math.sincos(angle, out float s, out float c);
return center + new float3(c * radius, 0f, s * radius);
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 7db18ae1e3adab1448a9769b002fb9cd
@@ -0,0 +1,43 @@
using Unity.Entities;
namespace ProjectM.Simulation
{
/// <summary>
/// Marks a Husk enemy: a server-simulated, OWNERLESS INTERPOLATED ghost that seeks the nearest player
/// and strikes on contact. Damageable like the training dummy (Health/HitRadius/DamageEvent), but unlike
/// the dummy it IS a replicated ghost, so every client sees it. Movement is server-authoritative — written
/// to LocalTransform in the plain <c>SimulationSystemGroup</c> (interpolated ghosts are NOT predicted) and
/// replicated via the stock LocalTransform default variant (no hand-written <c>[GhostField]</c>). Spawned by
/// the <see cref="WaveDirector"/> (round-robin over Husk variants).
/// </summary>
public struct EnemyTag : IComponentData { }
/// <summary>
/// Baked Husk tunables — identical on both worlds, not replicated (only server systems read them). Different
/// Husk variants (Grunt / Swarmer / Brute) are just different baked values of this component.
/// </summary>
public struct EnemyStats : IComponentData
{
/// <summary>Planar seek speed toward the target, world units/second.</summary>
public float MoveSpeed;
/// <summary>Centre-to-centre distance at which the Husk can strike a player.</summary>
public float AttackRange;
/// <summary>Damage dealt per strike.</summary>
public float AttackDamage;
/// <summary>Simulation ticks between strikes.</summary>
public int AttackCooldownTicks;
}
/// <summary>
/// Server-only per-Husk attack gate. Raw tick value of the earliest tick it may strike again; <c>0</c> =
/// ready. Compared by wrapping into a <see cref="Unity.NetCode.NetworkTick"/> (raw subtraction is unsafe
/// across tick wraparound), mirroring <see cref="AbilityCooldown"/>. Not replicated.
/// </summary>
public struct EnemyAttackCooldown : IComponentData
{
public uint NextAttackTick;
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: d06ea439174f0cd45b95e9a6e02c14bf
@@ -0,0 +1,17 @@
namespace ProjectM.Simulation
{
/// <summary>
/// Shared tick helpers for the project-wide convention that a stored raw "next tick" value of <c>0</c> means
/// "ready / nothing pending". A computed absolute tick (<c>ServerTick + delay</c>) can legitimately equal 0 at
/// <see cref="uint"/> wraparound, which would be misread as "ready"; <see cref="NonZero"/> coerces it to 1 (a
/// 1-tick error only at the single wrap instant). Mirrors the guard already inlined in
/// <see cref="RespawnMath.RespawnTick"/>, applied consistently at every cooldown/spawn "next tick" write
/// (<c>AbilityCooldown.NextFireTick</c>, <c>EnemyAttackCooldown.NextAttackTick</c>,
/// <c>EnemySpawner.NextSpawnTick</c>).
/// </summary>
public static class TickUtil
{
/// <summary>Coerce a computed raw tick of 0 to 1 so it never collides with the "0 = ready" sentinel.</summary>
public static uint NonZero(uint tick) => tick == 0u ? 1u : tick;
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 3cea93829504a584fabb291b2d559f2f
@@ -0,0 +1,47 @@
using Unity.Entities;
namespace ProjectM.Simulation
{
/// <summary>
/// Baked config for the Husk wave/threat director (singleton). The director escalates: wave N spawns
/// <c>BaseCount + (N-1)*CountPerWave</c> Husks (round-robin over the <see cref="WaveEnemyPrefab"/> pool) at a
/// deterministic ring around the <see cref="BaseAnchor"/>, one every <c>SpawnIntervalTicks</c>; once a wave is
/// cleared the field stays calm for <c>LullTicks</c> before the next, bigger wave. Replaces the flat sustain.
/// </summary>
public struct WaveDirector : IComponentData
{
public float RingRadius;
public int RingSlots;
public int BaseCount;
public int CountPerWave;
public int SpawnIntervalTicks;
public int LullTicks;
}
/// <summary>Baked pool of Husk prefab variants the director draws from round-robin (Grunt / Swarmer / Brute / ...).</summary>
public struct WaveEnemyPrefab : IBufferElementData
{
public Entity Prefab;
}
/// <summary>Phase constants for <see cref="WaveState.Phase"/>.</summary>
public static class WavePhase
{
public const byte Lull = 0;
public const byte Spawning = 1;
}
/// <summary>
/// Runtime state of the wave director (server-only singleton; not replicated). Tracks the current wave, the
/// phase (lull vs spawning), the next action tick, how many Husks remain to spawn this wave, and a monotonic
/// spawn counter (drives the ring slot + the round-robin prefab pick).
/// </summary>
public struct WaveState : IComponentData
{
public int WaveNumber;
public byte Phase;
public uint NextActionTick;
public int RemainingToSpawn;
public int SpawnCounter;
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 1999bc9f9b3f2ac4da59d1e8f51849be
@@ -0,0 +1,42 @@
using Unity.Entities;
using Unity.NetCode;
namespace ProjectM.Simulation
{
/// <summary>
/// Enableable, LOCAL (not replicated) "is dead" gate for a player. Derived every predicted tick from the
/// replicated <see cref="Health"/> by <see cref="PlayerDeathStateSystem"/> (Dead == Health.Current &lt;= 0) —
/// exactly the derive-don't-replicate idiom of <see cref="EffectiveCharacterStats"/>/<c>StatRecomputeSystem</c>,
/// so it is identical on server + owner-predicted client and rollback-correct with no replicated enabled bit.
/// Baked DISABLED (the player spawns alive). Movement/aim/fire systems query <c>.WithDisabled&lt;Dead&gt;()</c>
/// so a dead player is frozen and can't act.
/// </summary>
public struct Dead : IComponentData, IEnableableComponent { }
/// <summary>
/// Server-only respawn timer for a player. NOT replicated — recovery is server-authoritative and the refilled
/// <see cref="Health"/> (GhostField) + repositioned LocalTransform replicate instead. <see cref="RespawnTick"/>
/// == 0 means "no respawn pending / alive"; <see cref="DelayTicks"/> is the baked down-time before recovery.
/// </summary>
public struct RespawnState : IComponentData
{
/// <summary>Raw server tick at which to respawn; 0 = none pending.</summary>
public uint RespawnTick;
/// <summary>Ticks the player stays down before recovering (~60 ticks/sec).</summary>
public int DelayTicks;
/// <summary>Ticks of post-respawn damage immunity granted on recovery (~60 ticks/sec).</summary>
public int InvulnTicks;
}
/// <summary>
/// Replicated post-respawn damage-immunity window. <c>UntilTick</c> = the raw server tick until which the
/// player ignores damage; 0 = none. Set server-side by <c>PlayerRespawnSystem</c> on recovery, enforced by
/// <c>HealthApplyDamageSystem</c>, and a <c>[GhostField]</c> so the client HUD can show a SHIELDED cue.
/// </summary>
public struct RespawnInvuln : IComponentData
{
[GhostField] public uint UntilTick;
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 3bd1e4b63cf826446b85d11124bc291b
@@ -22,7 +22,7 @@ namespace ProjectM.Simulation
{
foreach (var (facing, transform, input) in
SystemAPI.Query<RefRW<PlayerFacing>, RefRW<LocalTransform>, RefRO<PlayerInput>>()
.WithAll<Simulate>())
.WithAll<Simulate>().WithDisabled<Dead>())
{
float2 aim = input.ValueRO.Aim;
if (math.lengthsq(aim) < 1e-6f)
@@ -24,7 +24,7 @@ namespace ProjectM.Simulation
{
foreach (var (control, input, stats) in
SystemAPI.Query<RefRW<CharacterControl>, RefRO<PlayerInput>, RefRO<EffectiveCharacterStats>>()
.WithAll<Simulate>())
.WithAll<Simulate>().WithDisabled<Dead>())
{
control.ValueRW.MoveVelocity =
CharacterControlMath.DesiredMovement(input.ValueRO.Move, stats.ValueRO.MoveSpeed);
@@ -0,0 +1,42 @@
using Unity.Burst;
using Unity.Entities;
using Unity.Mathematics;
using Unity.NetCode;
namespace ProjectM.Simulation
{
/// <summary>
/// Derives the LOCAL enableable <see cref="Dead"/> gate from the replicated <see cref="Health"/> every predicted
/// tick (Dead == Health.Current &lt;= 0). Runs in BOTH worlds inside
/// <see cref="PredictedSimulationSystemGroup"/>, BEFORE movement/aim/fire, so a dead player is excluded from
/// those systems (which query <c>.WithDisabled&lt;Dead&gt;()</c>) on the server AND the owner-predicting client.
/// Because it is a pure function of the already-replicated, reconciled Health (the same derive-don't-replicate
/// pattern as <see cref="StatRecomputeSystem"/>), the gate is identical across server, owner-client, and rollback
/// — no replicated enabled bit required. Also zeroes <see cref="CharacterControl.MoveVelocity"/> while dead so
/// the kinematic character holds still (the movement system is skipped and would otherwise coast on stale
/// velocity). The authoritative recovery (Health refill + reposition) is owned server-side by
/// <c>PlayerRespawnSystem</c>. Visits dead players too via <c>.WithPresent&lt;Dead&gt;()</c> (required to write
/// the enabled bit on an entity whose Dead is currently disabled).
/// </summary>
[UpdateInGroup(typeof(PredictedSimulationSystemGroup))]
[UpdateBefore(typeof(PlayerControlSystem))]
[UpdateBefore(typeof(PlayerAimSystem))]
[BurstCompile]
public partial struct PlayerDeathStateSystem : ISystem
{
[BurstCompile]
public void OnUpdate(ref SystemState state)
{
foreach (var (health, control, deadEnabled) in
SystemAPI.Query<RefRO<Health>, RefRW<CharacterControl>, EnabledRefRW<Dead>>()
.WithAll<PlayerTag, Simulate>()
.WithPresent<Dead>())
{
bool isDead = health.ValueRO.Current <= 0f;
deadEnabled.ValueRW = isDead;
if (isDead)
control.ValueRW.MoveVelocity = float3.zero;
}
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 65930c35d657ce84fbce6f1130efb441
@@ -0,0 +1,36 @@
using Unity.Mathematics;
namespace ProjectM.Simulation
{
/// <summary>
/// Pure, deterministic respawn-timer math (no RNG, no wall-clock) — unit-testable in EditMode without a netcode
/// world (mirrors <see cref="PlayerSpawnMath"/> / <see cref="EnemyAIMath"/>). Ticks are the server's monotonic
/// simulation ticks; a stored value of 0 means "no respawn pending / alive". Comparisons use a wrap-safe signed-delta (modular) compare — matching
/// <see cref="Unity.NetCode.NetworkTick.IsNewerThan"/> semantics — so they hold across the uint tick wraparound.
/// </summary>
public static class RespawnMath
{
/// <summary>
/// The tick at which a death at <paramref name="deathTick"/> should respawn, given
/// <paramref name="delayTicks"/> (clamped to &gt;= 1). Never returns 0 (0 is the "no respawn pending"
/// sentinel), so a death exactly at tick 0 still schedules a recovery.
/// </summary>
public static uint RespawnTick(uint deathTick, int delayTicks)
{
uint delay = (uint)math.max(1, delayTicks);
uint t = deathTick + delay;
return t == 0u ? 1u : t;
}
/// <summary>
/// True when <paramref name="now"/> has reached/passed a scheduled <paramref name="respawnTick"/> (and one
/// is actually scheduled, i.e. non-zero).
/// </summary>
public static bool IsDue(uint now, uint respawnTick)
{
// Wrap-safe modular compare (signed delta), NOT a raw `now >= respawnTick` (which is unsafe at the
// uint tick wraparound). Matches NetworkTick.IsNewerThan; keeps this helper pure-uint + unit-testable.
return respawnTick != 0u && (int)(now - respawnTick) >= 0;
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 128d07bd94a16ab40b8a71bdf4f3e4cf
+55
View File
@@ -327,6 +327,60 @@ Transform:
m_Children: []
m_Father: {fileID: 0}
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
--- !u!1 &1301940439
GameObject:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
serializedVersion: 6
m_Component:
- component: {fileID: 1301940441}
- component: {fileID: 1301940442}
m_Layer: 0
m_Name: WaveDirector
m_TagString: Untagged
m_Icon: {fileID: 0}
m_NavMeshLayer: 0
m_StaticEditorFlags: 0
m_IsActive: 1
--- !u!4 &1301940441
Transform:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 1301940439}
serializedVersion: 2
m_LocalRotation: {x: 0, y: 0, z: 0, w: 1}
m_LocalPosition: {x: 0, y: 0, z: 0}
m_LocalScale: {x: 1, y: 1, z: 1}
m_ConstrainProportionsScale: 0
m_Children: []
m_Father: {fileID: 0}
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
--- !u!114 &1301940442
MonoBehaviour:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 1301940439}
m_Enabled: 1
m_EditorHideFlags: 0
m_Script: {fileID: 11500000, guid: f0c287a066ae43b42b2bf1e1ccaa48bc, type: 3}
m_Name:
m_EditorClassIdentifier: ProjectM.Authoring::ProjectM.Authoring.WaveDirectorAuthoring
EnemyPrefabs:
- {fileID: 3885353946372160549, guid: 84c92c40b6b720441ac3a78870c0bba4, type: 3}
- {fileID: 3885353946372160549, guid: 9855c5a5d578bb74ba2fb0d41a73a3b9, type: 3}
- {fileID: 3885353946372160549, guid: 1bc4c3736da534a42a7040fcbe9c92d5, type: 3}
RingRadius: 16
RingSlots: 10
BaseCount: 4
CountPerWave: 2
SpawnIntervalTicks: 24
LullTicks: 240
--- !u!1 &1379903944
GameObject:
m_ObjectHideFlags: 0
@@ -852,3 +906,4 @@ SceneRoots:
- {fileID: 691660676}
- {fileID: 874705788}
- {fileID: 1930969067}
- {fileID: 1301940441}
@@ -0,0 +1,79 @@
using NUnit.Framework;
using ProjectM.Simulation;
using Unity.Mathematics;
namespace ProjectM.Tests
{
/// <summary>
/// Pure-function tests for <see cref="EnemyAIMath"/> (no ECS world), mirroring PlayerSpawnRingTests /
/// StatMathTests. Pins the deterministic Husk seek / strike / spawn-ring math the server AI relies on.
/// </summary>
public class EnemyAIMathTests
{
const float Eps = 1e-4f;
[Test]
public void SeekVelocity_MovesTowardTarget_AtSpeed()
{
var v = EnemyAIMath.SeekVelocity(new float3(0, 1, 0), new float3(10, 1, 0), 4f, 0.5f);
Assert.AreEqual(4f, math.length(v), Eps);
Assert.Greater(v.x, 0f);
Assert.AreEqual(0f, v.y, Eps);
Assert.AreEqual(0f, v.z, Eps);
}
[Test]
public void SeekVelocity_IgnoresVerticalSeparation()
{
// Target directly above (same XZ) is within stop distance on the plane -> no movement.
var v = EnemyAIMath.SeekVelocity(new float3(0, 0, 0), new float3(0, 50, 0), 4f, 0.5f);
Assert.AreEqual(0f, math.length(v), Eps);
}
[Test]
public void SeekVelocity_ZeroWithinStopDistance()
{
var v = EnemyAIMath.SeekVelocity(new float3(0, 1, 0), new float3(0.3f, 1, 0), 4f, 0.5f);
Assert.AreEqual(0f, math.length(v), Eps);
}
[Test]
public void InAttackRange_TrueInside_FalseOutside()
{
Assert.IsTrue(EnemyAIMath.InAttackRange(new float3(0, 1, 0), new float3(1.0f, 1, 0), 1.5f));
Assert.IsFalse(EnemyAIMath.InAttackRange(new float3(0, 1, 0), new float3(2.0f, 1, 0), 1.5f));
}
[Test]
public void InAttackRange_IgnoresVertical()
{
// Same XZ, large Y gap -> still in range on the plane.
Assert.IsTrue(EnemyAIMath.InAttackRange(new float3(0, 0, 0), new float3(0, 99, 0), 1.5f));
}
[Test]
public void RingPosition_OnRadius_AroundCenter()
{
var center = new float3(5, 1, -3);
var p = EnemyAIMath.RingPosition(center, 0, 8, 6f);
var d = p - center;
Assert.AreEqual(6f, math.length(new float2(d.x, d.z)), Eps); // planar distance == radius
Assert.AreEqual(center.y, p.y, Eps); // stays on the plane
// index 0, slots 8 -> angle 0 -> offset (+radius, 0, 0)
Assert.AreEqual(center.x + 6f, p.x, Eps);
Assert.AreEqual(center.z, p.z, Eps);
}
[Test]
public void RingPosition_Deterministic_And_Distinct()
{
var c = float3.zero;
var a = EnemyAIMath.RingPosition(c, 1, 8, 4f);
var b = EnemyAIMath.RingPosition(c, 1, 8, 4f);
Assert.AreEqual(a.x, b.x, Eps);
Assert.AreEqual(a.z, b.z, Eps);
var other = EnemyAIMath.RingPosition(c, 2, 8, 4f);
Assert.Greater(math.distance(a, other), 1e-3f);
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: d03b0dfede8436640a8bb615183d72b3
@@ -0,0 +1,46 @@
using NUnit.Framework;
using ProjectM.Simulation;
namespace ProjectM.Tests
{
/// <summary>
/// Pure-function tests for <see cref="RespawnMath"/> (no ECS world), mirroring PlayerSpawnRingTests /
/// EnemyAIMathTests. Pins the deterministic player respawn-timer math.
/// </summary>
public class RespawnMathTests
{
[Test]
public void RespawnTick_AddsDelay()
{
Assert.AreEqual(1180u, RespawnMath.RespawnTick(1000u, 180));
}
[Test]
public void RespawnTick_ClampsDelayToAtLeastOne()
{
Assert.AreEqual(1001u, RespawnMath.RespawnTick(1000u, 0));
Assert.AreEqual(1001u, RespawnMath.RespawnTick(1000u, -5));
}
[Test]
public void RespawnTick_NeverReturnsZeroSentinel()
{
// A death exactly at tick 0 must still schedule a non-zero recovery tick.
Assert.AreNotEqual(0u, RespawnMath.RespawnTick(0u, 1));
}
[Test]
public void IsDue_FalseBefore_TrueAtOrAfter()
{
Assert.IsFalse(RespawnMath.IsDue(1100u, 1180u));
Assert.IsTrue(RespawnMath.IsDue(1180u, 1180u));
Assert.IsTrue(RespawnMath.IsDue(1200u, 1180u));
}
[Test]
public void IsDue_FalseWhenNoneScheduled()
{
Assert.IsFalse(RespawnMath.IsDue(99999u, 0u));
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 42b88f5fe0fffa844b6d71660244e2b1