feat: 방어 시스템과 드로그 검증 경로 정리

- 애니메이션 이벤트 기반 방어/유지/해제 흐름과 HUD 피드백, 방어 디버그 로그를 추가했다.
- 드로그 기본기1 테스트 패턴을 정리하고 공격 판정을 OnEffect 기반으로 옮기며 드로그 범위 효과의 타겟 레이어를 정상화했다.
- 플레이어 퀵슬롯 테스트 세팅과 적-플레이어 겹침 방지 로직을 조정해 충돌 시 적이 수평 이동을 멈추고 최소 분리만 수행하게 했다.
This commit is contained in:
2026-04-07 21:28:52 +09:00
parent 147e9baa25
commit 0c9967d131
72 changed files with 231096 additions and 698 deletions

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -35,11 +35,28 @@ MonoBehaviour:
cooldown: 0 cooldown: 0
manaCost: 0 manaCost: 0
maxGemSlotCount: 0 maxGemSlotCount: 0
castStartEffects: castStartEffects: []
- {fileID: 11400000, guid: 7c2a987172742a64ebd9d0e424250e1f, type: 2} triggeredEffects:
- {fileID: 11400000, guid: 7d44a93a1fd71b7c58d35432b2f14eb6, type: 2} - triggerIndex: 0
triggeredEffects: [] effects:
- {fileID: 11400000, guid: 7c2a987172742a64ebd9d0e424250e1f, type: 2}
- {fileID: 11400000, guid: 7d44a93a1fd71b7c58d35432b2f14eb6, type: 2}
isChanneling: 0 isChanneling: 0
loopPhase:
enabled: 0
loopMode: 1
maxDuration: 3
tickInterval: 0.5
tickEffects: []
exitEffects: []
loopVfxPrefab: {fileID: 0}
loopVfxMountPath:
loopVfxLengthScale: 1
loopVfxWidthScale: 1
releasePhase:
enabled: 0
animationClips: []
startEffects: []
channelDuration: 3 channelDuration: 3
channelTickInterval: 0.5 channelTickInterval: 0.5
channelTickEffects: [] channelTickEffects: []

View File

@@ -38,6 +38,21 @@ MonoBehaviour:
castStartEffects: [] castStartEffects: []
triggeredEffects: [] triggeredEffects: []
isChanneling: 0 isChanneling: 0
loopPhase:
enabled: 0
loopMode: 1
maxDuration: 3
tickInterval: 0.5
tickEffects: []
exitEffects: []
loopVfxPrefab: {fileID: 0}
loopVfxMountPath:
loopVfxLengthScale: 1
loopVfxWidthScale: 1
releasePhase:
enabled: 0
animationClips: []
startEffects: []
channelDuration: 3 channelDuration: 3
channelTickInterval: 0.5 channelTickInterval: 0.5
channelTickEffects: [] channelTickEffects: []

View File

@@ -38,6 +38,21 @@ MonoBehaviour:
castStartEffects: [] castStartEffects: []
triggeredEffects: [] triggeredEffects: []
isChanneling: 0 isChanneling: 0
loopPhase:
enabled: 0
loopMode: 1
maxDuration: 3
tickInterval: 0.5
tickEffects: []
exitEffects: []
loopVfxPrefab: {fileID: 0}
loopVfxMountPath:
loopVfxLengthScale: 1
loopVfxWidthScale: 1
releasePhase:
enabled: 0
animationClips: []
startEffects: []
channelDuration: 3 channelDuration: 3
channelTickInterval: 0.5 channelTickInterval: 0.5
channelTickEffects: [] channelTickEffects: []

View File

@@ -35,11 +35,28 @@ MonoBehaviour:
cooldown: 0 cooldown: 0
manaCost: 0 manaCost: 0
maxGemSlotCount: 0 maxGemSlotCount: 0
castStartEffects: castStartEffects: []
- {fileID: 11400000, guid: 7aaebc3aef634dbeb912bd6fa3d0bbed, type: 2} triggeredEffects:
- {fileID: 11400000, guid: 39a0d364caa080e16a6bb8c1fdb5a119, type: 2} - triggerIndex: 0
triggeredEffects: [] effects:
- {fileID: 11400000, guid: 7aaebc3aef634dbeb912bd6fa3d0bbed, type: 2}
- {fileID: 11400000, guid: 39a0d364caa080e16a6bb8c1fdb5a119, type: 2}
isChanneling: 0 isChanneling: 0
loopPhase:
enabled: 0
loopMode: 1
maxDuration: 3
tickInterval: 0.5
tickEffects: []
exitEffects: []
loopVfxPrefab: {fileID: 0}
loopVfxMountPath:
loopVfxLengthScale: 1
loopVfxWidthScale: 1
releasePhase:
enabled: 0
animationClips: []
startEffects: []
channelDuration: 3 channelDuration: 3
channelTickInterval: 0.5 channelTickInterval: 0.5
channelTickEffects: [] channelTickEffects: []

View File

@@ -35,11 +35,28 @@ MonoBehaviour:
cooldown: 0 cooldown: 0
manaCost: 0 manaCost: 0
maxGemSlotCount: 0 maxGemSlotCount: 0
castStartEffects: castStartEffects: []
- {fileID: 11400000, guid: 9aff354899593121f89a55ada9ed27c8, type: 2} triggeredEffects:
- {fileID: 11400000, guid: 86d49ab180b62b395a64b1ba88045d97, type: 2} - triggerIndex: 0
triggeredEffects: [] effects:
- {fileID: 11400000, guid: 9aff354899593121f89a55ada9ed27c8, type: 2}
- {fileID: 11400000, guid: 86d49ab180b62b395a64b1ba88045d97, type: 2}
isChanneling: 0 isChanneling: 0
loopPhase:
enabled: 0
loopMode: 1
maxDuration: 3
tickInterval: 0.5
tickEffects: []
exitEffects: []
loopVfxPrefab: {fileID: 0}
loopVfxMountPath:
loopVfxLengthScale: 1
loopVfxWidthScale: 1
releasePhase:
enabled: 0
animationClips: []
startEffects: []
channelDuration: 3 channelDuration: 3
channelTickInterval: 0.5 channelTickInterval: 0.5
channelTickEffects: [] channelTickEffects: []

View File

@@ -35,11 +35,28 @@ MonoBehaviour:
cooldown: 0 cooldown: 0
manaCost: 0 manaCost: 0
maxGemSlotCount: 0 maxGemSlotCount: 0
castStartEffects: castStartEffects: []
- {fileID: 11400000, guid: 0f134a897a7e4d0e98c8d9058b1d79d1, type: 2} triggeredEffects:
- {fileID: 11400000, guid: 216d4b5f6ce9479e94e0d306399f4891, type: 2} - triggerIndex: 0
triggeredEffects: [] effects:
- {fileID: 11400000, guid: 0f134a897a7e4d0e98c8d9058b1d79d1, type: 2}
- {fileID: 11400000, guid: 216d4b5f6ce9479e94e0d306399f4891, type: 2}
isChanneling: 0 isChanneling: 0
loopPhase:
enabled: 0
loopMode: 1
maxDuration: 3
tickInterval: 0.5
tickEffects: []
exitEffects: []
loopVfxPrefab: {fileID: 0}
loopVfxMountPath:
loopVfxLengthScale: 1
loopVfxWidthScale: 1
releasePhase:
enabled: 0
animationClips: []
startEffects: []
channelDuration: 3 channelDuration: 3
channelTickInterval: 0.5 channelTickInterval: 0.5
channelTickEffects: [] channelTickEffects: []

View File

@@ -34,10 +34,27 @@ MonoBehaviour:
cooldown: 0 cooldown: 0
manaCost: 0 manaCost: 0
maxGemSlotCount: 0 maxGemSlotCount: 0
castStartEffects: castStartEffects: []
- {fileID: 11400000, guid: 3308562375e06dd89a551eba7fd828e3, type: 2} triggeredEffects:
triggeredEffects: [] - triggerIndex: 0
effects:
- {fileID: 11400000, guid: 3308562375e06dd89a551eba7fd828e3, type: 2}
isChanneling: 0 isChanneling: 0
loopPhase:
enabled: 0
loopMode: 1
maxDuration: 3
tickInterval: 0.5
tickEffects: []
exitEffects: []
loopVfxPrefab: {fileID: 0}
loopVfxMountPath:
loopVfxLengthScale: 1
loopVfxWidthScale: 1
releasePhase:
enabled: 0
animationClips: []
startEffects: []
channelDuration: 3 channelDuration: 3
channelTickInterval: 0.5 channelTickInterval: 0.5
channelTickEffects: [] channelTickEffects: []

View File

@@ -34,10 +34,27 @@ MonoBehaviour:
cooldown: 0 cooldown: 0
manaCost: 0 manaCost: 0
maxGemSlotCount: 0 maxGemSlotCount: 0
castStartEffects: castStartEffects: []
- {fileID: 11400000, guid: 52f075314ea0eb73aa4f335fb42046d3, type: 2} triggeredEffects:
triggeredEffects: [] - triggerIndex: 0
effects:
- {fileID: 11400000, guid: 52f075314ea0eb73aa4f335fb42046d3, type: 2}
isChanneling: 0 isChanneling: 0
loopPhase:
enabled: 0
loopMode: 1
maxDuration: 3
tickInterval: 0.5
tickEffects: []
exitEffects: []
loopVfxPrefab: {fileID: 0}
loopVfxMountPath:
loopVfxLengthScale: 1
loopVfxWidthScale: 1
releasePhase:
enabled: 0
animationClips: []
startEffects: []
channelDuration: 3 channelDuration: 3
channelTickInterval: 0.5 channelTickInterval: 0.5
channelTickEffects: [] channelTickEffects: []

View File

@@ -34,10 +34,27 @@ MonoBehaviour:
cooldown: 0 cooldown: 0
manaCost: 0 manaCost: 0
maxGemSlotCount: 0 maxGemSlotCount: 0
castStartEffects: castStartEffects: []
- {fileID: 11400000, guid: 8dd051fa8183f9809acbb44a9b45595d, type: 2} triggeredEffects:
triggeredEffects: [] - triggerIndex: 0
effects:
- {fileID: 11400000, guid: 8dd051fa8183f9809acbb44a9b45595d, type: 2}
isChanneling: 0 isChanneling: 0
loopPhase:
enabled: 0
loopMode: 1
maxDuration: 3
tickInterval: 0.5
tickEffects: []
exitEffects: []
loopVfxPrefab: {fileID: 0}
loopVfxMountPath:
loopVfxLengthScale: 1
loopVfxWidthScale: 1
releasePhase:
enabled: 0
animationClips: []
startEffects: []
channelDuration: 3 channelDuration: 3
channelTickInterval: 0.5 channelTickInterval: 0.5
channelTickEffects: [] channelTickEffects: []

View File

@@ -37,6 +37,21 @@ MonoBehaviour:
castStartEffects: [] castStartEffects: []
triggeredEffects: [] triggeredEffects: []
isChanneling: 0 isChanneling: 0
loopPhase:
enabled: 0
loopMode: 1
maxDuration: 3
tickInterval: 0.5
tickEffects: []
exitEffects: []
loopVfxPrefab: {fileID: 0}
loopVfxMountPath:
loopVfxLengthScale: 1
loopVfxWidthScale: 1
releasePhase:
enabled: 0
animationClips: []
startEffects: []
channelDuration: 3 channelDuration: 3
channelTickInterval: 0.5 channelTickInterval: 0.5
channelTickEffects: [] channelTickEffects: []

View File

@@ -34,10 +34,27 @@ MonoBehaviour:
cooldown: 0 cooldown: 0
manaCost: 0 manaCost: 0
maxGemSlotCount: 0 maxGemSlotCount: 0
castStartEffects: castStartEffects: []
- {fileID: 11400000, guid: c3acb7cffe4a84368adbca89fb6363e5, type: 2} triggeredEffects:
triggeredEffects: [] - triggerIndex: 0
effects:
- {fileID: 11400000, guid: c3acb7cffe4a84368adbca89fb6363e5, type: 2}
isChanneling: 0 isChanneling: 0
loopPhase:
enabled: 0
loopMode: 1
maxDuration: 3
tickInterval: 0.5
tickEffects: []
exitEffects: []
loopVfxPrefab: {fileID: 0}
loopVfxMountPath:
loopVfxLengthScale: 1
loopVfxWidthScale: 1
releasePhase:
enabled: 0
animationClips: []
startEffects: []
channelDuration: 3 channelDuration: 3
channelTickInterval: 0.5 channelTickInterval: 0.5
channelTickEffects: [] channelTickEffects: []

View File

@@ -35,10 +35,27 @@ MonoBehaviour:
cooldown: 0 cooldown: 0
manaCost: 0 manaCost: 0
maxGemSlotCount: 0 maxGemSlotCount: 0
castStartEffects: castStartEffects: []
- {fileID: 11400000, guid: a3fea66d68f45e93b9ed16f64e777363, type: 2} triggeredEffects:
triggeredEffects: [] - triggerIndex: 0
effects:
- {fileID: 11400000, guid: a3fea66d68f45e93b9ed16f64e777363, type: 2}
isChanneling: 0 isChanneling: 0
loopPhase:
enabled: 0
loopMode: 1
maxDuration: 3
tickInterval: 0.5
tickEffects: []
exitEffects: []
loopVfxPrefab: {fileID: 0}
loopVfxMountPath:
loopVfxLengthScale: 1
loopVfxWidthScale: 1
releasePhase:
enabled: 0
animationClips: []
startEffects: []
channelDuration: 3 channelDuration: 3
channelTickInterval: 0.5 channelTickInterval: 0.5
channelTickEffects: [] channelTickEffects: []

View File

@@ -35,10 +35,27 @@ MonoBehaviour:
cooldown: 0 cooldown: 0
manaCost: 0 manaCost: 0
maxGemSlotCount: 0 maxGemSlotCount: 0
castStartEffects: castStartEffects: []
- {fileID: 11400000, guid: 7c6dcda007f2749e584594e4645dfb49, type: 2} triggeredEffects:
triggeredEffects: [] - triggerIndex: 0
effects:
- {fileID: 11400000, guid: 7c6dcda007f2749e584594e4645dfb49, type: 2}
isChanneling: 0 isChanneling: 0
loopPhase:
enabled: 0
loopMode: 1
maxDuration: 3
tickInterval: 0.5
tickEffects: []
exitEffects: []
loopVfxPrefab: {fileID: 0}
loopVfxMountPath:
loopVfxLengthScale: 1
loopVfxWidthScale: 1
releasePhase:
enabled: 0
animationClips: []
startEffects: []
channelDuration: 3 channelDuration: 3
channelTickInterval: 0.5 channelTickInterval: 0.5
channelTickEffects: [] channelTickEffects: []

View File

@@ -34,10 +34,27 @@ MonoBehaviour:
cooldown: 0 cooldown: 0
manaCost: 0 manaCost: 0
maxGemSlotCount: 0 maxGemSlotCount: 0
castStartEffects: castStartEffects: []
- {fileID: 11400000, guid: 135f4690ea9c62bd8835b97c7ace22d7, type: 2} triggeredEffects:
triggeredEffects: [] - triggerIndex: 0
effects:
- {fileID: 11400000, guid: 135f4690ea9c62bd8835b97c7ace22d7, type: 2}
isChanneling: 0 isChanneling: 0
loopPhase:
enabled: 0
loopMode: 1
maxDuration: 3
tickInterval: 0.5
tickEffects: []
exitEffects: []
loopVfxPrefab: {fileID: 0}
loopVfxMountPath:
loopVfxLengthScale: 1
loopVfxWidthScale: 1
releasePhase:
enabled: 0
animationClips: []
startEffects: []
channelDuration: 3 channelDuration: 3
channelTickInterval: 0.5 channelTickInterval: 0.5
channelTickEffects: [] channelTickEffects: []

View File

@@ -36,10 +36,27 @@ MonoBehaviour:
cooldown: 0 cooldown: 0
manaCost: 0 manaCost: 0
maxGemSlotCount: 0 maxGemSlotCount: 0
castStartEffects: castStartEffects: []
- {fileID: 11400000, guid: 3d9ee31e7f777725fa5d08ff31f8f6d1, type: 2} triggeredEffects:
triggeredEffects: [] - triggerIndex: 0
effects:
- {fileID: 11400000, guid: 3d9ee31e7f777725fa5d08ff31f8f6d1, type: 2}
isChanneling: 0 isChanneling: 0
loopPhase:
enabled: 0
loopMode: 1
maxDuration: 3
tickInterval: 0.5
tickEffects: []
exitEffects: []
loopVfxPrefab: {fileID: 0}
loopVfxMountPath:
loopVfxLengthScale: 1
loopVfxWidthScale: 1
releasePhase:
enabled: 0
animationClips: []
startEffects: []
channelDuration: 3 channelDuration: 3
channelTickInterval: 0.5 channelTickInterval: 0.5
channelTickEffects: [] channelTickEffects: []

View File

@@ -34,10 +34,27 @@ MonoBehaviour:
cooldown: 0 cooldown: 0
manaCost: 0 manaCost: 0
maxGemSlotCount: 0 maxGemSlotCount: 0
castStartEffects: castStartEffects: []
- {fileID: 11400000, guid: fdbbc8f5dc30568cf934a62595987d5c, type: 2} triggeredEffects:
triggeredEffects: [] - triggerIndex: 0
effects:
- {fileID: 11400000, guid: fdbbc8f5dc30568cf934a62595987d5c, type: 2}
isChanneling: 0 isChanneling: 0
loopPhase:
enabled: 0
loopMode: 1
maxDuration: 3
tickInterval: 0.5
tickEffects: []
exitEffects: []
loopVfxPrefab: {fileID: 0}
loopVfxMountPath:
loopVfxLengthScale: 1
loopVfxWidthScale: 1
releasePhase:
enabled: 0
animationClips: []
startEffects: []
channelDuration: 3 channelDuration: 3
channelTickInterval: 0.5 channelTickInterval: 0.5
channelTickEffects: [] channelTickEffects: []

View File

@@ -34,10 +34,27 @@ MonoBehaviour:
cooldown: 0 cooldown: 0
manaCost: 0 manaCost: 0
maxGemSlotCount: 0 maxGemSlotCount: 0
castStartEffects: castStartEffects: []
- {fileID: 11400000, guid: 9a363d0ace156cebba88aa01565c0a55, type: 2} triggeredEffects:
triggeredEffects: [] - triggerIndex: 0
effects:
- {fileID: 11400000, guid: 9a363d0ace156cebba88aa01565c0a55, type: 2}
isChanneling: 0 isChanneling: 0
loopPhase:
enabled: 0
loopMode: 1
maxDuration: 3
tickInterval: 0.5
tickEffects: []
exitEffects: []
loopVfxPrefab: {fileID: 0}
loopVfxMountPath:
loopVfxLengthScale: 1
loopVfxWidthScale: 1
releasePhase:
enabled: 0
animationClips: []
startEffects: []
channelDuration: 3 channelDuration: 3
channelTickInterval: 0.5 channelTickInterval: 0.5
channelTickEffects: [] channelTickEffects: []

View File

@@ -34,10 +34,27 @@ MonoBehaviour:
cooldown: 0 cooldown: 0
manaCost: 0 manaCost: 0
maxGemSlotCount: 0 maxGemSlotCount: 0
castStartEffects: castStartEffects: []
- {fileID: 11400000, guid: 3112e5c033c4381c68a0c4d1924a738e, type: 2} triggeredEffects:
triggeredEffects: [] - triggerIndex: 0
effects:
- {fileID: 11400000, guid: 3112e5c033c4381c68a0c4d1924a738e, type: 2}
isChanneling: 0 isChanneling: 0
loopPhase:
enabled: 0
loopMode: 1
maxDuration: 3
tickInterval: 0.5
tickEffects: []
exitEffects: []
loopVfxPrefab: {fileID: 0}
loopVfxMountPath:
loopVfxLengthScale: 1
loopVfxWidthScale: 1
releasePhase:
enabled: 0
animationClips: []
startEffects: []
channelDuration: 3 channelDuration: 3
channelTickInterval: 0.5 channelTickInterval: 0.5
channelTickEffects: [] channelTickEffects: []

View File

@@ -34,10 +34,27 @@ MonoBehaviour:
cooldown: 0 cooldown: 0
manaCost: 0 manaCost: 0
maxGemSlotCount: 0 maxGemSlotCount: 0
castStartEffects: castStartEffects: []
- {fileID: 11400000, guid: 7061e70acfcf6971a8b451af29336e8a, type: 2} triggeredEffects:
triggeredEffects: [] - triggerIndex: 0
effects:
- {fileID: 11400000, guid: 7061e70acfcf6971a8b451af29336e8a, type: 2}
isChanneling: 0 isChanneling: 0
loopPhase:
enabled: 0
loopMode: 1
maxDuration: 3
tickInterval: 0.5
tickEffects: []
exitEffects: []
loopVfxPrefab: {fileID: 0}
loopVfxMountPath:
loopVfxLengthScale: 1
loopVfxWidthScale: 1
releasePhase:
enabled: 0
animationClips: []
startEffects: []
channelDuration: 3 channelDuration: 3
channelTickInterval: 0.5 channelTickInterval: 0.5
channelTickEffects: [] channelTickEffects: []

View File

@@ -34,10 +34,27 @@ MonoBehaviour:
cooldown: 0 cooldown: 0
manaCost: 0 manaCost: 0
maxGemSlotCount: 0 maxGemSlotCount: 0
castStartEffects: castStartEffects: []
- {fileID: 11400000, guid: ac7e8e62a369ee81195c6bf8fbebf25a, type: 2} triggeredEffects:
triggeredEffects: [] - triggerIndex: 0
effects:
- {fileID: 11400000, guid: ac7e8e62a369ee81195c6bf8fbebf25a, type: 2}
isChanneling: 0 isChanneling: 0
loopPhase:
enabled: 0
loopMode: 1
maxDuration: 3
tickInterval: 0.5
tickEffects: []
exitEffects: []
loopVfxPrefab: {fileID: 0}
loopVfxMountPath:
loopVfxLengthScale: 1
loopVfxWidthScale: 1
releasePhase:
enabled: 0
animationClips: []
startEffects: []
channelDuration: 3 channelDuration: 3
channelTickInterval: 0.5 channelTickInterval: 0.5
channelTickEffects: [] channelTickEffects: []

View File

@@ -35,10 +35,27 @@ MonoBehaviour:
cooldown: 0 cooldown: 0
manaCost: 0 manaCost: 0
maxGemSlotCount: 0 maxGemSlotCount: 0
castStartEffects: castStartEffects: []
- {fileID: 11400000, guid: a5f62aa768348b13ab9844fa521c5728, type: 2} triggeredEffects:
triggeredEffects: [] - triggerIndex: 0
effects:
- {fileID: 11400000, guid: a5f62aa768348b13ab9844fa521c5728, type: 2}
isChanneling: 0 isChanneling: 0
loopPhase:
enabled: 0
loopMode: 1
maxDuration: 3
tickInterval: 0.5
tickEffects: []
exitEffects: []
loopVfxPrefab: {fileID: 0}
loopVfxMountPath:
loopVfxLengthScale: 1
loopVfxWidthScale: 1
releasePhase:
enabled: 0
animationClips: []
startEffects: []
channelDuration: 3 channelDuration: 3
channelTickInterval: 0.5 channelTickInterval: 0.5
channelTickEffects: [] channelTickEffects: []

View File

@@ -35,10 +35,27 @@ MonoBehaviour:
cooldown: 0 cooldown: 0
manaCost: 0 manaCost: 0
maxGemSlotCount: 0 maxGemSlotCount: 0
castStartEffects: castStartEffects: []
- {fileID: 11400000, guid: 8b2a9b2de17f448a4a3e7d3d603688b7, type: 2} triggeredEffects:
triggeredEffects: [] - triggerIndex: 0
effects:
- {fileID: 11400000, guid: 8b2a9b2de17f448a4a3e7d3d603688b7, type: 2}
isChanneling: 0 isChanneling: 0
loopPhase:
enabled: 0
loopMode: 1
maxDuration: 3
tickInterval: 0.5
tickEffects: []
exitEffects: []
loopVfxPrefab: {fileID: 0}
loopVfxMountPath:
loopVfxLengthScale: 1
loopVfxWidthScale: 1
releasePhase:
enabled: 0
animationClips: []
startEffects: []
channelDuration: 3 channelDuration: 3
channelTickInterval: 0.5 channelTickInterval: 0.5
channelTickEffects: [] channelTickEffects: []

View File

@@ -37,6 +37,21 @@ MonoBehaviour:
castStartEffects: [] castStartEffects: []
triggeredEffects: [] triggeredEffects: []
isChanneling: 0 isChanneling: 0
loopPhase:
enabled: 0
loopMode: 1
maxDuration: 3
tickInterval: 0.5
tickEffects: []
exitEffects: []
loopVfxPrefab: {fileID: 0}
loopVfxMountPath:
loopVfxLengthScale: 1
loopVfxWidthScale: 1
releasePhase:
enabled: 0
animationClips: []
startEffects: []
channelDuration: 3 channelDuration: 3
channelTickInterval: 0.5 channelTickInterval: 0.5
channelTickEffects: [] channelTickEffects: []

View File

@@ -34,13 +34,28 @@ MonoBehaviour:
maxGemSlotCount: 2 maxGemSlotCount: 2
castStartEffects: [] castStartEffects: []
triggeredEffects: [] triggeredEffects: []
loopPhase:
enabled: 1
loopMode: 1
maxDuration: 3
tickInterval: 0.5
tickEffects:
- {fileID: 11400000, guid: 5439a9a24502924f6302c2493e61e527, type: 2}
exitEffects: []
loopVfxPrefab: {fileID: 1062685050423962, guid: 75ec5047abb8242419c33baf6ca45ca8, type: 3}
loopVfxMountPath: CastPoint
loopVfxLengthScale: 1
loopVfxWidthScale: 1
releasePhase:
enabled: 0
animationClips: []
startEffects: []
isChanneling: 1 isChanneling: 1
channelDuration: 3 channelDuration: 3
channelTickInterval: 0.5 channelTickInterval: 0.5
channelTickEffects: channelTickEffects: []
- {fileID: 11400000, guid: 5439a9a24502924f6302c2493e61e527, type: 2}
channelEndEffects: [] channelEndEffects: []
channelVfxPrefab: {fileID: 1062685050423962, guid: 75ec5047abb8242419c33baf6ca45ca8, type: 3} channelVfxPrefab: {fileID: 0}
channelVfxMountPath: CastPoint channelVfxMountPath:
channelVfxLengthScale: 1 channelVfxLengthScale: 1
channelVfxWidthScale: 1 channelVfxWidthScale: 1

View File

@@ -32,13 +32,28 @@ MonoBehaviour:
maxGemSlotCount: 2 maxGemSlotCount: 2
castStartEffects: [] castStartEffects: []
triggeredEffects: [] triggeredEffects: []
loopPhase:
enabled: 1
loopMode: 1
maxDuration: 2.5
tickInterval: 0.5
tickEffects:
- {fileID: 11400000, guid: c4a8062454924a3fd863efd0b8479e0a, type: 2}
exitEffects: []
loopVfxPrefab: {fileID: 100100000, guid: b9aac1c232ed68c44be97372b7fc6914, type: 3}
loopVfxMountPath: CastPoint
loopVfxLengthScale: 1
loopVfxWidthScale: 1
releasePhase:
enabled: 0
animationClips: []
startEffects: []
isChanneling: 1 isChanneling: 1
channelDuration: 2.5 channelDuration: 3
channelTickInterval: 0.5 channelTickInterval: 0.5
channelTickEffects: channelTickEffects: []
- {fileID: 11400000, guid: c4a8062454924a3fd863efd0b8479e0a, type: 2}
channelEndEffects: [] channelEndEffects: []
channelVfxPrefab: {fileID: 100100000, guid: b9aac1c232ed68c44be97372b7fc6914, type: 3} channelVfxPrefab: {fileID: 0}
channelVfxMountPath: CastPoint channelVfxMountPath:
channelVfxLengthScale: 1 channelVfxLengthScale: 1
channelVfxWidthScale: 1 channelVfxWidthScale: 1

View File

@@ -0,0 +1,64 @@
%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: 94f0a76cebcac2f4fb5daf1b675fd79f, type: 3}
m_Name: "Data_Skill_Player_\uBC29\uD328_\uBC29\uC5B4"
m_EditorClassIdentifier: Colosseum.Game::Colosseum.Skills.SkillData
skillName: "\uBC29\uC5B4"
description: "\uBC84\uD2BC\uC744 \uB204\uB974\uACE0 \uC788\uB294 \uB3D9\uC548 \uC815\uBA74
\uBC29\uD5A5\uC5D0\uC11C \uB4E4\uC5B4\uC624\uB294 \uD53C\uD574\uB97C \uBC84\uD2F4\uB2E4.
\uC720\uC9C0 \uC911\uC5D0\uB294 \uB9C8\uB098\uAC00 \uACC4\uC18D \uC18C\uBAA8\uB41C\uB2E4."
icon: {fileID: 0}
skillRole: 2
activationType: 1
baseTypes: 2
animationClips:
- {fileID: 7400000, guid: 81732d3b50fdd8645b9465a4f12a99ae, type: 2}
animationSpeed: 1.5
useRootMotion: 0
ignoreRootMotionY: 1
jumpToTarget: 0
blockMovementWhileCasting: 0
blockJumpWhileCasting: 1
blockOtherSkillsWhileCasting: 1
castTargetTrackingMode: 0
castTargetRotationSpeed: 12
castTargetStopDistance: 0
allowedWeaponTraits: 0
cooldown: 0
manaCost: 10
maxGemSlotCount: 2
castStartEffects: []
triggeredEffects: []
isChanneling: 1
loopPhase:
enabled: 1
loopMode: 2
maxDuration: 0
tickInterval: 0.25
tickEffects: []
exitEffects: []
loopVfxPrefab: {fileID: 0}
loopVfxMountPath:
loopVfxLengthScale: 1
loopVfxWidthScale: 1
releasePhase:
enabled: 0
animationClips: []
startEffects: []
channelDuration: 3
channelTickInterval: 0.5
channelTickEffects: []
channelEndEffects: []
channelVfxPrefab: {fileID: 0}
channelVfxMountPath:
channelVfxLengthScale: 1
channelVfxWidthScale: 1

View File

@@ -18,7 +18,7 @@ MonoBehaviour:
areaShape: 1 areaShape: 1
targetLayers: targetLayers:
serializedVersion: 2 serializedVersion: 2
m_Bits: 0 m_Bits: 4294967295
includeCasterInArea: 0 includeCasterInArea: 0
areaRadius: 3.4 areaRadius: 3.4
fanOriginDistance: 1.2 fanOriginDistance: 1.2
@@ -27,3 +27,4 @@ MonoBehaviour:
baseDamage: 48 baseDamage: 48
damageType: 0 damageType: 0
statScaling: 1.15 statScaling: 1.15
mitigationTier: 0

View File

@@ -18,7 +18,7 @@ MonoBehaviour:
areaShape: 1 areaShape: 1
targetLayers: targetLayers:
serializedVersion: 2 serializedVersion: 2
m_Bits: 0 m_Bits: 4294967295
includeCasterInArea: 0 includeCasterInArea: 0
areaRadius: 3.4 areaRadius: 3.4
fanOriginDistance: 1.2 fanOriginDistance: 1.2

View File

@@ -18,7 +18,7 @@ MonoBehaviour:
areaShape: 0 areaShape: 0
targetLayers: targetLayers:
serializedVersion: 2 serializedVersion: 2
m_Bits: 0 m_Bits: 4294967295
includeCasterInArea: 0 includeCasterInArea: 0
areaRadius: 4.2 areaRadius: 4.2
fanOriginDistance: 1 fanOriginDistance: 1
@@ -27,3 +27,4 @@ MonoBehaviour:
baseDamage: 34 baseDamage: 34
damageType: 0 damageType: 0
statScaling: 0.95 statScaling: 0.95
mitigationTier: 0

View File

@@ -18,7 +18,7 @@ MonoBehaviour:
areaShape: 0 areaShape: 0
targetLayers: targetLayers:
serializedVersion: 2 serializedVersion: 2
m_Bits: 0 m_Bits: 4294967295
includeCasterInArea: 0 includeCasterInArea: 0
areaRadius: 4.2 areaRadius: 4.2
fanOriginDistance: 1 fanOriginDistance: 1

View File

@@ -18,7 +18,7 @@ MonoBehaviour:
areaShape: 0 areaShape: 0
targetLayers: targetLayers:
serializedVersion: 2 serializedVersion: 2
m_Bits: 0 m_Bits: 4294967295
includeCasterInArea: 0 includeCasterInArea: 0
areaRadius: 4.75 areaRadius: 4.75
fanOriginDistance: 1 fanOriginDistance: 1
@@ -27,3 +27,4 @@ MonoBehaviour:
baseDamage: 22 baseDamage: 22
damageType: 0 damageType: 0
statScaling: 0.65 statScaling: 0.65
mitigationTier: 0

View File

@@ -18,7 +18,7 @@ MonoBehaviour:
areaShape: 0 areaShape: 0
targetLayers: targetLayers:
serializedVersion: 2 serializedVersion: 2
m_Bits: 0 m_Bits: 4294967295
includeCasterInArea: 0 includeCasterInArea: 0
areaRadius: 4.75 areaRadius: 4.75
fanOriginDistance: 1 fanOriginDistance: 1

View File

@@ -29,3 +29,4 @@ MonoBehaviour:
statScaling: 1.1 statScaling: 1.1
bonusAgainstDownedTarget: 1 bonusAgainstDownedTarget: 1
downedDamageMultiplier: 1.6 downedDamageMultiplier: 1.6
mitigationTier: 0

View File

@@ -18,7 +18,7 @@ MonoBehaviour:
areaShape: 0 areaShape: 0
targetLayers: targetLayers:
serializedVersion: 2 serializedVersion: 2
m_Bits: 0 m_Bits: 4294967295
includeCasterInArea: 0 includeCasterInArea: 0
areaRadius: 8.5 areaRadius: 8.5
fanOriginDistance: 1 fanOriginDistance: 1
@@ -27,3 +27,4 @@ MonoBehaviour:
baseDamage: 14 baseDamage: 14
damageType: 0 damageType: 0
statScaling: 0.35 statScaling: 0.35
mitigationTier: 0

View File

@@ -18,7 +18,7 @@ MonoBehaviour:
areaShape: 0 areaShape: 0
targetLayers: targetLayers:
serializedVersion: 2 serializedVersion: 2
m_Bits: 0 m_Bits: 4294967295
includeCasterInArea: 0 includeCasterInArea: 0
areaRadius: 8.5 areaRadius: 8.5
fanOriginDistance: 1 fanOriginDistance: 1
@@ -27,3 +27,4 @@ MonoBehaviour:
baseDamage: 17 baseDamage: 17
damageType: 0 damageType: 0
statScaling: 0.4 statScaling: 0.4
mitigationTier: 0

View File

@@ -18,7 +18,7 @@ MonoBehaviour:
areaShape: 0 areaShape: 0
targetLayers: targetLayers:
serializedVersion: 2 serializedVersion: 2
m_Bits: 0 m_Bits: 4294967295
includeCasterInArea: 0 includeCasterInArea: 0
areaRadius: 8.5 areaRadius: 8.5
fanOriginDistance: 1 fanOriginDistance: 1
@@ -27,3 +27,4 @@ MonoBehaviour:
baseDamage: 20 baseDamage: 20
damageType: 0 damageType: 0
statScaling: 0.45 statScaling: 0.45
mitigationTier: 0

View File

@@ -18,7 +18,7 @@ MonoBehaviour:
areaShape: 1 areaShape: 1
targetLayers: targetLayers:
serializedVersion: 2 serializedVersion: 2
m_Bits: 0 m_Bits: 4294967295
includeCasterInArea: 0 includeCasterInArea: 0
areaRadius: 3.6 areaRadius: 3.6
fanOriginDistance: 1.3 fanOriginDistance: 1.3
@@ -27,3 +27,4 @@ MonoBehaviour:
baseDamage: 20 baseDamage: 20
damageType: 0 damageType: 0
statScaling: 0.6 statScaling: 0.6
mitigationTier: 0

View File

@@ -18,7 +18,7 @@ MonoBehaviour:
areaShape: 1 areaShape: 1
targetLayers: targetLayers:
serializedVersion: 2 serializedVersion: 2
m_Bits: 0 m_Bits: 4294967295
includeCasterInArea: 0 includeCasterInArea: 0
areaRadius: 3.2 areaRadius: 3.2
fanOriginDistance: 1.15 fanOriginDistance: 1.15
@@ -27,3 +27,4 @@ MonoBehaviour:
baseDamage: 16 baseDamage: 16
damageType: 0 damageType: 0
statScaling: 0.45 statScaling: 0.45
mitigationTier: 0

View File

@@ -18,7 +18,7 @@ MonoBehaviour:
areaShape: 1 areaShape: 1
targetLayers: targetLayers:
serializedVersion: 2 serializedVersion: 2
m_Bits: 0 m_Bits: 4294967295
includeCasterInArea: 0 includeCasterInArea: 0
areaRadius: 3.25 areaRadius: 3.25
fanOriginDistance: 1.25 fanOriginDistance: 1.25
@@ -27,3 +27,4 @@ MonoBehaviour:
baseDamage: 22 baseDamage: 22
damageType: 0 damageType: 0
statScaling: 0.65 statScaling: 0.65
mitigationTier: 0

View File

@@ -18,7 +18,7 @@ MonoBehaviour:
areaShape: 1 areaShape: 1
targetLayers: targetLayers:
serializedVersion: 2 serializedVersion: 2
m_Bits: 0 m_Bits: 4294967295
includeCasterInArea: 0 includeCasterInArea: 0
areaRadius: 3.3 areaRadius: 3.3
fanOriginDistance: 1.25 fanOriginDistance: 1.25
@@ -27,3 +27,4 @@ MonoBehaviour:
baseDamage: 16 baseDamage: 16
damageType: 0 damageType: 0
statScaling: 0.5 statScaling: 0.5
mitigationTier: 0

View File

@@ -18,7 +18,7 @@ MonoBehaviour:
areaShape: 1 areaShape: 1
targetLayers: targetLayers:
serializedVersion: 2 serializedVersion: 2
m_Bits: 0 m_Bits: 4294967295
includeCasterInArea: 0 includeCasterInArea: 0
areaRadius: 3.5 areaRadius: 3.5
fanOriginDistance: 1.35 fanOriginDistance: 1.35
@@ -27,3 +27,4 @@ MonoBehaviour:
baseDamage: 26 baseDamage: 26
damageType: 0 damageType: 0
statScaling: 0.8 statScaling: 0.8
mitigationTier: 0

View File

@@ -18,7 +18,7 @@ MonoBehaviour:
areaShape: 1 areaShape: 1
targetLayers: targetLayers:
serializedVersion: 2 serializedVersion: 2
m_Bits: 0 m_Bits: 4294967295
includeCasterInArea: 0 includeCasterInArea: 0
areaRadius: 3.6 areaRadius: 3.6
fanOriginDistance: 1.35 fanOriginDistance: 1.35
@@ -27,3 +27,4 @@ MonoBehaviour:
baseDamage: 20 baseDamage: 20
damageType: 0 damageType: 0
statScaling: 0.6 statScaling: 0.6
mitigationTier: 0

View File

@@ -18,7 +18,7 @@ MonoBehaviour:
areaShape: 1 areaShape: 1
targetLayers: targetLayers:
serializedVersion: 2 serializedVersion: 2
m_Bits: 0 m_Bits: 4294967295
includeCasterInArea: 0 includeCasterInArea: 0
areaRadius: 2.6 areaRadius: 2.6
fanOriginDistance: 1.1 fanOriginDistance: 1.1
@@ -27,3 +27,4 @@ MonoBehaviour:
baseDamage: 12 baseDamage: 12
damageType: 0 damageType: 0
statScaling: 0.35 statScaling: 0.35
mitigationTier: 0

View File

@@ -18,7 +18,7 @@ MonoBehaviour:
areaShape: 1 areaShape: 1
targetLayers: targetLayers:
serializedVersion: 2 serializedVersion: 2
m_Bits: 0 m_Bits: 4294967295
includeCasterInArea: 0 includeCasterInArea: 0
areaRadius: 2.6 areaRadius: 2.6
fanOriginDistance: 1.1 fanOriginDistance: 1.1
@@ -27,3 +27,4 @@ MonoBehaviour:
baseDamage: 12 baseDamage: 12
damageType: 0 damageType: 0
statScaling: 0.35 statScaling: 0.35
mitigationTier: 0

View File

@@ -18,7 +18,7 @@ MonoBehaviour:
areaShape: 1 areaShape: 1
targetLayers: targetLayers:
serializedVersion: 2 serializedVersion: 2
m_Bits: 0 m_Bits: 4294967295
includeCasterInArea: 0 includeCasterInArea: 0
areaRadius: 3.1 areaRadius: 3.1
fanOriginDistance: 1.15 fanOriginDistance: 1.15
@@ -27,3 +27,4 @@ MonoBehaviour:
baseDamage: 18 baseDamage: 18
damageType: 0 damageType: 0
statScaling: 0.55 statScaling: 0.55
mitigationTier: 0

View File

@@ -18,7 +18,7 @@ MonoBehaviour:
areaShape: 1 areaShape: 1
targetLayers: targetLayers:
serializedVersion: 2 serializedVersion: 2
m_Bits: 0 m_Bits: 4294967295
includeCasterInArea: 0 includeCasterInArea: 0
areaRadius: 3.9 areaRadius: 3.9
fanOriginDistance: 1.35 fanOriginDistance: 1.35
@@ -27,3 +27,4 @@ MonoBehaviour:
baseDamage: 22 baseDamage: 22
damageType: 0 damageType: 0
statScaling: 0.65 statScaling: 0.65
mitigationTier: 0

View File

@@ -18,7 +18,7 @@ MonoBehaviour:
areaShape: 1 areaShape: 1
targetLayers: targetLayers:
serializedVersion: 2 serializedVersion: 2
m_Bits: 0 m_Bits: 4294967295
includeCasterInArea: 0 includeCasterInArea: 0
areaRadius: 3.6 areaRadius: 3.6
fanOriginDistance: 1.25 fanOriginDistance: 1.25
@@ -27,3 +27,4 @@ MonoBehaviour:
baseDamage: 18 baseDamage: 18
damageType: 0 damageType: 0
statScaling: 0.5 statScaling: 0.5
mitigationTier: 0

View File

@@ -18,7 +18,7 @@ MonoBehaviour:
areaShape: 2 areaShape: 2
targetLayers: targetLayers:
serializedVersion: 2 serializedVersion: 2
m_Bits: 0 m_Bits: 4294967295
includeCasterInArea: 0 includeCasterInArea: 0
areaRadius: 12 areaRadius: 12
fanOriginDistance: 1.2 fanOriginDistance: 1.2
@@ -27,3 +27,4 @@ MonoBehaviour:
baseDamage: 28 baseDamage: 28
damageType: 0 damageType: 0
statScaling: 0.7 statScaling: 0.7
mitigationTier: 0

View File

@@ -5063,7 +5063,7 @@ MonoBehaviour:
skillRegistry: {fileID: 11400000, guid: 1899abd2213d1374dac386c5c865eb16, type: 2} skillRegistry: {fileID: 11400000, guid: 1899abd2213d1374dac386c5c865eb16, type: 2}
skillSlots: skillSlots:
- {fileID: 0} - {fileID: 0}
- {fileID: 0} - {fileID: 11400000, guid: 3a221d8cb49bc80ee895968168987cf3, type: 2}
- {fileID: 11400000, guid: 2b3c4d5e6f7890abcdef1234567890ab, type: 2} - {fileID: 11400000, guid: 2b3c4d5e6f7890abcdef1234567890ab, type: 2}
- {fileID: 11400000, guid: a8f2a6b7c9d0e1f3a4b5c6d7e8a9f0b1, type: 2} - {fileID: 11400000, guid: a8f2a6b7c9d0e1f3a4b5c6d7e8a9f0b1, type: 2}
- {fileID: 11400000, guid: 4653bb40be03e3d418389a2268afb3e5, type: 2} - {fileID: 11400000, guid: 4653bb40be03e3d418389a2268afb3e5, type: 2}
@@ -5074,7 +5074,7 @@ MonoBehaviour:
socketedGems: socketedGems:
- {fileID: 0} - {fileID: 0}
- {fileID: 0} - {fileID: 0}
- baseSkill: {fileID: 0} - baseSkill: {fileID: 11400000, guid: 3a221d8cb49bc80ee895968168987cf3, type: 2}
socketedGems: socketedGems:
- {fileID: 0} - {fileID: 0}
- {fileID: 0} - {fileID: 0}

View File

@@ -0,0 +1,78 @@
using UnityEngine;
namespace Colosseum.Combat
{
/// <summary>
/// 피해가 방어/회피 규칙과 함께 전달될 때 사용하는 판정 등급입니다.
/// </summary>
public enum DamageMitigationTier
{
Normal,
Unblockable,
Undodgeable,
}
/// <summary>
/// 피해량과 출처, 방어 가능 여부를 함께 전달하는 런타임 컨텍스트입니다.
/// </summary>
public readonly struct DamageContext
{
public DamageContext(float amount, object source = null, DamageMitigationTier mitigationTier = DamageMitigationTier.Normal)
{
Amount = Mathf.Max(0f, amount);
Source = source;
MitigationTier = mitigationTier;
}
/// <summary>
/// 원본 피해량입니다.
/// </summary>
public float Amount { get; }
/// <summary>
/// 피해 출처입니다. GameObject 또는 Component가 일반적입니다.
/// </summary>
public object Source { get; }
/// <summary>
/// 방어/회피 허용 규칙입니다.
/// </summary>
public DamageMitigationTier MitigationTier { get; }
/// <summary>
/// 현재 피해가 방어 가능한지 여부입니다.
/// </summary>
public bool CanBeGuarded => MitigationTier != DamageMitigationTier.Unblockable;
/// <summary>
/// 현재 피해가 회피 가능한지 여부입니다.
/// </summary>
public bool CanBeDodged => MitigationTier != DamageMitigationTier.Undodgeable;
/// <summary>
/// 피해 출처를 GameObject로 해석합니다.
/// </summary>
public GameObject SourceGameObject => ResolveSourceGameObject(Source);
/// <summary>
/// 같은 메타데이터를 유지한 채 피해량만 교체합니다.
/// </summary>
public DamageContext WithAmount(float amount)
{
return new DamageContext(amount, Source, MitigationTier);
}
/// <summary>
/// object 기반 출처를 GameObject로 변환합니다.
/// </summary>
public static GameObject ResolveSourceGameObject(object source)
{
return source switch
{
GameObject sourceObject => sourceObject,
Component component => component.gameObject,
_ => null,
};
}
}
}

View File

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

View File

@@ -103,10 +103,19 @@ namespace Colosseum.Combat
/// </summary> /// </summary>
public float TakeDamage(float damage, object source = null) public float TakeDamage(float damage, object source = null)
{ {
return TakeDamage(new DamageContext(damage, source));
}
/// <summary>
/// 대미지 컨텍스트를 사용해 피해를 적용합니다.
/// </summary>
public float TakeDamage(DamageContext damageContext)
{
float damage = damageContext.Amount;
if (!IsServer || damage <= 0f) if (!IsServer || damage <= 0f)
return 0f; return 0f;
GameObject sourceObject = ResolveSource(source); GameObject sourceObject = damageContext.SourceGameObject;
float actualDamage = Mathf.Min(damage, currentHealth); float actualDamage = Mathf.Min(damage, currentHealth);
currentHealth = Mathf.Max(0f, currentHealth - actualDamage); currentHealth = Mathf.Max(0f, currentHealth - actualDamage);

View File

@@ -29,6 +29,13 @@ namespace Colosseum.Combat
/// <returns>실제로 적용된 대미지량</returns> /// <returns>실제로 적용된 대미지량</returns>
float TakeDamage(float damage, object source = null); float TakeDamage(float damage, object source = null);
/// <summary>
/// 대미지 컨텍스트를 사용해 대미지를 적용합니다.
/// </summary>
/// <param name="damageContext">피해량과 방어 규칙이 담긴 컨텍스트</param>
/// <returns>실제로 적용된 대미지량</returns>
float TakeDamage(DamageContext damageContext);
/// <summary> /// <summary>
/// 체력 회복 /// 체력 회복
/// </summary> /// </summary>

View File

@@ -54,6 +54,9 @@ namespace Colosseum.Editor
AnimationClip comboBasic1Hit1Clip0 = EnsureClipFromSource($"{AnimationsFolder}/Anim_Drog_콤보-기본기1_1_0.anim", $"{AnimationsFolder}/Anim_Drog_평타1R_0.anim"); AnimationClip comboBasic1Hit1Clip0 = EnsureClipFromSource($"{AnimationsFolder}/Anim_Drog_콤보-기본기1_1_0.anim", $"{AnimationsFolder}/Anim_Drog_평타1R_0.anim");
AnimationClip comboBasic1Hit1Clip1 = EnsureClipFromSource($"{AnimationsFolder}/Anim_Drog_콤보-기본기1_1_1.anim", $"{AnimationsFolder}/Anim_Drog_평타1R_1.anim"); AnimationClip comboBasic1Hit1Clip1 = EnsureClipFromSource($"{AnimationsFolder}/Anim_Drog_콤보-기본기1_1_1.anim", $"{AnimationsFolder}/Anim_Drog_평타1R_1.anim");
AnimationClip comboBasic1Hit2Clip = EnsureClipFromSource($"{AnimationsFolder}/Anim_Drog_콤보-기본기1_2_0.anim", LightCombo01BSourcePath, "A_MOD_SWD_Attack_LightCombo01B_RM_Neut"); AnimationClip comboBasic1Hit2Clip = EnsureClipFromSource($"{AnimationsFolder}/Anim_Drog_콤보-기본기1_2_0.anim", LightCombo01BSourcePath, "A_MOD_SWD_Attack_LightCombo01B_RM_Neut");
SetSingleOnEffectEvent(comboBasic1Hit1Clip0, -1f);
SetSingleOnEffectEvent(comboBasic1Hit1Clip1, 0.30f);
SetSingleOnEffectEvent(comboBasic1Hit2Clip, 0.28f);
AnimationClip comboBasic2Hit1Clip0 = EnsureClipFromSource($"{AnimationsFolder}/Anim_Drog_콤보-기본기2_1_0.anim", $"{AnimationsFolder}/Anim_Drog_평타2R_0.anim"); AnimationClip comboBasic2Hit1Clip0 = EnsureClipFromSource($"{AnimationsFolder}/Anim_Drog_콤보-기본기2_1_0.anim", $"{AnimationsFolder}/Anim_Drog_평타2R_0.anim");
AnimationClip comboBasic2Hit1Clip1 = EnsureClipFromSource($"{AnimationsFolder}/Anim_Drog_콤보-기본기2_1_1.anim", $"{AnimationsFolder}/Anim_Drog_평타2R_1.anim"); AnimationClip comboBasic2Hit1Clip1 = EnsureClipFromSource($"{AnimationsFolder}/Anim_Drog_콤보-기본기2_1_1.anim", $"{AnimationsFolder}/Anim_Drog_평타2R_1.anim");
@@ -971,6 +974,35 @@ namespace Colosseum.Editor
} }
} }
/// <summary>
/// 지정한 클립에 단일 OnEffect(0) 이벤트를 설정합니다. 음수 시간이면 이벤트를 비웁니다.
/// </summary>
private static void SetSingleOnEffectEvent(AnimationClip clip, float time)
{
if (clip == null)
return;
if (time < 0f)
{
AnimationUtility.SetAnimationEvents(clip, Array.Empty<AnimationEvent>());
EditorUtility.SetDirty(clip);
return;
}
float clampedTime = Mathf.Clamp(time, 0f, Mathf.Max(0f, clip.length - 0.01f));
AnimationUtility.SetAnimationEvents(clip, new[]
{
new AnimationEvent
{
time = clampedTime,
functionName = "OnEffect",
intParameter = 0,
},
});
EditorUtility.SetDirty(clip);
}
/// <summary> /// <summary>
/// 범위형 효과의 공통 판정 설정을 적용합니다. /// 범위형 효과의 공통 판정 설정을 적용합니다.
/// </summary> /// </summary>
@@ -987,6 +1019,7 @@ namespace Colosseum.Editor
serializedObject.FindProperty("targetTeam").enumValueIndex = (int)TargetTeam.Enemy; serializedObject.FindProperty("targetTeam").enumValueIndex = (int)TargetTeam.Enemy;
serializedObject.FindProperty("areaCenter").enumValueIndex = (int)areaCenter; serializedObject.FindProperty("areaCenter").enumValueIndex = (int)areaCenter;
serializedObject.FindProperty("areaShape").enumValueIndex = (int)areaShape; serializedObject.FindProperty("areaShape").enumValueIndex = (int)areaShape;
serializedObject.FindProperty("targetLayers").intValue = Physics.AllLayers;
serializedObject.FindProperty("includeCasterInArea").boolValue = false; serializedObject.FindProperty("includeCasterInArea").boolValue = false;
serializedObject.FindProperty("areaRadius").floatValue = areaRadius; serializedObject.FindProperty("areaRadius").floatValue = areaRadius;
serializedObject.FindProperty("fanOriginDistance").floatValue = fanOriginDistance; serializedObject.FindProperty("fanOriginDistance").floatValue = fanOriginDistance;
@@ -1173,7 +1206,8 @@ namespace Colosseum.Editor
serializedObject.FindProperty("cooldown").floatValue = 0f; serializedObject.FindProperty("cooldown").floatValue = 0f;
serializedObject.FindProperty("manaCost").floatValue = 0f; serializedObject.FindProperty("manaCost").floatValue = 0f;
serializedObject.FindProperty("maxGemSlotCount").intValue = 0; serializedObject.FindProperty("maxGemSlotCount").intValue = 0;
serializedObject.FindProperty("triggeredEffects").arraySize = 0; serializedObject.FindProperty("castStartEffects").arraySize = 0;
ConfigureTriggeredEffects(serializedObject, castStartEffects);
var clipObjects = new List<UnityEngine.Object>(); var clipObjects = new List<UnityEngine.Object>();
if (clips != null) if (clips != null)
@@ -1187,17 +1221,6 @@ namespace Colosseum.Editor
SetObjectList(serializedObject, "animationClips", clipObjects); SetObjectList(serializedObject, "animationClips", clipObjects);
var effectObjects = new List<UnityEngine.Object>();
if (castStartEffects != null)
{
for (int i = 0; i < castStartEffects.Length; i++)
{
if (castStartEffects[i] != null)
effectObjects.Add(castStartEffects[i]);
}
}
SetObjectList(serializedObject, "castStartEffects", effectObjects);
serializedObject.ApplyModifiedPropertiesWithoutUndo(); serializedObject.ApplyModifiedPropertiesWithoutUndo();
skill.RefreshAnimationClips(); skill.RefreshAnimationClips();
@@ -1205,6 +1228,40 @@ namespace Colosseum.Editor
return skill; return skill;
} }
/// <summary>
/// 전달된 효과를 모두 Trigger Index 0의 애니메이션 이벤트 효과로 기록합니다.
/// </summary>
private static void ConfigureTriggeredEffects(SerializedObject serializedObject, IReadOnlyList<SkillEffect> effects)
{
SerializedProperty triggeredEffectsProperty = serializedObject.FindProperty("triggeredEffects");
if (triggeredEffectsProperty == null)
return;
var validEffects = new List<SkillEffect>();
if (effects != null)
{
for (int i = 0; i < effects.Count; i++)
{
if (effects[i] != null)
validEffects.Add(effects[i]);
}
}
triggeredEffectsProperty.arraySize = validEffects.Count > 0 ? 1 : 0;
if (triggeredEffectsProperty.arraySize == 0)
return;
SerializedProperty entryProperty = triggeredEffectsProperty.GetArrayElementAtIndex(0);
entryProperty.FindPropertyRelative("triggerIndex").intValue = 0;
SerializedProperty effectsProperty = entryProperty.FindPropertyRelative("effects");
effectsProperty.arraySize = validEffects.Count;
for (int i = 0; i < validEffects.Count; i++)
{
effectsProperty.GetArrayElementAtIndex(i).objectReferenceValue = validEffects[i];
}
}
/// <summary> /// <summary>
/// 드로그 보스 패턴 자산을 생성하거나 갱신합니다. /// 드로그 보스 패턴 자산을 생성하거나 갱신합니다.
/// </summary> /// </summary>

View File

@@ -9,6 +9,7 @@ using Colosseum.Abnormalities;
using Colosseum.Passives; using Colosseum.Passives;
using Colosseum.Stats; using Colosseum.Stats;
using Colosseum.Combat; using Colosseum.Combat;
using Colosseum.Player;
using Colosseum.Skills; using Colosseum.Skills;
namespace Colosseum.Enemy namespace Colosseum.Enemy
@@ -28,6 +29,8 @@ namespace Colosseum.Enemy
[SerializeField] protected UnityEngine.AI.NavMeshAgent navMeshAgent; [SerializeField] protected UnityEngine.AI.NavMeshAgent navMeshAgent;
[Tooltip("이상상태 관리자")] [Tooltip("이상상태 관리자")]
[SerializeField] protected AbnormalityManager abnormalityManager; [SerializeField] protected AbnormalityManager abnormalityManager;
[Tooltip("플레이어와의 물리 겹침을 계산할 본체 콜라이더")]
[SerializeField] private Collider bodyCollider;
[Header("Data")] [Header("Data")]
[SerializeField] protected EnemyData enemyData; [SerializeField] protected EnemyData enemyData;
@@ -46,6 +49,12 @@ namespace Colosseum.Enemy
[Tooltip("보호막 타입이 지정되지 않았을 때 사용할 기본 보호막 이상상태 데이터")] [Tooltip("보호막 타입이 지정되지 않았을 때 사용할 기본 보호막 이상상태 데이터")]
[SerializeField] private AbnormalityData shieldStateAbnormality; [SerializeField] private AbnormalityData shieldStateAbnormality;
[Header("Player Separation")]
[Tooltip("적과 플레이어 사이에 추가로 유지할 수평 간격")]
[Min(0f)] [SerializeField] private float playerSeparationPadding = 0.1f;
[Tooltip("플레이어와 닿아 있을 때 적의 수평 이동을 멈출지 여부")]
[SerializeField] private bool freezeHorizontalMotionOnPlayerContact = true;
// 네트워크 동기화 변수 // 네트워크 동기화 변수
protected NetworkVariable<float> currentHealth = new NetworkVariable<float>(100f); protected NetworkVariable<float> currentHealth = new NetworkVariable<float>(100f);
@@ -95,6 +104,8 @@ namespace Colosseum.Enemy
navMeshAgent = GetComponent<UnityEngine.AI.NavMeshAgent>(); navMeshAgent = GetComponent<UnityEngine.AI.NavMeshAgent>();
if (abnormalityManager == null) if (abnormalityManager == null)
abnormalityManager = GetComponent<AbnormalityManager>(); abnormalityManager = GetComponent<AbnormalityManager>();
if (bodyCollider == null)
bodyCollider = GetComponent<Collider>();
// 서버에서 초기화 // 서버에서 초기화
if (IsServer) if (IsServer)
@@ -126,34 +137,27 @@ namespace Colosseum.Enemy
protected virtual void OnServerUpdate() { } protected virtual void OnServerUpdate() { }
/// <summary> /// <summary>
/// 보스와 플레이어가 겹치면 플레이어를 밀어냅니다. /// 보스와 플레이어가 겹치면 적 자신을 살짝 밀어내 겹침을 해소합니다.
/// 점프 착지 포함, 항상 실행됩니다. /// 점프 착지 포함, 항상 실행됩니다.
/// </summary> /// </summary>
private void LateUpdate() private void LateUpdate()
{ {
if (!IsServer || IsDead) return; if (!IsServer || IsDead) return;
float separationDist = navMeshAgent != null Vector3 separationOffset = ComputePlayerSeparationOffset();
? Mathf.Max(navMeshAgent.stoppingDistance, navMeshAgent.radius + 0.5f) if (separationOffset.sqrMagnitude <= 0.000001f)
: 1f; return;
int count = Physics.OverlapSphereNonAlloc(transform.position, separationDist, overlapBuffer); if (navMeshAgent != null && !isAirborne && navMeshAgent.enabled)
for (int i = 0; i < count; i++)
{ {
if (!overlapBuffer[i].TryGetComponent<CharacterController>(out var cc)) continue; if (navMeshAgent.velocity.sqrMagnitude > 0.01f)
Vector3 toPlayer = overlapBuffer[i].transform.position - transform.position;
toPlayer.y = 0f;
float dist = toPlayer.magnitude;
if (dist >= separationDist) continue;
// 플레이어를 보스 바깥으로 밀어냄
Vector3 pushDir = dist > 0.001f ? toPlayer.normalized : transform.forward;
cc.Move(pushDir * (separationDist - dist));
// 보스가 이동 중이었으면 정지 (플레이어 안으로 더 진입하지 않도록)
if (navMeshAgent != null && !isAirborne && navMeshAgent.velocity.sqrMagnitude > 0.01f)
navMeshAgent.isStopped = true; navMeshAgent.isStopped = true;
navMeshAgent.Move(separationOffset);
}
else
{
transform.position += separationOffset;
} }
} }
@@ -222,23 +226,10 @@ namespace Colosseum.Enemy
navMeshAgent.Warp(transform.position); navMeshAgent.Warp(transform.position);
} }
// XZ 차단: 플레이어 방향으로의 이동 방지 (일반 이동 중에만) if (freezeHorizontalMotionOnPlayerContact && IsTouchingPlayer())
float blockRadius = Mathf.Max(navMeshAgent.stoppingDistance, navMeshAgent.radius + 0.5f);
int count = Physics.OverlapSphereNonAlloc(transform.position, blockRadius, overlapBuffer);
for (int i = 0; i < count; i++)
{ {
if (!overlapBuffer[i].TryGetComponent<CharacterController>(out _)) continue; deltaPosition.x = 0f;
deltaPosition.z = 0f;
Vector3 toPlayer = overlapBuffer[i].transform.position - transform.position;
toPlayer.y = 0f;
if (toPlayer.sqrMagnitude < 0.0001f) continue;
Vector3 deltaXZ = new Vector3(deltaPosition.x, 0f, deltaPosition.z);
if (Vector3.Dot(deltaXZ, toPlayer.normalized) > 0f)
{
deltaPosition.x = 0f;
deltaPosition.z = 0f;
}
} }
navMeshAgent.Move(deltaPosition); navMeshAgent.Move(deltaPosition);
@@ -282,23 +273,36 @@ namespace Colosseum.Enemy
/// 대미지 적용 (서버에서 실행) /// 대미지 적용 (서버에서 실행)
/// </summary> /// </summary>
public virtual float TakeDamage(float damage, object source = null) public virtual float TakeDamage(float damage, object source = null)
{
return TakeDamage(new DamageContext(damage, source));
}
/// <summary>
/// 대미지 컨텍스트를 사용해 대미지를 적용합니다.
/// </summary>
public virtual float TakeDamage(DamageContext damageContext)
{ {
if (!IsServer || isDead.Value) if (!IsServer || isDead.Value)
return 0f; return 0f;
if (ShouldIgnoreIncomingDamage(damage, source)) float damage = damageContext.Amount;
if (damage <= 0f)
return 0f;
if (ShouldIgnoreIncomingDamage(damage, damageContext.Source))
return 0f; return 0f;
float mitigatedDamage = ConsumeShield(damage); float mitigatedDamage = ConsumeShield(damage);
float actualDamage = Mathf.Min(mitigatedDamage, currentHealth.Value); float actualDamage = Mathf.Min(mitigatedDamage, currentHealth.Value);
currentHealth.Value = Mathf.Max(0f, currentHealth.Value - actualDamage); currentHealth.Value = Mathf.Max(0f, currentHealth.Value - actualDamage);
CombatBalanceTracker.RecordDamage(source as GameObject, gameObject, actualDamage); GameObject sourceObject = damageContext.SourceGameObject;
RegisterThreatFromDamage(actualDamage, source); CombatBalanceTracker.RecordDamage(sourceObject, gameObject, actualDamage);
RegisterThreatFromDamage(actualDamage, sourceObject);
OnDamageTaken?.Invoke(actualDamage); OnDamageTaken?.Invoke(actualDamage);
// 대미지 피드백 (애니메이션, 이펙트 등) // 대미지 피드백 (애니메이션, 이펙트 등)
OnTakeDamageFeedback(actualDamage, source); OnTakeDamageFeedback(actualDamage, damageContext.Source);
if (currentHealth.Value <= 0f) if (currentHealth.Value <= 0f)
{ {
@@ -327,6 +331,93 @@ namespace Colosseum.Enemy
} }
} }
private Vector3 ComputePlayerSeparationOffset()
{
if (bodyCollider == null)
return Vector3.zero;
float scanRadius = GetPlayerDetectionRadius();
int count = Physics.OverlapSphereNonAlloc(transform.position, scanRadius, overlapBuffer);
Vector3 separationOffset = Vector3.zero;
int overlapCount = 0;
for (int i = 0; i < count; i++)
{
if (!TryGetPlayerCharacterController(overlapBuffer[i], out CharacterController playerController))
continue;
if (!Physics.ComputePenetration(
bodyCollider, transform.position, transform.rotation,
playerController, playerController.transform.position, playerController.transform.rotation,
out Vector3 separationDirection, out float separationDistance))
{
continue;
}
separationDirection.y = 0f;
if (separationDirection.sqrMagnitude <= 0.0001f)
separationDirection = -transform.forward;
separationOffset += separationDirection.normalized * (separationDistance + playerSeparationPadding);
overlapCount++;
}
if (overlapCount <= 0)
return Vector3.zero;
separationOffset /= overlapCount;
separationOffset.y = 0f;
return separationOffset;
}
private bool IsTouchingPlayer()
{
float scanRadius = GetPlayerDetectionRadius();
int count = Physics.OverlapSphereNonAlloc(transform.position, scanRadius, overlapBuffer);
for (int i = 0; i < count; i++)
{
if (!TryGetPlayerCharacterController(overlapBuffer[i], out CharacterController playerController))
continue;
Vector3 toPlayer = playerController.transform.position - transform.position;
toPlayer.y = 0f;
if (toPlayer.magnitude < GetRequiredSeparationDistance(playerController))
return true;
}
return false;
}
private float GetPlayerDetectionRadius()
{
float enemyRadius = navMeshAgent != null ? navMeshAgent.radius : 0.5f;
return enemyRadius + 1f + playerSeparationPadding;
}
private float GetRequiredSeparationDistance(CharacterController playerController)
{
float enemyRadius = navMeshAgent != null ? navMeshAgent.radius : 0.5f;
float playerRadius = playerController != null ? playerController.radius : 0.5f;
return enemyRadius + playerRadius + playerSeparationPadding;
}
private static bool TryGetPlayerCharacterController(Collider overlapCollider, out CharacterController playerController)
{
playerController = null;
if (overlapCollider == null)
return false;
playerController = overlapCollider.GetComponent<CharacterController>();
if (playerController == null)
playerController = overlapCollider.GetComponentInParent<CharacterController>();
if (playerController == null)
return false;
return playerController.GetComponent<PlayerNetworkController>() != null
|| playerController.GetComponentInParent<PlayerNetworkController>() != null;
}
/// <summary> /// <summary>
/// 체력 회복 (서버에서 실행) /// 체력 회복 (서버에서 실행)
/// </summary> /// </summary>

View File

@@ -25,6 +25,9 @@ namespace Colosseum.Player
[Tooltip("피격 제어 관리자")] [Tooltip("피격 제어 관리자")]
[SerializeField] private HitReactionController hitReactionController; [SerializeField] private HitReactionController hitReactionController;
[Tooltip("방어 상태 관리자")]
[SerializeField] private PlayerDefenseController defenseController;
[Tooltip("관전 관리자")] [Tooltip("관전 관리자")]
[SerializeField] private PlayerSpectator spectator; [SerializeField] private PlayerSpectator spectator;
@@ -145,7 +148,9 @@ namespace Colosseum.Player
if (!CanReceiveInput || IsStunned || IsStaggered || IsKnockbackActive || IsDowned) if (!CanReceiveInput || IsStunned || IsStaggered || IsKnockbackActive || IsDowned)
return 0f; return 0f;
return abnormalityManager != null ? abnormalityManager.MoveSpeedMultiplier : 1f; float abnormalityMultiplier = abnormalityManager != null ? abnormalityManager.MoveSpeedMultiplier : 1f;
float defenseMultiplier = defenseController != null ? defenseController.MoveSpeedMultiplier : 1f;
return abnormalityMultiplier * defenseMultiplier;
} }
} }
@@ -159,6 +164,8 @@ namespace Colosseum.Player
skillController = GetComponent<SkillController>(); skillController = GetComponent<SkillController>();
if (hitReactionController == null) if (hitReactionController == null)
hitReactionController = GetOrCreateHitReactionController(); hitReactionController = GetOrCreateHitReactionController();
if (defenseController == null)
defenseController = GetOrCreateDefenseController();
if (spectator == null) if (spectator == null)
spectator = GetComponentInChildren<PlayerSpectator>(); spectator = GetComponentInChildren<PlayerSpectator>();
} }
@@ -195,5 +202,14 @@ namespace Colosseum.Player
return gameObject.AddComponent<HitReactionController>(); return gameObject.AddComponent<HitReactionController>();
} }
private PlayerDefenseController GetOrCreateDefenseController()
{
PlayerDefenseController foundController = GetComponent<PlayerDefenseController>();
if (foundController != null)
return foundController;
return gameObject.AddComponent<PlayerDefenseController>();
}
} }
} }

View File

@@ -0,0 +1,225 @@
using System;
using UnityEngine;
using Colosseum.Combat;
using Colosseum.Skills;
namespace Colosseum.Player
{
/// <summary>
/// 플레이어의 방어 판정 상태를 관리합니다.
/// 마나 유지나 이동 감속 없이 순수하게 방어 가능 여부와 피해 감쇠만 처리합니다.
/// </summary>
[DisallowMultipleComponent]
public class PlayerDefenseController : MonoBehaviour
{
[Header("References")]
[Tooltip("완벽한 방어 보상과 이동 감속을 처리하는 유지 컨트롤러")]
[SerializeField] private PlayerDefenseSustainController sustainController;
[Header("Defense Settings")]
[Tooltip("정면 판정을 사용할지 여부")]
[SerializeField] private bool useFrontGuardArc = true;
[Tooltip("방어가 유효한 정면 반각입니다.")]
[Range(1f, 89f)] [SerializeField] private float guardHalfAngle = 75f;
[Tooltip("일반 방어 성공 시 남는 피해 배율입니다.")]
[Range(0f, 1f)] [SerializeField] private float guardDamageMultiplier = 0.65f;
[Tooltip("방어 시작 후 완벽한 방어로 인정하는 시간 창입니다.")]
[Min(0f)] [SerializeField] private float perfectGuardWindow = 0.5f;
[Tooltip("방어 판정 상세 로그 출력 여부")]
[SerializeField] private bool enableDefenseDebugLogs = true;
[Header("Debug")]
[Tooltip("현재 방어 판정 활성 여부")]
[SerializeField] private bool isDefenseStateActive;
[Tooltip("현재 방어 판정 유지 시간")]
[Min(0f)] [SerializeField] private float defenseStateElapsed;
[Tooltip("마지막 방어 판정으로 막은 피해량")]
[Min(0f)] [SerializeField] private float lastPreventedDamage;
[Tooltip("마지막 방어 판정이 완벽한 방어였는지 여부")]
[SerializeField] private bool lastWasPerfectGuard;
private bool perfectGuardAvailable;
/// <summary>
/// 방어 시작 시 완벽한 방어 유효 시간을 전달합니다.
/// </summary>
public event Action<float> OnDefenseStateEntered;
/// <summary>
/// 방어 성공 시 일반/완벽 여부와 막은 피해량을 전달합니다.
/// </summary>
public event Action<bool, float> OnDefenseResolved;
/// <summary>
/// 현재 방어 판정 활성 여부입니다.
/// </summary>
public bool IsDefenseStateActive => isDefenseStateActive;
/// <summary>
/// 방어 유지 시스템이 제공하는 이동 속도 배율입니다.
/// </summary>
public float MoveSpeedMultiplier
{
get
{
EnsureReferences();
return sustainController != null ? sustainController.MoveSpeedMultiplier : 1f;
}
}
private void Awake()
{
EnsureReferences();
}
private void OnDisable()
{
ClearDefenseState();
}
private void Update()
{
if (!isDefenseStateActive)
return;
defenseStateElapsed += Time.deltaTime;
}
/// <summary>
/// 애니메이션 이벤트로 방어 판정을 시작합니다.
/// </summary>
public void EnterDefenseState()
{
EnsureReferences();
isDefenseStateActive = true;
defenseStateElapsed = 0f;
lastPreventedDamage = 0f;
lastWasPerfectGuard = false;
perfectGuardAvailable = true;
OnDefenseStateEntered?.Invoke(perfectGuardWindow);
if (enableDefenseDebugLogs)
{
Debug.Log($"[Defense] 상태 시작 | owner={gameObject.name} | perfectWindow={perfectGuardWindow:F2}s");
}
}
/// <summary>
/// 애니메이션 이벤트로 방어 판정을 종료합니다.
/// </summary>
public void ExitDefenseState()
{
ClearDefenseState();
}
/// <summary>
/// 스킬 종료/취소 시 방어 판정을 정리합니다.
/// </summary>
public void HandleSkillExecutionEnded()
{
ClearDefenseState();
}
/// <summary>
/// 현재 방어 판정이 유효하다면 피해를 감쇠합니다.
/// </summary>
public float ResolveIncomingDamage(DamageContext damageContext)
{
EnsureReferences();
if (!isDefenseStateActive || !damageContext.CanBeGuarded)
return damageContext.Amount;
if (useFrontGuardArc && !IsWithinGuardArc(damageContext.SourceGameObject))
return damageContext.Amount;
bool isPerfectGuard = perfectGuardAvailable && defenseStateElapsed <= perfectGuardWindow;
perfectGuardAvailable = false;
lastWasPerfectGuard = isPerfectGuard;
if (isPerfectGuard)
{
sustainController?.RefundStartManaOnPerfectGuard();
lastPreventedDamage = damageContext.Amount;
LogDefenseResolution(damageContext, true, 0f);
OnDefenseResolved?.Invoke(true, lastPreventedDamage);
return 0f;
}
float resolvedDamage = damageContext.Amount * guardDamageMultiplier;
lastPreventedDamage = Mathf.Max(0f, damageContext.Amount - resolvedDamage);
LogDefenseResolution(damageContext, false, resolvedDamage);
OnDefenseResolved?.Invoke(false, lastPreventedDamage);
return resolvedDamage;
}
private void EnsureReferences()
{
if (sustainController == null)
sustainController = GetComponent<PlayerDefenseSustainController>();
}
private bool IsWithinGuardArc(GameObject sourceObject)
{
if (sourceObject == null)
return true;
Vector3 toSource = sourceObject.transform.position - transform.position;
toSource.y = 0f;
if (toSource.sqrMagnitude <= 0.0001f)
return true;
float dot = Vector3.Dot(transform.forward.normalized, toSource.normalized);
float threshold = Mathf.Cos(guardHalfAngle * Mathf.Deg2Rad);
return dot >= threshold;
}
private void ClearDefenseState()
{
isDefenseStateActive = false;
defenseStateElapsed = 0f;
lastPreventedDamage = 0f;
lastWasPerfectGuard = false;
perfectGuardAvailable = false;
}
private void LogDefenseResolution(DamageContext damageContext, bool isPerfectGuard, float resolvedDamage)
{
if (!enableDefenseDebugLogs)
return;
GameObject sourceObject = damageContext.SourceGameObject;
string sourceName = sourceObject != null ? sourceObject.name : "Unknown";
string sourceSkillName = ResolveSourceSkillName(sourceObject);
string guardType = isPerfectGuard ? "완벽 방어" : "방어";
Debug.Log(
$"[Defense] {guardType} 성공 | owner={gameObject.name} | source={sourceName} | skill={sourceSkillName} | incoming={damageContext.Amount:F2} | prevented={lastPreventedDamage:F2} | resolved={resolvedDamage:F2} | elapsed={defenseStateElapsed:F3}s | mitigation={damageContext.MitigationTier}");
}
private static string ResolveSourceSkillName(GameObject sourceObject)
{
if (sourceObject == null)
return "None";
SkillController skillController = sourceObject.GetComponent<SkillController>();
if (skillController == null)
skillController = sourceObject.GetComponentInParent<SkillController>();
if (skillController?.CurrentSkill == null)
return "None";
return skillController.CurrentSkill.SkillName;
}
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 64a105def0eba753fba29d2e8ef03638

View File

@@ -0,0 +1,171 @@
using UnityEngine;
using Colosseum.Passives;
using Colosseum.Skills;
using Colosseum.Weapons;
namespace Colosseum.Player
{
/// <summary>
/// 플레이어의 방어 유지 자원 소모를 관리합니다.
/// 방어 판정과 분리되어 있으며, 애니메이션 이벤트로 시작/종료됩니다.
/// </summary>
[DisallowMultipleComponent]
public class PlayerDefenseSustainController : MonoBehaviour
{
[Header("References")]
[Tooltip("플레이어 자원 관리자")]
[SerializeField] private PlayerNetworkController networkController;
[Tooltip("현재 실행 중인 스킬 정보 참조용")]
[SerializeField] private SkillController skillController;
[Tooltip("무기 마나 배율 참조용")]
[SerializeField] private WeaponEquipment weaponEquipment;
[Header("Sustain Settings")]
[Tooltip("방어 유지의 초당 기본 마나 소모량입니다.")]
[Min(0f)] [SerializeField] private float sustainManaPerSecond = 4f;
[Tooltip("방어 유지 시간이 길어질수록 추가되는 초당 마나 소모량입니다.")]
[Min(0f)] [SerializeField] private float sustainManaRampPerSecond = 4f;
[Tooltip("완벽한 방어 성공 시 환급할 시작 마나 비율입니다.")]
[Range(0f, 1f)] [SerializeField] private float perfectGuardStartManaRefundRatio = 1f;
[Tooltip("방어 유지 중 이동 속도 배율입니다.")]
[Range(0f, 1f)] [SerializeField] private float sustainMoveSpeedMultiplier = 0.45f;
[Header("Debug")]
[Tooltip("현재 방어 유지 활성 여부")]
[SerializeField] private bool isSustainActive;
[Tooltip("현재 방어 유지 시간")]
[Min(0f)] [SerializeField] private float sustainElapsed;
[Tooltip("현재 프레임에 계산된 초당 유지 마나")]
[Min(0f)] [SerializeField] private float currentSustainManaPerSecond;
[Tooltip("마지막으로 캡처한 시작 마나 소모량")]
[Min(0f)] [SerializeField] private float capturedStartManaCost;
/// <summary>
/// 현재 방어 유지 활성 여부입니다.
/// </summary>
public bool IsSustainActive => isSustainActive;
/// <summary>
/// 방어 유지 중 이동 속도 배율입니다.
/// </summary>
public float MoveSpeedMultiplier => isSustainActive ? sustainMoveSpeedMultiplier : 1f;
private void Awake()
{
EnsureReferences();
}
private void OnDisable()
{
ClearSustainState();
}
private void Update()
{
if (!isSustainActive)
return;
sustainElapsed += Time.deltaTime;
currentSustainManaPerSecond = sustainManaPerSecond + (sustainManaRampPerSecond * sustainElapsed);
if (networkController == null || !networkController.IsServer)
return;
float requiredMana = currentSustainManaPerSecond * Time.deltaTime;
float actualSpentMana = networkController.SpendMana(requiredMana);
if (actualSpentMana + 0.001f < requiredMana)
{
skillController?.CancelSkillFromServer(SkillCancelReason.ResourceExhausted);
ClearSustainState();
}
}
/// <summary>
/// 애니메이션 이벤트로 방어 유지 자원 소모를 시작합니다.
/// </summary>
public void BeginSustain()
{
EnsureReferences();
isSustainActive = true;
sustainElapsed = 0f;
currentSustainManaPerSecond = sustainManaPerSecond;
capturedStartManaCost = ResolveCurrentSkillManaCost();
}
/// <summary>
/// 애니메이션 이벤트로 방어 유지 자원 소모를 종료합니다.
/// </summary>
public void EndSustain()
{
ClearSustainState();
}
/// <summary>
/// 스킬 종료/취소 시 방어 유지 상태를 정리합니다.
/// </summary>
public void HandleSkillExecutionEnded()
{
ClearSustainState();
}
/// <summary>
/// 완벽한 방어 성공 시 시작 마나를 환급합니다.
/// </summary>
public void RefundStartManaOnPerfectGuard()
{
EnsureReferences();
if (!isSustainActive || networkController == null || !networkController.IsServer)
return;
float refundAmount = capturedStartManaCost * perfectGuardStartManaRefundRatio;
if (refundAmount <= 0f)
return;
networkController.RestoreMana(refundAmount);
}
private void EnsureReferences()
{
if (networkController == null)
networkController = GetComponent<PlayerNetworkController>();
if (skillController == null)
skillController = GetComponent<SkillController>();
if (weaponEquipment == null)
weaponEquipment = GetComponent<WeaponEquipment>();
}
private float ResolveCurrentSkillManaCost()
{
SkillLoadoutEntry loadoutEntry = skillController != null ? skillController.CurrentLoadoutEntry : null;
SkillData skill = loadoutEntry != null ? loadoutEntry.BaseSkill : skillController != null ? skillController.CurrentSkill : null;
if (skill == null)
return 0f;
float baseManaCost = loadoutEntry != null ? loadoutEntry.GetResolvedManaCost() : skill.ManaCost;
float weaponMultiplier = weaponEquipment != null ? weaponEquipment.ManaCostMultiplier : 1f;
float passiveMultiplier = PassiveRuntimeModifierUtility.GetManaCostMultiplier(gameObject, skill);
return baseManaCost * weaponMultiplier * passiveMultiplier;
}
private void ClearSustainState()
{
isSustainActive = false;
sustainElapsed = 0f;
currentSustainManaPerSecond = 0f;
capturedStartManaCost = 0f;
}
}
}

View File

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

View File

@@ -26,6 +26,9 @@ namespace Colosseum.Player
[Tooltip("이상상태 관리자 (없으면 자동 검색)")] [Tooltip("이상상태 관리자 (없으면 자동 검색)")]
[SerializeField] private AbnormalityManager abnormalityManager; [SerializeField] private AbnormalityManager abnormalityManager;
[Tooltip("방어 상태 관리자 (없으면 자동 검색)")]
[SerializeField] private PlayerDefenseController defenseController;
[Header("Shield")] [Header("Shield")]
[Tooltip("보호막 타입이 지정되지 않았을 때 사용할 기본 보호막 이상상태 데이터")] [Tooltip("보호막 타입이 지정되지 않았을 때 사용할 기본 보호막 이상상태 데이터")]
[SerializeField] private AbnormalityData shieldStateAbnormality; [SerializeField] private AbnormalityData shieldStateAbnormality;
@@ -101,6 +104,13 @@ namespace Colosseum.Player
abnormalityManager = GetComponent<AbnormalityManager>(); abnormalityManager = GetComponent<AbnormalityManager>();
} }
if (defenseController == null)
{
defenseController = GetComponent<PlayerDefenseController>();
if (defenseController == null)
defenseController = gameObject.AddComponent<PlayerDefenseController>();
}
EnsurePassiveRuntimeReferences(); EnsurePassiveRuntimeReferences();
currentHealth.OnValueChanged += HandleHealthChanged; currentHealth.OnValueChanged += HandleHealthChanged;
@@ -197,7 +207,7 @@ namespace Colosseum.Player
[Rpc(SendTo.Server)] [Rpc(SendTo.Server)]
public void TakeDamageRpc(float damage) public void TakeDamageRpc(float damage)
{ {
ApplyDamageInternal(damage, null); ApplyDamageInternal(new DamageContext(damage));
} }
/// <summary> /// <summary>
@@ -206,10 +216,7 @@ namespace Colosseum.Player
[Rpc(SendTo.Server)] [Rpc(SendTo.Server)]
public void UseManaRpc(float amount) public void UseManaRpc(float amount)
{ {
if (isDead.Value) SpendMana(amount);
return;
currentMana.Value = Mathf.Max(0f, currentMana.Value - amount);
} }
/// <summary> /// <summary>
@@ -230,10 +237,7 @@ namespace Colosseum.Player
[Rpc(SendTo.Server)] [Rpc(SendTo.Server)]
public void RestoreManaRpc(float amount) public void RestoreManaRpc(float amount)
{ {
if (isDead.Value) RestoreMana(amount);
return;
currentMana.Value = Mathf.Min(MaxMana, currentMana.Value + amount);
} }
/// <summary> /// <summary>
@@ -665,7 +669,15 @@ namespace Colosseum.Player
/// </summary> /// </summary>
public float TakeDamage(float damage, object source = null) public float TakeDamage(float damage, object source = null)
{ {
return ApplyDamageInternal(damage, source); return ApplyDamageInternal(new DamageContext(damage, source));
}
/// <summary>
/// 대미지 컨텍스트를 사용해 대미지를 적용합니다.
/// </summary>
public float TakeDamage(DamageContext damageContext)
{
return ApplyDamageInternal(damageContext);
} }
/// <summary> /// <summary>
@@ -682,6 +694,32 @@ namespace Colosseum.Player
return actualHeal; return actualHeal;
} }
/// <summary>
/// 마나를 소모하고 실제 소모량을 반환합니다.
/// </summary>
public float SpendMana(float amount)
{
if (!IsServer || isDead.Value || amount <= 0f)
return 0f;
float actualSpent = Mathf.Min(amount, currentMana.Value);
currentMana.Value = Mathf.Max(0f, currentMana.Value - actualSpent);
return actualSpent;
}
/// <summary>
/// 마나를 회복하고 실제 회복량을 반환합니다.
/// </summary>
public float RestoreMana(float amount)
{
if (!IsServer || isDead.Value || amount <= 0f)
return 0f;
float actualRestore = Mathf.Min(amount, MaxMana - currentMana.Value);
currentMana.Value = Mathf.Min(MaxMana, currentMana.Value + actualRestore);
return actualRestore;
}
/// <summary> /// <summary>
/// 보호막을 적용합니다. /// 보호막을 적용합니다.
/// </summary> /// </summary>
@@ -724,17 +762,29 @@ namespace Colosseum.Player
return remainingDamage; return remainingDamage;
} }
private float ApplyDamageInternal(float damage, object source) private float ApplyDamageInternal(DamageContext damageContext)
{ {
if (!IsServer || isDead.Value || IsDamageImmune()) if (!IsServer || isDead.Value || IsDamageImmune())
return 0f; return 0f;
float finalDamage = damage * GetIncomingDamageMultiplier(); if (defenseController == null)
defenseController = GetComponent<PlayerDefenseController>();
float rawDamage = damageContext.Amount;
if (rawDamage <= 0f)
return 0f;
if (defenseController != null)
{
rawDamage = defenseController.ResolveIncomingDamage(damageContext.WithAmount(rawDamage));
}
float finalDamage = rawDamage * GetIncomingDamageMultiplier();
float mitigatedDamage = ConsumeShield(finalDamage); float mitigatedDamage = ConsumeShield(finalDamage);
float actualDamage = Mathf.Min(mitigatedDamage, currentHealth.Value); float actualDamage = Mathf.Min(mitigatedDamage, currentHealth.Value);
currentHealth.Value = Mathf.Max(0f, currentHealth.Value - actualDamage); currentHealth.Value = Mathf.Max(0f, currentHealth.Value - actualDamage);
CombatBalanceTracker.RecordDamage(source as GameObject, gameObject, actualDamage); CombatBalanceTracker.RecordDamage(damageContext.SourceGameObject, gameObject, actualDamage);
if (currentHealth.Value <= 0f) if (currentHealth.Value <= 0f)
{ {

View File

@@ -872,16 +872,43 @@ namespace Colosseum.Player
private void OnSkill6Canceled(InputAction.CallbackContext context) => OnSkillCanceled(); private void OnSkill6Canceled(InputAction.CallbackContext context) => OnSkillCanceled();
/// <summary> /// <summary>
/// 스킬 버튼 해제 시 채널링 중단을 알립니다. /// 스킬 버튼 해제 시 반복 유지 단계 중단을 알립니다.
/// </summary> /// </summary>
private void OnSkillCanceled() private void OnSkillCanceled()
{ {
if (skillController != null && skillController.IsChannelingActive) if (skillController != null && skillController.CurrentSkill != null && skillController.CurrentSkill.RequiresLoopHold)
{ {
skillController.NotifyChannelHoldReleased(); skillController.NotifyLoopHoldReleased();
if (IsOwner)
RequestChannelHoldReleaseRpc();
} }
} }
/// <summary>
/// 반복 유지 입력 해제를 서버에 알리고, 다른 클라이언트에도 종료를 동기화합니다.
/// </summary>
[Rpc(SendTo.Server)]
private void RequestChannelHoldReleaseRpc()
{
if (skillController == null || skillController.CurrentSkill == null || !skillController.CurrentSkill.RequiresLoopHold)
return;
skillController.NotifyLoopHoldReleased();
SyncChannelHoldReleaseRpc();
}
/// <summary>
/// 서버에서 확정된 반복 유지 종료를 클라이언트에 전파합니다.
/// </summary>
[Rpc(SendTo.NotServer)]
private void SyncChannelHoldReleaseRpc()
{
if (skillController == null || skillController.CurrentSkill == null || !skillController.CurrentSkill.RequiresLoopHold)
return;
skillController.NotifyLoopHoldReleased();
}
private PlayerActionState GetOrCreateActionState() private PlayerActionState GetOrCreateActionState()
{ {
var foundState = GetComponent<PlayerActionState>(); var foundState = GetComponent<PlayerActionState>();

View File

@@ -31,6 +31,10 @@ namespace Colosseum.Skills.Effects
[Tooltip("스탯 계수 (1.0 = 100%)")] [Tooltip("스탯 계수 (1.0 = 100%)")]
[Min(0f)] [SerializeField] private float statScaling = 1f; [Min(0f)] [SerializeField] private float statScaling = 1f;
[Header("Mitigation")]
[Tooltip("이 피해가 방어/회피 규칙에서 어떤 판정으로 처리되는지 설정합니다.")]
[SerializeField] private DamageMitigationTier mitigationTier = DamageMitigationTier.Normal;
public float BaseDamage => baseDamage; public float BaseDamage => baseDamage;
public DamageType DamageKind => damageType; public DamageType DamageKind => damageType;
public float StatScaling => statScaling; public float StatScaling => statScaling;
@@ -46,7 +50,7 @@ namespace Colosseum.Skills.Effects
var damageable = target.GetComponent<IDamageable>(); var damageable = target.GetComponent<IDamageable>();
if (damageable != null) if (damageable != null)
{ {
damageable.TakeDamage(totalDamage, caster); damageable.TakeDamage(new DamageContext(totalDamage, caster, mitigationTier));
} }
} }

View File

@@ -27,6 +27,10 @@ namespace Colosseum.Skills.Effects
[Tooltip("다운 상태 대상에게 적용되는 추가 피해 배율")] [Tooltip("다운 상태 대상에게 적용되는 추가 피해 배율")]
[Min(1f)] [SerializeField] private float downedDamageMultiplier = 1.5f; [Min(1f)] [SerializeField] private float downedDamageMultiplier = 1.5f;
[Header("Mitigation")]
[Tooltip("이 피해가 방어/회피 규칙에서 어떤 판정으로 처리되는지 설정합니다.")]
[SerializeField] private DamageMitigationTier mitigationTier = DamageMitigationTier.Normal;
protected override void ApplyEffect(GameObject caster, GameObject target) protected override void ApplyEffect(GameObject caster, GameObject target)
{ {
if (target == null) if (target == null)
@@ -49,7 +53,7 @@ namespace Colosseum.Skills.Effects
totalDamage *= downedDamageMultiplier; totalDamage *= downedDamageMultiplier;
} }
damageable.TakeDamage(totalDamage, caster); damageable.TakeDamage(new DamageContext(totalDamage, caster, mitigationTier));
} }
/// <summary> /// <summary>

View File

@@ -26,6 +26,7 @@ namespace Colosseum.Skills
Stun, Stun,
Stagger, Stagger,
HitReaction, HitReaction,
ResourceExhausted,
Respawn, Respawn,
Revive, Revive,
} }
@@ -90,14 +91,18 @@ namespace Colosseum.Skills
private int currentIterationIndex = 0; private int currentIterationIndex = 0;
private GameObject currentTargetOverride; private GameObject currentTargetOverride;
private Vector3? currentGroundTargetPosition; private Vector3? currentGroundTargetPosition;
private IReadOnlyList<AnimationClip> currentPhaseAnimationClips = Array.Empty<AnimationClip>();
private bool isPlayingReleasePhase = false;
// 채널링 상태 // 반복 유지 단계 상태
private bool isChannelingActive = false; private bool isLoopPhaseActive = false;
private float channelElapsedTime = 0f; private float loopElapsedTime = 0f;
private float channelTickAccumulator = 0f; private float loopTickAccumulator = 0f;
private GameObject channelVfxInstance; private GameObject loopVfxInstance;
private readonly List<SkillEffect> currentChannelTickEffects = new(); private readonly List<SkillEffect> currentLoopTickEffects = new();
private readonly List<SkillEffect> currentChannelEndEffects = new(); private readonly List<SkillEffect> currentLoopExitEffects = new();
private readonly List<SkillEffect> currentReleaseStartEffects = new();
private bool loopHoldRequested = false;
// 쿨타임 추적 // 쿨타임 추적
private Dictionary<SkillData, float> cooldownTracker = new Dictionary<SkillData, float>(); private Dictionary<SkillData, float> cooldownTracker = new Dictionary<SkillData, float>();
@@ -114,7 +119,8 @@ namespace Colosseum.Skills
public string LastCancelledSkillName => lastCancelledSkillName; public string LastCancelledSkillName => lastCancelledSkillName;
public SkillExecutionResult LastExecutionResult => lastExecutionResult; public SkillExecutionResult LastExecutionResult => lastExecutionResult;
public GameObject CurrentTargetOverride => currentTargetOverride; public GameObject CurrentTargetOverride => currentTargetOverride;
public bool IsChannelingActive => isChannelingActive; public bool IsChannelingActive => isLoopPhaseActive;
public bool IsLoopPhaseActive => isLoopPhaseActive;
private void Awake() private void Awake()
{ {
@@ -251,10 +257,10 @@ namespace Colosseum.Skills
UpdateCastTargetTracking(); UpdateCastTargetTracking();
// 채널링 중일 때 // 반복 유지 단계 중일 때
if (isChannelingActive) if (isLoopPhaseActive)
{ {
UpdateChanneling(); UpdateLoopPhase();
return; return;
} }
@@ -267,18 +273,18 @@ namespace Colosseum.Skills
if (TryPlayNextClipInSequence()) if (TryPlayNextClipInSequence())
return; return;
// 다음 반복 차수가 있으면 시작 if (!isPlayingReleasePhase)
if (TryStartNextIteration())
return;
// 채널링 스킬이면 채널링 시작
if (currentSkill.IsChanneling)
{ {
StartChanneling(); // 다음 반복 차수가 있으면 시작
return; if (TryStartNextIteration())
return;
// 반복 유지 단계가 있으면 시작
if (TryStartLoopPhase())
return;
} }
// 모든 클립과 반복이 끝나면 종료 // 모든 클립과 단계가 끝나면 종료
if (debugMode) Debug.Log($"[Skill] Animation complete: {currentSkill.SkillName}"); if (debugMode) Debug.Log($"[Skill] Animation complete: {currentSkill.SkillName}");
RestoreBaseController(); RestoreBaseController();
CompleteCurrentSkillExecution(SkillExecutionResult.Completed); CompleteCurrentSkillExecution(SkillExecutionResult.Completed);
@@ -376,6 +382,7 @@ namespace Colosseum.Skills
BuildResolvedEffects(currentLoadoutEntry); BuildResolvedEffects(currentLoadoutEntry);
currentRepeatCount = currentLoadoutEntry.GetResolvedRepeatCount(); currentRepeatCount = currentLoadoutEntry.GetResolvedRepeatCount();
currentIterationIndex = 0; currentIterationIndex = 0;
loopHoldRequested = skill.RequiresLoopHold;
if (debugMode) Debug.Log($"[Skill] Cast: {skill.SkillName}"); if (debugMode) Debug.Log($"[Skill] Cast: {skill.SkillName}");
@@ -492,6 +499,9 @@ namespace Colosseum.Skills
currentTriggeredEffects.Clear(); currentTriggeredEffects.Clear();
currentCastStartAbnormalities.Clear(); currentCastStartAbnormalities.Clear();
currentTriggeredAbnormalities.Clear(); currentTriggeredAbnormalities.Clear();
currentLoopTickEffects.Clear();
currentLoopExitEffects.Clear();
currentReleaseStartEffects.Clear();
if (loadoutEntry == null) if (loadoutEntry == null)
return; return;
@@ -500,8 +510,9 @@ namespace Colosseum.Skills
loadoutEntry.CollectTriggeredEffects(currentTriggeredEffects); loadoutEntry.CollectTriggeredEffects(currentTriggeredEffects);
loadoutEntry.CollectCastStartAbnormalities(currentCastStartAbnormalities); loadoutEntry.CollectCastStartAbnormalities(currentCastStartAbnormalities);
loadoutEntry.CollectTriggeredAbnormalities(currentTriggeredAbnormalities); loadoutEntry.CollectTriggeredAbnormalities(currentTriggeredAbnormalities);
loadoutEntry.CollectChannelTickEffects(currentChannelTickEffects); loadoutEntry.CollectLoopTickEffects(currentLoopTickEffects);
loadoutEntry.CollectChannelEndEffects(currentChannelEndEffects); loadoutEntry.CollectLoopExitEffects(currentLoopExitEffects);
loadoutEntry.CollectReleaseStartEffects(currentReleaseStartEffects);
} }
/// <summary> /// <summary>
@@ -514,6 +525,8 @@ namespace Colosseum.Skills
currentIterationIndex++; currentIterationIndex++;
currentClipSequenceIndex = 0; currentClipSequenceIndex = 0;
isPlayingReleasePhase = false;
currentPhaseAnimationClips = currentSkill.AnimationClips;
if (debugMode && currentRepeatCount > 1) if (debugMode && currentRepeatCount > 1)
{ {
@@ -522,13 +535,13 @@ namespace Colosseum.Skills
TriggerCastStartEffects(); TriggerCastStartEffects();
if (currentSkill.AnimationClips.Count > 0 && animator != null) if (currentPhaseAnimationClips.Count > 0 && animator != null)
{ {
float resolvedAnimationSpeed = currentLoadoutEntry != null float resolvedAnimationSpeed = currentLoadoutEntry != null
? currentLoadoutEntry.GetResolvedAnimationSpeed() ? currentLoadoutEntry.GetResolvedAnimationSpeed()
: currentSkill.AnimationSpeed; : currentSkill.AnimationSpeed;
animator.speed = resolvedAnimationSpeed; animator.speed = resolvedAnimationSpeed;
PlaySkillClip(currentSkill.AnimationClips[0]); PlaySkillClip(currentPhaseAnimationClips[0]);
} }
TriggerImmediateSelfEffectsIfNeeded(); TriggerImmediateSelfEffectsIfNeeded();
@@ -542,16 +555,19 @@ namespace Colosseum.Skills
if (currentSkill == null) if (currentSkill == null)
return false; return false;
if (currentPhaseAnimationClips == null)
return false;
int nextIndex = currentClipSequenceIndex + 1; int nextIndex = currentClipSequenceIndex + 1;
if (nextIndex >= currentSkill.AnimationClips.Count) if (nextIndex >= currentPhaseAnimationClips.Count)
return false; return false;
currentClipSequenceIndex = nextIndex; currentClipSequenceIndex = nextIndex;
PlaySkillClip(currentSkill.AnimationClips[currentClipSequenceIndex]); PlaySkillClip(currentPhaseAnimationClips[currentClipSequenceIndex]);
if (debugMode) if (debugMode)
{ {
Debug.Log($"[Skill] Playing clip {currentClipSequenceIndex + 1}/{currentSkill.AnimationClips.Count}: {currentSkill.AnimationClips[currentClipSequenceIndex].name}"); Debug.Log($"[Skill] Playing clip {currentClipSequenceIndex + 1}/{currentPhaseAnimationClips.Count}: {currentPhaseAnimationClips[currentClipSequenceIndex].name}");
} }
return true; return true;
@@ -725,6 +741,45 @@ namespace Colosseum.Skills
if (debugMode) Debug.Log($"[Skill] End event received: {currentSkill.SkillName}"); if (debugMode) Debug.Log($"[Skill] End event received: {currentSkill.SkillName}");
} }
/// <summary>
/// 애니메이션 이벤트에서 호출. 방어 상태를 시작합니다.
/// </summary>
public void OnDefenseStateEnter()
{
PlayerDefenseController defenseController = GetComponent<PlayerDefenseController>();
defenseController?.EnterDefenseState();
}
/// <summary>
/// 애니메이션 이벤트에서 호출. 방어 상태를 종료합니다.
/// </summary>
public void OnDefenseStateExit()
{
PlayerDefenseController defenseController = GetComponent<PlayerDefenseController>();
defenseController?.ExitDefenseState();
}
/// <summary>
/// 애니메이션 이벤트에서 호출. 방어 유지 자원 소모를 시작합니다.
/// </summary>
public void OnDefenseSustainEnter()
{
PlayerDefenseSustainController sustainController = GetComponent<PlayerDefenseSustainController>();
if (sustainController == null)
sustainController = gameObject.AddComponent<PlayerDefenseSustainController>();
sustainController.BeginSustain();
}
/// <summary>
/// 애니메이션 이벤트에서 호출. 방어 유지 자원 소모를 종료합니다.
/// </summary>
public void OnDefenseSustainExit()
{
PlayerDefenseSustainController sustainController = GetComponent<PlayerDefenseSustainController>();
sustainController?.EndSustain();
}
/// <summary> /// <summary>
/// 현재 스킬을 강제 취소합니다. /// 현재 스킬을 강제 취소합니다.
/// </summary> /// </summary>
@@ -743,6 +798,33 @@ namespace Colosseum.Skills
return true; return true;
} }
/// <summary>
/// 서버에서 현재 스킬 취소를 확정하고 클라이언트에 동기화합니다.
/// </summary>
public bool CancelSkillFromServer(SkillCancelReason reason)
{
bool cancelled = CancelSkill(reason);
if (!cancelled)
return false;
if (NetworkManager.Singleton != null && NetworkManager.Singleton.IsServer)
{
SyncCancelledSkillClientRpc((int)reason);
}
return true;
}
[Rpc(SendTo.NotServer)]
private void SyncCancelledSkillClientRpc(int reasonValue)
{
SkillCancelReason reason = System.Enum.IsDefined(typeof(SkillCancelReason), reasonValue)
? (SkillCancelReason)reasonValue
: SkillCancelReason.Manual;
CancelSkill(reason);
}
public bool IsOnCooldown(SkillData skill) public bool IsOnCooldown(SkillData skill)
{ {
if (!cooldownTracker.ContainsKey(skill)) if (!cooldownTracker.ContainsKey(skill))
@@ -776,49 +858,64 @@ namespace Colosseum.Skills
} }
/// <summary> /// <summary>
/// 채널링을 시작합니다. 캐스트 애니메이션 종료 후 호출됩니다. /// 반복 유지 단계를 시작합니다. 캐스트 애니메이션 종료 후 호출됩니다.
/// </summary> /// </summary>
private void StartChanneling() private bool TryStartLoopPhase()
{ {
if (currentSkill == null || !currentSkill.IsChanneling) if (currentSkill == null || !currentSkill.HasLoopPhase)
return; return false;
isChannelingActive = true; if (currentSkill.RequiresLoopHold && !loopHoldRequested)
channelElapsedTime = 0f; {
channelTickAccumulator = 0f; if (debugMode)
Debug.Log($"[Skill] 반복 유지 진입 전 버튼 해제됨: {currentSkill.SkillName}");
SpawnChannelVfx(); if (TryStartReleasePhase())
return true;
RestoreBaseController();
CompleteCurrentSkillExecution(SkillExecutionResult.Cancelled);
return true;
}
isLoopPhaseActive = true;
loopElapsedTime = 0f;
loopTickAccumulator = 0f;
SpawnLoopVfx();
if (debugMode) if (debugMode)
Debug.Log($"[Skill] 채널링 시작: {currentSkill.SkillName} (duration={currentSkill.ChannelDuration}s, tick={currentSkill.ChannelTickInterval}s)"); Debug.Log($"[Skill] 반복 유지 시작: {currentSkill.SkillName} (duration={currentSkill.LoopMaxDuration}s, tick={currentSkill.LoopTickInterval}s)");
return true;
} }
/// <summary> /// <summary>
/// 채널링 VFX를 시전자 위치에 생성합니다. /// 반복 유지 VFX를 시전자 위치에 생성합니다.
/// </summary> /// </summary>
private void SpawnChannelVfx() private void SpawnLoopVfx()
{ {
if (currentSkill == null || currentSkill.ChannelVfxPrefab == null) if (currentSkill == null || currentSkill.LoopVfxPrefab == null)
return; return;
Transform mount = ResolveChannelVfxMount(); Transform mount = ResolveLoopVfxMount();
Vector3 spawnPos = mount != null ? mount.position : transform.position; Vector3 spawnPos = mount != null ? mount.position : transform.position;
channelVfxInstance = UnityEngine.Object.Instantiate( loopVfxInstance = UnityEngine.Object.Instantiate(
currentSkill.ChannelVfxPrefab, currentSkill.LoopVfxPrefab,
spawnPos, spawnPos,
transform.rotation); transform.rotation);
if (mount != null) if (mount != null)
channelVfxInstance.transform.SetParent(mount); loopVfxInstance.transform.SetParent(mount);
channelVfxInstance.transform.localScale = new Vector3( loopVfxInstance.transform.localScale = new Vector3(
currentSkill.ChannelVfxWidthScale, currentSkill.LoopVfxWidthScale,
currentSkill.ChannelVfxWidthScale, currentSkill.LoopVfxWidthScale,
currentSkill.ChannelVfxLengthScale); currentSkill.LoopVfxLengthScale);
// 모든 파티클을 루핑 모드로 설정 // 모든 파티클을 루핑 모드로 설정
ForceLoopParticleSystems(channelVfxInstance); ForceLoopParticleSystems(loopVfxInstance);
} }
/// <summary> /// <summary>
@@ -842,24 +939,24 @@ namespace Colosseum.Skills
} }
/// <summary> /// <summary>
/// channelVfxMountPath에서 VFX 장착 위치를 찾습니다. /// loopVfxMountPath에서 VFX 장착 위치를 찾습니다.
/// </summary> /// </summary>
private Transform ResolveChannelVfxMount() private Transform ResolveLoopVfxMount()
{ {
if (currentSkill == null || string.IsNullOrEmpty(currentSkill.ChannelVfxMountPath)) if (currentSkill == null || string.IsNullOrEmpty(currentSkill.LoopVfxMountPath))
return null; return null;
// Animator 하위에서 이름으로 재귀 검색 // Animator 하위에서 이름으로 재귀 검색
Animator animator = GetComponentInChildren<Animator>(); Animator animator = GetComponentInChildren<Animator>();
if (animator != null) if (animator != null)
{ {
Transform found = FindTransformRecursive(animator.transform, currentSkill.ChannelVfxMountPath); Transform found = FindTransformRecursive(animator.transform, currentSkill.LoopVfxMountPath);
if (found != null) if (found != null)
return found; return found;
} }
// 자식 GameObject에서 경로 검색 // 자식 GameObject에서 경로 검색
return transform.Find(currentSkill.ChannelVfxMountPath); return transform.Find(currentSkill.LoopVfxMountPath);
} }
private static Transform FindTransformRecursive(Transform parent, string name) private static Transform FindTransformRecursive(Transform parent, string name)
@@ -878,60 +975,60 @@ namespace Colosseum.Skills
} }
/// <summary> /// <summary>
/// 채널링 VFX를 파괴합니다. /// 반복 유지 VFX를 파괴합니다.
/// </summary> /// </summary>
private void DestroyChannelVfx() private void DestroyLoopVfx()
{ {
if (channelVfxInstance != null) if (loopVfxInstance != null)
{ {
UnityEngine.Object.Destroy(channelVfxInstance); UnityEngine.Object.Destroy(loopVfxInstance);
channelVfxInstance = null; loopVfxInstance = null;
} }
} }
/// <summary> /// <summary>
/// 채널링을 매 프레임 업데이트합니다. 틱 효과를 주기적으로 발동합니다. /// 반복 유지 단계를 매 프레임 업데이트합니다. 틱 효과를 주기적으로 발동합니다.
/// </summary> /// </summary>
private void UpdateChanneling() private void UpdateLoopPhase()
{ {
if (!isChannelingActive || currentSkill == null) if (!isLoopPhaseActive || currentSkill == null)
return; return;
channelElapsedTime += Time.deltaTime; loopElapsedTime += Time.deltaTime;
channelTickAccumulator += Time.deltaTime; loopTickAccumulator += Time.deltaTime;
// 틱 효과 발동 // 틱 효과 발동
float tickInterval = currentSkill.ChannelTickInterval; float tickInterval = currentSkill.LoopTickInterval;
while (channelTickAccumulator >= tickInterval) while (loopTickAccumulator >= tickInterval)
{ {
channelTickAccumulator -= tickInterval; loopTickAccumulator -= tickInterval;
TriggerChannelTick(); TriggerLoopTick();
} }
// 지속 시간 초과 → 채널링 종료 // 지속 시간 초과 → 반복 유지 종료
if (channelElapsedTime >= currentSkill.ChannelDuration) if (currentSkill.UsesLoopMaxDuration && loopElapsedTime >= currentSkill.LoopMaxDuration)
{ {
if (debugMode) if (debugMode)
Debug.Log($"[Skill] 채널링 지속 시간 만료: {currentSkill.SkillName}"); Debug.Log($"[Skill] 반복 유지 지속 시간 만료: {currentSkill.SkillName}");
EndChanneling(); EndLoopPhase();
} }
} }
/// <summary> /// <summary>
/// 채널링 틱 효과를 발동합니다. /// 반복 유지 틱 효과를 발동합니다.
/// </summary> /// </summary>
private void TriggerChannelTick() private void TriggerLoopTick()
{ {
if (currentChannelTickEffects.Count == 0) if (currentLoopTickEffects.Count == 0)
return; return;
if (debugMode) if (debugMode)
Debug.Log($"[Skill] 채널링 틱 발동: {currentSkill.SkillName} (elapsed={channelElapsedTime:F1}s)"); Debug.Log($"[Skill] 반복 유지 틱 발동: {currentSkill.SkillName} (elapsed={loopElapsedTime:F1}s)");
// VFX는 모든 클라이언트에서 로컬 실행 // VFX는 모든 클라이언트에서 로컬 실행
for (int i = 0; i < currentChannelTickEffects.Count; i++) for (int i = 0; i < currentLoopTickEffects.Count; i++)
{ {
SkillEffect effect = currentChannelTickEffects[i]; SkillEffect effect = currentLoopTickEffects[i];
if (effect != null && effect.IsVisualOnly) if (effect != null && effect.IsVisualOnly)
{ {
effect.ExecuteOnCast(gameObject, currentTargetOverride, currentGroundTargetPosition); effect.ExecuteOnCast(gameObject, currentTargetOverride, currentGroundTargetPosition);
@@ -942,14 +1039,14 @@ namespace Colosseum.Skills
if (NetworkManager.Singleton != null && !NetworkManager.Singleton.IsServer) if (NetworkManager.Singleton != null && !NetworkManager.Singleton.IsServer)
return; return;
for (int i = 0; i < currentChannelTickEffects.Count; i++) for (int i = 0; i < currentLoopTickEffects.Count; i++)
{ {
SkillEffect effect = currentChannelTickEffects[i]; SkillEffect effect = currentLoopTickEffects[i];
if (effect == null || effect.IsVisualOnly) if (effect == null || effect.IsVisualOnly)
continue; continue;
if (debugMode) if (debugMode)
Debug.Log($"[Skill] 채널링 틱 효과: {effect.name}"); Debug.Log($"[Skill] 반복 유지 틱 효과: {effect.name}");
if (showAreaDebug) if (showAreaDebug)
effect.DrawDebugRange(gameObject, debugDrawDuration, currentGroundTargetPosition); effect.DrawDebugRange(gameObject, debugDrawDuration, currentGroundTargetPosition);
@@ -959,40 +1056,43 @@ namespace Colosseum.Skills
} }
/// <summary> /// <summary>
/// 채널링을 종료합니다. 종료 효과를 발동하고 스킬 상태를 정리합니다. /// 반복 유지 단계를 종료합니다. 종료 효과를 발동하고 다음 단계를 시작합니다.
/// </summary> /// </summary>
private void EndChanneling() private void EndLoopPhase()
{ {
if (!isChannelingActive) if (!isLoopPhaseActive)
return; return;
// 채널링 종료 효과 발동 // 반복 유지 종료 효과 발동
TriggerChannelEndEffects(); TriggerLoopExitEffects();
DestroyChannelVfx(); DestroyLoopVfx();
isChannelingActive = false; isLoopPhaseActive = false;
channelElapsedTime = 0f; loopElapsedTime = 0f;
channelTickAccumulator = 0f; loopTickAccumulator = 0f;
if (debugMode) if (debugMode)
Debug.Log($"[Skill] 채널링 종료: {currentSkill?.SkillName}"); Debug.Log($"[Skill] 반복 유지 종료: {currentSkill?.SkillName}");
if (TryStartReleasePhase())
return;
RestoreBaseController(); RestoreBaseController();
CompleteCurrentSkillExecution(SkillExecutionResult.Completed); CompleteCurrentSkillExecution(SkillExecutionResult.Completed);
} }
/// <summary> /// <summary>
/// 채널링 종료 효과를 발동합니다. /// 반복 유지 종료 효과를 발동합니다.
/// </summary> /// </summary>
private void TriggerChannelEndEffects() private void TriggerLoopExitEffects()
{ {
if (currentChannelEndEffects.Count == 0) if (currentLoopExitEffects.Count == 0)
return; return;
// VFX는 모든 클라이언트에서 로컬 실행 // VFX는 모든 클라이언트에서 로컬 실행
for (int i = 0; i < currentChannelEndEffects.Count; i++) for (int i = 0; i < currentLoopExitEffects.Count; i++)
{ {
SkillEffect effect = currentChannelEndEffects[i]; SkillEffect effect = currentLoopExitEffects[i];
if (effect != null && effect.IsVisualOnly) if (effect != null && effect.IsVisualOnly)
{ {
effect.ExecuteOnCast(gameObject, currentTargetOverride, currentGroundTargetPosition); effect.ExecuteOnCast(gameObject, currentTargetOverride, currentGroundTargetPosition);
@@ -1003,32 +1103,104 @@ namespace Colosseum.Skills
if (NetworkManager.Singleton != null && !NetworkManager.Singleton.IsServer) if (NetworkManager.Singleton != null && !NetworkManager.Singleton.IsServer)
return; return;
for (int i = 0; i < currentChannelEndEffects.Count; i++) for (int i = 0; i < currentLoopExitEffects.Count; i++)
{ {
SkillEffect effect = currentChannelEndEffects[i]; SkillEffect effect = currentLoopExitEffects[i];
if (effect == null || effect.IsVisualOnly) if (effect == null || effect.IsVisualOnly)
continue; continue;
if (debugMode) if (debugMode)
Debug.Log($"[Skill] 채널링 종료 효과: {effect.name}"); Debug.Log($"[Skill] 반복 유지 종료 효과: {effect.name}");
effect.ExecuteOnCast(gameObject, currentTargetOverride, currentGroundTargetPosition); effect.ExecuteOnCast(gameObject, currentTargetOverride, currentGroundTargetPosition);
} }
} }
/// <summary> /// <summary>
/// 플레이어가 버튼을 놓았을 때 채널링을 중단합니다. /// 해제 단계를 시작합니다.
/// </summary>
private bool TryStartReleasePhase()
{
if (currentSkill == null || !currentSkill.HasReleasePhase)
return false;
currentClipSequenceIndex = 0;
isPlayingReleasePhase = true;
currentPhaseAnimationClips = currentSkill.ReleaseAnimationClips;
TriggerReleaseStartEffects();
if (currentPhaseAnimationClips.Count <= 0 || animator == null)
return false;
float resolvedAnimationSpeed = currentLoadoutEntry != null
? currentLoadoutEntry.GetResolvedAnimationSpeed()
: currentSkill.AnimationSpeed;
animator.speed = resolvedAnimationSpeed;
PlaySkillClip(currentPhaseAnimationClips[0]);
if (debugMode)
Debug.Log($"[Skill] 해제 단계 시작: {currentSkill.SkillName}");
return true;
}
/// <summary>
/// 해제 단계 시작 효과를 발동합니다.
/// </summary>
private void TriggerReleaseStartEffects()
{
if (currentReleaseStartEffects.Count == 0)
return;
for (int i = 0; i < currentReleaseStartEffects.Count; i++)
{
SkillEffect effect = currentReleaseStartEffects[i];
if (effect != null && effect.IsVisualOnly)
{
effect.ExecuteOnCast(gameObject, currentTargetOverride, currentGroundTargetPosition);
}
}
if (NetworkManager.Singleton != null && !NetworkManager.Singleton.IsServer)
return;
for (int i = 0; i < currentReleaseStartEffects.Count; i++)
{
SkillEffect effect = currentReleaseStartEffects[i];
if (effect == null || effect.IsVisualOnly)
continue;
effect.ExecuteOnCast(gameObject, currentTargetOverride, currentGroundTargetPosition);
}
}
/// <summary>
/// 플레이어가 버튼을 놓았을 때 반복 유지 단계를 중단합니다.
/// PlayerSkillInput에서 호출됩니다. /// PlayerSkillInput에서 호출됩니다.
/// </summary> /// </summary>
public void NotifyChannelHoldReleased() public void NotifyLoopHoldReleased()
{ {
if (!isChannelingActive) if (currentSkill == null || !currentSkill.RequiresLoopHold)
return;
loopHoldRequested = false;
if (!isLoopPhaseActive)
return; return;
if (debugMode) if (debugMode)
Debug.Log($"[Skill] 채널링 버튼 해제로 중단: {currentSkill?.SkillName}"); Debug.Log($"[Skill] 반복 유지 버튼 해제로 중단: {currentSkill?.SkillName}");
EndChanneling(); EndLoopPhase();
}
/// <summary>
/// 레거시 채널링 입력 해제 경로 호환 메서드입니다.
/// </summary>
public void NotifyChannelHoldReleased()
{
NotifyLoopHoldReleased();
} }
/// <summary> /// <summary>
@@ -1043,17 +1215,21 @@ namespace Colosseum.Skills
currentCastStartAbnormalities.Clear(); currentCastStartAbnormalities.Clear();
currentTriggeredAbnormalities.Clear(); currentTriggeredAbnormalities.Clear();
currentTriggeredTargetsBuffer.Clear(); currentTriggeredTargetsBuffer.Clear();
currentChannelTickEffects.Clear(); currentLoopTickEffects.Clear();
currentChannelEndEffects.Clear(); currentLoopExitEffects.Clear();
isChannelingActive = false; currentReleaseStartEffects.Clear();
channelElapsedTime = 0f; isLoopPhaseActive = false;
channelTickAccumulator = 0f; loopElapsedTime = 0f;
DestroyChannelVfx(); loopTickAccumulator = 0f;
DestroyLoopVfx();
currentTargetOverride = null; currentTargetOverride = null;
currentGroundTargetPosition = null; currentGroundTargetPosition = null;
currentPhaseAnimationClips = Array.Empty<AnimationClip>();
isPlayingReleasePhase = false;
currentClipSequenceIndex = 0; currentClipSequenceIndex = 0;
currentRepeatCount = 1; currentRepeatCount = 1;
currentIterationIndex = 0; currentIterationIndex = 0;
loopHoldRequested = false;
} }
/// <summary> /// <summary>
@@ -1062,9 +1238,19 @@ namespace Colosseum.Skills
private void CompleteCurrentSkillExecution(SkillExecutionResult result) private void CompleteCurrentSkillExecution(SkillExecutionResult result)
{ {
lastExecutionResult = result; lastExecutionResult = result;
NotifyDefenseStateEnded();
ClearCurrentSkillState(); ClearCurrentSkillState();
} }
private void NotifyDefenseStateEnded()
{
PlayerDefenseController defenseController = GetComponent<PlayerDefenseController>();
defenseController?.HandleSkillExecutionEnded();
PlayerDefenseSustainController sustainController = GetComponent<PlayerDefenseSustainController>();
sustainController?.HandleSkillExecutionEnded();
}
/// <summary> /// <summary>
/// 적 스킬이 시전 중일 때 대상 추적 정책을 적용합니다. /// 적 스킬이 시전 중일 때 대상 추적 정책을 적용합니다.
/// </summary> /// </summary>

View File

@@ -60,6 +60,96 @@ namespace Colosseum.Skills
MoveTowardTarget, MoveTowardTarget,
} }
/// <summary>
/// 반복 유지 단계의 입력/종료 조건입니다.
/// </summary>
public enum SkillLoopMode
{
None,
Timed,
HoldWhilePressed,
HoldWhilePressedWithMaxDuration,
}
/// <summary>
/// 채널링 스킬의 반복 유지 단계 데이터입니다.
/// </summary>
[Serializable]
public class SkillLoopPhaseData
{
[Tooltip("이 채널링 스킬이 반복 유지 단계를 사용하는지 여부")]
[SerializeField] private bool enabled = false;
[Tooltip("반복 유지 단계의 종료 규칙입니다.")]
[SerializeField] private SkillLoopMode loopMode = SkillLoopMode.Timed;
[Tooltip("반복 유지 최대 지속 시간 (초). 모드가 시간 제한을 사용할 때만 의미가 있습니다.")]
[Min(0f)] [SerializeField] private float maxDuration = 3f;
[Tooltip("반복 유지 틱 간격 (초). 이 간격마다 tickEffects가 발동합니다.")]
[Min(0.05f)] [SerializeField] private float tickInterval = 0.5f;
[Tooltip("반복 유지 중 주기적으로 발동하는 효과 목록")]
[SerializeField] private List<SkillEffect> tickEffects = new();
[Tooltip("반복 유지 종료 시 발동하는 효과 목록")]
[SerializeField] private List<SkillEffect> exitEffects = new();
[Tooltip("반복 유지 중 지속되는 VFX 프리팹")]
[SerializeField] private GameObject loopVfxPrefab;
[Tooltip("VFX 생성 기준 위치의 Transform 경로. 비어있으면 루트 위치.")]
[SerializeField] private string loopVfxMountPath;
[Tooltip("반복 유지 VFX 길이 배율")]
[Min(0.01f)] [SerializeField] private float loopVfxLengthScale = 1f;
[Tooltip("반복 유지 VFX 폭 배율")]
[Min(0.01f)] [SerializeField] private float loopVfxWidthScale = 1f;
public bool Enabled => enabled;
public SkillLoopMode LoopMode => enabled ? loopMode : SkillLoopMode.None;
public float MaxDuration => maxDuration;
public float TickInterval => tickInterval;
public IReadOnlyList<SkillEffect> TickEffects => tickEffects;
public IReadOnlyList<SkillEffect> ExitEffects => exitEffects;
public GameObject LoopVfxPrefab => loopVfxPrefab;
public string LoopVfxMountPath => loopVfxMountPath;
public float LoopVfxLengthScale => loopVfxLengthScale;
public float LoopVfxWidthScale => loopVfxWidthScale;
public bool RequiresHoldInput => enabled && (loopMode == SkillLoopMode.HoldWhilePressed || loopMode == SkillLoopMode.HoldWhilePressedWithMaxDuration);
public bool UsesMaxDuration => enabled && (loopMode == SkillLoopMode.Timed || loopMode == SkillLoopMode.HoldWhilePressedWithMaxDuration);
public bool HasAuthoringData =>
enabled ||
(tickEffects != null && tickEffects.Count > 0) ||
(exitEffects != null && exitEffects.Count > 0) ||
loopVfxPrefab != null ||
!string.IsNullOrWhiteSpace(loopVfxMountPath);
public void ApplyLegacyChanneling(float legacyDuration, float legacyTickInterval, List<SkillEffect> legacyTickEffects, List<SkillEffect> legacyExitEffects, GameObject legacyVfxPrefab, string legacyVfxMountPath, float legacyVfxLengthScale, float legacyVfxWidthScale)
{
enabled = true;
loopMode = legacyDuration > 0f ? SkillLoopMode.Timed : SkillLoopMode.HoldWhilePressed;
maxDuration = Mathf.Max(0f, legacyDuration);
tickInterval = Mathf.Max(0.05f, legacyTickInterval);
tickEffects = legacyTickEffects != null ? new List<SkillEffect>(legacyTickEffects) : new List<SkillEffect>();
exitEffects = legacyExitEffects != null ? new List<SkillEffect>(legacyExitEffects) : new List<SkillEffect>();
loopVfxPrefab = legacyVfxPrefab;
loopVfxMountPath = legacyVfxMountPath;
loopVfxLengthScale = Mathf.Max(0.01f, legacyVfxLengthScale);
loopVfxWidthScale = Mathf.Max(0.01f, legacyVfxWidthScale);
}
}
/// <summary>
/// 채널링 스킬의 해제 단계 데이터입니다.
/// </summary>
[Serializable]
public class SkillReleasePhaseData
{
[Tooltip("이 채널링 스킬이 해제 단계를 사용하는지 여부")]
[SerializeField] private bool enabled = false;
[Tooltip("반복 유지 종료 뒤 순차 재생할 해제 클립 목록")]
[SerializeField] private List<AnimationClip> animationClips = new();
[Tooltip("해제 단계 시작 즉시 발동하는 효과 목록")]
[SerializeField] private List<SkillEffect> startEffects = new();
public bool Enabled => enabled && ((animationClips != null && animationClips.Count > 0) || (startEffects != null && startEffects.Count > 0));
public IReadOnlyList<AnimationClip> AnimationClips => animationClips;
public IReadOnlyList<SkillEffect> StartEffects => startEffects;
}
/// <summary> /// <summary>
/// 스킬 데이터. 스킬의 기본 정보와 효과 목록을 관리합니다. /// 스킬 데이터. 스킬의 기본 정보와 효과 목록을 관리합니다.
/// </summary> /// </summary>
@@ -77,7 +167,11 @@ namespace Colosseum.Skills
/// </summary> /// </summary>
private void OnValidate() private void OnValidate()
{ {
bool changed = MigrateLegacyExecutionPhases();
RefreshAnimationClips(); RefreshAnimationClips();
if (changed)
UnityEditor.EditorUtility.SetDirty(this);
} }
/// <summary> /// <summary>
@@ -219,24 +313,24 @@ namespace Colosseum.Skills
[SerializeField] private List<SkillTriggeredEffectEntry> triggeredEffects = new(); [SerializeField] private List<SkillTriggeredEffectEntry> triggeredEffects = new();
[Header("채널링")] [Header("채널링")]
[Tooltip("이 스킬이 채널링 스킬인지 여부. 캐스트 애니메이션 종료 후 채널링이 시작됩니다.")] [Tooltip("이 스킬이 채널링 스킬인지 여부. 켜져 있을 때만 반복 유지/해제 단계를 사용합니다.")]
[SerializeField] private bool isChanneling = false; [SerializeField] private bool isChanneling = false;
[Tooltip("채널링 최대 지속 시간 (초)")]
[Min(0.1f)] [SerializeField] private float channelDuration = 3f; [Header("반복 유지 단계")]
[Tooltip("채널링 틱 간격 (초). 이 간격마다 channelTickEffects가 발동합니다.")] [SerializeField] private SkillLoopPhaseData loopPhase = new();
[Min(0.05f)] [SerializeField] private float channelTickInterval = 0.5f;
[Tooltip("채널링 중 주기적으로 발동하는 효과 목록")] [Header("해제 단계")]
[SerializeField] private List<SkillEffect> channelTickEffects = new(); [SerializeField] private SkillReleasePhaseData releasePhase = new();
[Tooltip("채널링 종료 시 발동하는 효과 목록 (지속 시간 만료 시)")]
[SerializeField] private List<SkillEffect> channelEndEffects = new(); [Header("레거시 채널링 데이터")]
[Tooltip("채널링 중 지속되는 VFX 프리팹. 채널링 시작에 시전자 위치에 생성되고 종료에 파괴됩니다.")] [HideInInspector] [Min(0f)] [SerializeField] private float channelDuration = 3f;
[SerializeField] private GameObject channelVfxPrefab; [HideInInspector] [Min(0.05f)] [SerializeField] private float channelTickInterval = 0.5f;
[Tooltip("VFX 생성 기준 위치의 Transform 경로. Animator 본 이름 (예: RightHand, Head) 또는 자식 GameObject 경로. 비어있으면 루트 위치.")] [HideInInspector] [SerializeField] private List<SkillEffect> channelTickEffects = new();
[SerializeField] private string channelVfxMountPath; [HideInInspector] [SerializeField] private List<SkillEffect> channelEndEffects = new();
[Tooltip("채널링 VFX 길이 배율. 빔의 진행 방향 (z축) 크기를 조절합니다.")] [HideInInspector] [SerializeField] private GameObject channelVfxPrefab;
[Min(0.01f)] [SerializeField] private float channelVfxLengthScale = 1f; [HideInInspector] [SerializeField] private string channelVfxMountPath;
[Tooltip("채널링 VFX 폭 배율. 빔의 너비 (x/y축) 크기를 조절합니다.")] [HideInInspector] [Min(0.01f)] [SerializeField] private float channelVfxLengthScale = 1f;
[Min(0.01f)] [SerializeField] private float channelVfxWidthScale = 1f; [HideInInspector] [Min(0.01f)] [SerializeField] private float channelVfxWidthScale = 1f;
// Properties // Properties
public string SkillName => skillName; public string SkillName => skillName;
@@ -273,14 +367,32 @@ namespace Colosseum.Skills
public IReadOnlyList<SkillTriggeredEffectEntry> TriggeredEffects => triggeredEffects; public IReadOnlyList<SkillTriggeredEffectEntry> TriggeredEffects => triggeredEffects;
public WeaponTrait AllowedWeaponTraits => allowedWeaponTraits; public WeaponTrait AllowedWeaponTraits => allowedWeaponTraits;
public bool IsChanneling => isChanneling; public bool IsChanneling => isChanneling;
public float ChannelDuration => channelDuration; public SkillLoopPhaseData LoopPhase => GetResolvedLoopPhase();
public float ChannelTickInterval => channelTickInterval; public SkillReleasePhaseData ReleasePhase => GetResolvedReleasePhase();
public IReadOnlyList<SkillEffect> ChannelTickEffects => channelTickEffects; public bool HasLoopPhase => isChanneling && GetResolvedLoopPhase().Enabled;
public IReadOnlyList<SkillEffect> ChannelEndEffects => channelEndEffects; public bool RequiresLoopHold => HasLoopPhase && GetResolvedLoopPhase().RequiresHoldInput;
public GameObject ChannelVfxPrefab => channelVfxPrefab; public bool UsesLoopMaxDuration => HasLoopPhase && GetResolvedLoopPhase().UsesMaxDuration;
public string ChannelVfxMountPath => channelVfxMountPath; public float LoopMaxDuration => HasLoopPhase ? GetResolvedLoopPhase().MaxDuration : 0f;
public float ChannelVfxLengthScale => channelVfxLengthScale; public bool IsInfiniteLoop => HasLoopPhase && !UsesLoopMaxDuration;
public float ChannelVfxWidthScale => channelVfxWidthScale; public float LoopTickInterval => HasLoopPhase ? GetResolvedLoopPhase().TickInterval : 0.05f;
public IReadOnlyList<SkillEffect> LoopTickEffects => HasLoopPhase ? GetResolvedLoopPhase().TickEffects : Array.Empty<SkillEffect>();
public IReadOnlyList<SkillEffect> LoopExitEffects => HasLoopPhase ? GetResolvedLoopPhase().ExitEffects : Array.Empty<SkillEffect>();
public GameObject LoopVfxPrefab => HasLoopPhase ? GetResolvedLoopPhase().LoopVfxPrefab : null;
public string LoopVfxMountPath => HasLoopPhase ? GetResolvedLoopPhase().LoopVfxMountPath : string.Empty;
public float LoopVfxLengthScale => HasLoopPhase ? GetResolvedLoopPhase().LoopVfxLengthScale : 1f;
public float LoopVfxWidthScale => HasLoopPhase ? GetResolvedLoopPhase().LoopVfxWidthScale : 1f;
public bool HasReleasePhase => isChanneling && GetResolvedReleasePhase().Enabled;
public IReadOnlyList<AnimationClip> ReleaseAnimationClips => HasReleasePhase ? GetResolvedReleasePhase().AnimationClips : Array.Empty<AnimationClip>();
public IReadOnlyList<SkillEffect> ReleaseStartEffects => HasReleasePhase ? GetResolvedReleasePhase().StartEffects : Array.Empty<SkillEffect>();
public float ChannelDuration => LoopMaxDuration;
public bool IsInfiniteChannel => IsInfiniteLoop;
public float ChannelTickInterval => LoopTickInterval;
public IReadOnlyList<SkillEffect> ChannelTickEffects => LoopTickEffects;
public IReadOnlyList<SkillEffect> ChannelEndEffects => LoopExitEffects;
public GameObject ChannelVfxPrefab => LoopVfxPrefab;
public string ChannelVfxMountPath => LoopVfxMountPath;
public float ChannelVfxLengthScale => LoopVfxLengthScale;
public float ChannelVfxWidthScale => LoopVfxWidthScale;
/// <summary> /// <summary>
/// 지정한 장착 조건과 현재 스킬 분류가 맞는지 확인합니다. /// 지정한 장착 조건과 현재 스킬 분류가 맞는지 확인합니다.
@@ -318,6 +430,62 @@ namespace Colosseum.Skills
return value.IndexOf("구르기", StringComparison.OrdinalIgnoreCase) >= 0 return value.IndexOf("구르기", StringComparison.OrdinalIgnoreCase) >= 0
|| value.IndexOf("회피", StringComparison.OrdinalIgnoreCase) >= 0; || value.IndexOf("회피", StringComparison.OrdinalIgnoreCase) >= 0;
} }
private SkillLoopPhaseData GetResolvedLoopPhase()
{
if (loopPhase == null)
loopPhase = new SkillLoopPhaseData();
if (loopPhase.HasAuthoringData || !isChanneling)
return loopPhase;
loopPhase.ApplyLegacyChanneling(
channelDuration,
channelTickInterval,
channelTickEffects,
channelEndEffects,
channelVfxPrefab,
channelVfxMountPath,
channelVfxLengthScale,
channelVfxWidthScale);
return loopPhase;
}
private SkillReleasePhaseData GetResolvedReleasePhase()
{
if (releasePhase == null)
releasePhase = new SkillReleasePhaseData();
return releasePhase;
}
#if UNITY_EDITOR
private bool MigrateLegacyExecutionPhases()
{
if (!isChanneling)
return false;
if (loopPhase == null)
{
loopPhase = new SkillLoopPhaseData();
}
if (loopPhase.HasAuthoringData)
return false;
loopPhase.ApplyLegacyChanneling(
channelDuration,
channelTickInterval,
channelTickEffects,
channelEndEffects,
channelVfxPrefab,
channelVfxMountPath,
channelVfxLengthScale,
channelVfxWidthScale);
return true;
}
#endif
} }
/// <summary> /// <summary>

View File

@@ -397,18 +397,18 @@ namespace Colosseum.Skills
} }
/// <summary> /// <summary>
/// 기반 스킬의 채널링 틱 효과를 수집합니다. /// 기반 스킬의 반복 유지 틱 효과를 수집합니다.
/// </summary> /// </summary>
public void CollectChannelTickEffects(List<SkillEffect> destination) public void CollectLoopTickEffects(List<SkillEffect> destination)
{ {
if (destination == null) if (destination == null)
return; return;
if (baseSkill != null && baseSkill.ChannelTickEffects != null) if (baseSkill != null && baseSkill.LoopTickEffects != null)
{ {
for (int i = 0; i < baseSkill.ChannelTickEffects.Count; i++) for (int i = 0; i < baseSkill.LoopTickEffects.Count; i++)
{ {
SkillEffect effect = baseSkill.ChannelTickEffects[i]; SkillEffect effect = baseSkill.LoopTickEffects[i];
if (effect != null) if (effect != null)
destination.Add(effect); destination.Add(effect);
} }
@@ -426,18 +426,18 @@ namespace Colosseum.Skills
} }
/// <summary> /// <summary>
/// 기반 스킬의 채널링 종료 효과를 수집합니다. /// 기반 스킬의 반복 유지 종료 효과를 수집합니다.
/// </summary> /// </summary>
public void CollectChannelEndEffects(List<SkillEffect> destination) public void CollectLoopExitEffects(List<SkillEffect> destination)
{ {
if (destination == null) if (destination == null)
return; return;
if (baseSkill != null && baseSkill.ChannelEndEffects != null) if (baseSkill != null && baseSkill.LoopExitEffects != null)
{ {
for (int i = 0; i < baseSkill.ChannelEndEffects.Count; i++) for (int i = 0; i < baseSkill.LoopExitEffects.Count; i++)
{ {
SkillEffect effect = baseSkill.ChannelEndEffects[i]; SkillEffect effect = baseSkill.LoopExitEffects[i];
if (effect != null) if (effect != null)
destination.Add(effect); destination.Add(effect);
} }
@@ -454,6 +454,51 @@ namespace Colosseum.Skills
} }
} }
/// <summary>
/// 기반 스킬의 해제 단계 시작 효과를 수집합니다.
/// </summary>
public void CollectReleaseStartEffects(List<SkillEffect> destination)
{
if (destination == null)
return;
if (baseSkill != null && baseSkill.ReleaseStartEffects != null)
{
for (int i = 0; i < baseSkill.ReleaseStartEffects.Count; i++)
{
SkillEffect effect = baseSkill.ReleaseStartEffects[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)
continue;
}
}
/// <summary>
/// 레거시 채널링 틱 효과 수집 호환 경로입니다.
/// </summary>
public void CollectChannelTickEffects(List<SkillEffect> destination)
{
CollectLoopTickEffects(destination);
}
/// <summary>
/// 레거시 채널링 종료 효과 수집 호환 경로입니다.
/// </summary>
public void CollectChannelEndEffects(List<SkillEffect> destination)
{
CollectLoopExitEffects(destination);
}
private static void AddTriggeredEffect(Dictionary<int, List<SkillEffect>> destination, int triggerIndex, SkillEffect effect) private static void AddTriggeredEffect(Dictionary<int, List<SkillEffect>> destination, int triggerIndex, SkillEffect effect)
{ {
if (!destination.TryGetValue(triggerIndex, out List<SkillEffect> effectList)) if (!destination.TryGetValue(triggerIndex, out List<SkillEffect> effectList))

View File

@@ -26,6 +26,34 @@ namespace Colosseum.UI
[Tooltip("이상상태 요약 텍스트를 자동 생성할지 여부")] [Tooltip("이상상태 요약 텍스트를 자동 생성할지 여부")]
[SerializeField] private bool autoCreateAbnormalitySummary = true; [SerializeField] private bool autoCreateAbnormalitySummary = true;
[Header("Defense Feedback")]
[Tooltip("방어 성공 피드백 텍스트 (비어 있으면 런타임에 자동 생성)")]
[SerializeField] private TMP_Text defenseFeedbackText;
[Tooltip("방어 성공 피드백 텍스트를 자동 생성할지 여부")]
[SerializeField] private bool autoCreateDefenseFeedback = true;
[Tooltip("일반 방어 성공 시 표시할 텍스트")]
[SerializeField] private string guardFeedbackLabel = "방어";
[Tooltip("완벽한 방어 성공 시 표시할 텍스트")]
[SerializeField] private string perfectGuardFeedbackLabel = "완벽 방어";
[Tooltip("완벽한 방어 유효 시간 동안 표시할 텍스트")]
[SerializeField] private string perfectGuardWindowLabel = "완벽 창";
[Tooltip("일반 방어 성공 텍스트 색상")]
[SerializeField] private Color guardFeedbackColor = new Color(0.65f, 0.86f, 1f, 1f);
[Tooltip("완벽한 방어 성공 텍스트 색상")]
[SerializeField] private Color perfectGuardFeedbackColor = new Color(1f, 0.92f, 0.45f, 1f);
[Tooltip("완벽한 방어 유효 시간 표시 색상")]
[SerializeField] private Color perfectGuardWindowColor = new Color(0.62f, 1f, 0.88f, 1f);
[Tooltip("방어 성공 텍스트 표시 시간")]
[Min(0.1f)] [SerializeField] private float defenseFeedbackDuration = 0.75f;
[Header("Passive UI")] [Header("Passive UI")]
[Tooltip("런타임 패시브 UI 컴포넌트를 자동으로 보정할지 여부")] [Tooltip("런타임 패시브 UI 컴포넌트를 자동으로 보정할지 여부")]
[SerializeField] private bool autoCreatePassiveTreeUi = true; [SerializeField] private bool autoCreatePassiveTreeUi = true;
@@ -36,7 +64,9 @@ namespace Colosseum.UI
private PlayerNetworkController targetPlayer; private PlayerNetworkController targetPlayer;
private AbnormalityManager targetAbnormalityManager; private AbnormalityManager targetAbnormalityManager;
private PlayerDefenseController targetDefenseController;
private float abnormalityRefreshTimer; private float abnormalityRefreshTimer;
private float defenseFeedbackRemaining;
private const float AbnormalityRefreshInterval = 0.1f; private const float AbnormalityRefreshInterval = 0.1f;
@@ -77,6 +107,8 @@ namespace Colosseum.UI
UpdateAbnormalitySummary(); UpdateAbnormalitySummary();
} }
} }
UpdateDefenseFeedback();
} }
private void OnDestroy() private void OnDestroy()
@@ -107,6 +139,7 @@ namespace Colosseum.UI
targetPlayer = player; targetPlayer = player;
targetAbnormalityManager = targetPlayer != null ? targetPlayer.GetComponent<AbnormalityManager>() : null; targetAbnormalityManager = targetPlayer != null ? targetPlayer.GetComponent<AbnormalityManager>() : null;
targetDefenseController = targetPlayer != null ? targetPlayer.GetComponent<PlayerDefenseController>() : null;
// 새 타겟 구독 // 새 타겟 구독
SubscribeToEvents(); SubscribeToEvents();
@@ -114,6 +147,7 @@ namespace Colosseum.UI
// 초기 값 설정 // 초기 값 설정
UpdateStatBars(); UpdateStatBars();
UpdateAbnormalitySummary(); UpdateAbnormalitySummary();
ClearDefenseFeedback();
} }
private void SubscribeToEvents() private void SubscribeToEvents()
@@ -123,6 +157,11 @@ namespace Colosseum.UI
targetPlayer.OnHealthChanged += HandleHealthChanged; targetPlayer.OnHealthChanged += HandleHealthChanged;
targetPlayer.OnManaChanged += HandleManaChanged; targetPlayer.OnManaChanged += HandleManaChanged;
targetPlayer.OnShieldChanged += HandleShieldChanged; targetPlayer.OnShieldChanged += HandleShieldChanged;
if (targetDefenseController != null)
{
targetDefenseController.OnDefenseStateEntered += HandleDefenseStateEntered;
targetDefenseController.OnDefenseResolved += HandleDefenseResolved;
}
if (targetAbnormalityManager != null) if (targetAbnormalityManager != null)
{ {
@@ -141,6 +180,12 @@ namespace Colosseum.UI
targetPlayer.OnShieldChanged -= HandleShieldChanged; targetPlayer.OnShieldChanged -= HandleShieldChanged;
} }
if (targetDefenseController != null)
{
targetDefenseController.OnDefenseStateEntered -= HandleDefenseStateEntered;
targetDefenseController.OnDefenseResolved -= HandleDefenseResolved;
}
if (targetAbnormalityManager != null) if (targetAbnormalityManager != null)
{ {
targetAbnormalityManager.OnAbnormalityAdded -= HandleAbnormalityAdded; targetAbnormalityManager.OnAbnormalityAdded -= HandleAbnormalityAdded;
@@ -244,6 +289,49 @@ namespace Colosseum.UI
abnormalitySummaryText = summaryText; abnormalitySummaryText = summaryText;
} }
private void EnsureDefenseFeedbackText()
{
if (defenseFeedbackText != null || !autoCreateDefenseFeedback)
return;
if (transform is not RectTransform parentRect)
return;
GameObject feedbackObject = new GameObject("DefenseFeedbackText", typeof(RectTransform));
feedbackObject.transform.SetParent(parentRect, false);
RectTransform rectTransform = feedbackObject.GetComponent<RectTransform>();
rectTransform.anchorMin = new Vector2(0.5f, 0f);
rectTransform.anchorMax = new Vector2(0.5f, 0f);
rectTransform.pivot = new Vector2(0.5f, 0f);
rectTransform.anchoredPosition = new Vector2(0f, 116f);
rectTransform.sizeDelta = new Vector2(360f, 48f);
TextMeshProUGUI feedback = feedbackObject.AddComponent<TextMeshProUGUI>();
feedback.fontSize = 28f;
feedback.fontStyle = FontStyles.Bold;
feedback.alignment = TextAlignmentOptions.Center;
feedback.textWrappingMode = TextWrappingModes.NoWrap;
feedback.richText = true;
feedback.alpha = 0f;
feedback.text = string.Empty;
TMP_FontAsset feedbackFont = healthBar != null && healthBar.FontAsset != null
? healthBar.FontAsset
: manaBar != null ? manaBar.FontAsset : null;
if (feedbackFont != null)
{
feedback.font = feedbackFont;
}
else if (TMP_Settings.defaultFontAsset != null)
{
feedback.font = TMP_Settings.defaultFontAsset;
}
defenseFeedbackText = feedback;
}
private void EnsurePassiveTreeUi() private void EnsurePassiveTreeUi()
{ {
if (!autoCreatePassiveTreeUi || GetComponent<PassiveTreeUI>() != null) if (!autoCreatePassiveTreeUi || GetComponent<PassiveTreeUI>() != null)
@@ -298,5 +386,79 @@ namespace Colosseum.UI
abnormalitySummaryText.text = builder.ToString(); abnormalitySummaryText.text = builder.ToString();
} }
private void HandleDefenseStateEntered(float perfectWindowDuration)
{
ShowDefenseFeedback(
perfectGuardWindowLabel,
perfectGuardWindowColor,
Mathf.Max(0.05f, perfectWindowDuration));
}
private void HandleDefenseResolved(bool isPerfectGuard, float preventedDamage)
{
if (preventedDamage <= 0f)
return;
ShowDefenseFeedback(
isPerfectGuard ? perfectGuardFeedbackLabel : guardFeedbackLabel,
isPerfectGuard ? perfectGuardFeedbackColor : guardFeedbackColor,
defenseFeedbackDuration);
}
private void UpdateDefenseFeedback()
{
if (defenseFeedbackRemaining <= 0f)
return;
if (defenseFeedbackText == null)
{
EnsureDefenseFeedbackText();
}
if (defenseFeedbackText == null)
return;
defenseFeedbackRemaining = Mathf.Max(0f, defenseFeedbackRemaining - Time.deltaTime);
defenseFeedbackText.alpha = defenseFeedbackDuration > 0f
? defenseFeedbackRemaining / defenseFeedbackDuration
: 0f;
if (defenseFeedbackRemaining <= 0f)
{
defenseFeedbackText.text = string.Empty;
defenseFeedbackText.alpha = 0f;
}
}
private void ClearDefenseFeedback()
{
defenseFeedbackRemaining = 0f;
if (defenseFeedbackText == null)
return;
defenseFeedbackText.text = string.Empty;
defenseFeedbackText.alpha = 0f;
}
private void ShowDefenseFeedback(string message, Color color, float duration)
{
if (string.IsNullOrEmpty(message))
return;
if (defenseFeedbackText == null)
{
EnsureDefenseFeedbackText();
}
if (defenseFeedbackText == null)
return;
defenseFeedbackRemaining = Mathf.Max(0.05f, duration);
defenseFeedbackText.text = message;
defenseFeedbackText.color = color;
defenseFeedbackText.alpha = 1f;
}
} }
} }