// ───────────────────────────────────────────────────────────────────────────── // WLDoubleTapDodge.cs — 화면 두 번 터치 = 그 방향으로 구르기(회피) · 모든 무기 공통 // // PD 원문(#889 · 2026-10-17): 「화면을 두번 터치하면 해당 방향으로 구르기 동작(회피)을 하도록 구현해줘. (모든 무기 공통)」 // // ■ 원리 — 짧은 탭(누름 → 뗌이 tapMaxSeconds 안 · 움직임 tapMaxMove 안) 뒤 gapMaxSeconds 안에 가까운 자리(gapMaxDistance)를 다시 누르면 // 그 화면 지점을 PC 발 높이 평면에 비춰 방향을 구하고, PC 를 그쪽으로 돌린 뒤 원본 회피 PCActor.Play_Dodge()(앞으로 3 m · 회피 쿨타임 · // 직업마다 애니메이터 dodge 상태 — 14개 컨트롤러 모두 있음)를 부른다. 탭이 PC 바로 위(minDirDistance 안)면 지금 바라보는 쪽. // ■ 막는 경우 — 버튼 등 눌리는 UI(Selectable) 위 탭 · 스킬 동작 중(allowDuringSkill 0) · 시간 정지(timeScale 0) · PC 없음/사망/꺼짐. // ■ 입력 = 레거시 Input(터치 우선 · 없으면 마우스 = 에디터) · 값 = Resources/WL/WLDoubleTapDodge.json (C45) · 끄기 = enabled 0(오브젝트 0) · 원본 수정 0줄 // 🔴 Debug.LogError 금지(원본 ErrorLogHook 이 팝업을 띄운다). // ───────────────────────────────────────────────────────────────────────────── using System.Collections.Generic; using UnityEngine; using UnityEngine.EventSystems; using UnityEngine.UI; namespace WL.Combat { [System.Serializable] public class WLDoubleTapDodgeJson { public int enabled = 0; public float tapMaxSeconds = 0.25f; // 한 번 탭: 누름 → 뗌 최대 시간 public float tapMaxMove = 0.03f; // 한 번 탭: 최대 움직임(화면 높이 비율) public float gapMaxSeconds = 0.3f; // 첫 탭 뗌 → 두 번째 누름 최대 간격 public float gapMaxDistance = 0.12f; // 두 탭 사이 최대 거리(화면 높이 비율) public int ignoreOverButtons = 1; public int allowDuringSkill = 0; public float minDirDistance = 0.3f; // 탭 지점이 PC 에서 이만큼(m) 안이면 바라보는 쪽 public float dustScale = 1f; // #892 구르기 흙먼지(원본 Effect_PCDash) 배율 public string note = ""; } /// 흙먼지 이펙트 인스턴스의 원래 크기(풀에서 다시 쓰여도 같은 기준으로 줄인다). internal sealed class WLDustScaleMark : MonoBehaviour { public Vector3 baseScale = Vector3.one; } public static class WLDoubleTapDodge { public const string ResourcesPath = "WL/WLDoubleTapDodge"; public static int Taps, DoubleTaps, Dodges, Blocked; public static string LastLog = ""; static WLDoubleTapDodgeJson s_cfg; static WLDoubleTapDodgeRunner s_runner; static bool s_down, s_downOnButton; static int s_finger; static Vector2 s_downPos, s_lastTapPos; static float s_downTime, s_lastTapTime = -10f, s_maxMove; static readonly List s_hits = new List(); internal static WLDoubleTapDodgeJson Cfg { get { if (s_cfg != null) return s_cfg; s_cfg = new WLDoubleTapDodgeJson(); 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; } return s_cfg; } } [RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.AfterSceneLoad)] static void Boot() { s_cfg = null; s_down = false; s_lastTapTime = -10f; if (Cfg.enabled == 0 || !DSUtil.CheckNull(s_runner)) return; // 꺼져 있으면 오브젝트 0 var go = new GameObject("[WL889] DoubleTapDodge"); go.hideFlags = HideFlags.HideAndDontSave; s_runner = go.AddComponent(); } internal static void Tick() { float now = Time.unscaledTime; if (Input.touchCount > 0) { for (int i = 0; i < Input.touchCount; i++) { var t = Input.GetTouch(i); if (t.phase == TouchPhase.Began) { if (!s_down) Down(t.position, now, t.fingerId); } else if (s_down && t.fingerId == s_finger) { if (t.phase == TouchPhase.Moved) Move(t.position); else if (t.phase == TouchPhase.Ended || t.phase == TouchPhase.Canceled) Up(t.position, now, t.phase == TouchPhase.Canceled); } } return; } if (Input.GetMouseButtonDown(0)) Down(Input.mousePosition, now, -1); else if (s_down && Input.GetMouseButton(0)) Move(Input.mousePosition); else if (s_down && Input.GetMouseButtonUp(0)) Up(Input.mousePosition, now, false); } /// 누름(시험 도구도 부른다). 두 번째 누름이 조건을 채우면 바로 구른다. public static void Down(Vector2 p, float now, int finger) { var c = Cfg; s_down = true; s_finger = finger; s_downPos = p; s_downTime = now; s_maxMove = 0f; s_downOnButton = c.ignoreOverButtons != 0 && OverButton(p); if (s_downOnButton) return; if (now - s_lastTapTime <= c.gapMaxSeconds && (p - s_lastTapPos).magnitude <= c.gapMaxDistance * Screen.height) { DoubleTaps++; s_lastTapTime = -10f; TryDodge(p); } } public static void Move(Vector2 p) { s_maxMove = Mathf.Max(s_maxMove, (p - s_downPos).magnitude); } /// 뗌(시험 도구도 부른다). 짧고 거의 안 움직였으면 「한 번 탭」으로 기억한다. public static void Up(Vector2 p, float now, bool canceled) { if (!s_down) return; s_down = false; var c = Cfg; Move(p); bool tap = !canceled && !s_downOnButton && now - s_downTime <= c.tapMaxSeconds && s_maxMove <= c.tapMaxMove * Screen.height; if (tap) { Taps++; s_lastTapPos = p; s_lastTapTime = now; } else s_lastTapTime = -10f; } static void TryDodge(Vector2 screen) { var c = Cfg; var pc = MyValue.MyPC; if (DSUtil.CheckNull(pc) || pc.IsDead() || !pc.gameObject.activeInHierarchy) { Blocked++; LastLog = "PC 없음"; return; } if (Time.timeScale <= 0f) { Blocked++; LastLog = "시간 정지"; return; } if (c.allowDuringSkill == 0 && pc.Get_CurAnim() == eAnim.Skill) { Blocked++; LastLog = "스킬 중"; return; } if (pc is PCActor wpc && WL.Combat.Weapon.WLSkillCancel.BlockDodge(wpc)) { Blocked++; LastLog = "점프 스킬 중(#908 이동 · 구르기 무시)"; return; } var cam = Camera.main; var pos = pc.transform.position; Vector3 dir = Vector3.zero; if (cam != null) { var ray = cam.ScreenPointToRay(screen); var plane = new Plane(Vector3.up, pos); float enter; if (plane.Raycast(ray, out enter)) { dir = ray.GetPoint(enter) - pos; dir.y = 0f; } } if (dir.magnitude < c.minDirDistance) { dir = pc.transform.forward; dir.y = 0f; } if (dir.sqrMagnitude < 1e-6f) dir = Vector3.forward; // #988 동작 6가지(장착 신발 · WLDodgeStyle.json) — spin = 이전 그대로(탭 쪽으로 돌아 원본 Play_Dodge · 못 구르면 방향 되돌림 #923) string log; if (WLDodgeStyle.Do(pc, dir.normalized, out log)) { Dodges++; LastLog = "회피 #" + Dodges + " " + log + " 방향 " + dir.normalized.ToString("F2"); } else { Blocked++; LastLog = log; } } public static int DustScaled; /// 원본 Actor.Play_Dodge 이펙트 콜백 훅(#892) — 흙먼지를 dustScale 배로(파티클 크기 모드 Hierarchy). public static void AdjustDust(GameObject fx) { if (fx == null) return; bool hide = WLDodgeStyle.SuppressDust; // #988 순간이동 회피는 흙먼지 없음(크기 0 · 풀에서 다시 쓰이면 기준 크기로 돌아온다) float k = hide ? 0f : Cfg.dustScale; if (!hide && (k <= 0f || Mathf.Approximately(k, 1f))) return; var mark = fx.GetComponent(); if (mark == null) { mark = fx.AddComponent(); mark.baseScale = fx.transform.localScale; foreach (var ps in fx.GetComponentsInChildren(true)) { var m = ps.main; m.scalingMode = ParticleSystemScalingMode.Hierarchy; } } fx.transform.localScale = mark.baseScale * k; DustScaled++; } static bool OverButton(Vector2 p) { var es = EventSystem.current; if (es == null) return false; var ped = new PointerEventData(es) { position = p }; s_hits.Clear(); es.RaycastAll(ped, s_hits); for (int i = 0; i < s_hits.Count; i++) { var go = s_hits[i].gameObject; if (go == null) continue; var sel = go.GetComponentInParent(); if (sel != null && sel.enabled && sel.IsInteractable()) return true; } return false; } } /// WLDoubleTapDodge 의 입력 읽기. 숨김 GameObject 1개. internal sealed class WLDoubleTapDodgeRunner : MonoBehaviour { void Update() { WLDoubleTapDodge.Tick(); } } }