diff --git a/Assets/_Game/Scripts/Combat/Simulation.meta b/Assets/_Game/Scripts/Combat/Simulation.meta new file mode 100644 index 00000000..3d9d21ca --- /dev/null +++ b/Assets/_Game/Scripts/Combat/Simulation.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: afad57d9ec73a1740803c20de7b89392 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/_Game/Scripts/Combat/Simulation/BuildSimulationEngine.cs b/Assets/_Game/Scripts/Combat/Simulation/BuildSimulationEngine.cs new file mode 100644 index 00000000..4367eb53 --- /dev/null +++ b/Assets/_Game/Scripts/Combat/Simulation/BuildSimulationEngine.cs @@ -0,0 +1,607 @@ +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 mainClipDuration = ResolveClipDuration(skill.SkillClip, resolvedAnimationSpeed); + float endClipDuration = ResolveClipDuration(skill.EndClip, 1f); + int repeatCount = loadoutEntry.GetResolvedRepeatCount(); + snapshot.castDuration = Mathf.Max(MinimumActionDuration, (mainClipDuration * repeatCount) + endClipDuration + ruleSet.MovementLossSecondsPerCast); + + Dictionary> effectMap = new Dictionary>(); + loadoutEntry.CollectTriggeredEffects(effectMap); + + BuildDamageEvents(snapshot, effectMap, context, weaponDamageMultiplier, ruleSet, mainClipDuration, resolvedAnimationSpeed, repeatCount, warnings); + snapshots[slotIndex] = snapshot; + } + + return snapshots; + } + + private static void BuildDamageEvents( + SkillRuntimeSnapshot snapshot, + Dictionary> effectMap, + SimulationContext context, + float weaponDamageMultiplier, + SimulationRuleSet ruleSet, + float mainClipDuration, + float resolvedAnimationSpeed, + int repeatCount, + List warnings) + { + if (snapshot == null || effectMap == null || effectMap.Count == 0) + return; + + List effectEvents = new List(); + AnimationClip clip = snapshot.skill.SkillClip; + if (clip != null) + { + AnimationEvent[] clipEvents = clip.events; + for (int i = 0; i < clipEvents.Length; i++) + { + if (string.Equals(clipEvents[i].functionName, "OnEffect", StringComparison.Ordinal)) + effectEvents.Add(clipEvents[i]); + } + } + + 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 = mainClipDuration * 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 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); + } + } +} diff --git a/Assets/_Game/Scripts/Combat/Simulation/BuildSimulationEngine.cs.meta b/Assets/_Game/Scripts/Combat/Simulation/BuildSimulationEngine.cs.meta new file mode 100644 index 00000000..5b1e55d6 --- /dev/null +++ b/Assets/_Game/Scripts/Combat/Simulation/BuildSimulationEngine.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 67edd6a2a08d58646a03a1b1c45e5a5c \ No newline at end of file diff --git a/Assets/_Game/Scripts/Combat/Simulation/BuildSimulationInput.cs b/Assets/_Game/Scripts/Combat/Simulation/BuildSimulationInput.cs new file mode 100644 index 00000000..593cad79 --- /dev/null +++ b/Assets/_Game/Scripts/Combat/Simulation/BuildSimulationInput.cs @@ -0,0 +1,338 @@ +using System.Collections.Generic; +using System.Text; + +using UnityEngine; + +using Colosseum.Passives; +using Colosseum.Skills; +using Colosseum.Weapons; + +namespace Colosseum.Combat.Simulation +{ + /// + /// 허수아비 계산 시뮬레이터가 사용할 완성형 빌드 입력값입니다. + /// 프리셋 없이 직접 구성한 빌드와 기존 프리셋 기반 입력을 모두 지원합니다. + /// + [System.Serializable] + public class BuildSimulationInput + { + private const int DefaultSlotCount = 7; + + [Header("Label")] + [SerializeField] private string buildName = "새 빌드"; + + [Header("Stats")] + [Min(0f)] [SerializeField] private float strength = 10f; + [Min(0f)] [SerializeField] private float dexterity = 10f; + [Min(0f)] [SerializeField] private float intelligence = 10f; + [Min(0f)] [SerializeField] private float vitality = 10f; + [Min(0f)] [SerializeField] private float wisdom = 10f; + [Min(0f)] [SerializeField] private float spirit = 10f; + + [Header("Build Assets")] + [SerializeField] private WeaponData weapon; + + [Header("Direct Build")] + [Tooltip("직접 구성한 스킬/젬 슬롯입니다. 비어 있으면 로드아웃 프리셋을 사용합니다.")] + [SerializeField] private SkillLoadoutEntry[] directSkillSlots = new SkillLoadoutEntry[DefaultSlotCount]; + [Tooltip("직접 구성한 패시브 트리입니다. 비어 있으면 패시브 프리셋의 트리를 사용합니다.")] + [SerializeField] private PassiveTreeData passiveTree; + [Tooltip("직접 선택한 패시브 노드 목록입니다. 비어 있으면 패시브 프리셋의 선택 노드를 사용합니다.")] + [SerializeField] private List selectedPassiveNodes = new List(); + + [Header("Preset Fallback")] + [SerializeField] private PassivePresetData passivePreset; + [SerializeField] private PlayerLoadoutPreset loadoutPreset; + + public string BuildName => string.IsNullOrWhiteSpace(buildName) ? "새 빌드" : buildName.Trim(); + public float Strength => strength; + public float Dexterity => dexterity; + public float Intelligence => intelligence; + public float Vitality => vitality; + public float Wisdom => wisdom; + public float Spirit => spirit; + public WeaponData Weapon => weapon; + public PassiveTreeData PassiveTree => passiveTree; + public IReadOnlyList SelectedPassiveNodes => selectedPassiveNodes; + public PassivePresetData PassivePreset => passivePreset; + public PlayerLoadoutPreset LoadoutPreset => loadoutPreset; + + /// + /// 입력 상태를 읽기 쉬운 라벨로 구성합니다. + /// + public string BuildLabel + { + get + { + string passiveName = ResolvePassiveLabel(); + string loadoutName = ResolveLoadoutLabel(); + return $"{BuildName} | Passive={passiveName} | Loadout={loadoutName}"; + } + } + + /// + /// 직접 구성 슬롯과 프리셋을 합쳐 실제 계산에 사용할 슬롯 배열을 반환합니다. + /// + public IReadOnlyList ResolveLoadoutEntries() + { + EnsureDirectSlotCapacity(); + + if (HasDirectSkillSetup()) + return directSkillSlots; + + return loadoutPreset != null ? loadoutPreset.Slots : System.Array.Empty(); + } + + /// + /// 실제 계산 가능한 기반 스킬이 하나 이상 있는지 반환합니다. + /// + public bool HasAnyResolvedSkill() + { + IReadOnlyList entries = ResolveLoadoutEntries(); + for (int i = 0; i < entries.Count; i++) + { + if (entries[i] != null && entries[i].BaseSkill != null) + return true; + } + + return false; + } + + /// + /// 실제 계산에 사용할 패시브 트리를 반환합니다. + /// + public PassiveTreeData ResolvePassiveTree() + { + if (passiveTree != null) + return passiveTree; + + return passivePreset != null ? passivePreset.Tree : null; + } + + /// + /// 실제 계산에 사용할 패시브 노드 ID 목록을 반환합니다. + /// + public List BuildSelectedPassiveNodeIdList() + { + List nodeIds = new List(); + + IReadOnlyList resolvedNodes = ResolveSelectedPassiveNodes(); + for (int i = 0; i < resolvedNodes.Count; i++) + { + PassiveNodeData node = resolvedNodes[i]; + if (node == null || string.IsNullOrWhiteSpace(node.NodeId)) + continue; + + nodeIds.Add(node.NodeId); + } + return nodeIds; + } + + /// + /// 실제 계산에 사용할 패시브 노드 목록을 반환합니다. + /// + public IReadOnlyList ResolveSelectedPassiveNodes() + { + if (selectedPassiveNodes != null && selectedPassiveNodes.Count > 0) + return selectedPassiveNodes; + + return passivePreset != null ? passivePreset.SelectedNodes : System.Array.Empty(); + } + + /// + /// 직접 구성 슬롯을 덮어씁니다. + /// + public void SetDirectSkillSlots(IReadOnlyList entries) + { + EnsureDirectSlotCapacity(); + + for (int i = 0; i < directSkillSlots.Length; i++) + { + directSkillSlots[i] = null; + } + + if (entries == null) + return; + + int copyCount = Mathf.Min(entries.Count, directSkillSlots.Length); + for (int i = 0; i < copyCount; i++) + { + SkillLoadoutEntry entry = entries[i]; + directSkillSlots[i] = entry != null ? entry.CreateCopy() : null; + } + } + + /// + /// 직접 구성 패시브를 덮어씁니다. + /// + public void SetDirectPassiveSelection(PassiveTreeData tree, IReadOnlyList nodes) + { + passiveTree = tree; + selectedPassiveNodes.Clear(); + + if (nodes == null) + return; + + for (int i = 0; i < nodes.Count; i++) + { + if (nodes[i] != null) + selectedPassiveNodes.Add(nodes[i]); + } + } + + /// + /// 빌드 이름을 갱신합니다. + /// + public void SetBuildName(string value) + { + buildName = value ?? string.Empty; + } + + /// + /// 현재 입력값의 복사본을 생성합니다. + /// + public BuildSimulationInput CreateCopy() + { + EnsureDirectSlotCapacity(); + + BuildSimulationInput copy = new BuildSimulationInput + { + buildName = buildName, + strength = strength, + dexterity = dexterity, + intelligence = intelligence, + vitality = vitality, + wisdom = wisdom, + spirit = spirit, + weapon = weapon, + passiveTree = passiveTree, + passivePreset = passivePreset, + loadoutPreset = loadoutPreset, + }; + + copy.directSkillSlots = new SkillLoadoutEntry[directSkillSlots.Length]; + for (int i = 0; i < directSkillSlots.Length; i++) + { + copy.directSkillSlots[i] = directSkillSlots[i] != null ? directSkillSlots[i].CreateCopy() : null; + } + + copy.selectedPassiveNodes = new List(selectedPassiveNodes.Count); + for (int i = 0; i < selectedPassiveNodes.Count; i++) + { + if (selectedPassiveNodes[i] != null) + copy.selectedPassiveNodes.Add(selectedPassiveNodes[i]); + } + + return copy; + } + + private bool HasDirectSkillSetup() + { + if (directSkillSlots == null) + return false; + + for (int i = 0; i < directSkillSlots.Length; i++) + { + if (directSkillSlots[i] != null && directSkillSlots[i].BaseSkill != null) + return true; + } + + return false; + } + + private void EnsureDirectSlotCapacity() + { + if (directSkillSlots != null && directSkillSlots.Length == DefaultSlotCount) + return; + + SkillLoadoutEntry[] resized = new SkillLoadoutEntry[DefaultSlotCount]; + if (directSkillSlots != null) + { + int copyCount = Mathf.Min(directSkillSlots.Length, resized.Length); + for (int i = 0; i < copyCount; i++) + { + resized[i] = directSkillSlots[i]; + } + } + + directSkillSlots = resized; + } + + private string ResolvePassiveLabel() + { + if (selectedPassiveNodes != null && selectedPassiveNodes.Count > 0) + return BuildPassiveNodeSummary(selectedPassiveNodes); + + if (passivePreset != null && !string.IsNullOrWhiteSpace(passivePreset.PresetName)) + return passivePreset.PresetName; + + return "패시브 없음"; + } + + private string ResolveLoadoutLabel() + { + if (HasDirectSkillSetup()) + return BuildLoadoutSummary(directSkillSlots); + + if (loadoutPreset != null && !string.IsNullOrWhiteSpace(loadoutPreset.PresetName)) + return loadoutPreset.PresetName; + + return "로드아웃 없음"; + } + + private static string BuildPassiveNodeSummary(IReadOnlyList nodes) + { + StringBuilder builder = new StringBuilder(); + for (int i = 0; i < nodes.Count; i++) + { + PassiveNodeData node = nodes[i]; + if (node == null) + continue; + + if (builder.Length > 0) + builder.Append('+'); + + builder.Append(string.IsNullOrWhiteSpace(node.DisplayName) ? node.name : node.DisplayName); + } + + return builder.Length > 0 ? builder.ToString() : "패시브 없음"; + } + + private static string BuildLoadoutSummary(IReadOnlyList entries) + { + StringBuilder builder = new StringBuilder(); + for (int i = 0; i < entries.Count; i++) + { + SkillLoadoutEntry entry = entries[i]; + if (entry == null || entry.BaseSkill == null) + continue; + + if (builder.Length > 0) + builder.Append(" / "); + + builder.Append('S'); + builder.Append(i); + builder.Append('='); + builder.Append(entry.BaseSkill.SkillName); + + bool hasGem = false; + IReadOnlyList gems = entry.SocketedGems; + for (int gemIndex = 0; gemIndex < gems.Count; gemIndex++) + { + SkillGemData gem = gems[gemIndex]; + if (gem == null) + continue; + + builder.Append(hasGem ? '+' : '['); + builder.Append(gem.GemName); + hasGem = true; + } + + if (hasGem) + builder.Append(']'); + } + + return builder.Length > 0 ? builder.ToString() : "직접구성"; + } + } +} diff --git a/Assets/_Game/Scripts/Combat/Simulation/BuildSimulationInput.cs.meta b/Assets/_Game/Scripts/Combat/Simulation/BuildSimulationInput.cs.meta new file mode 100644 index 00000000..c320fd92 --- /dev/null +++ b/Assets/_Game/Scripts/Combat/Simulation/BuildSimulationInput.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: f38a3a26c057b2f459b8d2bb89bc5482 \ No newline at end of file diff --git a/Assets/_Game/Scripts/Combat/Simulation/RotationPolicy.cs b/Assets/_Game/Scripts/Combat/Simulation/RotationPolicy.cs new file mode 100644 index 00000000..327b06e2 --- /dev/null +++ b/Assets/_Game/Scripts/Combat/Simulation/RotationPolicy.cs @@ -0,0 +1,56 @@ +using UnityEngine; + +namespace Colosseum.Combat.Simulation +{ + /// + /// 허수아비 계산 시뮬레이터의 회전 규칙입니다. + /// + [System.Serializable] + public class RotationPolicy + { + [Header("Label")] + [SerializeField] private string policyName = "기본 우선순위"; + + [Header("Priority")] + [Tooltip("앞에서부터 우선 적용할 슬롯 순서입니다. 0 기반 인덱스를 사용합니다.")] + [SerializeField] private int[] prioritySlots = new[] { 0, 1, 2, 3, 4, 5 }; + + [Header("Fallback")] + [SerializeField] private bool useFallbackSlot = true; + [Min(0)] [SerializeField] private int fallbackSlotIndex = 0; + + [Header("High Power Skill")] + [SerializeField] private bool delayHighPowerSkillUntilTime; + [Min(0)] [SerializeField] private int highPowerSlotIndex = 5; + [Min(0f)] [SerializeField] private float highPowerFirstUseTime = 0f; + + public string PolicyName => string.IsNullOrWhiteSpace(policyName) ? "Rotation" : policyName.Trim(); + public int[] PrioritySlots => prioritySlots ?? System.Array.Empty(); + public bool UseFallbackSlot => useFallbackSlot; + public int FallbackSlotIndex => Mathf.Max(0, fallbackSlotIndex); + public bool DelayHighPowerSkillUntilTime => delayHighPowerSkillUntilTime; + public int HighPowerSlotIndex => Mathf.Max(0, highPowerSlotIndex); + public float HighPowerFirstUseTime => Mathf.Max(0f, highPowerFirstUseTime); + + /// + /// 회전 정책 값을 한 번에 설정합니다. + /// + public void Configure( + string policyName, + int[] prioritySlots, + bool useFallbackSlot, + int fallbackSlotIndex, + bool delayHighPowerSkillUntilTime, + int highPowerSlotIndex, + float highPowerFirstUseTime) + { + this.policyName = policyName ?? string.Empty; + this.prioritySlots = prioritySlots ?? System.Array.Empty(); + this.useFallbackSlot = useFallbackSlot; + this.fallbackSlotIndex = Mathf.Max(0, fallbackSlotIndex); + this.delayHighPowerSkillUntilTime = delayHighPowerSkillUntilTime; + this.highPowerSlotIndex = Mathf.Max(0, highPowerSlotIndex); + this.highPowerFirstUseTime = Mathf.Max(0f, highPowerFirstUseTime); + } + } +} diff --git a/Assets/_Game/Scripts/Combat/Simulation/RotationPolicy.cs.meta b/Assets/_Game/Scripts/Combat/Simulation/RotationPolicy.cs.meta new file mode 100644 index 00000000..d433cb54 --- /dev/null +++ b/Assets/_Game/Scripts/Combat/Simulation/RotationPolicy.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: ef4440d129fca8442bdca9bdafdec626 \ No newline at end of file diff --git a/Assets/_Game/Scripts/Combat/Simulation/SimulationBatchReportUtility.cs b/Assets/_Game/Scripts/Combat/Simulation/SimulationBatchReportUtility.cs new file mode 100644 index 00000000..36c0b734 --- /dev/null +++ b/Assets/_Game/Scripts/Combat/Simulation/SimulationBatchReportUtility.cs @@ -0,0 +1,162 @@ +using System.Text; + +namespace Colosseum.Combat.Simulation +{ + /// + /// 배치 시뮬레이션 결과를 추출용 문자열로 변환합니다. + /// + public static class SimulationBatchReportUtility + { + /// + /// Markdown 리포트를 생성합니다. + /// + public static string BuildMarkdown(SimulationBatchResult result) + { + if (result == null) + return string.Empty; + + StringBuilder builder = new StringBuilder(); + builder.Append("# 허수아비 배치 시뮬레이션 결과"); + builder.AppendLine(); + builder.AppendLine(); + builder.Append("- Batch: "); + builder.Append(result.BatchName); + builder.AppendLine(); + builder.Append("- Generated Builds: "); + builder.Append(result.GeneratedBuildCount); + builder.AppendLine(); + builder.Append("- Truncated: "); + builder.Append(result.Truncated ? "Yes" : "No"); + builder.AppendLine(); + builder.AppendLine(); + builder.AppendLine("| 순위 | 빌드 | DPS | 총 피해 | 총 마나 | 첫 사이클 |"); + builder.AppendLine("| --- | --- | ---: | ---: | ---: | ---: |"); + + for (int i = 0; i < result.Entries.Count; i++) + { + SimulationBatchEntry entry = result.Entries[i]; + SimulationResult simulation = entry != null ? entry.Result : null; + if (simulation == null) + continue; + + builder.Append("| "); + builder.Append(i + 1); + builder.Append(" | "); + builder.Append(entry.BuildLabel); + builder.Append(" | "); + builder.Append(simulation.AverageDps.ToString("0.##")); + builder.Append(" | "); + builder.Append(simulation.TotalDamage.ToString("0.##")); + builder.Append(" | "); + builder.Append(simulation.TotalManaUsed.ToString("0.##")); + builder.Append(" | "); + builder.Append(simulation.FirstCycleEndTime >= 0f ? simulation.FirstCycleEndTime.ToString("0.##") + "s" : "미완료"); + builder.AppendLine(" |"); + } + + if (result.Warnings.Count > 0) + { + builder.AppendLine(); + builder.AppendLine("## 경고"); + builder.AppendLine(); + for (int i = 0; i < result.Warnings.Count; i++) + { + builder.Append("- "); + builder.Append(result.Warnings[i]); + builder.AppendLine(); + } + } + + return builder.ToString().TrimEnd(); + } + + /// + /// CSV 리포트를 생성합니다. + /// + public static string BuildCsv(SimulationBatchResult result) + { + if (result == null) + return string.Empty; + + StringBuilder builder = new StringBuilder(); + builder.AppendLine("Rank,BuildLabel,RuleName,RotationName,DurationSeconds,TotalDamage,AverageDps,TotalManaUsed,AverageManaPerSecond,FirstCycleEndTime,TopSkill,TopSkillDamage,Warnings"); + + for (int i = 0; i < result.Entries.Count; i++) + { + SimulationBatchEntry entry = result.Entries[i]; + SimulationResult simulation = entry != null ? entry.Result : null; + if (simulation == null) + continue; + + SimulationSkillBreakdown topSkill = simulation.SkillBreakdowns.Count > 0 ? simulation.SkillBreakdowns[0] : null; + string warnings = string.Join(" / ", simulation.Warnings); + + builder.Append(i + 1); + builder.Append(','); + builder.Append(Escape(entry.BuildLabel)); + builder.Append(','); + builder.Append(Escape(simulation.RuleName)); + builder.Append(','); + builder.Append(Escape(simulation.RotationName)); + builder.Append(','); + builder.Append(simulation.DurationSeconds.ToString("0.##")); + builder.Append(','); + builder.Append(simulation.TotalDamage.ToString("0.##")); + builder.Append(','); + builder.Append(simulation.AverageDps.ToString("0.##")); + builder.Append(','); + builder.Append(simulation.TotalManaUsed.ToString("0.##")); + builder.Append(','); + builder.Append(simulation.AverageManaPerSecond.ToString("0.##")); + builder.Append(','); + builder.Append(simulation.FirstCycleEndTime >= 0f ? simulation.FirstCycleEndTime.ToString("0.##") : string.Empty); + builder.Append(','); + builder.Append(Escape(topSkill != null ? topSkill.SkillName : string.Empty)); + builder.Append(','); + builder.Append(topSkill != null ? topSkill.TotalDamage.ToString("0.##") : string.Empty); + builder.Append(','); + builder.Append(Escape(warnings)); + builder.AppendLine(); + } + + return builder.ToString().TrimEnd(); + } + + /// + /// 기본 파일 이름을 생성합니다. + /// + public static string BuildDefaultFileName(SimulationBatchResult result, bool csv) + { + string batchName = result != null && !string.IsNullOrWhiteSpace(result.BatchName) ? result.BatchName : "BuildSimulationBatch"; + string extension = csv ? "csv" : "md"; + return $"{Sanitize(batchName)}.{extension}"; + } + + private static string Escape(string value) + { + if (string.IsNullOrEmpty(value)) + return string.Empty; + + bool needsQuotes = value.Contains(",") || value.Contains("\"") || value.Contains("\n") || value.Contains("\r"); + if (!needsQuotes) + return value; + + return $"\"{value.Replace("\"", "\"\"")}\""; + } + + private static string Sanitize(string value) + { + if (string.IsNullOrWhiteSpace(value)) + return "BuildSimulationBatch"; + + string sanitized = value; + char[] invalidChars = System.IO.Path.GetInvalidFileNameChars(); + for (int i = 0; i < invalidChars.Length; i++) + { + sanitized = sanitized.Replace(invalidChars[i], '_'); + } + + return sanitized.Replace(' ', '_'); + } + } +} diff --git a/Assets/_Game/Scripts/Combat/Simulation/SimulationBatchReportUtility.cs.meta b/Assets/_Game/Scripts/Combat/Simulation/SimulationBatchReportUtility.cs.meta new file mode 100644 index 00000000..29d40897 --- /dev/null +++ b/Assets/_Game/Scripts/Combat/Simulation/SimulationBatchReportUtility.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: d741ba4f455e909469d52f22bad21c8b \ No newline at end of file diff --git a/Assets/_Game/Scripts/Combat/Simulation/SimulationBatchResult.cs b/Assets/_Game/Scripts/Combat/Simulation/SimulationBatchResult.cs new file mode 100644 index 00000000..132efa9c --- /dev/null +++ b/Assets/_Game/Scripts/Combat/Simulation/SimulationBatchResult.cs @@ -0,0 +1,53 @@ +using System.Collections.Generic; + +using UnityEngine; + +namespace Colosseum.Combat.Simulation +{ + /// + /// 배치 시뮬레이션의 단일 결과 엔트리입니다. + /// + [System.Serializable] + public sealed class SimulationBatchEntry + { + [SerializeField] private string buildLabel; + [SerializeField] private SimulationResult result; + + public string BuildLabel => buildLabel; + public SimulationResult Result => result; + + public SimulationBatchEntry(string buildLabel, SimulationResult result) + { + this.buildLabel = buildLabel ?? string.Empty; + this.result = result; + } + } + + /// + /// 배치 시뮬레이션의 전체 결과입니다. + /// + [System.Serializable] + public sealed class SimulationBatchResult + { + [SerializeField] private string batchName = string.Empty; + [SerializeField] private int generatedBuildCount; + [SerializeField] private bool truncated; + [SerializeField] private List entries = new List(); + [SerializeField] private List warnings = new List(); + + public string BatchName => batchName; + public int GeneratedBuildCount => generatedBuildCount; + public bool Truncated => truncated; + public IReadOnlyList Entries => entries; + public IReadOnlyList Warnings => warnings; + + public void Initialize(string batchName, int generatedBuildCount, bool truncated, List entries, List warnings) + { + this.batchName = batchName ?? string.Empty; + this.generatedBuildCount = Mathf.Max(0, generatedBuildCount); + this.truncated = truncated; + this.entries = entries ?? new List(); + this.warnings = warnings ?? new List(); + } + } +} diff --git a/Assets/_Game/Scripts/Combat/Simulation/SimulationBatchResult.cs.meta b/Assets/_Game/Scripts/Combat/Simulation/SimulationBatchResult.cs.meta new file mode 100644 index 00000000..a75b4966 --- /dev/null +++ b/Assets/_Game/Scripts/Combat/Simulation/SimulationBatchResult.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 6b13c6fe196ef0343942b700dbc02414 \ No newline at end of file diff --git a/Assets/_Game/Scripts/Combat/Simulation/SimulationBatchRunner.cs b/Assets/_Game/Scripts/Combat/Simulation/SimulationBatchRunner.cs new file mode 100644 index 00000000..db62ee9d --- /dev/null +++ b/Assets/_Game/Scripts/Combat/Simulation/SimulationBatchRunner.cs @@ -0,0 +1,58 @@ +using System.Collections.Generic; + +namespace Colosseum.Combat.Simulation +{ + /// + /// 여러 빌드를 순회하며 배치 시뮬레이션을 실행합니다. + /// + public static class SimulationBatchRunner + { + /// + /// 생성된 빌드 목록을 순회 실행하고 결과를 묶어 반환합니다. + /// + public static SimulationBatchResult Run( + string batchName, + IReadOnlyList builds, + SimulationRuleSet ruleSet, + RotationPolicy rotationPolicy, + IReadOnlyList generationWarnings, + bool truncated) + { + List entries = new List(); + List warnings = new List(); + + if (generationWarnings != null) + { + for (int i = 0; i < generationWarnings.Count; i++) + { + if (!string.IsNullOrWhiteSpace(generationWarnings[i])) + warnings.Add(generationWarnings[i]); + } + } + + if (builds != null) + { + for (int i = 0; i < builds.Count; i++) + { + BuildSimulationInput build = builds[i]; + if (build == null) + continue; + + SimulationResult result = BuildSimulationEngine.Run(build, ruleSet, rotationPolicy); + entries.Add(new SimulationBatchEntry(build.BuildLabel, result)); + } + } + + entries.Sort((left, right) => + { + float leftDps = left != null && left.Result != null ? left.Result.AverageDps : 0f; + float rightDps = right != null && right.Result != null ? right.Result.AverageDps : 0f; + return rightDps.CompareTo(leftDps); + }); + + SimulationBatchResult batchResult = new SimulationBatchResult(); + batchResult.Initialize(batchName, builds != null ? builds.Count : 0, truncated, entries, warnings); + return batchResult; + } + } +} diff --git a/Assets/_Game/Scripts/Combat/Simulation/SimulationBatchRunner.cs.meta b/Assets/_Game/Scripts/Combat/Simulation/SimulationBatchRunner.cs.meta new file mode 100644 index 00000000..9e02df4a --- /dev/null +++ b/Assets/_Game/Scripts/Combat/Simulation/SimulationBatchRunner.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 886744cabe7fc084e884d2dbd297ed8e \ No newline at end of file diff --git a/Assets/_Game/Scripts/Combat/Simulation/SimulationCombinationGenerator.cs b/Assets/_Game/Scripts/Combat/Simulation/SimulationCombinationGenerator.cs new file mode 100644 index 00000000..a312db67 --- /dev/null +++ b/Assets/_Game/Scripts/Combat/Simulation/SimulationCombinationGenerator.cs @@ -0,0 +1,424 @@ +using System.Collections.Generic; + +using Colosseum.Passives; +using Colosseum.Skills; + +namespace Colosseum.Combat.Simulation +{ + /// + /// 스킬/젬/패시브 자산 풀에서 유효한 빌드 조합을 생성합니다. + /// + public static class SimulationCombinationGenerator + { + /// + /// 조합 조건에 따라 실제 시뮬레이션 입력 빌드를 생성합니다. + /// + public static List GenerateBuilds( + BuildSimulationInput template, + SimulationCombinationSpec spec, + IReadOnlyList skillPool, + IReadOnlyList gemPool, + IReadOnlyList passiveNodePool, + List warnings, + out bool truncated) + { + warnings ??= new List(); + truncated = false; + + BuildSimulationInput baseTemplate = template != null ? template.CreateCopy() : new BuildSimulationInput(); + List results = new List(); + SimulationCombinationSpec safeSpec = spec ?? new SimulationCombinationSpec(); + + List> passiveSelections = BuildPassiveSelections(baseTemplate, safeSpec, passiveNodePool, warnings); + List activeSlots = BuildActiveSlots(safeSpec); + Dictionary> loadoutCache = new Dictionary>(); + SkillLoadoutEntry[] workingSlots = new SkillLoadoutEntry[7]; + + for (int i = 0; i < workingSlots.Length; i++) + { + IReadOnlyList templateSlots = baseTemplate.ResolveLoadoutEntries(); + if (i < templateSlots.Count && templateSlots[i] != null) + workingSlots[i] = templateSlots[i].CreateCopy(); + } + + int buildIndex = 0; + for (int passiveIndex = 0; passiveIndex < passiveSelections.Count; passiveIndex++) + { + if (results.Count >= safeSpec.MaxBuildCount) + { + truncated = true; + break; + } + + List passiveSelection = passiveSelections[passiveIndex]; + GenerateSkillAssignments( + baseTemplate, + safeSpec, + skillPool, + gemPool, + activeSlots, + loadoutCache, + workingSlots, + 0, + new HashSet(), + passiveSelection, + results, + warnings, + ref buildIndex, + ref truncated); + } + + if (truncated) + warnings.Add($"조합 생성이 상한에 도달해 중단되었습니다. MaxBuildCount={safeSpec.MaxBuildCount}"); + + return results; + } + + private static void GenerateSkillAssignments( + BuildSimulationInput template, + SimulationCombinationSpec spec, + IReadOnlyList skillPool, + IReadOnlyList gemPool, + IReadOnlyList activeSlots, + Dictionary> loadoutCache, + SkillLoadoutEntry[] workingSlots, + int slotCursor, + HashSet usedSkills, + IReadOnlyList passiveSelection, + List results, + List warnings, + ref int buildIndex, + ref bool truncated) + { + if (truncated || results.Count >= spec.MaxBuildCount) + { + truncated = true; + return; + } + + if (slotCursor >= activeSlots.Count) + { + BuildSimulationInput variant = template.CreateCopy(); + variant.SetDirectSkillSlots(workingSlots); + variant.SetDirectPassiveSelection(spec.CombinePassives ? spec.PassiveTree : template.ResolvePassiveTree(), passiveSelection); + variant.SetBuildName($"{spec.BatchName}_{buildIndex + 1:D4}"); + results.Add(variant); + buildIndex++; + return; + } + + int slotIndex = activeSlots[slotCursor]; + if (!spec.CombineSkills) + { + GenerateFromTemplateSlot( + template, + spec, + gemPool, + activeSlots, + loadoutCache, + workingSlots, + slotCursor, + passiveSelection, + results, + warnings, + ref buildIndex, + ref truncated); + return; + } + + if (skillPool == null || skillPool.Count == 0) + { + warnings.Add("스킬 풀에 자산이 없어 조합 생성을 진행하지 못했습니다."); + truncated = true; + return; + } + + for (int i = 0; i < skillPool.Count; i++) + { + SkillData skill = skillPool[i]; + if (skill == null) + continue; + + if (!spec.AllowDuplicateSkills && usedSkills.Contains(skill)) + continue; + + List variants = GetLoadoutVariants(skill, spec, gemPool, loadoutCache); + for (int variantIndex = 0; variantIndex < variants.Count; variantIndex++) + { + workingSlots[slotIndex] = variants[variantIndex].CreateCopy(); + + bool added = false; + if (!spec.AllowDuplicateSkills) + { + added = usedSkills.Add(skill); + } + + GenerateSkillAssignments( + template, + spec, + skillPool, + gemPool, + activeSlots, + loadoutCache, + workingSlots, + slotCursor + 1, + usedSkills, + passiveSelection, + results, + warnings, + ref buildIndex, + ref truncated); + + if (added) + usedSkills.Remove(skill); + + if (truncated) + return; + } + } + } + + private static void GenerateFromTemplateSlot( + BuildSimulationInput template, + SimulationCombinationSpec spec, + IReadOnlyList gemPool, + IReadOnlyList activeSlots, + Dictionary> loadoutCache, + SkillLoadoutEntry[] workingSlots, + int slotCursor, + IReadOnlyList passiveSelection, + List results, + List warnings, + ref int buildIndex, + ref bool truncated) + { + int slotIndex = activeSlots[slotCursor]; + IReadOnlyList templateSlots = template.ResolveLoadoutEntries(); + SkillLoadoutEntry templateEntry = slotIndex < templateSlots.Count ? templateSlots[slotIndex] : null; + SkillData skill = templateEntry != null ? templateEntry.BaseSkill : null; + if (skill == null) + { + warnings.Add($"템플릿 슬롯 {slotIndex}에 기반 스킬이 없어 배치 생성이 중단되었습니다."); + truncated = true; + return; + } + + List variants = spec.CombineGems + ? GetLoadoutVariants(skill, spec, gemPool, loadoutCache) + : new List { templateEntry.CreateCopy() }; + + for (int i = 0; i < variants.Count; i++) + { + workingSlots[slotIndex] = variants[i].CreateCopy(); + GenerateSkillAssignments( + template, + spec, + null, + gemPool, + activeSlots, + loadoutCache, + workingSlots, + slotCursor + 1, + new HashSet(), + passiveSelection, + results, + warnings, + ref buildIndex, + ref truncated); + + if (truncated) + return; + } + } + + private static List GetLoadoutVariants( + SkillData skill, + SimulationCombinationSpec spec, + IReadOnlyList gemPool, + Dictionary> loadoutCache) + { + if (loadoutCache.TryGetValue(skill, out List cachedVariants)) + return cachedVariants; + + List variants = new List(); + int gemSlotCount = skill != null ? skill.MaxGemSlotCount : 0; + + if (skill == null) + return variants; + + List compatibleGems = new List(); + if (gemPool != null) + { + for (int i = 0; i < gemPool.Count; i++) + { + SkillGemData gem = gemPool[i]; + if (gem != null && gem.CanAttachToSkill(skill)) + compatibleGems.Add(gem); + } + } + + if (!spec.CombineGems || gemSlotCount <= 0 || compatibleGems.Count == 0) + { + variants.Add(SkillLoadoutEntry.CreateTemporary(skill)); + loadoutCache.Add(skill, variants); + return variants; + } + + List selected = new List(); + if (spec.IncludeEmptyGemSet) + variants.Add(SkillLoadoutEntry.CreateTemporary(skill)); + + CollectGemVariants(skill, compatibleGems, selected, 0, gemSlotCount, variants); + + if (variants.Count == 0) + variants.Add(SkillLoadoutEntry.CreateTemporary(skill)); + + loadoutCache.Add(skill, variants); + return variants; + } + + private static void CollectGemVariants( + SkillData skill, + IReadOnlyList compatibleGems, + List selected, + int startIndex, + int maxGemCount, + List variants) + { + if (selected.Count > 0) + { + SkillLoadoutEntry entry = SkillLoadoutEntry.CreateTemporary(skill); + bool valid = true; + for (int i = 0; i < selected.Count; i++) + { + if (!entry.TrySetGem(i, selected[i], out _)) + { + valid = false; + break; + } + } + + if (valid) + variants.Add(entry.CreateCopy()); + } + + if (selected.Count >= maxGemCount) + return; + + for (int i = startIndex; i < compatibleGems.Count; i++) + { + selected.Add(compatibleGems[i]); + CollectGemVariants(skill, compatibleGems, selected, i + 1, maxGemCount, variants); + selected.RemoveAt(selected.Count - 1); + } + } + + private static List> BuildPassiveSelections( + BuildSimulationInput template, + SimulationCombinationSpec spec, + IReadOnlyList passiveNodePool, + List warnings) + { + List> selections = new List>(); + + if (!spec.CombinePassives) + { + List currentSelection = new List(); + IReadOnlyList templateNodes = template.ResolveSelectedPassiveNodes(); + for (int i = 0; i < templateNodes.Count; i++) + { + if (templateNodes[i] != null) + currentSelection.Add(templateNodes[i]); + } + + selections.Add(currentSelection); + return selections; + } + + PassiveTreeData tree = spec.PassiveTree; + if (tree == null) + { + warnings.Add("패시브 트리가 없어 패시브 조합 생성은 빈 선택만 사용합니다."); + selections.Add(new List()); + return selections; + } + + List candidates = new List(); + IReadOnlyList sourcePool = passiveNodePool != null && passiveNodePool.Count > 0 ? passiveNodePool : tree.Nodes; + for (int i = 0; i < sourcePool.Count; i++) + { + PassiveNodeData node = sourcePool[i]; + if (node != null && tree.GetNodeById(node.NodeId) != null) + candidates.Add(node); + } + + List selected = new List(); + if (spec.IncludeEmptyPassiveSelection) + selections.Add(new List()); + + CollectPassiveSelections(tree, spec, candidates, selected, 0, selections); + + if (selections.Count == 0) + selections.Add(new List()); + + return selections; + } + + private static void CollectPassiveSelections( + PassiveTreeData tree, + SimulationCombinationSpec spec, + IReadOnlyList candidates, + List selected, + int startIndex, + List> results) + { + if (spec.MaxPassiveNodeCount > 0 && selected.Count >= spec.MaxPassiveNodeCount) + return; + + for (int i = startIndex; i < candidates.Count; i++) + { + PassiveNodeData node = candidates[i]; + selected.Add(node); + + if (TryValidatePassiveSelection(tree, selected)) + { + results.Add(new List(selected)); + CollectPassiveSelections(tree, spec, candidates, selected, i + 1, results); + } + + selected.RemoveAt(selected.Count - 1); + } + } + + private static bool TryValidatePassiveSelection(PassiveTreeData tree, IReadOnlyList selected) + { + List ids = new List(selected.Count); + for (int i = 0; i < selected.Count; i++) + { + if (selected[i] != null && !string.IsNullOrWhiteSpace(selected[i].NodeId)) + ids.Add(selected[i].NodeId); + } + + return tree.TryResolveSelection(ids, out _, out _); + } + + private static List BuildActiveSlots(SimulationCombinationSpec spec) + { + List activeSlots = new List(); + int[] slotIndices = spec.ActiveSlotIndices; + for (int i = 0; i < slotIndices.Length; i++) + { + int slotIndex = slotIndices[i]; + if (slotIndex < 0 || slotIndex >= 7 || activeSlots.Contains(slotIndex)) + continue; + + activeSlots.Add(slotIndex); + } + + if (activeSlots.Count == 0) + activeSlots.Add(0); + + return activeSlots; + } + } +} diff --git a/Assets/_Game/Scripts/Combat/Simulation/SimulationCombinationGenerator.cs.meta b/Assets/_Game/Scripts/Combat/Simulation/SimulationCombinationGenerator.cs.meta new file mode 100644 index 00000000..e4edbf33 --- /dev/null +++ b/Assets/_Game/Scripts/Combat/Simulation/SimulationCombinationGenerator.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 68001ec95567cad4bb70cfc9863c469a \ No newline at end of file diff --git a/Assets/_Game/Scripts/Combat/Simulation/SimulationCombinationSpec.cs b/Assets/_Game/Scripts/Combat/Simulation/SimulationCombinationSpec.cs new file mode 100644 index 00000000..9a94f08e --- /dev/null +++ b/Assets/_Game/Scripts/Combat/Simulation/SimulationCombinationSpec.cs @@ -0,0 +1,78 @@ +using UnityEngine; + +using Colosseum.Passives; + +namespace Colosseum.Combat.Simulation +{ + /// + /// 전수 점검용 조합 생성 조건입니다. + /// + [System.Serializable] + public class SimulationCombinationSpec + { + [Header("Label")] + [SerializeField] private string batchName = "전체 조합"; + + [Header("Dimensions")] + [SerializeField] private bool combineSkills = true; + [SerializeField] private bool combineGems = true; + [SerializeField] private bool combinePassives = true; + + [Header("Slots")] + [Tooltip("조합 생성 대상 슬롯 인덱스입니다. 0 기반입니다.")] + [SerializeField] private int[] activeSlotIndices = new[] { 0, 1, 2, 3, 4, 5 }; + [SerializeField] private bool allowDuplicateSkills; + [SerializeField] private bool includeEmptyGemSet = true; + + [Header("Passive")] + [SerializeField] private PassiveTreeData passiveTree; + [SerializeField] private bool includeEmptyPassiveSelection = true; + [Tooltip("0이면 포인트 허용 범위 안에서 제한 없이 생성합니다.")] + [Min(0)] [SerializeField] private int maxPassiveNodeCount = 0; + + [Header("Safety")] + [Tooltip("생성할 최대 빌드 수입니다. 조합 폭발을 막기 위한 안전장치입니다.")] + [Min(1)] [SerializeField] private int maxBuildCount = 500; + + public string BatchName => string.IsNullOrWhiteSpace(batchName) ? "전체 조합" : batchName.Trim(); + public bool CombineSkills => combineSkills; + public bool CombineGems => combineGems; + public bool CombinePassives => combinePassives; + public int[] ActiveSlotIndices => activeSlotIndices ?? System.Array.Empty(); + public bool AllowDuplicateSkills => allowDuplicateSkills; + public bool IncludeEmptyGemSet => includeEmptyGemSet; + public PassiveTreeData PassiveTree => passiveTree; + public bool IncludeEmptyPassiveSelection => includeEmptyPassiveSelection; + public int MaxPassiveNodeCount => Mathf.Max(0, maxPassiveNodeCount); + public int MaxBuildCount => Mathf.Max(1, maxBuildCount); + + /// + /// 조합 생성 조건을 한 번에 설정합니다. + /// + public void Configure( + string batchName, + bool combineSkills, + bool combineGems, + bool combinePassives, + int[] activeSlotIndices, + bool allowDuplicateSkills, + bool includeEmptyGemSet, + PassiveTreeData passiveTree, + bool includeEmptyPassiveSelection, + int maxPassiveNodeCount, + int maxBuildCount) + { + this.batchName = batchName ?? string.Empty; + this.combineSkills = combineSkills; + this.combineGems = combineGems; + this.combinePassives = combinePassives; + this.activeSlotIndices = activeSlotIndices ?? System.Array.Empty(); + this.allowDuplicateSkills = allowDuplicateSkills; + this.includeEmptyGemSet = includeEmptyGemSet; + this.passiveTree = passiveTree; + this.includeEmptyPassiveSelection = includeEmptyPassiveSelection; + this.maxPassiveNodeCount = Mathf.Max(0, maxPassiveNodeCount); + this.maxBuildCount = Mathf.Max(1, maxBuildCount); + } + } +} diff --git a/Assets/_Game/Scripts/Combat/Simulation/SimulationCombinationSpec.cs.meta b/Assets/_Game/Scripts/Combat/Simulation/SimulationCombinationSpec.cs.meta new file mode 100644 index 00000000..9ac4cd1d --- /dev/null +++ b/Assets/_Game/Scripts/Combat/Simulation/SimulationCombinationSpec.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 8311c1f399b18a54a844b5d373eb27fa \ No newline at end of file diff --git a/Assets/_Game/Scripts/Combat/Simulation/SimulationReportUtility.cs b/Assets/_Game/Scripts/Combat/Simulation/SimulationReportUtility.cs new file mode 100644 index 00000000..7c6d4934 --- /dev/null +++ b/Assets/_Game/Scripts/Combat/Simulation/SimulationReportUtility.cs @@ -0,0 +1,221 @@ +using System; +using System.Collections.Generic; +using System.Text; + +namespace Colosseum.Combat.Simulation +{ + /// + /// 결과 미리보기/추출 포맷입니다. + /// + public enum SimulationReportFormat + { + DetailText, + Markdown, + Csv, + } + + /// + /// 허수아비 계산 시뮬레이터 결과를 외부 공유용 문자열로 변환합니다. + /// + public static class SimulationReportUtility + { + /// + /// 선택한 포맷으로 결과 문자열을 생성합니다. + /// + public static string BuildReport(SimulationResult result, SimulationReportFormat format) + { + if (result == null) + return string.Empty; + + return format switch + { + SimulationReportFormat.Markdown => BuildMarkdown(result), + SimulationReportFormat.Csv => BuildCsv(result), + _ => result.DetailText, + }; + } + + /// + /// 마크다운 리포트를 생성합니다. + /// + public static string BuildMarkdown(SimulationResult result) + { + if (result == null) + return string.Empty; + + StringBuilder builder = new StringBuilder(); + builder.Append("# 허수아비 계산 시뮬레이션 결과"); + builder.AppendLine(); + builder.AppendLine(); + builder.Append("- Build: "); + builder.Append(result.BuildLabel); + builder.AppendLine(); + builder.Append("- Rule: "); + builder.Append(result.RuleName); + builder.AppendLine(); + builder.Append("- Rotation: "); + builder.Append(result.RotationName); + builder.AppendLine(); + builder.AppendLine(); + + builder.AppendLine("| 항목 | 값 |"); + builder.AppendLine("| --- | --- |"); + builder.Append("| Duration | "); + builder.Append(result.DurationSeconds.ToString("0.##")); + builder.AppendLine("s |"); + builder.Append("| Total Damage | "); + builder.Append(result.TotalDamage.ToString("0.##")); + builder.AppendLine(" |"); + builder.Append("| DPS | "); + builder.Append(result.AverageDps.ToString("0.##")); + builder.AppendLine(" |"); + builder.Append("| Total Mana | "); + builder.Append(result.TotalManaUsed.ToString("0.##")); + builder.AppendLine(" |"); + builder.Append("| Mana / Sec | "); + builder.Append(result.AverageManaPerSecond.ToString("0.##")); + builder.AppendLine(" |"); + builder.Append("| First Cycle End | "); + builder.Append(result.FirstCycleEndTime >= 0f ? result.FirstCycleEndTime.ToString("0.##") + "s" : "미완료"); + builder.AppendLine(" |"); + + if (result.SkillBreakdowns.Count > 0) + { + builder.AppendLine(); + builder.AppendLine("## 스킬 기여도"); + builder.AppendLine(); + builder.AppendLine("| 스킬 | 사용 횟수 | 누적 피해 |"); + builder.AppendLine("| --- | ---: | ---: |"); + + for (int i = 0; i < result.SkillBreakdowns.Count; i++) + { + SimulationSkillBreakdown entry = result.SkillBreakdowns[i]; + builder.Append("| "); + builder.Append(entry.SkillName); + builder.Append(" | "); + builder.Append(entry.CastCount); + builder.Append(" | "); + builder.Append(entry.TotalDamage.ToString("0.##")); + builder.AppendLine(" |"); + } + } + + if (result.Warnings.Count > 0) + { + builder.AppendLine(); + builder.AppendLine("## 경고"); + builder.AppendLine(); + for (int i = 0; i < result.Warnings.Count; i++) + { + builder.Append("- "); + builder.Append(result.Warnings[i]); + builder.AppendLine(); + } + } + + return builder.ToString().TrimEnd(); + } + + /// + /// CSV 리포트를 생성합니다. + /// + public static string BuildCsv(SimulationResult result) + { + if (result == null) + return string.Empty; + + StringBuilder builder = new StringBuilder(); + builder.AppendLine("BuildLabel,RuleName,RotationName,DurationSeconds,TotalDamage,AverageDps,TotalManaUsed,AverageManaPerSecond,FirstCycleEndTime,SkillName,CastCount,SkillDamage,Warnings"); + + string warnings = string.Join(" / ", result.Warnings); + IReadOnlyList breakdowns = result.SkillBreakdowns; + + if (breakdowns.Count == 0) + { + AppendCsvRow(builder, result, null, warnings); + return builder.ToString().TrimEnd(); + } + + for (int i = 0; i < breakdowns.Count; i++) + { + AppendCsvRow(builder, result, breakdowns[i], warnings); + } + + return builder.ToString().TrimEnd(); + } + + /// + /// 저장에 적합한 기본 파일 이름을 생성합니다. + /// + public static string BuildDefaultFileName(SimulationResult result, SimulationReportFormat format) + { + string baseName = result != null + ? $"{result.BuildLabel}_{result.RuleName}_{result.RotationName}" + : "BuildSimulation"; + + string extension = format == SimulationReportFormat.Csv ? "csv" : "md"; + return $"{SanitizeFileName(baseName)}.{extension}"; + } + + private static void AppendCsvRow( + StringBuilder builder, + SimulationResult result, + SimulationSkillBreakdown breakdown, + string warnings) + { + builder.Append(EscapeCsv(result.BuildLabel)); + builder.Append(','); + builder.Append(EscapeCsv(result.RuleName)); + builder.Append(','); + builder.Append(EscapeCsv(result.RotationName)); + builder.Append(','); + builder.Append(result.DurationSeconds.ToString("0.##")); + builder.Append(','); + builder.Append(result.TotalDamage.ToString("0.##")); + builder.Append(','); + builder.Append(result.AverageDps.ToString("0.##")); + builder.Append(','); + builder.Append(result.TotalManaUsed.ToString("0.##")); + builder.Append(','); + builder.Append(result.AverageManaPerSecond.ToString("0.##")); + builder.Append(','); + builder.Append(result.FirstCycleEndTime >= 0f ? result.FirstCycleEndTime.ToString("0.##") : string.Empty); + builder.Append(','); + builder.Append(EscapeCsv(breakdown != null ? breakdown.SkillName : string.Empty)); + builder.Append(','); + builder.Append(breakdown != null ? breakdown.CastCount.ToString() : string.Empty); + builder.Append(','); + builder.Append(breakdown != null ? breakdown.TotalDamage.ToString("0.##") : string.Empty); + builder.Append(','); + builder.Append(EscapeCsv(warnings)); + builder.AppendLine(); + } + + private static string EscapeCsv(string value) + { + if (string.IsNullOrEmpty(value)) + return string.Empty; + + bool needsQuotes = value.Contains(",") || value.Contains("\"") || value.Contains("\n") || value.Contains("\r"); + if (!needsQuotes) + return value; + + return $"\"{value.Replace("\"", "\"\"")}\""; + } + + private static string SanitizeFileName(string value) + { + if (string.IsNullOrWhiteSpace(value)) + return "BuildSimulation"; + + string sanitized = value; + char[] invalidChars = System.IO.Path.GetInvalidFileNameChars(); + for (int i = 0; i < invalidChars.Length; i++) + { + sanitized = sanitized.Replace(invalidChars[i], '_'); + } + + return sanitized.Replace(' ', '_'); + } + } +} diff --git a/Assets/_Game/Scripts/Combat/Simulation/SimulationReportUtility.cs.meta b/Assets/_Game/Scripts/Combat/Simulation/SimulationReportUtility.cs.meta new file mode 100644 index 00000000..73a4c9d4 --- /dev/null +++ b/Assets/_Game/Scripts/Combat/Simulation/SimulationReportUtility.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 47296b792ba84ab4cbf71d24c403fc0f \ No newline at end of file diff --git a/Assets/_Game/Scripts/Combat/Simulation/SimulationResult.cs b/Assets/_Game/Scripts/Combat/Simulation/SimulationResult.cs new file mode 100644 index 00000000..4e3d30e4 --- /dev/null +++ b/Assets/_Game/Scripts/Combat/Simulation/SimulationResult.cs @@ -0,0 +1,152 @@ +using System.Collections.Generic; +using System.Text; + +using UnityEngine; + +namespace Colosseum.Combat.Simulation +{ + /// + /// 스킬별 기여도 요약입니다. + /// + [System.Serializable] + public sealed class SimulationSkillBreakdown + { + [SerializeField] private string skillName; + [Min(0)] [SerializeField] private int castCount; + [Min(0f)] [SerializeField] private float totalDamage; + + public string SkillName => skillName; + public int CastCount => castCount; + public float TotalDamage => totalDamage; + + public SimulationSkillBreakdown(string skillName, int castCount, float totalDamage) + { + this.skillName = skillName ?? "Unknown"; + this.castCount = Mathf.Max(0, castCount); + this.totalDamage = Mathf.Max(0f, totalDamage); + } + } + + /// + /// 허수아비 계산 시뮬레이터 결과입니다. + /// + [System.Serializable] + public sealed class SimulationResult + { + [SerializeField] private string summaryLine = string.Empty; + [TextArea(8, 30)] + [SerializeField] private string detailText = string.Empty; + [SerializeField] private string buildLabel = string.Empty; + [SerializeField] private string ruleName = string.Empty; + [SerializeField] private string rotationName = string.Empty; + [Min(0f)] [SerializeField] private float durationSeconds; + [Min(0f)] [SerializeField] private float totalDamage; + [Min(0f)] [SerializeField] private float averageDps; + [Min(0f)] [SerializeField] private float totalManaUsed; + [Min(0f)] [SerializeField] private float averageManaPerSecond; + [SerializeField] private float firstCycleEndTime = -1f; + [SerializeField] private List skillBreakdowns = new List(); + [SerializeField] private List warnings = new List(); + + public string SummaryLine => summaryLine; + public string DetailText => detailText; + public string BuildLabel => buildLabel; + public string RuleName => ruleName; + public string RotationName => rotationName; + public float DurationSeconds => durationSeconds; + public float TotalDamage => totalDamage; + public float AverageDps => averageDps; + public float TotalManaUsed => totalManaUsed; + public float AverageManaPerSecond => averageManaPerSecond; + public float FirstCycleEndTime => firstCycleEndTime; + public IReadOnlyList SkillBreakdowns => skillBreakdowns; + public IReadOnlyList Warnings => warnings; + + public void FinalizeResult( + string buildLabel, + string ruleName, + string rotationName, + float durationSeconds, + float totalDamage, + float totalManaUsed, + float firstCycleEndTime, + List breakdowns, + List warnings) + { + this.buildLabel = buildLabel ?? string.Empty; + this.ruleName = ruleName ?? string.Empty; + this.rotationName = rotationName ?? string.Empty; + this.durationSeconds = Mathf.Max(0f, durationSeconds); + this.totalDamage = Mathf.Max(0f, totalDamage); + averageDps = this.durationSeconds > 0f ? this.totalDamage / this.durationSeconds : 0f; + this.totalManaUsed = Mathf.Max(0f, totalManaUsed); + averageManaPerSecond = this.durationSeconds > 0f ? this.totalManaUsed / this.durationSeconds : 0f; + this.firstCycleEndTime = firstCycleEndTime; + + skillBreakdowns = breakdowns ?? new List(); + this.warnings = warnings ?? new List(); + summaryLine = BuildSummaryLine(); + detailText = BuildDetailText(); + } + + private string BuildSummaryLine() + { + StringBuilder builder = new StringBuilder(); + builder.Append("[BuildSimulation] "); + builder.Append(buildLabel); + builder.Append(" | Rule="); + builder.Append(ruleName); + builder.Append(" | Rotation="); + builder.Append(rotationName); + builder.Append(" | Dmg="); + builder.Append(totalDamage.ToString("0.##")); + builder.Append(" | DPS="); + builder.Append(averageDps.ToString("0.##")); + builder.Append(" | Mana="); + builder.Append(totalManaUsed.ToString("0.##")); + builder.Append(" | Cycle="); + builder.Append(firstCycleEndTime >= 0f ? firstCycleEndTime.ToString("0.##") + "s" : "미완료"); + return builder.ToString(); + } + + private string BuildDetailText() + { + StringBuilder builder = new StringBuilder(); + builder.AppendLine(summaryLine); + builder.Append("Duration="); + builder.Append(durationSeconds.ToString("0.##")); + builder.Append("s | ManaPerSec="); + builder.Append(averageManaPerSecond.ToString("0.##")); + builder.AppendLine(); + + if (skillBreakdowns.Count > 0) + { + builder.AppendLine("Skill Breakdown"); + for (int i = 0; i < skillBreakdowns.Count; i++) + { + SimulationSkillBreakdown entry = skillBreakdowns[i]; + builder.Append("- "); + builder.Append(entry.SkillName); + builder.Append(" | Cast="); + builder.Append(entry.CastCount); + builder.Append(" | Dmg="); + builder.Append(entry.TotalDamage.ToString("0.##")); + builder.AppendLine(); + } + } + + if (warnings.Count > 0) + { + builder.AppendLine("Warnings"); + for (int i = 0; i < warnings.Count; i++) + { + builder.Append("- "); + builder.Append(warnings[i]); + builder.AppendLine(); + } + } + + return builder.ToString().TrimEnd(); + } + } +} diff --git a/Assets/_Game/Scripts/Combat/Simulation/SimulationResult.cs.meta b/Assets/_Game/Scripts/Combat/Simulation/SimulationResult.cs.meta new file mode 100644 index 00000000..547bb347 --- /dev/null +++ b/Assets/_Game/Scripts/Combat/Simulation/SimulationResult.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: bbfa42a3c08321246b85393c07df6a8e \ No newline at end of file diff --git a/Assets/_Game/Scripts/Combat/Simulation/SimulationRuleSet.cs b/Assets/_Game/Scripts/Combat/Simulation/SimulationRuleSet.cs new file mode 100644 index 00000000..e94a880b --- /dev/null +++ b/Assets/_Game/Scripts/Combat/Simulation/SimulationRuleSet.cs @@ -0,0 +1,38 @@ +using UnityEngine; + +namespace Colosseum.Combat.Simulation +{ + /// + /// 허수아비 계산 시뮬레이터의 고정 가정입니다. + /// + [System.Serializable] + public class SimulationRuleSet + { + [Header("Label")] + [SerializeField] private string ruleName = "Dummy10s"; + + [Header("Timeline")] + [Min(0.1f)] [SerializeField] private float durationSeconds = 10f; + [Min(1)] [SerializeField] private int targetCount = 1; + [Min(0f)] [SerializeField] private float movementLossSecondsPerCast = 0f; + [Min(0f)] [SerializeField] private float manaRegenPerSecond = 0f; + + public string RuleName => string.IsNullOrWhiteSpace(ruleName) ? "Rule" : ruleName.Trim(); + public float DurationSeconds => durationSeconds; + public int TargetCount => Mathf.Max(1, targetCount); + public float MovementLossSecondsPerCast => Mathf.Max(0f, movementLossSecondsPerCast); + public float ManaRegenPerSecond => Mathf.Max(0f, manaRegenPerSecond); + + /// + /// 룰셋 값을 한 번에 설정합니다. + /// + public void Configure(string ruleName, float durationSeconds, int targetCount, float movementLossSecondsPerCast, float manaRegenPerSecond) + { + this.ruleName = ruleName ?? string.Empty; + this.durationSeconds = Mathf.Max(0.1f, durationSeconds); + this.targetCount = Mathf.Max(1, targetCount); + this.movementLossSecondsPerCast = Mathf.Max(0f, movementLossSecondsPerCast); + this.manaRegenPerSecond = Mathf.Max(0f, manaRegenPerSecond); + } + } +} diff --git a/Assets/_Game/Scripts/Combat/Simulation/SimulationRuleSet.cs.meta b/Assets/_Game/Scripts/Combat/Simulation/SimulationRuleSet.cs.meta new file mode 100644 index 00000000..3087e250 --- /dev/null +++ b/Assets/_Game/Scripts/Combat/Simulation/SimulationRuleSet.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: d870cf57849e6f447b843cccdc235d42 \ No newline at end of file diff --git a/Assets/_Game/Scripts/Editor/BuildSimulationBatchCommands.cs b/Assets/_Game/Scripts/Editor/BuildSimulationBatchCommands.cs new file mode 100644 index 00000000..2d88e179 --- /dev/null +++ b/Assets/_Game/Scripts/Editor/BuildSimulationBatchCommands.cs @@ -0,0 +1,176 @@ +using System.Collections.Generic; +using System.IO; +using System.Text; + +using UnityEditor; +using UnityEngine; + +using Colosseum.Combat.Simulation; +using Colosseum.Passives; +using Colosseum.Skills; + +namespace Colosseum.Editor +{ + /// + /// 허수아비 계산 시뮬레이터의 배치 조사 실행 메뉴입니다. + /// + public static class BuildSimulationBatchCommands + { + private const string PlayerSkillFolder = "Assets/_Game/Data/Skills"; + private const string PlayerGemFolder = "Assets/_Game/Data/SkillGems"; + private const string PlayerPassiveFolder = "Assets/_Game/Data/Passives/Nodes"; + private const string PlayerPassiveTreePath = "Assets/_Game/Data/Passives/Data_PassiveTree_Player_Prototype.asset"; + private const string ReportFolder = "BuildSimulationReports"; + private static readonly HashSet DisabledPlayerSkillPaths = new HashSet + { + "Assets/_Game/Data/Skills/Data_Skill_Player_회전베기.asset", + "Assets/_Game/Data/Skills/Data_Skill_Player_돌진.asset", + }; + + /// + /// 현재 기준 플레이어 단일 슬롯 데미지 전수 조사를 실행합니다. + /// + [MenuItem("Tools/Colosseum/Simulation/Run Player Damage Sweep")] + private static void RunPlayerDamageSweep() + { + PassiveTreeData passiveTree = AssetDatabase.LoadAssetAtPath(PlayerPassiveTreePath); + if (passiveTree == null) + { + Debug.LogError($"[BuildSimulationBatch] 패시브 트리를 찾지 못했습니다. | Path={PlayerPassiveTreePath}"); + return; + } + + BuildSimulationInput template = new BuildSimulationInput(); + template.SetBuildName("플레이어_단일슬롯_데미지전수"); + + SimulationRuleSet ruleSet = new SimulationRuleSet(); + ruleSet.Configure("PlayerDamageSweep10s", 10f, 1, 0f, 0f); + + RotationPolicy rotationPolicy = new RotationPolicy(); + rotationPolicy.Configure("Slot0Only", new[] { 0 }, false, 0, false, 5, 0f); + + SimulationCombinationSpec combinationSpec = new SimulationCombinationSpec(); + combinationSpec.Configure( + "PlayerDamageSweep", + combineSkills: true, + combineGems: true, + combinePassives: true, + activeSlotIndices: new[] { 0 }, + allowDuplicateSkills: false, + includeEmptyGemSet: true, + passiveTree: passiveTree, + includeEmptyPassiveSelection: true, + maxPassiveNodeCount: 0, + maxBuildCount: 50000); + + List warnings = new List(); + List skills = LoadPlayerSkills(warnings); + List gems = LoadAssetsInFolder(PlayerGemFolder); + List passiveNodes = LoadAssetsInFolder(PlayerPassiveFolder); + + List builds = SimulationCombinationGenerator.GenerateBuilds( + template, + combinationSpec, + skills, + gems, + passiveNodes, + warnings, + out bool truncated); + + SimulationBatchResult result = SimulationBatchRunner.Run( + combinationSpec.BatchName, + builds, + ruleSet, + rotationPolicy, + warnings, + truncated); + + string reportDirectory = Path.Combine(Path.GetDirectoryName(Application.dataPath) ?? Application.dataPath, ReportFolder); + Directory.CreateDirectory(reportDirectory); + + string timestamp = System.DateTime.Now.ToString("yyyyMMdd_HHmmss"); + string markdownPath = Path.Combine(reportDirectory, $"PlayerDamageSweep_{timestamp}.md"); + string csvPath = Path.Combine(reportDirectory, $"PlayerDamageSweep_{timestamp}.csv"); + + File.WriteAllText(markdownPath, SimulationBatchReportUtility.BuildMarkdown(result), Encoding.UTF8); + File.WriteAllText(csvPath, SimulationBatchReportUtility.BuildCsv(result), Encoding.UTF8); + + Debug.Log(BuildSummary(result, markdownPath, csvPath)); + } + + private static List LoadPlayerSkills(List warnings) + { + List skills = LoadAssetsInFolder(PlayerSkillFolder); + List filtered = new List(); + for (int i = 0; i < skills.Count; i++) + { + SkillData skill = skills[i]; + if (skill == null || !skill.name.StartsWith("Data_Skill_Player_", System.StringComparison.Ordinal)) + continue; + + string assetPath = AssetDatabase.GetAssetPath(skill); + if (DisabledPlayerSkillPaths.Contains(assetPath)) + { + warnings?.Add($"애니메이션 미구현 스킬 제외: {skill.SkillName}"); + continue; + } + + filtered.Add(skill); + } + + return filtered; + } + + private static List LoadAssetsInFolder(string folderPath) where T : Object + { + List assets = new List(); + string[] guids = AssetDatabase.FindAssets($"t:{typeof(T).Name}", new[] { folderPath }); + for (int i = 0; i < guids.Length; i++) + { + string assetPath = AssetDatabase.GUIDToAssetPath(guids[i]); + T asset = AssetDatabase.LoadAssetAtPath(assetPath); + if (asset != null) + assets.Add(asset); + } + + return assets; + } + + private static string BuildSummary(SimulationBatchResult result, string markdownPath, string csvPath) + { + StringBuilder builder = new StringBuilder(); + builder.Append("[BuildSimulationBatch] 플레이어 단일 슬롯 데미지 전수 조사 완료"); + builder.Append(" | Builds="); + builder.Append(result.GeneratedBuildCount); + builder.Append(" | Truncated="); + builder.Append(result.Truncated); + builder.Append(" | Markdown="); + builder.Append(markdownPath); + builder.Append(" | CSV="); + builder.Append(csvPath); + + int topCount = Mathf.Min(10, result.Entries.Count); + for (int i = 0; i < topCount; i++) + { + SimulationBatchEntry entry = result.Entries[i]; + SimulationResult simulation = entry != null ? entry.Result : null; + if (simulation == null) + continue; + + builder.AppendLine(); + builder.Append('#'); + builder.Append(i + 1); + builder.Append(' '); + builder.Append(entry.BuildLabel); + builder.Append(" | DPS="); + builder.Append(simulation.AverageDps.ToString("0.##")); + builder.Append(" | Dmg="); + builder.Append(simulation.TotalDamage.ToString("0.##")); + builder.Append(" | Mana="); + builder.Append(simulation.TotalManaUsed.ToString("0.##")); + } + + return builder.ToString(); + } + } +} diff --git a/Assets/_Game/Scripts/Editor/BuildSimulationBatchCommands.cs.meta b/Assets/_Game/Scripts/Editor/BuildSimulationBatchCommands.cs.meta new file mode 100644 index 00000000..9073d453 --- /dev/null +++ b/Assets/_Game/Scripts/Editor/BuildSimulationBatchCommands.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 82f1fba75c8e4f040ad82e2aa0096063 \ No newline at end of file diff --git a/Assets/_Game/Scripts/Editor/BuildSimulationBatchWindow.cs b/Assets/_Game/Scripts/Editor/BuildSimulationBatchWindow.cs new file mode 100644 index 00000000..e3b88283 --- /dev/null +++ b/Assets/_Game/Scripts/Editor/BuildSimulationBatchWindow.cs @@ -0,0 +1,287 @@ +using System.Collections.Generic; +using System.IO; +using UnityEditor; +using UnityEngine; + +using Colosseum.Combat.Simulation; +using Colosseum.Passives; +using Colosseum.Skills; + +namespace Colosseum.Editor +{ + /// + /// 허수아비 계산 시뮬레이터의 전체 조합 배치 실행 창입니다. + /// + public sealed class BuildSimulationBatchWindow : EditorWindow + { + [SerializeField] private BuildSimulationInput templateInput = new BuildSimulationInput(); + [SerializeField] private SimulationRuleSet ruleSet = new SimulationRuleSet(); + [SerializeField] private RotationPolicy rotationPolicy = new RotationPolicy(); + [SerializeField] private SimulationCombinationSpec combinationSpec = new SimulationCombinationSpec(); + [SerializeField] private string skillSearchFolder = "Assets/_Game/Data/Skills"; + [SerializeField] private string gemSearchFolder = "Assets/_Game/Data/SkillGems"; + [SerializeField] private string passiveNodeSearchFolder = "Assets/_Game/Data/Passives"; + [SerializeField] private SimulationBatchResult lastBatchResult; + [SerializeField] private bool previewAsCsv; + + private Vector2 scrollPosition; + + [MenuItem("Tools/Colosseum/Simulation/Build Simulation Batch Window")] + private static void Open() + { + BuildSimulationBatchWindow window = GetWindow("Build Simulation Batch"); + window.minSize = new Vector2(620f, 720f); + window.Show(); + } + + private void OnGUI() + { + scrollPosition = EditorGUILayout.BeginScrollView(scrollPosition); + + DrawTemplateSection(); + EditorGUILayout.Space(12f); + DrawRuleSection(); + EditorGUILayout.Space(12f); + DrawRotationSection(); + EditorGUILayout.Space(12f); + DrawCombinationSection(); + EditorGUILayout.Space(12f); + DrawRunSection(); + EditorGUILayout.Space(12f); + DrawExportSection(); + EditorGUILayout.Space(12f); + DrawResultSection(); + + EditorGUILayout.EndScrollView(); + } + + private void DrawTemplateSection() + { + EditorGUILayout.LabelField("Template", EditorStyles.boldLabel); + EditorGUILayout.HelpBox("조합 생성에서 비활성화한 축은 이 템플릿 입력을 사용합니다.", MessageType.None); + + SerializedObject serializedWindow = new SerializedObject(this); + SerializedProperty buildProperty = serializedWindow.FindProperty(nameof(templateInput)); + + DrawProperty(buildProperty, "buildName"); + DrawProperty(buildProperty, "strength"); + DrawProperty(buildProperty, "dexterity"); + DrawProperty(buildProperty, "intelligence"); + DrawProperty(buildProperty, "vitality"); + DrawProperty(buildProperty, "wisdom"); + DrawProperty(buildProperty, "spirit"); + DrawProperty(buildProperty, "weapon"); + DrawProperty(buildProperty, "directSkillSlots", true); + DrawProperty(buildProperty, "passiveTree"); + DrawProperty(buildProperty, "selectedPassiveNodes", true); + DrawProperty(buildProperty, "passivePreset"); + DrawProperty(buildProperty, "loadoutPreset"); + + serializedWindow.ApplyModifiedProperties(); + } + + private void DrawRuleSection() + { + EditorGUILayout.LabelField("Simulation", EditorStyles.boldLabel); + SerializedObject serializedWindow = new SerializedObject(this); + SerializedProperty ruleProperty = serializedWindow.FindProperty(nameof(ruleSet)); + + DrawProperty(ruleProperty, "ruleName"); + DrawProperty(ruleProperty, "durationSeconds"); + DrawProperty(ruleProperty, "targetCount"); + DrawProperty(ruleProperty, "movementLossSecondsPerCast"); + DrawProperty(ruleProperty, "manaRegenPerSecond"); + + serializedWindow.ApplyModifiedProperties(); + } + + private void DrawRotationSection() + { + EditorGUILayout.LabelField("Rotation", EditorStyles.boldLabel); + SerializedObject serializedWindow = new SerializedObject(this); + SerializedProperty rotationProperty = serializedWindow.FindProperty(nameof(rotationPolicy)); + + DrawProperty(rotationProperty, "policyName"); + DrawProperty(rotationProperty, "prioritySlots", true); + DrawProperty(rotationProperty, "useFallbackSlot"); + DrawProperty(rotationProperty, "fallbackSlotIndex"); + DrawProperty(rotationProperty, "delayHighPowerSkillUntilTime"); + if (rotationPolicy.DelayHighPowerSkillUntilTime) + { + DrawProperty(rotationProperty, "highPowerSlotIndex"); + DrawProperty(rotationProperty, "highPowerFirstUseTime"); + } + + serializedWindow.ApplyModifiedProperties(); + } + + private void DrawCombinationSection() + { + EditorGUILayout.LabelField("Combination", EditorStyles.boldLabel); + SerializedObject serializedWindow = new SerializedObject(this); + SerializedProperty specProperty = serializedWindow.FindProperty(nameof(combinationSpec)); + + DrawProperty(specProperty, "batchName"); + DrawProperty(specProperty, "combineSkills"); + DrawProperty(specProperty, "combineGems"); + DrawProperty(specProperty, "combinePassives"); + DrawProperty(specProperty, "activeSlotIndices", true); + DrawProperty(specProperty, "allowDuplicateSkills"); + DrawProperty(specProperty, "includeEmptyGemSet"); + DrawProperty(specProperty, "passiveTree"); + DrawProperty(specProperty, "includeEmptyPassiveSelection"); + DrawProperty(specProperty, "maxPassiveNodeCount"); + DrawProperty(specProperty, "maxBuildCount"); + + skillSearchFolder = EditorGUILayout.TextField("Skill Folder", skillSearchFolder); + gemSearchFolder = EditorGUILayout.TextField("Gem Folder", gemSearchFolder); + passiveNodeSearchFolder = EditorGUILayout.TextField("Passive Folder", passiveNodeSearchFolder); + + serializedWindow.ApplyModifiedProperties(); + } + + private void DrawRunSection() + { + EditorGUILayout.LabelField("Run", EditorStyles.boldLabel); + EditorGUILayout.HelpBox("전수 조합은 매우 빠르게 폭증합니다. 폴더 범위를 줄이고 Max Build Count를 적절히 설정하는 편이 안전합니다.", MessageType.Warning); + + if (GUILayout.Button("Run Batch Simulation", GUILayout.Height(32f))) + { + RunBatchSimulation(); + } + } + + private void DrawExportSection() + { + EditorGUILayout.LabelField("Export", EditorStyles.boldLabel); + if (lastBatchResult == null) + { + EditorGUILayout.HelpBox("배치 실행 후 Markdown/CSV로 복사하거나 저장할 수 있습니다.", MessageType.None); + return; + } + + using (new EditorGUILayout.HorizontalScope()) + { + if (GUILayout.Button("Copy Markdown")) + EditorGUIUtility.systemCopyBuffer = SimulationBatchReportUtility.BuildMarkdown(lastBatchResult); + + if (GUILayout.Button("Copy CSV")) + EditorGUIUtility.systemCopyBuffer = SimulationBatchReportUtility.BuildCsv(lastBatchResult); + } + + using (new EditorGUILayout.HorizontalScope()) + { + if (GUILayout.Button("Save Markdown")) + SaveBatchReport(false); + + if (GUILayout.Button("Save CSV")) + SaveBatchReport(true); + } + } + + private void DrawResultSection() + { + EditorGUILayout.LabelField("Preview", EditorStyles.boldLabel); + if (lastBatchResult == null) + { + EditorGUILayout.HelpBox("아직 배치 결과가 없습니다.", MessageType.None); + return; + } + + EditorGUILayout.LabelField("Generated", lastBatchResult.GeneratedBuildCount.ToString()); + EditorGUILayout.LabelField("Truncated", lastBatchResult.Truncated ? "Yes" : "No"); + previewAsCsv = EditorGUILayout.Toggle("Preview CSV", previewAsCsv); + + string previewText = previewAsCsv + ? SimulationBatchReportUtility.BuildCsv(lastBatchResult) + : SimulationBatchReportUtility.BuildMarkdown(lastBatchResult); + + EditorGUILayout.TextArea(previewText, GUILayout.MinHeight(320f)); + } + + private void RunBatchSimulation() + { + List warnings = new List(); + + List skillPool = combinationSpec.CombineSkills + ? LoadAssetsInFolder(skillSearchFolder) + : new List(); + List gemPool = combinationSpec.CombineGems + ? LoadAssetsInFolder(gemSearchFolder) + : new List(); + List passivePool = combinationSpec.CombinePassives + ? LoadAssetsInFolder(passiveNodeSearchFolder) + : new List(); + + if (combinationSpec.CombineSkills && skillPool.Count == 0) + warnings.Add($"스킬 폴더에서 SkillData를 찾지 못했습니다: {skillSearchFolder}"); + if (combinationSpec.CombineGems && gemPool.Count == 0) + warnings.Add($"젬 폴더에서 SkillGemData를 찾지 못했습니다: {gemSearchFolder}"); + if (combinationSpec.CombinePassives && passivePool.Count == 0 && combinationSpec.PassiveTree != null) + warnings.Add($"패시브 폴더에서 PassiveNodeData를 찾지 못했습니다: {passiveNodeSearchFolder}"); + + List builds = SimulationCombinationGenerator.GenerateBuilds( + templateInput, + combinationSpec, + skillPool, + gemPool, + passivePool, + warnings, + out bool truncated); + + lastBatchResult = SimulationBatchRunner.Run( + combinationSpec.BatchName, + builds, + ruleSet, + rotationPolicy, + warnings, + truncated); + + Debug.Log($"[BuildSimulationBatch] 배치 실행 완료 | Builds={lastBatchResult.GeneratedBuildCount} | Truncated={lastBatchResult.Truncated}"); + } + + private void SaveBatchReport(bool csv) + { + string defaultName = SimulationBatchReportUtility.BuildDefaultFileName(lastBatchResult, csv); + string path = EditorUtility.SaveFilePanel( + "배치 시뮬레이션 결과 저장", + Application.dataPath, + defaultName, + csv ? "csv" : "md"); + + if (string.IsNullOrWhiteSpace(path)) + return; + + string contents = csv + ? SimulationBatchReportUtility.BuildCsv(lastBatchResult) + : SimulationBatchReportUtility.BuildMarkdown(lastBatchResult); + File.WriteAllText(path, contents); + Debug.Log($"[BuildSimulationBatch] 결과 파일을 저장했습니다. | Path={path}"); + } + + private static List LoadAssetsInFolder(string folderPath) where T : UnityEngine.Object + { + List assets = new List(); + if (string.IsNullOrWhiteSpace(folderPath)) + return assets; + + string[] guids = AssetDatabase.FindAssets($"t:{typeof(T).Name}", new[] { folderPath }); + for (int i = 0; i < guids.Length; i++) + { + string assetPath = AssetDatabase.GUIDToAssetPath(guids[i]); + T asset = AssetDatabase.LoadAssetAtPath(assetPath); + if (asset != null) + assets.Add(asset); + } + + return assets; + } + + private static void DrawProperty(SerializedProperty root, string relativePath, bool includeChildren = false) + { + SerializedProperty property = root.FindPropertyRelative(relativePath); + if (property != null) + EditorGUILayout.PropertyField(property, includeChildren); + } + } +} diff --git a/Assets/_Game/Scripts/Editor/BuildSimulationBatchWindow.cs.meta b/Assets/_Game/Scripts/Editor/BuildSimulationBatchWindow.cs.meta new file mode 100644 index 00000000..b4905cf5 --- /dev/null +++ b/Assets/_Game/Scripts/Editor/BuildSimulationBatchWindow.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 0ee99b72045222a418924f50d0e5f4ab \ No newline at end of file diff --git a/Assets/_Game/Scripts/Editor/BuildSimulationWindow.cs b/Assets/_Game/Scripts/Editor/BuildSimulationWindow.cs new file mode 100644 index 00000000..66269952 --- /dev/null +++ b/Assets/_Game/Scripts/Editor/BuildSimulationWindow.cs @@ -0,0 +1,219 @@ +using System.IO; + +using UnityEditor; +using UnityEngine; + +using Colosseum.Combat.Simulation; + +namespace Colosseum.Editor +{ + /// + /// 허수아비 계산 시뮬레이터 실행 창입니다. + /// + public sealed class BuildSimulationWindow : EditorWindow + { + [SerializeField] private BuildSimulationInput buildInput = new BuildSimulationInput(); + [SerializeField] private SimulationRuleSet ruleSet = new SimulationRuleSet(); + [SerializeField] private RotationPolicy rotationPolicy = new RotationPolicy(); + [SerializeField] private SimulationResult lastResult; + [SerializeField] private SimulationReportFormat previewFormat = SimulationReportFormat.DetailText; + + private Vector2 scrollPosition; + + [MenuItem("Tools/Colosseum/Simulation/Build Simulation Window")] + private static void Open() + { + BuildSimulationWindow window = GetWindow("Build Simulation"); + window.minSize = new Vector2(520f, 640f); + window.Show(); + } + + private void OnGUI() + { + scrollPosition = EditorGUILayout.BeginScrollView(scrollPosition); + + DrawBuildSection(); + EditorGUILayout.Space(12f); + DrawRuleSection(); + EditorGUILayout.Space(12f); + DrawRotationSection(); + EditorGUILayout.Space(12f); + DrawRunSection(); + EditorGUILayout.Space(12f); + DrawExportSection(); + EditorGUILayout.Space(12f); + DrawResultSection(); + + EditorGUILayout.EndScrollView(); + } + + private void DrawBuildSection() + { + EditorGUILayout.LabelField("Build", EditorStyles.boldLabel); + SerializedObject serializedWindow = new SerializedObject(this); + SerializedProperty buildProperty = serializedWindow.FindProperty(nameof(buildInput)); + + DrawProperty(buildProperty, "buildName"); + DrawProperty(buildProperty, "strength"); + DrawProperty(buildProperty, "dexterity"); + DrawProperty(buildProperty, "intelligence"); + DrawProperty(buildProperty, "vitality"); + DrawProperty(buildProperty, "wisdom"); + DrawProperty(buildProperty, "spirit"); + DrawProperty(buildProperty, "weapon"); + DrawProperty(buildProperty, "directSkillSlots", true); + DrawProperty(buildProperty, "passiveTree"); + DrawProperty(buildProperty, "selectedPassiveNodes", true); + DrawProperty(buildProperty, "passivePreset"); + DrawProperty(buildProperty, "loadoutPreset"); + + serializedWindow.ApplyModifiedProperties(); + } + + private void DrawRuleSection() + { + EditorGUILayout.LabelField("Simulation", EditorStyles.boldLabel); + SerializedObject serializedWindow = new SerializedObject(this); + SerializedProperty ruleProperty = serializedWindow.FindProperty(nameof(ruleSet)); + + DrawProperty(ruleProperty, "ruleName"); + DrawProperty(ruleProperty, "durationSeconds"); + DrawProperty(ruleProperty, "targetCount"); + DrawProperty(ruleProperty, "movementLossSecondsPerCast"); + DrawProperty(ruleProperty, "manaRegenPerSecond"); + + serializedWindow.ApplyModifiedProperties(); + } + + private void DrawRotationSection() + { + EditorGUILayout.LabelField("Rotation", EditorStyles.boldLabel); + SerializedObject serializedWindow = new SerializedObject(this); + SerializedProperty rotationProperty = serializedWindow.FindProperty(nameof(rotationPolicy)); + + DrawProperty(rotationProperty, "policyName"); + DrawProperty(rotationProperty, "prioritySlots", true); + DrawProperty(rotationProperty, "useFallbackSlot"); + DrawProperty(rotationProperty, "fallbackSlotIndex"); + DrawProperty(rotationProperty, "delayHighPowerSkillUntilTime"); + + if (rotationPolicy.DelayHighPowerSkillUntilTime) + { + DrawProperty(rotationProperty, "highPowerSlotIndex"); + DrawProperty(rotationProperty, "highPowerFirstUseTime"); + } + + serializedWindow.ApplyModifiedProperties(); + } + + private void DrawRunSection() + { + EditorGUILayout.LabelField("Run", EditorStyles.boldLabel); + EditorGUILayout.HelpBox("MVP 범위는 단일 대상 피해 계산입니다. 회복/보호막/위협과 버프성 효과는 이후 단계에서 확장합니다.", MessageType.Info); + + using (new EditorGUI.DisabledScope(buildInput == null || buildInput.LoadoutPreset == null)) + { + if (GUILayout.Button("Run Simulation", GUILayout.Height(32f))) + { + lastResult = BuildSimulationEngine.Run(buildInput, ruleSet, rotationPolicy); + if (lastResult != null && !string.IsNullOrWhiteSpace(lastResult.DetailText)) + { + Debug.Log(lastResult.DetailText); + } + } + } + } + + private void DrawResultSection() + { + EditorGUILayout.LabelField("Preview", EditorStyles.boldLabel); + + if (lastResult == null) + { + EditorGUILayout.HelpBox("아직 실행 결과가 없습니다.", MessageType.None); + return; + } + + EditorGUILayout.SelectableLabel(lastResult.SummaryLine, EditorStyles.textField, GUILayout.Height(36f)); + previewFormat = (SimulationReportFormat)EditorGUILayout.EnumPopup("Format", previewFormat); + string previewText = SimulationReportUtility.BuildReport(lastResult, previewFormat); + EditorGUILayout.TextArea(previewText, GUILayout.MinHeight(260f)); + } + + private static void DrawProperty(SerializedProperty root, string relativePath, bool includeChildren = false) + { + SerializedProperty property = root.FindPropertyRelative(relativePath); + if (property != null) + { + EditorGUILayout.PropertyField(property, includeChildren); + } + } + + private void DrawExportSection() + { + EditorGUILayout.LabelField("Export", EditorStyles.boldLabel); + + if (lastResult == null) + { + EditorGUILayout.HelpBox("실행 결과가 생기면 복사와 저장 버튼이 활성화됩니다.", MessageType.None); + return; + } + + using (new EditorGUILayout.HorizontalScope()) + { + if (GUILayout.Button("Copy Detail")) + { + CopyReport(SimulationReportFormat.DetailText); + } + + if (GUILayout.Button("Copy Markdown")) + { + CopyReport(SimulationReportFormat.Markdown); + } + + if (GUILayout.Button("Copy CSV")) + { + CopyReport(SimulationReportFormat.Csv); + } + } + + using (new EditorGUILayout.HorizontalScope()) + { + if (GUILayout.Button("Save Markdown")) + { + SaveReport(SimulationReportFormat.Markdown); + } + + if (GUILayout.Button("Save CSV")) + { + SaveReport(SimulationReportFormat.Csv); + } + } + } + + private void CopyReport(SimulationReportFormat format) + { + string report = SimulationReportUtility.BuildReport(lastResult, format); + EditorGUIUtility.systemCopyBuffer = report; + Debug.Log($"[BuildSimulation] 결과를 클립보드에 복사했습니다. | Format={format}"); + } + + private void SaveReport(SimulationReportFormat format) + { + string extension = format == SimulationReportFormat.Csv ? "csv" : "md"; + string defaultFileName = SimulationReportUtility.BuildDefaultFileName(lastResult, format); + string path = EditorUtility.SaveFilePanel( + "시뮬레이션 결과 저장", + Application.dataPath, + defaultFileName, + extension); + + if (string.IsNullOrWhiteSpace(path)) + return; + + string report = SimulationReportUtility.BuildReport(lastResult, format); + File.WriteAllText(path, report); + Debug.Log($"[BuildSimulation] 결과 파일을 저장했습니다. | Path={path}"); + } + } +} diff --git a/Assets/_Game/Scripts/Editor/BuildSimulationWindow.cs.meta b/Assets/_Game/Scripts/Editor/BuildSimulationWindow.cs.meta new file mode 100644 index 00000000..12daf85d --- /dev/null +++ b/Assets/_Game/Scripts/Editor/BuildSimulationWindow.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: fc6991eb0ed991a439747282181ff086 \ No newline at end of file diff --git a/Assets/_Game/Scripts/Skills/Effects/DamageEffect.cs b/Assets/_Game/Scripts/Skills/Effects/DamageEffect.cs index cdde04b3..c2cbfecf 100644 --- a/Assets/_Game/Scripts/Skills/Effects/DamageEffect.cs +++ b/Assets/_Game/Scripts/Skills/Effects/DamageEffect.cs @@ -31,6 +31,10 @@ namespace Colosseum.Skills.Effects [Tooltip("스탯 계수 (1.0 = 100%)")] [Min(0f)] [SerializeField] private float statScaling = 1f; + public float BaseDamage => baseDamage; + public DamageType DamageKind => damageType; + public float StatScaling => statScaling; + protected override void ApplyEffect(GameObject caster, GameObject target) { if (target == null) return;