Compare commits

...

3 Commits

Author SHA1 Message Date
2bc5241ff1 feat: 런타임 보스 디버그 패널 및 MCP 커맨드 추가
- HUD 우측 하단 접이식 디버그 패널 (DebugPanelUI): 보스 HP 슬라이더/직접입력/프리셋, 페이즈 전환, 리스폰, 보호막, 이상상태 적용
- MCP execute_menu_item으로 호출 가능한 MenuItem 커맨드 (DebugBossMenuItems): HP/페이즈/보호막 제어, 상태 조회, 임의 값 설정 지원
- BossEnemyEditor 인스펙터 HP 조작 확장: 퍼센트 슬라이더 및 직접 HP 입력 추가
- Test 씬 UI Canvas 하위 DebugPanel GameObject 배치
- UNITY_EDITOR || DEVELOPMENT_BUILD 가드로 릴리즈 빌드 미포함
2026-03-28 15:09:56 +09:00
343ef1b072 chore: 테스트 로드아웃 프리셋 정리
- 애니메이션 미구현 상태인 돌진, 회전베기 참조를 테스트용 플레이어 로드아웃 프리셋에서 제거
- 탱커/지원/딜러 계열 프리셋이 현재 사용 가능한 스킬 조합만 가리키도록 재생성한 결과를 반영
2026-03-28 15:09:26 +09:00
285da31047 feat: 허수아비 계산 시뮬레이터 추가
- 빌드 입력, 룰셋, 회전 정책, 결과/리포트 모델을 포함한 데미지 계산 시뮬레이터 기반을 추가
- 단일 실행 창과 배치 전수 조사 창, 플레이어 데미지 스윕 메뉴를 추가
- DamageEffect 계산값 접근자를 열어 기존 전투 공식을 시뮬레이터에서 재사용하도록 정리
2026-03-28 15:07:09 +09:00
50 changed files with 3892 additions and 21 deletions

View File

@@ -1184,6 +1184,7 @@ RectTransform:
- {fileID: 678443228}
- {fileID: 1221067101607693524}
- {fileID: 1943804129}
- {fileID: 985852648}
m_Father: {fileID: 0}
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
m_AnchorMin: {x: 0, y: 0}
@@ -5040,6 +5041,58 @@ Transform:
m_CorrespondingSourceObject: {fileID: 7132605379903659868, guid: 5b4ac53b97612ae4392b84786de0d50d, type: 3}
m_PrefabInstance: {fileID: 539760736}
m_PrefabAsset: {fileID: 0}
--- !u!1 &985852647
GameObject:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
serializedVersion: 6
m_Component:
- component: {fileID: 985852648}
- component: {fileID: 985852649}
m_Layer: 0
m_Name: DebugPanel
m_TagString: Untagged
m_Icon: {fileID: 0}
m_NavMeshLayer: 0
m_StaticEditorFlags: 0
m_IsActive: 1
--- !u!224 &985852648
RectTransform:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 985852647}
m_LocalRotation: {x: -0, y: -0, z: -0, w: 1}
m_LocalPosition: {x: 0, y: 0, z: 0}
m_LocalScale: {x: 1, y: 1, z: 1}
m_ConstrainProportionsScale: 0
m_Children: []
m_Father: {fileID: 260528176}
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
m_AnchorMin: {x: 0, y: 0}
m_AnchorMax: {x: 1, y: 1}
m_AnchoredPosition: {x: 0, y: 0}
m_SizeDelta: {x: 0, y: 0}
m_Pivot: {x: 0.5, y: 0.5}
--- !u!114 &985852649
MonoBehaviour:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 985852647}
m_Enabled: 1
m_EditorHideFlags: 0
m_Script: {fileID: 11500000, guid: 7b7611f77d92f8e41bfe5dfb5ac1768f, type: 3}
m_Name:
m_EditorClassIdentifier: Colosseum.Game::Colosseum.UI.DebugPanelUI
stunAbnormalityData: {fileID: 0}
silenceAbnormalityData: {fileID: 0}
panelWidth: 280
panelMaxHeight: 500
--- !u!1001 &989227100
PrefabInstance:
m_ObjectHideFlags: 0

View File

@@ -28,7 +28,7 @@ MonoBehaviour:
socketedGems:
- {fileID: 0}
- {fileID: 0}
- baseSkill: {fileID: 11400000, guid: f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3, type: 2}
- baseSkill: {fileID: 11400000, guid: b78d2eb76cdfbe248b65bafe6e1dc231, type: 2}
socketedGems:
- {fileID: 0}
- {fileID: 0}

View File

@@ -24,11 +24,11 @@ MonoBehaviour:
socketedGems:
- {fileID: 0}
- {fileID: 0}
- baseSkill: {fileID: 11400000, guid: d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1, type: 2}
- baseSkill: {fileID: 11400000, guid: b78d2eb76cdfbe248b65bafe6e1dc231, type: 2}
socketedGems:
- {fileID: 0}
- {fileID: 0}
- baseSkill: {fileID: 11400000, guid: f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3, type: 2}
- baseSkill: {fileID: 11400000, guid: a822c7e8c7cee5546ad594b582208e53, type: 2}
socketedGems:
- {fileID: 0}
- {fileID: 0}

View File

@@ -24,11 +24,11 @@ MonoBehaviour:
socketedGems:
- {fileID: 0}
- {fileID: 0}
- baseSkill: {fileID: 11400000, guid: d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1, type: 2}
- baseSkill: {fileID: 11400000, guid: b78d2eb76cdfbe248b65bafe6e1dc231, type: 2}
socketedGems:
- {fileID: 0}
- {fileID: 0}
- baseSkill: {fileID: 11400000, guid: f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3, type: 2}
- baseSkill: {fileID: 11400000, guid: a822c7e8c7cee5546ad594b582208e53, type: 2}
socketedGems:
- {fileID: 0}
- {fileID: 0}

View File

@@ -24,11 +24,11 @@ MonoBehaviour:
socketedGems:
- {fileID: 0}
- {fileID: 0}
- baseSkill: {fileID: 11400000, guid: d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1, type: 2}
- baseSkill: {fileID: 11400000, guid: b78d2eb76cdfbe248b65bafe6e1dc231, type: 2}
socketedGems:
- {fileID: 0}
- {fileID: 0}
- baseSkill: {fileID: 11400000, guid: f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3, type: 2}
- baseSkill: {fileID: 11400000, guid: a822c7e8c7cee5546ad594b582208e53, type: 2}
socketedGems:
- {fileID: 0}
- {fileID: 0}

View File

@@ -24,11 +24,11 @@ MonoBehaviour:
socketedGems:
- {fileID: 0}
- {fileID: 0}
- baseSkill: {fileID: 11400000, guid: d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1, type: 2}
- baseSkill: {fileID: 11400000, guid: b78d2eb76cdfbe248b65bafe6e1dc231, type: 2}
socketedGems:
- {fileID: 0}
- {fileID: 0}
- baseSkill: {fileID: 11400000, guid: f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3, type: 2}
- baseSkill: {fileID: 11400000, guid: a822c7e8c7cee5546ad594b582208e53, type: 2}
socketedGems:
- {fileID: 0}
- {fileID: 0}

View File

@@ -25,11 +25,11 @@ MonoBehaviour:
socketedGems:
- {fileID: 0}
- {fileID: 0}
- baseSkill: {fileID: 11400000, guid: d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1, type: 2}
- baseSkill: {fileID: 11400000, guid: b78d2eb76cdfbe248b65bafe6e1dc231, type: 2}
socketedGems:
- {fileID: 0}
- {fileID: 0}
- baseSkill: {fileID: 11400000, guid: f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3, type: 2}
- baseSkill: {fileID: 11400000, guid: a822c7e8c7cee5546ad594b582208e53, type: 2}
socketedGems:
- {fileID: 0}
- {fileID: 0}

View File

@@ -24,11 +24,11 @@ MonoBehaviour:
socketedGems:
- {fileID: 0}
- {fileID: 0}
- baseSkill: {fileID: 11400000, guid: d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1, type: 2}
- baseSkill: {fileID: 11400000, guid: b78d2eb76cdfbe248b65bafe6e1dc231, type: 2}
socketedGems:
- {fileID: 0}
- {fileID: 0}
- baseSkill: {fileID: 11400000, guid: f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3, type: 2}
- baseSkill: {fileID: 11400000, guid: a822c7e8c7cee5546ad594b582208e53, type: 2}
socketedGems:
- {fileID: 0}
- {fileID: 0}

View File

@@ -24,11 +24,11 @@ MonoBehaviour:
socketedGems:
- {fileID: 0}
- {fileID: 0}
- baseSkill: {fileID: 11400000, guid: d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1, type: 2}
- baseSkill: {fileID: 11400000, guid: b78d2eb76cdfbe248b65bafe6e1dc231, type: 2}
socketedGems:
- {fileID: 0}
- {fileID: 0}
- baseSkill: {fileID: 11400000, guid: f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3, type: 2}
- baseSkill: {fileID: 11400000, guid: a822c7e8c7cee5546ad594b582208e53, type: 2}
socketedGems:
- {fileID: 0}
- {fileID: 0}

View File

@@ -32,7 +32,7 @@ MonoBehaviour:
socketedGems:
- {fileID: 0}
- {fileID: 0}
- baseSkill: {fileID: 11400000, guid: f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3, type: 2}
- baseSkill: {fileID: 11400000, guid: a822c7e8c7cee5546ad594b582208e53, type: 2}
socketedGems:
- {fileID: 0}
- {fileID: 0}

View File

@@ -32,7 +32,7 @@ MonoBehaviour:
socketedGems:
- {fileID: 0}
- {fileID: 0}
- baseSkill: {fileID: 11400000, guid: f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3, type: 2}
- baseSkill: {fileID: 11400000, guid: a822c7e8c7cee5546ad594b582208e53, type: 2}
socketedGems:
- {fileID: 0}
- {fileID: 0}

View File

@@ -32,7 +32,7 @@ MonoBehaviour:
socketedGems:
- {fileID: 0}
- {fileID: 0}
- baseSkill: {fileID: 11400000, guid: f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3, type: 2}
- baseSkill: {fileID: 11400000, guid: a822c7e8c7cee5546ad594b582208e53, type: 2}
socketedGems:
- {fileID: 0}
- {fileID: 0}

View File

@@ -28,7 +28,7 @@ MonoBehaviour:
socketedGems:
- {fileID: 0}
- {fileID: 0}
- baseSkill: {fileID: 11400000, guid: f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3, type: 2}
- baseSkill: {fileID: 11400000, guid: b78d2eb76cdfbe248b65bafe6e1dc231, type: 2}
socketedGems:
- {fileID: 0}
- {fileID: 0}

View File

@@ -28,7 +28,7 @@ MonoBehaviour:
socketedGems:
- {fileID: 0}
- {fileID: 0}
- baseSkill: {fileID: 11400000, guid: f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3, type: 2}
- baseSkill: {fileID: 11400000, guid: b78d2eb76cdfbe248b65bafe6e1dc231, type: 2}
socketedGems:
- {fileID: 0}
- {fileID: 0}

View File

@@ -28,7 +28,7 @@ MonoBehaviour:
socketedGems:
- {fileID: 0}
- {fileID: 0}
- baseSkill: {fileID: 11400000, guid: f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3, type: 2}
- baseSkill: {fileID: 11400000, guid: b78d2eb76cdfbe248b65bafe6e1dc231, type: 2}
socketedGems:
- {fileID: 0}
- {fileID: 0}

View File

@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: afad57d9ec73a1740803c20de7b89392
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

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

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 67edd6a2a08d58646a03a1b1c45e5a5c

View 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() : "직접구성";
}
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: f38a3a26c057b2f459b8d2bb89bc5482

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

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: ef4440d129fca8442bdca9bdafdec626

View File

@@ -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(' ', '_');
}
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: d741ba4f455e909469d52f22bad21c8b

View File

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

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 6b13c6fe196ef0343942b700dbc02414

View File

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

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 886744cabe7fc084e884d2dbd297ed8e

View File

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

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 68001ec95567cad4bb70cfc9863c469a

View File

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

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 8311c1f399b18a54a844b5d373eb27fa

View File

@@ -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(' ', '_');
}
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 47296b792ba84ab4cbf71d24c403fc0f

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

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: bbfa42a3c08321246b85393c07df6a8e

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

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: d870cf57849e6f447b843cccdc235d42

View File

@@ -17,6 +17,8 @@ namespace Colosseum.Editor
private bool showThreatInfo = true;
private bool showDebugTools = true;
private int selectedPhaseIndex = 0;
private float debugHPPercent = 1f;
private float debugHPValue = 0f;
private void OnEnable()
{
@@ -214,6 +216,37 @@ namespace Colosseum.Editor
// HP 조작
EditorGUILayout.LabelField("HP 조작", EditorStyles.boldLabel);
EditorGUI.indentLevel++;
// 현재 HP 표시
EditorGUILayout.LabelField("현재",
$"{boss.CurrentHealth:F0} / {boss.MaxHealth:F0} ({(boss.MaxHealth > 0 ? boss.CurrentHealth / boss.MaxHealth * 100f : 0f):F1}%)");
// 퍼센트 슬라이더
EditorGUI.BeginChangeCheck();
debugHPPercent = EditorGUILayout.Slider("퍼센트", debugHPPercent, 0f, 1f);
if (EditorGUI.EndChangeCheck())
{
SetBossHP(debugHPPercent);
}
// 직접 HP 값 입력
EditorGUILayout.BeginHorizontal();
debugHPValue = EditorGUILayout.FloatField("직접 입력", debugHPValue);
EditorGUILayout.LabelField($"/ {boss.MaxHealth:F0}", GUILayout.Width(80));
if (GUILayout.Button("적용", GUILayout.Width(60)))
{
float clamped = Mathf.Clamp(debugHPValue, 0f, boss.MaxHealth);
float percent = boss.MaxHealth > 0 ? clamped / boss.MaxHealth : 0f;
debugHPPercent = percent;
SetBossHP(percent);
}
EditorGUILayout.EndHorizontal();
EditorGUI.indentLevel--;
// 빠른 HP 설정 버튼
EditorGUILayout.Space(3);
EditorGUILayout.BeginHorizontal();
if (GUILayout.Button("HP 10%"))
{

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

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 82f1fba75c8e4f040ad82e2aa0096063

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

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 0ee99b72045222a418924f50d0e5f4ab

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

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: fc6991eb0ed991a439747282181ff086

View File

@@ -0,0 +1,255 @@
using System.Text;
using Colosseum.Enemy;
using Colosseum.Abnormalities;
using UnityEditor;
using UnityEngine;
using UnityEngine.Networking;
namespace Colosseum.Editor
{
/// <summary>
/// MCP (execute_menu_item) 또는 에디터 메뉴에서 호출 가능한 보스 디버그 커맨드.
/// 파라미터가 필요한 연산은 static 필드를 설정한 뒤 Custom MenuItem을 호출합니다.
/// </summary>
public static class DebugBossMenuItems
{
private const string StunAbnormalityPath = "Assets/_Game/Data/Abnormalities/Data_Abnormality_Player_Stun.asset";
private const string SilenceAbnormalityPath = "Assets/_Game/Data/Abnormalities/Data_Abnormality_Player_Silence.asset";
// ── MCP가 설정 후 Custom 메뉴 호출하는 파라미터 필드 ──
/// <summary>
/// Set Boss HP Custom에서 사용할 퍼센트 (0.0 ~ 1.0).
/// MCP는 script_apply_edits로 이 값을 변경한 뒤 Custom 메뉴를 호출합니다.
/// </summary>
public static float customHPPercent = 0.5f;
/// <summary>
/// Force Phase Custom에서 사용할 페이즈 인덱스 (0-based).
/// </summary>
public static int customPhaseIndex = 0;
/// <summary>
/// Apply Shield Custom에서 사용할 보호막 수치.
/// </summary>
public static float customShieldAmount = 1000f;
// ── 보스 HP ──
[MenuItem("Tools/Colosseum/Debug/Boss/Set HP 10%")]
private static void SetHP10() => SetBossHP(0.1f);
[MenuItem("Tools/Colosseum/Debug/Boss/Set HP 25%")]
private static void SetHP25() => SetBossHP(0.25f);
[MenuItem("Tools/Colosseum/Debug/Boss/Set HP 50%")]
private static void SetHP50() => SetBossHP(0.5f);
[MenuItem("Tools/Colosseum/Debug/Boss/Set HP 75%")]
private static void SetHP75() => SetBossHP(0.75f);
[MenuItem("Tools/Colosseum/Debug/Boss/Set HP 100%")]
private static void SetHP100() => SetBossHP(1f);
[MenuItem("Tools/Colosseum/Debug/Boss/Set HP Custom")]
private static void SetHPCustom() => SetBossHP(Mathf.Clamp01(customHPPercent));
[MenuItem("Tools/Colosseum/Debug/Boss/Full Heal")]
private static void FullHeal()
{
BossEnemy boss = FindBoss();
if (boss == null) return;
boss.Heal(boss.MaxHealth);
Debug.Log($"[Debug] 보스 HP 풀회복 | HP={boss.CurrentHealth:F0}/{boss.MaxHealth:F0}");
}
// ── 보스 제어 ──
[MenuItem("Tools/Colosseum/Debug/Boss/Force Phase 0")]
private static void ForcePhase0() => ForcePhase(0);
[MenuItem("Tools/Colosseum/Debug/Boss/Force Phase 1")]
private static void ForcePhase1() => ForcePhase(1);
[MenuItem("Tools/Colosseum/Debug/Boss/Force Phase 2")]
private static void ForcePhase2() => ForcePhase(2);
[MenuItem("Tools/Colosseum/Debug/Boss/Force Phase Custom")]
private static void ForcePhaseCustom() => ForcePhase(customPhaseIndex);
[MenuItem("Tools/Colosseum/Debug/Boss/Restart Current Phase")]
private static void RestartPhase()
{
BossEnemy boss = FindBoss();
if (boss == null) return;
boss.RestartCurrentPhase();
Debug.Log($"[Debug] 보스 현재 페이즈 재시작 | Phase={boss.CurrentPhaseIndex}");
}
[MenuItem("Tools/Colosseum/Debug/Boss/Respawn")]
private static void Respawn()
{
BossEnemy boss = FindBoss();
if (boss == null) return;
boss.Respawn();
Debug.Log($"[Debug] 보스 리스폰 | HP={boss.CurrentHealth:F0}/{boss.MaxHealth:F0}");
}
// ── 보호막 ──
[MenuItem("Tools/Colosseum/Debug/Boss/Apply Shield 500")]
private static void ApplyShield500() => ApplyShield(500f);
[MenuItem("Tools/Colosseum/Debug/Boss/Apply Shield 1000")]
private static void ApplyShield1000() => ApplyShield(1000f);
[MenuItem("Tools/Colosseum/Debug/Boss/Apply Shield 5000")]
private static void ApplyShield5000() => ApplyShield(5000f);
[MenuItem("Tools/Colosseum/Debug/Boss/Apply Shield Custom")]
private static void ApplyShieldCustom() => ApplyShield(Mathf.Max(0f, customShieldAmount));
// ── 이상상태 ──
[MenuItem("Tools/Colosseum/Debug/Boss/Apply Stun")]
private static void ApplyStun()
{
BossEnemy boss = FindBoss();
if (boss == null) return;
AbnormalityData data = AssetDatabase.LoadAssetAtPath<AbnormalityData>(StunAbnormalityPath);
if (data == null)
{
Debug.LogWarning($"[Debug] 기절 에셋을 찾지 못했습니다: {StunAbnormalityPath}");
return;
}
AbnormalityManager am = boss.GetComponent<AbnormalityManager>();
if (am != null)
{
am.ApplyAbnormality(data, boss.gameObject);
Debug.Log($"[Debug] 보스 기절 적용 | {data.abnormalityName}");
}
}
[MenuItem("Tools/Colosseum/Debug/Boss/Apply Silence")]
private static void ApplySilence()
{
BossEnemy boss = FindBoss();
if (boss == null) return;
AbnormalityData data = AssetDatabase.LoadAssetAtPath<AbnormalityData>(SilenceAbnormalityPath);
if (data == null)
{
Debug.LogWarning($"[Debug] 침묵 에셋을 찾지 못했습니다: {SilenceAbnormalityPath}");
return;
}
AbnormalityManager am = boss.GetComponent<AbnormalityManager>();
if (am != null)
{
am.ApplyAbnormality(data, boss.gameObject);
Debug.Log($"[Debug] 보스 침묵 적용 | {data.abnormalityName}");
}
}
[MenuItem("Tools/Colosseum/Debug/Boss/Clear Threat")]
private static void ClearThreat()
{
BossEnemy boss = FindBoss();
if (boss == null) return;
boss.ClearAllThreat();
Debug.Log("[Debug] 보스 위협 초기화");
}
// ── 상태 조회 ──
[MenuItem("Tools/Colosseum/Debug/Boss/Log Status")]
private static void LogStatus()
{
BossEnemy boss = FindBoss();
if (boss == null) return;
StringBuilder sb = new StringBuilder();
sb.AppendLine($"[Debug] 보스 상태 | {boss.name}");
sb.AppendLine($" HP: {boss.CurrentHealth:F0} / {boss.MaxHealth:F0} ({(boss.MaxHealth > 0 ? boss.CurrentHealth / boss.MaxHealth * 100f : 0f):F1}%)");
sb.AppendLine($" Shield: {boss.Shield:F0}");
sb.AppendLine($" Phase: {boss.CurrentPhaseIndex + 1} / {boss.TotalPhases}");
sb.AppendLine($" IsDead: {boss.IsDead}");
if (boss.CurrentPhase != null)
sb.AppendLine($" PhaseName: {boss.CurrentPhase.PhaseName}");
Debug.Log(sb.ToString());
}
// ── 내부 구현 ──
/// <summary>
/// 활성 보스 찾기
/// </summary>
private static BossEnemy FindBoss()
{
if (!EditorApplication.isPlaying)
{
Debug.LogWarning("[Debug] 플레이 모드에서만 사용할 수 있습니다.");
return null;
}
BossEnemy boss = BossEnemy.ActiveBoss != null
? BossEnemy.ActiveBoss
: Object.FindFirstObjectByType<BossEnemy>();
if (boss == null)
Debug.LogWarning("[Debug] 활성 보스를 찾지 못했습니다.");
return boss;
}
/// <summary>
/// 보스 HP를 퍼센트로 설정
/// </summary>
private static void SetBossHP(float percent)
{
BossEnemy boss = FindBoss();
if (boss == null) return;
float targetHP = boss.MaxHealth * percent;
float diff = boss.CurrentHealth - targetHP;
if (diff > 0f)
boss.TakeDamage(diff);
else if (diff < 0f)
boss.Heal(-diff);
Debug.Log($"[Debug] 보스 HP 설정 {percent * 100f:F0}% | HP={boss.CurrentHealth:F0}/{boss.MaxHealth:F0}");
}
/// <summary>
/// 페이즈 강제 전환
/// </summary>
private static void ForcePhase(int index)
{
BossEnemy boss = FindBoss();
if (boss == null) return;
index = Mathf.Clamp(index, 0, Mathf.Max(0, boss.TotalPhases - 1));
boss.ForcePhaseTransition(index);
Debug.Log($"[Debug] 보스 페이즈 강제 전환 | Phase={index}");
}
/// <summary>
/// 보호막 적용
/// </summary>
private static void ApplyShield(float amount)
{
BossEnemy boss = FindBoss();
if (boss == null) return;
boss.ApplyShield(amount, 30f);
Debug.Log($"[Debug] 보스 보호막 적용 | Amount={amount:F0} | Total={boss.Shield:F0}");
}
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: b710fae394e79d44fa88104e3412c04a

View File

@@ -31,6 +31,10 @@ namespace Colosseum.Skills.Effects
[Tooltip("스탯 계수 (1.0 = 100%)")]
[Min(0f)] [SerializeField] private float statScaling = 1f;
public float BaseDamage => baseDamage;
public DamageType DamageKind => damageType;
public float StatScaling => statScaling;
protected override void ApplyEffect(GameObject caster, GameObject target)
{
if (target == null) return;

View File

@@ -0,0 +1,617 @@
#if UNITY_EDITOR || DEVELOPMENT_BUILD
using System;
using UnityEngine;
using UnityEngine.UI;
using TMPro;
using Unity.Netcode;
using Colosseum.Enemy;
using Colosseum.Abnormalities;
namespace Colosseum.UI
{
/// <summary>
/// 런타임 디버그 패널 UI.
/// 호스트(서버)에서 보스 HP, 페이즈, 보호막 등을 조작할 수 있는 접이식 HUD 패널입니다.
/// </summary>
public class DebugPanelUI : MonoBehaviour
{
[Header("Abnormality Data (Runtime)")]
[Tooltip("기절 이상상태 데이터")]
[SerializeField] private AbnormalityData stunAbnormalityData;
[Tooltip("침묵 이상상태 데이터")]
[SerializeField] private AbnormalityData silenceAbnormalityData;
[Header("Settings")]
[Tooltip("패널 너비")]
[SerializeField] private float panelWidth = 280f;
[Tooltip("패널 최대 높이")]
[SerializeField] private float panelMaxHeight = 500f;
// 보스 캐시
private BossEnemy cachedBoss;
private bool suppressSliderCallback;
// UI 참조
private GameObject toggleButtonObject;
private GameObject panelRoot;
private bool isPanelOpen;
// 보스 HP UI
private TMP_Text hpInfoText;
private Slider hpSlider;
private Image hpSliderFillImage;
private TMP_InputField hpInputField;
// 보스 제어 UI
private TMP_InputField phaseInputField;
// 보호막 UI
private TMP_InputField shieldAmountField;
private void Awake()
{
BuildUI();
}
private void Start()
{
if (panelRoot != null)
panelRoot.SetActive(false);
}
private void Update()
{
RefreshBoss();
if (cachedBoss == null)
return;
UpdateHPDisplay();
}
/// <summary>
/// 보스 참조 새로고침
/// </summary>
private void RefreshBoss()
{
if (cachedBoss != null && cachedBoss.gameObject.activeInHierarchy)
return;
cachedBoss = BossEnemy.ActiveBoss;
}
/// <summary>
/// 보스 없음 여부 반환
/// </summary>
private bool NoBoss => cachedBoss == null;
/// <summary>
/// 서버 권한 확인
/// </summary>
private bool IsHost => NetworkManager.Singleton != null && NetworkManager.Singleton.IsServer;
// ──────────────────────────────────────────────────
// UI 빌드
// ──────────────────────────────────────────────────
/// <summary>
/// 전체 UI 트리 생성
/// </summary>
private void BuildUI()
{
BuildToggleButton();
BuildPanel();
}
/// <summary>
/// 토글 버튼 생성 (우측 하단 고정)
/// </summary>
private void BuildToggleButton()
{
toggleButtonObject = new GameObject("DebugToggle",
typeof(RectTransform), typeof(Image), typeof(Button));
toggleButtonObject.transform.SetParent(transform, false);
RectTransform r = toggleButtonObject.GetComponent<RectTransform>();
r.anchorMin = new Vector2(1f, 0f);
r.anchorMax = new Vector2(1f, 0f);
r.pivot = new Vector2(1f, 0f);
r.anchoredPosition = new Vector2(-10f, 10f);
r.sizeDelta = new Vector2(80f, 36f);
toggleButtonObject.GetComponent<Image>().color = new Color(0.15f, 0.15f, 0.15f, 0.92f);
TMP_Text label = MakeTextChild("Label", toggleButtonObject.transform);
label.text = "Debug";
label.fontSize = 16f;
label.fontStyle = FontStyles.Bold;
label.alignment = TextAlignmentOptions.Center;
label.color = new Color(0.8f, 0.8f, 0.8f);
toggleButtonObject.GetComponent<Button>().onClick.AddListener(TogglePanel);
}
/// <summary>
/// 메인 패널 생성
/// </summary>
private void BuildPanel()
{
panelRoot = new GameObject("Panel",
typeof(RectTransform), typeof(Image), typeof(Mask), typeof(ScrollRect));
panelRoot.transform.SetParent(transform, false);
RectTransform pr = panelRoot.GetComponent<RectTransform>();
pr.anchorMin = new Vector2(1f, 0f);
pr.anchorMax = new Vector2(1f, 0f);
pr.pivot = new Vector2(1f, 0f);
pr.anchoredPosition = new Vector2(-10f, 56f);
pr.sizeDelta = new Vector2(panelWidth, panelMaxHeight);
panelRoot.GetComponent<Image>().color = new Color(0.08f, 0.08f, 0.08f, 0.92f);
panelRoot.GetComponent<Mask>().showMaskGraphic = true;
ScrollRect scroll = panelRoot.GetComponent<ScrollRect>();
scroll.horizontal = false;
scroll.movementType = ScrollRect.MovementType.Clamped;
scroll.scrollSensitivity = 30f;
// 뷰포트
GameObject viewport = new GameObject("Viewport",
typeof(RectTransform), typeof(Image));
viewport.transform.SetParent(panelRoot.transform, false);
RectTransform vr = viewport.GetComponent<RectTransform>();
vr.anchorMin = Vector2.zero;
vr.anchorMax = Vector2.one;
vr.sizeDelta = Vector2.zero;
viewport.GetComponent<Image>().color = Color.clear;
scroll.viewport = vr;
// 콘텐츠
GameObject content = new GameObject("Content",
typeof(RectTransform), typeof(VerticalLayoutGroup), typeof(ContentSizeFitter));
content.transform.SetParent(viewport.transform, false);
RectTransform cr = content.GetComponent<RectTransform>();
cr.anchorMin = new Vector2(0f, 1f);
cr.anchorMax = new Vector2(1f, 1f);
cr.pivot = new Vector2(0.5f, 1f);
cr.sizeDelta = Vector2.zero;
scroll.content = cr;
VerticalLayoutGroup vlg = content.GetComponent<VerticalLayoutGroup>();
vlg.childAlignment = TextAnchor.UpperLeft;
vlg.childControlWidth = true;
vlg.childControlHeight = true;
vlg.childForceExpandWidth = true;
vlg.childForceExpandHeight = false;
vlg.padding = new RectOffset(8, 8, 8, 8);
vlg.spacing = 6f;
content.GetComponent<ContentSizeFitter>().verticalFit = ContentSizeFitter.FitMode.PreferredSize;
// 섹션들
BuildBossHPSection(content.transform);
BuildBossControlSection(content.transform);
BuildShieldSection(content.transform);
BuildAbnormalitySection(content.transform);
}
/// <summary>
/// 보스 HP 섹션
/// </summary>
private void BuildBossHPSection(Transform parent)
{
MakeSectionHeader("보스 HP", parent);
hpInfoText = MakeLabel("HP: ---", parent, 15f);
hpSlider = MakeSlider("HPSlider", parent, out hpSliderFillImage);
hpSlider.onValueChanged.AddListener(OnHPSliderChanged);
// 직접 HP 입력
GameObject hpRow = MakeRow(parent);
hpInputField = MakeInputField("HPInput", hpRow.transform, "0", 100f);
MakeButton("적용", hpRow.transform, ApplyDirectHP, 60f);
// 프리셋
GameObject presetRow = MakeRow(parent);
MakeButton("10%", presetRow.transform, () => SetBossHP(0.1f), 55f);
MakeButton("30%", presetRow.transform, () => SetBossHP(0.3f), 55f);
MakeButton("50%", presetRow.transform, () => SetBossHP(0.5f), 55f);
MakeButton("100%", presetRow.transform, () => SetBossHP(1f), 55f);
}
/// <summary>
/// 보스 제어 섹션
/// </summary>
private void BuildBossControlSection(Transform parent)
{
MakeSectionHeader("보스 제어", parent);
GameObject phaseRow = MakeRow(parent);
MakeLabel("페이즈:", phaseRow.transform, 14f, 50f);
phaseInputField = MakeInputField("PhaseInput", phaseRow.transform, "0", 40f);
MakeButton("전환", phaseRow.transform, OnForcePhase, 60f);
MakeButton("현재 페이즈 재시작", parent, OnRestartPhase);
MakeButton("리스폰", parent, OnRespawn);
MakeButton("HP 풀회복", parent, OnFullHeal);
}
/// <summary>
/// 보호막 섹션
/// </summary>
private void BuildShieldSection(Transform parent)
{
MakeSectionHeader("보호막", parent);
GameObject row = MakeRow(parent);
MakeLabel("량:", row.transform, 14f, 30f);
shieldAmountField = MakeInputField("ShieldAmt", row.transform, "1000", 60f);
MakeButton("적용", row.transform, OnApplyShield, 60f);
}
/// <summary>
/// 이상상태 섹션
/// </summary>
private void BuildAbnormalitySection(Transform parent)
{
MakeSectionHeader("이상상태", parent);
MakeButton("기절", parent, OnApplyStun);
MakeButton("침묵", parent, OnApplySilence);
MakeButton("위협 초기화", parent, OnClearThreat);
}
// ──────────────────────────────────────────────────
// UI 업데이트
// ──────────────────────────────────────────────────
/// <summary>
/// HP 디스플레이 갱신
/// </summary>
private void UpdateHPDisplay()
{
if (hpInfoText == null || hpSlider == null)
return;
float current = cachedBoss.CurrentHealth;
float max = cachedBoss.MaxHealth;
float pct = max > 0f ? current / max : 0f;
hpInfoText.text = $"HP: {Mathf.CeilToInt(current)} / {Mathf.CeilToInt(max)} ({pct * 100f:F1}%)";
suppressSliderCallback = true;
hpSlider.value = pct;
suppressSliderCallback = false;
if (hpSliderFillImage != null)
hpSliderFillImage.color = GetHealthColor(pct);
}
/// <summary>
/// HP 퍼센트에 따른 색상
/// </summary>
private static Color GetHealthColor(float pct)
{
if (pct > 0.6f) return new Color(0.2f, 0.8f, 0.2f);
if (pct > 0.3f) return new Color(0.9f, 0.7f, 0.1f);
return new Color(0.9f, 0.2f, 0.2f);
}
// ──────────────────────────────────────────────────
// 보스 조작
// ──────────────────────────────────────────────────
/// <summary>
/// 보스 HP를 퍼센트로 설정
/// </summary>
private void SetBossHP(float percent)
{
if (!IsHost || NoBoss) return;
float targetHP = cachedBoss.MaxHealth * percent;
float diff = cachedBoss.CurrentHealth - targetHP;
if (diff > 0f)
cachedBoss.TakeDamage(diff);
else if (diff < 0f)
cachedBoss.Heal(-diff);
}
private void OnHPSliderChanged(float value)
{
if (suppressSliderCallback) return;
SetBossHP(value);
}
private void ApplyDirectHP()
{
if (NoBoss || !IsHost) return;
if (float.TryParse(hpInputField.text, out float hp))
{
float clamped = Mathf.Clamp(hp, 0f, cachedBoss.MaxHealth);
float pct = cachedBoss.MaxHealth > 0f ? clamped / cachedBoss.MaxHealth : 0f;
SetBossHP(pct);
}
}
private void OnForcePhase()
{
if (NoBoss || !IsHost) return;
if (int.TryParse(phaseInputField.text, out int phase))
{
phase = Mathf.Clamp(phase, 0, Mathf.Max(0, cachedBoss.TotalPhases - 1));
cachedBoss.ForcePhaseTransition(phase);
}
}
private void OnRestartPhase()
{
if (NoBoss || !IsHost) return;
cachedBoss.RestartCurrentPhase();
}
private void OnRespawn()
{
if (NoBoss || !IsHost) return;
cachedBoss.Respawn();
}
private void OnFullHeal()
{
if (NoBoss || !IsHost) return;
cachedBoss.Heal(cachedBoss.MaxHealth);
}
private void OnApplyShield()
{
if (NoBoss || !IsHost) return;
if (float.TryParse(shieldAmountField.text, out float amt) && amt > 0f)
cachedBoss.ApplyShield(amt, 30f);
}
private void OnApplyStun()
{
if (NoBoss || !IsHost || stunAbnormalityData == null) return;
AbnormalityManager am = cachedBoss.GetComponent<AbnormalityManager>();
if (am != null) am.ApplyAbnormality(stunAbnormalityData, cachedBoss.gameObject);
}
private void OnApplySilence()
{
if (NoBoss || !IsHost || silenceAbnormalityData == null) return;
AbnormalityManager am = cachedBoss.GetComponent<AbnormalityManager>();
if (am != null) am.ApplyAbnormality(silenceAbnormalityData, cachedBoss.gameObject);
}
private void OnClearThreat()
{
if (NoBoss || !IsHost) return;
cachedBoss.ClearAllThreat();
}
// ──────────────────────────────────────────────────
// 토글
// ──────────────────────────────────────────────────
private void TogglePanel()
{
isPanelOpen = !isPanelOpen;
if (panelRoot != null)
panelRoot.SetActive(isPanelOpen);
}
// ──────────────────────────────────────────────────
// UI 유틸리티
// ──────────────────────────────────────────────────
private static TMP_FontAsset DefaultFont => TMP_Settings.defaultFontAsset;
private static void MakeSectionHeader(string text, Transform parent)
{
TMP_Text h = MakeLabel(text, parent, 16f);
h.fontStyle = FontStyles.Bold;
h.color = new Color(0.9f, 0.9f, 0.9f);
}
private static TMP_Text MakeLabel(string text, Transform parent, float fontSize, float width = 0f)
{
GameObject go = new GameObject("Label", typeof(RectTransform), typeof(TextMeshProUGUI));
go.transform.SetParent(parent, false);
if (width > 0f)
{
LayoutElement le = go.AddComponent<LayoutElement>();
le.preferredWidth = width;
le.minWidth = width;
}
TMP_Text t = go.GetComponent<TextMeshProUGUI>();
t.text = text;
t.fontSize = fontSize;
t.color = new Color(0.75f, 0.75f, 0.75f);
t.alignment = TextAlignmentOptions.Left;
t.textWrappingMode = TextWrappingModes.NoWrap;
if (DefaultFont != null) t.font = DefaultFont;
return t;
}
private static Button MakeButton(string text, Transform parent, Action onClick, float width = 0f)
{
GameObject go = new GameObject("Btn_" + text,
typeof(RectTransform), typeof(Image), typeof(Button));
go.transform.SetParent(parent, false);
LayoutElement le = go.AddComponent<LayoutElement>();
le.preferredHeight = 28f;
if (width > 0f) { le.preferredWidth = width; le.minWidth = width; }
go.GetComponent<Image>().color = new Color(0.18f, 0.18f, 0.18f, 1f);
go.GetComponent<Button>().onClick.AddListener(new UnityEngine.Events.UnityAction(onClick));
// 버튼 내 텍스트
TMP_Text lbl = new GameObject("Text", typeof(RectTransform), typeof(TextMeshProUGUI))
.GetComponent<TextMeshProUGUI>();
lbl.transform.SetParent(go.transform, false);
RectTransform lr = lbl.GetComponent<RectTransform>();
lr.anchorMin = Vector2.zero;
lr.anchorMax = Vector2.one;
lr.sizeDelta = Vector2.zero;
lbl.text = text;
lbl.fontSize = 14f;
lbl.alignment = TextAlignmentOptions.Center;
lbl.color = new Color(0.85f, 0.85f, 0.85f);
lbl.textWrappingMode = TextWrappingModes.NoWrap;
if (DefaultFont != null) lbl.font = DefaultFont;
return go.GetComponent<Button>();
}
private static GameObject MakeRow(Transform parent)
{
GameObject go = new GameObject("Row", typeof(RectTransform), typeof(HorizontalLayoutGroup));
go.transform.SetParent(parent, false);
HorizontalLayoutGroup hlg = go.GetComponent<HorizontalLayoutGroup>();
hlg.childAlignment = TextAnchor.MiddleLeft;
hlg.childControlWidth = true;
hlg.childControlHeight = true;
hlg.childForceExpandWidth = false;
hlg.childForceExpandHeight = false;
hlg.spacing = 4f;
go.AddComponent<LayoutElement>().preferredHeight = 28f;
return go;
}
private static Slider MakeSlider(string name, Transform parent, out Image fillImage)
{
GameObject go = new GameObject(name, typeof(RectTransform), typeof(Slider));
go.transform.SetParent(parent, false);
go.AddComponent<LayoutElement>().preferredHeight = 24f;
Slider slider = go.GetComponent<Slider>();
slider.minValue = 0f;
slider.maxValue = 1f;
slider.value = 1f;
// 배경
GameObject bg = new GameObject("BG", typeof(RectTransform), typeof(Image));
bg.transform.SetParent(go.transform, false);
RectTransform bgr = bg.GetComponent<RectTransform>();
bgr.anchorMin = new Vector2(0f, 0.25f);
bgr.anchorMax = new Vector2(1f, 0.75f);
bgr.sizeDelta = Vector2.zero;
bg.GetComponent<Image>().color = new Color(0.2f, 0.2f, 0.2f, 1f);
slider.targetGraphic = bg.GetComponent<Image>();
// Fill Area + Fill
GameObject fa = new GameObject("Fill Area", typeof(RectTransform));
fa.transform.SetParent(go.transform, false);
RectTransform far = fa.GetComponent<RectTransform>();
far.anchorMin = Vector2.zero;
far.anchorMax = Vector2.one;
far.sizeDelta = Vector2.zero;
GameObject fill = new GameObject("Fill", typeof(RectTransform), typeof(Image));
fill.transform.SetParent(fa.transform, false);
RectTransform fr = fill.GetComponent<RectTransform>();
fr.anchorMin = Vector2.zero;
fr.anchorMax = Vector2.one;
fr.sizeDelta = Vector2.zero;
fillImage = fill.GetComponent<Image>();
fillImage.color = new Color(0.2f, 0.8f, 0.2f);
slider.fillRect = fr;
// Handle Slide Area + Handle
GameObject hsa = new GameObject("Handle Slide Area", typeof(RectTransform));
hsa.transform.SetParent(go.transform, false);
RectTransform hsar = hsa.GetComponent<RectTransform>();
hsar.anchorMin = Vector2.zero;
hsar.anchorMax = Vector2.one;
hsar.offsetMin = new Vector2(10f, 0f);
hsar.offsetMax = new Vector2(-10f, 0f);
GameObject handle = new GameObject("Handle", typeof(RectTransform), typeof(Image));
handle.transform.SetParent(hsa.transform, false);
handle.GetComponent<RectTransform>().sizeDelta = new Vector2(16f, 0f);
handle.GetComponent<Image>().color = Color.white;
slider.handleRect = handle.GetComponent<RectTransform>();
return slider;
}
private static TMP_InputField MakeInputField(string name, Transform parent,
string placeholder, float width)
{
GameObject go = new GameObject(name, typeof(RectTransform), typeof(Image), typeof(TMP_InputField));
go.transform.SetParent(parent, false);
LayoutElement le = go.AddComponent<LayoutElement>();
le.preferredWidth = width;
le.minWidth = width;
le.preferredHeight = 28f;
Image bg = go.GetComponent<Image>();
bg.color = new Color(0.12f, 0.12f, 0.12f, 1f);
TMP_InputField input = go.GetComponent<TMP_InputField>();
input.targetGraphic = bg;
// Text
TMP_Text txt = new GameObject("Text", typeof(RectTransform), typeof(TextMeshProUGUI))
.GetComponent<TextMeshProUGUI>();
txt.transform.SetParent(go.transform, false);
txt.GetComponent<RectTransform>().anchorMin = Vector2.zero;
txt.GetComponent<RectTransform>().anchorMax = Vector2.one;
txt.GetComponent<RectTransform>().offsetMin = new Vector2(4f, 2f);
txt.GetComponent<RectTransform>().offsetMax = new Vector2(-4f, -2f);
txt.fontSize = 14f;
txt.alignment = TextAlignmentOptions.MidlineLeft;
txt.color = Color.white;
txt.textWrappingMode = TextWrappingModes.NoWrap;
if (DefaultFont != null) txt.font = DefaultFont;
input.textComponent = txt;
// Placeholder
TMP_Text ph = new GameObject("Placeholder", typeof(RectTransform), typeof(TextMeshProUGUI))
.GetComponent<TextMeshProUGUI>();
ph.transform.SetParent(go.transform, false);
ph.GetComponent<RectTransform>().anchorMin = Vector2.zero;
ph.GetComponent<RectTransform>().anchorMax = Vector2.one;
ph.GetComponent<RectTransform>().offsetMin = new Vector2(4f, 2f);
ph.GetComponent<RectTransform>().offsetMax = new Vector2(-4f, -2f);
ph.fontSize = 14f;
ph.alignment = TextAlignmentOptions.MidlineLeft;
ph.color = new Color(0.5f, 0.5f, 0.5f);
ph.text = placeholder;
ph.textWrappingMode = TextWrappingModes.NoWrap;
if (DefaultFont != null) ph.font = DefaultFont;
input.placeholder = ph;
return input;
}
private static TMP_Text MakeTextChild(string name, Transform parent)
{
TMP_Text t = new GameObject(name, typeof(RectTransform), typeof(TextMeshProUGUI))
.GetComponent<TextMeshProUGUI>();
t.transform.SetParent(parent, false);
t.GetComponent<RectTransform>().anchorMin = Vector2.zero;
t.GetComponent<RectTransform>().anchorMax = Vector2.one;
t.GetComponent<RectTransform>().sizeDelta = Vector2.zero;
if (DefaultFont != null) t.font = DefaultFont;
return t;
}
}
}
#endif

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 7b7611f77d92f8e41bfe5dfb5ac1768f