2026-10-19 16:48:59 +00:00
// ─────────────────────────────────────────────────────────────────────────────
// 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`(피해 글자)를 부른다 → 같은 자리를 채워 준다.
2026-10-19 17:57:59 +00:00
// ■ 키: WASD/방향키 이동(섬 조이스틱과 같은 호출 · Set_AutoBraking(false) → JoystickStatus 1 → Run_byJoystick) · Space 기본 공격 1타 · Q 주력 · E 각성 · 숫자 등급(기존 훅)
// · O 무기 이전(왼쪽 순환) · P 무기 다음(오른쪽 순환) — PD 2026-10-20 #947 지시 · 종류(GroupNo)마다 1개 · R 더미 제자리 · T 슬로 모션 · Pause = 설정값(기본 Backslash)
2026-10-19 16:48:59 +00:00
// ■ 값 = 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" ; } }
}
[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 moveKeys = 1 ;
public string attackKey = "Space" ;
2026-10-19 17:57:59 +00:00
public string weaponPrevKey = "O" ; // PD #947: O = 무기 왼쪽 순환
public string weaponNextKey = "P" ; // PD #947: P = 무기 오른쪽 순환
2026-10-19 16:48:59 +00:00
public string dummyResetKey = "R" ;
public string slowKey = "T" ;
public float slowScale = 0.2f ;
2026-10-19 17:57:59 +00:00
public string pauseKey = "Backslash" ; // P 를 무기에 내주고 일시정지는 \ 로
2026-10-19 16:48:59 +00:00
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 ;
2026-10-19 17:57:59 +00:00
public static string MoveLog = "" ;
2026-10-19 16:48:59 +00:00
static WLSkillTestJson s_cfg ;
2026-10-19 17:57:59 +00:00
static System . Reflection . FieldInfo s_fEffects , s_fMagic , s_fMagicArr ;
float _lastRt , _simH , _simV ;
2026-10-19 16:48:59 +00:00
bool _moving , _slow ;
2026-10-19 17:57:59 +00:00
KeyCode _kAttack = KeyCode . Space , _kPrev = KeyCode . O , _kNext = KeyCode . P , _kReset = KeyCode . R , _kSlow = KeyCode . T , _kPause = KeyCode . Backslash ;
2026-10-19 16:48:59 +00:00
string _msg = "" ;
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 ;
2026-10-19 17:57:59 +00:00
_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 ;
2026-10-19 16:48:59 +00:00
if ( actors = = null | | actors . Length < 2 ) actors = new Actor [ ] { pcActor , dummyActor } ;
Lap ( "awake" ) ;
}
2026-10-19 17:57:59 +00:00
// 단계마다 try — 예외는 상태 파일(stage=<단계>-fail · 예외 종류 · 메시지 · 호출 위치 4줄)에 남긴다. 코루틴이 조용히 죽으면 콘솔 스팸에 묻혀 찾기 어려웠다(#947 1차 Play · PC.Set NRE 가 Freeze 1000건 아래)
2026-10-19 16:48:59 +00:00
IEnumerator Start ( )
{
var cfg = Cfg ;
2026-10-19 17:57:59 +00:00
if ( ! Try ( "navmesh" , ( ) = > { BakeGround ( ) ; ApplySky ( cfg ) ; } ) ) yield break ;
2026-10-19 16:48:59 +00:00
Lap ( "navmesh" ) ;
while ( TableChecker . Ins = = null | | ! TableChecker . Ins . CheckAllLoad ( ) ) yield return null ;
Lap ( "tables" ) ;
2026-10-19 17:57:59 +00:00
// InGameInfo.Start 의 이펙트 예열 루프(테이블 0.1 s 폴링 뒤 1프레임)가 끝난 다음에 PC 를 만든다 — 먼저 만들면 PC 피격 이펙트 키가 먼저 등록돼 그 루프의 Add 가 ArgumentException 으로 끊긴다(#947 2차 Play 실측)
for ( float t = 0f ; t < 3f & & ! EffectsPreloaded ( ) ; t + = Time . unscaledDeltaTime ) yield return null ;
Lap ( "ingameinfo" ) ;
2026-10-19 16:48:59 +00:00
2026-10-19 17:57:59 +00:00
if ( pcActor = = null | | dummyActor = = null ) { Fail ( "refs-missing" , "pcActor/dummyActor 참조 없음 — Build Scene 다시" ) ; yield break ; }
2026-10-19 16:48:59 +00:00
// PC — 직업 = 낀 WL 무기(없으면 baseClassId). 원본 Set 안에서 WL 무기 적용기·스타터 킷이 돈다(섬과 같은 경로)
2026-10-19 17:57:59 +00:00
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 를 읽는다)
pcActor . Set ( false , null , sdata , cls , true ) ;
FillMagicSlots ( pcActor , sdata ) ;
ActorInfo . Ins . Add_Actor ( pcActor , eRole . PC , 0 , pcActor . name ) ;
} ) ) yield break ;
2026-10-19 16:48:59 +00:00
Lap ( "pc " + cls ) ;
// 더미 — 몬스터 표 행으로 능력치 · 무적(WLTrailTest 체력 되채움) · 제자리
2026-10-19 17:57:59 +00:00
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 ;
2026-10-19 16:48:59 +00:00
Lap ( "dummy " + mobId ) ;
// 카메라 — 섬 구도 리그 + 원본 RealCamera 대상
2026-10-19 17:57:59 +00:00
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 ;
2026-10-19 16:48:59 +00:00
yield return null ;
2026-10-19 17:57:59 +00:00
if ( ! Try ( "ready" , ( ) = > { pcActor . Stop ( false ) ; pcActor . Change_Target ( false , dummyActor ) ; } ) ) yield break ;
2026-10-19 16:48:59 +00:00
Ready = true ;
Lap ( "ready" ) ;
}
2026-10-19 17:57:59 +00:00
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>예외 호출 위치 4줄 — 인자 목록·IL 오프셋·절대 경로를 지워 「at 클래스.메서드 파일.cs:줄」 만 남긴다.</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 < 4 ; 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 팝업)
/// <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 + ")" ;
}
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 ;
}
2026-10-19 16:48:59 +00:00
void OnDestroy ( ) { try { if ( System . IO . File . Exists ( StatusPath ) ) System . IO . File . Delete ( StatusPath ) ; } catch { } Time . timeScale = 1f ; }
// ─────────────────────────────────────────────────────────────────
static int ResolveClass ( WLSkillTestJson cfg )
{
try
{
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 ) { 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" ) ;
}
2026-10-19 17:57:59 +00:00
/// <summary>낀 무기를 무기 종류(GroupNo) 순으로 앞뒤로(dir ±1 · O = -1 · P = +1). 종류마다 1개 — SortedItems 는 장착 중인 것을 맨 앞에 두므로 그대로 쓰면 같은 종류 2개 사이를 왕복했다(#947 2차 Play 실측 · 묵광 2개).</summary>
2026-10-19 16:48:59 +00:00
public static string CycleWeapon ( int dir )
{
if ( ! WLInventory . EnsureLoaded ( ) ) return "인벤토리 미로드" ;
var cur = WLInventory . EquippedInSlot ( eFREquipmentType . Weapon ) ;
2026-10-19 17:57:59 +00:00
int curGroup = cur ! = null & & cur . itemJson ! = null ? cur . itemJson . GroupNo : - 1 ;
var grp = new List < int > ( ) ; var ids = new List < int > ( ) ; var names = new List < string > ( ) ;
2026-10-19 16:48:59 +00:00
foreach ( var it in WLInventory . SortedItems ( ) )
{
if ( it = = null | | it . itemJson = = null | | ! it . itemJson . IsEquipment | | it . itemJson . EquipmentType ! = eFREquipmentType . Weapon ) continue ;
2026-10-19 17:57:59 +00:00
int g = it . itemJson . GroupNo ;
if ( grp . IndexOf ( g ) > = 0 ) continue ; // 같은 종류는 첫 것(장착 중이면 그것)만
grp . Add ( g ) ; ids . Add ( it . rawItem . _id ) ; names . Add ( it . itemJson . Item_name + "(" + g + ")" ) ;
2026-10-19 16:48:59 +00:00
}
2026-10-19 17:57:59 +00:00
if ( grp . Count = = 0 ) return "무기 없음" ;
var order = new List < int > ( ) ; for ( int i = 0 ; i < grp . Count ; i + + ) order . Add ( i ) ;
order . Sort ( ( a , b ) = > grp [ a ] . CompareTo ( grp [ b ] ) ) ;
int curIdx = - 1 ; for ( int i = 0 ; i < order . Count ; i + + ) if ( grp [ order [ i ] ] = = curGroup ) { curIdx = i ; break ; }
int pos = curIdx < 0 ? 0 : ( curIdx + dir + order . Count ) % order . Count , next = order [ pos ] ;
2026-10-19 16:48:59 +00:00
bool ok = WLInventory . Equip ( ids [ next ] ) ;
2026-10-19 17:57:59 +00:00
return ( ok ? "장착 " : "실패 " ) + names [ next ] + " " + ( pos + 1 ) + "/" + order . Count + ( ok ? "" : " · " + WLInventory . LastMessage ) ;
2026-10-19 16:48:59 +00:00
}
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 ;
2026-10-19 17:57:59 +00:00
// 이동 — 섬 조이스틱(WLIslandAvatar.DrivePc · 원본 FloatingJoystick)과 같은 호출 순서. 누름 = Set_AutoBraking(false) → JoystickStatus 1 · 매 프레임 Run_byJoystick(카메라 기준) · 뗌 = JoystickStatus 0 → Set_AutoBraking(true)(Play_Idle · 자동 전투 재개)
float h = 0f , v = 0f ;
2026-10-19 16:48:59 +00:00
if ( cfg . moveKeys ! = 0 )
{
2026-10-19 17:57:59 +00:00
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 ) ;
2026-10-19 16:48:59 +00:00
}
2026-10-19 17:57:59 +00:00
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 ( ) ; }
2026-10-19 16:48:59 +00:00
if ( Input . GetKeyDown ( _kAttack ) ) { if ( Dummy ! = null ) pc . Change_Target ( false , Dummy ) ; pc . Play_Attack ( 0 , 1f ) ; _msg = "기본 공격" ; }
if ( Input . GetKeyDown ( _kPrev ) ) _msg = CycleWeapon ( - 1 ) ;
if ( Input . GetKeyDown ( _kNext ) ) _msg = CycleWeapon ( + 1 ) ;
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 + "]" : "" ) +
2026-10-19 17:57:59 +00:00
"\nWASD 이동 · " + _kAttack + " 공격 · " + WLSkillHotkey . Key + " 주력 · " + ( WLSkillHotkey . Key2 ! = KeyCode . None ? WLSkillHotkey . Key2 + " 각성 · " : "" ) + "숫자 등급 · " +
_kPrev + " 무기 이전 · " + _kNext + " 무기 다음 · " + _kReset + " 더미 · " + _kSlow + " 슬로 · " + _kPause + " 일시정지" +
2026-10-19 16:48:59 +00:00
( 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 ) ;
}
}
}