Project_WL/Assets/WL/SkillTest/WLSkillTestBoot.cs

494 lines
31 KiB
C#

// ─────────────────────────────────────────────────────────────────────────────
// WLSkillTestBoot.cs — 무기 스킬 구현용 가벼운 테스트 씬(WLSkillTest) 부트 (PD #947 · 2026-10-19)
//
// PD 원문: 「Title 씬에서 무기 스킬 작업을 진행하다보니 쓸데 없이 로딩이 너무 길고 테스트가 힘들어. … 최대한 가벼운 테스트용 씬을 만들고
// 이 씬에서 스킬 작업을 할 수 있도록 … (우리 게임의 무기 스킬 구현은 앞으로 테스트 씬을 통해 진행할게)」
//
// ■ 왜 — Title → 로그인 → Ingame → 섬 흐름은 Play 1회에 약 115 s(도메인 리로드 32 + Title 씬 21 + Title 초기화 47 + 로그인 3.5 + 섬 9 · #943-① 실측).
// 스킬 작업은 PC 1 + 무적 더미 1 + 평지면 충분하다 → Title 씬·섬 씬(FarmingIsland)·농장 시스템을 빼고 **원본 InGame 흐름만** 돌린다.
// ■ 어떻게 (원본 코드 0줄 · 테이블 0줄 · 빌드 설정 0줄)
// ① 씬에 매니저 프리팹을 직접 둔다(Title 이 Addressables 로 올리던 Tables·SoundInfo·UIAtlasMgr·SortOrder_5·ActorInfo + Mgr(AddrResourceMgr·CoroutineMgr·TimeMgr)
// + EventSystem + 원본 Main Camera 프리팹(RealCamera) + InGame 씬 루트 4종) → 테이블 로딩 대기. ※ 씬 조립 = Editor/WLSkillTestSceneBuilder.cs
// ② 개발 로그인(ServerInfo.Login · TitleInfo 없이 · ID = PlayerPrefs Option_UserID 또는 설정값) → m_ServerData(진짜 계정 데이터 · 장비·스킬 슬롯 그대로).
// ③ 섬·던전과 같은 LoadMapMgr 대체 노드(WLIslandMapNodes)에 시작 지점 + 섬 구도 카메라 리그(WLDungeonCameraRig).
// ④ InGameInfo·PCInfo 프리팹을 **로그인 뒤** 생성 → InGameInfo 가 NewGameUI(HUD · 조이스틱 · 장비창)를 올리고 NewGameUI.Start 가 Load_Map(섬 900)을 부른다.
// 그 전에 SceneInfo.CurStageData 를 900 행으로 미리 채워(리플렉션) 「같은 맵」 분기 = **섬 씬을 로드하지 않고** PCInfo.Make_Actors 만 돈다.
// ⑤ PC 가 생기면 StartCam + SetUI_byGameMode(Lobby) + 무적 더미(몬스터 표 hitTestDummyMobId · WLTrailTest 채택 · HP 되채움) → Stage = ready.
// ⑥ 키: WASD/방향키 이동(원본 Run_byJoystick) · [ ] 낀 무기 바꾸기(WLInventory 장비 순환) · R 더미 제자리 · Q/E/숫자 = 기존 훅(WLSkillHotkey·WLWeaponGradeCheat).
// ■ 값 = Resources/WL/WLSkillTest.json (C45) · 진단 = Describe() · 단계 시각 = Laps(로딩 실측 보고용)
// 🔴 Debug.LogError 금지(원본 ErrorLogHook 이 팝업을 띄운다) · 씬 이름 "WLSkillTest" 는 원본 "StatTest" 분기와 무관(그 분기는 MainToStatTest 를 요구한다).
// 🔴 섬 씬 이름(Level01)과 다르므로 WLIslandBridge·WLIslandMobZones 등 섬 전용 훅은 이 씬에서 돌지 않는다(의도).
// ─────────────────────────────────────────────────────────────────────────────
using System.Collections;
using System.Collections.Generic;
using System.Reflection;
using Unity.AI.Navigation;
using UnityEngine;
using UnityEngine.ResourceManagement.AsyncOperations;
using UnityEngine.SceneManagement;
using WL.Combat.Weapon;
using WL.Equip;
using WL.Island;
using WL.IslandMob;
namespace WL.SkillTest
{
[System.Serializable]
public class WLSkillTestJson
{
public string note = "";
public string loginId = "TestID"; // 개발 로그인 ID 기본값
public int useOptionUserId = 1; // 1 = PlayerPrefs Option_UserID(Title 에서 마지막에 쓴 ID)를 우선
public int mapId = 0; // 0 = WLIslandSettings.islandMapId(섬 행 900 · Lobby 모드) — 씬은 로드하지 않고 모드·노드만 빌린다
public float[] spawnPos = { 0f, 0f, 0f };
public int cameraRig = 1; // 1 = 섬 구도 카메라 리그(WLDungeonCameraRig) 생성
public int dummyMobId = 0; // 0 = WLIslandMobSettings.hitTestDummyMobId
public int dummyCount = 1;
public int dummySpawnerId = 1;
public float[] dummyOffset = { 0f, 0f, 0f }; // 전부 0 = WLIslandMobSettings.hitTestDummyOffset (PC 기준 오른쪽·위·앞)
public float dummyGap = 2.5f; // 더미가 둘 이상일 때 옆 간격 m
public float waitPcSeconds = 30f;
public int forceSpawn = 1; // 1 = PC 가 생긴 뒤 시작 지점(spawnPos)으로 한 번 더 워프
public int probeAtlas = 0; // 진단: 1 = 부트 첫머리에서 UI 아틀라스 로드 시간을 잰다(평소 0)
public int probeNoLogin = 0; // 진단: 1 = 로그인 없이 60 s 프레임 멈춤만 기록(평소 0)
public int probeProfiler = 0; // 진단: 1 = 프로파일러를 켜고 3 s 넘는 프레임의 무거운 호출 경로를 ProfileDump 에(평소 0 · probeNoLogin 과 함께)
public int dummyStill = 1; // 1 = 더미가 제자리(Stop) · 0 = 섬처럼 쫓아옴
public float[] skyColor = { 0.55f, 0.75f, 0.95f }; // 스카이박스가 없을 때 카메라 배경색(빈 값이면 손대지 않음)
public int moveKeys = 1; // WASD · 방향키 이동
public string weaponPrevKey = "LeftBracket";
public string weaponNextKey = "RightBracket";
public string dummyResetKey = "R";
public int label = 1; // 화면 왼쪽 위 상태 글자
public float labelFontScale = 0.018f;
public float groundSize = 60f; // 평지 한 변 m(빌더가 읽는다)
public float[] groundColor = { 0.45f, 0.62f, 0.35f };
}
public sealed class WLSkillTestBoot : MonoBehaviour
{
public const string SceneName = "WLSkillTest";
public const string ResourcesPath = "WL/WLSkillTest";
[Header("씬 빌더가 채운다 — 로그인 뒤에 생성하는 프리팹(InGameInfo → ProjectileInfo → PCInfo 순 · 투사체 예열이 InGameInfo.Ins 를 쓴다)")]
public GameObject inGameInfoPrefab;
public GameObject pcInfoPrefab;
public GameObject projectileInfoPrefab;
public static WLSkillTestBoot Ins;
public static string Stage = "none";
public static string LastLog = "";
public static bool Ready;
public static Actor Dummy;
public static readonly List<string> Laps = new List<string>();
/// <summary>PlayFab 호출 시각 기록(에디터 · 로딩 실측용 · 2차 Play 에서 로그인 52 s 의 내역을 보려고)</summary>
public static readonly List<string> PfLog = new List<string>();
/// <summary>0.5 s 넘게 멈춘 프레임 기록(시각@길이 · 메인 스레드가 무엇에 막혔는지 PfLog·Laps 와 맞춰 본다)</summary>
public static readonly List<string> Hangs = new List<string>();
public static float T0;
float _lastRt;
void LateUpdate()
{
float now = Time.realtimeSinceStartup;
if (_lastRt > 0f && now - _lastRt > 0.5f && Hangs.Count < 40)
{
string flags = "";
#if UNITY_EDITOR
flags = " L" + (UnityEditor.Lightmapping.isRunning ? 1 : 0) + "S" + (UnityEditor.ShaderUtil.anythingCompiling ? 1 : 0) + "C" + (UnityEditor.EditorApplication.isCompiling ? 1 : 0) + "U" + (UnityEditor.EditorApplication.isUpdating ? 1 : 0);
#endif
Hangs.Add((now - T0).ToString("F1") + "@" + (now - _lastRt).ToString("F1") + "s" + flags);
#if UNITY_EDITOR
// 멈춤 직후 그 프레임(과 직전 프레임)의 가장 무거운 호출 경로를 바로 적는다 — 프레임 버퍼가 밀려 사라지기 전에
if (Cfg.probeProfiler != 0) { int li = UnityEditorInternal.ProfilerDriver.lastFrameIndex; DumpFrame(li - 1, 1000f); DumpFrame(li, 1000f); }
#endif
}
_lastRt = now;
}
#if UNITY_EDITOR
/// <summary>진단: 프로파일러로 잡은 긴 프레임의 가장 무거운 호출 경로(멈춤의 정체)</summary>
public static string ProfileDump = "";
static void ProfilerStart() { s_prof.Length = 0; ProfileDump = ""; UnityEditorInternal.ProfilerDriver.ClearAllFrames(); UnityEditorInternal.ProfilerDriver.enabled = true; }
static readonly System.Text.StringBuilder s_prof = new System.Text.StringBuilder();
static void DumpFrame(int f, float minMs)
{
if (f < 0) return;
int col = UnityEditor.Profiling.HierarchyFrameDataView.columnTotalTime;
var kids = new List<int>();
using (var v = UnityEditorInternal.ProfilerDriver.GetHierarchyFrameDataView(f, 0, UnityEditor.Profiling.HierarchyFrameDataView.ViewModes.MergeSamplesWithTheSameName, col, false))
{
if (v == null || !v.valid || v.frameTimeMs < minMs) return;
s_prof.Append(" || frame ").Append(f).Append(' ').Append(v.frameTimeMs.ToString("F0")).Append("ms:");
int id = v.GetRootItemID();
for (int depth = 0; depth < 12; depth++)
{
kids.Clear(); v.GetItemChildren(id, kids);
if (kids.Count == 0) break;
int best = kids[0]; float bestMs = -1f, second = 0f; string secondName = "";
foreach (var k in kids) { float ms = v.GetItemColumnDataAsFloat(k, col); if (ms > bestMs) { if (bestMs > second) { second = bestMs; secondName = v.GetItemName(best); } bestMs = ms; best = k; } else if (ms > second) { second = ms; secondName = v.GetItemName(k); } }
s_prof.Append(" > ").Append(v.GetItemName(best)).Append(' ').Append(bestMs.ToString("F0"));
if (second > 1000f) s_prof.Append(" (+").Append(secondName).Append(' ').Append(second.ToString("F0")).Append(')');
id = best;
}
}
ProfileDump = s_prof.ToString();
}
static void ProfilerDump(float minMs)
{
int first = UnityEditorInternal.ProfilerDriver.firstFrameIndex, last = UnityEditorInternal.ProfilerDriver.lastFrameIndex;
s_prof.Append(" || buffer ").Append(first).Append("~").Append(last);
for (int f = first; f <= last && f >= 0; f++) DumpFrame(f, minMs);
ProfileDump = s_prof.ToString();
UnityEditorInternal.ProfilerDriver.enabled = false;
}
#endif
static System.Reflection.FieldInfo s_fAtlas;
static bool AtlasReady()
{
if (!UIAtlasMgr.isIns || UIAtlasMgr.Ins == null) return false;
if (s_fAtlas == null) s_fAtlas = typeof(UIAtlasMgr).GetField("UIAtlas", BindingFlags.NonPublic | BindingFlags.Instance);
return s_fAtlas != null && s_fAtlas.GetValue(UIAtlasMgr.Ins) != null;
}
#if UNITY_EDITOR
static void OnPf(PlayFab.Internal.ApiProcessingEventArgs e) { PfLog.Add((Time.realtimeSinceStartup - T0).ToString("F1") + " " + e.EventType + " " + e.ApiEndpoint); }
static void OnPfErr(PlayFab.SharedModels.PlayFabRequestCommon r, PlayFab.PlayFabError err) { PfLog.Add((Time.realtimeSinceStartup - T0).ToString("F1") + " ERR " + (err != null ? err.Error + " " + err.ErrorMessage : "?")); }
#endif
static WLSkillTestJson s_cfg;
bool _moving;
KeyCode _kPrev = KeyCode.LeftBracket, _kNext = KeyCode.RightBracket, _kReset = KeyCode.R;
string _weaponMsg = "";
GUIStyle _style;
public static WLSkillTestJson Cfg
{
get
{
if (s_cfg != null) return s_cfg;
s_cfg = new WLSkillTestJson();
var ta = Resources.Load<TextAsset>(ResourcesPath);
if (ta == null) { LastLog = "설정 없음 " + ResourcesPath; return s_cfg; }
try { JsonUtility.FromJsonOverwrite(ta.text, s_cfg); } catch (System.Exception ex) { LastLog = "설정 파싱 실패 — " + ex.Message; }
return s_cfg;
}
}
public static bool Active { get { return SceneManager.GetActiveScene().name == SceneName; } }
static Vector3 V3(float[] a) { return a != null && a.Length >= 3 ? new Vector3(a[0], a[1], a[2]) : Vector3.zero; }
static KeyCode Key(string s, KeyCode d) { KeyCode k; return !string.IsNullOrEmpty(s) && System.Enum.TryParse(s, true, out k) ? k : d; }
/// <summary>상태 파일(Library/ 아래 · 미추적) — 외부 스크립트는 Play 중 `eval` 대신 이 파일을 읽는다. 실측(8차 Play): Play 뒤 첫 `unity command eval/run_script` 가 EditorLoop 에서 약 35 s 메인 스레드를 멈춘다(스크립트 컴파일 준비).</summary>
public static string StatusPath { get { return System.IO.Path.GetFullPath(System.IO.Path.Combine(Application.dataPath, "../Library/WLSkillTest_status.txt")); } }
static void WriteStatus()
{
try { System.IO.File.WriteAllText(StatusPath, Describe()); } catch { }
}
static void Lap(string name)
{
Stage = name;
Laps.Add(name + " " + (Time.realtimeSinceStartup - T0).ToString("F1") + "s");
WriteStatus();
}
void Awake()
{
Ins = this;
T0 = Time.realtimeSinceStartup;
Laps.Clear(); PfLog.Clear(); Hangs.Clear(); Ready = false; Dummy = null; s_cfg = null;
#if UNITY_EDITOR
if (Cfg.probeProfiler != 0) ProfilerStart();
PlayFab.Internal.PlayFabHttp.ApiProcessingEventHandler -= OnPf; PlayFab.Internal.PlayFabHttp.ApiProcessingEventHandler += OnPf;
PlayFab.Internal.PlayFabHttp.ApiProcessingErrorEventHandler -= OnPfErr; PlayFab.Internal.PlayFabHttp.ApiProcessingErrorEventHandler += OnPfErr;
#endif
var cfg = Cfg;
_kPrev = Key(cfg.weaponPrevKey, KeyCode.LeftBracket);
_kNext = Key(cfg.weaponNextKey, KeyCode.RightBracket);
_kReset = Key(cfg.dummyResetKey, KeyCode.R);
Lap("awake");
}
IEnumerator Start()
{
var cfg = Cfg;
#if UNITY_EDITOR
// 진단(probeAtlas 1): UI 스프라이트 아틀라스(.spriteatlasv2)를 에디터 API 로 직접 읽어 걸리는 시간을 잰다 — 4차 Play 실측에서 로그인 중 메인 스레드가 16 s + 34 s 멈췄고 첫 멈춤 끝 = 아틀라스 도착 시각
if (cfg.probeAtlas != 0)
{
float a = Time.realtimeSinceStartup;
var atl = UnityEditor.AssetDatabase.LoadAssetAtPath<UnityEngine.U2D.SpriteAtlas>("Assets/Res_Addr/MainUI/UIAtlas.spriteatlasv2");
float b = Time.realtimeSinceStartup;
var sp = atl != null ? atl.GetSprite("grade1") : null;
float c = Time.realtimeSinceStartup;
Laps.Add("atlasLoad " + (b - a).ToString("F1") + "s getSprite " + (c - b).ToString("F1") + "s spr=" + (sp != null) + " n=" + (atl != null ? atl.spriteCount : -1));
}
#endif
// ⓪ 평지 NavMesh — 콜라이더 기준 런타임 베이크(WLDungeonSceneSetup 과 같은 방식 · 임포트 설정 0) + 배경색
BakeGround();
ApplySky(cfg);
Lap("navmesh");
// ⓪-2 Addressables 초기화를 **따로 재서** 보이게 한다(Use Asset Database 모드의 카탈로그 준비 · 어차피 첫 로드 때 든다)
var init = UnityEngine.AddressableAssets.Addressables.InitializeAsync();
while (!init.IsDone) yield return null;
Lap("addr-init");
// ① 테이블(Tables 프리팹 = TableChecker + 표 44개)
while (!TableChecker.isIns || TableChecker.Ins == null || !TableChecker.Ins.CheckAllLoad()) yield return null;
Lap("tables");
// 진단(probeNoLogin 1): 로그인 없이 60 s 동안 프레임 멈춤만 기록하고 멈춘다 — 멈춤이 PlayFab 때문인지 에디터(조명·셰이더) 때문인지 가른다
if (cfg.probeNoLogin != 0)
{
float w = 0f; while (w < 60f) { w += Time.unscaledDeltaTime; yield return null; }
#if UNITY_EDITOR
if (cfg.probeProfiler != 0) ProfilerDump(3000f);
#endif
Stage = "nologin-done"; LastLog = "로그인 없이 60 s 관찰"; WriteStatus(); yield break;
}
// ② 개발 로그인 — TitleInfo.OnClick_DevLogin 과 같은 순서(ID 저장 → ServerInfo.Login)
var si = ServerInfo.Ins;
string id = cfg.useOptionUserId != 0 ? PlayerPrefs.GetString(MyValue.OptionKey_UserID, cfg.loginId) : cfg.loginId;
if (string.IsNullOrEmpty(id)) id = string.IsNullOrEmpty(cfg.loginId) ? "TestID" : cfg.loginId;
PlayerPrefs.SetString(MyValue.OptionKey_UserID, id);
si.UserID = id;
bool done = false, fail = false, atlasLapped = false;
si.Login(newuser => { done = true; }, () => { fail = true; });
while (!done && !fail)
{
if (!atlasLapped && AtlasReady()) { atlasLapped = true; Lap("atlas"); Stage = "login"; } // UIAtlasMgr 의 스프라이트 아틀라스(에디터 로드)가 끝난 시각
yield return null;
}
if (!atlasLapped && AtlasReady()) Lap("atlas");
if (fail || si.m_ServerData == null) { Stage = "login-fail"; LastLog = "로그인 실패 — ID " + id; WriteStatus(); yield break; }
Lap("login " + id);
// ③ 맵 노드(LoadMapMgr 대체) + 카메라 리그 — PCInfo.Make_Actors 보다 먼저 있어야 한다(같은 프레임)
var icfg = WLIslandSettings.Instance;
int mapId = cfg.mapId > 0 ? cfg.mapId : (icfg != null ? icfg.islandMapId : 900);
MyValue.MyChoiceMapData.n_MapID = mapId;
MyValue.MyChoiceMapData.n_PortalIndex = 0;
Vector3 spawn = V3(cfg.spawnPos);
float yaw = icfg != null ? WLDungeonCameraRig.SolvedYaw(icfg) : 0f;
if (icfg != null)
{
var nodes = WLIslandMapNodes.Ensure(icfg);
if (nodes != null) nodes.SetStartPos(spawn, yaw);
if (cfg.cameraRig != 0 && Object.FindFirstObjectByType<WLDungeonCameraRig>() == null)
WLDungeonCameraRig.Create(icfg, gameObject.scene);
}
// ④ InGame 매니저 — NewGameUI.Start 의 Load_Map(mapId) 이 「같은 맵」 분기를 타도록 SceneInfo.CurStageData 를 미리 채운다
if (inGameInfoPrefab != null && InGameInfo.Ins == null) Instantiate(inGameInfoPrefab).name = "InGameInfo";
if (projectileInfoPrefab != null && !ProjectileInfo.isIns) Instantiate(projectileInfoPrefab).name = "ProjectileInfo"; // 🔴 InGameInfo 뒤 — 예열 투사체 OnEnable → Off → Show_HitEffect 가 InGameInfo.Ins 를 쓴다(1차 Play 실측 NRE 127건)
if (pcInfoPrefab != null && !PCInfo.isIns) Instantiate(pcInfoPrefab).name = "PCInfo";
var row = table_battlemapconfig.Ins.Get_Data(mapId);
var sinfo = InGameInfo.Ins != null ? InGameInfo.Ins.m_SceneInfo : null;
if (row == null) { Stage = "map-row-none"; LastLog = "BattleMapConfig 행 없음 " + mapId; WriteStatus(); yield break; }
if (sinfo == null) { Stage = "sceneinfo-none"; LastLog = "InGameInfo.m_SceneInfo 없음"; WriteStatus(); yield break; }
var f = typeof(SceneInfo).GetField("CurStageData", BindingFlags.NonPublic | BindingFlags.Instance);
if (f == null) { Stage = "reflect-fail"; LastLog = "SceneInfo.CurStageData 필드 없음"; WriteStatus(); yield break; }
f.SetValue(sinfo, row);
Lap("ingame");
// ⑤ PC — NewGameUI(HUD) 로드 → Load_Map → Make_Actors(Addressables) → PCActor.Set → MyValue.MyPC
float t = 0f;
while (t < cfg.waitPcSeconds && !(MyValue.bMyPC && MyValue.MyPC != null)) { t += Time.unscaledDeltaTime; yield return null; }
if (!(MyValue.bMyPC && MyValue.MyPC != null)) { Stage = "pc-timeout"; LastLog = "PC 생성 대기 초과 " + cfg.waitPcSeconds + "s · NewGameUI " + NewGameUI.isIns; WriteStatus(); yield break; }
Lap("pc");
yield return null;
// 시작 지점 보정 — 1차 Play 실측: PC 가 평지 모서리(28, -29)에 서 있었다(원인 미확정) → NavMesh 위 시작 지점으로 한 번 더 워프
if (cfg.forceSpawn != 0) { var pcA = MyValue.MyPC; pcA.Stop(true); pcA.Set_Warp(NavPos(spawn)); }
if (InGameInfo.Ins != null) InGameInfo.Ins.StartCam();
t = 0f;
while (t < 10f && !NewGameUI.isIns) { t += Time.unscaledDeltaTime; yield return null; }
if (NewGameUI.isIns && NewGameUI.Ins != null) NewGameUI.Ins.SetUI_byGameMode(eGameMode.Lobby); // 로딩 화면 닫기 + 섬과 같은 로비 UI
Lap("ui");
// ⑥ 무적 더미
yield return StartCoroutine(Co_SpawnDummies(cfg));
Ready = true;
LastLog = "";
Lap("ready");
}
void OnDestroy() { try { if (System.IO.File.Exists(StatusPath)) System.IO.File.Delete(StatusPath); } catch { } }
// ─────────────────────────────────────────────────────────────────
static Vector3 NavPos(Vector3 want)
{
UnityEngine.AI.NavMeshHit hit;
return UnityEngine.AI.NavMesh.SamplePosition(want, out hit, 5f, UnityEngine.AI.NavMesh.AllAreas) ? hit.position : want;
}
static void ApplySky(WLSkillTestJson cfg)
{
var cam = Camera.main;
if (cam == null || cfg.skyColor == null || cfg.skyColor.Length < 3) return;
if (RenderSettings.skybox == null) { cam.clearFlags = CameraClearFlags.SolidColor; cam.backgroundColor = new Color(cfg.skyColor[0], cfg.skyColor[1], cfg.skyColor[2]); }
}
void BakeGround()
{
var surf = Object.FindFirstObjectByType<NavMeshSurface>();
if (surf == null)
{
var ground = GameObject.Find("Ground");
if (ground == null) return;
surf = ground.AddComponent<NavMeshSurface>();
}
surf.useGeometry = UnityEngine.AI.NavMeshCollectGeometry.PhysicsColliders;
surf.BuildNavMesh();
}
IEnumerator Co_SpawnDummies(WLSkillTestJson cfg)
{
var ms = WLIslandMobSettings.Instance;
int mobId = cfg.dummyMobId > 0 ? cfg.dummyMobId : (ms != null ? ms.hitTestDummyMobId : 101);
var data = table_monsterlist.Ins.Get_Data_orNull(mobId);
if (data == null) { LastLog = "몬스터 표 없음 " + mobId; yield break; }
Vector3 off = V3(cfg.dummyOffset);
if (off == Vector3.zero && ms != null) off = ms.hitTestDummyOffset;
var pc = MyValue.MyPC;
int want = Mathf.Max(1, cfg.dummyCount), made = 0;
for (int i = 0; i < want; i++)
{
bool got = false; int idx = i;
AddrResourceMgr.Ins.LoadObject<GameObject>(data.s_MonsterPrefab, h =>
{
if (h.Status == AsyncOperationStatus.Succeeded && h.Result != null)
{
var mob = DSUtil.Get_Clone<MobActor>(h.Result);
AddrResourceMgr.Ins.Set_AddressableReleaseSelf(mob.gameObject);
ActorInfo.Ins.Add_Actor(mob, eRole.Mob, cfg.dummySpawnerId, data.s_MonsterPrefab);
mob.Set(true, null, null, cfg.dummySpawnerId, false);
Vector3 pos = DummyPos(pc, off, idx, cfg.dummyGap);
mob.Set(data, pos, null, 0);
if (cfg.dummyStill != 0) mob.Stop(true); // 제자리 더미(쫓아오지 않음) — 0 이면 섬처럼 움직인다
if (idx == 0) { WLTrailTest.Adopt(mob); Dummy = mob; }
var keep = mob.GetComponent<WLTrailTestDummy>();
if (keep == null) mob.gameObject.AddComponent<WLTrailTestDummy>().Begin(mob); // 2마리째부터도 체력 되채움
made++;
}
got = true;
});
while (!got) yield return null;
}
Lap("dummy " + made + "(" + mobId + ")");
}
static Vector3 DummyPos(Actor pc, Vector3 off, int idx, float gap)
{
Vector3 basePos = pc != null ? pc.Get_position() : Vector3.zero;
Quaternion rot = pc != null ? Quaternion.Euler(0f, pc.transform.eulerAngles.y, 0f) : Quaternion.identity;
Vector3 side = rot * Vector3.right;
Vector3 want = basePos + rot * off + side * (gap * ((idx + 1) / 2) * (idx % 2 == 0 ? 1f : -1f)) * (idx == 0 ? 0f : 1f);
UnityEngine.AI.NavMeshHit hit;
if (UnityEngine.AI.NavMesh.SamplePosition(want, out hit, 3f, UnityEngine.AI.NavMesh.AllAreas)) want = hit.position;
return want;
}
/// <summary>더미를 PC 앞 기본 자리로 되돌린다(R 키 · 프로브).</summary>
public static string ResetDummy()
{
var pc = MyValue.MyPC; var d = Dummy;
if (pc == null || d == null) return "PC/더미 없음";
var ms = WLIslandMobSettings.Instance; var cfg = Cfg;
Vector3 off = V3(cfg.dummyOffset); if (off == Vector3.zero && ms != null) off = ms.hitTestDummyOffset;
var pos = DummyPos(pc, off, 0, cfg.dummyGap);
d.Stop(true); d.Set_Warp(pos);
return "더미 " + pos.ToString("F1");
}
/// <summary>낀 무기를 인벤토리 무기 목록에서 앞뒤로 바꾼다(dir ±1 · 결과 한 줄).</summary>
public static string CycleWeapon(int dir)
{
if (!WLInventory.EnsureLoaded()) return "인벤토리 미로드";
var cur = WLInventory.EquippedInSlot(eFREquipmentType.Weapon);
var list = new List<int>(); var names = new List<string>(); int curIdx = -1;
foreach (var it in WLInventory.SortedItems())
{
if (it == null || it.itemJson == null || !it.itemJson.IsEquipment || it.itemJson.EquipmentType != eFREquipmentType.Weapon) continue;
if (cur != null && ReferenceEquals(it, cur)) curIdx = list.Count;
list.Add(it.rawItem._id); names.Add(it.itemJson.Item_name + "(" + it.itemJson.GroupNo + ")");
}
if (list.Count == 0) return "무기 없음";
int next = curIdx < 0 ? 0 : (curIdx + dir + list.Count) % list.Count;
bool ok = WLInventory.Equip(list[next]);
return (ok ? "장착 " : "실패 ") + names[next] + " " + (next + 1) + "/" + list.Count + (ok ? "" : " · " + WLInventory.LastMessage);
}
/// <summary>보고·프로브용 한 줄.</summary>
public static string Describe()
{
var pc = MyValue.MyPC;
string w = "-";
if (WLInventory.Loaded) { var cur = WLInventory.EquippedInSlot(eFREquipmentType.Weapon); if (cur != null && cur.itemJson != null) w = cur.itemJson.Item_name + "(" + cur.itemJson.GroupNo + ")"; }
return "stage=" + Stage + " ready=" + Ready + " pc=" + (pc != null ? pc.name + pc.Get_position().ToString("F1") : "-") + " weapon=" + w +
" dummy=" + (Dummy != null ? Dummy.name + Dummy.Get_position().ToString("F1") + " hp " + Dummy.Get_HP().ToString("F0") : "-") +
" laps=[" + string.Join(" · ", Laps.ToArray()) + "] pf=[" + string.Join(" · ", PfLog.ToArray()) + "] hangs=[" + string.Join(" ", Hangs.ToArray()) + "] " + LastLog
#if UNITY_EDITOR
+ (string.IsNullOrEmpty(ProfileDump) ? "" : " profile=[" + ProfileDump + "]")
#endif
;
}
// ─────────────────────────────────────────────────────────────────
void Update()
{
if (!Ready) return;
var pc = MyValue.MyPC as PCActor;
if (pc == null) return;
var cfg = Cfg;
if (cfg.moveKeys != 0)
{
float h = (Input.GetKey(KeyCode.D) || Input.GetKey(KeyCode.RightArrow) ? 1f : 0f) - (Input.GetKey(KeyCode.A) || Input.GetKey(KeyCode.LeftArrow) ? 1f : 0f);
float v = (Input.GetKey(KeyCode.W) || Input.GetKey(KeyCode.UpArrow) ? 1f : 0f) - (Input.GetKey(KeyCode.S) || Input.GetKey(KeyCode.DownArrow) ? 1f : 0f);
bool any = h != 0f || v != 0f;
if (any)
{
if (!_moving) { pc.Set_AutoBraking(false); pc.Set_JoystickStatus(1); _moving = true; }
var cam = Camera.main;
Vector3 fwd = cam != null ? Vector3.ProjectOnPlane(cam.transform.forward, Vector3.up) : Vector3.forward;
if (fwd.sqrMagnitude < 1e-4f) fwd = Vector3.forward;
fwd.Normalize();
Vector3 right = Vector3.Cross(Vector3.up, fwd);
pc.Run_byJoystick((fwd * v + right * h).normalized);
}
else if (_moving) { pc.Set_JoystickStatus(0); pc.Set_AutoBraking(true); _moving = false; }
}
if (Input.GetKeyDown(_kPrev)) _weaponMsg = CycleWeapon(-1);
if (Input.GetKeyDown(_kNext)) _weaponMsg = CycleWeapon(+1);
if (Input.GetKeyDown(_kReset)) _weaponMsg = ResetDummy();
}
void OnGUI()
{
if (Cfg.label == 0) return;
if (_style == null) { _style = new GUIStyle(GUI.skin.label); _style.alignment = TextAnchor.UpperLeft; _style.wordWrap = true; }
_style.fontSize = Mathf.Max(10, Mathf.RoundToInt(Screen.height * Cfg.labelFontScale));
_style.normal.textColor = Ready ? Color.white : new Color(1f, 0.9f, 0.5f);
string w = "-";
if (WLInventory.Loaded) { var cur = WLInventory.EquippedInSlot(eFREquipmentType.Weapon); if (cur != null && cur.itemJson != null) w = cur.itemJson.Item_name + "(" + cur.itemJson.GroupNo + ")"; }
string s = "WL 스킬 테스트 · " + Stage + (Ready ? "" : " …") +
"\n무기 " + w + " 더미 " + (Dummy != null ? Dummy.Get_HP().ToString("F0") : "-") +
"\nWASD 이동 · [ ] 무기 · R 더미 · Q 주력 · E 각성 · 숫자 등급" +
(string.IsNullOrEmpty(_weaponMsg) ? "" : "\n" + _weaponMsg) +
(Ready ? "\n" + string.Join(" · ", Laps.ToArray()) : "");
var r = new Rect(Screen.width * 0.01f, Screen.height * 0.01f, Screen.width * 0.6f, Screen.height * 0.3f);
GUI.color = new Color(0f, 0f, 0f, 0.6f); GUI.Label(new Rect(r.x + 1, r.y + 1, r.width, r.height), s, _style);
GUI.color = Color.white; GUI.Label(r, s, _style);
}
}
}