Project_WL/Assets/WL/Combat/Reaction/WLMobAI.cs

163 lines
9.5 KiB
C#

// ─────────────────────────────────────────────────────────────────────────────
// 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<MobActor> s_mobs = new HashSet<MobActor>();
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<TextAsset>(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<MobActor>(FindObjectsInactive.Include, FindObjectsSortMode.None)) if (m != null) { s_mobs.Add(m); if (m.isActiveAndEnabled) Apply(m, false); }
if (Object.FindFirstObjectByType<WLMobAIRunner>() == null)
{
var go = new GameObject("[WL903] MobAI"); go.hideFlags = HideFlags.HideAndDontSave; Object.DontDestroyOnLoad(go);
go.AddComponent<WLMobAIRunner>();
}
}
static void OnSpawned(in SpawnedEvent e)
{
var m = e.actor as MobActor;
if (m == null) return;
s_mobs.Add(m);
Apply(m, true);
}
/// <summary>시야 · 선공 여부를 맞춘다. spawned = 리스폰 직후(원본 On_Regen 이 이전 생의 선공 값으로 이미 전투에 들어갔으면 비선공은 되돌린다).</summary>
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; }
}
}
/// <summary>매 프레임: 공격 거리 도착 즉시 정지 · 주기적으로 선공 몹 시야 확인. 숨김 GameObject 1개.</summary>
public sealed class WLMobAIRunner : MonoBehaviour
{
float _nextSight;
static readonly List<MobActor> s_dead = new List<MobActor>(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);
}
}
}