Project_WL/Assets/WL/SkillTest/WLSkillTestHarness.cs

559 lines
41 KiB
C#
Raw 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.

// ─────────────────────────────────────────────────────────────────────────────
// WLSkillTestHarness.cs — 독립 스킬 테스트 씬(WLSkillTest) · 캐릭터 + 스킬 시스템만 따로 세운 하네스 (PD #947 재지시 · 2026-10-20)
//
// PD 원문: 「이 프로젝트를 기반으로 만들라는 의미가 아니라, 스킬 애니메이션 및 연출을 위해 캐릭터 및 스킬 시스템만 별도로 구축해서
// 테스트 목적으로 쓸 씬을 구축해달라는 뜻」
//
// ■ 방식 = 원본 개발자의 StatTest 하네스(MainToStatTest)와 같은 줄기: **로그인 0 · HUD(NewGameUI) 0 · 섬·로비 0 · Addressables 부트 0**.
// 씬에 직접 둔 것만으로 돈다 — 테이블 프리팹 · ActorInfo · InGameInfo(이펙트·투사체 풀) · ProjectileInfo · 원본 Main Camera · 치비 PC 프리팹 · 더미 몹 프리팹 · 평지.
// 순서: 평지 NavMesh 베이크 → 테이블 로딩 대기 → 오프라인 ServerData(신규 유저 꼴 · 자동 전투 1) → PC.Set(직업 = 낀 WL 무기) → 더미 Set(무적 · 제자리) → 카메라 리그 → ready.
// ■ 원본 코드와의 접점 = StatTest 전용 분기 13곳을 `WLTestScene.Active`(StatTest 또는 WLSkillTest)로 넓힌 것뿐(HUD·포털·서버 일시정지·대상 목록 분기 · 전부 #if UNITY_EDITOR 안).
// 이동·넉백·소환 AI 분기(Actor 1665·1710·2198 · MobActor 75 · SummonActor 60 · WizardActor 53·61)는 StatTest 전용으로 남겨 **실제 게임 동작**을 유지한다.
// ■ 이 클래스가 MainToStatTest 를 상속하는 이유 = 원본 분기가 `MainToStatTest.Ins.actors[1]`(대상)·`Set_Dmglabel`(피해 글자)를 부른다 → 같은 자리를 채워 준다.
// ■ 조작: **원본 가상패드**(NewGameUI 의 Floating Joystick 중첩 프리팹을 빌더가 같은 값으로 복제 · 화면 왼쪽 드래그 · PD 2026-10-20 #947 「원본과 동일한 가상패드 + 키 입력」)
// + WASD/방향키 이동(같은 호출 · Set_AutoBraking(false) → JoystickStatus 1 → Run_byJoystick) · Space 기본 공격 1타 · Q 주력 · E 각성 · 숫자 = 그 번호 무기(#958 · 등급 치트 대체)
// · #958 모든 무기 각성 상태(해금 등급 무시) · 자동 스킬 끔(Q · E 치트키로만 스킬 · 그 밖엔 기본 공격만) — WLTestScene.AllAwakened · ManualSkills · NumberKeysWeapon
// · O 무기 이전(왼쪽 순환) · P 무기 다음(오른쪽 순환) — PD 2026-10-20 #947 지시 · 종류(GroupNo)마다 1개 · R 더미 제자리 · T 슬로 모션 · Pause = 설정값(기본 Backslash)
// ■ 값 = Resources/WL/WLSkillTest.json (C45) · 상태 파일 = Library/WLSkillTest_status.txt(외부 스크립트는 Play 중 eval 대신 이 파일) · 🔴 Debug.LogError 금지(원본 ErrorLogHook 팝업)
// ─────────────────────────────────────────────────────────────────────────────
using System.Collections;
using System.Collections.Generic;
using Unity.AI.Navigation;
using UnityEngine;
using UnityEngine.SceneManagement;
using WL.Combat.Weapon;
using WL.Equip;
using WL.Island;
using WL.IslandMob;
namespace WL.SkillTest
{
/// <summary>원본 코드의 StatTest 전용 분기가 이 씬에서도 서도록 넓힌 판정(에디터 전용 분기 안에서만 쓰인다).</summary>
public static class WLTestScene
{
public const string Name = "WLSkillTest";
public static bool Active { get { var n = SceneManager.GetActiveScene().name; return n == Name || n == "StatTest"; } }
/// <summary>테스트 씬에서 원본 InGameInfo.Start 의 이펙트·스킬 예열(인스턴스 514 · 한 프레임 18 s · #947 실측)을 돌릴지 — JSON preloadEffects(기본 0 = 생략 · 첫 사용 때 로드).</summary>
public static bool PreloadEffects { get { return WLSkillTestHarness.Cfg.preloadEffects != 0; } }
// #958 PD 「모든 무기는 각성 상태로 동작 · 테스트 씬에선 치트키 Q(주력) · E(각성)로 내가 쓰는 경우가 아니면 기본 공격만 · 숫자 키 = 그 번호 무기로 즉시 교체(등급 변경 대체)」
// — 하네스 Awake 가 JSON 값으로 켜고 Play 시작마다 끈다(도메인 리로드 꺼짐 · 다른 씬에 남지 않게).
/// <summary>무기 기술 해금 등급 무시(주력 · 각성 둘 다 열림) — WLWeaponSkillSlots · WLSkillChain · WLPartSkillState 가 본다.</summary>
public static bool AllAwakened;
/// <summary>자동 스킬 시전 끔 — 원본 PCActor 자동 시전 · WL_TryAutoSkillNow(우선 시전 · 대쉬 도착 시전) · 파츠 스킬 자동 시전. Q/E 치트키(WLSkillHotkey)만 스킬을 쓴다.</summary>
public static bool ManualSkills;
/// <summary>숫자 키 = 무기 번호(등급 치트 WLWeaponGradeCheat 대신).</summary>
public static bool NumberKeysWeapon;
[RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.SubsystemRegistration)]
static void ResetFlags() { AllAwakened = ManualSkills = NumberKeysWeapon = false; }
}
[System.Serializable]
public class WLSkillTestJson
{
public string note = "";
public int baseClassId = 10101; // 낀 WL 무기가 없을 때 PC 직업(묵광 검+방패)
public int autoBattle = 1; // 1 = 섬처럼 자동 전투(대상 있으면 기본 공격 콤보)
public float[] spawnPos = { 0f, 0f, 0f };
public int cameraRig = 1; // 1 = 섬 구도 카메라 리그(WLDungeonCameraRig)
public int dummyMobId = 0; // 0 = WLIslandMobSettings.hitTestDummyMobId (몬스터 표 행 · 능력치)
public float[] dummyOffset = { 0f, 0f, 0f }; // 전부 0 = WLIslandMobSettings.hitTestDummyOffset
public int dummyStill = 1; // 1 = 더미 제자리
public int preloadEffects = 0; // 1 = 원본 InGameInfo 이펙트·스킬 예열(514 인스턴스 · 약 18 s) · 0 = 생략(첫 사용 때 로드)
public string gameMode = "Lobby"; // InGameInfo 게임 모드 — 섬과 같은 Lobby(자동 전투·대상 찾기가 로비 규칙). Stage 로 두면 PC 가 대상 없이 달린다(#948 실측). 로비 컨트롤러(pcanim_lobby) 문제는 Set_Obj 동안만 Stage 로 바꾸는 전투 형태 적용(섬 WLIslandAvatar.ApplyCombatForm 과 같은 꼴)으로 푼다
public float combatFormCheckSeconds = 0.5f; // 이 간격으로 PC 가 로비 컨트롤러면 전투 형태 다시 적용 · 대상이 없으면 더미를 다시 겨눔
public float autoResetDummySeconds = 2.0f; // 더미가 공격 사거리 밖(넉백 누적)에 이만큼(초) 머물면 PC 앞 기본 자리로(R 키와 같음) → 콤보가 끊기지 않고 반복된다(#948 · 0 = 끔)
public int moveKeys = 1; // 1 = WASD/방향키도 이동(가상패드와 함께 · PD #947 「가상패드 + 키 입력」)
public string attackKey = "Space";
public string weaponPrevKey = "O"; // PD #947: O = 무기 왼쪽 순환
public string weaponNextKey = "P"; // PD #947: P = 무기 오른쪽 순환
public string dummyResetKey = "R";
public string slowKey = "T";
public float slowScale = 0.2f;
public string pauseKey = "Backslash"; // P 를 무기에 내주고 일시정지는 \ 로
public int numberKeysWeapon = 1; // #958 1 = 숫자 키 1~9 · 0(=10) 이 그 번호 무기로 즉시 교체(번호 = O/P 순환과 같은 종류 순 · 화면 왼쪽 위 목록) · 등급 치트는 이 씬에서 끔
public int allAwakened = 1; // #958 1 = 모든 무기 각성 상태(등급과 무관하게 주력 · 각성 해금)
public int manualSkills = 1; // #958 1 = 자동 스킬 시전 끔(Q · E 치트키로만 스킬 · 그 밖엔 기본 공격만)
public string[] redirectScenes = new string[0]; // #960 이 씬을 연 채 Play 하면 스킬 테스트 씬으로 시작(WLSkillTestPlayRedirect · 원본 맵 조각 TestMap 등)
public int label = 1;
public float labelFontScale = 0.018f;
public float groundSize = 100f;
public float[] groundColor = { 0.45f, 0.62f, 0.35f };
public float[] skyColor = { 0.55f, 0.75f, 0.95f };
}
public sealed class WLSkillTestHarness : MainToStatTest
{
public const string ResourcesPath = "WL/WLSkillTest";
[Header("씬 빌더가 채운다")]
public Actor pcActor; // 치비 PC 프리팹 인스턴스(씬에 직접)
public MobActor dummyActor; // 더미 몹 프리팹 인스턴스(씬에 직접)
public static string Stage = "none", LastLog = "";
public static bool Ready;
public static Actor Dummy;
public static readonly List<string> Laps = new List<string>();
public static readonly List<string> Hangs = new List<string>();
public static float T0;
public static string MoveLog = "";
static WLSkillTestJson s_cfg;
static System.Reflection.FieldInfo s_fEffects, s_fMagic, s_fMagicArr;
float _lastRt, _simH, _simV, _formTimer, _outOfRange;
bool _moving, _slow;
KeyCode _kAttack = KeyCode.Space, _kPrev = KeyCode.O, _kNext = KeyCode.P, _kReset = KeyCode.R, _kSlow = KeyCode.T, _kPause = KeyCode.Backslash;
static readonly KeyCode[] s_numKeys = { KeyCode.Alpha1, KeyCode.Alpha2, KeyCode.Alpha3, KeyCode.Alpha4, KeyCode.Alpha5, KeyCode.Alpha6, KeyCode.Alpha7, KeyCode.Alpha8, KeyCode.Alpha9, KeyCode.Alpha0 };
static readonly KeyCode[] s_padKeys = { KeyCode.Keypad1, KeyCode.Keypad2, KeyCode.Keypad3, KeyCode.Keypad4, KeyCode.Keypad5, KeyCode.Keypad6, KeyCode.Keypad7, KeyCode.Keypad8, KeyCode.Keypad9, KeyCode.Keypad0 };
string _msg = "", _weaponLine = "";
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 string StatusPath { get { return System.IO.Path.GetFullPath(System.IO.Path.Combine(Application.dataPath, "../Library/WLSkillTest_status.txt")); } }
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; }
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(); }
// MainToStatTest.Awake 는 go_tables 를 복제한다 — 여기선 테이블이 씬에 직접 있으므로 싱글턴 자리만 잡는다
protected override void Awake()
{
Ins = this; isIns = true;
T0 = Time.realtimeSinceStartup;
Laps.Clear(); Hangs.Clear(); Ready = false; Dummy = null; s_cfg = null; LastLog = "";
var cfg = Cfg;
_kAttack = Key(cfg.attackKey, KeyCode.Space); _kPrev = Key(cfg.weaponPrevKey, KeyCode.O); _kNext = Key(cfg.weaponNextKey, KeyCode.P);
_kReset = Key(cfg.dummyResetKey, KeyCode.R); _kSlow = Key(cfg.slowKey, KeyCode.T); _kPause = Key(cfg.pauseKey, KeyCode.Backslash);
MoveLog = ""; _simH = _simV = 0f;
WLTestScene.AllAwakened = cfg.allAwakened != 0; WLTestScene.ManualSkills = cfg.manualSkills != 0; WLTestScene.NumberKeysWeapon = cfg.numberKeysWeapon != 0; // #958
if (actors == null || actors.Length < 2) actors = new Actor[] { pcActor, dummyActor };
Lap("awake");
}
// 단계마다 try — 예외는 상태 파일(stage=<단계>-fail · 예외 종류 · 메시지 · 호출 위치 8줄)에 남긴다. 코루틴이 조용히 죽으면 콘솔 스팸에 묻혀 찾기 어려웠다(#947 1차 Play · PC.Set NRE 가 Freeze 1000건 아래)
IEnumerator Start()
{
var cfg = Cfg;
if (!Try("navmesh", () => { BakeGround(); ApplySky(cfg); })) yield break;
Lap("navmesh");
while (TableChecker.Ins == null || !TableChecker.Ins.CheckAllLoad()) yield return null;
Lap("tables");
// 예열을 켠 경우만: InGameInfo.Start 의 이펙트 예열 루프(테이블 0.1 s 폴링 뒤 1프레임)가 끝난 다음에 PC 를 만든다(먼저 만들면 PC 피격 이펙트 키가 먼저 등록돼 그 루프의 Add 가 중복 · #947 2차 Play 실측 · 원본에 가드도 넣음)
if (cfg.preloadEffects != 0) for (float t = 0f; t < 3f && !EffectsPreloaded(); t += Time.unscaledDeltaTime) yield return null;
if (!Try("gamemode", () => SetGameMode(cfg.gameMode))) yield break;
Lap("ingameinfo");
if (pcActor == null || dummyActor == null) { Fail("refs-missing", "pcActor/dummyActor 참조 없음 — Build Scene 다시"); yield break; }
// PC — 직업 = 낀 WL 무기(없으면 baseClassId). 원본 Set 안에서 WL 무기 적용기·스타터 킷이 돈다(섬과 같은 경로)
int cls = 0;
if (!Try("pc", () =>
{
// 오프라인 서버 데이터(신규 유저 꼴 · 테이블 로딩 뒤에만 생성 가능) + 자동 전투
var sdata = new ServerData();
try { if (cfg.autoBattle != 0) sdata.Add_Common(eCommon.Auto, 1); } catch (System.Exception ex) { LastLog = "Auto 설정 실패 " + ex.Message; }
cls = ResolveClass(cfg);
pcActor.transform.position = NavPos(V3(cfg.spawnPos));
pcActor.gameObject.SetActive(true); // 빌더가 꺼 둔 것을 Set 직전에 켠다(원본 Set 도 켜지만 그 전에 콜라이더 bounds 를 읽는다)
WithStageMode(() => pcActor.Set(false, null, sdata, cls, true)); // Set 안의 Set_Obj 가 Lobby 면 로비 컨트롤러를 입힌다(#948 실측: 지팡이가 pcanim_lobby 로 서서 투사체 0) → 섬처럼 그 동안만 Stage
FillMagicSlots(pcActor, sdata);
ActorInfo.Ins.Add_Actor(pcActor, eRole.PC, 0, pcActor.name);
if (cfg.allAwakened != 0) { try { WL.PartSkill.WLPartSkillState.Rebuild("skilltest958"); } catch (System.Exception ex) { LastLog = "파츠 스킬 다시 짜기 실패 " + ex.Message; } } // #958 각성 해금을 파츠 스킬 칸(해머 대지 분쇄)에도 바로
})) yield break;
Lap("pc " + cls);
// 더미 — 몬스터 표 행으로 능력치 · 무적(WLTrailTest 체력 되채움) · 제자리
int mobId = 0;
if (!Try("dummy", () =>
{
var ms = WLIslandMobSettings.Instance;
mobId = cfg.dummyMobId > 0 ? cfg.dummyMobId : (ms != null ? ms.hitTestDummyMobId : 101);
var data = table_monsterlist.Ins.Get_Data_orNull(mobId);
if (data == null) throw new System.Exception("몬스터 표 없음 " + mobId);
dummyActor.gameObject.SetActive(true); // 빌더가 꺼 둔 것(Set 전 Update 의 IsCC 예외 방지) — Set 이 자식 SkinnedMeshRenderer bounds 를 읽으므로 먼저 켠다
ActorInfo.Ins.Add_Actor(dummyActor, eRole.Mob, 1, dummyActor.name);
dummyActor.Set(true, null, null, 1, false);
dummyActor.Set(data, DummyPos(cfg, ms), null, 0);
if (cfg.dummyStill != 0) dummyActor.Stop(true);
WLTrailTest.Adopt(dummyActor); Dummy = dummyActor;
if (dummyActor.GetComponent<WLTrailTestDummy>() == null) dummyActor.gameObject.AddComponent<WLTrailTestDummy>().Begin(dummyActor);
})) yield break;
Lap("dummy " + mobId);
// 카메라 — 섬 구도 리그 + 원본 RealCamera 대상
if (!Try("camera", () =>
{
var icfg = WLIslandSettings.Instance;
if (icfg != null && cfg.cameraRig != 0 && Object.FindFirstObjectByType<WLDungeonCameraRig>() == null) WLDungeonCameraRig.Create(icfg, gameObject.scene);
var rc = MyValue.m_RealCamera;
if (rc != null) rc.Set_Target(pcActor.transform, icfg != null ? WLDungeonCameraRig.SolvedYaw(icfg) : 0f);
})) yield break;
yield return null;
if (!Try("ready", () => { pcActor.Stop(false); pcActor.Change_Target(false, dummyActor); })) yield break;
Ready = true;
Lap("ready");
}
bool Try(string stage, System.Action step)
{
try { step(); return true; }
catch (System.Exception ex) { Fail(stage + "-fail", ex.GetType().Name + ": " + ex.Message + " @ " + Where(ex)); return false; }
}
/// <summary>예외 호출 위치 8줄 — 인자 목록·IL 오프셋·절대 경로를 지워 「at 클래스.메서드 파일.cs:줄」 만 남긴다(#957: 4줄이면 Unity 내부 3줄에 묻혀 호출자가 안 보였다).</summary>
static string Where(System.Exception ex)
{
var lines = (ex.StackTrace ?? "").Split('\n'); var sb = new System.Text.StringBuilder();
for (int i = 0; i < lines.Length && i < 8; i++)
sb.Append(System.Text.RegularExpressions.Regex.Replace(lines[i].Trim(), @"\s*\(.*?\)\s*\[0x[0-9a-fA-F]+\]\s*in\s*.*[\\/]", " ")).Append(" | ");
return sb.ToString();
}
void Fail(string stage, string msg) { Stage = stage; LastLog = msg; WriteStatus(); Debug.LogWarning("[WLSkillTest] " + stage + " — " + msg); } // LogError 금지(원본 ErrorLogHook 팝업)
static System.Reflection.FieldInfo s_fMode;
public static int CombatForms;
static System.Reflection.FieldInfo ModeField()
{
if (s_fMode == null) s_fMode = typeof(InGameInfo).GetField("m_GameMode", System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Public);
return s_fMode;
}
/// <summary>InGameInfo 의 비공개 m_GameMode 를 JSON 값(기본 Lobby = 섬과 같음)으로.</summary>
static void SetGameMode(string mode)
{
if (string.IsNullOrEmpty(mode)) return;
eGameMode m; if (!System.Enum.TryParse(mode, true, out m)) { LastLog = "게임 모드 이름 오류 " + mode; return; }
var ig = InGameInfo.Ins; var f = ModeField();
if (ig == null || f == null) { LastLog = "InGameInfo/m_GameMode 없음"; return; }
f.SetValue(ig, m);
}
/// <summary>act 를 Stage 모드로 돌린다(Lobby 였으면 끝나고 되돌림) — 원본 PCActor.Set_Obj 가 Lobby 면 pcanim_lobby 를 입히는 것을 피한다(섬 WLIslandAvatar.ApplyCombatForm · WLWeaponClasses.RefreshModelIfNeeded 와 같은 꼴).</summary>
static void WithStageMode(System.Action act)
{
var ig = InGameInfo.Ins; var f = ModeField();
object prev = ig != null && f != null ? f.GetValue(ig) : null; bool flipped = false;
try
{
if (f != null && prev is eGameMode && (eGameMode)prev == eGameMode.Lobby) { f.SetValue(ig, eGameMode.Stage); flipped = true; }
act();
}
finally { if (flipped) f.SetValue(ig, prev); }
}
static bool PcInLobbyForm(PCActor pc)
{
var an = pc != null ? pc.GetComponentInChildren<Animator>() : null;
var c = an != null ? an.runtimeAnimatorController : null;
return c != null && c.name.IndexOf("lobby", System.StringComparison.OrdinalIgnoreCase) >= 0;
}
/// <summary>무기 교체(O/P · 숫자 키 등급) 뒤 원본 Set_Obj 가 로비 컨트롤러를 다시 입혔으면 전투 형태로 되돌리고, 대상을 잃었으면 더미를 다시 겨눈다(0.5 s 마다 · JSON combatFormCheckSeconds).</summary>
void CheckCombatForm(PCActor pc)
{
if (PcInLobbyForm(pc)) { WithStageMode(() => pc.Set_Obj()); CombatForms++; _msg = "전투 형태 다시 적용 " + CombatForms; }
if (Dummy != null && pc.Get_Target() == null && !_moving) pc.Change_Target(false, Dummy);
// 더미가 사거리 밖에 머물면(넉백 누적) 자동 R — 콤보 반복 관찰용
float auto = Cfg.autoResetDummySeconds;
if (auto > 0f && Dummy != null)
{
var d = Dummy.Get_position() - pc.Get_position(); d.y = 0f;
if (d.magnitude > pc.WL_AttackRange + 0.3f) { _outOfRange += Mathf.Max(0.1f, Cfg.combatFormCheckSeconds); if (_outOfRange >= auto) { _outOfRange = 0f; _msg = "더미 사거리 밖 " + auto + " s → " + ResetDummy(); } }
else _outOfRange = 0f;
}
}
/// <summary>InGameInfo.Start 의 예열 루프가 돌았는가 — 비공개 dic_str_Effect 의 키 수 ≥ 이펙트 표(목록 1) 행 수.</summary>
static bool EffectsPreloaded()
{
var ig = InGameInfo.Ins; if (ig == null) return false;
if (s_fEffects == null) s_fEffects = typeof(InGameInfo).GetField("dic_str_Effect", System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Public);
var d = s_fEffects != null ? s_fEffects.GetValue(ig) as System.Collections.IDictionary : null;
if (d == null) return true; // 필드가 바뀌었으면 기다리지 않는다
var lst = table_effectlist.Ins != null ? table_effectlist.Ins.Get_DataList(1) : null;
return lst == null || d.Count >= lst.Count;
}
/// <summary>원본 PCActor.Set 은 HUD 스킬 카드(NewGameUI)가 있을 때만 dic_MagicData 를 채운다 → HUD 없는 이 씬에선 Use_Skill 의 dic_MagicData[index] 가 KeyNotFound(#947 2차 Play · Q/E 발동 실패). 같은 꼴로 스킬 칸 수만큼 채운다.</summary>
static void FillMagicSlots(Actor pc, ServerData sdata)
{
var bf = System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Public;
if (s_fMagic == null) s_fMagic = typeof(Actor).GetField("dic_MagicData", bf);
if (s_fMagicArr == null) s_fMagicArr = typeof(PCActor).GetField("arr_magicData", bf);
var dic = s_fMagic != null ? s_fMagic.GetValue(pc) as Dictionary<int, UseMagicInfo> : null;
if (dic == null) { LastLog = "dic_MagicData 없음"; return; }
var arr = s_fMagicArr != null ? s_fMagicArr.GetValue(pc) as System.Array : null;
int n = arr != null ? arr.Length : 6;
for (int i = 0; i < n; i++)
if (!dic.ContainsKey(i)) dic.Add(i, new UseMagicInfo { m_uiIndex = i, m_SkillTableData = table_skilllist.Ins.Get_Data_orNull(sdata.Get_EquipSkillID(i)) });
}
/// <summary>검증용 — 이동 키를 seconds 동안 누른 것과 같은 호출(카메라 기준 h · v · Update 의 같은 경로)을 돌리고 이동 거리를 MoveLog 에 남긴다.</summary>
public static string MoveFor(float seconds, float h, float v)
{
var ins = Ins as WLSkillTestHarness; if (ins == null) return "하네스 없음";
if (!Ready || MyValue.MyPC == null) return "ready 아님";
MoveLog = "이동 중"; ins.StartCoroutine(ins.CoMove(seconds, h, v));
return "이동 시작 " + seconds + " s (h " + h + " · v " + v + ")";
}
/// <summary>검증용 — 원본 가상패드(FloatingJoystick)를 화면 왼쪽 아래에서 누르고 (dx, dy) 방향으로 끈 채 seconds 유지한 뒤 뗀다(실제 터치와 같은 OnPointerDown → OnDrag → OnPointerUp). 결과 = MoveLog.</summary>
public static string PadFor(float seconds, float dx, float dy)
{
var ins = Ins as WLSkillTestHarness; if (ins == null) return "하네스 없음";
if (!Ready || MyValue.MyPC == null) return "ready 아님";
MoveLog = "패드 이동 중"; ins.StartCoroutine(ins.CoPad(seconds, dx, dy));
return "패드 시작 " + seconds + " s (" + dx + ", " + dy + ")";
}
IEnumerator CoPad(float seconds, float dx, float dy)
{
var joy = Object.FindAnyObjectByType<FloatingJoystick>(); var pc = MyValue.MyPC as PCActor;
if (joy == null || pc == null) { MoveLog = joy == null ? "FloatingJoystick 없음" : "PC 없음"; yield break; }
var ev = new UnityEngine.EventSystems.PointerEventData(UnityEngine.EventSystems.EventSystem.current);
Vector2 p0 = new Vector2(Screen.width * 0.25f, Screen.height * 0.3f), dir = new Vector2(dx, dy).normalized;
ev.position = p0; joy.OnPointerDown(ev);
Vector3 w0 = pc.Get_position(); float t0 = Time.time; string animMid = "-";
while (Time.time - t0 < seconds) { ev.position = p0 + dir * (Screen.height * 0.12f); joy.OnDrag(ev); animMid = pc.Get_CurAnim().ToString(); yield return null; }
string padDir = joy.Direction.ToString("F2");
joy.OnPointerUp(ev);
yield return null;
MoveLog = "패드 이동 " + Vector3.Distance(w0, pc.Get_position()).ToString("F2") + " m / " + seconds + " s · 패드 입력 " + padDir + " · 이동 중 anim " + animMid + " → 뗀 뒤 " + pc.Get_CurAnim() + " · joystick " + pc.JoystickStatus + " · RealCamera " + (MyValue.m_RealCamera != null);
}
IEnumerator CoMove(float seconds, float h, float v)
{
var pc = MyValue.MyPC as PCActor; if (pc == null) yield break;
Vector3 p0 = pc.Get_position(); float t0 = Time.time; string animMid = "-";
while (Time.time - t0 < seconds) { _simH = h; _simV = v; animMid = pc.Get_CurAnim().ToString(); yield return null; }
_simH = _simV = 0f;
yield return null;
MoveLog = "이동 " + Vector3.Distance(p0, pc.Get_position()).ToString("F2") + " m / " + seconds + " s · 이동 중 anim " + animMid + " → 뗀 뒤 " + pc.Get_CurAnim() + " · joystick " + pc.JoystickStatus;
}
void OnDestroy() { try { if (System.IO.File.Exists(StatusPath)) System.IO.File.Delete(StatusPath); } catch { } Time.timeScale = 1f; }
// ─────────────────────────────────────────────────────────────────
static int ResolveClass(WLSkillTestJson cfg)
{
try
{
WLWeaponClasses.Ensure(); // WL 추가 직업(대검 10801 등)을 ClassConfig 사전에 먼저 — 섬은 PCActor.Set 의 ResolveSpawnClass 훅이 하지만 이 씬의 ServerData 는 새 것이라 그 훅을 타지 않는다(#947 2회차 Play 실측: 대검 낀 세이브로 시작 → KeyNotFound 10801)
if (WLInventory.EnsureLoaded())
{
var w = WLInventory.EquippedInSlot(eFREquipmentType.Weapon);
if (w != null && w.itemJson != null) { int c = WLWeaponAttackTable.ClassIdFor(w.itemJson.GroupNo); if (c > 0) return c; }
}
}
catch (System.Exception ex) { LastLog = "직업 판정 실패 " + ex.Message; }
return cfg.baseClassId > 0 ? cfg.baseClassId : 10101;
}
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 Vector3 DummyPos(WLSkillTestJson cfg, WLIslandMobSettings ms)
{
Vector3 off = V3(cfg.dummyOffset); if (off == Vector3.zero && ms != null) off = ms.hitTestDummyOffset;
var pc = MyValue.MyPC;
Vector3 basePos = pc != null ? pc.Get_position() : V3(cfg.spawnPos);
Quaternion rot = pc != null ? Quaternion.Euler(0f, pc.transform.eulerAngles.y, 0f) : Quaternion.identity;
return NavPos(basePos + rot * off);
}
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 && surf.navMeshData != null) return; // 빌더가 구워 둔 데이터(씬 옆 에셋 · OnEnable 이 등록) — 런타임 베이크 5 s 생략
if (surf == null) { var ground = GameObject.Find("Ground"); if (ground == null) return; surf = ground.AddComponent<NavMeshSurface>(); }
surf.useGeometry = UnityEngine.AI.NavMeshCollectGeometry.PhysicsColliders;
surf.BuildNavMesh();
}
/// <summary>더미를 PC 앞 기본 자리로(R 키 · 프로브).</summary>
public static string ResetDummy()
{
var d = Dummy; if (d == null || MyValue.MyPC == null) return "PC/더미 없음";
var pos = DummyPos(Cfg, WLIslandMobSettings.Instance);
d.Stop(true); d.Set_Warp(pos);
return "더미 " + pos.ToString("F1");
}
/// <summary>낀 무기를 무기 종류(GroupNo) 순으로 앞뒤로(dir ±1 · O = -1 · P = +1). 종류마다 1개 — SortedItems 는 장착 중인 것을 맨 앞에 두므로 그대로 쓰면 같은 종류 2개 사이를 왕복했다(#947 2차 Play 실측 · 묵광 2개).</summary>
public static string CycleWeapon(int dir)
{
var grp = new List<int>(); var ids = new List<int>(); var names = new List<string>();
int curIdx; string err = WeaponList(grp, ids, names, out curIdx);
if (err != null) return err;
int pos = curIdx < 0 ? 0 : (curIdx + dir + grp.Count) % grp.Count;
return EquipAt(pos, ids, names);
}
/// <summary>#958 PD 「숫자 키를 눌러 해당하는 넘버의 무기로 즉시 바뀌게(기존 등급 변경 대체)」 — number(1부터) = WeaponList 순서(O/P 순환과 같음 · 화면 목록 번호).</summary>
public static string SelectWeapon(int number)
{
var grp = new List<int>(); var ids = new List<int>(); var names = new List<string>();
int curIdx; string err = WeaponList(grp, ids, names, out curIdx);
if (err != null) return err;
if (number < 1 || number > grp.Count) return "무기 " + number + "번 없음(1~" + grp.Count + ")";
if (number - 1 == curIdx) return "이미 " + number + " " + names[curIdx];
return EquipAt(number - 1, ids, names);
}
static string EquipAt(int pos, List<int> ids, List<string> names)
{
bool ok = WLInventory.Equip(ids[pos]);
return (ok ? "장착 " : "실패 ") + (pos + 1) + " " + names[pos] + " · " + (pos + 1) + "/" + ids.Count + (ok ? "" : " · " + WLInventory.LastMessage);
}
/// <summary>낀 수 있는 무기 — 종류(GroupNo) 오름차순 · 종류마다 1개(SortedItems 는 장착 중인 것을 맨 앞에 두므로 같은 종류는 장착 중인 것) · curIdx = 장착 중인 종류의 번호(0부터 · 없으면 -1). 실패면 이유 문장.</summary>
static string WeaponList(List<int> grp, List<int> ids, List<string> names, out int curIdx)
{
curIdx = -1;
if (!WLInventory.EnsureLoaded()) return "인벤토리 미로드";
var cur = WLInventory.EquippedInSlot(eFREquipmentType.Weapon);
int curGroup = cur != null && cur.itemJson != null ? cur.itemJson.GroupNo : -1;
var tmp = new List<KeyValuePair<int, Item>>();
foreach (var it in WLInventory.SortedItems())
{
if (it == null || it.itemJson == null || !it.itemJson.IsEquipment || it.itemJson.EquipmentType != eFREquipmentType.Weapon) continue;
int g = it.itemJson.GroupNo; bool dup = false;
foreach (var kv in tmp) if (kv.Key == g) { dup = true; break; }
if (!dup) tmp.Add(new KeyValuePair<int, Item>(g, it)); // 같은 종류는 첫 것(장착 중이면 그것)만
}
if (tmp.Count == 0) return "무기 없음";
tmp.Sort((a, b) => a.Key.CompareTo(b.Key));
for (int i = 0; i < tmp.Count; i++)
{
grp.Add(tmp[i].Key); ids.Add(tmp[i].Value.rawItem._id); names.Add(tmp[i].Value.itemJson.Item_name);
if (tmp[i].Key == curGroup) curIdx = i;
}
return null;
}
/// <summary>#958 화면 목록 한 줄 — 「1 묵광 · 2 파괴자 · …」(장착 중 = [ ]).</summary>
static string WeaponLine()
{
var grp = new List<int>(); var ids = new List<int>(); var names = new List<string>();
int curIdx; if (WeaponList(grp, ids, names, out curIdx) != null) return "";
var sb = new System.Text.StringBuilder();
for (int i = 0; i < names.Count; i++)
{
if (i > 0) sb.Append(" ");
string key = i < 9 ? (i + 1).ToString() : i == 9 ? "0" : "-";
sb.Append(i == curIdx ? "[" : "").Append(key).Append(' ').Append(names[i]).Append(i == curIdx ? "]" : "");
}
return sb.ToString();
}
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()) + "] hangs=[" + string.Join(" ", Hangs.ToArray()) + "] " + LastLog;
}
// ─────────────────────────────────────────────────────────────────
void LateUpdate()
{
float now = Time.realtimeSinceStartup;
if (_lastRt > 0f && now - _lastRt > 0.5f && Hangs.Count < 40) Hangs.Add((now - T0).ToString("F1") + "@" + (now - _lastRt).ToString("F1") + "s");
_lastRt = now;
}
void Update()
{
if (!Ready) return;
var pc = MyValue.MyPC as PCActor;
if (pc == null) return;
var cfg = Cfg;
_formTimer += Time.unscaledDeltaTime;
if (_formTimer >= Mathf.Max(0.1f, cfg.combatFormCheckSeconds)) { _formTimer = 0f; CheckCombatForm(pc); if (cfg.numberKeysWeapon != 0) _weaponLine = WeaponLine(); }
// 이동 — 섬 조이스틱(WLIslandAvatar.DrivePc · 원본 FloatingJoystick)과 같은 호출 순서. 누름 = Set_AutoBraking(false) → JoystickStatus 1 · 매 프레임 Run_byJoystick(카메라 기준) · 뗌 = JoystickStatus 0 → Set_AutoBraking(true)(Play_Idle · 자동 전투 재개)
float h = 0f, v = 0f;
if (cfg.moveKeys != 0)
{
h = (Input.GetKey(KeyCode.D) || Input.GetKey(KeyCode.RightArrow) ? 1f : 0f) - (Input.GetKey(KeyCode.A) || Input.GetKey(KeyCode.LeftArrow) ? 1f : 0f);
v = (Input.GetKey(KeyCode.W) || Input.GetKey(KeyCode.UpArrow) ? 1f : 0f) - (Input.GetKey(KeyCode.S) || Input.GetKey(KeyCode.DownArrow) ? 1f : 0f);
}
if (_simH != 0f || _simV != 0f) { h = _simH; v = _simV; } // 검증 프로브(MoveFor) — 키와 같은 경로
if (h != 0f || v != 0f)
{
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();
pc.Run_byJoystick((fwd * v + Vector3.Cross(Vector3.up, fwd) * h).normalized);
}
else if (_moving) { pc.Set_JoystickStatus(0); pc.Set_AutoBraking(true); _moving = false; }
// 평지 가장자리 — 공격 전진·피격 넉백으로 PC·더미가 1분에 약 20 m 흘러간다(#947 2차 Play 실측) → 끝에 닿기 전에 둘 다 원점으로
float half = cfg.groundSize * 0.5f - 3f; var pp = pc.Get_position();
if (Mathf.Abs(pp.x) > half || Mathf.Abs(pp.z) > half) { pc.Set_Warp(NavPos(V3(cfg.spawnPos))); _msg = "평지 가장자리 — PC·더미 원점 " + ResetDummy(); }
if (Input.GetKeyDown(_kAttack)) { if (WL.Combat.Weapon.WLGhostStrike.IsLanding(pc)) _msg = "착지 중 — 공격 불가"; else { if (Dummy != null) pc.Change_Target(false, Dummy); pc.Play_Attack(0, 1f); _msg = "기본 공격"; } } // #954 랜딩 중 공격 금지
if (Input.GetKeyDown(_kPrev)) _msg = CycleWeapon(-1);
if (Input.GetKeyDown(_kNext)) _msg = CycleWeapon(+1);
if (cfg.numberKeysWeapon != 0 && Input.anyKeyDown) // #958 숫자 키 = 그 번호 무기(1~9 · 0 = 10번 · 키패드 포함)
for (int i = 0; i < s_numKeys.Length; i++)
if (Input.GetKeyDown(s_numKeys[i]) || Input.GetKeyDown(s_padKeys[i])) { _msg = SelectWeapon(i + 1); break; }
if (Input.GetKeyDown(_kReset)) _msg = ResetDummy();
if (Input.GetKeyDown(_kSlow)) { _slow = !_slow; Time.timeScale = _slow ? Mathf.Clamp(cfg.slowScale, 0.05f, 1f) : 1f; _msg = _slow ? "슬로 모션 ×" + Time.timeScale : "정상 속도"; }
#if UNITY_EDITOR
if (Input.GetKeyDown(_kPause)) UnityEditor.EditorApplication.isPaused = !UnityEditor.EditorApplication.isPaused;
#endif
}
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 + " 더미 HP " + (Dummy != null ? Dummy.Get_HP().ToString("F0") : "-") + (_slow ? " [슬로 ×" + Time.timeScale + "]" : "") +
"\n가상패드(왼쪽 화면 드래그 · 원본과 동일) 또는 WASD 이동 · " + _kAttack + " 공격 · " + WLSkillHotkey.Key + " 주력 · " + (WLSkillHotkey.Key2 != KeyCode.None ? WLSkillHotkey.Key2 + " 각성 · " : "") + (Cfg.numberKeysWeapon != 0 ? "숫자 무기 · " : "숫자 등급 · ") +
_kPrev + " 무기 이전 · " + _kNext + " 무기 다음 · " + _kReset + " 더미 · " + _kSlow + " 슬로 · " + _kPause + " 일시정지" +
(Cfg.manualSkills != 0 ? "\n스킬 = " + WLSkillHotkey.Key + " · " + WLSkillHotkey.Key2 + " 로만(자동 스킬 끔)" + (Cfg.allAwakened != 0 ? " · 모든 무기 각성" : "") : "") +
(string.IsNullOrEmpty(_weaponLine) ? "" : "\n" + _weaponLine) +
(string.IsNullOrEmpty(_msg) ? "" : "\n" + _msg) + (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);
}
}
}