feat: 보호막 타입 분리 및 드로그 시그니처 전조 정리

- 보호막을 단일 수치에서 타입별 독립 인스턴스 구조로 리팩터링하고 같은 타입만 갱신되도록 정리
- 플레이어/보스 보호막 상태를 이상상태와 연동해 HUD 및 보스 UI에서 타입별로 식별 가능하게 보강
- 드로그 집행 개시 전조를 집행 준비 이상상태 기반으로 재구성하고 관련 데이터와 보스 컨텍스트를 정리
- 전투 밸런스 계측기와 디버그 메뉴를 추가해 피해, 치유, 보호막, 위협, 패턴 사용량 측정 경로를 마련
- 테스트용 보호막 A/B와 시그니처 전조 자산을 추가하고 기본 포트 7777 원복 후 빌드 및 런타임 검증을 완료
This commit is contained in:
2026-03-26 11:19:19 +09:00
parent 3db8acfaaa
commit aaa7d2d6a7
31 changed files with 2327 additions and 693 deletions

View File

@@ -6051,6 +6051,8 @@ MonoBehaviour:
m_EditorClassIdentifier: Colosseum.Game::Colosseum.UI.PlayerHUD
healthBar: {fileID: 281797463}
manaBar: {fileID: 1237841695}
abnormalitySummaryText: {fileID: 0}
autoCreateAbnormalitySummary: 1
autoFindPlayer: 1
--- !u!4 &1171449866 stripped
Transform:

View File

@@ -0,0 +1,32 @@
%YAML 1.1
%TAG !u! tag:unity3d.com,2011:
--- !u!114 &11400000
MonoBehaviour:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 0}
m_Enabled: 1
m_EditorHideFlags: 0
m_Script: {fileID: 11500000, guid: b08cc671f858a3b409170a5356e960a0, type: 3}
m_Name: Data_Abnormality_Common_보호막
m_EditorClassIdentifier: Colosseum.Game::Colosseum.Abnormalities.AbnormalityData
abnormalityName: 보호막
icon: {fileID: 0}
duration: 0
level: 1
isDebuff: 0
showInUI: 1
isShieldState: 1
ignoreHitReaction: 0
loopingVfxPrefab: {fileID: 0}
loopingVfxOffset: {x: 0, y: 0, z: 0}
loopingVfxScaleMultiplier: 1
parentLoopingVfxToTarget: 1
statModifiers: []
periodicInterval: 0
periodicValue: 0
controlType: 0
slowMultiplier: 0.5
incomingDamageMultiplier: 1

View File

@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 228bf5f3997e4cb582d5fcc66b8b93dc
NativeFormatImporter:
externalObjects: {}
mainObjectFileID: 0
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -0,0 +1,31 @@
%YAML 1.1
%TAG !u! tag:unity3d.com,2011:
--- !u!114 &11400000
MonoBehaviour:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 0}
m_Enabled: 1
m_EditorHideFlags: 0
m_Script: {fileID: 11500000, guid: b08cc671f858a3b409170a5356e960a0, type: 3}
m_Name: Data_Abnormality_Drog_집행준비
m_EditorClassIdentifier: Colosseum.Game::Colosseum.Abnormalities.AbnormalityData
abnormalityName: 집행 준비
icon: {fileID: 0}
duration: 0
level: 1
isDebuff: 0
showInUI: 0
ignoreHitReaction: 0
loopingVfxPrefab: {fileID: 1800972780968652, guid: 205d983549fba2a47a7808abf228f4be, type: 3}
loopingVfxOffset: {x: 0, y: 3.2, z: 0}
loopingVfxScaleMultiplier: 6
parentLoopingVfxToTarget: 0
statModifiers: []
periodicInterval: 0
periodicValue: 0
controlType: 0
slowMultiplier: 0.5
incomingDamageMultiplier: 1

View File

@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: fb1a782e44ff4dc19fd8b3c633360752
NativeFormatImporter:
externalObjects: {}
mainObjectFileID: 11400000
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -0,0 +1,32 @@
%YAML 1.1
%TAG !u! tag:unity3d.com,2011:
--- !u!114 &11400000
MonoBehaviour:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 0}
m_Enabled: 1
m_EditorHideFlags: 0
m_Script: {fileID: 11500000, guid: b08cc671f858a3b409170a5356e960a0, type: 3}
m_Name: Data_Abnormality_Test_보호막A
m_EditorClassIdentifier: Colosseum.Game::Colosseum.Abnormalities.AbnormalityData
abnormalityName: 보호막 A
icon: {fileID: 0}
duration: 0
level: 1
isDebuff: 0
showInUI: 1
isShieldState: 1
ignoreHitReaction: 0
loopingVfxPrefab: {fileID: 0}
loopingVfxOffset: {x: 0, y: 0, z: 0}
loopingVfxScaleMultiplier: 1
parentLoopingVfxToTarget: 1
statModifiers: []
periodicInterval: 0
periodicValue: 0
controlType: 0
slowMultiplier: 0.5
incomingDamageMultiplier: 1

View File

@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: d4336ef1eec3e8e479efd9bc67334534
NativeFormatImporter:
externalObjects: {}
mainObjectFileID: 0
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -0,0 +1,32 @@
%YAML 1.1
%TAG !u! tag:unity3d.com,2011:
--- !u!114 &11400000
MonoBehaviour:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 0}
m_Enabled: 1
m_EditorHideFlags: 0
m_Script: {fileID: 11500000, guid: b08cc671f858a3b409170a5356e960a0, type: 3}
m_Name: Data_Abnormality_Test_보호막B
m_EditorClassIdentifier: Colosseum.Game::Colosseum.Abnormalities.AbnormalityData
abnormalityName: 보호막 B
icon: {fileID: 0}
duration: 0
level: 1
isDebuff: 0
showInUI: 1
isShieldState: 1
ignoreHitReaction: 0
loopingVfxPrefab: {fileID: 0}
loopingVfxOffset: {x: 0, y: 0, z: 0}
loopingVfxScaleMultiplier: 1
parentLoopingVfxToTarget: 1
statModifiers: []
periodicInterval: 0
periodicValue: 0
controlType: 0
slowMultiplier: 0.5
incomingDamageMultiplier: 1

View File

@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: d33ac1a44c7874d4eb24fce2642521fd
NativeFormatImporter:
externalObjects: {}
mainObjectFileID: 0
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -19,5 +19,5 @@ MonoBehaviour:
Duration: 0
- Type: 1
Skill: {fileID: 0}
Duration: 6.5
Duration: 0
cooldown: 45

View File

@@ -15,9 +15,9 @@ MonoBehaviour:
skillName: "\uC9D1\uD589\uAC1C\uC2DC"
description: "\uB4DC\uB85C\uADF8\uAC00 \uD798\uC744 \uB04C\uC5B4\uBAA8\uC73C\uBA70 \uC9D1\uD589\uC744 \uC900\uBE44\uD569\uB2C8\uB2E4."
icon: {fileID: 0}
skillClip: {fileID: -5764696784021583549, guid: 5eaeca917bbeb494eb14ad0e0552c42f, type: 3}
endClip: {fileID: 0}
animationSpeed: 1
skillClip: {fileID: 1196400477972205583, guid: 330fdc27ae77eab44a6cfa040fa46036, type: 3}
endClip: {fileID: -473667693787628719, guid: 580fc907eee299f43a914da368c7a639, type: 3}
animationSpeed: 0.5
useRootMotion: 0
ignoreRootMotionY: 0
jumpToTarget: 0
@@ -26,4 +26,6 @@ MonoBehaviour:
blockOtherSkillsWhileCasting: 1
cooldown: 0
manaCost: 0
castStartEffects:
- {fileID: 11400000, guid: 032be692478542b2b7eae48b2a5b29c1, type: 2}
effects: []

View File

@@ -0,0 +1,26 @@
%YAML 1.1
%TAG !u! tag:unity3d.com,2011:
--- !u!114 &11400000
MonoBehaviour:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 0}
m_Enabled: 1
m_EditorHideFlags: 0
m_Script: {fileID: 11500000, guid: bf750718c64c4bd48af905d2927351de, type: 3}
m_Name: "Data_SkillEffect_Drog_\uC9D1\uD589\uAC1C\uC2DC_0_\uC9D1\uD589\uC900\uBE44"
m_EditorClassIdentifier: Colosseum.Game::Colosseum.Skills.Effects.AbnormalityEffect
targetType: 0
targetTeam: 0
areaCenter: 0
areaShape: 0
targetLayers:
serializedVersion: 2
m_Bits: 4294967295
areaRadius: 3
fanOriginDistance: 1
fanRadius: 3
fanHalfAngle: 45
abnormalityData: {fileID: 11400000, guid: fb1a782e44ff4dc19fd8b3c633360752, type: 2}

View File

@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 032be692478542b2b7eae48b2a5b29c1
NativeFormatImporter:
externalObjects: {}
mainObjectFileID: 11400000
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -27,3 +27,4 @@ MonoBehaviour:
baseShield: 28
shieldScaling: 0.8
duration: 5
shieldStateAbnormality: {fileID: 11400000, guid: 228bf5f3997e4cb582d5fcc66b8b93dc, type: 2}

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -2203,6 +2203,7 @@ MonoBehaviour:
phase3SlamInterval: 2
signatureMinPhase: 2
signatureRequiredDamageRatio: 0.1
signatureTelegraphAbnormality: {fileID: 11400000, guid: fb1a782e44ff4dc19fd8b3c633360752, type: 2}
signatureSuccessStaggerDuration: 2
signatureFailureAbnormality: {fileID: 11400000, guid: bc74f1485ad140c28cc14b821e22c127, type: 2}
signatureFailureDamage: 40

View File

@@ -68,9 +68,26 @@ namespace Colosseum.Abnormalities
[Tooltip("플레이어 HUD의 이상상태 UI에 표시할지 여부")]
public bool showInUI = true;
[Tooltip("보호막 계열 상태인지 여부 (보호막 인스턴스 동기화용)")]
public bool isShieldState = false;
[Tooltip("활성 중에는 일반 피격 반응(경직, 넉백, 다운)을 무시할지 여부")]
public bool ignoreHitReaction = false;
[Header("시각 효과")]
[Tooltip("이상 상태가 유지되는 동안 대상에 붙일 루핑 VFX 프리팹")]
public GameObject loopingVfxPrefab;
[Tooltip("루핑 VFX 위치 보정값")]
public Vector3 loopingVfxOffset = Vector3.zero;
[Tooltip("루핑 VFX 스케일 배율")]
[Min(0.01f)]
public float loopingVfxScaleMultiplier = 1f;
[Tooltip("루핑 VFX를 대상의 자식으로 붙일지 여부")]
public bool parentLoopingVfxToTarget = true;
[Header("스탯 수정자")]
[Tooltip("스탯에 적용할 수정자 목록")]
public List<AbnormalityStatModifier> statModifiers = new List<AbnormalityStatModifier>();
@@ -110,6 +127,11 @@ namespace Colosseum.Abnormalities
/// </summary>
public bool HasControlEffect => controlType != ControlType.None;
/// <summary>
/// 유지형 루핑 VFX가 있는지 확인
/// </summary>
public bool HasLoopingVfx => loopingVfxPrefab != null;
/// <summary>
/// 받는 피해 배율 변경 여부
/// </summary>

View File

@@ -26,6 +26,7 @@ namespace Colosseum.Abnormalities
// 활성화된 이상 상태 목록
private readonly List<ActiveAbnormality> activeAbnormalities = new List<ActiveAbnormality>();
private readonly Dictionary<int, GameObject> abnormalityVisualInstances = new Dictionary<int, GameObject>();
// 제어 효과 상태
private int stunCount;
@@ -150,6 +151,8 @@ namespace Colosseum.Abnormalities
{
SyncControlEffects();
}
RefreshAbnormalityVisuals();
}
public override void OnNetworkDespawn()
@@ -165,6 +168,8 @@ namespace Colosseum.Abnormalities
{
networkController.OnDeathStateChanged -= HandleDeathStateChanged;
}
ClearAllAbnormalityVisuals();
}
private void Update()
@@ -255,6 +260,7 @@ namespace Colosseum.Abnormalities
ApplyControlEffect(data);
RecalculateIncomingDamageMultiplier();
SyncAbnormalityAdd(newAbnormality, source);
RefreshAbnormalityVisuals();
OnAbnormalityAdded?.Invoke(newAbnormality);
OnAbnormalitiesChanged?.Invoke();
@@ -301,6 +307,7 @@ namespace Colosseum.Abnormalities
RecalculateIncomingDamageMultiplier();
SyncAbnormalityRemove(abnormality);
activeAbnormalities.Remove(abnormality);
RefreshAbnormalityVisuals();
OnAbnormalityRemoved?.Invoke(abnormality);
OnAbnormalitiesChanged?.Invoke();
@@ -593,6 +600,7 @@ namespace Colosseum.Abnormalities
private void OnSyncedAbnormalitiesChanged(NetworkListEvent<AbnormalitySyncData> changeEvent)
{
RefreshAbnormalityVisuals();
OnAbnormalitiesChanged?.Invoke();
}
@@ -651,6 +659,111 @@ namespace Colosseum.Abnormalities
Debug.Log($"[Abnormality] Cleared all abnormalities on death: {gameObject.name}");
}
/// <summary>
/// 현재 활성 이상상태 기준으로 루핑 VFX를 동기화합니다.
/// </summary>
private void RefreshAbnormalityVisuals()
{
HashSet<int> desiredAbnormalityIds = new HashSet<int>();
if (IsServer)
{
for (int i = 0; i < activeAbnormalities.Count; i++)
{
ActiveAbnormality abnormality = activeAbnormalities[i];
if (abnormality?.Data == null || !abnormality.Data.HasLoopingVfx)
continue;
int abnormalityId = abnormality.Data.GetInstanceID();
desiredAbnormalityIds.Add(abnormalityId);
if (!abnormalityVisualInstances.ContainsKey(abnormalityId) || abnormalityVisualInstances[abnormalityId] == null)
{
abnormalityVisualInstances[abnormalityId] = CreateLoopingVfxInstance(abnormality.Data);
}
}
}
else
{
for (int i = 0; i < syncedAbnormalities.Count; i++)
{
AbnormalitySyncData syncData = syncedAbnormalities[i];
AbnormalityData data = FindAbnormalityDataById(syncData.AbnormalityId);
if (data == null || !data.HasLoopingVfx)
continue;
desiredAbnormalityIds.Add(syncData.AbnormalityId);
if (!abnormalityVisualInstances.ContainsKey(syncData.AbnormalityId) || abnormalityVisualInstances[syncData.AbnormalityId] == null)
{
abnormalityVisualInstances[syncData.AbnormalityId] = CreateLoopingVfxInstance(data);
}
}
}
List<int> removeIds = new List<int>();
foreach (KeyValuePair<int, GameObject> pair in abnormalityVisualInstances)
{
if (desiredAbnormalityIds.Contains(pair.Key))
continue;
if (pair.Value != null)
Destroy(pair.Value);
removeIds.Add(pair.Key);
}
for (int i = 0; i < removeIds.Count; i++)
{
abnormalityVisualInstances.Remove(removeIds[i]);
}
}
/// <summary>
/// 루핑 VFX 인스턴스를 생성합니다.
/// </summary>
private GameObject CreateLoopingVfxInstance(AbnormalityData data)
{
if (data == null || data.loopingVfxPrefab == null)
return null;
Transform parent = data.parentLoopingVfxToTarget ? transform : null;
GameObject instance = Instantiate(data.loopingVfxPrefab, transform.position + data.loopingVfxOffset, Quaternion.identity, parent);
instance.transform.localScale *= data.loopingVfxScaleMultiplier;
if (data.parentLoopingVfxToTarget)
instance.transform.localPosition = data.loopingVfxOffset;
else
instance.transform.position = transform.position + data.loopingVfxOffset;
ParticleSystem[] particleSystems = instance.GetComponentsInChildren<ParticleSystem>(true);
for (int i = 0; i < particleSystems.Length; i++)
{
ParticleSystem particleSystem = particleSystems[i];
var main = particleSystem.main;
main.loop = true;
main.stopAction = ParticleSystemStopAction.None;
particleSystem.Clear(true);
particleSystem.Play(true);
}
return instance;
}
/// <summary>
/// 현재 관리 중인 루핑 VFX를 모두 제거합니다.
/// </summary>
private void ClearAllAbnormalityVisuals()
{
foreach (KeyValuePair<int, GameObject> pair in abnormalityVisualInstances)
{
if (pair.Value != null)
Destroy(pair.Value);
}
abnormalityVisualInstances.Clear();
}
private void TryCancelCurrentSkill(SkillCancelReason reason, string sourceName)
{
if (!IsServer || skillController == null || !skillController.IsPlayingAnimation)
@@ -686,6 +799,9 @@ namespace Colosseum.Abnormalities
/// </summary>
public bool HasAbnormality(AbnormalityData data)
{
if (data == null)
return false;
return activeAbnormalities.Exists(a => a.Data.abnormalityName == data.abnormalityName);
}
}

View File

@@ -0,0 +1,332 @@
using System.Collections.Generic;
using System.Text;
using Unity.Netcode;
using UnityEngine;
using Colosseum.Enemy;
using Colosseum.Skills;
namespace Colosseum.Combat
{
/// <summary>
/// 전투 밸런싱 검증을 위한 런타임 계측기입니다.
/// 플레이어/보스 기준으로 대미지, 치유, 보호막, 위협, 패턴 사용량을 누적합니다.
/// </summary>
public static class CombatBalanceTracker
{
private sealed class ActorMetrics
{
public string label;
public float totalDamageDealt;
public float bossDamageDealt;
public float damageTaken;
public float healDone;
public float healReceived;
public float shieldApplied;
public float shieldReceived;
public float threatGenerated;
public readonly Dictionary<string, float> damageBySkill = new Dictionary<string, float>();
public readonly Dictionary<string, float> healBySkill = new Dictionary<string, float>();
public readonly Dictionary<string, float> shieldBySkill = new Dictionary<string, float>();
public readonly Dictionary<string, float> threatBySkill = new Dictionary<string, float>();
}
private static readonly Dictionary<string, ActorMetrics> actorMetrics = new Dictionary<string, ActorMetrics>();
private static readonly Dictionary<string, int> bossPatternCounts = new Dictionary<string, int>();
private static readonly Dictionary<string, int> bossEventCounts = new Dictionary<string, int>();
private static float combatStartTime = -1f;
private static float lastEventTime = -1f;
/// <summary>
/// 누적된 전투 계측 데이터를 초기화합니다.
/// </summary>
public static void Reset()
{
actorMetrics.Clear();
bossPatternCounts.Clear();
bossEventCounts.Clear();
combatStartTime = -1f;
lastEventTime = -1f;
}
/// <summary>
/// 실제 적용된 대미지를 기록합니다.
/// </summary>
public static void RecordDamage(GameObject source, GameObject target, float actualDamage)
{
if (actualDamage <= 0f || target == null)
return;
MarkCombatEvent();
ActorMetrics targetMetrics = GetMetrics(target);
targetMetrics.damageTaken += actualDamage;
if (source == null)
return;
ActorMetrics sourceMetrics = GetMetrics(source);
sourceMetrics.totalDamageDealt += actualDamage;
if (target.GetComponent<BossEnemy>() != null || target.GetComponentInParent<BossEnemy>() != null)
{
sourceMetrics.bossDamageDealt += actualDamage;
}
AddSkillValue(sourceMetrics.damageBySkill, ResolveSkillLabel(source), actualDamage);
}
/// <summary>
/// 실제 적용된 회복량을 기록합니다.
/// </summary>
public static void RecordHeal(GameObject source, GameObject target, float actualHeal)
{
if (actualHeal <= 0f || target == null)
return;
MarkCombatEvent();
ActorMetrics targetMetrics = GetMetrics(target);
targetMetrics.healReceived += actualHeal;
if (source == null)
return;
ActorMetrics sourceMetrics = GetMetrics(source);
sourceMetrics.healDone += actualHeal;
AddSkillValue(sourceMetrics.healBySkill, ResolveSkillLabel(source), actualHeal);
}
/// <summary>
/// 실제 적용된 보호막 수치를 기록합니다.
/// </summary>
public static void RecordShield(GameObject source, GameObject target, float actualShield)
{
if (actualShield <= 0f || target == null)
return;
MarkCombatEvent();
ActorMetrics targetMetrics = GetMetrics(target);
targetMetrics.shieldReceived += actualShield;
if (source == null)
return;
ActorMetrics sourceMetrics = GetMetrics(source);
sourceMetrics.shieldApplied += actualShield;
AddSkillValue(sourceMetrics.shieldBySkill, ResolveSkillLabel(source), actualShield);
}
/// <summary>
/// 실제 적용된 위협 증가량을 기록합니다.
/// </summary>
public static void RecordThreat(GameObject source, float threatAmount)
{
if (threatAmount <= 0f || source == null)
return;
MarkCombatEvent();
ActorMetrics sourceMetrics = GetMetrics(source);
sourceMetrics.threatGenerated += threatAmount;
AddSkillValue(sourceMetrics.threatBySkill, ResolveSkillLabel(source), threatAmount);
}
/// <summary>
/// 보스 패턴 사용 횟수를 기록합니다.
/// </summary>
public static void RecordBossPattern(string patternName)
{
if (string.IsNullOrWhiteSpace(patternName))
return;
MarkCombatEvent();
AddCount(bossPatternCounts, patternName);
}
/// <summary>
/// 시그니처 성공/실패 같은 보스 전투 이벤트를 기록합니다.
/// </summary>
public static void RecordBossEvent(string eventName)
{
if (string.IsNullOrWhiteSpace(eventName))
return;
MarkCombatEvent();
AddCount(bossEventCounts, eventName);
}
/// <summary>
/// 현재 누적 계측 데이터를 보기 좋은 문자열로 반환합니다.
/// </summary>
public static string BuildSummary()
{
StringBuilder builder = new StringBuilder();
builder.Append("[Balance] 전투 요약");
if (combatStartTime >= 0f && lastEventTime >= combatStartTime)
{
builder.Append(" | Duration=");
builder.Append((lastEventTime - combatStartTime).ToString("0.00"));
builder.Append("s");
}
if (bossPatternCounts.Count > 0)
{
builder.AppendLine();
builder.Append("보스 패턴: ");
AppendCountSummary(builder, bossPatternCounts);
}
if (bossEventCounts.Count > 0)
{
builder.AppendLine();
builder.Append("보스 이벤트: ");
AppendCountSummary(builder, bossEventCounts);
}
foreach (KeyValuePair<string, ActorMetrics> pair in actorMetrics)
{
ActorMetrics metrics = pair.Value;
builder.AppendLine();
builder.Append("- ");
builder.Append(metrics.label);
builder.Append(" | BossDmg=");
builder.Append(metrics.bossDamageDealt.ToString("0.##"));
builder.Append(" | TotalDmg=");
builder.Append(metrics.totalDamageDealt.ToString("0.##"));
builder.Append(" | Taken=");
builder.Append(metrics.damageTaken.ToString("0.##"));
builder.Append(" | Heal=");
builder.Append(metrics.healDone.ToString("0.##"));
builder.Append(" | HealRecv=");
builder.Append(metrics.healReceived.ToString("0.##"));
builder.Append(" | Shield=");
builder.Append(metrics.shieldApplied.ToString("0.##"));
builder.Append(" | ShieldRecv=");
builder.Append(metrics.shieldReceived.ToString("0.##"));
builder.Append(" | Threat=");
builder.Append(metrics.threatGenerated.ToString("0.##"));
AppendSkillBreakdown(builder, "DmgBySkill", metrics.damageBySkill);
AppendSkillBreakdown(builder, "HealBySkill", metrics.healBySkill);
AppendSkillBreakdown(builder, "ShieldBySkill", metrics.shieldBySkill);
AppendSkillBreakdown(builder, "ThreatBySkill", metrics.threatBySkill);
}
return builder.ToString();
}
private static void MarkCombatEvent()
{
if (!Application.isPlaying)
return;
float now = Time.time;
if (combatStartTime < 0f)
combatStartTime = now;
lastEventTime = now;
}
private static ActorMetrics GetMetrics(GameObject actor)
{
string actorLabel = ResolveActorLabel(actor);
if (!actorMetrics.TryGetValue(actorLabel, out ActorMetrics metrics))
{
metrics = new ActorMetrics
{
label = actorLabel,
};
actorMetrics.Add(actorLabel, metrics);
}
return metrics;
}
private static string ResolveActorLabel(GameObject actor)
{
if (actor == null)
return "Unknown";
NetworkObject networkObject = actor.GetComponent<NetworkObject>() ?? actor.GetComponentInParent<NetworkObject>();
if (networkObject != null)
{
string roleLabel = actor.GetComponent<BossEnemy>() != null || actor.GetComponentInParent<BossEnemy>() != null
? "Boss"
: "Actor";
return $"{roleLabel}:{actor.name}(Owner={networkObject.OwnerClientId})";
}
return actor.name;
}
private static string ResolveSkillLabel(GameObject source)
{
if (source == null)
return "Unknown";
SkillController skillController = source.GetComponent<SkillController>() ?? source.GetComponentInParent<SkillController>();
if (skillController != null && skillController.CurrentSkill != null)
return skillController.CurrentSkill.SkillName;
return "Unknown";
}
private static void AddSkillValue(Dictionary<string, float> dictionary, string key, float value)
{
if (dictionary == null || string.IsNullOrWhiteSpace(key) || value <= 0f)
return;
dictionary.TryGetValue(key, out float currentValue);
dictionary[key] = currentValue + value;
}
private static void AddCount(Dictionary<string, int> dictionary, string key)
{
dictionary.TryGetValue(key, out int currentValue);
dictionary[key] = currentValue + 1;
}
private static void AppendCountSummary(StringBuilder builder, Dictionary<string, int> dictionary)
{
bool first = true;
foreach (KeyValuePair<string, int> pair in dictionary)
{
if (!first)
builder.Append(" | ");
builder.Append(pair.Key);
builder.Append('=');
builder.Append(pair.Value);
first = false;
}
}
private static void AppendSkillBreakdown(StringBuilder builder, string label, Dictionary<string, float> dictionary)
{
if (dictionary == null || dictionary.Count == 0)
return;
builder.Append(" | ");
builder.Append(label);
builder.Append('=');
bool first = true;
foreach (KeyValuePair<string, float> pair in dictionary)
{
if (!first)
builder.Append(", ");
builder.Append(pair.Key);
builder.Append(':');
builder.Append(pair.Value.ToString("0.##"));
first = false;
}
}
}
}

View File

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

View File

@@ -0,0 +1,271 @@
using System.Collections.Generic;
using UnityEngine;
using Colosseum.Abnormalities;
namespace Colosseum.Combat
{
/// <summary>
/// 개별 보호막 인스턴스입니다.
/// 같은 종류의 보호막은 하나로 합쳐지고, 다른 종류의 보호막은 독립적으로 유지됩니다.
/// </summary>
[System.Serializable]
public sealed class ActiveShield
{
[SerializeField] private AbnormalityData shieldType;
[SerializeField] private float remainingAmount;
[SerializeField] private float remainingDuration;
[SerializeField] private bool isPermanent;
[SerializeField] private GameObject source;
public AbnormalityData ShieldType => shieldType;
public float RemainingAmount => remainingAmount;
public float RemainingDuration => isPermanent ? 0f : remainingDuration;
public bool IsPermanent => isPermanent;
public bool IsExpired => !isPermanent && remainingDuration <= 0f;
public bool IsDepleted => remainingAmount <= 0f;
public GameObject Source => source;
public ActiveShield(AbnormalityData shieldType, float amount, float duration, GameObject source)
{
this.shieldType = shieldType;
remainingAmount = Mathf.Max(0f, amount);
isPermanent = duration <= 0f;
remainingDuration = isPermanent ? 0f : duration;
this.source = source;
}
/// <summary>
/// 같은 종류 보호막이 다시 적용되었을 때 양을 더하고 지속시간을 갱신합니다.
/// </summary>
public float Add(float amount, float duration, GameObject source)
{
float appliedAmount = Mathf.Max(0f, amount);
remainingAmount += appliedAmount;
if (duration <= 0f)
{
isPermanent = true;
remainingDuration = 0f;
}
else if (!isPermanent)
{
remainingDuration = duration;
}
if (source != null)
{
this.source = source;
}
return appliedAmount;
}
/// <summary>
/// 지속시간을 감소시킵니다.
/// </summary>
public bool Tick(float deltaTime)
{
if (isPermanent || deltaTime <= 0f)
return false;
remainingDuration = Mathf.Max(0f, remainingDuration - deltaTime);
return IsExpired;
}
/// <summary>
/// 들어오는 피해를 흡수합니다.
/// </summary>
public float Consume(float incomingDamage)
{
if (incomingDamage <= 0f || remainingAmount <= 0f)
return 0f;
float absorbed = Mathf.Min(remainingAmount, incomingDamage);
remainingAmount = Mathf.Max(0f, remainingAmount - absorbed);
return absorbed;
}
}
/// <summary>
/// 대상이 가진 보호막 인스턴스를 관리합니다.
/// </summary>
[System.Serializable]
public sealed class ShieldCollection
{
private readonly List<ActiveShield> activeShields = new List<ActiveShield>();
public IReadOnlyList<ActiveShield> ActiveShields => activeShields;
public float TotalAmount
{
get
{
float total = 0f;
for (int i = 0; i < activeShields.Count; i++)
{
total += activeShields[i].RemainingAmount;
}
return total;
}
}
/// <summary>
/// 보호막을 적용합니다. 같은 종류는 자기 자신만 합산 및 갱신합니다.
/// </summary>
public float ApplyShield(AbnormalityData shieldType, float amount, float duration, GameObject source)
{
if (amount <= 0f)
return 0f;
ActiveShield existingShield = FindShield(shieldType);
if (existingShield != null)
{
return existingShield.Add(amount, duration, source);
}
activeShields.Add(new ActiveShield(shieldType, amount, duration, source));
return amount;
}
/// <summary>
/// 보호막이 적용된 순서대로 피해를 흡수하고 남은 피해를 반환합니다.
/// </summary>
public float ConsumeDamage(float incomingDamage)
{
if (incomingDamage <= 0f || activeShields.Count == 0)
return incomingDamage;
float remainingDamage = incomingDamage;
for (int i = 0; i < activeShields.Count && remainingDamage > 0f; i++)
{
remainingDamage -= activeShields[i].Consume(remainingDamage);
}
CleanupInactiveShields();
return Mathf.Max(0f, remainingDamage);
}
/// <summary>
/// 지속시간 경과를 처리합니다.
/// </summary>
public bool Tick(float deltaTime)
{
bool changed = false;
for (int i = activeShields.Count - 1; i >= 0; i--)
{
ActiveShield shield = activeShields[i];
bool expired = shield.Tick(deltaTime);
if (!expired && !shield.IsDepleted)
continue;
activeShields.RemoveAt(i);
changed = true;
}
return changed;
}
/// <summary>
/// 모든 보호막을 제거합니다.
/// </summary>
public void Clear()
{
activeShields.Clear();
}
private ActiveShield FindShield(AbnormalityData shieldType)
{
for (int i = 0; i < activeShields.Count; i++)
{
if (activeShields[i].ShieldType == shieldType)
return activeShields[i];
}
return null;
}
private void CleanupInactiveShields()
{
for (int i = activeShields.Count - 1; i >= 0; i--)
{
if (!activeShields[i].IsExpired && !activeShields[i].IsDepleted)
continue;
activeShields.RemoveAt(i);
}
}
}
/// <summary>
/// 보호막 수치와 보호막 이상상태를 동기화하는 유틸리티입니다.
/// </summary>
public static class ShieldAbnormalityUtility
{
/// <summary>
/// 활성 보호막 목록에 맞춰 보호막 이상상태를 적용하거나 제거합니다.
/// </summary>
public static void SyncShieldAbnormalities(
AbnormalityManager abnormalityManager,
IReadOnlyList<ActiveShield> activeShields,
GameObject defaultSource)
{
if (abnormalityManager == null)
return;
HashSet<AbnormalityData> desiredShieldStates = new HashSet<AbnormalityData>();
Dictionary<AbnormalityData, GameObject> shieldSources = new Dictionary<AbnormalityData, GameObject>();
if (activeShields != null)
{
for (int i = 0; i < activeShields.Count; i++)
{
ActiveShield shield = activeShields[i];
if (shield == null || shield.RemainingAmount <= 0f || shield.ShieldType == null)
continue;
desiredShieldStates.Add(shield.ShieldType);
if (!shieldSources.ContainsKey(shield.ShieldType))
{
shieldSources.Add(shield.ShieldType, shield.Source != null ? shield.Source : defaultSource);
}
}
}
foreach (AbnormalityData shieldState in desiredShieldStates)
{
if (abnormalityManager.HasAbnormality(shieldState))
continue;
GameObject source = shieldSources.TryGetValue(shieldState, out GameObject resolvedSource)
? resolvedSource
: defaultSource;
abnormalityManager.ApplyAbnormality(shieldState, source);
}
IReadOnlyList<ActiveAbnormality> activeAbnormalities = abnormalityManager.ActiveAbnormalities;
List<AbnormalityData> removeList = new List<AbnormalityData>();
for (int i = 0; i < activeAbnormalities.Count; i++)
{
ActiveAbnormality activeAbnormality = activeAbnormalities[i];
if (activeAbnormality?.Data == null || !activeAbnormality.Data.isShieldState)
continue;
if (desiredShieldStates.Contains(activeAbnormality.Data))
continue;
removeList.Add(activeAbnormality.Data);
}
for (int i = 0; i < removeList.Count; i++)
{
abnormalityManager.RemoveAbnormality(removeList[i]);
}
}
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 69ea46fefb9597e49a4ce68c81ccf0ce

View File

@@ -7,6 +7,7 @@ using Colosseum.Skills;
using Colosseum.Skills.Effects;
using Colosseum.UI;
using Colosseum.Abnormalities;
using Colosseum.Combat;
using UnityEditor;
using UnityEngine;
@@ -35,6 +36,9 @@ namespace Colosseum.Editor
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";
private const string MarkAbnormalityPath = "Assets/_Game/Data/Abnormalities/Data_Abnormality_Player_집행자의낙인.asset";
private const string ShieldAbnormalityPath = "Assets/_Game/Data/Abnormalities/Data_Abnormality_Common_보호막.asset";
private const string ShieldTypeAPath = "Assets/_Game/Data/Abnormalities/Data_Abnormality_Test_보호막A.asset";
private const string ShieldTypeBPath = "Assets/_Game/Data/Abnormalities/Data_Abnormality_Test_보호막B.asset";
private const string SkillGemFolderPath = "Assets/_Game/Data/SkillGems";
private const string LoadoutPresetFolderPath = "Assets/_Game/Data/Loadouts";
private const string CrushGemPath = SkillGemFolderPath + "/Data_SkillGem_Player_파쇄.asset";
@@ -222,6 +226,22 @@ namespace Colosseum.Editor
Debug.Log($"[Debug] 보스 체력 | Name={boss.name} | HP={boss.CurrentHealth:0.###}/{boss.MaxHealth:0.###}");
}
[MenuItem("Tools/Colosseum/Debug/Reset Combat Balance Metrics")]
private static void ResetCombatBalanceMetrics()
{
CombatBalanceTracker.Reset();
Debug.Log("[Debug] 전투 밸런스 계측기를 초기화했습니다.");
}
[MenuItem("Tools/Colosseum/Debug/Log Combat Balance Summary")]
private static void LogCombatBalanceSummary()
{
string summary = CombatBalanceTracker.BuildSummary()
.Replace("\r\n", " || ")
.Replace("\n", " || ");
Debug.Log(summary);
}
[MenuItem("Tools/Colosseum/Debug/Apply Local Stun")]
private static void ApplyLocalStun()
{
@@ -267,6 +287,143 @@ namespace Colosseum.Editor
Debug.Log($"[Debug] 보스에게 기절 적용 | Target={abnormalityManager.gameObject.name} | Abnormality={abnormality.abnormalityName}");
}
[MenuItem("Tools/Colosseum/Debug/Apply Boss Shield")]
private static void ApplyBossShield()
{
if (!EditorApplication.isPlaying)
{
Debug.LogWarning("[Debug] 플레이 모드에서만 사용할 수 있습니다.");
return;
}
BossEnemy bossEnemy = FindBossEnemy();
if (bossEnemy == null)
{
Debug.LogWarning("[Debug] 활성 보스를 찾지 못했습니다.");
return;
}
AbnormalityData shieldAbnormality = AssetDatabase.LoadAssetAtPath<AbnormalityData>(ShieldAbnormalityPath);
if (shieldAbnormality == null)
{
Debug.LogWarning($"[Debug] 보호막 이상상태 에셋을 찾지 못했습니다: {ShieldAbnormalityPath}");
return;
}
float appliedShield = bossEnemy.ApplyShield(120f, 8f, shieldAbnormality, bossEnemy.gameObject);
Debug.Log($"[Debug] 보스에게 보호막 적용 | Target={bossEnemy.name} | Applied={appliedShield:0.##} | CurrentShield={bossEnemy.Shield:0.##}");
}
[MenuItem("Tools/Colosseum/Debug/Apply Boss Shield Type A")]
private static void ApplyBossShieldTypeA()
{
ApplyBossShieldWithType(ShieldTypeAPath, 100f, 4f);
}
[MenuItem("Tools/Colosseum/Debug/Apply Boss Shield Type B")]
private static void ApplyBossShieldTypeB()
{
ApplyBossShieldWithType(ShieldTypeBPath, 150f, 8f);
}
[MenuItem("Tools/Colosseum/Debug/Force Boss Phase 2")]
private static void ForceBossPhase2()
{
if (!EditorApplication.isPlaying)
{
Debug.LogWarning("[Debug] 플레이 모드에서만 사용할 수 있습니다.");
return;
}
BossEnemy bossEnemy = FindBossEnemy();
if (bossEnemy == null)
{
Debug.LogWarning("[Debug] 활성 보스를 찾지 못했습니다.");
return;
}
bossEnemy.ForcePhaseTransition(1);
Debug.Log($"[Debug] 보스를 Phase 2로 강제 전환했습니다. | Target={bossEnemy.name}");
}
[MenuItem("Tools/Colosseum/Debug/Force Boss Signature")]
private static void ForceBossSignature()
{
if (!EditorApplication.isPlaying)
{
Debug.LogWarning("[Debug] 플레이 모드에서만 사용할 수 있습니다.");
return;
}
BossCombatBehaviorContext context = FindBossCombatContext();
if (context == null)
{
Debug.LogWarning("[Debug] 보스 전투 컨텍스트를 찾지 못했습니다.");
return;
}
if (!context.ForceStartSignaturePattern())
{
Debug.LogWarning("[Debug] 집행 개시를 강제로 시작하지 못했습니다. 이미 실행 중이거나 패턴이 비어 있을 수 있습니다.");
return;
}
Debug.Log($"[Debug] 집행 개시를 강제로 시작했습니다. | Target={context.gameObject.name}");
}
[MenuItem("Tools/Colosseum/Debug/Preview Boss Signature Telegraph")]
private static void PreviewBossSignatureTelegraph()
{
if (!EditorApplication.isPlaying)
{
Debug.LogWarning("[Debug] 플레이 모드에서만 사용할 수 있습니다.");
return;
}
BossCombatBehaviorContext context = FindBossCombatContext();
if (context == null)
{
Debug.LogWarning("[Debug] 보스 전투 컨텍스트를 찾지 못했습니다.");
return;
}
if (!context.PreviewSignatureTelegraph())
{
Debug.LogWarning("[Debug] 집행 개시 전조 프리뷰를 시작하지 못했습니다. 이미 다른 스킬이 재생 중일 수 있습니다.");
return;
}
Debug.Log($"[Debug] 집행 개시 전조 프리뷰를 재생했습니다. | Target={context.gameObject.name}");
}
private static void ApplyBossShieldWithType(string assetPath, float amount, float duration)
{
if (!EditorApplication.isPlaying)
{
Debug.LogWarning("[Debug] 플레이 모드에서만 사용할 수 있습니다.");
return;
}
BossEnemy bossEnemy = FindBossEnemy();
if (bossEnemy == null)
{
Debug.LogWarning("[Debug] 활성 보스를 찾지 못했습니다.");
return;
}
AbnormalityData shieldAbnormality = AssetDatabase.LoadAssetAtPath<AbnormalityData>(assetPath);
if (shieldAbnormality == null)
{
Debug.LogWarning($"[Debug] 보호막 이상상태 에셋을 찾지 못했습니다: {assetPath}");
return;
}
float appliedShield = bossEnemy.ApplyShield(amount, duration, shieldAbnormality, bossEnemy.gameObject);
Debug.Log(
$"[Debug] 보스에게 타입 보호막 적용 | Target={bossEnemy.name} | ShieldType={shieldAbnormality.abnormalityName} | " +
$"Applied={appliedShield:0.##} | CurrentShield={bossEnemy.Shield:0.##} | Duration={duration:0.##}");
}
[MenuItem("Tools/Colosseum/Debug/Log HUD Abnormality Summary")]
private static void LogHudAbnormalitySummary()
{
@@ -286,6 +443,25 @@ namespace Colosseum.Editor
Debug.Log($"[Debug] HUD 이상상태 요약 | {playerHud.CurrentAbnormalitySummary}");
}
[MenuItem("Tools/Colosseum/Debug/Log Boss HUD Abnormality Summary")]
private static void LogBossHudAbnormalitySummary()
{
if (!EditorApplication.isPlaying)
{
Debug.LogWarning("[Debug] 플레이 모드에서만 사용할 수 있습니다.");
return;
}
BossHealthBarUI bossHealthBarUi = Object.FindFirstObjectByType<BossHealthBarUI>();
if (bossHealthBarUi == null)
{
Debug.LogWarning("[Debug] BossHealthBarUI를 찾지 못했습니다.");
return;
}
Debug.Log($"[Debug] 보스 HUD 이상상태 요약 | {bossHealthBarUi.CurrentAbnormalitySummary}");
}
[MenuItem("Tools/Colosseum/Debug/Apply Tank Loadout")]
private static void ApplyTankLoadout()
{
@@ -729,17 +905,27 @@ namespace Colosseum.Editor
}
private static AbnormalityManager FindBossAbnormalityManager()
{
BossEnemy bossEnemy = FindBossEnemy();
if (bossEnemy == null)
return null;
return bossEnemy.GetComponent<AbnormalityManager>();
}
private static BossEnemy FindBossEnemy()
{
BossEnemy activeBoss = BossEnemy.ActiveBoss;
if (activeBoss != null)
{
AbnormalityManager activeManager = activeBoss.GetComponent<AbnormalityManager>();
if (activeManager != null)
return activeManager;
}
return activeBoss;
BossEnemy bossEnemy = Object.FindFirstObjectByType<BossEnemy>();
return bossEnemy != null ? bossEnemy.GetComponent<AbnormalityManager>() : null;
return Object.FindFirstObjectByType<BossEnemy>();
}
private static BossCombatBehaviorContext FindBossCombatContext()
{
BossEnemy bossEnemy = FindBossEnemy();
return bossEnemy != null ? bossEnemy.GetComponent<BossCombatBehaviorContext>() : null;
}
private static void CastLocalSkill(int slotIndex)

View File

@@ -97,6 +97,9 @@ namespace Colosseum.Enemy
[Tooltip("시그니처 패턴 차단에 필요한 누적 피해 비율")]
[Range(0f, 1f)] [SerializeField] protected float signatureRequiredDamageRatio = 0.1f;
[Tooltip("시그니처 준비 상태를 나타내는 이상상태")]
[SerializeField] protected AbnormalityData signatureTelegraphAbnormality;
[Tooltip("시그니처 차단 성공 시 보스가 멈추는 시간")]
[Min(0f)] [SerializeField] protected float signatureSuccessStaggerDuration = 2f;
@@ -135,8 +138,11 @@ namespace Colosseum.Enemy
protected float nextTargetRefreshTime;
protected int meleePatternCounter;
protected bool isSignaturePatternActive;
protected bool isPreviewingSignatureTelegraph;
protected float signatureAccumulatedDamage;
protected float signatureRequiredDamage;
protected float signatureElapsedTime;
protected float signatureTotalDuration;
/// <summary>
/// 전용 컨텍스트 사용 시 BehaviorGraph를 비활성화할지 여부
@@ -187,6 +193,14 @@ namespace Colosseum.Enemy
/// 시그니처 패턴 진행 여부
/// </summary>
public bool IsSignaturePatternActive => isSignaturePatternActive;
public string SignaturePatternName => isSignaturePatternActive && signaturePattern != null ? signaturePattern.PatternName : string.Empty;
public float SignatureAccumulatedDamage => signatureAccumulatedDamage;
public float SignatureRequiredDamage => signatureRequiredDamage;
public float SignatureBreakProgressNormalized => signatureRequiredDamage > 0f ? Mathf.Clamp01(signatureAccumulatedDamage / signatureRequiredDamage) : 0f;
public float SignatureElapsedTime => signatureElapsedTime;
public float SignatureTotalDuration => signatureTotalDuration;
public float SignatureCastProgressNormalized => signatureTotalDuration > 0f ? Mathf.Clamp01(signatureElapsedTime / signatureTotalDuration) : 0f;
public float SignatureRemainingTime => Mathf.Max(0f, signatureTotalDuration - signatureElapsedTime);
/// <summary>
/// 디버그 로그 출력 여부
@@ -196,7 +210,7 @@ namespace Colosseum.Enemy
/// <summary>
/// 기절 등으로 인해 보스 전투 로직을 진행할 수 없는 상태인지 여부
/// </summary>
public bool IsBehaviorSuppressed => abnormalityManager != null && abnormalityManager.IsStunned;
public bool IsBehaviorSuppressed => isPreviewingSignatureTelegraph || (abnormalityManager != null && abnormalityManager.IsStunned);
/// <summary>
/// 현재 보스 패턴 페이즈
@@ -509,6 +523,35 @@ namespace Colosseum.Enemy
return true;
}
/// <summary>
/// 디버그 또는 특수 연출에서 시그니처 패턴을 강제로 시작합니다.
/// </summary>
public bool ForceStartSignaturePattern(GameObject target = null)
{
if (!IsServer || signaturePattern == null || activePatternCoroutine != null || isSignaturePatternActive)
return false;
GameObject resolvedTarget = IsValidHostileTarget(target) ? target : ResolvePrimaryTarget();
activePatternCoroutine = StartCoroutine(RunSignaturePatternCoroutine(signaturePattern, resolvedTarget));
return true;
}
/// <summary>
/// 네트워크 상태와 무관하게 시그니처 전조 모션만 미리보기로 재생합니다.
/// 전조 연출 확인용 디버그 경로입니다.
/// </summary>
public bool PreviewSignatureTelegraph()
{
if (signaturePattern == null || skillController == null)
return false;
if (activePatternCoroutine != null || isSignaturePatternActive || isPreviewingSignatureTelegraph)
return false;
StartCoroutine(PreviewSignatureTelegraphCoroutine());
return true;
}
protected virtual bool TryStartPrimaryLoopPattern()
{
if (currentTarget == null)
@@ -593,6 +636,7 @@ namespace Colosseum.Enemy
currentTarget = target;
LogDebug(GetType().Name, $"패턴 시작: {pattern.PatternName} / Target={(target != null ? target.name : "None")} / Phase={CurrentPatternPhase}");
CombatBalanceTracker.RecordBossPattern(pattern.PatternName);
activePatternCoroutine = StartCoroutine(RunPatternCoroutine(pattern, target));
}
@@ -747,6 +791,8 @@ namespace Colosseum.Enemy
isSignaturePatternActive = true;
signatureAccumulatedDamage = 0f;
signatureRequiredDamage = bossEnemy.MaxHealth * signatureRequiredDamageRatio;
signatureElapsedTime = 0f;
signatureTotalDuration = CalculatePatternDuration(pattern);
bool interrupted = false;
bool completed = true;
@@ -777,6 +823,7 @@ namespace Colosseum.Enemy
break;
}
signatureElapsedTime += Time.deltaTime;
remaining -= Time.deltaTime;
yield return null;
}
@@ -815,6 +862,7 @@ namespace Colosseum.Enemy
break;
}
signatureElapsedTime += Time.deltaTime;
yield return null;
}
@@ -827,6 +875,7 @@ namespace Colosseum.Enemy
skillController?.CancelSkill(SkillCancelReason.Interrupt);
UsePatternAction.MarkPatternUsed(gameObject, pattern);
LogDebug(GetType().Name, $"시그니처 차단 성공: 누적 피해 {signatureAccumulatedDamage:F1} / 필요 {signatureRequiredDamage:F1}");
CombatBalanceTracker.RecordBossEvent("집행 개시 차단 성공");
if (signatureSuccessStaggerDuration > 0f)
{
@@ -848,15 +897,110 @@ namespace Colosseum.Enemy
{
UsePatternAction.MarkPatternUsed(gameObject, pattern);
LogDebug(GetType().Name, $"시그니처 실패: 누적 피해 {signatureAccumulatedDamage:F1} / 필요 {signatureRequiredDamage:F1}");
CombatBalanceTracker.RecordBossEvent("집행 개시 실패");
ExecuteSignatureFailure();
}
if (abnormalityManager != null && signatureTelegraphAbnormality != null)
{
abnormalityManager.RemoveAbnormality(signatureTelegraphAbnormality);
}
isSignaturePatternActive = false;
signatureAccumulatedDamage = 0f;
signatureRequiredDamage = 0f;
signatureElapsedTime = 0f;
signatureTotalDuration = 0f;
activePatternCoroutine = null;
}
private IEnumerator PreviewSignatureTelegraphCoroutine()
{
bool restoreBehaviorGraph = behaviorGraphAgent != null && behaviorGraphAgent.enabled;
isPreviewingSignatureTelegraph = true;
if (restoreBehaviorGraph)
{
behaviorGraphAgent.enabled = false;
}
StopMovement();
if (skillController != null && skillController.IsPlayingAnimation)
{
skillController.CancelSkill(SkillCancelReason.Interrupt);
yield return null;
}
bool executed = false;
for (int i = 0; i < signaturePattern.Steps.Count; i++)
{
PatternStep step = signaturePattern.Steps[i];
if (step == null || step.Type != PatternStepType.Skill || step.Skill == null)
continue;
executed = skillController.ExecuteSkill(step.Skill);
break;
}
if (executed)
{
while (skillController != null && skillController.IsPlayingAnimation)
{
yield return null;
}
}
if (abnormalityManager != null && signatureTelegraphAbnormality != null)
{
abnormalityManager.RemoveAbnormality(signatureTelegraphAbnormality);
}
if (restoreBehaviorGraph && behaviorGraphAgent != null)
{
behaviorGraphAgent.enabled = true;
}
isPreviewingSignatureTelegraph = false;
}
private static float CalculatePatternDuration(BossPatternData pattern)
{
if (pattern == null || pattern.Steps == null)
return 0f;
float totalDuration = 0f;
for (int i = 0; i < pattern.Steps.Count; i++)
{
PatternStep step = pattern.Steps[i];
if (step == null)
continue;
if (step.Type == PatternStepType.Wait)
{
totalDuration += Mathf.Max(0f, step.Duration);
continue;
}
if (step.Skill == null)
continue;
AnimationClip skillClip = step.Skill.SkillClip;
if (skillClip != null)
{
float animationSpeed = Mathf.Max(0.01f, step.Skill.AnimationSpeed);
totalDuration += skillClip.length / animationSpeed;
}
if (step.Skill.EndClip != null)
{
totalDuration += step.Skill.EndClip.length;
}
}
return totalDuration;
}
private void ExecuteSignatureFailure()
{
PlayerNetworkController[] players = FindObjectsByType<PlayerNetworkController>(FindObjectsSortMode.None);

View File

@@ -5,6 +5,7 @@ using System.Text;
using UnityEngine;
using Unity.Netcode;
using Colosseum.Abnormalities;
using Colosseum.Stats;
using Colosseum.Combat;
using Colosseum.Skills;
@@ -24,6 +25,8 @@ namespace Colosseum.Enemy
[SerializeField] protected Animator animator;
[Tooltip("NavMeshAgent 또는 이동 컴포넌트")]
[SerializeField] protected UnityEngine.AI.NavMeshAgent navMeshAgent;
[Tooltip("이상상태 관리자")]
[SerializeField] protected AbnormalityManager abnormalityManager;
[Header("Data")]
[SerializeField] protected EnemyData enemyData;
@@ -38,11 +41,17 @@ namespace Colosseum.Enemy
[Tooltip("현재 타겟보다 이 값 이상 높을 때만 새 타겟으로 전환합니다.")]
[Min(0f)] [SerializeField] private float retargetThreshold = 0f;
[Header("Shield")]
[Tooltip("보호막 타입이 지정되지 않았을 때 사용할 기본 보호막 이상상태 데이터")]
[SerializeField] private AbnormalityData shieldStateAbnormality;
// 네트워크 동기화 변수
protected NetworkVariable<float> currentHealth = new NetworkVariable<float>(100f);
protected NetworkVariable<float> currentMana = new NetworkVariable<float>(50f);
protected NetworkVariable<bool> isDead = new NetworkVariable<bool>(false);
protected NetworkVariable<float> currentShield = new NetworkVariable<float>(0f);
private readonly ShieldCollection shieldCollection = new ShieldCollection();
// 플레이어 분리용 (레이어 의존 없이 CharacterController로 식별)
private readonly Collider[] overlapBuffer = new Collider[8];
@@ -56,9 +65,9 @@ namespace Colosseum.Enemy
private bool hasJumpTarget = false;
private Vector3 jumpStartXZ;
private Vector3 jumpTargetXZ;
// 이벤트
public event Action<float, float> OnHealthChanged; // currentHealth, maxHealth
public event Action<float, float> OnShieldChanged; // oldShield, newShield
public event Action<float> OnDamageTaken; // damage
public event Action OnDeath;
@@ -67,6 +76,7 @@ namespace Colosseum.Enemy
public float MaxHealth => characterStats != null ? characterStats.MaxHealth : 100f;
public float CurrentMana => currentMana.Value;
public float MaxMana => characterStats != null ? characterStats.MaxMana : 50f;
public float Shield => currentShield.Value;
public bool IsDead => isDead.Value;
public CharacterStats Stats => characterStats;
public EnemyData Data => enemyData;
@@ -82,6 +92,8 @@ namespace Colosseum.Enemy
animator = GetComponentInChildren<Animator>();
if (navMeshAgent == null)
navMeshAgent = GetComponent<UnityEngine.AI.NavMeshAgent>();
if (abnormalityManager == null)
abnormalityManager = GetComponent<AbnormalityManager>();
// 서버에서 초기화
if (IsServer)
@@ -91,12 +103,18 @@ namespace Colosseum.Enemy
// 클라이언트에서 체력 변화 감지
currentHealth.OnValueChanged += OnHealthChangedInternal;
currentShield.OnValueChanged += OnShieldChangedInternal;
}
protected virtual void Update()
{
if (!IsServer || IsDead) return;
if (shieldCollection.Tick(Time.deltaTime))
{
RefreshShieldState();
}
UpdateThreatState(Time.deltaTime);
OnServerUpdate();
}
@@ -233,6 +251,7 @@ namespace Colosseum.Enemy
public override void OnNetworkDespawn()
{
currentHealth.OnValueChanged -= OnHealthChangedInternal;
currentShield.OnValueChanged -= OnShieldChangedInternal;
}
/// <summary>
@@ -255,6 +274,8 @@ namespace Colosseum.Enemy
currentHealth.Value = MaxHealth;
currentMana.Value = MaxMana;
isDead.Value = false;
shieldCollection.Clear();
RefreshShieldState();
}
/// <summary>
@@ -265,9 +286,11 @@ namespace Colosseum.Enemy
if (!IsServer || isDead.Value)
return 0f;
float actualDamage = Mathf.Min(damage, currentHealth.Value);
float mitigatedDamage = ConsumeShield(damage);
float actualDamage = Mathf.Min(mitigatedDamage, currentHealth.Value);
currentHealth.Value = Mathf.Max(0f, currentHealth.Value - actualDamage);
CombatBalanceTracker.RecordDamage(source as GameObject, gameObject, actualDamage);
RegisterThreatFromDamage(actualDamage, source);
OnDamageTaken?.Invoke(actualDamage);
@@ -308,6 +331,20 @@ namespace Colosseum.Enemy
return actualHeal;
}
/// <summary>
/// 보호막을 적용합니다.
/// </summary>
public virtual float ApplyShield(float amount, float duration, AbnormalityData shieldAbnormality = null, GameObject source = null)
{
if (!IsServer || isDead.Value || amount <= 0f)
return 0f;
AbnormalityData shieldType = shieldAbnormality != null ? shieldAbnormality : shieldStateAbnormality;
float actualAppliedShield = shieldCollection.ApplyShield(shieldType, amount, duration, source);
RefreshShieldState();
return actualAppliedShield;
}
/// <summary>
/// 사망 애니메이션 재생 (모든 클라이언트에서 실행)
/// </summary>
@@ -342,6 +379,8 @@ namespace Colosseum.Enemy
protected virtual void HandleDeath()
{
isDead.Value = true;
shieldCollection.Clear();
RefreshShieldState();
ClearAllThreat();
// 실행 중인 스킬 즉시 취소
@@ -398,6 +437,31 @@ namespace Colosseum.Enemy
OnHealthChanged?.Invoke(newValue, MaxHealth);
}
private void OnShieldChangedInternal(float oldValue, float newValue)
{
OnShieldChanged?.Invoke(oldValue, newValue);
}
private float ConsumeShield(float incomingDamage)
{
if (incomingDamage <= 0f || currentShield.Value <= 0f)
return incomingDamage;
float remainingDamage = shieldCollection.ConsumeDamage(incomingDamage);
RefreshShieldState();
return remainingDamage;
}
private void RefreshShieldState()
{
currentShield.Value = shieldCollection.TotalAmount;
ShieldAbnormalityUtility.SyncShieldAbnormalities(
abnormalityManager,
shieldCollection.ActiveShields,
gameObject);
}
/// <summary>
/// 공격자 기준 위협 수치를 누적합니다.
/// </summary>

View File

@@ -21,11 +21,16 @@ namespace Colosseum.Player
[Tooltip("이상상태 관리자 (없으면 자동 검색)")]
[SerializeField] private AbnormalityManager abnormalityManager;
[Header("Shield")]
[Tooltip("보호막 타입이 지정되지 않았을 때 사용할 기본 보호막 이상상태 데이터")]
[SerializeField] private AbnormalityData shieldStateAbnormality;
// 네트워크 동기화 변수
private NetworkVariable<float> currentHealth = new NetworkVariable<float>(100f);
private NetworkVariable<float> currentMana = new NetworkVariable<float>(50f);
private NetworkVariable<bool> isDead = new NetworkVariable<bool>(false);
private NetworkVariable<float> currentShield = new NetworkVariable<float>(0f);
private readonly ShieldCollection shieldCollection = new ShieldCollection();
public float Health => currentHealth.Value;
public float Mana => currentMana.Value;
@@ -72,8 +77,19 @@ namespace Colosseum.Player
{
currentHealth.Value = MaxHealth;
currentMana.Value = MaxMana;
currentShield.Value = 0f;
isDead.Value = false;
RefreshShieldState();
}
}
private void Update()
{
if (!IsServer || isDead.Value)
return;
if (shieldCollection.Tick(Time.deltaTime))
{
RefreshShieldState();
}
}
@@ -112,17 +128,7 @@ namespace Colosseum.Player
[Rpc(SendTo.Server)]
public void TakeDamageRpc(float damage)
{
if (isDead.Value || IsDamageImmune()) return;
float finalDamage = damage * GetIncomingDamageMultiplier();
float mitigatedDamage = ConsumeShield(finalDamage);
float actualDamage = Mathf.Min(mitigatedDamage, currentHealth.Value);
currentHealth.Value = Mathf.Max(0f, currentHealth.Value - actualDamage);
if (currentHealth.Value <= 0f)
{
HandleDeath();
}
ApplyDamageInternal(damage, null);
}
/// <summary>
@@ -179,7 +185,8 @@ namespace Colosseum.Player
if (isDead.Value) return;
isDead.Value = true;
currentShield.Value = 0f;
shieldCollection.Clear();
RefreshShieldState();
// 사망 시 활성 이상 상태를 정리해 리스폰 시 잔존하지 않게 합니다.
if (abnormalityManager != null)
@@ -245,7 +252,8 @@ namespace Colosseum.Player
isDead.Value = false;
currentHealth.Value = MaxHealth;
currentMana.Value = MaxMana;
currentShield.Value = 0f;
shieldCollection.Clear();
RefreshShieldState();
// 이동 재활성화
var movement = GetComponent<PlayerMovement>();
@@ -298,19 +306,7 @@ namespace Colosseum.Player
/// </summary>
public float TakeDamage(float damage, object source = null)
{
if (!IsServer || isDead.Value || IsDamageImmune()) return 0f;
float finalDamage = damage * GetIncomingDamageMultiplier();
float mitigatedDamage = ConsumeShield(finalDamage);
float actualDamage = Mathf.Min(mitigatedDamage, currentHealth.Value);
currentHealth.Value = Mathf.Max(0f, currentHealth.Value - actualDamage);
if (currentHealth.Value <= 0f)
{
HandleDeath();
}
return actualDamage;
return ApplyDamageInternal(damage, source);
}
/// <summary>
@@ -329,18 +325,15 @@ namespace Colosseum.Player
/// <summary>
/// 보호막을 적용합니다.
/// </summary>
public void ApplyShield(float amount, float duration)
public float ApplyShield(float amount, float duration, AbnormalityData shieldAbnormality = null, GameObject source = null)
{
if (!IsServer || isDead.Value || amount <= 0f)
return;
return 0f;
currentShield.Value = Mathf.Max(currentShield.Value, amount);
if (duration > 0f)
{
CancelInvoke(nameof(ClearShield));
Invoke(nameof(ClearShield), duration);
}
AbnormalityData shieldType = shieldAbnormality != null ? shieldAbnormality : shieldStateAbnormality;
float actualAppliedShield = shieldCollection.ApplyShield(shieldType, amount, duration, source);
RefreshShieldState();
return actualAppliedShield;
}
private bool IsDamageImmune()
@@ -361,17 +354,39 @@ namespace Colosseum.Player
if (incomingDamage <= 0f || currentShield.Value <= 0f)
return incomingDamage;
float shieldAbsorb = Mathf.Min(currentShield.Value, incomingDamage);
currentShield.Value = Mathf.Max(0f, currentShield.Value - shieldAbsorb);
return Mathf.Max(0f, incomingDamage - shieldAbsorb);
float remainingDamage = shieldCollection.ConsumeDamage(incomingDamage);
RefreshShieldState();
return remainingDamage;
}
private void ClearShield()
private float ApplyDamageInternal(float damage, object source)
{
if (!IsServer)
return;
if (!IsServer || isDead.Value || IsDamageImmune())
return 0f;
currentShield.Value = 0f;
float finalDamage = damage * GetIncomingDamageMultiplier();
float mitigatedDamage = ConsumeShield(finalDamage);
float actualDamage = Mathf.Min(mitigatedDamage, currentHealth.Value);
currentHealth.Value = Mathf.Max(0f, currentHealth.Value - actualDamage);
CombatBalanceTracker.RecordDamage(source as GameObject, gameObject, actualDamage);
if (currentHealth.Value <= 0f)
{
HandleDeath();
}
return actualDamage;
}
private void RefreshShieldState()
{
currentShield.Value = shieldCollection.TotalAmount;
ShieldAbnormalityUtility.SyncShieldAbnormalities(
abnormalityManager,
shieldCollection.ActiveShields,
gameObject);
}
#endregion
}

View File

@@ -27,7 +27,8 @@ namespace Colosseum.Skills.Effects
var damageable = target.GetComponent<IDamageable>();
if (damageable != null)
{
damageable.Heal(totalHeal);
float actualHeal = damageable.Heal(totalHeal);
CombatBalanceTracker.RecordHeal(caster, target, actualHeal);
}
}

View File

@@ -1,7 +1,10 @@
using UnityEngine;
using Colosseum.Abnormalities;
using Colosseum.Enemy;
using Colosseum.Player;
using Colosseum.Stats;
using Colosseum.Combat;
namespace Colosseum.Skills.Effects
{
@@ -22,17 +25,30 @@ namespace Colosseum.Skills.Effects
[Tooltip("보호막 지속 시간")]
[Min(0f)] [SerializeField] private float duration = 5f;
[Header("Abnormality")]
[Tooltip("보호막 활성 여부를 나타내는 이상상태 데이터")]
[SerializeField] private AbnormalityData shieldStateAbnormality;
protected override void ApplyEffect(GameObject caster, GameObject target)
{
if (target == null)
return;
PlayerNetworkController networkController = target.GetComponent<PlayerNetworkController>();
if (networkController == null)
return;
float totalShield = CalculateShield(caster);
networkController.ApplyShield(totalShield, duration);
PlayerNetworkController playerNetworkController = target.GetComponent<PlayerNetworkController>();
if (playerNetworkController != null)
{
float actualShield = playerNetworkController.ApplyShield(totalShield, duration, shieldStateAbnormality, caster);
CombatBalanceTracker.RecordShield(caster, target, actualShield);
return;
}
EnemyBase enemyBase = target.GetComponent<EnemyBase>();
if (enemyBase != null)
{
float actualShield = enemyBase.ApplyShield(totalShield, duration, shieldStateAbnormality, caster);
CombatBalanceTracker.RecordShield(caster, target, actualShield);
}
}
private float CalculateShield(GameObject caster)

View File

@@ -82,6 +82,7 @@ namespace Colosseum.Skills.Effects
float desiredThreat = Mathf.Max(currentCasterThreat + flatThreatAmount, highestThreat + threatLeadBonus + flatThreatAmount);
enemy.SetThreat(caster, desiredThreat);
CombatBalanceTracker.RecordThreat(caster, Mathf.Max(0f, desiredThreat - currentCasterThreat));
}
}
}

View File

@@ -1,8 +1,11 @@
using System;
using System.Collections.Generic;
using System.Text;
using UnityEngine;
using UnityEngine.UI;
using TMPro;
using Colosseum.Abnormalities;
using Colosseum.Enemy;
namespace Colosseum.UI
@@ -22,6 +25,9 @@ namespace Colosseum.UI
[Tooltip("보스 이름 텍스트")]
[SerializeField] private TMP_Text bossNameText;
[Tooltip("보스 이상상태 요약 텍스트 (비어 있으면 런타임에 자동 생성)")]
[SerializeField] private TMP_Text abnormalitySummaryText;
[Header("Target")]
[Tooltip("추적할 보스 (런타임에 설정 가능)")]
@@ -33,16 +39,31 @@ namespace Colosseum.UI
[Tooltip("슬라이더 값 변환 속도")]
[Min(0f)] [SerializeField] private float lerpSpeed = 5f;
[Tooltip("보스 이상상태 요약 텍스트를 자동 생성할지 여부")]
[SerializeField] private bool autoCreateAbnormalitySummary = true;
[Header("Signature UI")]
[Tooltip("시그니처 패턴 전용 UI를 표시할지 여부")]
[SerializeField] private bool showSignatureUi = false;
[Tooltip("집행 개시 진행도를 표시할 루트 오브젝트")]
[SerializeField] private RectTransform signatureRoot;
[SerializeField] private Image signatureFillImage;
[SerializeField] private TMP_Text signatureNameText;
[SerializeField] private TMP_Text signatureDetailText;
private float displayHealthRatio;
private float targetHealthRatio;
private bool isSubscribed;
private bool isSubscribedToStaticEvent;
private BossCombatBehaviorContext bossCombatContext;
private AbnormalityManager targetAbnormalityManager;
/// <summary>
/// 현재 추적 중인 보스
/// </summary>
public BossEnemy TargetBoss => targetBoss;
public string CurrentAbnormalitySummary => abnormalitySummaryText != null ? abnormalitySummaryText.text : string.Empty;
/// <summary>
/// 보스 수동 설정 (런타임에서 호출)
@@ -53,6 +74,7 @@ namespace Colosseum.UI
UnsubscribeFromBoss();
targetBoss = boss;
targetAbnormalityManager = targetBoss != null ? targetBoss.GetComponent<AbnormalityManager>() : null;
// 새 보스 이벤트 구독
SubscribeToBoss();
@@ -60,12 +82,20 @@ namespace Colosseum.UI
// 초기 UI 업데이트
if (targetBoss != null)
{
bossCombatContext = targetBoss.GetComponent<BossCombatBehaviorContext>();
EnsureAbnormalitySummaryText();
UpdateBossName();
UpdateHealthImmediate();
UpdateAbnormalitySummary();
UpdateSignatureUi();
gameObject.SetActive(true);
}
else
{
bossCombatContext = null;
if (abnormalitySummaryText != null)
abnormalitySummaryText.text = string.Empty;
SetSignatureVisible(false);
gameObject.SetActive(false);
}
}
@@ -93,6 +123,13 @@ namespace Colosseum.UI
if (bossNameText == null)
bossNameText = transform.Find("SliderBox/Label_BossName")?.GetComponent<TMP_Text>();
if (showSignatureUi)
{
EnsureSignatureUi();
}
EnsureAbnormalitySummaryText();
}
private void OnEnable()
@@ -124,8 +161,11 @@ namespace Colosseum.UI
else if (targetBoss != null)
{
SubscribeToBoss();
bossCombatContext = targetBoss.GetComponent<BossCombatBehaviorContext>();
UpdateBossName();
UpdateHealthImmediate();
UpdateAbnormalitySummary();
UpdateSignatureUi();
}
else
{
@@ -145,6 +185,14 @@ namespace Colosseum.UI
UpdateSliderVisual();
}
UpdateSignatureUi();
if (targetBoss != null)
{
UpdateHealthText(targetBoss.CurrentHealth, targetBoss.MaxHealth);
UpdateAbnormalitySummary();
}
}
private void OnDestroy()
{
@@ -218,7 +266,10 @@ namespace Colosseum.UI
{
if (healthText != null)
{
healthText.text = $"{Mathf.CeilToInt(currentHealth)} / {Mathf.CeilToInt(maxHealth)}";
int shieldValue = targetBoss != null ? Mathf.CeilToInt(targetBoss.Shield) : 0;
healthText.text = shieldValue > 0
? $"{Mathf.CeilToInt(currentHealth)} / {Mathf.CeilToInt(maxHealth)} (+{shieldValue})"
: $"{Mathf.CeilToInt(currentHealth)} / {Mathf.CeilToInt(maxHealth)}";
}
}
private void UpdateBossName()
@@ -237,6 +288,221 @@ namespace Colosseum.UI
bossNameText.text = targetBoss.name;
}
}
private void EnsureAbnormalitySummaryText()
{
if (abnormalitySummaryText != null || !autoCreateAbnormalitySummary)
return;
Transform sliderBox = transform.Find("SliderBox");
if (sliderBox == null)
sliderBox = transform;
GameObject summaryObject = new GameObject("Label_BossAbnormalities", typeof(RectTransform), typeof(TextMeshProUGUI));
summaryObject.transform.SetParent(sliderBox, false);
RectTransform rectTransform = summaryObject.GetComponent<RectTransform>();
rectTransform.anchorMin = new Vector2(0f, 1f);
rectTransform.anchorMax = new Vector2(1f, 1f);
rectTransform.pivot = new Vector2(0.5f, 1f);
rectTransform.anchoredPosition = new Vector2(0f, -32f);
rectTransform.sizeDelta = new Vector2(0f, 24f);
TextMeshProUGUI summaryText = summaryObject.GetComponent<TextMeshProUGUI>();
summaryText.fontSize = 16f;
summaryText.alignment = TextAlignmentOptions.MidlineLeft;
summaryText.textWrappingMode = TextWrappingModes.NoWrap;
summaryText.richText = true;
summaryText.text = string.Empty;
if (bossNameText != null && bossNameText.font != null)
{
summaryText.font = bossNameText.font;
summaryText.fontSharedMaterial = bossNameText.fontSharedMaterial;
}
else if (TMP_Settings.defaultFontAsset != null)
{
summaryText.font = TMP_Settings.defaultFontAsset;
}
abnormalitySummaryText = summaryText;
}
private void UpdateAbnormalitySummary()
{
if (abnormalitySummaryText == null)
{
EnsureAbnormalitySummaryText();
}
if (abnormalitySummaryText == null)
return;
if (targetAbnormalityManager == null)
{
abnormalitySummaryText.text = string.Empty;
return;
}
IReadOnlyList<ActiveAbnormality> activeAbnormalities = targetAbnormalityManager.ActiveAbnormalities;
StringBuilder builder = new StringBuilder();
for (int i = 0; i < activeAbnormalities.Count; i++)
{
ActiveAbnormality abnormality = activeAbnormalities[i];
if (abnormality?.Data == null || !abnormality.Data.showInUI)
continue;
if (builder.Length > 0)
builder.Append(" ");
string color = abnormality.Data.isDebuff ? "#FF7070" : "#70D0FF";
builder.Append("<color=");
builder.Append(color);
builder.Append(">");
builder.Append(abnormality.Data.abnormalityName);
if (!abnormality.Data.IsPermanent)
{
builder.Append(" ");
builder.Append(Mathf.CeilToInt(Mathf.Max(0f, abnormality.RemainingDuration)));
builder.Append("s");
}
builder.Append("</color>");
}
abnormalitySummaryText.text = builder.ToString();
}
private void UpdateSignatureUi()
{
if (!showSignatureUi)
{
SetSignatureVisible(false);
return;
}
if (signatureRoot == null)
return;
if (targetBoss == null)
{
SetSignatureVisible(false);
return;
}
if (bossCombatContext == null)
bossCombatContext = targetBoss.GetComponent<BossCombatBehaviorContext>();
if (bossCombatContext == null || !bossCombatContext.IsSignaturePatternActive)
{
SetSignatureVisible(false);
return;
}
SetSignatureVisible(true);
if (signatureNameText != null)
{
signatureNameText.text = string.IsNullOrEmpty(bossCombatContext.SignaturePatternName)
? "시그니처"
: bossCombatContext.SignaturePatternName;
}
if (signatureDetailText != null)
{
signatureDetailText.text =
$"차단 {Mathf.CeilToInt(bossCombatContext.SignatureAccumulatedDamage)} / {Mathf.CeilToInt(bossCombatContext.SignatureRequiredDamage)}" +
$" | {bossCombatContext.SignatureRemainingTime:0.0}s";
}
if (signatureFillImage != null)
{
signatureFillImage.fillAmount = 1f - bossCombatContext.SignatureCastProgressNormalized;
}
}
private void EnsureSignatureUi()
{
if (!showSignatureUi)
return;
if (signatureRoot != null && signatureFillImage != null && signatureNameText != null && signatureDetailText != null)
return;
Transform sliderBox = transform.Find("SliderBox");
if (sliderBox == null)
sliderBox = transform;
GameObject rootObject = new GameObject("SignatureBar", typeof(RectTransform), typeof(Image));
rootObject.transform.SetParent(sliderBox, false);
signatureRoot = rootObject.GetComponent<RectTransform>();
Image backgroundImage = rootObject.GetComponent<Image>();
backgroundImage.color = new Color(0.08f, 0.08f, 0.08f, 0.88f);
signatureRoot.anchorMin = new Vector2(0f, 0f);
signatureRoot.anchorMax = new Vector2(1f, 0f);
signatureRoot.pivot = new Vector2(0.5f, 1f);
signatureRoot.anchoredPosition = new Vector2(0f, -48f);
signatureRoot.sizeDelta = new Vector2(0f, 42f);
GameObject fillObject = new GameObject("Fill", typeof(RectTransform), typeof(Image));
fillObject.transform.SetParent(signatureRoot, false);
RectTransform fillRect = fillObject.GetComponent<RectTransform>();
signatureFillImage = fillObject.GetComponent<Image>();
signatureFillImage.color = new Color(0.88f, 0.48f, 0.12f, 0.95f);
signatureFillImage.type = Image.Type.Filled;
signatureFillImage.fillMethod = Image.FillMethod.Horizontal;
signatureFillImage.fillOrigin = 0;
signatureFillImage.fillAmount = 1f;
fillRect.anchorMin = new Vector2(0f, 0f);
fillRect.anchorMax = new Vector2(1f, 1f);
fillRect.offsetMin = new Vector2(2f, 2f);
fillRect.offsetMax = new Vector2(-2f, -2f);
signatureNameText = CreateSignatureText("Label_SignatureName", TextAlignmentOptions.TopLeft, 18f, FontStyles.Bold);
signatureDetailText = CreateSignatureText("Label_SignatureDetail", TextAlignmentOptions.TopRight, 15f, FontStyles.Normal);
SetSignatureVisible(false);
}
private TMP_Text CreateSignatureText(string objectName, TextAlignmentOptions alignment, float fontSize, FontStyles fontStyle)
{
GameObject textObject = new GameObject(objectName, typeof(RectTransform), typeof(TextMeshProUGUI));
textObject.transform.SetParent(signatureRoot, false);
RectTransform rectTransform = textObject.GetComponent<RectTransform>();
rectTransform.anchorMin = new Vector2(0f, 0f);
rectTransform.anchorMax = new Vector2(1f, 1f);
rectTransform.offsetMin = new Vector2(6f, 4f);
rectTransform.offsetMax = new Vector2(-6f, -4f);
TMP_Text text = textObject.GetComponent<TextMeshProUGUI>();
text.alignment = alignment;
text.fontSize = fontSize;
text.fontStyle = fontStyle;
text.color = Color.white;
text.textWrappingMode = TextWrappingModes.NoWrap;
if (bossNameText != null && bossNameText.font != null)
{
text.font = bossNameText.font;
text.fontSharedMaterial = bossNameText.fontSharedMaterial;
}
return text;
}
private void SetSignatureVisible(bool visible)
{
if (signatureRoot == null || signatureRoot.gameObject.activeSelf == visible)
return;
signatureRoot.gameObject.SetActive(visible);
}
#if UNITY_EDITOR
private void OnValidate()
{