diff --git a/Assets/_Project/Art/Environment/Graveyard-v1/Graveyard-Finite-v1.png.meta b/Assets/_Project/Art/Environment/Graveyard-v1/Graveyard-Finite-v1.png.meta index a593d54..2f6c4d5 100644 --- a/Assets/_Project/Art/Environment/Graveyard-v1/Graveyard-Finite-v1.png.meta +++ b/Assets/_Project/Art/Environment/Graveyard-v1/Graveyard-Finite-v1.png.meta @@ -45,7 +45,7 @@ TextureImporter: compressionQuality: 50 spriteMode: 1 spriteExtrude: 1 - spriteMeshType: 1 + spriteMeshType: 0 alignment: 0 spritePivot: {x: 0.5, y: 0.5} spritePixelsToUnits: 32 diff --git a/Assets/_Project/Constants/Combat/CombatConstants.asset b/Assets/_Project/Constants/Combat/CombatConstants.asset index 2295a72..58821ff 100644 --- a/Assets/_Project/Constants/Combat/CombatConstants.asset +++ b/Assets/_Project/Constants/Combat/CombatConstants.asset @@ -12,6 +12,7 @@ MonoBehaviour: m_Script: {fileID: 11500000, guid: db153e77cef645c49a74705c9d542ea5, type: 3} m_Name: CombatConstants m_EditorClassIdentifier: BumpCombat.Runtime::BumpCombat.Constants.CombatConstants + enableFrontalBumpSelfDamage: 0 FrontThreshold: 0.707 BackThreshold: -0.5 PositionDamageMultiplier: 1.2 diff --git a/Assets/_Project/Constants/Combat/CombatFeedback.asset b/Assets/_Project/Constants/Combat/CombatFeedback.asset index 72f7eaa..7044029 100644 --- a/Assets/_Project/Constants/Combat/CombatFeedback.asset +++ b/Assets/_Project/Constants/Combat/CombatFeedback.asset @@ -27,6 +27,9 @@ MonoBehaviour: CameraShakeDuration: 0.12 CameraShakePixels: 3 HeavyCameraShakePixels: 5 + RearHitCameraPunchDuration: 0.07 + RearHitCameraPunchPixels: 1 + RearHitCameraPunchRearmDelay: 0.45 HeavyKnockbackThreshold: 1 BlockedBumpHoldDuration: 0.08 BlockedBumpFadeDuration: 0.04 diff --git a/Assets/_Project/Constants/Defense.meta b/Assets/_Project/Constants/Defense.meta new file mode 100644 index 0000000..ad03bf4 --- /dev/null +++ b/Assets/_Project/Constants/Defense.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: c70ac61900f04f0fb18a608ef6f6c38c +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/_Project/Constants/Defense/DefenseConstants.asset b/Assets/_Project/Constants/Defense/DefenseConstants.asset new file mode 100644 index 0000000..153bd2b --- /dev/null +++ b/Assets/_Project/Constants/Defense/DefenseConstants.asset @@ -0,0 +1,58 @@ +%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: 8a83cd68928e48f89b011bc92f0ca8e3, type: 3} + m_Name: DefenseConstants + m_EditorClassIdentifier: BumpCombat.Runtime::BumpCombat.Constants.DefenseConstants + InitialConstructionCurrency: 100 + DirectTowerCost: 30 + SummonTowerCost: 40 + PortalRewardCurrency: 20 + BaseMaximumHealth: 12 + PortalEnemyCount: 8 + ArrivalBaseDamage: 1 + PortalSpawnInterval: 1.25 + RouteSpeedMultiplier: 0.75 + PostAttackProgressSeconds: 0.65 + BuildInteractionRadius: 1.5 + ArenaCenter: {x: 0, y: 0} + ArenaPlayableHalfExtents: {x: 11, y: 6} + PortalPositions: + - {x: -9, y: 3.25} + - {x: -9, y: -3.25} + BasePositions: + - {x: 9, y: 0} + - {x: 9, y: 0} + BuildPadPositions: + - {x: -3.25, y: 3.25} + - {x: 3.25, y: -3.25} + WaypointPositions: + - {x: -3, y: 3.25} + - {x: 3, y: 3.25} + - {x: -3, y: -3.25} + - {x: 3, y: -3.25} + PortalImmuneTowerKinds: + - 0 + - 1 + PlayerSightRadius: 6 + OutsideSightDamageEfficiency: 0.7 + TowerRange: 5.5 + DirectTowerDamage: 10 + DirectTowerAttackInterval: 0.75 + SummonDamage: 6 + SummonAttackInterval: 0.9 + SummonUnitCount: 2 + SummonAreaRadius: 2.25 + SummonUnitMoveSpeed: 2.5 + SummonAttackRange: 1.2 + PlayerReviveDelaySeconds: 5 + PlayerReviveInvulnerabilitySeconds: 1 + RewardPickupRadius: 1.35 diff --git a/Assets/_Project/Constants/Defense/DefenseConstants.asset.meta b/Assets/_Project/Constants/Defense/DefenseConstants.asset.meta new file mode 100644 index 0000000..75a7c34 --- /dev/null +++ b/Assets/_Project/Constants/Defense/DefenseConstants.asset.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 1949e4e0aabb41ff8e66ddd2ee493ba3 +NativeFormatImporter: + externalObjects: {} + mainObjectFileID: 11400000 + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/_Project/Constants/Enemies/ArmoredSkeleton.asset b/Assets/_Project/Constants/Enemies/ArmoredSkeleton.asset index 144619e..dd163c6 100644 --- a/Assets/_Project/Constants/Enemies/ArmoredSkeleton.asset +++ b/Assets/_Project/Constants/Enemies/ArmoredSkeleton.asset @@ -16,7 +16,7 @@ MonoBehaviour: role: 0 attackShape: 1 maxHealth: 42 - moveSpeed: 1.1 + moveSpeed: 1.375 attackDamage: 18 warningDuration: 0.85 activeDuration: 0.25 diff --git a/Assets/_Project/Constants/Enemies/Bat.asset b/Assets/_Project/Constants/Enemies/Bat.asset index 6814c9e..c7e28f9 100644 --- a/Assets/_Project/Constants/Enemies/Bat.asset +++ b/Assets/_Project/Constants/Enemies/Bat.asset @@ -13,10 +13,10 @@ MonoBehaviour: m_Name: Bat m_EditorClassIdentifier: BumpCombat.Runtime::BumpCombat.Enemies.EnemyDefinition kind: 5 - role: 1 + role: 0 attackShape: 0 maxHealth: 8 - moveSpeed: 1.5 + moveSpeed: 1.875 attackDamage: 8 warningDuration: 0.35 activeDuration: 0.25 @@ -27,7 +27,7 @@ MonoBehaviour: attackWidth: 0 attackRadius: 0.45 attackAnimationLeadTime: 0 - experienceValue: 1 + experienceValue: 2 attackPatterns: - animationState: Attack attackShape: 0 @@ -42,3 +42,4 @@ MonoBehaviour: damageMultiplier: 1 separationRadius: 0.55 separationStrength: 0.6 + isFlying: 1 diff --git a/Assets/_Project/Constants/Enemies/GreatswordSkeleton.asset b/Assets/_Project/Constants/Enemies/GreatswordSkeleton.asset index a05d8ec..3a973e8 100644 --- a/Assets/_Project/Constants/Enemies/GreatswordSkeleton.asset +++ b/Assets/_Project/Constants/Enemies/GreatswordSkeleton.asset @@ -16,7 +16,7 @@ MonoBehaviour: role: 0 attackShape: 2 maxHealth: 50 - moveSpeed: 0.9 + moveSpeed: 1.125 attackDamage: 18 warningDuration: 1.2 activeDuration: 0.22 diff --git a/Assets/_Project/Constants/Enemies/Lancer.asset b/Assets/_Project/Constants/Enemies/Lancer.asset index ecb81d5..5b25c2d 100644 --- a/Assets/_Project/Constants/Enemies/Lancer.asset +++ b/Assets/_Project/Constants/Enemies/Lancer.asset @@ -16,7 +16,7 @@ MonoBehaviour: role: 0 attackShape: 1 maxHealth: 40 - moveSpeed: 1.15 + moveSpeed: 1.4375 attackDamage: 15 warningDuration: 0.9 activeDuration: 0.18 diff --git a/Assets/_Project/Constants/Enemies/NecroGolem.asset b/Assets/_Project/Constants/Enemies/NecroGolem.asset index 084b6ec..8a1f4e5 100644 --- a/Assets/_Project/Constants/Enemies/NecroGolem.asset +++ b/Assets/_Project/Constants/Enemies/NecroGolem.asset @@ -16,7 +16,7 @@ MonoBehaviour: role: 0 attackShape: 1 maxHealth: 60 - moveSpeed: 0.65 + moveSpeed: 0.8125 attackDamage: 30 warningDuration: 1.05 activeDuration: 0.25 diff --git a/Assets/_Project/Constants/Enemies/Necrofire.asset b/Assets/_Project/Constants/Enemies/Necrofire.asset index 093a1dc..84bcfad 100644 --- a/Assets/_Project/Constants/Enemies/Necrofire.asset +++ b/Assets/_Project/Constants/Enemies/Necrofire.asset @@ -16,7 +16,7 @@ MonoBehaviour: role: 0 attackShape: 1 maxHealth: 12 - moveSpeed: 0.9 + moveSpeed: 1.125 attackDamage: 10 warningDuration: 1.5 activeDuration: 0.375 @@ -44,3 +44,4 @@ MonoBehaviour: damageMultiplier: 1 separationRadius: 0.55 separationStrength: 0.6 + isFlying: 1 diff --git a/Assets/_Project/Constants/Enemies/Necromancer.asset b/Assets/_Project/Constants/Enemies/Necromancer.asset index afd76d3..63592e6 100644 --- a/Assets/_Project/Constants/Enemies/Necromancer.asset +++ b/Assets/_Project/Constants/Enemies/Necromancer.asset @@ -16,7 +16,7 @@ MonoBehaviour: role: 0 attackShape: 2 maxHealth: 60 - moveSpeed: 0.55 + moveSpeed: 0.6875 attackDamage: 30 warningDuration: 1.1 activeDuration: 0.375 diff --git a/Assets/_Project/Constants/Enemies/Skeleton.asset b/Assets/_Project/Constants/Enemies/Skeleton.asset index 80543ec..3e763ad 100644 --- a/Assets/_Project/Constants/Enemies/Skeleton.asset +++ b/Assets/_Project/Constants/Enemies/Skeleton.asset @@ -15,8 +15,8 @@ MonoBehaviour: kind: 1 role: 0 attackShape: 1 - maxHealth: 10 - moveSpeed: 1.2 + maxHealth: 24 + moveSpeed: 1.5 attackDamage: 12 warningDuration: 0.7 activeDuration: 0.15 @@ -26,7 +26,7 @@ MonoBehaviour: attackLength: 0.8 attackWidth: 0.6 attackRadius: 0 - experienceValue: 2 + experienceValue: 3 attackPatterns: - animationState: Attack attackShape: 1 diff --git a/Assets/_Project/Constants/Enemies/SkeletonArcher.asset b/Assets/_Project/Constants/Enemies/SkeletonArcher.asset index b0c67e4..13930a5 100644 --- a/Assets/_Project/Constants/Enemies/SkeletonArcher.asset +++ b/Assets/_Project/Constants/Enemies/SkeletonArcher.asset @@ -16,7 +16,7 @@ MonoBehaviour: role: 0 attackShape: 1 maxHealth: 14 - moveSpeed: 0.9 + moveSpeed: 1.125 attackDamage: 12 warningDuration: 1 activeDuration: 0.15 diff --git a/Assets/_Project/Constants/Enemies/Slime.asset b/Assets/_Project/Constants/Enemies/Slime.asset index aac900f..4471e4c 100644 --- a/Assets/_Project/Constants/Enemies/Slime.asset +++ b/Assets/_Project/Constants/Enemies/Slime.asset @@ -13,10 +13,10 @@ MonoBehaviour: m_Name: Slime m_EditorClassIdentifier: BumpCombat.Runtime::BumpCombat.Enemies.EnemyDefinition kind: 0 - role: 1 + role: 0 attackShape: 0 maxHealth: 10 - moveSpeed: 1.3 + moveSpeed: 1.625 attackDamage: 10 warningDuration: 0.35 activeDuration: 0.25 @@ -26,6 +26,6 @@ MonoBehaviour: attackLength: 0 attackWidth: 0 attackRadius: 0.45 - experienceValue: 1 + experienceValue: 2 separationRadius: 0.55 separationStrength: 0.6 diff --git a/Assets/_Project/Constants/Enemies/Warlock.asset b/Assets/_Project/Constants/Enemies/Warlock.asset index 3a80f9c..f54b858 100644 --- a/Assets/_Project/Constants/Enemies/Warlock.asset +++ b/Assets/_Project/Constants/Enemies/Warlock.asset @@ -16,7 +16,7 @@ MonoBehaviour: role: 0 attackShape: 3 maxHealth: 30 - moveSpeed: 1 + moveSpeed: 1.25 attackDamage: 20 warningDuration: 0.8 activeDuration: 0.15 diff --git a/Assets/_Project/Constants/Enemies/Werebear.asset b/Assets/_Project/Constants/Enemies/Werebear.asset index ff5ea99..9ca0d92 100644 --- a/Assets/_Project/Constants/Enemies/Werebear.asset +++ b/Assets/_Project/Constants/Enemies/Werebear.asset @@ -16,7 +16,7 @@ MonoBehaviour: role: 0 attackShape: 2 maxHealth: 55 - moveSpeed: 0.8 + moveSpeed: 1 attackDamage: 24 warningDuration: 0.9 activeDuration: 0.25 diff --git a/Assets/_Project/Constants/Enemies/Werewolf.asset b/Assets/_Project/Constants/Enemies/Werewolf.asset index 250826d..982f2e3 100644 --- a/Assets/_Project/Constants/Enemies/Werewolf.asset +++ b/Assets/_Project/Constants/Enemies/Werewolf.asset @@ -16,7 +16,7 @@ MonoBehaviour: role: 0 attackShape: 2 maxHealth: 38 - moveSpeed: 1.45 + moveSpeed: 1.8125 attackDamage: 20 warningDuration: 0.8 activeDuration: 0.25 diff --git a/Assets/_Project/Constants/Player/PlayerConstants.asset b/Assets/_Project/Constants/Player/PlayerConstants.asset index 9326944..a3b6050 100644 --- a/Assets/_Project/Constants/Player/PlayerConstants.asset +++ b/Assets/_Project/Constants/Player/PlayerConstants.asset @@ -12,7 +12,7 @@ MonoBehaviour: m_Script: {fileID: 11500000, guid: 2643d1587a2745d0b584eadd6b34978b, type: 3} m_Name: PlayerConstants m_EditorClassIdentifier: BumpCombat.Runtime::BumpCombat.Constants.PlayerConstants - BaseMoveSpeed: 3 + BaseMoveSpeed: 3.75 BaseMaxHealth: 100 InvulnerabilityDuration: 1 KnockbackDuration: 0.14 diff --git a/Assets/_Project/Constants/Relics.meta b/Assets/_Project/Constants/Relics.meta new file mode 100644 index 0000000..843d619 --- /dev/null +++ b/Assets/_Project/Constants/Relics.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: b1a37a7f408649c8b975bca1bda14e86 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/_Project/Constants/Relics/PassiveRelicConstants.asset b/Assets/_Project/Constants/Relics/PassiveRelicConstants.asset new file mode 100644 index 0000000..d478630 --- /dev/null +++ b/Assets/_Project/Constants/Relics/PassiveRelicConstants.asset @@ -0,0 +1,21 @@ +%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: f0f9bca102564c4395943d6b39c6d9f0, type: 3} + m_Name: PassiveRelicConstants + m_EditorClassIdentifier: BumpCombat.Runtime::BumpCombat.Constants.PassiveRelicConstants + TwinHornDoubleDamageChance: 0.15 + ResonanceEchoChance: 0.08 + ResonanceEchoCooldownSeconds: 3 + ResonanceEchoDamageMultiplier: 0.5 + RuptureSeedChance: 0.25 + RuptureExplosionMaxHealthFraction: 0.1 + RuptureExplosionPlayerBodyDiameters: 3 diff --git a/Assets/_Project/Constants/Relics/PassiveRelicConstants.asset.meta b/Assets/_Project/Constants/Relics/PassiveRelicConstants.asset.meta new file mode 100644 index 0000000..22258ea --- /dev/null +++ b/Assets/_Project/Constants/Relics/PassiveRelicConstants.asset.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 2c5773908a374e96ad132e07d5bcc421 +NativeFormatImporter: + externalObjects: {} + mainObjectFileID: 11400000 + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/_Project/Constants/Resources/GameplayConstants.asset b/Assets/_Project/Constants/Resources/GameplayConstants.asset index 83c7aab..4ba8ce7 100644 --- a/Assets/_Project/Constants/Resources/GameplayConstants.asset +++ b/Assets/_Project/Constants/Resources/GameplayConstants.asset @@ -20,3 +20,5 @@ MonoBehaviour: artifacts: {fileID: 11400000, guid: 619a1bb1a7bb416fbf1a4d0f4e4f66c5, type: 2} run: {fileID: 11400000, guid: af4f8662e3d84fbf8df4757fc7bfd22f, type: 2} combatFeedback: {fileID: 11400000, guid: 6ab7e48e63b84ea3ac749bd827d1a87f, type: 2} + defense: {fileID: 11400000, guid: 1949e4e0aabb41ff8e66ddd2ee493ba3, type: 2} + passiveRelics: {fileID: 11400000, guid: 2c5773908a374e96ad132e07d5bcc421, type: 2} diff --git a/Assets/_Project/Constants/Run/RunConstants.asset b/Assets/_Project/Constants/Run/RunConstants.asset index 7360026..f47d80c 100644 --- a/Assets/_Project/Constants/Run/RunConstants.asset +++ b/Assets/_Project/Constants/Run/RunConstants.asset @@ -147,5 +147,13 @@ MonoBehaviour: ArenaPlayableHalfExtents: {x: 15, y: 8.4375} ArenaPlayerPadding: {x: 0.5, y: 0.625} ArenaCameraViewHalfExtents: {x: 10, y: 5.625} + StageCameraViewHalfExtents: {x: 7.5, y: 4.21875} + StageComparisonOriginalCameraViewHalfExtents: {x: 5, y: 2.8125} + StageComparisonCameraViewHalfExtents: {x: 7.5, y: 4.21875} ArenaCameraDeadZone: {x: 0.5, y: 0.3} ArenaCameraSmoothTime: 0.15 + Stage1FirstCombatRoomScaleComparison: 0 + StagePlayerVisibilityRadius: 6 + StageExploredFogOpacity: 0.65 + StageUnseenFogOpacity: 1 + StageEncounterSightRangeMultiplier: 0.5 diff --git a/Assets/_Project/Prefabs/Enemies/Bat.prefab b/Assets/_Project/Prefabs/Enemies/Bat.prefab index 9cef86c..2b55794 100644 --- a/Assets/_Project/Prefabs/Enemies/Bat.prefab +++ b/Assets/_Project/Prefabs/Enemies/Bat.prefab @@ -70,7 +70,7 @@ CircleCollider2D: m_GameObject: {fileID: 810786416157290827} m_Enabled: 1 serializedVersion: 3 - m_IsTrigger: 1 + m_IsTrigger: 0 m_Offset: {x: 0, y: -0.28} m_Radius: 0.22 --- !u!114 &3111862855908660951 diff --git a/Assets/_Project/Prefabs/Enemies/Slime.prefab b/Assets/_Project/Prefabs/Enemies/Slime.prefab index 8e359b4..fb3ab0d 100644 --- a/Assets/_Project/Prefabs/Enemies/Slime.prefab +++ b/Assets/_Project/Prefabs/Enemies/Slime.prefab @@ -190,7 +190,7 @@ CircleCollider2D: m_CallbackLayers: serializedVersion: 2 m_Bits: 4294967295 - m_IsTrigger: 1 + m_IsTrigger: 0 m_UsedByEffector: 0 m_CompositeOperation: 0 m_CompositeOrder: 0 diff --git a/Assets/_Project/Prefabs/Player/Player.prefab b/Assets/_Project/Prefabs/Player/Player.prefab index e324fd1..40833f4 100644 --- a/Assets/_Project/Prefabs/Player/Player.prefab +++ b/Assets/_Project/Prefabs/Player/Player.prefab @@ -200,7 +200,7 @@ CircleCollider2D: m_UsedByEffector: 0 m_CompositeOperation: 0 m_CompositeOrder: 0 - m_Offset: {x: 0, y: -0.28} + m_Offset: {x: 0, y: -0.06} m_Radius: 0.22 --- !u!114 &6788187778577634304 MonoBehaviour: diff --git a/Assets/_Project/Scripts/Combat/ArtifactStatusVisual.cs b/Assets/_Project/Scripts/Combat/ArtifactStatusVisual.cs index 4cc5145..4a9741f 100644 --- a/Assets/_Project/Scripts/Combat/ArtifactStatusVisual.cs +++ b/Assets/_Project/Scripts/Combat/ArtifactStatusVisual.cs @@ -9,6 +9,7 @@ namespace BumpCombat.Combat public sealed class ArtifactStatusVisual : MonoBehaviour { public const int StatusSortingOffset = 1; + public const float StatusVisualAnchorOffsetY = -13f / 32f; private const int FrameCount = 6; private const float PixelsPerUnit = 32f; @@ -213,14 +214,23 @@ namespace BumpCombat.Combat return; } - Vector2 groundAnchor = enemy.GroundAnchorPosition; - slot.Root.transform.SetPositionAndRotation( - new Vector3(groundAnchor.x, groundAnchor.y, transform.position.z), - Quaternion.identity); + Transform visualTransform = enemy.CurrentVisualTransform; + if (slot.Root.transform.parent != visualTransform) + { + slot.Root.transform.SetParent(visualTransform, false); + } + + float anchorOffsetY = sourceRenderer.flipY + ? -StatusVisualAnchorOffsetY + : StatusVisualAnchorOffsetY; + slot.Root.transform.localPosition = + new Vector3(0f, anchorOffsetY, 0f); + slot.Root.transform.localRotation = Quaternion.identity; slot.Root.transform.localScale = Vector3.one; slot.Renderer.sortingLayerID = sourceRenderer.sortingLayerID; slot.Renderer.sortingOrder = - sourceRenderer.sortingOrder + StatusSortingOffset; + enemy.CurrentVisualSortingOrder + StatusSortingOffset; + slot.Renderer.forceRenderingOff = sourceRenderer.forceRenderingOff; slot.Renderer.flipX = sourceRenderer.flipX; slot.Renderer.flipY = sourceRenderer.flipY; slot.Renderer.sharedMaterial = sourceRenderer.sharedMaterial; diff --git a/Assets/_Project/Scripts/Combat/BumpCombatResolver.cs b/Assets/_Project/Scripts/Combat/BumpCombatResolver.cs index f21e5b0..ae389b2 100644 --- a/Assets/_Project/Scripts/Combat/BumpCombatResolver.cs +++ b/Assets/_Project/Scripts/Combat/BumpCombatResolver.cs @@ -3,6 +3,7 @@ using BumpCombat.Core; using BumpCombat.Constants; using BumpCombat.Enemies; using BumpCombat.Player; +using BumpCombat.Progression; using UnityEngine; namespace BumpCombat.Combat @@ -12,6 +13,7 @@ namespace BumpCombat.Combat public sealed class BumpCombatResolver : MonoBehaviour { private float baseDamage => GameplayConstants.Current.Combat.BaseDamage; + private bool enableFrontalBumpSelfDamage => GameplayConstants.Current.Combat.EnableFrontalBumpSelfDamage; private float dashDamage => GameplayConstants.Current.Combat.DashDamage; private float strongDashDamage => GameplayConstants.Current.Combat.StrongDashDamage; private float minimumAttackSpeed => GameplayConstants.Current.Combat.MinimumAttackSpeed; @@ -35,6 +37,7 @@ namespace BumpCombat.Combat private readonly Dictionary nextNormalHitTimes = new(); private PlayerStats playerStats; + private PlayerHealth playerHealth; private PlayerController playerController; private DashController dashController; private ActiveArtifactController activeArtifactController; @@ -43,6 +46,7 @@ namespace BumpCombat.Combat private void Awake() { playerStats = GetComponent(); + playerHealth = GetComponent(); playerController = GetComponent(); dashController = GetComponent(); activeArtifactController = GetComponent(); @@ -335,6 +339,10 @@ namespace BumpCombat.Combat playerStats.CalculateDamage(rawDamage, damageTag), side, isOffsetHit); + finalDamage = PassiveRelicController.Instance?.ApplyOrdinaryBumpDamageMultiplier( + finalDamage, + !isDash && string.IsNullOrEmpty(sourceId), + artifactColor.HasValue) ?? finalDamage; Vector2 knockbackDirection = -enemyToPlayer; bool suppressOrdinaryEventReaction = !isDash && isEventEnemy; float appliedKnockback = knockbackOverride @@ -361,7 +369,8 @@ namespace BumpCombat.Combat appliedKnockback, artifactColor.Value, castIdentity, - !suppressOrdinaryEventReaction); + !suppressOrdinaryEventReaction, + EnemyDamageSource.Player); acceptedHit = artifactHitResult != EnemyArtifactHitResult.Rejected; } @@ -372,7 +381,8 @@ namespace BumpCombat.Combat knockbackDirection, appliedKnockback, !suppressOrdinaryEventReaction, - !isDash); + !isDash, + EnemyDamageSource.Player); } if (!acceptedHit) @@ -477,16 +487,24 @@ namespace BumpCombat.Combat if (!isDash && !protectionBegan) { - if (!enemy.IsCrowd + if (enableFrontalBumpSelfDamage + && !artifactColor.HasValue + && side == HitSide.Front) + { + playerHealth?.TryTakeDamage( + enemy.ContactDamage, + enemyToPlayer, + enemy.PlayerKnockbackDistance); + } + else if (side != HitSide.Front + && !enemy.IsCrowd && BumpCombatMath.AppliesPlayerImpactRecoil(side, isEventEnemy)) { playerController.ApplyImpactRecoil( enemyToPlayer, - side == HitSide.Front - ? frontPlayerImpactRecoilDistance - : side == HitSide.Back && isEventEnemy - ? eventBackPlayerImpactRecoilDistance - : playerImpactRecoilDistance); + side == HitSide.Back && isEventEnemy + ? eventBackPlayerImpactRecoilDistance + : playerImpactRecoilDistance); } activeArtifactController?.AddNormalHitCharge(); } diff --git a/Assets/_Project/Scripts/Combat/CombatFeedback.cs b/Assets/_Project/Scripts/Combat/CombatFeedback.cs index 690a055..45a129a 100644 --- a/Assets/_Project/Scripts/Combat/CombatFeedback.cs +++ b/Assets/_Project/Scripts/Combat/CombatFeedback.cs @@ -27,6 +27,9 @@ namespace BumpCombat.Combat private float cameraShakeDuration => GameplayConstants.Current.CombatFeedback.CameraShakeDuration; private float cameraShakePixels => GameplayConstants.Current.CombatFeedback.CameraShakePixels; private float heavyCameraShakePixels => GameplayConstants.Current.CombatFeedback.HeavyCameraShakePixels; + private float rearHitCameraPunchDuration => GameplayConstants.Current.CombatFeedback.RearHitCameraPunchDuration; + private float rearHitCameraPunchPixels => GameplayConstants.Current.CombatFeedback.RearHitCameraPunchPixels; + private float rearHitCameraPunchRearmDelay => GameplayConstants.Current.CombatFeedback.RearHitCameraPunchRearmDelay; private float heavyKnockbackThreshold => GameplayConstants.Current.CombatFeedback.HeavyKnockbackThreshold; [SerializeField, Min(1f)] private float assetsPixelsPerUnit = 32f; @@ -96,6 +99,7 @@ namespace BumpCombat.Combat private float flashUntil; private float blinkUntil; private Vector3 cameraRestPosition; + private float lastRearHitCameraPunchAt = float.NegativeInfinity; private Material effectMaterial; private Vector3 lastCycloneSamplePosition; private Vector2 lastDashAfterimagePosition; @@ -309,6 +313,7 @@ namespace BumpCombat.Combat private void OnEnable() { activeInstance = this; + lastRearHitCameraPunchAt = float.NegativeInfinity; CombatEvents.OnValidHit += HandleValidHit; CombatEvents.OnStunApplied += HandleStunApplied; CombatEvents.OnAttackCancelled += HandleAttackCancelled; @@ -1354,6 +1359,19 @@ namespace BumpCombat.Combat return; } + if (result.IsOrdinaryBump && result.HasBackBonus) + { + float now = Time.time; + bool rearHitCameraPunchRearmed = + now - lastRearHitCameraPunchAt >= rearHitCameraPunchRearmDelay; + lastRearHitCameraPunchAt = now; + + if (rearHitCameraPunchRearmed) + { + RequestRearHitCameraPunch(result.KnockbackDirection); + } + } + Color color; float strength; int rayCount; @@ -2203,6 +2221,63 @@ namespace BumpCombat.Combat playerVisualCoroutine = null; } + private void RequestRearHitCameraPunch(Vector2 direction) + { + if (worldCamera == null || + rearHitCameraPunchDuration <= 0f || + rearHitCameraPunchPixels <= 0f || + rearHitCameraPunchRearmDelay < 0f || + assetsPixelsPerUnit <= 0f || + direction.sqrMagnitude <= Mathf.Epsilon || + cameraShakeCoroutine != null) + { + return; + } + + cameraRestPosition = worldCamera.transform.position; + cameraShakeCoroutine = StartCoroutine( + RearHitCameraPunchRoutine(direction.normalized)); + } + + private IEnumerator RearHitCameraPunchRoutine(Vector2 normalizedDirection) + { + float startTime = Time.unscaledTime; + + while (Time.unscaledTime - startTime < rearHitCameraPunchDuration) + { + float t = + (Time.unscaledTime - startTime) / rearHitCameraPunchDuration; + Vector2 pixelOffset = + normalizedDirection * (rearHitCameraPunchPixels * (1f - t)); + Vector2 offset = new Vector2( + Mathf.Round(pixelOffset.x) / assetsPixelsPerUnit, + Mathf.Round(pixelOffset.y) / assetsPixelsPerUnit); + + if (cameraFollow != null) + { + cameraFollow.SetExternalShakeOffset(offset); + } + else + { + worldCamera.transform.position = + cameraRestPosition + new Vector3(offset.x, offset.y, 0f); + } + + yield return null; + } + + if (cameraFollow != null) + { + cameraFollow.SetExternalShakeOffset(Vector2.zero); + } + else + { + worldCamera.transform.position = cameraRestPosition; + } + + cameraShakeCoroutine = null; + } + private void RequestCameraShake(float pixels) { if (worldCamera == null || pixels <= 0f || cameraShakeDuration <= 0f) diff --git a/Assets/_Project/Scripts/Constants/CombatConstants.cs b/Assets/_Project/Scripts/Constants/CombatConstants.cs index b825e30..c72d5c9 100644 --- a/Assets/_Project/Scripts/Constants/CombatConstants.cs +++ b/Assets/_Project/Scripts/Constants/CombatConstants.cs @@ -5,6 +5,17 @@ namespace BumpCombat.Constants [CreateAssetMenu(menuName = "BumpCombat/Constants/Combat")] public sealed class CombatConstants : ScriptableObject { + [Header("정면 몸통박치기 접촉 피해")] + [SerializeField, Tooltip("OFF: 정면 몸통박치기 자체 피해 없음. ON: 이전 enemy.ContactDamage를 PlayerHealth.TryTakeDamage로 전달해 일반 무적·가드·피격 넉백을 적용하며 EnemyAttack는 영향 없음.")] + private bool enableFrontalBumpSelfDamage; + + public bool EnableFrontalBumpSelfDamage + { + get => enableFrontalBumpSelfDamage; + set => enableFrontalBumpSelfDamage = value; + } + + [Header("몸통박치기 방향 판정")] [Tooltip("적 정면으로 분류하는 방향 내적 기준"), Range(-1f, 1f)] public float FrontThreshold = 0.707f; [Tooltip("적 후방으로 분류하는 방향 내적 기준"), Range(-1f, 1f)] public float BackThreshold = -0.5f; diff --git a/Assets/_Project/Scripts/Constants/CombatFeedbackSettings.cs b/Assets/_Project/Scripts/Constants/CombatFeedbackSettings.cs index 460f465..48d7370 100644 --- a/Assets/_Project/Scripts/Constants/CombatFeedbackSettings.cs +++ b/Assets/_Project/Scripts/Constants/CombatFeedbackSettings.cs @@ -25,6 +25,9 @@ namespace BumpCombat.Constants [Tooltip("흔들림 지속시간 (초)"), Min(0f)] public float CameraShakeDuration = 0.12f; [Tooltip("일반 흔들림 크기 (픽셀)"), Min(0f)] public float CameraShakePixels = 3f; [Tooltip("강한 흔들림 크기 (픽셀)"), Min(0f)] public float HeavyCameraShakePixels = 5f; + public float RearHitCameraPunchDuration = 0.07f; + public float RearHitCameraPunchPixels = 1f; + public float RearHitCameraPunchRearmDelay = 0.45f; [Tooltip("강한 피격으로 표시하는 넉백 거리 (유닛)"), Min(0f)] public float HeavyKnockbackThreshold = 1f; [Header("무적 적 몸통박치기 표시")] diff --git a/Assets/_Project/Scripts/Constants/DefenseConstants.cs b/Assets/_Project/Scripts/Constants/DefenseConstants.cs new file mode 100644 index 0000000..8ee32ef --- /dev/null +++ b/Assets/_Project/Scripts/Constants/DefenseConstants.cs @@ -0,0 +1,117 @@ +using BumpCombat.Defense; +using UnityEngine; + +namespace BumpCombat.Constants +{ + [CreateAssetMenu(menuName = "BumpCombat/Constants/Defense Prototype")] + public sealed class DefenseConstants : ScriptableObject + { + [Header("Prototype economy")] + [Tooltip("Initial construction currency for the test mode.")] + [Min(0)] public int InitialConstructionCurrency = 100; + [Min(0)] public int DirectTowerCost = 30; + [Min(0)] public int SummonTowerCost = 40; + [Min(0)] public int PortalRewardCurrency = 20; + + [Min(0.1f)] public float BuildInteractionRadius = 1.5f; + [Header("Prototype arena")] + public Vector2 ArenaCenter = Vector2.zero; + public Vector2 ArenaPlayableHalfExtents = new(11f, 6f); + + [Header("Prototype bases and routes")] + [Min(1)] public int BaseMaximumHealth = 12; + [Min(1)] public int PortalEnemyCount = 8; + [Min(0)] public int ArrivalBaseDamage = 1; + [Min(0.1f)] public float PortalSpawnInterval = 1.25f; + [Min(0.1f)] public float RouteSpeedMultiplier = 0.75f; + [Min(0f)] public float PostAttackProgressSeconds = 0.65f; + public Vector2[] PortalPositions = + { + new(-9f, 3.25f), + new(-9f, -3.25f), + }; + public Vector2[] BasePositions = + { + new(9f, 0f), + new(9f, 0f), + }; + public Vector2[] BuildPadPositions = + { + new(-3.25f, 3.25f), + new(3.25f, -3.25f), + }; + [Tooltip("Two ordered waypoints per lane, indexed by lane * 2 + waypoint.")] + public Vector2[] WaypointPositions = + { + new(-3f, 3.25f), new(3f, 3.25f), + new(-3f, -3.25f), new(3f, -3.25f), + }; + [Tooltip("The tower source kind ignored by each portal's enemies.")] + public DefenseTowerKind[] PortalImmuneTowerKinds = + { + DefenseTowerKind.Direct, + DefenseTowerKind.Summon, + }; + + [Header("Prototype tower sight and damage")] + [Min(0.1f)] public float PlayerSightRadius = 6f; + [Range(0f, 1f)] public float OutsideSightDamageEfficiency = 0.7f; + [Min(0.1f)] public float TowerRange = 5.5f; + [Min(0.1f)] public float DirectTowerDamage = 10f; + [Min(0.05f)] public float DirectTowerAttackInterval = 0.75f; + [Min(0.1f)] public float SummonDamage = 6f; + [Min(0.05f)] public float SummonAttackInterval = 0.9f; + [Min(1)] public int SummonUnitCount = 2; + [Min(0.1f)] public float SummonAreaRadius = 2.25f; + [Min(0.1f)] public float SummonUnitMoveSpeed = 2.5f; + [Min(0.1f)] public float SummonAttackRange = 1.2f; + + [Header("Prototype player recovery")] + [Min(0f)] public float PlayerReviveDelaySeconds = 5f; + [Min(0f)] public float PlayerReviveInvulnerabilitySeconds = 1f; + [Min(0.1f)] public float RewardPickupRadius = 1.35f; + + public Vector2 GetPortalPosition(int lane) + { + return GetPosition(PortalPositions, lane, new Vector2(-9f, lane == 0 ? 3.25f : -3.25f)); + } + + public Vector2 GetBasePosition(int lane) + { + return GetPosition(BasePositions, lane, new Vector2(9f, 0f)); + } + + public Vector2 GetBuildPadPosition(int index) + { + return GetPosition(BuildPadPositions, index, Vector2.zero); + } + + public Vector2 GetWaypointPosition(int lane, int waypointIndex) + { + int index = lane * 2 + waypointIndex; + Vector2 fallback = new( + waypointIndex == 0 ? -3f : 3f, + lane == 0 ? 3.25f : -3.25f); + return GetPosition(WaypointPositions, index, fallback); + } + + public DefenseTowerKind GetPortalImmuneTowerKind(int lane) + { + if (PortalImmuneTowerKinds != null + && lane >= 0 + && lane < PortalImmuneTowerKinds.Length) + { + return PortalImmuneTowerKinds[lane]; + } + + return (DefenseTowerKind)Mathf.Clamp(lane, 0, 1); + } + + private static Vector2 GetPosition(Vector2[] positions, int index, Vector2 fallback) + { + return positions != null && index >= 0 && index < positions.Length + ? positions[index] + : fallback; + } + } +} diff --git a/Assets/_Project/Scripts/Constants/DefenseConstants.cs.meta b/Assets/_Project/Scripts/Constants/DefenseConstants.cs.meta new file mode 100644 index 0000000..725d7cf --- /dev/null +++ b/Assets/_Project/Scripts/Constants/DefenseConstants.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 8a83cd68928e48f89b011bc92f0ca8e3 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/_Project/Scripts/Constants/EnemyConstants.cs b/Assets/_Project/Scripts/Constants/EnemyConstants.cs index d4d18fb..bd2d71f 100644 --- a/Assets/_Project/Scripts/Constants/EnemyConstants.cs +++ b/Assets/_Project/Scripts/Constants/EnemyConstants.cs @@ -21,6 +21,17 @@ namespace BumpCombat.Constants [Tooltip("군중형 접근 기준점의 가장 안쪽 반경 (유닛)"), Min(0f)] public float CrowdApproachBaseRadius = 0.75f; [Tooltip("군중형 접근 기준점 동심원 간 간격 (유닛)"), Min(0f)] public float CrowdApproachRingSpacing = 0.15f; [Min(0f)] public float SummonedEnemyNoAnimationLockDuration = 0.15f; + [Tooltip("시야 장애물 차폐를 무시하고 무조건 감지하는 초근접 반경 (유닛)"), Min(0f)] public float ProximityAlertRange = 2.0f; + [Tooltip("적의 기본 시야 사거리 (유닛)"), Min(0f)] public float DefaultSightRange = 8.0f; + [Tooltip("일반 적이 시야를 잃은 뒤 마지막 위치를 기억하는 시간 (초)"), Min(0f)] public float LostSightMemoryDuration = 1.5f; + [Tooltip("비탐지 일반 적의 로밍 반경 최솟값 (유닛)"), Min(0f)] public float RoamRadiusMin = 2.5f; + [Tooltip("비탐지 일반 적의 로밍 반경 최댓값 (유닛)"), Min(0f)] public float RoamRadiusMax = 4.0f; + [Tooltip("로밍 지점 도착 후 최소 대기시간 (초)"), Min(0f)] public float RoamPauseMin = 0.4f; + [Tooltip("로밍 지점 도착 후 최대 대기시간 (초)"), Min(0f)] public float RoamPauseMax = 1.0f; + [Tooltip("EncounterGroup 내부 적의 최소 초기 간격 (유닛)"), Min(0f)] public float EncounterGroupMinSpacing = 1.5f; + [Tooltip("EncounterGroup 내부 적의 최대 초기 간격 (유닛)"), Min(0f)] public float EncounterGroupMaxSpacing = 2.5f; + [Tooltip("장애물 회피(Wall Slide) 원형 캐스트 반경"), Min(0.05f)] public float WallSlideCastRadius = 0.35f; + [Tooltip("장애물 회피(Wall Slide) 원형 캐스트 거리"), Min(0.05f)] public float WallSlideCastDistance = 0.4f; [Header("적과 플레이어 사이 거리")] [Tooltip("일반 적이 주는 플레이어 넉백 거리 (유닛)"), Min(0f)] public float NormalPlayerKnockbackDistance = 0.75f; diff --git a/Assets/_Project/Scripts/Constants/GameplayConstants.cs b/Assets/_Project/Scripts/Constants/GameplayConstants.cs index 578e22d..4acbf2c 100644 --- a/Assets/_Project/Scripts/Constants/GameplayConstants.cs +++ b/Assets/_Project/Scripts/Constants/GameplayConstants.cs @@ -15,6 +15,8 @@ namespace BumpCombat.Constants private static ArtifactConstants fallbackArtifacts; private static RunConstants fallbackRun; private static CombatFeedbackSettings fallbackCombatFeedback; + private static DefenseConstants fallbackDefense; + private static PassiveRelicConstants fallbackPassiveRelics; [Header("설정 분류")] [SerializeField, Tooltip("플레이어 기본 능력치, 피격, 가드 설정")] @@ -33,6 +35,10 @@ namespace BumpCombat.Constants private RunConstants run; [SerializeField, Tooltip("타격, 피격, 가드 효과의 표시 설정")] private CombatFeedbackSettings combatFeedback; + [SerializeField, Tooltip("역병형 방어전 테스트 모드 설정")] + private DefenseConstants defense; + [SerializeField, Tooltip("방어전 패시브 유물 효과 설정")] + private PassiveRelicConstants passiveRelics; public static GameplayConstants Current { @@ -60,6 +66,8 @@ namespace BumpCombat.Constants public RunConstants Run => Resolve(run, ref fallbackRun); public CombatFeedbackSettings CombatFeedback => Resolve(combatFeedback, ref fallbackCombatFeedback); + public DefenseConstants Defense => Resolve(defense, ref fallbackDefense); + public PassiveRelicConstants PassiveRelics => Resolve(passiveRelics, ref fallbackPassiveRelics); #if UNITY_EDITOR public static void SetCurrentForTests(GameplayConstants value) @@ -78,6 +86,8 @@ namespace BumpCombat.Constants fallbackArtifacts = null; fallbackRun = null; fallbackCombatFeedback = null; + fallbackDefense = null; + fallbackPassiveRelics = null; } #endif diff --git a/Assets/_Project/Scripts/Constants/PassiveRelicConstants.cs b/Assets/_Project/Scripts/Constants/PassiveRelicConstants.cs new file mode 100644 index 0000000..076f97a --- /dev/null +++ b/Assets/_Project/Scripts/Constants/PassiveRelicConstants.cs @@ -0,0 +1,21 @@ +using UnityEngine; + +namespace BumpCombat.Constants +{ + [CreateAssetMenu(menuName = "BumpCombat/Constants/Passive Relics")] + public sealed class PassiveRelicConstants : ScriptableObject + { + [Header("Twin Horn Talisman")] + [Range(0f, 1f)] public float TwinHornDoubleDamageChance = 0.15f; + + [Header("Resonance Core")] + [Range(0f, 1f)] public float ResonanceEchoChance = 0.08f; + [Min(0f)] public float ResonanceEchoCooldownSeconds = 3f; + [Range(0f, 1f)] public float ResonanceEchoDamageMultiplier = 0.5f; + + [Header("Rupture Seed")] + [Range(0f, 1f)] public float RuptureSeedChance = 0.25f; + [Range(0f, 1f)] public float RuptureExplosionMaxHealthFraction = 0.1f; + [Min(0f)] public float RuptureExplosionPlayerBodyDiameters = 3f; + } +} diff --git a/Assets/_Project/Scripts/Constants/PassiveRelicConstants.cs.meta b/Assets/_Project/Scripts/Constants/PassiveRelicConstants.cs.meta new file mode 100644 index 0000000..0d62003 --- /dev/null +++ b/Assets/_Project/Scripts/Constants/PassiveRelicConstants.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: f0f9bca102564c4395943d6b39c6d9f0 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/_Project/Scripts/Constants/PlayerConstants.cs b/Assets/_Project/Scripts/Constants/PlayerConstants.cs index 025eb85..b1cdf49 100644 --- a/Assets/_Project/Scripts/Constants/PlayerConstants.cs +++ b/Assets/_Project/Scripts/Constants/PlayerConstants.cs @@ -6,7 +6,7 @@ namespace BumpCombat.Constants public sealed class PlayerConstants : ScriptableObject { [Header("기본 능력치")] - [Tooltip("플레이어 기본 이동 속도 (유닛/초)"), Min(0f)] public float BaseMoveSpeed = 3f; + [Tooltip("플레이어 기본 이동 속도 (유닛/초)"), Min(0f)] public float BaseMoveSpeed = 3.75f; [Tooltip("플레이어 기본 최대 체력"), Min(1f)] public float BaseMaxHealth = 100f; [Header("피격")] diff --git a/Assets/_Project/Scripts/Constants/RunConstants.cs b/Assets/_Project/Scripts/Constants/RunConstants.cs index a2efbd6..6c7ace2 100644 --- a/Assets/_Project/Scripts/Constants/RunConstants.cs +++ b/Assets/_Project/Scripts/Constants/RunConstants.cs @@ -5,6 +5,13 @@ using UnityEngine; namespace BumpCombat.Constants { + public enum RoomScaleComparisonMode + { + Disabled = 0, + Original = 1, + Expanded = 2, + } + [Serializable] public struct SpawnDensityPhase { @@ -123,9 +130,25 @@ namespace BumpCombat.Constants public Vector2 ArenaPlayableHalfExtents = new(15f, 8.4375f); public Vector2 ArenaPlayerPadding = new(0.5f, 0.625f); public Vector2 ArenaCameraViewHalfExtents = new(10f, 5.625f); + [Tooltip("스테이지 방에서 사용할 플레이어 추적 시야의 반쪽 크기")] + public Vector2 StageCameraViewHalfExtents = new(7.5f, 4.21875f); + [Tooltip("대표 Stage 1 비교 방에서 Original 선택 시 사용할 좁은 추적 시야의 반쪽 크기")] + public Vector2 StageComparisonOriginalCameraViewHalfExtents = new(5f, 2.8125f); + [Tooltip("대표 Stage 1 비교 방에서 Expanded 선택 시 사용할 추적 시야의 반쪽 크기")] + public Vector2 StageComparisonCameraViewHalfExtents = new(7.5f, 4.21875f); public Vector2 ArenaCameraDeadZone = new(0.5f, 0.3f); [Min(0f)] public float ArenaCameraSmoothTime = 0.15f; + [Header("대표 Stage 1 방 크기 비교")] + [Tooltip("Disabled는 기존 카탈로그 선택을 유지하고, Original/Expanded는 입구 직결 첫 전투 방을 S1-A로 고정합니다.")] + public RoomScaleComparisonMode Stage1FirstCombatRoomScaleComparison = RoomScaleComparisonMode.Disabled; + + [Header("제한 시야 교전")] + [Min(0.1f)] public float StagePlayerVisibilityRadius = 6f; + [Range(0f, 1f)] public float StageExploredFogOpacity = 0.65f; + [Range(0f, 1f)] public float StageUnseenFogOpacity = 1f; + [Range(0.1f, 1f)] public float StageEncounterSightRangeMultiplier = 0.5f; + public float[] EliteTimes(bool development, bool production) { float[] times = production diff --git a/Assets/_Project/Scripts/Core/ArenaBounds.cs b/Assets/_Project/Scripts/Core/ArenaBounds.cs index f259cab..1d6e166 100644 --- a/Assets/_Project/Scripts/Core/ArenaBounds.cs +++ b/Assets/_Project/Scripts/Core/ArenaBounds.cs @@ -12,8 +12,12 @@ namespace BumpCombat.Core { public static ArenaBounds Instance { get; private set; } - public Vector2 BackgroundHalfExtents => GameplayConstants.Current.Run.ArenaBackgroundHalfExtents; - public Vector2 PlayableHalfExtents => GameplayConstants.Current.Run.ArenaPlayableHalfExtents; + private Vector2 customCenter = Vector2.zero; + private Vector2? customPlayableHalfExtents; + + public Vector2 ZoneCenter => customCenter; + public Vector2 BackgroundHalfExtents => customPlayableHalfExtents ?? GameplayConstants.Current.Run.ArenaBackgroundHalfExtents; + public Vector2 PlayableHalfExtents => customPlayableHalfExtents ?? GameplayConstants.Current.Run.ArenaPlayableHalfExtents; public Vector2 PlayerPadding => GameplayConstants.Current.Run.ArenaPlayerPadding; public Vector2 CameraViewHalfExtents => GameplayConstants.Current.Run.ArenaCameraViewHalfExtents; public Vector2 PlayerClampHalfExtents => new( @@ -28,6 +32,18 @@ namespace BumpCombat.Core Mathf.Max(0f, BackgroundHalfExtents.x - CameraViewHalfExtents.x), Mathf.Max(0f, BackgroundHalfExtents.y - CameraViewHalfExtents.y)); + public void SetZoneBounds(Vector2 center, Vector2? halfExtents = null) + { + customCenter = center; + customPlayableHalfExtents = halfExtents; + } + + public void ResetToDefaultBounds() + { + customCenter = Vector2.zero; + customPlayableHalfExtents = null; + } + private void Awake() { Instance = this; @@ -50,13 +66,13 @@ namespace BumpCombat.Core public Vector2 ClampPlayerPosition(Vector2 position) { - return ClampToHalfExtents(position, PlayerClampHalfExtents); + return ClampToHalfExtents(position, PlayerClampHalfExtents, customCenter); } public Vector2 ClampEnemyPosition(Vector2 position, float edgeInset = 0f) { Vector2 extents = GetReachableHalfExtents(edgeInset); - return ClampToHalfExtents(position, extents); + return ClampToHalfExtents(position, extents, customCenter); } public Vector2 ClampEnemyPosition( @@ -73,7 +89,7 @@ namespace BumpCombat.Core // player. The previous collider-aware clamp introduced a second, // scale-dependent boundary that was especially visible on elites // and bosses at the edge of the map. - return ClampToHalfExtents(position, GetReachableHalfExtents(edgeInset)); + return ClampToHalfExtents(position, GetReachableHalfExtents(edgeInset), customCenter); } public Vector2 GetReachableHalfExtents(float edgeInset = 0f) @@ -86,13 +102,13 @@ namespace BumpCombat.Core public bool IsInsideReachableArena(Vector2 position, float inset = 0f) { Vector2 extents = GetReachableHalfExtents(inset); - return Mathf.Abs(position.x) <= extents.x + 0.0001f - && Mathf.Abs(position.y) <= extents.y + 0.0001f; + return Mathf.Abs(position.x - customCenter.x) <= extents.x + 0.0001f + && Mathf.Abs(position.y - customCenter.y) <= extents.y + 0.0001f; } public Vector2 ClampCameraCenter(Vector2 position) { - return ClampToHalfExtents(position, CameraCenterHalfExtents); + return ClampToHalfExtents(position, CameraCenterHalfExtents, customCenter); } public Vector2 ClampCameraCenter( @@ -102,7 +118,7 @@ namespace BumpCombat.Core Vector2 centerHalfExtents = new( Mathf.Max(0f, BackgroundHalfExtents.x - Mathf.Abs(viewHalfExtents.x)), Mathf.Max(0f, BackgroundHalfExtents.y - Mathf.Abs(viewHalfExtents.y))); - return ClampToHalfExtents(position, centerHalfExtents); + return ClampToHalfExtents(position, centerHalfExtents, customCenter); } public Vector2 GetCameraVisibleHalfExtents(Camera camera) @@ -143,10 +159,11 @@ namespace BumpCombat.Core private static Vector2 ClampToHalfExtents( Vector2 position, - Vector2 halfExtents) + Vector2 halfExtents, + Vector2 center) { - position.x = Mathf.Clamp(position.x, -halfExtents.x, halfExtents.x); - position.y = Mathf.Clamp(position.y, -halfExtents.y, halfExtents.y); + position.x = Mathf.Clamp(position.x, center.x - halfExtents.x, center.x + halfExtents.x); + position.y = Mathf.Clamp(position.y, center.y - halfExtents.y, center.y + halfExtents.y); return position; } } diff --git a/Assets/_Project/Scripts/Core/ArenaCameraFollow.cs b/Assets/_Project/Scripts/Core/ArenaCameraFollow.cs index f1f04b4..6bf3e69 100644 --- a/Assets/_Project/Scripts/Core/ArenaCameraFollow.cs +++ b/Assets/_Project/Scripts/Core/ArenaCameraFollow.cs @@ -1,5 +1,7 @@ +using System.Reflection; using BumpCombat.Constants; using BumpCombat.Player; +using BumpCombat.Stage; using UnityEngine; using UnityEngine.Rendering; @@ -16,11 +18,41 @@ namespace BumpCombat.Core private Vector2 externalShakeOffset; private bool initialized; private RunManager runManager; + private Component pixelPerfectCamera; + private PropertyInfo pixelPerfectAssetsPpu; + private PropertyInfo pixelPerfectReferenceWidth; + private PropertyInfo pixelPerfectReferenceHeight; + + private const int PixelPerfectAssetsPpu = 32; public Vector2 CurrentCenter => currentCenter; public Vector2 DeadZone => GameplayConstants.Current.Run.ArenaCameraDeadZone; public float SmoothTime => GameplayConstants.Current.Run.ArenaCameraSmoothTime; public Vector2 ExternalShakeOffset => externalShakeOffset; + public Camera WorldCamera => worldCamera != null ? worldCamera : (worldCamera = GetComponent()); + public float OrthographicSize => WorldCamera != null ? WorldCamera.orthographicSize : 5.625f; + + public void SetOrthographicSize(float size) + { + ApplyPixelPerfectReference( + IsStageViewportActive(), + GetCurrentStagePreset(), + IsOriginalComparisonCurrentZone()); + if (WorldCamera != null) + { + WorldCamera.orthographicSize = size; + } + } + + public static float CalculateTargetOrthographicSize( + Camera cam, + Vector2 playableHalfExtents, + RoomPreset roomPreset = null, + bool originalComparison = false) + { + Vector2 stageView = GetStageView(roomPreset, originalComparison); + return stageView.y > 0.01f ? stageView.y : 2.8125f; + } private void Awake() { @@ -85,6 +117,23 @@ namespace BumpCombat.Core return; } + bool stageViewport = Stage.StageManager.Instance != null + && Stage.StageManager.Instance.CurrentZone != null; + RoomPreset roomPreset = stageViewport + ? Stage.StageManager.Instance.CurrentZone.Preset + : null; + bool originalComparison = stageViewport + && Stage.StageManager.Instance.CurrentZone.IsStage1FirstCombatComparison; + ApplyPixelPerfectReference(stageViewport, roomPreset, originalComparison); + Vector2 stageView = GetStageView(roomPreset, originalComparison); + float configuredOrtho = stageViewport + ? stageView.y + : GameplayConstants.Current.Run.ArenaCameraViewHalfExtents.y; + if (configuredOrtho > 0.01f) + { + camera.orthographicSize = configuredOrtho; + } + int targetWidth = camera.targetTexture != null ? camera.targetTexture.width : Screen.width; @@ -95,23 +144,98 @@ namespace BumpCombat.Core int evenHeight = targetHeight & ~1; if (targetWidth < 2 || targetHeight < 2 - || (evenWidth == targetWidth && evenHeight == targetHeight)) + || evenWidth < 2 + || evenHeight < 2) { return; } + Rect desiredPixelRect = new(0f, 0f, evenWidth, evenHeight); Rect currentPixelRect = camera.pixelRect; - if (Mathf.Abs(currentPixelRect.x) < 0.01f - && Mathf.Abs(currentPixelRect.y) < 0.01f - && Mathf.Abs(currentPixelRect.width - evenWidth) < 0.01f - && Mathf.Abs(currentPixelRect.height - evenHeight) < 0.01f) + bool pixelRectChanged = Mathf.Abs(currentPixelRect.x - desiredPixelRect.x) > 0.01f + || Mathf.Abs(currentPixelRect.y - desiredPixelRect.y) > 0.01f + || Mathf.Abs(currentPixelRect.width - desiredPixelRect.width) > 0.01f + || Mathf.Abs(currentPixelRect.height - desiredPixelRect.height) > 0.01f; + if (pixelRectChanged) + { + camera.pixelRect = desiredPixelRect; + } + + camera.ResetAspect(); + camera.orthographicSize = configuredOrtho * evenHeight / targetHeight; + } + + private bool IsStageViewportActive() + { + return Stage.StageManager.Instance != null + && Stage.StageManager.Instance.CurrentZone != null; + } + + private void ApplyPixelPerfectReference( + bool stageViewport, + RoomPreset roomPreset = null, + bool originalComparison = false) + { + if (WorldCamera == null) { return; } - camera.pixelRect = new Rect(0f, 0f, evenWidth, evenHeight); - camera.ResetAspect(); - camera.orthographicSize *= evenHeight / (float)targetHeight; + ResolvePixelPerfectCamera(); + if (pixelPerfectCamera == null) + { + return; + } + + Vector2 referenceView = stageViewport + ? GetStageView(roomPreset, originalComparison) + : GameplayConstants.Current.Run.ArenaCameraViewHalfExtents; + int referenceWidth = Mathf.Max( + 1, + Mathf.RoundToInt(Mathf.Abs(referenceView.x) * 2f * PixelPerfectAssetsPpu)); + int referenceHeight = Mathf.Max( + 1, + Mathf.RoundToInt(Mathf.Abs(referenceView.y) * 2f * PixelPerfectAssetsPpu)); + + SetPixelPerfectIntProperty(pixelPerfectAssetsPpu, PixelPerfectAssetsPpu); + SetPixelPerfectIntProperty(pixelPerfectReferenceWidth, referenceWidth); + SetPixelPerfectIntProperty(pixelPerfectReferenceHeight, referenceHeight); + } + + private void ResolvePixelPerfectCamera() + { + if (pixelPerfectCamera != null) + { + return; + } + + pixelPerfectCamera = WorldCamera.GetComponent( + "UnityEngine.Rendering.Universal.PixelPerfectCamera"); + if (pixelPerfectCamera == null) + { + return; + } + + System.Type componentType = pixelPerfectCamera.GetType(); + pixelPerfectAssetsPpu = componentType.GetProperty("assetsPPU"); + pixelPerfectReferenceWidth = componentType.GetProperty("refResolutionX"); + pixelPerfectReferenceHeight = componentType.GetProperty("refResolutionY"); + } + + private void SetPixelPerfectIntProperty(PropertyInfo property, int value) + { + if (property == null || !property.CanWrite) + { + return; + } + + object currentValue = property.GetValue(pixelPerfectCamera); + if (currentValue is int currentInt && currentInt == value) + { + return; + } + + property.SetValue(pixelPerfectCamera, value); } private void LateUpdate() @@ -123,6 +247,12 @@ namespace BumpCombat.Core ResolveBounds(); ResolveTarget(); + if (Stage.StageManager.Instance != null && Stage.StageManager.Instance.IsTransitioning) + { + ApplyCameraPosition(); + return; + } + if (RunManager.GameplayInputEnabled && target != null) { Vector2 desiredCenter = GetDeadZoneTargetCenter( @@ -244,6 +374,39 @@ namespace BumpCombat.Core } } + private RoomPreset GetCurrentStagePreset() + { + return Stage.StageManager.Instance != null + && Stage.StageManager.Instance.CurrentZone != null + ? Stage.StageManager.Instance.CurrentZone.Preset + : null; + } + + private bool IsOriginalComparisonCurrentZone() + { + return Stage.StageManager.Instance != null + && Stage.StageManager.Instance.CurrentZone != null + && Stage.StageManager.Instance.CurrentZone.IsStage1FirstCombatComparison + && GameplayConstants.Current.Run.Stage1FirstCombatRoomScaleComparison + == RoomScaleComparisonMode.Original; + } + + private static Vector2 GetStageView(RoomPreset roomPreset, bool originalComparison) + { + RunConstants run = GameplayConstants.Current.Run; + if (LevelPresetCatalog.IsExpandedComparisonPreset(roomPreset)) + { + return run.StageComparisonCameraViewHalfExtents; + } + + if (originalComparison) + { + return run.StageComparisonOriginalCameraViewHalfExtents; + } + + return run.StageCameraViewHalfExtents; + } + private void ApplyCameraPosition() { Vector2 finalCenter = currentCenter + externalShakeOffset; diff --git a/Assets/_Project/Scripts/Core/RunManager.cs b/Assets/_Project/Scripts/Core/RunManager.cs index 05ce5ae..655110a 100644 --- a/Assets/_Project/Scripts/Core/RunManager.cs +++ b/Assets/_Project/Scripts/Core/RunManager.cs @@ -1,4 +1,5 @@ using System; +using System.Collections; using BumpCombat.Constants; using UnityEngine; using UnityEngine.InputSystem; @@ -17,6 +18,7 @@ namespace BumpCombat.Core { Development, Production, + DefensePrototype, } public sealed class RunManager : MonoBehaviour @@ -28,6 +30,9 @@ namespace BumpCombat.Core private bool guardUnlocked = true; private bool artifactChargeUnlocked = true; private bool gameOver; + private bool gameOverPresentation; + private bool runCleared; + private int currentSeed = -1; private bool titleOpen; private bool pauseOpen; private bool selectionOpen; @@ -42,6 +47,8 @@ namespace BumpCombat.Core public static RunManager Instance { get; private set; } public static bool GameplayInputEnabled => Instance == null || (!Instance.gameOver + && !Instance.gameOverPresentation + && !Instance.runCleared && !Instance.titleOpen && !Instance.pauseOpen && !Instance.selectionOpen); @@ -55,11 +62,16 @@ namespace BumpCombat.Core public event Action OnArtifactChargeAvailabilityChanged; public event Action OnPauseChanged; public event Action OnGameOver; + public event Action OnRunCleared; public float ElapsedTime { get; private set; } public float ProgressionTime => GetProgressionTime(ElapsedTime, UseDebugEventTimes); public bool IsGameOver => gameOver; + public bool IsGameOverPresentationActive => gameOverPresentation; + public bool IsRunCleared => runCleared; + public int CurrentSeed => currentSeed > 0 ? currentSeed : 12345; + public void SetRunSeed(int seed) => currentSeed = seed; public bool IsTitleScreen => titleOpen; public bool IsPaused => pauseOpen; public bool IsSelectionOpen => selectionOpen; @@ -70,13 +82,15 @@ namespace BumpCombat.Core && selectedRunMode == RunMode.Production; public bool IsDevelopmentRun => hasSelectedRunMode && selectedRunMode == RunMode.Development; + public bool IsDefensePrototypeRun => hasSelectedRunMode + && selectedRunMode == RunMode.DefensePrototype; public bool IsGuardUnlocked => !IsProductionRun || guardUnlocked; public bool IsArtifactChargeUnlocked => !IsProductionRun || artifactChargeUnlocked; public int StartupArtifactSelectionCount => hasSelectedRunMode - ? selectedRunMode == RunMode.Development - ? GameplayConstants.Current.Run.DevelopmentStartupArtifactSelectionCount - : GameplayConstants.Current.Run.ProductionStartupArtifactSelectionCount + ? selectedRunMode == RunMode.Production + ? GameplayConstants.Current.Run.ProductionStartupArtifactSelectionCount + : GameplayConstants.Current.Run.DevelopmentStartupArtifactSelectionCount : GameplayConstants.Current.Run.LegacyStartupArtifactSelectionCount; public int RaisedEliteEventCount => nextEliteEventIndex; public bool UseDebugArtifactSelection @@ -131,6 +145,7 @@ namespace BumpCombat.Core if (FindAnyObjectByType() != null) { EnsurePresentationServices(); + EnsureDefensePrototypeController(); return; } @@ -140,6 +155,15 @@ namespace BumpCombat.Core canvas.gameObject.AddComponent(); } EnsurePresentationServices(); + EnsureDefensePrototypeController(); + } + + private void EnsureDefensePrototypeController() + { + if (GetComponent() == null) + { + gameObject.AddComponent(); + } } private void EnsurePresentationServices() @@ -170,6 +194,19 @@ namespace BumpCombat.Core { canvas.gameObject.AddComponent(); } + if (canvas.GetComponent() == null) + { + canvas.gameObject.AddComponent(); + } + if (canvas.GetComponent() == null) + { + canvas.gameObject.AddComponent(); + } + + if (FindAnyObjectByType() == null) + { + gameObject.AddComponent(); + } } private void OnDestroy() @@ -182,6 +219,11 @@ namespace BumpCombat.Core private void Update() { + if (gameOverPresentation) + { + return; + } + if (gameOver) { if (Keyboard.current?.rKey.wasPressedThisFrame == true) @@ -192,6 +234,11 @@ namespace BumpCombat.Core return; } + if (runCleared) + { + return; + } + if (selectionOpen) { return; @@ -222,7 +269,13 @@ namespace BumpCombat.Core public bool TryOpenSelection(MonoBehaviour owner) { - if (owner == null || titleOpen || selectionOpen || gameOver || pauseOpen) + if (owner == null + || titleOpen + || selectionOpen + || gameOver + || gameOverPresentation + || runCleared + || pauseOpen) { return false; } @@ -308,6 +361,8 @@ namespace BumpCombat.Core public bool TryOpenPause() { if (gameOver + || gameOverPresentation + || runCleared || titleOpen || selectionOpen || pauseOpen @@ -365,21 +420,69 @@ namespace BumpCombat.Core return; } + gameOverPresentation = false; gameOver = true; pauseOpen = false; RefreshTimeScale(); OnGameOver?.Invoke(); } + public void BeginGameOverPresentation(float duration) + { + if (gameOver || runCleared || gameOverPresentation) + { + return; + } + + gameOverPresentation = true; + pauseOpen = false; + StartCoroutine(CompleteGameOverPresentation(Mathf.Max(0f, duration))); + } + + private IEnumerator CompleteGameOverPresentation(float duration) + { + if (duration > 0f) + { + yield return new WaitForSeconds(duration); + } + + if (!gameOverPresentation) + { + yield break; + } + + gameOverPresentation = false; + EndRun(); + } + + public void EndRunAsClear() + { + if (gameOver || gameOverPresentation || runCleared) + { + return; + } + + runCleared = true; + pauseOpen = false; + RefreshTimeScale(); + OnRunCleared?.Invoke(); + } + private void RefreshTimeScale() { - Time.timeScale = gameOver || titleOpen || pauseOpen || selectionOpen + Time.timeScale = gameOver || runCleared || titleOpen || pauseOpen || selectionOpen ? 0f : 1f; } private void RaiseTimedEvents() { + if (BumpCombat.Stage.StageManager.IsStageModeActive + || IsDefensePrototypeRun) + { + return; + } + float[] eliteTimes = CurrentEliteEventTimes; float midBossTime = GetEventTimeForCurrentRun(RunTimedEvent.MidBoss); float finalBossTime = GetEventTimeForCurrentRun(RunTimedEvent.FinalBoss); diff --git a/Assets/_Project/Scripts/Core/YSortRenderer.cs b/Assets/_Project/Scripts/Core/YSortRenderer.cs index e6a7df1..6832293 100644 --- a/Assets/_Project/Scripts/Core/YSortRenderer.cs +++ b/Assets/_Project/Scripts/Core/YSortRenderer.cs @@ -22,7 +22,13 @@ namespace BumpCombat.Core public int CalculateSortingOrder() { - return baseOrder - Mathf.RoundToInt(transform.position.y * precision); + float y = transform.position.y; + ArenaBounds bounds = ArenaBounds.Resolve(); + if (bounds != null) + { + y -= bounds.ZoneCenter.y; + } + return baseOrder - Mathf.RoundToInt(y * precision); } } } diff --git a/Assets/_Project/Scripts/Defense.meta b/Assets/_Project/Scripts/Defense.meta new file mode 100644 index 0000000..44122ae --- /dev/null +++ b/Assets/_Project/Scripts/Defense.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: b21501b83f844601916bda51aa90fef4 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/_Project/Scripts/Defense/DefensePrototypeController.cs b/Assets/_Project/Scripts/Defense/DefensePrototypeController.cs new file mode 100644 index 0000000..896d52a --- /dev/null +++ b/Assets/_Project/Scripts/Defense/DefensePrototypeController.cs @@ -0,0 +1,828 @@ +using System; +using System.Collections.Generic; +using BumpCombat.Constants; +using BumpCombat.Core; +using BumpCombat.Progression; +using BumpCombat.Enemies; +using BumpCombat.Player; +using BumpCombat.Spawning; +using UnityEngine; +using UnityEngine.InputSystem; +using UnityEngine.UI; + +namespace BumpCombat.Defense +{ + [DisallowMultipleComponent] + public sealed class DefensePrototypeController : MonoBehaviour + { + private const int LaneCount = 2; + + private static Sprite whiteSprite; + + private readonly Dictionary activeAgents = new(); + private readonly DefenseTower[] towers = new DefenseTower[LaneCount]; + private readonly bool[] padsBuilt = new bool[LaneCount]; + private readonly int[] suppliedPerLane = new int[LaneCount]; + private readonly DefenseWaveLedger[] portalLedgers = new DefenseWaveLedger[LaneCount]; + private readonly bool[] portalSupplyEnded = new bool[LaneCount]; + private readonly bool[] portalRewardVisible = new bool[LaneCount]; + private readonly bool[] portalRewardObjectCreated = new bool[LaneCount]; + private readonly GameObject[] portalRewardObjects = new GameObject[LaneCount]; + private readonly GameObject[] portalRewardLabels = new GameObject[LaneCount]; + private int baseHealth; + private float nextSupplyTime; + private int nextSupplyLane; + private bool active; + private bool playerDead; + private bool failed; + private int constructionCurrency; + private int pendingPassiveRelicChoices; + private float reviveRemaining; + private GameObject worldRoot; + private Text hudText; + private RunManager runManager; + private SpawnDirector spawnDirector; + private PlayerHealth playerHealth; + private PlayerController playerController; + private DefenseConstants tuning; + + public static DefensePrototypeController Instance { get; private set; } + public static Sprite WhiteSprite + { + get + { + if (whiteSprite == null) + { + whiteSprite = Sprite.Create( + Texture2D.whiteTexture, + new Rect(0f, 0f, 1f, 1f), + new Vector2(0.5f, 0.5f), + 1f); + whiteSprite.name = "Defense Prototype White Primitive"; + } + + return whiteSprite; + } + } + + public DefenseConstants Tuning => tuning ?? GameplayConstants.Current.Defense; + public bool IsActive => active; + public bool PlayerIsAlive => playerHealth != null && playerHealth.CurrentHealth > 0f; + public bool CanOperateTowers => DefensePrototypeRules.CanOperateTowers( + PlayerIsAlive, + active && !failed && runManager != null + && !runManager.IsGameOver + && !runManager.IsRunCleared + && !runManager.IsPaused + && !runManager.IsSelectionOpen + && !runManager.IsTitleScreen); + public int ConstructionCurrency => constructionCurrency; + public int GetBaseHealth(int lane) => lane >= 0 && lane < LaneCount ? baseHealth : 0; + public int GetPortalRemainingSupply(int lane) + { + if (tuning == null || lane < 0 || lane >= LaneCount) + { + return 0; + } + + return Mathf.Max( + 0, + tuning.PortalEnemyCount - (portalLedgers[lane]?.SuppliedCount ?? 0)); + } + + public int RemainingPortalSupply + { + get + { + if (tuning == null) + { + return 0; + } + + int remaining = 0; + for (int lane = 0; lane < LaneCount; lane++) + { + remaining += Mathf.Max( + 0, + tuning.PortalEnemyCount - (portalLedgers[lane]?.SuppliedCount ?? 0)); + } + + return remaining; + } + } + public bool IsRewardVisible + { + get + { + for (int lane = 0; lane < LaneCount; lane++) + { + if (portalRewardVisible[lane]) + { + return true; + } + } + + return false; + } + } + public float PlayerReviveRemaining => playerDead ? reviveRemaining : 0f; + + private void Awake() + { + if (Instance != null && Instance != this) + { + Destroy(this); + return; + } + + Instance = this; + } + + private void Start() + { + runManager = RunManager.Instance; + if (runManager != null) + { + runManager.OnRunStarted += HandleRunStarted; + runManager.OnGameOver += HandleRunEnded; + runManager.OnRunCleared += HandleRunEnded; + } + + if (runManager != null + && runManager.IsDefensePrototypeRun + && !runManager.IsTitleScreen) + { + Activate(); + } + } + + private void OnDestroy() + { + if (runManager != null) + { + runManager.OnRunStarted -= HandleRunStarted; + runManager.OnGameOver -= HandleRunEnded; + runManager.OnRunCleared -= HandleRunEnded; + } + + foreach (KeyValuePair pair in activeAgents) + { + if (pair.Key != null) + { + pair.Key.OnDied -= HandleEnemyDied; + } + } + + if (Instance == this) + { + Instance = null; + } + } + + private void Update() + { + if (!active || runManager == null || failed || runManager.IsGameOver || runManager.IsRunCleared) + { + return; + } + + if (runManager.IsPaused || runManager.IsSelectionOpen || runManager.IsTitleScreen) + { + UpdateHud(); + return; + } + + UpdatePlayerRecovery(); + UpdatePortalSupply(); + TryOfferRewards(); + TryCollectRewards(); + UpdateHud(); + + if (Keyboard.current != null && CanOperateTowers) + { + if (Keyboard.current.digit1Key.wasPressedThisFrame + || Keyboard.current.numpad1Key.wasPressedThisFrame) + { + TryBuildNearestTower(DefenseTowerKind.Direct); + } + else if (Keyboard.current.digit2Key.wasPressedThisFrame + || Keyboard.current.numpad2Key.wasPressedThisFrame) + { + TryBuildNearestTower(DefenseTowerKind.Summon); + } + } + } + + public void Activate() + { + if (active || runManager == null || !runManager.IsDefensePrototypeRun) + { + return; + } + + tuning = GameplayConstants.Current.Defense; + spawnDirector = FindAnyObjectByType(); + playerHealth = FindAnyObjectByType(); + playerController = FindAnyObjectByType(); + if (playerController != null) + { + PassiveRelicController relics = playerController.GetComponent(); + if (relics == null) + { + relics = playerController.gameObject.AddComponent(); + } + + relics.BeginDefenseRun(); + } + ArenaBounds bounds = ArenaBounds.Resolve(); + bounds?.SetZoneBounds(tuning.ArenaCenter, tuning.ArenaPlayableHalfExtents); + FindAnyObjectByType()?.ResetCenter(tuning.ArenaCenter); + active = true; + failed = false; + playerDead = false; + constructionCurrency = tuning.InitialConstructionCurrency; + pendingPassiveRelicChoices = 0; + baseHealth = tuning.BaseMaximumHealth; + activeAgents.Clear(); + for (int lane = 0; lane < LaneCount; lane++) + { + suppliedPerLane[lane] = 0; + portalLedgers[lane] = new DefenseWaveLedger(tuning.PortalEnemyCount); + portalSupplyEnded[lane] = false; + portalRewardVisible[lane] = false; + portalRewardObjectCreated[lane] = false; + portalRewardObjects[lane] = null; + portalRewardLabels[lane] = null; + padsBuilt[lane] = false; + towers[lane] = null; + } + + nextSupplyLane = 0; + nextSupplyTime = Time.time + 0.5f; + CreateWorldVisuals(); + CreateHud(); + UpdateHud(); + } + + public bool TryBuildTowerAtPad(int padIndex) + { + return TryBuildTowerAtPad( + padIndex, + padIndex == 0 ? DefenseTowerKind.Direct : DefenseTowerKind.Summon); + } + + public bool TryBuildTowerAtPad(int padIndex, DefenseTowerKind kind) + { + if (!CanOperateTowers + || padIndex < 0 + || padIndex >= LaneCount + || padsBuilt[padIndex] + || playerController == null) + { + return false; + } + + Vector2 position = tuning.GetBuildPadPosition(padIndex); + if (((Vector2)playerController.transform.position - position).sqrMagnitude + > tuning.BuildInteractionRadius * tuning.BuildInteractionRadius) + { + return false; + } + + int cost = kind == DefenseTowerKind.Direct + ? tuning.DirectTowerCost + : tuning.SummonTowerCost; + if (constructionCurrency < cost) + { + return false; + } + + GameObject towerObject = new( + kind == DefenseTowerKind.Direct ? "Direct Damage Tower" : "Summon Tower"); + towerObject.transform.SetParent(worldRoot.transform, false); + towerObject.transform.position = position; + DefenseTower tower = towerObject.AddComponent(); + tower.Configure(this, kind, padIndex, tuning); + towers[padIndex] = tower; + padsBuilt[padIndex] = true; + constructionCurrency -= cost; + return true; + } + + private bool TryBuildNearestTower(DefenseTowerKind kind) + { + if (playerController == null) + { + return false; + } + + int nearestPad = -1; + float nearestDistance = tuning.BuildInteractionRadius * tuning.BuildInteractionRadius; + for (int pad = 0; pad < LaneCount; pad++) + { + if (padsBuilt[pad]) + { + continue; + } + + float distance = ((Vector2)playerController.transform.position + - tuning.GetBuildPadPosition(pad)).sqrMagnitude; + if (distance <= nearestDistance) + { + nearestDistance = distance; + nearestPad = pad; + } + } + + return nearestPad >= 0 && TryBuildTowerAtPad(nearestPad, kind); + } + + public bool TryAttackFromTower(DefenseTower tower) + { + if (!CanOperateTowers || tower == null) + { + return false; + } + + DefenseRouteAgent target = FindNearestTarget( + tower.transform.position, + tuning.TowerRange, + tower.Kind); + return tower.TryAttack(target); + } + + public DefenseRouteAgent FindNearestTarget( + Vector2 sourcePosition, + float range, + DefenseTowerKind sourceKind) + { + DefenseRouteAgent nearest = null; + float nearestDistance = range * range; + foreach (DefenseRouteAgent agent in activeAgents.Values) + { + if (agent == null + || agent.HasArrived + || agent.Enemy == null + || agent.Enemy.IsDead + || DefensePrototypeRules.IsTowerImmune( + sourceKind, + agent.ImmuneTowerKind)) + { + continue; + } + + float distance = ((Vector2)agent.transform.position - sourcePosition).sqrMagnitude; + if (distance <= nearestDistance) + { + nearestDistance = distance; + nearest = agent; + } + } + + return nearest; + } + + public bool TryApplyTowerDamage( + DefenseTower tower, + DefenseRouteAgent target, + float damage) + { + if (!CanOperateTowers || tower == null || target == null || playerHealth == null) + { + return false; + } + + float distanceToPlayer = Vector2.Distance( + playerHealth.transform.position, + tower.transform.position); + return target.TryTakeTowerDamage( + tower.Kind, + damage, + distanceToPlayer, + tuning.PlayerSightRadius, + tuning.OutsideSightDamageEfficiency, + tower.transform.position); + } + + public bool HandlePlayerDied() + { + if (!active || failed || runManager == null || runManager.IsGameOver || runManager.IsRunCleared) + { + return false; + } + + playerDead = true; + reviveRemaining = tuning.PlayerReviveDelaySeconds; + playerController?.SetTransitionLocked(true); + return true; + } + + private void HandleRunStarted() + { + if (runManager != null && runManager.IsDefensePrototypeRun) + { + Activate(); + } + } + + private void HandleRunEnded() + { + failed = true; + playerController?.GetComponent()?.EndDefenseRun(); + } + + private void UpdatePlayerRecovery() + { + if (!playerDead) + { + return; + } + + reviveRemaining = Mathf.Max(0f, reviveRemaining - Time.deltaTime); + if (reviveRemaining > 0f || !PlayerCanBeRevived()) + { + return; + } + + playerDead = false; + playerHealth?.ReviveForDefense(tuning.PlayerReviveInvulnerabilitySeconds); + } + + private bool PlayerCanBeRevived() + { + return !DefensePrototypeRules.IsBaseDestroyed(baseHealth); + } + + private void UpdatePortalSupply() + { + if (spawnDirector == null || Time.time < nextSupplyTime) + { + return; + } + + for (int attempt = 0; attempt < LaneCount; attempt++) + { + int lane = (nextSupplyLane + attempt) % LaneCount; + if (suppliedPerLane[lane] >= tuning.PortalEnemyCount) + { + continue; + } + + EnemyController enemy = spawnDirector.SpawnDefenseSkeleton( + tuning.GetPortalPosition(lane)); + if (enemy == null) + { + nextSupplyTime = Time.time + tuning.PortalSpawnInterval; + return; + } + + Vector2[] route = + { + tuning.GetPortalPosition(lane), + tuning.GetWaypointPosition(lane, 0), + tuning.GetWaypointPosition(lane, 1), + tuning.GetBasePosition(lane), + }; + DefenseRouteAgent agent = enemy.gameObject.AddComponent(); + agent.Configure( + enemy, + lane, + route, + enemy.MoveSpeed * tuning.RouteSpeedMultiplier, + Mathf.Max(0.1f, enemy.AttackRange + 0.35f), + tuning.PostAttackProgressSeconds, + tuning.GetPortalImmuneTowerKind(lane), + HandleEnemyArrived); + enemy.OnDied += HandleEnemyDied; + activeAgents.Add(enemy, agent); + portalLedgers[lane].RegisterSupply(enemy.SpawnLifetimeIdentity); + suppliedPerLane[lane]++; + if (suppliedPerLane[lane] >= tuning.PortalEnemyCount) + { + portalSupplyEnded[lane] = true; + portalLedgers[lane].MarkSupplyEnded(); + } + + nextSupplyLane = (lane + 1) % LaneCount; + nextSupplyTime = Time.time + tuning.PortalSpawnInterval; + return; + } + + for (int lane = 0; lane < LaneCount; lane++) + { + if (!portalSupplyEnded[lane] + && suppliedPerLane[lane] >= tuning.PortalEnemyCount) + { + portalSupplyEnded[lane] = true; + portalLedgers[lane].MarkSupplyEnded(); + } + } + } + private void HandleEnemyDied(EnemyController enemy) + { + if (enemy == null || !activeAgents.TryGetValue(enemy, out DefenseRouteAgent agent)) + { + return; + } + + enemy.OnDied -= HandleEnemyDied; + activeAgents.Remove(enemy); + int lane = Mathf.Clamp(agent.Lane, 0, LaneCount - 1); + portalLedgers[lane]?.TryResolve( + enemy.SpawnLifetimeIdentity, + DefenseEnemyResolution.Death); + } + + private void HandleEnemyArrived(DefenseRouteAgent agent) + { + if (agent == null + || agent.Enemy == null + || !activeAgents.ContainsKey(agent.Enemy)) + { + return; + } + + EnemyController enemy = agent.Enemy; + enemy.OnDied -= HandleEnemyDied; + activeAgents.Remove(enemy); + int lane = Mathf.Clamp(agent.Lane, 0, LaneCount - 1); + portalLedgers[lane]?.TryResolve( + enemy.SpawnLifetimeIdentity, + DefenseEnemyResolution.Arrival); + baseHealth = Mathf.Max(0, baseHealth - tuning.ArrivalBaseDamage); + enemy.RetireFromRun(includeProtected: true, suppressFeedback: true); + if (DefensePrototypeRules.IsBaseDestroyed(baseHealth)) + { + failed = true; + runManager.EndRun(); + } + } + + private void TryOfferRewards() + { + for (int lane = 0; lane < LaneCount; lane++) + { + DefenseWaveLedger ledger = portalLedgers[lane]; + if (!portalSupplyEnded[lane] + || ledger == null + || portalRewardVisible[lane] + || CountActiveAgentsForLane(lane) > 0 + || !ledger.TryOfferReward()) + { + continue; + } + + portalRewardVisible[lane] = true; + if (!portalRewardObjectCreated[lane]) + { + portalRewardObjectCreated[lane] = true; + Vector2 rewardPosition = tuning.GetPortalPosition(lane) + Vector2.up * 1.2f; + portalRewardObjects[lane] = CreateShape( + $"Construction Reward {lane + 1}", + rewardPosition, + new Vector2(0.85f, 0.85f), + new Color32(255, 224, 84, 255), + 7); + portalRewardLabels[lane] = CreateWorldLabel( + $"Reward Label {lane + 1}", + rewardPosition + Vector2.up * 0.65f, + $"+{tuning.PortalRewardCurrency} BUILD"); + } + + portalRewardObjects[lane].SetActive(true); + } + } + + private void TryCollectRewards() + { + if (playerController == null || playerHealth == null || !PlayerIsAlive) + { + return; + } + + Vector2 playerPosition = playerController.transform.position; + for (int lane = 0; lane < LaneCount; lane++) + { + if (!portalRewardVisible[lane] + || portalRewardObjects[lane] == null + || Vector2.Distance( + playerPosition, + portalRewardObjects[lane].transform.position) + > tuning.RewardPickupRadius + || !portalLedgers[lane].TryCollectReward()) + { + continue; + } + + portalRewardVisible[lane] = false; + portalRewardObjects[lane].SetActive(false); + portalRewardLabels[lane]?.SetActive(false); + constructionCurrency += tuning.PortalRewardCurrency; + PassiveRelicController relics = playerController.GetComponent(); + ArtifactRewardController rewards = FindAnyObjectByType(); + if (relics != null && relics.AvailableCount > 0 && rewards != null) + { + pendingPassiveRelicChoices++; + rewards.QueuePassiveRelicReward(); + } + else if (relics == null || relics.AvailableCount == 0 || rewards == null) + { + constructionCurrency += tuning.PortalRewardCurrency; + } + } + + TryFinishClear(); + } + + public void ResolvePassiveRelicChoice() + { + pendingPassiveRelicChoices = Mathf.Max(0, pendingPassiveRelicChoices - 1); + TryFinishClear(); + } + + private void TryFinishClear() + { + if (failed || activeAgents.Count > 0 || pendingPassiveRelicChoices > 0 || runManager == null) + { + return; + } + + for (int lane = 0; lane < LaneCount; lane++) + { + if (!portalSupplyEnded[lane] + || portalLedgers[lane] == null + || !portalLedgers[lane].IsRewardCollected) + { + return; + } + } + + if (!DefensePrototypeRules.IsBaseDestroyed(baseHealth) + && !runManager.IsGameOver + && !runManager.IsRunCleared) + { + runManager.EndRunAsClear(); + } + } + + private int CountActiveAgentsForLane(int lane) + { + int count = 0; + foreach (DefenseRouteAgent agent in activeAgents.Values) + { + if (agent != null && agent.Lane == lane) + { + count++; + } + } + + return count; + } + private void CreateWorldVisuals() + { + if (worldRoot != null) + { + return; + } + + worldRoot = new GameObject("Defense Prototype World"); + worldRoot.transform.SetParent(transform, false); + for (int lane = 0; lane < LaneCount; lane++) + { + Vector2 portal = tuning.GetPortalPosition(lane); + Vector2 basePosition = tuning.GetBasePosition(lane); + CreateLaneLine(lane, portal, tuning.GetWaypointPosition(lane, 0)); + CreateLaneLine(lane, tuning.GetWaypointPosition(lane, 0), tuning.GetWaypointPosition(lane, 1)); + CreateLaneLine(lane, tuning.GetWaypointPosition(lane, 1), basePosition); + CreateShape( + $"Portal {lane + 1}", + portal, + new Vector2(0.85f, 1.25f), + new Color32(179, 84, 220, 255), + 5); + CreateWorldLabel($"Portal {lane + 1} Label", portal + Vector2.right * 1.6f + Vector2.up * 0.9f, $"P{lane + 1} PORTAL\nIMMUNE {tuning.GetPortalImmuneTowerKind(lane).ToString().ToUpperInvariant()}"); + if (lane == 0) + { + CreateShape( + "Shared Base", + basePosition, + new Vector2(1.15f, 2.1f), + new Color32(226, 78, 87, 255), + 5); + CreateWorldLabel("Shared Base Label", basePosition + Vector2.up * 1.4f, "BASE"); + } + } + + for (int pad = 0; pad < LaneCount; pad++) + { + Vector2 position = tuning.GetBuildPadPosition(pad); + CreateShape( + $"Build Pad {pad + 1}", + position, + new Vector2(1.35f, 1.35f), + new Color32(76, 104, 125, 255), + 3); + CreateWorldLabel( + $"Build Pad {pad + 1} Label", + position + Vector2.down * 0.95f, + "PAD " + (pad + 1)); + } + } + + private void CreateHud() + { + Canvas canvas = FindAnyObjectByType(); + if (canvas == null || hudText != null) + { + return; + } + + GameObject hudObject = new( + "Defense Prototype HUD", + typeof(RectTransform), + typeof(CanvasRenderer), + typeof(Text)); + hudObject.transform.SetParent(canvas.transform, false); + RectTransform rect = hudObject.GetComponent(); + rect.anchorMin = new Vector2(0f, 1f); + rect.anchorMax = new Vector2(0f, 1f); + rect.pivot = new Vector2(0f, 1f); + rect.anchoredPosition = new Vector2(28f, -452f); + rect.sizeDelta = new Vector2(900f, 110f); + hudText = hudObject.GetComponent(); + hudText.font = BumpCombat.UI.PresentationUiStyle.GetFont(); + hudText.fontSize = 24; + hudText.color = new Color32(236, 240, 246, 255); + hudText.alignment = TextAnchor.UpperLeft; + hudText.horizontalOverflow = HorizontalWrapMode.Overflow; + hudText.verticalOverflow = VerticalWrapMode.Overflow; + } + + private void UpdateHud() + { + if (hudText == null || tuning == null) + { + return; + } + + string status = playerDead + ? $"PLAYER DOWN revive {Mathf.CeilToInt(reviveRemaining)}s" + : IsRewardVisible ? "REWARD READY move to portal" : $"1 DIRECT {tuning.DirectTowerCost} / 2 SUMMON {tuning.SummonTowerCost} near a pad"; + 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" + + status; + } + + private void CreateLaneLine(int lane, Vector2 from, Vector2 to) + { + GameObject lineObject = new($"Lane {lane + 1} Segment"); + lineObject.transform.SetParent(worldRoot.transform, false); + LineRenderer line = lineObject.AddComponent(); + line.useWorldSpace = true; + line.positionCount = 2; + line.SetPosition(0, from); + line.SetPosition(1, to); + line.startWidth = 0.08f; + line.endWidth = 0.08f; + line.startColor = new Color32(108, 151, 177, 210); + line.endColor = new Color32(108, 151, 177, 210); + line.material = new Material(Shader.Find("Sprites/Default")); + line.sortingOrder = 1; + } + + private GameObject CreateShape( + string name, + Vector2 position, + Vector2 size, + Color color, + int sortingOrder) + { + GameObject shape = new(name); + shape.transform.SetParent(worldRoot.transform, false); + shape.transform.position = position; + shape.transform.localScale = new Vector3(size.x, size.y, 1f); + SpriteRenderer renderer = shape.AddComponent(); + renderer.sprite = WhiteSprite; + renderer.color = color; + renderer.sortingOrder = sortingOrder; + return shape; + } + + private GameObject CreateWorldLabel(string name, Vector2 position, string text) + { + GameObject labelObject = new(name); + labelObject.transform.SetParent(worldRoot.transform, false); + labelObject.transform.position = position; + TextMesh label = labelObject.AddComponent(); + label.text = text; + label.anchor = TextAnchor.MiddleCenter; + label.alignment = TextAlignment.Center; + label.characterSize = 0.08f; + label.fontSize = 32; + label.color = Color.white; + label.GetComponent().sortingOrder = 12; + return labelObject; + } + } +} diff --git a/Assets/_Project/Scripts/Defense/DefensePrototypeController.cs.meta b/Assets/_Project/Scripts/Defense/DefensePrototypeController.cs.meta new file mode 100644 index 0000000..88df6d3 --- /dev/null +++ b/Assets/_Project/Scripts/Defense/DefensePrototypeController.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: a39d0992fba741809dfd6e95bc9cfd83 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/_Project/Scripts/Defense/DefensePrototypeRules.cs b/Assets/_Project/Scripts/Defense/DefensePrototypeRules.cs new file mode 100644 index 0000000..592ce64 --- /dev/null +++ b/Assets/_Project/Scripts/Defense/DefensePrototypeRules.cs @@ -0,0 +1,126 @@ +using UnityEngine; + +namespace BumpCombat.Defense +{ + public enum DefenseTowerKind + { + Direct, + Summon, + } + + public enum DefenseEnemyResolution + { + Death, + Arrival, + } + + public static class DefensePrototypeRules + { + public static float GetTowerEfficiency( + float distanceToPlayer, + float sightRadius, + float insideEfficiency, + float outsideEfficiency) + { + return distanceToPlayer <= sightRadius + ? insideEfficiency + : outsideEfficiency; + } + + public static bool IsTowerImmune( + DefenseTowerKind sourceKind, + DefenseTowerKind immuneKind) + { + return sourceKind == immuneKind; + } + + public static bool CanOperateTowers(bool playerAlive, bool runActive) + { + return playerAlive && runActive; + } + + public static bool IsBaseDestroyed(float currentHealth) + { + return currentHealth <= 0f; + } + } + + public sealed class DefenseWaveLedger + { + private readonly int expectedSupplyCount; + private readonly System.Collections.Generic.HashSet suppliedIds = new(); + private readonly System.Collections.Generic.HashSet resolvedIds = new(); + private bool supplyEnded; + private bool rewardOffered; + private bool rewardCollected; + + public DefenseWaveLedger(int expectedSupplyCount) + { + this.expectedSupplyCount = Mathf.Max(0, expectedSupplyCount); + } + + public int SuppliedCount => suppliedIds.Count; + public int ResolvedCount => resolvedIds.Count; + public int KillEventCount { get; private set; } + public bool IsSupplyEnded => supplyEnded; + public bool IsRewardOffered => rewardOffered; + public bool IsRewardCollected => rewardCollected; + + public bool RegisterSupply(int enemyId) + { + if (supplyEnded + || SuppliedCount >= expectedSupplyCount + || enemyId < 0) + { + return false; + } + + return suppliedIds.Add(enemyId); + } + + public void MarkSupplyEnded() + { + supplyEnded = true; + } + + public bool TryResolve(int enemyId, DefenseEnemyResolution resolution) + { + if (!suppliedIds.Contains(enemyId) || !resolvedIds.Add(enemyId)) + { + return false; + } + + if (resolution == DefenseEnemyResolution.Death) + { + KillEventCount++; + } + + return true; + } + + public bool TryOfferReward() + { + if (rewardOffered + || !supplyEnded + || SuppliedCount < expectedSupplyCount + || ResolvedCount < SuppliedCount) + { + return false; + } + + rewardOffered = true; + return true; + } + + public bool TryCollectReward() + { + if (!rewardOffered || rewardCollected) + { + return false; + } + + rewardCollected = true; + return true; + } + } +} diff --git a/Assets/_Project/Scripts/Defense/DefensePrototypeRules.cs.meta b/Assets/_Project/Scripts/Defense/DefensePrototypeRules.cs.meta new file mode 100644 index 0000000..c701608 --- /dev/null +++ b/Assets/_Project/Scripts/Defense/DefensePrototypeRules.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 9be2c059f93b466e9735447cb1bd2146 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/_Project/Scripts/Defense/DefenseRouteAgent.cs b/Assets/_Project/Scripts/Defense/DefenseRouteAgent.cs new file mode 100644 index 0000000..6610c8c --- /dev/null +++ b/Assets/_Project/Scripts/Defense/DefenseRouteAgent.cs @@ -0,0 +1,196 @@ +using System; +using BumpCombat.Enemies; +using UnityEngine; + +namespace BumpCombat.Defense +{ + [DisallowMultipleComponent] + public sealed class DefenseRouteAgent : MonoBehaviour + { + private EnemyController enemy; + private Vector2[] waypoints = Array.Empty(); + private Action arrivalHandler; + private Transform player; + private BumpCombat.Player.PlayerHealth playerHealth; + private float routeSpeed; + private float playerEngagementRange; + private float postAttackProgressSeconds; + private float postAttackProgressRemaining; + private bool wasAttackSequenceInProgress; + private bool configured; + private int waypointIndex; + + public EnemyController Enemy => enemy; + public int Lane { get; private set; } + public int LifetimeId => enemy != null ? enemy.SpawnLifetimeIdentity : -1; + public DefenseTowerKind ImmuneTowerKind { get; private set; } + public Vector2 AnchorPosition => waypoints.Length > 0 ? waypoints[0] : transform.position; + public bool HasArrived { get; private set; } + + public void Configure( + EnemyController configuredEnemy, + int lane, + Vector2[] route, + float speed, + float engagementRange, + float postAttackProgress, + DefenseTowerKind immuneTowerKind, + Action onArrived) + { + enemy = configuredEnemy; + Lane = lane; + waypoints = route ?? Array.Empty(); + routeSpeed = Mathf.Max(0.1f, speed); + playerEngagementRange = Mathf.Max(0.1f, engagementRange); + postAttackProgressSeconds = Mathf.Max(0f, postAttackProgress); + postAttackProgressRemaining = 0f; + wasAttackSequenceInProgress = false; + ImmuneTowerKind = immuneTowerKind; + arrivalHandler = onArrived; + waypointIndex = 0; + configured = enemy != null && waypoints.Length > 0; + HasArrived = false; + player = FindAnyObjectByType()?.transform; + playerHealth = player != null + ? player.GetComponent() + : null; + if (configured) + { + enemy.TryReposition(waypoints[0]); + } + } + + public bool TryDriveFixedUpdate(float deltaTime) + { + if (!configured || HasArrived || enemy == null || enemy.IsDead) + { + return false; + } + + if (enemy.State == EnemyState.Spawn) + { + return false; + } + + bool attackSequenceActive = enemy.IsAttackSequenceInProgress; + if (attackSequenceActive) + { + wasAttackSequenceInProgress = true; + return false; + } + + if (wasAttackSequenceInProgress) + { + wasAttackSequenceInProgress = false; + postAttackProgressRemaining = postAttackProgressSeconds; + } + + if (enemy.IsStunned) + { + return false; + } + + postAttackProgressRemaining = Mathf.Max( + 0f, + postAttackProgressRemaining - Mathf.Max(0f, deltaTime)); + if (player == null) + { + player = FindAnyObjectByType()?.transform; + playerHealth = player != null + ? player.GetComponent() + : null; + } + + if (postAttackProgressRemaining <= 0f + && player != null + && playerHealth != null + && playerHealth.CurrentHealth > 0f + && (player.position - transform.position).sqrMagnitude + <= playerEngagementRange * playerEngagementRange) + { + enemy.Alert(); + return false; + } + + if (waypointIndex >= waypoints.Length) + { + CompleteArrival(); + return true; + } + + Vector2 current = transform.position; + Vector2 target = waypoints[waypointIndex]; + Vector2 next = Vector2.MoveTowards( + current, + target, + routeSpeed * Mathf.Max(0f, deltaTime)); + if (!enemy.TryReposition(next)) + { + return false; + } + + Vector2 movementDirection = next - current; + if (movementDirection.sqrMagnitude > 0.000001f) + { + enemy.SetFacingDirection(movementDirection); + } + Animator animator = enemy.GetComponent(); + animator?.SetBool("IsMoving", (next - current).sqrMagnitude > 0.000001f); + if ((target - next).sqrMagnitude <= 0.01f) + { + waypointIndex++; + if (waypointIndex >= waypoints.Length) + { + CompleteArrival(); + } + } + + return true; + } + + public bool TryTakeTowerDamage( + DefenseTowerKind sourceKind, + float requestedDamage, + float distanceToPlayer, + float sightRadius, + float outsideSightEfficiency, + Vector2 sourcePosition) + { + if (!configured + || HasArrived + || enemy == null + || enemy.IsDead + || DefensePrototypeRules.IsTowerImmune( + sourceKind, + ImmuneTowerKind)) + { + return false; + } + + float efficiency = DefensePrototypeRules.GetTowerEfficiency( + distanceToPlayer, + sightRadius, + 1f, + outsideSightEfficiency); + Vector2 direction = ((Vector2)transform.position - sourcePosition).normalized; + return enemy.TryTakeDamage( + Mathf.Max(0f, requestedDamage) * efficiency, + direction, + 0f, + false, + false, + EnemyDamageSource.Tower); + } + + private void CompleteArrival() + { + if (HasArrived) + { + return; + } + + HasArrived = true; + arrivalHandler?.Invoke(this); + } + } +} diff --git a/Assets/_Project/Scripts/Defense/DefenseRouteAgent.cs.meta b/Assets/_Project/Scripts/Defense/DefenseRouteAgent.cs.meta new file mode 100644 index 0000000..748b8bd --- /dev/null +++ b/Assets/_Project/Scripts/Defense/DefenseRouteAgent.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: be1e0aa426264604ac15478324ef43aa +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/_Project/Scripts/Defense/DefenseSummonUnit.cs b/Assets/_Project/Scripts/Defense/DefenseSummonUnit.cs new file mode 100644 index 0000000..0179cdf --- /dev/null +++ b/Assets/_Project/Scripts/Defense/DefenseSummonUnit.cs @@ -0,0 +1,79 @@ +using UnityEngine; + +namespace BumpCombat.Defense +{ + [DisallowMultipleComponent] + public sealed class DefenseSummonUnit : MonoBehaviour + { + private DefenseTower owner; + private DefensePrototypeController controller; + private Vector2 anchorLocalPosition; + private SpriteRenderer spriteRenderer; + private float attackCooldown; + + public void Configure( + DefenseTower tower, + DefensePrototypeController prototypeController, + Vector2 localAnchor) + { + owner = tower; + controller = prototypeController; + anchorLocalPosition = localAnchor; + spriteRenderer = gameObject.AddComponent(); + Sprite[] frames = Resources.LoadAll("Tutorial/Swordsman_Idle"); + if (frames != null && frames.Length > 0) + { + spriteRenderer.sprite = frames[0]; + } + spriteRenderer.color = new Color32(255, 234, 137, 255); + spriteRenderer.sortingOrder = 11; + transform.localScale = new Vector3(0.9f, 0.9f, 1f); + } + + private void Update() + { + if (owner == null + || !owner.isActiveAndEnabled + || controller == null + || !controller.CanOperateTowers) + { + return; + } + + attackCooldown = Mathf.Max(0f, attackCooldown - Time.deltaTime); + DefenseRouteAgent target = controller.FindNearestTarget( + transform.position, + controller.Tuning.SummonAreaRadius, + DefenseTowerKind.Summon); + Vector2 anchor = owner.transform.TransformPoint(anchorLocalPosition); + if (target == null) + { + transform.position = Vector2.MoveTowards( + transform.position, + anchor, + controller.Tuning.SummonUnitMoveSpeed * Time.deltaTime); + return; + } + + Vector2 targetPosition = target.transform.position; + Vector2 fromAnchor = targetPosition - anchor; + if (fromAnchor.magnitude > controller.Tuning.SummonAreaRadius) + { + targetPosition = anchor + fromAnchor.normalized * controller.Tuning.SummonAreaRadius; + } + + transform.position = Vector2.MoveTowards( + transform.position, + targetPosition, + controller.Tuning.SummonUnitMoveSpeed * Time.deltaTime); + if (attackCooldown <= 0f + && (target.transform.position - transform.position).sqrMagnitude <= controller.Tuning.SummonAttackRange * controller.Tuning.SummonAttackRange) + { + if (owner.TryAttack(target)) + { + attackCooldown = controller.Tuning.SummonAttackInterval; + } + } + } + } +} diff --git a/Assets/_Project/Scripts/Defense/DefenseSummonUnit.cs.meta b/Assets/_Project/Scripts/Defense/DefenseSummonUnit.cs.meta new file mode 100644 index 0000000..7f939f5 --- /dev/null +++ b/Assets/_Project/Scripts/Defense/DefenseSummonUnit.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: c2c4cbe8f78e4e11898e78be45697a12 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/_Project/Scripts/Defense/DefenseTower.cs b/Assets/_Project/Scripts/Defense/DefenseTower.cs new file mode 100644 index 0000000..2d734ee --- /dev/null +++ b/Assets/_Project/Scripts/Defense/DefenseTower.cs @@ -0,0 +1,118 @@ +using BumpCombat.Constants; +using UnityEngine; + +namespace BumpCombat.Defense +{ + [DisallowMultipleComponent] + public sealed class DefenseTower : MonoBehaviour + { + private DefensePrototypeController owner; + private DefenseConstants tuning; + private float attackCooldown; + private TextMesh label; + + public DefenseTowerKind Kind { get; private set; } + public int PadIndex { get; private set; } + public bool IsOperational => isActiveAndEnabled + && owner != null + && owner.CanOperateTowers; + + public void Configure( + DefensePrototypeController controller, + DefenseTowerKind kind, + int padIndex, + DefenseConstants constants) + { + owner = controller; + Kind = kind; + PadIndex = padIndex; + tuning = constants; + CreateVisuals(); + if (Kind == DefenseTowerKind.Summon) + { + for (int i = 0; i < tuning.SummonUnitCount; i++) + { + GameObject unitObject = new($"Summon Unit {i + 1}"); + unitObject.transform.SetParent(transform, false); + unitObject.transform.localPosition = new Vector3( + (i - (tuning.SummonUnitCount - 1) * 0.5f) * 0.42f, + -0.5f, + 0f); + DefenseSummonUnit unit = unitObject.AddComponent(); + unit.Configure(this, controller, unitObject.transform.localPosition); + } + } + } + + private void Update() + { + if (!IsOperational || Kind != DefenseTowerKind.Direct) + { + return; + } + + attackCooldown -= Time.deltaTime; + if (attackCooldown > 0f) + { + return; + } + + float interval = Kind == DefenseTowerKind.Direct + ? tuning.DirectTowerAttackInterval + : tuning.SummonAttackInterval; + if (owner.TryAttackFromTower(this)) + { + attackCooldown = Mathf.Max(0.05f, interval); + } + } + + private void CreateVisuals() + { + SpriteRenderer renderer = gameObject.GetComponent(); + if (renderer == null) + { + renderer = gameObject.AddComponent(); + } + renderer.sprite = DefensePrototypeController.WhiteSprite; + renderer.color = Kind == DefenseTowerKind.Direct + ? new Color32(65, 211, 240, 255) + : new Color32(244, 154, 74, 255); + renderer.sortingOrder = 8; + transform.localScale = new Vector3(0.72f, 0.72f, 1f); + + GameObject crown = new("Tower Crown"); + crown.transform.SetParent(transform, false); + crown.transform.localPosition = new Vector3(0f, 0.42f, 0f); + crown.transform.localScale = new Vector3(0.42f, 0.22f, 1f); + SpriteRenderer crownRenderer = crown.AddComponent(); + crownRenderer.sprite = DefensePrototypeController.WhiteSprite; + crownRenderer.color = Color.white; + crownRenderer.sortingOrder = 9; + + GameObject labelObject = new("Tower Label"); + labelObject.transform.SetParent(transform, false); + labelObject.transform.localPosition = new Vector3(0f, 0.85f, 0f); + label = labelObject.AddComponent(); + label.text = Kind == DefenseTowerKind.Direct ? "DIRECT" : "SUMMON"; + label.anchor = TextAnchor.MiddleCenter; + label.alignment = TextAlignment.Center; + label.characterSize = 0.16f; + label.fontSize = 32; + label.color = Color.white; + label.GetComponent().sortingOrder = 10; + } + + public bool TryAttack(DefenseRouteAgent target) + { + if (target == null || owner == null) + { + return false; + } + + float damage = Kind == DefenseTowerKind.Direct + ? tuning.DirectTowerDamage + : tuning.SummonDamage; + return owner.TryApplyTowerDamage(this, target, damage); + } + } +} diff --git a/Assets/_Project/Scripts/Defense/DefenseTower.cs.meta b/Assets/_Project/Scripts/Defense/DefenseTower.cs.meta new file mode 100644 index 0000000..782f407 --- /dev/null +++ b/Assets/_Project/Scripts/Defense/DefenseTower.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: e82deace7a1a47e0b14c872019e51a01 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/_Project/Scripts/Editor/AgyPlayValidationHarness.cs b/Assets/_Project/Scripts/Editor/AgyPlayValidationHarness.cs new file mode 100644 index 0000000..e9919fe --- /dev/null +++ b/Assets/_Project/Scripts/Editor/AgyPlayValidationHarness.cs @@ -0,0 +1,237 @@ +using System; +using System.Collections; +using System.IO; +using BumpCombat.Core; +using BumpCombat.Player; +using BumpCombat.Progression; +using BumpCombat.Stage; +using BumpCombat.UI; +using UnityEditor; +using UnityEngine; +using UnityEngine.InputSystem; +using UnityEngine.InputSystem.LowLevel; + +[InitializeOnLoad] +internal static class AgyPlayValidationHarness +{ + // A validation run starts only when run.marker explicitly contains "run". + private const string Out = "D:/project/game/output/agy-play-validation-20260920/"; + private const string RunMarker = Out + "run.marker"; + + static AgyPlayValidationHarness() + { + EditorApplication.delayCall += () => + { + if (IsRunRequested() && !EditorApplication.isPlayingOrWillChangePlaymode) + EditorApplication.EnterPlaymode(); + }; + EditorApplication.playModeStateChanged += s => + { + if (s == PlayModeStateChange.EnteredPlayMode && IsRunRequested()) + new GameObject("AgyPlayValidationRunner") { hideFlags = HideFlags.HideAndDontSave } + .AddComponent(); + }; + } + + private static bool IsRunRequested() + { + return File.Exists(RunMarker) + && string.Equals(File.ReadAllText(RunMarker).Trim(), "run", StringComparison.Ordinal); + } +} + +internal sealed class AgyPlayValidationRunner : MonoBehaviour +{ + private const string Out = "D:/project/game/output/agy-play-validation-20260920/"; + + private IEnumerator Start() + { + yield return new WaitForSecondsRealtime(0.5f); + RunManager run = RunManager.Instance; + if (run == null) { Fail("RunManager missing"); yield break; } + if (run.IsTitleScreen) run.BeginRun(RunMode.Development); + yield return ResolveBlockingSelections(run); + if (run.IsSelectionOpen || Time.timeScale <= 0f) + { + Fail($"Startup selection did not resolve. selectionOpen={run.IsSelectionOpen}, timeScale={Time.timeScale:R}"); + yield break; + } + + StageManager stage = StageManager.Instance; + PlayerController player = FindAnyObjectByType(); + WorldMapPanel map = FindAnyObjectByType(); + if (stage == null || stage.CurrentZone == null || player == null || map == null) + { Fail("Stage/player/map initialization failed"); yield break; } + + Capture("01-start"); + Vector2 before = player.transform.position; + yield return Hold(Key.RightArrow, 0.5f); + Vector2 after = player.transform.position; + float movementDistance = Vector2.Distance(before, after); + File.WriteAllText(Out + "movement.txt", $"before={before:R}\nafter={after:R}\ndistance={movementDistance:R}\n"); + Capture("02-moved"); + if (movementDistance <= 0.01f) + { + Fail($"Player did not move. distance={movementDistance:R}"); + yield break; + } + + yield return Press(Key.M); + File.WriteAllText(Out + "map.txt", $"open={map.IsOpen}\ntimeScale={Time.timeScale:R}\n"); + Capture("03-world-map-open"); + if (!map.IsOpen || Time.timeScale > 0f) + { + Fail($"World map did not open correctly. open={map.IsOpen}, timeScale={Time.timeScale:R}"); + yield break; + } + yield return Press(Key.M); + if (map.IsOpen || Time.timeScale <= 0f) + { + Fail($"World map did not close correctly. open={map.IsOpen}, timeScale={Time.timeScale:R}"); + yield break; + } + + int target = -1; StageDirection exit = StageDirection.North; + for (int i = 0; i < 4; i++) + { + int candidate = stage.CurrentZone.NodeData.GetConnection((StageDirection)i); + if (candidate >= 0) { target = candidate; exit = (StageDirection)i; break; } + } + if (target < 0) { Fail("No connected zone"); yield break; } + int oldZone = stage.CurrentZone.NodeId; + stage.RequestZoneTransition(target, exit.Opposite()); + yield return null; + File.WriteAllText(Out + "transition-active.txt", $"transitioning={stage.IsTransitioning}\nlocked={player.IsTransitionLocked}\n"); + Capture("04-transition-active"); + float deadline = Time.realtimeSinceStartup + 4f; + while (stage.IsTransitioning && Time.realtimeSinceStartup < deadline) yield return null; + ArenaBounds bounds = ArenaBounds.Resolve(); + File.WriteAllText(Out + "transition.txt", + $"oldZone={oldZone}\nnewZone={stage.CurrentZone?.NodeId ?? -1}\ntarget={target}\n" + + $"transitioning={stage.IsTransitioning}\nlocked={player.IsTransitionLocked}\n" + + $"boundsCenter={bounds?.ZoneCenter.ToString("R")}\nzoneCenter={stage.CurrentZone?.ZoneCenter.ToString("R")}\n"); + Capture("05-transition-complete"); + if (stage.IsTransitioning + || player.IsTransitionLocked + || stage.CurrentZone == null + || stage.CurrentZone.NodeId != target) + { + Fail($"Zone transition failed. oldZone={oldZone}, target={target}, newZone={stage.CurrentZone?.NodeId ?? -1}, transitioning={stage.IsTransitioning}, locked={player.IsTransitionLocked}"); + yield break; + } + + run.EndRunAsClear(); + yield return null; + RunClearPanel clear = FindAnyObjectByType(); + File.WriteAllText(Out + "clear.txt", $"runCleared={run.IsRunCleared}\ntimeScale={Time.timeScale:R}\npanel={(clear != null)}\n"); + Capture("06-run-clear"); + if (!run.IsRunCleared || Time.timeScale > 0f || clear == null) + { + Fail($"Run clear validation failed. runCleared={run.IsRunCleared}, timeScale={Time.timeScale:R}, panel={(clear != null)}"); + yield break; + } + File.WriteAllText(Out + "completed.marker", DateTime.Now.ToString("O")); + DeleteRunMarker(); + EditorApplication.ExitPlaymode(); + } + + private static IEnumerator ResolveBlockingSelections(RunManager run) + { + ArtifactRewardController rewards = FindAnyObjectByType(); + RunTutorialController tutorial = FindAnyObjectByType(); + float deadline = Time.realtimeSinceStartup + 6f; + + while (Time.realtimeSinceStartup < deadline) + { + bool handled = false; + + if (rewards != null && rewards.DebugIsSelectionVisible) + { + if (rewards.DebugShownCount <= 0 || !rewards.DebugChooseOption(0)) + { + Fail("Could not resolve startup artifact selection."); + yield break; + } + + handled = true; + } + else if (tutorial != null && tutorial.DebugIsVisible) + { + if (!tutorial.DebugContinue()) + { + Fail("Could not dismiss startup tutorial."); + yield break; + } + + handled = true; + } + + if (!handled && !run.IsSelectionOpen && Time.timeScale > 0f) + { + yield return null; + if (!run.IsSelectionOpen && Time.timeScale > 0f) + yield break; + } + + yield return null; + } + } + + private static IEnumerator Hold(Key key, float seconds) + { + if (Keyboard.current == null) yield break; + InputSystem.QueueStateEvent(Keyboard.current, new KeyboardState(key)); + float end = Time.realtimeSinceStartup + seconds; + while (Time.realtimeSinceStartup < end) yield return null; + InputSystem.QueueStateEvent(Keyboard.current, new KeyboardState()); + yield return null; + } + + private static IEnumerator Press(Key key) + { + if (Keyboard.current == null) yield break; + InputSystem.QueueStateEvent(Keyboard.current, new KeyboardState(key)); + yield return null; + InputSystem.QueueStateEvent(Keyboard.current, new KeyboardState()); + yield return null; + } + + private static void Capture(string name) + { + Directory.CreateDirectory(Out); + Camera camera = Camera.main; + if (camera == null) return; + RenderTexture previous = camera.targetTexture; + RenderTexture active = RenderTexture.active; + var target = new RenderTexture(1280, 720, 24); + var texture = new Texture2D(1280, 720, TextureFormat.RGB24, false); + try + { + camera.targetTexture = target; camera.Render(); RenderTexture.active = target; + texture.ReadPixels(new Rect(0, 0, 1280, 720), 0, 0); texture.Apply(); + File.WriteAllBytes(Out + name + ".png", texture.EncodeToPNG()); + } + finally + { + camera.targetTexture = previous; RenderTexture.active = active; + DestroyImmediate(texture); target.Release(); DestroyImmediate(target); + } + } + + private static void Fail(string message) + { + Directory.CreateDirectory(Out); + File.WriteAllText(Out + "validation-error.txt", message); + DeleteRunMarker(); + EditorApplication.ExitPlaymode(); + } + + private static void DeleteRunMarker() + { + string marker = Out + "run.marker"; + if (File.Exists(marker)) + { + File.Delete(marker); + } + } +} diff --git a/Assets/_Project/Scripts/Editor/AgyPlayValidationHarness.cs.meta b/Assets/_Project/Scripts/Editor/AgyPlayValidationHarness.cs.meta new file mode 100644 index 0000000..b57d5fe --- /dev/null +++ b/Assets/_Project/Scripts/Editor/AgyPlayValidationHarness.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 5ec05c0337f620543ba3d30114ee07fe \ No newline at end of file diff --git a/Assets/_Project/Scripts/Editor/BumpCombatProjectSetup.cs b/Assets/_Project/Scripts/Editor/BumpCombatProjectSetup.cs index a36cf7e..67d5535 100644 --- a/Assets/_Project/Scripts/Editor/BumpCombatProjectSetup.cs +++ b/Assets/_Project/Scripts/Editor/BumpCombatProjectSetup.cs @@ -1271,7 +1271,7 @@ namespace BumpCombat.Editor body.collisionDetectionMode = CollisionDetectionMode2D.Continuous; CircleCollider2D collider = player.AddComponent(); - collider.offset = new Vector2(0f, -0.28f); + collider.offset = new Vector2(0f, -0.06f); collider.radius = 0.22f; player.AddComponent(); @@ -1394,10 +1394,7 @@ namespace BumpCombat.Editor radius, experience, animationLeadTime); - definition.ConfigureRole( - kind == EnemyKind.Slime || kind == EnemyKind.Skeleton - ? EnemyRole.Crowd - : EnemyRole.Normal); + definition.ConfigureRole(EnemyRole.Normal); AssetDatabase.CreateAsset(definition, path); return definition; } diff --git a/Assets/_Project/Scripts/Enemies/EnemyAttack.cs b/Assets/_Project/Scripts/Enemies/EnemyAttack.cs index 6abe6f2..1ef27a5 100644 --- a/Assets/_Project/Scripts/Enemies/EnemyAttack.cs +++ b/Assets/_Project/Scripts/Enemies/EnemyAttack.cs @@ -22,6 +22,7 @@ namespace BumpCombat.Enemies private bool lancerTipNeedsRebase; private bool necrofireRayActive; private bool necrofireRayHitConfirmed; + private float necrofireRayLength; private bool meleeAttackActive; private bool meleeHitConfirmed; private StageEnemyEffectVisual necrofireRayVisual; @@ -392,12 +393,16 @@ namespace BumpCombat.Enemies Vector2 direction = lockedDirection.sqrMagnitude > 0.0001f ? lockedDirection : Vector2.right; - float length = controller.AttackLength; + necrofireRayLength = GetTerrainBlockedLength( + attackOrigin, + direction, + controller.AttackLength, + controller.AttackWidth * 0.5f); necrofireRayVisual = StageEnemyEffectVisual.PlayRay( gameObject, attackOrigin, direction, - length, + necrofireRayLength, controller.AttackWidth, controller.ActiveDuration); TickNecrofireRay(); @@ -982,7 +987,13 @@ namespace BumpCombat.Enemies Vector2 direction = lockedDirection.sqrMagnitude > 0.0001f ? lockedDirection : Vector2.right; - float length = controller.AttackLength; + float length = necrofireRayLength > 0f + ? necrofireRayLength + : GetTerrainBlockedLength( + attackOrigin, + direction, + controller.AttackLength, + controller.AttackWidth * 0.5f); float width = Mathf.Max(0.001f, controller.AttackWidth); Vector2 center = attackOrigin + direction * (length * 0.5f); float angle = Mathf.Atan2(direction.y, direction.x) * Mathf.Rad2Deg; @@ -1018,5 +1029,44 @@ namespace BumpCombat.Enemies } } } + + private static float GetTerrainBlockedLength( + Vector2 origin, + Vector2 direction, + float maxLength, + float radius) + { + float clampedLength = Mathf.Max(0f, maxLength); + if (clampedLength <= 0f || direction.sqrMagnitude <= 0.0001f) + { + return clampedLength; + } + + RaycastHit2D[] hits = Physics2D.CircleCastAll( + origin, + Mathf.Max(0.01f, radius), + direction.normalized, + clampedLength, + Physics2D.AllLayers); + float blockedLength = clampedLength; + for (int i = 0; i < hits.Length; i++) + { + Collider2D collider = hits[i].collider; + if (collider == null || collider.isTrigger) + { + continue; + } + + bool isTerrain = collider.GetComponentInParent() != null + || collider.name.StartsWith("Wall_") + || collider.gameObject.name.Contains("Wall"); + if (isTerrain) + { + blockedLength = Mathf.Min(blockedLength, hits[i].distance); + } + } + + return blockedLength; + } } } diff --git a/Assets/_Project/Scripts/Enemies/EnemyController.cs b/Assets/_Project/Scripts/Enemies/EnemyController.cs index 199ca56..728b3cd 100644 --- a/Assets/_Project/Scripts/Enemies/EnemyController.cs +++ b/Assets/_Project/Scripts/Enemies/EnemyController.cs @@ -6,6 +6,7 @@ using BumpCombat.Constants; using BumpCombat.Core; using BumpCombat.Player; using BumpCombat.Progression; +using BumpCombat.Stage; using UnityEngine; namespace BumpCombat.Enemies @@ -49,6 +50,7 @@ namespace BumpCombat.Enemies Active, Recovery, Dead, + Idle, } [RequireComponent(typeof(Rigidbody2D), typeof(Collider2D), typeof(SpriteRenderer))] @@ -68,6 +70,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 Dictionary> EncounterGroups = new(); private static Sprite launchWhiteSprite; private static int nextSpawnLifetimeIdentity; [SerializeField] private EnemyDefinition definition; @@ -85,6 +88,9 @@ namespace BumpCombat.Enemies private EnemyAttack enemyAttack; private EnemyModel enemyModel; private ArtifactStatusVisual statusVisual; + private readonly List fogRenderers = new(); + private readonly List fogRendererForceOffStates = new(); + private bool fogVisibilityHidden; private ActiveArtifactController activeArtifactController; private Transform player; private PlayerHealth playerHealth; @@ -97,6 +103,7 @@ namespace BumpCombat.Enemies private float igniteNextTickIn; private float igniteTickInterval; private float igniteTickDamage; + private EnemyDamageSource igniteDamageSource; private float shockRemaining; private float shockIncrease; private float vulnerabilityRemaining; @@ -123,6 +130,7 @@ namespace BumpCombat.Enemies private IEnemyDamageGate damageGate; private IEnemyAttackPatternGate attackPatternGate; private IEnemyActionGate actionGate; + private BumpCombat.Defense.DefenseRouteAgent defenseRouteAgent; private bool summoned; private bool phaseSummon; private bool summonedEventLike; @@ -142,6 +150,15 @@ namespace BumpCombat.Enemies private readonly HashSet recentArtifactContactKeys = new(StringComparer.Ordinal); private readonly Queue recentArtifactContactOrder = new(); private const int MaximumRememberedArtifactContacts = 128; + private const int RoamTargetSelectionAttempts = 12; + private bool hasDetectedPlayer; + private int encounterGroupId = -1; + private bool hasRoamAnchor; + private Vector2 roamAnchor; + private Vector2 roamTarget; + private float roamPauseRemaining; + private float sightMemoryRemaining; + private Vector2 lastSeenPlayerPosition; public static int AliveCount { get; private set; } public static int CrowdAliveCount { get; private set; } @@ -160,6 +177,8 @@ namespace BumpCombat.Enemies public float CurrentHealth { get; private set; } public float LastAppliedDamage { get; private set; } public float LastReportedDamage { get; private set; } + public EnemyDamageSource LastDamageSource { get; private set; } + public float MaximumHealthAtDeath { get; private set; } public int CurrentAttackPatternIndex => attackPatternIndex; public EnemyAttackPattern CurrentAttackPattern => definition != null ? definition.GetAttackPattern(attackPatternIndex) @@ -216,6 +235,12 @@ namespace BumpCombat.Enemies public Vector2 GroundAnchorPosition => body != null ? body.position : transform.position; + public Transform CurrentVisualTransform => airborneVisualObject != null + ? airborneVisualObject.transform + : transform; + public int CurrentVisualSortingOrder => airborneVisualObject != null + ? spriteRenderer.sortingOrder + 2 + : spriteRenderer.sortingOrder; public bool IsKnockbackActive => knockbackVelocity.sqrMagnitude > 0.0025f; public bool IsLaunchVisualActive => launchVisualCoroutine != null; public bool IsAttackSequenceInProgress => State == EnemyState.Warning @@ -228,6 +253,11 @@ namespace BumpCombat.Enemies public EnemyController SummonOwner => summonOwner; public bool IsProtectedFromCleanup => runEventRole.HasValue || phaseSummon; public bool HasContactDamage => definition != null && definition.HasContactDamage; + public bool IsFlying => definition != null && definition.IsFlying; + public Collider2D BodyCollider => bodyCollider != null ? bodyCollider : (bodyCollider = GetComponent()); + public bool HasDetectedPlayer => hasDetectedPlayer; + public int EncounterGroupId => encounterGroupId; + public float SightRange => enemyModel != null ? enemyModel.SightRange : (definition != null ? definition.SightRange : 8f); public bool IsRangedAttackStartAllowed => definition == null || !definition.IsRanged || CanStartRangedAttack(); @@ -369,6 +399,21 @@ namespace BumpCombat.Enemies } #endif + public void ConfigureEncounterGroup(int groupId, Vector2 anchor) + { + UnregisterEncounterGroup(); + encounterGroupId = groupId; + roamAnchor = anchor; + roamTarget = anchor; + hasRoamAnchor = true; + RegisterEncounterGroup(); + } + + public void ConfigureSightRangeMultiplier(float multiplier) + { + EnsureEnemyModel().ConfigureSightRangeMultiplier(multiplier); + } + private void Awake() { nextSpawnLifetimeIdentity = nextSpawnLifetimeIdentity == int.MaxValue @@ -386,6 +431,7 @@ namespace BumpCombat.Enemies } bodyCollider = GetComponent(); spriteRenderer = GetComponent(); + CacheFogRenderers(); animator = GetComponent(); enemyAttack = GetComponent(); ConfigureEnemyModel(); @@ -433,6 +479,12 @@ namespace BumpCombat.Enemies private void OnEnable() { + if (fogVisibilityHidden) + { + ForceFogRenderersOff(); + } + + RegisterEncounterGroup(); if (enemyModel != null) { enemyModel.OnStatChanged += HandleCharacterStatChanged; @@ -455,12 +507,20 @@ namespace BumpCombat.Enemies } KeepBodyInsideArena(); + if (IsFlying) + { + StageObstacle.RegisterFlyingEnemy(this); + } } private void Start() { InitializeGroggyTierFromDefinition(); KeepBodyInsideArena(); + if (IsFlying) + { + StageObstacle.RegisterFlyingEnemy(this); + } if (definition == null) { enabled = false; @@ -477,8 +537,16 @@ namespace BumpCombat.Enemies player = playerController.transform; playerHealth = playerController.GetComponent(); activeArtifactController = playerController.GetComponent(); + if (!hasRoamAnchor) + { + roamAnchor = body != null ? body.position : (Vector2)transform.position; + roamTarget = roamAnchor; + hasRoamAnchor = true; + } InitializeInitialShieldColor(); CurrentHealth = MaximumHealth; + LastDamageSource = EnemyDamageSource.None; + MaximumHealthAtDeath = 0f; float summonDuration = summoned ? GetSummonAnimationDuration() : 0f; bool playSummonAnimation = summonDuration > 0f; EnterState( @@ -507,6 +575,7 @@ namespace BumpCombat.Enemies private void OnDisable() { + UnregisterEncounterGroup(); if (enemyModel != null) { enemyModel.OnStatChanged -= HandleCharacterStatChanged; @@ -601,6 +670,13 @@ namespace BumpCombat.Enemies return; } + defenseRouteAgent ??= GetComponent(); + if (defenseRouteAgent != null + && defenseRouteAgent.TryDriveFixedUpdate(Time.fixedDeltaTime)) + { + return; + } + if (!AllowsStateMovement(State, AttackShape)) { body.linearVelocity = Vector2.zero; @@ -611,12 +687,35 @@ namespace BumpCombat.Enemies { case EnemyState.Spawn: if (stateTimer <= 0f) + { + if (CheckPlayerDetection()) + { + EnterState(EnemyState.Chase, 0f); + } + else + { + EnterState(EnemyState.Idle, 0f); + } + } + break; + + case EnemyState.Idle: + SetAttacking(false); + if (CheckPlayerDetection()) { EnterState(EnemyState.Chase, 0f); } + else + { + UpdateRoaming(); + } break; case EnemyState.Chase: + if (!RefreshChaseAwareness()) + { + break; + } if (actionGate != null && actionGate.IsAttackLocked) { body.linearVelocity = Vector2.zero; @@ -731,7 +830,8 @@ namespace BumpCombat.Enemies Vector2 knockbackDirection, float knockbackForce, bool playHurtAnimation = true, - bool isNormalBump = false) + bool isNormalBump = false, + EnemyDamageSource damageSource = EnemyDamageSource.None) { return TryTakeDamageWithEffects( damage, @@ -741,6 +841,7 @@ namespace BumpCombat.Enemies isNormalBump, IsShocked, IsBumpVulnerable, + damageSource, out _); } @@ -752,9 +853,11 @@ namespace BumpCombat.Enemies bool isNormalBump, bool isShockedAtHit, bool isBumpVulnerableAtHit, + EnemyDamageSource damageSource, out bool noDamageAfterEffects) { noDamageAfterEffects = false; + Alert(); if (IsDead || damage <= 0f) { LastAppliedDamage = 0f; @@ -802,13 +905,16 @@ namespace BumpCombat.Enemies ? damageTaken : calculatedDamage; float healthBeforeDamage = CurrentHealth; + float maximumHealthBeforeDamage = MaximumHealth; CurrentHealth = Mathf.Max(0f, CurrentHealth - damageTaken); LastAppliedDamage = healthBeforeDamage - CurrentHealth; + LastDamageSource = damageSource; OnDamageApplied?.Invoke(healthBeforeDamage, LastAppliedDamage); bool damageReactionBlocked = damageGate != null && damageGate.IsDamageBlocked; if (CurrentHealth <= 0f) { + MaximumHealthAtDeath = maximumHealthBeforeDamage; Die(); } else if (damageReactionBlocked) @@ -817,13 +923,13 @@ namespace BumpCombat.Enemies // desperation phase. Do not append the opening hit's // knockback or hurt reaction after that transition. knockbackVelocity = Vector2.zero; - body.linearVelocity = Vector2.zero; + if (body != null) body.linearVelocity = Vector2.zero; } else { if (knockbackVelocity.sqrMagnitude <= 0.0025f) { - knockbackStartPosition = body.position; + knockbackStartPosition = body != null ? body.position : (Vector2)transform.position; } knockbackVelocity += knockbackDirection.normalized * knockbackForce @@ -914,7 +1020,8 @@ namespace BumpCombat.Enemies float knockbackForce, ArtifactColor artifactColor, int castIdentity, - bool playHurtAnimation = true) + bool playHurtAnimation = true, + EnemyDamageSource damageSource = EnemyDamageSource.None) { lastArtifactContactCounted = false; if (IsDead) @@ -956,7 +1063,8 @@ namespace BumpCombat.Enemies knockbackDirection, knockbackForce, playHurtAnimation, - false) + false, + damageSource) ? EnemyArtifactHitResult.DamageApplied : EnemyArtifactHitResult.Rejected; } @@ -1004,7 +1112,8 @@ namespace BumpCombat.Enemies public bool ApplyIgnite( float duration, float tickInterval, - float damagePerTick) + float damagePerTick, + EnemyDamageSource damageSource = EnemyDamageSource.Player) { if (IsDead || duration <= 0f @@ -1017,6 +1126,7 @@ namespace BumpCombat.Enemies bool wasInactive = igniteRemaining <= 0f; igniteRemaining = Mathf.Max(igniteRemaining, duration); igniteTickDamage = Mathf.Max(igniteTickDamage, damagePerTick); + igniteDamageSource = damageSource; if (wasInactive) { igniteTickInterval = tickInterval; @@ -1085,6 +1195,7 @@ namespace BumpCombat.Enemies false, isShockedAtTick, false, + igniteDamageSource, out bool noDamageAfterEffects); if ((!damageApplied && !noDamageAfterEffects) || IsDead) { @@ -1821,19 +1932,28 @@ namespace BumpCombat.Enemies Vector2 attackSlot = GetHorizontalRootAttackSlot( attackTarget, direction); + if (!IsFlying) + { + ZoneInstance currentZone = StageManager.Instance != null + ? StageManager.Instance.CurrentZone + : null; + if (currentZone != null + && currentZone.Preset != null + && RoomPathfinder.TryGetNextWaypoint( + currentZone, + body.position, + attackSlot, + false, + out Vector2 waypoint)) + { + attackSlot = waypoint; + } + } Vector2 toAttackSlot = attackSlot - body.position; movementDirection = toAttackSlot.sqrMagnitude > 0.0001f ? toAttackSlot.normalized : Vector2.zero; } - if (IsCrowd) - { - Vector2 toApproachPoint = - (Vector2)player.position + crowdApproachOffset - body.position; - movementDirection = toApproachPoint.sqrMagnitude > 0.01f - ? toApproachPoint.normalized - : Vector2.zero; - } else if (definition.IsRanged && rangedAttackOutsideGate) { // A ranged attacker must enter the visible, reachable area @@ -1845,11 +1965,45 @@ namespace BumpCombat.Enemies { movementDirection = -direction; } + else + { + Vector2 targetPoint = attackTarget; + if (IsCrowd) + { + float distToPlayer = (attackTarget - body.position).magnitude; + targetPoint = distToPlayer > 2.5f + ? attackTarget + : (Vector2)player.position + crowdApproachOffset; + } + + if (!IsFlying) + { + ZoneInstance currentZone = StageManager.Instance != null ? StageManager.Instance.CurrentZone : null; + if (currentZone != null && currentZone.Preset != null) + { + if (RoomPathfinder.TryGetNextWaypoint( + currentZone, + body.position, + targetPoint, + IsFlying, + out Vector2 waypoint)) + { + targetPoint = waypoint; + } + } + } + + Vector2 toApproachPoint = targetPoint - body.position; + movementDirection = toApproachPoint.sqrMagnitude > 0.01f + ? toApproachPoint.normalized + : Vector2.zero; + } Vector2 separation = CalculateSeparation(); - Vector2 velocity = Vector2.ClampMagnitude( + Vector2 desiredVelocity = Vector2.ClampMagnitude( movementDirection + separation * separationStrength, 1f) * MoveSpeed; + Vector2 velocity = CalculateWallSlideVelocity(desiredVelocity); body.MovePosition(ClampEnemyPosition( body.position + velocity * Time.fixedDeltaTime)); SetMoving(velocity.sqrMagnitude > 0.0001f); @@ -1864,10 +2018,11 @@ namespace BumpCombat.Enemies if (bounds != null) { Vector2 actorExtents = bounds.SharedActorClampHalfExtents; + Vector2 center = bounds.ZoneCenter; boundedTarget.y = Mathf.Clamp( attackTarget.y, - -actorExtents.y, - actorExtents.y); + center.y - actorExtents.y, + center.y + actorExtents.y); } // Horizontal-root attacks measure range to the player's collider @@ -1919,9 +2074,10 @@ namespace BumpCombat.Enemies EnemyConstants tuning = GameplayConstants.Current.Enemies; Vector2 reachable = bounds.GetReachableHalfExtents( tuning.RangedApproachArenaInset); + Vector2 center = bounds.ZoneCenter; Vector2 interiorTarget = new( - Mathf.Clamp(targetPosition.x, -reachable.x, reachable.x), - Mathf.Clamp(targetPosition.y, -reachable.y, reachable.y)); + Mathf.Clamp(targetPosition.x, center.x - reachable.x, center.x + reachable.x), + Mathf.Clamp(targetPosition.y, center.y - reachable.y, center.y + reachable.y)); Camera camera = Camera.main; if (camera != null) { @@ -1933,10 +2089,10 @@ namespace BumpCombat.Enemies // Keep the approach point in the intersection of the // reachable and visible interiors. This is essential when // the player is held against an outer map edge. - float minimumX = Mathf.Max(-reachable.x, cameraCenter.x - visible.x); - float maximumX = Mathf.Min(reachable.x, cameraCenter.x + visible.x); - float minimumY = Mathf.Max(-reachable.y, cameraCenter.y - visible.y); - float maximumY = Mathf.Min(reachable.y, cameraCenter.y + visible.y); + float minimumX = Mathf.Max(center.x - reachable.x, cameraCenter.x - visible.x); + float maximumX = Mathf.Min(center.x + reachable.x, cameraCenter.x + visible.x); + float minimumY = Mathf.Max(center.y - reachable.y, cameraCenter.y - visible.y); + float maximumY = Mathf.Min(center.y + reachable.y, cameraCenter.y + visible.y); if (minimumX <= maximumX) { interiorTarget.x = Mathf.Clamp( @@ -2095,6 +2251,11 @@ namespace BumpCombat.Enemies return cachedSeparation; } + public void SetFacingDirection(Vector2 direction) + { + SetFacing(direction); + } + private void SetFacing(Vector2 direction) { if (direction.sqrMagnitude <= 0f) @@ -2126,6 +2287,276 @@ namespace BumpCombat.Enemies || (state == EnemyState.Warning && attackAnimationStarted)); } + public void Alert() + { + MarkPlayerDetected(false); + if (State == EnemyState.Idle) + { + EnterState(EnemyState.Chase, 0f); + } + } + + public bool CheckPlayerDetection() + { + if (!CanCurrentlyDetectPlayer()) return false; + MarkPlayerDetected(true); + return true; + } + + private bool CanCurrentlyDetectPlayer() + { + EnsurePlayerReference(); + if (player == null) return false; + + Vector2 myPos = GroundAnchorPosition; + float distance = Vector2.Distance(myPos, player.position); + if (distance > SightRange) return false; + + float proximityRange = GameplayConstants.Current.Enemies.ProximityAlertRange; + return distance <= proximityRange + || RoomPathfinder.HasLineOfSight(myPos, player.position, IsFlying, bodyCollider); + } + + private void EnsurePlayerReference() + { + if (player != null) return; + + PlayerController playerController = FindAnyObjectByType(); + if (playerController == null) return; + + player = playerController.transform; + playerHealth = playerController.GetComponent(); + activeArtifactController = playerController.GetComponent(); + } + + private void MarkPlayerDetected(bool alertGroup) + { + EnsurePlayerReference(); + hasDetectedPlayer = true; + sightMemoryRemaining = GameplayConstants.Current.Enemies.LostSightMemoryDuration; + if (player != null) + { + lastSeenPlayerPosition = player.position; + } + + if (alertGroup && encounterGroupId >= 0 + && EncounterGroups.TryGetValue(encounterGroupId, out HashSet group)) + { + foreach (EnemyController member in group) + { + if (member == null || member == this || member.IsDead || member.IsEventEnemy) + { + continue; + } + member.MarkPlayerDetected(false); + if (member.State == EnemyState.Idle) + { + member.EnterState(EnemyState.Chase, 0f); + } + } + } + } + + private bool RefreshChaseAwareness() + { + if (IsEventEnemy) + { + return true; + } + + if (CanCurrentlyDetectPlayer()) + { + MarkPlayerDetected(false); + return true; + } + + sightMemoryRemaining = Mathf.Max(0f, sightMemoryRemaining - Time.fixedDeltaTime); + if (sightMemoryRemaining > 0f) + { + return true; + } + + Vector2 toLastSeen = lastSeenPlayerPosition - body.position; + if (toLastSeen.sqrMagnitude <= 0.16f) + { + hasDetectedPlayer = false; + body.linearVelocity = Vector2.zero; + SetMoving(false); + EnterState(EnemyState.Idle, 0f); + return false; + } + + MoveTowardPoint(lastSeenPlayerPosition, MoveSpeed); + return false; + } + + private void UpdateRoaming() + { + if (IsEventEnemy || !hasRoamAnchor) + { + body.linearVelocity = Vector2.zero; + SetMoving(false); + return; + } + + EnemyConstants tuning = GameplayConstants.Current.Enemies; + if (roamPauseRemaining > 0f) + { + roamPauseRemaining = Mathf.Max(0f, roamPauseRemaining - Time.fixedDeltaTime); + body.linearVelocity = Vector2.zero; + SetMoving(false); + return; + } + + if ((roamTarget - body.position).sqrMagnitude <= 0.16f) + { + if (!TryChooseRoamTarget(tuning)) + { + roamTarget = body.position; + } + roamPauseRemaining = UnityEngine.Random.Range(tuning.RoamPauseMin, tuning.RoamPauseMax); + body.linearVelocity = Vector2.zero; + SetMoving(false); + return; + } + + MoveTowardPoint(roamTarget, MoveSpeed * 0.6f); + } + + private bool TryChooseRoamTarget(EnemyConstants tuning) + { + ZoneInstance currentZone = StageManager.Instance != null + ? StageManager.Instance.CurrentZone + : null; + + for (int attempt = 0; attempt < RoamTargetSelectionAttempts; attempt++) + { + float radius = UnityEngine.Random.Range(tuning.RoamRadiusMin, tuning.RoamRadiusMax); + Vector2 offset = UnityEngine.Random.insideUnitCircle.normalized * radius; + Vector2 candidate = ClampEnemyPosition(roamAnchor + offset); + + if (currentZone == null || currentZone.Preset == null) + { + roamTarget = candidate; + return true; + } + + if (RoomPathfinder.TryGetReachableRoamPoint( + currentZone, + body.position, + candidate, + IsFlying, + out Vector2 reachableTarget)) + { + roamTarget = reachableTarget; + return true; + } + } + + return false; + } + + private void MoveTowardPoint(Vector2 targetPoint, float speed) + { + if (!IsFlying) + { + ZoneInstance currentZone = StageManager.Instance != null + ? StageManager.Instance.CurrentZone + : null; + if (currentZone != null && currentZone.Preset != null + && RoomPathfinder.TryGetNextWaypoint( + currentZone, + body.position, + targetPoint, + false, + out Vector2 waypoint)) + { + targetPoint = waypoint; + } + } + + Vector2 direction = targetPoint - body.position; + if (direction.sqrMagnitude <= 0.0001f) + { + body.linearVelocity = Vector2.zero; + SetMoving(false); + return; + } + + direction.Normalize(); + SetFacing(direction); + Vector2 desiredVelocity = direction * speed; + Vector2 velocity = CalculateWallSlideVelocity(desiredVelocity); + body.MovePosition(ClampEnemyPosition( + body.position + velocity * Time.fixedDeltaTime)); + SetMoving(velocity.sqrMagnitude > 0.0001f); + } + + private void RegisterEncounterGroup() + { + if (encounterGroupId < 0) return; + if (!EncounterGroups.TryGetValue(encounterGroupId, out HashSet group)) + { + group = new HashSet(); + EncounterGroups.Add(encounterGroupId, group); + } + group.Add(this); + } + + private void UnregisterEncounterGroup() + { + if (encounterGroupId < 0 + || !EncounterGroups.TryGetValue(encounterGroupId, out HashSet group)) + { + return; + } + + group.Remove(this); + if (group.Count == 0) + { + EncounterGroups.Remove(encounterGroupId); + } + } + + private Vector2 CalculateWallSlideVelocity(Vector2 velocity) + { + if (velocity.sqrMagnitude < 0.0001f) return Vector2.zero; + + float radius = GameplayConstants.Current.Enemies.WallSlideCastRadius; + float distance = GameplayConstants.Current.Enemies.WallSlideCastDistance; + Vector2 dir = velocity.normalized; + + RaycastHit2D hit = Physics2D.CircleCast( + body.position, + radius, + dir, + distance); + + if (hit.collider != null && !hit.collider.isTrigger && hit.collider != bodyCollider) + { + bool isObstacle = hit.collider.GetComponentInParent() != null; + bool isWall = hit.collider.name.StartsWith("Wall_") || hit.collider.gameObject.name.Contains("Wall"); + + if ((isObstacle && !IsFlying) || isWall) + { + Vector2 normal = hit.normal; + Vector2 tangent = new Vector2(-normal.y, normal.x); + if (Vector2.Dot(velocity, tangent) < 0f) + { + tangent = -tangent; + } + + float forwardProjection = Vector2.Dot(velocity, tangent); + if (forwardProjection > 0.01f) + { + return tangent * (forwardProjection * 1.05f); + } + } + } + + return velocity; + } + public static bool ShouldStartAttackAnimation( float warningTimeRemaining, float leadTime, @@ -2294,6 +2725,89 @@ namespace BumpCombat.Enemies ReleaseSpriteVisibilityLease(reason); } + public void SetFogVisibility(bool visible) + { + if (visible) + { + if (!fogVisibilityHidden) + { + return; + } + + for (int i = 0; i < fogRenderers.Count; i++) + { + if (fogRenderers[i] != null) + { + fogRenderers[i].forceRenderingOff = fogRendererForceOffStates[i]; + } + } + + fogRenderers.Clear(); + fogRendererForceOffStates.Clear(); + fogVisibilityHidden = false; + return; + } + + if (!fogVisibilityHidden) + { + CacheFogRenderers(); + fogVisibilityHidden = true; + } + + ForceFogRenderersOff(); + } + + private void CacheFogRenderers() + { + if (fogVisibilityHidden) + { + return; + } + + fogRenderers.Clear(); + fogRendererForceOffStates.Clear(); + Renderer[] renderers = GetComponentsInChildren(true); + for (int i = 0; i < renderers.Length; i++) + { + fogRenderers.Add(renderers[i]); + fogRendererForceOffStates.Add(renderers[i].forceRenderingOff); + } + } + + private void ForceFogRenderersOff() + { + for (int i = 0; i < fogRenderers.Count; i++) + { + if (fogRenderers[i] != null) + { + fogRenderers[i].forceRenderingOff = true; + } + } + } + + private void OnTransformChildrenChanged() + { + if (!fogVisibilityHidden) + { + CacheFogRenderers(); + return; + } + + Renderer[] renderers = GetComponentsInChildren(true); + for (int i = 0; i < renderers.Length; i++) + { + if (fogRenderers.Contains(renderers[i])) + { + renderers[i].forceRenderingOff = true; + continue; + } + + fogRenderers.Add(renderers[i]); + fogRendererForceOffStates.Add(renderers[i].forceRenderingOff); + renderers[i].forceRenderingOff = true; + } + } + private void AcquireSpriteVisibilityLease(string reason) { if (string.IsNullOrEmpty(reason)) diff --git a/Assets/_Project/Scripts/Enemies/EnemyDamageSource.cs b/Assets/_Project/Scripts/Enemies/EnemyDamageSource.cs new file mode 100644 index 0000000..68eefcc --- /dev/null +++ b/Assets/_Project/Scripts/Enemies/EnemyDamageSource.cs @@ -0,0 +1,10 @@ +namespace BumpCombat.Enemies +{ + public enum EnemyDamageSource + { + None, + Player, + Tower, + PassiveRelicExplosion, + } +} \ No newline at end of file diff --git a/Assets/_Project/Scripts/Enemies/EnemyDamageSource.cs.meta b/Assets/_Project/Scripts/Enemies/EnemyDamageSource.cs.meta new file mode 100644 index 0000000..e05e1c1 --- /dev/null +++ b/Assets/_Project/Scripts/Enemies/EnemyDamageSource.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: c98a61b910a94e56aa5cdfa41234baf2 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/_Project/Scripts/Enemies/EnemyDefinition.cs b/Assets/_Project/Scripts/Enemies/EnemyDefinition.cs index 085f80c..c37259b 100644 --- a/Assets/_Project/Scripts/Enemies/EnemyDefinition.cs +++ b/Assets/_Project/Scripts/Enemies/EnemyDefinition.cs @@ -297,6 +297,8 @@ namespace BumpCombat.Enemies [SerializeField] private EnemyAttackPattern[] attackPatterns; [SerializeField, Min(0f)] private float separationRadius = 0.55f; [SerializeField, Min(0f)] private float separationStrength = 0.6f; + [SerializeField] private bool isFlying; + [SerializeField, Min(0f)] private float sightRange = 8f; public EnemyKind Kind => kind; public EnemyRole Role => role; @@ -326,6 +328,8 @@ namespace BumpCombat.Enemies public bool IsCrowd => role == EnemyRole.Crowd; public bool HasContactDamage => kind == EnemyKind.Slime || kind == EnemyKind.Bat; + public bool IsFlying => isFlying || kind == EnemyKind.Bat || kind == EnemyKind.Necrofire; + public float SightRange => sightRange > 0.01f ? sightRange : 8f; public EnemyAttackPattern GetAttackPattern(int index) { @@ -394,6 +398,16 @@ namespace BumpCombat.Enemies { attackPatterns = patterns; } + + public void ConfigureFlying(bool flying) + { + isFlying = flying; + } + + public void ConfigureSightRange(float range) + { + sightRange = range; + } #endif } } diff --git a/Assets/_Project/Scripts/Enemies/EnemyModel.cs b/Assets/_Project/Scripts/Enemies/EnemyModel.cs index 18a2b50..ea3c565 100644 --- a/Assets/_Project/Scripts/Enemies/EnemyModel.cs +++ b/Assets/_Project/Scripts/Enemies/EnemyModel.cs @@ -10,6 +10,7 @@ namespace BumpCombat.Enemies private float maxHealthMultiplier = 1f; private float moveSpeedMultiplier = 1f; private float attackDamageMultiplier = 1f; + private float sightRangeMultiplier = 1f; public EnemyDefinition Definition => definition; public override float MaxHealth => definition == null @@ -26,6 +27,14 @@ namespace BumpCombat.Enemies ? 0f : CalculateAttackDamage( definition.AttackDamage * attackDamageMultiplier); + public float SightRange => definition == null + ? 8f + : definition.SightRange * sightRangeMultiplier; + + public void ConfigureSightRangeMultiplier(float multiplier) + { + sightRangeMultiplier = Mathf.Max(0.1f, multiplier); + } public void ConfigureDefinition(EnemyDefinition enemyDefinition) { diff --git a/Assets/_Project/Scripts/Enemies/EnemyProjectile.cs b/Assets/_Project/Scripts/Enemies/EnemyProjectile.cs index 74f5e83..5ed1a4a 100644 --- a/Assets/_Project/Scripts/Enemies/EnemyProjectile.cs +++ b/Assets/_Project/Scripts/Enemies/EnemyProjectile.cs @@ -1,5 +1,6 @@ using BumpCombat.Player; using BumpCombat.Core; +using BumpCombat.Stage; using UnityEngine; namespace BumpCombat.Enemies @@ -82,6 +83,8 @@ namespace BumpCombat.Enemies } bool hitTarget = false; + float targetDistance = float.PositiveInfinity; + float blockerDistance = float.PositiveInfinity; if (distance > 0f) { RaycastHit2D[] hits = Physics2D.CircleCastAll( @@ -92,12 +95,28 @@ namespace BumpCombat.Enemies Physics2D.AllLayers); for (int i = 0; i < hits.Length; i++) { - if (hits[i].collider != null - && hits[i].collider.GetComponentInParent() == target) + Collider2D collider = hits[i].collider; + if (collider == null || collider.isTrigger) { - hitTarget = true; - break; + continue; } + + if (collider.GetComponentInParent() == target) + { + targetDistance = Mathf.Min(targetDistance, hits[i].distance); + } + else if (IsTerrainBlocker(collider)) + { + blockerDistance = Mathf.Min(blockerDistance, hits[i].distance); + } + } + + hitTarget = targetDistance < blockerDistance; + if (!hitTarget && blockerDistance < float.PositiveInfinity) + { + resolved = true; + Destroy(gameObject); + return; } } else @@ -134,6 +153,14 @@ namespace BumpCombat.Enemies } } + private static bool IsTerrainBlocker(Collider2D collider) + { + return collider != null + && (collider.GetComponentInParent() != null + || collider.name.StartsWith("Wall_") + || collider.gameObject.name.Contains("Wall")); + } + private void OnDisable() { resolved = true; diff --git a/Assets/_Project/Scripts/Player/ActiveArtifactController.cs b/Assets/_Project/Scripts/Player/ActiveArtifactController.cs index 338b260..020d743 100644 --- a/Assets/_Project/Scripts/Player/ActiveArtifactController.cs +++ b/Assets/_Project/Scripts/Player/ActiveArtifactController.cs @@ -5,6 +5,7 @@ using BumpCombat.Combat; using BumpCombat.Constants; using BumpCombat.Core; using BumpCombat.Enemies; +using BumpCombat.Progression; using UnityEngine; using UnityEngine.InputSystem; @@ -339,6 +340,13 @@ namespace BumpCombat.Player { UpdateTemporaryPlayerModifiers(); + if (playerHealth != null && playerHealth.CurrentHealth <= 0f) + { + CancelArtifactInput(); + CancelExecution(); + return; + } + if (playerHealth != null && playerHealth.IsHurtMovementLocked) { CancelArtifactInput(); @@ -450,6 +458,7 @@ namespace BumpCombat.Player if (definition == null || IsExecutingArtifact || (dashController != null && dashController.IsDashing) + || (playerHealth != null && playerHealth.CurrentHealth <= 0f) || (playerHealth != null && playerHealth.IsHurtMovementLocked) || (charged && !IsArtifactChargeUnlocked)) { @@ -546,7 +555,7 @@ namespace BumpCombat.Player return true; } - private int GetOwnedArtifactColorCount(ArtifactColor color) + public int GetOwnedArtifactColorCount(ArtifactColor color) { int count = 0; for (int i = 0; i < ownedArtifacts.Count; i++) @@ -1168,6 +1177,7 @@ namespace BumpCombat.Player float nextHitAt = 0f; int hitIndex = 0; int hitCount = GetScheduledHitCount(duration, hitInterval); + bool movementBlocked = false; while (elapsed < duration) { visual.transform.position = body.position @@ -1206,10 +1216,24 @@ namespace BumpCombat.Player yield return new WaitForFixedUpdate(); elapsed += Time.fixedDeltaTime; - body.MovePosition(Vector2.Lerp( - start, - end, - Mathf.Clamp01(elapsed / duration))); + if (!movementBlocked + && playerController != null + && !playerController.IsTransitionLocked) + { + Vector2 intendedEnd = Vector2.Lerp( + start, + end, + Mathf.Clamp01(elapsed / duration)); + Vector2 clampedEnd = playerController.ClampMovementToTerrain( + body.position, + intendedEnd); + movementBlocked = (clampedEnd - intendedEnd).sqrMagnitude > 0.0001f; + body.MovePosition(clampedEnd); + } + else if (playerController != null && playerController.IsTransitionLocked) + { + movementBlocked = true; + } visual.transform.position = body.position + GetCycloneShadowOffsetWorld(); } @@ -1236,7 +1260,6 @@ namespace BumpCombat.Player hitIndex++; } - body.position = end; DestroyVisual(visual); EndExecution(); } @@ -2190,6 +2213,134 @@ namespace BumpCombat.Player return false; } + public bool TryEmitPassiveRelicEcho( + Vector2 origin, + Vector2 direction, + float damageMultiplier) + { + ActiveArtifactDefinition definition = CurrentArtifact; + if (definition == null + || definition.NormalDamage <= 0f + || damageMultiplier <= 0f + || definition.Effect == ActiveArtifactEffect.Pulse) + { + return false; + } + + if (direction.sqrMagnitude < MinimumDirectionSqrMagnitude) + { + direction = definition.Effect == ActiveArtifactEffect.ThunderCrash + ? GetThunderCrashDirection() + : GetUseDirection(); + } + direction.Normalize(); + int castIdentity = ++nextArtifactCastIdentity; + float echoIgniteTickDamage = playerStats.CalculateDamage( + definition.NormalIgniteTickDamage * damageMultiplier, + definition.DamageTag); + if (definition.Effect == ActiveArtifactEffect.ChainLightning) + { + EnemyController first = FindInitialChainTarget(origin, definition.NormalRange); + if (first == null) + { + return false; + } + + bool applied = false; + foreach (ChainStrike strike in PlanArcChain(definition, first, origin)) + { + applied |= ApplyArtifactHit( + strike.Enemy, + definition, + strike.Damage * damageMultiplier, + (strike.End - strike.Start).normalized, + strike.Knockback, + true, + false, + echoIgniteTickDamage, + castIdentity, + true); + } + + return applied; + } + + Vector2 center = origin; + Vector2 radii = new Vector2(definition.NormalRange, definition.NormalRange); + float rayRange = definition.NormalRange; + float rayHalfWidth = Mathf.Max(0.05f, definition.NormalWidth * 0.5f); + switch (definition.Effect) + { + case ActiveArtifactEffect.Cyclone: + center += GetCycloneHitCenterOffset(definition.NormalRange, false); + radii = GetCycloneHitRadii(definition.NormalRange, false); + break; + case ActiveArtifactEffect.ThunderCrash: + center = origin + direction * 0.45f; + radii = GetThunderCrashHitRadii(definition.NormalRange, direction); + break; + } + + List enemies = FindNearestTargets( + definition.Effect == ActiveArtifactEffect.Dash + || definition.Effect == ActiveArtifactEffect.Phoenix + ? origin + direction * (rayRange * 0.5f) + : center, + Mathf.Max(radii.x, radii.y, rayRange), + null); + int hitCount = 0; + bool anyDamage = false; + foreach (EnemyController enemy in enemies) + { + if (hitCount >= definition.NormalMaxTargets) + { + break; + } + + Vector2 position = enemy.GroundAnchorPosition; + Vector2 offset = position - origin; + bool inside = definition.Effect switch + { + ActiveArtifactEffect.Dash or ActiveArtifactEffect.Phoenix => + Vector2.Dot(offset, direction) >= 0f + && Vector2.Dot(offset, direction) <= rayRange + && Mathf.Abs(Vector2.Dot(offset, new Vector2(-direction.y, direction.x))) <= rayHalfWidth, + _ => GetEllipseDistance(position - center, radii) <= 1.0001f, + }; + if (!inside) + { + continue; + } + + Vector2 hitDirection = definition.Effect == ActiveArtifactEffect.Dash + || definition.Effect == ActiveArtifactEffect.Phoenix + ? direction + : (position - center).normalized; + if (hitDirection.sqrMagnitude < MinimumDirectionSqrMagnitude) + { + hitDirection = direction; + } + + bool damaged = ApplyArtifactHit( + enemy, + definition, + definition.NormalDamage * damageMultiplier, + hitDirection, + definition.NormalKnockback, + true, + false, + echoIgniteTickDamage, + castIdentity, + true); + anyDamage |= damaged; + if (damaged) + { + hitCount++; + } + } + + return anyDamage; + } private bool ApplyArtifactHit( EnemyController enemy, ActiveArtifactDefinition definition, @@ -2199,7 +2350,8 @@ namespace BumpCombat.Player bool showsHitFeedback = true, bool charged = false, float igniteTickDamage = 0f, - int castIdentity = 0) + int castIdentity = 0, + bool passiveRelicEcho = false) { if (enemy == null || enemy.IsDead) { @@ -2219,7 +2371,9 @@ namespace BumpCombat.Player direction, knockback, definition.ArtifactColor, - castIdentity); + castIdentity, + true, + EnemyDamageSource.Player); if (result == EnemyArtifactHitResult.Rejected) { return false; @@ -2233,7 +2387,7 @@ namespace BumpCombat.Player if (result == EnemyArtifactHitResult.Shielded) { - if (enemy.LastArtifactContactCounted) + if (!passiveRelicEcho && enemy.LastArtifactContactCounted) { CombatEvents.RaiseArtifactEffectiveHit( enemy.gameObject, @@ -2262,7 +2416,7 @@ namespace BumpCombat.Player igniteTickDamage); } - if (damageWasApplied) + if (damageWasApplied && !passiveRelicEcho) { CombatEvents.RaiseArtifactEffectiveHit( enemy.gameObject, @@ -2283,7 +2437,7 @@ namespace BumpCombat.Player false, false, definition.DamageTag, - definition.ArtifactId, + passiveRelicEcho ? PassiveRelicController.EchoSourceId : definition.ArtifactId, false, result == EnemyArtifactHitResult.DamageApplied && showsHitFeedback, diff --git a/Assets/_Project/Scripts/Player/DashController.cs b/Assets/_Project/Scripts/Player/DashController.cs index 69fa0b5..d95b546 100644 --- a/Assets/_Project/Scripts/Player/DashController.cs +++ b/Assets/_Project/Scripts/Player/DashController.cs @@ -44,7 +44,8 @@ namespace BumpCombat.Player || IsDashing || playerController == null || playerController.IsDamageKnockbackActive - || playerController.IsHurtMovementLocked) + || playerController.IsHurtMovementLocked + || playerController.IsTransitionLocked) { return false; } @@ -81,19 +82,29 @@ namespace BumpCombat.Player : dashDuration; Vector2 start = body.position; Vector2 end = playerController.ClampToArena(start + direction * distance); + Vector2 finalPosition = start; combatResolver.BeginDashPathContactSession(); float elapsed = 0f; while (elapsed < duration) { yield return new WaitForFixedUpdate(); + if (playerController.IsTransitionLocked) + { + break; + } + combatFeedback.EmitDashAfterimage(body.position, isStrongDash); Vector2 segmentStart = body.position; elapsed += Time.fixedDeltaTime; - Vector2 segmentEnd = Vector2.Lerp( + Vector2 intendedEnd = Vector2.Lerp( start, end, Mathf.Clamp01(elapsed / duration)); + Vector2 segmentEnd = playerController.ClampMovementToTerrain( + segmentStart, + intendedEnd); + bool blocked = (segmentEnd - intendedEnd).sqrMagnitude > 0.0001f; Vector2 segment = segmentEnd - segmentStart; if (segment.sqrMagnitude > 0.000001f) { @@ -116,9 +127,14 @@ namespace BumpCombat.Player } body.MovePosition(segmentEnd); + finalPosition = segmentEnd; + if (blocked) + { + break; + } } - body.position = end; + body.position = finalPosition; IsDashing = false; IsStrongDashing = false; animator.SetBool(IsDashingParameter, false); diff --git a/Assets/_Project/Scripts/Player/PlayerController.cs b/Assets/_Project/Scripts/Player/PlayerController.cs index 211aefb..54e56a5 100644 --- a/Assets/_Project/Scripts/Player/PlayerController.cs +++ b/Assets/_Project/Scripts/Player/PlayerController.cs @@ -16,6 +16,8 @@ namespace BumpCombat.Player Animator.StringToHash("Base Layer.BackBumpAttack"); private static readonly int ArtifactUseState = Animator.StringToHash("Base Layer.ArtifactUse"); + private static readonly int DeathState = + Animator.StringToHash("Base Layer.Death"); public const string BumpAttackStateName = "BumpAttack"; public const string BackBumpAttackStateName = "BackBumpAttack"; @@ -45,6 +47,7 @@ namespace BumpCombat.Player public bool IsDamageKnockbackActive { get; private set; } public bool IsHurtMovementLocked => playerHealth != null && playerHealth.IsHurtMovementLocked; + public bool IsTransitionLocked { get; private set; } public Vector2 PlayerClampHalfExtents { get @@ -95,6 +98,23 @@ namespace BumpCombat.Player return TryPlayCombatAnimation(ArtifactUseState, false); } + public float PlayDeathAnimation() + { + if (animator == null + || animator.runtimeAnimatorController == null + || !animator.isActiveAndEnabled + || !animator.HasState(0, DeathState)) + { + return 0f; + } + + SetTransitionLocked(true); + animator.SetBool(IsMovingParameter, false); + animator.Play(DeathState, 0, 0f); + animator.Update(0f); + return Mathf.Max(0f, animator.GetCurrentAnimatorStateInfo(0).length); + } + private bool TryPlayCombatAnimation( int stateHash, bool blockActiveArtifactUse) @@ -148,7 +168,7 @@ namespace BumpCombat.Player private void Update() { - if (!RunManager.GameplayInputEnabled || IsHurtMovementLocked) + if (!RunManager.GameplayInputEnabled || IsHurtMovementLocked || IsTransitionLocked) { SetMoveInput(Vector2.zero); return; @@ -172,6 +192,13 @@ namespace BumpCombat.Player private void FixedUpdate() { AttackIntentVelocity = Vector2.zero; + if (IsTransitionLocked) + { + if (body != null) body.linearVelocity = Vector2.zero; + CurrentVelocity = Vector2.zero; + return; + } + if (dashController != null && dashController.IsDashing) { pendingImpactRecoil = Vector2.zero; @@ -244,6 +271,57 @@ namespace BumpCombat.Player - GameplayConstants.Current.Player.ArenaPadding.y)); } + public Vector2 ClampMovementToTerrain(Vector2 start, Vector2 desiredPosition) + { + Vector2 desired = ClampToArena(desiredPosition); + Vector2 delta = desired - start; + float distance = delta.magnitude; + if (distance <= 0.0001f) + { + return desired; + } + + Collider2D ownCollider = GetComponent(); + float radius = 0.2f; + if (ownCollider != null) + { + Vector2 extents = ownCollider.bounds.extents; + radius = Mathf.Max(0.05f, Mathf.Min(extents.x, extents.y) * 0.9f); + } + + Vector2 direction = delta / distance; + RaycastHit2D[] hits = Physics2D.CircleCastAll( + start, + radius, + direction, + distance, + Physics2D.AllLayers); + float allowedDistance = distance; + for (int i = 0; i < hits.Length; i++) + { + Collider2D collider = hits[i].collider; + if (collider == null + || collider.isTrigger + || collider == ownCollider + || collider.transform.IsChildOf(transform)) + { + continue; + } + + bool isTerrain = collider.GetComponentInParent() != null + || collider.name.StartsWith("Wall_") + || collider.gameObject.name.Contains("Wall"); + if (isTerrain) + { + allowedDistance = Mathf.Min( + allowedDistance, + Mathf.Max(0f, hits[i].distance - 0.02f)); + } + } + + return start + direction * allowedDistance; + } + public bool TryReposition(Vector2 position) { if (body == null) @@ -338,5 +416,36 @@ namespace BumpCombat.Player spriteRenderer.flipX = input.x < 0f; } } + + public void SetTransitionLocked(bool locked) + { + IsTransitionLocked = locked; + if (locked) + { + moveInput = Vector2.zero; + CurrentVelocity = Vector2.zero; + AttackIntentVelocity = Vector2.zero; + if (body != null) + { + body.linearVelocity = Vector2.zero; + } + if (animator != null && animator.isActiveAndEnabled) + { + animator.SetBool(IsMovingParameter, false); + } + } + } + + public void SetFacingDirection(Vector2 direction) + { + if (direction.sqrMagnitude > 0.0001f) + { + FacingDirection = direction.normalized; + if (spriteRenderer != null && !Mathf.Approximately(direction.x, 0f)) + { + spriteRenderer.flipX = direction.x < 0f; + } + } + } } } diff --git a/Assets/_Project/Scripts/Player/PlayerHealth.cs b/Assets/_Project/Scripts/Player/PlayerHealth.cs index cac95d3..b8e7a46 100644 --- a/Assets/_Project/Scripts/Player/PlayerHealth.cs +++ b/Assets/_Project/Scripts/Player/PlayerHealth.cs @@ -2,6 +2,7 @@ using System; using BumpCombat.Combat; using BumpCombat.Core; using BumpCombat.Constants; +using BumpCombat.Defense; using UnityEngine; using UnityEngine.InputSystem; using UnityEngine.InputSystem.LowLevel; @@ -235,8 +236,29 @@ namespace BumpCombat.Player if (CurrentHealth <= 0f) { + float deathAnimationDuration = playerController.PlayDeathAnimation(); OnDied?.Invoke(); - RunManager.Instance?.EndRun(); + RunManager runManager = RunManager.Instance; + if (runManager != null + && runManager.IsDefensePrototypeRun + && DefensePrototypeController.Instance != null + && DefensePrototypeController.Instance.HandlePlayerDied()) + { + return true; + } + if (runManager == null) + { + return true; + } + + if (deathAnimationDuration > 0f) + { + runManager.BeginGameOverPresentation(deathAnimationDuration); + } + else + { + runManager.EndRun(); + } } return true; @@ -247,6 +269,26 @@ namespace BumpCombat.Player return value >= 0f ? value : configuredValue; } + public bool ReviveForDefense(float invulnerabilitySeconds) + { + if (CurrentHealth > 0f) + { + return false; + } + + CurrentHealth = MaxHealth; + invulnerableUntil = Time.time + Mathf.Max(0f, invulnerabilitySeconds); + hurtMovementLockedUntil = 0f; + guardUntil = 0f; + guardCooldownUntil = 0f; + activeGuardHasBlockedAttack = false; + playerController.SetTransitionLocked(false); + animator.Rebind(); + animator.Update(0f); + OnHealthChanged?.Invoke(CurrentHealth, MaxHealth); + return true; + } + public void Heal(float amount) { if (amount <= 0f || CurrentHealth <= 0f) diff --git a/Assets/_Project/Scripts/Progression/ArtifactRewardController.cs b/Assets/_Project/Scripts/Progression/ArtifactRewardController.cs index a05f7f6..0eb9f6d 100644 --- a/Assets/_Project/Scripts/Progression/ArtifactRewardController.cs +++ b/Assets/_Project/Scripts/Progression/ArtifactRewardController.cs @@ -3,6 +3,7 @@ using BumpCombat.Audio; using BumpCombat.Combat; using BumpCombat.Core; using BumpCombat.Player; +using BumpCombat.Defense; using BumpCombat.Spawning; using BumpCombat.UI; using UnityEngine; @@ -30,6 +31,7 @@ namespace BumpCombat.Progression private readonly Queue pendingRewards = new(); private readonly HashSet queuedSources = new(); + private readonly PassiveRelicId[] shownRelics = new PassiveRelicId[DebugOptionCount]; private readonly ActiveArtifactDefinition[] shownArtifacts = new ActiveArtifactDefinition[DebugOptionCount]; private ActiveArtifactController artifacts; @@ -42,6 +44,9 @@ namespace BumpCombat.Progression private int shownCount; private int selectedIndex; private bool debugStartupSelection; + private bool showingPassiveRelicReward; + private bool pendingPassiveRelicReward; + private PassiveRelicController passiveRelics; private int startupSelectionCount; private bool debugStartupSelectionPending; private int debugStartupSelectionEarliestFrame; @@ -148,7 +153,7 @@ namespace BumpCombat.Progression RunManager.Instance?.StartupArtifactSelectionCount ?? 3); } - private void QueueStartupSelection(int selectionCount) + public void QueueStartupSelection(int selectionCount) { startupSelectionCount = Mathf.Max(0, selectionCount); debugStartupSelectionPending = true; @@ -157,15 +162,25 @@ namespace BumpCombat.Progression private bool ChooseOption(int optionIndex) { + if (showingPassiveRelicReward) + { + return ChoosePassiveRelicOption(optionIndex); + } if (panel == null || !panel.activeSelf || optionIndex < 0 - || optionIndex >= shownCount - || !artifacts.TryAddArtifact(shownArtifacts[optionIndex])) + || optionIndex >= shownCount) { return false; } + if (!artifacts.TryAddArtifact(shownArtifacts[optionIndex])) + { + Debug.LogWarning($"[ArtifactRewardController] Could not add artifact {shownArtifacts[optionIndex]?.name}. Closing selection to prevent lock."); + CloseSelection(); + return false; + } + BumpCombatAudioService.Instance?.PlayUi("ui_confirm"); if (artifacts.OwnedArtifactCount == 1) { @@ -201,14 +216,55 @@ namespace BumpCombat.Progression return true; } + public void QueuePassiveRelicReward() + { + pendingPassiveRelicReward = true; + TryOpenPendingReward(); + } + + private bool ChoosePassiveRelicOption(int optionIndex) + { + if (panel == null + || !panel.activeSelf + || optionIndex < 0 + || optionIndex >= shownCount + || passiveRelics == null + || !passiveRelics.TryAcquire(shownRelics[optionIndex])) + { + return false; + } + + showingPassiveRelicReward = false; + pendingPassiveRelicReward = false; + BumpCombatAudioService.Instance?.PlayUi("ui_confirm"); + DefensePrototypeController.Instance?.ResolvePassiveRelicChoice(); + CloseSelection(); + return true; + } private bool OpenStartupSelection() { + if (artifacts == null) + { + artifacts = FindAnyObjectByType(); + } + + if (panel == null) + { + CreatePanel(); + } + if (RunManager.Instance == null || !RunManager.Instance.TryOpenSelection(this)) { return false; } + if (artifacts == null) + { + RunManager.Instance.CloseSelection(this); + return false; + } + debugSelectionsRequired = Mathf.Min( startupSelectionCount, artifacts.MaxOwnedArtifacts - artifacts.OwnedArtifactCount); @@ -243,6 +299,26 @@ namespace BumpCombat.Progression } ArtifactColor requiredColor = GetStartupColor(selectedCount); + if (artifacts.GetOwnedArtifactColorCount(requiredColor) >= artifacts.MaxArtifactsPerColor) + { + bool foundAvailableColor = false; + for (int c = 0; c < StartupColorCount; c++) + { + ArtifactColor candidateColor = GetStartupColor(c); + if (artifacts.GetOwnedArtifactColorCount(candidateColor) < artifacts.MaxArtifactsPerColor) + { + requiredColor = candidateColor; + foundAvailableColor = true; + break; + } + } + + if (!foundAvailableColor) + { + return false; + } + } + for (int i = 0; i < artifacts.CatalogCount && shownCount < DebugOptionCount; i++) @@ -284,6 +360,12 @@ namespace BumpCombat.Progression private void HandleEventEnemyDefeated(RunTimedEvent timedEvent) { + if (Stage.StageManager.IsStageModeActive) + { + // In stage mode, StageManager.HandleMajorBossDefeated coordinates rewards + return; + } + if (timedEvent == RunTimedEvent.Elite) { if (RunManager.Instance?.IsProductionRun == true) @@ -311,8 +393,41 @@ namespace BumpCombat.Progression } } + public void QueueColorReward(ArtifactColor color) + { + if (artifacts == null) + { + artifacts = FindAnyObjectByType(); + } + + if (artifacts == null + || artifacts.OwnedArtifactCount >= artifacts.MaxOwnedArtifacts) + { + return; + } + + if (artifacts.GetOwnedArtifactColorCount(color) >= artifacts.MaxArtifactsPerColor) + { + return; + } + + RewardSource source = color switch + { + ArtifactColor.Red => RewardSource.EliteRed, + ArtifactColor.Blue => RewardSource.EliteBlue, + _ => RewardSource.Elite, + }; + + QueueReward(source); + } + private void QueueReward(RewardSource source) { + if (artifacts == null) + { + artifacts = FindAnyObjectByType(); + } + if (artifacts == null || artifacts.OwnedArtifactCount >= artifacts.MaxOwnedArtifacts) { @@ -330,8 +445,19 @@ namespace BumpCombat.Progression private void TryOpenPendingReward() { - if (pendingRewards.Count == 0 || artifacts == null) + if (artifacts == null) { + artifacts = FindAnyObjectByType(); + } + + if (artifacts == null || (pendingRewards.Count == 0 && !pendingPassiveRelicReward)) + { + return; + } + + if (pendingPassiveRelicReward) + { + TryOpenPassiveRelicReward(); return; } @@ -341,6 +467,11 @@ namespace BumpCombat.Progression return; } + if (panel == null) + { + CreatePanel(); + } + if (RunManager.Instance == null || !RunManager.Instance.TryOpenSelection(this)) { @@ -359,7 +490,8 @@ namespace BumpCombat.Progression if (definition != null && !artifacts.OwnsArtifact(definition) && (!hasRequiredColor - || definition.ArtifactColor == requiredColor)) + || definition.ArtifactColor == requiredColor) + && artifacts.GetOwnedArtifactColorCount(definition.ArtifactColor) < artifacts.MaxArtifactsPerColor) { candidates.Add(definition); } @@ -384,16 +516,65 @@ namespace BumpCombat.Progression titleText.text = source switch { - RewardSource.Elite => "ELITE REWARD - CHOOSE ARTIFACT", + RewardSource.Elite => "ELITE REWARD - CHOOSE RED ARTIFACT", RewardSource.EliteRed => "ELITE REWARD - CHOOSE RED ARTIFACT", RewardSource.EliteBlue => "ELITE REWARD - CHOOSE BLUE ARTIFACT", - RewardSource.MidBoss => "MID BOSS REWARD - CHOOSE ARTIFACT", + RewardSource.MidBoss => "MID BOSS REWARD - CHOOSE BLUE ARTIFACT", _ => "CHOOSE ARTIFACT", }; selectedIndex = 0; ShowOptions(); } + private void TryOpenPassiveRelicReward() + { + passiveRelics = FindAnyObjectByType(); + if (passiveRelics == null || RunManager.Instance == null) + { + return; + } + + if (panel == null) + { + CreatePanel(); + } + + if (!RunManager.Instance.TryOpenSelection(this)) + { + return; + } + + PassiveRelicId[] available = passiveRelics.GetAvailableRelics(); + shownCount = Mathf.Min(3, available.Length); + if (shownCount == 0) + { + RunManager.Instance.CloseSelection(this); + pendingPassiveRelicReward = false; + DefensePrototypeController.Instance?.ResolvePassiveRelicChoice(); + return; + } + + for (int i = 0; i < shownCount; i++) + { + shownRelics[i] = available[i]; + } + + showingPassiveRelicReward = true; + selectedIndex = 0; + titleText.text = "PASSIVE RELIC - CHOOSE ONE"; + ShowOptions(); + } + + private static string GetPassiveRelicName(PassiveRelicId id) + { + return id switch + { + PassiveRelicId.TwinHornTalisman => "01 TWIN HORN TALISMAN\n15% chance: ordinary bump deals 2x damage", + PassiveRelicId.ResonanceCore => "02 RESONANCE CORE\n8% chance: echo current artifact at 50% damage", + PassiveRelicId.RuptureSeed => "03 RUPTURE SEED\n25% chance: player kill explodes for 10% max HP", + _ => id.ToString(), + }; + } private void HandleSelectionChanged(bool isOpen) { if (!isOpen && (panel == null || !panel.activeSelf)) @@ -450,6 +631,13 @@ namespace BumpCombat.Progression continue; } + if (showingPassiveRelicReward) + { + optionIconImages[i].gameObject.SetActive(false); + optionTexts[i].text = GetPassiveRelicName(shownRelics[i]); + continue; + } + ActiveArtifactDefinition definition = shownArtifacts[i]; Sprite icon = PresentationUiStyle.GetArtifactIcon(definition); optionIconImages[i].sprite = icon; @@ -518,9 +706,11 @@ namespace BumpCombat.Progression { switch (source) { + case RewardSource.Elite: case RewardSource.EliteRed: color = ArtifactColor.Red; return true; + case RewardSource.MidBoss: case RewardSource.EliteBlue: color = ArtifactColor.Blue; return true; @@ -605,6 +795,7 @@ namespace BumpCombat.Progression #if UNITY_EDITOR public int DebugShownCount => shownCount; public bool DebugIsSelectionVisible => panel != null && panel.activeSelf; + public int DebugShownPassiveRelicCount => showingPassiveRelicReward ? shownCount : 0; public ActiveArtifactDefinition DebugGetShownArtifact(int index) { diff --git a/Assets/_Project/Scripts/Progression/PassiveRelicController.cs b/Assets/_Project/Scripts/Progression/PassiveRelicController.cs new file mode 100644 index 0000000..f98fa29 --- /dev/null +++ b/Assets/_Project/Scripts/Progression/PassiveRelicController.cs @@ -0,0 +1,268 @@ +using System.Collections.Generic; +using System.Linq; +using BumpCombat.Combat; +using BumpCombat.Constants; +using BumpCombat.Enemies; +using BumpCombat.Player; +using UnityEngine; + +namespace BumpCombat.Progression +{ + public enum PassiveRelicId + { + TwinHornTalisman, + ResonanceCore, + RuptureSeed, + } + + public static class PassiveRelicRules + { + private static readonly PassiveRelicId[] Catalog = + { + PassiveRelicId.TwinHornTalisman, + PassiveRelicId.ResonanceCore, + PassiveRelicId.RuptureSeed, + }; + + public static bool RollsChance(float chance, float roll) => + chance > 0f && roll >= 0f && roll < Mathf.Clamp01(chance); + + public static PassiveRelicId[] GetAvailableRelics( + IReadOnlyCollection owned) + { + List available = new(Catalog.Length); + foreach (PassiveRelicId id in Catalog) + { + if (owned == null || !owned.Contains(id)) + { + available.Add(id); + } + } + + return available.ToArray(); + } + + public static float ApplyTwinHornDamage( + float damage, + bool ownsRelic, + bool isOrdinaryBump, + bool hasArtifactColor, + float chance, + float roll) + { + return ownsRelic + && isOrdinaryBump + && !hasArtifactColor + && RollsChance(chance, roll) + ? damage * 2f + : damage; + } + public static float GetExplosionDamage(float maximumHealth, float fraction) => + Mathf.Max(0f, maximumHealth) * Mathf.Clamp01(fraction); + + public static float GetExplosionRadius(float bodyDiameter, float diameterMultiplier) => + Mathf.Max(0f, bodyDiameter) * Mathf.Max(0f, diameterMultiplier); + + public static bool IsPlayerCreditedKill(EnemyDamageSource source) => + source == EnemyDamageSource.Player; + } + + public sealed class PassiveRelicController : MonoBehaviour + { + public const string EchoSourceId = "passive_relic_echo"; + + private readonly HashSet owned = new(); + private readonly HashSet processedDeaths = new(); + private ActiveArtifactController artifacts; + private PlayerController player; + private Collider2D playerCollider; + private PlayerHealth playerHealth; + private bool active; + private float nextEchoAllowedAt; + + public static PassiveRelicController Instance { get; private set; } + public int OwnedCount => owned.Count; + public bool Owns(PassiveRelicId id) => owned.Contains(id); + public int AvailableCount => GetAvailableRelics().Length; + +#if UNITY_EDITOR + public static System.Func RandomRollForTests { get; set; } +#endif + + private void Awake() + { + if (Instance != null && Instance != this) + { + Destroy(this); + return; + } + + Instance = this; + } + + private void OnEnable() + { + CombatEvents.OnValidHit += HandleValidHit; + CombatEvents.OnEnemyDied += HandleEnemyDied; + } + + private void OnDisable() + { + active = false; + CombatEvents.OnValidHit -= HandleValidHit; + CombatEvents.OnEnemyDied -= HandleEnemyDied; + } + +private void OnDestroy() + { + if (Instance == this) + { + Instance = null; + } + } + + public void BeginDefenseRun() + { + owned.Clear(); + processedDeaths.Clear(); + nextEchoAllowedAt = 0f; + player = GetComponent(); + playerCollider = GetComponent(); + playerHealth = GetComponent(); + artifacts = GetComponent(); + active = true; + } + + public void EndDefenseRun() + { + active = false; + } + + public PassiveRelicId[] GetAvailableRelics() => + PassiveRelicRules.GetAvailableRelics(owned); + + public bool TryAcquire(PassiveRelicId id) + { + if (!active || !System.Array.Exists(GetAvailableRelics(), candidate => candidate == id)) + { + return false; + } + + return owned.Add(id); + } + + public float ApplyOrdinaryBumpDamageMultiplier( + float damage, + bool isOrdinaryBump, + bool hasArtifactColor) + { + if (!active || !owned.Contains(PassiveRelicId.TwinHornTalisman)) + { + return damage; + } + + return PassiveRelicRules.ApplyTwinHornDamage( + damage, + true, + isOrdinaryBump, + hasArtifactColor, + GameplayConstants.Current.PassiveRelics.TwinHornDoubleDamageChance, + GetRandomRoll()); + } + private void HandleValidHit(CombatHitResult hit) + { + if (!active + || !owned.Contains(PassiveRelicId.ResonanceCore) + || hit.Attacker != gameObject + || hit.Damage <= 0f + || playerHealth == null + || playerHealth.CurrentHealth <= 0f + || hit.SourceId == EchoSourceId + || Time.time < nextEchoAllowedAt + || !RollsChance(GameplayConstants.Current.PassiveRelics.ResonanceEchoChance) + || artifacts == null) + { + return; + } + + if (artifacts.TryEmitPassiveRelicEcho( + hit.HitPosition, + hit.KnockbackDirection, + GameplayConstants.Current.PassiveRelics.ResonanceEchoDamageMultiplier)) + { + nextEchoAllowedAt = Time.time + + GameplayConstants.Current.PassiveRelics.ResonanceEchoCooldownSeconds; + } + } + + private void HandleEnemyDied(GameObject target) + { + if (!active || !owned.Contains(PassiveRelicId.RuptureSeed) || target == null) + { + return; + } + + EnemyController victim = target.GetComponent(); + if (victim == null + || !PassiveRelicRules.IsPlayerCreditedKill(victim.LastDamageSource) + || !processedDeaths.Add(victim.SpawnLifetimeIdentity) + || !RollsChance(GameplayConstants.Current.PassiveRelics.RuptureSeedChance)) + { + return; + } + + float damage = PassiveRelicRules.GetExplosionDamage( + victim.MaximumHealthAtDeath, + GameplayConstants.Current.PassiveRelics.RuptureExplosionMaxHealthFraction); + float diameter = playerCollider != null + ? Mathf.Max(playerCollider.bounds.size.x, playerCollider.bounds.size.y) + : 0f; + float radius = PassiveRelicRules.GetExplosionRadius( + diameter, + GameplayConstants.Current.PassiveRelics.RuptureExplosionPlayerBodyDiameters); + if (damage <= 0f || radius <= 0f || playerHealth == null || playerHealth.CurrentHealth <= 0f) + { + return; + } + + Collider2D[] hits = Physics2D.OverlapCircleAll(victim.transform.position, radius); + HashSet damagedEnemies = new(); + foreach (Collider2D hit in hits) + { + EnemyController enemy = hit != null ? hit.GetComponentInParent() : null; + if (enemy == null + || enemy == victim + || enemy.IsDead + || !damagedEnemies.Add(enemy)) + { + continue; + } + + Vector2 direction = ((Vector2)enemy.transform.position - (Vector2)victim.transform.position).normalized; + enemy.TryTakeDamage( + damage, + direction, + 0f, + true, + false, + EnemyDamageSource.PassiveRelicExplosion); + } + } + + private static float GetRandomRoll() + { +#if UNITY_EDITOR + if (RandomRollForTests != null) + { + return RandomRollForTests(); + } +#endif + return Random.value; + } + + private static bool RollsChance(float chance) + { +return PassiveRelicRules.RollsChance(chance, GetRandomRoll()); + } + } +} diff --git a/Assets/_Project/Scripts/Progression/PassiveRelicController.cs.meta b/Assets/_Project/Scripts/Progression/PassiveRelicController.cs.meta new file mode 100644 index 0000000..f762270 --- /dev/null +++ b/Assets/_Project/Scripts/Progression/PassiveRelicController.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 6b531569e5b6419493b2af9d6a0be4ee +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/_Project/Scripts/Spawning/SpawnDirector.cs b/Assets/_Project/Scripts/Spawning/SpawnDirector.cs index d03e2e9..f830557 100644 --- a/Assets/_Project/Scripts/Spawning/SpawnDirector.cs +++ b/Assets/_Project/Scripts/Spawning/SpawnDirector.cs @@ -44,6 +44,7 @@ namespace BumpCombat.Spawning // current elite is still alive and the event must wait in the queue. private int nextEliteScheduleOrdinal; private int nextEliteSpawnOrdinal; + private static int nextEncounterGroupId; private const int SummonSpawnAngleSteps = 32; private static RunConstants Tuning => GameplayConstants.Current.Run; @@ -148,6 +149,477 @@ namespace BumpCombat.Spawning return HasTimedEventEnemy(timedEvent); } + public void SpawnZoneEncounter(BumpCombat.Stage.ZoneInstance zone) + { + if (zone == null) return; + EnsureTuningLoaded(); + Vector2 center = zone.ZoneCenter; + Vector2 extents = zone.PlayableHalfExtents; + BumpCombat.Stage.RoomPreset preset = zone.Preset; + + if (zone.NodeType == BumpCombat.Stage.StageNodeType.Elite) + { + Vector2 spawnPos = center; + if (preset != null && preset.SpawnPoints.Count > 0) + { + var pt = preset.SpawnPoints.Find(p => p.Role == BumpCombat.Stage.SpawnRole.Elite); + if (pt.Role == BumpCombat.Stage.SpawnRole.Elite) + { + spawnPos = preset.CellToWorldPosition(center, pt.CellPosition); + } + } + + RunEventEnemyTuning tuning = GetEliteTuning(0); + EnemyController prefab = GetEventPrefab(RunTimedEvent.Elite, tuning.PrefabKind); + if (prefab != null) + { + EnemyController enemy = Instantiate(prefab, spawnPos, Quaternion.identity); + enemy.name = "Stage 1 Elite"; + enemy.ConfigureRunEventEnemy(RunTimedEvent.Elite, tuning); + enemy.OnDied += HandleEventEnemyDied; + GetEventList(RunTimedEvent.Elite).Add(enemy); + zone.RegisterSpawnedEnemy(enemy); + OnEventEnemySpawned?.Invoke(RunTimedEvent.Elite, enemy); + } + + SpawnPresetSupportMinions(zone, center, preset); + return; + } + + if (zone.NodeType == BumpCombat.Stage.StageNodeType.MidBoss) + { + Vector2 spawnPos = center; + if (preset != null && preset.SpawnPoints.Count > 0) + { + var pt = preset.SpawnPoints.Find(p => p.Role == BumpCombat.Stage.SpawnRole.MidBoss); + if (pt.Role == BumpCombat.Stage.SpawnRole.MidBoss) + { + spawnPos = preset.CellToWorldPosition(center, pt.CellPosition); + } + } + + RunEventEnemyTuning tuning = GetEventEnemyTuning(RunTimedEvent.MidBoss); + EnemyController prefab = GetEventPrefab(RunTimedEvent.MidBoss, tuning.PrefabKind); + if (prefab != null) + { + EnemyController enemy = Instantiate(prefab, spawnPos, Quaternion.identity); + enemy.name = "Stage 2 MidBoss"; + enemy.ConfigureRunEventEnemy(RunTimedEvent.MidBoss, tuning); + enemy.OnDied += HandleEventEnemyDied; + GetEventList(RunTimedEvent.MidBoss).Add(enemy); + zone.RegisterSpawnedEnemy(enemy); + OnEventEnemySpawned?.Invoke(RunTimedEvent.MidBoss, enemy); + } + + SpawnPresetSupportMinions(zone, center, preset); + return; + } + + if (zone.NodeType == BumpCombat.Stage.StageNodeType.FinalBoss) + { + Vector2 spawnPos = center; + if (preset != null && preset.SpawnPoints.Count > 0) + { + var pt = preset.SpawnPoints.Find(p => p.Role == BumpCombat.Stage.SpawnRole.FinalBoss); + if (pt.Role == BumpCombat.Stage.SpawnRole.FinalBoss) + { + spawnPos = preset.CellToWorldPosition(center, pt.CellPosition); + } + } + + RunEventEnemyTuning tuning = GetEventEnemyTuning(RunTimedEvent.FinalBoss); + EnemyController prefab = GetEventPrefab(RunTimedEvent.FinalBoss, tuning.PrefabKind); + if (prefab != null) + { + EnemyController enemy = Instantiate(prefab, spawnPos, Quaternion.identity); + enemy.name = "Stage 3 FinalBoss"; + enemy.ConfigureRunEventEnemy(RunTimedEvent.FinalBoss, tuning); + enemy.OnDied += HandleEventEnemyDied; + GetEventList(RunTimedEvent.FinalBoss).Add(enemy); + zone.RegisterSpawnedEnemy(enemy); + OnEventEnemySpawned?.Invoke(RunTimedEvent.FinalBoss, enemy); + } + + SpawnPresetSupportMinions(zone, center, preset); + return; + } + + if (zone.NodeType == BumpCombat.Stage.StageNodeType.Combat || zone.NodeType == BumpCombat.Stage.StageNodeType.Fork) + { + if (preset != null && preset.EncounterPool != null && preset.EncounterPool.Count > 0) + { + SpawnPresetEncounterPool(zone, center, preset); + return; + } + + if (preset != null && preset.SpawnPoints.Count > 0) + { + SpawnPresetAnchorsFallback(zone, center, preset); + return; + } + + // Fallback: Random area spawn if no preset anchors + if (enemyPrefabs == null || enemyPrefabs.Length == 0) return; + int count = UnityEngine.Random.Range(3, 6); + for (int i = 0; i < count; i++) + { + EnemyRole role = (i % 2 == 0) ? EnemyRole.Normal : EnemyRole.Crowd; + EnemyController prefab = GetRandomPrefab(role) ?? enemyPrefabs[0]; + Vector2 offset = new Vector2( + UnityEngine.Random.Range(-extents.x * 0.6f, extents.x * 0.6f), + UnityEngine.Random.Range(-extents.y * 0.6f, extents.y * 0.6f)); + EnemyController enemy = Instantiate(prefab, center + offset, Quaternion.identity); + enemy.transform.SetParent(zone.transform, true); + zone.RegisterSpawnedEnemy(enemy); + } + } + } + + private void SpawnPresetEncounterPool(BumpCombat.Stage.ZoneInstance zone, Vector2 center, BumpCombat.Stage.RoomPreset preset) + { + if (preset.EncounterPool == null || preset.EncounterPool.Count == 0) + { + SpawnPresetAnchorsFallback(zone, center, preset); + return; + } + + // Select encounter (default Encounter A for S1-A first verification) + BumpCombat.Stage.RoomEncounter encounter = preset.EncounterPool[0]; + if (encounter.Items == null || encounter.Items.Count == 0) + { + SpawnPresetAnchorsFallback(zone, center, preset); + return; + } + + PlayerController player = FindAnyObjectByType(); + Vector2 playerPos = (player != null) + ? (Vector2)player.transform.position + : zone.GetEntryPosition(BumpCombat.Stage.StageDirection.West); + + List occupiedPositions = new(); + + // Sort items so NormalMelee / Ranged claim authored anchors first before Crowd + List sortedItems = new(encounter.Items); + sortedItems.Sort((a, b) => + { + bool aCrowd = a.EnemyKind.Equals("Bat", StringComparison.OrdinalIgnoreCase) || a.EnemyKind.Equals("Slime", StringComparison.OrdinalIgnoreCase); + bool bCrowd = b.EnemyKind.Equals("Bat", StringComparison.OrdinalIgnoreCase) || b.EnemyKind.Equals("Slime", StringComparison.OrdinalIgnoreCase); + return aCrowd.CompareTo(bCrowd); + }); + + foreach (var item in sortedItems) + { + EnemyController prefab = ResolvePrefabForKind(item.EnemyKind); + if (prefab == null) continue; + + bool isFast = item.EnemyKind.Equals("Bat", StringComparison.OrdinalIgnoreCase); + bool isRanged = item.EnemyKind.Contains("Archer", StringComparison.OrdinalIgnoreCase); + float requiredSafeDist = isRanged ? 7.0f : (isFast ? 5.0f : 4.0f); + + int remaining = item.Count; + while (remaining > 0) + { + int groupSize = Mathf.Min( + SelectEncounterGroupSize(preset.StageIndex, UnityEngine.Random.value), + remaining); + nextEncounterGroupId = nextEncounterGroupId == int.MaxValue + ? 1 + : nextEncounterGroupId + 1; + int groupId = nextEncounterGroupId; + Vector2 groupAnchor = Vector2.zero; + + for (int memberIndex = 0; memberIndex < groupSize; memberIndex++) + { + Vector2 spawnWorldPos = memberIndex == 0 + ? FindSafeSpawnPosition( + preset, + center, + playerPos, + item.EnemyKind, + requiredSafeDist, + occupiedPositions) + : FindSafeGroupSpawnPosition( + preset, + center, + playerPos, + requiredSafeDist, + groupAnchor, + occupiedPositions); + + if (memberIndex == 0) + { + groupAnchor = spawnWorldPos; + } + + EnemyController enemy = Instantiate(prefab, spawnWorldPos, Quaternion.identity); + enemy.name = $"{item.EnemyKind}_{occupiedPositions.Count + 1}"; + enemy.transform.SetParent(zone.transform, true); + enemy.ConfigureSightRangeMultiplier( + GameplayConstants.Current.Run.StageEncounterSightRangeMultiplier); + enemy.ConfigureEncounterGroup(groupId, groupAnchor); + zone.RegisterSpawnedEnemy(enemy); + occupiedPositions.Add(spawnWorldPos); + } + + remaining -= groupSize; + } + } + } + + public static int SelectEncounterGroupSize(int stageIndex, float roll) + { + roll = Mathf.Clamp01(roll); + if (stageIndex <= 1) + { + if (roll < 0.55f) return 1; + return roll < 0.90f ? 2 : 3; + } + if (stageIndex == 2) + { + if (roll < 0.35f) return 1; + return roll < 0.80f ? 2 : 3; + } + + if (roll < 0.20f) return 1; + return roll < 0.70f ? 2 : 3; + } + + private Vector2 FindSafeGroupSpawnPosition( + BumpCombat.Stage.RoomPreset preset, + Vector2 zoneCenter, + Vector2 playerPos, + float safeDist, + Vector2 groupAnchor, + List occupied) + { + EnemyConstants tuning = GameplayConstants.Current.Enemies; + Vector2 best = groupAnchor; + float bestDistance = float.MaxValue; + + for (int cy = 0; cy < preset.GridHeight; cy++) + { + for (int cx = 0; cx < preset.GridWidth; cx++) + { + if (preset.GridMap[cx, cy] != BumpCombat.Stage.PresetCellType.Walkable) + { + continue; + } + + Vector2 candidate = preset.CellToWorldPosition( + zoneCenter, + new Vector2Int(cx, cy)); + float groupDistance = Vector2.Distance(groupAnchor, candidate); + if (groupDistance < tuning.EncounterGroupMinSpacing + || groupDistance > tuning.EncounterGroupMaxSpacing + || Vector2.Distance(playerPos, candidate) < safeDist + || !IsSeparatedFromOccupied(candidate, occupied, 0.8f)) + { + continue; + } + + float targetDistance = Mathf.Abs( + groupDistance + - (tuning.EncounterGroupMinSpacing + tuning.EncounterGroupMaxSpacing) * 0.5f); + if (targetDistance < bestDistance) + { + bestDistance = targetDistance; + best = candidate; + } + } + } + + return bestDistance < float.MaxValue + ? best + : FindSafeSpawnPosition( + preset, + zoneCenter, + playerPos, + string.Empty, + safeDist, + occupied); + } + + private Vector2 FindSafeSpawnPosition(BumpCombat.Stage.RoomPreset preset, Vector2 zoneCenter, Vector2 playerPos, + string enemyKind, float safeDist, List occupied) + { + BumpCombat.Stage.SpawnRole targetRole = BumpCombat.Stage.SpawnRole.NormalMelee; + if (enemyKind.Equals("Bat", StringComparison.OrdinalIgnoreCase) || enemyKind.Equals("Slime", StringComparison.OrdinalIgnoreCase)) + { + targetRole = BumpCombat.Stage.SpawnRole.Crowd; + } + else if (enemyKind.Contains("Archer", StringComparison.OrdinalIgnoreCase)) + { + targetRole = BumpCombat.Stage.SpawnRole.Ranged; + } + + // 1. Check matching preset anchors first + for (int i = 0; i < preset.SpawnPoints.Count; i++) + { + var pt = preset.SpawnPoints[i]; + if (pt.Role != targetRole) continue; + + Vector2 wPos = preset.CellToWorldPosition(zoneCenter, pt.CellPosition); + if (Vector2.Distance(playerPos, wPos) >= safeDist && IsSeparatedFromOccupied(wPos, occupied, 0.8f)) + { + return wPos; + } + } + + // 2. Search spiral from anchor or center in Walkable cells + Vector2Int originCell = (targetRole == BumpCombat.Stage.SpawnRole.Crowd && preset.SpawnPoints.Count > 0) + ? preset.SpawnPoints.Find(p => p.Role == BumpCombat.Stage.SpawnRole.Crowd).CellPosition + : new Vector2Int(preset.GridWidth / 2, preset.GridHeight / 2); + + if (originCell.x < 0 || originCell.x >= preset.GridWidth) originCell = new Vector2Int(preset.GridWidth / 2, preset.GridHeight / 2); + + int maxRadius = Mathf.Max(preset.GridWidth, preset.GridHeight); + for (int r = 0; r <= maxRadius; r++) + { + for (int dy = -r; dy <= r; dy++) + { + for (int dx = -r; dx <= r; dx++) + { + if (Mathf.Abs(dx) != r && Mathf.Abs(dy) != r) continue; + + int cx = originCell.x + dx; + int cy = originCell.y + dy; + if (cx < 0 || cx >= preset.GridWidth || cy < 0 || cy >= preset.GridHeight) continue; + + if (preset.GridMap[cx, cy] != BumpCombat.Stage.PresetCellType.Walkable) continue; + + Vector2 candidatePos = preset.CellToWorldPosition(zoneCenter, new Vector2Int(cx, cy)); + if (Vector2.Distance(playerPos, candidatePos) >= safeDist && IsSeparatedFromOccupied(candidatePos, occupied, 0.8f)) + { + return candidatePos; + } + } + } + } + + // Fallback: farthest walkable cell from player + Vector2 farthestPos = zoneCenter; + float maxDist = -1f; + for (int cy = 0; cy < preset.GridHeight; cy++) + { + for (int cx = 0; cx < preset.GridWidth; cx++) + { + if (preset.GridMap[cx, cy] != BumpCombat.Stage.PresetCellType.Walkable) continue; + Vector2 p = preset.CellToWorldPosition(zoneCenter, new Vector2Int(cx, cy)); + float d = Vector2.Distance(playerPos, p); + if (d > maxDist && IsSeparatedFromOccupied(p, occupied, 0.6f)) + { + maxDist = d; + farthestPos = p; + } + } + } + return farthestPos; + } + + private static bool IsSeparatedFromOccupied(Vector2 pos, List occupied, float minSeparation) + { + for (int i = 0; i < occupied.Count; i++) + { + if (Vector2.Distance(pos, occupied[i]) < minSeparation) + { + return false; + } + } + return true; + } + + private void SpawnPresetAnchorsFallback(BumpCombat.Stage.ZoneInstance zone, Vector2 center, BumpCombat.Stage.RoomPreset preset) + { + foreach (var spawnPoint in preset.SpawnPoints) + { + if (spawnPoint.Role == BumpCombat.Stage.SpawnRole.Elite || + spawnPoint.Role == BumpCombat.Stage.SpawnRole.MidBoss || + spawnPoint.Role == BumpCombat.Stage.SpawnRole.FinalBoss) + { + continue; + } + + EnemyController prefab = ResolvePrefabForRole(spawnPoint.Role); + if (prefab != null) + { + Vector2 worldPos = preset.CellToWorldPosition(center, spawnPoint.CellPosition); + EnemyController enemy = Instantiate(prefab, worldPos, Quaternion.identity); + enemy.transform.SetParent(zone.transform, true); + zone.RegisterSpawnedEnemy(enemy); + } + } + } + + private EnemyController ResolvePrefabForKind(string kind) + { + if (string.IsNullOrEmpty(kind)) return null; + + if (Enum.TryParse(kind, true, out EnemyKind parsedKind)) + { + EnemyController prefab = GetPrefab(parsedKind); + if (prefab != null) return prefab; + } + + string lower = kind.ToLowerInvariant(); + if (lower.Contains("bat")) return GetPrefab(EnemyKind.Bat) ?? GetRandomPrefab(EnemyRole.Crowd); + if (lower.Contains("archer")) return GetPrefab(EnemyKind.SkeletonArcher) ?? GetRandomPrefab(EnemyRole.Normal); + if (lower.Contains("skeleton")) return GetPrefab(EnemyKind.Skeleton) ?? GetRandomPrefab(EnemyRole.Normal); + if (lower.Contains("slime")) return GetPrefab(EnemyKind.Slime) ?? GetRandomPrefab(EnemyRole.Crowd); + if (lower.Contains("golem")) return GetPrefab(EnemyKind.NecroGolem) ?? GetRandomPrefab(EnemyRole.Normal); + + return null; + } + + private void SpawnPresetSupportMinions(BumpCombat.Stage.ZoneInstance zone, Vector2 center, BumpCombat.Stage.RoomPreset preset) + { + if (preset == null || preset.SpawnPoints.Count == 0) return; + + foreach (var pt in preset.SpawnPoints) + { + if (pt.Role == BumpCombat.Stage.SpawnRole.Elite || + pt.Role == BumpCombat.Stage.SpawnRole.MidBoss || + pt.Role == BumpCombat.Stage.SpawnRole.FinalBoss) + { + continue; + } + + EnemyController prefab = ResolvePrefabForRole(pt.Role); + if (prefab != null) + { + Vector2 worldPos = preset.CellToWorldPosition(center, pt.CellPosition); + EnemyController minion = Instantiate(prefab, worldPos, Quaternion.identity); + zone.RegisterSpawnedEnemy(minion); + } + } + } + + private EnemyController ResolvePrefabForRole(BumpCombat.Stage.SpawnRole role) + { + switch (role) + { + case BumpCombat.Stage.SpawnRole.Ranged: + return GetPrefab(EnemyKind.SkeletonArcher) + ?? GetPrefab(EnemyKind.Warlock) + ?? GetPrefab(EnemyKind.Necrofire) + ?? GetRandomPrefab(EnemyRole.Normal) + ?? (enemyPrefabs != null && enemyPrefabs.Length > 0 ? enemyPrefabs[0] : null); + + case BumpCombat.Stage.SpawnRole.Crowd: + return GetPrefab(EnemyKind.Bat) + ?? GetPrefab(EnemyKind.Slime) + ?? GetRandomPrefab(EnemyRole.Crowd) + ?? (enemyPrefabs != null && enemyPrefabs.Length > 0 ? enemyPrefabs[0] : null); + + case BumpCombat.Stage.SpawnRole.NormalMelee: + default: + return GetPrefab(EnemyKind.Skeleton) + ?? GetPrefab(EnemyKind.Lancer) + ?? GetPrefab(EnemyKind.GreatswordSkeleton) + ?? GetRandomPrefab(EnemyRole.Normal) + ?? (enemyPrefabs != null && enemyPrefabs.Length > 0 ? enemyPrefabs[0] : null); + } + } + private void Start() { if (RunManager.Instance != null) @@ -175,6 +647,11 @@ namespace BumpCombat.Spawning { return; } + if (BumpCombat.Stage.StageManager.IsStageModeActive + || RunManager.Instance?.IsDefensePrototypeRun == true) + { + return; + } TrySpawnPendingEvents(); float elapsedTime = RunManager.Instance?.ProgressionTime ?? 0f; bool productionRun = RunManager.Instance?.IsProductionRun == true; @@ -732,11 +1209,21 @@ namespace BumpCombat.Spawning if (role == RunTimedEvent.MidBoss) { midBossDefeated = true; - RunManager.Instance?.UnlockArtifactCharge(); + BumpCombat.Stage.StageManager.Instance?.HandleMajorBossDefeated( + BumpCombat.Stage.StageNodeType.MidBoss, + enemy.GetComponentInParent()); } else if (role == RunTimedEvent.Elite) { - RunManager.Instance?.UnlockGuard(); + BumpCombat.Stage.StageManager.Instance?.HandleMajorBossDefeated( + BumpCombat.Stage.StageNodeType.Elite, + enemy.GetComponentInParent()); + } + else if (role == RunTimedEvent.FinalBoss) + { + BumpCombat.Stage.StageManager.Instance?.HandleMajorBossDefeated( + BumpCombat.Stage.StageNodeType.FinalBoss, + enemy.GetComponentInParent()); } OnEventEnemyDefeated?.Invoke(role); @@ -837,6 +1324,18 @@ namespace BumpCombat.Spawning return null; } + public EnemyController SpawnDefenseSkeleton(Vector2 position) + { + EnsureTuningLoaded(); + EnemyController prefab = GetPrefab(EnemyKind.Skeleton); + if (prefab == null) + { + return null; + } + + return Instantiate(prefab, position, Quaternion.identity); + } + private EnemyController GetPrefab(EnemyKind kind) { if (enemyPrefabs == null) @@ -1254,11 +1753,20 @@ namespace BumpCombat.Spawning return false; } - EnemyRole role = crowd ? EnemyRole.Crowd : EnemyRole.Normal; EnemyController[] candidates = new EnemyController[count]; for (int i = 0; i < count; i++) { - candidates[i] = GetRandomPrefab(role); + if (crowd) + { + EnemyKind preferredKind = i % 2 == 0 ? EnemyKind.Bat : EnemyKind.Slime; + EnemyKind fallbackKind = preferredKind == EnemyKind.Bat ? EnemyKind.Slime : EnemyKind.Bat; + candidates[i] = GetPrefab(preferredKind) ?? GetPrefab(fallbackKind); + } + else + { + candidates[i] = GetRandomPrefab(EnemyRole.Normal); + } + if (candidates[i] == null) { return false; @@ -1405,6 +1913,14 @@ namespace BumpCombat.Spawning { summonedEnemies.Add(enemy); enemy.OnDied += HandleSummonedEnemyDied; + + EnemyController owner = enemy.SummonOwner; + BumpCombat.Stage.ZoneInstance ownerZone = + owner != null ? owner.GetComponentInParent() : null; + if (ownerZone != null) + { + ownerZone.RegisterSpawnedEnemy(enemy); + } } private void HandleSummonedEnemyDied(EnemyController enemy) diff --git a/Assets/_Project/Scripts/Stage.meta b/Assets/_Project/Scripts/Stage.meta new file mode 100644 index 0000000..a961866 --- /dev/null +++ b/Assets/_Project/Scripts/Stage.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: e815e053fa79d1d48937f5a46191e144 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/_Project/Scripts/Stage/LevelPresetCatalog.cs b/Assets/_Project/Scripts/Stage/LevelPresetCatalog.cs new file mode 100644 index 0000000..ad2df67 --- /dev/null +++ b/Assets/_Project/Scripts/Stage/LevelPresetCatalog.cs @@ -0,0 +1,1147 @@ +using System; +using System.Collections.Generic; +using UnityEngine; + +namespace BumpCombat.Stage +{ + public static class LevelPresetCatalog + { + public const string S1AComparisonExpandedPresetId = "GRAVE_S1_COMBAT_A_EXPANDED"; + + private static readonly Dictionary catalog = new(); + private static bool initialized; + + public static IReadOnlyDictionary AllPresets + { + get + { + EnsureInitialized(); + return catalog; + } + } + + public static void EnsureInitialized() + { + if (initialized) return; + initialized = true; + + // Stage 1 + RegisterPreset(BuildS1A()); + RegisterPreset(BuildS1AExpandedComparison()); + RegisterPreset(BuildS1B()); + RegisterPreset(BuildS1C()); + RegisterPreset(BuildS1D()); + + // Stage 2 + RegisterPreset(BuildS2A()); + RegisterPreset(BuildS2B()); + RegisterPreset(BuildS2C()); + RegisterPreset(BuildS2D()); + + // Stage 3 + RegisterPreset(BuildS3A()); + RegisterPreset(BuildS3B()); + RegisterPreset(BuildS3C()); + RegisterPreset(BuildS3D()); + + // Safe nodes (Entrance, Reward, Recovery, Exit) for Stages 1, 2, 3 + RegisterSafePresets(); + } + + public static RoomPreset GetPreset(string presetId) + { + EnsureInitialized(); + if (string.IsNullOrEmpty(presetId)) return null; + + if (catalog.TryGetValue(presetId, out var preset)) + { + return preset; + } + + // Case-insensitive / shortcut check (e.g. "S1-A" for "GRAVE_S1_COMBAT_A") + foreach (var kvp in catalog) + { + if (string.Equals(kvp.Key, presetId, StringComparison.OrdinalIgnoreCase) || + string.Equals(kvp.Value.PresetId, presetId, StringComparison.OrdinalIgnoreCase) || + string.Equals(kvp.Value.DisplayName, presetId, StringComparison.OrdinalIgnoreCase)) + { + return kvp.Value; + } + } + + return null; + } + + public static bool IsExpandedComparisonPreset(RoomPreset preset) + { + return preset != null + && string.Equals( + preset.PresetId, + S1AComparisonExpandedPresetId, + StringComparison.OrdinalIgnoreCase); + } + + public static RoomPreset GetPresetForNode(int stageIndex, StageNodeType nodeType, int nodeId, int seed) + { + EnsureInitialized(); + + string key = nodeType switch + { + StageNodeType.Entrance => $"S{stageIndex}_ENTRANCE", + StageNodeType.Combat => SelectCombatPresetId(stageIndex, nodeId, seed), + StageNodeType.Fork => $"GRAVE_S{stageIndex}_{(stageIndex == 1 ? "FORK" : (stageIndex == 2 ? "FORK_HAZARD" : "PREBOSS_FORK"))}", + StageNodeType.Reward => $"S{stageIndex}_REWARD", + StageNodeType.Recovery => $"S{stageIndex}_RECOVERY", + StageNodeType.Elite => "GRAVE_S1_ELITE_ARENA", + StageNodeType.MidBoss => "GRAVE_S2_MIDBOSS", + StageNodeType.FinalBoss => "GRAVE_S3_FINAL_NECROMANCER", + StageNodeType.StageExit => $"S{stageIndex}_EXIT", + _ => $"S{stageIndex}_COMBAT_A", + }; + + RoomPreset preset = GetPreset(key); + if (preset == null) + { + // Fallback to S1-A + preset = catalog["GRAVE_S1_COMBAT_A"]; + } + return preset; + } + + private static string SelectCombatPresetId(int stageIndex, int nodeId, int seed) + { + int hash = (seed ^ (nodeId * 37) ^ (stageIndex * 101)) & 0x7FFFFFFF; + return stageIndex switch + { + 1 => (hash % 2 == 0) ? "GRAVE_S1_COMBAT_A" : "GRAVE_S1_COMBAT_B", + 2 => (hash % 2 == 0) ? "GRAVE_S2_COMBAT_A" : "GRAVE_S2_COMBAT_B", + 3 => (hash % 2 == 0) ? "GRAVE_S3_COMBAT_A" : "GRAVE_S3_COMBAT_B", + _ => "GRAVE_S1_COMBAT_A", + }; + } + + private static void RegisterPreset(RoomPreset preset) + { + if (preset == null) return; + NormalizeSocketCells(preset); + ValidatePresetGeometryAndAnchors(preset); + catalog[preset.PresetId] = preset; + } + + private static void NormalizeSocketCells(RoomPreset preset) + { + if (preset.GridMap == null) return; + + for (int y = 0; y < preset.GridHeight; y++) + { + for (int x = 0; x < preset.GridWidth; x++) + { + bool isPerimeter = x == 0 || x == preset.GridWidth - 1 || y == 0 || y == preset.GridHeight - 1; + if (isPerimeter && preset.GridMap[x, y] == PresetCellType.Socket) + { + preset.GridMap[x, y] = PresetCellType.Empty; + } + } + } + + foreach (var socket in preset.Sockets) + { + bool horizontal = socket.Direction == StageDirection.North || socket.Direction == StageDirection.South; + int outwardX = socket.Direction == StageDirection.East ? 1 : socket.Direction == StageDirection.West ? -1 : 0; + int outwardY = socket.Direction == StageDirection.South ? 1 : socket.Direction == StageDirection.North ? -1 : 0; + + for (int i = 0; i < socket.Width; i++) + { + int x = socket.CellPosition.x + (horizontal ? i : 0); + int y = socket.CellPosition.y + (horizontal ? 0 : i); + x += outwardX; + y += outwardY; + + while (x >= 0 && x < preset.GridWidth && y >= 0 && y < preset.GridHeight) + { + preset.GridMap[x, y] = PresetCellType.Empty; + x += outwardX; + y += outwardY; + } + } + } + + foreach (var socket in preset.Sockets) + { + bool horizontal = socket.Direction == StageDirection.North || socket.Direction == StageDirection.South; + for (int i = 0; i < socket.Width; i++) + { + int x = socket.CellPosition.x + (horizontal ? i : 0); + int y = socket.CellPosition.y + (horizontal ? 0 : i); + if (x >= 0 && x < preset.GridWidth && y >= 0 && y < preset.GridHeight) + { + preset.GridMap[x, y] = PresetCellType.Socket; + } + } + } + } + + public static void ParseGridAndAnchors(RoomPreset preset, string[] gridLines, int expectedWidth = 0, int expectedHeight = 0) + { + if (preset == null) throw new ArgumentNullException(nameof(preset)); + if (gridLines == null) throw new ArgumentNullException(nameof(gridLines)); + + bool isStrictMode = expectedWidth > 0 && expectedHeight > 0; + if (isStrictMode) + { + if (gridLines.Length != expectedHeight) + { + throw new ArgumentException($"Preset {preset.PresetId}: Grid height mismatch. Expected {expectedHeight}, got {gridLines.Length}."); + } + + for (int y = 0; y < expectedHeight; y++) + { + string line = gridLines[y]; + if (line == null || line.Length != expectedWidth) + { + throw new ArgumentException($"Preset {preset.PresetId}: Line {y:D2} length mismatch. Expected {expectedWidth}, got {line?.Length ?? 0}."); + } + } + } + + int height = isStrictMode ? expectedHeight : gridLines.Length; + int maxLen = 0; + for (int i = 0; i < gridLines.Length; i++) + { + if (gridLines[i] != null && gridLines[i].Length > maxLen) maxLen = gridLines[i].Length; + } + int width = isStrictMode ? expectedWidth : maxLen; + + preset.GridWidth = width; + preset.GridHeight = height; + preset.GridMap = new PresetCellType[width, height]; + + for (int y = 0; y < height; y++) + { + string line = gridLines[y]; + for (int x = 0; x < width; x++) + { + char c = (line != null && x < line.Length) ? line[x] : '□'; + switch (c) + { + case '□': + preset.GridMap[x, y] = PresetCellType.Empty; + break; + case '■': + preset.GridMap[x, y] = PresetCellType.Walkable; + break; + case '▲': + preset.GridMap[x, y] = PresetCellType.Obstacle; + break; + case '○': + preset.GridMap[x, y] = PresetCellType.Socket; + break; + case '☆': + preset.GridMap[x, y] = PresetCellType.PlayerAnchor; + preset.PlayerAnchor = new Vector2Int(x, y); + break; + case '△': + preset.GridMap[x, y] = PresetCellType.RewardAnchor; + preset.RewardAnchor = new Vector2Int(x, y); + break; + case '◇': + preset.GridMap[x, y] = PresetCellType.Walkable; + if (!preset.SpawnPoints.Exists(p => p.CellPosition == new Vector2Int(x, y))) + { + preset.SpawnPoints.Add(new RoomPresetSpawnPoint($"M_auto_{x}_{y}", SpawnRole.NormalMelee, new Vector2Int(x, y))); + } + break; + case '◆': + preset.GridMap[x, y] = PresetCellType.Walkable; + if (!preset.SpawnPoints.Exists(p => p.CellPosition == new Vector2Int(x, y))) + { + preset.SpawnPoints.Add(new RoomPresetSpawnPoint($"R_auto_{x}_{y}", SpawnRole.Ranged, new Vector2Int(x, y))); + } + break; + case '★': + preset.GridMap[x, y] = PresetCellType.Walkable; + if (!preset.SpawnPoints.Exists(p => p.CellPosition == new Vector2Int(x, y))) + { + SpawnRole bossRole = preset.StageIndex switch + { + 1 => SpawnRole.Elite, + 2 => SpawnRole.MidBoss, + 3 => SpawnRole.FinalBoss, + _ => SpawnRole.Elite, + }; + preset.SpawnPoints.Add(new RoomPresetSpawnPoint($"Boss_{x}_{y}", bossRole, new Vector2Int(x, y))); + } + break; + default: + if (isStrictMode) + { + throw new ArgumentException($"Preset {preset.PresetId}: Unknown cell character '{c}' at ({x},{y})."); + } + preset.GridMap[x, y] = PresetCellType.Walkable; + break; + } + } + } + + if (isStrictMode) + { + ValidatePresetGeometryAndAnchors(preset); + } + } + + public static void ValidatePresetGeometryAndAnchors(RoomPreset preset) + { + if (preset == null) return; + + // Validate PlayerAnchor if defined + if (preset.PlayerAnchor.x >= 0 && preset.PlayerAnchor.y >= 0) + { + if (preset.PlayerAnchor.x >= preset.GridWidth || preset.PlayerAnchor.y >= preset.GridHeight) + { + throw new ArgumentException($"Preset {preset.PresetId}: PlayerAnchor ({preset.PlayerAnchor.x},{preset.PlayerAnchor.y}) is out of bounds ({preset.GridWidth}x{preset.GridHeight})."); + } + var cellType = preset.GridMap[preset.PlayerAnchor.x, preset.PlayerAnchor.y]; + if (cellType == PresetCellType.Empty || cellType == PresetCellType.Obstacle) + { + throw new ArgumentException($"Preset {preset.PresetId}: PlayerAnchor ({preset.PlayerAnchor.x},{preset.PlayerAnchor.y}) cannot be on {cellType}."); + } + } + + // Validate SpawnPoints + for (int i = 0; i < preset.SpawnPoints.Count; i++) + { + var pt = preset.SpawnPoints[i]; + if (pt.CellPosition.x < 0 || pt.CellPosition.x >= preset.GridWidth || + pt.CellPosition.y < 0 || pt.CellPosition.y >= preset.GridHeight) + { + throw new ArgumentException($"Preset {preset.PresetId}: SpawnPoint {pt.Id} ({pt.CellPosition.x},{pt.CellPosition.y}) is out of bounds."); + } + var cellType = preset.GridMap[pt.CellPosition.x, pt.CellPosition.y]; + if (cellType == PresetCellType.Empty || cellType == PresetCellType.Obstacle) + { + throw new ArgumentException($"Preset {preset.PresetId}: SpawnPoint {pt.Id} ({pt.CellPosition.x},{pt.CellPosition.y}) cannot be on {cellType}."); + } + } + + // Validate Sockets + for (int i = 0; i < preset.Sockets.Count; i++) + { + var sock = preset.Sockets[i]; + for (int w = 0; w < sock.Width; w++) + { + int sx = sock.Direction switch + { + StageDirection.North or StageDirection.South => sock.CellPosition.x + w, + _ => sock.CellPosition.x, + }; + int sy = sock.Direction switch + { + StageDirection.East or StageDirection.West => sock.CellPosition.y + w, + _ => sock.CellPosition.y, + }; + + if (sx < 0 || sx >= preset.GridWidth || sy < 0 || sy >= preset.GridHeight) + { + throw new ArgumentException($"Preset {preset.PresetId}: Socket {sock.Id} cell ({sx},{sy}) is out of bounds."); + } + if (preset.GridMap[sx, sy] != PresetCellType.Socket) + { + throw new ArgumentException($"Preset {preset.PresetId}: Socket {sock.Id} cell ({sx},{sy}) must be a Socket cell '○', but was {preset.GridMap[sx, sy]}."); + } + } + } + } + + #region Stage 1 Presets + + private static RoomPreset BuildS1A() + { + RoomPreset preset = new RoomPreset + { + PresetId = "GRAVE_S1_COMBAT_A", + DisplayName = "S1-A Split Pillars", + StageIndex = 1, + NodeType = StageNodeType.Combat, + }; + + string[] grid = new string[] + { + "□□□□□□□○□□□□□□□□□□", + "□□□■■■■■■■■■■■■□□□", + "□□■■■■■■■■◇■■■■■□□", + "□■■■◇■▲■■■■■■■■■■□", + "○■■■■■▲■■■■■▲■■■■○", + "□■■☆■■■■■■■◇■■■■■□", + "□■■■■■■■■■▲■■■■■■□", + "□■■■■■■■■■▲■■■■■■□", + "□■■■◇■■■■■■■◇■■■■□", + "□□■■■■■■■■■■■■■■□□", + "□□□■■■■■■■■■■■■□□□", + "□□□□□□□○□□□□□□□□□□" + }; + + preset.Sockets.Add(new RoomPresetSocket("N1", StageDirection.North, new Vector2Int(7, 0), 1)); + preset.Sockets.Add(new RoomPresetSocket("W1", StageDirection.West, new Vector2Int(0, 4), 1)); + preset.Sockets.Add(new RoomPresetSocket("E1", StageDirection.East, new Vector2Int(17, 4), 1)); + preset.Sockets.Add(new RoomPresetSocket("S1", StageDirection.South, new Vector2Int(7, 11), 1)); + + + + preset.SpawnPoints.Add(new RoomPresetSpawnPoint("M02", SpawnRole.NormalMelee, new Vector2Int(10, 2))); + preset.SpawnPoints.Add(new RoomPresetSpawnPoint("M01", SpawnRole.NormalMelee, new Vector2Int(4, 3))); + preset.SpawnPoints.Add(new RoomPresetSpawnPoint("M03", SpawnRole.Crowd, new Vector2Int(11, 5))); + preset.SpawnPoints.Add(new RoomPresetSpawnPoint("M04", SpawnRole.NormalMelee, new Vector2Int(4, 8))); + preset.SpawnPoints.Add(new RoomPresetSpawnPoint("M05", SpawnRole.NormalMelee, new Vector2Int(12, 8))); + ParseGridAndAnchors(preset, grid, 18, 12); + + preset.EncounterPool.Add(new RoomEncounter("Encounter A").Add("Bat", 3).Add("Skeleton", 2)); + preset.EncounterPool.Add(new RoomEncounter("Encounter B").Add("Slime", 3).Add("Skeleton", 2)); + preset.EncounterPool.Add(new RoomEncounter("Encounter C").Add("Bat", 2).Add("Slime", 2).Add("Skeleton", 2)); + + return preset; + } + + private static RoomPreset BuildS1AExpandedComparison() + { + RoomPreset preset = new RoomPreset + { + PresetId = S1AComparisonExpandedPresetId, + DisplayName = "S1-A Expanded Comparison", + StageIndex = 1, + NodeType = StageNodeType.Combat, + }; + + // The comparison room is a real 36 x 24 cell layout. Every cell, + // obstacle, socket, and anchor remains at the existing one-unit + // world scale; only the room footprint and spacing are enlarged. + const int width = 36; + const int height = 24; + char[,] cells = new char[width, height]; + for (int y = 0; y < height; y++) + { + for (int x = 0; x < width; x++) + { + cells[x, y] = '□'; + } + } + + // Broad floor with a clear central axis and open left/right flanks. + for (int y = 1; y < height - 1; y++) + { + for (int x = 2; x < width - 2; x++) + { + cells[x, y] = '■'; + } + } + for (int x = 1; x < width - 1; x++) + { + cells[x, 11] = '■'; + } + for (int y = 1; y < height - 1; y++) + { + cells[17, y] = '■'; + } + + // Keep the original 1x1 obstacle cells and cluster footprints, + // relocated so the center and flank approaches remain open. + Vector2Int[] obstacleCells = + { + new(10, 7), new(10, 8), new(11, 8), + new(24, 7), + new(20, 14), new(21, 14), + new(13, 16), new(14, 16), + }; + for (int i = 0; i < obstacleCells.Length; i++) + { + cells[obstacleCells[i].x, obstacleCells[i].y] = '▲'; + } + + // Narrow one-cell doors. The inward cells are walkable so the + // existing safe-entry search can settle inside the room. + cells[17, 0] = '○'; + cells[0, 11] = '○'; + cells[35, 11] = '○'; + cells[17, 23] = '○'; + + // Preserve five authored encounter anchors and the player anchor, + // while spreading the groups across the expanded room. + cells[8, 6] = '◇'; + cells[27, 6] = '◇'; + cells[29, 11] = '◇'; + cells[7, 17] = '◇'; + cells[27, 17] = '◇'; + cells[6, 11] = '☆'; + + string[] grid = new string[height]; + for (int y = 0; y < height; y++) + { + char[] row = new char[width]; + for (int x = 0; x < width; x++) + { + row[x] = cells[x, y]; + } + grid[y] = new string(row); + } + + preset.Sockets.Add(new RoomPresetSocket("N1", StageDirection.North, new Vector2Int(17, 0), 1)); + preset.Sockets.Add(new RoomPresetSocket("W1", StageDirection.West, new Vector2Int(0, 11), 1)); + preset.Sockets.Add(new RoomPresetSocket("E1", StageDirection.East, new Vector2Int(35, 11), 1)); + preset.Sockets.Add(new RoomPresetSocket("S1", StageDirection.South, new Vector2Int(17, 23), 1)); + + preset.SpawnPoints.Add(new RoomPresetSpawnPoint("M01", SpawnRole.NormalMelee, new Vector2Int(8, 6))); + preset.SpawnPoints.Add(new RoomPresetSpawnPoint("M02", SpawnRole.NormalMelee, new Vector2Int(27, 6))); + preset.SpawnPoints.Add(new RoomPresetSpawnPoint("M03", SpawnRole.Crowd, new Vector2Int(29, 11))); + preset.SpawnPoints.Add(new RoomPresetSpawnPoint("M04", SpawnRole.NormalMelee, new Vector2Int(7, 17))); + preset.SpawnPoints.Add(new RoomPresetSpawnPoint("M05", SpawnRole.NormalMelee, new Vector2Int(27, 17))); + + ParseGridAndAnchors(preset, grid, width, height); + + // The expanded room keeps the canonical item types and group rules, + // while doubling only its encounter counts for the larger footprint. + preset.EncounterPool.Add(new RoomEncounter("Encounter A").Add("Bat", 6).Add("Skeleton", 4)); + preset.EncounterPool.Add(new RoomEncounter("Encounter B").Add("Slime", 6).Add("Skeleton", 4)); + preset.EncounterPool.Add(new RoomEncounter("Encounter C").Add("Bat", 4).Add("Slime", 4).Add("Skeleton", 4)); + + return preset; + } + + private static RoomPreset BuildS1B() + { + RoomPreset preset = new RoomPreset + { + PresetId = "GRAVE_S1_COMBAT_B", + DisplayName = "S1-B Crescent Route", + StageIndex = 1, + NodeType = StageNodeType.Combat, + }; + + string[] grid = new string[] + { + "□□□□□□□○□□□□□□□□□□", + "□□□■■■■■■■■■■■■□□□", + "□□■■◇■■▲■■■■■■■■□□", + "□■■■■■■▲■■■◇■■■■■□", + "○■■☆■■■▲■■■■■■■■○■", + "□■■■■■■■■■■■◇■■■■□", + "□■■■■▲▲■■■■■■■■■■□", + "□■■■■■■◇■■■■■■■■■□", + "□■■■■■■■■■■▲▲■■■■□", + "□□■■◇■■■■■■■■■■■□□", + "□□□■■■■■■■■■■■■□□□", + "□□□□□□□○□□□□□□□□□□" + }; + + + preset.Sockets.Add(new RoomPresetSocket("N1", StageDirection.North, new Vector2Int(7, 0), 1)); + preset.Sockets.Add(new RoomPresetSocket("W1", StageDirection.West, new Vector2Int(0, 4), 1)); + preset.Sockets.Add(new RoomPresetSocket("E1", StageDirection.East, new Vector2Int(16, 4), 1)); + preset.Sockets.Add(new RoomPresetSocket("S1", StageDirection.South, new Vector2Int(7, 11), 1)); + + preset.SpawnPoints.Add(new RoomPresetSpawnPoint("M01", SpawnRole.NormalMelee, new Vector2Int(4, 2))); + preset.SpawnPoints.Add(new RoomPresetSpawnPoint("M02", SpawnRole.NormalMelee, new Vector2Int(11, 3))); + preset.SpawnPoints.Add(new RoomPresetSpawnPoint("M03", SpawnRole.Crowd, new Vector2Int(12, 5))); + preset.SpawnPoints.Add(new RoomPresetSpawnPoint("M04", SpawnRole.NormalMelee, new Vector2Int(7, 7))); + preset.SpawnPoints.Add(new RoomPresetSpawnPoint("M05", SpawnRole.NormalMelee, new Vector2Int(4, 9))); + ParseGridAndAnchors(preset, grid, 18, 12); + + preset.EncounterPool.Add(new RoomEncounter("Encounter A").Add("Bat", 4).Add("Skeleton", 2)); + preset.EncounterPool.Add(new RoomEncounter("Encounter B").Add("Slime", 4).Add("Skeleton", 2)); + preset.EncounterPool.Add(new RoomEncounter("Encounter C").Add("Bat", 2).Add("Slime", 2).Add("Skeleton", 2)); + + return preset; + } + + private static RoomPreset BuildS1C() + { + RoomPreset preset = new RoomPreset + { + PresetId = "GRAVE_S1_FORK", + DisplayName = "S1-C Meaningful Fork", + StageIndex = 1, + NodeType = StageNodeType.Fork, + }; + + string[] grid = new string[] + { + "□□□○□□□□□□□□○□□□□□", + "□□■■■■■■■■■■■■■□□□", + "□□■☆■■■■■■■■■◇■■□□", + "□■■■■■■■■■■■■■■■■□", + "□■■■■■▲■■■■■■■■■■□", + "○■■■■■▲▲■■■■■■■■○■", + "○■■■■■■■■■■▲■■■■○■", + "□■■■■■■■■■▲▲■■■■■□", + "□■■■◇■■■■■■■■◇■■■□", + "□□■■■■■■■■■■■■■■□□", + "□□□○■■■■■■■■○■■□□□", + "□□□■□□□□□□□□■□□□□□" + }; + + + preset.Sockets.Add(new RoomPresetSocket("N1", StageDirection.North, new Vector2Int(3, 0), 1)); + preset.Sockets.Add(new RoomPresetSocket("N2", StageDirection.North, new Vector2Int(12, 0), 1)); + preset.Sockets.Add(new RoomPresetSocket("W1", StageDirection.West, new Vector2Int(0, 5), 2)); + preset.Sockets.Add(new RoomPresetSocket("E1", StageDirection.East, new Vector2Int(16, 5), 2)); + preset.Sockets.Add(new RoomPresetSocket("S1", StageDirection.South, new Vector2Int(3, 10), 1)); + preset.Sockets.Add(new RoomPresetSocket("S2", StageDirection.South, new Vector2Int(12, 10), 1)); + + preset.SpawnPoints.Add(new RoomPresetSpawnPoint("M01", SpawnRole.NormalMelee, new Vector2Int(13, 2))); + preset.SpawnPoints.Add(new RoomPresetSpawnPoint("M02", SpawnRole.Crowd, new Vector2Int(4, 8))); + preset.SpawnPoints.Add(new RoomPresetSpawnPoint("M03", SpawnRole.NormalMelee, new Vector2Int(13, 8))); + ParseGridAndAnchors(preset, grid, 18, 12); + preset.RewardAnchor = new Vector2Int(3, 2); + + preset.EncounterPool.Add(new RoomEncounter("Encounter A").Add("Skeleton", 2).Add("Bat", 3)); + preset.EncounterPool.Add(new RoomEncounter("Encounter B").Add("Slime", 3).Add("Skeleton", 2)); + + return preset; + } + + private static RoomPreset BuildS1D() + { + RoomPreset preset = new RoomPreset + { + PresetId = "GRAVE_S1_ELITE_ARENA", + DisplayName = "S1-D Elite Broken Chapel", + StageIndex = 1, + NodeType = StageNodeType.Elite, + }; + + string[] grid = new string[] + { + "□□□□□□□□○○□□□□□□□□□□", + "□□□■■■■■■■■■■■■■■□□□", + "□□■■■■■■■■■■■■■■■■□□", + "□■■■◇■▲■■■■■■▲■◇■■■□", + "□■■■■■▲■■■■■■▲■■■■■□", + "○■■■■■■■■■■■■■■■■■○■", + "○■■■■■■■■★■■■■■■■■○■", + "□■■■■■■■■■■■■■■■■■■□", + "□■■■■■■■■☆■■■■■■■■■□", + "□■■■■■▲■■■■■■▲■■■■■□", + "□■■■◇■▲■■■■■■▲■◇■■■□", + "□□■■■■■■■■■■■■■■■■□□", + "□□□■■■■■■■■■■■■■■□□□", + "□□□□□□□□○○□□□□□□□□□□" + }; + + + preset.Sockets.Add(new RoomPresetSocket("N1", StageDirection.North, new Vector2Int(8, 0), 2)); + preset.Sockets.Add(new RoomPresetSocket("W1", StageDirection.West, new Vector2Int(0, 5), 2)); + preset.Sockets.Add(new RoomPresetSocket("E1", StageDirection.East, new Vector2Int(18, 5), 2)); + preset.Sockets.Add(new RoomPresetSocket("S1", StageDirection.South, new Vector2Int(8, 13), 2)); + + preset.SpawnPoints.Add(new RoomPresetSpawnPoint("Support01", SpawnRole.NormalMelee, new Vector2Int(4, 3))); + preset.SpawnPoints.Add(new RoomPresetSpawnPoint("Support02", SpawnRole.NormalMelee, new Vector2Int(15, 3))); + preset.SpawnPoints.Add(new RoomPresetSpawnPoint("EliteSpawn", SpawnRole.Elite, new Vector2Int(9, 6))); + preset.SpawnPoints.Add(new RoomPresetSpawnPoint("Support03", SpawnRole.NormalMelee, new Vector2Int(4, 10))); + preset.SpawnPoints.Add(new RoomPresetSpawnPoint("Support04", SpawnRole.NormalMelee, new Vector2Int(15, 10))); + ParseGridAndAnchors(preset, grid, 20, 14); + + preset.EncounterPool.Add(new RoomEncounter("Encounter A").Add("Elite", 1).Add("Skeleton", 2).Add("Bat", 3)); + + return preset; + } + + #endregion + + #region Stage 2 Presets + + private static RoomPreset BuildS2A() + { + RoomPreset preset = new RoomPreset + { + PresetId = "GRAVE_S2_COMBAT_A", + DisplayName = "S2-A Crossfire Ruins", + StageIndex = 2, + NodeType = StageNodeType.Combat, + }; + + string[] grid = new string[] + { + "□□□□□□□□○□□□□□□□□□□□", + "□□□■■■■■■■■■■■■■■□□□", + "□□■■■◆■■■■■■■■■■■■□□", + "□■■■■■▲■■■■■■▲■■■■■□", + "□■■■◇■▲■■■■■■▲■◇■■■□", + "○■■■■■■■■■■■■■■■■■○■", + "□■■☆■■■■■■■■■■■■■■■□", + "□■■■■■■■■■◇■■■■■■■■□", + "□■■■■■▲■■■■■■▲■■■■■□", + "□■■■■■▲■■■■■■▲■◆■■■□", + "□■■■◇■■■■■■■■■■◇■■■□", + "□□■■■■■■■■■■■■■■■■□□", + "□□□■■■■■■■■■■■■■■□□□", + "□□□□□□□□○□□□□□□□□□□□" + }; + + + preset.Sockets.Add(new RoomPresetSocket("N1", StageDirection.North, new Vector2Int(8, 0), 1)); + preset.Sockets.Add(new RoomPresetSocket("W1", StageDirection.West, new Vector2Int(0, 5), 1)); + preset.Sockets.Add(new RoomPresetSocket("E1", StageDirection.East, new Vector2Int(18, 5), 1)); + preset.Sockets.Add(new RoomPresetSocket("S1", StageDirection.South, new Vector2Int(8, 13), 1)); + + preset.SpawnPoints.Add(new RoomPresetSpawnPoint("R01", SpawnRole.Ranged, new Vector2Int(5, 2))); + preset.SpawnPoints.Add(new RoomPresetSpawnPoint("M01", SpawnRole.NormalMelee, new Vector2Int(4, 4))); + preset.SpawnPoints.Add(new RoomPresetSpawnPoint("M02", SpawnRole.NormalMelee, new Vector2Int(15, 4))); + preset.SpawnPoints.Add(new RoomPresetSpawnPoint("C01", SpawnRole.Crowd, new Vector2Int(10, 7))); + preset.SpawnPoints.Add(new RoomPresetSpawnPoint("R02", SpawnRole.Ranged, new Vector2Int(15, 9))); + preset.SpawnPoints.Add(new RoomPresetSpawnPoint("M03", SpawnRole.NormalMelee, new Vector2Int(4, 10))); + preset.SpawnPoints.Add(new RoomPresetSpawnPoint("M04", SpawnRole.NormalMelee, new Vector2Int(15, 10))); + ParseGridAndAnchors(preset, grid, 20, 14); + + preset.EncounterPool.Add(new RoomEncounter("Encounter A").Add("Skeleton", 3).Add("Archer", 1).Add("Bat", 2)); + preset.EncounterPool.Add(new RoomEncounter("Encounter B").Add("Slime", 3).Add("Skeleton", 3)); + + return preset; + } + + private static RoomPreset BuildS2B() + { + RoomPreset preset = new RoomPreset + { + PresetId = "GRAVE_S2_COMBAT_B", + DisplayName = "S2-B Pressure Lanes", + StageIndex = 2, + NodeType = StageNodeType.Combat, + }; + + string[] grid = new string[] + { + "□□□□□□□□○○□□□□□□□□□□", + "□□□■■■■■■■■■■■■■■□□□", + "□□■■◇■▲■■■■■◆■■■■■□□", + "□■■■■■▲■■■■■■■■■■■■□", + "○■■■■■▲■■■▲■■■■■■■○■", + "□■■☆■■■■■■▲■■■◇■■■■□", + "□■■■■■■■■■▲■■■■■■■■□", + "□■■■■■■■■■■■■▲■■■■■□", + "□■■■◇■▲■■■■■■▲■■■■■□", + "□■■■■■▲■■■■■■▲■◇■■■□", + "□■■■◆■▲■■■■■■■■■■■■□", + "□□■■■■■■■■■■■■■■■■□□", + "□□□■■■■■■■■■■■■■■□□□", + "□□□□□□□□○○□□□□□□□□□□" + }; + + + preset.Sockets.Add(new RoomPresetSocket("N1", StageDirection.North, new Vector2Int(8, 0), 2)); + preset.Sockets.Add(new RoomPresetSocket("W1", StageDirection.West, new Vector2Int(0, 4), 1)); + preset.Sockets.Add(new RoomPresetSocket("E1", StageDirection.East, new Vector2Int(18, 4), 1)); + preset.Sockets.Add(new RoomPresetSocket("S1", StageDirection.South, new Vector2Int(8, 13), 2)); + + preset.SpawnPoints.Add(new RoomPresetSpawnPoint("M01", SpawnRole.NormalMelee, new Vector2Int(4, 2))); + preset.SpawnPoints.Add(new RoomPresetSpawnPoint("R01", SpawnRole.Ranged, new Vector2Int(12, 2))); + preset.SpawnPoints.Add(new RoomPresetSpawnPoint("M02", SpawnRole.NormalMelee, new Vector2Int(14, 5))); + preset.SpawnPoints.Add(new RoomPresetSpawnPoint("M03", SpawnRole.NormalMelee, new Vector2Int(4, 8))); + preset.SpawnPoints.Add(new RoomPresetSpawnPoint("M04", SpawnRole.NormalMelee, new Vector2Int(15, 9))); + preset.SpawnPoints.Add(new RoomPresetSpawnPoint("R02", SpawnRole.Ranged, new Vector2Int(4, 10))); + ParseGridAndAnchors(preset, grid, 20, 14); + + preset.EncounterPool.Add(new RoomEncounter("Encounter A").Add("Skeleton", 4).Add("Archer", 1)); + preset.EncounterPool.Add(new RoomEncounter("Encounter B").Add("Slime", 3).Add("Skeleton", 3)); + + return preset; + } + + private static RoomPreset BuildS2C() + { + RoomPreset preset = new RoomPreset + { + PresetId = "GRAVE_S2_FORK_HAZARD", + DisplayName = "S2-C Hazard Fork", + StageIndex = 2, + NodeType = StageNodeType.Fork, + }; + + string[] grid = new string[] + { + "□□□○□□□□□□□□□□○□□□□□", + "□□■■■■■■■■■■■■■■■□□□", + "□□■■■■■■■■■■■■◆■■■□□", + "□■■■■■■■■■■■■■■■■■■□", + "□■■■■■■■■■■■■■■■■■■□", + "○■■■■■■■☆■■■■■■■■■○■", + "○■■■■■▲■■■■■■▲■■■■○■", + "□■■■■■▲▲■■■■■■■■■■■□", + "□■■■◇■■■■■▲■■■◇■■■■□", + "□■■■■■■■■■▲▲■■■■■■■□", + "□■■■■■■■■■■■■■■■■■■□", + "□□■■◇■■■■■■■■■◇■■■□□", + "□□□○■■■■■■■■■■○■■□□□", + "□□□■□□□□□□□□□□■□□□□□" + }; + + + preset.Sockets.Add(new RoomPresetSocket("N1", StageDirection.North, new Vector2Int(3, 0), 1)); + preset.Sockets.Add(new RoomPresetSocket("N2", StageDirection.North, new Vector2Int(14, 0), 1)); + preset.Sockets.Add(new RoomPresetSocket("W1", StageDirection.West, new Vector2Int(0, 5), 2)); + preset.Sockets.Add(new RoomPresetSocket("E1", StageDirection.East, new Vector2Int(18, 5), 2)); + preset.Sockets.Add(new RoomPresetSocket("S1", StageDirection.South, new Vector2Int(3, 12), 1)); + preset.Sockets.Add(new RoomPresetSocket("S2", StageDirection.South, new Vector2Int(14, 12), 1)); + + preset.SpawnPoints.Add(new RoomPresetSpawnPoint("R01", SpawnRole.Ranged, new Vector2Int(14, 2))); + preset.SpawnPoints.Add(new RoomPresetSpawnPoint("M01", SpawnRole.NormalMelee, new Vector2Int(4, 8))); + preset.SpawnPoints.Add(new RoomPresetSpawnPoint("M02", SpawnRole.NormalMelee, new Vector2Int(14, 8))); + preset.SpawnPoints.Add(new RoomPresetSpawnPoint("M03", SpawnRole.NormalMelee, new Vector2Int(4, 11))); + preset.SpawnPoints.Add(new RoomPresetSpawnPoint("M04", SpawnRole.NormalMelee, new Vector2Int(14, 11))); + ParseGridAndAnchors(preset, grid, 20, 14); + preset.RewardAnchor = new Vector2Int(3, 2); + + preset.EncounterPool.Add(new RoomEncounter("Encounter A").Add("Skeleton", 3).Add("Archer", 1)); + preset.EncounterPool.Add(new RoomEncounter("Encounter B").Add("Bat", 3).Add("Skeleton", 2)); + + return preset; + } + + private static RoomPreset BuildS2D() + { + RoomPreset preset = new RoomPreset + { + PresetId = "GRAVE_S2_MIDBOSS", + DisplayName = "S2-D Mid Boss Divided Hall", + StageIndex = 2, + NodeType = StageNodeType.MidBoss, + }; + + string[] grid = new string[] + { + "□□□□□□□□□○○□□□□□□□□□□□", + "□□□■■■■■■■■■■■■■■■■□□□", + "□□■■■■■■■■■■■■■■■■■■□□", + "□■■■◇■■■■■■■■■■■■◇■■■□", + "□■■■■■■▲■■■■■■▲■■■■■■□", + "□■■■■■■▲■■■■■■▲■■■■■■□", + "○■■■■■■■■■■■■■■■■■■■○■", + "○■■■■■■■■■★■■■■■■■■■○■", + "□■■■■■■■■■■■■■■■■■■■■□", + "□■■■■■■■■■☆■■■■■■■■■■□", + "□■■■■■■▲■■■■■■▲■■■■■■□", + "□■■■■■■▲■■■■■■▲■■■■■■□", + "□■■■■◇■■■■■■■■■■◇■■■■□", + "□□■■■■■■■■■■■■■■■■■■□□", + "□□□■■■■■■■■■■■■■■■■□□□", + "□□□□□□□□□○○□□□□□□□□□□□" + }; + + + preset.Sockets.Add(new RoomPresetSocket("N1", StageDirection.North, new Vector2Int(9, 0), 2)); + preset.Sockets.Add(new RoomPresetSocket("W1", StageDirection.West, new Vector2Int(0, 6), 2)); + preset.Sockets.Add(new RoomPresetSocket("E1", StageDirection.East, new Vector2Int(20, 6), 2)); + preset.Sockets.Add(new RoomPresetSocket("S1", StageDirection.South, new Vector2Int(9, 15), 2)); + + preset.SpawnPoints.Add(new RoomPresetSpawnPoint("Support01", SpawnRole.NormalMelee, new Vector2Int(4, 3))); + preset.SpawnPoints.Add(new RoomPresetSpawnPoint("Support02", SpawnRole.NormalMelee, new Vector2Int(17, 3))); + preset.SpawnPoints.Add(new RoomPresetSpawnPoint("BossSpawn", SpawnRole.MidBoss, new Vector2Int(10, 7))); + preset.SpawnPoints.Add(new RoomPresetSpawnPoint("Support03", SpawnRole.NormalMelee, new Vector2Int(5, 12))); + preset.SpawnPoints.Add(new RoomPresetSpawnPoint("Support04", SpawnRole.NormalMelee, new Vector2Int(16, 12))); + ParseGridAndAnchors(preset, grid, 22, 16); + + preset.EncounterPool.Add(new RoomEncounter("Encounter A").Add("MidBoss", 1).Add("Bat", 4).Add("Skeleton", 2)); + + return preset; + } + + #endregion + + #region Stage 3 Presets + + private static RoomPreset BuildS3A() + { + RoomPreset preset = new RoomPreset + { + PresetId = "GRAVE_S3_COMBAT_A", + DisplayName = "S3-A Rotating Pressure", + StageIndex = 3, + NodeType = StageNodeType.Combat, + }; + + string[] grid = new string[] + { + "□□□□□□□□□○□□□□□□□□□□□□", + "□□□■■■■■■■■■■■■■■■■□□□", + "□□■■◆■■■■■■■■■■◇■■■■□□", + "□■■■■■■■■■■■■■▲■■■■■■□", + "□■■◇■■■▲■■■■■▲▲■■■■■■□", + "○■■■■■■▲▲■■■■■■■■◆■■○■", + "□■■■■■■■■■☆■■■■■■■■■■□", + "□■■■■■■■■■■■■■■■■■■■■□", + "□■■■■■■■■■◇■■■■■■■■■■□", + "□■■■■■■■■■■■■■▲■■■■■■□", + "□■■■■■■▲▲■■■■▲▲■■■■■■□", + "□■■■◆■■▲■■■■■■■■■■◇■■□", + "□■■■■■■■■■■■■■■■■■■■■□", + "□□■■■◇■■■■■■■■■◆■■■■□□", + "□□□■■■■■■■■■■■■■■■■□□□", + "□□□□□□□□□○□□□□□□□□□□□□" + }; + + + preset.Sockets.Add(new RoomPresetSocket("N1", StageDirection.North, new Vector2Int(9, 0), 1)); + preset.Sockets.Add(new RoomPresetSocket("W1", StageDirection.West, new Vector2Int(0, 5), 1)); + preset.Sockets.Add(new RoomPresetSocket("E1", StageDirection.East, new Vector2Int(20, 5), 1)); + preset.Sockets.Add(new RoomPresetSocket("S1", StageDirection.South, new Vector2Int(9, 15), 1)); + + preset.SpawnPoints.Add(new RoomPresetSpawnPoint("R01", SpawnRole.Ranged, new Vector2Int(4, 2))); + preset.SpawnPoints.Add(new RoomPresetSpawnPoint("M01", SpawnRole.NormalMelee, new Vector2Int(15, 2))); + preset.SpawnPoints.Add(new RoomPresetSpawnPoint("M02", SpawnRole.NormalMelee, new Vector2Int(3, 4))); + preset.SpawnPoints.Add(new RoomPresetSpawnPoint("R02", SpawnRole.Ranged, new Vector2Int(17, 5))); + preset.SpawnPoints.Add(new RoomPresetSpawnPoint("M03", SpawnRole.NormalMelee, new Vector2Int(10, 8))); + preset.SpawnPoints.Add(new RoomPresetSpawnPoint("R03", SpawnRole.Ranged, new Vector2Int(4, 11))); + preset.SpawnPoints.Add(new RoomPresetSpawnPoint("M04", SpawnRole.NormalMelee, new Vector2Int(18, 11))); + preset.SpawnPoints.Add(new RoomPresetSpawnPoint("M05", SpawnRole.NormalMelee, new Vector2Int(5, 13))); + preset.SpawnPoints.Add(new RoomPresetSpawnPoint("R04", SpawnRole.Ranged, new Vector2Int(15, 13))); + ParseGridAndAnchors(preset, grid, 22, 16); + + preset.EncounterPool.Add(new RoomEncounter("Encounter A").Add("Skeleton", 4).Add("Archer", 2)); + preset.EncounterPool.Add(new RoomEncounter("Encounter B").Add("Bat", 4).Add("Archer", 1)); + + return preset; + } + + private static RoomPreset BuildS3B() + { + RoomPreset preset = new RoomPreset + { + PresetId = "GRAVE_S3_COMBAT_B", + DisplayName = "S3-B Broken Procession", + StageIndex = 3, + NodeType = StageNodeType.Combat, + }; + + string[] grid = new string[] + { + "□□□□□○□□□□□□□□□□○□□□□□", + "□□□■■■■■■■■■■■■■■■■□□□", + "□□■◇■■▲■■■■■■■■■◆■■■□□", + "□■■■■■▲■■■■■■■■■■■■■■□", + "□■■■■■▲■■■■■■■■■■◇■■■□", + "○■■☆■■■■■■▲■■■■■■■■■○■", + "○■■■■■■■■■▲■■■■■■■■■○■", + "□■■■◇■■■■■▲■■■■■■◇■■■□", + "□■■■■■■■■■■■■■▲■■■■■■□", + "□■■■■■■■■■■■■■▲■■■■■■□", + "□■■◆■■■■■■■■■■▲◇■■■■■□", + "□■■■■■■■▲■■■■■■■■■■■■□", + "□■■■◇■■■▲■■■■■■■■◆■■■□", + "□□■■■■■■■■■■■■■■■■■■□□", + "□□□■○■■■■■■■■■■○■■■□□□", + "□□□□■□□□□□□□□□□■□□□□□□" + }; + + + preset.Sockets.Add(new RoomPresetSocket("N1", StageDirection.North, new Vector2Int(5, 0), 1)); + preset.Sockets.Add(new RoomPresetSocket("N2", StageDirection.North, new Vector2Int(16, 0), 1)); + preset.Sockets.Add(new RoomPresetSocket("W1", StageDirection.West, new Vector2Int(0, 5), 2)); + preset.Sockets.Add(new RoomPresetSocket("E1", StageDirection.East, new Vector2Int(20, 5), 2)); + preset.Sockets.Add(new RoomPresetSocket("S1", StageDirection.South, new Vector2Int(4, 14), 1)); + preset.Sockets.Add(new RoomPresetSocket("S2", StageDirection.South, new Vector2Int(15, 14), 1)); + + preset.SpawnPoints.Add(new RoomPresetSpawnPoint("M01", SpawnRole.NormalMelee, new Vector2Int(3, 2))); + preset.SpawnPoints.Add(new RoomPresetSpawnPoint("R01", SpawnRole.Ranged, new Vector2Int(16, 2))); + preset.SpawnPoints.Add(new RoomPresetSpawnPoint("M02", SpawnRole.NormalMelee, new Vector2Int(17, 4))); + preset.SpawnPoints.Add(new RoomPresetSpawnPoint("M03", SpawnRole.NormalMelee, new Vector2Int(4, 7))); + preset.SpawnPoints.Add(new RoomPresetSpawnPoint("M04", SpawnRole.NormalMelee, new Vector2Int(17, 7))); + preset.SpawnPoints.Add(new RoomPresetSpawnPoint("R02", SpawnRole.Ranged, new Vector2Int(3, 10))); + preset.SpawnPoints.Add(new RoomPresetSpawnPoint("M05", SpawnRole.NormalMelee, new Vector2Int(15, 10))); + preset.SpawnPoints.Add(new RoomPresetSpawnPoint("M06", SpawnRole.NormalMelee, new Vector2Int(4, 12))); + preset.SpawnPoints.Add(new RoomPresetSpawnPoint("R03", SpawnRole.Ranged, new Vector2Int(17, 12))); + ParseGridAndAnchors(preset, grid, 22, 16); + + preset.EncounterPool.Add(new RoomEncounter("Encounter A").Add("Skeleton", 5).Add("Archer", 2)); + preset.EncounterPool.Add(new RoomEncounter("Encounter B").Add("Bat", 4).Add("Archer", 1)); + + return preset; + } + + private static RoomPreset BuildS3C() + { + RoomPreset preset = new RoomPreset + { + PresetId = "GRAVE_S3_PREBOSS_FORK", + DisplayName = "S3-C Pre-Boss Choice", + StageIndex = 3, + NodeType = StageNodeType.Fork, + }; + + string[] grid = new string[] + { + "□□□○□□□□□□□□□□○□□□□□", + "□□■■■■■■■■■■■■■■■□□□", + "□□■■■■■■■■■■■■◇■■■□□", + "□■■■■■■■■■■▲▲■■■■■■□", + "□■■■■■■■■■■■▲■■■■■■□", + "○■■■■■■■☆■■■■■■■■■○■", + "○■■■■■■■■■■■■■▲■■■○■", + "□■■■■■■■■■■■■■■■■■■□", + "□■■■◇■■▲■■■■■■■◆■■■□", + "□■■■■■■▲▲■■■■■■■■■■□", + "□■■■◇■■■■■■■■■◇■■■■□", + "□□■■■■■■■■■■■■■■■■□□", + "□□□■■■■■○■■■■■■■■□□□", + "□□□□□□□□■□□□□□□□□□□□" + }; + + + preset.Sockets.Add(new RoomPresetSocket("N1", StageDirection.North, new Vector2Int(3, 0), 1)); + preset.Sockets.Add(new RoomPresetSocket("N2", StageDirection.North, new Vector2Int(14, 0), 1)); + preset.Sockets.Add(new RoomPresetSocket("W1", StageDirection.West, new Vector2Int(0, 5), 2)); + preset.Sockets.Add(new RoomPresetSocket("E1", StageDirection.East, new Vector2Int(18, 5), 2)); + preset.Sockets.Add(new RoomPresetSocket("S1", StageDirection.South, new Vector2Int(8, 12), 1)); + + preset.SpawnPoints.Add(new RoomPresetSpawnPoint("M01", SpawnRole.NormalMelee, new Vector2Int(14, 2))); + preset.SpawnPoints.Add(new RoomPresetSpawnPoint("M02", SpawnRole.NormalMelee, new Vector2Int(4, 8))); + preset.SpawnPoints.Add(new RoomPresetSpawnPoint("R01", SpawnRole.Ranged, new Vector2Int(15, 8))); + preset.SpawnPoints.Add(new RoomPresetSpawnPoint("M03", SpawnRole.NormalMelee, new Vector2Int(4, 10))); + preset.SpawnPoints.Add(new RoomPresetSpawnPoint("M04", SpawnRole.NormalMelee, new Vector2Int(14, 10))); + ParseGridAndAnchors(preset, grid, 20, 14); + preset.RewardAnchor = new Vector2Int(3, 2); + + preset.EncounterPool.Add(new RoomEncounter("Encounter A").Add("Skeleton", 4).Add("Archer", 1)); + + return preset; + } + + private static RoomPreset BuildS3D() + { + RoomPreset preset = new RoomPreset + { + PresetId = "GRAVE_S3_FINAL_NECROMANCER", + DisplayName = "S3-D Necromancer Final Arena", + StageIndex = 3, + NodeType = StageNodeType.FinalBoss, + }; + + string[] grid = new string[] + { + "□□□□□□□□□□□○○○□□□□□□□□□□", + "□□□■■■■■■■■■■■■■■■■■■□□□", + "□□■■■■■■■■■■■■■■■■■■■■□□", + "□■■■◇■■■▲■■■■■■▲■■■◇■■■□", + "□■■■■■■■▲■■■■■■▲■■■■■■■□", + "□■■■■■■■■■■■■■■■■■■■■■■□", + "□■■■■■■■■■■■■■■■■■■■■■■□", + "○■■■■▲■■■■■■■■■■■■▲■■○■■", + "○■■◇■▲■■■■■★■■■■■■▲■◇○■■", + "○■■■■■■■■■■■■■■■■■■■■○■■", + "□■■■■■■■■■■☆■■■■■■■■■■■□", + "□■■■■■■■▲■■■■■■▲■■■■■■■□", + "□■■■■■■◇▲■■■■■■▲◇■■■■■■□", + "□■■■■■■■■■■■■■■■■■■■■■■□", + "□■■■■■◇■■■■■■■■■■◇■■■■■□", + "□□■■■■■■■■■■■■■■■■■■■■□□", + "□□□■■■■■■■■■■■■■■■■■■□□□", + "□□□□□□□□□□□○○○□□□□□□□□□□" + }; + + + preset.Sockets.Add(new RoomPresetSocket("N1", StageDirection.North, new Vector2Int(11, 0), 3)); + preset.Sockets.Add(new RoomPresetSocket("W1", StageDirection.West, new Vector2Int(0, 7), 3)); + preset.Sockets.Add(new RoomPresetSocket("E1", StageDirection.East, new Vector2Int(21, 7), 3)); + preset.Sockets.Add(new RoomPresetSocket("S1", StageDirection.South, new Vector2Int(11, 17), 3)); + + preset.SpawnPoints.Add(new RoomPresetSpawnPoint("Support01", SpawnRole.NormalMelee, new Vector2Int(4, 3))); + preset.SpawnPoints.Add(new RoomPresetSpawnPoint("Support02", SpawnRole.NormalMelee, new Vector2Int(19, 3))); + preset.SpawnPoints.Add(new RoomPresetSpawnPoint("Support03", SpawnRole.NormalMelee, new Vector2Int(3, 8))); + preset.SpawnPoints.Add(new RoomPresetSpawnPoint("BossSpawn", SpawnRole.FinalBoss, new Vector2Int(11, 8))); + preset.SpawnPoints.Add(new RoomPresetSpawnPoint("Support04", SpawnRole.NormalMelee, new Vector2Int(20, 8))); + preset.SpawnPoints.Add(new RoomPresetSpawnPoint("Support05", SpawnRole.NormalMelee, new Vector2Int(7, 12))); + preset.SpawnPoints.Add(new RoomPresetSpawnPoint("Support06", SpawnRole.NormalMelee, new Vector2Int(16, 12))); + preset.SpawnPoints.Add(new RoomPresetSpawnPoint("Support07", SpawnRole.NormalMelee, new Vector2Int(6, 14))); + preset.SpawnPoints.Add(new RoomPresetSpawnPoint("Support08", SpawnRole.NormalMelee, new Vector2Int(17, 14))); + ParseGridAndAnchors(preset, grid, 24, 18); + + preset.EncounterPool.Add(new RoomEncounter("Encounter A").Add("FinalBoss", 1).Add("Skeleton", 4).Add("Archer", 2)); + + return preset; + } + + #endregion + + #region Safe Presets (Entrance, Reward, Recovery, Exit) + + private static void RegisterSafePresets() + { + // S1 Safe Rooms (18x12) + RegisterPreset(BuildStandardSafeRoom("S1_ENTRANCE", 1, StageNodeType.Entrance, 18, 12)); + RegisterPreset(BuildStandardSafeRoom("S1_REWARD", 1, StageNodeType.Reward, 18, 12)); + RegisterPreset(BuildStandardSafeRoom("S1_RECOVERY", 1, StageNodeType.Recovery, 18, 12)); + RegisterPreset(BuildStandardSafeRoom("S1_EXIT", 1, StageNodeType.StageExit, 18, 12)); + + // S2 Safe Rooms (20x14) + RegisterPreset(BuildStandardSafeRoom("S2_ENTRANCE", 2, StageNodeType.Entrance, 20, 14)); + RegisterPreset(BuildStandardSafeRoom("S2_REWARD", 2, StageNodeType.Reward, 20, 14)); + RegisterPreset(BuildStandardSafeRoom("S2_RECOVERY", 2, StageNodeType.Recovery, 20, 14)); + RegisterPreset(BuildStandardSafeRoom("S2_EXIT", 2, StageNodeType.StageExit, 20, 14)); + + // S3 Safe Rooms (22x16) + RegisterPreset(BuildStandardSafeRoom("S3_ENTRANCE", 3, StageNodeType.Entrance, 22, 16)); + RegisterPreset(BuildStandardSafeRoom("S3_REWARD", 3, StageNodeType.Reward, 22, 16)); + RegisterPreset(BuildStandardSafeRoom("S3_RECOVERY", 3, StageNodeType.Recovery, 22, 16)); + RegisterPreset(BuildStandardSafeRoom("S3_EXIT", 3, StageNodeType.StageExit, 22, 16)); + } + + private static RoomPreset BuildStandardSafeRoom(string presetId, int stageIndex, StageNodeType nodeType, int width, int height) + { + RoomPreset preset = new RoomPreset + { + PresetId = presetId, + DisplayName = $"{nodeType} Room", + StageIndex = stageIndex, + NodeType = nodeType, + GridWidth = width, + GridHeight = height, + GridMap = new PresetCellType[width, height], + }; + + for (int y = 0; y < height; y++) + { + for (int x = 0; x < width; x++) + { + bool isPerimeter = (x == 0 || x == width - 1 || y == 0 || y == height - 1); + preset.GridMap[x, y] = isPerimeter ? PresetCellType.Empty : PresetCellType.Walkable; + } + } + + int midX = width / 2; + int midY = height / 2; + + // 4 directional sockets (width 2) + int sockStartX = midX - 1; + int sockStartY = midY - 1; + preset.Sockets.Add(new RoomPresetSocket("N1", StageDirection.North, new Vector2Int(sockStartX, 0), 2)); + preset.Sockets.Add(new RoomPresetSocket("S1", StageDirection.South, new Vector2Int(sockStartX, height - 1), 2)); + preset.Sockets.Add(new RoomPresetSocket("W1", StageDirection.West, new Vector2Int(0, sockStartY), 2)); + preset.Sockets.Add(new RoomPresetSocket("E1", StageDirection.East, new Vector2Int(width - 1, sockStartY), 2)); + + // Mark socket cells as Socket so EmptyCell colliders are not generated in doorways + foreach (var socket in preset.Sockets) + { + bool isHorizontal = socket.Direction == StageDirection.North || socket.Direction == StageDirection.South; + for (int i = 0; i < socket.Width; i++) + { + int sx = socket.CellPosition.x + (isHorizontal ? i : 0); + int sy = socket.CellPosition.y + (isHorizontal ? 0 : i); + if (sx >= 0 && sx < width && sy >= 0 && sy < height) + { + preset.GridMap[sx, sy] = PresetCellType.Socket; + } + } + } + + preset.PlayerAnchor = new Vector2Int(midX, midY); + if (nodeType == StageNodeType.Reward || nodeType == StageNodeType.Recovery) + { + preset.RewardAnchor = new Vector2Int(midX, midY); + preset.GridMap[midX, midY] = PresetCellType.RewardAnchor; + } + + return preset; + } + + #endregion + } +} + + + diff --git a/Assets/_Project/Scripts/Stage/LevelPresetCatalog.cs.meta b/Assets/_Project/Scripts/Stage/LevelPresetCatalog.cs.meta new file mode 100644 index 0000000..c9a2f58 --- /dev/null +++ b/Assets/_Project/Scripts/Stage/LevelPresetCatalog.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 2c003d169eb47fa4686effb16f4ea632 \ No newline at end of file diff --git a/Assets/_Project/Scripts/Stage/RoomPathfinder.cs b/Assets/_Project/Scripts/Stage/RoomPathfinder.cs new file mode 100644 index 0000000..99c0a77 --- /dev/null +++ b/Assets/_Project/Scripts/Stage/RoomPathfinder.cs @@ -0,0 +1,320 @@ +using System; +using System.Collections.Generic; +using BumpCombat.Enemies; +using UnityEngine; + +namespace BumpCombat.Stage +{ + public static class RoomPathfinder + { + private static readonly int[] Dx = { 0, 1, 0, -1, 1, 1, -1, -1 }; + private static readonly int[] Dy = { -1, 0, 1, 0, -1, 1, 1, -1 }; + private static readonly float[] Cost = { 1f, 1f, 1f, 1f, 1.414f, 1.414f, 1.414f, 1.414f }; + + public static bool HasLineOfSight(Vector2 fromPos, Vector2 toPos, bool isFlying, Collider2D ignoreCollider = null) + { + RaycastHit2D[] hits = Physics2D.LinecastAll(fromPos, toPos); + for (int i = 0; i < hits.Length; i++) + { + Collider2D col = hits[i].collider; + if (col == null || col == ignoreCollider || col.isTrigger) continue; + + if (col.GetComponentInParent() != null) continue; + if (col.GetComponentInParent() != null) continue; + + StageObstacle obs = col.GetComponentInParent(); + if (obs != null) + { + if (isFlying) continue; + return false; + } + + if (col.name.StartsWith("Wall_") || col.gameObject.name.Contains("Wall")) + { + return false; + } + } + return true; + } + + public static bool TryGetNextWaypoint( + ZoneInstance zone, + Vector2 currentPos, + Vector2 targetPos, + bool isFlying, + out Vector2 nextWaypoint) + { + nextWaypoint = targetPos; + + if (isFlying || zone == null || zone.Preset == null) + { + return true; + } + + if (HasLineOfSight(currentPos, targetPos, isFlying)) + { + return true; + } + + RoomPreset preset = zone.Preset; + int width = preset.GridWidth; + int height = preset.GridHeight; + if (width <= 0 || height <= 0 || preset.GridMap == null) + { + return true; + } + + Vector2 center = zone.ZoneCenter; + Vector2Int startCell = WorldToCell(preset, center, currentPos); + Vector2Int targetCell = WorldToCell(preset, center, targetPos); + + startCell.x = Mathf.Clamp(startCell.x, 0, width - 1); + startCell.y = Mathf.Clamp(startCell.y, 0, height - 1); + targetCell.x = Mathf.Clamp(targetCell.x, 0, width - 1); + targetCell.y = Mathf.Clamp(targetCell.y, 0, height - 1); + + if (startCell == targetCell) + { + return true; + } + + Vector2Int nextCell = FindAStarNextCell(preset, startCell, targetCell, out _); + if (nextCell != startCell) + { + nextWaypoint = preset.CellToWorldPosition(center, nextCell); + return true; + } + + return true; + } + + public static bool TryGetReachableRoamPoint( + ZoneInstance zone, + Vector2 currentPos, + Vector2 candidatePos, + bool isFlying, + out Vector2 roamPoint) + { + roamPoint = currentPos; + if (zone == null || zone.Preset == null) + { + return false; + } + + RoomPreset preset = zone.Preset; + if (preset.GridMap == null || preset.GridWidth <= 0 || preset.GridHeight <= 0) + { + return false; + } + + Vector2 center = zone.ZoneCenter; + Vector2Int startCell = WorldToCell(preset, center, currentPos); + Vector2Int targetCell = WorldToCell(preset, center, candidatePos); + if (!IsInsideGrid(preset, startCell) || !IsInsideGrid(preset, targetCell)) + { + return false; + } + + PresetCellType targetType = preset.GridMap[targetCell.x, targetCell.y]; + if (targetType == PresetCellType.Empty + || (!isFlying && targetType == PresetCellType.Obstacle)) + { + return false; + } + + Vector2 targetCenter = preset.CellToWorldPosition(center, targetCell); + if (isFlying) + { + if (!HasLineOfSight(currentPos, targetCenter, true)) + { + return false; + } + + roamPoint = targetCenter; + return true; + } + + if (!IsWalkable(preset, startCell.x, startCell.y)) + { + return false; + } + + if (startCell == targetCell) + { + roamPoint = targetCenter; + return true; + } + + FindAStarNextCell(preset, startCell, targetCell, out bool reachedTarget); + if (!reachedTarget) + { + return false; + } + + roamPoint = targetCenter; + return true; + } + + private static bool IsInsideGrid(RoomPreset preset, Vector2Int cell) + { + return cell.x >= 0 && cell.x < preset.GridWidth + && cell.y >= 0 && cell.y < preset.GridHeight; + } + public static Vector2Int WorldToCell(RoomPreset preset, Vector2 zoneCenter, Vector2 worldPos) + { + Vector2 local = worldPos - zoneCenter; + int x = Mathf.RoundToInt(local.x + (preset.GridWidth - 1) * 0.5f); + int y = Mathf.RoundToInt((preset.GridHeight - 1) * 0.5f - local.y); + return new Vector2Int(x, y); + } + + public static bool IsWalkable(RoomPreset preset, int x, int y) + { + if (x < 0 || x >= preset.GridWidth || y < 0 || y >= preset.GridHeight) + { + return false; + } + + PresetCellType type = preset.GridMap[x, y]; + return type != PresetCellType.Obstacle && type != PresetCellType.Empty; + } + + private static Vector2Int FindAStarNextCell( + RoomPreset preset, + Vector2Int start, + Vector2Int target, + out bool reachedTarget) + { + reachedTarget = start == target; + int width = preset.GridWidth; + int height = preset.GridHeight; + int totalCells = width * height; + + float[] gCost = new float[totalCells]; + bool[] closed = new bool[totalCells]; + int[] parent = new int[totalCells]; + + for (int i = 0; i < totalCells; i++) + { + gCost[i] = float.MaxValue; + parent[i] = -1; + } + + int startIndex = start.y * width + start.x; + int targetIndex = target.y * width + target.x; + + gCost[startIndex] = 0f; + + List openList = new List { startIndex }; + + while (openList.Count > 0) + { + int bestIdx = 0; + int current = openList[0]; + int curX = current % width; + int curY = current / width; + float bestF = gCost[current] + Heuristic(curX, curY, target.x, target.y); + + for (int i = 1; i < openList.Count; i++) + { + int c = openList[i]; + int cx = c % width; + int cy = c / width; + float f = gCost[c] + Heuristic(cx, cy, target.x, target.y); + if (f < bestF) + { + bestF = f; + current = c; + bestIdx = i; + } + } + + if (current == targetIndex) + { + reachedTarget = true; + break; + } + + openList.RemoveAt(bestIdx); + closed[current] = true; + + curX = current % width; + curY = current / width; + + for (int d = 0; d < 8; d++) + { + int nx = curX + Dx[d]; + int ny = curY + Dy[d]; + + if (!IsWalkable(preset, nx, ny)) continue; + + if (d >= 4) + { + if (!IsWalkable(preset, curX, ny) || !IsWalkable(preset, nx, curY)) + { + continue; + } + } + + int nIdx = ny * width + nx; + if (closed[nIdx]) continue; + + float tentativeG = gCost[current] + Cost[d]; + if (tentativeG < gCost[nIdx]) + { + gCost[nIdx] = tentativeG; + parent[nIdx] = current; + + if (!openList.Contains(nIdx)) + { + openList.Add(nIdx); + } + } + } + } + + int curr = targetIndex; + if (parent[curr] == -1) + { + float bestH = float.MaxValue; + int closest = startIndex; + for (int i = 0; i < totalCells; i++) + { + if (closed[i]) + { + int ix = i % width; + int iy = i / width; + float h = Heuristic(ix, iy, target.x, target.y); + if (h < bestH) + { + bestH = h; + closest = i; + } + } + } + curr = closest; + } + + if (curr == startIndex) + { + return start; + } + + while (parent[curr] != -1 && parent[curr] != startIndex) + { + curr = parent[curr]; + } + + int nextX = curr % width; + int nextY = curr / width; + return new Vector2Int(nextX, nextY); + } + + private static float Heuristic(int x1, int y1, int x2, int y2) + { + int dx = Math.Abs(x1 - x2); + int dy = Math.Abs(y1 - y2); + return (dx + dy) + (1.414f - 2f) * Math.Min(dx, dy); + } + } +} diff --git a/Assets/_Project/Scripts/Stage/RoomPathfinder.cs.meta b/Assets/_Project/Scripts/Stage/RoomPathfinder.cs.meta new file mode 100644 index 0000000..8b29df5 --- /dev/null +++ b/Assets/_Project/Scripts/Stage/RoomPathfinder.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: b000a747520a0a24099e7fbe4ae07ff2 \ No newline at end of file diff --git a/Assets/_Project/Scripts/Stage/RoomPreset.cs b/Assets/_Project/Scripts/Stage/RoomPreset.cs new file mode 100644 index 0000000..2b041ba --- /dev/null +++ b/Assets/_Project/Scripts/Stage/RoomPreset.cs @@ -0,0 +1,263 @@ +using System; +using System.Collections.Generic; +using UnityEngine; + +namespace BumpCombat.Stage +{ + public enum PresetCellType + { + Empty = 0, // □ + Walkable = 1, // ■ + Obstacle = 2, // ▲ + Socket = 3, // ○ + PlayerAnchor = 4, // ☆ + RewardAnchor = 5, // △ + } + + public enum SpawnRole + { + NormalMelee, + Crowd, + Ranged, + Elite, + MidBoss, + FinalBoss, + } + + [Serializable] + public struct RoomPresetSocket + { + public string Id; + public StageDirection Direction; + public Vector2Int CellPosition; + public int Width; + + public RoomPresetSocket(string id, StageDirection direction, Vector2Int cellPos, int width = 2) + { + Id = id; + Direction = direction; + CellPosition = cellPos; + Width = width; + } + } + + [Serializable] + public struct RoomPresetSpawnPoint + { + public string Id; + public SpawnRole Role; + public Vector2Int CellPosition; + + public RoomPresetSpawnPoint(string id, SpawnRole role, Vector2Int cellPos) + { + Id = id; + Role = role; + CellPosition = cellPos; + } + } + + [Serializable] + public sealed class RoomEncounterItem + { + public string EnemyKind; // "Bat", "Skeleton", "Archer", "Slime", "Golem" + public int Count; + + public RoomEncounterItem(string enemyKind, int count) + { + EnemyKind = enemyKind; + Count = count; + } + } + + [Serializable] + public sealed class RoomEncounter + { + public string Name; + public List Items = new(); + + public RoomEncounter(string name) + { + Name = name; + } + + public RoomEncounter Add(string kind, int count) + { + Items.Add(new RoomEncounterItem(kind, count)); + return this; + } + } + + public sealed class RoomPreset + { + public string PresetId { get; set; } + public string DisplayName { get; set; } + public int StageIndex { get; set; } + public StageNodeType NodeType { get; set; } + public int GridWidth { get; set; } + public int GridHeight { get; set; } + + public PresetCellType[,] GridMap { get; set; } + public List Sockets { get; } = new(); + public List SpawnPoints { get; } = new(); + public List EncounterPool { get; } = new(); + + public Vector2Int PlayerAnchor { get; set; } = new(-1, -1); + public Vector2Int RewardAnchor { get; set; } = new(-1, -1); + + public Vector2 PlayableHalfExtents => new(GridWidth * 0.5f, GridHeight * 0.5f); + + public Vector2 CellToLocalPosition(int x, int y) + { + return new Vector2( + x - (GridWidth - 1) * 0.5f, + (GridHeight - 1) * 0.5f - y); + } + + public Vector2 CellToLocalPosition(Vector2Int cell) + { + return CellToLocalPosition(cell.x, cell.y); + } + + public Vector2 CellToWorldPosition(Vector2 zoneCenter, Vector2Int cell) + { + return zoneCenter + CellToLocalPosition(cell); + } + + public RoomPresetSocket? GetSocket(string socketId) + { + if (string.IsNullOrEmpty(socketId)) return null; + for (int i = 0; i < Sockets.Count; i++) + { + if (string.Equals(Sockets[i].Id, socketId, StringComparison.OrdinalIgnoreCase)) + { + return Sockets[i]; + } + } + return null; + } + + public bool HasSocket(StageDirection dir) + { + for (int i = 0; i < Sockets.Count; i++) + { + if (Sockets[i].Direction == dir) return true; + } + return false; + } + + public RoomPresetSocket? GetSocket(StageDirection dir) + { + for (int i = 0; i < Sockets.Count; i++) + { + if (Sockets[i].Direction == dir) return Sockets[i]; + } + return null; + } + + public Vector2 GetSocketCenterLocalPosition(RoomPresetSocket socket) + { + float centerX = socket.CellPosition.x; + float centerY = socket.CellPosition.y; + + if (socket.Direction == StageDirection.North || socket.Direction == StageDirection.South) + { + centerX += (socket.Width - 1) * 0.5f; + } + else + { + centerY += (socket.Width - 1) * 0.5f; + } + + return new Vector2( + centerX - (GridWidth - 1) * 0.5f, + (GridHeight - 1) * 0.5f - centerY); + } + + public Vector2 GetSocketLocalPosition(StageDirection dir) + { + RoomPresetSocket? socket = GetSocket(dir); + if (socket.HasValue) + { + return GetSocketCenterLocalPosition(socket.Value); + } + + return dir switch + { + StageDirection.North => new Vector2(0f, PlayableHalfExtents.y), + StageDirection.South => new Vector2(0f, -PlayableHalfExtents.y), + StageDirection.East => new Vector2(PlayableHalfExtents.x, 0f), + StageDirection.West => new Vector2(-PlayableHalfExtents.x, 0f), + _ => Vector2.zero, + }; + } + + public Vector2 GetEntryLocalPosition(RoomPresetSocket socket) + { + Vector2 basePos = GetSocketCenterLocalPosition(socket); + int maxDepth = Math.Max(GridWidth, GridHeight); + for (int depth = 2; GridMap != null && depth < maxDepth; depth++) + { + bool clear = true; + for (int offset = 0; offset < socket.Width; offset++) + { + int x = socket.CellPosition.x; + int y = socket.CellPosition.y; + if (socket.Direction == StageDirection.North) { x += offset; y += depth; } + else if (socket.Direction == StageDirection.South) { x += offset; y -= depth; } + else if (socket.Direction == StageDirection.East) { x -= depth; y += offset; } + else if (socket.Direction == StageDirection.West) { x += depth; y += offset; } + + if (!RoomPathfinder.IsWalkable(this, x, y)) + { + clear = false; + break; + } + } + + if (!clear) continue; + + return socket.Direction switch + { + StageDirection.North => basePos + new Vector2(0f, -depth), + StageDirection.South => basePos + new Vector2(0f, depth), + StageDirection.East => basePos + new Vector2(-depth, 0f), + StageDirection.West => basePos + new Vector2(depth, 0f), + _ => basePos, + }; + } + + const float fallbackOffset = 1.35f; + return socket.Direction switch + { + StageDirection.North => basePos + new Vector2(0f, -fallbackOffset), + StageDirection.South => basePos + new Vector2(0f, fallbackOffset), + StageDirection.East => basePos + new Vector2(-fallbackOffset, 0f), + StageDirection.West => basePos + new Vector2(fallbackOffset, 0f), + _ => basePos, + }; + } + + public Vector2 GetEntryLocalPosition(StageDirection entryDirection, string socketId = null) + { + RoomPresetSocket? socket = !string.IsNullOrEmpty(socketId) + ? GetSocket(socketId) + : GetSocket(entryDirection); + + if (socket.HasValue) + { + return GetEntryLocalPosition(socket.Value); + } + + Vector2 basePos = GetSocketLocalPosition(entryDirection); + float inwardOffset = 1.35f; + return entryDirection switch + { + StageDirection.North => basePos + new Vector2(0f, -inwardOffset), + StageDirection.South => basePos + new Vector2(0f, inwardOffset), + StageDirection.East => basePos + new Vector2(-inwardOffset, 0f), + StageDirection.West => basePos + new Vector2(inwardOffset, 0f), + _ => basePos, + }; + } + } +} diff --git a/Assets/_Project/Scripts/Stage/RoomPreset.cs.meta b/Assets/_Project/Scripts/Stage/RoomPreset.cs.meta new file mode 100644 index 0000000..2866663 --- /dev/null +++ b/Assets/_Project/Scripts/Stage/RoomPreset.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: d49115a75f6ce4040a5a4ab8e6dbe013 \ No newline at end of file diff --git a/Assets/_Project/Scripts/Stage/StageData.cs b/Assets/_Project/Scripts/Stage/StageData.cs new file mode 100644 index 0000000..f60f86d --- /dev/null +++ b/Assets/_Project/Scripts/Stage/StageData.cs @@ -0,0 +1,226 @@ +using System; +using System.Collections.Generic; +using UnityEngine; + +namespace BumpCombat.Stage +{ + public enum StageDirection + { + North = 0, + East = 1, + South = 2, + West = 3, + } + + public static class StageDirectionExtensions + { + public static StageDirection Opposite(this StageDirection direction) + { + return direction switch + { + StageDirection.North => StageDirection.South, + StageDirection.East => StageDirection.West, + StageDirection.South => StageDirection.North, + StageDirection.West => StageDirection.East, + _ => StageDirection.North, + }; + } + + public static Vector2Int ToVector2Int(this StageDirection direction) + { + return direction switch + { + StageDirection.North => new Vector2Int(0, 1), + StageDirection.East => new Vector2Int(1, 0), + StageDirection.South => new Vector2Int(0, -1), + StageDirection.West => new Vector2Int(-1, 0), + _ => Vector2Int.zero, + }; + } + + public static Vector2 ToVector2(this StageDirection direction) + { + Vector2Int v = direction.ToVector2Int(); + return new Vector2(v.x, v.y); + } + } + + public enum StageNodeType + { + Entrance, + Combat, + Fork, + Reward, + Recovery, + Elite, + MidBoss, + FinalBoss, + StageExit, + } + + public enum StageRewardType + { + None, + RedArtifact, + BlueArtifact, + Upgrade, + Recovery, + Guard, + ArtifactCharge, + } + + [Serializable] + public sealed class StageSocketConnection + { + public string FromSocketId = string.Empty; + public StageDirection Direction; + public int TargetNodeId = -1; + public string TargetSocketId = string.Empty; + } + + [Serializable] + public sealed class StageNode + { + public int Id; + public StageNodeType NodeType; + public Vector2Int GridPosition; + public StageRewardType RewardType; + public string PresetId = string.Empty; + public bool Visited; + public bool Cleared; + public bool IsCurrent; + + // Direction -> Connected NodeId. (-1 if not connected) + public int[] Connections = new int[4] { -1, -1, -1, -1 }; + public List SocketConnections = new(); + + public int GetConnection(StageDirection dir) + { + int index = (int)dir; + return (index >= 0 && index < Connections.Length) ? Connections[index] : -1; + } + + public void SetConnection(StageDirection dir, int targetNodeId) + { + int index = (int)dir; + if (index >= 0 && index < Connections.Length) + { + Connections[index] = targetNodeId; + } + } + + public bool HasConnection(StageDirection dir) + { + return GetConnection(dir) >= 0; + } + + public StageSocketConnection GetSocketConnection(string socketId) + { + if (string.IsNullOrEmpty(socketId)) return null; + for (int i = 0; i < SocketConnections.Count; i++) + { + StageSocketConnection connection = SocketConnections[i]; + if (connection != null + && string.Equals(connection.FromSocketId, socketId, StringComparison.OrdinalIgnoreCase)) + { + return connection; + } + } + return null; + } + + public void SetSocketConnection( + string fromSocketId, + StageDirection direction, + int targetNodeId, + string targetSocketId) + { + if (string.IsNullOrEmpty(fromSocketId)) + { + SetConnection(direction, targetNodeId); + return; + } + + StageSocketConnection connection = GetSocketConnection(fromSocketId); + if (connection == null) + { + connection = new StageSocketConnection { FromSocketId = fromSocketId }; + SocketConnections.Add(connection); + } + + connection.Direction = direction; + connection.TargetNodeId = targetNodeId; + connection.TargetSocketId = targetSocketId ?? string.Empty; + if (!HasConnection(direction)) + { + SetConnection(direction, targetNodeId); + } + } + + public int ConnectionCount + { + get + { + int count = 0; + for (int i = 0; i < Connections.Length; i++) + { + if (Connections[i] >= 0) count++; + } + return count; + } + } + } + + [Serializable] + public sealed class StagePlan + { + public int StageIndex; // 1, 2, 3 + public int Seed; + public List Nodes = new(); + public int StartNodeId; + public int MajorEncounterNodeId; + public int ExitNodeId; + + public StageNode StartNode => GetNode(StartNodeId); + public StageNode MajorEncounterNode => GetNode(MajorEncounterNodeId); + public StageNode ExitNode => GetNode(ExitNodeId); + + public StageNode GetNode(int id) + { + for (int i = 0; i < Nodes.Count; i++) + { + if (Nodes[i].Id == id) return Nodes[i]; + } + return null; + } + + public StageNode GetNodeAt(Vector2Int gridPosition) + { + for (int i = 0; i < Nodes.Count; i++) + { + if (Nodes[i].GridPosition == gridPosition) return Nodes[i]; + } + return null; + } + + public RectInt GetGridBounds() + { + if (Nodes.Count == 0) return new RectInt(0, 0, 1, 1); + int minX = int.MaxValue; + int minY = int.MaxValue; + int maxX = int.MinValue; + int maxY = int.MinValue; + + for (int i = 0; i < Nodes.Count; i++) + { + Vector2Int p = Nodes[i].GridPosition; + if (p.x < minX) minX = p.x; + if (p.y < minY) minY = p.y; + if (p.x > maxX) maxX = p.x; + if (p.y > maxY) maxY = p.y; + } + + return new RectInt(minX, minY, maxX - minX + 1, maxY - minY + 1); + } + } +} diff --git a/Assets/_Project/Scripts/Stage/StageData.cs.meta b/Assets/_Project/Scripts/Stage/StageData.cs.meta new file mode 100644 index 0000000..25ae384 --- /dev/null +++ b/Assets/_Project/Scripts/Stage/StageData.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: f104d6187a441b64490a36c56c14a47b \ No newline at end of file diff --git a/Assets/_Project/Scripts/Stage/StageGenerator.cs b/Assets/_Project/Scripts/Stage/StageGenerator.cs new file mode 100644 index 0000000..5dd6536 --- /dev/null +++ b/Assets/_Project/Scripts/Stage/StageGenerator.cs @@ -0,0 +1,179 @@ +using System; +using System.Collections.Generic; +using UnityEngine; + +namespace BumpCombat.Stage +{ + public static class StageGenerator + { + public static StagePlan GenerateStage(int stageIndex, int seed, int targetNodeCount = 0) + { + if (targetNodeCount <= 0) + { + targetNodeCount = stageIndex switch + { + 1 => 6, + 2 => 7, + 3 => 8, + _ => 6, + }; + } + + // Stage-specific seed scrambling for deterministic distinct stages within the same run + int stageSeed = seed ^ (stageIndex * 7919); + System.Random rng = new System.Random(stageSeed); + + StagePlan plan = new StagePlan + { + StageIndex = stageIndex, + Seed = seed, + }; + + Dictionary occupied = new(); + int nextId = 1; + + StageNode CreateNode(StageNodeType type, Vector2Int pos, StageRewardType reward = StageRewardType.None, string presetId = "") + { + int nodeId = nextId++; + if (string.IsNullOrEmpty(presetId)) + { + RoomPreset preset = LevelPresetCatalog.GetPresetForNode(stageIndex, type, nodeId, stageSeed); + presetId = preset != null ? preset.PresetId : $"{type}_Default"; + } + + StageNode node = new StageNode + { + Id = nodeId, + NodeType = type, + GridPosition = pos, + RewardType = reward, + PresetId = presetId, + }; + plan.Nodes.Add(node); + occupied[pos] = node; + return node; + } + + void Connect(StageNode from, StageNode to, StageDirection dir) + { + RoomPreset fromPreset = LevelPresetCatalog.GetPreset(from.PresetId); + RoomPreset toPreset = LevelPresetCatalog.GetPreset(to.PresetId); + RoomPresetSocket? fromSocket = FindAvailableSocket(from, fromPreset, dir); + RoomPresetSocket? toSocket = FindAvailableSocket(to, toPreset, dir.Opposite()); + if (fromSocket.HasValue && toSocket.HasValue) + { + from.SetSocketConnection(fromSocket.Value.Id, dir, to.Id, toSocket.Value.Id); + to.SetSocketConnection(toSocket.Value.Id, dir.Opposite(), from.Id, fromSocket.Value.Id); + return; + } + from.SetConnection(dir, to.Id); + to.SetConnection(dir.Opposite(), from.Id); + } + + RoomPresetSocket? FindAvailableSocket(StageNode node, RoomPreset preset, StageDirection dir) + { + if (preset == null) return null; + for (int i = 0; i < preset.Sockets.Count; i++) + { + RoomPresetSocket socket = preset.Sockets[i]; + if (socket.Direction == dir && node.GetSocketConnection(socket.Id) == null) + { + return socket; + } + } + return null; + } + + // Primary progression directions: prefer East (+X) or North (+Y) + bool goEastFirst = rng.NextDouble() < 0.5; + StageDirection primaryDir = goEastFirst ? StageDirection.East : StageDirection.North; + StageDirection branchDir = goEastFirst ? StageDirection.North : StageDirection.East; + + // 1. Start Node at (0,0) + Vector2Int currentPos = Vector2Int.zero; + StageNode startNode = CreateNode(StageNodeType.Entrance, currentPos, StageRewardType.None); + plan.StartNodeId = startNode.Id; + + // 2. Initial Combat Node + currentPos += primaryDir.ToVector2Int(); + StageNode firstCombat = CreateNode(StageNodeType.Combat, currentPos, StageRewardType.None); + Connect(startNode, firstCombat, primaryDir); + + // 3. Fork Node + currentPos += primaryDir.ToVector2Int(); + StageNode forkNode = CreateNode(StageNodeType.Fork, currentPos, StageRewardType.None); + Connect(firstCombat, forkNode, primaryDir); + + // Branch A: Side Branch (Reward / Recovery) + Vector2Int branchPos = forkNode.GridPosition + branchDir.ToVector2Int(); + StageRewardType rewardType = StageRewardType.None; + + StageNode rewardNode = CreateNode(StageNodeType.Reward, branchPos, rewardType); + Connect(forkNode, rewardNode, branchDir); + + // Optional extra node on branch if targetNodeCount allows + if (targetNodeCount >= 7) + { + Vector2Int extraBranchPos = branchPos + branchDir.ToVector2Int(); + if (!occupied.ContainsKey(extraBranchPos)) + { + StageNode recoveryNode = CreateNode(StageNodeType.Recovery, extraBranchPos, StageRewardType.Recovery); + Connect(rewardNode, recoveryNode, branchDir); + } + } + + // Branch B: Major Progression Path + currentPos = forkNode.GridPosition + primaryDir.ToVector2Int(); + StageNode preBossCombat = CreateNode(StageNodeType.Combat, currentPos, StageRewardType.None); + Connect(forkNode, preBossCombat, primaryDir); + + // Additional combat node for stage 3 or large count + if (stageIndex == 3 || targetNodeCount >= 8) + { + Vector2Int extraCombatPos = currentPos + primaryDir.ToVector2Int(); + if (!occupied.ContainsKey(extraCombatPos)) + { + StageNode extraCombat = CreateNode(StageNodeType.Combat, extraCombatPos, StageRewardType.None); + Connect(preBossCombat, extraCombat, primaryDir); + currentPos = extraCombatPos; + } + } + + // Major Encounter Node + currentPos += primaryDir.ToVector2Int(); + StageNodeType majorType = stageIndex switch + { + 1 => StageNodeType.Elite, + 2 => StageNodeType.MidBoss, + _ => StageNodeType.FinalBoss, + }; + StageRewardType majorReward = stageIndex switch + { + 1 => StageRewardType.Guard, + 2 => StageRewardType.ArtifactCharge, + _ => StageRewardType.None, + }; + + StageNode bossNode = CreateNode(majorType, currentPos, majorReward); + StageNode lastCombat = occupied.TryGetValue(currentPos - primaryDir.ToVector2Int(), out var prev) ? prev : preBossCombat; + Connect(lastCombat, bossNode, primaryDir); + plan.MajorEncounterNodeId = bossNode.Id; + + // Stage Exit (Stage 1 & 2 only) + if (stageIndex < 3) + { + currentPos += primaryDir.ToVector2Int(); + StageNode exitNode = CreateNode(StageNodeType.StageExit, currentPos, StageRewardType.None); + Connect(bossNode, exitNode, primaryDir); + plan.ExitNodeId = exitNode.Id; + } + else + { + // Stage 3 ends with FinalBoss + plan.ExitNodeId = bossNode.Id; + } + + return plan; + } + } +} diff --git a/Assets/_Project/Scripts/Stage/StageGenerator.cs.meta b/Assets/_Project/Scripts/Stage/StageGenerator.cs.meta new file mode 100644 index 0000000..5d33f62 --- /dev/null +++ b/Assets/_Project/Scripts/Stage/StageGenerator.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 88a6a4cfc3af80346b8d19e35e0dd000 \ No newline at end of file diff --git a/Assets/_Project/Scripts/Stage/StageManager.cs b/Assets/_Project/Scripts/Stage/StageManager.cs new file mode 100644 index 0000000..dae398f --- /dev/null +++ b/Assets/_Project/Scripts/Stage/StageManager.cs @@ -0,0 +1,1229 @@ +using System; +using System.Collections.Generic; +using BumpCombat.Audio; +using BumpCombat.Combat; +using BumpCombat.Constants; +using BumpCombat.Core; +using BumpCombat.Player; +using BumpCombat.Progression; +using BumpCombat.Spawning; +using UnityEngine; + +namespace BumpCombat.Stage +{ + public sealed class StageManager : MonoBehaviour + { + public static StageManager Instance { get; private set; } + public static bool IsStageModeActive => Instance != null && Instance.isActiveAndEnabled && Instance.hasActiveStage; + + [SerializeField] private int customSeed = -1; + [SerializeField] private int currentStageIndex = 1; + [SerializeField] private Vector2 zoneSpacing = new(36f, 26f); + [SerializeField] private Vector2 zonePlayableHalfExtents = new(14.5f, 7.8125f); + + private readonly Dictionary zoneMap = new(); + private StagePlan currentPlan; + private ZoneInstance currentZone; + private bool hasActiveStage; + private int currentSeed; + private Sprite graveyardFloorSprite; + private Sprite obstacleSprite; + private Material floorMaterial; + + public event Action OnStageLoaded; + public event Action OnZoneChanged; + public event Action OnStageCleared; + + public StagePlan CurrentPlan => currentPlan; + public ZoneInstance CurrentZone => currentZone; + public int CurrentStageIndex => currentStageIndex; + public int CurrentSeed => currentSeed; + public bool IsTransitioning => isTransitioning; + public bool CanTriggerZoneTransition => !isTransitioning && (!Application.isPlaying || Time.time >= lastTransitionEndTime + 0.35f); + + private bool isTransitioning; + private float lastTransitionEndTime; + + private void Awake() + { + Instance = this; + } + + private void OnDestroy() + { + if (Instance == this) + { + Instance = null; + } + + if (RunManager.Instance != null) + { + RunManager.Instance.OnRunStarted -= HandleRunStarted; + } + } + + private void Start() + { + if (RunManager.Instance != null) + { + RunManager.Instance.OnRunStarted += HandleRunStarted; + // If run already started (e.g. In tests or editor fast reload) + if (!RunManager.Instance.IsTitleScreen && !hasActiveStage) + { + HandleRunStarted(); + } + } + else + { + // Autonomous startup for test scenes + StartRun(customSeed > 0 ? customSeed : 12345); + } + } + + private void HandleRunStarted() + { + if (RunManager.Instance != null && RunManager.Instance.IsDefensePrototypeRun) + { + return; + } + + int seed = customSeed > 0 + ? customSeed + : UnityEngine.Random.Range(100000, 999999); + StartRun(seed); + } + + public void StartRun(int seed) + { + currentSeed = seed; + currentStageIndex = 1; + BuildStage(currentStageIndex); + } + + public void BuildStage(int stageIndex) + { + currentStageIndex = stageIndex; + hasActiveStage = true; + + // Cache floor visual assets from scene if available + CacheFloorVisuals(); + + // Destroy previous zones + CleanupZones(); + + // Generate stage plan + currentPlan = StageGenerator.GenerateStage(stageIndex, currentSeed); + ApplyRoomScaleComparison(currentPlan); + + // Construct zones in world + foreach (StageNode node in currentPlan.Nodes) + { + Vector2 worldCenter = new Vector2( + node.GridPosition.x * zoneSpacing.x, + node.GridPosition.y * zoneSpacing.y); + + GameObject zoneObj = new GameObject($"Zone_{node.Id}_{node.NodeType}"); + zoneObj.transform.position = worldCenter; + zoneObj.transform.SetParent(transform); + + ZoneInstance zone = zoneObj.AddComponent(); + zone.Initialize(node, zonePlayableHalfExtents); + zone.SetStage1FirstCombatComparison( + stageIndex == 1 + && GameplayConstants.Current.Run.Stage1FirstCombatRoomScaleComparison + != RoomScaleComparisonMode.Disabled + && node.NodeType == StageNodeType.Combat + && IsConnected(currentPlan.StartNode, node)); + + // Build floor visual & perimeter walls with door sockets + BuildZoneGeometry(zoneObj, zone, node); + + zoneMap[node.Id] = zone; + } + + // Activate Entrance Zone + StageNode startNode = currentPlan.StartNode; + if (startNode != null && zoneMap.TryGetValue(startNode.Id, out ZoneInstance startZone)) + { + currentZone = startZone; + startZone.ActivateZone(); + + PlayerController player = FindAnyObjectByType(); + if (player != null) + { + Vector2 targetPos = startZone.ZoneCenter; + if (!player.TryReposition(targetPos)) + { + player.transform.position = targetPos; + } + } + + ArenaCameraFollow cameraFollow = FindAnyObjectByType(); + if (cameraFollow != null) + { + float targetOrtho = ArenaCameraFollow.CalculateTargetOrthographicSize( + cameraFollow.WorldCamera, + startZone.PlayableHalfExtents, + startZone.Preset, + startZone.IsStage1FirstCombatComparison); + cameraFollow.SetOrthographicSize(targetOrtho); + cameraFollow.ResetCenter(startZone.ZoneCenter); + } + } + + OnStageLoaded?.Invoke(currentPlan); + OnZoneChanged?.Invoke(currentZone); + } + + private void CacheFloorVisuals() + { + if (graveyardFloorSprite != null) return; + + ArenaBounds bounds = ArenaBounds.Resolve(); + if (bounds != null) + { + SpriteRenderer sr = bounds.GetComponent(); + if (sr != null) + { + graveyardFloorSprite = sr.sprite; + floorMaterial = sr.sharedMaterial; + // Disable the single static arena background so zone floors show + sr.enabled = false; + } + } + } + + private void BuildZoneGeometry(GameObject zoneObj, ZoneInstance zone, StageNode node) + { + Vector2 half = zone.PlayableHalfExtents; + float doorWidth = 4f; + + // 1. Floor Sprite + GameObject floor = new GameObject("Floor"); + floor.transform.SetParent(zoneObj.transform, false); + SpriteRenderer floorRenderer = floor.AddComponent(); + if (SortingLayer.NameToID("Ground") != 0) + { + floorRenderer.sortingLayerName = "Ground"; + floorRenderer.sortingOrder = -100; + } + else + { + floorRenderer.sortingLayerName = "Default"; + floorRenderer.sortingOrder = -30000; + } + + if (graveyardFloorSprite != null) + { + floorRenderer.sprite = graveyardFloorSprite; + if (floorMaterial != null) floorRenderer.sharedMaterial = floorMaterial; + + if (LevelPresetCatalog.IsExpandedComparisonPreset(zone.Preset)) + { + floorRenderer.drawMode = SpriteDrawMode.Tiled; + floorRenderer.size = new Vector2(half.x * 2f, half.y * 2f); + } + } + + RoomPreset preset = zone.Preset; + if (preset != null && zone.HasRuntimeGrid) + { + int w = preset.GridWidth; + int h = preset.GridHeight; + + // 2. Empty (□) boundaries and Obstacles (▲) + for (int y = 0; y < h; y++) + { + for (int x = 0; x < w; x++) + { + PresetCellType cellType = zone.GetRuntimeCell(x, y); + if (cellType == PresetCellType.Obstacle) + { + BuildObstacle(zoneObj, zone, preset, x, y); + } + else if (cellType == PresetCellType.Empty) + { + BuildEmptyCellCollider(zoneObj, zone, preset, x, y); + } + } + } + + // 3. Perimeter walls aligned with preset sockets + BuildPresetPerimeter(zoneObj, zone, node, preset, StageDirection.North); + BuildPresetPerimeter(zoneObj, zone, node, preset, StageDirection.South); + BuildPresetPerimeter(zoneObj, zone, node, preset, StageDirection.East); + BuildPresetPerimeter(zoneObj, zone, node, preset, StageDirection.West); + } + else + { + // Fallback for non-preset zones + BuildPerimeterWall(zoneObj, zone, node, StageDirection.North, + new Vector2(0f, half.y), + new Vector2(half.x * 2f, 1f), + doorWidth); + + BuildPerimeterWall(zoneObj, zone, node, StageDirection.South, + new Vector2(0f, -half.y), + new Vector2(half.x * 2f, 1f), + doorWidth); + + BuildPerimeterWall(zoneObj, zone, node, StageDirection.East, + new Vector2(half.x, 0f), + new Vector2(1f, half.y * 2f), + doorWidth); + + BuildPerimeterWall(zoneObj, zone, node, StageDirection.West, + new Vector2(-half.x, 0f), + new Vector2(1f, half.y * 2f), + doorWidth); + } + } + + private static Sprite plainWhiteSprite; + private static PhysicsMaterial2D frictionlessMaterial; + + private static void ApplyRoomScaleComparison(StagePlan plan) + { + if (plan == null || plan.StageIndex != 1) + { + return; + } + + RoomScaleComparisonMode mode = GameplayConstants.Current.Run + .Stage1FirstCombatRoomScaleComparison; + if (mode == RoomScaleComparisonMode.Disabled) + { + return; + } + + StageNode entrance = plan.StartNode; + if (entrance == null) + { + return; + } + + for (int i = 0; i < plan.Nodes.Count; i++) + { + StageNode node = plan.Nodes[i]; + if (node.NodeType != StageNodeType.Combat || !IsConnected(entrance, node)) + { + continue; + } + + node.PresetId = mode == RoomScaleComparisonMode.Expanded + ? LevelPresetCatalog.S1AComparisonExpandedPresetId + : "GRAVE_S1_COMBAT_A"; + return; + } + } + + private static bool IsConnected(StageNode from, StageNode to) + { + for (int direction = 0; direction < 4; direction++) + { + if (from.GetConnection((StageDirection)direction) == to.Id) + { + return true; + } + } + + return false; + } + + private static PhysicsMaterial2D GetFrictionlessMaterial() + { + if (frictionlessMaterial != null) return frictionlessMaterial; + frictionlessMaterial = new PhysicsMaterial2D("Frictionless") + { + friction = 0f, + bounciness = 0f + }; + return frictionlessMaterial; + } + + private static Sprite GetOrCreatePlainWhiteSprite() + { + if (plainWhiteSprite != null) return plainWhiteSprite; + + Texture2D tex = new Texture2D(4, 4, TextureFormat.RGBA32, false); + Color[] colors = new Color[16]; + for (int i = 0; i < 16; i++) colors[i] = Color.white; + tex.SetPixels(colors); + tex.Apply(); + tex.filterMode = FilterMode.Point; + plainWhiteSprite = Sprite.Create(tex, new Rect(0, 0, 4, 4), new Vector2(0.5f, 0.5f), 4f); + return plainWhiteSprite; + } + + private static GameObject CreateLineSegment(Transform parent, string name, Vector2 localPos, Vector2 size, Color color, int order = 5) + { + GameObject seg = new GameObject(name); + seg.transform.SetParent(parent, false); + seg.transform.localPosition = localPos; + seg.transform.localScale = size; + SpriteRenderer sr = seg.AddComponent(); + sr.sprite = GetOrCreatePlainWhiteSprite(); + sr.color = color; + sr.sortingLayerName = "Default"; + sr.sortingOrder = order; + return seg; + } + + private void BuildEmptyCellCollider(GameObject zoneObj, ZoneInstance zone, RoomPreset preset, int x, int y) + { + GameObject emptyObj = new GameObject($"EmptyCell_{x}_{y}"); + emptyObj.transform.SetParent(zoneObj.transform, false); + Vector2 localPos = preset.CellToLocalPosition(x, y); + emptyObj.transform.localPosition = localPos; + + BoxCollider2D collider = emptyObj.AddComponent(); + collider.size = new Vector2(1f, 1f); + collider.sharedMaterial = GetFrictionlessMaterial(); + emptyObj.AddComponent(); + + // Dark outside backdrop for empty cell (no redundant line segment to avoid double borders) + GameObject backdrop = CreateLineSegment( + emptyObj.transform, + "Backdrop", + Vector2.zero, + new Vector2(1f, 1f), + Color.black, + 1); + SpriteRenderer backdropRenderer = backdrop.GetComponent(); + if (SortingLayer.NameToID("Ground") != 0) + { + backdropRenderer.sortingLayerName = "Ground"; + backdropRenderer.sortingOrder = -90; + } + else + { + backdropRenderer.sortingLayerName = "Default"; + backdropRenderer.sortingOrder = -29990; + } + + zone.RegisterObstacle(emptyObj); + } + + private void BuildPresetPerimeter(GameObject zoneObj, ZoneInstance zone, StageNode node, RoomPreset preset, StageDirection dir) + { + Vector2 half = zone.PlayableHalfExtents; + List<(RoomPresetSocket socket, StageSocketConnection connection)> openings = new(); + for (int i = 0; i < preset.Sockets.Count; i++) + { + RoomPresetSocket socket = preset.Sockets[i]; + if (socket.Direction != dir) continue; + StageSocketConnection connection = node.GetSocketConnection(socket.Id); + if (connection != null && connection.TargetNodeId >= 0) + { + openings.Add((socket, connection)); + } + } + + if (openings.Count == 0) + { + int legacyTargetId = node.GetConnection(dir); + RoomPresetSocket? legacySocket = preset.GetSocket(dir); + if (legacyTargetId >= 0 && legacySocket.HasValue) + { + openings.Add((legacySocket.Value, new StageSocketConnection + { + FromSocketId = legacySocket.Value.Id, + Direction = dir, + TargetNodeId = legacyTargetId, + })); + } + } + + if (openings.Count == 0) + { + Vector2 centerOffset = dir switch + { + StageDirection.North => new Vector2(0f, half.y), + StageDirection.South => new Vector2(0f, -half.y), + StageDirection.East => new Vector2(half.x, 0f), + StageDirection.West => new Vector2(-half.x, 0f), + _ => Vector2.zero, + }; + Vector2 totalSize = (dir == StageDirection.North || dir == StageDirection.South) + ? new Vector2(half.x * 2f, 1f) + : new Vector2(1f, half.y * 2f); + + GameObject wall = new GameObject($"Wall_{dir}_Solid"); + wall.transform.SetParent(zoneObj.transform, false); + wall.transform.localPosition = centerOffset; + BoxCollider2D collider = wall.AddComponent(); + collider.size = totalSize; + collider.sharedMaterial = GetFrictionlessMaterial(); + AddWallVisual(wall, totalSize, dir, true, true); + return; + } + + bool horizontal = dir == StageDirection.North || dir == StageDirection.South; + openings.Sort((a, b) => + { + Vector2 aPos = preset.GetSocketCenterLocalPosition(a.socket); + Vector2 bPos = preset.GetSocketCenterLocalPosition(b.socket); + return (horizontal ? aPos.x : aPos.y).CompareTo(horizontal ? bPos.x : bPos.y); + }); + + float halfLength = horizontal ? half.x : half.y; + float fixedAxis = dir switch + { + StageDirection.North => half.y, + StageDirection.South => -half.y, + StageDirection.East => half.x, + StageDirection.West => -half.x, + _ => 0f, + }; + float cursor = -halfLength; + + for (int i = 0; i < openings.Count; i++) + { + RoomPresetSocket socket = openings[i].socket; + StageSocketConnection connection = openings[i].connection; + Vector2 center = preset.GetSocketCenterLocalPosition(socket); + float centerAxis = horizontal ? center.x : center.y; + float openMin = Mathf.Clamp(centerAxis - socket.Width * 0.5f, -halfLength, halfLength); + float openMax = Mathf.Clamp(centerAxis + socket.Width * 0.5f, -halfLength, halfLength); + + CreatePresetWallSegment(zoneObj, dir, cursor, openMin, fixedAxis); + cursor = Mathf.Max(cursor, openMax); + + Vector2 connectorPosition = horizontal + ? new Vector2(center.x, fixedAxis) + : new Vector2(fixedAxis, center.y); + CreatePresetConnector(zoneObj, zone, dir, socket, connection, connectorPosition); + } + + CreatePresetWallSegment(zoneObj, dir, cursor, halfLength, fixedAxis); + } + + private void CreatePresetWallSegment( + GameObject zoneObj, + StageDirection dir, + float start, + float end, + float fixedAxis) + { + float length = end - start; + if (length <= 0.05f) return; + + bool horizontal = dir == StageDirection.North || dir == StageDirection.South; + GameObject wall = new GameObject($"Wall_{dir}_Segment"); + wall.transform.SetParent(zoneObj.transform, false); + wall.transform.localPosition = horizontal + ? new Vector2((start + end) * 0.5f, fixedAxis) + : new Vector2(fixedAxis, (start + end) * 0.5f); + + Vector2 size = horizontal ? new Vector2(length, 1f) : new Vector2(1f, length); + BoxCollider2D collider = wall.AddComponent(); + collider.size = size; + collider.sharedMaterial = GetFrictionlessMaterial(); + AddWallVisual(wall, size, dir); + } + + private void CreatePresetConnector( + GameObject zoneObj, + ZoneInstance zone, + StageDirection dir, + RoomPresetSocket socket, + StageSocketConnection connection, + Vector2 localPosition) + { + GameObject connectorObj = new GameObject($"Connector_{socket.Id}_{dir}"); + connectorObj.transform.SetParent(zoneObj.transform, false); + connectorObj.transform.localPosition = localPosition; + BoxCollider2D trigger = connectorObj.AddComponent(); + trigger.isTrigger = true; + + ZoneConnector connector = connectorObj.AddComponent(); + connector.Configure( + socket.Id, + dir, + connection.TargetNodeId, + connection.TargetSocketId, + zone.LockUntilClear, + socket.Width, + 1.0f); + zone.RegisterConnector(socket.Id, dir, connector); + } + + private void AddWallVisual(GameObject wallObj, Vector2 size, StageDirection dir, bool trimStart = false, bool trimEnd = false) + { + float lineThick = 0.08f; + Color boundaryLineColor = new Color(0.45f, 0.70f, 0.95f, 0.95f); + Color darkOuterColor = new Color(0.04f, 0.06f, 0.09f, 0.80f); + + // 1. Dark outer backdrop (outside playable area) + Vector2 backdropOffset = dir switch + { + StageDirection.North => new Vector2(0f, 0.15f), + StageDirection.South => new Vector2(0f, -0.15f), + StageDirection.East => new Vector2(0.15f, 0f), + StageDirection.West => new Vector2(-0.15f, 0f), + _ => Vector2.zero, + }; + CreateLineSegment(wallObj.transform, "WallBackdrop", backdropOffset, size, darkOuterColor, 2); + + // 2. Crisp boundary line on the interior edge trimmed at corners so lines never overshoot + float trimCorner = 0.5f; + bool isHorizontal = (dir == StageDirection.North || dir == StageDirection.South); + + float lineWidth = size.x; + float linePosX = 0f; + if (isHorizontal) + { + if (trimStart && trimEnd) + { + lineWidth = Mathf.Max(0.05f, size.x - trimCorner * 2f); + } + else if (trimStart) + { + lineWidth = Mathf.Max(0.05f, size.x - trimCorner); + linePosX = trimCorner * 0.5f; + } + else if (trimEnd) + { + lineWidth = Mathf.Max(0.05f, size.x - trimCorner); + linePosX = -trimCorner * 0.5f; + } + } + + float lineHeight = size.y; + float linePosY = 0f; + if (!isHorizontal) + { + if (trimStart && trimEnd) + { + lineHeight = Mathf.Max(0.05f, size.y - trimCorner * 2f); + } + else if (trimStart) + { + lineHeight = Mathf.Max(0.05f, size.y - trimCorner); + linePosY = trimCorner * 0.5f; + } + else if (trimEnd) + { + lineHeight = Mathf.Max(0.05f, size.y - trimCorner); + linePosY = -trimCorner * 0.5f; + } + } + + Vector2 lineOffset = dir switch + { + StageDirection.North => new Vector2(linePosX, -size.y * 0.5f + lineThick * 0.5f), + StageDirection.South => new Vector2(linePosX, size.y * 0.5f - lineThick * 0.5f), + StageDirection.East => new Vector2(-size.x * 0.5f + lineThick * 0.5f, linePosY), + StageDirection.West => new Vector2(size.x * 0.5f - lineThick * 0.5f, linePosY), + _ => Vector2.zero, + }; + Vector2 lineScale = isHorizontal + ? new Vector2(lineWidth, lineThick) + : new Vector2(lineThick, lineHeight); + + CreateLineSegment(wallObj.transform, "BoundaryLine", lineOffset, lineScale, boundaryLineColor, 6); + } + + private void BuildObstacle(GameObject zoneObj, ZoneInstance zone, RoomPreset preset, int x, int y) + { + GameObject obstacleObj = new GameObject($"Obstacle_{x}_{y}"); + obstacleObj.transform.SetParent(zoneObj.transform, false); + Vector2 localPos = preset.CellToLocalPosition(x, y); + obstacleObj.transform.localPosition = localPos; + + BoxCollider2D collider = obstacleObj.AddComponent(); + collider.size = new Vector2(1f, 1f); + collider.sharedMaterial = GetFrictionlessMaterial(); + obstacleObj.AddComponent(); + + // Check adjacent cells so contiguous pillars only outline their outer perimeter! + bool openNorth = (y == 0 || preset.GridMap[x, y - 1] != PresetCellType.Obstacle); + bool openSouth = (y == preset.GridHeight - 1 || preset.GridMap[x, y + 1] != PresetCellType.Obstacle); + bool openWest = (x == 0 || preset.GridMap[x - 1, y] != PresetCellType.Obstacle); + bool openEast = (x == preset.GridWidth - 1 || preset.GridMap[x + 1, y] != PresetCellType.Obstacle); + + Color outlineColor = new Color(0.55f, 0.75f, 0.95f, 0.92f); + Color fillColor = new Color(0.10f, 0.13f, 0.18f, 0.65f); + float lineThick = 0.08f; + + // Semi-transparent dark interior fill + CreateLineSegment(obstacleObj.transform, "Fill", Vector2.zero, new Vector2(1f, 1f), fillColor, 2); + + // Outer perimeter lines for the pillar/obstacle cluster + if (openNorth) + { + CreateLineSegment(obstacleObj.transform, "Line_N", new Vector2(0f, 0.5f - lineThick * 0.5f), new Vector2(1f, lineThick), outlineColor, 5); + } + if (openSouth) + { + CreateLineSegment(obstacleObj.transform, "Line_S", new Vector2(0f, -0.5f + lineThick * 0.5f), new Vector2(1f, lineThick), outlineColor, 5); + } + if (openWest) + { + CreateLineSegment(obstacleObj.transform, "Line_W", new Vector2(-0.5f + lineThick * 0.5f, 0f), new Vector2(lineThick, 1f), outlineColor, 5); + } + if (openEast) + { + CreateLineSegment(obstacleObj.transform, "Line_E", new Vector2(0.5f - lineThick * 0.5f, 0f), new Vector2(lineThick, 1f), outlineColor, 5); + } + + zone.RegisterObstacle(obstacleObj); + } + + private Sprite GetOrCreateObstacleSprite() + { + if (obstacleSprite != null) return obstacleSprite; + +#if UNITY_EDITOR + obstacleSprite = UnityEditor.AssetDatabase.LoadAssetAtPath("Assets/_Project/Art/Environment/Graveyard-v1/GraveSlab.png"); + if (obstacleSprite != null) return obstacleSprite; +#endif + + // Try loading existing grave slab via Resources + obstacleSprite = Resources.Load("Environment/Graveyard-v1/GraveSlab"); + if (obstacleSprite != null) return obstacleSprite; + + // Autonomous stone slab procedural sprite fallback + int texSize = 32; + Texture2D tex = new Texture2D(texSize, texSize, TextureFormat.RGBA32, false); + Color darkStone = new Color(0.18f, 0.20f, 0.24f, 1f); + Color stoneBorder = new Color(0.35f, 0.38f, 0.44f, 1f); + Color highlight = new Color(0.45f, 0.48f, 0.55f, 1f); + + for (int cy = 0; cy < texSize; cy++) + { + for (int cx = 0; cx < texSize; cx++) + { + bool isEdge = (cx == 0 || cx == texSize - 1 || cy == 0 || cy == texSize - 1); + bool isTopHighlight = (cy == texSize - 2 && cx > 1 && cx < texSize - 2); + Color c = isTopHighlight ? highlight : (isEdge ? stoneBorder : darkStone); + tex.SetPixel(cx, cy, c); + } + } + tex.Apply(); + tex.filterMode = FilterMode.Point; + obstacleSprite = Sprite.Create(tex, new Rect(0, 0, texSize, texSize), new Vector2(0.5f, 0.5f), 32f); + return obstacleSprite; + } + + private void BuildPerimeterWall(GameObject zoneObj, ZoneInstance zone, StageNode node, + StageDirection dir, Vector2 wallCenterOffset, Vector2 wallTotalSize, float doorWidth) + { + int targetId = node.GetConnection(dir); + bool hasSocket = targetId >= 0; + + if (!hasSocket) + { + // Solid wall + GameObject wall = new GameObject($"Wall_{dir}"); + wall.transform.SetParent(zoneObj.transform, false); + wall.transform.localPosition = wallCenterOffset; + BoxCollider2D collider = wall.AddComponent(); + collider.size = wallTotalSize; + collider.sharedMaterial = GetFrictionlessMaterial(); + AddWallVisual(wall, wallTotalSize, dir, true, true); + } + else + { + // Split wall around socket + bool isHorizontal = (dir == StageDirection.North || dir == StageDirection.South); + if (isHorizontal) + { + float totalWidth = wallTotalSize.x; + float sideWidth = (totalWidth - doorWidth) * 0.5f; + float leftOffset = -(sideWidth + doorWidth) * 0.5f; + float rightOffset = (sideWidth + doorWidth) * 0.5f; + + GameObject leftWall = new GameObject($"Wall_{dir}_L"); + leftWall.transform.SetParent(zoneObj.transform, false); + leftWall.transform.localPosition = new Vector2(leftOffset, wallCenterOffset.y); + BoxCollider2D colL = leftWall.AddComponent(); + colL.size = new Vector2(sideWidth, wallTotalSize.y); + colL.sharedMaterial = GetFrictionlessMaterial(); + AddWallVisual(leftWall, new Vector2(sideWidth, wallTotalSize.y), dir, true, false); + + GameObject rightWall = new GameObject($"Wall_{dir}_R"); + rightWall.transform.SetParent(zoneObj.transform, false); + rightWall.transform.localPosition = new Vector2(rightOffset, wallCenterOffset.y); + BoxCollider2D colR = rightWall.AddComponent(); + colR.size = new Vector2(sideWidth, wallTotalSize.y); + colR.sharedMaterial = GetFrictionlessMaterial(); + AddWallVisual(rightWall, new Vector2(sideWidth, wallTotalSize.y), dir, false, true); + } + else + { + float totalHeight = wallTotalSize.y; + float sideHeight = (totalHeight - doorWidth) * 0.5f; + float bottomOffset = -(sideHeight + doorWidth) * 0.5f; + float topOffset = (sideHeight + doorWidth) * 0.5f; + + GameObject btmWall = new GameObject($"Wall_{dir}_B"); + btmWall.transform.SetParent(zoneObj.transform, false); + btmWall.transform.localPosition = new Vector2(wallCenterOffset.x, bottomOffset); + BoxCollider2D colB = btmWall.AddComponent(); + colB.size = new Vector2(wallTotalSize.x, sideHeight); + colB.sharedMaterial = GetFrictionlessMaterial(); + AddWallVisual(btmWall, new Vector2(wallTotalSize.x, sideHeight), dir, true, false); + + GameObject topWall = new GameObject($"Wall_{dir}_T"); + topWall.transform.SetParent(zoneObj.transform, false); + topWall.transform.localPosition = new Vector2(wallCenterOffset.x, topOffset); + BoxCollider2D colT = topWall.AddComponent(); + colT.size = new Vector2(wallTotalSize.x, sideHeight); + colT.sharedMaterial = GetFrictionlessMaterial(); + AddWallVisual(topWall, new Vector2(wallTotalSize.x, sideHeight), dir, false, true); + } + + // Connector trigger in doorway (extended into room for reliable touch) + GameObject connectorObj = new GameObject($"Connector_{dir}"); + connectorObj.transform.SetParent(zoneObj.transform, false); + Vector2 triggerOffset = dir switch + { + StageDirection.North => wallCenterOffset + new Vector2(0f, -0.5f), + StageDirection.South => wallCenterOffset + new Vector2(0f, 0.5f), + StageDirection.East => wallCenterOffset + new Vector2(-0.5f, 0f), + StageDirection.West => wallCenterOffset + new Vector2(0.5f, 0f), + _ => wallCenterOffset, + }; + connectorObj.transform.localPosition = wallCenterOffset; + BoxCollider2D trigger = connectorObj.AddComponent(); + trigger.isTrigger = true; + + ZoneConnector connector = connectorObj.AddComponent(); + connector.Configure(dir, targetId, zone.LockUntilClear, doorWidth, 1.0f); + zone.RegisterConnector(dir, connector); + } + } + + public void RequestZoneTransition(int targetNodeId, StageDirection entrySocketDirection, string entrySocketId = null) + { + if (isTransitioning || !CanTriggerZoneTransition) + { + return; + } + + if (!zoneMap.TryGetValue(targetNodeId, out ZoneInstance nextZone)) + { + return; + } + + ZoneConnector destinationConnector = !string.IsNullOrEmpty(entrySocketId) + ? nextZone.GetConnector(entrySocketId) + : nextZone.GetConnector(entrySocketDirection); + if (destinationConnector == null + || destinationConnector.Direction != entrySocketDirection) + { + return; + } + + PlayerController player = FindAnyObjectByType(); + Collider2D playerBody = player != null ? player.GetComponent() : null; + if (playerBody != null + && !nextZone.TryGetEntryPosition( + entrySocketDirection, + entrySocketId, + playerBody, + out _)) + { + return; + } + + if (!Application.isPlaying) + { + ExecuteTransitionImmediate(nextZone, entrySocketDirection, entrySocketId); + return; + } + + StartCoroutine(CoTransitionZone(nextZone, entrySocketDirection, entrySocketId)); + } + + public bool CheatTeleportToBossRoom() + { + if (currentPlan == null || zoneMap == null || zoneMap.Count == 0) + { + return false; + } + + // Priority: FinalBoss -> MidBoss -> Elite + StageNode targetNode = null; + foreach (var node in currentPlan.Nodes) + { + if (node.NodeType == StageNodeType.FinalBoss) + { + targetNode = node; + break; + } + } + if (targetNode == null) + { + foreach (var node in currentPlan.Nodes) + { + if (node.NodeType == StageNodeType.MidBoss) + { + targetNode = node; + break; + } + } + } + if (targetNode == null) + { + foreach (var node in currentPlan.Nodes) + { + if (node.NodeType == StageNodeType.Elite) + { + targetNode = node; + break; + } + } + } + + if (targetNode == null || !zoneMap.TryGetValue(targetNode.Id, out ZoneInstance bossZone)) + { + return false; + } + + if (currentZone == bossZone) + { + return false; + } + + StageDirection entryDir = StageDirection.West; + if (bossZone.Preset != null && bossZone.Preset.Sockets.Count > 0) + { + entryDir = bossZone.Preset.Sockets[0].Direction; + } + + ExecuteTransitionImmediate(bossZone, entryDir); + return true; + } + + private static Vector2 GetInwardDirection(StageDirection entryDirection) + { + return entryDirection switch + { + StageDirection.West => Vector2.right, + StageDirection.East => Vector2.left, + StageDirection.North => Vector2.down, + StageDirection.South => Vector2.up, + _ => Vector2.right, + }; + } + + private void ExecuteTransitionImmediate(ZoneInstance nextZone, StageDirection entrySocketDirection, string entrySocketId = null) + { + ZoneConnector destinationConnector = !string.IsNullOrEmpty(entrySocketId) + ? nextZone.GetConnector(entrySocketId) + : nextZone.GetConnector(entrySocketDirection); + PlayerController player = FindAnyObjectByType(); + Collider2D playerBody = player != null ? player.GetComponent() : null; + Vector2 targetPos; + if (playerBody == null) + { + targetPos = nextZone.GetEntryPosition(entrySocketDirection, entrySocketId); + } + else if (!nextZone.TryGetEntryPosition( + entrySocketDirection, + entrySocketId, + playerBody, + out targetPos)) + { + return; + } + + if (currentZone != null) + { + currentZone.DeactivateZone(); + } + + currentZone = nextZone; + + ArenaBounds bounds = ArenaBounds.Resolve(); + if (bounds != null) + { + bounds.SetZoneBounds(nextZone.ZoneCenter, nextZone.PlayableHalfExtents); + } + + if (player != null) + { + Vector2 inwardDir = GetInwardDirection(entrySocketDirection); + player.SetTransitionLocked(true); + if (!player.TryReposition(targetPos)) + { + player.transform.position = targetPos; + } + player.SetFacingDirection(inwardDir); + Physics2D.SyncTransforms(); + destinationConnector?.SuppressUntilSeparated(playerBody); + player.SetTransitionLocked(false); + } + + currentZone.ActivateZone(); + + ArenaCameraFollow cameraFollow = FindAnyObjectByType(); + if (cameraFollow != null) + { + float targetOrtho = ArenaCameraFollow.CalculateTargetOrthographicSize( + cameraFollow.WorldCamera, + nextZone.PlayableHalfExtents, + nextZone.Preset, + nextZone.IsStage1FirstCombatComparison); + cameraFollow.SetOrthographicSize(targetOrtho); + cameraFollow.ResetCenter(targetPos); + } + + lastTransitionEndTime = Time.time; + isTransitioning = false; + OnZoneChanged?.Invoke(currentZone); + BumpCombatAudioService.Instance?.PlayUi("ui_confirm"); + + if (nextZone.NodeType == StageNodeType.StageExit) + { + HandleStageExitReached(); + } + } + + private System.Collections.IEnumerator CoTransitionZone(ZoneInstance nextZone, StageDirection entrySocketDirection, string entrySocketId = null) + { + ZoneInstance prevZone = currentZone; + ZoneConnector destinationConnector = !string.IsNullOrEmpty(entrySocketId) + ? nextZone.GetConnector(entrySocketId) + : nextZone.GetConnector(entrySocketDirection); + + PlayerController player = FindAnyObjectByType(); + Collider2D playerBody = player != null ? player.GetComponent() : null; + Vector2 targetPos; + if (playerBody == null) + { + targetPos = nextZone.GetEntryPosition(entrySocketDirection, entrySocketId); + } + else if (!nextZone.TryGetEntryPosition( + entrySocketDirection, + entrySocketId, + playerBody, + out targetPos)) + { + yield break; + } + + isTransitioning = true; + if (player != null) + { + player.SetTransitionLocked(true); + } + + if (prevZone != null) + { + prevZone.DeactivateZone(); + } + + currentZone = nextZone; + + // 1. Update bounds and position player at the doorway threshold of the next room + ArenaBounds bounds = ArenaBounds.Resolve(); + if (bounds != null) + { + bounds.SetZoneBounds(nextZone.ZoneCenter, nextZone.PlayableHalfExtents); + } + + Vector2 inwardDir = GetInwardDirection(entrySocketDirection); + + if (player != null) + { + player.SetFacingDirection(inwardDir); + if (!player.TryReposition(targetPos)) + { + player.transform.position = targetPos; + } + Physics2D.SyncTransforms(); + destinationConnector?.SuppressUntilSeparated(playerBody); + } + + // 2. Apply the fixed stage viewport at the doorway. Keeping this + // immediate prevents a room-center sweep from revealing the room. + ArenaCameraFollow cameraFollow = FindAnyObjectByType(); + Camera cam = cameraFollow != null ? cameraFollow.WorldCamera : null; + float targetOrtho = ArenaCameraFollow.CalculateTargetOrthographicSize( + cam, + nextZone.PlayableHalfExtents, + nextZone.Preset, + nextZone.IsStage1FirstCombatComparison); + float duration = 0.35f; + if (cameraFollow != null) + { + cameraFollow.SetOrthographicSize(targetOrtho); + cameraFollow.ResetCenter(targetPos); + } + + yield return new WaitForSecondsRealtime(duration); + + // 3. Settle player precisely at doorway threshold facing inwards + if (player != null) + { + if (!player.TryReposition(targetPos)) + { + player.transform.position = targetPos; + } + player.SetFacingDirection(inwardDir); + Physics2D.SyncTransforms(); + } + + if (cameraFollow != null) + { + cameraFollow.SetOrthographicSize(targetOrtho); + cameraFollow.ResetCenter(targetPos); + } + + // 4. Activate new zone (monsters wake up and see player at doorway) + currentZone.ActivateZone(); + + if (player != null) + { + player.SetTransitionLocked(false); + } + + lastTransitionEndTime = Time.time; + isTransitioning = false; + OnZoneChanged?.Invoke(currentZone); + BumpCombatAudioService.Instance?.PlayUi("ui_confirm"); + + if (nextZone.NodeType == StageNodeType.StageExit) + { + HandleStageExitReached(); + } + } + + private void HandleStageExitReached() + { + OnStageCleared?.Invoke(currentStageIndex); + if (currentStageIndex < 3) + { + AdvanceToNextStage(); + } + } + + public void AdvanceToNextStage() + { + BuildStage(currentStageIndex + 1); + } + + public void SpawnEncounterForZone(ZoneInstance zone) + { + SpawnDirector spawnDirector = FindAnyObjectByType(); + if (spawnDirector != null) + { + spawnDirector.SpawnZoneEncounter(zone); + } + + // Handle Reward / Recovery nodes + if (zone.NodeType == StageNodeType.Reward) + { + SpawnRewardNodePickup(zone); + } + else if (zone.NodeType == StageNodeType.Recovery) + { + SpawnRecoveryNodePickup(zone); + } + } + + private void SpawnRewardNodePickup(ZoneInstance zone) + { + zone.SetCleared(); + } + + private void SpawnRecoveryNodePickup(ZoneInstance zone) + { + if (zone == null || zone.Preset == null || zone.Preset.RewardAnchor.x < 0 || zone.Preset.RewardAnchor.y < 0) + { + return; + } + + GameObject triggerObject = new("RecoveryAnchor"); + triggerObject.transform.SetParent(zone.transform, true); + triggerObject.transform.position = zone.Preset.CellToWorldPosition(zone.ZoneCenter, zone.Preset.RewardAnchor); + + CircleCollider2D triggerCollider = triggerObject.AddComponent(); + triggerCollider.isTrigger = true; + triggerCollider.radius = 0.75f; + + RecoveryAnchorTrigger trigger = triggerObject.AddComponent(); + trigger.Initialize(zone); + } + + public void HandleMajorBossDefeated(StageNodeType bossType, ZoneInstance defeatedZone = null) + { + ZoneInstance ownerZone = defeatedZone != null ? defeatedZone : currentZone; + if (ownerZone != null) + { + ownerZone.SetCleared(); + } + + RunManager run = RunManager.Instance ?? FindAnyObjectByType(); + ArtifactRewardController rewardCtrl = FindAnyObjectByType(); + + if (bossType == StageNodeType.Elite) + { + run?.UnlockGuard(); + rewardCtrl?.QueueColorReward(ArtifactColor.Red); + } + else if (bossType == StageNodeType.MidBoss) + { + run?.UnlockArtifactCharge(); + rewardCtrl?.QueueColorReward(ArtifactColor.Blue); + } + else if (bossType == StageNodeType.FinalBoss) + { + run?.EndRunAsClear(); + } + } + + private void CleanupZones() + { + foreach (var kvp in zoneMap) + { + if (kvp.Value != null) + { + if (Application.isPlaying) + { + Destroy(kvp.Value.gameObject); + } + else + { + DestroyImmediate(kvp.Value.gameObject); + } + } + } + zoneMap.Clear(); + currentZone = null; + } + } + + internal sealed class RecoveryAnchorTrigger : MonoBehaviour + { + private ZoneInstance zone; + private bool consumed; + + public void Initialize(ZoneInstance ownerZone) + { + zone = ownerZone; + } + + private void OnTriggerEnter2D(Collider2D other) + { + if (consumed) + { + return; + } + + PlayerHealth playerHealth = other.GetComponentInParent(); + if (playerHealth == null) + { + return; + } + + consumed = true; + playerHealth.Heal(playerHealth.MaxHealth * 0.5f); + zone?.SetCleared(); + Destroy(gameObject); + } + } +} diff --git a/Assets/_Project/Scripts/Stage/StageManager.cs.meta b/Assets/_Project/Scripts/Stage/StageManager.cs.meta new file mode 100644 index 0000000..5e18227 --- /dev/null +++ b/Assets/_Project/Scripts/Stage/StageManager.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 3a9bd30ad88a2b6439ddd24c147ba25b \ No newline at end of file diff --git a/Assets/_Project/Scripts/Stage/StageObstacle.cs b/Assets/_Project/Scripts/Stage/StageObstacle.cs new file mode 100644 index 0000000..d735446 --- /dev/null +++ b/Assets/_Project/Scripts/Stage/StageObstacle.cs @@ -0,0 +1,72 @@ +using System.Collections.Generic; +using BumpCombat.Enemies; +using UnityEngine; + +namespace BumpCombat.Stage +{ + [ExecuteAlways] + [RequireComponent(typeof(Collider2D))] + public sealed class StageObstacle : MonoBehaviour + { + private static readonly HashSet activeObstacles = new(); + public static IReadOnlyCollection AllObstacles => activeObstacles; + + private Collider2D obstacleCollider; + public Collider2D Collider => obstacleCollider != null ? obstacleCollider : (obstacleCollider = GetComponent()); + + private void Awake() + { + if (obstacleCollider == null) + { + obstacleCollider = GetComponent(); + } + activeObstacles.Add(this); + } + + private void OnEnable() + { + activeObstacles.Add(this); + IgnoreCollisionsWithFlyingEnemies(); + } + + private void OnDisable() + { + activeObstacles.Remove(this); + } + + private void OnDestroy() + { + activeObstacles.Remove(this); + } + + public void IgnoreCollisionsWithFlyingEnemies() + { + if (Collider == null) return; + + EnemyController[] enemies = FindObjectsByType(FindObjectsSortMode.None); + for (int i = 0; i < enemies.Length; i++) + { + EnemyController enemy = enemies[i]; + if (enemy != null && enemy.IsFlying && enemy.BodyCollider != null) + { + Physics2D.IgnoreCollision(enemy.BodyCollider, Collider, true); + } + } + } + + public static void RegisterFlyingEnemy(EnemyController flyingEnemy) + { + if (flyingEnemy == null || !flyingEnemy.IsFlying || flyingEnemy.BodyCollider == null) return; + + activeObstacles.RemoveWhere(obs => obs == null); + + foreach (StageObstacle obstacle in activeObstacles) + { + if (obstacle != null && obstacle.Collider != null) + { + Physics2D.IgnoreCollision(flyingEnemy.BodyCollider, obstacle.Collider, true); + } + } + } + } +} diff --git a/Assets/_Project/Scripts/Stage/StageObstacle.cs.meta b/Assets/_Project/Scripts/Stage/StageObstacle.cs.meta new file mode 100644 index 0000000..65420e8 --- /dev/null +++ b/Assets/_Project/Scripts/Stage/StageObstacle.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 310bf4383bf41c64e85c457bc29aed17 \ No newline at end of file diff --git a/Assets/_Project/Scripts/Stage/ZoneConnector.cs b/Assets/_Project/Scripts/Stage/ZoneConnector.cs new file mode 100644 index 0000000..310439a --- /dev/null +++ b/Assets/_Project/Scripts/Stage/ZoneConnector.cs @@ -0,0 +1,397 @@ +using System; +using BumpCombat.Player; +using UnityEngine; + +namespace BumpCombat.Stage +{ + [RequireComponent(typeof(Collider2D))] + public sealed class ZoneConnector : MonoBehaviour + { + [SerializeField] private string socketId = string.Empty; + [SerializeField] private StageDirection direction; + [SerializeField] private int targetNodeId = -1; + [SerializeField] private string targetSocketId = string.Empty; + [SerializeField] private bool isLocked; + + [SerializeField] private float socketWidth = 2f; + [SerializeField] private float padDepth = 1f; + + private const float ArrivalClearance = 0.03f; + private const float FallbackPlayerBodyExtent = 0.275f; + private Collider2D triggerCollider; + private BoxCollider2D barrierCollider; + private SpriteRenderer barrierVisual; + private Collider2D playerWaitingForSeparation; + + public string SocketId => socketId; + public StageDirection Direction => direction; + public int TargetNodeId => targetNodeId; + public string TargetSocketId => targetSocketId; + public bool IsLocked => isLocked; + + public bool TryGetArrivalPosition(Collider2D playerBody, out Vector2 position) + { + EnsureComponents(); + position = transform.position; + if (triggerCollider == null) + { + return false; + } + + Bounds triggerBounds = triggerCollider.bounds; + Vector2 inward = GetInwardDirection(direction); + Vector2 bodyExtents = playerBody != null && playerBody.enabled + ? playerBody.bounds.extents + : new Vector2(FallbackPlayerBodyExtent, FallbackPlayerBodyExtent); + Vector2 bodyOffset = playerBody != null && playerBody.enabled + ? (Vector2)playerBody.bounds.center - (Vector2)playerBody.transform.position + : Vector2.zero; + Vector2 bodyCenter = triggerBounds.center; + + if (inward.x > 0f) + { + bodyCenter.x = triggerBounds.max.x + bodyExtents.x + ArrivalClearance; + } + else if (inward.x < 0f) + { + bodyCenter.x = triggerBounds.min.x - bodyExtents.x - ArrivalClearance; + } + else if (inward.y > 0f) + { + bodyCenter.y = triggerBounds.max.y + bodyExtents.y + ArrivalClearance; + } + else if (inward.y < 0f) + { + bodyCenter.y = triggerBounds.min.y - bodyExtents.y - ArrivalClearance; + } + + position = bodyCenter - bodyOffset; + return true; + } + + public void SuppressUntilSeparated(Collider2D playerBody) + { + if (playerBody != null + && triggerCollider != null + && triggerCollider.bounds.Intersects(playerBody.bounds)) + { + playerWaitingForSeparation = playerBody; + } + } + + public void Configure(string sockId, StageDirection dir, int targetId, string targetSockId, bool locked = false, float width = 2f, float depth = 1f) + { + socketId = sockId; + direction = dir; + targetNodeId = targetId; + targetSocketId = targetSockId ?? string.Empty; + isLocked = locked; + socketWidth = Mathf.Max(0.5f, width); + padDepth = Mathf.Max(0.4f, depth); + EnsureComponents(); + UpdateVisuals(); + } + + public void Configure(StageDirection dir, int targetId, bool locked = false, float width = 2f, float depth = 1f) + { + Configure(string.Empty, dir, targetId, string.Empty, locked, width, depth); + } + + public void Configure(string sockId, StageDirection dir, int targetId, bool locked = false, float width = 2f, float depth = 1f) + { + Configure(sockId, dir, targetId, string.Empty, locked, width, depth); + } + + public void SetLocked(bool locked) + { + isLocked = locked; + UpdateVisuals(); + } + + private void Awake() + { + EnsureComponents(); + UpdateVisuals(); + } + + private GameObject gatePadObj; + private SpriteRenderer padRenderer; + private GameObject labelObj; + private TextMesh labelTextMesh; + + private void EnsureComponents() + { + if (triggerCollider == null) + { + triggerCollider = GetComponent(); + if (triggerCollider != null) + { + triggerCollider.isTrigger = true; + } + } + + // Align trigger collider inwards inside the room boundary + if (triggerCollider is BoxCollider2D boxCol) + { + bool isHorizontal = (direction == StageDirection.North || direction == StageDirection.South); + Vector2 colSize = isHorizontal + ? new Vector2(socketWidth, padDepth) + : new Vector2(padDepth, socketWidth); + + Vector2 colOffset = direction switch + { + StageDirection.North => new Vector2(0f, -padDepth * 0.5f), + StageDirection.South => new Vector2(0f, padDepth * 0.5f), + StageDirection.East => new Vector2(-padDepth * 0.5f, 0f), + StageDirection.West => new Vector2(padDepth * 0.5f, 0f), + _ => Vector2.zero, + }; + + boxCol.size = colSize; + boxCol.offset = colOffset; + } + + if (barrierCollider == null) + { + Transform barrierTransform = transform.Find("BarrierCollider"); + GameObject barrierObject = barrierTransform != null + ? barrierTransform.gameObject + : new GameObject("BarrierCollider"); + barrierObject.transform.SetParent(transform, false); + barrierObject.transform.localPosition = Vector3.zero; + barrierCollider = barrierObject.GetComponent(); + if (barrierCollider == null) + { + barrierCollider = barrierObject.AddComponent(); + } + barrierCollider.isTrigger = false; + } + + if (barrierCollider != null) + { + bool isHorizontal = direction == StageDirection.North || direction == StageDirection.South; + barrierCollider.size = isHorizontal + ? new Vector2(socketWidth, 0.3f) + : new Vector2(0.3f, socketWidth); + barrierCollider.offset = Vector2.zero; + barrierCollider.enabled = isLocked; + } + + EnsureVisuals(); + } + + private void EnsureVisuals() + { + if (gatePadObj == null) + { + Transform padTransform = transform.Find("GatePad"); + if (padTransform != null) + { + gatePadObj = padTransform.gameObject; + padRenderer = gatePadObj.GetComponent(); + } + else + { + gatePadObj = new GameObject("GatePad"); + gatePadObj.transform.SetParent(transform, false); + padRenderer = gatePadObj.AddComponent(); + padRenderer.sprite = GetOrCreatePadSprite(); + padRenderer.sortingLayerName = "Default"; + padRenderer.sortingOrder = -50; + } + } + + // Align GatePad inwards so it never protrudes past room perimeter lines + if (gatePadObj != null) + { + bool isHorizontal = (direction == StageDirection.North || direction == StageDirection.South); + Vector2 padScale = isHorizontal + ? new Vector2(socketWidth * 0.5f, padDepth * 0.5f) + : new Vector2(padDepth * 0.5f, socketWidth * 0.5f); + + Vector2 padOffset = direction switch + { + StageDirection.North => new Vector2(0f, -padDepth * 0.5f), + StageDirection.South => new Vector2(0f, padDepth * 0.5f), + StageDirection.East => new Vector2(-padDepth * 0.5f, 0f), + StageDirection.West => new Vector2(padDepth * 0.5f, 0f), + _ => Vector2.zero, + }; + + gatePadObj.transform.localScale = padScale; + gatePadObj.transform.localPosition = padOffset; + } + + if (labelObj == null) + { + Transform labelTransform = transform.Find("GateLabel"); + if (labelTransform != null) + { + labelObj = labelTransform.gameObject; + labelTextMesh = labelObj.GetComponent(); + } + else + { + labelObj = new GameObject("GateLabel"); + labelObj.transform.SetParent(transform, false); + labelTextMesh = labelObj.AddComponent(); + labelTextMesh.anchor = TextAnchor.MiddleCenter; + labelTextMesh.alignment = TextAlignment.Center; + labelTextMesh.fontSize = 28; + labelTextMesh.characterSize = 0.055f; + labelTextMesh.fontStyle = FontStyle.Bold; + + MeshRenderer mr = labelObj.GetComponent(); + if (mr != null) + { + mr.sortingLayerName = "Default"; + mr.sortingOrder = 500; + } + } + } + + if (labelObj != null) + { + // Position label inwards past the pad threshold + float labelDist = padDepth + 0.4f; + Vector2 labelOffset = direction switch + { + StageDirection.East => new Vector2(-labelDist, 0f), + StageDirection.West => new Vector2(labelDist, 0f), + StageDirection.North => new Vector2(0f, -labelDist), + StageDirection.South => new Vector2(0f, labelDist), + _ => Vector2.zero, + }; + labelObj.transform.localPosition = labelOffset; + } + + if (barrierVisual == null) + { + Transform visualTransform = transform.Find("BarrierVisual"); + if (visualTransform != null) + { + barrierVisual = visualTransform.GetComponent(); + } + } + } + + private void UpdateVisuals() + { + EnsureVisuals(); + + if (padRenderer != null) + { + padRenderer.color = isLocked + ? new Color(0.9f, 0.25f, 0.2f, 0.8f) // Red-orange when locked + : new Color(0.2f, 0.85f, 0.95f, 0.75f); // Bright cyan when open + } + + if (labelTextMesh != null) + { + string arrow = direction switch + { + StageDirection.East => "▶", + StageDirection.West => "◀", + StageDirection.North => "▲", + StageDirection.South => "▼", + _ => "▶", + }; + string socketName = string.IsNullOrEmpty(socketId) ? "출구" : socketId; + labelTextMesh.text = isLocked + ? $"[ 잠김 {arrow} ]" + : $"[ {socketName} 출구 {arrow} ]"; + labelTextMesh.color = isLocked + ? new Color(1f, 0.35f, 0.35f, 1f) + : new Color(0.4f, 1f, 0.85f, 1f); + } + + if (barrierVisual != null) + { + barrierVisual.gameObject.SetActive(isLocked); + } + + if (barrierCollider != null) + { + barrierCollider.enabled = isLocked; + } + } + + private static Sprite padSprite; + private static Sprite GetOrCreatePadSprite() + { + if (padSprite != null) return padSprite; + + int size = 32; + Texture2D tex = new Texture2D(size, size, TextureFormat.RGBA32, false); + Color fill = new Color(1f, 1f, 1f, 0.85f); + Color border = new Color(1f, 1f, 1f, 1f); + + for (int y = 0; y < size; y++) + { + for (int x = 0; x < size; x++) + { + bool isBorder = (x <= 1 || x >= size - 2 || y <= 1 || y >= size - 2); + tex.SetPixel(x, y, isBorder ? border : fill); + } + } + tex.Apply(); + tex.filterMode = FilterMode.Point; + padSprite = Sprite.Create(tex, new Rect(0, 0, size, size), new Vector2(0.5f, 0.5f), 16f); + return padSprite; + } + + private static Vector2 GetInwardDirection(StageDirection entryDirection) + { + return entryDirection switch + { + StageDirection.West => Vector2.right, + StageDirection.East => Vector2.left, + StageDirection.North => Vector2.down, + StageDirection.South => Vector2.up, + _ => Vector2.right, + }; + } + + private void OnTriggerEnter2D(Collider2D other) + { + TryHandlePlayerTrigger(other); + } + + private void OnTriggerStay2D(Collider2D other) + { + TryHandlePlayerTrigger(other); + } + + private void TryHandlePlayerTrigger(Collider2D other) + { + PlayerController player = other.GetComponent() + ?? other.GetComponentInParent(); + if (player == null || playerWaitingForSeparation == other) + { + return; + } + + if (isLocked || targetNodeId < 0) + { + return; + } + + StageManager stageManager = StageManager.Instance; + if (stageManager != null + && (stageManager.IsTransitioning || !stageManager.CanTriggerZoneTransition)) + { + return; + } + + stageManager?.RequestZoneTransition(targetNodeId, direction.Opposite(), targetSocketId); + } + + private void OnTriggerExit2D(Collider2D other) + { + if (playerWaitingForSeparation == other) + { + playerWaitingForSeparation = null; + } + } + } +} diff --git a/Assets/_Project/Scripts/Stage/ZoneConnector.cs.meta b/Assets/_Project/Scripts/Stage/ZoneConnector.cs.meta new file mode 100644 index 0000000..269e5e5 --- /dev/null +++ b/Assets/_Project/Scripts/Stage/ZoneConnector.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: b932041c3dc4d3a47a4261ef873089ad \ No newline at end of file diff --git a/Assets/_Project/Scripts/Stage/ZoneInstance.cs b/Assets/_Project/Scripts/Stage/ZoneInstance.cs new file mode 100644 index 0000000..d1e9c7b --- /dev/null +++ b/Assets/_Project/Scripts/Stage/ZoneInstance.cs @@ -0,0 +1,879 @@ +using System; +using System.Collections.Generic; +using BumpCombat.Constants; +using BumpCombat.Core; +using BumpCombat.Enemies; +using BumpCombat.Player; +using UnityEngine; + +namespace BumpCombat.Stage +{ + public sealed class ZoneInstance : MonoBehaviour + { + [SerializeField] private int nodeId; + [SerializeField] private StageNodeType nodeType; + [SerializeField] private Vector2 playableHalfExtents = new(14.5f, 7.8125f); + [SerializeField] private bool lockUntilClear; + + private readonly Dictionary connectorsById = new(StringComparer.OrdinalIgnoreCase); + private readonly Dictionary connectorsByDir = new(); + private readonly List spawnedEnemies = new(); + private readonly List spawnedObstacles = new(); + private readonly HashSet fogHiddenEnemies = new(); + private StageNode nodeData; + private RoomPreset preset; + private PresetCellType[,] runtimeGrid; + private bool[,] visibleCells; + private bool[,] exploredCells; + private Vector2Int lastVisibilityOrigin = new(int.MinValue, int.MinValue); + private PlayerController visibilityPlayer; + private StageFogOfWar fogOfWar; + private int visibilityRevision; + private bool hasSpawnedEncounter; + private bool isCleared; + + public int NodeId => nodeId; + public StageNodeType NodeType => nodeType; + public Vector2 PlayableHalfExtents => playableHalfExtents; + public Vector2 ZoneCenter => (Vector2)transform.position; + public bool LockUntilClear => lockUntilClear; + public bool IsCleared => isCleared; + public bool IsStage1FirstCombatComparison { get; private set; } + public RoomPreset Preset => preset; + public StageNode NodeData => nodeData; + public bool HasRuntimeGrid => runtimeGrid != null; + public IReadOnlyList SpawnedEnemies => spawnedEnemies; + public IReadOnlyList SpawnedObstacles => spawnedObstacles; + public StageFogOfWar FogOfWar => fogOfWar; + public int VisibilityRevision => visibilityRevision; + + public void SetStage1FirstCombatComparison(bool value) + { + IsStage1FirstCombatComparison = value; + } + + public void Initialize(StageNode node, Vector2 halfExtents) + { + nodeData = node; + nodeId = node.Id; + nodeType = node.NodeType; + + preset = LevelPresetCatalog.GetPreset(node.PresetId); + if (preset != null) + { + playableHalfExtents = preset.PlayableHalfExtents; + runtimeGrid = BuildRuntimeGrid(node, preset); + } + else + { + playableHalfExtents = halfExtents; + runtimeGrid = null; + } + + if (runtimeGrid != null && preset != null) + { + visibleCells = new bool[preset.GridWidth, preset.GridHeight]; + exploredCells = new bool[preset.GridWidth, preset.GridHeight]; + if (Application.isPlaying) + { + fogOfWar = gameObject.AddComponent(); + fogOfWar.Initialize(this); + } + } + + // Major encounters are locked until cleared. Normal combat is break-through (LockUntilClear = false). + lockUntilClear = nodeType switch + { + StageNodeType.Elite => true, + StageNodeType.MidBoss => true, + StageNodeType.FinalBoss => true, + _ => false, + }; + + isCleared = node.Cleared; + } + + public PresetCellType GetRuntimeCell(int x, int y) + { + return runtimeGrid[x, y]; + } + + public bool IsCellVisible(int x, int y) + { + return IsInsideGrid(x, y) && visibleCells != null && visibleCells[x, y]; + } + + public bool IsCellExplored(int x, int y) + { + return IsInsideGrid(x, y) && exploredCells != null && exploredCells[x, y]; + } + + public bool IsWorldPositionVisible(Vector2 worldPosition) + { + if (preset == null || visibleCells == null) + { + return false; + } + + Vector2Int cell = RoomPathfinder.WorldToCell(preset, ZoneCenter, worldPosition); + return IsCellVisible(cell.x, cell.y); + } + + public bool UpdateVisibility(Vector2 playerWorldPosition) + { + if (preset == null || runtimeGrid == null || visibleCells == null) + { + return false; + } + + Vector2Int origin = RoomPathfinder.WorldToCell( + preset, + ZoneCenter, + playerWorldPosition); + if (!IsInsideGrid(origin.x, origin.y)) + { + return false; + } + + if (origin == lastVisibilityOrigin) + { + RefreshEnemyVisibility(); + return false; + } + + lastVisibilityOrigin = origin; + float radius = Mathf.Max( + 0.1f, + GameplayConstants.Current.Run.StagePlayerVisibilityRadius); + float radiusSquared = radius * radius; + bool changed = false; + + for (int y = 0; y < preset.GridHeight; y++) + { + for (int x = 0; x < preset.GridWidth; x++) + { + PresetCellType cellType = runtimeGrid[x, y]; + bool wasVisible = visibleCells[x, y]; + bool visible = false; + if (cellType != PresetCellType.Empty) + { + Vector2 cellPosition = preset.CellToWorldPosition( + ZoneCenter, + new Vector2Int(x, y)); + bool inRadius = (cellPosition - playerWorldPosition).sqrMagnitude + <= radiusSquared; + visible = inRadius + && HasGridLineOfSight(origin, new Vector2Int(x, y)); + } + + visibleCells[x, y] = visible; + changed |= wasVisible != visible; + if (visible && !exploredCells[x, y]) + { + exploredCells[x, y] = true; + changed = true; + } + } + } + + if (changed) + { + visibilityRevision++; + fogOfWar?.Refresh(); + } + + RefreshEnemyVisibility(); + return changed; + } + + private void RefreshEnemyVisibility() + { + for (int i = 0; i < spawnedEnemies.Count; i++) + { + EnemyController enemy = spawnedEnemies[i]; + if (enemy == null) + { + continue; + } + + bool visible = IsWorldPositionVisible(enemy.GroundAnchorPosition); + if (visible) + { + if (fogHiddenEnemies.Remove(enemy)) + { + enemy.SetFogVisibility(true); + } + } + else if (fogHiddenEnemies.Add(enemy)) + { + enemy.SetFogVisibility(false); + } + } + + } + + private bool HasGridLineOfSight(Vector2Int from, Vector2Int to) + { + int x = from.x; + int y = from.y; + int dx = Mathf.Abs(to.x - from.x); + int dy = Mathf.Abs(to.y - from.y); + int stepX = from.x < to.x ? 1 : -1; + int stepY = from.y < to.y ? 1 : -1; + int error = dx - dy; + + while (x != to.x || y != to.y) + { + int twiceError = error * 2; + bool moveX = twiceError > -dy; + bool moveY = twiceError < dx; + int nextX = x + (moveX ? stepX : 0); + int nextY = y + (moveY ? stepY : 0); + if (moveX && moveY + && (!IsTransparentCell(nextX, y) + || !IsTransparentCell(x, nextY))) + { + return false; + } + + if (moveX) + { + error -= dy; + x = nextX; + } + if (moveY) + { + error += dx; + y = nextY; + } + + if (x == to.x && y == to.y) + { + return true; + } + + if (!IsInsideGrid(x, y) + || runtimeGrid[x, y] == PresetCellType.Empty + || runtimeGrid[x, y] == PresetCellType.Obstacle) + { + return false; + } + } + + return true; + } + + private bool IsTransparentCell(int x, int y) + { + return IsInsideGrid(x, y) + && runtimeGrid[x, y] != PresetCellType.Empty + && runtimeGrid[x, y] != PresetCellType.Obstacle; + } + + private bool IsInsideGrid(int x, int y) + { + return preset != null + && x >= 0 + && x < preset.GridWidth + && y >= 0 + && y < preset.GridHeight; + } + + private static PresetCellType[,] BuildRuntimeGrid(StageNode node, RoomPreset roomPreset) + { + if (roomPreset.GridMap == null) + { + return null; + } + + int width = roomPreset.GridWidth; + int height = roomPreset.GridHeight; + PresetCellType[,] resolvedGrid = new PresetCellType[width, height]; + for (int y = 0; y < height; y++) + { + for (int x = 0; x < width; x++) + { + PresetCellType cellType = roomPreset.GridMap[x, y]; + resolvedGrid[x, y] = cellType == PresetCellType.Socket + && !IsPresetSocketActive(node, roomPreset, x, y) + ? PresetCellType.Empty + : cellType; + } + } + + for (int i = 0; i < roomPreset.Sockets.Count; i++) + { + RoomPresetSocket socket = roomPreset.Sockets[i]; + if (!IsPresetSocketActive(node, roomPreset, socket.CellPosition.x, socket.CellPosition.y)) + { + continue; + } + + bool horizontal = socket.Direction == StageDirection.North || socket.Direction == StageDirection.South; + for (int offset = 0; offset < socket.Width; offset++) + { + int x = socket.CellPosition.x + (horizontal ? offset : 0); + int y = socket.CellPosition.y + (horizontal ? 0 : offset); + + while (x >= 0 && x < width && y >= 0 && y < height) + { + resolvedGrid[x, y] = PresetCellType.Socket; + + if (socket.Direction == StageDirection.North) y--; + else if (socket.Direction == StageDirection.South) y++; + else if (socket.Direction == StageDirection.East) x++; + else if (socket.Direction == StageDirection.West) x--; + } + } + } + + return resolvedGrid; + } + + private static bool IsPresetSocketActive(StageNode node, RoomPreset roomPreset, int x, int y) + { + for (int i = 0; i < roomPreset.Sockets.Count; i++) + { + RoomPresetSocket socket = roomPreset.Sockets[i]; + bool horizontal = socket.Direction == StageDirection.North || socket.Direction == StageDirection.South; + bool containsCell = horizontal + ? y == socket.CellPosition.y && x >= socket.CellPosition.x && x < socket.CellPosition.x + socket.Width + : x == socket.CellPosition.x && y >= socket.CellPosition.y && y < socket.CellPosition.y + socket.Width; + + if (!containsCell) continue; + + StageSocketConnection connection = node.GetSocketConnection(socket.Id); + if (connection != null && connection.TargetNodeId >= 0) + { + return true; + } + + bool hasSocketConnectionInDirection = false; + for (int j = 0; j < roomPreset.Sockets.Count; j++) + { + RoomPresetSocket directionSocket = roomPreset.Sockets[j]; + if (directionSocket.Direction != socket.Direction) continue; + + StageSocketConnection directionConnection = node.GetSocketConnection(directionSocket.Id); + if (directionConnection != null && directionConnection.TargetNodeId >= 0) + { + hasSocketConnectionInDirection = true; + break; + } + } + + if (!hasSocketConnectionInDirection && node.GetConnection(socket.Direction) >= 0) + { + RoomPresetSocket? legacySocket = roomPreset.GetSocket(socket.Direction); + return legacySocket.HasValue + && string.Equals(legacySocket.Value.Id, socket.Id, StringComparison.OrdinalIgnoreCase); + } + + return false; + } + + return false; + } + + public void RegisterConnector(string socketId, StageDirection dir, ZoneConnector connector) + { + if (connector == null) return; + if (!string.IsNullOrEmpty(socketId)) + { + connectorsById[socketId] = connector; + } + connectorsByDir[dir] = connector; + connector.SetLocked(lockUntilClear && !isCleared); + } + + public void RegisterConnector(StageDirection dir, ZoneConnector connector) + { + RegisterConnector(string.Empty, dir, connector); + } + + public void RegisterObstacle(GameObject obstacle) + { + if (obstacle != null && !spawnedObstacles.Contains(obstacle)) + { + spawnedObstacles.Add(obstacle); + Collider2D obsCollider = obstacle.GetComponent(); + if (obsCollider != null) + { + for (int i = 0; i < spawnedEnemies.Count; i++) + { + EnemyController enemy = spawnedEnemies[i]; + if (enemy != null && enemy.IsFlying && enemy.BodyCollider != null) + { + Physics2D.IgnoreCollision(enemy.BodyCollider, obsCollider, true); + } + } + } + } + } + + public ZoneConnector GetConnector(string socketId) + { + if (!string.IsNullOrEmpty(socketId) && connectorsById.TryGetValue(socketId, out var connector)) + { + return connector; + } + return null; + } + + public ZoneConnector GetConnector(StageDirection dir) + { + connectorsByDir.TryGetValue(dir, out var connector); + return connector; + } + + public Vector2 GetEntryPosition( + StageDirection entryDirection, + string socketId = null, + Collider2D playerBody = null) + { + if (TryGetEntryPosition(entryDirection, socketId, playerBody, out Vector2 arrivalPosition)) + { + return arrivalPosition; + } + + if (preset != null) + { + return ZoneCenter + preset.GetEntryLocalPosition(entryDirection, socketId); + } + + Vector2 center = ZoneCenter; + return entryDirection switch + { + StageDirection.North => center + new Vector2(0f, playableHalfExtents.y - 1.35f), + StageDirection.South => center + new Vector2(0f, -playableHalfExtents.y + 1.35f), + StageDirection.East => center + new Vector2(playableHalfExtents.x - 1.35f, 0f), + StageDirection.West => center + new Vector2(-playableHalfExtents.x + 1.35f, 0f), + _ => center, + }; + } + + public bool TryGetEntryPosition( + StageDirection entryDirection, + string socketId, + Collider2D playerBody, + out Vector2 position) + { + position = default; + ZoneConnector connector = !string.IsNullOrEmpty(socketId) + ? GetConnector(socketId) + : GetConnector(entryDirection); + if (connector == null + || !connector.TryGetArrivalPosition(playerBody, out Vector2 arrivalPosition)) + { + return false; + } + + return TryFindSafeArrivalPosition(connector, arrivalPosition, playerBody, out position); + } + + private bool TryFindSafeArrivalPosition( + ZoneConnector connector, + Vector2 candidate, + Collider2D playerBody, + out Vector2 safePosition) + { + safePosition = default; + if (playerBody == null) + { + safePosition = candidate; + return true; + } + + Vector2 inward = connector.Direction switch + { + StageDirection.West => Vector2.right, + StageDirection.East => Vector2.left, + StageDirection.North => Vector2.down, + StageDirection.South => Vector2.up, + _ => Vector2.right, + }; + Vector2 bodyOffset = (Vector2)playerBody.bounds.center - (Vector2)playerBody.transform.position; + Vector2 bodyExtents = playerBody.bounds.extents; + const float arrivalSearchStep = 0.25f; + float maxDistance = Mathf.Max(playableHalfExtents.x, playableHalfExtents.y) * 2f; + + for (float distance = 0f; distance <= maxDistance; distance += arrivalSearchStep) + { + Vector2 candidatePosition = candidate + inward * distance; + Vector2 candidateBodyCenter = candidatePosition + bodyOffset; + if (IsArrivalPositionClear( + candidatePosition, + candidateBodyCenter, + bodyExtents, + playerBody)) + { + safePosition = candidatePosition; + return true; + } + } + + return false; + } + + private bool IsArrivalPositionClear( + Vector2 playerPosition, + Vector2 bodyCenter, + Vector2 bodyExtents, + Collider2D playerBody) + { + ArenaBounds bounds = ArenaBounds.Resolve(); + Vector2 playerPadding = bounds != null + ? bounds.PlayerPadding + : GameplayConstants.Current.Run.ArenaPlayerPadding; + Vector2 localPosition = playerPosition - ZoneCenter; + Vector2 localClamp = new Vector2( + Mathf.Max(0f, playableHalfExtents.x - playerPadding.x), + Mathf.Max(0f, playableHalfExtents.y - playerPadding.y)); + if (Mathf.Abs(localPosition.x) > localClamp.x + 0.0001f + || Mathf.Abs(localPosition.y) > localClamp.y + 0.0001f) + { + return false; + } + + if (runtimeGrid != null && preset != null) + { + for (int x = -1; x <= 1; x += 2) + { + for (int y = -1; y <= 1; y += 2) + { + Vector2 sample = bodyCenter + new Vector2( + bodyExtents.x * 0.8f * x, + bodyExtents.y * 0.8f * y); + Vector2Int cell = RoomPathfinder.WorldToCell(preset, ZoneCenter, sample); + if (!RoomPathfinder.IsWalkable(preset, cell.x, cell.y) + || runtimeGrid[cell.x, cell.y] == PresetCellType.Empty + || runtimeGrid[cell.x, cell.y] == PresetCellType.Obstacle) + { + return false; + } + } + } + } + + Collider2D[] overlaps = Physics2D.OverlapBoxAll(bodyCenter, bodyExtents * 2f, 0f); + for (int i = 0; i < overlaps.Length; i++) + { + Collider2D collider = overlaps[i]; + if (collider == null + || collider.isTrigger + || collider == playerBody + || collider.transform.IsChildOf(playerBody.transform)) + { + continue; + } + + if (collider.GetComponentInParent() != null + || collider.GetComponentInParent() != null + || collider.name.StartsWith("Wall_") + || collider.gameObject.name.Contains("Wall")) + { + return false; + } + } + + return true; + } + + public void SetCleared() + { + isCleared = true; + if (nodeData != null) + { + nodeData.Cleared = true; + } + + HashSet unlocked = new(); + foreach (var kvp in connectorsById) + { + if (kvp.Value != null && unlocked.Add(kvp.Value)) + { + kvp.Value.SetLocked(false); + } + } + + foreach (var kvp in connectorsByDir) + { + if (kvp.Value != null && unlocked.Add(kvp.Value)) + { + kvp.Value.SetLocked(false); + } + } + } + + public void ActivateZone() + { + gameObject.SetActive(true); + if (nodeData != null) + { + nodeData.Visited = true; + nodeData.IsCurrent = true; + } + + ArenaBounds bounds = ArenaBounds.Resolve(); + if (bounds != null) + { + bounds.SetZoneBounds(ZoneCenter, playableHalfExtents); + } + + // Reactivate alive enemies belonging to this zone + for (int i = 0; i < spawnedEnemies.Count; i++) + { + if (spawnedEnemies[i] != null && !spawnedEnemies[i].IsDead) + { + spawnedEnemies[i].gameObject.SetActive(true); + } + } + + if (!hasSpawnedEncounter && !isCleared) + { + SpawnZoneEncounter(); + } + + visibilityPlayer = FindAnyObjectByType(); + if (visibilityPlayer != null) + { + lastVisibilityOrigin = new Vector2Int(int.MinValue, int.MinValue); + UpdateVisibility(visibilityPlayer.transform.position); + } + } + + private void Update() + { + if (!isActiveAndEnabled + || StageManager.Instance == null + || StageManager.Instance.CurrentZone != this) + { + return; + } + + visibilityPlayer ??= FindAnyObjectByType(); + if (visibilityPlayer != null) + { + UpdateVisibility(visibilityPlayer.transform.position); + } + } + + public void DeactivateZone() + { + if (nodeData != null) + { + nodeData.IsCurrent = false; + } + + // Cleanup dead references and deactivate alive enemies + for (int i = spawnedEnemies.Count - 1; i >= 0; i--) + { + if (spawnedEnemies[i] == null || spawnedEnemies[i].IsDead) + { + spawnedEnemies.RemoveAt(i); + } + else + { + // Keep alive enemies intact but deactivate so they don't participate in other zones + spawnedEnemies[i].gameObject.SetActive(false); + } + } + } + + private void SpawnZoneEncounter() + { + hasSpawnedEncounter = true; + + // Handled via StageManager and SpawnDirector + StageManager stageManager = StageManager.Instance; + if (stageManager != null) + { + stageManager.SpawnEncounterForZone(this); + } + } + + public void RegisterSpawnedEnemy(EnemyController enemy) + { + if (enemy == null) return; + enemy.transform.SetParent(transform, true); + spawnedEnemies.Add(enemy); + enemy.OnDied += HandleEnemyDied; + + if (enemy.IsFlying && enemy.BodyCollider != null) + { + for (int i = 0; i < spawnedObstacles.Count; i++) + { + GameObject obstacle = spawnedObstacles[i]; + if (obstacle != null) + { + Collider2D obsCollider = obstacle.GetComponent(); + if (obsCollider != null) + { + Physics2D.IgnoreCollision(enemy.BodyCollider, obsCollider, true); + } + } + } + } + + if (StageManager.Instance != null + && StageManager.Instance.CurrentZone == this) + { + RefreshEnemyVisibility(); + } + } + + private void HandleEnemyDied(EnemyController enemy) + { + spawnedEnemies.Remove(enemy); + // Record clear on all enemies dead (both for locked bosses and normal breakthrough rooms) + if (spawnedEnemies.Count == 0) + { + SetCleared(); + } + } + } + + public sealed class StageFogOfWar : MonoBehaviour + { + private ZoneInstance zone; + private Mesh mesh; + private MeshRenderer meshRenderer; + private Material fogMaterial; + private Color[] vertexColors; + + public void Initialize(ZoneInstance owner) + { + zone = owner; + BuildMesh(); + Refresh(); + } + + public float GetCellFogAlpha(int x, int y) + { + if (zone == null || zone.Preset == null) + { + return 1f; + } + + if (zone.GetRuntimeCell(x, y) == PresetCellType.Empty) + { + return 0f; + } + + if (zone.IsCellVisible(x, y)) + { + return 0f; + } + + return zone.IsCellExplored(x, y) + ? Mathf.Clamp01(GameplayConstants.Current.Run.StageExploredFogOpacity) + : Mathf.Clamp01(GameplayConstants.Current.Run.StageUnseenFogOpacity); + } + + public void Refresh() + { + if (zone == null || zone.Preset == null || mesh == null) + { + return; + } + + RoomPreset preset = zone.Preset; + for (int y = 0; y < preset.GridHeight; y++) + { + for (int x = 0; x < preset.GridWidth; x++) + { + float alpha = GetCellFogAlpha(x, y); + int vertex = (y * preset.GridWidth + x) * 4; + Color fogColor = new(0f, 0f, 0f, alpha); + vertexColors[vertex] = fogColor; + vertexColors[vertex + 1] = fogColor; + vertexColors[vertex + 2] = fogColor; + vertexColors[vertex + 3] = fogColor; + } + } + + mesh.colors = vertexColors; + } + + private void BuildMesh() + { + if (zone == null || zone.Preset == null) + { + return; + } + + RoomPreset preset = zone.Preset; + int cellCount = preset.GridWidth * preset.GridHeight; + Vector3[] vertices = new Vector3[cellCount * 4]; + Vector2[] uv = new Vector2[cellCount * 4]; + int[] triangles = new int[cellCount * 6]; + vertexColors = new Color[cellCount * 4]; + + for (int y = 0; y < preset.GridHeight; y++) + { + for (int x = 0; x < preset.GridWidth; x++) + { + Vector2 center = preset.CellToLocalPosition(x, y); + int vertex = (y * preset.GridWidth + x) * 4; + vertices[vertex] = new Vector3(center.x - 0.5f, center.y - 0.5f, 0f); + vertices[vertex + 1] = new Vector3(center.x - 0.5f, center.y + 0.5f, 0f); + vertices[vertex + 2] = new Vector3(center.x + 0.5f, center.y + 0.5f, 0f); + vertices[vertex + 3] = new Vector3(center.x + 0.5f, center.y - 0.5f, 0f); + uv[vertex] = Vector2.zero; + uv[vertex + 1] = Vector2.up; + uv[vertex + 2] = Vector2.one; + uv[vertex + 3] = Vector2.right; + + int triangle = (y * preset.GridWidth + x) * 6; + triangles[triangle] = vertex; + triangles[triangle + 1] = vertex + 1; + triangles[triangle + 2] = vertex + 2; + triangles[triangle + 3] = vertex; + triangles[triangle + 4] = vertex + 2; + triangles[triangle + 5] = vertex + 3; + } + } + + mesh = new Mesh + { + name = $"{zone.name} Fog Mesh", + hideFlags = HideFlags.HideAndDontSave, + }; + mesh.vertices = vertices; + mesh.uv = uv; + mesh.triangles = triangles; + mesh.colors = vertexColors; + mesh.MarkDynamic(); + + MeshFilter meshFilter = gameObject.AddComponent(); + meshFilter.sharedMesh = mesh; + meshRenderer = gameObject.AddComponent(); + meshRenderer.sortingLayerName = "Default"; + meshRenderer.sortingOrder = 32760; + + Shader shader = Shader.Find("Sprites/Default"); + if (shader != null) + { + fogMaterial = new Material(shader) + { + hideFlags = HideFlags.HideAndDontSave, + }; + meshRenderer.sharedMaterial = fogMaterial; + } + } + + private void OnDestroy() + { + if (Application.isPlaying) + { + if (mesh != null) Destroy(mesh); + if (fogMaterial != null) Destroy(fogMaterial); + } + else + { + if (mesh != null) DestroyImmediate(mesh); + if (fogMaterial != null) DestroyImmediate(fogMaterial); + } + } + } +} diff --git a/Assets/_Project/Scripts/Stage/ZoneInstance.cs.meta b/Assets/_Project/Scripts/Stage/ZoneInstance.cs.meta new file mode 100644 index 0000000..0c14424 --- /dev/null +++ b/Assets/_Project/Scripts/Stage/ZoneInstance.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 7cf2808cfbde3d84480fceef82b7f3a8 \ No newline at end of file diff --git a/Assets/_Project/Scripts/UI/MissionAchievementPanel.cs b/Assets/_Project/Scripts/UI/MissionAchievementPanel.cs index aa9ea87..6be611a 100644 --- a/Assets/_Project/Scripts/UI/MissionAchievementPanel.cs +++ b/Assets/_Project/Scripts/UI/MissionAchievementPanel.cs @@ -331,18 +331,19 @@ namespace BumpCombat.UI 24, TextAnchor.MiddleLeft, Vector2.zero, - new Vector2(220f, 110f)); + new Vector2(240f, 220f)); RectTransform hintRect = hintText.rectTransform; hintRect.anchorMin = new Vector2(0f, 1f); hintRect.anchorMax = new Vector2(0f, 1f); hintRect.pivot = new Vector2(0f, 1f); - hintRect.anchoredPosition = new Vector2(24f, -250f); + hintRect.anchoredPosition = new Vector2(24f, -230f); hintText.supportRichText = true; hintText.resizeTextForBestFit = false; hintText.horizontalOverflow = HorizontalWrapMode.Wrap; hintText.verticalOverflow = VerticalWrapMode.Overflow; - hintText.text = "TAB\n" - + "(미션 / 업적)"; + hintText.text = "TAB (미션/업적)\n\n" + + "조작 안내\n" + + "방향키 [▲▼◀▶]: 이동\n[A]: 스킬 / [D]: 가드\n[S]: 교체 / [M]: 지도"; hintText.color = Color.white; } diff --git a/Assets/_Project/Scripts/UI/RunClearPanel.cs b/Assets/_Project/Scripts/UI/RunClearPanel.cs new file mode 100644 index 0000000..aa0b1da --- /dev/null +++ b/Assets/_Project/Scripts/UI/RunClearPanel.cs @@ -0,0 +1,137 @@ +using BumpCombat.Audio; +using BumpCombat.Core; +using BumpCombat.Progression; +using UnityEngine; +using UnityEngine.InputSystem; +using UnityEngine.UI; + +namespace BumpCombat.UI +{ + public sealed class RunClearPanel : MonoBehaviour + { + private GameObject panel; + private Text titleText; + private Text statsText; + private Text restartText; + private bool isShown; + + private void Start() + { + CreatePanel(); + if (RunManager.Instance != null) + { + RunManager.Instance.OnRunCleared += HandleRunCleared; + } + } + + private void OnDestroy() + { + if (RunManager.Instance != null) + { + RunManager.Instance.OnRunCleared -= HandleRunCleared; + } + } + + private void Update() + { + if (!isShown || Keyboard.current == null) + { + return; + } + + if (Keyboard.current.rKey.wasPressedThisFrame) + { + RunManager.Instance?.RestartRun(); + } + else if (Keyboard.current.escapeKey.wasPressedThisFrame) + { + RunManager.Instance?.RestartToTitle(); + } + } + + private void HandleRunCleared() + { + isShown = true; + panel.SetActive(true); + BumpCombatAudioService.Instance?.PlayUi("ui_confirm"); + + float elapsed = RunManager.Instance != null ? RunManager.Instance.ElapsedTime : 0f; + int minutes = Mathf.FloorToInt(elapsed / 60f); + int seconds = Mathf.FloorToInt(elapsed % 60f); + + ExperienceSystem exp = FindAnyObjectByType(); + int level = exp != null ? exp.Level : 1; + + statsText.text = RunManager.Instance != null && RunManager.Instance.IsDefensePrototypeRun ? $"CLEAR TIME: {minutes:D2}:{seconds:D2}\nCONSTRUCTION REWARD COLLECTED\nDEFENSE TEST CLEARED" : $"CLEAR TIME: {minutes:D2}:{seconds:D2}\nFINAL LEVEL: LV {level}\nSTAGE: 3 / 3 COMPLETED"; + } + + private void CreatePanel() + { + Canvas canvas = GetComponentInParent() ?? FindAnyObjectByType(); + if (canvas == null) + { + return; + } + + panel = new GameObject("RunClearPanel"); + panel.transform.SetParent(canvas.transform, false); + RectTransform panelRect = panel.AddComponent(); + panelRect.anchorMin = Vector2.zero; + panelRect.anchorMax = Vector2.one; + panelRect.offsetMin = Vector2.zero; + panelRect.offsetMax = Vector2.zero; + + // Background dark dim with gold tint + Image bg = panel.AddComponent(); + bg.color = new Color(0.08f, 0.08f, 0.04f, 0.94f); + + // Title + GameObject titleObj = new GameObject("ClearTitle"); + titleObj.transform.SetParent(panel.transform, false); + titleText = titleObj.AddComponent(); + titleText.font = Resources.GetBuiltinResource("LegacyRuntime.ttf"); + titleText.fontSize = 42; + titleText.fontStyle = FontStyle.Bold; + titleText.alignment = TextAnchor.MiddleCenter; + titleText.color = new Color(1f, 0.85f, 0.25f); + titleText.text = RunManager.Instance != null && RunManager.Instance.IsDefensePrototypeRun ? "★ DEFENSE TEST CLEARED ★" : "★ RUN CLEAR ★"; + RectTransform titleRect = titleObj.GetComponent(); + titleRect.anchorMin = new Vector2(0f, 0.6f); + titleRect.anchorMax = new Vector2(1f, 0.8f); + titleRect.offsetMin = Vector2.zero; + titleRect.offsetMax = Vector2.zero; + + // Stats + GameObject statsObj = new GameObject("ClearStats"); + statsObj.transform.SetParent(panel.transform, false); + statsText = statsObj.AddComponent(); + statsText.font = Resources.GetBuiltinResource("LegacyRuntime.ttf"); + statsText.fontSize = 20; + statsText.alignment = TextAnchor.MiddleCenter; + statsText.color = new Color(0.9f, 0.9f, 0.95f); + statsText.lineSpacing = 1.4f; + RectTransform statsRect = statsObj.GetComponent(); + statsRect.anchorMin = new Vector2(0.1f, 0.35f); + statsRect.anchorMax = new Vector2(0.9f, 0.55f); + statsRect.offsetMin = Vector2.zero; + statsRect.offsetMax = Vector2.zero; + + // Restart Hint + GameObject restartObj = new GameObject("ClearRestartHint"); + restartObj.transform.SetParent(panel.transform, false); + restartText = restartObj.AddComponent(); + restartText.font = Resources.GetBuiltinResource("LegacyRuntime.ttf"); + restartText.fontSize = 16; + restartText.alignment = TextAnchor.MiddleCenter; + restartText.color = new Color(0.7f, 0.7f, 0.75f); + restartText.text = "[R] 키로 재시작 | [ESC] 키로 타이틀로 이동"; + RectTransform restartRect = restartObj.GetComponent(); + restartRect.anchorMin = new Vector2(0f, 0.15f); + restartRect.anchorMax = new Vector2(1f, 0.25f); + restartRect.offsetMin = Vector2.zero; + restartRect.offsetMax = Vector2.zero; + + panel.SetActive(false); + } + } +} diff --git a/Assets/_Project/Scripts/UI/RunClearPanel.cs.meta b/Assets/_Project/Scripts/UI/RunClearPanel.cs.meta new file mode 100644 index 0000000..0050cd1 --- /dev/null +++ b/Assets/_Project/Scripts/UI/RunClearPanel.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 5c63f44ba2673624d9e2fcdb546c8268 \ No newline at end of file diff --git a/Assets/_Project/Scripts/UI/RunHUD.cs b/Assets/_Project/Scripts/UI/RunHUD.cs index 8710994..0cb831b 100644 --- a/Assets/_Project/Scripts/UI/RunHUD.cs +++ b/Assets/_Project/Scripts/UI/RunHUD.cs @@ -53,13 +53,17 @@ namespace BumpCombat.UI private Button[] debugSpawnButtons = System.Array.Empty