convert game playing..
This commit is contained in:
@@ -45,7 +45,7 @@ TextureImporter:
|
||||
compressionQuality: 50
|
||||
spriteMode: 1
|
||||
spriteExtrude: 1
|
||||
spriteMeshType: 1
|
||||
spriteMeshType: 0
|
||||
alignment: 0
|
||||
spritePivot: {x: 0.5, y: 0.5}
|
||||
spritePixelsToUnits: 32
|
||||
|
||||
@@ -12,6 +12,7 @@ MonoBehaviour:
|
||||
m_Script: {fileID: 11500000, guid: db153e77cef645c49a74705c9d542ea5, type: 3}
|
||||
m_Name: CombatConstants
|
||||
m_EditorClassIdentifier: BumpCombat.Runtime::BumpCombat.Constants.CombatConstants
|
||||
enableFrontalBumpSelfDamage: 0
|
||||
FrontThreshold: 0.707
|
||||
BackThreshold: -0.5
|
||||
PositionDamageMultiplier: 1.2
|
||||
|
||||
@@ -27,6 +27,9 @@ MonoBehaviour:
|
||||
CameraShakeDuration: 0.12
|
||||
CameraShakePixels: 3
|
||||
HeavyCameraShakePixels: 5
|
||||
RearHitCameraPunchDuration: 0.07
|
||||
RearHitCameraPunchPixels: 1
|
||||
RearHitCameraPunchRearmDelay: 0.45
|
||||
HeavyKnockbackThreshold: 1
|
||||
BlockedBumpHoldDuration: 0.08
|
||||
BlockedBumpFadeDuration: 0.04
|
||||
|
||||
@@ -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
|
||||
attackShape: 1
|
||||
maxHealth: 42
|
||||
moveSpeed: 1.1
|
||||
moveSpeed: 1.375
|
||||
attackDamage: 18
|
||||
warningDuration: 0.85
|
||||
activeDuration: 0.25
|
||||
|
||||
@@ -13,10 +13,10 @@ MonoBehaviour:
|
||||
m_Name: Bat
|
||||
m_EditorClassIdentifier: BumpCombat.Runtime::BumpCombat.Enemies.EnemyDefinition
|
||||
kind: 5
|
||||
role: 1
|
||||
role: 0
|
||||
attackShape: 0
|
||||
maxHealth: 8
|
||||
moveSpeed: 1.5
|
||||
moveSpeed: 1.875
|
||||
attackDamage: 8
|
||||
warningDuration: 0.35
|
||||
activeDuration: 0.25
|
||||
@@ -27,7 +27,7 @@ MonoBehaviour:
|
||||
attackWidth: 0
|
||||
attackRadius: 0.45
|
||||
attackAnimationLeadTime: 0
|
||||
experienceValue: 1
|
||||
experienceValue: 2
|
||||
attackPatterns:
|
||||
- animationState: Attack
|
||||
attackShape: 0
|
||||
@@ -42,3 +42,4 @@ MonoBehaviour:
|
||||
damageMultiplier: 1
|
||||
separationRadius: 0.55
|
||||
separationStrength: 0.6
|
||||
isFlying: 1
|
||||
|
||||
@@ -16,7 +16,7 @@ MonoBehaviour:
|
||||
role: 0
|
||||
attackShape: 2
|
||||
maxHealth: 50
|
||||
moveSpeed: 0.9
|
||||
moveSpeed: 1.125
|
||||
attackDamage: 18
|
||||
warningDuration: 1.2
|
||||
activeDuration: 0.22
|
||||
|
||||
@@ -16,7 +16,7 @@ MonoBehaviour:
|
||||
role: 0
|
||||
attackShape: 1
|
||||
maxHealth: 40
|
||||
moveSpeed: 1.15
|
||||
moveSpeed: 1.4375
|
||||
attackDamage: 15
|
||||
warningDuration: 0.9
|
||||
activeDuration: 0.18
|
||||
|
||||
@@ -16,7 +16,7 @@ MonoBehaviour:
|
||||
role: 0
|
||||
attackShape: 1
|
||||
maxHealth: 60
|
||||
moveSpeed: 0.65
|
||||
moveSpeed: 0.8125
|
||||
attackDamage: 30
|
||||
warningDuration: 1.05
|
||||
activeDuration: 0.25
|
||||
|
||||
@@ -16,7 +16,7 @@ MonoBehaviour:
|
||||
role: 0
|
||||
attackShape: 1
|
||||
maxHealth: 12
|
||||
moveSpeed: 0.9
|
||||
moveSpeed: 1.125
|
||||
attackDamage: 10
|
||||
warningDuration: 1.5
|
||||
activeDuration: 0.375
|
||||
@@ -44,3 +44,4 @@ MonoBehaviour:
|
||||
damageMultiplier: 1
|
||||
separationRadius: 0.55
|
||||
separationStrength: 0.6
|
||||
isFlying: 1
|
||||
|
||||
@@ -16,7 +16,7 @@ MonoBehaviour:
|
||||
role: 0
|
||||
attackShape: 2
|
||||
maxHealth: 60
|
||||
moveSpeed: 0.55
|
||||
moveSpeed: 0.6875
|
||||
attackDamage: 30
|
||||
warningDuration: 1.1
|
||||
activeDuration: 0.375
|
||||
|
||||
@@ -15,8 +15,8 @@ MonoBehaviour:
|
||||
kind: 1
|
||||
role: 0
|
||||
attackShape: 1
|
||||
maxHealth: 10
|
||||
moveSpeed: 1.2
|
||||
maxHealth: 24
|
||||
moveSpeed: 1.5
|
||||
attackDamage: 12
|
||||
warningDuration: 0.7
|
||||
activeDuration: 0.15
|
||||
@@ -26,7 +26,7 @@ MonoBehaviour:
|
||||
attackLength: 0.8
|
||||
attackWidth: 0.6
|
||||
attackRadius: 0
|
||||
experienceValue: 2
|
||||
experienceValue: 3
|
||||
attackPatterns:
|
||||
- animationState: Attack
|
||||
attackShape: 1
|
||||
|
||||
@@ -16,7 +16,7 @@ MonoBehaviour:
|
||||
role: 0
|
||||
attackShape: 1
|
||||
maxHealth: 14
|
||||
moveSpeed: 0.9
|
||||
moveSpeed: 1.125
|
||||
attackDamage: 12
|
||||
warningDuration: 1
|
||||
activeDuration: 0.15
|
||||
|
||||
@@ -13,10 +13,10 @@ MonoBehaviour:
|
||||
m_Name: Slime
|
||||
m_EditorClassIdentifier: BumpCombat.Runtime::BumpCombat.Enemies.EnemyDefinition
|
||||
kind: 0
|
||||
role: 1
|
||||
role: 0
|
||||
attackShape: 0
|
||||
maxHealth: 10
|
||||
moveSpeed: 1.3
|
||||
moveSpeed: 1.625
|
||||
attackDamage: 10
|
||||
warningDuration: 0.35
|
||||
activeDuration: 0.25
|
||||
@@ -26,6 +26,6 @@ MonoBehaviour:
|
||||
attackLength: 0
|
||||
attackWidth: 0
|
||||
attackRadius: 0.45
|
||||
experienceValue: 1
|
||||
experienceValue: 2
|
||||
separationRadius: 0.55
|
||||
separationStrength: 0.6
|
||||
|
||||
@@ -16,7 +16,7 @@ MonoBehaviour:
|
||||
role: 0
|
||||
attackShape: 3
|
||||
maxHealth: 30
|
||||
moveSpeed: 1
|
||||
moveSpeed: 1.25
|
||||
attackDamage: 20
|
||||
warningDuration: 0.8
|
||||
activeDuration: 0.15
|
||||
|
||||
@@ -16,7 +16,7 @@ MonoBehaviour:
|
||||
role: 0
|
||||
attackShape: 2
|
||||
maxHealth: 55
|
||||
moveSpeed: 0.8
|
||||
moveSpeed: 1
|
||||
attackDamage: 24
|
||||
warningDuration: 0.9
|
||||
activeDuration: 0.25
|
||||
|
||||
@@ -16,7 +16,7 @@ MonoBehaviour:
|
||||
role: 0
|
||||
attackShape: 2
|
||||
maxHealth: 38
|
||||
moveSpeed: 1.45
|
||||
moveSpeed: 1.8125
|
||||
attackDamage: 20
|
||||
warningDuration: 0.8
|
||||
activeDuration: 0.25
|
||||
|
||||
@@ -12,7 +12,7 @@ MonoBehaviour:
|
||||
m_Script: {fileID: 11500000, guid: 2643d1587a2745d0b584eadd6b34978b, type: 3}
|
||||
m_Name: PlayerConstants
|
||||
m_EditorClassIdentifier: BumpCombat.Runtime::BumpCombat.Constants.PlayerConstants
|
||||
BaseMoveSpeed: 3
|
||||
BaseMoveSpeed: 3.75
|
||||
BaseMaxHealth: 100
|
||||
InvulnerabilityDuration: 1
|
||||
KnockbackDuration: 0.14
|
||||
|
||||
@@ -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}
|
||||
run: {fileID: 11400000, guid: af4f8662e3d84fbf8df4757fc7bfd22f, type: 2}
|
||||
combatFeedback: {fileID: 11400000, guid: 6ab7e48e63b84ea3ac749bd827d1a87f, type: 2}
|
||||
defense: {fileID: 11400000, guid: 1949e4e0aabb41ff8e66ddd2ee493ba3, type: 2}
|
||||
passiveRelics: {fileID: 11400000, guid: 2c5773908a374e96ad132e07d5bcc421, type: 2}
|
||||
|
||||
@@ -147,5 +147,13 @@ MonoBehaviour:
|
||||
ArenaPlayableHalfExtents: {x: 15, y: 8.4375}
|
||||
ArenaPlayerPadding: {x: 0.5, y: 0.625}
|
||||
ArenaCameraViewHalfExtents: {x: 10, y: 5.625}
|
||||
StageCameraViewHalfExtents: {x: 7.5, y: 4.21875}
|
||||
StageComparisonOriginalCameraViewHalfExtents: {x: 5, y: 2.8125}
|
||||
StageComparisonCameraViewHalfExtents: {x: 7.5, y: 4.21875}
|
||||
ArenaCameraDeadZone: {x: 0.5, y: 0.3}
|
||||
ArenaCameraSmoothTime: 0.15
|
||||
Stage1FirstCombatRoomScaleComparison: 0
|
||||
StagePlayerVisibilityRadius: 6
|
||||
StageExploredFogOpacity: 0.65
|
||||
StageUnseenFogOpacity: 1
|
||||
StageEncounterSightRangeMultiplier: 0.5
|
||||
|
||||
@@ -70,7 +70,7 @@ CircleCollider2D:
|
||||
m_GameObject: {fileID: 810786416157290827}
|
||||
m_Enabled: 1
|
||||
serializedVersion: 3
|
||||
m_IsTrigger: 1
|
||||
m_IsTrigger: 0
|
||||
m_Offset: {x: 0, y: -0.28}
|
||||
m_Radius: 0.22
|
||||
--- !u!114 &3111862855908660951
|
||||
|
||||
@@ -190,7 +190,7 @@ CircleCollider2D:
|
||||
m_CallbackLayers:
|
||||
serializedVersion: 2
|
||||
m_Bits: 4294967295
|
||||
m_IsTrigger: 1
|
||||
m_IsTrigger: 0
|
||||
m_UsedByEffector: 0
|
||||
m_CompositeOperation: 0
|
||||
m_CompositeOrder: 0
|
||||
|
||||
@@ -200,7 +200,7 @@ CircleCollider2D:
|
||||
m_UsedByEffector: 0
|
||||
m_CompositeOperation: 0
|
||||
m_CompositeOrder: 0
|
||||
m_Offset: {x: 0, y: -0.28}
|
||||
m_Offset: {x: 0, y: -0.06}
|
||||
m_Radius: 0.22
|
||||
--- !u!114 &6788187778577634304
|
||||
MonoBehaviour:
|
||||
|
||||
@@ -9,6 +9,7 @@ namespace BumpCombat.Combat
|
||||
public sealed class ArtifactStatusVisual : MonoBehaviour
|
||||
{
|
||||
public const int StatusSortingOffset = 1;
|
||||
public const float StatusVisualAnchorOffsetY = -13f / 32f;
|
||||
|
||||
private const int FrameCount = 6;
|
||||
private const float PixelsPerUnit = 32f;
|
||||
@@ -213,14 +214,23 @@ namespace BumpCombat.Combat
|
||||
return;
|
||||
}
|
||||
|
||||
Vector2 groundAnchor = enemy.GroundAnchorPosition;
|
||||
slot.Root.transform.SetPositionAndRotation(
|
||||
new Vector3(groundAnchor.x, groundAnchor.y, transform.position.z),
|
||||
Quaternion.identity);
|
||||
Transform visualTransform = enemy.CurrentVisualTransform;
|
||||
if (slot.Root.transform.parent != visualTransform)
|
||||
{
|
||||
slot.Root.transform.SetParent(visualTransform, false);
|
||||
}
|
||||
|
||||
float anchorOffsetY = sourceRenderer.flipY
|
||||
? -StatusVisualAnchorOffsetY
|
||||
: StatusVisualAnchorOffsetY;
|
||||
slot.Root.transform.localPosition =
|
||||
new Vector3(0f, anchorOffsetY, 0f);
|
||||
slot.Root.transform.localRotation = Quaternion.identity;
|
||||
slot.Root.transform.localScale = Vector3.one;
|
||||
slot.Renderer.sortingLayerID = sourceRenderer.sortingLayerID;
|
||||
slot.Renderer.sortingOrder =
|
||||
sourceRenderer.sortingOrder + StatusSortingOffset;
|
||||
enemy.CurrentVisualSortingOrder + StatusSortingOffset;
|
||||
slot.Renderer.forceRenderingOff = sourceRenderer.forceRenderingOff;
|
||||
slot.Renderer.flipX = sourceRenderer.flipX;
|
||||
slot.Renderer.flipY = sourceRenderer.flipY;
|
||||
slot.Renderer.sharedMaterial = sourceRenderer.sharedMaterial;
|
||||
|
||||
@@ -3,6 +3,7 @@ using BumpCombat.Core;
|
||||
using BumpCombat.Constants;
|
||||
using BumpCombat.Enemies;
|
||||
using BumpCombat.Player;
|
||||
using BumpCombat.Progression;
|
||||
using UnityEngine;
|
||||
|
||||
namespace BumpCombat.Combat
|
||||
@@ -12,6 +13,7 @@ namespace BumpCombat.Combat
|
||||
public sealed class BumpCombatResolver : MonoBehaviour
|
||||
{
|
||||
private float baseDamage => GameplayConstants.Current.Combat.BaseDamage;
|
||||
private bool enableFrontalBumpSelfDamage => GameplayConstants.Current.Combat.EnableFrontalBumpSelfDamage;
|
||||
private float dashDamage => GameplayConstants.Current.Combat.DashDamage;
|
||||
private float strongDashDamage => GameplayConstants.Current.Combat.StrongDashDamage;
|
||||
private float minimumAttackSpeed => GameplayConstants.Current.Combat.MinimumAttackSpeed;
|
||||
@@ -35,6 +37,7 @@ namespace BumpCombat.Combat
|
||||
private readonly Dictionary<int, float> nextNormalHitTimes = new();
|
||||
|
||||
private PlayerStats playerStats;
|
||||
private PlayerHealth playerHealth;
|
||||
private PlayerController playerController;
|
||||
private DashController dashController;
|
||||
private ActiveArtifactController activeArtifactController;
|
||||
@@ -43,6 +46,7 @@ namespace BumpCombat.Combat
|
||||
private void Awake()
|
||||
{
|
||||
playerStats = GetComponent<PlayerStats>();
|
||||
playerHealth = GetComponent<PlayerHealth>();
|
||||
playerController = GetComponent<PlayerController>();
|
||||
dashController = GetComponent<DashController>();
|
||||
activeArtifactController = GetComponent<ActiveArtifactController>();
|
||||
@@ -335,6 +339,10 @@ namespace BumpCombat.Combat
|
||||
playerStats.CalculateDamage(rawDamage, damageTag),
|
||||
side,
|
||||
isOffsetHit);
|
||||
finalDamage = PassiveRelicController.Instance?.ApplyOrdinaryBumpDamageMultiplier(
|
||||
finalDamage,
|
||||
!isDash && string.IsNullOrEmpty(sourceId),
|
||||
artifactColor.HasValue) ?? finalDamage;
|
||||
Vector2 knockbackDirection = -enemyToPlayer;
|
||||
bool suppressOrdinaryEventReaction = !isDash && isEventEnemy;
|
||||
float appliedKnockback = knockbackOverride
|
||||
@@ -361,7 +369,8 @@ namespace BumpCombat.Combat
|
||||
appliedKnockback,
|
||||
artifactColor.Value,
|
||||
castIdentity,
|
||||
!suppressOrdinaryEventReaction);
|
||||
!suppressOrdinaryEventReaction,
|
||||
EnemyDamageSource.Player);
|
||||
acceptedHit = artifactHitResult
|
||||
!= EnemyArtifactHitResult.Rejected;
|
||||
}
|
||||
@@ -372,7 +381,8 @@ namespace BumpCombat.Combat
|
||||
knockbackDirection,
|
||||
appliedKnockback,
|
||||
!suppressOrdinaryEventReaction,
|
||||
!isDash);
|
||||
!isDash,
|
||||
EnemyDamageSource.Player);
|
||||
}
|
||||
|
||||
if (!acceptedHit)
|
||||
@@ -477,14 +487,22 @@ namespace BumpCombat.Combat
|
||||
|
||||
if (!isDash && !protectionBegan)
|
||||
{
|
||||
if (!enemy.IsCrowd
|
||||
if (enableFrontalBumpSelfDamage
|
||||
&& !artifactColor.HasValue
|
||||
&& side == HitSide.Front)
|
||||
{
|
||||
playerHealth?.TryTakeDamage(
|
||||
enemy.ContactDamage,
|
||||
enemyToPlayer,
|
||||
enemy.PlayerKnockbackDistance);
|
||||
}
|
||||
else if (side != HitSide.Front
|
||||
&& !enemy.IsCrowd
|
||||
&& BumpCombatMath.AppliesPlayerImpactRecoil(side, isEventEnemy))
|
||||
{
|
||||
playerController.ApplyImpactRecoil(
|
||||
enemyToPlayer,
|
||||
side == HitSide.Front
|
||||
? frontPlayerImpactRecoilDistance
|
||||
: side == HitSide.Back && isEventEnemy
|
||||
side == HitSide.Back && isEventEnemy
|
||||
? eventBackPlayerImpactRecoilDistance
|
||||
: playerImpactRecoilDistance);
|
||||
}
|
||||
|
||||
@@ -27,6 +27,9 @@ namespace BumpCombat.Combat
|
||||
private float cameraShakeDuration => GameplayConstants.Current.CombatFeedback.CameraShakeDuration;
|
||||
private float cameraShakePixels => GameplayConstants.Current.CombatFeedback.CameraShakePixels;
|
||||
private float heavyCameraShakePixels => GameplayConstants.Current.CombatFeedback.HeavyCameraShakePixels;
|
||||
private float rearHitCameraPunchDuration => GameplayConstants.Current.CombatFeedback.RearHitCameraPunchDuration;
|
||||
private float rearHitCameraPunchPixels => GameplayConstants.Current.CombatFeedback.RearHitCameraPunchPixels;
|
||||
private float rearHitCameraPunchRearmDelay => GameplayConstants.Current.CombatFeedback.RearHitCameraPunchRearmDelay;
|
||||
private float heavyKnockbackThreshold => GameplayConstants.Current.CombatFeedback.HeavyKnockbackThreshold;
|
||||
[SerializeField, Min(1f)] private float assetsPixelsPerUnit = 32f;
|
||||
|
||||
@@ -96,6 +99,7 @@ namespace BumpCombat.Combat
|
||||
private float flashUntil;
|
||||
private float blinkUntil;
|
||||
private Vector3 cameraRestPosition;
|
||||
private float lastRearHitCameraPunchAt = float.NegativeInfinity;
|
||||
private Material effectMaterial;
|
||||
private Vector3 lastCycloneSamplePosition;
|
||||
private Vector2 lastDashAfterimagePosition;
|
||||
@@ -309,6 +313,7 @@ namespace BumpCombat.Combat
|
||||
private void OnEnable()
|
||||
{
|
||||
activeInstance = this;
|
||||
lastRearHitCameraPunchAt = float.NegativeInfinity;
|
||||
CombatEvents.OnValidHit += HandleValidHit;
|
||||
CombatEvents.OnStunApplied += HandleStunApplied;
|
||||
CombatEvents.OnAttackCancelled += HandleAttackCancelled;
|
||||
@@ -1354,6 +1359,19 @@ namespace BumpCombat.Combat
|
||||
return;
|
||||
}
|
||||
|
||||
if (result.IsOrdinaryBump && result.HasBackBonus)
|
||||
{
|
||||
float now = Time.time;
|
||||
bool rearHitCameraPunchRearmed =
|
||||
now - lastRearHitCameraPunchAt >= rearHitCameraPunchRearmDelay;
|
||||
lastRearHitCameraPunchAt = now;
|
||||
|
||||
if (rearHitCameraPunchRearmed)
|
||||
{
|
||||
RequestRearHitCameraPunch(result.KnockbackDirection);
|
||||
}
|
||||
}
|
||||
|
||||
Color color;
|
||||
float strength;
|
||||
int rayCount;
|
||||
@@ -2203,6 +2221,63 @@ namespace BumpCombat.Combat
|
||||
playerVisualCoroutine = null;
|
||||
}
|
||||
|
||||
private void RequestRearHitCameraPunch(Vector2 direction)
|
||||
{
|
||||
if (worldCamera == null ||
|
||||
rearHitCameraPunchDuration <= 0f ||
|
||||
rearHitCameraPunchPixels <= 0f ||
|
||||
rearHitCameraPunchRearmDelay < 0f ||
|
||||
assetsPixelsPerUnit <= 0f ||
|
||||
direction.sqrMagnitude <= Mathf.Epsilon ||
|
||||
cameraShakeCoroutine != null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
cameraRestPosition = worldCamera.transform.position;
|
||||
cameraShakeCoroutine = StartCoroutine(
|
||||
RearHitCameraPunchRoutine(direction.normalized));
|
||||
}
|
||||
|
||||
private IEnumerator RearHitCameraPunchRoutine(Vector2 normalizedDirection)
|
||||
{
|
||||
float startTime = Time.unscaledTime;
|
||||
|
||||
while (Time.unscaledTime - startTime < rearHitCameraPunchDuration)
|
||||
{
|
||||
float t =
|
||||
(Time.unscaledTime - startTime) / rearHitCameraPunchDuration;
|
||||
Vector2 pixelOffset =
|
||||
normalizedDirection * (rearHitCameraPunchPixels * (1f - t));
|
||||
Vector2 offset = new Vector2(
|
||||
Mathf.Round(pixelOffset.x) / assetsPixelsPerUnit,
|
||||
Mathf.Round(pixelOffset.y) / assetsPixelsPerUnit);
|
||||
|
||||
if (cameraFollow != null)
|
||||
{
|
||||
cameraFollow.SetExternalShakeOffset(offset);
|
||||
}
|
||||
else
|
||||
{
|
||||
worldCamera.transform.position =
|
||||
cameraRestPosition + new Vector3(offset.x, offset.y, 0f);
|
||||
}
|
||||
|
||||
yield return null;
|
||||
}
|
||||
|
||||
if (cameraFollow != null)
|
||||
{
|
||||
cameraFollow.SetExternalShakeOffset(Vector2.zero);
|
||||
}
|
||||
else
|
||||
{
|
||||
worldCamera.transform.position = cameraRestPosition;
|
||||
}
|
||||
|
||||
cameraShakeCoroutine = null;
|
||||
}
|
||||
|
||||
private void RequestCameraShake(float pixels)
|
||||
{
|
||||
if (worldCamera == null || pixels <= 0f || cameraShakeDuration <= 0f)
|
||||
|
||||
@@ -5,6 +5,17 @@ namespace BumpCombat.Constants
|
||||
[CreateAssetMenu(menuName = "BumpCombat/Constants/Combat")]
|
||||
public sealed class CombatConstants : ScriptableObject
|
||||
{
|
||||
[Header("정면 몸통박치기 접촉 피해")]
|
||||
[SerializeField, Tooltip("OFF: 정면 몸통박치기 자체 피해 없음. ON: 이전 enemy.ContactDamage를 PlayerHealth.TryTakeDamage로 전달해 일반 무적·가드·피격 넉백을 적용하며 EnemyAttack는 영향 없음.")]
|
||||
private bool enableFrontalBumpSelfDamage;
|
||||
|
||||
public bool EnableFrontalBumpSelfDamage
|
||||
{
|
||||
get => enableFrontalBumpSelfDamage;
|
||||
set => enableFrontalBumpSelfDamage = value;
|
||||
}
|
||||
|
||||
|
||||
[Header("몸통박치기 방향 판정")]
|
||||
[Tooltip("적 정면으로 분류하는 방향 내적 기준"), Range(-1f, 1f)] public float FrontThreshold = 0.707f;
|
||||
[Tooltip("적 후방으로 분류하는 방향 내적 기준"), Range(-1f, 1f)] public float BackThreshold = -0.5f;
|
||||
|
||||
@@ -25,6 +25,9 @@ namespace BumpCombat.Constants
|
||||
[Tooltip("흔들림 지속시간 (초)"), Min(0f)] public float CameraShakeDuration = 0.12f;
|
||||
[Tooltip("일반 흔들림 크기 (픽셀)"), Min(0f)] public float CameraShakePixels = 3f;
|
||||
[Tooltip("강한 흔들림 크기 (픽셀)"), Min(0f)] public float HeavyCameraShakePixels = 5f;
|
||||
public float RearHitCameraPunchDuration = 0.07f;
|
||||
public float RearHitCameraPunchPixels = 1f;
|
||||
public float RearHitCameraPunchRearmDelay = 0.45f;
|
||||
[Tooltip("강한 피격으로 표시하는 넉백 거리 (유닛)"), Min(0f)] public float HeavyKnockbackThreshold = 1f;
|
||||
|
||||
[Header("무적 적 몸통박치기 표시")]
|
||||
|
||||
@@ -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 CrowdApproachRingSpacing = 0.15f;
|
||||
[Min(0f)] public float SummonedEnemyNoAnimationLockDuration = 0.15f;
|
||||
[Tooltip("시야 장애물 차폐를 무시하고 무조건 감지하는 초근접 반경 (유닛)"), Min(0f)] public float ProximityAlertRange = 2.0f;
|
||||
[Tooltip("적의 기본 시야 사거리 (유닛)"), Min(0f)] public float DefaultSightRange = 8.0f;
|
||||
[Tooltip("일반 적이 시야를 잃은 뒤 마지막 위치를 기억하는 시간 (초)"), Min(0f)] public float LostSightMemoryDuration = 1.5f;
|
||||
[Tooltip("비탐지 일반 적의 로밍 반경 최솟값 (유닛)"), Min(0f)] public float RoamRadiusMin = 2.5f;
|
||||
[Tooltip("비탐지 일반 적의 로밍 반경 최댓값 (유닛)"), Min(0f)] public float RoamRadiusMax = 4.0f;
|
||||
[Tooltip("로밍 지점 도착 후 최소 대기시간 (초)"), Min(0f)] public float RoamPauseMin = 0.4f;
|
||||
[Tooltip("로밍 지점 도착 후 최대 대기시간 (초)"), Min(0f)] public float RoamPauseMax = 1.0f;
|
||||
[Tooltip("EncounterGroup 내부 적의 최소 초기 간격 (유닛)"), Min(0f)] public float EncounterGroupMinSpacing = 1.5f;
|
||||
[Tooltip("EncounterGroup 내부 적의 최대 초기 간격 (유닛)"), Min(0f)] public float EncounterGroupMaxSpacing = 2.5f;
|
||||
[Tooltip("장애물 회피(Wall Slide) 원형 캐스트 반경"), Min(0.05f)] public float WallSlideCastRadius = 0.35f;
|
||||
[Tooltip("장애물 회피(Wall Slide) 원형 캐스트 거리"), Min(0.05f)] public float WallSlideCastDistance = 0.4f;
|
||||
|
||||
[Header("적과 플레이어 사이 거리")]
|
||||
[Tooltip("일반 적이 주는 플레이어 넉백 거리 (유닛)"), Min(0f)] public float NormalPlayerKnockbackDistance = 0.75f;
|
||||
|
||||
@@ -15,6 +15,8 @@ namespace BumpCombat.Constants
|
||||
private static ArtifactConstants fallbackArtifacts;
|
||||
private static RunConstants fallbackRun;
|
||||
private static CombatFeedbackSettings fallbackCombatFeedback;
|
||||
private static DefenseConstants fallbackDefense;
|
||||
private static PassiveRelicConstants fallbackPassiveRelics;
|
||||
|
||||
[Header("설정 분류")]
|
||||
[SerializeField, Tooltip("플레이어 기본 능력치, 피격, 가드 설정")]
|
||||
@@ -33,6 +35,10 @@ namespace BumpCombat.Constants
|
||||
private RunConstants run;
|
||||
[SerializeField, Tooltip("타격, 피격, 가드 효과의 표시 설정")]
|
||||
private CombatFeedbackSettings combatFeedback;
|
||||
[SerializeField, Tooltip("역병형 방어전 테스트 모드 설정")]
|
||||
private DefenseConstants defense;
|
||||
[SerializeField, Tooltip("방어전 패시브 유물 효과 설정")]
|
||||
private PassiveRelicConstants passiveRelics;
|
||||
|
||||
public static GameplayConstants Current
|
||||
{
|
||||
@@ -60,6 +66,8 @@ namespace BumpCombat.Constants
|
||||
public RunConstants Run => Resolve(run, ref fallbackRun);
|
||||
public CombatFeedbackSettings CombatFeedback =>
|
||||
Resolve(combatFeedback, ref fallbackCombatFeedback);
|
||||
public DefenseConstants Defense => Resolve(defense, ref fallbackDefense);
|
||||
public PassiveRelicConstants PassiveRelics => Resolve(passiveRelics, ref fallbackPassiveRelics);
|
||||
|
||||
#if UNITY_EDITOR
|
||||
public static void SetCurrentForTests(GameplayConstants value)
|
||||
@@ -78,6 +86,8 @@ namespace BumpCombat.Constants
|
||||
fallbackArtifacts = null;
|
||||
fallbackRun = null;
|
||||
fallbackCombatFeedback = null;
|
||||
fallbackDefense = null;
|
||||
fallbackPassiveRelics = null;
|
||||
}
|
||||
#endif
|
||||
|
||||
|
||||
@@ -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
|
||||
{
|
||||
[Header("기본 능력치")]
|
||||
[Tooltip("플레이어 기본 이동 속도 (유닛/초)"), Min(0f)] public float BaseMoveSpeed = 3f;
|
||||
[Tooltip("플레이어 기본 이동 속도 (유닛/초)"), Min(0f)] public float BaseMoveSpeed = 3.75f;
|
||||
[Tooltip("플레이어 기본 최대 체력"), Min(1f)] public float BaseMaxHealth = 100f;
|
||||
|
||||
[Header("피격")]
|
||||
|
||||
@@ -5,6 +5,13 @@ using UnityEngine;
|
||||
|
||||
namespace BumpCombat.Constants
|
||||
{
|
||||
public enum RoomScaleComparisonMode
|
||||
{
|
||||
Disabled = 0,
|
||||
Original = 1,
|
||||
Expanded = 2,
|
||||
}
|
||||
|
||||
[Serializable]
|
||||
public struct SpawnDensityPhase
|
||||
{
|
||||
@@ -123,9 +130,25 @@ namespace BumpCombat.Constants
|
||||
public Vector2 ArenaPlayableHalfExtents = new(15f, 8.4375f);
|
||||
public Vector2 ArenaPlayerPadding = new(0.5f, 0.625f);
|
||||
public Vector2 ArenaCameraViewHalfExtents = new(10f, 5.625f);
|
||||
[Tooltip("스테이지 방에서 사용할 플레이어 추적 시야의 반쪽 크기")]
|
||||
public Vector2 StageCameraViewHalfExtents = new(7.5f, 4.21875f);
|
||||
[Tooltip("대표 Stage 1 비교 방에서 Original 선택 시 사용할 좁은 추적 시야의 반쪽 크기")]
|
||||
public Vector2 StageComparisonOriginalCameraViewHalfExtents = new(5f, 2.8125f);
|
||||
[Tooltip("대표 Stage 1 비교 방에서 Expanded 선택 시 사용할 추적 시야의 반쪽 크기")]
|
||||
public Vector2 StageComparisonCameraViewHalfExtents = new(7.5f, 4.21875f);
|
||||
public Vector2 ArenaCameraDeadZone = new(0.5f, 0.3f);
|
||||
[Min(0f)] public float ArenaCameraSmoothTime = 0.15f;
|
||||
|
||||
[Header("대표 Stage 1 방 크기 비교")]
|
||||
[Tooltip("Disabled는 기존 카탈로그 선택을 유지하고, Original/Expanded는 입구 직결 첫 전투 방을 S1-A로 고정합니다.")]
|
||||
public RoomScaleComparisonMode Stage1FirstCombatRoomScaleComparison = RoomScaleComparisonMode.Disabled;
|
||||
|
||||
[Header("제한 시야 교전")]
|
||||
[Min(0.1f)] public float StagePlayerVisibilityRadius = 6f;
|
||||
[Range(0f, 1f)] public float StageExploredFogOpacity = 0.65f;
|
||||
[Range(0f, 1f)] public float StageUnseenFogOpacity = 1f;
|
||||
[Range(0.1f, 1f)] public float StageEncounterSightRangeMultiplier = 0.5f;
|
||||
|
||||
public float[] EliteTimes(bool development, bool production)
|
||||
{
|
||||
float[] times = production
|
||||
|
||||
@@ -12,8 +12,12 @@ namespace BumpCombat.Core
|
||||
{
|
||||
public static ArenaBounds Instance { get; private set; }
|
||||
|
||||
public Vector2 BackgroundHalfExtents => GameplayConstants.Current.Run.ArenaBackgroundHalfExtents;
|
||||
public Vector2 PlayableHalfExtents => GameplayConstants.Current.Run.ArenaPlayableHalfExtents;
|
||||
private Vector2 customCenter = Vector2.zero;
|
||||
private Vector2? customPlayableHalfExtents;
|
||||
|
||||
public Vector2 ZoneCenter => customCenter;
|
||||
public Vector2 BackgroundHalfExtents => customPlayableHalfExtents ?? GameplayConstants.Current.Run.ArenaBackgroundHalfExtents;
|
||||
public Vector2 PlayableHalfExtents => customPlayableHalfExtents ?? GameplayConstants.Current.Run.ArenaPlayableHalfExtents;
|
||||
public Vector2 PlayerPadding => GameplayConstants.Current.Run.ArenaPlayerPadding;
|
||||
public Vector2 CameraViewHalfExtents => GameplayConstants.Current.Run.ArenaCameraViewHalfExtents;
|
||||
public Vector2 PlayerClampHalfExtents => new(
|
||||
@@ -28,6 +32,18 @@ namespace BumpCombat.Core
|
||||
Mathf.Max(0f, BackgroundHalfExtents.x - CameraViewHalfExtents.x),
|
||||
Mathf.Max(0f, BackgroundHalfExtents.y - CameraViewHalfExtents.y));
|
||||
|
||||
public void SetZoneBounds(Vector2 center, Vector2? halfExtents = null)
|
||||
{
|
||||
customCenter = center;
|
||||
customPlayableHalfExtents = halfExtents;
|
||||
}
|
||||
|
||||
public void ResetToDefaultBounds()
|
||||
{
|
||||
customCenter = Vector2.zero;
|
||||
customPlayableHalfExtents = null;
|
||||
}
|
||||
|
||||
private void Awake()
|
||||
{
|
||||
Instance = this;
|
||||
@@ -50,13 +66,13 @@ namespace BumpCombat.Core
|
||||
|
||||
public Vector2 ClampPlayerPosition(Vector2 position)
|
||||
{
|
||||
return ClampToHalfExtents(position, PlayerClampHalfExtents);
|
||||
return ClampToHalfExtents(position, PlayerClampHalfExtents, customCenter);
|
||||
}
|
||||
|
||||
public Vector2 ClampEnemyPosition(Vector2 position, float edgeInset = 0f)
|
||||
{
|
||||
Vector2 extents = GetReachableHalfExtents(edgeInset);
|
||||
return ClampToHalfExtents(position, extents);
|
||||
return ClampToHalfExtents(position, extents, customCenter);
|
||||
}
|
||||
|
||||
public Vector2 ClampEnemyPosition(
|
||||
@@ -73,7 +89,7 @@ namespace BumpCombat.Core
|
||||
// player. The previous collider-aware clamp introduced a second,
|
||||
// scale-dependent boundary that was especially visible on elites
|
||||
// and bosses at the edge of the map.
|
||||
return ClampToHalfExtents(position, GetReachableHalfExtents(edgeInset));
|
||||
return ClampToHalfExtents(position, GetReachableHalfExtents(edgeInset), customCenter);
|
||||
}
|
||||
|
||||
public Vector2 GetReachableHalfExtents(float edgeInset = 0f)
|
||||
@@ -86,13 +102,13 @@ namespace BumpCombat.Core
|
||||
public bool IsInsideReachableArena(Vector2 position, float inset = 0f)
|
||||
{
|
||||
Vector2 extents = GetReachableHalfExtents(inset);
|
||||
return Mathf.Abs(position.x) <= extents.x + 0.0001f
|
||||
&& Mathf.Abs(position.y) <= extents.y + 0.0001f;
|
||||
return Mathf.Abs(position.x - customCenter.x) <= extents.x + 0.0001f
|
||||
&& Mathf.Abs(position.y - customCenter.y) <= extents.y + 0.0001f;
|
||||
}
|
||||
|
||||
public Vector2 ClampCameraCenter(Vector2 position)
|
||||
{
|
||||
return ClampToHalfExtents(position, CameraCenterHalfExtents);
|
||||
return ClampToHalfExtents(position, CameraCenterHalfExtents, customCenter);
|
||||
}
|
||||
|
||||
public Vector2 ClampCameraCenter(
|
||||
@@ -102,7 +118,7 @@ namespace BumpCombat.Core
|
||||
Vector2 centerHalfExtents = new(
|
||||
Mathf.Max(0f, BackgroundHalfExtents.x - Mathf.Abs(viewHalfExtents.x)),
|
||||
Mathf.Max(0f, BackgroundHalfExtents.y - Mathf.Abs(viewHalfExtents.y)));
|
||||
return ClampToHalfExtents(position, centerHalfExtents);
|
||||
return ClampToHalfExtents(position, centerHalfExtents, customCenter);
|
||||
}
|
||||
|
||||
public Vector2 GetCameraVisibleHalfExtents(Camera camera)
|
||||
@@ -143,10 +159,11 @@ namespace BumpCombat.Core
|
||||
|
||||
private static Vector2 ClampToHalfExtents(
|
||||
Vector2 position,
|
||||
Vector2 halfExtents)
|
||||
Vector2 halfExtents,
|
||||
Vector2 center)
|
||||
{
|
||||
position.x = Mathf.Clamp(position.x, -halfExtents.x, halfExtents.x);
|
||||
position.y = Mathf.Clamp(position.y, -halfExtents.y, halfExtents.y);
|
||||
position.x = Mathf.Clamp(position.x, center.x - halfExtents.x, center.x + halfExtents.x);
|
||||
position.y = Mathf.Clamp(position.y, center.y - halfExtents.y, center.y + halfExtents.y);
|
||||
return position;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
using System.Reflection;
|
||||
using BumpCombat.Constants;
|
||||
using BumpCombat.Player;
|
||||
using BumpCombat.Stage;
|
||||
using UnityEngine;
|
||||
using UnityEngine.Rendering;
|
||||
|
||||
@@ -16,11 +18,41 @@ namespace BumpCombat.Core
|
||||
private Vector2 externalShakeOffset;
|
||||
private bool initialized;
|
||||
private RunManager runManager;
|
||||
private Component pixelPerfectCamera;
|
||||
private PropertyInfo pixelPerfectAssetsPpu;
|
||||
private PropertyInfo pixelPerfectReferenceWidth;
|
||||
private PropertyInfo pixelPerfectReferenceHeight;
|
||||
|
||||
private const int PixelPerfectAssetsPpu = 32;
|
||||
|
||||
public Vector2 CurrentCenter => currentCenter;
|
||||
public Vector2 DeadZone => GameplayConstants.Current.Run.ArenaCameraDeadZone;
|
||||
public float SmoothTime => GameplayConstants.Current.Run.ArenaCameraSmoothTime;
|
||||
public Vector2 ExternalShakeOffset => externalShakeOffset;
|
||||
public Camera WorldCamera => worldCamera != null ? worldCamera : (worldCamera = GetComponent<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()
|
||||
{
|
||||
@@ -85,6 +117,23 @@ namespace BumpCombat.Core
|
||||
return;
|
||||
}
|
||||
|
||||
bool stageViewport = Stage.StageManager.Instance != null
|
||||
&& Stage.StageManager.Instance.CurrentZone != null;
|
||||
RoomPreset roomPreset = stageViewport
|
||||
? Stage.StageManager.Instance.CurrentZone.Preset
|
||||
: null;
|
||||
bool originalComparison = stageViewport
|
||||
&& Stage.StageManager.Instance.CurrentZone.IsStage1FirstCombatComparison;
|
||||
ApplyPixelPerfectReference(stageViewport, roomPreset, originalComparison);
|
||||
Vector2 stageView = GetStageView(roomPreset, originalComparison);
|
||||
float configuredOrtho = stageViewport
|
||||
? stageView.y
|
||||
: GameplayConstants.Current.Run.ArenaCameraViewHalfExtents.y;
|
||||
if (configuredOrtho > 0.01f)
|
||||
{
|
||||
camera.orthographicSize = configuredOrtho;
|
||||
}
|
||||
|
||||
int targetWidth = camera.targetTexture != null
|
||||
? camera.targetTexture.width
|
||||
: Screen.width;
|
||||
@@ -95,23 +144,98 @@ namespace BumpCombat.Core
|
||||
int evenHeight = targetHeight & ~1;
|
||||
if (targetWidth < 2
|
||||
|| targetHeight < 2
|
||||
|| (evenWidth == targetWidth && evenHeight == targetHeight))
|
||||
|| evenWidth < 2
|
||||
|| evenHeight < 2)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
Rect desiredPixelRect = new(0f, 0f, evenWidth, evenHeight);
|
||||
Rect currentPixelRect = camera.pixelRect;
|
||||
if (Mathf.Abs(currentPixelRect.x) < 0.01f
|
||||
&& Mathf.Abs(currentPixelRect.y) < 0.01f
|
||||
&& Mathf.Abs(currentPixelRect.width - evenWidth) < 0.01f
|
||||
&& Mathf.Abs(currentPixelRect.height - evenHeight) < 0.01f)
|
||||
bool pixelRectChanged = Mathf.Abs(currentPixelRect.x - desiredPixelRect.x) > 0.01f
|
||||
|| Mathf.Abs(currentPixelRect.y - desiredPixelRect.y) > 0.01f
|
||||
|| Mathf.Abs(currentPixelRect.width - desiredPixelRect.width) > 0.01f
|
||||
|| Mathf.Abs(currentPixelRect.height - desiredPixelRect.height) > 0.01f;
|
||||
if (pixelRectChanged)
|
||||
{
|
||||
camera.pixelRect = desiredPixelRect;
|
||||
}
|
||||
|
||||
camera.ResetAspect();
|
||||
camera.orthographicSize = configuredOrtho * evenHeight / targetHeight;
|
||||
}
|
||||
|
||||
private bool IsStageViewportActive()
|
||||
{
|
||||
return Stage.StageManager.Instance != null
|
||||
&& Stage.StageManager.Instance.CurrentZone != null;
|
||||
}
|
||||
|
||||
private void ApplyPixelPerfectReference(
|
||||
bool stageViewport,
|
||||
RoomPreset roomPreset = null,
|
||||
bool originalComparison = false)
|
||||
{
|
||||
if (WorldCamera == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
camera.pixelRect = new Rect(0f, 0f, evenWidth, evenHeight);
|
||||
camera.ResetAspect();
|
||||
camera.orthographicSize *= evenHeight / (float)targetHeight;
|
||||
ResolvePixelPerfectCamera();
|
||||
if (pixelPerfectCamera == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
Vector2 referenceView = stageViewport
|
||||
? GetStageView(roomPreset, originalComparison)
|
||||
: GameplayConstants.Current.Run.ArenaCameraViewHalfExtents;
|
||||
int referenceWidth = Mathf.Max(
|
||||
1,
|
||||
Mathf.RoundToInt(Mathf.Abs(referenceView.x) * 2f * PixelPerfectAssetsPpu));
|
||||
int referenceHeight = Mathf.Max(
|
||||
1,
|
||||
Mathf.RoundToInt(Mathf.Abs(referenceView.y) * 2f * PixelPerfectAssetsPpu));
|
||||
|
||||
SetPixelPerfectIntProperty(pixelPerfectAssetsPpu, PixelPerfectAssetsPpu);
|
||||
SetPixelPerfectIntProperty(pixelPerfectReferenceWidth, referenceWidth);
|
||||
SetPixelPerfectIntProperty(pixelPerfectReferenceHeight, referenceHeight);
|
||||
}
|
||||
|
||||
private void ResolvePixelPerfectCamera()
|
||||
{
|
||||
if (pixelPerfectCamera != null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
pixelPerfectCamera = WorldCamera.GetComponent(
|
||||
"UnityEngine.Rendering.Universal.PixelPerfectCamera");
|
||||
if (pixelPerfectCamera == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
System.Type componentType = pixelPerfectCamera.GetType();
|
||||
pixelPerfectAssetsPpu = componentType.GetProperty("assetsPPU");
|
||||
pixelPerfectReferenceWidth = componentType.GetProperty("refResolutionX");
|
||||
pixelPerfectReferenceHeight = componentType.GetProperty("refResolutionY");
|
||||
}
|
||||
|
||||
private void SetPixelPerfectIntProperty(PropertyInfo property, int value)
|
||||
{
|
||||
if (property == null || !property.CanWrite)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
object currentValue = property.GetValue(pixelPerfectCamera);
|
||||
if (currentValue is int currentInt && currentInt == value)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
property.SetValue(pixelPerfectCamera, value);
|
||||
}
|
||||
|
||||
private void LateUpdate()
|
||||
@@ -123,6 +247,12 @@ namespace BumpCombat.Core
|
||||
|
||||
ResolveBounds();
|
||||
ResolveTarget();
|
||||
if (Stage.StageManager.Instance != null && Stage.StageManager.Instance.IsTransitioning)
|
||||
{
|
||||
ApplyCameraPosition();
|
||||
return;
|
||||
}
|
||||
|
||||
if (RunManager.GameplayInputEnabled && target != null)
|
||||
{
|
||||
Vector2 desiredCenter = GetDeadZoneTargetCenter(
|
||||
@@ -244,6 +374,39 @@ namespace BumpCombat.Core
|
||||
}
|
||||
}
|
||||
|
||||
private RoomPreset GetCurrentStagePreset()
|
||||
{
|
||||
return Stage.StageManager.Instance != null
|
||||
&& Stage.StageManager.Instance.CurrentZone != null
|
||||
? Stage.StageManager.Instance.CurrentZone.Preset
|
||||
: null;
|
||||
}
|
||||
|
||||
private bool IsOriginalComparisonCurrentZone()
|
||||
{
|
||||
return Stage.StageManager.Instance != null
|
||||
&& Stage.StageManager.Instance.CurrentZone != null
|
||||
&& Stage.StageManager.Instance.CurrentZone.IsStage1FirstCombatComparison
|
||||
&& GameplayConstants.Current.Run.Stage1FirstCombatRoomScaleComparison
|
||||
== RoomScaleComparisonMode.Original;
|
||||
}
|
||||
|
||||
private static Vector2 GetStageView(RoomPreset roomPreset, bool originalComparison)
|
||||
{
|
||||
RunConstants run = GameplayConstants.Current.Run;
|
||||
if (LevelPresetCatalog.IsExpandedComparisonPreset(roomPreset))
|
||||
{
|
||||
return run.StageComparisonCameraViewHalfExtents;
|
||||
}
|
||||
|
||||
if (originalComparison)
|
||||
{
|
||||
return run.StageComparisonOriginalCameraViewHalfExtents;
|
||||
}
|
||||
|
||||
return run.StageCameraViewHalfExtents;
|
||||
}
|
||||
|
||||
private void ApplyCameraPosition()
|
||||
{
|
||||
Vector2 finalCenter = currentCenter + externalShakeOffset;
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using System;
|
||||
using System.Collections;
|
||||
using BumpCombat.Constants;
|
||||
using UnityEngine;
|
||||
using UnityEngine.InputSystem;
|
||||
@@ -17,6 +18,7 @@ namespace BumpCombat.Core
|
||||
{
|
||||
Development,
|
||||
Production,
|
||||
DefensePrototype,
|
||||
}
|
||||
|
||||
public sealed class RunManager : MonoBehaviour
|
||||
@@ -28,6 +30,9 @@ namespace BumpCombat.Core
|
||||
private bool guardUnlocked = true;
|
||||
private bool artifactChargeUnlocked = true;
|
||||
private bool gameOver;
|
||||
private bool gameOverPresentation;
|
||||
private bool runCleared;
|
||||
private int currentSeed = -1;
|
||||
private bool titleOpen;
|
||||
private bool pauseOpen;
|
||||
private bool selectionOpen;
|
||||
@@ -42,6 +47,8 @@ namespace BumpCombat.Core
|
||||
public static RunManager Instance { get; private set; }
|
||||
public static bool GameplayInputEnabled =>
|
||||
Instance == null || (!Instance.gameOver
|
||||
&& !Instance.gameOverPresentation
|
||||
&& !Instance.runCleared
|
||||
&& !Instance.titleOpen
|
||||
&& !Instance.pauseOpen
|
||||
&& !Instance.selectionOpen);
|
||||
@@ -55,11 +62,16 @@ namespace BumpCombat.Core
|
||||
public event Action<bool> OnArtifactChargeAvailabilityChanged;
|
||||
public event Action<bool> OnPauseChanged;
|
||||
public event Action OnGameOver;
|
||||
public event Action OnRunCleared;
|
||||
|
||||
public float ElapsedTime { get; private set; }
|
||||
public float ProgressionTime =>
|
||||
GetProgressionTime(ElapsedTime, UseDebugEventTimes);
|
||||
public bool IsGameOver => gameOver;
|
||||
public bool IsGameOverPresentationActive => gameOverPresentation;
|
||||
public bool IsRunCleared => runCleared;
|
||||
public int CurrentSeed => currentSeed > 0 ? currentSeed : 12345;
|
||||
public void SetRunSeed(int seed) => currentSeed = seed;
|
||||
public bool IsTitleScreen => titleOpen;
|
||||
public bool IsPaused => pauseOpen;
|
||||
public bool IsSelectionOpen => selectionOpen;
|
||||
@@ -70,13 +82,15 @@ namespace BumpCombat.Core
|
||||
&& selectedRunMode == RunMode.Production;
|
||||
public bool IsDevelopmentRun => hasSelectedRunMode
|
||||
&& selectedRunMode == RunMode.Development;
|
||||
public bool IsDefensePrototypeRun => hasSelectedRunMode
|
||||
&& selectedRunMode == RunMode.DefensePrototype;
|
||||
public bool IsGuardUnlocked => !IsProductionRun || guardUnlocked;
|
||||
public bool IsArtifactChargeUnlocked => !IsProductionRun
|
||||
|| artifactChargeUnlocked;
|
||||
public int StartupArtifactSelectionCount => hasSelectedRunMode
|
||||
? selectedRunMode == RunMode.Development
|
||||
? GameplayConstants.Current.Run.DevelopmentStartupArtifactSelectionCount
|
||||
: GameplayConstants.Current.Run.ProductionStartupArtifactSelectionCount
|
||||
? selectedRunMode == RunMode.Production
|
||||
? GameplayConstants.Current.Run.ProductionStartupArtifactSelectionCount
|
||||
: GameplayConstants.Current.Run.DevelopmentStartupArtifactSelectionCount
|
||||
: GameplayConstants.Current.Run.LegacyStartupArtifactSelectionCount;
|
||||
public int RaisedEliteEventCount => nextEliteEventIndex;
|
||||
public bool UseDebugArtifactSelection
|
||||
@@ -131,6 +145,7 @@ namespace BumpCombat.Core
|
||||
if (FindAnyObjectByType<BumpCombat.Progression.ArtifactRewardController>() != null)
|
||||
{
|
||||
EnsurePresentationServices();
|
||||
EnsureDefensePrototypeController();
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -140,6 +155,15 @@ namespace BumpCombat.Core
|
||||
canvas.gameObject.AddComponent<BumpCombat.Progression.ArtifactRewardController>();
|
||||
}
|
||||
EnsurePresentationServices();
|
||||
EnsureDefensePrototypeController();
|
||||
}
|
||||
|
||||
private void EnsureDefensePrototypeController()
|
||||
{
|
||||
if (GetComponent<BumpCombat.Defense.DefensePrototypeController>() == null)
|
||||
{
|
||||
gameObject.AddComponent<BumpCombat.Defense.DefensePrototypeController>();
|
||||
}
|
||||
}
|
||||
|
||||
private void EnsurePresentationServices()
|
||||
@@ -170,6 +194,19 @@ namespace BumpCombat.Core
|
||||
{
|
||||
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()
|
||||
@@ -182,6 +219,11 @@ namespace BumpCombat.Core
|
||||
|
||||
private void Update()
|
||||
{
|
||||
if (gameOverPresentation)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (gameOver)
|
||||
{
|
||||
if (Keyboard.current?.rKey.wasPressedThisFrame == true)
|
||||
@@ -192,6 +234,11 @@ namespace BumpCombat.Core
|
||||
return;
|
||||
}
|
||||
|
||||
if (runCleared)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (selectionOpen)
|
||||
{
|
||||
return;
|
||||
@@ -222,7 +269,13 @@ namespace BumpCombat.Core
|
||||
|
||||
public bool TryOpenSelection(MonoBehaviour owner)
|
||||
{
|
||||
if (owner == null || titleOpen || selectionOpen || gameOver || pauseOpen)
|
||||
if (owner == null
|
||||
|| titleOpen
|
||||
|| selectionOpen
|
||||
|| gameOver
|
||||
|| gameOverPresentation
|
||||
|| runCleared
|
||||
|| pauseOpen)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
@@ -308,6 +361,8 @@ namespace BumpCombat.Core
|
||||
public bool TryOpenPause()
|
||||
{
|
||||
if (gameOver
|
||||
|| gameOverPresentation
|
||||
|| runCleared
|
||||
|| titleOpen
|
||||
|| selectionOpen
|
||||
|| pauseOpen
|
||||
@@ -365,21 +420,69 @@ namespace BumpCombat.Core
|
||||
return;
|
||||
}
|
||||
|
||||
gameOverPresentation = false;
|
||||
gameOver = true;
|
||||
pauseOpen = false;
|
||||
RefreshTimeScale();
|
||||
OnGameOver?.Invoke();
|
||||
}
|
||||
|
||||
public void BeginGameOverPresentation(float duration)
|
||||
{
|
||||
if (gameOver || runCleared || gameOverPresentation)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
gameOverPresentation = true;
|
||||
pauseOpen = false;
|
||||
StartCoroutine(CompleteGameOverPresentation(Mathf.Max(0f, duration)));
|
||||
}
|
||||
|
||||
private IEnumerator CompleteGameOverPresentation(float duration)
|
||||
{
|
||||
if (duration > 0f)
|
||||
{
|
||||
yield return new WaitForSeconds(duration);
|
||||
}
|
||||
|
||||
if (!gameOverPresentation)
|
||||
{
|
||||
yield break;
|
||||
}
|
||||
|
||||
gameOverPresentation = false;
|
||||
EndRun();
|
||||
}
|
||||
|
||||
public void EndRunAsClear()
|
||||
{
|
||||
if (gameOver || gameOverPresentation || runCleared)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
runCleared = true;
|
||||
pauseOpen = false;
|
||||
RefreshTimeScale();
|
||||
OnRunCleared?.Invoke();
|
||||
}
|
||||
|
||||
private void RefreshTimeScale()
|
||||
{
|
||||
Time.timeScale = gameOver || titleOpen || pauseOpen || selectionOpen
|
||||
Time.timeScale = gameOver || runCleared || titleOpen || pauseOpen || selectionOpen
|
||||
? 0f
|
||||
: 1f;
|
||||
}
|
||||
|
||||
private void RaiseTimedEvents()
|
||||
{
|
||||
if (BumpCombat.Stage.StageManager.IsStageModeActive
|
||||
|| IsDefensePrototypeRun)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
float[] eliteTimes = CurrentEliteEventTimes;
|
||||
float midBossTime = GetEventTimeForCurrentRun(RunTimedEvent.MidBoss);
|
||||
float finalBossTime = GetEventTimeForCurrentRun(RunTimedEvent.FinalBoss);
|
||||
|
||||
@@ -22,7 +22,13 @@ namespace BumpCombat.Core
|
||||
|
||||
public int CalculateSortingOrder()
|
||||
{
|
||||
return baseOrder - Mathf.RoundToInt(transform.position.y * precision);
|
||||
float y = transform.position.y;
|
||||
ArenaBounds bounds = ArenaBounds.Resolve();
|
||||
if (bounds != null)
|
||||
{
|
||||
y -= bounds.ZoneCenter.y;
|
||||
}
|
||||
return baseOrder - Mathf.RoundToInt(y * precision);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
CircleCollider2D collider = player.AddComponent<CircleCollider2D>();
|
||||
collider.offset = new Vector2(0f, -0.28f);
|
||||
collider.offset = new Vector2(0f, -0.06f);
|
||||
collider.radius = 0.22f;
|
||||
|
||||
player.AddComponent<PlayerStats>();
|
||||
@@ -1394,10 +1394,7 @@ namespace BumpCombat.Editor
|
||||
radius,
|
||||
experience,
|
||||
animationLeadTime);
|
||||
definition.ConfigureRole(
|
||||
kind == EnemyKind.Slime || kind == EnemyKind.Skeleton
|
||||
? EnemyRole.Crowd
|
||||
: EnemyRole.Normal);
|
||||
definition.ConfigureRole(EnemyRole.Normal);
|
||||
AssetDatabase.CreateAsset(definition, path);
|
||||
return definition;
|
||||
}
|
||||
|
||||
@@ -22,6 +22,7 @@ namespace BumpCombat.Enemies
|
||||
private bool lancerTipNeedsRebase;
|
||||
private bool necrofireRayActive;
|
||||
private bool necrofireRayHitConfirmed;
|
||||
private float necrofireRayLength;
|
||||
private bool meleeAttackActive;
|
||||
private bool meleeHitConfirmed;
|
||||
private StageEnemyEffectVisual necrofireRayVisual;
|
||||
@@ -392,12 +393,16 @@ namespace BumpCombat.Enemies
|
||||
Vector2 direction = lockedDirection.sqrMagnitude > 0.0001f
|
||||
? lockedDirection
|
||||
: Vector2.right;
|
||||
float length = controller.AttackLength;
|
||||
necrofireRayLength = GetTerrainBlockedLength(
|
||||
attackOrigin,
|
||||
direction,
|
||||
controller.AttackLength,
|
||||
controller.AttackWidth * 0.5f);
|
||||
necrofireRayVisual = StageEnemyEffectVisual.PlayRay(
|
||||
gameObject,
|
||||
attackOrigin,
|
||||
direction,
|
||||
length,
|
||||
necrofireRayLength,
|
||||
controller.AttackWidth,
|
||||
controller.ActiveDuration);
|
||||
TickNecrofireRay();
|
||||
@@ -982,7 +987,13 @@ namespace BumpCombat.Enemies
|
||||
Vector2 direction = lockedDirection.sqrMagnitude > 0.0001f
|
||||
? lockedDirection
|
||||
: Vector2.right;
|
||||
float length = controller.AttackLength;
|
||||
float length = necrofireRayLength > 0f
|
||||
? necrofireRayLength
|
||||
: GetTerrainBlockedLength(
|
||||
attackOrigin,
|
||||
direction,
|
||||
controller.AttackLength,
|
||||
controller.AttackWidth * 0.5f);
|
||||
float width = Mathf.Max(0.001f, controller.AttackWidth);
|
||||
Vector2 center = attackOrigin + direction * (length * 0.5f);
|
||||
float angle = Mathf.Atan2(direction.y, direction.x) * Mathf.Rad2Deg;
|
||||
@@ -1018,5 +1029,44 @@ namespace BumpCombat.Enemies
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static float GetTerrainBlockedLength(
|
||||
Vector2 origin,
|
||||
Vector2 direction,
|
||||
float maxLength,
|
||||
float radius)
|
||||
{
|
||||
float clampedLength = Mathf.Max(0f, maxLength);
|
||||
if (clampedLength <= 0f || direction.sqrMagnitude <= 0.0001f)
|
||||
{
|
||||
return clampedLength;
|
||||
}
|
||||
|
||||
RaycastHit2D[] hits = Physics2D.CircleCastAll(
|
||||
origin,
|
||||
Mathf.Max(0.01f, radius),
|
||||
direction.normalized,
|
||||
clampedLength,
|
||||
Physics2D.AllLayers);
|
||||
float blockedLength = clampedLength;
|
||||
for (int i = 0; i < hits.Length; i++)
|
||||
{
|
||||
Collider2D collider = hits[i].collider;
|
||||
if (collider == null || collider.isTrigger)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
bool isTerrain = collider.GetComponentInParent<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.Player;
|
||||
using BumpCombat.Progression;
|
||||
using BumpCombat.Stage;
|
||||
using UnityEngine;
|
||||
|
||||
namespace BumpCombat.Enemies
|
||||
@@ -49,6 +50,7 @@ namespace BumpCombat.Enemies
|
||||
Active,
|
||||
Recovery,
|
||||
Dead,
|
||||
Idle,
|
||||
}
|
||||
|
||||
[RequireComponent(typeof(Rigidbody2D), typeof(Collider2D), typeof(SpriteRenderer))]
|
||||
@@ -68,6 +70,7 @@ namespace BumpCombat.Enemies
|
||||
private static readonly int HurtParameter = Animator.StringToHash("Hurt");
|
||||
private static readonly int DeathState = Animator.StringToHash("Base Layer.Death");
|
||||
private static readonly Collider2D[] SeparationBuffer = new Collider2D[16];
|
||||
private static readonly Dictionary<int, HashSet<EnemyController>> EncounterGroups = new();
|
||||
private static Sprite launchWhiteSprite;
|
||||
private static int nextSpawnLifetimeIdentity;
|
||||
[SerializeField] private EnemyDefinition definition;
|
||||
@@ -85,6 +88,9 @@ namespace BumpCombat.Enemies
|
||||
private EnemyAttack enemyAttack;
|
||||
private EnemyModel enemyModel;
|
||||
private ArtifactStatusVisual statusVisual;
|
||||
private readonly List<Renderer> fogRenderers = new();
|
||||
private readonly List<bool> fogRendererForceOffStates = new();
|
||||
private bool fogVisibilityHidden;
|
||||
private ActiveArtifactController activeArtifactController;
|
||||
private Transform player;
|
||||
private PlayerHealth playerHealth;
|
||||
@@ -97,6 +103,7 @@ namespace BumpCombat.Enemies
|
||||
private float igniteNextTickIn;
|
||||
private float igniteTickInterval;
|
||||
private float igniteTickDamage;
|
||||
private EnemyDamageSource igniteDamageSource;
|
||||
private float shockRemaining;
|
||||
private float shockIncrease;
|
||||
private float vulnerabilityRemaining;
|
||||
@@ -123,6 +130,7 @@ namespace BumpCombat.Enemies
|
||||
private IEnemyDamageGate damageGate;
|
||||
private IEnemyAttackPatternGate attackPatternGate;
|
||||
private IEnemyActionGate actionGate;
|
||||
private BumpCombat.Defense.DefenseRouteAgent defenseRouteAgent;
|
||||
private bool summoned;
|
||||
private bool phaseSummon;
|
||||
private bool summonedEventLike;
|
||||
@@ -142,6 +150,15 @@ namespace BumpCombat.Enemies
|
||||
private readonly HashSet<string> recentArtifactContactKeys = new(StringComparer.Ordinal);
|
||||
private readonly Queue<string> recentArtifactContactOrder = new();
|
||||
private const int MaximumRememberedArtifactContacts = 128;
|
||||
private const int RoamTargetSelectionAttempts = 12;
|
||||
private bool hasDetectedPlayer;
|
||||
private int encounterGroupId = -1;
|
||||
private bool hasRoamAnchor;
|
||||
private Vector2 roamAnchor;
|
||||
private Vector2 roamTarget;
|
||||
private float roamPauseRemaining;
|
||||
private float sightMemoryRemaining;
|
||||
private Vector2 lastSeenPlayerPosition;
|
||||
|
||||
public static int AliveCount { get; private set; }
|
||||
public static int CrowdAliveCount { get; private set; }
|
||||
@@ -160,6 +177,8 @@ namespace BumpCombat.Enemies
|
||||
public float CurrentHealth { get; private set; }
|
||||
public float LastAppliedDamage { get; private set; }
|
||||
public float LastReportedDamage { get; private set; }
|
||||
public EnemyDamageSource LastDamageSource { get; private set; }
|
||||
public float MaximumHealthAtDeath { get; private set; }
|
||||
public int CurrentAttackPatternIndex => attackPatternIndex;
|
||||
public EnemyAttackPattern CurrentAttackPattern => definition != null
|
||||
? definition.GetAttackPattern(attackPatternIndex)
|
||||
@@ -216,6 +235,12 @@ namespace BumpCombat.Enemies
|
||||
public Vector2 GroundAnchorPosition => body != null
|
||||
? body.position
|
||||
: transform.position;
|
||||
public Transform CurrentVisualTransform => airborneVisualObject != null
|
||||
? airborneVisualObject.transform
|
||||
: transform;
|
||||
public int CurrentVisualSortingOrder => airborneVisualObject != null
|
||||
? spriteRenderer.sortingOrder + 2
|
||||
: spriteRenderer.sortingOrder;
|
||||
public bool IsKnockbackActive => knockbackVelocity.sqrMagnitude > 0.0025f;
|
||||
public bool IsLaunchVisualActive => launchVisualCoroutine != null;
|
||||
public bool IsAttackSequenceInProgress => State == EnemyState.Warning
|
||||
@@ -228,6 +253,11 @@ namespace BumpCombat.Enemies
|
||||
public EnemyController SummonOwner => summonOwner;
|
||||
public bool IsProtectedFromCleanup => runEventRole.HasValue || phaseSummon;
|
||||
public bool HasContactDamage => definition != null && definition.HasContactDamage;
|
||||
public bool IsFlying => definition != null && definition.IsFlying;
|
||||
public Collider2D BodyCollider => bodyCollider != null ? bodyCollider : (bodyCollider = GetComponent<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
|
||||
|| !definition.IsRanged
|
||||
|| CanStartRangedAttack();
|
||||
@@ -369,6 +399,21 @@ namespace BumpCombat.Enemies
|
||||
}
|
||||
#endif
|
||||
|
||||
public void ConfigureEncounterGroup(int groupId, Vector2 anchor)
|
||||
{
|
||||
UnregisterEncounterGroup();
|
||||
encounterGroupId = groupId;
|
||||
roamAnchor = anchor;
|
||||
roamTarget = anchor;
|
||||
hasRoamAnchor = true;
|
||||
RegisterEncounterGroup();
|
||||
}
|
||||
|
||||
public void ConfigureSightRangeMultiplier(float multiplier)
|
||||
{
|
||||
EnsureEnemyModel().ConfigureSightRangeMultiplier(multiplier);
|
||||
}
|
||||
|
||||
private void Awake()
|
||||
{
|
||||
nextSpawnLifetimeIdentity = nextSpawnLifetimeIdentity == int.MaxValue
|
||||
@@ -386,6 +431,7 @@ namespace BumpCombat.Enemies
|
||||
}
|
||||
bodyCollider = GetComponent<Collider2D>();
|
||||
spriteRenderer = GetComponent<SpriteRenderer>();
|
||||
CacheFogRenderers();
|
||||
animator = GetComponent<Animator>();
|
||||
enemyAttack = GetComponent<EnemyAttack>();
|
||||
ConfigureEnemyModel();
|
||||
@@ -433,6 +479,12 @@ namespace BumpCombat.Enemies
|
||||
|
||||
private void OnEnable()
|
||||
{
|
||||
if (fogVisibilityHidden)
|
||||
{
|
||||
ForceFogRenderersOff();
|
||||
}
|
||||
|
||||
RegisterEncounterGroup();
|
||||
if (enemyModel != null)
|
||||
{
|
||||
enemyModel.OnStatChanged += HandleCharacterStatChanged;
|
||||
@@ -455,12 +507,20 @@ namespace BumpCombat.Enemies
|
||||
}
|
||||
|
||||
KeepBodyInsideArena();
|
||||
if (IsFlying)
|
||||
{
|
||||
StageObstacle.RegisterFlyingEnemy(this);
|
||||
}
|
||||
}
|
||||
|
||||
private void Start()
|
||||
{
|
||||
InitializeGroggyTierFromDefinition();
|
||||
KeepBodyInsideArena();
|
||||
if (IsFlying)
|
||||
{
|
||||
StageObstacle.RegisterFlyingEnemy(this);
|
||||
}
|
||||
if (definition == null)
|
||||
{
|
||||
enabled = false;
|
||||
@@ -477,8 +537,16 @@ namespace BumpCombat.Enemies
|
||||
player = playerController.transform;
|
||||
playerHealth = playerController.GetComponent<PlayerHealth>();
|
||||
activeArtifactController = playerController.GetComponent<ActiveArtifactController>();
|
||||
if (!hasRoamAnchor)
|
||||
{
|
||||
roamAnchor = body != null ? body.position : (Vector2)transform.position;
|
||||
roamTarget = roamAnchor;
|
||||
hasRoamAnchor = true;
|
||||
}
|
||||
InitializeInitialShieldColor();
|
||||
CurrentHealth = MaximumHealth;
|
||||
LastDamageSource = EnemyDamageSource.None;
|
||||
MaximumHealthAtDeath = 0f;
|
||||
float summonDuration = summoned ? GetSummonAnimationDuration() : 0f;
|
||||
bool playSummonAnimation = summonDuration > 0f;
|
||||
EnterState(
|
||||
@@ -507,6 +575,7 @@ namespace BumpCombat.Enemies
|
||||
|
||||
private void OnDisable()
|
||||
{
|
||||
UnregisterEncounterGroup();
|
||||
if (enemyModel != null)
|
||||
{
|
||||
enemyModel.OnStatChanged -= HandleCharacterStatChanged;
|
||||
@@ -601,6 +670,13 @@ namespace BumpCombat.Enemies
|
||||
return;
|
||||
}
|
||||
|
||||
defenseRouteAgent ??= GetComponent<BumpCombat.Defense.DefenseRouteAgent>();
|
||||
if (defenseRouteAgent != null
|
||||
&& defenseRouteAgent.TryDriveFixedUpdate(Time.fixedDeltaTime))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (!AllowsStateMovement(State, AttackShape))
|
||||
{
|
||||
body.linearVelocity = Vector2.zero;
|
||||
@@ -611,12 +687,35 @@ namespace BumpCombat.Enemies
|
||||
{
|
||||
case EnemyState.Spawn:
|
||||
if (stateTimer <= 0f)
|
||||
{
|
||||
if (CheckPlayerDetection())
|
||||
{
|
||||
EnterState(EnemyState.Chase, 0f);
|
||||
}
|
||||
else
|
||||
{
|
||||
EnterState(EnemyState.Idle, 0f);
|
||||
}
|
||||
}
|
||||
break;
|
||||
|
||||
case EnemyState.Idle:
|
||||
SetAttacking(false);
|
||||
if (CheckPlayerDetection())
|
||||
{
|
||||
EnterState(EnemyState.Chase, 0f);
|
||||
}
|
||||
else
|
||||
{
|
||||
UpdateRoaming();
|
||||
}
|
||||
break;
|
||||
|
||||
case EnemyState.Chase:
|
||||
if (!RefreshChaseAwareness())
|
||||
{
|
||||
break;
|
||||
}
|
||||
if (actionGate != null && actionGate.IsAttackLocked)
|
||||
{
|
||||
body.linearVelocity = Vector2.zero;
|
||||
@@ -731,7 +830,8 @@ namespace BumpCombat.Enemies
|
||||
Vector2 knockbackDirection,
|
||||
float knockbackForce,
|
||||
bool playHurtAnimation = true,
|
||||
bool isNormalBump = false)
|
||||
bool isNormalBump = false,
|
||||
EnemyDamageSource damageSource = EnemyDamageSource.None)
|
||||
{
|
||||
return TryTakeDamageWithEffects(
|
||||
damage,
|
||||
@@ -741,6 +841,7 @@ namespace BumpCombat.Enemies
|
||||
isNormalBump,
|
||||
IsShocked,
|
||||
IsBumpVulnerable,
|
||||
damageSource,
|
||||
out _);
|
||||
}
|
||||
|
||||
@@ -752,9 +853,11 @@ namespace BumpCombat.Enemies
|
||||
bool isNormalBump,
|
||||
bool isShockedAtHit,
|
||||
bool isBumpVulnerableAtHit,
|
||||
EnemyDamageSource damageSource,
|
||||
out bool noDamageAfterEffects)
|
||||
{
|
||||
noDamageAfterEffects = false;
|
||||
Alert();
|
||||
if (IsDead || damage <= 0f)
|
||||
{
|
||||
LastAppliedDamage = 0f;
|
||||
@@ -802,13 +905,16 @@ namespace BumpCombat.Enemies
|
||||
? damageTaken
|
||||
: calculatedDamage;
|
||||
float healthBeforeDamage = CurrentHealth;
|
||||
float maximumHealthBeforeDamage = MaximumHealth;
|
||||
CurrentHealth = Mathf.Max(0f, CurrentHealth - damageTaken);
|
||||
LastAppliedDamage = healthBeforeDamage - CurrentHealth;
|
||||
LastDamageSource = damageSource;
|
||||
OnDamageApplied?.Invoke(healthBeforeDamage, LastAppliedDamage);
|
||||
bool damageReactionBlocked = damageGate != null
|
||||
&& damageGate.IsDamageBlocked;
|
||||
if (CurrentHealth <= 0f)
|
||||
{
|
||||
MaximumHealthAtDeath = maximumHealthBeforeDamage;
|
||||
Die();
|
||||
}
|
||||
else if (damageReactionBlocked)
|
||||
@@ -817,13 +923,13 @@ namespace BumpCombat.Enemies
|
||||
// desperation phase. Do not append the opening hit's
|
||||
// knockback or hurt reaction after that transition.
|
||||
knockbackVelocity = Vector2.zero;
|
||||
body.linearVelocity = Vector2.zero;
|
||||
if (body != null) body.linearVelocity = Vector2.zero;
|
||||
}
|
||||
else
|
||||
{
|
||||
if (knockbackVelocity.sqrMagnitude <= 0.0025f)
|
||||
{
|
||||
knockbackStartPosition = body.position;
|
||||
knockbackStartPosition = body != null ? body.position : (Vector2)transform.position;
|
||||
}
|
||||
knockbackVelocity += knockbackDirection.normalized
|
||||
* knockbackForce
|
||||
@@ -914,7 +1020,8 @@ namespace BumpCombat.Enemies
|
||||
float knockbackForce,
|
||||
ArtifactColor artifactColor,
|
||||
int castIdentity,
|
||||
bool playHurtAnimation = true)
|
||||
bool playHurtAnimation = true,
|
||||
EnemyDamageSource damageSource = EnemyDamageSource.None)
|
||||
{
|
||||
lastArtifactContactCounted = false;
|
||||
if (IsDead)
|
||||
@@ -956,7 +1063,8 @@ namespace BumpCombat.Enemies
|
||||
knockbackDirection,
|
||||
knockbackForce,
|
||||
playHurtAnimation,
|
||||
false)
|
||||
false,
|
||||
damageSource)
|
||||
? EnemyArtifactHitResult.DamageApplied
|
||||
: EnemyArtifactHitResult.Rejected;
|
||||
}
|
||||
@@ -1004,7 +1112,8 @@ namespace BumpCombat.Enemies
|
||||
public bool ApplyIgnite(
|
||||
float duration,
|
||||
float tickInterval,
|
||||
float damagePerTick)
|
||||
float damagePerTick,
|
||||
EnemyDamageSource damageSource = EnemyDamageSource.Player)
|
||||
{
|
||||
if (IsDead
|
||||
|| duration <= 0f
|
||||
@@ -1017,6 +1126,7 @@ namespace BumpCombat.Enemies
|
||||
bool wasInactive = igniteRemaining <= 0f;
|
||||
igniteRemaining = Mathf.Max(igniteRemaining, duration);
|
||||
igniteTickDamage = Mathf.Max(igniteTickDamage, damagePerTick);
|
||||
igniteDamageSource = damageSource;
|
||||
if (wasInactive)
|
||||
{
|
||||
igniteTickInterval = tickInterval;
|
||||
@@ -1085,6 +1195,7 @@ namespace BumpCombat.Enemies
|
||||
false,
|
||||
isShockedAtTick,
|
||||
false,
|
||||
igniteDamageSource,
|
||||
out bool noDamageAfterEffects);
|
||||
if ((!damageApplied && !noDamageAfterEffects) || IsDead)
|
||||
{
|
||||
@@ -1821,19 +1932,28 @@ namespace BumpCombat.Enemies
|
||||
Vector2 attackSlot = GetHorizontalRootAttackSlot(
|
||||
attackTarget,
|
||||
direction);
|
||||
if (!IsFlying)
|
||||
{
|
||||
ZoneInstance currentZone = StageManager.Instance != null
|
||||
? StageManager.Instance.CurrentZone
|
||||
: null;
|
||||
if (currentZone != null
|
||||
&& currentZone.Preset != null
|
||||
&& RoomPathfinder.TryGetNextWaypoint(
|
||||
currentZone,
|
||||
body.position,
|
||||
attackSlot,
|
||||
false,
|
||||
out Vector2 waypoint))
|
||||
{
|
||||
attackSlot = waypoint;
|
||||
}
|
||||
}
|
||||
Vector2 toAttackSlot = attackSlot - body.position;
|
||||
movementDirection = toAttackSlot.sqrMagnitude > 0.0001f
|
||||
? toAttackSlot.normalized
|
||||
: Vector2.zero;
|
||||
}
|
||||
if (IsCrowd)
|
||||
{
|
||||
Vector2 toApproachPoint =
|
||||
(Vector2)player.position + crowdApproachOffset - body.position;
|
||||
movementDirection = toApproachPoint.sqrMagnitude > 0.01f
|
||||
? toApproachPoint.normalized
|
||||
: Vector2.zero;
|
||||
}
|
||||
else if (definition.IsRanged && rangedAttackOutsideGate)
|
||||
{
|
||||
// A ranged attacker must enter the visible, reachable area
|
||||
@@ -1845,11 +1965,45 @@ namespace BumpCombat.Enemies
|
||||
{
|
||||
movementDirection = -direction;
|
||||
}
|
||||
else
|
||||
{
|
||||
Vector2 targetPoint = attackTarget;
|
||||
if (IsCrowd)
|
||||
{
|
||||
float distToPlayer = (attackTarget - body.position).magnitude;
|
||||
targetPoint = distToPlayer > 2.5f
|
||||
? attackTarget
|
||||
: (Vector2)player.position + crowdApproachOffset;
|
||||
}
|
||||
|
||||
if (!IsFlying)
|
||||
{
|
||||
ZoneInstance currentZone = StageManager.Instance != null ? StageManager.Instance.CurrentZone : null;
|
||||
if (currentZone != null && currentZone.Preset != null)
|
||||
{
|
||||
if (RoomPathfinder.TryGetNextWaypoint(
|
||||
currentZone,
|
||||
body.position,
|
||||
targetPoint,
|
||||
IsFlying,
|
||||
out Vector2 waypoint))
|
||||
{
|
||||
targetPoint = waypoint;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Vector2 toApproachPoint = targetPoint - body.position;
|
||||
movementDirection = toApproachPoint.sqrMagnitude > 0.01f
|
||||
? toApproachPoint.normalized
|
||||
: Vector2.zero;
|
||||
}
|
||||
|
||||
Vector2 separation = CalculateSeparation();
|
||||
Vector2 velocity = Vector2.ClampMagnitude(
|
||||
Vector2 desiredVelocity = Vector2.ClampMagnitude(
|
||||
movementDirection + separation * separationStrength,
|
||||
1f) * MoveSpeed;
|
||||
Vector2 velocity = CalculateWallSlideVelocity(desiredVelocity);
|
||||
body.MovePosition(ClampEnemyPosition(
|
||||
body.position + velocity * Time.fixedDeltaTime));
|
||||
SetMoving(velocity.sqrMagnitude > 0.0001f);
|
||||
@@ -1864,10 +2018,11 @@ namespace BumpCombat.Enemies
|
||||
if (bounds != null)
|
||||
{
|
||||
Vector2 actorExtents = bounds.SharedActorClampHalfExtents;
|
||||
Vector2 center = bounds.ZoneCenter;
|
||||
boundedTarget.y = Mathf.Clamp(
|
||||
attackTarget.y,
|
||||
-actorExtents.y,
|
||||
actorExtents.y);
|
||||
center.y - actorExtents.y,
|
||||
center.y + actorExtents.y);
|
||||
}
|
||||
|
||||
// Horizontal-root attacks measure range to the player's collider
|
||||
@@ -1919,9 +2074,10 @@ namespace BumpCombat.Enemies
|
||||
EnemyConstants tuning = GameplayConstants.Current.Enemies;
|
||||
Vector2 reachable = bounds.GetReachableHalfExtents(
|
||||
tuning.RangedApproachArenaInset);
|
||||
Vector2 center = bounds.ZoneCenter;
|
||||
Vector2 interiorTarget = new(
|
||||
Mathf.Clamp(targetPosition.x, -reachable.x, reachable.x),
|
||||
Mathf.Clamp(targetPosition.y, -reachable.y, reachable.y));
|
||||
Mathf.Clamp(targetPosition.x, center.x - reachable.x, center.x + reachable.x),
|
||||
Mathf.Clamp(targetPosition.y, center.y - reachable.y, center.y + reachable.y));
|
||||
Camera camera = Camera.main;
|
||||
if (camera != null)
|
||||
{
|
||||
@@ -1933,10 +2089,10 @@ namespace BumpCombat.Enemies
|
||||
// Keep the approach point in the intersection of the
|
||||
// reachable and visible interiors. This is essential when
|
||||
// the player is held against an outer map edge.
|
||||
float minimumX = Mathf.Max(-reachable.x, cameraCenter.x - visible.x);
|
||||
float maximumX = Mathf.Min(reachable.x, cameraCenter.x + visible.x);
|
||||
float minimumY = Mathf.Max(-reachable.y, cameraCenter.y - visible.y);
|
||||
float maximumY = Mathf.Min(reachable.y, cameraCenter.y + visible.y);
|
||||
float minimumX = Mathf.Max(center.x - reachable.x, cameraCenter.x - visible.x);
|
||||
float maximumX = Mathf.Min(center.x + reachable.x, cameraCenter.x + visible.x);
|
||||
float minimumY = Mathf.Max(center.y - reachable.y, cameraCenter.y - visible.y);
|
||||
float maximumY = Mathf.Min(center.y + reachable.y, cameraCenter.y + visible.y);
|
||||
if (minimumX <= maximumX)
|
||||
{
|
||||
interiorTarget.x = Mathf.Clamp(
|
||||
@@ -2095,6 +2251,11 @@ namespace BumpCombat.Enemies
|
||||
return cachedSeparation;
|
||||
}
|
||||
|
||||
public void SetFacingDirection(Vector2 direction)
|
||||
{
|
||||
SetFacing(direction);
|
||||
}
|
||||
|
||||
private void SetFacing(Vector2 direction)
|
||||
{
|
||||
if (direction.sqrMagnitude <= 0f)
|
||||
@@ -2126,6 +2287,276 @@ namespace BumpCombat.Enemies
|
||||
|| (state == EnemyState.Warning && attackAnimationStarted));
|
||||
}
|
||||
|
||||
public void Alert()
|
||||
{
|
||||
MarkPlayerDetected(false);
|
||||
if (State == EnemyState.Idle)
|
||||
{
|
||||
EnterState(EnemyState.Chase, 0f);
|
||||
}
|
||||
}
|
||||
|
||||
public bool CheckPlayerDetection()
|
||||
{
|
||||
if (!CanCurrentlyDetectPlayer()) return false;
|
||||
MarkPlayerDetected(true);
|
||||
return true;
|
||||
}
|
||||
|
||||
private bool CanCurrentlyDetectPlayer()
|
||||
{
|
||||
EnsurePlayerReference();
|
||||
if (player == null) return false;
|
||||
|
||||
Vector2 myPos = GroundAnchorPosition;
|
||||
float distance = Vector2.Distance(myPos, player.position);
|
||||
if (distance > SightRange) return false;
|
||||
|
||||
float proximityRange = GameplayConstants.Current.Enemies.ProximityAlertRange;
|
||||
return distance <= proximityRange
|
||||
|| RoomPathfinder.HasLineOfSight(myPos, player.position, IsFlying, bodyCollider);
|
||||
}
|
||||
|
||||
private void EnsurePlayerReference()
|
||||
{
|
||||
if (player != null) return;
|
||||
|
||||
PlayerController playerController = FindAnyObjectByType<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(
|
||||
float warningTimeRemaining,
|
||||
float leadTime,
|
||||
@@ -2294,6 +2725,89 @@ namespace BumpCombat.Enemies
|
||||
ReleaseSpriteVisibilityLease(reason);
|
||||
}
|
||||
|
||||
public void SetFogVisibility(bool visible)
|
||||
{
|
||||
if (visible)
|
||||
{
|
||||
if (!fogVisibilityHidden)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
for (int i = 0; i < fogRenderers.Count; i++)
|
||||
{
|
||||
if (fogRenderers[i] != null)
|
||||
{
|
||||
fogRenderers[i].forceRenderingOff = fogRendererForceOffStates[i];
|
||||
}
|
||||
}
|
||||
|
||||
fogRenderers.Clear();
|
||||
fogRendererForceOffStates.Clear();
|
||||
fogVisibilityHidden = false;
|
||||
return;
|
||||
}
|
||||
|
||||
if (!fogVisibilityHidden)
|
||||
{
|
||||
CacheFogRenderers();
|
||||
fogVisibilityHidden = true;
|
||||
}
|
||||
|
||||
ForceFogRenderersOff();
|
||||
}
|
||||
|
||||
private void CacheFogRenderers()
|
||||
{
|
||||
if (fogVisibilityHidden)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
fogRenderers.Clear();
|
||||
fogRendererForceOffStates.Clear();
|
||||
Renderer[] renderers = GetComponentsInChildren<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)
|
||||
{
|
||||
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, Min(0f)] private float separationRadius = 0.55f;
|
||||
[SerializeField, Min(0f)] private float separationStrength = 0.6f;
|
||||
[SerializeField] private bool isFlying;
|
||||
[SerializeField, Min(0f)] private float sightRange = 8f;
|
||||
|
||||
public EnemyKind Kind => kind;
|
||||
public EnemyRole Role => role;
|
||||
@@ -326,6 +328,8 @@ namespace BumpCombat.Enemies
|
||||
public bool IsCrowd => role == EnemyRole.Crowd;
|
||||
public bool HasContactDamage => kind == EnemyKind.Slime
|
||||
|| kind == EnemyKind.Bat;
|
||||
public bool IsFlying => isFlying || kind == EnemyKind.Bat || kind == EnemyKind.Necrofire;
|
||||
public float SightRange => sightRange > 0.01f ? sightRange : 8f;
|
||||
|
||||
public EnemyAttackPattern GetAttackPattern(int index)
|
||||
{
|
||||
@@ -394,6 +398,16 @@ namespace BumpCombat.Enemies
|
||||
{
|
||||
attackPatterns = patterns;
|
||||
}
|
||||
|
||||
public void ConfigureFlying(bool flying)
|
||||
{
|
||||
isFlying = flying;
|
||||
}
|
||||
|
||||
public void ConfigureSightRange(float range)
|
||||
{
|
||||
sightRange = range;
|
||||
}
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ namespace BumpCombat.Enemies
|
||||
private float maxHealthMultiplier = 1f;
|
||||
private float moveSpeedMultiplier = 1f;
|
||||
private float attackDamageMultiplier = 1f;
|
||||
private float sightRangeMultiplier = 1f;
|
||||
|
||||
public EnemyDefinition Definition => definition;
|
||||
public override float MaxHealth => definition == null
|
||||
@@ -26,6 +27,14 @@ namespace BumpCombat.Enemies
|
||||
? 0f
|
||||
: CalculateAttackDamage(
|
||||
definition.AttackDamage * attackDamageMultiplier);
|
||||
public float SightRange => definition == null
|
||||
? 8f
|
||||
: definition.SightRange * sightRangeMultiplier;
|
||||
|
||||
public void ConfigureSightRangeMultiplier(float multiplier)
|
||||
{
|
||||
sightRangeMultiplier = Mathf.Max(0.1f, multiplier);
|
||||
}
|
||||
|
||||
public void ConfigureDefinition(EnemyDefinition enemyDefinition)
|
||||
{
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
using BumpCombat.Player;
|
||||
using BumpCombat.Core;
|
||||
using BumpCombat.Stage;
|
||||
using UnityEngine;
|
||||
|
||||
namespace BumpCombat.Enemies
|
||||
@@ -82,6 +83,8 @@ namespace BumpCombat.Enemies
|
||||
}
|
||||
|
||||
bool hitTarget = false;
|
||||
float targetDistance = float.PositiveInfinity;
|
||||
float blockerDistance = float.PositiveInfinity;
|
||||
if (distance > 0f)
|
||||
{
|
||||
RaycastHit2D[] hits = Physics2D.CircleCastAll(
|
||||
@@ -92,12 +95,28 @@ namespace BumpCombat.Enemies
|
||||
Physics2D.AllLayers);
|
||||
for (int i = 0; i < hits.Length; i++)
|
||||
{
|
||||
if (hits[i].collider != null
|
||||
&& hits[i].collider.GetComponentInParent<PlayerHealth>() == target)
|
||||
Collider2D collider = hits[i].collider;
|
||||
if (collider == null || collider.isTrigger)
|
||||
{
|
||||
hitTarget = true;
|
||||
break;
|
||||
continue;
|
||||
}
|
||||
|
||||
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
|
||||
@@ -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()
|
||||
{
|
||||
resolved = true;
|
||||
|
||||
@@ -5,6 +5,7 @@ using BumpCombat.Combat;
|
||||
using BumpCombat.Constants;
|
||||
using BumpCombat.Core;
|
||||
using BumpCombat.Enemies;
|
||||
using BumpCombat.Progression;
|
||||
using UnityEngine;
|
||||
using UnityEngine.InputSystem;
|
||||
|
||||
@@ -339,6 +340,13 @@ namespace BumpCombat.Player
|
||||
{
|
||||
UpdateTemporaryPlayerModifiers();
|
||||
|
||||
if (playerHealth != null && playerHealth.CurrentHealth <= 0f)
|
||||
{
|
||||
CancelArtifactInput();
|
||||
CancelExecution();
|
||||
return;
|
||||
}
|
||||
|
||||
if (playerHealth != null && playerHealth.IsHurtMovementLocked)
|
||||
{
|
||||
CancelArtifactInput();
|
||||
@@ -450,6 +458,7 @@ namespace BumpCombat.Player
|
||||
if (definition == null
|
||||
|| IsExecutingArtifact
|
||||
|| (dashController != null && dashController.IsDashing)
|
||||
|| (playerHealth != null && playerHealth.CurrentHealth <= 0f)
|
||||
|| (playerHealth != null && playerHealth.IsHurtMovementLocked)
|
||||
|| (charged && !IsArtifactChargeUnlocked))
|
||||
{
|
||||
@@ -546,7 +555,7 @@ namespace BumpCombat.Player
|
||||
return true;
|
||||
}
|
||||
|
||||
private int GetOwnedArtifactColorCount(ArtifactColor color)
|
||||
public int GetOwnedArtifactColorCount(ArtifactColor color)
|
||||
{
|
||||
int count = 0;
|
||||
for (int i = 0; i < ownedArtifacts.Count; i++)
|
||||
@@ -1168,6 +1177,7 @@ namespace BumpCombat.Player
|
||||
float nextHitAt = 0f;
|
||||
int hitIndex = 0;
|
||||
int hitCount = GetScheduledHitCount(duration, hitInterval);
|
||||
bool movementBlocked = false;
|
||||
while (elapsed < duration)
|
||||
{
|
||||
visual.transform.position = body.position
|
||||
@@ -1206,10 +1216,24 @@ namespace BumpCombat.Player
|
||||
|
||||
yield return new WaitForFixedUpdate();
|
||||
elapsed += Time.fixedDeltaTime;
|
||||
body.MovePosition(Vector2.Lerp(
|
||||
if (!movementBlocked
|
||||
&& playerController != null
|
||||
&& !playerController.IsTransitionLocked)
|
||||
{
|
||||
Vector2 intendedEnd = Vector2.Lerp(
|
||||
start,
|
||||
end,
|
||||
Mathf.Clamp01(elapsed / duration)));
|
||||
Mathf.Clamp01(elapsed / duration));
|
||||
Vector2 clampedEnd = playerController.ClampMovementToTerrain(
|
||||
body.position,
|
||||
intendedEnd);
|
||||
movementBlocked = (clampedEnd - intendedEnd).sqrMagnitude > 0.0001f;
|
||||
body.MovePosition(clampedEnd);
|
||||
}
|
||||
else if (playerController != null && playerController.IsTransitionLocked)
|
||||
{
|
||||
movementBlocked = true;
|
||||
}
|
||||
visual.transform.position = body.position
|
||||
+ GetCycloneShadowOffsetWorld();
|
||||
}
|
||||
@@ -1236,7 +1260,6 @@ namespace BumpCombat.Player
|
||||
hitIndex++;
|
||||
}
|
||||
|
||||
body.position = end;
|
||||
DestroyVisual(visual);
|
||||
EndExecution();
|
||||
}
|
||||
@@ -2190,6 +2213,134 @@ namespace BumpCombat.Player
|
||||
return false;
|
||||
}
|
||||
|
||||
public bool TryEmitPassiveRelicEcho(
|
||||
Vector2 origin,
|
||||
Vector2 direction,
|
||||
float damageMultiplier)
|
||||
{
|
||||
ActiveArtifactDefinition definition = CurrentArtifact;
|
||||
if (definition == null
|
||||
|| definition.NormalDamage <= 0f
|
||||
|| damageMultiplier <= 0f
|
||||
|| definition.Effect == ActiveArtifactEffect.Pulse)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (direction.sqrMagnitude < MinimumDirectionSqrMagnitude)
|
||||
{
|
||||
direction = definition.Effect == ActiveArtifactEffect.ThunderCrash
|
||||
? GetThunderCrashDirection()
|
||||
: GetUseDirection();
|
||||
}
|
||||
direction.Normalize();
|
||||
int castIdentity = ++nextArtifactCastIdentity;
|
||||
float echoIgniteTickDamage = playerStats.CalculateDamage(
|
||||
definition.NormalIgniteTickDamage * damageMultiplier,
|
||||
definition.DamageTag);
|
||||
if (definition.Effect == ActiveArtifactEffect.ChainLightning)
|
||||
{
|
||||
EnemyController first = FindInitialChainTarget(origin, definition.NormalRange);
|
||||
if (first == null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
bool applied = false;
|
||||
foreach (ChainStrike strike in PlanArcChain(definition, first, origin))
|
||||
{
|
||||
applied |= ApplyArtifactHit(
|
||||
strike.Enemy,
|
||||
definition,
|
||||
strike.Damage * damageMultiplier,
|
||||
(strike.End - strike.Start).normalized,
|
||||
strike.Knockback,
|
||||
true,
|
||||
false,
|
||||
echoIgniteTickDamage,
|
||||
castIdentity,
|
||||
true);
|
||||
}
|
||||
|
||||
return applied;
|
||||
}
|
||||
|
||||
Vector2 center = origin;
|
||||
Vector2 radii = new Vector2(definition.NormalRange, definition.NormalRange);
|
||||
float rayRange = definition.NormalRange;
|
||||
float rayHalfWidth = Mathf.Max(0.05f, definition.NormalWidth * 0.5f);
|
||||
switch (definition.Effect)
|
||||
{
|
||||
case ActiveArtifactEffect.Cyclone:
|
||||
center += GetCycloneHitCenterOffset(definition.NormalRange, false);
|
||||
radii = GetCycloneHitRadii(definition.NormalRange, false);
|
||||
break;
|
||||
case ActiveArtifactEffect.ThunderCrash:
|
||||
center = origin + direction * 0.45f;
|
||||
radii = GetThunderCrashHitRadii(definition.NormalRange, direction);
|
||||
break;
|
||||
}
|
||||
|
||||
List<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(
|
||||
EnemyController enemy,
|
||||
ActiveArtifactDefinition definition,
|
||||
@@ -2199,7 +2350,8 @@ namespace BumpCombat.Player
|
||||
bool showsHitFeedback = true,
|
||||
bool charged = false,
|
||||
float igniteTickDamage = 0f,
|
||||
int castIdentity = 0)
|
||||
int castIdentity = 0,
|
||||
bool passiveRelicEcho = false)
|
||||
{
|
||||
if (enemy == null || enemy.IsDead)
|
||||
{
|
||||
@@ -2219,7 +2371,9 @@ namespace BumpCombat.Player
|
||||
direction,
|
||||
knockback,
|
||||
definition.ArtifactColor,
|
||||
castIdentity);
|
||||
castIdentity,
|
||||
true,
|
||||
EnemyDamageSource.Player);
|
||||
if (result == EnemyArtifactHitResult.Rejected)
|
||||
{
|
||||
return false;
|
||||
@@ -2233,7 +2387,7 @@ namespace BumpCombat.Player
|
||||
|
||||
if (result == EnemyArtifactHitResult.Shielded)
|
||||
{
|
||||
if (enemy.LastArtifactContactCounted)
|
||||
if (!passiveRelicEcho && enemy.LastArtifactContactCounted)
|
||||
{
|
||||
CombatEvents.RaiseArtifactEffectiveHit(
|
||||
enemy.gameObject,
|
||||
@@ -2262,7 +2416,7 @@ namespace BumpCombat.Player
|
||||
igniteTickDamage);
|
||||
}
|
||||
|
||||
if (damageWasApplied)
|
||||
if (damageWasApplied && !passiveRelicEcho)
|
||||
{
|
||||
CombatEvents.RaiseArtifactEffectiveHit(
|
||||
enemy.gameObject,
|
||||
@@ -2283,7 +2437,7 @@ namespace BumpCombat.Player
|
||||
false,
|
||||
false,
|
||||
definition.DamageTag,
|
||||
definition.ArtifactId,
|
||||
passiveRelicEcho ? PassiveRelicController.EchoSourceId : definition.ArtifactId,
|
||||
false,
|
||||
result == EnemyArtifactHitResult.DamageApplied
|
||||
&& showsHitFeedback,
|
||||
|
||||
@@ -44,7 +44,8 @@ namespace BumpCombat.Player
|
||||
|| IsDashing
|
||||
|| playerController == null
|
||||
|| playerController.IsDamageKnockbackActive
|
||||
|| playerController.IsHurtMovementLocked)
|
||||
|| playerController.IsHurtMovementLocked
|
||||
|| playerController.IsTransitionLocked)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
@@ -81,19 +82,29 @@ namespace BumpCombat.Player
|
||||
: dashDuration;
|
||||
Vector2 start = body.position;
|
||||
Vector2 end = playerController.ClampToArena(start + direction * distance);
|
||||
Vector2 finalPosition = start;
|
||||
|
||||
combatResolver.BeginDashPathContactSession();
|
||||
float elapsed = 0f;
|
||||
while (elapsed < duration)
|
||||
{
|
||||
yield return new WaitForFixedUpdate();
|
||||
if (playerController.IsTransitionLocked)
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
combatFeedback.EmitDashAfterimage(body.position, isStrongDash);
|
||||
Vector2 segmentStart = body.position;
|
||||
elapsed += Time.fixedDeltaTime;
|
||||
Vector2 segmentEnd = Vector2.Lerp(
|
||||
Vector2 intendedEnd = Vector2.Lerp(
|
||||
start,
|
||||
end,
|
||||
Mathf.Clamp01(elapsed / duration));
|
||||
Vector2 segmentEnd = playerController.ClampMovementToTerrain(
|
||||
segmentStart,
|
||||
intendedEnd);
|
||||
bool blocked = (segmentEnd - intendedEnd).sqrMagnitude > 0.0001f;
|
||||
Vector2 segment = segmentEnd - segmentStart;
|
||||
if (segment.sqrMagnitude > 0.000001f)
|
||||
{
|
||||
@@ -116,9 +127,14 @@ namespace BumpCombat.Player
|
||||
}
|
||||
|
||||
body.MovePosition(segmentEnd);
|
||||
finalPosition = segmentEnd;
|
||||
if (blocked)
|
||||
{
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
body.position = end;
|
||||
body.position = finalPosition;
|
||||
IsDashing = false;
|
||||
IsStrongDashing = false;
|
||||
animator.SetBool(IsDashingParameter, false);
|
||||
|
||||
@@ -16,6 +16,8 @@ namespace BumpCombat.Player
|
||||
Animator.StringToHash("Base Layer.BackBumpAttack");
|
||||
private static readonly int ArtifactUseState =
|
||||
Animator.StringToHash("Base Layer.ArtifactUse");
|
||||
private static readonly int DeathState =
|
||||
Animator.StringToHash("Base Layer.Death");
|
||||
|
||||
public const string BumpAttackStateName = "BumpAttack";
|
||||
public const string BackBumpAttackStateName = "BackBumpAttack";
|
||||
@@ -45,6 +47,7 @@ namespace BumpCombat.Player
|
||||
public bool IsDamageKnockbackActive { get; private set; }
|
||||
public bool IsHurtMovementLocked =>
|
||||
playerHealth != null && playerHealth.IsHurtMovementLocked;
|
||||
public bool IsTransitionLocked { get; private set; }
|
||||
public Vector2 PlayerClampHalfExtents
|
||||
{
|
||||
get
|
||||
@@ -95,6 +98,23 @@ namespace BumpCombat.Player
|
||||
return TryPlayCombatAnimation(ArtifactUseState, false);
|
||||
}
|
||||
|
||||
public float PlayDeathAnimation()
|
||||
{
|
||||
if (animator == null
|
||||
|| animator.runtimeAnimatorController == null
|
||||
|| !animator.isActiveAndEnabled
|
||||
|| !animator.HasState(0, DeathState))
|
||||
{
|
||||
return 0f;
|
||||
}
|
||||
|
||||
SetTransitionLocked(true);
|
||||
animator.SetBool(IsMovingParameter, false);
|
||||
animator.Play(DeathState, 0, 0f);
|
||||
animator.Update(0f);
|
||||
return Mathf.Max(0f, animator.GetCurrentAnimatorStateInfo(0).length);
|
||||
}
|
||||
|
||||
private bool TryPlayCombatAnimation(
|
||||
int stateHash,
|
||||
bool blockActiveArtifactUse)
|
||||
@@ -148,7 +168,7 @@ namespace BumpCombat.Player
|
||||
|
||||
private void Update()
|
||||
{
|
||||
if (!RunManager.GameplayInputEnabled || IsHurtMovementLocked)
|
||||
if (!RunManager.GameplayInputEnabled || IsHurtMovementLocked || IsTransitionLocked)
|
||||
{
|
||||
SetMoveInput(Vector2.zero);
|
||||
return;
|
||||
@@ -172,6 +192,13 @@ namespace BumpCombat.Player
|
||||
private void FixedUpdate()
|
||||
{
|
||||
AttackIntentVelocity = Vector2.zero;
|
||||
if (IsTransitionLocked)
|
||||
{
|
||||
if (body != null) body.linearVelocity = Vector2.zero;
|
||||
CurrentVelocity = Vector2.zero;
|
||||
return;
|
||||
}
|
||||
|
||||
if (dashController != null && dashController.IsDashing)
|
||||
{
|
||||
pendingImpactRecoil = Vector2.zero;
|
||||
@@ -244,6 +271,57 @@ namespace BumpCombat.Player
|
||||
- GameplayConstants.Current.Player.ArenaPadding.y));
|
||||
}
|
||||
|
||||
public Vector2 ClampMovementToTerrain(Vector2 start, Vector2 desiredPosition)
|
||||
{
|
||||
Vector2 desired = ClampToArena(desiredPosition);
|
||||
Vector2 delta = desired - start;
|
||||
float distance = delta.magnitude;
|
||||
if (distance <= 0.0001f)
|
||||
{
|
||||
return desired;
|
||||
}
|
||||
|
||||
Collider2D ownCollider = GetComponent<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)
|
||||
{
|
||||
if (body == null)
|
||||
@@ -338,5 +416,36 @@ namespace BumpCombat.Player
|
||||
spriteRenderer.flipX = input.x < 0f;
|
||||
}
|
||||
}
|
||||
|
||||
public void SetTransitionLocked(bool locked)
|
||||
{
|
||||
IsTransitionLocked = locked;
|
||||
if (locked)
|
||||
{
|
||||
moveInput = Vector2.zero;
|
||||
CurrentVelocity = Vector2.zero;
|
||||
AttackIntentVelocity = Vector2.zero;
|
||||
if (body != null)
|
||||
{
|
||||
body.linearVelocity = Vector2.zero;
|
||||
}
|
||||
if (animator != null && animator.isActiveAndEnabled)
|
||||
{
|
||||
animator.SetBool(IsMovingParameter, false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public void SetFacingDirection(Vector2 direction)
|
||||
{
|
||||
if (direction.sqrMagnitude > 0.0001f)
|
||||
{
|
||||
FacingDirection = direction.normalized;
|
||||
if (spriteRenderer != null && !Mathf.Approximately(direction.x, 0f))
|
||||
{
|
||||
spriteRenderer.flipX = direction.x < 0f;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ using System;
|
||||
using BumpCombat.Combat;
|
||||
using BumpCombat.Core;
|
||||
using BumpCombat.Constants;
|
||||
using BumpCombat.Defense;
|
||||
using UnityEngine;
|
||||
using UnityEngine.InputSystem;
|
||||
using UnityEngine.InputSystem.LowLevel;
|
||||
@@ -235,8 +236,29 @@ namespace BumpCombat.Player
|
||||
|
||||
if (CurrentHealth <= 0f)
|
||||
{
|
||||
float deathAnimationDuration = playerController.PlayDeathAnimation();
|
||||
OnDied?.Invoke();
|
||||
RunManager.Instance?.EndRun();
|
||||
RunManager runManager = RunManager.Instance;
|
||||
if (runManager != null
|
||||
&& runManager.IsDefensePrototypeRun
|
||||
&& DefensePrototypeController.Instance != null
|
||||
&& DefensePrototypeController.Instance.HandlePlayerDied())
|
||||
{
|
||||
return true;
|
||||
}
|
||||
if (runManager == null)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
if (deathAnimationDuration > 0f)
|
||||
{
|
||||
runManager.BeginGameOverPresentation(deathAnimationDuration);
|
||||
}
|
||||
else
|
||||
{
|
||||
runManager.EndRun();
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
@@ -247,6 +269,26 @@ namespace BumpCombat.Player
|
||||
return value >= 0f ? value : configuredValue;
|
||||
}
|
||||
|
||||
public bool ReviveForDefense(float invulnerabilitySeconds)
|
||||
{
|
||||
if (CurrentHealth > 0f)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
CurrentHealth = MaxHealth;
|
||||
invulnerableUntil = Time.time + Mathf.Max(0f, invulnerabilitySeconds);
|
||||
hurtMovementLockedUntil = 0f;
|
||||
guardUntil = 0f;
|
||||
guardCooldownUntil = 0f;
|
||||
activeGuardHasBlockedAttack = false;
|
||||
playerController.SetTransitionLocked(false);
|
||||
animator.Rebind();
|
||||
animator.Update(0f);
|
||||
OnHealthChanged?.Invoke(CurrentHealth, MaxHealth);
|
||||
return true;
|
||||
}
|
||||
|
||||
public void Heal(float amount)
|
||||
{
|
||||
if (amount <= 0f || CurrentHealth <= 0f)
|
||||
|
||||
@@ -3,6 +3,7 @@ using BumpCombat.Audio;
|
||||
using BumpCombat.Combat;
|
||||
using BumpCombat.Core;
|
||||
using BumpCombat.Player;
|
||||
using BumpCombat.Defense;
|
||||
using BumpCombat.Spawning;
|
||||
using BumpCombat.UI;
|
||||
using UnityEngine;
|
||||
@@ -30,6 +31,7 @@ namespace BumpCombat.Progression
|
||||
|
||||
private readonly Queue<RewardSource> pendingRewards = new();
|
||||
private readonly HashSet<RewardSource> queuedSources = new();
|
||||
private readonly PassiveRelicId[] shownRelics = new PassiveRelicId[DebugOptionCount];
|
||||
private readonly ActiveArtifactDefinition[] shownArtifacts =
|
||||
new ActiveArtifactDefinition[DebugOptionCount];
|
||||
private ActiveArtifactController artifacts;
|
||||
@@ -42,6 +44,9 @@ namespace BumpCombat.Progression
|
||||
private int shownCount;
|
||||
private int selectedIndex;
|
||||
private bool debugStartupSelection;
|
||||
private bool showingPassiveRelicReward;
|
||||
private bool pendingPassiveRelicReward;
|
||||
private PassiveRelicController passiveRelics;
|
||||
private int startupSelectionCount;
|
||||
private bool debugStartupSelectionPending;
|
||||
private int debugStartupSelectionEarliestFrame;
|
||||
@@ -148,7 +153,7 @@ namespace BumpCombat.Progression
|
||||
RunManager.Instance?.StartupArtifactSelectionCount ?? 3);
|
||||
}
|
||||
|
||||
private void QueueStartupSelection(int selectionCount)
|
||||
public void QueueStartupSelection(int selectionCount)
|
||||
{
|
||||
startupSelectionCount = Mathf.Max(0, selectionCount);
|
||||
debugStartupSelectionPending = true;
|
||||
@@ -157,15 +162,25 @@ namespace BumpCombat.Progression
|
||||
|
||||
private bool ChooseOption(int optionIndex)
|
||||
{
|
||||
if (showingPassiveRelicReward)
|
||||
{
|
||||
return ChoosePassiveRelicOption(optionIndex);
|
||||
}
|
||||
if (panel == null
|
||||
|| !panel.activeSelf
|
||||
|| optionIndex < 0
|
||||
|| optionIndex >= shownCount
|
||||
|| !artifacts.TryAddArtifact(shownArtifacts[optionIndex]))
|
||||
|| optionIndex >= shownCount)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!artifacts.TryAddArtifact(shownArtifacts[optionIndex]))
|
||||
{
|
||||
Debug.LogWarning($"[ArtifactRewardController] Could not add artifact {shownArtifacts[optionIndex]?.name}. Closing selection to prevent lock.");
|
||||
CloseSelection();
|
||||
return false;
|
||||
}
|
||||
|
||||
BumpCombatAudioService.Instance?.PlayUi("ui_confirm");
|
||||
if (artifacts.OwnedArtifactCount == 1)
|
||||
{
|
||||
@@ -201,14 +216,55 @@ namespace BumpCombat.Progression
|
||||
return true;
|
||||
}
|
||||
|
||||
public void QueuePassiveRelicReward()
|
||||
{
|
||||
pendingPassiveRelicReward = true;
|
||||
TryOpenPendingReward();
|
||||
}
|
||||
|
||||
private bool ChoosePassiveRelicOption(int optionIndex)
|
||||
{
|
||||
if (panel == null
|
||||
|| !panel.activeSelf
|
||||
|| optionIndex < 0
|
||||
|| optionIndex >= shownCount
|
||||
|| passiveRelics == null
|
||||
|| !passiveRelics.TryAcquire(shownRelics[optionIndex]))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
showingPassiveRelicReward = false;
|
||||
pendingPassiveRelicReward = false;
|
||||
BumpCombatAudioService.Instance?.PlayUi("ui_confirm");
|
||||
DefensePrototypeController.Instance?.ResolvePassiveRelicChoice();
|
||||
CloseSelection();
|
||||
return true;
|
||||
}
|
||||
private bool OpenStartupSelection()
|
||||
{
|
||||
if (artifacts == null)
|
||||
{
|
||||
artifacts = FindAnyObjectByType<ActiveArtifactController>();
|
||||
}
|
||||
|
||||
if (panel == null)
|
||||
{
|
||||
CreatePanel();
|
||||
}
|
||||
|
||||
if (RunManager.Instance == null
|
||||
|| !RunManager.Instance.TryOpenSelection(this))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (artifacts == null)
|
||||
{
|
||||
RunManager.Instance.CloseSelection(this);
|
||||
return false;
|
||||
}
|
||||
|
||||
debugSelectionsRequired = Mathf.Min(
|
||||
startupSelectionCount,
|
||||
artifacts.MaxOwnedArtifacts - artifacts.OwnedArtifactCount);
|
||||
@@ -243,6 +299,26 @@ namespace BumpCombat.Progression
|
||||
}
|
||||
|
||||
ArtifactColor requiredColor = GetStartupColor(selectedCount);
|
||||
if (artifacts.GetOwnedArtifactColorCount(requiredColor) >= artifacts.MaxArtifactsPerColor)
|
||||
{
|
||||
bool foundAvailableColor = false;
|
||||
for (int c = 0; c < StartupColorCount; c++)
|
||||
{
|
||||
ArtifactColor candidateColor = GetStartupColor(c);
|
||||
if (artifacts.GetOwnedArtifactColorCount(candidateColor) < artifacts.MaxArtifactsPerColor)
|
||||
{
|
||||
requiredColor = candidateColor;
|
||||
foundAvailableColor = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!foundAvailableColor)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
for (int i = 0;
|
||||
i < artifacts.CatalogCount && shownCount < DebugOptionCount;
|
||||
i++)
|
||||
@@ -284,6 +360,12 @@ namespace BumpCombat.Progression
|
||||
|
||||
private void HandleEventEnemyDefeated(RunTimedEvent timedEvent)
|
||||
{
|
||||
if (Stage.StageManager.IsStageModeActive)
|
||||
{
|
||||
// In stage mode, StageManager.HandleMajorBossDefeated coordinates rewards
|
||||
return;
|
||||
}
|
||||
|
||||
if (timedEvent == RunTimedEvent.Elite)
|
||||
{
|
||||
if (RunManager.Instance?.IsProductionRun == true)
|
||||
@@ -311,8 +393,41 @@ namespace BumpCombat.Progression
|
||||
}
|
||||
}
|
||||
|
||||
public void QueueColorReward(ArtifactColor color)
|
||||
{
|
||||
if (artifacts == null)
|
||||
{
|
||||
artifacts = FindAnyObjectByType<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)
|
||||
{
|
||||
if (artifacts == null)
|
||||
{
|
||||
artifacts = FindAnyObjectByType<ActiveArtifactController>();
|
||||
}
|
||||
|
||||
if (artifacts == null
|
||||
|| artifacts.OwnedArtifactCount >= artifacts.MaxOwnedArtifacts)
|
||||
{
|
||||
@@ -330,8 +445,19 @@ namespace BumpCombat.Progression
|
||||
|
||||
private void TryOpenPendingReward()
|
||||
{
|
||||
if (pendingRewards.Count == 0 || artifacts == null)
|
||||
if (artifacts == null)
|
||||
{
|
||||
artifacts = FindAnyObjectByType<ActiveArtifactController>();
|
||||
}
|
||||
|
||||
if (artifacts == null || (pendingRewards.Count == 0 && !pendingPassiveRelicReward))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (pendingPassiveRelicReward)
|
||||
{
|
||||
TryOpenPassiveRelicReward();
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -341,6 +467,11 @@ namespace BumpCombat.Progression
|
||||
return;
|
||||
}
|
||||
|
||||
if (panel == null)
|
||||
{
|
||||
CreatePanel();
|
||||
}
|
||||
|
||||
if (RunManager.Instance == null
|
||||
|| !RunManager.Instance.TryOpenSelection(this))
|
||||
{
|
||||
@@ -359,7 +490,8 @@ namespace BumpCombat.Progression
|
||||
if (definition != null
|
||||
&& !artifacts.OwnsArtifact(definition)
|
||||
&& (!hasRequiredColor
|
||||
|| definition.ArtifactColor == requiredColor))
|
||||
|| definition.ArtifactColor == requiredColor)
|
||||
&& artifacts.GetOwnedArtifactColorCount(definition.ArtifactColor) < artifacts.MaxArtifactsPerColor)
|
||||
{
|
||||
candidates.Add(definition);
|
||||
}
|
||||
@@ -384,16 +516,65 @@ namespace BumpCombat.Progression
|
||||
|
||||
titleText.text = source switch
|
||||
{
|
||||
RewardSource.Elite => "ELITE REWARD - CHOOSE ARTIFACT",
|
||||
RewardSource.Elite => "ELITE REWARD - CHOOSE RED ARTIFACT",
|
||||
RewardSource.EliteRed => "ELITE REWARD - CHOOSE RED ARTIFACT",
|
||||
RewardSource.EliteBlue => "ELITE REWARD - CHOOSE BLUE ARTIFACT",
|
||||
RewardSource.MidBoss => "MID BOSS REWARD - CHOOSE ARTIFACT",
|
||||
RewardSource.MidBoss => "MID BOSS REWARD - CHOOSE BLUE ARTIFACT",
|
||||
_ => "CHOOSE ARTIFACT",
|
||||
};
|
||||
selectedIndex = 0;
|
||||
ShowOptions();
|
||||
}
|
||||
|
||||
private void TryOpenPassiveRelicReward()
|
||||
{
|
||||
passiveRelics = FindAnyObjectByType<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)
|
||||
{
|
||||
if (!isOpen && (panel == null || !panel.activeSelf))
|
||||
@@ -450,6 +631,13 @@ namespace BumpCombat.Progression
|
||||
continue;
|
||||
}
|
||||
|
||||
if (showingPassiveRelicReward)
|
||||
{
|
||||
optionIconImages[i].gameObject.SetActive(false);
|
||||
optionTexts[i].text = GetPassiveRelicName(shownRelics[i]);
|
||||
continue;
|
||||
}
|
||||
|
||||
ActiveArtifactDefinition definition = shownArtifacts[i];
|
||||
Sprite icon = PresentationUiStyle.GetArtifactIcon(definition);
|
||||
optionIconImages[i].sprite = icon;
|
||||
@@ -518,9 +706,11 @@ namespace BumpCombat.Progression
|
||||
{
|
||||
switch (source)
|
||||
{
|
||||
case RewardSource.Elite:
|
||||
case RewardSource.EliteRed:
|
||||
color = ArtifactColor.Red;
|
||||
return true;
|
||||
case RewardSource.MidBoss:
|
||||
case RewardSource.EliteBlue:
|
||||
color = ArtifactColor.Blue;
|
||||
return true;
|
||||
@@ -605,6 +795,7 @@ namespace BumpCombat.Progression
|
||||
#if UNITY_EDITOR
|
||||
public int DebugShownCount => shownCount;
|
||||
public bool DebugIsSelectionVisible => panel != null && panel.activeSelf;
|
||||
public int DebugShownPassiveRelicCount => showingPassiveRelicReward ? shownCount : 0;
|
||||
|
||||
public ActiveArtifactDefinition DebugGetShownArtifact(int index)
|
||||
{
|
||||
|
||||
@@ -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.
|
||||
private int nextEliteScheduleOrdinal;
|
||||
private int nextEliteSpawnOrdinal;
|
||||
private static int nextEncounterGroupId;
|
||||
private const int SummonSpawnAngleSteps = 32;
|
||||
|
||||
private static RunConstants Tuning => GameplayConstants.Current.Run;
|
||||
@@ -148,6 +149,477 @@ namespace BumpCombat.Spawning
|
||||
return HasTimedEventEnemy(timedEvent);
|
||||
}
|
||||
|
||||
public void SpawnZoneEncounter(BumpCombat.Stage.ZoneInstance zone)
|
||||
{
|
||||
if (zone == null) return;
|
||||
EnsureTuningLoaded();
|
||||
Vector2 center = zone.ZoneCenter;
|
||||
Vector2 extents = zone.PlayableHalfExtents;
|
||||
BumpCombat.Stage.RoomPreset preset = zone.Preset;
|
||||
|
||||
if (zone.NodeType == BumpCombat.Stage.StageNodeType.Elite)
|
||||
{
|
||||
Vector2 spawnPos = center;
|
||||
if (preset != null && preset.SpawnPoints.Count > 0)
|
||||
{
|
||||
var pt = preset.SpawnPoints.Find(p => p.Role == BumpCombat.Stage.SpawnRole.Elite);
|
||||
if (pt.Role == BumpCombat.Stage.SpawnRole.Elite)
|
||||
{
|
||||
spawnPos = preset.CellToWorldPosition(center, pt.CellPosition);
|
||||
}
|
||||
}
|
||||
|
||||
RunEventEnemyTuning tuning = GetEliteTuning(0);
|
||||
EnemyController prefab = GetEventPrefab(RunTimedEvent.Elite, tuning.PrefabKind);
|
||||
if (prefab != null)
|
||||
{
|
||||
EnemyController enemy = Instantiate(prefab, spawnPos, Quaternion.identity);
|
||||
enemy.name = "Stage 1 Elite";
|
||||
enemy.ConfigureRunEventEnemy(RunTimedEvent.Elite, tuning);
|
||||
enemy.OnDied += HandleEventEnemyDied;
|
||||
GetEventList(RunTimedEvent.Elite).Add(enemy);
|
||||
zone.RegisterSpawnedEnemy(enemy);
|
||||
OnEventEnemySpawned?.Invoke(RunTimedEvent.Elite, enemy);
|
||||
}
|
||||
|
||||
SpawnPresetSupportMinions(zone, center, preset);
|
||||
return;
|
||||
}
|
||||
|
||||
if (zone.NodeType == BumpCombat.Stage.StageNodeType.MidBoss)
|
||||
{
|
||||
Vector2 spawnPos = center;
|
||||
if (preset != null && preset.SpawnPoints.Count > 0)
|
||||
{
|
||||
var pt = preset.SpawnPoints.Find(p => p.Role == BumpCombat.Stage.SpawnRole.MidBoss);
|
||||
if (pt.Role == BumpCombat.Stage.SpawnRole.MidBoss)
|
||||
{
|
||||
spawnPos = preset.CellToWorldPosition(center, pt.CellPosition);
|
||||
}
|
||||
}
|
||||
|
||||
RunEventEnemyTuning tuning = GetEventEnemyTuning(RunTimedEvent.MidBoss);
|
||||
EnemyController prefab = GetEventPrefab(RunTimedEvent.MidBoss, tuning.PrefabKind);
|
||||
if (prefab != null)
|
||||
{
|
||||
EnemyController enemy = Instantiate(prefab, spawnPos, Quaternion.identity);
|
||||
enemy.name = "Stage 2 MidBoss";
|
||||
enemy.ConfigureRunEventEnemy(RunTimedEvent.MidBoss, tuning);
|
||||
enemy.OnDied += HandleEventEnemyDied;
|
||||
GetEventList(RunTimedEvent.MidBoss).Add(enemy);
|
||||
zone.RegisterSpawnedEnemy(enemy);
|
||||
OnEventEnemySpawned?.Invoke(RunTimedEvent.MidBoss, enemy);
|
||||
}
|
||||
|
||||
SpawnPresetSupportMinions(zone, center, preset);
|
||||
return;
|
||||
}
|
||||
|
||||
if (zone.NodeType == BumpCombat.Stage.StageNodeType.FinalBoss)
|
||||
{
|
||||
Vector2 spawnPos = center;
|
||||
if (preset != null && preset.SpawnPoints.Count > 0)
|
||||
{
|
||||
var pt = preset.SpawnPoints.Find(p => p.Role == BumpCombat.Stage.SpawnRole.FinalBoss);
|
||||
if (pt.Role == BumpCombat.Stage.SpawnRole.FinalBoss)
|
||||
{
|
||||
spawnPos = preset.CellToWorldPosition(center, pt.CellPosition);
|
||||
}
|
||||
}
|
||||
|
||||
RunEventEnemyTuning tuning = GetEventEnemyTuning(RunTimedEvent.FinalBoss);
|
||||
EnemyController prefab = GetEventPrefab(RunTimedEvent.FinalBoss, tuning.PrefabKind);
|
||||
if (prefab != null)
|
||||
{
|
||||
EnemyController enemy = Instantiate(prefab, spawnPos, Quaternion.identity);
|
||||
enemy.name = "Stage 3 FinalBoss";
|
||||
enemy.ConfigureRunEventEnemy(RunTimedEvent.FinalBoss, tuning);
|
||||
enemy.OnDied += HandleEventEnemyDied;
|
||||
GetEventList(RunTimedEvent.FinalBoss).Add(enemy);
|
||||
zone.RegisterSpawnedEnemy(enemy);
|
||||
OnEventEnemySpawned?.Invoke(RunTimedEvent.FinalBoss, enemy);
|
||||
}
|
||||
|
||||
SpawnPresetSupportMinions(zone, center, preset);
|
||||
return;
|
||||
}
|
||||
|
||||
if (zone.NodeType == BumpCombat.Stage.StageNodeType.Combat || zone.NodeType == BumpCombat.Stage.StageNodeType.Fork)
|
||||
{
|
||||
if (preset != null && preset.EncounterPool != null && preset.EncounterPool.Count > 0)
|
||||
{
|
||||
SpawnPresetEncounterPool(zone, center, preset);
|
||||
return;
|
||||
}
|
||||
|
||||
if (preset != null && preset.SpawnPoints.Count > 0)
|
||||
{
|
||||
SpawnPresetAnchorsFallback(zone, center, preset);
|
||||
return;
|
||||
}
|
||||
|
||||
// Fallback: Random area spawn if no preset anchors
|
||||
if (enemyPrefabs == null || enemyPrefabs.Length == 0) return;
|
||||
int count = UnityEngine.Random.Range(3, 6);
|
||||
for (int i = 0; i < count; i++)
|
||||
{
|
||||
EnemyRole role = (i % 2 == 0) ? EnemyRole.Normal : EnemyRole.Crowd;
|
||||
EnemyController prefab = GetRandomPrefab(role) ?? enemyPrefabs[0];
|
||||
Vector2 offset = new Vector2(
|
||||
UnityEngine.Random.Range(-extents.x * 0.6f, extents.x * 0.6f),
|
||||
UnityEngine.Random.Range(-extents.y * 0.6f, extents.y * 0.6f));
|
||||
EnemyController enemy = Instantiate(prefab, center + offset, Quaternion.identity);
|
||||
enemy.transform.SetParent(zone.transform, true);
|
||||
zone.RegisterSpawnedEnemy(enemy);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void SpawnPresetEncounterPool(BumpCombat.Stage.ZoneInstance zone, Vector2 center, BumpCombat.Stage.RoomPreset preset)
|
||||
{
|
||||
if (preset.EncounterPool == null || preset.EncounterPool.Count == 0)
|
||||
{
|
||||
SpawnPresetAnchorsFallback(zone, center, preset);
|
||||
return;
|
||||
}
|
||||
|
||||
// Select encounter (default Encounter A for S1-A first verification)
|
||||
BumpCombat.Stage.RoomEncounter encounter = preset.EncounterPool[0];
|
||||
if (encounter.Items == null || encounter.Items.Count == 0)
|
||||
{
|
||||
SpawnPresetAnchorsFallback(zone, center, preset);
|
||||
return;
|
||||
}
|
||||
|
||||
PlayerController player = FindAnyObjectByType<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()
|
||||
{
|
||||
if (RunManager.Instance != null)
|
||||
@@ -175,6 +647,11 @@ namespace BumpCombat.Spawning
|
||||
{
|
||||
return;
|
||||
}
|
||||
if (BumpCombat.Stage.StageManager.IsStageModeActive
|
||||
|| RunManager.Instance?.IsDefensePrototypeRun == true)
|
||||
{
|
||||
return;
|
||||
}
|
||||
TrySpawnPendingEvents();
|
||||
float elapsedTime = RunManager.Instance?.ProgressionTime ?? 0f;
|
||||
bool productionRun = RunManager.Instance?.IsProductionRun == true;
|
||||
@@ -732,11 +1209,21 @@ namespace BumpCombat.Spawning
|
||||
if (role == RunTimedEvent.MidBoss)
|
||||
{
|
||||
midBossDefeated = true;
|
||||
RunManager.Instance?.UnlockArtifactCharge();
|
||||
BumpCombat.Stage.StageManager.Instance?.HandleMajorBossDefeated(
|
||||
BumpCombat.Stage.StageNodeType.MidBoss,
|
||||
enemy.GetComponentInParent<BumpCombat.Stage.ZoneInstance>());
|
||||
}
|
||||
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);
|
||||
|
||||
@@ -837,6 +1324,18 @@ namespace BumpCombat.Spawning
|
||||
return null;
|
||||
}
|
||||
|
||||
public EnemyController SpawnDefenseSkeleton(Vector2 position)
|
||||
{
|
||||
EnsureTuningLoaded();
|
||||
EnemyController prefab = GetPrefab(EnemyKind.Skeleton);
|
||||
if (prefab == null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return Instantiate(prefab, position, Quaternion.identity);
|
||||
}
|
||||
|
||||
private EnemyController GetPrefab(EnemyKind kind)
|
||||
{
|
||||
if (enemyPrefabs == null)
|
||||
@@ -1254,11 +1753,20 @@ namespace BumpCombat.Spawning
|
||||
return false;
|
||||
}
|
||||
|
||||
EnemyRole role = crowd ? EnemyRole.Crowd : EnemyRole.Normal;
|
||||
EnemyController[] candidates = new EnemyController[count];
|
||||
for (int i = 0; i < count; i++)
|
||||
{
|
||||
candidates[i] = GetRandomPrefab(role);
|
||||
if (crowd)
|
||||
{
|
||||
EnemyKind preferredKind = i % 2 == 0 ? EnemyKind.Bat : EnemyKind.Slime;
|
||||
EnemyKind fallbackKind = preferredKind == EnemyKind.Bat ? EnemyKind.Slime : EnemyKind.Bat;
|
||||
candidates[i] = GetPrefab(preferredKind) ?? GetPrefab(fallbackKind);
|
||||
}
|
||||
else
|
||||
{
|
||||
candidates[i] = GetRandomPrefab(EnemyRole.Normal);
|
||||
}
|
||||
|
||||
if (candidates[i] == null)
|
||||
{
|
||||
return false;
|
||||
@@ -1405,6 +1913,14 @@ namespace BumpCombat.Spawning
|
||||
{
|
||||
summonedEnemies.Add(enemy);
|
||||
enemy.OnDied += HandleSummonedEnemyDied;
|
||||
|
||||
EnemyController owner = enemy.SummonOwner;
|
||||
BumpCombat.Stage.ZoneInstance ownerZone =
|
||||
owner != null ? owner.GetComponentInParent<BumpCombat.Stage.ZoneInstance>() : null;
|
||||
if (ownerZone != null)
|
||||
{
|
||||
ownerZone.RegisterSpawnedEnemy(enemy);
|
||||
}
|
||||
}
|
||||
|
||||
private void HandleSummonedEnemyDied(EnemyController enemy)
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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,
|
||||
TextAnchor.MiddleLeft,
|
||||
Vector2.zero,
|
||||
new Vector2(220f, 110f));
|
||||
new Vector2(240f, 220f));
|
||||
RectTransform hintRect = hintText.rectTransform;
|
||||
hintRect.anchorMin = new Vector2(0f, 1f);
|
||||
hintRect.anchorMax = new Vector2(0f, 1f);
|
||||
hintRect.pivot = new Vector2(0f, 1f);
|
||||
hintRect.anchoredPosition = new Vector2(24f, -250f);
|
||||
hintRect.anchoredPosition = new Vector2(24f, -230f);
|
||||
hintText.supportRichText = true;
|
||||
hintText.resizeTextForBestFit = false;
|
||||
hintText.horizontalOverflow = HorizontalWrapMode.Wrap;
|
||||
hintText.verticalOverflow = VerticalWrapMode.Overflow;
|
||||
hintText.text = "<size=36><color=#FFD675><b>TAB</b></color></size>\n"
|
||||
+ "<size=24>(미션 / 업적)</size>";
|
||||
hintText.text = "<size=32><color=#FFD675><b>TAB</b></color></size> <size=20>(미션/업적)</size>\n\n"
|
||||
+ "<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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -53,13 +53,17 @@ namespace BumpCombat.UI
|
||||
private Button[] debugSpawnButtons = System.Array.Empty<Button>();
|
||||
private GameObject minimapPanel;
|
||||
private RectTransform minimapMapRect;
|
||||
private RectTransform minimapTerrainRoot;
|
||||
private RectTransform minimapMarkerRoot;
|
||||
private RectTransform minimapViewportRoot;
|
||||
private Image minimapPlayerMarker;
|
||||
private Image[] minimapViewportEdges = System.Array.Empty<Image>();
|
||||
private readonly List<Image> minimapEnemyMarkers = new();
|
||||
private readonly Dictionary<Vector2Int, Image> minimapTerrainCells = new();
|
||||
private EnemyController[] minimapEnemies = System.Array.Empty<EnemyController>();
|
||||
private float minimapEnemyRefreshElapsed;
|
||||
private Stage.ZoneInstance minimapTerrainZone;
|
||||
private int minimapTerrainVisibilityRevision = -1;
|
||||
private ArenaBounds arenaBounds;
|
||||
// The panel sprite has transparent source padding. At multiplier 1,
|
||||
// 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 readonly Stack<Text> damagePopupPool = 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 damagePopupLifecycleVersion;
|
||||
private float compactHudFadeElapsed;
|
||||
private int lastNextEventSecond = -1;
|
||||
private bool lastNextEventUsesDebugTimes;
|
||||
private bool lastNextEventIsProductionRun;
|
||||
private static readonly Color PlayerDamagePopupColor =
|
||||
new(244f / 255f, 117f / 255f, 112f / 255f, 1f);
|
||||
private static readonly Color GuardReadyColor = new Color32(255, 205, 92, 255);
|
||||
@@ -741,61 +732,25 @@ namespace BumpCombat.UI
|
||||
new Vector2(0f, -100f),
|
||||
new Vector2(420f, 28f));
|
||||
ConfigureCompactHudText(eventText, 14, 18);
|
||||
eventText.gameObject.SetActive(true);
|
||||
eventText.gameObject.SetActive(false);
|
||||
UpdateNextEventHud();
|
||||
}
|
||||
|
||||
private void UpdateNextEventHud()
|
||||
{
|
||||
if (eventText == null || runManager == null || showingTimedEventMessage)
|
||||
if (eventText == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
int elapsedSecond = Mathf.FloorToInt(runManager.ElapsedTime);
|
||||
if (elapsedSecond == lastNextEventSecond
|
||||
&& runManager.UseDebugEventTimes == lastNextEventUsesDebugTimes
|
||||
&& runManager.IsProductionRun == lastNextEventIsProductionRun)
|
||||
if (!showingTimedEventMessage)
|
||||
{
|
||||
if (eventText.gameObject.activeSelf)
|
||||
{
|
||||
eventText.gameObject.SetActive(false);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
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;
|
||||
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;
|
||||
}
|
||||
|
||||
int seconds = Mathf.FloorToInt(nextTime);
|
||||
eventText.text =
|
||||
$"{nextLabel} {seconds / 60:00}:{seconds % 60:00}";
|
||||
}
|
||||
|
||||
private void LayoutDebugText()
|
||||
@@ -844,6 +799,20 @@ namespace BumpCombat.UI
|
||||
{
|
||||
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;
|
||||
if (!TryGetArtifactPopupColor(result, out popupColor))
|
||||
{
|
||||
@@ -1271,7 +1240,7 @@ namespace BumpCombat.UI
|
||||
RunTimedEvent.MidBoss,
|
||||
RunTimedEvent.FinalBoss,
|
||||
};
|
||||
string[] labels = { "엘리트 출현", "중간보스 출현", "보스 출현" };
|
||||
string[] labels = { "엘리트 출현", "중간보스 출현", "보스방 이동" };
|
||||
debugSpawnButtons = new Button[events.Length];
|
||||
for (int i = 0; i < events.Length; i++)
|
||||
{
|
||||
@@ -1296,6 +1265,19 @@ namespace BumpCombat.UI
|
||||
Button button = buttonObject.GetComponent<Button>();
|
||||
button.targetGraphic = image;
|
||||
PresentationUiStyle.ApplyButton(button);
|
||||
if (i == 2)
|
||||
{
|
||||
button.onClick.AddListener(() =>
|
||||
{
|
||||
if (Stage.StageManager.Instance != null)
|
||||
{
|
||||
Stage.StageManager.Instance.CheatTeleportToBossRoom();
|
||||
}
|
||||
UpdateDebugSpawnHud();
|
||||
});
|
||||
}
|
||||
else
|
||||
{
|
||||
RunTimedEvent requestedEvent = events[i];
|
||||
button.onClick.AddListener(() =>
|
||||
{
|
||||
@@ -1306,6 +1288,7 @@ namespace BumpCombat.UI
|
||||
}
|
||||
UpdateDebugSpawnHud();
|
||||
});
|
||||
}
|
||||
CreateRuntimeText(
|
||||
buttonObject.transform,
|
||||
"Label",
|
||||
@@ -1327,7 +1310,7 @@ namespace BumpCombat.UI
|
||||
|
||||
bool visible = runManager != null && runManager.UseDebugArtifactSelection;
|
||||
debugSpawnPanel.SetActive(visible);
|
||||
if (!visible || spawnDirector == null)
|
||||
if (!visible)
|
||||
{
|
||||
return;
|
||||
}
|
||||
@@ -1338,10 +1321,19 @@ namespace BumpCombat.UI
|
||||
RunTimedEvent.MidBoss,
|
||||
RunTimedEvent.FinalBoss,
|
||||
};
|
||||
bool inputEnabled = RunManager.GameplayInputEnabled;
|
||||
for (int i = 0; i < debugSpawnButtons.Length; i++)
|
||||
{
|
||||
if (i == 2)
|
||||
{
|
||||
debugSpawnButtons[i].interactable =
|
||||
spawnDirector.CanDebugSpawnEventEnemy(events[i]);
|
||||
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));
|
||||
Image minimapFrame = minimapPanel.GetComponent<Image>();
|
||||
PresentationUiStyle.ApplyPanel(minimapFrame);
|
||||
minimapFrame.pixelsPerUnitMultiplier = 1f;
|
||||
minimapFrame.pixelsPerUnitMultiplier = PresentationUiStyle.ThreePixelBorderMultiplier;
|
||||
minimapFrame.fillCenter = false;
|
||||
|
||||
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.AddComponent<RectMask2D>();
|
||||
|
||||
minimapTerrainRoot = CreateMinimapLayer(map.transform, "Minimap Terrain");
|
||||
minimapMarkerRoot = CreateMinimapLayer(map.transform, "Markers");
|
||||
minimapViewportRoot = CreateMinimapLayer(map.transform, "Minimap Camera Viewport");
|
||||
minimapMarkerRoot.SetAsLastSibling();
|
||||
@@ -1453,6 +1446,8 @@ namespace BumpCombat.UI
|
||||
return;
|
||||
}
|
||||
|
||||
UpdateMinimapTerrain();
|
||||
|
||||
SetMinimapMarkerPosition(
|
||||
minimapPlayerMarker,
|
||||
playerHealth != null ? playerHealth.transform.position : Vector3.zero,
|
||||
@@ -1469,6 +1464,9 @@ namespace BumpCombat.UI
|
||||
}
|
||||
|
||||
int markerIndex = 0;
|
||||
Stage.ZoneInstance visibilityZone = Stage.StageManager.Instance != null
|
||||
? Stage.StageManager.Instance.CurrentZone
|
||||
: null;
|
||||
for (int i = 0; i < minimapEnemies.Length; i++)
|
||||
{
|
||||
EnemyController enemy = minimapEnemies[i];
|
||||
@@ -1476,6 +1474,16 @@ namespace BumpCombat.UI
|
||||
{
|
||||
continue;
|
||||
}
|
||||
if (worldCamera == null
|
||||
|| !arenaBounds.IsInsideCameraVisible(worldCamera, enemy.GroundAnchorPosition))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
if (visibilityZone != null
|
||||
&& !visibilityZone.IsWorldPositionVisible(enemy.GroundAnchorPosition))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
Image marker = GetMinimapEnemyMarker(markerIndex++);
|
||||
marker.gameObject.SetActive(true);
|
||||
@@ -1499,6 +1507,132 @@ namespace BumpCombat.UI
|
||||
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)
|
||||
{
|
||||
while (minimapEnemyMarkers.Count <= index)
|
||||
@@ -1544,9 +1678,11 @@ namespace BumpCombat.UI
|
||||
return;
|
||||
}
|
||||
|
||||
Vector2 center = arenaBounds != null ? arenaBounds.ZoneCenter : Vector2.zero;
|
||||
Vector2 localPos = worldPosition - center;
|
||||
Vector2 normalized = new(
|
||||
Mathf.Clamp01((worldPosition.x + extents.x) / (extents.x * 2f)),
|
||||
Mathf.Clamp01((worldPosition.y + extents.y) / (extents.y * 2f)));
|
||||
Mathf.Clamp01((localPos.x + extents.x) / (extents.x * 2f)),
|
||||
Mathf.Clamp01((localPos.y + extents.y) / (extents.y * 2f)));
|
||||
Vector2 halfMarker = marker.rectTransform.rect.size * 0.5f;
|
||||
float halfMapWidth = mapSize.x * 0.5f;
|
||||
float halfMapHeight = mapSize.y * 0.5f;
|
||||
@@ -1575,8 +1711,9 @@ namespace BumpCombat.UI
|
||||
|
||||
Vector2 mapSize = minimapMapRect.rect.size;
|
||||
Bounds visible = arenaBounds.GetCameraVisibleBounds(worldCamera);
|
||||
Vector2 min = WorldToMinimapPosition(visible.min, extents, mapSize);
|
||||
Vector2 max = WorldToMinimapPosition(visible.max, extents, mapSize);
|
||||
Vector2 zoneCenter = arenaBounds != null ? arenaBounds.ZoneCenter : Vector2.zero;
|
||||
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 height = Mathf.Max(2f, max.y - min.y);
|
||||
Vector2 center = (min + max) * 0.5f;
|
||||
@@ -1617,11 +1754,13 @@ namespace BumpCombat.UI
|
||||
private static Vector2 WorldToMinimapPosition(
|
||||
Vector2 worldPosition,
|
||||
Vector2 extents,
|
||||
Vector2 mapSize)
|
||||
Vector2 mapSize,
|
||||
Vector2 center = default)
|
||||
{
|
||||
Vector2 localPos = worldPosition - center;
|
||||
Vector2 normalized = new(
|
||||
Mathf.Clamp01((worldPosition.x + extents.x) / (extents.x * 2f)),
|
||||
Mathf.Clamp01((worldPosition.y + extents.y) / (extents.y * 2f)));
|
||||
Mathf.Clamp01((localPos.x + extents.x) / (extents.x * 2f)),
|
||||
Mathf.Clamp01((localPos.y + extents.y) / (extents.y * 2f)));
|
||||
return new Vector2(
|
||||
(normalized.x - 0.5f) * mapSize.x,
|
||||
(normalized.y - 0.5f) * mapSize.y);
|
||||
@@ -1705,12 +1844,10 @@ namespace BumpCombat.UI
|
||||
private IEnumerator ShowEventMessage(string message)
|
||||
{
|
||||
showingTimedEventMessage = true;
|
||||
lastNextEventSecond = -1;
|
||||
eventText.text = message;
|
||||
eventText.gameObject.SetActive(true);
|
||||
yield return new WaitForSecondsRealtime(3f);
|
||||
showingTimedEventMessage = false;
|
||||
lastNextEventSecond = -1;
|
||||
UpdateNextEventHud();
|
||||
}
|
||||
|
||||
@@ -1760,7 +1897,7 @@ namespace BumpCombat.UI
|
||||
"Presentation/Fonts/NanumGothic-Regular";
|
||||
private const string NeoFontResource = "Presentation/Fonts/neodgm";
|
||||
private const string GalmuriFontResource = "Presentation/Fonts/Galmuri9";
|
||||
private const float ThreePixelBorderMultiplier = 1f / 3f;
|
||||
internal const float ThreePixelBorderMultiplier = 1f / 3f;
|
||||
|
||||
private static readonly Color PanelFallback =
|
||||
new(24f / 255f, 27f / 255f, 39f / 255f, 0.94f);
|
||||
|
||||
@@ -456,6 +456,11 @@ namespace BumpCombat.UI
|
||||
BeginRun(RunMode.Production);
|
||||
}
|
||||
|
||||
private void BeginDefensePrototypeRun()
|
||||
{
|
||||
BeginRun(RunMode.DefensePrototype);
|
||||
}
|
||||
|
||||
private void OpenTitleModes()
|
||||
{
|
||||
SetScreen(Screen.TitleModes);
|
||||
@@ -469,6 +474,10 @@ namespace BumpCombat.UI
|
||||
ShowButton(index++, "개발용 시작", BeginDevelopmentRun);
|
||||
}
|
||||
ShowButton(index++, "기본 모드", BeginProductionRun);
|
||||
if (IsDevelopmentStartVisible(Application.isEditor, Debug.isDebugBuild))
|
||||
{
|
||||
ShowButton(index++, "방어전 테스트", BeginDefensePrototypeRun);
|
||||
}
|
||||
ShowDisabledButton(index++, "챌린지 모드(준비 중)");
|
||||
ShowDisabledButton(index++, "하드코어 모드(준비 중)");
|
||||
ShowButton(index, "뒤로", ReturnToTitleMenu);
|
||||
@@ -650,7 +659,7 @@ namespace BumpCombat.UI
|
||||
inputHelpText.alignment = TextAnchor.MiddleLeft;
|
||||
inputHelpText.color = new Color32(200, 208, 218, 255);
|
||||
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.gameObject.SetActive(false);
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user