Project_WL/Assets/WL/Island/WLIslandSceneSetup.cs

541 lines
28 KiB
C#
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// ─────────────────────────────────────────────────────────────────────────────
// 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
{
/// <summary>섬 씬 런타임 준비. 정적 · 상태 없음.</summary>
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 자리 채우기
// ─────────────────────────────────────────────────────────────────
/// <summary>
/// 섬 씬에도 `LoadMapMgr` 를 만들어 둔다. 없으면 `PCInfo.Make_Actors:24` 가 죽은 참조를 건드린다.
/// 🔴 `PCInfo.Make_Actors` 보다 **먼저** 불려야 한다(= sceneLoaded 즉시 · 같은 프레임).
/// </summary>
public static void EnsureMapNodes(WLIslandSettings cfg, Scene scene)
{
if (InGameInfo.Ins == null) return; // 섬 씬만 단독 Play = 원본 흐름이 없다 → 필요 없다
var mgr = WLIslandMapNodes.Ensure(cfg);
if (mgr == null) return;
mgr.LoadSceneName = cfg.islandSceneName;
mgr.SetStartPos(IslandStartPos(), WLDungeonCameraRig.SolvedYaw(cfg));
WLIslandBridge.Log(cfg, "섬 맵 노드 준비(LoadMapMgr) — " + scene.name + " · 시작 " + mgr.StartPos.ToString("F1"));
}
/// <summary>섬의 시작 지점 = FI 플레이어가 서 있는 자리(없으면 원점).</summary>
static Vector3 IslandStartPos()
{
var fiPlayer = Object.FindFirstObjectByType<PlayerController>();
return fiPlayer != null ? fiPlayer.transform.position : Vector3.zero;
}
/// <summary>던전 씬에도 같은 이유로 `LoadMapMgr` 자리를 채운다(맵 프리팹 없음 = 주소 로드 0).</summary>
public static void EnsureDungeonMapNodes(WLIslandSettings cfg, Scene scene, WLDungeonDef d)
{
if (InGameInfo.Ins == null) return;
var mgr = WLIslandMapNodes.Ensure(cfg);
if (mgr == null) return;
mgr.LoadSceneName = d.sceneName;
mgr.SetStartPos(DungeonStartPos(d), WLDungeonCameraRig.SolvedYaw(cfg));
WLIslandBridge.Log(cfg, "던전 맵 노드 준비(LoadMapMgr) — " + scene.name + " · 시작 " + mgr.StartPos.ToString("F1"));
}
/// <summary>던전(아레나)에는 포털이 없다 — 스테이지 표의 앵커가 시작 지점이다.</summary>
public static Vector3 DungeonStartPos(WLDungeonDef d)
{
var def = WLDungeonSceneSetup.StageDefOf(d);
return def != null ? def.anchor : Vector3.zero;
}
// ─────────────────────────────────────────────────────────────────
// ② 중복 정리
// ─────────────────────────────────────────────────────────────────
public static void FixDuplicates(WLIslandSettings cfg, Scene scene)
{
ApplyIslandCameraDistance(cfg, scene); // 2026-09-15 PD 「섬과 던전 모두 좀 더 가까이」 — 섬 카메라 거리(SO)
ApplyPlayerScale(cfg); // 2026-09-16 PD 「캐릭터 30 % 키워」
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<Camera>(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<AudioListener>(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);
}
// ─────────────────────────────────────────────────────────────────
// ②-b 섬 카메라 거리 — FI `PlayerVirtualCamera` 의 CinemachineFramingTransposer.m_CameraDistance(씬 값 22)를
// 섬 로드 때 SO 값으로 덮는다(2026-09-15 PD 「섬과 던전 모두 좀 더 가까이」 · FI 씬·코드 0줄).
// `islandCamDistance` 0 이하면 무동작 = 씬 값 그대로(되돌리기).
// ─────────────────────────────────────────────────────────────────
public static int IslandCamApplied;
public static void ApplyIslandCameraDistance(WLIslandSettings cfg, Scene scene)
{
if (cfg == null || cfg.islandCamDistance <= 0f) return;
var vcams = Object.FindObjectsByType<Cinemachine.CinemachineVirtualCamera>(FindObjectsSortMode.None);
for (int i = 0; i < vcams.Length; i++)
{
var v = vcams[i];
if (v == null || v.gameObject.scene != scene) continue;
if (!string.IsNullOrEmpty(cfg.islandVcamName) && v.name != cfg.islandVcamName) continue;
var ft = v.GetCinemachineComponent<Cinemachine.CinemachineFramingTransposer>();
if (ft == null) continue;
float was = ft.m_CameraDistance;
ft.m_CameraDistance = cfg.islandCamDistance;
IslandCamApplied++;
string pitchLog = "";
if (cfg.islandCamPitch > 0f)
{
// 2026-09-15 PD 「카메라 각도 60도로 비교」 — vcam 은 Aim 없이 고정 회전이라 트랜스폼 회전의 X 만 바꾼다(씬 값 45).
var e = v.transform.eulerAngles;
v.transform.rotation = Quaternion.Euler(cfg.islandCamPitch, e.y, 0f);
pitchLog = " · 각도 " + e.x.ToString("F0") + " → " + cfg.islandCamPitch.ToString("F0") + "°";
}
WLIslandBridge.Log(cfg, "섬 카메라 거리 — " + v.name + " " + was.ToString("F1") + " → " +
cfg.islandCamDistance.ToString("F1") + " m" + pitchLog);
}
}
// ── 2026-09-16 PD 「캐릭터의 크기를 30 % 키워」 — FI 플레이어 트랜스폼 스케일(CharacterController·애니 함께 커진다) · 프리팹 0줄 · 표식으로 1회만
public static int PlayerScaled;
public static void ApplyPlayerScale(WLIslandSettings cfg)
{
if (cfg == null || cfg.islandPlayerScale <= 0f || Mathf.Approximately(cfg.islandPlayerScale, 1f)) return;
var fiPlayer = Object.FindFirstObjectByType<PlayerController>();
if (fiPlayer == null || fiPlayer.GetComponent<WLPlayerScaled>() != null) return;
fiPlayer.transform.localScale = fiPlayer.transform.localScale * cfg.islandPlayerScale;
fiPlayer.gameObject.AddComponent<WLPlayerScaled>();
PlayerScaled++;
WLIslandBridge.Log(cfg, "섬 플레이어 크기 ×" + cfg.islandPlayerScale.ToString("F2") + " → " + fiPlayer.transform.localScale.ToString("F2"));
}
static readonly List<Camera> s_disabledCams = new List<Camera>(4);
static readonly List<AudioListener> s_disabledListeners = new List<AudioListener>(4);
/// <summary>섬을 떠날 때 우리가 끈 것만 되살린다(C8 — 원래대로).</summary>
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();
}
/// <summary>WL 전투 PC 를 섬에서 숨긴다(플레이어 2명 방지). 던전에 가면 다시 만들어진다.</summary>
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<PlayerController>();
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<WLIslandPcRig>();
rig.Bind(fiPlayer.GetComponent<Animator>(), 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);
}
/// <summary>전투/이동 컴포넌트를 떼어 낸다 — 남기는 것은 Animator · 렌더러 · WL 룩/크기 보정뿐.</summary>
static void StripCombatComponents(GameObject root)
{
// 🔴 [RequireComponent] 의존 때문에 순서에 따라 삭제가 거부될 수 있다 → 2회 훑고 개별 try
for (int pass = 0; pass < 2; pass++)
{
var mbs = root.GetComponentsInChildren<MonoBehaviour>(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<UnityEngine.AI.NavMeshAgent>(true));
KillAll(root.GetComponentsInChildren<UnityEngine.AI.NavMeshObstacle>(true));
KillAll(root.GetComponentsInChildren<Rigidbody>(true));
KillAll(root.GetComponentsInChildren<Collider>(true)); // CharacterController 포함
KillAll(root.GetComponentsInChildren<AudioSource>(true));
KillAll(root.GetComponentsInChildren<AudioListener>(true));
}
static void KillAll<T>(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<SkinnedMeshRenderer>(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<Transform>(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;
}
/// <summary>월드 AABB 높이(실측 보고용).</summary>
public static float MeasureHeight(GameObject root)
{
var rs = root.GetComponentsInChildren<Renderer>(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;
}
}
/// <summary>
/// 섬·던전 공용 `LoadMapMgr`. 원본은 Awake 에서 맵 프리팹을 Addressables 로 받아 오는데,
/// 섬·던전은 씬 자체가 맵이라 받을 것이 없다 → Awake 만 갈아끼워 **주소 로드 0**·싱글턴만 채운다.
/// 🔴 원본 `LoadMapMgr.cs` 는 한 줄도 고치지 않는다(파생 + private 필드 주입으로 해결).
///
/// ■ WL-816i 가 고친 것 2가지 (PD 실플레이 에러 ①)
/// ① `MissingReferenceException … WLIslandMapNodes has been destroyed` —
/// 씬마다 새로 만들던 노드가 `LoadSceneMode` 전환에서 파괴되는데 정적 `Ins` 는 그대로 남아
/// `Get_PortalPos` 의 `StartCoroutine` 이 죽은 객체를 때렸다.
/// → **DontDestroyOnLoad 단 하나**만 두고 씬이 바뀌면 시작 지점만 갈아끼운다.
/// ② `Co_Get_PortalPos` 의 `while (CheckNull(m_MapData)) yield return null;` 이 끝나지 않던 것 —
/// 우리는 맵 프리팹이 없어 `m_MapData` 가 영원히 null 이라 `Make_Actors` 의 콜백(펫·일꾼)과
/// `PCActor.Set_Warp`·`MobControlMgr` 의 도달성 검사가 **영영 안 불렸다**.
/// → 런타임 `MapData` 를 하나 만들어 넣는다. **꺼진 오브젝트**에 붙여서
/// `MapData.Awake/Start` 가 돌지 않게 한다(= `MapData.Ins` 도, RenderSettings 앰비언트·안개도 무변경).
/// </summary>
public sealed class WLIslandMapNodes : LoadMapMgr
{
public const int ZoneSlots = 16; // n_PortalIndex 가 무엇이 오든 IndexOutOfRange 가 안 나게
public const int ObjSlots = 8; // Get_tfObj(index) 여유분
static WLIslandMapNodes s_ins;
static readonly System.Reflection.FieldInfo F_MapData =
typeof(LoadMapMgr).GetField("m_MapData", System.Reflection.BindingFlags.NonPublic |
System.Reflection.BindingFlags.Instance);
static readonly System.Reflection.FieldInfo F_GoMap =
typeof(LoadMapMgr).GetField("go_map", System.Reflection.BindingFlags.NonPublic |
System.Reflection.BindingFlags.Instance);
Transform _start;
MapData _data;
GameObject _dummyMap;
/// <summary>현재 씬의 시작 지점(보고·진단용).</summary>
public Vector3 StartPos { get { return _start != null ? _start.position : Vector3.zero; } }
protected override void Awake() { Ins = this; isIns = true; }
/// <summary>
/// 씬을 넘어 사는 노드를 **하나만** 만들고 싱글턴 자리를 되찾는다.
/// (다른 맵의 원본 `LoadMapMgr` 가 `Ins` 를 가져갔다가 그 씬이 언로드되면 죽은 참조가 남는다 — 그것도 여기서 덮는다.)
/// </summary>
public static WLIslandMapNodes Ensure(WLIslandSettings cfg)
{
if (s_ins == null)
{
var go = new GameObject(WLIslandSceneSetup.MapNodesName);
Object.DontDestroyOnLoad(go);
s_ins = go.AddComponent<WLIslandMapNodes>();
s_ins.LoadMapPrefab = "";
s_ins.Build();
WLIslandSceneSetup.MapNodesMade++;
}
Ins = s_ins; isIns = true; // 죽은 참조로 남지 않게 매번 되찾는다
return s_ins;
}
void Build()
{
// ① 시작 지점 노드 — 포털/부활/농부/오브젝트 자리를 전부 이 하나로 답한다(던전은 아레나라 포털이 없다).
var holder = new GameObject("MapNodes");
holder.transform.SetParent(transform, false);
holder.SetActive(false); // 🔴 꺼진 채로 AddComponent → MapData.Awake/Start 가 아예 안 돈다
var startGo = new GameObject("start");
startGo.transform.SetParent(holder.transform, false);
_start = startGo.transform;
_data = holder.AddComponent<MapData>();
var objs = new Transform[ObjSlots];
for (int i = 0; i < ObjSlots; i++) objs[i] = _start;
_data.m_MapZoneData = new List<MapZoneData>(ZoneSlots);
for (int i = 0; i < ZoneSlots; i++)
{
_data.m_MapZoneData.Add(new MapZoneData
{
go_portal = startGo,
go_reincarnation = startGo,
go_mazeNextPortal = null, // Set_MazeNextPortal 은 null 이면 그냥 넘어간다(원본 확인)
tf_Farmer = _start,
tf_Obj = objs,
CamRot_Portal = 0f,
CamRot_Reincarnation = 0f,
});
}
// ② SetActive_Map(bool) 이 만지는 자리(비어 있으면 NullReference) — 아무것도 안 든 더미.
_dummyMap = new GameObject("map");
_dummyMap.transform.SetParent(transform, false);
// ③ 원본의 private 필드에 넣는다 — 원본 코드 0줄로 `Co_Get_*` 의 대기를 즉시 끝낸다.
if (F_MapData != null) F_MapData.SetValue(this, _data);
if (F_GoMap != null) F_GoMap.SetValue(this, _dummyMap);
}
/// <summary>씬이 바뀔 때마다 시작 지점과 카메라 초기 방향만 갈아끼운다(객체는 그대로 산다).</summary>
public void SetStartPos(Vector3 pos, float camYaw)
{
if (_start == null) Build();
_start.position = pos - Vector3.forward; // 원본이 +Vector3.forward 를 더해 돌려준다 → 결과가 pos 가 되게
if (_data == null || _data.m_MapZoneData == null) return;
for (int i = 0; i < _data.m_MapZoneData.Count; i++)
{
var z = _data.m_MapZoneData[i];
if (z == null) continue;
z.CamRot_Portal = camYaw;
z.CamRot_Reincarnation = camYaw;
}
}
}
/// <summary>FI 애니메이터의 파라미터를 치비 애니메이터로 그대로 흘린다(Humanoid 리타깃).</summary>
public sealed class WLIslandPcRig : MonoBehaviour
{
Animator _src, _dst;
AnimatorControllerParameter[] _params;
public void Bind(Animator src, WLIslandSettings cfg)
{
_src = src;
_dst = GetComponent<Animator>();
if (_dst == null) _dst = GetComponentInChildren<Animator>(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;
}
}
}
}
/// <summary>섬 코인(= `IslandManager.Coin` · FI 의 유일한 재화)에 닿는 유일한 창구.</summary>
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;
}
}
/// <summary>프리팹 로드 — 실기(Addressables)와 에디터 단독 Play 양쪽을 덮는다.</summary>
public static class WLIslandAssets
{
static readonly Dictionary<string, GameObject> s_cache = new Dictionary<string, GameObject>(4);
static TMPro.TMP_FontAsset s_font;
static bool s_fontTried;
/// <summary>이름표·버튼용 한글 폰트(없으면 null = TMP 기본 폰트).</summary>
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<TMPro.TMP_FontAsset>(path);
if (s_font != null) return s_font;
#endif
try
{
var h = UnityEngine.AddressableAssets.Addressables.LoadAssetAsync<TMPro.TMP_FontAsset>(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<GameObject>(path);
if (go != null) { s_cache[path] = go; return go; }
#endif
// 빌드에서는 Addressables 동기 로드가 필요하다(섬 진입은 이미 로딩 화면 안이다).
try
{
var h = UnityEngine.AddressableAssets.Addressables.LoadAssetAsync<GameObject>(path);
go = h.WaitForCompletion();
if (go != null) s_cache[path] = go;
}
catch (System.Exception ex) { Debug.LogWarning("[WL-816d] 프리팹 로드 실패 " + path + " — " + ex.Message); }
return go;
}
}
}