// ───────────────────────────────────────────────────────────────────────────── // WLIslandSceneSetup.cs — 섬 씬(FarmingIsland Level01)에 **런타임으로만** 얹는 것들 // // 발주서 WL-816d §1-① 「원본 씬은 수정 0 — 얹어야 하면 WL 새 파일이 런타임에 얹어라」 // 816c 설계서 §B-2 표(중복 정리) · §1-B-3(섬 캐릭터 = 우리 전투 캐릭터) // // ■ 하는 일 3가지 (FI 씬·FI 코드 0줄) // ① 맵 노드 : `PCInfo.Make_Actors` 가 부르는 `LoadMapMgr.Ins` 를 섬 씬에도 만들어 준다. // 🔴 MapData 는 **일부러 붙이지 않는다** — MapData.Start 가 RenderSettings(앰비언트·안개)를 // 덮어쓰는데 섬 배경/조명은 816a(gameplay) 소유라 건드리면 안 된다. // ② 중복 정리 : WL 전투 PC(Actor) 숨김 · FI 카메라/AudioListener 중복 해제. // ③ 캐릭터 교체 : FI 농부 메시를 끄고 그 자리에 치비(LH_M05)를 **자식으로** 붙인다. // 조작·상호작용·NavMesh 는 FI `PlayerController` 그대로 — 모델만 바뀐다. // // 🔴 Debug.LogError / Debug.LogException 을 쓰지 않는다. // ───────────────────────────────────────────────────────────────────────────── using System.Collections.Generic; using UnityEngine; using UnityEngine.SceneManagement; using CryingSnow.FarmingIsland; namespace WL.Island { /// 섬 씬 런타임 준비. 정적 · 상태 없음. public static class WLIslandSceneSetup { public const string MapNodesName = "~WL_IslandMapNodes"; public const string PcVisualName = "~WL_IslandPcVisual"; public static int MapNodesMade, PcHidden, CamerasFixed, ListenersFixed, VisualSwaps; public static string LastSwapLog = ""; // ───────────────────────────────────────────────────────────────── // ① 맵 노드 — LoadMapMgr 자리 채우기 // ───────────────────────────────────────────────────────────────── /// /// 섬 씬에도 `LoadMapMgr` 를 만들어 둔다. 없으면 `PCInfo.Make_Actors:24` 가 죽은 참조를 건드린다. /// 🔴 `PCInfo.Make_Actors` 보다 **먼저** 불려야 한다(= sceneLoaded 즉시 · 같은 프레임). /// public static void EnsureMapNodes(WLIslandSettings cfg, Scene scene) { if (InGameInfo.Ins == null) return; // 섬 씬만 단독 Play = 원본 흐름이 없다 → 필요 없다 if (LoadMapMgr.isIns && LoadMapMgr.Ins != null) return; var go = new GameObject(MapNodesName); SceneManager.MoveGameObjectToScene(go, scene); // 🔴 원본 LoadMapMgr 를 그대로 붙이면 Awake 가 빈 주소로 Addressables 를 때려 에러가 난다. // 파생 클래스가 Awake 만 갈아끼운다 = 원본 0줄 · 주소 로드 0 · Ins 만 채운다. var mgr = go.AddComponent(); mgr.LoadSceneName = cfg.islandSceneName; mgr.LoadMapPrefab = ""; MapNodesMade++; WLIslandBridge.Log(cfg, "섬 맵 노드 생성(LoadMapMgr) — " + scene.name); } /// 던전 씬에도 같은 이유로 `LoadMapMgr` 자리를 채운다(맵 프리팹 없음 = 주소 로드 0). public static void EnsureDungeonMapNodes(WLIslandSettings cfg, Scene scene, WLDungeonDef d) { if (InGameInfo.Ins == null) return; if (LoadMapMgr.isIns && LoadMapMgr.Ins != null && LoadMapMgr.Ins.gameObject.scene == scene) return; var go = new GameObject(MapNodesName); SceneManager.MoveGameObjectToScene(go, scene); var mgr = go.AddComponent(); mgr.LoadSceneName = d.sceneName; mgr.LoadMapPrefab = ""; MapNodesMade++; WLIslandBridge.Log(cfg, "던전 맵 노드 생성(LoadMapMgr) — " + scene.name); } // ───────────────────────────────────────────────────────────────── // ② 중복 정리 // ───────────────────────────────────────────────────────────────── public static void FixDuplicates(WLIslandSettings cfg, Scene scene) { if (cfg.fixIslandDuplicates == 0) return; HideWlPc(cfg); // FI 카메라·AudioListener 중복 — Additive 로 붙었을 때만 정리한다. // · 섬 씬만 단독으로 열려 있으면(검증 Play) FI 카메라가 유일한 카메라라 끄면 안 된다. if (SceneManager.sceneCount <= 1) return; // 카메라 소유권 — 섬은 FarmingIsland 처럼 보여야 한다(PD) → 기본은 FI 카메라가 이긴다. if (cfg.islandCameraOwner != 0) { bool fiWins = cfg.islandCameraOwner == 1; var cams = Object.FindObjectsByType(FindObjectsSortMode.None); for (int i = 0; i < cams.Length; i++) { var c = cams[i]; if (c == null) continue; bool inIsland = c.gameObject.scene == scene; bool want = fiWins ? inIsland : !inIsland; if (c.enabled == want) continue; if (!want) s_disabledCams.Add(c); // 섬을 떠날 때 되살린다 c.enabled = want; CamerasFixed++; } } var listeners = Object.FindObjectsByType(FindObjectsSortMode.None); int alive = 0; for (int i = 0; i < listeners.Length; i++) { if (listeners[i] == null || !listeners[i].enabled) continue; alive++; if (alive > 1) { listeners[i].enabled = false; s_disabledListeners.Add(listeners[i]); ListenersFixed++; } } WLIslandBridge.Log(cfg, "중복 정리 — PC 숨김 " + PcHidden + " · 카메라 " + CamerasFixed + " · AudioListener 해제 " + ListenersFixed); } static readonly List s_disabledCams = new List(4); static readonly List s_disabledListeners = new List(4); /// 섬을 떠날 때 우리가 끈 것만 되살린다(C8 — 원래대로). public static void RestoreDuplicates() { for (int i = 0; i < s_disabledCams.Count; i++) if (s_disabledCams[i] != null) s_disabledCams[i].enabled = true; for (int i = 0; i < s_disabledListeners.Count; i++) if (s_disabledListeners[i] != null) s_disabledListeners[i].enabled = true; s_disabledCams.Clear(); s_disabledListeners.Clear(); } /// WL 전투 PC 를 섬에서 숨긴다(플레이어 2명 방지). 던전에 가면 다시 만들어진다. public static bool HideWlPc(WLIslandSettings cfg) { if (cfg.hideWlPcOnIsland == 0) return true; if (!MyValue.bMyPC || MyValue.MyPC == null) return false; if (!MyValue.MyPC.gameObject.activeSelf) return true; MyValue.MyPC.gameObject.SetActive(false); PcHidden++; return true; } // ───────────────────────────────────────────────────────────────── // ③ 캐릭터 교체 — FI 조작 그대로, 모델만 우리 치비로 // ───────────────────────────────────────────────────────────────── public static void SwapPlayerVisual(WLIslandSettings cfg, Scene scene) { if (cfg.swapIslandPlayer == 0) return; var fiPlayer = Object.FindFirstObjectByType(); if (fiPlayer == null) { LastSwapLog = "FI PlayerController 없음 — 교체 건너뜀"; return; } if (fiPlayer.transform.Find(PcVisualName) != null) return; // 이미 했다 var prefab = WLIslandAssets.LoadPrefab(cfg.pcPrefabPath); if (prefab == null) { LastSwapLog = "치비 프리팹을 못 찾았다 — " + cfg.pcPrefabPath; return; } // 🔴 비활성 부모 아래에서 인스턴스 → PCActor 등 전투 컴포넌트의 Awake 가 돌지 않는다. var holder = new GameObject("~WLIslandSpawnHolder"); holder.SetActive(false); var visual = Object.Instantiate(prefab, holder.transform); visual.name = PcVisualName; StripCombatComponents(visual); visual.transform.SetParent(fiPlayer.transform, false); visual.transform.localPosition = Vector3.zero; visual.transform.localRotation = Quaternion.identity; visual.transform.localScale = new Vector3(cfg.islandPcScale, cfg.islandPcScale, cfg.islandPcScale); // FI 농부 메시 숨김(FI 오브젝트는 파괴하지 않는다 — 끄기만 한다 = 되돌리기 가능) int hidden = HideFiModel(cfg, fiPlayer.gameObject, visual); // 애니메이션은 FI 컨트롤러를 그대로 흘린다(둘 다 Humanoid → 리타깃) var rig = visual.AddComponent(); rig.Bind(fiPlayer.GetComponent(), cfg); Object.Destroy(holder); visual.SetActive(true); VisualSwaps++; float h = MeasureHeight(visual); LastSwapLog = "치비 교체 완료 — 스케일 " + visual.transform.localScale.x.ToString("F4") + " · 월드 높이 " + h.ToString("F4") + " m · FI 메시 숨김 " + hidden + "개"; WLIslandBridge.Log(cfg, LastSwapLog); } /// 전투/이동 컴포넌트를 떼어 낸다 — 남기는 것은 Animator · 렌더러 · WL 룩/크기 보정뿐. static void StripCombatComponents(GameObject root) { // 🔴 [RequireComponent] 의존 때문에 순서에 따라 삭제가 거부될 수 있다 → 2회 훑고 개별 try for (int pass = 0; pass < 2; pass++) { var mbs = root.GetComponentsInChildren(true); for (int i = 0; i < mbs.Length; i++) { var mb = mbs[i]; if (mb == null) continue; if (mb is WL.Character.WLPcScaleCompensator) continue; // 높이 1.1912 m 유지 if (mb is WL.Look.Character.WLCharacterLook) continue; // 팔레트 UV 도트화(814x) 유지 Kill(mb); } } KillAll(root.GetComponentsInChildren(true)); KillAll(root.GetComponentsInChildren(true)); KillAll(root.GetComponentsInChildren(true)); KillAll(root.GetComponentsInChildren(true)); // CharacterController 포함 KillAll(root.GetComponentsInChildren(true)); KillAll(root.GetComponentsInChildren(true)); } static void KillAll(T[] arr) where T : Component { if (arr == null) return; for (int i = 0; i < arr.Length; i++) Kill(arr[i]); } static void Kill(Component c) { if (c == null) return; try { Object.DestroyImmediate(c); } catch (System.Exception) { var b = c as Behaviour; if (b != null) b.enabled = false; } } static int HideFiModel(WLIslandSettings cfg, GameObject fiPlayer, GameObject exclude) { int n = 0; if (!string.IsNullOrEmpty(cfg.fiPlayerModelName)) { var t = FindChild(fiPlayer.transform, cfg.fiPlayerModelName, exclude.transform); if (t != null) { t.gameObject.SetActive(false); return 1; } } var smrs = fiPlayer.GetComponentsInChildren(true); for (int i = 0; i < smrs.Length; i++) { if (smrs[i] == null) continue; if (smrs[i].transform.IsChildOf(exclude.transform)) continue; smrs[i].enabled = false; n++; } return n; } static Transform FindChild(Transform root, string name, Transform skip) { var all = root.GetComponentsInChildren(true); for (int i = 0; i < all.Length; i++) { if (all[i] == root || all[i] == skip) continue; if (skip != null && all[i].IsChildOf(skip)) continue; if (string.Equals(all[i].name, name, System.StringComparison.OrdinalIgnoreCase)) return all[i]; } return null; } /// 월드 AABB 높이(실측 보고용). public static float MeasureHeight(GameObject root) { var rs = root.GetComponentsInChildren(true); bool any = false; Bounds b = new Bounds(); for (int i = 0; i < rs.Length; i++) { if (rs[i] == null) continue; if (!any) { b = rs[i].bounds; any = true; } else b.Encapsulate(rs[i].bounds); } return any ? b.size.y : 0f; } } /// /// 섬 씬 전용 `LoadMapMgr`. 원본은 Awake 에서 맵 프리팹을 Addressables 로 받아 오는데, /// 섬은 씬 자체가 맵이라 받을 것이 없다 → Awake 만 갈아끼워 **주소 로드 0**·싱글턴만 채운다. /// 🔴 원본 `LoadMapMgr.cs` 는 한 줄도 고치지 않는다(파생으로 해결). /// public sealed class WLIslandMapNodes : LoadMapMgr { protected override void Awake() { Ins = this; isIns = true; } } /// FI 애니메이터의 파라미터를 치비 애니메이터로 그대로 흘린다(Humanoid 리타깃). public sealed class WLIslandPcRig : MonoBehaviour { Animator _src, _dst; AnimatorControllerParameter[] _params; public void Bind(Animator src, WLIslandSettings cfg) { _src = src; _dst = GetComponent(); if (_dst == null) _dst = GetComponentInChildren(true); if (_src == null || _dst == null) return; if (cfg.mirrorFiAnimator != 0) { _dst.runtimeAnimatorController = _src.runtimeAnimatorController; _dst.applyRootMotion = false; _dst.updateMode = _src.updateMode; _dst.cullingMode = AnimatorCullingMode.AlwaysAnimate; _params = _src.parameters; } } void LateUpdate() { if (_src == null || _dst == null || _params == null) return; for (int i = 0; i < _params.Length; i++) { var p = _params[i]; switch (p.type) { case AnimatorControllerParameterType.Float: _dst.SetFloat(p.nameHash, _src.GetFloat(p.nameHash)); break; case AnimatorControllerParameterType.Int: _dst.SetInteger(p.nameHash, _src.GetInteger(p.nameHash)); break; case AnimatorControllerParameterType.Bool: _dst.SetBool(p.nameHash, _src.GetBool(p.nameHash)); break; } } } } /// 섬 코인(= `IslandManager.Coin` · FI 의 유일한 재화)에 닿는 유일한 창구. public static class WLIslandCoin { public static bool Ready { get { return IslandManager.Instance != null; } } public static bool TryGet(out int coin) { coin = 0; var m = IslandManager.Instance; if (m == null) return false; coin = m.Coin; return true; } public static bool Add(int amount) { var m = IslandManager.Instance; if (m == null) return false; m.Coin += amount; // setter 가 OnCoinChanged 를 쏜다 → CoinDisplay 가 갱신된다 return true; } } /// 프리팹 로드 — 실기(Addressables)와 에디터 단독 Play 양쪽을 덮는다. public static class WLIslandAssets { static readonly Dictionary s_cache = new Dictionary(4); static TMPro.TMP_FontAsset s_font; static bool s_fontTried; /// 이름표·버튼용 한글 폰트(없으면 null = TMP 기본 폰트). public static TMPro.TMP_FontAsset Font(string path) { if (s_fontTried) return s_font; s_fontTried = true; if (string.IsNullOrEmpty(path)) return null; #if UNITY_EDITOR s_font = UnityEditor.AssetDatabase.LoadAssetAtPath(path); if (s_font != null) return s_font; #endif try { var h = UnityEngine.AddressableAssets.Addressables.LoadAssetAsync(path); s_font = h.WaitForCompletion(); } catch (System.Exception) { s_font = null; } return s_font; } public static GameObject LoadPrefab(string path) { if (string.IsNullOrEmpty(path)) return null; GameObject go; if (s_cache.TryGetValue(path, out go) && go != null) return go; #if UNITY_EDITOR go = UnityEditor.AssetDatabase.LoadAssetAtPath(path); if (go != null) { s_cache[path] = go; return go; } #endif // 빌드에서는 Addressables 동기 로드가 필요하다(섬 진입은 이미 로딩 화면 안이다). try { var h = UnityEngine.AddressableAssets.Addressables.LoadAssetAsync(path); go = h.WaitForCompletion(); if (go != null) s_cache[path] = go; } catch (System.Exception ex) { Debug.LogWarning("[WL-816d] 프리팹 로드 실패 " + path + " — " + ex.Message); } return go; } } }