fix: 드로그 기본기3 시작 루트모션과 추적 보정 정리
- 기본기3 시작 프레임의 수평 루트모션 스냅을 차단하고 정면 투영/접촉 정지 거리 보정을 정리 - 스킬 완료 판정과 시작 타깃 정렬 로직을 보강하고 드로그/플레이어 정면 및 시작점 디버그 gizmo를 추가 - 드로그 기본기3 관련 애니메이션·패턴·스킬 자산을 재정리하고 Blender 보조 스크립트를 추가
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -1,5 +1,5 @@
|
||||
fileFormatVersion: 2
|
||||
guid: d2aea82703e56bb44abb129504693c7e
|
||||
guid: cde2d1a448bf3be59b2c9bbebe2ad7e3
|
||||
NativeFormatImporter:
|
||||
externalObjects: {}
|
||||
mainObjectFileID: 7400000
|
||||
|
||||
104771
Assets/_Game/Animations/Anim_Drog_콤보-기본기3_2_0.anim
Normal file
104771
Assets/_Game/Animations/Anim_Drog_콤보-기본기3_2_0.anim
Normal file
File diff suppressed because it is too large
Load Diff
@@ -1,8 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: fc435c80b8f1348f889910629f8eec51
|
||||
guid: 3f077b889a0c441a8be8f51575b34f37
|
||||
NativeFormatImporter:
|
||||
externalObjects: {}
|
||||
mainObjectFileID: 11400000
|
||||
mainObjectFileID: 7400000
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
36930
Assets/_Game/Animations/Anim_Drog_콤보-기본기3_2_1.anim
Normal file
36930
Assets/_Game/Animations/Anim_Drog_콤보-기본기3_2_1.anim
Normal file
File diff suppressed because it is too large
Load Diff
8
Assets/_Game/Animations/Anim_Drog_콤보-기본기3_2_1.anim.meta
Normal file
8
Assets/_Game/Animations/Anim_Drog_콤보-기본기3_2_1.anim.meta
Normal file
@@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: e63e8710eded48197ae534daf847bbb5
|
||||
NativeFormatImporter:
|
||||
externalObjects: {}
|
||||
mainObjectFileID: 7400000
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
8
Assets/_Game/Scripts/Debug.meta
Normal file
8
Assets/_Game/Scripts/Debug.meta
Normal file
@@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 70661168ff52fffe7b990c89bbe7b0dc
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
44
Assets/_Game/Scripts/Debug/FacingDirectionGizmoUtility.cs
Normal file
44
Assets/_Game/Scripts/Debug/FacingDirectionGizmoUtility.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 3481720c56d01e6dfa9327a4c386a1cd
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -58,6 +58,7 @@ namespace Colosseum.Skills
|
||||
None,
|
||||
FaceTarget,
|
||||
MoveTowardTarget,
|
||||
FaceTargetOnStart,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
177
Tools/Blender/approx_tpose.py
Normal file
177
Tools/Blender/approx_tpose.py
Normal 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()
|
||||
Reference in New Issue
Block a user