// ───────────────────────────────────────────────────────────────────────────── // WLMobAI.cs — 몬스터 AI 보정(시야 · 선공/비선공 · 공격 거리 도착 즉시 정지) — 표 기반 · 원본 코드 0줄 (#903 · 2026-10-17) // // PD 원문: // 「근접 적이 너무 플레이어와 붙지 않도록 근접 공격 시 공격 거리를 확보」 // 「원거리 적은 공격 가능한 사정거리 내 플레이어가 있을 때 이동을 멈추고 공격 (플레이어 공격 로직과 동일하게 시야 범위와 공격 범위는 다르며, // 시야 범위는 원거리 몬스터와 동일한 수준으로)」 // 「몬스터는 선공형 몬스터와 비선공 몬스터로 구성 — 비선공은 플레이어에게 공격 받을 경우에만 공격 시작 · 선공형은 플레이어가 시야 범위 내 있을 때 // 공격 가능한 범위까지 이동&추적 한 후 공격」 // // ■ 원인(2026-10-17 Play 실측) — 근접 박쥐(공격 거리 2 m)가 1.89 m 에서 「공격」 상태로 바뀐 뒤에도 NavMeshAgent 경로가 살아 있어 // 1.04 m 까지 미끄러져 들어왔다(원본 MobActor 공격 상태는 이동을 멈추지 않는다). 원거리도 같은 구조. // ■ 하는 일 // ① 공격 거리 도착 즉시 정지 — 공격 상태 · 대상이 공격 거리(m_NavStopDistance = 표 f_BaseATKRange) 안이면 경로를 지우고 속도 0 · 달리기 모션이면 대기 모션. // ② 시야 — 스폰(Spawned) 때 감지 반경(MobActor.f_BaseChaseRange = 원본 Check_Battle 이 쓰는 값)을 sightRange 로. 귀환 거리(표 f_BaseChaseRange)는 그대로. // 선공 몹은 순찰 중에도 0.2 초마다 시야 안 메인 PC 를 보고 전투로 들어간다(원본은 순찰 도착 · 리스폰 때만 확인). // ③ 선공/비선공 — 스폰 때 몹 ID 별 aggressive 값으로 b_IsFirstAttack 을 맞춘다(목록에 없으면 표 b_IsFirstAttack). 비선공은 맞으면 원본 // Change_Target 이 선공으로 바꿔 반격한다(리스폰 때 다시 비선공). // ■ 값 = Resources/WL/WLMobAI.json (C45) · 끄기 = enabled 0 // 🔴 어셈블리 주의: Assets/WL/Combat/ 에 .asmdef 를 만들지 말 것 · Debug.LogError 금지(원본 팝업). // ───────────────────────────────────────────────────────────────────────────── using System.Collections.Generic; using System.Reflection; using UnityEngine; using WL.Combat.Core; namespace WL.Combat.Reaction { [System.Serializable] public class WLMobAggro { public int monsterId; public int aggressive; } [System.Serializable] public class WLMobAIJson { public int enabled; public float sightRange; // 몹 시야(감지 반경 m) · 0 = 표 값 public int stopOnRange = 1; // 공격 거리 도착 즉시 정지 public float sightCheckSeconds = 0.2f; public WLMobAggro[] aggro = new WLMobAggro[0]; public string note = ""; } public static class WLMobAI { public const string ResourcesPath = "WL/WLMobAI"; public static int Applied, Stops, SightEngages, Calmed; public static string LastLog = ""; internal static WLMobAIJson s_cfg; internal static readonly HashSet s_mobs = new HashSet(); static readonly BindingFlags BF = BindingFlags.Instance | BindingFlags.NonPublic | BindingFlags.Public; internal static FieldInfo fStatus, fStop, fFirst, fChase, fTable; internal static WLMobAIJson Cfg { get { if (s_cfg != null) return s_cfg; s_cfg = new WLMobAIJson(); 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; } } static FieldInfo Field(System.Type t, string n) { for (; t != null; t = t.BaseType) { var f = t.GetField(n, BF); if (f != null) return f; } return null; } [RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.AfterSceneLoad)] static void Boot() { s_cfg = null; s_mobs.Clear(); CombatEvents.Spawned.Remove(OnSpawned); if (Cfg.enabled == 0) return; fStatus = Field(typeof(MobActor), "MobStatus"); fStop = Field(typeof(MobActor), "m_NavStopDistance"); fFirst = Field(typeof(MobActor), "b_IsFirstAttack"); fChase = Field(typeof(MobActor), "f_BaseChaseRange"); fTable = Field(typeof(MobActor), "m_MonsterTableData"); CombatEvents.Spawned.Add(OnSpawned); foreach (var m in Object.FindObjectsByType(FindObjectsInactive.Include, FindObjectsSortMode.None)) if (m != null) { s_mobs.Add(m); if (m.isActiveAndEnabled) Apply(m, false); } if (Object.FindFirstObjectByType() == null) { var go = new GameObject("[WL903] MobAI"); go.hideFlags = HideFlags.HideAndDontSave; Object.DontDestroyOnLoad(go); go.AddComponent(); } } static void OnSpawned(in SpawnedEvent e) { var m = e.actor as MobActor; if (m == null) return; s_mobs.Add(m); Apply(m, true); } /// 시야 · 선공 여부를 맞춘다. spawned = 리스폰 직후(원본 On_Regen 이 이전 생의 선공 값으로 이미 전투에 들어갔으면 비선공은 되돌린다). static void Apply(MobActor m, bool spawned) { try { var c = Cfg; if (c.sightRange > 0f && fChase != null) fChase.SetValue(m, c.sightRange); int id = 0; var tab = fTable != null ? fTable.GetValue(m) as MonsterTableData : null; if (tab != null) id = tab.n_MonsterID; bool aggressive = tab != null && tab.b_IsFirstAttack; if (c.aggro != null) foreach (var a in c.aggro) if (a != null && a.monsterId == id) { aggressive = a.aggressive != 0; break; } if (fFirst != null) fFirst.SetValue(m, aggressive); if (!aggressive && spawned && fStatus != null) { var st = (eMobStatus)fStatus.GetValue(m); if (st == eMobStatus.Battle || st == eMobStatus.Chase || st == eMobStatus.Attack) { m.Change_MobStatus(eMobStatus.PatrolWait); Calmed++; } } Applied++; LastLog = m.name + " id " + id + " 선공 " + aggressive + " 시야 " + c.sightRange; } catch (System.Exception ex) { LastLog = "적용 실패 — " + ex.Message; } } } /// 매 프레임: 공격 거리 도착 즉시 정지 · 주기적으로 선공 몹 시야 확인. 숨김 GameObject 1개. public sealed class WLMobAIRunner : MonoBehaviour { float _nextSight; static readonly List s_dead = new List(8); void Update() { var c = WLMobAI.s_cfg; if (c == null || c.enabled == 0 || WLMobAI.fStatus == null) return; bool sight = Time.time >= _nextSight; if (sight) _nextSight = Time.time + Mathf.Max(0.05f, c.sightCheckSeconds); var pc = MyValue.MyPC; s_dead.Clear(); foreach (var m in WLMobAI.s_mobs) { if (m == null) { s_dead.Add(m); continue; } if (!m.isActiveAndEnabled || m.IsDead() || m.IsCC()) continue; eMobStatus st; try { st = (eMobStatus)WLMobAI.fStatus.GetValue(m); } catch { continue; } // ① 공격 거리 도착 즉시 정지(근접 · 원거리 공통) if (c.stopOnRange != 0 && st == eMobStatus.Attack) { var tg = m.Get_Target(); var ag = m.Get_Agent(); if (tg != null && !tg.IsDead() && ag != null && ag.enabled && ag.isOnNavMesh && WLMobAI.fStop != null) { float range = (float)WLMobAI.fStop.GetValue(m); var d = tg.Get_position() - m.Get_position(); d.y = 0f; if (d.magnitude <= range && (ag.hasPath || ag.velocity.sqrMagnitude > 0.0025f)) { ag.ResetPath(); ag.velocity = Vector3.zero; if (m.Get_CurAnim() == eAnim.Run) m.Play_Idle(); WLMobAI.Stops++; } } } // ② 선공 몹 시야 — 순찰 중 시야 안 메인 PC → 전투 if (sight && pc != null && !pc.IsDead() && c.sightRange > 0f && (st == eMobStatus.PatrolWait || st == eMobStatus.PatrolRot || st == eMobStatus.Patroling) && WLMobAI.fFirst != null && (bool)WLMobAI.fFirst.GetValue(m)) { var d = pc.Get_position() - m.Get_position(); d.y = 0f; if (d.magnitude <= c.sightRange) { m.Change_MobStatus(eMobStatus.Battle); WLMobAI.SightEngages++; } } } foreach (var m in s_dead) WLMobAI.s_mobs.Remove(m); } } }