diff --git a/Assets/Settings/UniversalRP.asset b/Assets/Settings/UniversalRP.asset index 9333679..773f9b2 100644 --- a/Assets/Settings/UniversalRP.asset +++ b/Assets/Settings/UniversalRP.asset @@ -18,6 +18,7 @@ MonoBehaviour: m_RendererData: {fileID: 0} m_RendererDataList: - {fileID: 11400000, guid: 424799608f7334c24bf367e4bbfa7f9a, type: 2} + - {fileID: 11400000, guid: 05aadf49be3030546aafc018b71af214, type: 2} m_DefaultRendererIndex: 0 m_RequireDepthTexture: 0 m_RequireOpaqueTexture: 0 diff --git a/Assets/_Project/Art/Defense25DComparison.meta b/Assets/_Project/Art/Defense25DComparison.meta new file mode 100644 index 0000000..198f6cf --- /dev/null +++ b/Assets/_Project/Art/Defense25DComparison.meta @@ -0,0 +1,3 @@ +fileFormatVersion: 2 +guid: 323f26e40e9341819b74539ab1205e3a +folderAsset: yes diff --git a/Assets/_Project/Art/Defense25DComparison/Base.mat b/Assets/_Project/Art/Defense25DComparison/Base.mat new file mode 100644 index 0000000..535f563 --- /dev/null +++ b/Assets/_Project/Art/Defense25DComparison/Base.mat @@ -0,0 +1,71 @@ +%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: Base + m_Shader: {fileID: 4800000, guid: 650dd9526735d5b46b79224bc6e94025, type: 3} + m_Parent: {fileID: 0} + m_ModifiedSerializedProperties: 0 + m_ValidKeywords: [] + m_InvalidKeywords: [] + m_LightmapFlags: 4 + 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} + - _MainTex: + 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 + - _BlendOp: 0 + - _Cull: 2 + - _Cutoff: 0.5 + - _DstBlend: 0 + - _DstBlendAlpha: 0 + - _QueueOffset: 0 + - _SampleGI: 0 + - _SrcBlend: 1 + - _SrcBlendAlpha: 1 + - _Surface: 0 + - _XRMotionVectorsPass: 1 + - _ZWrite: 1 + m_Colors: + - _BaseColor: {r: 0.45, g: 0.77, b: 0.82, a: 1} + - _Color: {r: 0.45, g: 0.77, b: 0.82, a: 1} + m_BuildTextureStacks: [] + m_AllowLocking: 1 +--- !u!114 &6200414664021777617 +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/Art/Defense25DComparison/Base.mat.meta b/Assets/_Project/Art/Defense25DComparison/Base.mat.meta new file mode 100644 index 0000000..90bcde6 --- /dev/null +++ b/Assets/_Project/Art/Defense25DComparison/Base.mat.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 27680ecdcb0d9c4408af43beb753bd64 +NativeFormatImporter: + externalObjects: {} + mainObjectFileID: 2100000 + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/_Project/Art/Defense25DComparison/ColliderBoundary.mat b/Assets/_Project/Art/Defense25DComparison/ColliderBoundary.mat new file mode 100644 index 0000000..acecbec --- /dev/null +++ b/Assets/_Project/Art/Defense25DComparison/ColliderBoundary.mat @@ -0,0 +1,71 @@ +%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: ColliderBoundary + m_Shader: {fileID: 4800000, guid: 650dd9526735d5b46b79224bc6e94025, type: 3} + m_Parent: {fileID: 0} + m_ModifiedSerializedProperties: 0 + m_ValidKeywords: [] + m_InvalidKeywords: [] + m_LightmapFlags: 4 + 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} + - _MainTex: + 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 + - _BlendOp: 0 + - _Cull: 2 + - _Cutoff: 0.5 + - _DstBlend: 0 + - _DstBlendAlpha: 0 + - _QueueOffset: 0 + - _SampleGI: 0 + - _SrcBlend: 1 + - _SrcBlendAlpha: 1 + - _Surface: 0 + - _XRMotionVectorsPass: 1 + - _ZWrite: 1 + m_Colors: + - _BaseColor: {r: 0.38, g: 0.71, b: 0.55, a: 1} + - _Color: {r: 0.37999994, g: 0.71, b: 0.54999995, a: 1} + m_BuildTextureStacks: [] + m_AllowLocking: 1 +--- !u!114 &7555243765816861683 +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/Art/Defense25DComparison/ColliderBoundary.mat.meta b/Assets/_Project/Art/Defense25DComparison/ColliderBoundary.mat.meta new file mode 100644 index 0000000..55da9da --- /dev/null +++ b/Assets/_Project/Art/Defense25DComparison/ColliderBoundary.mat.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 96ad047810b9ae846bc2f54bf2968c31 +NativeFormatImporter: + externalObjects: {} + mainObjectFileID: 2100000 + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/_Project/Art/Defense25DComparison/ComparisonRenderer.asset b/Assets/_Project/Art/Defense25DComparison/ComparisonRenderer.asset new file mode 100644 index 0000000..143b31a --- /dev/null +++ b/Assets/_Project/Art/Defense25DComparison/ComparisonRenderer.asset @@ -0,0 +1,56 @@ +%YAML 1.1 +%TAG !u! tag:unity3d.com,2011: +--- !u!114 &11400000 +MonoBehaviour: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 0} + m_Enabled: 1 + m_EditorHideFlags: 0 + m_Script: {fileID: 11500000, guid: de640fe3d0db1804a85f9fc8f5cadab6, type: 3} + m_Name: ComparisonRenderer + m_EditorClassIdentifier: Unity.RenderPipelines.Universal.Runtime::UnityEngine.Rendering.Universal.UniversalRendererData + debugShaders: + debugReplacementPS: {fileID: 4800000, guid: cf852408f2e174538bcd9b7fda1c5ae7, type: 3} + hdrDebugViewPS: {fileID: 4800000, guid: 573620ae32aec764abd4d728906d2587, type: 3} + probeVolumeSamplingDebugComputeShader: {fileID: 7200000, guid: 53626a513ea68ce47b59dc1299fe3959, type: 3} + probeVolumeResources: + probeVolumeDebugShader: {fileID: 0} + probeVolumeFragmentationDebugShader: {fileID: 0} + probeVolumeOffsetDebugShader: {fileID: 0} + probeVolumeSamplingDebugShader: {fileID: 0} + probeSamplingDebugMesh: {fileID: 0} + probeSamplingDebugTexture: {fileID: 0} + probeVolumeBlendStatesCS: {fileID: 0} + m_RendererFeatures: [] + m_RendererFeatureMap: + m_UseNativeRenderPass: 0 + xrSystemData: {fileID: 0} + postProcessData: {fileID: 0} + m_AssetVersion: 3 + m_PrepassLayerMask: + serializedVersion: 2 + m_Bits: 4294967295 + m_OpaqueLayerMask: + serializedVersion: 2 + m_Bits: 4294967295 + m_TransparentLayerMask: + serializedVersion: 2 + m_Bits: 4294967295 + m_DefaultStencilState: + overrideStencilState: 0 + stencilReference: 0 + stencilCompareFunction: 8 + passOperation: 2 + failOperation: 0 + zFailOperation: 0 + m_ShadowTransparentReceive: 1 + m_RenderingMode: 0 + m_DepthPrimingMode: 0 + m_CopyDepthMode: 1 + m_DepthAttachmentFormat: 0 + m_DepthTextureFormat: 0 + m_AccurateGbufferNormals: 0 + m_IntermediateTextureMode: 1 diff --git a/Assets/_Project/Art/Defense25DComparison/ComparisonRenderer.asset.meta b/Assets/_Project/Art/Defense25DComparison/ComparisonRenderer.asset.meta new file mode 100644 index 0000000..a185942 --- /dev/null +++ b/Assets/_Project/Art/Defense25DComparison/ComparisonRenderer.asset.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 05aadf49be3030546aafc018b71af214 +NativeFormatImporter: + externalObjects: {} + mainObjectFileID: 11400000 + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/_Project/Art/Defense25DComparison/ComparisonSprite.shader b/Assets/_Project/Art/Defense25DComparison/ComparisonSprite.shader new file mode 100644 index 0000000..cd5db4d --- /dev/null +++ b/Assets/_Project/Art/Defense25DComparison/ComparisonSprite.shader @@ -0,0 +1,62 @@ +Shader "BumpCombat/Comparison Sprite Depth" +{ + Properties + { + [PerRendererData] _MainTex ("Existing sprite texture", 2D) = "white" {} + _BaseColor ("Tint", Color) = (1,1,1,1) + _Cutoff ("Alpha clip", Range(0,1)) = 0.4 + [Enum(UnityEngine.Rendering.BlendMode)] _SrcBlend ("Source blend", Float) = 1 + [Enum(UnityEngine.Rendering.BlendMode)] _DstBlend ("Destination blend", Float) = 0 + [Toggle] _ZWrite ("Depth write", Float) = 1 + } + SubShader + { + Tags { "RenderPipeline"="UniversalPipeline" "RenderType"="TransparentCutout" "Queue"="AlphaTest" } + Pass + { + Name "ComparisonUnlit" + Tags { "LightMode"="UniversalForward" } + Cull Off + ZTest LEqual + ZWrite [_ZWrite] + Blend [_SrcBlend] [_DstBlend] + HLSLPROGRAM + #pragma vertex Vert + #pragma fragment Frag + #pragma multi_compile_instancing + #include "Packages/com.unity.render-pipelines.universal/Shaders/2D/Include/Core2D.hlsl" + struct Attributes + { + float3 positionOS : POSITION; + float2 uv : TEXCOORD0; + half4 color : COLOR; + UNITY_VERTEX_INPUT_INSTANCE_ID + }; + struct Varyings { float4 positionCS : SV_POSITION; float2 uv : TEXCOORD0; half4 color : COLOR; }; + TEXTURE2D(_MainTex); + SAMPLER(sampler_MainTex); + CBUFFER_START(UnityPerMaterial) + half4 _BaseColor; + half _Cutoff; + CBUFFER_END + Varyings Vert(Attributes input) + { + Varyings output; + UNITY_SETUP_INSTANCE_ID(input); + SetUpSpriteInstanceProperties(); + input.positionOS = UnityFlipSprite(input.positionOS, unity_SpriteProps.xy); + output.positionCS = TransformObjectToHClip(input.positionOS.xyz); + output.uv = input.uv; + output.color = input.color; + return output; + } + half4 Frag(Varyings input) : SV_Target + { + half4 color = SAMPLE_TEXTURE2D(_MainTex, sampler_MainTex, input.uv) * input.color * _BaseColor; + clip(color.a - _Cutoff); + return color; + } + ENDHLSL + } + } +} diff --git a/Assets/_Project/Art/Defense25DComparison/ComparisonSprite.shader.meta b/Assets/_Project/Art/Defense25DComparison/ComparisonSprite.shader.meta new file mode 100644 index 0000000..f1f739c --- /dev/null +++ b/Assets/_Project/Art/Defense25DComparison/ComparisonSprite.shader.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 4aadfb1143d5401f9af3e4eb53746d77 diff --git a/Assets/_Project/Art/Defense25DComparison/CycloneHitBoundary.mat b/Assets/_Project/Art/Defense25DComparison/CycloneHitBoundary.mat new file mode 100644 index 0000000..9531fcc --- /dev/null +++ b/Assets/_Project/Art/Defense25DComparison/CycloneHitBoundary.mat @@ -0,0 +1,71 @@ +%YAML 1.1 +%TAG !u! tag:unity3d.com,2011: +--- !u!114 &-4571542010389989135 +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: CycloneHitBoundary + m_Shader: {fileID: 4800000, guid: 650dd9526735d5b46b79224bc6e94025, type: 3} + m_Parent: {fileID: 0} + m_ModifiedSerializedProperties: 0 + m_ValidKeywords: [] + m_InvalidKeywords: [] + m_LightmapFlags: 4 + 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} + - _MainTex: + 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 + - _BlendOp: 0 + - _Cull: 2 + - _Cutoff: 0.5 + - _DstBlend: 0 + - _DstBlendAlpha: 0 + - _QueueOffset: 0 + - _SampleGI: 0 + - _SrcBlend: 1 + - _SrcBlendAlpha: 1 + - _Surface: 0 + - _XRMotionVectorsPass: 1 + - _ZWrite: 1 + m_Colors: + - _BaseColor: {r: 1, g: 0.72, b: 0.18, a: 1} + - _Color: {r: 1, g: 0.72, b: 0.17999998, a: 1} + m_BuildTextureStacks: [] + m_AllowLocking: 1 diff --git a/Assets/_Project/Art/Defense25DComparison/CycloneHitBoundary.mat.meta b/Assets/_Project/Art/Defense25DComparison/CycloneHitBoundary.mat.meta new file mode 100644 index 0000000..410635c --- /dev/null +++ b/Assets/_Project/Art/Defense25DComparison/CycloneHitBoundary.mat.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: eee57eaf919f7b347b8bc513b3804b89 +NativeFormatImporter: + externalObjects: {} + mainObjectFileID: 2100000 + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/_Project/Art/Defense25DComparison/Ground.mat b/Assets/_Project/Art/Defense25DComparison/Ground.mat new file mode 100644 index 0000000..1c204eb --- /dev/null +++ b/Assets/_Project/Art/Defense25DComparison/Ground.mat @@ -0,0 +1,71 @@ +%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: Ground + m_Shader: {fileID: 4800000, guid: 650dd9526735d5b46b79224bc6e94025, type: 3} + m_Parent: {fileID: 0} + m_ModifiedSerializedProperties: 0 + m_ValidKeywords: [] + m_InvalidKeywords: [] + m_LightmapFlags: 4 + 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} + - _MainTex: + 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 + - _BlendOp: 0 + - _Cull: 2 + - _Cutoff: 0.5 + - _DstBlend: 0 + - _DstBlendAlpha: 0 + - _QueueOffset: 0 + - _SampleGI: 0 + - _SrcBlend: 1 + - _SrcBlendAlpha: 1 + - _Surface: 0 + - _XRMotionVectorsPass: 1 + - _ZWrite: 1 + m_Colors: + - _BaseColor: {r: 0.12, g: 0.18, b: 0.2, a: 1} + - _Color: {r: 0.11999995, g: 0.17999998, b: 0.19999996, a: 1} + m_BuildTextureStacks: [] + m_AllowLocking: 1 +--- !u!114 &7274897316288786868 +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/Art/Defense25DComparison/Ground.mat.meta b/Assets/_Project/Art/Defense25DComparison/Ground.mat.meta new file mode 100644 index 0000000..f3d2981 --- /dev/null +++ b/Assets/_Project/Art/Defense25DComparison/Ground.mat.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 0ab5f733c0b0e8a428f90a7aa1f33d1b +NativeFormatImporter: + externalObjects: {} + mainObjectFileID: 2100000 + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/_Project/Art/Defense25DComparison/GroundShadow.mat b/Assets/_Project/Art/Defense25DComparison/GroundShadow.mat new file mode 100644 index 0000000..c6731cb --- /dev/null +++ b/Assets/_Project/Art/Defense25DComparison/GroundShadow.mat @@ -0,0 +1,39 @@ +%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: GroundShadow + m_Shader: {fileID: 4800000, guid: 4aadfb1143d5401f9af3e4eb53746d77, type: 3} + m_Parent: {fileID: 0} + m_ModifiedSerializedProperties: 0 + m_ValidKeywords: [] + m_InvalidKeywords: [] + m_LightmapFlags: 4 + m_EnableInstancingVariants: 0 + m_DoubleSidedGI: 0 + m_CustomRenderQueue: 3000 + stringTagMap: {} + disabledShaderPasses: [] + m_LockedProperties: + m_SavedProperties: + serializedVersion: 3 + m_TexEnvs: + - _MainTex: + m_Texture: {fileID: 0} + m_Scale: {x: 1, y: 1} + m_Offset: {x: 0, y: 0} + m_Ints: [] + m_Floats: + - _Cutoff: 0.001 + - _DstBlend: 10 + - _SrcBlend: 5 + - _ZWrite: 0 + m_Colors: + - _BaseColor: {r: 0, g: 0, b: 0, a: 0.35} + m_BuildTextureStacks: [] + m_AllowLocking: 1 diff --git a/Assets/_Project/Art/Defense25DComparison/GroundShadow.mat.meta b/Assets/_Project/Art/Defense25DComparison/GroundShadow.mat.meta new file mode 100644 index 0000000..9686cb6 --- /dev/null +++ b/Assets/_Project/Art/Defense25DComparison/GroundShadow.mat.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 7262b6d429f43844ebd26ac4dfcf2cd6 +NativeFormatImporter: + externalObjects: {} + mainObjectFileID: 2100000 + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/_Project/Art/Defense25DComparison/Route.mat b/Assets/_Project/Art/Defense25DComparison/Route.mat new file mode 100644 index 0000000..7bebda4 --- /dev/null +++ b/Assets/_Project/Art/Defense25DComparison/Route.mat @@ -0,0 +1,71 @@ +%YAML 1.1 +%TAG !u! tag:unity3d.com,2011: +--- !u!114 &-8376117332504685327 +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: Route + m_Shader: {fileID: 4800000, guid: 650dd9526735d5b46b79224bc6e94025, type: 3} + m_Parent: {fileID: 0} + m_ModifiedSerializedProperties: 0 + m_ValidKeywords: [] + m_InvalidKeywords: [] + m_LightmapFlags: 4 + 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} + - _MainTex: + 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 + - _BlendOp: 0 + - _Cull: 2 + - _Cutoff: 0.5 + - _DstBlend: 0 + - _DstBlendAlpha: 0 + - _QueueOffset: 0 + - _SampleGI: 0 + - _SrcBlend: 1 + - _SrcBlendAlpha: 1 + - _Surface: 0 + - _XRMotionVectorsPass: 1 + - _ZWrite: 1 + m_Colors: + - _BaseColor: {r: 0.22, g: 0.34, b: 0.31, a: 1} + - _Color: {r: 0.21999997, g: 0.33999997, b: 0.30999997, a: 1} + m_BuildTextureStacks: [] + m_AllowLocking: 1 diff --git a/Assets/_Project/Art/Defense25DComparison/Route.mat.meta b/Assets/_Project/Art/Defense25DComparison/Route.mat.meta new file mode 100644 index 0000000..363d712 --- /dev/null +++ b/Assets/_Project/Art/Defense25DComparison/Route.mat.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 65a6bf5b1c33b80489a703cc8fe91efb +NativeFormatImporter: + externalObjects: {} + mainObjectFileID: 2100000 + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/_Project/Art/Defense25DComparison/SpriteCutout.mat b/Assets/_Project/Art/Defense25DComparison/SpriteCutout.mat new file mode 100644 index 0000000..74a7d1b --- /dev/null +++ b/Assets/_Project/Art/Defense25DComparison/SpriteCutout.mat @@ -0,0 +1,40 @@ +%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: SpriteCutout + m_Shader: {fileID: 4800000, guid: 4aadfb1143d5401f9af3e4eb53746d77, type: 3} + m_Parent: {fileID: 0} + m_ModifiedSerializedProperties: 0 + m_ValidKeywords: [] + m_InvalidKeywords: + - _ZWRITE_ON + m_LightmapFlags: 4 + m_EnableInstancingVariants: 0 + m_DoubleSidedGI: 0 + m_CustomRenderQueue: 2450 + stringTagMap: {} + disabledShaderPasses: [] + m_LockedProperties: + m_SavedProperties: + serializedVersion: 3 + m_TexEnvs: + - _MainTex: + m_Texture: {fileID: 0} + m_Scale: {x: 1, y: 1} + m_Offset: {x: 0, y: 0} + m_Ints: [] + m_Floats: + - _Cutoff: 0.4 + - _DstBlend: 0 + - _SrcBlend: 1 + - _ZWrite: 1 + m_Colors: + - _BaseColor: {r: 1, g: 1, b: 1, a: 1} + m_BuildTextureStacks: [] + m_AllowLocking: 1 diff --git a/Assets/_Project/Art/Defense25DComparison/SpriteCutout.mat.meta b/Assets/_Project/Art/Defense25DComparison/SpriteCutout.mat.meta new file mode 100644 index 0000000..f8c80f9 --- /dev/null +++ b/Assets/_Project/Art/Defense25DComparison/SpriteCutout.mat.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 30482bc8080f8594a92573c7e91e7d44 +NativeFormatImporter: + externalObjects: {} + mainObjectFileID: 2100000 + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/_Project/Art/Defense25DComparison/SpriteTransparent.mat b/Assets/_Project/Art/Defense25DComparison/SpriteTransparent.mat new file mode 100644 index 0000000..f5dd4ad --- /dev/null +++ b/Assets/_Project/Art/Defense25DComparison/SpriteTransparent.mat @@ -0,0 +1,39 @@ +%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: SpriteTransparent + m_Shader: {fileID: 4800000, guid: 4aadfb1143d5401f9af3e4eb53746d77, type: 3} + m_Parent: {fileID: 0} + m_ModifiedSerializedProperties: 0 + m_ValidKeywords: [] + m_InvalidKeywords: [] + m_LightmapFlags: 4 + m_EnableInstancingVariants: 0 + m_DoubleSidedGI: 0 + m_CustomRenderQueue: 3000 + stringTagMap: {} + disabledShaderPasses: [] + m_LockedProperties: + m_SavedProperties: + serializedVersion: 3 + m_TexEnvs: + - _MainTex: + m_Texture: {fileID: 0} + m_Scale: {x: 1, y: 1} + m_Offset: {x: 0, y: 0} + m_Ints: [] + m_Floats: + - _Cutoff: 0.001 + - _DstBlend: 10 + - _SrcBlend: 5 + - _ZWrite: 0 + m_Colors: + - _BaseColor: {r: 1, g: 1, b: 1, a: 0.55} + m_BuildTextureStacks: [] + m_AllowLocking: 1 diff --git a/Assets/_Project/Art/Defense25DComparison/SpriteTransparent.mat.meta b/Assets/_Project/Art/Defense25DComparison/SpriteTransparent.mat.meta new file mode 100644 index 0000000..5a299f7 --- /dev/null +++ b/Assets/_Project/Art/Defense25DComparison/SpriteTransparent.mat.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: dfafc04d69db1824191ef8b87cf1295a +NativeFormatImporter: + externalObjects: {} + mainObjectFileID: 2100000 + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/_Project/Art/Defense25DComparison/Wall.mat b/Assets/_Project/Art/Defense25DComparison/Wall.mat new file mode 100644 index 0000000..a176f93 --- /dev/null +++ b/Assets/_Project/Art/Defense25DComparison/Wall.mat @@ -0,0 +1,71 @@ +%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: Wall + m_Shader: {fileID: 4800000, guid: 650dd9526735d5b46b79224bc6e94025, type: 3} + m_Parent: {fileID: 0} + m_ModifiedSerializedProperties: 0 + m_ValidKeywords: [] + m_InvalidKeywords: [] + m_LightmapFlags: 4 + 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} + - _MainTex: + 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 + - _BlendOp: 0 + - _Cull: 2 + - _Cutoff: 0.5 + - _DstBlend: 0 + - _DstBlendAlpha: 0 + - _QueueOffset: 0 + - _SampleGI: 0 + - _SrcBlend: 1 + - _SrcBlendAlpha: 1 + - _Surface: 0 + - _XRMotionVectorsPass: 1 + - _ZWrite: 1 + m_Colors: + - _BaseColor: {r: 0.48, g: 0.51, b: 0.48, a: 1} + - _Color: {r: 0.47999996, g: 0.50999993, b: 0.47999996, a: 1} + m_BuildTextureStacks: [] + m_AllowLocking: 1 +--- !u!114 &7177300302112874480 +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/Art/Defense25DComparison/Wall.mat.meta b/Assets/_Project/Art/Defense25DComparison/Wall.mat.meta new file mode 100644 index 0000000..823cd97 --- /dev/null +++ b/Assets/_Project/Art/Defense25DComparison/Wall.mat.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: b982e9bfd1eb9ce4c80b628b9b2b6c91 +NativeFormatImporter: + externalObjects: {} + mainObjectFileID: 2100000 + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/_Project/Art/DefenseTowers-v1/Resources/DefenseTowers-v1/Effects/Fire/Eyeball Monster_Beam-Clean.png b/Assets/_Project/Art/DefenseTowers-v1/Resources/DefenseTowers-v1/Effects/Fire/Eyeball Monster_Beam-Clean.png new file mode 100644 index 0000000..50dc89b Binary files /dev/null and b/Assets/_Project/Art/DefenseTowers-v1/Resources/DefenseTowers-v1/Effects/Fire/Eyeball Monster_Beam-Clean.png differ diff --git a/Assets/_Project/Art/DefenseTowers-v1/Resources/DefenseTowers-v1/Effects/Fire/Eyeball Monster_Beam-Clean.png.meta b/Assets/_Project/Art/DefenseTowers-v1/Resources/DefenseTowers-v1/Effects/Fire/Eyeball Monster_Beam-Clean.png.meta new file mode 100644 index 0000000..c732901 --- /dev/null +++ b/Assets/_Project/Art/DefenseTowers-v1/Resources/DefenseTowers-v1/Effects/Fire/Eyeball Monster_Beam-Clean.png.meta @@ -0,0 +1,195 @@ +fileFormatVersion: 2 +guid: 6d35e75312d3418ea2f58a33521b2ebb +TextureImporter: + internalIDToNameTable: + - first: + 213: 21300000 + second: Eyeball Monster_Beam-Clean_0 + - first: + 213: 21300002 + second: Eyeball Monster_Beam-Clean_1 + - first: + 213: 21300004 + second: Eyeball Monster_Beam-Clean_2 + externalObjects: {} + serializedVersion: 13 + mipmaps: + mipMapMode: 0 + enableMipMap: 0 + sRGBTexture: 1 + linearTexture: 0 + fadeOut: 0 + borderMipMap: 0 + mipMapsPreserveCoverage: 0 + alphaTestReferenceValue: 0.5 + mipMapFadeDistanceStart: 1 + mipMapFadeDistanceEnd: 3 + bumpmap: + convertToNormalMap: 0 + externalNormalMap: 0 + heightScale: 0.25 + normalMapFilter: 0 + flipGreenChannel: 0 + isReadable: 0 + streamingMipmaps: 0 + streamingMipmapsPriority: 0 + vTOnly: 0 + ignoreMipmapLimit: 0 + grayScaleToAlpha: 0 + generateCubemap: 6 + cubemapConvolution: 0 + seamlessCubemap: 0 + textureFormat: 1 + maxTextureSize: 2048 + textureSettings: + serializedVersion: 2 + filterMode: 0 + aniso: 1 + mipBias: 0 + wrapU: 1 + wrapV: 1 + wrapW: 1 + nPOTScale: 0 + lightmap: 0 + compressionQuality: 50 + spriteMode: 2 + spriteExtrude: 1 + spriteMeshType: 1 + alignment: 0 + spritePivot: {x: 0.5, y: 0.5} + spritePixelsToUnits: 32 + spriteBorder: {x: 0, y: 0, z: 0, w: 0} + spriteGenerateFallbackPhysicsShape: 1 + alphaUsage: 1 + alphaIsTransparency: 1 + spriteTessellationDetail: -1 + textureType: 8 + textureShape: 1 + singleChannelComponent: 0 + flipbookRows: 1 + flipbookColumns: 1 + maxTextureSizeSet: 0 + compressionQualitySet: 0 + textureFormatSet: 0 + ignorePngGamma: 0 + applyGammaDecoding: 0 + swizzle: 50462976 + cookieLightType: 0 + platformSettings: + - serializedVersion: 4 + buildTarget: DefaultTexturePlatform + maxTextureSize: 2048 + resizeAlgorithm: 0 + textureFormat: -1 + textureCompression: 0 + compressionQuality: 50 + crunchedCompression: 0 + allowsAlphaSplitting: 0 + overridden: 0 + ignorePlatformSupport: 0 + androidETC2FallbackOverride: 0 + forceMaximumCompressionQuality_BC6H_BC7: 0 + - serializedVersion: 4 + buildTarget: Standalone + maxTextureSize: 2048 + resizeAlgorithm: 0 + textureFormat: -1 + textureCompression: 1 + compressionQuality: 50 + crunchedCompression: 0 + allowsAlphaSplitting: 0 + overridden: 0 + ignorePlatformSupport: 0 + androidETC2FallbackOverride: 0 + forceMaximumCompressionQuality_BC6H_BC7: 0 + spriteSheet: + serializedVersion: 2 + sprites: + - serializedVersion: 2 + name: Eyeball Monster_Beam-Clean_0 + rect: + serializedVersion: 2 + x: 0 + y: 0 + width: 100 + height: 100 + alignment: 0 + pivot: {x: 0.5, y: 0.5} + border: {x: 0, y: 0, z: 0, w: 0} + customData: + outline: [] + physicsShape: [] + tessellationDetail: 0 + bones: [] + spriteID: 3004ea3b219640239711f5b7b844b6c8 + internalID: 21300000 + vertices: [] + indices: + edges: [] + weights: [] + - serializedVersion: 2 + name: Eyeball Monster_Beam-Clean_1 + rect: + serializedVersion: 2 + x: 100 + y: 0 + width: 100 + height: 100 + alignment: 0 + pivot: {x: 0.5, y: 0.5} + border: {x: 0, y: 0, z: 0, w: 0} + customData: + outline: [] + physicsShape: [] + tessellationDetail: 0 + bones: [] + spriteID: aaf17a191ca741768f84425aa8f5ef0a + internalID: 21300002 + vertices: [] + indices: + edges: [] + weights: [] + - serializedVersion: 2 + name: Eyeball Monster_Beam-Clean_2 + rect: + serializedVersion: 2 + x: 200 + y: 0 + width: 100 + height: 100 + alignment: 0 + pivot: {x: 0.5, y: 0.5} + border: {x: 0, y: 0, z: 0, w: 0} + customData: + outline: [] + physicsShape: [] + tessellationDetail: 0 + bones: [] + spriteID: f78d7efc0c5f4f7289005c5130b031dd + internalID: 21300004 + vertices: [] + indices: + edges: [] + weights: [] + outline: [] + customData: + physicsShape: [] + bones: [] + spriteID: 3822a7d3d5c64ffe9cd15697e04623c2 + internalID: 0 + vertices: [] + indices: + edges: [] + weights: [] + secondaryTextures: [] + spriteCustomMetadata: + entries: [] + nameFileIdTable: + Eyeball Monster_Beam-Clean_0: 21300000 + Eyeball Monster_Beam-Clean_1: 21300002 + Eyeball Monster_Beam-Clean_2: 21300004 + mipmapLimitGroupName: + pSDRemoveMatte: 0 + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/_Project/Art/DefenseTowers-v1/Resources/DefenseTowers-v1/Effects/Fire/Eyeball Monster_Beam.png b/Assets/_Project/Art/DefenseTowers-v1/Resources/DefenseTowers-v1/Effects/Fire/Eyeball Monster_Beam.png new file mode 100644 index 0000000..f864ca6 Binary files /dev/null and b/Assets/_Project/Art/DefenseTowers-v1/Resources/DefenseTowers-v1/Effects/Fire/Eyeball Monster_Beam.png differ diff --git a/Assets/_Project/Art/DefenseTowers-v1/Resources/DefenseTowers-v1/Effects/Fire/Eyeball Monster_Beam.png.meta b/Assets/_Project/Art/DefenseTowers-v1/Resources/DefenseTowers-v1/Effects/Fire/Eyeball Monster_Beam.png.meta new file mode 100644 index 0000000..72e78b2 --- /dev/null +++ b/Assets/_Project/Art/DefenseTowers-v1/Resources/DefenseTowers-v1/Effects/Fire/Eyeball Monster_Beam.png.meta @@ -0,0 +1,195 @@ +fileFormatVersion: 2 +guid: 6fbe849b409d46c682a64fe95eaf2e8a +TextureImporter: + internalIDToNameTable: + - first: + 213: 21300000 + second: Eyeball Monster_Beam_0 + - first: + 213: 21300002 + second: Eyeball Monster_Beam_1 + - first: + 213: 21300004 + second: Eyeball Monster_Beam_2 + externalObjects: {} + serializedVersion: 13 + mipmaps: + mipMapMode: 0 + enableMipMap: 0 + sRGBTexture: 1 + linearTexture: 0 + fadeOut: 0 + borderMipMap: 0 + mipMapsPreserveCoverage: 0 + alphaTestReferenceValue: 0.5 + mipMapFadeDistanceStart: 1 + mipMapFadeDistanceEnd: 3 + bumpmap: + convertToNormalMap: 0 + externalNormalMap: 0 + heightScale: 0.25 + normalMapFilter: 0 + flipGreenChannel: 0 + isReadable: 0 + streamingMipmaps: 0 + streamingMipmapsPriority: 0 + vTOnly: 0 + ignoreMipmapLimit: 0 + grayScaleToAlpha: 0 + generateCubemap: 6 + cubemapConvolution: 0 + seamlessCubemap: 0 + textureFormat: 1 + maxTextureSize: 2048 + textureSettings: + serializedVersion: 2 + filterMode: 0 + aniso: 1 + mipBias: 0 + wrapU: 1 + wrapV: 1 + wrapW: 1 + nPOTScale: 0 + lightmap: 0 + compressionQuality: 50 + spriteMode: 2 + spriteExtrude: 1 + spriteMeshType: 1 + alignment: 0 + spritePivot: {x: 0.5, y: 0.5} + spritePixelsToUnits: 32 + spriteBorder: {x: 0, y: 0, z: 0, w: 0} + spriteGenerateFallbackPhysicsShape: 1 + alphaUsage: 1 + alphaIsTransparency: 1 + spriteTessellationDetail: -1 + textureType: 8 + textureShape: 1 + singleChannelComponent: 0 + flipbookRows: 1 + flipbookColumns: 1 + maxTextureSizeSet: 0 + compressionQualitySet: 0 + textureFormatSet: 0 + ignorePngGamma: 0 + applyGammaDecoding: 0 + swizzle: 50462976 + cookieLightType: 0 + platformSettings: + - serializedVersion: 4 + buildTarget: DefaultTexturePlatform + maxTextureSize: 2048 + resizeAlgorithm: 0 + textureFormat: -1 + textureCompression: 0 + compressionQuality: 50 + crunchedCompression: 0 + allowsAlphaSplitting: 0 + overridden: 0 + ignorePlatformSupport: 0 + androidETC2FallbackOverride: 0 + forceMaximumCompressionQuality_BC6H_BC7: 0 + - serializedVersion: 4 + buildTarget: Standalone + maxTextureSize: 2048 + resizeAlgorithm: 0 + textureFormat: -1 + textureCompression: 1 + compressionQuality: 50 + crunchedCompression: 0 + allowsAlphaSplitting: 0 + overridden: 0 + ignorePlatformSupport: 0 + androidETC2FallbackOverride: 0 + forceMaximumCompressionQuality_BC6H_BC7: 0 + spriteSheet: + serializedVersion: 2 + sprites: + - serializedVersion: 2 + name: Eyeball Monster_Beam_0 + rect: + serializedVersion: 2 + x: 0 + y: 0 + width: 100 + height: 100 + alignment: 0 + pivot: {x: 0.5, y: 0.5} + border: {x: 0, y: 0, z: 0, w: 0} + customData: + outline: [] + physicsShape: [] + tessellationDetail: 0 + bones: [] + spriteID: 55045f58609a4086830a0380c190ac68 + internalID: 21300000 + vertices: [] + indices: + edges: [] + weights: [] + - serializedVersion: 2 + name: Eyeball Monster_Beam_1 + rect: + serializedVersion: 2 + x: 100 + y: 0 + width: 100 + height: 100 + alignment: 0 + pivot: {x: 0.5, y: 0.5} + border: {x: 0, y: 0, z: 0, w: 0} + customData: + outline: [] + physicsShape: [] + tessellationDetail: 0 + bones: [] + spriteID: ce5cb0d22820462184cee78fe93d0542 + internalID: 21300002 + vertices: [] + indices: + edges: [] + weights: [] + - serializedVersion: 2 + name: Eyeball Monster_Beam_2 + rect: + serializedVersion: 2 + x: 200 + y: 0 + width: 100 + height: 100 + alignment: 0 + pivot: {x: 0.5, y: 0.5} + border: {x: 0, y: 0, z: 0, w: 0} + customData: + outline: [] + physicsShape: [] + tessellationDetail: 0 + bones: [] + spriteID: e357145bdc1c4e0cb6379be3ab7a4e10 + internalID: 21300004 + vertices: [] + indices: + edges: [] + weights: [] + outline: [] + customData: + physicsShape: [] + bones: [] + spriteID: 03e7593104da4d908b5a05f6ec08bb41 + internalID: 0 + vertices: [] + indices: + edges: [] + weights: [] + secondaryTextures: [] + spriteCustomMetadata: + entries: [] + nameFileIdTable: + Eyeball Monster_Beam_0: 21300000 + Eyeball Monster_Beam_1: 21300002 + Eyeball Monster_Beam_2: 21300004 + mipmapLimitGroupName: + pSDRemoveMatte: 0 + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/_Project/Art/DefenseTowers-v1/Resources/DefenseTowers-v1/Effects/Ice/Blizzard-Wave-v1.png b/Assets/_Project/Art/DefenseTowers-v1/Resources/DefenseTowers-v1/Effects/Ice/Blizzard-Wave-v1.png new file mode 100644 index 0000000..4c23d35 Binary files /dev/null and b/Assets/_Project/Art/DefenseTowers-v1/Resources/DefenseTowers-v1/Effects/Ice/Blizzard-Wave-v1.png differ diff --git a/Assets/_Project/Art/DefenseTowers-v1/Resources/DefenseTowers-v1/Effects/Ice/Blizzard-Wave-v1.png.meta b/Assets/_Project/Art/DefenseTowers-v1/Resources/DefenseTowers-v1/Effects/Ice/Blizzard-Wave-v1.png.meta new file mode 100644 index 0000000..bd05e34 --- /dev/null +++ b/Assets/_Project/Art/DefenseTowers-v1/Resources/DefenseTowers-v1/Effects/Ice/Blizzard-Wave-v1.png.meta @@ -0,0 +1,273 @@ +fileFormatVersion: 2 +guid: 7c04e8afcbb04d1c838e3d7b8ed4f052 +TextureImporter: + internalIDToNameTable: + - first: + 213: 499323505792861607 + second: Blizzard-Wave-v1_0 + - first: + 213: 634351548535658973 + second: Blizzard-Wave-v1_1 + - first: + 213: 753672340007810633 + second: Blizzard-Wave-v1_2 + - first: + 213: 136483814077234494 + second: Blizzard-Wave-v1_3 + - first: + 213: 237513938985374204 + second: Blizzard-Wave-v1_4 + - first: + 213: 238326498544796154 + second: Blizzard-Wave-v1_5 + externalObjects: {} + serializedVersion: 13 + mipmaps: + mipMapMode: 0 + enableMipMap: 0 + sRGBTexture: 1 + linearTexture: 0 + fadeOut: 0 + borderMipMap: 0 + mipMapsPreserveCoverage: 0 + alphaTestReferenceValue: 0.5 + mipMapFadeDistanceStart: 1 + mipMapFadeDistanceEnd: 3 + bumpmap: + convertToNormalMap: 0 + externalNormalMap: 0 + heightScale: 0.25 + normalMapFilter: 0 + flipGreenChannel: 0 + isReadable: 0 + streamingMipmaps: 0 + streamingMipmapsPriority: 0 + vTOnly: 0 + ignoreMipmapLimit: 0 + grayScaleToAlpha: 0 + generateCubemap: 6 + cubemapConvolution: 0 + seamlessCubemap: 0 + textureFormat: 1 + maxTextureSize: 2048 + textureSettings: + serializedVersion: 2 + filterMode: 0 + aniso: 1 + mipBias: 0 + wrapU: 1 + wrapV: 1 + wrapW: 1 + nPOTScale: 0 + lightmap: 0 + compressionQuality: 50 + spriteMode: 2 + spriteExtrude: 1 + spriteMeshType: 1 + alignment: 0 + spritePivot: {x: 0.5, y: 0.5} + spritePixelsToUnits: 32 + spriteBorder: {x: 0, y: 0, z: 0, w: 0} + spriteGenerateFallbackPhysicsShape: 1 + alphaUsage: 1 + alphaIsTransparency: 1 + spriteTessellationDetail: -1 + textureType: 8 + textureShape: 1 + singleChannelComponent: 0 + flipbookRows: 1 + flipbookColumns: 1 + maxTextureSizeSet: 0 + compressionQualitySet: 0 + textureFormatSet: 0 + ignorePngGamma: 0 + applyGammaDecoding: 0 + swizzle: 50462976 + cookieLightType: 0 + platformSettings: + - serializedVersion: 4 + buildTarget: DefaultTexturePlatform + maxTextureSize: 2048 + resizeAlgorithm: 0 + textureFormat: -1 + textureCompression: 0 + compressionQuality: 50 + crunchedCompression: 0 + allowsAlphaSplitting: 0 + overridden: 0 + ignorePlatformSupport: 0 + androidETC2FallbackOverride: 0 + forceMaximumCompressionQuality_BC6H_BC7: 0 + - serializedVersion: 4 + buildTarget: Standalone + maxTextureSize: 2048 + resizeAlgorithm: 0 + textureFormat: -1 + textureCompression: 0 + compressionQuality: 50 + crunchedCompression: 0 + allowsAlphaSplitting: 0 + overridden: 0 + ignorePlatformSupport: 0 + androidETC2FallbackOverride: 0 + forceMaximumCompressionQuality_BC6H_BC7: 0 + spriteSheet: + serializedVersion: 2 + sprites: + - serializedVersion: 2 + name: Blizzard-Wave-v1_0 + rect: + serializedVersion: 2 + x: 0 + y: 0 + width: 100 + height: 100 + alignment: 9 + pivot: {x: 0.5, y: 0.5} + border: {x: 0, y: 0, z: 0, w: 0} + customData: + outline: [] + physicsShape: [] + tessellationDetail: 0 + bones: [] + spriteID: 224300080edb492d88531ed4614fde1f + internalID: 499323505792861607 + vertices: [] + indices: + edges: [] + weights: [] + - serializedVersion: 2 + name: Blizzard-Wave-v1_1 + rect: + serializedVersion: 2 + x: 100 + y: 0 + width: 100 + height: 100 + alignment: 9 + pivot: {x: 0.5, y: 0.5} + border: {x: 0, y: 0, z: 0, w: 0} + customData: + outline: [] + physicsShape: [] + tessellationDetail: 0 + bones: [] + spriteID: 1248c85d569747eab70540ccd056a016 + internalID: 634351548535658973 + vertices: [] + indices: + edges: [] + weights: [] + - serializedVersion: 2 + name: Blizzard-Wave-v1_2 + rect: + serializedVersion: 2 + x: 200 + y: 0 + width: 100 + height: 100 + alignment: 9 + pivot: {x: 0.5, y: 0.5} + border: {x: 0, y: 0, z: 0, w: 0} + customData: + outline: [] + physicsShape: [] + tessellationDetail: 0 + bones: [] + spriteID: 9befe5b709184ebfad67cd6d02081fde + internalID: 753672340007810633 + vertices: [] + indices: + edges: [] + weights: [] + - serializedVersion: 2 + name: Blizzard-Wave-v1_3 + rect: + serializedVersion: 2 + x: 300 + y: 0 + width: 100 + height: 100 + alignment: 9 + pivot: {x: 0.5, y: 0.5} + border: {x: 0, y: 0, z: 0, w: 0} + customData: + outline: [] + physicsShape: [] + tessellationDetail: 0 + bones: [] + spriteID: 0b12349417254db2aecdb9f222fb4046 + internalID: 136483814077234494 + vertices: [] + indices: + edges: [] + weights: [] + - serializedVersion: 2 + name: Blizzard-Wave-v1_4 + rect: + serializedVersion: 2 + x: 400 + y: 0 + width: 100 + height: 100 + alignment: 9 + pivot: {x: 0.5, y: 0.5} + border: {x: 0, y: 0, z: 0, w: 0} + customData: + outline: [] + physicsShape: [] + tessellationDetail: 0 + bones: [] + spriteID: a36b8f4a1187414994d28eb6ade93b39 + internalID: 237513938985374204 + vertices: [] + indices: + edges: [] + weights: [] + - serializedVersion: 2 + name: Blizzard-Wave-v1_5 + rect: + serializedVersion: 2 + x: 500 + y: 0 + width: 100 + height: 100 + alignment: 9 + pivot: {x: 0.5, y: 0.5} + border: {x: 0, y: 0, z: 0, w: 0} + customData: + outline: [] + physicsShape: [] + tessellationDetail: 0 + bones: [] + spriteID: b825c6cd44144880ae70b27a6326cf2d + internalID: 238326498544796154 + vertices: [] + indices: + edges: [] + weights: [] + outline: [] + customData: + physicsShape: [] + bones: [] + spriteID: a0051b77a00549349642a6abc5dbeeb7 + internalID: 0 + vertices: [] + indices: + edges: [] + weights: [] + secondaryTextures: [] + spriteCustomMetadata: + entries: [] + nameFileIdTable: + Blizzard-Wave-v1_0: 499323505792861607 + Blizzard-Wave-v1_1: 634351548535658973 + Blizzard-Wave-v1_2: 753672340007810633 + Blizzard-Wave-v1_3: 136483814077234494 + Blizzard-Wave-v1_4: 237513938985374204 + Blizzard-Wave-v1_5: 238326498544796154 + mipmapLimitGroupName: + pSDRemoveMatte: 0 + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/_Project/Art/DefenseTowers-v1/Resources/DefenseTowers-v1/Effects/Ice/Shard-Blizzard-v1.png b/Assets/_Project/Art/DefenseTowers-v1/Resources/DefenseTowers-v1/Effects/Ice/Shard-Blizzard-v1.png new file mode 100644 index 0000000..8f0953b Binary files /dev/null and b/Assets/_Project/Art/DefenseTowers-v1/Resources/DefenseTowers-v1/Effects/Ice/Shard-Blizzard-v1.png differ diff --git a/Assets/_Project/Art/DefenseTowers-v1/Resources/DefenseTowers-v1/Effects/Ice/Shard-Blizzard-v1.png.meta b/Assets/_Project/Art/DefenseTowers-v1/Resources/DefenseTowers-v1/Effects/Ice/Shard-Blizzard-v1.png.meta new file mode 100644 index 0000000..4319815 --- /dev/null +++ b/Assets/_Project/Art/DefenseTowers-v1/Resources/DefenseTowers-v1/Effects/Ice/Shard-Blizzard-v1.png.meta @@ -0,0 +1,143 @@ +fileFormatVersion: 2 +guid: 2617f115eff24ff185a333b7112250a5 +TextureImporter: + internalIDToNameTable: + - first: + 213: 97093349900010584 + second: Shard-Blizzard-v1_0 + externalObjects: {} + serializedVersion: 13 + mipmaps: + mipMapMode: 0 + enableMipMap: 0 + sRGBTexture: 1 + linearTexture: 0 + fadeOut: 0 + borderMipMap: 0 + mipMapsPreserveCoverage: 0 + alphaTestReferenceValue: 0.5 + mipMapFadeDistanceStart: 1 + mipMapFadeDistanceEnd: 3 + bumpmap: + convertToNormalMap: 0 + externalNormalMap: 0 + heightScale: 0.25 + normalMapFilter: 0 + flipGreenChannel: 0 + isReadable: 0 + streamingMipmaps: 0 + streamingMipmapsPriority: 0 + vTOnly: 0 + ignoreMipmapLimit: 0 + grayScaleToAlpha: 0 + generateCubemap: 6 + cubemapConvolution: 0 + seamlessCubemap: 0 + textureFormat: 1 + maxTextureSize: 2048 + textureSettings: + serializedVersion: 2 + filterMode: 0 + aniso: 1 + mipBias: 0 + wrapU: 1 + wrapV: 1 + wrapW: 1 + nPOTScale: 0 + lightmap: 0 + compressionQuality: 50 + spriteMode: 2 + spriteExtrude: 1 + spriteMeshType: 1 + alignment: 0 + spritePivot: {x: 0.5, y: 0.5} + spritePixelsToUnits: 32 + spriteBorder: {x: 0, y: 0, z: 0, w: 0} + spriteGenerateFallbackPhysicsShape: 1 + alphaUsage: 1 + alphaIsTransparency: 1 + spriteTessellationDetail: -1 + textureType: 8 + textureShape: 1 + singleChannelComponent: 0 + flipbookRows: 1 + flipbookColumns: 1 + maxTextureSizeSet: 0 + compressionQualitySet: 0 + textureFormatSet: 0 + ignorePngGamma: 0 + applyGammaDecoding: 0 + swizzle: 50462976 + cookieLightType: 0 + platformSettings: + - serializedVersion: 4 + buildTarget: DefaultTexturePlatform + maxTextureSize: 2048 + resizeAlgorithm: 0 + textureFormat: -1 + textureCompression: 0 + compressionQuality: 50 + crunchedCompression: 0 + allowsAlphaSplitting: 0 + overridden: 0 + ignorePlatformSupport: 0 + androidETC2FallbackOverride: 0 + forceMaximumCompressionQuality_BC6H_BC7: 0 + - serializedVersion: 4 + buildTarget: Standalone + maxTextureSize: 2048 + resizeAlgorithm: 0 + textureFormat: -1 + textureCompression: 0 + compressionQuality: 50 + crunchedCompression: 0 + allowsAlphaSplitting: 0 + overridden: 0 + ignorePlatformSupport: 0 + androidETC2FallbackOverride: 0 + forceMaximumCompressionQuality_BC6H_BC7: 0 + spriteSheet: + serializedVersion: 2 + sprites: + - serializedVersion: 2 + name: Shard-Blizzard-v1_0 + rect: + serializedVersion: 2 + x: 0 + y: 0 + width: 100 + height: 100 + alignment: 9 + pivot: {x: 0.5, y: 0.5} + border: {x: 0, y: 0, z: 0, w: 0} + customData: + outline: [] + physicsShape: [] + tessellationDetail: 0 + bones: [] + spriteID: f6f836e9ee53460391035373f7943c1a + internalID: 97093349900010584 + vertices: [] + indices: + edges: [] + weights: [] + outline: [] + customData: + physicsShape: [] + bones: [] + spriteID: 33e656590cae43ca8d0440f93b760ccb + internalID: 0 + vertices: [] + indices: + edges: [] + weights: [] + secondaryTextures: [] + spriteCustomMetadata: + entries: [] + nameFileIdTable: + Shard-Blizzard-v1_0: 97093349900010584 + mipmapLimitGroupName: + pSDRemoveMatte: 0 + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/_Project/Art/DefenseTowers-v1/Resources/DefenseTowers-v1/Summons/GiantSlime/Attack01.png b/Assets/_Project/Art/DefenseTowers-v1/Resources/DefenseTowers-v1/Summons/GiantSlime/Attack01.png index 805bfcd..9343bdf 100644 Binary files a/Assets/_Project/Art/DefenseTowers-v1/Resources/DefenseTowers-v1/Summons/GiantSlime/Attack01.png and b/Assets/_Project/Art/DefenseTowers-v1/Resources/DefenseTowers-v1/Summons/GiantSlime/Attack01.png differ diff --git a/Assets/_Project/Art/DefenseTowers-v1/Resources/DefenseTowers-v1/Summons/GiantSlime/Attack02.png b/Assets/_Project/Art/DefenseTowers-v1/Resources/DefenseTowers-v1/Summons/GiantSlime/Attack02.png index b5f3344..cad05a9 100644 Binary files a/Assets/_Project/Art/DefenseTowers-v1/Resources/DefenseTowers-v1/Summons/GiantSlime/Attack02.png and b/Assets/_Project/Art/DefenseTowers-v1/Resources/DefenseTowers-v1/Summons/GiantSlime/Attack02.png differ diff --git a/Assets/_Project/Art/DefenseTowers-v1/Resources/DefenseTowers-v1/Summons/GiantSlime/Death.png b/Assets/_Project/Art/DefenseTowers-v1/Resources/DefenseTowers-v1/Summons/GiantSlime/Death.png index 6f41839..c55643f 100644 Binary files a/Assets/_Project/Art/DefenseTowers-v1/Resources/DefenseTowers-v1/Summons/GiantSlime/Death.png and b/Assets/_Project/Art/DefenseTowers-v1/Resources/DefenseTowers-v1/Summons/GiantSlime/Death.png differ diff --git a/Assets/_Project/Art/DefenseTowers-v1/Resources/DefenseTowers-v1/Summons/GiantSlime/Hurt.png b/Assets/_Project/Art/DefenseTowers-v1/Resources/DefenseTowers-v1/Summons/GiantSlime/Hurt.png index 7e32045..b1c23e9 100644 Binary files a/Assets/_Project/Art/DefenseTowers-v1/Resources/DefenseTowers-v1/Summons/GiantSlime/Hurt.png and b/Assets/_Project/Art/DefenseTowers-v1/Resources/DefenseTowers-v1/Summons/GiantSlime/Hurt.png differ diff --git a/Assets/_Project/Art/DefenseTowers-v1/Resources/DefenseTowers-v1/Summons/GiantSlime/Idle.png b/Assets/_Project/Art/DefenseTowers-v1/Resources/DefenseTowers-v1/Summons/GiantSlime/Idle.png index 45ff683..d0ff497 100644 Binary files a/Assets/_Project/Art/DefenseTowers-v1/Resources/DefenseTowers-v1/Summons/GiantSlime/Idle.png and b/Assets/_Project/Art/DefenseTowers-v1/Resources/DefenseTowers-v1/Summons/GiantSlime/Idle.png differ diff --git a/Assets/_Project/Art/DefenseTowers-v1/Resources/DefenseTowers-v1/Summons/GiantSlime/Walk.png b/Assets/_Project/Art/DefenseTowers-v1/Resources/DefenseTowers-v1/Summons/GiantSlime/Walk.png index 5de3986..a8d9999 100644 Binary files a/Assets/_Project/Art/DefenseTowers-v1/Resources/DefenseTowers-v1/Summons/GiantSlime/Walk.png and b/Assets/_Project/Art/DefenseTowers-v1/Resources/DefenseTowers-v1/Summons/GiantSlime/Walk.png differ diff --git a/Assets/_Project/Art/DefenseTowers-v1/Resources/DefenseTowers-v1/Summons/LavaSlime/Attack01.png b/Assets/_Project/Art/DefenseTowers-v1/Resources/DefenseTowers-v1/Summons/LavaSlime/Attack01.png index c348392..0e1c319 100644 Binary files a/Assets/_Project/Art/DefenseTowers-v1/Resources/DefenseTowers-v1/Summons/LavaSlime/Attack01.png and b/Assets/_Project/Art/DefenseTowers-v1/Resources/DefenseTowers-v1/Summons/LavaSlime/Attack01.png differ diff --git a/Assets/_Project/Art/DefenseTowers-v1/Resources/DefenseTowers-v1/Summons/LavaSlime/Attack02.png b/Assets/_Project/Art/DefenseTowers-v1/Resources/DefenseTowers-v1/Summons/LavaSlime/Attack02.png index 9a2879b..5ed963f 100644 Binary files a/Assets/_Project/Art/DefenseTowers-v1/Resources/DefenseTowers-v1/Summons/LavaSlime/Attack02.png and b/Assets/_Project/Art/DefenseTowers-v1/Resources/DefenseTowers-v1/Summons/LavaSlime/Attack02.png differ diff --git a/Assets/_Project/Art/DefenseTowers-v1/Resources/DefenseTowers-v1/Summons/LavaSlime/Death.png b/Assets/_Project/Art/DefenseTowers-v1/Resources/DefenseTowers-v1/Summons/LavaSlime/Death.png index 98873fb..81485af 100644 Binary files a/Assets/_Project/Art/DefenseTowers-v1/Resources/DefenseTowers-v1/Summons/LavaSlime/Death.png and b/Assets/_Project/Art/DefenseTowers-v1/Resources/DefenseTowers-v1/Summons/LavaSlime/Death.png differ diff --git a/Assets/_Project/Art/DefenseTowers-v1/Resources/DefenseTowers-v1/Summons/LavaSlime/Hurt.png b/Assets/_Project/Art/DefenseTowers-v1/Resources/DefenseTowers-v1/Summons/LavaSlime/Hurt.png index 32acbd8..e640e6d 100644 Binary files a/Assets/_Project/Art/DefenseTowers-v1/Resources/DefenseTowers-v1/Summons/LavaSlime/Hurt.png and b/Assets/_Project/Art/DefenseTowers-v1/Resources/DefenseTowers-v1/Summons/LavaSlime/Hurt.png differ diff --git a/Assets/_Project/Art/DefenseTowers-v1/Resources/DefenseTowers-v1/Summons/LavaSlime/Idle.png b/Assets/_Project/Art/DefenseTowers-v1/Resources/DefenseTowers-v1/Summons/LavaSlime/Idle.png index fd328f8..eb0d988 100644 Binary files a/Assets/_Project/Art/DefenseTowers-v1/Resources/DefenseTowers-v1/Summons/LavaSlime/Idle.png and b/Assets/_Project/Art/DefenseTowers-v1/Resources/DefenseTowers-v1/Summons/LavaSlime/Idle.png differ diff --git a/Assets/_Project/Art/DefenseTowers-v1/Resources/DefenseTowers-v1/Summons/LavaSlime/Walk.png b/Assets/_Project/Art/DefenseTowers-v1/Resources/DefenseTowers-v1/Summons/LavaSlime/Walk.png index 259e38f..8d703c7 100644 Binary files a/Assets/_Project/Art/DefenseTowers-v1/Resources/DefenseTowers-v1/Summons/LavaSlime/Walk.png and b/Assets/_Project/Art/DefenseTowers-v1/Resources/DefenseTowers-v1/Summons/LavaSlime/Walk.png differ diff --git a/Assets/_Project/Art/DefenseTowers-v1/Resources/DefenseTowers-v1/Summons/Slime/Attack01.png b/Assets/_Project/Art/DefenseTowers-v1/Resources/DefenseTowers-v1/Summons/Slime/Attack01.png index 0a81629..df76e96 100644 Binary files a/Assets/_Project/Art/DefenseTowers-v1/Resources/DefenseTowers-v1/Summons/Slime/Attack01.png and b/Assets/_Project/Art/DefenseTowers-v1/Resources/DefenseTowers-v1/Summons/Slime/Attack01.png differ diff --git a/Assets/_Project/Art/DefenseTowers-v1/Resources/DefenseTowers-v1/Summons/Slime/Attack02.png b/Assets/_Project/Art/DefenseTowers-v1/Resources/DefenseTowers-v1/Summons/Slime/Attack02.png index ce1b840..2462064 100644 Binary files a/Assets/_Project/Art/DefenseTowers-v1/Resources/DefenseTowers-v1/Summons/Slime/Attack02.png and b/Assets/_Project/Art/DefenseTowers-v1/Resources/DefenseTowers-v1/Summons/Slime/Attack02.png differ diff --git a/Assets/_Project/Art/DefenseTowers-v1/Resources/DefenseTowers-v1/Summons/Slime/Death.png b/Assets/_Project/Art/DefenseTowers-v1/Resources/DefenseTowers-v1/Summons/Slime/Death.png index a4d5031..620a3e3 100644 Binary files a/Assets/_Project/Art/DefenseTowers-v1/Resources/DefenseTowers-v1/Summons/Slime/Death.png and b/Assets/_Project/Art/DefenseTowers-v1/Resources/DefenseTowers-v1/Summons/Slime/Death.png differ diff --git a/Assets/_Project/Art/DefenseTowers-v1/Resources/DefenseTowers-v1/Summons/Slime/Hurt.png b/Assets/_Project/Art/DefenseTowers-v1/Resources/DefenseTowers-v1/Summons/Slime/Hurt.png index 3944add..0d4e6ee 100644 Binary files a/Assets/_Project/Art/DefenseTowers-v1/Resources/DefenseTowers-v1/Summons/Slime/Hurt.png and b/Assets/_Project/Art/DefenseTowers-v1/Resources/DefenseTowers-v1/Summons/Slime/Hurt.png differ diff --git a/Assets/_Project/Art/DefenseTowers-v1/Resources/DefenseTowers-v1/Summons/Slime/Idle.png b/Assets/_Project/Art/DefenseTowers-v1/Resources/DefenseTowers-v1/Summons/Slime/Idle.png index e4a5095..5f6f284 100644 Binary files a/Assets/_Project/Art/DefenseTowers-v1/Resources/DefenseTowers-v1/Summons/Slime/Idle.png and b/Assets/_Project/Art/DefenseTowers-v1/Resources/DefenseTowers-v1/Summons/Slime/Idle.png differ diff --git a/Assets/_Project/Art/DefenseTowers-v1/Resources/DefenseTowers-v1/Summons/Slime/Walk.png b/Assets/_Project/Art/DefenseTowers-v1/Resources/DefenseTowers-v1/Summons/Slime/Walk.png index ecf387d..074d8f8 100644 Binary files a/Assets/_Project/Art/DefenseTowers-v1/Resources/DefenseTowers-v1/Summons/Slime/Walk.png and b/Assets/_Project/Art/DefenseTowers-v1/Resources/DefenseTowers-v1/Summons/Slime/Walk.png differ diff --git a/Assets/_Project/Constants/Defense/DefenseConstants.asset b/Assets/_Project/Constants/Defense/DefenseConstants.asset index 158c429..91607f9 100644 --- a/Assets/_Project/Constants/Defense/DefenseConstants.asset +++ b/Assets/_Project/Constants/Defense/DefenseConstants.asset @@ -59,12 +59,26 @@ MonoBehaviour: TowerRange: 5.5 DirectTowerDamage: 10 DirectTowerAttackInterval: 0.75 + FireT4BeamSecondTierSeconds: 1 + FireT4BeamMaximumTierSeconds: 2 + FireT4BeamBaseDamageMultiplier: 0.4 + FireT4BeamSecondTierMultiplier: 1.5 + FireT4BeamMaximumMultiplier: 2 + FireT4BeamResetAfterSeconds: 1 + FireT4BeamSecondTierWidthMultiplier: 1.5 + FireT4BeamMaximumWidthMultiplier: 2 SummonDamage: 6 SummonAttackInterval: 0.9 SummonUnitCount: 2 + SummonUnitMaximumHealth: 30 + SummonUnitCollisionRadius: 0.32 + SummonUnitRespawnSeconds: 3 SummonAreaRadius: 2.25 SummonUnitMoveSpeed: 2.5 SummonAttackRange: 1.2 + SummonBodySlamImpactFrame: 3 + SummonBodySlamContactMargin: 0.06 + SummonBodySlamSpeedMultiplier: 2 LightningDamage: 6 LightningInterval: 1.1 LightningRadius: 1.3 @@ -72,6 +86,8 @@ MonoBehaviour: ColdInterval: 1.2 ColdSlowFraction: 0.35 ColdSlowSeconds: 1.5 + ColdProjectileBurstInterval: 0.12 + FreezeboltProjectileCount: 5 SeismicDamage: 2 SeismicInterval: 2 SeismicStunSeconds: 0.45 diff --git a/Assets/_Project/Constants/Defense25DComparison.meta b/Assets/_Project/Constants/Defense25DComparison.meta new file mode 100644 index 0000000..8d46f5e --- /dev/null +++ b/Assets/_Project/Constants/Defense25DComparison.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 9d600a0ef58439f4480db67b07e4d705 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/_Project/Constants/Defense25DComparison/ComparisonSettings.asset b/Assets/_Project/Constants/Defense25DComparison/ComparisonSettings.asset new file mode 100644 index 0000000..c7a0d71 --- /dev/null +++ b/Assets/_Project/Constants/Defense25DComparison/ComparisonSettings.asset @@ -0,0 +1,128 @@ +%YAML 1.1 +%TAG !u! tag:unity3d.com,2011: +--- !u!114 &11400000 +MonoBehaviour: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 0} + m_Enabled: 1 + m_EditorHideFlags: 0 + m_Script: {fileID: 11500000, guid: 5366a103ee5d47b79aa3feb1b3ca3f4d, type: 3} + m_Name: ComparisonSettings + m_EditorClassIdentifier: BumpCombat.Runtime::BumpCombat.Constants.Defense25DComparisonSettings + GroundY: 0 + MapHalfExtents: {x: 10.5, y: 5.5} + PlayerSpawn: {x: -5, y: -2} + TowerPosition: {x: 1, y: 1.8} + Route: + - {x: -9, y: 0} + - {x: -4, y: 0} + - {x: -2, y: 2.4} + - {x: 4, y: 2.4} + - {x: 9, y: 0} + SummonSlots: + - {x: -0.6, y: -0.9} + - {x: 0.6, y: -0.9} + PortalImmunity: 0 + CameraPitch: 50 + CameraSize: 6.5 + SouthBoundaryRenderHeightFraction: 0.4 + PlayerRadius: 0.275 + PlayerColliderOffset: {x: 0, y: -0.075} + PlayerVisualScale: 1.25 + PlayerFootOffset: -0.29296875 + EnemyRadius: 0.275 + EnemyColliderOffset: {x: 0, y: -0.35} + EnemyVisualScale: 1.25 + EnemyFootOffset: -0.4 + EnemyBodyCenterCorrectionPixels: 3 + SummonVisualScale: 1.1 + SummonFootOffset: -0.30937502 + ActorHeight: 0.85 + MovementSkin: 0.002 + ContactSensorMargin: 0.025 + Enemy: {fileID: 11400000, guid: 7f938c46038bd274896aa9402aadad11, type: 2} + EnemyAttackClip: {fileID: 7400000, guid: 9b8ced6bd5e565a4da13186c14ccbfbb, type: 2} + Cyclone: {fileID: 11400000, guid: 0cd054022c7b4781bc8fa2d35121a0de, type: 2} + PlayerArt: + Idle: + - {fileID: 8592238648091824432, guid: cf5ee5ee251612148a5a3cceb77e3e09, type: 3} + - {fileID: 1250004922889109162, guid: cf5ee5ee251612148a5a3cceb77e3e09, type: 3} + - {fileID: -5028620923284104887, guid: cf5ee5ee251612148a5a3cceb77e3e09, type: 3} + - {fileID: 2022252980215292256, guid: cf5ee5ee251612148a5a3cceb77e3e09, type: 3} + - {fileID: -2599092321403200321, guid: cf5ee5ee251612148a5a3cceb77e3e09, type: 3} + - {fileID: 5488730835254470119, guid: cf5ee5ee251612148a5a3cceb77e3e09, type: 3} + Walk: + - {fileID: -510655448263928137, guid: 6f1776132693af24c9999cf2e7c518c7, type: 3} + - {fileID: 3479404773181816430, guid: 6f1776132693af24c9999cf2e7c518c7, type: 3} + - {fileID: -2283166651885375184, guid: 6f1776132693af24c9999cf2e7c518c7, type: 3} + - {fileID: 3210028164496969578, guid: 6f1776132693af24c9999cf2e7c518c7, type: 3} + - {fileID: -2276603239645387600, guid: 6f1776132693af24c9999cf2e7c518c7, type: 3} + - {fileID: 4573818798471642851, guid: 6f1776132693af24c9999cf2e7c518c7, type: 3} + - {fileID: 8383016689336500281, guid: 6f1776132693af24c9999cf2e7c518c7, type: 3} + - {fileID: 4467538224481176286, guid: 6f1776132693af24c9999cf2e7c518c7, type: 3} + Attack: + - {fileID: 21300000, guid: 6c25effeb9ec57a4a20f65d23369edad, type: 3} + - {fileID: 21300001, guid: 6c25effeb9ec57a4a20f65d23369edad, type: 3} + - {fileID: 21300002, guid: 6c25effeb9ec57a4a20f65d23369edad, type: 3} + - {fileID: 21300003, guid: 6c25effeb9ec57a4a20f65d23369edad, type: 3} + - {fileID: 21300004, guid: 6c25effeb9ec57a4a20f65d23369edad, type: 3} + - {fileID: 21300005, guid: 6c25effeb9ec57a4a20f65d23369edad, type: 3} + Hurt: + - {fileID: 457371520115034197, guid: 5940cfeb72cce444fb5a7bf7c5373eca, type: 3} + - {fileID: 3404584728210486745, guid: 5940cfeb72cce444fb5a7bf7c5373eca, type: 3} + - {fileID: 7129331961366924758, guid: 5940cfeb72cce444fb5a7bf7c5373eca, type: 3} + - {fileID: 8197722601435448183, guid: 5940cfeb72cce444fb5a7bf7c5373eca, type: 3} + - {fileID: 7704259262516875320, guid: 5940cfeb72cce444fb5a7bf7c5373eca, type: 3} + Death: + - {fileID: 2811593804088240790, guid: 70b33e6f7545e6f47a46385d74aaaf83, type: 3} + - {fileID: -7828097450302239583, guid: 70b33e6f7545e6f47a46385d74aaaf83, type: 3} + - {fileID: 361189222234674287, guid: 70b33e6f7545e6f47a46385d74aaaf83, type: 3} + - {fileID: 9116452327212125477, guid: 70b33e6f7545e6f47a46385d74aaaf83, type: 3} + EnemyArt: + Idle: + - {fileID: 8488328654572304352, guid: 07f9aef17c7d24e4399f66b931c1c71f, type: 3} + - {fileID: 3789744679490320462, guid: 07f9aef17c7d24e4399f66b931c1c71f, type: 3} + - {fileID: 2240755264892537048, guid: 07f9aef17c7d24e4399f66b931c1c71f, type: 3} + - {fileID: 2034087598805426463, guid: 07f9aef17c7d24e4399f66b931c1c71f, type: 3} + - {fileID: 8271520994771697968, guid: 07f9aef17c7d24e4399f66b931c1c71f, type: 3} + - {fileID: 5978591873814285882, guid: 07f9aef17c7d24e4399f66b931c1c71f, type: 3} + Walk: + - {fileID: -319509429510461966, guid: dbe397424a57ec34991241bdec4068bf, type: 3} + - {fileID: -5704084491819767634, guid: dbe397424a57ec34991241bdec4068bf, type: 3} + - {fileID: 8477824621842717129, guid: dbe397424a57ec34991241bdec4068bf, type: 3} + - {fileID: 3051669912371892857, guid: dbe397424a57ec34991241bdec4068bf, type: 3} + - {fileID: 7539542233003775106, guid: dbe397424a57ec34991241bdec4068bf, type: 3} + - {fileID: 2788143195985776107, guid: dbe397424a57ec34991241bdec4068bf, type: 3} + - {fileID: -119383558338234881, guid: dbe397424a57ec34991241bdec4068bf, type: 3} + - {fileID: 1567333206898370016, guid: dbe397424a57ec34991241bdec4068bf, type: 3} + Attack: + - {fileID: -4931541286253664743, guid: 017709d2bd1cb5d489eb197051a78427, type: 3} + - {fileID: 6703666654972841543, guid: 017709d2bd1cb5d489eb197051a78427, type: 3} + - {fileID: 5374936872560645210, guid: 017709d2bd1cb5d489eb197051a78427, type: 3} + - {fileID: -5799585484308707375, guid: 017709d2bd1cb5d489eb197051a78427, type: 3} + - {fileID: 4528416531530387699, guid: 017709d2bd1cb5d489eb197051a78427, type: 3} + - {fileID: 3317778627300228566, guid: 017709d2bd1cb5d489eb197051a78427, type: 3} + Hurt: + - {fileID: 933120629603562079, guid: 142640e0a095a714c82d152b666b9884, type: 3} + - {fileID: -494057186547109401, guid: 142640e0a095a714c82d152b666b9884, type: 3} + - {fileID: 2785721821212994034, guid: 142640e0a095a714c82d152b666b9884, type: 3} + - {fileID: -5188259159075104939, guid: 142640e0a095a714c82d152b666b9884, type: 3} + Death: + - {fileID: 8610279347594985942, guid: 7d5e7c2c790e42346a9f2ebbf23461e4, type: 3} + - {fileID: -3134359789022687535, guid: 7d5e7c2c790e42346a9f2ebbf23461e4, type: 3} + - {fileID: 8399632352712144361, guid: 7d5e7c2c790e42346a9f2ebbf23461e4, type: 3} + - {fileID: -2072202752667699138, guid: 7d5e7c2c790e42346a9f2ebbf23461e4, type: 3} + - {fileID: 7143198583629916187, guid: 7d5e7c2c790e42346a9f2ebbf23461e4, type: 3} + - {fileID: 4093069291906818420, guid: 7d5e7c2c790e42346a9f2ebbf23461e4, type: 3} + CutoutMaterial: {fileID: 2100000, guid: 30482bc8080f8594a92573c7e91e7d44, type: 2} + TransparentMaterial: {fileID: 2100000, guid: dfafc04d69db1824191ef8b87cf1295a, type: 2} + GroundMaterial: {fileID: 2100000, guid: 0ab5f733c0b0e8a428f90a7aa1f33d1b, type: 2} + WallMaterial: {fileID: 2100000, guid: b982e9bfd1eb9ce4c80b628b9b2b6c91, type: 2} + PathMaterial: {fileID: 2100000, guid: 65a6bf5b1c33b80489a703cc8fe91efb, type: 2} + ShadowMaterial: {fileID: 2100000, guid: 7262b6d429f43844ebd26ac4dfcf2cd6, type: 2} + BaseMaterial: {fileID: 2100000, guid: 27680ecdcb0d9c4408af43beb753bd64, type: 2} + BoundaryMaterial: {fileID: 2100000, guid: 96ad047810b9ae846bc2f54bf2968c31, type: 2} + HitBoundaryMaterial: {fileID: 2100000, guid: eee57eaf919f7b347b8bc513b3804b89, type: 2} diff --git a/Assets/_Project/Constants/Defense25DComparison/ComparisonSettings.asset.meta b/Assets/_Project/Constants/Defense25DComparison/ComparisonSettings.asset.meta new file mode 100644 index 0000000..129dc7c --- /dev/null +++ b/Assets/_Project/Constants/Defense25DComparison/ComparisonSettings.asset.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: fc0c99c602cf67042a4305ccbdcc8c2f +NativeFormatImporter: + externalObjects: {} + mainObjectFileID: 11400000 + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/_Project/Resources/Combat/TowerImpact-Cold-Clean-v1.png b/Assets/_Project/Resources/Combat/TowerImpact-Cold-Clean-v1.png new file mode 100644 index 0000000..b467b35 Binary files /dev/null and b/Assets/_Project/Resources/Combat/TowerImpact-Cold-Clean-v1.png differ diff --git a/Assets/_Project/Resources/Combat/TowerImpact-Cold-Clean-v1.png.meta b/Assets/_Project/Resources/Combat/TowerImpact-Cold-Clean-v1.png.meta new file mode 100644 index 0000000..e939d97 --- /dev/null +++ b/Assets/_Project/Resources/Combat/TowerImpact-Cold-Clean-v1.png.meta @@ -0,0 +1,221 @@ +fileFormatVersion: 2 +guid: daaafe9016d35f219b2a03842f2b90cc +TextureImporter: + internalIDToNameTable: + - first: + 213: 21300000 + second: TowerImpact-Cold-Clean-v1_0 + - first: + 213: 21300001 + second: TowerImpact-Cold-Clean-v1_1 + - first: + 213: 21300002 + second: TowerImpact-Cold-Clean-v1_2 + - first: + 213: 21300003 + second: TowerImpact-Cold-Clean-v1_3 + externalObjects: {} + serializedVersion: 13 + mipmaps: + mipMapMode: 0 + enableMipMap: 0 + sRGBTexture: 1 + linearTexture: 0 + fadeOut: 0 + borderMipMap: 0 + mipMapsPreserveCoverage: 0 + alphaTestReferenceValue: 0.5 + mipMapFadeDistanceStart: 1 + mipMapFadeDistanceEnd: 3 + bumpmap: + convertToNormalMap: 0 + externalNormalMap: 0 + heightScale: 0.25 + normalMapFilter: 0 + flipGreenChannel: 0 + isReadable: 0 + streamingMipmaps: 0 + streamingMipmapsPriority: 0 + vTOnly: 0 + ignoreMipmapLimit: 0 + grayScaleToAlpha: 0 + generateCubemap: 6 + cubemapConvolution: 0 + seamlessCubemap: 0 + textureFormat: 1 + maxTextureSize: 2048 + textureSettings: + serializedVersion: 2 + filterMode: 0 + aniso: 1 + mipBias: 0 + wrapU: 1 + wrapV: 1 + wrapW: 1 + nPOTScale: 0 + lightmap: 0 + compressionQuality: 50 + spriteMode: 2 + spriteExtrude: 1 + spriteMeshType: 1 + alignment: 0 + spritePivot: {x: 0.5, y: 0.5} + spritePixelsToUnits: 32 + spriteBorder: {x: 0, y: 0, z: 0, w: 0} + spriteGenerateFallbackPhysicsShape: 1 + alphaUsage: 1 + alphaIsTransparency: 1 + spriteTessellationDetail: -1 + textureType: 8 + textureShape: 1 + singleChannelComponent: 0 + flipbookRows: 1 + flipbookColumns: 1 + maxTextureSizeSet: 0 + compressionQualitySet: 0 + textureFormatSet: 0 + ignorePngGamma: 0 + applyGammaDecoding: 0 + swizzle: 50462976 + cookieLightType: 0 + platformSettings: + - serializedVersion: 4 + buildTarget: DefaultTexturePlatform + maxTextureSize: 2048 + resizeAlgorithm: 0 + textureFormat: -1 + textureCompression: 0 + compressionQuality: 50 + crunchedCompression: 0 + allowsAlphaSplitting: 0 + overridden: 0 + ignorePlatformSupport: 0 + androidETC2FallbackOverride: 0 + forceMaximumCompressionQuality_BC6H_BC7: 0 + - serializedVersion: 4 + buildTarget: Standalone + maxTextureSize: 2048 + resizeAlgorithm: 0 + textureFormat: -1 + textureCompression: 1 + compressionQuality: 50 + crunchedCompression: 0 + allowsAlphaSplitting: 0 + overridden: 0 + ignorePlatformSupport: 0 + androidETC2FallbackOverride: 0 + forceMaximumCompressionQuality_BC6H_BC7: 0 + spriteSheet: + serializedVersion: 2 + sprites: + - serializedVersion: 2 + name: TowerImpact-Cold-Clean-v1_0 + rect: + serializedVersion: 2 + x: 0 + y: 0 + width: 64 + height: 64 + alignment: 0 + pivot: {x: 0.5, y: 0.5} + border: {x: 0, y: 0, z: 0, w: 0} + customData: + outline: [] + physicsShape: [] + tessellationDetail: 0 + bones: [] + spriteID: 62887aabfb2455d6bf9ba33d6ab6ee61 + internalID: 21300000 + vertices: [] + indices: + edges: [] + weights: [] + - serializedVersion: 2 + name: TowerImpact-Cold-Clean-v1_1 + rect: + serializedVersion: 2 + x: 64 + y: 0 + width: 64 + height: 64 + alignment: 0 + pivot: {x: 0.5, y: 0.5} + border: {x: 0, y: 0, z: 0, w: 0} + customData: + outline: [] + physicsShape: [] + tessellationDetail: 0 + bones: [] + spriteID: 1ef8844679b85eb38e016775dabbeb5c + internalID: 21300001 + vertices: [] + indices: + edges: [] + weights: [] + - serializedVersion: 2 + name: TowerImpact-Cold-Clean-v1_2 + rect: + serializedVersion: 2 + x: 128 + y: 0 + width: 64 + height: 64 + alignment: 0 + pivot: {x: 0.5, y: 0.5} + border: {x: 0, y: 0, z: 0, w: 0} + customData: + outline: [] + physicsShape: [] + tessellationDetail: 0 + bones: [] + spriteID: df4addd380a154b4ac0aede7503160b3 + internalID: 21300002 + vertices: [] + indices: + edges: [] + weights: [] + - serializedVersion: 2 + name: TowerImpact-Cold-Clean-v1_3 + rect: + serializedVersion: 2 + x: 192 + y: 0 + width: 64 + height: 64 + alignment: 0 + pivot: {x: 0.5, y: 0.5} + border: {x: 0, y: 0, z: 0, w: 0} + customData: + outline: [] + physicsShape: [] + tessellationDetail: 0 + bones: [] + spriteID: 2114fea9861751a0a42fd244ace547fd + internalID: 21300003 + vertices: [] + indices: + edges: [] + weights: [] + outline: [] + customData: + physicsShape: [] + bones: [] + spriteID: b4ebc1971c8c555ba6bce87e94485543 + internalID: 0 + vertices: [] + indices: + edges: [] + weights: [] + secondaryTextures: [] + spriteCustomMetadata: + entries: [] + nameFileIdTable: + TowerImpact-Cold-Clean-v1_0: 21300000 + TowerImpact-Cold-Clean-v1_1: 21300001 + TowerImpact-Cold-Clean-v1_2: 21300002 + TowerImpact-Cold-Clean-v1_3: 21300003 + mipmapLimitGroupName: + pSDRemoveMatte: 0 + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/_Project/Resources/Combat/TowerImpact-Direct-Clean-v1.png b/Assets/_Project/Resources/Combat/TowerImpact-Direct-Clean-v1.png new file mode 100644 index 0000000..5d1b11c Binary files /dev/null and b/Assets/_Project/Resources/Combat/TowerImpact-Direct-Clean-v1.png differ diff --git a/Assets/_Project/Resources/Combat/TowerImpact-Direct-Clean-v1.png.meta b/Assets/_Project/Resources/Combat/TowerImpact-Direct-Clean-v1.png.meta new file mode 100644 index 0000000..946c61d --- /dev/null +++ b/Assets/_Project/Resources/Combat/TowerImpact-Direct-Clean-v1.png.meta @@ -0,0 +1,221 @@ +fileFormatVersion: 2 +guid: 136d80cc377d54c59d4cab2156b087c0 +TextureImporter: + internalIDToNameTable: + - first: + 213: 21300000 + second: TowerImpact-Direct-Clean-v1_0 + - first: + 213: 21300001 + second: TowerImpact-Direct-Clean-v1_1 + - first: + 213: 21300002 + second: TowerImpact-Direct-Clean-v1_2 + - first: + 213: 21300003 + second: TowerImpact-Direct-Clean-v1_3 + externalObjects: {} + serializedVersion: 13 + mipmaps: + mipMapMode: 0 + enableMipMap: 0 + sRGBTexture: 1 + linearTexture: 0 + fadeOut: 0 + borderMipMap: 0 + mipMapsPreserveCoverage: 0 + alphaTestReferenceValue: 0.5 + mipMapFadeDistanceStart: 1 + mipMapFadeDistanceEnd: 3 + bumpmap: + convertToNormalMap: 0 + externalNormalMap: 0 + heightScale: 0.25 + normalMapFilter: 0 + flipGreenChannel: 0 + isReadable: 0 + streamingMipmaps: 0 + streamingMipmapsPriority: 0 + vTOnly: 0 + ignoreMipmapLimit: 0 + grayScaleToAlpha: 0 + generateCubemap: 6 + cubemapConvolution: 0 + seamlessCubemap: 0 + textureFormat: 1 + maxTextureSize: 2048 + textureSettings: + serializedVersion: 2 + filterMode: 0 + aniso: 1 + mipBias: 0 + wrapU: 1 + wrapV: 1 + wrapW: 1 + nPOTScale: 0 + lightmap: 0 + compressionQuality: 50 + spriteMode: 2 + spriteExtrude: 1 + spriteMeshType: 1 + alignment: 0 + spritePivot: {x: 0.5, y: 0.5} + spritePixelsToUnits: 32 + spriteBorder: {x: 0, y: 0, z: 0, w: 0} + spriteGenerateFallbackPhysicsShape: 1 + alphaUsage: 1 + alphaIsTransparency: 1 + spriteTessellationDetail: -1 + textureType: 8 + textureShape: 1 + singleChannelComponent: 0 + flipbookRows: 1 + flipbookColumns: 1 + maxTextureSizeSet: 0 + compressionQualitySet: 0 + textureFormatSet: 0 + ignorePngGamma: 0 + applyGammaDecoding: 0 + swizzle: 50462976 + cookieLightType: 0 + platformSettings: + - serializedVersion: 4 + buildTarget: DefaultTexturePlatform + maxTextureSize: 2048 + resizeAlgorithm: 0 + textureFormat: -1 + textureCompression: 0 + compressionQuality: 50 + crunchedCompression: 0 + allowsAlphaSplitting: 0 + overridden: 0 + ignorePlatformSupport: 0 + androidETC2FallbackOverride: 0 + forceMaximumCompressionQuality_BC6H_BC7: 0 + - serializedVersion: 4 + buildTarget: Standalone + maxTextureSize: 2048 + resizeAlgorithm: 0 + textureFormat: -1 + textureCompression: 1 + compressionQuality: 50 + crunchedCompression: 0 + allowsAlphaSplitting: 0 + overridden: 0 + ignorePlatformSupport: 0 + androidETC2FallbackOverride: 0 + forceMaximumCompressionQuality_BC6H_BC7: 0 + spriteSheet: + serializedVersion: 2 + sprites: + - serializedVersion: 2 + name: TowerImpact-Direct-Clean-v1_0 + rect: + serializedVersion: 2 + x: 0 + y: 0 + width: 64 + height: 64 + alignment: 0 + pivot: {x: 0.5, y: 0.5} + border: {x: 0, y: 0, z: 0, w: 0} + customData: + outline: [] + physicsShape: [] + tessellationDetail: 0 + bones: [] + spriteID: 48954119e7eb5350add1264f92577ff3 + internalID: 21300000 + vertices: [] + indices: + edges: [] + weights: [] + - serializedVersion: 2 + name: TowerImpact-Direct-Clean-v1_1 + rect: + serializedVersion: 2 + x: 64 + y: 0 + width: 64 + height: 64 + alignment: 0 + pivot: {x: 0.5, y: 0.5} + border: {x: 0, y: 0, z: 0, w: 0} + customData: + outline: [] + physicsShape: [] + tessellationDetail: 0 + bones: [] + spriteID: 6969f47957f454d78c21068a664e7911 + internalID: 21300001 + vertices: [] + indices: + edges: [] + weights: [] + - serializedVersion: 2 + name: TowerImpact-Direct-Clean-v1_2 + rect: + serializedVersion: 2 + x: 128 + y: 0 + width: 64 + height: 64 + alignment: 0 + pivot: {x: 0.5, y: 0.5} + border: {x: 0, y: 0, z: 0, w: 0} + customData: + outline: [] + physicsShape: [] + tessellationDetail: 0 + bones: [] + spriteID: efb88da9b27c593890a8c47611dc8457 + internalID: 21300002 + vertices: [] + indices: + edges: [] + weights: [] + - serializedVersion: 2 + name: TowerImpact-Direct-Clean-v1_3 + rect: + serializedVersion: 2 + x: 192 + y: 0 + width: 64 + height: 64 + alignment: 0 + pivot: {x: 0.5, y: 0.5} + border: {x: 0, y: 0, z: 0, w: 0} + customData: + outline: [] + physicsShape: [] + tessellationDetail: 0 + bones: [] + spriteID: 72ac250e7eec588db4555181fd91dd57 + internalID: 21300003 + vertices: [] + indices: + edges: [] + weights: [] + outline: [] + customData: + physicsShape: [] + bones: [] + spriteID: 21ab6e30d13251e19433ff928affccb8 + internalID: 0 + vertices: [] + indices: + edges: [] + weights: [] + secondaryTextures: [] + spriteCustomMetadata: + entries: [] + nameFileIdTable: + TowerImpact-Direct-Clean-v1_0: 21300000 + TowerImpact-Direct-Clean-v1_1: 21300001 + TowerImpact-Direct-Clean-v1_2: 21300002 + TowerImpact-Direct-Clean-v1_3: 21300003 + mipmapLimitGroupName: + pSDRemoveMatte: 0 + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/_Project/Resources/Combat/TowerImpact-Lightning-Clean-v1.png b/Assets/_Project/Resources/Combat/TowerImpact-Lightning-Clean-v1.png new file mode 100644 index 0000000..178b1dd Binary files /dev/null and b/Assets/_Project/Resources/Combat/TowerImpact-Lightning-Clean-v1.png differ diff --git a/Assets/_Project/Resources/Combat/TowerImpact-Lightning-Clean-v1.png.meta b/Assets/_Project/Resources/Combat/TowerImpact-Lightning-Clean-v1.png.meta new file mode 100644 index 0000000..a487bbf --- /dev/null +++ b/Assets/_Project/Resources/Combat/TowerImpact-Lightning-Clean-v1.png.meta @@ -0,0 +1,221 @@ +fileFormatVersion: 2 +guid: 1a71b5120f2055fea4503f9620cf7d26 +TextureImporter: + internalIDToNameTable: + - first: + 213: 21300000 + second: TowerImpact-Lightning-Clean-v1_0 + - first: + 213: 21300001 + second: TowerImpact-Lightning-Clean-v1_1 + - first: + 213: 21300002 + second: TowerImpact-Lightning-Clean-v1_2 + - first: + 213: 21300003 + second: TowerImpact-Lightning-Clean-v1_3 + externalObjects: {} + serializedVersion: 13 + mipmaps: + mipMapMode: 0 + enableMipMap: 0 + sRGBTexture: 1 + linearTexture: 0 + fadeOut: 0 + borderMipMap: 0 + mipMapsPreserveCoverage: 0 + alphaTestReferenceValue: 0.5 + mipMapFadeDistanceStart: 1 + mipMapFadeDistanceEnd: 3 + bumpmap: + convertToNormalMap: 0 + externalNormalMap: 0 + heightScale: 0.25 + normalMapFilter: 0 + flipGreenChannel: 0 + isReadable: 0 + streamingMipmaps: 0 + streamingMipmapsPriority: 0 + vTOnly: 0 + ignoreMipmapLimit: 0 + grayScaleToAlpha: 0 + generateCubemap: 6 + cubemapConvolution: 0 + seamlessCubemap: 0 + textureFormat: 1 + maxTextureSize: 2048 + textureSettings: + serializedVersion: 2 + filterMode: 0 + aniso: 1 + mipBias: 0 + wrapU: 1 + wrapV: 1 + wrapW: 1 + nPOTScale: 0 + lightmap: 0 + compressionQuality: 50 + spriteMode: 2 + spriteExtrude: 1 + spriteMeshType: 1 + alignment: 0 + spritePivot: {x: 0.5, y: 0.5} + spritePixelsToUnits: 32 + spriteBorder: {x: 0, y: 0, z: 0, w: 0} + spriteGenerateFallbackPhysicsShape: 1 + alphaUsage: 1 + alphaIsTransparency: 1 + spriteTessellationDetail: -1 + textureType: 8 + textureShape: 1 + singleChannelComponent: 0 + flipbookRows: 1 + flipbookColumns: 1 + maxTextureSizeSet: 0 + compressionQualitySet: 0 + textureFormatSet: 0 + ignorePngGamma: 0 + applyGammaDecoding: 0 + swizzle: 50462976 + cookieLightType: 0 + platformSettings: + - serializedVersion: 4 + buildTarget: DefaultTexturePlatform + maxTextureSize: 2048 + resizeAlgorithm: 0 + textureFormat: -1 + textureCompression: 0 + compressionQuality: 50 + crunchedCompression: 0 + allowsAlphaSplitting: 0 + overridden: 0 + ignorePlatformSupport: 0 + androidETC2FallbackOverride: 0 + forceMaximumCompressionQuality_BC6H_BC7: 0 + - serializedVersion: 4 + buildTarget: Standalone + maxTextureSize: 2048 + resizeAlgorithm: 0 + textureFormat: -1 + textureCompression: 1 + compressionQuality: 50 + crunchedCompression: 0 + allowsAlphaSplitting: 0 + overridden: 0 + ignorePlatformSupport: 0 + androidETC2FallbackOverride: 0 + forceMaximumCompressionQuality_BC6H_BC7: 0 + spriteSheet: + serializedVersion: 2 + sprites: + - serializedVersion: 2 + name: TowerImpact-Lightning-Clean-v1_0 + rect: + serializedVersion: 2 + x: 0 + y: 0 + width: 64 + height: 64 + alignment: 0 + pivot: {x: 0.5, y: 0.5} + border: {x: 0, y: 0, z: 0, w: 0} + customData: + outline: [] + physicsShape: [] + tessellationDetail: 0 + bones: [] + spriteID: cd9ac527673f5338bdc9dccb1e306861 + internalID: 21300000 + vertices: [] + indices: + edges: [] + weights: [] + - serializedVersion: 2 + name: TowerImpact-Lightning-Clean-v1_1 + rect: + serializedVersion: 2 + x: 64 + y: 0 + width: 64 + height: 64 + alignment: 0 + pivot: {x: 0.5, y: 0.5} + border: {x: 0, y: 0, z: 0, w: 0} + customData: + outline: [] + physicsShape: [] + tessellationDetail: 0 + bones: [] + spriteID: 34dea063251f5665aa3d45a5878ffa9d + internalID: 21300001 + vertices: [] + indices: + edges: [] + weights: [] + - serializedVersion: 2 + name: TowerImpact-Lightning-Clean-v1_2 + rect: + serializedVersion: 2 + x: 128 + y: 0 + width: 64 + height: 64 + alignment: 0 + pivot: {x: 0.5, y: 0.5} + border: {x: 0, y: 0, z: 0, w: 0} + customData: + outline: [] + physicsShape: [] + tessellationDetail: 0 + bones: [] + spriteID: 7e65f69e56405469811e382ff9dacd41 + internalID: 21300002 + vertices: [] + indices: + edges: [] + weights: [] + - serializedVersion: 2 + name: TowerImpact-Lightning-Clean-v1_3 + rect: + serializedVersion: 2 + x: 192 + y: 0 + width: 64 + height: 64 + alignment: 0 + pivot: {x: 0.5, y: 0.5} + border: {x: 0, y: 0, z: 0, w: 0} + customData: + outline: [] + physicsShape: [] + tessellationDetail: 0 + bones: [] + spriteID: 956bbc5f98175616893445c3b4599b23 + internalID: 21300003 + vertices: [] + indices: + edges: [] + weights: [] + outline: [] + customData: + physicsShape: [] + bones: [] + spriteID: b9d5f7a5978651938d2a349901de74b6 + internalID: 0 + vertices: [] + indices: + edges: [] + weights: [] + secondaryTextures: [] + spriteCustomMetadata: + entries: [] + nameFileIdTable: + TowerImpact-Lightning-Clean-v1_0: 21300000 + TowerImpact-Lightning-Clean-v1_1: 21300001 + TowerImpact-Lightning-Clean-v1_2: 21300002 + TowerImpact-Lightning-Clean-v1_3: 21300003 + mipmapLimitGroupName: + pSDRemoveMatte: 0 + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/_Project/Resources/Combat/TowerImpact-Seismic-Clean-v1.png b/Assets/_Project/Resources/Combat/TowerImpact-Seismic-Clean-v1.png new file mode 100644 index 0000000..48e4964 Binary files /dev/null and b/Assets/_Project/Resources/Combat/TowerImpact-Seismic-Clean-v1.png differ diff --git a/Assets/_Project/Resources/Combat/TowerImpact-Seismic-Clean-v1.png.meta b/Assets/_Project/Resources/Combat/TowerImpact-Seismic-Clean-v1.png.meta new file mode 100644 index 0000000..c758eb6 --- /dev/null +++ b/Assets/_Project/Resources/Combat/TowerImpact-Seismic-Clean-v1.png.meta @@ -0,0 +1,221 @@ +fileFormatVersion: 2 +guid: be2fa36cda1f59498a70c2eca27cff28 +TextureImporter: + internalIDToNameTable: + - first: + 213: 21300000 + second: TowerImpact-Seismic-Clean-v1_0 + - first: + 213: 21300001 + second: TowerImpact-Seismic-Clean-v1_1 + - first: + 213: 21300002 + second: TowerImpact-Seismic-Clean-v1_2 + - first: + 213: 21300003 + second: TowerImpact-Seismic-Clean-v1_3 + externalObjects: {} + serializedVersion: 13 + mipmaps: + mipMapMode: 0 + enableMipMap: 0 + sRGBTexture: 1 + linearTexture: 0 + fadeOut: 0 + borderMipMap: 0 + mipMapsPreserveCoverage: 0 + alphaTestReferenceValue: 0.5 + mipMapFadeDistanceStart: 1 + mipMapFadeDistanceEnd: 3 + bumpmap: + convertToNormalMap: 0 + externalNormalMap: 0 + heightScale: 0.25 + normalMapFilter: 0 + flipGreenChannel: 0 + isReadable: 0 + streamingMipmaps: 0 + streamingMipmapsPriority: 0 + vTOnly: 0 + ignoreMipmapLimit: 0 + grayScaleToAlpha: 0 + generateCubemap: 6 + cubemapConvolution: 0 + seamlessCubemap: 0 + textureFormat: 1 + maxTextureSize: 2048 + textureSettings: + serializedVersion: 2 + filterMode: 0 + aniso: 1 + mipBias: 0 + wrapU: 1 + wrapV: 1 + wrapW: 1 + nPOTScale: 0 + lightmap: 0 + compressionQuality: 50 + spriteMode: 2 + spriteExtrude: 1 + spriteMeshType: 1 + alignment: 0 + spritePivot: {x: 0.5, y: 0.5} + spritePixelsToUnits: 32 + spriteBorder: {x: 0, y: 0, z: 0, w: 0} + spriteGenerateFallbackPhysicsShape: 1 + alphaUsage: 1 + alphaIsTransparency: 1 + spriteTessellationDetail: -1 + textureType: 8 + textureShape: 1 + singleChannelComponent: 0 + flipbookRows: 1 + flipbookColumns: 1 + maxTextureSizeSet: 0 + compressionQualitySet: 0 + textureFormatSet: 0 + ignorePngGamma: 0 + applyGammaDecoding: 0 + swizzle: 50462976 + cookieLightType: 0 + platformSettings: + - serializedVersion: 4 + buildTarget: DefaultTexturePlatform + maxTextureSize: 2048 + resizeAlgorithm: 0 + textureFormat: -1 + textureCompression: 0 + compressionQuality: 50 + crunchedCompression: 0 + allowsAlphaSplitting: 0 + overridden: 0 + ignorePlatformSupport: 0 + androidETC2FallbackOverride: 0 + forceMaximumCompressionQuality_BC6H_BC7: 0 + - serializedVersion: 4 + buildTarget: Standalone + maxTextureSize: 2048 + resizeAlgorithm: 0 + textureFormat: -1 + textureCompression: 1 + compressionQuality: 50 + crunchedCompression: 0 + allowsAlphaSplitting: 0 + overridden: 0 + ignorePlatformSupport: 0 + androidETC2FallbackOverride: 0 + forceMaximumCompressionQuality_BC6H_BC7: 0 + spriteSheet: + serializedVersion: 2 + sprites: + - serializedVersion: 2 + name: TowerImpact-Seismic-Clean-v1_0 + rect: + serializedVersion: 2 + x: 0 + y: 0 + width: 64 + height: 64 + alignment: 0 + pivot: {x: 0.5, y: 0.5} + border: {x: 0, y: 0, z: 0, w: 0} + customData: + outline: [] + physicsShape: [] + tessellationDetail: 0 + bones: [] + spriteID: 9708f16c72a05b94b510c07e866e9269 + internalID: 21300000 + vertices: [] + indices: + edges: [] + weights: [] + - serializedVersion: 2 + name: TowerImpact-Seismic-Clean-v1_1 + rect: + serializedVersion: 2 + x: 64 + y: 0 + width: 64 + height: 64 + alignment: 0 + pivot: {x: 0.5, y: 0.5} + border: {x: 0, y: 0, z: 0, w: 0} + customData: + outline: [] + physicsShape: [] + tessellationDetail: 0 + bones: [] + spriteID: 60535391499d593eb24ee76f37793b0d + internalID: 21300001 + vertices: [] + indices: + edges: [] + weights: [] + - serializedVersion: 2 + name: TowerImpact-Seismic-Clean-v1_2 + rect: + serializedVersion: 2 + x: 128 + y: 0 + width: 64 + height: 64 + alignment: 0 + pivot: {x: 0.5, y: 0.5} + border: {x: 0, y: 0, z: 0, w: 0} + customData: + outline: [] + physicsShape: [] + tessellationDetail: 0 + bones: [] + spriteID: 875de6a3cda757c786c7d8e63a69b0e7 + internalID: 21300002 + vertices: [] + indices: + edges: [] + weights: [] + - serializedVersion: 2 + name: TowerImpact-Seismic-Clean-v1_3 + rect: + serializedVersion: 2 + x: 192 + y: 0 + width: 64 + height: 64 + alignment: 0 + pivot: {x: 0.5, y: 0.5} + border: {x: 0, y: 0, z: 0, w: 0} + customData: + outline: [] + physicsShape: [] + tessellationDetail: 0 + bones: [] + spriteID: 7905bdb7ff9e592a9933c01c744b578a + internalID: 21300003 + vertices: [] + indices: + edges: [] + weights: [] + outline: [] + customData: + physicsShape: [] + bones: [] + spriteID: 1201601e4d9c55189882aaed409487e7 + internalID: 0 + vertices: [] + indices: + edges: [] + weights: [] + secondaryTextures: [] + spriteCustomMetadata: + entries: [] + nameFileIdTable: + TowerImpact-Seismic-Clean-v1_0: 21300000 + TowerImpact-Seismic-Clean-v1_1: 21300001 + TowerImpact-Seismic-Clean-v1_2: 21300002 + TowerImpact-Seismic-Clean-v1_3: 21300003 + mipmapLimitGroupName: + pSDRemoveMatte: 0 + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/_Project/Resources/Combat/TowerImpact-Seismic-Normal-v1.png b/Assets/_Project/Resources/Combat/TowerImpact-Seismic-Normal-v1.png new file mode 100644 index 0000000..5c9f5e8 Binary files /dev/null and b/Assets/_Project/Resources/Combat/TowerImpact-Seismic-Normal-v1.png differ diff --git a/Assets/_Project/Resources/Combat/TowerImpact-Seismic-Normal-v1.png.meta b/Assets/_Project/Resources/Combat/TowerImpact-Seismic-Normal-v1.png.meta new file mode 100644 index 0000000..cacb8dc --- /dev/null +++ b/Assets/_Project/Resources/Combat/TowerImpact-Seismic-Normal-v1.png.meta @@ -0,0 +1,221 @@ +fileFormatVersion: 2 +guid: 7246d13b8b525c719cb6e7a2e3fef6f2 +TextureImporter: + internalIDToNameTable: + - first: + 213: 21300000 + second: TowerImpact-Seismic-Normal-v1_0 + - first: + 213: 21300001 + second: TowerImpact-Seismic-Normal-v1_1 + - first: + 213: 21300002 + second: TowerImpact-Seismic-Normal-v1_2 + - first: + 213: 21300003 + second: TowerImpact-Seismic-Normal-v1_3 + externalObjects: {} + serializedVersion: 13 + mipmaps: + mipMapMode: 0 + enableMipMap: 0 + sRGBTexture: 1 + linearTexture: 0 + fadeOut: 0 + borderMipMap: 0 + mipMapsPreserveCoverage: 0 + alphaTestReferenceValue: 0.5 + mipMapFadeDistanceStart: 1 + mipMapFadeDistanceEnd: 3 + bumpmap: + convertToNormalMap: 0 + externalNormalMap: 0 + heightScale: 0.25 + normalMapFilter: 0 + flipGreenChannel: 0 + isReadable: 0 + streamingMipmaps: 0 + streamingMipmapsPriority: 0 + vTOnly: 0 + ignoreMipmapLimit: 0 + grayScaleToAlpha: 0 + generateCubemap: 6 + cubemapConvolution: 0 + seamlessCubemap: 0 + textureFormat: 1 + maxTextureSize: 2048 + textureSettings: + serializedVersion: 2 + filterMode: 0 + aniso: 1 + mipBias: 0 + wrapU: 1 + wrapV: 1 + wrapW: 1 + nPOTScale: 0 + lightmap: 0 + compressionQuality: 50 + spriteMode: 2 + spriteExtrude: 1 + spriteMeshType: 1 + alignment: 0 + spritePivot: {x: 0.5, y: 0.5} + spritePixelsToUnits: 32 + spriteBorder: {x: 0, y: 0, z: 0, w: 0} + spriteGenerateFallbackPhysicsShape: 1 + alphaUsage: 1 + alphaIsTransparency: 1 + spriteTessellationDetail: -1 + textureType: 8 + textureShape: 1 + singleChannelComponent: 0 + flipbookRows: 1 + flipbookColumns: 1 + maxTextureSizeSet: 0 + compressionQualitySet: 0 + textureFormatSet: 0 + ignorePngGamma: 0 + applyGammaDecoding: 0 + swizzle: 50462976 + cookieLightType: 0 + platformSettings: + - serializedVersion: 4 + buildTarget: DefaultTexturePlatform + maxTextureSize: 2048 + resizeAlgorithm: 0 + textureFormat: -1 + textureCompression: 0 + compressionQuality: 50 + crunchedCompression: 0 + allowsAlphaSplitting: 0 + overridden: 0 + ignorePlatformSupport: 0 + androidETC2FallbackOverride: 0 + forceMaximumCompressionQuality_BC6H_BC7: 0 + - serializedVersion: 4 + buildTarget: Standalone + maxTextureSize: 2048 + resizeAlgorithm: 0 + textureFormat: -1 + textureCompression: 1 + compressionQuality: 50 + crunchedCompression: 0 + allowsAlphaSplitting: 0 + overridden: 0 + ignorePlatformSupport: 0 + androidETC2FallbackOverride: 0 + forceMaximumCompressionQuality_BC6H_BC7: 0 + spriteSheet: + serializedVersion: 2 + sprites: + - serializedVersion: 2 + name: TowerImpact-Seismic-Normal-v1_0 + rect: + serializedVersion: 2 + x: 0 + y: 0 + width: 64 + height: 64 + alignment: 0 + pivot: {x: 0.5, y: 0.5} + border: {x: 0, y: 0, z: 0, w: 0} + customData: + outline: [] + physicsShape: [] + tessellationDetail: 0 + bones: [] + spriteID: fefcecbb2c555d6c977e3582d697f9ca + internalID: 21300000 + vertices: [] + indices: + edges: [] + weights: [] + - serializedVersion: 2 + name: TowerImpact-Seismic-Normal-v1_1 + rect: + serializedVersion: 2 + x: 64 + y: 0 + width: 64 + height: 64 + alignment: 0 + pivot: {x: 0.5, y: 0.5} + border: {x: 0, y: 0, z: 0, w: 0} + customData: + outline: [] + physicsShape: [] + tessellationDetail: 0 + bones: [] + spriteID: b7aefd876ef65b27bacaef971ecc6c57 + internalID: 21300001 + vertices: [] + indices: + edges: [] + weights: [] + - serializedVersion: 2 + name: TowerImpact-Seismic-Normal-v1_2 + rect: + serializedVersion: 2 + x: 128 + y: 0 + width: 64 + height: 64 + alignment: 0 + pivot: {x: 0.5, y: 0.5} + border: {x: 0, y: 0, z: 0, w: 0} + customData: + outline: [] + physicsShape: [] + tessellationDetail: 0 + bones: [] + spriteID: adc350e7b0dd5b95b944af14ea921d9b + internalID: 21300002 + vertices: [] + indices: + edges: [] + weights: [] + - serializedVersion: 2 + name: TowerImpact-Seismic-Normal-v1_3 + rect: + serializedVersion: 2 + x: 192 + y: 0 + width: 64 + height: 64 + alignment: 0 + pivot: {x: 0.5, y: 0.5} + border: {x: 0, y: 0, z: 0, w: 0} + customData: + outline: [] + physicsShape: [] + tessellationDetail: 0 + bones: [] + spriteID: e55b0e212cf0547bbc9f8b19f51b228e + internalID: 21300003 + vertices: [] + indices: + edges: [] + weights: [] + outline: [] + customData: + physicsShape: [] + bones: [] + spriteID: 75d8c3fb144e5c81887a154320c7f74f + internalID: 0 + vertices: [] + indices: + edges: [] + weights: [] + secondaryTextures: [] + spriteCustomMetadata: + entries: [] + nameFileIdTable: + TowerImpact-Seismic-Normal-v1_0: 21300000 + TowerImpact-Seismic-Normal-v1_1: 21300001 + TowerImpact-Seismic-Normal-v1_2: 21300002 + TowerImpact-Seismic-Normal-v1_3: 21300003 + mipmapLimitGroupName: + pSDRemoveMatte: 0 + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/_Project/Scenes/Defense25DComparison.unity b/Assets/_Project/Scenes/Defense25DComparison.unity new file mode 100644 index 0000000..3d7e283 --- /dev/null +++ b/Assets/_Project/Scenes/Defense25DComparison.unity @@ -0,0 +1,1670 @@ +%YAML 1.1 +%TAG !u! tag:unity3d.com,2011: +--- !u!29 &1 +OcclusionCullingSettings: + m_ObjectHideFlags: 0 + serializedVersion: 2 + m_OcclusionBakeSettings: + smallestOccluder: 5 + smallestHole: 0.25 + backfaceThreshold: 100 + m_SceneGUID: 00000000000000000000000000000000 + m_OcclusionCullingData: {fileID: 0} +--- !u!104 &2 +RenderSettings: + m_ObjectHideFlags: 0 + serializedVersion: 10 + m_Fog: 0 + m_FogColor: {r: 0.5, g: 0.5, b: 0.5, a: 1} + m_FogMode: 3 + m_FogDensity: 0.01 + m_LinearFogStart: 0 + m_LinearFogEnd: 300 + m_AmbientSkyColor: {r: 0.212, g: 0.227, b: 0.259, a: 1} + m_AmbientEquatorColor: {r: 0.114, g: 0.125, b: 0.133, a: 1} + m_AmbientGroundColor: {r: 0.047, g: 0.043, b: 0.035, a: 1} + m_AmbientIntensity: 1 + m_AmbientMode: 3 + m_SubtractiveShadowColor: {r: 0.42, g: 0.478, b: 0.627, a: 1} + m_SkyboxMaterial: {fileID: 0} + m_HaloStrength: 0.5 + m_FlareStrength: 1 + m_FlareFadeSpeed: 3 + m_HaloTexture: {fileID: 0} + m_SpotCookie: {fileID: 10001, guid: 0000000000000000e000000000000000, type: 0} + m_DefaultReflectionMode: 0 + m_DefaultReflectionResolution: 128 + m_ReflectionBounces: 1 + m_ReflectionIntensity: 1 + m_CustomReflection: {fileID: 0} + m_Sun: {fileID: 0} + m_UseRadianceAmbientProbe: 0 +--- !u!157 &3 +LightmapSettings: + m_ObjectHideFlags: 0 + serializedVersion: 13 + m_BakeOnSceneLoad: 0 + m_GISettings: + serializedVersion: 2 + m_BounceScale: 1 + m_IndirectOutputScale: 1 + m_AlbedoBoost: 1 + m_EnvironmentLightingMode: 0 + m_EnableBakedLightmaps: 0 + m_EnableRealtimeLightmaps: 0 + m_LightmapEditorSettings: + serializedVersion: 12 + m_Resolution: 2 + m_BakeResolution: 40 + m_AtlasSize: 1024 + m_AO: 0 + m_AOMaxDistance: 1 + m_CompAOExponent: 1 + m_CompAOExponentDirect: 0 + m_ExtractAmbientOcclusion: 0 + m_Padding: 2 + m_LightmapParameters: {fileID: 0} + m_LightmapsBakeMode: 1 + m_TextureCompression: 1 + m_ReflectionCompression: 2 + m_MixedBakeMode: 2 + m_BakeBackend: 2 + m_PVRSampling: 1 + m_PVRDirectSampleCount: 32 + m_PVRSampleCount: 512 + m_PVRBounces: 2 + m_PVREnvironmentSampleCount: 256 + m_PVREnvironmentReferencePointCount: 2048 + m_PVRFilteringMode: 1 + m_PVRDenoiserTypeDirect: 1 + m_PVRDenoiserTypeIndirect: 1 + m_PVRDenoiserTypeAO: 1 + m_PVRFilterTypeDirect: 0 + m_PVRFilterTypeIndirect: 0 + m_PVRFilterTypeAO: 0 + m_PVREnvironmentMIS: 1 + m_PVRCulling: 1 + m_PVRFilteringGaussRadiusDirect: 1 + m_PVRFilteringGaussRadiusIndirect: 1 + m_PVRFilteringGaussRadiusAO: 1 + m_PVRFilteringAtrousPositionSigmaDirect: 0.5 + m_PVRFilteringAtrousPositionSigmaIndirect: 2 + m_PVRFilteringAtrousPositionSigmaAO: 1 + m_ExportTrainingData: 0 + m_TrainingDataDestination: TrainingData + m_LightProbeSampleCountMultiplier: 4 + m_LightingDataAsset: {fileID: 20201, guid: 0000000000000000f000000000000000, type: 0} + m_LightingSettings: {fileID: 0} +--- !u!196 &4 +NavMeshSettings: + serializedVersion: 2 + m_ObjectHideFlags: 0 + m_BuildSettings: + serializedVersion: 3 + agentTypeID: 0 + agentRadius: 0.5 + agentHeight: 2 + agentSlope: 45 + agentClimb: 0.4 + ledgeDropHeight: 0 + maxJumpAcrossDistance: 0 + minRegionArea: 2 + manualCellSize: 0 + cellSize: 0.16666667 + manualTileSize: 0 + tileSize: 256 + buildHeightMesh: 0 + maxJobWorkers: 0 + preserveTilesOutsideBounds: 0 + debug: + m_Flags: 0 + m_NavMeshData: {fileID: 0} +--- !u!1 &132768949 +GameObject: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + serializedVersion: 6 + m_Component: + - component: {fileID: 132768950} + - component: {fileID: 132768953} + - component: {fileID: 132768952} + - component: {fileID: 132768951} + m_Layer: 9 + m_Name: Central wall - slide and occlusion + m_TagString: Untagged + m_Icon: {fileID: 0} + m_NavMeshLayer: 0 + m_StaticEditorFlags: 0 + m_IsActive: 1 +--- !u!4 &132768950 +Transform: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 132768949} + serializedVersion: 2 + m_LocalRotation: {x: 0, y: 0, z: 0, w: 1} + m_LocalPosition: {x: -1, y: 0.55, z: -0.15} + m_LocalScale: {x: 3.2, y: 1.1, z: 1.5} + m_ConstrainProportionsScale: 0 + m_Children: [] + m_Father: {fileID: 812530543} + m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0} +--- !u!23 &132768951 +MeshRenderer: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 132768949} + m_Enabled: 1 + m_CastShadows: 1 + m_ReceiveShadows: 1 + m_DynamicOccludee: 1 + m_StaticShadowCaster: 0 + m_MotionVectors: 1 + m_LightProbeUsage: 1 + m_ReflectionProbeUsage: 1 + m_RayTracingMode: 2 + m_RayTraceProcedural: 0 + m_RayTracingAccelStructBuildFlagsOverride: 0 + m_RayTracingAccelStructBuildFlags: 1 + m_SmallMeshCulling: 1 + m_ForceMeshLod: -1 + m_MeshLodSelectionBias: 0 + m_RenderingLayerMask: 1 + m_RendererPriority: 0 + m_Materials: + - {fileID: 2100000, guid: b982e9bfd1eb9ce4c80b628b9b2b6c91, type: 2} + m_StaticBatchInfo: + firstSubMesh: 0 + subMeshCount: 0 + m_StaticBatchRoot: {fileID: 0} + m_ProbeAnchor: {fileID: 0} + m_LightProbeVolumeOverride: {fileID: 0} + m_ScaleInLightmap: 1 + m_ReceiveGI: 1 + m_PreserveUVs: 1 + m_IgnoreNormalsForChartDetection: 0 + m_ImportantGI: 0 + m_StitchLightmapSeams: 1 + m_SelectedEditorRenderState: 3 + m_MinimumChartSize: 4 + m_AutoUVMaxDistance: 0.5 + m_AutoUVMaxAngle: 89 + m_LightmapParameters: {fileID: 0} + m_GlobalIlluminationMeshLod: 0 + m_SortingLayerID: 0 + m_SortingLayer: 0 + m_SortingOrder: 0 + m_MaskInteraction: 0 + m_AdditionalVertexStreams: {fileID: 0} +--- !u!65 &132768952 +BoxCollider: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 132768949} + m_Material: {fileID: 0} + m_IncludeLayers: + serializedVersion: 2 + m_Bits: 0 + m_ExcludeLayers: + serializedVersion: 2 + m_Bits: 0 + m_LayerOverridePriority: 0 + m_IsTrigger: 0 + m_ProvidesContacts: 0 + m_Enabled: 1 + serializedVersion: 3 + m_Size: {x: 1, y: 1, z: 1} + m_Center: {x: 0, y: 0, z: 0} +--- !u!33 &132768953 +MeshFilter: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 132768949} + m_Mesh: {fileID: 10202, guid: 0000000000000000e000000000000000, type: 0} +--- !u!1 &234139660 +GameObject: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + serializedVersion: 6 + m_Component: + - component: {fileID: 234139661} + - component: {fileID: 234139663} + - component: {fileID: 234139662} + m_Layer: 0 + m_Name: XZ ground + m_TagString: Untagged + m_Icon: {fileID: 0} + m_NavMeshLayer: 0 + m_StaticEditorFlags: 0 + m_IsActive: 1 +--- !u!4 &234139661 +Transform: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 234139660} + serializedVersion: 2 + m_LocalRotation: {x: 0, y: 0, z: 0, w: 1} + m_LocalPosition: {x: 0, y: -0.12, z: 0} + m_LocalScale: {x: 21, y: 0.2, z: 11} + m_ConstrainProportionsScale: 0 + m_Children: [] + m_Father: {fileID: 812530543} + m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0} +--- !u!23 &234139662 +MeshRenderer: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 234139660} + m_Enabled: 1 + m_CastShadows: 1 + m_ReceiveShadows: 1 + m_DynamicOccludee: 1 + m_StaticShadowCaster: 0 + m_MotionVectors: 1 + m_LightProbeUsage: 1 + m_ReflectionProbeUsage: 1 + m_RayTracingMode: 2 + m_RayTraceProcedural: 0 + m_RayTracingAccelStructBuildFlagsOverride: 0 + m_RayTracingAccelStructBuildFlags: 1 + m_SmallMeshCulling: 1 + m_ForceMeshLod: -1 + m_MeshLodSelectionBias: 0 + m_RenderingLayerMask: 1 + m_RendererPriority: 0 + m_Materials: + - {fileID: 2100000, guid: 0ab5f733c0b0e8a428f90a7aa1f33d1b, type: 2} + m_StaticBatchInfo: + firstSubMesh: 0 + subMeshCount: 0 + m_StaticBatchRoot: {fileID: 0} + m_ProbeAnchor: {fileID: 0} + m_LightProbeVolumeOverride: {fileID: 0} + m_ScaleInLightmap: 1 + m_ReceiveGI: 1 + m_PreserveUVs: 1 + m_IgnoreNormalsForChartDetection: 0 + m_ImportantGI: 0 + m_StitchLightmapSeams: 1 + m_SelectedEditorRenderState: 3 + m_MinimumChartSize: 4 + m_AutoUVMaxDistance: 0.5 + m_AutoUVMaxAngle: 89 + m_LightmapParameters: {fileID: 0} + m_GlobalIlluminationMeshLod: 0 + m_SortingLayerID: 0 + m_SortingLayer: 0 + m_SortingOrder: 0 + m_MaskInteraction: 0 + m_AdditionalVertexStreams: {fileID: 0} +--- !u!33 &234139663 +MeshFilter: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 234139660} + m_Mesh: {fileID: 10202, guid: 0000000000000000e000000000000000, type: 0} +--- !u!1 &317225495 +GameObject: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + serializedVersion: 6 + m_Component: + - component: {fileID: 317225496} + - component: {fileID: 317225499} + - component: {fileID: 317225498} + - component: {fileID: 317225497} + m_Layer: 9 + m_Name: East boundary + m_TagString: Untagged + m_Icon: {fileID: 0} + m_NavMeshLayer: 0 + m_StaticEditorFlags: 0 + m_IsActive: 1 +--- !u!4 &317225496 +Transform: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 317225495} + serializedVersion: 2 + m_LocalRotation: {x: 0, y: 0, z: 0, w: 1} + m_LocalPosition: {x: 10.5, y: 0.6, z: 0} + m_LocalScale: {x: 0.5, y: 1.2, z: 11} + m_ConstrainProportionsScale: 0 + m_Children: [] + m_Father: {fileID: 812530543} + m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0} +--- !u!23 &317225497 +MeshRenderer: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 317225495} + m_Enabled: 1 + m_CastShadows: 1 + m_ReceiveShadows: 1 + m_DynamicOccludee: 1 + m_StaticShadowCaster: 0 + m_MotionVectors: 1 + m_LightProbeUsage: 1 + m_ReflectionProbeUsage: 1 + m_RayTracingMode: 2 + m_RayTraceProcedural: 0 + m_RayTracingAccelStructBuildFlagsOverride: 0 + m_RayTracingAccelStructBuildFlags: 1 + m_SmallMeshCulling: 1 + m_ForceMeshLod: -1 + m_MeshLodSelectionBias: 0 + m_RenderingLayerMask: 1 + m_RendererPriority: 0 + m_Materials: + - {fileID: 2100000, guid: b982e9bfd1eb9ce4c80b628b9b2b6c91, type: 2} + m_StaticBatchInfo: + firstSubMesh: 0 + subMeshCount: 0 + m_StaticBatchRoot: {fileID: 0} + m_ProbeAnchor: {fileID: 0} + m_LightProbeVolumeOverride: {fileID: 0} + m_ScaleInLightmap: 1 + m_ReceiveGI: 1 + m_PreserveUVs: 1 + m_IgnoreNormalsForChartDetection: 0 + m_ImportantGI: 0 + m_StitchLightmapSeams: 1 + m_SelectedEditorRenderState: 3 + m_MinimumChartSize: 4 + m_AutoUVMaxDistance: 0.5 + m_AutoUVMaxAngle: 89 + m_LightmapParameters: {fileID: 0} + m_GlobalIlluminationMeshLod: 0 + m_SortingLayerID: 0 + m_SortingLayer: 0 + m_SortingOrder: 0 + m_MaskInteraction: 0 + m_AdditionalVertexStreams: {fileID: 0} +--- !u!65 &317225498 +BoxCollider: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 317225495} + m_Material: {fileID: 0} + m_IncludeLayers: + serializedVersion: 2 + m_Bits: 0 + m_ExcludeLayers: + serializedVersion: 2 + m_Bits: 0 + m_LayerOverridePriority: 0 + m_IsTrigger: 0 + m_ProvidesContacts: 0 + m_Enabled: 1 + serializedVersion: 3 + m_Size: {x: 1, y: 1, z: 1} + m_Center: {x: 0, y: 0, z: 0} +--- !u!33 &317225499 +MeshFilter: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 317225495} + m_Mesh: {fileID: 10202, guid: 0000000000000000e000000000000000, type: 0} +--- !u!1 &345621296 +GameObject: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + serializedVersion: 6 + m_Component: + - component: {fileID: 345621297} + - component: {fileID: 345621300} + - component: {fileID: 345621299} + - component: {fileID: 345621298} + m_Layer: 9 + m_Name: South boundary + m_TagString: Untagged + m_Icon: {fileID: 0} + m_NavMeshLayer: 0 + m_StaticEditorFlags: 0 + m_IsActive: 1 +--- !u!4 &345621297 +Transform: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 345621296} + serializedVersion: 2 + m_LocalRotation: {x: 0, y: 0, z: 0, w: 1} + m_LocalPosition: {x: 0, y: 0.6, z: -5.5} + m_LocalScale: {x: 21.5, y: 1.2, z: 0.5} + m_ConstrainProportionsScale: 0 + m_Children: [] + m_Father: {fileID: 812530543} + m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0} +--- !u!23 &345621298 +MeshRenderer: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 345621296} + m_Enabled: 1 + m_CastShadows: 1 + m_ReceiveShadows: 1 + m_DynamicOccludee: 1 + m_StaticShadowCaster: 0 + m_MotionVectors: 1 + m_LightProbeUsage: 1 + m_ReflectionProbeUsage: 1 + m_RayTracingMode: 2 + m_RayTraceProcedural: 0 + m_RayTracingAccelStructBuildFlagsOverride: 0 + m_RayTracingAccelStructBuildFlags: 1 + m_SmallMeshCulling: 1 + m_ForceMeshLod: -1 + m_MeshLodSelectionBias: 0 + m_RenderingLayerMask: 1 + m_RendererPriority: 0 + m_Materials: + - {fileID: 2100000, guid: b982e9bfd1eb9ce4c80b628b9b2b6c91, type: 2} + m_StaticBatchInfo: + firstSubMesh: 0 + subMeshCount: 0 + m_StaticBatchRoot: {fileID: 0} + m_ProbeAnchor: {fileID: 0} + m_LightProbeVolumeOverride: {fileID: 0} + m_ScaleInLightmap: 1 + m_ReceiveGI: 1 + m_PreserveUVs: 1 + m_IgnoreNormalsForChartDetection: 0 + m_ImportantGI: 0 + m_StitchLightmapSeams: 1 + m_SelectedEditorRenderState: 3 + m_MinimumChartSize: 4 + m_AutoUVMaxDistance: 0.5 + m_AutoUVMaxAngle: 89 + m_LightmapParameters: {fileID: 0} + m_GlobalIlluminationMeshLod: 0 + m_SortingLayerID: 0 + m_SortingLayer: 0 + m_SortingOrder: 0 + m_MaskInteraction: 0 + m_AdditionalVertexStreams: {fileID: 0} +--- !u!65 &345621299 +BoxCollider: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 345621296} + m_Material: {fileID: 0} + m_IncludeLayers: + serializedVersion: 2 + m_Bits: 0 + m_ExcludeLayers: + serializedVersion: 2 + m_Bits: 0 + m_LayerOverridePriority: 0 + m_IsTrigger: 0 + m_ProvidesContacts: 0 + m_Enabled: 1 + serializedVersion: 3 + m_Size: {x: 1, y: 1, z: 1} + m_Center: {x: 0, y: 0, z: 0} +--- !u!33 &345621300 +MeshFilter: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 345621296} + m_Mesh: {fileID: 10202, guid: 0000000000000000e000000000000000, type: 0} +--- !u!1 &459064629 +GameObject: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + serializedVersion: 6 + m_Component: + - component: {fileID: 459064630} + - component: {fileID: 459064633} + - component: {fileID: 459064632} + - component: {fileID: 459064631} + m_Layer: 9 + m_Name: South obstacle + m_TagString: Untagged + m_Icon: {fileID: 0} + m_NavMeshLayer: 0 + m_StaticEditorFlags: 0 + m_IsActive: 1 +--- !u!4 &459064630 +Transform: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 459064629} + serializedVersion: 2 + m_LocalRotation: {x: 0, y: 0, z: 0, w: 1} + m_LocalPosition: {x: 4, y: 0.75, z: -2.5} + m_LocalScale: {x: 2, y: 1.5, z: 1.5} + m_ConstrainProportionsScale: 0 + m_Children: [] + m_Father: {fileID: 812530543} + m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0} +--- !u!23 &459064631 +MeshRenderer: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 459064629} + m_Enabled: 1 + m_CastShadows: 1 + m_ReceiveShadows: 1 + m_DynamicOccludee: 1 + m_StaticShadowCaster: 0 + m_MotionVectors: 1 + m_LightProbeUsage: 1 + m_ReflectionProbeUsage: 1 + m_RayTracingMode: 2 + m_RayTraceProcedural: 0 + m_RayTracingAccelStructBuildFlagsOverride: 0 + m_RayTracingAccelStructBuildFlags: 1 + m_SmallMeshCulling: 1 + m_ForceMeshLod: -1 + m_MeshLodSelectionBias: 0 + m_RenderingLayerMask: 1 + m_RendererPriority: 0 + m_Materials: + - {fileID: 2100000, guid: b982e9bfd1eb9ce4c80b628b9b2b6c91, type: 2} + m_StaticBatchInfo: + firstSubMesh: 0 + subMeshCount: 0 + m_StaticBatchRoot: {fileID: 0} + m_ProbeAnchor: {fileID: 0} + m_LightProbeVolumeOverride: {fileID: 0} + m_ScaleInLightmap: 1 + m_ReceiveGI: 1 + m_PreserveUVs: 1 + m_IgnoreNormalsForChartDetection: 0 + m_ImportantGI: 0 + m_StitchLightmapSeams: 1 + m_SelectedEditorRenderState: 3 + m_MinimumChartSize: 4 + m_AutoUVMaxDistance: 0.5 + m_AutoUVMaxAngle: 89 + m_LightmapParameters: {fileID: 0} + m_GlobalIlluminationMeshLod: 0 + m_SortingLayerID: 0 + m_SortingLayer: 0 + m_SortingOrder: 0 + m_MaskInteraction: 0 + m_AdditionalVertexStreams: {fileID: 0} +--- !u!65 &459064632 +BoxCollider: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 459064629} + m_Material: {fileID: 0} + m_IncludeLayers: + serializedVersion: 2 + m_Bits: 0 + m_ExcludeLayers: + serializedVersion: 2 + m_Bits: 0 + m_LayerOverridePriority: 0 + m_IsTrigger: 0 + m_ProvidesContacts: 0 + m_Enabled: 1 + serializedVersion: 3 + m_Size: {x: 1, y: 1, z: 1} + m_Center: {x: 0, y: 0, z: 0} +--- !u!33 &459064633 +MeshFilter: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 459064629} + m_Mesh: {fileID: 10202, guid: 0000000000000000e000000000000000, type: 0} +--- !u!1 &545694293 +GameObject: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + serializedVersion: 6 + m_Component: + - component: {fileID: 545694294} + - component: {fileID: 545694296} + - component: {fileID: 545694295} + m_Layer: 0 + m_Name: Route segment 2 + m_TagString: Untagged + m_Icon: {fileID: 0} + m_NavMeshLayer: 0 + m_StaticEditorFlags: 0 + m_IsActive: 1 +--- !u!4 &545694294 +Transform: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 545694293} + serializedVersion: 2 + m_LocalRotation: {x: -0, y: 0.3404253, z: -0, w: 0.9402716} + m_LocalPosition: {x: -3, y: -0.005, z: 1.2} + m_LocalScale: {x: 0.65, y: 0.015, z: 3.1241} + m_ConstrainProportionsScale: 0 + m_Children: [] + m_Father: {fileID: 812530543} + m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0} +--- !u!23 &545694295 +MeshRenderer: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 545694293} + m_Enabled: 1 + m_CastShadows: 1 + m_ReceiveShadows: 1 + m_DynamicOccludee: 1 + m_StaticShadowCaster: 0 + m_MotionVectors: 1 + m_LightProbeUsage: 1 + m_ReflectionProbeUsage: 1 + m_RayTracingMode: 2 + m_RayTraceProcedural: 0 + m_RayTracingAccelStructBuildFlagsOverride: 0 + m_RayTracingAccelStructBuildFlags: 1 + m_SmallMeshCulling: 1 + m_ForceMeshLod: -1 + m_MeshLodSelectionBias: 0 + m_RenderingLayerMask: 1 + m_RendererPriority: 0 + m_Materials: + - {fileID: 2100000, guid: 65a6bf5b1c33b80489a703cc8fe91efb, type: 2} + m_StaticBatchInfo: + firstSubMesh: 0 + subMeshCount: 0 + m_StaticBatchRoot: {fileID: 0} + m_ProbeAnchor: {fileID: 0} + m_LightProbeVolumeOverride: {fileID: 0} + m_ScaleInLightmap: 1 + m_ReceiveGI: 1 + m_PreserveUVs: 1 + m_IgnoreNormalsForChartDetection: 0 + m_ImportantGI: 0 + m_StitchLightmapSeams: 1 + m_SelectedEditorRenderState: 3 + m_MinimumChartSize: 4 + m_AutoUVMaxDistance: 0.5 + m_AutoUVMaxAngle: 89 + m_LightmapParameters: {fileID: 0} + m_GlobalIlluminationMeshLod: 0 + m_SortingLayerID: 0 + m_SortingLayer: 0 + m_SortingOrder: 0 + m_MaskInteraction: 0 + m_AdditionalVertexStreams: {fileID: 0} +--- !u!33 &545694296 +MeshFilter: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 545694293} + m_Mesh: {fileID: 10202, guid: 0000000000000000e000000000000000, type: 0} +--- !u!1 &687250416 +GameObject: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + serializedVersion: 6 + m_Component: + - component: {fileID: 687250417} + - component: {fileID: 687250419} + - component: {fileID: 687250418} + m_Layer: 0 + m_Name: Route segment 3 + m_TagString: Untagged + m_Icon: {fileID: 0} + m_NavMeshLayer: 0 + m_StaticEditorFlags: 0 + m_IsActive: 1 +--- !u!4 &687250417 +Transform: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 687250416} + serializedVersion: 2 + m_LocalRotation: {x: -0, y: 0.7071068, z: -0, w: 0.7071068} + m_LocalPosition: {x: 1, y: -0.005, z: 2.4} + m_LocalScale: {x: 0.65, y: 0.015, z: 6} + m_ConstrainProportionsScale: 0 + m_Children: [] + m_Father: {fileID: 812530543} + m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0} +--- !u!23 &687250418 +MeshRenderer: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 687250416} + m_Enabled: 1 + m_CastShadows: 1 + m_ReceiveShadows: 1 + m_DynamicOccludee: 1 + m_StaticShadowCaster: 0 + m_MotionVectors: 1 + m_LightProbeUsage: 1 + m_ReflectionProbeUsage: 1 + m_RayTracingMode: 2 + m_RayTraceProcedural: 0 + m_RayTracingAccelStructBuildFlagsOverride: 0 + m_RayTracingAccelStructBuildFlags: 1 + m_SmallMeshCulling: 1 + m_ForceMeshLod: -1 + m_MeshLodSelectionBias: 0 + m_RenderingLayerMask: 1 + m_RendererPriority: 0 + m_Materials: + - {fileID: 2100000, guid: 65a6bf5b1c33b80489a703cc8fe91efb, type: 2} + m_StaticBatchInfo: + firstSubMesh: 0 + subMeshCount: 0 + m_StaticBatchRoot: {fileID: 0} + m_ProbeAnchor: {fileID: 0} + m_LightProbeVolumeOverride: {fileID: 0} + m_ScaleInLightmap: 1 + m_ReceiveGI: 1 + m_PreserveUVs: 1 + m_IgnoreNormalsForChartDetection: 0 + m_ImportantGI: 0 + m_StitchLightmapSeams: 1 + m_SelectedEditorRenderState: 3 + m_MinimumChartSize: 4 + m_AutoUVMaxDistance: 0.5 + m_AutoUVMaxAngle: 89 + m_LightmapParameters: {fileID: 0} + m_GlobalIlluminationMeshLod: 0 + m_SortingLayerID: 0 + m_SortingLayer: 0 + m_SortingOrder: 0 + m_MaskInteraction: 0 + m_AdditionalVertexStreams: {fileID: 0} +--- !u!33 &687250419 +MeshFilter: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 687250416} + m_Mesh: {fileID: 10202, guid: 0000000000000000e000000000000000, type: 0} +--- !u!1 &812272347 +GameObject: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + serializedVersion: 6 + m_Component: + - component: {fileID: 812272349} + - component: {fileID: 812272348} + m_Layer: 0 + m_Name: Physics3D comparison (independent) + m_TagString: Untagged + m_Icon: {fileID: 0} + m_NavMeshLayer: 0 + m_StaticEditorFlags: 0 + m_IsActive: 1 +--- !u!114 &812272348 +MonoBehaviour: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 812272347} + m_Enabled: 1 + m_EditorHideFlags: 0 + m_Script: {fileID: 11500000, guid: 3da342f87d5c43a5905256f7b5515c7e, type: 3} + m_Name: + m_EditorClassIdentifier: BumpCombat.Runtime::BumpCombat.Defense.Comparison.Defense25DComparison + settings: {fileID: 11400000, guid: fc0c99c602cf67042a4305ccbdcc8c2f, type: 2} + view: {fileID: 1781564655} +--- !u!4 &812272349 +Transform: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 812272347} + serializedVersion: 2 + m_LocalRotation: {x: 0, y: 0, z: 0, w: 1} + m_LocalPosition: {x: 0, y: 0, z: 0} + m_LocalScale: {x: 1, y: 1, z: 1} + m_ConstrainProportionsScale: 0 + m_Children: [] + m_Father: {fileID: 0} + m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0} +--- !u!1 &812530542 +GameObject: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + serializedVersion: 6 + m_Component: + - component: {fileID: 812530543} + m_Layer: 0 + m_Name: Ground and Physics3D obstacles + m_TagString: Untagged + m_Icon: {fileID: 0} + m_NavMeshLayer: 0 + m_StaticEditorFlags: 0 + m_IsActive: 1 +--- !u!4 &812530543 +Transform: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 812530542} + serializedVersion: 2 + m_LocalRotation: {x: 0, y: 0, z: 0, w: 1} + m_LocalPosition: {x: 0, y: 0, z: 0} + m_LocalScale: {x: 1, y: 1, z: 1} + m_ConstrainProportionsScale: 0 + m_Children: + - {fileID: 234139661} + - {fileID: 1545421147} + - {fileID: 345621297} + - {fileID: 1771165275} + - {fileID: 317225496} + - {fileID: 132768950} + - {fileID: 459064630} + - {fileID: 1633901613} + - {fileID: 545694294} + - {fileID: 687250417} + - {fileID: 1761765622} + - {fileID: 1779791400} + - {fileID: 1360739456} + m_Father: {fileID: 0} + m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0} +--- !u!1 &1360739455 +GameObject: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + serializedVersion: 6 + m_Component: + - component: {fileID: 1360739456} + - component: {fileID: 1360739458} + - component: {fileID: 1360739457} + m_Layer: 0 + m_Name: Base - arrival marker + m_TagString: Untagged + m_Icon: {fileID: 0} + m_NavMeshLayer: 0 + m_StaticEditorFlags: 0 + m_IsActive: 1 +--- !u!4 &1360739456 +Transform: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 1360739455} + serializedVersion: 2 + m_LocalRotation: {x: 0, y: 0, z: 0, w: 1} + m_LocalPosition: {x: 9, y: 0.2, z: 0} + m_LocalScale: {x: 1, y: 0.4, z: 1} + m_ConstrainProportionsScale: 0 + m_Children: [] + m_Father: {fileID: 812530543} + m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0} +--- !u!23 &1360739457 +MeshRenderer: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 1360739455} + m_Enabled: 1 + m_CastShadows: 1 + m_ReceiveShadows: 1 + m_DynamicOccludee: 1 + m_StaticShadowCaster: 0 + m_MotionVectors: 1 + m_LightProbeUsage: 1 + m_ReflectionProbeUsage: 1 + m_RayTracingMode: 2 + m_RayTraceProcedural: 0 + m_RayTracingAccelStructBuildFlagsOverride: 0 + m_RayTracingAccelStructBuildFlags: 1 + m_SmallMeshCulling: 1 + m_ForceMeshLod: -1 + m_MeshLodSelectionBias: 0 + m_RenderingLayerMask: 1 + m_RendererPriority: 0 + m_Materials: + - {fileID: 2100000, guid: 27680ecdcb0d9c4408af43beb753bd64, type: 2} + m_StaticBatchInfo: + firstSubMesh: 0 + subMeshCount: 0 + m_StaticBatchRoot: {fileID: 0} + m_ProbeAnchor: {fileID: 0} + m_LightProbeVolumeOverride: {fileID: 0} + m_ScaleInLightmap: 1 + m_ReceiveGI: 1 + m_PreserveUVs: 1 + m_IgnoreNormalsForChartDetection: 0 + m_ImportantGI: 0 + m_StitchLightmapSeams: 1 + m_SelectedEditorRenderState: 3 + m_MinimumChartSize: 4 + m_AutoUVMaxDistance: 0.5 + m_AutoUVMaxAngle: 89 + m_LightmapParameters: {fileID: 0} + m_GlobalIlluminationMeshLod: 0 + m_SortingLayerID: 0 + m_SortingLayer: 0 + m_SortingOrder: 0 + m_MaskInteraction: 0 + m_AdditionalVertexStreams: {fileID: 0} +--- !u!33 &1360739458 +MeshFilter: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 1360739455} + m_Mesh: {fileID: 10202, guid: 0000000000000000e000000000000000, type: 0} +--- !u!1 &1545421146 +GameObject: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + serializedVersion: 6 + m_Component: + - component: {fileID: 1545421147} + - component: {fileID: 1545421150} + - component: {fileID: 1545421149} + - component: {fileID: 1545421148} + m_Layer: 9 + m_Name: North boundary + m_TagString: Untagged + m_Icon: {fileID: 0} + m_NavMeshLayer: 0 + m_StaticEditorFlags: 0 + m_IsActive: 1 +--- !u!4 &1545421147 +Transform: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 1545421146} + serializedVersion: 2 + m_LocalRotation: {x: 0, y: 0, z: 0, w: 1} + m_LocalPosition: {x: 0, y: 0.6, z: 5.5} + m_LocalScale: {x: 21.5, y: 1.2, z: 0.5} + m_ConstrainProportionsScale: 0 + m_Children: [] + m_Father: {fileID: 812530543} + m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0} +--- !u!23 &1545421148 +MeshRenderer: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 1545421146} + m_Enabled: 1 + m_CastShadows: 1 + m_ReceiveShadows: 1 + m_DynamicOccludee: 1 + m_StaticShadowCaster: 0 + m_MotionVectors: 1 + m_LightProbeUsage: 1 + m_ReflectionProbeUsage: 1 + m_RayTracingMode: 2 + m_RayTraceProcedural: 0 + m_RayTracingAccelStructBuildFlagsOverride: 0 + m_RayTracingAccelStructBuildFlags: 1 + m_SmallMeshCulling: 1 + m_ForceMeshLod: -1 + m_MeshLodSelectionBias: 0 + m_RenderingLayerMask: 1 + m_RendererPriority: 0 + m_Materials: + - {fileID: 2100000, guid: b982e9bfd1eb9ce4c80b628b9b2b6c91, type: 2} + m_StaticBatchInfo: + firstSubMesh: 0 + subMeshCount: 0 + m_StaticBatchRoot: {fileID: 0} + m_ProbeAnchor: {fileID: 0} + m_LightProbeVolumeOverride: {fileID: 0} + m_ScaleInLightmap: 1 + m_ReceiveGI: 1 + m_PreserveUVs: 1 + m_IgnoreNormalsForChartDetection: 0 + m_ImportantGI: 0 + m_StitchLightmapSeams: 1 + m_SelectedEditorRenderState: 3 + m_MinimumChartSize: 4 + m_AutoUVMaxDistance: 0.5 + m_AutoUVMaxAngle: 89 + m_LightmapParameters: {fileID: 0} + m_GlobalIlluminationMeshLod: 0 + m_SortingLayerID: 0 + m_SortingLayer: 0 + m_SortingOrder: 0 + m_MaskInteraction: 0 + m_AdditionalVertexStreams: {fileID: 0} +--- !u!65 &1545421149 +BoxCollider: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 1545421146} + m_Material: {fileID: 0} + m_IncludeLayers: + serializedVersion: 2 + m_Bits: 0 + m_ExcludeLayers: + serializedVersion: 2 + m_Bits: 0 + m_LayerOverridePriority: 0 + m_IsTrigger: 0 + m_ProvidesContacts: 0 + m_Enabled: 1 + serializedVersion: 3 + m_Size: {x: 1, y: 1, z: 1} + m_Center: {x: 0, y: 0, z: 0} +--- !u!33 &1545421150 +MeshFilter: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 1545421146} + m_Mesh: {fileID: 10202, guid: 0000000000000000e000000000000000, type: 0} +--- !u!1 &1633901612 +GameObject: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + serializedVersion: 6 + m_Component: + - component: {fileID: 1633901613} + - component: {fileID: 1633901615} + - component: {fileID: 1633901614} + m_Layer: 0 + m_Name: Route segment 1 + m_TagString: Untagged + m_Icon: {fileID: 0} + m_NavMeshLayer: 0 + m_StaticEditorFlags: 0 + m_IsActive: 1 +--- !u!4 &1633901613 +Transform: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 1633901612} + serializedVersion: 2 + m_LocalRotation: {x: -0, y: 0.7071068, z: -0, w: 0.7071068} + m_LocalPosition: {x: -6.5, y: -0.005, z: 0} + m_LocalScale: {x: 0.65, y: 0.015, z: 5} + m_ConstrainProportionsScale: 0 + m_Children: [] + m_Father: {fileID: 812530543} + m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0} +--- !u!23 &1633901614 +MeshRenderer: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 1633901612} + m_Enabled: 1 + m_CastShadows: 1 + m_ReceiveShadows: 1 + m_DynamicOccludee: 1 + m_StaticShadowCaster: 0 + m_MotionVectors: 1 + m_LightProbeUsage: 1 + m_ReflectionProbeUsage: 1 + m_RayTracingMode: 2 + m_RayTraceProcedural: 0 + m_RayTracingAccelStructBuildFlagsOverride: 0 + m_RayTracingAccelStructBuildFlags: 1 + m_SmallMeshCulling: 1 + m_ForceMeshLod: -1 + m_MeshLodSelectionBias: 0 + m_RenderingLayerMask: 1 + m_RendererPriority: 0 + m_Materials: + - {fileID: 2100000, guid: 65a6bf5b1c33b80489a703cc8fe91efb, type: 2} + m_StaticBatchInfo: + firstSubMesh: 0 + subMeshCount: 0 + m_StaticBatchRoot: {fileID: 0} + m_ProbeAnchor: {fileID: 0} + m_LightProbeVolumeOverride: {fileID: 0} + m_ScaleInLightmap: 1 + m_ReceiveGI: 1 + m_PreserveUVs: 1 + m_IgnoreNormalsForChartDetection: 0 + m_ImportantGI: 0 + m_StitchLightmapSeams: 1 + m_SelectedEditorRenderState: 3 + m_MinimumChartSize: 4 + m_AutoUVMaxDistance: 0.5 + m_AutoUVMaxAngle: 89 + m_LightmapParameters: {fileID: 0} + m_GlobalIlluminationMeshLod: 0 + m_SortingLayerID: 0 + m_SortingLayer: 0 + m_SortingOrder: 0 + m_MaskInteraction: 0 + m_AdditionalVertexStreams: {fileID: 0} +--- !u!33 &1633901615 +MeshFilter: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 1633901612} + m_Mesh: {fileID: 10202, guid: 0000000000000000e000000000000000, type: 0} +--- !u!1 &1761765621 +GameObject: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + serializedVersion: 6 + m_Component: + - component: {fileID: 1761765622} + - component: {fileID: 1761765624} + - component: {fileID: 1761765623} + m_Layer: 0 + m_Name: Route segment 4 + m_TagString: Untagged + m_Icon: {fileID: 0} + m_NavMeshLayer: 0 + m_StaticEditorFlags: 0 + m_IsActive: 1 +--- !u!4 &1761765622 +Transform: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 1761765621} + serializedVersion: 2 + m_LocalRotation: {x: -0, y: 0.8463838, z: -0, w: 0.5325734} + m_LocalPosition: {x: 6.5, y: -0.005, z: 1.2} + m_LocalScale: {x: 0.65, y: 0.015, z: 5.5461698} + m_ConstrainProportionsScale: 0 + m_Children: [] + m_Father: {fileID: 812530543} + m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0} +--- !u!23 &1761765623 +MeshRenderer: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 1761765621} + m_Enabled: 1 + m_CastShadows: 1 + m_ReceiveShadows: 1 + m_DynamicOccludee: 1 + m_StaticShadowCaster: 0 + m_MotionVectors: 1 + m_LightProbeUsage: 1 + m_ReflectionProbeUsage: 1 + m_RayTracingMode: 2 + m_RayTraceProcedural: 0 + m_RayTracingAccelStructBuildFlagsOverride: 0 + m_RayTracingAccelStructBuildFlags: 1 + m_SmallMeshCulling: 1 + m_ForceMeshLod: -1 + m_MeshLodSelectionBias: 0 + m_RenderingLayerMask: 1 + m_RendererPriority: 0 + m_Materials: + - {fileID: 2100000, guid: 65a6bf5b1c33b80489a703cc8fe91efb, type: 2} + m_StaticBatchInfo: + firstSubMesh: 0 + subMeshCount: 0 + m_StaticBatchRoot: {fileID: 0} + m_ProbeAnchor: {fileID: 0} + m_LightProbeVolumeOverride: {fileID: 0} + m_ScaleInLightmap: 1 + m_ReceiveGI: 1 + m_PreserveUVs: 1 + m_IgnoreNormalsForChartDetection: 0 + m_ImportantGI: 0 + m_StitchLightmapSeams: 1 + m_SelectedEditorRenderState: 3 + m_MinimumChartSize: 4 + m_AutoUVMaxDistance: 0.5 + m_AutoUVMaxAngle: 89 + m_LightmapParameters: {fileID: 0} + m_GlobalIlluminationMeshLod: 0 + m_SortingLayerID: 0 + m_SortingLayer: 0 + m_SortingOrder: 0 + m_MaskInteraction: 0 + m_AdditionalVertexStreams: {fileID: 0} +--- !u!33 &1761765624 +MeshFilter: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 1761765621} + m_Mesh: {fileID: 10202, guid: 0000000000000000e000000000000000, type: 0} +--- !u!1 &1771165274 +GameObject: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + serializedVersion: 6 + m_Component: + - component: {fileID: 1771165275} + - component: {fileID: 1771165278} + - component: {fileID: 1771165277} + - component: {fileID: 1771165276} + m_Layer: 9 + m_Name: West boundary + m_TagString: Untagged + m_Icon: {fileID: 0} + m_NavMeshLayer: 0 + m_StaticEditorFlags: 0 + m_IsActive: 1 +--- !u!4 &1771165275 +Transform: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 1771165274} + serializedVersion: 2 + m_LocalRotation: {x: 0, y: 0, z: 0, w: 1} + m_LocalPosition: {x: -10.5, y: 0.6, z: 0} + m_LocalScale: {x: 0.5, y: 1.2, z: 11} + m_ConstrainProportionsScale: 0 + m_Children: [] + m_Father: {fileID: 812530543} + m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0} +--- !u!23 &1771165276 +MeshRenderer: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 1771165274} + m_Enabled: 1 + m_CastShadows: 1 + m_ReceiveShadows: 1 + m_DynamicOccludee: 1 + m_StaticShadowCaster: 0 + m_MotionVectors: 1 + m_LightProbeUsage: 1 + m_ReflectionProbeUsage: 1 + m_RayTracingMode: 2 + m_RayTraceProcedural: 0 + m_RayTracingAccelStructBuildFlagsOverride: 0 + m_RayTracingAccelStructBuildFlags: 1 + m_SmallMeshCulling: 1 + m_ForceMeshLod: -1 + m_MeshLodSelectionBias: 0 + m_RenderingLayerMask: 1 + m_RendererPriority: 0 + m_Materials: + - {fileID: 2100000, guid: b982e9bfd1eb9ce4c80b628b9b2b6c91, type: 2} + m_StaticBatchInfo: + firstSubMesh: 0 + subMeshCount: 0 + m_StaticBatchRoot: {fileID: 0} + m_ProbeAnchor: {fileID: 0} + m_LightProbeVolumeOverride: {fileID: 0} + m_ScaleInLightmap: 1 + m_ReceiveGI: 1 + m_PreserveUVs: 1 + m_IgnoreNormalsForChartDetection: 0 + m_ImportantGI: 0 + m_StitchLightmapSeams: 1 + m_SelectedEditorRenderState: 3 + m_MinimumChartSize: 4 + m_AutoUVMaxDistance: 0.5 + m_AutoUVMaxAngle: 89 + m_LightmapParameters: {fileID: 0} + m_GlobalIlluminationMeshLod: 0 + m_SortingLayerID: 0 + m_SortingLayer: 0 + m_SortingOrder: 0 + m_MaskInteraction: 0 + m_AdditionalVertexStreams: {fileID: 0} +--- !u!65 &1771165277 +BoxCollider: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 1771165274} + m_Material: {fileID: 0} + m_IncludeLayers: + serializedVersion: 2 + m_Bits: 0 + m_ExcludeLayers: + serializedVersion: 2 + m_Bits: 0 + m_LayerOverridePriority: 0 + m_IsTrigger: 0 + m_ProvidesContacts: 0 + m_Enabled: 1 + serializedVersion: 3 + m_Size: {x: 1, y: 1, z: 1} + m_Center: {x: 0, y: 0, z: 0} +--- !u!33 &1771165278 +MeshFilter: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 1771165274} + m_Mesh: {fileID: 10202, guid: 0000000000000000e000000000000000, type: 0} +--- !u!1 &1779791399 +GameObject: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + serializedVersion: 6 + m_Component: + - component: {fileID: 1779791400} + - component: {fileID: 1779791402} + - component: {fileID: 1779791401} + m_Layer: 0 + m_Name: Portal + m_TagString: Untagged + m_Icon: {fileID: 0} + m_NavMeshLayer: 0 + m_StaticEditorFlags: 0 + m_IsActive: 1 +--- !u!4 &1779791400 +Transform: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 1779791399} + serializedVersion: 2 + m_LocalRotation: {x: 0, y: 0, z: 0, w: 1} + m_LocalPosition: {x: -9, y: 0.06, z: 0} + m_LocalScale: {x: 1, y: 0.12, z: 1} + m_ConstrainProportionsScale: 0 + m_Children: [] + m_Father: {fileID: 812530543} + m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0} +--- !u!23 &1779791401 +MeshRenderer: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 1779791399} + m_Enabled: 1 + m_CastShadows: 1 + m_ReceiveShadows: 1 + m_DynamicOccludee: 1 + m_StaticShadowCaster: 0 + m_MotionVectors: 1 + m_LightProbeUsage: 1 + m_ReflectionProbeUsage: 1 + m_RayTracingMode: 2 + m_RayTraceProcedural: 0 + m_RayTracingAccelStructBuildFlagsOverride: 0 + m_RayTracingAccelStructBuildFlags: 1 + m_SmallMeshCulling: 1 + m_ForceMeshLod: -1 + m_MeshLodSelectionBias: 0 + m_RenderingLayerMask: 1 + m_RendererPriority: 0 + m_Materials: + - {fileID: 2100000, guid: 65a6bf5b1c33b80489a703cc8fe91efb, type: 2} + m_StaticBatchInfo: + firstSubMesh: 0 + subMeshCount: 0 + m_StaticBatchRoot: {fileID: 0} + m_ProbeAnchor: {fileID: 0} + m_LightProbeVolumeOverride: {fileID: 0} + m_ScaleInLightmap: 1 + m_ReceiveGI: 1 + m_PreserveUVs: 1 + m_IgnoreNormalsForChartDetection: 0 + m_ImportantGI: 0 + m_StitchLightmapSeams: 1 + m_SelectedEditorRenderState: 3 + m_MinimumChartSize: 4 + m_AutoUVMaxDistance: 0.5 + m_AutoUVMaxAngle: 89 + m_LightmapParameters: {fileID: 0} + m_GlobalIlluminationMeshLod: 0 + m_SortingLayerID: 0 + m_SortingLayer: 0 + m_SortingOrder: 0 + m_MaskInteraction: 0 + m_AdditionalVertexStreams: {fileID: 0} +--- !u!33 &1779791402 +MeshFilter: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 1779791399} + m_Mesh: {fileID: 10202, guid: 0000000000000000e000000000000000, type: 0} +--- !u!1 &1781564654 +GameObject: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + serializedVersion: 6 + m_Component: + - component: {fileID: 1781564659} + - component: {fileID: 1781564658} + - component: {fileID: 1781564657} + - component: {fileID: 1781564656} + - component: {fileID: 1781564655} + m_Layer: 0 + m_Name: Comparison Camera - dedicated Universal renderer + m_TagString: Untagged + m_Icon: {fileID: 0} + m_NavMeshLayer: 0 + m_StaticEditorFlags: 0 + m_IsActive: 1 +--- !u!114 &1781564655 +MonoBehaviour: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 1781564654} + m_Enabled: 1 + m_EditorHideFlags: 0 + m_Script: {fileID: 11500000, guid: 18762ed1791d450386964308b36444f2, type: 3} + m_Name: + m_EditorClassIdentifier: BumpCombat.Runtime::BumpCombat.Defense.Comparison.Defense25DView +--- !u!81 &1781564656 +AudioListener: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 1781564654} + m_Enabled: 1 +--- !u!114 &1781564657 +MonoBehaviour: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 1781564654} + m_Enabled: 1 + m_EditorHideFlags: 0 + m_Script: {fileID: 11500000, guid: a79441f348de89743a2939f4d699eac1, type: 3} + m_Name: + m_EditorClassIdentifier: Unity.RenderPipelines.Universal.Runtime::UnityEngine.Rendering.Universal.UniversalAdditionalCameraData + m_RenderShadows: 1 + m_RequiresDepthTextureOption: 2 + m_RequiresOpaqueTextureOption: 2 + m_CameraType: 0 + m_Cameras: [] + m_RendererIndex: 1 + m_VolumeLayerMask: + serializedVersion: 2 + m_Bits: 1 + m_VolumeTrigger: {fileID: 0} + m_VolumeFrameworkUpdateModeOption: 2 + m_RenderPostProcessing: 0 + m_Antialiasing: 0 + m_AntialiasingQuality: 2 + m_StopNaN: 0 + m_Dithering: 0 + m_ClearDepth: 1 + m_AllowXRRendering: 1 + m_AllowHDROutput: 1 + m_UseScreenCoordOverride: 0 + m_ScreenSizeOverride: {x: 0, y: 0, z: 0, w: 0} + m_ScreenCoordScaleBias: {x: 0, y: 0, z: 0, w: 0} + m_RequiresDepthTexture: 0 + m_RequiresColorTexture: 0 + m_TaaSettings: + m_Quality: 3 + m_FrameInfluence: 0.1 + m_JitterScale: 1 + m_MipBias: 0 + m_VarianceClampScale: 0.9 + m_ContrastAdaptiveSharpening: 0 + m_Version: 2 +--- !u!20 &1781564658 +Camera: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 1781564654} + m_Enabled: 1 + serializedVersion: 2 + m_ClearFlags: 2 + m_BackGroundColor: {r: 0.055, g: 0.08, b: 0.1, a: 1} + m_projectionMatrixMode: 1 + m_GateFitMode: 2 + m_FOVAxisMode: 0 + m_Iso: 200 + m_ShutterSpeed: 0.005 + m_Aperture: 16 + m_FocusDistance: 10 + m_FocalLength: 50 + m_BladeCount: 5 + m_Curvature: {x: 2, y: 11} + m_BarrelClipping: 0.25 + m_Anamorphism: 0 + m_SensorSize: {x: 36, y: 24} + m_LensShift: {x: 0, y: 0} + m_NormalizedViewPortRect: + serializedVersion: 2 + x: 0 + y: 0 + width: 1 + height: 1 + near clip plane: 0.1 + far clip plane: 100 + field of view: 60 + orthographic: 1 + orthographic size: 6.5 + m_Depth: 0 + m_CullingMask: + serializedVersion: 2 + m_Bits: 4294967295 + m_RenderingPath: -1 + m_TargetTexture: {fileID: 0} + m_TargetDisplay: 0 + m_TargetEye: 3 + m_HDR: 0 + m_AllowMSAA: 0 + m_AllowDynamicResolution: 0 + m_ForceIntoRT: 0 + m_OcclusionCulling: 1 + m_StereoConvergence: 10 + m_StereoSeparation: 0.022 +--- !u!4 &1781564659 +Transform: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 1781564654} + serializedVersion: 2 + m_LocalRotation: {x: 0.4226183, y: 0, z: 0, w: 0.9063078} + m_LocalPosition: {x: 0, y: 19.151114, z: -16.069689} + m_LocalScale: {x: 1, y: 1, z: 1} + m_ConstrainProportionsScale: 0 + m_Children: [] + m_Father: {fileID: 0} + m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0} +--- !u!1660057539 &9223372036854775807 +SceneRoots: + m_ObjectHideFlags: 0 + m_Roots: + - {fileID: 812272349} + - {fileID: 1781564659} + - {fileID: 812530543} diff --git a/Assets/_Project/Scenes/Defense25DComparison.unity.meta b/Assets/_Project/Scenes/Defense25DComparison.unity.meta new file mode 100644 index 0000000..ff3f4e3 --- /dev/null +++ b/Assets/_Project/Scenes/Defense25DComparison.unity.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: c0496bc9cbfa2af48bbfa51b8b378ee2 +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/_Project/Scripts/Combat/CombatFeedback.cs b/Assets/_Project/Scripts/Combat/CombatFeedback.cs index 45a129a..3184292 100644 --- a/Assets/_Project/Scripts/Combat/CombatFeedback.cs +++ b/Assets/_Project/Scripts/Combat/CombatFeedback.cs @@ -4,6 +4,7 @@ using BumpCombat.Core; using BumpCombat.Constants; using BumpCombat.Enemies; using BumpCombat.Player; +using BumpCombat.Defense; using BumpCombat.UI; using UnityEngine; @@ -81,6 +82,7 @@ namespace BumpCombat.Combat private readonly HashSet blockedBumpVisuals = new(); private readonly HashSet guardBlockImpactVisuals = new(); private readonly HashSet artifactImpactVisuals = new(); + private readonly HashSet towerImpactVisuals = new(); private readonly HashSet playerHurtImpactVisuals = new(); private readonly Dictionary artifactImpactFrameCache = new(); private SpriteRenderer playerRenderer; @@ -129,6 +131,7 @@ namespace BumpCombat.Combat public int ActiveBlockedBumpVisualCount => blockedBumpVisuals.Count; public int ActiveGuardBlockImpactVisualCount => guardBlockImpactVisuals.Count; public int ActiveArtifactImpactVisualCount => artifactImpactVisuals.Count; + public int ActiveTowerImpactVisualCount => towerImpactVisuals.Count; public int ActivePlayerHurtImpactVisualCount => playerHurtImpactVisuals.Count; public static string GetPlayerHurtImpactResourcePath() @@ -189,6 +192,69 @@ namespace BumpCombat.Combat + "-v1"; } + public static string GetTowerImpactResourcePath(DefenseTowerKind kind) + { + return kind switch + { + DefenseTowerKind.Direct => "Combat/TowerImpact-Direct-Clean-v1", + DefenseTowerKind.Summon => null, + DefenseTowerKind.Lightning => "Combat/TowerImpact-Lightning-Clean-v1", + DefenseTowerKind.Cold => "Combat/TowerImpact-Cold-Clean-v1", + DefenseTowerKind.Seismic => "Combat/TowerImpact-Seismic-Clean-v1", + _ => null, + }; + } + + public static void ShowTowerImpact( + DefenseTowerKind kind, EnemyController target, Vector2 hitPosition) + { + CombatFeedback feedback = GetActive(); + if (feedback != null && feedback.isActiveAndEnabled) + { + feedback.CreateTowerImpact(kind, target, hitPosition); + } + } + + private void CreateTowerImpact( + DefenseTowerKind kind, EnemyController target, Vector2 hitPosition) + { + string resourcePath = GetTowerImpactResourcePath(kind); + if (string.IsNullOrEmpty(resourcePath)) + { + return; + } + + Sprite[] frames = LoadBumpImpactFrames(resourcePath); + if (frames == null || frames.Length != BumpImpactFrameCount) + { + return; + } + + GameObject root = new($"Tower Impact {kind}"); + root.transform.localScale = Vector3.one * 2f; + root.transform.position = hitPosition; + SpriteRenderer renderer = root.AddComponent(); + renderer.sprite = frames[0]; + renderer.color = Color.white; + SpriteRenderer targetRenderer = target != null + ? target.GetComponent() : null; + if (targetRenderer != null) + { + renderer.sortingLayerID = targetRenderer.sortingLayerID; + renderer.sortingOrder = targetRenderer.sortingOrder + + DefenseTowerArtBounds.TowerHitSortingOffset; + } + else if (playerRenderer != null) + { + renderer.sortingLayerID = playerRenderer.sortingLayerID; + renderer.sortingOrder = playerRenderer.sortingOrder + + DefenseTowerArtBounds.TowerHitSortingOffset; + } + RegisterTransient(root, false); + towerImpactVisuals.Add(root); + StartCoroutine(AnimateArtifactImpact(root, renderer, frames)); + } + public static float GetArtifactImpactFrameDuration(int frameIndex) { return GetBumpImpactFrameDuration(frameIndex); @@ -526,6 +592,7 @@ namespace BumpCombat.Combat ClearBlockedBumpVisuals(); ClearGuardBlockImpactVisuals(); ClearArtifactImpactVisuals(); + ClearTowerImpactVisuals(); ClearPlayerHurtImpactVisuals(); ClearCycloneAfterimages(); } @@ -1894,6 +1961,22 @@ namespace BumpCombat.Combat artifactImpactVisuals.Clear(); } + private void ClearTowerImpactVisuals() + { + if (towerImpactVisuals.Count == 0) + { + return; + } + + GameObject[] visuals = new GameObject[towerImpactVisuals.Count]; + towerImpactVisuals.CopyTo(visuals); + foreach (GameObject visual in visuals) + { + DestroyTransient(visual); + } + towerImpactVisuals.Clear(); + } + private void ClearPlayerHurtImpactVisuals() { if (playerHurtImpactVisuals.Count == 0) @@ -2398,6 +2481,7 @@ namespace BumpCombat.Combat blockedBumpVisuals.RemoveWhere(visual => visual == null); guardBlockImpactVisuals.RemoveWhere(visual => visual == null); artifactImpactVisuals.RemoveWhere(visual => visual == null); + towerImpactVisuals.RemoveWhere(visual => visual == null); playerHurtImpactVisuals.RemoveWhere(visual => visual == null); return; } @@ -2406,6 +2490,7 @@ namespace BumpCombat.Combat blockedBumpVisuals.Remove(transientObject); guardBlockImpactVisuals.Remove(transientObject); artifactImpactVisuals.Remove(transientObject); + towerImpactVisuals.Remove(transientObject); playerHurtImpactVisuals.Remove(transientObject); int instanceId = transientObject.GetInstanceID(); activeTransientIds.Remove(instanceId); diff --git a/Assets/_Project/Scripts/Combat/CycloneGeometry.cs b/Assets/_Project/Scripts/Combat/CycloneGeometry.cs new file mode 100644 index 0000000..7597bfe --- /dev/null +++ b/Assets/_Project/Scripts/Combat/CycloneGeometry.cs @@ -0,0 +1,47 @@ +using System; +using UnityEngine; + +namespace BumpCombat.Combat +{ + // Authored ground geometry shared by the XY controller and XZ comparison. + // Sprite frames and decoration consume elapsed time independently of damage ticks. + public static class CycloneGeometry + { + public const float PixelsPerUnit = 32f; + public const float ShadowLocalY = -7.5f / PixelsPerUnit; + public const float CenterOffsetY = 5f / PixelsPerUnit; + public const float FrameSeconds = .05f; + + public static float VisualScale(float range, bool charged) => range / (charged ? 1.25f : .85f); + + public static Vector2 Radii(float range, bool charged) => + (charged ? new Vector2(46f, 18f) : new Vector2(38.0625f, 15.03125f)) + / PixelsPerUnit * VisualScale(range, charged); + + public static Vector2 CenterOffset(float range, bool charged, float rootScaleY) => + Vector2.up * (ShadowLocalY * rootScaleY + CenterOffsetY * VisualScale(range, charged)); + + public static int ScheduledHitCount(float duration, float interval) => + duration <= 0f || interval <= 0f ? 1 + : Mathf.Max(1, Mathf.FloorToInt((duration + .0001f) / interval) + 1); + + public static float NormalizedDistance(Vector2 offset, Vector2 radii) => + offset.x * offset.x / (radii.x * radii.x) + + offset.y * offset.y / (radii.y * radii.y); + + // Keep the original closest-point and 64 boundary samples, including tolerances. + // The adapter supplies Collider2D.ClosestPoint or an XZ circular footprint. + public static bool Overlaps(Vector2 center, Vector2 radii, Func closestPoint) + { + if (NormalizedDistance(closestPoint(center) - center, radii) <= 1.0001f) return true; + const int samples = 64; + for (int i = 0; i < samples; i++) + { + float angle = Mathf.PI * 2f * i / samples; + Vector2 point = center + new Vector2(Mathf.Cos(angle) * radii.x, Mathf.Sin(angle) * radii.y); + if ((closestPoint(point) - point).sqrMagnitude <= .000001f) return true; + } + return false; + } + } +} diff --git a/Assets/_Project/Scripts/Combat/CycloneGeometry.cs.meta b/Assets/_Project/Scripts/Combat/CycloneGeometry.cs.meta new file mode 100644 index 0000000..59dcc42 --- /dev/null +++ b/Assets/_Project/Scripts/Combat/CycloneGeometry.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 0a2c269eb2424df68bfbd7d08ffee36d diff --git a/Assets/_Project/Scripts/Constants/Defense25DCombatFeedbackConstants.cs b/Assets/_Project/Scripts/Constants/Defense25DCombatFeedbackConstants.cs new file mode 100644 index 0000000..1c2e75a --- /dev/null +++ b/Assets/_Project/Scripts/Constants/Defense25DCombatFeedbackConstants.cs @@ -0,0 +1,49 @@ +using System.Collections.Generic; +using BumpCombat.Combat; +using BumpCombat.Player; + +namespace BumpCombat.Constants +{ + /// Existing authored feedback timings and paths used by the 2.5D presentation. + public static class Defense25DCombatFeedbackConstants + { + public const string ChargeResourceRoot = "Artifacts/ThreeColor-v1/Charge/"; + public const string ChargeAuraSuffix = "-Aura-v1"; + public const string ChargeReadyAuraSuffix = "-ReadyAura-v1"; + public const string ChargeFlashSuffix = "-Flash-v1"; + public const string ChargeReleaseSuffix = "-Release-v1"; + public const string BlockedBumpResource = "Combat/BlockedBump-Wizard-v1"; + public const float PulseGatherDuration = .1f; + public const float InteractionStreakDuration = .11f; + + public static IReadOnlyList BumpFrames { get; } = new[] + { + CombatFeedback.GetBumpImpactFrameDuration(0), + CombatFeedback.GetBumpImpactFrameDuration(1), + CombatFeedback.GetBumpImpactFrameDuration(2), + CombatFeedback.GetBumpImpactFrameDuration(3), + }; + + public static IReadOnlyList ArtifactFrames { get; } = new[] + { + CombatFeedback.GetArtifactImpactFrameDuration(0), + CombatFeedback.GetArtifactImpactFrameDuration(1), + CombatFeedback.GetArtifactImpactFrameDuration(2), + CombatFeedback.GetArtifactImpactFrameDuration(3), + }; + + public static IReadOnlyList PlayerHurtFrames { get; } = new[] + { + CombatFeedback.GetPlayerHurtImpactFrameDuration(0), + CombatFeedback.GetPlayerHurtImpactFrameDuration(1), + CombatFeedback.GetPlayerHurtImpactFrameDuration(2), + }; + + public static IReadOnlyList ChargeAuraFrames { get; } = new[] + { .06f, .06f, .06f, .06f, .06f, .06f }; + public static IReadOnlyList ChargeFlashFrames { get; } = new[] + { .03f, .04f, .04f, .03f }; + public static IReadOnlyList ChargeReleaseFrames { get; } = new[] + { .03f, .04f, .04f, .04f, .04f, .05f }; + } +} diff --git a/Assets/_Project/Scripts/Constants/Defense25DCombatFeedbackConstants.cs.meta b/Assets/_Project/Scripts/Constants/Defense25DCombatFeedbackConstants.cs.meta new file mode 100644 index 0000000..330c91a --- /dev/null +++ b/Assets/_Project/Scripts/Constants/Defense25DCombatFeedbackConstants.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: c2a7338f9d1144eb9ab4bc9b7d29c26f diff --git a/Assets/_Project/Scripts/Constants/Defense25DCombatPopupConstants.cs b/Assets/_Project/Scripts/Constants/Defense25DCombatPopupConstants.cs new file mode 100644 index 0000000..9368141 --- /dev/null +++ b/Assets/_Project/Scripts/Constants/Defense25DCombatPopupConstants.cs @@ -0,0 +1,25 @@ +using UnityEngine; + +namespace BumpCombat.Constants +{ + public static class Defense25DCombatPopupConstants + { + public const int MaximumConcurrentPopups = 12; + public const float LifetimeSeconds = 0.6f; + public const float RisePixelsAt1080p = 40f; + public const float ReferenceScreenHeight = 1080f; + public const float ReferenceInternalHeight = 360f; + public const float FontPixelsAt1080p = 30f; + public const float OutlinePixels = 1f; + public const float PopupWidthInternal = 100f; + public const float PopupHeightInternal = 20f; + + public static readonly Color PlayerDamageColor = + new(244f / 255f, 117f / 255f, 112f / 255f, 1f); + public static readonly Color LaunchColor = new(0.95f, 0.55f, 1f); + public static readonly Color BackHitColor = new(1f, 0.75f, 0.2f); + public static readonly Color SideHitColor = new(0.65f, 1f, 0.3f); + public static readonly Color OutlineColor = new(18f / 255f, 23f / 255f, 34f / 255f, + 230f / 255f); + } +} diff --git a/Assets/_Project/Scripts/Constants/Defense25DCombatPopupConstants.cs.meta b/Assets/_Project/Scripts/Constants/Defense25DCombatPopupConstants.cs.meta new file mode 100644 index 0000000..7466098 --- /dev/null +++ b/Assets/_Project/Scripts/Constants/Defense25DCombatPopupConstants.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 4bdb0ea9afc64f17a25d84d1cf8a7a31 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/_Project/Scripts/Constants/Defense25DComparisonSettings.cs b/Assets/_Project/Scripts/Constants/Defense25DComparisonSettings.cs new file mode 100644 index 0000000..a2dbf3a --- /dev/null +++ b/Assets/_Project/Scripts/Constants/Defense25DComparisonSettings.cs @@ -0,0 +1,80 @@ +using System; +using BumpCombat.Defense; +using BumpCombat.Enemies; +using BumpCombat.Player; +using UnityEngine; + +namespace BumpCombat.Constants +{ + [Serializable] + public sealed class ComparisonSpriteFrames + { + public Sprite[] Idle = Array.Empty(); + public Sprite[] Walk = Array.Empty(); + public Sprite[] Attack = Array.Empty(); + public Sprite[] Hurt = Array.Empty(); + public Sprite[] Death = Array.Empty(); + } + + [CreateAssetMenu(menuName = "BumpCombat/Constants/Defense 2.5D Comparison")] + public sealed class Defense25DComparisonSettings : ScriptableObject + { + public const float SummonBodyPixelsPerUnit = 32f; + public const float SlimeBodyCoreDiameterPixels = 11f; + public const float GiantSlimeBodyCoreDiameterPixels = 22f; + public const float LavaSlimeBodyCoreDiameterPixels = 13f; + public static readonly Vector2 SlimeBodyCenterFromFootPixels = new(-1.5f, 7.5f); + public static readonly Vector2 GiantSlimeBodyCenterFromFootPixels = new(-3f, 17f); + public static readonly Vector2 LavaSlimeBodyCenterFromFootPixels = new(-2f, 8.5f); + + [Header("Independent comparison layout; not final balance")] + public float GroundY; + public Vector2 MapHalfExtents = new(10.5f, 5.5f); + public Vector2 PlayerSpawn = new(-5f, -2f); + public Vector2 TowerPosition = new(1f, 1.8f); + public Vector2[] Route = { new(-9f, 0f), new(-4f, 0f), new(-2f, 2.4f), new(4f, 2.4f), new(9f, 0f) }; + public Vector2[] SummonSlots = { new(-.6f, -.9f), new(.6f, -.9f) }; + public DefenseTowerKind PortalImmunity = DefenseTowerKind.Direct; + public float CameraPitch = 50f; + public float CameraSize = 6.5f; + [Range(.1f, 1f)] public float SouthBoundaryRenderHeightFraction = .4f; + + [Header("XZ footprints baked from existing prefab XY colliders")] + public float PlayerRadius = .275f; + public Vector2 PlayerColliderOffset = new(0f, -.075f); + public float PlayerVisualScale = 1.25f; + public float PlayerFootOffset = -7.5f / 32f * 1.25f; + public float EnemyRadius = .275f; + public Vector2 EnemyColliderOffset = new(0f, -.35f); + public float EnemyVisualScale = 1.25f; + public float EnemyFootOffset = -.4f; + public float EnemyBodyCenterCorrectionPixels = 3f; + public float SummonVisualScale = 1.1f; + public float SummonFootOffset = -9f / 32f * 1.1f; + public float ActorHeight = .85f; + public float MovementSkin = .002f; + public float ContactSensorMargin = .025f; + + // Unnamed, otherwise unused layers in the baseline project; no TagManager edits. + public const int TerrainLayer = 9; + public const int ActorLayer = 10; + public const int TerrainMask = 1 << TerrainLayer; + public const int ActorMask = 1 << ActorLayer; + + [Header("Existing definitions and art; originals are not edited")] + public EnemyDefinition Enemy; + public AnimationClip EnemyAttackClip; + public ActiveArtifactDefinition Cyclone; + public ComparisonSpriteFrames PlayerArt = new(); + public ComparisonSpriteFrames EnemyArt = new(); + public Material CutoutMaterial; + public Material TransparentMaterial; + public Material GroundMaterial; + public Material WallMaterial; + public Material PathMaterial; + public Material ShadowMaterial; + public Material BaseMaterial; + public Material BoundaryMaterial; + public Material HitBoundaryMaterial; + } +} diff --git a/Assets/_Project/Scripts/Constants/Defense25DComparisonSettings.cs.meta b/Assets/_Project/Scripts/Constants/Defense25DComparisonSettings.cs.meta new file mode 100644 index 0000000..1205dde --- /dev/null +++ b/Assets/_Project/Scripts/Constants/Defense25DComparisonSettings.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 5366a103ee5d47b79aa3feb1b3ca3f4d diff --git a/Assets/_Project/Scripts/Constants/Defense25DPlayerAnimationConstants.cs b/Assets/_Project/Scripts/Constants/Defense25DPlayerAnimationConstants.cs new file mode 100644 index 0000000..7278c8d --- /dev/null +++ b/Assets/_Project/Scripts/Constants/Defense25DPlayerAnimationConstants.cs @@ -0,0 +1,15 @@ +namespace BumpCombat.Constants +{ + /// Sample cadence copied from the authored Swordsman animation clips. + public static class Defense25DPlayerAnimationConstants + { + public const float IdleFrameSeconds = 1f / 8f; + public const float WalkFrameSeconds = 1f / 10f; + public const float HurtFrameSeconds = 1f / 10f; + public const float DeathFrameSeconds = 1f / 8f; + public const float DashFrameSeconds = 1f / 60f; + public const float BumpFrameSeconds = 1f / 25f; + public const float ArtifactUseFrameSeconds = 1f / 20f; + public const float NonPlayerDeathFallbackDuration = 0.5f; + } +} diff --git a/Assets/_Project/Scripts/Constants/Defense25DPlayerAnimationConstants.cs.meta b/Assets/_Project/Scripts/Constants/Defense25DPlayerAnimationConstants.cs.meta new file mode 100644 index 0000000..5dc62dd --- /dev/null +++ b/Assets/_Project/Scripts/Constants/Defense25DPlayerAnimationConstants.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 0ee47e8d5cf8ceb40bf54520dc0485a1 \ No newline at end of file diff --git a/Assets/_Project/Scripts/Constants/DefenseConstants.cs b/Assets/_Project/Scripts/Constants/DefenseConstants.cs index 6dafab7..f33b115 100644 --- a/Assets/_Project/Scripts/Constants/DefenseConstants.cs +++ b/Assets/_Project/Scripts/Constants/DefenseConstants.cs @@ -72,12 +72,26 @@ namespace BumpCombat.Constants [Min(0.1f)] public float TowerRange = 5.5f; [Min(0.1f)] public float DirectTowerDamage = 10f; [Min(0.05f)] public float DirectTowerAttackInterval = 0.75f; + [Min(0f)] public float FireT4BeamSecondTierSeconds = 1f; + [Min(0f)] public float FireT4BeamMaximumTierSeconds = 2f; + [Min(0f)] public float FireT4BeamBaseDamageMultiplier = 0.4f; + [Min(1f)] public float FireT4BeamSecondTierMultiplier = 1.5f; + [Min(1f)] public float FireT4BeamMaximumMultiplier = 2f; + [Min(0f)] public float FireT4BeamResetAfterSeconds = 1f; + [Min(1f)] public float FireT4BeamSecondTierWidthMultiplier = 1.5f; + [Min(1f)] public float FireT4BeamMaximumWidthMultiplier = 2f; [Min(0.1f)] public float SummonDamage = 6f; [Min(0.05f)] public float SummonAttackInterval = 0.9f; [Min(1)] public int SummonUnitCount = 2; + [Min(1f)] public float SummonUnitMaximumHealth = 30f; + [Min(0.05f)] public float SummonUnitCollisionRadius = 0.32f; + [Min(0f)] public float SummonUnitRespawnSeconds = 3f; [Min(0.1f)] public float SummonAreaRadius = 2.25f; [Min(0.1f)] public float SummonUnitMoveSpeed = 2.5f; [Min(0.1f)] public float SummonAttackRange = 1.2f; + [Range(0, 5)] public int SummonBodySlamImpactFrame = 3; + [Min(0f)] public float SummonBodySlamContactMargin = 0.06f; + [Min(1f)] public float SummonBodySlamSpeedMultiplier = 2f; [Header("Six tower family comparison values")] [Min(0.1f)] public float LightningDamage = 6f; [Min(0.05f)] public float LightningInterval = 1.1f; @@ -86,6 +100,8 @@ namespace BumpCombat.Constants [Min(0.05f)] public float ColdInterval = 1.2f; [Min(0f)] public float ColdSlowFraction = 0.35f; [Min(0f)] public float ColdSlowSeconds = 1.5f; + [Min(0.01f)] public float ColdProjectileBurstInterval = 0.12f; + [Min(1)] public int FreezeboltProjectileCount = 5; [Min(0.1f)] public float SeismicDamage = 2f; [Min(0.05f)] public float SeismicInterval = 2f; [Min(0f)] public float SeismicStunSeconds = 0.45f; @@ -156,4 +172,41 @@ namespace BumpCombat.Constants : fallback; } } + + public static class DefenseTowerHitTestLayout + { + public static readonly DefenseTowerKind[] KindsByPad = + { + DefenseTowerKind.Direct, + DefenseTowerKind.Summon, + DefenseTowerKind.Cold, + DefenseTowerKind.Lightning, + DefenseTowerKind.Empowering, + DefenseTowerKind.Seismic, + }; + + public static readonly Vector2[] DummyPositionsByPad = + { + new(-2.25f, 2.95f), + new(3.95f, -3.2f), + new(-4.9f, -1.4f), + new(4.9f, 1.4f), + new(1f, 2.5f), + new(0f, -1.4f), + }; + + public static Vector2 GetDummyPosition(DefenseTowerKind kind) + { + for (int i = 0; i < KindsByPad.Length; i++) + { + if (KindsByPad[i] == kind) + { + return DummyPositionsByPad[i]; + } + } + + return Vector2.zero; + } + } + } diff --git a/Assets/_Project/Scripts/Constants/DefenseTowerArtDurations.cs b/Assets/_Project/Scripts/Constants/DefenseTowerArtDurations.cs index 31fa2d2..18def96 100644 --- a/Assets/_Project/Scripts/Constants/DefenseTowerArtDurations.cs +++ b/Assets/_Project/Scripts/Constants/DefenseTowerArtDurations.cs @@ -96,11 +96,13 @@ namespace BumpCombat.Defense ["Effects/Fire/Meteor-Impact"] = new[] { 40, 50, 60, 70, 60, 60, 60, 80 }, ["Sources/BlackKnight-NoSkull"] = new[] { 125, 125, 125, 125, 125, 125, 125 }, ["Effects/Fire/Flamethrower"] = new[] { 80, 80, 80, 70, 65, 65, 70 }, + ["Effects/Fire/Eyeball Monster_Beam-Clean"] = new[] { 100, 100, 120 }, ["Effects/Lightning/T1-Bolt"] = new[] { 30, 40, 30, 30, 40, 40, 40, 50 }, ["Effects/Lightning/T2-Bolt"] = new[] { 30, 40, 30, 30, 40, 40, 40, 50 }, ["Effects/Lightning/T3-Bolt"] = new[] { 30, 40, 30, 30, 40, 40, 40, 50 }, ["Effects/Lightning/Arc"] = new[] { 30, 40, 30, 30, 40, 40, 40, 50 }, ["Effects/Lightning/ImpactWave"] = new[] { 40, 40, 50, 50, 60, 60 }, + ["Effects/Ice/Blizzard-Wave-v1"] = new[] { 40, 40, 50, 50, 60, 60 }, ["Effects/Lightning/LightningStorm"] = new[] { 50, 50, 50, 50, 50, 50, 50, 50, 50, 50, 50, 50, 50, 50, 50, 50 }, ["Effects/Ice/T1-Volley"] = new[] { 125 }, ["Effects/Ice/T2-Volley"] = new[] { 125 }, @@ -167,6 +169,58 @@ namespace BumpCombat.Constants /// Offline pixel measurements for non-readable defense art and original shadows. public static class DefenseTowerArtBounds { + public const float TowerBillboardScale = 0.72f * 2.1f; + public static readonly Vector2 LightningStormGroundCenterPixels = new(61f, 34f); + public const float LightningStormGroundRadiusPixels = 55.362442f; + public const float SeismicGroundMarkerWidthPixels = 18f; + public const int FireBeamStartX = 1; + public const int FireBeamEndX = 99; + public const float FireBeamCenterY = 49.5f; + public const float FireBeamIgnitionSeconds = 0.1f; + public const float FireBeamExtinguishSeconds = 0.12f; + public const float FireT4BeamBaseWidthMultiplier = 1f; + public static Vector2 StaffTipPixels(bool attack, int frame) + { + frame = Mathf.Clamp(frame, 0, 5); + if (attack) + { + return frame switch + { + 0 => new Vector2(56.5f, 44.5f), + 1 => new Vector2(57.5f, 43.5f), + _ => new Vector2(58.5f, 42.5f), + }; + } + return frame < 2 + ? new Vector2(56.5f, 44.5f) + : new Vector2(56.5f, 43.5f); + } + public const int TowerHitSortingOffset = 102; + public const int BlizzardShardCount = 8; + public const float BlizzardLaunchIntervalSeconds = 0.04f; + public const float BlizzardLandingSeconds = 0.24f; + public const float BlizzardLastLandingSeconds = BlizzardLandingSeconds + + (BlizzardShardCount - 1) * BlizzardLaunchIntervalSeconds; + public const float BlizzardLandingRadiusFraction = 0.65f; + public const float BlizzardRangeAlpha = 0.15f; + public const float BlizzardImpactRadiusAlpha = 0.18f; + public const float BlizzardShardScale = 0.52f; + public const float BlizzardTrailScale = 0.3f; + public const float BlizzardTrailAlpha = 0.32f; + public const float BlizzardTrailOffset = 0.12f; + public const float BlizzardImpactScale = 0.16f; + public const float BlizzardWaveScale = 0.7f; + // Maximum visible half-extents measured from Blizzard-Wave-v1's alpha frames. + public const float BlizzardWaveHalfWidthPixels = 22.5f; + public const float BlizzardWaveHalfHeightPixels = 11f; + public const float BlizzardWavePixelsPerUnit = 32f; + public const float BlizzardWaveRadiusX = BlizzardWaveHalfWidthPixels + / BlizzardWavePixelsPerUnit * BlizzardWaveScale; + public const float BlizzardWaveRadiusY = BlizzardWaveHalfHeightPixels + / BlizzardWavePixelsPerUnit * BlizzardWaveScale; + public const float BlizzardDropHeight = 1.2f; + public const float BlizzardDropHeightStep = 0.22f; + private static readonly Vector2Int[] LightningBeamBounds = { new(8, 56), new(8, 89), new(8, 89), new(8, 89), @@ -181,6 +235,14 @@ namespace BumpCombat.Constants public static Vector2Int LightningBeamXBounds(int frame) => LightningBeamBounds[Mathf.Clamp(frame, 0, LightningBeamBounds.Length - 1)]; + public static Vector2 BlizzardLandingOffset(int index, float radius) + { + float fraction = BlizzardLandingRadiusFraction + * Mathf.Sqrt((index + 0.5f) / BlizzardShardCount); + float angle = index * 2.39996323f; + return radius * fraction * new Vector2(Mathf.Cos(angle), Mathf.Sin(angle)); + } + public static Vector2 ImpactWaveCenterPixels(int frame, Sprite sprite) { return frame >= ImpactWaveCenters.Length diff --git a/Assets/_Project/Scripts/Core/CharacterModel.cs b/Assets/_Project/Scripts/Core/CharacterModel.cs index 70705b5..64dcf7d 100644 --- a/Assets/_Project/Scripts/Core/CharacterModel.cs +++ b/Assets/_Project/Scripts/Core/CharacterModel.cs @@ -103,6 +103,7 @@ namespace BumpCombat.Core public event Action OnStatChanged; + public bool IsMinion { get; set; } public abstract float MoveSpeed { get; } public abstract float MaxHealth { get; } diff --git a/Assets/_Project/Scripts/Core/RunManager.cs b/Assets/_Project/Scripts/Core/RunManager.cs index 655110a..03b2040 100644 --- a/Assets/_Project/Scripts/Core/RunManager.cs +++ b/Assets/_Project/Scripts/Core/RunManager.cs @@ -407,6 +407,7 @@ namespace BumpCombat.Core public void RestartToTitle() { + BumpCombat.Defense.DefensePrototypeController.CancelTowerHitTestRun(); runModeOnNextLoad = null; skipTitleOnNextLoad = false; Time.timeScale = 1f; diff --git a/Assets/_Project/Scripts/Defense/Comparison.meta b/Assets/_Project/Scripts/Defense/Comparison.meta new file mode 100644 index 0000000..8dffbd5 --- /dev/null +++ b/Assets/_Project/Scripts/Defense/Comparison.meta @@ -0,0 +1,3 @@ +fileFormatVersion: 2 +guid: 4c2979e1dc2744f2b23a56fac198fc91 +folderAsset: yes diff --git a/Assets/_Project/Scripts/Defense/Comparison/Defense25DActor.cs b/Assets/_Project/Scripts/Defense/Comparison/Defense25DActor.cs new file mode 100644 index 0000000..8191be7 --- /dev/null +++ b/Assets/_Project/Scripts/Defense/Comparison/Defense25DActor.cs @@ -0,0 +1,453 @@ +using System; +using BumpCombat.Combat; +using BumpCombat.Constants; +using BumpCombat.Core; +using BumpCombat.Enemies; +using UnityEngine; + +namespace BumpCombat.Defense.Comparison +{ + public enum ComparisonActorKind { Player, Enemy, Summon } + + [DisallowMultipleComponent] + public sealed class Defense25DActor : CharacterModel + { + private Defense25DComparison world; + private CapsuleCollider sensor; + private readonly RaycastHit[] movementHits = new RaycastHit[64]; + private Vector2 knockbackVelocity; + private Vector2 knockbackStartPosition; + private float knockbackRemaining; + private float hurtUntil; + private float invulnerableUntil; + private float shieldAmount, shieldExpiresAt; + private float guardUntil, guardCooldownUntil; + private bool guardHasBlocked; + private bool knockbackCompletionPending; + private float enemyMaximumHealthOverride; + private Func closestPoint; + private Vector2 facing = Vector2.right; + private Vector2 unflippedBodyCenterOffset; + + public ComparisonActorKind Kind { get; private set; } + public Rigidbody Body { get; private set; } + public CapsuleCollider BodyCollider { get; private set; } + public Defense25DSprite Visual { get; private set; } + public Vector2 Facing + { + get => facing; + set + { + bool wasLeft = facing.x < 0f; + facing = value; + if (BodyCollider != null && wasLeft != (facing.x < 0f)) ApplyColliderCenters(); + } + } + public Vector2 Position => Ground(Body.position); + public Vector2 Center => Position + Offset + BodyCenterOffset; + public float Radius { get; private set; } + public Vector2 Offset { get; private set; } + public Vector2 BodyCenterOffset => Facing.x < 0f + ? new Vector2(-unflippedBodyCenterOffset.x, unflippedBodyCenterOffset.y) + : unflippedBodyCenterOffset; + public float FootOffset { get; private set; } + public float CurrentHealth { get; private set; } + public float CurrentShield => world.GameTime < shieldExpiresAt ? shieldAmount : 0f; + public bool IsAlive => isActiveAndEnabled && CurrentHealth > 0f; + public bool IsHurt => world.GameTime < hurtUntil; + public bool IsGuarding => Kind == ComparisonActorKind.Player && IsAlive + && world.GameTime < guardUntil; + public float GuardCooldownRemaining => Mathf.Max(0f, guardCooldownUntil - world.GameTime); + public float GuardReadinessNormalized => GameplayConstants.Current.Player.GuardCooldownDuration > 0f + ? 1f - Mathf.Clamp01(GuardCooldownRemaining / GameplayConstants.Current.Player.GuardCooldownDuration) + : 1f; + public bool HasKnockback => knockbackVelocity.sqrMagnitude > .0025f; + public int DamageEvents { get; private set; } + public int ContactCallbacks { get; private set; } + public bool MovementBlocked { get; private set; } + public Vector2 LastMove { get; private set; } + public Collider LastMovementBlocker { get; private set; } + public Func ClosestPointFunction => closestPoint; + + public override float MoveSpeed => Evaluate(CharacterStat.MoveSpeed, Kind switch + { + ComparisonActorKind.Player => GameplayConstants.Current.Player.BaseMoveSpeed, + ComparisonActorKind.Enemy => world.Settings.Enemy.MoveSpeed, + _ => GameplayConstants.Current.Defense.SummonUnitMoveSpeed, + }); + + public override float MaxHealth => Evaluate(CharacterStat.MaxHealth, Kind switch + { + ComparisonActorKind.Player => GameplayConstants.Current.Player.BaseMaxHealth, + ComparisonActorKind.Enemy => enemyMaximumHealthOverride > 0f + ? enemyMaximumHealthOverride : world.Settings.Enemy.MaxHealth, + _ => GameplayConstants.Current.Defense.SummonUnitMaximumHealth, + }); + + public static Vector2 Ground(Vector3 point) => new(point.x, point.z); + public static Vector3 OnGround(Vector2 point, float y = 0f) => new(point.x, y, point.y); + + public void Configure(Defense25DComparison owner, ComparisonActorKind kind) + { + world = owner; + Kind = kind; + IsMinion = kind == ComparisonActorKind.Summon; + Defense25DComparisonSettings s = owner.Settings; + Radius = kind == ComparisonActorKind.Player ? s.PlayerRadius + : kind == ComparisonActorKind.Enemy ? s.EnemyRadius + : s.SummonVisualScale * Defense25DComparisonSettings.SlimeBodyCoreDiameterPixels + / Defense25DComparisonSettings.SummonBodyPixelsPerUnit * .5f; + Offset = kind == ComparisonActorKind.Player ? s.PlayerColliderOffset + : kind == ComparisonActorKind.Enemy ? s.EnemyColliderOffset : Vector2.zero; + Vector2 initialCenterPixels = kind == ComparisonActorKind.Enemy + ? new Vector2(0f, s.EnemyBodyCenterCorrectionPixels) + : kind == ComparisonActorKind.Summon + ? Defense25DComparisonSettings.SlimeBodyCenterFromFootPixels + : Vector2.zero; + float initialScale = kind == ComparisonActorKind.Player ? s.PlayerVisualScale + : kind == ComparisonActorKind.Enemy ? s.EnemyVisualScale : s.SummonVisualScale; + float initialHeight = kind == ComparisonActorKind.Summon ? 2f * Radius : Mathf.Max(s.ActorHeight, 2f * Radius); + Vector2 initialScreenCenter = initialCenterPixels + * (kind == ComparisonActorKind.Enemy + ? 2f * s.CameraSize / Defense25DView.Height + : initialScale / Defense25DComparisonSettings.SummonBodyPixelsPerUnit); + Camera camera = owner.View != null ? owner.View.Camera : null; + Quaternion projectionRotation = BodyProjectionRotation(camera, s.CameraPitch); + Vector3 cameraUp = projectionRotation * Vector3.up; + if (kind == ComparisonActorKind.Enemy) + initialScreenCenter.y += cameraUp.y * initialHeight * .5f; + unflippedBodyCenterOffset = kind == ComparisonActorKind.Player + ? Vector2.zero + : ScreenCenterOffset(initialScreenCenter, initialHeight * .5f, camera, s.CameraPitch); + FootOffset = kind == ComparisonActorKind.Player ? s.PlayerFootOffset + : kind == ComparisonActorKind.Enemy ? s.EnemyFootOffset : s.SummonFootOffset; + gameObject.layer = Defense25DComparisonSettings.ActorLayer; + Body = gameObject.AddComponent(); + Body.isKinematic = true; + Body.useGravity = false; + Body.constraints = RigidbodyConstraints.FreezePositionY | RigidbodyConstraints.FreezeRotation; + Body.interpolation = RigidbodyInterpolation.Interpolate; + Body.collisionDetectionMode = CollisionDetectionMode.ContinuousSpeculative; + BodyCollider = gameObject.AddComponent(); + BodyCollider.radius = Radius; + BodyCollider.height = kind == ComparisonActorKind.Summon ? 2f * Radius : Mathf.Max(s.ActorHeight, 2f * Radius); + ApplyColliderCenters(); + sensor = gameObject.AddComponent(); + sensor.center = BodyCollider.center; + sensor.radius = Radius + s.ContactSensorMargin; + sensor.height = BodyCollider.height + 2f * s.ContactSensorMargin; + sensor.isTrigger = true; + closestPoint = ClosestGroundPoint; + Visual = gameObject.AddComponent(); + Visual.Configure(world, this); + ResetAt(Ground(transform.position)); + } + + public void ResetAt(Vector2 position) + { + foreach (AppliedStatModifier modifier in GetAppliedModifiers()) RemoveModifiersFromSource(modifier.SourceId); + gameObject.SetActive(true); + enabled = true; + CurrentHealth = MaxHealth; + shieldAmount = shieldExpiresAt = 0f; + guardUntil = guardCooldownUntil = 0f; + guardHasBlocked = false; + DamageEvents = ContactCallbacks = 0; + hurtUntil = invulnerableUntil = 0f; + knockbackVelocity = LastMove = Vector2.zero; + knockbackRemaining = 0f; + knockbackCompletionPending = false; + MovementBlocked = false; + Facing = Vector2.right; + Body.position = OnGround(position, world.Settings.GroundY); + Body.rotation = Quaternion.identity; + transform.position = Body.position; + BodyCollider.enabled = sensor.enabled = true; + Visual.ResetVisual(); + } + + public void SetEnemyMaximumHealth(float maximumHealth) + { + if (Kind == ComparisonActorKind.Enemy) + enemyMaximumHealthOverride = Mathf.Max(0f, maximumHealth); + } + + public void SetSummonVariantScale(float scale, float coreDiameterPixels, Vector2 bodyCenterFromFootPixels) + { + if (Kind != ComparisonActorKind.Summon) return; + float pixelsPerUnit = Defense25DComparisonSettings.SummonBodyPixelsPerUnit; + Radius = coreDiameterPixels * scale / pixelsPerUnit * .5f; + unflippedBodyCenterOffset = ScreenCenterOffset(bodyCenterFromFootPixels * (scale / pixelsPerUnit), Radius, + world.View != null ? world.View.Camera : null, world.Settings.CameraPitch); + BodyCollider.radius = Radius; + BodyCollider.height = 2f * Radius; + ApplyColliderCenters(); + sensor.radius = Radius + world.Settings.ContactSensorMargin; + sensor.height = BodyCollider.height + 2f * world.Settings.ContactSensorMargin; + } + + private void ApplyColliderCenters() + { + Vector3 center = new(Offset.x + BodyCenterOffset.x, BodyCollider.height * .5f, + Offset.y + BodyCenterOffset.y); + BodyCollider.center = center; + if (sensor != null) sensor.center = center; + } + + private static Vector2 ScreenCenterOffset(Vector2 screenOffset, float colliderCenterY, + Camera camera, float cameraPitch) + { + Quaternion rotation = BodyProjectionRotation(camera, cameraPitch); + Vector3 right = rotation * Vector3.right; + Vector3 up = rotation * Vector3.up; + float targetRight = screenOffset.x; + float targetUp = screenOffset.y - up.y * colliderCenterY; + float determinant = right.x * up.z - right.z * up.x; + return new Vector2( + (targetRight * up.z - right.z * targetUp) / determinant, + (right.x * targetUp - targetRight * up.x) / determinant); + } + + private static Quaternion BodyProjectionRotation(Camera camera, float cameraPitch) + { + Quaternion fallback = Quaternion.Euler(cameraPitch, 0f, 0f); + if (camera == null) return fallback; + Quaternion current = camera.transform.rotation; + Vector3 right = current * Vector3.right; + Vector3 up = current * Vector3.up; + float determinant = right.x * up.z - right.z * up.x; + return Mathf.Abs(determinant) > .0001f ? current : fallback; + } + + public Vector2 ClosestGroundPoint(Vector2 point) => Center + Vector2.ClampMagnitude(point - Center, Radius); + public float GapTo(Defense25DActor other) => Vector2.Distance(Center, other.Center) - Radius - other.Radius; + + public bool TakeDamage(float rawDamage, Vector2 direction, float knockback = 0f, + EnemyDamageSource source = EnemyDamageSource.Player, bool ordinaryBump = false) + { + return TakeDamageWithResult(rawDamage, direction, knockback, source, ordinaryBump, out _); + } + + public bool TakeDamageWithResult(float rawDamage, Vector2 direction, float knockback, + EnemyDamageSource source, bool ordinaryBump, out float actualDamage) + { + actualDamage = 0f; + if (!IsAlive || !world.IsRunning || world.Paused + || (Kind == ComparisonActorKind.Summon && !world.TowersOperational)) return false; + Defense25DEnemy enemy = Kind == ComparisonActorKind.Enemy + ? GetComponent() : null; + float incoming = CalculateIncomingDamage(rawDamage); + if (enemy != null) incoming = enemy.ApplyDamageTakenEffects(incoming, ordinaryBump); + float applied = DamageCalculator.FinalizeDamage(incoming); + if (applied <= 0f) return false; + if (Kind == ComparisonActorKind.Player && IsGuarding) + { + if (!guardHasBlocked) + { + guardHasBlocked = true; + PlayerConstants player = GameplayConstants.Current.Player; + float recharge = player.GuardCooldownDuration + * Mathf.Clamp01(player.GuardSuccessCooldownRechargePercent / 100f); + guardCooldownUntil = Mathf.Max(world.GameTime, guardCooldownUntil - recharge); + } + world.CombatFeedback?.NotifyGuardImpact(direction); + return false; + } + if (Kind == ComparisonActorKind.Player && world.GameTime < invulnerableUntil) + return false; + enemy?.SetLastDamageSource(source); + if (Kind == ComparisonActorKind.Player && CurrentShield > 0f) + { + float absorbed = Mathf.Min(shieldAmount, applied); + shieldAmount -= absorbed; + applied -= absorbed; + if (applied <= 0f) + { + GrantInvulnerability(GameplayConstants.Current.Player.InvulnerabilityDuration); + return true; + } + } + float healthBefore = CurrentHealth; + CurrentHealth = Mathf.Max(0f, CurrentHealth - applied); + actualDamage = healthBefore - CurrentHealth; + DamageEvents++; + if (Kind == ComparisonActorKind.Player) + { + invulnerableUntil = world.GameTime + GameplayConstants.Current.Player.InvulnerabilityDuration; + hurtUntil = world.GameTime + GameplayConstants.Current.Player.HurtMovementLockDuration; + world.CancelCyclone(); + world.NotifyPlayerDamageTaken(healthBefore - CurrentHealth); + if (actualDamage > 0f) + { + Vector3 popupPosition = transform.position; + if (world.View != null) popupPosition += world.View.transform.up * .75f; + world.NotifyPlayerDamagePopup(actualDamage, popupPosition); + world.CombatFeedback?.NotifyPlayerHurt(transform.position, direction, knockback); + } + } + ApplyKnockback(direction, knockback); + Visual.ShowHurt(); + if (CurrentHealth <= 0f) + { + StopBody(); + if (Kind == ComparisonActorKind.Player || Kind == ComparisonActorKind.Enemy) Visual.ShowDeath(); + else Visual.Hide(); + BodyCollider.enabled = sensor.enabled = false; + world.NotifyDeath(this); + } + return true; + } + + public void GrantInvulnerability(float duration) => invulnerableUntil = world.GameTime + duration; + + public bool TryActivateGuard() + { + PlayerConstants tuning = GameplayConstants.Current.Player; + if (Kind != ComparisonActorKind.Player || !isActiveAndEnabled || !IsAlive + || Time.timeScale <= 0f || !world.IsRunning || world.Paused || IsHurt || IsGuarding + || GuardCooldownRemaining > 0f) + return false; + guardHasBlocked = false; + guardUntil = world.GameTime + tuning.GuardDuration; + guardCooldownUntil = world.GameTime + tuning.GuardCooldownDuration; + return true; + } + + public bool Heal(float amount) + { + if (Kind != ComparisonActorKind.Player || !IsAlive || amount <= 0f) return false; + float before = CurrentHealth; + CurrentHealth = Mathf.Min(MaxHealth, CurrentHealth + amount); + return CurrentHealth > before; + } + + public bool GrantShield(float amount, float duration) + { + if (Kind != ComparisonActorKind.Player || !IsAlive || amount <= 0f || duration <= 0f) + return false; + shieldAmount = amount; + shieldExpiresAt = world.GameTime + duration; + return true; + } + + public void ApplyKnockback(Vector2 direction, float magnitude) + { + if (magnitude <= 0f) return; + knockbackStartPosition = Position; + knockbackCompletionPending = true; + // Existing enemy force is a velocity; the player's value is a distance. + float speed = Kind == ComparisonActorKind.Player + ? magnitude / Mathf.Max(.001f, GameplayConstants.Current.Player.KnockbackDuration) : magnitude; + knockbackVelocity = direction.normalized * speed; + knockbackRemaining = GameplayConstants.Current.Player.KnockbackDuration; + } + + public Vector2 ConsumeKnockback(float dt) + { + Vector2 delta = knockbackVelocity * (Kind == ComparisonActorKind.Player ? Mathf.Min(dt, knockbackRemaining) : dt); + if (Kind == ComparisonActorKind.Player) + { + knockbackRemaining = Mathf.Max(0f, knockbackRemaining - dt); + if (knockbackRemaining <= 0f) knockbackVelocity = Vector2.zero; + } + else knockbackVelocity = Vector2.MoveTowards(knockbackVelocity, Vector2.zero, + GameplayConstants.Current.Enemies.KnockbackDeceleration * dt); + return delta; + } + + public Vector2 Move(Vector2 displacement, bool slide = true) + { + Vector2 start = Position; + Vector2 next = start; + Vector2 remainder = displacement; + MovementBlocked = false; + LastMovementBlocker = null; + for (int pass = 0; pass < (slide ? 3 : 1) && remainder.sqrMagnitude > .00000001f; pass++) + { + float length = remainder.magnitude; + Vector3 direction = OnGround(remainder / length); + float cylinder = Mathf.Max(0f, BodyCollider.height * .5f - Radius); + Vector3 center = OnGround(next + Offset + BodyCenterOffset, + world.Settings.GroundY + BodyCollider.center.y); + int count = Physics.CapsuleCastNonAlloc(center + Vector3.up * cylinder, + center - Vector3.up * cylinder, Radius, direction, movementHits, + length + world.Settings.MovementSkin, + Defense25DComparisonSettings.TerrainMask | Defense25DComparisonSettings.ActorMask, + QueryTriggerInteraction.Ignore); + float allowed = length; + Vector2 normal = Vector2.zero; + for (int i = 0; i < count; i++) + { + RaycastHit hit = movementHits[i]; + if (hit.collider.attachedRigidbody == Body) continue; + Defense25DActor other = hit.collider.GetComponentInParent(); + if (other != null && (Kind == ComparisonActorKind.Enemy || other.Kind == ComparisonActorKind.Enemy)) continue; + if (other != null && Ignores(other)) continue; + Vector2 hitNormal = Ground(hit.normal).normalized; + if (Vector2.Dot(remainder, hitNormal) >= -.00001f) continue; + float distance = Mathf.Max(0f, hit.distance - world.Settings.MovementSkin); + if (distance < allowed) + { + allowed = distance; + normal = hitNormal; + LastMovementBlocker = hit.collider; + } + } + next += remainder / length * allowed; + if (allowed >= length) break; + MovementBlocked = true; + Vector2 remaining = remainder / length * (length - allowed); + remainder = remaining - normal * Mathf.Min(0f, Vector2.Dot(remaining, normal)); + } + Body.MovePosition(OnGround(next, world.Settings.GroundY)); + LastMove = next - start; + if (knockbackCompletionPending && !HasKnockback) + { + knockbackCompletionPending = false; + world.CombatFeedback?.NotifyKnockbackCompleted(this, knockbackStartPosition, next); + } + return LastMove; + } + + public bool Ignores(Defense25DActor other) => + (Kind == ComparisonActorKind.Player && other.IsMinion) + || (IsMinion && other.Kind == ComparisonActorKind.Player); + + public void IgnorePhysicalPair(Defense25DActor other) + { + foreach (Collider mine in GetComponents()) + foreach (Collider theirs in other.GetComponents()) Physics.IgnoreCollision(mine, theirs); + } + + public void StopBody() + { + knockbackVelocity = LastMove = Vector2.zero; + knockbackRemaining = 0f; + knockbackCompletionPending = false; + BodyCollider.enabled = sensor.enabled = false; + } + + public void RestoreLiveCollisionAndVisuals() + { + if (!isActiveAndEnabled || CurrentHealth <= 0f) return; + BodyCollider.enabled = sensor.enabled = true; + Visual.RestoreVisibility(); + } + + private void OnTriggerEnter(Collider other) + { + if (other.isTrigger || !IsAlive || !world.IsRunning || world.Paused) return; + Defense25DActor actor = other.GetComponentInParent(); + if (actor == this || (actor != null && Ignores(actor))) return; + ContactCallbacks++; + } + + private void OnDisable() + { + if (Kind == ComparisonActorKind.Player) guardUntil = 0f; + if (BodyCollider != null) StopBody(); + if (Visual != null) Visual.Hide(); + if (world != null && Kind == ComparisonActorKind.Player) world.CancelCyclone(); + } + } +} diff --git a/Assets/_Project/Scripts/Defense/Comparison/Defense25DActor.cs.meta b/Assets/_Project/Scripts/Defense/Comparison/Defense25DActor.cs.meta new file mode 100644 index 0000000..66fceaa --- /dev/null +++ b/Assets/_Project/Scripts/Defense/Comparison/Defense25DActor.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 38cdcdac9597465fa33d491290a6e574 diff --git a/Assets/_Project/Scripts/Defense/Comparison/Defense25DArtifactExecutor.cs b/Assets/_Project/Scripts/Defense/Comparison/Defense25DArtifactExecutor.cs new file mode 100644 index 0000000..278d1e7 --- /dev/null +++ b/Assets/_Project/Scripts/Defense/Comparison/Defense25DArtifactExecutor.cs @@ -0,0 +1,1017 @@ +using System; +using System.Collections.Generic; +using BumpCombat.Combat; +using BumpCombat.Constants; +using BumpCombat.Core; +using BumpCombat.Enemies; +using BumpCombat.Player; +using UnityEngine; + +namespace BumpCombat.Defense.Comparison +{ + public readonly struct Defense25DArtifactContact + { + public Defense25DArtifactContact(EnemyArtifactContactResult result, bool counted) + { Result = result; Counted = counted; } + public EnemyArtifactContactResult Result { get; } + public bool Counted { get; } + } + + /// Runs source-defined artifact actions against the 2.5D actors. + public sealed partial class Defense25DArtifactExecutor + { + private sealed class Cast + { + public Defense25DArtifactUse Use; + public int Identity; + public Vector2 Origin; + public Vector2 Direction; + public float Elapsed; + public float NextTick; + public int Tick; + public int HitCount; + public bool MovementBlocked; + public readonly HashSet Hit = new(); + public readonly List Strikes = new(); + public readonly List Lines = new(); + public readonly List Sprites = new(); + public readonly List Frames = new(); + public SpriteRenderer CycloneBack; + public SpriteRenderer CycloneFront; + public SpriteRenderer PulseSprite; + public SpriteRenderer EffectSprite; + public float VisualAge; + } + + private readonly struct ChainStrike + { + public ChainStrike(Defense25DActor target, Vector2 start, Vector2 end, int depth, float damage) + { + Target = target; + Start = start; + End = end; + Depth = depth; + Damage = damage; + } + public Defense25DActor Target { get; } + public Vector2 Start { get; } + public Vector2 End { get; } + public int Depth { get; } + public float Damage { get; } + } + + private const float DirectionEpsilon = 0.0001f; + private const float PulseEllipseAspect = 13f / 32f; + private const float ThunderCrashScale = 1.05f; + private const float CycloneFrameSeconds = .05f; + private const float ThunderCrashVisualSeconds = .16f; + private static readonly float[] PhoenixFrameSeconds = { .02f, .03f, .03f, .03f, .02f, .02f }; + private static readonly float[] ThunderFrameSeconds = { .02f, .03f, .03f, .03f, .02f, .03f }; + private static readonly float[] ChainFrameSeconds = { .03f, .04f, .03f, .03f, .04f, .04f, .04f, .05f }; + private const string PulseDamageReductionSuffix = ".damage-reduction"; + private const string CycloneMoveSpeedSuffix = ".move-speed"; + private static readonly Vector2[] ThunderCrashDirections = + { + Vector2.right, new Vector2(1f, 1f).normalized, Vector2.up, new Vector2(-1f, 1f).normalized, + Vector2.left, new Vector2(-1f, -1f).normalized, Vector2.down, new Vector2(1f, -1f).normalized, + }; + + private readonly List candidates = new(); + private readonly HashSet visited = new(); + private readonly List casts = new(); + private readonly Dictionary buffs = new(); + private Defense25DComparison world; + private Defense25DLoadout loadout; + private bool useNormalOnRelease; + private bool isDashing; + private bool locksArtifactInput; + private int castIdentity; + private Cast cyclone; + + /// Lets the owner apply the original enemy color-shield contact rules. + public Func RegisterArtifactContact { get; set; } + + /// Lets the owner port original ignite, vulnerability and shock status handlers. + public Action ApplySecondaryEffect { get; set; } + + /// Connect to Defense25DRelicRuntime.NotifyHit in the owner. + public Action NotifyRelicHit { get; set; } + + public bool IsExecutingArtifact => locksArtifactInput; + public bool IsDashing => isDashing; + + public void Configure(Defense25DComparison owner, Defense25DLoadout artifactLoadout) + { + if (world != null && loadout != null && loadout.ExecuteArtifact == TryExecute) + loadout.ExecuteArtifact = null; + world = owner ?? throw new ArgumentNullException(nameof(owner)); + loadout = artifactLoadout ?? throw new ArgumentNullException(nameof(artifactLoadout)); + loadout.ExecuteArtifact = TryExecute; + } + + public bool OnArtifactButtonPressed() + { + useNormalOnRelease = false; + if (!CanBegin()) return false; + if (!loadout.ChargeUnlocked) + { + useNormalOnRelease = true; + return true; + } + return loadout.BeginCharge(); + } + + public bool OnArtifactButtonReleased() + { + bool useNormal = useNormalOnRelease; + useNormalOnRelease = false; + if (loadout == null) return false; + if (loadout.IsCharging) return loadout.ReleaseCharge(); + return useNormal && CanExecuteNow() && loadout.TryUse(false); + } + + public void Step(float deltaTime) + { + if (world == null || loadout == null || deltaTime <= 0f) + return; + if (!world.IsRunning) { Cancel(); return; } + if (world.Paused) return; + if (world.Player == null || !world.Player.IsAlive || world.Player.IsHurt) + { + Cancel(); + return; + } + + loadout.AdvanceCharge(deltaTime); + UpdateBuffs(); + for (int i = casts.Count - 1; i >= 0; i--) + { + Cast cast = casts[i]; + cast.Elapsed += deltaTime; + bool complete = StepCast(cast, deltaTime); + UpdateCastVisuals(cast, deltaTime); + if (!complete) continue; + if (ReferenceEquals(cyclone, cast)) + { + FinishCyclone(cast); + cyclone = null; + } + DestroyCast(cast); + casts.RemoveAt(i); + } + locksArtifactInput = cyclone != null || isDashing; + } + + public void Cancel() + { + useNormalOnRelease = false; + loadout?.CancelCharge(); + foreach (Cast cast in casts) DestroyCast(cast); + casts.Clear(); + cyclone = null; + isDashing = locksArtifactInput = false; + if (world?.Player != null) + { + foreach (string source in new List(buffs.Keys)) + world.Player.RemoveModifiersFromSource(source); + } + buffs.Clear(); + } + + public bool TryExecute(Defense25DArtifactUse use) + { + if (!CanExecuteNow() || use.Definition == null || casts.Exists(c => + c.Use.Definition.Effect == ActiveArtifactEffect.Dash && isDashing)) + return false; + Defense25DActor player = world.Player; + Vector2 direction = GetUseDirection(); + Cast cast = new() + { + Use = use, + Identity = castIdentity == int.MaxValue ? 1 : castIdentity + 1, + Origin = player.Position, + Direction = use.Effect == ActiveArtifactEffect.ThunderCrash ? GetFacingSector(player.Facing) : direction, + }; + castIdentity = cast.Identity; + + switch (use.Effect) + { + case ActiveArtifactEffect.Dash: + isDashing = true; + locksArtifactInput = true; + casts.Add(cast); + break; + case ActiveArtifactEffect.Pulse: + CreatePulseSprite(cast); + cast.NextTick = 0f; + casts.Add(cast); + break; + case ActiveArtifactEffect.Phoenix: + ExecutePhoenix(cast); + casts.Add(cast); + break; + case ActiveArtifactEffect.Cyclone: + StartCyclone(cast); + casts.Add(cast); + break; + case ActiveArtifactEffect.ThunderCrash: + StartThunderCrash(cast); + casts.Add(cast); + break; + case ActiveArtifactEffect.ChainLightning: + if (!PlanChain(cast)) return false; + casts.Add(cast); + break; + default: + return false; + } + ApplyUseBuff(use); + return true; + } + + private bool CanBegin() => CanExecuteNow() && !loadout.IsCharging + && loadout.CurrentArtifact != null + && loadout.Gauge >= loadout.CurrentArtifact.NormalGaugeCost; + + private bool CanExecuteNow() => world != null && loadout != null && world.IsRunning + && !world.Paused && world.Player != null && world.Player.IsAlive + && !world.Player.IsHurt && !isDashing && !locksArtifactInput; + + private bool StepCast(Cast cast, float dt) + { + ActiveArtifactDefinition d = cast.Use.Definition; + bool charged = cast.Use.Charged; + float duration = cast.Use.Duration; + switch (cast.Use.Effect) + { + case ActiveArtifactEffect.Dash: + return StepDash(cast, dt); + case ActiveArtifactEffect.Pulse: + StepPulse(cast); + return cast.Elapsed >= duration; + case ActiveArtifactEffect.Phoenix: + return cast.Elapsed >= .15f; + case ActiveArtifactEffect.Cyclone: + StepCyclone(cast, dt); + return cast.Elapsed >= Mathf.Max(duration, Time.fixedDeltaTime); + case ActiveArtifactEffect.ThunderCrash: + StepThunderCrash(cast); + float thunderDuration = Mathf.Max(duration, Time.fixedDeltaTime); + if (cast.Elapsed >= thunderDuration) locksArtifactInput = false; + return cast.Elapsed >= thunderDuration + .16f; + case ActiveArtifactEffect.ChainLightning: + StepChain(cast); + float chainDuration = Mathf.Max(d.HitInterval, Time.fixedDeltaTime) + * (cast.Strikes.Count == 0 ? 0 : cast.Strikes[cast.Strikes.Count - 1].Depth + 1); + return cast.Elapsed >= chainDuration + .3f; + default: + return true; + } + } + + private bool StepDash(Cast cast, float dt) + { + float duration = Mathf.Max(.0001f, cast.Use.Charged + ? GameplayConstants.Current.Artifacts.ChargedDashDuration + : GameplayConstants.Current.Artifacts.DashDuration); + if (cast.Elapsed <= duration) + { + float next = Mathf.Min(duration, cast.Elapsed); + Vector2 target = cast.Origin + cast.Direction * cast.Use.Range * Mathf.Clamp01(next / duration); + Vector2 before = world.Player.Position; + world.Player.Move(target - before, false); + Vector2 moved = world.Player.Position - before; + if (moved.sqrMagnitude > 0f) + { + CreateDashAfterimage(cast, before); + HitRay(cast, before, moved.normalized, moved.magnitude, cast.Use.MaxTargets, true, + includeActorRadius: true); + } + } + if (cast.Elapsed < duration) return false; + isDashing = false; + locksArtifactInput = false; + return cast.Elapsed >= duration + .14f; + } + + private void StepPulse(Cast cast) + { + float range = cast.Use.Range; + Vector2 radii = new(range, range * PulseEllipseAspect); + foreach (Defense25DActor target in Query(cast.Origin, range, null)) + { + if (cast.Hit.Contains(target) || EllipseDistance(target.Position - cast.Origin, radii) > 1.0001f) + continue; + cast.Hit.Add(target); + Defense25DEnemy enemy = target.GetComponent(); + Defense25DArtifactContact contact = enemy != null && RegisterArtifactContact != null + ? RegisterArtifactContact(enemy, cast.Use.Definition.ArtifactColor, cast.Identity) + : new Defense25DArtifactContact(EnemyArtifactContactResult.Groggy, false); + bool counted = contact.Result == EnemyArtifactContactResult.Groggy || contact.Counted; + if (contact.Result == EnemyArtifactContactResult.Ignored || !counted) continue; + if (contact.Result != EnemyArtifactContactResult.Groggy) + { + if (contact.Counted) RaiseArtifactContact(cast, target, cast.Direction, true); + continue; + } + Vector2 offset = target.Position - cast.Origin; + Vector2 direction = offset.sqrMagnitude > DirectionEpsilon ? offset.normalized : cast.Direction; + Vector2 boundary = EllipseBoundary(direction, radii); + float clearance = GameplayConstants.Current.Artifacts.PulseBoundaryClearance; + float minimum = Mathf.Max(offset.magnitude + cast.Use.Knockback, boundary.magnitude + clearance); + Vector2 request = cast.Origin + direction * minimum; + Vector2 previousPosition = target.Position; + Vector2 moved = target.Move(request - target.Position, false); + if ((target.Position - request).sqrMagnitude <= .04f) + { + world.CombatFeedback?.NotifyPulseGather(enemy, previousPosition, previousPosition + moved); + enemy.ApplyStun(cast.Use.Charged + ? GameplayConstants.Current.Artifacts.PulseChargedStunDuration + : GameplayConstants.Current.Artifacts.PulseNormalStunDuration); + CombatEvents.RaiseArtifactEffectiveHit(target.gameObject, cast.Identity, + cast.Use.Effect, cast.Use.Charged); + CombatEvents.RaiseValidHit(CombatHitResult.ForArtifactContact( + world.Player.gameObject, target.gameObject, cast.Use.Effect, cast.Use.Charged, + HitSide.Front, direction, target.Position, cast.Use.Definition.DamageTag, + cast.Use.Definition.ArtifactId)); + } + } + } + + private void ExecutePhoenix(Cast cast) + { + ActiveArtifactDefinition d = cast.Use.Definition; + float width = Mathf.Max(.05f, cast.Use.Width); + float ignite = cast.Use.Charged ? d.ChargedIgniteTickDamage : d.NormalIgniteTickDamage; + HitRay(cast, cast.Origin, cast.Direction, cast.Use.Range, cast.Use.MaxTargets, + false, ignite, false); + CreateLine(cast, cast.Origin, cast.Origin + cast.Direction * cast.Use.Range, d.EffectColor, width); + SpriteRenderer renderer = CreateEffectSprite(cast, + cast.Use.Charged + ? "Artifacts/ThreeColor-v1/ScorchingRay/ScorchingRay-Ghostfire-Charged-v1" + : "Artifacts/ThreeColor-v1/ScorchingRay/ScorchingRay-Ghostfire-Normal-v1", + 6, cast.Use.Charged ? 256 : 128, cast.Use.Charged ? 128 : 64, + cast.Origin, cast.Direction, 32f, 0f); + if (renderer != null) + { + float visibleWidth = cast.Use.Charged ? 192f : 96f; + float visibleHeight = cast.Use.Charged ? 28f : 14f; + renderer.transform.localScale = new Vector3(cast.Use.Range * 32f / visibleWidth, + cast.Use.Width * 32f / visibleHeight, 1f); + } + } + + private void StartCyclone(Cast cast) + { + cyclone = cast; + locksArtifactInput = true; + float duration = Mathf.Max(cast.Use.Duration, Time.fixedDeltaTime); + cast.NextTick = 0f; + cast.HitCount = ScheduledHitCount(duration, cast.Use.Definition.HitInterval); + cast.Origin = world.Player.Position; + cast.Direction = GetUseDirection(); + cast.Strikes.Clear(); + CreateEllipse(cast, world.Player.Position, CycloneRadii(cast), cast.Use.Definition.EffectColor); + CreateCycloneSprites(cast); + } + + private void StepCyclone(Cast cast, float dt) + { + ActiveArtifactDefinition d = cast.Use.Definition; + float duration = Mathf.Max(cast.Use.Duration, Time.fixedDeltaTime); + float hitInterval = Mathf.Max(d.HitInterval, Time.fixedDeltaTime); + while (cast.Tick < cast.HitCount && cast.Elapsed + .0001f >= cast.NextTick) + { + Vector2 center = CycloneCenter(cast); + HitEllipse(cast, center, CycloneRadii(cast), cast.Use.Damage, + cast.Tick == cast.HitCount - 1 ? cast.Use.Knockback : 0f, cast.Use.MaxTargets, true); + cast.Tick++; + cast.NextTick += hitInterval; + } + if (cast.Elapsed < duration && !cast.MovementBlocked) + { + float moveDistance = cast.Use.Charged ? d.ChargedMoveDistance : d.NormalMoveDistance; + float previousProgress = Mathf.Clamp01((cast.Elapsed - dt) / duration); + float currentProgress = Mathf.Clamp01(cast.Elapsed / duration); + Vector2 intended = cast.Direction * moveDistance * (currentProgress - previousProgress); + Vector2 moved = world.Player.Move(intended); + cast.MovementBlocked = (moved - intended).sqrMagnitude > .0001f; + } + } + + private void FinishCyclone(Cast cast) + { + while (cast.Tick < cast.HitCount) + { + HitEllipse(cast, CycloneCenter(cast), CycloneRadii(cast), cast.Use.Damage, + cast.Tick == cast.HitCount - 1 ? cast.Use.Knockback : 0f, cast.Use.MaxTargets, true); + cast.Tick++; + } + ApplyTimedModifier(cast.Use.Definition.ArtifactId + CycloneMoveSpeedSuffix, + CharacterStat.MoveSpeed, + cast.Use.Charged ? cast.Use.Definition.ChargedMoveSpeedIncrease : cast.Use.Definition.NormalMoveSpeedIncrease, + cast.Use.Charged ? cast.Use.Definition.ChargedBuffDuration : cast.Use.Definition.NormalBuffDuration); + } + + private void StartThunderCrash(Cast cast) + { + ActiveArtifactDefinition d = cast.Use.Definition; + float invulnerability = cast.Use.Charged ? d.ChargedInvulnerabilityDuration : d.NormalInvulnerabilityDuration; + if (invulnerability > 0f) + { + world.Player.GrantInvulnerability(invulnerability); + world.CombatFeedback?.NotifyShield(invulnerability); + } + CombatEvents.RaiseThunderShield( + Defense25DActor.OnGround(world.Player.Position, world.Settings.GroundY), invulnerability); + locksArtifactInput = true; + cast.Origin += cast.Direction * .45f; + cast.NextTick = 0f; + cast.HitCount = ScheduledHitCount(Mathf.Max(cast.Use.Duration, Time.fixedDeltaTime), d.HitInterval); + Vector2 center = ThunderCrashCenter(cast); + CreateEllipse(cast, center, ThunderCrashRadii(cast), d.EffectColor); + SpriteRenderer renderer = CreateEffectSprite(cast, + "Artifacts/ThreeColor-v1/ThunderCrash/ThunderCrash-Combined-v6", + 6, 100, 100, center - ThunderCrashOffset(cast.Use.Range), Vector2.right, 32f, + ThunderCrashScale * cast.Use.Range); + if (renderer != null) renderer.sortingOrder = 211; + } + + private void StepThunderCrash(Cast cast) + { + ActiveArtifactDefinition d = cast.Use.Definition; + float interval = Mathf.Max(d.HitInterval, Time.fixedDeltaTime); + while (cast.Tick < cast.HitCount && cast.Elapsed + .0001f >= cast.NextTick) + { + int hitTargets = HitEllipse(cast, ThunderCrashCenter(cast), ThunderCrashRadii(cast), cast.Use.Damage, + cast.Tick == cast.HitCount - 1 ? cast.Use.Knockback : 0f, cast.Use.MaxTargets, true); + if (hitTargets > 0) cast.VisualAge = 0f; + cast.Tick++; + cast.NextTick += interval; + } + } + + private bool PlanChain(Cast cast) + { + Defense25DActor first = Query(cast.Origin, cast.Use.Range, null).Count > 0 + ? Query(cast.Origin, cast.Use.Range, null)[0] : null; + if (first == null) return false; + ActiveArtifactDefinition d = cast.Use.Definition; + visited.Clear(); + if (cast.Use.Charged) + { + Queue queue = new(); + visited.Add(first); + queue.Enqueue(new ChainNode(first, cast.Origin, 0)); + while (queue.Count > 0 && visited.Count <= cast.Use.MaxTargets) + { + ChainNode node = queue.Dequeue(); + AddStrike(cast, node.Target, node.Source, node.Depth); + if (node.Depth + 1 >= d.ChargedChainDepth) continue; + List next = Query(node.Target.Position, d.ChainRange, visited); + for (int i = 0; i < Mathf.Min(d.ChargedBranchCount, next.Count) + && visited.Count < cast.Use.MaxTargets; i++) + { + visited.Add(next[i]); + queue.Enqueue(new ChainNode(next[i], node.Target.Position, node.Depth + 1)); + } + } + } + else + { + Defense25DActor current = first; + Vector2 source = cast.Origin; + int depth = 0; + int count = 0; + while (current != null && depth < d.NormalChainDepth && count < cast.Use.MaxTargets) + { + if (!visited.Add(current)) break; + AddStrike(cast, current, source, depth); + count++; + if (count >= cast.Use.MaxTargets) break; + List next = Query(current.Position, d.ChainRange, visited); + Defense25DActor nextMain = next.Count > 0 ? next[0] : null; + for (int i = 0; i < Mathf.Min(d.NormalBranchCount, next.Count - (nextMain != null ? 1 : 0)) + && count < cast.Use.MaxTargets; i++) + { + Defense25DActor branch = next[i + 1]; + if (!visited.Add(branch)) continue; + AddStrike(cast, branch, current.Position, depth + 1); + count++; + } + source = current.Position; + current = nextMain; + depth++; + } + } + for (int i = 0; i < cast.Strikes.Count; i++) + { + ChainStrike strike = cast.Strikes[i]; + CreateLine(cast, strike.Start, strike.End, d.EffectColor, cast.Use.Charged ? .09f : .07f); + SpriteRenderer arc = CreateEffectSprite(cast, + "Artifacts/ThreeColor-v1/ChainLightning/Ghostfire-Arc-edit-v1", + 8, 100, 100, Vector2.Lerp(strike.Start, strike.End, .5f), + strike.End - strike.Start, 32f, 0f, 8, 50, 81, 15); + if (arc != null) + arc.transform.localScale = new Vector3((strike.End - strike.Start).magnitude / (81f / 32f), 1f, 1f); + } + return cast.Strikes.Count > 0; + } + + private readonly struct ChainNode + { + public ChainNode(Defense25DActor target, Vector2 source, int depth) + { Target = target; Source = source; Depth = depth; } + public Defense25DActor Target { get; } + public Vector2 Source { get; } + public int Depth { get; } + } + + private static void AddStrike(Cast cast, Defense25DActor target, Vector2 start, int depth) + { + float baseDamage = cast.Use.Charged ? cast.Use.Definition.ChargedDamage : cast.Use.Definition.NormalDamage; + float damage = DamageCalculator.ApplyRepeatedMultiplier(baseDamage, + cast.Use.Definition.DamageMultiplierPerChain, depth); + cast.Strikes.Add(new ChainStrike(target, start, target.Position, depth, damage)); + } + + private void StepChain(Cast cast) + { + float interval = Mathf.Max(cast.Use.Definition.HitInterval, Time.fixedDeltaTime); + for (int i = 0; i < cast.Strikes.Count; i++) + { + ChainStrike strike = cast.Strikes[i]; + float progress = Mathf.Clamp01((cast.Elapsed - strike.Depth * interval) / interval); + float strength = GameplayConstants.Current.Artifacts.ProjectileAccelerationStrength; + float eased = strength <= 0f ? progress : (Mathf.Exp(strength * progress) - 1f) / (Mathf.Exp(strength) - 1f); + if (i < cast.Lines.Count && cast.Lines[i] != null) + cast.Lines[i].SetPosition(1, Defense25DActor.OnGround( + Vector2.Lerp(strike.Start, strike.End, eased), world.Settings.GroundY + .04f)); + if (i < cast.Sprites.Count && cast.Sprites[i] != null) + PositionBillboard(cast.Sprites[i], Vector2.Lerp(strike.Start, strike.End, eased), + strike.End - strike.Start); + if (cast.Hit.Contains(strike.Target) || cast.Elapsed + .0001f < (strike.Depth + 1) * interval) continue; + if (strike.Target == null || !strike.Target.IsAlive) { cast.Hit.Add(strike.Target); continue; } + ApplyDamage(cast, strike.Target, strike.Damage, strike.End - strike.Start, + cast.Use.Knockback, strike.Damage, strike.Depth); + cast.Hit.Add(strike.Target); + } + } + + private void HitRay(Cast cast, Vector2 origin, Vector2 direction, float range, int maxTargets, + bool deduplicate, float igniteTickDamage = 0f, bool includeActorRadius = true) + { + Vector2 center = origin + direction * (range * .5f); + var targets = new List(Query(center, range, null)); + foreach (Defense25DActor target in targets) + { + Vector2 offset = target.Position - origin; + float along = Vector2.Dot(offset, direction); + float across = Mathf.Abs(Vector2.Dot(offset, new Vector2(-direction.y, direction.x))); + if (along < -.0001f || along > range + .0001f + || across > cast.Use.Width * .5f + (includeActorRadius ? target.Radius : 0f)) + continue; + if (deduplicate && cast.Hit.Contains(target)) continue; + if (!ApplyDamage(cast, target, cast.Use.Damage, direction, cast.Use.Knockback, igniteTickDamage, 0)) continue; + cast.Hit.Add(target); + if (++cast.HitCount >= maxTargets) break; + } + } + + private int HitEllipse(Cast cast, Vector2 center, Vector2 radii, float damage, float knockback, + int maxTargets, bool bodyColliderOverlap = false) + { + int hits = 0; + IReadOnlyList targets = bodyColliderOverlap + ? world.QueryEnemies(center, radii + Vector2.one * world.Settings.EnemyRadius) + : Query(center, Mathf.Max(radii.x, radii.y), null); + foreach (Defense25DActor target in new List(targets)) + { + if (hits >= maxTargets) break; + bool inside = bodyColliderOverlap + ? DoesBodyColliderOverlapEllipse(target, center, radii) + : EllipseDistance(target.Position - center, radii) <= 1.0001f; + if (!inside) continue; + if (ApplyDamage(cast, target, damage, target.Position - center, knockback, 0f, 0)) hits++; + } + return hits; + } + + private bool DoesBodyColliderOverlapEllipse(Defense25DActor actor, Vector2 center, Vector2 radii) + { + if (actor == null || actor.BodyCollider == null || !actor.BodyCollider.enabled) return false; + float colliderPlaneY = world.Settings.GroundY + actor.BodyCollider.center.y; + return CycloneGeometry.Overlaps(center, radii, point => + Defense25DActor.Ground(actor.BodyCollider.ClosestPoint( + Defense25DActor.OnGround(point, colliderPlaneY)))); + } + + private bool ApplyDamage(Cast cast, Defense25DActor target, float rawDamage, Vector2 direction, + float knockback, float igniteTickDamage, int depth) + { + if (target == null || !target.IsAlive) return false; + ActiveArtifactDefinition d = cast.Use.Definition; + float scaledDamage = world.Player.CalculateDamage(rawDamage, d.DamageTag); + Defense25DEnemy enemy = target.GetComponent(); + if (enemy != null && RegisterArtifactContact != null) + { + Defense25DArtifactContact contact = RegisterArtifactContact(enemy, d.ArtifactColor, cast.Identity); + if (contact.Result == EnemyArtifactContactResult.Ignored) return false; + if (contact.Result == EnemyArtifactContactResult.Shielded) + { + if (contact.Counted) RaiseArtifactContact(cast, target, direction, true); + return false; + } + } + if (!target.TakeDamage(scaledDamage, direction, knockback)) return false; + float incomingDamage = DamageCalculator.FinalizeDamage(target.CalculateIncomingDamage(scaledDamage)); + if (enemy != null) + { + bool hasSecondary = d.Effect switch + { + ActiveArtifactEffect.Phoenix => + (cast.Use.Charged ? d.ChargedIgniteDuration : d.NormalIgniteDuration) > 0f, + ActiveArtifactEffect.ThunderCrash => + (cast.Use.Charged ? d.ChargedVulnerabilityDuration : d.NormalVulnerabilityDuration) > 0f, + ActiveArtifactEffect.ChainLightning => + UnityEngine.Random.value < (cast.Use.Charged ? d.ChargedShockChance : d.NormalShockChance), + _ => false, + }; + if (hasSecondary) + { + float appliedIgnite = igniteTickDamage > 0f + ? world.Player.CalculateDamage(igniteTickDamage, d.DamageTag) : 0f; + ApplySecondaryEffect?.Invoke(enemy, d, cast.Use.Charged, appliedIgnite); + } + CombatEvents.RaiseArtifactEffectiveHit(target.gameObject, cast.Identity, d.Effect, cast.Use.Charged); + CombatEvents.RaiseValidHit(new CombatHitResult( + world.Player.gameObject, target.gameObject, d.Effect == ActiveArtifactEffect.Dash, + HitSide.Front, incomingDamage, direction, knockback, + target.ClosestGroundPoint(world.Player.Center), + damageTag: d.DamageTag, sourceId: d.ArtifactId, usesPositionBonus: false, + showsHitFeedback: incomingDamage > 0f, isArtifactHit: true, + artifactEffect: d.Effect, isChargedArtifact: cast.Use.Charged, + baseBumpDamage: world.Player.CalculateDamage( + GameplayConstants.Current.Combat.BaseDamage, DamageTag.Collision))); + Defense25DRelicTarget relicTarget = world.CreateRelicTarget(enemy); + NotifyRelicHit?.Invoke(new Defense25DRelicHit(relicTarget, + Defense25DActor.OnGround(target.ClosestGroundPoint(world.Player.Center), world.Settings.GroundY), + Defense25DActor.OnGround(direction), incomingDamage, + world.Player.CalculateDamage(GameplayConstants.Current.Combat.BaseDamage, DamageTag.Collision), + ordinaryBump: false, artifactHit: true, sourceId: d.ArtifactId)); + } + target.Visual?.ShowHurt(); + return true; + } + + private void RaiseArtifactContact(Cast cast, Defense25DActor target, Vector2 direction, bool counted) + { + CombatEvents.RaiseArtifactEffectiveHit(target.gameObject, cast.Identity, cast.Use.Effect, + cast.Use.Charged, counted); + CombatEvents.RaiseValidHit(CombatHitResult.ForArtifactContact( + world.Player.gameObject, target.gameObject, cast.Use.Effect, cast.Use.Charged, + HitSide.Front, direction, target.Position, cast.Use.Definition.DamageTag, + cast.Use.Definition.ArtifactId)); + } + + private List Query(Vector2 center, float range, HashSet excluded) + { + candidates.Clear(); + foreach (Defense25DActor target in world.QueryEnemies(center, Vector2.one * range)) + { + Vector2 offset = target.Position - center; + if (offset.sqrMagnitude <= range * range + .0001f && (excluded == null || !excluded.Contains(target))) + candidates.Add(target); + } + return candidates; + } + + private Vector2 GetUseDirection() + { + Vector2 facing = world.Player.Facing; + return facing.sqrMagnitude > DirectionEpsilon ? facing.normalized : Vector2.right; + } + + private static Vector2 GetFacingSector(Vector2 facing) + { + if (facing.sqrMagnitude <= DirectionEpsilon) return Vector2.right; + float angle = Mathf.Atan2(facing.y, facing.x); + int sector = Mathf.FloorToInt((angle + Mathf.PI * .125f - .0001f) / (Mathf.PI * .25f)); + return ThunderCrashDirections[(sector % 8 + 8) % 8]; + } + + private Vector2 CycloneCenter(Cast cast) => world.Player.Position + + CycloneGeometry.CenterOffset(cast.Use.Range, cast.Use.Charged, world.Settings.PlayerVisualScale); + + private static Vector2 CycloneRadii(Cast cast) => CycloneGeometry.Radii(cast.Use.Range, cast.Use.Charged); + + private Vector2 ThunderCrashCenter(Cast cast) => cast.Origin; + + private static Vector2 ThunderCrashRadii(Cast cast) => new Vector2(17f, 7.25f) / 32f + * (ThunderCrashScale * cast.Use.Range); + + private static Vector2 ThunderCrashOffset(float range) => new Vector2(-.5f, .5f) / 32f * ThunderCrashScale * range; + + private static float EllipseDistance(Vector2 offset, Vector2 radii) + { + float x = offset.x / Mathf.Max(.0001f, radii.x); + float y = offset.y / Mathf.Max(.0001f, radii.y); + return Mathf.Sqrt(x * x + y * y); + } + + private static Vector2 EllipseBoundary(Vector2 direction, Vector2 radii) + { + float denominator = Mathf.Sqrt(direction.x * direction.x / (radii.x * radii.x) + + direction.y * direction.y / (radii.y * radii.y)); + return denominator <= .0001f ? Vector2.zero : direction / denominator; + } + + private static int ScheduledHitCount(float duration, float interval) => duration <= 0f || interval <= 0f + ? 1 : Mathf.Max(1, Mathf.FloorToInt((duration + .0001f) / interval) + 1); + + private void ApplyUseBuff(Defense25DArtifactUse use) + { + ActiveArtifactDefinition d = use.Definition; + if (use.Effect == ActiveArtifactEffect.Pulse) + ApplyTimedModifier(d.ArtifactId + PulseDamageReductionSuffix, CharacterStat.IncomingDamage, + use.Charged ? d.ChargedDamageReduction : d.NormalDamageReduction, + use.Charged ? d.ChargedBuffDuration : d.NormalBuffDuration); + } + + private void ApplyTimedModifier(string source, CharacterStat stat, float amount, float duration) + { + if (duration <= 0f || amount <= 0f) return; + world.Player.RemoveModifiersFromSource(source); + bool damageReduction = stat == CharacterStat.IncomingDamage; + world.Player.AddModifier(new StatModifier(source, stat, + damageReduction ? ModifierOperation.More : ModifierOperation.Increased, + damageReduction ? -amount : amount)); + buffs[source] = (stat, world.GameTime + duration); + } + + private void UpdateBuffs() + { + List expired = null; + foreach (KeyValuePair buff in buffs) + { + if (world.GameTime < buff.Value.expires) continue; + expired ??= new List(); + expired.Add(buff.Key); + } + if (expired == null) return; + foreach (string source in expired) + { + world.Player.RemoveModifiersFromSource(source); + buffs.Remove(source); + } + } + + private void CreatePulseSprite(Cast cast) + { + bool charged = cast.Use.Charged; + Sprite[] frames = LoadFrames(charged + ? "Artifacts/ThreeColor-v1/PulseRing/PulseRing-charged-retouched-v1" + : "Artifacts/ThreeColor-v1/PulseRing/PulseRing-normal-retouched-v1", + 7, charged ? 256 : 160, charged ? 104 : 100, 32f); + if (frames == null) return; + cast.Frames.Add(frames); + cast.PulseSprite = CreateSprite("Artifact pulse ring", frames[0], 210); + cast.Sprites.Add(cast.PulseSprite); + PositionBillboard(cast.PulseSprite, cast.Origin, Vector2.right); + float visibleWidth = charged ? 128f : 80f; + float visibleHeight = charged ? 104f : 100f; + cast.PulseSprite.transform.localScale = new Vector3( + cast.Use.Range * 2f * 32f / visibleWidth, + cast.Use.Range * 2f * PulseEllipseAspect * 32f / visibleHeight, + 1f); + } + + private void CreateCycloneSprites(Cast cast) + { + bool charged = cast.Use.Charged; + string key = "Artifacts/ThreeColor-v1/CycloneOrbit-v3/Cyclone-" + (charged ? "Charged-" : "Normal-"); + Sprite[] back = LoadFrames(key + "Back-v3", 8, 100, 100, 32f); + Sprite[] front = LoadFrames(key + "Front-v3", 8, 100, 100, 32f); + if (back == null || front == null) + { + DestroyFrames(back); + DestroyFrames(front); + return; + } + cast.Frames.Add(back); + cast.Frames.Add(front); + cast.CycloneBack = CreateSprite("Cyclone orbit back", back[0], -1); + cast.CycloneFront = CreateSprite("Cyclone orbit front", front[0], 1); + cast.Sprites.Add(cast.CycloneBack); + cast.Sprites.Add(cast.CycloneFront); + float baseRange = charged ? 1.25f : .85f; + float scale = cast.Use.Range / baseRange; + cast.CycloneBack.transform.localScale = cast.CycloneFront.transform.localScale = Vector3.one * scale; + SpriteRenderer playerRenderer = world.Player.Visual != null ? world.Player.Visual.Renderer : null; + if (playerRenderer != null) + { + cast.CycloneBack.sortingLayerID = cast.CycloneFront.sortingLayerID = playerRenderer.sortingLayerID; + cast.CycloneBack.sortingOrder = playerRenderer.sortingOrder - 1; + cast.CycloneFront.sortingOrder = playerRenderer.sortingOrder + 1; + } + } + + private SpriteRenderer CreateEffectSprite(Cast cast, string path, int count, int frameWidth, + int frameHeight, Vector2 position, Vector2 direction, float pixelsPerUnit, float uniformScale, + int cropX = 0, int cropY = 0, int cropWidth = 0, int cropHeight = 0) + { + Sprite[] frames = LoadFrames(path, count, frameWidth, frameHeight, pixelsPerUnit, + cropX, cropY, cropWidth, cropHeight); + if (frames == null) return null; + cast.Frames.Add(frames); + SpriteRenderer renderer = CreateSprite("Artifact " + cast.Use.Effect, frames[0], 210); + cast.Sprites.Add(renderer); + PositionBillboard(renderer, position, direction); + if (uniformScale > 0f) renderer.transform.localScale = Vector3.one * uniformScale; + cast.EffectSprite = renderer; + return renderer; + } + + private Sprite[] LoadFrames(string path, int count, int frameWidth, int frameHeight, + float pixelsPerUnit, int cropX = 0, int cropY = 0, int cropWidth = 0, int cropHeight = 0) + { + Texture2D texture = Resources.Load(path); + if (texture == null || texture.width < frameWidth * count || texture.height < frameHeight) + return null; + texture.filterMode = FilterMode.Point; + texture.wrapMode = TextureWrapMode.Clamp; + int width = cropWidth > 0 ? cropWidth : frameWidth; + int height = cropHeight > 0 ? cropHeight : frameHeight; + Sprite[] frames = new Sprite[count]; + for (int i = 0; i < count; i++) + { + frames[i] = Sprite.Create(texture, new Rect(i * frameWidth + cropX, cropY, width, height), + new Vector2(.5f, .5f), pixelsPerUnit); + frames[i].name = $"{castFrameName(path)} {i + 1}"; + } + return frames; + + static string castFrameName(string resourcePath) => resourcePath.Substring(resourcePath.LastIndexOf('/') + 1); + } + + private SpriteRenderer CreateSprite(string label, Sprite initial, int sortingOrder) + { + GameObject go = new(label); + go.transform.SetParent(world.transform, false); + SpriteRenderer renderer = go.AddComponent(); + renderer.sharedMaterial = world.Settings.CutoutMaterial; + renderer.sprite = initial; + renderer.sortingOrder = sortingOrder; + return renderer; + } + + private void PositionBillboard(SpriteRenderer renderer, Vector2 position, Vector2 direction) + { + if (renderer == null) return; + Quaternion viewRotation = world.View != null ? world.View.transform.rotation + : Quaternion.Euler(world.Settings.CameraPitch, 0f, 0f); + float angle = direction.sqrMagnitude > DirectionEpsilon + ? Mathf.Atan2(direction.y, direction.x) * Mathf.Rad2Deg : 0f; + renderer.transform.rotation = viewRotation * Quaternion.Euler(0f, 0f, angle); + renderer.transform.position = Defense25DActor.OnGround(position, world.Settings.GroundY + .2f); + } + + private void CreateDashAfterimage(Cast cast, Vector2 position) + { + SpriteRenderer playerRenderer = world.Player.Visual != null ? world.Player.Visual.Renderer : null; + if (playerRenderer == null || playerRenderer.sprite == null) return; + SpriteRenderer echo = CreateSprite("Dash afterimage", playerRenderer.sprite, playerRenderer.sortingOrder - 1); + echo.sortingLayerID = playerRenderer.sortingLayerID; + echo.color = new Color(1f, 1f, 1f, .48f); + echo.transform.localScale = playerRenderer.transform.localScale; + PositionBillboard(echo, position + world.Player.Offset, cast.Direction); + cast.Sprites.Add(echo); + } + + private void UpdateCastVisuals(Cast cast, float deltaTime) + { + ActiveArtifactDefinition d = cast.Use.Definition; + switch (cast.Use.Effect) + { + case ActiveArtifactEffect.Pulse when cast.PulseSprite != null: + { + float progress = Mathf.Clamp01(cast.Elapsed / Mathf.Max(.0001f, cast.Use.Duration)); + float cycle = progress < .5f ? progress * 2f : (1f - progress) * 2f; + int frame = Mathf.Min(6, Mathf.FloorToInt(cycle * 7f)); + cast.PulseSprite.sprite = cast.Frames[0][frame]; + cast.PulseSprite.color = new Color(1f, 1f, 1f, 1f - progress); + PositionBillboard(cast.PulseSprite, cast.Origin, Vector2.right); + break; + } + case ActiveArtifactEffect.Phoenix when cast.EffectSprite != null: + cast.EffectSprite.sprite = FrameAt(cast.Frames[0], PhoenixFrameSeconds, cast.Elapsed); + break; + case ActiveArtifactEffect.Cyclone: + { + Vector2 center = CycloneCenter(cast); + int frame = Mathf.FloorToInt(cast.Elapsed / CycloneFrameSeconds) % 8; + if (cast.CycloneBack != null) + { + cast.CycloneBack.sprite = cast.Frames[0][frame]; + cast.CycloneFront.sprite = cast.Frames[1][frame]; + PositionBillboard(cast.CycloneBack, center + Vector2.down * (7.5f / 32f), Vector2.right); + PositionBillboard(cast.CycloneFront, center + Vector2.down * (7.5f / 32f), Vector2.right); + cast.CycloneBack.transform.position += Vector3.forward * .12f; + cast.CycloneFront.transform.position -= Vector3.forward * .12f; + } + if (cast.Lines.Count > 0) SetEllipse(cast.Lines[0], center, CycloneRadii(cast), world.Settings.GroundY + .035f); + break; + } + case ActiveArtifactEffect.ThunderCrash when cast.EffectSprite != null: + cast.EffectSprite.sprite = FrameAt(cast.Frames[0], ThunderFrameSeconds, cast.VisualAge); + cast.VisualAge += deltaTime; + break; + case ActiveArtifactEffect.ChainLightning: + for (int i = 0; i < cast.Sprites.Count; i++) + { + SpriteRenderer arc = cast.Sprites[i]; + if (arc != null) arc.sprite = FrameAt(cast.Frames[i], ChainFrameSeconds, + Mathf.Max(0f, cast.Elapsed - cast.Strikes[i].Depth * Mathf.Max(d.HitInterval, Time.fixedDeltaTime))); + } + break; + case ActiveArtifactEffect.Dash: + foreach (SpriteRenderer echo in cast.Sprites) + if (echo != null) echo.color = new Color(1f, 1f, 1f, + Mathf.Max(0f, echo.color.a - deltaTime * 3.5f)); + break; + } + } + + private static Sprite FrameAt(Sprite[] frames, float[] durations, float elapsed) + { + if (frames == null || frames.Length == 0) return null; + for (int i = 0; i < durations.Length && i < frames.Length; i++) + { + if (elapsed < durations[i]) return frames[i]; + elapsed -= durations[i]; + } + return frames[frames.Length - 1]; + } + + private static void DestroyFrames(Sprite[] frames) + { + if (frames == null) return; + foreach (Sprite frame in frames) + if (frame != null) UnityEngine.Object.Destroy(frame); + } + + private void CreateEllipse(Cast cast, Vector2 center, Vector2 radii, Color color) + { + LineRenderer line = NewLine("Artifact ground ring", color, .035f); + line.positionCount = 65; + line.loop = true; + SetEllipse(line, center, radii, world.Settings.GroundY + .025f); + cast.Lines.Add(line); + } + + private void SetEllipse(LineRenderer line, Vector2 center, Vector2 radii, float groundY) + { + for (int i = 0; i < line.positionCount; i++) + { + float angle = i * Mathf.PI * 2f / (line.positionCount - 1); + line.SetPosition(i, Defense25DActor.OnGround(center + new Vector2(Mathf.Cos(angle) * radii.x, + Mathf.Sin(angle) * radii.y), groundY)); + } + } + + private void CreateLine(Cast cast, Vector2 start, Vector2 end, Color color, float width) + { + LineRenderer line = NewLine("Artifact path", color, width); + line.positionCount = 2; + line.SetPosition(0, Defense25DActor.OnGround(start, world.Settings.GroundY + .04f)); + line.SetPosition(1, Defense25DActor.OnGround(end, world.Settings.GroundY + .04f)); + cast.Lines.Add(line); + } + + private LineRenderer NewLine(string label, Color color, float width) + { + GameObject go = new(label); + go.transform.SetParent(world.transform, false); + LineRenderer line = go.AddComponent(); + line.sharedMaterial = world.Settings.BoundaryMaterial; + line.startColor = line.endColor = color; + line.widthMultiplier = width; + line.alignment = LineAlignment.View; + line.useWorldSpace = true; + return line; + } + + private static void DestroyCast(Cast cast) + { + foreach (LineRenderer line in cast.Lines) + if (line != null) UnityEngine.Object.Destroy(line.gameObject); + cast.Lines.Clear(); + foreach (SpriteRenderer sprite in cast.Sprites) + if (sprite != null) UnityEngine.Object.Destroy(sprite.gameObject); + cast.Sprites.Clear(); + foreach (Sprite[] frames in cast.Frames) DestroyFrames(frames); + cast.Frames.Clear(); + } + + } +} diff --git a/Assets/_Project/Scripts/Defense/Comparison/Defense25DArtifactExecutor.cs.meta b/Assets/_Project/Scripts/Defense/Comparison/Defense25DArtifactExecutor.cs.meta new file mode 100644 index 0000000..c01fd51 --- /dev/null +++ b/Assets/_Project/Scripts/Defense/Comparison/Defense25DArtifactExecutor.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: b7f4860a4f6547628f229a4ef4a81231 diff --git a/Assets/_Project/Scripts/Defense/Comparison/Defense25DArtifactExecutorReplay.cs b/Assets/_Project/Scripts/Defense/Comparison/Defense25DArtifactExecutorReplay.cs new file mode 100644 index 0000000..2ffdc88 --- /dev/null +++ b/Assets/_Project/Scripts/Defense/Comparison/Defense25DArtifactExecutorReplay.cs @@ -0,0 +1,156 @@ +using System.Collections.Generic; +using BumpCombat.Combat; +using BumpCombat.Constants; +using BumpCombat.Core; +using BumpCombat.Enemies; +using BumpCombat.Player; +using UnityEngine; + +namespace BumpCombat.Defense.Comparison +{ + public sealed partial class Defense25DArtifactExecutor + { + public bool TryReplay(Defense25DArtifactUse use, Vector3 origin, Vector3 direction, float damageMultiplier) + { + ActiveArtifactDefinition definition = use.Definition; + if (world == null || definition == null || !world.IsRunning || world.Paused + || world.Player == null || !world.Player.IsAlive || damageMultiplier <= 0f + || use.Damage <= 0f || use.Effect == ActiveArtifactEffect.Pulse) + return false; + + Vector2 replayOrigin = new(origin.x, origin.z); + Vector2 replayDirection = new(direction.x, direction.z); + if (replayDirection.sqrMagnitude < DirectionEpsilon) + replayDirection = use.Effect == ActiveArtifactEffect.ThunderCrash + ? GetFacingSector(world.Player.Facing) : world.Player.Facing; + if (replayDirection.sqrMagnitude < DirectionEpsilon) replayDirection = Vector2.right; + replayDirection.Normalize(); + + var replay = new Cast + { + Use = use, + Identity = castIdentity == int.MaxValue ? 1 : castIdentity + 1, + Origin = replayOrigin, + Direction = replayDirection, + }; + castIdentity = replay.Identity; + + if (use.Effect == ActiveArtifactEffect.ChainLightning) + return ReplayChain(replay, damageMultiplier); + + float range = use.Range; + Vector2 center = replayOrigin; + Vector2 radii = Vector2.one * range; + bool ray = use.Effect is ActiveArtifactEffect.Dash or ActiveArtifactEffect.Phoenix; + float halfWidth = Mathf.Max(.05f, use.Width * .5f); + if (use.Effect == ActiveArtifactEffect.Cyclone) + { + center += CycloneGeometry.CenterOffset(range, use.Charged, world.Settings.PlayerVisualScale); + radii = CycloneGeometry.Radii(range, use.Charged); + } + else if (use.Effect == ActiveArtifactEffect.ThunderCrash) + { + center = replayOrigin + replayDirection * .45f; + radii = new Vector2(17f, 7.25f) / 32f * (ThunderCrashScale * range); + } + + int hits = 0; + float igniteDamage = world.Player.CalculateDamage( + (use.Charged ? definition.ChargedIgniteTickDamage : definition.NormalIgniteTickDamage) + * damageMultiplier, definition.DamageTag); + foreach (Defense25DActor target in GetReplayCandidates( + ray ? replayOrigin + replayDirection * (range * .5f) : center, + Mathf.Max(radii.x, radii.y, range))) + { + if (hits >= use.MaxTargets) break; + Vector2 offset = target.Position - replayOrigin; + bool inside = ray + ? Vector2.Dot(offset, replayDirection) >= 0f + && Vector2.Dot(offset, replayDirection) <= range + && Mathf.Abs(Vector2.Dot(offset, new Vector2(-replayDirection.y, replayDirection.x))) <= halfWidth + : EllipseDistance(target.Position - center, radii) <= 1.0001f; + if (!inside) continue; + + Vector2 hitDirection = ray ? replayDirection : target.Position - center; + if (hitDirection.sqrMagnitude < DirectionEpsilon) hitDirection = replayDirection; + if (ApplyReplayDamage(replay, target, use.Damage * damageMultiplier, + hitDirection.normalized, use.Knockback, igniteDamage)) + hits++; + } + return hits > 0; + } + + private bool ReplayChain(Cast cast, float damageMultiplier) + { + if (!PlanChain(cast)) return false; + ActiveArtifactDefinition definition = cast.Use.Definition; + float igniteDamage = world.Player.CalculateDamage( + (cast.Use.Charged ? definition.ChargedIgniteTickDamage : definition.NormalIgniteTickDamage) + * damageMultiplier, definition.DamageTag); + var strikes = new List(cast.Strikes); + bool applied = false; + foreach (ChainStrike strike in strikes) + { + Vector2 hitDirection = strike.End - strike.Start; + if (hitDirection.sqrMagnitude < DirectionEpsilon) hitDirection = cast.Direction; + applied |= ApplyReplayDamage(cast, strike.Target, strike.Damage * damageMultiplier, + hitDirection, cast.Use.Knockback, igniteDamage); + } + DestroyCast(cast); + return applied; + } + + private List GetReplayCandidates(Vector2 center, float radius) + { + var result = new List(Query(center, radius, null)); + result.Sort((left, right) => + (left.Position - center).sqrMagnitude.CompareTo((right.Position - center).sqrMagnitude)); + return result; + } + + private bool ApplyReplayDamage(Cast cast, Defense25DActor target, float rawDamage, + Vector2 direction, float knockback, float igniteTickDamage) + { + if (target == null || !target.IsAlive || rawDamage <= 0f) return false; + ActiveArtifactDefinition d = cast.Use.Definition; + direction = direction.sqrMagnitude > DirectionEpsilon ? direction.normalized : cast.Direction; + float scaledDamage = world.Player.CalculateDamage(rawDamage, d.DamageTag); + Defense25DEnemy enemy = target.GetComponent(); + if (enemy != null && RegisterArtifactContact != null) + { + Defense25DArtifactContact contact = RegisterArtifactContact(enemy, d.ArtifactColor, cast.Identity); + if (contact.Result == EnemyArtifactContactResult.Ignored) return false; + if (contact.Result == EnemyArtifactContactResult.Shielded) return false; + } + if (!target.TakeDamage(scaledDamage, direction, knockback)) return false; + + float incomingDamage = DamageCalculator.FinalizeDamage(target.CalculateIncomingDamage(scaledDamage)); + if (enemy != null) + { + bool secondary = d.Effect switch + { + ActiveArtifactEffect.Phoenix => + (cast.Use.Charged ? d.ChargedIgniteDuration : d.NormalIgniteDuration) > 0f, + ActiveArtifactEffect.ThunderCrash => + (cast.Use.Charged ? d.ChargedVulnerabilityDuration : d.NormalVulnerabilityDuration) > 0f, + ActiveArtifactEffect.ChainLightning => + UnityEngine.Random.value < (cast.Use.Charged ? d.ChargedShockChance : d.NormalShockChance), + _ => false, + }; + if (secondary) + ApplySecondaryEffect?.Invoke(enemy, d, cast.Use.Charged, igniteTickDamage); + CombatEvents.RaiseValidHit(new CombatHitResult( + world.Player.gameObject, target.gameObject, false, HitSide.Front, + incomingDamage, direction, knockback, + target.Position, damageTag: d.DamageTag, + sourceId: Defense25DRelicRuntime.EchoSourceId, usesPositionBonus: false, + showsHitFeedback: incomingDamage > 0f, isArtifactHit: true, + artifactEffect: d.Effect, isChargedArtifact: cast.Use.Charged, + baseBumpDamage: world.Player.CalculateDamage( + GameplayConstants.Current.Combat.BaseDamage, DamageTag.Collision))); + } + target.Visual?.ShowHurt(); + return true; + } + } +} diff --git a/Assets/_Project/Scripts/Defense/Comparison/Defense25DArtifactExecutorReplay.cs.meta b/Assets/_Project/Scripts/Defense/Comparison/Defense25DArtifactExecutorReplay.cs.meta new file mode 100644 index 0000000..347c1ae --- /dev/null +++ b/Assets/_Project/Scripts/Defense/Comparison/Defense25DArtifactExecutorReplay.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 09c2eaf519a34c1e9e61bc3598716b7b diff --git a/Assets/_Project/Scripts/Defense/Comparison/Defense25DCombatFeedback.cs b/Assets/_Project/Scripts/Defense/Comparison/Defense25DCombatFeedback.cs new file mode 100644 index 0000000..18aa65e --- /dev/null +++ b/Assets/_Project/Scripts/Defense/Comparison/Defense25DCombatFeedback.cs @@ -0,0 +1,882 @@ +using System; +using System.Collections.Generic; +using BumpCombat.Combat; +using BumpCombat.Constants; +using BumpCombat.Defense; +using BumpCombat.Player; +using UnityEngine; + +namespace BumpCombat.Defense.Comparison +{ + /// Restores the original combat's sprite feedback on the Physics3D presentation. + [DisallowMultipleComponent] + public sealed class Defense25DCombatFeedback : MonoBehaviour + { + private sealed class SpriteEffect + { + public GameObject Root; + public SpriteRenderer Renderer; + public Sprite[] Frames; + public IReadOnlyList Durations; + public int Frame; + public float Elapsed; + public bool Loop; + public float Alpha = 1f; + public EffectKind Kind; + public LineRenderer Line; + public float Duration; + public float Hold; + public float Fade; + public bool Heavy; + public bool Unscaled; + public bool PoolLineWhenRemoved; + public bool FollowPlayer; + public bool FollowVisualGroundAnchor; + public Vector2 FollowGroundOffset; + public bool Expand; + public Vector3 MoveStart; + public Vector3 MoveEnd; + public float FollowHeight; + public SpriteRenderer[] Ghosts; + public float[] GhostAlphas; + } + + private readonly List effects = new(); + private readonly List heavyEffects = new(); + private readonly Stack lineEffectPool = new(); + private readonly Dictionary frameCache = new(); + private Defense25DComparison world; + private SpriteEffect aura; + private SpriteEffect readyAura; + private SpriteEffect chargeFlash; + private SpriteEffect switchPulse; + private SpriteEffect guardImpact; + private SpriteEffect blockedBump; + private SpriteRenderer playerRenderer; + private float flashRemaining; + private float blinkRemaining; + private float blinkElapsed; + private float hitStopUntil; + private float cameraShakeUntil; + private float cameraShakeDuration; + private Color flashColor = Color.white; + private bool directionalPunch; + private float lastRearPunchAt = float.NegativeInfinity; + private float nextGuardImpactAt = float.NegativeInfinity; + private float capturedTimeScale = 1f; + private bool ownsTimeFreeze; + private Vector2 shakeDirection; + private float shakePixels; + private Material transparentEffectMaterial; + private bool bound; + + public int ActiveEffectCount => effects.Count; + public int ActiveHeavyEffectCount => heavyEffects.Count; + public int ActiveBumpImpactCount { get; private set; } + public int ActiveArtifactImpactCount { get; private set; } + public int ActivePlayerHurtImpactCount { get; private set; } + public bool IsChargingVisualVisible => aura != null || readyAura != null; + public bool IsReadyFlashVisible => chargeFlash != null; + public Material TransparentEffectMaterial => transparentEffectMaterial; + + public void Bind(Defense25DComparison owner) + { + if (bound) Unbind(); + world = owner; + if (world == null || !world.isActiveAndEnabled || world.Player == null) return; + bound = true; + capturedTimeScale = Time.timeScale > 0f ? Time.timeScale : 1f; + playerRenderer = world.Player.Visual != null ? world.Player.Visual.Renderer : null; + if (world.Settings.TransparentMaterial != null) + { + transparentEffectMaterial = new Material(world.Settings.TransparentMaterial) + { + name = "Defense25D Combat Feedback Transparent", + }; + if (transparentEffectMaterial.HasProperty("_BaseColor")) + transparentEffectMaterial.SetColor("_BaseColor", Color.white); + if (transparentEffectMaterial.HasProperty("_Cutoff")) + transparentEffectMaterial.SetFloat("_Cutoff", .001f); + } + CombatEvents.OnValidHit += HandleValidHit; + } + + private void OnEnable() + { + if (world != null && world.Player != null && !bound) Bind(world); + } + + private void Update() + { + TickFeedback(Time.deltaTime, Time.unscaledDeltaTime); + } + + /// Advances scaled sprite effects and unscaled camera/hit-stop timers. + public void TickFeedback(float scaledDeltaTime, float unscaledDeltaTime) + { + if (!bound || world == null) return; + if (!world.isActiveAndEnabled) + { + Unbind(); + return; + } + scaledDeltaTime = Mathf.Max(0f, scaledDeltaTime); + if (world.Paused) scaledDeltaTime = 0f; + unscaledDeltaTime = Mathf.Max(0f, unscaledDeltaTime); + AdvanceEffects(scaledDeltaTime, unscaledDeltaTime); + AdvanceChargeVisuals(scaledDeltaTime); + if (flashRemaining > 0f) flashRemaining = Mathf.Max(0f, flashRemaining - unscaledDeltaTime); + if (blinkRemaining > 0f) + { + blinkRemaining = Mathf.Max(0f, blinkRemaining - scaledDeltaTime); + blinkElapsed += scaledDeltaTime; + } + ApplyPlayerTint(); + + if (hitStopUntil > 0f && Time.unscaledTime >= hitStopUntil) + { + hitStopUntil = 0f; + RestoreOwnedTimeFreeze(); + } + if (cameraShakeUntil > 0f) + { + if (Time.unscaledTime >= cameraShakeUntil) + { + cameraShakeUntil = 0f; + if (world.View != null) world.View.ClearFeedbackCameraOffset(); + } + else if (world.View != null) + { + float fade = Mathf.Clamp01((cameraShakeUntil - Time.unscaledTime) + / Mathf.Max(.001f, cameraShakeDuration)); + if (directionalPunch) + { + Vector2 punch = shakeDirection * shakePixels * fade; + world.View.SetFeedbackCameraOffset(new Vector2(Mathf.Round(punch.x), Mathf.Round(punch.y))); + } + else + { + Vector2 jitter = UnityEngine.Random.insideUnitCircle * shakePixels * fade; + world.View.SetFeedbackCameraOffset(new Vector2(Mathf.Round(jitter.x), Mathf.Round(jitter.y))); + } + } + } + } + + public void NotifyValidHit(CombatHitResult result) + { + if (!bound || world == null || !world.isActiveAndEnabled || world.Player == null + || result.Attacker != world.Player.gameObject || result.Target == null) return; + + // Per-contact artifact flashes are deliberately emitted before this feedback gate, + // matching CombatFeedback.HandleValidHit for zero-damage/hidden contacts. + bool artifactVisualCreated = result.IsArtifactHit && CreateArtifactImpact(result); + if (!result.ShowsHitFeedback) return; + + CombatFeedbackSettings tuning = GameplayConstants.Current.CombatFeedback; + if (result.IsOrdinaryBump && result.HasBackBonus) + { + float now = Time.time; + if (now - lastRearPunchAt >= tuning.RearHitCameraPunchRearmDelay) + { + if (cameraShakeUntil <= Time.unscaledTime + && result.KnockbackDirection.sqrMagnitude > Mathf.Epsilon) + { + shakePixels = tuning.RearHitCameraPunchPixels; + shakeDirection = result.KnockbackDirection.normalized; + cameraShakeDuration = tuning.RearHitCameraPunchDuration; + cameraShakeUntil = Time.unscaledTime + tuning.RearHitCameraPunchDuration; + directionalPunch = true; + } + } + lastRearPunchAt = now; + } + + Color flash = HitColor(result); + flashColor = Color.Lerp(Color.white, flash, .55f); + if (!artifactVisualCreated && result.IsOrdinaryBump) + CreateImpact(result.HitPosition, BumpFrames(result.Side), Defense25DCombatFeedbackConstants.BumpFrames, + 2f, Color.white, result.KnockbackDirection, "Bump Impact", EffectKind.Bump, + true, 102); + if (result.KnockbackForce > 0f) + CreateFloorStreak(result.HitPosition, result.KnockbackDirection, flash); + + flashRemaining = Mathf.Max(flashRemaining, tuning.FlashDuration); + RequestHitStop(result.IsStrongDash ? tuning.StrongDashHitStopDuration + : result.IsDash ? tuning.DashHitStopDuration + : result.HasBackBonus ? tuning.BackHitStopDuration + : tuning.NormalHitStopDuration); + if (result.IsStrongDash) RequestCameraShake(tuning.HeavyCameraShakePixels, result.KnockbackDirection); + } + + public void NotifyPlayerHurt(Vector2 groundPosition, bool heavy) => + NotifyPlayerHurt(groundPosition, Vector2.zero, heavy); + + public void NotifyPlayerHurt(Vector2 groundPosition, Vector2 knockbackDirection, bool heavy) + { + if (!bound || world == null || !world.isActiveAndEnabled || world.Player == null) return; + CombatFeedbackSettings tuning = GameplayConstants.Current.CombatFeedback; + Vector2 origin = groundPosition; + if (origin == Vector2.zero) + origin = world.Player.Position; + if (knockbackDirection.sqrMagnitude > 0f) origin -= knockbackDirection.normalized * .18f; + CreateImpact(origin, LoadFrames(CombatFeedback.GetPlayerHurtImpactResourcePath()), + Defense25DCombatFeedbackConstants.PlayerHurtFrames, 1f, Color.white, Vector2.zero, "Player Hurt Impact", EffectKind.Hurt); + flashRemaining = Mathf.Max(flashRemaining, tuning.DamageFlashDuration); + flashColor = Color.Lerp(Color.white, new Color(1f, .18f, .12f, 1f), .55f); + blinkRemaining = Mathf.Max(blinkRemaining, GameplayConstants.Current.Player.InvulnerabilityDuration); + blinkElapsed = 0f; + RequestHitStop(tuning.DamageHitStopDuration); + RequestCameraShake(heavy ? tuning.HeavyCameraShakePixels : tuning.CameraShakePixels, Vector2.zero); + } + + public void NotifyPlayerHurt(Vector3 worldPosition, bool heavy) => + NotifyPlayerHurt(new Vector2(worldPosition.x, worldPosition.z), heavy); + + public void NotifyPlayerHurt(Vector3 worldPosition, Vector2 knockbackDirection, float knockbackDistance) + { + float threshold = GameplayConstants.Current.CombatFeedback.HeavyKnockbackThreshold; + NotifyPlayerHurt(new Vector2(worldPosition.x, worldPosition.z), knockbackDirection, + knockbackDistance >= threshold); + } + + public void NotifyChargeChanged(ActiveArtifactDefinition definition, float normalized, bool becameFull) + { + if (!bound || world == null || !world.isActiveAndEnabled || definition == null || world.Player == null) return; + normalized = Mathf.Clamp01(normalized); + if (normalized <= 0f) + { + ClearChargeVisuals(); + return; + } + if (normalized >= 1f) + { + ClearEffect(ref aura); + EnsureChargeSlot(ref readyAura, definition, Defense25DCombatFeedbackConstants.ChargeReadyAuraSuffix, true, Defense25DCombatFeedbackConstants.ChargeAuraFrames, -1); + if (becameFull) + { + EnsureChargeSlot(ref chargeFlash, definition, Defense25DCombatFeedbackConstants.ChargeFlashSuffix, false, Defense25DCombatFeedbackConstants.ChargeFlashFrames, 1, + ArtifactChargeVisual.FlashFrameCount); + if (chargeFlash != null) chargeFlash.Loop = false; + } + return; + } + ClearEffect(ref readyAura); + ClearEffect(ref chargeFlash); + EnsureChargeSlot(ref aura, definition, Defense25DCombatFeedbackConstants.ChargeAuraSuffix, true, Defense25DCombatFeedbackConstants.ChargeAuraFrames, -1, + ArtifactChargeVisual.FrameCount); + if (aura != null) aura.Alpha = Mathf.Lerp(.55f, .85f, normalized); + } + + public void NotifyArtifactReleased(ActiveArtifactDefinition definition, Vector2 groundOrigin, bool charged) + { + if (!bound || world == null || !world.isActiveAndEnabled || definition == null) return; + string path = Defense25DCombatFeedbackConstants.ChargeResourceRoot + definition.Effect + Defense25DCombatFeedbackConstants.ChargeReleaseSuffix; + Sprite[] frames = LoadFrames(path); + if (frames.Length == ArtifactChargeVisual.FrameCount) + CreateImpact(groundOrigin, frames, Defense25DCombatFeedbackConstants.ChargeReleaseFrames, 1f, Color.white, + Vector2.zero, "Artifact Release", EffectKind.Release, false, 1); + ClearChargeVisuals(); + } + + public void NotifyArtifactSwitched(ActiveArtifactDefinition definition) + { + if (!bound || world == null || !world.isActiveAndEnabled || definition == null || playerRenderer == null) return; + EnsureChargeSlot(ref switchPulse, definition, Defense25DCombatFeedbackConstants.ChargeReleaseSuffix, false, Defense25DCombatFeedbackConstants.ChargeAuraFrames, 1, + ArtifactChargeVisual.FrameCount); + if (switchPulse != null) switchPulse.Loop = false; + } + + public void NotifyGuardImpact(Vector2 incomingDirection) + { + if (!bound || world == null || !world.isActiveAndEnabled || world.Player == null || playerRenderer == null + || Time.time < nextGuardImpactAt) return; + CombatFeedbackSettings tuning = GameplayConstants.Current.CombatFeedback; + Sprite sprite = Resources.Load(CombatFeedback.GetGuardBlockImpactResourcePath()); + if (sprite == null) return; + nextGuardImpactAt = Time.time + tuning.GuardBlockImpactInterval; + Vector2 direction = incomingDirection.sqrMagnitude > 0f + ? incomingDirection.normalized : Vector2.zero; + Vector2 position = world.Player.Center - direction * tuning.GuardBlockImpactOffset; + SpriteEffect effect = CreateStaticSprite("Player Guard Block Impact", sprite, + ToWorld(position), playerRenderer.sortingOrder + 20, EffectKind.Guard); + if (effect != null) + { + effect.FollowPlayer = true; + effect.FollowGroundOffset = -direction * tuning.GuardBlockImpactOffset; + effect.Hold = tuning.GuardBlockImpactHoldDuration; + effect.Fade = tuning.GuardBlockImpactFadeDuration; + effect.Duration = effect.Hold + effect.Fade; + guardImpact = effect; + } + } + + public void NotifyBlockedBump(Vector2 hitPosition, Vector2 approachDirection) + { + if (!bound || world == null || !world.isActiveAndEnabled || playerRenderer == null) return; + CombatFeedbackSettings tuning = GameplayConstants.Current.CombatFeedback; + Sprite sprite = Resources.Load(Defense25DCombatFeedbackConstants.BlockedBumpResource); + if (sprite == null) return; + Vector3 position = ToWorld(hitPosition) + Vector3.up * tuning.BlockedBumpVerticalOffset; + SpriteEffect effect = CreateStaticSprite("Blocked Bump", sprite, position, + playerRenderer.sortingOrder + 102, EffectKind.Blocked); + if (effect != null) + { + effect.Renderer.flipX = approachDirection.x < 0f; + effect.Hold = tuning.BlockedBumpHoldDuration; + effect.Fade = tuning.BlockedBumpFadeDuration; + effect.Duration = effect.Hold + effect.Fade; + blockedBump = effect; + } + } + + public void NotifyTowerImpact(DefenseTowerKind kind, Defense25DEnemy target, Vector2 hitPosition) + { + if (!bound || world == null || !world.isActiveAndEnabled || target == null) return; + string path = CombatFeedback.GetTowerImpactResourcePath(kind); + Sprite[] frames = LoadFrames(path); + if (frames.Length != CombatFeedback.BumpImpactFrameCount) return; + SpriteEffect effect = new(); + if (!CreateImpact(hitPosition, frames, Defense25DCombatFeedbackConstants.ArtifactFrames, 2f, Color.white, + Vector2.zero, $"Tower Impact {kind}", EffectKind.Tower, false, + DefenseTowerArtBounds.TowerHitSortingOffset, effect)) return; + SpriteRenderer targetRenderer = target.Actor != null && target.Actor.Visual != null + ? target.Actor.Visual.Renderer : null; + if (targetRenderer != null && effect.Renderer != null) + { + effect.Renderer.sortingLayerID = targetRenderer.sortingLayerID; + effect.Renderer.sortingOrder = targetRenderer.sortingOrder + + DefenseTowerArtBounds.TowerHitSortingOffset; + } + } + + public void NotifyShield(float duration) + { + if (!bound || world == null || !world.isActiveAndEnabled || duration <= 0f) return; + SpriteEffect ring = CreateRing(PlayerVisualGroundPosition, .6f, + new Color(.15f, .95f, .95f, 1f), duration, true); + if (ring != null) + { + ring.FollowPlayer = true; + ring.FollowVisualGroundAnchor = true; + } + } + + public void NotifyPulseGather(Defense25DEnemy target, Vector2 previousPosition, Vector2 finalPosition) + { + if (!bound || world == null || !world.isActiveAndEnabled || target == null + || target.Actor == null || target.Actor.Visual == null) return; + float distance = Vector2.Distance(previousPosition, finalPosition); + if (!CombatFeedback.ShouldShowPulseStreak(distance)) + { + CreateRing(finalPosition, .26f, new Color(1f, .39f, .39f, 1f), .12f, true); + return; + } + + SpriteRenderer source = target.Actor.Visual.Renderer; + if (source == null || source.sprite == null) return; + GameObject root = new("Pulse Gather Afterimage"); + root.transform.rotation = world.View != null ? world.View.transform.rotation : Quaternion.identity; + root.transform.localScale = source.transform.lossyScale; + float height = source.transform.position.y - world.Settings.GroundY; + root.transform.position = ToWorld(previousPosition) + Vector3.up * height; + SpriteRenderer first = root.AddComponent(); + CopyGhost(source, first, 2, new Color(1f, .39f, .39f, .65f)); + GameObject secondObject = new("Pulse Gather Afterimage 2"); + secondObject.transform.SetParent(root.transform, false); + secondObject.transform.localPosition = Vector3.left * .08f; + SpriteRenderer second = secondObject.AddComponent(); + CopyGhost(source, second, 1, new Color(1f, .65f, .65f, .4f)); + LineRenderer streak = AcquireLine("Pulse Gather Streak"); + CombatFeedback.ConfigureWorldStreakRenderer(streak, transparentEffectMaterial, + new Color(1f, .39f, .39f, .8f), .055f, ToWorld(previousPosition), ToWorld(finalPosition)); + SpriteEffect effect = new() + { + Root = root, Line = streak, Frames = Array.Empty(), + Durations = new[] { Defense25DCombatFeedbackConstants.PulseGatherDuration }, + Duration = Defense25DCombatFeedbackConstants.PulseGatherDuration, + Unscaled = true, Heavy = true, + MoveStart = ToWorld(previousPosition) + Vector3.up * height, + MoveEnd = ToWorld(finalPosition) + Vector3.up * height, + FollowHeight = height, + Ghosts = new[] { first, second }, + GhostAlphas = new[] { .65f, .4f }, + }; + AddEffect(effect, true); + } + + public void NotifyKnockbackCompleted(Defense25DActor target, Vector2 start, Vector2 end) + { + if (!bound || world == null || !world.isActiveAndEnabled || target == null + || target.Kind != ComparisonActorKind.Enemy + || !IsOwnedEnemy(target)) return; + if (CombatFeedback.ShouldShowKnockbackDust(Vector2.Distance(start, end))) + CreateRing(end, .2f, new Color(.9f, .8f, 1f, 1f), .13f, true); + } + + public void ResetFeedback() + { + ClearAllEffects(); + ClearChargeVisuals(); + flashRemaining = blinkRemaining = blinkElapsed = hitStopUntil = cameraShakeUntil = 0f; + ActiveBumpImpactCount = ActiveArtifactImpactCount = ActivePlayerHurtImpactCount = 0; + directionalPunch = false; + shakeDirection = Vector2.zero; + shakePixels = cameraShakeDuration = 0f; + if (world != null && world.Player != null && world.Player.Visual != null) + world.Player.Visual.ClearFeedbackTint(); + if (world != null && world.View != null) world.View.ClearFeedbackCameraOffset(); + RestoreOwnedTimeFreeze(); + lastRearPunchAt = float.NegativeInfinity; + nextGuardImpactAt = float.NegativeInfinity; + } + + private void HandleValidHit(CombatHitResult result) => NotifyValidHit(result); + + private void Unbind() + { + if (bound) CombatEvents.OnValidHit -= HandleValidHit; + bound = false; + ResetFeedback(); + while (lineEffectPool.Count > 0) + { + GameObject pooled = lineEffectPool.Pop(); + if (pooled != null) Destroy(pooled); + } + frameCache.Clear(); + playerRenderer = null; + if (transparentEffectMaterial != null) + { + Destroy(transparentEffectMaterial); + transparentEffectMaterial = null; + } + } + + private void OnDisable() => Unbind(); + + private void OnDestroy() => Unbind(); + + private void RestoreOwnedTimeFreeze() + { + if (!ownsTimeFreeze) return; + Time.timeScale = capturedTimeScale; + ownsTimeFreeze = false; + } + + private enum EffectKind { Generic, Bump, Artifact, Hurt, Release, Charge, Guard, Blocked, Tower } + + private bool CreateArtifactImpact(CombatHitResult result) + { + Sprite[] frames = LoadFrames(CombatFeedback.GetArtifactImpactResourcePath( + result.ArtifactEffect, result.IsChargedArtifact)); + if (frames.Length != CombatFeedback.ArtifactImpactFrameCount) return false; + Vector2 direction = result.KnockbackDirection.sqrMagnitude > 0f + ? result.KnockbackDirection.normalized : Vector2.right; + bool created = CreateImpact(result.HitPosition, frames, Defense25DCombatFeedbackConstants.ArtifactFrames, 2f, + Color.white, direction, "Artifact Impact " + result.ArtifactEffect, + EffectKind.Artifact, true, 102); + return created; + } + + private Sprite[] BumpFrames(HitSide side) + { + ActiveArtifactDefinition selected = world.Loadout != null ? world.Loadout.CurrentArtifact : null; + string path = selected != null + ? $"Artifacts/ThreeColor-v1/Combat/BumpImpact-{(side == HitSide.Back ? "Strong" : side == HitSide.Side ? "Medium" : "Weak")}-{selected.ArtifactColor}-v1" + : CombatFeedback.GetBumpImpactResourcePath(side); + Sprite[] frames = LoadFrames(path); + return frames.Length == CombatFeedback.BumpImpactFrameCount + ? frames : LoadFrames(CombatFeedback.GetBumpImpactResourcePath(side)); + } + + private bool CreateImpact(Vector2 groundPosition, Sprite[] frames, IReadOnlyList durations, + float scale, Color color, Vector2 direction, string label, EffectKind kind, + bool rotate = false, int sortingOffset = 102, SpriteEffect capture = null) + { + if (frames == null || frames.Length == 0 || world == null || world.Player == null) return false; + SpriteEffect effect = new() + { + Root = new GameObject(label), Frames = frames, Durations = durations, + Loop = false, Renderer = null, + }; + effect.Root.transform.position = ToWorld(groundPosition); + effect.Root.transform.localScale = Vector3.one * scale; + if (world.View != null) + { + effect.Root.transform.rotation = world.View.transform.rotation; + if (rotate && direction.sqrMagnitude > .0001f) + { + Vector3 worldDirection = new(direction.x, 0f, direction.y); + Vector3 projected = new(Vector3.Dot(worldDirection, world.View.transform.right), + Vector3.Dot(worldDirection, world.View.transform.up), 0f); + if (projected.sqrMagnitude > .0001f) + effect.Root.transform.rotation *= Quaternion.AngleAxis( + Mathf.Atan2(projected.y, projected.x) * Mathf.Rad2Deg, Vector3.forward); + } + } + effect.Renderer = effect.Root.AddComponent(); + effect.Renderer.sprite = frames[0]; + effect.Renderer.color = color; + if (transparentEffectMaterial != null) effect.Renderer.sharedMaterial = transparentEffectMaterial; + if (playerRenderer != null) + { + effect.Renderer.sortingLayerID = playerRenderer.sortingLayerID; + effect.Renderer.sortingOrder = playerRenderer.sortingOrder + sortingOffset; + } + effects.Add(effect); + effect.Kind = kind; + if (capture != null) + { + capture.Root = effect.Root; + capture.Renderer = effect.Renderer; + capture.Frames = effect.Frames; + capture.Durations = effect.Durations; + capture.Kind = effect.Kind; + } + if (kind == EffectKind.Bump) ActiveBumpImpactCount++; + if (kind == EffectKind.Artifact) ActiveArtifactImpactCount++; + if (kind == EffectKind.Hurt) ActivePlayerHurtImpactCount++; + return true; + } + + private SpriteEffect CreateStaticSprite(string label, Sprite sprite, Vector3 position, + int sortingOffset, EffectKind kind) + { + if (sprite == null || world == null) return null; + GameObject root = new(label); + root.transform.position = position; + if (world.View != null) root.transform.rotation = world.View.transform.rotation; + SpriteRenderer renderer = root.AddComponent(); + renderer.sprite = sprite; + renderer.color = Color.white; + if (transparentEffectMaterial != null) renderer.sharedMaterial = transparentEffectMaterial; + if (playerRenderer != null) + { + renderer.sortingLayerID = playerRenderer.sortingLayerID; + renderer.sortingOrder = playerRenderer.sortingOrder + sortingOffset; + } + SpriteEffect effect = new() { Root = root, Renderer = renderer, + Frames = Array.Empty(), Durations = new[] { .001f }, Kind = kind }; + effects.Add(effect); + return effect; + } + + private void CreateFloorStreak(Vector2 position, Vector2 direction, Color color) + { + if (direction.sqrMagnitude < .0001f || world == null) return; + LineRenderer line = AcquireLine("Interaction Streak"); + GameObject root = line.gameObject; + CombatFeedback.ConfigureWorldStreakRenderer(line, transparentEffectMaterial, + color, .04f, ToWorld(position), ToWorld(position + direction.normalized * .34f)); + SpriteEffect effect = new() { Root = root, Line = line, + Duration = Defense25DCombatFeedbackConstants.InteractionStreakDuration, + Durations = new[] { Defense25DCombatFeedbackConstants.InteractionStreakDuration }, + Frames = Array.Empty(), Loop = false, Unscaled = true, Heavy = true, + PoolLineWhenRemoved = true }; + AddEffect(effect, true); + } + + private SpriteEffect CreateRing(Vector2 position, float radius, Color color, + float duration, bool useScaledTime) + { + if (!bound || world == null || duration <= 0f) return null; + LineRenderer line = AcquireLine("Interaction Ring"); + GameObject root = line.gameObject; + root.transform.SetPositionAndRotation(ToWorld(position) + Vector3.up * .025f, Quaternion.identity); + root.transform.localScale = Vector3.one * .72f; + line.enabled = true; + line.loop = true; + line.useWorldSpace = false; + line.positionCount = 24; + line.startWidth = line.endWidth = 0f; + line.widthMultiplier = .045f; + line.numCornerVertices = 0; + line.numCapVertices = 2; + line.sharedMaterial = transparentEffectMaterial; + line.startColor = line.endColor = color; + line.sortingOrder = 210; + for (int i = 0; i < line.positionCount; i++) + { + float angle = i * Mathf.PI * 2f / line.positionCount; + line.SetPosition(i, new Vector3(Mathf.Cos(angle) * radius, 0f, + Mathf.Sin(angle) * radius)); + } + SpriteEffect effect = new() { Root = root, Line = line, Duration = duration, + Durations = new[] { duration }, Frames = Array.Empty(), + Unscaled = !useScaledTime, Heavy = true, PoolLineWhenRemoved = true, + Expand = true }; + AddEffect(effect, true); + return effects.Contains(effect) ? effect : null; + } + + private LineRenderer AcquireLine(string label) + { + GameObject root = lineEffectPool.Count > 0 ? lineEffectPool.Pop() : new GameObject(label); + root.name = label; + root.SetActive(true); + LineRenderer line = root.GetComponent(); + if (line == null) line = root.AddComponent(); + line.enabled = true; + line.loop = false; + return line; + } + + private void AddEffect(SpriteEffect effect, bool heavy) + { + effects.Add(effect); + effect.Heavy = heavy; + if (!heavy) return; + heavyEffects.Add(effect); + int limit = CombatFeedback.MaxConcurrentHeavyEffects; + while (heavyEffects.Count > limit) + { + SpriteEffect oldest = heavyEffects[0]; + int index = effects.IndexOf(oldest); + if (index >= 0) RemoveEffectAt(index, oldest); + else heavyEffects.RemoveAt(0); + } + } + + private bool IsOwnedEnemy(Defense25DActor actor) + { + foreach (Defense25DEnemy enemy in world.Enemies) + if (enemy != null && enemy.Actor == actor) return true; + return false; + } + + private void CopyGhost(SpriteRenderer source, SpriteRenderer ghost, int sortingOffset, Color color) + { + ghost.sprite = source.sprite; + ghost.flipX = source.flipX; + ghost.flipY = source.flipY; + ghost.color = color; + ghost.sharedMaterial = transparentEffectMaterial; + ghost.sortingLayerID = source.sortingLayerID; + ghost.sortingOrder = source.sortingOrder + sortingOffset; + } + + private void EnsureChargeSlot(ref SpriteEffect slot, ActiveArtifactDefinition definition, + string suffix, bool loop, IReadOnlyList durations, int sortingOffset, int expectedFrameCount = ArtifactChargeVisual.FrameCount) + { + if (slot != null) return; + Sprite[] frames = LoadFrames(Defense25DCombatFeedbackConstants.ChargeResourceRoot + definition.Effect + suffix); + if (frames.Length != expectedFrameCount || playerRenderer == null) return; + GameObject root = new("Artifact Charge " + suffix); + root.transform.SetParent(playerRenderer.transform, false); + SpriteRenderer renderer = root.AddComponent(); + renderer.sprite = frames[0]; + renderer.color = Color.white; + renderer.sharedMaterial = playerRenderer.sharedMaterial; + renderer.sortingLayerID = playerRenderer.sortingLayerID; + renderer.sortingOrder = playerRenderer.sortingOrder + sortingOffset; + if (transparentEffectMaterial != null) renderer.sharedMaterial = transparentEffectMaterial; + slot = new SpriteEffect { Root = root, Renderer = renderer, Frames = frames, + Durations = durations, Loop = loop, Kind = EffectKind.Charge }; + effects.Add(slot); + } + + private void AdvanceChargeVisuals(float dt) + { + if (aura != null) aura.Renderer.color = new Color(1f, 1f, 1f, aura.Alpha); + } + + private void AdvanceEffects(float scaledDeltaTime, float unscaledDeltaTime) + { + for (int i = effects.Count - 1; i >= 0; i--) + { + SpriteEffect effect = effects[i]; + if (effect == null || effect.Root == null) { effects.RemoveAt(i); continue; } + float dt = effect.Unscaled ? unscaledDeltaTime : scaledDeltaTime; + if (dt <= 0f) continue; + if (effect.Frames.Length == 0) + { + effect.Elapsed += dt; + float progress = Mathf.Clamp01(effect.Elapsed / Mathf.Max(.001f, effect.Duration)); + if (effect.FollowPlayer && world.Player != null) + { + Vector2 followPosition = effect.FollowVisualGroundAnchor + ? PlayerVisualGroundPosition : world.Player.Position; + effect.Root.transform.position = ToWorld(followPosition + effect.FollowGroundOffset) + + Vector3.up * .025f; + } + if (effect.MoveEnd != Vector3.zero) + effect.Root.transform.position = Vector3.Lerp(effect.MoveStart, effect.MoveEnd, progress); + if (effect.Expand) + effect.Root.transform.localScale = Vector3.one * Mathf.Lerp(.72f, 1.2f, progress); + if (effect.Ghosts != null) + for (int g = 0; g < effect.Ghosts.Length; g++) + if (effect.Ghosts[g] != null) + { + Color ghostColor = effect.Ghosts[g].color; + ghostColor.a = effect.GhostAlphas[g] * (1f - progress); + effect.Ghosts[g].color = ghostColor; + } + if (effect.Renderer != null && effect.Duration > 0f) + { + float fadeProgress = effect.Fade > 0f + ? Mathf.Clamp01((effect.Elapsed - effect.Hold) / effect.Fade) : 1f; + Color spriteColor = effect.Renderer.color; + spriteColor.a = 1f - fadeProgress; + effect.Renderer.color = spriteColor; + if (effect.Elapsed >= effect.Duration) RemoveEffectAt(i, effect); + continue; + } + if (effect.Line != null) + { + Color color = effect.Line.startColor; + color.a = 1f - progress; + effect.Line.startColor = effect.Line.endColor = color; + } + if (effect.Elapsed >= effect.Duration) RemoveEffectAt(i, effect); + continue; + } + effect.Elapsed += dt; + while (effect.Elapsed >= effect.Durations[Mathf.Clamp(effect.Frame, 0, effect.Durations.Count - 1)]) + { + effect.Elapsed -= effect.Durations[Mathf.Clamp(effect.Frame, 0, effect.Durations.Count - 1)]; + int next = effect.Frame + 1; + if (next >= effect.Frames.Length) + { + if (!effect.Loop) { RemoveEffectAt(i, effect); break; } + next = 0; + } + effect.Frame = next; + if (effect.Renderer != null) effect.Renderer.sprite = effect.Frames[next]; + } + } + } + + private void RemoveEffectAt(int index, SpriteEffect effect) + { + if (effect == aura) aura = null; + if (effect == readyAura) readyAura = null; + if (effect == chargeFlash) chargeFlash = null; + if (effect == switchPulse) switchPulse = null; + if (effect == guardImpact) guardImpact = null; + if (effect == blockedBump) blockedBump = null; + if (effect.Kind == EffectKind.Bump) ActiveBumpImpactCount = Mathf.Max(0, ActiveBumpImpactCount - 1); + if (effect.Kind == EffectKind.Artifact) ActiveArtifactImpactCount = Mathf.Max(0, ActiveArtifactImpactCount - 1); + if (effect.Kind == EffectKind.Hurt) ActivePlayerHurtImpactCount = Mathf.Max(0, ActivePlayerHurtImpactCount - 1); + heavyEffects.Remove(effect); + if (effect.PoolLineWhenRemoved && effect.Root != null) + { + RecycleLineRoot(effect.Root, effect.Line); + } + else if (effect.Root != null) Destroy(effect.Root); + effects.RemoveAt(index); + } + + private void RecycleLineRoot(GameObject root, LineRenderer line) + { + if (line != null) line.enabled = false; + root.SetActive(false); + lineEffectPool.Push(root); + } + + private void ApplyPlayerTint() + { + if (world == null || world.Player == null || world.Player.Visual == null) return; + Color tint = Color.white; + if (blinkRemaining > 0f && GameplayConstants.Current.CombatFeedback.InvulnerabilityBlinkInterval > 0f + && Mathf.FloorToInt(blinkElapsed / GameplayConstants.Current.CombatFeedback.InvulnerabilityBlinkInterval) % 2 == 0) + { + float alpha = GameplayConstants.Current.CombatFeedback.InvulnerabilityBlinkAlpha; + tint = new Color(1f, 1f, 1f, alpha); + } + if (flashRemaining > 0f) + { + tint.r = flashColor.r; + tint.g = flashColor.g; + tint.b = flashColor.b; + } + world.Player.Visual.SetFeedbackTint(tint); + } + + private Color HitColor(CombatHitResult? hit) + { + if (hit.HasValue && hit.Value.IsArtifactHit) + return ActiveArtifactDefinition.GetArtifactPaletteColor(ActiveArtifactDefinition.GetArtifactColor(hit.Value.ArtifactEffect)); + if (world.Loadout != null && world.Loadout.CurrentArtifact != null) + return ActiveArtifactDefinition.GetArtifactPaletteColor(world.Loadout.CurrentArtifact.ArtifactColor); + if (!hit.HasValue) return new Color(1f, .18f, .12f, 1f); + if (hit.Value.IsStrongDash) return new Color(.95f, .5f, 1f, 1f); + if (hit.Value.IsDash) return new Color(.2f, .95f, 1f, 1f); + if (hit.Value.HasBackBonus) return new Color(1f, .55f, .12f, 1f); + if (hit.Value.HasPositionBonus) return new Color(.65f, 1f, .3f, 1f); + return new Color(.78f, .72f, 1f, 1f); + } + + private void RequestHitStop(float duration) + { + if (duration <= 0f) return; + if (!ownsTimeFreeze) + { + if (Time.timeScale <= 0f) return; + capturedTimeScale = Time.timeScale; + ownsTimeFreeze = true; + Time.timeScale = 0f; + } + hitStopUntil = Mathf.Max(hitStopUntil, Time.unscaledTime + duration); + } + + private void RequestCameraShake(float pixels, Vector2 direction) + { + if (pixels <= 0f || world.View == null) return; + shakePixels = pixels; + shakeDirection = direction.sqrMagnitude > 0f ? direction.normalized : Vector2.zero; + cameraShakeDuration = GameplayConstants.Current.CombatFeedback.CameraShakeDuration; + cameraShakeUntil = Time.unscaledTime + cameraShakeDuration; + directionalPunch = false; + } + + private void ClearChargeVisuals() + { + ClearEffect(ref aura); + ClearEffect(ref readyAura); + ClearEffect(ref chargeFlash); + ClearEffect(ref switchPulse); + } + + private void ClearEffect(ref SpriteEffect effect) + { + if (effect == null) return; + if (effect.Root != null) Destroy(effect.Root); + effects.Remove(effect); + effect = null; + } + + private void ClearAllEffects() + { + foreach (SpriteEffect effect in effects) + if (effect != null && effect.Root != null) Destroy(effect.Root); + effects.Clear(); + aura = readyAura = chargeFlash = switchPulse = null; + guardImpact = blockedBump = null; + } + + private Sprite[] LoadFrames(string path) + { + if (string.IsNullOrEmpty(path)) return Array.Empty(); + if (!frameCache.TryGetValue(path, out Sprite[] frames)) + { + frames = Resources.LoadAll(path); + Array.Sort(frames, (a, b) => string.CompareOrdinal(a.name, b.name)); + frameCache.Add(path, frames); + } + return frames; + } + + private Vector2 PlayerVisualGroundPosition + { + get + { + if (world == null || world.Player == null || world.Player.Visual == null) + return world != null && world.Player != null ? world.Player.Center : Vector2.zero; + Vector3 foot = world.Player.Visual.FootPosition; + return new Vector2(foot.x, foot.z); + } + } + + private Vector3 ToWorld(Vector2 point) => new(point.x, world.Settings.GroundY + .015f, point.y); + } +} diff --git a/Assets/_Project/Scripts/Defense/Comparison/Defense25DCombatFeedback.cs.meta b/Assets/_Project/Scripts/Defense/Comparison/Defense25DCombatFeedback.cs.meta new file mode 100644 index 0000000..28ab580 --- /dev/null +++ b/Assets/_Project/Scripts/Defense/Comparison/Defense25DCombatFeedback.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 6a149c72e9544596b216326c1b27fbe4 diff --git a/Assets/_Project/Scripts/Defense/Comparison/Defense25DComparison.cs b/Assets/_Project/Scripts/Defense/Comparison/Defense25DComparison.cs new file mode 100644 index 0000000..8ff5d59 --- /dev/null +++ b/Assets/_Project/Scripts/Defense/Comparison/Defense25DComparison.cs @@ -0,0 +1,971 @@ +using System.Collections.Generic; +using BumpCombat.Combat; +using BumpCombat.Constants; +using BumpCombat.Core; +using BumpCombat.Player; +using UnityEngine; +using UnityEngine.InputSystem; + +namespace BumpCombat.Defense.Comparison +{ + // An independent Physics3D experiment. No RunManager, PlayerController or 2D proxy. + public sealed partial class Defense25DComparison : MonoBehaviour + { + private const int PortalLaneCount = 2; + [SerializeField] private Defense25DComparisonSettings settings; + [SerializeField] private Defense25DView view; + private readonly List enemies = new(); + private readonly List summons = new(); + private readonly Collider[] overlap = new Collider[128]; + private readonly HashSet unique = new(); + private readonly List candidates = new(); + private readonly Dictionary nextBump = new(); + private readonly List adjustedArenaTransforms = new(); + private readonly List originalArenaPositions = new(); + private readonly List originalArenaScales = new(); + private readonly DefenseWaveLedger[] portalLedgers = new DefenseWaveLedger[PortalLaneCount]; + private readonly int[] suppliedPerLane = new int[PortalLaneCount]; + private readonly bool[] portalSupplyEnded = new bool[PortalLaneCount]; + private readonly GameObject[] portalRewards = new GameObject[PortalLaneCount]; + private readonly Vector2[][] routes = new Vector2[PortalLaneCount][]; + private readonly List startupArtifactOptions = new(); + private Defense25DCycloneVisual cycloneVisual; + private Defense25DCombatFeedback combatFeedback; + private Defense25DPlayerArt playerArt; + private float previousChargeProgress = -1f; + private MeshFilter adjustedSouthBoundary; + private Mesh originalSouthBoundaryMesh; + private Mesh adjustedSouthBoundaryMesh; + private Vector2 input; + private Vector2 aim = Vector2.right; + private Vector2 cycloneDirection; + private float nextSpawn, reviveAt, cycloneDuration, cycloneInterval; + private float cycloneDistance, cycloneRange, buffUntil; + private bool cycloneCharged, cycloneBlocked, initialized; + private int nextCycloneTick, scheduledCycloneTicks; + private int nextSupplyLane; + private int startupSelectionsRemaining, startupSelectionTotal, startupSelectionIndex; + private ArtifactColor startupArtifactColor; + private bool startupSelectionPending; + private float fallbackGauge; + private const string CycloneBuff = "defense-25d-cyclone"; + + public Defense25DComparisonSettings Settings => settings; + public Vector2 ArenaHalfExtents { get; private set; } + public Defense25DView View => view; + public Defense25DActor Player { get; private set; } + public IReadOnlyList Enemies => enemies; + public IReadOnlyList Summons => summons; + public DefenseWaveLedger Ledger { get; private set; } + public Defense25DTowerManager TowerManager { get; private set; } + public Defense25DLoadout Loadout { get; private set; } + public Defense25DArtifactExecutor ArtifactExecutor { get; private set; } + public Defense25DRelicRuntime RelicRuntime { get; private set; } + public float GameTime { get; private set; } + public float Gauge => Loadout != null ? Loadout.Gauge : fallbackGauge; + public float BaseHealth { get; private set; } + public int KillCurrency { get; private set; } + public bool IsRunning { get; private set; } + public bool RunCleared { get; private set; } + public bool RunFailed { get; private set; } + public bool Paused { get; private set; } + public bool AutoSpawn { get; set; } = true; + public bool ReadInput { get; set; } = true; + public bool ScreenRelativeMovement { get; private set; } = true; + public bool CycloneActive { get; private set; } + public float CycloneElapsed { get; private set; } + public int CycloneTicks { get; private set; } + public int CycloneDamageEvents { get; private set; } + public int BumpEvents { get; private set; } + public bool ShowBoundaries { get; set; } = true; + public bool TowersOperational => !Paused && DefensePrototypeRules.CanOperateTowers( + Player != null && Player.IsAlive, IsRunning); + public float TowerEfficiency + { + get + { + if (Player == null || TowerManager == null) return 0f; + float best = 0f; + foreach (Defense25DTower tower in TowerManager.Towers) + if (tower != null) best = Mathf.Max(best, tower.SightEfficiency); + return best; + } + } + public Defense25DEnemy CommandTarget { get; private set; } + public Defense25DCycloneVisual CycloneVisual => cycloneVisual; + public Defense25DCombatFeedback CombatFeedback => combatFeedback; + public Defense25DPlayerArt PlayerArt => playerArt; + private DefenseConstants Tuning => GameplayConstants.Current.Defense; + public Vector2[] GetRouteForLane(int lane) => routes[Mathf.Clamp(lane, 0, PortalLaneCount - 1)]; + public bool IsTowerImmuneToEnemy(DefenseTowerKind kind, Defense25DEnemy enemy) => + enemy == null || DefensePrototypeRules.IsTowerImmune(kind, Tuning.GetPortalImmuneTowerKind(enemy.Lane)); + + private void Start() + { + if (initialized || settings == null) return; + // This scene has no RunManager to resume a paused project's clock. + Time.timeScale = 1f; + Initialize(settings, view); + } + + public void Initialize(Defense25DComparisonSettings configuration, Defense25DView display = null) + { + if (initialized) return; + settings = configuration; + view = display; + initialized = true; + ArenaHalfExtents = settings.MapHalfExtents; + ReduceSouthBoundaryRenderHeight(); + Player = CreateActor(ComparisonActorKind.Player, "Player (Physics3D)", settings.PlayerSpawn); + combatFeedback = gameObject.AddComponent(); + combatFeedback.Bind(this); + playerArt = gameObject.AddComponent(); + playerArt.Bind(this); + for (int lane = 0; lane < PortalLaneCount; lane++) + { + routes[lane] = new[] + { + Tuning.GetPortalPosition(lane), + Tuning.GetWaypointPosition(lane, 0), + Tuning.GetWaypointPosition(lane, 1), + Tuning.GetBasePosition(lane), + }; + for (int i = 0; i < Tuning.PortalEnemyCount; i++) + { + int id = lane * Tuning.PortalEnemyCount + i; + Defense25DActor actor = CreateActor(ComparisonActorKind.Enemy, + $"Portal {lane + 1} Enemy {i + 1}", routes[lane][0]); + Defense25DEnemy enemy = actor.gameObject.AddComponent(); + enemy.Configure(this, actor, id, lane); + AttachEnemyStatusVisual(enemy); + enemies.Add(enemy); + } + } + CreateRuntimeRouteVisuals(); + TowerManager = gameObject.AddComponent(); + TowerManager.Configure(this); + ArtifactConstants artifactTuning = GameplayConstants.Current.Artifacts; + Loadout = new Defense25DLoadout(artifactTuning.StartingGauge, artifactTuning.MaximumGauge, + artifactTuning.MaximumOwnedArtifacts, artifactTuning.MaximumArtifactsPerColor); + Loadout.ConfigureCatalog(artifactTuning.Definitions); + ArtifactExecutor = new Defense25DArtifactExecutor(); + ArtifactExecutor.Configure(this, Loadout); + ConfigureRelicRuntime(); + ConfigureRelicEffectCallbacks(); + ConfigureRelicReplayCallbacks(); + CreatePortalRewards(); + cycloneVisual = new GameObject("Cyclone comparison").AddComponent(); + cycloneVisual.transform.SetParent(transform, false); + cycloneVisual.Configure(this); + ResetRun(); + } + + private void ReduceSouthBoundaryRenderHeight() + { + GameObject boundary = GameObject.Find("South boundary"); + if (boundary == null) return; + ExpandArenaForSummonSlots(boundary); + MeshFilter meshFilter = boundary != null ? boundary.GetComponent() : null; + if (meshFilter == null || meshFilter.sharedMesh == null) return; + originalSouthBoundaryMesh = meshFilter.sharedMesh; + adjustedSouthBoundaryMesh = Instantiate(originalSouthBoundaryMesh); + adjustedSouthBoundaryMesh.name = originalSouthBoundaryMesh.name + " (comparison render height)"; + adjustedSouthBoundary = meshFilter; + Mesh mesh = adjustedSouthBoundaryMesh; + Vector3[] vertices = mesh.vertices; + float floor = mesh.bounds.min.y; + float fraction = Mathf.Clamp01(settings.SouthBoundaryRenderHeightFraction); + for (int i = 0; i < vertices.Length; i++) + vertices[i].y = floor + (vertices[i].y - floor) * fraction; + mesh.vertices = vertices; + mesh.RecalculateNormals(); + mesh.RecalculateBounds(); + meshFilter.sharedMesh = mesh; + } + + private void ExpandArenaForSummonSlots(GameObject southBoundary) + { + BoxCollider collider = southBoundary.GetComponent(); + if (collider == null) return; + + DefenseConstants tuning = GameplayConstants.Current.Defense; + int maximumSummons = tuning.SummonUnitCount + 4; + int lastSlot = Mathf.Max(0, maximumSummons - 1); + float lastSlotOffset = -.55f - .8f * (lastSlot / 3); + float southernmostSlot = float.PositiveInfinity; + for (int pad = 0; pad < 6; pad++) + southernmostSlot = Mathf.Min(southernmostSlot, + tuning.GetBuildPadPosition(pad).y + lastSlotOffset); + + float maximumVariantRadius = Defense25DComparisonSettings.GiantSlimeBodyCoreDiameterPixels + * 1.55f / Defense25DComparisonSettings.SummonBodyPixelsPerUnit * .5f; + float requiredNorthFace = southernmostSlot - maximumVariantRadius - settings.MovementSkin; + float shiftSouth = Mathf.Min(0f, requiredNorthFace - collider.bounds.max.z); + if (shiftSouth >= 0f) return; + + float extension = -shiftSouth; + float wallThickness = collider.bounds.size.z; + AdjustArenaTransform(southBoundary.transform, + southBoundary.transform.position + Vector3.forward * shiftSouth, + southBoundary.transform.localScale); + AdjustArenaObject("North boundary", Vector3.forward * extension, 0f); + float perimeterScaleOffset = extension * 2f + wallThickness; + AdjustArenaObject("XZ ground", Vector3.zero, perimeterScaleOffset); + AdjustArenaObject("East boundary", Vector3.zero, perimeterScaleOffset); + AdjustArenaObject("West boundary", Vector3.zero, perimeterScaleOffset); + ArenaHalfExtents += Vector2.up * extension; + } + + private void AdjustArenaObject(string objectName, Vector3 positionOffset, float zScaleOffset) + { + GameObject target = GameObject.Find(objectName); + if (target == null) return; + Vector3 scale = target.transform.localScale; + scale.z += zScaleOffset; + AdjustArenaTransform(target.transform, target.transform.position + positionOffset, scale); + } + + private void AdjustArenaTransform(Transform target, Vector3 position, Vector3 scale) + { + int index = adjustedArenaTransforms.IndexOf(target); + if (index < 0) + { + adjustedArenaTransforms.Add(target); + originalArenaPositions.Add(target.position); + originalArenaScales.Add(target.localScale); + } + target.position = position; + target.localScale = scale; + } + + private void OnDestroy() + { + for (int i = 0; i < adjustedArenaTransforms.Count; i++) + { + Transform target = adjustedArenaTransforms[i]; + if (target == null) continue; + target.position = originalArenaPositions[i]; + target.localScale = originalArenaScales[i]; + } + if (adjustedSouthBoundary != null + && adjustedSouthBoundary.sharedMesh == adjustedSouthBoundaryMesh) + adjustedSouthBoundary.sharedMesh = originalSouthBoundaryMesh; + if (adjustedSouthBoundaryMesh != null) Destroy(adjustedSouthBoundaryMesh); + } + + private Defense25DActor CreateActor(ComparisonActorKind kind, string label, Vector2 position) + { + GameObject go = new(label); + go.transform.SetParent(transform, false); + go.transform.position = Defense25DActor.OnGround(position, settings.GroundY); + Defense25DActor actor = go.AddComponent(); + actor.Configure(this, kind); + return actor; + } + + public void RegisterSummon(Defense25DSummon summon) + { + if (summon != null && !summons.Contains(summon)) summons.Add(summon); + } + + public void UnregisterSummon(Defense25DSummon summon) => summons.Remove(summon); + + internal static void AttachEnemyStatusVisual(Defense25DEnemy enemy) + { + if (enemy == null) return; + Defense25DStatusVisual visual = enemy.GetComponent(); + if (visual == null) visual = enemy.gameObject.AddComponent(); + visual.Bind(enemy); + } + + private void CreatePortalRewards() + { + for (int lane = 0; lane < PortalLaneCount; lane++) + { + GameObject reward = GameObject.CreatePrimitive(PrimitiveType.Sphere); + reward.name = $"Portal {lane + 1} Construction Reward"; + reward.transform.SetParent(transform, false); + reward.transform.localScale = Vector3.one * .38f; + reward.transform.position = Defense25DActor.OnGround( + Tuning.GetPortalPosition(lane) + Vector2.up * 1.2f, settings.GroundY + .3f); + Destroy(reward.GetComponent()); + reward.GetComponent().material.color = new Color32(255, 224, 84, 255); + reward.SetActive(false); + portalRewards[lane] = reward; + } + } + + private void CreateRuntimeRouteVisuals() + { + for (int segment = 1; segment <= 4; segment++) + GameObject.Find($"Route segment {segment}")?.SetActive(false); + GameObject.Find("Portal")?.SetActive(false); + GameObject.Find("Base - arrival marker")?.SetActive(false); + GameObject.Find("South obstacle")?.SetActive(false); + + Transform root = new GameObject("Defense routes and build pads").transform; + root.SetParent(transform, false); + void Box(string label, Vector2 center, Vector3 size, Material material, float height) + { + GameObject item = GameObject.CreatePrimitive(PrimitiveType.Cube); + item.name = label; + item.transform.SetParent(root, false); + item.transform.position = Defense25DActor.OnGround(center, settings.GroundY + height); + item.transform.localScale = size; + item.GetComponent().sharedMaterial = material; + Destroy(item.GetComponent()); + } + + for (int lane = 0; lane < PortalLaneCount; lane++) + { + for (int i = 0; i < routes[lane].Length - 1; i++) + { + Vector2 start = routes[lane][i], end = routes[lane][i + 1]; + Vector2 delta = end - start; + GameObject segment = GameObject.CreatePrimitive(PrimitiveType.Cube); + segment.name = $"Portal {lane + 1} route segment {i + 1}"; + segment.transform.SetParent(root, false); + segment.transform.position = Defense25DActor.OnGround((start + end) * .5f, settings.GroundY - .005f); + segment.transform.localScale = new Vector3(.65f, .015f, delta.magnitude); + segment.transform.rotation = Quaternion.LookRotation(Defense25DActor.OnGround(delta.normalized)); + segment.GetComponent().sharedMaterial = settings.PathMaterial; + Destroy(segment.GetComponent()); + } + Box($"Portal {lane + 1} marker", routes[lane][0], new Vector3(1f, .12f, 1f), settings.PathMaterial, .06f); + } + + Box("Shared base marker", Tuning.GetBasePosition(0), new Vector3(1.25f, .4f, 1.25f), settings.BaseMaterial, .2f); + for (int pad = 0; pad < 6; pad++) + Box($"Tower build pad {pad + 1}", Tuning.GetBuildPadPosition(pad), + new Vector3(.8f, .08f, .8f), settings.BaseMaterial, .035f); + } + + public void ResetRun() + { + if (!initialized) return; + ResetPresentationRewards(); + combatFeedback?.ResetFeedback(); + playerArt?.ResetVisuals(); + previousChargeProgress = -1f; + ArtifactExecutor.Cancel(); + Loadout.ResetRun(); + RelicRuntime?.BeginRun(); + if (view != null) BeginStartupArtifactSelection(); + CancelCyclone(); + GameTime = nextSpawn = reviveAt = buffUntil = 0f; + BaseHealth = Tuning.BaseMaximumHealth; + fallbackGauge = GameplayConstants.Current.Artifacts.StartingGauge; + BumpEvents = CycloneTicks = CycloneDamageEvents = KillCurrency = 0; + input = Vector2.zero; + aim = Vector2.right; + Paused = startupSelectionPending; + IsRunning = true; + RunCleared = RunFailed = false; + CommandTarget = null; + nextBump.Clear(); + nextSupplyLane = 0; + Ledger = new DefenseWaveLedger(enemies.Count); + for (int lane = 0; lane < PortalLaneCount; lane++) + { + portalLedgers[lane] = new DefenseWaveLedger(Tuning.PortalEnemyCount); + suppliedPerLane[lane] = 0; + portalSupplyEnded[lane] = false; + portalRewards[lane]?.SetActive(false); + } + TowerManager.ResetForRun(); + Player.ResetAt(settings.PlayerSpawn); + foreach (Defense25DEnemy enemy in enemies) enemy.ResetForRun(); + foreach (Defense25DSummon summon in summons) summon.ResetForRun(); + Physics.SyncTransforms(); + } + + private void Update() + { + if (!initialized || !ReadInput || Keyboard.current == null) return; + Keyboard k = Keyboard.current; + if (k.rKey.wasPressedThisFrame) ResetRun(); + if (k.pKey.wasPressedThisFrame && !startupSelectionPending + && !HasPendingFieldRelicReward && !showingArtifactSelection) + SetPaused(!Paused); + if (k.escapeKey.wasPressedThisFrame) CancelCyclone(); + if (k.bKey.wasPressedThisFrame) ShowBoundaries = !ShowBoundaries; + if (k.cKey.wasPressedThisFrame) ScreenRelativeMovement = !ScreenRelativeMovement; + if (k.tabKey.wasPressedThisFrame) cycloneVisual.Ribbon = !cycloneVisual.Ribbon; + if (k.mKey.wasPressedThisFrame) cycloneVisual.Transparent = !cycloneVisual.Transparent; + if (k.hKey.wasPressedThisFrame) cycloneVisual.Height = cycloneVisual.Height < .4f ? .65f : .15f; + if (k.vKey.wasPressedThisFrame) cycloneVisual.DecorationSpeed = cycloneVisual.DecorationSpeed < 2f ? 4f : 1f; + if (k.nKey.wasPressedThisFrame) cycloneVisual.RadiusScale = cycloneVisual.RadiusScale < 1.1f ? 1.35f : 1f; + if (k.gKey.wasPressedThisFrame && IsRunning && !Paused) DebugFillGauge(); + if (startupSelectionPending) + { + if (k.leftArrowKey.wasPressedThisFrame || k.upArrowKey.wasPressedThisFrame) + startupSelectionIndex = (startupSelectionIndex + startupArtifactOptions.Count - 1) + % startupArtifactOptions.Count; + else if (k.rightArrowKey.wasPressedThisFrame || k.downArrowKey.wasPressedThisFrame) + startupSelectionIndex = (startupSelectionIndex + 1) % startupArtifactOptions.Count; + if (k.spaceKey.wasPressedThisFrame) ChooseStartupArtifact(startupSelectionIndex); + return; + } + if (IsTowerShowcaseSimulationActive) + { + input = Vector2.zero; + return; + } + if (!IsRunning || Paused || !Player.IsAlive) { input = Vector2.zero; return; } + Mouse mouse = Mouse.current; + bool pointerOverUI = mouse != null && IsPointerOverDefenseUI(mouse.position.ReadValue()); + if (HasPendingFieldRelicReward || showingArtifactSelection + || (pointerOverUI && mouse != null + && (mouse.leftButton.wasPressedThisFrame || mouse.leftButton.wasReleasedThisFrame))) + { + input = Vector2.zero; + return; + } + if (k.dKey.wasPressedThisFrame) Player.TryActivateGuard(); + if (k.sKey.wasPressedThisFrame && !ArtifactExecutor.IsExecutingArtifact + && !ArtifactExecutor.IsDashing && Loadout.OwnedArtifactCount > 0) + { + int current = 0; + for (int i = 0; i < Loadout.OwnedArtifacts.Count; i++) + if (ReferenceEquals(Loadout.OwnedArtifacts[i], Loadout.CurrentArtifact)) { current = i; break; } + if (Loadout.Select((current + 1) % Loadout.OwnedArtifactCount)) + { + combatFeedback?.NotifyArtifactSwitched(Loadout.CurrentArtifact); + playerArt?.Bind(this); + } + } + if (k.aKey.wasPressedThisFrame) ArtifactExecutor.OnArtifactButtonPressed(); + if (k.aKey.wasReleasedThisFrame) ArtifactExecutor.OnArtifactButtonReleased(); + if (TowersOperational) + { + if (k.digit1Key.wasPressedThisFrame || k.numpad1Key.wasPressedThisFrame) + TryBuildNearestTower(DefenseTowerKind.Direct); + else if (k.digit2Key.wasPressedThisFrame || k.numpad2Key.wasPressedThisFrame) + TryBuildNearestTower(DefenseTowerKind.Summon); + else if (k.digit3Key.wasPressedThisFrame || k.numpad3Key.wasPressedThisFrame) + TryBuildNearestTower(DefenseTowerKind.Lightning); + else if (k.digit4Key.wasPressedThisFrame || k.numpad4Key.wasPressedThisFrame) + TryBuildNearestTower(DefenseTowerKind.Cold); + else if (k.digit5Key.wasPressedThisFrame || k.numpad5Key.wasPressedThisFrame) + TryBuildNearestTower(DefenseTowerKind.Seismic); + else if (k.digit6Key.wasPressedThisFrame || k.numpad6Key.wasPressedThisFrame) + TryBuildNearestTower(DefenseTowerKind.Empowering); + else if (k.uKey.wasPressedThisFrame) + TryUpgradeNearestTower(DefenseTowerBranch.None); + else if (k.jKey.wasPressedThisFrame) + TryUpgradeNearestTower(DefenseTowerBranch.First); + else if (k.kKey.wasPressedThisFrame) + TryUpgradeNearestTower(DefenseTowerBranch.Second); + } + // D is the original guard binding; movement remains on the original arrow-key layout. + Vector2 keys = new((k.rightArrowKey.isPressed ? 1 : 0) + - (k.leftArrowKey.isPressed ? 1 : 0), + (k.wKey.isPressed || k.upArrowKey.isPressed ? 1 : 0) + - (k.downArrowKey.isPressed ? 1 : 0)); + input = view != null && ScreenRelativeMovement ? view.KeyboardToGround(keys) : Vector2.ClampMagnitude(keys, 1f); + if (view != null && mouse != null && view.TryScreenToGround(mouse.position.ReadValue(), settings.GroundY, out Vector2 point)) + { + Vector2 direction = point - Player.Position; + if (direction.sqrMagnitude > .0001f) aim = direction.normalized; + } + } + + private bool TryBuildNearestTower(DefenseTowerKind kind) + { + int nearestPad = -1; + float nearestDistance = Tuning.BuildInteractionRadius * Tuning.BuildInteractionRadius; + for (int pad = 0; pad < 6; pad++) + { + if (TowerManager.GetTowerAtPad(pad) != null) continue; + float distance = (Player.Position - Tuning.GetBuildPadPosition(pad)).sqrMagnitude; + if (distance > nearestDistance) continue; + nearestDistance = distance; + nearestPad = pad; + } + return nearestPad >= 0 && TowerManager.TryBuildTowerAtPad(nearestPad, kind); + } + + private void BeginStartupArtifactSelection() + { + startupSelectionsRemaining = Mathf.Min(3, GameplayConstants.Current.Artifacts.MaximumOwnedArtifacts); + startupSelectionTotal = startupSelectionsRemaining; + startupSelectionPending = startupSelectionsRemaining > 0; + startupSelectionIndex = 0; + if (startupSelectionPending) PopulateStartupArtifactOptions(); + } + + private void PopulateStartupArtifactOptions() + { + startupArtifactOptions.Clear(); + int selectionIndex = Mathf.Min(3 - startupSelectionsRemaining, 2); + startupArtifactColor = selectionIndex switch + { + 0 => ArtifactColor.Green, + 1 => ArtifactColor.Red, + _ => ArtifactColor.Blue, + }; + for (int i = 0; i < Loadout.Catalog.Count; i++) + { + ActiveArtifactDefinition definition = Loadout.Catalog[i]; + if (definition != null && definition.ArtifactColor == startupArtifactColor + && !Loadout.OwnsArtifact(definition.Effect)) + startupArtifactOptions.Add(definition); + } + if (startupArtifactOptions.Count == 0) startupSelectionPending = false; + } + + private bool ChooseStartupArtifact(int index) + { + if (!startupSelectionPending || index < 0 || index >= startupArtifactOptions.Count + || !Loadout.TryAddArtifact(startupArtifactOptions[index])) return false; + startupSelectionsRemaining--; + if (startupSelectionsRemaining <= 0) + { + startupSelectionPending = false; + Paused = false; + startupArtifactOptions.Clear(); + return true; + } + PopulateStartupArtifactOptions(); + startupSelectionIndex = 0; + if (!startupSelectionPending) Paused = false; + return true; + } + + private bool TryUpgradeNearestTower(DefenseTowerBranch branch) + { + Defense25DTower nearest = null; + float nearestDistance = Tuning.BuildInteractionRadius * Tuning.BuildInteractionRadius; + foreach (Defense25DTower tower in TowerManager.TowersInConstructionOrder) + { + if (tower == null) continue; + float distance = (Player.Position - tower.Position).sqrMagnitude; + if (distance > nearestDistance) continue; + nearestDistance = distance; + nearest = tower; + } + return nearest != null && TowerManager.TryUpgradeTowerAtPad(nearest.PadIndex, branch); + } + + public void SetMovementInput(Vector2 direction) => input = Vector2.ClampMagnitude(direction, 1f); + public void DebugFillGauge() + { + if (Loadout != null) Loadout.AddGauge(Loadout.MaximumGauge); + else fallbackGauge = GameplayConstants.Current.Artifacts.MaximumGauge; + } + public void SetPaused(bool value) { Paused = value; input = Vector2.zero; } + + private void FixedUpdate() + { + if (!initialized || !IsRunning || Paused) return; + float dt = Time.fixedDeltaTime; + if (IsTowerShowcaseSimulationActive) + { + AdvanceTowerShowcase(dt); + TowerManager.Step(dt); + return; + } + GameTime += dt; + RelicRuntime?.Advance(GameTime, Player.CurrentHealth); + if (buffUntil > 0f && GameTime >= buffUntil) + { + Player.RemoveModifiersFromSource(CycloneBuff); + buffUntil = 0f; + } + if (!Player.IsAlive && GameTime >= reviveAt) + { + Player.ResetAt(settings.PlayerSpawn); + Player.GrantInvulnerability(Tuning.PlayerReviveInvulnerabilitySeconds); + } + if (AutoSpawn && GameTime >= nextSpawn && Ledger.SuppliedCount < enemies.Count) + { + SpawnNextEnemy(); + nextSpawn = GameTime + Tuning.PortalSpawnInterval; + } + if (Player.IsAlive) + { + if (CycloneActive) StepCyclone(dt); + else if (Player.HasKnockback || Player.IsHurt) Player.Move(Player.ConsumeKnockback(dt)); + else + { + Vector2 velocity = input * Player.MoveSpeed; + ResolveBumps(velocity); + if (Player.IsAlive && !Player.IsHurt) + { + Vector2 moved = Player.Move(velocity * dt); + if (moved.sqrMagnitude > .0000001f) + { + AddGauge(GameplayConstants.Current.Artifacts.MovementGaugeGainPerSecond + * Mathf.Min(dt, moved.magnitude / Mathf.Max(.001f, Player.MoveSpeed))); + RelicRuntime?.AddVoluntaryMovement(moved.magnitude, + Player.Radius * 2f, Player.CurrentHealth); + } + } + } + if (input.sqrMagnitude > .001f) Player.Facing = input.normalized; + } + CommandTarget = null; + ArtifactExecutor.Step(dt); + float chargeProgress = Loadout != null && Loadout.IsCharging ? Loadout.ChargeProgress : 0f; + if (Loadout?.CurrentArtifact != null) + combatFeedback?.NotifyChargeChanged(Loadout.CurrentArtifact, chargeProgress, + chargeProgress >= 1f && previousChargeProgress < 1f); + previousChargeProgress = chargeProgress; + StepRelicHealingOrbs(); + TowerManager.Step(dt); + foreach (Defense25DEnemy enemy in enemies) enemy.Step(dt); + foreach (Defense25DSummon summon in summons) summon.Step(dt); + OfferPortalRewards(); + TryCollectPortalRewards(); + TryFinishRunClear(); + } + + public Defense25DEnemy SpawnNextEnemy() + { + if (Ledger.SuppliedCount >= enemies.Count) return null; + for (int attempt = 0; attempt < PortalLaneCount; attempt++) + { + int lane = (nextSupplyLane + attempt) % PortalLaneCount; + if (suppliedPerLane[lane] >= Tuning.PortalEnemyCount) continue; + Defense25DEnemy enemy = null; + foreach (Defense25DEnemy candidate in enemies) + { + if (candidate.Lane == lane && candidate.CanSpawn) { enemy = candidate; break; } + } + if (enemy == null || !Ledger.RegisterSupply(enemy.Id) + || !portalLedgers[lane].RegisterSupply(enemy.Id)) return null; + suppliedPerLane[lane]++; + enemy.Spawn(routes[lane][0]); + if (suppliedPerLane[lane] >= Tuning.PortalEnemyCount) + { + portalSupplyEnded[lane] = true; + portalLedgers[lane].MarkSupplyEnded(); + } + nextSupplyLane = (lane + 1) % PortalLaneCount; + if (Ledger.SuppliedCount >= enemies.Count) Ledger.MarkSupplyEnded(); + return enemy; + } + return null; + } + + public bool TryCollectRewardAtLane(int lane) + { + if (lane < 0 || lane >= PortalLaneCount || !IsRunning || Paused + || Player == null || !Player.IsAlive || portalRewards[lane] == null + || !portalLedgers[lane].IsRewardOffered + || Vector2.Distance(Player.Position, Tuning.GetPortalPosition(lane) + Vector2.up * 1.2f) + > Tuning.RewardPickupRadius + || !portalLedgers[lane].TryCollectReward()) return false; + portalRewards[lane].SetActive(false); + TowerManager.AddCurrency(Tuning.PortalRewardCurrency); + QueueFieldRelicRewardForCollectedPortal(lane); + TryFinishRunClear(); + return true; + } + + private void OfferPortalRewards() + { + for (int lane = 0; lane < PortalLaneCount; lane++) + { + if (!portalSupplyEnded[lane] || !portalLedgers[lane].TryOfferReward()) continue; + if (portalRewards[lane] == null) + { + GameObject reward = GameObject.CreatePrimitive(PrimitiveType.Sphere); + reward.name = $"Portal {lane + 1} Construction Reward"; + reward.transform.SetParent(transform, false); + reward.transform.localScale = Vector3.one * .38f; + reward.transform.position = Defense25DActor.OnGround( + Tuning.GetPortalPosition(lane) + Vector2.up * 1.2f, settings.GroundY + .3f); + Destroy(reward.GetComponent()); + reward.GetComponent().material.color = new Color32(255, 224, 84, 255); + portalRewards[lane] = reward; + } + portalRewards[lane].SetActive(true); + } + if (portalSupplyEnded[0] && portalSupplyEnded[1]) Ledger.TryOfferReward(); + } + + private void TryCollectPortalRewards() + { + for (int lane = 0; lane < PortalLaneCount; lane++) + if (portalRewards[lane] != null && portalRewards[lane].activeSelf) + TryCollectRewardAtLane(lane); + } + + private void TryFinishRunClear() + { + if (!IsRunning || RunFailed || Ledger.ResolvedCount < enemies.Count + || !portalSupplyEnded[0] || !portalSupplyEnded[1] + || !portalLedgers[0].IsRewardCollected || !portalLedgers[1].IsRewardCollected + || !CanFinishRunClearPresentation) return; + Ledger.TryOfferReward(); + Ledger.TryCollectReward(); + RunCleared = true; + EndRun(); + } + + private void AddGauge(float gain) + { + if (CycloneActive || !Player.IsAlive || !IsRunning || Paused) return; + float adjusted = Player.Evaluate(CharacterStat.ArtifactGaugeGain, gain); + if (Loadout != null) Loadout.AddGauge(adjusted); + else fallbackGauge = Mathf.Clamp(fallbackGauge + adjusted, + 0f, GameplayConstants.Current.Artifacts.MaximumGauge); + } + + // A Physics3D broadphase only. The final footprint/ellipse checks below use XZ. + public IReadOnlyList QueryEnemies(Vector2 center, Vector2 radii) + { + candidates.Clear(); + unique.Clear(); + Vector3 low = Defense25DActor.OnGround(center, settings.GroundY + .05f); + Vector3 high = low + Vector3.up * settings.ActorHeight; + int count = Physics.OverlapBoxNonAlloc((low + high) * .5f, + new Vector3(radii.x + 1f, settings.ActorHeight, radii.y + 1f), overlap, + Quaternion.identity, Defense25DComparisonSettings.ActorMask, QueryTriggerInteraction.Ignore); + for (int i = 0; i < count; i++) + { + Defense25DActor actor = overlap[i].GetComponentInParent(); + if (actor != null && actor.Kind == ComparisonActorKind.Enemy && actor.IsAlive && unique.Add(actor)) + candidates.Add(actor); + } + candidates.Sort((a, b) => (a.Position - center).sqrMagnitude.CompareTo((b.Position - center).sqrMagnitude)); + return candidates; + } + + public void ResolveBumps(Vector2 attackVelocity) + { + CombatConstants combat = GameplayConstants.Current.Combat; + if (!IsRunning || Paused || !Player.IsAlive || Player.IsHurt || Player.HasKnockback || CycloneActive + || attackVelocity.magnitude < combat.MinimumAttackSpeed) return; + QueryEnemies(Player.Center, Vector2.one * (Player.Radius + .5f)); + var bumpTargets = new List(candidates); + foreach (Defense25DActor enemy in bumpTargets) + { + if (Player.GapTo(enemy) > settings.ContactSensorMargin + || (nextBump.TryGetValue(enemy, out float next) && GameTime < next)) continue; + Vector2 toward = (enemy.Position - Player.Position).normalized; + if (Vector2.Dot(attackVelocity.normalized, toward) < combat.MinimumApproachDot) continue; + HitSide side = BumpCombatMath.ClassifySide(enemy.Facing, Player.Position - enemy.Position); + bool offset = BumpCombatMath.IsOffsetHit(side, attackVelocity, Player.Position, enemy.Position, combat.MinimumOffsetDistance); + float baseDamage = Player.CalculateDamage(combat.BaseDamage, DamageTag.Collision); + float damage = BumpCombatMath.ApplySideMultiplier(baseDamage, side, offset); + damage = RelicRuntime?.ApplyTrophyBumpDamage( + RelicRuntime.ApplyOrdinaryBumpDamage(damage, true, false), true) ?? damage; + float force = combat.KnockbackForce * (side == HitSide.Front ? combat.FrontKnockbackMultiplier : 1f); + if (!enemy.TakeDamage(damage, toward, force, + BumpCombat.Enemies.EnemyDamageSource.Player, ordinaryBump: true)) + { + combatFeedback?.NotifyBlockedBump(enemy.ClosestGroundPoint(Player.Center), + attackVelocity.normalized); + continue; + } + nextBump[enemy] = GameTime + combat.NormalHitInterval; + BumpEvents++; + AddGauge(GameplayConstants.Current.Artifacts.NormalBumpGaugeGain); + Player.Visual.ShowAttack(side == HitSide.Back); + if (side == HitSide.Back) enemy.GetComponent()?.InterruptAttackFromRearHit(); + if (side == HitSide.Front && !offset && combat.EnableFrontalBumpSelfDamage) + Player.TakeDamage(settings.Enemy.AttackDamage, -toward, GameplayConstants.Current.Enemies.NormalPlayerKnockbackDistance); + CombatEvents.RaiseValidHit(new CombatHitResult( + Player.gameObject, + enemy.gameObject, + false, + side, + damage, + toward, + force, + enemy.ClosestGroundPoint(Player.Center), + offset, + damageTag: DamageTag.Collision, + sourceId: null, + baseBumpDamage: damage)); + RelicRuntime?.NotifyHit(CreateRelicHit(enemy.GetComponent(), damage, + toward, baseDamage, true, false), Player.CurrentHealth, Player.MaxHealth); + } + } + + public bool TryUseCyclone(bool charged, Vector2 direction) + { + if (!IsRunning || Paused || !Player.IsAlive || Player.IsHurt || CycloneActive) return false; + var definition = settings.Cyclone; + float cost = charged ? definition.ChargedGaugeCost : definition.NormalGaugeCost; + if (Gauge < cost) return false; + if (Loadout != null) Loadout.AddGauge(-cost); + else fallbackGauge = Mathf.Max(0f, fallbackGauge - cost); + CycloneActive = true; + cycloneCharged = charged; + cycloneBlocked = false; + CycloneElapsed = 0f; + CycloneTicks = CycloneDamageEvents = nextCycloneTick = 0; + cycloneDuration = Mathf.Max(Time.fixedDeltaTime, charged ? definition.ChargedDuration : definition.NormalDuration); + cycloneInterval = Mathf.Max(Time.fixedDeltaTime, definition.HitInterval); + cycloneDistance = charged ? definition.ChargedMoveDistance : definition.NormalMoveDistance; + cycloneRange = charged ? definition.ChargedRange : definition.NormalRange; + cycloneDirection = direction.sqrMagnitude > .0001f ? direction.normalized : Player.Facing; + scheduledCycloneTicks = CycloneGeometry.ScheduledHitCount(cycloneDuration, cycloneInterval); + float invulnerability = charged ? definition.ChargedInvulnerabilityDuration : definition.NormalInvulnerabilityDuration; + if (invulnerability > 0f) Player.GrantInvulnerability(invulnerability); + cycloneVisual.SetVisible(true); + return true; + } + + public Vector2 CycloneCenter => Player.Position + CycloneGeometry.CenterOffset(cycloneRange, cycloneCharged, settings.PlayerVisualScale); + public Vector2 CycloneRadii => CycloneGeometry.Radii(cycloneRange, cycloneCharged); + public bool IsCycloneCharged => cycloneCharged; + + private void StepCyclone(float dt) + { + while (nextCycloneTick < scheduledCycloneTicks && CycloneElapsed + .0001f >= nextCycloneTick * cycloneInterval) + { + DealCycloneTick(nextCycloneTick == scheduledCycloneTicks - 1); + nextCycloneTick++; + CycloneTicks++; + } + if (CycloneElapsed >= cycloneDuration) + { + // Includes a tick exactly on the duration boundary, without losing it to a frame step. + while (nextCycloneTick < scheduledCycloneTicks) + { + DealCycloneTick(nextCycloneTick == scheduledCycloneTicks - 1); + nextCycloneTick++; + CycloneTicks++; + } + CycloneActive = false; + cycloneVisual.SetVisible(false); + Player.RemoveModifiersFromSource(CycloneBuff); + float increase = cycloneCharged ? settings.Cyclone.ChargedMoveSpeedIncrease : settings.Cyclone.NormalMoveSpeedIncrease; + Player.AddModifier(new StatModifier(CycloneBuff, CharacterStat.MoveSpeed, ModifierOperation.Increased, increase)); + buffUntil = GameTime + (cycloneCharged ? settings.Cyclone.ChargedBuffDuration : settings.Cyclone.NormalBuffDuration); + return; + } + float nextTime = Mathf.Min(cycloneDuration, CycloneElapsed + dt); + if (!cycloneBlocked) + { + Player.Move(cycloneDirection * (cycloneDistance * (nextTime - CycloneElapsed) / cycloneDuration), false); + cycloneBlocked = Player.MovementBlocked; + } + CycloneElapsed = nextTime; + } + + private void DealCycloneTick(bool final) + { + Vector2 center = CycloneCenter; + Vector2 radii = CycloneRadii; + QueryEnemies(center, radii); + int cap = cycloneCharged ? settings.Cyclone.ChargedMaxTargets : settings.Cyclone.NormalMaxTargets; + int hit = 0; + foreach (Defense25DActor enemy in candidates) + { + if (hit >= cap) break; + if (!CycloneGeometry.Overlaps(center, radii, enemy.ClosestPointFunction)) continue; + float damage = Player.CalculateDamage(cycloneCharged ? settings.Cyclone.ChargedDamage : settings.Cyclone.NormalDamage, + settings.Cyclone.DamageTag); + float knockback = final ? (cycloneCharged ? settings.Cyclone.ChargedKnockback : settings.Cyclone.NormalKnockback) : 0f; + if (!enemy.TakeDamage(damage, enemy.Position - center, knockback)) continue; + hit++; + CycloneDamageEvents++; + } + } + + public void CancelCyclone() + { + CycloneActive = false; + cycloneBlocked = false; + if (cycloneVisual != null) cycloneVisual.SetVisible(false); + } + + public void NotifyDeath(Defense25DActor actor) + { + if (actor == Player) + { + CancelCyclone(); + Player.RemoveModifiersFromSource(CycloneBuff); + buffUntil = 0f; + reviveAt = GameTime + Tuning.PlayerReviveDelaySeconds; + RelicRuntime?.Advance(GameTime, 0f); + foreach (Defense25DSummon summon in summons) summon.SuspendAttack(); + CommandTarget = null; + } + else if (actor.Kind == ComparisonActorKind.Enemy) + { + Defense25DEnemy enemy = actor.GetComponent(); + if (enemy != null && enemy.IsShowcaseDummy) NotifyTowerShowcaseDummyDeath(enemy); + else ResolveEnemy(enemy, DefenseEnemyResolution.Death); + } + else actor.GetComponent()?.BeginRespawn(); + } + + public void ResolveEnemy(Defense25DEnemy enemy, DefenseEnemyResolution resolution) + { + if (enemy == null || !Ledger.TryResolve(enemy.Id, resolution)) return; + if (resolution == DefenseEnemyResolution.Death) + RelicRuntime?.NotifyEnemyDeath(CreateRelicTarget(enemy), Player.CurrentHealth); + portalLedgers[Mathf.Clamp(enemy.Lane, 0, PortalLaneCount - 1)]?.TryResolve(enemy.Id, resolution); + if (resolution == DefenseEnemyResolution.Death) KillCurrency += Tuning.EnemyKillCurrency; + if (resolution == DefenseEnemyResolution.Death) TowerManager.AddCurrency(Tuning.EnemyKillCurrency); + else BaseHealth = Mathf.Max(0f, BaseHealth - Tuning.ArrivalBaseDamage); + if (resolution == DefenseEnemyResolution.Death) enemy.RetireForDeathVisual(); + else enemy.Retire(); + if (DefensePrototypeRules.IsBaseDestroyed(BaseHealth)) + { + RunFailed = true; + EndRun(); + } + } + + public void EndRun() + { + IsRunning = false; + ArtifactExecutor?.Cancel(); + RelicRuntime?.EndRun(); + CancelCyclone(); + input = Vector2.zero; + CommandTarget = null; + Player.RemoveModifiersFromSource(CycloneBuff); + TowerManager?.CancelAll(); + Player.StopBody(); + foreach (Defense25DEnemy enemy in enemies) enemy.Actor.StopBody(); + foreach (Defense25DSummon summon in summons) { summon.SuspendAttack(); summon.Actor.StopBody(); } + } + + private void OnDisable() + { + CleanupPresentationDamagePopups(); + combatFeedback?.ResetFeedback(); + if (combatFeedback != null) combatFeedback.enabled = false; + if (initialized) EndRun(); + } + + private void RestoreRuntimeFeedbackAfterEnable() + { + if (!initialized) return; + if (combatFeedback != null) + { + if (!combatFeedback.enabled) combatFeedback.enabled = true; + else if (combatFeedback.TransparentEffectMaterial == null) combatFeedback.Bind(this); + } + if (playerArt != null) + { + playerArt.ResetVisuals(); + playerArt.Bind(this); + } + } + + private void NotifyPlayerArtifactUseVisual(Defense25DArtifactUse use) + { + if (Player == null || use.Definition == null) return; + if (use.Effect == ActiveArtifactEffect.Dash) Player.Visual.ShowDash(); + else Player.Visual.ShowArtifactUse(); + combatFeedback?.NotifyArtifactReleased(use.Definition, + Player.Position, use.Charged); + } + + private void OnGUI() + { + DrawHud(Defense25DView.GetViewport(Screen.width, Screen.height)); + } + + private void DrawHud(Rect viewport) => DrawDefensePresentationGUI(viewport); + } +} diff --git a/Assets/_Project/Scripts/Defense/Comparison/Defense25DComparison.cs.meta b/Assets/_Project/Scripts/Defense/Comparison/Defense25DComparison.cs.meta new file mode 100644 index 0000000..1e67a61 --- /dev/null +++ b/Assets/_Project/Scripts/Defense/Comparison/Defense25DComparison.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 3da342f87d5c43a5905256f7b5515c7e diff --git a/Assets/_Project/Scripts/Defense/Comparison/Defense25DComparisonPresentation.cs b/Assets/_Project/Scripts/Defense/Comparison/Defense25DComparisonPresentation.cs new file mode 100644 index 0000000..1a9f80b --- /dev/null +++ b/Assets/_Project/Scripts/Defense/Comparison/Defense25DComparisonPresentation.cs @@ -0,0 +1,731 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using BumpCombat.Combat; +using System.Text.RegularExpressions; +using BumpCombat.Constants; +using BumpCombat.Defense; +using BumpCombat.Player; +using BumpCombat.Progression; +using BumpCombat.UI; +using UnityEngine; + +namespace BumpCombat.Defense.Comparison +{ + public sealed partial class Defense25DComparison + { + private static readonly Rect RunStatusRect = new(7, 5, 626, 20); + private static readonly Rect ArtifactBarRect = new(7, 28, 626, 25); + private static readonly Rect BottomControlsRect = new(7, 320, 626, 37); + private static readonly Rect FieldRelicPanelRect = new(112, 96, 416, 168); + private static readonly Rect StartupArtifactPanelRect = new(107, 88, 426, 184); + private static readonly Rect ArtifactPickerRect = new(378, 61, 250, 151); + private static readonly Rect[] PersistentDefenseUiRects = + { RunStatusRect, ArtifactBarRect, BottomControlsRect }; + private static readonly DefenseTowerKind[] PresentationTowerKinds = + { + DefenseTowerKind.Direct, DefenseTowerKind.Summon, DefenseTowerKind.Lightning, + DefenseTowerKind.Cold, DefenseTowerKind.Seismic, DefenseTowerKind.Empowering, + }; + + private DefenseTowerKind selectedBuildKind = DefenseTowerKind.Direct; + private int selectedBuildPad = -1; + private int startupArtifactPage; + private readonly Queue earnedRelicChoices = new(); + private readonly bool[] relicChoiceQueuedForLane = new bool[2]; + private bool resumeAfterRelicChoice; + private Func enterValidationShowcase; + private Action exitValidationShowcase; + private bool validationShowcaseActive; + private readonly CombatDamagePopup[] combatDamagePopups = + new CombatDamagePopup[Defense25DCombatPopupConstants.MaximumConcurrentPopups]; + private int activeCombatDamagePopupCount; + private Defense25DCombatPopupLifecycle combatPopupLifecycle; + private bool combatPopupEventsSubscribed; + private bool hasPresentationMessage; + private string presentationMessage; + private float presentationMessageUntil; + private GUIStyle compactStatusStyle; + private GUIStyle combatDamagePopupStyle; + + private struct CombatDamagePopup + { + public string Text; + public Color Color; + public Vector3 Position; + public float StartedAt; + public bool IsTowerPopup; + + public void Set(string text, Color color, Vector3 position, bool isTowerPopup) + { + Text = text; + Color = color; + Position = position; + StartedAt = Time.unscaledTime; + IsTowerPopup = isTowerPopup; + } + } + + public IReadOnlyList TowerShowcaseKinds => PresentationTowerKinds; + public int SelectedBuildPad => selectedBuildPad; + public DefenseTowerKind SelectedBuildKind => selectedBuildKind; + public IReadOnlyList PendingFieldRelicChoices => + earnedRelicChoices.Count == 0 ? Array.Empty() : earnedRelicChoices.Peek(); + public bool HasPendingFieldRelicReward => earnedRelicChoices.Count > 0; + public bool CanFinishRunClearPresentation => !HasPendingFieldRelicReward; + public bool IsValidationShowcaseActive => validationShowcaseActive; + public int ActiveTowerDamagePopupCount + { + get + { + PruneCombatDamagePopups(); + int count = 0; + for (int i = 0; i < activeCombatDamagePopupCount; i++) + if (combatDamagePopups[i].IsTowerPopup) count++; + return count; + } + } + public int ActiveCombatDamagePopupCount + { + get + { + PruneCombatDamagePopups(); + return activeCombatDamagePopupCount; + } + } + + public Rect[] GetPersistentDefenseUiHitRects() => (Rect[])PersistentDefenseUiRects.Clone(); + + public Rect GetSelectedPadDetailsRect() + { + if (selectedBuildPad < 0 || TowerManager == null) return Rect.zero; + float x = 8f; + if (view != null && view.Camera != null) + { + Vector2 pad = GameplayConstants.Current.Defense.GetBuildPadPosition(selectedBuildPad); + Vector3 projected = view.Camera.WorldToViewportPoint( + new Vector3(pad.x, settings.GroundY, pad.y)); + if (projected.x < .5f) x = 422f; + } + return new Rect(x, 119, 210, 122); + } + + public void RecordTowerDamagePopup(float actualDamage, Color color, Vector3 worldPosition) + { + if (actualDamage > 0f) + AddCombatDamagePopup($"-{DamageCalculator.FormatDamage(actualDamage)}", + color, worldPosition, isTowerPopup: true); + } + + public void NotifyPlayerDamagePopup(float actualDamage, Vector3 worldPosition) + { + if (actualDamage > 0f) + AddCombatDamagePopup($"-{DamageCalculator.FormatDamage(actualDamage)}", + Defense25DCombatPopupConstants.PlayerDamageColor, worldPosition, + isTowerPopup: false); + } + + private void HandleComparisonValidHit(CombatHitResult result) + { + if (Player == null || result.Attacker != Player.gameObject || result.Target == null) return; + Defense25DEnemy target = result.Target.GetComponentInParent(); + if (target == null || !enemies.Contains(target)) return; + + string damageText = DamageCalculator.FormatDamage(result.Damage); + string popupText = !string.IsNullOrEmpty(result.SourceId) + ? $"{GetArtifactHitDisplayName(result.SourceId)} {damageText}" + : result.HasBackBonus + ? $"BACK ×1.5 {damageText}" + : result.IsOffsetHit + ? $"OFFSET ×1.2 {damageText}" + : result.HasPositionBonus + ? $"SIDE ×1.2 {damageText}" + : damageText; + if (result.LaunchSucceeded) popupText = $"LAUNCH {popupText}"; + + Color color = TryGetArtifactPopupColor(result, out Color artifactColor) + ? artifactColor + : result.LaunchSucceeded + ? Defense25DCombatPopupConstants.LaunchColor + : result.HasBackBonus + ? Defense25DCombatPopupConstants.BackHitColor + : result.HasPositionBonus + ? Defense25DCombatPopupConstants.SideHitColor + : Color.white; + AddCombatDamagePopup(popupText, color, result.Target.transform.position, + isTowerPopup: false); + } + + private static bool TryGetArtifactPopupColor(CombatHitResult result, out Color color) + { + if (result.IsArtifactHit) + { + color = GetArtifactPopupColor(result.ArtifactEffect); + return true; + } + if (TryGetLegacyArtifactEffect(result.SourceId, out ActiveArtifactEffect effect)) + { + color = GetArtifactPopupColor(effect); + return true; + } + color = Color.white; + return false; + } + + private static Color GetArtifactPopupColor(ActiveArtifactEffect effect) + { + if (effect < ActiveArtifactEffect.Dash || effect > ActiveArtifactEffect.ChainLightning) + return Color.white; + return ActiveArtifactDefinition.GetArtifactPaletteColor( + ActiveArtifactDefinition.GetArtifactColor(effect)); + } + + private static bool TryGetLegacyArtifactEffect(string sourceId, out ActiveArtifactEffect effect) + { + if (string.Equals(sourceId, "dash", StringComparison.OrdinalIgnoreCase)) + effect = ActiveArtifactEffect.Dash; + else if (string.Equals(sourceId, "pulse", StringComparison.OrdinalIgnoreCase)) + effect = ActiveArtifactEffect.Pulse; + else if (string.Equals(sourceId, "phoenix", StringComparison.OrdinalIgnoreCase) + || string.Equals(sourceId, "scorching_ray", StringComparison.OrdinalIgnoreCase)) + effect = ActiveArtifactEffect.Phoenix; + else if (string.Equals(sourceId, "cyclone", StringComparison.OrdinalIgnoreCase)) + effect = ActiveArtifactEffect.Cyclone; + else if (string.Equals(sourceId, "thunder_crash", StringComparison.OrdinalIgnoreCase)) + effect = ActiveArtifactEffect.ThunderCrash; + else if (string.Equals(sourceId, "chain_lightning", StringComparison.OrdinalIgnoreCase) + || string.Equals(sourceId, "arc", StringComparison.OrdinalIgnoreCase)) + effect = ActiveArtifactEffect.ChainLightning; + else + { + effect = default; + return false; + } + return true; + } + + private string GetArtifactHitDisplayName(string sourceId) + { + if (Loadout != null) + foreach (ActiveArtifactDefinition definition in Loadout.Catalog) + if (string.Equals(definition.ArtifactId, sourceId, + StringComparison.OrdinalIgnoreCase)) return definition.DisplayName; + return sourceId.ToUpperInvariant(); + } + + private void AddCombatDamagePopup(string text, Color color, Vector3 worldPosition, + bool isTowerPopup) + { + if (!isActiveAndEnabled) return; + PruneCombatDamagePopups(); + if (activeCombatDamagePopupCount >= combatDamagePopups.Length) return; + combatDamagePopups[activeCombatDamagePopupCount++].Set( + text, color, worldPosition, isTowerPopup); + } + + private void PruneCombatDamagePopups() + { + float now = Time.unscaledTime; + for (int i = activeCombatDamagePopupCount - 1; i >= 0; i--) + { + if (now - combatDamagePopups[i].StartedAt + < Defense25DCombatPopupConstants.LifetimeSeconds) continue; + activeCombatDamagePopupCount--; + combatDamagePopups[i] = combatDamagePopups[activeCombatDamagePopupCount]; + combatDamagePopups[activeCombatDamagePopupCount] = default; + } + } + + private void InitializePresentationDamagePopups() + { + if (combatPopupLifecycle == null) + { + combatPopupLifecycle = GetComponent(); + if (combatPopupLifecycle == null) + combatPopupLifecycle = gameObject.AddComponent(); + } + combatPopupLifecycle.Bind(this); + SetCombatDamagePopupEventsSubscribed(true); + } + + private void CleanupPresentationDamagePopups() + { + SetCombatDamagePopupEventsSubscribed(false); + } + + private void OnEnable() + { + if (!initialized) return; + InitializePresentationDamagePopups(); + RestoreRuntimeFeedbackAfterEnable(); + } + + internal void SetCombatDamagePopupEventsSubscribed(bool subscribed) + { + if (combatPopupEventsSubscribed == subscribed) return; + if (subscribed) + CombatEvents.OnValidHit += HandleComparisonValidHit; + else + { + CombatEvents.OnValidHit -= HandleComparisonValidHit; + activeCombatDamagePopupCount = 0; + Array.Clear(combatDamagePopups, 0, combatDamagePopups.Length); + } + combatPopupEventsSubscribed = subscribed; + } + + public bool IsPointerOverDefenseUI(Vector2 screenPosition) + { + Rect viewport = Defense25DView.GetViewport(Screen.width, Screen.height); + if (viewport.width <= 0f) return false; + float scale = viewport.width / Defense25DView.Width; + Vector2 gui = new((screenPosition.x - viewport.x) / scale, + (Screen.height - screenPosition.y - viewport.y) / scale); + if (startupSelectionPending) return StartupArtifactPanelRect.Contains(gui); + if (HasPendingFieldRelicReward) return FieldRelicPanelRect.Contains(gui); + if (IsValidationShowcaseActive) return new Rect(7, 5, 626, 28).Contains(gui); + if (RunStatusRect.Contains(gui) || ArtifactBarRect.Contains(gui) + || BottomControlsRect.Contains(gui)) return true; + Rect details = GetSelectedPadDetailsRect(); + return details.width > 0f && details.Contains(gui) + || showingArtifactSelection && ArtifactPickerRect.Contains(gui); + } + + public void BindValidationShowcase(Func enter, Action exit) + { + enterValidationShowcase = enter; + exitValidationShowcase = exit; + } + + public bool EnterValidationShowcase() + { + if (validationShowcaseActive || enterValidationShowcase == null + || HasPendingFieldRelicReward || !enterValidationShowcase()) return false; + validationShowcaseActive = true; + showingArtifactSelection = false; + return true; + } + + public bool ExitValidationShowcase() + { + if (!validationShowcaseActive || exitValidationShowcase == null) return false; + exitValidationShowcase(); + validationShowcaseActive = false; + return true; + } + + public Sprite GetTowerShowcaseSprite(DefenseTowerKind kind) + { + if (!PresentationTowerKinds.Contains(kind)) return null; + Sprite[] frames = DefenseTowerArt.Load(DefenseTowerArt.BodyKey(kind, 1, "Idle")); + return frames.Length == 0 ? null : frames[0]; + } + + public bool SelectBuildKind(DefenseTowerKind kind) + { + if (!PresentationTowerKinds.Contains(kind)) return false; + selectedBuildKind = kind; + return true; + } + + public bool SelectBuildPad(int padIndex) + { + if (TowerManager == null || padIndex < 0 || padIndex >= TowerManager.Towers.Count) return false; + selectedBuildPad = padIndex; + return true; + } + + public bool BuildSelectedTower() + { + bool built = selectedBuildPad >= 0 + && TowerManager.TryBuildTowerAtPad(selectedBuildPad, selectedBuildKind); + SetPresentationMessage(built ? $"{TowerName(selectedBuildKind)} built" : "Move closer to the selected pad"); + return built; + } + + public bool UpgradeSelectedTower(DefenseTowerBranch branch) + { + bool upgraded = selectedBuildPad >= 0 + && TowerManager.TryUpgradeTowerAtPad(selectedBuildPad, branch); + SetPresentationMessage(upgraded ? "Tower upgraded" : "Upgrade unavailable at this pad"); + return upgraded; + } + + public bool SelectOwnedArtifact(int index) + { + if (Loadout == null || !Loadout.Select(index)) return false; + ArtifactExecutor?.Cancel(); + return true; + } + + public bool QueueFieldRelicRewardForCollectedPortal(int lane) + { + if (lane < 0 || lane >= relicChoiceQueuedForLane.Length || relicChoiceQueuedForLane[lane] + || Loadout == null || portalLedgers[lane] == null || !portalLedgers[lane].IsRewardCollected) + return false; + PassiveRelicId[] available = Loadout.GetAvailableRelics(); + if (available.Length == 0) return false; + for (int i = available.Length - 1; i > 0; i--) + { + int swap = UnityEngine.Random.Range(0, i + 1); + (available[i], available[swap]) = (available[swap], available[i]); + } + earnedRelicChoices.Enqueue(available.Take(Mathf.Min(3, available.Length)).ToArray()); + relicChoiceQueuedForLane[lane] = true; + if (earnedRelicChoices.Count == 1) + { + resumeAfterRelicChoice = IsRunning && !Paused && !startupSelectionPending; + SetPaused(true); + } + return true; + } + + public bool ResolveFieldRelicChoice(PassiveRelicId id) + { + if (!HasPendingFieldRelicReward || !earnedRelicChoices.Peek().Contains(id) + || !Loadout.TryAcquireRelic(id)) return false; + earnedRelicChoices.Dequeue(); + if (earnedRelicChoices.Count == 0) + { + if (resumeAfterRelicChoice && IsRunning && Player != null && Player.IsAlive) + SetPaused(false); + resumeAfterRelicChoice = false; + TryFinishRunClear(); + } + return true; + } + + public void ResetPresentationRewards() + { + InitializePresentationDamagePopups(); + activeCombatDamagePopupCount = 0; + Array.Clear(combatDamagePopups, 0, combatDamagePopups.Length); + earnedRelicChoices.Clear(); + Array.Clear(relicChoiceQueuedForLane, 0, relicChoiceQueuedForLane.Length); + resumeAfterRelicChoice = false; + if (validationShowcaseActive) ExitValidationShowcase(); + } + + public void DrawDefensePresentationGUI(Rect viewport) + { + if (!initialized || viewport.width <= 0f || viewport.height <= 0f) return; + float scale = viewport.width / Defense25DView.Width; + Matrix4x4 previous = GUI.matrix; + try + { + GUI.matrix = Matrix4x4.TRS(new Vector3(viewport.x, viewport.y, 0f), + Quaternion.identity, new Vector3(scale, scale, 1f)); + DrawRunStatus(); + DrawCombatDamagePopups(scale); + if (IsValidationShowcaseActive) + { + GUI.Box(new Rect(7, 5, 626, 28), "Six-family tower combat showcase"); + if (GUI.Button(new Rect(548, 7, 76, 24), "General run")) ExitValidationShowcase(); + return; + } + DrawArtifactBar(); + DrawTowerCatalog(); + DrawPadAndUpgradePanel(); + DrawFieldRelicReward(); + DrawStartupArtifactPicker(); + if (hasPresentationMessage && GameTime < presentationMessageUntil) + GUI.Label(new Rect(245, 317, 150, 20), presentationMessage, CenteredStyle()); + if (Paused && !startupSelectionPending && !HasPendingFieldRelicReward) + GUI.Box(new Rect(264, 159, 112, 32), "PAUSED"); + if (RunFailed || RunCleared) + GUI.Box(new Rect(224, 151, 192, 42), RunCleared ? "DEFENSE CLEARED" : "BASE DESTROYED"); + } + finally { GUI.matrix = previous; } + } + + private void DrawCombatDamagePopups(float scale) + { + PruneCombatDamagePopups(); + if (view == null || view.Camera == null) return; + Color previousColor = GUI.color; + try + { + combatDamagePopupStyle ??= new GUIStyle(GUI.skin.label) + { + font = PresentationUiStyle.GetGalmuriFont(), + fontSize = Mathf.RoundToInt(Defense25DCombatPopupConstants.FontPixelsAt1080p + * Defense25DCombatPopupConstants.ReferenceInternalHeight + / Defense25DCombatPopupConstants.ReferenceScreenHeight), + alignment = TextAnchor.MiddleCenter, + wordWrap = false, + clipping = TextClipping.Overflow, + }; + for (int i = 0; i < activeCombatDamagePopupCount; i++) + { + CombatDamagePopup popup = combatDamagePopups[i]; + Vector3 projected = view.Camera.WorldToViewportPoint(popup.Position); + if (projected.z <= 0f || projected.x < 0f || projected.x > 1f + || projected.y < 0f || projected.y > 1f) continue; + float age = Mathf.Max(0f, Time.unscaledTime - popup.StartedAt); + float x = projected.x * Defense25DView.Width; + float y = (1f - projected.y) * Defense25DView.Height + - age * Defense25DCombatPopupConstants.RisePixelsAt1080p + * Defense25DCombatPopupConstants.ReferenceInternalHeight + / Defense25DCombatPopupConstants.ReferenceScreenHeight; + Rect rect = new( + x - Defense25DCombatPopupConstants.PopupWidthInternal * 0.5f, + y - Defense25DCombatPopupConstants.PopupHeightInternal * 0.5f, + Defense25DCombatPopupConstants.PopupWidthInternal, + Defense25DCombatPopupConstants.PopupHeightInternal); + Color faded = popup.Color; + faded.a *= 1f - age / Defense25DCombatPopupConstants.LifetimeSeconds; + Color outline = Defense25DCombatPopupConstants.OutlineColor; + outline.a *= faded.a; + GUI.color = outline; + float outlineOffset = Defense25DCombatPopupConstants.OutlinePixels + / Mathf.Max(1f, scale); + GUI.Label(new Rect(rect.x - outlineOffset, rect.y, rect.width, rect.height), + popup.Text, combatDamagePopupStyle); + GUI.Label(new Rect(rect.x + outlineOffset, rect.y, rect.width, rect.height), + popup.Text, combatDamagePopupStyle); + GUI.Label(new Rect(rect.x, rect.y - outlineOffset, rect.width, rect.height), + popup.Text, combatDamagePopupStyle); + GUI.Label(new Rect(rect.x, rect.y + outlineOffset, rect.width, rect.height), + popup.Text, combatDamagePopupStyle); + GUI.color = faded; + GUI.Label(rect, popup.Text, combatDamagePopupStyle); + } + } + finally { GUI.color = previousColor; } + } + + private void DrawRunStatus() + { + GUI.Box(RunStatusRect, ""); + if (Player == null || TowerManager == null || Ledger == null) return; + compactStatusStyle ??= new GUIStyle(GUI.skin.label) { fontSize = 9 }; + GUI.Label(new Rect(14, 6, 612, 18), + $"HP {Player.CurrentHealth:0}/{Player.MaxHealth:0} Base {BaseHealth:0} " + + $"Wave {Ledger.ResolvedCount}/{enemies.Count} Build {TowerManager.ConstructionCurrency} " + + $"{GameTime:0.0}s Relics {Loadout.OwnedRelicCount}/20 " + + $"Efficiency {TowerEfficiency:P0} {(IsRunning ? "DEFENDING" : "RUN ENDED")}", compactStatusStyle); + } + + private void DrawArtifactBar() + { + GUI.Box(ArtifactBarRect, ""); + GUI.Label(new Rect(13, 29, 105, 23), $"Gauge {Gauge:0}"); + if (Loadout.CurrentArtifact == null) + { + GUI.Label(new Rect(120, 29, 220, 23), "No artifact equipped"); + } + else + { + GUI.Label(new Rect(120, 29, 225, 23), Loadout.CurrentArtifact.DisplayName); + GUI.Label(new Rect(347, 29, 113, 23), Loadout.IsCharging + ? $"Charge {Loadout.ChargeProgress:P0}" : "Ready"); + } + if (GUI.Button(new Rect(466, 29, 76, 23), "Artifacts")) showingArtifactSelection = !showingArtifactSelection; + if (HasPendingFieldRelicReward) GUI.Label(new Rect(548, 29, 77, 23), "RELIC REWARD"); + else if (GUI.Button(new Rect(548, 29, 77, 23), IsValidationShowcaseActive ? "General run" : "Showcase")) + { + if (IsValidationShowcaseActive) ExitValidationShowcase(); + else EnterValidationShowcase(); + } + if (showingArtifactSelection) DrawOwnedArtifactPicker(); + } + + private bool showingArtifactSelection; + + private void DrawOwnedArtifactPicker() + { + GUI.Box(ArtifactPickerRect, "Choose artifact"); + for (int i = 0; i < Loadout.OwnedArtifacts.Count; i++) + { + ActiveArtifactDefinition artifact = Loadout.OwnedArtifacts[i]; + if (GUI.Button(new Rect(390, 83 + i * 28, 226, 24), artifact.DisplayName) + && SelectOwnedArtifact(i)) showingArtifactSelection = false; + } + } + + private void DrawTowerCatalog() + { + GUI.Box(BottomControlsRect, ""); + for (int i = 0; i < PresentationTowerKinds.Length; i++) + { + DefenseTowerKind kind = PresentationTowerKinds[i]; + Rect button = new(12 + i * 51, 326, 49, 25); + string label = selectedBuildKind == kind ? $"*{TowerShortName(kind)}" : TowerShortName(kind); + if (GUI.Button(button, label)) + SelectBuildKind(kind); + } + } + + private void DrawPadAndUpgradePanel() + { + for (int pad = 0; pad < 6; pad++) + { + Defense25DTower tower = TowerManager.GetTowerAtPad(pad); + string label = tower == null ? $"P{pad + 1}" : $"P{pad + 1} T{tower.Tier}"; + if (GUI.Button(new Rect(322 + pad * 51, 326, 49, 25), label)) SelectBuildPad(pad); + } + + Rect panel = GetSelectedPadDetailsRect(); + if (panel.width <= 0f) return; + Defense25DTower selected = selectedBuildPad >= 0 ? TowerManager.GetTowerAtPad(selectedBuildPad) : null; + GUI.Box(panel, ""); + if (GUI.Button(new Rect(panel.xMax - 24, panel.y + 3, 20, 19), "×")) + { + selectedBuildPad = -1; + return; + } + if (selected == null) + { + int cost = BuildCost(selectedBuildKind); + GUI.Label(new Rect(panel.x + 10, panel.y + 20, panel.width - 20, 23), + $"Pad {selectedBuildPad + 1}: {TowerName(selectedBuildKind)}"); + if (GUI.Button(new Rect(panel.x + 10, panel.y + 54, 112, 27), $"Build {cost}") + && selectedBuildPad >= 0) + BuildSelectedTower(); + return; + } + + GUI.Label(new Rect(panel.x + 10, panel.y + 20, panel.width - 20, 24), + $"Pad {selectedBuildPad + 1}: {TowerName(selected.Kind)} T{selected.Tier}"); + if (selected.Tier < 3) + { + if (GUI.Button(new Rect(panel.x + 10, panel.y + 54, 180, 27), + $"Upgrade {UpgradeCost(selected.Tier)}")) + UpgradeSelectedTower(DefenseTowerBranch.None); + } + else if (selected.Tier == 3) + { + string first = DefenseTower.GetBranchName(selected.Kind, DefenseTowerBranch.First); + string second = DefenseTower.GetBranchName(selected.Kind, DefenseTowerBranch.Second); + GUI.Label(new Rect(panel.x + 10, panel.y + 45, panel.width - 20, 27), + $"I {first} II {second}", compactStatusStyle); + if (GUI.Button(new Rect(panel.x + 10, panel.y + 76, 86, 27), $"I {UpgradeCost(3)}")) + UpgradeSelectedTower(DefenseTowerBranch.First); + if (GUI.Button(new Rect(panel.x + 104, panel.y + 76, 86, 27), $"II {UpgradeCost(3)}")) + UpgradeSelectedTower(DefenseTowerBranch.Second); + } + else GUI.Label(new Rect(panel.x + 10, panel.y + 54, 190, 42), + $"{TowerName(selected.Kind)} IV\n{DefenseTower.GetBranchName(selected.Kind, selected.Branch)}"); + } + + private void DrawFieldRelicReward() + { + if (!HasPendingFieldRelicReward) return; + PassiveRelicId[] choices = earnedRelicChoices.Peek(); + GUI.Box(FieldRelicPanelRect, $"Portal field reward {Loadout.OwnedRelicCount}/20"); + for (int i = 0; i < choices.Length; i++) + { + Rect button = new(132, 126 + i * 36, 376, 30); + if (GUI.Button(button, RelicName(choices[i]))) ResolveFieldRelicChoice(choices[i]); + } + } + + private void DrawStartupArtifactPicker() + { + if (!startupSelectionPending) return; + GUI.Box(StartupArtifactPanelRect, + $"Choose {startupArtifactColor} artifact {Loadout.OwnedArtifactCount + 1}/{startupSelectionTotal}"); + int pageCount = Mathf.Max(1, Mathf.CeilToInt(startupArtifactOptions.Count / 4f)); + startupArtifactPage = Mathf.Clamp(startupArtifactPage, 0, pageCount - 1); + int firstChoice = startupArtifactPage * 4; + int lastChoice = Mathf.Min(startupArtifactOptions.Count, firstChoice + 4); + for (int i = firstChoice; i < lastChoice; i++) + { + Rect button = new(132, 119 + (i - firstChoice) * 32, 376, 28); + if (GUI.Button(button, startupArtifactOptions[i].DisplayName)) ChooseStartupArtifact(i); + } + if (startupArtifactPage > 0 && GUI.Button(new Rect(132, 250, 70, 21), "Previous")) startupArtifactPage--; + if (startupArtifactPage + 1 < pageCount && GUI.Button(new Rect(438, 250, 70, 21), "Next")) startupArtifactPage++; + } + + private void SetPresentationMessage(string message) + { + presentationMessage = message; + presentationMessageUntil = GameTime + 2f; + hasPresentationMessage = true; + } + + private int BuildCost(DefenseTowerKind kind) + { + DefenseConstants tuning = GameplayConstants.Current.Defense; + return kind switch + { + DefenseTowerKind.Direct => tuning.DirectTowerCost, + DefenseTowerKind.Summon => tuning.SummonTowerCost, + DefenseTowerKind.Lightning => tuning.LightningTowerCost, + DefenseTowerKind.Cold => tuning.ColdTowerCost, + DefenseTowerKind.Seismic => tuning.SeismicTowerCost, + DefenseTowerKind.Empowering => tuning.EmpoweringTowerCost, + _ => 0, + }; + } + + private int UpgradeCost(int tier) => tier switch + { + 1 => GameplayConstants.Current.Defense.Tier2UpgradeCost, + 2 => GameplayConstants.Current.Defense.Tier3UpgradeCost, + _ => GameplayConstants.Current.Defense.Tier4UpgradeCost, + }; + + private static string TowerName(DefenseTowerKind kind) => kind switch + { + DefenseTowerKind.Direct => "Fire", + DefenseTowerKind.Summon => "Summon", + DefenseTowerKind.Lightning => "Lightning", + DefenseTowerKind.Cold => "Cold", + DefenseTowerKind.Seismic => "Seismic", + DefenseTowerKind.Empowering => "Support", + _ => kind.ToString(), + }; + + private static string TowerShortName(DefenseTowerKind kind) => kind switch + { + DefenseTowerKind.Direct => "Fire", + DefenseTowerKind.Summon => "Summon", + DefenseTowerKind.Lightning => "Storm", + DefenseTowerKind.Cold => "Cold", + DefenseTowerKind.Seismic => "Earth", + DefenseTowerKind.Empowering => "Support", + _ => kind.ToString(), + }; + + private static string RelicName(PassiveRelicId id) => + Regex.Replace(id.ToString(), "(?<=[a-z])(?=[A-Z])", " "); + + private static GUIStyle CenteredStyle() + { + GUIStyle style = new(GUI.skin.label) { alignment = TextAnchor.MiddleCenter }; + return style; + } + } + + internal sealed class Defense25DCombatPopupLifecycle : MonoBehaviour + { + private Defense25DComparison owner; + + public void Bind(Defense25DComparison comparison) + { + if (owner == comparison) return; + if (owner != null) owner.SetCombatDamagePopupEventsSubscribed(false); + owner = comparison; + if (isActiveAndEnabled && owner != null) + owner.SetCombatDamagePopupEventsSubscribed(true); + } + + private void OnEnable() + { + if (owner != null) owner.SetCombatDamagePopupEventsSubscribed(true); + } + + private void OnDisable() + { + if (owner != null) owner.SetCombatDamagePopupEventsSubscribed(false); + } + + private void OnDestroy() + { + if (owner != null) owner.SetCombatDamagePopupEventsSubscribed(false); + owner = null; + } + } +} diff --git a/Assets/_Project/Scripts/Defense/Comparison/Defense25DComparisonPresentation.cs.meta b/Assets/_Project/Scripts/Defense/Comparison/Defense25DComparisonPresentation.cs.meta new file mode 100644 index 0000000..9cc3f61 --- /dev/null +++ b/Assets/_Project/Scripts/Defense/Comparison/Defense25DComparisonPresentation.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 9f8db4b736f6478fb580f7fe926c70c1 diff --git a/Assets/_Project/Scripts/Defense/Comparison/Defense25DComparisonRelicReplay.cs b/Assets/_Project/Scripts/Defense/Comparison/Defense25DComparisonRelicReplay.cs new file mode 100644 index 0000000..662a313 --- /dev/null +++ b/Assets/_Project/Scripts/Defense/Comparison/Defense25DComparisonRelicReplay.cs @@ -0,0 +1,13 @@ +namespace BumpCombat.Defense.Comparison +{ + public sealed partial class Defense25DComparison + { + private void ConfigureRelicReplayCallbacks() + { + relicEffects.EmitEcho = (use, origin, direction, multiplier) => + ArtifactExecutor.TryReplay(use, origin, direction, multiplier); + relicEffects.EmitRepeat = (use, origin, direction, multiplier) => + ArtifactExecutor.TryReplay(use, origin, direction, multiplier); + } + } +} diff --git a/Assets/_Project/Scripts/Defense/Comparison/Defense25DComparisonRelicReplay.cs.meta b/Assets/_Project/Scripts/Defense/Comparison/Defense25DComparisonRelicReplay.cs.meta new file mode 100644 index 0000000..aac6ca8 --- /dev/null +++ b/Assets/_Project/Scripts/Defense/Comparison/Defense25DComparisonRelicReplay.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: b7c43d5582674f77a9befa672a24bd6c diff --git a/Assets/_Project/Scripts/Defense/Comparison/Defense25DComparisonRelics.cs b/Assets/_Project/Scripts/Defense/Comparison/Defense25DComparisonRelics.cs new file mode 100644 index 0000000..1af9ecd --- /dev/null +++ b/Assets/_Project/Scripts/Defense/Comparison/Defense25DComparisonRelics.cs @@ -0,0 +1,212 @@ +using System.Collections.Generic; +using BumpCombat.Combat; +using BumpCombat.Constants; +using BumpCombat.Core; +using BumpCombat.Defense; +using BumpCombat.Enemies; +using BumpCombat.Player; +using UnityEngine; + +namespace BumpCombat.Defense.Comparison +{ + public sealed partial class Defense25DComparison + { + private sealed class RelicHealingOrb + { + public GameObject Object; + public float ExpiresAt; + } + + private readonly List relicHealingOrbs = new(); + private Defense25DRelicEffects relicEffects; + private float relicOrbLastStepTime; + + private void ConfigureRelicRuntime() + { + relicEffects = new Defense25DRelicEffects(); + RelicRuntime = new Defense25DRelicRuntime( + Loadout, GameplayConstants.Current.PassiveRelics, relicEffects); + Loadout.ArtifactUseSucceeded += NotifyRelicArtifactUse; + Loadout.ArtifactUseSucceeded += NotifyPlayerArtifactUseVisual; + ArtifactExecutor.NotifyRelicHit = NotifyRelicHit; + RelicRuntime.BeginRun(GameTime); + } + + private void ConfigureRelicEffectCallbacks() + { + Defense25DRelicEffects effects = relicEffects; + effects.QueryEnemies = QueryRelicTargets; + effects.DamageEnemy = ApplyRelicDamage; + effects.IgniteEnemy = ApplyRelicIgnite; + effects.CalculatePlayerDamage = (damage, tag) => Player.CalculateDamage(damage, tag); + effects.ApplyVulnerability = (target, duration, increase) => + FindRelicEnemy(target.Identity)?.ApplyBumpVulnerability(duration, increase) == true; + effects.ApplySlow = (target, duration, fraction) => + FindRelicEnemy(target.Identity)?.ApplySlow(duration, fraction) == true; + effects.HealPlayer = amount => Player.Heal(amount); + effects.GrantShield = (amount, duration) => Player.GrantShield(amount, duration); + effects.RestoreGauge = fraction => + { + if (!IsRunning || !Player.IsAlive || fraction <= 0f) return false; + float before = Gauge; + Loadout.AddGauge(Loadout.MaximumGauge * fraction); + return Gauge > before; + }; + effects.BoostVisibleTowers = BoostVisibleRelicTowers; + effects.AwardCurrency = AwardRelicCurrency; + effects.SpawnHealingOrb = SpawnRelicHealingOrb; + effects.SetTimedPlayerModifier = (modifier, duration) => + { + if (Player.IsAlive && duration > 0f) Player.AddModifier(modifier); + }; + effects.RemovePlayerModifier = source => Player.RemoveModifiersFromSource(source); + } + + private IReadOnlyList QueryRelicTargets(Vector3 center, float radius) + { + var results = new List(); + float radiusSquared = radius * radius; + foreach (Defense25DEnemy enemy in enemies) + { + if (enemy == null || !enemy.IsAvailable) continue; + Vector2 closest = enemy.Actor.ClosestGroundPoint(new Vector2(center.x, center.z)); + Vector2 delta = closest - new Vector2(center.x, center.z); + if (delta.sqrMagnitude <= radiusSquared) results.Add(CreateRelicTarget(enemy)); + } + return results; + } + + private bool ApplyRelicDamage(Defense25DRelicTarget target, float damage, + Vector3 direction, EnemyDamageSource source) + { + Defense25DEnemy enemy = FindRelicEnemy(target.Identity); + if (enemy == null || !enemy.IsAvailable || damage <= 0f) return false; + return enemy.Actor.TakeDamage(damage, new Vector2(direction.x, direction.z), 0f, source); + } + + private bool ApplyRelicIgnite(Defense25DRelicTarget target, float duration, + float interval, float damage, EnemyDamageSource source) + { + return FindRelicEnemy(target.Identity)?.ApplyIgnite(duration, interval, damage, source) == true; + } + + private Defense25DEnemy FindRelicEnemy(int identity) + { + foreach (Defense25DEnemy enemy in enemies) + if (enemy != null && enemy.Id == identity) return enemy; + return null; + } + + private bool BoostVisibleRelicTowers(float duration) + { + if (!TowersOperational || duration <= 0f) return false; + bool applied = false; + foreach (Defense25DTower tower in TowerManager.Towers) + { + if (tower == null || tower.Kind is DefenseTowerKind.Empowering or DefenseTowerKind.Summon + || Vector2.Distance(Player.Position, tower.Position) > Tuning.PlayerSightRadius) continue; + tower.ApplyBattleBaton(duration, + GameplayConstants.Current.PassiveRelics.BattleBatonTowerDamageIncrease); + applied = true; + } + return applied; + } + + private bool AwardRelicCurrency(int amount) + { + if (!IsRunning || Player == null || !Player.IsAlive || amount <= 0) return false; + TowerManager.AddCurrency(amount); + return true; + } + + private void SpawnRelicHealingOrb(Vector3 position, float healFraction, float lifetime) + { + if (!IsRunning || lifetime <= 0f) return; + GameObject orb = GameObject.CreatePrimitive(PrimitiveType.Sphere); + orb.name = "Pilgrim Healing Orb"; + orb.transform.SetParent(transform, true); + orb.transform.position = position + Vector3.up * .2f; + orb.transform.localScale = Vector3.one * .3f; + Collider collider = orb.GetComponent(); + if (collider != null) Destroy(collider); + Renderer renderer = orb.GetComponent(); + if (renderer != null) renderer.material.color = new Color(.35f, 1f, .55f); + relicHealingOrbs.Add(new RelicHealingOrb + { + Object = orb, + ExpiresAt = GameTime + lifetime, + }); + } + + private void StepRelicHealingOrbs() + { + if (GameTime < relicOrbLastStepTime) + { + foreach (RelicHealingOrb oldOrb in relicHealingOrbs) + if (oldOrb.Object != null) Destroy(oldOrb.Object); + relicHealingOrbs.Clear(); + RelicRuntime?.NotifyHealingOrbExpired(); + } + relicOrbLastStepTime = GameTime; + float pickupRadius = GameplayConstants.Current.Items.ExperienceOrbPickupRadius; + for (int i = relicHealingOrbs.Count - 1; i >= 0; i--) + { + RelicHealingOrb orb = relicHealingOrbs[i]; + if (orb.Object == null || GameTime >= orb.ExpiresAt) + { + if (orb.Object != null) Destroy(orb.Object); + RelicRuntime?.NotifyHealingOrbExpired(); + relicHealingOrbs.RemoveAt(i); + continue; + } + if (Player == null || !Player.IsAlive) continue; + Vector2 orbPosition = Defense25DActor.Ground(orb.Object.transform.position); + if ((Player.Position - orbPosition).sqrMagnitude > pickupRadius * pickupRadius) continue; + bool pickedUp = RelicRuntime?.NotifyHealingOrbPickedUp(Player.MaxHealth) == true; + Destroy(orb.Object); + relicHealingOrbs.RemoveAt(i); + } + } + + private void NotifyRelicArtifactUse(Defense25DArtifactUse use) + { + if (Player == null) return; + RelicRuntime?.NotifyArtifactUse(use, + Defense25DActor.OnGround(Player.Position, settings.GroundY), + Defense25DActor.OnGround(Player.Facing), Player.CurrentHealth); + } + + private void NotifyRelicHit(Defense25DRelicHit hit) => + RelicRuntime?.NotifyHit(hit, Player != null ? Player.CurrentHealth : 0f, + Player != null ? Player.MaxHealth : 0f); + + internal Defense25DRelicTarget CreateRelicTarget(Defense25DEnemy enemy) + { + Defense25DActor actor = enemy.Actor; + return new Defense25DRelicTarget(enemy.Id, + Defense25DActor.OnGround(actor.Position, settings.GroundY), actor.CurrentHealth, + actor.MaxHealth, enemy.LastDamageSource, !actor.IsAlive, + igniteRemaining: enemy.IgniteRemaining, igniteInterval: enemy.IgniteInterval, + igniteTickDamage: enemy.IgniteTickDamage); + } + + public void NotifyPlayerDamageTaken(float actualLoss) => + RelicRuntime?.NotifyPlayerDamageTaken(actualLoss, Player.CurrentHealth, + Player.MaxHealth, Defense25DActor.OnGround(Player.Position, settings.GroundY)); + + private Defense25DRelicHit CreateRelicHit(Defense25DEnemy enemy, float damage, + Vector2 direction, float baseBumpDamage, bool ordinaryBump, bool artifactHit) + { + Defense25DActor actor = enemy.Actor; + Defense25DRelicTarget target = new(enemy.Id, + Defense25DActor.OnGround(actor.Position, settings.GroundY), actor.CurrentHealth, + actor.MaxHealth, enemy.LastDamageSource, !actor.IsAlive, + igniteRemaining: enemy.IgniteRemaining, igniteInterval: enemy.IgniteInterval, + igniteTickDamage: enemy.IgniteTickDamage); + return new Defense25DRelicHit(target, + Defense25DActor.OnGround(actor.ClosestGroundPoint(Player.Center), settings.GroundY), + Defense25DActor.OnGround(direction), damage, baseBumpDamage, ordinaryBump, + artifactHit); + } + } +} diff --git a/Assets/_Project/Scripts/Defense/Comparison/Defense25DComparisonRelics.cs.meta b/Assets/_Project/Scripts/Defense/Comparison/Defense25DComparisonRelics.cs.meta new file mode 100644 index 0000000..77875ab --- /dev/null +++ b/Assets/_Project/Scripts/Defense/Comparison/Defense25DComparisonRelics.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 71a94f1e2f6e49ca9a4c44b77f51054e diff --git a/Assets/_Project/Scripts/Defense/Comparison/Defense25DComparisonShowcase.cs b/Assets/_Project/Scripts/Defense/Comparison/Defense25DComparisonShowcase.cs new file mode 100644 index 0000000..58beca4 --- /dev/null +++ b/Assets/_Project/Scripts/Defense/Comparison/Defense25DComparisonShowcase.cs @@ -0,0 +1,262 @@ +using System.Collections.Generic; +using System.Linq; +using BumpCombat.Constants; +using BumpCombat.Defense; +using UnityEngine; + +namespace BumpCombat.Defense.Comparison +{ + public sealed partial class Defense25DComparison + { + private static DefenseTowerKind[] ShowcaseKinds => DefenseTowerHitTestLayout.KindsByPad; + + private readonly Dictionary showcaseDummyKinds = new(); + private readonly Dictionary showcaseDummyPositions = new(); + private readonly Dictionary showcaseObservedDamageEvents = new(); + private readonly Dictionary showcaseHitCounts = new(); + private readonly HashSet showcaseSuspendedEnemies = new(); + private Defense25DTowerManager runTowerManager; + private Defense25DTowerManager showcaseTowerManager; + private GameObject showcaseRoot; + private Vector2 runPlayerPosition; + private float runGameTime; + private bool towerShowcaseActive; + + public bool IsTowerShowcaseSimulationActive => towerShowcaseActive; + public IReadOnlyCollection TowerShowcaseDummies => showcaseDummyKinds.Keys; + public int TowerShowcaseCurrency => showcaseTowerManager != null + ? showcaseTowerManager.ConstructionCurrency : 0; + + public IEnumerable GetTowerTargets(DefenseTowerKind sourceKind) + { + foreach (Defense25DEnemy enemy in enemies) + if (enemy != null && !enemy.IsShowcaseDummy) + yield return enemy; + + if (!towerShowcaseActive) yield break; + foreach (KeyValuePair pair in showcaseDummyKinds) + if (pair.Value == sourceKind && pair.Key != null && pair.Key.IsAvailable) + yield return pair.Key; + } + + public bool IsTowerShowcaseTarget(DefenseTowerKind sourceKind, Defense25DEnemy target) + { + if (target == null) return false; + if (!target.IsShowcaseDummy) return enemies.Contains(target); + return towerShowcaseActive + && showcaseDummyKinds.TryGetValue(target, out DefenseTowerKind assignedKind) + && assignedKind == sourceKind; + } + + public Defense25DEnemy GetTowerShowcaseDummy(DefenseTowerKind kind) + { + foreach (KeyValuePair pair in showcaseDummyKinds) + if (pair.Value == kind) return pair.Key; + return null; + } + + public int GetTowerShowcaseHitCount(DefenseTowerKind kind) => + showcaseHitCounts.TryGetValue(kind, out int count) ? count : 0; + + public float GetTowerShowcaseSupportBonus(DefenseTowerKind kind) + { + if (showcaseTowerManager == null) return 0f; + foreach (Defense25DTower tower in showcaseTowerManager.Towers) + if (tower != null && tower.Kind == kind) return tower.ShowcaseSupportBonus; + return 0f; + } + + public bool EnterTowerShowcase() + { + if (towerShowcaseActive || !IsRunning || Paused || RunCleared || RunFailed + || Player == null || !Player.IsAlive || TowerManager == null + || HasPendingFieldRelicReward || startupSelectionPending || showingArtifactSelection + || CycloneActive || ArtifactExecutor.IsExecutingArtifact || ArtifactExecutor.IsDashing) + return false; + + runTowerManager = TowerManager; + runPlayerPosition = Player.Position; + runGameTime = GameTime; + towerShowcaseActive = true; + showcaseSuspendedEnemies.Clear(); + runTowerManager.SetShowcaseSuspended(true); + foreach (Defense25DEnemy enemy in enemies) + { + if (enemy == null || !enemy.gameObject.activeSelf) continue; + showcaseSuspendedEnemies.Add(enemy); + enemy.gameObject.SetActive(false); + } + + showcaseRoot = new GameObject("Isolated tower showcase"); + showcaseRoot.transform.SetParent(transform, false); + GameObject managerObject = new("Showcase construction economy"); + managerObject.transform.SetParent(showcaseRoot.transform, false); + showcaseTowerManager = managerObject.AddComponent(); + showcaseTowerManager.Configure(this); + TowerManager = showcaseTowerManager; + showcaseHitCounts.Clear(); + + int totalBuildCost = 0; + foreach (DefenseTowerKind kind in ShowcaseKinds) totalBuildCost += ShowcaseBuildCost(kind); + showcaseTowerManager.AddCurrency(totalBuildCost); + + for (int i = 0; i < ShowcaseKinds.Length; i++) + { + Vector2 pad = Tuning.GetBuildPadPosition(i); + MovePlayerForShowcaseBuild(pad); + if (!showcaseTowerManager.TryBuildTowerAtPad(i, ShowcaseKinds[i])) + { + ExitTowerShowcase(); + return false; + } + } + RestoreShowcasePlayerPosition(); + + for (int i = 0; i < ShowcaseKinds.Length; i++) + { + DefenseTowerKind kind = ShowcaseKinds[i]; + Vector2 pad = Tuning.GetBuildPadPosition(i); + Vector2 position = DefenseTowerHitTestLayout.GetDummyPosition(kind); + GameObject actorObject = new($"Showcase Skeleton - {kind}"); + actorObject.transform.SetParent(transform, false); + actorObject.transform.position = Defense25DActor.OnGround(position, settings.GroundY); + Defense25DActor actor = actorObject.AddComponent(); + actor.Configure(this, ComparisonActorKind.Enemy); + actor.SetEnemyMaximumHealth(Mathf.Max(1f, settings.Enemy.MaxHealth)); + Defense25DEnemy dummy = actorObject.AddComponent(); + dummy.Configure(this, actor, int.MinValue + i, FindNonImmuneShowcaseLane(kind)); + AttachEnemyStatusVisual(dummy); + dummy.ConfigureShowcaseDummy(); + dummy.Spawn(position); + showcaseDummyKinds.Add(dummy, kind); + showcaseDummyPositions.Add(dummy, position); + showcaseObservedDamageEvents.Add(dummy, 0); + showcaseHitCounts[kind] = 0; + } + + Physics.SyncTransforms(); + return true; + } + + public void ExitTowerShowcase() + { + if (!towerShowcaseActive) return; + towerShowcaseActive = false; + RestoreShowcasePlayerPosition(); + if (showcaseTowerManager != null) + { + showcaseTowerManager.CancelAll(); + showcaseTowerManager.ResetForRun(); + } + TowerManager = runTowerManager; + GameTime = runGameTime; + runTowerManager?.SetShowcaseSuspended(false); + foreach (Defense25DEnemy enemy in showcaseSuspendedEnemies) + if (enemy != null) + { + enemy.gameObject.SetActive(true); + enemy.Actor.RestoreLiveCollisionAndVisuals(); + } + foreach (Defense25DEnemy dummy in showcaseDummyKinds.Keys) + if (dummy != null) + { + dummy.gameObject.SetActive(false); + Destroy(dummy.gameObject); + } + if (showcaseRoot != null) Destroy(showcaseRoot); + + showcaseDummyKinds.Clear(); + showcaseDummyPositions.Clear(); + showcaseObservedDamageEvents.Clear(); + showcaseHitCounts.Clear(); + showcaseSuspendedEnemies.Clear(); + showcaseTowerManager = null; + showcaseRoot = null; + runTowerManager = null; + Physics.SyncTransforms(); + } + + public void AdvanceTowerShowcase(float dt) + { + if (!towerShowcaseActive || dt <= 0f) return; + GameTime += dt; + foreach (Defense25DEnemy dummy in showcaseDummyKinds.Keys.ToArray()) + { + AccumulateShowcaseDamage(dummy); + } + if (showcaseTowerManager == null) return; + foreach (Defense25DTower tower in showcaseTowerManager.Towers) + if (tower != null && tower.Kind == DefenseTowerKind.Summon) + tower.StepShowcaseSummons(dt); + } + + public void NotifyTowerShowcaseDummyDeath(Defense25DEnemy enemy) + { + if (!towerShowcaseActive || enemy == null + || !showcaseDummyKinds.ContainsKey(enemy)) return; + AccumulateShowcaseDamage(enemy); + enemy.ConfigureShowcaseDummy(); + enemy.Spawn(showcaseDummyPositions[enemy]); + showcaseObservedDamageEvents[enemy] = 0; + Physics.SyncTransforms(); + } + + private void AccumulateShowcaseDamage(Defense25DEnemy enemy) + { + if (!showcaseDummyKinds.TryGetValue(enemy, out DefenseTowerKind kind)) return; + int current = enemy.Actor.DamageEvents; + int previous = showcaseObservedDamageEvents.TryGetValue(enemy, out int observed) ? observed : 0; + if (current >= previous) showcaseHitCounts[kind] += current - previous; + showcaseObservedDamageEvents[enemy] = current; + } + + private void MovePlayerForShowcaseBuild(Vector2 position) + { + Player.Body.position = Defense25DActor.OnGround(position, settings.GroundY); + Player.transform.position = Player.Body.position; + Physics.SyncTransforms(); + } + + private void RestoreShowcasePlayerPosition() + { + if (Player == null || Player.Body == null) return; + Player.Body.position = Defense25DActor.OnGround(runPlayerPosition, settings.GroundY); + Player.transform.position = Player.Body.position; + } + + private int ShowcaseBuildCost(DefenseTowerKind kind) => kind switch + { + DefenseTowerKind.Direct => Tuning.DirectTowerCost, + DefenseTowerKind.Summon => Tuning.SummonTowerCost, + DefenseTowerKind.Lightning => Tuning.LightningTowerCost, + DefenseTowerKind.Cold => Tuning.ColdTowerCost, + DefenseTowerKind.Seismic => Tuning.SeismicTowerCost, + DefenseTowerKind.Empowering => Tuning.EmpoweringTowerCost, + _ => 0, + }; + + private int FindNonImmuneShowcaseLane(DefenseTowerKind kind) + { + for (int lane = 0; lane < 2; lane++) + if (!DefensePrototypeRules.IsTowerImmune(kind, Tuning.GetPortalImmuneTowerKind(lane))) + return lane; + return 0; + } + } + + public sealed partial class Defense25DEnemy + { + public void ConfigureShowcaseDummy() => showcaseDummy = true; + } + + public sealed partial class Defense25DTower + { + public float ShowcaseSupportBonus => Tuning.SupportDamageBonusPerTier * supportAuraStrength; + + public void StepShowcaseSummons(float dt) + { + foreach (Defense25DSummon summon in ownedSummons) + if (summon != null) summon.Step(dt); + } + } +} diff --git a/Assets/_Project/Scripts/Defense/Comparison/Defense25DComparisonShowcase.cs.meta b/Assets/_Project/Scripts/Defense/Comparison/Defense25DComparisonShowcase.cs.meta new file mode 100644 index 0000000..c50f9ca --- /dev/null +++ b/Assets/_Project/Scripts/Defense/Comparison/Defense25DComparisonShowcase.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 3408d4c9c47b4639b0c6d601915cc820 diff --git a/Assets/_Project/Scripts/Defense/Comparison/Defense25DCycloneVisual.cs b/Assets/_Project/Scripts/Defense/Comparison/Defense25DCycloneVisual.cs new file mode 100644 index 0000000..a608b84 --- /dev/null +++ b/Assets/_Project/Scripts/Defense/Comparison/Defense25DCycloneVisual.cs @@ -0,0 +1,146 @@ +using System; +using BumpCombat.Combat; +using BumpCombat.Player; +using UnityEngine; + +namespace BumpCombat.Defense.Comparison +{ + public sealed class Defense25DCycloneVisual : MonoBehaviour + { + private Defense25DComparison world; + private readonly Sprite[][] back = new Sprite[2][]; + private readonly Sprite[][] front = new Sprite[2][]; + private SpriteRenderer backRenderer, frontRenderer; + private MeshRenderer ribbonRenderer; + private Mesh ribbonMesh; + private Material ribbonMaterial; + private LineRenderer boundary; + private readonly Vector3[] vertices = new Vector3[130]; + private readonly Vector2[] uv = new Vector2[130]; + private bool visible; + public bool Ribbon { get; set; } + public bool Transparent { get; set; } + public float Height { get; set; } = .15f; + public float DecorationSpeed { get; set; } = 1f; + public float RadiusScale { get; set; } = 1f; + public bool IsVisible => visible; + + public void Configure(Defense25DComparison owner) + { + world = owner; + for (int i = 0; i < 2; i++) + { + string kind = i == 0 ? "Normal" : "Charged"; + string key = $"Artifacts/ThreeColor-v1/CycloneOrbit-v3/Cyclone-{kind}-"; + back[i] = Load(key + "Back-v3"); + front[i] = Load(key + "Front-v3"); + } + backRenderer = CreateSprite("Existing Back frames"); + frontRenderer = CreateSprite("Existing Front frames"); + GameObject ribbon = new("Existing texture on XZ ribbon"); + ribbon.transform.SetParent(transform, false); + ribbonMesh = new Mesh { name = "Cyclone visual ribbon (no hitbox)" }; + ribbonMesh.MarkDynamic(); + ribbon.AddComponent().sharedMesh = ribbonMesh; + ribbonRenderer = ribbon.AddComponent(); + ribbonMaterial = new Material(world.Settings.CutoutMaterial); + ribbonRenderer.sharedMaterial = ribbonMaterial; + boundary = Defense25DSprite.CreateLine("Actual damage ellipse", world.Settings.HitBoundaryMaterial, transform, .025f); + int[] triangles = new int[64 * 6]; + for (int i = 0; i < 64; i++) + { + int v = i * 2, t = i * 6; + triangles[t] = v; triangles[t + 1] = v + 1; triangles[t + 2] = v + 2; + triangles[t + 3] = v + 1; triangles[t + 4] = v + 3; triangles[t + 5] = v + 2; + } + ribbonMesh.vertices = vertices; + ribbonMesh.triangles = triangles; + SetVisible(false); + } + + private static Sprite[] Load(string key) + { + Texture2D texture = Resources.Load(key); + if (!ActiveArtifactController.IsCycloneOrbitTextureValid(texture)) + throw new InvalidOperationException($"Existing Cyclone frame sheet missing or invalid: {key}"); + return ActiveArtifactController.CreateCycloneOrbitFrames(texture, key); + } + + private SpriteRenderer CreateSprite(string label) + { + GameObject go = new(label); + go.transform.SetParent(transform, false); + return go.AddComponent(); + } + + public void SetVisible(bool value) + { + visible = value; + if (backRenderer == null) return; + backRenderer.enabled = frontRenderer.enabled = value && !Ribbon; + ribbonRenderer.enabled = value && Ribbon; + boundary.enabled = value && world.ShowBoundaries; + } + + private void LateUpdate() + { + if (!visible || !world.CycloneActive) return; + SetVisible(true); + int kind = world.IsCycloneCharged ? 1 : 0; + int frame = Mathf.FloorToInt(world.CycloneElapsed / CycloneGeometry.FrameSeconds); + if (front[kind].Length == 0 || back[kind].Length == 0) return; + Sprite frontFrame = front[kind][frame % front[kind].Length]; + Sprite backFrame = back[kind][frame % back[kind].Length]; + Material material = Transparent ? world.Settings.TransparentMaterial : world.Settings.CutoutMaterial; + backRenderer.sharedMaterial = frontRenderer.sharedMaterial = material; + backRenderer.sprite = backFrame; + frontRenderer.sprite = frontFrame; + Vector2 center = world.CycloneCenter; + Vector2 radii = world.CycloneRadii; + Quaternion rotation = world.View != null ? world.View.transform.rotation : Quaternion.Euler(world.Settings.CameraPitch, 0f, 0f); + Vector3 position = Defense25DActor.OnGround(center, world.Settings.GroundY); + float scale = CycloneGeometry.VisualScale(world.IsCycloneCharged ? world.Settings.Cyclone.ChargedRange : world.Settings.Cyclone.NormalRange, + world.IsCycloneCharged) * RadiusScale; + foreach (SpriteRenderer renderer in new[] { backRenderer, frontRenderer }) + { + renderer.transform.rotation = rotation; + renderer.transform.localScale = Vector3.one * scale; + renderer.transform.position = position + Vector3.up * Height; + } + // Real depth, not ZTest Always or a high global sorting layer. + backRenderer.transform.position += Vector3.forward * .12f; + frontRenderer.transform.position -= Vector3.forward * .12f; + Defense25DSprite.SetEllipse(boundary, center, radii, world.Settings.GroundY + .035f); + if (!Ribbon) return; + ribbonMaterial.CopyPropertiesFromMaterial(material); + ribbonMaterial.mainTexture = frontFrame.texture; + Rect rect = frontFrame.textureRect; + Vector2 texel = frontFrame.texture.texelSize; + for (int i = 0; i <= 64; i++) + { + float t = i / 64f; + float angle = t * Mathf.PI * 2f + world.CycloneElapsed * DecorationSpeed * Mathf.PI * 2f; + Vector2 ground = center + new Vector2(Mathf.Cos(angle) * radii.x, Mathf.Sin(angle) * radii.y) * RadiusScale; + float height = world.Settings.GroundY + Height + .075f * Mathf.Sin(angle * 3f); + vertices[2 * i] = Defense25DActor.OnGround(ground, height - .08f); + vertices[2 * i + 1] = Defense25DActor.OnGround(ground, height + .08f); + uv[2 * i] = new Vector2((rect.x + rect.width * t) * texel.x, rect.y * texel.y); + uv[2 * i + 1] = new Vector2((rect.x + rect.width * t) * texel.x, rect.yMax * texel.y); + } + ribbonMesh.vertices = vertices; + ribbonMesh.uv = uv; + ribbonMesh.RecalculateBounds(); + } + + private void OnDisable() => SetVisible(false); + private void OnDestroy() + { + foreach (Sprite[] frames in back) + if (frames != null) foreach (Sprite frame in frames) Destroy(frame); + foreach (Sprite[] frames in front) + if (frames != null) foreach (Sprite frame in frames) Destroy(frame); + if (ribbonMesh != null) Destroy(ribbonMesh); + if (ribbonMaterial != null) Destroy(ribbonMaterial); + } + } +} diff --git a/Assets/_Project/Scripts/Defense/Comparison/Defense25DCycloneVisual.cs.meta b/Assets/_Project/Scripts/Defense/Comparison/Defense25DCycloneVisual.cs.meta new file mode 100644 index 0000000..9ef35d6 --- /dev/null +++ b/Assets/_Project/Scripts/Defense/Comparison/Defense25DCycloneVisual.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 01d30ec45bfe4e77a9ff3fca2a4d592a diff --git a/Assets/_Project/Scripts/Defense/Comparison/Defense25DEnemy.cs b/Assets/_Project/Scripts/Defense/Comparison/Defense25DEnemy.cs new file mode 100644 index 0000000..8f9b3bd --- /dev/null +++ b/Assets/_Project/Scripts/Defense/Comparison/Defense25DEnemy.cs @@ -0,0 +1,310 @@ +using BumpCombat.Constants; +using BumpCombat.Combat; +using BumpCombat.Enemies; +using UnityEngine; + +namespace BumpCombat.Defense.Comparison +{ + public sealed partial class Defense25DEnemy : MonoBehaviour + { + private Defense25DComparison world; + private EnemyAttackPattern pattern; + private Defense25DActor target; + private Vector2 attackDirection; + private float attackStart = -1f, progressUntil; + private float deathVisualRemovalAt = -1f; + private float slowUntil, slowFraction, stunUntil; + private int stunVisualRevision; + private float igniteRemaining, igniteInterval, igniteNextTick, igniteDamage; + private float shockRemaining, shockIncrease, vulnerabilityRemaining, vulnerabilityIncrease; + private bool dealtDamage, supplied, retired; + private bool showcaseDummy; + public Defense25DActor Actor { get; private set; } + public int Id { get; private set; } + public int Lane { get; private set; } + public bool CanSpawn => !supplied; + public int WaypointIndex { get; private set; } + public bool IsAvailable => isActiveAndEnabled && supplied && !retired && Actor.IsAlive; + public bool IsShowcaseDummy => showcaseDummy; + public bool IsAttacking => attackStart >= 0f; + public bool IsStunned => world != null && world.GameTime < stunUntil; + public int StunVisualRevision => stunVisualRevision; + public bool IsIgnited => igniteRemaining > 0f; + public bool IsShocked => shockRemaining > 0f; + public EnemyDamageSource LastDamageSource { get; private set; } + public float IgniteRemaining => igniteRemaining; + public float IgniteInterval => igniteInterval; + public float IgniteTickDamage => igniteDamage; + + public void Configure(Defense25DComparison owner, Defense25DActor actor, int id, int lane = 0) + { + showcaseDummy = false; + world = owner; Actor = actor; Id = id; Lane = lane; + pattern = world.Settings.Enemy.GetAttackPattern(0); + ConfigureSkeletonCombat(world.Settings.Enemy, world.Settings.EnemyVisualScale, + world.Settings.EnemyAttackClip); + } + + public void ResetForRun() + { + showcaseDummy = false; + supplied = retired = false; + attackStart = -1f; + deathVisualRemovalAt = -1f; + progressUntil = 0f; + slowUntil = slowFraction = stunUntil = 0f; + ResetArtifactEffects(); + LastDamageSource = EnemyDamageSource.None; + WaypointIndex = 1; + target = null; + skeletonAttackAnimationStarted = false; + SetSkeletonSlashVisible(false); + gameObject.SetActive(false); + } + + public void Spawn(Vector2 position) + { + supplied = true; + retired = false; + attackStart = -1f; + deathVisualRemovalAt = -1f; + slowUntil = slowFraction = stunUntil = 0f; + ResetArtifactEffects(); + LastDamageSource = EnemyDamageSource.None; + WaypointIndex = 1; + Actor.ResetAt(position); + } + + public void Retire() + { + retired = true; + deathVisualRemovalAt = -1f; + attackStart = -1f; + target = null; + Actor.StopBody(); + gameObject.SetActive(false); + } + + public void RetireForDeathVisual() + { + retired = true; + attackStart = -1f; + target = null; + SetSkeletonSlashVisible(false); + Actor.StopBody(); + float deathDuration = GameplayConstants.Current.Enemies.DefaultDeathDuration; + float padding = GameplayConstants.Current.Enemies.DeathRemovalPadding; + deathVisualRemovalAt = world.GameTime + deathDuration + padding; + } + + private void LateUpdate() + { + if (deathVisualRemovalAt >= 0f && world.GameTime >= deathVisualRemovalAt) + { + deathVisualRemovalAt = -1f; + gameObject.SetActive(false); + } + } + + public bool InterruptAttackFromRearHit() + { + if (!IsRearInterruptible) return false; + EndAttack(); + CombatEvents.RaiseAttackCancelled(gameObject, false); + CombatEvents.RaiseRearAttackCancelled(gameObject); + return true; + } + + private bool IsRearInterruptible + { + get + { + if (!IsAttacking) return false; + float elapsed = world.GameTime - attackStart; + return elapsed < pattern.WarningDuration + || elapsed < pattern.WarningDuration + pattern.ActiveDuration; + } + } + + private void EndAttack() + { + attackStart = -1f; + target = null; + Actor.Visual.SetAttack(false); + progressUntil = world.GameTime + GameplayConstants.Current.Defense.PostAttackProgressSeconds; + } + + public void Step(float dt) + { + if (!IsAvailable) return; + StepArtifactEffects(dt); + if (showcaseDummy) { Actor.StopBody(); return; } + if (skeletonDefinition != null && StepSkeletonCombat(dt)) return; + if (IsStunned) { Actor.StopBody(); return; } + if (Actor.HasKnockback) { Actor.Move(Actor.ConsumeKnockback(dt)); return; } + if (IsAttacking) + { + if (target == null || !target.IsAlive || (target.IsMinion && !world.TowersOperational)) + { EndAttack(); return; } + float elapsed = world.GameTime - attackStart; + float activeElapsed = elapsed - pattern.WarningDuration; + Actor.Visual.SetWarning(elapsed < pattern.WarningDuration); + if (pattern.IsContactWindowActive(activeElapsed, pattern.ActiveDuration)) + { + Actor.Move(attackDirection * (GameplayConstants.Current.Enemies.BodyContactSpeed * dt), false); + if (!dealtDamage && Actor.GapTo(target) <= world.Settings.ContactSensorMargin) + { + dealtDamage = target.TakeDamage(Actor.CalculateAttackDamage(world.Settings.Enemy.AttackDamage * pattern.DamageMultiplier), + attackDirection, target.IsMinion ? .3f : GameplayConstants.Current.Enemies.NormalPlayerKnockbackDistance); + } + } + if (elapsed >= pattern.WarningDuration + pattern.ActiveDuration + pattern.RecoveryDuration) EndAttack(); + return; + } + if (world.GameTime >= progressUntil) + { + target = FindTarget(); + if (target != null) + { + attackStart = world.GameTime; + dealtDamage = false; + attackDirection = (target.Position - Actor.Position).normalized; + Actor.Facing = attackDirection; + Actor.Visual.SetAttack(true); + return; + } + } + Vector2[] route = world.GetRouteForLane(Lane); + Vector2 delta = route[WaypointIndex] - Actor.Position; + if (delta.magnitude <= .2f) + { + if (WaypointIndex >= route.Length - 1) { world.ResolveEnemy(this, DefenseEnemyResolution.Arrival); return; } + WaypointIndex++; + delta = route[WaypointIndex] - Actor.Position; + } + Actor.Facing = delta.normalized; + Vector2 separation = Vector2.zero; + foreach (Defense25DEnemy other in world.Enemies) + { + if (other == this || !other.IsAvailable) continue; + Vector2 away = Actor.Position - other.Actor.Position; + float length = away.magnitude; + if (length > .001f && length < world.Settings.Enemy.SeparationRadius) + separation += away / length * (1f - length / world.Settings.Enemy.SeparationRadius); + } + Vector2 direction = (delta.normalized + separation * world.Settings.Enemy.SeparationStrength).normalized; + float slow = world.GameTime < slowUntil ? slowFraction : 0f; + Actor.Move(direction * Mathf.Min(delta.magnitude, + Actor.MoveSpeed * (1f - slow) * GameplayConstants.Current.Defense.RouteSpeedMultiplier * dt)); + } + + public bool ApplyIgnite(float duration, float interval, float tickDamage, + EnemyDamageSource source = EnemyDamageSource.Player) + { + if (!IsAvailable || duration <= 0f || interval <= 0f || tickDamage <= 0f) return false; + bool wasInactive = igniteRemaining <= 0f; + igniteRemaining = Mathf.Max(igniteRemaining, duration); + igniteDamage = Mathf.Max(igniteDamage, tickDamage); + if (wasInactive) igniteInterval = interval; + if (wasInactive) igniteNextTick = interval; + igniteSource = source; + return true; + } + + public bool ApplyShock(float duration, float damageIncrease) + { + if (!IsAvailable || duration <= 0f || damageIncrease <= 0f) return false; + shockRemaining = Mathf.Max(shockRemaining, duration); + shockIncrease = Mathf.Max(shockIncrease, damageIncrease); + return true; + } + + public bool ApplyBumpVulnerability(float duration, float damageIncrease) + { + if (!IsAvailable || duration <= 0f || damageIncrease <= 0f) return false; + vulnerabilityRemaining = Mathf.Max(vulnerabilityRemaining, duration); + vulnerabilityIncrease = Mathf.Max(vulnerabilityIncrease, damageIncrease); + return true; + } + + public float ApplyDamageTakenEffects(float damage, bool ordinaryBump) => + DamageCalculator.ApplyDamageTakenEffects(damage, shockIncrease, + shockRemaining > 0f, vulnerabilityIncrease, + ordinaryBump && vulnerabilityRemaining > 0f); + + public void SetLastDamageSource(EnemyDamageSource source) => LastDamageSource = source; + + private EnemyDamageSource igniteSource; + + private void StepArtifactEffects(float dt) + { + if (dt <= 0f) return; + if (igniteRemaining > 0f) + { + float before = igniteRemaining; + igniteRemaining = Mathf.Max(0f, before - dt); + while (igniteNextTick <= dt + .0001f && igniteNextTick <= before + .0001f) + { + Actor.TakeDamage(igniteDamage, Vector2.zero, 0f, igniteSource); + if (!Actor.IsAlive) break; + igniteNextTick += igniteInterval; + } + igniteNextTick -= dt; + if (igniteRemaining <= 0f) + igniteNextTick = igniteInterval = igniteDamage = 0f; + } + shockRemaining = Mathf.Max(0f, shockRemaining - dt); + if (shockRemaining <= 0f) shockIncrease = 0f; + vulnerabilityRemaining = Mathf.Max(0f, vulnerabilityRemaining - dt); + if (vulnerabilityRemaining <= 0f) vulnerabilityIncrease = 0f; + } + + private void ResetArtifactEffects() + { + igniteRemaining = igniteInterval = igniteNextTick = igniteDamage = 0f; + shockRemaining = shockIncrease = vulnerabilityRemaining = vulnerabilityIncrease = 0f; + igniteSource = EnemyDamageSource.None; + } + + public bool ApplySlow(float duration, float fraction) + { + if (!IsAvailable || IsStunned || duration <= 0f || fraction <= 0f) return false; + slowUntil = Mathf.Max(slowUntil, world.GameTime + duration); + slowFraction = Mathf.Max(slowFraction, Mathf.Clamp01(fraction)); + return true; + } + + public bool ApplyStun(float duration) + { + if (!IsAvailable || duration <= 0f) return false; + stunVisualRevision++; + stunUntil = Mathf.Max(stunUntil, world.GameTime + duration); + if (IsAttacking) + { + attackStart = -1f; + target = null; + Actor.Visual.SetAttack(false); + progressUntil = world.GameTime + GameplayConstants.Current.Defense.PostAttackProgressSeconds; + } + Actor.StopBody(); + return true; + } + + private Defense25DActor FindTarget() + { + Defense25DActor best = null; + float distance = pattern.AttackRange; + void Consider(Defense25DActor actor) + { + if (!actor.IsAlive) return; + float candidate = Vector2.Distance(Actor.Position, actor.Position); + if (candidate > distance) return; + distance = candidate; best = actor; + } + Consider(world.Player); + if (world.TowersOperational) + foreach (Defense25DSummon summon in world.Summons) Consider(summon.Actor); + return best; + } + } +} diff --git a/Assets/_Project/Scripts/Defense/Comparison/Defense25DEnemy.cs.meta b/Assets/_Project/Scripts/Defense/Comparison/Defense25DEnemy.cs.meta new file mode 100644 index 0000000..878d843 --- /dev/null +++ b/Assets/_Project/Scripts/Defense/Comparison/Defense25DEnemy.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: fb1f1b5a3fcb417788dec00833f6b32a diff --git a/Assets/_Project/Scripts/Defense/Comparison/Defense25DLoadout.cs b/Assets/_Project/Scripts/Defense/Comparison/Defense25DLoadout.cs new file mode 100644 index 0000000..28ae968 --- /dev/null +++ b/Assets/_Project/Scripts/Defense/Comparison/Defense25DLoadout.cs @@ -0,0 +1,192 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using BumpCombat.Constants; +using BumpCombat.Player; +using BumpCombat.Progression; +using UnityEngine; + +namespace BumpCombat.Defense.Comparison +{ + public readonly struct Defense25DArtifactUse + { + public Defense25DArtifactUse(ActiveArtifactDefinition definition, bool charged) + { + Definition = definition; + Charged = charged; + } + + public ActiveArtifactDefinition Definition { get; } + public bool Charged { get; } + public ActiveArtifactEffect Effect => Definition.Effect; + public float Damage => Charged ? Definition.ChargedDamage : Definition.NormalDamage; + public float Range => Charged ? Definition.ChargedRange : Definition.NormalRange; + public float Width => Charged ? Definition.ChargedWidth : Definition.NormalWidth; + public float Duration => Charged ? Definition.ChargedDuration : Definition.NormalDuration; + public float Knockback => Charged ? Definition.ChargedKnockback : Definition.NormalKnockback; + public int MaxTargets => Charged ? Definition.ChargedMaxTargets : Definition.NormalMaxTargets; + public float GaugeCost => Charged ? Definition.ChargedGaugeCost : Definition.NormalGaugeCost; + } + + /// + /// Owns the comparison run's artifact/relic state. The world supplies effect execution + /// callbacks so this state stays independent of the original Physics2D singletons. + /// + public sealed class Defense25DLoadout + { + private readonly List catalog = new(); + private readonly List ownedArtifacts = new(); + private readonly HashSet ownedRelics = new(); + private readonly float startingGauge; + private readonly float maximumGauge; + private readonly int maximumArtifacts; + private readonly int maximumPerColor; + private int selectedIndex; + private float chargeElapsed; + private ActiveArtifactDefinition chargingArtifact; + + public Defense25DLoadout(float startingGauge, float maximumGauge, int maximumArtifacts, int maximumPerColor) + { + this.startingGauge = Mathf.Clamp(startingGauge, 0f, Mathf.Max(0f, maximumGauge)); + this.maximumGauge = Mathf.Max(0f, maximumGauge); + this.maximumArtifacts = Mathf.Max(0, maximumArtifacts); + this.maximumPerColor = Mathf.Max(0, maximumPerColor); + Gauge = this.startingGauge; + } + + public Func ExecuteArtifact { get; set; } + public Action DispatchRelicTrigger { get; set; } + public event Action ArtifactUseSucceeded; + public bool ChargeUnlocked { get; set; } = true; + public float Gauge { get; private set; } + public float MaximumGauge => maximumGauge; + public float ChargeProgress => chargingArtifact == null || chargingArtifact.ChargeDuration <= 0f + ? 0f : Mathf.Clamp01(chargeElapsed / chargingArtifact.ChargeDuration); + public bool IsCharging => chargingArtifact != null; + public int OwnedArtifactCount => ownedArtifacts.Count; + public int OwnedRelicCount => ownedRelics.Count; + public IReadOnlyList Catalog => catalog; + public IReadOnlyList OwnedArtifacts => ownedArtifacts; + public IReadOnlyCollection OwnedRelics => ownedRelics; + public ActiveArtifactDefinition CurrentArtifact => ownedArtifacts.Count == 0 + ? null : ownedArtifacts[Mathf.Clamp(selectedIndex, 0, ownedArtifacts.Count - 1)]; + + public void ConfigureCatalog(IEnumerable definitions) + { + catalog.Clear(); + if (definitions == null) return; + foreach (ActiveArtifactDefinition definition in definitions) + { + if (definition != null && !catalog.Any(item => item.Effect == definition.Effect)) + catalog.Add(definition); + } + } + + public ActiveArtifactDefinition FindArtifact(ActiveArtifactEffect effect) => + catalog.FirstOrDefault(definition => definition.Effect == effect); + + public bool TryAddArtifact(ActiveArtifactDefinition definition) + { + if (definition == null || ownedArtifacts.Count >= maximumArtifacts + || ownedArtifacts.Any(item => item.Effect == definition.Effect) + || ownedArtifacts.Count(item => item.ArtifactColor == definition.ArtifactColor) >= maximumPerColor) + return false; + ownedArtifacts.Add(definition); + if (ownedArtifacts.Count == 1) selectedIndex = 0; + return true; + } + + public bool OwnsArtifact(ActiveArtifactEffect effect) => + ownedArtifacts.Any(definition => definition.Effect == effect); + + public bool Select(int index) + { + if (index < 0 || index >= ownedArtifacts.Count) return false; + if (selectedIndex != index) CancelCharge(); + selectedIndex = index; + return true; + } + + public void AddGauge(float amount) => Gauge = Mathf.Clamp(Gauge + amount, 0f, maximumGauge); + + public bool BeginCharge() + { + ActiveArtifactDefinition definition = CurrentArtifact; + if (IsCharging || definition == null || definition.ChargeDuration <= 0f + || !ChargeUnlocked || Gauge < definition.NormalGaugeCost) + return false; + chargingArtifact = definition; + chargeElapsed = 0f; + return true; + } + + public void AdvanceCharge(float deltaTime) + { + if (IsCharging) chargeElapsed = Mathf.Min(chargeElapsed + Mathf.Max(0f, deltaTime), chargingArtifact.ChargeDuration); + } + + public bool ReleaseCharge() + { + ActiveArtifactDefinition definition = chargingArtifact; + if (definition == null) return false; + bool charged = ReferenceEquals(definition, CurrentArtifact) && ChargeUnlocked + && ChargeProgress >= 1f && Gauge >= definition.ChargedGaugeCost; + CancelCharge(); + return TryUse(charged); + } + + public void CancelCharge() + { + chargingArtifact = null; + chargeElapsed = 0f; + } + + public bool TryUse(bool charged) + { + ActiveArtifactDefinition definition = CurrentArtifact; + if (definition == null || IsCharging || (charged && !ChargeUnlocked)) return false; + Defense25DArtifactUse use = new(definition, charged); + if (Gauge < use.GaugeCost || ExecuteArtifact == null || !ExecuteArtifact(use)) return false; + AddGauge(-use.GaugeCost); + ArtifactUseSucceeded?.Invoke(use); + return true; + } + + public PassiveRelicId[] GetAvailableRelics() + { + return PassiveRelicRules.GetAvailableRelics(ownedRelics) + .Where(CanOfferRelic).ToArray(); + } + + public bool TryAcquireRelic(PassiveRelicId id) + { + if (!GetAvailableRelics().Contains(id)) return false; + return ownedRelics.Add(id); + } + + public bool OwnsRelic(PassiveRelicId id) => ownedRelics.Contains(id); + + public void NotifyRelicTrigger(PassiveRelicId id) + { + if (ownedRelics.Contains(id)) DispatchRelicTrigger?.Invoke(id); + } + + public void ResetRun() + { + CancelCharge(); + ownedArtifacts.Clear(); + ownedRelics.Clear(); + selectedIndex = 0; + Gauge = startingGauge; + } + + private bool CanOfferRelic(PassiveRelicId id) + { + if (id == PassiveRelicId.EmberCarrier) + return ownedRelics.Contains(PassiveRelicId.EmberWick) || OwnsArtifact(ActiveArtifactEffect.Phoenix); + if (id == PassiveRelicId.ResonantCrystal) + return ownedArtifacts.Any(definition => definition.NormalDamage > 0f || definition.ChargedDamage > 0f); + return true; + } + } +} diff --git a/Assets/_Project/Scripts/Defense/Comparison/Defense25DLoadout.cs.meta b/Assets/_Project/Scripts/Defense/Comparison/Defense25DLoadout.cs.meta new file mode 100644 index 0000000..b2c7a21 --- /dev/null +++ b/Assets/_Project/Scripts/Defense/Comparison/Defense25DLoadout.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 1060f84efa70459e92eff3b57938c07a diff --git a/Assets/_Project/Scripts/Defense/Comparison/Defense25DPlayerArt.cs b/Assets/_Project/Scripts/Defense/Comparison/Defense25DPlayerArt.cs new file mode 100644 index 0000000..16893b3 --- /dev/null +++ b/Assets/_Project/Scripts/Defense/Comparison/Defense25DPlayerArt.cs @@ -0,0 +1,173 @@ +using System.Collections.Generic; +using BumpCombat.Constants; +using BumpCombat.Player; +using UnityEngine; + +namespace BumpCombat.Defense.Comparison +{ + [DefaultExecutionOrder(1001)] + public sealed class Defense25DPlayerArt : MonoBehaviour + { + private readonly Dictionary equipmentFrames = new(); + private readonly Dictionary equipmentOriginals = new(); + private readonly Dictionary guardFrames = new(); + private Defense25DComparison world; + private Defense25DActor player; + private SpriteRenderer playerRenderer; + private SpriteRenderer guardOutline; + + public void Bind(Defense25DComparison comparison) + { + if (world != comparison) ResetVisuals(); + world = comparison; + player = world != null ? world.Player : null; + playerRenderer = player != null && player.Visual != null + ? player.Visual.Renderer : null; + if (world != null && !world.isActiveAndEnabled) + { + ResetVisuals(); + return; + } + RefreshEquipmentSprite(); + RefreshGuardOutline(); + } + + public void ResetVisuals() + { + RestoreEquipmentSprite(); + if (guardOutline != null) guardOutline.enabled = false; + } + + private void LateUpdate() + { + if (world == null || !world.isActiveAndEnabled) + { + ResetVisuals(); + return; + } + if (player == null) player = world.Player; + if (playerRenderer == null && player != null && player.Visual != null) + playerRenderer = player.Visual.Renderer; + RefreshEquipmentSprite(); + RefreshGuardOutline(); + } + + private void RefreshEquipmentSprite() + { + if (playerRenderer == null || playerRenderer.sprite == null) return; + Sprite current = playerRenderer.sprite; + Sprite original = ResolveOriginalSprite(current); + ActiveArtifactDefinition selected = world != null ? world.Loadout?.CurrentArtifact : null; + if (selected == null) + { + playerRenderer.sprite = original; + return; + } + + string baseTextureName = StripArtifactPaletteSuffix(original.texture.name); + string path = "Artifacts/ThreeColor-v1/Player/" + baseTextureName + + "-" + selected.ArtifactColor + "-v1"; + if (!equipmentFrames.TryGetValue(path, out Sprite[] frames)) + { + frames = Resources.LoadAll(path); + equipmentFrames[path] = frames; + } + foreach (Sprite frame in frames) + { + if (frame.rect != original.rect) continue; + equipmentOriginals[frame] = original; + playerRenderer.sprite = frame; + return; + } + playerRenderer.sprite = original; + } + + private void RefreshGuardOutline() + { + bool visible = isActiveAndEnabled && player != null && player.IsAlive + && player.IsGuarding + && playerRenderer != null && playerRenderer.enabled && playerRenderer.sprite != null; + if (!visible) + { + if (guardOutline != null) guardOutline.enabled = false; + return; + } + + if (guardOutline == null) + { + GameObject outlineObject = new("Player Guard Outline"); + outlineObject.transform.SetParent(playerRenderer.transform, false); + guardOutline = outlineObject.AddComponent(); + } + + Sprite current = playerRenderer.sprite; + Sprite original = ResolveOriginalSprite(current); + if (!guardFrames.TryGetValue(original, out Sprite mask)) + { + foreach (Sprite frame in Resources.LoadAll( + "Combat/Guard-v1/" + StripArtifactPaletteSuffix(original.texture.name) + "-outline")) + { + if (frame.rect != original.rect) continue; + mask = frame; + break; + } + guardFrames[original] = mask; + } + guardOutline.sprite = mask; + guardOutline.enabled = mask != null; + guardOutline.flipX = playerRenderer.flipX; + guardOutline.flipY = playerRenderer.flipY; + guardOutline.color = new Color(1f, 1f, 1f, playerRenderer.color.a); + guardOutline.sharedMaterial = playerRenderer.sharedMaterial; + guardOutline.sortingLayerID = playerRenderer.sortingLayerID; + guardOutline.sortingOrder = playerRenderer.sortingOrder + 1; + } + + private void RestoreEquipmentSprite() + { + if (playerRenderer != null && playerRenderer.sprite != null + && equipmentOriginals.TryGetValue(playerRenderer.sprite, out Sprite original)) + playerRenderer.sprite = original; + } + + private Sprite ResolveOriginalSprite(Sprite current) + { + if (equipmentOriginals.TryGetValue(current, out Sprite original)) return original; + + string textureName = current.texture.name; + string baseTextureName = StripArtifactPaletteSuffix(textureName); + if (baseTextureName == textureName || world == null || world.Settings == null + || world.Settings.PlayerArt == null) + return current; + + ComparisonSpriteFrames playerFrames = world.Settings.PlayerArt; + Sprite originalFrame = FindMatchingFrame(playerFrames.Idle, baseTextureName, current.rect) + ?? FindMatchingFrame(playerFrames.Walk, baseTextureName, current.rect) + ?? FindMatchingFrame(playerFrames.Attack, baseTextureName, current.rect) + ?? FindMatchingFrame(playerFrames.Hurt, baseTextureName, current.rect) + ?? FindMatchingFrame(playerFrames.Death, baseTextureName, current.rect); + if (originalFrame != null) equipmentOriginals[current] = originalFrame; + return originalFrame != null ? originalFrame : current; + } + + private static Sprite FindMatchingFrame(Sprite[] frames, string textureName, Rect rect) + { + if (frames == null) return null; + foreach (Sprite frame in frames) + if (frame != null && frame.texture.name == textureName && frame.rect == rect) + return frame; + return null; + } + + private static string StripArtifactPaletteSuffix(string textureName) + { + string[] suffixes = { "-Green-v1", "-Red-v1", "-Blue-v1" }; + foreach (string suffix in suffixes) + if (textureName.EndsWith(suffix)) + return textureName.Substring(0, textureName.Length - suffix.Length); + return textureName; + } + + private void OnDisable() => ResetVisuals(); + } +} diff --git a/Assets/_Project/Scripts/Defense/Comparison/Defense25DPlayerArt.cs.meta b/Assets/_Project/Scripts/Defense/Comparison/Defense25DPlayerArt.cs.meta new file mode 100644 index 0000000..fd14abe --- /dev/null +++ b/Assets/_Project/Scripts/Defense/Comparison/Defense25DPlayerArt.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 98960d72f62d4a23b1e4f4678b15787b +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 1001 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/_Project/Scripts/Defense/Comparison/Defense25DRelicRuntime.cs b/Assets/_Project/Scripts/Defense/Comparison/Defense25DRelicRuntime.cs new file mode 100644 index 0000000..7f73472 --- /dev/null +++ b/Assets/_Project/Scripts/Defense/Comparison/Defense25DRelicRuntime.cs @@ -0,0 +1,518 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using BumpCombat.Combat; +using BumpCombat.Constants; +using BumpCombat.Core; +using BumpCombat.Enemies; +using BumpCombat.Player; +using BumpCombat.Progression; +using UnityEngine; + +namespace BumpCombat.Defense.Comparison +{ + public readonly struct Defense25DRelicTarget + { + public Defense25DRelicTarget( + int identity, Vector3 position, float health, float maximumHealth, + EnemyDamageSource lastDamageSource, bool isDead, bool isElite = false, + bool isMidBoss = false, bool isFinalBoss = false, + float igniteRemaining = 0f, float igniteInterval = 0f, float igniteTickDamage = 0f) + { + Identity = identity; + Position = position; + Health = health; + MaximumHealth = maximumHealth; + LastDamageSource = lastDamageSource; + IsDead = isDead; + IsElite = isElite; + IsMidBoss = isMidBoss; + IsFinalBoss = isFinalBoss; + IgniteRemaining = igniteRemaining; + IgniteInterval = igniteInterval; + IgniteTickDamage = igniteTickDamage; + } + + public int Identity { get; } + public Vector3 Position { get; } + public float Health { get; } + public float MaximumHealth { get; } + public EnemyDamageSource LastDamageSource { get; } + public bool IsDead { get; } + public bool IsElite { get; } + public bool IsMidBoss { get; } + public bool IsFinalBoss { get; } + public float IgniteRemaining { get; } + public float IgniteInterval { get; } + public float IgniteTickDamage { get; } + } + + public readonly struct Defense25DRelicHit + { + public Defense25DRelicHit( + Defense25DRelicTarget target, Vector3 position, Vector3 direction, + float damage, float baseBumpDamage, bool ordinaryBump, + bool artifactHit = false, string sourceId = null) + { + Target = target; + Position = position; + Direction = direction; + Damage = damage; + BaseBumpDamage = baseBumpDamage; + OrdinaryBump = ordinaryBump; + ArtifactHit = artifactHit; + SourceId = sourceId; + } + + public Defense25DRelicTarget Target { get; } + public Vector3 Position { get; } + public Vector3 Direction { get; } + public float Damage { get; } + public float BaseBumpDamage { get; } + public bool OrdinaryBump { get; } + public bool ArtifactHit { get; } + public string SourceId { get; } + } + + /// World operations required by relic procs; QueryEnemies returns 2.5D body/circle overlaps, not center-radius matches. + public sealed class Defense25DRelicEffects + { + public Func> QueryEnemies { get; set; } + public Func DamageEnemy { get; set; } + public Func IgniteEnemy { get; set; } + public Func CalculatePlayerDamage { get; set; } + public Func ApplyVulnerability { get; set; } + public Func ApplySlow { get; set; } + public Func HealPlayer { get; set; } + public Func GrantShield { get; set; } + public Func RestoreGauge { get; set; } + public Func BoostVisibleTowers { get; set; } + public Func AwardCurrency { get; set; } + public Func EmitEcho { get; set; } + public Func EmitRepeat { get; set; } + public Action SpawnHealingOrb { get; set; } + public Action SetTimedPlayerModifier { get; set; } + public Action RemovePlayerModifier { get; set; } + } + + /// + /// Stateful passive relic rules for the 2.5D run. It owns proc state/timers only; the + /// comparison world supplies target queries and applies every physical/gameplay effect. + /// + public sealed class Defense25DRelicRuntime + { + public const string EchoSourceId = PassiveRelicController.EchoSourceId; + public const string HunterBuffSourceId = "relic.hunter-feather"; + public const string TrophyBuffSourceId = "relic.trophy-mask"; + + private readonly Defense25DLoadout loadout; + private readonly PassiveRelicConstants tuning; + private readonly Defense25DRelicEffects effects; + private readonly Func randomRoll; + private readonly HashSet processedDeaths = new(); + private readonly Dictionary recentBumps = new(); + private readonly Dictionary riftHits = new(); + private float now; + private float nextEcho, nextWave, nextChain, nextCarrier, nextBlood, nextRecovery; + private float nextHeart, heartDamage, nextBell, nextRepeat, nextBaton, nextLedger, nextPouch; + private float hunterUntil, trophyUntil, voluntaryDistance; + private float healingOrbExpiresAt; + private bool wheelCharged, repeatPending, healingOrbPending, active; + private Defense25DArtifactUse pendingRepeat; + private Vector3 repeatOrigin, repeatDirection; + + public Defense25DRelicRuntime( + Defense25DLoadout loadout, PassiveRelicConstants tuning, + Defense25DRelicEffects effects, Func randomRoll = null) + { + this.loadout = loadout ?? throw new ArgumentNullException(nameof(loadout)); + this.tuning = tuning ?? throw new ArgumentNullException(nameof(tuning)); + this.effects = effects ?? throw new ArgumentNullException(nameof(effects)); + this.randomRoll = randomRoll ?? (() => UnityEngine.Random.value); + } + + public bool IsActive => active; + public bool IsWheelCharged => wheelCharged; + public bool IsRepeatPending => repeatPending; + public bool IsHealingOrbPending => healingOrbPending; + public float HunterExpiresAt => hunterUntil; + public float TrophyExpiresAt => trophyUntil; + + public void BeginRun(float time = 0f) + { + ResetState(time); + active = true; + } + + public void EndRun() + { + active = false; + repeatPending = false; + healingOrbPending = false; + ClearTemporaryModifiers(); + } + + public void ResetRun(float time = 0f) => ResetState(time); + + private void ResetState(float time) + { + ClearTemporaryModifiers(); + now = time; + processedDeaths.Clear(); + recentBumps.Clear(); + riftHits.Clear(); + nextEcho = nextWave = nextChain = nextCarrier = nextBlood = nextRecovery = 0f; + nextHeart = nextBell = nextRepeat = nextBaton = nextLedger = nextPouch = 0f; + heartDamage = voluntaryDistance = 0f; + hunterUntil = trophyUntil = 0f; + wheelCharged = repeatPending = healingOrbPending = false; + healingOrbExpiresAt = 0f; + active = false; + } + + public void Advance(float time, float playerHealth) + { + now = time; + if (repeatPending && now >= repeatAt && (!active || playerHealth <= 0f)) + repeatPending = false; + + if (!active || playerHealth <= 0f) + { + ClearTemporaryModifiers(); + return; + } + + if (hunterUntil > 0f && now >= hunterUntil) + { + effects.RemovePlayerModifier?.Invoke(HunterBuffSourceId); + hunterUntil = 0f; + } + if (trophyUntil > 0f && now >= trophyUntil) + { + effects.RemovePlayerModifier?.Invoke(TrophyBuffSourceId); + trophyUntil = 0f; + } + if (healingOrbPending && now >= healingOrbExpiresAt) + healingOrbPending = false; + if (repeatPending && now >= repeatAt) + { + repeatPending = false; + if (effects.EmitRepeat?.Invoke(pendingRepeat, repeatOrigin, repeatDirection, + tuning.ResonantCrystalDamageFraction) == true) + nextRepeat = now + tuning.ResonantCrystalCooldownSeconds; + } + } + + private float repeatAt; + + public float ApplyOrdinaryBumpDamage(float damage, bool ordinaryBump, bool hasArtifactColor) + { + if (!active || !loadout.OwnsRelic(PassiveRelicId.TwinHornTalisman)) return damage; + return PassiveRelicRules.ApplyTwinHornDamage(damage, true, ordinaryBump, + hasArtifactColor, tuning.TwinHornDoubleDamageChance, Roll()); + } + + public float ApplyTrophyBumpDamage(float damage, bool ordinaryBump) => + active && ordinaryBump && now < trophyUntil + ? damage * (1f + tuning.TrophyMaskBumpDamageIncrease) : damage; + + public void AddVoluntaryMovement(float actualDistance, float bodyDiameter, float playerHealth) + { + if (!active || !loadout.OwnsRelic(PassiveRelicId.SprintWheel) || wheelCharged + || actualDistance <= 0f || playerHealth <= 0f || bodyDiameter <= 0f) return; + voluntaryDistance += actualDistance; + wheelCharged = voluntaryDistance >= bodyDiameter * tuning.SprintWheelRequiredBodyDiameters; + } + + public void NotifyHit(Defense25DRelicHit hit, float playerHealth, float maximumPlayerHealth = 0f) + { + if (maximumPlayerHealth > 0f) MaximumPlayerHealth = maximumPlayerHealth; + if (!active || hit.Damage <= 0f || playerHealth <= 0f + || hit.SourceId == EchoSourceId) return; + + if (hit.OrdinaryBump && !hit.ArtifactHit && loadout.OwnsRelic(PassiveRelicId.BondLedger)) + recentBumps[hit.Target.Identity] = now; + if (hit.OrdinaryBump && !hit.ArtifactHit) TrySprintWheel(hit); + if (hit.BaseBumpDamage > 0f) + { + TryWaveNail(hit); + TryCapacitorRing(hit); + } + TryEmberWick(hit); + TryRiftWedge(hit); + TryExecutioner(hit); + TryBloodFang(playerHealth); + + if (loadout.OwnsRelic(PassiveRelicId.ResonanceCore) && loadout.CurrentArtifact != null && now >= nextEcho + && Rolls(tuning.ResonanceEchoChance)) + { + ActiveArtifactDefinition current = loadout.CurrentArtifact; + var use = current != null ? new Defense25DArtifactUse(current, false) : default; + if (effects.EmitEcho?.Invoke(use, hit.Position, hit.Direction, tuning.ResonanceEchoDamageMultiplier) == true) + nextEcho = now + tuning.ResonanceEchoCooldownSeconds; + } + } + + public void NotifyArtifactUse(Defense25DArtifactUse use, Vector3 origin, Vector3 direction, float playerHealth) + { + if (!active) return; + TryBattleBaton(); + if (!loadout.OwnsRelic(PassiveRelicId.ResonantCrystal) || repeatPending || now < nextRepeat + || playerHealth <= 0f || !Rolls(tuning.ResonantCrystalChance)) return; + pendingRepeat = use; + repeatOrigin = origin; + repeatDirection = direction; + repeatAt = now + tuning.ResonantCrystalDelaySeconds; + repeatPending = true; + } + + public void NotifyPlayerDamageTaken(float actualLoss, float playerHealth, float maximumHealth, Vector3 playerPosition) + { + if (!active || actualLoss <= 0f || playerHealth <= 0f) return; + if (loadout.OwnsRelic(PassiveRelicId.BackflowHeart) && now >= nextHeart) + { + heartDamage += actualLoss; + if (heartDamage >= maximumHealth * tuning.BackflowHeartDamageThreshold) + { + heartDamage = 0f; + if (effects.GrantShield?.Invoke(maximumHealth * tuning.BackflowHeartShieldFraction, + tuning.BackflowHeartShieldDurationSeconds) == true) + nextHeart = now + tuning.BackflowHeartCooldownSeconds; + } + } + if (!loadout.OwnsRelic(PassiveRelicId.CounterBell) || now < nextBell + || !Rolls(tuning.CounterBellChance)) return; + bool applied = false; + foreach (Defense25DRelicTarget target in Query(playerPosition, BodyRadius(playerPosition))) + { + if (!target.IsDead) + applied |= effects.ApplySlow?.Invoke(target, + tuning.CounterBellSlowDurationSeconds, tuning.CounterBellSlowFraction) == true; + } + if (applied) nextBell = now + tuning.CounterBellCooldownSeconds; + } + + public void NotifyEnemyDeath(Defense25DRelicTarget victim, float playerHealth) + { + if (!active || !processedDeaths.Add(victim.Identity) || playerHealth <= 0f) return; + riftHits.Remove(victim.Identity); + TryBondLedger(victim); + recentBumps.Remove(victim.Identity); + if (!PassiveRelicRules.IsPlayerRewardKill(victim.LastDamageSource)) return; + TryHunterFeather(); + if (loadout.OwnsRelic(PassiveRelicId.RecoveryFunnel) && now >= nextRecovery + && Rolls(tuning.RecoveryFunnelChance)) + { + if (effects.RestoreGauge?.Invoke(tuning.RecoveryFunnelGaugeFraction) == true) + nextRecovery = now + tuning.RecoveryFunnelCooldownSeconds; + } + TryTrophy(victim); + TryPilgrimPouch(victim); + if (!PassiveRelicRules.IsPlayerCreditedKill(victim.LastDamageSource)) return; + TryEmberCarrier(victim); + TryRupture(victim); + } + + public bool NotifyHealingOrbPickedUp(float playerMaximumHealth) + { + if (!active || !healingOrbPending) return false; + healingOrbPending = false; + return effects.HealPlayer?.Invoke(playerMaximumHealth * tuning.PilgrimPouchHealMaxHealthFraction) == true; + } + + public void NotifyHealingOrbExpired() => healingOrbPending = false; + + private void TryWaveNail(Defense25DRelicHit hit) + { + if (!loadout.OwnsRelic(PassiveRelicId.WaveNail) || now < nextWave || !Rolls(tuning.WaveNailChance)) return; + float damage = hit.BaseBumpDamage * tuning.WaveNailDamageFraction; + bool applied = damage > 0f && DamageArea(hit.Position, BodyRadius(hit.Position), damage, + null, int.MaxValue, EnemyDamageSource.PassiveRelicAttack) > 0; + if (applied) nextWave = now + tuning.WaveNailCooldownSeconds; + } + + private void TryCapacitorRing(Defense25DRelicHit hit) + { + if (!loadout.OwnsRelic(PassiveRelicId.CapacitorRing) || now < nextChain || !Rolls(tuning.CapacitorRingChance)) return; + float damage = hit.BaseBumpDamage * tuning.CapacitorRingDamageFraction; + int applied = damage <= 0f ? 0 : DamageArea(hit.Position, BodyRadius(hit.Position), damage, + hit.Target.Identity, tuning.CapacitorRingMaxTargets, EnemyDamageSource.PassiveRelicAttack); + if (applied > 0) nextChain = now + tuning.CapacitorRingCooldownSeconds; + } + + private void TryEmberWick(Defense25DRelicHit hit) + { + if (!loadout.OwnsRelic(PassiveRelicId.EmberWick) || !Rolls(tuning.EmberWickChance)) return; + ActiveArtifactDefinition phoenix = loadout.FindArtifact(ActiveArtifactEffect.Phoenix); + if (phoenix == null || hit.Target.IsDead) return; + float tickDamage = effects.CalculatePlayerDamage?.Invoke( + phoenix.NormalIgniteTickDamage, phoenix.DamageTag) ?? phoenix.NormalIgniteTickDamage; + effects.IgniteEnemy?.Invoke(hit.Target, phoenix.NormalIgniteDuration, + phoenix.NormalIgniteInterval, tickDamage, EnemyDamageSource.Player); + } + + private void TrySprintWheel(Defense25DRelicHit hit) + { + if (!wheelCharged || hit.BaseBumpDamage <= 0f) return; + wheelCharged = false; + voluntaryDistance = 0f; + DamageArea(hit.Position, BodyRadius(hit.Position), + hit.BaseBumpDamage * tuning.SprintWheelShockwaveDamageFraction, null, + int.MaxValue, EnemyDamageSource.PassiveRelicAttack); + } + + private void TryRiftWedge(Defense25DRelicHit hit) + { + if (!loadout.OwnsRelic(PassiveRelicId.RiftWedge) || !hit.OrdinaryBump || hit.ArtifactHit + || hit.Target.IsDead) return; + riftHits.TryGetValue(hit.Target.Identity, out var progress); + int count = now - progress.at > tuning.RiftWedgeMaxHitGapSeconds ? 1 : progress.hits + 1; + if (count < tuning.RiftWedgeRequiredHits) + { + riftHits[hit.Target.Identity] = (count, now); + return; + } + riftHits.Remove(hit.Target.Identity); + ActiveArtifactDefinition thunder = loadout.FindArtifact(ActiveArtifactEffect.ThunderCrash); + if (thunder != null) + effects.ApplyVulnerability?.Invoke(hit.Target, thunder.NormalVulnerabilityDuration, + thunder.NormalVulnerabilityIncrease); + } + + private void TryExecutioner(Defense25DRelicHit hit) + { + if (!loadout.OwnsRelic(PassiveRelicId.ExecutionerSigil) || !hit.OrdinaryBump || hit.ArtifactHit + || !Rolls(tuning.ExecutionerSigilChance) || hit.Target.IsDead || hit.Target.IsMidBoss + || hit.Target.IsFinalBoss || hit.Target.Health > hit.Target.MaximumHealth * tuning.ExecutionerSigilHealthThreshold) return; + effects.DamageEnemy?.Invoke(hit.Target, hit.Target.MaximumHealth * 100f, + hit.Direction, EnemyDamageSource.PassiveRelicAttack); + } + + private void TryBloodFang(float playerHealth) + { + if (!loadout.OwnsRelic(PassiveRelicId.BloodFang) || now < nextBlood + || playerHealth >= MaximumPlayerHealth || !Rolls(tuning.BloodFangChance)) return; + if (effects.HealPlayer?.Invoke(MaximumPlayerHealth * tuning.BloodFangHealMaxHealthFraction) == true) + nextBlood = now + tuning.BloodFangCooldownSeconds; + } + + private float MaximumPlayerHealth { get; set; } + public void SetMaximumPlayerHealth(float value) => MaximumPlayerHealth = Mathf.Max(0f, value); + + private void TryRupture(Defense25DRelicTarget victim) + { + if (!loadout.OwnsRelic(PassiveRelicId.RuptureSeed) || !Rolls(tuning.RuptureSeedChance)) return; + float damage = PassiveRelicRules.GetExplosionDamage(victim.MaximumHealth, tuning.RuptureExplosionMaxHealthFraction); + if (damage <= 0f) return; + DamageArea(victim.Position, BodyRadius(victim.Position, tuning.RuptureExplosionPlayerBodyDiameters), + damage, victim.Identity, int.MaxValue, EnemyDamageSource.PassiveRelicExplosion); + } + + private void TryHunterFeather() + { + if (!loadout.OwnsRelic(PassiveRelicId.HunterFeather) || !Rolls(tuning.HunterFeatherChance)) return; + effects.SetTimedPlayerModifier?.Invoke(new StatModifier( + HunterBuffSourceId, CharacterStat.MoveSpeed, ModifierOperation.Increased, + tuning.HunterFeatherMoveSpeedIncrease), tuning.HunterFeatherDurationSeconds); + hunterUntil = now + tuning.HunterFeatherDurationSeconds; + } + + private void TryTrophy(Defense25DRelicTarget victim) + { + if (!loadout.OwnsRelic(PassiveRelicId.TrophyMask) || !victim.IsElite) return; + effects.SetTimedPlayerModifier?.Invoke(new StatModifier( + TrophyBuffSourceId, CharacterStat.MoveSpeed, ModifierOperation.Increased, + tuning.TrophyMaskMoveSpeedIncrease), tuning.TrophyMaskDurationSeconds); + trophyUntil = now + tuning.TrophyMaskDurationSeconds; + } + + private void TryBattleBaton() + { + if (loadout.OwnsRelic(PassiveRelicId.BattleBaton) && now >= nextBaton + && effects.BoostVisibleTowers?.Invoke(tuning.BattleBatonDurationSeconds) == true) + nextBaton = now + tuning.BattleBatonCooldownSeconds; + } + + private void TryBondLedger(Defense25DRelicTarget victim) + { + if (!loadout.OwnsRelic(PassiveRelicId.BondLedger) || victim.LastDamageSource != EnemyDamageSource.Tower + || now < nextLedger || !recentBumps.TryGetValue(victim.Identity, out float bumpedAt) + || now - bumpedAt > tuning.BondLedgerBumpWindowSeconds || !Rolls(tuning.BondLedgerChance)) return; + if (effects.AwardCurrency?.Invoke(tuning.BondLedgerCurrency) == true) + nextLedger = now + tuning.BondLedgerCooldownSeconds; + } + + private void TryPilgrimPouch(Defense25DRelicTarget victim) + { + if (!loadout.OwnsRelic(PassiveRelicId.PilgrimPouch) || healingOrbPending || now < nextPouch + || !Rolls(tuning.PilgrimPouchChance)) return; + if (effects.SpawnHealingOrb == null) return; + effects.SpawnHealingOrb(victim.Position, tuning.PilgrimPouchHealMaxHealthFraction, + tuning.PilgrimPouchOrbLifetimeSeconds); + healingOrbPending = true; + healingOrbExpiresAt = now + tuning.PilgrimPouchOrbLifetimeSeconds; + nextPouch = now + tuning.PilgrimPouchCooldownSeconds; + } + + private void TryEmberCarrier(Defense25DRelicTarget victim) + { + if (!loadout.OwnsRelic(PassiveRelicId.EmberCarrier) || victim.IgniteRemaining <= 0f + || victim.IgniteInterval <= 0f || victim.IgniteTickDamage <= 0f || now < nextCarrier + || !Rolls(tuning.EmberCarrierChance)) return; + int count = 0; + foreach (Defense25DRelicTarget target in Query(victim.Position, BodyRadius(victim.Position)) + .Where(enemy => enemy.Identity != victim.Identity && !enemy.IsDead) + .OrderBy(enemy => GroundDistanceSquared(enemy.Position, victim.Position))) + { + if (count >= tuning.EmberCarrierMaxTargets) break; + if (effects.IgniteEnemy?.Invoke(target, victim.IgniteRemaining, victim.IgniteInterval, + victim.IgniteTickDamage, EnemyDamageSource.PassiveRelicTransferredIgnite) == true) + count++; + } + if (count > 0) nextCarrier = now + tuning.EmberCarrierCooldownSeconds; + } + + private int DamageArea(Vector3 origin, float radius, float damage, int? excludedIdentity, + int maxTargets, EnemyDamageSource source) + { + if (damage <= 0f || radius <= 0f || effects.DamageEnemy == null) return 0; + int count = 0; + foreach (Defense25DRelicTarget target in Query(origin, radius) + .Where(enemy => !enemy.IsDead && (!excludedIdentity.HasValue + || enemy.Identity != excludedIdentity.Value)) + .OrderBy(enemy => GroundDistanceSquared(enemy.Position, origin))) + { + if (count >= maxTargets) break; + Vector3 direction = target.Position - origin; + direction.y = 0f; + if (effects.DamageEnemy(target, damage, direction.sqrMagnitude > 0f ? direction.normalized : Vector3.zero, source)) + count++; + } + return count; + } + + private IReadOnlyList Query(Vector3 center, float radius) => + effects.QueryEnemies?.Invoke(center, radius) ?? Array.Empty(); + + private float BodyRadius(Vector3 center, float multiplier = 3f) => + PassiveRelicRules.GetExplosionRadius(BodyDiameterProvider?.Invoke() ?? 0f, multiplier); + + public Func BodyDiameterProvider { get; set; } + + private static float GroundDistanceSquared(Vector3 a, Vector3 b) + { + float dx = a.x - b.x, dz = a.z - b.z; + return dx * dx + dz * dz; + } + + private bool Rolls(float chance) => PassiveRelicRules.RollsChance(chance, Roll()); + private float Roll() => randomRoll(); + + private void ClearTemporaryModifiers() + { + effects.RemovePlayerModifier?.Invoke(HunterBuffSourceId); + effects.RemovePlayerModifier?.Invoke(TrophyBuffSourceId); + hunterUntil = trophyUntil = 0f; + } + } +} diff --git a/Assets/_Project/Scripts/Defense/Comparison/Defense25DRelicRuntime.cs.meta b/Assets/_Project/Scripts/Defense/Comparison/Defense25DRelicRuntime.cs.meta new file mode 100644 index 0000000..4d571d3 --- /dev/null +++ b/Assets/_Project/Scripts/Defense/Comparison/Defense25DRelicRuntime.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 8003ed4e9dc54893b405abfe3dd2378a diff --git a/Assets/_Project/Scripts/Defense/Comparison/Defense25DSkeletonCombat.cs b/Assets/_Project/Scripts/Defense/Comparison/Defense25DSkeletonCombat.cs new file mode 100644 index 0000000..12c8179 --- /dev/null +++ b/Assets/_Project/Scripts/Defense/Comparison/Defense25DSkeletonCombat.cs @@ -0,0 +1,329 @@ +using BumpCombat.Enemies; +using BumpCombat.Core; +using UnityEngine; + +namespace BumpCombat.Defense.Comparison +{ + public sealed partial class Defense25DEnemy + { + private EnemyDefinition skeletonDefinition; + private float skeletonRootScale = 1f; + private AnimationClip skeletonAttackClip; + private LineRenderer skeletonSlash; + private bool skeletonAttackAnimationStarted; + private float SkeletonGeometryScale => pattern.GeometryScaleMode + == EnemyAttackGeometryScaleMode.RootScale ? skeletonRootScale : 1f; + + public void ConfigureSkeletonCombat( + EnemyDefinition definition, + float sourceRootScale, + AnimationClip sourceAttackClip) + { + if (definition == null || definition.Kind != EnemyKind.Skeleton) + { + skeletonDefinition = null; + skeletonAttackClip = null; + SetSkeletonSlashVisible(false); + return; + } + + skeletonDefinition = definition; + pattern = definition.GetAttackPattern(0); + skeletonRootScale = Mathf.Max(.0001f, Mathf.Abs(sourceRootScale)); + skeletonAttackClip = sourceAttackClip; + EnsureSkeletonSlash(); + skeletonSlash.gameObject.SetActive(true); + SetSkeletonSlashVisible(false); + } + + private void OnEnable() => SetSkeletonSlashVisible(false); + + public bool StepSkeletonCombat(float dt) + { + if (skeletonDefinition == null || !IsAvailable) return false; + if (!world.IsRunning || world.Paused) return true; + if (IsStunned) + { + Actor.StopBody(); + Actor.Visual.SetWarning(false); + SetSkeletonSlashVisible(false); + return true; + } + if (Actor.HasKnockback) + { + Actor.Move(Actor.ConsumeKnockback(dt)); + if (IsAttacking) UpdateSkeletonSlash(world.GameTime - attackStart); + return true; + } + + if (IsAttacking) + { + StepSkeletonAttack(); + return true; + } + + if (world.GameTime < progressUntil) + { + Actor.Visual.SetWarning(false); + SetSkeletonSlashVisible(false); + return false; + } + + target = FindSkeletonTarget(); + if (target == null) + { + Actor.Visual.SetWarning(false); + SetSkeletonSlashVisible(false); + return false; + } + + Vector2 targetPosition = target.Center; + Vector2 toTarget = targetPosition - Actor.Position; + float range = pattern.AttackRange * SkeletonGeometryScale; + if (toTarget.magnitude > range) + { + target = null; + return false; + } + + attackDirection = EnemyAttack.ResolveAttackDirection( + pattern.DirectionMode, + toTarget, + Actor.Facing); + if (!IsSkeletonSlashAligned(targetPosition)) + { + MoveToSkeletonSlashSlot(targetPosition, range, dt); + target = null; + return true; + } + Actor.Facing = attackDirection; + attackStart = world.GameTime; + dealtDamage = false; + skeletonAttackAnimationStarted = false; + Actor.Visual.SetWarning(true); + UpdateSkeletonSlash(0f); + return true; + } + + public static bool SkeletonSlashIntersectsCircle( + Vector2 attackOrigin, + Vector2 lockedDirection, + float attackLength, + float attackWidth, + Vector2 targetCenter, + float targetRadius) + { + if (attackLength <= 0f || attackWidth <= 0f || targetRadius < 0f) return false; + Vector2 direction = lockedDirection.sqrMagnitude > .0001f + ? lockedDirection.normalized + : Vector2.right; + Vector2 lateral = new(-direction.y, direction.x); + Vector2 delta = targetCenter - attackOrigin; + float forward = Vector2.Dot(delta, direction); + if (forward < 0f) return false; + + float side = Vector2.Dot(delta, lateral); + float nearestForward = Mathf.Clamp(forward, 0f, attackLength); + float nearestSide = Mathf.Clamp(side, -attackWidth * .5f, attackWidth * .5f); + float forwardGap = forward - nearestForward; + float sideGap = side - nearestSide; + return forwardGap * forwardGap + sideGap * sideGap + <= targetRadius * targetRadius; + } + + public static bool SkeletonAttackAnimationShouldStart( + EnemyAttackPattern attackPattern, + float elapsed) + { + float warningRemaining = attackPattern.WarningDuration - elapsed; + return EnemyController.ShouldStartAttackAnimation( + warningRemaining, + attackPattern.AnimationLeadTime, + false); + } + + public static int SkeletonAttackFrameAt(AnimationClip clip, float elapsed, int frameCount) + { + if (clip == null || frameCount <= 0) return 0; + float clipTime = Mathf.Clamp(elapsed, 0f, clip.length); + return Mathf.Clamp(Mathf.FloorToInt(clipTime * clip.frameRate), 0, frameCount - 1); + } + + private Defense25DActor FindSkeletonTarget() + { + Defense25DActor best = null; + float bestDistance = pattern.AttackRange * SkeletonGeometryScale; + void Consider(Defense25DActor actor) + { + if (actor == null || !actor.IsAlive) return; + float distance = Vector2.Distance(Actor.Position, actor.Center); + if (distance > bestDistance) return; + best = actor; + bestDistance = distance; + } + + Consider(world.Player); + if (world.TowersOperational) + foreach (Defense25DSummon summon in world.Summons) + Consider(summon.Actor); + return best; + } + + private bool IsSkeletonSlashAligned(Vector2 targetCenter) + { + Vector2 origin = GetSkeletonSlashOrigin(); + Vector2 lateral = new(-attackDirection.y, attackDirection.x); + float targetSide = Vector2.Dot(targetCenter - origin, lateral); + float halfWidth = pattern.AttackWidth * SkeletonGeometryScale * .5f; + return Mathf.Abs(targetSide) <= halfWidth + target.Radius; + } + + private void MoveToSkeletonSlashSlot(Vector2 targetCenter, float range, float dt) + { + Vector2 boundedTarget = targetCenter; + float edge = Mathf.Max(0f, world.ArenaHalfExtents.y - Actor.Radius); + boundedTarget.y = Mathf.Clamp(targetCenter.y, -edge, edge); + float verticalDelta = targetCenter.y - boundedTarget.y; + float horizontalDistance = Mathf.Sqrt(Mathf.Max( + 0f, + range * range - verticalDelta * verticalDelta)); + horizontalDistance = Mathf.Max(0f, horizontalDistance - .02f); + Vector2 slot = new( + targetCenter.x - attackDirection.x * horizontalDistance, + boundedTarget.y); + Vector2 delta = slot - Actor.Position; + if (delta.sqrMagnitude <= .0001f) return; + Actor.Facing = delta.normalized; + float slow = world.GameTime < slowUntil ? slowFraction : 0f; + float speed = Actor.Evaluate(CharacterStat.MoveSpeed, skeletonDefinition.MoveSpeed); + Actor.Move(delta.normalized * Mathf.Min(delta.magnitude, speed * (1f - slow) * dt)); + } + + private void StepSkeletonAttack() + { + if (target == null || !target.IsAlive + || (target.IsMinion && !world.TowersOperational)) + { + EndSkeletonAttack(); + return; + } + + float elapsed = world.GameTime - attackStart; + float animationLead = Mathf.Max(0f, pattern.WarningDuration - pattern.AnimationLeadTime); + if (!skeletonAttackAnimationStarted && SkeletonAttackAnimationShouldStart(pattern, elapsed)) + { + skeletonAttackAnimationStarted = true; + Actor.Visual.SetAttack(true); + } + + bool warning = elapsed < pattern.WarningDuration; + Actor.Visual.SetWarning(warning && !skeletonAttackAnimationStarted); + if (skeletonAttackAnimationStarted) + { + float attackElapsed = elapsed - animationLead; + Actor.Visual.SetAttackFrame(SkeletonAttackFrameAt( + skeletonAttackClip, + attackElapsed, + world.Settings.EnemyArt.Attack.Length)); + } + + UpdateSkeletonSlash(elapsed); + float activeElapsed = elapsed - pattern.WarningDuration; + if (!dealtDamage + && pattern.IsContactWindowActive(activeElapsed, pattern.ActiveDuration) + && SkeletonSlashIntersectsCircle( + GetSkeletonSlashOrigin(), + attackDirection, + pattern.AttackLength * SkeletonGeometryScale, + pattern.AttackWidth * SkeletonGeometryScale, + target.Center, + target.Radius)) + { + Vector2 knockbackDirection = target.Center - Actor.Position; + if (knockbackDirection.sqrMagnitude <= .0001f) + knockbackDirection = attackDirection; + float knockback = target.IsMinion + ? 0f + : BumpCombat.Constants.GameplayConstants.Current.Enemies.NormalPlayerKnockbackDistance; + dealtDamage = target.TakeDamage( + Actor.CalculateAttackDamage(skeletonDefinition.AttackDamage * pattern.DamageMultiplier), + knockbackDirection.normalized, + knockback); + } + + if (elapsed >= pattern.WarningDuration + pattern.ActiveDuration + pattern.RecoveryDuration) + EndSkeletonAttack(); + } + + private Vector2 GetSkeletonSlashOrigin() + { + return EnemyAttack.GetAttackOrigin( + Actor.Position, + attackDirection, + pattern.AttackOriginOffset * SkeletonGeometryScale, + pattern.DirectionMode); + } + + private void UpdateSkeletonSlash(float elapsed) + { + if (skeletonSlash == null) return; + bool visible = elapsed < pattern.WarningDuration + pattern.ActiveDuration; + SetSkeletonSlashVisible(visible); + if (!visible) return; + + Vector2 origin = GetSkeletonSlashOrigin(); + Vector2 direction = attackDirection.sqrMagnitude > .0001f + ? attackDirection.normalized + : Vector2.right; + Vector2 side = pattern.DirectionMode == EnemyAttackDirectionMode.HorizontalRoot + ? Vector2.up + : new Vector2(-direction.y, direction.x); + float length = pattern.AttackLength * SkeletonGeometryScale; + float halfWidth = pattern.AttackWidth * SkeletonGeometryScale * .5f; + Vector2 end = origin + direction * length; + float y = world.Settings.GroundY + .025f; + skeletonSlash.SetPosition(0, Defense25DActor.OnGround(origin - side * halfWidth, y)); + skeletonSlash.SetPosition(1, Defense25DActor.OnGround(end - side * halfWidth, y)); + skeletonSlash.SetPosition(2, Defense25DActor.OnGround(end + side * halfWidth, y)); + skeletonSlash.SetPosition(3, Defense25DActor.OnGround(origin + side * halfWidth, y)); + skeletonSlash.SetPosition(4, Defense25DActor.OnGround(origin - side * halfWidth, y)); + Color color = elapsed < pattern.WarningDuration + ? new Color(1f, .7f, .15f, .95f) + : new Color(1f, .2f, .12f, 1f); + skeletonSlash.startColor = skeletonSlash.endColor = color; + } + + private void EnsureSkeletonSlash() + { + if (skeletonSlash != null) return; + GameObject go = new("Skeleton slash warning"); + go.transform.SetParent(transform, false); + skeletonSlash = Defense25DSprite.CreateLine( + go.name, + world.Settings.HitBoundaryMaterial != null + ? world.Settings.HitBoundaryMaterial + : world.Settings.BoundaryMaterial, + go.transform, + .025f); + skeletonSlash.loop = false; + skeletonSlash.positionCount = 5; + skeletonSlash.enabled = false; + } + + private void SetSkeletonSlashVisible(bool visible) + { + if (skeletonSlash != null) skeletonSlash.enabled = visible; + } + + private void EndSkeletonAttack() + { + attackStart = -1f; + target = null; + skeletonAttackAnimationStarted = false; + Actor.Visual.SetWarning(false); + Actor.Visual.SetAttack(false); + SetSkeletonSlashVisible(false); + progressUntil = world.GameTime; + } + } +} diff --git a/Assets/_Project/Scripts/Defense/Comparison/Defense25DSkeletonCombat.cs.meta b/Assets/_Project/Scripts/Defense/Comparison/Defense25DSkeletonCombat.cs.meta new file mode 100644 index 0000000..94c6b7e --- /dev/null +++ b/Assets/_Project/Scripts/Defense/Comparison/Defense25DSkeletonCombat.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 41e5d84f32414319800222052a38f643 diff --git a/Assets/_Project/Scripts/Defense/Comparison/Defense25DSprite.cs b/Assets/_Project/Scripts/Defense/Comparison/Defense25DSprite.cs new file mode 100644 index 0000000..e7f8f52 --- /dev/null +++ b/Assets/_Project/Scripts/Defense/Comparison/Defense25DSprite.cs @@ -0,0 +1,473 @@ +using BumpCombat.Constants; +using BumpCombat.Combat; +using BumpCombat.Defense; +using BumpCombat.Player; +using System; +using UnityEngine; + +namespace BumpCombat.Defense.Comparison +{ + // Rendering only: this child never owns a body, a hurtbox or an authoritative position. + [DefaultExecutionOrder(-100)] + public sealed class Defense25DSprite : MonoBehaviour + { + private Defense25DComparison world; + private Defense25DActor actor; + private SpriteRenderer sprite; + private LineRenderer footprint; + private ComparisonSpriteFrames frames; + private Sprite[] transientFrames; + private float[] transientDurations; + private float transientStart, transientDuration; + private string summonIdleKey = "Summons/Slime/Idle"; + private string summonWalkKey = "Summons/Slime/Walk"; + private float scale = 1f, footOffset, hurtUntil, attackUntil, attackStart; + private bool attacking, warning, tower; + private bool hideAfterDeath; + private string activePlayerMotion; + private Color feedbackTint = Color.white; + private int forcedFrame; + private Vector3 previousPosition; + public SpriteRenderer Renderer => sprite; + public float VisualScale => scale; + public float FootVisualOffset => footOffset; + public float VisualHeight { get; set; } + public Vector3 FootPosition => Defense25DActor.OnGround( + Defense25DActor.Ground(transform.position) + (actor != null ? actor.Offset : Vector2.zero), + world.Settings.GroundY); + + public void Configure(Defense25DComparison owner, Defense25DActor body) + { + world = owner; actor = body; + scale = body.Kind == ComparisonActorKind.Player ? world.Settings.PlayerVisualScale + : body.Kind == ComparisonActorKind.Enemy ? world.Settings.EnemyVisualScale : world.Settings.SummonVisualScale; + footOffset = body.FootOffset; + frames = body.Kind == ComparisonActorKind.Player ? world.Settings.PlayerArt + : body.Kind == ComparisonActorKind.Enemy ? world.Settings.EnemyArt + : new ComparisonSpriteFrames + { + Idle = DefenseTowerArt.Load("Summons/Slime/Idle"), + Walk = DefenseTowerArt.Load("Summons/Slime/Walk"), + Attack = DefenseTowerArt.Load("Summons/Slime/Attack01"), + Hurt = DefenseTowerArt.Load("Summons/Slime/Hurt"), + Death = DefenseTowerArt.Load("Summons/Slime/Death"), + }; + CreateVisual(); + } + + public void ConfigureTower(Defense25DComparison owner) + { + world = owner; tower = true; footOffset = -9f / 32f; + frames = new ComparisonSpriteFrames { Idle = DefenseTowerArt.Load(DefenseTowerArt.BodyKey(DefenseTowerKind.Summon, 1, "Idle")) }; + CreateVisual(); + } + + public void ConfigureSummonArt(int tier, DefenseTowerBranch branch) + { + string family = tier == 4 && branch == DefenseTowerBranch.First + ? "GiantSlime" : tier == 4 && branch == DefenseTowerBranch.Second + ? "LavaSlime" : "Slime"; + summonIdleKey = $"Summons/{family}/Idle"; + summonWalkKey = $"Summons/{family}/Walk"; + frames = new ComparisonSpriteFrames + { + Idle = DefenseTowerArt.Load(summonIdleKey), + Walk = DefenseTowerArt.Load(summonWalkKey), + Attack = DefenseTowerArt.Load($"Summons/{family}/Attack01"), + Hurt = DefenseTowerArt.Load($"Summons/{family}/Hurt"), + Death = DefenseTowerArt.Load($"Summons/{family}/Death"), + }; + float baseScale = world.Settings.SummonVisualScale; + scale = family == "GiantSlime" ? 1.55f + : family == "LavaSlime" ? 1.15f : baseScale; + footOffset = family == "GiantSlime" ? 0f + : world.Settings.SummonFootOffset * (scale / baseScale); + float coreDiameterPixels = family == "GiantSlime" + ? Defense25DComparisonSettings.GiantSlimeBodyCoreDiameterPixels + : family == "LavaSlime" ? Defense25DComparisonSettings.LavaSlimeBodyCoreDiameterPixels + : Defense25DComparisonSettings.SlimeBodyCoreDiameterPixels; + Vector2 bodyCenterFromFootPixels = family == "GiantSlime" + ? Defense25DComparisonSettings.GiantSlimeBodyCenterFromFootPixels + : family == "LavaSlime" ? Defense25DComparisonSettings.LavaSlimeBodyCenterFromFootPixels + : Defense25DComparisonSettings.SlimeBodyCenterFromFootPixels; + actor.SetSummonVariantScale(scale, coreDiameterPixels, bodyCenterFromFootPixels); + if (sprite != null && frames.Idle.Length > 0) sprite.sprite = frames.Idle[0]; + } + + private void CreateVisual() + { + GameObject image = new("Billboard - visual only"); + image.transform.SetParent(transform, false); + sprite = image.AddComponent(); + sprite.sharedMaterial = world.Settings.CutoutMaterial; + sprite.sprite = frames.Idle.Length > 0 ? frames.Idle[0] : null; + footprint = CreateLine("Body capsule bounds", world.Settings.BoundaryMaterial, transform, .03f); + ResetVisual(); + } + + public static LineRenderer CreateLine(string label, Material material, Transform parent, float width) + { + GameObject go = new(label); + go.transform.SetParent(parent, false); + LineRenderer line = go.AddComponent(); + line.sharedMaterial = material; + line.useWorldSpace = true; + line.loop = true; + line.widthMultiplier = width; + line.alignment = LineAlignment.View; + line.textureMode = LineTextureMode.Stretch; + line.positionCount = 65; + return line; + } + + public static void SetEllipse(LineRenderer line, Vector2 center, Vector2 radii, float y) + { + for (int i = 0; i < line.positionCount; i++) + { + float angle = i * Mathf.PI * 2f / (line.positionCount - 1); + line.SetPosition(i, Defense25DActor.OnGround(center + new Vector2(Mathf.Cos(angle) * radii.x, Mathf.Sin(angle) * radii.y), y)); + } + } + + public static float Duration(string key) + { + int count = DefenseTowerArt.Load(key).Length; + float result = 0f; + for (int i = 0; i < count; i++) result += DefenseTowerArt.FrameSeconds(key, i); + return Mathf.Max(.01f, result); + } + + public static int FrameAt(string key, float elapsed, bool loop) + { + int count = DefenseTowerArt.Load(key).Length; + if (count <= 0) return 0; + if (loop) elapsed %= Duration(key); + for (int i = 0; i < count; i++) + { + float duration = DefenseTowerArt.FrameSeconds(key, i); + if (elapsed < duration) return i; + elapsed -= duration; + } + return count - 1; + } + + public static float PixelAlignedScale(float authoredScale, int targetPixelHeight, + float orthographicSize, float pixelsPerUnit) + { + if (authoredScale <= 0f || targetPixelHeight <= 0 + || orthographicSize <= 0f || pixelsPerUnit <= 0f) + return authoredScale; + + float authoredPixelPitch = targetPixelHeight / (2f * orthographicSize) + * authoredScale / pixelsPerUnit; + int alignedPitch = Mathf.Max(1, Mathf.RoundToInt(authoredPixelPitch)); + return alignedPitch * (2f * orthographicSize) * pixelsPerUnit / targetPixelHeight; + } + + public static Vector3 PixelGridOffset(Camera camera, Sprite sprite, + bool flippedHorizontally, + Vector3 spritePivotWorldPosition, Vector3 visualScale) + { + if (camera == null || sprite == null || !camera.orthographic + || camera.orthographicSize <= 0f || sprite.pixelsPerUnit <= 0f) + return Vector3.zero; + + int pixelHeight = camera.targetTexture != null + ? camera.targetTexture.height : camera.pixelHeight; + if (pixelHeight <= 0) return Vector3.zero; + + float unitsPerPixel = 2f * camera.orthographicSize / pixelHeight; + float texelPitch = visualScale.y / sprite.pixelsPerUnit / unitsPerPixel; + Vector3 screen = camera.WorldToScreenPoint(spritePivotWorldPosition); + float pivotX = flippedHorizontally ? sprite.rect.width - sprite.pivot.x : sprite.pivot.x; + float left = screen.x - pivotX * texelPitch; + float bottom = screen.y - sprite.pivot.y * texelPitch; + float deltaX = Mathf.Round(left) - left; + float deltaY = Mathf.Round(bottom) - bottom; + return camera.transform.right * (deltaX * unitsPerPixel) + + camera.transform.up * (deltaY * unitsPerPixel); + } + + public void ResetVisual() + { + hurtUntil = attackUntil = 0f; + transientFrames = null; + transientDurations = null; + transientDuration = 0f; + hideAfterDeath = false; + activePlayerMotion = null; + ClearFeedbackTint(); + attacking = warning = false; + VisualHeight = 0f; + previousPosition = transform.position; + if (sprite != null) sprite.enabled = true; + } + + public void Hide() + { + if (sprite != null) sprite.enabled = false; + if (footprint != null) footprint.enabled = false; + } + + public void RestoreVisibility() + { + if (sprite != null) sprite.enabled = true; + if (footprint != null) footprint.enabled = true; + } + + public void ShowHurt() + { + if (actor != null && actor.Kind != ComparisonActorKind.Player) + { + StartTimedSequence(frames.Hurt, Defense25DPlayerAnimationConstants.HurtFrameSeconds, "Hurt"); + return; + } + PlayPlayerSequence("Hurt", Defense25DPlayerAnimationConstants.HurtFrameSeconds); + } + public void ShowAttack(bool backHit = false) + { + if (activePlayerMotion is "Hurt" or "Dash" or "Death" or "ArtifactUse-v1") return; + if (actor == null || actor.Kind != ComparisonActorKind.Player) + { + attackUntil = world.GameTime + .24f; + return; + } + PlayPlayerSequence(backHit ? "BackBumpAttack-v2" : "BumpAttack-v1", + Defense25DPlayerAnimationConstants.BumpFrameSeconds); + } + + public void ShowArtifactUse() + { + if (activePlayerMotion is "Hurt" or "Dash" or "Death") return; + PlayPlayerSequence("ArtifactUse-v1", Defense25DPlayerAnimationConstants.ArtifactUseFrameSeconds); + } + + public void ShowDash() + { + if (activePlayerMotion is "Hurt" or "Death") return; + PlayPlayerSequence("Dash", Defense25DPlayerAnimationConstants.DashFrameSeconds); + } + + public void ShowDeath() + { + if (actor != null && actor.Kind != ComparisonActorKind.Player) + { + float duration = actor.Kind == ComparisonActorKind.Enemy + ? GameplayConstants.Current.Enemies.DefaultDeathDuration + : Defense25DPlayerAnimationConstants.NonPlayerDeathFallbackDuration; + StartTimedSequence(frames.Death, + duration / Mathf.Max(1, frames.Death.Length), "Death"); + } + else BeginPlayerSequence("Death", Defense25DPlayerAnimationConstants.DeathFrameSeconds, frames.Death); + hideAfterDeath = true; + activePlayerMotion = "Death"; + } + + private void PlayPlayerSequence(string motion, float frameSeconds) + { + ArtifactColor color = world.Loadout?.CurrentArtifact?.ArtifactColor ?? ArtifactColor.Green; + string path = $"Artifacts/ThreeColor-v1/Player/Swordsman_{motion}-{color}-v1"; + Sprite[] fallback = motion switch + { + "BumpAttack-v1" => frames.Attack, + "Hurt" => frames.Hurt, + "Death" => frames.Death, + _ => Array.Empty(), + }; + BeginPlayerSequence(motion, frameSeconds, fallback); + } + + private void BeginPlayerSequence(string motion, float frameSeconds, Sprite[] fallback) + { + ArtifactColor color = world.Loadout?.CurrentArtifact?.ArtifactColor ?? ArtifactColor.Green; + string path = $"Artifacts/ThreeColor-v1/Player/Swordsman_{motion}-{color}-v1"; + Sprite[] sequence = Resources.LoadAll(path); + Array.Sort(sequence, (a, b) => FrameNumber(a.name).CompareTo(FrameNumber(b.name))); + if (sequence.Length == 0) sequence = fallback; + if (sequence.Length == 0) return; + transientFrames = sequence; + transientDurations = UniformDurations(sequence.Length, frameSeconds); + transientStart = world.GameTime; + transientDuration = Sum(transientDurations); + attackUntil = transientStart + transientDuration; + activePlayerMotion = motion; + if (motion == "Hurt") hurtUntil = attackUntil; + } + + private void StartTimedSequence(Sprite[] framesToPlay, float frameSeconds, string motion) + { + if (framesToPlay == null || framesToPlay.Length == 0) return; + transientFrames = framesToPlay; + transientDurations = UniformDurations(transientFrames.Length, frameSeconds); + transientStart = world.GameTime; + transientDuration = Sum(transientDurations); + attackUntil = transientStart + transientDuration; + activePlayerMotion = motion; + if (motion == "Hurt") hurtUntil = attackUntil; + } + + private static float[] UniformDurations(int count, float seconds) + { + float[] result = new float[count]; + Array.Fill(result, seconds); + return result; + } + + private static float Sum(float[] values) + { + float result = 0f; + foreach (float value in values) result += value; + return result; + } + + private static int FrameNumber(string name) + { + int separator = name.LastIndexOf('_'); + return separator >= 0 && int.TryParse(name.Substring(separator + 1), out int index) ? index : 0; + } + public void SetFeedbackTint(Color tint) => feedbackTint = tint; + public void ClearFeedbackTint() => feedbackTint = Color.white; + public void SetAttack(bool value) { if (value && !attacking) attackStart = world.GameTime; attacking = value; forcedFrame = -1; warning = false; } + public void SetAttackFrame(int value) { forcedFrame = value; } + public void SetWarning(bool value) => warning = value; + + private void LateUpdate() + { + if (world != null && world.isActiveAndEnabled && world.CombatFeedback != null) + { + if (!world.CombatFeedback.enabled) world.CombatFeedback.enabled = true; + else if (world.CombatFeedback.TransparentEffectMaterial == null) + world.CombatFeedback.Bind(world); + } + if (sprite == null || !sprite.enabled) return; + Quaternion facingCamera = world.View != null ? world.View.transform.rotation : Quaternion.Euler(world.Settings.CameraPitch, 0f, 0f); + sprite.transform.rotation = facingCamera; + float visualScale = scale; + if (actor != null && actor.Kind == ComparisonActorKind.Player && sprite.sprite != null + && world.View != null) + { + int pixelHeight = world.View.Camera.targetTexture != null + ? world.View.Camera.targetTexture.height : world.View.Camera.pixelHeight; + visualScale = PixelAlignedScale(scale, pixelHeight, + world.View.Camera.orthographicSize, sprite.sprite.pixelsPerUnit); + } + sprite.transform.localScale = Vector3.one * visualScale; + float authoredScale = scale > 0f ? visualScale / scale : 1f; + sprite.transform.position = FootPosition + Vector3.up * VisualHeight + - facingCamera * Vector3.up * (footOffset * authoredScale); + bool moving = (transform.position - previousPosition).sqrMagnitude > .0000001f; + previousPosition = transform.position; + if (transientFrames != null && world.GameTime - transientStart >= transientDuration) + { + transientFrames = null; + transientDurations = null; + activePlayerMotion = null; + if (hideAfterDeath) { Hide(); return; } + } + Sprite[] sequence = transientFrames != null ? transientFrames + : world.GameTime < hurtUntil && frames.Hurt.Length > 0 ? frames.Hurt + : (attacking || world.GameTime < attackUntil) && frames.Attack.Length > 0 ? frames.Attack + : moving && frames.Walk.Length > 0 ? frames.Walk : frames.Idle; + int frame = transientFrames != null ? TimedFrame(transientDurations, world.GameTime - transientStart) + : forcedFrame >= 0 && attacking ? forcedFrame + : Mathf.FloorToInt((attacking ? world.GameTime - attackStart : world.GameTime) + / (sequence == frames.Idle + ? Defense25DPlayerAnimationConstants.IdleFrameSeconds + : Defense25DPlayerAnimationConstants.WalkFrameSeconds)); + if (actor != null && actor.IsMinion && !attacking) + frame = FrameAt(moving ? summonWalkKey : summonIdleKey, world.GameTime, true); + if (sequence.Length > 0) sprite.sprite = sequence[Mathf.Max(0, frame) % sequence.Length]; + if (actor != null) sprite.flipX = actor.Facing.x < 0f; + if (actor != null && actor.Kind == ComparisonActorKind.Player && world.View != null) + sprite.transform.position += PixelGridOffset(world.View.Camera, sprite.sprite, sprite.flipX, + sprite.transform.position, sprite.transform.lossyScale); + Color baseTint = warning ? new Color(1f, .55f, .4f) + : tower && !world.TowersOperational ? new Color(.45f, .45f, .45f) : Color.white; + sprite.color = new Color(baseTint.r * feedbackTint.r, baseTint.g * feedbackTint.g, + baseTint.b * feedbackTint.b, baseTint.a * feedbackTint.a); + Material transparent = world.CombatFeedback != null + ? world.CombatFeedback.TransparentEffectMaterial : world.Settings.TransparentMaterial; + sprite.sharedMaterial = actor != null && actor.Kind == ComparisonActorKind.Player + && sprite.color.a < .999f && transparent != null + ? transparent : world.Settings.CutoutMaterial; + footprint.enabled = world.ShowBoundaries; + if (actor != null) + { + if (world.View != null) + SetCapsuleProjection(footprint, actor.BodyCollider, world.View.Camera, .015f); + else footprint.enabled = false; + } + else + { + SetEllipse(footprint, world.Settings.TowerPosition, + Vector2.one * GameplayConstants.Current.Defense.SummonAreaRadius, + world.Settings.GroundY + .02f); + } + } + + public static void SetCapsuleProjection(LineRenderer line, CapsuleCollider capsule, + Camera camera, float cameraOffset) + { + Vector3 axisDirection = capsule.direction switch + { + 0 => capsule.transform.right, + 2 => capsule.transform.forward, + _ => capsule.transform.up, + }; + Vector3 scale = capsule.transform.lossyScale; + float axialScale = capsule.direction switch + { + 0 => Mathf.Abs(scale.x), + 2 => Mathf.Abs(scale.z), + _ => Mathf.Abs(scale.y), + }; + float radialScale = capsule.direction switch + { + 0 => Mathf.Max(Mathf.Abs(scale.y), Mathf.Abs(scale.z)), + 2 => Mathf.Max(Mathf.Abs(scale.x), Mathf.Abs(scale.y)), + _ => Mathf.Max(Mathf.Abs(scale.x), Mathf.Abs(scale.z)), + }; + float radius = capsule.radius * radialScale; + float height = capsule.height * axialScale; + height = Mathf.Max(height, 2f * radius); + float halfSegment = Mathf.Max(0f, height * .5f - radius); + Vector3 segment = axisDirection.normalized * halfSegment; + Vector2 projectedAxis = new(Vector3.Dot(segment, camera.transform.right), + Vector3.Dot(segment, camera.transform.up)); + Vector2 direction = projectedAxis.sqrMagnitude > .000001f + ? projectedAxis.normalized : Vector2.up; + Vector2 perpendicular = new(-direction.y, direction.x); + Vector3 center = capsule.transform.TransformPoint(capsule.center) + - camera.transform.forward * cameraOffset; + line.positionCount = 66; + line.loop = true; + line.widthMultiplier = .03f; + for (int i = 0; i <= 32; i++) + { + float angle = -Mathf.PI * .5f + Mathf.PI * i / 32f; + Vector2 point = projectedAxis + radius + * (direction * Mathf.Cos(angle) + perpendicular * Mathf.Sin(angle)); + line.SetPosition(i, center + camera.transform.right * point.x + camera.transform.up * point.y); + } + for (int i = 0; i <= 32; i++) + { + float angle = Mathf.PI * .5f + Mathf.PI * i / 32f; + Vector2 point = -projectedAxis + radius + * (direction * Mathf.Cos(angle) + perpendicular * Mathf.Sin(angle)); + line.SetPosition(33 + i, center + camera.transform.right * point.x + camera.transform.up * point.y); + } + } + + private static int TimedFrame(float[] durations, float elapsed) + { + if (durations == null || durations.Length == 0) return 0; + for (int i = 0; i < durations.Length; i++) + { + if (elapsed < durations[i]) return i; + elapsed -= durations[i]; + } + return durations.Length - 1; + } + + } +} diff --git a/Assets/_Project/Scripts/Defense/Comparison/Defense25DSprite.cs.meta b/Assets/_Project/Scripts/Defense/Comparison/Defense25DSprite.cs.meta new file mode 100644 index 0000000..37b7565 --- /dev/null +++ b/Assets/_Project/Scripts/Defense/Comparison/Defense25DSprite.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: e0f62837c9d2482e9e3b23f43b10ee33 diff --git a/Assets/_Project/Scripts/Defense/Comparison/Defense25DStatusVisual.cs b/Assets/_Project/Scripts/Defense/Comparison/Defense25DStatusVisual.cs new file mode 100644 index 0000000..2e85f10 --- /dev/null +++ b/Assets/_Project/Scripts/Defense/Comparison/Defense25DStatusVisual.cs @@ -0,0 +1,344 @@ +using System; +using System.Collections.Generic; +using BumpCombat.Combat; +using BumpCombat.Enemies; +using UnityEngine; + +namespace BumpCombat.Defense.Comparison +{ + [DefaultExecutionOrder(1000)] + public sealed class Defense25DStatusVisual : MonoBehaviour + { + public const int StatusSortingOffset = 1; + public const float StatusVisualAnchorOffsetY = -13f / 32f; + + private const int FrameCount = 6; + private const float PixelsPerUnit = 32f; + private const float StunMarkerGap = 4f / 32f; + private const string IgniteResourcePath = "Artifacts/ThreeColor-v1/StatusEffects/Ignite-Warlock-v1"; + private const string ShockResourcePath = "Artifacts/ThreeColor-v1/StatusEffects/Shock-Warlock-v1"; + + private static readonly float[] IgniteFrameDurations = { .1f, .1f, .1f, .1f, .1f, .1f }; + private static readonly float[] ShockFrameDurations = { .06f, .04f, .07f, .05f, .07f, .11f }; + private sealed class CachedFrames + { + public CachedFrames(Sprite[] frames, bool ownsSprites) + { + Frames = frames; + OwnsSprites = ownsSprites; + } + + public Sprite[] Frames { get; } + public bool OwnsSprites { get; } + } + + private static readonly Dictionary FrameCache = new(); + + private sealed class StatusSlot + { + public StatusSlot(string resourcePath, string objectName, float[] frameDurations) + { + ResourcePath = resourcePath; + ObjectName = objectName; + FrameDurations = frameDurations; + } + + public string ResourcePath { get; } + public string ObjectName { get; } + public float[] FrameDurations { get; } + public Sprite[] Frames; + public GameObject Root; + public SpriteRenderer Renderer; + public int FrameIndex; + public float FrameElapsed; + } + + private readonly StatusSlot ignite = new( + IgniteResourcePath, "Ignite Status Visual", IgniteFrameDurations); + private readonly StatusSlot shock = new( + ShockResourcePath, "Shock Status Visual", ShockFrameDurations); + + private Defense25DEnemy enemy; + private Defense25DComparison owner; + private GameObject stunRoot; + private SpriteRenderer stunRenderer; + private float stunElapsed; + private bool stunActive; + private int lastStunVisualRevision; + private Defense25DSprite tintedVisual; + + [RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.SubsystemRegistration)] + private static void ResetFrameCache() + { + foreach (CachedFrames cached in FrameCache.Values) + { + if (!cached.OwnsSprites) continue; + foreach (Sprite sprite in cached.Frames) + { + if (sprite == null) continue; + if (Application.isPlaying) Destroy(sprite); + else DestroyImmediate(sprite); + } + } + + FrameCache.Clear(); + } + + public void Bind(Defense25DEnemy value) + { + if (enemy == value) return; + ClearSlot(ignite); + ClearSlot(shock); + ClearStunMarker(); + ClearStunTint(); + enemy = value; + owner = enemy != null ? enemy.GetComponentInParent() : null; + lastStunVisualRevision = enemy != null ? enemy.StunVisualRevision : 0; + } + + private void OnDisable() + { + ClearSlot(ignite); + ClearSlot(shock); + ClearStunMarker(); + ClearStunTint(); + } + + private void LateUpdate() + { + SpriteRenderer sourceRenderer = enemy != null && enemy.Actor != null + && enemy.Actor.Visual != null ? enemy.Actor.Visual.Renderer : null; + if (enemy == null || !enemy.isActiveAndEnabled || owner == null + || !owner.isActiveAndEnabled || !owner.IsRunning || enemy.Actor == null + || !enemy.Actor.IsAlive || sourceRenderer == null || !sourceRenderer.enabled) + { + ClearSlot(ignite); + ClearSlot(shock); + ClearStunMarker(); + ClearStunTint(); + return; + } + + float deltaTime = owner.Paused ? 0f : Time.deltaTime; + UpdateSlot(ignite, enemy.IsIgnited, sourceRenderer, deltaTime); + UpdateSlot(shock, enemy.IsShocked, sourceRenderer, deltaTime); + UpdateStunMarker(sourceRenderer, deltaTime); + } + + private void UpdateStunMarker(SpriteRenderer sourceRenderer, float deltaTime) + { + bool active = enemy.IsStunned; + bool refreshed = enemy.StunVisualRevision != lastStunVisualRevision; + lastStunVisualRevision = enemy.StunVisualRevision; + if (!active) + { + ClearStunMarker(); + ClearStunTint(); + return; + } + + if (stunRoot == null || !stunActive || refreshed) + { + ClearStunMarker(); + CreateStunMarker(sourceRenderer); + ApplyStunTint(); + } + + stunActive = true; + stunElapsed += deltaTime; + Transform actorTransform = enemy.Actor.transform; + stunRoot.transform.localPosition = CalculateLocalStunMarkerPosition( + actorTransform, sourceRenderer); + stunRoot.transform.localRotation = Quaternion.Inverse(actorTransform.rotation) + * sourceRenderer.transform.rotation; + stunRoot.transform.localScale = CombatFeedback.CalculateStunMarkerBaseLocalScale( + actorTransform.lossyScale); + stunRenderer.sprite = CrowdControlMarkerArt.Frame(stunElapsed, false); + stunRenderer.sortingLayerID = sourceRenderer.sortingLayerID; + stunRenderer.sortingOrder = CombatFeedback.CalculateStunMarkerSortingOrder( + sourceRenderer.sortingOrder); + stunRenderer.forceRenderingOff = sourceRenderer.forceRenderingOff; + stunRenderer.enabled = sourceRenderer.enabled; + stunRenderer.sharedMaterial = sourceRenderer.sharedMaterial; + stunRenderer.color = new Color(1f, 1f, 1f, sourceRenderer.color.a); + } + + private void CreateStunMarker(SpriteRenderer sourceRenderer) + { + Transform actorTransform = enemy.Actor.transform; + stunRoot = new GameObject("Stun Runes"); + stunRoot.transform.SetParent(actorTransform, false); + stunRoot.transform.localPosition = CalculateLocalStunMarkerPosition( + actorTransform, sourceRenderer); + stunRoot.transform.localScale = CombatFeedback.CalculateStunMarkerBaseLocalScale( + actorTransform.lossyScale); + stunRenderer = stunRoot.AddComponent(); + stunRenderer.sprite = CrowdControlMarkerArt.Frame(0f, false); + stunRenderer.color = Color.white; + stunRenderer.sharedMaterial = sourceRenderer.sharedMaterial; + stunElapsed = 0f; + stunActive = true; + } + + private static Vector3 CalculateLocalStunMarkerPosition( + Transform actorTransform, SpriteRenderer sourceRenderer) + { + Transform bodyTransform = sourceRenderer.transform; + Vector3 head = bodyTransform.TransformPoint(new Vector3( + 0f, CrowdControlMarkerArt.HeadLocalHeight(sourceRenderer.sprite), 0f)); + Vector3 markerPosition = head + bodyTransform.up * (StunMarkerGap + + CrowdControlMarkerArt.MarkerHalfHeight + * Mathf.Abs(bodyTransform.lossyScale.y)); + return actorTransform.InverseTransformPoint(markerPosition); + } + + private void ApplyStunTint() + { + tintedVisual = enemy.Actor.Visual; + if (tintedVisual != null) + tintedVisual.SetFeedbackTint(Color.Lerp(Color.white, new Color(.72f, .42f, 1f), .3f)); + } + + private void ClearStunTint() + { + if (tintedVisual != null) tintedVisual.ClearFeedbackTint(); + tintedVisual = null; + } + + private void ClearStunMarker() + { + if (stunRoot != null) + { + stunRoot.SetActive(false); + Destroy(stunRoot); + } + + stunRoot = null; + stunRenderer = null; + stunElapsed = 0f; + stunActive = false; + } + + private void UpdateSlot( + StatusSlot slot, bool active, SpriteRenderer sourceRenderer, float deltaTime) + { + if (!active) + { + ClearSlot(slot); + return; + } + + if (slot.Root == null) + { + slot.Frames ??= LoadFrames(slot.ResourcePath); + if (slot.Frames.Length == 0) return; + CreateSlot(slot); + } + + SyncSlot(slot, sourceRenderer); + AdvanceSlot(slot, deltaTime); + } + + private static void CreateSlot(StatusSlot slot) + { + slot.Root = new GameObject(slot.ObjectName); + slot.Renderer = slot.Root.AddComponent(); + slot.FrameIndex = 0; + slot.FrameElapsed = 0f; + slot.Renderer.sprite = slot.Frames[0]; + } + + private static void SyncSlot(StatusSlot slot, SpriteRenderer sourceRenderer) + { + if (slot.Root == null || slot.Renderer == null) return; + + Transform sourceTransform = sourceRenderer.transform; + if (slot.Root.transform.parent != sourceTransform) + slot.Root.transform.SetParent(sourceTransform, false); + + slot.Root.transform.localPosition = new Vector3( + 0f, sourceRenderer.flipY ? -StatusVisualAnchorOffsetY : StatusVisualAnchorOffsetY, 0f); + slot.Root.transform.localRotation = Quaternion.identity; + slot.Root.transform.localScale = Vector3.one; + slot.Renderer.sortingLayerID = sourceRenderer.sortingLayerID; + slot.Renderer.sortingOrder = sourceRenderer.sortingOrder + StatusSortingOffset; + slot.Renderer.forceRenderingOff = sourceRenderer.forceRenderingOff; + slot.Renderer.flipX = sourceRenderer.flipX; + slot.Renderer.flipY = sourceRenderer.flipY; + slot.Renderer.sharedMaterial = sourceRenderer.sharedMaterial; + slot.Renderer.color = new Color(1f, 1f, 1f, sourceRenderer.color.a); + slot.Renderer.enabled = sourceRenderer.enabled; + } + + private static void AdvanceSlot(StatusSlot slot, float deltaTime) + { + if (slot.Renderer == null || slot.Frames == null + || slot.Frames.Length == 0 || deltaTime <= 0f) return; + + slot.FrameElapsed += deltaTime; + while (slot.FrameElapsed >= slot.FrameDurations[slot.FrameIndex]) + { + slot.FrameElapsed -= slot.FrameDurations[slot.FrameIndex]; + slot.FrameIndex = (slot.FrameIndex + 1) % slot.Frames.Length; + slot.Renderer.sprite = slot.Frames[slot.FrameIndex]; + } + } + + private static void ClearSlot(StatusSlot slot) + { + if (slot.Root != null) + { + slot.Root.SetActive(false); + Destroy(slot.Root); + } + + slot.Root = null; + slot.Renderer = null; + slot.FrameIndex = 0; + slot.FrameElapsed = 0f; + } + + private static Sprite[] LoadFrames(string resourcePath) + { + if (FrameCache.TryGetValue(resourcePath, out CachedFrames cached)) return cached.Frames; + + Sprite[] importedFrames = Resources.LoadAll(resourcePath); + if (importedFrames != null && importedFrames.Length >= FrameCount) + { + Sprite[] frames = new Sprite[FrameCount]; + Array.Copy(importedFrames, frames, FrameCount); + FrameCache[resourcePath] = new CachedFrames(frames, false); + return frames; + } + + Texture2D texture = importedFrames != null && importedFrames.Length > 0 + ? importedFrames[0].texture + : Resources.Load(resourcePath); + if (texture == null || texture.width < FrameCount + || texture.width % FrameCount != 0 || texture.height <= 0) + { + Sprite[] missing = Array.Empty(); + FrameCache[resourcePath] = new CachedFrames(missing, false); + return missing; + } + + int frameWidth = texture.width / FrameCount; + Sprite[] generatedFrames = new Sprite[FrameCount]; + for (int i = 0; i < FrameCount; i++) + { + generatedFrames[i] = Sprite.Create( + texture, + new Rect(i * frameWidth, 0f, frameWidth, texture.height), + new Vector2(.5f, .125f), + PixelsPerUnit, + 0, + SpriteMeshType.FullRect); + generatedFrames[i].name = $"{resourcePath} Frame {i + 1}"; + generatedFrames[i].hideFlags = HideFlags.HideAndDontSave; + } + + FrameCache[resourcePath] = new CachedFrames(generatedFrames, true); + return generatedFrames; + } + } +} diff --git a/Assets/_Project/Scripts/Defense/Comparison/Defense25DStatusVisual.cs.meta b/Assets/_Project/Scripts/Defense/Comparison/Defense25DStatusVisual.cs.meta new file mode 100644 index 0000000..333b516 --- /dev/null +++ b/Assets/_Project/Scripts/Defense/Comparison/Defense25DStatusVisual.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: e0877869c1f6490a945d49e8fc16fead diff --git a/Assets/_Project/Scripts/Defense/Comparison/Defense25DSummon.cs b/Assets/_Project/Scripts/Defense/Comparison/Defense25DSummon.cs new file mode 100644 index 0000000..0424699 --- /dev/null +++ b/Assets/_Project/Scripts/Defense/Comparison/Defense25DSummon.cs @@ -0,0 +1,159 @@ +using BumpCombat.Constants; +using BumpCombat.Defense; +using UnityEngine; + +namespace BumpCombat.Defense.Comparison +{ + public sealed class Defense25DSummon : MonoBehaviour + { + private Defense25DComparison world; + private Defense25DTower tower; + private Vector2 anchor; + private Defense25DEnemy target; + private float attackElapsed, nextAttack, respawnRemaining; + private bool attacking, hit, returning; + public Defense25DActor Actor { get; private set; } + public int SlotIndex { get; private set; } + public Defense25DTower Owner => tower; + public int AttackEvents { get; private set; } + public int LastDamageFrame { get; private set; } = -1; + public int Respawns { get; private set; } + public bool IsAttacking => attacking; + public bool IsReturningToAnchor => returning; + public float RespawnRemaining => tower != null ? tower.SummonRespawnRemaining : respawnRemaining; + private DefenseConstants Tuning => GameplayConstants.Current.Defense; + + public void Configure(Defense25DComparison owner, Defense25DActor actor, + Vector2 slot, Defense25DTower towerOwner = null, int slotIndex = -1) + { + world = owner; + Actor = actor; + anchor = slot; + tower = towerOwner; + SlotIndex = slotIndex; + if (tower != null) Actor.Visual.ConfigureSummonArt(tower.Tier, tower.Branch); + } + + public void ResetForRun() + { + SuspendAttack(); + Actor.ResetAt(anchor); + AttackEvents = Respawns = 0; + LastDamageFrame = -1; + nextAttack = respawnRemaining = 0f; + } + + public void BeginRespawn() + { + SuspendAttack(); + if (tower != null) tower.NotifySummonUnitDied(this); + else respawnRemaining = Tuning.SummonUnitRespawnSeconds; + } + + public void SuspendAttack() + { + attacking = hit = returning = false; + target = null; + attackElapsed = 0f; + Actor.Visual.SetAttack(false); + } + + public void Step(float dt) + { + if (!world.TowersOperational || (tower != null && !tower.isActiveAndEnabled)) + { SuspendAttack(); return; } + if (!Actor.IsAlive) + { + if (tower != null) return; + respawnRemaining = Mathf.Max(0f, respawnRemaining - dt); + if (respawnRemaining <= 0f) { Actor.ResetAt(anchor); Respawns++; } + return; + } + if (Actor.HasKnockback) { Actor.Move(Actor.ConsumeKnockback(dt)); return; } + if (attacking) + { + Defense25DEnemy commandTarget = tower != null ? tower.CommandTarget : world.CommandTarget; + if (target == null || !target.IsAvailable || target != commandTarget) + { FinishAttack(); return; } + attackElapsed += dt; + string attackKey = GetAttackKey(); + int frame = Defense25DSprite.FrameAt(attackKey, attackElapsed, false); + Actor.Visual.SetAttackFrame(frame); + bool scout = tower != null && tower.Tier == 4 + && tower.Branch == DefenseTowerBranch.Second; + bool basicSlime = tower == null || tower.Tier < 4; + if (frame >= Tuning.SummonBodySlamImpactFrame && !hit && basicSlime) + { + Vector2 direction = (target.Actor.Center - Actor.Center).normalized; + Actor.Facing = direction; + Actor.Move(direction * (Actor.MoveSpeed * Tuning.SummonBodySlamSpeedMultiplier * dt), false); + } + if (!hit && frame >= Tuning.SummonBodySlamImpactFrame + && (scout || !basicSlime + || Actor.GapTo(target.Actor) <= Tuning.SummonBodySlamContactMargin)) + { + hit = true; + bool landed = tower != null + ? tower.TryAttackSummon(this, target) + : target.Actor.TakeDamage(Actor.CalculateAttackDamage(Tuning.SummonDamage) + * world.TowerEfficiency, (target.Actor.Center - Actor.Center).normalized); + if (landed) + { + AttackEvents++; + LastDamageFrame = frame; + } + } + if (attackElapsed >= Defense25DSprite.Duration(attackKey)) FinishAttack(); + return; + } + Defense25DEnemy activeTarget = tower != null ? tower.CommandTarget : world.CommandTarget; + target = activeTarget; + if (returning || target == null || !target.IsAvailable) + { + Vector2 delta = anchor - Actor.Position; + Actor.Move(Vector2.ClampMagnitude(delta, Actor.MoveSpeed * dt)); + if (returning && (delta.magnitude <= .08f || Actor.MovementBlocked)) + returning = false; + return; + } + Vector2 previousPosition = Actor.Position; + Vector2 toward = target.Actor.Position - previousPosition; + Actor.Facing = toward.normalized; + bool isScout = tower != null && tower.Tier == 4 + && tower.Branch == DefenseTowerBranch.Second; + float range = Tuning.SummonAttackRange * (isScout ? 2.5f : 1f); + Vector2 nextPosition = previousPosition; + if (toward.magnitude > range * .8f) + nextPosition += Actor.Move(toward.normalized * Mathf.Min( + toward.magnitude - range * .8f, Actor.MoveSpeed * dt)); + if (world.GameTime >= nextAttack + && (target.Actor.Position - nextPosition).sqrMagnitude <= range * range) + { + attacking = true; + hit = false; + attackElapsed = 0f; + int tier = tower != null ? tower.Tier : 1; + nextAttack = world.GameTime + Tuning.SummonAttackInterval + / (1f + .15f * (tier - 1)) + * (tower != null ? tower.AttackIntervalMultiplier : 1f); + Actor.Visual.SetAttack(true); + } + } + + private void FinishAttack() + { + attacking = false; + returning = tower == null || tower.Tier < 4; + target = null; + Actor.Visual.SetAttack(false); + } + + private string GetAttackKey() + { + string family = tower != null && tower.Tier == 4 + ? tower.Branch == DefenseTowerBranch.First ? "GiantSlime" : "LavaSlime" + : "Slime"; + return $"Summons/{family}/Attack01"; + } + } +} diff --git a/Assets/_Project/Scripts/Defense/Comparison/Defense25DSummon.cs.meta b/Assets/_Project/Scripts/Defense/Comparison/Defense25DSummon.cs.meta new file mode 100644 index 0000000..43a16b8 --- /dev/null +++ b/Assets/_Project/Scripts/Defense/Comparison/Defense25DSummon.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 397309e5e32f429fb6903a42040ba89e diff --git a/Assets/_Project/Scripts/Defense/Comparison/Defense25DTower.cs b/Assets/_Project/Scripts/Defense/Comparison/Defense25DTower.cs new file mode 100644 index 0000000..057d53a --- /dev/null +++ b/Assets/_Project/Scripts/Defense/Comparison/Defense25DTower.cs @@ -0,0 +1,194 @@ +using BumpCombat.Constants; +using BumpCombat.Defense; +using UnityEngine; + +namespace BumpCombat.Defense.Comparison +{ + public sealed partial class Defense25DTower : MonoBehaviour + { + private Defense25DTowerManager manager; + private Defense25DComparison world; + private DefenseConstants Tuning => GameplayConstants.Current.Defense; + private SpriteRenderer body; + private Sprite[] idleFrames; + private Sprite[] attackFrames; + private float cooldown; + private float supportAuraStrength; + + public DefenseTowerKind Kind { get; private set; } + public DefenseTowerBranch Branch { get; private set; } + public int Tier { get; private set; } + public int PadIndex { get; private set; } + public Vector2 Position { get; private set; } + public Color HitFeedbackColor => Kind switch + { + DefenseTowerKind.Direct => new Color32(240, 96, 65, 255), + DefenseTowerKind.Summon => new Color32(244, 154, 74, 255), + DefenseTowerKind.Lightning => new Color32(238, 218, 83, 255), + DefenseTowerKind.Cold => new Color32(65, 211, 240, 255), + DefenseTowerKind.Seismic => new Color32(175, 141, 100, 255), + _ => new Color32(148, 228, 122, 255), + }; + public float SightEfficiency => DefensePrototypeRules.GetTowerEfficiency( + Vector2.Distance(world.Player.Position, Position), Tuning.PlayerSightRadius, + 1f, Tuning.OutsideSightDamageEfficiency); + + public void Configure(Defense25DTowerManager owner, Defense25DComparison run, + int padIndex, DefenseTowerKind kind, Vector2 position) + { + manager = owner; + world = run; + PadIndex = padIndex; + Kind = kind; + Position = position; + Tier = 1; + Branch = DefenseTowerBranch.None; + transform.position = Defense25DActor.OnGround(position, world.Settings.GroundY); + CreateVisual(); + InitializeSummons(); + } + + public bool TryUpgrade(DefenseTowerBranch branch) + { + if (Tier >= 4 || (Tier < 3 && branch != DefenseTowerBranch.None) + || (Tier == 3 && branch is not (DefenseTowerBranch.First or DefenseTowerBranch.Second))) return false; + CancelPending(); + Tier++; + if (Tier == 4) Branch = branch; + ReplaceSummonsForUpgrade(); + RefreshVisual(); + return true; + } + + public void Step(float dt) + { + if (!world.TowersOperational) return; + StepBodyAnimation(dt); + if (Kind == DefenseTowerKind.Empowering) + return; + if (Kind == DefenseTowerKind.Summon) + { + StepSummons(dt); + StepParityAttacks(dt); + return; + } + StepParityAttacks(dt); + } + + public void CancelPending() + { + CancelParityAttacks(); + cooldown = 0f; + ResetBodyAnimation(); + foreach (Defense25DSummon summon in ownedSummons) + if (summon != null) summon.SuspendAttack(); + } + + private void OnDisable() + { + CancelPending(); + DestroySummonsForRunReset(); + } + + private bool ApplyDamage(Defense25DEnemy enemy, float damage) + { + if (enemy == null || !enemy.IsAvailable || !world.IsTowerShowcaseTarget(Kind, enemy) + || world.IsTowerImmuneToEnemy(Kind, enemy)) return false; + Vector2 direction = (enemy.Actor.Position - Position).normalized; + float multiplier = 1f; + foreach (Defense25DTower support in manager.TowersInConstructionOrder) + { + if (support == null || support == this || support.Kind != DefenseTowerKind.Empowering) continue; + float efficiency = DefensePrototypeRules.GetTowerEfficiency( + Vector2.Distance(world.Player.Position, support.Position), Tuning.PlayerSightRadius, + 1f, Tuning.OutsideSightDamageEfficiency); + if (Vector2.Distance(support.Position, Position) <= Tuning.SupportRadius) + multiplier = Mathf.Max(multiplier, + 1f + Tuning.SupportDamageBonusPerTier * support.Tier * efficiency); + if (support.Tier == 4 && support.Branch == DefenseTowerBranch.Second + && Vector2.Distance(support.Position, enemy.Actor.Position) <= Tuning.SupportRadius) + multiplier *= 1f + Tuning.SmotheringDamageBonus * efficiency; + } + multiplier *= GetBattleBatonDamageMultiplier(); + bool hit = enemy.Actor.TakeDamageWithResult(damage * multiplier * SightEfficiency, direction, + 0f, BumpCombat.Enemies.EnemyDamageSource.Tower, false, out float actualDamage); + if (actualDamage > 0f) + { + world.CombatFeedback?.NotifyTowerImpact(Kind, enemy, + enemy.Actor.ClosestGroundPoint(Position)); + world.RecordTowerDamagePopup(actualDamage, HitFeedbackColor, + enemy.Actor.transform.position + Vector3.up * .7f); + } + return hit; + } + + public void ResetSupport() + { + supportInterval = 1f; + supportIntervalSet = false; + supportAuraStrength = 0f; + SetBodySupportAuraTier(0, 0f); + } + + public void UpdateSupport() + { + if (Kind != DefenseTowerKind.Empowering || !world.TowersOperational) return; + foreach (Defense25DTower tower in manager.TowersInConstructionOrder) + { + if (tower == null || tower == this || Vector2.Distance(Position, tower.Position) > Tuning.SupportRadius) continue; + tower.SetSupport(Tier == 4 && Branch == DefenseTowerBranch.First + ? Tuning.ImbuingAttackIntervalMultiplier : 1f, + SightEfficiency, Tier, Tier * SightEfficiency); + } + } + + private float supportInterval = 1f; + private bool supportIntervalSet; + private void SetSupport(float interval, float efficiency, int supportTier, float strength) + { + if (Tuning.SupportDamageBonusPerTier * strength > 0f && strength > supportAuraStrength) + { + supportAuraStrength = strength; + SetBodySupportAuraTier(supportTier, strength); + } + if (interval >= 1f || supportIntervalSet) return; + supportInterval = 1f - (1f - interval) * efficiency; + supportIntervalSet = true; + } + + private float GetAttackIntervalMultiplier() => supportInterval; + public float AttackIntervalMultiplier => GetAttackIntervalMultiplier(); + + private void PlayAttack(Defense25DEnemy target) + { + StartBodyAttack(target.Actor.Position.x < Position.x); + } + + private void CreateVisual() + { + GameObject visual = new("Tower billboard"); + visual.transform.SetParent(transform, false); + body = visual.AddComponent(); + body.sharedMaterial = world.Settings.CutoutMaterial; + RefreshVisual(); + } + + private void RefreshVisual() + { + ConfigureBodyAnimation(); + } + + private void LateUpdate() + { + if (body == null) return; + Quaternion facing = world.View != null ? world.View.transform.rotation + : Quaternion.Euler(world.Settings.CameraPitch, 0f, 0f); + body.transform.rotation = facing; + body.transform.position = transform.position + Vector3.up * .45f - facing * Vector3.up * (-9f / 32f); + body.transform.localScale = Vector3.one * DefenseTowerArtBounds.TowerBillboardScale; + if (world.TowersOperational) body.color = Color.white; + else body.color = new Color(.45f, .45f, .45f); + UpdateBodySupportAuraVisual(); + } + } +} diff --git a/Assets/_Project/Scripts/Defense/Comparison/Defense25DTower.cs.meta b/Assets/_Project/Scripts/Defense/Comparison/Defense25DTower.cs.meta new file mode 100644 index 0000000..a5da043 --- /dev/null +++ b/Assets/_Project/Scripts/Defense/Comparison/Defense25DTower.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 82f31b61a3df4e2bb0e8a828184f7527 diff --git a/Assets/_Project/Scripts/Defense/Comparison/Defense25DTowerAttacks.cs b/Assets/_Project/Scripts/Defense/Comparison/Defense25DTowerAttacks.cs new file mode 100644 index 0000000..717d8d7 --- /dev/null +++ b/Assets/_Project/Scripts/Defense/Comparison/Defense25DTowerAttacks.cs @@ -0,0 +1,820 @@ +using System; +using System.Collections.Generic; +using BumpCombat.Constants; +using BumpCombat.Defense; +using UnityEngine; + +namespace BumpCombat.Defense.Comparison +{ + public sealed partial class Defense25DTower + { + private sealed class ParityFlight + { + public float Delay; + public float Elapsed; + public float Duration; + public float StartHeightOffset; + public Vector2 Start; + public Vector2 End; + public Defense25DEnemy Target; + public Action Impact; + public GameObject Root; + public DefenseSpriteSequence Sequence; + public bool TrackTarget; + public bool HideAtImpact; + public Func VisualStartProvider; + } + + private sealed class CenteredImpactWave + { + public SpriteRenderer Renderer; + public Vector2 Center; + public float Scale; + } + + private readonly List parityFlights = new(); + private readonly List parityEffects = new(); + private readonly HashSet parityUniqueTargets = new(); + private readonly List centeredImpactWaves = new(); + private string flameBeamKey = "Effects/Fire/Eyeball Monster_Beam-Clean"; + private SpriteRenderer flameBeamRenderer; + private GameObject flameBeamRoot; + private Defense25DEnemy flameBeamTarget; + private float flameStreakStartedAt; + private float flameLastSuccessfulAt; + private bool flameStreakActive; + private bool showcaseBodyWasEnabled; + private readonly List showcaseSuspendedObjects = new(); + private readonly List showcaseObjectActiveStates = new(); + + public void SetShowcaseSuspended(bool suspended) + { + if (manager == null || manager.IsSuspended != suspended) return; + if (suspended) + { + showcaseBodyWasEnabled = body != null && body.enabled; + if (body != null) body.enabled = false; + showcaseSuspendedObjects.Clear(); + showcaseObjectActiveStates.Clear(); + foreach (Defense25DSummon summon in ownedSummons) + RememberShowcaseObject(summon != null ? summon.gameObject : null); + foreach (GameObject effect in parityEffects) + RememberShowcaseObject(effect); + } + else + { + for (int i = 0; i < showcaseSuspendedObjects.Count; i++) + if (showcaseSuspendedObjects[i] != null) + showcaseSuspendedObjects[i].SetActive(showcaseObjectActiveStates[i]); + foreach (Defense25DSummon summon in ownedSummons) + if (summon != null && summon.gameObject.activeSelf) + summon.Actor.RestoreLiveCollisionAndVisuals(); + if (body != null) body.enabled = showcaseBodyWasEnabled; + showcaseSuspendedObjects.Clear(); + showcaseObjectActiveStates.Clear(); + } + } + + private void RememberShowcaseObject(GameObject instance) + { + if (instance == null || showcaseSuspendedObjects.Contains(instance)) return; + showcaseSuspendedObjects.Add(instance); + showcaseObjectActiveStates.Add(instance.activeSelf); + instance.SetActive(false); + } + + public void StepParityAttacks(float dt) + { + if (!world.TowersOperational) return; + AdvanceParityFlights(dt); + if (Kind is DefenseTowerKind.Empowering or DefenseTowerKind.Summon) return; + + if (Kind == DefenseTowerKind.Direct && Tier == 4 + && Branch == DefenseTowerBranch.Second) + { + StepParityFlamethrower(dt); + return; + } + + cooldown -= dt; + if (cooldown > 0f) return; + + Defense25DEnemy target = FindParityTarget( + Kind is DefenseTowerKind.Cold or DefenseTowerKind.Seismic, + GetParityRange(), preserveTarget: false); + if (target == null) return; + + var previousBodyAnimation = CaptureBodyAnimationState(); + PlayAttack(target); + bool fired = Kind switch + { + DefenseTowerKind.Direct => FireParityDirect(target), + DefenseTowerKind.Lightning => FireParityLightning(target), + DefenseTowerKind.Cold => FireParityCold(target), + DefenseTowerKind.Seismic => FireParitySeismic(target), + _ => false, + }; + if (!fired) + { + RestoreBodyAnimationState(previousBodyAnimation); + return; + } + + float interval = Kind switch + { + DefenseTowerKind.Direct => Tuning.DirectTowerAttackInterval, + DefenseTowerKind.Lightning => Tuning.LightningInterval, + DefenseTowerKind.Cold => Tuning.ColdInterval, + _ => Tuning.SeismicInterval, + }; + if (Tier == 4 && Kind == DefenseTowerKind.Direct + && Branch == DefenseTowerBranch.Second) interval *= 0.4f; + cooldown = Mathf.Max(0.05f, + interval / (1f + 0.15f * (Tier - 1)) * GetAttackIntervalMultiplier()); + } + + public void CancelParityAttacks() + { + parityFlights.Clear(); + centeredImpactWaves.Clear(); + for (int i = 0; i < parityEffects.Count; i++) + if (parityEffects[i] != null) Destroy(parityEffects[i]); + parityEffects.Clear(); + if (flameBeamRoot != null) Destroy(flameBeamRoot); + flameBeamRoot = null; + flameBeamRenderer = null; + flameBeamTarget = null; + flameStreakActive = false; + flameStreakStartedAt = flameLastSuccessfulAt = 0f; + cooldown = 0f; + } + + private bool FireParityDirect(Defense25DEnemy target) + { + float scale = GetParityDamageScale(); + if (Tier == 4 && Branch == DefenseTowerBranch.First) + { + Vector2 destination = target.Actor.Position; + Vector2 start = destination + new Vector2(-0.81f, 0.81f); + CreateParityFlight("Effects/Fire/Meteor-Flight", start, destination, + 0f, 0.32f, target, true, + impact => + { + if (!CanParityImpact(impact)) return; + ApplyParityArea(impact.Actor.Position, 1.8f, + Tuning.DirectTowerDamage * scale * 1.6f); + CreateParityRadius(impact.Actor.Position, 1.8f, impact, .22f, .9f); + SpriteRenderer hit = CreateParitySprite("Effects/Fire/Meteor-Impact", impact.Actor.Position, + Vector2.one, 0f, 13, destroy: true); + SetParityHitSorting(hit, impact); + }); + } + else + { + Vector3 visualStart = ParityStaffTipVisualWorldPosition; + Vector2 start = Defense25DActor.Ground(visualStart); + string key = $"Effects/Fire/T{Tier}-Fireball"; + CreateParityFlight(key, start, target.Actor.Position, 0f, 0.18f, target, true, + impact => + { + if (!CanParityImpact(impact)) return; + ApplyDamage(impact, Tuning.DirectTowerDamage * scale); + SpriteRenderer hit = CreateParitySprite("Effects/Fire/Impact", impact.Actor.Position, + Vector2.one, 0f, 13, destroy: true); + SetParityHitSorting(hit, impact); + }, visualStart: visualStart); + } + return true; + } + + private bool FireParityLightning(Defense25DEnemy target) + { + Vector2 center = target.Actor.Position; + Vector2 visualCenter = GetParityShadowPosition(target); + float radius = Tier == 4 + ? Branch == DefenseTowerBranch.First ? 3f : 2.4f + : Tuning.LightningRadius; + float damage = Tuning.LightningDamage * GetParityDamageScale() + * (Tier == 4 && Branch == DefenseTowerBranch.Second ? 1.4f : 1f); + int maxTargets = Tier == 4 && Branch == DefenseTowerBranch.First ? 3 : int.MaxValue; + if (!ApplyParityArea(center, radius, damage, maxTargets)) return false; + CreateParityRadius(visualCenter, radius, target, .22f, .9f); + + string key = Tier == 4 && Branch == DefenseTowerBranch.Second + ? "Effects/Lightning/LightningStorm" + : Tier == 4 ? "Effects/Lightning/Arc" : $"Effects/Lightning/T{Tier}-Bolt"; + GameObject root = CreateParityRoot("Lightning Attack Effect"); + if (Tier == 4 && Branch == DefenseTowerBranch.Second) + { + SpriteRenderer storm = CreateParitySprite(root.transform, key, visualCenter, + Vector2.one, 0f, 13, destroy: true); + SetParityHitSorting(storm, target); + FitParityStorm(storm, visualCenter, radius); + } + else + { + Vector2 origin = GetParityStaffTip(); + SpriteRenderer bolt = CreateParitySprite(root.transform, key, + Vector2.Lerp(origin, visualCenter, .5f), Vector2.one, + GroundAngle(origin, visualCenter), 14, destroy: true); + SetParityHitSorting(bolt, target); + FitParityBeam(bolt, origin, visualCenter, 1f); + } + if (Tier == 4) + { + float waveScale = Mathf.Max(0.8f, radius / 1.4f); + SpriteRenderer wave = CreateParitySprite(root.transform, "Effects/Lightning/ImpactWave", visualCenter, + Vector2.one * waveScale, 0f, 13, destroy: true, startDelay: 0.15f); + SetParityHitSorting(wave, target); + } + return true; + } + + private bool FireParityCold(Defense25DEnemy target) + { + float damageScale = GetParityDamageScale(); + Vector3 visualStart = ParityStaffTipVisualWorldPosition; + Vector2 start = Defense25DActor.Ground(visualStart); + if (Tier == 4 && Branch == DefenseTowerBranch.Second) + { + Vector2 center = target.Actor.Position; + Vector2 shadowCenter = GetParityShadowPosition(target); + CreateParityRadius(shadowCenter, 1.65f, target, + DefenseTowerArtBounds.BlizzardLastLandingSeconds + .22f, + DefenseTowerArtBounds.BlizzardImpactRadiusAlpha); + for (int i = 0; i < DefenseTowerArtBounds.BlizzardShardCount; i++) + { + float launchDelay = i * DefenseTowerArtBounds.BlizzardLaunchIntervalSeconds; + Vector2 landing = shadowCenter + DefenseTowerArtBounds.BlizzardLandingOffset(i, 1.65f); + float height = DefenseTowerArtBounds.BlizzardDropHeight + + i % 4 * DefenseTowerArtBounds.BlizzardDropHeightStep; + float rotation = 90f + (i % 5 - 2) * 7f; + Vector2 shardStart = landing + Vector2.up * height; + SpriteRenderer wave = CreateParitySprite("Effects/Ice/Blizzard-Wave-v1", landing, + Vector2.one * DefenseTowerArtBounds.BlizzardWaveScale, 0f, 13, + destroy: true, startDelay: launchDelay + DefenseTowerArtBounds.BlizzardLandingSeconds); + SetParityHitSorting(wave, target); + centeredImpactWaves.Add(new CenteredImpactWave + { + Renderer = wave, + Center = landing, + Scale = DefenseTowerArtBounds.BlizzardWaveScale, + }); + SpriteRenderer impact = CreateParitySprite("Effects/Ice/Impact", landing, + Vector2.one * DefenseTowerArtBounds.BlizzardImpactScale, 0f, 14, + destroy: true, startDelay: launchDelay + DefenseTowerArtBounds.BlizzardLandingSeconds); + SetParityHitSorting(impact, target); + CreateParityFlight("Effects/Ice/Shard-Blizzard-v1", shardStart, landing, + launchDelay, DefenseTowerArtBounds.BlizzardLandingSeconds, target, false, + _ => ApplyParityBlizzardWave(landing, 1.65f, + Tuning.ColdDamage * damageScale * 0.5f), rotation, + scale: Vector2.one * DefenseTowerArtBounds.BlizzardShardScale); + CreateParityFlight("Effects/Ice/Shard-Blizzard-v1", + shardStart + Vector2.up * DefenseTowerArtBounds.BlizzardTrailOffset, + landing + Vector2.up * DefenseTowerArtBounds.BlizzardTrailOffset, + launchDelay, DefenseTowerArtBounds.BlizzardLandingSeconds, target, false, + null, rotation, scale: Vector2.one * DefenseTowerArtBounds.BlizzardTrailScale, + alpha: DefenseTowerArtBounds.BlizzardTrailAlpha); + } + return true; + } + + int projectileCount = Tier == 4 ? Tuning.FreezeboltProjectileCount : Tier; + string projectileKey = Tier == 4 ? "Effects/Ice/Freezebolt" : "Effects/Ice/T1-Volley"; + for (int i = 0; i < projectileCount; i++) + { + float delay = i * Tuning.ColdProjectileBurstInterval; + CreateParityFlight(projectileKey, start, target.Actor.Position, delay, 0.18f, + target, true, impact => + { + if (!CanParityImpact(impact)) return; + float sight = SightEfficiency; + impact.ApplySlow(Tuning.ColdSlowSeconds * (1f + .25f * (Tier - 1)), + Tuning.ColdSlowFraction * sight); + ApplyDamage(impact, Tuning.ColdDamage * damageScale + * (Tier == 4 ? 1.8f : 1f)); + if (Tier == 4) impact.ApplyStun(.3f * sight); + SpriteRenderer hit = CreateParitySprite("Effects/Ice/Impact", impact.Actor.Position, + Vector2.one, 0f, 13, destroy: true); + SetParityHitSorting(hit, impact); + }, hideAtImpact: true, scale: Vector2.one, + visualStartProvider: () => ParityStaffTipVisualWorldPosition, + visualStart: visualStart); + } + return true; + } + + private bool FireParitySeismic(Defense25DEnemy target) + { + Vector2 center = target.Actor.Position; + float radius = Tier == 4 + ? Branch == DefenseTowerBranch.Second ? 2.2f : 1.8f + : Tuning.SeismicRadius; + bool applied = Tier == 4 && Branch == DefenseTowerBranch.Second + ? ApplyParityArea(center, radius, Tuning.SeismicDamage * GetParityDamageScale(), + slowSeconds: 2f, slowFraction: .5f) + : ApplyParityArea(center, radius, Tuning.SeismicDamage * GetParityDamageScale(), + stunSeconds: Tuning.SeismicStunSeconds * (Tier == 4 ? 2f : 1f)); + if (!applied) return false; + + string key = Tier == 4 + ? Branch == DefenseTowerBranch.First ? "Effects/Control/StoneGaze" : "Effects/Control/Temporal" + : $"Effects/Control/T{Tier}-Aura"; + int visibleWidth = Tier == 4 + ? Branch == DefenseTowerBranch.First ? 40 : 45 + : 32 + (Tier - 1) * 6; + float scale = GetParityGroundMarkerScale(target, key, visibleWidth); + SpriteRenderer marker = CreateParitySprite(key, center, Vector2.one * scale, 0f, 13, destroy: true); + SetParityGroundSorting(marker, target); + return true; + } + + private void StepParityFlamethrower(float dt) + { + float now = world.GameTime; + if (flameStreakActive && now - flameLastSuccessfulAt >= Tuning.FireT4BeamResetAfterSeconds) + { + EndParityFlameBeam(true); + ResetParityFlameStreak(); + } + + bool valid = flameBeamTarget != null && CanParityImpact(flameBeamTarget) + && Vector2.Distance(Position, flameBeamTarget.Actor.Position) <= GetParityRange(); + if (!valid) + { + EndParityFlameBeam(false); + flameBeamTarget = FindParityTarget(false, GetParityRange(), false); + if (flameBeamTarget == null) return; + EnsureParityFlameBeam(flameBeamTarget); + } + + cooldown -= dt; + if (cooldown > 0f) return; + Defense25DEnemy target = flameBeamTarget; + EnsureParityFlameBeam(target); + float elapsed = flameStreakActive ? Mathf.Max(0f, now - flameStreakStartedAt) : 0f; + float rampMultiplier = elapsed >= Tuning.FireT4BeamMaximumTierSeconds + ? Tuning.FireT4BeamMaximumMultiplier + : elapsed >= Tuning.FireT4BeamSecondTierSeconds + ? Tuning.FireT4BeamSecondTierMultiplier : 1f; + float beamMultiplier = Tuning.FireT4BeamBaseDamageMultiplier * rampMultiplier; + float expectedDamage = Tuning.DirectTowerDamage * GetParityDamageScale() + * Tuning.FireT4BeamBaseDamageMultiplier * rampMultiplier; + float damage = Tuning.DirectTowerDamage * GetParityDamageScale() * beamMultiplier; + AssertParityFlameMultiplier(rampMultiplier, beamMultiplier); + Debug.Assert(Mathf.Approximately(damage, expectedDamage), + "Flamethrower damage must retain the source base multiplier before ramp scaling."); + if (!ApplyDamage(target, damage)) + { + EndParityFlameBeam(false); + return; + } + if (!flameStreakActive) flameStreakStartedAt = now; + flameStreakActive = true; + flameLastSuccessfulAt = now; + cooldown = Mathf.Max(.05f, Tuning.DirectTowerAttackInterval * 0.4f + / (1f + .15f * (Tier - 1)) * GetAttackIntervalMultiplier()); + PlayAttack(target); + FitParityFlameBeam(target, elapsed); + } + + private void EnsureParityFlameBeam(Defense25DEnemy target) + { + if (flameBeamRoot != null && flameBeamTarget == target) return; + EndParityFlameBeam(true); + Sprite[] frames = DefenseTowerArt.Load(flameBeamKey); + if (frames.Length == 0) return; + flameBeamRoot = CreateParityRoot("Fire T4 Sustained Beam"); + GameObject beam = new("Beam Sprite"); + beam.transform.SetParent(flameBeamRoot.transform, false); + flameBeamRenderer = beam.AddComponent(); + flameBeamRenderer.sharedMaterial = world.Settings.CutoutMaterial; + flameBeamRenderer.sortingOrder = 14; + flameBeamRenderer.sprite = frames[0]; + flameBeamTarget = target; + SetParityHitSorting(flameBeamRenderer, target); + if (!flameStreakActive) flameStreakStartedAt = world.GameTime; + FitParityFlameBeam(target, 0f); + } + + private void FitParityFlameBeam(Defense25DEnemy target, float elapsed) + { + if (flameBeamRenderer == null || flameBeamRenderer.sprite == null || target == null) return; + Sprite[] frames = DefenseTowerArt.Load(flameBeamKey); + if (frames.Length >= 2 && elapsed >= DefenseTowerArtBounds.FireBeamIgnitionSeconds) + flameBeamRenderer.sprite = frames[1]; + Vector3 start = ParityStaffTipVisualWorldPosition; + Vector3 end = Defense25DActor.OnGround(target.Actor.Center, world.Settings.GroundY + .03f); + Vector3 direction = end - start; + Quaternion camera = world.View != null ? world.View.transform.rotation + : Quaternion.Euler(world.Settings.CameraPitch, 0f, 0f); + Vector3 right = camera * Vector3.right; + Vector3 up = camera * Vector3.up; + float distance = new Vector2(Vector3.Dot(direction, right), Vector3.Dot(direction, up)).magnitude; + float angle = Mathf.Atan2(Vector3.Dot(direction, up), Vector3.Dot(direction, right)) * Mathf.Rad2Deg; + float ppu = flameBeamRenderer.sprite.pixelsPerUnit; + float scaleX = distance * ppu + / (DefenseTowerArtBounds.FireBeamEndX - DefenseTowerArtBounds.FireBeamStartX); + float width = elapsed >= Tuning.FireT4BeamMaximumTierSeconds + ? Tuning.FireT4BeamMaximumWidthMultiplier + : elapsed >= Tuning.FireT4BeamSecondTierSeconds + ? Tuning.FireT4BeamSecondTierWidthMultiplier + : DefenseTowerArtBounds.FireT4BeamBaseWidthMultiplier; + Transform beam = flameBeamRenderer.transform; + beam.rotation = camera * Quaternion.Euler(0f, 0f, angle); + beam.localScale = new Vector3(scaleX, width, 1f); + Vector2 pixelStart = new( + (DefenseTowerArtBounds.FireBeamStartX - flameBeamRenderer.sprite.pivot.x) / ppu, + (flameBeamRenderer.sprite.rect.height - DefenseTowerArtBounds.FireBeamCenterY + - flameBeamRenderer.sprite.pivot.y) / ppu); + beam.position = start - beam.right * (pixelStart.x * scaleX) - beam.up * (pixelStart.y * width); + } + + private void EndParityFlameBeam(bool immediate) + { + if (flameBeamRoot == null) return; + Sprite[] frames = DefenseTowerArt.Load(flameBeamKey); + if (frames.Length >= 3 && flameBeamRenderer != null) flameBeamRenderer.sprite = frames[2]; + if (immediate) Destroy(flameBeamRoot); + else Destroy(flameBeamRoot, DefenseTowerArtBounds.FireBeamExtinguishSeconds); + flameBeamRoot = null; + flameBeamRenderer = null; + flameBeamTarget = null; + } + + private void ResetParityFlameStreak() + { + flameStreakActive = false; + flameStreakStartedAt = flameLastSuccessfulAt = 0f; + } + + private void AdvanceParityFlights(float dt) + { + AlignCenteredImpactWaves(); + for (int i = parityFlights.Count - 1; i >= 0; i--) + { + ParityFlight flight = parityFlights[i]; + float activeDelta = dt; + if (flight.Delay > 0f) + { + float delayStep = Mathf.Min(flight.Delay, dt); + flight.Delay -= delayStep; + activeDelta -= delayStep; + if (flight.Delay <= 0f && flight.VisualStartProvider != null) + { + Vector3 start = flight.VisualStartProvider(); + flight.Start = Defense25DActor.Ground(start); + flight.StartHeightOffset = start.y - world.Settings.GroundY - .04f; + flight.VisualStartProvider = null; + } + if (flight.Delay > 0f) continue; + } + flight.Elapsed += activeDelta; + Vector2 end = flight.TrackTarget && CanParityImpact(flight.Target) + ? flight.Target.Actor.Position : flight.End; + float progress = Mathf.Clamp01(flight.Elapsed / flight.Duration); + if (flight.Root != null) + { + float height = world.Settings.GroundY + .04f + + Mathf.Lerp(flight.StartHeightOffset, 0f, progress); + flight.Root.transform.position = Defense25DActor.OnGround( + Vector2.Lerp(flight.Start, end, progress), height); + SpriteRenderer sprite = flight.Root.GetComponentInChildren(); + if (sprite != null && flight.Delay <= 0f) sprite.enabled = true; + } + if (progress < 1f) continue; + parityFlights.RemoveAt(i); + if (flight.HideAtImpact && flight.Root != null) + { + SpriteRenderer sprite = flight.Root.GetComponentInChildren(); + if (sprite != null) sprite.enabled = false; + } + flight.Impact?.Invoke(flight.Target); + if (flight.Root != null) Destroy(flight.Root); + } + for (int i = parityEffects.Count - 1; i >= 0; i--) + { + if (parityEffects[i] == null) { parityEffects.RemoveAt(i); continue; } + if (parityEffects[i].transform.childCount != 0) continue; + Destroy(parityEffects[i]); + parityEffects.RemoveAt(i); + } + } + + private void CreateParityFlight(string key, Vector2 start, Vector2 end, float delay, + float duration, Defense25DEnemy target, bool trackTarget, + Action impact, float rotation = 0f, bool hideAtImpact = false, + Vector2? scale = null, float alpha = 1f, string nextKey = null, + Func visualStartProvider = null, Vector3? visualStart = null) + { + GameObject root = CreateParityRoot(key.Contains("Shard") ? "Blizzard Shard Flight" : "Tower Projectile"); + SpriteRenderer renderer = CreateParitySprite(root.transform, key, Vector2.zero, + scale ?? Vector2.one * (Kind == DefenseTowerKind.Direct ? .85f : 1f), + rotation, 14, destroy: false, nextKey: nextKey); + renderer.color = new Color(1f, 1f, 1f, alpha); + SetParityHitSorting(renderer, target); + renderer.enabled = delay <= 0f; + DefenseSpriteSequence sequence = renderer.GetComponent(); + sequence.SetCanAdvance(() => world != null && world.TowersOperational + && (manager == null || !manager.IsSuspended)); + if (delay > 0f && nextKey == null) + { + sequence.Play(renderer, key, DefenseTowerArt.Load(key), false, startDelay: delay); + sequence.SetCanAdvance(() => world != null && world.TowersOperational + && (manager == null || !manager.IsSuspended)); + } + sequence.SetCompletionCallback(() => + { + if (renderer != null && root != null) renderer.enabled = true; + }); + if (delay > 0f && key is "Effects/Ice/T1-Volley" or "Effects/Ice/Freezebolt") + Debug.Assert(visualStartProvider != null, + "Each delayed cold salvo projectile must resample its staff-tip at launch."); + Vector3 initialVisualStart = visualStart ?? Defense25DActor.OnGround( + start, world.Settings.GroundY + .04f); + parityFlights.Add(new ParityFlight + { + Delay = delay, + Duration = Mathf.Max(.001f, duration), + Start = start, + End = end, + Target = target, + TrackTarget = trackTarget, + Impact = impact, + Root = root, + Sequence = sequence, + HideAtImpact = hideAtImpact, + StartHeightOffset = initialVisualStart.y - world.Settings.GroundY - .04f, + VisualStartProvider = visualStartProvider, + }); + root.transform.position = initialVisualStart; + renderer.transform.localPosition = Vector3.zero; + } + + private GameObject CreateParityRoot(string label) + { + GameObject root = new(label); + root.transform.SetParent(transform.parent, false); + parityEffects.Add(root); + return root; + } + + private SpriteRenderer CreateParitySprite(string key, Vector2 position, Vector2 scale, + float rotation, int sortingOrder, bool destroy, string nextKey = null, + float startDelay = 0f) + { + GameObject root = CreateParityRoot("Tower Attack Effect"); + return CreateParitySprite(root.transform, key, position, scale, rotation, + sortingOrder, destroy, nextKey, startDelay); + } + + private SpriteRenderer CreateParitySprite(Transform parent, string key, Vector2 position, + Vector2 scale, float rotation, int sortingOrder, bool destroy, + string nextKey = null, float startDelay = 0f) + { + GameObject image = new(key); + image.transform.SetParent(parent, false); + image.transform.position = Defense25DActor.OnGround(position, world.Settings.GroundY + .04f); + image.transform.localScale = new Vector3(scale.x, scale.y, 1f); + Quaternion camera = world.View != null ? world.View.transform.rotation + : Quaternion.Euler(world.Settings.CameraPitch, 0f, 0f); + image.transform.rotation = camera * Quaternion.Euler(0f, 0f, rotation); + SpriteRenderer renderer = image.AddComponent(); + renderer.sharedMaterial = world.Settings.CutoutMaterial; + renderer.sortingOrder = sortingOrder; + Sprite[] frames = DefenseTowerArt.Load(key); + Sprite[] next = string.IsNullOrEmpty(nextKey) ? null : DefenseTowerArt.Load(nextKey); + renderer.enabled = startDelay <= 0f; + DefenseSpriteSequence sequence = image.AddComponent(); + sequence.Play(renderer, key, frames, destroy, nextKey, next, startDelay); + sequence.SetCanAdvance(() => world != null && world.TowersOperational + && (manager == null || !manager.IsSuspended)); + return renderer; + } + + private bool ApplyParityArea(Vector2 center, float radius, float damage, + int maxTargets = int.MaxValue, float slowSeconds = 0f, + float slowFraction = 0f, float stunSeconds = 0f) + { + int count = 0; + bool appliedAny = false; + parityUniqueTargets.Clear(); + foreach (Defense25DActor actor in world.QueryEnemies(center, Vector2.one * radius)) + { + if (count++ >= maxTargets) break; + Defense25DEnemy enemy = actor.GetComponent(); + if (enemy == null || !CanParityImpact(enemy) || !parityUniqueTargets.Add(enemy) + || (actor.Position - center).sqrMagnitude > radius * radius) continue; + bool applied = ApplyDamage(enemy, damage); + float sight = SightEfficiency; + if (slowSeconds > 0f) applied |= enemy.ApplySlow(slowSeconds, slowFraction * sight); + if (stunSeconds > 0f) applied |= enemy.ApplyStun(stunSeconds * sight); + appliedAny |= applied; + } + return appliedAny; + } + + private void ApplyParityBlizzardWave(Vector2 center, float castRadius, float damage) + { + float radiusX = DefenseTowerArtBounds.BlizzardWaveRadiusX; + float radiusY = DefenseTowerArtBounds.BlizzardWaveRadiusY; + float broadphase = castRadius + radiusX; + foreach (Defense25DActor actor in world.QueryEnemies(center, Vector2.one * broadphase)) + { + Defense25DEnemy enemy = actor.GetComponent(); + if (enemy == null || !CanParityImpact(enemy)) continue; + Vector2 delta = Defense25DActor.Ground(actor.Visual.FootPosition) - center; + if (delta.x * delta.x / (radiusX * radiusX) + + delta.y * delta.y / (radiusY * radiusY) > 1f) continue; + ApplyDamage(enemy, damage); + float sight = SightEfficiency; + enemy.ApplySlow(Tuning.ColdSlowSeconds * 1.5f, Tuning.ColdSlowFraction * sight); + enemy.ApplyStun(.35f * sight); + } + } + + private bool CanParityImpact(Defense25DEnemy target) => target != null + && target.IsAvailable && world != null && world.IsRunning + && world.IsTowerShowcaseTarget(Kind, target); + + private bool IsParityTowerImmune(Defense25DEnemy target) => + world.IsTowerImmuneToEnemy(Kind, target); + + private Defense25DEnemy FindParityTarget(bool leading, float range, bool preserveTarget) + { + if (preserveTarget && CanParityImpact(flameBeamTarget) + && Vector2.Distance(Position, flameBeamTarget.Actor.Position) <= range) + return flameBeamTarget; + Defense25DEnemy nearest = null; + float best = range * range; + foreach (Defense25DEnemy enemy in world.GetTowerTargets(Kind)) + { + if (!CanParityImpact(enemy) || (!leading && IsParityTowerImmune(enemy))) continue; + float distance = (enemy.Actor.Position - Position).sqrMagnitude; + if (distance > best) continue; + best = distance; + nearest = enemy; + } + return nearest; + } + + private float GetParityRange() => Kind == DefenseTowerKind.Direct && Tier == 4 + && Branch == DefenseTowerBranch.Second ? Tuning.TowerRange * .5f : Tuning.TowerRange; + + private float GetParityDamageScale() => 1f + Tuning.TowerDamageBonusPerTier * (Tier - 1); + + private Vector2 GetParityStaffTip() + { + return Defense25DActor.Ground(ParityStaffTipVisualWorldPosition); + } + + private float GetParityGroundMarkerScale(Defense25DEnemy target, string key, int widthPixels) + { + Sprite[] frames = DefenseTowerArt.Load(key); + Sprite sprite = frames.Length > 0 ? frames[0] : null; + if (sprite == null) return 0f; + SpriteRenderer enemyRenderer = target.Actor.Visual != null ? target.Actor.Visual.Renderer : null; + Sprite targetSprite = enemyRenderer != null ? enemyRenderer.sprite : null; + if (targetSprite == null) return 0f; + float markerWidth = DefenseTowerArtBounds.SeismicGroundMarkerWidthPixels + * Mathf.Abs(enemyRenderer.transform.lossyScale.x) / targetSprite.pixelsPerUnit; + return markerWidth / (widthPixels / sprite.pixelsPerUnit + * Mathf.Max(.001f, Mathf.Abs(transform.lossyScale.x))); + } + + private static float GroundAngle(Vector2 start, Vector2 end) => + Mathf.Atan2(end.y - start.y, end.x - start.x) * Mathf.Rad2Deg; + + private static void SetParityHitSorting(Renderer renderer, Defense25DEnemy target) + { + SpriteRenderer targetRenderer = target != null && target.Actor != null + && target.Actor.Visual != null ? target.Actor.Visual.Renderer : null; + if (renderer == null || targetRenderer == null) return; + renderer.sortingLayerID = targetRenderer.sortingLayerID; + renderer.sortingOrder = targetRenderer.sortingOrder + + DefenseTowerArtBounds.TowerHitSortingOffset; + } + + private static void SetParityGroundSorting(Renderer renderer, Defense25DEnemy target) + { + SpriteRenderer targetRenderer = target != null && target.Actor != null + && target.Actor.Visual != null ? target.Actor.Visual.Renderer : null; + if (renderer == null || targetRenderer == null) return; + renderer.sortingLayerID = targetRenderer.sortingLayerID; + renderer.sortingOrder = targetRenderer.sortingOrder - 1; + } + + private void CreateParityRadius(Vector2 center, float radius, Defense25DEnemy target, + float lifetime, float alpha) + { + GameObject root = CreateParityRoot("Tower Impact Radius"); + GameObject ring = new("Impact Radius"); + ring.transform.SetParent(root.transform, false); + LineRenderer line = ring.AddComponent(); + line.sharedMaterial = world.Settings.BoundaryMaterial; + line.useWorldSpace = true; + line.loop = true; + line.widthMultiplier = .07f; + line.positionCount = 40; + Color color = Kind switch + { + DefenseTowerKind.Direct => new Color32(240, 96, 65, 255), + DefenseTowerKind.Lightning => new Color32(238, 218, 83, 255), + DefenseTowerKind.Cold => new Color32(65, 211, 240, 255), + DefenseTowerKind.Seismic => new Color32(175, 141, 100, 255), + _ => Color.white, + }; + color.a = alpha; + line.startColor = line.endColor = color; + for (int i = 0; i < line.positionCount; i++) + { + float angle = i * Mathf.PI * 2f / line.positionCount; + Vector2 point = center + new Vector2(Mathf.Cos(angle), Mathf.Sin(angle)) * radius; + line.SetPosition(i, Defense25DActor.OnGround(point, world.Settings.GroundY + .02f)); + } + SetParityHitSorting(line, target); + Destroy(root, lifetime); + } + + private void FitParityBeam(SpriteRenderer renderer, Vector2 start, Vector2 end, float thickness) + { + if (renderer == null || renderer.sprite == null) return; + Vector3 a = Defense25DActor.OnGround(start, world.Settings.GroundY + .04f); + Vector3 b = Defense25DActor.OnGround(end, world.Settings.GroundY + .04f); + Vector3 direction = b - a; + Quaternion camera = world.View != null ? world.View.transform.rotation + : Quaternion.Euler(world.Settings.CameraPitch, 0f, 0f); + Vector3 right = camera * Vector3.right; + Vector3 up = camera * Vector3.up; + float screenX = Vector3.Dot(direction, right); + float screenY = Vector3.Dot(direction, up); + float distance = new Vector2(screenX, screenY).magnitude; + float angle = Mathf.Atan2(screenY, screenX) * Mathf.Rad2Deg; + Vector2Int bounds = DefenseTowerArtBounds.LightningBeamXBounds(0); + float ppu = renderer.sprite.pixelsPerUnit; + float scaleX = distance * ppu / Mathf.Max(1f, bounds.y - bounds.x); + renderer.transform.rotation = camera * Quaternion.Euler(0f, 0f, angle); + renderer.transform.localScale = new Vector3(scaleX, thickness, 1f); + float localStart = (bounds.x - renderer.sprite.pivot.x) / ppu * scaleX; + renderer.transform.position = a - renderer.transform.right * localStart; + } + + private static Vector2 GetParityShadowPosition(Defense25DEnemy target) => + target != null && target.Actor != null && target.Actor.Visual != null + ? Defense25DActor.Ground(target.Actor.Visual.FootPosition) + : target != null ? target.Actor.Position : Vector2.zero; + + private void FitParityStorm(SpriteRenderer renderer, Vector2 center, float radius) + { + if (renderer == null || renderer.sprite == null) return; + Quaternion camera = world.View != null ? world.View.transform.rotation + : Quaternion.Euler(world.Settings.CameraPitch, 0f, 0f); + float scale = radius * renderer.sprite.pixelsPerUnit + / DefenseTowerArtBounds.LightningStormGroundRadiusPixels; + Vector2 localCenter = (DefenseTowerArtBounds.LightningStormGroundCenterPixels + - renderer.sprite.pivot) / renderer.sprite.pixelsPerUnit; + renderer.transform.rotation = camera; + renderer.transform.localScale = Vector3.one * scale; + Vector3 desired = Defense25DActor.OnGround(center, world.Settings.GroundY + .04f); + renderer.transform.position = desired - renderer.transform.TransformVector(localCenter); + } + + private void AlignCenteredImpactWaves() + { + for (int i = centeredImpactWaves.Count - 1; i >= 0; i--) + { + CenteredImpactWave wave = centeredImpactWaves[i]; + SpriteRenderer renderer = wave.Renderer; + if (renderer == null || renderer.sprite == null) + { + centeredImpactWaves.RemoveAt(i); + continue; + } + + Sprite[] frames = DefenseTowerArt.Load("Effects/Ice/Blizzard-Wave-v1"); + int frame = Array.IndexOf(frames, renderer.sprite); + Vector2 centerPixels = DefenseTowerArtBounds.ImpactWaveCenterPixels(frame, renderer.sprite); + Vector2 localCenter = (centerPixels - renderer.sprite.pivot) + / renderer.sprite.pixelsPerUnit; + Quaternion camera = world.View != null ? world.View.transform.rotation + : Quaternion.Euler(world.Settings.CameraPitch, 0f, 0f); + renderer.transform.rotation = camera; + renderer.transform.localScale = Vector3.one * wave.Scale; + Vector3 desired = Defense25DActor.OnGround(wave.Center, world.Settings.GroundY + .04f); + renderer.transform.position = desired - renderer.transform.TransformVector(localCenter); + Debug.Assert(Vector3.Distance(renderer.transform.TransformPoint(localCenter), desired) < 0.001f, + "Blizzard wave's authored frame center must align with its landing point."); + } + } + + private void AssertParityFlameMultiplier(float rampMultiplier, float damageMultiplier) + { + float baseMultiplier = Tuning.FireT4BeamBaseDamageMultiplier; + Debug.Assert(Mathf.Approximately(baseMultiplier, 0.4f)); + Debug.Assert(Mathf.Approximately(baseMultiplier * 1f, 0.4f)); + Debug.Assert(Mathf.Approximately(baseMultiplier * Tuning.FireT4BeamSecondTierMultiplier, 0.6f)); + Debug.Assert(Mathf.Approximately(baseMultiplier * Tuning.FireT4BeamMaximumMultiplier, 0.8f)); + Debug.Assert(Mathf.Approximately(damageMultiplier, baseMultiplier * rampMultiplier)); + } + } +} diff --git a/Assets/_Project/Scripts/Defense/Comparison/Defense25DTowerAttacks.cs.meta b/Assets/_Project/Scripts/Defense/Comparison/Defense25DTowerAttacks.cs.meta new file mode 100644 index 0000000..7c6ebf2 --- /dev/null +++ b/Assets/_Project/Scripts/Defense/Comparison/Defense25DTowerAttacks.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: bab6fcd2cca241e388a2719e797bb218 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/_Project/Scripts/Defense/Comparison/Defense25DTowerBodyAnimation.cs b/Assets/_Project/Scripts/Defense/Comparison/Defense25DTowerBodyAnimation.cs new file mode 100644 index 0000000..0779e7e --- /dev/null +++ b/Assets/_Project/Scripts/Defense/Comparison/Defense25DTowerBodyAnimation.cs @@ -0,0 +1,213 @@ +using BumpCombat.Constants; +using BumpCombat.Defense; +using UnityEngine; + +namespace BumpCombat.Defense.Comparison +{ + public sealed partial class Defense25DTower + { + private string bodyIdleKey; + private string bodyAttackKey; + private int bodyFrame; + private float bodyFrameTime; + private bool bodyAttacking; + private SpriteRenderer supportAuraRenderer; + private Sprite[] supportAuraFrames; + private string supportAuraKey; + private int supportAuraTier; + + public int BodyAnimationFrame => bodyFrame; + public bool IsBodyAttacking => bodyAttacking; + public int BodySupportAuraTier => supportAuraTier; + public Vector3 ParityStaffTipVisualWorldPosition => GetParityStaffTipVisualWorldPosition(); + + private readonly struct BodyAnimationSnapshot + { + public readonly int Frame; + public readonly float FrameTime; + public readonly bool Attacking; + public readonly bool FlipX; + public readonly Sprite Sprite; + + public BodyAnimationSnapshot(int frame, float frameTime, bool attacking, + bool flipX, Sprite sprite) + { + Frame = frame; + FrameTime = frameTime; + Attacking = attacking; + FlipX = flipX; + Sprite = sprite; + } + } + + private void ConfigureBodyAnimation() + { + bodyIdleKey = DefenseTowerArt.BodyKey(Kind, Tier, "Idle"); + bodyAttackKey = Kind switch + { + DefenseTowerKind.Direct or DefenseTowerKind.Lightning or DefenseTowerKind.Cold + => DefenseTowerArt.BodyKey(Kind, Tier, "Attack01"), + DefenseTowerKind.Seismic => DefenseTowerArt.BodyKey(Kind, Tier, "Attack"), + DefenseTowerKind.Empowering => DefenseTowerArt.BodyKey(Kind, Tier, "Heal"), + _ => null, + }; + idleFrames = DefenseTowerArt.Load(bodyIdleKey); + attackFrames = string.IsNullOrEmpty(bodyAttackKey) + ? System.Array.Empty() : DefenseTowerArt.Load(bodyAttackKey); + bodyAttacking = false; + bodyFrame = 0; + bodyFrameTime = 0f; + if (body != null) + body.sprite = idleFrames.Length > 0 ? idleFrames[0] : null; + UpdateBodySupportAuraVisual(); + } + + private void StartBodyAttack(bool faceLeft) + { + if (body == null || attackFrames == null || attackFrames.Length == 0) return; + body.flipX = faceLeft; + bodyAttacking = true; + bodyFrame = 0; + bodyFrameTime = 0f; + body.sprite = attackFrames[0]; + UpdateBodySupportAuraVisual(); + } + + private void StepBodyAnimation(float dt) + { + if (body == null || idleFrames == null || idleFrames.Length == 0) return; + bodyFrameTime += Mathf.Max(0f, dt); + Sprite[] frames = bodyAttacking ? attackFrames : idleFrames; + string key = bodyAttacking ? bodyAttackKey : bodyIdleKey; + if (frames == null || frames.Length == 0) + { + bodyAttacking = false; + bodyFrame = 0; + bodyFrameTime = 0f; + body.sprite = idleFrames[0]; + UpdateBodySupportAuraVisual(); + return; + } + + bool changed = false; + while (bodyFrameTime >= DefenseTowerArt.FrameSeconds(key, bodyFrame)) + { + bodyFrameTime -= DefenseTowerArt.FrameSeconds(key, bodyFrame); + bodyFrame++; + if (bodyFrame >= frames.Length) + { + if (bodyAttacking) + { + bodyAttacking = false; + bodyFrame = 0; + frames = idleFrames; + key = bodyIdleKey; + } + else + { + bodyFrame = 0; + } + } + body.sprite = frames[bodyFrame]; + changed = true; + } + + if (changed) UpdateBodySupportAuraVisual(); + } + + private void ResetBodyAnimation() + { + bodyAttacking = false; + bodyFrame = 0; + bodyFrameTime = 0f; + if (body != null) + body.sprite = idleFrames != null && idleFrames.Length > 0 ? idleFrames[0] : null; + UpdateBodySupportAuraVisual(); + } + + private BodyAnimationSnapshot CaptureBodyAnimationState() => + new(bodyFrame, bodyFrameTime, bodyAttacking, + body != null && body.flipX, body != null ? body.sprite : null); + + private void RestoreBodyAnimationState(BodyAnimationSnapshot snapshot) + { + bodyFrame = snapshot.Frame; + bodyFrameTime = snapshot.FrameTime; + bodyAttacking = snapshot.Attacking; + if (body != null) + { + body.flipX = snapshot.FlipX; + body.sprite = snapshot.Sprite; + } + UpdateBodySupportAuraVisual(); + } + + private Vector3 GetParityStaffTipVisualWorldPosition() + { + if (body == null || body.sprite == null) return transform.position; + Vector2 pixels = DefenseTowerArtBounds.StaffTipPixels(bodyAttacking, bodyFrame); + Vector2 localPixels = pixels - body.sprite.pivot; + localPixels.y = body.sprite.rect.height - pixels.y - body.sprite.pivot.y; + if (body.flipX) localPixels.x = -localPixels.x; + return body.transform.TransformPoint(localPixels / body.sprite.pixelsPerUnit); + } + + private void SetBodySupportAuraTier(int tier, float strength) + { + _ = strength; + supportAuraTier = Mathf.Max(0, tier); + UpdateBodySupportAuraVisual(); + } + + private void UpdateBodySupportAuraVisual() + { + if (supportAuraTier <= 0 || body == null || body.sprite == null) + { + if (supportAuraRenderer != null) supportAuraRenderer.enabled = false; + return; + } + + string motionKey = bodyAttacking && !string.IsNullOrEmpty(bodyAttackKey) + ? bodyAttackKey : bodyIdleKey; + string key = motionKey.Replace("Bodies/", "SupportMasks/"); + if (supportAuraKey != key) + { + supportAuraKey = key; + supportAuraFrames = DefenseTowerArt.Load(key); + } + + if (supportAuraRenderer == null) + { + GameObject auraObject = new("Support Aura Art"); + auraObject.transform.SetParent(transform, false); + supportAuraRenderer = auraObject.AddComponent(); + supportAuraRenderer.sharedMaterial = body.sharedMaterial; + } + + int maskFrame = 0; + if (supportAuraFrames != null) + { + for (int i = 0; i < supportAuraFrames.Length; i++) + { + if (supportAuraFrames[i].name != body.sprite.name) continue; + maskFrame = i; + break; + } + } + + supportAuraRenderer.sprite = supportAuraFrames != null + && supportAuraFrames.Length > maskFrame ? supportAuraFrames[maskFrame] : null; + supportAuraRenderer.flipX = body.flipX; + supportAuraRenderer.transform.position = body.transform.position; + supportAuraRenderer.transform.rotation = body.transform.rotation; + supportAuraRenderer.transform.localScale = body.transform.localScale; + supportAuraRenderer.sortingOrder = body.sortingOrder + 1; + Color[] colors = GameplayConstants.Current.Defense.SupportTierColors; + supportAuraRenderer.color = colors != null && colors.Length > 0 + ? colors[Mathf.Clamp(supportAuraTier - 1, 0, colors.Length - 1)] + : new Color32(151, 169, 119, 102); + supportAuraRenderer.enabled = supportAuraFrames != null + && supportAuraFrames.Length > 0 && supportAuraRenderer.sprite != null; + } + } +} diff --git a/Assets/_Project/Scripts/Defense/Comparison/Defense25DTowerBodyAnimation.cs.meta b/Assets/_Project/Scripts/Defense/Comparison/Defense25DTowerBodyAnimation.cs.meta new file mode 100644 index 0000000..b7c376c --- /dev/null +++ b/Assets/_Project/Scripts/Defense/Comparison/Defense25DTowerBodyAnimation.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 316e185f506a4e14a5c16c86d2c2b10a +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/_Project/Scripts/Defense/Comparison/Defense25DTowerManager.cs b/Assets/_Project/Scripts/Defense/Comparison/Defense25DTowerManager.cs new file mode 100644 index 0000000..2d74c94 --- /dev/null +++ b/Assets/_Project/Scripts/Defense/Comparison/Defense25DTowerManager.cs @@ -0,0 +1,132 @@ +using System.Collections.Generic; +using BumpCombat.Constants; +using BumpCombat.Defense; +using UnityEngine; + +namespace BumpCombat.Defense.Comparison +{ + public sealed class Defense25DTowerManager : MonoBehaviour + { + private const int PadCount = 6; + private readonly Defense25DTower[] towers = new Defense25DTower[PadCount]; + private readonly List constructionOrder = new(PadCount); + private Defense25DComparison world; + private DefenseConstants Tuning => GameplayConstants.Current.Defense; + + public int ConstructionCurrency { get; private set; } + public bool IsSuspended { get; private set; } + public IReadOnlyList Towers => towers; + public IReadOnlyList TowersInConstructionOrder => constructionOrder; + + public void Configure(Defense25DComparison owner) + { + world = owner; + ResetForRun(); + } + + public void ResetForRun() + { + for (int i = 0; i < towers.Length; i++) + { + if (towers[i] != null) + { + towers[i].DestroySummonsForRunReset(); + Destroy(towers[i].gameObject); + } + towers[i] = null; + } + constructionOrder.Clear(); + ConstructionCurrency = Tuning.InitialConstructionCurrency; + } + + public Defense25DTower GetTowerAtPad(int padIndex) + { + return padIndex >= 0 && padIndex < towers.Length ? towers[padIndex] : null; + } + + public bool TryBuildTowerAtPad(int padIndex, DefenseTowerKind kind) + { + if (!CanInteract(padIndex) || towers[padIndex] != null) return false; + int cost = GetBuildCost(kind); + if (cost < 0 || ConstructionCurrency < cost) return false; + + Vector2 position = Tuning.GetBuildPadPosition(padIndex); + GameObject instance = new($"2.5D {kind} Tower Pad {padIndex}"); + instance.transform.SetParent(transform, false); + Defense25DTower tower = instance.AddComponent(); + tower.Configure(this, world, padIndex, kind, position); + towers[padIndex] = tower; + constructionOrder.Add(tower); + ConstructionCurrency -= cost; + return true; + } + + public bool TryUpgradeTowerAtPad(int padIndex, DefenseTowerBranch branch) + { + if (!CanInteract(padIndex) || towers[padIndex] == null) return false; + Defense25DTower tower = towers[padIndex]; + if (tower.Tier >= 4 + || (tower.Tier < 3 && branch != DefenseTowerBranch.None) + || (tower.Tier == 3 && branch != DefenseTowerBranch.First + && branch != DefenseTowerBranch.Second)) return false; + int cost = tower.Tier switch + { + 1 => Tuning.Tier2UpgradeCost, + 2 => Tuning.Tier3UpgradeCost, + _ => Tuning.Tier4UpgradeCost, + }; + if (ConstructionCurrency < cost || !tower.TryUpgrade(branch)) return false; + ConstructionCurrency -= cost; + return true; + } + + public void AddCurrency(int amount) + { + if (amount > 0) ConstructionCurrency += amount; + } + + public void Step(float deltaTime) + { + if (IsSuspended || !world.TowersOperational) return; + foreach (Defense25DTower tower in constructionOrder) tower?.ResetSupport(); + foreach (Defense25DTower tower in constructionOrder) tower?.UpdateSupport(); + foreach (Defense25DTower tower in constructionOrder) tower?.Step(deltaTime); + } + + public void SetShowcaseSuspended(bool suspended) + { + if (IsSuspended == suspended) return; + IsSuspended = suspended; + foreach (Defense25DTower tower in towers) + tower?.SetShowcaseSuspended(suspended); + } + + public void CancelAll() + { + for (int i = 0; i < towers.Length; i++) towers[i]?.CancelPending(); + } + + private bool CanInteract(int padIndex) + { + if (world == null || !world.TowersOperational || world.Player == null + || padIndex < 0 || padIndex >= towers.Length) return false; + Vector2 pad = Tuning.GetBuildPadPosition(padIndex); + return (world.Player.Position - pad).sqrMagnitude + <= Tuning.BuildInteractionRadius * Tuning.BuildInteractionRadius; + } + + private int GetBuildCost(DefenseTowerKind kind) + { + return kind switch + { + DefenseTowerKind.Direct => Tuning.DirectTowerCost, + DefenseTowerKind.Summon => Tuning.SummonTowerCost, + DefenseTowerKind.Lightning => Tuning.LightningTowerCost, + DefenseTowerKind.Cold => Tuning.ColdTowerCost, + DefenseTowerKind.Seismic => Tuning.SeismicTowerCost, + DefenseTowerKind.Empowering => Tuning.EmpoweringTowerCost, + _ => -1, + }; + } + } +} diff --git a/Assets/_Project/Scripts/Defense/Comparison/Defense25DTowerManager.cs.meta b/Assets/_Project/Scripts/Defense/Comparison/Defense25DTowerManager.cs.meta new file mode 100644 index 0000000..e301d0a --- /dev/null +++ b/Assets/_Project/Scripts/Defense/Comparison/Defense25DTowerManager.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 8b336ec294884d46ac2789fe954ca665 diff --git a/Assets/_Project/Scripts/Defense/Comparison/Defense25DTowerRelics.cs b/Assets/_Project/Scripts/Defense/Comparison/Defense25DTowerRelics.cs new file mode 100644 index 0000000..32bf48f --- /dev/null +++ b/Assets/_Project/Scripts/Defense/Comparison/Defense25DTowerRelics.cs @@ -0,0 +1,18 @@ +namespace BumpCombat.Defense.Comparison +{ + public sealed partial class Defense25DTower + { + private float battleBatonUntil; + private float battleBatonDamageIncrease; + + public void ApplyBattleBaton(float duration, float damageIncrease) + { + if (!world.TowersOperational || duration <= 0f || damageIncrease <= 0f) return; + battleBatonUntil = world.GameTime + duration; + battleBatonDamageIncrease = damageIncrease; + } + + private float GetBattleBatonDamageMultiplier() => + world.GameTime < battleBatonUntil ? 1f + battleBatonDamageIncrease : 1f; + } +} diff --git a/Assets/_Project/Scripts/Defense/Comparison/Defense25DTowerRelics.cs.meta b/Assets/_Project/Scripts/Defense/Comparison/Defense25DTowerRelics.cs.meta new file mode 100644 index 0000000..9624b71 --- /dev/null +++ b/Assets/_Project/Scripts/Defense/Comparison/Defense25DTowerRelics.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: b298f0b9e1894fcda2da6a36c35131ad diff --git a/Assets/_Project/Scripts/Defense/Comparison/Defense25DTowerSummonPack.cs b/Assets/_Project/Scripts/Defense/Comparison/Defense25DTowerSummonPack.cs new file mode 100644 index 0000000..d776f6f --- /dev/null +++ b/Assets/_Project/Scripts/Defense/Comparison/Defense25DTowerSummonPack.cs @@ -0,0 +1,19 @@ +using UnityEngine; + +namespace BumpCombat.Defense.Comparison +{ + public sealed partial class Defense25DTower + { + public void InitializeSummonPack() => InitializeSummons(); + + public void StepSummonPack(float dt) => StepSummons(dt); + + public void ReplaceSummonPack() => ReplaceSummonsForUpgrade(); + + public void CancelSummonPack() + { + foreach (Defense25DSummon summon in ownedSummons) + if (summon != null) summon.SuspendAttack(); + } + } +} diff --git a/Assets/_Project/Scripts/Defense/Comparison/Defense25DTowerSummonPack.cs.meta b/Assets/_Project/Scripts/Defense/Comparison/Defense25DTowerSummonPack.cs.meta new file mode 100644 index 0000000..6747113 --- /dev/null +++ b/Assets/_Project/Scripts/Defense/Comparison/Defense25DTowerSummonPack.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: e182ccb767a147b2a6d7712c03672b4a diff --git a/Assets/_Project/Scripts/Defense/Comparison/Defense25DTowerSummons.cs b/Assets/_Project/Scripts/Defense/Comparison/Defense25DTowerSummons.cs new file mode 100644 index 0000000..ed5f5a6 --- /dev/null +++ b/Assets/_Project/Scripts/Defense/Comparison/Defense25DTowerSummons.cs @@ -0,0 +1,165 @@ +using System.Collections.Generic; +using BumpCombat.Constants; +using BumpCombat.Defense; +using UnityEngine; + +namespace BumpCombat.Defense.Comparison +{ + public sealed partial class Defense25DTower + { + private readonly List ownedSummons = new(); + private readonly Stack freeSummonSlots = new(); + private readonly Dictionary deadSummons = new(); + private int summonCount, nextSummonIndex, targetSummonCount; + private float summonRespawnRemaining; + + public Defense25DEnemy CommandTarget { get; private set; } + public int SummonCount => summonCount; + public int TargetSummonCount => targetSummonCount; + public float SummonRespawnRemaining => summonRespawnRemaining; + + private void InitializeSummons() + { + if (Kind != DefenseTowerKind.Summon) return; + targetSummonCount = Tuning.SummonUnitCount; + AddSummonUnits(targetSummonCount); + } + + private void ReplaceSummonsForUpgrade() + { + if (Kind != DefenseTowerKind.Summon) return; + int count = Tier == 4 && Branch == DefenseTowerBranch.First + ? 1 : targetSummonCount + (Tier == 4 ? 2 : 1); + DestroySummons(); + targetSummonCount = count; + summonRespawnRemaining = 0f; + AddSummonUnits(count); + } + + private void DestroySummons() + { + CommandTarget = null; + foreach (Defense25DSummon summon in ownedSummons) + { + if (summon == null) continue; + world.UnregisterSummon(summon); + Destroy(summon.gameObject); + } + ownedSummons.Clear(); + freeSummonSlots.Clear(); + deadSummons.Clear(); + summonCount = nextSummonIndex = 0; + } + + public void DestroySummonsForRunReset() => DestroySummons(); + + private void AddSummonUnits(int count) + { + for (int i = 0; i < count; i++) + { + int slot = freeSummonSlots.Count > 0 ? freeSummonSlots.Pop() : nextSummonIndex++; + if (deadSummons.TryGetValue(slot, out Defense25DSummon dead)) + { + deadSummons.Remove(slot); + if (dead != null) + { + ownedSummons.Remove(dead); + world.UnregisterSummon(dead); + Destroy(dead.gameObject); + } + } + Vector2 local = new((slot % 3 - 1) * 0.8f, -0.55f - 0.8f * (slot / 3)); + GameObject unit = new($"{SummonFamily} Unit {slot + 1}"); + unit.transform.SetParent(transform, false); + unit.transform.position = Defense25DActor.OnGround(Position + local, world.Settings.GroundY); + Defense25DActor actor = unit.AddComponent(); + actor.Configure(world, ComparisonActorKind.Summon); + actor.IgnorePhysicalPair(world.Player); + Defense25DSummon summon = unit.AddComponent(); + summon.Configure(world, actor, Position + local, this, slot); + ownedSummons.Add(summon); + world.RegisterSummon(summon); + summonCount++; + } + } + + private string SummonFamily => Tier == 4 && Branch == DefenseTowerBranch.First + ? "GiantSlime" : Tier == 4 && Branch == DefenseTowerBranch.Second + ? "LavaSlime" : "Slime"; + + private void StepSummons(float dt) + { + if (!world.TowersOperational) return; + CommandTarget = FindSummonTarget(); + if (summonCount >= targetSummonCount) return; + summonRespawnRemaining = Mathf.Max(0f, summonRespawnRemaining - dt); + if (summonRespawnRemaining > 0f) return; + AddSummonUnits(1); + summonRespawnRemaining = Tuning.SummonUnitRespawnSeconds; + } + + private Defense25DEnemy FindSummonTarget() + { + Defense25DEnemy nearest = null; + float best = Tuning.SummonAreaRadius * Tuning.SummonAreaRadius; + foreach (Defense25DEnemy enemy in world.GetTowerTargets(Kind)) + { + if (!enemy.IsAvailable || !world.IsTowerShowcaseTarget(Kind, enemy) + || world.IsTowerImmuneToEnemy(Kind, enemy)) continue; + float distance = (enemy.Actor.Position - Position).sqrMagnitude; + if (distance > best) continue; + best = distance; + nearest = enemy; + } + return nearest; + } + + public void NotifySummonUnitDied(Defense25DSummon summon) + { + if (summon == null || !ownedSummons.Contains(summon) + || deadSummons.ContainsKey(summon.SlotIndex)) return; + summonCount = Mathf.Max(0, summonCount - 1); + deadSummons.Add(summon.SlotIndex, summon); + freeSummonSlots.Push(summon.SlotIndex); + summonRespawnRemaining = Mathf.Max(summonRespawnRemaining, + Tuning.SummonUnitRespawnSeconds); + } + + public bool TryAttackSummon(Defense25DSummon summon, Defense25DEnemy target) + { + if (summon == null || target == null || !target.IsAvailable + || !world.TowersOperational) return false; + if (Tier == 4 && Branch == DefenseTowerBranch.Second) + { + ShowScoutAttack(summon.Actor.Position, target); + return true; + } + float damage = Tuning.SummonDamage + * (1f + Tuning.TowerDamageBonusPerTier * (Tier - 1)); + if (Tier == 4 && Branch == DefenseTowerBranch.First) damage *= 1.8f; + bool hit = ApplyDamage(target, damage); + if (hit && Tier == 4 && Branch == DefenseTowerBranch.First) + target.ApplyStun(.3f * SightEfficiency); + return hit; + } + + private void ShowScoutAttack(Vector2 origin, Defense25DEnemy target) + { + if (!target.IsAvailable) return; + Vector2 center = target.Actor.Position; + for (int i = 0; i < 4; i++) + { + Vector2 point = Vector2.Lerp(origin, center, (i + 1) / 5f); + CreateParitySprite("Summons/LavaSlime/GroundSpike-Segment", point, + Vector2.one, 0f, 14, destroy: true, startDelay: i * 0.12f); + } + CreateParityFlight("Summons/LavaSlime/GroundSpike-Segment", origin, center, + 0.48f, 0.16f, target, true, impact => + { + if (CanParityImpact(impact)) + ApplyDamage(impact, Tuning.SummonDamage + * (1f + Tuning.TowerDamageBonusPerTier * (Tier - 1))); + }, scale: Vector2.one); + } + } +} diff --git a/Assets/_Project/Scripts/Defense/Comparison/Defense25DTowerSummons.cs.meta b/Assets/_Project/Scripts/Defense/Comparison/Defense25DTowerSummons.cs.meta new file mode 100644 index 0000000..e83479b --- /dev/null +++ b/Assets/_Project/Scripts/Defense/Comparison/Defense25DTowerSummons.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: cd008c1183ab4dbba67134274f6f09a0 diff --git a/Assets/_Project/Scripts/Defense/Comparison/Defense25DView.cs b/Assets/_Project/Scripts/Defense/Comparison/Defense25DView.cs new file mode 100644 index 0000000..63a35f9 --- /dev/null +++ b/Assets/_Project/Scripts/Defense/Comparison/Defense25DView.cs @@ -0,0 +1,154 @@ +using UnityEngine; +using UnityEngine.UI; + +namespace BumpCombat.Defense.Comparison +{ + [RequireComponent(typeof(Camera))] + [DefaultExecutionOrder(-200)] + public sealed class Defense25DView : MonoBehaviour + { + public const int Width = 640; + public const int Height = 360; + public Camera Camera { get; private set; } + public RenderTexture Texture { get; private set; } + private GameObject presentation; + private RawImage presentationImage; + private int previousCullingMask; + private Vector2 feedbackCameraOffset; + private Vector3 lastAppliedFeedbackOffset; + + private void Awake() + { + Camera = GetComponent(); + SetDefensePresentationAngle(); + Texture = new RenderTexture(Width, Height, 24, RenderTextureFormat.ARGB32) + { + name = "Defense25D 640x360", filterMode = FilterMode.Point, + antiAliasing = 1, useMipMap = false, + }; + Texture.Create(); + Camera.targetTexture = Texture; + Camera.aspect = (float)Width / Height; + + // The world camera renders offscreen; a real display camera presents that texture. + int uiLayer = LayerMask.NameToLayer("UI"); + previousCullingMask = Camera.cullingMask; + Camera.cullingMask &= ~(1 << uiLayer); + presentation = new GameObject("Comparison Display Camera", typeof(Camera)); + presentation.transform.SetParent(transform, false); + Camera output = presentation.GetComponent(); + output.orthographic = true; + output.clearFlags = CameraClearFlags.SolidColor; + output.backgroundColor = Color.black; + output.cullingMask = 1 << uiLayer; + output.depth = Camera.depth + 1f; + output.targetDisplay = Camera.targetDisplay; + output.nearClipPlane = .1f; + output.farClipPlane = 2f; + output.allowHDR = false; + output.allowMSAA = false; + + GameObject canvasObject = new("Comparison Presentation", typeof(Canvas)); + canvasObject.layer = uiLayer; + canvasObject.transform.SetParent(presentation.transform, false); + Canvas canvas = canvasObject.GetComponent(); + canvas.renderMode = RenderMode.ScreenSpaceCamera; + canvas.worldCamera = output; + canvas.planeDistance = 1f; + canvas.pixelPerfect = true; + GameObject imageObject = new("640x360 Point Output", typeof(RectTransform), typeof(RawImage)); + imageObject.layer = uiLayer; + imageObject.transform.SetParent(canvasObject.transform, false); + presentationImage = imageObject.GetComponent(); + presentationImage.texture = Texture; + presentationImage.raycastTarget = false; + presentationImage.rectTransform.anchorMin = presentationImage.rectTransform.anchorMax = Vector2.one * .5f; + LateUpdate(); + } + + private void SetDefensePresentationAngle() + { + Defense25DComparison comparison = FindAnyObjectByType(); + if (comparison == null || comparison.Settings == null) return; + + float groundY = comparison.Settings.GroundY; + Ray centerRay = Camera.ViewportPointToRay(new Vector3(.5f, .5f)); + Plane ground = new(Vector3.up, new Vector3(0f, groundY, 0f)); + if (!ground.Raycast(centerRay, out float distance) || distance <= 0f) return; + + Vector3 focus = centerRay.GetPoint(distance); + Vector3 horizontalForward = Vector3.ProjectOnPlane(Camera.transform.forward, Vector3.up).normalized; + if (horizontalForward.sqrMagnitude < .001f) return; + + float elevation = comparison.Settings.CameraPitch * Mathf.Deg2Rad; + Vector3 direction = horizontalForward * Mathf.Cos(elevation) - Vector3.up * Mathf.Sin(elevation); + float cameraDistance = Vector3.Distance(Camera.transform.position, focus); + Camera.transform.SetPositionAndRotation(focus - direction * cameraDistance, + Quaternion.LookRotation(direction, Vector3.up)); + } + + private void OnEnable() { if (presentation != null) presentation.SetActive(true); } + private void OnDisable() { if (presentation != null) presentation.SetActive(false); } + + private void LateUpdate() + { + if (Camera != null) + { + Camera.transform.position -= lastAppliedFeedbackOffset; + float unitsPerReferencePixel = 2f * Camera.orthographicSize / Height; + lastAppliedFeedbackOffset = Camera.transform.right * (feedbackCameraOffset.x * unitsPerReferencePixel) + + Camera.transform.up * (feedbackCameraOffset.y * unitsPerReferencePixel); + Camera.transform.position += lastAppliedFeedbackOffset; + } + if (presentationImage != null) + presentationImage.rectTransform.sizeDelta = GetViewport(Screen.width, Screen.height).size; + } + + public void SetFeedbackCameraOffset(Vector2 screenPixelOffset) => feedbackCameraOffset = screenPixelOffset; + + public void ClearFeedbackCameraOffset() + { + feedbackCameraOffset = Vector2.zero; + if (Camera != null && lastAppliedFeedbackOffset != Vector3.zero) + { + Camera.transform.position -= lastAppliedFeedbackOffset; + lastAppliedFeedbackOffset = Vector3.zero; + } + } + + public static Rect GetViewport(int width, int height) + { + float fit = Mathf.Min(width / (float)Width, height / (float)Height); + float scale = fit >= 1f ? Mathf.Floor(fit) : fit; + return new Rect((width - Width * scale) * .5f, (height - Height * scale) * .5f, + Width * scale, Height * scale); + } + + public bool TryScreenToGround(Vector2 screen, float groundY, out Vector2 point) + { + point = default; + Rect viewport = GetViewport(Screen.width, Screen.height); + if (Camera == null || !viewport.Contains(screen) || viewport.width <= 0f) return false; + Vector2 uv = (screen - viewport.position) / viewport.size; + Ray ray = Camera.ViewportPointToRay(new Vector3(uv.x, uv.y, 0f)); + Plane plane = new(Vector3.up, new Vector3(0f, groundY, 0f)); + if (!plane.Raycast(ray, out float distance) || distance < 0f) return false; + point = Defense25DActor.Ground(ray.GetPoint(distance)); + return true; + } + + public Vector2 KeyboardToGround(Vector2 keys) + { + Vector2 right = Defense25DActor.Ground(transform.right).normalized; + Vector2 up = Defense25DActor.Ground(transform.up).normalized; + return Vector2.ClampMagnitude(right * keys.x + up * keys.y, 1f); + } + + private void OnDestroy() + { + if (presentation != null) { presentation.SetActive(false); Destroy(presentation); } + if (Camera != null) { Camera.targetTexture = null; Camera.cullingMask = previousCullingMask; } + if (Texture != null) { Texture.Release(); Destroy(Texture); } + } + } +} diff --git a/Assets/_Project/Scripts/Defense/Comparison/Defense25DView.cs.meta b/Assets/_Project/Scripts/Defense/Comparison/Defense25DView.cs.meta new file mode 100644 index 0000000..36f3568 --- /dev/null +++ b/Assets/_Project/Scripts/Defense/Comparison/Defense25DView.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 18762ed1791d450386964308b36444f2 diff --git a/Assets/_Project/Scripts/Defense/DefensePrototypeController.cs b/Assets/_Project/Scripts/Defense/DefensePrototypeController.cs index 235fdeb..6385c8a 100644 --- a/Assets/_Project/Scripts/Defense/DefensePrototypeController.cs +++ b/Assets/_Project/Scripts/Defense/DefensePrototypeController.cs @@ -19,6 +19,7 @@ namespace BumpCombat.Defense private const int BuildPadCount = 6; private static Sprite whiteSprite; + private static bool startTowerHitTestRun; private readonly Dictionary activeAgents = new(); private readonly DefenseTower[] towers = new DefenseTower[BuildPadCount]; @@ -29,6 +30,7 @@ namespace BumpCombat.Defense private readonly bool[] portalSupplyEnded = new bool[LaneCount]; private readonly bool[] portalRewardVisible = new bool[LaneCount]; private readonly bool[] portalRewardObjectCreated = new bool[LaneCount]; + private readonly Dictionary towerTestDummyKinds = new(); private readonly GameObject[] portalRewardObjects = new GameObject[LaneCount]; private readonly GameObject[] portalRewardLabels = new GameObject[LaneCount]; private int baseHealth; @@ -37,6 +39,7 @@ namespace BumpCombat.Defense private bool active; private bool playerDead; private bool failed; + private bool towerHitTestLayout; private int constructionCurrency; private int pendingPassiveRelicChoices; private float reviveRemaining; @@ -49,6 +52,16 @@ namespace BumpCombat.Defense private DefenseConstants tuning; public static DefensePrototypeController Instance { get; private set; } + public static void PrepareTowerHitTestRun() + { + startTowerHitTestRun = true; + } + + public static void CancelTowerHitTestRun() + { + startTowerHitTestRun = false; + } + public static Sprite WhiteSprite { get @@ -69,6 +82,7 @@ namespace BumpCombat.Defense public DefenseConstants Tuning => tuning ?? GameplayConstants.Current.Defense; public bool IsActive => active; + public bool IsTowerHitTestLayout => towerHitTestLayout; public bool PlayerIsAlive => playerHealth != null && playerHealth.CurrentHealth > 0f; public bool CanOperateTowers => DefensePrototypeRules.CanOperateTowers( PlayerIsAlive, @@ -195,9 +209,12 @@ namespace BumpCombat.Defense } UpdatePlayerRecovery(); - UpdatePortalSupply(); - TryOfferRewards(); - TryCollectRewards(); + if (!towerHitTestLayout) + { + UpdatePortalSupply(); + TryOfferRewards(); + TryCollectRewards(); + } UpdateHud(); if (Keyboard.current != null && CanOperateTowers) @@ -232,6 +249,7 @@ namespace BumpCombat.Defense } tuning = GameplayConstants.Current.Defense; + towerHitTestLayout = startTowerHitTestRun && runManager.IsDefensePrototypeRun; spawnDirector = FindAnyObjectByType(); playerHealth = FindAnyObjectByType(); playerController = FindAnyObjectByType(); @@ -277,6 +295,10 @@ namespace BumpCombat.Defense nextSupplyTime = Time.time + 0.5f; CreateWorldVisuals(); CreateHud(); + if (towerHitTestLayout) + { + CreateTowerHitTestLayout(); + } UpdateHud(); } @@ -468,6 +490,9 @@ namespace BumpCombat.Defense || agent.HasArrived || agent.Enemy == null || agent.Enemy.IsDead + || (towerHitTestLayout + && towerTestDummyKinds.TryGetValue(agent, out DefenseTowerKind dummyKind) + && dummyKind != sourceKind) || (!ignoreTowerImmunity && DefensePrototypeRules.IsTowerImmune( sourceKind, @@ -487,7 +512,26 @@ namespace BumpCombat.Defense return nearest; } - public List GetTargetsInRange(Vector2 position, float radius) + public DefenseSummonUnit FindNearestSummonTarget(Vector2 position, float radius) + { + DefenseSummonUnit nearest = null; + float nearestDistance = radius * radius; + foreach (DefenseTower tower in towers) + { + if (tower == null || tower.Kind != DefenseTowerKind.Summon || !tower.IsOperational) continue; + foreach (DefenseSummonUnit unit in tower.GetComponentsInChildren()) + { + if (unit == null || !unit.IsOperational) continue; + float distance = ((Vector2)unit.transform.position - position).sqrMagnitude; + if (distance <= nearestDistance) { nearestDistance = distance; nearest = unit; } + } + } + return nearest; + } + public List GetTargetsInRange( + Vector2 position, + float radius, + DefenseTowerKind? sourceKind = null) { List targets = new(); float radiusSquared = radius * radius; @@ -495,6 +539,10 @@ namespace BumpCombat.Defense { if (agent != null && !agent.HasArrived && agent.Enemy != null && !agent.Enemy.IsDead + && (!towerHitTestLayout + || !sourceKind.HasValue + || !towerTestDummyKinds.TryGetValue(agent, out DefenseTowerKind dummyKind) + || dummyKind == sourceKind.Value) && ((Vector2)agent.transform.position - position).sqrMagnitude <= radiusSquared) { @@ -694,13 +742,31 @@ namespace BumpCombat.Defense .BattleBatonTowerDamageIncrease; } - return target.TryTakeTowerDamage( + EnemyController enemy = target.Enemy; + float healthBefore = enemy != null ? enemy.CurrentHealth : 0f; + bool hit = target.TryTakeTowerDamage( tower.Kind, damage * multiplier, distanceToPlayer, tuning.PlayerSightRadius, tuning.OutsideSightDamageEfficiency, tower.transform.position); + float appliedDamage = enemy != null + ? Mathf.Max(0f, healthBefore - enemy.CurrentHealth) + : 0f; + if (appliedDamage > 0f) + { + FindAnyObjectByType()?.ShowTowerDamagePopup( + appliedDamage, + tower.HitFeedbackColor, + target.transform.position); + BumpCombat.Combat.CombatFeedback.ShowTowerImpact( + tower.Kind, + target.Enemy, + target.transform.position); + } + + return hit; } public bool HandlePlayerDied() @@ -752,6 +818,59 @@ namespace BumpCombat.Defense return !DefensePrototypeRules.IsBaseDestroyed(baseHealth); } + private void CreateTowerHitTestLayout() + { + constructionCurrency = Mathf.Max( + constructionCurrency, + BuildPadCount * (tuning.Tier2UpgradeCost + + tuning.Tier3UpgradeCost + tuning.Tier4UpgradeCost)); + for (int pad = 0; pad < BuildPadCount; pad++) + { + DefenseTowerKind kind = DefenseTowerHitTestLayout.KindsByPad[pad]; + GameObject towerObject = new($"Tower Test {kind}"); + towerObject.transform.SetParent(worldRoot.transform, false); + towerObject.transform.position = tuning.GetBuildPadPosition(pad); + DefenseTower tower = towerObject.AddComponent(); + tower.Configure(this, kind, pad, tuning); + towers[pad] = tower; + padsBuilt[pad] = true; + + Vector2 dummyPosition = DefenseTowerHitTestLayout.DummyPositionsByPad[pad]; + SpawnTowerTestDummy(kind, dummyPosition); + CreateWorldLabel( + $"Tower Test Dummy {kind} Label", + dummyPosition + Vector2.up * 1.1f, + kind == DefenseTowerKind.Direct + ? "DUMMY FIRE" : $"DUMMY {kind.ToString().ToUpperInvariant()}"); + } + } + + private void SpawnTowerTestDummy(DefenseTowerKind kind, Vector2 position) + { + if (spawnDirector == null) + { + return; + } + + EnemyController enemy = spawnDirector.SpawnDefenseSkeleton(position); + if (enemy == null) + { + return; + } + + enemy.name = $"Tower Test Dummy {kind}"; + enemy.TryReposition(position); + enemy.ConfigureTowerTestDummy(); + DefenseRouteAgent agent = enemy.gameObject.AddComponent(); + agent.ConfigureTowerTestDummy( + enemy, + kind == DefenseTowerKind.Empowering + ? DefenseTowerKind.Direct : DefenseTowerKind.Empowering); + enemy.OnDied += HandleEnemyDied; + activeAgents.Add(enemy, agent); + towerTestDummyKinds.Add(agent, kind); + } + private void UpdatePortalSupply() { if (spawnDirector == null || Time.time < nextSupplyTime) @@ -826,6 +945,16 @@ namespace BumpCombat.Defense enemy.OnDied -= HandleEnemyDied; activeAgents.Remove(enemy); + if (towerHitTestLayout + && towerTestDummyKinds.TryGetValue(agent, out DefenseTowerKind dummyKind)) + { + towerTestDummyKinds.Remove(agent); + SpawnTowerTestDummy( + dummyKind, + DefenseTowerHitTestLayout.GetDummyPosition(dummyKind)); + return; + } + int lane = Mathf.Clamp(agent.Lane, 0, LaneCount - 1); if (portalLedgers[lane]?.TryResolve( enemy.SpawnLifetimeIdentity, @@ -944,7 +1073,8 @@ namespace BumpCombat.Defense private void TryFinishClear() { - if (failed || activeAgents.Count > 0 || pendingPassiveRelicChoices > 0 || runManager == null) + if (towerHitTestLayout + || failed || activeAgents.Count > 0 || pendingPassiveRelicChoices > 0 || runManager == null) { return; } @@ -1018,9 +1148,12 @@ namespace BumpCombat.Defense DefenseTowerKind immune = tuning.GetPortalImmuneTowerKind(lane); string immuneName = immune == DefenseTowerKind.Direct ? "FIRE" : immune.ToString().ToUpperInvariant(); - CreateWorldLabel($"Portal {lane + 1} Label", - portal + Vector2.right * 1.6f + Vector2.up * 0.9f, - $"P{lane + 1} PORTAL\nIMMUNE {immuneName}"); + if (!towerHitTestLayout) + { + CreateWorldLabel($"Portal {lane + 1} Label", + portal + Vector2.right * 1.6f + Vector2.up * 0.9f, + $"P{lane + 1} PORTAL\nIMMUNE {immuneName}"); + } if (lane == 0) { CreateShape( @@ -1098,6 +1231,16 @@ namespace BumpCombat.Defense : nearby.Tier == 3 ? $"J {DefenseTower.GetBranchName(nearby.Kind, DefenseTowerBranch.First)} / K {DefenseTower.GetBranchName(nearby.Kind, DefenseTowerBranch.Second)} {tuning.Tier4UpgradeCost}" : $"U TIER {nearby.Tier + 1} {(nearby.Tier == 1 ? tuning.Tier2UpgradeCost : tuning.Tier3UpgradeCost)}"; + if (towerHitTestLayout) + { + hudText.text = + "TOWER HIT TEST\n" + + "Fixed dummies auto-recreate when defeated; no portal waves or rewards\n" + + $"BUILD {constructionCurrency} U: T1→T2→T3 J/K: choose a T4 branch\n" + + upgrade; + return; + } + hudText.text = $"DEFENSE TEST BASE {baseHealth}/{tuning.BaseMaximumHealth}\n" + $"BUILD {constructionCurrency} P1 WAIT {GetPortalRemainingSupply(0)} LIVE {CountActiveAgentsForLane(0)} P2 WAIT {GetPortalRemainingSupply(1)} LIVE {CountActiveAgentsForLane(1)}\n" diff --git a/Assets/_Project/Scripts/Defense/DefenseRouteAgent.cs b/Assets/_Project/Scripts/Defense/DefenseRouteAgent.cs index aecd7ad..ff5b046 100644 --- a/Assets/_Project/Scripts/Defense/DefenseRouteAgent.cs +++ b/Assets/_Project/Scripts/Defense/DefenseRouteAgent.cs @@ -18,6 +18,8 @@ namespace BumpCombat.Defense private float postAttackProgressRemaining; private bool wasAttackSequenceInProgress; private bool configured; + private bool stationaryTowerTestDummy; + private Vector2 stationaryTowerTestDummyAnchor; private int waypointIndex; public EnemyController Enemy => enemy; @@ -60,8 +62,44 @@ namespace BumpCombat.Defense } } + public void ConfigureTowerTestDummy(EnemyController configuredEnemy, DefenseTowerKind immuneTowerKind) + { + enemy = configuredEnemy; + Lane = -1; + waypoints = Array.Empty(); + routeSpeed = 0f; + playerEngagementRange = 0f; + postAttackProgressSeconds = 0f; + ImmuneTowerKind = immuneTowerKind; + arrivalHandler = null; + waypointIndex = 0; + configured = enemy != null; + stationaryTowerTestDummy = configured; + stationaryTowerTestDummyAnchor = configuredEnemy != null + ? configuredEnemy.transform.position : Vector2.zero; + HasArrived = false; + player = FindAnyObjectByType()?.transform; + playerHealth = player != null ? player.GetComponent() : null; + } + public bool TryDriveFixedUpdate(float deltaTime) { + if (stationaryTowerTestDummy) + { + if (enemy == null || enemy.IsDead) + { + return true; + } + if (enemy.State == EnemyState.Spawn) + { + return false; + } + + enemy.SetDefenseCombatTarget(null); + enemy.TryReposition(stationaryTowerTestDummyAnchor); + return true; + } + if (!configured || HasArrived || enemy == null || enemy.IsDead) { return false; @@ -90,6 +128,17 @@ namespace BumpCombat.Defense return false; } + DefensePrototypeController defense = DefensePrototypeController.Instance; + DefenseSummonUnit summonTarget = defense != null + ? defense.FindNearestSummonTarget(transform.position, + Mathf.Max(playerEngagementRange, defense.Tuning.SummonAreaRadius)) + : null; + enemy.SetDefenseCombatTarget(summonTarget); + if (summonTarget != null) + { + enemy.Alert(); + return false; + } postAttackProgressRemaining = Mathf.Max( 0f, postAttackProgressRemaining - Mathf.Max(0f, deltaTime)); diff --git a/Assets/_Project/Scripts/Defense/DefenseSpriteSequence.cs b/Assets/_Project/Scripts/Defense/DefenseSpriteSequence.cs index 48d0a23..5cd782d 100644 --- a/Assets/_Project/Scripts/Defense/DefenseSpriteSequence.cs +++ b/Assets/_Project/Scripts/Defense/DefenseSpriteSequence.cs @@ -1,3 +1,4 @@ +using System; using UnityEngine; using BumpCombat.Constants; @@ -17,9 +18,17 @@ namespace BumpCombat.Defense private float delayRemaining; private bool animationFinished; private Vector2 travelStart; + private System.Func travelStartProvider; private Vector2 travelEnd; private float travelDuration; private float travelElapsed; + private Action travelArrival; + private System.Func travelEndProvider; + private System.Func travelCanAdvance; + private bool travelArrivalInvoked; + private bool hideRendererAtArrival; + private System.Action completionCallback; + private bool finished; private bool fitsLightningBeam; private Vector2 beamStart; private Vector2 beamEnd; @@ -27,11 +36,16 @@ namespace BumpCombat.Defense private bool centersImpactWave; private Vector2 impactWaveCenter; private float impactWaveScale; + private bool fitsLightningStormGround; + private Vector2 lightningStormGroundCenter; + private float lightningStormGroundRadius; public void Play(SpriteRenderer renderer, string sequenceKey, Sprite[] sequenceFrames, bool destroyOnFinish, string followingKey = null, Sprite[] followingFrames = null, float startDelay = 0f) { + completionCallback = null; + finished = false; target = renderer; key = sequenceKey; frames = sequenceFrames; @@ -41,8 +55,7 @@ namespace BumpCombat.Defense delayRemaining = Mathf.Max(0f, startDelay); if (target == null || frames == null || frames.Length == 0) { - enabled = false; - if (destroyWhenFinished) Destroy(gameObject); + FinishSequence(); return; } frame = 0; @@ -69,13 +82,55 @@ namespace BumpCombat.Defense UpdateVisualAlignment(); } - public void Travel(Vector2 start, Vector2 end, float duration) + public void FitLightningStormGround(Vector2 center, float radius) + { + fitsLightningStormGround = true; + lightningStormGroundCenter = center; + lightningStormGroundRadius = Mathf.Max(0f, radius); + UpdateVisualAlignment(); + } + + public bool IsFinished => finished; + + public void SetCompletionCallback(System.Action callback) + { + if (callback == null) return; + if (finished) callback(); + else completionCallback += callback; + } + + public void SetCanAdvance(System.Func canAdvance) + { + travelCanAdvance = canAdvance; + } + + public void Travel(Vector2 start, Vector2 end, float duration, + Action onArrival = null, System.Func endProvider = null, + System.Func canAdvance = null, bool hideAtArrival = false, + System.Func startProvider = null) { travelStart = start; + travelStartProvider = startProvider; travelEnd = end; travelDuration = Mathf.Max(0.001f, duration); travelElapsed = 0f; - transform.position = start; + travelArrival = onArrival; + travelEndProvider = endProvider; + travelCanAdvance = canAdvance ?? travelCanAdvance; + travelArrivalInvoked = false; + hideRendererAtArrival = hideAtArrival; + if (delayRemaining <= 0f) ResolveTravelStart(); + else transform.position = start; + } + + private void ResolveTravelStart() + { + if (travelStartProvider != null) + { + travelStart = travelStartProvider(); + travelStartProvider = null; + } + transform.position = travelStart; } private void Update() @@ -85,17 +140,28 @@ namespace BumpCombat.Defense FinishSequence(); return; } + if (travelCanAdvance != null && !travelCanAdvance()) return; if (delayRemaining > 0f) { delayRemaining -= Time.deltaTime; if (delayRemaining > 0f) return; + if (travelDuration > 0f || travelStartProvider != null) + ResolveTravelStart(); target.enabled = true; } if (travelDuration > 0f && travelElapsed < travelDuration) { travelElapsed += Time.deltaTime; + Vector2 currentEnd = travelEndProvider != null + ? travelEndProvider() : travelEnd; transform.position = Vector2.Lerp( - travelStart, travelEnd, Mathf.Clamp01(travelElapsed / travelDuration)); + travelStart, currentEnd, Mathf.Clamp01(travelElapsed / travelDuration)); + if (travelElapsed >= travelDuration && !travelArrivalInvoked) + { + travelArrivalInvoked = true; + if (hideRendererAtArrival && target != null) target.enabled = false; + travelArrival?.Invoke(); + } } if (animationFinished) { @@ -147,6 +213,16 @@ namespace BumpCombat.Defense transform.position = beamStart - (Vector2)(transform.right * localStart); transform.localScale = new Vector3(scaleX, beamThickness, 1f); } + if (fitsLightningStormGround) + { + float scale = lightningStormGroundRadius * target.sprite.pixelsPerUnit + / DefenseTowerArtBounds.LightningStormGroundRadiusPixels; + transform.localScale = Vector3.one * scale; + Vector2 localCenter = (DefenseTowerArtBounds.LightningStormGroundCenterPixels + - target.sprite.pivot) / target.sprite.pixelsPerUnit; + transform.position = lightningStormGroundCenter + - (Vector2)transform.TransformVector(localCenter); + } if (centersImpactWave) { transform.localScale = Vector3.one * impactWaveScale; @@ -158,6 +234,8 @@ namespace BumpCombat.Defense private void FinishSequence() { + if (finished) return; + finished = true; if (destroyWhenFinished) { Destroy(gameObject); @@ -167,6 +245,10 @@ namespace BumpCombat.Defense if (target != null) target.enabled = false; enabled = false; } + + System.Action callback = completionCallback; + completionCallback = null; + callback?.Invoke(); } } } diff --git a/Assets/_Project/Scripts/Defense/DefenseSummonUnit.cs b/Assets/_Project/Scripts/Defense/DefenseSummonUnit.cs index 08d7ead..a27b849 100644 --- a/Assets/_Project/Scripts/Defense/DefenseSummonUnit.cs +++ b/Assets/_Project/Scripts/Defense/DefenseSummonUnit.cs @@ -1,9 +1,12 @@ using UnityEngine; +using BumpCombat.Combat; +using BumpCombat.Core; +using BumpCombat.Player; namespace BumpCombat.Defense { [DisallowMultipleComponent] - public sealed class DefenseSummonUnit : MonoBehaviour + public sealed class DefenseSummonUnit : CharacterModel { private Sprite[] idleFrames; private Sprite[] attackFrames; @@ -21,19 +24,46 @@ namespace BumpCombat.Defense private Vector2 anchorLocalPosition; private SpriteRenderer spriteRenderer; private float attackCooldown; + private CircleCollider2D bodyCollider; + private Rigidbody2D body; + private static readonly RaycastHit2D[] MovementHits = new RaycastHit2D[16]; + private float currentHealth; + private bool dead; + private bool returningToAnchor; + private DefenseRouteAgent pendingAttackTarget; + private bool attackDamageApplied; + public int SlotIndex { get; private set; } + public override float MoveSpeed => controller != null ? controller.Tuning.SummonUnitMoveSpeed : 0f; + public override float MaxHealth => owner != null ? owner.SummonUnitMaximumHealth : 1f; + public bool IsDead => dead; + public float CurrentHealth => currentHealth; + public DefenseTower Owner => owner; + public Collider2D BodyCollider => bodyCollider; + public bool IsOperational => isActiveAndEnabled && !dead && owner != null && owner.IsOperational; public void Configure( DefenseTower tower, DefensePrototypeController prototypeController, - Vector2 localAnchor) + Vector2 localAnchor, + int slotIndex) { + IsMinion = true; owner = tower; controller = prototypeController; + SlotIndex = slotIndex; anchorLocalPosition = localAnchor; + bodyCollider = gameObject.AddComponent(); + bodyCollider.radius = owner.SummonUnitCollisionRadius; + body = gameObject.AddComponent(); + body.bodyType = RigidbodyType2D.Kinematic; + body.gravityScale = 0f; + body.freezeRotation = true; spriteRenderer = gameObject.AddComponent(); spriteRenderer.color = Color.white; spriteRenderer.sortingOrder = 11; + IgnorePlayerCollisionPairs(); RefreshArt(); + currentHealth = MaxHealth; } public void RefreshArt() @@ -64,54 +94,195 @@ namespace BumpCombat.Defense transform.localScale = new Vector3(scale, scale, 1f); } - private void Update() + private void FixedUpdate() { + if (owner == null || !owner.isActiveAndEnabled + || controller == null || !IsOperational) return; + UpdateSpriteAnimation(); - if (owner == null - || !owner.isActiveAndEnabled - || controller == null - || !controller.CanOperateTowers) + attackCooldown = Mathf.Max(0f, attackCooldown - Time.deltaTime); + Vector2 anchor = owner.transform.TransformPoint(anchorLocalPosition); + bool isBasicSlime = owner.Tier < 4; + bool isScout = owner.Tier == 4 + && owner.Branch == DefenseTowerBranch.Second; + + if (playingAttack) { + if (!attackDamageApplied + && (pendingAttackTarget == null + || pendingAttackTarget != owner.CommandTarget + || pendingAttackTarget.HasArrived + || pendingAttackTarget.Enemy == null + || pendingAttackTarget.Enemy.IsDead)) + { + playingAttack = false; + attackDamageApplied = true; + pendingAttackTarget = null; + if (isBasicSlime) returningToAnchor = true; + spriteFrame = 0; + spriteFrameTime = 0f; + if (idleFrames != null && idleFrames.Length > 0) + spriteRenderer.sprite = idleFrames[0]; + return; + } + + Vector2 contactPosition = body.position; + if (isBasicSlime && spriteFrame >= controller.Tuning.SummonBodySlamImpactFrame) + { + if (pendingAttackTarget != null && !pendingAttackTarget.HasArrived + && pendingAttackTarget.Enemy != null && !pendingAttackTarget.Enemy.IsDead) + { + contactPosition = MoveBody(Vector2.MoveTowards(body.position, + pendingAttackTarget.transform.position, + MoveSpeed * controller.Tuning.SummonBodySlamSpeedMultiplier + * Time.fixedDeltaTime)); + } + } + + if (!attackDamageApplied + && spriteFrame >= controller.Tuning.SummonBodySlamImpactFrame + && pendingAttackTarget != null && !pendingAttackTarget.HasArrived + && pendingAttackTarget.Enemy != null && !pendingAttackTarget.Enemy.IsDead + && (isScout || !isBasicSlime + || IsAtContactDistance(pendingAttackTarget, contactPosition))) + { + bool hit = owner.TryAttack(pendingAttackTarget); + attackDamageApplied = true; + if (hit) owner.ShowSummonAttackEffect(body.position, pendingAttackTarget); + } return; } - attackCooldown = Mathf.Max(0f, attackCooldown - Time.deltaTime); - Vector2 anchor = owner.transform.TransformPoint(anchorLocalPosition); - DefenseRouteAgent target = controller.FindNearestTarget( - anchor, - controller.Tuning.SummonAreaRadius, - DefenseTowerKind.Summon); - if (target == null) + if (returningToAnchor) { - Vector2 previous = transform.position; - transform.position = Vector2.MoveTowards( - previous, anchor, controller.Tuning.SummonUnitMoveSpeed * Time.deltaTime); - isWalking = ((Vector2)transform.position - previous).sqrMagnitude > 0.000001f; + Vector2 previous = body.position; + Vector2 next = MoveBody(Vector2.MoveTowards(previous, anchor, + MoveSpeed * Time.fixedDeltaTime)); + isWalking = (next - previous).sqrMagnitude > 0.000001f; + if ((next - anchor).sqrMagnitude < 0.01f + || (next - previous).sqrMagnitude < 0.000001f) + { + returningToAnchor = false; + } + return; + } + + DefenseRouteAgent target = owner.CommandTarget; + if (target == null || target.HasArrived || target.Enemy == null || target.Enemy.IsDead) + { + Vector2 previous = body.position; + Vector2 next = MoveBody(Vector2.MoveTowards(previous, anchor, + MoveSpeed * Time.fixedDeltaTime)); + isWalking = (next - previous).sqrMagnitude > 0.000001f; return; } Vector2 targetPosition = target.transform.position; spriteRenderer.flipX = targetPosition.x < transform.position.x; - Vector2 previousPosition = transform.position; - transform.position = Vector2.MoveTowards( - previousPosition, targetPosition, - controller.Tuning.SummonUnitMoveSpeed * Time.deltaTime); - isWalking = ((Vector2)transform.position - previousPosition).sqrMagnitude > 0.000001f; float attackRange = controller.Tuning.SummonAttackRange - * (owner.Tier == 4 && owner.Branch == DefenseTowerBranch.Second ? 2.5f : 1f); - if (attackCooldown <= 0f - && (targetPosition - (Vector2)transform.position).sqrMagnitude - <= attackRange * attackRange - && owner.TryAttack(target)) + * (isScout ? 2.5f : 1f); + Vector2 previousPosition = body.position; + Vector2 toTarget = targetPosition - previousPosition; + Vector2 stopPosition = targetPosition - toTarget.normalized * attackRange * 0.8f; + Vector2 nextPosition = previousPosition; + if (toTarget.magnitude > attackRange * 0.8f) { + nextPosition = MoveBody(Vector2.MoveTowards(previousPosition, + stopPosition, MoveSpeed * Time.fixedDeltaTime)); + } + isWalking = (nextPosition - previousPosition).sqrMagnitude > 0.000001f; + if (attackCooldown <= 0f + && (targetPosition - nextPosition).sqrMagnitude <= attackRange * attackRange) + { + pendingAttackTarget = target; + attackDamageApplied = false; + returningToAnchor = isBasicSlime; PlayAttackSprite(); - owner.ShowSummonAttackEffect(transform.position, targetPosition); attackCooldown = controller.Tuning.SummonAttackInterval / (1f + 0.15f * (owner.Tier - 1)) * controller.GetTowerAttackIntervalMultiplier(owner); } } + private bool IsAtContactDistance(DefenseRouteAgent target, Vector2 summonCenter) + { + Collider2D targetCollider = target.Enemy.GetComponent(); + if (targetCollider == null) + { + return Vector2.Distance(summonCenter, target.transform.position) + <= controller.Tuning.SummonBodySlamContactMargin; + } + + Vector2 targetSurface = targetCollider.ClosestPoint(summonCenter); + float bodyRadius = bodyCollider.bounds.extents.x; + float surfaceGap = Vector2.Distance(summonCenter, targetSurface) - bodyRadius; + return surfaceGap <= controller.Tuning.SummonBodySlamContactMargin; + } + + private void IgnorePlayerCollisionPairs() + { + if (!IsMinion) return; + PlayerController player = FindAnyObjectByType(); + CharacterModel playerModel = player != null + ? player.GetComponent() : null; + if (player == null || playerModel == null || playerModel.IsMinion) return; + + Collider2D[] playerColliders = player.GetComponentsInChildren(true); + for (int i = 0; i < playerColliders.Length; i++) + { + Collider2D playerCollider = playerColliders[i]; + if (playerCollider != null && playerCollider != bodyCollider) + { + Physics2D.IgnoreCollision(bodyCollider, playerCollider, true); + } + } + } + private Vector2 MoveBody(Vector2 destination) + { + Vector2 delta = destination - body.position; + float distance = delta.magnitude; + if (distance <= 0.0001f) return body.position; + Vector2 direction = delta / distance; + ContactFilter2D filter = ContactFilter2D.noFilter; + filter.useTriggers = false; + int hitCount = bodyCollider.Cast(direction, filter, MovementHits, distance); + for (int i = 0; i < hitCount; i++) + { + Collider2D hit = MovementHits[i].collider; + if (hit == null || hit.transform == transform) continue; + if (IsMinion && hit.GetComponentInParent() != null) continue; + if (MovementHits[i].distance <= 0.001f + && Vector2.Dot(direction, body.position - (Vector2)hit.bounds.center) >= 0f) continue; + distance = Mathf.Min(distance, Mathf.Max(0f, MovementHits[i].distance - 0.01f)); + } + Vector2 next = body.position + direction * distance; + body.MovePosition(next); + return next; + } + public void RetireForReplacement() + { + if (bodyCollider != null) bodyCollider.enabled = false; + if (spriteRenderer != null) spriteRenderer.enabled = false; + if (body != null) body.simulated = false; + enabled = false; + owner = null; + gameObject.SetActive(false); + } + public bool TryTakeDamage(float damage, Vector2 knockbackDirection) + { + if (!IsOperational || damage <= 0f) return false; + float applied = DamageCalculator.FinalizeDamage(CalculateIncomingDamage(damage)); + if (applied <= 0f) return false; + currentHealth = Mathf.Max(0f, currentHealth - applied); + if (currentHealth <= 0f) + { + dead = true; + bodyCollider.enabled = false; + spriteRenderer.enabled = false; + owner.NotifySummonUnitDied(this); + } + return true; + } private void PlayAttackSprite() { if (attackFrames == null || attackFrames.Length == 0) diff --git a/Assets/_Project/Scripts/Defense/DefenseTower.cs b/Assets/_Project/Scripts/Defense/DefenseTower.cs index f712a7a..e1c4b8c 100644 --- a/Assets/_Project/Scripts/Defense/DefenseTower.cs +++ b/Assets/_Project/Scripts/Defense/DefenseTower.cs @@ -1,4 +1,5 @@ using BumpCombat.Constants; +using System.Collections.Generic; using UnityEngine; namespace BumpCombat.Defense @@ -13,6 +14,18 @@ namespace BumpCombat.Defense private LineRenderer effectRange; private static Material effectMaterial; private int summonCount; + private int nextSummonIndex; + private int targetSummonCount; + private float summonRespawnRemaining; + private readonly Stack freeSummonSlots = new(); + private readonly Dictionary deadSummons = new(); + private readonly HashSet attackEffectRoots = new(); + private GameObject flameBeamEffect; + private SpriteRenderer flameBeamRenderer; + private DefenseRouteAgent flameBeamTarget; + private float flameBeamStreakStartedAt; + private float flameBeamLastSuccessfulAttackAt; + private bool flameBeamStreakActive; private SpriteRenderer bodyRenderer; private SpriteRenderer supportAuraRenderer; private SpriteRenderer supportRangeRenderer; @@ -37,6 +50,10 @@ namespace BumpCombat.Defense && owner != null && owner.CanOperateTowers; public bool IsDamaging => Kind != DefenseTowerKind.Empowering; + public Color HitFeedbackColor => GetTowerColor(); + public float SummonUnitMaximumHealth => tuning != null ? tuning.SummonUnitMaximumHealth : 1f; + public float SummonUnitCollisionRadius => tuning != null ? tuning.SummonUnitCollisionRadius : 0.32f; + public DefenseRouteAgent CommandTarget { get; private set; } public void Configure( DefensePrototypeController controller, @@ -50,6 +67,8 @@ namespace BumpCombat.Defense tuning = constants; Tier = 1; Branch = DefenseTowerBranch.None; + summonCount = 0; + targetSummonCount = Kind == DefenseTowerKind.Summon ? tuning.SummonUnitCount : 0; CreateVisuals(); if (Kind == DefenseTowerKind.Summon) { @@ -74,20 +93,10 @@ namespace BumpCombat.Defense if (Kind == DefenseTowerKind.Summon) { - if (Tier == 4 && Branch == DefenseTowerBranch.First) - { - foreach (DefenseSummonUnit unit in GetComponentsInChildren()) - { - unit.enabled = false; - Destroy(unit.gameObject); - } - summonCount = 0; - AddSummonUnits(1); - } - else - { - AddSummonUnits(Tier == 4 ? 2 : 1); - } + int upgradedSummonCount = Tier == 4 && Branch == DefenseTowerBranch.First + ? 1 + : targetSummonCount + (Tier == 4 ? 2 : 1); + ReplaceSummonUnits(upgradedSummonCount); } UpdateLabel(); @@ -107,11 +116,230 @@ namespace BumpCombat.Defense { supportAuraRenderer.enabled = false; } + ClearAttackEffects(); + EndFlamethrowerBeam(immediate: true, resetStreak: true); + CommandTarget = null; + } + + private void TrackAttackEffect(GameObject effect) + { + if (effect == null) return; + attackEffectRoots.Add(effect); + DefenseSpriteSequence[] sequences = + effect.GetComponentsInChildren(true); + if (sequences.Length == 0) + { + DestroyAttackEffectWhenComplete(effect); + return; + } + + foreach (DefenseSpriteSequence sequence in sequences) + { + TrackAttackSequence(effect, sequence); + } + } + + private void TrackAttackSequence(GameObject effect, DefenseSpriteSequence sequence) + { + if (effect == null || sequence == null) return; + attackEffectRoots.Add(effect); + sequence.SetCompletionCallback(() => DestroyAttackEffectWhenComplete(effect)); + } + + private void DestroyAttackEffectWhenComplete(GameObject effect) + { + if (effect == null || !attackEffectRoots.Contains(effect)) return; + foreach (DefenseSpriteSequence sequence + in effect.GetComponentsInChildren(true)) + { + if (!sequence.IsFinished) return; + } + + attackEffectRoots.Remove(effect); + Destroy(effect); + } + + private void ClearAttackEffects() + { + foreach (GameObject effect in attackEffectRoots) + { + if (effect != null) Destroy(effect); + } + attackEffectRoots.Clear(); + } + + private bool IsFireT4Flamethrower => Kind == DefenseTowerKind.Direct + && Tier == 4 && Branch == DefenseTowerBranch.Second; + + private bool IsFlamethrowerTargetValid(DefenseRouteAgent target) + { + return target != null && !target.HasArrived && target.Enemy != null + && !target.Enemy.IsDead + && Vector2.Distance(transform.position, target.transform.position) + <= GetEffectRange(); + } + + private void UpdateFlamethrowerBeam() + { + if (flameBeamStreakActive + && Time.time - flameBeamLastSuccessfulAttackAt + >= tuning.FireT4BeamResetAfterSeconds) + { + EndFlamethrowerBeam(); + ResetFlamethrowerStreak(); + return; + } + if (flameBeamEffect == null) return; + if (!IsOperational || !IsFireT4Flamethrower + || !IsFlamethrowerTargetValid(flameBeamTarget)) + { + EndFlamethrowerBeam(); + return; + } + Sprite[] frames = DefenseTowerArt.Load("Effects/Fire/Eyeball Monster_Beam-Clean"); + if (Time.time - flameBeamStreakStartedAt >= DefenseTowerArtBounds.FireBeamIgnitionSeconds + && flameBeamRenderer.sprite != frames[1]) + flameBeamRenderer.sprite = frames[1]; + FitFlamethrowerBeam(GetTargetHitCenter(flameBeamTarget, flameBeamTarget.transform.position)); + } + + private bool PulseFlamethrower(DefenseRouteAgent target, float tierScale) + { + float now = Time.time; + if (flameBeamStreakActive + && now - flameBeamLastSuccessfulAttackAt >= tuning.FireT4BeamResetAfterSeconds) + { + EndFlamethrowerBeam(); + ResetFlamethrowerStreak(); + } + if (flameBeamEffect == null || flameBeamTarget != target) + { + EndFlamethrowerBeam(immediate: true); + Sprite[] frames = DefenseTowerArt.Load("Effects/Fire/Eyeball Monster_Beam-Clean"); + flameBeamEffect = new GameObject("Fire T4 Sustained Beam"); + flameBeamEffect.transform.SetParent(transform.parent, false); + GameObject beamObject = new("Beam Sprite"); + beamObject.transform.SetParent(flameBeamEffect.transform, false); + flameBeamRenderer = beamObject.AddComponent(); + flameBeamRenderer.sortingOrder = 14; + flameBeamRenderer.sprite = frames[0]; + flameBeamTarget = target; + if (!flameBeamStreakActive) flameBeamStreakStartedAt = now; + SetHitRendererSorting(flameBeamRenderer, target); + FitFlamethrowerBeam(GetTargetHitCenter(target, target.transform.position)); + } + float elapsed = Mathf.Max(0f, now - flameBeamStreakStartedAt); + float streakMultiplier = elapsed >= tuning.FireT4BeamMaximumTierSeconds + ? tuning.FireT4BeamMaximumMultiplier + : elapsed >= tuning.FireT4BeamSecondTierSeconds + ? tuning.FireT4BeamSecondTierMultiplier : 1f; + bool hit = owner.TryApplyTowerDamage(this, target, + tuning.DirectTowerDamage * tierScale + * tuning.FireT4BeamBaseDamageMultiplier * streakMultiplier); + if (!hit) + { + EndFlamethrowerBeam(); + return false; + } + flameBeamStreakActive = true; + flameBeamLastSuccessfulAttackAt = now; + PlayBodyAttack(target.transform.position.x < transform.position.x); + FitFlamethrowerBeam(GetTargetHitCenter(target, target.transform.position)); + return true; + } + + private void FitFlamethrowerBeam(Vector2 end) + { + if (flameBeamRenderer == null || flameBeamRenderer.sprite == null) return; + Vector2 start = GetStaffTipWorldPosition(); + Vector2 direction = end - start; + float distance = direction.magnitude; + float ppu = flameBeamRenderer.sprite.pixelsPerUnit; + float scaleX = distance * ppu + / (DefenseTowerArtBounds.FireBeamEndX - DefenseTowerArtBounds.FireBeamStartX); + float angle = distance > 0f + ? Mathf.Atan2(direction.y, direction.x) * Mathf.Rad2Deg : 0f; + Transform beam = flameBeamRenderer.transform; + beam.rotation = Quaternion.Euler(0f, 0f, angle); + float elapsed = flameBeamStreakActive + ? Mathf.Max(0f, Time.time - flameBeamStreakStartedAt) : 0f; + float widthMultiplier = elapsed >= tuning.FireT4BeamMaximumTierSeconds + ? tuning.FireT4BeamMaximumWidthMultiplier + : elapsed >= tuning.FireT4BeamSecondTierSeconds + ? tuning.FireT4BeamSecondTierWidthMultiplier + : DefenseTowerArtBounds.FireT4BeamBaseWidthMultiplier; + beam.localScale = new Vector3(scaleX, widthMultiplier, 1f); + Vector2 localStart = new( + (DefenseTowerArtBounds.FireBeamStartX - flameBeamRenderer.sprite.pivot.x) / ppu, + (flameBeamRenderer.sprite.rect.height - DefenseTowerArtBounds.FireBeamCenterY + - flameBeamRenderer.sprite.pivot.y) / ppu); + beam.position = start - (Vector2)beam.right * (localStart.x * scaleX) + - (Vector2)beam.up * (localStart.y * widthMultiplier); + } + + private Vector2 GetStaffTipWorldPosition() + { + if (bodyRenderer == null || bodyRenderer.sprite == null) return transform.position; + Vector2 tipPixels = DefenseTowerArtBounds.StaffTipPixels(bodyAttacking, bodyFrame); + Vector2 localPixels = tipPixels - bodyRenderer.sprite.pivot; + localPixels.y = bodyRenderer.sprite.rect.height - tipPixels.y - bodyRenderer.sprite.pivot.y; + if (bodyRenderer.flipX) localPixels.x = -localPixels.x; + return bodyRenderer.transform.TransformPoint(localPixels / bodyRenderer.sprite.pixelsPerUnit); + } + + private void ResetFlamethrowerStreak() + { + flameBeamStreakActive = false; + flameBeamStreakStartedAt = 0f; + flameBeamLastSuccessfulAttackAt = 0f; + } + + private void EndFlamethrowerBeam(bool immediate = false, bool resetStreak = false) + { + if (flameBeamEffect != null) + { + Sprite[] frames = DefenseTowerArt.Load("Effects/Fire/Eyeball Monster_Beam-Clean"); + if (flameBeamRenderer != null && frames.Length >= 3) + flameBeamRenderer.sprite = frames[2]; + if (immediate) Destroy(flameBeamEffect); + else Destroy(flameBeamEffect, DefenseTowerArtBounds.FireBeamExtinguishSeconds); + flameBeamEffect = null; + flameBeamRenderer = null; + flameBeamTarget = null; + } + if (resetStreak) ResetFlamethrowerStreak(); } private void Update() { + BumpCombat.Core.RunManager runManager = BumpCombat.Core.RunManager.Instance; + if (runManager != null + && (runManager.IsGameOver || runManager.IsRunCleared || runManager.IsTitleScreen)) + { + ClearAttackEffects(); + EndFlamethrowerBeam(immediate: true, resetStreak: true); + } UpdateBodyAnimation(); + UpdateFlamethrowerBeam(); + if (Kind == DefenseTowerKind.Summon && IsOperational && summonCount < targetSummonCount) + { + summonRespawnRemaining -= Time.deltaTime; + if (summonRespawnRemaining <= 0f) + { + AddSummonUnits(1); + summonRespawnRemaining = tuning.SummonUnitRespawnSeconds; + } + } + if (Kind == DefenseTowerKind.Summon && IsOperational) + { + CommandTarget = owner.FindNearestTarget( + transform.position, tuning.SummonAreaRadius, Kind); + if (CommandTarget != null && bodyRenderer != null) + { + bodyRenderer.flipX = + CommandTarget.transform.position.x < transform.position.x; + } + } if (Kind == DefenseTowerKind.Empowering) UpdateEffectRange(); else if (effectRange != null) @@ -161,11 +389,26 @@ namespace BumpCombat.Defense } float range = GetEffectRange(); - DefenseRouteAgent target = owner.FindNearestTarget( - transform.position, range, Kind, - Kind == DefenseTowerKind.Cold || Kind == DefenseTowerKind.Seismic); + DefenseRouteAgent target; + if (IsFireT4Flamethrower) + { + if (IsFlamethrowerTargetValid(flameBeamTarget)) + target = flameBeamTarget; + else + { + EndFlamethrowerBeam(); + target = owner.FindNearestTarget(transform.position, range, Kind); + } + } + else + { + target = owner.FindNearestTarget( + transform.position, range, Kind, + Kind == DefenseTowerKind.Cold || Kind == DefenseTowerKind.Seismic); + } if (target == null) { + if (IsFireT4Flamethrower) EndFlamethrowerBeam(); return false; } @@ -173,28 +416,31 @@ namespace BumpCombat.Defense Vector2 lightningVisualCenter = DefenseTowerArtBounds.EnemyShadowWorldPosition( target.Enemy, target.transform.position); float scale = 1f + tuning.TowerDamageBonusPerTier * (Tier - 1); + if (IsFireT4Flamethrower) return PulseFlamethrower(target, scale); float impactRadius = 0f; - bool cone = false; bool applied; + System.Action projectileImpact = null; switch (Kind) { case DefenseTowerKind.Direct: - if (Tier == 4 && Branch == DefenseTowerBranch.Second) - { - cone = true; - impactRadius = range; - applied = AttackCone(center - (Vector2)transform.position, - range, tuning.DirectTowerDamage * scale * 0.4f); - } - else if (Tier == 4) + if (Tier == 4) { impactRadius = 1.8f; - applied = AttackArea(center, impactRadius, - tuning.DirectTowerDamage * scale * 1.6f); + applied = true; + projectileImpact = () => + { + if (target != null && !target.HasArrived && target.Enemy != null + && !target.Enemy.IsDead) + { + AttackArea(target.transform.position, impactRadius, + tuning.DirectTowerDamage * scale * 1.6f); + } + }; } else { - applied = owner.TryApplyTowerDamage(this, target, + applied = true; + projectileImpact = () => owner.TryApplyTowerDamage(this, target, tuning.DirectTowerDamage * scale); } break; @@ -213,23 +459,22 @@ namespace BumpCombat.Defense if (Tier == 4 && Branch == DefenseTowerBranch.Second) { impactRadius = 1.65f; - applied = AttackArea(center, impactRadius, - tuning.ColdDamage * scale * 0.5f, - int.MaxValue, tuning.ColdSlowSeconds * 1.5f, - tuning.ColdSlowFraction, 0.35f); + applied = true; } else { - bool slowed = owner.TryApplyTowerSlow(this, target, - tuning.ColdSlowSeconds * (1f + 0.25f * (Tier - 1)), - tuning.ColdSlowFraction); - bool damaged = owner.TryApplyTowerDamage(this, target, - tuning.ColdDamage * scale * (Tier == 4 ? 1.8f : 1f)); - if (Tier == 4) + applied = true; + projectileImpact = () => { - owner.TryApplyTowerStun(this, target, 0.3f); - } - applied = slowed || damaged; + if (target == null || target.HasArrived || target.Enemy == null + || target.Enemy.IsDead) return; + owner.TryApplyTowerSlow(this, target, + tuning.ColdSlowSeconds * (1f + 0.25f * (Tier - 1)), + tuning.ColdSlowFraction); + owner.TryApplyTowerDamage(this, target, + tuning.ColdDamage * scale * (Tier == 4 ? 1.8f : 1f)); + if (Tier == 4) owner.TryApplyTowerStun(this, target, 0.3f); + }; } break; @@ -253,35 +498,16 @@ namespace BumpCombat.Defense if (applied) { PlayBodyAttack(center.x < transform.position.x); - bool shadowCenteredCircle = Kind == DefenseTowerKind.Seismic - || (Kind == DefenseTowerKind.Cold && Tier == 4 - && Branch == DefenseTowerBranch.Second); - ShowAttackEffect(center, impactRadius, cone, - visualCenter: Kind == DefenseTowerKind.Lightning || shadowCenteredCircle - ? lightningVisualCenter : null); + bool shadowCenteredCircle = Kind == DefenseTowerKind.Seismic || IsBlizzard; + ShowAttackEffect(center, impactRadius, + visualCenter: shadowCenteredCircle ? lightningVisualCenter : null, + projectileImpact: projectileImpact, + projectileTarget: target, + damageScale: scale); } return applied; } - private bool AttackCone(Vector2 direction, float range, float damage) - { - bool applied = false; - Vector2 facing = direction.normalized; - foreach (DefenseRouteAgent agent in owner.GetTargetsInRange( - transform.position, range)) - { - Vector2 toAgent = ((Vector2)agent.transform.position - - (Vector2)transform.position).normalized; - if (toAgent.sqrMagnitude < 0.000001f - || Vector2.Dot(facing, toAgent) >= 0.5f) - { - applied |= owner.TryApplyTowerDamage(this, agent, damage); - } - } - - return applied; - } - private bool AttackArea( Vector2 center, float radius, @@ -293,7 +519,7 @@ namespace BumpCombat.Defense { bool applied = false; int count = 0; - foreach (DefenseRouteAgent agent in owner.GetTargetsInRange(center, radius)) + foreach (DefenseRouteAgent agent in owner.GetTargetsInRange(center, radius, Kind)) { if (count++ >= maxTargets) { @@ -327,6 +553,12 @@ namespace BumpCombat.Defense damage *= 1.8f; } + if (Tier == 4 && Kind == DefenseTowerKind.Summon + && Branch == DefenseTowerBranch.Second) + { + return true; + } + bool hit = owner.TryApplyTowerDamage(this, target, damage); if (hit && Tier == 4 && Kind == DefenseTowerKind.Summon && Branch == DefenseTowerBranch.First) @@ -337,20 +569,48 @@ namespace BumpCombat.Defense return hit; } - public void ShowSummonAttackEffect(Vector2 origin, Vector2 target) + public void ShowSummonAttackEffect(Vector2 origin, DefenseRouteAgent target) { - if (IsOperational && Kind == DefenseTowerKind.Summon) + if (!CanApplyProjectileImpact(target) || Kind != DefenseTowerKind.Summon) return; + Vector2 center = target.transform.position; + if (Tier == 4 && Branch == DefenseTowerBranch.Second) { - ShowAttackEffect(target, 0f, false, origin); + GameObject effect = new("Summon Attack Effect"); + effect.transform.SetParent(transform.parent, false); + for (int i = 0; i < 4; i++) + { + Vector2 position = Vector2.Lerp(origin, center, (i + 1) / 5f); + CreateAnimatedArt(effect.transform, $"Shot Sprite {i + 1}", + "Summons/LavaSlime/GroundSpike-Segment", position, + Vector2.one, 0f, 14, startDelay: i * 0.12f); + } + SpriteRenderer projectile = CreateAnimatedArt(effect.transform, "Shot Sprite 5", + "Summons/LavaSlime/GroundSpike-Segment", origin, + Vector2.one, 0f, 14, startDelay: 0.48f); + projectile.GetComponent().Travel( + origin, center, 0.16f, + () => ApplyProjectileImpact(effect, target, null, 0f, () => + { + float damage = tuning.SummonDamage + * (1f + tuning.TowerDamageBonusPerTier * (Tier - 1)); + owner.TryApplyTowerDamage(this, target, damage); + }), + () => target != null && !target.HasArrived && target.Enemy != null + ? (Vector2)target.transform.position : center, + () => IsOperational); + TrackAttackEffect(effect); + return; } - } + ShowAttackEffect(center, 0f, source: origin, projectileTarget: target); + } private float GetEffectRange() { if (Kind == DefenseTowerKind.Summon) { return tuning.SummonAreaRadius; } + if (Kind == DefenseTowerKind.Empowering) { return tuning.SupportRadius; @@ -364,6 +624,7 @@ namespace BumpCombat.Defense { GameObject rangeObject = new("Effect Range"); rangeObject.transform.SetParent(transform, false); + if (Kind == DefenseTowerKind.Empowering) { supportRangeRenderer = rangeObject.AddComponent(); @@ -384,7 +645,8 @@ namespace BumpCombat.Defense effectRange.sharedMaterial = GetEffectMaterial(); effectRange.sortingOrder = 3; Color color = GetTowerColor(); - color.a = Kind == DefenseTowerKind.Empowering ? 0.35f : 0.22f; + color.a = Kind == DefenseTowerKind.Empowering ? 0.35f + : IsBlizzard ? DefenseTowerArtBounds.BlizzardRangeAlpha : 0.22f; effectRange.startColor = color; effectRange.endColor = color; UpdateEffectRange(); @@ -422,6 +684,11 @@ namespace BumpCombat.Defense { return; } + Color rangeColor = GetTowerColor(); + rangeColor.a = IsBlizzard + ? DefenseTowerArtBounds.BlizzardRangeAlpha : 0.22f; + effectRange.startColor = rangeColor; + effectRange.endColor = rangeColor; effectRange.transform.localPosition = DefenseTowerArtBounds.CharacterShadowOffset( Kind, bodyRenderer != null && bodyRenderer.flipX); float worldScale = Mathf.Max(0.001f, transform.lossyScale.x); @@ -438,16 +705,15 @@ namespace BumpCombat.Defense } private void ShowAttackEffect( - Vector2 center, float radius, bool cone, Vector2? source = null, - Vector2? visualCenter = null) + Vector2 center, float radius, Vector2? source = null, + Vector2? visualCenter = null, System.Action projectileImpact = null, + DefenseRouteAgent projectileTarget = null, float damageScale = 1f) { Vector2 origin = source ?? (Vector2)transform.position; Vector2 direction = center - origin; GameObject effect = new($"{Kind} Attack Effect"); effect.transform.SetParent(transform.parent, false); - bool shadowCenteredCircle = Kind == DefenseTowerKind.Seismic - || (Kind == DefenseTowerKind.Cold && Tier == 4 - && Branch == DefenseTowerBranch.Second); + bool shadowCenteredCircle = Kind == DefenseTowerKind.Seismic || IsBlizzard; Vector2 effectCenter = shadowCenteredCircle ? visualCenter ?? center : center; effect.transform.position = effectCenter; Color color = GetTowerColor(); @@ -456,17 +722,19 @@ namespace BumpCombat.Defense switch (Kind) { case DefenseTowerKind.Direct: - ShowFireArt(effect.transform, origin, center, direction); + ShowFireArt(effect.transform, origin, center, direction, projectileImpact, projectileTarget); break; case DefenseTowerKind.Lightning: - Vector2 shadowOrigin = transform.TransformPoint(DefenseTowerArtBounds.CharacterShadowOffset(Kind, bodyRenderer != null && bodyRenderer.flipX)); Vector2 impactCenter = visualCenter ?? center; - ShowLightningArt(effect.transform, shadowOrigin, impactCenter, - impactCenter - shadowOrigin, radius); + Vector2 lightningOrigin = Tier == 4 && Branch == DefenseTowerBranch.Second + ? impactCenter : GetStaffTipWorldPosition(); + ShowLightningArt(effect.transform, lightningOrigin, impactCenter, + impactCenter - lightningOrigin, radius, projectileTarget); break; case DefenseTowerKind.Cold: - ShowColdArt(effect.transform, origin, center, direction, - shadowCenteredCircle ? effectCenter : (Vector2?)null); + ShowColdArt(effect.transform, origin, center, direction, radius, + shadowCenteredCircle ? effectCenter : (Vector2?)null, + projectileImpact, projectileTarget, damageScale); break; case DefenseTowerKind.Seismic: string controlKey = Tier == 4 @@ -476,9 +744,11 @@ namespace BumpCombat.Defense int visibleWidth = Tier == 4 ? (Branch == DefenseTowerBranch.First ? 40 : 45) : 32 + (Tier - 1) * 6; - float controlScale = ScaleToVisibleWidth(radius * 2f, visibleWidth); - CreateAnimatedArt(effect.transform, "Impact Sprite", controlKey, + float controlScale = GetSeismicGroundMarkerScale( + projectileTarget, controlKey, visibleWidth, effect.transform); + SpriteRenderer control = CreateAnimatedArt(effect.transform, "Impact Sprite", controlKey, effectCenter, Vector2.one * controlScale, 0f, 13); + SetGroundHitRendererSorting(control, projectileTarget); break; case DefenseTowerKind.Summon: if (Tier == 4 && Branch == DefenseTowerBranch.Second) @@ -493,29 +763,20 @@ namespace BumpCombat.Defense } else { - CreateAnimatedArt(effect.transform, "Impact Sprite", - "Effects/Fire/Impact", center, Vector2.one, 0f, 13); + SpriteRenderer impact = CreateAnimatedArt(effect.transform, "Impact Sprite", + "Effects/Fire/Impact", GetTargetHitCenter(projectileTarget, center), + Vector2.one * 0.45f, 0f, 13); + SetHitRendererSorting(impact, projectileTarget); } break; } - if (cone) - { - LineRenderer outline = CreateEffectLine( - effect.transform, "Cone Radius", color, 0.07f); - outline.positionCount = 3; - Vector2 forward = direction.normalized * radius; - outline.SetPosition(0, origin - center); - outline.SetPosition(1, origin - center - + (Vector2)(Quaternion.Euler(0f, 0f, -60f) * forward)); - outline.SetPosition(2, origin - center - + (Vector2)(Quaternion.Euler(0f, 0f, 60f) * forward)); - Destroy(outline.gameObject, 0.22f); - } - else if (radius > 0f) + if (radius > 0f && Kind != DefenseTowerKind.Seismic) { + Color radiusColor = color; + if (IsBlizzard) radiusColor.a = DefenseTowerArtBounds.BlizzardImpactRadiusAlpha; LineRenderer area = CreateEffectLine( - effect.transform, "Impact Radius", color, 0.07f); + effect.transform, "Impact Radius", radiusColor, 0.07f); area.loop = true; area.positionCount = 40; for (int i = 0; i < area.positionCount; i++) @@ -525,58 +786,131 @@ namespace BumpCombat.Defense Mathf.Cos(angle) * radius, Mathf.Sin(angle) * radius, 0f)); } - Destroy(area.gameObject, 0.22f); + SetHitRendererSorting(area, projectileTarget); + float radiusLifetime = Kind == DefenseTowerKind.Cold && Tier == 4 + && Branch == DefenseTowerBranch.Second + ? DefenseTowerArtBounds.BlizzardLastLandingSeconds + 0.22f + : 0.22f; + Destroy(area.gameObject, radiusLifetime); } - Destroy(effect, 1.6f); + TrackAttackEffect(effect); + } + + private static Vector2 GetTargetHitCenter(DefenseRouteAgent target, Vector2 fallback) => + target != null && target.Enemy != null + ? (Vector2)target.Enemy.transform.position : fallback; + + private static float GetSeismicGroundMarkerScale( + DefenseRouteAgent target, string controlKey, int maximumVisiblePixels, + Transform effectRoot) + { + SpriteRenderer targetRenderer = target != null && target.Enemy != null + ? target.Enemy.GetComponent() : null; + Sprite targetSprite = targetRenderer != null ? targetRenderer.sprite : null; + Sprite[] controlFrames = DefenseTowerArt.Load(controlKey); + Sprite controlSprite = controlFrames.Length > 0 ? controlFrames[0] : null; + if (targetSprite == null || controlSprite == null) return 0f; + float markerWorldWidth = DefenseTowerArtBounds.SeismicGroundMarkerWidthPixels + * Mathf.Abs(targetRenderer.transform.lossyScale.x) / targetSprite.pixelsPerUnit; + float parentScale = effectRoot != null + ? Mathf.Abs(effectRoot.lossyScale.x) : 1f; + float frameWidthWorld = maximumVisiblePixels / controlSprite.pixelsPerUnit; + return markerWorldWidth / (frameWidthWorld * Mathf.Max(0.001f, parentScale)); + } + + private static void SetGroundHitRendererSorting(Renderer renderer, DefenseRouteAgent target) + { + if (renderer == null || target == null || target.Enemy == null) return; + SpriteRenderer targetRenderer = target.Enemy.GetComponent(); + if (targetRenderer == null) return; + renderer.sortingLayerID = targetRenderer.sortingLayerID; + renderer.sortingOrder = targetRenderer.sortingOrder - 1; + } + + private static void SetHitRendererSorting(Renderer renderer, DefenseRouteAgent target) + { + if (renderer == null || target == null || target.Enemy == null) return; + SpriteRenderer targetRenderer = target.Enemy.GetComponent(); + if (targetRenderer == null) return; + renderer.sortingLayerID = targetRenderer.sortingLayerID; + renderer.sortingOrder = targetRenderer.sortingOrder + + DefenseTowerArtBounds.TowerHitSortingOffset; + } + + private void ApplyProjectileImpact( + GameObject effect, DefenseRouteAgent target, string impactKey, + float impactScale, System.Action damageAction) + { + if (!CanApplyProjectileImpact(target)) return; + Vector2 impactPosition = GetTargetHitCenter(target, target.transform.position); + damageAction?.Invoke(); + if (string.IsNullOrEmpty(impactKey) || effect == null) return; + + SpriteRenderer impact = CreateAnimatedArt( + effect.transform, "Impact Sprite", impactKey, impactPosition, + Vector2.one * impactScale, 0f, 13); + SetHitRendererSorting(impact, target); + TrackAttackSequence(effect, impact.GetComponent()); + } + + private bool CanApplyProjectileImpact(DefenseRouteAgent target) + { + return IsOperational && target != null && !target.HasArrived + && target.Enemy != null && !target.Enemy.IsDead; } private void ShowFireArt( - Transform parent, Vector2 origin, Vector2 center, Vector2 direction) + Transform parent, Vector2 origin, Vector2 center, Vector2 direction, + System.Action projectileImpact, DefenseRouteAgent projectileTarget) { if (Tier == 4 && Branch == DefenseTowerBranch.First) { + Vector2 end = GetTargetHitCenter(projectileTarget, center); + Vector2 start = end + new Vector2(-0.81f, 0.81f); SpriteRenderer meteor = CreateAnimatedArt(parent, "Impact Sprite", - "Effects/Fire/Meteor-Flight", center + new Vector2(-0.81f, 0.81f), + "Effects/Fire/Meteor-Flight", start, Vector2.one, 0f, 13, "Effects/Fire/Meteor-Impact"); + SetHitRendererSorting(meteor, projectileTarget); DefenseSpriteSequence sequence = meteor.GetComponent(); - Vector2 start = center + new Vector2(-0.81f, 0.81f); - meteor.transform.position = start; - sequence.Travel(start, center, 0.32f); + sequence.Travel(start, end, 0.32f, + () => ApplyProjectileImpact( + parent.gameObject, projectileTarget, null, 0f, projectileImpact), + () => GetTargetHitCenter(projectileTarget, center), + () => IsOperational); return; } - if (Tier == 4 && Branch == DefenseTowerBranch.Second) - { - float visibleWidth = 88f / 32f; - float scaleX = Mathf.Max(0.1f, direction.magnitude / visibleWidth); - CreateAnimatedArt(parent, "Shot Sprite", "Effects/Fire/Flamethrower", - origin, new Vector2(scaleX, 1f), - Mathf.Atan2(direction.y, direction.x) * Mathf.Rad2Deg, - 14); - } - else + if (Tier < 4) { + origin = GetStaffTipWorldPosition(); string key = $"Effects/Fire/T{Tier}-Fireball"; + Vector2 end = GetTargetHitCenter(projectileTarget, center); + direction = end - origin; SpriteRenderer shot = CreateAnimatedArt(parent, "Shot Sprite", key, - Vector2.Lerp(origin, center, 0.5f), Vector2.one * 0.85f, + Vector2.Lerp(origin, end, 0.5f), Vector2.one * 0.85f, Mathf.Atan2(direction.y, direction.x) * Mathf.Rad2Deg, 14); DefenseSpriteSequence sequence = shot.GetComponent(); - sequence.Travel(origin, center, 0.18f); + sequence.Travel(origin, end, 0.18f, + () => ApplyProjectileImpact( + parent.gameObject, projectileTarget, "Effects/Fire/Impact", 1f, + projectileImpact), + () => GetTargetHitCenter(projectileTarget, center), + () => IsOperational); } - CreateAnimatedArt(parent, "Impact Sprite", "Effects/Fire/Impact", - center, Vector2.one, 0f, 13, startDelay: 0.18f); } private void ShowLightningArt( Transform parent, Vector2 origin, Vector2 center, - Vector2 direction, float radius) + Vector2 direction, float radius, DefenseRouteAgent target) { if (Tier == 4 && Branch == DefenseTowerBranch.Second) { - float scale = Mathf.Max(0.8f, radius / 1.2f); - CreateAnimatedArt(parent, "Shot Sprite", "Effects/Lightning/LightningStorm", - center, Vector2.one * scale, 0f, 13); + SpriteRenderer storm = CreateAnimatedArt(parent, "Shot Sprite", + "Effects/Lightning/LightningStorm", center, Vector2.one, 0f, 13); + storm.GetComponent() + .FitLightningStormGround(center, radius); + SetHitRendererSorting(storm, target); } else { @@ -586,38 +920,145 @@ namespace BumpCombat.Defense Vector2.one, Mathf.Atan2(direction.y, direction.x) * Mathf.Rad2Deg, 14); bolt.GetComponent().FitLightningBeam(origin, center, 1f); } - float waveScale = Mathf.Max(0.8f, radius / 1.4f); - SpriteRenderer wave = CreateAnimatedArt(parent, "Impact Sprite", "Effects/Lightning/ImpactWave", - center, Vector2.one * waveScale, 0f, 13, startDelay: 0.15f); - wave.GetComponent().CenterImpactWave(center, waveScale); + if (Tier == 4) + { + float waveScale = Mathf.Max(0.8f, radius / 1.4f); + SpriteRenderer wave = CreateAnimatedArt(parent, "Impact Sprite", "Effects/Lightning/ImpactWave", + center, Vector2.one * waveScale, 0f, 13, startDelay: 0.15f); + wave.GetComponent().CenterImpactWave(center, waveScale); + SetHitRendererSorting(wave, target); + } } private void ShowColdArt( - Transform parent, Vector2 origin, Vector2 center, Vector2 direction, - Vector2? visualCenter = null) + Transform parent, Vector2 origin, Vector2 center, Vector2 direction, float radius, + Vector2? visualCenter, System.Action projectileImpact, + DefenseRouteAgent projectileTarget, float damageScale) { if (Tier == 4 && Branch == DefenseTowerBranch.Second) { - float cageScale = ScaleToVisibleWidth(3.3f, 48f); - Vector2 cageCenter = visualCenter ?? center; - CreateAnimatedArt(parent, "Shot Sprite", "Effects/Ice/GlacialCage-Back", - cageCenter, Vector2.one * cageScale, 0f, 12); - CreateAnimatedArt(parent, "Impact Sprite", "Effects/Ice/GlacialCage-Front", - cageCenter, Vector2.one * cageScale, 0f, 14); + ShowBlizzardSalvo(parent, visualCenter ?? center, radius, + projectileTarget, + landing => AttackBlizzardWave( + landing, radius, tuning.ColdDamage * damageScale * 0.5f)); return; } string key = Tier == 4 - ? "Effects/Ice/Freezebolt" : $"Effects/Ice/T{Tier}-Volley"; - SpriteRenderer shot = CreateAnimatedArt(parent, "Shot Sprite", key, - Vector2.Lerp(origin, center, 0.5f), Vector2.one, - Mathf.Atan2(direction.y, direction.x) * Mathf.Rad2Deg, 14); - shot.GetComponent().Travel(origin, center, 0.18f); - CreateAnimatedArt(parent, "Impact Sprite", "Effects/Ice/Impact", - center, Vector2.one, 0f, 13, startDelay: 0.18f); + ? "Effects/Ice/Freezebolt" : "Effects/Ice/T1-Volley"; + origin = GetStaffTipWorldPosition(); + direction = center - origin; + Vector2 end = GetTargetHitCenter(projectileTarget, center); + int projectileCount = Tier == 4 ? tuning.FreezeboltProjectileCount : Tier; + float burstInterval = tuning.ColdProjectileBurstInterval; + for (int i = 0; i < projectileCount; i++) + { + SpriteRenderer shot = CreateAnimatedArt(parent, $"Shot Sprite {i + 1}", key, + Vector2.Lerp(origin, end, 0.5f), Vector2.one, + Mathf.Atan2(direction.y, direction.x) * Mathf.Rad2Deg, 14, + startDelay: i * burstInterval); + shot.GetComponent().Travel(origin, end, 0.18f, + () => ApplyProjectileImpact( + parent.gameObject, projectileTarget, "Effects/Ice/Impact", 1f, + projectileImpact), + () => GetTargetHitCenter(projectileTarget, center), + () => IsOperational, hideAtArrival: true, + startProvider: GetStaffTipWorldPosition); + } } - private static SpriteRenderer CreateAnimatedArt( + private void ShowBlizzardSalvo( + Transform parent, Vector2 center, float radius, + DefenseRouteAgent target, System.Action landingDamageAction) + { + for (int i = 0; i < DefenseTowerArtBounds.BlizzardShardCount; i++) + { + float launchDelay = i * DefenseTowerArtBounds.BlizzardLaunchIntervalSeconds; + Vector2 landing = center + + DefenseTowerArtBounds.BlizzardLandingOffset(i, radius); + float height = DefenseTowerArtBounds.BlizzardDropHeight + + i % 4 * DefenseTowerArtBounds.BlizzardDropHeightStep; + float rotation = 90f + (i % 5 - 2) * 7f; + Vector2 start = landing + Vector2.up * height; + + float landingDelay = launchDelay + DefenseTowerArtBounds.BlizzardLandingSeconds; + SpriteRenderer wave = CreateAnimatedArt(parent, $"Blizzard Wave {i}", + "Effects/Ice/Blizzard-Wave-v1", landing, + Vector2.one * DefenseTowerArtBounds.BlizzardWaveScale, + 0f, 13, startDelay: landingDelay); + wave.GetComponent().CenterImpactWave( + landing, DefenseTowerArtBounds.BlizzardWaveScale); + SetHitRendererSorting(wave, target); + + SpriteRenderer impact = CreateAnimatedArt(parent, $"Blizzard Impact {i}", + "Effects/Ice/Impact", landing, + Vector2.one * DefenseTowerArtBounds.BlizzardImpactScale, + 0f, 14, startDelay: landingDelay); + SetHitRendererSorting(impact, target); + + SpriteRenderer shard = CreateAnimatedArt(parent, $"Blizzard Shard {i}", + "Effects/Ice/Shard-Blizzard-v1", start, + Vector2.one * DefenseTowerArtBounds.BlizzardShardScale, + rotation, 14, startDelay: launchDelay); + SetHitRendererSorting(shard, target); + shard.GetComponent().Travel( + start, landing, DefenseTowerArtBounds.BlizzardLandingSeconds, + () => landingDamageAction?.Invoke(landing), + canAdvance: () => IsOperational); + + Vector2 trailOffset = Vector2.up * DefenseTowerArtBounds.BlizzardTrailOffset; + SpriteRenderer trail = CreateAnimatedArt(parent, $"Blizzard Trail {i}", + "Effects/Ice/Shard-Blizzard-v1", start + trailOffset, + Vector2.one * DefenseTowerArtBounds.BlizzardTrailScale, + rotation, 13, startDelay: launchDelay); + trail.color = new Color(0.72f, 0.9f, 1f, + DefenseTowerArtBounds.BlizzardTrailAlpha); + SetHitRendererSorting(trail, target); + trail.GetComponent().Travel( + start + trailOffset, landing + trailOffset, + DefenseTowerArtBounds.BlizzardLandingSeconds, + canAdvance: () => IsOperational); + } + } + + private bool AttackBlizzardWave(Vector2 center, float castRadius, float damage) + { + float radiusX = DefenseTowerArtBounds.BlizzardWaveRadiusX; + float radiusY = DefenseTowerArtBounds.BlizzardWaveRadiusY; + // GetTargetsInRange measures from the agent root; the generous broadphase + // leaves room for the enemy sprite's shadow offset before the ellipse test. + float broadphaseRadius = castRadius + radiusX; + bool applied = false; + foreach (DefenseRouteAgent agent in owner.GetTargetsInRange( + center, broadphaseRadius, Kind)) + { + if (agent == null || agent.HasArrived || agent.Enemy == null + || agent.Enemy.IsDead) + { + continue; + } + + Vector2 groundPosition = DefenseTowerArtBounds.EnemyShadowWorldPosition( + agent.Enemy, agent.transform.position); + Vector2 normalized = groundPosition - center; + float ellipseDistance = normalized.x * normalized.x / (radiusX * radiusX) + + normalized.y * normalized.y / (radiusY * radiusY); + if (ellipseDistance > 1f) continue; + + bool damaged = owner.TryApplyTowerDamage(this, agent, damage); + bool slowed = owner.TryApplyTowerSlow(this, agent, + tuning.ColdSlowSeconds * 1.5f, tuning.ColdSlowFraction); + bool stunned = owner.TryApplyTowerStun(this, agent, 0.35f); + applied |= damaged || slowed || stunned; + } + + return applied; + } + + private bool IsBlizzard => Kind == DefenseTowerKind.Cold && Tier == 4 + && Branch == DefenseTowerBranch.Second; + + private SpriteRenderer CreateAnimatedArt( Transform parent, string name, string key, Vector2 position, Vector2 scale, float rotation, int sortingOrder, string nextKey = null, float startDelay = 0f) @@ -633,8 +1074,9 @@ namespace BumpCombat.Defense Sprite[] frames = DefenseTowerArt.Load(key); Sprite[] next = string.IsNullOrEmpty(nextKey) ? null : DefenseTowerArt.Load(nextKey); - spriteObject.AddComponent().Play( - renderer, key, frames, true, nextKey, next, startDelay); + DefenseSpriteSequence sequence = spriteObject.AddComponent(); + sequence.Play(renderer, key, frames, true, nextKey, next, startDelay); + sequence.SetCanAdvance(() => IsOperational); return renderer; } @@ -669,17 +1111,51 @@ namespace BumpCombat.Defense return effectMaterial; } + private void ReplaceSummonUnits(int count) + { + DefenseSummonUnit[] existingUnits = + GetComponentsInChildren(true); + summonCount = 0; + nextSummonIndex = 0; + targetSummonCount = count; + summonRespawnRemaining = 0f; + freeSummonSlots.Clear(); + deadSummons.Clear(); + foreach (DefenseSummonUnit unit in existingUnits) + { + unit.RetireForReplacement(); + Destroy(unit.gameObject); + } + AddSummonUnits(count); + } + public void NotifySummonUnitDied(DefenseSummonUnit unit) + { + if (unit == null || unit.Owner != this) return; + summonCount = Mathf.Max(0, summonCount - 1); + if (!deadSummons.ContainsKey(unit.SlotIndex)) + { + deadSummons.Add(unit.SlotIndex, unit); + freeSummonSlots.Push(unit.SlotIndex); + } + summonRespawnRemaining = Mathf.Max(summonRespawnRemaining, tuning.SummonUnitRespawnSeconds); + } private void AddSummonUnits(int count) { for (int i = 0; i < count; i++) { - int unitIndex = summonCount++; + int unitIndex = freeSummonSlots.Count > 0 ? freeSummonSlots.Pop() : nextSummonIndex++; + if (deadSummons.TryGetValue(unitIndex, out DefenseSummonUnit deadUnit)) + { + deadSummons.Remove(unitIndex); + if (deadUnit != null) Destroy(deadUnit.gameObject); + } + summonCount++; GameObject unitObject = new($"Summon Unit {unitIndex + 1}"); unitObject.transform.SetParent(transform, false); unitObject.transform.localPosition = new Vector3( - (unitIndex % 3 - 1) * 0.42f, -0.5f - 0.25f * (unitIndex / 3), 0f); + (unitIndex % 3 - 1) * 0.8f, -0.55f - 0.8f * (unitIndex / 3), 0f); DefenseSummonUnit unit = unitObject.AddComponent(); - unit.Configure(this, owner, unitObject.transform.localPosition); + unit.Configure(this, owner, unitObject.transform.localPosition, unitIndex); } } @@ -877,11 +1353,11 @@ namespace BumpCombat.Defense { DefenseTowerKind.Direct => first ? "METEOR" : "FLAMETHROWER", DefenseTowerKind.Lightning => first ? "ARC" : "LIGHTNING STORM", - DefenseTowerKind.Cold => first ? "FREEZEBOLT" : "GLACIAL CAGE", + DefenseTowerKind.Cold => first ? "FREEZEBOLT" : "BLIZZARD", DefenseTowerKind.Seismic => first ? "STONE GAZE" : "TEMPORAL", DefenseTowerKind.Summon => first ? "SENTINEL" : "SCOUT", _ => first ? "IMBUING" : "SMOTHERING", }; } } -} \ No newline at end of file +} diff --git a/Assets/_Project/Scripts/Editor/BumpCombat.Editor.asmdef b/Assets/_Project/Scripts/Editor/BumpCombat.Editor.asmdef index 68116b0..caf6030 100644 --- a/Assets/_Project/Scripts/Editor/BumpCombat.Editor.asmdef +++ b/Assets/_Project/Scripts/Editor/BumpCombat.Editor.asmdef @@ -5,6 +5,7 @@ "BumpCombat.Runtime", "Unity.2D.Sprite.Editor", "Unity.RenderPipelines.Universal.2D.Runtime", + "Unity.RenderPipelines.Universal.Runtime", "Unity.InputSystem", "UnityEngine.UI" ], diff --git a/Assets/_Project/Scripts/Editor/Defense25DComparisonSetup.cs b/Assets/_Project/Scripts/Editor/Defense25DComparisonSetup.cs new file mode 100644 index 0000000..0bf2aa2 --- /dev/null +++ b/Assets/_Project/Scripts/Editor/Defense25DComparisonSetup.cs @@ -0,0 +1,242 @@ +using System; +using System.IO; +using System.Linq; +using BumpCombat.Constants; +using BumpCombat.Defense.Comparison; +using BumpCombat.Enemies; +using BumpCombat.Player; +using UnityEditor; +using UnityEditor.SceneManagement; +using UnityEngine; +using UnityEngine.Rendering; +using UnityEngine.Rendering.Universal; +using UnityEngine.SceneManagement; + +namespace BumpCombat.Editor +{ + public static class Defense25DComparisonSetup + { + public const string ScenePath = "Assets/_Project/Scenes/Defense25DComparison.unity"; + public const string SettingsPath = "Assets/_Project/Constants/Defense25DComparison/ComparisonSettings.asset"; + private const string AssetFolder = "Assets/_Project/Art/Defense25DComparison"; + private const string RendererPath = AssetFolder + "/ComparisonRenderer.asset"; + + [MenuItem("BumpCombat/2.5D Comparison/Create Independent Scene")] + public static void Generate() + { + // This command only owns the comparison assets. Never opens or regenerates the main scene. + if (EditorApplication.isPlayingOrWillChangePlaymode) + throw new InvalidOperationException("Stop Play mode before generating comparison assets."); + var stopwatch = System.Diagnostics.Stopwatch.StartNew(); + EnsureFolder("Assets/_Project/Constants/Defense25DComparison"); + EnsureFolder(AssetFolder); + Defense25DComparisonSettings settings = AssetDatabase.LoadAssetAtPath(SettingsPath); + if (settings == null) + { + settings = ScriptableObject.CreateInstance(); + AssetDatabase.CreateAsset(settings, SettingsPath); + } + settings.Enemy = AssetDatabase.LoadAssetAtPath("Assets/_Project/Constants/Enemies/Skeleton.asset"); + settings.EnemyAttackClip = AssetDatabase.LoadAssetAtPath( + "Assets/_Project/Art/Characters/Skeleton/Animations/Skeleton_Attack.anim"); + settings.EnemyRadius = 0.275f; // Skeleton prefab circle: radius .22 at root scale 1.25. + settings.EnemyColliderOffset = new Vector2(0f, -0.35f); // Skeleton prefab offset: -.28 at root scale 1.25. + settings.EnemyVisualScale = 1.25f; // Skeleton prefab root scale. + settings.Cyclone = AssetDatabase.FindAssets("CycloneArtifact t:ActiveArtifactDefinition", new[] { "Assets/_Project" }) + .Select(AssetDatabase.GUIDToAssetPath).Select(AssetDatabase.LoadAssetAtPath).FirstOrDefault(); + if (settings.Enemy == null || settings.Cyclone == null) throw new InvalidOperationException("Existing Skeleton/Cyclone definitions not found."); + settings.PlayerArt = ReadCharacter("Swordsman", "Swordsman", "BumpAttack-v1"); + settings.EnemyArt = ReadCharacter("Skeleton", "Skeleton", "Attack"); + settings.CutoutMaterial = SpriteMaterial("SpriteCutout", false, Color.white); + settings.TransparentMaterial = SpriteMaterial("SpriteTransparent", true, new Color(1f, 1f, 1f, .55f)); + settings.GroundMaterial = SolidMaterial("Ground", new Color(.12f, .18f, .2f)); + settings.WallMaterial = SolidMaterial("Wall", new Color(.48f, .51f, .48f)); + settings.PathMaterial = SolidMaterial("Route", new Color(.22f, .34f, .31f)); + settings.BaseMaterial = SolidMaterial("Base", new Color(.45f, .77f, .82f)); + settings.ShadowMaterial = SpriteMaterial("GroundShadow", true, new Color(0f, 0f, 0f, .35f)); + settings.BoundaryMaterial = SolidMaterial("ColliderBoundary", new Color(.38f, .71f, .55f)); + settings.HitBoundaryMaterial = SolidMaterial("CycloneHitBoundary", new Color(1f, .72f, .18f)); + EditorUtility.SetDirty(settings); + int rendererIndex = AppendComparisonRenderer(); + if (AssetDatabase.LoadAssetAtPath(ScenePath) == null) + { + Scene previous = SceneManager.GetActiveScene(); + // A fresh batch Editor has an untitled scene which Unity cannot extend additively. + // Interactive Editors retain their scenes and unsaved work. + bool replaceBatchStartup = Application.isBatchMode && string.IsNullOrEmpty(previous.path); + Scene scene = EditorSceneManager.NewScene(NewSceneSetup.EmptyScene, + replaceBatchStartup ? NewSceneMode.Single : NewSceneMode.Additive); + SceneManager.SetActiveScene(scene); + try + { + GameObject root = new("Physics3D comparison (independent)"); + var controller = root.AddComponent(); + GameObject cameraObject = new("Comparison Camera - dedicated Universal renderer"); + Camera camera = cameraObject.AddComponent(); + camera.orthographic = true; + camera.orthographicSize = settings.CameraSize; + camera.backgroundColor = new Color(.055f, .08f, .1f); + camera.clearFlags = CameraClearFlags.SolidColor; + camera.allowHDR = false; + camera.allowMSAA = false; + camera.nearClipPlane = .1f; + camera.farClipPlane = 100f; + camera.transform.rotation = Quaternion.Euler(settings.CameraPitch, 0f, 0f); + camera.transform.position = new Vector3(0f, settings.GroundY, 0f) - camera.transform.forward * 25f; + camera.GetUniversalAdditionalCameraData().SetRenderer(rendererIndex); + cameraObject.AddComponent(); + Defense25DView view = cameraObject.AddComponent(); + SerializedObject serialized = new(controller); + serialized.FindProperty("settings").objectReferenceValue = settings; + serialized.FindProperty("view").objectReferenceValue = view; + serialized.ApplyModifiedPropertiesWithoutUndo(); + CreateMap(settings); + EditorSceneManager.SaveScene(scene, ScenePath); + } + finally + { + if (!replaceBatchStartup) + { + EditorSceneManager.CloseScene(scene, true); + if (previous.IsValid() && previous.isLoaded) SceneManager.SetActiveScene(previous); + } + } + } + AssetDatabase.SaveAssets(); + AssetDatabase.Refresh(); + stopwatch.Stop(); + Debug.Log($"DEFENSE25D_SETUP scene={ScenePath}; renderer={rendererIndex}; setupMilliseconds={stopwatch.ElapsedMilliseconds}; PNG edits=0; frame edits=0; Front/Back edits=0"); + } + + public static void SyncSolution() + { + Type type = AppDomain.CurrentDomain.GetAssemblies().Select(a => a.GetType("UnityEditor.SyncVS")).FirstOrDefault(t => t != null); + var method = type?.GetMethod("SyncSolution", System.Reflection.BindingFlags.Static | System.Reflection.BindingFlags.Public | System.Reflection.BindingFlags.NonPublic); + if (method == null) throw new InvalidOperationException("Unity SyncVS.SyncSolution not found."); + method.Invoke(null, null); + Debug.Log("DEFENSE25D_SOLUTION_SYNCED"); + } + + public static void SyncDefenseSkeletonOnly() + { + Defense25DComparisonSettings settings = AssetDatabase.LoadAssetAtPath(SettingsPath); + EnemyDefinition skeleton = AssetDatabase.LoadAssetAtPath("Assets/_Project/Constants/Enemies/Skeleton.asset"); + if (settings == null || skeleton == null) + throw new InvalidOperationException("Existing comparison settings or Skeleton definition not found."); + settings.Enemy = skeleton; + settings.EnemyAttackClip = AssetDatabase.LoadAssetAtPath( + "Assets/_Project/Art/Characters/Skeleton/Animations/Skeleton_Attack.anim"); + settings.EnemyArt = ReadCharacter("Skeleton", "Skeleton", "Attack"); + EditorUtility.SetDirty(settings); + AssetDatabase.SaveAssets(); + Debug.Log("DEFENSE25D_SKELETON_SETTINGS_SYNCED settingsOnly=true; sceneRegenerated=false"); + } + + private static ComparisonSpriteFrames ReadCharacter(string folder, string prefix, string attack) + { + Sprite[] Read(string suffix) => AssetDatabase.LoadAllAssetsAtPath($"Assets/_Project/Art/Characters/{folder}/{prefix}_{suffix}.png") + .OfType().OrderBy(s => int.TryParse(s.name[(s.name.LastIndexOf('_') + 1)..], out int n) ? n : 0).ToArray(); + var frames = new ComparisonSpriteFrames { Idle = Read("Idle"), Walk = Read("Walk"), Attack = Read(attack), Hurt = Read("Hurt"), Death = Read("Death") }; + if (frames.Idle.Length == 0) throw new InvalidOperationException($"Existing {prefix} idle sprites missing."); + return frames; + } + + private static int AppendComparisonRenderer() + { + UniversalRendererData renderer = AssetDatabase.LoadAssetAtPath(RendererPath); + if (renderer == null) + { + renderer = ScriptableObject.CreateInstance(); + AssetDatabase.CreateAsset(renderer, RendererPath); + } + UniversalRenderPipelineAsset pipeline = GraphicsSettings.currentRenderPipeline as UniversalRenderPipelineAsset + ?? AssetDatabase.LoadAssetAtPath("Assets/Settings/UniversalRP.asset"); + if (pipeline == null) throw new InvalidOperationException("Active Universal pipeline not found."); + SerializedObject serialized = new(pipeline); + SerializedProperty list = serialized.FindProperty("m_RendererDataList"); + for (int i = 0; i < list.arraySize; i++) + if (list.GetArrayElementAtIndex(i).objectReferenceValue == renderer) return i; + int index = list.arraySize; + list.InsertArrayElementAtIndex(index); + list.GetArrayElementAtIndex(index).objectReferenceValue = renderer; + serialized.ApplyModifiedPropertiesWithoutUndo(); + return index; + } + + private static Material SpriteMaterial(string name, bool alpha, Color color) + { + Material material = GetMaterial(name, "BumpCombat/Comparison Sprite Depth"); + material.SetColor("_BaseColor", color); + material.SetFloat("_Cutoff", alpha ? .001f : .4f); + material.SetFloat("_ZWrite", alpha ? 0f : 1f); + material.SetFloat("_SrcBlend", alpha ? (float)BlendMode.SrcAlpha : (float)BlendMode.One); + material.SetFloat("_DstBlend", alpha ? (float)BlendMode.OneMinusSrcAlpha : (float)BlendMode.Zero); + material.renderQueue = alpha ? (int)RenderQueue.Transparent : (int)RenderQueue.AlphaTest; + EditorUtility.SetDirty(material); + return material; + } + + private static Material SolidMaterial(string name, Color color) + { + Material material = GetMaterial(name, "Universal Render Pipeline/Unlit"); + material.SetColor("_BaseColor", color); + EditorUtility.SetDirty(material); + return material; + } + + private static Material GetMaterial(string name, string shaderName) + { + string path = $"{AssetFolder}/{name}.mat"; + Material material = AssetDatabase.LoadAssetAtPath(path); + if (material != null) return material; + Shader shader = Shader.Find(shaderName); + if (shader == null) throw new InvalidOperationException($"Shader missing: {shaderName}"); + material = new Material(shader) { name = name }; + AssetDatabase.CreateAsset(material, path); + return material; + } + + private static void CreateMap(Defense25DComparisonSettings settings) + { + Transform map = new GameObject("Ground and Physics3D obstacles").transform; + GameObject Box(string name, Vector3 center, Vector3 size, Material material, bool collision) + { + GameObject go = GameObject.CreatePrimitive(PrimitiveType.Cube); + go.name = name; + go.transform.SetParent(map, false); + go.transform.position = center + Vector3.up * settings.GroundY; + go.transform.localScale = size; + go.GetComponent().sharedMaterial = material; + if (collision) go.layer = Defense25DComparisonSettings.TerrainLayer; + else UnityEngine.Object.DestroyImmediate(go.GetComponent()); + return go; + } + Vector2 extents = settings.MapHalfExtents; + Box("XZ ground", new Vector3(0f, -.12f, 0f), new Vector3(extents.x * 2f, .2f, extents.y * 2f), settings.GroundMaterial, false); + Box("North boundary", new Vector3(0f, .6f, extents.y), new Vector3(extents.x * 2f + .5f, 1.2f, .5f), settings.WallMaterial, true); + Box("South boundary", new Vector3(0f, .6f, -extents.y), new Vector3(extents.x * 2f + .5f, 1.2f, .5f), settings.WallMaterial, true); + Box("West boundary", new Vector3(-extents.x, .6f, 0f), new Vector3(.5f, 1.2f, extents.y * 2f), settings.WallMaterial, true); + Box("East boundary", new Vector3(extents.x, .6f, 0f), new Vector3(.5f, 1.2f, extents.y * 2f), settings.WallMaterial, true); + Box("Central wall - slide and occlusion", new Vector3(-1f, .55f, -.15f), new Vector3(3.2f, 1.1f, 1.5f), settings.WallMaterial, true); + Box("South obstacle", new Vector3(4f, .75f, -2.5f), new Vector3(2f, 1.5f, 1.5f), settings.WallMaterial, true); + for (int i = 0; i < settings.Route.Length - 1; i++) + { + Vector2 a = settings.Route[i], b = settings.Route[i + 1]; + GameObject path = Box($"Route segment {i + 1}", Defense25DActor.OnGround((a + b) * .5f, -.005f), + new Vector3(.65f, .015f, Vector2.Distance(a, b)), settings.PathMaterial, false); + path.transform.rotation = Quaternion.LookRotation(Defense25DActor.OnGround(b - a)); + } + Box("Portal", Defense25DActor.OnGround(settings.Route[0], .06f), new Vector3(1f, .12f, 1f), settings.PathMaterial, false); + Vector2 end = settings.Route[^1]; + Box("Base - arrival marker", Defense25DActor.OnGround(end, .2f), new Vector3(1f, .4f, 1f), settings.BaseMaterial, false); + } + + private static void EnsureFolder(string path) + { + if (AssetDatabase.IsValidFolder(path)) return; + string parent = Path.GetDirectoryName(path).Replace('\\', '/'); + EnsureFolder(parent); + AssetDatabase.CreateFolder(parent, Path.GetFileName(path)); + } + } +} diff --git a/Assets/_Project/Scripts/Editor/Defense25DComparisonSetup.cs.meta b/Assets/_Project/Scripts/Editor/Defense25DComparisonSetup.cs.meta new file mode 100644 index 0000000..3f88506 --- /dev/null +++ b/Assets/_Project/Scripts/Editor/Defense25DComparisonSetup.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 3353e1ca60584c7591e353d3dbf7568c diff --git a/Assets/_Project/Scripts/Enemies/EnemyAttack.cs b/Assets/_Project/Scripts/Enemies/EnemyAttack.cs index 1ef27a5..f96225c 100644 --- a/Assets/_Project/Scripts/Enemies/EnemyAttack.cs +++ b/Assets/_Project/Scripts/Enemies/EnemyAttack.cs @@ -1,5 +1,6 @@ using System.Collections.Generic; using BumpCombat.Constants; +using BumpCombat.Defense; using BumpCombat.Player; using UnityEngine; @@ -25,6 +26,8 @@ namespace BumpCombat.Enemies private float necrofireRayLength; private bool meleeAttackActive; private bool meleeHitConfirmed; + private bool defenseSummonHitConfirmed; + private bool checkingDefenseSummonGeometry; private StageEnemyEffectVisual necrofireRayVisual; private readonly List stageAoeVisuals = new(); private readonly List stageMultiWarnings = new(); @@ -202,6 +205,7 @@ namespace BumpCombat.Enemies attackOrigin = transform.position; meleeAttackActive = false; meleeHitConfirmed = false; + defenseSummonHitConfirmed = false; if (IsLancerAttack) { EnsureLancerVisual(); @@ -440,6 +444,7 @@ namespace BumpCombat.Enemies public void TickActive(float activeElapsed) { + TryApplyDefenseSummonHit(activeElapsed); if (necrofireRayActive) { TickNecrofireRay(); @@ -495,6 +500,7 @@ namespace BumpCombat.Enemies necrofireRayHitConfirmed = false; meleeAttackActive = false; meleeHitConfirmed = false; + defenseSummonHitConfirmed = false; if (necrofireRayVisual != null) { Destroy(necrofireRayVisual.gameObject); @@ -796,6 +802,28 @@ namespace BumpCombat.Enemies || controller.AttackShape == EnemyAttackShape.Cone; } + private void TryApplyDefenseSummonHit(float activeElapsed) + { + if (defenseSummonHitConfirmed || !controller.CurrentAttackPattern.IsContactWindowActive(activeElapsed, controller.ActiveDuration)) return; + DefenseSummonUnit target = controller.DefenseCombatTarget; + if (target == null || target.BodyCollider == null) return; + bool hit; + if (controller.AttackShape == EnemyAttackShape.Body) + { + ColliderDistance2D distance = controller.BodyCollider.Distance(target.BodyCollider); + hit = distance.isOverlapped || distance.distance <= 0.03f; + } + else + { + checkingDefenseSummonGeometry = true; + hit = ContainsPoint(target.BodyCollider.bounds.center); + checkingDefenseSummonGeometry = false; + } + if (!hit) return; + Vector2 direction = (Vector2)target.transform.position - (Vector2)transform.position; + if (direction.sqrMagnitude <= 0.0001f) direction = lockedDirection; + defenseSummonHitConfirmed = target.TryTakeDamage(controller.AttackDamage, direction.normalized); + } private void TryApplyMeleeHit(float activeElapsed) { if (!meleeAttackActive || meleeHitConfirmed) @@ -943,31 +971,26 @@ namespace BumpCombat.Enemies private bool ContainsPlayerCollider(Collider2D[] hits) { - Collider2D playerCollider = GetPlayerCollider(); - if (playerCollider == null || hits == null) - { - return false; - } - + Collider2D targetCollider = GetPlayerCollider(); + if (targetCollider == null || hits == null) return false; for (int i = 0; i < hits.Length; i++) { - if (hits[i] == playerCollider - || hits[i].GetComponentInParent() == playerHealth) - { - return true; - } + if (hits[i] == targetCollider) return true; + if (!checkingDefenseSummonGeometry + && hits[i].GetComponentInParent() == playerHealth) return true; } - return false; } private Collider2D GetPlayerCollider() { - return playerHealth != null - ? playerHealth.GetComponent() - : null; + if (checkingDefenseSummonGeometry) + { + DefenseSummonUnit target = controller != null ? controller.DefenseCombatTarget : null; + return target != null ? target.BodyCollider : null; + } + return playerHealth != null ? playerHealth.GetComponent() : null; } - private void TickNecrofireRay() { if (!necrofireRayActive || necrofireRayHitConfirmed) diff --git a/Assets/_Project/Scripts/Enemies/EnemyController.cs b/Assets/_Project/Scripts/Enemies/EnemyController.cs index fa4400a..2ef6341 100644 --- a/Assets/_Project/Scripts/Enemies/EnemyController.cs +++ b/Assets/_Project/Scripts/Enemies/EnemyController.cs @@ -4,6 +4,7 @@ using System.Collections.Generic; using BumpCombat.Combat; using BumpCombat.Constants; using BumpCombat.Core; +using BumpCombat.Defense; using BumpCombat.Player; using BumpCombat.Progression; using BumpCombat.Stage; @@ -70,6 +71,7 @@ namespace BumpCombat.Enemies private static readonly int HurtParameter = Animator.StringToHash("Hurt"); private static readonly int DeathState = Animator.StringToHash("Base Layer.Death"); private static readonly Collider2D[] SeparationBuffer = new Collider2D[16]; + private static readonly RaycastHit2D[] RouteCollisionBuffer = new RaycastHit2D[16]; private static readonly Dictionary> EncounterGroups = new(); private static Sprite launchWhiteSprite; private static int nextSpawnLifetimeIdentity; @@ -94,6 +96,7 @@ namespace BumpCombat.Enemies private ActiveArtifactController activeArtifactController; private Transform player; private PlayerHealth playerHealth; + private DefenseSummonUnit defenseCombatTarget; private Vector2 lockedDirection; private Vector2 capturedTarget; private Vector2 knockbackVelocity; @@ -112,6 +115,7 @@ namespace BumpCombat.Enemies private float vulnerabilityIncrease; private bool ownsAttackToken; private bool registeredAlive; + private bool suppressTowerTestDummyRewards; private float nextSeparationRefreshTime; private Vector2 cachedSeparation; private RunTimedEvent? runEventRole; @@ -259,6 +263,7 @@ namespace BumpCombat.Enemies public EnemyController SummonOwner => summonOwner; public bool IsProtectedFromCleanup => runEventRole.HasValue || phaseSummon; public bool HasContactDamage => definition != null && definition.HasContactDamage; + public DefenseSummonUnit DefenseCombatTarget => defenseCombatTarget != null && defenseCombatTarget.IsOperational ? defenseCombatTarget : null; public bool IsFlying => definition != null && definition.IsFlying; public Collider2D BodyCollider => bodyCollider != null ? bodyCollider : (bodyCollider = GetComponent()); public bool HasDetectedPlayer => hasDetectedPlayer; @@ -405,6 +410,16 @@ namespace BumpCombat.Enemies } #endif + public void ConfigureTowerTestDummy() + { + suppressTowerTestDummyRewards = true; + if (body != null) + { + body.linearVelocity = Vector2.zero; + body.constraints = RigidbodyConstraints2D.FreezeAll; + } + } + public void ConfigureEncounterGroup(int groupId, Vector2 anchor) { UnregisterEncounterGroup(); @@ -1588,9 +1603,29 @@ namespace BumpCombat.Enemies return false; } + Vector2 destination = ClampEnemyPosition(position); + Vector2 delta = destination - body.position; + if (DefensePrototypeController.Instance != null && DefensePrototypeController.Instance.IsActive + && delta.sqrMagnitude > 0.000001f) + { + ContactFilter2D filter = ContactFilter2D.noFilter; + filter.useTriggers = false; + int hitCount = bodyCollider.Cast(delta.normalized, filter, + RouteCollisionBuffer, delta.magnitude); + for (int i = 0; i < hitCount; i++) + { + Collider2D hit = RouteCollisionBuffer[i].collider; + DefenseSummonUnit summon = hit != null + ? hit.GetComponentInParent() : null; + if (summon == null || !summon.IsOperational) continue; + if (RouteCollisionBuffer[i].distance <= 0.001f + && Vector2.Dot(delta.normalized, body.position - (Vector2)hit.bounds.center) >= 0f) continue; + return false; + } + } knockbackVelocity = Vector2.zero; body.linearVelocity = Vector2.zero; - body.position = ClampEnemyPosition(position); + body.position = destination; transform.position = body.position; return true; } @@ -1793,7 +1828,7 @@ namespace BumpCombat.Enemies SetFacing(direction); float repeatWarning = WarningDuration * runEventTuning.RepeatWarningDurationMultiplier; - BeginAttackWarning(direction, player.position, repeatWarning); + BeginAttackWarning(direction, attackTarget, repeatWarning); } private void BeginAttackWarning( @@ -1953,7 +1988,7 @@ namespace BumpCombat.Enemies CurrentAttackPatternIndex); completedAttacksInSequence = 0; remainingBackHitsToInterrupt = BackHitsToInterrupt; - BeginAttackWarning(direction, player.position, WarningDuration); + BeginAttackWarning(direction, attackTarget, WarningDuration); return; } @@ -2181,6 +2216,11 @@ namespace BumpCombat.Enemies private Vector2 GetAttackTargetPosition() { + DefenseSummonUnit summon = DefenseCombatTarget; + if (summon != null && summon.BodyCollider != null) + { + return summon.BodyCollider.bounds.center; + } if (UsesHorizontalRootAttackVisual && playerHealth != null) { Collider2D targetCollider = @@ -2201,20 +2241,18 @@ namespace BumpCombat.Enemies return true; } - float targetMinY = player.position.y; - float targetMaxY = player.position.y; - if (playerHealth != null) + Collider2D targetCollider = DefenseCombatTarget != null + ? DefenseCombatTarget.BodyCollider + : playerHealth != null ? playerHealth.GetComponent() : null; + float targetMinY = DefenseCombatTarget != null + ? DefenseCombatTarget.transform.position.y : player.position.y; + float targetMaxY = targetMinY; + if (targetCollider != null) { - Collider2D targetCollider = - playerHealth.GetComponent(); - if (targetCollider != null) - { - Bounds targetBounds = targetCollider.bounds; - targetMinY = targetBounds.min.y; - targetMaxY = targetBounds.max.y; - } + Bounds targetBounds = targetCollider.bounds; + targetMinY = targetBounds.min.y; + targetMaxY = targetBounds.max.y; } - // Match the same world-space horizontal attack band used by // EnemyAttack's OverlapBox. Collider bounds include the player // foot offset, so alignment does not infer the body from its @@ -2318,6 +2356,10 @@ namespace BumpCombat.Enemies || (state == EnemyState.Warning && attackAnimationStarted)); } + public void SetDefenseCombatTarget(DefenseSummonUnit target) + { + defenseCombatTarget = target != null && target.IsOperational ? target : null; + } public void Alert() { MarkPlayerDetected(false); @@ -2390,7 +2432,7 @@ namespace BumpCombat.Enemies private bool RefreshChaseAwareness() { - if (IsEventEnemy) + if (IsEventEnemy || DefenseCombatTarget != null) { return true; } @@ -2930,16 +2972,19 @@ namespace BumpCombat.Enemies animator.Play(DeathState, 0, 0f); } - if (experienceOrbPrefab != null) + if (!suppressTowerTestDummyRewards) { - ExperienceOrb orb = Instantiate(experienceOrbPrefab, transform.position, Quaternion.identity); - orb.Initialize( - experienceValueOverride >= 0 - ? experienceValueOverride - : definition.ExperienceValue); - } + if (experienceOrbPrefab != null) + { + ExperienceOrb orb = Instantiate(experienceOrbPrefab, transform.position, Quaternion.identity); + orb.Initialize( + experienceValueOverride >= 0 + ? experienceValueOverride + : definition.ExperienceValue); + } - CombatEvents.RaiseEnemyDied(gameObject); + CombatEvents.RaiseEnemyDied(gameObject); + } OnDied?.Invoke(this); Destroy( gameObject, diff --git a/Assets/_Project/Scripts/Player/ActiveArtifactController.cs b/Assets/_Project/Scripts/Player/ActiveArtifactController.cs index 8ae54ce..32bc22d 100644 --- a/Assets/_Project/Scripts/Player/ActiveArtifactController.cs +++ b/Assets/_Project/Scripts/Player/ActiveArtifactController.cs @@ -2777,18 +2777,7 @@ namespace BumpCombat.Player float definitionRange, bool charged) { - float visualScale = GetCycloneVisualScale( - definitionRange, - charged); - return new Vector2( - charged - ? CycloneChargedOrbitRadiusXPixels - : CycloneNormalOrbitRadiusXPixels, - charged - ? CycloneChargedOrbitRadiusYPixels - : CycloneNormalOrbitRadiusYPixels) - / CycloneOrbitPixelsPerUnit - * visualScale; + return CycloneGeometry.Radii(definitionRange, charged); } private static float GetThunderCrashVisualScale( @@ -2982,14 +2971,14 @@ namespace BumpCombat.Player return true; } - private static bool IsCycloneOrbitTextureValid(Texture2D texture) + internal static bool IsCycloneOrbitTextureValid(Texture2D texture) { return texture != null && texture.width >= CycloneOrbitFrameSize * CycloneOrbitFrameCount && texture.height >= CycloneOrbitFrameSize; } - private static Sprite[] CreateCycloneOrbitFrames( + internal static Sprite[] CreateCycloneOrbitFrames( Texture2D texture, string layerName) { diff --git a/Assets/_Project/Scripts/UI/RunHUD.cs b/Assets/_Project/Scripts/UI/RunHUD.cs index 0cb831b..629d0ec 100644 --- a/Assets/_Project/Scripts/UI/RunHUD.cs +++ b/Assets/_Project/Scripts/UI/RunHUD.cs @@ -71,6 +71,7 @@ namespace BumpCombat.UI private Image[] artifactSlotImages = System.Array.Empty(); private Image[] artifactSlotIcons = System.Array.Empty(); private readonly Stack damagePopupPool = new(); + public int ActiveDamagePopupCount => activeDamagePopupCount; private readonly HashSet activeDamagePopups = new(); private int activeDamagePopupCount; private int damagePopupLifecycleVersion; @@ -916,6 +917,22 @@ namespace BumpCombat.UI playerHealth.transform.position + Vector3.up * 0.75f); } + public void ShowTowerDamagePopup( + float actualDamage, + Color color, + Vector3 worldPosition) + { + if (actualDamage <= 0f) + { + return; + } + + TryShowDamagePopup( + $"-{DamageCalculator.FormatDamage(actualDamage)}", + color, + worldPosition); + } + private bool TryShowDamagePopup( string text, Color color, diff --git a/Assets/_Project/Scripts/UI/RunMenuController.cs b/Assets/_Project/Scripts/UI/RunMenuController.cs index 2b25b03..c8ac1fc 100644 --- a/Assets/_Project/Scripts/UI/RunMenuController.cs +++ b/Assets/_Project/Scripts/UI/RunMenuController.cs @@ -541,6 +541,15 @@ namespace BumpCombat.UI private void BeginRun(RunMode mode) { + if (mode == RunMode.DefensePrototype) + { + BumpCombat.Defense.DefensePrototypeController.PrepareTowerHitTestRun(); + } + else + { + BumpCombat.Defense.DefensePrototypeController.CancelTowerHitTestRun(); + } + if (runManager == null || !runManager.BeginRun(mode)) { return; @@ -580,6 +589,7 @@ namespace BumpCombat.UI private void ReturnToTitle() { + BumpCombat.Defense.DefensePrototypeController.CancelTowerHitTestRun(); audioService?.PlayUi("ui_confirm"); runManager?.RestartToTitle(); } diff --git a/Assets/_Project/Tests/EditMode/Defense25DComparisonTests.cs b/Assets/_Project/Tests/EditMode/Defense25DComparisonTests.cs new file mode 100644 index 0000000..faabdac --- /dev/null +++ b/Assets/_Project/Tests/EditMode/Defense25DComparisonTests.cs @@ -0,0 +1,65 @@ +using System.Reflection; +using BumpCombat.Combat; +using BumpCombat.Defense.Comparison; +using BumpCombat.Player; +using NUnit.Framework; +using UnityEditor; +using UnityEngine; + +namespace BumpCombat.Tests +{ + public sealed class Defense25DComparisonTests + { + [Test] + public void IndependentSceneExists() + { + Assert.That(AssetDatabase.LoadAssetAtPath( + "Assets/_Project/Scenes/Defense25DComparison.unity"), Is.Not.Null); + } + + [TestCase(false, .85f, 38.0625f / 32f, 15.03125f / 32f, 3)] + [TestCase(true, 1.25f, 46f / 32f, 18f / 32f, 5)] + public void CyclonePreservesAuthoredEllipseAndTickSchedule( + bool charged, float range, float radiusX, float radiusZ, int count) + { + Vector2 radii = CycloneGeometry.Radii(range, charged); + Assert.That(radii.x, Is.EqualTo(radiusX).Within(.00001f)); + Assert.That(radii.y, Is.EqualTo(radiusZ).Within(.00001f)); + Assert.That(CycloneGeometry.ScheduledHitCount(charged ? .65f : .36f, .14f), Is.EqualTo(count)); + MethodInfo original = typeof(ActiveArtifactController).GetMethod( + "GetCycloneHitRadii", BindingFlags.NonPublic | BindingFlags.Static); + Assert.That(original.Invoke(null, new object[] { range, charged }), Is.EqualTo(radii)); + } + + [Test] + public void EllipseUsesBodyEdgeRatherThanGroundCenterOrSphere() + { + Vector2 radii = CycloneGeometry.Radii(.85f, false); + Vector2 bodyCenter = new(0f, radii.y + .2f); + Vector2 Closest(Vector2 point) => bodyCenter + Vector2.ClampMagnitude(point - bodyCenter, .24f); + Assert.That(CycloneGeometry.Overlaps(Vector2.zero, radii, Closest), Is.True); + bodyCenter.y = radii.y + .3f; + Assert.That(CycloneGeometry.Overlaps(Vector2.zero, radii, Closest), Is.False); + } + + [Test] + public void ViewportCentersIntegerUpscaleAndRejectsLetterbox() + { + Rect rect = Defense25DView.GetViewport(1920, 1080); + Assert.That(rect, Is.EqualTo(new Rect(0, 0, 1920, 1080))); + rect = Defense25DView.GetViewport(1399, 799); + Assert.That(rect.width, Is.EqualTo(1280)); + Assert.That(rect.height, Is.EqualTo(720)); + Assert.That(rect.Contains(Vector2.zero), Is.False); + } + + [Test] + public void SharedBumpDamageStillFloorsOnlyAtFinalBoundary() + { + Assert.That(DamageCalculator.FinalizeDamage( + BumpCombatMath.ApplySideMultiplier(10.9f, HitSide.Side)), Is.EqualTo(13)); + Assert.That(DamageCalculator.FinalizeDamage( + BumpCombatMath.ApplySideMultiplier(10f, HitSide.Back)), Is.EqualTo(15)); + } + } +} diff --git a/Assets/_Project/Tests/EditMode/Defense25DComparisonTests.cs.meta b/Assets/_Project/Tests/EditMode/Defense25DComparisonTests.cs.meta new file mode 100644 index 0000000..75231b3 --- /dev/null +++ b/Assets/_Project/Tests/EditMode/Defense25DComparisonTests.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 2b42d4c49df64f999778421ebe6a83e3 diff --git a/Assets/_Project/Tests/EditMode/Defense25DSkeletonCombatTests.cs b/Assets/_Project/Tests/EditMode/Defense25DSkeletonCombatTests.cs new file mode 100644 index 0000000..b7e9788 --- /dev/null +++ b/Assets/_Project/Tests/EditMode/Defense25DSkeletonCombatTests.cs @@ -0,0 +1,55 @@ +using BumpCombat.Defense.Comparison; +using BumpCombat.Enemies; +using NUnit.Framework; +using UnityEditor; +using UnityEngine; + +namespace BumpCombat.Tests +{ + public sealed class Defense25DSkeletonCombatTests + { + [Test] + public void SkeletonSlashUsesSourceBoxFootprintAndRejectsTargetsBehindOrigin() + { + Assert.That(Defense25DEnemy.SkeletonSlashIntersectsCircle(Vector2.zero, Vector2.right, 1f, .75f, + new Vector2(.5f, .49f), .1f), Is.False, + "A hurtbox beyond the authored half-width must not be hit."); + Assert.That(Defense25DEnemy.SkeletonSlashIntersectsCircle(Vector2.zero, Vector2.right, 1f, .75f, + new Vector2(.5f, .45f), .1f), Is.True, + "A circular hurtbox overlapping the authored box edge must be hit."); + Assert.That(Defense25DEnemy.SkeletonSlashIntersectsCircle(Vector2.zero, Vector2.right, 1f, .75f, + new Vector2(-.01f, 0f), .5f), Is.False, + "The source attack rejects a target whose collider center is behind the slash origin."); + } + + [Test] + public void SkeletonAttackAnimationUsesDefinitionLeadTime() + { + EnemyDefinition skeleton = AssetDatabase.LoadAssetAtPath( + "Assets/_Project/Constants/Enemies/Skeleton.asset"); + Assert.That(skeleton, Is.Not.Null); + EnemyAttackPattern pattern = skeleton.GetAttackPattern(0); + + Assert.That(Defense25DEnemy.SkeletonAttackAnimationShouldStart(pattern, .324f), Is.False); + Assert.That(Defense25DEnemy.SkeletonAttackAnimationShouldStart(pattern, .326f), Is.True); + Assert.That(pattern.WarningDuration, Is.EqualTo(.7f).Within(.0001f)); + Assert.That(pattern.AnimationLeadTime, Is.EqualTo(.375f).Within(.0001f)); + } + + [Test] + public void SkeletonAttackFramesFollowTheNonLoopingSourceClip() + { + AnimationClip clip = AssetDatabase.LoadAssetAtPath( + "Assets/_Project/Art/Characters/Skeleton/Animations/Skeleton_Attack.anim"); + Assert.That(clip, Is.Not.Null); + Assert.That(clip.frameRate, Is.EqualTo(8f).Within(.001f)); + Assert.That(clip.length, Is.EqualTo(.75f).Within(.001f)); + Assert.That(clip.isLooping, Is.False); + Assert.That(Defense25DEnemy.SkeletonAttackFrameAt(clip, .124f, 6), Is.EqualTo(0)); + Assert.That(Defense25DEnemy.SkeletonAttackFrameAt(clip, .126f, 6), Is.EqualTo(1)); + Assert.That(Defense25DEnemy.SkeletonAttackFrameAt(clip, 1f, 6), Is.EqualTo(5), + "A non-looping source attack holds its last authored frame through recovery."); + } + + } +} diff --git a/Assets/_Project/Tests/EditMode/Defense25DSkeletonCombatTests.cs.meta b/Assets/_Project/Tests/EditMode/Defense25DSkeletonCombatTests.cs.meta new file mode 100644 index 0000000..6851c41 --- /dev/null +++ b/Assets/_Project/Tests/EditMode/Defense25DSkeletonCombatTests.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 799325f1d64440fd8f07e757025dff0e diff --git a/Assets/_Project/Tests/PlayMode/BumpCombat.PlayModeTests.asmdef b/Assets/_Project/Tests/PlayMode/BumpCombat.PlayModeTests.asmdef index 9f7d037..c16564b 100644 --- a/Assets/_Project/Tests/PlayMode/BumpCombat.PlayModeTests.asmdef +++ b/Assets/_Project/Tests/PlayMode/BumpCombat.PlayModeTests.asmdef @@ -3,6 +3,7 @@ "rootNamespace": "BumpCombat.PlayModeTests", "references": [ "BumpCombat.Runtime", + "Unity.RenderPipelines.Universal.Runtime", "Unity.InputSystem", "UnityEngine.UI" ], diff --git a/Assets/_Project/Tests/PlayMode/ComparisonSpriteFlipRenderPlayModeTests.cs b/Assets/_Project/Tests/PlayMode/ComparisonSpriteFlipRenderPlayModeTests.cs new file mode 100644 index 0000000..c585d13 --- /dev/null +++ b/Assets/_Project/Tests/PlayMode/ComparisonSpriteFlipRenderPlayModeTests.cs @@ -0,0 +1,222 @@ +using System.Collections; +using NUnit.Framework; +using UnityEngine; +using UnityEngine.Rendering.Universal; +using UnityEngine.TestTools; +using Object = UnityEngine.Object; + +namespace BumpCombat.Tests +{ + public sealed class ComparisonSpriteFlipRenderPlayModeTests + { + private const int FlipRenderTestLayer = 31; + + [UnityTest] + public IEnumerator SpriteRendererFlipXMirrorsAsymmetricRenderedSprite() + { + const int width = 80; + const int height = 40; + const int textureWidth = 8; + const int textureHeight = 4; + GameObject cameraObject = null; + GameObject spriteObject = null; + Texture2D source = null; + Texture2D capture = null; + RenderTexture target = null; + Material material = null; + Sprite sprite = null; + + try + { + Shader shader = Shader.Find("BumpCombat/Comparison Sprite Depth"); + Assert.That(shader, Is.Not.Null); + material = new Material(shader) { enableInstancing = true }; + source = new Texture2D(textureWidth, textureHeight, TextureFormat.RGBA32, false) + { + filterMode = FilterMode.Point, + wrapMode = TextureWrapMode.Clamp, + }; + Color32[] sourcePixels = new Color32[textureWidth * textureHeight]; + for (int i = 0; i < sourcePixels.Length; i++) sourcePixels[i] = new Color32(0, 0, 0, 0); + sourcePixels[0] = new Color32(255, 32, 32, 255); + sourcePixels[1] = new Color32(255, 96, 32, 255); + sourcePixels[textureWidth + 2] = new Color32(32, 255, 32, 255); + sourcePixels[textureWidth * 2 + 5] = new Color32(32, 64, 255, 255); + sourcePixels[textureWidth * 3 + 7] = new Color32(255, 255, 32, 255); + source.SetPixels32(sourcePixels); + source.Apply(false, false); + sprite = Sprite.Create(source, new Rect(0, 0, textureWidth, textureHeight), + new Vector2(.5f, .5f), 2f); + + target = new RenderTexture(width, height, 24, RenderTextureFormat.ARGB32) + { + filterMode = FilterMode.Point, + antiAliasing = 1, + }; + target.Create(); + cameraObject = new GameObject("Comparison flip render test camera"); + Camera camera = cameraObject.AddComponent(); + cameraObject.AddComponent().SetRenderer(1); + camera.orthographic = true; + camera.orthographicSize = 1f; + camera.aspect = (float)width / height; + camera.clearFlags = CameraClearFlags.SolidColor; + camera.backgroundColor = Color.black; + camera.targetTexture = target; + camera.cullingMask = 1 << FlipRenderTestLayer; + camera.transform.position = new Vector3(0f, 0f, -10f); + + spriteObject = new GameObject("Asymmetric flip test sprite"); + spriteObject.layer = FlipRenderTestLayer; + SpriteRenderer renderer = spriteObject.AddComponent(); + renderer.sprite = sprite; + renderer.sharedMaterial = material; + renderer.flipX = false; + capture = new Texture2D(width, height, TextureFormat.RGBA32, false); + yield return null; + Color[] forward = Capture(camera, target, capture); + + renderer.flipX = true; + yield return null; + Color[] flipped = Capture(camera, target, capture); + + int mismatchedPixels = 0; + int forwardBrightPixels = 0; + int flippedBrightPixels = 0; + int flipChangedPixels = 0; + for (int y = 0; y < height; y++) + for (int x = 0; x < width; x++) + { + Color expected = forward[y * width + (width - 1 - x)]; + Color actual = flipped[y * width + x]; + Color sameCoordinateForward = forward[y * width + x]; + if (IsBright(forward[y * width + x])) forwardBrightPixels++; + if (IsBright(actual)) flippedBrightPixels++; + if (ColorDistance(sameCoordinateForward, actual) > .02f) flipChangedPixels++; + if (Mathf.Abs(expected.r - actual.r) + Mathf.Abs(expected.g - actual.g) + + Mathf.Abs(expected.b - actual.b) + Mathf.Abs(expected.a - actual.a) > .02f) + mismatchedPixels++; + } + Assert.That(forwardBrightPixels, Is.GreaterThan(0), "The forward image must contain rendered sprite pixels."); + Assert.That(flippedBrightPixels, Is.GreaterThan(0), "The flipped image must contain rendered sprite pixels."); + Assert.That(flipChangedPixels, Is.GreaterThan(0), "Flipping this asymmetric sprite must change actual pixels."); + Assert.That(mismatchedPixels, Is.Zero, + "SpriteRenderer.flipX must mirror the rendered pixels, not merely expose a flip flag."); + } + finally + { + if (spriteObject != null) Object.Destroy(spriteObject); + if (cameraObject != null) Object.Destroy(cameraObject); + if (sprite != null) Object.Destroy(sprite); + if (source != null) Object.Destroy(source); + if (capture != null) Object.Destroy(capture); + if (target != null) { target.Release(); Object.Destroy(target); } + if (material != null) Object.Destroy(material); + } + } + + [UnityTest] + public IEnumerator SharedSpriteMaterialStillRendersMeshAndLineGeometry() + { + const int width = 80; + const int height = 80; + GameObject cameraObject = null; + GameObject meshObject = null; + GameObject lineObject = null; + Texture2D capture = null; + RenderTexture target = null; + Material material = null; + Mesh mesh = null; + try + { + Shader shader = Shader.Find("BumpCombat/Comparison Sprite Depth"); + Assert.That(shader, Is.Not.Null); + material = new Material(shader); + target = new RenderTexture(width, height, 24, RenderTextureFormat.ARGB32); + target.Create(); + + cameraObject = new GameObject("Shared material render test camera"); + Camera camera = cameraObject.AddComponent(); + cameraObject.AddComponent().SetRenderer(1); + camera.orthographic = true; + camera.orthographicSize = 2f; + camera.aspect = 1f; + camera.clearFlags = CameraClearFlags.SolidColor; + camera.backgroundColor = Color.black; + camera.targetTexture = target; + camera.cullingMask = 1 << FlipRenderTestLayer; + camera.transform.position = new Vector3(0f, 0f, -10f); + + meshObject = new GameObject("Ground-like MeshRenderer"); + meshObject.layer = FlipRenderTestLayer; + mesh = new Mesh + { + vertices = new[] + { + new Vector3(-.6f, .1f, 0f), new Vector3(.6f, .1f, 0f), + new Vector3(-.6f, 1.3f, 0f), new Vector3(.6f, 1.3f, 0f), + }, + uv = new[] { Vector2.zero, Vector2.right, Vector2.up, Vector2.one }, + colors = new[] { Color.white, Color.white, Color.white, Color.white }, + triangles = new[] { 0, 2, 1, 2, 3, 1 }, + }; + mesh.RecalculateBounds(); + meshObject.AddComponent().sharedMesh = mesh; + meshObject.AddComponent().sharedMaterial = material; + + lineObject = new GameObject("Ground-like LineRenderer"); + lineObject.layer = FlipRenderTestLayer; + LineRenderer line = lineObject.AddComponent(); + line.useWorldSpace = true; + line.positionCount = 2; + line.startWidth = line.endWidth = .12f; + line.startColor = line.endColor = Color.red; + line.sharedMaterial = material; + line.SetPosition(0, new Vector3(-1.2f, -1f, 0f)); + line.SetPosition(1, new Vector3(1.2f, -1f, 0f)); + + capture = new Texture2D(width, height, TextureFormat.RGBA32, false); + yield return null; + Color[] pixels = Capture(camera, target, capture); + int meshPixels = 0; + int linePixels = 0; + foreach (Color pixel in pixels) + { + if (pixel.r > .8f && pixel.g > .8f && pixel.b > .8f) meshPixels++; + if (pixel.r > .8f && pixel.g < .3f && pixel.b < .3f) linePixels++; + } + Assert.That(meshPixels, Is.GreaterThan(10), + "The shared shader must keep opaque floor/mesh geometry visible with default SpriteProps."); + Assert.That(linePixels, Is.GreaterThan(3), + "LineRenderer geometry using the shared comparison material must remain visible."); + } + finally + { + if (meshObject != null) Object.Destroy(meshObject); + if (lineObject != null) Object.Destroy(lineObject); + if (cameraObject != null) Object.Destroy(cameraObject); + if (capture != null) Object.Destroy(capture); + if (mesh != null) Object.Destroy(mesh); + if (target != null) { target.Release(); Object.Destroy(target); } + if (material != null) Object.Destroy(material); + } + } + + private static Color[] Capture(Camera camera, RenderTexture target, Texture2D capture) + { + RenderTexture previous = RenderTexture.active; + camera.Render(); + RenderTexture.active = target; + capture.ReadPixels(new Rect(0f, 0f, target.width, target.height), 0, 0); + capture.Apply(false, false); + Color[] pixels = capture.GetPixels(); + RenderTexture.active = previous; + return pixels; + } + + private static bool IsBright(Color color) => color.r > .25f || color.g > .25f || color.b > .25f; + + private static float ColorDistance(Color a, Color b) => Mathf.Abs(a.r - b.r) + Mathf.Abs(a.g - b.g) + + Mathf.Abs(a.b - b.b) + Mathf.Abs(a.a - b.a); + } +} diff --git a/Assets/_Project/Tests/PlayMode/ComparisonSpriteFlipRenderPlayModeTests.cs.meta b/Assets/_Project/Tests/PlayMode/ComparisonSpriteFlipRenderPlayModeTests.cs.meta new file mode 100644 index 0000000..7bc75b0 --- /dev/null +++ b/Assets/_Project/Tests/PlayMode/ComparisonSpriteFlipRenderPlayModeTests.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 18ab377df2c44fd9bb9967d73267ac35 diff --git a/Assets/_Project/Tests/PlayMode/Defense25DArtifactExecutorPlayModeTests.cs b/Assets/_Project/Tests/PlayMode/Defense25DArtifactExecutorPlayModeTests.cs new file mode 100644 index 0000000..4bcd0d2 --- /dev/null +++ b/Assets/_Project/Tests/PlayMode/Defense25DArtifactExecutorPlayModeTests.cs @@ -0,0 +1,261 @@ +using System.Collections; +using System.Linq; +using BumpCombat.Combat; +using BumpCombat.Constants; +using BumpCombat.Defense.Comparison; +using BumpCombat.Player; +using NUnit.Framework; +using UnityEditor; +using UnityEngine; +using UnityEngine.TestTools; +using Object = UnityEngine.Object; + +namespace BumpCombat.Tests +{ + public sealed class Defense25DArtifactExecutorPlayModeTests + { + private const string SettingsPath = "Assets/_Project/Constants/Defense25DComparison/ComparisonSettings.asset"; + private Defense25DComparison world; + private Defense25DComparisonSettings settings; + private Defense25DLoadout loadout; + private Defense25DArtifactExecutor executor; + private float previousTimeScale; + + [UnitySetUp] + public IEnumerator SetUp() + { + previousTimeScale = Time.timeScale; + Time.timeScale = 1f; + settings = Object.Instantiate(AssetDatabase.LoadAssetAtPath(SettingsPath)); + Assert.That(settings, Is.Not.Null); + world = new GameObject("Artifact executor test world").AddComponent(); + world.Initialize(settings); + world.AutoSpawn = false; + world.ReadInput = false; + world.Player.ResetAt(Vector2.zero); + loadout = new Defense25DLoadout(100f, 100f, 6, 2); + loadout.ConfigureCatalog(GameplayConstants.Current.Artifacts.Definitions); + executor = new Defense25DArtifactExecutor(); + executor.Configure(world, loadout); + yield return new WaitForFixedUpdate(); + } + + [UnityTearDown] + public IEnumerator TearDown() + { + executor?.Cancel(); + if (world != null) Object.Destroy(world.gameObject); + if (settings != null) Object.Destroy(settings); + Time.timeScale = previousTimeScale; + yield return null; + } + + [UnityTest] + public IEnumerator ChargeLockedInputUsesNormalArtifactOnRelease() + { + ActiveArtifactDefinition phoenix = loadout.FindArtifact(ActiveArtifactEffect.Phoenix); + Assert.That(phoenix, Is.Not.Null); + Assert.That(loadout.TryAddArtifact(phoenix), Is.True); + loadout.ChargeUnlocked = false; + + Assert.That(executor.OnArtifactButtonPressed(), Is.True); + Assert.That(loadout.IsCharging, Is.False); + Assert.That(executor.OnArtifactButtonReleased(), Is.True); + Assert.That(loadout.Gauge, Is.EqualTo(100f - phoenix.NormalGaugeCost).Within(.001f)); + yield return null; + } + + [UnityTest] + public IEnumerator HurtPlayerCannotStartArtifactInput() + { + ActiveArtifactDefinition phoenix = loadout.FindArtifact(ActiveArtifactEffect.Phoenix); + Assert.That(loadout.TryAddArtifact(phoenix), Is.True); + Assert.That(world.Player.TakeDamage(1f, Vector2.left), Is.True); + + Assert.That(executor.OnArtifactButtonPressed(), Is.False); + Assert.That(loadout.Gauge, Is.EqualTo(100f)); + yield return null; + } + + [UnityTest] + public IEnumerator DeadPlayerCannotStartArtifactInput() + { + ActiveArtifactDefinition phoenix = loadout.FindArtifact(ActiveArtifactEffect.Phoenix); + Assert.That(loadout.TryAddArtifact(phoenix), Is.True); + Assert.That(world.Player.TakeDamage(world.Player.CurrentHealth + 1f, Vector2.left), Is.True); + + Assert.That(executor.OnArtifactButtonPressed(), Is.False); + Assert.That(loadout.Gauge, Is.EqualTo(100f)); + yield return null; + } + + [UnityTest] + public IEnumerator ActiveDashRejectsAnotherArtifactPress() + { + ActiveArtifactDefinition dash = loadout.FindArtifact(ActiveArtifactEffect.Dash); + Assert.That(loadout.TryAddArtifact(dash), Is.True); + loadout.ChargeUnlocked = false; + + Assert.That(loadout.TryUse(false), Is.True); + Assert.That(executor.IsDashing, Is.True); + Assert.That(executor.OnArtifactButtonPressed(), Is.False); + yield return null; + } + + [UnityTest] + public IEnumerator ChainLightningDamagesOnlyAfterItsAuthoredTravelInterval() + { + ActiveArtifactDefinition chain = loadout.FindArtifact(ActiveArtifactEffect.ChainLightning); + Assert.That(chain, Is.Not.Null); + Assert.That(loadout.TryAddArtifact(chain), Is.True); + Defense25DEnemy enemy = world.Enemies[0]; + enemy.Spawn(Vector2.right * .5f); + Physics.SyncTransforms(); + float health = enemy.Actor.CurrentHealth; + + Assert.That(loadout.TryUse(false), Is.True); + executor.Step(Mathf.Max(.01f, chain.HitInterval * .5f)); + Assert.That(enemy.Actor.CurrentHealth, Is.EqualTo(health)); + executor.Step(Mathf.Max(.01f, chain.HitInterval)); + Assert.That(enemy.Actor.CurrentHealth, Is.LessThan(health)); + yield return null; + } + + [UnityTest] + public IEnumerator DamageHitPublishesCombatAndRelicNotifications() + { + ActiveArtifactDefinition phoenix = loadout.FindArtifact(ActiveArtifactEffect.Phoenix); + Assert.That(loadout.TryAddArtifact(phoenix), Is.True); + Defense25DEnemy enemy = world.Enemies[0]; + enemy.Spawn(Vector2.right * .5f); + Physics.SyncTransforms(); + bool validHit = false; + bool relicHit = false; + void OnValidHit(CombatHitResult hit) + { + if (hit.Target == enemy.gameObject && hit.IsArtifactHit) validHit = true; + } + CombatEvents.OnValidHit += OnValidHit; + executor.NotifyRelicHit = _ => relicHit = true; + + try + { + Assert.That(loadout.TryUse(false), Is.True); + Assert.That(validHit, Is.True); + Assert.That(relicHit, Is.True); + } + finally + { + CombatEvents.OnValidHit -= OnValidHit; + } + yield return null; + } + + [UnityTest] + public IEnumerator CycloneHitsEnemyWhoseBodyColliderOverlapsEllipse() + { + ActiveArtifactDefinition cyclone = loadout.FindArtifact(ActiveArtifactEffect.Cyclone); + Assert.That(loadout.TryAddArtifact(cyclone), Is.True); + Defense25DEnemy enemy = world.Enemies[0]; + Vector2 center = CycloneGeometry.CenterOffset(cyclone.NormalRange, false, settings.PlayerVisualScale); + Vector2 radii = CycloneGeometry.Radii(cyclone.NormalRange, false); + Vector2 intendedBodyCenter = center + Vector2.right * (radii.x + enemy.Actor.Radius * .6f); + enemy.Spawn(intendedBodyCenter - enemy.Actor.Offset); + Physics.SyncTransforms(); + Assert.That(CycloneGeometry.NormalizedDistance(enemy.Actor.Position - center, radii), Is.GreaterThan(1f), + "The actor anchor must be outside the authored ellipse for this body-overlap case."); + float colliderPlaneY = settings.GroundY + enemy.Actor.BodyCollider.center.y; + Assert.That(CycloneGeometry.Overlaps(center, radii, point => + Defense25DActor.Ground(enemy.Actor.BodyCollider.ClosestPoint( + Defense25DActor.OnGround(point, colliderPlaneY)))), + Is.True, "The body collider must overlap the authored ellipse."); + float health = enemy.Actor.CurrentHealth; + + Assert.That(loadout.TryUse(false), Is.True); + executor.Step(cyclone.NormalDuration + .05f); + + Assert.That(enemy.Actor.CurrentHealth, Is.LessThan(health)); + yield return null; + } + + [UnityTest] + public IEnumerator RelicCallbackMayQueryAgainWhileCycloneEnumeratesHits() + { + world.SetPaused(false); + ActiveArtifactDefinition cyclone = loadout.FindArtifact(ActiveArtifactEffect.Cyclone); + Assert.That(loadout.TryAddArtifact(cyclone), Is.True); + Defense25DEnemy first = world.Enemies[0]; + Defense25DEnemy second = world.Enemies[1]; + Vector2 center = CycloneGeometry.CenterOffset(cyclone.NormalRange, false, settings.PlayerVisualScale); + first.Spawn(center + Vector2.left * .1f); + second.Spawn(center + Vector2.right * .1f); + Physics.SyncTransforms(); + float firstHealth = first.Actor.CurrentHealth; + float secondHealth = second.Actor.CurrentHealth; + executor.NotifyRelicHit = _ => world.QueryEnemies(new Vector2(20f, 20f), Vector2.one); + + Assert.That(loadout.TryUse(false), Is.True); + executor.Step(cyclone.NormalDuration + .05f); + + Assert.That(first.Actor.CurrentHealth, Is.LessThan(firstHealth)); + Assert.That(second.Actor.CurrentHealth, Is.LessThan(secondHealth)); + yield return null; + } + + [UnityTest] + public IEnumerator PhoenixRayUsesGroundAnchorHalfWidthWithoutActorRadiusPadding() + { + ActiveArtifactDefinition phoenix = loadout.FindArtifact(ActiveArtifactEffect.Phoenix); + Assert.That(loadout.TryAddArtifact(phoenix), Is.True); + Defense25DEnemy enemy = world.Enemies[0]; + float across = phoenix.NormalWidth * .5f + enemy.Actor.Radius * .5f; + enemy.Spawn(new Vector2(.5f, across)); + Physics.SyncTransforms(); + float health = enemy.Actor.CurrentHealth; + + Assert.That(loadout.TryUse(false), Is.True); + + Assert.That(enemy.Actor.CurrentHealth, Is.EqualTo(health)); + yield return null; + } + + [UnityTest] + public IEnumerator CycloneIsRegisteredTicksAndReleasesItsExecutionLock() + { + ActiveArtifactDefinition cyclone = loadout.FindArtifact(ActiveArtifactEffect.Cyclone); + Assert.That(loadout.TryAddArtifact(cyclone), Is.True); + Defense25DEnemy enemy = world.Enemies[0]; + Vector2 hitCenter = CycloneGeometry.CenterOffset(cyclone.NormalRange, false, settings.PlayerVisualScale); + enemy.Spawn(hitCenter); + Physics.SyncTransforms(); + float health = enemy.Actor.CurrentHealth; + + Assert.That(loadout.TryUse(false), Is.True); + Assert.That(executor.IsExecutingArtifact, Is.True); + executor.Step(cyclone.NormalDuration + .05f); + + Assert.That(enemy.Actor.CurrentHealth, Is.LessThan(health)); + Assert.That(executor.IsExecutingArtifact, Is.False); + yield return null; + } + + [UnityTest] + public IEnumerator ThunderCrashIsRegisteredDamagesAndReleasesItsExecutionLock() + { + ActiveArtifactDefinition thunder = loadout.FindArtifact(ActiveArtifactEffect.ThunderCrash); + Assert.That(loadout.TryAddArtifact(thunder), Is.True); + Defense25DEnemy enemy = world.Enemies[0]; + enemy.Spawn(Vector2.right * .48f); + Physics.SyncTransforms(); + float health = enemy.Actor.CurrentHealth; + + Assert.That(loadout.TryUse(false), Is.True); + Assert.That(executor.IsExecutingArtifact, Is.True); + executor.Step(thunder.NormalDuration + .05f); + + Assert.That(enemy.Actor.CurrentHealth, Is.LessThan(health)); + Assert.That(executor.IsExecutingArtifact, Is.False); + yield return null; + } + } +} diff --git a/Assets/_Project/Tests/PlayMode/Defense25DArtifactExecutorPlayModeTests.cs.meta b/Assets/_Project/Tests/PlayMode/Defense25DArtifactExecutorPlayModeTests.cs.meta new file mode 100644 index 0000000..73effec --- /dev/null +++ b/Assets/_Project/Tests/PlayMode/Defense25DArtifactExecutorPlayModeTests.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 62ae67a34a5f4b1893f15bb4a948fe33 diff --git a/Assets/_Project/Tests/PlayMode/Defense25DBodyColliderPlayModeTests.cs b/Assets/_Project/Tests/PlayMode/Defense25DBodyColliderPlayModeTests.cs new file mode 100644 index 0000000..6a9c12c --- /dev/null +++ b/Assets/_Project/Tests/PlayMode/Defense25DBodyColliderPlayModeTests.cs @@ -0,0 +1,168 @@ +using System.IO; +using BumpCombat.Defense; +using BumpCombat.Defense.Comparison; +using NUnit.Framework; +using UnityEditor.SceneManagement; +using UnityEngine; +using UnityEngine.SceneManagement; +using UnityEngine.TestTools; +using Object = UnityEngine.Object; + +namespace BumpCombat.Tests +{ + public sealed class Defense25DBodyColliderPlayModeTests + { + private const string ScenePath = "Assets/_Project/Scenes/Defense25DComparison.unity"; + private const float Ppu = 32f; + private Scene ownedTestScene; + + [UnityTest] + public System.Collections.IEnumerator BodyCoreCapsulesMatchFixedFamilyScreenBoundsAndCenters() + { + Time.timeScale = 1f; + yield return EditorSceneManager.LoadSceneAsyncInPlayMode(ScenePath, + new LoadSceneParameters(LoadSceneMode.Single)); + ownedTestScene = SceneManager.GetActiveScene(); + Defense25DComparison world = Object.FindAnyObjectByType(); + Assert.That(world, Is.Not.Null); + yield return null; + world.AutoSpawn = false; + world.ReadInput = false; + world.ShowBoundaries = true; + world.SetPaused(true); + + Vector2 origin = new(0f, -2f); + world.Player.ResetAt(origin + Vector2.left * 4f); + world.Enemies[0].Spawn(origin + Vector2.left * 2f); + Defense25DActor slime = Summon(world, "Body Slime", origin, 1, DefenseTowerBranch.First); + Defense25DActor giant = Summon(world, "Body Giant", origin + Vector2.right * 2f, 4, DefenseTowerBranch.First); + Defense25DActor lava = Summon(world, "Body Lava", origin + Vector2.right * 4f, 4, DefenseTowerBranch.Second); + lava.Facing = Vector2.left; + Physics.SyncTransforms(); + yield return null; + yield return null; + + Defense25DActor[] actors = { world.Player, world.Enemies[0].Actor, slime, giant, lava }; + string output = System.Environment.GetEnvironmentVariable("DEFENSE25D_OUTPUT"); + if (string.IsNullOrWhiteSpace(output)) output = Path.Combine(Path.GetTempPath(), "Defense25DValidation", "captures"); + Directory.CreateDirectory(output); + string path = Path.Combine(output, "body-collider-overlay.png"); + world.View.Camera.Render(); + RenderTexture.active = world.View.Texture; + Texture2D image = new(world.View.Texture.width, world.View.Texture.height, TextureFormat.RGBA32, false); + image.ReadPixels(new Rect(0, 0, image.width, image.height), 0, 0); + image.Apply(); + File.WriteAllBytes(path, image.EncodeToPNG()); + Object.DestroyImmediate(image); + RenderTexture.active = null; + + // Player/Skeleton retain their existing 2D-collider-derived baseline. + AssertShape(world.Player, .275f, .85f); + AssertShape(world.Enemies[0].Actor, .275f, .85f); + // Fixed inscribed body cores from the shadowless Idle audit, 32 PPU, actual variant render scale. + AssertShape(slime, .1890625f, .378125f); // 11px inner body core * 1.1 / 32 + AssertShape(giant, .5328125f, 1.065625f); // 22px inner body core * 1.55 / 32 + AssertShape(lava, .23359375f, .4671875f); // 13px inner body core * 1.15 / 32 + AssertSkeletonCenter(world, world.Enemies[0].Actor); + AssertBodyCenter(world, slime, -1.5f, -1.5f); + AssertBodyCenter(world, giant, -3f, 17f); + AssertBodyCenter(world, lava, -2f, -.5f); + AssertCapsuleScreenCore(world, slime, 11f); + AssertCapsuleScreenCore(world, giant, 22f); + AssertCapsuleScreenCore(world, lava, 13f); + + Defense25DActor probe = Summon(world, "Body Slime contact probe", + origin + Vector2.right * .37f, 1, DefenseTowerBranch.First); + Physics.SyncTransforms(); + Assert.That(slime.GapTo(probe), Is.EqualTo(-.008125f).Within(.002f)); + Assert.That(Physics.ComputePenetration(slime.BodyCollider, slime.transform.position, + slime.transform.rotation, probe.BodyCollider, probe.transform.position, + probe.transform.rotation, out _, out _), Is.True, + "The actual capsule bodies overlap at the tested body-core contact distance."); + probe.ResetAt(origin + Vector2.right * .4f); + Physics.SyncTransforms(); + Assert.That(slime.GapTo(probe), Is.EqualTo(.021875f).Within(.002f)); + Assert.That(Physics.ComputePenetration(slime.BodyCollider, slime.transform.position, + slime.transform.rotation, probe.BodyCollider, probe.transform.position, + probe.transform.rotation, out _, out _), Is.False, + "The actual capsule bodies miss beyond the tested body-core boundary."); + Assert.That(File.Exists(path), Is.True); + yield return null; + } + + [UnityTearDown] + public System.Collections.IEnumerator UnloadOwnedTestScene() + { + Time.timeScale = 1f; + if (ownedTestScene.IsValid() && ownedTestScene.isLoaded) + { + Scene cleanup = SceneManager.CreateScene("BodyColliderTestCleanup"); + SceneManager.SetActiveScene(cleanup); + AsyncOperation unload = SceneManager.UnloadSceneAsync(ownedTestScene); + if (unload != null) yield return unload; + } + ownedTestScene = default; + } + + private static void AssertShape(Defense25DActor actor, float radius, float height) + { + Assert.That(actor.Radius, Is.EqualTo(radius).Within(.0001f), actor.name + " combat radius"); + Assert.That(actor.BodyCollider.radius, Is.EqualTo(radius).Within(.0001f), actor.name + " capsule radius"); + Assert.That(actor.BodyCollider.height, Is.EqualTo(height).Within(.0001f), actor.name + " capsule height"); + } + + private static void AssertBodyCenter(Defense25DComparison world, Defense25DActor actor, + float centerXFromPivotPixels, float centerUpFromPivotPixels) + { + float scale = actor.Visual.VisualScale; + Vector3 anchor = world.View.Camera.WorldToScreenPoint(actor.Visual.FootPosition); + Vector3 center = world.View.Camera.WorldToScreenPoint( + actor.BodyCollider.transform.TransformPoint(actor.BodyCollider.center)); + float pixelsPerUnit = world.View.Camera.pixelHeight / (2f * world.View.Camera.orthographicSize); + float footCorrectionPixels = -actor.Visual.FootVisualOffset * Ppu / scale; + float facingSign = actor.Facing.x < 0f ? -1f : 1f; + float expectedX = centerXFromPivotPixels * facingSign * scale / Ppu * pixelsPerUnit; + float expectedY = (centerUpFromPivotPixels + footCorrectionPixels) * scale / Ppu * pixelsPerUnit; + Assert.That(center.x - anchor.x, Is.EqualTo(expectedX).Within(1.5f), actor.name + " projected body center X"); + Assert.That(center.y - anchor.y, Is.EqualTo(expectedY).Within(1.5f), actor.name + " projected body center Y"); + } + + private static void AssertSkeletonCenter(Defense25DComparison world, Defense25DActor skeleton) + { + Camera camera = world.View.Camera; + Vector3 anchor = camera.WorldToScreenPoint(skeleton.Visual.FootPosition); + Vector3 center = camera.WorldToScreenPoint( + skeleton.BodyCollider.transform.TransformPoint(skeleton.BodyCollider.center)); + float pixelsPerUnit = camera.pixelHeight / (2f * camera.orthographicSize); + float currentCenterHeight = Vector3.Dot(camera.transform.up, + Vector3.up * (skeleton.BodyCollider.height * .5f)) * pixelsPerUnit; + Assert.That(center.y - anchor.y, Is.EqualTo(currentCenterHeight + 3f).Within(1f), + "Skeleton keeps its 2D radius and raises the fixed 2.5D capsule center by the reviewed three internal pixels."); + } + + private static void AssertCapsuleScreenCore(Defense25DComparison world, + Defense25DActor actor, float auditedCoreDiameterPixels) + { + float pixelsPerUnit = world.View.Camera.pixelHeight / (2f * world.View.Camera.orthographicSize); + float actualDiameterPixels = actor.BodyCollider.radius * 2f * pixelsPerUnit; + float expectedDiameterPixels = auditedCoreDiameterPixels * actor.Visual.VisualScale + / Ppu * pixelsPerUnit; + Assert.That(actualDiameterPixels, Is.EqualTo(expectedDiameterPixels).Within(.5f), + actor.name + " projected capsule diameter must fit inside the literal audited body core height."); + } + + private static Defense25DActor Summon(Defense25DComparison world, string name, + Vector2 position, int tier, DefenseTowerBranch branch) + { + GameObject root = new(name); + root.transform.SetParent(world.transform, false); + root.transform.position = Defense25DActor.OnGround(position, world.Settings.GroundY); + Defense25DActor actor = root.AddComponent(); + actor.Configure(world, ComparisonActorKind.Summon); + actor.Visual.ConfigureSummonArt(tier, branch); + actor.ResetAt(position); + return actor; + } + } +} + diff --git a/Assets/_Project/Tests/PlayMode/Defense25DBodyColliderPlayModeTests.cs.meta b/Assets/_Project/Tests/PlayMode/Defense25DBodyColliderPlayModeTests.cs.meta new file mode 100644 index 0000000..e9a286a --- /dev/null +++ b/Assets/_Project/Tests/PlayMode/Defense25DBodyColliderPlayModeTests.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 70ceeaf7eb944594994b668154b4459f diff --git a/Assets/_Project/Tests/PlayMode/Defense25DCombatFeedbackPlayModeTests.cs b/Assets/_Project/Tests/PlayMode/Defense25DCombatFeedbackPlayModeTests.cs new file mode 100644 index 0000000..52e4888 --- /dev/null +++ b/Assets/_Project/Tests/PlayMode/Defense25DCombatFeedbackPlayModeTests.cs @@ -0,0 +1,328 @@ +using System.Collections; +using System.Linq; +using BumpCombat.Combat; +using BumpCombat.Constants; +using BumpCombat.Defense; +using BumpCombat.Defense.Comparison; +using BumpCombat.Player; +using NUnit.Framework; +using UnityEditor; +using UnityEngine; +using UnityEngine.TestTools; +using Object = UnityEngine.Object; + +namespace BumpCombat.Tests +{ + public sealed class Defense25DCombatFeedbackPlayModeTests + { + private const string SettingsPath = "Assets/_Project/Constants/Defense25DComparison/ComparisonSettings.asset"; + private Defense25DComparison world; + private Defense25DComparisonSettings settings; + private Defense25DView view; + private float previousTimeScale; + private float previousBlinkInterval; + + [UnitySetUp] + public IEnumerator SetUp() + { + previousTimeScale = Time.timeScale; + previousBlinkInterval = GameplayConstants.Current.CombatFeedback.InvulnerabilityBlinkInterval; + Time.timeScale = 1f; + settings = Object.Instantiate( + AssetDatabase.LoadAssetAtPath(SettingsPath)); + Assert.That(settings, Is.Not.Null, "Run Defense25DComparisonSetup.Generate first."); + settings.Enemy = AssetDatabase.LoadAssetAtPath( + "Assets/_Project/Constants/Enemies/Slime.asset"); + settings.EnemyArt = ReadCharacter("Slime", "Slime", "Attack"); + + GameObject viewObject = new("Feedback render camera"); + Camera camera = viewObject.AddComponent(); + camera.orthographic = true; + camera.orthographicSize = 6.5f; + camera.clearFlags = CameraClearFlags.SolidColor; + camera.backgroundColor = new Color(.07f, .09f, .11f, 1f); + view = viewObject.AddComponent(); + + world = new GameObject("Combat feedback world under test").AddComponent(); + world.Initialize(settings, view); + camera.transform.position = new Vector3(0f, 12f, -12f); + camera.transform.LookAt(new Vector3(0f, settings.GroundY, 0f)); + world.AutoSpawn = false; + world.ReadInput = false; + world.Player.ResetAt(Vector2.zero); + yield return new WaitForFixedUpdate(); + } + + [UnityTearDown] + public IEnumerator TearDown() + { + if (world != null) Object.Destroy(world.gameObject); + if (view != null) Object.Destroy(view.gameObject); + if (settings != null) Object.Destroy(settings); + GameplayConstants.Current.CombatFeedback.InvulnerabilityBlinkInterval = previousBlinkInterval; + Time.timeScale = previousTimeScale; + yield return null; + } + + [UnityTest] + public IEnumerator HiddenArtifactContactCreatesOnlyTheAuthoredContactImpact() + { + Time.timeScale = .37f; + Defense25DCombatFeedback feedback = world.CombatFeedback; + Defense25DEnemy target = world.Enemies[0]; + CombatHitResult contact = CombatHitResult.ForArtifactContact( + world.Player.gameObject, target.gameObject, ActiveArtifactEffect.Pulse, false, + HitSide.Front, Vector2.right, target.Actor.Position, DamageTag.Collision, + "feedback-contact-test"); + + feedback.NotifyValidHit(contact); + + Assert.That(feedback.ActiveArtifactImpactCount, Is.EqualTo(1)); + Assert.That(Time.timeScale, Is.EqualTo(.37f), + "A hidden/zero-damage contact gets its source sprite but must not request hit-stop."); + SpriteRenderer impact = Object.FindObjectsByType(FindObjectsSortMode.None) + .FirstOrDefault(renderer => renderer.gameObject.name.StartsWith("Artifact Impact ")); + Assert.That(impact, Is.Not.Null); + Sprite[] authoredFrames = Resources.LoadAll( + CombatFeedback.GetArtifactImpactResourcePath(ActiveArtifactEffect.Pulse, false)); + System.Array.Sort(authoredFrames, (a, b) => string.CompareOrdinal(a.name, b.name)); + Assert.That(authoredFrames, Has.Length.EqualTo(CombatFeedback.ArtifactImpactFrameCount)); + Assert.That(impact.sprite, Is.SameAs(authoredFrames[0])); + Assert.That(impact.sharedMaterial, Is.SameAs(feedback.TransparentEffectMaterial)); + yield return null; + } + + [UnityTest] + public IEnumerator PlayerAlpha035UsesTransparentMaterialAndRendersIt() + { + Defense25DCombatFeedback feedback = world.CombatFeedback; + CombatFeedbackSettings tuning = GameplayConstants.Current.CombatFeedback; + float blinkInterval = tuning.InvulnerabilityBlinkInterval; + SpriteRenderer player = world.Player.Visual.Renderer; + Assert.That(feedback.TransparentEffectMaterial, Is.Not.Null); + Assert.That(feedback.TransparentEffectMaterial.GetFloat("_Cutoff"), Is.EqualTo(.001f).Within(.0001f)); + Assert.That(feedback.TransparentEffectMaterial.GetColor("_BaseColor"), Is.EqualTo(Color.white)); + + feedback.NotifyPlayerHurt(world.Player.transform.position, Vector2.right, + GameplayConstants.Current.CombatFeedback.HeavyKnockbackThreshold); + // With the authored blink interval temporarily disabled, the same hurt/flash scene + // is the opaque capture baseline. Re-enabling it produces the source alpha-.35 phase. + tuning.InvulnerabilityBlinkInterval = 0f; + feedback.TickFeedback(0f, 0f); + yield return null; + Color[] opaqueCapture = CaptureView(); + + tuning.InvulnerabilityBlinkInterval = blinkInterval; + feedback.TickFeedback(0f, 0f); + yield return null; + Assert.That(player.color.a, Is.EqualTo(.35f).Within(.001f)); + Assert.That(player.sharedMaterial, Is.SameAs(feedback.TransparentEffectMaterial), + "The alpha test must select the unclipped, cloned transparent effect material."); + Color[] fadedCapture = CaptureView(); + + int changedPixels = 0; + for (int i = 0; i < opaqueCapture.Length; i++) + { + Color a = opaqueCapture[i]; + Color b = fadedCapture[i]; + if (Mathf.Abs(a.r - b.r) + Mathf.Abs(a.g - b.g) + Mathf.Abs(a.b - b.b) > .08f) + changedPixels++; + } + Assert.That(changedPixels, Is.GreaterThan(0), + "A camera render capture must show the player changing at alpha .35; cutout clipping is not acceptable."); + tuning.InvulnerabilityBlinkInterval = blinkInterval; + feedback.ResetFeedback(); + yield return null; + } + + [UnityTest] + public IEnumerator HurtUsesSourceKnockbackDirectionForImpactOriginAndOwnsHitStop() + { + Defense25DCombatFeedback feedback = world.CombatFeedback; + Vector2 playerPosition = world.Player.Position; + Vector2 knockbackDirection = new(.6f, .8f); + Vector3 worldPosition = world.Player.transform.position; + float heavyThreshold = GameplayConstants.Current.CombatFeedback.HeavyKnockbackThreshold; + + feedback.NotifyPlayerHurt(worldPosition, knockbackDirection, heavyThreshold); + + SpriteRenderer impact = Object.FindObjectsByType(FindObjectsSortMode.None) + .FirstOrDefault(renderer => renderer.gameObject.name == "Player Hurt Impact"); + Assert.That(impact, Is.Not.Null); + Vector2 expectedOrigin = playerPosition - knockbackDirection.normalized * .18f; + Assert.That(impact.transform.position.x, Is.EqualTo(expectedOrigin.x).Within(.001f)); + Assert.That(impact.transform.position.z, Is.EqualTo(expectedOrigin.y).Within(.001f)); + Assert.That(feedback.ActivePlayerHurtImpactCount, Is.EqualTo(1)); + Assert.That(Time.timeScale, Is.Zero); + + feedback.ResetFeedback(); + Assert.That(Time.timeScale, Is.EqualTo(1f), "Reset releases only the hit-stop owned by this feedback component."); + yield return null; + } + + [UnityTest] + public IEnumerator ResetDoesNotChangeAnExternalPauseAndClearsOwnedVisuals() + { + Defense25DCombatFeedback feedback = world.CombatFeedback; + Time.timeScale = 0f; + feedback.NotifyPlayerHurt(world.Player.transform.position, Vector2.right, 0f); + feedback.ResetFeedback(); + + Assert.That(Time.timeScale, Is.Zero, + "Reset must not claim or restore a time scale that was already externally paused."); + Assert.That(feedback.ActiveEffectCount, Is.Zero); + Assert.That(feedback.TransparentEffectMaterial, Is.Not.Null, + "Reset keeps the bound world's cloned material for the next event."); + yield return null; + } + + [UnityTest] + public IEnumerator ChargeReadyFlashSwitchAndReleaseUseTheirAuthoredSheets() + { + Defense25DCombatFeedback feedback = world.CombatFeedback; + ActiveArtifactDefinition artifact = world.Loadout.FindArtifact(ActiveArtifactEffect.Pulse); + Assert.That(artifact, Is.Not.Null); + Assert.That(world.Loadout.TryAddArtifact(artifact), Is.True); + + feedback.NotifyChargeChanged(artifact, .5f, false); + Assert.That(feedback.IsChargingVisualVisible, Is.True); + feedback.NotifyChargeChanged(artifact, 1f, true); + Assert.That(feedback.IsReadyFlashVisible, Is.True, + "The four-frame Flash sheet is validated against FlashFrameCount, not the six-frame aura count."); + Sprite[] flashFrames = Resources.LoadAll( + Defense25DCombatFeedbackConstants.ChargeResourceRoot + artifact.Effect + + Defense25DCombatFeedbackConstants.ChargeFlashSuffix); + Assert.That(flashFrames, Has.Length.EqualTo(ArtifactChargeVisual.FlashFrameCount)); + + feedback.NotifyArtifactSwitched(artifact); + SpriteRenderer switchPulse = Object.FindObjectsByType(FindObjectsSortMode.None) + .FirstOrDefault(renderer => renderer.gameObject.name.Contains("Artifact Charge ") + && renderer.gameObject.name.Contains(Defense25DCombatFeedbackConstants.ChargeReleaseSuffix)); + Assert.That(switchPulse, Is.Not.Null); + Assert.That(switchPulse.sharedMaterial, Is.SameAs(feedback.TransparentEffectMaterial)); + + feedback.NotifyArtifactReleased(artifact, world.Player.Position, true); + SpriteRenderer release = Object.FindObjectsByType(FindObjectsSortMode.None) + .FirstOrDefault(renderer => renderer.gameObject.name == "Artifact Release"); + Assert.That(release, Is.Not.Null); + Assert.That(feedback.ActiveEffectCount, Is.GreaterThan(0)); + feedback.ResetFeedback(); + Assert.That(feedback.ActiveEffectCount, Is.Zero); + yield return null; + } + + [UnityTest] + public IEnumerator GuardBlockedBumpAndTowerFeedbackUseOriginalSprites() + { + Defense25DCombatFeedback feedback = world.CombatFeedback; + Defense25DEnemy target = world.Enemies[0]; + feedback.NotifyGuardImpact(Vector2.left); + feedback.NotifyBlockedBump(target.Actor.Position, Vector2.right); + feedback.NotifyTowerImpact(DefenseTowerKind.Direct, target, target.Actor.Position); + + SpriteRenderer guard = Object.FindObjectsByType(FindObjectsSortMode.None) + .FirstOrDefault(renderer => renderer.gameObject.name == "Player Guard Block Impact"); + SpriteRenderer blocked = Object.FindObjectsByType(FindObjectsSortMode.None) + .FirstOrDefault(renderer => renderer.gameObject.name == "Blocked Bump"); + SpriteRenderer tower = Object.FindObjectsByType(FindObjectsSortMode.None) + .FirstOrDefault(renderer => renderer.gameObject.name == "Tower Impact Direct"); + Assert.That(guard, Is.Not.Null); + Assert.That(blocked, Is.Not.Null); + Assert.That(blocked.flipX, Is.False); + Assert.That(tower, Is.Not.Null); + Assert.That(guard.sharedMaterial, Is.SameAs(feedback.TransparentEffectMaterial)); + Assert.That(blocked.sharedMaterial, Is.SameAs(feedback.TransparentEffectMaterial)); + Assert.That(tower.sharedMaterial, Is.SameAs(feedback.TransparentEffectMaterial)); + Sprite[] towerFrames = Resources.LoadAll(CombatFeedback.GetTowerImpactResourcePath(DefenseTowerKind.Direct)); + Assert.That(towerFrames, Has.Length.EqualTo(CombatFeedback.BumpImpactFrameCount)); + Assert.That(tower.sprite, Is.SameAs(towerFrames[0])); + feedback.ResetFeedback(); + Assert.That(feedback.ActiveEffectCount, Is.Zero); + yield return null; + } + + [UnityTest] + public IEnumerator ShieldPulseAndEnemyDustEffectsRemainWithinHeavyEffectCap() + { + Defense25DCombatFeedback feedback = world.CombatFeedback; + Defense25DEnemy target = world.Enemies[0]; + Vector2 start = target.Actor.Position; + Vector2 finish = start + Vector2.right * .65f; + feedback.NotifyShield(.2f); + feedback.NotifyPulseGather(target, start, finish); + feedback.NotifyKnockbackCompleted(target.Actor, start, finish); + for (int i = 0; i < CombatFeedback.MaxConcurrentHeavyEffects + 4; i++) + feedback.NotifyShield(.2f); + + Assert.That(feedback.ActiveHeavyEffectCount, Is.LessThanOrEqualTo(CombatFeedback.MaxConcurrentHeavyEffects)); + Assert.That(Object.FindObjectsByType(FindObjectsSortMode.None) + .Any(line => line.gameObject.name == "Interaction Ring"), Is.True); + Assert.That(Object.FindObjectsByType(FindObjectsSortMode.None) + .Any(renderer => renderer.gameObject.name == "Pulse Gather Afterimage"), Is.True); + feedback.ResetFeedback(); + Assert.That(feedback.ActiveHeavyEffectCount, Is.Zero); + yield return null; + } + + [UnityTest] + public IEnumerator ShieldRingStartsAndFollowsThePlayerVisualGroundAnchor() + { + Defense25DCombatFeedback feedback = world.CombatFeedback; + world.SetPaused(false); + Vector3 foot = world.Player.Visual.FootPosition; + Vector2 initialAnchor = new(foot.x, foot.z); + Assert.That(Vector2.Distance(world.Player.Position, initialAnchor), Is.GreaterThan(.01f), + "Fixture must retain the player's authored foot-to-capsule offset so the old body anchor would fail."); + + feedback.NotifyShield(.5f); + LineRenderer ring = Object.FindObjectsByType(FindObjectsSortMode.None) + .FirstOrDefault(line => line.gameObject.name == "Interaction Ring"); + Assert.That(ring, Is.Not.Null); + Assert.That(ring.transform.position.x, Is.EqualTo(initialAnchor.x).Within(.001f)); + Assert.That(ring.transform.position.z, Is.EqualTo(initialAnchor.y).Within(.001f)); + + Vector2 movedPosition = world.Player.Position + new Vector2(1.1f, -.65f); + world.Player.ResetAt(movedPosition); + Physics.SyncTransforms(); + yield return null; + yield return null; + Vector3 movedFoot = world.Player.Visual.FootPosition; + feedback.TickFeedback(.016f, .016f); + + Assert.That(ring.transform.position.x, Is.EqualTo(movedFoot.x).Within(.001f)); + Assert.That(ring.transform.position.z, Is.EqualTo(movedFoot.z).Within(.001f)); + Vector2 ringGround = Defense25DActor.Ground(ring.transform.position); + Assert.That(Vector2.Distance(ringGround, world.Player.Position), Is.GreaterThan(.01f)); + yield return null; + } + + private Color[] CaptureView() + { + RenderTexture previous = RenderTexture.active; + Texture2D capture = new(Defense25DView.Width, Defense25DView.Height, TextureFormat.RGBA32, false); + view.Camera.Render(); + RenderTexture.active = view.Texture; + capture.ReadPixels(new Rect(0f, 0f, Defense25DView.Width, Defense25DView.Height), 0, 0); + capture.Apply(); + Color[] pixels = capture.GetPixels(); + RenderTexture.active = previous; + Object.Destroy(capture); + return pixels; + } + + private static ComparisonSpriteFrames ReadCharacter(string folder, string prefix, string attack) + { + Sprite[] Read(string suffix) => AssetDatabase.LoadAllAssetsAtPath( + $"Assets/_Project/Art/Characters/{folder}/{prefix}_{suffix}.png") + .OfType() + .OrderBy(sprite => int.TryParse(sprite.name[(sprite.name.LastIndexOf('_') + 1)..], out int frame) + ? frame : 0) + .ToArray(); + return new ComparisonSpriteFrames + { + Idle = Read("Idle"), Walk = Read("Walk"), Attack = Read(attack), + Hurt = Read("Hurt"), Death = Read("Death"), + }; + } + } +} diff --git a/Assets/_Project/Tests/PlayMode/Defense25DCombatFeedbackPlayModeTests.cs.meta b/Assets/_Project/Tests/PlayMode/Defense25DCombatFeedbackPlayModeTests.cs.meta new file mode 100644 index 0000000..f3ba71a --- /dev/null +++ b/Assets/_Project/Tests/PlayMode/Defense25DCombatFeedbackPlayModeTests.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 57c219ff31f44c4b991a2ffb62de14fd diff --git a/Assets/_Project/Tests/PlayMode/Defense25DCombatPopupPlayModeTests.cs b/Assets/_Project/Tests/PlayMode/Defense25DCombatPopupPlayModeTests.cs new file mode 100644 index 0000000..86fb112 --- /dev/null +++ b/Assets/_Project/Tests/PlayMode/Defense25DCombatPopupPlayModeTests.cs @@ -0,0 +1,205 @@ +using System; +using System.Collections; +using System.Linq; +using System.Reflection; +using BumpCombat.Combat; +using BumpCombat.Constants; +using BumpCombat.Defense.Comparison; +using BumpCombat.Player; +using NUnit.Framework; +using UnityEditor; +using UnityEngine; +using UnityEngine.TestTools; +using Object = UnityEngine.Object; + +namespace BumpCombat.Tests +{ + public sealed class Defense25DCombatPopupPlayModeTests + { + private const string SettingsPath = + "Assets/_Project/Constants/Defense25DComparison/ComparisonSettings.asset"; + private Defense25DComparison world; + private Defense25DComparisonSettings settings; + private float previousTimeScale; + + [UnitySetUp] + public IEnumerator SetUp() + { + previousTimeScale = Time.timeScale; + Time.timeScale = 1f; + settings = Object.Instantiate( + AssetDatabase.LoadAssetAtPath(SettingsPath)); + Assert.That(settings, Is.Not.Null); + settings.Enemy = AssetDatabase.LoadAssetAtPath( + "Assets/_Project/Constants/Enemies/Slime.asset"); + settings.EnemyArt = ReadCharacter(); + world = new GameObject("2.5D combat popup test world") + .AddComponent(); + world.Initialize(settings); + world.AutoSpawn = false; + world.ReadInput = false; + yield return null; + } + + [UnityTearDown] + public IEnumerator TearDown() + { + if (world != null) Object.Destroy(world.gameObject); + if (settings != null) Object.Destroy(settings); + Time.timeScale = previousTimeScale; + yield return null; + } + + [UnityTest] + public IEnumerator ValidHitPopups_MatchNormalSideBackAndArtifactContracts() + { + GameObject target = world.Enemies[0].gameObject; + RaiseHit(target, HitSide.Front, 31.9f); + AssertPopup(0, "31", Color.white); + + world.ResetRun(); + target = world.Enemies[0].gameObject; + RaiseHit(target, HitSide.Side, 22.8f); + AssertPopup(0, "SIDE ×1.2 22", new Color(0.65f, 1f, 0.3f)); + + world.ResetRun(); + target = world.Enemies[0].gameObject; + RaiseHit(target, HitSide.Back, 9.8f); + AssertPopup(0, "BACK ×1.5 9", new Color(1f, 0.75f, 0.2f)); + + world.ResetRun(); + target = world.Enemies[0].gameObject; + CombatEvents.RaiseValidHit(new CombatHitResult( + world.Player.gameObject, target, true, HitSide.Front, 13f, + Vector2.right, 0f, Vector2.zero, launchSucceeded: true)); + AssertPopup(0, "LAUNCH 13", new Color(0.95f, 0.55f, 1f)); + + world.ResetRun(); + target = world.Enemies[0].gameObject; + ActiveArtifactDefinition dash = world.Loadout.FindArtifact(ActiveArtifactEffect.Dash); + Assert.That(dash, Is.Not.Null); + CombatEvents.RaiseValidHit(new CombatHitResult( + world.Player.gameObject, target, true, HitSide.Front, 15f, Vector2.right, + 0f, Vector2.zero, sourceId: dash.ArtifactId, isArtifactHit: true, + artifactEffect: ActiveArtifactEffect.Dash)); + AssertPopup(0, $"{dash.DisplayName} 15", + ActiveArtifactDefinition.GetArtifactPaletteColor(dash.ArtifactColor)); + + world.ResetRun(); + target = world.Enemies[0].gameObject; + ActiveArtifactDefinition cyclone = world.Loadout.FindArtifact(ActiveArtifactEffect.Cyclone); + Assert.That(cyclone, Is.Not.Null); + CombatEvents.RaiseValidHit(CombatHitResult.ForArtifactContact( + world.Player.gameObject, target, ActiveArtifactEffect.Cyclone, false, + HitSide.Front, Vector2.right, Vector2.zero, cyclone.DamageTag, cyclone.ArtifactId)); + AssertPopup(0, $"{cyclone.DisplayName} 0", + ActiveArtifactDefinition.GetArtifactPaletteColor(cyclone.ArtifactColor)); + yield return null; + } + + [UnityTest] + public IEnumerator PopupEvents_AreWorldScopedResettableAndRemovedOnDisable() + { + GameObject worldTarget = world.Enemies[0].gameObject; + GameObject unrelatedTarget = new("Unrelated popup target"); + GameObject unrelatedAttacker = new("Unrelated popup attacker"); + try + { + RaiseHit(unrelatedTarget, HitSide.Front, 10f); + Assert.That(world.ActiveCombatDamagePopupCount, Is.Zero); + CombatEvents.RaiseValidHit(new CombatHitResult( + unrelatedAttacker, worldTarget, false, HitSide.Front, 10f, + Vector2.right, 0f, Vector2.zero)); + Assert.That(world.ActiveCombatDamagePopupCount, Is.Zero, + "Hits from another attacker against this world's enemy are ignored."); + + RaiseHit(worldTarget, HitSide.Front, 10f); + Assert.That(world.ActiveCombatDamagePopupCount, Is.EqualTo(1)); + world.ResetRun(); + Assert.That(world.ActiveCombatDamagePopupCount, Is.Zero); + + world.enabled = false; + RaiseHit(worldTarget, HitSide.Front, 10f); + world.NotifyPlayerDamagePopup(10f, Vector3.zero); + world.RecordTowerDamagePopup(10f, Color.cyan, Vector3.one); + world.enabled = true; + Assert.That(world.ActiveCombatDamagePopupCount, Is.Zero, + "A disabled comparison must reject event and direct popup additions."); + RaiseHit(world.Enemies[0].gameObject, HitSide.Front, 10f); + Assert.That(world.ActiveCombatDamagePopupCount, Is.EqualTo(1), + "Re-enabling the comparison must restore one event subscription."); + } + finally + { + Object.Destroy(unrelatedTarget); + Object.Destroy(unrelatedAttacker); + } + yield return null; + } + + [UnityTest] + public IEnumerator PlayerAndTowerDamagePopups_ShareTwelveReusableUnscaledSlots() + { + for (int i = 0; i < 13; i++) + world.NotifyPlayerDamagePopup(12.9f, Vector3.zero); + Assert.That(world.ActiveCombatDamagePopupCount, Is.EqualTo(12)); + AssertPopup(0, "-12", new Color(244f / 255f, 117f / 255f, 112f / 255f)); + + world.RecordTowerDamagePopup(8.9f, Color.cyan, Vector3.one); + Assert.That(world.ActiveTowerDamagePopupCount, Is.Zero, + "An active-set overflow must be rejected for towers as well."); + + Time.timeScale = 0f; + yield return new WaitForSecondsRealtime(0.65f); + Assert.That(world.ActiveCombatDamagePopupCount, Is.Zero, + "Popup expiry uses unscaled time, including while paused."); + + world.NotifyPlayerDamagePopup(14.5f, Vector3.zero); + world.RecordTowerDamagePopup(8.9f, Color.cyan, Vector3.one); + Assert.That(world.ActiveCombatDamagePopupCount, Is.EqualTo(2)); + Assert.That(world.ActiveTowerDamagePopupCount, Is.EqualTo(1)); + AssertPopup(0, "-14", new Color(244f / 255f, 117f / 255f, 112f / 255f)); + AssertPopup(1, "-8", Color.cyan); + world.NotifyPlayerDamagePopup(0f, Vector3.zero); + Assert.That(world.ActiveCombatDamagePopupCount, Is.EqualTo(2), + "The public player damage API ignores zero actual HP loss."); + } + + private void RaiseHit(GameObject target, HitSide side, float damage) + { + CombatEvents.RaiseValidHit(new CombatHitResult( + world.Player.gameObject, target, false, side, damage, Vector2.right, + 0f, Vector2.zero, isOffsetHit: false)); + } + + private void AssertPopup(int index, string text, Color color) + { + FieldInfo popupArrayField = typeof(Defense25DComparison).GetField( + "combatDamagePopups", BindingFlags.Instance | BindingFlags.NonPublic); + Assert.That(popupArrayField, Is.Not.Null); + Array popupArray = (Array)popupArrayField.GetValue(world); + object popup = popupArray.GetValue(index); + Type popupType = popup.GetType(); + Assert.That((string)popupType.GetField("Text").GetValue(popup), Is.EqualTo(text)); + Color actualColor = (Color)popupType.GetField("Color").GetValue(popup); + Assert.That(actualColor.r, Is.EqualTo(color.r).Within(0.002f)); + Assert.That(actualColor.g, Is.EqualTo(color.g).Within(0.002f)); + Assert.That(actualColor.b, Is.EqualTo(color.b).Within(0.002f)); + } + + private static ComparisonSpriteFrames ReadCharacter() + { + Sprite[] Read(string suffix) => AssetDatabase.LoadAllAssetsAtPath( + $"Assets/_Project/Art/Characters/Slime/Slime_{suffix}.png") + .OfType() + .OrderBy(sprite => int.TryParse( + sprite.name[(sprite.name.LastIndexOf('_') + 1)..], out int frame) ? frame : 0) + .ToArray(); + return new ComparisonSpriteFrames + { + Idle = Read("Idle"), Walk = Read("Walk"), Attack = Read("Attack"), + Hurt = Read("Hurt"), Death = Read("Death"), + }; + } + } +} diff --git a/Assets/_Project/Tests/PlayMode/Defense25DCombatPopupPlayModeTests.cs.meta b/Assets/_Project/Tests/PlayMode/Defense25DCombatPopupPlayModeTests.cs.meta new file mode 100644 index 0000000..e6c5771 --- /dev/null +++ b/Assets/_Project/Tests/PlayMode/Defense25DCombatPopupPlayModeTests.cs.meta @@ -0,0 +1,13 @@ +fileFormatVersion: 2 +guid: d51c6f9d985c46bea7689db196a237cb +labels: +- Test +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/_Project/Tests/PlayMode/Defense25DComparisonHudLayoutPlayModeTests.cs b/Assets/_Project/Tests/PlayMode/Defense25DComparisonHudLayoutPlayModeTests.cs new file mode 100644 index 0000000..432aea8 --- /dev/null +++ b/Assets/_Project/Tests/PlayMode/Defense25DComparisonHudLayoutPlayModeTests.cs @@ -0,0 +1,99 @@ +using System.Collections; +using System.Linq; +using BumpCombat.Constants; +using BumpCombat.Defense; +using BumpCombat.Defense.Comparison; +using NUnit.Framework; +using UnityEditor; +using UnityEngine; +using UnityEngine.TestTools; +using Object = UnityEngine.Object; + +namespace BumpCombat.Tests +{ + public sealed class Defense25DComparisonHudLayoutPlayModeTests + { + private Defense25DComparison world; + private Defense25DComparisonSettings settings; + private Camera projectionCamera; + private const string SettingsPath = "Assets/_Project/Constants/Defense25DComparison/ComparisonSettings.asset"; + + [UnitySetUp] + public IEnumerator SetUp() + { + settings = Object.Instantiate(AssetDatabase.LoadAssetAtPath(SettingsPath)); + Assert.That(settings, Is.Not.Null); + settings.Enemy = AssetDatabase.LoadAssetAtPath( + "Assets/_Project/Constants/Enemies/Slime.asset"); + world = new GameObject("HUD geometry world").AddComponent(); + world.Initialize(settings); + + GameObject cameraObject = new("HUD pad projection camera"); + projectionCamera = cameraObject.AddComponent(); + projectionCamera.orthographic = true; + projectionCamera.orthographicSize = settings.CameraSize; + projectionCamera.aspect = (float)Defense25DView.Width / Defense25DView.Height; + Rect viewport = Defense25DView.GetViewport(Screen.width, Screen.height); + projectionCamera.rect = new Rect(viewport.x / Screen.width, viewport.y / Screen.height, + viewport.width / Screen.width, viewport.height / Screen.height); + projectionCamera.transform.SetPositionAndRotation( + new Vector3(0f, 13f, -11f), Quaternion.Euler(settings.CameraPitch, 0f, 0f)); + yield return null; + } + + [UnityTearDown] + public IEnumerator TearDown() + { + if (world != null) Object.Destroy(world.gameObject); + if (projectionCamera != null) Object.Destroy(projectionCamera.gameObject); + if (settings != null) Object.Destroy(settings); + yield return null; + } + + [UnityTest] + public IEnumerator PersistentHudControlsLeaveAllSixProjectedPadsClearAndConsumeOnlyTheirDrawnRegions() + { + Rect viewport = Defense25DView.GetViewport(Screen.width, Screen.height); + float scale = viewport.width / Defense25DView.Width; + Rect[] controls = world.GetPersistentDefenseUiHitRects(); + Assert.That(controls, Is.Not.Empty); + + for (int pad = 0; pad < 6; pad++) + { + Vector2 ground = GameplayConstants.Current.Defense.GetBuildPadPosition(pad); + Vector3 screen = projectionCamera.WorldToScreenPoint( + new Vector3(ground.x, settings.GroundY, ground.y)); + Vector2 gui = new((screen.x - viewport.x) / scale, + (Screen.height - screen.y - viewport.y) / scale); + Rect padFootprint = new(gui.x - 14f, gui.y - 14f, 28f, 28f); + + Assert.That(controls.Any(control => control.Overlaps(padFootprint)), Is.False, + $"Persistent HUD control covers projected pad {pad + 1} at {gui}."); + Assert.That(world.IsPointerOverDefenseUI(ScreenPoint(viewport, scale, gui)), Is.False, + $"A click on projected pad {pad + 1} is incorrectly captured by the HUD."); + } + + foreach (Rect control in controls) + { + Vector2 center = control.center; + Assert.That(world.IsPointerOverDefenseUI(ScreenPoint(viewport, scale, center)), Is.True, + $"Drawn HUD control at {center} is not protected from combat input."); + } + + Rect noDetails = world.GetSelectedPadDetailsRect(); + Assert.That(noDetails.width <= 0f || noDetails.height <= 0f, Is.True, + "Detailed construction controls should not occupy arena space before a pad is selected."); + Assert.That(world.SelectBuildPad(0), Is.True); + Rect details = world.GetSelectedPadDetailsRect(); + Assert.That(details.width, Is.GreaterThan(0f)); + Assert.That(details.height, Is.GreaterThan(0f)); + Assert.That(world.IsPointerOverDefenseUI(ScreenPoint(viewport, scale, details.center)), Is.True, + "The contextual detail panel's click area must match its drawn panel."); + yield return null; + } + + private static Vector2 ScreenPoint(Rect viewport, float scale, Vector2 gui) => + new(viewport.x + gui.x * scale, + Screen.height - (viewport.y + gui.y * scale)); + } +} diff --git a/Assets/_Project/Tests/PlayMode/Defense25DComparisonHudLayoutPlayModeTests.cs.meta b/Assets/_Project/Tests/PlayMode/Defense25DComparisonHudLayoutPlayModeTests.cs.meta new file mode 100644 index 0000000..171da7f --- /dev/null +++ b/Assets/_Project/Tests/PlayMode/Defense25DComparisonHudLayoutPlayModeTests.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 90b66cbb8eb94b1091252ef9220d3ab5 diff --git a/Assets/_Project/Tests/PlayMode/Defense25DComparisonPlayModeTests.cs b/Assets/_Project/Tests/PlayMode/Defense25DComparisonPlayModeTests.cs new file mode 100644 index 0000000..e7ef092 --- /dev/null +++ b/Assets/_Project/Tests/PlayMode/Defense25DComparisonPlayModeTests.cs @@ -0,0 +1,1139 @@ +using System; +using System.Collections; +using System.IO; +using System.Linq; +using BumpCombat.Combat; +using BumpCombat.Constants; +using BumpCombat.Core; +using BumpCombat.Defense; +using BumpCombat.Defense.Comparison; +using BumpCombat.Player; +using NUnit.Framework; +using UnityEditor; +using UnityEditor.SceneManagement; +using UnityEngine; +using UnityEngine.InputSystem; +using UnityEngine.InputSystem.LowLevel; +using UnityEngine.Rendering; +using UnityEngine.SceneManagement; +using UnityEngine.TestTools; +using UnityEngine.UI; +using Object = UnityEngine.Object; + +namespace BumpCombat.Tests +{ + public sealed class Defense25DComparisonPlayModeTests + { + private Defense25DComparison world; + private Defense25DComparisonSettings settings; + private float previousTimeScale; + private const string SettingsPath = "Assets/_Project/Constants/Defense25DComparison/ComparisonSettings.asset"; + + [UnitySetUp] + public IEnumerator SetUp() + { + previousTimeScale = Time.timeScale; + Time.timeScale = 5f; + settings = Object.Instantiate(AssetDatabase.LoadAssetAtPath(SettingsPath)); + Assert.That(settings, Is.Not.Null, "Run Defense25DComparisonSetup.Generate first."); + settings.Enemy = AssetDatabase.LoadAssetAtPath( + "Assets/_Project/Constants/Enemies/Slime.asset"); + settings.EnemyArt = ReadCharacter("Slime", "Slime", "Attack"); + settings.EnemyRadius = .24f; + settings.EnemyColliderOffset = new Vector2(0f, -.28f); + settings.EnemyVisualScale = 1f; + settings.EnemyFootOffset = -.4f; + settings.EnemyBodyCenterCorrectionPixels = 0f; + world = new GameObject("Comparison under test").AddComponent(); + world.Initialize(settings); + world.AutoSpawn = false; + world.ReadInput = false; + world.Player.ResetAt(Vector2.zero); + yield return new WaitForFixedUpdate(); + } + + [UnityTearDown] + public IEnumerator TearDown() + { + if (world != null) Object.Destroy(world.gameObject); + if (settings != null) Object.Destroy(settings); + Time.timeScale = previousTimeScale; + yield return null; + } + + [UnityTest] + public IEnumerator ResonantCrystalRepeatExpiresWhileDeadAndDoesNotReturnOnRevive() + { + var relicTuning = GameplayConstants.Current.PassiveRelics; + float previousChance = relicTuning.ResonantCrystalChance; + float previousDelay = relicTuning.ResonantCrystalDelaySeconds; + try + { + relicTuning.ResonantCrystalChance = 1f; + relicTuning.ResonantCrystalDelaySeconds = .2f; + CompleteStartupArtifactSelection(world); + ActiveArtifactDefinition artifact = world.Loadout.FindArtifact(ActiveArtifactEffect.Cyclone); + Assert.That(world.Loadout.TryAddArtifact(artifact), Is.True); + Assert.That(world.Loadout.TryAcquireRelic(BumpCombat.Progression.PassiveRelicId.ResonantCrystal), Is.True); + world.Loadout.AddGauge(artifact.NormalGaugeCost); + Assert.That(world.Loadout.Gauge, Is.GreaterThanOrEqualTo(artifact.NormalGaugeCost)); + world.RelicRuntime.NotifyArtifactUse(new Defense25DArtifactUse(artifact, false), + Vector3.zero, Vector3.right, world.Player.CurrentHealth); + Assert.That(world.RelicRuntime.IsRepeatPending, Is.True); + + Assert.That(world.Player.TakeDamage(world.Player.MaxHealth * 10f, Vector2.left), Is.True); + Assert.That(world.Player.IsAlive, Is.False); + for (int i = 0; i < 30 && world.RelicRuntime.IsRepeatPending; i++) + yield return new WaitForFixedUpdate(); + + Assert.That(world.Player.IsAlive, Is.False, "The repeat deadline must pass before the five-second revive."); + Assert.That(world.RelicRuntime.IsRepeatPending, Is.False, + "A repeat whose delay expires at zero health is discarded, not deferred to revival."); + float reviveDeadline = world.GameTime + GameplayConstants.Current.Defense.PlayerReviveDelaySeconds + 1f; + float realtimeDeadline = Time.realtimeSinceStartup + 10f; + while (!world.Player.IsAlive && world.GameTime < reviveDeadline + && Time.realtimeSinceStartup < realtimeDeadline) + yield return null; + + Assert.That(world.Player.IsAlive, Is.True); + Assert.That(world.RelicRuntime.IsRepeatPending, Is.False); + } + finally + { + relicTuning.ResonantCrystalChance = previousChance; + relicTuning.ResonantCrystalDelaySeconds = previousDelay; + } + } + + [UnityTest] + public IEnumerator ArtifactHitRelicStatusTargetsResolveTheActualDefenseEnemy() + { + var relicTuning = GameplayConstants.Current.PassiveRelics; + float previousChance = relicTuning.EmberWickChance; + try + { + relicTuning.EmberWickChance = 1f; + CompleteStartupArtifactSelection(world); + ActiveArtifactDefinition cyclone = world.Loadout.FindArtifact(ActiveArtifactEffect.Cyclone); + Assert.That(world.Loadout.TryAddArtifact(cyclone), Is.True); + Assert.That(world.Loadout.TryAcquireRelic( + BumpCombat.Progression.PassiveRelicId.EmberWick), Is.True); + world.Loadout.AddGauge(cyclone.NormalGaugeCost); + Assert.That(world.Loadout.Gauge, Is.GreaterThanOrEqualTo(cyclone.NormalGaugeCost)); + Defense25DEnemy enemy = world.Enemies[0]; + Vector2 center = CycloneGeometry.CenterOffset(cyclone.NormalRange, false, + settings.PlayerVisualScale); + enemy.Spawn(center); + Physics.SyncTransforms(); + + Assert.That(world.Loadout.TryUse(false), Is.True); + world.ArtifactExecutor.Step(cyclone.NormalDuration + .05f); + + Assert.That(enemy.IgniteRemaining, Is.GreaterThan(0f), + "An artifact-originated relic hit must resolve the enemy's live status target."); + } + finally + { + relicTuning.EmberWickChance = previousChance; + } + yield return null; + } + + [UnityTest] + public IEnumerator BumpTriggeredResonanceReplayDoesNotInvalidateWorldQueryTargets() + { + var relicTuning = GameplayConstants.Current.PassiveRelics; + float previousChance = relicTuning.ResonanceEchoChance; + float previousCooldown = relicTuning.ResonanceEchoCooldownSeconds; + try + { + relicTuning.ResonanceEchoChance = 1f; + relicTuning.ResonanceEchoCooldownSeconds = 0f; + ActiveArtifactDefinition chain = world.Loadout.FindArtifact(ActiveArtifactEffect.ChainLightning); + Assert.That(world.Loadout.TryAddArtifact(chain), Is.True); + Assert.That(world.Loadout.TryAcquireRelic( + BumpCombat.Progression.PassiveRelicId.ResonanceCore), Is.True); + Defense25DEnemy first = world.Enemies[0]; + Defense25DEnemy second = world.Enemies[1]; + first.Spawn(new Vector2(.5f, .28f)); + second.Spawn(new Vector2(1f, .28f)); + Physics.SyncTransforms(); + Assert.That(world.Player.GapTo(second.Actor), + Is.GreaterThan(settings.ContactSensorMargin), + "The second enemy must be reached by the replay, not by the ordinary bump."); + bool ordinaryBumpResolved = false; + bool echoHitSecondEnemy = false; + void CaptureHit(CombatHitResult hit) + { + ordinaryBumpResolved |= hit.Target == first.gameObject && hit.IsOrdinaryBump; + echoHitSecondEnemy |= hit.Target == second.gameObject + && hit.SourceId == Defense25DRelicRuntime.EchoSourceId; + } + CombatEvents.OnValidHit += CaptureHit; + + try + { + world.ResolveBumps(Vector2.right * world.Player.MoveSpeed); + + Assert.That(world.BumpEvents, Is.GreaterThan(0), "The initial ordinary bump must resolve."); + Assert.That(ordinaryBumpResolved, Is.True); + Assert.That(first.Actor.DamageEvents, Is.GreaterThan(0)); + Assert.That(second.Actor.DamageEvents, Is.GreaterThan(0)); + Assert.That(echoHitSecondEnemy, Is.True, + "The synchronous echo chain must query and hit the second enemy without invalidating the bump snapshot."); + } + finally + { + CombatEvents.OnValidHit -= CaptureHit; + } + } + finally + { + relicTuning.ResonanceEchoChance = previousChance; + relicTuning.ResonanceEchoCooldownSeconds = previousCooldown; + } + yield return null; + } + + private GameObject Wall(Vector2 position, Vector3 size) + { + GameObject wall = GameObject.CreatePrimitive(PrimitiveType.Cube); + wall.transform.SetParent(world.transform); + wall.layer = Defense25DComparisonSettings.TerrainLayer; + wall.transform.position = Defense25DActor.OnGround(position, settings.GroundY + size.y * .5f); + wall.transform.localScale = size; + Physics.SyncTransforms(); + return wall; + } + + private static ComparisonSpriteFrames ReadCharacter(string folder, string prefix, string attack) + { + Sprite[] Read(string suffix) => AssetDatabase.LoadAllAssetsAtPath( + $"Assets/_Project/Art/Characters/{folder}/{prefix}_{suffix}.png") + .OfType() + .OrderBy(sprite => int.TryParse(sprite.name[(sprite.name.LastIndexOf('_') + 1)..], out int frame) ? frame : 0) + .ToArray(); + return new ComparisonSpriteFrames + { + Idle = Read("Idle"), Walk = Read("Walk"), Attack = Read(attack), + Hurt = Read("Hurt"), Death = Read("Death") + }; + } + + [UnityTest] + public IEnumerator SavedSceneStartsFromStoppedTimeAndReadsWasd() + { + Object.Destroy(world.gameObject); + yield return null; + // The original project's TimeManager currently starts at zero. Do not + // hide that startup condition with the fixture's accelerated clock. + Time.timeScale = 0f; + yield return EditorSceneManager.LoadSceneAsyncInPlayMode("Assets/_Project/Scenes/Defense25DComparison.unity", new LoadSceneParameters(LoadSceneMode.Single)); + world = Object.FindAnyObjectByType(); + yield return null; + Assert.That(world, Is.Not.Null); + CompleteStartupArtifactSelection(world); + Assert.That(Time.timeScale, Is.EqualTo(1f), "The independent scene must start its own clock even when the project starts paused."); + Assert.That(world.ReadInput && world.AutoSpawn && world.IsRunning && !world.Paused, Is.True); + + float started = world.GameTime; + yield return new WaitForSecondsRealtime(.15f); + Assert.That(world.GameTime, Is.GreaterThan(started)); + Assert.That(world.Ledger.SuppliedCount, Is.GreaterThan(0), "Enemies must spawn without any key press."); + Defense25DEnemy enemy = world.Enemies[0]; + Vector2 enemyBefore = enemy.Actor.Position; + yield return new WaitForSecondsRealtime(.15f); + Assert.That(Vector2.Distance(enemyBefore, enemy.Actor.Position), Is.GreaterThan(.01f), "The route must advance automatically."); + + Keyboard previousKeyboard = Keyboard.current; + Keyboard keyboard = null; + InputSettings previousInputSettings = InputSystem.settings; + InputSettings testInputSettings = Object.Instantiate(previousInputSettings); + try + { + // Batch-mode Game View has no focus. Scope routing to this test, + // preserving the normal InputSystem/Update/FixedUpdate loop. + testInputSettings.editorInputBehaviorInPlayMode = InputSettings.EditorInputBehaviorInPlayMode.AllDeviceInputAlwaysGoesToGameView; + testInputSettings.backgroundBehavior = InputSettings.BackgroundBehavior.IgnoreFocus; + InputSystem.settings = testInputSettings; + keyboard = InputSystem.AddDevice(); + foreach (Key key in new[] { Key.UpArrow, Key.LeftArrow, Key.DownArrow, Key.RightArrow }) + { + Vector2 before = world.Player.Position; + Vector2 direction = key == Key.UpArrow ? Vector2.up + : key == Key.LeftArrow ? Vector2.left + : key == Key.DownArrow ? Vector2.down : Vector2.right; + yield return HoldKey(keyboard, key); + Vector2 moved = world.Player.Position - before; + Assert.That(Vector2.Dot(moved, world.View.KeyboardToGround(direction)), Is.GreaterThan(.05f), $"{key} must move the saved-scene player in screen-relative direction. Moved={moved}"); + Debug.Log($"COMPARISON_WASD key={key} moved={moved} gameTime={world.GameTime:0.000}"); + } + yield return HoldKey(keyboard, Key.P); + Assert.That(world.Paused, Is.True); + Vector2 pausedPosition = world.Player.Position; + float pausedTime = world.GameTime; + yield return HoldKey(keyboard, Key.W); + Assert.That(world.Player.Position, Is.EqualTo(pausedPosition)); + Assert.That(world.GameTime, Is.EqualTo(pausedTime)); + yield return HoldKey(keyboard, Key.P); + Assert.That(world.Paused, Is.False); + Assert.That(world.GameTime, Is.GreaterThan(pausedTime)); + yield return HoldKey(keyboard, Key.P); + yield return HoldKey(keyboard, Key.R); + CompleteStartupArtifactSelection(world); + Assert.That(world.IsRunning && !world.Paused, Is.True); + Assert.That(world.GameTime, Is.LessThan(pausedTime)); + Assert.That(world.Player.Position, Is.EqualTo(world.Settings.PlayerSpawn)); + Assert.That(Time.timeScale, Is.EqualTo(1f)); + } + finally + { + if (keyboard != null && keyboard.added) InputSystem.RemoveDevice(keyboard); + InputSystem.settings = previousInputSettings; + Object.Destroy(testInputSettings); + if (previousKeyboard != null && previousKeyboard.added) previousKeyboard.MakeCurrent(); + } + } + + [UnityTest] + public IEnumerator SavedSceneUsesDefenseSkeletonAndBothPortalRoutesReachTheBase() + { + Object.Destroy(world.gameObject); + yield return null; + Time.timeScale = 1f; + yield return EditorSceneManager.LoadSceneAsyncInPlayMode("Assets/_Project/Scenes/Defense25DComparison.unity", new LoadSceneParameters(LoadSceneMode.Single)); + world = Object.FindAnyObjectByType(); + yield return null; + + Assert.That(world, Is.Not.Null); + CompleteStartupArtifactSelection(world); + Assert.That(world.Settings.Enemy.name, Is.EqualTo("Skeleton")); + Assert.That(world.Settings.EnemyArt.Idle.Length, Is.GreaterThan(0)); + Assert.That(world.Settings.EnemyArt.Walk.Length, Is.GreaterThan(0)); + Assert.That(world.Settings.EnemyArt.Attack.Length, Is.GreaterThan(0)); + Assert.That(world.Settings.EnemyArt.Idle[0].name, Does.StartWith("Skeleton_Idle")); + Assert.That(world.Settings.EnemyArt.Attack[0].name, Does.StartWith("Skeleton_Attack")); + Assert.That(world.Settings.EnemyRadius, Is.EqualTo(.275f).Within(.0001f)); + Assert.That(world.Settings.EnemyColliderOffset, Is.EqualTo(new Vector2(0f, -.35f))); + Assert.That(world.Settings.EnemyVisualScale, Is.EqualTo(1.25f)); + Assert.That(world.Settings.EnemyAttackClip, Is.Not.Null); + Assert.That(world.GetRouteForLane(0)[0], Is.EqualTo(GameplayConstants.Current.Defense.GetPortalPosition(0))); + Assert.That(world.GetRouteForLane(1)[0], Is.EqualTo(GameplayConstants.Current.Defense.GetPortalPosition(1))); + Assert.That(GameObject.Find("South obstacle"), Is.Null, + "The legacy obstacle must be disabled so it cannot block the lower defense route."); + + Defense25DEnemy upperWaveEnemy = world.SpawnNextEnemy(); + Defense25DEnemy lowerWaveEnemy = world.SpawnNextEnemy(); + Assert.That(upperWaveEnemy.IsAvailable && lowerWaveEnemy.IsAvailable, Is.True, + "Both lanes must have living Skeleton actors before the wave capture."); + Assert.That(new[] { upperWaveEnemy.Lane, lowerWaveEnemy.Lane }, Is.EqualTo(new[] { 0, 1 })); + Time.timeScale = 1f; + yield return new WaitForSeconds(.75f); + Assert.That(upperWaveEnemy.Actor.Visual.Renderer.sprite.name, Does.StartWith("Skeleton_")); + Assert.That(lowerWaveEnemy.Actor.Visual.Renderer.sprite.name, Does.StartWith("Skeleton_")); + Capture("11-saved-scene-two-route-skeleton-wave-active"); + + typeof(Defense25DComparison).GetProperty(nameof(Defense25DComparison.BaseHealth)) + .GetSetMethod(true).Invoke(world, new object[] { 100f }); + world.Player.ResetAt(new Vector2(0f, 5f)); + Time.timeScale = 8f; + yield return new WaitForSecondsRealtime(5f); + + Assert.That(world.Ledger.SuppliedCount, Is.EqualTo(16), "The saved scene must supply the complete 2x8 portal wave."); + Assert.That(world.Enemies.Count(e => e.Lane == 0), Is.EqualTo(8)); + Assert.That(world.Enemies.Count(e => e.Lane == 1), Is.EqualTo(8)); + Assert.That(world.Ledger.ResolvedCount, Is.EqualTo(16), "Both authored routes must reach the shared base."); + Assert.That(world.Ledger.KillEventCount, Is.Zero, "All 16 resolutions must be arrivals, not deaths."); + Assert.That(world.BaseHealth, Is.EqualTo(100f - 16f * GameplayConstants.Current.Defense.ArrivalBaseDamage)); + Assert.That(world.IsRunning, Is.True, "The elevated health is limited to this route-isolation fixture."); + Assert.That(world.RunCleared, Is.False, "The run must remain active until the field rewards are collected."); + Capture("10-saved-scene-two-route-skeleton-wave"); + } + + [UnityTest] + public IEnumerator PKeyCannotResumeTheRunWhileAFieldRelicChoiceIsOpen() + { + CompleteStartupArtifactSelection(world); + typeof(Defense25DComparison).GetProperty(nameof(Defense25DComparison.BaseHealth)) + .GetSetMethod(true).Invoke(world, new object[] { 100f }); + var laneZeroEnemies = new System.Collections.Generic.List(); + Defense25DEnemy movingEnemy = null; + for (int i = 0; i < GameplayConstants.Current.Defense.PortalEnemyCount * 2; i++) + { + Defense25DEnemy enemy = world.SpawnNextEnemy(); + Assert.That(enemy, Is.Not.Null); + if (enemy.Lane == 0) laneZeroEnemies.Add(enemy); + else if (movingEnemy == null) movingEnemy = enemy; + } + Assert.That(movingEnemy, Is.Not.Null); + foreach (Defense25DEnemy enemy in laneZeroEnemies) + world.ResolveEnemy(enemy, DefenseEnemyResolution.Arrival); + + world.Player.ResetAt(GameplayConstants.Current.Defense.GetPortalPosition(0) + Vector2.up * 1.2f); + world.ReadInput = true; + Physics.SyncTransforms(); + float modalDeadline = Time.realtimeSinceStartup + 3f; + while (!world.HasPendingFieldRelicReward && Time.realtimeSinceStartup < modalDeadline) + yield return new WaitForFixedUpdate(); + Assert.That(world.HasPendingFieldRelicReward, Is.True, + "Collecting an earned portal reward must open the actual field relic modal."); + Assert.That(world.Paused, Is.True); + + Keyboard previousKeyboard = Keyboard.current; + InputSettings previousInputSettings = InputSystem.settings; + InputSettings testInputSettings = Object.Instantiate(previousInputSettings); + Keyboard keyboard = null; + try + { + testInputSettings.editorInputBehaviorInPlayMode = InputSettings.EditorInputBehaviorInPlayMode.AllDeviceInputAlwaysGoesToGameView; + testInputSettings.backgroundBehavior = InputSettings.BackgroundBehavior.IgnoreFocus; + InputSystem.settings = testInputSettings; + keyboard = InputSystem.AddDevice(); + float pausedTime = world.GameTime; + Vector2 pausedEnemyPosition = movingEnemy.Actor.Position; + + yield return HoldKey(keyboard, Key.P); + Assert.That(world.HasPendingFieldRelicReward, Is.True); + Assert.That(world.Paused, Is.True, "P must not toggle the blocking field-choice modal."); + yield return new WaitForSecondsRealtime(.2f); + Assert.That(world.GameTime, Is.EqualTo(pausedTime).Within(.0001f)); + Assert.That(movingEnemy.Actor.Position, Is.EqualTo(pausedEnemyPosition), + "An active route enemy must remain frozen while the field choice is open."); + } + finally + { + if (keyboard != null && keyboard.added) InputSystem.RemoveDevice(keyboard); + InputSystem.settings = previousInputSettings; + Object.Destroy(testInputSettings); + if (previousKeyboard != null && previousKeyboard.added) previousKeyboard.MakeCurrent(); + } + } + + private static IEnumerator HoldKey(Keyboard keyboard, Key key) + { + float until = Time.realtimeSinceStartup + .15f; + while (Time.realtimeSinceStartup < until) + { + InputSystem.QueueStateEvent(keyboard, new KeyboardState(key)); + yield return null; + } + InputSystem.QueueStateEvent(keyboard, new KeyboardState()); + yield return null; + } + + private static void CompleteStartupArtifactSelection(Defense25DComparison comparison) + { + var choose = typeof(Defense25DComparison).GetMethod("ChooseStartupArtifact", + System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic); + Assert.That(choose, Is.Not.Null); + for (int i = 0; comparison.Paused && i < 3; i++) + Assert.That(choose.Invoke(comparison, new object[] { 0 }), Is.EqualTo(true)); + Assert.That(comparison.Paused, Is.False, + "The run fixture must finish the authored startup artifact choices before simulating play."); + } + + [UnityTest] + public IEnumerator SavedSceneHudHandlesUnavailableViewportAndRestoresGuiMatrix() + { + Object.Destroy(world.gameObject); + yield return null; + yield return EditorSceneManager.LoadSceneAsyncInPlayMode("Assets/_Project/Scenes/Defense25DComparison.unity", new LoadSceneParameters(LoadSceneMode.Single)); + world = Object.FindAnyObjectByType(); + yield return null; + world.SetPaused(true); + var drawHud = typeof(Defense25DComparison).GetMethod("DrawHud", + System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic); + Assert.That(drawHud, Is.Not.Null); + ComparisonHudTestWindow window = ScriptableObject.CreateInstance(); + try + { + window.Show(); + foreach (Vector2Int size in new[] { new Vector2Int(0, 360), new Vector2Int(640, 0), new Vector2Int(1, 1), new Vector2Int(640, 360), new Vector2Int(1399, 799) }) + { + bool called = false; + Exception failure = null; + window.Draw = () => + { + window.Draw = null; + called = true; + Matrix4x4 original = GUI.matrix; + try + { + GUI.matrix = Matrix4x4.TRS(new Vector3(3f, 5f, 0f), Quaternion.identity, new Vector3(2f, 2f, 1f)); + Matrix4x4 before = GUI.matrix; + drawHud.Invoke(world, new object[] { Defense25DView.GetViewport(size.x, size.y) }); + Assert.That(GUI.matrix, Is.EqualTo(before), "The HUD must restore its caller's GUI state."); + } + catch (Exception error) { failure = error; } + finally { GUI.matrix = original; } + }; + window.SendEvent(new Event { type = EventType.Repaint }); + Assert.That(called, Is.True, "Exercise the real HUD inside a valid IMGUI callback."); + if (failure != null) throw failure; + LogAssert.NoUnexpectedReceived(); + } + } + finally + { + window.Draw = null; + window.Close(); + } + } + + public sealed class ComparisonHudTestWindow : EditorWindow + { + public Action Draw; + private void OnGUI() => Draw?.Invoke(); + } + + private Defense25DEnemy DurableTarget(Vector2 root) + { + Defense25DEnemy target = world.SpawnNextEnemy(); + target.enabled = false; // Same 3D actor/hurtbox, with the route brain explicitly suspended. + target.Actor.AddModifier(new StatModifier("test-health", CharacterStat.MaxHealth, ModifierOperation.Flat, 90f)); + // ResetAt deliberately clears modifiers; apply health before a damage probe by using a cloned definition instead. + var serialized = new SerializedObject(settings.Enemy = Object.Instantiate(settings.Enemy)); + serialized.FindProperty("maxHealth").floatValue = 100f; + serialized.ApplyModifiedPropertiesWithoutUndo(); + target.Actor.ResetAt(root); + Physics.SyncTransforms(); + return target; + } + + [UnityTest] + public IEnumerator CastBlocksSlidesAndReportsReal3DContact() + { + settings.GroundY = 2f; + world.Player.ResetAt(Vector2.zero); + GameObject wall = Wall(new Vector2(1f, 0f), new Vector3(.2f, 1.5f, 6f)); + world.SetMovementInput(new Vector2(1f, 1f)); + for (int i = 0; i < 40; i++) yield return new WaitForFixedUpdate(); + Assert.That(world.Player.Position.x, Is.LessThanOrEqualTo(.9f - settings.PlayerRadius + .003f)); + Assert.That(world.Player.Position.y, Is.GreaterThan(1f), "Wall slide must retain tangential movement."); + Assert.That(world.Player.Body.position.y, Is.EqualTo(2f).Within(.0001f)); + Assert.That(world.Player.ContactCallbacks, Is.GreaterThan(0), "A trigger contact must actually be delivered by Physics3D."); + Assert.That(Physics.ComputePenetration(world.Player.BodyCollider, world.Player.Body.position, Quaternion.identity, + wall.GetComponent(), wall.transform.position, wall.transform.rotation, out _, out _), Is.False); + Assert.That(world.GetComponentsInChildren(true), Is.Empty); + Assert.That(world.GetComponentsInChildren(true), Is.Empty); + world.SetMovementInput(Vector2.right); + float gauge = world.Gauge; + for (int i = 0; i < 10; i++) yield return new WaitForFixedUpdate(); + Assert.That(world.Gauge, Is.EqualTo(gauge).Within(.02f), "Holding into a wall must not charge movement gauge."); + } + + [UnityTest] + public IEnumerator BumpDeduplicatesColliderHitsAndHonorsTargetInterval() + { + Defense25DEnemy enemy = DurableTarget(new Vector2(.5f, .205f)); + enemy.Actor.Facing = Vector2.right; + enemy.gameObject.AddComponent().size = Vector3.one * .1f; + CombatHitResult? hit = null; + void CaptureHit(CombatHitResult result) { hit = result; } + CombatEvents.OnValidHit += CaptureHit; + try + { + Physics.SyncTransforms(); + Assert.That(world.QueryEnemies(Vector2.zero, Vector2.one).Count, Is.EqualTo(1)); + world.ResolveBumps(Vector2.right * world.Player.MoveSpeed); + world.ResolveBumps(Vector2.right * world.Player.MoveSpeed); + Assert.That(enemy.Actor.CurrentHealth, Is.EqualTo(85f)); + Assert.That(enemy.Actor.DamageEvents, Is.EqualTo(1)); + Assert.That(world.BumpEvents, Is.EqualTo(1)); + Assert.That(world.Gauge, Is.EqualTo(GameplayConstants.Current.Artifacts.NormalBumpGaugeGain)); + Assert.That(hit.HasValue, Is.True); + Assert.That(hit.Value.Attacker, Is.SameAs(world.Player.gameObject)); + Assert.That(hit.Value.Target, Is.SameAs(enemy.gameObject)); + Assert.That(hit.Value.IsOrdinaryBump && !hit.Value.IsArtifactHit, Is.True); + Assert.That(hit.Value.SourceId, Is.Null); + Assert.That(hit.Value.BaseBumpDamage, Is.EqualTo(15f)); + Assert.That(hit.Value.KnockbackDirection, + Is.EqualTo((enemy.Actor.Position - world.Player.Position).normalized)); + Assert.That(hit.Value.HitPosition.x, Is.EqualTo(enemy.Actor.Center.x - enemy.Actor.Radius).Within(.001f)); + for (int i = 0; i < 15; i++) yield return new WaitForFixedUpdate(); + enemy.Actor.Body.position = Defense25DActor.OnGround(new Vector2(.5f, .205f), settings.GroundY); + Physics.SyncTransforms(); + world.ResolveBumps(Vector2.right * world.Player.MoveSpeed); + Assert.That(enemy.Actor.DamageEvents, Is.EqualTo(2)); + } + finally { CombatEvents.OnValidHit -= CaptureHit; } + } + + [UnityTest] + public IEnumerator SlimeContactPassesThroughAndEnemyStillStopsAtTerrain() + { + Defense25DEnemy enemy = DurableTarget(new Vector2(100.7f, 100f)); + world.Player.ResetAt(new Vector2(100f, 100f)); + enemy.Actor.ResetAt(new Vector2(100.7f, 100f)); + Physics.SyncTransforms(); + world.SetPaused(true); + Assert.That(world.Player.BodyCollider.enabled && enemy.Actor.BodyCollider.enabled, Is.True, + "Pausing the simulation must keep both physical colliders enabled."); + world.Player.Move(Vector2.right * 1.5f); + Assert.That(world.Player.LastMove.x, Is.EqualTo(1.5f).Within(.001f)); + Assert.That(world.Player.MovementBlocked, Is.False, "A Slime must not block player movement."); + yield return new WaitForFixedUpdate(); + Physics.SyncTransforms(); + Assert.That(world.Player.BodyCollider.enabled && enemy.Actor.BodyCollider.enabled, Is.True); + Assert.That(world.Player.Position.x, Is.GreaterThan(enemy.Actor.Position.x), "The player must finish beyond the pass-through Slime."); + + enemy.Actor.ResetAt(new Vector2(110f, 100f)); + Wall(new Vector2(112f, 100f), new Vector3(.2f, 1.5f, 6f)); + Assert.That(enemy.Actor.BodyCollider.enabled, Is.True); + enemy.Actor.Move(Vector2.right * 2f); + yield return new WaitForFixedUpdate(); + Assert.That(enemy.Actor.LastMove.x, Is.GreaterThan(0f), "The enemy must move up to the wall before being blocked."); + Assert.That(enemy.Actor.LastMove.x, Is.LessThan(1.7f), "An enemy must still stop at terrain."); + Assert.That(enemy.Actor.Position.x, Is.LessThan(111.7f), "The enemy root must remain on the near side of the wall."); + } + + [UnityTest] + public IEnumerator SlimeSideHitDoesNotRecoilOrDamageThePlayer() + { + Defense25DEnemy enemy = DurableTarget(new Vector2(.5f, .205f)); + world.Player.ResetAt(Vector2.zero); + enemy.Actor.ResetAt(new Vector2(.5f, .205f)); + enemy.Actor.Facing = Vector2.up; + Physics.SyncTransforms(); + HitSide classified = BumpCombatMath.ClassifySide(enemy.Actor.Facing, + world.Player.Position - enemy.Actor.Position); + Assert.That(classified, Is.EqualTo(HitSide.Side), "The chosen root positions and facing must form a side hit."); + Assert.That(world.Player.Position, Is.EqualTo(Vector2.zero)); + + world.ResolveBumps(Vector2.right * world.Player.MoveSpeed); + Assert.That(enemy.Actor.DamageEvents, Is.EqualTo(1)); + Assert.That(world.Player.HasKnockback, Is.False, "Slime crowd hits must not recoil the player."); + Assert.That(world.Player.DamageEvents, Is.Zero, "A side hit must not trigger frontal self-damage."); + Assert.That(world.Player.Position, Is.EqualTo(Vector2.zero), "The player root must remain at the side-hit position."); + yield return null; + } + + [UnityTest] + public IEnumerator RearHitCancelsAnActiveWarningOnceInAttackThenRearOrder() + { + Defense25DEnemy enemy = DurableTarget(new Vector2(.5f, .205f)); + Assert.That(enemy, Is.Not.Null); + enemy.enabled = true; + enemy.Actor.ResetAt(new Vector2(.5f, .205f)); + world.Player.ResetAt(Vector2.zero); + var events = new System.Collections.Generic.List(); + void OnRear(GameObject target) { if (target == enemy.gameObject) events.Add("rear"); } + void OnAttack(GameObject target, bool fromStun) { if (target == enemy.gameObject) events.Add("attack"); } + CombatEvents.OnRearAttackCancelled += OnRear; + CombatEvents.OnAttackCancelled += OnAttack; + try + { + enemy.Step(Time.fixedDeltaTime); + Assert.That(enemy.IsAttacking, Is.True); + enemy.Actor.Facing = Vector2.right; + Physics.SyncTransforms(); + world.ResolveBumps(Vector2.right * world.Player.MoveSpeed); + Assert.That(world.BumpEvents, Is.EqualTo(1)); + Assert.That(enemy.Actor.DamageEvents, Is.EqualTo(1)); + Assert.That(enemy.Actor.IsAlive, Is.True, "The rear hit must leave the Slime available for attack-cancel checks."); + Assert.That(events, Is.EqualTo(new[] { "attack", "rear" })); + } + finally + { + CombatEvents.OnRearAttackCancelled -= OnRear; + CombatEvents.OnAttackCancelled -= OnAttack; + } + yield return null; + } + + [UnityTest] + public IEnumerator IdleRearBumpDoesNotPublishCancellationEvents() + { + Defense25DEnemy enemy = DurableTarget(new Vector2(.5f, .205f)); + int rearCancelled = 0, attacksCancelled = 0; + void OnRear(GameObject target) { if (target == enemy.gameObject) rearCancelled++; } + void OnAttack(GameObject target, bool fromStun) { if (target == enemy.gameObject) attacksCancelled++; } + CombatEvents.OnRearAttackCancelled += OnRear; + CombatEvents.OnAttackCancelled += OnAttack; + try + { + enemy.Actor.Facing = Vector2.right; + world.ResolveBumps(Vector2.right * world.Player.MoveSpeed); + Assert.That(enemy.Actor.DamageEvents, Is.EqualTo(1)); + Assert.That(rearCancelled, Is.Zero); + Assert.That(attacksCancelled, Is.Zero); + } + finally + { + CombatEvents.OnRearAttackCancelled -= OnRear; + CombatEvents.OnAttackCancelled -= OnAttack; + } + yield return null; + } + + [UnityTest] + public IEnumerator NaturalAttackCompletionDoesNotPublishCancellationEvents() + { + Defense25DEnemy enemy = world.SpawnNextEnemy(); + Assert.That(enemy, Is.Not.Null); + enemy.Actor.ResetAt(new Vector2(.5f, .205f)); + int rearCancelled = 0, attacksCancelled = 0; + void OnRear(GameObject target) { if (target == enemy.gameObject) rearCancelled++; } + void OnAttack(GameObject target, bool fromStun) { if (target == enemy.gameObject) attacksCancelled++; } + CombatEvents.OnRearAttackCancelled += OnRear; + CombatEvents.OnAttackCancelled += OnAttack; + try + { + enemy.Step(Time.fixedDeltaTime); + Assert.That(enemy.IsAttacking, Is.True); + for (int i = 0; i < 100 && enemy.IsAttacking; i++) yield return new WaitForFixedUpdate(); + Assert.That(enemy.IsAttacking, Is.False); + Assert.That(rearCancelled, Is.Zero); + Assert.That(attacksCancelled, Is.Zero); + } + finally + { + CombatEvents.OnRearAttackCancelled -= OnRear; + CombatEvents.OnAttackCancelled -= OnAttack; + } + yield return null; + } + + [UnityTest] + public IEnumerator InvalidAttackTargetCleanupDoesNotPublishCancellationEvents() + { + Defense25DEnemy enemy = world.SpawnNextEnemy(); + Assert.That(enemy, Is.Not.Null); + enemy.Actor.ResetAt(new Vector2(.5f, .205f)); + int rearCancelled = 0, attacksCancelled = 0; + void OnRear(GameObject target) { if (target == enemy.gameObject) rearCancelled++; } + void OnAttack(GameObject target, bool fromStun) { if (target == enemy.gameObject) attacksCancelled++; } + CombatEvents.OnRearAttackCancelled += OnRear; + CombatEvents.OnAttackCancelled += OnAttack; + try + { + enemy.Step(Time.fixedDeltaTime); + Assert.That(enemy.IsAttacking, Is.True); + world.Player.gameObject.SetActive(false); + enemy.Step(Time.fixedDeltaTime); + Assert.That(enemy.IsAttacking, Is.False); + Assert.That(rearCancelled, Is.Zero); + Assert.That(attacksCancelled, Is.Zero); + } + finally + { + CombatEvents.OnRearAttackCancelled -= OnRear; + CombatEvents.OnAttackCancelled -= OnAttack; + } + yield return null; + } + + [UnityTest] + public IEnumerator VisualHeightDoesNotChangeFootprintAndBroadphaseHasBoundedHeight() + { + Defense25DEnemy enemy = DurableTarget(new Vector2(1f, 0f)); + Vector2 center = enemy.Actor.Center; + enemy.Actor.Visual.VisualHeight = 5f; + yield return null; + Assert.That(enemy.Actor.Center, Is.EqualTo(center)); + Assert.That(world.QueryEnemies(Vector2.zero, Vector2.one).Count, Is.EqualTo(1)); + enemy.Actor.Body.position += Vector3.up * 20f; + Physics.SyncTransforms(); + Assert.That(world.QueryEnemies(Vector2.zero, Vector2.one), Is.Empty); + } + + [UnityTest] + public IEnumerator CycloneKeepsDamageTicksIndependentOfPauseFramesAndDecoration() + { + Defense25DEnemy enemy = DurableTarget(new Vector2(.7f, .205f)); + foreach (bool charged in new[] { false, true }) + { + world.Player.ResetAt(Vector2.zero); + enemy.Actor.ResetAt(new Vector2(.7f, .205f)); + Physics.SyncTransforms(); + world.DebugFillGauge(); + world.CycloneVisual.DecorationSpeed = charged ? 4f : 1f; + world.CycloneVisual.Height = charged ? .65f : .15f; + world.CycloneVisual.RadiusScale = charged ? 1.35f : 1f; + Assert.That(world.TryUseCyclone(charged, Vector2.right), Is.True); + yield return new WaitForFixedUpdate(); + world.SetPaused(true); + float elapsed = world.CycloneElapsed, gameTime = world.GameTime; + int events = enemy.Actor.DamageEvents; + for (int i = 0; i < 5; i++) yield return null; + Assert.That(world.CycloneElapsed, Is.EqualTo(elapsed)); + Assert.That(world.GameTime, Is.EqualTo(gameTime)); + Assert.That(enemy.Actor.DamageEvents, Is.EqualTo(events)); + world.SetPaused(false); + for (int i = 0; i < 50 && world.CycloneActive; i++) yield return new WaitForFixedUpdate(); + Assert.That(world.CycloneActive, Is.False); + Assert.That(world.CycloneTicks, Is.EqualTo(charged ? 5 : 3)); + Assert.That(world.CycloneDamageEvents, Is.EqualTo(charged ? 5 : 3)); + Assert.That(enemy.Actor.CurrentHealth, Is.EqualTo(charged ? 60f : 82f)); + Assert.That(world.Gauge, Is.EqualTo(70f)); + Assert.That(world.Player.MoveSpeed, Is.GreaterThan(GameplayConstants.Current.Player.BaseMoveSpeed)); + Assert.That(world.CycloneVisual.IsVisible, Is.False); + } + } + + [UnityTest] + public IEnumerator CancelDeathDisableAndReuseLeaveNoDelayedHitsOrSummonRespawn() + { + Defense25DEnemy enemy = DurableTarget(new Vector2(.7f, .205f)); + world.Player.ResetAt(GameplayConstants.Current.Defense.GetBuildPadPosition(0)); + Assert.That(world.TowerManager.TryBuildTowerAtPad(0, DefenseTowerKind.Summon), Is.True); + world.DebugFillGauge(); + world.TryUseCyclone(true, Vector2.right); + yield return new WaitForFixedUpdate(); + world.CancelCyclone(); + int hits = enemy.Actor.DamageEvents; + for (int i = 0; i < 40; i++) yield return new WaitForFixedUpdate(); + Assert.That(enemy.Actor.DamageEvents, Is.EqualTo(hits)); + Assert.That(world.Player.MoveSpeed, Is.EqualTo(GameplayConstants.Current.Player.BaseMoveSpeed)); + Defense25DSummon summon = world.Summons[0]; + summon.Actor.TakeDamage(999f, Vector2.zero); + world.Player.TakeDamage(999f, Vector2.zero); + float remaining = summon.RespawnRemaining; + for (int i = 0; i < 25; i++) yield return new WaitForFixedUpdate(); + Assert.That(world.TowersOperational, Is.False); + Assert.That(summon.RespawnRemaining, Is.EqualTo(remaining)); + Assert.That(world.CycloneVisual.IsVisible, Is.False); + world.enabled = false; + Assert.That(world.Player.BodyCollider.enabled, Is.False); + Assert.That(world.Summons.All(s => !s.IsAttacking), Is.True); + world.enabled = true; + world.ResetRun(); + yield return new WaitForFixedUpdate(); + Assert.That(world.Player.IsAlive && world.Player.BodyCollider.enabled, Is.True); + Assert.That(world.Ledger.SuppliedCount, Is.Zero); + Assert.That(world.Gauge, Is.EqualTo(GameplayConstants.Current.Artifacts.StartingGauge)); + Assert.That(world.Enemies.All(e => !e.gameObject.activeSelf), Is.True); + Assert.That(world.Summons, Is.Empty); + } + + [UnityTest] + public IEnumerator RoutePreservesWaypointsAndArrivalCannotGrantKillRewardTwice() + { + settings.PortalImmunity = DefenseTowerKind.Summon; + world.Player.ResetAt(new Vector2(-8f, -4f)); + Defense25DEnemy enemy = world.SpawnNextEnemy(); + float before = world.BaseHealth; + for (int i = 0; i < 30; i++) yield return new WaitForFixedUpdate(); + int waypoint = enemy.WaypointIndex; + enemy.Actor.ApplyKnockback(Vector2.up, .5f); + yield return new WaitForFixedUpdate(); + Assert.That(enemy.WaypointIndex, Is.EqualTo(waypoint)); + while (enemy.IsAvailable && world.GameTime < 45f) yield return new WaitForFixedUpdate(); + Assert.That(enemy.IsAvailable, Is.False, "The route enemy must reach the base."); + Assert.That(world.Ledger.ResolvedCount, Is.EqualTo(1)); + Assert.That(world.Ledger.KillEventCount, Is.Zero); + Assert.That(world.KillCurrency, Is.Zero); + Assert.That(world.BaseHealth, Is.EqualTo(before - GameplayConstants.Current.Defense.ArrivalBaseDamage)); + world.ResolveEnemy(enemy, DefenseEnemyResolution.Arrival); + Assert.That(world.BaseHealth, Is.EqualTo(before - GameplayConstants.Current.Defense.ArrivalBaseDamage)); + } + + [UnityTest] + public IEnumerator SummonHitsPhysicallyAndRespawnsWhileTowerIsOperational() + { + Vector2 pad = GameplayConstants.Current.Defense.GetBuildPadPosition(0); + world.Player.ResetAt(pad); + Assert.That(world.TowerManager.TryBuildTowerAtPad(0, DefenseTowerKind.Summon), Is.True); + Defense25DTower tower = world.TowerManager.GetTowerAtPad(0); + Defense25DEnemy enemy = world.SpawnNextEnemy(); + if (world.IsTowerImmuneToEnemy(DefenseTowerKind.Summon, enemy)) + enemy = world.SpawnNextEnemy(); + enemy.Actor.ResetAt(tower.Position + new Vector2(1f, 0f)); + world.Player.ResetAt(pad + new Vector2(-1.5f, -1.5f)); + Physics.SyncTransforms(); + for (int i = 0; i < 180 && world.Summons.Sum(s => s.AttackEvents) == 0; i++) yield return new WaitForFixedUpdate(); + Assert.That(world.Summons.Sum(s => s.AttackEvents), Is.GreaterThan(0)); + Defense25DSummon summon = world.Summons[0]; + Assert.That(Physics.GetIgnoreCollision(summon.Actor.BodyCollider, world.Player.BodyCollider), Is.True); + Assert.That(Physics.GetIgnoreCollision(summon.Actor.BodyCollider, world.Summons[1].Actor.BodyCollider), Is.False); + int slot = summon.SlotIndex; + summon.Actor.TakeDamage(999f, Vector2.zero); + Assert.That(summon.Actor.BodyCollider.enabled, Is.False); + Defense25DSummon replacement = null; + for (int i = 0; i < 180 && replacement == null; i++) + { + replacement = world.Summons.FirstOrDefault(s => s.SlotIndex == slot && s.Actor.IsAlive); + if (replacement == null) yield return new WaitForFixedUpdate(); + } + Assert.That(replacement, Is.Not.Null); + Assert.That(replacement, Is.Not.SameAs(summon)); + Assert.That(replacement.Actor.BodyCollider.enabled, Is.True); + Assert.That(tower.SummonCount, Is.EqualTo(tower.TargetSummonCount)); + } + + [UnityTest] + public IEnumerator ViewportRayAndCameraBasisPreserveGroundCoordinates() + { + GameObject cameraObject = new("Viewport mapping test"); + cameraObject.transform.SetParent(world.transform); + Defense25DView display = cameraObject.AddComponent(); + Camera camera = display.Camera; + camera.orthographic = true; + camera.orthographicSize = 6.5f; + camera.transform.rotation = Quaternion.Euler(50f, 35f, 0f); + camera.transform.position = Vector3.up * 2f - camera.transform.forward * 25f; + Vector2 expected = new(1f, -.5f); + Vector3 uv = camera.WorldToViewportPoint(Defense25DActor.OnGround(expected, 2f)); + Rect viewport = Defense25DView.GetViewport(Screen.width, Screen.height); + Vector2 screen = viewport.position + Vector2.Scale(new Vector2(uv.x, uv.y), viewport.size); + Assert.That(display.TryScreenToGround(screen, 2f, out Vector2 actual), Is.True); + Assert.That(Vector2.Distance(actual, expected), Is.LessThan(.001f)); + Assert.That(display.TryScreenToGround(new Vector2(-1f, -1f), 2f, out _), Is.False); + Assert.That(display.KeyboardToGround(Vector2.one).magnitude, Is.EqualTo(1f).Within(.0001f)); + Assert.That(Vector2.Dot(display.KeyboardToGround(Vector2.right), + Defense25DActor.Ground(camera.transform.right).normalized), Is.GreaterThan(.999f)); + camera.transform.rotation = Quaternion.identity; + Assert.That(display.TryScreenToGround(viewport.center, 2f, out _), Is.False, "A parallel ray must not replace the last valid aim."); + yield return null; + } + + [UnityTest] + public IEnumerator ComparisonScenePresentsPixelsToGameDisplayAndCleansUp() + { + Object.Destroy(world.gameObject); + yield return null; + yield return EditorSceneManager.LoadSceneAsyncInPlayMode("Assets/_Project/Scenes/Defense25DComparison.unity", new LoadSceneParameters(LoadSceneMode.Single)); + world = Object.FindAnyObjectByType(); + yield return null; + world.ReadInput = false; + world.AutoSpawn = false; + world.ResetRun(); + world.SetPaused(true); + Defense25DView view = world.View; + Camera output = Camera.allCameras.SingleOrDefault(c => c.targetTexture == null && c.targetDisplay == view.Camera.targetDisplay); + Assert.That(output, Is.Not.Null, "The comparison must have a camera rendering to the Game display, not only an offscreen texture and OnGUI."); + Assert.That(EditorGUIUtility.IsDisplayReferencedByCameras(output.targetDisplay), Is.True, + "Check the same display predicate that controls Unity's No cameras rendering warning."); + // Batch mode automatically renders texture cameras only. Invoke the actual Game view + // display-render path without changing the presentation camera's screen destination. + var renderDisplay = typeof(EditorGUIUtility).GetMethod("RenderPlayModeViewCamerasInternal", + System.Reflection.BindingFlags.Static | System.Reflection.BindingFlags.NonPublic); + Assert.That(renderDisplay, Is.Not.Null); + bool renderedToDisplay = false; + void Rendered(ScriptableRenderContext context, Camera camera) + { + if (camera == output) renderedToDisplay = true; + } + for (int i = 0; i < 3; i++) yield return null; + + RawImage image = view.GetComponentInChildren(); + Assert.That(image, Is.Not.Null); + Assert.That(image.canvas.renderMode, Is.EqualTo(RenderMode.ScreenSpaceCamera)); + Assert.That(image.canvas.worldCamera, Is.SameAs(output)); + Assert.That(image.texture, Is.SameAs(view.Texture)); + Rect viewport = Defense25DView.GetViewport(Screen.width, Screen.height); + Assert.That(image.rectTransform.rect.size, Is.EqualTo(viewport.size)); + RenderTexture presented = new(Screen.width, Screen.height, 24, RenderTextureFormat.ARGB32); + Texture2D screen = new(Screen.width, Screen.height, TextureFormat.RGB24, false); + Texture2D source = new(640, 360, TextureFormat.RGB24, false); + RenderTexture previous = RenderTexture.active; + presented.Create(); + RenderPipelineManager.endCameraRendering += Rendered; + try + { + for (int i = 0; i < 3; i++) + { + Canvas.ForceUpdateCanvases(); + renderDisplay.Invoke(null, new object[] { presented, output.targetDisplay, Vector2.zero, false, false }); + yield return null; + } + Assert.That(renderedToDisplay, Is.True, "The screen camera must actually render through the Game view pipeline."); + Assert.That(output.targetTexture, Is.Null); + RenderTexture.active = presented; + screen.ReadPixels(new Rect(0, 0, screen.width, screen.height), 0, 0); + screen.Apply(); + // Match GameView.deviceFlippedTargetInView on top-origin graphics APIs. + // This is the Editor display target's orientation, not the world's texture UVs. + if (SystemInfo.graphicsUVStartsAtTop) + { + Color32[] pixels = screen.GetPixels32(); + Color32[] upright = new Color32[pixels.Length]; + for (int y = 0; y < screen.height; y++) + Array.Copy(pixels, y * screen.width, upright, (screen.height - 1 - y) * screen.width, screen.width); + screen.SetPixels32(upright); + screen.Apply(); + } + RenderTexture.active = view.Texture; + source.ReadPixels(new Rect(0, 0, 640, 360), 0, 0); + source.Apply(); + view.Camera.Render(); + Color32[] sourcePixels = source.GetPixels32(); + AssertCapturedGeometry(sourcePixels); + string directory = Environment.GetEnvironmentVariable("DEFENSE25D_OUTPUT") ?? Path.Combine(Application.temporaryCachePath, "Defense25DComparison"); + Directory.CreateDirectory(directory); + File.WriteAllBytes(Path.Combine(directory, "10-display-output.png"), screen.EncodeToPNG()); + File.WriteAllBytes(Path.Combine(directory, "10-display-source.png"), source.EncodeToPNG()); + int matched = 0, samples = 0; + for (int y = 17; y < 360; y += 31) + for (int x = 13; x < 640; x += 29) + { + Color expected = source.GetPixel(x, y); + Color actual = screen.GetPixel(Mathf.FloorToInt(viewport.x + (x + .5f) * viewport.width / 640f), + Mathf.FloorToInt(viewport.y + (y + .5f) * viewport.height / 360f)); + if (Mathf.Abs(actual.r - expected.r) < .02f && Mathf.Abs(actual.g - expected.g) < .02f && Mathf.Abs(actual.b - expected.b) < .02f) matched++; + samples++; + } + Assert.That(matched / (float)samples, Is.GreaterThan(.95f), "The display must present the scene with the same orientation and point-scaled viewport."); + if (viewport.x >= 1f || viewport.y >= 1f) + Assert.That(screen.GetPixel(0, 0).maxColorComponent, Is.LessThan(.01f), "Letterbox pixels must stay black."); + } + finally + { + RenderPipelineManager.endCameraRendering -= Rendered; + RenderTexture.active = previous; + presented.Release(); + Object.Destroy(presented); + Object.Destroy(screen); + Object.Destroy(source); + } + RenderTexture ownedTexture = view.Texture; + Camera sceneCamera = view.Camera; + view.enabled = false; + yield return null; + Assert.That(output.isActiveAndEnabled, Is.False); + view.enabled = true; + yield return null; + Assert.That(output.isActiveAndEnabled, Is.True); + Assert.That(view.GetComponentsInChildren().Length, Is.EqualTo(2), "Re-enabling must reuse the output camera."); + Object.Destroy(view); + yield return null; + yield return null; + Assert.That(output == null && ownedTexture == null, Is.True, "Removing the view must release its output camera and render texture."); + Assert.That(sceneCamera.targetTexture, Is.Null); + } + + [UnityTest] + public IEnumerator CaptureIndependentSceneAndExistingTextureVariants() + { + Time.timeScale = 1f; + Object.Destroy(world.gameObject); + yield return null; + yield return EditorSceneManager.LoadSceneAsyncInPlayMode("Assets/_Project/Scenes/Defense25DComparison.unity", new LoadSceneParameters(LoadSceneMode.Single)); + world = Object.FindAnyObjectByType(); + yield return null; + Assert.That(world, Is.Not.Null); + world.ReadInput = false; + world.AutoSpawn = false; + world.ResetRun(); + world.SetPaused(true); + for (int i = 0; i < 3; i++) yield return null; + Capture("01-scene"); + world.SetPaused(false); + world.DebugFillGauge(); + Assert.That(world.TryUseCyclone(false, Vector2.right), Is.True, "The capture must start a real Cyclone."); + for (int i = 0; i < 6; i++) yield return new WaitForFixedUpdate(); + world.SetPaused(true); + Assert.That(world.CycloneActive, Is.True, $"Cyclone ended before capture: elapsed={world.CycloneElapsed}, ticks={world.CycloneTicks}."); + Color32[] frontBackPixels = null, ribbonPixels = null; + foreach (string variant in new[] { "02-front-back-cutout", "03-front-back-alpha", "04-ribbon", "05-ribbon-radius", "06-ribbon-height", "07-ribbon-speed" }) + { + world.CycloneVisual.Ribbon = string.CompareOrdinal(variant, "04") >= 0; + world.CycloneVisual.Transparent = variant.Contains("alpha"); + world.CycloneVisual.RadiusScale = variant.Contains("radius") ? 1.35f : 1f; + world.CycloneVisual.Height = variant.Contains("height") ? .65f : .15f; + world.CycloneVisual.DecorationSpeed = variant.Contains("speed") ? 4f : 1f; + for (int i = 0; i < 3; i++) yield return null; + Assert.That(world.CycloneActive && world.CycloneVisual.IsVisible, Is.True, "The captured effect must still be active and visible."); + Assert.That(world.CycloneVisual.GetComponentsInChildren().All(r => r.sprite != null), + Is.True, "Existing Cyclone PNG frames must be loaded and assigned, not just an enabled empty renderer."); + Color32[] pixels = Capture(variant); + if (variant == "02-front-back-cutout") frontBackPixels = pixels; + else if (variant == "03-front-back-alpha" || variant == "04-ribbon") + Assert.That(DifferentPixels(frontBackPixels, pixels), Is.GreaterThan(4), variant + " must visibly differ from front/back cutout."); + if (variant == "04-ribbon") ribbonPixels = pixels; + else if (variant.StartsWith("05") || variant.StartsWith("06") || variant.StartsWith("07")) + Assert.That(DifferentPixels(ribbonPixels, pixels), Is.GreaterThan(4), variant + " must visibly change the same-texture ribbon."); + } + world.Player.Body.interpolation = RigidbodyInterpolation.None; + world.Player.Body.position = Defense25DActor.OnGround(new Vector2(-1f, 1.15f), world.Settings.GroundY); + world.Player.transform.position = world.Player.Body.position; + world.CycloneVisual.Ribbon = false; + world.CycloneVisual.DecorationSpeed = 1f; + Physics.SyncTransforms(); + for (int i = 0; i < 3; i++) yield return null; + Color32[] occluded = Capture("08-wall-occlusion"); + GameObject wall = GameObject.Find("Central wall - slide and occlusion"); + Assert.That(wall, Is.Not.Null); + Renderer wallRenderer = wall.GetComponent(); + wallRenderer.enabled = false; + for (int i = 0; i < 3; i++) yield return null; + Color32[] exposed = Capture("09-wall-hidden-control"); + Assert.That(DifferentPixels(occluded, exposed), Is.GreaterThan(100)); + Assert.That(CountCyclonePixels(exposed), Is.GreaterThan(CountCyclonePixels(occluded)), + "Removing the wall must reveal effect pixels previously hidden by real depth."); + wallRenderer.enabled = true; + } + + private static int CountCyclonePixels(Color32[] pixels) => pixels.Count(p => p.g > 90 && p.g > p.r * 1.7f && p.b > p.r * 1.2f && p.b < p.g); + + private static int DifferentPixels(Color32[] a, Color32[] b) + { + int count = 0; + for (int i = 0; i < a.Length; i++) if (!a[i].Equals(b[i])) count++; + return count; + } + + private Color32[] Capture(string label) + { + string directory = Environment.GetEnvironmentVariable("DEFENSE25D_OUTPUT") + ?? Path.Combine(Application.temporaryCachePath, "Defense25DComparison"); + Directory.CreateDirectory(directory); + world.View.Camera.Render(); + RenderTexture previous = RenderTexture.active; + RenderTexture.active = world.View.Texture; + Texture2D image = new(640, 360, TextureFormat.RGB24, false); + image.ReadPixels(new Rect(0, 0, 640, 360), 0, 0); + image.Apply(); + RenderTexture.active = previous; + Color32[] pixels = image.GetPixels32(); + AssertCapturedGeometry(pixels); + File.WriteAllBytes(Path.Combine(directory, label + "-640x360.png"), image.EncodeToPNG()); + Texture2D upscale = new(1920, 1080, TextureFormat.RGB24, false); + Color32[] large = new Color32[1920 * 1080]; + for (int y = 0; y < 1080; y++) + for (int x = 0; x < 1920; x++) large[y * 1920 + x] = pixels[(y / 3) * 640 + x / 3]; + upscale.SetPixels32(large); + upscale.Apply(); + File.WriteAllBytes(Path.Combine(directory, label + "-1920x1080.png"), upscale.EncodeToPNG()); + Object.Destroy(image); + Object.Destroy(upscale); + return pixels; + } + + private void AssertCapturedGeometry(Color32[] pixels) + { + Color32 background = pixels[0]; + int scenePixels = pixels.Count(pixel => !pixel.Equals(background)); + Assert.That(scenePixels, Is.GreaterThan(10000), + "The saved scene capture must contain substantial rendered map geometry, not a flat clear color."); + Camera camera = world.View.Camera; + for (int pad = 0; pad < 6; pad++) + { + Vector2 ground = GameplayConstants.Current.Defense.GetBuildPadPosition(pad); + Vector3 screen = camera.WorldToScreenPoint( + Defense25DActor.OnGround(ground, world.Settings.GroundY + .035f)); + Assert.That(screen.z, Is.GreaterThan(camera.nearClipPlane), $"Captured build pad {pad} must be in front of the camera."); + Assert.That(screen.x, Is.InRange(8f, Defense25DView.Width - 8f), $"Captured build pad {pad} must be on-screen."); + Assert.That(screen.y, Is.InRange(8f, Defense25DView.Height - 8f), $"Captured build pad {pad} must be on-screen."); + } + + Vector3 playerScreen = camera.WorldToScreenPoint(world.Player.transform.position + Vector3.up * .45f); + Assert.That(CountBrightPixels(pixels, playerScreen, 12), Is.GreaterThan(3), + "The comparison player must contribute rendered sprite pixels."); + } + + private static int CountBrightPixels(Color32[] pixels, Vector3 screen, int radius) + { + int count = 0, cx = Mathf.RoundToInt(screen.x), cy = Mathf.RoundToInt(screen.y); + for (int y = -radius; y <= radius; y++) + for (int x = -radius; x <= radius; x++) + { + int px = cx + x, py = cy + y; + if (px < 0 || px >= Defense25DView.Width || py < 0 || py >= Defense25DView.Height) continue; + Color32 pixel = pixels[py * Defense25DView.Width + px]; + if (pixel.r > 90 || pixel.g > 90 || pixel.b > 90) count++; + } + return count; + } + } +} diff --git a/Assets/_Project/Tests/PlayMode/Defense25DComparisonPlayModeTests.cs.meta b/Assets/_Project/Tests/PlayMode/Defense25DComparisonPlayModeTests.cs.meta new file mode 100644 index 0000000..8a708d4 --- /dev/null +++ b/Assets/_Project/Tests/PlayMode/Defense25DComparisonPlayModeTests.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: efe5ea77be2b4a86bf2248e9c77fe623 diff --git a/Assets/_Project/Tests/PlayMode/Defense25DComparisonPresentationPlayModeTests.cs b/Assets/_Project/Tests/PlayMode/Defense25DComparisonPresentationPlayModeTests.cs new file mode 100644 index 0000000..a369407 --- /dev/null +++ b/Assets/_Project/Tests/PlayMode/Defense25DComparisonPresentationPlayModeTests.cs @@ -0,0 +1,157 @@ +using System.Collections; +using System.Linq; +using BumpCombat.Combat; +using BumpCombat.Constants; +using BumpCombat.Defense; +using BumpCombat.Defense.Comparison; +using BumpCombat.Player; +using BumpCombat.Progression; +using NUnit.Framework; +using UnityEditor; +using UnityEngine; +using UnityEngine.TestTools; +using Object = UnityEngine.Object; + +namespace BumpCombat.Tests +{ + public sealed class Defense25DComparisonPresentationPlayModeTests + { + private Defense25DComparison world; + private Defense25DComparisonSettings settings; + private float previousTimeScale; + private const string SettingsPath = "Assets/_Project/Constants/Defense25DComparison/ComparisonSettings.asset"; + + [UnitySetUp] + public IEnumerator SetUp() + { + previousTimeScale = Time.timeScale; + Time.timeScale = 1f; + settings = Object.Instantiate(AssetDatabase.LoadAssetAtPath(SettingsPath)); + Assert.That(settings, Is.Not.Null); + settings.Enemy = AssetDatabase.LoadAssetAtPath( + "Assets/_Project/Constants/Enemies/Slime.asset"); + settings.EnemyArt = ReadCharacter(); + world = new GameObject("Presentation integration world").AddComponent(); + world.Initialize(settings); + world.AutoSpawn = false; + world.ReadInput = false; + yield return new WaitForFixedUpdate(); + } + + [UnityTearDown] + public IEnumerator TearDown() + { + if (world != null) Object.Destroy(world.gameObject); + if (settings != null) Object.Destroy(settings); + Time.timeScale = previousTimeScale; + yield return null; + } + + [UnityTest] + public IEnumerator BuildCatalogUsesAllSixRealTowerSpritesAndBuildsSelectedTypeAtSelectedPad() + { + Assert.That(world.TowerShowcaseKinds.Count(), Is.EqualTo(6)); + foreach (DefenseTowerKind kind in world.TowerShowcaseKinds) + Assert.That(world.GetTowerShowcaseSprite(kind), Is.Not.Null, $"Missing actual idle art for {kind}"); + + const int pad = 2; + Vector2 position = GameplayConstants.Current.Defense.GetBuildPadPosition(pad); + world.Player.ResetAt(position); + Assert.That(world.SelectBuildKind(DefenseTowerKind.Lightning), Is.True); + Assert.That(world.SelectBuildPad(pad), Is.True); + int before = world.TowerManager.ConstructionCurrency; + Assert.That(world.BuildSelectedTower(), Is.True); + + Defense25DTower tower = world.TowerManager.GetTowerAtPad(pad); + Assert.That(tower, Is.Not.Null); + Assert.That(tower.Kind, Is.EqualTo(DefenseTowerKind.Lightning)); + Assert.That(world.TowerManager.ConstructionCurrency, Is.LessThan(before)); + yield return null; + } + + [UnityTest] + public IEnumerator SelectedTowerOffersOnlyLegalTierAndFinalBranchUpgrades() + { + const int pad = 0; + world.Player.ResetAt(GameplayConstants.Current.Defense.GetBuildPadPosition(pad)); + world.TowerManager.AddCurrency(1000); + Assert.That(world.SelectBuildPad(pad), Is.True); + Assert.That(world.BuildSelectedTower(), Is.True); + Assert.That(world.UpgradeSelectedTower(DefenseTowerBranch.First), Is.False, + "Branches are only selected when upgrading from tier three."); + Assert.That(world.UpgradeSelectedTower(DefenseTowerBranch.None), Is.True); + Assert.That(world.TowerManager.GetTowerAtPad(pad).Tier, Is.EqualTo(2)); + Assert.That(world.UpgradeSelectedTower(DefenseTowerBranch.None), Is.True); + Assert.That(world.TowerManager.GetTowerAtPad(pad).Tier, Is.EqualTo(3)); + Assert.That(world.UpgradeSelectedTower(DefenseTowerBranch.Second), Is.True); + Defense25DTower tierFour = world.TowerManager.GetTowerAtPad(pad); + Assert.That(tierFour.Tier, Is.EqualTo(4)); + Assert.That(tierFour.Branch, Is.EqualTo(DefenseTowerBranch.Second)); + Assert.That(world.UpgradeSelectedTower(DefenseTowerBranch.First), Is.False); + yield return null; + } + + [UnityTest] + public IEnumerator RelicCannotBeGrantedOutsideACollectedPortalReward() + { + PassiveRelicId available = world.Loadout.GetAvailableRelics().First(); + Assert.That(world.QueueFieldRelicRewardForCollectedPortal(0), Is.False); + Assert.That(world.HasPendingFieldRelicReward, Is.False); + Assert.That(world.ResolveFieldRelicChoice(available), Is.False); + Assert.That(world.Loadout.OwnsRelic(available), Is.False); + Assert.That(world.Paused, Is.False); + yield return null; + } + + [UnityTest] + public IEnumerator PresentationPointerRegionsConsumeUiClicksButLeaveArenaPointerFree() + { + Rect viewport = Defense25DView.GetViewport(Screen.width, Screen.height); + float scale = viewport.width / Defense25DView.Width; + Vector2 ScreenPoint(float x, float y) => new(viewport.x + x * scale, + Screen.height - (viewport.y + y * scale)); + + foreach (Rect control in world.GetPersistentDefenseUiHitRects()) + Assert.That(world.IsPointerOverDefenseUI(ScreenPoint(control.center.x, control.center.y)), Is.True, + $"Clicks on persistent HUD control {control} must not also operate the arena."); + Assert.That(world.SelectBuildPad(0), Is.True); + Rect details = world.GetSelectedPadDetailsRect(); + Assert.That(details.width, Is.GreaterThan(0f)); + Assert.That(world.IsPointerOverDefenseUI(ScreenPoint(details.center.x, details.center.y)), Is.True, + "Clicks in the selected pad's contextual panel must not reach the arena."); + Assert.That(world.IsPointerOverDefenseUI(ScreenPoint(320, 180)), Is.False, + "The central field remains available for aiming and movement."); + yield return null; + } + + [UnityTest] + public IEnumerator ShowcaseModeUsesDedicatedEntryAndExitHooks() + { + int entered = 0; + int exited = 0; + world.BindValidationShowcase(() => { entered++; return true; }, () => exited++); + Assert.That(world.EnterValidationShowcase(), Is.True); + Assert.That(world.IsValidationShowcaseActive, Is.True); + Assert.That(world.EnterValidationShowcase(), Is.False); + Assert.That(entered, Is.EqualTo(1)); + Assert.That(world.ExitValidationShowcase(), Is.True); + Assert.That(world.IsValidationShowcaseActive, Is.False); + Assert.That(exited, Is.EqualTo(1)); + yield return null; + } + + private static ComparisonSpriteFrames ReadCharacter() + { + Sprite[] Read(string suffix) => AssetDatabase.LoadAllAssetsAtPath( + $"Assets/_Project/Art/Characters/Slime/Slime_{suffix}.png") + .OfType() + .OrderBy(sprite => int.TryParse(sprite.name[(sprite.name.LastIndexOf('_') + 1)..], out int frame) ? frame : 0) + .ToArray(); + return new ComparisonSpriteFrames + { + Idle = Read("Idle"), Walk = Read("Walk"), Attack = Read("Attack"), + Hurt = Read("Hurt"), Death = Read("Death"), + }; + } + } +} diff --git a/Assets/_Project/Tests/PlayMode/Defense25DComparisonPresentationPlayModeTests.cs.meta b/Assets/_Project/Tests/PlayMode/Defense25DComparisonPresentationPlayModeTests.cs.meta new file mode 100644 index 0000000..9b6634c --- /dev/null +++ b/Assets/_Project/Tests/PlayMode/Defense25DComparisonPresentationPlayModeTests.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: d2655c2a00f04de2a5c26d1a7e69fe6a diff --git a/Assets/_Project/Tests/PlayMode/Defense25DComparisonShowcasePlayModeTests.cs b/Assets/_Project/Tests/PlayMode/Defense25DComparisonShowcasePlayModeTests.cs new file mode 100644 index 0000000..68d78d1 --- /dev/null +++ b/Assets/_Project/Tests/PlayMode/Defense25DComparisonShowcasePlayModeTests.cs @@ -0,0 +1,325 @@ +using System.Collections; +using System.IO; +using System.Linq; +using System.Reflection; +using BumpCombat.Combat; +using BumpCombat.Constants; +using BumpCombat.Defense; +using BumpCombat.Defense.Comparison; +using BumpCombat.Enemies; +using NUnit.Framework; +using UnityEditor.SceneManagement; +using UnityEngine; +using UnityEngine.SceneManagement; +using UnityEngine.TestTools; +using Object = UnityEngine.Object; + +namespace BumpCombat.Tests +{ + public sealed class Defense25DComparisonShowcasePlayModeTests + { + private const string ScenePath = "Assets/_Project/Scenes/Defense25DComparison.unity"; + private Defense25DComparison world; + private float previousTimeScale; + + [UnitySetUp] + public IEnumerator SetUp() + { + previousTimeScale = Time.timeScale; + Time.timeScale = 1f; + yield return EditorSceneManager.LoadSceneAsyncInPlayMode(ScenePath, + new LoadSceneParameters(LoadSceneMode.Single)); + world = Object.FindAnyObjectByType(); + Assert.That(world, Is.Not.Null); + yield return null; + world.AutoSpawn = false; + world.ReadInput = false; + CompleteStartupArtifactSelection(world); + yield return null; + } + + [UnityTearDown] + public IEnumerator TearDown() + { + Time.timeScale = previousTimeScale; + yield return null; + } + + [UnityTest] + public IEnumerator ShowcaseUsesSixRealTowerFamiliesAndStationarySkeletonsThenRestoresTheRun() + { + Defense25DTowerManager runManager = world.TowerManager; + runManager.AddCurrency(1000); + DefenseConstants tuning = GameplayConstants.Current.Defense; + Vector2 directPad = tuning.GetBuildPadPosition(0); + world.Player.ResetAt(directPad); + Physics.SyncTransforms(); + Assert.That(runManager.TryBuildTowerAtPad(0, DefenseTowerKind.Direct), Is.True); + Defense25DTower runDirect = runManager.GetTowerAtPad(0); + Vector2 summonPad = tuning.GetBuildPadPosition(1); + world.Player.ResetAt(summonPad); + Physics.SyncTransforms(); + Assert.That(runManager.TryBuildTowerAtPad(1, DefenseTowerKind.Summon), Is.True); + Defense25DTower runSummonTower = runManager.GetTowerAtPad(1); + Defense25DSummon runSummon = world.Summons.First(summon => summon.Owner == runSummonTower); + Assert.That(runSummon, Is.Not.Null); + runSummon.Actor.ResetAt(DefenseTowerHitTestLayout.GetDummyPosition(DefenseTowerKind.Summon)); + + Defense25DEnemy runEnemy = null; + for (int attempt = 0; attempt < GameplayConstants.Current.Defense.PortalEnemyCount * 2; attempt++) + { + Defense25DEnemy candidate = world.SpawnNextEnemy(); + if (candidate == null) break; + if (!world.IsTowerImmuneToEnemy(DefenseTowerKind.Direct, candidate)) + { + runEnemy = candidate; + break; + } + } + Assert.That(runEnemy, Is.Not.Null, "A non-immune lane must be available within the configured two-lane supply."); + runEnemy.Actor.ResetAt(runDirect.Position + Vector2.right * .7f); + Physics.SyncTransforms(); + runDirect.StepParityAttacks(0f); + Transform runProjectile = world.transform.Find("Tower Projectile"); + Assert.That(runProjectile, Is.Not.Null, "The existing run must have a live tower projectile before showcase entry."); + DefenseSpriteSequence projectileSequence = runProjectile.GetComponentInChildren(true); + Assert.That(projectileSequence, Is.Not.Null); + Assert.That(projectileSequence.IsFinished, Is.False); + + Assert.That(world.Settings.Enemy.name, Is.EqualTo("Skeleton")); + Assert.That(world.Settings.EnemyAttackClip, Is.Not.Null); + Assert.That(world.Settings.EnemyArt.Attack.Length, Is.GreaterThan(0)); + Assert.That(world.Settings.EnemyArt.Attack[0].name, Does.StartWith("Skeleton_Attack")); + int runCurrency = runManager.ConstructionCurrency; + int supplied = world.Ledger.SuppliedCount; + int resolved = world.Ledger.ResolvedCount; + int kills = world.KillCurrency; + float baseHealth = world.BaseHealth; + float gameTime = world.GameTime; + Vector2 playerPosition = new(-4.5f, 0f); + world.Player.ResetAt(playerPosition); + Physics.SyncTransforms(); + DefenseTowerKind[] kinds = DefenseTowerHitTestLayout.KindsByPad; + + world.BindValidationShowcase(world.EnterTowerShowcase, world.ExitTowerShowcase); + Assert.That(world.EnterValidationShowcase(), Is.True); + Assert.That(world.IsTowerShowcaseSimulationActive, Is.True); + Assert.That(world.TowerManager, Is.Not.SameAs(runManager)); + Assert.That(runManager.IsSuspended, Is.True); + Assert.That(runDirect.GetComponentInChildren(true).enabled, Is.False, + "The parked run tower must not render over the showcase tower on the shared pad."); + Assert.That(runSummon.gameObject.activeSelf, Is.False, + "Parked summon colliders and movement must not participate in the showcase."); + Assert.That(runProjectile.gameObject.activeInHierarchy, Is.False, + "A parked flight and its sprite sequence must not advance or expire during showcase time."); + Assert.That(runEnemy.gameObject.activeSelf, Is.False, + "Normal route enemies must be suspended while the isolated showcase runs."); + Assert.That(world.TowerShowcaseDummies.Count, Is.EqualTo(kinds.Length)); + Assert.That(world.TowerManager.Towers.Count, Is.EqualTo(kinds.Length)); + for (int i = 0; i < kinds.Length; i++) + { + Assert.That(world.TowerManager.GetTowerAtPad(i).Kind, Is.EqualTo(kinds[i])); + Assert.That(world.TowerManager.GetTowerAtPad(i).Tier, Is.EqualTo(1)); + Defense25DEnemy dummy = world.GetTowerShowcaseDummy(kinds[i]); + Assert.That(dummy, Is.Not.Null); + Assert.That(dummy.IsShowcaseDummy, Is.True); + Assert.That(dummy.IsAvailable, Is.True); + Assert.That(dummy.Actor.Visual.Renderer.sprite.name, Does.StartWith("Skeleton_Idle")); + Assert.That(world.IsTowerShowcaseTarget(kinds[i], dummy), Is.True); + Assert.That(world.IsTowerShowcaseTarget(kinds[(i + 1) % kinds.Length], dummy), Is.False); + Assert.That(world.GetTowerTargets(kinds[i]).Where(target => target.IsShowcaseDummy), + Is.EquivalentTo(new[] { dummy }), "Each family may query only its assigned showcase target."); + } + + var dummyPositions = kinds.ToDictionary(kind => kind, + kind => world.GetTowerShowcaseDummy(kind).Actor.Position); + + float evidenceDeadline = Time.realtimeSinceStartup + 20f; + while (Time.realtimeSinceStartup < evidenceDeadline + && kinds.Where(kind => kind != DefenseTowerKind.Empowering) + .Any(kind => world.GetTowerShowcaseHitCount(kind) == 0)) + yield return new WaitForFixedUpdate(); + + for (int i = 0; i < 150; i++) yield return new WaitForFixedUpdate(); + + Camera camera = world.View.Camera; + camera.Render(); + Color32[] rendered = ReadPixels(world.View.Texture); + foreach (DefenseTowerKind kind in kinds) + { + Defense25DTower tower = world.TowerManager.TowersInConstructionOrder + .First(item => item.Kind == kind); + SpriteRenderer towerBody = tower.transform.Find("Tower billboard").GetComponent(); + Assert.That(towerBody.sprite, Is.Not.Null, $"{kind} must render its authored tower body sprite."); + Assert.That(RendererContribution(camera, rendered, towerBody), Is.GreaterThan(0), + $"{kind} tower body must contribute pixels through the saved scene's renderer."); + + Defense25DEnemy dummy = world.GetTowerShowcaseDummy(kind); + Assert.That(RendererContribution(camera, rendered, dummy.Actor.Visual.Renderer), Is.GreaterThan(0), + $"The {kind} assigned Skeleton must contribute full-body sprite pixels."); + } + Assert.That(RendererContribution(camera, rendered, world.Player.Visual.Renderer), Is.GreaterThan(0), + "The saved-scene player sprite must remain visible in the showcase capture."); + + CaptureTowerShowcase(); + + foreach (DefenseTowerKind kind in kinds.Where(kind => kind != DefenseTowerKind.Empowering)) + { + Defense25DTower tower = world.TowerManager.TowersInConstructionOrder + .First(item => item.Kind == kind); + string summonState = kind == DefenseTowerKind.Summon + ? string.Join("; ", world.Summons.Where(item => item.Owner == tower).Select(item => + $"slot={item.SlotIndex} pos={item.Actor.Position} alive={item.Actor.IsAlive} " + + $"attack={item.IsAttacking} attacks={item.AttackEvents} moving={item.Actor.LastMove} " + + $"knockback={item.Actor.HasKnockback} elapsed={GetPrivateFloat(item, "attackElapsed"):0.000} " + + $"next={GetPrivateFloat(item, "nextAttack"):0.000} targetGap=" + + (tower.CommandTarget == null ? "none" : item.Actor.GapTo(tower.CommandTarget.Actor).ToString("0.000")))) + : string.Empty; + Assert.That(world.GetTowerShowcaseHitCount(kind), Is.GreaterThan(0), + $"No actual attack evidence for {kind}. command={tower.CommandTarget?.name ?? "none"}; " + + $"targets={world.GetTowerTargets(kind).Count()} gameTime={world.GameTime:0.000} " + + $"running={world.IsRunning} paused={world.Paused} operational={world.TowersOperational} " + + $"towerEnabled={tower.isActiveAndEnabled} summonCount={tower.SummonCount}/{tower.TargetSummonCount} {summonState}"); + } + Assert.That(world.GetTowerShowcaseSupportBonus(DefenseTowerKind.Direct), Is.GreaterThan(0f), + "The pad-4 Empowering tower must support the pad-0 Direct tower within the authored radius."); + Assert.That(world.GetTowerShowcaseSupportBonus(DefenseTowerKind.Summon), Is.EqualTo(0f), + "The remote pad-1 Summon tower is outside the authored support radius."); + foreach (DefenseTowerKind kind in kinds) + Assert.That(world.GetTowerShowcaseDummy(kind).Actor.Position, + Is.EqualTo(dummyPositions[kind]), $"The {kind} Skeleton target must remain stationary."); + + Defense25DEnemy respawnTarget = world.GetTowerShowcaseDummy(DefenseTowerKind.Direct); + float aliveDeadline = Time.realtimeSinceStartup + 8f; + while (!respawnTarget.IsAvailable && Time.realtimeSinceStartup < aliveDeadline) + yield return new WaitForFixedUpdate(); + Assert.That(respawnTarget.IsAvailable, Is.True); + Defense25DTower directTower = world.TowerManager.GetTowerAtPad(0); + MethodInfo applyDamage = typeof(Defense25DTower).GetMethod("ApplyDamage", + BindingFlags.Instance | BindingFlags.NonPublic); + Assert.That(applyDamage, Is.Not.Null); + int popupCount = world.ActiveTowerDamagePopupCount; + Assert.That(applyDamage.Invoke(directTower, + new object[] { respawnTarget, respawnTarget.Actor.MaxHealth + 1f }), Is.EqualTo(true)); + Assert.That(respawnTarget.Actor.CurrentHealth, Is.EqualTo(respawnTarget.Actor.MaxHealth), + "A showcase dummy respawns synchronously at full health after a lethal tower hit."); + Assert.That(world.ActiveTowerDamagePopupCount, Is.GreaterThan(popupCount), + "The lethal hit's actual damage popup must be recorded before immediate dummy respawn."); + Assert.That(world.Ledger.SuppliedCount, Is.EqualTo(supplied)); + Assert.That(world.Ledger.ResolvedCount, Is.EqualTo(resolved)); + Assert.That(world.KillCurrency, Is.EqualTo(kills)); + Assert.That(world.TowerManager.ConstructionCurrency, + Is.EqualTo(GameplayConstants.Current.Defense.InitialConstructionCurrency)); + + Assert.That(respawnTarget.IsAvailable, Is.True, + "A dead tower-test dummy must be replaced immediately, not after the summon-unit timer."); + Assert.That(respawnTarget.Actor.Position, + Is.EqualTo(DefenseTowerHitTestLayout.GetDummyPosition(DefenseTowerKind.Direct))); + Assert.That(world.Ledger.ResolvedCount, Is.EqualTo(resolved)); + Assert.That(world.KillCurrency, Is.EqualTo(kills)); + + Assert.That(world.ExitValidationShowcase(), Is.True); + Assert.That(world.IsTowerShowcaseSimulationActive, Is.False); + Assert.That(world.TowerManager, Is.SameAs(runManager)); + Assert.That(runManager.IsSuspended, Is.False); + Assert.That(runDirect.GetComponentInChildren(true).enabled, Is.True); + Assert.That(runSummon.gameObject.activeSelf, Is.True); + Assert.That(runSummon.Actor.BodyCollider.enabled, Is.True); + Assert.That(runSummon.Actor.Visual.Renderer.enabled, Is.True); + Assert.That(runProjectile.gameObject.activeInHierarchy, Is.True); + Assert.That(projectileSequence.IsFinished, Is.False, + "The existing projectile must resume with its remaining flight after showcase exit."); + Assert.That(runEnemy.gameObject.activeSelf, Is.True, + "The previously active route Skeleton must resume after leaving the showcase."); + Assert.That(runEnemy.Actor.BodyCollider.enabled, Is.True, + "The resumed route Skeleton restores its collision footprint."); + Assert.That(runEnemy.Actor.Visual.Renderer.enabled, Is.True, + "The resumed route Skeleton restores its billboard."); + Assert.That(runManager.ConstructionCurrency, Is.EqualTo(runCurrency)); + Assert.That(world.Player.Position, Is.EqualTo(playerPosition)); + Assert.That(world.Ledger.SuppliedCount, Is.EqualTo(supplied)); + Assert.That(world.Ledger.ResolvedCount, Is.EqualTo(resolved)); + Assert.That(world.KillCurrency, Is.EqualTo(kills)); + Assert.That(world.BaseHealth, Is.EqualTo(baseHealth)); + Assert.That(world.GameTime, Is.EqualTo(gameTime).Within(.001f)); + Assert.That(world.IsRunning, Is.True); + } + + private static float GetPrivateFloat(Defense25DSummon summon, string field) => + (float)typeof(Defense25DSummon).GetField(field, BindingFlags.Instance | BindingFlags.NonPublic) + .GetValue(summon); + + private static int RendererContribution(Camera camera, Color32[] visible, Renderer renderer) + { + Vector3 screen = camera.WorldToScreenPoint(renderer.bounds.center); + Assert.That(screen.z, Is.GreaterThan(camera.nearClipPlane)); + bool wasEnabled = renderer.enabled; + try + { + renderer.enabled = false; + camera.Render(); + Color32[] hidden = ReadPixels(camera.targetTexture as RenderTexture); + return DifferentPixelsNear(visible, hidden, screen, 28); + } + finally + { + renderer.enabled = wasEnabled; + camera.Render(); + } + } + + private static int DifferentPixelsNear(Color32[] a, Color32[] b, Vector3 center, int radius) + { + int count = 0, cx = Mathf.RoundToInt(center.x), cy = Mathf.RoundToInt(center.y); + for (int y = -radius; y <= radius; y++) + for (int x = -radius; x <= radius; x++) + { + int px = cx + x, py = cy + y; + if (px < 0 || px >= Defense25DView.Width || py < 0 || py >= Defense25DView.Height) continue; + if (!a[py * Defense25DView.Width + px].Equals(b[py * Defense25DView.Width + px])) count++; + } + return count; + } + + private static Color32[] ReadPixels(RenderTexture texture) + { + RenderTexture previous = RenderTexture.active; + RenderTexture.active = texture; + Texture2D image = new(Defense25DView.Width, Defense25DView.Height, TextureFormat.RGB24, false); + image.ReadPixels(new Rect(0, 0, Defense25DView.Width, Defense25DView.Height), 0, 0); + image.Apply(); + Color32[] pixels = image.GetPixels32(); + RenderTexture.active = previous; + Object.DestroyImmediate(image); + return pixels; + } + + private static void CompleteStartupArtifactSelection(Defense25DComparison comparison) + { + var choose = typeof(Defense25DComparison).GetMethod("ChooseStartupArtifact", + BindingFlags.Instance | BindingFlags.NonPublic); + Assert.That(choose, Is.Not.Null); + for (int i = 0; comparison.Paused && i < 3; i++) + Assert.That(choose.Invoke(comparison, new object[] { 0 }), Is.EqualTo(true)); + Assert.That(comparison.Paused, Is.False, + "The showcase fixture must complete the authored startup artifact choices before entering the run."); + } + + private void CaptureTowerShowcase() + { + string directory = System.Environment.GetEnvironmentVariable("DEFENSE25D_OUTPUT") + ?? Path.Combine(Application.temporaryCachePath, "Defense25DComparison"); + Directory.CreateDirectory(directory); + world.View.Camera.Render(); + RenderTexture previous = RenderTexture.active; + RenderTexture.active = world.View.Texture; + Texture2D image = new(Defense25DView.Width, Defense25DView.Height, TextureFormat.RGB24, false); + image.ReadPixels(new Rect(0, 0, Defense25DView.Width, Defense25DView.Height), 0, 0); + image.Apply(); + RenderTexture.active = previous; + File.WriteAllBytes(Path.Combine(directory, "20-tower-showcase-six-families-640x360.png"), + image.EncodeToPNG()); + Object.Destroy(image); + } + } +} diff --git a/Assets/_Project/Tests/PlayMode/Defense25DComparisonShowcasePlayModeTests.cs.meta b/Assets/_Project/Tests/PlayMode/Defense25DComparisonShowcasePlayModeTests.cs.meta new file mode 100644 index 0000000..db5f083 --- /dev/null +++ b/Assets/_Project/Tests/PlayMode/Defense25DComparisonShowcasePlayModeTests.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 9809ce461dde4db6a6fac9d0626bbd42 diff --git a/Assets/_Project/Tests/PlayMode/Defense25DGroundAnchorPlayModeTests.cs b/Assets/_Project/Tests/PlayMode/Defense25DGroundAnchorPlayModeTests.cs new file mode 100644 index 0000000..e91ec58 --- /dev/null +++ b/Assets/_Project/Tests/PlayMode/Defense25DGroundAnchorPlayModeTests.cs @@ -0,0 +1,144 @@ +using System.Collections; +using System.IO; +using BumpCombat.Defense; +using BumpCombat.Defense.Comparison; +using NUnit.Framework; +using UnityEditor.SceneManagement; +using UnityEngine; +using UnityEngine.SceneManagement; +using UnityEngine.TestTools; +using Object = UnityEngine.Object; + +namespace BumpCombat.Tests +{ + public sealed class Defense25DGroundAnchorPlayModeTests + { + private const string ScenePath = "Assets/_Project/Scenes/Defense25DComparison.unity"; + private Scene ownedTestScene; + + [UnityTest] + public IEnumerator ActorVisualGroundAnchorsMatchInterpolatedColliderCentersWithoutCylinderShadows() + { + Time.timeScale = 1f; + yield return EditorSceneManager.LoadSceneAsyncInPlayMode(ScenePath, + new LoadSceneParameters(LoadSceneMode.Single)); + ownedTestScene = SceneManager.GetActiveScene(); + + Defense25DComparison world = Object.FindAnyObjectByType(); + Assert.That(world, Is.Not.Null); + yield return null; + world.AutoSpawn = false; + world.ReadInput = false; + world.ShowBoundaries = true; + world.SetPaused(true); + + Vector2 origin = new(0f, -2f); + world.Player.ResetAt(origin + Vector2.left * 4f); + world.Enemies[0].Spawn(origin + Vector2.left * 2f); + + Defense25DActor baseSlime = CreateSummon(world, "Anchor Slime", origin, 1, DefenseTowerBranch.First); + Defense25DActor giantSlime = CreateSummon(world, "Anchor Giant Slime", origin + Vector2.right * 2f, 4, DefenseTowerBranch.First); + Defense25DActor lavaSlime = CreateSummon(world, "Anchor Lava Slime", origin + Vector2.right * 4f, 4, DefenseTowerBranch.Second); + Physics.SyncTransforms(); + yield return null; + yield return null; + + Defense25DActor[] actors = { world.Player, world.Enemies[0].Actor, baseSlime, giantSlime, lavaSlime }; + Assert.That(giantSlime.Visual.FootVisualOffset, Is.Zero, + "The authored Giant Slime ground point (50,60) maps to its 128x128 center pivot (64,64), so it needs no local foot correction."); + Assert.That(baseSlime.Visual.FootVisualOffset, Is.EqualTo(world.Settings.SummonFootOffset).Within(.0001f)); + Assert.That(lavaSlime.Visual.FootVisualOffset, + Is.EqualTo(world.Settings.SummonFootOffset * (lavaSlime.Visual.VisualScale / world.Settings.SummonVisualScale)).Within(.0001f)); + foreach (Defense25DActor actor in actors) + { + Vector2 renderCenter = Defense25DActor.Ground(actor.transform.position) + actor.Offset; + Vector3 expectedAnchor = Defense25DActor.OnGround(renderCenter, world.Settings.GroundY); + Vector3 colliderCenter = actor.BodyCollider.transform.TransformPoint(actor.BodyCollider.center); + + Assert.That(Vector2.Distance(actor.Center, Defense25DActor.Ground(actor.Body.position) + + actor.Offset + actor.BodyCenterOffset), Is.LessThan(.001f), + $"{actor.Kind} combat center must use the fixed body-to-foot offset."); + Assert.That(Vector3.Distance(actor.Visual.FootPosition, expectedAnchor), Is.LessThan(.001f), + $"{actor.Kind} ground anchor must remain independent from the body-center correction."); + Assert.That(Vector2.Distance(new Vector2(colliderCenter.x, colliderCenter.z), renderCenter + actor.BodyCenterOffset), + Is.LessThan(.001f), $"{actor.Kind} capsule center must use its fixed body-to-foot offset from the visual ground anchor."); + Assert.That(actor.BodyCollider.radius, Is.EqualTo(actor.Radius).Within(.0001f)); + Assert.That(actor.BodyCollider.height, Is.GreaterThanOrEqualTo(2f * actor.Radius)); + Assert.That(actor.BodyCollider.center.x, Is.EqualTo(actor.Offset.x + actor.BodyCenterOffset.x).Within(.0001f)); + Assert.That(actor.BodyCollider.center.z, Is.EqualTo(actor.Offset.y + actor.BodyCenterOffset.y).Within(.0001f)); + foreach (Transform child in actor.GetComponentsInChildren(true)) + Assert.That(child.name, Is.Not.EqualTo("Ground shadow"), + $"{actor.Kind} must not get the extra runtime cylinder shadow."); + + SpriteRenderer renderer = actor.Visual.Renderer; + Assert.That(renderer, Is.Not.Null); + Assert.That(renderer.sprite, Is.Not.Null, + $"{actor.Kind} retains its existing sprite, including any authored shadow pixels."); + float scaledCorrection = actor.Visual.FootVisualOffset + * (renderer.transform.localScale.y / actor.Visual.VisualScale); + float renderedCorrection = Vector3.Dot(renderer.transform.position - expectedAnchor, + world.View.transform.up); + float halfPixelWorld = world.View.Camera.orthographicSize / Defense25DView.Height; + Assert.That(renderedCorrection, Is.EqualTo(-scaledCorrection).Within(halfPixelWorld + .0001f), + $"{actor.Kind} local billboard-foot correction must scale with its rendered scale."); + if (actor == giantSlime) + { + Vector3 pivotScreen = world.View.Camera.WorldToScreenPoint(renderer.transform.position); + Vector3 anchorScreen = world.View.Camera.WorldToScreenPoint(expectedAnchor); + Assert.That(Mathf.Abs(pivotScreen.y - anchorScreen.y), Is.LessThanOrEqualTo(1f), + "Giant Slime's authored center pivot must project onto its ground anchor within one render pixel."); + } + } + + string output = System.Environment.GetEnvironmentVariable("DEFENSE25D_OUTPUT"); + if (string.IsNullOrWhiteSpace(output)) + output = Path.Combine(Path.GetTempPath(), "Defense25DValidation", "captures"); + Directory.CreateDirectory(output); + string capturePath = Path.Combine(output, "ground-anchor-actors-after.png"); + world.View.Camera.Render(); + Texture2D capture = ReadPixels(world.View.Texture); + File.WriteAllBytes(capturePath, capture.EncodeToPNG()); + Object.DestroyImmediate(capture); + Assert.That(File.Exists(capturePath), Is.True); + Assert.That(new FileInfo(capturePath).Length, Is.GreaterThan(0)); + } + + [UnityTearDown] + public IEnumerator UnloadOwnedTestScene() + { + Time.timeScale = 1f; + if (ownedTestScene.IsValid() && ownedTestScene.isLoaded) + { + Scene cleanup = SceneManager.CreateScene("GroundAnchorTestCleanup"); + SceneManager.SetActiveScene(cleanup); + AsyncOperation unload = SceneManager.UnloadSceneAsync(ownedTestScene); + if (unload != null) yield return unload; + } + ownedTestScene = default; + } + + private static Defense25DActor CreateSummon(Defense25DComparison world, string name, + Vector2 position, int tier, DefenseTowerBranch branch) + { + GameObject root = new(name); + root.transform.SetParent(world.transform, false); + root.transform.position = Defense25DActor.OnGround(position, world.Settings.GroundY); + Defense25DActor actor = root.AddComponent(); + actor.Configure(world, ComparisonActorKind.Summon); + actor.Visual.ConfigureSummonArt(tier, branch); + actor.ResetAt(position); + return actor; + } + + private static Texture2D ReadPixels(RenderTexture source) + { + RenderTexture previous = RenderTexture.active; + RenderTexture.active = source; + Texture2D copy = new(source.width, source.height, TextureFormat.RGBA32, false); + copy.ReadPixels(new Rect(0, 0, source.width, source.height), 0, 0); + copy.Apply(); + RenderTexture.active = previous; + return copy; + } + } +} diff --git a/Assets/_Project/Tests/PlayMode/Defense25DGroundAnchorPlayModeTests.cs.meta b/Assets/_Project/Tests/PlayMode/Defense25DGroundAnchorPlayModeTests.cs.meta new file mode 100644 index 0000000..0be3366 --- /dev/null +++ b/Assets/_Project/Tests/PlayMode/Defense25DGroundAnchorPlayModeTests.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 4c853c82d04b4d999d32a036c7c20d1b diff --git a/Assets/_Project/Tests/PlayMode/Defense25DLoadoutPlayModeTests.cs b/Assets/_Project/Tests/PlayMode/Defense25DLoadoutPlayModeTests.cs new file mode 100644 index 0000000..3ffda10 --- /dev/null +++ b/Assets/_Project/Tests/PlayMode/Defense25DLoadoutPlayModeTests.cs @@ -0,0 +1,136 @@ +using System; +using System.Collections.Generic; +using BumpCombat.Combat; +using BumpCombat.Player; +using BumpCombat.Progression; +using BumpCombat.Defense.Comparison; +using NUnit.Framework; +using UnityEngine; +using Object = UnityEngine.Object; + +namespace BumpCombat.Tests +{ + public sealed class Defense25DLoadoutPlayModeTests + { + private readonly List definitions = new(); + + [TearDown] + public void TearDown() + { + foreach (ActiveArtifactDefinition definition in definitions) + Object.DestroyImmediate(definition); + definitions.Clear(); + } + + [Test] + public void SourceCatalogsExposeAllSixActiveEffectsAndTwentyRelics() + { + Assert.That(Enum.GetValues(typeof(ActiveArtifactEffect)).Length, Is.EqualTo(6)); + Assert.That(Enum.GetValues(typeof(PassiveRelicId)).Length, Is.EqualTo(20)); + Assert.That(PassiveRelicRules.GetAvailableRelics(Array.Empty()).Length, + Is.EqualTo(20)); + } + + [Test] + public void EveryActiveEffectDispatchesAndEveryRelicCanBeAcquired() + { + Defense25DLoadout loadout = NewLoadout(1000f, 6, 6); + List activated = new(); + foreach (ActiveArtifactEffect effect in Enum.GetValues(typeof(ActiveArtifactEffect))) + { + ActiveArtifactDefinition definition = Definition(effect, 1f, 2f); + Assert.That(loadout.TryAddArtifact(definition), Is.True, effect.ToString()); + Assert.That(loadout.Select(loadout.OwnedArtifactCount - 1), Is.True); + loadout.ExecuteArtifact = use => + { + activated.Add(use.Effect); + return true; + }; + Assert.That(loadout.TryUse(false), Is.True, effect.ToString()); + } + Assert.That(activated, Is.EquivalentTo(Enum.GetValues(typeof(ActiveArtifactEffect)))); + + foreach (PassiveRelicId relic in Enum.GetValues(typeof(PassiveRelicId))) + Assert.That(loadout.TryAcquireRelic(relic), Is.True, relic.ToString()); + Assert.That(loadout.OwnedRelicCount, Is.EqualTo(20)); + } + + [Test] + public void LoadoutEnforcesArtifactColorAndCapacityRules() + { + Defense25DLoadout loadout = NewLoadout(100f, 3, 1); + ActiveArtifactDefinition dash = Definition(ActiveArtifactEffect.Dash, 10f, 30f); + ActiveArtifactDefinition cyclone = Definition(ActiveArtifactEffect.Cyclone, 10f, 30f); + ActiveArtifactDefinition pulse = Definition(ActiveArtifactEffect.Pulse, 10f, 30f); + + Assert.That(loadout.TryAddArtifact(dash), Is.True); + Assert.That(loadout.TryAddArtifact(cyclone), Is.False); + Assert.That(loadout.TryAddArtifact(pulse), Is.True); + Assert.That(loadout.OwnedArtifactCount, Is.EqualTo(2)); + } + + [Test] + public void ChargeReleaseUsesDefinitionCostAndFailedExecutionKeepsGauge() + { + Defense25DLoadout loadout = NewLoadout(80f, 3, 1); + ActiveArtifactDefinition definition = Definition(ActiveArtifactEffect.Pulse, 20f, 60f, 2f); + Assert.That(loadout.TryAddArtifact(definition), Is.True); + loadout.Select(0); + loadout.ExecuteArtifact = _ => false; + + Assert.That(loadout.BeginCharge(), Is.True); + loadout.AdvanceCharge(1f); + Assert.That(loadout.ChargeProgress, Is.EqualTo(.5f).Within(.001f)); + Assert.That(loadout.ReleaseCharge(), Is.False); + Assert.That(loadout.Gauge, Is.EqualTo(80f)); + Assert.That(loadout.IsCharging, Is.False); + + loadout.ExecuteArtifact = _ => true; + Assert.That(loadout.TryUse(false), Is.True); + Assert.That(loadout.Gauge, Is.EqualTo(60f)); + } + + [Test] + public void RunResetClearsOwnedStateAndRestoresStartingGauge() + { + Defense25DLoadout loadout = NewLoadout(25f, 3, 1); + Assert.That(loadout.TryAddArtifact(Definition(ActiveArtifactEffect.Dash, 10f, 30f)), Is.True); + Assert.That(loadout.TryAcquireRelic(PassiveRelicId.TwinHornTalisman), Is.True); + + loadout.ResetRun(); + + Assert.That(loadout.OwnedArtifactCount, Is.Zero); + Assert.That(loadout.OwnedRelicCount, Is.Zero); + Assert.That(loadout.Gauge, Is.EqualTo(25f)); + } + + [Test] + public void RelicOfferPrerequisitesAndTriggerDispatchAreEnforced() + { + Defense25DLoadout loadout = NewLoadout(50f, 3, 1); + int triggers = 0; + loadout.DispatchRelicTrigger = _ => triggers++; + + Assert.That(loadout.TryAcquireRelic(PassiveRelicId.EmberCarrier), Is.False); + Assert.That(loadout.TryAcquireRelic(PassiveRelicId.EmberWick), Is.True); + Assert.That(loadout.TryAcquireRelic(PassiveRelicId.EmberCarrier), Is.True); + loadout.NotifyRelicTrigger(PassiveRelicId.EmberCarrier); + loadout.NotifyRelicTrigger(PassiveRelicId.BloodFang); + Assert.That(triggers, Is.EqualTo(1)); + } + + private static Defense25DLoadout NewLoadout(float startingGauge, int maxArtifacts, int maxPerColor) => + new(startingGauge, 100f, maxArtifacts, maxPerColor); + + private ActiveArtifactDefinition Definition( + ActiveArtifactEffect effect, float normalCost, float chargedCost, float chargeDuration = .8f) + { + ActiveArtifactDefinition definition = ScriptableObject.CreateInstance(); + definition.Configure(effect.ToString(), effect.ToString(), "*", effect, + DamageTag.Collision, Color.white, Color.white, normalCost, chargedCost, + chargeDuration, 1f, 2f, 1f, 2f, 1f, 2f); + definitions.Add(definition); + return definition; + } + } +} diff --git a/Assets/_Project/Tests/PlayMode/Defense25DLoadoutPlayModeTests.cs.meta b/Assets/_Project/Tests/PlayMode/Defense25DLoadoutPlayModeTests.cs.meta new file mode 100644 index 0000000..6e48fa8 --- /dev/null +++ b/Assets/_Project/Tests/PlayMode/Defense25DLoadoutPlayModeTests.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: db873143f6314ed6ba44eee6effec287 diff --git a/Assets/_Project/Tests/PlayMode/Defense25DPixelAlignmentPlayModeTests.cs b/Assets/_Project/Tests/PlayMode/Defense25DPixelAlignmentPlayModeTests.cs new file mode 100644 index 0000000..898874a --- /dev/null +++ b/Assets/_Project/Tests/PlayMode/Defense25DPixelAlignmentPlayModeTests.cs @@ -0,0 +1,334 @@ +using System.Collections; +using System.IO; +using BumpCombat.Constants; +using BumpCombat.Defense.Comparison; +using NUnit.Framework; +using UnityEditor.SceneManagement; +using UnityEngine; +using UnityEngine.SceneManagement; +using UnityEngine.TestTools; +using Object = UnityEngine.Object; + +namespace BumpCombat.Tests +{ + public sealed class Defense25DPixelAlignmentPlayModeTests + { + private const string ScenePath = "Assets/_Project/Scenes/Defense25DComparison.unity"; + + [UnityTest] + public IEnumerator SavedSceneIdleUsesIntegerProjectedSourceTexels() + { + Time.timeScale = 1f; + yield return EditorSceneManager.LoadSceneAsyncInPlayMode(ScenePath, + new LoadSceneParameters(LoadSceneMode.Single)); + + Defense25DComparison world = Object.FindAnyObjectByType(); + Assert.That(world, Is.Not.Null); + yield return null; + world.AutoSpawn = false; + world.ReadInput = false; + world.SetPaused(true); + world.Player.ResetAt(world.Settings.PlayerSpawn); + yield return null; + + SpriteRenderer renderer = world.Player.Visual.Renderer; + Sprite source = world.Settings.PlayerArt.Idle[0]; + Assert.That(source, Is.Not.Null); + renderer.sprite = source; + world.View.Camera.Render(); + + float unitsPerTexel = 1f / source.pixelsPerUnit; + Vector3 texelStep = renderer.transform.TransformVector(Vector3.up * unitsPerTexel); + Vector3 a = world.View.Camera.WorldToScreenPoint(renderer.transform.position); + Vector3 b = world.View.Camera.WorldToScreenPoint(renderer.transform.position + texelStep); + float projectedTexelPitch = Vector2.Distance(a, b); + + string output = System.Environment.GetEnvironmentVariable("DEFENSE25D_OUTPUT"); + if (string.IsNullOrWhiteSpace(output)) + output = Path.Combine(Path.GetTempPath(), "Defense25DValidation", "captures"); + Directory.CreateDirectory(output); + string capturePath = Path.Combine(output, "player-idle-after.png"); + Texture2D captured = ReadPixels(world.View.Texture); + File.WriteAllBytes(capturePath, captured.EncodeToPNG()); + Object.DestroyImmediate(captured); + Texture2D sourceFrame = ReadSpriteSource(source); + File.WriteAllBytes(Path.Combine(output, "player-idle-source-after.png"), + sourceFrame.EncodeToPNG()); + Object.DestroyImmediate(sourceFrame); + File.WriteAllText(Path.Combine(output, "player-idle-after.txt"), + $"camera={world.View.Camera.pixelWidth}x{world.View.Camera.pixelHeight}; " + + $"orthographicSize={world.View.Camera.orthographicSize:R}; " + + $"pixelsPerUnit={source.pixelsPerUnit:R}; " + + $"authoredScale={world.Settings.PlayerVisualScale:R}; " + + $"visualScale={renderer.transform.lossyScale.y:R}; " + + $"projectedTexelPitch={projectedTexelPitch:R}; playerPosition={world.Player.Position}; " + + $"sprite={source.name}"); + + Assert.That(projectedTexelPitch, Is.EqualTo(Mathf.Round(projectedTexelPitch)).Within(.001f), + $"A stationary source texel spans {projectedTexelPitch:R} internal pixels. " + + $"The captured AFTER render is {capturePath}."); + Assert.That(source.texture.filterMode, Is.EqualTo(FilterMode.Point)); + Assert.That(renderer.transform.localScale.y, Is.EqualTo(1.1555555f).Within(.001f), + "Only the player's visual scale is quantized; the authored collider and camera framing stay fixed."); + } + + [UnityTest] + public IEnumerator FeedbackTintSurvivesSpriteLateUpdateAndCameraOffsetIsComposedInPixels() + { + Time.timeScale = 1f; + yield return EditorSceneManager.LoadSceneAsyncInPlayMode(ScenePath, + new LoadSceneParameters(LoadSceneMode.Single)); + Defense25DComparison world = Object.FindAnyObjectByType(); + Assert.That(world, Is.Not.Null); + yield return null; + world.AutoSpawn = false; + world.ReadInput = false; + world.SetPaused(true); + + Color tint = new(.6f, .8f, .9f, .35f); + world.Player.Visual.SetFeedbackTint(tint); + yield return null; + Assert.That(world.Player.Visual.Renderer.color, Is.EqualTo(tint)); + SpriteRenderer playerRenderer = world.Player.Visual.Renderer; + Assert.That(playerRenderer.sharedMaterial, Is.Not.SameAs(world.Settings.CutoutMaterial)); + Assert.That(playerRenderer.sharedMaterial.GetColor("_BaseColor"), Is.EqualTo(Color.white)); + Assert.That(playerRenderer.sharedMaterial.GetFloat("_Cutoff"), Is.LessThan(.35f)); + + Camera camera = world.View.Camera; + CameraClearFlags savedClear = camera.clearFlags; + Color savedBackground = camera.backgroundColor; + int savedMask = camera.cullingMask; + Renderer[] renderers = Object.FindObjectsByType(FindObjectsSortMode.None); + bool[] rendererStates = new bool[renderers.Length]; + for (int i = 0; i < renderers.Length; i++) + { + rendererStates[i] = renderers[i].enabled; + renderers[i].enabled = renderers[i] == playerRenderer; + } + camera.clearFlags = CameraClearFlags.SolidColor; + camera.backgroundColor = Color.clear; + camera.cullingMask = -1; + camera.Render(); + Texture2D blinkCapture = ReadPixels(world.View.Texture); + int visiblePixels = 0; + foreach (Color32 pixel in blinkCapture.GetPixels32()) + if (pixel.a > 0 && (pixel.r > 0 || pixel.g > 0 || pixel.b > 0)) visiblePixels++; + string output = System.Environment.GetEnvironmentVariable("DEFENSE25D_OUTPUT"); + if (string.IsNullOrWhiteSpace(output)) + output = Path.Combine(Path.GetTempPath(), "Defense25DValidation", "captures"); + Directory.CreateDirectory(output); + string blinkCapturePath = Path.Combine(output, "player-blink-alpha-035.png"); + File.WriteAllBytes(blinkCapturePath, blinkCapture.EncodeToPNG()); + Object.DestroyImmediate(blinkCapture); + camera.clearFlags = savedClear; + camera.backgroundColor = savedBackground; + camera.cullingMask = savedMask; + for (int i = 0; i < renderers.Length; i++) + if (renderers[i] != null) renderers[i].enabled = rendererStates[i]; + Assert.That(visiblePixels, Is.GreaterThan(0), + $"The .35 alpha player blink must render through the configured cutout cutoff; capture={blinkCapturePath}."); + + world.Player.Visual.ClearFeedbackTint(); + yield return null; + Assert.That(world.Player.Visual.Renderer.color, Is.EqualTo(Color.white)); + Assert.That(playerRenderer.sharedMaterial, Is.SameAs(world.Settings.CutoutMaterial)); + + Vector3 target = world.Player.Visual.Renderer.transform.position; + Vector3 before = camera.WorldToScreenPoint(target); + world.View.SetFeedbackCameraOffset(new Vector2(3f, -2f)); + yield return new WaitForEndOfFrame(); + Vector3 after = camera.WorldToScreenPoint(target); + Assert.That(after.x - before.x, Is.EqualTo(-3f).Within(.02f)); + Assert.That(after.y - before.y, Is.EqualTo(2f).Within(.02f)); + SpriteRenderer shakenPlayer = world.Player.Visual.Renderer; + Vector3 shakenPivot = camera.WorldToScreenPoint(shakenPlayer.transform.position); + float shakenPitch = camera.targetTexture.height / (2f * camera.orthographicSize) + * shakenPlayer.transform.lossyScale.x / shakenPlayer.sprite.pixelsPerUnit; + float flippedPivotX = shakenPlayer.flipX + ? shakenPlayer.sprite.rect.width - shakenPlayer.sprite.pivot.x : shakenPlayer.sprite.pivot.x; + float shakenLeft = shakenPivot.x - flippedPivotX * shakenPitch; + float shakenBottom = shakenPivot.y - shakenPlayer.sprite.pivot.y * shakenPitch; + Assert.That(shakenLeft, Is.EqualTo(Mathf.Round(shakenLeft)).Within(.001f), + $"Player source-pixel edges must remain aligned after camera shake offset; pivot={shakenPivot}."); + Assert.That(shakenBottom, Is.EqualTo(Mathf.Round(shakenBottom)).Within(.001f), + $"Player source-pixel edges must remain aligned after camera shake offset; pivot={shakenPivot}."); + world.View.ClearFeedbackCameraOffset(); + yield return null; + Vector3 cleared = camera.WorldToScreenPoint(target); + Assert.That(cleared.x, Is.EqualTo(before.x).Within(.02f)); + Assert.That(cleared.y, Is.EqualTo(before.y).Within(.02f)); + } + + [UnityTest] + public IEnumerator SpritePixelEdgesStayOnTheSameGridAtFractionalGroundPositions() + { + Time.timeScale = 1f; + yield return EditorSceneManager.LoadSceneAsyncInPlayMode(ScenePath, + new LoadSceneParameters(LoadSceneMode.Single)); + Defense25DComparison world = Object.FindAnyObjectByType(); + Assert.That(world, Is.Not.Null); + yield return null; + world.AutoSpawn = false; + world.ReadInput = false; + world.SetPaused(true); + + SpriteRenderer renderer = world.Player.Visual.Renderer; + renderer.sprite = world.Settings.PlayerArt.Idle[0]; + Vector2[] positions = { new(-4.873f, -2.123f), new(-4.811f, -2.067f) }; + foreach (Vector2 position in positions) + { + foreach (float facing in new[] { 1f, -1f }) + { + world.Player.ResetAt(position); + world.Player.Facing = new Vector2(facing, 0f); + // A normal yield resumes before LateUpdate; observe the rendered transform afterwards. + yield return new WaitForEndOfFrame(); + Vector3 projectedPivot = world.View.Camera.WorldToScreenPoint(renderer.transform.position); + float pitch = world.View.Camera.targetTexture.height + / (2f * world.View.Camera.orthographicSize) + * renderer.transform.lossyScale.x / renderer.sprite.pixelsPerUnit; + float pivotX = renderer.flipX + ? renderer.sprite.rect.width - renderer.sprite.pivot.x : renderer.sprite.pivot.x; + float pixelOriginX = projectedPivot.x - pivotX * pitch; + float pixelOriginY = projectedPivot.y - renderer.sprite.pivot.y * pitch; + Assert.That(pixelOriginX, Is.EqualTo(Mathf.Round(pixelOriginX)).Within(.001f), + $"Horizontal source-pixel edges must snap at ground position {position}, facing {facing}; pivot={projectedPivot}."); + Assert.That(pixelOriginY, Is.EqualTo(Mathf.Round(pixelOriginY)).Within(.001f), + $"Vertical source-pixel edges must snap at ground position {position}, facing {facing}; pivot={projectedPivot}."); + } + } + } + + [UnityTest] + public IEnumerator FacingFlipMirrorsTheRenderedIdleAtTheSameFrameAndPosition() + { + Time.timeScale = 1f; + yield return EditorSceneManager.LoadSceneAsyncInPlayMode(ScenePath, + new LoadSceneParameters(LoadSceneMode.Single)); + Defense25DComparison world = Object.FindAnyObjectByType(); + Assert.That(world, Is.Not.Null); + yield return null; + world.AutoSpawn = false; + world.ReadInput = false; + world.SetPaused(true); + Vector2 position = new(-4.873f, -2.123f); + world.Player.ResetAt(position); + world.Player.Facing = Vector2.right; + SpriteRenderer player = world.Player.Visual.Renderer; + player.sprite = world.Settings.PlayerArt.Idle[0]; + + Renderer[] renderers = Object.FindObjectsByType(FindObjectsSortMode.None); + bool[] rendererStates = new bool[renderers.Length]; + for (int i = 0; i < renderers.Length; i++) + { + rendererStates[i] = renderers[i].enabled; + renderers[i].enabled = renderers[i] == player; + } + Camera camera = world.View.Camera; + CameraClearFlags savedClear = camera.clearFlags; + Color savedBackground = camera.backgroundColor; + camera.clearFlags = CameraClearFlags.SolidColor; + camera.backgroundColor = Color.clear; + + yield return new WaitForEndOfFrame(); + Assert.That(player.sprite.name, Is.EqualTo(world.Settings.PlayerArt.Idle[0].name)); + Texture2D right = CaptureCamera(camera, world.View.Texture); + world.Player.Facing = Vector2.left; + yield return new WaitForEndOfFrame(); + Assert.That(player.sprite.name, Is.EqualTo(world.Settings.PlayerArt.Idle[0].name)); + Texture2D left = CaptureCamera(camera, world.View.Texture); + + string output = System.Environment.GetEnvironmentVariable("DEFENSE25D_OUTPUT"); + if (string.IsNullOrWhiteSpace(output)) + output = Path.Combine(Path.GetTempPath(), "Defense25DValidation", "captures"); + Directory.CreateDirectory(output); + string rightPath = Path.Combine(output, "player-idle-facing-right.png"); + string leftPath = Path.Combine(output, "player-idle-facing-left.png"); + File.WriteAllBytes(rightPath, right.EncodeToPNG()); + File.WriteAllBytes(leftPath, left.EncodeToPNG()); + int directDifference = Difference(right, left, false); + int mirroredDifference = Difference(right, left, true); + + Object.DestroyImmediate(right); + Object.DestroyImmediate(left); + camera.clearFlags = savedClear; + camera.backgroundColor = savedBackground; + for (int i = 0; i < renderers.Length; i++) + if (renderers[i] != null) renderers[i].enabled = rendererStates[i]; + + Assert.That(player.flipX, Is.True, "Left facing must drive the SpriteRenderer flip state."); + Assert.That(directDifference, Is.GreaterThan(0), "The authored idle frame must be asymmetric."); + Assert.That(mirroredDifference, Is.LessThan(directDifference), + $"The actual left-facing capture must mirror the right-facing render; captures={rightPath}, {leftPath}."); + } + + private static Texture2D ReadPixels(RenderTexture source) + { + RenderTexture previous = RenderTexture.active; + RenderTexture.active = source; + Texture2D copy = new(source.width, source.height, TextureFormat.RGBA32, false); + copy.ReadPixels(new Rect(0, 0, source.width, source.height), 0, 0); + copy.Apply(); + RenderTexture.active = previous; + return copy; + } + + private static Texture2D CaptureCamera(Camera camera, RenderTexture source) + { + camera.Render(); + return ReadPixels(source); + } + + private static int Difference(Texture2D first, Texture2D second, bool mirrorSecond) + { + int width = first.width; + int height = first.height; + Color32[] a = first.GetPixels32(); + Color32[] b = second.GetPixels32(); + int left = width, right = -1, bottom = height, top = -1; + for (int y = 0; y < height; y++) + for (int x = 0; x < width; x++) + if (a[y * width + x].a > 0 || b[y * width + x].a > 0) + { + left = Mathf.Min(left, x); right = Mathf.Max(right, x); + bottom = Mathf.Min(bottom, y); top = Mathf.Max(top, y); + } + Assert.That(right, Is.GreaterThanOrEqualTo(left), "Both captures should contain the player."); + int difference = 0; + for (int y = bottom; y <= top; y++) + for (int x = left; x <= right; x++) + { + Color32 ca = a[y * width + x]; + Color32 cb = b[y * width + (mirrorSecond ? left + right - x : x)]; + difference += Mathf.Abs(ca.r - cb.r) + Mathf.Abs(ca.g - cb.g) + + Mathf.Abs(ca.b - cb.b) + Mathf.Abs(ca.a - cb.a); + } + return difference; + } + + private static Texture2D ReadSpriteSource(Sprite sprite) + { + Rect rect = sprite.textureRect; + int width = Mathf.RoundToInt(rect.width); + int height = Mathf.RoundToInt(rect.height); + RenderTexture previous = RenderTexture.active; + RenderTexture target = RenderTexture.GetTemporary(width, height, 0, + RenderTextureFormat.ARGB32, RenderTextureReadWrite.sRGB); + RenderTexture.active = target; + GL.PushMatrix(); + GL.LoadPixelMatrix(0, width, 0, height); + GL.Clear(true, true, Color.clear); + Rect uv = new(rect.x / sprite.texture.width, rect.y / sprite.texture.height, + rect.width / sprite.texture.width, rect.height / sprite.texture.height); + Graphics.DrawTexture(new Rect(0, 0, width, height), sprite.texture, uv, 0, 0, 0, 0); + GL.PopMatrix(); + + Texture2D copy = new(width, height, TextureFormat.RGBA32, false); + copy.ReadPixels(new Rect(0, 0, width, height), 0, 0); + copy.Apply(); + RenderTexture.active = previous; + RenderTexture.ReleaseTemporary(target); + return copy; + } + } +} diff --git a/Assets/_Project/Tests/PlayMode/Defense25DPixelAlignmentPlayModeTests.cs.meta b/Assets/_Project/Tests/PlayMode/Defense25DPixelAlignmentPlayModeTests.cs.meta new file mode 100644 index 0000000..8aa1de1 --- /dev/null +++ b/Assets/_Project/Tests/PlayMode/Defense25DPixelAlignmentPlayModeTests.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: f7940a39adb9442e8ee18b9d93abcc7e diff --git a/Assets/_Project/Tests/PlayMode/Defense25DPlayerArtPlayModeTests.cs b/Assets/_Project/Tests/PlayMode/Defense25DPlayerArtPlayModeTests.cs new file mode 100644 index 0000000..06eaf02 --- /dev/null +++ b/Assets/_Project/Tests/PlayMode/Defense25DPlayerArtPlayModeTests.cs @@ -0,0 +1,180 @@ +using System.Collections; +using System.Linq; +using BumpCombat.Constants; +using BumpCombat.Defense.Comparison; +using BumpCombat.Player; +using NUnit.Framework; +using UnityEditor; +using UnityEngine; +using UnityEngine.TestTools; +using Object = UnityEngine.Object; + +namespace BumpCombat.Tests +{ + public sealed class Defense25DPlayerArtPlayModeTests + { + private const string SettingsPath = + "Assets/_Project/Constants/Defense25DComparison/ComparisonSettings.asset"; + private Defense25DComparison world; + private Defense25DComparisonSettings settings; + private Defense25DPlayerArt playerArt; + + [UnitySetUp] + public IEnumerator SetUp() + { + settings = Object.Instantiate( + AssetDatabase.LoadAssetAtPath(SettingsPath)); + Assert.That(settings, Is.Not.Null); + settings.Enemy = AssetDatabase.LoadAssetAtPath( + "Assets/_Project/Constants/Enemies/Slime.asset"); + settings.PlayerArt = ReadCharacter("Swordsman", "Swordsman", "BumpAttack-v1"); + settings.EnemyArt = ReadCharacter("Slime", "Slime", "Attack"); + world = new GameObject("2.5D player art test world") + .AddComponent(); + world.Initialize(settings); + world.AutoSpawn = false; + world.ReadInput = false; + playerArt = world.Player.gameObject.AddComponent(); + playerArt.Bind(world); + yield return null; + } + + [UnityTearDown] + public IEnumerator TearDown() + { + if (world != null) Object.Destroy(world.gameObject); + if (settings != null) Object.Destroy(settings); + yield return null; + } + + [UnityTest] + public IEnumerator EquipmentSprite_UsesMatchingExistingFramesForEachPaletteAndRestores() + { + ActiveArtifactEffect[] effects = + { + ActiveArtifactEffect.Dash, + ActiveArtifactEffect.Pulse, + ActiveArtifactEffect.ChainLightning, + }; + SpriteRenderer body = world.Player.Visual.Renderer; + for (int i = 0; i < effects.Length; i++) + { + ActiveArtifactDefinition definition = world.Loadout.FindArtifact(effects[i]); + Assert.That(definition, Is.Not.Null, effects[i].ToString()); + Assert.That(world.Loadout.TryAddArtifact(definition), Is.True); + Assert.That(world.Loadout.Select(i), Is.True); + yield return null; + + Sprite tinted = body.sprite; + Assert.That(tinted, Is.Not.Null); + Assert.That(tinted.texture.name, + Is.EqualTo("Swordsman_Idle-" + definition.ArtifactColor + "-v1")); + Sprite original = settings.PlayerArt.Idle.FirstOrDefault( + frame => frame.rect == tinted.rect); + Assert.That(original, Is.Not.Null); + Assert.That(tinted.rect, Is.EqualTo(original.rect)); + Assert.That(tinted.pivot, Is.EqualTo(original.pivot)); + Assert.That(tinted.pixelsPerUnit, Is.EqualTo(original.pixelsPerUnit)); + playerArt.ResetVisuals(); + Assert.That(body.sprite.texture.name, Is.EqualTo("Swordsman_Idle")); + } + } + + [UnityTest] + public IEnumerator PrecoloredSequenceFrame_IsResolvedToBaseForSelectedPalette() + { + Sprite[] blueFrames = Resources.LoadAll( + "Artifacts/ThreeColor-v1/Player/Swordsman_Idle-Blue-v1"); + Sprite blueFrame = blueFrames.FirstOrDefault(frame => + settings.PlayerArt.Idle.Any(original => original.rect == frame.rect)); + Assert.That(blueFrame, Is.Not.Null); + + ActiveArtifactDefinition red = world.Loadout.FindArtifact(ActiveArtifactEffect.Pulse); + Assert.That(red, Is.Not.Null); + Assert.That(world.Loadout.TryAddArtifact(red), Is.True); + Assert.That(world.Loadout.Select(0), Is.True); + world.Player.Visual.Renderer.sprite = blueFrame; + yield return null; + + Sprite recolored = world.Player.Visual.Renderer.sprite; + Assert.That(recolored.texture.name, Is.EqualTo("Swordsman_Idle-Red-v1")); + Assert.That(recolored.rect, Is.EqualTo(blueFrame.rect)); + Assert.That(recolored.rect, Is.EqualTo(settings.PlayerArt.Idle + .First(original => original.rect == blueFrame.rect).rect)); + + playerArt.ResetVisuals(); + Assert.That(world.Player.Visual.Renderer.sprite.texture.name, Is.EqualTo("Swordsman_Idle")); + } + + [UnityTest] + public IEnumerator PrecoloredArtifactUseFrame_UsesBasePaletteAndGuardMaskKeys() + { + ActiveArtifactDefinition red = world.Loadout.FindArtifact(ActiveArtifactEffect.Pulse); + Assert.That(red, Is.Not.Null); + Assert.That(world.Loadout.TryAddArtifact(red), Is.True); + Assert.That(world.Loadout.Select(0), Is.True); + Sprite sequenceFrame = Resources.LoadAll( + "Artifacts/ThreeColor-v1/Player/Swordsman_ArtifactUse-v1-Blue-v1").First(); + SpriteRenderer body = world.Player.Visual.Renderer; + body.sprite = sequenceFrame; + world.Player.Visual.enabled = false; + Assert.That(world.Player.TryActivateGuard(), Is.True); + yield return null; + + SpriteRenderer outline = body.transform.Find("Player Guard Outline") + .GetComponent(); + Assert.That(outline.enabled, Is.True); + Assert.That(outline.sprite.texture.name, + Is.EqualTo("Swordsman_ArtifactUse-v1-outline")); + Assert.That(outline.sprite.rect, Is.EqualTo(sequenceFrame.rect)); + Assert.That(body.sprite.texture.name, + Is.EqualTo("Swordsman_ArtifactUse-v1-Red-v1")); + Assert.That(body.sprite.rect, Is.EqualTo(sequenceFrame.rect)); + } + + [UnityTest] + public IEnumerator DisabledWorld_DoesNotReapplyPaletteVisuals() + { + ActiveArtifactDefinition red = world.Loadout.FindArtifact(ActiveArtifactEffect.Pulse); + Assert.That(world.Loadout.TryAddArtifact(red), Is.True); + Assert.That(world.Loadout.Select(0), Is.True); + SpriteRenderer body = world.Player.Visual.Renderer; + Sprite original = settings.PlayerArt.Idle[0]; + body.sprite = original; + world.enabled = false; + yield return null; + + Assert.That(body.sprite, Is.SameAs(original)); + world.enabled = true; + } + + [UnityTest] + public IEnumerator UnguardedPlayer_HasNoOutlineAndResetRestoresSourceSprite() + { + SpriteRenderer body = world.Player.Visual.Renderer; + Assert.That(body.transform.Find("Player Guard Outline"), Is.Null, + "The art component has no local guard-state substitute; it follows the actor state."); + yield return null; + playerArt.ResetVisuals(); + Assert.That(body.sprite.texture.name, Is.EqualTo("Swordsman_Idle")); + + playerArt.enabled = false; + Assert.That(body.sprite.texture.name, Is.EqualTo("Swordsman_Idle")); + } + + private static ComparisonSpriteFrames ReadCharacter(string folder, string prefix, string attack) + { + Sprite[] Read(string suffix) => AssetDatabase.LoadAllAssetsAtPath( + $"Assets/_Project/Art/Characters/{folder}/{prefix}_{suffix}.png") + .OfType() + .OrderBy(sprite => int.TryParse( + sprite.name[(sprite.name.LastIndexOf('_') + 1)..], out int frame) ? frame : 0) + .ToArray(); + return new ComparisonSpriteFrames + { + Idle = Read("Idle"), Walk = Read("Walk"), Attack = Read(attack), + Hurt = Read("Hurt"), Death = Read("Death"), + }; + } + } +} diff --git a/Assets/_Project/Tests/PlayMode/Defense25DPlayerArtPlayModeTests.cs.meta b/Assets/_Project/Tests/PlayMode/Defense25DPlayerArtPlayModeTests.cs.meta new file mode 100644 index 0000000..fbf881f --- /dev/null +++ b/Assets/_Project/Tests/PlayMode/Defense25DPlayerArtPlayModeTests.cs.meta @@ -0,0 +1,13 @@ +fileFormatVersion: 2 +guid: 2b492f677ef5445a979e91e4200fe965 +labels: +- Test +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/_Project/Tests/PlayMode/Defense25DRelicReplayPlayModeTests.cs b/Assets/_Project/Tests/PlayMode/Defense25DRelicReplayPlayModeTests.cs new file mode 100644 index 0000000..4098142 --- /dev/null +++ b/Assets/_Project/Tests/PlayMode/Defense25DRelicReplayPlayModeTests.cs @@ -0,0 +1,172 @@ +using System.Collections; +using System.Reflection; +using BumpCombat.Combat; +using BumpCombat.Constants; +using BumpCombat.Defense.Comparison; +using BumpCombat.Enemies; +using BumpCombat.Player; +using BumpCombat.Progression; +using NUnit.Framework; +using UnityEditor; +using UnityEngine; +using UnityEngine.TestTools; +using Object = UnityEngine.Object; + +namespace BumpCombat.Tests +{ + public sealed class Defense25DRelicReplayPlayModeTests + { + private const string SettingsPath = "Assets/_Project/Constants/Defense25DComparison/ComparisonSettings.asset"; + private GameplayConstants originalConstants; + private GameplayConstants testConstants; + private PassiveRelicConstants testRelicTuning; + private Defense25DComparisonSettings settings; + private Defense25DComparison world; + private Defense25DEnemy enemy; + private ActiveArtifactDefinition phoenix; + private int ordinaryHits; + private int replayHits; + private int ordinaryHitFrame = -1; + private int replayHitFrame = -1; + private float previousTimeScale; + + [UnitySetUp] + public IEnumerator SetUp() + { + previousTimeScale = Time.timeScale; + Time.timeScale = 1f; + ordinaryHits = replayHits = 0; + ordinaryHitFrame = replayHitFrame = -1; + originalConstants = GameplayConstants.Current; + testConstants = Object.Instantiate(originalConstants); + testRelicTuning = Object.Instantiate(originalConstants.PassiveRelics); + testRelicTuning.ResonanceEchoChance = 1f; + testRelicTuning.ResonantCrystalChance = 1f; + testRelicTuning.ResonantCrystalDelaySeconds = .1f; + typeof(GameplayConstants).GetField("passiveRelics", BindingFlags.Instance | BindingFlags.NonPublic) + .SetValue(testConstants, testRelicTuning); + GameplayConstants.SetCurrentForTests(testConstants); + + settings = Object.Instantiate(AssetDatabase.LoadAssetAtPath(SettingsPath)); + Assert.That(settings, Is.Not.Null); + world = new GameObject("Relic replay integration world").AddComponent(); + world.Initialize(settings); + world.AutoSpawn = false; + world.ReadInput = false; + world.Player.ResetAt(Vector2.zero); + phoenix = world.Loadout.FindArtifact(ActiveArtifactEffect.Phoenix); + Assert.That(world.Loadout.TryAddArtifact(phoenix), Is.True); + testRelicTuning.ResonantCrystalDelaySeconds = .5f; + enemy = world.Enemies[0]; + enemy.Actor.SetEnemyMaximumHealth(1000f); + enemy.Spawn(Vector2.right * .45f); + Physics.SyncTransforms(); + CombatEvents.OnValidHit += OnValidHit; + yield return new WaitForFixedUpdate(); + } + + [UnityTearDown] + public IEnumerator TearDown() + { + CombatEvents.OnValidHit -= OnValidHit; + if (world != null) Object.Destroy(world.gameObject); + if (settings != null) Object.Destroy(settings); + if (testRelicTuning != null) Object.Destroy(testRelicTuning); + if (testConstants != null) Object.Destroy(testConstants); + GameplayConstants.SetCurrentForTests(originalConstants); + Time.timeScale = previousTimeScale; + yield return null; + } + + [UnityTest] + public IEnumerator ResonanceCoreReplaysImmediatelyWithoutExtraGaugeOrRelicRecursion() + { + Assert.That(world.Loadout.TryAcquireRelic(PassiveRelicId.ResonanceCore), Is.True); + world.Loadout.AddGauge(phoenix.NormalGaugeCost); + Assert.That(world.Loadout.Gauge, Is.GreaterThanOrEqualTo(phoenix.NormalGaugeCost)); + float initialHealth = enemy.Actor.CurrentHealth; + float initialGauge = world.Loadout.Gauge; + + Assert.That(world.Loadout.TryUse(false), Is.True); + Assert.That(ordinaryHits, Is.EqualTo(1)); + Assert.That(replayHits, Is.EqualTo(1)); + Assert.That(replayHitFrame, Is.EqualTo(ordinaryHitFrame), + "Echo damage must be applied synchronously in the original hit's frame."); + Assert.That(world.ArtifactExecutor.IsExecutingArtifact, Is.False); + + float expectedDamage = DamageCalculator.FinalizeDamage( + world.Player.CalculateDamage(phoenix.NormalDamage, phoenix.DamageTag)) + + DamageCalculator.FinalizeDamage(world.Player.CalculateDamage( + phoenix.NormalDamage * testRelicTuning.ResonanceEchoDamageMultiplier, phoenix.DamageTag)); + Assert.That(initialHealth - enemy.Actor.CurrentHealth, + Is.EqualTo(expectedDamage).Within(.02f)); + Assert.That(ordinaryHits, Is.EqualTo(1)); + Assert.That(replayHits, Is.EqualTo(1)); + Assert.That(world.Loadout.Gauge, + Is.EqualTo(initialGauge - phoenix.NormalGaugeCost).Within(.001f)); + + yield return null; + } + + [UnityTest] + public IEnumerator ResonantCrystalReplaysImmediatelyWhenDelayExpiresWithoutExtraGaugeOrAnotherPendingRepeat() + { + Assert.That(world.Loadout.TryAcquireRelic(PassiveRelicId.ResonantCrystal), Is.True); + world.Loadout.AddGauge(phoenix.NormalGaugeCost); + Assert.That(world.Loadout.Gauge, Is.GreaterThanOrEqualTo(phoenix.NormalGaugeCost)); + float initialHealth = enemy.Actor.CurrentHealth; + float initialGauge = world.Loadout.Gauge; + + Assert.That(world.Loadout.TryUse(false), Is.True); + Assert.That(world.RelicRuntime.IsRepeatPending, Is.True); + Assert.That(ordinaryHits, Is.EqualTo(1)); + Assert.That(replayHits, Is.Zero); + Assert.That(world.Loadout.Gauge, + Is.EqualTo(initialGauge - phoenix.NormalGaugeCost).Within(.001f)); + + float timeout = Time.realtimeSinceStartup + 2f; + while (world.RelicRuntime.IsRepeatPending && Time.realtimeSinceStartup < timeout) + { + yield return new WaitForFixedUpdate(); + Assert.That(world.Player.IsAlive, Is.True, "The player must remain alive throughout the repeat delay."); + Assert.That(enemy.Actor.IsAlive, Is.True, "The target must survive the initial Phoenix hit and ignite ticks."); + Assert.That(Vector2.Distance(world.Player.Position, enemy.Actor.Position), + Is.LessThanOrEqualTo(phoenix.NormalRange), "The target must stay within the replay ray during its delay."); + } + + float expectedDamage = DamageCalculator.FinalizeDamage( + world.Player.CalculateDamage(phoenix.NormalDamage, phoenix.DamageTag)) + + DamageCalculator.FinalizeDamage(world.Player.CalculateDamage( + phoenix.NormalDamage * testRelicTuning.ResonantCrystalDamageFraction, phoenix.DamageTag)); + Assert.That(world.RelicRuntime.IsRepeatPending, Is.False); + Assert.That(replayHits, Is.EqualTo(1)); + Assert.That(replayHitFrame, Is.GreaterThan(ordinaryHitFrame)); + Assert.That(replayHitFrame, Is.EqualTo(Time.frameCount), + "Repeat damage must land in the frame the relic delay expires, not in a later cast step."); + Assert.That(world.ArtifactExecutor.IsExecutingArtifact, Is.False); + Assert.That(initialHealth - enemy.Actor.CurrentHealth, + Is.EqualTo(expectedDamage).Within(.02f)); + Assert.That(ordinaryHits, Is.EqualTo(1)); + Assert.That(replayHits, Is.EqualTo(1)); + Assert.That(world.Loadout.Gauge, + Is.EqualTo(initialGauge - phoenix.NormalGaugeCost).Within(.001f)); + + yield return null; + } + + private void OnValidHit(CombatHitResult hit) + { + if (hit.Target != enemy?.gameObject || !hit.IsArtifactHit) return; + if (hit.SourceId == Defense25DRelicRuntime.EchoSourceId) + { + replayHits++; + replayHitFrame = Time.frameCount; + } + else if (hit.SourceId == phoenix?.ArtifactId) + { + ordinaryHits++; + ordinaryHitFrame = Time.frameCount; + } + } + } +} diff --git a/Assets/_Project/Tests/PlayMode/Defense25DRelicReplayPlayModeTests.cs.meta b/Assets/_Project/Tests/PlayMode/Defense25DRelicReplayPlayModeTests.cs.meta new file mode 100644 index 0000000..f47b8d9 --- /dev/null +++ b/Assets/_Project/Tests/PlayMode/Defense25DRelicReplayPlayModeTests.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 3e3510a879b4487f93b6235ebd55c7fc diff --git a/Assets/_Project/Tests/PlayMode/Defense25DRelicRuntimeTests.cs b/Assets/_Project/Tests/PlayMode/Defense25DRelicRuntimeTests.cs new file mode 100644 index 0000000..385f083 --- /dev/null +++ b/Assets/_Project/Tests/PlayMode/Defense25DRelicRuntimeTests.cs @@ -0,0 +1,351 @@ +using System.Collections.Generic; +using BumpCombat.Combat; +using BumpCombat.Constants; +using BumpCombat.Defense.Comparison; +using BumpCombat.Enemies; +using BumpCombat.Player; +using BumpCombat.Progression; +using NUnit.Framework; +using UnityEngine; +using Object = UnityEngine.Object; + +namespace BumpCombat.Tests +{ + public sealed class Defense25DRelicRuntimeTests + { + private PassiveRelicConstants tuning; + private Defense25DLoadout loadout; + private readonly List artifactDefinitions = new(); + + [SetUp] + public void SetUp() + { + tuning = ScriptableObject.CreateInstance(); + loadout = new Defense25DLoadout(0f, 100f, 6, 6); + } + + [TearDown] + public void TearDown() + { + Object.DestroyImmediate(tuning); + foreach (ActiveArtifactDefinition definition in artifactDefinitions) + Object.DestroyImmediate(definition); + artifactDefinitions.Clear(); + } + + [Test] + public void TwinHornOnlyDoublesOrdinaryUncoloredBumpsWhenTheConfiguredRollSucceeds() + { + tuning.TwinHornDoubleDamageChance = 1f; + loadout.TryAcquireRelic(PassiveRelicId.TwinHornTalisman); + Defense25DRelicRuntime runtime = NewRuntime(new Defense25DRelicEffects()); + + Assert.That(runtime.ApplyOrdinaryBumpDamage(10f, true, false), Is.EqualTo(20f)); + Assert.That(runtime.ApplyOrdinaryBumpDamage(10f, false, false), Is.EqualTo(10f)); + Assert.That(runtime.ApplyOrdinaryBumpDamage(10f, true, true), Is.EqualTo(10f)); + } + + [Test] + public void WaveNailAppliesSourceDamageToNearbyEnemiesAndStartsCooldownOnlyOnAppliedHit() + { + tuning.WaveNailChance = 1f; + tuning.WaveNailDamageFraction = .6f; + tuning.WaveNailCooldownSeconds = 1.5f; + loadout.TryAcquireRelic(PassiveRelicId.WaveNail); + int applied = 0; + int attempts = 0; + bool allowDamage = false; + float observedDamage = 0f; + EnemyDamageSource observedSource = EnemyDamageSource.None; + Defense25DRelicTarget nearby = Target(2, new Vector3(1f, 0f, 0f)); + Defense25DRelicEffects effects = new() + { + QueryEnemies = (_, _) => new[] { nearby }, + DamageEnemy = (_, damage, _, source) => + { + attempts++; + observedDamage = damage; + observedSource = source; + if (!allowDamage) return false; + applied++; + return true; + }, + }; + Defense25DRelicRuntime runtime = NewRuntime(effects); + runtime.BodyDiameterProvider = () => 1f; + runtime.NotifyHit(new Defense25DRelicHit(Target(1, Vector3.zero), Vector3.zero, + Vector3.right, 10f, 10f, true), 100f); + allowDamage = true; + runtime.NotifyHit(new Defense25DRelicHit(Target(0, Vector3.zero), Vector3.zero, + Vector3.right, 10f, 10f, true), 100f); + runtime.Advance(1f, 100f); + runtime.NotifyHit(new Defense25DRelicHit(Target(0, Vector3.zero), Vector3.zero, + Vector3.right, 10f, 10f, true), 100f); + + Assert.That(applied, Is.EqualTo(1)); + Assert.That(attempts, Is.EqualTo(2)); + Assert.That(observedDamage, Is.EqualTo(6f)); + Assert.That(observedSource, Is.EqualTo(EnemyDamageSource.PassiveRelicAttack)); + } + + [Test] + public void CounterBellSlowsEveryLivingEnemyInItsQueryInsteadOfStoppingAtFirstSuccess() + { + tuning.CounterBellChance = 1f; + loadout.TryAcquireRelic(PassiveRelicId.CounterBell); + List slowed = new(); + Defense25DRelicTarget[] targets = + { + Target(1, Vector3.zero), + Target(2, Vector3.right), + new(3, Vector3.forward, 10f, 10f, EnemyDamageSource.None, true), + }; + Defense25DRelicRuntime runtime = NewRuntime(new Defense25DRelicEffects + { + QueryEnemies = (_, _) => targets, + ApplySlow = (target, _, _) => { slowed.Add(target.Identity); return true; }, + }); + runtime.BodyDiameterProvider = () => 1f; + + runtime.NotifyPlayerDamageTaken(1f, 99f, 100f, Vector3.zero); + + Assert.That(slowed, Is.EquivalentTo(new[] { 1, 2 })); + } + + [Test] + public void LethalValidHitStillRunsWaveNailBloodFangAndResonanceCore() + { + tuning.WaveNailChance = 1f; + tuning.ResonanceEchoChance = 1f; + tuning.BloodFangChance = 1f; + loadout.TryAcquireRelic(PassiveRelicId.WaveNail); + loadout.TryAcquireRelic(PassiveRelicId.BloodFang); + loadout.TryAcquireRelic(PassiveRelicId.ResonanceCore); + ActiveArtifactDefinition artifact = Artifact(ActiveArtifactEffect.Pulse); + Assert.That(loadout.TryAddArtifact(artifact), Is.True); + int damageCalls = 0, healCalls = 0, echoCalls = 0; + Defense25DRelicTarget nearby = Target(11, Vector3.right); + Defense25DRelicRuntime runtime = NewRuntime(new Defense25DRelicEffects + { + QueryEnemies = (_, _) => new[] { nearby }, + DamageEnemy = (_, _, _, _) => { damageCalls++; return true; }, + HealPlayer = _ => { healCalls++; return true; }, + EmitEcho = (use, _, _, _) => + { + Assert.That(use.Definition, Is.SameAs(artifact)); + echoCalls++; + return true; + }, + }); + runtime.BodyDiameterProvider = () => 1f; + runtime.NotifyHit(new Defense25DRelicHit( + Target(10, Vector3.zero, isDead: true), Vector3.zero, Vector3.right, + 10f, 10f, true), 50f, 100f); + + Assert.That(damageCalls, Is.EqualTo(1)); + Assert.That(healCalls, Is.EqualTo(1)); + Assert.That(echoCalls, Is.EqualTo(1)); + } + + [Test] + public void WaveNailCanDamageTheOriginalHitTargetWhenItSurvivesTheBump() + { + tuning.WaveNailChance = 1f; + loadout.TryAcquireRelic(PassiveRelicId.WaveNail); + List damaged = new(); + Defense25DRelicTarget struck = Target(0, Vector3.zero); + Defense25DRelicTarget neighbor = Target(1, Vector3.right); + Defense25DRelicRuntime runtime = NewRuntime(new Defense25DRelicEffects + { + QueryEnemies = (_, _) => new[] { struck, neighbor }, + DamageEnemy = (target, _, _, _) => { damaged.Add(target.Identity); return true; }, + }); + runtime.BodyDiameterProvider = () => 1f; + + runtime.NotifyHit(new Defense25DRelicHit(struck, Vector3.zero, Vector3.right, + 10f, 10f, true), 100f); + + Assert.That(damaged, Is.EquivalentTo(new[] { 0, 1 })); + } + + [Test] + public void WaveNailIncludesAnEnemyWhoseBodyOverlapsTheAreaAtItsOuterEdge() + { + tuning.WaveNailChance = 1f; + loadout.TryAcquireRelic(PassiveRelicId.WaveNail); + List damaged = new(); + Defense25DRelicTarget boundaryTarget = Target(12, new Vector3(3.2f, 0f, 0f)); + const float targetBodyRadius = .3f; + Defense25DRelicRuntime runtime = NewRuntime(new Defense25DRelicEffects + { + QueryEnemies = (center, areaRadius) => + Vector3.Distance(new Vector3(center.x, 0f, center.z), + new Vector3(boundaryTarget.Position.x, 0f, boundaryTarget.Position.z)) + <= areaRadius + targetBodyRadius + ? new[] { boundaryTarget } + : System.Array.Empty(), + DamageEnemy = (target, _, _, _) => { damaged.Add(target.Identity); return true; }, + }); + runtime.BodyDiameterProvider = () => 1f; + + runtime.NotifyHit(new Defense25DRelicHit(Target(0, Vector3.zero), Vector3.zero, + Vector3.right, 10f, 10f, true), 100f); + + Assert.That(damaged, Is.EqualTo(new[] { boundaryTarget.Identity })); + } + + [Test] + public void RiftWedgeAppliesThunderCrashVulnerabilityOnTheConfiguredOrdinaryHitCount() + { + tuning.RiftWedgeRequiredHits = 3; + loadout.TryAcquireRelic(PassiveRelicId.RiftWedge); + ActiveArtifactDefinition thunder = Artifact(ActiveArtifactEffect.ThunderCrash, + normalVulnerabilityDuration: 2.5f, normalVulnerabilityIncrease: .3f); + loadout.ConfigureCatalog(new[] { thunder }); + int applied = 0; + Defense25DRelicEffects effects = new() + { + ApplyVulnerability = (_, duration, increase) => + { + Assert.That(duration, Is.EqualTo(2.5f)); + Assert.That(increase, Is.EqualTo(.3f)); + applied++; + return true; + }, + }; + Defense25DRelicRuntime runtime = NewRuntime(effects); + Defense25DRelicTarget target = Target(7, Vector3.zero); + + for (int i = 0; i < 2; i++) + runtime.NotifyHit(new Defense25DRelicHit(target, Vector3.zero, Vector3.right, + 1f, 0f, true), 100f); + Assert.That(applied, Is.Zero); + runtime.NotifyHit(new Defense25DRelicHit(target, Vector3.zero, Vector3.right, + 1f, 0f, true), 100f); + + Assert.That(applied, Is.EqualTo(1)); + } + + [Test] + public void BondLedgerPaysOnlyForTowerKillsAfterRecentBumpAndDeduplicatesDeaths() + { + tuning.BondLedgerChance = 1f; + tuning.BondLedgerCurrency = 2; + tuning.BondLedgerBumpWindowSeconds = 3f; + loadout.TryAcquireRelic(PassiveRelicId.BondLedger); + int currency = 0; + Defense25DRelicRuntime runtime = NewRuntime(new Defense25DRelicEffects + { + AwardCurrency = amount => { currency += amount; return true; }, + }); + Defense25DRelicTarget target = Target(0, Vector3.zero); + runtime.NotifyHit(new Defense25DRelicHit(target, Vector3.zero, Vector3.right, + 1f, 0f, true), 100f); + runtime.Advance(1f, 100f); + Defense25DRelicTarget towerKill = Target(0, Vector3.zero, EnemyDamageSource.Tower); + + runtime.NotifyEnemyDeath(towerKill, 100f); + runtime.NotifyEnemyDeath(towerKill, 100f); + + Assert.That(currency, Is.EqualTo(2)); + } + + [Test] + public void RunResetClearsRuntimeTimersAndTemporaryModifiersThroughOwnerCallbacks() + { + tuning.HunterFeatherChance = 1f; + loadout.TryAcquireRelic(PassiveRelicId.HunterFeather); + int added = 0, removed = 0; + Defense25DRelicRuntime runtime = NewRuntime(new Defense25DRelicEffects + { + SetTimedPlayerModifier = (_, _) => added++, + RemovePlayerModifier = _ => removed++, + }); + runtime.NotifyEnemyDeath(Target(8, Vector3.zero, EnemyDamageSource.Player), 100f); + Assert.That(runtime.HunterExpiresAt, Is.GreaterThan(0f)); + + runtime.ResetRun(4f); + + Assert.That(runtime.IsActive, Is.False); + Assert.That(runtime.HunterExpiresAt, Is.Zero); + Assert.That(added, Is.EqualTo(1)); + Assert.That(removed, Is.GreaterThanOrEqualTo(1)); + } + + [Test] + public void ResonantCrystalRepeatExpiresIfItsDelayCompletesWhilePlayerIsDead() + { + tuning.ResonantCrystalChance = 1f; + tuning.ResonantCrystalDelaySeconds = 1f; + ActiveArtifactDefinition artifact = Artifact(ActiveArtifactEffect.Cyclone); + Assert.That(loadout.TryAddArtifact(artifact), Is.True); + Assert.That(loadout.TryAcquireRelic(PassiveRelicId.ResonantCrystal), Is.True); + int repeats = 0; + Defense25DRelicRuntime runtime = NewRuntime(new Defense25DRelicEffects + { + EmitRepeat = (_, _, _, _) => { repeats++; return true; }, + }); + + runtime.NotifyArtifactUse(new Defense25DArtifactUse(artifact, false), + Vector3.zero, Vector3.right, 100f); + Assert.That(runtime.IsRepeatPending, Is.True); + + runtime.Advance(1f, 0f); + runtime.Advance(2f, 100f); + + Assert.That(runtime.IsRepeatPending, Is.False); + Assert.That(repeats, Is.Zero); + } + + [Test] + public void ResonantCrystalRepeatStillFiresAfterDeathIfDelayHasNotEndedYet() + { + tuning.ResonantCrystalChance = 1f; + tuning.ResonantCrystalDelaySeconds = 1f; + ActiveArtifactDefinition artifact = Artifact(ActiveArtifactEffect.Cyclone); + Assert.That(loadout.TryAddArtifact(artifact), Is.True); + Assert.That(loadout.TryAcquireRelic(PassiveRelicId.ResonantCrystal), Is.True); + int repeats = 0; + Defense25DRelicRuntime runtime = NewRuntime(new Defense25DRelicEffects + { + EmitRepeat = (_, _, _, _) => { repeats++; return true; }, + }); + + runtime.NotifyArtifactUse(new Defense25DArtifactUse(artifact, false), + Vector3.zero, Vector3.right, 100f); + runtime.Advance(.5f, 0f); + Assert.That(runtime.IsRepeatPending, Is.True); + + runtime.Advance(1f, 100f); + + Assert.That(runtime.IsRepeatPending, Is.False); + Assert.That(repeats, Is.EqualTo(1)); + } + + private Defense25DRelicRuntime NewRuntime(Defense25DRelicEffects effects) + { + Defense25DRelicRuntime runtime = new(loadout, tuning, effects, () => 0f); + runtime.BeginRun(); + return runtime; + } + + private ActiveArtifactDefinition Artifact( + ActiveArtifactEffect effect, float normalVulnerabilityDuration = 0f, + float normalVulnerabilityIncrease = 0f) + { + ActiveArtifactDefinition definition = ScriptableObject.CreateInstance(); + definition.Configure(effect.ToString(), effect.ToString(), "*", effect, + DamageTag.Collision, Color.white, Color.white, 0f, 0f, 1f, + 1f, 1f, 1f, 1f, 1f, 1f, + normalBumpVulnerabilityIncrease: normalVulnerabilityIncrease, + normalBumpVulnerabilityDuration: normalVulnerabilityDuration); + artifactDefinitions.Add(definition); + return definition; + } + + private static Defense25DRelicTarget Target(int id, Vector3 position, + EnemyDamageSource source = EnemyDamageSource.None, bool isDead = false) => + new(id, position, 100f, 100f, source, isDead); + } + +} diff --git a/Assets/_Project/Tests/PlayMode/Defense25DRelicRuntimeTests.cs.meta b/Assets/_Project/Tests/PlayMode/Defense25DRelicRuntimeTests.cs.meta new file mode 100644 index 0000000..2df9a55 --- /dev/null +++ b/Assets/_Project/Tests/PlayMode/Defense25DRelicRuntimeTests.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 4d8d21d279da421a87e8af5b91db0012 diff --git a/Assets/_Project/Tests/PlayMode/Defense25DSavedSceneViewPlayModeTests.cs b/Assets/_Project/Tests/PlayMode/Defense25DSavedSceneViewPlayModeTests.cs new file mode 100644 index 0000000..4ef13d6 --- /dev/null +++ b/Assets/_Project/Tests/PlayMode/Defense25DSavedSceneViewPlayModeTests.cs @@ -0,0 +1,329 @@ +using System.Collections; +using System.Linq; +using BumpCombat.Constants; +using BumpCombat.Defense; +using BumpCombat.Core; +using BumpCombat.Defense.Comparison; +using NUnit.Framework; +using UnityEditor.SceneManagement; +using UnityEngine; +using UnityEngine.SceneManagement; +using UnityEngine.TestTools; +using Object = UnityEngine.Object; + +namespace BumpCombat.Tests +{ + public sealed class Defense25DSavedSceneViewPlayModeTests + { + private const string ScenePath = "Assets/_Project/Scenes/Defense25DComparison.unity"; + private Defense25DComparison world; + + [UnityTest] + public IEnumerator SavedSceneShowsAllBuildPadsRoutesAndPlayerWithNormalWallOcclusion() + { + Time.timeScale = 1f; + yield return EditorSceneManager.LoadSceneAsyncInPlayMode(ScenePath, + new LoadSceneParameters(LoadSceneMode.Single)); + world = Object.FindAnyObjectByType(); + yield return null; + + Assert.That(world, Is.Not.Null); + world.SetPaused(true); + Camera camera = world.View.Camera; + Assert.That(camera.transform.position.y, Is.GreaterThan(world.Settings.GroundY), + "The defense presentation camera must remain above the ground plane."); + Ray centerRay = camera.ViewportPointToRay(new Vector3(.5f, .5f)); + Plane groundPlane = new(Vector3.up, new Vector3(0f, world.Settings.GroundY, 0f)); + Assert.That(groundPlane.Raycast(centerRay, out float focusDistance), Is.True, + "The camera focus ray must intersect the defense ground plane."); + Assert.That((centerRay.GetPoint(focusDistance).y - world.Settings.GroundY), + Is.EqualTo(0f).Within(.001f)); + camera.Render(); + Color32[] pixels = ReadPixels(world.View.Texture); + + for (int pad = 0; pad < 6; pad++) + { + Vector2 ground = GameplayConstants.Current.Defense.GetBuildPadPosition(pad); + Vector3 padCenter = Defense25DActor.OnGround(ground, world.Settings.GroundY + .035f); + Vector3 screen = camera.WorldToScreenPoint(padCenter); + Assert.That(screen.z, Is.GreaterThan(camera.nearClipPlane), $"Pad {pad} must face the camera."); + Assert.That(screen.x, Is.InRange(8f, Defense25DView.Width - 8f), $"Pad {pad} must be on-screen."); + Assert.That(screen.y, Is.InRange(8f, Defense25DView.Height - 8f), $"Pad {pad} must be on-screen."); + GameObject padObject = GameObject.Find($"Tower build pad {pad + 1}"); + Assert.That(padObject, Is.Not.Null); + Renderer padRenderer = padObject.GetComponent(); + Assert.That(RendererContributesPixels(camera, pixels, padRenderer, screen), Is.GreaterThan(3), + $"Pad {pad} must contribute pixels through normal depth rendering."); + + Rect viewport = Defense25DView.GetViewport(Screen.width, Screen.height); + Vector2 displayPoint = viewport.position + new Vector2( + screen.x / Defense25DView.Width * viewport.width, + screen.y / Defense25DView.Height * viewport.height); + Assert.That(world.View.TryScreenToGround(displayPoint, world.Settings.GroundY, out Vector2 hit), + Is.True, $"Pad {pad} must have a usable screen-space interaction point."); + Assert.That(Vector2.Distance(hit, ground), Is.LessThan(.2f), $"Pad {pad} hit point must map to its ground position."); + } + + foreach (Vector2 point in new[] + { + GameplayConstants.Current.Defense.GetPortalPosition(0), + GameplayConstants.Current.Defense.GetWaypointPosition(0, 0), + GameplayConstants.Current.Defense.GetPortalPosition(1), + GameplayConstants.Current.Defense.GetWaypointPosition(1, 0), + }) + { + Vector3 routePoint = Defense25DActor.OnGround(point, world.Settings.GroundY + .06f); + Vector3 screen = camera.WorldToScreenPoint(routePoint); + Assert.That(screen.z, Is.GreaterThan(camera.nearClipPlane)); + Assert.That(screen.x, Is.InRange(0f, Defense25DView.Width)); + Assert.That(screen.y, Is.InRange(0f, Defense25DView.Height)); + Assert.That(CountMaterialPixels(pixels, screen, world.Settings.PathMaterial.color), Is.GreaterThan(0), + $"Route marker at {point} must be pixel-visible."); + } + + GameObject baseMarker = GameObject.Find("Shared base marker"); + Assert.That(baseMarker, Is.Not.Null); + Renderer baseRenderer = baseMarker.GetComponent(); + Vector3 baseScreen = camera.WorldToScreenPoint(baseRenderer.bounds.center); + Assert.That(RendererContributesPixels(camera, pixels, baseRenderer, baseScreen), Is.GreaterThan(0), + "The saved base marker itself must contribute visible pixels using its BaseMaterial."); + + AssertActorVisible(pixels, world.Player.transform.position); + + GameObject wall = GameObject.Find("South boundary"); + Assert.That(wall, Is.Not.Null); + Collider wallCollider = wall.GetComponent(); + Renderer wallRenderer = wall.GetComponent(); + Assert.That(wallCollider != null && wallCollider.enabled, Is.True, "The saved wall collider must remain enabled."); + Assert.That(wallRenderer != null && wallRenderer.enabled, Is.True); + Assert.That(wallCollider.bounds.size.y, Is.EqualTo(1.2f).Within(.02f), + "The boundary collision height must remain unchanged."); + Assert.That(wallRenderer.bounds.size.y, Is.LessThan(wallCollider.bounds.size.y), + "Only the rendered south boundary is lowered to expose the build pads."); + + Bounds renderedWall = wallRenderer.bounds; + Vector3 wallSurfacePoint = new(renderedWall.center.x, + Mathf.Lerp(renderedWall.min.y, renderedWall.max.y, .75f), renderedWall.max.z); + Vector3 wallSurfaceScreen = camera.WorldToScreenPoint(wallSurfacePoint); + Ray ray = camera.ViewportPointToRay(new Vector3(wallSurfaceScreen.x / Defense25DView.Width, + wallSurfaceScreen.y / Defense25DView.Height)); + Plane depthGroundPlane = new(Vector3.up, new Vector3(0f, world.Settings.GroundY + .25f, 0f)); + Assert.That(depthGroundPlane.Raycast(ray, out float groundDistance), Is.True); + Vector3 behindWall = ray.GetPoint(groundDistance); + Vector3 behindScreen = camera.WorldToScreenPoint(behindWall); + string probeGeometry = $"probeWorld={behindWall}, probeScreen={behindScreen}, " + + $"wallBounds={renderedWall}, cameraDepth=[{camera.nearClipPlane}, {camera.farClipPlane}]"; + Assert.That(behindScreen.z, Is.InRange(camera.nearClipPlane, camera.farClipPlane), + $"The ground probe must be inside camera depth range: {probeGeometry}"); + Assert.That(behindScreen.x, Is.InRange(0f, Defense25DView.Width), + $"The ground probe must project inside the render target: {probeGeometry}"); + Assert.That(behindScreen.y, Is.InRange(0f, Defense25DView.Height), + $"The ground probe must project inside the render target: {probeGeometry}"); + Assert.That(behindWall.z, Is.GreaterThan(renderedWall.max.z), + $"The depth probe must be on the ground behind the rendered wall bounds: {probeGeometry}"); + Assert.That(Physics.Raycast(ray, out RaycastHit wallHit, Vector3.Distance(camera.transform.position, behindWall), + ~0, QueryTriggerInteraction.Ignore) && wallHit.collider == wallCollider, Is.True, + "The wall must intersect the sightline from its visible mesh to ground geometry behind it."); + + GameObject depthProbe = GameObject.CreatePrimitive(PrimitiveType.Cube); + depthProbe.transform.position = behindWall; + depthProbe.transform.localScale = new Vector3(.12f, .12f, .04f); + Renderer probeRenderer = depthProbe.GetComponent(); + probeRenderer.sharedMaterial = world.Settings.PathMaterial; + Object.DestroyImmediate(depthProbe.GetComponent()); + camera.Render(); + Color32[] wallWithProbe = ReadPixels(world.View.Texture); + probeRenderer.enabled = false; + camera.Render(); + Color32[] wallWithoutProbe = ReadPixels(world.View.Texture); + probeRenderer.enabled = true; + Assert.That(DifferentPixelsNear(wallWithProbe, wallWithoutProbe, behindScreen, 18), Is.Zero, + "Normal wall depth must hide a ground-level probe behind the visible wall mesh."); + wallRenderer.enabled = false; + camera.Render(); + Color32[] exposedWithProbe = ReadPixels(world.View.Texture); + probeRenderer.enabled = false; + camera.Render(); + Color32[] exposedWithoutProbe = ReadPixels(world.View.Texture); + probeRenderer.enabled = true; + wallRenderer.enabled = true; + int exposedProbePixels = DifferentPixelsNear(exposedWithProbe, exposedWithoutProbe, behindScreen, 18); + Assert.That(exposedProbePixels, Is.GreaterThan(4), + $"Hiding the wall must reveal actual ground-level probe pixels through ordinary depth occlusion; " + + $"changedPixels={exposedProbePixels}, probeBounds={probeRenderer.bounds}, {probeGeometry}"); + Object.DestroyImmediate(depthProbe); + } + + [UnityTest] + public IEnumerator SavedSceneSummonSlotsClearTheSouthWallAndCanAttack() + { + Time.timeScale = 1f; + yield return EditorSceneManager.LoadSceneAsyncInPlayMode(ScenePath, + new LoadSceneParameters(LoadSceneMode.Single)); + world = Object.FindAnyObjectByType(); + yield return null; + Assert.That(world, Is.Not.Null); + world.ReadInput = false; + world.AutoSpawn = false; + + var choose = typeof(Defense25DComparison).GetMethod("ChooseStartupArtifact", + System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic); + Assert.That(choose, Is.Not.Null); + for (int i = 0; world.Paused && i < 3; i++) + Assert.That(choose.Invoke(world, new object[] { 0 }), Is.EqualTo(true)); + Assert.That(world.Paused, Is.False); + + DefenseConstants tuning = GameplayConstants.Current.Defense; + Vector2 pad = tuning.GetBuildPadPosition(1); + world.TowerManager.AddCurrency(1000); + world.Player.ResetAt(pad); + Physics.SyncTransforms(); + Assert.That(world.TowerManager.TryBuildTowerAtPad(1, DefenseTowerKind.Summon), Is.True); + Defense25DTower tower = world.TowerManager.GetTowerAtPad(1); + BoxCollider southWall = GameObject.Find("South boundary").GetComponent(); + BoxCollider northWall = GameObject.Find("North boundary").GetComponent(); + BoxCollider eastWall = GameObject.Find("East boundary").GetComponent(); + BoxCollider westWall = GameObject.Find("West boundary").GetComponent(); + Bounds groundBounds = GameObject.Find("XZ ground").GetComponent().bounds; + Assert.That(groundBounds.min.z, Is.LessThanOrEqualTo(southWall.bounds.min.z + .001f), + "The runtime ground must span through the moved south wall thickness."); + Assert.That(groundBounds.max.z, Is.GreaterThanOrEqualTo(northWall.bounds.max.z - .001f), + "The runtime ground must span through the moved north wall thickness."); + Assert.That(eastWall.bounds.min.z, Is.LessThanOrEqualTo(southWall.bounds.min.z + .001f)); + Assert.That(eastWall.bounds.max.z, Is.GreaterThanOrEqualTo(northWall.bounds.max.z - .001f)); + Assert.That(westWall.bounds.min.z, Is.LessThanOrEqualTo(southWall.bounds.min.z + .001f)); + Assert.That(westWall.bounds.max.z, Is.GreaterThanOrEqualTo(northWall.bounds.max.z - .001f)); + Assert.That(southWall.bounds.Intersects(eastWall.bounds), Is.True, + "The expanded south and east wall colliders must remain connected."); + Assert.That(southWall.bounds.Intersects(westWall.bounds), Is.True); + Assert.That(northWall.bounds.Intersects(eastWall.bounds), Is.True); + Assert.That(northWall.bounds.Intersects(westWall.bounds), Is.True); + float deepestT4Slot = pad.y - 1.35f; + Assert.That(southWall.bounds.max.z, Is.LessThan(deepestT4Slot + - tuning.SummonUnitCollisionRadius * 1.55f), + "The saved comparison boundary must leave room for the largest T4 pack slot and variant footprint."); + Vector3 deepestSlotScreen = world.View.Camera.WorldToScreenPoint( + Defense25DActor.OnGround(new Vector2(pad.x, deepestT4Slot), world.Settings.GroundY) + + Vector3.up * .45f); + Assert.That(deepestSlotScreen.z, Is.GreaterThan(world.View.Camera.nearClipPlane)); + Assert.That(deepestSlotScreen.x, Is.InRange(0f, Defense25DView.Width)); + Assert.That(deepestSlotScreen.y, Is.InRange(0f, Defense25DView.Height), + "The expanded slot footprint must remain inside the saved comparison camera view."); + + foreach (Defense25DSummon summon in world.Summons.Where(item => item.Owner == tower)) + { + Vector2 originalSlot = pad + new Vector2((summon.SlotIndex % 3 - 1) * .8f, + -.55f - .8f * (summon.SlotIndex / 3)); + Assert.That(summon.Actor.Position, Is.EqualTo(originalSlot), + "Runtime map clearance must not rewrite the original summon slot policy."); + Assert.That(Physics.ComputePenetration(summon.Actor.BodyCollider, summon.transform.position, + summon.transform.rotation, southWall, southWall.transform.position, southWall.transform.rotation, + out _, out _), Is.False, $"Summon slot {summon.SlotIndex} must not spawn inside the saved wall."); + } + + Defense25DSummon[] pack = world.Summons.Where(item => item.Owner == tower).ToArray(); + Assert.That(pack.Length, Is.GreaterThan(0)); + Defense25DEnemy target = world.SpawnNextEnemy(); + Assert.That(target.Lane, Is.EqualTo(0)); + target.Actor.SetEnemyMaximumHealth(1000f); + target.Actor.ResetAt(DefenseTowerHitTestLayout.GetDummyPosition(DefenseTowerKind.Summon)); + target.ApplyStun(20f); + world.Player.ResetAt(Vector2.zero); + Physics.SyncTransforms(); + float attackDeadline = Time.realtimeSinceStartup + 8f; + while (pack.All(item => item.AttackEvents == 0) + && Time.realtimeSinceStartup < attackDeadline) + yield return new WaitForFixedUpdate(); + string packState = string.Join("; ", pack.Select(item => + { + Collider blocker = item.Actor.LastMovementBlocker; + string blockerState = blocker == null ? "none" + : $"{blocker.name}[layer={LayerMask.LayerToName(blocker.gameObject.layer)} " + + $"bounds={blocker.bounds}]"; + return $"slot={item.SlotIndex} position={item.Actor.Position} " + + $"blocked={item.Actor.MovementBlocked} blocker={blockerState} " + + $"gap={item.Actor.GapTo(target.Actor):0.000} attacking={item.IsAttacking} " + + $"hits={item.AttackEvents} lastMove={item.Actor.LastMove}"; + })); + Assert.That(pack.Sum(item => item.AttackEvents), Is.GreaterThan(0), + $"At least one summon in the saved-scene pack must attack with the south boundary present. " + + $"commandTarget={tower.CommandTarget}, targetAvailable={target.IsAvailable}, " + + $"gameTime={world.GameTime:0.00}, playerAlive={world.Player.IsAlive}, " + + $"targetPosition={target.Actor.Position}; pack: {packState}"); + Assert.That(target.Actor.CurrentHealth, Is.LessThan(target.Actor.MaxHealth)); + } + + private static void AssertActorVisible(Color32[] pixels, Vector3 worldPosition) + { + Defense25DView view = Object.FindAnyObjectByType(); + Vector3 screen = view.Camera.WorldToScreenPoint(worldPosition + Vector3.up * .45f); + Assert.That(screen.z, Is.GreaterThan(view.Camera.nearClipPlane)); + int different = 0; + for (int y = -12; y <= 12; y++) + for (int x = -10; x <= 10; x++) + { + int px = Mathf.RoundToInt(screen.x) + x, py = Mathf.RoundToInt(screen.y) + y; + if (px < 0 || px >= Defense25DView.Width || py < 0 || py >= Defense25DView.Height) continue; + Color32 pixel = pixels[py * Defense25DView.Width + px]; + if (pixel.r > 90 || pixel.g > 90 || pixel.b > 90) different++; + } + Assert.That(different, Is.GreaterThan(3), "The player sprite must contribute visible pixels."); + } + + private static int CountMaterialPixels(Color32[] pixels, Vector3 screen, Color expected) + { + int count = 0; + int cx = Mathf.RoundToInt(screen.x), cy = Mathf.RoundToInt(screen.y); + for (int y = -10; y <= 10; y++) + for (int x = -10; x <= 10; x++) + { + int px = cx + x, py = cy + y; + if (px < 0 || px >= Defense25DView.Width || py < 0 || py >= Defense25DView.Height) continue; + Color32 pixel = pixels[py * Defense25DView.Width + px]; + if (Mathf.Abs(pixel.r - expected.r * 255f) < 45f + && Mathf.Abs(pixel.g - expected.g * 255f) < 45f + && Mathf.Abs(pixel.b - expected.b * 255f) < 45f) count++; + } + return count; + } + + private static int RendererContributesPixels(Camera camera, Color32[] visible, + Renderer renderer, Vector3 screen) + { + bool wasEnabled = renderer.enabled; + renderer.enabled = false; + camera.Render(); + Color32[] hidden = ReadPixels(camera.targetTexture as RenderTexture); + renderer.enabled = wasEnabled; + camera.Render(); + return DifferentPixelsNear(visible, hidden, screen, 18); + } + + private static int DifferentPixelsNear(Color32[] a, Color32[] b, Vector3 center, int radius) + { + int count = 0, cx = Mathf.RoundToInt(center.x), cy = Mathf.RoundToInt(center.y); + for (int y = -radius; y <= radius; y++) + for (int x = -radius; x <= radius; x++) + { + int px = cx + x, py = cy + y; + if (px < 0 || px >= Defense25DView.Width || py < 0 || py >= Defense25DView.Height) continue; + int index = py * Defense25DView.Width + px; + if (!a[index].Equals(b[index])) count++; + } + return count; + } + + private static Color32[] ReadPixels(RenderTexture texture) + { + RenderTexture previous = RenderTexture.active; + RenderTexture.active = texture; + Texture2D image = new(Defense25DView.Width, Defense25DView.Height, TextureFormat.RGB24, false); + image.ReadPixels(new Rect(0, 0, Defense25DView.Width, Defense25DView.Height), 0, 0); + image.Apply(); + Color32[] pixels = image.GetPixels32(); + RenderTexture.active = previous; + Object.DestroyImmediate(image); + return pixels; + } + } +} diff --git a/Assets/_Project/Tests/PlayMode/Defense25DSavedSceneViewPlayModeTests.cs.meta b/Assets/_Project/Tests/PlayMode/Defense25DSavedSceneViewPlayModeTests.cs.meta new file mode 100644 index 0000000..2bea55c --- /dev/null +++ b/Assets/_Project/Tests/PlayMode/Defense25DSavedSceneViewPlayModeTests.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: d09d0d9e789c4b6686ed1292a3e0f86f diff --git a/Assets/_Project/Tests/PlayMode/Defense25DSkeletonCombatPlayModeTests.cs b/Assets/_Project/Tests/PlayMode/Defense25DSkeletonCombatPlayModeTests.cs new file mode 100644 index 0000000..9676119 --- /dev/null +++ b/Assets/_Project/Tests/PlayMode/Defense25DSkeletonCombatPlayModeTests.cs @@ -0,0 +1,92 @@ +using System.Collections; +using System.Reflection; +using BumpCombat.Constants; +using BumpCombat.Defense.Comparison; +using BumpCombat.Enemies; +using NUnit.Framework; +using UnityEditor; +using UnityEngine; +using UnityEngine.TestTools; +using Object = UnityEngine.Object; + +namespace BumpCombat.Tests +{ + public sealed class Defense25DSkeletonCombatPlayModeTests + { + private Defense25DComparison world; + private Defense25DComparisonSettings settings; + private AnimationClip attackClip; + private float previousTimeScale; + + [UnitySetUp] + public IEnumerator SetUp() + { + previousTimeScale = Time.timeScale; + Time.timeScale = 1f; + settings = Object.Instantiate(AssetDatabase.LoadAssetAtPath( + "Assets/_Project/Constants/Defense25DComparison/ComparisonSettings.asset")); + Assert.That(settings, Is.Not.Null); + world = new GameObject("Skeleton combat under test").AddComponent(); + world.Initialize(settings); + world.AutoSpawn = false; + world.ReadInput = false; + world.SetPaused(false); + Time.timeScale = 0f; + yield return null; + } + + [UnityTearDown] + public IEnumerator TearDown() + { + if (world != null) Object.Destroy(world.gameObject); + if (settings != null) Object.Destroy(settings); + Time.timeScale = previousTimeScale; + yield return null; + } + + [UnityTest] + public IEnumerator SkeletonSlashDamagesOnceWhenItsAuthoredActiveWindowBegins() + { + EnemyDefinition skeleton = AssetDatabase.LoadAssetAtPath( + "Assets/_Project/Constants/Enemies/Skeleton.asset"); + attackClip = AssetDatabase.LoadAssetAtPath( + "Assets/_Project/Art/Characters/Skeleton/Animations/Skeleton_Attack.anim"); + Assert.That(skeleton, Is.Not.Null); + Assert.That(attackClip, Is.Not.Null); + Defense25DEnemy enemy = world.Enemies[0]; + Assert.That(world.IsRunning, Is.True); + enemy.Spawn(Vector2.zero); + enemy.ConfigureSkeletonCombat(skeleton, 1.25f, attackClip); + Assert.That(world.Settings.EnemyRadius, Is.EqualTo(.275f).Within(.0001f)); + Assert.That(world.Settings.EnemyColliderOffset, Is.EqualTo(new Vector2(0f, -.35f))); + world.Player.ResetAt(new Vector2(.5f, 0f)); + Physics.SyncTransforms(); + + SetGameTime(0f); + Assert.That(enemy.StepSkeletonCombat(Time.fixedDeltaTime), Is.True); + Assert.That(world.IsRunning, Is.True); + Assert.That(enemy.IsAttacking, Is.True); + SetGameTime(.699f); + enemy.StepSkeletonCombat(Time.fixedDeltaTime); + Assert.That(world.Player.DamageEvents, Is.Zero, + "The warning phase must not deal damage."); + + SetGameTime(.7f); + enemy.StepSkeletonCombat(Time.fixedDeltaTime); + Assert.That(world.Player.DamageEvents, Is.EqualTo(1)); + Assert.That(world.Player.CurrentHealth, Is.EqualTo(world.Player.MaxHealth - 12f).Within(.001f)); + + SetGameTime(.72f); + enemy.StepSkeletonCombat(Time.fixedDeltaTime); + Assert.That(world.Player.DamageEvents, Is.EqualTo(1), + "A successful slash is latched for the rest of its active window."); + yield return null; + } + + private void SetGameTime(float value) + { + typeof(Defense25DComparison).GetProperty(nameof(Defense25DComparison.GameTime)) + .GetSetMethod(true).Invoke(world, new object[] { value }); + } + } +} diff --git a/Assets/_Project/Tests/PlayMode/Defense25DSkeletonCombatPlayModeTests.cs.meta b/Assets/_Project/Tests/PlayMode/Defense25DSkeletonCombatPlayModeTests.cs.meta new file mode 100644 index 0000000..4bcc177 --- /dev/null +++ b/Assets/_Project/Tests/PlayMode/Defense25DSkeletonCombatPlayModeTests.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 785220a7fcfa4c80bf7dc14b6beae34b diff --git a/Assets/_Project/Tests/PlayMode/Defense25DStatusVisualPlayModeTests.cs b/Assets/_Project/Tests/PlayMode/Defense25DStatusVisualPlayModeTests.cs new file mode 100644 index 0000000..2ab27fd --- /dev/null +++ b/Assets/_Project/Tests/PlayMode/Defense25DStatusVisualPlayModeTests.cs @@ -0,0 +1,147 @@ +using System.Collections; +using System.Reflection; +using BumpCombat.Defense.Comparison; +using NUnit.Framework; +using UnityEngine; +using UnityEngine.TestTools; +using Object = UnityEngine.Object; + +namespace BumpCombat.Tests +{ + public sealed class Defense25DStatusVisualPlayModeTests + { + private GameObject enemyObject; + private GameObject actorObject; + private GameObject spriteObject; + private GameObject worldObject; + + [UnityTearDown] + public IEnumerator TearDown() + { + if (worldObject != null) Object.Destroy(worldObject); + else + { + if (enemyObject != null) Object.Destroy(enemyObject); + if (actorObject != null) Object.Destroy(actorObject); + if (spriteObject != null) Object.Destroy(spriteObject); + } + yield return null; + } + + [UnityTest] + public IEnumerator ActiveStatusesRenderExistingFramesAndClearWhenTheirTimersExpire() + { + const string ignitePath = "Artifacts/ThreeColor-v1/StatusEffects/Ignite-Warlock-v1"; + Sprite[] importedFrames = Resources.LoadAll(ignitePath); + Texture2D igniteTexture = importedFrames.Length > 0 + ? importedFrames[0].texture : Resources.Load(ignitePath); + Assert.That(igniteTexture, Is.Not.Null); + + worldObject = new GameObject("Status visual world"); + Defense25DComparison owner = worldObject.AddComponent(); + SetBackingField(owner, "IsRunning", true); + SetBackingField(owner, "Paused", true); + + spriteObject = new GameObject("Status visual source"); + SpriteRenderer sourceRenderer = spriteObject.AddComponent(); + actorObject = new GameObject("Status visual actor"); + actorObject.transform.SetParent(worldObject.transform, false); + spriteObject.transform.SetParent(actorObject.transform, false); + spriteObject.transform.rotation = Quaternion.Euler(30f, 0f, 0f); + sourceRenderer.sortingOrder = 12; + sourceRenderer.flipX = true; + sourceRenderer.color = new Color(1f, 1f, 1f, .4f); + + Defense25DActor actor = actorObject.AddComponent(); + SetBackingField(actor, "CurrentHealth", 10f); + Defense25DSprite actorVisual = actorObject.AddComponent(); + SetPrivateField(actorVisual, "sprite", sourceRenderer); + actorVisual.enabled = false; + SetBackingField(actor, "Visual", actorVisual); + + enemyObject = new GameObject("Status visual enemy"); + enemyObject.transform.SetParent(worldObject.transform, false); + Defense25DEnemy enemy = enemyObject.AddComponent(); + SetBackingField(enemy, "Actor", actor); + SetPrivateField(enemy, "world", owner); + SetPrivateField(enemy, "igniteRemaining", .5f); + SetPrivateField(enemy, "shockRemaining", .5f); + SetPrivateField(enemy, "stunUntil", .5f); + SetPrivateField(enemy, "stunVisualRevision", 1); + Defense25DStatusVisual statusVisual = enemyObject.AddComponent(); + statusVisual.Bind(enemy); + + statusVisual.SendMessage("LateUpdate"); + + Transform igniteVisual = spriteObject.transform.Find("Ignite Status Visual"); + Transform shockVisual = spriteObject.transform.Find("Shock Status Visual"); + Assert.That(igniteVisual, Is.Not.Null); + Assert.That(shockVisual, Is.Not.Null); + Assert.That(enemy.IsIgnited, Is.True); + Assert.That(enemy.IsShocked, Is.True); + Assert.That(GetPrivateField(GetPrivateField(statusVisual, "ignite"), "FrameElapsed"), + Is.EqualTo(0f)); + Assert.That(GetPrivateField(GetPrivateField(statusVisual, "shock"), "FrameElapsed"), + Is.EqualTo(0f)); + Assert.That(GetPrivateField(statusVisual, "stunElapsed"), Is.EqualTo(0f)); + Transform stunMarker = actorObject.transform.Find("Stun Runes"); + Assert.That(stunMarker, Is.Not.Null); + SpriteRenderer stunRenderer = stunMarker.GetComponent(); + Assert.That(stunRenderer.sharedMaterial, Is.SameAs(sourceRenderer.sharedMaterial)); + Assert.That(stunRenderer.color.a, Is.EqualTo(sourceRenderer.color.a).Within(.001f)); + Assert.That(stunMarker.localPosition.z, Is.GreaterThan(0f)); + + SpriteRenderer igniteRenderer = igniteVisual.GetComponent(); + Assert.That(igniteRenderer.sprite, Is.Not.Null); + Assert.That(igniteRenderer.sprite.texture, Is.SameAs(igniteTexture)); + Assert.That(igniteRenderer.sortingOrder, Is.EqualTo(sourceRenderer.sortingOrder + 1)); + Assert.That(igniteRenderer.flipX, Is.EqualTo(sourceRenderer.flipX)); + Assert.That(igniteRenderer.color.a, Is.EqualTo(sourceRenderer.color.a).Within(.001f)); + Assert.That(igniteVisual.localPosition.y, + Is.EqualTo(Defense25DStatusVisual.StatusVisualAnchorOffsetY).Within(.001f)); + + SetPrivateField(enemy, "igniteRemaining", 0f); + SetPrivateField(enemy, "shockRemaining", 0f); + statusVisual.SendMessage("LateUpdate"); + Assert.That(enemy.IsIgnited, Is.False); + Assert.That(enemy.IsShocked, Is.False); + Assert.That(GetPrivateField(GetPrivateField(statusVisual, "ignite"), "Root"), Is.Null); + Assert.That(GetPrivateField(GetPrivateField(statusVisual, "shock"), "Root"), Is.Null); + + SetPrivateField(enemy, "igniteRemaining", .5f); + SetBackingField(owner, "Paused", false); + statusVisual.SendMessage("LateUpdate"); + Assert.That(GetPrivateField(GetPrivateField(statusVisual, "ignite"), "Root"), Is.Not.Null); + SetBackingField(owner, "IsRunning", false); + statusVisual.SendMessage("LateUpdate"); + Assert.That(GetPrivateField(GetPrivateField(statusVisual, "ignite"), "Root"), Is.Null); + + SetBackingField(owner, "IsRunning", true); + statusVisual.SendMessage("LateUpdate"); + Assert.That(GetPrivateField(GetPrivateField(statusVisual, "ignite"), "Root"), Is.Not.Null); + owner.enabled = false; + statusVisual.SendMessage("LateUpdate"); + Assert.That(GetPrivateField(GetPrivateField(statusVisual, "ignite"), "Root"), Is.Null); + yield return null; + } + + private static void SetBackingField(object target, string propertyName, object value) => + SetPrivateField(target, $"<{propertyName}>k__BackingField", value); + + private static void SetPrivateField(object target, string fieldName, object value) + { + FieldInfo field = target.GetType().GetField( + fieldName, BindingFlags.Instance | BindingFlags.NonPublic | BindingFlags.Public); + Assert.That(field, Is.Not.Null, $"Missing test field {fieldName}"); + field.SetValue(target, value); + } + + private static object GetPrivateField(object target, string fieldName) + { + FieldInfo field = target.GetType().GetField( + fieldName, BindingFlags.Instance | BindingFlags.NonPublic | BindingFlags.Public); + Assert.That(field, Is.Not.Null, $"Missing test field {fieldName}"); + return field.GetValue(target); + } + } +} diff --git a/Assets/_Project/Tests/PlayMode/Defense25DStatusVisualPlayModeTests.cs.meta b/Assets/_Project/Tests/PlayMode/Defense25DStatusVisualPlayModeTests.cs.meta new file mode 100644 index 0000000..d57484e --- /dev/null +++ b/Assets/_Project/Tests/PlayMode/Defense25DStatusVisualPlayModeTests.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 7751eb9767744bb198826d23a801b32d diff --git a/Assets/_Project/Tests/PlayMode/Defense25DSummonPackPlayModeTests.cs b/Assets/_Project/Tests/PlayMode/Defense25DSummonPackPlayModeTests.cs new file mode 100644 index 0000000..afa587e --- /dev/null +++ b/Assets/_Project/Tests/PlayMode/Defense25DSummonPackPlayModeTests.cs @@ -0,0 +1,270 @@ +using System.Collections; +using BumpCombat.Constants; +using BumpCombat.Defense; +using BumpCombat.Defense.Comparison; +using NUnit.Framework; +using UnityEditor; +using UnityEngine; +using UnityEngine.TestTools; + +namespace BumpCombat.Tests +{ + public sealed class Defense25DSummonPackPlayModeTests + { + private Defense25DComparison world; + private Defense25DComparisonSettings settings; + + [UnitySetUp] + public IEnumerator SetUp() + { + settings = Object.Instantiate(AssetDatabase.LoadAssetAtPath( + "Assets/_Project/Constants/Defense25DComparison/ComparisonSettings.asset")); + world = new GameObject("2.5D summon pack test").AddComponent(); + world.Initialize(settings); + world.AutoSpawn = false; + world.ReadInput = false; + yield return null; + } + + [UnityTearDown] + public IEnumerator TearDown() + { + if (world != null) Object.Destroy(world.gameObject); + if (settings != null) Object.Destroy(settings); + yield return null; + } + + [Test] + public void UpgradeReplacesEverySlotAndResetsThePackTimer() + { + Defense25DTower tower = BuildSummonTower(); + Defense25DSummon oldUnit = world.Summons[0]; + oldUnit.Actor.TakeDamage(1000f, Vector2.zero); + Assert.That(tower.SummonRespawnRemaining, + Is.EqualTo(GameplayConstants.Current.Defense.SummonUnitRespawnSeconds)); + + UpgradeToTierFour(tower, DefenseTowerBranch.Second); + + int expected = GameplayConstants.Current.Defense.SummonUnitCount + 4; + Assert.That(tower.TargetSummonCount, Is.EqualTo(expected)); + Assert.That(tower.SummonCount, Is.EqualTo(expected)); + Assert.That(tower.SummonRespawnRemaining, Is.Zero); + Assert.That(world.Summons.Count, Is.EqualTo(expected)); + CollectionAssert.DoesNotContain(world.Summons, oldUnit); + CollectionAssert.AreEquivalent(System.Linq.Enumerable.Range(0, expected), + System.Linq.Enumerable.Select(world.Summons, summon => summon.SlotIndex)); + } + + [UnityTest] + public IEnumerator BasicSlimeDealsNoInstantDamageAndRequiresAttackFrameContact() + { + Defense25DTower tower = BuildSummonTower(); + Defense25DSummon summon = world.Summons[0]; + foreach (Defense25DSummon other in world.Summons) + if (other != summon) other.gameObject.SetActive(false); + world.Player.ResetAt(new Vector2(4f, 4f)); + Defense25DEnemy target = SpawnSummonVulnerableEnemy(); + target.Actor.ResetAt(summon.Actor.Position + Vector2.right * .9f); + Physics.SyncTransforms(); + tower.StepSummonPack(0f); + float health = target.Actor.CurrentHealth; + + float elapsed = 0f; + while (elapsed < .124f) + { + yield return new WaitForFixedUpdate(); + elapsed += Time.fixedDeltaTime; + } + Assert.That(target.Actor.CurrentHealth, Is.EqualTo(health), + "the body-slam hit is gated by the authored impact frame"); + + float deadline = Time.realtimeSinceStartup + 2f; + while (target.Actor.CurrentHealth >= health && Time.realtimeSinceStartup < deadline) + yield return new WaitForFixedUpdate(); + + Assert.That(target.Actor.CurrentHealth, Is.LessThan(health), + "the later attack frame must make physical contact before applying damage"); + Assert.That(summon.AttackEvents, Is.EqualTo(1), + "the isolated summon must own the only successful damage event"); + Assert.That(summon.LastDamageFrame, Is.GreaterThanOrEqualTo( + GameplayConstants.Current.Defense.SummonBodySlamImpactFrame), + "damage must occur on or after the authored impact frame"); + Assert.That(summon.Actor.GapTo(target.Actor), Is.LessThanOrEqualTo( + GameplayConstants.Current.Defense.SummonBodySlamContactMargin), + "damage requires physical body contact"); + yield return null; + } + + [Test] + public void SummonStartsAttackUsingPositionReachedByThisStep() + { + Defense25DTower tower = BuildSummonTower(); + Defense25DSummon summon = world.Summons[0]; + foreach (Defense25DSummon other in world.Summons) + if (other != summon) other.gameObject.SetActive(false); + world.Player.ResetAt(new Vector2(4f, 4f)); + Defense25DEnemy target = SpawnSummonVulnerableEnemy(); + target.Actor.ResetAt(summon.Actor.Position + Vector2.right * 1.25f); + Physics.SyncTransforms(); + + tower.StepSummonPack(.2f); + summon.Step(.2f); + + Assert.That(summon.IsAttacking, Is.True, + $"The original starts an attack when the post-move root position enters attack range. " + + $"target={tower.CommandTarget}, running={world.IsRunning}, operational={world.TowersOperational}, " + + $"available={target.IsAvailable}, summon={summon.Actor.Position}, " + + $"targetPosition={target.Actor.Position}, blocked={summon.Actor.MovementBlocked}, " + + $"lastMove={summon.Actor.LastMove}"); + } + + [UnityTest] + public IEnumerator SummonWithNoValidTargetReturnsToItsAnchor() + { + Defense25DTower tower = BuildSummonTower(); + Defense25DSummon summon = world.Summons[0]; + foreach (Defense25DSummon other in world.Summons) + if (other != summon) other.gameObject.SetActive(false); + world.Player.ResetAt(new Vector2(4f, 4f)); + Vector2 anchor = summon.Actor.Position; + summon.Actor.ResetAt(anchor + Vector2.right * .5f); + Physics.SyncTransforms(); + Vector2 before = summon.Actor.Position; + + tower.StepSummonPack(.2f); + summon.Step(.2f); + + Assert.That(tower.CommandTarget, Is.Null); + Assert.That(Vector2.Dot(summon.Actor.LastMove, anchor - before), + Is.GreaterThan(0f), + "The original moves toward its anchor while no command target is valid."); + yield return null; + } + + [UnityTest] + public IEnumerator OnlyBasicSlimeReturnsToItsAnchorAfterAttack() + { + Defense25DTower tower = BuildSummonTower(); + Defense25DSummon summon = world.Summons[0]; + foreach (Defense25DSummon other in world.Summons) + if (other != summon) other.gameObject.SetActive(false); + world.Player.ResetAt(new Vector2(4f, 4f)); + Defense25DEnemy target = SpawnSummonVulnerableEnemy(); + target.Actor.ResetAt(summon.Actor.Position + Vector2.right * .7f); + Physics.SyncTransforms(); + tower.StepSummonPack(.01f); + summon.Step(.01f); + Assert.That(summon.IsAttacking, Is.True, + $"Basic summon should start when its target is in range; target={tower.CommandTarget}, " + + $"running={world.IsRunning}, operational={world.TowersOperational}, " + + $"available={target.IsAvailable}, summon={summon.Actor.Position}, targetPosition={target.Actor.Position}"); + float deadline = Time.realtimeSinceStartup + 2f; + while (summon.IsAttacking && Time.realtimeSinceStartup < deadline) + yield return new WaitForFixedUpdate(); + Assert.That(summon.IsAttacking, Is.False); + Assert.That(summon.IsReturningToAnchor, Is.True, + "Basic Slime returns after its attack sequence."); + yield return null; + } + + [UnityTest] + public IEnumerator TierFourSentinelDoesNotReturnAfterAttack() + { + Defense25DTower tower = BuildSummonTower(); + UpgradeToTierFour(tower, DefenseTowerBranch.First); + Defense25DSummon summon = world.Summons[0]; + foreach (Defense25DSummon other in world.Summons) + if (other != summon) other.gameObject.SetActive(false); + world.Player.ResetAt(new Vector2(4f, 4f)); + Defense25DEnemy target = SpawnSummonVulnerableEnemy(); + target.Actor.ResetAt(summon.Actor.Position + Vector2.right * .7f); + Physics.SyncTransforms(); + + tower.StepSummonPack(.01f); + summon.Step(.01f); + Assert.That(summon.IsAttacking, Is.True, + $"Sentinel should start its attack in range; target={tower.CommandTarget}, " + + $"running={world.IsRunning}, operational={world.TowersOperational}, " + + $"available={target.IsAvailable}, summon={summon.Actor.Position}, targetPosition={target.Actor.Position}"); + float deadline = Time.realtimeSinceStartup + 2f; + while (summon.IsAttacking && Time.realtimeSinceStartup < deadline) + yield return new WaitForFixedUpdate(); + + Assert.That(summon.IsAttacking, Is.False); + Assert.That(summon.IsReturningToAnchor, Is.False, + "Original return-to-anchor behavior is limited to basic Slime attacks."); + yield return null; + } + + [UnityTest] + public IEnumerator ScoutGroundSpikeWaitsForItsOriginalLaunchAndTravelTime() + { + Defense25DTower tower = BuildSummonTower(); + UpgradeToTierFour(tower, DefenseTowerBranch.Second); + Defense25DSummon scout = world.Summons[0]; + Defense25DEnemy target = SpawnSummonVulnerableEnemy(); + target.Actor.ResetAt(tower.Position + Vector2.right); + Physics.SyncTransforms(); + float health = target.Actor.CurrentHealth; + + Assert.That(tower.TryAttackSummon(scout, target), Is.True); + tower.StepParityAttacks(.48f); + Assert.That(target.Actor.CurrentHealth, Is.EqualTo(health), "launch frame is not impact"); + tower.StepParityAttacks(.159f); + Assert.That(target.Actor.CurrentHealth, Is.EqualTo(health), "the projectile remains in flight"); + tower.StepParityAttacks(.001f); + Assert.That(target.Actor.CurrentHealth, Is.LessThan(health)); + yield return null; + } + + [UnityTest] + public IEnumerator SummonRespawnTimerPausesWhilePlayerIsDead() + { + Defense25DTower tower = BuildSummonTower(); + Defense25DSummon summon = world.Summons[0]; + summon.Actor.TakeDamage(1000f, Vector2.zero); + float respawnSeconds = GameplayConstants.Current.Defense.SummonUnitRespawnSeconds; + Assert.That(tower.SummonRespawnRemaining, Is.EqualTo(respawnSeconds)); + Assert.That(world.Player.TakeDamage(world.Player.CurrentHealth + 1f, Vector2.left), Is.True); + Assert.That(world.Player.IsAlive, Is.False, "the timer pause requires an actually dead player"); + Assert.That(world.TowersOperational, Is.False); + + tower.StepSummonPack(1f); + Assert.That(tower.SummonRespawnRemaining, Is.EqualTo(respawnSeconds)); + Assert.That(tower.SummonCount, Is.EqualTo(GameplayConstants.Current.Defense.SummonUnitCount - 1)); + + world.Player.ResetAt(GameplayConstants.Current.Defense.GetBuildPadPosition(0)); + tower.StepSummonPack(respawnSeconds - .01f); + Assert.That(tower.SummonCount, Is.EqualTo(GameplayConstants.Current.Defense.SummonUnitCount - 1)); + tower.StepSummonPack(.02f); + Assert.That(tower.SummonCount, Is.EqualTo(GameplayConstants.Current.Defense.SummonUnitCount)); + yield return null; + } + + private Defense25DTower BuildSummonTower() + { + world.Player.ResetAt(GameplayConstants.Current.Defense.GetBuildPadPosition(0)); + Assert.That(world.TowerManager.TryBuildTowerAtPad(0, DefenseTowerKind.Summon), Is.True); + return world.TowerManager.GetTowerAtPad(0); + } + + private void UpgradeToTierFour(Defense25DTower tower, DefenseTowerBranch branch) + { + world.TowerManager.AddCurrency(1000); + Assert.That(world.TowerManager.TryUpgradeTowerAtPad(tower.PadIndex, DefenseTowerBranch.None), Is.True); + Assert.That(world.TowerManager.TryUpgradeTowerAtPad(tower.PadIndex, DefenseTowerBranch.None), Is.True); + Assert.That(world.TowerManager.TryUpgradeTowerAtPad(tower.PadIndex, branch), Is.True); + } + + private Defense25DEnemy SpawnSummonVulnerableEnemy() + { + for (int i = 0; i < 2; i++) + { + Defense25DEnemy candidate = world.SpawnNextEnemy(); + if (!world.IsTowerImmuneToEnemy(DefenseTowerKind.Summon, candidate)) return candidate; + } + Assert.Fail("Both portal lanes unexpectedly reject summon-tower damage."); + return null; + } + } +} diff --git a/Assets/_Project/Tests/PlayMode/Defense25DSummonPackPlayModeTests.cs.meta b/Assets/_Project/Tests/PlayMode/Defense25DSummonPackPlayModeTests.cs.meta new file mode 100644 index 0000000..d56a209 --- /dev/null +++ b/Assets/_Project/Tests/PlayMode/Defense25DSummonPackPlayModeTests.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 4987f4d643f34729ab7405159683701e diff --git a/Assets/_Project/Tests/PlayMode/Defense25DTowerAttackParityPlayModeTests.cs b/Assets/_Project/Tests/PlayMode/Defense25DTowerAttackParityPlayModeTests.cs new file mode 100644 index 0000000..d3e4d5a --- /dev/null +++ b/Assets/_Project/Tests/PlayMode/Defense25DTowerAttackParityPlayModeTests.cs @@ -0,0 +1,482 @@ +using System.Linq; +using BumpCombat.Combat; +using BumpCombat.Constants; +using BumpCombat.Defense; +using BumpCombat.Defense.Comparison; +using BumpCombat.Enemies; +using NUnit.Framework; +using UnityEditor; +using UnityEngine; + +namespace BumpCombat.Tests +{ + public sealed class Defense25DTowerAttackParityPlayModeTests + { + private Defense25DComparison world; + private Defense25DComparisonSettings settings; + private EnemyDefinition highHealthEnemy; + + [SetUp] + public void SetUp() + { + settings = Object.Instantiate(AssetDatabase.LoadAssetAtPath( + "Assets/_Project/Constants/Defense25DComparison/ComparisonSettings.asset")); + EnemyDefinition sourceEnemy = settings.Enemy; + highHealthEnemy = Object.Instantiate(sourceEnemy); + highHealthEnemy.name = sourceEnemy.name + " Flame Ramp Test"; + highHealthEnemy.Configure(sourceEnemy.Kind, sourceEnemy.AttackShape, 10000f, + sourceEnemy.MoveSpeed, sourceEnemy.AttackDamage, sourceEnemy.WarningDuration, + sourceEnemy.ActiveDuration, sourceEnemy.RecoveryDuration, sourceEnemy.AttackRange, + sourceEnemy.PreferredRange, sourceEnemy.AttackLength, sourceEnemy.AttackWidth, + sourceEnemy.AttackRadius, sourceEnemy.ExperienceValue, sourceEnemy.AttackAnimationLeadTime); + highHealthEnemy.ConfigureRole(sourceEnemy.Role); + highHealthEnemy.ConfigureAttackPatterns(sourceEnemy.AttackPatterns); + settings.Enemy = highHealthEnemy; + world = new GameObject("2.5D tower attack parity fixture").AddComponent(); + world.Initialize(settings); + world.AutoSpawn = false; + world.ReadInput = false; + } + + [TearDown] + public void TearDown() + { + if (world != null) Object.DestroyImmediate(world.gameObject); + if (highHealthEnemy != null) Object.DestroyImmediate(highHealthEnemy); + if (settings != null) Object.DestroyImmediate(settings); + } + + [Test] + public void MeteorFinalBranchShowsFlightAndDealsDamageOnlyAtAuthoredArrival() + { + Defense25DTower tower = BuildT4(DefenseTowerKind.Direct, DefenseTowerBranch.First); + Defense25DEnemy target = SpawnTarget(DefenseTowerKind.Direct, tower.Position + Vector2.right); + float before = target.Actor.CurrentHealth; + + tower.StepParityAttacks(0f); + Assert.That(target.Actor.CurrentHealth, Is.EqualTo(before), "Meteor must not damage on launch."); + Assert.That(FindNamed("Tower Projectile").Length, Is.EqualTo(1)); + + tower.StepParityAttacks(.319f); + Assert.That(target.Actor.CurrentHealth, Is.EqualTo(before), "The authored flight is 0.32 seconds."); + tower.StepParityAttacks(.001f); + Assert.That(target.Actor.DamageEvents, Is.GreaterThan(0), "The arrival callback must produce a hit."); + Assert.That(FindNamed("Tower Attack Effect").Length, Is.GreaterThan(0), "Meteor impact art is emitted on arrival."); + } + + [Test] + public void FlamethrowerFinalBranchKeepsBeamAndUsesCooldownAndContinuousRamp() + { + Defense25DTower tower = BuildT4(DefenseTowerKind.Direct, DefenseTowerBranch.Second); + Defense25DEnemy target = SpawnTarget(DefenseTowerKind.Direct, + tower.Position + Vector2.right, immobilize: true); + float previous = target.Actor.CurrentHealth; + + StepWorld(1); + Assert.That(target.Actor.CurrentHealth, Is.LessThan(previous)); + previous = target.Actor.CurrentHealth; + Assert.That(FindNamed("Fire T4 Sustained Beam").Length, Is.EqualTo(1)); + + StepWorld(9); + Assert.That(target.Actor.CurrentHealth, Is.EqualTo(previous), + "The beam must respect its authored pulse cooldown instead of damaging each frame."); + StepWorld(2); + Assert.That(target.Actor.CurrentHealth, Is.LessThan(previous), "A cooldown pulse lands after its interval."); + + float earlyPulse = previous - target.Actor.CurrentHealth; + SpriteRenderer beam = FindNamed("Fire T4 Sustained Beam").Single() + .GetComponentInChildren(); + Assert.That(beam.sprite, Is.EqualTo(DefenseTowerArt.Load( + "Effects/Fire/Eyeball Monster_Beam-Clean")[1]), + "The sustained beam advances to its authored ignition frame after 0.1 seconds."); + StepWorld(Mathf.CeilToInt(GameplayConstants.Current.Defense.FireT4BeamSecondTierSeconds + / Time.fixedDeltaTime)); + float beforeLatePulse = target.Actor.CurrentHealth; + StepWorld(11); + Assert.That(target.Actor.CurrentHealth, Is.LessThan(beforeLatePulse)); + float rampedPulse = beforeLatePulse - target.Actor.CurrentHealth; + Assert.That(rampedPulse, Is.GreaterThan(earlyPulse * 1.25f), + "Continuous target time must advance the flame damage stage while retaining the same target."); + } + + [Test] + public void FlamethrowerRetainsTowerRampAcrossTargetSwapAndResetsAfterOneSecondWithoutHits() + { + Defense25DTower tower = BuildT4(DefenseTowerKind.Direct, DefenseTowerBranch.Second); + Defense25DEnemy first = SpawnTarget(DefenseTowerKind.Direct, + tower.Position + Vector2.right, immobilize: true); + StepWorld(1); + StepWorld(Mathf.CeilToInt(GameplayConstants.Current.Defense.FireT4BeamSecondTierSeconds + / Time.fixedDeltaTime) + 2); + + first.Actor.ResetAt(tower.Position + Vector2.right * 8f); + Defense25DEnemy replacement = SpawnTarget(DefenseTowerKind.Direct, + tower.Position + Vector2.right * .9f, immobilize: true); + float secondTierDamage = NextDamage(replacement); + float baseDamage = GameplayConstants.Current.Defense.DirectTowerDamage + * (1f + GameplayConstants.Current.Defense.TowerDamageBonusPerTier * 3f) + * GameplayConstants.Current.Defense.FireT4BeamBaseDamageMultiplier + * tower.SightEfficiency; + Assert.That(secondTierDamage, Is.EqualTo(DamageCalculator.FinalizeDamage(baseDamage + * GameplayConstants.Current.Defense.FireT4BeamSecondTierMultiplier)), + "The streak clock belongs to the tower and survives changing its target."); + + float remainingToMaximum = GameplayConstants.Current.Defense.FireT4BeamMaximumTierSeconds + - world.GameTime; + StepWorld(Mathf.CeilToInt(remainingToMaximum / Time.fixedDeltaTime) + 2); + float maximumTierDamage = NextDamage(replacement); + Assert.That(maximumTierDamage, Is.EqualTo(DamageCalculator.FinalizeDamage(baseDamage + * GameplayConstants.Current.Defense.FireT4BeamMaximumMultiplier)), + "The sustained ramp reaches the original maximum tier after two seconds."); + + replacement.Actor.ResetAt(tower.Position + Vector2.right * 8f); + StepWorld(Mathf.CeilToInt(GameplayConstants.Current.Defense.FireT4BeamResetAfterSeconds + / Time.fixedDeltaTime) + 1); + Defense25DEnemy afterReset = SpawnTarget(DefenseTowerKind.Direct, + tower.Position + Vector2.right * .9f, immobilize: true); + float resetDamage = NextDamage(afterReset); + Assert.That(resetDamage, Is.EqualTo(DamageCalculator.FinalizeDamage(baseDamage)), + "One second without a successful hit resets the tower's flame stage."); + } + + [Test] + public void ArcFinalBranchHitsAtMostThreeTargetsAndEmitsItsAttackVisual() + { + Defense25DTower tower = BuildT4(DefenseTowerKind.Lightning, DefenseTowerBranch.First); + Defense25DEnemy[] targets = SpawnTargets(DefenseTowerKind.Lightning, tower.Position, 4); + + tower.StepParityAttacks(0f); + + Assert.That(targets.Count(enemy => enemy.Actor.DamageEvents > 0), Is.EqualTo(3), + "The Arc branch is capped at three actual hits."); + Assert.That(world.ActiveTowerDamagePopupCount, Is.GreaterThan(0), + "Actual tower health loss is surfaced through the comparison HUD damage popup."); + GameObject visual = FindNamed("Lightning Attack Effect").SingleOrDefault(); + Assert.That(visual, Is.Not.Null); + Assert.That(visual.GetComponentInChildren(), Is.Not.Null); + } + + [Test] + public void LightningStormFinalBranchHitsEveryTargetInItsAuthoredArea() + { + Defense25DTower tower = BuildT4(DefenseTowerKind.Lightning, DefenseTowerBranch.Second); + Defense25DEnemy[] targets = SpawnTargets(DefenseTowerKind.Lightning, + tower.Position + new Vector2(.65f, 0f), 4); + + tower.StepParityAttacks(0f); + + Assert.That(targets.Count(enemy => enemy.Actor.DamageEvents > 0), Is.EqualTo(targets.Length)); + GameObject visual = FindNamed("Lightning Attack Effect").SingleOrDefault(); + Assert.That(visual, Is.Not.Null); + Assert.That(visual.GetComponentInChildren(), Is.Not.Null); + } + + [Test] + public void FreezeboltFinalBranchLaunchesFiveProjectilesAndHitsAtFlightArrival() + { + Defense25DTower tower = BuildT4(DefenseTowerKind.Cold, DefenseTowerBranch.First); + Defense25DEnemy target = SpawnTarget(DefenseTowerKind.Cold, tower.Position + Vector2.right); + float before = target.Actor.CurrentHealth; + + tower.StepParityAttacks(0f); + Assert.That(FindNamed("Tower Projectile").Length, Is.EqualTo( + GameplayConstants.Current.Defense.FreezeboltProjectileCount)); + Assert.That(target.Actor.CurrentHealth, Is.EqualTo(before)); + tower.StepParityAttacks(.179f); + Assert.That(target.Actor.CurrentHealth, Is.EqualTo(before)); + tower.StepParityAttacks(.001f); + Assert.That(target.Actor.DamageEvents, Is.GreaterThan(0)); + Assert.That(target.IsStunned, Is.True, "Freezebolt's impact applies its authored stun."); + } + + [Test] + public void BlizzardFinalBranchCreatesEightIndependentFlightsAndStaggeredLaunches() + { + Defense25DTower tower = BuildT4(DefenseTowerKind.Cold, DefenseTowerBranch.Second); + Defense25DEnemy target = SpawnTarget(DefenseTowerKind.Cold, tower.Position + Vector2.right); + + tower.StepParityAttacks(0f); + GameObject[] shards = FindNamed("Blizzard Shard Flight"); + Assert.That(shards.Length, Is.EqualTo(DefenseTowerArtBounds.BlizzardShardCount * 2), + "Each of the eight shards has a separate main and trail flight."); + Assert.That(shards.Select(item => item.transform.position).Distinct().Count(), Is.GreaterThan(8)); + Assert.That(shards.Count(item => item.GetComponentInChildren().enabled), Is.EqualTo(2), + "Only the first shard and its trail launch at t=0."); + + tower.StepParityAttacks(DefenseTowerArtBounds.BlizzardLaunchIntervalSeconds); + Assert.That(shards.Count(item => item != null + && item.GetComponentInChildren().enabled), Is.EqualTo(4), + "The second shard launches after the authored 0.04-second interval."); + Vector2 landingOffset = DefenseTowerArtBounds.BlizzardLandingOffset(0, 1.65f); + target.Actor.ResetAt(target.Actor.Position + landingOffset); + target.Actor.Visual.SendMessage("LateUpdate", SendMessageOptions.DontRequireReceiver); + tower.StepParityAttacks(DefenseTowerArtBounds.BlizzardLandingSeconds + - DefenseTowerArtBounds.BlizzardLaunchIntervalSeconds); + tower.StepParityAttacks(.001f); + Assert.That(target.Actor.DamageEvents, Is.GreaterThan(0), + "A landing wave must resolve on its authored 0.24-second arrival (allowing one millisecond for float accumulation)."); + } + + [Test] + public void BlizzardLandingUsesItsAuthoredEllipseRatherThanTheCastCircle() + { + Defense25DTower tower = BuildT4(DefenseTowerKind.Cold, DefenseTowerBranch.Second); + Vector2 triggerPosition = tower.Position + Vector2.right * .8f; + Defense25DEnemy trigger = SpawnTarget(DefenseTowerKind.Cold, triggerPosition); + Vector2 landingCenter = triggerPosition + + DefenseTowerArtBounds.BlizzardLandingOffset(0, 1.65f); + Defense25DEnemy inside = SpawnTarget(DefenseTowerKind.Cold, landingCenter); + float rx = DefenseTowerArtBounds.BlizzardWaveRadiusX; + float ry = DefenseTowerArtBounds.BlizzardWaveRadiusY; + Vector2 outsidePosition = Vector2.zero; + bool foundOutside = false; + for (float dx = rx * 1.05f; dx < 1.65f; dx += .02f) + { + Vector2 candidate = landingCenter + Vector2.right * dx; + bool overlapsAnyLanding = false; + for (int i = 0; i < DefenseTowerArtBounds.BlizzardShardCount; i++) + { + Vector2 center = triggerPosition + + DefenseTowerArtBounds.BlizzardLandingOffset(i, 1.65f); + Vector2 delta = candidate - center; + if (delta.x * delta.x / (rx * rx) + delta.y * delta.y / (ry * ry) <= 1f) + { + overlapsAnyLanding = true; + break; + } + } + if (overlapsAnyLanding || Vector2.Distance(candidate, triggerPosition) > 1.65f + rx) continue; + outsidePosition = candidate; + foundOutside = true; + break; + } + Assert.That(foundOutside, Is.True, "Fixture needs a point inside cast reach but outside all landing ellipses."); + Defense25DEnemy outside = SpawnTarget(DefenseTowerKind.Cold, outsidePosition); + + tower.StepParityAttacks(0f); + tower.StepParityAttacks(DefenseTowerArtBounds.BlizzardLandingSeconds); + + Assert.That(inside.Actor.DamageEvents, Is.GreaterThan(0), "The first shard's ellipse must hit its center."); + Assert.That(outside.Actor.DamageEvents, Is.EqualTo(0), + "Being inside the broad cast circle is not sufficient to receive an ellipse landing hit."); + } + + [Test] + public void StoneGazeFinalBranchDealsDamageAndStunsOnAttack() + { + Defense25DTower tower = BuildT4(DefenseTowerKind.Seismic, DefenseTowerBranch.First); + Defense25DEnemy target = SpawnTarget(DefenseTowerKind.Seismic, tower.Position + Vector2.right); + + tower.StepParityAttacks(0f); + + Assert.That(target.Actor.DamageEvents, Is.GreaterThan(0)); + Assert.That(target.IsStunned, Is.True); + Assert.That(FindNamed("Tower Attack Effect").Length, Is.GreaterThan(0), + "The authored Stone Gaze marker is rendered with the attack."); + } + + [Test] + public void TemporalFinalBranchDealsDamageAndAppliesItsSlowInsteadOfStun() + { + Defense25DTower tower = BuildT4(DefenseTowerKind.Seismic, DefenseTowerBranch.Second); + Defense25DEnemy target = SpawnTarget(DefenseTowerKind.Seismic, tower.Position + Vector2.right); + + tower.StepParityAttacks(0f); + + Assert.That(target.Actor.DamageEvents, Is.GreaterThan(0)); + Assert.That(target.IsStunned, Is.False); + var slowFraction = typeof(Defense25DEnemy).GetField("slowFraction", + System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic); + Assert.That(slowFraction, Is.Not.Null); + Assert.That((float)slowFraction.GetValue(target), Is.EqualTo(.5f).Within(.001f)); + Assert.That(FindNamed("Tower Attack Effect").Length, Is.GreaterThan(0)); + } + + [Test] + public void OutsideSightAppliesOnceToDamageAndControlDuration() + { + Defense25DTower tower = BuildT4(DefenseTowerKind.Seismic, DefenseTowerBranch.First); + world.Player.ResetAt(tower.Position + Vector2.right * 7f); + Defense25DEnemy target = SpawnTarget(DefenseTowerKind.Seismic, tower.Position + Vector2.right); + float before = target.Actor.CurrentHealth; + float now = world.GameTime; + Assert.That(tower.SightEfficiency, Is.EqualTo(.7f)); + + tower.StepParityAttacks(0f); + + float expectedDamage = GameplayConstants.Current.Defense.SeismicDamage + * (1f + GameplayConstants.Current.Defense.TowerDamageBonusPerTier * 3f) * .7f; + Assert.That(before - target.Actor.CurrentHealth, + Is.EqualTo(DamageCalculator.FinalizeDamage(expectedDamage))); + var stunUntil = typeof(Defense25DEnemy).GetField("stunUntil", + System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic); + Assert.That((float)stunUntil.GetValue(target) - now, + Is.EqualTo(GameplayConstants.Current.Defense.SeismicStunSeconds * 2f * .7f).Within(.001f)); + } + + [Test] + public void GiantSlimeFinalBranchSummonAttackDealsDamageAndStuns() + { + Defense25DTower tower = BuildT4(DefenseTowerKind.Summon, DefenseTowerBranch.First); + Defense25DSummon giant = world.Summons.Single(); + Defense25DEnemy target = SpawnTarget(DefenseTowerKind.Summon, tower.Position + Vector2.right); + int hitsBefore = target.Actor.DamageEvents; + + Assert.That(tower.TryAttackSummon(giant, target), Is.True); + + Assert.That(target.Actor.DamageEvents, Is.EqualTo(hitsBefore + 1)); + Assert.That(target.IsStunned, Is.True); + Assert.That(giant.name, Does.Contain("GiantSlime")); + } + + [Test] + public void ScoutFinalBranchUsesItsAuthoredDelayedFlightBeforeDamage() + { + Defense25DTower tower = BuildT4(DefenseTowerKind.Summon, DefenseTowerBranch.Second); + Defense25DSummon scout = world.Summons.First(); + Defense25DEnemy target = SpawnTarget(DefenseTowerKind.Summon, tower.Position + Vector2.right); + float before = target.Actor.CurrentHealth; + + Assert.That(tower.TryAttackSummon(scout, target), Is.True); + Assert.That(target.Actor.CurrentHealth, Is.EqualTo(before)); + Assert.That(FindNamed("Tower Projectile").Length, Is.EqualTo(1)); + tower.StepParityAttacks(.48f); + Assert.That(target.Actor.CurrentHealth, Is.EqualTo(before), "The projectile launches after 0.48 seconds."); + tower.StepParityAttacks(.159f); + Assert.That(target.Actor.CurrentHealth, Is.EqualTo(before)); + tower.StepParityAttacks(.001f); + Assert.That(target.Actor.DamageEvents, Is.GreaterThan(0), "Scout flight arrival applies the hit."); + } + + [Test] + public void ImbuingAtSeventyPercentSightAppliesItsOriginalIntervalMultiplierOnce() + { + Defense25DTower support = BuildT4At(0, DefenseTowerKind.Empowering, DefenseTowerBranch.First); + Defense25DTower attacker = BuildT4At(4, DefenseTowerKind.Lightning, DefenseTowerBranch.First); + world.Player.ResetAt(new Vector2(0f, -2f)); + Assert.That(support.SightEfficiency, Is.EqualTo(.7f)); + Defense25DEnemy target = SpawnTarget(DefenseTowerKind.Lightning, + attacker.Position + Vector2.right); + + world.TowerManager.Step(0f); + + float expected = 1f - (1f - GameplayConstants.Current.Defense.ImbuingAttackIntervalMultiplier) * .7f; + Assert.That(attacker.AttackIntervalMultiplier, Is.EqualTo(expected).Within(.001f)); + Assert.That(target.Actor.DamageEvents, Is.GreaterThan(0), + "Imbuing must be exercised through a real supported tower hit."); + } + + [TestCase(4.9f, 1f)] + [TestCase(-2f, .7f)] + public void TwoSmotheringSupportsStackAfterOneMaximumBaseBonus(float playerZ, float sight) + { + BuildT4At(0, DefenseTowerKind.Empowering, DefenseTowerBranch.Second); + BuildT4At(3, DefenseTowerKind.Empowering, DefenseTowerBranch.Second); + Defense25DTower attacker = BuildTowerAt(4, DefenseTowerKind.Lightning); + world.Player.ResetAt(new Vector2(0f, playerZ)); + Defense25DEnemy target = SpawnTarget(DefenseTowerKind.Lightning, attacker.Position); + float before = target.Actor.CurrentHealth; + + world.TowerManager.Step(0f); + + float baseDamage = GameplayConstants.Current.Defense.LightningDamage; + float baseSupport = 1f + GameplayConstants.Current.Defense.SupportDamageBonusPerTier * 4f * sight; + float smothering = 1f + GameplayConstants.Current.Defense.SmotheringDamageBonus * sight; + float expected = baseDamage * sight * baseSupport * smothering * smothering; + Assert.That(target.Actor.CurrentHealth, + Is.EqualTo(before - DamageCalculator.FinalizeDamage(expected)), + "Original policy applies one max tier support bonus and each in-range Smothering multiplier."); + } + + [Test] + public void FirstInRangeImbuingSupportOwnsAttackIntervalWhenTwoSupportsOverlap() + { + BuildT4At(0, DefenseTowerKind.Empowering, DefenseTowerBranch.First); + BuildT4At(3, DefenseTowerKind.Empowering, DefenseTowerBranch.First); + Defense25DTower attacker = BuildTowerAt(4, DefenseTowerKind.Lightning); + world.Player.ResetAt(new Vector2(-3.25f, 4.9f)); + Defense25DEnemy target = SpawnTarget(DefenseTowerKind.Lightning, + attacker.Position + Vector2.right); + + world.TowerManager.Step(0f); + + Assert.That(attacker.AttackIntervalMultiplier, + Is.EqualTo(GameplayConstants.Current.Defense.ImbuingAttackIntervalMultiplier).Within(.001f), + "The original controller returns the first in-range T4 Imbuing support."); + Assert.That(target.Actor.DamageEvents, Is.GreaterThan(0)); + } + + private Defense25DTower BuildT4(DefenseTowerKind kind, DefenseTowerBranch branch) => + BuildT4At(0, kind, branch); + + private Defense25DTower BuildT4At(int pad, DefenseTowerKind kind, DefenseTowerBranch branch) + { + Defense25DTower tower = BuildTowerAt(pad, kind); + world.TowerManager.AddCurrency(1000); + Assert.That(world.TowerManager.TryUpgradeTowerAtPad(pad, DefenseTowerBranch.None), Is.True); + Assert.That(world.TowerManager.TryUpgradeTowerAtPad(pad, DefenseTowerBranch.None), Is.True); + Assert.That(world.TowerManager.TryUpgradeTowerAtPad(pad, branch), Is.True); + Assert.That(tower.Tier, Is.EqualTo(4)); + Assert.That(tower.Branch, Is.EqualTo(branch)); + return tower; + } + + private Defense25DTower BuildTowerAt(int pad, DefenseTowerKind kind) + { + world.Player.ResetAt(GameplayConstants.Current.Defense.GetBuildPadPosition(pad)); + world.TowerManager.AddCurrency(1000); + Assert.That(world.TowerManager.TryBuildTowerAtPad(pad, kind), Is.True, $"pad={pad} {kind}"); + Defense25DTower tower = world.TowerManager.GetTowerAtPad(pad); + tower.SendMessage("LateUpdate", SendMessageOptions.DontRequireReceiver); + return tower; + } + + private Defense25DEnemy SpawnTarget(DefenseTowerKind kind, Vector2 position, bool immobilize = false) + { + Defense25DEnemy target = null; + for (int i = 0; i < GameplayConstants.Current.Defense.PortalEnemyCount * 2; i++) + { + Defense25DEnemy candidate = world.SpawnNextEnemy(); + if (candidate == null) break; + if (world.IsTowerImmuneToEnemy(kind, candidate)) continue; + target = candidate; + break; + } + Assert.That(target, Is.Not.Null, $"No lane target is eligible for {kind}."); + target.Actor.ResetAt(position); + if (immobilize) target.ApplyStun(30f); + target.Actor.Visual.SendMessage("LateUpdate", SendMessageOptions.DontRequireReceiver); + Physics.SyncTransforms(); + return target; + } + + private Defense25DEnemy[] SpawnTargets(DefenseTowerKind kind, Vector2 center, int count) + { + var targets = new System.Collections.Generic.List(); + for (int i = 0; i < count; i++) + targets.Add(SpawnTarget(kind, center + new Vector2((i - (count - 1) * .5f) * .18f, 0f))); + return targets.ToArray(); + } + + private void StepWorld(int steps) + { + for (int i = 0; i < steps; i++) + world.SendMessage("FixedUpdate", SendMessageOptions.RequireReceiver); + } + + private float NextDamage(Defense25DEnemy target, int maxFrames = 100) + { + float before = target.Actor.CurrentHealth; + int damageEvents = target.Actor.DamageEvents; + for (int i = 0; i < maxFrames && target.Actor.DamageEvents == damageEvents; i++) + StepWorld(1); + Assert.That(target.Actor.DamageEvents, Is.GreaterThan(damageEvents), + "The next authored tower pulse must reach the selected target."); + return before - target.Actor.CurrentHealth; + } + + private static GameObject[] FindNamed(string name) => + Object.FindObjectsByType(FindObjectsSortMode.None) + .Where(item => item.scene.IsValid() && item.name == name).ToArray(); + } +} diff --git a/Assets/_Project/Tests/PlayMode/Defense25DTowerAttackParityPlayModeTests.cs.meta b/Assets/_Project/Tests/PlayMode/Defense25DTowerAttackParityPlayModeTests.cs.meta new file mode 100644 index 0000000..1fe658e --- /dev/null +++ b/Assets/_Project/Tests/PlayMode/Defense25DTowerAttackParityPlayModeTests.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 64a81f573c3748b3bfde39c36b1a7d27 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/_Project/Tests/PlayMode/Defense25DTowerBodyAnimationPlayModeTests.cs b/Assets/_Project/Tests/PlayMode/Defense25DTowerBodyAnimationPlayModeTests.cs new file mode 100644 index 0000000..e47c1cb --- /dev/null +++ b/Assets/_Project/Tests/PlayMode/Defense25DTowerBodyAnimationPlayModeTests.cs @@ -0,0 +1,174 @@ +using System.Reflection; +using System.Linq; +using BumpCombat.Constants; +using BumpCombat.Defense; +using BumpCombat.Defense.Comparison; +using BumpCombat.Enemies; +using NUnit.Framework; +using UnityEditor; +using UnityEngine; + +namespace BumpCombat.Tests +{ + public sealed class Defense25DTowerBodyAnimationPlayModeTests + { + private Defense25DComparison world; + private Defense25DComparisonSettings settings; + private EnemyDefinition highHealthEnemy; + + [SetUp] + public void SetUp() + { + settings = Object.Instantiate(AssetDatabase.LoadAssetAtPath( + "Assets/_Project/Constants/Defense25DComparison/ComparisonSettings.asset")); + EnemyDefinition sourceEnemy = settings.Enemy; + highHealthEnemy = Object.Instantiate(sourceEnemy); + highHealthEnemy.name = sourceEnemy.name + " Tower Body Animation Test"; + highHealthEnemy.Configure(sourceEnemy.Kind, sourceEnemy.AttackShape, 10000f, + sourceEnemy.MoveSpeed, sourceEnemy.AttackDamage, sourceEnemy.WarningDuration, + sourceEnemy.ActiveDuration, sourceEnemy.RecoveryDuration, sourceEnemy.AttackRange, + sourceEnemy.PreferredRange, sourceEnemy.AttackLength, sourceEnemy.AttackWidth, + sourceEnemy.AttackRadius, sourceEnemy.ExperienceValue, sourceEnemy.AttackAnimationLeadTime); + highHealthEnemy.ConfigureRole(sourceEnemy.Role); + highHealthEnemy.ConfigureAttackPatterns(sourceEnemy.AttackPatterns); + settings.Enemy = highHealthEnemy; + world = new GameObject("2.5D tower body animation test").AddComponent(); + world.Initialize(settings); + world.AutoSpawn = false; + world.ReadInput = false; + } + + [TearDown] + public void TearDown() + { + if (world != null) Object.DestroyImmediate(world.gameObject); + if (highHealthEnemy != null) Object.DestroyImmediate(highHealthEnemy); + if (settings != null) Object.DestroyImmediate(settings); + } + + [Test] + public void SuccessfulAttackAdvancesAuthoredFramesThenReturnsToIdle() + { + Defense25DTower tower = BuildTower(DefenseTowerKind.Direct, 0); + Defense25DEnemy target = SpawnTarget(tower); + Sprite[] attack = DefenseTowerArt.Load(DefenseTowerArt.BodyKey(tower.Kind, tower.Tier, "Attack01")); + Sprite[] idle = DefenseTowerArt.Load(DefenseTowerArt.BodyKey(tower.Kind, tower.Tier, "Idle")); + SpriteRenderer body = tower.transform.Find("Tower billboard").GetComponent(); + + tower.Step(0f); + Assert.That(tower.IsBodyAttacking, Is.True); + Assert.That(body.sprite, Is.EqualTo(attack[0])); + + tower.Step(.125f); + Assert.That(tower.BodyAnimationFrame, Is.EqualTo(1)); + Assert.That(body.sprite, Is.EqualTo(attack[1])); + + target.Actor.ResetAt(tower.Position + Vector2.right + * (GameplayConstants.Current.Defense.TowerRange + 2f)); + Physics.SyncTransforms(); + tower.Step(.625f); + Assert.That(tower.IsBodyAttacking, Is.False); + Assert.That(tower.BodyAnimationFrame, Is.EqualTo(0)); + Assert.That(body.sprite, Is.EqualTo(idle[0])); + Assert.That(target.IsAvailable, Is.True); + } + + [Test] + public void FailedShotRestoresThePriorBodyAnimationState() + { + Defense25DTower tower = BuildTower(DefenseTowerKind.Lightning, 0); + tower.Step(.125f); + + SpriteRenderer body = tower.transform.Find("Tower billboard").GetComponent(); + Sprite previousSprite = body.sprite; + bool previousFlip = body.flipX; + int previousFrame = tower.BodyAnimationFrame; + bool previousAttacking = tower.IsBodyAttacking; + Defense25DEnemy target = SpawnTarget(tower); + target.gameObject.layer = 0; + Physics.SyncTransforms(); + + tower.StepParityAttacks(0f); + + Assert.That(tower.BodyAnimationFrame, Is.EqualTo(previousFrame)); + Assert.That(tower.IsBodyAttacking, Is.EqualTo(previousAttacking)); + Assert.That(body.flipX, Is.EqualTo(previousFlip)); + Assert.That(body.sprite, Is.EqualTo(previousSprite)); + } + + [Test] + public void ProjectileLaunchMatchesRenderedStaffTipThroughPitchedCamera() + { + Defense25DTower tower = BuildTower(DefenseTowerKind.Direct, 0); + SpawnTarget(tower); + tower.Step(0f); + + SpriteRenderer body = tower.transform.Find("Tower billboard").GetComponent(); + Camera camera = CreatePitchedCamera(body.transform.position); + Vector3 staffTip = tower.ParityStaffTipVisualWorldPosition; + GameObject projectile = GameObject.Find("Tower Projectile"); + Assert.That(projectile, Is.Not.Null); + + Vector3 projectedStaffTip = camera.WorldToScreenPoint(staffTip); + Vector3 projectedLaunch = camera.WorldToScreenPoint(projectile.transform.position); + Assert.That(projectedStaffTip.z, Is.GreaterThan(0f)); + Assert.That(projectedLaunch.z, Is.GreaterThan(0f)); + Assert.That(Vector2.Distance(projectedStaffTip, projectedLaunch), Is.LessThan(.01f), + "The projectile's rendered launch point must coincide with the active frame's staff tip."); + } + + [Test] + public void SupportAuraUsesMaskFrameAndTracksBillboardTransform() + { + Defense25DTower support = BuildTower(DefenseTowerKind.Empowering, 0); + Defense25DTower receiver = BuildTower(DefenseTowerKind.Direct, 4); + Assert.That(Vector2.Distance(support.Position, receiver.Position), + Is.LessThanOrEqualTo(GameplayConstants.Current.Defense.SupportRadius)); + + world.TowerManager.Step(0f); + + SpriteRenderer body = receiver.transform.Find("Tower billboard").GetComponent(); + SpriteRenderer aura = receiver.transform.Find("Support Aura Art").GetComponent(); + Assert.That(receiver.BodySupportAuraTier, Is.EqualTo(support.Tier)); + Assert.That(aura.enabled, Is.True); + Assert.That(aura.sprite.name, Is.EqualTo(body.sprite.name)); + Assert.That(aura.flipX, Is.EqualTo(body.flipX)); + Assert.That(Vector3.Distance(aura.transform.position, body.transform.position), Is.LessThan(.001f)); + Assert.That(Vector3.Distance(aura.transform.localScale, body.transform.localScale), Is.LessThan(.001f)); + } + + private Defense25DTower BuildTower(DefenseTowerKind kind, int pad) + { + world.Player.ResetAt(GameplayConstants.Current.Defense.GetBuildPadPosition(pad)); + world.TowerManager.AddCurrency(1000); + Assert.That(world.TowerManager.TryBuildTowerAtPad(pad, kind), Is.True, + $"pad={pad} kind={kind}"); + Defense25DTower tower = world.TowerManager.GetTowerAtPad(pad); + tower.SendMessage("LateUpdate", SendMessageOptions.DontRequireReceiver); + return tower; + } + + private Defense25DEnemy SpawnTarget(Defense25DTower tower) + { + Defense25DEnemy target = Enumerable.Range(0, + GameplayConstants.Current.Defense.PortalEnemyCount * 2) + .Select(_ => world.SpawnNextEnemy()) + .First(enemy => enemy != null && !world.IsTowerImmuneToEnemy(tower.Kind, enemy)); + target.Actor.ResetAt(tower.Position + Vector2.right); + target.ApplyStun(30f); + Physics.SyncTransforms(); + return target; + } + + private Camera CreatePitchedCamera(Vector3 target) + { + GameObject cameraObject = new("Staff tip launch projection camera"); + Camera camera = cameraObject.AddComponent(); + camera.orthographic = true; + camera.transform.rotation = Quaternion.Euler(settings.CameraPitch, 0f, 0f); + camera.transform.position = target - camera.transform.forward * 10f; + camera.transform.SetParent(world.transform, true); + return camera; + } + } +} diff --git a/Assets/_Project/Tests/PlayMode/Defense25DTowerBodyAnimationPlayModeTests.cs.meta b/Assets/_Project/Tests/PlayMode/Defense25DTowerBodyAnimationPlayModeTests.cs.meta new file mode 100644 index 0000000..8a931be --- /dev/null +++ b/Assets/_Project/Tests/PlayMode/Defense25DTowerBodyAnimationPlayModeTests.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 84c386504702483db01d1fc7617fa2ec +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/_Project/Tests/PlayMode/Defense25DTowerPlayModeTests.cs b/Assets/_Project/Tests/PlayMode/Defense25DTowerPlayModeTests.cs new file mode 100644 index 0000000..305a148 --- /dev/null +++ b/Assets/_Project/Tests/PlayMode/Defense25DTowerPlayModeTests.cs @@ -0,0 +1,403 @@ +using System; +using System.Collections; +using System.Collections.Generic; +using System.Linq; +using System.Reflection; +using BumpCombat.Defense; +using BumpCombat.Defense.Comparison; +using BumpCombat.Constants; +using BumpCombat.Player; +using NUnit.Framework; +using UnityEngine; +using UnityEditor; +using UnityEngine.TestTools; + +namespace BumpCombat.Tests +{ + public sealed class Defense25DTowerPlayModeTests + { + private Defense25DComparison world; + private Defense25DComparisonSettings settings; + + [UnitySetUp] + public IEnumerator SetUp() + { + settings = UnityEngine.Object.Instantiate(AssetDatabase.LoadAssetAtPath( + "Assets/_Project/Constants/Defense25DComparison/ComparisonSettings.asset")); + world = new GameObject("2.5D tower test").AddComponent(); + world.Initialize(settings); + world.AutoSpawn = false; + world.ReadInput = false; + yield return null; + } + + [UnityTearDown] + public IEnumerator TearDown() + { + if (world != null) UnityEngine.Object.Destroy(world.gameObject); + if (settings != null) UnityEngine.Object.Destroy(settings); + yield return null; + } + + [Test] + public void EachTowerFamilyCanBeBuiltOnAnEmptyPadWithItsConfiguredCost() + { + Type managerType = typeof(Defense25DComparison).Assembly.GetType( + "BumpCombat.Defense.Comparison.Defense25DTowerManager"); + Assert.That(managerType, Is.Not.Null, + "The 2.5D run needs a tower manager that owns pads, construction currency, and tower instances."); + + Component manager = world.GetComponent(managerType); + Assert.That(manager, Is.Not.Null); + MethodInfo build = managerType.GetMethod("TryBuildTowerAtPad", + new[] { typeof(int), typeof(DefenseTowerKind) }); + MethodInfo getTower = managerType.GetMethod("GetTowerAtPad", new[] { typeof(int) }); + Assert.That(build, Is.Not.Null); + Assert.That(getTower, Is.Not.Null); + + foreach (DefenseTowerKind kind in new[] + { + DefenseTowerKind.Direct, + DefenseTowerKind.Summon, + DefenseTowerKind.Lightning, + DefenseTowerKind.Cold, + DefenseTowerKind.Seismic, + DefenseTowerKind.Empowering, + }) + { + world.ResetRun(); + world.Player.ResetAt(GameplayConstants.Current.Defense.GetBuildPadPosition(0)); + Physics.SyncTransforms(); + + Assert.That(build.Invoke(manager, new object[] { 0, kind }), Is.EqualTo(true), kind.ToString()); + object tower = getTower.Invoke(manager, new object[] { 0 }); + Assert.That(tower, Is.Not.Null, kind.ToString()); + Type towerType = tower.GetType(); + Assert.That(towerType.GetProperty("Kind").GetValue(tower), Is.EqualTo(kind)); + Assert.That(towerType.GetProperty("Tier").GetValue(tower), Is.EqualTo(1)); + Assert.That(towerType.GetProperty("Branch").GetValue(tower), + Is.EqualTo(DefenseTowerBranch.None)); + Assert.That(managerType.GetProperty("ConstructionCurrency").GetValue(manager), + Is.EqualTo(GameplayConstants.Current.Defense.InitialConstructionCurrency - BuildCost(kind))); + } + } + + [Test] + public void TowerEfficiencyIsZeroUntilARealTowerIsBuilt() + { + world.Player.ResetAt(world.Settings.TowerPosition); + + Assert.That(world.TowerEfficiency, Is.EqualTo(0f)); + + world.Player.ResetAt(GameplayConstants.Current.Defense.GetBuildPadPosition(0)); + Assert.That(world.TowerManager.TryBuildTowerAtPad(0, DefenseTowerKind.Direct), Is.True); + + Assert.That(world.TowerEfficiency, Is.EqualTo(1f)); + } + + [Test] + public void ImbuingIntervalUsesFirstConstructedSupportEvenWhenPadOrderIsReversed() + { + Defense25DTowerManager manager = world.TowerManager; + manager.AddCurrency(10000); + Defense25DTower firstBuilt = BuildTower(4, DefenseTowerKind.Empowering); + UpgradeToTierFourImbuing(manager, 4); + Defense25DTower secondBuilt = BuildTower(0, DefenseTowerKind.Empowering); + UpgradeToTierFourImbuing(manager, 0); + Defense25DTower receiver = BuildTower(2, DefenseTowerKind.Direct); + + SetTowerPosition(firstBuilt, Vector2.zero); + SetTowerPosition(secondBuilt, new Vector2(6.5f, 0f)); + SetTowerPosition(receiver, new Vector2(3.25f, 0f)); + world.Player.ResetAt(Vector2.zero); + manager.Step(0f); + + Assert.That(manager.TowersInConstructionOrder[0], Is.SameAs(firstBuilt)); + Assert.That(manager.TowersInConstructionOrder[1], Is.SameAs(secondBuilt)); + Assert.That(receiver.AttackIntervalMultiplier, + Is.EqualTo(GameplayConstants.Current.Defense.ImbuingAttackIntervalMultiplier).Within(.001f), + "The first built in-range Imbuing tower wins before the later, out-of-sight source."); + } + + [Test] + public void NearestTowerBuildUsesTheClosestEmptyPadWithinInteractionRange() + { + Vector2 pad = GameplayConstants.Current.Defense.GetBuildPadPosition(0); + world.Player.ResetAt(pad); + MethodInfo buildNearest = typeof(Defense25DComparison).GetMethod("TryBuildNearestTower", + BindingFlags.Instance | BindingFlags.NonPublic); + Assert.That(buildNearest, Is.Not.Null); + + Assert.That(buildNearest.Invoke(world, new object[] { DefenseTowerKind.Direct }), Is.EqualTo(true)); + Assert.That(world.TowerManager.GetTowerAtPad(0), Is.Not.Null); + } + + [Test] + public void EveryTowerFamilyCanReachBothTierFourBranches() + { + Type managerType = typeof(Defense25DComparison).Assembly.GetType( + "BumpCombat.Defense.Comparison.Defense25DTowerManager"); + Component manager = world.GetComponent(managerType); + MethodInfo build = managerType.GetMethod("TryBuildTowerAtPad", + new[] { typeof(int), typeof(DefenseTowerKind) }); + MethodInfo upgrade = managerType.GetMethod("TryUpgradeTowerAtPad", + new[] { typeof(int), typeof(DefenseTowerBranch) }); + MethodInfo addCurrency = managerType.GetMethod("AddCurrency"); + MethodInfo getTower = managerType.GetMethod("GetTowerAtPad"); + foreach (DefenseTowerKind kind in System.Enum.GetValues(typeof(DefenseTowerKind))) + { + foreach (DefenseTowerBranch branch in new[] { DefenseTowerBranch.First, DefenseTowerBranch.Second }) + { + world.ResetRun(); + world.Player.ResetAt(GameplayConstants.Current.Defense.GetBuildPadPosition(0)); + addCurrency.Invoke(manager, new object[] { 1000 }); + Assert.That(build.Invoke(manager, new object[] { 0, kind }), Is.EqualTo(true), kind.ToString()); + Assert.That(upgrade.Invoke(manager, new object[] { 0, DefenseTowerBranch.None }), Is.EqualTo(true)); + Assert.That(upgrade.Invoke(manager, new object[] { 0, DefenseTowerBranch.None }), Is.EqualTo(true)); + Assert.That(upgrade.Invoke(manager, new object[] { 0, branch }), Is.EqualTo(true)); + object tower = getTower.Invoke(manager, new object[] { 0 }); + Assert.That(tower.GetType().GetProperty("Tier").GetValue(tower), Is.EqualTo(4)); + Assert.That(tower.GetType().GetProperty("Branch").GetValue(tower), Is.EqualTo(branch)); + if (kind == DefenseTowerKind.Summon) + { + int expected = branch == DefenseTowerBranch.First ? 1 + : GameplayConstants.Current.Defense.SummonUnitCount + 4; + Assert.That(world.Summons.Count, Is.EqualTo(expected), branch.ToString()); + } + } + } + } + + [Test] + public void DirectFireballDealsDamageOnlyAfterItsSourceFlightDuration() + { + world.Player.ResetAt(GameplayConstants.Current.Defense.GetBuildPadPosition(0)); + Assert.That(world.TowerManager.TryBuildTowerAtPad(0, DefenseTowerKind.Direct), Is.True); + Defense25DTower tower = world.TowerManager.GetTowerAtPad(0); + Defense25DEnemy target = null; + for (int i = 0; i < 2; i++) + { + Defense25DEnemy candidate = world.SpawnNextEnemy(); + if (!world.IsTowerImmuneToEnemy(DefenseTowerKind.Direct, candidate)) + { + target = candidate; + break; + } + } + Assert.That(target, Is.Not.Null); + target.Actor.ResetAt(tower.Position + Vector2.right); + Physics.SyncTransforms(); + float health = target.Actor.CurrentHealth; + + tower.StepParityAttacks(0f); + Assert.That(target.Actor.CurrentHealth, Is.EqualTo(health), "launch must not apply damage"); + tower.StepParityAttacks(.17f); + Assert.That(target.Actor.CurrentHealth, Is.EqualTo(health), "projectile is still in flight"); + tower.StepParityAttacks(.01f); + + Assert.That(target.Actor.CurrentHealth, Is.LessThan(health), "impact callback applies damage"); + } + + [Test] + public void ScoutAttackDamagesAfterOriginalLaunchAndFlightTiming() + { + Vector2 pad = GameplayConstants.Current.Defense.GetBuildPadPosition(0); + world.Player.ResetAt(pad); + Assert.That(world.TowerManager.TryBuildTowerAtPad(0, DefenseTowerKind.Summon), Is.True); + Defense25DTower tower = world.TowerManager.GetTowerAtPad(0); + world.TowerManager.AddCurrency(1000); + Assert.That(world.TowerManager.TryUpgradeTowerAtPad(0, DefenseTowerBranch.None), Is.True); + Assert.That(world.TowerManager.TryUpgradeTowerAtPad(0, DefenseTowerBranch.None), Is.True); + Assert.That(world.TowerManager.TryUpgradeTowerAtPad(0, DefenseTowerBranch.Second), Is.True); + Defense25DSummon scout = world.Summons[0]; + Defense25DEnemy target = world.SpawnNextEnemy(); + if (world.IsTowerImmuneToEnemy(DefenseTowerKind.Summon, target)) + target = world.SpawnNextEnemy(); + target.Actor.ResetAt(tower.Position + Vector2.right); + Physics.SyncTransforms(); + float health = target.Actor.CurrentHealth; + + Assert.That(tower.TryAttackSummon(scout, target), Is.True); + world.TowerManager.Step(.47f); + Assert.That(target.Actor.CurrentHealth, Is.EqualTo(health), "the scout's projectile has not launched"); + world.TowerManager.Step(.01f); + Assert.That(target.Actor.CurrentHealth, Is.EqualTo(health), "the projectile has only just launched"); + world.TowerManager.Step(.159f); + Assert.That(target.Actor.CurrentHealth, Is.EqualTo(health), "the projectile is still in flight"); + world.TowerManager.Step(.001f); + + Assert.That(target.Actor.CurrentHealth, Is.LessThan(health)); + } + + [UnityTest] + public IEnumerator OwnedSummonsKeepAttackingTheirTowerCommandTarget() + { + Vector2 pad = GameplayConstants.Current.Defense.GetBuildPadPosition(0); + world.Player.ResetAt(pad); + Assert.That(world.TowerManager.TryBuildTowerAtPad(0, DefenseTowerKind.Summon), Is.True); + Defense25DTower tower = world.TowerManager.GetTowerAtPad(0); + Defense25DSummon summon = world.Summons[0]; + Defense25DEnemy target = world.SpawnNextEnemy(); + if (world.IsTowerImmuneToEnemy(DefenseTowerKind.Summon, target)) + target = world.SpawnNextEnemy(); + target.Actor.ResetAt(summon.Actor.Position + Vector2.left * .9f); + target.ApplyStun(2f); + Physics.SyncTransforms(); + float health = target.Actor.CurrentHealth; + + tower.Step(0f); + summon.Step(0f); + Assert.That(summon.IsAttacking, Is.True); + + for (int i = 0; i < 60 && target.Actor.CurrentHealth >= health; i++) + yield return new WaitForFixedUpdate(); + Assert.That(target.Actor.CurrentHealth, Is.LessThan(health)); + } + + [UnityTest] + public IEnumerator BothPortalsSupplyTheirOwnRoutesAndRewardsGateVictory() + { + int addedArtifacts = 0; + foreach (ActiveArtifactDefinition artifact in world.Loadout.Catalog) + { + if (world.Loadout.TryAddArtifact(artifact)) addedArtifacts++; + if (addedArtifacts == 3) break; + } + Assert.That(addedArtifacts, Is.EqualTo(3), "The route/reward fixture needs three eligible owned artifacts."); + + int expectedPerPortal = GameplayConstants.Current.Defense.PortalEnemyCount; + int totalExpected = expectedPerPortal * 2; + List supplied = new(); + for (int i = 0; i < totalExpected; i++) + { + Defense25DEnemy enemy = world.SpawnNextEnemy(); + Assert.That(enemy, Is.Not.Null, $"supply {i + 1}/{totalExpected}"); + supplied.Add(enemy); + } + Assert.That(world.SpawnNextEnemy(), Is.Null); + PropertyInfo laneProperty = typeof(Defense25DEnemy).GetProperty("Lane"); + Assert.That(laneProperty, Is.Not.Null); + for (int i = 0; i < supplied.Count; i++) + { + int lane = (int)laneProperty.GetValue(supplied[i]); + Assert.That(lane, Is.EqualTo(i % 2), $"alternating portal supply #{i}"); + Assert.That(Vector2.Distance(supplied[i].Actor.Position, + GameplayConstants.Current.Defense.GetPortalPosition(lane)), Is.LessThan(.001f)); + } + + foreach (Defense25DEnemy enemy in supplied) + enemy.Actor.TakeDamage(10000f, Vector2.zero); + yield return new WaitForSeconds(.1f); + + Assert.That(world.IsRunning, Is.True, "both field rewards must be collected before victory"); + MethodInfo collect = typeof(Defense25DComparison).GetMethod("TryCollectRewardAtLane"); + Assert.That(collect, Is.Not.Null); + for (int lane = 0; lane < 2; lane++) + { + Vector2 pickup = GameplayConstants.Current.Defense.GetPortalPosition(lane) + Vector2.up * 1.2f; + world.Player.ResetAt(pickup); + Assert.That(collect.Invoke(world, new object[] { lane }), Is.EqualTo(true), $"reward lane {lane}"); + Assert.That(world.IsRunning, Is.True, "The run must remain active while an earned relic choice is pending."); + Assert.That(world.HasPendingFieldRelicReward, Is.True, $"field relic choice lane {lane}"); + Assert.That(world.Paused, Is.True, "The earned field choice must pause the run before the next pickup."); + Assert.That(world.ResolveFieldRelicChoice(world.PendingFieldRelicChoices[0]), Is.True, + $"resolve field relic choice lane {lane}"); + } + Assert.That(world.IsRunning, Is.False); + Assert.That(typeof(Defense25DComparison).GetProperty("RunCleared").GetValue(world), Is.EqualTo(true)); + } + + [Test] + public void SummonVariantsUseAuditedCoreScaleForVisualColliderAndFoot() + { + world.TowerManager.AddCurrency(10000); + + Defense25DTower slimeTower = BuildTower(1, DefenseTowerKind.Summon); + AssertSummonVariant(slimeTower, world.Settings.SummonVisualScale); + UpgradeToTierFourImbuing(world.TowerManager, 1); + AssertSummonVariant(slimeTower, 1.55f); + + Defense25DTower scoutTower = BuildTower(2, DefenseTowerKind.Summon); + UpgradeToTierFourImbuing(world.TowerManager, 2, DefenseTowerBranch.Second); + AssertSummonVariant(scoutTower, 1.15f); + } + + [UnityTest] + public IEnumerator TowerImmunityIsResolvedForEachPortalLane() + { + Defense25DEnemy upper = world.SpawnNextEnemy(); + Defense25DEnemy lower = world.SpawnNextEnemy(); + Assert.That(upper.Lane, Is.EqualTo(0)); + Assert.That(lower.Lane, Is.EqualTo(1)); + var tuning = GameplayConstants.Current.Defense; + Assert.That(world.IsTowerImmuneToEnemy(tuning.GetPortalImmuneTowerKind(0), upper), Is.True); + Assert.That(world.IsTowerImmuneToEnemy(tuning.GetPortalImmuneTowerKind(1), lower), Is.True); + Assert.That(world.IsTowerImmuneToEnemy(tuning.GetPortalImmuneTowerKind(0), lower), Is.False); + Assert.That(world.IsTowerImmuneToEnemy(tuning.GetPortalImmuneTowerKind(1), upper), Is.False); + yield return null; + } + + private static int BuildCost(DefenseTowerKind kind) + { + var tuning = GameplayConstants.Current.Defense; + return kind switch + { + DefenseTowerKind.Direct => tuning.DirectTowerCost, + DefenseTowerKind.Summon => tuning.SummonTowerCost, + DefenseTowerKind.Lightning => tuning.LightningTowerCost, + DefenseTowerKind.Cold => tuning.ColdTowerCost, + DefenseTowerKind.Seismic => tuning.SeismicTowerCost, + DefenseTowerKind.Empowering => tuning.EmpoweringTowerCost, + _ => -1, + }; + } + + private Defense25DTower BuildTower(int pad, DefenseTowerKind kind) + { + world.Player.ResetAt(GameplayConstants.Current.Defense.GetBuildPadPosition(pad)); + Physics.SyncTransforms(); + Assert.That(world.TowerManager.TryBuildTowerAtPad(pad, kind), Is.True, $"pad={pad} kind={kind}"); + return world.TowerManager.GetTowerAtPad(pad); + } + + private static void UpgradeToTierFourImbuing(Defense25DTowerManager manager, int pad, + DefenseTowerBranch finalBranch = DefenseTowerBranch.First) + { + Assert.That(manager.TryUpgradeTowerAtPad(pad, DefenseTowerBranch.None), Is.True); + Assert.That(manager.TryUpgradeTowerAtPad(pad, DefenseTowerBranch.None), Is.True); + Assert.That(manager.TryUpgradeTowerAtPad(pad, finalBranch), Is.True); + } + + private void SetTowerPosition(Defense25DTower tower, Vector2 position) + { + typeof(Defense25DTower).GetProperty(nameof(Defense25DTower.Position)) + .SetValue(tower, position); + tower.transform.position = Defense25DActor.OnGround(position, world.Settings.GroundY); + } + + private void AssertSummonVariant(Defense25DTower tower, float scale) + { + Defense25DSummon[] units = world.Summons.Where(unit => unit.Owner == tower).ToArray(); + Assert.That(units.Length, Is.EqualTo(tower.TargetSummonCount)); + float coreDiameter = scale > 1.4f + ? Defense25DComparisonSettings.GiantSlimeBodyCoreDiameterPixels + : scale > world.Settings.SummonVisualScale + ? Defense25DComparisonSettings.LavaSlimeBodyCoreDiameterPixels + : Defense25DComparisonSettings.SlimeBodyCoreDiameterPixels; + float radius = coreDiameter * scale / Defense25DComparisonSettings.SummonBodyPixelsPerUnit * .5f; + foreach (Defense25DSummon summon in units) + { + Defense25DSprite visual = summon.Actor.Visual; + Assert.That(visual.VisualScale, Is.EqualTo(scale).Within(.001f)); + float expectedFootOffset = scale > 1.4f ? 0f + : world.Settings.SummonFootOffset * scale / world.Settings.SummonVisualScale; + Assert.That(visual.FootVisualOffset, Is.EqualTo(expectedFootOffset).Within(.001f)); + Assert.That(summon.Actor.Radius, Is.EqualTo(radius).Within(.001f)); + Assert.That(summon.Actor.BodyCollider.radius, Is.EqualTo(radius).Within(.001f)); + Assert.That(summon.Actor.BodyCollider.height, Is.EqualTo(2f * radius).Within(.001f)); + Assert.That(visual.transform.Find("Ground shadow"), Is.Null, + "The comparison actor keeps its existing authored sprite and has no added ground-shadow cylinder."); + } + } + } +} diff --git a/Assets/_Project/Tests/PlayMode/Defense25DTowerPlayModeTests.cs.meta b/Assets/_Project/Tests/PlayMode/Defense25DTowerPlayModeTests.cs.meta new file mode 100644 index 0000000..f9b80cd --- /dev/null +++ b/Assets/_Project/Tests/PlayMode/Defense25DTowerPlayModeTests.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 83a28477b5f458c4f8368ae7760c9a67 \ No newline at end of file diff --git a/Assets/_Project/Tests/PlayMode/DefensePrototypePlayModeTests.cs b/Assets/_Project/Tests/PlayMode/DefensePrototypePlayModeTests.cs index 6471fc3..ee86481 100644 --- a/Assets/_Project/Tests/PlayMode/DefensePrototypePlayModeTests.cs +++ b/Assets/_Project/Tests/PlayMode/DefensePrototypePlayModeTests.cs @@ -11,8 +11,11 @@ using BumpCombat.Enemies; using BumpCombat.Player; using BumpCombat.Progression; using BumpCombat.Spawning; +using BumpCombat.UI; using NUnit.Framework; using UnityEngine; +using UnityEngine.InputSystem; +using UnityEngine.InputSystem.LowLevel; using UnityEngine.SceneManagement; using UnityEngine.TestTools; using UnityEngine.UI; @@ -29,6 +32,7 @@ namespace BumpCombat.PlayModeTests private DefenseConstants touchedDefenseConstants; private int originalArrivalBaseDamage; private float originalPortalSpawnInterval; + private float originalSummonRespawnSeconds; private bool defenseConstantsSaved; private ArenaCameraFollow captureCameraFollow; private Camera captureCamera; @@ -59,14 +63,68 @@ namespace BumpCombat.PlayModeTests } keyCount++; } - Assert.That(keyCount, Is.EqualTo(132)); + Assert.That(keyCount, Is.EqualTo(134)); AssertSpritePivot("Effects/Fire/Meteor-Flight", 0.625f, 0.375f); AssertSpritePivot("Effects/Fire/Flamethrower", 0.09375f, 0.5f); + AssertSpritePivot("Effects/Fire/Eyeball Monster_Beam", 0.5f, 0.5f); AssertSpritePivot("Effects/Lightning/T1-Bolt", 0.08f, 0.57f); AssertSpritePivot("Effects/Control/T1-Aura", 0.5f, 0.4f); AssertSpritePivot("Summons/LavaSlime/GroundSpike-Segment", 0.5f, 0.4f); } + [Test] + public void TowerImpactCleanResources_AreTowerOnlyFourFrameSprites() + { + (DefenseTowerKind kind, string path)[] towerResources = + { + (DefenseTowerKind.Direct, "Combat/TowerImpact-Direct-Clean-v1"), + (DefenseTowerKind.Lightning, "Combat/TowerImpact-Lightning-Clean-v1"), + (DefenseTowerKind.Cold, "Combat/TowerImpact-Cold-Clean-v1"), + (DefenseTowerKind.Seismic, "Combat/TowerImpact-Seismic-Clean-v1"), + }; + + foreach ((DefenseTowerKind kind, string path) in towerResources) + { + Assert.That(CombatFeedback.GetTowerImpactResourcePath(kind), Is.EqualTo(path), kind.ToString()); + Sprite[] frames = Resources.LoadAll(path); + Assert.That(frames, Has.Length.EqualTo(4), path); + foreach (Sprite frame in frames) + { + Assert.That(frame.pixelsPerUnit, Is.EqualTo(32f), path); + Assert.That(frame.texture.filterMode, Is.EqualTo(FilterMode.Point), path); + Assert.That(frame.texture.mipmapCount, Is.EqualTo(1), path); + } + } + + Assert.That(CombatFeedback.GetTowerImpactResourcePath(DefenseTowerKind.Summon), Is.Null); + Assert.That(CombatFeedback.GetArtifactImpactResourcePath( + ActiveArtifactEffect.Phoenix, charged: false), + Is.EqualTo("Artifacts/ThreeColor-v1/Combat/ArtifactImpact-SearingRay-Normal-v1")); + Assert.That(CombatFeedback.GetArtifactImpactResourcePath( + ActiveArtifactEffect.ThunderCrash, charged: false), + Is.EqualTo("Combat/ArtifactImpact-ThunderCrash-Normal-v1")); + Assert.That(CombatFeedback.GetArtifactImpactResourcePath( + ActiveArtifactEffect.Dash, charged: false), + Is.EqualTo("Combat/ArtifactImpact-Dash-Normal-v1")); + } + + [UnityTest] + public IEnumerator DelayedNonTravelSequenceKeepsItsConfiguredPosition() + { + const string key = "Effects/Lightning/ImpactWave"; + GameObject effect = new("Delayed Non-Travel Effect Test"); + Vector2 position = new(3.25f, -1.75f); + effect.transform.position = position; + SpriteRenderer renderer = effect.AddComponent(); + DefenseSpriteSequence sequence = effect.AddComponent(); + sequence.Play(renderer, key, DefenseTowerArt.Load(key), false, startDelay: 0.01f); + + yield return new WaitForSecondsRealtime(0.04f); + + Assert.That(Vector2.Distance(effect.transform.position, position), Is.LessThan(0.0001f)); + UnityEngine.Object.Destroy(effect); + } + [UnityTest] public IEnumerator DefenseTowerLightningBeamFitsMeasuredBoundsAndCollapsesAtZeroDistance() { @@ -175,6 +233,7 @@ namespace BumpCombat.PlayModeTests { RestoreCameraFixture(); RestoreDefenseConstants(); + DefensePrototypeController.CancelTowerHitTestRun(); RunManager runManager = RunManager.Instance; if (runManager != null) { @@ -334,9 +393,20 @@ namespace BumpCombat.PlayModeTests float lowerHealth = lowerAgent.Enemy.CurrentHealth; Assert.That(upper.TryAttack(upperAgent), Is.True); Assert.That(lowerAgent.Enemy.TryReposition(lower.transform.position), Is.True); + Physics2D.SyncTransforms(); Assert.That(lower.TryPulse(), Is.True); Assert.That(upperAgent.Enemy.CurrentHealth, Is.LessThan(upperHealth)); - Assert.That(lowerAgent.Enemy.CurrentHealth, Is.LessThan(lowerHealth)); + Assert.That(lowerAgent.Enemy.CurrentHealth, Is.EqualTo(lowerHealth), + "Direct projectile damage must wait until its visible travel reaches the target."); + + float deadline = Time.realtimeSinceStartup + 1f; + while (Time.realtimeSinceStartup < deadline + && lowerAgent.Enemy.CurrentHealth >= lowerHealth) + { + yield return null; + } + Assert.That(lowerAgent.Enemy.CurrentHealth, Is.LessThan(lowerHealth), + "Direct projectile damage must apply when the visible travel arrives."); } [UnityTest] @@ -449,23 +519,22 @@ namespace BumpCombat.PlayModeTests Assert.That(bolt, Is.Not.Null); Assert.That(bolt.localScale.x, Is.EqualTo(0f), "A zero-distance strike must collapse its visible bolt to zero length."); - Transform wave = effect.transform.Find("Impact Sprite"); - SpriteRenderer waveRenderer = wave?.GetComponent(); - Assert.That(waveRenderer, Is.Not.Null); - Vector2 expectedShadow = DefenseTowerArtBounds.EnemyShadowWorldPosition( - target.Enemy, target.transform.position); - Vector2 waveLocalCenter = (DefenseTowerArtBounds.ImpactWaveCenterPixels( - 0, waveRenderer.sprite) - waveRenderer.sprite.pivot) - / waveRenderer.sprite.pixelsPerUnit; - Assert.That(Vector2.Distance(wave.TransformPoint(waveLocalCenter), expectedShadow), - Is.LessThan(0.0001f), - "The visible impact wave center must land on the target's shadow anchor."); + Assert.That(effect.transform.Find("Impact Sprite"), Is.Null, + "T1 lightning keeps its bolt but does not create an impact wave."); } SpriteRenderer impact = effect.transform.Find("Impact Sprite") ?.GetComponent(); - Assert.That(impact, Is.Not.Null); - Assert.That(impact.sprite, Is.Not.Null, - "The placeholder impact must be replaceable by an imported sprite."); + if (kinds[i] == DefenseTowerKind.Lightning) + { + Assert.That(impact, Is.Null, + "T1 lightning has no Impact Sprite while its bolt remains visible."); + } + else + { + Assert.That(impact, Is.Not.Null); + Assert.That(impact.sprite, Is.Not.Null, + "The placeholder impact must be replaceable by an imported sprite."); + } } } @@ -500,6 +569,698 @@ namespace BumpCombat.PlayModeTests "An automatic attack must show the same effect as a manual pulse."); } + [UnityTest] + public IEnumerator DefenseTowerHitTest_ShowcaseBuildsAndFiltersSixFixedDummyPairs() + { + yield return StartDefenseRun(prepareTowerHitTest: true); + DefensePrototypeController controller = DefensePrototypeController.Instance; + Assert.That(controller.IsTowerHitTestLayout, Is.True); + + DefenseTowerKind[] kinds = DefenseTowerHitTestLayout.KindsByPad; + DefenseRouteAgent[] allAgents = FindRouteAgents(); + DefenseTower[] towers = FindTowers(); + Assert.That(towers, Has.Length.EqualTo(6)); + int dummyCount = 0; + for (int i = 0; i < allAgents.Length; i++) + { + if (allAgents[i].Enemy != null + && allAgents[i].Enemy.name.StartsWith("Tower Test Dummy ", StringComparison.Ordinal)) + dummyCount++; + } + Assert.That(dummyCount, Is.EqualTo(6)); + + for (int pad = 0; pad < kinds.Length; pad++) + { + DefenseTowerKind kind = kinds[pad]; + DefenseTower tower = FindTower(pad, kind); + DefenseRouteAgent dummy = FindTowerTestDummy(kind); + Assert.That(tower, Is.Not.Null, $"Missing T1 {kind} tower."); + Assert.That(tower.Tier, Is.EqualTo(1)); + Assert.That(dummy, Is.Not.Null, $"Missing {kind} target dummy."); + Assert.That(Vector2.Distance(dummy.transform.position, + DefenseTowerHitTestLayout.GetDummyPosition(kind)), Is.LessThan(0.01f)); + Rigidbody2D body = dummy.Enemy.GetComponent(); + Assert.That(body, Is.Not.Null); + Assert.That(body.constraints, Is.EqualTo(RigidbodyConstraints2D.FreezeAll), + $"{kind} dummy must remain fixed."); + + float distance = Vector2.Distance(tower.transform.position, dummy.transform.position); + float range = kind == DefenseTowerKind.Summon + ? controller.Tuning.SummonAreaRadius : controller.Tuning.TowerRange; + if (kind == DefenseTowerKind.Direct) + { + Assert.That(distance, Is.EqualTo(2.19f).Within(0.08f)); + Assert.That(distance, Is.LessThan(range)); + } + else if (kind == DefenseTowerKind.Summon) + { + Assert.That(distance, Is.EqualTo(1.84f).Within(0.08f)); + Assert.That(distance, Is.LessThan(range)); + } + DefenseRouteAgent nearest = controller.FindNearestTarget( + tower.transform.position, range, kind, + kind == DefenseTowerKind.Cold || kind == DefenseTowerKind.Seismic); + Assert.That(nearest, Is.SameAs(dummy), $"{kind} must choose its own dummy."); + + List areaTargets = controller.GetTargetsInRange( + dummy.transform.position, 100f, kind); + CollectionAssert.Contains(areaTargets, dummy); + for (int other = 0; other < kinds.Length; other++) + { + if (other != pad) + CollectionAssert.DoesNotContain(areaTargets, FindTowerTestDummy(kinds[other]), + $"{kind} area targeting must exclude {kinds[other]} dummy."); + } + } + + DefenseTower support = FindTower(4, DefenseTowerKind.Empowering); + DefenseRouteAgent directDummy = FindTowerTestDummy(DefenseTowerKind.Direct); + Assert.That(Vector2.Distance(support.transform.position, directDummy.transform.position), + Is.LessThan(controller.Tuning.SupportRadius)); + PrepareCameraForDefenseCapture(); + CaptureCameraToPath( + @"D:\project\game\output\tower-hit-test-v1\tower-hit-test-layout.png"); + } + + [UnityTest] + public IEnumerator DefenseTowerHitTest_ReportsActualOverkillAndDummyDeathHasNoRewards() + { + yield return StartDefenseRun(prepareTowerHitTest: true); + DefensePrototypeController controller = DefensePrototypeController.Instance; + DefenseTower tower = FindTower(0, DefenseTowerKind.Direct); + DefenseRouteAgent dummy = FindTowerTestDummy(DefenseTowerKind.Direct); + RunHUD hud = UnityEngine.Object.FindAnyObjectByType(); + ExperienceSystem experience = UnityEngine.Object.FindAnyObjectByType(); + Assert.That(tower, Is.Not.Null); + Assert.That(dummy, Is.Not.Null); + Assert.That(hud, Is.Not.Null); + Assert.That(experience, Is.Not.Null); + + yield return DisableTowersAndDrainPopups(hud); + int currencyBefore = controller.ConstructionCurrency; + int experienceBefore = experience.CurrentExperience; + int deathEvents = 0; + GameObject killedDummy = dummy.gameObject; + Action onEnemyDied = target => + { + if (target == killedDummy) deathEvents++; + }; + CombatEvents.OnEnemyDied += onEnemyDied; + try + { + SetEnemyCurrentHealth(dummy.Enemy, 3f); + Assert.That(controller.TryApplyTowerDamage(tower, dummy, 100f), Is.True); + Assert.That(dummy.Enemy.CurrentHealth, Is.Zero); + Assert.That(hud.ActiveDamagePopupCount, Is.EqualTo(1)); + yield return null; + Text popup = FindActiveTowerDamagePopup(); + Assert.That(popup, Is.Not.Null); + Assert.That(popup.text, Is.EqualTo("-3"), + "Overkill should show the actual 3 HP lost, not the requested 100 damage."); + Assert.That(FindObjectsByName("Tower Impact Direct"), Is.GreaterThan(0), + "A real tower HP hit should show CombatFeedback impact."); + + Vector2 canonicalPosition = DefenseTowerHitTestLayout.GetDummyPosition( + DefenseTowerKind.Direct); + float deadline = Time.realtimeSinceStartup + ReadyTimeoutSeconds; + DefenseRouteAgent replacement = null; + while (replacement == null && Time.realtimeSinceStartup < deadline) + { + yield return null; + replacement = FindTowerTestDummy(DefenseTowerKind.Direct); + } + Assert.That(replacement, Is.Not.Null, "The dummy should respawn after death."); + Assert.That(replacement, Is.Not.SameAs(dummy)); + CollectionAssert.Contains( + controller.GetTargetsInRange(canonicalPosition, 0.1f, DefenseTowerKind.Direct), + replacement, + "The respawned dummy should be registered in the controller target set."); + Assert.That(Vector2.Distance(replacement.transform.position, canonicalPosition), + Is.LessThan(0.01f)); + Assert.That(replacement.Enemy.GetComponent().constraints, + Is.EqualTo(RigidbodyConstraints2D.FreezeAll)); + Assert.That(experience.CurrentExperience, Is.EqualTo(experienceBefore)); + Assert.That(controller.ConstructionCurrency, Is.EqualTo(currencyBefore)); + Assert.That(controller.GetBaseHealth(0), Is.EqualTo(controller.Tuning.BaseMaximumHealth)); + Assert.That(deathEvents, Is.Zero, + "Showcase dummy deaths must not emit the regular enemy kill event."); + } + finally + { + CombatEvents.OnEnemyDied -= onEnemyDied; + } + } + + [UnityTest] + public IEnumerator DefenseTowerHitTest_SupportTiersAndSmotheringAmplifyOnlyTheirTarget() + { + yield return StartDefenseRun(prepareTowerHitTest: true); + RunHUD hud = UnityEngine.Object.FindAnyObjectByType(); + yield return DisableTowersAndDrainPopups(hud); + DefensePrototypeController controller = DefensePrototypeController.Instance; + DefenseTower direct = FindTower(0, DefenseTowerKind.Direct); + DefenseTower support = FindTower(4, DefenseTowerKind.Empowering); + DefenseRouteAgent target = FindTowerTestDummy(DefenseTowerKind.Direct); + direct.enabled = true; + support.enabled = true; + SetTestField(direct, "attackCooldown", 60f); + Assert.That(controller.GetActiveSupportFor(direct), Is.SameAs(support)); + Assert.That(Vector2.Distance(support.transform.position, target.transform.position), + Is.LessThan(controller.Tuning.SupportRadius)); + + float previousDamage = 0f; + for (int tier = 1; tier <= 3; tier++) + { + if (tier > 1) Assert.That(support.Upgrade(DefenseTowerBranch.None), Is.True); + float maximumHealth = target.Enemy.MaximumHealth; + SetEnemyCurrentHealth(target.Enemy, maximumHealth); + Assert.That(controller.TryApplyTowerDamage(direct, target, 10f), Is.True); + float appliedDamage = maximumHealth - target.Enemy.CurrentHealth; + Assert.That(appliedDamage, Is.GreaterThan(previousDamage), + $"Support T{tier} should increase damage to its nearby damage tower."); + previousDamage = appliedDamage; + } + + Assert.That(support.Upgrade(DefenseTowerBranch.Second), Is.True); + Assert.That(support.Tier, Is.EqualTo(4)); + Assert.That(support.Branch, Is.EqualTo(DefenseTowerBranch.Second)); + float targetMaximumHealth = target.Enemy.MaximumHealth; + SetEnemyCurrentHealth(target.Enemy, targetMaximumHealth); + Assert.That(controller.TryApplyTowerDamage(direct, target, 10f), Is.True); + float smotheringDamage = targetMaximumHealth - target.Enemy.CurrentHealth; + Assert.That(smotheringDamage, Is.GreaterThan(previousDamage)); + + DefenseRouteAgent otherTarget = FindTowerTestDummy(DefenseTowerKind.Lightning); + Assert.That(Vector2.Distance(support.transform.position, otherTarget.transform.position), + Is.GreaterThan(controller.Tuning.SupportRadius)); + float otherMaximumHealth = otherTarget.Enemy.MaximumHealth; + SetEnemyCurrentHealth(otherTarget.Enemy, otherMaximumHealth); + DefenseTower lightning = FindTower(3, DefenseTowerKind.Lightning); + Assert.That(controller.TryApplyTowerDamage(lightning, otherTarget, 10f), Is.True); + Assert.That(otherMaximumHealth - otherTarget.Enemy.CurrentHealth, + Is.LessThan(smotheringDamage), + "Smothering should not boost a target outside its radius."); + } + + [UnityTest] + public IEnumerator DefenseTowerHitTest_SummonUpdateDamagesItsOwnDummy() + { + yield return StartDefenseRun(prepareTowerHitTest: true); + RunHUD hud = UnityEngine.Object.FindAnyObjectByType(); + yield return DisableTowersAndDrainPopups(hud); + DefenseTower summon = FindTower(1, DefenseTowerKind.Summon); + DefenseRouteAgent ownDummy = FindTowerTestDummy(DefenseTowerKind.Summon); + Assert.That(summon, Is.Not.Null); + Assert.That(ownDummy, Is.Not.Null); + float[] otherHealth = new float[DefenseTowerHitTestLayout.KindsByPad.Length]; + for (int i = 0; i < otherHealth.Length; i++) + { + DefenseTowerKind kind = DefenseTowerHitTestLayout.KindsByPad[i]; + otherHealth[i] = FindTowerTestDummy(kind).Enemy.CurrentHealth; + } + SetEnemyCurrentHealth(ownDummy.Enemy, ownDummy.Enemy.MaximumHealth); + float initialHealth = ownDummy.Enemy.CurrentHealth; + summon.enabled = true; + + float deadline = Time.realtimeSinceStartup + ReadyTimeoutSeconds; + while (Time.realtimeSinceStartup < deadline + && ownDummy.Enemy != null && ownDummy.Enemy.CurrentHealth >= initialHealth) + yield return new WaitForFixedUpdate(); + Assert.That(ownDummy.Enemy.CurrentHealth, Is.LessThan(initialHealth), + "A live summon unit must autonomously acquire and damage its dedicated dummy."); + for (int i = 0; i < otherHealth.Length; i++) + { + DefenseTowerKind kind = DefenseTowerHitTestLayout.KindsByPad[i]; + if (kind == DefenseTowerKind.Summon) continue; + Assert.That(FindTowerTestDummy(kind).Enemy.CurrentHealth, Is.EqualTo(otherHealth[i]), + $"Summon attack must not damage the {kind} dummy."); + } + } + + [UnityTest] + public IEnumerator DefenseTowerHitTest_FireT4SecondAutomaticallyHitsWithinItsReducedRange() + { + yield return StartDefenseRun(prepareTowerHitTest: true); + RunHUD hud = UnityEngine.Object.FindAnyObjectByType(); + yield return DisableTowersAndDrainPopups(hud); + DefensePrototypeController controller = DefensePrototypeController.Instance; + DefenseTower fire = FindTower(0, DefenseTowerKind.Direct); + DefenseRouteAgent target = FindTowerTestDummy(DefenseTowerKind.Direct); + Assert.That(fire.Upgrade(DefenseTowerBranch.None), Is.True); + Assert.That(fire.Upgrade(DefenseTowerBranch.None), Is.True); + Assert.That(fire.Upgrade(DefenseTowerBranch.Second), Is.True); + float targetDistance = Vector2.Distance(fire.transform.position, target.transform.position); + Assert.That(targetDistance, Is.EqualTo(2.19f).Within(0.08f)); + Assert.That(targetDistance, Is.LessThanOrEqualTo(controller.Tuning.TowerRange * 0.5f), + "Fire T4 Second must retain the 2.75 minimum range around its 2.19 target."); + SetEnemyCurrentHealth(target.Enemy, target.Enemy.MaximumHealth); + float initialHealth = target.Enemy.CurrentHealth; + SetTestField(fire, "attackCooldown", 0f); + fire.enabled = true; + + float deadline = Time.realtimeSinceStartup + ReadyTimeoutSeconds; + while (Time.realtimeSinceStartup < deadline + && target.Enemy != null && target.Enemy.CurrentHealth >= initialHealth) + yield return new WaitForFixedUpdate(); + Assert.That(target.Enemy.CurrentHealth, Is.LessThan(initialHealth), + "Fire T4 Second must autonomously hit its dedicated target at the reduced range."); + Assert.That(fire.Branch, Is.EqualTo(DefenseTowerBranch.Second)); + } + + [UnityTest] + public IEnumerator DefenseTowerHitTest_FireT4BeamScalesAndResetsAfterTargetBreak() + { + yield return StartDefenseRun(prepareTowerHitTest: true); + RunHUD hud = UnityEngine.Object.FindAnyObjectByType(); + yield return DisableTowersAndDrainPopups(hud); + DefensePrototypeController controller = DefensePrototypeController.Instance; + PlayerController player = UnityEngine.Object.FindAnyObjectByType(); + DefenseTower fire = FindTower(0, DefenseTowerKind.Direct); + DefenseRouteAgent target = FindTowerTestDummy(DefenseTowerKind.Direct); + Assert.That(fire.Upgrade(DefenseTowerBranch.None), Is.True); + Assert.That(fire.Upgrade(DefenseTowerBranch.None), Is.True); + Assert.That(fire.Upgrade(DefenseTowerBranch.Second), Is.True); + FreezeEnemyControllers(FindRouteAgents()); + SetTestField(target.Enemy, "k__BackingField", 10000f); + MovePlayerNear(player, fire.transform.position); + SetTestField(fire, "attackCooldown", 60f); + fire.enabled = true; + float before = target.Enemy.CurrentHealth; + Assert.That(fire.TryPulse(), Is.True); + float firstHit = before - target.Enemy.CurrentHealth; + float expectedBase = controller.Tuning.DirectTowerDamage + * (1f + controller.Tuning.TowerDamageBonusPerTier * (fire.Tier - 1)) + * controller.Tuning.FireT4BeamBaseDamageMultiplier; + Assert.That(firstHit, Is.EqualTo(expectedBase).Within(0.1f), + "The first hit preserves Flamethrower's existing 0.4 base coefficient."); + var beamRendererField = typeof(DefenseTower).GetField("flameBeamRenderer", + BindingFlags.Instance | BindingFlags.NonPublic); + SpriteRenderer beamRenderer = (SpriteRenderer)beamRendererField.GetValue(fire); + Assert.That(beamRenderer.transform.localScale.y, Is.EqualTo(1f).Within(0.01f)); + PrepareCameraForDefenseCapture(); + yield return new WaitForSeconds(0.55f); + Assert.That(beamRenderer.sprite.name, Does.EndWith("_1"), + "The maintained beam stays on frame 1 instead of cycling to its fade frame."); + CaptureCameraToPath(Path.GetFullPath(Path.Combine(Application.dataPath, "..", "..", "output", "cold-laser-fixes-20260925", "beam-base.png"))); + before = target.Enemy.CurrentHealth; + Assert.That(fire.TryPulse(), Is.True); + Assert.That(before - target.Enemy.CurrentHealth, Is.EqualTo(firstHit).Within(0.1f)); + yield return new WaitForSeconds(0.55f); + before = target.Enemy.CurrentHealth; + Assert.That(fire.TryPulse(), Is.True); + Assert.That(before - target.Enemy.CurrentHealth, Is.EqualTo(DamageCalculator.FinalizeDamage(expectedBase * 1.5f)).Within(0.1f)); + Assert.That(beamRenderer.transform.localScale.y, + Is.EqualTo(controller.Tuning.FireT4BeamSecondTierWidthMultiplier).Within(0.01f)); + yield return new WaitForSeconds(0.55f); + before = target.Enemy.CurrentHealth; + Assert.That(fire.TryPulse(), Is.True); + Assert.That(before - target.Enemy.CurrentHealth, Is.EqualTo(DamageCalculator.FinalizeDamage(expectedBase * 1.5f)).Within(0.1f)); + yield return new WaitForSeconds(0.45f); + before = target.Enemy.CurrentHealth; + Assert.That(fire.TryPulse(), Is.True); + Assert.That(before - target.Enemy.CurrentHealth, Is.EqualTo(DamageCalculator.FinalizeDamage(expectedBase * 2f)).Within(0.1f)); + Assert.That(beamRenderer.transform.localScale.y, + Is.EqualTo(controller.Tuning.FireT4BeamMaximumWidthMultiplier).Within(0.01f)); + CaptureCameraToPath(Path.GetFullPath(Path.Combine(Application.dataPath, "..", "..", "output", "cold-laser-fixes-20260925", "beam-max.png"))); + DefenseRouteAgent retarget = FindTowerTestDummy(DefenseTowerKind.Lightning); + Assert.That(retarget, Is.Not.Null); + Assert.That(retarget.Enemy.TryReposition(target.transform.position), Is.True); + SetTestField(retarget.Enemy, "k__BackingField", 10000f); + Physics2D.SyncTransforms(); + float streakStartedAt = (float)typeof(DefenseTower).GetField("flameBeamStreakStartedAt", + BindingFlags.Instance | BindingFlags.NonPublic).GetValue(fire); + MethodInfo pulseFlamethrower = typeof(DefenseTower).GetMethod("PulseFlamethrower", + BindingFlags.Instance | BindingFlags.NonPublic); + float tierScale = 1f + controller.Tuning.TowerDamageBonusPerTier * (fire.Tier - 1); + float retargetBefore = retarget.Enemy.CurrentHealth; + Assert.That((bool)pulseFlamethrower.Invoke(fire, new object[] { retarget, tierScale }), Is.True); + Assert.That(retargetBefore - retarget.Enemy.CurrentHealth, + Is.EqualTo(DamageCalculator.FinalizeDamage(expectedBase * 2f)).Within(0.1f), + "Changing target inside the one-second gap retains the tower's 200% damage tier."); + Assert.That((float)typeof(DefenseTower).GetField("flameBeamStreakStartedAt", + BindingFlags.Instance | BindingFlags.NonPublic).GetValue(fire), Is.EqualTo(streakStartedAt)); + beamRenderer = (SpriteRenderer)beamRendererField.GetValue(fire); + Assert.That(beamRenderer.transform.localScale.y, + Is.EqualTo(controller.Tuning.FireT4BeamMaximumWidthMultiplier).Within(0.01f)); + AssertStaffTipWorldTransform(fire, beamRenderer); + Assert.That(retarget.Enemy.TryReposition(fire.transform.position + + Vector3.right * (controller.Tuning.TowerRange + 1f)), Is.True); + Physics2D.SyncTransforms(); + + yield return new WaitForSeconds(controller.Tuning.FireT4BeamResetAfterSeconds + 0.05f); + Assert.That(beamRenderer == null, Is.True, + "The timed-out renderer is destroyed before the next attack."); + before = target.Enemy.CurrentHealth; + Assert.That(fire.TryPulse(), Is.True); + beamRenderer = (SpriteRenderer)beamRendererField.GetValue(fire); + Assert.That(beamRenderer, Is.Not.Null); + Assert.That(before - target.Enemy.CurrentHealth, Is.EqualTo(firstHit).Within(0.1f), + "An idle gap of one second resets the tower-owned damage tier."); + Assert.That(beamRenderer.transform.localScale.y, Is.EqualTo(1f).Within(0.01f)); + Vector2 originalPosition = target.transform.position; + target.transform.position = fire.transform.position + Vector3.right + * (controller.Tuning.TowerRange + 1f); + Physics2D.SyncTransforms(); + yield return null; + var targetField = typeof(DefenseTower).GetField("flameBeamTarget", + BindingFlags.Instance | BindingFlags.NonPublic); + Assert.That(targetField.GetValue(fire), Is.Null); + Assert.That(beamRenderer.sprite.name, Does.EndWith("_2"), + "Frame 2 appears only after the target leaves range."); + target.transform.position = originalPosition; + Physics2D.SyncTransforms(); + yield return null; + before = target.Enemy.CurrentHealth; + Assert.That(fire.TryPulse(), Is.True); + Assert.That(before - target.Enemy.CurrentHealth, Is.EqualTo(firstHit).Within(0.1f)); + Assert.That(DefenseTowerArt.Load("Effects/Fire/Eyeball Monster_Beam-Clean").Length, Is.EqualTo(3)); + Assert.That(DefenseTowerArt.Load("Effects/Fire/Eyeball Monster_Beam").Length, Is.EqualTo(3)); + } + + private static void AssertStaffTipWorldTransform(DefenseTower fire, SpriteRenderer beamRenderer) + { + FieldInfo bodyField = typeof(DefenseTower).GetField("bodyRenderer", + BindingFlags.Instance | BindingFlags.NonPublic); + FieldInfo framesField = typeof(DefenseTower).GetField("bodyAttackFrames", + BindingFlags.Instance | BindingFlags.NonPublic); + FieldInfo attackingField = typeof(DefenseTower).GetField("bodyAttacking", + BindingFlags.Instance | BindingFlags.NonPublic); + FieldInfo frameField = typeof(DefenseTower).GetField("bodyFrame", + BindingFlags.Instance | BindingFlags.NonPublic); + SpriteRenderer body = (SpriteRenderer)bodyField.GetValue(fire); + Sprite[] frames = (Sprite[])framesField.GetValue(fire); + MethodInfo getTip = typeof(DefenseTower).GetMethod("GetStaffTipWorldPosition", + BindingFlags.Instance | BindingFlags.NonPublic); + bool savedFlip = body.flipX; + bool savedAttack = (bool)attackingField.GetValue(fire); + int savedFrame = (int)frameField.GetValue(fire); + Sprite savedSprite = body.sprite; + for (int flip = 0; flip < 2; flip++) + { + body.flipX = flip != 0; + for (int frame = 0; frame < frames.Length; frame++) + { + attackingField.SetValue(fire, true); + frameField.SetValue(fire, frame); + body.sprite = frames[frame]; + Vector2 tip = DefenseTowerArtBounds.StaffTipPixels(true, frame); + Vector2 local = tip - body.sprite.pivot; + local.y = body.sprite.rect.height - tip.y - body.sprite.pivot.y; + if (body.flipX) local.x = -local.x; + Vector2 expected = body.transform.TransformPoint(local / body.sprite.pixelsPerUnit); + Vector2 actual = (Vector2)getTip.Invoke(fire, null); + Assert.That(Vector2.Distance(actual, expected), Is.LessThan(0.001f), + $"Staff tip frame {frame}, flipX={body.flipX} must use body TransformPoint."); + } + } + body.flipX = savedFlip; + attackingField.SetValue(fire, savedAttack); + frameField.SetValue(fire, savedFrame); + body.sprite = savedSprite; + Vector2 expectedStart = (Vector2)getTip.Invoke(fire, null); + float ppu = beamRenderer.sprite.pixelsPerUnit; + Vector2 visibleStart = (Vector2)beamRenderer.transform.position + + (Vector2)beamRenderer.transform.right + * ((DefenseTowerArtBounds.FireBeamStartX - beamRenderer.sprite.pivot.x) / ppu + * beamRenderer.transform.localScale.x) + + (Vector2)beamRenderer.transform.up + * ((beamRenderer.sprite.rect.height - DefenseTowerArtBounds.FireBeamCenterY + - beamRenderer.sprite.pivot.y) / ppu * beamRenderer.transform.localScale.y); + Assert.That(Vector2.Distance(visibleStart, expectedStart), Is.LessThan(0.02f), + "The clean beam centerline starts at the animated wand pixel."); + } + + [UnityTest] + public IEnumerator DefenseTowerHitTest_RetryKeepsShowcaseAndTitleThenNormalRunCancelsIt() + { + yield return StartDefenseRun(prepareTowerHitTest: true); + Assert.That(DefensePrototypeController.Instance.IsTowerHitTestLayout, Is.True); + RunManager.Instance.RestartRun(); + yield return WaitForReloadedRun(); + Assert.That(DefensePrototypeController.Instance.IsTowerHitTestLayout, Is.True, + "Retrying the same defense mode should preserve the test layout."); + + RunManager.Instance.RestartToTitle(); + yield return WaitForTitleScreen(); + RunMenuController menu = UnityEngine.Object.FindAnyObjectByType(); + Assert.That(menu, Is.Not.Null); + MethodInfo beginRun = typeof(RunMenuController).GetMethod( + "BeginRun", BindingFlags.Instance | BindingFlags.NonPublic); + Assert.That(beginRun, Is.Not.Null); + beginRun.Invoke(menu, new object[] { RunMode.Development }); + yield return WaitForStartedRun(); + Assert.That(RunManager.Instance.IsDevelopmentRun, Is.True); + Assert.That(DefensePrototypeController.Instance.IsTowerHitTestLayout, Is.False, + "Starting a normal mode from the title should clear showcase state."); + DefensePrototypeController.CancelTowerHitTestRun(); + } + + [UnityTest] + public IEnumerator DefensePrototype_SkeletonAndSummonsFightWithoutHittingRemotePlayer() + { + yield return StartDefenseRun(); + DefensePrototypeController controller = DefensePrototypeController.Instance; + PlayerController player = UnityEngine.Object.FindAnyObjectByType(); + PlayerHealth playerHealth = player.GetComponent(); + DefenseTower tower = BuildTower(controller, player, 0, DefenseTowerKind.Summon); + MovePlayerNear(player, new Vector2(10f, 5.5f)); + yield return WaitForRouteAgents(2); + DefenseRouteAgent agent = FindAgentWithImmunity(DefenseTowerKind.Direct); + Assert.That(agent, Is.Not.Null); + yield return WaitForAgentReady(agent); + DefenseSummonUnit[] units = tower.GetComponentsInChildren(); + Assert.That(units.Length, Is.EqualTo(controller.Tuning.SummonUnitCount)); + for (int i = 0; i < units.Length; i++) + { + Assert.That(units[i].BodyCollider.isTrigger, Is.False); + Assert.That(units[i].GetComponent(), Is.Null, + "A friendly tower unit must not enter the enemy roster."); + for (int j = i + 1; j < units.Length; j++) + { + Assert.That(units[i].BodyCollider.Distance(units[j].BodyCollider).isOverlapped, + Is.False, "Summon slots must start as separate physical bodies."); + } + } + + DefenseSummonUnit target = units[0]; + EnemyController skeleton = agent.Enemy; + Assert.That(skeleton.Definition.Kind, Is.EqualTo(EnemyKind.Skeleton)); + Assert.That(Physics2D.GetIgnoreCollision( + skeleton.BodyCollider, target.BodyCollider), Is.False, + "Enemy and summon bodies must keep physical collision enabled."); + Assert.That(skeleton.TryReposition( + (Vector2)target.transform.position + Vector2.left * 1.8f), Is.True); + Physics2D.SyncTransforms(); + yield return new WaitForFixedUpdate(); + + Assert.That(skeleton.DefenseCombatTarget, Is.SameAs(target), + "A nearby Skeleton should acquire the living summon through the route AI."); + Vector2 beforeBlockedMove = skeleton.transform.position; + Assert.That(skeleton.TryReposition( + (Vector2)target.transform.position + Vector2.right * 1.5f), Is.False, + "Route reposition must stop at the summon collider instead of passing through it."); + Assert.That((Vector2)skeleton.transform.position, Is.EqualTo(beforeBlockedMove)); + + float summonHealth = target.CurrentHealth; + float skeletonHealth = skeleton.CurrentHealth; + float playerHealthBefore = playerHealth.CurrentHealth; + float deadline = Time.realtimeSinceStartup + 8f; + while (Time.realtimeSinceStartup < deadline + && (target.CurrentHealth >= summonHealth + || skeleton.CurrentHealth >= skeletonHealth)) + { + yield return new WaitForFixedUpdate(); + } + + Assert.That(target.CurrentHealth, Is.LessThan(summonHealth), + "The Skeleton's live attack must damage the selected summon."); + Assert.That(skeleton.CurrentHealth, Is.LessThan(skeletonHealth), + "The tower summon must damage the Skeleton as an individual target."); + Assert.That(playerHealth.CurrentHealth, Is.EqualTo(playerHealthBefore).Within(0.01f), + "Targeting a summon must not route that hit to the distant player."); + ColliderDistance2D finalDistance = skeleton.BodyCollider.Distance(target.BodyCollider); + Assert.That(finalDistance.isOverlapped, Is.False, + "Several FixedUpdate steps must preserve physical separation between the fighters."); + } + [UnityTest] + public IEnumerator DefensePrototype_PlayerCanMoveThroughScoutT4Summons() + { + yield return StartDefenseRun(); + DefensePrototypeController controller = DefensePrototypeController.Instance; + PlayerController player = UnityEngine.Object.FindAnyObjectByType(); + DefenseTower tower = BuildTower(controller, player, 0, DefenseTowerKind.Summon); + Assert.That(tower.Upgrade(DefenseTowerBranch.None), Is.True); + Assert.That(tower.Upgrade(DefenseTowerBranch.None), Is.True); + Assert.That(tower.Upgrade(DefenseTowerBranch.Second), Is.True); + Assert.That(tower.Tier, Is.EqualTo(4)); + Assert.That(tower.Branch, Is.EqualTo(DefenseTowerBranch.Second)); + yield return new WaitForFixedUpdate(); + + DefenseSummonUnit[] units = tower.GetComponentsInChildren(); + Assert.That(units.Length, Is.EqualTo(6)); + Collider2D playerCollider = player.GetComponent(); + CharacterModel playerModel = player.GetComponent(); + Assert.That(playerCollider, Is.Not.Null); + Assert.That(playerModel, Is.Not.Null); + Assert.That(playerModel.IsMinion, Is.False); + Vector2 center = Vector2.zero; + float maxX = float.NegativeInfinity; + for (int i = 0; i < units.Length; i++) + { + Assert.That(units[i].IsMinion, Is.True); + Assert.That(Physics2D.GetIgnoreCollision(playerCollider, units[i].BodyCollider), Is.True, + "Every T4 summon collider must ignore only the player's body pair."); + units[i].enabled = false; + center += (Vector2)units[i].transform.position; + maxX = Mathf.Max(maxX, units[i].transform.position.x + + units[i].BodyCollider.bounds.extents.x); + } + for (int i = 0; i < units.Length; i++) + { + for (int j = i + 1; j < units.Length; j++) + { + Assert.That(Physics2D.GetIgnoreCollision( + units[i].BodyCollider, units[j].BodyCollider), Is.False, + "Summon-to-summon physical separation must remain enabled."); + } + } + + Vector2 start = center / units.Length; + Assert.That(player.TryReposition(start), Is.True); + Physics2D.SyncTransforms(); + yield return new WaitForFixedUpdate(); + int overlaps = 0; + for (int i = 0; i < units.Length; i++) + { + if (playerCollider.Distance(units[i].BodyCollider).isOverlapped) overlaps++; + } + Assert.That(overlaps, Is.GreaterThan(0), + "The regression starts with the player physically inside the Scout group."); + + Keyboard previousKeyboard = Keyboard.current; + InputSettings.BackgroundBehavior previousBackgroundBehavior = + InputSystem.settings.backgroundBehavior; + InputSettings.EditorInputBehaviorInPlayMode previousEditorBehavior = + InputSystem.settings.editorInputBehaviorInPlayMode; + bool previousRunInBackground = Application.runInBackground; + InputSystem.settings.backgroundBehavior = + InputSettings.BackgroundBehavior.IgnoreFocus; + InputSystem.settings.editorInputBehaviorInPlayMode = + InputSettings.EditorInputBehaviorInPlayMode.AllDeviceInputAlwaysGoesToGameView; + Application.runInBackground = true; + Keyboard keyboard = InputSystem.AddDevice(); + keyboard.MakeCurrent(); + try + { + InputSystem.QueueStateEvent(keyboard, new KeyboardState(Key.RightArrow)); + yield return null; + Assert.That(keyboard.rightArrowKey.isPressed, Is.True); + float deadline = Time.realtimeSinceStartup + 1.5f; + while (Time.realtimeSinceStartup < deadline) + { + yield return new WaitForFixedUpdate(); + } + InputSystem.QueueStateEvent(keyboard, new KeyboardState()); + yield return new WaitForFixedUpdate(); + Assert.That(player.transform.position.x, Is.GreaterThan(maxX + 0.35f), + "Real PlayerController MovePosition input must let the player escape the Scout bodies."); + } + finally + { + if (keyboard != null && keyboard.added) + { + InputSystem.QueueStateEvent(keyboard, new KeyboardState()); + InputSystem.RemoveDevice(keyboard); + } + if (previousKeyboard != null && previousKeyboard.added) previousKeyboard.MakeCurrent(); + InputSystem.settings.backgroundBehavior = previousBackgroundBehavior; + InputSystem.settings.editorInputBehaviorInPlayMode = previousEditorBehavior; + Application.runInBackground = previousRunInBackground; + } + } + [UnityTest] + public IEnumerator DefensePrototype_SummonDeathRespawnsInSameSlotAndPausesWithPlayer() + { + try + { + yield return StartDefenseRun(); + DefensePrototypeController controller = DefensePrototypeController.Instance; + PlayerController player = UnityEngine.Object.FindAnyObjectByType(); + PlayerHealth playerHealth = player.GetComponent(); + DefenseTower tower = BuildTower(controller, player, 0, DefenseTowerKind.Summon); + SaveDefenseConstants(controller.Tuning); + Collider2D playerCollider = player.GetComponent(); + originalSummonRespawnSeconds = controller.Tuning.SummonUnitRespawnSeconds; + controller.Tuning.SummonUnitRespawnSeconds = 0.15f; + FreezeEnemyControllers(FindRouteAgents()); + + DefenseSummonUnit[] units = tower.GetComponentsInChildren(); + int slot = units[0].SlotIndex; + Vector3 expectedPosition = units[0].transform.position; + for (int cycle = 0; cycle < 3; cycle++) + { + DefenseSummonUnit victim = FindSummonInSlot(tower, slot); + Assert.That(victim, Is.Not.Null); + Assert.That(victim.TryTakeDamage(victim.CurrentHealth + 1f, Vector2.zero), Is.True); + Assert.That(victim.IsDead, Is.True); + Assert.That(victim.BodyCollider.enabled, Is.False); + Assert.That(controller.FindNearestSummonTarget(victim.transform.position, 0.1f), Is.Null, + "Dead units must be excluded from enemy target selection."); + float deadline = Time.realtimeSinceStartup + 2f; + DefenseSummonUnit replacement = null; + while (replacement == null && Time.realtimeSinceStartup < deadline) + { + yield return null; + replacement = FindSummonInSlot(tower, slot); + } + Assert.That(replacement, Is.Not.Null, "The dead slot should be replenished."); + Assert.That(replacement, Is.Not.SameAs(victim)); + Assert.That(replacement.BodyCollider.enabled, Is.True); + Assert.That(replacement.IsMinion, Is.True); + Assert.That(Physics2D.GetIgnoreCollision(playerCollider, replacement.BodyCollider), Is.True, + "A replenished minion must reapply its player collision-pair exception."); + Assert.That(Vector3.Distance(replacement.transform.position, expectedPosition), Is.LessThan(0.001f), + "Repeated replenishment must not drift the summon anchor."); + Assert.That(tower.GetComponentsInChildren().Length, + Is.EqualTo(controller.Tuning.SummonUnitCount), + "The previous hidden corpse must be removed when its slot is reused."); + } + + DefenseSummonUnit pendingDeath = FindSummonInSlot(tower, slot); + Assert.That(pendingDeath.TryTakeDamage(pendingDeath.CurrentHealth + 1f, Vector2.zero), Is.True); + Assert.That(playerHealth.TryTakeDamage(playerHealth.MaxHealth + 1f, Vector2.left, 0f), Is.True); + yield return new WaitForSecondsRealtime(0.35f); + Assert.That(controller.PlayerIsAlive, Is.False); + Assert.That(FindSummonInSlotIncludingDead(tower, slot), Is.SameAs(pendingDeath)); + Assert.That(pendingDeath.IsDead, Is.True); + Assert.That(pendingDeath.BodyCollider.enabled, Is.False); + Assert.That(tower.GetComponentsInChildren().Length, + Is.EqualTo(controller.Tuning.SummonUnitCount), + "A dead player must not create a replacement while tower operation is suspended."); + } + finally + { + RestoreDefenseConstants(); + RestoreCameraFixture(); + } + } + + private static DefenseSummonUnit FindSummonInSlotIncludingDead(DefenseTower tower, int slot) + { + foreach (DefenseSummonUnit unit in tower.GetComponentsInChildren()) + { + if (unit.SlotIndex == slot) return unit; + } + return null; + } + private static DefenseSummonUnit FindSummonInSlot(DefenseTower tower, int slot) + { + foreach (DefenseSummonUnit unit in tower.GetComponentsInChildren()) + { + if (unit.SlotIndex == slot && unit.IsOperational) return unit; + } + return null; + } [UnityTest] public IEnumerator DefensePrototype_SummonPlaysExistingAttackSpriteOnHit() { @@ -510,29 +1271,256 @@ namespace BumpCombat.PlayModeTests controller, player, 0, DefenseTowerKind.Summon); yield return WaitForRouteAgents(2); DefenseRouteAgent target = FindAgentWithImmunity(DefenseTowerKind.Direct); + Assert.That(target, Is.Not.Null); yield return WaitForAgentReady(target); FreezeEnemyControllers(FindRouteAgents()); DefenseSummonUnit unit = summon.GetComponentInChildren(); Assert.That(unit, Is.Not.Null); - Assert.That(target.Enemy.TryReposition(unit.transform.position), Is.True); + foreach (DefenseSummonUnit other in summon.GetComponentsInChildren()) + { + if (other != unit) other.enabled = false; + } + unit.BodyCollider.enabled = false; + Assert.That(target.Enemy.TryReposition( + (Vector2)unit.transform.position + Vector2.left * 0.7f), Is.True); + unit.BodyCollider.enabled = true; Physics2D.SyncTransforms(); + yield return null; + yield return new WaitForFixedUpdate(); + Assert.That(summon.CommandTarget, Is.SameAs(target), + "Summons must follow the target selected by their tower body."); + SpriteRenderer towerBody = summon.transform.Find("Tower Body") + .GetComponent(); + Assert.That(towerBody.flipX, + Is.EqualTo(target.transform.position.x < summon.transform.position.x), + "The summon tower body must face the same target it orders its units to attack."); + float healthBeforeAttack = target.Enemy.CurrentHealth; + Sprite[] attackFrames = DefenseTowerArt.Load("Summons/Slime/Attack01"); bool attackSpriteSeen = false; bool effectSeen = false; + bool contactDamageSeen = false; float deadline = Time.realtimeSinceStartup + 2f; while (Time.realtimeSinceStartup < deadline - && (!attackSpriteSeen || !effectSeen)) + && (!attackSpriteSeen || !effectSeen || !contactDamageSeen)) { yield return null; Sprite sprite = unit.GetComponent().sprite; - attackSpriteSeen |= sprite != null - && sprite.name.Contains("Attack01"); + int frame = Array.IndexOf(attackFrames, sprite); + if (frame >= 0) + { + attackSpriteSeen = true; + if (frame < 3) + { + Assert.That(target.Enemy.CurrentHealth, Is.EqualTo(healthBeforeAttack), + "Damage must wait until the slime reaches its body-slam frames."); + } + contactDamageSeen |= frame >= 3 + && target.Enemy.CurrentHealth < healthBeforeAttack; + } effectSeen |= GameObject.Find("Summon Attack Effect") != null; } Assert.That(attackSpriteSeen, Is.True, "The summoned slime must play its approved Attack01 sheet."); + Assert.That(contactDamageSeen, Is.True, + "The target must take damage when Attack01 reaches the body-slam frames."); Assert.That(effectSeen, Is.True, - "A successful summon hit must show its impact."); + "A successful summon hit must show its small contact effect."); + } + + [UnityTest] + public IEnumerator DefensePrototype_GiantSentinelCanHitSmallEnemyAtMeleeRange() + { + yield return StartDefenseRun(); + DefensePrototypeController controller = DefensePrototypeController.Instance; + PlayerController player = UnityEngine.Object.FindAnyObjectByType(); + Assert.That(controller.TryAwardBondLedgerCurrency(500), Is.True); + DefenseTower tower = BuildTower( + controller, player, 0, DefenseTowerKind.Summon); + tower.enabled = false; + Assert.That(controller.TryUpgradeTowerAtPad(0, DefenseTowerBranch.None), Is.True); + Assert.That(controller.TryUpgradeTowerAtPad(0, DefenseTowerBranch.None), Is.True); + Assert.That(controller.TryUpgradeTowerAtPad(0, DefenseTowerBranch.First), Is.True); + yield return WaitForRouteAgents(2); + + DefenseRouteAgent target = null; + foreach (DefenseRouteAgent agent in FindRouteAgents()) + { + if (agent.ImmuneTowerKind != DefenseTowerKind.Summon) + { + target = agent; + break; + } + } + Assert.That(target, Is.Not.Null); + yield return WaitForAgentReady(target); + FreezeEnemyControllers(FindRouteAgents()); + + DefenseSummonUnit giant = tower.GetComponentInChildren(); + Assert.That(giant, Is.Not.Null); + foreach (DefenseSummonUnit other in tower.GetComponentsInChildren()) + { + if (other != giant) other.enabled = false; + } + Vector2 targetPosition = (Vector2)giant.transform.position + Vector2.right * 0.95f; + Assert.That(target.Enemy.TryReposition(targetPosition), Is.True); + Physics2D.SyncTransforms(); + Collider2D targetCollider = target.Enemy.GetComponent(); + Vector2 targetSurface = targetCollider.ClosestPoint(giant.transform.position); + float surfaceGap = Vector2.Distance(giant.transform.position, targetSurface) + - giant.BodyCollider.bounds.extents.x; + Assert.That(surfaceGap, + Is.GreaterThan(controller.Tuning.SummonBodySlamContactMargin), + "This fixture must keep the small target outside body-slam contact distance."); + + tower.enabled = true; + yield return null; + Assert.That(tower.CommandTarget, Is.SameAs(target)); + float healthBefore = target.Enemy.CurrentHealth; + float deadline = Time.realtimeSinceStartup + 2f; + while (Time.realtimeSinceStartup < deadline + && target.Enemy.CurrentHealth >= healthBefore) + { + yield return null; + } + Assert.That(target.Enemy.CurrentHealth, Is.LessThan(healthBefore), + "Giant/Sentinel must retain its melee attack range against a small enemy."); + } + + [UnityTest] + public IEnumerator DefensePrototype_SummonTowerFacingTracksCommandTarget() + { + yield return StartDefenseRun(); + DefensePrototypeController controller = DefensePrototypeController.Instance; + PlayerController player = UnityEngine.Object.FindAnyObjectByType(); + DefenseTower tower = BuildTower( + controller, player, 0, DefenseTowerKind.Summon); + yield return WaitForRouteAgents(4); + DefenseRouteAgent[] agents = FindRouteAgents(); + foreach (DefenseRouteAgent agent in agents) + { + yield return WaitForAgentReady(agent); + } + + List eligible = new(); + foreach (DefenseRouteAgent agent in agents) + { + if (agent.Lane == 0 && !agent.Enemy.IsDead) + eligible.Add(agent); + } + Assert.That(eligible.Count, Is.GreaterThanOrEqualTo(2)); + foreach (DefenseSummonUnit unit in tower.GetComponentsInChildren()) + unit.enabled = false; + FreezeEnemyControllers(agents); + + DefenseRouteAgent rightTarget = eligible[0]; + DefenseRouteAgent leftTarget = eligible[1]; + Vector2 center = tower.transform.position; + Assert.That(rightTarget.Enemy.TryReposition(center + Vector2.right * 1.9f), Is.True); + Assert.That(leftTarget.Enemy.TryReposition(center + Vector2.left * 1.2f), Is.True); + Physics2D.SyncTransforms(); + yield return null; + + SpriteRenderer body = tower.transform.Find("Tower Body").GetComponent(); + Assert.That(tower.CommandTarget, Is.SameAs(leftTarget)); + Assert.That(body.flipX, Is.True, + "The body must face left while commanding the left-side target."); + + Assert.That(leftTarget.Enemy.TryReposition(center + Vector2.left * 3.5f), Is.True); + Assert.That(rightTarget.Enemy.TryReposition(center + Vector2.right * 1f), Is.True); + Physics2D.SyncTransforms(); + yield return null; + + Assert.That(tower.CommandTarget, Is.SameAs(rightTarget)); + Assert.That(body.flipX, Is.False, + "The body must turn right when its command switches to the right-side target."); + } + + [UnityTest] + public IEnumerator DefensePrototype_FireProjectileDamagesAtMovingTargetArrivalAndSurvivesPause() + { + yield return StartDefenseRun(); + DefensePrototypeController controller = DefensePrototypeController.Instance; + PlayerController player = UnityEngine.Object.FindAnyObjectByType(); + PlayerHealth playerHealth = player.GetComponent(); + DefenseTower tower = BuildTower(controller, player, 0, DefenseTowerKind.Direct); + yield return WaitForRouteAgents(2); + DefenseRouteAgent target = FindAgentWithImmunity(DefenseTowerKind.Summon); + Assert.That(target, Is.Not.Null); + yield return WaitForAgentReady(target); + FreezeEnemyControllers(FindRouteAgents()); + Assert.That(target.Enemy.TryReposition( + (Vector2)tower.transform.position + Vector2.right * 1f), Is.True); + Physics2D.SyncTransforms(); + target = controller.FindNearestTarget( + tower.transform.position, controller.Tuning.TowerRange, DefenseTowerKind.Direct); + Assert.That(target, Is.Not.Null, + "The repositioned compatible target must be in the tower's firing range."); + float healthBefore = target.Enemy.CurrentHealth; + + Assert.That(tower.TryPulse(), Is.True); + Assert.That(target.Enemy.CurrentHealth, Is.EqualTo(healthBefore), + "A fireball must not damage when it is launched."); + Assert.That(playerHealth.TryTakeDamage( + playerHealth.MaxHealth + 1f, Vector2.zero, 0f), Is.True); + yield return new WaitForSeconds(1.7f); + + Assert.That(target.Enemy.CurrentHealth, Is.EqualTo(healthBefore), + "The traveling fireball must not deal damage while tower operation is stopped."); + Assert.That(GameObject.Find("Direct Attack Effect"), Is.Not.Null, + "The pending projectile and its effect root must survive the pause."); + Assert.That(playerHealth.ReviveForDefense(0f), Is.True); + Assert.That(target.Enemy.TryReposition( + (Vector2)target.transform.position + Vector2.up * 0.25f), Is.True); + Physics2D.SyncTransforms(); + + float deadline = Time.realtimeSinceStartup + 2f; + while (Time.realtimeSinceStartup < deadline + && target.Enemy.CurrentHealth >= healthBefore) + { + yield return null; + } + Assert.That(target.Enemy.CurrentHealth, Is.LessThan(healthBefore), + "The traveling fireball must follow and damage its target on arrival."); + yield return new WaitForSeconds(0.5f); + Assert.That(GameObject.Find("Direct Attack Effect"), Is.Null, + "The effect root must be removed after its sequences finish."); + } + + [UnityTest] + public IEnumerator DefensePrototype_MeteorDealsDamageOnceWhenFlightArrives() + { + yield return StartDefenseRun(prepareTowerHitTest: true); + RunHUD hud = UnityEngine.Object.FindAnyObjectByType(); + yield return DisableTowersAndDrainPopups(hud); + DefenseTower fire = FindTower(0, DefenseTowerKind.Direct); + DefenseRouteAgent target = FindTowerTestDummy(DefenseTowerKind.Direct); + Assert.That(fire, Is.Not.Null); + Assert.That(target, Is.Not.Null); + Assert.That(fire.Upgrade(DefenseTowerBranch.None), Is.True); + Assert.That(fire.Upgrade(DefenseTowerBranch.None), Is.True); + Assert.That(fire.Upgrade(DefenseTowerBranch.First), Is.True); + SetEnemyCurrentHealth(target.Enemy, target.Enemy.MaximumHealth); + float initialHealth = target.Enemy.CurrentHealth; + SetTestField(fire, "attackCooldown", 2f); + fire.enabled = true; + + Assert.That(fire.TryPulse(), Is.True); + Assert.That(target.Enemy.CurrentHealth, Is.EqualTo(initialHealth), + "Meteor must not apply its tier-four damage before the visible flight arrives."); + + float deadline = Time.realtimeSinceStartup + 1.5f; + while (Time.realtimeSinceStartup < deadline + && target.Enemy.CurrentHealth >= initialHealth) + { + yield return null; + } + Assert.That(target.Enemy.CurrentHealth, Is.LessThan(initialHealth), + "Meteor must deal damage when its visible flight reaches the target."); + float healthAfterArrival = target.Enemy.CurrentHealth; + yield return new WaitForSeconds(0.15f); + Assert.That(target.Enemy.CurrentHealth, Is.EqualTo(healthAfterArrival), + "One Meteor flight must not apply duplicate damage."); } [UnityTest] @@ -586,29 +1574,96 @@ namespace BumpCombat.PlayModeTests [UnityTest] public IEnumerator DefensePrototype_SentinelAndScoutHaveDifferentUnitCounts() { - yield return StartDefenseRun(); - DefensePrototypeController controller = DefensePrototypeController.Instance; - PlayerController player = UnityEngine.Object.FindAnyObjectByType(); - Assert.That(controller.TryAwardBondLedgerCurrency(500), Is.True); - DefenseTower sentinel = BuildTower(controller, player, 0, - DefenseTowerKind.Summon); - DefenseTower scout = BuildTower(controller, player, 1, - DefenseTowerKind.Summon); + try + { + yield return StartDefenseRun(); + DefensePrototypeController controller = DefensePrototypeController.Instance; + PlayerController player = UnityEngine.Object.FindAnyObjectByType(); + Assert.That(controller.TryAwardBondLedgerCurrency(500), Is.True); + DefenseTower sentinel = BuildTower(controller, player, 0, + DefenseTowerKind.Summon); + DefenseTower scout = BuildTower(controller, player, 1, + DefenseTowerKind.Summon); + Collider2D playerCollider = player.GetComponent(); + int baseCount = controller.Tuning.SummonUnitCount; + SaveDefenseConstants(controller.Tuning); + controller.Tuning.SummonUnitRespawnSeconds = 0.05f; - Assert.That(player.TryReposition(sentinel.transform.position), Is.True); - Assert.That(controller.TryUpgradeTowerAtPad(0, DefenseTowerBranch.None), Is.True); - Assert.That(controller.TryUpgradeTowerAtPad(0, DefenseTowerBranch.None), Is.True); - Assert.That(controller.TryUpgradeTowerAtPad(0, DefenseTowerBranch.First), Is.True); - Assert.That(player.TryReposition(scout.transform.position), Is.True); - Assert.That(controller.TryUpgradeTowerAtPad(1, DefenseTowerBranch.None), Is.True); - Assert.That(controller.TryUpgradeTowerAtPad(1, DefenseTowerBranch.None), Is.True); - Assert.That(controller.TryUpgradeTowerAtPad(1, DefenseTowerBranch.Second), Is.True); - yield return null; + Assert.That(player.TryReposition(sentinel.transform.position), Is.True); + DefenseSummonUnit[] previousSentinel = + sentinel.GetComponentsInChildren(); + Assert.That(previousSentinel.Length, Is.EqualTo(baseCount)); + Assert.That(previousSentinel[0].TryTakeDamage( + previousSentinel[0].CurrentHealth + 1f, Vector2.zero), Is.True); + Assert.That(previousSentinel[0].IsDead, Is.True); + Assert.That(controller.TryUpgradeTowerAtPad(0, DefenseTowerBranch.None), Is.True); + DefenseSummonUnit[] currentSentinel = AssertSummonUnitsReplaced( + sentinel, previousSentinel, baseCount + 1, playerCollider); + yield return new WaitForSecondsRealtime(0.2f); + Assert.That(sentinel.GetComponentsInChildren(), + Is.EquivalentTo(currentSentinel), + "The pending old respawn must not add an extra unit after the upgrade."); - Assert.That(sentinel.GetComponentsInChildren().Length, - Is.EqualTo(1)); - Assert.That(scout.GetComponentsInChildren().Length, - Is.EqualTo(6)); + previousSentinel = currentSentinel; + Assert.That(controller.TryUpgradeTowerAtPad(0, DefenseTowerBranch.None), Is.True); + currentSentinel = AssertSummonUnitsReplaced( + sentinel, previousSentinel, baseCount + 2, playerCollider); + previousSentinel = currentSentinel; + Assert.That(controller.TryUpgradeTowerAtPad(0, DefenseTowerBranch.First), Is.True); + AssertSummonUnitsReplaced(sentinel, previousSentinel, 1, playerCollider); + + Assert.That(player.TryReposition(scout.transform.position), Is.True); + DefenseSummonUnit[] previousScout = + scout.GetComponentsInChildren(); + Assert.That(previousScout.Length, Is.EqualTo(baseCount)); + Assert.That(controller.TryUpgradeTowerAtPad(1, DefenseTowerBranch.None), Is.True); + DefenseSummonUnit[] currentScout = AssertSummonUnitsReplaced( + scout, previousScout, baseCount + 1, playerCollider); + previousScout = currentScout; + Assert.That(controller.TryUpgradeTowerAtPad(1, DefenseTowerBranch.None), Is.True); + currentScout = AssertSummonUnitsReplaced( + scout, previousScout, baseCount + 2, playerCollider); + previousScout = currentScout; + Assert.That(controller.TryUpgradeTowerAtPad(1, DefenseTowerBranch.Second), Is.True); + AssertSummonUnitsReplaced(scout, previousScout, baseCount + 4, playerCollider); + } + finally + { + RestoreDefenseConstants(); + } + } + + private static DefenseSummonUnit[] AssertSummonUnitsReplaced( + DefenseTower tower, + DefenseSummonUnit[] previousUnits, + int expectedCount, + Collider2D playerCollider) + { + foreach (DefenseSummonUnit oldUnit in previousUnits) + { + Assert.That(oldUnit.IsOperational, Is.False, + "Replaced units must stop operating immediately."); + Assert.That(oldUnit.BodyCollider.enabled, Is.False, + "Replaced unit colliders must be disabled before deferred Destroy."); + Assert.That(oldUnit.gameObject.activeSelf, Is.False, + "Replaced unit objects must become inactive before new units spawn."); + } + + DefenseSummonUnit[] currentUnits = + tower.GetComponentsInChildren(); + Assert.That(currentUnits.Length, Is.EqualTo(expectedCount)); + foreach (DefenseSummonUnit currentUnit in currentUnits) + { + Assert.That(System.Array.IndexOf(previousUnits, currentUnit), Is.EqualTo(-1), + "Every upgrade must create new summon instances."); + Assert.That(currentUnit.CurrentHealth, Is.EqualTo(currentUnit.MaxHealth), + "New summons must begin at full health."); + Assert.That(currentUnit.IsMinion, Is.True); + Assert.That(Physics2D.GetIgnoreCollision( + playerCollider, currentUnit.BodyCollider), Is.True, + "New summons must keep the player collision exception."); + } + return currentUnits; } [UnityTest] public IEnumerator DefensePrototype_SupportAuraShowsOnlyOnActiveRecipientAndCapturesRuntime() @@ -764,8 +1819,17 @@ namespace BumpCombat.PlayModeTests DefenseTower support = BuildTower(controller, player, 5, DefenseTowerKind.Empowering); Assert.That(target.Enemy.TryReposition(cold.transform.position), Is.True); + Physics2D.SyncTransforms(); float health = target.Enemy.CurrentHealth; Assert.That(cold.TryPulse(), Is.True); + Assert.That(target.Enemy.CurrentHealth, Is.EqualTo(health), + "Cold projectile damage and slow must wait for visible arrival."); + float coldArrivalDeadline = Time.realtimeSinceStartup + 1f; + while (Time.realtimeSinceStartup < coldArrivalDeadline + && target.Enemy.CurrentHealth >= health) + { + yield return null; + } Assert.That(target.Enemy.CurrentHealth, Is.LessThan(health)); Assert.That(target.Enemy.MovementSpeedMultiplier, Is.LessThan(1f)); @@ -2188,7 +3252,7 @@ namespace BumpCombat.PlayModeTests Assert.That(field, Is.Not.Null, $"Missing fixture field {name}."); field.SetValue(target, value); } - private IEnumerator StartDefenseRun() + private IEnumerator StartDefenseRun(bool prepareTowerHitTest = false) { AsyncOperation load = SceneManager.LoadSceneAsync(SceneName); while (!load.isDone) @@ -2201,6 +3265,10 @@ namespace BumpCombat.PlayModeTests RunManager runManager = RunManager.Instance; Assert.That(runManager, Is.Not.Null); Assert.That(runManager.IsTitleScreen, Is.True); + if (prepareTowerHitTest) + DefensePrototypeController.PrepareTowerHitTestRun(); + else + DefensePrototypeController.CancelTowerHitTestRun(); Assert.That(runManager.BeginRun(RunMode.DefensePrototype), Is.True); float deadline = Time.realtimeSinceStartup + ReadyTimeoutSeconds; @@ -2348,6 +3416,97 @@ namespace BumpCombat.PlayModeTests FindObjectsSortMode.None); } + private static DefenseRouteAgent FindTowerTestDummy(DefenseTowerKind kind) + { + DefenseRouteAgent[] agents = FindRouteAgents(); + string expectedName = $"Tower Test Dummy {kind}"; + for (int i = 0; i < agents.Length; i++) + { + if (agents[i] != null && agents[i].Enemy != null + && !agents[i].Enemy.IsDead + && agents[i].Enemy.name == expectedName) + return agents[i]; + } + return null; + } + + private static IEnumerator DisableTowersAndDrainPopups(RunHUD hud) + { + DefenseTower[] towers = FindTowers(); + for (int i = 0; i < towers.Length; i++) towers[i].enabled = false; + float deadline = Time.realtimeSinceStartup + 2f; + while (hud != null && hud.ActiveDamagePopupCount > 0 + && Time.realtimeSinceStartup < deadline) + yield return null; + Assert.That(hud, Is.Not.Null); + Assert.That(hud.ActiveDamagePopupCount, Is.Zero, + "Existing automatic attack popups should expire before measuring a new hit."); + } + + private static Text FindActiveTowerDamagePopup() + { + Text[] texts = UnityEngine.Object.FindObjectsByType( + FindObjectsInactive.Include, FindObjectsSortMode.None); + for (int i = 0; i < texts.Length; i++) + { + if (texts[i].gameObject.activeInHierarchy + && !string.IsNullOrEmpty(texts[i].text) + && texts[i].text.StartsWith("-", StringComparison.Ordinal)) + return texts[i]; + } + return null; + } + + private static int FindObjectsByName(string expectedName) + { + Transform[] transforms = UnityEngine.Object.FindObjectsByType( + FindObjectsInactive.Include, FindObjectsSortMode.None); + int count = 0; + for (int i = 0; i < transforms.Length; i++) + if (transforms[i].name == expectedName) count++; + return count; + } + + private static IEnumerator WaitForReloadedRun() + { + float deadline = Time.realtimeSinceStartup + ReadyTimeoutSeconds; + while (Time.realtimeSinceStartup < deadline + && (RunManager.Instance == null + || RunManager.Instance.IsTitleScreen + || DefensePrototypeController.Instance == null + || !DefensePrototypeController.Instance.IsActive)) + yield return null; + Assert.That(RunManager.Instance, Is.Not.Null); + Assert.That(RunManager.Instance.IsTitleScreen, Is.False); + Assert.That(DefensePrototypeController.Instance, Is.Not.Null); + Assert.That(DefensePrototypeController.Instance.IsActive, Is.True); + if (RunManager.Instance.IsSelectionOpen) + yield return CloseStartupSelection(RunManager.Instance); + } + + private static IEnumerator WaitForStartedRun() + { + float deadline = Time.realtimeSinceStartup + ReadyTimeoutSeconds; + while (Time.realtimeSinceStartup < deadline + && (RunManager.Instance == null || RunManager.Instance.IsTitleScreen)) + yield return null; + Assert.That(RunManager.Instance, Is.Not.Null); + Assert.That(RunManager.Instance.IsTitleScreen, Is.False); + if (RunManager.Instance.IsSelectionOpen) + yield return CloseStartupSelection(RunManager.Instance); + } + + private static IEnumerator WaitForTitleScreen() + { + float deadline = Time.realtimeSinceStartup + ReadyTimeoutSeconds; + while (Time.realtimeSinceStartup < deadline + && (RunManager.Instance == null || !RunManager.Instance.IsTitleScreen)) + yield return null; + Assert.That(RunManager.Instance, Is.Not.Null); + Assert.That(RunManager.Instance.IsTitleScreen, Is.True); + } + + private static IEnumerator WaitForRouteAgents(int minimum) { float deadline = Time.realtimeSinceStartup + ReadyTimeoutSeconds; @@ -2656,6 +3815,7 @@ namespace BumpCombat.PlayModeTests touchedDefenseConstants.ArrivalBaseDamage = originalArrivalBaseDamage; touchedDefenseConstants.PortalSpawnInterval = originalPortalSpawnInterval; + touchedDefenseConstants.SummonUnitRespawnSeconds = originalSummonRespawnSeconds; defenseConstantsSaved = false; touchedDefenseConstants = null; } @@ -2670,6 +3830,7 @@ namespace BumpCombat.PlayModeTests touchedDefenseConstants = constants; originalArrivalBaseDamage = constants.ArrivalBaseDamage; originalPortalSpawnInterval = constants.PortalSpawnInterval; + originalSummonRespawnSeconds = constants.SummonUnitRespawnSeconds; defenseConstantsSaved = true; } } diff --git a/Assets/_Project/Tests/PlayMode/DefenseSummonCrowdAttackPlayModeTests.cs b/Assets/_Project/Tests/PlayMode/DefenseSummonCrowdAttackPlayModeTests.cs new file mode 100644 index 0000000..dd0bb27 --- /dev/null +++ b/Assets/_Project/Tests/PlayMode/DefenseSummonCrowdAttackPlayModeTests.cs @@ -0,0 +1,199 @@ +using System; +using System.Collections; +using System.Reflection; +using BumpCombat.Constants; +using BumpCombat.Core; +using BumpCombat.Defense; +using BumpCombat.Enemies; +using NUnit.Framework; +using UnityEngine; +using UnityEngine.TestTools; + +namespace BumpCombat.PlayModeTests +{ + public sealed class DefenseSummonCrowdAttackPlayModeTests + { + private const float RepeatedHitTimeoutSeconds = 12f; + private static readonly BindingFlags PrivateInstance = + BindingFlags.Instance | BindingFlags.NonPublic; + + [UnityTest] + public IEnumerator T1ToT3DefaultShowcaseSummonsRepeatedlyHitTheirOwnDummy() + { + MethodInfo startRun = typeof(DefensePrototypePlayModeTests).GetMethod( + "StartDefenseRun", PrivateInstance); + Assert.That(startRun, Is.Not.Null); + IEnumerator setup = (IEnumerator)startRun.Invoke( + new DefensePrototypePlayModeTests(), new object[] { true }); + yield return setup; + + DefensePrototypeController controller = DefensePrototypeController.Instance; + DefenseTower tower = FindSummonTower(); + DefenseRouteAgent target = FindSummonDummy(); + Assert.That(controller.IsTowerHitTestLayout, Is.True); + Assert.That(tower, Is.Not.Null); + Assert.That(target, Is.Not.Null); + Assert.That(Vector2.Distance(tower.transform.position, + controller.Tuning.GetBuildPadPosition(tower.PadIndex)), Is.LessThan(0.001f)); + Assert.That(Vector2.Distance(target.transform.position, + DefenseTowerHitTestLayout.GetDummyPosition(DefenseTowerKind.Summon)), + Is.LessThan(0.001f)); + + foreach (DefenseTower other in UnityEngine.Object.FindObjectsByType( + FindObjectsSortMode.None)) + { + if (other != tower) other.enabled = false; + } + + SetEnemyCurrentHealth(target.Enemy, 10000f); + tower.enabled = true; + float readyDeadline = Time.realtimeSinceStartup + 8f; + while (Time.realtimeSinceStartup < readyDeadline + && (target.Enemy.State == EnemyState.Spawn || tower.CommandTarget != target)) + { + yield return new WaitForFixedUpdate(); + } + Assert.That(target.Enemy.State, Is.Not.EqualTo(EnemyState.Spawn)); + Assert.That(tower.CommandTarget, Is.SameAs(target)); + Assert.That(tower.Tier, Is.EqualTo(1)); + + Sprite[] attackFrames = DefenseTowerArt.Load("Summons/Slime/Attack01"); + FieldInfo appliedField = typeof(DefenseSummonUnit).GetField( + "attackDamageApplied", PrivateInstance); + Assert.That(appliedField, Is.Not.Null); + + for (int tier = 1; tier <= 3; tier++) + { + if (tier > 1) + { + Assert.That(tower.Upgrade(DefenseTowerBranch.None), Is.True); + yield return null; + } + + Assert.That(tower.Tier, Is.EqualTo(tier)); + Assert.That(tower.CommandTarget, Is.SameAs(target)); + Assert.That(controller.Tuning.SummonBodySlamImpactFrame, Is.EqualTo(3)); + Assert.That(controller.Tuning.SummonBodySlamSpeedMultiplier, Is.EqualTo(2f)); + Assert.That(controller.Tuning.SummonBodySlamContactMargin, Is.EqualTo(0.06f)); + + DefenseSummonUnit[] units = tower.GetComponentsInChildren(); + Assert.That(units.Length, Is.EqualTo(controller.Tuning.SummonUnitCount + tier - 1)); + for (int i = 0; i < units.Length; i++) + { + for (int j = i + 1; j < units.Length; j++) + { + Assert.That(Physics2D.GetIgnoreCollision( + units[i].BodyCollider, units[j].BodyCollider), Is.False, + "Summon bodies must keep colliding while attack flow recovers."); + } + } + + float maximumHit = controller.Tuning.SummonDamage + * (1f + controller.Tuning.TowerDamageBonusPerTier * (tier - 1)); + int hitSteps = 0; + float deadline = Time.realtimeSinceStartup + RepeatedHitTimeoutSeconds; + + while (hitSteps < 2 && Time.realtimeSinceStartup < deadline + && !target.Enemy.IsDead) + { + float healthBefore = target.Enemy.CurrentHealth; + bool[] appliedBefore = new bool[units.Length]; + for (int i = 0; i < units.Length; i++) + appliedBefore[i] = (bool)appliedField.GetValue(units[i]); + + yield return new WaitForFixedUpdate(); + + float damage = healthBefore - target.Enemy.CurrentHealth; + if (damage <= 0f) continue; + + int resolvedAttacks = 0; + bool impactFrameVisible = false; + for (int i = 0; i < units.Length; i++) + { + if (!appliedBefore[i] && (bool)appliedField.GetValue(units[i])) + resolvedAttacks++; + int frame = Array.IndexOf( + attackFrames, units[i].GetComponent().sprite); + impactFrameVisible |= frame >= controller.Tuning.SummonBodySlamImpactFrame; + } + + Assert.That(resolvedAttacks, Is.GreaterThan(0), + "Every HP change must match a body-slam resolution in that physics step."); + Assert.That(damage, Is.LessThanOrEqualTo( + resolvedAttacks * maximumHit + 0.01f), + "Each summon must damage at most once during one attack sequence."); + Assert.That(impactFrameVisible, Is.True, + "Damage must begin at Attack01 frame 3."); + hitSteps++; + Debug.Log($"SUMMON_CROWD T{tier} hitStep={hitSteps} damage={damage:F2}"); + } + + Assert.That(hitSteps, Is.GreaterThanOrEqualTo(2), + $"The real default T{tier} pack must land repeated hits without moving any unit or target. " + + BuildBlockerDiagnostics(tower, target, units, attackFrames)); + } + } + + private static string BuildBlockerDiagnostics( + DefenseTower tower, + DefenseRouteAgent target, + DefenseSummonUnit[] units, + Sprite[] attackFrames) + { + Collider2D targetCollider = target.Enemy.GetComponent(); + System.Text.StringBuilder result = new(); + result.AppendLine($"tower={tower.transform.position} target={target.transform.position}"); + foreach (DefenseSummonUnit unit in units) + { + Vector2 center = unit.BodyCollider.bounds.center; + Vector2 delta = (Vector2)target.transform.position - center; + float gap = Vector2.Distance(center, targetCollider.ClosestPoint(center)) + - unit.BodyCollider.bounds.extents.x; + int frame = Array.IndexOf( + attackFrames, unit.GetComponent().sprite); + result.Append($"{unit.name}[{unit.SlotIndex}] pos={center} gap={gap:F3} frame={frame} cast="); + if (delta.sqrMagnitude > 0.0001f) + { + ContactFilter2D filter = ContactFilter2D.noFilter; + filter.useTriggers = false; + RaycastHit2D[] hits = new RaycastHit2D[16]; + int count = unit.BodyCollider.Cast( + delta.normalized, filter, hits, delta.magnitude); + for (int i = 0; i < count; i++) + { + if (i > 0) result.Append(","); + result.Append($"{hits[i].collider.name}@{hits[i].distance:F3}"); + } + } + result.AppendLine(); + } + return result.ToString(); + } + + private static DefenseTower FindSummonTower() + { + foreach (DefenseTower tower in UnityEngine.Object.FindObjectsByType( + FindObjectsSortMode.None)) + if (tower.Kind == DefenseTowerKind.Summon && tower.PadIndex == 1) + return tower; + return null; + } + + private static DefenseRouteAgent FindSummonDummy() + { + foreach (DefenseRouteAgent agent in UnityEngine.Object.FindObjectsByType( + FindObjectsSortMode.None)) + if (agent.Enemy != null && agent.Enemy.name == "Tower Test Dummy Summon") + return agent; + return null; + } + + private static void SetEnemyCurrentHealth(EnemyController enemy, float health) + { + FieldInfo field = typeof(EnemyController).GetField( + "k__BackingField", PrivateInstance); + Assert.That(field, Is.Not.Null); + field.SetValue(enemy, health); + } + } +} diff --git a/Assets/_Project/Tests/PlayMode/DefenseSummonCrowdAttackPlayModeTests.cs.meta b/Assets/_Project/Tests/PlayMode/DefenseSummonCrowdAttackPlayModeTests.cs.meta new file mode 100644 index 0000000..fe836cd --- /dev/null +++ b/Assets/_Project/Tests/PlayMode/DefenseSummonCrowdAttackPlayModeTests.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 83054d7ca59e3ad4381a918d748d5fc9 \ No newline at end of file diff --git a/Assets/_Project/Tests/PlayMode/DefenseTowerVisualPlayModeTests.cs b/Assets/_Project/Tests/PlayMode/DefenseTowerVisualPlayModeTests.cs new file mode 100644 index 0000000..f4011df --- /dev/null +++ b/Assets/_Project/Tests/PlayMode/DefenseTowerVisualPlayModeTests.cs @@ -0,0 +1,990 @@ +using System.Collections; +using System.Collections.Generic; +using System.IO; +using System.Reflection; +using BumpCombat.Constants; +using BumpCombat.Core; +using BumpCombat.Defense; +using BumpCombat.Enemies; +using BumpCombat.Progression; +using BumpCombat.Player; +using BumpCombat.UI; +using NUnit.Framework; +using UnityEngine; +using UnityEngine.SceneManagement; +using UnityEngine.TestTools; + +namespace BumpCombat.PlayModeTests +{ + public sealed class DefenseTowerVisualPlayModeTests + { + private const string SceneName = "CombatPrototype"; + private const string StartupSelectionPanelName = "Artifact Reward Selection"; + private const float ReadyTimeoutSeconds = 8f; + private float originalTimeScale; + private Camera captureCamera; + private ArenaCameraFollow captureCameraFollow; + private Vector3 originalCameraPosition; + private float originalCameraOrthographicSize; + private bool originalCameraFollowEnabled; + private bool cameraStateSaved; +#if UNITY_EDITOR + private bool originalGrantCatalogForTests; + private bool originalForceProductionModeForTests; +#endif + + [SetUp] + public void SetUp() + { + originalTimeScale = Time.timeScale; + Time.timeScale = 1f; + DefensePrototypeController.CancelTowerHitTestRun(); +#if UNITY_EDITOR + originalGrantCatalogForTests = ArtifactRewardController.GrantCatalogForTests; + originalForceProductionModeForTests = RunManager.ForceProductionModeForTests; + ArtifactRewardController.GrantCatalogForTests = false; + RunManager.ForceProductionModeForTests = false; +#endif + } + + [TearDown] + public void TearDown() + { + DefensePrototypeController.CancelTowerHitTestRun(); + RunManager runManager = RunManager.Instance; + if (runManager != null) runManager.ClosePause(); + GameObject selectionPanel = GameObject.Find(StartupSelectionPanelName); + if (selectionPanel != null) selectionPanel.SetActive(false); +#if UNITY_EDITOR + ArtifactRewardController.GrantCatalogForTests = originalGrantCatalogForTests; + RunManager.ForceProductionModeForTests = originalForceProductionModeForTests; +#endif + if (cameraStateSaved && captureCamera != null) + { + captureCamera.transform.position = originalCameraPosition; + captureCamera.orthographicSize = originalCameraOrthographicSize; + if (captureCameraFollow != null) + captureCameraFollow.enabled = originalCameraFollowEnabled; + } + Time.timeScale = originalTimeScale; + } + + [UnityTest] + public IEnumerator ColdTower_TiersFireSequentialSingleProjectilesWithPerShotImpacts() + { + yield return StartTowerHitTestRun(); + DefensePrototypeController controller = DefensePrototypeController.Instance; + PlayerController player = Object.FindAnyObjectByType(); + DefenseTower cold = FindTower(DefenseTowerKind.Cold); + DefenseRouteAgent target = FindDummy(DefenseTowerKind.Cold); + foreach (DefenseTower tower in Object.FindObjectsByType(FindObjectsSortMode.None)) + if (tower != cold) tower.enabled = false; + Assert.That(target.Enemy.TryReposition(cold.transform.position), Is.True); + Physics2D.SyncTransforms(); + Sprite[] volleyFrames = DefenseTowerArt.Load("Effects/Ice/T1-Volley"); + Sprite[] freezeboltFrames = DefenseTowerArt.Load("Effects/Ice/Freezebolt"); + Assert.That(controller.Tuning.ColdProjectileBurstInterval, Is.EqualTo(0.12f)); + + yield return AssertColdVolley(cold, target, volleyFrames, 1, + controller.Tuning.ColdProjectileBurstInterval, faceLeft: true); + for (int tier = 2; tier <= 3; tier++) + { + Assert.That(player.TryReposition(controller.Tuning.GetBuildPadPosition(cold.PadIndex)), Is.True); + Assert.That(controller.TryUpgradeTowerAtPad(cold.PadIndex, DefenseTowerBranch.None), Is.True); + yield return AssertColdVolley(cold, target, volleyFrames, tier, + controller.Tuning.ColdProjectileBurstInterval, faceLeft: tier == 2); + } + Assert.That(player.TryReposition(controller.Tuning.GetBuildPadPosition(cold.PadIndex)), Is.True); + Assert.That(controller.TryUpgradeTowerAtPad(cold.PadIndex, DefenseTowerBranch.First), Is.True); + Assert.That(controller.Tuning.FreezeboltProjectileCount, Is.EqualTo(5)); + yield return AssertColdVolley(cold, target, freezeboltFrames, + 5, controller.Tuning.ColdProjectileBurstInterval, faceLeft: true); + } + + [UnityTest] + public IEnumerator FireAndLightningProjectiles_StartAtStaffTipWhenFacingEitherDirection() + { + yield return StartTowerHitTestRun(); + DefenseTower fire = FindTower(DefenseTowerKind.Direct); + DefenseTower lightning = FindTower(DefenseTowerKind.Lightning); + PlayerController player = Object.FindAnyObjectByType(); + foreach (DefenseTower tower in Object.FindObjectsByType(FindObjectsSortMode.None)) + if (tower != fire && tower != lightning) tower.enabled = false; + SetField(fire, "attackCooldown", 60f); + SetField(lightning, "attackCooldown", 60f); + + yield return AssertStaffTipOriginsBothSides(fire, FindDummy(fire.Kind)); + yield return AssertStaffTipOriginsBothSides(lightning, FindDummy(lightning.Kind)); + + Assert.That(player.TryReposition( + DefensePrototypeController.Instance.Tuning.GetBuildPadPosition(lightning.PadIndex)), + Is.True); + Assert.That(DefensePrototypeController.Instance.TryUpgradeTowerAtPad( + lightning.PadIndex, DefenseTowerBranch.None), Is.True); + Assert.That(DefensePrototypeController.Instance.TryUpgradeTowerAtPad( + lightning.PadIndex, DefenseTowerBranch.None), Is.True); + Assert.That(DefensePrototypeController.Instance.TryUpgradeTowerAtPad( + lightning.PadIndex, DefenseTowerBranch.First), Is.True); + yield return AssertStaffTipOriginsBothSides(lightning, FindDummy(lightning.Kind)); + } + + [UnityTest] + public IEnumerator Blizzard_AutomaticallyHitsTheUnmovedColdDummy() + { + yield return StartTowerHitTestRun(); + DefensePrototypeController controller = DefensePrototypeController.Instance; + PlayerController player = Object.FindAnyObjectByType(); + DefenseTower cold = FindTower(DefenseTowerKind.Cold); + foreach (DefenseTower tower in Object.FindObjectsByType( + FindObjectsSortMode.None)) + tower.enabled = false; + SetField(cold, "attackCooldown", 60f); + yield return new WaitForSeconds(1f); + UpgradeToSecondBranch(controller, player, cold); + SetField(cold, "attackCooldown", -0.1f); + cold.enabled = true; + + DefenseRouteAgent target = FindDummy(DefenseTowerKind.Cold); + FieldInfo healthField = typeof(EnemyController).GetField( + "k__BackingField", BindingFlags.Instance | BindingFlags.NonPublic); + healthField.SetValue(target.Enemy, 10000f); + Vector2 originalRoot = target.transform.position; + Vector2 originalShadow = DefenseTowerArtBounds.EnemyShadowWorldPosition( + target.Enemy, originalRoot); + List<(float damage, EnemyDamageSource source)> damageEvents = new(); + System.Action observer = (_, damage) => + damageEvents.Add((damage, target.Enemy.LastDamageSource)); + target.Enemy.OnDamageApplied += observer; + + yield return new WaitForSeconds(0.9f); + + float nearestEllipseDistance = float.PositiveInfinity; + for (int i = 0; i < DefenseTowerArtBounds.BlizzardShardCount; i++) + { + Vector2 landing = originalRoot + + DefenseTowerArtBounds.BlizzardLandingOffset(i, 1.65f); + Vector2 delta = originalShadow - landing; + float ellipseDistance = delta.x * delta.x + / (DefenseTowerArtBounds.BlizzardWaveRadiusX + * DefenseTowerArtBounds.BlizzardWaveRadiusX) + + delta.y * delta.y + / (DefenseTowerArtBounds.BlizzardWaveRadiusY + * DefenseTowerArtBounds.BlizzardWaveRadiusY); + nearestEllipseDistance = Mathf.Min(nearestEllipseDistance, ellipseDistance); + } + + Assert.That(damageEvents.Count, Is.GreaterThan(0), + $"Automatic Blizzard should hit its selected unmoved target. root={originalRoot}, " + + $"shadow={originalShadow}, castCenter={originalRoot}, " + + $"nearestEllipseDistance={nearestEllipseDistance:F4}."); + Assert.That(damageEvents[0].source, Is.EqualTo(EnemyDamageSource.Tower)); + Assert.That(Vector2.Distance(target.transform.position, originalRoot), + Is.LessThan(0.001f), + "The dummy remains at its original test-scene position."); + target.Enemy.OnDamageApplied -= observer; + } + + [UnityTest] + public IEnumerator Blizzard_DamagesEachLandingEllipseAndHitsOverlapsSeparately() + { + yield return StartTowerHitTestRun(); + DefensePrototypeController controller = DefensePrototypeController.Instance; + PlayerController player = Object.FindAnyObjectByType(); + DefenseTower cold = FindTower(DefenseTowerKind.Cold); + foreach (DefenseTower tower in Object.FindObjectsByType( + FindObjectsSortMode.None)) + tower.enabled = false; + SetField(cold, "attackCooldown", 60f); + yield return new WaitForSeconds(1f); + UpgradeToSecondBranch(controller, player, cold); + SetField(cold, "attackCooldown", 60f); + cold.enabled = true; + + DefenseRouteAgent target = FindDummy(DefenseTowerKind.Cold); + FieldInfo healthField = typeof(EnemyController).GetField( + "k__BackingField", BindingFlags.Instance | BindingFlags.NonPublic); + healthField.SetValue(target.Enemy, 10000f); + Vector2 originalRoot = target.transform.position; + SpriteRenderer targetRenderer = target.Enemy.GetComponent(); + targetRenderer.flipX = false; + Vector2 shadowOffset = DefenseTowerArtBounds.EnemyShadowWorldPosition( + target.Enemy, originalRoot) - originalRoot; + List<(float time, float damage, EnemyDamageSource source)> damageEvents = new(); + System.Action damageObserver = (_, appliedDamage) => + damageEvents.Add((Time.time, appliedDamage, target.Enemy.LastDamageSource)); + target.Enemy.OnDamageApplied += damageObserver; + + float expectedDamage = Mathf.Floor(controller.Tuning.ColdDamage + * (1f + controller.Tuning.TowerDamageBonusPerTier * (cold.Tier - 1)) * 0.5f); + Vector2 singleAttackCenter = DefenseTowerArtBounds.EnemyShadowWorldPosition( + target.Enemy, target.transform.position); + Vector2 singleWaveGround = singleAttackCenter + + DefenseTowerArtBounds.BlizzardLandingOffset(0, 1.65f); + float singleStartHealth = target.Enemy.CurrentHealth; + float singleStartedAt = Time.time; + Assert.That(cold.TryPulse(), Is.True); + Vector2 singleRoot = singleWaveGround - shadowOffset; + Assert.That(target.Enemy.TryReposition(singleRoot), Is.True); + SetField(target, "stationaryTowerTestDummyAnchor", singleRoot); + Physics2D.SyncTransforms(); + float singleHitDeadline = Time.realtimeSinceStartup + 0.8f; + while (damageEvents.Count == 0 && Time.realtimeSinceStartup < singleHitDeadline) + yield return null; + Assert.That(damageEvents.Count, Is.EqualTo(1), + "A target at one landing center is hit once by that wave only."); + Assert.That(damageEvents[0].source, Is.EqualTo(EnemyDamageSource.Tower)); + Assert.That(damageEvents[0].damage, Is.EqualTo(expectedDamage).Within(0.001f)); + Assert.That(target.Enemy.MovementSpeedMultiplier, Is.LessThan(1f)); + Assert.That(target.Enemy.IsStunned, Is.True); + float singleRemaining = DefenseTowerArtBounds.BlizzardLastLandingSeconds + 0.1f + - (Time.time - singleStartedAt); + if (singleRemaining > 0f) yield return new WaitForSeconds(singleRemaining); + Assert.That(damageEvents.Count, Is.EqualTo(1), + "The wave never repeats its hit while its expansion animation plays."); + Assert.That(singleStartHealth - target.Enemy.CurrentHealth, + Is.EqualTo(expectedDamage).Within(0.001f)); + + damageEvents.Clear(); + healthField.SetValue(target.Enemy, 10000f); + SetField(cold, "attackCooldown", 60f); + Vector2 overlapAttackCenter = DefenseTowerArtBounds.EnemyShadowWorldPosition( + target.Enemy, target.transform.position); + Vector2 first = DefenseTowerArtBounds.BlizzardLandingOffset(0, 1.65f); + Vector2 second = DefenseTowerArtBounds.BlizzardLandingOffset(1, 1.65f); + Vector2 overlapGround = overlapAttackCenter + (first + second) * 0.5f; + float overlapStartedAt = Time.time; + Assert.That(cold.TryPulse(), Is.True); + Vector2 overlapRoot = overlapGround - shadowOffset; + Assert.That(target.Enemy.TryReposition(overlapRoot), Is.True); + SetField(target, "stationaryTowerTestDummyAnchor", overlapRoot); + Physics2D.SyncTransforms(); + float overlapDeadline = Time.realtimeSinceStartup + 0.8f; + while (damageEvents.Count < 2 && Time.realtimeSinceStartup < overlapDeadline) + yield return null; + Assert.That(damageEvents.Count, Is.EqualTo(2), + "Only the two overlapping landing ellipses hit this target."); + for (int i = 0; i < damageEvents.Count; i++) + { + Assert.That(damageEvents[i].source, Is.EqualTo(EnemyDamageSource.Tower)); + Assert.That(damageEvents[i].damage, Is.EqualTo(expectedDamage).Within(0.001f), + $"Wave event {i} applies the configured per-wave damage."); + } + Assert.That(target.Enemy.MovementSpeedMultiplier, Is.LessThan(1f)); + Assert.That(target.Enemy.IsStunned, Is.True); + float overlapRemaining = DefenseTowerArtBounds.BlizzardLastLandingSeconds + 0.1f + - (Time.time - overlapStartedAt); + if (overlapRemaining > 0f) yield return new WaitForSeconds(overlapRemaining); + Assert.That(damageEvents.Count, Is.EqualTo(2), + "Neither overlapping wave repeats during its animated lifetime."); + Assert.That(10000f - target.Enemy.CurrentHealth, + Is.EqualTo(expectedDamage * 2f).Within(0.001f)); + + damageEvents.Clear(); + healthField.SetValue(target.Enemy, 10000f); + SetField(cold, "attackCooldown", 60f); + Vector2 outerAttackCenter = DefenseTowerArtBounds.EnemyShadowWorldPosition( + target.Enemy, target.transform.position); + Assert.That(cold.TryPulse(), Is.True); + Vector2 outerRoot = outerAttackCenter + new Vector2(1.5f, 0f) - shadowOffset; + Assert.That(target.Enemy.TryReposition(outerRoot), Is.True); + SetField(target, "stationaryTowerTestDummyAnchor", outerRoot); + Physics2D.SyncTransforms(); + yield return new WaitForSeconds(DefenseTowerArtBounds.BlizzardLastLandingSeconds + 0.1f); + Assert.That(damageEvents.Count, Is.Zero, + "A target outside every wave ellipse but inside the cast radius is not hit."); + Assert.That(target.Enemy.CurrentHealth, Is.EqualTo(10000f).Within(0.001f)); + target.Enemy.OnDamageApplied -= damageObserver; + } + + private static IEnumerator AssertStaffTipOriginsBothSides( + DefenseTower tower, DefenseRouteAgent target) + { + FieldInfo healthField = typeof(EnemyController).GetField( + "k__BackingField", BindingFlags.Instance | BindingFlags.NonPublic); + foreach (bool faceLeft in new[] { true, false }) + { + healthField.SetValue(target.Enemy, 10000f); + Vector2 targetPosition = (Vector2)tower.transform.position + + (faceLeft ? Vector2.left : Vector2.right) * 1.25f; + Assert.That(target.Enemy.TryReposition(targetPosition), Is.True); + Physics2D.SyncTransforms(); + SetField(tower, "attackCooldown", 60f); + string effectName = tower.Kind == DefenseTowerKind.Direct + ? "Direct Attack Effect" : "Lightning Attack Effect"; + HashSet previousEffectIds = new(); + foreach (Transform existing in Object.FindObjectsByType( + FindObjectsInactive.Include, FindObjectsSortMode.None)) + if (existing.name == effectName) + previousEffectIds.Add(existing.gameObject.GetInstanceID()); + + Assert.That(tower.TryPulse(), Is.True); + GameObject effect = null; + foreach (Transform candidate in Object.FindObjectsByType( + FindObjectsInactive.Include, FindObjectsSortMode.None)) + { + if (candidate.name == effectName + && !previousEffectIds.Contains(candidate.gameObject.GetInstanceID())) + { + effect = candidate.gameObject; + break; + } + } + Assert.That(effect, Is.Not.Null); + Transform shot = effect.transform.Find("Shot Sprite"); + Assert.That(shot, Is.Not.Null); + SpriteRenderer renderer = shot.GetComponent(); + Vector2 expectedStart = GetStaffTipWorldPosition(tower); + if (tower.Kind == DefenseTowerKind.Direct) + { + Vector2 travelStart = (Vector2)GetSequenceField( + shot.GetComponent(), "travelStart"); + Assert.That(Vector2.Distance(travelStart, expectedStart), + Is.LessThan(0.0001f), + $"Fire T{tower.Tier}, flipX={faceLeft}, travel start must match the attack-frame staff tip."); + Assert.That(Vector2.Distance(shot.position, expectedStart), + Is.LessThan(0.0001f), "Fireball renderer starts at the staff tip."); + } + else + { + string key = tower.Tier == 4 + ? "Effects/Lightning/Arc" : "Effects/Lightning/T1-Bolt"; + int frame = FindFrameIndex(DefenseTowerArt.Load(key), renderer.sprite); + Vector2Int bounds = DefenseTowerArtBounds.LightningBeamXBounds(frame); + Vector3 localStart = new( + (bounds.x - renderer.sprite.pivot.x) / renderer.sprite.pixelsPerUnit, + 0f, 0f); + Assert.That(Vector2.Distance( + renderer.transform.TransformPoint(localStart), expectedStart), + Is.LessThan(0.0001f), + $"Lightning T{tower.Tier} renderer starts at the staff tip."); + } + + yield return null; + } + } + + private static IEnumerator AssertColdVolley( + DefenseTower tower, DefenseRouteAgent target, Sprite[] expectedFrames, + int expectedCount, float burstInterval, bool faceLeft = false) + { + FieldInfo healthField = typeof(EnemyController).GetField( + "k__BackingField", BindingFlags.Instance | BindingFlags.NonPublic); + Assert.That(healthField, Is.Not.Null); + healthField.SetValue(target.Enemy, 10000f); + Vector2 targetPosition = (Vector2)tower.transform.position + + (faceLeft ? Vector2.left : Vector2.right) * 1.25f; + Assert.That(target.Enemy.TryReposition(targetPosition), Is.True); + Physics2D.SyncTransforms(); + float healthBefore = target.Enemy.CurrentHealth; + SetField(tower, "attackCooldown", 60f); + Assert.That(tower.TryPulse(), Is.True); + GameObject effect = GameObject.Find("Cold Attack Effect"); + Assert.That(effect, Is.Not.Null); + SpriteRenderer[] renderers = effect.GetComponentsInChildren(true); + List shots = new(); + foreach (SpriteRenderer renderer in renderers) + { + if (!renderer.name.StartsWith("Shot Sprite ")) continue; + shots.Add(renderer); + Assert.That(renderer.sprite, Is.SameAs(expectedFrames[0]), + "Every projectile must reuse one existing projectile sheet."); + } + Assert.That(shots.Count, Is.EqualTo(expectedCount)); + AssertStaffTipStart(tower, shots[0]); + for (int i = 0; i < shots.Count; i++) + Assert.That(shots[i].enabled, Is.EqualTo(i == 0), + "Only the first projectile is visible at launch."); + + HashSet impactIds = new(); + HashSet hiddenShotsAtImpact = new(); + Dictionary launchTimes = new(); + bool flightRendererHiddenAtImpact = false; + float previousHealth = healthBefore; + int damageEvents = 0; + float deadline = Time.realtimeSinceStartup + 1.5f; + while (Time.realtimeSinceStartup < deadline && effect != null) + { + float now = Time.realtimeSinceStartup; + for (int i = 0; i < shots.Count; i++) + if (shots[i] != null && shots[i].enabled && !launchTimes.ContainsKey(i)) + { + launchTimes.Add(i, now); + AssertStaffTipStart(tower, shots[i]); + } + foreach (Transform child in effect.transform) + if (child.name == "Impact Sprite") + impactIds.Add(child.gameObject.GetInstanceID()); + if (impactIds.Count > 0 && (shots[0] == null || !shots[0].enabled)) + flightRendererHiddenAtImpact = true; + for (int i = 0; i < shots.Count; i++) + if (impactIds.Count > i && (shots[i] == null || !shots[i].enabled)) + hiddenShotsAtImpact.Add(i); + float currentHealth = target.Enemy.CurrentHealth; + if (currentHealth < previousHealth) + { + damageEvents++; + previousHealth = currentHealth; + } + yield return null; + } + Assert.That(launchTimes.Count, Is.EqualTo(expectedCount), + "Each projectile renderer must become visible in sequence."); + for (int i = 1; i < expectedCount; i++) + Assert.That(launchTimes[i] - launchTimes[i - 1], Is.EqualTo(burstInterval).Within(0.035f)); + Assert.That(impactIds.Count, Is.EqualTo(expectedCount), + "Each projectile must create a distinct impact before shared cleanup."); + Assert.That(flightRendererHiddenAtImpact, Is.True, + "The flight renderer must be hidden on arrival while its impact still animates."); + Assert.That(hiddenShotsAtImpact.Count, Is.EqualTo(expectedCount), + "Every flight renderer must be hidden on its own arrival."); + Assert.That(damageEvents, Is.EqualTo(expectedCount), + "Every projectile must apply its existing damage on arrival."); + Assert.That(target.Enemy.CurrentHealth, Is.LessThan(healthBefore)); + } + + private static void AssertStaffTipStart(DefenseTower tower, SpriteRenderer shot) + { + Vector2 travelStart = (Vector2)GetSequenceField( + shot.GetComponent(), "travelStart"); + Assert.That(IsStaffTipAnchor(tower, travelStart), Is.True, + "Each Cold projectile, including delayed shots, starts at a measured staff-tip frame anchor."); + } + + private static bool IsStaffTipAnchor(DefenseTower tower, Vector2 position) + { + SpriteRenderer body = (SpriteRenderer)typeof(DefenseTower).GetField( + "bodyRenderer", BindingFlags.Instance | BindingFlags.NonPublic).GetValue(tower); + Sprite sprite = body.sprite; + for (int frame = 0; frame < 6; frame++) + { + Vector2 tipPixels = DefenseTowerArtBounds.StaffTipPixels(true, frame); + Vector2 localPixels = tipPixels - sprite.pivot; + localPixels.y = sprite.rect.height - tipPixels.y - sprite.pivot.y; + if (body.flipX) localPixels.x = -localPixels.x; + Vector2 anchor = body.transform.TransformPoint( + localPixels / sprite.pixelsPerUnit); + if (Vector2.Distance(anchor, position) < 0.0001f) return true; + } + return false; + } + + private static Vector2 GetStaffTipWorldPosition(DefenseTower tower) => + (Vector2)typeof(DefenseTower).GetMethod("GetStaffTipWorldPosition", + BindingFlags.Instance | BindingFlags.NonPublic).Invoke(tower, null); + + private static object GetSequenceField(DefenseSpriteSequence sequence, string name) => + typeof(DefenseSpriteSequence).GetField(name, + BindingFlags.Instance | BindingFlags.NonPublic).GetValue(sequence); + [UnityTest] + public IEnumerator LightningTiersOneToThreeDoNotCreateImpactWaves() + { + yield return StartTowerHitTestRun(); + DefensePrototypeController controller = DefensePrototypeController.Instance; + PlayerController player = Object.FindAnyObjectByType(); + DefenseTower lightning = FindTower(DefenseTowerKind.Lightning); + DefenseRouteAgent target = FindDummy(DefenseTowerKind.Lightning); + foreach (DefenseTower tower in Object.FindObjectsByType(FindObjectsSortMode.None)) + if (tower != lightning) tower.enabled = false; + Assert.That(target.Enemy.TryReposition(lightning.transform.position), Is.True); + Physics2D.SyncTransforms(); + + for (int tier = 1; tier <= 3; tier++) + { + if (tier > 1) + { + Assert.That(player.TryReposition( + controller.Tuning.GetBuildPadPosition(lightning.PadIndex)), Is.True); + Assert.That(controller.TryUpgradeTowerAtPad( + lightning.PadIndex, DefenseTowerBranch.None), Is.True); + } + + SetField(lightning, "attackCooldown", 60f); + Assert.That(lightning.TryPulse(), Is.True, $"T{tier} lightning should attack."); + GameObject effect = GameObject.Find("Lightning Attack Effect"); + Assert.That(effect, Is.Not.Null); + foreach (SpriteRenderer renderer in effect.GetComponentsInChildren(true)) + Assert.That(renderer.name, Is.Not.EqualTo("Impact Sprite"), + $"T{tier} lightning must not create an impact wave."); + Object.DestroyImmediate(effect); + } + } + + [UnityTest] + public IEnumerator TowerVisuals_AlignHitsFitStormShrinkStunAndStaggerBlizzardLanding() + { + yield return StartTowerHitTestRun(); + DefensePrototypeController controller = DefensePrototypeController.Instance; + PlayerController player = Object.FindAnyObjectByType(); + Assert.That(controller, Is.Not.Null); + Assert.That(player, Is.Not.Null); + + DefenseTower[] allTowers = Object.FindObjectsByType( + FindObjectsSortMode.None); + foreach (DefenseTower tower in allTowers) tower.enabled = false; + + // Fire impact uses the enemy body position and target-relative sorting. + DefenseTower fire = FindTower(DefenseTowerKind.Direct); + DefenseRouteAgent fireTarget = FindDummy(DefenseTowerKind.Direct); + fire.enabled = true; + SetField(fire, "attackCooldown", 60f); + float fireHealth = fireTarget.Enemy.CurrentHealth; + Assert.That(fire.TryPulse(), Is.True); + float fireDeadline = Time.realtimeSinceStartup + 1f; + while (Time.realtimeSinceStartup < fireDeadline + && fireTarget.Enemy.CurrentHealth >= fireHealth) + yield return null; + Assert.That(fireTarget.Enemy.CurrentHealth, Is.LessThan(fireHealth)); + GameObject fireEffect = GameObject.Find("Direct Attack Effect"); + Assert.That(fireEffect, Is.Not.Null); + Transform fireImpact = fireEffect.transform.Find("Impact Sprite"); + Assert.That(fireImpact, Is.Not.Null); + SpriteRenderer fireRenderer = fireImpact.GetComponent(); + SpriteRenderer fireTargetRenderer = fireTarget.Enemy.GetComponent(); + Vector2 expectedFireCenter = fireTarget.Enemy.transform.position; + Assert.That(Vector2.Distance(fireImpact.position, expectedFireCenter), + Is.LessThan(0.01f)); + AssertHitSorting(fireRenderer, fireTargetRenderer); + GameObject sharedFireImpact = GameObject.Find("Tower Impact Direct"); + Assert.That(sharedFireImpact, Is.Not.Null); + AssertHitSorting(sharedFireImpact.GetComponent(), fireTargetRenderer); + CaptureRender("tower-visual-01-fire-impact.png"); + + // LightningStorm's measured ground center and furthest ring pixel fit radius 2.4. + DefenseTower lightning = FindTower(DefenseTowerKind.Lightning); + UpgradeToSecondBranch(controller, player, lightning); + lightning.enabled = true; + SetField(lightning, "attackCooldown", 60f); + Assert.That(DefenseTower.GetBranchName( + DefenseTowerKind.Lightning, DefenseTowerBranch.Second), + Is.EqualTo("LIGHTNING STORM")); + Assert.That(lightning.TryPulse(), Is.True); + CaptureRender("tower-visual-02-lightning-storm.png"); + GameObject lightningEffect = GameObject.Find("Lightning Attack Effect"); + Assert.That(lightningEffect, Is.Not.Null); + Transform storm = lightningEffect.transform.Find("Shot Sprite"); + Assert.That(storm, Is.Not.Null); + DefenseSpriteSequence stormSequence = + storm.GetComponent(); + SpriteRenderer stormRenderer = storm.GetComponent(); + Vector2 stormCenter = FindDummy(DefenseTowerKind.Lightning).transform.position; + AssertHitSorting(stormRenderer, + FindDummy(DefenseTowerKind.Lightning).Enemy.GetComponent()); + const string stormKey = "Effects/Lightning/LightningStorm"; + Sprite[] stormSprites = DefenseTowerArt.Load(stormKey); + Assert.That(stormSprites, Has.Length.EqualTo(16)); + GameObject boundsProbe = new("Lightning Storm Bounds Probe"); + SpriteRenderer probeRenderer = boundsProbe.AddComponent(); + DefenseSpriteSequence probeSequence = + boundsProbe.AddComponent(); + probeSequence.Play(probeRenderer, stormKey, stormSprites, false); + for (int i = 0; i < stormSprites.Length; i++) + { + Sprite frame = stormSprites[i]; + probeRenderer.sprite = frame; + probeSequence.FitLightningStormGround(stormCenter, 2.4f); + Vector2 localCenter = + (DefenseTowerArtBounds.LightningStormGroundCenterPixels - frame.pivot) + / frame.pixelsPerUnit; + Assert.That(Vector2.Distance( + probeRenderer.transform.TransformPoint(localCenter), stormCenter), + Is.LessThan(0.01f), $"Ground center frame {i}."); + float fittedRadius = probeRenderer.transform.lossyScale.x + * DefenseTowerArtBounds.LightningStormGroundRadiusPixels + / frame.pixelsPerUnit; + Assert.That(fittedRadius, Is.LessThanOrEqualTo(2.4001f), + $"Measured ground footprint frame {i}."); + } + Object.Destroy(boundsProbe); + + float ringFrameDeadline = Time.realtimeSinceStartup + 1f; + int visibleStormFrame = FindFrameIndex(stormSprites, stormRenderer.sprite); + while ((visibleStormFrame < 4 || visibleStormFrame > 10) + && Time.realtimeSinceStartup < ringFrameDeadline) + { + yield return null; + visibleStormFrame = stormRenderer != null + ? FindFrameIndex(stormSprites, stormRenderer.sprite) : -1; + } + Assert.That(visibleStormFrame, Is.InRange(4, 10), + "The second Storm capture must include visible ground-ring frames."); + CaptureRender("tower-visual-02b-lightning-groundring.png"); + + // Seismic shows a small shadow-centered marker while keeping tower range guidance. + DefenseTower seismic = FindTower(DefenseTowerKind.Seismic); + DefenseRouteAgent seismicTarget = FindDummy(DefenseTowerKind.Seismic); + seismic.enabled = true; + SetField(seismic, "attackCooldown", 60f); + Assert.That(seismic.TryPulse(), Is.True); + GameObject seismicEffect = GameObject.Find("Seismic Attack Effect"); + Assert.That(seismicEffect, Is.Not.Null); + SpriteRenderer stunArt = seismicEffect.transform.Find("Impact Sprite") + .GetComponent(); + SpriteRenderer seismicTargetRenderer = seismicTarget.Enemy.GetComponent(); + const int seismicMaximumVisiblePixels = 32; + float expectedMarkerScale = DefenseTowerArtBounds.SeismicGroundMarkerWidthPixels + * Mathf.Abs(seismicTargetRenderer.transform.lossyScale.x) + * stunArt.sprite.pixelsPerUnit + / (seismicTargetRenderer.sprite.pixelsPerUnit + * seismicMaximumVisiblePixels + * Mathf.Abs(seismicEffect.transform.lossyScale.x)); + Assert.That(stunArt.transform.localScale.x, + Is.EqualTo(expectedMarkerScale).Within(0.01f), + "The constant effect scale must fit the measured maximum visible frame width."); + Assert.That((Vector2)stunArt.transform.position, + Is.EqualTo(DefenseTowerArtBounds.EnemyShadowWorldPosition( + seismicTarget.Enemy, seismicTarget.transform.position))); + Assert.That(stunArt.sortingLayerID, Is.EqualTo(seismicTargetRenderer.sortingLayerID)); + Assert.That(stunArt.sortingOrder, Is.EqualTo(seismicTargetRenderer.sortingOrder - 1)); + Assert.That(seismicEffect.transform.Find("Impact Radius"), Is.Null, + "The large transient Seismic radius ring is omitted from the ground marker."); + LineRenderer seismicRange = seismic.transform.Find("Effect Range") + .GetComponent(); + Assert.That(seismicRange, Is.Not.Null, + "The tower's persistent attack-range display remains available."); + Assert.That(seismicRange.GetPosition(0).magnitude, Is.GreaterThan(0f)); + CaptureRender("tower-visual-03-seismic-stun.png"); + + // Blizzard staggers shards and applies damage at each landing ellipse. + DefenseTower cold = FindTower(DefenseTowerKind.Cold); + SetField(cold, "attackCooldown", 60f); + cold.enabled = false; + yield return new WaitForSeconds(1f); + UpgradeToSecondBranch(controller, player, cold); + cold.enabled = true; + SetField(cold, "attackCooldown", 60f); + DefenseRouteAgent coldTarget = FindDummy(DefenseTowerKind.Cold); + float coldHealth = coldTarget.Enemy.CurrentHealth; + float blizzardStartedAt = Time.time; + Assert.That(DefenseTower.GetBranchName( + DefenseTowerKind.Cold, DefenseTowerBranch.Second), + Is.EqualTo("BLIZZARD")); + Assert.That(cold.TryPulse(), Is.True); + GameObject coldEffect = GameObject.Find("Cold Attack Effect"); + Assert.That(coldEffect, Is.Not.Null); + Vector2 coldShadowCenter = DefenseTowerArtBounds.EnemyShadowWorldPosition( + coldTarget.Enemy, coldTarget.transform.position); + Assert.That(Vector2.Distance(coldEffect.transform.position, + coldShadowCenter), Is.LessThan(0.01f)); + LineRenderer radiusLine = coldEffect.transform.Find("Impact Radius") + .GetComponent(); + Assert.That(radiusLine, Is.Not.Null); + Assert.That(radiusLine.startColor.a, + Is.EqualTo(DefenseTowerArtBounds.BlizzardImpactRadiusAlpha).Within(0.001f), + "Only Blizzard softens the per-cast radius outline."); + Assert.That(cold.transform.Find("Effect Range").GetComponent() + .startColor.a, Is.EqualTo(DefenseTowerArtBounds.BlizzardRangeAlpha).Within(0.001f), + "Only Blizzard uses the quieter live range outline."); + float radius = 0f; + for (int i = 0; i < radiusLine.positionCount; i++) + radius = Mathf.Max(radius, radiusLine.GetPosition(i).magnitude); + Assert.That(radius, Is.EqualTo(1.65f).Within(0.01f)); + Assert.That(coldTarget.Enemy.CurrentHealth, Is.EqualTo(coldHealth), + "Blizzard damage waits for the first shard landing."); + + SpriteRenderer[] blizzardSprites = + coldEffect.GetComponentsInChildren(true); + SpriteRenderer[] shards = new SpriteRenderer[DefenseTowerArtBounds.BlizzardShardCount]; + SpriteRenderer[] trails = new SpriteRenderer[DefenseTowerArtBounds.BlizzardShardCount]; + SpriteRenderer[] impacts = new SpriteRenderer[DefenseTowerArtBounds.BlizzardShardCount]; + SpriteRenderer[] waves = new SpriteRenderer[DefenseTowerArtBounds.BlizzardShardCount]; + int shardCount = 0; + int trailCount = 0; + int impactCount = 0; + int waveCount = 0; + foreach (SpriteRenderer renderer in blizzardSprites) + { + if (renderer.name.StartsWith("Blizzard Shard ")) + { + int index = renderer.name["Blizzard Shard ".Length] - '0'; + shards[index] = renderer; + shardCount++; + Assert.That(renderer.transform.localScale.x, + Is.EqualTo(DefenseTowerArtBounds.BlizzardShardScale).Within(0.001f)); + Assert.That(renderer.sprite, + Is.SameAs(DefenseTowerArt.Load("Effects/Ice/Shard-Blizzard-v1")[0])); + Assert.That(renderer.enabled, Is.EqualTo(index == 0), + "Delayed shards stay hidden until their staggered launch."); + AssertHitSorting(renderer, + coldTarget.Enemy.GetComponent()); + } + else if (renderer.name.StartsWith("Blizzard Trail ")) + { + int index = renderer.name["Blizzard Trail ".Length] - '0'; + trails[index] = renderer; + trailCount++; + Assert.That(renderer.transform.localScale.x, + Is.EqualTo(DefenseTowerArtBounds.BlizzardTrailScale).Within(0.001f)); + Assert.That(renderer.color.a, + Is.EqualTo(DefenseTowerArtBounds.BlizzardTrailAlpha).Within(0.001f)); + Assert.That(renderer.enabled, Is.EqualTo(index == 0), + "The brief shard trail follows its staggered projectile."); + } + else if (renderer.name.StartsWith("Blizzard Impact ")) + { + int index = renderer.name["Blizzard Impact ".Length] - '0'; + impacts[index] = renderer; + impactCount++; + Assert.That(renderer.transform.localScale.x, + Is.EqualTo(DefenseTowerArtBounds.BlizzardImpactScale).Within(0.001f)); + Assert.That(renderer.enabled, Is.False, + "Each impact stays hidden until its shard lands."); + AssertHitSorting(renderer, + coldTarget.Enemy.GetComponent()); + } + else if (renderer.name.StartsWith("Blizzard Wave ")) + { + int index = renderer.name["Blizzard Wave ".Length] - '0'; + waves[index] = renderer; + waveCount++; + Assert.That(renderer.transform.localScale.x, + Is.EqualTo(DefenseTowerArtBounds.BlizzardWaveScale).Within(0.001f)); + Assert.That(renderer.enabled, Is.False, + "Each ground wave waits for its matching shard landing."); + Vector2 expectedCenter = (Vector2)coldEffect.transform.position + + DefenseTowerArtBounds.BlizzardLandingOffset(index, radius); + Vector2 localCenter = (DefenseTowerArtBounds.ImpactWaveCenterPixels( + 0, renderer.sprite) - renderer.sprite.pivot) + / renderer.sprite.pixelsPerUnit; + Assert.That(Vector2.Distance( + renderer.transform.TransformPoint(localCenter), expectedCenter), + Is.LessThan(0.01f), $"Wave {index} stays centered on its landing."); + float outerRadius = 0f; + foreach (Vector2 vertex in renderer.sprite.vertices) + { + outerRadius = Mathf.Max(outerRadius, Vector2.Distance( + renderer.transform.TransformPoint(vertex), expectedCenter)); + } + Assert.That(Vector2.Distance(expectedCenter, coldEffect.transform.position) + + outerRadius, Is.LessThanOrEqualTo(radius + 0.01f), + $"Wave {index} stays within the Blizzard attack radius."); + AssertHitSorting(renderer, + coldTarget.Enemy.GetComponent()); + } + } + Assert.That(shardCount, Is.EqualTo(DefenseTowerArtBounds.BlizzardShardCount)); + Assert.That(trailCount, Is.EqualTo(DefenseTowerArtBounds.BlizzardShardCount)); + Assert.That(impactCount, Is.EqualTo(DefenseTowerArtBounds.BlizzardShardCount)); + Assert.That(waveCount, Is.EqualTo(DefenseTowerArtBounds.BlizzardShardCount)); + + float firstLandingDeadline = Time.realtimeSinceStartup + 0.6f; + while (!impacts[0].enabled && Time.realtimeSinceStartup < firstLandingDeadline) + yield return null; + Assert.That(impacts[0].enabled, Is.True, + "The first impact appears when the first shard lands."); + Assert.That(waves[0].enabled, Is.True, + "The first ground wave appears when the first shard lands."); + Assert.That(Time.time - blizzardStartedAt, + Is.EqualTo(DefenseTowerArtBounds.BlizzardLandingSeconds).Within(0.05f)); + + yield return new WaitForSeconds(0.21f); + + float landingDeadline = Time.realtimeSinceStartup + 0.25f; + while (Time.realtimeSinceStartup < landingDeadline + && !impacts[DefenseTowerArtBounds.BlizzardShardCount - 1].enabled) + yield return null; + Assert.That(impacts[DefenseTowerArtBounds.BlizzardShardCount - 1].enabled, Is.True, + "The final wave lands at its staggered time."); + Assert.That(Time.time - blizzardStartedAt, + Is.EqualTo(DefenseTowerArtBounds.BlizzardLastLandingSeconds).Within(0.05f)); + int lastImpact = DefenseTowerArtBounds.BlizzardShardCount - 1; + yield return null; + Assert.That(impacts[lastImpact].enabled, Is.True, + "The final impact appears on the frame after its shard lands."); + Assert.That(waves[lastImpact].enabled, Is.True, + "The final wave appears on the frame after its shard lands."); + float captureTimeScale = Time.timeScale; + Time.timeScale = 0f; + CaptureRender("tower-visual-04-blizzard-last-landing.png"); + Time.timeScale = captureTimeScale; + yield return new WaitForSeconds(0.2f); + Assert.That(coldEffect, Is.Not.Null, + "The attack root remains alive while the final wave animation is playing."); + float cleanupDeadline = Time.realtimeSinceStartup + 0.6f; + while (coldEffect != null && Time.realtimeSinceStartup < cleanupDeadline) + yield return null; + Assert.That(coldEffect == null, Is.True, + "The attack root is destroyed after every shard, impact, trail, and wave sequence finishes."); + } + + private void CaptureRender(string fileName) + { + if (!cameraStateSaved) + { + captureCameraFollow = Object.FindAnyObjectByType(); + captureCamera = captureCameraFollow != null + ? captureCameraFollow.WorldCamera : Camera.main; + Assert.That(captureCamera, Is.Not.Null); + originalCameraPosition = captureCamera.transform.position; + originalCameraOrthographicSize = captureCamera.orthographicSize; + originalCameraFollowEnabled = captureCameraFollow != null + && captureCameraFollow.enabled; + cameraStateSaved = true; + } + + if (captureCameraFollow != null) captureCameraFollow.enabled = false; + captureCamera.transform.position = new Vector3( + 0f, 0f, captureCamera.transform.position.z); + captureCamera.orthographicSize = 9f; + + const int width = 1920; + const int height = 1080; + RenderTexture renderTarget = new(width, height, 24); + Assert.That(renderTarget.Create(), Is.True); + Texture2D image = new(width, height, TextureFormat.RGB24, false); + RenderTexture previousTarget = captureCamera.targetTexture; + RenderTexture previousActive = RenderTexture.active; + try + { + captureCamera.targetTexture = renderTarget; + captureCamera.Render(); + RenderTexture.active = renderTarget; + image.ReadPixels(new Rect(0, 0, width, height), 0, 0); + image.Apply(); + Directory.CreateDirectory( + @"D:\project\game\output\tower-hit-test-v1"); + File.WriteAllBytes( + Path.Combine(@"D:\project\game\output\tower-hit-test-v1", fileName), + image.EncodeToPNG()); + } + finally + { + captureCamera.targetTexture = previousTarget; + RenderTexture.active = previousActive; + Object.Destroy(image); + renderTarget.Release(); + Object.Destroy(renderTarget); + } + } + + private static int FindFrameIndex(Sprite[] frames, Sprite current) + { + for (int i = 0; i < frames.Length; i++) + if (frames[i] == current) return i; + return -1; + } + + private static void AssertHitSorting(Renderer effect, SpriteRenderer target) + { + Assert.That(effect.sortingLayerID, Is.EqualTo(target.sortingLayerID)); + Assert.That(effect.sortingOrder, + Is.EqualTo(target.sortingOrder + DefenseTowerArtBounds.TowerHitSortingOffset)); + } + + private static void UpgradeToSecondBranch( + DefensePrototypeController controller, PlayerController player, + DefenseTower tower) + { + Assert.That(player.TryReposition( + controller.Tuning.GetBuildPadPosition(tower.PadIndex)), Is.True); + Assert.That(controller.TryUpgradeTowerAtPad(tower.PadIndex, + DefenseTowerBranch.None), Is.True); + Assert.That(controller.TryUpgradeTowerAtPad(tower.PadIndex, + DefenseTowerBranch.None), Is.True); + Assert.That(controller.TryUpgradeTowerAtPad(tower.PadIndex, + DefenseTowerBranch.Second), Is.True); + } + + private static DefenseTower FindTower(DefenseTowerKind kind) + { + int padIndex = System.Array.IndexOf( + DefenseTowerHitTestLayout.KindsByPad, kind); + Assert.That(padIndex, Is.GreaterThanOrEqualTo(0), + $"Tower kind {kind} is absent from the hit-test layout."); + DefenseTower[] towers = Object.FindObjectsByType( + FindObjectsSortMode.None); + foreach (DefenseTower tower in towers) + if (tower.PadIndex == padIndex && tower.Kind == kind) return tower; + Assert.Fail($"Missing tower {kind} at pad {padIndex}."); + return null; + } + + private static DefenseRouteAgent FindDummy(DefenseTowerKind kind) + { + DefenseRouteAgent[] agents = Object.FindObjectsByType( + FindObjectsSortMode.None); + string expectedName = $"Tower Test Dummy {kind}"; + foreach (DefenseRouteAgent agent in agents) + if (agent.Enemy != null && agent.Enemy.name == expectedName) + return agent; + Assert.Fail($"Missing fixed target {expectedName}."); + return null; + } + + private static void SetField(object instance, string name, object value) + { + FieldInfo field = instance.GetType().GetField( + name, BindingFlags.Instance | BindingFlags.NonPublic); + Assert.That(field, Is.Not.Null, $"Missing field {name}."); + field.SetValue(instance, value); + } + + private static IEnumerator StartTowerHitTestRun() + { + AsyncOperation load = SceneManager.LoadSceneAsync(SceneName); + while (!load.isDone) yield return null; + yield return null; + yield return null; + RunManager runManager = RunManager.Instance; + Assert.That(runManager, Is.Not.Null); + DefensePrototypeController.PrepareTowerHitTestRun(); + Assert.That(runManager.BeginRun(RunMode.DefensePrototype), Is.True); + + float deadline = Time.realtimeSinceStartup + ReadyTimeoutSeconds; + while (Time.realtimeSinceStartup < deadline + && (DefensePrototypeController.Instance == null + || !DefensePrototypeController.Instance.IsActive)) + yield return null; + Assert.That(DefensePrototypeController.Instance, Is.Not.Null); + Assert.That(DefensePrototypeController.Instance.IsActive, Is.True); + yield return CloseStartupSelection(runManager); + } + + private static IEnumerator CloseStartupSelection(RunManager runManager) + { + float deadline = Time.realtimeSinceStartup + ReadyTimeoutSeconds; + while (Time.realtimeSinceStartup < deadline && !runManager.IsSelectionOpen) + yield return null; + Assert.That(runManager.IsSelectionOpen, Is.True); +#if UNITY_EDITOR + ArtifactRewardController rewardController = + Object.FindAnyObjectByType(); + Assert.That(rewardController, Is.Not.Null); + int count = runManager.StartupArtifactSelectionCount; + Assert.That(count, Is.GreaterThan(0)); + for (int i = 0; i < count; i++) + { + Assert.That(rewardController.DebugChooseOption(0), Is.True); + yield return null; + } +#else + Assert.Ignore("The tower visual fixture requires Unity Editor test APIs."); +#endif + yield return null; +#if UNITY_EDITOR + RunTutorialController tutorial = + Object.FindAnyObjectByType(); + Assert.That(tutorial, Is.Not.Null); + bool observed = false; + deadline = Time.realtimeSinceStartup + ReadyTimeoutSeconds; + while (Time.realtimeSinceStartup < deadline) + { + if (tutorial.DebugIsVisible) + { + observed = true; + Assert.That(tutorial.DebugContinue(), Is.True); + yield return null; + continue; + } + if (!runManager.IsSelectionOpen && tutorial.DebugPendingCount == 0) + break; + yield return null; + } + Assert.That(observed, Is.True); + Assert.That(tutorial.DebugIsVisible, Is.False); +#else + Assert.Ignore("The tower visual fixture requires Unity Editor test APIs."); +#endif + Assert.That(runManager.IsSelectionOpen, Is.False); + } + } +} diff --git a/Assets/_Project/Tests/PlayMode/DefenseTowerVisualPlayModeTests.cs.meta b/Assets/_Project/Tests/PlayMode/DefenseTowerVisualPlayModeTests.cs.meta new file mode 100644 index 0000000..02a25f5 --- /dev/null +++ b/Assets/_Project/Tests/PlayMode/DefenseTowerVisualPlayModeTests.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: bf51fb447b414b2da8684437f1bded7d