// ───────────────────────────────────────────────────────────────────────────── // WLBowPoseIK.cs — 활 쏘는 자세에서 두 손을 머리 바깥으로 벌린다 (#967 · 2026-10-22) // // PD 원문: 「활 기본 공격시에도 한쪽 손이 가만히 있는 동작처럼 보이는 현상을 수정해줘. (위에서 아래로 내려다 보는 각도일 때 특히 두드러지며, 아래에서 위를 볼때도 한손만 쓰는 것처럼 보임)」 // ■ 원인(편집 모드 손 실측 · 조직 레포 scripts/wl/probes/967_bowhands.cs) — 활 1·2타(Archer RangeAttack1 · BowShot1/2_S) 두 손이 머리 높이(0.6~0.86 m · 머리 0.70 m)에서 // 앞뒤 일직선(활 손 앞 0.29 m · 당기는 손 뒤 0.36 m · 좌우 0.00~0.15 m) → 위 카메라에서 화면 위/아래로 쏘면 큰 머리가 한 손을 가린다(위를 보면 활 손 · 아래를 보면 당기는 손). // ■ 활 직업 PC 애니메이터의 IK(OnAnimatorIK · 컨트롤러 기본 층 IK Pass) — states 의 상태를 재생하는 동안 두 손 목표 = 애니메이션 손 위치 + PC 기준 오프셋 // (rightOffset = 당기는 손 · leftOffset = 활 손 · (오른쪽, 위, 앞) m) · 손 방향(회전)은 애니메이션 그대로 · 가중치 = 상태 정규화 시각 rampIn 까지 0 → weight · rampOut 부터 weight → 0 · 전환 중엔 두 상태 가중치를 섞는다. // ■ 붙이기 = WLBowChargeBoot 가 0.5 s 마다 MyValue.MyPC 의 애니메이터 오브젝트에 보장(Ensure). // ■ 값 = Resources/WL/WLBowPoseIK.json (C45) · enabled 0 = 끔 · RuntimeDisabled(프로브 전후 비교용) // 🔴 Debug.LogError 금지(원본 팝업). // ───────────────────────────────────────────────────────────────────────────── using System.Collections.Generic; using UnityEngine; namespace WL.Combat.Weapon { [System.Serializable] public class WLBowPoseIKJson { public int enabled = 0; public int[] classes = new int[0]; public string[] states = new string[0]; // 기본 층 상태 이름(활 기본 공격 · 속사가 쓰는 상태) public float[] rightOffset = { 0f, 0f, 0f }; // 당기는 손(오른손) 목표 오프셋 — PC 기준 (오른쪽, 위, 앞) m public float[] leftOffset = { 0f, 0f, 0f }; // 활 손(왼손) public float weight = 1f; public float rampIn = 0.15f, rampOut = 0.85f; // 상태 정규화 시각 — 0 → rampIn 가중치 오름 · rampOut → 1 내림 public string note = ""; } public sealed class WLBowPoseIK : MonoBehaviour { public const string ResourcesPath = "WL/WLBowPoseIK"; public static bool RuntimeDisabled; public static int Attached, Frames; public static float LastWeight; public static string LastLog = ""; static WLBowPoseIKJson s_cfg; static readonly HashSet s_hashes = new HashSet(); Animator _an; PCActor _pc; static WLBowPoseIKJson Cfg { get { if (s_cfg != null) return s_cfg; s_cfg = new WLBowPoseIKJson(); s_hashes.Clear(); var ta = Resources.Load(ResourcesPath); if (ta == null) { LastLog = "설정 없음 " + ResourcesPath; return s_cfg; } try { JsonUtility.FromJsonOverwrite(ta.text, s_cfg); } catch (System.Exception ex) { LastLog = "설정 파싱 실패 — " + ex.Message; } if (s_cfg.states != null) foreach (var s in s_cfg.states) if (!string.IsNullOrEmpty(s)) s_hashes.Add(Animator.StringToHash(s)); return s_cfg; } } [RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.SubsystemRegistration)] static void Reset() { s_cfg = null; s_hashes.Clear(); RuntimeDisabled = false; Attached = Frames = 0; LastWeight = 0f; LastLog = ""; } /// 활 직업이면 PC 애니메이터 오브젝트에 붙인다(이미 있으면 그대로). public static void Ensure(PCActor pc) { if (pc == null || pc.m_animation == null) return; var c = Cfg; if (c.enabled == 0) return; var go = pc.m_animation.gameObject; if (go.GetComponent() != null) return; go.AddComponent(); Attached++; } static Vector3 V(float[] a) { return a != null && a.Length >= 3 ? new Vector3(a[0], a[1], a[2]) : Vector3.zero; } float StateWeight(AnimatorStateInfo st, WLBowPoseIKJson c) { if (!s_hashes.Contains(st.shortNameHash)) return 0f; float n = Mathf.Clamp01(st.normalizedTime); float w = 1f; if (c.rampIn > 0.001f && n < c.rampIn) w = n / c.rampIn; if (c.rampOut < 0.999f && n > c.rampOut) w = Mathf.Min(w, (1f - n) / Mathf.Max(0.001f, 1f - c.rampOut)); return Mathf.Clamp01(w) * Mathf.Clamp01(c.weight); } void OnAnimatorIK(int layer) { if (layer != 0 || RuntimeDisabled) return; var c = Cfg; if (c.enabled == 0 || s_hashes.Count == 0) return; if (_an == null) _an = GetComponent(); if (_pc == null) _pc = GetComponentInParent(); if (_an == null || _pc == null || c.classes == null || System.Array.IndexOf(c.classes, _pc.Get_ID()) < 0) return; float w; if (_an.IsInTransition(0)) { float tr = Mathf.Clamp01(_an.GetAnimatorTransitionInfo(0).normalizedTime); w = Mathf.Lerp(StateWeight(_an.GetCurrentAnimatorStateInfo(0), c), StateWeight(_an.GetNextAnimatorStateInfo(0), c), tr); } else w = StateWeight(_an.GetCurrentAnimatorStateInfo(0), c); LastWeight = w; if (w <= 0.001f) return; var rot = _pc.transform.rotation; var r = _an.GetIKPosition(AvatarIKGoal.RightHand) + rot * V(c.rightOffset); var l = _an.GetIKPosition(AvatarIKGoal.LeftHand) + rot * V(c.leftOffset); _an.SetIKPositionWeight(AvatarIKGoal.RightHand, w); _an.SetIKPosition(AvatarIKGoal.RightHand, r); _an.SetIKPositionWeight(AvatarIKGoal.LeftHand, w); _an.SetIKPosition(AvatarIKGoal.LeftHand, l); Frames++; } } }