feat: 젬 테스트 경로 및 보스 기절 디버그 추가

- 다중 젬 슬롯용 타입을 별도 스크립트로 분리하고 테스트 젬/로드아웃 자산 생성 경로를 정리

- 젬 테스트 전용 공격 스킬과 분리된 애니메이션 자산을 추가해 베이스 스킬 검증 경로를 마련

- PlayerSkillDebugMenu와 MPP 디버그 메뉴를 보강해 젬 프리셋 적용, 원격 테스트, 보스 기절 디버그 메뉴를 추가

- BossCombatBehaviorContext와 공통 BT 액션이 기절 상태를 존중하도록 수정해 보스 추적과 패턴 실행을 중단

- Unity 리프레시와 외부 빌드 통과를 확인하고 드로그전 및 MPP 기준 젬 프리셋 적용 흐름을 검증
This commit is contained in:
2026-03-25 18:38:12 +09:00
parent 35a5b272cb
commit 24b284ad7e
39 changed files with 4443 additions and 463 deletions

View File

@@ -0,0 +1,69 @@
using System.Collections.Generic;
using UnityEngine;
namespace Colosseum.Skills
{
/// <summary>
/// 플레이어가 슬롯별로 사용할 스킬/젬 조합 프리셋입니다.
/// </summary>
[CreateAssetMenu(fileName = "NewPlayerLoadoutPreset", menuName = "Colosseum/Player Loadout Preset")]
public class PlayerLoadoutPreset : ScriptableObject
{
private const int DefaultSlotCount = 7;
[Header("기본 정보")]
[SerializeField] private string presetName;
[TextArea(2, 4)]
[SerializeField] private string description;
[Header("슬롯 구성")]
[SerializeField] private SkillLoadoutEntry[] slots = new SkillLoadoutEntry[DefaultSlotCount];
public string PresetName => presetName;
public string Description => description;
public IReadOnlyList<SkillLoadoutEntry> Slots => slots;
private void OnValidate()
{
EnsureSlotCapacity();
}
public void EnsureSlotCapacity(int slotCount = DefaultSlotCount)
{
slotCount = Mathf.Max(0, slotCount);
if (slots != null && slots.Length == slotCount)
{
EnsureGemSlots();
return;
}
SkillLoadoutEntry[] resized = new SkillLoadoutEntry[slotCount];
if (slots != null)
{
int copyCount = Mathf.Min(slots.Length, resized.Length);
for (int i = 0; i < copyCount; i++)
{
resized[i] = slots[i];
}
}
slots = resized;
EnsureGemSlots();
}
private void EnsureGemSlots()
{
if (slots == null)
return;
for (int i = 0; i < slots.Length; i++)
{
if (slots[i] == null)
slots[i] = new SkillLoadoutEntry();
slots[i].EnsureGemSlotCapacity();
}
}
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 26d5895a89de4f24aade1ea4b5f7644e

View File

@@ -53,6 +53,9 @@ namespace Colosseum.Skills
// 현재 실행 중인 스킬
private SkillData currentSkill;
private SkillLoadoutEntry currentLoadoutEntry;
private readonly List<SkillEffect> currentCastStartEffects = new();
private readonly Dictionary<int, List<SkillEffect>> currentTriggeredEffects = new();
private bool skillEndRequested; // OnSkillEnd 이벤트 호출 여부
private bool waitingForEndAnimation; // EndAnimation 종료 대기 중
@@ -66,6 +69,7 @@ namespace Colosseum.Skills
public bool UsesRootMotion => currentSkill != null && currentSkill.UseRootMotion;
public bool IgnoreRootMotionY => currentSkill != null && currentSkill.IgnoreRootMotionY;
public SkillData CurrentSkill => currentSkill;
public SkillLoadoutEntry CurrentLoadoutEntry => currentLoadoutEntry;
public Animator Animator => animator;
public SkillCancelReason LastCancelReason => lastCancelReason;
public string LastCancelledSkillName => lastCancelledSkillName;
@@ -131,6 +135,15 @@ namespace Colosseum.Skills
/// </summary>
public bool ExecuteSkill(SkillData skill)
{
return ExecuteSkill(SkillLoadoutEntry.CreateTemporary(skill));
}
/// <summary>
/// 슬롯 엔트리 기준으로 스킬 시전
/// </summary>
public bool ExecuteSkill(SkillLoadoutEntry loadoutEntry)
{
SkillData skill = loadoutEntry != null ? loadoutEntry.BaseSkill : null;
if (skill == null)
{
Debug.LogWarning("Skill is null!");
@@ -157,17 +170,19 @@ namespace Colosseum.Skills
return false;
}
currentLoadoutEntry = loadoutEntry != null ? loadoutEntry.CreateCopy() : SkillLoadoutEntry.CreateTemporary(skill);
currentSkill = skill;
skillEndRequested = false;
waitingForEndAnimation = false;
lastCancelReason = SkillCancelReason.None;
BuildResolvedEffects(currentLoadoutEntry);
if (debugMode) Debug.Log($"[Skill] Cast: {skill.SkillName}");
// 쿨타임 시작
StartCooldown(skill);
StartCooldown(skill, currentLoadoutEntry.GetResolvedCooldown());
TriggerCastStartEffects(skill);
TriggerCastStartEffects();
// 스킬 애니메이션 재생
if (skill.SkillClip != null && animator != null)
@@ -176,7 +191,7 @@ namespace Colosseum.Skills
PlaySkillClip(skill.SkillClip);
}
TriggerImmediateSelfEffectsIfNeeded(skill);
TriggerImmediateSelfEffectsIfNeeded();
return true;
}
@@ -185,17 +200,17 @@ namespace Colosseum.Skills
/// 시전 시작 즉시 발동하는 효과를 실행합니다.
/// 서버 권한으로만 처리해 실제 게임플레이 효과가 한 번만 적용되게 합니다.
/// </summary>
private void TriggerCastStartEffects(SkillData skill)
private void TriggerCastStartEffects()
{
if (skill == null || skill.CastStartEffects == null || skill.CastStartEffects.Count == 0)
if (currentSkill == null || currentCastStartEffects.Count == 0)
return;
if (NetworkManager.Singleton != null && !NetworkManager.Singleton.IsServer)
return;
for (int i = 0; i < skill.CastStartEffects.Count; i++)
for (int i = 0; i < currentCastStartEffects.Count; i++)
{
SkillEffect effect = skill.CastStartEffects[i];
SkillEffect effect = currentCastStartEffects[i];
if (effect == null)
continue;
@@ -208,20 +223,23 @@ namespace Colosseum.Skills
/// 애니메이션 이벤트가 없는 자기 자신 대상 효과는 시전 즉시 발동합니다.
/// 버프/무적 같은 자기 강화 스킬이 이벤트 누락으로 동작하지 않는 상황을 막기 위한 보정입니다.
/// </summary>
private void TriggerImmediateSelfEffectsIfNeeded(SkillData skill)
private void TriggerImmediateSelfEffectsIfNeeded()
{
if (skill == null || skill.Effects == null || skill.Effects.Count == 0)
if (currentSkill == null || currentTriggeredEffects.Count == 0)
return;
if (NetworkManager.Singleton != null && !NetworkManager.Singleton.IsServer)
return;
if (skill.SkillClip != null && skill.SkillClip.events != null && skill.SkillClip.events.Length > 0)
if (currentSkill.SkillClip != null && currentSkill.SkillClip.events != null && currentSkill.SkillClip.events.Length > 0)
return;
for (int i = 0; i < skill.Effects.Count; i++)
if (!currentTriggeredEffects.TryGetValue(0, out List<SkillEffect> effectsAtZero))
return;
for (int i = 0; i < effectsAtZero.Count; i++)
{
SkillEffect effect = skill.Effects[i];
SkillEffect effect = effectsAtZero[i];
if (effect == null || effect.TargetType != TargetType.Self)
continue;
@@ -230,6 +248,21 @@ namespace Colosseum.Skills
}
}
/// <summary>
/// 현재 슬롯 엔트리 기준으로 시전 시작/트리거 효과를 합성합니다.
/// </summary>
private void BuildResolvedEffects(SkillLoadoutEntry loadoutEntry)
{
currentCastStartEffects.Clear();
currentTriggeredEffects.Clear();
if (loadoutEntry == null)
return;
loadoutEntry.CollectCastStartEffects(currentCastStartEffects);
loadoutEntry.CollectTriggeredEffects(currentTriggeredEffects);
}
/// <summary>
/// 스킬 클립으로 Override Controller 생성 후 재생
/// </summary>
@@ -354,23 +387,28 @@ namespace Colosseum.Skills
return;
}
var effects = currentSkill.Effects;
if (index < 0 || index >= effects.Count)
if (!currentTriggeredEffects.TryGetValue(index, out List<SkillEffect> effects) || effects == null || effects.Count == 0)
{
if (debugMode) Debug.LogWarning($"[Effect] Invalid index: {index}");
return;
}
var effect = effects[index];
if (debugMode) Debug.Log($"[Effect] {effect.name} (index {index})");
// 공격 범위 시각화
if (showAreaDebug)
for (int i = 0; i < effects.Count; i++)
{
effect.DrawDebugRange(gameObject, debugDrawDuration);
}
SkillEffect effect = effects[i];
if (effect == null)
continue;
effect.ExecuteOnCast(gameObject);
if (debugMode) Debug.Log($"[Effect] {effect.name} (index {index})");
// 공격 범위 시각화
if (showAreaDebug)
{
effect.DrawDebugRange(gameObject, debugDrawDuration);
}
effect.ExecuteOnCast(gameObject);
}
}
/// <summary>
@@ -408,6 +446,9 @@ namespace Colosseum.Skills
RestoreBaseController();
currentSkill = null;
currentLoadoutEntry = null;
currentCastStartEffects.Clear();
currentTriggeredEffects.Clear();
skillEndRequested = false;
waitingForEndAnimation = false;
return true;
@@ -430,9 +471,9 @@ namespace Colosseum.Skills
return Mathf.Max(0f, remaining);
}
private void StartCooldown(SkillData skill)
private void StartCooldown(SkillData skill, float cooldownDuration)
{
cooldownTracker[skill] = Time.time + skill.Cooldown;
cooldownTracker[skill] = Time.time + cooldownDuration;
}
public void ResetCooldown(SkillData skill)

View File

@@ -1,6 +1,8 @@
using UnityEngine;
using System;
using System.Collections.Generic;
using UnityEngine;
namespace Colosseum.Skills
{
/// <summary>
@@ -43,6 +45,10 @@ namespace Colosseum.Skills
[Min(0f)] [SerializeField] private float cooldown = 1f;
[Min(0f)] [SerializeField] private float manaCost = 0f;
[Header("젬 슬롯")]
[Tooltip("이 스킬에 장착 가능한 젬 슬롯 수")]
[Min(0)] [SerializeField] private int maxGemSlotCount = 2;
[Header("효과 목록")]
[Tooltip("시전 시작 즉시 발동하는 효과 목록. 시전 보호 버프 등에 사용됩니다.")]
[SerializeField] private List<SkillEffect> castStartEffects = new List<SkillEffect>();
@@ -60,6 +66,7 @@ namespace Colosseum.Skills
public float AnimationSpeed => animationSpeed;
public float Cooldown => cooldown;
public float ManaCost => manaCost;
public int MaxGemSlotCount => maxGemSlotCount;
public bool UseRootMotion => useRootMotion;
public bool IgnoreRootMotionY => ignoreRootMotionY;
public bool JumpToTarget => jumpToTarget;

View File

@@ -0,0 +1,55 @@
using System;
using System.Collections.Generic;
using UnityEngine;
namespace Colosseum.Skills
{
/// <summary>
/// 젬 효과가 발동될 애니메이션 이벤트 인덱스와 효과 목록입니다.
/// </summary>
[Serializable]
public class SkillGemTriggeredEffectEntry
{
[Tooltip("OnEffect(index)와 매칭되는 애니메이션 이벤트 인덱스")]
[Min(0)] [SerializeField] private int triggerIndex = 0;
[Tooltip("해당 인덱스에서 함께 실행할 추가 효과")]
[SerializeField] private List<SkillEffect> effects = new();
public int TriggerIndex => triggerIndex;
public IReadOnlyList<SkillEffect> Effects => effects;
}
/// <summary>
/// 스킬의 기반 효과 위에 추가 동작을 덧붙이는 젬 데이터입니다.
/// </summary>
[CreateAssetMenu(fileName = "NewSkillGem", menuName = "Colosseum/Skill Gem")]
public class SkillGemData : ScriptableObject
{
[Header("기본 정보")]
[SerializeField] private string gemName;
[TextArea(2, 4)]
[SerializeField] private string description;
[SerializeField] private Sprite icon;
[Header("기본 수치 보정")]
[Tooltip("장착 시 마나 비용 배율")]
[Min(0f)] [SerializeField] private float manaCostMultiplier = 1f;
[Tooltip("장착 시 쿨타임 배율")]
[Min(0f)] [SerializeField] private float cooldownMultiplier = 1f;
[Header("추가 효과")]
[Tooltip("시전 시작 시 즉시 발동하는 추가 효과")]
[SerializeField] private List<SkillEffect> castStartEffects = new();
[Tooltip("애니메이션 이벤트 인덱스별로 발동하는 추가 효과")]
[SerializeField] private List<SkillGemTriggeredEffectEntry> triggeredEffects = new();
public string GemName => gemName;
public string Description => description;
public Sprite Icon => icon;
public float ManaCostMultiplier => manaCostMultiplier;
public float CooldownMultiplier => cooldownMultiplier;
public IReadOnlyList<SkillEffect> CastStartEffects => castStartEffects;
public IReadOnlyList<SkillGemTriggeredEffectEntry> TriggeredEffects => triggeredEffects;
}
}

View File

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

View File

@@ -0,0 +1,226 @@
using System.Collections.Generic;
using UnityEngine;
namespace Colosseum.Skills
{
/// <summary>
/// 단일 슬롯에서 사용할 스킬과 장착된 젬 조합입니다.
/// </summary>
[System.Serializable]
public class SkillLoadoutEntry
{
private const int DefaultGemSlotCount = 2;
[Tooltip("이 슬롯의 기반 스킬")]
[SerializeField] private SkillData baseSkill;
[Tooltip("기반 스킬에 장착된 젬")]
[SerializeField] private SkillGemData[] socketedGems = new SkillGemData[DefaultGemSlotCount];
public SkillData BaseSkill => baseSkill;
public IReadOnlyList<SkillGemData> SocketedGems => socketedGems;
public static SkillLoadoutEntry CreateTemporary(SkillData skill)
{
SkillLoadoutEntry entry = new SkillLoadoutEntry();
entry.SetBaseSkill(skill);
entry.EnsureGemSlotCapacity();
return entry;
}
public SkillLoadoutEntry CreateCopy()
{
SkillLoadoutEntry copy = new SkillLoadoutEntry();
copy.baseSkill = baseSkill;
copy.socketedGems = new SkillGemData[socketedGems != null ? socketedGems.Length : DefaultGemSlotCount];
if (socketedGems != null)
{
for (int i = 0; i < socketedGems.Length; i++)
{
copy.socketedGems[i] = socketedGems[i];
}
}
return copy;
}
public void EnsureGemSlotCapacity(int slotCount = -1)
{
if (slotCount < 0)
{
slotCount = baseSkill != null ? baseSkill.MaxGemSlotCount : DefaultGemSlotCount;
}
slotCount = Mathf.Max(0, slotCount);
if (socketedGems != null && socketedGems.Length == slotCount)
return;
SkillGemData[] resized = new SkillGemData[slotCount];
if (socketedGems != null)
{
int copyCount = Mathf.Min(socketedGems.Length, resized.Length);
for (int i = 0; i < copyCount; i++)
{
resized[i] = socketedGems[i];
}
}
socketedGems = resized;
}
public void SetBaseSkill(SkillData skill)
{
baseSkill = skill;
EnsureGemSlotCapacity();
}
public void SetGem(int slotIndex, SkillGemData gem)
{
EnsureGemSlotCapacity();
if (slotIndex < 0 || slotIndex >= socketedGems.Length)
return;
socketedGems[slotIndex] = gem;
}
public SkillGemData GetGem(int slotIndex)
{
EnsureGemSlotCapacity();
if (slotIndex < 0 || slotIndex >= socketedGems.Length)
return null;
return socketedGems[slotIndex];
}
public float GetResolvedManaCost()
{
if (baseSkill == null)
return 0f;
float resolved = baseSkill.ManaCost;
if (socketedGems == null)
return resolved;
for (int i = 0; i < socketedGems.Length; i++)
{
SkillGemData gem = socketedGems[i];
if (gem == null)
continue;
resolved *= gem.ManaCostMultiplier;
}
return resolved;
}
public float GetResolvedCooldown()
{
if (baseSkill == null)
return 0f;
float resolved = baseSkill.Cooldown;
if (socketedGems == null)
return resolved;
for (int i = 0; i < socketedGems.Length; i++)
{
SkillGemData gem = socketedGems[i];
if (gem == null)
continue;
resolved *= gem.CooldownMultiplier;
}
return resolved;
}
public void CollectCastStartEffects(List<SkillEffect> destination)
{
if (destination == null)
return;
if (baseSkill != null && baseSkill.CastStartEffects != null)
{
for (int i = 0; i < baseSkill.CastStartEffects.Count; i++)
{
SkillEffect effect = baseSkill.CastStartEffects[i];
if (effect != null)
destination.Add(effect);
}
}
if (socketedGems == null)
return;
for (int i = 0; i < socketedGems.Length; i++)
{
SkillGemData gem = socketedGems[i];
if (gem == null || gem.CastStartEffects == null)
continue;
for (int j = 0; j < gem.CastStartEffects.Count; j++)
{
SkillEffect effect = gem.CastStartEffects[j];
if (effect != null)
destination.Add(effect);
}
}
}
public void CollectTriggeredEffects(Dictionary<int, List<SkillEffect>> destination)
{
if (destination == null)
return;
if (baseSkill != null && baseSkill.Effects != null)
{
for (int i = 0; i < baseSkill.Effects.Count; i++)
{
SkillEffect effect = baseSkill.Effects[i];
if (effect == null)
continue;
AddTriggeredEffect(destination, i, effect);
}
}
if (socketedGems == null)
return;
for (int i = 0; i < socketedGems.Length; i++)
{
SkillGemData gem = socketedGems[i];
if (gem == null || gem.TriggeredEffects == null)
continue;
for (int j = 0; j < gem.TriggeredEffects.Count; j++)
{
SkillGemTriggeredEffectEntry entry = gem.TriggeredEffects[j];
if (entry == null || entry.Effects == null)
continue;
for (int k = 0; k < entry.Effects.Count; k++)
{
SkillEffect effect = entry.Effects[k];
if (effect == null)
continue;
AddTriggeredEffect(destination, entry.TriggerIndex, effect);
}
}
}
}
private static void AddTriggeredEffect(Dictionary<int, List<SkillEffect>> destination, int triggerIndex, SkillEffect effect)
{
if (!destination.TryGetValue(triggerIndex, out List<SkillEffect> effectList))
{
effectList = new List<SkillEffect>();
destination.Add(triggerIndex, effectList);
}
effectList.Add(effect);
}
}
}

View File

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