diff --git a/Assets/_Project/Materials/M_ResourceNode.mat b/Assets/_Project/Materials/M_ResourceNode.mat new file mode 100644 index 000000000..1bf3a99b4 --- /dev/null +++ b/Assets/_Project/Materials/M_ResourceNode.mat @@ -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 diff --git a/Assets/_Project/Materials/M_ResourceNode.mat.meta b/Assets/_Project/Materials/M_ResourceNode.mat.meta new file mode 100644 index 000000000..136d58921 --- /dev/null +++ b/Assets/_Project/Materials/M_ResourceNode.mat.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: ec6276f885d7500448a10531a20bda9a +NativeFormatImporter: + externalObjects: {} + mainObjectFileID: 2100000 + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/_Project/Materials/M_Storage.mat b/Assets/_Project/Materials/M_Storage.mat new file mode 100644 index 000000000..592024d92 --- /dev/null +++ b/Assets/_Project/Materials/M_Storage.mat @@ -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 diff --git a/Assets/_Project/Materials/M_Storage.mat.meta b/Assets/_Project/Materials/M_Storage.mat.meta new file mode 100644 index 000000000..395c34449 --- /dev/null +++ b/Assets/_Project/Materials/M_Storage.mat.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 9190cd8b2a7600a49a543832e1e34071 +NativeFormatImporter: + externalObjects: {} + mainObjectFileID: 2100000 + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/_Project/Materials/M_Turret.mat b/Assets/_Project/Materials/M_Turret.mat new file mode 100644 index 000000000..a1cbe50b1 --- /dev/null +++ b/Assets/_Project/Materials/M_Turret.mat @@ -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 diff --git a/Assets/_Project/Materials/M_Turret.mat.meta b/Assets/_Project/Materials/M_Turret.mat.meta new file mode 100644 index 000000000..31e212477 --- /dev/null +++ b/Assets/_Project/Materials/M_Turret.mat.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: c01dbe8a4e818c5469eceae8b286bf4d +NativeFormatImporter: + externalObjects: {} + mainObjectFileID: 2100000 + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/_Project/Prefabs/ResourceNode.prefab b/Assets/_Project/Prefabs/ResourceNode.prefab index 43b285976..0c96caf11 100644 --- a/Assets/_Project/Prefabs/ResourceNode.prefab +++ b/Assets/_Project/Prefabs/ResourceNode.prefab @@ -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 diff --git a/Assets/_Project/Prefabs/Storage.prefab b/Assets/_Project/Prefabs/Storage.prefab index 0ee41bbab..abebfdfc0 100644 --- a/Assets/_Project/Prefabs/Storage.prefab +++ b/Assets/_Project/Prefabs/Storage.prefab @@ -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 diff --git a/Assets/_Project/Prefabs/Turret.prefab b/Assets/_Project/Prefabs/Turret.prefab index c70177ba4..6d6c56ad2 100644 --- a/Assets/_Project/Prefabs/Turret.prefab +++ b/Assets/_Project/Prefabs/Turret.prefab @@ -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 diff --git a/Assets/_Project/Scripts/Authoring/Combat/EnemyAuthoring.cs b/Assets/_Project/Scripts/Authoring/Combat/EnemyAuthoring.cs index 6863f8030..a5f985baa 100644 --- a/Assets/_Project/Scripts/Authoring/Combat/EnemyAuthoring.cs +++ b/Assets/_Project/Scripts/Authoring/Combat/EnemyAuthoring.cs @@ -50,6 +50,8 @@ namespace ProjectM.Authoring AttackCooldownTicks = authoring.AttackCooldownTicks, }); AddComponent(entity, new EnemyAttackCooldown { NextAttackTick = 0 }); + AddComponent(entity); // server-only recoil state (zero = not knocked) + AddComponent(entity); // replicated telegraph signal (zero = not winding up) } } } diff --git a/Assets/_Project/Scripts/Authoring/Player/PlayerAuthoring.cs b/Assets/_Project/Scripts/Authoring/Player/PlayerAuthoring.cs index 08407360a..a28e07576 100644 --- a/Assets/_Project/Scripts/Authoring/Player/PlayerAuthoring.cs +++ b/Assets/_Project/Scripts/Authoring/Player/PlayerAuthoring.cs @@ -67,6 +67,8 @@ namespace ProjectM.Authoring // Empty replicated modifier stack (grown by upgrades/pickups/debug hook, server-authoritative). AddBuffer(entity); + // Server-only expiry tracker for timed buffs (paired with a StatModifier by SourceId; not replicated). + AddBuffer(entity); // Combat: server-authoritative health (Current replicated for display), the player's // damageable hit radius, predicted cooldown state, and the per-tick damage inbox. diff --git a/Assets/_Project/Scripts/Client/Presentation/AimReticleSystem.cs b/Assets/_Project/Scripts/Client/Presentation/AimReticleSystem.cs index c68196216..8a449b1ba 100644 --- a/Assets/_Project/Scripts/Client/Presentation/AimReticleSystem.cs +++ b/Assets/_Project/Scripts/Client/Presentation/AimReticleSystem.cs @@ -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(); EntityManager.CompleteDependencyBeforeRO(); + EntityManager.CompleteDependencyBeforeRO(); foreach (var (xform, facing) in SystemAPI.Query, RefRO>() .WithAll()) @@ -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>().WithAll()) + { + 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(); + _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) ---- diff --git a/Assets/_Project/Scripts/Client/Presentation/AmbientAudioSystem.cs b/Assets/_Project/Scripts/Client/Presentation/AmbientAudioSystem.cs new file mode 100644 index 000000000..ed6de32a2 --- /dev/null +++ b/Assets/_Project/Scripts/Client/Presentation/AmbientAudioSystem.cs @@ -0,0 +1,147 @@ +using ProjectM.Simulation; +using Unity.Entities; +using Unity.NetCode; +using UnityEngine; + +namespace ProjectM.Client +{ + /// + /// Client-only AMBIENT audio + cycle-phase stingers. A managed presentation + /// (, main thread, no Burst) that OBSERVES the replicated + /// and never touches the simulation. On start it plays a low, seamless-looping + /// procedural drone (asset-free, AudioClip.Create like CombatFeedbackSystem.MakeClip); 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. + /// + [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(); + _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(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; + } + } +} diff --git a/Assets/_Project/Scripts/Client/Presentation/AmbientAudioSystem.cs.meta b/Assets/_Project/Scripts/Client/Presentation/AmbientAudioSystem.cs.meta new file mode 100644 index 000000000..45ded2200 --- /dev/null +++ b/Assets/_Project/Scripts/Client/Presentation/AmbientAudioSystem.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: a72ff134349646a408c917908bd6613c \ No newline at end of file diff --git a/Assets/_Project/Scripts/Client/Presentation/CombatFeedbackSystem.cs b/Assets/_Project/Scripts/Client/Presentation/CombatFeedbackSystem.cs index 9f2cfbacc..5bf4699b2 100644 --- a/Assets/_Project/Scripts/Client/Presentation/CombatFeedbackSystem.cs +++ b/Assets/_Project/Scripts/Client/Presentation/CombatFeedbackSystem.cs @@ -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 _cache = new(); readonly HashSet _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(); EntityManager.CompleteDependencyBeforeRO(); + EntityManager.CompleteDependencyBeforeRO(); // 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(entity); + uint windup = isEnemy && SystemAPI.HasComponent(entity) ? SystemAPI.GetComponent(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]); } diff --git a/Assets/_Project/Scripts/Client/Presentation/FeelConfig.cs b/Assets/_Project/Scripts/Client/Presentation/FeelConfig.cs new file mode 100644 index 000000000..6c211b30b --- /dev/null +++ b/Assets/_Project/Scripts/Client/Presentation/FeelConfig.cs @@ -0,0 +1,113 @@ +using UnityEngine; + +namespace ProjectM.Client +{ + /// + /// Live-tunable knobs for the client-only COMBAT-FEEL slice (Stage E). A static bridge — mirrors + /// — so values can be poked at runtime via MCP execute_code + /// (e.g. ProjectM.Client.FeelConfig.HitShakeLocal = 0.4f;) WITHOUT a recompile, for interactive + /// tuning. Read ONLY by client-presentation systems (, + /// ) and the MonoBehaviour — all non-Burst, + /// main-thread. NEVER read these from a [BurstCompile] system (managed-static + Color/enum-in-Burst + /// hazards); they are presentation-only and never touch the deterministic simulation. + /// + /// Defaults match the values previously hardcoded in CombatFeedbackSystem so behaviour is byte-identical + /// until a knob is poked. re-stamps every field on play-enter via + /// [RuntimeInitializeOnLoadMethod(SubsystemRegistration)] 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 's reset prevents). + /// + /// + public static class FeelConfig + { + // ---- Feature 1: hit camera punch + (camera-only) hit-stop ---- + /// Camera shake when the LOCAL player is hit (fed to PrototypeCameraRig.AddShake, clamp 0.8). + public static float HitShakeLocal; + /// Camera shake when a remote player / Husk is hit. + public static float HitShakeRemote; + /// Hit-spark particle burst count (procedural fallback path). + public static int HitBurstCount; + /// Hit SFX volume. + public static float HitSfxVolume; + /// Degrees of transient FOV "kick" on a LOCAL hit — the netcode-safe hit-stop (NEVER Time.timeScale). 0 = off. + public static float HitStopFovKick; + /// Milliseconds the FOV kick eases back to base. + public static float HitStopDurationMs; + + // ---- Feature 1/2: death camera punch ---- + /// Camera shake on LOCAL player death (loudest event by design). + public static float PlayerDeathShake; + /// Camera shake on a remote player's death. + public static float RemotePlayerDeathShake; + /// Base death-burst particle count (player death + Husk-death base). + public static int DeathBurstCount; + + // ---- Feature 2: kill-shot fanfare (Husk death) ---- + /// Camera shake on a Husk kill (nudged above a glancing hit, kept under PlayerDeathShake). + public static float KillShake; + /// Multiplier on DeathBurstCount for a Husk kill (result clamped by MaxActiveVfx). + public static float KillBurstScale; + /// Optional FOV kick on a kill (degrees). 0 = off. + public static float KillFovKick; + /// Husk-death SFX volume. + public static float KillSfxVolume; + + // ---- Feature 3: respawn shimmer / fade-in (local player recovery) ---- + /// Master gate for the local-player respawn shimmer. + public static bool RespawnShimmerEnabled; + /// Particle burst count for the recovery shimmer. + public static int RespawnShimmerBurst; + /// Light camera punch on recovery so respawn reads as "reinforcing". + public static float RespawnShimmerShake; + + // ---- Feature 4: reticle lock-on tether (cosmetic aim HINT) ---- + /// Master gate for the lock-on tether. + public static bool LockOnEnabled; + /// Show the tether only on the Gamepad scheme (mirrors the server's gamepad-only auto-target assist). + public static bool LockOnGamepadOnly; + /// Max world distance from the player to a tethered Husk. + public static float LockOnRange; + /// Forward half-arc (degrees) around PlayerFacing within which a Husk is eligible. + public static float LockOnArcDegrees; + /// Tether line tint (subtle highlight, not a laser). + public static Color LockOnLineColor; + /// Tether line width (world units). + 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; + } + } +} diff --git a/Assets/_Project/Scripts/Client/Presentation/FeelConfig.cs.meta b/Assets/_Project/Scripts/Client/Presentation/FeelConfig.cs.meta new file mode 100644 index 000000000..382c98750 --- /dev/null +++ b/Assets/_Project/Scripts/Client/Presentation/FeelConfig.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: be55ae1703fcd284bbc1acddb417133c \ No newline at end of file diff --git a/Assets/_Project/Scripts/Client/Presentation/HudSystem.cs b/Assets/_Project/Scripts/Client/Presentation/HudSystem.cs index f09e5a203..ef182eee3 100644 --- a/Assets/_Project/Scripts/Client/Presentation/HudSystem.cs +++ b/Assets/_Project/Scripts/Client/Presentation/HudSystem.cs @@ -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 diff --git a/Assets/_Project/Scripts/Client/Presentation/PrototypeCameraRig.cs b/Assets/_Project/Scripts/Client/Presentation/PrototypeCameraRig.cs index 4655e1f8f..2bc218b2f 100644 --- a/Assets/_Project/Scripts/Client/Presentation/PrototypeCameraRig.cs +++ b/Assets/_Project/Scripts/Client/Presentation/PrototypeCameraRig.cs @@ -33,6 +33,10 @@ namespace ProjectM.Client /// Local player's planar facing (XZ), published each client tick for the aim look-ahead. public static float2 TargetFacing; + /// 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. + public static float2 TargetMoveDir; + /// 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). static float s_shake; @@ -40,6 +44,20 @@ namespace ProjectM.Client /// Add a one-shot camera-shake impulse (clamped). Called by CombatFeedbackSystem on hits/deaths. public static void AddShake(float amount) => s_shake = Mathf.Min(s_shake + amount, 0.8f); + /// Transient additive FOV "kick" (degrees) - the netcode-safe hit-stop. Decayed each LateUpdate. + static float s_fovKick; + static float s_fovLambda = 30f; // ease-back rate; PunchFov sets it from the requested duration + + /// Add a one-shot FOV kick eased back over . Presentation only + /// (NEVER Time.timeScale, which would desync the deterministic predicted sim). + 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(); _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>() + foreach (var (transform, facing, input) in + SystemAPI.Query, RefRO, RefRO>() .WithAll()) { PrototypeCameraRig.TargetWorldPos = transform.ValueRO.Position; PrototypeCameraRig.TargetFacing = facing.ValueRO.Direction; + PrototypeCameraRig.TargetMoveDir = input.ValueRO.Move; found = true; break; } diff --git a/Assets/_Project/Scripts/Server/Building/AbilityUpgradeSystem.cs b/Assets/_Project/Scripts/Server/Building/AbilityUpgradeSystem.cs index 542fff40a..72a116c33 100644 --- a/Assets/_Project/Scripts/Server/Building/AbilityUpgradeSystem.cs +++ b/Assets/_Project/Scripts/Server/Building/AbilityUpgradeSystem.cs @@ -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) diff --git a/Assets/_Project/Scripts/Server/Combat/EnemyAISystem.cs b/Assets/_Project/Scripts/Server/Combat/EnemyAISystem.cs index 13ad7a44f..acf6e3dbd 100644 --- a/Assets/_Project/Scripts/Server/Combat/EnemyAISystem.cs +++ b/Assets/_Project/Scripts/Server/Combat/EnemyAISystem.cs @@ -59,14 +59,32 @@ namespace ProjectM.Server float dt = SystemAPI.Time.DeltaTime; var serverTick = SystemAPI.GetSingleton().ServerTick; + uint now = serverTick.TickIndexForValidTick; var ecb = new EntityCommandBuffer(Allocator.Temp); - foreach (var (xform, stats, cooldown) in - SystemAPI.Query, RefRO, RefRW>() + foreach (var (xform, stats, cooldown, knockback, windup) in + SystemAPI.Query, RefRO, RefRW, + RefRW, RefRW>() .WithAll()) { 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); } } } diff --git a/Assets/_Project/Scripts/Server/Combat/ProjectileDamageSystem.cs b/Assets/_Project/Scripts/Server/Combat/ProjectileDamageSystem.cs index c49531e08..0b0dd5c7e 100644 --- a/Assets/_Project/Scripts/Server/Combat/ProjectileDamageSystem.cs +++ b/Assets/_Project/Scripts/Server/Combat/ProjectileDamageSystem.cs @@ -40,6 +40,9 @@ namespace ProjectM.Server /// Lookup used to read a target's owner so a projectile never hits its own caster. ComponentLookup m_GhostOwnerLookup; + /// RW lookup to stamp server-only knockback on a hit Husk (Husks bake KnockbackState; players/dummies don't). + ComponentLookup m_KnockbackLookup; + /// Extra forgiveness added to a target's hit radius to approximate the projectile's own size. const float k_ProjectileRadius = 0.2f; @@ -47,6 +50,7 @@ namespace ProjectM.Server public void OnCreate(ref SystemState state) { m_GhostOwnerLookup = state.GetComponentLookup(isReadOnly: true); + m_KnockbackLookup = state.GetComponentLookup(isReadOnly: false); // No projectiles → nothing to expire or hit-test; skip the tick (and its allocations) entirely. state.RequireForUpdate(); @@ -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(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; } diff --git a/Assets/_Project/Scripts/Server/Combat/TimedModifierExpirySystem.cs b/Assets/_Project/Scripts/Server/Combat/TimedModifierExpirySystem.cs new file mode 100644 index 000000000..50a72d6d4 --- /dev/null +++ b/Assets/_Project/Scripts/Server/Combat/TimedModifierExpirySystem.cs @@ -0,0 +1,56 @@ +using ProjectM.Simulation; +using Unity.Burst; +using Unity.Entities; +using Unity.NetCode; + +namespace ProjectM.Server +{ + /// + /// Server-authoritative expiry of TIMED s. Each tick it walks every entity's + /// server-only buffer; for any row whose has + /// elapsed (wrap-safe 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 (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. + /// + [BurstCompile] + [WorldSystemFilter(WorldSystemFilterFlags.ServerSimulation)] + [UpdateInGroup(typeof(SimulationSystemGroup))] + public partial struct TimedModifierExpirySystem : ISystem + { + [BurstCompile] + public void OnCreate(ref SystemState state) + { + state.RequireForUpdate(); + state.RequireForUpdate(state.GetEntityQuery(ComponentType.ReadOnly())); + } + + [BurstCompile] + public void OnUpdate(ref SystemState state) + { + var serverTick = SystemAPI.GetSingleton().ServerTick; + if (!serverTick.IsValid) + return; + + foreach (var (timed, mods) in + SystemAPI.Query, DynamicBuffer>()) + { + 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); + } + } + } + } +} diff --git a/Assets/_Project/Scripts/Server/Combat/TimedModifierExpirySystem.cs.meta b/Assets/_Project/Scripts/Server/Combat/TimedModifierExpirySystem.cs.meta new file mode 100644 index 000000000..9ba00a187 --- /dev/null +++ b/Assets/_Project/Scripts/Server/Combat/TimedModifierExpirySystem.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 94c6107954fa4d94f8ead51cfe4de3b7 \ No newline at end of file diff --git a/Assets/_Project/Scripts/Server/Economy/ResourceHarvestSystem.cs b/Assets/_Project/Scripts/Server/Economy/ResourceHarvestSystem.cs index e7ff1d46a..5082420af 100644 --- a/Assets/_Project/Scripts/Server/Economy/ResourceHarvestSystem.cs +++ b/Assets/_Project/Scripts/Server/Economy/ResourceHarvestSystem.cs @@ -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) diff --git a/Assets/_Project/Scripts/Server/World/CyclePhaseSystem.cs b/Assets/_Project/Scripts/Server/World/CyclePhaseSystem.cs index 50204923d..fefcea607 100644 --- a/Assets/_Project/Scripts/Server/World/CyclePhaseSystem.cs +++ b/Assets/_Project/Scripts/Server/World/CyclePhaseSystem.cs @@ -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(out var waveSync)) + cycle.WaveNumber = waveSync.WaveNumber; + SystemAPI.SetComponent(cycleEntity, cycle); SystemAPI.SetComponent(cycleEntity, runtime); } diff --git a/Assets/_Project/Scripts/Simulation/Combat/AttackWindup.cs b/Assets/_Project/Scripts/Simulation/Combat/AttackWindup.cs new file mode 100644 index 000000000..0a27ed8ae --- /dev/null +++ b/Assets/_Project/Scripts/Simulation/Combat/AttackWindup.cs @@ -0,0 +1,19 @@ +using Unity.Entities; +using Unity.NetCode; + +namespace ProjectM.Simulation +{ + /// + /// Replicated Husk attack-telegraph signal. While is non-zero the Husk is + /// "winding up" to strike; EnemyAISystem sets it ~ 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). + /// + public struct AttackWindup : IComponentData + { + /// Server tick the wind-up completes + the strike lands (0 = not winding up; scheduled via TickUtil.NonZero). + [GhostField] public uint WindUpUntilTick; + } +} diff --git a/Assets/_Project/Scripts/Simulation/Combat/AttackWindup.cs.meta b/Assets/_Project/Scripts/Simulation/Combat/AttackWindup.cs.meta new file mode 100644 index 000000000..4049593c6 --- /dev/null +++ b/Assets/_Project/Scripts/Simulation/Combat/AttackWindup.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: f2c9d899714758b4baefe6c1cbb3be0a \ No newline at end of file diff --git a/Assets/_Project/Scripts/Simulation/Combat/KnockbackState.cs b/Assets/_Project/Scripts/Simulation/Combat/KnockbackState.cs new file mode 100644 index 000000000..ec1a5cdfd --- /dev/null +++ b/Assets/_Project/Scripts/Simulation/Combat/KnockbackState.cs @@ -0,0 +1,26 @@ +using Unity.Entities; +using Unity.Mathematics; + +namespace ProjectM.Simulation +{ + /// + /// SERVER-ONLY transient knockback on a Husk. While has not elapsed, EnemyAISystem + /// moves the Husk along at (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 + /// (KnockbackSpeed = 0 disables knockback globally). + /// + public struct KnockbackState : IComponentData + { + /// Planar (XZ) knockback heading — the projectile's direction at impact. + public float2 Dir; + + /// Knockback speed (world units/sec) applied for the window; 0 = not knocked. + public float Speed; + + /// Server tick until which the knockback is active (0 = none; scheduled via TickUtil.NonZero). + public uint UntilTick; + } +} diff --git a/Assets/_Project/Scripts/Simulation/Combat/KnockbackState.cs.meta b/Assets/_Project/Scripts/Simulation/Combat/KnockbackState.cs.meta new file mode 100644 index 000000000..4cf14853c --- /dev/null +++ b/Assets/_Project/Scripts/Simulation/Combat/KnockbackState.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 9fa07e28f83ad6b43a8164b8c673a6b1 \ No newline at end of file diff --git a/Assets/_Project/Scripts/Simulation/Combat/TimedModifier.cs b/Assets/_Project/Scripts/Simulation/Combat/TimedModifier.cs new file mode 100644 index 000000000..44c06a8d4 --- /dev/null +++ b/Assets/_Project/Scripts/Simulation/Combat/TimedModifier.cs @@ -0,0 +1,35 @@ +using Unity.Entities; + +namespace ProjectM.Simulation +{ + /// + /// SERVER-ONLY expiry tracker paired with a by . It is NOT a + /// [GhostField] and lives in a SEPARATE buffer so the replicated 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; TimedModifierExpirySystem removes the matching StatModifier + /// when 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. + /// + public struct TimedModifier : IBufferElementData + { + /// Matches the this row governs. + public uint SourceId; + + /// Server tick at which the paired StatModifier expires (0 = no expiry / inert; schedule via TickUtil.NonZero). + public uint UntilTick; + } + + /// Pure helpers for removing modifiers by provenance (clear-by-type / timed expiry). Deterministic, no RNG/wall-clock. + public static class TimedModifierUtil + { + /// Remove every row whose SourceId matches (RemoveAtSwapBack). Returns the count removed. + public static int RemoveBySourceId(DynamicBuffer 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; + } + } +} diff --git a/Assets/_Project/Scripts/Simulation/Combat/TimedModifier.cs.meta b/Assets/_Project/Scripts/Simulation/Combat/TimedModifier.cs.meta new file mode 100644 index 000000000..20c63bea3 --- /dev/null +++ b/Assets/_Project/Scripts/Simulation/Combat/TimedModifier.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 67465323b013e6a4cb59519111b1b9e5 \ No newline at end of file diff --git a/Assets/_Project/Scripts/Simulation/Tuning.cs b/Assets/_Project/Scripts/Simulation/Tuning.cs new file mode 100644 index 000000000..340f5161b --- /dev/null +++ b/Assets/_Project/Scripts/Simulation/Tuning.cs @@ -0,0 +1,54 @@ +namespace ProjectM.Simulation +{ + /// + /// Central home for gameplay-balance constants that were previously buried as private consts + /// inside individual systems, so designers have one searchable place to tune them. Burst-safe (compile-time + /// consts only — they inline into the consuming systems with no runtime cost or managed reference). + /// + /// Systems reference these via Tuning.* (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. + /// + /// + /// Values that already live in a clear, public, semantically-named home (NOT duplicated here): + /// + /// / — cycle phase durations. + /// — base→expedition world-space offset. + /// Per-ability/character stats — authored in ScriptableObjects, baked to the AbilityDatabase blob (M3). + /// + /// + /// + public static class Tuning + { + // ---- Ability damage upgrade (AbilityUpgradeSystem) ---- + + /// Distinct sentinel SourceId so the upgrade StatModifier is found + grown in place + /// (replace-by-SourceId keeps the bounded modifier buffer from growing a row per upgrade). + public const uint AbilityUpgradeSourceId = 0x00A0E711u; + + /// Damage bonus added per upgrade tier (PercentAdd op): +25% per tier. + public const float AbilityUpgradeTierStep = 0.25f; + + /// Aether cost charged to the shared ledger per upgrade tier. + public const int AbilityUpgradeCostAmount = 20; + + // ---- Resource harvest (ResourceHarvestSystem) ---- + + /// Effective projectile radius used by the swept-segment node-hit test (added to the node's + /// HitRadius). Tunnel-safe because the segment is reconstructed from Projectile.LastStep. + public const float HarvestProjectileRadius = 0.2f; + + // ---- Enemy knockback (ProjectileDamageSystem stamps on hit; EnemyAISystem applies + suppresses seek/strike) ---- + + /// Knockback speed (world units/sec) a Husk recoils at when shot; 0 disables knockback globally. + public const float KnockbackSpeed = 8f; + + /// Server ticks the knockback lasts (~60 ticks/sec). + public const int KnockbackDurationTicks = 8; + + // ---- Husk attack telegraph (EnemyAISystem 2-phase strike; client cue in CombatFeedbackSystem) ---- + + /// Wind-up ticks before a Husk strike lands (~0.3s @ 60 ticks/sec). 0/1 = near-instant (legacy behaviour). + public const int AttackWindupTicks = 18; + } +} diff --git a/Assets/_Project/Scripts/Simulation/Tuning.cs.meta b/Assets/_Project/Scripts/Simulation/Tuning.cs.meta new file mode 100644 index 000000000..56e4b4cfc --- /dev/null +++ b/Assets/_Project/Scripts/Simulation/Tuning.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: a78ec19017f42bd4a8a16bfa8a03886d \ No newline at end of file diff --git a/Assets/_Project/Scripts/Simulation/World/CycleComponents.cs b/Assets/_Project/Scripts/Simulation/World/CycleComponents.cs index 3cc6636a0..4e2fa599e 100644 --- a/Assets/_Project/Scripts/Simulation/World/CycleComponents.cs +++ b/Assets/_Project/Scripts/Simulation/World/CycleComponents.cs @@ -21,6 +21,9 @@ namespace ProjectM.Simulation /// Server tick the current timed phase ends (Expedition/Build only; 0 in Defend). [GhostField] public uint PhaseEndTick; + + /// 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). + [GhostField] public int WaveNumber; } /// Phase constants for (a byte, not an enum, for trivial Burst/serialization). diff --git a/Assets/_Project/Tests/EditMode/BuildPlaceSystemTests.cs b/Assets/_Project/Tests/EditMode/BuildPlaceSystemTests.cs new file mode 100644 index 000000000..334b8e1b1 --- /dev/null +++ b/Assets/_Project/Tests/EditMode/BuildPlaceSystemTests.cs @@ -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 +{ + /// + /// Plain-Entities EditMode tests for the server-only — 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. + /// + public class BuildPlaceSystemTests + { + static (World world, SimulationSystemGroup group) MakeWorld(string name, int oreCount) + { + var world = new World(name); + var group = world.GetOrCreateSystemManaged(); + group.AddSystemToUpdateList(world.GetOrCreateSystem()); + 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); + + var catalogE = em.CreateEntity(typeof(StructureCatalog)); + var catalog = em.AddBuffer(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(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(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."); + } + } + } +} diff --git a/Assets/_Project/Tests/EditMode/BuildPlaceSystemTests.cs.meta b/Assets/_Project/Tests/EditMode/BuildPlaceSystemTests.cs.meta new file mode 100644 index 000000000..bc7727da4 --- /dev/null +++ b/Assets/_Project/Tests/EditMode/BuildPlaceSystemTests.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 69248c6e19368b246a8aa8b151a8f7b0 \ No newline at end of file diff --git a/Assets/_Project/Tests/EditMode/CyclePhaseSystemTests.cs b/Assets/_Project/Tests/EditMode/CyclePhaseSystemTests.cs new file mode 100644 index 000000000..3db5c6083 --- /dev/null +++ b/Assets/_Project/Tests/EditMode/CyclePhaseSystemTests.cs @@ -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 +{ + /// + /// Plain-Entities EditMode tests for the server-only — 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. + /// + public class CyclePhaseSystemTests + { + static (World world, SimulationSystemGroup group) MakeWorld(string name, uint serverTick) + { + var world = new World(name); + var group = world.GetOrCreateSystemManaged(); + group.AddSystemToUpdateList(world.GetOrCreateSystem()); + 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(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(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(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(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(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(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(cycle).WaveNumber, + "CycleState.WaveNumber mirrors the server-only WaveState.WaveNumber so the replicated-state-only HUD can show it."); + } + } +} +} diff --git a/Assets/_Project/Tests/EditMode/CyclePhaseSystemTests.cs.meta b/Assets/_Project/Tests/EditMode/CyclePhaseSystemTests.cs.meta new file mode 100644 index 000000000..9020c32fb --- /dev/null +++ b/Assets/_Project/Tests/EditMode/CyclePhaseSystemTests.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: def6f8080b5a28d4eb9ee4781b283752 \ No newline at end of file diff --git a/Assets/_Project/Tests/EditMode/ExpeditionGateSystemTests.cs b/Assets/_Project/Tests/EditMode/ExpeditionGateSystemTests.cs new file mode 100644 index 000000000..00b194475 --- /dev/null +++ b/Assets/_Project/Tests/EditMode/ExpeditionGateSystemTests.cs @@ -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 +{ + /// + /// Plain-Entities EditMode tests for the server-only (walk-in region + /// transit). A bare world is seeded with an ExpeditionGate (+ 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. + /// + public class ExpeditionGateSystemTests + { + static (World world, SimulationSystemGroup group) MakeWorld(string name) + { + var world = new World(name); + var group = world.GetOrCreateSystemManaged(); + group.AddSystemToUpdateList(world.GetOrCreateSystem()); + 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(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(player).Region, + "Region flips to the gate's ToRegion."); + var p = em.GetComponentData(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(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(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(cycle).PhaseEndTick, + "Returning to base mid-Expedition caps PhaseEndTick to 1 so Defend starts next tick."); + } + } + } +} diff --git a/Assets/_Project/Tests/EditMode/ExpeditionGateSystemTests.cs.meta b/Assets/_Project/Tests/EditMode/ExpeditionGateSystemTests.cs.meta new file mode 100644 index 000000000..fa9a62453 --- /dev/null +++ b/Assets/_Project/Tests/EditMode/ExpeditionGateSystemTests.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: dfddde749d3109843901804073127701 \ No newline at end of file diff --git a/Assets/_Project/Tests/EditMode/KnockbackTests.cs b/Assets/_Project/Tests/EditMode/KnockbackTests.cs new file mode 100644 index 000000000..77f2a08cd --- /dev/null +++ b/Assets/_Project/Tests/EditMode/KnockbackTests.cs @@ -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 +{ + /// + /// Plain-Entities EditMode tests for enemy KNOCKBACK (server-only, no re-bake). Two halves: + /// ProjectileDamageSystem STAMPS a 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. + /// + 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(); + group.AddSystemToUpdateList(world.GetOrCreateSystem()); + 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(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(husk).Length, "The hit still deals damage."); + Assert.IsFalse(em.Exists(proj), "The projectile is consumed on hit."); + var kb = em.GetComponentData(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(); + group.AddSystemToUpdateList(world.GetOrCreateSystem()); + 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(e); + em.AddBuffer(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(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(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(husk).Position.x; + Assert.Greater(xResumed, xKnocked, "Once the knockback window elapses the Husk seeks back toward the player."); + Assert.AreEqual(0u, em.GetComponentData(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(player).Length, + "A recoiling Husk does not strike even when inside AttackRange."); + } + } + } +} diff --git a/Assets/_Project/Tests/EditMode/KnockbackTests.cs.meta b/Assets/_Project/Tests/EditMode/KnockbackTests.cs.meta new file mode 100644 index 000000000..c7fcb7fe3 --- /dev/null +++ b/Assets/_Project/Tests/EditMode/KnockbackTests.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 85b62d9117f60de448d7a515c0709a89 \ No newline at end of file diff --git a/Assets/_Project/Tests/EditMode/PlayerRespawnSystemTests.cs b/Assets/_Project/Tests/EditMode/PlayerRespawnSystemTests.cs new file mode 100644 index 000000000..c95e2639d --- /dev/null +++ b/Assets/_Project/Tests/EditMode/PlayerRespawnSystemTests.cs @@ -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 +{ + /// + /// Plain-Entities EditMode tests for the server-only — 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. + /// + public class PlayerRespawnSystemTests + { + static (World world, SimulationSystemGroup group) MakeWorld(string name, uint serverTick) + { + var world = new World(name); + var group = world.GetOrCreateSystemManaged(); + group.AddSystemToUpdateList(world.GetOrCreateSystem()); + 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(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(player).RespawnTick, + "A newly-dead player schedules its respawn tick (now + delay)."); + Assert.AreEqual(0f, em.GetComponentData(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(player).Current, 1e-4f, "Health refills to the effective max."); + Assert.AreEqual(320u, em.GetComponentData(player).UntilTick, + "Post-respawn invulnerability is granted until now + InvulnTicks (200 + 120)."); + Assert.AreEqual(0u, em.GetComponentData(player).RespawnTick, "The respawn schedule is cleared on recovery."); + Assert.Less(em.GetComponentData(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(player).RespawnTick, + "An alive player clears any stale pending respawn schedule."); + Assert.AreEqual(50f, em.GetComponentData(player).Current, 1e-4f, "Alive health is untouched."); + } + } + } +} diff --git a/Assets/_Project/Tests/EditMode/PlayerRespawnSystemTests.cs.meta b/Assets/_Project/Tests/EditMode/PlayerRespawnSystemTests.cs.meta new file mode 100644 index 000000000..8559ae033 --- /dev/null +++ b/Assets/_Project/Tests/EditMode/PlayerRespawnSystemTests.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 5be985c7449132943ad509f0426bd2cb \ No newline at end of file diff --git a/Assets/_Project/Tests/EditMode/RegionTransitSystemTests.cs b/Assets/_Project/Tests/EditMode/RegionTransitSystemTests.cs new file mode 100644 index 000000000..ba3f83754 --- /dev/null +++ b/Assets/_Project/Tests/EditMode/RegionTransitSystemTests.cs @@ -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 +{ + /// + /// Plain-Entities EditMode tests for the server-only — 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. + /// + public class RegionTransitSystemTests + { + static (World world, SimulationSystemGroup group) MakeWorld(string name) + { + var world = new World(name); + var group = world.GetOrCreateSystemManaged(); + group.AddSystemToUpdateList(world.GetOrCreateSystem()); + 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(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(player).Region, + "The sender's player flips to the requested region."); + Assert.AreEqual(1005f, em.GetComponentData(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(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."); + } + } + } +} diff --git a/Assets/_Project/Tests/EditMode/RegionTransitSystemTests.cs.meta b/Assets/_Project/Tests/EditMode/RegionTransitSystemTests.cs.meta new file mode 100644 index 000000000..8294f1ad9 --- /dev/null +++ b/Assets/_Project/Tests/EditMode/RegionTransitSystemTests.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 727cdc6da6f5b8842ba5b311702e5224 \ No newline at end of file diff --git a/Assets/_Project/Tests/EditMode/ResourceHarvestSystemTests.cs b/Assets/_Project/Tests/EditMode/ResourceHarvestSystemTests.cs new file mode 100644 index 000000000..7dfa3e541 --- /dev/null +++ b/Assets/_Project/Tests/EditMode/ResourceHarvestSystemTests.cs @@ -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 +{ + /// + /// Plain-Entities EditMode tests for the server-only — 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. + /// + public class ResourceHarvestSystemTests + { + static (World world, SimulationSystemGroup group, Entity ledger) MakeWorld(string name) + { + var world = new World(name); + var group = world.GetOrCreateSystemManaged(); + group.AddSystemToUpdateList(world.GetOrCreateSystem()); + group.SortSystems(); + world.SetTime(new TimeData(elapsedTime: 0f, deltaTime: 1f / 60f)); + var em = world.EntityManager; + var ledger = em.CreateEntity(typeof(ResourceLedger)); + em.AddBuffer(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(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(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(node).Remaining, "A miss leaves Remaining untouched."); + Assert.IsTrue(em.Exists(proj), "A projectile that hits no node survives (no destroy-on-miss)."); + } + } + } +} diff --git a/Assets/_Project/Tests/EditMode/ResourceHarvestSystemTests.cs.meta b/Assets/_Project/Tests/EditMode/ResourceHarvestSystemTests.cs.meta new file mode 100644 index 000000000..b5da84703 --- /dev/null +++ b/Assets/_Project/Tests/EditMode/ResourceHarvestSystemTests.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 375130bf205b09744a1fc73efb304bfe \ No newline at end of file diff --git a/Assets/_Project/Tests/EditMode/StorageOpReceiveSystemTests.cs b/Assets/_Project/Tests/EditMode/StorageOpReceiveSystemTests.cs new file mode 100644 index 000000000..55b7779d8 --- /dev/null +++ b/Assets/_Project/Tests/EditMode/StorageOpReceiveSystemTests.cs @@ -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 +{ + /// + /// Plain-Entities EditMode tests for the server-only — the RPC handler + /// that applies deposit/withdraw ops to the shared storage container's replicated StorageEntry buffer. + /// A bare world with a SharedStorageContainer singleton (carrying the buffer) plus synthetic + /// StorageOpRequest + ReceiveRpcCommandRequest 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. + /// + public class StorageOpReceiveSystemTests + { + static (World world, SimulationSystemGroup group) MakeWorld(string name) + { + var world = new World(name); + var group = world.GetOrCreateSystemManaged(); + group.AddSystemToUpdateList(world.GetOrCreateSystem()); + 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(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(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(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(container); + Assert.AreEqual(0, buf.Length, "Withdrawing the whole stack drops the row entirely."); + } + } + } +} diff --git a/Assets/_Project/Tests/EditMode/StorageOpReceiveSystemTests.cs.meta b/Assets/_Project/Tests/EditMode/StorageOpReceiveSystemTests.cs.meta new file mode 100644 index 000000000..4e8a07d7b --- /dev/null +++ b/Assets/_Project/Tests/EditMode/StorageOpReceiveSystemTests.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 62eb6ac6dd96837468c04d1d89b39499 \ No newline at end of file diff --git a/Assets/_Project/Tests/EditMode/TelegraphTests.cs b/Assets/_Project/Tests/EditMode/TelegraphTests.cs new file mode 100644 index 000000000..18d26effd --- /dev/null +++ b/Assets/_Project/Tests/EditMode/TelegraphTests.cs @@ -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 +{ + /// + /// 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 + /// ( = 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). + /// + 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(); + group.AddSystemToUpdateList(world.GetOrCreateSystem()); + 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(e); + em.AddBuffer(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(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(husk).WindUpUntilTick, + "An in-range, ready Husk commits a wind-up until now + AttackWindupTicks."); + Assert.AreEqual(0, em.GetBuffer(player).Length, "No damage lands during the wind-up."); + + SetServerTick(world, expected); + group.Update(); // wind-up elapsed -> strike lands + Assert.AreEqual(1, em.GetBuffer(player).Length, "The strike lands exactly when the wind-up elapses."); + Assert.AreEqual(0u, em.GetComponentData(husk).WindUpUntilTick, "The wind-up resets after the strike."); + Assert.AreNotEqual(0u, em.GetComponentData(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(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(player).Length, "Leaving range mid-wind-up cancels the strike."); + Assert.AreEqual(0u, em.GetComponentData(husk).WindUpUntilTick, "The cancelled wind-up is cleared."); + } + } + } +} diff --git a/Assets/_Project/Tests/EditMode/TelegraphTests.cs.meta b/Assets/_Project/Tests/EditMode/TelegraphTests.cs.meta new file mode 100644 index 000000000..b978efe6f --- /dev/null +++ b/Assets/_Project/Tests/EditMode/TelegraphTests.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 4eceb2e9d3917444281e0513e22e34d0 \ No newline at end of file diff --git a/Assets/_Project/Tests/EditMode/TimedModifierExpirySystemTests.cs b/Assets/_Project/Tests/EditMode/TimedModifierExpirySystemTests.cs new file mode 100644 index 000000000..340559fc1 --- /dev/null +++ b/Assets/_Project/Tests/EditMode/TimedModifierExpirySystemTests.cs @@ -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 +{ + /// + /// Plain-Entities EditMode tests for the server-only — 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. + /// + public class TimedModifierExpirySystemTests + { + static (World world, SimulationSystemGroup group) MakeWorld(string name, uint serverTick) + { + var world = new World(name); + var group = world.GetOrCreateSystemManaged(); + group.AddSystemToUpdateList(world.GetOrCreateSystem()); + 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(e); + em.AddBuffer(e); + return e; + } + + static void AddTimedMod(EntityManager em, Entity e, byte target, float value, uint sourceId, uint untilTick) + { + em.GetBuffer(e).Add(new StatModifier { Target = target, Op = (byte)ModOp.Flat, Value = value, SourceId = sourceId }); + em.GetBuffer(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(p).Length, "Modifier persists before its expiry tick."); + Assert.AreEqual(1, em.GetBuffer(p).Length); + + SetServerTick(world, 300); + group.Update(); // due (300 not newer than 300) + Assert.AreEqual(0, em.GetBuffer(p).Length, "Expired modifier is removed by SourceId."); + Assert.AreEqual(0, em.GetBuffer(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(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(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(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(p), 7u); + Assert.AreEqual(1, removed, "Clear-by-SourceId removes exactly the matching row."); + var mods = em.GetBuffer(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(p).Length, "A modifier scheduled at the wrap sentinel still expires."); + } + } + } +} diff --git a/Assets/_Project/Tests/EditMode/TimedModifierExpirySystemTests.cs.meta b/Assets/_Project/Tests/EditMode/TimedModifierExpirySystemTests.cs.meta new file mode 100644 index 000000000..8fb77b657 --- /dev/null +++ b/Assets/_Project/Tests/EditMode/TimedModifierExpirySystemTests.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 3714a9107b437a0419aec060006cec76 \ No newline at end of file diff --git a/Assets/_Project/Tests/EditMode/TurretFireSystemTests.cs b/Assets/_Project/Tests/EditMode/TurretFireSystemTests.cs new file mode 100644 index 000000000..802f90ccc --- /dev/null +++ b/Assets/_Project/Tests/EditMode/TurretFireSystemTests.cs @@ -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 +{ + /// + /// Plain-Entities EditMode tests for the server-only (hitscan defense turret). + /// A bare world is seeded with a NetworkTime 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 + /// PlacedStructure.NextTick cooldown, and appends a DamageEvent{SourceNetworkId=-1}. These tests + /// pin range filtering, region gating, and the wrap-safe cooldown. + /// + public class TurretFireSystemTests + { + static (World world, SimulationSystemGroup group) MakeWorld(string name, uint serverTick) + { + var world = new World(name); + var group = world.GetOrCreateSystemManaged(); + group.AddSystemToUpdateList(world.GetOrCreateSystem()); + 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(e); + em.AddBuffer(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(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(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(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(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(husk).Length, "Fires on the first ready tick."); + + SetServerTick(world, 210); + group.Update(); // 210 < 230 -> still cooling down + Assert.AreEqual(1, em.GetBuffer(husk).Length, "No second shot while cooling down."); + + SetServerTick(world, 240); + group.Update(); // 240 >= 230 -> ready again + Assert.AreEqual(2, em.GetBuffer(husk).Length, "Fires again once the cooldown elapses."); + } + } + } +} diff --git a/Assets/_Project/Tests/EditMode/TurretFireSystemTests.cs.meta b/Assets/_Project/Tests/EditMode/TurretFireSystemTests.cs.meta new file mode 100644 index 000000000..2b5b858ef --- /dev/null +++ b/Assets/_Project/Tests/EditMode/TurretFireSystemTests.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: a86a8f9715eff4c45817718443589cdb \ No newline at end of file diff --git a/Assets/_Project/Tests/EditMode/WaveSystemTests.cs b/Assets/_Project/Tests/EditMode/WaveSystemTests.cs new file mode 100644 index 000000000..fde396581 --- /dev/null +++ b/Assets/_Project/Tests/EditMode/WaveSystemTests.cs @@ -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 +{ + /// + /// Plain-Entities EditMode tests for the server-only (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 Prefab-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. + /// + public class WaveSystemTests + { + static (World world, SimulationSystemGroup group) MakeWorld(string name, uint serverTick, byte cyclePhase) + { + var world = new World(name); + var group = world.GetOrCreateSystemManaged(); + group.AddSystemToUpdateList(world.GetOrCreateSystem()); + 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(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(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(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(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(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(dir).Phase, + "A fully-spawned wave with no live Husks returns to Lull."); + } + } + } +} diff --git a/Assets/_Project/Tests/EditMode/WaveSystemTests.cs.meta b/Assets/_Project/Tests/EditMode/WaveSystemTests.cs.meta new file mode 100644 index 000000000..b84cb823d --- /dev/null +++ b/Assets/_Project/Tests/EditMode/WaveSystemTests.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: b4f0f87ab30c466459b587c756994096 \ No newline at end of file diff --git a/Docs/Vault/00_Home/Home.md b/Docs/Vault/00_Home/Home.md index 26b73a0b7..86373565c 100644 --- a/Docs/Vault/00_Home/Home.md +++ b/Docs/Vault/00_Home/Home.md @@ -15,8 +15,8 @@ Multiplayer game on **Unity DOTS** (Entities) + **Netcode for Entities** (server - **Vision** → [[Pillars]] — design pillars & locked decisions · [[Identity]] — the fiction (sci-fi frontier colony) - **Game Design** → [[Systems_Index]] — per-system design docs - **Roadmap** → [[Milestones]] · [[Backlog]] -- **Sessions** → `07_Sessions/2026/` — dated work logs (latest: [[2026-06-03_Pre_M6_Cleanup]]) -- **Decisions** → `07_Sessions/_Decisions/` — decision records DR-001 … DR-012 · [[DR-001_Netcode_Test_Harness]] · latest [[DR-012_Aim_Controls_Cursor_Gamepad]] +- **Sessions** → `07_Sessions/2026/` — dated work logs (latest: [[2026-06-04_Polish_Backlog_Pass]]) +- **Decisions** → `07_Sessions/_Decisions/` — decision records DR-001 … DR-016 · [[DR-001_Netcode_Test_Harness]] · latest [[DR-016_Stage_G_Combat_Gameplay]] - **Meta** → [[Documentation_Protocol]] · [[Tags]] - **Templates** → [[Session_Log_Template]] · [[Decision_Record_Template]] diff --git a/Docs/Vault/06_Roadmap/Backlog.md b/Docs/Vault/06_Roadmap/Backlog.md index ec99a08f7..da6fe22a1 100644 --- a/Docs/Vault/06_Roadmap/Backlog.md +++ b/Docs/Vault/06_Roadmap/Backlog.md @@ -10,11 +10,13 @@ permalink: gamevault/06-roadmap/backlog Unordered pool of candidate work. Promote to a [[Milestones|milestone]] when committed. +> **2026-06-04 Polish & backlog-clear pass** ([[2026-06-04_Polish_Backlog_Pass]], [[DR-016_Stage_G_Combat_Gameplay]]) — delivered + validated (EditMode 86→127): hygiene/reconcile; **+system tests for every M6 server system**; centralized `Tuning.cs` consts; replicated **wave number** on the HUD; procedural **ambient + combat juice** (camera punch / FOV hit-stop, kill-shot fanfare, respawn shimmer, reticle lock-on tether); **ghost-prop reskin** (Storage/Turret/Node distinct materials) + **SSAO/ACES post verified**; **timed/removable modifiers**, **enemy knockback**, **Husk attack telegraph**; and the **aim-drift fix** (movement-based camera look-ahead). Remaining from the selected Stage-G slice: ranged **Spitter**, **multi-prefab abilities**, + small fold-ins (storage proximity-gate, pickup auto-grant, standalone-debug RPC). Then Stages H (controls/UI) + I (multi-client harness + operator live runs). + - [x] Upgrade Unity 6.4 → 6.6 — done (now `6000.6.0a6`). Entities/Collections/Graphics → 6.5.0; **Netcode → 6.6.0 and Physics → 6.5.0 also renumbered into the editor line** (not independent 1.x as [[DR-001_Netcode_Test_Harness]] assumed). See [[2026-05-30_M1_Player_Slice]]. - [x] Define the core gameplay loop and the first predicted player ghost — delivered as M1 ([[2026-05-30_M1_Player_Slice]]). -- [ ] **Re-validate the M1 play-tick on a stable Unity 6.x** — live runtime blocked on the 6.6 alpha ([[DR-002_Unity66_Alpha_Netcode_Transport]]); optionally reproduce with the `networked-cube` sample to file a bug. +- [x] **Re-validate the M1 play-tick on a stable Unity 6.x** — moot/subsumed 2026-06-04: M1–M6 are all runtime-validated on the stable **6.4.7** line (the 6.6 alpha netcode bug never affected 6.4.7). The original "blocked on 6.6 alpha" framing ([[DR-002_Unity66_Alpha_Netcode_Transport]]) no longer applies. - [ ] Replace template `SampleScene` with a dedicated bootstrap scene + gameplay subscene. -- [ ] Optional template cleanup: remove `com.unity.visualscripting`, `Assets/TutorialInfo/`, `Assets/Readme.asset` (delete each asset **with** its `.meta`). +- [x] Optional template cleanup: remove `com.unity.visualscripting`, `Assets/TutorialInfo/`, `Assets/Readme.asset` — **done 2026-06-03** (pre-M6 cleanup; see the "2026-06-03 Visual & Controls Polish" section below + [[2026-06-03_Pre_M6_Cleanup]]). Duplicate of the now-checked item there. - [x] Decide **relay provider** before M4 — resolved: **Direct IP/LAN now, Unity Relay later** ([[DR-005_M4_Connection_Model_Direct_IP]], [[2026-06-01_M4_LAN_CoOp_And_Classification_Fix]]). - [x] Decide home-base **grid 2D vs 3D** before M6 — resolved 2026-06-02: **planar single-level `int2` grid**, CellSize 1.0, 32×32 plot (full 3D/stacked deferred). Locked in `BaseGridMath` — [[DR-008_M5_HomeBase_BaseLayer_Storage]]. - [ ] Decide **production replication** (predicted vs server-only) before M7 (automation). @@ -24,12 +26,12 @@ Unordered pool of candidate work. Promote to a [[Milestones|milestone]] when com - [x] **M2 follow-up — player death/respawn** — done in **M5.5**: derived `Dead` enableable gate (from replicated Health) + server `PlayerRespawnSystem` (full HP + reposition to base after a delay). [[DR-009_GameFeel_Identity_FirstBlood]] - [ ] M2 polish — projectile/dummy visuals (primitive meshes/materials currently); optional predicted client-side auto-target if the soft server reconcile feels off. - [ ] **M3 follow-up — UI/icon/description pipeline** for abilities (managed lookup keyed by `AbilityId`, off the blob). Deferred from M3 ([[2026-05-31_M3_Data_Driven_Abilities]]). -- [ ] **M3 follow-up — timed / removable modifiers** (expiry on `NetworkTick`, `ClearByType` via `StatModifier.SourceId`). M3 modifiers are permanent-once-granted. +- [x] **M3 follow-up — timed / removable modifiers** — done 2026-06-04 ([[DR-016_Stage_G_Combat_Gameplay]]): a SEPARATE server-only `TimedModifier{SourceId,UntilTick}` buffer (keeps the replicated `StatModifier` layout byte-identical → no re-bake) + `TimedModifierExpirySystem` (expiry on `NetworkTick`) + `TimedModifierUtil.RemoveBySourceId` (clear-by-type). +4 EditMode tests. - [ ] **M3 follow-up — multi-prefab abilities** (a per-ability *different* projectile ghost) needs `ProjectileClassificationSystem` generalized beyond the single shared prefab. - [ ] **M3 follow-up — standalone-server debug modifier path** via `IRpcCommand` (current `DebugModifierInjectionSystem` is in-editor single-process only). - [x] **M3 follow-up — rate-limited turning** — done 2026-06-03 (pre-M6 cleanup): `PlayerAimSystem` now rotates `PlayerFacing` toward the aim target at `EffectiveCharacterStats.TurnRateRadiansPerSec` (authored 720°/s) instead of snapping; deterministic in the predicted loop (fixed-step `dt`, replays on rollback). [[2026-06-03_Pre_M6_Cleanup]]. - [ ] **M3 polish — pickup visuals** (primitive sphere/default material currently); pickup auto-grant feel (continuous overlap). -- [ ] **M5 follow-up — base/expedition subscene split + streaming (Option C)**: the persistent-space split the locked world design ultimately needs (`SceneSystem.LoadSceneAsync`/`UnloadScene`, per-world load on the listen-server, enter-expedition/return-to-base transition). Deferred to its own world-architecture milestone — M6/M7 only need the anchor + grid, now done ([[DR-008_M5_HomeBase_BaseLayer_Storage]]). The physics-in-prediction + base-layer slices of M5 are done ([[DR-006_M5_Physics_In_Prediction]], [[DR-008_M5_HomeBase_BaseLayer_Storage]]). +- [ ] **M5 follow-up — base/expedition subscene split + streaming (Option C)** — **superseded 2026-06-03 by [[DR-013_M6_Aether_Cycle_Region_Split]]** (coordinate-region + per-connection `GhostRelevancy` delivered the split without `SceneSystem` streaming). Kept only as a note for a future larger-world milestone where true async streaming is wanted; do NOT build streaming now. Original framing: the persistent-space split the locked world design ultimately needs (`SceneSystem.LoadSceneAsync`/`UnloadScene`, per-world load on the listen-server, enter-expedition/return-to-base transition). Deferred to its own world-architecture milestone — M6/M7 only need the anchor + grid, now done ([[DR-008_M5_HomeBase_BaseLayer_Storage]]). The physics-in-prediction + base-layer slices of M5 are done ([[DR-006_M5_Physics_In_Prediction]], [[DR-008_M5_HomeBase_BaseLayer_Storage]]). - [ ] **M5 follow-up — shared-storage disk persistence** (host-only): runtime structures don't exist until M6, so nothing to save yet; add a thin per-record serialization slice (replayed through M6's placement path) after M6. `BaseAnchor`/`StorageEntry` are already flat/serialization-friendly. - [ ] **M5 follow-up — storage interaction polish**: proximity gate the deposit/withdraw (the container carries `HitRadius`); real item/UI model beyond the fixed test item; multi-writer ordering beyond first-come server apply. - [ ] **M5 follow-up — multi-client shared storage**: validate two clients see identical shared-storage buffer state (pairs with the deferred M5b multi-client interpolation + M4 two-build tests). diff --git a/Docs/Vault/06_Roadmap/Milestones.md b/Docs/Vault/06_Roadmap/Milestones.md index 47615493c..ff39d64e1 100644 --- a/Docs/Vault/06_Roadmap/Milestones.md +++ b/Docs/Vault/06_Roadmap/Milestones.md @@ -20,6 +20,7 @@ permalink: gamevault/06-roadmap/milestones | **— 2026-06-03 Visual & controls polish —** | Non-milestone polish layered on M5.5 (no mechanical rework): HDRP→URP art import + reusable converter; a cohesive **Synty** sci-fi colony world (cosmetic SampleScene GameObjects) + **GabrielAguiar** combat VFX; **KBM mouse-cursor aim + gamepad aim** with last-actuation device auto-switch (rides the existing `PlayerInput.Aim` ghost field). | ✅ Done 2026-06-03 — [[DR-010_Art_Import_URP_Conversion_Visual_Upgrade]], [[DR-011_Synty_World_VFX_Integration]], [[DR-012_Aim_Controls_Cursor_Gamepad]] | | **— 2026-06-03 Pre-M6 cleanup —** | Loose-ends pass before M6: vault roadmap reconcile, Unity-template + orphaned-material removal, rate-limited turning, console/runtime health gate. | ✅ Done 2026-06-03 — [[2026-06-03_Pre_M6_Cleanup]] | | **M6 — The Aether Cycle (core loop)** | Reframed from "grid build placement" into the first vertical slice of the **core game loop**: Expedition (gather) → Defend (wave) → Build/Charge (spend), persistent base + procedural sorties, escalating toward a goal. Build placement is now Stage 3 of this milestone. | 🚧 In progress 2026-06-03 — **Stages 0–4 done + runtime-validated** on 6.4.7 (M6 core loop systems complete): **base/expedition split via coordinate-region + `GhostRelevancy`** (player transit despawns/re-grants the other region's ghosts; server==client); a **server phase-director** (Expedition→Defend→Build→Expedition auto-cycle, cycle 1→2, Husk `WaveSystem` only in Defend, escalation 4→6); and **resources + harvest** — a **global CycleDirector ghost** carrying the replicated `CycleState` + a shared resource **ledger** (relevant in every region, unlike the base storage), a procedural **expedition field** (8 resource-node ghosts seeded per cycle, region-scoped), and a tunnel-safe **harvest** sweep depositing into the ledger; client **HUD** shows phase + resource counts. Supersedes DR-008's "split requires streaming" framing. **Stage 3** (generic automation-ready **structure model** + data-driven catalog + grid **build-placement** RPC with co-op-atomic commit + a hitscan **turret** that auto-defends + **ability tiers** via a bounded StatModifier) and **Stage 4 goal meter** are **done + validated** (turret placed/Ore-deducted/replicated; two same-tick requests → one build; turrets killed the wave; ability damage 20→30 bounded; goal increments per cycle). Disk-persistence **writer deferred to post-M7** (M7-additive surface — tick fields + frozen schema — baked now); the structure model is the M7 production-chain foundation. Playable walk-in-gate loop with build/spend, visible in the HUD. — [[DR-014_M6_Build_Structures_Automation_Foundation]] — [[DR-013_M6_Aether_Cycle_Region_Split]], [[2026-06-03_M6_Aether_Cycle_CoreLoop]] | +| **— 2026-06-04 Polish & backlog-clear pass —** | Comprehensive *sequential* polish across hygiene/reconcile, system-level tests, HUD (TMP + replicated wave number), procedural audio + combat juice, ghost-prop reskin + post-processing, new gameplay content, controls/UX, and a validation-harness + operator handoff. Clears the open [[Backlog]]. | 🚧 In progress 2026-06-04 | | **M7 — Automation** | Self-running tick-based production chains (deterministic offline catch-up) | ⬜ | Promote items from [[Backlog]] here when committed. \ No newline at end of file diff --git a/Docs/Vault/07_Sessions/2026/2026-06-04_Polish_Backlog_Pass.md b/Docs/Vault/07_Sessions/2026/2026-06-04_Polish_Backlog_Pass.md new file mode 100644 index 000000000..2914e2d6c --- /dev/null +++ b/Docs/Vault/07_Sessions/2026/2026-06-04_Polish_Backlog_Pass.md @@ -0,0 +1,75 @@ +--- +date: 2026-06-04 +type: session +tags: [session, polish, backlog, testing, hygiene, netcode, audio] +--- + +# Session 2026-06-04 — Polish & Backlog-Clear Pass (Stages A–G) + +## Goal + +Operator asked to **clear the open backlog and do a comprehensive polish pass on everything**, executed **sequentially** at full scope. A critical read (3 explore audits + direct verification) found the game systems-complete through M6, single-client runtime-validated, console clean, netcode well-built and convention-compliant. The plan (`~/.claude/plans/melodic-yawning-hearth.md`) sequences the work into 9 dependency-ordered stages **A→I**, each landing cleanly (compile → console → tests → runtime spot-check). + +This log covers **Stages A, B, C, D, and E(audio)**. Stages A–C were done on the unfocused editor; the operator then focused Unity, enabling the Burst-edit + ghost-re-bake + Play-mode validation for **D** (one new netcode surface, validated server==client in Play) and **E(audio)** (validated running in Play). The remaining Stage E *feel* tweaks + Stages F–I continue next. + +## Done + +### Stage A — Hygiene, reconcile, const-centralization +- **Backlog reconciled** (`06_Roadmap/Backlog.md`): M1-revalidation → moot/subsumed (M1–M6 validated on stable 6.4.7); duplicate "template cleanup" checked off; M5 subscene-split/streaming → **superseded by [[DR-013_M6_Aether_Cycle_Region_Split]]** (region + GhostRelevancy; do not build streaming). +- **Milestones**: added the **2026-06-04 Polish & backlog-clear pass** row. +- **Home MOC** un-stale'd: latest session pointer + decisions range DR-001…DR-015. +- **`Tuning.cs`** created (`Simulation/Tuning.cs`) — central, Burst-safe home for the previously-buried balance consts. +- Verified **no stale "74" test-count claim** in CLAUDE.md (audit misattribution; the "74/74" is the historical M5.5 Milestones row, left as-is). + +### Stage B — System-level EditMode tests for M6 systems +**+31 cases / 9 files** (86 → **117** green): `StorageOpReceiveSystemTests`, `TurretFireSystemTests`, `ExpeditionGateSystemTests`, `CyclePhaseSystemTests`, `PlayerRespawnSystemTests`, `ResourceHarvestSystemTests` (incl. N-projectile at-most-once destroy), `WaveSystemTests`, `BuildPlaceSystemTests` (incl. **co-op same-cell atomicity**), `RegionTransitSystemTests`. Plain-Entities pattern; immediate-ECB-playback so no separate ECB system. + +### Stage C — wire systems to `Tuning` (code) +- `ResourceHarvestSystem.k_ProjectileRadius` and `AbilityUpgradeSystem` `UpgradeSourceId`/`TierStep`/`CostAmount` now derive from `Tuning.*` (value-identical const-from-const; comments preserved; not a query-set change → no Burst-binary hazard). 117/117 green. + +### Stage D — replicated wave number (the ONE new netcode surface) ✅ Play-validated +- Added `[GhostField] int WaveNumber` to `CycleState`; `CyclePhaseSystem` is the single writer (syncs it each tick from the server-only `WaveState`); `HudSystem` shows "WAVE N — M HUSKS" during Defend. +- +1 EditMode test (118 green). **Play-mode validated server==client**: both worlds reported identical `CycleState` (Wave 0 at rest, and **Wave 7 after seeding the server `WaveState`** → synced + replicated). **No "not a known Burst entry point" spam** after the forced CycleDirector re-bake (focused editor) and **no errors** — only the pre-existing in-editor "Server Tick Batching" warnings (a known backlog item, not a regression). +- **TMP HUD migration deferred** — would add a TMP font-asset dependency, against the project's asset-free HUD convention. Kept legacy `Text`; documented as optional future polish. + +### Stage E(audio) — procedural ambient + phase stingers ✅ Play-validated +- New **`AmbientAudioSystem`** (`Client/Presentation`, client-only `SystemBase` in `PresentationSystemGroup`, observer-only): a low (vol 0.10) **seamless-looping procedural drone** (frequencies snapped to integer cycles/buffer so the loop has no click) + short procedural **phase-change stingers** (Expedition/Defend/Build, with a tenser "wave incoming" cue + a Defend volume swell). Asset-free (`AudioClip.Create`, mirrors `CombatFeedbackSystem.MakeClip`); never mutates the sim. +- **Play-validated**: `~AmbientAudio` AudioSource `isPlaying=true, loop=true, vol=0.10, clip=176400 samples (4s)`, no runtime/job-safety errors. + +### Stage E(feel) — combat juice (4 client-only features) ✅ operator-approved +- New **`FeelConfig`** static (`Client/Presentation/FeelConfig.cs`) — ~22 live-pokeable knobs, reset on play-enter via `[RuntimeInitializeOnLoadMethod(SubsystemRegistration)]` (the `AimPresentation` precedent). Client-presentation only; never read from Burst. +- **Implemented (observer-only, validated in Play):** (1) hit camera-punch routed through `FeelConfig` + a netcode-safe FOV "hit-stop" kick (`PrototypeCameraRig.PunchFov` — NEVER `Time.timeScale`); (2) kill-shot fanfare (amplified Husk-death-on-prune burst/shake/SFX); (3) respawn shimmer (local Health 0→positive edge in `CombatFeedbackSystem`); (4) reticle lock-on tether in `AimReticleSystem` (client computes nearest living Husk itself — no replicated target exists — gamepad-gated `LineRenderer`). +- Defaults **adversarially reviewed** (3-critic + synthesis Workflow) and **operator-approved live** ("feels good") in a forced Defend wave (10 Husks, server==client). Console clean. + +### Stage F — ghost-prop reskin + post-processing ✅ (values verified) +- **Prop reskin** — distinct material *assets* (persist into Play, no shared-material bleed; assigned via `PrefabUtility.SaveAsPrefabAsset`, the CLAUDE.md-safe prefab-asset edit) so props stop reading as identical "batteries": Storage → new **`M_Storage`** (dark steel + cool-blue emissive), Turret → new **`M_Turret`** (green ally-tech, distinct from orange Husks), ResourceNode → new **`M_ResourceNode`** (amber/gold), UpgradePickup keeps its glow. Found `M_Env_Storage` was a blank auto-stub (internal name "Universal Render Pipeline/Lit", white base) — replaced. Verified each prop's material/shader/base+emission **values** (not just a render). +- **Post-processing verified already complete:** SSAO renderer feature present + `isActive` on the active `PC_Renderer`; URP `colorGradingMode=HighDynamicRange` (correct for ACES); `PostFX_DarkSciFi` = Bloom + Tonemapping(ACES) + ColorAdjustments + Vignette, all active. Backlog "add SSAO" satisfied. +- **Deferred (diminishing returns, plan-noted):** reflection probe, ORM-repack / deeper material fidelity, a proper turret MESH (battery mesh kept — distinct material is the 80/20; a real turret mesh needs a Synty asset + Entities-Graphics verification). Materials are assets → instantly re-tintable on operator request. + +### Stage G — new gameplay (in progress; design-reviewed) ✅ timed modifiers · knockback · telegraph +- **Adversarial design review** (4-agent Workflow: netcode/determinism · reuse · test-plan → synthesis) gave per-feature specs (re-bake?/determinism/files/tests). No-re-bake: timed modifiers, knockback, debug-RPC, storage-gate, pickup, multi-prefab. Re-bake: Spitter (new ghost types) + telegraph (one `[GhostField]` on the Husk). +- **Timed/removable modifiers ✅** — server-only `TimedModifier{SourceId,UntilTick}` buffer (StatModifier layout untouched → provably no re-bake) + `TimedModifierExpirySystem` (removes the matching StatModifier when due; replicates via the existing buffer; `StatRecomputeSystem` unchanged) + `TimedModifierUtil.RemoveBySourceId` (clear-by-type). +4 tests. +- **Enemy knockback ✅** — server-only `KnockbackState{Dir,Speed,UntilTick}` (no re-bake; Husk position already replicates), stamped by `ProjectileDamageSystem` on hit (backward-compatible via `TryGetSingleton` so existing tests pass), applied by `EnemyAISystem` as the SOLE position writer (recoil replaces seek + suppresses the strike). Tunable `Tuning.KnockbackSpeed`(8, 0=off) / `KnockbackDurationTicks`(8). +3 tests. +- **Husk attack telegraph ✅ (re-bake)** — replicated `[GhostField] AttackWindup.WindUpUntilTick` on the Husk; `EnemyAISystem` restructured to a 2-phase strike (commit wind-up when in-range + cooldown-ready → strike at expiry; cancel on leave-range; a knocked Husk doesn't wind up); client cue in `CombatFeedbackSystem` (observer, warns on the wind-up-start edge). Tunable `Tuning.AttackWindupTicks`(18 ≈ 0.3s, 0/1 = instant). +2 tests. **Re-bake Play-validated: server==client (4 Husks, 2 winding up, identical maxWindTick), no Burst-cache spam, no errors. 127 EditMode green.** + +### Aim-drift fix (operator request, end of session) ✅ +- **Symptom:** holding the cursor near the player, the aim/reticle swims without mouse movement when the character turns / the camera pans — feels inaccurate. **Cause:** the camera look-ahead led toward `PlayerFacing` (aim) → turning to face a near-cursor panned the camera → the live cursor screen-ray re-projected onto a different ground point → aim drifted (worst near the player, short lever arm). **Fix:** `PrototypeCameraRig` now leads toward **MOVEMENT** (`PlayerInput.Move`), not aim — a stationary aim no longer pans the camera; the cam still anticipates where you're going. Researched (gamedeveloper.com dual-stick; Relic Hunters Zero "ignore the crosshair unless not moving"). EditMode **127/127**, console clean; "feels accurate now" = operator feel-test (tunable: `AimLeadDistance` 0 = no lead). Recorded in [[DR-012_Aim_Controls_Cursor_Gamepad]] Refinement 2. + +## Decisions + +- **Created [[DR-016_Stage_G_Combat_Gameplay]]** (timed-modifier / knockback / telegraph architecture + the deferred Spitter / multi-prefab) and **amended [[DR-012_Aim_Controls_Cursor_Gamepad]]** (Refinement 2: movement-based camera look-ahead supersedes the facing-based look-ahead). + +- **Debug systems kept `#if UNITY_EDITOR`-gated in place** (not moved to a new Editor asmdef). Already build-stripped; tightly coupled to their Client/Server worlds; a separate Editor asmdef risks DOTS system-discovery surprises for no functional gain. `execute_code` statics preserved. +- **RegionRelevancySystem is integration/operator-validated, not unit-tested.** `Unity.NetCode.GhostRelevancy` has an **internal** constructor + `readonly` `GhostRelevancySet` → the singleton genuinely cannot be constructed from the test assembly. Relies on existing runtime validation ([[DR-013_M6_Aether_Cycle_Region_Split]]) + the Stage-I multi-client checklist. (Verified type shapes via `unity_reflect`.) + +## Open / deferred + +- **Enemy knockback + Husk attack-telegraph → re-homed to Stage G** (server/netcode, not client presentation): the 3-critic review proved both inherently touch the sim/netcode surface — knockback fights `EnemyAISystem`'s per-tick `LocalTransform` write on the interpolated ghost (needs a server knockback-state component + EditMode test); telegraph needs a NEW replicated wind-up signal (`[GhostEnabledBit]`/`[GhostField]` on the Husk → re-bake) because `EnemyAttackCooldown`/`EnemyStats`/`AttackRange` are server-only. Each behind an adversarial review + test. +- **Stage C decor-LOD client-only split** — verify deferred (wants the gameplay subscene open; tick-budget optimization, not correctness). +- **Stages F–I**: ghost-prop reskin + post-processing (F); new content — Spitter/boss/timed-modifiers/multi-prefab-abilities/storage-proximity/standalone-debug-RPC (G); controls — rebind + ability slots + ability UI (H); validation harness + operator-required live runs — two-build LAN co-op, live fire, standalone server perf (I). + +## Next + +- **Polish stages A–F are DONE + validated** (118 EditMode green; the one new netcode surface — `CycleState.WaveNumber` — proven server==client in Play; feel operator-approved; props reskinned + post verified). +- **Stage G done so far:** timed modifiers, knockback, attack telegraph (all validated). **Remaining:** the ranged **Spitter** (the large one — new `EnemySpitter` + new interpolated `EnemyProjectile` ghost prefabs + spit-fire/move/damage-vs-players systems; re-bake = new ghost TYPES, not an existing-ghost serializer change) and **multi-prefab abilities** (generalize the non-Burst `ProjectileClassificationSystem` to a ghost-type SET; core correctness — no owner-client double-spawn — is Play-only). Small fold-ins still open: standalone-server debug RPC, storage proximity-gate, pickup auto-grant (confirm intent). +- **Stage H** (rebindable controls + ability slots + ability icon/UI) and **Stage I** (thin-client/MPPM harness + operator-required live runs: two-build LAN co-op, live fire, standalone server perf — the standing ~1.25–1.75 ticks/frame question). diff --git a/Docs/Vault/07_Sessions/_Decisions/DR-012_Aim_Controls_Cursor_Gamepad.md b/Docs/Vault/07_Sessions/_Decisions/DR-012_Aim_Controls_Cursor_Gamepad.md index e8a27ddf5..c159b9ceb 100644 --- a/Docs/Vault/07_Sessions/_Decisions/DR-012_Aim_Controls_Cursor_Gamepad.md +++ b/Docs/Vault/07_Sessions/_Decisions/DR-012_Aim_Controls_Cursor_Gamepad.md @@ -52,6 +52,12 @@ Operator feedback after the first pass: make KBM aiming feel more natural / worl - **Skipped** (with reasons): constant-screen-size scaling (fixed-distance cam), aim line (redundant with the ring), KBM enemy-magnetism (would undermine the intentional gamepad-only assist). - Validated: EditMode **86/86**, console clean; runtime (focused) KBM ring active at the cursor ground point, OS cursor hidden, `TargetFacing` published, no exceptions. +## Refinement 2 — movement-based camera look-ahead (aim-drift fix, 2026-06-04) + +Operator feedback: holding the mouse cursor NEAR the player, the aim/reticle "swims" without moving the mouse when the character turns or the camera pans — feels inaccurate. **Root cause:** the look-ahead in Refinement 1 led the framed point toward `PlayerFacing` (the aim). The KBM reticle/aim is the LIVE cursor screen-ray re-projected onto the ground each frame, so turning to face a near-cursor moved the camera (aim → facing → look-ahead → camera pan), and a stationary mouse then re-projected to a DIFFERENT ground point → the aim direction drifted. Worst near the player (short lever arm → high angular sensitivity). Research (gamedeveloper.com dual-stick controls; Relic Hunters Zero) confirmed that coupling the camera to the crosshair/aim causes exactly this and is conditioned-out in shipped games. + +**Fix (supersedes the facing-based look-ahead in Refinement 1):** the camera look-ahead now leads toward **MOVEMENT** (`PlayerInput.Move`), not aim/facing. `PrototypeCameraTargetSystem` publishes `PrototypeCameraRig.TargetMoveDir`; the rig leads toward it. A stationary aim no longer pans the camera → the reticle is rock-stable while turning/aiming; the camera still anticipates where the player is MOVING. `AimLeadDistance` (default 2.5, tunable, 0 = off) is unchanged in magnitude — only its direction source changed. EditMode **127/127**, console clean. Live "feels accurate now" = operator (focused Game view). + ## Open / deferred - The **real mouse-cursor path + live device auto-switch** need a **focused** Game view — the unfocused editor can't inject mouse position / device actuation (validated the replication, math, gate, and reticle headlessly via `DebugInputInjectionSystem` + forced scheme). Operator focused click-test pending. diff --git a/Docs/Vault/07_Sessions/_Decisions/DR-016_Stage_G_Combat_Gameplay.md b/Docs/Vault/07_Sessions/_Decisions/DR-016_Stage_G_Combat_Gameplay.md new file mode 100644 index 000000000..19e38224f --- /dev/null +++ b/Docs/Vault/07_Sessions/_Decisions/DR-016_Stage_G_Combat_Gameplay.md @@ -0,0 +1,35 @@ +--- +id: DR-016 +title: Stage G combat gameplay slice — timed modifiers, knockback, attack telegraph (+ deferred Spitter/multi-prefab) +status: accepted +date: 2026-06-04 +tags: +- decision +- gameplay +- combat +- netcode +- enemies +- modifiers +permalink: gamevault/07-sessions/decisions/dr-016-stage-g-combat-gameplay +--- + +# DR-016 — Stage G combat gameplay slice + +## Context + +The 2026-06-04 polish pass ([[2026-06-04_Polish_Backlog_Pass]]) reached a "new gameplay" stage after polish stages A–F. The operator selected a slice: ranged **Spitter** enemy, **knockback + attack telegraph**, and **timed/removable modifiers + multi-prefab abilities** (small fold-ins — storage proximity-gate, pickup auto-grant, standalone-debug RPC — riding along). Each is netcode-touching, so a **4-agent adversarial design-review Workflow** (netcode/determinism · reuse · test-plan → synthesis) ran first and produced per-feature specs (re-bake?, determinism, files, EditMode tests). This DR records the architecture the review locked and what shipped. + +## Decision + +- **Timed/removable modifiers — a SEPARATE server-only `TimedModifier{SourceId,UntilTick}` buffer, NOT a field on the replicated `StatModifier`.** Adding ANY member (even non-`[GhostField]`) to a `[GhostField]` buffer element regenerates its serializer/stride/hash = an effective re-bake; the separate buffer keeps `StatModifier` byte-identical. `TimedModifierExpirySystem` (server, plain `SimulationSystemGroup`) removes the matching `StatModifier` by `SourceId` when due (wrap-safe `NetworkTick`); the shortened `[GhostField]` buffer auto-replicates and `StatRecomputeSystem` reverts the effective stat unchanged. `TimedModifierUtil.RemoveBySourceId` is the clear-by-type helper. **No re-bake.** +- **Enemy knockback — server-only `KnockbackState{Dir,Speed,UntilTick}`, applied INSIDE `EnemyAISystem`.** Husk position already replicates (stock `LocalTransform` variant), so knockback needs NO new `[GhostField]` (no re-bake). The one desync trap is two writers of the Husk's `Position`; `EnemyAISystem` is the sole writer, so knockback is blended there (recoil REPLACES seek + suppresses the strike for the window). `ProjectileDamageSystem` stamps `KnockbackState` on hit (it has the projectile heading; backward-compatible via `TryGetSingleton` so existing tests pass). Tuned by `Tuning.KnockbackSpeed`(8, 0=off) / `KnockbackDurationTicks`(8). **No re-bake.** +- **Husk attack telegraph — replicated `[GhostField] AttackWindup.WindUpUntilTick` on the Husk (a re-bake) + a 2-phase strike.** The client has NONE of the timing inputs (`EnemyStats` / `EnemyAttackCooldown` are server-only), so the wind-up signal MUST be replicated. A `uint` tick (not a `[GhostEnabledBit]`) so the client cue can ramp/countdown and survive a missed snapshot. `EnemyAISystem` restructured: when first in-range + cooldown-ready it commits `WindUpUntilTick = now + Tuning.AttackWindupTicks` and damages nothing; strikes when the tick elapses; cancels on leave-range; a knocked Husk doesn't wind up. Client cue = observer in `CombatFeedbackSystem` (warns on the wind-up-start edge). Tuned by `Tuning.AttackWindupTicks`(18 ≈ 0.3s, 0/1 = instant). **Re-bake** (done on a focused editor). +- **Deferred (still selected, not yet built):** ranged **Spitter** (two NEW ghost prefabs — `EnemySpitter` + an interpolated `EnemyProjectile` — + spit-fire/move/damage-vs-players systems; the enemy projectile must NOT reuse the player's predicted projectile or `ProjectileDamageSystem`'s faction-blind target query; the plain-group sweep must use a stored `LastStep`, not `DeltaTime`, per the DR-013 dt-trap) and **multi-prefab abilities** (generalize the non-Burst `ProjectileClassificationSystem` to a ghost-type SET; core correctness — no owner-client double-spawn — is Play-only, `NetCodeTestWorld` being internal in 1.13.2). Small fold-ins (storage proximity-gate, pickup auto-grant, standalone-debug RPC) also pending. + +## Consequences + +- **Validated (6.4.7):** EditMode **86 → 127** (timed-mods +4, knockback +3, telegraph +2; the M6 system tests from Stage B are the rest). Console clean throughout. The telegraph re-bake was **Play-validated server==client** (4 Husks both worlds, 2 winding up, identical `maxWindTick`; no "not a known Burst entry point" spam; no errors). Knockback's existing-test backward-compat confirmed. +- **Reusable rulings:** never mutate a replicated buffer-element layout (use a separate server-only tracking buffer); server-only state that drives an already-replicated field needs no re-bake; a replicated signal is mandatory when the client lacks the timing inputs; keep ONE writer of an interpolated ghost's transform; `replace_method` (MCP `script_apply_edits`) does NOT handle `struct` ISystems — use line/anchor edits. +- **Tunables** let the operator dial or disable each gameplay change (knockback speed 0 = off; windup ticks 0/1 = instant/legacy). + +See [[2026-06-04_Polish_Backlog_Pass]]. Builds on [[DR-009_GameFeel_Identity_FirstBlood]] (Husk/feel), [[DR-013_M6_Aether_Cycle_Region_Split]] (dt-trap, region gating), [[DR-004_M3_DataDriven_Abilities_Modifiers]] (StatModifier).