feat: 드로그 공통 보스 BT 프레임워크 정리

- 보스 공통 전투 컨텍스트와 패턴 역할 기반 BT 액션을 추가
- 드로그 패턴 선택을 다운 추가타, 도약, 기본 및 보조 패턴 우선순위 브랜치로 이관
- BT_Drog authoring 그래프를 공통 구조에 맞게 재구성
- 드로그 전용 BT 헬퍼를 정리하고 공통 베이스 액션으로 통합
- 플레이 검증으로 도약, 기본 패턴, 내려찍기, 다운 추가타 루프를 확인
This commit is contained in:
2026-03-23 16:02:45 +09:00
parent 74ea3e57b8
commit 1fec139e81
65 changed files with 4514 additions and 2374 deletions

View File

@@ -0,0 +1,249 @@
using System;
using Colosseum;
using Colosseum.AI;
using Colosseum.Combat;
using Colosseum.Enemy;
using Colosseum.Player;
using Colosseum.Skills;
using Unity.Behavior;
using Unity.Properties;
using UnityEngine;
using Action = Unity.Behavior.Action;
/// <summary>
/// 보스 공통 패턴 실행용 Behavior Action 기반 클래스입니다.
/// </summary>
[Serializable, GeneratePropertyBag]
public abstract partial class BossPatternActionBase : Action
{
[SerializeReference]
public BlackboardVariable<GameObject> Target;
protected BossEnemy bossEnemy;
protected EnemyBase enemyBase;
protected SkillController skillController;
protected BossCombatBehaviorContext combatBehaviorContext;
protected UnityEngine.AI.NavMeshAgent navMeshAgent;
private BossPatternData activePattern;
private GameObject activeTarget;
private int currentStepIndex;
private bool isWaiting;
private float waitEndTime;
/// <summary>
/// 액션 시작 시 실제로 실행할 패턴과 대상을 결정합니다.
/// </summary>
protected abstract bool TryResolvePattern(out BossPatternData pattern, out GameObject target);
protected override Status OnStart()
{
ResolveReferences();
ClearRuntimeState();
if (!IsReady())
return Status.Failure;
if (bossEnemy.IsDead || bossEnemy.IsTransitioning)
return Status.Failure;
if (skillController.IsPlayingAnimation)
return Status.Failure;
if (!TryResolvePattern(out BossPatternData pattern, out GameObject target))
return Status.Failure;
activePattern = pattern;
activeTarget = target;
if (Target != null)
Target.Value = target;
StopMovement();
return ExecuteCurrentStep();
}
protected override Status OnUpdate()
{
if (!IsReady() || activePattern == null)
return Status.Failure;
if (bossEnemy.IsDead || bossEnemy.IsTransitioning)
return Status.Failure;
if (isWaiting)
{
if (Time.time < waitEndTime)
return Status.Running;
isWaiting = false;
}
else if (skillController.IsPlayingAnimation)
{
return Status.Running;
}
currentStepIndex++;
if (currentStepIndex >= activePattern.Steps.Count)
{
UsePatternAction.MarkPatternUsed(GameObject, activePattern);
return Status.Success;
}
return ExecuteCurrentStep();
}
protected override void OnEnd()
{
ClearRuntimeState();
}
protected virtual GameObject ResolveStepTarget(GameObject fallbackTarget)
{
return fallbackTarget;
}
protected GameObject FindNearestLivingPlayer()
{
PlayerNetworkController[] players = UnityEngine.Object.FindObjectsByType<PlayerNetworkController>(FindObjectsSortMode.None);
GameObject nearestTarget = null;
float nearestDistance = float.MaxValue;
float maxDistance = enemyBase != null && enemyBase.Data != null ? enemyBase.Data.AggroRange : Mathf.Infinity;
for (int i = 0; i < players.Length; i++)
{
PlayerNetworkController player = players[i];
if (player == null || player.IsDead || !player.gameObject.activeInHierarchy)
continue;
GameObject candidate = player.gameObject;
if (Team.IsSameTeam(GameObject, candidate))
continue;
float distance = Vector3.Distance(GameObject.transform.position, candidate.transform.position);
if (distance > maxDistance || distance >= nearestDistance)
continue;
nearestDistance = distance;
nearestTarget = candidate;
}
return nearestTarget;
}
protected GameObject ResolvePrimaryTarget()
{
GameObject highestThreatTarget = enemyBase != null
? enemyBase.GetHighestThreatTarget(Target?.Value, null, enemyBase.Data != null ? enemyBase.Data.AggroRange : Mathf.Infinity)
: null;
GameObject target = highestThreatTarget != null ? highestThreatTarget : FindNearestLivingPlayer();
if (Target != null)
Target.Value = target;
return target;
}
protected bool IsValidHostileTarget(GameObject candidate)
{
if (candidate == null || !candidate.activeInHierarchy)
return false;
if (Team.IsSameTeam(GameObject, candidate))
return false;
IDamageable damageable = candidate.GetComponent<IDamageable>();
return damageable == null || !damageable.IsDead;
}
protected void StopMovement()
{
if (navMeshAgent == null || !navMeshAgent.enabled)
return;
navMeshAgent.isStopped = true;
navMeshAgent.ResetPath();
}
protected void LogDebug(string message)
{
combatBehaviorContext?.LogDebug(GetType().Name, message);
}
private Status ExecuteCurrentStep()
{
if (activePattern == null || currentStepIndex < 0 || currentStepIndex >= activePattern.Steps.Count)
return Status.Failure;
PatternStep step = activePattern.Steps[currentStepIndex];
if (step.Type == PatternStepType.Wait)
{
isWaiting = true;
waitEndTime = Time.time + step.Duration;
return Status.Running;
}
if (step.Skill == null)
{
Debug.LogWarning($"[{GetType().Name}] 스킬이 비어 있는 패턴 스텝입니다: {activePattern.PatternName} / Step={currentStepIndex}");
return Status.Failure;
}
GameObject skillTarget = activeTarget;
if (step.Skill.JumpToTarget)
{
skillTarget = ResolveStepTarget(activeTarget);
if (skillTarget == null)
{
LogDebug($"점프 타겟을 찾지 못해 실패: {activePattern.PatternName}");
return Status.Failure;
}
enemyBase?.SetJumpTarget(skillTarget.transform.position);
}
if (!skillController.ExecuteSkill(step.Skill))
{
Debug.LogWarning($"[{GetType().Name}] 스킬 실행 실패: {step.Skill.SkillName}");
return Status.Failure;
}
LogDebug($"패턴 실행: {activePattern.PatternName} / Step={currentStepIndex} / Skill={step.Skill.SkillName}");
return Status.Running;
}
private bool IsReady()
{
return bossEnemy != null && enemyBase != null && skillController != null && combatBehaviorContext != null;
}
private void ResolveReferences()
{
if (bossEnemy == null)
bossEnemy = GameObject.GetComponent<BossEnemy>();
if (enemyBase == null)
enemyBase = GameObject.GetComponent<EnemyBase>();
if (skillController == null)
skillController = GameObject.GetComponent<SkillController>();
if (combatBehaviorContext == null)
combatBehaviorContext = GameObject.GetComponent<BossCombatBehaviorContext>();
if (navMeshAgent == null)
navMeshAgent = GameObject.GetComponent<UnityEngine.AI.NavMeshAgent>();
}
private void ClearRuntimeState()
{
activePattern = null;
activeTarget = null;
currentStepIndex = 0;
isWaiting = false;
waitEndTime = 0f;
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 44fff80e86b16f6419b0e952efbebf2a

View File

@@ -0,0 +1,20 @@
using System;
using Colosseum.Enemy;
using Unity.Behavior;
using Unity.Properties;
/// <summary>
/// 기동 패턴 준비 여부를 확인하는 체크 액션입니다.
/// </summary>
[Serializable, GeneratePropertyBag]
[NodeDescription(
name: "Check Mobility Pattern Ready",
story: "기동 패턴 준비 여부 확인",
category: "Action",
id: "5b4f133ba50f46759c1c1d3347eb0b0d")]
public partial class CheckMobilityPatternReadyAction : CheckPatternReadyActionBase
{
protected override BossCombatPatternRole PatternRole => BossCombatPatternRole.Mobility;
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 489f56d9043e6d24fbe8e5574b6729be

View File

@@ -0,0 +1,31 @@
using System;
using Colosseum.AI;
using Colosseum.Enemy;
using Unity.Behavior;
using Unity.Properties;
using Action = Unity.Behavior.Action;
/// <summary>
/// 지정된 공통 패턴 역할의 준비 여부를 확인하는 체크 액션 기반 클래스입니다.
/// </summary>
[Serializable, GeneratePropertyBag]
public abstract partial class CheckPatternReadyActionBase : Action
{
/// <summary>
/// 현재 액션이 검사할 패턴 역할입니다.
/// </summary>
protected abstract BossCombatPatternRole PatternRole { get; }
protected override Status OnStart()
{
BossCombatBehaviorContext context = GameObject.GetComponent<BossCombatBehaviorContext>();
if (context == null)
return Status.Failure;
BossPatternData pattern = context.GetPattern(PatternRole);
return UsePatternAction.IsPatternReady(GameObject, pattern) ? Status.Success : Status.Failure;
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 59a58b1d5cc33f943a1af10764ee11b5

View File

@@ -0,0 +1,20 @@
using System;
using Colosseum.Enemy;
using Unity.Behavior;
using Unity.Properties;
/// <summary>
/// 기본 패턴 준비 여부를 확인하는 체크 액션입니다.
/// </summary>
[Serializable, GeneratePropertyBag]
[NodeDescription(
name: "Check Primary Pattern Ready",
story: "기본 패턴 준비 여부 확인",
category: "Action",
id: "88626617015e43ef97ea4dd05cce55e0")]
public partial class CheckPrimaryPatternReadyAction : CheckPatternReadyActionBase
{
protected override BossCombatPatternRole PatternRole => BossCombatPatternRole.Primary;
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: e3b45dc2b81beac44a35a3a6545c0488

View File

@@ -0,0 +1,20 @@
using System;
using Colosseum.Enemy;
using Unity.Behavior;
using Unity.Properties;
/// <summary>
/// 징벌 패턴 준비 여부를 확인하는 체크 액션입니다.
/// </summary>
[Serializable, GeneratePropertyBag]
[NodeDescription(
name: "Check Punish Pattern Ready",
story: "징벌 패턴 준비 여부 확인",
category: "Action",
id: "e855b3f8bdce44efa85859358d67c7a7")]
public partial class CheckPunishPatternReadyAction : CheckPatternReadyActionBase
{
protected override BossCombatPatternRole PatternRole => BossCombatPatternRole.Punish;
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 2211d1182dbbf7741b0058718afae162

View File

@@ -0,0 +1,20 @@
using System;
using Colosseum.Enemy;
using Unity.Behavior;
using Unity.Properties;
/// <summary>
/// 보조 패턴 준비 여부를 확인하는 체크 액션입니다.
/// </summary>
[Serializable, GeneratePropertyBag]
[NodeDescription(
name: "Check Secondary Pattern Ready",
story: "보조 패턴 준비 여부 확인",
category: "Action",
id: "72d4626f97fe4de4aedfda612961957f")]
public partial class CheckSecondaryPatternReadyAction : CheckPatternReadyActionBase
{
protected override BossCombatPatternRole PatternRole => BossCombatPatternRole.Secondary;
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: e155d7ca234bf8148bef34617a3a8739

View File

@@ -0,0 +1,26 @@
using System;
using Colosseum.Enemy;
using Unity.Behavior;
using Unity.Properties;
using Action = Unity.Behavior.Action;
/// <summary>
/// 현재 근접 패턴 차례가 보조 패턴인지 확인하는 공통 체크 액션입니다.
/// </summary>
[Serializable, GeneratePropertyBag]
[NodeDescription(
name: "Check Secondary Pattern Turn",
story: "현재 근접 패턴 차례가 보조 패턴인지 확인",
category: "Action",
id: "e85477bd25894248aeeea8b41efc7f48")]
public partial class CheckSecondaryPatternTurnAction : Action
{
protected override Status OnStart()
{
BossCombatBehaviorContext context = GameObject.GetComponent<BossCombatBehaviorContext>();
return context != null && context.IsNextSecondaryPattern() ? Status.Success : Status.Failure;
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 435950077eea65d43beb6bfaba38dc60

View File

@@ -0,0 +1,35 @@
using System;
using Colosseum.Enemy;
using Unity.Behavior;
using Unity.Properties;
using UnityEngine;
using Action = Unity.Behavior.Action;
/// <summary>
/// 현재 타겟이 보스의 공격 사거리 안에 있는지 확인하는 공통 체크 액션입니다.
/// </summary>
[Serializable, GeneratePropertyBag]
[NodeDescription(
name: "Check Target In Attack Range",
story: "[Target] ",
category: "Action",
id: "16821bba281d49f699d1ac9ec613dcce")]
public partial class CheckTargetInAttackRangeAction : Action
{
[SerializeReference]
public BlackboardVariable<GameObject> Target;
protected override Status OnStart()
{
if (Target?.Value == null)
return Status.Failure;
EnemyBase enemyBase = GameObject.GetComponent<EnemyBase>();
float attackRange = enemyBase != null && enemyBase.Data != null ? enemyBase.Data.AttackRange : 2f;
float distance = Vector3.Distance(GameObject.transform.position, Target.Value.transform.position);
return distance <= attackRange + 0.25f ? Status.Success : Status.Failure;
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 5b3844411f6dd784089c40c5d4325b45

View File

@@ -0,0 +1,46 @@
using System;
using Colosseum.Enemy;
using Unity.Behavior;
using Unity.Properties;
using UnityEngine;
using Action = Unity.Behavior.Action;
/// <summary>
/// 보스의 주 대상을 갱신하는 공통 Behavior Action입니다.
/// </summary>
[Serializable, GeneratePropertyBag]
[NodeDescription(
name: "Refresh Primary Target",
story: "보스 주 대상을 [Target] ",
category: "Action",
id: "b7dbb1fc0c0d451795e9f02d6f4d3930")]
public partial class RefreshPrimaryTargetAction : Action
{
[SerializeReference]
public BlackboardVariable<GameObject> Target;
protected override Status OnStart()
{
EnemyBase enemyBase = GameObject.GetComponent<EnemyBase>();
if (enemyBase == null)
return Status.Failure;
GameObject currentTarget = Target != null ? Target.Value : null;
float aggroRange = enemyBase.Data != null ? enemyBase.Data.AggroRange : Mathf.Infinity;
GameObject resolvedTarget = enemyBase.GetHighestThreatTarget(currentTarget, null, aggroRange);
if (resolvedTarget == null)
{
BossCombatBehaviorContext context = GameObject.GetComponent<BossCombatBehaviorContext>();
resolvedTarget = context != null ? context.FindNearestLivingTarget() : null;
}
if (Target != null)
Target.Value = resolvedTarget;
return resolvedTarget != null ? Status.Success : Status.Failure;
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 47bff77f5775ca3409b7f9676596d561

View File

@@ -0,0 +1,93 @@
using System;
using Colosseum;
using Colosseum.Combat;
using Colosseum.Enemy;
using Colosseum.Player;
using Unity.Behavior;
using Unity.Properties;
using UnityEngine;
using Action = Unity.Behavior.Action;
/// <summary>
/// 일정 반경 내에서 가장 가까운 다운 대상 플레이어를 선택하는 Behavior Action입니다.
/// </summary>
[Serializable, GeneratePropertyBag]
[NodeDescription(
name: "Select Nearest Downed Target",
story: "[SearchRadius] [Target] ",
category: "Action",
id: "ee1146ad46ec4730acb4d6c883a5a771")]
public partial class SelectNearestDownedTargetAction : Action
{
[SerializeReference]
public BlackboardVariable<GameObject> Target;
[SerializeReference]
public BlackboardVariable<float> SearchRadius = new BlackboardVariable<float>(0f);
protected override Status OnStart()
{
float searchRadius = ResolveSearchRadius();
HitReactionController[] hitReactionControllers = UnityEngine.Object.FindObjectsByType<HitReactionController>(FindObjectsSortMode.None);
GameObject nearestTarget = null;
float nearestDistance = float.MaxValue;
for (int i = 0; i < hitReactionControllers.Length; i++)
{
HitReactionController controller = hitReactionControllers[i];
if (controller == null || !controller.IsDowned)
continue;
GameObject candidate = controller.gameObject;
if (candidate == null || !candidate.activeInHierarchy)
continue;
if (Team.IsSameTeam(GameObject, candidate))
continue;
IDamageable damageable = candidate.GetComponent<IDamageable>();
if (damageable != null && damageable.IsDead)
continue;
float distance = Vector3.Distance(GameObject.transform.position, candidate.transform.position);
if (distance > searchRadius || distance >= nearestDistance)
continue;
nearestDistance = distance;
nearestTarget = candidate;
}
if (nearestTarget == null)
return Status.Failure;
Target.Value = nearestTarget;
LogDebug($"다운 대상 선택: {nearestTarget.name}");
return Status.Success;
}
private float ResolveSearchRadius()
{
if (SearchRadius.Value > 0f)
return SearchRadius.Value;
BossCombatBehaviorContext context = GameObject.GetComponent<BossCombatBehaviorContext>();
if (context != null)
return context.PunishSearchRadius;
EnemyBase enemyBase = GameObject.GetComponent<EnemyBase>();
if (enemyBase != null && enemyBase.Data != null)
return enemyBase.Data.AttackRange + 4f;
return 6f;
}
private void LogDebug(string message)
{
BossCombatBehaviorContext context = GameObject.GetComponent<BossCombatBehaviorContext>();
context?.LogDebug(nameof(SelectNearestDownedTargetAction), message);
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: f7453c48fa1599a44a10be23750ade75

View File

@@ -0,0 +1,172 @@
using System;
using Unity.Behavior;
using Unity.Properties;
using UnityEngine;
using Colosseum;
using Colosseum.Combat;
using Colosseum.Enemy;
using Action = Unity.Behavior.Action;
/// <summary>
/// 거리 조건에 맞는 대상을 선택하는 Behavior Action입니다.
/// 가장 가까운 대상, 가장 먼 대상, 후보 중 랜덤 선택을 지원합니다.
/// </summary>
[Serializable]
public enum DistanceTargetSelectionMode
{
Nearest = 0,
Farthest = 1,
Random = 2,
}
[Serializable, GeneratePropertyBag]
[NodeDescription(
name: "Select Target By Distance",
story: "[Tag] [MinRange] [MaxRange] [SelectionMode] [Target] ",
category: "Action",
id: "4f6a830df3ff4ff5bf8bd2c8b433aa41")]
public partial class SelectTargetByDistanceAction : Action
{
[SerializeReference]
public BlackboardVariable<GameObject> Target;
[SerializeReference]
public BlackboardVariable<string> Tag = new BlackboardVariable<string>("Player");
[SerializeReference]
public BlackboardVariable<float> MinRange = new BlackboardVariable<float>(0f);
[SerializeReference]
public BlackboardVariable<float> MaxRange = new BlackboardVariable<float>(20f);
[SerializeReference]
public BlackboardVariable<DistanceTargetSelectionMode> SelectionMode =
new BlackboardVariable<DistanceTargetSelectionMode>(DistanceTargetSelectionMode.Farthest);
protected override Status OnStart()
{
if (string.IsNullOrEmpty(Tag.Value))
return Status.Failure;
GameObject[] candidates = GameObject.FindGameObjectsWithTag(Tag.Value);
if (candidates == null || candidates.Length == 0)
return Status.Failure;
float minRange = Mathf.Max(0f, MinRange.Value);
float maxRange = Mathf.Max(minRange, MaxRange.Value);
BossCombatBehaviorContext context = GameObject.GetComponent<BossCombatBehaviorContext>();
if (minRange <= 0f && context != null)
minRange = context.MobilityTriggerDistance;
if (maxRange <= minRange)
{
EnemyBase enemyBase = GameObject.GetComponent<EnemyBase>();
maxRange = enemyBase != null && enemyBase.Data != null ? enemyBase.Data.AggroRange : 20f;
}
GameObject selectedTarget = SelectionMode.Value switch
{
DistanceTargetSelectionMode.Nearest => FindNearestTarget(candidates, minRange, maxRange),
DistanceTargetSelectionMode.Random => FindRandomTarget(candidates, minRange, maxRange),
_ => FindFarthestTarget(candidates, minRange, maxRange),
};
if (selectedTarget == null)
return Status.Failure;
Target.Value = selectedTarget;
return Status.Success;
}
private GameObject FindNearestTarget(GameObject[] candidates, float minRange, float maxRange)
{
GameObject bestTarget = null;
float bestDistance = float.MaxValue;
for (int i = 0; i < candidates.Length; i++)
{
GameObject candidate = candidates[i];
if (!IsValidTarget(candidate, minRange, maxRange, out float distance))
continue;
if (distance >= bestDistance)
continue;
bestDistance = distance;
bestTarget = candidate;
}
return bestTarget;
}
private GameObject FindFarthestTarget(GameObject[] candidates, float minRange, float maxRange)
{
GameObject bestTarget = null;
float bestDistance = minRange;
for (int i = 0; i < candidates.Length; i++)
{
GameObject candidate = candidates[i];
if (!IsValidTarget(candidate, minRange, maxRange, out float distance))
continue;
if (distance <= bestDistance)
continue;
bestDistance = distance;
bestTarget = candidate;
}
return bestTarget;
}
private GameObject FindRandomTarget(GameObject[] candidates, float minRange, float maxRange)
{
System.Collections.Generic.List<GameObject> validTargets = new System.Collections.Generic.List<GameObject>();
for (int i = 0; i < candidates.Length; i++)
{
GameObject candidate = candidates[i];
if (!IsValidTarget(candidate, minRange, maxRange, out _))
continue;
validTargets.Add(candidate);
}
if (validTargets.Count == 0)
return null;
int randomIndex = UnityEngine.Random.Range(0, validTargets.Count);
return validTargets[randomIndex];
}
private bool IsValidTarget(GameObject candidate, float minRange, float maxRange, out float distance)
{
distance = 0f;
if (candidate == null || !candidate.activeInHierarchy)
return false;
if (Team.IsSameTeam(GameObject, candidate))
return false;
IDamageable damageable = candidate.GetComponent<IDamageable>();
if (damageable != null && damageable.IsDead)
return false;
EnemyBase enemyBase = GameObject.GetComponent<EnemyBase>();
float sightRange = enemyBase != null && enemyBase.Data != null
? enemyBase.Data.AggroRange
: maxRange;
distance = Vector3.Distance(GameObject.transform.position, candidate.transform.position);
if (distance < minRange || distance > maxRange || distance > sightRange)
return false;
return true;
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: ce75af43cd1bd434495f187749228a63

View File

@@ -0,0 +1,20 @@
using System;
using Colosseum.Enemy;
using Unity.Behavior;
using Unity.Properties;
/// <summary>
/// 기동 패턴을 실행하는 공통 액션입니다.
/// </summary>
[Serializable, GeneratePropertyBag]
[NodeDescription(
name: "Use Mobility Pattern",
story: "기동 패턴 실행",
category: "Action",
id: "bb19ca5ae11a4d9586180f7cba9f76cc")]
public partial class UseMobilityPatternAction : UsePatternRoleActionBase
{
protected override BossCombatPatternRole PatternRole => BossCombatPatternRole.Mobility;
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 137700b0db09e724899700f0da861132

View File

@@ -1,9 +1,15 @@
using System;
using Colosseum;
using Colosseum.AI;
using Colosseum.Combat;
using Colosseum.Enemy;
using Colosseum.Skills;
using Unity.Behavior;
using Unity.Properties;
using UnityEngine;
using Action = Unity.Behavior.Action;
/// <summary>
@@ -17,11 +23,13 @@ public partial class UsePatternAction : Action
[SerializeReference] public BlackboardVariable<BossPatternData> Pattern;
[SerializeReference] public BlackboardVariable<GameObject> Target;
private static readonly System.Collections.Generic.Dictionary<string, float> patternReadyTimes =
new System.Collections.Generic.Dictionary<string, float>();
private SkillController skillController;
private int currentStepIndex;
private float waitEndTime;
private bool isWaiting;
private float lastUsedTime = float.MinValue;
protected override Status OnStart()
{
@@ -31,13 +39,21 @@ public partial class UsePatternAction : Action
return Status.Failure;
}
if (Time.time - lastUsedTime < Pattern.Value.Cooldown)
if (!IsPatternReady(GameObject, Pattern.Value))
{
LogDebug($"쿨다운 중: {Pattern.Value.PatternName}");
return Status.Failure;
}
if (Pattern.Value.Steps.Count == 0)
{
LogDebug("스텝이 비어 있는 패턴이라 실패합니다.");
return Status.Failure;
}
if (RequiresJumpTarget(Pattern.Value) && ResolveJumpTarget() == null)
{
LogDebug($"점프 타겟을 찾지 못해 실패: {Pattern.Value.PatternName}");
return Status.Failure;
}
@@ -76,7 +92,7 @@ public partial class UsePatternAction : Action
if (currentStepIndex >= Pattern.Value.Steps.Count)
{
lastUsedTime = Time.time;
MarkPatternUsed(GameObject, Pattern.Value);
return Status.Success;
}
@@ -106,6 +122,16 @@ public partial class UsePatternAction : Action
return Status.Failure;
}
GameObject jumpTarget = null;
if (step.Skill.JumpToTarget)
{
jumpTarget = ResolveJumpTarget();
if (jumpTarget == null)
{
return Status.Failure;
}
}
bool success = skillController.ExecuteSkill(step.Skill);
if (!success)
{
@@ -113,15 +139,101 @@ public partial class UsePatternAction : Action
return Status.Failure;
}
LogDebug($"패턴 실행: {Pattern.Value.PatternName} / Step={currentStepIndex} / Skill={step.Skill.SkillName}");
// jumpToTarget 스킬이면 타겟 위치 전달
if (step.Skill.JumpToTarget)
{
if (Target?.Value == null)
Debug.LogWarning($"[UsePatternAction] '{step.Skill.SkillName}'은 JumpToTarget 스킬이지만 Target이 바인딩되지 않았습니다.");
else
GameObject.GetComponent<Colosseum.Enemy.EnemyBase>()?.SetJumpTarget(Target.Value.transform.position);
GameObject.GetComponent<EnemyBase>()?.SetJumpTarget(jumpTarget.transform.position);
}
return Status.Running;
}
/// <summary>
/// 점프 대상이 근접 상태라면, 더 멀리 있는 유효 타겟으로 재선택합니다.
/// 도약 패턴이 전방 탱커 대신 원거리 플레이어를 징벌할 수 있도록 합니다.
/// </summary>
private GameObject ResolveJumpTarget()
{
GameObject currentTarget = Target?.Value;
EnemyBase enemyBase = GameObject.GetComponent<EnemyBase>();
float maxJumpDistance = enemyBase?.Data != null ? enemyBase.Data.AggroRange : 20f;
return IsValidJumpTarget(currentTarget, maxJumpDistance) ? currentTarget : null;
}
/// <summary>
/// 점프 타겟의 생존 여부, 팀, 거리 조건을 검사합니다.
/// </summary>
private bool IsValidJumpTarget(GameObject candidate, float maxDistance)
{
if (candidate == null || !candidate.activeInHierarchy)
return false;
if (Team.IsSameTeam(GameObject, candidate))
return false;
IDamageable damageable = candidate.GetComponent<IDamageable>();
if (damageable != null && damageable.IsDead)
return false;
float distance = Vector3.Distance(GameObject.transform.position, candidate.transform.position);
return distance <= maxDistance;
}
/// <summary>
/// 패턴 안에 점프 타겟이 필요한 스텝이 있는지 확인합니다.
/// </summary>
private static bool RequiresJumpTarget(BossPatternData pattern)
{
if (pattern == null || pattern.Steps == null)
return false;
for (int i = 0; i < pattern.Steps.Count; i++)
{
PatternStep step = pattern.Steps[i];
if (step.Type == PatternStepType.Skill && step.Skill != null && step.Skill.JumpToTarget)
return true;
}
return false;
}
/// <summary>
/// 동일한 보스 오브젝트가 같은 패턴을 다시 사용할 수 있는지 확인합니다.
/// </summary>
public static bool IsPatternReady(GameObject owner, BossPatternData pattern)
{
if (owner == null || pattern == null)
return false;
string cooldownKey = BuildCooldownKey(owner, pattern);
if (!patternReadyTimes.TryGetValue(cooldownKey, out float readyTime))
return true;
return Time.time >= readyTime;
}
/// <summary>
/// 패턴 사용 직후 다음 사용 가능 시점을 기록합니다.
/// </summary>
public static void MarkPatternUsed(GameObject owner, BossPatternData pattern)
{
if (owner == null || pattern == null)
return;
string cooldownKey = BuildCooldownKey(owner, pattern);
patternReadyTimes[cooldownKey] = Time.time + pattern.Cooldown;
}
private static string BuildCooldownKey(GameObject owner, BossPatternData pattern)
{
return $"{owner.GetInstanceID()}_{pattern.GetInstanceID()}";
}
private void LogDebug(string message)
{
BossCombatBehaviorContext context = GameObject.GetComponent<BossCombatBehaviorContext>();
context?.LogDebug(nameof(UsePatternAction), message);
}
}

View File

@@ -0,0 +1,57 @@
using System;
using Colosseum.AI;
using Colosseum.Enemy;
using Unity.Behavior;
using Unity.Properties;
using UnityEngine;
/// <summary>
/// 지정된 공통 패턴 역할을 실행하는 액션 기반 클래스입니다.
/// </summary>
[Serializable, GeneratePropertyBag]
public abstract partial class UsePatternRoleActionBase : BossPatternActionBase
{
/// <summary>
/// 현재 액션이 실행할 공통 패턴 역할입니다.
/// </summary>
protected abstract BossCombatPatternRole PatternRole { get; }
protected override bool TryResolvePattern(out BossPatternData pattern, out GameObject target)
{
BossCombatBehaviorContext context = GameObject.GetComponent<BossCombatBehaviorContext>();
pattern = context != null ? context.GetPattern(PatternRole) : null;
target = Target != null ? Target.Value : null;
if (pattern == null || !UsePatternAction.IsPatternReady(GameObject, pattern))
return false;
if (target == null && PatternRole.IsMeleeRole())
target = ResolvePrimaryTarget();
if (target == null && PatternRole == BossCombatPatternRole.Mobility)
target = context != null ? context.FindMobilityTarget() : null;
if (target == null)
return false;
if (PatternRole.IsMeleeRole() && context != null)
context.RegisterPatternUse(PatternRole);
return true;
}
protected override GameObject ResolveStepTarget(GameObject fallbackTarget)
{
BossCombatBehaviorContext context = GameObject.GetComponent<BossCombatBehaviorContext>();
if (PatternRole == BossCombatPatternRole.Mobility && context != null)
{
return context.IsValidMobilityTarget(fallbackTarget)
? fallbackTarget
: context.FindMobilityTarget();
}
return base.ResolveStepTarget(fallbackTarget);
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 33384929fd7ec3c4f9240ac748de185c

View File

@@ -0,0 +1,20 @@
using System;
using Colosseum.Enemy;
using Unity.Behavior;
using Unity.Properties;
/// <summary>
/// 기본 패턴을 실행하는 공통 액션입니다.
/// </summary>
[Serializable, GeneratePropertyBag]
[NodeDescription(
name: "Use Primary Pattern",
story: "기본 패턴 실행",
category: "Action",
id: "45d71c690f6342bcbbd348b6df5b77f1")]
public partial class UsePrimaryPatternAction : UsePatternRoleActionBase
{
protected override BossCombatPatternRole PatternRole => BossCombatPatternRole.Primary;
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 2d8de0b13ad776845a14b35e16485f53

View File

@@ -0,0 +1,20 @@
using System;
using Colosseum.Enemy;
using Unity.Behavior;
using Unity.Properties;
/// <summary>
/// 징벌 패턴을 실행하는 공통 액션입니다.
/// </summary>
[Serializable, GeneratePropertyBag]
[NodeDescription(
name: "Use Punish Pattern",
story: "징벌 패턴 실행",
category: "Action",
id: "55f3c204a22b42dca6ae96e555f11a70")]
public partial class UsePunishPatternAction : UsePatternRoleActionBase
{
protected override BossCombatPatternRole PatternRole => BossCombatPatternRole.Punish;
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 16cef0dbbe7946d46b3021b0c1802669

View File

@@ -0,0 +1,20 @@
using System;
using Colosseum.Enemy;
using Unity.Behavior;
using Unity.Properties;
/// <summary>
/// 보조 패턴을 실행하는 공통 액션입니다.
/// </summary>
[Serializable, GeneratePropertyBag]
[NodeDescription(
name: "Use Secondary Pattern",
story: "보조 패턴 실행",
category: "Action",
id: "5169d341ce0c4400ae7fa3b58dde5b7a")]
public partial class UseSecondaryPatternAction : UsePatternRoleActionBase
{
protected override BossCombatPatternRole PatternRole => BossCombatPatternRole.Secondary;
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 79cd3375939c8a244bad9d8e1f02a45d

View File

@@ -0,0 +1,33 @@
using System;
using Colosseum.Combat;
using Unity.Behavior;
using Unity.Properties;
using UnityEngine;
using Action = Unity.Behavior.Action;
/// <summary>
/// 현재 타겟이 살아 있는 유효 대상인지 확인하는 공통 체크 액션입니다.
/// </summary>
[Serializable, GeneratePropertyBag]
[NodeDescription(
name: "Validate Target",
story: "[Target] ",
category: "Action",
id: "e9ec7a3b5a5447138ecf85ab0c57b21f")]
public partial class ValidateTargetAction : Action
{
[SerializeReference]
public BlackboardVariable<GameObject> Target;
protected override Status OnStart()
{
if (Target?.Value == null || !Target.Value.activeInHierarchy)
return Status.Failure;
IDamageable damageable = Target.Value.GetComponent<IDamageable>();
return damageable != null && damageable.IsDead ? Status.Failure : Status.Success;
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 4108d806232628e43839703188adeae8