Project_WL/Assets/WL/IslandMob/WLIslandMobZones.cs

446 lines
23 KiB
C#

// ─────────────────────────────────────────────────────────────────────────────
// 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;
/// <summary>이 존이 「살려 둔」 몹(누적에 센 것) — 죽거나 꺼지면 빠진다.</summary>
public readonly HashSet<Actor> live = new HashSet<Actor>();
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<Zone> Zones = new List<Zone>(16);
WLIslandMobSettings _cfg;
Scene _scene;
bool _dummyOnly; // 존은 꺼져 있고 무적 몬스터(hitTestDummy) 1마리 자리만 둔다
public bool DummyOnly { get { return _dummyOnly; } }
float _scanTimer, _labelTimer;
readonly Dictionary<Vector3Int, Zone> _byTile = new Dictionary<Vector3Int, Zone>(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<WLIslandMobZones>();
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<FIIsland>(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); }
}
}
/// <summary>
/// 존이 꺼져 있을 때(2026-10-15 PD 「타격감 체크용 무적 몬스터 1기」) — 시작 땅(격자 0,0 · 없으면 첫 열린 땅)에 무적 몬스터 1마리 전용 자리 1곳.
/// 라벨·누적 기록 없음 · 스포너는 일반 존과 같은 조립(`ArmSpawner`) · 1마리만 남기는 것은 `WLTrailTest`(OnSpawned 훅).
/// </summary>
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<TextMeshPro>() : 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<Farm>(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<TextMeshPro>();
tmp.text = "Lv.???"; tmp.fontSize = 2.4f;
labelGo.SetActive(false);
var mgr = go.AddComponent<MobControlMgr>();
mgr.SpawnerID = _cfg.spawnerId;
mgr.MobGenRange = _cfg.zoneRadius;
mgr.text_lv = tmp;
go.SetActive(true);
z.mgr = mgr;
}
/// <summary>쿨타임이 끝났을 때 — 기록 0 · 비활성 몹을 표의 동시 수만큼 깨운다(StageSpawner.DeployWave 와 같은 경로).</summary>
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<Actor> 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 = "";
/// <summary>PD 「플레이어 공격 3~4방에 죽게」 — 등장 직후 MaxHP·HP 를 설정값으로(표 수정 0 · 던전 몹 무변경).</summary>
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 + ";");
}
/// <summary>섬 세이브 초기화 때(WLIslandStart.Prepare) 존 기록도 지운다.</summary>
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();
}
}
}