Files
Colosseum/Assets/_Game/Scripts/AI/BossPatternData.cs
dal4segno 72aae85afd feat: 스킬-패턴 자동 동기화 추가
- Data_Pattern_{name}가 Data_Skill_{name}_{seq}를 순번대로 찾아 시퀀스 스텝을 자동 재구성하도록 추가
- SkillData asset 변경 시 BossPatternData를 다시 맞추는 에디터 postprocessor를 추가
- 드로그 리빌드 경로와 콤보-기본기1 패턴 자산을 새 자동 동기화 규칙에 맞게 정리
2026-04-10 11:37:20 +09:00

336 lines
12 KiB
C#

using System;
using System.Collections.Generic;
using UnityEngine;
using Colosseum.Abnormalities;
using Colosseum.Skills;
namespace Colosseum.AI
{
/// <summary>
/// 패턴의 대분류. grace period 판단에 사용됩니다.
/// </summary>
public enum PatternCategory
{
/// <summary>기본 패턴 — grace period 제한 없음</summary>
Basic,
/// <summary>대형 패턴 — basicLoopCount 이후 사용 가능</summary>
Big,
/// <summary>징벌 패턴 — 항상 허용, bigPattern 카운터 리셋</summary>
Punish,
}
/// <summary>
/// 패턴의 타겟 해석 방식
/// </summary>
public enum TargetResolveMode
{
/// <summary>타겟 해석 불필요 (시그니처 등 내부 처리)</summary>
None,
/// <summary>가장 위협도가 높은 근접 대상</summary>
HighestThreat,
/// <summary>기동 패턴 전용 타겟</summary>
Mobility,
/// <summary>유틸리티 패턴 전용 타겟</summary>
Utility,
}
public enum PatternStepType { Skill, Wait, ChargeWait }
/// <summary>
/// ChargeWait 스텝의 차단 관련 설정 데이터입니다.
/// 충전 대기 중 플레이어가 누적 피해를 충족하면 차단 성공으로 처리됩니다.
/// </summary>
[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;
}
/// <summary>
/// 보스 패턴 데이터. 순서대로 실행할 스텝(스킬 또는 대기) 목록과 쿨타임을 정의합니다.
/// </summary>
[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<PatternStep> steps = new List<PatternStep>();
[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<PatternStep> Steps => steps;
public float Cooldown => cooldown;
public int MinPhase => minPhase;
public bool SkipJumpStepOnNoTarget => skipJumpStepOnNoTarget;
/// <summary>
/// Big 패턴인지 반환합니다 (grace period 대상).
/// </summary>
public bool IsBigPattern => category == PatternCategory.Big;
#if UNITY_EDITOR
/// <summary>
/// 패턴 자산 이름을 기준으로 시퀀스 스킬 스텝을 자동 동기화합니다.
/// Data_Pattern_{이름} 패턴은 Data_Skill_{이름}_{순번} 스킬들을 찾아 앞쪽 스킬 스텝 구간을 재구성합니다.
/// </summary>
private void OnValidate()
{
RefreshPatternStepsFromMatchingSkills();
}
/// <summary>
/// 매칭되는 시퀀스 스킬을 찾아 패턴 스텝에 반영합니다.
/// </summary>
public bool RefreshPatternStepsFromMatchingSkills()
{
if (!name.StartsWith(PatternAssetPrefix, StringComparison.Ordinal))
return false;
string patternKey = name.Substring(PatternAssetPrefix.Length);
if (string.IsNullOrEmpty(patternKey))
return false;
List<SkillData> matchingSkills = FindMatchingSkills(patternKey);
if (matchingSkills.Count == 0)
return false;
int insertionIndex = -1;
var preservedSteps = new List<PatternStep>(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<PatternStep>(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;
}
/// <summary>
/// 패턴 이름과 매칭되는 시퀀스 스킬 목록을 순번 기준으로 수집합니다.
/// </summary>
private static List<SkillData> 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<SkillData>(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<SkillData>(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;
}
/// <summary>
/// 지정한 스텝이 현재 패턴 이름에 대응하는 시퀀스 스킬 스텝인지 확인합니다.
/// </summary>
private static bool IsMatchingSkillStep(PatternStep step, string patternKey)
{
return step != null
&& step.Type == PatternStepType.Skill
&& step.Skill != null
&& TryGetMatchingSkillOrder(step.Skill.name, patternKey, out _);
}
/// <summary>
/// 스킬 자산 이름에서 패턴과 매칭되는 순번을 추출합니다.
/// </summary>
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);
}
/// <summary>
/// 현재 스텝 구성과 재구성 결과가 같은지 비교합니다.
/// </summary>
private static bool AreStepsEquivalent(IReadOnlyList<PatternStep> currentSteps, IReadOnlyList<PatternStep> 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;
}
/// <summary>
/// ChargeWait 보조 데이터를 비교합니다.
/// </summary>
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
}
}