feat: 허수아비 계산 시뮬레이터 추가
- 빌드 입력, 룰셋, 회전 정책, 결과/리포트 모델을 포함한 데미지 계산 시뮬레이터 기반을 추가 - 단일 실행 창과 배치 전수 조사 창, 플레이어 데미지 스윕 메뉴를 추가 - DamageEffect 계산값 접근자를 열어 기존 전투 공식을 시뮬레이터에서 재사용하도록 정리
This commit is contained in:
8
Assets/_Game/Scripts/Combat/Simulation.meta
Normal file
8
Assets/_Game/Scripts/Combat/Simulation.meta
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
fileFormatVersion: 2
|
||||||
|
guid: afad57d9ec73a1740803c20de7b89392
|
||||||
|
folderAsset: yes
|
||||||
|
DefaultImporter:
|
||||||
|
externalObjects: {}
|
||||||
|
userData:
|
||||||
|
assetBundleName:
|
||||||
|
assetBundleVariant:
|
||||||
607
Assets/_Game/Scripts/Combat/Simulation/BuildSimulationEngine.cs
Normal file
607
Assets/_Game/Scripts/Combat/Simulation/BuildSimulationEngine.cs
Normal file
@@ -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
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 허수아비 계산 시뮬레이터의 단일 대상 피해 계산 엔진입니다.
|
||||||
|
/// </summary>
|
||||||
|
public static class BuildSimulationEngine
|
||||||
|
{
|
||||||
|
private const float MinimumActionDuration = 0.01f;
|
||||||
|
private const string UnsupportedEffectsWarning = "현재 MVP는 DamageEffect만 계산합니다.";
|
||||||
|
|
||||||
|
private sealed class SimulationContext
|
||||||
|
{
|
||||||
|
public GameObject actor;
|
||||||
|
public CharacterStats stats;
|
||||||
|
public PassiveRuntimeController passiveController;
|
||||||
|
public float currentMana;
|
||||||
|
public float maxMana;
|
||||||
|
public float totalDamage;
|
||||||
|
public float totalManaUsed;
|
||||||
|
public float currentTime;
|
||||||
|
public float firstCycleEndTime = -1f;
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class SkillRuntimeSnapshot
|
||||||
|
{
|
||||||
|
public int slotIndex;
|
||||||
|
public SkillLoadoutEntry loadoutEntry;
|
||||||
|
public SkillData skill;
|
||||||
|
public string skillName;
|
||||||
|
public float castDuration;
|
||||||
|
public float cooldown;
|
||||||
|
public float manaCost;
|
||||||
|
public List<DamageEventSnapshot> damageEvents = new List<DamageEventSnapshot>();
|
||||||
|
public bool containsUnsupportedEffects;
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class DamageEventSnapshot
|
||||||
|
{
|
||||||
|
public float relativeTime;
|
||||||
|
public float damageAmount;
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class SkillMetrics
|
||||||
|
{
|
||||||
|
public string skillName;
|
||||||
|
public int castCount;
|
||||||
|
public float totalDamage;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 입력 조건에 따라 허수아비 계산 시뮬레이션을 실행합니다.
|
||||||
|
/// </summary>
|
||||||
|
public static SimulationResult Run(
|
||||||
|
BuildSimulationInput input,
|
||||||
|
SimulationRuleSet ruleSet,
|
||||||
|
RotationPolicy rotationPolicy)
|
||||||
|
{
|
||||||
|
SimulationResult result = new SimulationResult();
|
||||||
|
List<string> warnings = new List<string>();
|
||||||
|
|
||||||
|
if (input == null)
|
||||||
|
{
|
||||||
|
warnings.Add("BuildSimulationInput이 없습니다.");
|
||||||
|
result.FinalizeResult("입력 없음", string.Empty, string.Empty, 0f, 0f, 0f, -1f, new List<SimulationSkillBreakdown>(), warnings);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
IReadOnlyList<SkillLoadoutEntry> resolvedEntries = input.ResolveLoadoutEntries();
|
||||||
|
if (resolvedEntries == null || resolvedEntries.Count == 0 || !input.HasAnyResolvedSkill())
|
||||||
|
{
|
||||||
|
warnings.Add("유효한 스킬 슬롯이 없습니다.");
|
||||||
|
result.FinalizeResult(input.BuildLabel, ruleSet != null ? ruleSet.RuleName : string.Empty, rotationPolicy != null ? rotationPolicy.PolicyName : string.Empty, 0f, 0f, 0f, -1f, new List<SimulationSkillBreakdown>(), warnings);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
ruleSet ??= new SimulationRuleSet();
|
||||||
|
rotationPolicy ??= new RotationPolicy();
|
||||||
|
|
||||||
|
SimulationContext context = CreateContext(input, warnings);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
SkillRuntimeSnapshot[] skillSnapshots = BuildSnapshots(input, context, ruleSet, warnings);
|
||||||
|
Dictionary<int, float> cooldownReadyTimes = new Dictionary<int, float>();
|
||||||
|
Dictionary<int, SkillMetrics> metricsBySlot = new Dictionary<int, SkillMetrics>();
|
||||||
|
HashSet<int> cycleCompletedSlots = new HashSet<int>();
|
||||||
|
List<int> validPrioritySlots = CollectValidPrioritySlots(rotationPolicy, skillSnapshots);
|
||||||
|
|
||||||
|
while (context.currentTime < ruleSet.DurationSeconds)
|
||||||
|
{
|
||||||
|
SkillRuntimeSnapshot nextSkill = TrySelectSkill(skillSnapshots, cooldownReadyTimes, context, rotationPolicy);
|
||||||
|
if (nextSkill == null)
|
||||||
|
{
|
||||||
|
if (!TryAdvanceIdleTime(skillSnapshots, cooldownReadyTimes, context, ruleSet, rotationPolicy, warnings))
|
||||||
|
break;
|
||||||
|
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
float castStartTime = context.currentTime;
|
||||||
|
context.currentMana = Mathf.Max(0f, context.currentMana - nextSkill.manaCost);
|
||||||
|
context.totalManaUsed += nextSkill.manaCost;
|
||||||
|
cooldownReadyTimes[nextSkill.slotIndex] = castStartTime + nextSkill.cooldown;
|
||||||
|
|
||||||
|
if (!metricsBySlot.TryGetValue(nextSkill.slotIndex, out SkillMetrics skillMetrics))
|
||||||
|
{
|
||||||
|
skillMetrics = new SkillMetrics
|
||||||
|
{
|
||||||
|
skillName = nextSkill.skillName,
|
||||||
|
};
|
||||||
|
metricsBySlot.Add(nextSkill.slotIndex, skillMetrics);
|
||||||
|
}
|
||||||
|
|
||||||
|
skillMetrics.castCount++;
|
||||||
|
|
||||||
|
for (int i = 0; i < nextSkill.damageEvents.Count; i++)
|
||||||
|
{
|
||||||
|
DamageEventSnapshot damageEvent = nextSkill.damageEvents[i];
|
||||||
|
float eventTime = castStartTime + damageEvent.relativeTime;
|
||||||
|
if (eventTime > ruleSet.DurationSeconds)
|
||||||
|
continue;
|
||||||
|
|
||||||
|
context.totalDamage += damageEvent.damageAmount;
|
||||||
|
skillMetrics.totalDamage += damageEvent.damageAmount;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (validPrioritySlots.Contains(nextSkill.slotIndex))
|
||||||
|
{
|
||||||
|
cycleCompletedSlots.Add(nextSkill.slotIndex);
|
||||||
|
if (context.firstCycleEndTime < 0f && cycleCompletedSlots.Count >= validPrioritySlots.Count)
|
||||||
|
{
|
||||||
|
context.firstCycleEndTime = Mathf.Min(ruleSet.DurationSeconds, castStartTime + nextSkill.castDuration);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
AdvanceTime(context, Mathf.Min(ruleSet.DurationSeconds, castStartTime + nextSkill.castDuration), ruleSet);
|
||||||
|
}
|
||||||
|
|
||||||
|
result.FinalizeResult(
|
||||||
|
input.BuildLabel,
|
||||||
|
ruleSet.RuleName,
|
||||||
|
rotationPolicy.PolicyName,
|
||||||
|
ruleSet.DurationSeconds,
|
||||||
|
context.totalDamage,
|
||||||
|
context.totalManaUsed,
|
||||||
|
context.firstCycleEndTime,
|
||||||
|
BuildBreakdowns(metricsBySlot),
|
||||||
|
warnings);
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
DestroyContext(context);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static SimulationContext CreateContext(BuildSimulationInput input, List<string> warnings)
|
||||||
|
{
|
||||||
|
SimulationContext context = new SimulationContext
|
||||||
|
{
|
||||||
|
actor = new GameObject("BuildSimulationActor")
|
||||||
|
{
|
||||||
|
hideFlags = HideFlags.HideAndDontSave,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
context.stats = context.actor.AddComponent<CharacterStats>();
|
||||||
|
context.passiveController = context.actor.AddComponent<PassiveRuntimeController>();
|
||||||
|
context.passiveController.Initialize(context.stats);
|
||||||
|
|
||||||
|
ApplyBaseStats(context.stats, input);
|
||||||
|
ApplyWeaponStats(context.stats, input.Weapon);
|
||||||
|
|
||||||
|
PassiveTreeData resolvedTree = input.ResolvePassiveTree();
|
||||||
|
List<string> selectedNodeIds = input.BuildSelectedPassiveNodeIdList();
|
||||||
|
if (resolvedTree != null && selectedNodeIds.Count > 0)
|
||||||
|
{
|
||||||
|
bool applied = context.passiveController.TryApplySelection(
|
||||||
|
resolvedTree,
|
||||||
|
selectedNodeIds,
|
||||||
|
input.PassivePreset != null ? input.PassivePreset.PresetName : "직접구성",
|
||||||
|
out string reason);
|
||||||
|
|
||||||
|
if (!applied && !string.IsNullOrWhiteSpace(reason))
|
||||||
|
warnings.Add($"패시브 적용 실패: {reason}");
|
||||||
|
}
|
||||||
|
|
||||||
|
context.maxMana = context.stats.MaxMana;
|
||||||
|
context.currentMana = context.maxMana;
|
||||||
|
return context;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void ApplyBaseStats(CharacterStats stats, BuildSimulationInput input)
|
||||||
|
{
|
||||||
|
stats.Strength.BaseValue = input.Strength;
|
||||||
|
stats.Dexterity.BaseValue = input.Dexterity;
|
||||||
|
stats.Intelligence.BaseValue = input.Intelligence;
|
||||||
|
stats.Vitality.BaseValue = input.Vitality;
|
||||||
|
stats.Wisdom.BaseValue = input.Wisdom;
|
||||||
|
stats.Spirit.BaseValue = input.Spirit;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void ApplyWeaponStats(CharacterStats stats, WeaponData weapon)
|
||||||
|
{
|
||||||
|
if (stats == null || weapon == null)
|
||||||
|
return;
|
||||||
|
|
||||||
|
foreach (StatType statType in Enum.GetValues(typeof(StatType)))
|
||||||
|
{
|
||||||
|
int bonus = weapon.GetStatBonus(statType);
|
||||||
|
if (bonus == 0)
|
||||||
|
continue;
|
||||||
|
|
||||||
|
CharacterStat stat = stats.GetStat(statType);
|
||||||
|
stat?.AddModifier(new StatModifier(bonus, StatModType.Flat, weapon));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static SkillRuntimeSnapshot[] BuildSnapshots(
|
||||||
|
BuildSimulationInput input,
|
||||||
|
SimulationContext context,
|
||||||
|
SimulationRuleSet ruleSet,
|
||||||
|
List<string> warnings)
|
||||||
|
{
|
||||||
|
IReadOnlyList<SkillLoadoutEntry> slots = input.ResolveLoadoutEntries();
|
||||||
|
SkillRuntimeSnapshot[] snapshots = new SkillRuntimeSnapshot[slots.Count];
|
||||||
|
float weaponDamageMultiplier = input.Weapon != null ? input.Weapon.DamageMultiplier : 1f;
|
||||||
|
float weaponManaMultiplier = input.Weapon != null ? input.Weapon.ManaCostMultiplier : 1f;
|
||||||
|
|
||||||
|
for (int slotIndex = 0; slotIndex < slots.Count; slotIndex++)
|
||||||
|
{
|
||||||
|
SkillLoadoutEntry sourceEntry = slots[slotIndex];
|
||||||
|
SkillData skill = sourceEntry != null ? sourceEntry.BaseSkill : null;
|
||||||
|
if (sourceEntry == null || skill == null)
|
||||||
|
continue;
|
||||||
|
|
||||||
|
SkillLoadoutEntry loadoutEntry = sourceEntry.CreateCopy();
|
||||||
|
SkillRuntimeSnapshot snapshot = new SkillRuntimeSnapshot
|
||||||
|
{
|
||||||
|
slotIndex = slotIndex,
|
||||||
|
loadoutEntry = loadoutEntry,
|
||||||
|
skill = skill,
|
||||||
|
skillName = string.IsNullOrWhiteSpace(skill.SkillName) ? $"Slot {slotIndex}" : skill.SkillName,
|
||||||
|
cooldown = loadoutEntry.GetResolvedCooldown(),
|
||||||
|
manaCost = loadoutEntry.GetResolvedManaCost() * weaponManaMultiplier * context.passiveController.GetManaCostMultiplier(skill),
|
||||||
|
};
|
||||||
|
|
||||||
|
float resolvedAnimationSpeed = loadoutEntry.GetResolvedAnimationSpeed();
|
||||||
|
float 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<int, List<SkillEffect>> effectMap = new Dictionary<int, List<SkillEffect>>();
|
||||||
|
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<int, List<SkillEffect>> effectMap,
|
||||||
|
SimulationContext context,
|
||||||
|
float weaponDamageMultiplier,
|
||||||
|
SimulationRuleSet ruleSet,
|
||||||
|
float mainClipDuration,
|
||||||
|
float resolvedAnimationSpeed,
|
||||||
|
int repeatCount,
|
||||||
|
List<string> warnings)
|
||||||
|
{
|
||||||
|
if (snapshot == null || effectMap == null || effectMap.Count == 0)
|
||||||
|
return;
|
||||||
|
|
||||||
|
List<AnimationEvent> effectEvents = new List<AnimationEvent>();
|
||||||
|
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<SkillEffect> effectsAtIndex) || effectsAtIndex == null)
|
||||||
|
continue;
|
||||||
|
|
||||||
|
float relativeTime = iterationOffset + (animationEvent.time / Mathf.Max(0.05f, resolvedAnimationSpeed));
|
||||||
|
|
||||||
|
for (int effectListIndex = 0; effectListIndex < effectsAtIndex.Count; effectListIndex++)
|
||||||
|
{
|
||||||
|
SkillEffect effect = effectsAtIndex[effectListIndex];
|
||||||
|
if (effect is DamageEffect damageEffect)
|
||||||
|
{
|
||||||
|
float damageAmount = CalculateDamage(damageEffect, context.stats, snapshot.loadoutEntry, passiveDamageMultiplier, weaponDamageMultiplier);
|
||||||
|
damageAmount *= ResolveTargetCount(effect, ruleSet);
|
||||||
|
|
||||||
|
snapshot.damageEvents.Add(new DamageEventSnapshot
|
||||||
|
{
|
||||||
|
relativeTime = relativeTime,
|
||||||
|
damageAmount = damageAmount,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
snapshot.containsUnsupportedEffects = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (snapshot.containsUnsupportedEffects)
|
||||||
|
warnings.Add($"{snapshot.skillName}: {UnsupportedEffectsWarning}");
|
||||||
|
|
||||||
|
if (effectEvents.Count == 0 && effectMap.Count > 0)
|
||||||
|
warnings.Add($"{snapshot.skillName}: OnEffect 애니메이션 이벤트가 없어 트리거 효과를 계산하지 못했습니다.");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static float CalculateDamage(
|
||||||
|
DamageEffect effect,
|
||||||
|
CharacterStats stats,
|
||||||
|
SkillLoadoutEntry loadoutEntry,
|
||||||
|
float passiveDamageMultiplier,
|
||||||
|
float weaponDamageMultiplier)
|
||||||
|
{
|
||||||
|
if (effect == null || stats == null)
|
||||||
|
return 0f;
|
||||||
|
|
||||||
|
if (effect.DamageKind == DamageType.True)
|
||||||
|
{
|
||||||
|
return effect.BaseDamage * loadoutEntry.GetResolvedDamageMultiplier() * passiveDamageMultiplier;
|
||||||
|
}
|
||||||
|
|
||||||
|
float statDamage = effect.DamageKind switch
|
||||||
|
{
|
||||||
|
DamageType.Physical => stats.PhysicalDamage,
|
||||||
|
DamageType.Magical => stats.MagicDamage,
|
||||||
|
DamageType.Ranged => stats.Dexterity.FinalValue * 2f,
|
||||||
|
_ => 0f,
|
||||||
|
};
|
||||||
|
|
||||||
|
float baseTotal = effect.BaseDamage + (statDamage * effect.StatScaling);
|
||||||
|
return baseTotal * weaponDamageMultiplier * loadoutEntry.GetResolvedDamageMultiplier() * passiveDamageMultiplier;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static int ResolveTargetCount(SkillEffect effect, SimulationRuleSet ruleSet)
|
||||||
|
{
|
||||||
|
if (effect == null)
|
||||||
|
return 1;
|
||||||
|
|
||||||
|
return effect.TargetType == TargetType.Area ? ruleSet.TargetCount : 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static SkillRuntimeSnapshot TrySelectSkill(
|
||||||
|
SkillRuntimeSnapshot[] snapshots,
|
||||||
|
Dictionary<int, float> cooldownReadyTimes,
|
||||||
|
SimulationContext context,
|
||||||
|
RotationPolicy rotationPolicy)
|
||||||
|
{
|
||||||
|
SkillRuntimeSnapshot snapshot = TrySelectFromSlots(rotationPolicy.PrioritySlots, snapshots, cooldownReadyTimes, context, rotationPolicy);
|
||||||
|
if (snapshot != null)
|
||||||
|
return snapshot;
|
||||||
|
|
||||||
|
if (!rotationPolicy.UseFallbackSlot)
|
||||||
|
return null;
|
||||||
|
|
||||||
|
return TrySelectFromSlots(new[] { rotationPolicy.FallbackSlotIndex }, snapshots, cooldownReadyTimes, context, rotationPolicy);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static SkillRuntimeSnapshot TrySelectFromSlots(
|
||||||
|
int[] slotOrder,
|
||||||
|
SkillRuntimeSnapshot[] snapshots,
|
||||||
|
Dictionary<int, float> cooldownReadyTimes,
|
||||||
|
SimulationContext context,
|
||||||
|
RotationPolicy rotationPolicy)
|
||||||
|
{
|
||||||
|
if (slotOrder == null || snapshots == null)
|
||||||
|
return null;
|
||||||
|
|
||||||
|
for (int i = 0; i < slotOrder.Length; i++)
|
||||||
|
{
|
||||||
|
int slotIndex = slotOrder[i];
|
||||||
|
if (slotIndex < 0 || slotIndex >= snapshots.Length)
|
||||||
|
continue;
|
||||||
|
|
||||||
|
SkillRuntimeSnapshot snapshot = snapshots[slotIndex];
|
||||||
|
if (snapshot == null)
|
||||||
|
continue;
|
||||||
|
|
||||||
|
if (rotationPolicy.DelayHighPowerSkillUntilTime &&
|
||||||
|
slotIndex == rotationPolicy.HighPowerSlotIndex &&
|
||||||
|
context.currentTime < rotationPolicy.HighPowerFirstUseTime &&
|
||||||
|
!cooldownReadyTimes.ContainsKey(slotIndex))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (cooldownReadyTimes.TryGetValue(slotIndex, out float readyTime) && readyTime > context.currentTime)
|
||||||
|
continue;
|
||||||
|
|
||||||
|
if (snapshot.manaCost > context.currentMana)
|
||||||
|
continue;
|
||||||
|
|
||||||
|
return snapshot;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool TryAdvanceIdleTime(
|
||||||
|
SkillRuntimeSnapshot[] snapshots,
|
||||||
|
Dictionary<int, float> cooldownReadyTimes,
|
||||||
|
SimulationContext context,
|
||||||
|
SimulationRuleSet ruleSet,
|
||||||
|
RotationPolicy rotationPolicy,
|
||||||
|
List<string> warnings)
|
||||||
|
{
|
||||||
|
float nextReadyTime = float.PositiveInfinity;
|
||||||
|
bool hasActionCandidate = false;
|
||||||
|
|
||||||
|
for (int i = 0; i < snapshots.Length; i++)
|
||||||
|
{
|
||||||
|
SkillRuntimeSnapshot snapshot = snapshots[i];
|
||||||
|
if (snapshot == null || !IsSlotRelevantToRotation(snapshot.slotIndex, rotationPolicy))
|
||||||
|
continue;
|
||||||
|
|
||||||
|
hasActionCandidate = true;
|
||||||
|
if (rotationPolicy.DelayHighPowerSkillUntilTime &&
|
||||||
|
snapshot.slotIndex == rotationPolicy.HighPowerSlotIndex &&
|
||||||
|
context.currentTime < rotationPolicy.HighPowerFirstUseTime &&
|
||||||
|
!cooldownReadyTimes.ContainsKey(snapshot.slotIndex))
|
||||||
|
{
|
||||||
|
nextReadyTime = Mathf.Min(nextReadyTime, rotationPolicy.HighPowerFirstUseTime);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (cooldownReadyTimes.TryGetValue(snapshot.slotIndex, out float readyTime))
|
||||||
|
{
|
||||||
|
if (readyTime > context.currentTime)
|
||||||
|
nextReadyTime = Mathf.Min(nextReadyTime, readyTime);
|
||||||
|
}
|
||||||
|
else if (snapshot.manaCost <= context.currentMana)
|
||||||
|
{
|
||||||
|
warnings.Add("대기 없이 사용할 수 있는 스킬을 찾지 못해 시뮬레이션을 중단했습니다.");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!hasActionCandidate)
|
||||||
|
{
|
||||||
|
warnings.Add("유효한 스킬이 없어 시뮬레이션을 종료했습니다.");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ruleSet.ManaRegenPerSecond > 0f)
|
||||||
|
{
|
||||||
|
float nextManaReadyTime = ResolveNextManaReadyTime(snapshots, context, ruleSet, rotationPolicy);
|
||||||
|
nextReadyTime = Mathf.Min(nextReadyTime, nextManaReadyTime);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (float.IsPositiveInfinity(nextReadyTime))
|
||||||
|
{
|
||||||
|
warnings.Add("더 이상 실행 가능한 스킬이 없어 시뮬레이션을 조기 종료했습니다.");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (nextReadyTime <= context.currentTime + 0.0001f)
|
||||||
|
{
|
||||||
|
warnings.Add("다음 행동 시점을 계산하지 못해 시뮬레이션을 중단했습니다.");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
AdvanceTime(context, Mathf.Min(ruleSet.DurationSeconds, nextReadyTime), ruleSet);
|
||||||
|
return context.currentTime < ruleSet.DurationSeconds;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static float ResolveNextManaReadyTime(
|
||||||
|
SkillRuntimeSnapshot[] snapshots,
|
||||||
|
SimulationContext context,
|
||||||
|
SimulationRuleSet ruleSet,
|
||||||
|
RotationPolicy rotationPolicy)
|
||||||
|
{
|
||||||
|
float nextTime = float.PositiveInfinity;
|
||||||
|
for (int i = 0; i < snapshots.Length; i++)
|
||||||
|
{
|
||||||
|
SkillRuntimeSnapshot snapshot = snapshots[i];
|
||||||
|
if (snapshot == null ||
|
||||||
|
!IsSlotRelevantToRotation(snapshot.slotIndex, rotationPolicy) ||
|
||||||
|
snapshot.manaCost <= context.currentMana)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
float shortage = snapshot.manaCost - context.currentMana;
|
||||||
|
nextTime = Mathf.Min(nextTime, context.currentTime + (shortage / ruleSet.ManaRegenPerSecond));
|
||||||
|
}
|
||||||
|
|
||||||
|
return nextTime;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void AdvanceTime(SimulationContext context, float nextTime, SimulationRuleSet ruleSet)
|
||||||
|
{
|
||||||
|
float clampedNextTime = Mathf.Max(context.currentTime, nextTime);
|
||||||
|
float deltaTime = clampedNextTime - context.currentTime;
|
||||||
|
if (deltaTime <= 0f)
|
||||||
|
return;
|
||||||
|
|
||||||
|
context.currentTime = clampedNextTime;
|
||||||
|
if (ruleSet.ManaRegenPerSecond > 0f)
|
||||||
|
{
|
||||||
|
context.currentMana = Mathf.Min(context.maxMana, context.currentMana + (ruleSet.ManaRegenPerSecond * deltaTime));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static float ResolveClipDuration(AnimationClip clip, float speed)
|
||||||
|
{
|
||||||
|
if (clip == null)
|
||||||
|
return 0f;
|
||||||
|
|
||||||
|
return clip.length / Mathf.Max(0.05f, speed);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static List<int> CollectValidPrioritySlots(RotationPolicy rotationPolicy, SkillRuntimeSnapshot[] snapshots)
|
||||||
|
{
|
||||||
|
List<int> validSlots = new List<int>();
|
||||||
|
int[] prioritySlots = rotationPolicy.PrioritySlots;
|
||||||
|
for (int i = 0; i < prioritySlots.Length; i++)
|
||||||
|
{
|
||||||
|
int slotIndex = prioritySlots[i];
|
||||||
|
if (slotIndex < 0 || slotIndex >= snapshots.Length)
|
||||||
|
continue;
|
||||||
|
|
||||||
|
if (snapshots[slotIndex] == null || validSlots.Contains(slotIndex))
|
||||||
|
continue;
|
||||||
|
|
||||||
|
validSlots.Add(slotIndex);
|
||||||
|
}
|
||||||
|
|
||||||
|
return validSlots;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool IsSlotRelevantToRotation(int slotIndex, RotationPolicy rotationPolicy)
|
||||||
|
{
|
||||||
|
int[] prioritySlots = rotationPolicy.PrioritySlots;
|
||||||
|
for (int i = 0; i < prioritySlots.Length; i++)
|
||||||
|
{
|
||||||
|
if (prioritySlots[i] == slotIndex)
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return rotationPolicy.UseFallbackSlot && rotationPolicy.FallbackSlotIndex == slotIndex;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static List<SimulationSkillBreakdown> BuildBreakdowns(Dictionary<int, SkillMetrics> metricsBySlot)
|
||||||
|
{
|
||||||
|
List<SimulationSkillBreakdown> breakdowns = new List<SimulationSkillBreakdown>();
|
||||||
|
foreach (KeyValuePair<int, SkillMetrics> pair in metricsBySlot)
|
||||||
|
{
|
||||||
|
SkillMetrics metrics = pair.Value;
|
||||||
|
breakdowns.Add(new SimulationSkillBreakdown(metrics.skillName, metrics.castCount, metrics.totalDamage));
|
||||||
|
}
|
||||||
|
|
||||||
|
breakdowns.Sort((left, right) => right.TotalDamage.CompareTo(left.TotalDamage));
|
||||||
|
return breakdowns;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void DestroyContext(SimulationContext context)
|
||||||
|
{
|
||||||
|
if (context == null || context.actor == null)
|
||||||
|
return;
|
||||||
|
|
||||||
|
if (Application.isPlaying)
|
||||||
|
{
|
||||||
|
UnityEngine.Object.Destroy(context.actor);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
UnityEngine.Object.DestroyImmediate(context.actor);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
fileFormatVersion: 2
|
||||||
|
guid: 67edd6a2a08d58646a03a1b1c45e5a5c
|
||||||
338
Assets/_Game/Scripts/Combat/Simulation/BuildSimulationInput.cs
Normal file
338
Assets/_Game/Scripts/Combat/Simulation/BuildSimulationInput.cs
Normal file
@@ -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
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 허수아비 계산 시뮬레이터가 사용할 완성형 빌드 입력값입니다.
|
||||||
|
/// 프리셋 없이 직접 구성한 빌드와 기존 프리셋 기반 입력을 모두 지원합니다.
|
||||||
|
/// </summary>
|
||||||
|
[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<PassiveNodeData> selectedPassiveNodes = new List<PassiveNodeData>();
|
||||||
|
|
||||||
|
[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<PassiveNodeData> SelectedPassiveNodes => selectedPassiveNodes;
|
||||||
|
public PassivePresetData PassivePreset => passivePreset;
|
||||||
|
public PlayerLoadoutPreset LoadoutPreset => loadoutPreset;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 입력 상태를 읽기 쉬운 라벨로 구성합니다.
|
||||||
|
/// </summary>
|
||||||
|
public string BuildLabel
|
||||||
|
{
|
||||||
|
get
|
||||||
|
{
|
||||||
|
string passiveName = ResolvePassiveLabel();
|
||||||
|
string loadoutName = ResolveLoadoutLabel();
|
||||||
|
return $"{BuildName} | Passive={passiveName} | Loadout={loadoutName}";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 직접 구성 슬롯과 프리셋을 합쳐 실제 계산에 사용할 슬롯 배열을 반환합니다.
|
||||||
|
/// </summary>
|
||||||
|
public IReadOnlyList<SkillLoadoutEntry> ResolveLoadoutEntries()
|
||||||
|
{
|
||||||
|
EnsureDirectSlotCapacity();
|
||||||
|
|
||||||
|
if (HasDirectSkillSetup())
|
||||||
|
return directSkillSlots;
|
||||||
|
|
||||||
|
return loadoutPreset != null ? loadoutPreset.Slots : System.Array.Empty<SkillLoadoutEntry>();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 실제 계산 가능한 기반 스킬이 하나 이상 있는지 반환합니다.
|
||||||
|
/// </summary>
|
||||||
|
public bool HasAnyResolvedSkill()
|
||||||
|
{
|
||||||
|
IReadOnlyList<SkillLoadoutEntry> entries = ResolveLoadoutEntries();
|
||||||
|
for (int i = 0; i < entries.Count; i++)
|
||||||
|
{
|
||||||
|
if (entries[i] != null && entries[i].BaseSkill != null)
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 실제 계산에 사용할 패시브 트리를 반환합니다.
|
||||||
|
/// </summary>
|
||||||
|
public PassiveTreeData ResolvePassiveTree()
|
||||||
|
{
|
||||||
|
if (passiveTree != null)
|
||||||
|
return passiveTree;
|
||||||
|
|
||||||
|
return passivePreset != null ? passivePreset.Tree : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 실제 계산에 사용할 패시브 노드 ID 목록을 반환합니다.
|
||||||
|
/// </summary>
|
||||||
|
public List<string> BuildSelectedPassiveNodeIdList()
|
||||||
|
{
|
||||||
|
List<string> nodeIds = new List<string>();
|
||||||
|
|
||||||
|
IReadOnlyList<PassiveNodeData> 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 실제 계산에 사용할 패시브 노드 목록을 반환합니다.
|
||||||
|
/// </summary>
|
||||||
|
public IReadOnlyList<PassiveNodeData> ResolveSelectedPassiveNodes()
|
||||||
|
{
|
||||||
|
if (selectedPassiveNodes != null && selectedPassiveNodes.Count > 0)
|
||||||
|
return selectedPassiveNodes;
|
||||||
|
|
||||||
|
return passivePreset != null ? passivePreset.SelectedNodes : System.Array.Empty<PassiveNodeData>();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 직접 구성 슬롯을 덮어씁니다.
|
||||||
|
/// </summary>
|
||||||
|
public void SetDirectSkillSlots(IReadOnlyList<SkillLoadoutEntry> 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 직접 구성 패시브를 덮어씁니다.
|
||||||
|
/// </summary>
|
||||||
|
public void SetDirectPassiveSelection(PassiveTreeData tree, IReadOnlyList<PassiveNodeData> 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]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 빌드 이름을 갱신합니다.
|
||||||
|
/// </summary>
|
||||||
|
public void SetBuildName(string value)
|
||||||
|
{
|
||||||
|
buildName = value ?? string.Empty;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 현재 입력값의 복사본을 생성합니다.
|
||||||
|
/// </summary>
|
||||||
|
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<PassiveNodeData>(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<PassiveNodeData> 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<SkillLoadoutEntry> 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<SkillGemData> 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() : "직접구성";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
fileFormatVersion: 2
|
||||||
|
guid: f38a3a26c057b2f459b8d2bb89bc5482
|
||||||
56
Assets/_Game/Scripts/Combat/Simulation/RotationPolicy.cs
Normal file
56
Assets/_Game/Scripts/Combat/Simulation/RotationPolicy.cs
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
using UnityEngine;
|
||||||
|
|
||||||
|
namespace Colosseum.Combat.Simulation
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 허수아비 계산 시뮬레이터의 회전 규칙입니다.
|
||||||
|
/// </summary>
|
||||||
|
[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<int>();
|
||||||
|
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);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 회전 정책 값을 한 번에 설정합니다.
|
||||||
|
/// </summary>
|
||||||
|
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<int>();
|
||||||
|
this.useFallbackSlot = useFallbackSlot;
|
||||||
|
this.fallbackSlotIndex = Mathf.Max(0, fallbackSlotIndex);
|
||||||
|
this.delayHighPowerSkillUntilTime = delayHighPowerSkillUntilTime;
|
||||||
|
this.highPowerSlotIndex = Mathf.Max(0, highPowerSlotIndex);
|
||||||
|
this.highPowerFirstUseTime = Mathf.Max(0f, highPowerFirstUseTime);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
fileFormatVersion: 2
|
||||||
|
guid: ef4440d129fca8442bdca9bdafdec626
|
||||||
@@ -0,0 +1,162 @@
|
|||||||
|
using System.Text;
|
||||||
|
|
||||||
|
namespace Colosseum.Combat.Simulation
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 배치 시뮬레이션 결과를 추출용 문자열로 변환합니다.
|
||||||
|
/// </summary>
|
||||||
|
public static class SimulationBatchReportUtility
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Markdown 리포트를 생성합니다.
|
||||||
|
/// </summary>
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// CSV 리포트를 생성합니다.
|
||||||
|
/// </summary>
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 기본 파일 이름을 생성합니다.
|
||||||
|
/// </summary>
|
||||||
|
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(' ', '_');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
fileFormatVersion: 2
|
||||||
|
guid: d741ba4f455e909469d52f22bad21c8b
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
using System.Collections.Generic;
|
||||||
|
|
||||||
|
using UnityEngine;
|
||||||
|
|
||||||
|
namespace Colosseum.Combat.Simulation
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 배치 시뮬레이션의 단일 결과 엔트리입니다.
|
||||||
|
/// </summary>
|
||||||
|
[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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 배치 시뮬레이션의 전체 결과입니다.
|
||||||
|
/// </summary>
|
||||||
|
[System.Serializable]
|
||||||
|
public sealed class SimulationBatchResult
|
||||||
|
{
|
||||||
|
[SerializeField] private string batchName = string.Empty;
|
||||||
|
[SerializeField] private int generatedBuildCount;
|
||||||
|
[SerializeField] private bool truncated;
|
||||||
|
[SerializeField] private List<SimulationBatchEntry> entries = new List<SimulationBatchEntry>();
|
||||||
|
[SerializeField] private List<string> warnings = new List<string>();
|
||||||
|
|
||||||
|
public string BatchName => batchName;
|
||||||
|
public int GeneratedBuildCount => generatedBuildCount;
|
||||||
|
public bool Truncated => truncated;
|
||||||
|
public IReadOnlyList<SimulationBatchEntry> Entries => entries;
|
||||||
|
public IReadOnlyList<string> Warnings => warnings;
|
||||||
|
|
||||||
|
public void Initialize(string batchName, int generatedBuildCount, bool truncated, List<SimulationBatchEntry> entries, List<string> warnings)
|
||||||
|
{
|
||||||
|
this.batchName = batchName ?? string.Empty;
|
||||||
|
this.generatedBuildCount = Mathf.Max(0, generatedBuildCount);
|
||||||
|
this.truncated = truncated;
|
||||||
|
this.entries = entries ?? new List<SimulationBatchEntry>();
|
||||||
|
this.warnings = warnings ?? new List<string>();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
fileFormatVersion: 2
|
||||||
|
guid: 6b13c6fe196ef0343942b700dbc02414
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
using System.Collections.Generic;
|
||||||
|
|
||||||
|
namespace Colosseum.Combat.Simulation
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 여러 빌드를 순회하며 배치 시뮬레이션을 실행합니다.
|
||||||
|
/// </summary>
|
||||||
|
public static class SimulationBatchRunner
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 생성된 빌드 목록을 순회 실행하고 결과를 묶어 반환합니다.
|
||||||
|
/// </summary>
|
||||||
|
public static SimulationBatchResult Run(
|
||||||
|
string batchName,
|
||||||
|
IReadOnlyList<BuildSimulationInput> builds,
|
||||||
|
SimulationRuleSet ruleSet,
|
||||||
|
RotationPolicy rotationPolicy,
|
||||||
|
IReadOnlyList<string> generationWarnings,
|
||||||
|
bool truncated)
|
||||||
|
{
|
||||||
|
List<SimulationBatchEntry> entries = new List<SimulationBatchEntry>();
|
||||||
|
List<string> warnings = new List<string>();
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
fileFormatVersion: 2
|
||||||
|
guid: 886744cabe7fc084e884d2dbd297ed8e
|
||||||
@@ -0,0 +1,424 @@
|
|||||||
|
using System.Collections.Generic;
|
||||||
|
|
||||||
|
using Colosseum.Passives;
|
||||||
|
using Colosseum.Skills;
|
||||||
|
|
||||||
|
namespace Colosseum.Combat.Simulation
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 스킬/젬/패시브 자산 풀에서 유효한 빌드 조합을 생성합니다.
|
||||||
|
/// </summary>
|
||||||
|
public static class SimulationCombinationGenerator
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 조합 조건에 따라 실제 시뮬레이션 입력 빌드를 생성합니다.
|
||||||
|
/// </summary>
|
||||||
|
public static List<BuildSimulationInput> GenerateBuilds(
|
||||||
|
BuildSimulationInput template,
|
||||||
|
SimulationCombinationSpec spec,
|
||||||
|
IReadOnlyList<SkillData> skillPool,
|
||||||
|
IReadOnlyList<SkillGemData> gemPool,
|
||||||
|
IReadOnlyList<PassiveNodeData> passiveNodePool,
|
||||||
|
List<string> warnings,
|
||||||
|
out bool truncated)
|
||||||
|
{
|
||||||
|
warnings ??= new List<string>();
|
||||||
|
truncated = false;
|
||||||
|
|
||||||
|
BuildSimulationInput baseTemplate = template != null ? template.CreateCopy() : new BuildSimulationInput();
|
||||||
|
List<BuildSimulationInput> results = new List<BuildSimulationInput>();
|
||||||
|
SimulationCombinationSpec safeSpec = spec ?? new SimulationCombinationSpec();
|
||||||
|
|
||||||
|
List<List<PassiveNodeData>> passiveSelections = BuildPassiveSelections(baseTemplate, safeSpec, passiveNodePool, warnings);
|
||||||
|
List<int> activeSlots = BuildActiveSlots(safeSpec);
|
||||||
|
Dictionary<SkillData, List<SkillLoadoutEntry>> loadoutCache = new Dictionary<SkillData, List<SkillLoadoutEntry>>();
|
||||||
|
SkillLoadoutEntry[] workingSlots = new SkillLoadoutEntry[7];
|
||||||
|
|
||||||
|
for (int i = 0; i < workingSlots.Length; i++)
|
||||||
|
{
|
||||||
|
IReadOnlyList<SkillLoadoutEntry> 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<PassiveNodeData> passiveSelection = passiveSelections[passiveIndex];
|
||||||
|
GenerateSkillAssignments(
|
||||||
|
baseTemplate,
|
||||||
|
safeSpec,
|
||||||
|
skillPool,
|
||||||
|
gemPool,
|
||||||
|
activeSlots,
|
||||||
|
loadoutCache,
|
||||||
|
workingSlots,
|
||||||
|
0,
|
||||||
|
new HashSet<SkillData>(),
|
||||||
|
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<SkillData> skillPool,
|
||||||
|
IReadOnlyList<SkillGemData> gemPool,
|
||||||
|
IReadOnlyList<int> activeSlots,
|
||||||
|
Dictionary<SkillData, List<SkillLoadoutEntry>> loadoutCache,
|
||||||
|
SkillLoadoutEntry[] workingSlots,
|
||||||
|
int slotCursor,
|
||||||
|
HashSet<SkillData> usedSkills,
|
||||||
|
IReadOnlyList<PassiveNodeData> passiveSelection,
|
||||||
|
List<BuildSimulationInput> results,
|
||||||
|
List<string> 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<SkillLoadoutEntry> 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<SkillGemData> gemPool,
|
||||||
|
IReadOnlyList<int> activeSlots,
|
||||||
|
Dictionary<SkillData, List<SkillLoadoutEntry>> loadoutCache,
|
||||||
|
SkillLoadoutEntry[] workingSlots,
|
||||||
|
int slotCursor,
|
||||||
|
IReadOnlyList<PassiveNodeData> passiveSelection,
|
||||||
|
List<BuildSimulationInput> results,
|
||||||
|
List<string> warnings,
|
||||||
|
ref int buildIndex,
|
||||||
|
ref bool truncated)
|
||||||
|
{
|
||||||
|
int slotIndex = activeSlots[slotCursor];
|
||||||
|
IReadOnlyList<SkillLoadoutEntry> 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<SkillLoadoutEntry> variants = spec.CombineGems
|
||||||
|
? GetLoadoutVariants(skill, spec, gemPool, loadoutCache)
|
||||||
|
: new List<SkillLoadoutEntry> { 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<SkillData>(),
|
||||||
|
passiveSelection,
|
||||||
|
results,
|
||||||
|
warnings,
|
||||||
|
ref buildIndex,
|
||||||
|
ref truncated);
|
||||||
|
|
||||||
|
if (truncated)
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static List<SkillLoadoutEntry> GetLoadoutVariants(
|
||||||
|
SkillData skill,
|
||||||
|
SimulationCombinationSpec spec,
|
||||||
|
IReadOnlyList<SkillGemData> gemPool,
|
||||||
|
Dictionary<SkillData, List<SkillLoadoutEntry>> loadoutCache)
|
||||||
|
{
|
||||||
|
if (loadoutCache.TryGetValue(skill, out List<SkillLoadoutEntry> cachedVariants))
|
||||||
|
return cachedVariants;
|
||||||
|
|
||||||
|
List<SkillLoadoutEntry> variants = new List<SkillLoadoutEntry>();
|
||||||
|
int gemSlotCount = skill != null ? skill.MaxGemSlotCount : 0;
|
||||||
|
|
||||||
|
if (skill == null)
|
||||||
|
return variants;
|
||||||
|
|
||||||
|
List<SkillGemData> compatibleGems = new List<SkillGemData>();
|
||||||
|
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<SkillGemData> selected = new List<SkillGemData>();
|
||||||
|
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<SkillGemData> compatibleGems,
|
||||||
|
List<SkillGemData> selected,
|
||||||
|
int startIndex,
|
||||||
|
int maxGemCount,
|
||||||
|
List<SkillLoadoutEntry> 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<List<PassiveNodeData>> BuildPassiveSelections(
|
||||||
|
BuildSimulationInput template,
|
||||||
|
SimulationCombinationSpec spec,
|
||||||
|
IReadOnlyList<PassiveNodeData> passiveNodePool,
|
||||||
|
List<string> warnings)
|
||||||
|
{
|
||||||
|
List<List<PassiveNodeData>> selections = new List<List<PassiveNodeData>>();
|
||||||
|
|
||||||
|
if (!spec.CombinePassives)
|
||||||
|
{
|
||||||
|
List<PassiveNodeData> currentSelection = new List<PassiveNodeData>();
|
||||||
|
IReadOnlyList<PassiveNodeData> 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<PassiveNodeData>());
|
||||||
|
return selections;
|
||||||
|
}
|
||||||
|
|
||||||
|
List<PassiveNodeData> candidates = new List<PassiveNodeData>();
|
||||||
|
IReadOnlyList<PassiveNodeData> 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<PassiveNodeData> selected = new List<PassiveNodeData>();
|
||||||
|
if (spec.IncludeEmptyPassiveSelection)
|
||||||
|
selections.Add(new List<PassiveNodeData>());
|
||||||
|
|
||||||
|
CollectPassiveSelections(tree, spec, candidates, selected, 0, selections);
|
||||||
|
|
||||||
|
if (selections.Count == 0)
|
||||||
|
selections.Add(new List<PassiveNodeData>());
|
||||||
|
|
||||||
|
return selections;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void CollectPassiveSelections(
|
||||||
|
PassiveTreeData tree,
|
||||||
|
SimulationCombinationSpec spec,
|
||||||
|
IReadOnlyList<PassiveNodeData> candidates,
|
||||||
|
List<PassiveNodeData> selected,
|
||||||
|
int startIndex,
|
||||||
|
List<List<PassiveNodeData>> 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<PassiveNodeData>(selected));
|
||||||
|
CollectPassiveSelections(tree, spec, candidates, selected, i + 1, results);
|
||||||
|
}
|
||||||
|
|
||||||
|
selected.RemoveAt(selected.Count - 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool TryValidatePassiveSelection(PassiveTreeData tree, IReadOnlyList<PassiveNodeData> selected)
|
||||||
|
{
|
||||||
|
List<string> ids = new List<string>(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<int> BuildActiveSlots(SimulationCombinationSpec spec)
|
||||||
|
{
|
||||||
|
List<int> activeSlots = new List<int>();
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
fileFormatVersion: 2
|
||||||
|
guid: 68001ec95567cad4bb70cfc9863c469a
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
using UnityEngine;
|
||||||
|
|
||||||
|
using Colosseum.Passives;
|
||||||
|
|
||||||
|
namespace Colosseum.Combat.Simulation
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 전수 점검용 조합 생성 조건입니다.
|
||||||
|
/// </summary>
|
||||||
|
[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<int>();
|
||||||
|
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);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 조합 생성 조건을 한 번에 설정합니다.
|
||||||
|
/// </summary>
|
||||||
|
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<int>();
|
||||||
|
this.allowDuplicateSkills = allowDuplicateSkills;
|
||||||
|
this.includeEmptyGemSet = includeEmptyGemSet;
|
||||||
|
this.passiveTree = passiveTree;
|
||||||
|
this.includeEmptyPassiveSelection = includeEmptyPassiveSelection;
|
||||||
|
this.maxPassiveNodeCount = Mathf.Max(0, maxPassiveNodeCount);
|
||||||
|
this.maxBuildCount = Mathf.Max(1, maxBuildCount);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
fileFormatVersion: 2
|
||||||
|
guid: 8311c1f399b18a54a844b5d373eb27fa
|
||||||
@@ -0,0 +1,221 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Text;
|
||||||
|
|
||||||
|
namespace Colosseum.Combat.Simulation
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 결과 미리보기/추출 포맷입니다.
|
||||||
|
/// </summary>
|
||||||
|
public enum SimulationReportFormat
|
||||||
|
{
|
||||||
|
DetailText,
|
||||||
|
Markdown,
|
||||||
|
Csv,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 허수아비 계산 시뮬레이터 결과를 외부 공유용 문자열로 변환합니다.
|
||||||
|
/// </summary>
|
||||||
|
public static class SimulationReportUtility
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 선택한 포맷으로 결과 문자열을 생성합니다.
|
||||||
|
/// </summary>
|
||||||
|
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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 마크다운 리포트를 생성합니다.
|
||||||
|
/// </summary>
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// CSV 리포트를 생성합니다.
|
||||||
|
/// </summary>
|
||||||
|
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<SimulationSkillBreakdown> 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();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 저장에 적합한 기본 파일 이름을 생성합니다.
|
||||||
|
/// </summary>
|
||||||
|
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(' ', '_');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
fileFormatVersion: 2
|
||||||
|
guid: 47296b792ba84ab4cbf71d24c403fc0f
|
||||||
152
Assets/_Game/Scripts/Combat/Simulation/SimulationResult.cs
Normal file
152
Assets/_Game/Scripts/Combat/Simulation/SimulationResult.cs
Normal file
@@ -0,0 +1,152 @@
|
|||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Text;
|
||||||
|
|
||||||
|
using UnityEngine;
|
||||||
|
|
||||||
|
namespace Colosseum.Combat.Simulation
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 스킬별 기여도 요약입니다.
|
||||||
|
/// </summary>
|
||||||
|
[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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 허수아비 계산 시뮬레이터 결과입니다.
|
||||||
|
/// </summary>
|
||||||
|
[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<SimulationSkillBreakdown> skillBreakdowns = new List<SimulationSkillBreakdown>();
|
||||||
|
[SerializeField] private List<string> warnings = new List<string>();
|
||||||
|
|
||||||
|
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<SimulationSkillBreakdown> SkillBreakdowns => skillBreakdowns;
|
||||||
|
public IReadOnlyList<string> Warnings => warnings;
|
||||||
|
|
||||||
|
public void FinalizeResult(
|
||||||
|
string buildLabel,
|
||||||
|
string ruleName,
|
||||||
|
string rotationName,
|
||||||
|
float durationSeconds,
|
||||||
|
float totalDamage,
|
||||||
|
float totalManaUsed,
|
||||||
|
float firstCycleEndTime,
|
||||||
|
List<SimulationSkillBreakdown> breakdowns,
|
||||||
|
List<string> 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<SimulationSkillBreakdown>();
|
||||||
|
this.warnings = warnings ?? new List<string>();
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
fileFormatVersion: 2
|
||||||
|
guid: bbfa42a3c08321246b85393c07df6a8e
|
||||||
38
Assets/_Game/Scripts/Combat/Simulation/SimulationRuleSet.cs
Normal file
38
Assets/_Game/Scripts/Combat/Simulation/SimulationRuleSet.cs
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
using UnityEngine;
|
||||||
|
|
||||||
|
namespace Colosseum.Combat.Simulation
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 허수아비 계산 시뮬레이터의 고정 가정입니다.
|
||||||
|
/// </summary>
|
||||||
|
[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);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 룰셋 값을 한 번에 설정합니다.
|
||||||
|
/// </summary>
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
fileFormatVersion: 2
|
||||||
|
guid: d870cf57849e6f447b843cccdc235d42
|
||||||
176
Assets/_Game/Scripts/Editor/BuildSimulationBatchCommands.cs
Normal file
176
Assets/_Game/Scripts/Editor/BuildSimulationBatchCommands.cs
Normal file
@@ -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
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 허수아비 계산 시뮬레이터의 배치 조사 실행 메뉴입니다.
|
||||||
|
/// </summary>
|
||||||
|
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<string> DisabledPlayerSkillPaths = new HashSet<string>
|
||||||
|
{
|
||||||
|
"Assets/_Game/Data/Skills/Data_Skill_Player_회전베기.asset",
|
||||||
|
"Assets/_Game/Data/Skills/Data_Skill_Player_돌진.asset",
|
||||||
|
};
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 현재 기준 플레이어 단일 슬롯 데미지 전수 조사를 실행합니다.
|
||||||
|
/// </summary>
|
||||||
|
[MenuItem("Tools/Colosseum/Simulation/Run Player Damage Sweep")]
|
||||||
|
private static void RunPlayerDamageSweep()
|
||||||
|
{
|
||||||
|
PassiveTreeData passiveTree = AssetDatabase.LoadAssetAtPath<PassiveTreeData>(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<string> warnings = new List<string>();
|
||||||
|
List<SkillData> skills = LoadPlayerSkills(warnings);
|
||||||
|
List<SkillGemData> gems = LoadAssetsInFolder<SkillGemData>(PlayerGemFolder);
|
||||||
|
List<PassiveNodeData> passiveNodes = LoadAssetsInFolder<PassiveNodeData>(PlayerPassiveFolder);
|
||||||
|
|
||||||
|
List<BuildSimulationInput> 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<SkillData> LoadPlayerSkills(List<string> warnings)
|
||||||
|
{
|
||||||
|
List<SkillData> skills = LoadAssetsInFolder<SkillData>(PlayerSkillFolder);
|
||||||
|
List<SkillData> filtered = new List<SkillData>();
|
||||||
|
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<T> LoadAssetsInFolder<T>(string folderPath) where T : Object
|
||||||
|
{
|
||||||
|
List<T> assets = new List<T>();
|
||||||
|
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<T>(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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
fileFormatVersion: 2
|
||||||
|
guid: 82f1fba75c8e4f040ad82e2aa0096063
|
||||||
287
Assets/_Game/Scripts/Editor/BuildSimulationBatchWindow.cs
Normal file
287
Assets/_Game/Scripts/Editor/BuildSimulationBatchWindow.cs
Normal file
@@ -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
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 허수아비 계산 시뮬레이터의 전체 조합 배치 실행 창입니다.
|
||||||
|
/// </summary>
|
||||||
|
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<BuildSimulationBatchWindow>("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<string> warnings = new List<string>();
|
||||||
|
|
||||||
|
List<SkillData> skillPool = combinationSpec.CombineSkills
|
||||||
|
? LoadAssetsInFolder<SkillData>(skillSearchFolder)
|
||||||
|
: new List<SkillData>();
|
||||||
|
List<SkillGemData> gemPool = combinationSpec.CombineGems
|
||||||
|
? LoadAssetsInFolder<SkillGemData>(gemSearchFolder)
|
||||||
|
: new List<SkillGemData>();
|
||||||
|
List<PassiveNodeData> passivePool = combinationSpec.CombinePassives
|
||||||
|
? LoadAssetsInFolder<PassiveNodeData>(passiveNodeSearchFolder)
|
||||||
|
: new List<PassiveNodeData>();
|
||||||
|
|
||||||
|
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<BuildSimulationInput> 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<T> LoadAssetsInFolder<T>(string folderPath) where T : UnityEngine.Object
|
||||||
|
{
|
||||||
|
List<T> assets = new List<T>();
|
||||||
|
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<T>(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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
fileFormatVersion: 2
|
||||||
|
guid: 0ee99b72045222a418924f50d0e5f4ab
|
||||||
219
Assets/_Game/Scripts/Editor/BuildSimulationWindow.cs
Normal file
219
Assets/_Game/Scripts/Editor/BuildSimulationWindow.cs
Normal file
@@ -0,0 +1,219 @@
|
|||||||
|
using System.IO;
|
||||||
|
|
||||||
|
using UnityEditor;
|
||||||
|
using UnityEngine;
|
||||||
|
|
||||||
|
using Colosseum.Combat.Simulation;
|
||||||
|
|
||||||
|
namespace Colosseum.Editor
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 허수아비 계산 시뮬레이터 실행 창입니다.
|
||||||
|
/// </summary>
|
||||||
|
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<BuildSimulationWindow>("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}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
fileFormatVersion: 2
|
||||||
|
guid: fc6991eb0ed991a439747282181ff086
|
||||||
@@ -31,6 +31,10 @@ namespace Colosseum.Skills.Effects
|
|||||||
[Tooltip("스탯 계수 (1.0 = 100%)")]
|
[Tooltip("스탯 계수 (1.0 = 100%)")]
|
||||||
[Min(0f)] [SerializeField] private float statScaling = 1f;
|
[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)
|
protected override void ApplyEffect(GameObject caster, GameObject target)
|
||||||
{
|
{
|
||||||
if (target == null) return;
|
if (target == null) return;
|
||||||
|
|||||||
Reference in New Issue
Block a user