fix: 드로그 기본기3 시작 루트모션과 추적 보정 정리

- 기본기3 시작 프레임의 수평 루트모션 스냅을 차단하고 정면 투영/접촉 정지 거리 보정을 정리
- 스킬 완료 판정과 시작 타깃 정렬 로직을 보강하고 드로그/플레이어 정면 및 시작점 디버그 gizmo를 추가
- 드로그 기본기3 관련 애니메이션·패턴·스킬 자산을 재정리하고 Blender 보조 스크립트를 추가
This commit is contained in:
2026-04-17 09:56:40 +09:00
parent 5ba543ed8c
commit 8c08e63c81
20 changed files with 173170 additions and 162503 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -1,5 +1,5 @@
fileFormatVersion: 2
guid: d2aea82703e56bb44abb129504693c7e
guid: cde2d1a448bf3be59b2c9bbebe2ad7e3
NativeFormatImporter:
externalObjects: {}
mainObjectFileID: 7400000

File diff suppressed because it is too large Load Diff

View File

@@ -1,8 +1,8 @@
fileFormatVersion: 2
guid: fc435c80b8f1348f889910629f8eec51
guid: 3f077b889a0c441a8be8f51575b34f37
NativeFormatImporter:
externalObjects: {}
mainObjectFileID: 11400000
mainObjectFileID: 7400000
userData:
assetBundleName:
assetBundleVariant:

File diff suppressed because it is too large Load Diff

View File

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

View File

@@ -476,7 +476,7 @@ AnimatorState:
m_MirrorParameterActive: 0
m_CycleOffsetParameterActive: 0
m_TimeParameterActive: 0
m_Motion: {fileID: 7400000, guid: ed7913df0854ae86a9374a34d7ebdc7d, type: 2}
m_Motion: {fileID: 7400000, guid: ebc8e8e59d375104babccd596aa3bdc0, type: 2}
m_Tag:
m_SpeedParameter:
m_MirrorParameter:

View File

@@ -33,7 +33,7 @@ MonoBehaviour:
telegraphAbnormality: {fileID: 0}
staggerDuration: 2
- Type: 0
Skill: {fileID: 11400000, guid: fc435c80b8f1348f889910629f8eec51, type: 2}
Skill: {fileID: 0}
Duration: 0
ChargeData:
requiredDamageRatio: 0.1

View File

@@ -19,7 +19,7 @@ MonoBehaviour:
activationType: 1
baseTypes: 1
animationClips:
- {fileID: 7400000, guid: d2aea82703e56bb44abb129504693c7e, type: 2}
- {fileID: 7400000, guid: cde2d1a448bf3be59b2c9bbebe2ad7e3, type: 2}
animationSpeed: 1
useRootMotion: 1
ignoreRootMotionY: 1
@@ -27,7 +27,7 @@ MonoBehaviour:
blockMovementWhileCasting: 1
blockJumpWhileCasting: 1
blockOtherSkillsWhileCasting: 1
castTargetTrackingMode: 1
castTargetTrackingMode: 3
castTargetRotationSpeed: 12
castTargetStopDistance: 2.5
allowedWeaponTraits: 0

View File

@@ -19,7 +19,8 @@ MonoBehaviour:
activationType: 1
baseTypes: 1
animationClips:
- {fileID: 7400000, guid: 32273d0b13198f443852f3b1ebbd760e, type: 2}
- {fileID: 7400000, guid: 3f077b889a0c441a8be8f51575b34f37, type: 2}
- {fileID: 7400000, guid: e63e8710eded48197ae534daf847bbb5, type: 2}
animationSpeed: 1
useRootMotion: 1
ignoreRootMotionY: 1
@@ -27,7 +28,7 @@ MonoBehaviour:
blockMovementWhileCasting: 1
blockJumpWhileCasting: 1
blockOtherSkillsWhileCasting: 1
castTargetTrackingMode: 1
castTargetTrackingMode: 0
castTargetRotationSpeed: 12
castTargetStopDistance: 2.5
allowedWeaponTraits: 0

View File

@@ -1,65 +0,0 @@
%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_Drog_\uCF64\uBCF4-\uAE30\uBCF8\uAE303_3"
m_EditorClassIdentifier: Colosseum.Game::Colosseum.Skills.SkillData
skillName: "\uCF64\uBCF4-\uAE30\uBCF8\uAE303 3\uD0C0"
description: "\uAE30\uBCF8\uAE30 \uCF64\uBCF43\uC758 \uBC1C\uCC28\uAE30 \uB9C8\uBB34\uB9AC\uC785\uB2C8\uB2E4."
icon: {fileID: 0}
skillRole: 1
activationType: 1
baseTypes: 1
animationClips:
- {fileID: 7400000, guid: 9678ad326a270e9aa9cb5ebf5fa00279, type: 2}
animationSpeed: 1
useRootMotion: 1
ignoreRootMotionY: 1
jumpToTarget: 0
blockMovementWhileCasting: 1
blockJumpWhileCasting: 1
blockOtherSkillsWhileCasting: 1
castTargetTrackingMode: 1
castTargetRotationSpeed: 12
castTargetStopDistance: 2.5
allowedWeaponTraits: 0
cooldown: 0
manaCost: 0
maxGemSlotCount: 0
castStartEffects: []
triggeredEffects:
- triggerIndex: 0
effects:
- {fileID: 11400000, guid: 7061e70acfcf6971a8b451af29336e8a, type: 2}
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
channelTickInterval: 0.5
channelTickEffects: []
channelEndEffects: []
channelVfxPrefab: {fileID: 0}
channelVfxMountPath:
channelVfxLengthScale: 1
channelVfxWidthScale: 1

View File

@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 70661168ff52fffe7b990c89bbe7b0dc
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -0,0 +1,44 @@
using UnityEngine;
namespace Colosseum.Debugging
{
/// <summary>
/// 씬 뷰에서 캐릭터의 정면 방향을 화살표로 표시하는 Gizmo 유틸리티입니다.
/// </summary>
public static class FacingDirectionGizmoUtility
{
/// <summary>
/// 지정한 Transform의 정면 방향 화살표를 그립니다.
/// </summary>
public static void DrawFacingArrow(Transform targetTransform, Color color, float length = 1.6f, float headLength = 0.35f, float headWidth = 0.22f, float heightOffset = 0.1f, float shaftThickness = 0.08f)
{
if (targetTransform == null)
return;
Vector3 origin = targetTransform.position + Vector3.up * heightOffset;
Vector3 forward = targetTransform.forward.normalized;
if (forward.sqrMagnitude <= 0.0001f)
return;
Vector3 tip = origin + forward * length;
Vector3 right = targetTransform.right.normalized;
Vector3 up = targetTransform.up.normalized;
Vector3 headBase = tip - forward * headLength;
Vector3 rightOffset = right * shaftThickness;
Vector3 upOffset = up * shaftThickness;
Gizmos.color = color;
Gizmos.DrawLine(origin, tip);
Gizmos.DrawLine(origin + rightOffset, tip + rightOffset);
Gizmos.DrawLine(origin - rightOffset, tip - rightOffset);
Gizmos.DrawLine(origin + upOffset, tip + upOffset);
Gizmos.DrawLine(origin - upOffset, tip - upOffset);
Gizmos.DrawLine(tip, headBase + right * headWidth);
Gizmos.DrawLine(tip, headBase - right * headWidth);
Gizmos.DrawLine(tip, headBase + up * headWidth * 0.7f);
Gizmos.DrawLine(tip, headBase - up * headWidth * 0.7f);
Gizmos.DrawSphere(origin, shaftThickness * 0.55f);
Gizmos.DrawSphere(tip, shaftThickness * 0.65f);
}
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 3481720c56d01e6dfa9327a4c386a1cd

View File

@@ -2,6 +2,7 @@ using Unity.Behavior;
using Unity.Netcode;
using UnityEngine;
using Colosseum.Debugging;
using Colosseum.Stats;
namespace Colosseum.Enemy
@@ -11,6 +12,8 @@ namespace Colosseum.Enemy
/// </summary>
public class BossEnemy : EnemyBase
{
private static readonly Color FacingGizmoColor = new Color(1f, 0.25f, 0.2f, 1f);
[Header("Boss Settings")]
[Tooltip("초기 Behavior Graph")]
[SerializeField] private BehaviorGraph initialBehaviorGraph;
@@ -76,5 +79,10 @@ namespace Colosseum.Enemy
base.HandleDeath();
}
private void OnDrawGizmos()
{
FacingDirectionGizmoUtility.DrawFacingArrow(transform, FacingGizmoColor, length: 2.2f, headLength: 0.45f, headWidth: 0.28f, heightOffset: 0.15f, shaftThickness: 0.14f);
}
}
}

View File

@@ -52,6 +52,8 @@ namespace Colosseum.Enemy
[Header("Player Separation")]
[Tooltip("적과 플레이어 사이에 추가로 유지할 수평 간격")]
[Min(0f)] [SerializeField] private float playerSeparationPadding = 0.1f;
[Tooltip("실제 겹치기 직전에도 근접 기본기 루트모션을 미리 차단할 추가 수평 여유 거리")]
[Min(0f)] [SerializeField] private float playerSurfaceLockPadding = 0.75f;
[Tooltip("플레이어와 닿아 있을 때 적의 수평 이동을 멈출지 여부")]
[SerializeField] private bool freezeHorizontalMotionOnPlayerContact = true;
@@ -75,6 +77,10 @@ namespace Colosseum.Enemy
private bool hasJumpTarget = false;
private Vector3 jumpStartXZ;
private Vector3 jumpTargetXZ;
private bool isHoldingSkillTransitionPosition;
private bool hasLastStableSkillPosition;
private Vector3 heldSkillTransitionPosition;
private Vector3 lastStableSkillPosition;
// 이벤트
public event Action<float, float> OnHealthChanged; // currentHealth, maxHealth
public event Action<float, float> OnShieldChanged; // oldShield, newShield
@@ -145,7 +151,15 @@ namespace Colosseum.Enemy
{
if (!IsServer || IsDead) return;
if (freezeHorizontalMotionOnPlayerContact)
{
ApplySkillTransitionContactPositionHold();
if (!isHoldingSkillTransitionPosition)
{
lastStableSkillPosition = transform.position;
hasLastStableSkillPosition = true;
}
return;
}
Vector3 separationOffset = ComputePlayerSeparationOffset();
if (separationOffset.sqrMagnitude <= 0.000001f)
@@ -189,6 +203,31 @@ namespace Colosseum.Enemy
&& !skillCtrl.IgnoreRootMotionY;
Vector3 deltaPosition = animator.deltaPosition;
bool shouldApplyRootPosition = skillCtrl == null || skillCtrl.ShouldApplyRootMotionPosition;
if (!shouldApplyRootPosition)
{
deltaPosition.x = 0f;
deltaPosition.z = 0f;
}
if (skillCtrl != null && skillCtrl.ConsumeInitialSkillStartHorizontalRootMotionSuppression())
{
deltaPosition.x = 0f;
deltaPosition.z = 0f;
}
bool shouldProjectRootMotionToFacing = skillCtrl != null && skillCtrl.ShouldProjectHorizontalRootMotionToFacing;
if (shouldProjectRootMotionToFacing)
deltaPosition = ProjectHorizontalDeltaOntoFacing(deltaPosition);
if (skillCtrl != null)
deltaPosition = ClampRootMotionAgainstTargetStopDistance(skillCtrl, deltaPosition);
bool isTouchingPlayerContact = freezeHorizontalMotionOnPlayerContact && IsTouchingPlayer();
bool shouldFreezeSkillHorizontalMotion =
isTouchingPlayerContact &&
skillCtrl != null &&
skillCtrl.IsPlayingAnimation;
if (needsYMotion)
{
@@ -209,16 +248,41 @@ namespace Colosseum.Enemy
newXZ.x - transform.position.x,
deltaPosition.y,
newXZ.z - transform.position.z);
if (!shouldApplyRootPosition)
{
desiredDelta.x = 0f;
desiredDelta.z = 0f;
}
if (shouldProjectRootMotionToFacing)
desiredDelta = ProjectHorizontalDeltaOntoFacing(desiredDelta);
if (shouldFreezeSkillHorizontalMotion)
{
desiredDelta.x = 0f;
desiredDelta.z = 0f;
}
if (freezeHorizontalMotionOnPlayerContact)
{
if (isTouchingPlayerContact && !shouldFreezeSkillHorizontalMotion)
desiredDelta = ProjectHorizontalDeltaOntoFacing(desiredDelta);
desiredDelta = LimitHorizontalDeltaAgainstPlayerContact(desiredDelta);
}
transform.position += desiredDelta;
}
else
{
// jumpToTarget 없으면 기존처럼 애니메이션 루트모션 그대로 적용
if (shouldFreezeSkillHorizontalMotion)
{
deltaPosition.x = 0f;
deltaPosition.z = 0f;
}
if (freezeHorizontalMotionOnPlayerContact)
{
if (isTouchingPlayerContact && !shouldFreezeSkillHorizontalMotion)
deltaPosition = ProjectHorizontalDeltaOntoFacing(deltaPosition);
deltaPosition = LimitHorizontalDeltaAgainstPlayerContact(deltaPosition);
}
transform.position += deltaPosition;
}
@@ -249,13 +313,21 @@ namespace Colosseum.Enemy
if (freezeHorizontalMotionOnPlayerContact)
{
ApplyPlayerContactMovementLock();
if (shouldFreezeSkillHorizontalMotion)
{
deltaPosition.x = 0f;
deltaPosition.z = 0f;
}
else if (isTouchingPlayerContact)
deltaPosition = ProjectHorizontalDeltaOntoFacing(deltaPosition);
deltaPosition = LimitHorizontalDeltaAgainstPlayerContact(deltaPosition);
}
navMeshAgent.Move(deltaPosition);
}
if (animator.deltaRotation != Quaternion.identity)
bool shouldApplyRootRotation = skillCtrl == null || skillCtrl.ShouldApplyRootMotionRotation;
if (shouldApplyRootRotation && animator.deltaRotation != Quaternion.identity)
transform.rotation *= animator.deltaRotation;
}
@@ -389,6 +461,42 @@ namespace Colosseum.Enemy
return separationOffset;
}
/// <summary>
/// 스킬 시작 직후 아직 Animator가 Skill 상태로 전이되지 않은 접촉 프레임에서는
/// 수평 좌표를 직전 안정 위치에 고정해 전환 구간 드리프트를 막습니다.
/// </summary>
private void ApplySkillTransitionContactPositionHold()
{
var skillCtrl = GetComponent<SkillController>();
bool shouldHoldPosition =
skillCtrl != null &&
skillCtrl.IsPlayingAnimation &&
!skillCtrl.ShouldApplyRootMotionPosition &&
IsTouchingPlayer();
if (!shouldHoldPosition)
{
isHoldingSkillTransitionPosition = false;
return;
}
if (!isHoldingSkillTransitionPosition)
{
heldSkillTransitionPosition = hasLastStableSkillPosition
? lastStableSkillPosition
: transform.position;
isHoldingSkillTransitionPosition = true;
}
Vector3 lockedPosition = transform.position;
lockedPosition.x = heldSkillTransitionPosition.x;
lockedPosition.z = heldSkillTransitionPosition.z;
transform.position = lockedPosition;
if (navMeshAgent != null && navMeshAgent.enabled)
navMeshAgent.Warp(transform.position);
}
private bool IsTouchingPlayer()
{
if (bodyCollider == null)
@@ -421,6 +529,14 @@ namespace Colosseum.Enemy
if (horizontalDelta.sqrMagnitude <= 0.000001f)
return deltaPosition;
if (TryGetTouchingPlayerDirection(out Vector3 touchingPlayerDirection) &&
IsBlockedByContactForwardHemisphere(horizontalDelta, touchingPlayerDirection))
{
deltaPosition.x = 0f;
deltaPosition.z = 0f;
return deltaPosition;
}
horizontalDelta = ClampHorizontalDeltaToPlayerSurface(horizontalDelta);
if (horizontalDelta.sqrMagnitude <= 0.000001f)
{
@@ -447,6 +563,78 @@ namespace Colosseum.Enemy
return deltaPosition;
}
/// <summary>
/// 접촉 중인 상태에서는 루트모션의 수평 이동을 적의 정면축으로만 투영합니다.
/// 플레이어 표면에 걸리면서 측면으로 미끄러지는 현상을 줄이기 위한 보정입니다.
/// </summary>
private Vector3 ProjectHorizontalDeltaOntoFacing(Vector3 deltaPosition)
{
Vector3 horizontalDelta = new Vector3(deltaPosition.x, 0f, deltaPosition.z);
if (horizontalDelta.sqrMagnitude <= 0.000001f)
return deltaPosition;
Vector3 forward = transform.forward;
forward.y = 0f;
if (forward.sqrMagnitude <= 0.000001f)
return deltaPosition;
forward.Normalize();
float forwardDistance = Vector3.Dot(horizontalDelta, forward);
Vector3 projected = forward * forwardDistance;
deltaPosition.x = projected.x;
deltaPosition.z = projected.z;
return deltaPosition;
}
/// <summary>
/// 현재 실제로 겹치고 있는 플레이어 중심 방향을 수평 기준으로 구합니다.
/// 접촉 중 전방 진입을 즉시 차단하기 위한 단순화된 방향 계산입니다.
/// </summary>
private bool TryGetTouchingPlayerDirection(out Vector3 touchingPlayerDirection)
{
touchingPlayerDirection = Vector3.zero;
if (bodyCollider == null)
return false;
float scanRadius = GetPlayerDetectionRadius();
Vector3 scanCenter = bodyCollider.bounds.center;
int count = Physics.OverlapSphereNonAlloc(scanCenter, scanRadius, overlapBuffer);
int overlapCount = 0;
for (int i = 0; i < count; i++)
{
if (!TryGetPlayerCharacterController(overlapBuffer[i], out CharacterController playerController))
continue;
if (!Physics.ComputePenetration(
bodyCollider, bodyCollider.transform.position, bodyCollider.transform.rotation,
playerController, playerController.transform.position, playerController.transform.rotation,
out _, out float separationDistance) ||
separationDistance <= 0.0001f)
{
continue;
}
Vector3 towardPlayer = playerController.bounds.center - scanCenter;
towardPlayer.y = 0f;
if (towardPlayer.sqrMagnitude <= 0.0001f)
continue;
touchingPlayerDirection += towardPlayer.normalized;
overlapCount++;
}
if (overlapCount <= 0 || touchingPlayerDirection.sqrMagnitude <= 0.0001f)
{
touchingPlayerDirection = Vector3.zero;
return false;
}
touchingPlayerDirection.Normalize();
return true;
}
private Vector3 ClampHorizontalDeltaToPlayerSurface(Vector3 horizontalDelta)
{
if (bodyCollider == null || horizontalDelta.sqrMagnitude <= 0.000001f)
@@ -606,13 +794,64 @@ namespace Colosseum.Enemy
return horizontalDelta * min;
}
/// <summary>
/// 루트모션 근접 스킬이 대상 표면 정지 거리 안으로 파고들지 않도록 수평 이동을 제한합니다.
/// 현재는 MoveTowardTarget에만 쓰이던 stopDistance를 루트모션 근접기에도 동일하게 적용합니다.
/// </summary>
private Vector3 ClampRootMotionAgainstTargetStopDistance(SkillController skillCtrl, Vector3 deltaPosition)
{
if (skillCtrl == null || !skillCtrl.UsesRootMotion)
return deltaPosition;
SkillData currentSkill = skillCtrl.CurrentSkill;
GameObject currentTarget = skillCtrl.CurrentTargetOverride;
if (currentSkill == null || currentTarget == null || !currentTarget.activeInHierarchy)
return deltaPosition;
float stopDistance = Mathf.Max(0f, currentSkill.CastTargetStopDistance);
if (stopDistance <= 0f)
return deltaPosition;
Vector3 horizontalDelta = new Vector3(deltaPosition.x, 0f, deltaPosition.z);
if (horizontalDelta.sqrMagnitude <= 0.000001f)
return deltaPosition;
Vector3 towardTarget = TargetSurfaceUtility.GetHorizontalDirectionToSurface(transform.position, currentTarget);
if (towardTarget.sqrMagnitude <= 0.0001f)
return deltaPosition;
towardTarget.Normalize();
float currentSurfaceDistance = TargetSurfaceUtility.GetHorizontalSurfaceDistance(transform.position, currentTarget);
if (currentSurfaceDistance <= stopDistance)
{
deltaPosition.x = 0f;
deltaPosition.z = 0f;
return deltaPosition;
}
float towardDistance = Vector3.Dot(horizontalDelta, towardTarget);
if (towardDistance <= 0f)
return deltaPosition;
float allowedTowardDistance = Mathf.Max(0f, currentSurfaceDistance - stopDistance);
if (towardDistance <= allowedTowardDistance)
return deltaPosition;
Vector3 lateralDelta = horizontalDelta - towardTarget * towardDistance;
Vector3 clampedHorizontalDelta = lateralDelta + towardTarget * allowedTowardDistance;
deltaPosition.x = clampedHorizontalDelta.x;
deltaPosition.z = clampedHorizontalDelta.z;
return deltaPosition;
}
private bool WouldCrossPlayerSurface(Vector3 horizontalOffset)
{
if (bodyCollider == null)
return false;
float enemyRadius = GetBodyHorizontalRadius();
float minimumSurfaceDistance = enemyRadius + playerSeparationPadding;
float minimumSurfaceDistance = enemyRadius + playerSeparationPadding + playerSurfaceLockPadding;
float scanRadius = GetPlayerDetectionRadius() + horizontalOffset.magnitude;
Vector3 scanCenter = bodyCollider.bounds.center + horizontalOffset;
int count = Physics.OverlapSphereNonAlloc(scanCenter, scanRadius, overlapBuffer);

View File

@@ -8,6 +8,7 @@ using Unity.Netcode;
using Colosseum.Abnormalities;
using Colosseum.Combat;
using Colosseum.Debugging;
using Colosseum.Passives;
using Colosseum.Skills;
using Colosseum.Stats;
@@ -19,6 +20,8 @@ namespace Colosseum.Player
/// </summary>
public class PlayerNetworkController : NetworkBehaviour, IDamageable
{
private static readonly Color FacingGizmoColor = new Color(0.2f, 0.85f, 1f, 1f);
[Header("References")]
[Tooltip("CharacterStats 컴포넌트 (없으면 자동 검색)")]
[SerializeField] private CharacterStats characterStats;
@@ -219,6 +222,11 @@ namespace Colosseum.Player
SpendMana(amount);
}
private void OnDrawGizmos()
{
FacingDirectionGizmoUtility.DrawFacingArrow(transform, FacingGizmoColor, length: 1.7f, headLength: 0.35f, headWidth: 0.22f, heightOffset: 0.12f, shaftThickness: 0.11f);
}
/// <summary>
/// 체력 회복 (서버에서만 실행)
/// </summary>

View File

@@ -55,6 +55,8 @@ namespace Colosseum.Skills
private const string IdleStateName = "Idle";
private const string BossIdlePhase1StateName = "Idle_Phase1";
private const string BossIdlePhase3StateName = "Idle_Phase3";
private const int MaxDebugSkillStartPoints = 16;
private static readonly Color SkillStartPointGizmoColor = new Color(1f, 0.92f, 0.2f, 0.95f);
[Header("애니메이션")]
[SerializeField] private Animator animator;
@@ -122,6 +124,8 @@ namespace Colosseum.Skills
private bool shouldBlendIntoCurrentSkill = true;
private bool shouldRestoreToIdleAfterCurrentSkill = true;
private bool isBossPatternBoundarySkill = false;
private bool hasAppliedStartTargetFacing = false;
private bool shouldSuppressInitialSkillStartHorizontalRootMotion = false;
// 쿨타임 추적
private Dictionary<SkillData, float> cooldownTracker = new Dictionary<SkillData, float>();
@@ -131,12 +135,64 @@ namespace Colosseum.Skills
private float currentClipExpectedDuration = 0f;
private string currentClipDebugName = string.Empty;
private int currentClipStartFrame = -1;
private readonly List<Vector3> debugSkillStartPoints = new();
public bool IsExecutingSkill => currentSkill != null;
public bool IsPlayingAnimation => currentSkill != null;
public bool UsesRootMotion => currentSkill != null && currentSkill.UseRootMotion;
public bool IgnoreRootMotionY => currentSkill != null && currentSkill.IgnoreRootMotionY;
/// <summary>
/// 현재 스킬의 루트 위치를 실제 이동에 반영해도 되는지 반환합니다.
/// 스킬 시작 직후 아직 Animator가 Skill 상태로 전이되지 않은 프레임에서는
/// 이전 상태의 루트 XZ가 누적되지 않도록 위치 적용을 잠시 차단합니다.
/// </summary>
public bool ShouldApplyRootMotionPosition =>
currentSkill == null
|| !currentSkill.UseRootMotion
|| animator == null
|| IsCurrentStateSkillState(animator.GetCurrentAnimatorStateInfo(0));
/// <summary>
/// 현재 스킬의 루트 회전을 실제 Transform 회전에 반영해야 하는지 반환합니다.
/// 대상 추적이 코드 회전으로 이미 해결되는 FaceTarget 스킬은
/// 잘라 쓴 클립의 종료 루트 회전이 다음 스킬로 누적되지 않도록 애니메이션 루트 회전을 차단합니다.
/// </summary>
public bool ShouldApplyRootMotionRotation =>
currentSkill == null
|| !currentSkill.UseRootMotion
|| (currentSkill.CastTargetTrackingMode != SkillCastTargetTrackingMode.FaceTarget
&& currentSkill.CastTargetTrackingMode != SkillCastTargetTrackingMode.FaceTargetOnStart);
/// <summary>
/// 현재 스킬의 수평 루트모션을 항상 정면축 기준으로 투영해야 하는지 반환합니다.
/// 근접 추적형 보스 스킬은 클립 내부의 비스듬한 XZ 이동 대신 현재 정면 기준 전진만 사용합니다.
/// </summary>
public bool ShouldProjectHorizontalRootMotionToFacing =>
currentSkill != null
&& currentSkill.UseRootMotion
&& (currentSkill.CastTargetTrackingMode == SkillCastTargetTrackingMode.FaceTarget
|| currentSkill.CastTargetTrackingMode == SkillCastTargetTrackingMode.FaceTargetOnStart);
/// <summary>
/// 스킬 진입 첫 프레임의 비정상적인 수평 루트 스냅을 1회 차단해야 하는지 반환합니다.
/// Animator가 Skill 상태 normalizedTime 0으로 막 진입한 프레임에서만 true를 반환하고 곧바로 소비합니다.
/// </summary>
public bool ConsumeInitialSkillStartHorizontalRootMotionSuppression()
{
if (!shouldSuppressInitialSkillStartHorizontalRootMotion || currentSkill == null || !currentSkill.UseRootMotion || animator == null)
return false;
AnimatorStateInfo stateInfo = animator.GetCurrentAnimatorStateInfo(0);
if (!IsCurrentStateSkillState(stateInfo))
return false;
if (stateInfo.normalizedTime <= 0.001f)
{
shouldSuppressInitialSkillStartHorizontalRootMotion = false;
return true;
}
shouldSuppressInitialSkillStartHorizontalRootMotion = false;
return false;
}
public SkillData CurrentSkill => currentSkill;
public SkillLoadoutEntry CurrentLoadoutEntry => currentLoadoutEntry;
public Animator Animator => animator;
@@ -293,6 +349,9 @@ namespace Colosseum.Skills
var stateInfo = animator.GetCurrentAnimatorStateInfo(0);
if (!IsCurrentStateSkillState(stateInfo))
return;
// 애니메이션 종료 시 처리
if (stateInfo.normalizedTime >= 1f)
{
@@ -419,6 +478,7 @@ namespace Colosseum.Skills
currentRepeatCount = currentLoadoutEntry.GetResolvedRepeatCount();
currentIterationIndex = 0;
loopHoldRequested = skill.RequiresLoopHold;
RecordSkillStartPoint();
if (debugMode) Debug.Log($"[Skill] Cast: {skill.SkillName}");
@@ -485,6 +545,34 @@ namespace Colosseum.Skills
}
}
/// <summary>
/// 씬 뷰에서 스킬 시작 위치를 추적하기 위해 현재 루트 위치를 기록합니다.
/// </summary>
private void RecordSkillStartPoint()
{
debugSkillStartPoints.Add(transform.position);
if (debugSkillStartPoints.Count <= MaxDebugSkillStartPoints)
return;
debugSkillStartPoints.RemoveAt(0);
}
private void OnDrawGizmos()
{
if (debugSkillStartPoints.Count <= 0)
return;
Gizmos.color = SkillStartPointGizmoColor;
for (int i = 0; i < debugSkillStartPoints.Count; i++)
{
Vector3 point = debugSkillStartPoints[i] + Vector3.up * 0.06f;
float radius = i == debugSkillStartPoints.Count - 1 ? 0.14f : 0.1f;
Gizmos.DrawSphere(point, radius);
}
}
/// <summary>
/// 애니메이션 이벤트가 없는 자기 자신 대상 효과는 시전 즉시 발동합니다.
/// 버프/무적 같은 자기 강화 스킬이 이벤트 누락으로 동작하지 않는 상황을 막기 위한 보정입니다.
@@ -561,6 +649,8 @@ namespace Colosseum.Skills
currentIterationIndex++;
currentClipSequenceIndex = 0;
hasAppliedStartTargetFacing = false;
shouldSuppressInitialSkillStartHorizontalRootMotion = currentSkill.UseRootMotion;
isPlayingReleasePhase = false;
currentPhaseAnimationClips = currentSkill.AnimationClips;
@@ -570,6 +660,7 @@ namespace Colosseum.Skills
}
TriggerCastStartEffects();
ApplyImmediateCastTargetFacingBeforeSkillStart();
if (currentPhaseAnimationClips.Count > 0 && animator != null)
{
@@ -584,6 +675,37 @@ namespace Colosseum.Skills
TriggerImmediateSelfEffectsIfNeeded();
}
/// <summary>
/// 루트모션 근접 스킬은 첫 프레임 이동이 현재 정면 기준으로 바로 적용되므로
/// 실제 클립 재생 전에 대상 방향으로 한 번 즉시 정렬해 시작 방향이 틀어지지 않게 합니다.
/// </summary>
private void ApplyImmediateCastTargetFacingBeforeSkillStart()
{
if (currentSkill == null || currentTargetOverride == null || !currentTargetOverride.activeInHierarchy)
return;
if (IsSpawned && !IsServer)
return;
bool shouldSnapFacing =
currentSkill.UseRootMotion &&
(currentSkill.CastTargetTrackingMode == SkillCastTargetTrackingMode.FaceTarget
|| currentSkill.CastTargetTrackingMode == SkillCastTargetTrackingMode.FaceTargetOnStart
|| currentSkill.CastTargetTrackingMode == SkillCastTargetTrackingMode.MoveTowardTarget);
if (!shouldSnapFacing)
return;
Vector3 direction = TargetSurfaceUtility.GetHorizontalDirectionToSurface(transform.position, currentTargetOverride);
if (direction.sqrMagnitude < 0.0001f)
return;
transform.rotation = Quaternion.LookRotation(direction.normalized);
if (currentSkill.CastTargetTrackingMode == SkillCastTargetTrackingMode.FaceTargetOnStart)
hasAppliedStartTargetFacing = true;
}
/// <summary>
/// 시퀀스 내 다음 클립이 있으면 재생합니다.
/// </summary>
@@ -1297,6 +1419,8 @@ namespace Colosseum.Skills
currentClipStartFrame = -1;
shouldBlendIntoCurrentSkill = true;
shouldRestoreToIdleAfterCurrentSkill = true;
hasAppliedStartTargetFacing = false;
shouldSuppressInitialSkillStartHorizontalRootMotion = false;
}
/// <summary>
@@ -1373,7 +1497,15 @@ namespace Colosseum.Skills
&& currentSkill.CastTargetTrackingMode == SkillCastTargetTrackingMode.FaceTarget
&& enemyBase.IsTouchingPlayerContact;
if (currentSkill.CastTargetTrackingMode == SkillCastTargetTrackingMode.FaceTarget ||
if (currentSkill.CastTargetTrackingMode == SkillCastTargetTrackingMode.FaceTargetOnStart)
{
if (!hasAppliedStartTargetFacing)
{
transform.rotation = Quaternion.LookRotation(direction.normalized);
hasAppliedStartTargetFacing = true;
}
}
else if (currentSkill.CastTargetTrackingMode == SkillCastTargetTrackingMode.FaceTarget ||
currentSkill.CastTargetTrackingMode == SkillCastTargetTrackingMode.MoveTowardTarget)
{
if (!suppressRotationWhileContactingPlayer)
@@ -1598,6 +1730,15 @@ namespace Colosseum.Skills
return Animator.StringToHash($"{BaseLayerName}.{SKILL_STATE_NAME}");
}
/// <summary>
/// 현재 레이어가 실제 스킬 상태를 재생 중인지 확인합니다.
/// 스킬 시작 직후 한 프레임 동안 이전 상태 정보가 남아 조기 종료되는 것을 방지합니다.
/// </summary>
private static bool IsCurrentStateSkillState(AnimatorStateInfo stateInfo)
{
return stateInfo.fullPathHash == GetSkillStateHash();
}
/// <summary>
/// 스킬 종료 후 복귀할 상태 해시를 결정합니다.
/// </summary>

View File

@@ -58,6 +58,7 @@ namespace Colosseum.Skills
None,
FaceTarget,
MoveTowardTarget,
FaceTargetOnStart,
}
/// <summary>

View File

@@ -0,0 +1,177 @@
import bpy
from mathutils import Matrix
from mathutils import Vector
# 사용 방법
# 1. 대략 T포즈로 만들고 싶은 Armature를 활성 선택한다.
# 2. Pose Mode에서 이 스크립트를 실행한다.
# 3. 결과가 뒤집히면 FORWARD_AXIS 부호를 바꿔 다시 실행한다.
# 4. 팔 방향이 반대로 가면 LEFT_AXIS / RIGHT_AXIS를 서로 바꿔 다시 실행한다.
# 축 설정
# Blender FBX는 전방이 -Y인 경우가 많다.
UP_AXIS = Vector((0.0, 0.0, 1.0))
FORWARD_AXIS = Vector((0.0, -1.0, 0.0))
LEFT_AXIS = Vector((1.0, 0.0, 0.0))
RIGHT_AXIS = Vector((-1.0, 0.0, 0.0))
DOWN_AXIS = Vector((0.0, 0.0, -1.0))
# 현재 포즈를 최대한 유지한 채 방향만 맞추려면 False
# 먼저 완전히 Rest Pose로 돌려놓고 시작하려면 True
RESET_TO_REST_BEFORE_ALIGN = False
# 현재 프레임에 키를 찍고 싶으면 True
INSERT_KEYFRAME = False
BONE_PATTERNS = {
"hips": ["hips", "pelvis", "root", "cog"],
"spine": ["spine", "spine_01", "spine1", "abdomen"],
"chest": ["chest", "spine_02", "spine2", "upperchest", "upper_chest"],
"neck": ["neck", "neck_01"],
"head": ["head"],
"shoulder_l": ["shoulder.l", "clavicle_l", "clavicle.l", "leftshoulder", "shoulder_l"],
"upperarm_l": ["upperarm_l", "upperarm.l", "arm_l", "leftarm", "upper_arm_l"],
"forearm_l": ["lowerarm_l", "forearm_l", "forearm.l", "leftforearm", "lower_arm_l"],
"hand_l": ["hand_l", "hand.l", "lefthand"],
"shoulder_r": ["shoulder.r", "clavicle_r", "clavicle.r", "rightshoulder", "shoulder_r"],
"upperarm_r": ["upperarm_r", "upperarm.r", "arm_r", "rightarm", "upper_arm_r"],
"forearm_r": ["lowerarm_r", "forearm_r", "forearm.r", "rightforearm", "lower_arm_r"],
"hand_r": ["hand_r", "hand.r", "righthand"],
"thigh_l": ["thigh_l", "upleg_l", "upleg.l", "leftupleg", "upperleg_l"],
"shin_l": ["calf_l", "lowerleg_l", "shin_l", "leg_l", "lowerleg.l"],
"foot_l": ["foot_l", "foot.l", "leftfoot"],
"toe_l": ["toe_l", "toes_l", "ball_l", "toe.l", "toes.l", "ball.l"],
"thigh_r": ["thigh_r", "upleg_r", "upleg.r", "rightupleg", "upperleg_r"],
"shin_r": ["calf_r", "lowerleg_r", "shin_r", "leg_r", "lowerleg.r"],
"foot_r": ["foot_r", "foot.r", "rightfoot"],
"toe_r": ["toe_r", "toes_r", "ball_r", "toe.r", "toes.r", "ball.r"],
}
def normalize_name(name: str) -> str:
return name.lower().replace(" ", "").replace("-", "").replace(":", "").replace("_", "")
def find_pose_bone(armature_obj: bpy.types.Object, key: str):
patterns = [normalize_name(item) for item in BONE_PATTERNS[key]]
for pose_bone in armature_obj.pose.bones:
normalized = normalize_name(pose_bone.name)
if normalized in patterns:
return pose_bone
for pose_bone in armature_obj.pose.bones:
normalized = normalize_name(pose_bone.name)
for pattern in patterns:
if pattern in normalized:
return pose_bone
return None
def get_bone_vector(pose_bone: bpy.types.PoseBone) -> Vector:
children = [child for child in pose_bone.children if child.bone.use_deform]
if children:
child = children[0]
vector = child.head - pose_bone.head
else:
vector = pose_bone.tail - pose_bone.head
if vector.length < 1e-6:
vector = pose_bone.tail - pose_bone.head
return vector.normalized()
def rotate_pose_bone_towards(pose_bone: bpy.types.PoseBone, target_direction: Vector):
current_direction = get_bone_vector(pose_bone)
target_direction = target_direction.normalized()
if current_direction.length < 1e-6 or target_direction.length < 1e-6:
return
rotation = current_direction.rotation_difference(target_direction)
pivot = pose_bone.head.copy()
transform = (
Matrix.Translation(pivot)
@ rotation.to_matrix().to_4x4()
@ Matrix.Translation(-pivot)
)
pose_bone.matrix = transform @ pose_bone.matrix
def insert_rotation_keyframe(pose_bone: bpy.types.PoseBone):
if pose_bone.rotation_mode == "QUATERNION":
pose_bone.keyframe_insert(data_path="rotation_quaternion")
elif pose_bone.rotation_mode == "AXIS_ANGLE":
pose_bone.keyframe_insert(data_path="rotation_axis_angle")
else:
pose_bone.keyframe_insert(data_path="rotation_euler")
def align_bone(armature_obj: bpy.types.Object, key: str, target_direction: Vector, label: str, report: list[str]):
pose_bone = find_pose_bone(armature_obj, key)
if pose_bone is None:
report.append(f"[누락] {label}")
return
rotate_pose_bone_towards(pose_bone, target_direction)
bpy.context.view_layer.update()
if INSERT_KEYFRAME:
insert_rotation_keyframe(pose_bone)
report.append(f"[정렬] {label}: {pose_bone.name}")
def main():
armature_obj = bpy.context.object
if armature_obj is None or armature_obj.type != "ARMATURE":
raise RuntimeError("활성 오브젝트가 Armature가 아닙니다.")
if bpy.context.mode != "POSE":
raise RuntimeError("Pose Mode에서 실행해야 합니다.")
if RESET_TO_REST_BEFORE_ALIGN:
bpy.ops.pose.select_all(action="SELECT")
bpy.ops.pose.transforms_clear()
bpy.context.view_layer.update()
report = []
align_bone(armature_obj, "spine", UP_AXIS, "Spine", report)
align_bone(armature_obj, "chest", UP_AXIS, "Chest", report)
align_bone(armature_obj, "neck", UP_AXIS, "Neck", report)
align_bone(armature_obj, "head", UP_AXIS, "Head", report)
align_bone(armature_obj, "shoulder_l", LEFT_AXIS, "Shoulder_L", report)
align_bone(armature_obj, "upperarm_l", LEFT_AXIS, "UpperArm_L", report)
align_bone(armature_obj, "forearm_l", LEFT_AXIS, "ForeArm_L", report)
align_bone(armature_obj, "hand_l", LEFT_AXIS, "Hand_L", report)
align_bone(armature_obj, "shoulder_r", RIGHT_AXIS, "Shoulder_R", report)
align_bone(armature_obj, "upperarm_r", RIGHT_AXIS, "UpperArm_R", report)
align_bone(armature_obj, "forearm_r", RIGHT_AXIS, "ForeArm_R", report)
align_bone(armature_obj, "hand_r", RIGHT_AXIS, "Hand_R", report)
align_bone(armature_obj, "thigh_l", DOWN_AXIS, "Thigh_L", report)
align_bone(armature_obj, "shin_l", DOWN_AXIS, "Shin_L", report)
align_bone(armature_obj, "foot_l", FORWARD_AXIS, "Foot_L", report)
align_bone(armature_obj, "toe_l", FORWARD_AXIS, "Toe_L", report)
align_bone(armature_obj, "thigh_r", DOWN_AXIS, "Thigh_R", report)
align_bone(armature_obj, "shin_r", DOWN_AXIS, "Shin_R", report)
align_bone(armature_obj, "foot_r", FORWARD_AXIS, "Foot_R", report)
align_bone(armature_obj, "toe_r", FORWARD_AXIS, "Toe_R", report)
print("\n".join(report))
print("대략 T포즈 정렬이 끝났습니다. 결과가 뒤집히면 상단 축 설정을 바꿔 다시 실행하세요.")
main()