feat: 드로그 보스 AI 및 런타임 상태 구조 재구성

- 드로그 전투 컨텍스트를 BossBehaviorRuntimeState 중심 구조로 정리하고 BossEnemy, 패턴 액션, 조건 노드가 마지막 실행 결과와 phase 상태를 직접 사용하도록 갱신
- BT_Drog와 재빌드 에디터 스크립트를 확장해 phase 전환, 집행 결과 분기, 거리/쿨타임 기반 패턴 선택을 드로그 전용 자산과 노드 파라미터로 재구성
- 드로그 패턴/스킬/이펙트/애니메이션 플레이스홀더 자산을 재생성하고 보스 프리팹이 새 런타임 상태 및 등록 클립 구성을 참조하도록 정리
This commit is contained in:
2026-04-06 13:56:47 +09:00
parent 60275c6cd9
commit 904bc88d36
172 changed files with 98477 additions and 3490 deletions

View File

@@ -25,7 +25,7 @@ public abstract partial class BossPatternActionBase : Action
protected BossEnemy bossEnemy;
protected EnemyBase enemyBase;
protected SkillController skillController;
protected BossCombatBehaviorContext combatBehaviorContext;
protected BossBehaviorRuntimeState runtimeState;
protected UnityEngine.AI.NavMeshAgent navMeshAgent;
protected AbnormalityManager abnormalityManager;
@@ -34,6 +34,7 @@ public abstract partial class BossPatternActionBase : Action
private int currentStepIndex;
private bool isWaiting;
private float waitEndTime;
private bool isSkillStepExecuting;
private bool isChargeWaiting;
private float chargeEndTime;
@@ -47,6 +48,11 @@ public abstract partial class BossPatternActionBase : Action
/// </summary>
protected abstract bool TryResolvePattern(out BossPatternData pattern, out GameObject target);
/// <summary>
/// 패턴이 의미 있는 결과와 함께 종료되었을 때, 실패 결과도 다음 노드로 넘길지 결정합니다.
/// </summary>
protected virtual bool ContinueSequenceOnResolvedFailure => false;
protected override Status OnStart()
{
ResolveReferences();
@@ -55,13 +61,13 @@ public abstract partial class BossPatternActionBase : Action
if (!IsReady())
return Status.Failure;
if (combatBehaviorContext.IsBehaviorSuppressed)
if (runtimeState.IsBehaviorSuppressed)
{
StopMovement();
return Status.Failure;
}
if (bossEnemy.IsDead || bossEnemy.IsTransitioning)
if (bossEnemy.IsDead)
return Status.Failure;
if (skillController.IsPlayingAnimation)
@@ -72,10 +78,16 @@ public abstract partial class BossPatternActionBase : Action
activePattern = pattern;
activeTarget = target;
runtimeState.BeginPatternExecution(activePattern);
runtimeState.WasChargeBroken = false;
runtimeState.LastChargeStaggerDuration = 0f;
if (Target != null)
Target.Value = target;
runtimeState.SetCurrentTarget(target);
StopMovement();
return ExecuteCurrentStep();
}
@@ -83,16 +95,16 @@ public abstract partial class BossPatternActionBase : Action
protected override Status OnUpdate()
{
if (!IsReady() || activePattern == null)
return Status.Failure;
return FinalizePatternFailure(BossPatternExecutionResult.Failed);
if (combatBehaviorContext.IsBehaviorSuppressed)
if (runtimeState.IsBehaviorSuppressed)
{
StopMovement();
return Status.Failure;
return FinalizePatternFailure(BossPatternExecutionResult.Cancelled);
}
if (bossEnemy.IsDead || bossEnemy.IsTransitioning)
return Status.Failure;
if (bossEnemy.IsDead)
return FinalizePatternFailure(BossPatternExecutionResult.Cancelled);
if (isChargeWaiting)
{
@@ -100,9 +112,11 @@ public abstract partial class BossPatternActionBase : Action
{
EndChargeWait(broken: true);
skillController?.CancelSkill(SkillCancelReason.Interrupt);
runtimeState.WasChargeBroken = true;
runtimeState.SetPatternCooldown(activePattern);
LogDebug($"충전 차단 성공: 누적 {chargeAccumulatedDamage:F1} / 필요 {chargeRequiredDamage:F1}");
CombatBalanceTracker.RecordBossEvent("집행 개시 차단 성공");
return Status.Failure;
return FinalizeResolvedPattern(BossPatternExecutionResult.Failed);
}
if (Time.time < chargeEndTime)
@@ -117,16 +131,21 @@ public abstract partial class BossPatternActionBase : Action
isWaiting = false;
}
else if (skillController.IsPlayingAnimation)
else if (isSkillStepExecuting)
{
return Status.Running;
if (skillController.IsPlayingAnimation)
return Status.Running;
isSkillStepExecuting = false;
if (skillController.LastExecutionResult != SkillExecutionResult.Completed)
return FinalizePatternFailure(BossPatternExecutionResult.Cancelled);
}
currentStepIndex++;
if (currentStepIndex >= activePattern.Steps.Count)
{
UsePatternAction.MarkPatternUsed(GameObject, activePattern);
return Status.Success;
runtimeState.SetPatternCooldown(activePattern);
return FinalizeResolvedPattern(BossPatternExecutionResult.Succeeded);
}
return ExecuteCurrentStep();
@@ -205,7 +224,7 @@ public abstract partial class BossPatternActionBase : Action
protected void LogDebug(string message)
{
combatBehaviorContext?.LogDebug(GetType().Name, message);
runtimeState?.LogDebug(GetType().Name, message);
}
private Status ExecuteCurrentStep()
@@ -241,7 +260,7 @@ public abstract partial class BossPatternActionBase : Action
{
if (activePattern != null && activePattern.SkipJumpStepOnNoTarget)
{
UsePatternAction.MarkPatternUsed(GameObject, activePattern);
runtimeState.SetPatternCooldown(activePattern);
LogDebug($"점프 대상 없음, 조합 패턴 조기 종료: {activePattern.PatternName}");
return Status.Success;
}
@@ -253,12 +272,13 @@ public abstract partial class BossPatternActionBase : Action
enemyBase?.SetJumpTarget(skillTarget.transform.position);
}
if (!skillController.ExecuteSkill(step.Skill))
if (!skillController.ExecuteSkill(step.Skill, skillTarget))
{
Debug.LogWarning($"[{GetType().Name}] 스킬 실행 실패: {step.Skill.SkillName}");
return Status.Failure;
return FinalizePatternFailure(BossPatternExecutionResult.Failed);
}
isSkillStepExecuting = true;
LogDebug($"패턴 실행: {activePattern.PatternName} / Step={currentStepIndex} / Skill={step.Skill.SkillName}");
return Status.Running;
}
@@ -308,7 +328,7 @@ public abstract partial class BossPatternActionBase : Action
if (broken && activeChargeData != null)
{
combatBehaviorContext.LastChargeStaggerDuration = activeChargeData.StaggerDuration;
runtimeState.LastChargeStaggerDuration = activeChargeData.StaggerDuration;
}
activeChargeData = null;
@@ -327,7 +347,7 @@ public abstract partial class BossPatternActionBase : Action
private bool IsReady()
{
return bossEnemy != null && enemyBase != null && skillController != null && combatBehaviorContext != null;
return bossEnemy != null && enemyBase != null && skillController != null && runtimeState != null;
}
private void ResolveReferences()
@@ -341,8 +361,8 @@ public abstract partial class BossPatternActionBase : Action
if (skillController == null)
skillController = GameObject.GetComponent<SkillController>();
if (combatBehaviorContext == null)
combatBehaviorContext = GameObject.GetComponent<BossCombatBehaviorContext>();
if (runtimeState == null)
runtimeState = GameObject.GetComponent<BossBehaviorRuntimeState>();
if (navMeshAgent == null)
navMeshAgent = GameObject.GetComponent<UnityEngine.AI.NavMeshAgent>();
@@ -360,6 +380,22 @@ public abstract partial class BossPatternActionBase : Action
activeTarget = null;
currentStepIndex = 0;
isWaiting = false;
isSkillStepExecuting = false;
waitEndTime = 0f;
}
private Status FinalizeResolvedPattern(BossPatternExecutionResult result)
{
runtimeState?.CompletePatternExecution(activePattern, result);
if (result == BossPatternExecutionResult.Failed && ContinueSequenceOnResolvedFailure)
return Status.Success;
return Status.Success;
}
private Status FinalizePatternFailure(BossPatternExecutionResult result)
{
runtimeState?.CompletePatternExecution(activePattern, result);
return Status.Failure;
}
}

View File

@@ -11,7 +11,7 @@ using Action = Unity.Behavior.Action;
/// <summary>
/// 보스를 경직시키는 BT 액션입니다.
/// 충전 차단 성공 등의 상황에서 사용됩니다.
/// 경직 시간은 BossCombatBehaviorContext.LastChargeStaggerDuration에서 읽습니다.
/// 경직 시간은 BossBehaviorRuntimeState.LastChargeStaggerDuration에서 읽습니다.
/// </summary>
[Serializable, GeneratePropertyBag]
[NodeDescription(
@@ -21,21 +21,21 @@ using Action = Unity.Behavior.Action;
id: "d4e5f6a7-1111-2222-3333-888899990000")]
public partial class BossStaggerAction : Action
{
private BossCombatBehaviorContext combatBehaviorContext;
private BossBehaviorRuntimeState runtimeState;
private BossEnemy bossEnemy;
private EnemyBase enemyBase;
private float staggerEndTime;
protected override Status OnStart()
{
combatBehaviorContext = GameObject.GetComponent<BossCombatBehaviorContext>();
runtimeState = GameObject.GetComponent<BossBehaviorRuntimeState>();
bossEnemy = GameObject.GetComponent<BossEnemy>();
enemyBase = GameObject.GetComponent<EnemyBase>();
if (combatBehaviorContext == null || bossEnemy == null)
if (runtimeState == null || bossEnemy == null)
return Status.Failure;
float staggerDuration = combatBehaviorContext.LastChargeStaggerDuration;
float staggerDuration = runtimeState.LastChargeStaggerDuration;
if (staggerDuration <= 0f)
return Status.Success;

View File

@@ -23,7 +23,7 @@ public partial class ChaseTargetAction : Action
protected override Status OnStart()
{
BossCombatBehaviorContext context = GameObject.GetComponent<BossCombatBehaviorContext>();
BossBehaviorRuntimeState context = GameObject.GetComponent<BossBehaviorRuntimeState>();
if (context != null && context.IsBehaviorSuppressed)
{
return Status.Failure;
@@ -54,7 +54,7 @@ public partial class ChaseTargetAction : Action
protected override Status OnUpdate()
{
BossCombatBehaviorContext context = GameObject.GetComponent<BossCombatBehaviorContext>();
BossBehaviorRuntimeState context = GameObject.GetComponent<BossBehaviorRuntimeState>();
if (context != null && context.IsBehaviorSuppressed)
{
if (agent != null)

View File

@@ -1,6 +1,8 @@
using System;
using Colosseum;
using Colosseum.Enemy;
using Colosseum.Player;
using Unity.Behavior;
using Unity.Properties;
@@ -22,10 +24,13 @@ public partial class RefreshPrimaryTargetAction : Action
[SerializeReference]
public BlackboardVariable<GameObject> Target;
[SerializeReference]
public BlackboardVariable<float> SearchRange = new BlackboardVariable<float>(0f);
protected override Status OnStart()
{
BossCombatBehaviorContext context = GameObject.GetComponent<BossCombatBehaviorContext>();
if (context != null && context.IsBehaviorSuppressed)
BossBehaviorRuntimeState runtimeState = GameObject.GetComponent<BossBehaviorRuntimeState>();
if (runtimeState != null && runtimeState.IsBehaviorSuppressed)
return Status.Failure;
EnemyBase enemyBase = GameObject.GetComponent<EnemyBase>();
@@ -33,17 +38,55 @@ public partial class RefreshPrimaryTargetAction : Action
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);
float searchRange = ResolveSearchRange(enemyBase);
GameObject resolvedTarget = enemyBase.GetHighestThreatTarget(currentTarget, null, searchRange);
if (resolvedTarget == null)
{
resolvedTarget = context != null ? context.FindNearestLivingTarget() : null;
}
resolvedTarget = FindNearestLivingTarget(enemyBase, currentTarget, searchRange);
if (Target != null)
Target.Value = resolvedTarget;
runtimeState?.SetCurrentTarget(resolvedTarget);
runtimeState?.LogDebug(nameof(RefreshPrimaryTargetAction), resolvedTarget != null
? $"주 대상 갱신: {resolvedTarget.name}"
: "주 대상 갱신 실패");
return resolvedTarget != null ? Status.Success : Status.Failure;
}
private float ResolveSearchRange(EnemyBase enemyBase)
{
if (SearchRange != null && SearchRange.Value > 0f)
return SearchRange.Value;
return enemyBase != null && enemyBase.Data != null ? enemyBase.Data.AggroRange : Mathf.Infinity;
}
private GameObject FindNearestLivingTarget(EnemyBase enemyBase, GameObject currentTarget, float searchRange)
{
PlayerNetworkController[] players = UnityEngine.Object.FindObjectsByType<PlayerNetworkController>(FindObjectsSortMode.None);
GameObject nearestTarget = null;
float nearestDistance = float.MaxValue;
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 > searchRange || distance >= nearestDistance)
continue;
nearestDistance = distance;
nearestTarget = candidate;
}
return nearestTarget != null ? nearestTarget : currentTarget;
}
}

View File

@@ -34,30 +34,32 @@ public partial class SelectAlternateTargetByDistanceAction : Action
protected override Status OnStart()
{
BossCombatBehaviorContext context = GameObject.GetComponent<BossCombatBehaviorContext>();
if (context == null)
BossBehaviorRuntimeState runtimeState = GameObject.GetComponent<BossBehaviorRuntimeState>();
if (runtimeState == null)
return Status.Failure;
float minRange = MinRange.Value > 0f ? MinRange.Value : context.UtilityTriggerDistance;
float minRange = Mathf.Max(0f, MinRange.Value);
float maxRange = MaxRange.Value > 0f
? MaxRange.Value
: (context.EnemyBase != null && context.EnemyBase.Data != null ? context.EnemyBase.Data.AggroRange : 20f);
: (runtimeState.EnemyBase != null && runtimeState.EnemyBase.Data != null ? runtimeState.EnemyBase.Data.AggroRange : 20f);
GameObject selectedTarget = SelectTarget(context, minRange, maxRange);
GameObject selectedTarget = SelectTarget(runtimeState, minRange, maxRange);
if (selectedTarget == null)
return Status.Failure;
Target.Value = selectedTarget;
runtimeState.SetCurrentTarget(selectedTarget);
runtimeState.LogDebug(nameof(SelectAlternateTargetByDistanceAction), $"보조 대상 선택: {selectedTarget.name}");
return Status.Success;
}
private GameObject SelectTarget(BossCombatBehaviorContext context, float minRange, float maxRange)
private GameObject SelectTarget(BossBehaviorRuntimeState runtimeState, float minRange, float maxRange)
{
PlayerNetworkController[] players = UnityEngine.Object.FindObjectsByType<PlayerNetworkController>(FindObjectsSortMode.None);
if (players == null || players.Length == 0)
return null;
GameObject primaryTarget = context.ResolvePrimaryTarget();
GameObject primaryTarget = ResolvePrimaryTarget(runtimeState);
List<GameObject> validTargets = new List<GameObject>();
for (int i = 0; i < players.Length; i++)
@@ -70,7 +72,7 @@ public partial class SelectAlternateTargetByDistanceAction : Action
if (candidate == primaryTarget)
continue;
if (!context.IsValidHostileTarget(candidate))
if (!IsValidHostileTarget(candidate))
continue;
float distance = Vector3.Distance(GameObject.transform.position, candidate.transform.position);
@@ -82,7 +84,7 @@ public partial class SelectAlternateTargetByDistanceAction : Action
if (validTargets.Count == 0)
{
if (primaryTarget != null && context.IsValidHostileTarget(primaryTarget))
if (primaryTarget != null && IsValidHostileTarget(primaryTarget))
{
float primaryDistance = Vector3.Distance(GameObject.transform.position, primaryTarget.transform.position);
if (primaryDistance >= minRange && primaryDistance <= maxRange)
@@ -95,4 +97,52 @@ public partial class SelectAlternateTargetByDistanceAction : Action
int randomIndex = UnityEngine.Random.Range(0, validTargets.Count);
return validTargets[randomIndex];
}
private GameObject ResolvePrimaryTarget(BossBehaviorRuntimeState runtimeState)
{
EnemyBase enemyBase = runtimeState.EnemyBase;
GameObject currentTarget = runtimeState.CurrentTarget;
float aggroRange = enemyBase != null && enemyBase.Data != null ? enemyBase.Data.AggroRange : Mathf.Infinity;
GameObject highestThreatTarget = enemyBase != null
? enemyBase.GetHighestThreatTarget(currentTarget, null, aggroRange)
: null;
if (highestThreatTarget != null)
return highestThreatTarget;
PlayerNetworkController[] players = UnityEngine.Object.FindObjectsByType<PlayerNetworkController>(FindObjectsSortMode.None);
GameObject nearestTarget = null;
float nearestDistance = float.MaxValue;
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 (!IsValidHostileTarget(candidate))
continue;
float distance = Vector3.Distance(GameObject.transform.position, candidate.transform.position);
if (distance > aggroRange || distance >= nearestDistance)
continue;
nearestDistance = distance;
nearestTarget = candidate;
}
return nearestTarget;
}
private 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;
}
}

View File

@@ -65,29 +65,19 @@ public partial class SelectNearestDownedTargetAction : Action
return Status.Failure;
Target.Value = nearestTarget;
GameObject.GetComponent<BossBehaviorRuntimeState>()?.SetCurrentTarget(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;
return Mathf.Max(0f, SearchRadius.Value);
}
private void LogDebug(string message)
{
BossCombatBehaviorContext context = GameObject.GetComponent<BossCombatBehaviorContext>();
BossBehaviorRuntimeState context = GameObject.GetComponent<BossBehaviorRuntimeState>();
context?.LogDebug(nameof(SelectNearestDownedTargetAction), message);
}
}

View File

@@ -57,10 +57,7 @@ public partial class SelectTargetByDistanceAction : Action
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;
BossBehaviorRuntimeState runtimeState = GameObject.GetComponent<BossBehaviorRuntimeState>();
if (maxRange <= minRange)
{
@@ -79,6 +76,8 @@ public partial class SelectTargetByDistanceAction : Action
return Status.Failure;
Target.Value = selectedTarget;
runtimeState?.SetCurrentTarget(selectedTarget);
runtimeState?.LogDebug(nameof(SelectTargetByDistanceAction), $"거리 기반 대상 선택: {selectedTarget.name} / Mode={SelectionMode.Value}");
return Status.Success;
}

View File

@@ -0,0 +1,40 @@
using System;
using Colosseum.Enemy;
using Unity.Behavior;
using Unity.Properties;
using UnityEngine;
using Action = Unity.Behavior.Action;
/// <summary>
/// BT가 보스의 현재 페이즈 값을 직접 설정합니다.
/// </summary>
[Serializable, GeneratePropertyBag]
[NodeDescription(
name: "Set Boss Phase",
story: "[TargetPhase] ",
category: "Action",
id: "931c24f8-761f-4d07-a7a0-23c7a7678d6b")]
public partial class SetBossPhaseAction : Action
{
[SerializeReference]
[Tooltip("설정할 목표 페이즈 (1부터 시작)")]
public BlackboardVariable<int> TargetPhase = new BlackboardVariable<int>(1);
[SerializeReference]
[Tooltip("true면 페이즈 경과 시간도 함께 초기화합니다.")]
public BlackboardVariable<bool> ResetTimer = new BlackboardVariable<bool>(true);
protected override Status OnStart()
{
BossBehaviorRuntimeState context = GameObject.GetComponent<BossBehaviorRuntimeState>();
if (context == null)
return Status.Failure;
context.SetCurrentPatternPhase(TargetPhase?.Value ?? 1, ResetTimer?.Value ?? true);
context.LogDebug(nameof(SetBossPhaseAction), $"현재 페이즈 설정: {context.CurrentPatternPhase}");
return Status.Success;
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 7d6f3b56c0073c6fda51d5837ffc49a7

View File

@@ -14,7 +14,7 @@ using Action = Unity.Behavior.Action;
/// <summary>
/// 충전 차단에 실패하여 패턴이 완료되었을 때, 전체 플레이어에게 범위 효과를 적용합니다.
/// 기존 BossCombatBehaviorContext.ExecuteSignatureFailure()의 BT 노드 이관 버전입니다.
/// 런타임 상태에 저장된 시그니처 실패 수치를 읽어 범위 효과를 적용하는 BT 노드입니다.
/// </summary>
[Serializable, GeneratePropertyBag]
[NodeDescription(
@@ -24,14 +24,14 @@ using Action = Unity.Behavior.Action;
id: "c3d4e5f6-1111-2222-3333-777788889999")]
public partial class SignatureFailureEffectsAction : Action
{
private BossCombatBehaviorContext combatBehaviorContext;
private BossBehaviorRuntimeState runtimeState;
protected override Status OnStart()
{
combatBehaviorContext = GameObject.GetComponent<BossCombatBehaviorContext>();
if (combatBehaviorContext == null)
runtimeState = GameObject.GetComponent<BossBehaviorRuntimeState>();
if (runtimeState == null)
{
Debug.LogWarning("[SignatureFailureEffects] BossCombatBehaviorContext를 찾을 수 없습니다.");
Debug.LogWarning("[SignatureFailureEffects] BossBehaviorRuntimeState를 찾을 수 없습니다.");
return Status.Failure;
}
@@ -41,13 +41,13 @@ public partial class SignatureFailureEffectsAction : Action
private void ApplyFailureEffects()
{
float failureDamage = combatBehaviorContext.SignatureFailureDamage;
AbnormalityData failureAbnormality = combatBehaviorContext.SignatureFailureAbnormality;
float knockbackRadius = combatBehaviorContext.SignatureFailureKnockbackRadius;
float downRadius = combatBehaviorContext.SignatureFailureDownRadius;
float knockbackSpeed = combatBehaviorContext.SignatureFailureKnockbackSpeed;
float knockbackDuration = combatBehaviorContext.SignatureFailureKnockbackDuration;
float downDuration = combatBehaviorContext.SignatureFailureDownDuration;
float failureDamage = runtimeState.SignatureFailureDamage;
AbnormalityData failureAbnormality = runtimeState.SignatureFailureAbnormality;
float knockbackRadius = runtimeState.SignatureFailureKnockbackRadius;
float downRadius = runtimeState.SignatureFailureDownRadius;
float knockbackSpeed = runtimeState.SignatureFailureKnockbackSpeed;
float knockbackDuration = runtimeState.SignatureFailureKnockbackDuration;
float downDuration = runtimeState.SignatureFailureDownDuration;
PlayerNetworkController[] players = UnityEngine.Object.FindObjectsByType<PlayerNetworkController>(FindObjectsSortMode.None);
for (int i = 0; i < players.Length; i++)
@@ -57,9 +57,6 @@ public partial class SignatureFailureEffectsAction : Action
continue;
GameObject target = player.gameObject;
if (!combatBehaviorContext.IsValidHostileTarget(target))
continue;
player.TakeDamage(failureDamage, GameObject);
if (failureAbnormality != null)

View File

@@ -30,6 +30,7 @@ public partial class UsePatternAction : Action
private int currentStepIndex;
private float waitEndTime;
private bool isWaiting;
private bool isSkillStepExecuting;
protected override Status OnStart()
{
@@ -66,6 +67,7 @@ public partial class UsePatternAction : Action
currentStepIndex = 0;
isWaiting = false;
isSkillStepExecuting = false;
return ExecuteCurrentStep();
}
@@ -83,9 +85,17 @@ public partial class UsePatternAction : Action
}
else
{
if (skillController.IsPlayingAnimation)
return Status.Running;
if (isSkillStepExecuting)
{
if (skillController.IsPlayingAnimation)
return Status.Running;
isSkillStepExecuting = false;
if (skillController.LastExecutionResult != SkillExecutionResult.Completed)
return Status.Failure;
}
else if (skillController.IsPlayingAnimation)
return Status.Running;
}
currentStepIndex++;
@@ -102,6 +112,7 @@ public partial class UsePatternAction : Action
protected override void OnEnd()
{
skillController = null;
isSkillStepExecuting = false;
}
private Status ExecuteCurrentStep()
@@ -132,13 +143,17 @@ public partial class UsePatternAction : Action
}
}
bool success = skillController.ExecuteSkill(step.Skill);
GameObject skillTarget = step.Skill.JumpToTarget ? jumpTarget : Target?.Value;
bool success = skillTarget != null
? skillController.ExecuteSkill(step.Skill, skillTarget)
: skillController.ExecuteSkill(step.Skill);
if (!success)
{
Debug.LogWarning($"[UsePatternAction] 스킬 실행 실패: {step.Skill.SkillName} (index {currentStepIndex})");
return Status.Failure;
}
isSkillStepExecuting = true;
LogDebug($"패턴 실행: {Pattern.Value.PatternName} / Step={currentStepIndex} / Skill={step.Skill.SkillName}");
// jumpToTarget 스킬이면 타겟 위치 전달
@@ -233,7 +248,7 @@ public partial class UsePatternAction : Action
private void LogDebug(string message)
{
BossCombatBehaviorContext context = GameObject.GetComponent<BossCombatBehaviorContext>();
BossBehaviorRuntimeState context = GameObject.GetComponent<BossBehaviorRuntimeState>();
context?.LogDebug(nameof(UsePatternAction), message);
}
}

View File

@@ -26,6 +26,15 @@ public partial class UsePatternByRoleAction : BossPatternActionBase
[Tooltip("실행할 패턴")]
public BlackboardVariable<BossPatternData> Pattern;
[SerializeReference]
[Tooltip("패턴이 실패 결과로 끝나도 BT 시퀀스를 다음 노드까지 진행합니다.")]
public BlackboardVariable<bool> ContinueOnResolvedFailure = new BlackboardVariable<bool>(false);
/// <summary>
/// 결과 분기용 패턴은 실패 결과도 다음 노드에서 처리할 수 있게 시퀀스를 유지합니다.
/// </summary>
protected override bool ContinueSequenceOnResolvedFailure => ContinueOnResolvedFailure?.Value ?? false;
protected override Status OnStart()
{
BossPatternData pattern = Pattern?.Value;
@@ -36,7 +45,7 @@ public partial class UsePatternByRoleAction : BossPatternActionBase
// 여기서는 RegisterPatternUse만 호출 (근접 패턴 전용)
if (pattern.IsMelee)
{
BossCombatBehaviorContext context = GameObject.GetComponent<BossCombatBehaviorContext>();
BossBehaviorRuntimeState context = GameObject.GetComponent<BossBehaviorRuntimeState>();
context?.RegisterPatternUse(pattern);
}
@@ -66,7 +75,8 @@ public partial class UsePatternByRoleAction : BossPatternActionBase
if (pattern == null)
return false;
if (!UsePatternAction.IsPatternReady(GameObject, pattern))
BossBehaviorRuntimeState context = GameObject.GetComponent<BossBehaviorRuntimeState>();
if (context == null || !context.IsPatternReady(pattern))
return false;
if (target == null)
@@ -77,26 +87,6 @@ public partial class UsePatternByRoleAction : BossPatternActionBase
protected override GameObject ResolveStepTarget(GameObject fallbackTarget)
{
BossPatternData pattern = Pattern?.Value;
if (pattern == null)
return base.ResolveStepTarget(fallbackTarget);
BossCombatBehaviorContext context = GameObject.GetComponent<BossCombatBehaviorContext>();
if (context == null)
return base.ResolveStepTarget(fallbackTarget);
TargetResolveMode targetMode = pattern.TargetMode;
if (targetMode == TargetResolveMode.Mobility)
return context.IsValidMobilityTarget(fallbackTarget)
? fallbackTarget
: context.FindMobilityTarget();
if (targetMode == TargetResolveMode.Utility)
return context.IsValidUtilityTarget(fallbackTarget)
? fallbackTarget
: context.FindUtilityTarget();
return base.ResolveStepTarget(fallbackTarget);
}
}

View File

@@ -36,7 +36,9 @@ public partial class UseSkillAction : Action
}
// 스킬 실행 시도
bool success = skillController.ExecuteSkill(.Value);
bool success = Target?.Value != null
? skillController.ExecuteSkill(.Value, Target.Value)
: skillController.ExecuteSkill(.Value);
if (!success)
{
// 이미 다른 스킬 사용 중이거나 쿨타임
@@ -63,9 +65,7 @@ public partial class UseSkillAction : Action
// 스킬 애니메이션이 종료되면 성공
if (!skillController.IsPlayingAnimation)
{
return Status.Success;
}
return skillController.LastExecutionResult == SkillExecutionResult.Completed ? Status.Success : Status.Failure;
return Status.Running;
}

View File

@@ -0,0 +1,32 @@
using System;
using Colosseum.Enemy;
using Unity.Behavior;
using Unity.Properties;
using UnityEngine;
namespace Colosseum.AI.BehaviorActions.Conditions
{
/// <summary>
/// 마지막 대형 패턴 이후 누적된 기본 루프 횟수가 기준 이상인지 확인합니다.
/// </summary>
[Serializable, GeneratePropertyBag]
[Condition(name: "Is Basic Loop Count At Least", story: "기본 루프 누적 횟수가 [Count] ?", id: "5c54d42c-780b-4334-bf58-1f7d4c79f4ea")]
[NodeDescription(
name: "Is Basic Loop Count At Least",
story: "기본 루프 누적 횟수가 [Count] ?",
category: "Condition/Pattern")]
public partial class IsBasicLoopCountAtLeastCondition : Unity.Behavior.Condition
{
[SerializeReference]
[Tooltip("필요한 최소 기본 루프 횟수")]
public BlackboardVariable<int> Count = new BlackboardVariable<int>(0);
public override bool IsTrue()
{
BossBehaviorRuntimeState runtimeState = GameObject.GetComponent<BossBehaviorRuntimeState>();
return runtimeState != null && runtimeState.BasicLoopCountSinceLastBigPattern >= Mathf.Max(0, Count?.Value ?? 0);
}
}
}

View File

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

View File

@@ -0,0 +1,32 @@
using System;
using Colosseum.Enemy;
using Unity.Behavior;
using Unity.Properties;
using UnityEngine;
namespace Colosseum.AI.BehaviorActions.Conditions
{
/// <summary>
/// 보스가 보유한 커스텀 조건 플래그가 활성화되었는지 확인합니다.
/// </summary>
[Serializable, GeneratePropertyBag]
[Condition(name: "Is Boss Custom Condition True", story: "커스텀 조건 [ConditionId] ?", id: "0c4a5f77-a599-40a7-80fb-d22c4bb27f19")]
[NodeDescription(
name: "Is Boss Custom Condition True",
story: "커스텀 조건 [ConditionId] ?",
category: "Condition/Phase")]
public partial class IsBossCustomConditionTrueCondition : Unity.Behavior.Condition
{
[SerializeReference]
[Tooltip("확인할 커스텀 조건 ID")]
public BlackboardVariable<string> ConditionId = new BlackboardVariable<string>(string.Empty);
public override bool IsTrue()
{
BossBehaviorRuntimeState context = GameObject.GetComponent<BossBehaviorRuntimeState>();
return context != null && !string.IsNullOrEmpty(ConditionId?.Value) && context.CheckPhaseCustomCondition(ConditionId.Value);
}
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 125ba0ac5df532b00b25e8bfc3f556e3

View File

@@ -0,0 +1,27 @@
using System;
using Colosseum.Enemy;
using Unity.Behavior;
using Unity.Properties;
using UnityEngine;
using Condition = Unity.Behavior.Condition;
namespace Colosseum.AI.BehaviorActions.Conditions
{
[Serializable, GeneratePropertyBag]
[Condition(name: "Is Charge Broken", story: "충전이 차단되었는가?", id: "e5f6a7b8-1111-2222-3333-aaaaaaaa0001")]
[NodeDescription(
name: "Is Charge Broken",
story: "Charge was broken by accumulated damage",
category: "Condition/Pattern")]
public partial class IsChargeBrokenCondition : Condition
{
public override bool IsTrue()
{
BossBehaviorRuntimeState context = GameObject.GetComponent<BossBehaviorRuntimeState>();
return context != null && context.WasChargeBroken;
}
}
}

View File

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

View File

@@ -0,0 +1,32 @@
using System;
using Colosseum.Enemy;
using Unity.Behavior;
using Unity.Properties;
using UnityEngine;
namespace Colosseum.AI.BehaviorActions.Conditions
{
/// <summary>
/// 현재 보스 페이즈가 지정한 값과 같은지 확인합니다.
/// </summary>
[Serializable, GeneratePropertyBag]
[Condition(name: "Is Current Phase", story: "현재 페이즈가 [Phase] ?", id: "6dc82e39-6f84-43df-b8ce-5b7c0ac8e390")]
[NodeDescription(
name: "Is Current Phase",
story: "현재 페이즈가 [Phase] ?",
category: "Condition/Phase")]
public partial class IsCurrentPhaseCondition : Unity.Behavior.Condition
{
[SerializeReference]
[Tooltip("확인할 현재 페이즈 값 (1부터 시작)")]
public BlackboardVariable<int> Phase = new BlackboardVariable<int>(1);
public override bool IsTrue()
{
BossBehaviorRuntimeState context = GameObject.GetComponent<BossBehaviorRuntimeState>();
return context != null && context.CurrentPatternPhase == Mathf.Max(1, Phase?.Value ?? 1);
}
}
}

View File

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

View File

@@ -23,13 +23,13 @@ namespace Colosseum.AI.BehaviorActions.Conditions
category: "Condition/Pattern")]
public partial class IsDownedTargetInRangeCondition : Condition
{
[Min(0f)]
[SerializeReference]
[Tooltip("다운된 대상을 탐색할 최대 반경")]
[SerializeField]
private float searchRadius = 6f;
public BlackboardVariable<float> SearchRadius = new BlackboardVariable<float>(6f);
public override bool IsTrue()
{
float searchRadius = Mathf.Max(0f, SearchRadius.Value);
HitReactionController[] controllers = UnityEngine.Object.FindObjectsByType<HitReactionController>(FindObjectsSortMode.None);
for (int i = 0; i < controllers.Length; i++)

View File

@@ -32,7 +32,7 @@ namespace Colosseum.AI.BehaviorActions.Conditions
if (minPhase <= 1)
return true;
BossCombatBehaviorContext context = GameObject.GetComponent<BossCombatBehaviorContext>();
BossBehaviorRuntimeState context = GameObject.GetComponent<BossBehaviorRuntimeState>();
return context != null && context.CurrentPatternPhase >= minPhase;
}
}

View File

@@ -0,0 +1,31 @@
using System;
using Colosseum.Enemy;
using Unity.Behavior;
using Unity.Properties;
using UnityEngine;
namespace Colosseum.AI.BehaviorActions.Conditions
{
/// <summary>
/// 마지막 패턴 실행 결과가 지정한 값과 일치하는지 확인합니다.
/// </summary>
[Serializable, GeneratePropertyBag]
[Condition(name: "Is Pattern Execution Result", story: "마지막 패턴 결과가 [Result] ?", id: "4ffbf07b-3fa4-42cc-9a61-75fd07b05db6")]
[NodeDescription(
name: "Is Pattern Execution Result",
story: "마지막 패턴 결과가 [Result] ?",
category: "Condition/Pattern")]
public partial class IsPatternExecutionResultCondition : Unity.Behavior.Condition
{
[SerializeReference]
public BlackboardVariable<BossPatternExecutionResult> Result = new BlackboardVariable<BossPatternExecutionResult>(BossPatternExecutionResult.Succeeded);
public override bool IsTrue()
{
BossBehaviorRuntimeState runtimeState = GameObject.GetComponent<BossBehaviorRuntimeState>();
return runtimeState != null && runtimeState.LastPatternExecutionResult == (Result?.Value ?? BossPatternExecutionResult.None);
}
}
}

View File

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

View File

@@ -0,0 +1,32 @@
using System;
using Colosseum.Enemy;
using Unity.Behavior;
using Unity.Properties;
using UnityEngine;
namespace Colosseum.AI.BehaviorActions.Conditions
{
/// <summary>
/// 현재 페이즈의 경과 시간이 기준 이상인지 확인합니다.
/// </summary>
[Serializable, GeneratePropertyBag]
[Condition(name: "Is Phase Elapsed Time Above", story: "페이즈 경과 시간이 [Seconds] ?", id: "f0e0f5b3-3cb7-4991-ae8a-e89efcc0dbca")]
[NodeDescription(
name: "Is Phase Elapsed Time Above",
story: "페이즈 경과 시간이 [Seconds] ?",
category: "Condition/Phase")]
public partial class IsPhaseElapsedTimeAboveCondition : Unity.Behavior.Condition
{
[SerializeReference]
[Tooltip("확인할 최소 경과 시간(초)")]
public BlackboardVariable<float> Seconds = new BlackboardVariable<float>(0f);
public override bool IsTrue()
{
BossBehaviorRuntimeState context = GameObject.GetComponent<BossBehaviorRuntimeState>();
return context != null && context.PhaseElapsedTime >= Mathf.Max(0f, Seconds?.Value ?? 0f);
}
}
}

View File

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

View File

@@ -23,13 +23,13 @@ namespace Colosseum.AI.BehaviorActions.Conditions
category: "Condition/Pattern")]
public partial class IsTargetBeyondDistanceCondition : Condition
{
[Min(0f)]
[SerializeReference]
[Tooltip("이 거리 이상 떨어진 대상이 있는지 확인")]
[SerializeField]
private float minDistance = 8f;
public BlackboardVariable<float> MinDistance = new BlackboardVariable<float>(8f);
public override bool IsTrue()
{
float minDistance = Mathf.Max(0f, MinDistance.Value);
IDamageable[] targets = UnityEngine.Object.FindObjectsByType<MonoBehaviour>(FindObjectsSortMode.None)
.OfType<IDamageable>()
.ToArray();

View File

@@ -17,20 +17,22 @@ namespace Colosseum.AI.BehaviorActions.Conditions
[Condition(name: "Is Target In Attack Range", story: "타겟이 공격 사거리 안에 있는가?", id: "57370b5b23f82a54dabc4f189a23286a")]
[NodeDescription(
name: "Is Target In Attack Range",
story: "Is [Target] in attack range",
story: "Is [Target] in [AttackRange]m attack range",
category: "Condition/Combat")]
public partial class IsTargetInAttackRangeCondition : Condition
{
[SerializeReference]
public BlackboardVariable<GameObject> Target;
[SerializeReference]
public BlackboardVariable<float> AttackRange = new BlackboardVariable<float>(2f);
public override bool IsTrue()
{
if (Target?.Value == null)
return false;
EnemyBase enemyBase = GameObject.GetComponent<EnemyBase>();
float attackRange = enemyBase != null && enemyBase.Data != null ? enemyBase.Data.AttackRange : 2f;
float attackRange = Mathf.Max(0f, AttackRange.Value);
float distance = Vector3.Distance(GameObject.transform.position, Target.Value.transform.position);
return distance <= attackRange + 0.25f;
}

View File

@@ -19,7 +19,7 @@ namespace Colosseum.AI.BehaviorActions.Conditions
if (pattern == null)
return false;
BossCombatBehaviorContext context = gameObject.GetComponent<BossCombatBehaviorContext>();
BossBehaviorRuntimeState context = gameObject.GetComponent<BossBehaviorRuntimeState>();
if (context == null)
return false;
@@ -32,7 +32,7 @@ namespace Colosseum.AI.BehaviorActions.Conditions
if (!context.IsPatternGracePeriodAllowed(pattern))
return false;
return UsePatternAction.IsPatternReady(gameObject, pattern);
return context.IsPatternReady(pattern);
}
}
}