// ─────────────────────────────────────────────────────────────────────────────
// WLIslandNavMesh.cs — WL 쪽 변경(해변 걷기 콜라이더 · 상인 대역 캐릭터) 뒤 FI 섬 내비메시를 다시 굽는다 (2026-10-15)
//
// ■ 왜 — FI `IslandManager` 는 자기 변경(확장·소품)에서만 `NavMeshSurface.UpdateNavMesh` 를 부른다.
// WL 이 런타임에 깐 콜라이더·NavMeshModifierVolume 은 다음 FI 갱신까지 내비메시에 안 들어간다.
// ■ 해변 걷기(PD 「섬의 가장자리까지 조금 더 많이 이동」) — FI 에이전트 설정(경사 30° · 반지름 0.5)으로는 해변 경사(실측 28~46°)에서 끊긴다.
// 그래서 **같은 소스·같은 에이전트 종류 ID** 로 경사·반지름만 바꾼 설정(`WLIslandLookSettings.beachWalkNav*`)으로 통째로 다시 굽고 바꿔 끼운다
// (실측 16 m 섬 4~8 ms · 끼우는 동안 에이전트는 메시 위에 그대로).
// FI 가 나중에 자기 설정으로 갱신하면(확장 등) 바뀐 칸만 30°·0.5 로 돌아가므로 → FI 갱신이 끝나는 순간을 지켜보다가(`Co_Watch`) 다시 굽는다.
// ■ 소스 수집·범위는 AI Navigation 2.0.14 `NavMeshSurface` 의 비공개 `CollectSources`·`CalculateWorldBounds` 를 그대로 부른다(동작 동일 보장).
// 못 찾으면(패키지 변경) FI 기본 갱신(`UpdateNavMesh`)으로 돌아간다. FI 코드 0줄.
// 🔴 Debug.LogError / Debug.LogException 을 쓰지 않는다(ErrorLogHookManager 팝업).
// ─────────────────────────────────────────────────────────────────────────────
using System.Collections;
using System.Collections.Generic;
using System.Reflection;
using Unity.AI.Navigation;
using UnityEngine;
using UnityEngine.AI;
using FIIslandManager = CryingSnow.FarmingIsland.IslandManager;
namespace WL.Island
{
public static class WLIslandNavMesh
{
// ── 진단(프로브가 읽는다)
public static int Requests, Updates, Skipped, CustomBuilds, FiReapplies;
public static string LastLog = "", LastBuildLog = "";
static bool s_pending, s_watching;
static string s_why = "";
static NavMeshData s_ours; // 마지막으로 이쪽이 구워 끼운 데이터
static MethodInfo s_collect, s_bounds;
static FieldInfo s_fiBusy; // FI IslandManager.navMeshUpdateCoroutine(비공개) — FI 갱신 진행 중이면 null 아님
static bool s_reflTried;
/// FI 섬 내비메시 다시 굽기를 예약한다(2프레임 뒤 · 같은 사이의 요청은 합친다).
public static void RequestUpdate(string why)
{
Requests++;
s_why = string.IsNullOrEmpty(s_why) ? why : s_why + " + " + why;
EnsureWatch();
if (s_pending) return;
var runner = WLIslandBridge.Runner;
if (runner == null) { Run(); return; }
s_pending = true;
runner.StartCoroutine(Co_Run());
}
static IEnumerator Co_Run()
{
yield return null;
yield return null;
s_pending = false;
Run();
}
static void Run()
{
string why = s_why; s_why = "";
var im = FIIslandManager.Instance;
var surf = im != null ? im.GetComponent() : null;
if (surf == null || surf.navMeshData == null) { Skipped++; LastLog = "건너뜀(FI 내비메시 첫 베이크 전) — " + why; return; }
Updates++;
if (TryCustomBuild(surf)) { LastLog = "섬 내비메시 다시 굽기 #" + Updates + " — " + why + " · " + LastBuildLog; return; }
surf.UpdateNavMesh(surf.navMeshData);
LastLog = "FI 섬 내비메시 갱신 #" + Updates + " — " + why + (string.IsNullOrEmpty(LastBuildLog) ? "" : " (" + LastBuildLog + ")");
}
static bool CustomEnabled(out float slope, out float radius)
{
var cfg = WL.Look.Farm.WLIslandLookSettings.Instance;
slope = cfg != null ? cfg.beachWalkNavSlope : 0f;
radius = cfg != null ? cfg.beachWalkNavRadius : 0f;
return cfg != null && cfg.enabled_ != 0 && cfg.beachWalkEnabled != 0 && (slope > 0f || radius > 0f);
}
static bool Reflect()
{
if (!s_reflTried)
{
s_reflTried = true;
var bf = BindingFlags.Instance | BindingFlags.NonPublic;
s_collect = typeof(NavMeshSurface).GetMethod("CollectSources", bf, null, System.Type.EmptyTypes, null);
s_bounds = typeof(NavMeshSurface).GetMethod("CalculateWorldBounds", bf, null, new[] { typeof(List) }, null);
s_fiBusy = typeof(FIIslandManager).GetField("navMeshUpdateCoroutine", bf);
}
return s_collect != null && s_bounds != null;
}
/// 같은 소스·같은 에이전트 종류로 경사·반지름만 바꿔 통째로 굽고 바꿔 끼운다. 못 하면 false(→ FI 기본 갱신).
static bool TryCustomBuild(NavMeshSurface surf)
{
float slope, radius;
if (!CustomEnabled(out slope, out radius)) { LastBuildLog = ""; return false; }
if (!Reflect()) { LastBuildLog = "다시 굽기 불가(NavMeshSurface 비공개 메서드 없음 · 패키지 변경?) → FI 기본 갱신"; return false; }
List sources;
Bounds bounds;
try
{
sources = (List)s_collect.Invoke(surf, null);
if (surf.collectObjects != CollectObjects.Volume) bounds = (Bounds)s_bounds.Invoke(surf, new object[] { sources });
else bounds = new Bounds(surf.center, new Vector3(Mathf.Abs(surf.size.x), Mathf.Abs(surf.size.y), Mathf.Abs(surf.size.z)));
}
catch (System.Exception ex) { LastBuildLog = "소스 수집 실패 — " + ex.Message + " → FI 기본 갱신"; return false; }
var st = surf.GetBuildSettings();
float slope0 = st.agentSlope, radius0 = st.agentRadius;
if (slope > 0f) st.agentSlope = slope;
if (radius > 0f) st.agentRadius = radius;
var sw = System.Diagnostics.Stopwatch.StartNew();
var data = NavMeshBuilder.BuildNavMeshData(st, sources, bounds, surf.transform.position, surf.transform.rotation);
float ms = (float)sw.Elapsed.TotalMilliseconds;
if (data == null) { LastBuildLog = "다시 굽기 실패(null) → FI 기본 갱신"; return false; }
data.name = surf.gameObject.name;
var old = surf.navMeshData;
surf.RemoveData();
surf.navMeshData = data;
surf.AddData();
if (old != null && old != data && old.GetInstanceID() < 0) Object.Destroy(old); // 런타임에 구운 옛 데이터만 버린다(에셋은 건드리지 않음)
s_ours = data;
CustomBuilds++;
LastBuildLog = "경사 " + slope0.ToString("F0") + "→" + st.agentSlope.ToString("F0") + "° · 반지름 " + radius0.ToString("F2") + "→" + st.agentRadius.ToString("F2") +
" m · 소스 " + sources.Count + " · " + ms.ToString("F1") + " ms";
return true;
}
// ── FI 가 자기 설정으로 다시 구운 뒤 → 우리 설정으로 되돌린다 ─────────────────
static void EnsureWatch()
{
if (s_watching) return;
var runner = WLIslandBridge.Runner;
if (runner == null) return;
s_watching = true;
runner.StartCoroutine(Co_Watch());
}
static IEnumerator Co_Watch()
{
bool wasBusy = false;
while (true)
{
yield return null;
var im = FIIslandManager.Instance;
if (im == null) { wasBusy = false; continue; }
float slope, radius;
if (!CustomEnabled(out slope, out radius) || !Reflect()) { wasBusy = false; continue; }
var surf = im.GetComponent();
if (surf == null || surf.navMeshData == null) continue;
bool busy = s_fiBusy != null && s_fiBusy.GetValue(im) != null;
if (wasBusy && !busy) { FiReapplies++; RequestUpdate("FI 갱신 뒤 재적용"); }
else if (!busy && !s_pending && s_ours != surf.navMeshData) { FiReapplies++; RequestUpdate("FI 새 내비메시"); }
wasBusy = busy;
}
}
}
}