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); } } }