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
{
///
/// 허수아비 계산 시뮬레이터의 단일 대상 피해 계산 엔진입니다.
///
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 damageEvents = new List();
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;
}
///
/// 입력 조건에 따라 허수아비 계산 시뮬레이션을 실행합니다.
///
public static SimulationResult Run(
BuildSimulationInput input,
SimulationRuleSet ruleSet,
RotationPolicy rotationPolicy)
{
SimulationResult result = new SimulationResult();
List warnings = new List();
if (input == null)
{
warnings.Add("BuildSimulationInput이 없습니다.");
result.FinalizeResult("입력 없음", string.Empty, string.Empty, 0f, 0f, 0f, -1f, new List(), warnings);
return result;
}
IReadOnlyList 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(), warnings);
return result;
}
ruleSet ??= new SimulationRuleSet();
rotationPolicy ??= new RotationPolicy();
SimulationContext context = CreateContext(input, warnings);
try
{
SkillRuntimeSnapshot[] skillSnapshots = BuildSnapshots(input, context, ruleSet, warnings);
Dictionary cooldownReadyTimes = new Dictionary();
Dictionary metricsBySlot = new Dictionary();
HashSet cycleCompletedSlots = new HashSet();
List 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 warnings)
{
SimulationContext context = new SimulationContext
{
actor = new GameObject("BuildSimulationActor")
{
hideFlags = HideFlags.HideAndDontSave,
},
};
context.stats = context.actor.AddComponent();
context.passiveController = context.actor.AddComponent();
context.passiveController.Initialize(context.stats);
ApplyBaseStats(context.stats, input);
ApplyWeaponStats(context.stats, input.Weapon);
PassiveTreeData resolvedTree = input.ResolvePassiveTree();
List 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 warnings)
{
IReadOnlyList 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> effectMap = new Dictionary>();
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> effectMap,
SimulationContext context,
float weaponDamageMultiplier,
SimulationRuleSet ruleSet,
float totalClipDuration,
float resolvedAnimationSpeed,
int repeatCount,
List warnings)
{
if (snapshot == null || effectMap == null || effectMap.Count == 0)
return;
// 모든 클립에서 OnEffect 이벤트를 수집합니다.
List effectEvents = new List();
IReadOnlyList 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 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 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 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 cooldownReadyTimes,
SimulationContext context,
SimulationRuleSet ruleSet,
RotationPolicy rotationPolicy,
List 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);
}
///
/// 클립 목록 전체의 재생 시간을 합산합니다.
///
private static float ResolveTotalClipDuration(IReadOnlyList 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 CollectValidPrioritySlots(RotationPolicy rotationPolicy, SkillRuntimeSnapshot[] snapshots)
{
List validSlots = new List();
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 BuildBreakdowns(Dictionary metricsBySlot)
{
List breakdowns = new List();
foreach (KeyValuePair 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);
}
}
}