// ───────────────────────────────────────────────────────────────────────────── // WLIslandMobZones.cs — 섬 몬스터 존: 열린 땅 1칸마다 1곳 · 한도 뒤 긴 쿨타임 · 남은 시간 글자 (#822) // // ■ 존 1개 = 힘민지 `MobControlMgr` 1개(SpawnerID = 설정 · MonsterAppear 표가 몹 종류·동시 수·리젠 간격을 정한다 · 던전 스포너와 같은 조립) // + 3D 라벨(WLIslandLabel) + PlayerPrefs 기록(누적 등장 수 · 재등장 시각). // ■ 누적 등장 = `CombatEvents.Spawned`(MobActor.On_Regen 원본 훅)를 존 반경으로 가려내 센다. // 한도 도달 → 리젠 예약 차단(`StopAllCoroutines` · StageSpawner 와 같은 경로) + 이후 리젠으로 또 나오면 즉시 회수(`Off`). // 남아 있는 몹은 잡을 때까지 유지. 쿨타임(실시간)이 끝나면 기록을 0 으로 하고 비활성 몹을 깨운다(`On_Regen` · StageSpawner.DeployWave 와 같은 경로). // ■ 2026-10-15 — 존이 꺼져 있어도(zonesEnabled 0) 타격감 확인용 무적 몬스터(hitTestDummy 1)가 켜져 있으면 시작 땅에 그 1마리 전용 자리만 만든다(`RescanDummyOnly`). // 🔴 원본 수정 0줄 · Debug.LogError 금지(원본 ErrorLogHook 이 팝업을 띄운다). // ───────────────────────────────────────────────────────────────────────────── using System; using System.Collections.Generic; using CryingSnow.FarmingIsland; using TMPro; using UnityEngine; using UnityEngine.AI; using UnityEngine.SceneManagement; using WL.Combat.Core; using WL.Island; using FIIsland = CryingSnow.FarmingIsland.Island; // 🔴 `WL.Island` 네임스페이스와 이름이 겹친다 using FIIslandManager = CryingSnow.FarmingIsland.IslandManager; using UObject = UnityEngine.Object; namespace WL.IslandMob { public sealed class WLIslandMobZones : MonoBehaviour { public sealed class Zone { public Vector3Int tile; public string key; public GameObject root; public MobControlMgr mgr; public TextMeshPro label; public Vector3 pos; public int spawned; public long cooldownUntilTicks; // 0 = 쿨타임 아님 public bool capped; public int recalled; /// 이 존이 「살려 둔」 몹(누적에 센 것) — 죽거나 꺼지면 빠진다. public readonly HashSet live = new HashSet(); public int Alive { get { live.RemoveWhere(a => a == null || !a.isActiveAndEnabled || a.IsDead()); return live.Count; } } public bool Cooling { get { return cooldownUntilTicks > 0 && DateTime.UtcNow.Ticks < cooldownUntilTicks; } } public TimeSpan Remaining { get { return cooldownUntilTicks > 0 ? new TimeSpan(Math.Max(0, cooldownUntilTicks - DateTime.UtcNow.Ticks)) : TimeSpan.Zero; } } } public static WLIslandMobZones Instance { get; private set; } public static string LastLog = ""; public static int SpawnEvents, CapHits, Rearms; public readonly List Zones = new List(16); WLIslandMobSettings _cfg; Scene _scene; bool _dummyOnly; // 존은 꺼져 있고 무적 몬스터(hitTestDummy) 1마리 자리만 둔다 public bool DummyOnly { get { return _dummyOnly; } } float _scanTimer, _labelTimer; readonly Dictionary _byTile = new Dictionary(16); public static WLIslandMobZones Begin(WLIslandSettings islandCfg, Scene scene) { if (Instance != null) return Instance; var cfg = WLIslandMobSettings.Instance; if (cfg == null || cfg.enabled_ == 0) return null; // #850 섬 몬스터 존 끔(아바타는 그대로) — 단, 2026-10-15 타격감 확인용 무적 몬스터(hitTestDummy)가 켜져 있으면 그 1마리 전용 자리만 만든다 if (cfg.zonesEnabled == 0 && cfg.hitTestDummy == 0) return null; if (InGameInfo.Ins == null) return null; // 섬 단독 Play(룩 확인)에는 테이블·ActorInfo 가 없다 var go = new GameObject("~WL_IslandMobZones"); if (scene.IsValid()) SceneManager.MoveGameObjectToScene(go, scene); Instance = go.AddComponent(); Instance._cfg = cfg; Instance._scene = scene; Instance._dummyOnly = cfg.zonesEnabled == 0; CombatEvents.Spawned.Add(Instance.OnSpawned); CombatEvents.Killed.Add(Instance.OnKilled); Instance.Rescan(); return Instance; } void OnDestroy() { CombatEvents.Spawned.Remove(OnSpawned); CombatEvents.Killed.Remove(OnKilled); if (Instance == this) Instance = null; } void Log(string s) { LastLog = s; if (_cfg != null && _cfg.verboseLog != 0) Debug.Log("[WL-822] " + s); } void Update() { _scanTimer += Time.unscaledDeltaTime; if (_scanTimer >= _cfg.rescanSeconds) { _scanTimer = 0f; Rescan(); } _labelTimer += Time.unscaledDeltaTime; if (_labelTimer >= _cfg.labelRefreshSeconds) { _labelTimer = 0f; Tick(); } } // ── 타일 → 존 ─────────────────────────────────────────────────── public void Rescan() { var mgr = FIIslandManager.Instance; if (mgr == null) return; var tiles = UObject.FindObjectsByType(FindObjectsInactive.Exclude, FindObjectsSortMode.None); if (_dummyOnly) { RescanDummyOnly(tiles); return; } for (int i = 0; i < tiles.Length; i++) { var isl = tiles[i]; if (isl == null || !isl.IsUnlocked || !isl.gameObject.activeInHierarchy) continue; if (_cfg.skipBridgeTiles != 0 && isl.IsBridge) continue; if (isl.transform.localScale.x < 0.999f) continue; // 활성화 애니(점프) 중 var key = isl.GetPosition(); if (_byTile.ContainsKey(key)) continue; var z = MakeZone(isl, key); if (z != null) { _byTile[key] = z; Zones.Add(z); } } } /// /// 존이 꺼져 있을 때(2026-10-15 PD 「타격감 체크용 무적 몬스터 1기」) — 시작 땅(격자 0,0 · 없으면 첫 열린 땅)에 무적 몬스터 1마리 전용 자리 1곳. /// 라벨·누적 기록 없음 · 스포너는 일반 존과 같은 조립(`ArmSpawner`) · 1마리만 남기는 것은 `WLTrailTest`(OnSpawned 훅). /// void RescanDummyOnly(FIIsland[] tiles) { if (Zones.Count > 0) return; FIIsland start = null; for (int i = 0; i < tiles.Length; i++) { var isl = tiles[i]; if (isl == null || !isl.IsUnlocked || !isl.gameObject.activeInHierarchy || isl.IsBridge) continue; if (isl.transform.localScale.x < 0.999f) continue; // 활성화 애니(점프) 중 if (start == null || isl.GetPosition() == Vector3Int.zero) start = isl; if (isl.GetPosition() == Vector3Int.zero) break; } if (start == null) return; var key = start.GetPosition(); NavMeshHit hit; if (!NavMesh.SamplePosition(start.transform.position + _cfg.hitTestDummyOffset, out hit, _cfg.zoneNavSnap, NavMesh.AllAreas)) { Log("무적 몬스터 자리 없음(NavMesh) — 타일 " + key); return; } var z = new Zone { tile = key, key = _cfg.prefsPrefix + "dummy", pos = hit.position }; z.root = new GameObject("~WL_HitTestDummyZone"); if (_scene.IsValid()) SceneManager.MoveGameObjectToScene(z.root, _scene); z.root.transform.position = z.pos; ArmSpawner(z); _byTile[key] = z; Zones.Add(z); Log("무적 몬스터 자리 — 타일 " + key + " @" + z.pos.ToString("F1") + "(몬스터 존 꺼짐 · 1마리 전용 · 종류 " + _cfg.hitTestDummyMobId + ")"); } Zone MakeZone(FIIsland isl, Vector3Int key) { Vector3 center = isl.transform.position; Vector3 pos; if (!PickZonePos(center, out pos)) { Log("존 자리 없음(NavMesh) — 타일 " + key); return null; } var z = new Zone { tile = key, key = _cfg.prefsPrefix + key.x + "_" + key.z, pos = pos }; z.spawned = PlayerPrefs.GetInt(z.key + "_n", 0); long ticks; z.cooldownUntilTicks = long.TryParse(PlayerPrefs.GetString(z.key + "_t", "0"), out ticks) ? ticks : 0; if (z.cooldownUntilTicks > 0 && DateTime.UtcNow.Ticks >= z.cooldownUntilTicks) { z.cooldownUntilTicks = 0; z.spawned = 0; SaveZone(z); } z.capped = z.cooldownUntilTicks > 0; z.root = new GameObject("~WL_MobZone_" + key.x + "_" + key.z); if (_scene.IsValid()) SceneManager.MoveGameObjectToScene(z.root, _scene); z.root.transform.position = pos; WLIslandLabel.Attach(z.root.transform, new Vector3(0f, _cfg.labelHeight, 0f), ""); var lt = z.root.transform.Find("label"); z.label = lt != null ? lt.GetComponent() : null; if (z.label != null) z.label.fontSize = _cfg.labelFontSize; if (!z.capped) ArmSpawner(z); RegisterKey(z.key); UpdateLabel(z); Log("존 생성 — 타일 " + key + " @" + pos.ToString("F1") + " 누적 " + z.spawned + "/" + _cfg.spawnLimit + (z.capped ? " 쿨타임 " + z.Remaining : "")); return z; } bool PickZonePos(Vector3 center, out Vector3 pos) { // 설정 오프셋 → 4 모서리 순으로 NavMesh 위이고 밭에서 떨어진 자리를 고른다 Vector3 o = _cfg.zoneOffset; Vector3[] cands = { center + o, center + new Vector3(-o.x, o.y, o.z), center + new Vector3(o.x, o.y, -o.z), center + new Vector3(-o.x, o.y, -o.z), center }; for (int i = 0; i < cands.Length; i++) { NavMeshHit hit; if (!NavMesh.SamplePosition(cands[i], out hit, _cfg.zoneNavSnap, NavMesh.AllAreas)) continue; if (_cfg.avoidFarmRadius > 0f && NearFarm(hit.position, _cfg.avoidFarmRadius)) continue; pos = hit.position; return true; } pos = center; return false; } static bool NearFarm(Vector3 p, float r) { var farms = UObject.FindObjectsByType(FindObjectsInactive.Exclude, FindObjectsSortMode.None); float r2 = r * r; for (int i = 0; i < farms.Length; i++) { if (farms[i] == null) continue; var c = farms[i].transform.position; c.y = p.y; if ((c - p).sqrMagnitude <= r2) return true; } return false; } // ── 스포너 ───────────────────────────────────────────────────── void ArmSpawner(Zone z) { if (z.mgr != null) return; var go = new GameObject("~WL_Spawner_" + _cfg.spawnerId); go.SetActive(false); // 🔴 필드부터 채우고 깨운다(던전과 동일) go.transform.SetParent(z.root.transform, false); go.transform.localPosition = Vector3.zero; var labelGo = new GameObject("lv"); labelGo.transform.SetParent(go.transform, false); var tmp = labelGo.AddComponent(); tmp.text = "Lv.???"; tmp.fontSize = 2.4f; labelGo.SetActive(false); var mgr = go.AddComponent(); mgr.SpawnerID = _cfg.spawnerId; mgr.MobGenRange = _cfg.zoneRadius; mgr.text_lv = tmp; go.SetActive(true); z.mgr = mgr; } /// 쿨타임이 끝났을 때 — 기록 0 · 비활성 몹을 표의 동시 수만큼 깨운다(StageSpawner.DeployWave 와 같은 경로). void Rearm(Zone z) { z.capped = false; z.spawned = 0; z.cooldownUntilTicks = 0; z.recalled = 0; SaveZone(z); Rearms++; if (z.mgr == null) { ArmSpawner(z); Log("존 재가동(스포너 신설) — 타일 " + z.tile); return; } int want = Mathf.Min(TableMaxCount(), _cfg.aliveMax); int done = 0; var info = ActorInfo.Ins; if (DSUtil.CheckNull(info) || want <= 0) { Log("존 재가동 — 깨울 대상 없음(ActorInfo/표)"); return; } List actors = null; try { actors = info.Get_Actors(eRole.Mob, _cfg.spawnerId, true); if (actors != null) { int alive = z.Alive; for (int i = 0; i < actors.Count && alive + done < want; i++) { var mob = actors[i] as MobActor; if (DSUtil.CheckNull(mob) || mob.isActiveAndEnabled) continue; _pendingZone = z; // 깨우는 순간 Spawned 이벤트가 이 존에 귀속되게 mob.On_Regen(); _pendingZone = null; NavMeshHit hit; var want2 = z.pos + new Vector3(UnityEngine.Random.Range(-_cfg.zoneRadius, _cfg.zoneRadius), 0f, UnityEngine.Random.Range(-_cfg.zoneRadius, _cfg.zoneRadius)); if (NavMesh.SamplePosition(want2, out hit, _cfg.zoneNavSnap, NavMesh.AllAreas)) mob.Set_Warp(hit.position); else mob.Set_Warp(z.pos); done++; } } } catch (Exception ex) { Log("존 재가동 실패 — " + ex.Message); } finally { if (actors != null) info.Release_Actors(actors); } Log("존 재가동 — 타일 " + z.tile + " 깨움 " + done); } int TableMaxCount() { try { var d = table_monsterappear.Ins.Get_Data_orNull(_cfg.spawnerId); return d != null ? d.n_MaxMonsterCount : 0; } catch { return 0; } } bool InZone(Zone z, Vector3 p) { float r = _cfg.zoneRadius + 2.5f; var d = p - z.pos; d.y = 0f; return d.sqrMagnitude <= r * r; } Zone ZoneOf(Vector3 p) { Zone best = null; float bestD = float.MaxValue; for (int i = 0; i < Zones.Count; i++) { var z = Zones[i]; var d = p - z.pos; d.y = 0f; float sq = d.sqrMagnitude; if (sq < bestD) { bestD = sq; best = z; } } float r = _cfg.zoneRadius + 2.5f; return best != null && bestD <= r * r ? best : null; } // ── 등장 집계(원본 훅 이벤트) ────────────────────────────────── Zone _pendingZone; void OnSpawned(in SpawnedEvent e) { if (e.actor == null) return; if (e.actor.Get_Group() != _cfg.spawnerId) return; // #828 궤적 테스트 모드(PlayerPrefs WL_TrailTest=1 · 2026-10-15 설정 hitTestDummy) — 더미 1마리 외 즉시 회수(누적에 안 센다) int recall = WLTrailTest.RecallKind(e.actor); if (recall != 0) { try { e.actor.Off(); WLTrailTest.Recalled++; } catch { } // 더미가 있는 동안 이 존의 리젠 예약도 멈춘다(한도 도달 때와 같은 경로) — 매초 등장·회수 반복을 막는다. // 더미가 아직 없고 종류만 다를 때(recall 1)는 멈추지 않는다 — 다음 리젠에서 원하는 종류를 기다린다. if (recall == 2) { var zr = _pendingZone ?? ZoneOf(e.position); try { if (zr != null && zr.mgr != null && zr.mgr.Get_LoadComplete()) zr.mgr.StopAllCoroutines(); } catch { } } return; } var z = _pendingZone ?? ZoneOf(e.position); if (z == null) return; SpawnEvents++; // 한도 뒤(쿨타임) · 동시 상한 초과 → 즉시 회수(누적에 안 센다) if (z.capped || z.Cooling || z.Alive >= _cfg.aliveMax) { if (_cfg.recallOverLimit != 0) { try { e.actor.Off(); z.recalled++; } catch { } } return; } z.live.Add(e.actor); z.spawned++; ApplyMobHp(e.actor); WLTrailTest.Adopt(e.actor); // #828 — 첫 몹 = 무적 더미(ApplyMobHp 뒤에 체력을 덮는다) if (z.spawned >= _cfg.spawnLimit) { z.capped = true; z.cooldownUntilTicks = DateTime.UtcNow.Ticks + (long)(_cfg.cooldownSeconds * TimeSpan.TicksPerSecond); CapHits++; try { if (z.mgr != null && z.mgr.Get_LoadComplete()) z.mgr.StopAllCoroutines(); } catch { } Log("존 한도 도달 — 타일 " + z.tile + " " + z.spawned + "/" + _cfg.spawnLimit + " → 쿨타임 " + TimeSpan.FromSeconds(_cfg.cooldownSeconds)); } if (!_dummyOnly) SaveZone(z); // 무적 몬스터 전용 자리는 누적 기록을 남기지 않는다 UpdateLabel(z); } public static int HpOverrides; public static string LastHpLog = ""; /// PD 「플레이어 공격 3~4방에 죽게」 — 등장 직후 MaxHP·HP 를 설정값으로(표 수정 0 · 던전 몹 무변경). void ApplyMobHp(Actor a) { if (_cfg.mobMaxHp <= 0) return; try { var st = a.Get_StatInfo(); if (st == null) return; double before = st.Get_Stat(eStat.MaxHP); st.Set_Stat(eStat.MaxHP, _cfg.mobMaxHp); st.Set_Stat(eStat.HP, _cfg.mobMaxHp); HpOverrides++; LastHpLog = a.name + " MaxHP " + before.ToString("F0") + " → " + _cfg.mobMaxHp.ToString("F0") + " (HP " + a.Get_HP().ToString("F0") + "/" + a.Get_MaxHP().ToString("F0") + ")"; } catch (Exception ex) { LastHpLog = "HP 덮기 실패 — " + ex.Message; } } void OnKilled(in KilledEvent e) { if (e.victim == null) return; for (int i = 0; i < Zones.Count; i++) if (Zones[i].live.Remove(e.victim)) { UpdateLabel(Zones[i]); break; } } // ── 주기 처리 · 라벨 ─────────────────────────────────────────── void Tick() { for (int i = 0; i < Zones.Count; i++) { var z = Zones[i]; if (z.capped && !z.Cooling) Rearm(z); UpdateLabel(z); } } int AliveIn(Zone z) { return z.Alive; } void UpdateLabel(Zone z) { if (z.label == null) return; if (z.capped || z.Cooling) { string s = string.Format(_cfg.labelCooldownFormat, z.Remaining); int alive = AliveIn(z); if (alive > 0) s += string.Format(_cfg.labelRemainSuffix, alive); z.label.text = s; z.label.color = _cfg.labelCooldownColor; } else { z.label.text = string.Format(_cfg.labelActiveFormat, z.spawned, _cfg.spawnLimit); z.label.color = _cfg.labelActiveColor; } } // ── 저장 ─────────────────────────────────────────────────────── void SaveZone(Zone z) { PlayerPrefs.SetInt(z.key + "_n", z.spawned); PlayerPrefs.SetString(z.key + "_t", z.cooldownUntilTicks.ToString()); } void RegisterKey(string key) { string list = PlayerPrefs.GetString(_cfg.prefsPrefix + "keys", ""); if (list.Contains(key + ";")) return; PlayerPrefs.SetString(_cfg.prefsPrefix + "keys", list + key + ";"); } /// 섬 세이브 초기화 때(WLIslandStart.Prepare) 존 기록도 지운다. public static void ResetSave() { var cfg = WLIslandMobSettings.Instance; if (cfg == null || cfg.resetWithIslandSave == 0) return; string list = PlayerPrefs.GetString(cfg.prefsPrefix + "keys", ""); var keys = list.Split(';'); for (int i = 0; i < keys.Length; i++) { if (string.IsNullOrEmpty(keys[i])) continue; PlayerPrefs.DeleteKey(keys[i] + "_n"); PlayerPrefs.DeleteKey(keys[i] + "_t"); } PlayerPrefs.DeleteKey(cfg.prefsPrefix + "keys"); } public string Describe() { var sb = new System.Text.StringBuilder(); sb.Append("zones=").Append(Zones.Count).Append(" spawnEvents=").Append(SpawnEvents).Append(" caps=").Append(CapHits).Append(" rearms=").Append(Rearms).Append(" hpOverrides=").Append(HpOverrides).Append(" lastHp=[").Append(LastHpLog).Append("]\n"); for (int i = 0; i < Zones.Count; i++) { var z = Zones[i]; sb.Append(" ").Append(z.tile).Append(" @").Append(z.pos.ToString("F1")).Append(" spawned=").Append(z.spawned).Append(" capped=").Append(z.capped) .Append(" cooling=").Append(z.Cooling).Append(" alive=").Append(AliveIn(z)).Append(" mgr=").Append(z.mgr != null ? (z.mgr.Get_LoadComplete() ? "loaded" : "loading") : "none") .Append(" label='").Append(z.label != null ? z.label.text : "-").Append("'\n"); } return sb.ToString(); } } }