feat: 런타임 보스 디버그 패널 및 MCP 커맨드 추가

- HUD 우측 하단 접이식 디버그 패널 (DebugPanelUI): 보스 HP 슬라이더/직접입력/프리셋, 페이즈 전환, 리스폰, 보호막, 이상상태 적용
- MCP execute_menu_item으로 호출 가능한 MenuItem 커맨드 (DebugBossMenuItems): HP/페이즈/보호막 제어, 상태 조회, 임의 값 설정 지원
- BossEnemyEditor 인스펙터 HP 조작 확장: 퍼센트 슬라이더 및 직접 HP 입력 추가
- Test 씬 UI Canvas 하위 DebugPanel GameObject 배치
- UNITY_EDITOR || DEVELOPMENT_BUILD 가드로 릴리즈 빌드 미포함
This commit is contained in:
2026-03-28 15:09:56 +09:00
parent 343ef1b072
commit 2bc5241ff1
6 changed files with 962 additions and 0 deletions

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