Files
Colosseum/Assets/_Game/Scripts/Combat/Simulation/BuildSimulationEngine.cs
dal4segno 52b0e682a8 feat: 스킬 애니메이션 N클립 순차 재생 및 이름 기반 자동 매칭 시스템
- SkillData: skillClip/endClip 단일 필드를 animationClips 리스트로 통합
  - Data_Skill_ 접두사 애셋 이름과 Anim_{key}_{순서} 클립을 자동 매칭
  - 레거시 skillClip/endClip 데이터 자동 마이그레이션
- SkillController: 클립 시퀀스 내 순차 재생 로직 (TryPlayNextClipInSequence)
  - baseSkillClip을 컨트롤러 Skill state에서 OnValidate로 자동 발견
  - waitingForEndAnimation / IsInEndAnimation 제거
- BuildSimulationEngine: 전체 클립 duration 합산 및 모든 클립 OnEffect 이벤트 파싱
- PlayerAbnormalityVerificationRunner: GetSkillDuration 전체 클립 길이 합산으로 변경
- EnemyBase: IsInEndAnimation 참조 제거
- AnimationClipExtractor: animationClips 리스트 기반 relink/collect로 변경
- AnimationClipSkillDataMatcher: 클립 변경 시 관련 SkillData 자동 갱신 (AssetPostprocessor)
- BaseSkillClipAssigner: 모든 컨트롤러의 Skill state에 base clip 일괄 할당 에디터 메뉴
- pre-commit hook: Anim_ 네이밍 규칙에 {순서} 패턴 추가 및 Anim_↔Data_Skill_ 매칭 검증
2026-04-02 18:57:03 +09:00

639 lines
26 KiB
C#

using System;
using System.Collections.Generic;
using UnityEngine;
using Colosseum.Passives;
using Colosseum.Skills;
using Colosseum.Skills.Effects;
using Colosseum.Stats;
using Colosseum.Weapons;
namespace Colosseum.Combat.Simulation
{
/// <summary>
/// 허수아비 계산 시뮬레이터의 단일 대상 피해 계산 엔진입니다.
/// </summary>
public static class BuildSimulationEngine
{
private const float MinimumActionDuration = 0.01f;
private const string UnsupportedEffectsWarning = "현재 MVP는 DamageEffect만 계산합니다.";
private sealed class SimulationContext
{
public GameObject actor;
public CharacterStats stats;
public PassiveRuntimeController passiveController;
public float currentMana;
public float maxMana;
public float totalDamage;
public float totalManaUsed;
public float currentTime;
public float firstCycleEndTime = -1f;
}
private sealed class SkillRuntimeSnapshot
{
public int slotIndex;
public SkillLoadoutEntry loadoutEntry;
public SkillData skill;
public string skillName;
public float castDuration;
public float cooldown;
public float manaCost;
public List<DamageEventSnapshot> damageEvents = new List<DamageEventSnapshot>();
public bool containsUnsupportedEffects;
}
private sealed class DamageEventSnapshot
{
public float relativeTime;
public float damageAmount;
}
private sealed class SkillMetrics
{
public string skillName;
public int castCount;
public float totalDamage;
}
/// <summary>
/// 입력 조건에 따라 허수아비 계산 시뮬레이션을 실행합니다.
/// </summary>
public static SimulationResult Run(
BuildSimulationInput input,
SimulationRuleSet ruleSet,
RotationPolicy rotationPolicy)
{
SimulationResult result = new SimulationResult();
List<string> warnings = new List<string>();
if (input == null)
{
warnings.Add("BuildSimulationInput이 없습니다.");
result.FinalizeResult("입력 없음", string.Empty, string.Empty, 0f, 0f, 0f, -1f, new List<SimulationSkillBreakdown>(), warnings);
return result;
}
IReadOnlyList<SkillLoadoutEntry> resolvedEntries = input.ResolveLoadoutEntries();
if (resolvedEntries == null || resolvedEntries.Count == 0 || !input.HasAnyResolvedSkill())
{
warnings.Add("유효한 스킬 슬롯이 없습니다.");
result.FinalizeResult(input.BuildLabel, ruleSet != null ? ruleSet.RuleName : string.Empty, rotationPolicy != null ? rotationPolicy.PolicyName : string.Empty, 0f, 0f, 0f, -1f, new List<SimulationSkillBreakdown>(), warnings);
return result;
}
ruleSet ??= new SimulationRuleSet();
rotationPolicy ??= new RotationPolicy();
SimulationContext context = CreateContext(input, warnings);
try
{
SkillRuntimeSnapshot[] skillSnapshots = BuildSnapshots(input, context, ruleSet, warnings);
Dictionary<int, float> cooldownReadyTimes = new Dictionary<int, float>();
Dictionary<int, SkillMetrics> metricsBySlot = new Dictionary<int, SkillMetrics>();
HashSet<int> cycleCompletedSlots = new HashSet<int>();
List<int> validPrioritySlots = CollectValidPrioritySlots(rotationPolicy, skillSnapshots);
while (context.currentTime < ruleSet.DurationSeconds)
{
SkillRuntimeSnapshot nextSkill = TrySelectSkill(skillSnapshots, cooldownReadyTimes, context, rotationPolicy);
if (nextSkill == null)
{
if (!TryAdvanceIdleTime(skillSnapshots, cooldownReadyTimes, context, ruleSet, rotationPolicy, warnings))
break;
continue;
}
float castStartTime = context.currentTime;
context.currentMana = Mathf.Max(0f, context.currentMana - nextSkill.manaCost);
context.totalManaUsed += nextSkill.manaCost;
cooldownReadyTimes[nextSkill.slotIndex] = castStartTime + nextSkill.cooldown;
if (!metricsBySlot.TryGetValue(nextSkill.slotIndex, out SkillMetrics skillMetrics))
{
skillMetrics = new SkillMetrics
{
skillName = nextSkill.skillName,
};
metricsBySlot.Add(nextSkill.slotIndex, skillMetrics);
}
skillMetrics.castCount++;
for (int i = 0; i < nextSkill.damageEvents.Count; i++)
{
DamageEventSnapshot damageEvent = nextSkill.damageEvents[i];
float eventTime = castStartTime + damageEvent.relativeTime;
if (eventTime > ruleSet.DurationSeconds)
continue;
context.totalDamage += damageEvent.damageAmount;
skillMetrics.totalDamage += damageEvent.damageAmount;
}
if (validPrioritySlots.Contains(nextSkill.slotIndex))
{
cycleCompletedSlots.Add(nextSkill.slotIndex);
if (context.firstCycleEndTime < 0f && cycleCompletedSlots.Count >= validPrioritySlots.Count)
{
context.firstCycleEndTime = Mathf.Min(ruleSet.DurationSeconds, castStartTime + nextSkill.castDuration);
}
}
AdvanceTime(context, Mathf.Min(ruleSet.DurationSeconds, castStartTime + nextSkill.castDuration), ruleSet);
}
result.FinalizeResult(
input.BuildLabel,
ruleSet.RuleName,
rotationPolicy.PolicyName,
ruleSet.DurationSeconds,
context.totalDamage,
context.totalManaUsed,
context.firstCycleEndTime,
BuildBreakdowns(metricsBySlot),
warnings);
return result;
}
finally
{
DestroyContext(context);
}
}
private static SimulationContext CreateContext(BuildSimulationInput input, List<string> warnings)
{
SimulationContext context = new SimulationContext
{
actor = new GameObject("BuildSimulationActor")
{
hideFlags = HideFlags.HideAndDontSave,
},
};
context.stats = context.actor.AddComponent<CharacterStats>();
context.passiveController = context.actor.AddComponent<PassiveRuntimeController>();
context.passiveController.Initialize(context.stats);
ApplyBaseStats(context.stats, input);
ApplyWeaponStats(context.stats, input.Weapon);
PassiveTreeData resolvedTree = input.ResolvePassiveTree();
List<string> selectedNodeIds = input.BuildSelectedPassiveNodeIdList();
if (resolvedTree != null && selectedNodeIds.Count > 0)
{
bool applied = context.passiveController.TryApplySelection(
resolvedTree,
selectedNodeIds,
input.PassivePreset != null ? input.PassivePreset.PresetName : "직접구성",
out string reason);
if (!applied && !string.IsNullOrWhiteSpace(reason))
warnings.Add($"패시브 적용 실패: {reason}");
}
context.maxMana = context.stats.MaxMana;
context.currentMana = context.maxMana;
return context;
}
private static void ApplyBaseStats(CharacterStats stats, BuildSimulationInput input)
{
stats.Strength.BaseValue = input.Strength;
stats.Dexterity.BaseValue = input.Dexterity;
stats.Intelligence.BaseValue = input.Intelligence;
stats.Vitality.BaseValue = input.Vitality;
stats.Wisdom.BaseValue = input.Wisdom;
stats.Spirit.BaseValue = input.Spirit;
}
private static void ApplyWeaponStats(CharacterStats stats, WeaponData weapon)
{
if (stats == null || weapon == null)
return;
foreach (StatType statType in Enum.GetValues(typeof(StatType)))
{
int bonus = weapon.GetStatBonus(statType);
if (bonus == 0)
continue;
CharacterStat stat = stats.GetStat(statType);
stat?.AddModifier(new StatModifier(bonus, StatModType.Flat, weapon));
}
}
private static SkillRuntimeSnapshot[] BuildSnapshots(
BuildSimulationInput input,
SimulationContext context,
SimulationRuleSet ruleSet,
List<string> warnings)
{
IReadOnlyList<SkillLoadoutEntry> slots = input.ResolveLoadoutEntries();
SkillRuntimeSnapshot[] snapshots = new SkillRuntimeSnapshot[slots.Count];
float weaponDamageMultiplier = input.Weapon != null ? input.Weapon.DamageMultiplier : 1f;
float weaponManaMultiplier = input.Weapon != null ? input.Weapon.ManaCostMultiplier : 1f;
for (int slotIndex = 0; slotIndex < slots.Count; slotIndex++)
{
SkillLoadoutEntry sourceEntry = slots[slotIndex];
SkillData skill = sourceEntry != null ? sourceEntry.BaseSkill : null;
if (sourceEntry == null || skill == null)
continue;
SkillLoadoutEntry loadoutEntry = sourceEntry.CreateCopy();
SkillRuntimeSnapshot snapshot = new SkillRuntimeSnapshot
{
slotIndex = slotIndex,
loadoutEntry = loadoutEntry,
skill = skill,
skillName = string.IsNullOrWhiteSpace(skill.SkillName) ? $"Slot {slotIndex}" : skill.SkillName,
cooldown = loadoutEntry.GetResolvedCooldown(),
manaCost = loadoutEntry.GetResolvedManaCost() * weaponManaMultiplier * context.passiveController.GetManaCostMultiplier(skill),
};
float resolvedAnimationSpeed = loadoutEntry.GetResolvedAnimationSpeed();
float totalClipDuration = ResolveTotalClipDuration(skill.AnimationClips, resolvedAnimationSpeed);
int repeatCount = loadoutEntry.GetResolvedRepeatCount();
snapshot.castDuration = Mathf.Max(MinimumActionDuration, (totalClipDuration * repeatCount) + ruleSet.MovementLossSecondsPerCast);
Dictionary<int, List<SkillEffect>> effectMap = new Dictionary<int, List<SkillEffect>>();
loadoutEntry.CollectTriggeredEffects(effectMap);
BuildDamageEvents(snapshot, effectMap, context, weaponDamageMultiplier, ruleSet, totalClipDuration, resolvedAnimationSpeed, repeatCount, warnings);
snapshots[slotIndex] = snapshot;
}
return snapshots;
}
private static void BuildDamageEvents(
SkillRuntimeSnapshot snapshot,
Dictionary<int, List<SkillEffect>> effectMap,
SimulationContext context,
float weaponDamageMultiplier,
SimulationRuleSet ruleSet,
float totalClipDuration,
float resolvedAnimationSpeed,
int repeatCount,
List<string> warnings)
{
if (snapshot == null || effectMap == null || effectMap.Count == 0)
return;
// 모든 클립에서 OnEffect 이벤트를 수집합니다.
List<AnimationEvent> effectEvents = new List<AnimationEvent>();
IReadOnlyList<AnimationClip> clips = snapshot.skill.AnimationClips;
if (clips != null)
{
float timeOffset = 0f;
for (int clipIndex = 0; clipIndex < clips.Count; clipIndex++)
{
AnimationClip clip = clips[clipIndex];
if (clip == null) continue;
AnimationEvent[] clipEvents = clip.events;
for (int i = 0; i < clipEvents.Length; i++)
{
if (string.Equals(clipEvents[i].functionName, "OnEffect", StringComparison.Ordinal))
{
// 이벤트 시간에 이전 클립들의 누적 길이를 더합니다.
AnimationEvent offsetEvent = clipEvents[i];
offsetEvent.time += timeOffset;
effectEvents.Add(offsetEvent);
}
}
timeOffset += clip.length;
}
}
effectEvents.Sort((left, right) => left.time.CompareTo(right.time));
float passiveDamageMultiplier = context.passiveController.GetDamageMultiplier(snapshot.skill);
for (int iteration = 0; iteration < repeatCount; iteration++)
{
float iterationOffset = totalClipDuration * iteration;
for (int eventIndex = 0; eventIndex < effectEvents.Count; eventIndex++)
{
AnimationEvent animationEvent = effectEvents[eventIndex];
if (!effectMap.TryGetValue(animationEvent.intParameter, out List<SkillEffect> effectsAtIndex) || effectsAtIndex == null)
continue;
float relativeTime = iterationOffset + (animationEvent.time / Mathf.Max(0.05f, resolvedAnimationSpeed));
for (int effectListIndex = 0; effectListIndex < effectsAtIndex.Count; effectListIndex++)
{
SkillEffect effect = effectsAtIndex[effectListIndex];
if (effect is DamageEffect damageEffect)
{
float damageAmount = CalculateDamage(damageEffect, context.stats, snapshot.loadoutEntry, passiveDamageMultiplier, weaponDamageMultiplier);
damageAmount *= ResolveTargetCount(effect, ruleSet);
snapshot.damageEvents.Add(new DamageEventSnapshot
{
relativeTime = relativeTime,
damageAmount = damageAmount,
});
}
else
{
snapshot.containsUnsupportedEffects = true;
}
}
}
}
if (snapshot.containsUnsupportedEffects)
warnings.Add($"{snapshot.skillName}: {UnsupportedEffectsWarning}");
if (effectEvents.Count == 0 && effectMap.Count > 0)
warnings.Add($"{snapshot.skillName}: OnEffect 애니메이션 이벤트가 없어 트리거 효과를 계산하지 못했습니다.");
}
private static float CalculateDamage(
DamageEffect effect,
CharacterStats stats,
SkillLoadoutEntry loadoutEntry,
float passiveDamageMultiplier,
float weaponDamageMultiplier)
{
if (effect == null || stats == null)
return 0f;
if (effect.DamageKind == DamageType.True)
{
return effect.BaseDamage * loadoutEntry.GetResolvedDamageMultiplier() * passiveDamageMultiplier;
}
float statDamage = effect.DamageKind switch
{
DamageType.Physical => stats.PhysicalDamage,
DamageType.Magical => stats.MagicDamage,
DamageType.Ranged => stats.Dexterity.FinalValue * 2f,
_ => 0f,
};
float baseTotal = effect.BaseDamage + (statDamage * effect.StatScaling);
return baseTotal * weaponDamageMultiplier * loadoutEntry.GetResolvedDamageMultiplier() * passiveDamageMultiplier;
}
private static int ResolveTargetCount(SkillEffect effect, SimulationRuleSet ruleSet)
{
if (effect == null)
return 1;
return effect.TargetType == TargetType.Area ? ruleSet.TargetCount : 1;
}
private static SkillRuntimeSnapshot TrySelectSkill(
SkillRuntimeSnapshot[] snapshots,
Dictionary<int, float> cooldownReadyTimes,
SimulationContext context,
RotationPolicy rotationPolicy)
{
SkillRuntimeSnapshot snapshot = TrySelectFromSlots(rotationPolicy.PrioritySlots, snapshots, cooldownReadyTimes, context, rotationPolicy);
if (snapshot != null)
return snapshot;
if (!rotationPolicy.UseFallbackSlot)
return null;
return TrySelectFromSlots(new[] { rotationPolicy.FallbackSlotIndex }, snapshots, cooldownReadyTimes, context, rotationPolicy);
}
private static SkillRuntimeSnapshot TrySelectFromSlots(
int[] slotOrder,
SkillRuntimeSnapshot[] snapshots,
Dictionary<int, float> cooldownReadyTimes,
SimulationContext context,
RotationPolicy rotationPolicy)
{
if (slotOrder == null || snapshots == null)
return null;
for (int i = 0; i < slotOrder.Length; i++)
{
int slotIndex = slotOrder[i];
if (slotIndex < 0 || slotIndex >= snapshots.Length)
continue;
SkillRuntimeSnapshot snapshot = snapshots[slotIndex];
if (snapshot == null)
continue;
if (rotationPolicy.DelayHighPowerSkillUntilTime &&
slotIndex == rotationPolicy.HighPowerSlotIndex &&
context.currentTime < rotationPolicy.HighPowerFirstUseTime &&
!cooldownReadyTimes.ContainsKey(slotIndex))
{
continue;
}
if (cooldownReadyTimes.TryGetValue(slotIndex, out float readyTime) && readyTime > context.currentTime)
continue;
if (snapshot.manaCost > context.currentMana)
continue;
return snapshot;
}
return null;
}
private static bool TryAdvanceIdleTime(
SkillRuntimeSnapshot[] snapshots,
Dictionary<int, float> cooldownReadyTimes,
SimulationContext context,
SimulationRuleSet ruleSet,
RotationPolicy rotationPolicy,
List<string> warnings)
{
float nextReadyTime = float.PositiveInfinity;
bool hasActionCandidate = false;
for (int i = 0; i < snapshots.Length; i++)
{
SkillRuntimeSnapshot snapshot = snapshots[i];
if (snapshot == null || !IsSlotRelevantToRotation(snapshot.slotIndex, rotationPolicy))
continue;
hasActionCandidate = true;
if (rotationPolicy.DelayHighPowerSkillUntilTime &&
snapshot.slotIndex == rotationPolicy.HighPowerSlotIndex &&
context.currentTime < rotationPolicy.HighPowerFirstUseTime &&
!cooldownReadyTimes.ContainsKey(snapshot.slotIndex))
{
nextReadyTime = Mathf.Min(nextReadyTime, rotationPolicy.HighPowerFirstUseTime);
continue;
}
if (cooldownReadyTimes.TryGetValue(snapshot.slotIndex, out float readyTime))
{
if (readyTime > context.currentTime)
nextReadyTime = Mathf.Min(nextReadyTime, readyTime);
}
else if (snapshot.manaCost <= context.currentMana)
{
warnings.Add("대기 없이 사용할 수 있는 스킬을 찾지 못해 시뮬레이션을 중단했습니다.");
return false;
}
}
if (!hasActionCandidate)
{
warnings.Add("유효한 스킬이 없어 시뮬레이션을 종료했습니다.");
return false;
}
if (ruleSet.ManaRegenPerSecond > 0f)
{
float nextManaReadyTime = ResolveNextManaReadyTime(snapshots, context, ruleSet, rotationPolicy);
nextReadyTime = Mathf.Min(nextReadyTime, nextManaReadyTime);
}
if (float.IsPositiveInfinity(nextReadyTime))
{
warnings.Add("더 이상 실행 가능한 스킬이 없어 시뮬레이션을 조기 종료했습니다.");
return false;
}
if (nextReadyTime <= context.currentTime + 0.0001f)
{
warnings.Add("다음 행동 시점을 계산하지 못해 시뮬레이션을 중단했습니다.");
return false;
}
AdvanceTime(context, Mathf.Min(ruleSet.DurationSeconds, nextReadyTime), ruleSet);
return context.currentTime < ruleSet.DurationSeconds;
}
private static float ResolveNextManaReadyTime(
SkillRuntimeSnapshot[] snapshots,
SimulationContext context,
SimulationRuleSet ruleSet,
RotationPolicy rotationPolicy)
{
float nextTime = float.PositiveInfinity;
for (int i = 0; i < snapshots.Length; i++)
{
SkillRuntimeSnapshot snapshot = snapshots[i];
if (snapshot == null ||
!IsSlotRelevantToRotation(snapshot.slotIndex, rotationPolicy) ||
snapshot.manaCost <= context.currentMana)
{
continue;
}
float shortage = snapshot.manaCost - context.currentMana;
nextTime = Mathf.Min(nextTime, context.currentTime + (shortage / ruleSet.ManaRegenPerSecond));
}
return nextTime;
}
private static void AdvanceTime(SimulationContext context, float nextTime, SimulationRuleSet ruleSet)
{
float clampedNextTime = Mathf.Max(context.currentTime, nextTime);
float deltaTime = clampedNextTime - context.currentTime;
if (deltaTime <= 0f)
return;
context.currentTime = clampedNextTime;
if (ruleSet.ManaRegenPerSecond > 0f)
{
context.currentMana = Mathf.Min(context.maxMana, context.currentMana + (ruleSet.ManaRegenPerSecond * deltaTime));
}
}
private static float ResolveClipDuration(AnimationClip clip, float speed)
{
if (clip == null)
return 0f;
return clip.length / Mathf.Max(0.05f, speed);
}
/// <summary>
/// 클립 목록 전체의 재생 시간을 합산합니다.
/// </summary>
private static float ResolveTotalClipDuration(IReadOnlyList<AnimationClip> clips, float speed)
{
if (clips == null || clips.Count == 0)
return 0f;
float total = 0f;
for (int i = 0; i < clips.Count; i++)
{
total += ResolveClipDuration(clips[i], speed);
}
return total;
}
private static List<int> CollectValidPrioritySlots(RotationPolicy rotationPolicy, SkillRuntimeSnapshot[] snapshots)
{
List<int> validSlots = new List<int>();
int[] prioritySlots = rotationPolicy.PrioritySlots;
for (int i = 0; i < prioritySlots.Length; i++)
{
int slotIndex = prioritySlots[i];
if (slotIndex < 0 || slotIndex >= snapshots.Length)
continue;
if (snapshots[slotIndex] == null || validSlots.Contains(slotIndex))
continue;
validSlots.Add(slotIndex);
}
return validSlots;
}
private static bool IsSlotRelevantToRotation(int slotIndex, RotationPolicy rotationPolicy)
{
int[] prioritySlots = rotationPolicy.PrioritySlots;
for (int i = 0; i < prioritySlots.Length; i++)
{
if (prioritySlots[i] == slotIndex)
return true;
}
return rotationPolicy.UseFallbackSlot && rotationPolicy.FallbackSlotIndex == slotIndex;
}
private static List<SimulationSkillBreakdown> BuildBreakdowns(Dictionary<int, SkillMetrics> metricsBySlot)
{
List<SimulationSkillBreakdown> breakdowns = new List<SimulationSkillBreakdown>();
foreach (KeyValuePair<int, SkillMetrics> pair in metricsBySlot)
{
SkillMetrics metrics = pair.Value;
breakdowns.Add(new SimulationSkillBreakdown(metrics.skillName, metrics.castCount, metrics.totalDamage));
}
breakdowns.Sort((left, right) => right.TotalDamage.CompareTo(left.TotalDamage));
return breakdowns;
}
private static void DestroyContext(SimulationContext context)
{
if (context == null || context.actor == null)
return;
if (Application.isPlaying)
{
UnityEngine.Object.Destroy(context.actor);
return;
}
UnityEngine.Object.DestroyImmediate(context.actor);
}
}
}