feat: 드로그 보스 리네임 및 기본 패턴 구현
- TestBoss 관련 프리팹, 데이터, 애니메이션, BT 자산을 Drog 명칭으로 정리 - 드로그 전용 패턴 컨트롤러와 기본 패턴 루프를 추가 - 오른손치기2 기반 내려찍기와 다운 추가타 연계 진입점을 구현 - 점프, 스윙, 다운 추가타 관련 스킬 및 이펙트 데이터를 정리
This commit is contained in:
@@ -33,6 +33,7 @@ namespace Colosseum.Enemy
|
||||
|
||||
// 컴포넌트
|
||||
private BehaviorGraphAgent behaviorAgent;
|
||||
private DrogPatternController drogPatternController;
|
||||
|
||||
// 페이즈 상태
|
||||
private int currentPhaseIndex = 0;
|
||||
@@ -77,8 +78,15 @@ namespace Colosseum.Enemy
|
||||
behaviorAgent = gameObject.AddComponent<BehaviorGraphAgent>();
|
||||
}
|
||||
|
||||
drogPatternController = GetComponent<DrogPatternController>();
|
||||
|
||||
// 초기 AI 설정
|
||||
if (IsServer && initialBehaviorGraph != null)
|
||||
if (IsServer && drogPatternController != null && drogPatternController.DisableBehaviorGraph)
|
||||
{
|
||||
behaviorAgent.enabled = false;
|
||||
behaviorAgent.Graph = null;
|
||||
}
|
||||
else if (IsServer && initialBehaviorGraph != null)
|
||||
{
|
||||
behaviorAgent.Graph = initialBehaviorGraph;
|
||||
}
|
||||
|
||||
408
Assets/_Game/Scripts/Enemy/DrogPatternController.cs
Normal file
408
Assets/_Game/Scripts/Enemy/DrogPatternController.cs
Normal file
@@ -0,0 +1,408 @@
|
||||
using System.Collections;
|
||||
using System.Collections.Generic;
|
||||
|
||||
using UnityEngine;
|
||||
using Unity.Netcode;
|
||||
|
||||
using Colosseum.AI;
|
||||
using Colosseum.Player;
|
||||
using Colosseum.Skills;
|
||||
|
||||
namespace Colosseum.Enemy
|
||||
{
|
||||
/// <summary>
|
||||
/// 드로그 전용 패턴 선택 컨트롤러입니다.
|
||||
/// 기본 루프, 도약, 다운 추가타 같은 고우선 패턴을 직접 선택합니다.
|
||||
/// </summary>
|
||||
[DisallowMultipleComponent]
|
||||
[RequireComponent(typeof(BossEnemy))]
|
||||
[RequireComponent(typeof(SkillController))]
|
||||
public class DrogPatternController : NetworkBehaviour
|
||||
{
|
||||
[Header("References")]
|
||||
[SerializeField] private BossEnemy bossEnemy;
|
||||
[SerializeField] private EnemyBase enemyBase;
|
||||
[SerializeField] private SkillController skillController;
|
||||
[SerializeField] private UnityEngine.AI.NavMeshAgent navMeshAgent;
|
||||
|
||||
[Header("Pattern Data")]
|
||||
[Tooltip("기본 근접 압박 패턴")]
|
||||
[SerializeField] private BossPatternData mainPattern;
|
||||
|
||||
[Tooltip("먼 대상 징벌용 도약 패턴")]
|
||||
[SerializeField] private BossPatternData leapPattern;
|
||||
|
||||
[Tooltip("다운 대상이 있을 때 우선 발동하는 광역 추가타 패턴")]
|
||||
[SerializeField] private BossPatternData downPunishPattern;
|
||||
|
||||
[Header("Phase Thresholds")]
|
||||
[Tooltip("2페이즈 진입 체력 비율")]
|
||||
[Range(0f, 1f)] [SerializeField] private float phase2HealthThreshold = 0.75f;
|
||||
|
||||
[Tooltip("3페이즈 진입 체력 비율")]
|
||||
[Range(0f, 1f)] [SerializeField] private float phase3HealthThreshold = 0.4f;
|
||||
|
||||
[Header("Targeting")]
|
||||
[Tooltip("타겟 재탐색 주기")]
|
||||
[Min(0.05f)] [SerializeField] private float targetRefreshInterval = 0.2f;
|
||||
|
||||
[Tooltip("도약 패턴을 고려하기 시작하는 거리")]
|
||||
[Min(0f)] [SerializeField] private float leapDistanceThreshold = 8f;
|
||||
|
||||
[Tooltip("다운 추가타를 고려할 최대 반경")]
|
||||
[Min(0f)] [SerializeField] private float downPunishSearchRadius = 6f;
|
||||
|
||||
[Header("Behavior")]
|
||||
[Tooltip("드로그 전용 컨트롤러 사용 시 기존 BehaviorGraph를 비활성화할지 여부")]
|
||||
[SerializeField] private bool disableBehaviorGraph = true;
|
||||
|
||||
[Tooltip("디버그 로그 출력 여부")]
|
||||
[SerializeField] private bool debugMode = false;
|
||||
|
||||
private readonly Dictionary<BossPatternData, float> patternCooldownTracker = new Dictionary<BossPatternData, float>();
|
||||
|
||||
private Coroutine activePatternCoroutine;
|
||||
private GameObject currentTarget;
|
||||
private float nextTargetRefreshTime;
|
||||
|
||||
/// <summary>
|
||||
/// 드로그 컨트롤러 사용 시 BehaviorGraph를 비활성화할지 여부
|
||||
/// </summary>
|
||||
public bool DisableBehaviorGraph => disableBehaviorGraph;
|
||||
|
||||
/// <summary>
|
||||
/// 현재 전용 패턴 실행 중인지 여부
|
||||
/// </summary>
|
||||
public bool IsExecutingPattern => activePatternCoroutine != null;
|
||||
|
||||
/// <summary>
|
||||
/// 현재 드로그 패턴 페이즈
|
||||
/// </summary>
|
||||
public int CurrentPatternPhase
|
||||
{
|
||||
get
|
||||
{
|
||||
float healthRatio = bossEnemy != null && bossEnemy.MaxHealth > 0f
|
||||
? bossEnemy.CurrentHealth / bossEnemy.MaxHealth
|
||||
: 1f;
|
||||
|
||||
if (healthRatio <= phase3HealthThreshold)
|
||||
return 3;
|
||||
|
||||
if (healthRatio <= phase2HealthThreshold)
|
||||
return 2;
|
||||
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
|
||||
private void Awake()
|
||||
{
|
||||
ResolveReferences();
|
||||
}
|
||||
|
||||
public override void OnNetworkSpawn()
|
||||
{
|
||||
ResolveReferences();
|
||||
|
||||
if (!IsServer)
|
||||
{
|
||||
enabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
private void Update()
|
||||
{
|
||||
if (!IsServer)
|
||||
return;
|
||||
|
||||
ResolveReferences();
|
||||
|
||||
if (bossEnemy == null || enemyBase == null || skillController == null)
|
||||
return;
|
||||
|
||||
if (bossEnemy.IsDead || bossEnemy.IsTransitioning)
|
||||
return;
|
||||
|
||||
RefreshTargetIfNeeded();
|
||||
UpdateMovement();
|
||||
|
||||
if (activePatternCoroutine != null || skillController.IsPlayingAnimation)
|
||||
return;
|
||||
|
||||
if (TryStartDownPunishPattern())
|
||||
return;
|
||||
|
||||
TryStartMainPattern();
|
||||
}
|
||||
|
||||
private bool TryStartDownPunishPattern()
|
||||
{
|
||||
if (!IsPatternReady(downPunishPattern))
|
||||
return false;
|
||||
|
||||
GameObject downedTarget = FindNearestDownedTarget();
|
||||
if (downedTarget == null)
|
||||
return false;
|
||||
|
||||
StartPattern(downPunishPattern, downedTarget);
|
||||
return true;
|
||||
}
|
||||
|
||||
private bool TryStartMainPattern()
|
||||
{
|
||||
if (currentTarget == null)
|
||||
return false;
|
||||
|
||||
float distanceToTarget = Vector3.Distance(transform.position, currentTarget.transform.position);
|
||||
|
||||
if (distanceToTarget >= leapDistanceThreshold && IsPatternReady(leapPattern))
|
||||
{
|
||||
StartPattern(leapPattern, currentTarget);
|
||||
return true;
|
||||
}
|
||||
|
||||
float attackRange = enemyBase.Data != null ? enemyBase.Data.AttackRange : 2f;
|
||||
if (distanceToTarget <= attackRange + 0.25f && IsPatternReady(mainPattern))
|
||||
{
|
||||
StartPattern(mainPattern, currentTarget);
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private void StartPattern(BossPatternData pattern, GameObject target)
|
||||
{
|
||||
if (pattern == null || activePatternCoroutine != null)
|
||||
return;
|
||||
|
||||
if (debugMode)
|
||||
{
|
||||
string targetName = target != null ? target.name : "None";
|
||||
Debug.Log($"[DrogPattern] 패턴 시작: {pattern.PatternName} / Target={targetName} / Phase={CurrentPatternPhase}");
|
||||
}
|
||||
|
||||
activePatternCoroutine = StartCoroutine(RunPatternCoroutine(pattern, target));
|
||||
}
|
||||
|
||||
private IEnumerator RunPatternCoroutine(BossPatternData pattern, GameObject target)
|
||||
{
|
||||
StopMovement();
|
||||
|
||||
bool completed = true;
|
||||
BossPatternData chainedPattern = null;
|
||||
GameObject chainedTarget = null;
|
||||
|
||||
for (int i = 0; i < pattern.Steps.Count; i++)
|
||||
{
|
||||
PatternStep step = pattern.Steps[i];
|
||||
|
||||
if (step.Type == PatternStepType.Wait)
|
||||
{
|
||||
yield return new WaitForSeconds(step.Duration);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (step.Skill == null)
|
||||
{
|
||||
completed = false;
|
||||
Debug.LogWarning($"[DrogPattern] 패턴 스텝 스킬이 비어 있습니다. Pattern={pattern.PatternName}, Index={i}");
|
||||
break;
|
||||
}
|
||||
|
||||
if (step.Skill.JumpToTarget && target != null)
|
||||
{
|
||||
enemyBase?.SetJumpTarget(target.transform.position);
|
||||
}
|
||||
|
||||
if (!skillController.ExecuteSkill(step.Skill))
|
||||
{
|
||||
completed = false;
|
||||
if (debugMode)
|
||||
{
|
||||
Debug.LogWarning($"[DrogPattern] 스킬 실행 실패: {step.Skill.SkillName}");
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
yield return new WaitUntil(() => skillController == null || !skillController.IsPlayingAnimation || bossEnemy == null || bossEnemy.IsDead);
|
||||
|
||||
if (bossEnemy == null || bossEnemy.IsDead)
|
||||
{
|
||||
completed = false;
|
||||
break;
|
||||
}
|
||||
|
||||
if (pattern != downPunishPattern && TryPrepareDownPunishChain(out chainedTarget))
|
||||
{
|
||||
chainedPattern = downPunishPattern;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (completed)
|
||||
{
|
||||
patternCooldownTracker[pattern] = Time.time + pattern.Cooldown;
|
||||
}
|
||||
|
||||
activePatternCoroutine = null;
|
||||
|
||||
if (chainedPattern != null && chainedTarget != null && bossEnemy != null && !bossEnemy.IsDead)
|
||||
{
|
||||
StartPattern(chainedPattern, chainedTarget);
|
||||
}
|
||||
}
|
||||
|
||||
private bool IsPatternReady(BossPatternData pattern)
|
||||
{
|
||||
if (pattern == null || pattern.Steps == null || pattern.Steps.Count == 0)
|
||||
return false;
|
||||
|
||||
if (!patternCooldownTracker.TryGetValue(pattern, out float readyTime))
|
||||
return true;
|
||||
|
||||
return Time.time >= readyTime;
|
||||
}
|
||||
|
||||
private void RefreshTargetIfNeeded()
|
||||
{
|
||||
if (Time.time < nextTargetRefreshTime)
|
||||
return;
|
||||
|
||||
nextTargetRefreshTime = Time.time + targetRefreshInterval;
|
||||
|
||||
GameObject highestThreatTarget = enemyBase.GetHighestThreatTarget(currentTarget, null, enemyBase.Data != null ? enemyBase.Data.AggroRange : Mathf.Infinity);
|
||||
currentTarget = highestThreatTarget != null ? highestThreatTarget : FindNearestLivingPlayer();
|
||||
}
|
||||
|
||||
private void UpdateMovement()
|
||||
{
|
||||
if (navMeshAgent == null || !navMeshAgent.enabled)
|
||||
return;
|
||||
|
||||
if (activePatternCoroutine != null || (skillController != null && skillController.IsPlayingAnimation))
|
||||
{
|
||||
StopMovement();
|
||||
return;
|
||||
}
|
||||
|
||||
if (currentTarget == null)
|
||||
{
|
||||
StopMovement();
|
||||
return;
|
||||
}
|
||||
|
||||
float attackRange = enemyBase.Data != null ? enemyBase.Data.AttackRange : 2f;
|
||||
float distanceToTarget = Vector3.Distance(transform.position, currentTarget.transform.position);
|
||||
if (distanceToTarget <= attackRange)
|
||||
{
|
||||
StopMovement();
|
||||
return;
|
||||
}
|
||||
|
||||
navMeshAgent.isStopped = false;
|
||||
navMeshAgent.SetDestination(currentTarget.transform.position);
|
||||
}
|
||||
|
||||
private void StopMovement()
|
||||
{
|
||||
if (navMeshAgent == null || !navMeshAgent.enabled)
|
||||
return;
|
||||
|
||||
navMeshAgent.isStopped = true;
|
||||
navMeshAgent.ResetPath();
|
||||
}
|
||||
|
||||
private GameObject FindNearestDownedTarget()
|
||||
{
|
||||
HitReactionController[] hitReactionControllers = FindObjectsByType<HitReactionController>(FindObjectsSortMode.None);
|
||||
|
||||
GameObject bestTarget = null;
|
||||
float bestDistance = 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 || Team.IsSameTeam(gameObject, candidate))
|
||||
continue;
|
||||
|
||||
float distance = Vector3.Distance(transform.position, candidate.transform.position);
|
||||
if (distance > downPunishSearchRadius || distance >= bestDistance)
|
||||
continue;
|
||||
|
||||
bestDistance = distance;
|
||||
bestTarget = candidate;
|
||||
}
|
||||
|
||||
return bestTarget;
|
||||
}
|
||||
|
||||
private bool TryPrepareDownPunishChain(out GameObject downedTarget)
|
||||
{
|
||||
downedTarget = null;
|
||||
|
||||
if (!IsPatternReady(downPunishPattern))
|
||||
return false;
|
||||
|
||||
downedTarget = FindNearestDownedTarget();
|
||||
if (downedTarget == null)
|
||||
return false;
|
||||
|
||||
if (debugMode)
|
||||
{
|
||||
Debug.Log($"[DrogPattern] 다운 대상 감지, 다운 추가타 연계 준비: {downedTarget.name}");
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private GameObject FindNearestLivingPlayer()
|
||||
{
|
||||
PlayerNetworkController[] players = FindObjectsByType<PlayerNetworkController>(FindObjectsSortMode.None);
|
||||
|
||||
GameObject bestTarget = null;
|
||||
float bestDistance = float.MaxValue;
|
||||
float aggroRange = 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(transform.position, candidate.transform.position);
|
||||
if (distance > aggroRange || distance >= bestDistance)
|
||||
continue;
|
||||
|
||||
bestDistance = distance;
|
||||
bestTarget = candidate;
|
||||
}
|
||||
|
||||
return bestTarget;
|
||||
}
|
||||
|
||||
private void ResolveReferences()
|
||||
{
|
||||
if (bossEnemy == null)
|
||||
bossEnemy = GetComponent<BossEnemy>();
|
||||
|
||||
if (enemyBase == null)
|
||||
enemyBase = GetComponent<EnemyBase>();
|
||||
|
||||
if (skillController == null)
|
||||
skillController = GetComponent<SkillController>();
|
||||
|
||||
if (navMeshAgent == null)
|
||||
navMeshAgent = GetComponent<UnityEngine.AI.NavMeshAgent>();
|
||||
}
|
||||
}
|
||||
}
|
||||
11
Assets/_Game/Scripts/Enemy/DrogPatternController.cs.meta
Normal file
11
Assets/_Game/Scripts/Enemy/DrogPatternController.cs.meta
Normal file
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: c5b2d4ef2f1b4ee49b5f7f2c7175fd10
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -30,7 +30,11 @@ namespace Colosseum.Skills.Effects
|
||||
return;
|
||||
}
|
||||
|
||||
if (!target.TryGetComponent(out HitReactionController hitReactionController))
|
||||
HitReactionController hitReactionController = target.GetComponent<HitReactionController>();
|
||||
if (hitReactionController == null)
|
||||
hitReactionController = target.GetComponentInParent<HitReactionController>();
|
||||
|
||||
if (hitReactionController == null)
|
||||
{
|
||||
Debug.LogWarning($"[DownEffect] HitReactionController not found on target: {target.name}");
|
||||
return;
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
using UnityEngine;
|
||||
|
||||
using Colosseum.Combat;
|
||||
using Colosseum.Player;
|
||||
using Colosseum.Stats;
|
||||
using Colosseum.Weapons;
|
||||
|
||||
namespace Colosseum.Skills.Effects
|
||||
{
|
||||
/// <summary>
|
||||
/// 피격 제어 상태에 따라 추가 피해를 줄 수 있는 대미지 효과입니다.
|
||||
/// 현재는 다운 상태인 대상에게 추가 배율을 적용합니다.
|
||||
/// </summary>
|
||||
[CreateAssetMenu(fileName = "HitReactionDamageEffect", menuName = "Colosseum/Skills/Effects/HitReaction Damage")]
|
||||
public class HitReactionDamageEffect : SkillEffect
|
||||
{
|
||||
[Header("Damage Settings")]
|
||||
[Min(0f)] [SerializeField] private float baseDamage = 10f;
|
||||
[SerializeField] private DamageType damageType = DamageType.Physical;
|
||||
[Tooltip("스탯 계수 (1.0 = 100%)")]
|
||||
[Min(0f)] [SerializeField] private float statScaling = 1f;
|
||||
|
||||
[Header("Hit Reaction Bonus")]
|
||||
[Tooltip("다운 상태인 대상에게 추가 배율을 적용할지 여부")]
|
||||
[SerializeField] private bool bonusAgainstDownedTarget = true;
|
||||
|
||||
[Tooltip("다운 상태 대상에게 적용되는 추가 피해 배율")]
|
||||
[Min(1f)] [SerializeField] private float downedDamageMultiplier = 1.5f;
|
||||
|
||||
protected override void ApplyEffect(GameObject caster, GameObject target)
|
||||
{
|
||||
if (target == null)
|
||||
return;
|
||||
|
||||
IDamageable damageable = target.GetComponent<IDamageable>();
|
||||
if (damageable == null)
|
||||
return;
|
||||
|
||||
float totalDamage = CalculateDamage(caster);
|
||||
|
||||
HitReactionController hitReactionController = target.GetComponent<HitReactionController>();
|
||||
if (hitReactionController == null)
|
||||
hitReactionController = target.GetComponentInParent<HitReactionController>();
|
||||
|
||||
if (bonusAgainstDownedTarget
|
||||
&& hitReactionController != null
|
||||
&& hitReactionController.IsDowned)
|
||||
{
|
||||
totalDamage *= downedDamageMultiplier;
|
||||
}
|
||||
|
||||
damageable.TakeDamage(totalDamage, caster);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 시전자 스탯 기반 대미지 계산
|
||||
/// 공식: baseDamage + (statDamage * scaling)
|
||||
/// </summary>
|
||||
private float CalculateDamage(GameObject caster)
|
||||
{
|
||||
if (damageType == DamageType.True)
|
||||
{
|
||||
return baseDamage;
|
||||
}
|
||||
|
||||
CharacterStats stats = caster != null ? caster.GetComponent<CharacterStats>() : null;
|
||||
if (stats == null)
|
||||
{
|
||||
return baseDamage;
|
||||
}
|
||||
|
||||
float statDamage = damageType switch
|
||||
{
|
||||
DamageType.Physical => stats.PhysicalDamage,
|
||||
DamageType.Magical => stats.MagicDamage,
|
||||
DamageType.Ranged => stats.Dexterity.FinalValue * 2f,
|
||||
_ => 0f,
|
||||
};
|
||||
|
||||
float baseTotal = baseDamage + (statDamage * statScaling);
|
||||
return baseTotal * GetDamageMultiplier(caster);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 시전자의 무기 데미지 배율 조회
|
||||
/// </summary>
|
||||
private float GetDamageMultiplier(GameObject caster)
|
||||
{
|
||||
WeaponEquipment weaponEquipment = caster != null ? caster.GetComponent<WeaponEquipment>() : null;
|
||||
return weaponEquipment != null ? weaponEquipment.DamageMultiplier : 1f;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 6fd8c6f4c4454ecdb111d13dc8471024
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -28,13 +28,21 @@ namespace Colosseum.Skills.Effects
|
||||
|
||||
Vector3 knockbackVelocity = direction * force + Vector3.up * upwardForce;
|
||||
|
||||
if (target.TryGetComponent<HitReactionController>(out var hitReactionController))
|
||||
HitReactionController hitReactionController = target.GetComponent<HitReactionController>();
|
||||
if (hitReactionController == null)
|
||||
hitReactionController = target.GetComponentInParent<HitReactionController>();
|
||||
|
||||
if (hitReactionController != null)
|
||||
{
|
||||
hitReactionController.ApplyKnockback(knockbackVelocity, duration);
|
||||
return;
|
||||
}
|
||||
|
||||
if (target.TryGetComponent<PlayerMovement>(out var playerMovement))
|
||||
PlayerMovement playerMovement = target.GetComponent<PlayerMovement>();
|
||||
if (playerMovement == null)
|
||||
playerMovement = target.GetComponentInParent<PlayerMovement>();
|
||||
|
||||
if (playerMovement != null)
|
||||
{
|
||||
playerMovement.ApplyForcedMovement(knockbackVelocity, duration);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user