Project_WL/Assets/WL/Island/WLIslandSceneSetup.cs

317 lines
16 KiB
C#

// ─────────────────────────────────────────────────────────────────────────────
// 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 = 원본 흐름이 없다 → 필요 없다
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<WLIslandMapNodes>();
mgr.LoadSceneName = cfg.islandSceneName;
mgr.LoadMapPrefab = "";
MapNodesMade++;
WLIslandBridge.Log(cfg, "섬 맵 노드 생성(LoadMapMgr) — " + scene.name);
}
/// <summary>던전 씬에도 같은 이유로 `LoadMapMgr` 자리를 채운다(맵 프리팹 없음 = 주소 로드 0).</summary>
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<WLIslandMapNodes>();
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;
// WL 전투 PC 는 섬에서 숨긴다(플레이어 2명 방지) — 던전에 가면 다시 만들어진다.
if (cfg.hideWlPcOnIsland != 0 && MyValue.bMyPC && MyValue.MyPC != null)
{
MyValue.MyPC.gameObject.SetActive(false);
PcHidden++;
}
// FI 카메라·AudioListener 중복 — Additive 로 붙었을 때만 정리한다.
// · 섬 씬만 단독으로 열려 있으면(검증 Play) FI 카메라가 유일한 카메라라 끄면 안 된다.
if (SceneManager.sceneCount <= 1) return;
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].gameObject.scene == scene) { listeners[i].enabled = false; ListenersFixed++; }
}
WLIslandBridge.Log(cfg, "중복 정리 — PC 숨김 " + PcHidden + " · AudioListener 해제 " + ListenersFixed);
}
// ─────────────────────────────────────────────────────────────────
// ③ 캐릭터 교체 — 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)
{
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) 유지
Object.DestroyImmediate(mb);
}
var cc = root.GetComponent<CharacterController>(); if (cc != null) Object.DestroyImmediate(cc);
var agents = root.GetComponentsInChildren<UnityEngine.AI.NavMeshAgent>(true);
for (int i = 0; i < agents.Length; i++) if (agents[i] != null) Object.DestroyImmediate(agents[i]);
var obs = root.GetComponentsInChildren<UnityEngine.AI.NavMeshObstacle>(true);
for (int i = 0; i < obs.Length; i++) if (obs[i] != null) Object.DestroyImmediate(obs[i]);
var rbs = root.GetComponentsInChildren<Rigidbody>(true);
for (int i = 0; i < rbs.Length; i++) if (rbs[i] != null) Object.DestroyImmediate(rbs[i]);
var cols = root.GetComponentsInChildren<Collider>(true);
for (int i = 0; i < cols.Length; i++) if (cols[i] != null) Object.DestroyImmediate(cols[i]);
var srcs = root.GetComponentsInChildren<AudioSource>(true);
for (int i = 0; i < srcs.Length; i++) if (srcs[i] != null) Object.DestroyImmediate(srcs[i]);
var listeners = root.GetComponentsInChildren<AudioListener>(true);
for (int i = 0; i < listeners.Length; i++) if (listeners[i] != null) Object.DestroyImmediate(listeners[i]);
}
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` 는 한 줄도 고치지 않는다(파생으로 해결).
/// </summary>
public sealed class WLIslandMapNodes : LoadMapMgr
{
protected override void Awake() { Ins = this; isIns = true; }
}
/// <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);
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;
}
}
}