// ───────────────────────────────────────────────────────────────────────────── // WLSkillFollow.cs — 회전하며 이동하는 스킬(칼날 폭풍)이 자동 전투 중에도 가까운 적을 따라가게 한다 // // PD 원문(#885 · 2026-10-17): 「자동 전투 중에도 가까운 적을 따라가며 돌게 해줘.」 // // ■ 원리 — 원본 Skill_Whirlwind 는 지속시간 동안 PC 조이스틱 상태를 2(조이스틱만)로 둔다 → 자동 접근(PCActor.Update)이 멈춰 // 조이스틱을 잡지 않으면 제자리에서 돈다. 원본 SkillEvent 훅(CombatEvents.SkillFired)에서 표의 스킬이면 따라가기를 켜고, // 조이스틱 상태가 2 인 동안 플레이어가 조이스틱을 잡지 않은 프레임마다 가까운 적 쪽으로 NavMeshAgent.Move(원본 조이스틱 이동과 같은 방식). // 조이스틱 상태가 2 에서 풀리면(스킬 끝) 끈다. // ■ 손 뗌 보류 — 조이스틱 상태 2 중에 손을 떼면 원본 Set_AutoBraking(true) 의 Play_Idle 이 회전 동작을 끊고 대기 자세로 미끄러진다 // (섬 WLIslandAvatar · 힘민지 FloatingJoystick 둘 다). PCActor.Set_AutoBraking 첫 줄 훅(DeferBrake)이 그 호출을 스킬 끝까지 미루고, // 끝났을 때 손을 안 잡고 있으면 다시 부른다(IsStop 해제 → 자동 전투 재개). 값 deferBrake. // ■ 값 = Resources/WL/WLSkillFollow.json (C45) · 끄기 = enabled 0(구독 · 오브젝트 0) · 원본 수정 = PCActor.Set_AutoBraking 1줄 // 🔴 Debug.LogError 금지(원본 ErrorLogHook 이 팝업을 띄운다). // ───────────────────────────────────────────────────────────────────────────── using System.Collections.Generic; using UnityEngine; using UnityEngine.AI; using WL.Combat.Core; namespace WL.Combat.Weapon { [System.Serializable] public class WLSkillFollowJson { public int enabled = 0; public string[] skills = new string[0]; public float speedMul = 1f; public float stopDistance = 1f; public float searchRadius = 8f; public float retargetSeconds = 0.25f; public float startGraceSeconds = 0.5f; public float inputDeadzone = 0.2f; public int faceTarget = 1; public int deferBrake = 0; public string note = ""; } public static class WLSkillFollow { public const string ResourcesPath = "WL/WLSkillFollow"; public static int Started, Ended, SteerFrames, NoTargetFrames, InRangeFrames, BrakeDeferred, BrakeApplied, BrakeDropped; public static float MovedMeters; public static string LastLog = ""; public static bool Active { get { return s_pc != null; } } static WLSkillFollowJson s_cfg; static readonly HashSet s_skills = new HashSet(); static PCActor s_pc; static NavMeshAgent s_agent; static Actor s_target; static float s_armedAt, s_retargetAt; static bool s_locked; // 조이스틱 상태 2 를 한 번이라도 봤다 static PCActor s_brakePc; // 손 뗌 보류 중인 PC static Joystick s_joy; // 힘민지 FloatingJoystick — 섬은 WLIslandAvatar(SimpleInput)도 같이 본다 static int s_joyLookFrame; static WLSkillFollowRunner s_runner; static WLSkillFollowJson Cfg { get { if (s_cfg != null) return s_cfg; s_cfg = new WLSkillFollowJson(); 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; } s_skills.Clear(); if (s_cfg.skills != null) foreach (var s in s_cfg.skills) if (!string.IsNullOrEmpty(s)) s_skills.Add(s); return s_cfg; } } [RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.AfterSceneLoad)] static void Boot() { s_cfg = null; s_pc = null; s_brakePc = null; if (Cfg.enabled == 0) { CombatEvents.SkillFired.Remove(OnSkillFired); return; } // 꺼져 있으면 구독 · 오브젝트 0 CombatEvents.SkillFired.Add(OnSkillFired); } static void OnSkillFired(in SkillFiredEvent e) { var pc = e.actor as PCActor; if (pc == null || e.skill == null || !pc.IsMainPC()) return; var c = Cfg; if (c.enabled == 0 || !s_skills.Contains(e.skill.e_Skill.ToString())) return; EnsureRunner(); s_pc = pc; s_agent = pc.GetComponent(); s_target = null; s_armedAt = Time.time; s_retargetAt = 0f; s_locked = false; Started++; LastLog = "켜짐 " + e.skill.e_Skill + " #" + Started; } static void EnsureRunner() { if (!DSUtil.CheckNull(s_runner)) return; var go = new GameObject("[WL885] SkillFollowRunner"); go.hideFlags = HideFlags.HideAndDontSave; s_runner = go.AddComponent(); } /// PCActor.Set_AutoBraking(true) 첫 줄 훅 — 조이스틱 상태 2(회전하며 이동) 중이면 호출을 스킬 끝까지 미룬다(true = 원본은 지금 아무것도 안 함). public static bool DeferBrake(PCActor pc) { if (pc == null || pc.JoystickStatus != 2 || !pc.IsMainPC()) return false; var c = Cfg; if (c.enabled == 0 || c.deferBrake == 0) return false; EnsureRunner(); s_brakePc = pc; BrakeDeferred++; return true; } internal static void Tick() { TickBrake(); var pc = s_pc; if (pc == null) return; var c = Cfg; if (DSUtil.CheckNull(pc) || pc.IsDead()) { Stop("PC 없음"); return; } if (pc.JoystickStatus != 2) { // 원본 Skill_Whirlwind 가 SkillEvent 직후 상태 2 로 바꾼다 — 아직 못 봤으면 잠깐 기다리고, 본 뒤에 풀리면 스킬 끝 if (s_locked || Time.time - s_armedAt > c.startGraceSeconds) Stop(s_locked ? "스킬 끝" : "상태 2 못 봄"); return; } s_locked = true; if (PlayerSteering(c)) { SteerFrames++; return; } if (pc.IsCC() || s_agent == null || !s_agent.isOnNavMesh) return; var pos = pc.Get_position(); if (Time.time >= s_retargetAt || DSUtil.CheckNull(s_target) || s_target.IsDead()) { s_retargetAt = Time.time + c.retargetSeconds; s_target = ActorInfo.Ins != null ? ActorInfo.Ins.Get_Nearest_EnemyActor_orNull(pc.IsEnemy(), pos, c.searchRadius) : null; } if (DSUtil.CheckNull(s_target)) { NoTargetFrames++; return; } var to = s_target.Get_position() - pos; to.y = 0f; float d = to.magnitude; if (d <= c.stopDistance) { InRangeFrames++; return; } var dir = to / d; float step = Mathf.Min(d - c.stopDistance, pc.Get_MoveSpeed() * c.speedMul * Time.deltaTime); s_agent.Move(dir * step); if (c.faceTarget != 0) pc.transform.rotation = Quaternion.LookRotation(dir); MovedMeters += step; } static void TickBrake() { var pc = s_brakePc; if (pc == null) return; if (DSUtil.CheckNull(pc)) { s_brakePc = null; return; } if (pc.JoystickStatus == 2) return; // 아직 스킬 중 s_brakePc = null; if (PlayerSteering(Cfg)) { BrakeDropped++; return; } // 다시 잡고 있다 — 손을 뗄 때 원래 경로가 부른다 pc.Set_AutoBraking(true); // 상태 2 가 풀렸으니 이번엔 원본 그대로 돈다 BrakeApplied++; } static bool PlayerSteering(WLSkillFollowJson c) { var av = WL.IslandMob.WLIslandAvatar.Instance; if (av != null && av.PcMoving) return true; if (DSUtil.CheckNull(s_joy) && Time.frameCount >= s_joyLookFrame) { s_joy = Object.FindAnyObjectByType(); s_joyLookFrame = Time.frameCount + 60; } return !DSUtil.CheckNull(s_joy) && s_joy.isActiveAndEnabled && s_joy.Direction.sqrMagnitude > c.inputDeadzone * c.inputDeadzone; } static void Stop(string why) { s_pc = null; s_agent = null; s_target = null; Ended++; LastLog = "꺼짐(" + why + ") · 이동 누적 " + MovedMeters.ToString("F1") + " m · 조이스틱 " + SteerFrames + " · 적 없음 " + NoTargetFrames + " · 붙음 " + InRangeFrames; } } /// WLSkillFollow 의 시간 축. 숨김 GameObject 1개 · 따라가기 켜졌을 때만 일한다. internal sealed class WLSkillFollowRunner : MonoBehaviour { void Update() { WLSkillFollow.Tick(); } } }