convert game playing..

This commit is contained in:
2026-09-23 12:53:52 +09:00
parent 37c7942b71
commit 5d11288a6f
136 changed files with 15856 additions and 299 deletions
@@ -45,7 +45,7 @@ TextureImporter:
compressionQuality: 50 compressionQuality: 50
spriteMode: 1 spriteMode: 1
spriteExtrude: 1 spriteExtrude: 1
spriteMeshType: 1 spriteMeshType: 0
alignment: 0 alignment: 0
spritePivot: {x: 0.5, y: 0.5} spritePivot: {x: 0.5, y: 0.5}
spritePixelsToUnits: 32 spritePixelsToUnits: 32
@@ -12,6 +12,7 @@ MonoBehaviour:
m_Script: {fileID: 11500000, guid: db153e77cef645c49a74705c9d542ea5, type: 3} m_Script: {fileID: 11500000, guid: db153e77cef645c49a74705c9d542ea5, type: 3}
m_Name: CombatConstants m_Name: CombatConstants
m_EditorClassIdentifier: BumpCombat.Runtime::BumpCombat.Constants.CombatConstants m_EditorClassIdentifier: BumpCombat.Runtime::BumpCombat.Constants.CombatConstants
enableFrontalBumpSelfDamage: 0
FrontThreshold: 0.707 FrontThreshold: 0.707
BackThreshold: -0.5 BackThreshold: -0.5
PositionDamageMultiplier: 1.2 PositionDamageMultiplier: 1.2
@@ -27,6 +27,9 @@ MonoBehaviour:
CameraShakeDuration: 0.12 CameraShakeDuration: 0.12
CameraShakePixels: 3 CameraShakePixels: 3
HeavyCameraShakePixels: 5 HeavyCameraShakePixels: 5
RearHitCameraPunchDuration: 0.07
RearHitCameraPunchPixels: 1
RearHitCameraPunchRearmDelay: 0.45
HeavyKnockbackThreshold: 1 HeavyKnockbackThreshold: 1
BlockedBumpHoldDuration: 0.08 BlockedBumpHoldDuration: 0.08
BlockedBumpFadeDuration: 0.04 BlockedBumpFadeDuration: 0.04
+8
View File
@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: c70ac61900f04f0fb18a608ef6f6c38c
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
@@ -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
@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 1949e4e0aabb41ff8e66ddd2ee493ba3
NativeFormatImporter:
externalObjects: {}
mainObjectFileID: 11400000
userData:
assetBundleName:
assetBundleVariant:
@@ -16,7 +16,7 @@ MonoBehaviour:
role: 0 role: 0
attackShape: 1 attackShape: 1
maxHealth: 42 maxHealth: 42
moveSpeed: 1.1 moveSpeed: 1.375
attackDamage: 18 attackDamage: 18
warningDuration: 0.85 warningDuration: 0.85
activeDuration: 0.25 activeDuration: 0.25
+4 -3
View File
@@ -13,10 +13,10 @@ MonoBehaviour:
m_Name: Bat m_Name: Bat
m_EditorClassIdentifier: BumpCombat.Runtime::BumpCombat.Enemies.EnemyDefinition m_EditorClassIdentifier: BumpCombat.Runtime::BumpCombat.Enemies.EnemyDefinition
kind: 5 kind: 5
role: 1 role: 0
attackShape: 0 attackShape: 0
maxHealth: 8 maxHealth: 8
moveSpeed: 1.5 moveSpeed: 1.875
attackDamage: 8 attackDamage: 8
warningDuration: 0.35 warningDuration: 0.35
activeDuration: 0.25 activeDuration: 0.25
@@ -27,7 +27,7 @@ MonoBehaviour:
attackWidth: 0 attackWidth: 0
attackRadius: 0.45 attackRadius: 0.45
attackAnimationLeadTime: 0 attackAnimationLeadTime: 0
experienceValue: 1 experienceValue: 2
attackPatterns: attackPatterns:
- animationState: Attack - animationState: Attack
attackShape: 0 attackShape: 0
@@ -42,3 +42,4 @@ MonoBehaviour:
damageMultiplier: 1 damageMultiplier: 1
separationRadius: 0.55 separationRadius: 0.55
separationStrength: 0.6 separationStrength: 0.6
isFlying: 1
@@ -16,7 +16,7 @@ MonoBehaviour:
role: 0 role: 0
attackShape: 2 attackShape: 2
maxHealth: 50 maxHealth: 50
moveSpeed: 0.9 moveSpeed: 1.125
attackDamage: 18 attackDamage: 18
warningDuration: 1.2 warningDuration: 1.2
activeDuration: 0.22 activeDuration: 0.22
@@ -16,7 +16,7 @@ MonoBehaviour:
role: 0 role: 0
attackShape: 1 attackShape: 1
maxHealth: 40 maxHealth: 40
moveSpeed: 1.15 moveSpeed: 1.4375
attackDamage: 15 attackDamage: 15
warningDuration: 0.9 warningDuration: 0.9
activeDuration: 0.18 activeDuration: 0.18
@@ -16,7 +16,7 @@ MonoBehaviour:
role: 0 role: 0
attackShape: 1 attackShape: 1
maxHealth: 60 maxHealth: 60
moveSpeed: 0.65 moveSpeed: 0.8125
attackDamage: 30 attackDamage: 30
warningDuration: 1.05 warningDuration: 1.05
activeDuration: 0.25 activeDuration: 0.25
@@ -16,7 +16,7 @@ MonoBehaviour:
role: 0 role: 0
attackShape: 1 attackShape: 1
maxHealth: 12 maxHealth: 12
moveSpeed: 0.9 moveSpeed: 1.125
attackDamage: 10 attackDamage: 10
warningDuration: 1.5 warningDuration: 1.5
activeDuration: 0.375 activeDuration: 0.375
@@ -44,3 +44,4 @@ MonoBehaviour:
damageMultiplier: 1 damageMultiplier: 1
separationRadius: 0.55 separationRadius: 0.55
separationStrength: 0.6 separationStrength: 0.6
isFlying: 1
@@ -16,7 +16,7 @@ MonoBehaviour:
role: 0 role: 0
attackShape: 2 attackShape: 2
maxHealth: 60 maxHealth: 60
moveSpeed: 0.55 moveSpeed: 0.6875
attackDamage: 30 attackDamage: 30
warningDuration: 1.1 warningDuration: 1.1
activeDuration: 0.375 activeDuration: 0.375
@@ -15,8 +15,8 @@ MonoBehaviour:
kind: 1 kind: 1
role: 0 role: 0
attackShape: 1 attackShape: 1
maxHealth: 10 maxHealth: 24
moveSpeed: 1.2 moveSpeed: 1.5
attackDamage: 12 attackDamage: 12
warningDuration: 0.7 warningDuration: 0.7
activeDuration: 0.15 activeDuration: 0.15
@@ -26,7 +26,7 @@ MonoBehaviour:
attackLength: 0.8 attackLength: 0.8
attackWidth: 0.6 attackWidth: 0.6
attackRadius: 0 attackRadius: 0
experienceValue: 2 experienceValue: 3
attackPatterns: attackPatterns:
- animationState: Attack - animationState: Attack
attackShape: 1 attackShape: 1
@@ -16,7 +16,7 @@ MonoBehaviour:
role: 0 role: 0
attackShape: 1 attackShape: 1
maxHealth: 14 maxHealth: 14
moveSpeed: 0.9 moveSpeed: 1.125
attackDamage: 12 attackDamage: 12
warningDuration: 1 warningDuration: 1
activeDuration: 0.15 activeDuration: 0.15
@@ -13,10 +13,10 @@ MonoBehaviour:
m_Name: Slime m_Name: Slime
m_EditorClassIdentifier: BumpCombat.Runtime::BumpCombat.Enemies.EnemyDefinition m_EditorClassIdentifier: BumpCombat.Runtime::BumpCombat.Enemies.EnemyDefinition
kind: 0 kind: 0
role: 1 role: 0
attackShape: 0 attackShape: 0
maxHealth: 10 maxHealth: 10
moveSpeed: 1.3 moveSpeed: 1.625
attackDamage: 10 attackDamage: 10
warningDuration: 0.35 warningDuration: 0.35
activeDuration: 0.25 activeDuration: 0.25
@@ -26,6 +26,6 @@ MonoBehaviour:
attackLength: 0 attackLength: 0
attackWidth: 0 attackWidth: 0
attackRadius: 0.45 attackRadius: 0.45
experienceValue: 1 experienceValue: 2
separationRadius: 0.55 separationRadius: 0.55
separationStrength: 0.6 separationStrength: 0.6
@@ -16,7 +16,7 @@ MonoBehaviour:
role: 0 role: 0
attackShape: 3 attackShape: 3
maxHealth: 30 maxHealth: 30
moveSpeed: 1 moveSpeed: 1.25
attackDamage: 20 attackDamage: 20
warningDuration: 0.8 warningDuration: 0.8
activeDuration: 0.15 activeDuration: 0.15
@@ -16,7 +16,7 @@ MonoBehaviour:
role: 0 role: 0
attackShape: 2 attackShape: 2
maxHealth: 55 maxHealth: 55
moveSpeed: 0.8 moveSpeed: 1
attackDamage: 24 attackDamage: 24
warningDuration: 0.9 warningDuration: 0.9
activeDuration: 0.25 activeDuration: 0.25
@@ -16,7 +16,7 @@ MonoBehaviour:
role: 0 role: 0
attackShape: 2 attackShape: 2
maxHealth: 38 maxHealth: 38
moveSpeed: 1.45 moveSpeed: 1.8125
attackDamage: 20 attackDamage: 20
warningDuration: 0.8 warningDuration: 0.8
activeDuration: 0.25 activeDuration: 0.25
@@ -12,7 +12,7 @@ MonoBehaviour:
m_Script: {fileID: 11500000, guid: 2643d1587a2745d0b584eadd6b34978b, type: 3} m_Script: {fileID: 11500000, guid: 2643d1587a2745d0b584eadd6b34978b, type: 3}
m_Name: PlayerConstants m_Name: PlayerConstants
m_EditorClassIdentifier: BumpCombat.Runtime::BumpCombat.Constants.PlayerConstants m_EditorClassIdentifier: BumpCombat.Runtime::BumpCombat.Constants.PlayerConstants
BaseMoveSpeed: 3 BaseMoveSpeed: 3.75
BaseMaxHealth: 100 BaseMaxHealth: 100
InvulnerabilityDuration: 1 InvulnerabilityDuration: 1
KnockbackDuration: 0.14 KnockbackDuration: 0.14
+8
View File
@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: b1a37a7f408649c8b975bca1bda14e86
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
@@ -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
@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 2c5773908a374e96ad132e07d5bcc421
NativeFormatImporter:
externalObjects: {}
mainObjectFileID: 11400000
userData:
assetBundleName:
assetBundleVariant:
@@ -20,3 +20,5 @@ MonoBehaviour:
artifacts: {fileID: 11400000, guid: 619a1bb1a7bb416fbf1a4d0f4e4f66c5, type: 2} artifacts: {fileID: 11400000, guid: 619a1bb1a7bb416fbf1a4d0f4e4f66c5, type: 2}
run: {fileID: 11400000, guid: af4f8662e3d84fbf8df4757fc7bfd22f, type: 2} run: {fileID: 11400000, guid: af4f8662e3d84fbf8df4757fc7bfd22f, type: 2}
combatFeedback: {fileID: 11400000, guid: 6ab7e48e63b84ea3ac749bd827d1a87f, type: 2} combatFeedback: {fileID: 11400000, guid: 6ab7e48e63b84ea3ac749bd827d1a87f, type: 2}
defense: {fileID: 11400000, guid: 1949e4e0aabb41ff8e66ddd2ee493ba3, type: 2}
passiveRelics: {fileID: 11400000, guid: 2c5773908a374e96ad132e07d5bcc421, type: 2}
@@ -147,5 +147,13 @@ MonoBehaviour:
ArenaPlayableHalfExtents: {x: 15, y: 8.4375} ArenaPlayableHalfExtents: {x: 15, y: 8.4375}
ArenaPlayerPadding: {x: 0.5, y: 0.625} ArenaPlayerPadding: {x: 0.5, y: 0.625}
ArenaCameraViewHalfExtents: {x: 10, y: 5.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} ArenaCameraDeadZone: {x: 0.5, y: 0.3}
ArenaCameraSmoothTime: 0.15 ArenaCameraSmoothTime: 0.15
Stage1FirstCombatRoomScaleComparison: 0
StagePlayerVisibilityRadius: 6
StageExploredFogOpacity: 0.65
StageUnseenFogOpacity: 1
StageEncounterSightRangeMultiplier: 0.5
+1 -1
View File
@@ -70,7 +70,7 @@ CircleCollider2D:
m_GameObject: {fileID: 810786416157290827} m_GameObject: {fileID: 810786416157290827}
m_Enabled: 1 m_Enabled: 1
serializedVersion: 3 serializedVersion: 3
m_IsTrigger: 1 m_IsTrigger: 0
m_Offset: {x: 0, y: -0.28} m_Offset: {x: 0, y: -0.28}
m_Radius: 0.22 m_Radius: 0.22
--- !u!114 &3111862855908660951 --- !u!114 &3111862855908660951
+1 -1
View File
@@ -190,7 +190,7 @@ CircleCollider2D:
m_CallbackLayers: m_CallbackLayers:
serializedVersion: 2 serializedVersion: 2
m_Bits: 4294967295 m_Bits: 4294967295
m_IsTrigger: 1 m_IsTrigger: 0
m_UsedByEffector: 0 m_UsedByEffector: 0
m_CompositeOperation: 0 m_CompositeOperation: 0
m_CompositeOrder: 0 m_CompositeOrder: 0
+1 -1
View File
@@ -200,7 +200,7 @@ CircleCollider2D:
m_UsedByEffector: 0 m_UsedByEffector: 0
m_CompositeOperation: 0 m_CompositeOperation: 0
m_CompositeOrder: 0 m_CompositeOrder: 0
m_Offset: {x: 0, y: -0.28} m_Offset: {x: 0, y: -0.06}
m_Radius: 0.22 m_Radius: 0.22
--- !u!114 &6788187778577634304 --- !u!114 &6788187778577634304
MonoBehaviour: MonoBehaviour:
@@ -9,6 +9,7 @@ namespace BumpCombat.Combat
public sealed class ArtifactStatusVisual : MonoBehaviour public sealed class ArtifactStatusVisual : MonoBehaviour
{ {
public const int StatusSortingOffset = 1; public const int StatusSortingOffset = 1;
public const float StatusVisualAnchorOffsetY = -13f / 32f;
private const int FrameCount = 6; private const int FrameCount = 6;
private const float PixelsPerUnit = 32f; private const float PixelsPerUnit = 32f;
@@ -213,14 +214,23 @@ namespace BumpCombat.Combat
return; return;
} }
Vector2 groundAnchor = enemy.GroundAnchorPosition; Transform visualTransform = enemy.CurrentVisualTransform;
slot.Root.transform.SetPositionAndRotation( if (slot.Root.transform.parent != visualTransform)
new Vector3(groundAnchor.x, groundAnchor.y, transform.position.z), {
Quaternion.identity); 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.Root.transform.localScale = Vector3.one;
slot.Renderer.sortingLayerID = sourceRenderer.sortingLayerID; slot.Renderer.sortingLayerID = sourceRenderer.sortingLayerID;
slot.Renderer.sortingOrder = slot.Renderer.sortingOrder =
sourceRenderer.sortingOrder + StatusSortingOffset; enemy.CurrentVisualSortingOrder + StatusSortingOffset;
slot.Renderer.forceRenderingOff = sourceRenderer.forceRenderingOff;
slot.Renderer.flipX = sourceRenderer.flipX; slot.Renderer.flipX = sourceRenderer.flipX;
slot.Renderer.flipY = sourceRenderer.flipY; slot.Renderer.flipY = sourceRenderer.flipY;
slot.Renderer.sharedMaterial = sourceRenderer.sharedMaterial; slot.Renderer.sharedMaterial = sourceRenderer.sharedMaterial;
@@ -3,6 +3,7 @@ using BumpCombat.Core;
using BumpCombat.Constants; using BumpCombat.Constants;
using BumpCombat.Enemies; using BumpCombat.Enemies;
using BumpCombat.Player; using BumpCombat.Player;
using BumpCombat.Progression;
using UnityEngine; using UnityEngine;
namespace BumpCombat.Combat namespace BumpCombat.Combat
@@ -12,6 +13,7 @@ namespace BumpCombat.Combat
public sealed class BumpCombatResolver : MonoBehaviour public sealed class BumpCombatResolver : MonoBehaviour
{ {
private float baseDamage => GameplayConstants.Current.Combat.BaseDamage; private float baseDamage => GameplayConstants.Current.Combat.BaseDamage;
private bool enableFrontalBumpSelfDamage => GameplayConstants.Current.Combat.EnableFrontalBumpSelfDamage;
private float dashDamage => GameplayConstants.Current.Combat.DashDamage; private float dashDamage => GameplayConstants.Current.Combat.DashDamage;
private float strongDashDamage => GameplayConstants.Current.Combat.StrongDashDamage; private float strongDashDamage => GameplayConstants.Current.Combat.StrongDashDamage;
private float minimumAttackSpeed => GameplayConstants.Current.Combat.MinimumAttackSpeed; private float minimumAttackSpeed => GameplayConstants.Current.Combat.MinimumAttackSpeed;
@@ -35,6 +37,7 @@ namespace BumpCombat.Combat
private readonly Dictionary<int, float> nextNormalHitTimes = new(); private readonly Dictionary<int, float> nextNormalHitTimes = new();
private PlayerStats playerStats; private PlayerStats playerStats;
private PlayerHealth playerHealth;
private PlayerController playerController; private PlayerController playerController;
private DashController dashController; private DashController dashController;
private ActiveArtifactController activeArtifactController; private ActiveArtifactController activeArtifactController;
@@ -43,6 +46,7 @@ namespace BumpCombat.Combat
private void Awake() private void Awake()
{ {
playerStats = GetComponent<PlayerStats>(); playerStats = GetComponent<PlayerStats>();
playerHealth = GetComponent<PlayerHealth>();
playerController = GetComponent<PlayerController>(); playerController = GetComponent<PlayerController>();
dashController = GetComponent<DashController>(); dashController = GetComponent<DashController>();
activeArtifactController = GetComponent<ActiveArtifactController>(); activeArtifactController = GetComponent<ActiveArtifactController>();
@@ -335,6 +339,10 @@ namespace BumpCombat.Combat
playerStats.CalculateDamage(rawDamage, damageTag), playerStats.CalculateDamage(rawDamage, damageTag),
side, side,
isOffsetHit); isOffsetHit);
finalDamage = PassiveRelicController.Instance?.ApplyOrdinaryBumpDamageMultiplier(
finalDamage,
!isDash && string.IsNullOrEmpty(sourceId),
artifactColor.HasValue) ?? finalDamage;
Vector2 knockbackDirection = -enemyToPlayer; Vector2 knockbackDirection = -enemyToPlayer;
bool suppressOrdinaryEventReaction = !isDash && isEventEnemy; bool suppressOrdinaryEventReaction = !isDash && isEventEnemy;
float appliedKnockback = knockbackOverride float appliedKnockback = knockbackOverride
@@ -361,7 +369,8 @@ namespace BumpCombat.Combat
appliedKnockback, appliedKnockback,
artifactColor.Value, artifactColor.Value,
castIdentity, castIdentity,
!suppressOrdinaryEventReaction); !suppressOrdinaryEventReaction,
EnemyDamageSource.Player);
acceptedHit = artifactHitResult acceptedHit = artifactHitResult
!= EnemyArtifactHitResult.Rejected; != EnemyArtifactHitResult.Rejected;
} }
@@ -372,7 +381,8 @@ namespace BumpCombat.Combat
knockbackDirection, knockbackDirection,
appliedKnockback, appliedKnockback,
!suppressOrdinaryEventReaction, !suppressOrdinaryEventReaction,
!isDash); !isDash,
EnemyDamageSource.Player);
} }
if (!acceptedHit) if (!acceptedHit)
@@ -477,16 +487,24 @@ namespace BumpCombat.Combat
if (!isDash && !protectionBegan) 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)) && BumpCombatMath.AppliesPlayerImpactRecoil(side, isEventEnemy))
{ {
playerController.ApplyImpactRecoil( playerController.ApplyImpactRecoil(
enemyToPlayer, enemyToPlayer,
side == HitSide.Front side == HitSide.Back && isEventEnemy
? frontPlayerImpactRecoilDistance ? eventBackPlayerImpactRecoilDistance
: side == HitSide.Back && isEventEnemy : playerImpactRecoilDistance);
? eventBackPlayerImpactRecoilDistance
: playerImpactRecoilDistance);
} }
activeArtifactController?.AddNormalHitCharge(); activeArtifactController?.AddNormalHitCharge();
} }
@@ -27,6 +27,9 @@ namespace BumpCombat.Combat
private float cameraShakeDuration => GameplayConstants.Current.CombatFeedback.CameraShakeDuration; private float cameraShakeDuration => GameplayConstants.Current.CombatFeedback.CameraShakeDuration;
private float cameraShakePixels => GameplayConstants.Current.CombatFeedback.CameraShakePixels; private float cameraShakePixels => GameplayConstants.Current.CombatFeedback.CameraShakePixels;
private float heavyCameraShakePixels => GameplayConstants.Current.CombatFeedback.HeavyCameraShakePixels; 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; private float heavyKnockbackThreshold => GameplayConstants.Current.CombatFeedback.HeavyKnockbackThreshold;
[SerializeField, Min(1f)] private float assetsPixelsPerUnit = 32f; [SerializeField, Min(1f)] private float assetsPixelsPerUnit = 32f;
@@ -96,6 +99,7 @@ namespace BumpCombat.Combat
private float flashUntil; private float flashUntil;
private float blinkUntil; private float blinkUntil;
private Vector3 cameraRestPosition; private Vector3 cameraRestPosition;
private float lastRearHitCameraPunchAt = float.NegativeInfinity;
private Material effectMaterial; private Material effectMaterial;
private Vector3 lastCycloneSamplePosition; private Vector3 lastCycloneSamplePosition;
private Vector2 lastDashAfterimagePosition; private Vector2 lastDashAfterimagePosition;
@@ -309,6 +313,7 @@ namespace BumpCombat.Combat
private void OnEnable() private void OnEnable()
{ {
activeInstance = this; activeInstance = this;
lastRearHitCameraPunchAt = float.NegativeInfinity;
CombatEvents.OnValidHit += HandleValidHit; CombatEvents.OnValidHit += HandleValidHit;
CombatEvents.OnStunApplied += HandleStunApplied; CombatEvents.OnStunApplied += HandleStunApplied;
CombatEvents.OnAttackCancelled += HandleAttackCancelled; CombatEvents.OnAttackCancelled += HandleAttackCancelled;
@@ -1354,6 +1359,19 @@ namespace BumpCombat.Combat
return; return;
} }
if (result.IsOrdinaryBump && result.HasBackBonus)
{
float now = Time.time;
bool rearHitCameraPunchRearmed =
now - lastRearHitCameraPunchAt >= rearHitCameraPunchRearmDelay;
lastRearHitCameraPunchAt = now;
if (rearHitCameraPunchRearmed)
{
RequestRearHitCameraPunch(result.KnockbackDirection);
}
}
Color color; Color color;
float strength; float strength;
int rayCount; int rayCount;
@@ -2203,6 +2221,63 @@ namespace BumpCombat.Combat
playerVisualCoroutine = null; 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) private void RequestCameraShake(float pixels)
{ {
if (worldCamera == null || pixels <= 0f || cameraShakeDuration <= 0f) if (worldCamera == null || pixels <= 0f || cameraShakeDuration <= 0f)
@@ -5,6 +5,17 @@ namespace BumpCombat.Constants
[CreateAssetMenu(menuName = "BumpCombat/Constants/Combat")] [CreateAssetMenu(menuName = "BumpCombat/Constants/Combat")]
public sealed class CombatConstants : ScriptableObject 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("몸통박치기 방향 판정")] [Header("몸통박치기 방향 판정")]
[Tooltip("적 정면으로 분류하는 방향 내적 기준"), Range(-1f, 1f)] public float FrontThreshold = 0.707f; [Tooltip("적 정면으로 분류하는 방향 내적 기준"), Range(-1f, 1f)] public float FrontThreshold = 0.707f;
[Tooltip("적 후방으로 분류하는 방향 내적 기준"), Range(-1f, 1f)] public float BackThreshold = -0.5f; [Tooltip("적 후방으로 분류하는 방향 내적 기준"), Range(-1f, 1f)] public float BackThreshold = -0.5f;
@@ -25,6 +25,9 @@ namespace BumpCombat.Constants
[Tooltip("흔들림 지속시간 (초)"), Min(0f)] public float CameraShakeDuration = 0.12f; [Tooltip("흔들림 지속시간 (초)"), Min(0f)] public float CameraShakeDuration = 0.12f;
[Tooltip("일반 흔들림 크기 (픽셀)"), Min(0f)] public float CameraShakePixels = 3f; [Tooltip("일반 흔들림 크기 (픽셀)"), Min(0f)] public float CameraShakePixels = 3f;
[Tooltip("강한 흔들림 크기 (픽셀)"), Min(0f)] public float HeavyCameraShakePixels = 5f; [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; [Tooltip("강한 피격으로 표시하는 넉백 거리 (유닛)"), Min(0f)] public float HeavyKnockbackThreshold = 1f;
[Header("무적 적 몸통박치기 표시")] [Header("무적 적 몸통박치기 표시")]
@@ -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;
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 8a83cd68928e48f89b011bc92f0ca8e3
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -21,6 +21,17 @@ namespace BumpCombat.Constants
[Tooltip("군중형 접근 기준점의 가장 안쪽 반경 (유닛)"), Min(0f)] public float CrowdApproachBaseRadius = 0.75f; [Tooltip("군중형 접근 기준점의 가장 안쪽 반경 (유닛)"), Min(0f)] public float CrowdApproachBaseRadius = 0.75f;
[Tooltip("군중형 접근 기준점 동심원 간 간격 (유닛)"), Min(0f)] public float CrowdApproachRingSpacing = 0.15f; [Tooltip("군중형 접근 기준점 동심원 간 간격 (유닛)"), Min(0f)] public float CrowdApproachRingSpacing = 0.15f;
[Min(0f)] public float SummonedEnemyNoAnimationLockDuration = 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("적과 플레이어 사이 거리")] [Header("적과 플레이어 사이 거리")]
[Tooltip("일반 적이 주는 플레이어 넉백 거리 (유닛)"), Min(0f)] public float NormalPlayerKnockbackDistance = 0.75f; [Tooltip("일반 적이 주는 플레이어 넉백 거리 (유닛)"), Min(0f)] public float NormalPlayerKnockbackDistance = 0.75f;
@@ -15,6 +15,8 @@ namespace BumpCombat.Constants
private static ArtifactConstants fallbackArtifacts; private static ArtifactConstants fallbackArtifacts;
private static RunConstants fallbackRun; private static RunConstants fallbackRun;
private static CombatFeedbackSettings fallbackCombatFeedback; private static CombatFeedbackSettings fallbackCombatFeedback;
private static DefenseConstants fallbackDefense;
private static PassiveRelicConstants fallbackPassiveRelics;
[Header("설정 분류")] [Header("설정 분류")]
[SerializeField, Tooltip("플레이어 기본 능력치, 피격, 가드 설정")] [SerializeField, Tooltip("플레이어 기본 능력치, 피격, 가드 설정")]
@@ -33,6 +35,10 @@ namespace BumpCombat.Constants
private RunConstants run; private RunConstants run;
[SerializeField, Tooltip("타격, 피격, 가드 효과의 표시 설정")] [SerializeField, Tooltip("타격, 피격, 가드 효과의 표시 설정")]
private CombatFeedbackSettings combatFeedback; private CombatFeedbackSettings combatFeedback;
[SerializeField, Tooltip("역병형 방어전 테스트 모드 설정")]
private DefenseConstants defense;
[SerializeField, Tooltip("방어전 패시브 유물 효과 설정")]
private PassiveRelicConstants passiveRelics;
public static GameplayConstants Current public static GameplayConstants Current
{ {
@@ -60,6 +66,8 @@ namespace BumpCombat.Constants
public RunConstants Run => Resolve(run, ref fallbackRun); public RunConstants Run => Resolve(run, ref fallbackRun);
public CombatFeedbackSettings CombatFeedback => public CombatFeedbackSettings CombatFeedback =>
Resolve(combatFeedback, ref fallbackCombatFeedback); Resolve(combatFeedback, ref fallbackCombatFeedback);
public DefenseConstants Defense => Resolve(defense, ref fallbackDefense);
public PassiveRelicConstants PassiveRelics => Resolve(passiveRelics, ref fallbackPassiveRelics);
#if UNITY_EDITOR #if UNITY_EDITOR
public static void SetCurrentForTests(GameplayConstants value) public static void SetCurrentForTests(GameplayConstants value)
@@ -78,6 +86,8 @@ namespace BumpCombat.Constants
fallbackArtifacts = null; fallbackArtifacts = null;
fallbackRun = null; fallbackRun = null;
fallbackCombatFeedback = null; fallbackCombatFeedback = null;
fallbackDefense = null;
fallbackPassiveRelics = null;
} }
#endif #endif
@@ -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;
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: f0f9bca102564c4395943d6b39c6d9f0
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -6,7 +6,7 @@ namespace BumpCombat.Constants
public sealed class PlayerConstants : ScriptableObject public sealed class PlayerConstants : ScriptableObject
{ {
[Header("기본 능력치")] [Header("기본 능력치")]
[Tooltip("플레이어 기본 이동 속도 (유닛/초)"), Min(0f)] public float BaseMoveSpeed = 3f; [Tooltip("플레이어 기본 이동 속도 (유닛/초)"), Min(0f)] public float BaseMoveSpeed = 3.75f;
[Tooltip("플레이어 기본 최대 체력"), Min(1f)] public float BaseMaxHealth = 100f; [Tooltip("플레이어 기본 최대 체력"), Min(1f)] public float BaseMaxHealth = 100f;
[Header("피격")] [Header("피격")]
@@ -5,6 +5,13 @@ using UnityEngine;
namespace BumpCombat.Constants namespace BumpCombat.Constants
{ {
public enum RoomScaleComparisonMode
{
Disabled = 0,
Original = 1,
Expanded = 2,
}
[Serializable] [Serializable]
public struct SpawnDensityPhase public struct SpawnDensityPhase
{ {
@@ -123,9 +130,25 @@ namespace BumpCombat.Constants
public Vector2 ArenaPlayableHalfExtents = new(15f, 8.4375f); public Vector2 ArenaPlayableHalfExtents = new(15f, 8.4375f);
public Vector2 ArenaPlayerPadding = new(0.5f, 0.625f); public Vector2 ArenaPlayerPadding = new(0.5f, 0.625f);
public Vector2 ArenaCameraViewHalfExtents = new(10f, 5.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); public Vector2 ArenaCameraDeadZone = new(0.5f, 0.3f);
[Min(0f)] public float ArenaCameraSmoothTime = 0.15f; [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) public float[] EliteTimes(bool development, bool production)
{ {
float[] times = production float[] times = production
+29 -12
View File
@@ -12,8 +12,12 @@ namespace BumpCombat.Core
{ {
public static ArenaBounds Instance { get; private set; } public static ArenaBounds Instance { get; private set; }
public Vector2 BackgroundHalfExtents => GameplayConstants.Current.Run.ArenaBackgroundHalfExtents; private Vector2 customCenter = Vector2.zero;
public Vector2 PlayableHalfExtents => GameplayConstants.Current.Run.ArenaPlayableHalfExtents; 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 PlayerPadding => GameplayConstants.Current.Run.ArenaPlayerPadding;
public Vector2 CameraViewHalfExtents => GameplayConstants.Current.Run.ArenaCameraViewHalfExtents; public Vector2 CameraViewHalfExtents => GameplayConstants.Current.Run.ArenaCameraViewHalfExtents;
public Vector2 PlayerClampHalfExtents => new( public Vector2 PlayerClampHalfExtents => new(
@@ -28,6 +32,18 @@ namespace BumpCombat.Core
Mathf.Max(0f, BackgroundHalfExtents.x - CameraViewHalfExtents.x), Mathf.Max(0f, BackgroundHalfExtents.x - CameraViewHalfExtents.x),
Mathf.Max(0f, BackgroundHalfExtents.y - CameraViewHalfExtents.y)); 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() private void Awake()
{ {
Instance = this; Instance = this;
@@ -50,13 +66,13 @@ namespace BumpCombat.Core
public Vector2 ClampPlayerPosition(Vector2 position) public Vector2 ClampPlayerPosition(Vector2 position)
{ {
return ClampToHalfExtents(position, PlayerClampHalfExtents); return ClampToHalfExtents(position, PlayerClampHalfExtents, customCenter);
} }
public Vector2 ClampEnemyPosition(Vector2 position, float edgeInset = 0f) public Vector2 ClampEnemyPosition(Vector2 position, float edgeInset = 0f)
{ {
Vector2 extents = GetReachableHalfExtents(edgeInset); Vector2 extents = GetReachableHalfExtents(edgeInset);
return ClampToHalfExtents(position, extents); return ClampToHalfExtents(position, extents, customCenter);
} }
public Vector2 ClampEnemyPosition( public Vector2 ClampEnemyPosition(
@@ -73,7 +89,7 @@ namespace BumpCombat.Core
// player. The previous collider-aware clamp introduced a second, // player. The previous collider-aware clamp introduced a second,
// scale-dependent boundary that was especially visible on elites // scale-dependent boundary that was especially visible on elites
// and bosses at the edge of the map. // 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) public Vector2 GetReachableHalfExtents(float edgeInset = 0f)
@@ -86,13 +102,13 @@ namespace BumpCombat.Core
public bool IsInsideReachableArena(Vector2 position, float inset = 0f) public bool IsInsideReachableArena(Vector2 position, float inset = 0f)
{ {
Vector2 extents = GetReachableHalfExtents(inset); Vector2 extents = GetReachableHalfExtents(inset);
return Mathf.Abs(position.x) <= extents.x + 0.0001f return Mathf.Abs(position.x - customCenter.x) <= extents.x + 0.0001f
&& Mathf.Abs(position.y) <= extents.y + 0.0001f; && Mathf.Abs(position.y - customCenter.y) <= extents.y + 0.0001f;
} }
public Vector2 ClampCameraCenter(Vector2 position) public Vector2 ClampCameraCenter(Vector2 position)
{ {
return ClampToHalfExtents(position, CameraCenterHalfExtents); return ClampToHalfExtents(position, CameraCenterHalfExtents, customCenter);
} }
public Vector2 ClampCameraCenter( public Vector2 ClampCameraCenter(
@@ -102,7 +118,7 @@ namespace BumpCombat.Core
Vector2 centerHalfExtents = new( Vector2 centerHalfExtents = new(
Mathf.Max(0f, BackgroundHalfExtents.x - Mathf.Abs(viewHalfExtents.x)), Mathf.Max(0f, BackgroundHalfExtents.x - Mathf.Abs(viewHalfExtents.x)),
Mathf.Max(0f, BackgroundHalfExtents.y - Mathf.Abs(viewHalfExtents.y))); Mathf.Max(0f, BackgroundHalfExtents.y - Mathf.Abs(viewHalfExtents.y)));
return ClampToHalfExtents(position, centerHalfExtents); return ClampToHalfExtents(position, centerHalfExtents, customCenter);
} }
public Vector2 GetCameraVisibleHalfExtents(Camera camera) public Vector2 GetCameraVisibleHalfExtents(Camera camera)
@@ -143,10 +159,11 @@ namespace BumpCombat.Core
private static Vector2 ClampToHalfExtents( private static Vector2 ClampToHalfExtents(
Vector2 position, Vector2 position,
Vector2 halfExtents) Vector2 halfExtents,
Vector2 center)
{ {
position.x = Mathf.Clamp(position.x, -halfExtents.x, halfExtents.x); position.x = Mathf.Clamp(position.x, center.x - halfExtents.x, center.x + halfExtents.x);
position.y = Mathf.Clamp(position.y, -halfExtents.y, halfExtents.y); position.y = Mathf.Clamp(position.y, center.y - halfExtents.y, center.y + halfExtents.y);
return position; return position;
} }
} }
@@ -1,5 +1,7 @@
using System.Reflection;
using BumpCombat.Constants; using BumpCombat.Constants;
using BumpCombat.Player; using BumpCombat.Player;
using BumpCombat.Stage;
using UnityEngine; using UnityEngine;
using UnityEngine.Rendering; using UnityEngine.Rendering;
@@ -16,11 +18,41 @@ namespace BumpCombat.Core
private Vector2 externalShakeOffset; private Vector2 externalShakeOffset;
private bool initialized; private bool initialized;
private RunManager runManager; 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 CurrentCenter => currentCenter;
public Vector2 DeadZone => GameplayConstants.Current.Run.ArenaCameraDeadZone; public Vector2 DeadZone => GameplayConstants.Current.Run.ArenaCameraDeadZone;
public float SmoothTime => GameplayConstants.Current.Run.ArenaCameraSmoothTime; public float SmoothTime => GameplayConstants.Current.Run.ArenaCameraSmoothTime;
public Vector2 ExternalShakeOffset => externalShakeOffset; public Vector2 ExternalShakeOffset => externalShakeOffset;
public Camera WorldCamera => worldCamera != null ? worldCamera : (worldCamera = GetComponent<Camera>());
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() private void Awake()
{ {
@@ -85,6 +117,23 @@ namespace BumpCombat.Core
return; 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 int targetWidth = camera.targetTexture != null
? camera.targetTexture.width ? camera.targetTexture.width
: Screen.width; : Screen.width;
@@ -95,23 +144,98 @@ namespace BumpCombat.Core
int evenHeight = targetHeight & ~1; int evenHeight = targetHeight & ~1;
if (targetWidth < 2 if (targetWidth < 2
|| targetHeight < 2 || targetHeight < 2
|| (evenWidth == targetWidth && evenHeight == targetHeight)) || evenWidth < 2
|| evenHeight < 2)
{ {
return; return;
} }
Rect desiredPixelRect = new(0f, 0f, evenWidth, evenHeight);
Rect currentPixelRect = camera.pixelRect; Rect currentPixelRect = camera.pixelRect;
if (Mathf.Abs(currentPixelRect.x) < 0.01f bool pixelRectChanged = Mathf.Abs(currentPixelRect.x - desiredPixelRect.x) > 0.01f
&& Mathf.Abs(currentPixelRect.y) < 0.01f || Mathf.Abs(currentPixelRect.y - desiredPixelRect.y) > 0.01f
&& Mathf.Abs(currentPixelRect.width - evenWidth) < 0.01f || Mathf.Abs(currentPixelRect.width - desiredPixelRect.width) > 0.01f
&& Mathf.Abs(currentPixelRect.height - evenHeight) < 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; return;
} }
camera.pixelRect = new Rect(0f, 0f, evenWidth, evenHeight); ResolvePixelPerfectCamera();
camera.ResetAspect(); if (pixelPerfectCamera == null)
camera.orthographicSize *= evenHeight / (float)targetHeight; {
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() private void LateUpdate()
@@ -123,6 +247,12 @@ namespace BumpCombat.Core
ResolveBounds(); ResolveBounds();
ResolveTarget(); ResolveTarget();
if (Stage.StageManager.Instance != null && Stage.StageManager.Instance.IsTransitioning)
{
ApplyCameraPosition();
return;
}
if (RunManager.GameplayInputEnabled && target != null) if (RunManager.GameplayInputEnabled && target != null)
{ {
Vector2 desiredCenter = GetDeadZoneTargetCenter( 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() private void ApplyCameraPosition()
{ {
Vector2 finalCenter = currentCenter + externalShakeOffset; Vector2 finalCenter = currentCenter + externalShakeOffset;
+108 -5
View File
@@ -1,4 +1,5 @@
using System; using System;
using System.Collections;
using BumpCombat.Constants; using BumpCombat.Constants;
using UnityEngine; using UnityEngine;
using UnityEngine.InputSystem; using UnityEngine.InputSystem;
@@ -17,6 +18,7 @@ namespace BumpCombat.Core
{ {
Development, Development,
Production, Production,
DefensePrototype,
} }
public sealed class RunManager : MonoBehaviour public sealed class RunManager : MonoBehaviour
@@ -28,6 +30,9 @@ namespace BumpCombat.Core
private bool guardUnlocked = true; private bool guardUnlocked = true;
private bool artifactChargeUnlocked = true; private bool artifactChargeUnlocked = true;
private bool gameOver; private bool gameOver;
private bool gameOverPresentation;
private bool runCleared;
private int currentSeed = -1;
private bool titleOpen; private bool titleOpen;
private bool pauseOpen; private bool pauseOpen;
private bool selectionOpen; private bool selectionOpen;
@@ -42,6 +47,8 @@ namespace BumpCombat.Core
public static RunManager Instance { get; private set; } public static RunManager Instance { get; private set; }
public static bool GameplayInputEnabled => public static bool GameplayInputEnabled =>
Instance == null || (!Instance.gameOver Instance == null || (!Instance.gameOver
&& !Instance.gameOverPresentation
&& !Instance.runCleared
&& !Instance.titleOpen && !Instance.titleOpen
&& !Instance.pauseOpen && !Instance.pauseOpen
&& !Instance.selectionOpen); && !Instance.selectionOpen);
@@ -55,11 +62,16 @@ namespace BumpCombat.Core
public event Action<bool> OnArtifactChargeAvailabilityChanged; public event Action<bool> OnArtifactChargeAvailabilityChanged;
public event Action<bool> OnPauseChanged; public event Action<bool> OnPauseChanged;
public event Action OnGameOver; public event Action OnGameOver;
public event Action OnRunCleared;
public float ElapsedTime { get; private set; } public float ElapsedTime { get; private set; }
public float ProgressionTime => public float ProgressionTime =>
GetProgressionTime(ElapsedTime, UseDebugEventTimes); GetProgressionTime(ElapsedTime, UseDebugEventTimes);
public bool IsGameOver => gameOver; 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 IsTitleScreen => titleOpen;
public bool IsPaused => pauseOpen; public bool IsPaused => pauseOpen;
public bool IsSelectionOpen => selectionOpen; public bool IsSelectionOpen => selectionOpen;
@@ -70,13 +82,15 @@ namespace BumpCombat.Core
&& selectedRunMode == RunMode.Production; && selectedRunMode == RunMode.Production;
public bool IsDevelopmentRun => hasSelectedRunMode public bool IsDevelopmentRun => hasSelectedRunMode
&& selectedRunMode == RunMode.Development; && selectedRunMode == RunMode.Development;
public bool IsDefensePrototypeRun => hasSelectedRunMode
&& selectedRunMode == RunMode.DefensePrototype;
public bool IsGuardUnlocked => !IsProductionRun || guardUnlocked; public bool IsGuardUnlocked => !IsProductionRun || guardUnlocked;
public bool IsArtifactChargeUnlocked => !IsProductionRun public bool IsArtifactChargeUnlocked => !IsProductionRun
|| artifactChargeUnlocked; || artifactChargeUnlocked;
public int StartupArtifactSelectionCount => hasSelectedRunMode public int StartupArtifactSelectionCount => hasSelectedRunMode
? selectedRunMode == RunMode.Development ? selectedRunMode == RunMode.Production
? GameplayConstants.Current.Run.DevelopmentStartupArtifactSelectionCount ? GameplayConstants.Current.Run.ProductionStartupArtifactSelectionCount
: GameplayConstants.Current.Run.ProductionStartupArtifactSelectionCount : GameplayConstants.Current.Run.DevelopmentStartupArtifactSelectionCount
: GameplayConstants.Current.Run.LegacyStartupArtifactSelectionCount; : GameplayConstants.Current.Run.LegacyStartupArtifactSelectionCount;
public int RaisedEliteEventCount => nextEliteEventIndex; public int RaisedEliteEventCount => nextEliteEventIndex;
public bool UseDebugArtifactSelection public bool UseDebugArtifactSelection
@@ -131,6 +145,7 @@ namespace BumpCombat.Core
if (FindAnyObjectByType<BumpCombat.Progression.ArtifactRewardController>() != null) if (FindAnyObjectByType<BumpCombat.Progression.ArtifactRewardController>() != null)
{ {
EnsurePresentationServices(); EnsurePresentationServices();
EnsureDefensePrototypeController();
return; return;
} }
@@ -140,6 +155,15 @@ namespace BumpCombat.Core
canvas.gameObject.AddComponent<BumpCombat.Progression.ArtifactRewardController>(); canvas.gameObject.AddComponent<BumpCombat.Progression.ArtifactRewardController>();
} }
EnsurePresentationServices(); EnsurePresentationServices();
EnsureDefensePrototypeController();
}
private void EnsureDefensePrototypeController()
{
if (GetComponent<BumpCombat.Defense.DefensePrototypeController>() == null)
{
gameObject.AddComponent<BumpCombat.Defense.DefensePrototypeController>();
}
} }
private void EnsurePresentationServices() private void EnsurePresentationServices()
@@ -170,6 +194,19 @@ namespace BumpCombat.Core
{ {
canvas.gameObject.AddComponent<BumpCombat.UI.MissionAchievementPanel>(); canvas.gameObject.AddComponent<BumpCombat.UI.MissionAchievementPanel>();
} }
if (canvas.GetComponent<BumpCombat.UI.WorldMapPanel>() == null)
{
canvas.gameObject.AddComponent<BumpCombat.UI.WorldMapPanel>();
}
if (canvas.GetComponent<BumpCombat.UI.RunClearPanel>() == null)
{
canvas.gameObject.AddComponent<BumpCombat.UI.RunClearPanel>();
}
if (FindAnyObjectByType<BumpCombat.Stage.StageManager>() == null)
{
gameObject.AddComponent<BumpCombat.Stage.StageManager>();
}
} }
private void OnDestroy() private void OnDestroy()
@@ -182,6 +219,11 @@ namespace BumpCombat.Core
private void Update() private void Update()
{ {
if (gameOverPresentation)
{
return;
}
if (gameOver) if (gameOver)
{ {
if (Keyboard.current?.rKey.wasPressedThisFrame == true) if (Keyboard.current?.rKey.wasPressedThisFrame == true)
@@ -192,6 +234,11 @@ namespace BumpCombat.Core
return; return;
} }
if (runCleared)
{
return;
}
if (selectionOpen) if (selectionOpen)
{ {
return; return;
@@ -222,7 +269,13 @@ namespace BumpCombat.Core
public bool TryOpenSelection(MonoBehaviour owner) public bool TryOpenSelection(MonoBehaviour owner)
{ {
if (owner == null || titleOpen || selectionOpen || gameOver || pauseOpen) if (owner == null
|| titleOpen
|| selectionOpen
|| gameOver
|| gameOverPresentation
|| runCleared
|| pauseOpen)
{ {
return false; return false;
} }
@@ -308,6 +361,8 @@ namespace BumpCombat.Core
public bool TryOpenPause() public bool TryOpenPause()
{ {
if (gameOver if (gameOver
|| gameOverPresentation
|| runCleared
|| titleOpen || titleOpen
|| selectionOpen || selectionOpen
|| pauseOpen || pauseOpen
@@ -365,21 +420,69 @@ namespace BumpCombat.Core
return; return;
} }
gameOverPresentation = false;
gameOver = true; gameOver = true;
pauseOpen = false; pauseOpen = false;
RefreshTimeScale(); RefreshTimeScale();
OnGameOver?.Invoke(); 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() private void RefreshTimeScale()
{ {
Time.timeScale = gameOver || titleOpen || pauseOpen || selectionOpen Time.timeScale = gameOver || runCleared || titleOpen || pauseOpen || selectionOpen
? 0f ? 0f
: 1f; : 1f;
} }
private void RaiseTimedEvents() private void RaiseTimedEvents()
{ {
if (BumpCombat.Stage.StageManager.IsStageModeActive
|| IsDefensePrototypeRun)
{
return;
}
float[] eliteTimes = CurrentEliteEventTimes; float[] eliteTimes = CurrentEliteEventTimes;
float midBossTime = GetEventTimeForCurrentRun(RunTimedEvent.MidBoss); float midBossTime = GetEventTimeForCurrentRun(RunTimedEvent.MidBoss);
float finalBossTime = GetEventTimeForCurrentRun(RunTimedEvent.FinalBoss); float finalBossTime = GetEventTimeForCurrentRun(RunTimedEvent.FinalBoss);
@@ -22,7 +22,13 @@ namespace BumpCombat.Core
public int CalculateSortingOrder() 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);
} }
} }
} }
+8
View File
@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: b21501b83f844601916bda51aa90fef4
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
@@ -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<EnemyController, DefenseRouteAgent> 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<EnemyController, DefenseRouteAgent> 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<SpawnDirector>();
playerHealth = FindAnyObjectByType<PlayerHealth>();
playerController = FindAnyObjectByType<PlayerController>();
if (playerController != null)
{
PassiveRelicController relics = playerController.GetComponent<PassiveRelicController>();
if (relics == null)
{
relics = playerController.gameObject.AddComponent<PassiveRelicController>();
}
relics.BeginDefenseRun();
}
ArenaBounds bounds = ArenaBounds.Resolve();
bounds?.SetZoneBounds(tuning.ArenaCenter, tuning.ArenaPlayableHalfExtents);
FindAnyObjectByType<ArenaCameraFollow>()?.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<DefenseTower>();
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<PassiveRelicController>()?.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<DefenseRouteAgent>();
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<PassiveRelicController>();
ArtifactRewardController rewards = FindAnyObjectByType<ArtifactRewardController>();
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<Canvas>();
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<RectTransform>();
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<Text>();
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<LineRenderer>();
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<SpriteRenderer>();
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<TextMesh>();
label.text = text;
label.anchor = TextAnchor.MiddleCenter;
label.alignment = TextAlignment.Center;
label.characterSize = 0.08f;
label.fontSize = 32;
label.color = Color.white;
label.GetComponent<MeshRenderer>().sortingOrder = 12;
return labelObject;
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: a39d0992fba741809dfd6e95bc9cfd83
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -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<int> suppliedIds = new();
private readonly System.Collections.Generic.HashSet<int> 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;
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 9be2c059f93b466e9735447cb1bd2146
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -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<Vector2>();
private Action<DefenseRouteAgent> 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<DefenseRouteAgent> onArrived)
{
enemy = configuredEnemy;
Lane = lane;
waypoints = route ?? Array.Empty<Vector2>();
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<BumpCombat.Player.PlayerController>()?.transform;
playerHealth = player != null
? player.GetComponent<BumpCombat.Player.PlayerHealth>()
: 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<BumpCombat.Player.PlayerController>()?.transform;
playerHealth = player != null
? player.GetComponent<BumpCombat.Player.PlayerHealth>()
: 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>();
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);
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: be1e0aa426264604ac15478324ef43aa
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -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<SpriteRenderer>();
Sprite[] frames = Resources.LoadAll<Sprite>("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;
}
}
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: c2c4cbe8f78e4e11898e78be45697a12
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -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<DefenseSummonUnit>();
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<SpriteRenderer>();
if (renderer == null)
{
renderer = gameObject.AddComponent<SpriteRenderer>();
}
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<SpriteRenderer>();
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<TextMesh>();
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<MeshRenderer>().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);
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: e82deace7a1a47e0b14c872019e51a01
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -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<AgyPlayValidationRunner>();
};
}
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<PlayerController>();
WorldMapPanel map = FindAnyObjectByType<WorldMapPanel>();
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<RunClearPanel>();
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<ArtifactRewardController>();
RunTutorialController tutorial = FindAnyObjectByType<RunTutorialController>();
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);
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 5ec05c0337f620543ba3d30114ee07fe
@@ -1271,7 +1271,7 @@ namespace BumpCombat.Editor
body.collisionDetectionMode = CollisionDetectionMode2D.Continuous; body.collisionDetectionMode = CollisionDetectionMode2D.Continuous;
CircleCollider2D collider = player.AddComponent<CircleCollider2D>(); CircleCollider2D collider = player.AddComponent<CircleCollider2D>();
collider.offset = new Vector2(0f, -0.28f); collider.offset = new Vector2(0f, -0.06f);
collider.radius = 0.22f; collider.radius = 0.22f;
player.AddComponent<PlayerStats>(); player.AddComponent<PlayerStats>();
@@ -1394,10 +1394,7 @@ namespace BumpCombat.Editor
radius, radius,
experience, experience,
animationLeadTime); animationLeadTime);
definition.ConfigureRole( definition.ConfigureRole(EnemyRole.Normal);
kind == EnemyKind.Slime || kind == EnemyKind.Skeleton
? EnemyRole.Crowd
: EnemyRole.Normal);
AssetDatabase.CreateAsset(definition, path); AssetDatabase.CreateAsset(definition, path);
return definition; return definition;
} }
+53 -3
View File
@@ -22,6 +22,7 @@ namespace BumpCombat.Enemies
private bool lancerTipNeedsRebase; private bool lancerTipNeedsRebase;
private bool necrofireRayActive; private bool necrofireRayActive;
private bool necrofireRayHitConfirmed; private bool necrofireRayHitConfirmed;
private float necrofireRayLength;
private bool meleeAttackActive; private bool meleeAttackActive;
private bool meleeHitConfirmed; private bool meleeHitConfirmed;
private StageEnemyEffectVisual necrofireRayVisual; private StageEnemyEffectVisual necrofireRayVisual;
@@ -392,12 +393,16 @@ namespace BumpCombat.Enemies
Vector2 direction = lockedDirection.sqrMagnitude > 0.0001f Vector2 direction = lockedDirection.sqrMagnitude > 0.0001f
? lockedDirection ? lockedDirection
: Vector2.right; : Vector2.right;
float length = controller.AttackLength; necrofireRayLength = GetTerrainBlockedLength(
attackOrigin,
direction,
controller.AttackLength,
controller.AttackWidth * 0.5f);
necrofireRayVisual = StageEnemyEffectVisual.PlayRay( necrofireRayVisual = StageEnemyEffectVisual.PlayRay(
gameObject, gameObject,
attackOrigin, attackOrigin,
direction, direction,
length, necrofireRayLength,
controller.AttackWidth, controller.AttackWidth,
controller.ActiveDuration); controller.ActiveDuration);
TickNecrofireRay(); TickNecrofireRay();
@@ -982,7 +987,13 @@ namespace BumpCombat.Enemies
Vector2 direction = lockedDirection.sqrMagnitude > 0.0001f Vector2 direction = lockedDirection.sqrMagnitude > 0.0001f
? lockedDirection ? lockedDirection
: Vector2.right; : 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); float width = Mathf.Max(0.001f, controller.AttackWidth);
Vector2 center = attackOrigin + direction * (length * 0.5f); Vector2 center = attackOrigin + direction * (length * 0.5f);
float angle = Mathf.Atan2(direction.y, direction.x) * Mathf.Rad2Deg; 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<BumpCombat.Stage.StageObstacle>() != null
|| collider.name.StartsWith("Wall_")
|| collider.gameObject.name.Contains("Wall");
if (isTerrain)
{
blockedLength = Mathf.Min(blockedLength, hits[i].distance);
}
}
return blockedLength;
}
} }
} }
@@ -6,6 +6,7 @@ using BumpCombat.Constants;
using BumpCombat.Core; using BumpCombat.Core;
using BumpCombat.Player; using BumpCombat.Player;
using BumpCombat.Progression; using BumpCombat.Progression;
using BumpCombat.Stage;
using UnityEngine; using UnityEngine;
namespace BumpCombat.Enemies namespace BumpCombat.Enemies
@@ -49,6 +50,7 @@ namespace BumpCombat.Enemies
Active, Active,
Recovery, Recovery,
Dead, Dead,
Idle,
} }
[RequireComponent(typeof(Rigidbody2D), typeof(Collider2D), typeof(SpriteRenderer))] [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 HurtParameter = Animator.StringToHash("Hurt");
private static readonly int DeathState = Animator.StringToHash("Base Layer.Death"); private static readonly int DeathState = Animator.StringToHash("Base Layer.Death");
private static readonly Collider2D[] SeparationBuffer = new Collider2D[16]; private static readonly Collider2D[] SeparationBuffer = new Collider2D[16];
private static readonly Dictionary<int, HashSet<EnemyController>> EncounterGroups = new();
private static Sprite launchWhiteSprite; private static Sprite launchWhiteSprite;
private static int nextSpawnLifetimeIdentity; private static int nextSpawnLifetimeIdentity;
[SerializeField] private EnemyDefinition definition; [SerializeField] private EnemyDefinition definition;
@@ -85,6 +88,9 @@ namespace BumpCombat.Enemies
private EnemyAttack enemyAttack; private EnemyAttack enemyAttack;
private EnemyModel enemyModel; private EnemyModel enemyModel;
private ArtifactStatusVisual statusVisual; private ArtifactStatusVisual statusVisual;
private readonly List<Renderer> fogRenderers = new();
private readonly List<bool> fogRendererForceOffStates = new();
private bool fogVisibilityHidden;
private ActiveArtifactController activeArtifactController; private ActiveArtifactController activeArtifactController;
private Transform player; private Transform player;
private PlayerHealth playerHealth; private PlayerHealth playerHealth;
@@ -97,6 +103,7 @@ namespace BumpCombat.Enemies
private float igniteNextTickIn; private float igniteNextTickIn;
private float igniteTickInterval; private float igniteTickInterval;
private float igniteTickDamage; private float igniteTickDamage;
private EnemyDamageSource igniteDamageSource;
private float shockRemaining; private float shockRemaining;
private float shockIncrease; private float shockIncrease;
private float vulnerabilityRemaining; private float vulnerabilityRemaining;
@@ -123,6 +130,7 @@ namespace BumpCombat.Enemies
private IEnemyDamageGate damageGate; private IEnemyDamageGate damageGate;
private IEnemyAttackPatternGate attackPatternGate; private IEnemyAttackPatternGate attackPatternGate;
private IEnemyActionGate actionGate; private IEnemyActionGate actionGate;
private BumpCombat.Defense.DefenseRouteAgent defenseRouteAgent;
private bool summoned; private bool summoned;
private bool phaseSummon; private bool phaseSummon;
private bool summonedEventLike; private bool summonedEventLike;
@@ -142,6 +150,15 @@ namespace BumpCombat.Enemies
private readonly HashSet<string> recentArtifactContactKeys = new(StringComparer.Ordinal); private readonly HashSet<string> recentArtifactContactKeys = new(StringComparer.Ordinal);
private readonly Queue<string> recentArtifactContactOrder = new(); private readonly Queue<string> recentArtifactContactOrder = new();
private const int MaximumRememberedArtifactContacts = 128; 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 AliveCount { get; private set; }
public static int CrowdAliveCount { 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 CurrentHealth { get; private set; }
public float LastAppliedDamage { get; private set; } public float LastAppliedDamage { get; private set; }
public float LastReportedDamage { 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 int CurrentAttackPatternIndex => attackPatternIndex;
public EnemyAttackPattern CurrentAttackPattern => definition != null public EnemyAttackPattern CurrentAttackPattern => definition != null
? definition.GetAttackPattern(attackPatternIndex) ? definition.GetAttackPattern(attackPatternIndex)
@@ -216,6 +235,12 @@ namespace BumpCombat.Enemies
public Vector2 GroundAnchorPosition => body != null public Vector2 GroundAnchorPosition => body != null
? body.position ? body.position
: transform.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 IsKnockbackActive => knockbackVelocity.sqrMagnitude > 0.0025f;
public bool IsLaunchVisualActive => launchVisualCoroutine != null; public bool IsLaunchVisualActive => launchVisualCoroutine != null;
public bool IsAttackSequenceInProgress => State == EnemyState.Warning public bool IsAttackSequenceInProgress => State == EnemyState.Warning
@@ -228,6 +253,11 @@ namespace BumpCombat.Enemies
public EnemyController SummonOwner => summonOwner; public EnemyController SummonOwner => summonOwner;
public bool IsProtectedFromCleanup => runEventRole.HasValue || phaseSummon; public bool IsProtectedFromCleanup => runEventRole.HasValue || phaseSummon;
public bool HasContactDamage => definition != null && definition.HasContactDamage; public bool HasContactDamage => definition != null && definition.HasContactDamage;
public bool IsFlying => definition != null && definition.IsFlying;
public Collider2D BodyCollider => bodyCollider != null ? bodyCollider : (bodyCollider = GetComponent<Collider2D>());
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 public bool IsRangedAttackStartAllowed => definition == null
|| !definition.IsRanged || !definition.IsRanged
|| CanStartRangedAttack(); || CanStartRangedAttack();
@@ -369,6 +399,21 @@ namespace BumpCombat.Enemies
} }
#endif #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() private void Awake()
{ {
nextSpawnLifetimeIdentity = nextSpawnLifetimeIdentity == int.MaxValue nextSpawnLifetimeIdentity = nextSpawnLifetimeIdentity == int.MaxValue
@@ -386,6 +431,7 @@ namespace BumpCombat.Enemies
} }
bodyCollider = GetComponent<Collider2D>(); bodyCollider = GetComponent<Collider2D>();
spriteRenderer = GetComponent<SpriteRenderer>(); spriteRenderer = GetComponent<SpriteRenderer>();
CacheFogRenderers();
animator = GetComponent<Animator>(); animator = GetComponent<Animator>();
enemyAttack = GetComponent<EnemyAttack>(); enemyAttack = GetComponent<EnemyAttack>();
ConfigureEnemyModel(); ConfigureEnemyModel();
@@ -433,6 +479,12 @@ namespace BumpCombat.Enemies
private void OnEnable() private void OnEnable()
{ {
if (fogVisibilityHidden)
{
ForceFogRenderersOff();
}
RegisterEncounterGroup();
if (enemyModel != null) if (enemyModel != null)
{ {
enemyModel.OnStatChanged += HandleCharacterStatChanged; enemyModel.OnStatChanged += HandleCharacterStatChanged;
@@ -455,12 +507,20 @@ namespace BumpCombat.Enemies
} }
KeepBodyInsideArena(); KeepBodyInsideArena();
if (IsFlying)
{
StageObstacle.RegisterFlyingEnemy(this);
}
} }
private void Start() private void Start()
{ {
InitializeGroggyTierFromDefinition(); InitializeGroggyTierFromDefinition();
KeepBodyInsideArena(); KeepBodyInsideArena();
if (IsFlying)
{
StageObstacle.RegisterFlyingEnemy(this);
}
if (definition == null) if (definition == null)
{ {
enabled = false; enabled = false;
@@ -477,8 +537,16 @@ namespace BumpCombat.Enemies
player = playerController.transform; player = playerController.transform;
playerHealth = playerController.GetComponent<PlayerHealth>(); playerHealth = playerController.GetComponent<PlayerHealth>();
activeArtifactController = playerController.GetComponent<ActiveArtifactController>(); activeArtifactController = playerController.GetComponent<ActiveArtifactController>();
if (!hasRoamAnchor)
{
roamAnchor = body != null ? body.position : (Vector2)transform.position;
roamTarget = roamAnchor;
hasRoamAnchor = true;
}
InitializeInitialShieldColor(); InitializeInitialShieldColor();
CurrentHealth = MaximumHealth; CurrentHealth = MaximumHealth;
LastDamageSource = EnemyDamageSource.None;
MaximumHealthAtDeath = 0f;
float summonDuration = summoned ? GetSummonAnimationDuration() : 0f; float summonDuration = summoned ? GetSummonAnimationDuration() : 0f;
bool playSummonAnimation = summonDuration > 0f; bool playSummonAnimation = summonDuration > 0f;
EnterState( EnterState(
@@ -507,6 +575,7 @@ namespace BumpCombat.Enemies
private void OnDisable() private void OnDisable()
{ {
UnregisterEncounterGroup();
if (enemyModel != null) if (enemyModel != null)
{ {
enemyModel.OnStatChanged -= HandleCharacterStatChanged; enemyModel.OnStatChanged -= HandleCharacterStatChanged;
@@ -601,6 +670,13 @@ namespace BumpCombat.Enemies
return; return;
} }
defenseRouteAgent ??= GetComponent<BumpCombat.Defense.DefenseRouteAgent>();
if (defenseRouteAgent != null
&& defenseRouteAgent.TryDriveFixedUpdate(Time.fixedDeltaTime))
{
return;
}
if (!AllowsStateMovement(State, AttackShape)) if (!AllowsStateMovement(State, AttackShape))
{ {
body.linearVelocity = Vector2.zero; body.linearVelocity = Vector2.zero;
@@ -611,12 +687,35 @@ namespace BumpCombat.Enemies
{ {
case EnemyState.Spawn: case EnemyState.Spawn:
if (stateTimer <= 0f) 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); EnterState(EnemyState.Chase, 0f);
} }
else
{
UpdateRoaming();
}
break; break;
case EnemyState.Chase: case EnemyState.Chase:
if (!RefreshChaseAwareness())
{
break;
}
if (actionGate != null && actionGate.IsAttackLocked) if (actionGate != null && actionGate.IsAttackLocked)
{ {
body.linearVelocity = Vector2.zero; body.linearVelocity = Vector2.zero;
@@ -731,7 +830,8 @@ namespace BumpCombat.Enemies
Vector2 knockbackDirection, Vector2 knockbackDirection,
float knockbackForce, float knockbackForce,
bool playHurtAnimation = true, bool playHurtAnimation = true,
bool isNormalBump = false) bool isNormalBump = false,
EnemyDamageSource damageSource = EnemyDamageSource.None)
{ {
return TryTakeDamageWithEffects( return TryTakeDamageWithEffects(
damage, damage,
@@ -741,6 +841,7 @@ namespace BumpCombat.Enemies
isNormalBump, isNormalBump,
IsShocked, IsShocked,
IsBumpVulnerable, IsBumpVulnerable,
damageSource,
out _); out _);
} }
@@ -752,9 +853,11 @@ namespace BumpCombat.Enemies
bool isNormalBump, bool isNormalBump,
bool isShockedAtHit, bool isShockedAtHit,
bool isBumpVulnerableAtHit, bool isBumpVulnerableAtHit,
EnemyDamageSource damageSource,
out bool noDamageAfterEffects) out bool noDamageAfterEffects)
{ {
noDamageAfterEffects = false; noDamageAfterEffects = false;
Alert();
if (IsDead || damage <= 0f) if (IsDead || damage <= 0f)
{ {
LastAppliedDamage = 0f; LastAppliedDamage = 0f;
@@ -802,13 +905,16 @@ namespace BumpCombat.Enemies
? damageTaken ? damageTaken
: calculatedDamage; : calculatedDamage;
float healthBeforeDamage = CurrentHealth; float healthBeforeDamage = CurrentHealth;
float maximumHealthBeforeDamage = MaximumHealth;
CurrentHealth = Mathf.Max(0f, CurrentHealth - damageTaken); CurrentHealth = Mathf.Max(0f, CurrentHealth - damageTaken);
LastAppliedDamage = healthBeforeDamage - CurrentHealth; LastAppliedDamage = healthBeforeDamage - CurrentHealth;
LastDamageSource = damageSource;
OnDamageApplied?.Invoke(healthBeforeDamage, LastAppliedDamage); OnDamageApplied?.Invoke(healthBeforeDamage, LastAppliedDamage);
bool damageReactionBlocked = damageGate != null bool damageReactionBlocked = damageGate != null
&& damageGate.IsDamageBlocked; && damageGate.IsDamageBlocked;
if (CurrentHealth <= 0f) if (CurrentHealth <= 0f)
{ {
MaximumHealthAtDeath = maximumHealthBeforeDamage;
Die(); Die();
} }
else if (damageReactionBlocked) else if (damageReactionBlocked)
@@ -817,13 +923,13 @@ namespace BumpCombat.Enemies
// desperation phase. Do not append the opening hit's // desperation phase. Do not append the opening hit's
// knockback or hurt reaction after that transition. // knockback or hurt reaction after that transition.
knockbackVelocity = Vector2.zero; knockbackVelocity = Vector2.zero;
body.linearVelocity = Vector2.zero; if (body != null) body.linearVelocity = Vector2.zero;
} }
else else
{ {
if (knockbackVelocity.sqrMagnitude <= 0.0025f) if (knockbackVelocity.sqrMagnitude <= 0.0025f)
{ {
knockbackStartPosition = body.position; knockbackStartPosition = body != null ? body.position : (Vector2)transform.position;
} }
knockbackVelocity += knockbackDirection.normalized knockbackVelocity += knockbackDirection.normalized
* knockbackForce * knockbackForce
@@ -914,7 +1020,8 @@ namespace BumpCombat.Enemies
float knockbackForce, float knockbackForce,
ArtifactColor artifactColor, ArtifactColor artifactColor,
int castIdentity, int castIdentity,
bool playHurtAnimation = true) bool playHurtAnimation = true,
EnemyDamageSource damageSource = EnemyDamageSource.None)
{ {
lastArtifactContactCounted = false; lastArtifactContactCounted = false;
if (IsDead) if (IsDead)
@@ -956,7 +1063,8 @@ namespace BumpCombat.Enemies
knockbackDirection, knockbackDirection,
knockbackForce, knockbackForce,
playHurtAnimation, playHurtAnimation,
false) false,
damageSource)
? EnemyArtifactHitResult.DamageApplied ? EnemyArtifactHitResult.DamageApplied
: EnemyArtifactHitResult.Rejected; : EnemyArtifactHitResult.Rejected;
} }
@@ -1004,7 +1112,8 @@ namespace BumpCombat.Enemies
public bool ApplyIgnite( public bool ApplyIgnite(
float duration, float duration,
float tickInterval, float tickInterval,
float damagePerTick) float damagePerTick,
EnemyDamageSource damageSource = EnemyDamageSource.Player)
{ {
if (IsDead if (IsDead
|| duration <= 0f || duration <= 0f
@@ -1017,6 +1126,7 @@ namespace BumpCombat.Enemies
bool wasInactive = igniteRemaining <= 0f; bool wasInactive = igniteRemaining <= 0f;
igniteRemaining = Mathf.Max(igniteRemaining, duration); igniteRemaining = Mathf.Max(igniteRemaining, duration);
igniteTickDamage = Mathf.Max(igniteTickDamage, damagePerTick); igniteTickDamage = Mathf.Max(igniteTickDamage, damagePerTick);
igniteDamageSource = damageSource;
if (wasInactive) if (wasInactive)
{ {
igniteTickInterval = tickInterval; igniteTickInterval = tickInterval;
@@ -1085,6 +1195,7 @@ namespace BumpCombat.Enemies
false, false,
isShockedAtTick, isShockedAtTick,
false, false,
igniteDamageSource,
out bool noDamageAfterEffects); out bool noDamageAfterEffects);
if ((!damageApplied && !noDamageAfterEffects) || IsDead) if ((!damageApplied && !noDamageAfterEffects) || IsDead)
{ {
@@ -1821,19 +1932,28 @@ namespace BumpCombat.Enemies
Vector2 attackSlot = GetHorizontalRootAttackSlot( Vector2 attackSlot = GetHorizontalRootAttackSlot(
attackTarget, attackTarget,
direction); 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; Vector2 toAttackSlot = attackSlot - body.position;
movementDirection = toAttackSlot.sqrMagnitude > 0.0001f movementDirection = toAttackSlot.sqrMagnitude > 0.0001f
? toAttackSlot.normalized ? toAttackSlot.normalized
: Vector2.zero; : 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) else if (definition.IsRanged && rangedAttackOutsideGate)
{ {
// A ranged attacker must enter the visible, reachable area // A ranged attacker must enter the visible, reachable area
@@ -1845,11 +1965,45 @@ namespace BumpCombat.Enemies
{ {
movementDirection = -direction; 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 separation = CalculateSeparation();
Vector2 velocity = Vector2.ClampMagnitude( Vector2 desiredVelocity = Vector2.ClampMagnitude(
movementDirection + separation * separationStrength, movementDirection + separation * separationStrength,
1f) * MoveSpeed; 1f) * MoveSpeed;
Vector2 velocity = CalculateWallSlideVelocity(desiredVelocity);
body.MovePosition(ClampEnemyPosition( body.MovePosition(ClampEnemyPosition(
body.position + velocity * Time.fixedDeltaTime)); body.position + velocity * Time.fixedDeltaTime));
SetMoving(velocity.sqrMagnitude > 0.0001f); SetMoving(velocity.sqrMagnitude > 0.0001f);
@@ -1864,10 +2018,11 @@ namespace BumpCombat.Enemies
if (bounds != null) if (bounds != null)
{ {
Vector2 actorExtents = bounds.SharedActorClampHalfExtents; Vector2 actorExtents = bounds.SharedActorClampHalfExtents;
Vector2 center = bounds.ZoneCenter;
boundedTarget.y = Mathf.Clamp( boundedTarget.y = Mathf.Clamp(
attackTarget.y, attackTarget.y,
-actorExtents.y, center.y - actorExtents.y,
actorExtents.y); center.y + actorExtents.y);
} }
// Horizontal-root attacks measure range to the player's collider // Horizontal-root attacks measure range to the player's collider
@@ -1919,9 +2074,10 @@ namespace BumpCombat.Enemies
EnemyConstants tuning = GameplayConstants.Current.Enemies; EnemyConstants tuning = GameplayConstants.Current.Enemies;
Vector2 reachable = bounds.GetReachableHalfExtents( Vector2 reachable = bounds.GetReachableHalfExtents(
tuning.RangedApproachArenaInset); tuning.RangedApproachArenaInset);
Vector2 center = bounds.ZoneCenter;
Vector2 interiorTarget = new( Vector2 interiorTarget = new(
Mathf.Clamp(targetPosition.x, -reachable.x, reachable.x), Mathf.Clamp(targetPosition.x, center.x - reachable.x, center.x + reachable.x),
Mathf.Clamp(targetPosition.y, -reachable.y, reachable.y)); Mathf.Clamp(targetPosition.y, center.y - reachable.y, center.y + reachable.y));
Camera camera = Camera.main; Camera camera = Camera.main;
if (camera != null) if (camera != null)
{ {
@@ -1933,10 +2089,10 @@ namespace BumpCombat.Enemies
// Keep the approach point in the intersection of the // Keep the approach point in the intersection of the
// reachable and visible interiors. This is essential when // reachable and visible interiors. This is essential when
// the player is held against an outer map edge. // the player is held against an outer map edge.
float minimumX = Mathf.Max(-reachable.x, cameraCenter.x - visible.x); float minimumX = Mathf.Max(center.x - reachable.x, cameraCenter.x - visible.x);
float maximumX = Mathf.Min(reachable.x, cameraCenter.x + visible.x); float maximumX = Mathf.Min(center.x + reachable.x, cameraCenter.x + visible.x);
float minimumY = Mathf.Max(-reachable.y, cameraCenter.y - visible.y); float minimumY = Mathf.Max(center.y - reachable.y, cameraCenter.y - visible.y);
float maximumY = Mathf.Min(reachable.y, cameraCenter.y + visible.y); float maximumY = Mathf.Min(center.y + reachable.y, cameraCenter.y + visible.y);
if (minimumX <= maximumX) if (minimumX <= maximumX)
{ {
interiorTarget.x = Mathf.Clamp( interiorTarget.x = Mathf.Clamp(
@@ -2095,6 +2251,11 @@ namespace BumpCombat.Enemies
return cachedSeparation; return cachedSeparation;
} }
public void SetFacingDirection(Vector2 direction)
{
SetFacing(direction);
}
private void SetFacing(Vector2 direction) private void SetFacing(Vector2 direction)
{ {
if (direction.sqrMagnitude <= 0f) if (direction.sqrMagnitude <= 0f)
@@ -2126,6 +2287,276 @@ namespace BumpCombat.Enemies
|| (state == EnemyState.Warning && attackAnimationStarted)); || (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<PlayerController>();
if (playerController == null) return;
player = playerController.transform;
playerHealth = playerController.GetComponent<PlayerHealth>();
activeArtifactController = playerController.GetComponent<ActiveArtifactController>();
}
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<EnemyController> 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<EnemyController> group))
{
group = new HashSet<EnemyController>();
EncounterGroups.Add(encounterGroupId, group);
}
group.Add(this);
}
private void UnregisterEncounterGroup()
{
if (encounterGroupId < 0
|| !EncounterGroups.TryGetValue(encounterGroupId, out HashSet<EnemyController> 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<StageObstacle>() != 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( public static bool ShouldStartAttackAnimation(
float warningTimeRemaining, float warningTimeRemaining,
float leadTime, float leadTime,
@@ -2294,6 +2725,89 @@ namespace BumpCombat.Enemies
ReleaseSpriteVisibilityLease(reason); 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<Renderer>(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<Renderer>(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) private void AcquireSpriteVisibilityLease(string reason)
{ {
if (string.IsNullOrEmpty(reason)) if (string.IsNullOrEmpty(reason))
@@ -0,0 +1,10 @@
namespace BumpCombat.Enemies
{
public enum EnemyDamageSource
{
None,
Player,
Tower,
PassiveRelicExplosion,
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: c98a61b910a94e56aa5cdfa41234baf2
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -297,6 +297,8 @@ namespace BumpCombat.Enemies
[SerializeField] private EnemyAttackPattern[] attackPatterns; [SerializeField] private EnemyAttackPattern[] attackPatterns;
[SerializeField, Min(0f)] private float separationRadius = 0.55f; [SerializeField, Min(0f)] private float separationRadius = 0.55f;
[SerializeField, Min(0f)] private float separationStrength = 0.6f; [SerializeField, Min(0f)] private float separationStrength = 0.6f;
[SerializeField] private bool isFlying;
[SerializeField, Min(0f)] private float sightRange = 8f;
public EnemyKind Kind => kind; public EnemyKind Kind => kind;
public EnemyRole Role => role; public EnemyRole Role => role;
@@ -326,6 +328,8 @@ namespace BumpCombat.Enemies
public bool IsCrowd => role == EnemyRole.Crowd; public bool IsCrowd => role == EnemyRole.Crowd;
public bool HasContactDamage => kind == EnemyKind.Slime public bool HasContactDamage => kind == EnemyKind.Slime
|| kind == EnemyKind.Bat; || 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) public EnemyAttackPattern GetAttackPattern(int index)
{ {
@@ -394,6 +398,16 @@ namespace BumpCombat.Enemies
{ {
attackPatterns = patterns; attackPatterns = patterns;
} }
public void ConfigureFlying(bool flying)
{
isFlying = flying;
}
public void ConfigureSightRange(float range)
{
sightRange = range;
}
#endif #endif
} }
} }
@@ -10,6 +10,7 @@ namespace BumpCombat.Enemies
private float maxHealthMultiplier = 1f; private float maxHealthMultiplier = 1f;
private float moveSpeedMultiplier = 1f; private float moveSpeedMultiplier = 1f;
private float attackDamageMultiplier = 1f; private float attackDamageMultiplier = 1f;
private float sightRangeMultiplier = 1f;
public EnemyDefinition Definition => definition; public EnemyDefinition Definition => definition;
public override float MaxHealth => definition == null public override float MaxHealth => definition == null
@@ -26,6 +27,14 @@ namespace BumpCombat.Enemies
? 0f ? 0f
: CalculateAttackDamage( : CalculateAttackDamage(
definition.AttackDamage * attackDamageMultiplier); 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) public void ConfigureDefinition(EnemyDefinition enemyDefinition)
{ {
@@ -1,5 +1,6 @@
using BumpCombat.Player; using BumpCombat.Player;
using BumpCombat.Core; using BumpCombat.Core;
using BumpCombat.Stage;
using UnityEngine; using UnityEngine;
namespace BumpCombat.Enemies namespace BumpCombat.Enemies
@@ -82,6 +83,8 @@ namespace BumpCombat.Enemies
} }
bool hitTarget = false; bool hitTarget = false;
float targetDistance = float.PositiveInfinity;
float blockerDistance = float.PositiveInfinity;
if (distance > 0f) if (distance > 0f)
{ {
RaycastHit2D[] hits = Physics2D.CircleCastAll( RaycastHit2D[] hits = Physics2D.CircleCastAll(
@@ -92,12 +95,28 @@ namespace BumpCombat.Enemies
Physics2D.AllLayers); Physics2D.AllLayers);
for (int i = 0; i < hits.Length; i++) for (int i = 0; i < hits.Length; i++)
{ {
if (hits[i].collider != null Collider2D collider = hits[i].collider;
&& hits[i].collider.GetComponentInParent<PlayerHealth>() == target) if (collider == null || collider.isTrigger)
{ {
hitTarget = true; continue;
break;
} }
if (collider.GetComponentInParent<PlayerHealth>() == 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 else
@@ -134,6 +153,14 @@ namespace BumpCombat.Enemies
} }
} }
private static bool IsTerrainBlocker(Collider2D collider)
{
return collider != null
&& (collider.GetComponentInParent<StageObstacle>() != null
|| collider.name.StartsWith("Wall_")
|| collider.gameObject.name.Contains("Wall"));
}
private void OnDisable() private void OnDisable()
{ {
resolved = true; resolved = true;
@@ -5,6 +5,7 @@ using BumpCombat.Combat;
using BumpCombat.Constants; using BumpCombat.Constants;
using BumpCombat.Core; using BumpCombat.Core;
using BumpCombat.Enemies; using BumpCombat.Enemies;
using BumpCombat.Progression;
using UnityEngine; using UnityEngine;
using UnityEngine.InputSystem; using UnityEngine.InputSystem;
@@ -339,6 +340,13 @@ namespace BumpCombat.Player
{ {
UpdateTemporaryPlayerModifiers(); UpdateTemporaryPlayerModifiers();
if (playerHealth != null && playerHealth.CurrentHealth <= 0f)
{
CancelArtifactInput();
CancelExecution();
return;
}
if (playerHealth != null && playerHealth.IsHurtMovementLocked) if (playerHealth != null && playerHealth.IsHurtMovementLocked)
{ {
CancelArtifactInput(); CancelArtifactInput();
@@ -450,6 +458,7 @@ namespace BumpCombat.Player
if (definition == null if (definition == null
|| IsExecutingArtifact || IsExecutingArtifact
|| (dashController != null && dashController.IsDashing) || (dashController != null && dashController.IsDashing)
|| (playerHealth != null && playerHealth.CurrentHealth <= 0f)
|| (playerHealth != null && playerHealth.IsHurtMovementLocked) || (playerHealth != null && playerHealth.IsHurtMovementLocked)
|| (charged && !IsArtifactChargeUnlocked)) || (charged && !IsArtifactChargeUnlocked))
{ {
@@ -546,7 +555,7 @@ namespace BumpCombat.Player
return true; return true;
} }
private int GetOwnedArtifactColorCount(ArtifactColor color) public int GetOwnedArtifactColorCount(ArtifactColor color)
{ {
int count = 0; int count = 0;
for (int i = 0; i < ownedArtifacts.Count; i++) for (int i = 0; i < ownedArtifacts.Count; i++)
@@ -1168,6 +1177,7 @@ namespace BumpCombat.Player
float nextHitAt = 0f; float nextHitAt = 0f;
int hitIndex = 0; int hitIndex = 0;
int hitCount = GetScheduledHitCount(duration, hitInterval); int hitCount = GetScheduledHitCount(duration, hitInterval);
bool movementBlocked = false;
while (elapsed < duration) while (elapsed < duration)
{ {
visual.transform.position = body.position visual.transform.position = body.position
@@ -1206,10 +1216,24 @@ namespace BumpCombat.Player
yield return new WaitForFixedUpdate(); yield return new WaitForFixedUpdate();
elapsed += Time.fixedDeltaTime; elapsed += Time.fixedDeltaTime;
body.MovePosition(Vector2.Lerp( if (!movementBlocked
start, && playerController != null
end, && !playerController.IsTransitionLocked)
Mathf.Clamp01(elapsed / duration))); {
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 visual.transform.position = body.position
+ GetCycloneShadowOffsetWorld(); + GetCycloneShadowOffsetWorld();
} }
@@ -1236,7 +1260,6 @@ namespace BumpCombat.Player
hitIndex++; hitIndex++;
} }
body.position = end;
DestroyVisual(visual); DestroyVisual(visual);
EndExecution(); EndExecution();
} }
@@ -2190,6 +2213,134 @@ namespace BumpCombat.Player
return false; 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<EnemyController> 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( private bool ApplyArtifactHit(
EnemyController enemy, EnemyController enemy,
ActiveArtifactDefinition definition, ActiveArtifactDefinition definition,
@@ -2199,7 +2350,8 @@ namespace BumpCombat.Player
bool showsHitFeedback = true, bool showsHitFeedback = true,
bool charged = false, bool charged = false,
float igniteTickDamage = 0f, float igniteTickDamage = 0f,
int castIdentity = 0) int castIdentity = 0,
bool passiveRelicEcho = false)
{ {
if (enemy == null || enemy.IsDead) if (enemy == null || enemy.IsDead)
{ {
@@ -2219,7 +2371,9 @@ namespace BumpCombat.Player
direction, direction,
knockback, knockback,
definition.ArtifactColor, definition.ArtifactColor,
castIdentity); castIdentity,
true,
EnemyDamageSource.Player);
if (result == EnemyArtifactHitResult.Rejected) if (result == EnemyArtifactHitResult.Rejected)
{ {
return false; return false;
@@ -2233,7 +2387,7 @@ namespace BumpCombat.Player
if (result == EnemyArtifactHitResult.Shielded) if (result == EnemyArtifactHitResult.Shielded)
{ {
if (enemy.LastArtifactContactCounted) if (!passiveRelicEcho && enemy.LastArtifactContactCounted)
{ {
CombatEvents.RaiseArtifactEffectiveHit( CombatEvents.RaiseArtifactEffectiveHit(
enemy.gameObject, enemy.gameObject,
@@ -2262,7 +2416,7 @@ namespace BumpCombat.Player
igniteTickDamage); igniteTickDamage);
} }
if (damageWasApplied) if (damageWasApplied && !passiveRelicEcho)
{ {
CombatEvents.RaiseArtifactEffectiveHit( CombatEvents.RaiseArtifactEffectiveHit(
enemy.gameObject, enemy.gameObject,
@@ -2283,7 +2437,7 @@ namespace BumpCombat.Player
false, false,
false, false,
definition.DamageTag, definition.DamageTag,
definition.ArtifactId, passiveRelicEcho ? PassiveRelicController.EchoSourceId : definition.ArtifactId,
false, false,
result == EnemyArtifactHitResult.DamageApplied result == EnemyArtifactHitResult.DamageApplied
&& showsHitFeedback, && showsHitFeedback,
@@ -44,7 +44,8 @@ namespace BumpCombat.Player
|| IsDashing || IsDashing
|| playerController == null || playerController == null
|| playerController.IsDamageKnockbackActive || playerController.IsDamageKnockbackActive
|| playerController.IsHurtMovementLocked) || playerController.IsHurtMovementLocked
|| playerController.IsTransitionLocked)
{ {
return false; return false;
} }
@@ -81,19 +82,29 @@ namespace BumpCombat.Player
: dashDuration; : dashDuration;
Vector2 start = body.position; Vector2 start = body.position;
Vector2 end = playerController.ClampToArena(start + direction * distance); Vector2 end = playerController.ClampToArena(start + direction * distance);
Vector2 finalPosition = start;
combatResolver.BeginDashPathContactSession(); combatResolver.BeginDashPathContactSession();
float elapsed = 0f; float elapsed = 0f;
while (elapsed < duration) while (elapsed < duration)
{ {
yield return new WaitForFixedUpdate(); yield return new WaitForFixedUpdate();
if (playerController.IsTransitionLocked)
{
break;
}
combatFeedback.EmitDashAfterimage(body.position, isStrongDash); combatFeedback.EmitDashAfterimage(body.position, isStrongDash);
Vector2 segmentStart = body.position; Vector2 segmentStart = body.position;
elapsed += Time.fixedDeltaTime; elapsed += Time.fixedDeltaTime;
Vector2 segmentEnd = Vector2.Lerp( Vector2 intendedEnd = Vector2.Lerp(
start, start,
end, end,
Mathf.Clamp01(elapsed / duration)); Mathf.Clamp01(elapsed / duration));
Vector2 segmentEnd = playerController.ClampMovementToTerrain(
segmentStart,
intendedEnd);
bool blocked = (segmentEnd - intendedEnd).sqrMagnitude > 0.0001f;
Vector2 segment = segmentEnd - segmentStart; Vector2 segment = segmentEnd - segmentStart;
if (segment.sqrMagnitude > 0.000001f) if (segment.sqrMagnitude > 0.000001f)
{ {
@@ -116,9 +127,14 @@ namespace BumpCombat.Player
} }
body.MovePosition(segmentEnd); body.MovePosition(segmentEnd);
finalPosition = segmentEnd;
if (blocked)
{
break;
}
} }
body.position = end; body.position = finalPosition;
IsDashing = false; IsDashing = false;
IsStrongDashing = false; IsStrongDashing = false;
animator.SetBool(IsDashingParameter, false); animator.SetBool(IsDashingParameter, false);
@@ -16,6 +16,8 @@ namespace BumpCombat.Player
Animator.StringToHash("Base Layer.BackBumpAttack"); Animator.StringToHash("Base Layer.BackBumpAttack");
private static readonly int ArtifactUseState = private static readonly int ArtifactUseState =
Animator.StringToHash("Base Layer.ArtifactUse"); Animator.StringToHash("Base Layer.ArtifactUse");
private static readonly int DeathState =
Animator.StringToHash("Base Layer.Death");
public const string BumpAttackStateName = "BumpAttack"; public const string BumpAttackStateName = "BumpAttack";
public const string BackBumpAttackStateName = "BackBumpAttack"; public const string BackBumpAttackStateName = "BackBumpAttack";
@@ -45,6 +47,7 @@ namespace BumpCombat.Player
public bool IsDamageKnockbackActive { get; private set; } public bool IsDamageKnockbackActive { get; private set; }
public bool IsHurtMovementLocked => public bool IsHurtMovementLocked =>
playerHealth != null && playerHealth.IsHurtMovementLocked; playerHealth != null && playerHealth.IsHurtMovementLocked;
public bool IsTransitionLocked { get; private set; }
public Vector2 PlayerClampHalfExtents public Vector2 PlayerClampHalfExtents
{ {
get get
@@ -95,6 +98,23 @@ namespace BumpCombat.Player
return TryPlayCombatAnimation(ArtifactUseState, false); 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( private bool TryPlayCombatAnimation(
int stateHash, int stateHash,
bool blockActiveArtifactUse) bool blockActiveArtifactUse)
@@ -148,7 +168,7 @@ namespace BumpCombat.Player
private void Update() private void Update()
{ {
if (!RunManager.GameplayInputEnabled || IsHurtMovementLocked) if (!RunManager.GameplayInputEnabled || IsHurtMovementLocked || IsTransitionLocked)
{ {
SetMoveInput(Vector2.zero); SetMoveInput(Vector2.zero);
return; return;
@@ -172,6 +192,13 @@ namespace BumpCombat.Player
private void FixedUpdate() private void FixedUpdate()
{ {
AttackIntentVelocity = Vector2.zero; AttackIntentVelocity = Vector2.zero;
if (IsTransitionLocked)
{
if (body != null) body.linearVelocity = Vector2.zero;
CurrentVelocity = Vector2.zero;
return;
}
if (dashController != null && dashController.IsDashing) if (dashController != null && dashController.IsDashing)
{ {
pendingImpactRecoil = Vector2.zero; pendingImpactRecoil = Vector2.zero;
@@ -244,6 +271,57 @@ namespace BumpCombat.Player
- GameplayConstants.Current.Player.ArenaPadding.y)); - 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<Collider2D>();
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<BumpCombat.Stage.StageObstacle>() != 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) public bool TryReposition(Vector2 position)
{ {
if (body == null) if (body == null)
@@ -338,5 +416,36 @@ namespace BumpCombat.Player
spriteRenderer.flipX = input.x < 0f; 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;
}
}
}
} }
} }
+43 -1
View File
@@ -2,6 +2,7 @@ using System;
using BumpCombat.Combat; using BumpCombat.Combat;
using BumpCombat.Core; using BumpCombat.Core;
using BumpCombat.Constants; using BumpCombat.Constants;
using BumpCombat.Defense;
using UnityEngine; using UnityEngine;
using UnityEngine.InputSystem; using UnityEngine.InputSystem;
using UnityEngine.InputSystem.LowLevel; using UnityEngine.InputSystem.LowLevel;
@@ -235,8 +236,29 @@ namespace BumpCombat.Player
if (CurrentHealth <= 0f) if (CurrentHealth <= 0f)
{ {
float deathAnimationDuration = playerController.PlayDeathAnimation();
OnDied?.Invoke(); 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; return true;
@@ -247,6 +269,26 @@ namespace BumpCombat.Player
return value >= 0f ? value : configuredValue; 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) public void Heal(float amount)
{ {
if (amount <= 0f || CurrentHealth <= 0f) if (amount <= 0f || CurrentHealth <= 0f)
@@ -3,6 +3,7 @@ using BumpCombat.Audio;
using BumpCombat.Combat; using BumpCombat.Combat;
using BumpCombat.Core; using BumpCombat.Core;
using BumpCombat.Player; using BumpCombat.Player;
using BumpCombat.Defense;
using BumpCombat.Spawning; using BumpCombat.Spawning;
using BumpCombat.UI; using BumpCombat.UI;
using UnityEngine; using UnityEngine;
@@ -30,6 +31,7 @@ namespace BumpCombat.Progression
private readonly Queue<RewardSource> pendingRewards = new(); private readonly Queue<RewardSource> pendingRewards = new();
private readonly HashSet<RewardSource> queuedSources = new(); private readonly HashSet<RewardSource> queuedSources = new();
private readonly PassiveRelicId[] shownRelics = new PassiveRelicId[DebugOptionCount];
private readonly ActiveArtifactDefinition[] shownArtifacts = private readonly ActiveArtifactDefinition[] shownArtifacts =
new ActiveArtifactDefinition[DebugOptionCount]; new ActiveArtifactDefinition[DebugOptionCount];
private ActiveArtifactController artifacts; private ActiveArtifactController artifacts;
@@ -42,6 +44,9 @@ namespace BumpCombat.Progression
private int shownCount; private int shownCount;
private int selectedIndex; private int selectedIndex;
private bool debugStartupSelection; private bool debugStartupSelection;
private bool showingPassiveRelicReward;
private bool pendingPassiveRelicReward;
private PassiveRelicController passiveRelics;
private int startupSelectionCount; private int startupSelectionCount;
private bool debugStartupSelectionPending; private bool debugStartupSelectionPending;
private int debugStartupSelectionEarliestFrame; private int debugStartupSelectionEarliestFrame;
@@ -148,7 +153,7 @@ namespace BumpCombat.Progression
RunManager.Instance?.StartupArtifactSelectionCount ?? 3); RunManager.Instance?.StartupArtifactSelectionCount ?? 3);
} }
private void QueueStartupSelection(int selectionCount) public void QueueStartupSelection(int selectionCount)
{ {
startupSelectionCount = Mathf.Max(0, selectionCount); startupSelectionCount = Mathf.Max(0, selectionCount);
debugStartupSelectionPending = true; debugStartupSelectionPending = true;
@@ -157,15 +162,25 @@ namespace BumpCombat.Progression
private bool ChooseOption(int optionIndex) private bool ChooseOption(int optionIndex)
{ {
if (showingPassiveRelicReward)
{
return ChoosePassiveRelicOption(optionIndex);
}
if (panel == null if (panel == null
|| !panel.activeSelf || !panel.activeSelf
|| optionIndex < 0 || optionIndex < 0
|| optionIndex >= shownCount || optionIndex >= shownCount)
|| !artifacts.TryAddArtifact(shownArtifacts[optionIndex]))
{ {
return false; 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"); BumpCombatAudioService.Instance?.PlayUi("ui_confirm");
if (artifacts.OwnedArtifactCount == 1) if (artifacts.OwnedArtifactCount == 1)
{ {
@@ -201,14 +216,55 @@ namespace BumpCombat.Progression
return true; 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() private bool OpenStartupSelection()
{ {
if (artifacts == null)
{
artifacts = FindAnyObjectByType<ActiveArtifactController>();
}
if (panel == null)
{
CreatePanel();
}
if (RunManager.Instance == null if (RunManager.Instance == null
|| !RunManager.Instance.TryOpenSelection(this)) || !RunManager.Instance.TryOpenSelection(this))
{ {
return false; return false;
} }
if (artifacts == null)
{
RunManager.Instance.CloseSelection(this);
return false;
}
debugSelectionsRequired = Mathf.Min( debugSelectionsRequired = Mathf.Min(
startupSelectionCount, startupSelectionCount,
artifacts.MaxOwnedArtifacts - artifacts.OwnedArtifactCount); artifacts.MaxOwnedArtifacts - artifacts.OwnedArtifactCount);
@@ -243,6 +299,26 @@ namespace BumpCombat.Progression
} }
ArtifactColor requiredColor = GetStartupColor(selectedCount); 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; for (int i = 0;
i < artifacts.CatalogCount && shownCount < DebugOptionCount; i < artifacts.CatalogCount && shownCount < DebugOptionCount;
i++) i++)
@@ -284,6 +360,12 @@ namespace BumpCombat.Progression
private void HandleEventEnemyDefeated(RunTimedEvent timedEvent) private void HandleEventEnemyDefeated(RunTimedEvent timedEvent)
{ {
if (Stage.StageManager.IsStageModeActive)
{
// In stage mode, StageManager.HandleMajorBossDefeated coordinates rewards
return;
}
if (timedEvent == RunTimedEvent.Elite) if (timedEvent == RunTimedEvent.Elite)
{ {
if (RunManager.Instance?.IsProductionRun == true) if (RunManager.Instance?.IsProductionRun == true)
@@ -311,8 +393,41 @@ namespace BumpCombat.Progression
} }
} }
public void QueueColorReward(ArtifactColor color)
{
if (artifacts == null)
{
artifacts = FindAnyObjectByType<ActiveArtifactController>();
}
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) private void QueueReward(RewardSource source)
{ {
if (artifacts == null)
{
artifacts = FindAnyObjectByType<ActiveArtifactController>();
}
if (artifacts == null if (artifacts == null
|| artifacts.OwnedArtifactCount >= artifacts.MaxOwnedArtifacts) || artifacts.OwnedArtifactCount >= artifacts.MaxOwnedArtifacts)
{ {
@@ -330,8 +445,19 @@ namespace BumpCombat.Progression
private void TryOpenPendingReward() private void TryOpenPendingReward()
{ {
if (pendingRewards.Count == 0 || artifacts == null) if (artifacts == null)
{ {
artifacts = FindAnyObjectByType<ActiveArtifactController>();
}
if (artifacts == null || (pendingRewards.Count == 0 && !pendingPassiveRelicReward))
{
return;
}
if (pendingPassiveRelicReward)
{
TryOpenPassiveRelicReward();
return; return;
} }
@@ -341,6 +467,11 @@ namespace BumpCombat.Progression
return; return;
} }
if (panel == null)
{
CreatePanel();
}
if (RunManager.Instance == null if (RunManager.Instance == null
|| !RunManager.Instance.TryOpenSelection(this)) || !RunManager.Instance.TryOpenSelection(this))
{ {
@@ -359,7 +490,8 @@ namespace BumpCombat.Progression
if (definition != null if (definition != null
&& !artifacts.OwnsArtifact(definition) && !artifacts.OwnsArtifact(definition)
&& (!hasRequiredColor && (!hasRequiredColor
|| definition.ArtifactColor == requiredColor)) || definition.ArtifactColor == requiredColor)
&& artifacts.GetOwnedArtifactColorCount(definition.ArtifactColor) < artifacts.MaxArtifactsPerColor)
{ {
candidates.Add(definition); candidates.Add(definition);
} }
@@ -384,16 +516,65 @@ namespace BumpCombat.Progression
titleText.text = source switch 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.EliteRed => "ELITE REWARD - CHOOSE RED ARTIFACT",
RewardSource.EliteBlue => "ELITE REWARD - CHOOSE BLUE 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", _ => "CHOOSE ARTIFACT",
}; };
selectedIndex = 0; selectedIndex = 0;
ShowOptions(); ShowOptions();
} }
private void TryOpenPassiveRelicReward()
{
passiveRelics = FindAnyObjectByType<PassiveRelicController>();
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) private void HandleSelectionChanged(bool isOpen)
{ {
if (!isOpen && (panel == null || !panel.activeSelf)) if (!isOpen && (panel == null || !panel.activeSelf))
@@ -450,6 +631,13 @@ namespace BumpCombat.Progression
continue; continue;
} }
if (showingPassiveRelicReward)
{
optionIconImages[i].gameObject.SetActive(false);
optionTexts[i].text = GetPassiveRelicName(shownRelics[i]);
continue;
}
ActiveArtifactDefinition definition = shownArtifacts[i]; ActiveArtifactDefinition definition = shownArtifacts[i];
Sprite icon = PresentationUiStyle.GetArtifactIcon(definition); Sprite icon = PresentationUiStyle.GetArtifactIcon(definition);
optionIconImages[i].sprite = icon; optionIconImages[i].sprite = icon;
@@ -518,9 +706,11 @@ namespace BumpCombat.Progression
{ {
switch (source) switch (source)
{ {
case RewardSource.Elite:
case RewardSource.EliteRed: case RewardSource.EliteRed:
color = ArtifactColor.Red; color = ArtifactColor.Red;
return true; return true;
case RewardSource.MidBoss:
case RewardSource.EliteBlue: case RewardSource.EliteBlue:
color = ArtifactColor.Blue; color = ArtifactColor.Blue;
return true; return true;
@@ -605,6 +795,7 @@ namespace BumpCombat.Progression
#if UNITY_EDITOR #if UNITY_EDITOR
public int DebugShownCount => shownCount; public int DebugShownCount => shownCount;
public bool DebugIsSelectionVisible => panel != null && panel.activeSelf; public bool DebugIsSelectionVisible => panel != null && panel.activeSelf;
public int DebugShownPassiveRelicCount => showingPassiveRelicReward ? shownCount : 0;
public ActiveArtifactDefinition DebugGetShownArtifact(int index) public ActiveArtifactDefinition DebugGetShownArtifact(int index)
{ {
@@ -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<PassiveRelicId> owned)
{
List<PassiveRelicId> 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<PassiveRelicId> owned = new();
private readonly HashSet<int> 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<float> 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<PlayerController>();
playerCollider = GetComponent<Collider2D>();
playerHealth = GetComponent<PlayerHealth>();
artifacts = GetComponent<ActiveArtifactController>();
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<EnemyController>();
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<EnemyController> damagedEnemies = new();
foreach (Collider2D hit in hits)
{
EnemyController enemy = hit != null ? hit.GetComponentInParent<EnemyController>() : 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());
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 6b531569e5b6419493b2af9d6a0be4ee
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -44,6 +44,7 @@ namespace BumpCombat.Spawning
// current elite is still alive and the event must wait in the queue. // current elite is still alive and the event must wait in the queue.
private int nextEliteScheduleOrdinal; private int nextEliteScheduleOrdinal;
private int nextEliteSpawnOrdinal; private int nextEliteSpawnOrdinal;
private static int nextEncounterGroupId;
private const int SummonSpawnAngleSteps = 32; private const int SummonSpawnAngleSteps = 32;
private static RunConstants Tuning => GameplayConstants.Current.Run; private static RunConstants Tuning => GameplayConstants.Current.Run;
@@ -148,6 +149,477 @@ namespace BumpCombat.Spawning
return HasTimedEventEnemy(timedEvent); 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<PlayerController>();
Vector2 playerPos = (player != null)
? (Vector2)player.transform.position
: zone.GetEntryPosition(BumpCombat.Stage.StageDirection.West);
List<Vector2> occupiedPositions = new();
// Sort items so NormalMelee / Ranged claim authored anchors first before Crowd
List<BumpCombat.Stage.RoomEncounterItem> 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<Vector2> 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<Vector2> 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<Vector2> 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<EnemyKind>(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() private void Start()
{ {
if (RunManager.Instance != null) if (RunManager.Instance != null)
@@ -175,6 +647,11 @@ namespace BumpCombat.Spawning
{ {
return; return;
} }
if (BumpCombat.Stage.StageManager.IsStageModeActive
|| RunManager.Instance?.IsDefensePrototypeRun == true)
{
return;
}
TrySpawnPendingEvents(); TrySpawnPendingEvents();
float elapsedTime = RunManager.Instance?.ProgressionTime ?? 0f; float elapsedTime = RunManager.Instance?.ProgressionTime ?? 0f;
bool productionRun = RunManager.Instance?.IsProductionRun == true; bool productionRun = RunManager.Instance?.IsProductionRun == true;
@@ -732,11 +1209,21 @@ namespace BumpCombat.Spawning
if (role == RunTimedEvent.MidBoss) if (role == RunTimedEvent.MidBoss)
{ {
midBossDefeated = true; midBossDefeated = true;
RunManager.Instance?.UnlockArtifactCharge(); BumpCombat.Stage.StageManager.Instance?.HandleMajorBossDefeated(
BumpCombat.Stage.StageNodeType.MidBoss,
enemy.GetComponentInParent<BumpCombat.Stage.ZoneInstance>());
} }
else if (role == RunTimedEvent.Elite) else if (role == RunTimedEvent.Elite)
{ {
RunManager.Instance?.UnlockGuard(); BumpCombat.Stage.StageManager.Instance?.HandleMajorBossDefeated(
BumpCombat.Stage.StageNodeType.Elite,
enemy.GetComponentInParent<BumpCombat.Stage.ZoneInstance>());
}
else if (role == RunTimedEvent.FinalBoss)
{
BumpCombat.Stage.StageManager.Instance?.HandleMajorBossDefeated(
BumpCombat.Stage.StageNodeType.FinalBoss,
enemy.GetComponentInParent<BumpCombat.Stage.ZoneInstance>());
} }
OnEventEnemyDefeated?.Invoke(role); OnEventEnemyDefeated?.Invoke(role);
@@ -837,6 +1324,18 @@ namespace BumpCombat.Spawning
return null; 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) private EnemyController GetPrefab(EnemyKind kind)
{ {
if (enemyPrefabs == null) if (enemyPrefabs == null)
@@ -1254,11 +1753,20 @@ namespace BumpCombat.Spawning
return false; return false;
} }
EnemyRole role = crowd ? EnemyRole.Crowd : EnemyRole.Normal;
EnemyController[] candidates = new EnemyController[count]; EnemyController[] candidates = new EnemyController[count];
for (int i = 0; i < count; i++) 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) if (candidates[i] == null)
{ {
return false; return false;
@@ -1405,6 +1913,14 @@ namespace BumpCombat.Spawning
{ {
summonedEnemies.Add(enemy); summonedEnemies.Add(enemy);
enemy.OnDied += HandleSummonedEnemyDied; enemy.OnDied += HandleSummonedEnemyDied;
EnemyController owner = enemy.SummonOwner;
BumpCombat.Stage.ZoneInstance ownerZone =
owner != null ? owner.GetComponentInParent<BumpCombat.Stage.ZoneInstance>() : null;
if (ownerZone != null)
{
ownerZone.RegisterSpawnedEnemy(enemy);
}
} }
private void HandleSummonedEnemyDied(EnemyController enemy) private void HandleSummonedEnemyDied(EnemyController enemy)
+8
View File
@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: e815e053fa79d1d48937f5a46191e144
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 2c003d169eb47fa4686effb16f4ea632
@@ -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<BumpCombat.Player.PlayerController>() != null) continue;
if (col.GetComponentInParent<EnemyController>() != null) continue;
StageObstacle obs = col.GetComponentInParent<StageObstacle>();
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<int> openList = new List<int> { 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);
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: b000a747520a0a24099e7fbe4ae07ff2
+263
View File
@@ -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<RoomEncounterItem> 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<RoomPresetSocket> Sockets { get; } = new();
public List<RoomPresetSpawnPoint> SpawnPoints { get; } = new();
public List<RoomEncounter> 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,
};
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: d49115a75f6ce4040a5a4ab8e6dbe013
+226
View File
@@ -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<StageSocketConnection> 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<StageNode> 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);
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: f104d6187a441b64490a36c56c14a47b
@@ -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<Vector2Int, StageNode> 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;
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 88a6a4cfc3af80346b8d19e35e0dd000
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 3a9bd30ad88a2b6439ddd24c147ba25b
@@ -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<StageObstacle> activeObstacles = new();
public static IReadOnlyCollection<StageObstacle> AllObstacles => activeObstacles;
private Collider2D obstacleCollider;
public Collider2D Collider => obstacleCollider != null ? obstacleCollider : (obstacleCollider = GetComponent<Collider2D>());
private void Awake()
{
if (obstacleCollider == null)
{
obstacleCollider = GetComponent<Collider2D>();
}
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<EnemyController>(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);
}
}
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 310bf4383bf41c64e85c457bc29aed17
@@ -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<Collider2D>();
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<BoxCollider2D>();
if (barrierCollider == null)
{
barrierCollider = barrierObject.AddComponent<BoxCollider2D>();
}
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<SpriteRenderer>();
}
else
{
gatePadObj = new GameObject("GatePad");
gatePadObj.transform.SetParent(transform, false);
padRenderer = gatePadObj.AddComponent<SpriteRenderer>();
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<TextMesh>();
}
else
{
labelObj = new GameObject("GateLabel");
labelObj.transform.SetParent(transform, false);
labelTextMesh = labelObj.AddComponent<TextMesh>();
labelTextMesh.anchor = TextAnchor.MiddleCenter;
labelTextMesh.alignment = TextAlignment.Center;
labelTextMesh.fontSize = 28;
labelTextMesh.characterSize = 0.055f;
labelTextMesh.fontStyle = FontStyle.Bold;
MeshRenderer mr = labelObj.GetComponent<MeshRenderer>();
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<SpriteRenderer>();
}
}
}
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<PlayerController>()
?? other.GetComponentInParent<PlayerController>();
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;
}
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: b932041c3dc4d3a47a4261ef873089ad
@@ -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<string, ZoneConnector> connectorsById = new(StringComparer.OrdinalIgnoreCase);
private readonly Dictionary<StageDirection, ZoneConnector> connectorsByDir = new();
private readonly List<EnemyController> spawnedEnemies = new();
private readonly List<GameObject> spawnedObstacles = new();
private readonly HashSet<EnemyController> 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<EnemyController> SpawnedEnemies => spawnedEnemies;
public IReadOnlyList<GameObject> 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<StageFogOfWar>();
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<Collider2D>();
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<StageObstacle>() != null
|| collider.GetComponentInParent<ZoneConnector>() != 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<ZoneConnector> 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<PlayerController>();
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<PlayerController>();
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<Collider2D>();
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>();
meshFilter.sharedMesh = mesh;
meshRenderer = gameObject.AddComponent<MeshRenderer>();
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);
}
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 7cf2808cfbde3d84480fceef82b7f3a8
@@ -331,18 +331,19 @@ namespace BumpCombat.UI
24, 24,
TextAnchor.MiddleLeft, TextAnchor.MiddleLeft,
Vector2.zero, Vector2.zero,
new Vector2(220f, 110f)); new Vector2(240f, 220f));
RectTransform hintRect = hintText.rectTransform; RectTransform hintRect = hintText.rectTransform;
hintRect.anchorMin = new Vector2(0f, 1f); hintRect.anchorMin = new Vector2(0f, 1f);
hintRect.anchorMax = new Vector2(0f, 1f); hintRect.anchorMax = new Vector2(0f, 1f);
hintRect.pivot = 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.supportRichText = true;
hintText.resizeTextForBestFit = false; hintText.resizeTextForBestFit = false;
hintText.horizontalOverflow = HorizontalWrapMode.Wrap; hintText.horizontalOverflow = HorizontalWrapMode.Wrap;
hintText.verticalOverflow = VerticalWrapMode.Overflow; hintText.verticalOverflow = VerticalWrapMode.Overflow;
hintText.text = "<size=36><color=#FFD675><b>TAB</b></color></size>\n" hintText.text = "<size=32><color=#FFD675><b>TAB</b></color></size> <size=20>(미션/업적)</size>\n\n"
+ "<size=24>(미션 / 업적)</size>"; + "<size=18><color=#79E5FF><b>조작 안내</b></color></size>\n"
+ "<size=16><color=#FFFFFF>방향키 [▲▼◀▶]: 이동\n[A]: 스킬 / [D]: 가드\n[S]: 교체 / [M]: 지도</color></size>";
hintText.color = Color.white; hintText.color = Color.white;
} }
+137
View File
@@ -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<ExperienceSystem>();
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<Canvas>() ?? FindAnyObjectByType<Canvas>();
if (canvas == null)
{
return;
}
panel = new GameObject("RunClearPanel");
panel.transform.SetParent(canvas.transform, false);
RectTransform panelRect = panel.AddComponent<RectTransform>();
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<Image>();
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<Text>();
titleText.font = Resources.GetBuiltinResource<Font>("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<RectTransform>();
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<Text>();
statsText.font = Resources.GetBuiltinResource<Font>("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<RectTransform>();
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<Text>();
restartText.font = Resources.GetBuiltinResource<Font>("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<RectTransform>();
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);
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 5c63f44ba2673624d9e2fcdb546c8268
+214 -77
View File
@@ -53,13 +53,17 @@ namespace BumpCombat.UI
private Button[] debugSpawnButtons = System.Array.Empty<Button>(); private Button[] debugSpawnButtons = System.Array.Empty<Button>();
private GameObject minimapPanel; private GameObject minimapPanel;
private RectTransform minimapMapRect; private RectTransform minimapMapRect;
private RectTransform minimapTerrainRoot;
private RectTransform minimapMarkerRoot; private RectTransform minimapMarkerRoot;
private RectTransform minimapViewportRoot; private RectTransform minimapViewportRoot;
private Image minimapPlayerMarker; private Image minimapPlayerMarker;
private Image[] minimapViewportEdges = System.Array.Empty<Image>(); private Image[] minimapViewportEdges = System.Array.Empty<Image>();
private readonly List<Image> minimapEnemyMarkers = new(); private readonly List<Image> minimapEnemyMarkers = new();
private readonly Dictionary<Vector2Int, Image> minimapTerrainCells = new();
private EnemyController[] minimapEnemies = System.Array.Empty<EnemyController>(); private EnemyController[] minimapEnemies = System.Array.Empty<EnemyController>();
private float minimapEnemyRefreshElapsed; private float minimapEnemyRefreshElapsed;
private Stage.ZoneInstance minimapTerrainZone;
private int minimapTerrainVisibilityRevision = -1;
private ArenaBounds arenaBounds; private ArenaBounds arenaBounds;
// The panel sprite has transparent source padding. At multiplier 1, // The panel sprite has transparent source padding. At multiplier 1,
// 6px of canvas space keeps the map inside the visible frame. // 6px of canvas space keeps the map inside the visible frame.
@@ -68,22 +72,9 @@ namespace BumpCombat.UI
private Image[] artifactSlotIcons = System.Array.Empty<Image>(); private Image[] artifactSlotIcons = System.Array.Empty<Image>();
private readonly Stack<Text> damagePopupPool = new(); private readonly Stack<Text> damagePopupPool = new();
private readonly HashSet<Text> activeDamagePopups = new(); private readonly HashSet<Text> activeDamagePopups = new();
private static readonly RunTimedEvent[] NextBossEvents =
{
RunTimedEvent.MidBoss,
RunTimedEvent.FinalBoss,
};
private static readonly string[] NextBossLabels =
{
"다음 중간보스",
"다음 보스",
};
private int activeDamagePopupCount; private int activeDamagePopupCount;
private int damagePopupLifecycleVersion; private int damagePopupLifecycleVersion;
private float compactHudFadeElapsed; private float compactHudFadeElapsed;
private int lastNextEventSecond = -1;
private bool lastNextEventUsesDebugTimes;
private bool lastNextEventIsProductionRun;
private static readonly Color PlayerDamagePopupColor = private static readonly Color PlayerDamagePopupColor =
new(244f / 255f, 117f / 255f, 112f / 255f, 1f); new(244f / 255f, 117f / 255f, 112f / 255f, 1f);
private static readonly Color GuardReadyColor = new Color32(255, 205, 92, 255); private static readonly Color GuardReadyColor = new Color32(255, 205, 92, 255);
@@ -741,61 +732,25 @@ namespace BumpCombat.UI
new Vector2(0f, -100f), new Vector2(0f, -100f),
new Vector2(420f, 28f)); new Vector2(420f, 28f));
ConfigureCompactHudText(eventText, 14, 18); ConfigureCompactHudText(eventText, 14, 18);
eventText.gameObject.SetActive(true); eventText.gameObject.SetActive(false);
UpdateNextEventHud(); UpdateNextEventHud();
} }
private void UpdateNextEventHud() private void UpdateNextEventHud()
{ {
if (eventText == null || runManager == null || showingTimedEventMessage) if (eventText == null)
{ {
return; return;
} }
int elapsedSecond = Mathf.FloorToInt(runManager.ElapsedTime); if (!showingTimedEventMessage)
if (elapsedSecond == lastNextEventSecond
&& runManager.UseDebugEventTimes == lastNextEventUsesDebugTimes
&& runManager.IsProductionRun == lastNextEventIsProductionRun)
{ {
return; if (eventText.gameObject.activeSelf)
}
lastNextEventSecond = elapsedSecond;
lastNextEventUsesDebugTimes = runManager.UseDebugEventTimes;
lastNextEventIsProductionRun = runManager.IsProductionRun;
float nextTime = float.PositiveInfinity;
string nextLabel = "다음 이벤트 없음";
for (int i = 0; i < runManager.CurrentEliteEventCount; i++)
{
float eliteTime = runManager.GetEliteEventTimeForCurrentRun(i);
if (eliteTime > runManager.ElapsedTime)
{ {
nextTime = eliteTime; eventText.gameObject.SetActive(false);
nextLabel = "다음 엘리트";
break;
} }
}
for (int i = 0; i < NextBossEvents.Length; i++)
{
float eventTime = runManager.GetEventTimeForCurrentRun(
NextBossEvents[i]);
if (eventTime > runManager.ElapsedTime && eventTime < nextTime)
{
nextTime = eventTime;
nextLabel = NextBossLabels[i];
}
}
if (float.IsPositiveInfinity(nextTime))
{
eventText.text = nextLabel;
return; return;
} }
int seconds = Mathf.FloorToInt(nextTime);
eventText.text =
$"{nextLabel} {seconds / 60:00}:{seconds % 60:00}";
} }
private void LayoutDebugText() private void LayoutDebugText()
@@ -844,6 +799,20 @@ namespace BumpCombat.UI
{ {
popupText = $"LAUNCH {popupText}"; popupText = $"LAUNCH {popupText}";
} }
EnemyController popupEnemy = result.Target != null
? result.Target.GetComponentInParent<EnemyController>()
: null;
Stage.ZoneInstance popupZone = Stage.StageManager.Instance != null
? Stage.StageManager.Instance.CurrentZone
: null;
if (popupEnemy != null
&& popupZone != null
&& !popupZone.IsWorldPositionVisible(popupEnemy.GroundAnchorPosition))
{
return;
}
Color popupColor; Color popupColor;
if (!TryGetArtifactPopupColor(result, out popupColor)) if (!TryGetArtifactPopupColor(result, out popupColor))
{ {
@@ -1271,7 +1240,7 @@ namespace BumpCombat.UI
RunTimedEvent.MidBoss, RunTimedEvent.MidBoss,
RunTimedEvent.FinalBoss, RunTimedEvent.FinalBoss,
}; };
string[] labels = { "엘리트 출현", "중간보스 출현", "보스 출현" }; string[] labels = { "엘리트 출현", "중간보스 출현", "보스방 이동" };
debugSpawnButtons = new Button[events.Length]; debugSpawnButtons = new Button[events.Length];
for (int i = 0; i < events.Length; i++) for (int i = 0; i < events.Length; i++)
{ {
@@ -1296,16 +1265,30 @@ namespace BumpCombat.UI
Button button = buttonObject.GetComponent<Button>(); Button button = buttonObject.GetComponent<Button>();
button.targetGraphic = image; button.targetGraphic = image;
PresentationUiStyle.ApplyButton(button); PresentationUiStyle.ApplyButton(button);
RunTimedEvent requestedEvent = events[i]; if (i == 2)
button.onClick.AddListener(() =>
{ {
if (spawnDirector != null button.onClick.AddListener(() =>
&& spawnDirector.TryDebugSpawnEventEnemy(requestedEvent))
{ {
HandleTimedEvent(requestedEvent); if (Stage.StageManager.Instance != null)
} {
UpdateDebugSpawnHud(); Stage.StageManager.Instance.CheatTeleportToBossRoom();
}); }
UpdateDebugSpawnHud();
});
}
else
{
RunTimedEvent requestedEvent = events[i];
button.onClick.AddListener(() =>
{
if (spawnDirector != null
&& spawnDirector.TryDebugSpawnEventEnemy(requestedEvent))
{
HandleTimedEvent(requestedEvent);
}
UpdateDebugSpawnHud();
});
}
CreateRuntimeText( CreateRuntimeText(
buttonObject.transform, buttonObject.transform,
"Label", "Label",
@@ -1327,7 +1310,7 @@ namespace BumpCombat.UI
bool visible = runManager != null && runManager.UseDebugArtifactSelection; bool visible = runManager != null && runManager.UseDebugArtifactSelection;
debugSpawnPanel.SetActive(visible); debugSpawnPanel.SetActive(visible);
if (!visible || spawnDirector == null) if (!visible)
{ {
return; return;
} }
@@ -1338,10 +1321,19 @@ namespace BumpCombat.UI
RunTimedEvent.MidBoss, RunTimedEvent.MidBoss,
RunTimedEvent.FinalBoss, RunTimedEvent.FinalBoss,
}; };
bool inputEnabled = RunManager.GameplayInputEnabled;
for (int i = 0; i < debugSpawnButtons.Length; i++) for (int i = 0; i < debugSpawnButtons.Length; i++)
{ {
debugSpawnButtons[i].interactable = if (i == 2)
spawnDirector.CanDebugSpawnEventEnemy(events[i]); {
debugSpawnButtons[i].interactable =
inputEnabled && Stage.StageManager.Instance != null;
}
else
{
debugSpawnButtons[i].interactable =
spawnDirector != null && spawnDirector.CanDebugSpawnEventEnemy(events[i]);
}
} }
} }
@@ -1370,7 +1362,7 @@ namespace BumpCombat.UI
new Vector2(panelWidth, panelHeight)); new Vector2(panelWidth, panelHeight));
Image minimapFrame = minimapPanel.GetComponent<Image>(); Image minimapFrame = minimapPanel.GetComponent<Image>();
PresentationUiStyle.ApplyPanel(minimapFrame); PresentationUiStyle.ApplyPanel(minimapFrame);
minimapFrame.pixelsPerUnitMultiplier = 1f; minimapFrame.pixelsPerUnitMultiplier = PresentationUiStyle.ThreePixelBorderMultiplier;
minimapFrame.fillCenter = false; minimapFrame.fillCenter = false;
GameObject map = CreateRuntimePanel( GameObject map = CreateRuntimePanel(
@@ -1384,6 +1376,7 @@ namespace BumpCombat.UI
map.GetComponent<Image>().color = new Color(0.035f, 0.06f, 0.08f, 0.28f); map.GetComponent<Image>().color = new Color(0.035f, 0.06f, 0.08f, 0.28f);
map.AddComponent<RectMask2D>(); map.AddComponent<RectMask2D>();
minimapTerrainRoot = CreateMinimapLayer(map.transform, "Minimap Terrain");
minimapMarkerRoot = CreateMinimapLayer(map.transform, "Markers"); minimapMarkerRoot = CreateMinimapLayer(map.transform, "Markers");
minimapViewportRoot = CreateMinimapLayer(map.transform, "Minimap Camera Viewport"); minimapViewportRoot = CreateMinimapLayer(map.transform, "Minimap Camera Viewport");
minimapMarkerRoot.SetAsLastSibling(); minimapMarkerRoot.SetAsLastSibling();
@@ -1453,6 +1446,8 @@ namespace BumpCombat.UI
return; return;
} }
UpdateMinimapTerrain();
SetMinimapMarkerPosition( SetMinimapMarkerPosition(
minimapPlayerMarker, minimapPlayerMarker,
playerHealth != null ? playerHealth.transform.position : Vector3.zero, playerHealth != null ? playerHealth.transform.position : Vector3.zero,
@@ -1469,6 +1464,9 @@ namespace BumpCombat.UI
} }
int markerIndex = 0; int markerIndex = 0;
Stage.ZoneInstance visibilityZone = Stage.StageManager.Instance != null
? Stage.StageManager.Instance.CurrentZone
: null;
for (int i = 0; i < minimapEnemies.Length; i++) for (int i = 0; i < minimapEnemies.Length; i++)
{ {
EnemyController enemy = minimapEnemies[i]; EnemyController enemy = minimapEnemies[i];
@@ -1476,6 +1474,16 @@ namespace BumpCombat.UI
{ {
continue; continue;
} }
if (worldCamera == null
|| !arenaBounds.IsInsideCameraVisible(worldCamera, enemy.GroundAnchorPosition))
{
continue;
}
if (visibilityZone != null
&& !visibilityZone.IsWorldPositionVisible(enemy.GroundAnchorPosition))
{
continue;
}
Image marker = GetMinimapEnemyMarker(markerIndex++); Image marker = GetMinimapEnemyMarker(markerIndex++);
marker.gameObject.SetActive(true); marker.gameObject.SetActive(true);
@@ -1499,6 +1507,132 @@ namespace BumpCombat.UI
UpdateMinimapViewport(extents); UpdateMinimapViewport(extents);
} }
private void UpdateMinimapTerrain()
{
Stage.ZoneInstance currentZone = Stage.StageManager.Instance != null
? Stage.StageManager.Instance.CurrentZone
: null;
if (currentZone == minimapTerrainZone)
{
if (currentZone != null
&& currentZone.VisibilityRevision != minimapTerrainVisibilityRevision)
{
UpdateMinimapTerrainVisibility(currentZone);
}
return;
}
minimapTerrainZone = currentZone;
minimapTerrainVisibilityRevision = -1;
minimapTerrainCells.Clear();
if (minimapTerrainRoot == null)
{
return;
}
for (int i = minimapTerrainRoot.childCount - 1; i >= 0; i--)
{
Destroy(minimapTerrainRoot.GetChild(i).gameObject);
}
Stage.RoomPreset preset = currentZone != null ? currentZone.Preset : null;
if (preset == null || !currentZone.HasRuntimeGrid ||
preset.GridWidth <= 0 || preset.GridHeight <= 0)
{
return;
}
Vector2 mapSize = minimapMapRect.rect.size;
float cellWidth = mapSize.x / preset.GridWidth;
float cellHeight = mapSize.y / preset.GridHeight;
float left = -mapSize.x * 0.5f;
float top = mapSize.y * 0.5f;
for (int y = 0; y < preset.GridHeight; y++)
{
for (int x = 0; x < preset.GridWidth; x++)
{
Stage.PresetCellType cellType = currentZone.GetRuntimeCell(x, y);
if (cellType == Stage.PresetCellType.Empty)
{
continue;
}
Image cell = CreateRuntimeImage(
minimapTerrainRoot,
$"Terrain {x},{y}",
Vector2.zero,
new Vector2(cellWidth + 0.2f, cellHeight + 0.2f));
cell.raycastTarget = false;
minimapTerrainCells[new Vector2Int(x, y)] = cell;
SetRectTransform(
cell.rectTransform,
new Vector2(0.5f, 0.5f),
new Vector2(0.5f, 0.5f),
new Vector2(
left + (x + 0.5f) * cellWidth,
top - (y + 0.5f) * cellHeight),
cell.rectTransform.sizeDelta);
}
}
UpdateMinimapTerrainVisibility(currentZone);
}
private void UpdateMinimapTerrainVisibility(Stage.ZoneInstance zone)
{
if (zone == null)
{
return;
}
foreach (KeyValuePair<Vector2Int, Image> entry in minimapTerrainCells)
{
Vector2Int cell = entry.Key;
if (entry.Value == null)
{
continue;
}
entry.Value.color = GetMinimapTerrainColor(
zone.GetRuntimeCell(cell.x, cell.y),
zone.IsCellVisible(cell.x, cell.y),
zone.IsCellExplored(cell.x, cell.y));
}
minimapTerrainVisibilityRevision = zone.VisibilityRevision;
}
private static Color GetMinimapTerrainColor(
Stage.PresetCellType cellType,
bool visible,
bool explored)
{
Color baseColor = cellType switch
{
Stage.PresetCellType.Obstacle => new Color32(12, 18, 22, 240),
Stage.PresetCellType.Socket => new Color32(54, 103, 112, 230),
Stage.PresetCellType.RewardAnchor => new Color32(73, 88, 70, 205),
_ => new Color32(41, 57, 62, 190),
};
if (visible)
{
return baseColor;
}
if (explored)
{
return new Color(
baseColor.r * 0.35f,
baseColor.g * 0.35f,
baseColor.b * 0.35f,
1f);
}
return new Color32(2, 4, 5, 255);
}
private Image GetMinimapEnemyMarker(int index) private Image GetMinimapEnemyMarker(int index)
{ {
while (minimapEnemyMarkers.Count <= index) while (minimapEnemyMarkers.Count <= index)
@@ -1544,9 +1678,11 @@ namespace BumpCombat.UI
return; return;
} }
Vector2 center = arenaBounds != null ? arenaBounds.ZoneCenter : Vector2.zero;
Vector2 localPos = worldPosition - center;
Vector2 normalized = new( Vector2 normalized = new(
Mathf.Clamp01((worldPosition.x + extents.x) / (extents.x * 2f)), Mathf.Clamp01((localPos.x + extents.x) / (extents.x * 2f)),
Mathf.Clamp01((worldPosition.y + extents.y) / (extents.y * 2f))); Mathf.Clamp01((localPos.y + extents.y) / (extents.y * 2f)));
Vector2 halfMarker = marker.rectTransform.rect.size * 0.5f; Vector2 halfMarker = marker.rectTransform.rect.size * 0.5f;
float halfMapWidth = mapSize.x * 0.5f; float halfMapWidth = mapSize.x * 0.5f;
float halfMapHeight = mapSize.y * 0.5f; float halfMapHeight = mapSize.y * 0.5f;
@@ -1575,8 +1711,9 @@ namespace BumpCombat.UI
Vector2 mapSize = minimapMapRect.rect.size; Vector2 mapSize = minimapMapRect.rect.size;
Bounds visible = arenaBounds.GetCameraVisibleBounds(worldCamera); Bounds visible = arenaBounds.GetCameraVisibleBounds(worldCamera);
Vector2 min = WorldToMinimapPosition(visible.min, extents, mapSize); Vector2 zoneCenter = arenaBounds != null ? arenaBounds.ZoneCenter : Vector2.zero;
Vector2 max = WorldToMinimapPosition(visible.max, extents, mapSize); Vector2 min = WorldToMinimapPosition(visible.min, extents, mapSize, zoneCenter);
Vector2 max = WorldToMinimapPosition(visible.max, extents, mapSize, zoneCenter);
float width = Mathf.Max(2f, max.x - min.x); float width = Mathf.Max(2f, max.x - min.x);
float height = Mathf.Max(2f, max.y - min.y); float height = Mathf.Max(2f, max.y - min.y);
Vector2 center = (min + max) * 0.5f; Vector2 center = (min + max) * 0.5f;
@@ -1617,11 +1754,13 @@ namespace BumpCombat.UI
private static Vector2 WorldToMinimapPosition( private static Vector2 WorldToMinimapPosition(
Vector2 worldPosition, Vector2 worldPosition,
Vector2 extents, Vector2 extents,
Vector2 mapSize) Vector2 mapSize,
Vector2 center = default)
{ {
Vector2 localPos = worldPosition - center;
Vector2 normalized = new( Vector2 normalized = new(
Mathf.Clamp01((worldPosition.x + extents.x) / (extents.x * 2f)), Mathf.Clamp01((localPos.x + extents.x) / (extents.x * 2f)),
Mathf.Clamp01((worldPosition.y + extents.y) / (extents.y * 2f))); Mathf.Clamp01((localPos.y + extents.y) / (extents.y * 2f)));
return new Vector2( return new Vector2(
(normalized.x - 0.5f) * mapSize.x, (normalized.x - 0.5f) * mapSize.x,
(normalized.y - 0.5f) * mapSize.y); (normalized.y - 0.5f) * mapSize.y);
@@ -1705,12 +1844,10 @@ namespace BumpCombat.UI
private IEnumerator ShowEventMessage(string message) private IEnumerator ShowEventMessage(string message)
{ {
showingTimedEventMessage = true; showingTimedEventMessage = true;
lastNextEventSecond = -1;
eventText.text = message; eventText.text = message;
eventText.gameObject.SetActive(true); eventText.gameObject.SetActive(true);
yield return new WaitForSecondsRealtime(3f); yield return new WaitForSecondsRealtime(3f);
showingTimedEventMessage = false; showingTimedEventMessage = false;
lastNextEventSecond = -1;
UpdateNextEventHud(); UpdateNextEventHud();
} }
@@ -1760,7 +1897,7 @@ namespace BumpCombat.UI
"Presentation/Fonts/NanumGothic-Regular"; "Presentation/Fonts/NanumGothic-Regular";
private const string NeoFontResource = "Presentation/Fonts/neodgm"; private const string NeoFontResource = "Presentation/Fonts/neodgm";
private const string GalmuriFontResource = "Presentation/Fonts/Galmuri9"; private const string GalmuriFontResource = "Presentation/Fonts/Galmuri9";
private const float ThreePixelBorderMultiplier = 1f / 3f; internal const float ThreePixelBorderMultiplier = 1f / 3f;
private static readonly Color PanelFallback = private static readonly Color PanelFallback =
new(24f / 255f, 27f / 255f, 39f / 255f, 0.94f); new(24f / 255f, 27f / 255f, 39f / 255f, 0.94f);
@@ -456,6 +456,11 @@ namespace BumpCombat.UI
BeginRun(RunMode.Production); BeginRun(RunMode.Production);
} }
private void BeginDefensePrototypeRun()
{
BeginRun(RunMode.DefensePrototype);
}
private void OpenTitleModes() private void OpenTitleModes()
{ {
SetScreen(Screen.TitleModes); SetScreen(Screen.TitleModes);
@@ -469,6 +474,10 @@ namespace BumpCombat.UI
ShowButton(index++, "개발용 시작", BeginDevelopmentRun); ShowButton(index++, "개발용 시작", BeginDevelopmentRun);
} }
ShowButton(index++, "기본 모드", BeginProductionRun); ShowButton(index++, "기본 모드", BeginProductionRun);
if (IsDevelopmentStartVisible(Application.isEditor, Debug.isDebugBuild))
{
ShowButton(index++, "방어전 테스트", BeginDefensePrototypeRun);
}
ShowDisabledButton(index++, "챌린지 모드(준비 중)"); ShowDisabledButton(index++, "챌린지 모드(준비 중)");
ShowDisabledButton(index++, "하드코어 모드(준비 중)"); ShowDisabledButton(index++, "하드코어 모드(준비 중)");
ShowButton(index, "뒤로", ReturnToTitleMenu); ShowButton(index, "뒤로", ReturnToTitleMenu);
@@ -650,7 +659,7 @@ namespace BumpCombat.UI
inputHelpText.alignment = TextAnchor.MiddleLeft; inputHelpText.alignment = TextAnchor.MiddleLeft;
inputHelpText.color = new Color32(200, 208, 218, 255); inputHelpText.color = new Color32(200, 208, 218, 255);
CreateSettingsFocusRows(panel.transform); CreateSettingsFocusRows(panel.transform);
for (int i = 0; i < 5; i++) for (int i = 0; i < 6; i++)
{ {
Button button = CreateButton(panel.transform, $"Button {i + 1}", font, new Vector2(0f, 95f - i * 88f)); Button button = CreateButton(panel.transform, $"Button {i + 1}", font, new Vector2(0f, 95f - i * 88f));
button.gameObject.SetActive(false); button.gameObject.SetActive(false);
+340
View File
@@ -0,0 +1,340 @@
using System.Collections.Generic;
using BumpCombat.Audio;
using BumpCombat.Core;
using BumpCombat.Stage;
using UnityEngine;
using UnityEngine.InputSystem;
using UnityEngine.UI;
namespace BumpCombat.UI
{
public sealed class WorldMapPanel : MonoBehaviour
{
private GameObject panel;
private Text titleText;
private Text seedText;
private RectTransform mapContainer;
private readonly List<GameObject> spawnedElements = new();
private bool isOpen;
private RunManager runManager;
public bool IsOpen => isOpen;
private void Start()
{
runManager = RunManager.Instance;
CreatePanel();
}
private void Update()
{
if (Keyboard.current == null || runManager == null)
{
return;
}
if (Keyboard.current.mKey.wasPressedThisFrame)
{
if (isOpen)
{
CloseMap();
}
else
{
OpenMap();
}
}
else if (isOpen && Keyboard.current.escapeKey.wasPressedThisFrame)
{
CloseMap();
}
}
public void OpenMap()
{
if (isOpen || runManager == null || runManager.IsTitleScreen || runManager.IsGameOver || runManager.IsRunCleared)
{
return;
}
if (!runManager.TryOpenSelection(this))
{
return;
}
isOpen = true;
panel.SetActive(true);
BumpCombatAudioService.Instance?.PlayUi("ui_confirm");
RefreshMap();
}
public void CloseMap()
{
if (!isOpen)
{
return;
}
isOpen = false;
panel.SetActive(false);
runManager?.SuppressPauseOpeningForCurrentFrame();
runManager?.CloseSelection(this);
BumpCombatAudioService.Instance?.PlayUi("ui_cancel");
}
private void CreatePanel()
{
Canvas canvas = GetComponentInParent<Canvas>() ?? FindAnyObjectByType<Canvas>();
if (canvas == null)
{
return;
}
panel = new GameObject("WorldMapPanel");
panel.transform.SetParent(canvas.transform, false);
RectTransform panelRect = panel.AddComponent<RectTransform>();
panelRect.anchorMin = Vector2.zero;
panelRect.anchorMax = Vector2.one;
panelRect.offsetMin = Vector2.zero;
panelRect.offsetMax = Vector2.zero;
// Background dark dim
Image bg = panel.AddComponent<Image>();
bg.color = new Color(0.05f, 0.06f, 0.1f, 0.92f);
// Top Header: Title & Seed
GameObject titleObj = new GameObject("MapTitle");
titleObj.transform.SetParent(panel.transform, false);
titleText = titleObj.AddComponent<Text>();
titleText.font = Resources.GetBuiltinResource<Font>("LegacyRuntime.ttf");
titleText.fontSize = 28;
titleText.fontStyle = FontStyle.Bold;
titleText.alignment = TextAnchor.MiddleCenter;
titleText.color = new Color(0.9f, 0.85f, 0.6f);
RectTransform titleRect = titleObj.GetComponent<RectTransform>();
titleRect.anchorMin = new Vector2(0f, 1f);
titleRect.anchorMax = new Vector2(1f, 1f);
titleRect.pivot = new Vector2(0.5f, 1f);
titleRect.anchoredPosition = new Vector2(0f, -25f);
titleRect.sizeDelta = new Vector2(0f, 40f);
GameObject seedObj = new GameObject("MapSeed");
seedObj.transform.SetParent(panel.transform, false);
seedText = seedObj.AddComponent<Text>();
seedText.font = Resources.GetBuiltinResource<Font>("LegacyRuntime.ttf");
seedText.fontSize = 16;
seedText.alignment = TextAnchor.MiddleCenter;
seedText.color = new Color(0.7f, 0.7f, 0.8f);
RectTransform seedRect = seedObj.GetComponent<RectTransform>();
seedRect.anchorMin = new Vector2(0f, 1f);
seedRect.anchorMax = new Vector2(1f, 1f);
seedRect.pivot = new Vector2(0.5f, 1f);
seedRect.anchoredPosition = new Vector2(0f, -65f);
seedRect.sizeDelta = new Vector2(0f, 25f);
// Container for grid nodes
GameObject containerObj = new GameObject("MapContainer");
containerObj.transform.SetParent(panel.transform, false);
mapContainer = containerObj.AddComponent<RectTransform>();
mapContainer.anchorMin = new Vector2(0.1f, 0.1f);
mapContainer.anchorMax = new Vector2(0.9f, 0.85f);
mapContainer.offsetMin = Vector2.zero;
mapContainer.offsetMax = Vector2.zero;
// Bottom guide
GameObject guideObj = new GameObject("MapGuide");
guideObj.transform.SetParent(panel.transform, false);
Text guideText = guideObj.AddComponent<Text>();
guideText.font = Resources.GetBuiltinResource<Font>("LegacyRuntime.ttf");
guideText.fontSize = 16;
guideText.fontStyle = FontStyle.Bold;
guideText.alignment = TextAnchor.MiddleCenter;
guideText.color = new Color(1f, 0.85f, 0.35f, 1f); // Prominent yellow
guideText.text = "※ 지도는 확인용입니다. 창을 닫고 캐릭터를 움직여 방의 [출구 ▶]로 들어가면 다음 구역으로 진입합니다.\n[M] 또는 [ESC] 키를 눌러 지도를 닫으세요.";
RectTransform guideRect = guideObj.GetComponent<RectTransform>();
guideRect.anchorMin = new Vector2(0f, 0f);
guideRect.anchorMax = new Vector2(1f, 0f);
guideRect.pivot = new Vector2(0.5f, 0f);
guideRect.anchoredPosition = new Vector2(0f, 15f);
guideRect.sizeDelta = new Vector2(0f, 45f);
panel.SetActive(false);
}
private void RefreshMap()
{
// Clear previous elements
for (int i = 0; i < spawnedElements.Count; i++)
{
if (spawnedElements[i] != null)
{
Destroy(spawnedElements[i]);
}
}
spawnedElements.Clear();
StageManager stageManager = StageManager.Instance;
if (stageManager == null || stageManager.CurrentPlan == null)
{
titleText.text = "STAGE WORLD MAP";
seedText.text = "NO ACTIVE STAGE";
return;
}
StagePlan plan = stageManager.CurrentPlan;
titleText.text = $"STAGE {plan.StageIndex} — {GetStageSubtitle(plan.StageIndex)}";
seedText.text = $"SEED: {stageManager.CurrentSeed}";
RectInt bounds = plan.GetGridBounds();
float cellWidth = 140f;
float cellHeight = 90f;
Vector2 origin = new Vector2(
-(bounds.x + bounds.width * 0.5f - 0.5f) * cellWidth,
-(bounds.y + bounds.height * 0.5f - 0.5f) * cellHeight);
// Render Connection Lines first
foreach (StageNode node in plan.Nodes)
{
Vector2 nodePos = origin + new Vector2(node.GridPosition.x * cellWidth, node.GridPosition.y * cellHeight);
for (int d = 0; d < 4; d++)
{
StageDirection dir = (StageDirection)d;
int targetId = node.GetConnection(dir);
if (targetId > node.Id) // Draw each line once
{
StageNode targetNode = plan.GetNode(targetId);
if (targetNode != null)
{
Vector2 targetPos = origin + new Vector2(targetNode.GridPosition.x * cellWidth, targetNode.GridPosition.y * cellHeight);
CreateConnectionLine(nodePos, targetPos);
}
}
}
}
// Render Nodes
foreach (StageNode node in plan.Nodes)
{
Vector2 nodePos = origin + new Vector2(node.GridPosition.x * cellWidth, node.GridPosition.y * cellHeight);
CreateNodeCard(node, nodePos);
}
}
private static string GetStageSubtitle(int stageIndex)
{
return stageIndex switch
{
1 => "ELITE STAGE",
2 => "MID BOSS STAGE",
3 => "FINAL BOSS STAGE",
_ => "EXPLORATION",
};
}
private void CreateConnectionLine(Vector2 from, Vector2 to)
{
GameObject lineObj = new GameObject("MapLine");
lineObj.transform.SetParent(mapContainer, false);
Image lineImg = lineObj.AddComponent<Image>();
lineImg.color = new Color(0.3f, 0.4f, 0.55f, 0.6f);
RectTransform rt = lineObj.GetComponent<RectTransform>();
Vector2 diff = to - from;
rt.sizeDelta = new Vector2(diff.magnitude, 4f);
rt.pivot = new Vector2(0f, 0.5f);
rt.anchoredPosition = from;
float angle = Mathf.Atan2(diff.y, diff.x) * Mathf.Rad2Deg;
rt.localRotation = Quaternion.Euler(0f, 0f, angle);
spawnedElements.Add(lineObj);
}
private void CreateNodeCard(StageNode node, Vector2 anchoredPos)
{
GameObject nodeObj = new GameObject($"NodeCard_{node.Id}");
nodeObj.transform.SetParent(mapContainer, false);
RectTransform rt = nodeObj.AddComponent<RectTransform>();
rt.sizeDelta = new Vector2(110f, 65f);
rt.anchoredPosition = anchoredPos;
Image bg = nodeObj.AddComponent<Image>();
bg.color = GetNodeColor(node);
// Node Text: Name / Type
GameObject textObj = new GameObject("Label");
textObj.transform.SetParent(nodeObj.transform, false);
Text label = textObj.AddComponent<Text>();
label.font = Resources.GetBuiltinResource<Font>("LegacyRuntime.ttf");
label.fontSize = 12;
label.fontStyle = FontStyle.Bold;
label.alignment = TextAnchor.MiddleCenter;
label.color = Color.white;
label.text = GetNodeDisplayText(node);
RectTransform textRt = textObj.GetComponent<RectTransform>();
textRt.anchorMin = Vector2.zero;
textRt.anchorMax = Vector2.one;
textRt.offsetMin = new Vector2(4f, 4f);
textRt.offsetMax = new Vector2(-4f, -4f);
// Current Player Indicator Border
if (node.IsCurrent)
{
GameObject borderObj = new GameObject("CurrentBorder");
borderObj.transform.SetParent(nodeObj.transform, false);
Image borderImg = borderObj.AddComponent<Image>();
borderImg.color = new Color(1f, 0.85f, 0.2f, 0.9f);
RectTransform borderRt = borderObj.GetComponent<RectTransform>();
borderRt.anchorMin = Vector2.zero;
borderRt.anchorMax = Vector2.one;
borderRt.offsetMin = new Vector2(-4f, -4f);
borderRt.offsetMax = new Vector2(4f, 4f);
borderObj.transform.SetAsFirstSibling();
}
spawnedElements.Add(nodeObj);
}
private static Color GetNodeColor(StageNode node)
{
if (node.IsCurrent)
{
return new Color(0.2f, 0.5f, 0.8f, 0.95f);
}
return node.NodeType switch
{
StageNodeType.Entrance => new Color(0.25f, 0.45f, 0.35f, 0.9f),
StageNodeType.Combat => new Color(0.35f, 0.25f, 0.25f, 0.9f),
StageNodeType.Fork => new Color(0.3f, 0.3f, 0.45f, 0.9f),
StageNodeType.Reward => new Color(0.5f, 0.4f, 0.15f, 0.9f),
StageNodeType.Recovery => new Color(0.2f, 0.45f, 0.25f, 0.9f),
StageNodeType.Elite => new Color(0.6f, 0.25f, 0.15f, 0.95f),
StageNodeType.MidBoss => new Color(0.65f, 0.2f, 0.2f, 0.95f),
StageNodeType.FinalBoss => new Color(0.6f, 0.15f, 0.5f, 0.95f),
StageNodeType.StageExit => new Color(0.2f, 0.55f, 0.45f, 0.9f),
_ => new Color(0.3f, 0.3f, 0.35f, 0.9f),
};
}
private static string GetNodeDisplayText(StageNode node)
{
string currentTag = node.IsCurrent ? "[YOU]\n" : "";
string typeName = node.NodeType switch
{
StageNodeType.Entrance => "ENTRANCE",
StageNodeType.Combat => "COMBAT",
StageNodeType.Fork => "FORK",
StageNodeType.Reward => $"REWARD\n<{node.RewardType}>",
StageNodeType.Recovery => "RECOVERY\n<HEAL 50%>",
StageNodeType.Elite => "★ ELITE ★",
StageNodeType.MidBoss => "★ MID BOSS ★",
StageNodeType.FinalBoss => "☠ NECROMANCER ☠",
StageNodeType.StageExit => "STAGE EXIT",
_ => node.NodeType.ToString().ToUpper(),
};
return currentTag + typeName;
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 3ac4129f6b1ac4a4789bc13d9ad8a435

Some files were not shown because too many files have changed in this diff Show More