using System; using System.Collections.Generic; using UnityEngine; using Colosseum.Abnormalities; using Colosseum.Skills; namespace Colosseum.AI { /// /// 패턴의 대분류. grace period 판단에 사용됩니다. /// public enum PatternCategory { /// 기본 패턴 — grace period 제한 없음 Basic, /// 대형 패턴 — basicLoopCount 이후 사용 가능 Big, /// 징벌 패턴 — 항상 허용, bigPattern 카운터 리셋 Punish, } /// /// 패턴의 타겟 해석 방식 /// public enum TargetResolveMode { /// 타겟 해석 불필요 (시그니처 등 내부 처리) None, /// 가장 위협도가 높은 근접 대상 HighestThreat, /// 기동 패턴 전용 타겟 Mobility, /// 유틸리티 패턴 전용 타겟 Utility, } public enum PatternStepType { Skill, Wait, ChargeWait } /// /// ChargeWait 스텝의 차단 관련 설정 데이터입니다. /// 충전 대기 중 플레이어가 누적 피해를 충족하면 차단 성공으로 처리됩니다. /// [System.Serializable] public class ChargeStepData { [Header("차단 조건")] [Tooltip("차단에 필요한 누적 피해 비율 (보스 최대 체력 기준)")] [Range(0f, 1f)] [SerializeField] private float requiredDamageRatio = 0.1f; [Header("전조 효과")] [Tooltip("충전 중 부여할 전조 이상상태 (루핑 VFX 등)")] [SerializeField] private AbnormalityData telegraphAbnormality; [Header("차단 성공 효과")] [Tooltip("차단 성공 시 보스 경직 시간")] [Min(0f)] [SerializeField] private float staggerDuration = 2f; public float RequiredDamageRatio => requiredDamageRatio; public AbnormalityData TelegraphAbnormality => telegraphAbnormality; public float StaggerDuration => staggerDuration; } [System.Serializable] public class PatternStep { public PatternStepType Type = PatternStepType.Skill; public SkillData Skill; [Min(0f)] public float Duration = 0.5f; [Tooltip("ChargeWait 타입 전용 차단 설정")] public ChargeStepData ChargeData; } /// /// 보스 패턴 데이터. 순서대로 실행할 스텝(스킬 또는 대기) 목록과 쿨타임을 정의합니다. /// [CreateAssetMenu(fileName = "NewBossPattern", menuName = "Colosseum/Boss Pattern")] public class BossPatternData : ScriptableObject { #if UNITY_EDITOR private const string PatternAssetPrefix = "Data_Pattern_"; private const string SkillAssetPrefix = "Data_Skill_"; private const string SkillSearchFolder = "Assets/_Game/Data/Skills"; #endif [Header("패턴 정보")] [SerializeField] private string patternName; [Header("패턴 특성")] [Tooltip("패턴 분류 — grace period 판단에 사용")] [SerializeField] private PatternCategory category = PatternCategory.Basic; [Tooltip("시그니처 패턴 여부 — 현재 사용되지 않음 (ChargeWait 스텝으로 대체)")] [SerializeField] private bool isSignature; [Tooltip("근접 패턴 여부 — meleePatternCounter 갱신")] [SerializeField] private bool isMelee; [Tooltip("타겟 해석 방식")] [SerializeField] private TargetResolveMode targetMode = TargetResolveMode.HighestThreat; [Header("스텝 순서")] [SerializeField] private List steps = new List(); [Header("쿨타임")] [Min(0f)] [Tooltip("패턴 완료 후 다시 사용 가능해지기까지의 시간")] [SerializeField] private float cooldown = 5f; [Header("페이즈 제한")] [Min(1)] [Tooltip("이 패턴을 사용하기 시작하는 최소 페이즈 (1=Phase 1부터)")] [SerializeField] private int minPhase = 1; [Header("조건부 점프")] [Tooltip("점프 스텝에서 대상을 찾지 못하면 해당 스텝을 스킵하고 패턴을 종료합니다 (조합 패턴용)")] [SerializeField] private bool skipJumpStepOnNoTarget = false; public string PatternName => patternName; public PatternCategory Category => category; public bool IsSignature => isSignature; public bool IsMelee => isMelee; public TargetResolveMode TargetMode => targetMode; public IReadOnlyList Steps => steps; public float Cooldown => cooldown; public int MinPhase => minPhase; public bool SkipJumpStepOnNoTarget => skipJumpStepOnNoTarget; /// /// Big 패턴인지 반환합니다 (grace period 대상). /// public bool IsBigPattern => category == PatternCategory.Big; #if UNITY_EDITOR /// /// 패턴 자산 이름을 기준으로 시퀀스 스킬 스텝을 자동 동기화합니다. /// Data_Pattern_{이름} 패턴은 Data_Skill_{이름}_{순번} 스킬들을 찾아 앞쪽 스킬 스텝 구간을 재구성합니다. /// private void OnValidate() { RefreshPatternStepsFromMatchingSkills(); } /// /// 매칭되는 시퀀스 스킬을 찾아 패턴 스텝에 반영합니다. /// public bool RefreshPatternStepsFromMatchingSkills() { if (!name.StartsWith(PatternAssetPrefix, StringComparison.Ordinal)) return false; string patternKey = name.Substring(PatternAssetPrefix.Length); if (string.IsNullOrEmpty(patternKey)) return false; List matchingSkills = FindMatchingSkills(patternKey); if (matchingSkills.Count == 0) return false; int insertionIndex = -1; var preservedSteps = new List(steps.Count); for (int i = 0; i < steps.Count; i++) { PatternStep step = steps[i]; if (IsMatchingSkillStep(step, patternKey)) { if (insertionIndex < 0) insertionIndex = preservedSteps.Count; continue; } preservedSteps.Add(step); } if (insertionIndex < 0) insertionIndex = 0; var rebuiltSteps = new List(preservedSteps.Count + matchingSkills.Count); for (int i = 0; i < insertionIndex; i++) rebuiltSteps.Add(preservedSteps[i]); for (int i = 0; i < matchingSkills.Count; i++) { rebuiltSteps.Add(new PatternStep { Type = PatternStepType.Skill, Skill = matchingSkills[i], Duration = 0f, ChargeData = null, }); } for (int i = insertionIndex; i < preservedSteps.Count; i++) rebuiltSteps.Add(preservedSteps[i]); if (AreStepsEquivalent(steps, rebuiltSteps)) return false; steps = rebuiltSteps; UnityEditor.EditorUtility.SetDirty(this); return true; } /// /// 패턴 이름과 매칭되는 시퀀스 스킬 목록을 순번 기준으로 수집합니다. /// private static List FindMatchingSkills(string patternKey) { string[] guids = UnityEditor.AssetDatabase.FindAssets("t:SkillData", new[] { SkillSearchFolder }); var matchedSkills = new List<(SkillData skill, int order)>(); for (int i = 0; i < guids.Length; i++) { string path = UnityEditor.AssetDatabase.GUIDToAssetPath(guids[i]); SkillData skill = UnityEditor.AssetDatabase.LoadAssetAtPath(path); if (skill == null) continue; if (!TryGetMatchingSkillOrder(skill.name, patternKey, out int order)) continue; matchedSkills.Add((skill, order)); } matchedSkills.Sort((a, b) => a.order.CompareTo(b.order)); var orderedSkills = new List(matchedSkills.Count); int previousOrder = int.MinValue; for (int i = 0; i < matchedSkills.Count; i++) { if (matchedSkills[i].order == previousOrder) continue; orderedSkills.Add(matchedSkills[i].skill); previousOrder = matchedSkills[i].order; } return orderedSkills; } /// /// 지정한 스텝이 현재 패턴 이름에 대응하는 시퀀스 스킬 스텝인지 확인합니다. /// private static bool IsMatchingSkillStep(PatternStep step, string patternKey) { return step != null && step.Type == PatternStepType.Skill && step.Skill != null && TryGetMatchingSkillOrder(step.Skill.name, patternKey, out _); } /// /// 스킬 자산 이름에서 패턴과 매칭되는 순번을 추출합니다. /// private static bool TryGetMatchingSkillOrder(string skillAssetName, string patternKey, out int order) { order = 0; if (string.IsNullOrEmpty(skillAssetName) || string.IsNullOrEmpty(patternKey)) return false; string prefix = $"{SkillAssetPrefix}{patternKey}_"; if (!skillAssetName.StartsWith(prefix, StringComparison.Ordinal)) return false; string suffix = skillAssetName.Substring(prefix.Length); return int.TryParse(suffix, out order); } /// /// 현재 스텝 구성과 재구성 결과가 같은지 비교합니다. /// private static bool AreStepsEquivalent(IReadOnlyList currentSteps, IReadOnlyList rebuiltSteps) { if (ReferenceEquals(currentSteps, rebuiltSteps)) return true; if (currentSteps == null || rebuiltSteps == null) return false; if (currentSteps.Count != rebuiltSteps.Count) return false; for (int i = 0; i < currentSteps.Count; i++) { PatternStep current = currentSteps[i]; PatternStep rebuilt = rebuiltSteps[i]; if (current == null || rebuilt == null) { if (current != rebuilt) return false; continue; } if (current.Type != rebuilt.Type) return false; if (current.Skill != rebuilt.Skill) return false; if (!Mathf.Approximately(current.Duration, rebuilt.Duration)) return false; if (!AreChargeDataEquivalent(current.ChargeData, rebuilt.ChargeData)) return false; } return true; } /// /// ChargeWait 보조 데이터를 비교합니다. /// private static bool AreChargeDataEquivalent(ChargeStepData current, ChargeStepData rebuilt) { if (ReferenceEquals(current, rebuilt)) return true; if (current == null || rebuilt == null) return false; return Mathf.Approximately(current.RequiredDamageRatio, rebuilt.RequiredDamageRatio) && current.TelegraphAbnormality == rebuilt.TelegraphAbnormality && Mathf.Approximately(current.StaggerDuration, rebuilt.StaggerDuration); } #endif } }