feat: 젬 반복 시전 로직 및 테스트 프리셋 추가

- SkillGemData에 카테고리, 시전 속도 배율, 추가 반복 횟수 필드를 추가함
- SkillLoadoutEntry가 젬 합산 기준 최종 속도와 반복 횟수를 계산하도록 확장함
- SkillController가 반복 횟수만큼 스킬을 재시전하고 시작 효과와 OnEffect를 매 반복에 다시 적용하도록 수정함
- 연속 젬과 반복 젬 테스트 프리셋을 추가하고 디버그 메뉴에 적용 및 계산 로그 경로를 보강함
- 공격형 테스트 젬 자산과 추가 대미지 이펙트를 정리하고 무젬 35, 반복 젬 70 피해를 검증함
This commit is contained in:
2026-03-26 12:36:03 +09:00
parent dedfb60a4c
commit b4475ea77f
21 changed files with 726 additions and 91 deletions

View File

@@ -44,6 +44,7 @@ namespace Colosseum.Editor
private const string CrushGemPath = SkillGemFolderPath + "/Data_SkillGem_Player_파쇄.asset";
private const string ChallengerGemPath = SkillGemFolderPath + "/Data_SkillGem_Player_도전자.asset";
private const string GuardianGemPath = SkillGemFolderPath + "/Data_SkillGem_Player_수호.asset";
private const string RepeatGemPath = SkillGemFolderPath + "/Data_SkillGem_Player_연속.asset";
private const string EdgeGemPath = SkillGemFolderPath + "/Data_SkillGem_Player_예리함.asset";
private const string ImpactGemPath = SkillGemFolderPath + "/Data_SkillGem_Player_충격.asset";
private const string BreachGemPath = SkillGemFolderPath + "/Data_SkillGem_Player_관통.asset";
@@ -53,6 +54,7 @@ namespace Colosseum.Editor
private const string TankGemPresetPath = LoadoutPresetFolderPath + "/Data_LoadoutPreset_Player_탱커_젬테스트.asset";
private const string SupportGemPresetPath = LoadoutPresetFolderPath + "/Data_LoadoutPreset_Player_지원_젬테스트.asset";
private const string DpsGemPresetPath = LoadoutPresetFolderPath + "/Data_LoadoutPreset_Player_딜러_젬테스트.asset";
private const string RepeatGemPresetPath = LoadoutPresetFolderPath + "/Data_LoadoutPreset_Player_반복젬테스트.asset";
private const string TankDualGemPresetPath = LoadoutPresetFolderPath + "/Data_LoadoutPreset_Player_탱커_복합젬테스트.asset";
private const string SupportDualGemPresetPath = LoadoutPresetFolderPath + "/Data_LoadoutPreset_Player_지원_복합젬테스트.asset";
private const string DpsDualGemPresetPath = LoadoutPresetFolderPath + "/Data_LoadoutPreset_Player_딜러_복합젬테스트.asset";
@@ -522,6 +524,12 @@ namespace Colosseum.Editor
ApplyLoadoutPreset(DpsGemPresetPath, "딜러 젬");
}
[MenuItem("Tools/Colosseum/Debug/Apply Repeat Gem Loadout")]
private static void ApplyRepeatGemLoadout()
{
ApplyLoadoutPreset(RepeatGemPresetPath, "반복 젬");
}
[MenuItem("Tools/Colosseum/Debug/Apply Tank Dual Gem Loadout")]
private static void ApplyTankDualGemLoadout()
{
@@ -580,48 +588,77 @@ namespace Colosseum.Editor
CrushGemPath,
"파쇄",
"고위력 기술의 단일 피해를 강화하는 테스트용 젬",
SkillGemCategory.Attack,
1.15f,
1.1f,
1f,
0,
damageEffect);
CreateOrUpdateGemAsset(
ChallengerGemPath,
"도전자",
"고위력 기술에 위협 선점 기능을 얹는 테스트용 젬",
SkillGemCategory.Threat,
1f,
1f,
1f,
0,
tauntEffect);
CreateOrUpdateGemAsset(
GuardianGemPath,
"수호",
"고위력 기술에 보호막 보조를 얹는 테스트용 젬",
SkillGemCategory.Support,
1.05f,
1.1f,
1f,
0,
shieldEffect);
CreateOrUpdateGemAsset(
RepeatGemPath,
"연속",
"붙은 스킬을 한 번 더 반복 시전하는 테스트용 젬",
SkillGemCategory.Efficiency,
1.2f,
1.15f,
1.1f,
1,
null);
CreateOrUpdateGemAsset(
EdgeGemPath,
"예리함",
"고정 추가 피해를 부여하는 테스트용 공격 젬",
SkillGemCategory.Attack,
1f,
1f,
1f,
0,
edgeDamageEffect);
CreateOrUpdateGemAsset(
ImpactGemPath,
"충격",
"중간 고정 추가 피해를 부여하는 테스트용 공격 젬",
SkillGemCategory.Attack,
1f,
1f,
1f,
0,
impactDamageEffect);
CreateOrUpdateGemAsset(
BreachGemPath,
"관통",
"높은 고정 추가 피해를 부여하는 테스트용 공격 젬",
SkillGemCategory.Attack,
1f,
1f,
1f,
0,
breachDamageEffect);
AssetDatabase.SaveAssets();
@@ -653,6 +690,7 @@ namespace Colosseum.Editor
SkillGemData crushGem = AssetDatabase.LoadAssetAtPath<SkillGemData>(CrushGemPath);
SkillGemData challengerGem = AssetDatabase.LoadAssetAtPath<SkillGemData>(ChallengerGemPath);
SkillGemData guardianGem = AssetDatabase.LoadAssetAtPath<SkillGemData>(GuardianGemPath);
SkillGemData repeatGem = AssetDatabase.LoadAssetAtPath<SkillGemData>(RepeatGemPath);
SkillGemData edgeGem = AssetDatabase.LoadAssetAtPath<SkillGemData>(EdgeGemPath);
SkillGemData impactGem = AssetDatabase.LoadAssetAtPath<SkillGemData>(ImpactGemPath);
SkillGemData breachGem = AssetDatabase.LoadAssetAtPath<SkillGemData>(BreachGemPath);
@@ -698,6 +736,19 @@ namespace Colosseum.Editor
CreateEntry(gemTestSkill, crushGem),
CreateEntry(evadeSkill)));
CreateOrUpdatePresetAsset(
RepeatGemPresetPath,
"반복 젬 테스트",
"연속 젬을 사용하는 반복 시전 검증 프리셋",
CreateLoadoutEntries(
CreateEntry(slashSkill),
CreateEntry(pierceSkill),
CreateEntry(spinSkill),
CreateEntry(dashSkill),
CreateEntry(projectileSkill),
CreateEntry(gemTestSkill, repeatGem),
CreateEntry(evadeSkill)));
CreateOrUpdatePresetAsset(
TankDualGemPresetPath,
"탱커 복합 젬 테스트",
@@ -854,6 +905,8 @@ namespace Colosseum.Editor
float resolvedManaCost = loadoutEntry.GetResolvedManaCost();
float resolvedCooldown = loadoutEntry.GetResolvedCooldown();
float resolvedAnimationSpeed = loadoutEntry.GetResolvedAnimationSpeed();
int resolvedRepeatCount = loadoutEntry.GetResolvedRepeatCount();
StringBuilder builder = new StringBuilder();
builder.Append("[Debug] 6번 슬롯 계산값 | ");
@@ -863,8 +916,13 @@ namespace Colosseum.Editor
builder.Append(resolvedManaCost.ToString("0.###"));
builder.Append(" | Cooldown=");
builder.Append(resolvedCooldown.ToString("0.###"));
builder.Append(" | Speed=");
builder.Append(resolvedAnimationSpeed.ToString("0.###"));
builder.Append(" | Repeat=");
builder.Append(resolvedRepeatCount);
builder.Append(" | GemSlots=");
builder.Append(loadoutEntry.SocketedGems.Count);
AppendGemCategorySummary(builder, loadoutEntry);
Debug.Log(builder.ToString());
}
@@ -1098,7 +1156,16 @@ namespace Colosseum.Editor
EditorUtility.SetDirty(skill);
}
private static void CreateOrUpdateGemAsset(string assetPath, string gemName, string description, float manaCostMultiplier, float cooldownMultiplier, SkillEffect triggeredEffect)
private static void CreateOrUpdateGemAsset(
string assetPath,
string gemName,
string description,
SkillGemCategory category,
float manaCostMultiplier,
float cooldownMultiplier,
float castSpeedMultiplier,
int additionalRepeatCount,
SkillEffect triggeredEffect)
{
SkillGemData gem = AssetDatabase.LoadAssetAtPath<SkillGemData>(assetPath);
if (gem == null)
@@ -1115,8 +1182,11 @@ namespace Colosseum.Editor
SerializedObject serializedGem = new SerializedObject(gem);
serializedGem.FindProperty("gemName").stringValue = gemName;
serializedGem.FindProperty("description").stringValue = description;
serializedGem.FindProperty("category").enumValueIndex = (int)category;
serializedGem.FindProperty("manaCostMultiplier").floatValue = manaCostMultiplier;
serializedGem.FindProperty("cooldownMultiplier").floatValue = cooldownMultiplier;
serializedGem.FindProperty("castSpeedMultiplier").floatValue = castSpeedMultiplier;
serializedGem.FindProperty("additionalRepeatCount").intValue = additionalRepeatCount;
SerializedProperty castStartEffectsProperty = serializedGem.FindProperty("castStartEffects");
castStartEffectsProperty.arraySize = 0;
@@ -1253,6 +1323,33 @@ namespace Colosseum.Editor
builder.Append("]");
}
private static void AppendGemCategorySummary(StringBuilder builder, SkillLoadoutEntry loadoutEntry)
{
if (builder == null || loadoutEntry == null || loadoutEntry.SocketedGems == null)
return;
bool hasGem = false;
StringBuilder categoryBuilder = new StringBuilder();
for (int i = 0; i < loadoutEntry.SocketedGems.Count; i++)
{
SkillGemData gem = loadoutEntry.SocketedGems[i];
if (gem == null)
continue;
if (hasGem)
categoryBuilder.Append(", ");
categoryBuilder.Append(gem.Category);
hasGem = true;
}
if (!hasGem)
return;
builder.Append(" | Category=");
builder.Append(categoryBuilder);
}
private static void CastOwnedPlayerSkillAsServer(ulong ownerClientId, int slotIndex)
{
if (!EditorApplication.isPlaying)

View File

@@ -56,14 +56,15 @@ namespace Colosseum.Skills
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 종료 대기 중
private int currentRepeatCount = 1;
private int currentIterationIndex = 0;
// 쿨타임 추적
private Dictionary<SkillData, float> cooldownTracker = new Dictionary<SkillData, float>();
public bool IsExecutingSkill => currentSkill != null && !skillEndRequested;
public bool IsExecutingSkill => currentSkill != null;
public bool IsPlayingAnimation => currentSkill != null;
public bool IsInEndAnimation => waitingForEndAnimation;
public bool UsesRootMotion => currentSkill != null && currentSkill.UseRootMotion;
@@ -105,7 +106,7 @@ namespace Colosseum.Skills
{
if (debugMode) Debug.Log($"[Skill] EndAnimation complete: {currentSkill.SkillName}");
RestoreBaseController();
currentSkill = null;
ClearCurrentSkillState();
}
return;
}
@@ -113,6 +114,9 @@ namespace Colosseum.Skills
// 애니메이션 종료 시 처리 (OnSkillEnd 여부와 관계없이 애니메이션 끝까지 재생)
if (stateInfo.normalizedTime >= 1f)
{
if (TryStartNextIteration())
return;
if (currentSkill.EndClip != null)
{
// EndAnimation 재생 후 종료 대기
@@ -125,7 +129,7 @@ namespace Colosseum.Skills
// EndAnimation 없으면 바로 종료
if (debugMode) Debug.Log($"[Skill] Animation complete: {currentSkill.SkillName}");
RestoreBaseController();
currentSkill = null;
ClearCurrentSkillState();
}
}
}
@@ -172,26 +176,18 @@ namespace Colosseum.Skills
currentLoadoutEntry = loadoutEntry != null ? loadoutEntry.CreateCopy() : SkillLoadoutEntry.CreateTemporary(skill);
currentSkill = skill;
skillEndRequested = false;
waitingForEndAnimation = false;
lastCancelReason = SkillCancelReason.None;
BuildResolvedEffects(currentLoadoutEntry);
currentRepeatCount = currentLoadoutEntry.GetResolvedRepeatCount();
currentIterationIndex = 0;
if (debugMode) Debug.Log($"[Skill] Cast: {skill.SkillName}");
// 쿨타임 시작
StartCooldown(skill, currentLoadoutEntry.GetResolvedCooldown());
TriggerCastStartEffects();
// 스킬 애니메이션 재생
if (skill.SkillClip != null && animator != null)
{
animator.speed = skill.AnimationSpeed;
PlaySkillClip(skill.SkillClip);
}
TriggerImmediateSelfEffectsIfNeeded();
StartCurrentIteration();
return true;
}
@@ -263,6 +259,51 @@ namespace Colosseum.Skills
loadoutEntry.CollectTriggeredEffects(currentTriggeredEffects);
}
/// <summary>
/// 현재 스킬의 반복 차수 하나를 시작합니다.
/// </summary>
private void StartCurrentIteration()
{
if (currentSkill == null)
return;
currentIterationIndex++;
waitingForEndAnimation = false;
if (debugMode && currentRepeatCount > 1)
{
Debug.Log($"[Skill] Iteration {currentIterationIndex}/{currentRepeatCount}: {currentSkill.SkillName}");
}
TriggerCastStartEffects();
if (currentSkill.SkillClip != null && animator != null)
{
float resolvedAnimationSpeed = currentLoadoutEntry != null
? currentLoadoutEntry.GetResolvedAnimationSpeed()
: currentSkill.AnimationSpeed;
animator.speed = resolvedAnimationSpeed;
PlaySkillClip(currentSkill.SkillClip);
}
TriggerImmediateSelfEffectsIfNeeded();
}
/// <summary>
/// 반복 시전이 남아 있으면 다음 차수를 시작합니다.
/// </summary>
private bool TryStartNextIteration()
{
if (currentSkill == null)
return false;
if (currentIterationIndex >= currentRepeatCount)
return false;
StartCurrentIteration();
return true;
}
/// <summary>
/// 스킬 클립으로 Override Controller 생성 후 재생
/// </summary>
@@ -426,9 +467,7 @@ namespace Colosseum.Skills
return;
}
skillEndRequested = true;
if (debugMode) Debug.Log($"[Skill] End requested: {currentSkill.SkillName} (will complete after animation)");
if (debugMode) Debug.Log($"[Skill] End event received: {currentSkill.SkillName}");
}
/// <summary>
@@ -445,12 +484,7 @@ namespace Colosseum.Skills
Debug.Log($"[Skill] Cancelled: {currentSkill.SkillName} / reason={reason}");
RestoreBaseController();
currentSkill = null;
currentLoadoutEntry = null;
currentCastStartEffects.Clear();
currentTriggeredEffects.Clear();
skillEndRequested = false;
waitingForEndAnimation = false;
ClearCurrentSkillState();
return true;
}
@@ -485,5 +519,19 @@ namespace Colosseum.Skills
{
cooldownTracker.Clear();
}
/// <summary>
/// 현재 실행 중인 스킬 상태를 정리합니다.
/// </summary>
private void ClearCurrentSkillState()
{
currentSkill = null;
currentLoadoutEntry = null;
currentCastStartEffects.Clear();
currentTriggeredEffects.Clear();
waitingForEndAnimation = false;
currentRepeatCount = 1;
currentIterationIndex = 0;
}
}
}

View File

@@ -5,6 +5,20 @@ using UnityEngine;
namespace Colosseum.Skills
{
/// <summary>
/// 젬의 주 역할 분류입니다.
/// </summary>
public enum SkillGemCategory
{
Common,
Attack,
Threat,
Defense,
Support,
Control,
Efficiency,
}
/// <summary>
/// 젬 효과가 발동될 애니메이션 이벤트 인덱스와 효과 목록입니다.
/// </summary>
@@ -31,12 +45,18 @@ namespace Colosseum.Skills
[TextArea(2, 4)]
[SerializeField] private string description;
[SerializeField] private Sprite icon;
[Tooltip("젬의 주 역할 분류")]
[SerializeField] private SkillGemCategory category = SkillGemCategory.Common;
[Header("기본 수치 보정")]
[Tooltip("장착 시 마나 비용 배율")]
[Min(0f)] [SerializeField] private float manaCostMultiplier = 1f;
[Tooltip("장착 시 쿨타임 배율")]
[Min(0f)] [SerializeField] private float cooldownMultiplier = 1f;
[Tooltip("장착 시 스킬 애니메이션 재생 속도 배율")]
[Min(0.1f)] [SerializeField] private float castSpeedMultiplier = 1f;
[Tooltip("기반 스킬 시전을 몇 회 더 반복할지 정의합니다. 현재는 계산/표시용으로만 사용됩니다.")]
[Min(0)] [SerializeField] private int additionalRepeatCount = 0;
[Header("추가 효과")]
[Tooltip("시전 시작 시 즉시 발동하는 추가 효과")]
@@ -47,8 +67,11 @@ namespace Colosseum.Skills
public string GemName => gemName;
public string Description => description;
public Sprite Icon => icon;
public SkillGemCategory Category => category;
public float ManaCostMultiplier => manaCostMultiplier;
public float CooldownMultiplier => cooldownMultiplier;
public float CastSpeedMultiplier => castSpeedMultiplier;
public int AdditionalRepeatCount => additionalRepeatCount;
public IReadOnlyList<SkillEffect> CastStartEffects => castStartEffects;
public IReadOnlyList<SkillGemTriggeredEffectEntry> TriggeredEffects => triggeredEffects;
}

View File

@@ -135,6 +135,48 @@ namespace Colosseum.Skills
return resolved;
}
public float GetResolvedAnimationSpeed()
{
if (baseSkill == null)
return 0f;
float resolved = baseSkill.AnimationSpeed;
if (socketedGems == null)
return resolved;
for (int i = 0; i < socketedGems.Length; i++)
{
SkillGemData gem = socketedGems[i];
if (gem == null)
continue;
resolved *= gem.CastSpeedMultiplier;
}
return Mathf.Max(0.05f, resolved);
}
public int GetResolvedRepeatCount()
{
if (baseSkill == null)
return 0;
int resolved = 1;
if (socketedGems == null)
return resolved;
for (int i = 0; i < socketedGems.Length; i++)
{
SkillGemData gem = socketedGems[i];
if (gem == null)
continue;
resolved += gem.AdditionalRepeatCount;
}
return Mathf.Max(1, resolved);
}
public void CollectCastStartEffects(List<SkillEffect> destination)
{
if (destination == null)