From e04e38030726e3158ea54dc7ed246c90bbc9db1b Mon Sep 17 00:00:00 2001 From: swrring Date: Wed, 21 Oct 2026 14:29:56 +0900 Subject: [PATCH] =?UTF-8?q?WL=20#960=20PD=20=EC=A7=80=EC=8B=9C=204?= =?UTF-8?q?=EA=B1=B4(=EB=B9=84=EC=88=98=20=C2=B7=20=ED=85=8C=EC=8A=A4?= =?UTF-8?q?=ED=8A=B8=20=EC=94=AC)=20=E2=80=94=20=EC=9E=94=EC=83=81=20?= =?UTF-8?q?=EA=B2=80=EC=A0=95=20=C2=B7=20=EA=B0=81=EC=84=B1=20=EC=B0=A9?= =?UTF-8?q?=EC=A7=80=20=EC=98=86=20=EC=A7=84=EC=9E=85=20=EC=96=91=EC=86=90?= =?UTF-8?q?=20=EB=B2=A0=EA=B8=B0=20=C2=B7=20=EC=A3=BC=EB=A0=A5=2010?= =?UTF-8?q?=ED=9A=8C=20=ED=8C=90=EC=A0=95=20=C2=B7=20TestMap=20Play=20=3D?= =?UTF-8?q?=20=EC=8A=A4=ED=82=AC=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EC=94=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ① 잔상 색: 주력 난사 잔상(#959 teal) · 각성 진입 그림자 → 검정(0,0,0,0.5) · 4타 순간이동 · 시전 · 착지 잔상은 원래 거의 검정(0.07,0.03,0.12) - ② 비수 각성 착지: 낙하로 보인 원인 = 모델이 드러난 뒤 ghost_land(Platform Sub_HeroLanding = 공중에서 내려앉는 클립) 재생 + 옆 진입 동안 실제 모델 숨김 → 착지 상태 = 4타 양손 낮게 가르기(DualBlades_DodgeAttack · 이벤트 끔) · 실제 모델이 보인 채 옆 1.2 m 에서 0.2 s 진입 · 0.04 s 마다 검은 지금 자세 잔상 · 끝 잔상 3 = 양손 베기 자세 · 착지 동안 공격 전진 표 막음(AttackRootMotion.Gate · 실측 이동 0.75 → 0.11 m) - ③ 비수 주력: 원본 판정(시전 0.5 s 뒤 0.1 s 간격 6회 · 난사와 무관) 끄고(go_effect Loop 0) 난사 틱 0.24 → 0.2 s · 휘두름마다 0.1 s 뒤 원본 한 대 피해 1회 × 10(Skill_ShadowSlash.ProcessCollider · 1회 피해 그대로 → 총량 원본의 10/6) · 범위 PC 발 3.5 m + 몸 반지름 · 대상 쪽 ±110° · 판정 시각 대기열(끊김에도 휘두름마다) · 실측 3회 모두 10/10 · 0.2 s 간격 - ④ 테스트 씬 미실행: PD 가 Play 한 씬 = Assets/Scenes/TestMap.unity(원본 맵 조각 · LoadMapMgr.Awake NRE · #957 과 같음) → WLSkillTestPlayRedirect(에디터) = WLSkillTest.json redirectScenes 의 씬을 연 채 Play 하면 Play 시작 씬을 WLSkillTest.unity 로(playModeStartScene · 끝나면 원래 씬) · 실측 TestMap 에서 Play → ready · 콘솔 오류 0 Co-Authored-By: Claude Opus 5.5 --- Assets/WL/Combat/AttackRootMotion.cs | 1 + .../Weapon/Resources/WL/WLGhostStrike.json | 14 ++-- .../Weapon/Resources/WL/WLShadowSlash.json | 12 +++- Assets/WL/Combat/Weapon/WLGhostStrike.cs | 34 ++++++++- Assets/WL/Combat/Weapon/WLShadowSlash.cs | 70 +++++++++++++++++-- .../Editor/WLSkillTestPlayRedirect.cs | 59 ++++++++++++++++ .../Editor/WLSkillTestPlayRedirect.cs.meta | 2 + .../SkillTest/Resources/WL/WLSkillTest.json | 2 + Assets/WL/SkillTest/WLSkillTestHarness.cs | 1 + 9 files changed, 181 insertions(+), 14 deletions(-) create mode 100644 Assets/WL/SkillTest/Editor/WLSkillTestPlayRedirect.cs create mode 100644 Assets/WL/SkillTest/Editor/WLSkillTestPlayRedirect.cs.meta diff --git a/Assets/WL/Combat/AttackRootMotion.cs b/Assets/WL/Combat/AttackRootMotion.cs index 5b4c5b657..c90557f1f 100644 --- a/Assets/WL/Combat/AttackRootMotion.cs +++ b/Assets/WL/Combat/AttackRootMotion.cs @@ -140,6 +140,7 @@ namespace WL.Combat if (_agent == null || !_agent.enabled || !_agent.isOnNavMesh) return false; if (!_pc.WL_CanAttackStep()) return false; if (DashDriver.IsDashingNow(_pc)) return false; + if (WL.Combat.Weapon.WLGhostStrike.IsLanding(_pc)) return false; // #960 비수 각성 착지(양손 베기 = 4타 클립)는 옆 진입 뒤 멈춰야 한다 — 4타 전진 표가 착지 뒤 0.65 m 더 밀었다(실측 이동 0.75 m) return true; } diff --git a/Assets/WL/Combat/Weapon/Resources/WL/WLGhostStrike.json b/Assets/WL/Combat/Weapon/Resources/WL/WLGhostStrike.json index dfbb06f09..f89392bfd 100644 --- a/Assets/WL/Combat/Weapon/Resources/WL/WLGhostStrike.json +++ b/Assets/WL/Combat/Weapon/Resources/WL/WLGhostStrike.json @@ -42,7 +42,11 @@ "jumpForward": 0.1, "jumpSide": 1.0, "jumpSeconds": 0.25, - "landState": "ghost_land", + "landState": "DualBlades_DodgeAttack", + "landStateStart": 0.0, + "landNoEvents": 1, + "entryGhostInterval": 0.04, + "_note_960": "#960 PD 「각성 마지막 랜딩 때 그림자는 옆에서 등장하는데 캐릭터는 공중에서 낙하 → 캐릭터를 양손 베기 모션 상태로 그림자처럼 옆에서 등장하며 랜딩」 · 「비수 공격 잔상 색 = 검은색」 — 낙하로 보인 원인 = 착지 상태 ghost_land(Platform Sub_HeroLanding = 공중에서 내려앉는 클립)를 모델이 드러난 뒤 재생 · 옆 진입 동안은 실제 모델을 숨기고 반투명 teal 그림자만 그렸음 → 착지 상태 = 4타 양손 낮게 가르기(DualBlades_DodgeAttack = Stander@DualBlades_Attack3 · 처음부터 · 애니메이션 이벤트 끔 = 4타 순간이동·카메라·타격 안 섞임) · 실제 모델이 보인 채 옆 1.2 m 에서 0.2 s 감속 진입 · 지나온 길에 0.04 s 마다 지금 자세 검은 잔상(landGhostColor 0,0,0,0.5 · 유지 0.03 · 소멸 0.18) · 끝 잔상 3 = 양손 베기 자세", "landHeight": 0.0, "landFallSeconds": 0.2, "entrySide": 1.2, @@ -57,11 +61,11 @@ "dustScale": 0.15, "keepCamera": 1, "fallForward": 0.0, - "landPose": "ghost_land@0.25", - "landHideModel": 1, - "landGhostColor": [ 0.55, 0.95, 0.85, 0.45 ], + "landPose": "DualBlades_DodgeAttack@0.3", + "landHideModel": 0, + "landGhostColor": [ 0.0, 0.0, 0.0, 0.5 ], "landGhostHold": 0.03, - "landGhostFade": 0.1, + "landGhostFade": 0.18, "popGhosts": 3, "_note_955": "#955 PD 「너무 높은 데서 떨어짐 → 공중 낙하가 아니라 0.5 m 쯤 살짝 낮은 공간에서 순간이동하듯 등장하며 착지 · 수직 랜딩 뒤 미끄러짐이 아니라 전진하듯 살짝 미끄러지는 연출」 — 높이 0.5 → 0.2 · 낙하 0.15 → 0.06 s(거의 즉시 등장) · 등장 뒤 전진 미끄러짐 0.45 m / 0.3 s 감속 · 잔상 3", "_note_954": "#954 PD 「각성 뒤 너무 높은 위치에서 튀어나옴 → 0.5 m 정도 낮은 높이에서 반투명 상태로 갑자기 이펙트와 함께 튀어나오며 랜딩 · 랜딩 시간 짧게 · 랜딩 중 공격 불가」 — 높이 1.2 → 0.5 · 낙하 0.25 → 0.15 s · 전체 1.0 → 0.45 s · 미끄러짐 0.4/0.35 → 0.3/0.25 · 낙하 중 실제 모델 숨김 + 착지 자세(ghost_land@0.25)의 반투명 teal 캐릭터를 매 프레임(유지 0.02 · 소멸 0.08) · 착지 순간 모델 보임 + 잔상 2 + 먼지 · 공격 금지 = IsLanding(하네스 공격 키) + IsStop(자동 전투)", diff --git a/Assets/WL/Combat/Weapon/Resources/WL/WLShadowSlash.json b/Assets/WL/Combat/Weapon/Resources/WL/WLShadowSlash.json index cf582b256..9c1d2b929 100644 --- a/Assets/WL/Combat/Weapon/Resources/WL/WLShadowSlash.json +++ b/Assets/WL/Combat/Weapon/Resources/WL/WLShadowSlash.json @@ -17,7 +17,13 @@ "fxPrefabWidth": 5.26, "hideOriginalFx": 1, "slashStates": [ "attack", "DualBlades_Attack2" ], - "slashInterval": 0.24, + "slashInterval": 0.2, + "slashHits": 10, + "slashHitDelay": 0.1, + "slashHitRadius": 3.5, + "slashHitRangeMul": 1.6, + "slashHitHalfAngle": 110, + "_note_960": "#960 PD 「비수 주력 스킬 공격 시 총 10회 타격이 가능하도록 애니메이션 재생 타이밍에 적절하게 판정」 · 「비수 공격 잔상 색 = 검은색」 — 원본 판정 = 시전 0.5 s 뒤 go_effect 가 0.1 s 간격 6회(Loop 6 · 난사와 무관) → 끄고(Loop 0) 난사 휘두름 틱 0.24 → 0.2 s(2.0 s 에 10번 휘두름 · 1.6 배속 exit 0.31 s 보다 짧음) 마다 0.1 s 뒤(1·2타 클립의 칼이 지나가는 순간) 1회씩 10회 = 원본 한 대 피해(1회 피해 그대로 → 총량 원본 6회의 10/6) · 범위 = PC 발 기준 3.5 m + 몸 반지름 · 대상 쪽 ±110°(맞을 때마다 더미가 밀려나 공격 범위 × 1.6 = 1.6 m 로는 10회 중 7회 · 2.5 m 로도 3.0 m 까지 밀려 빗나감 실측 · 원본 판정 상자는 앞 약 4.4 m 까지) · 판정 시각은 대기열(프레임 끊김에도 휘두름마다 1회) · 정면 사본 반복도 0.2 s(휘두름마다) · 잔상 색 teal → 검정(0,0,0,0.5)", "slashSpeed": 1.6, "slashSeconds": 2.0, "fxMain": 1, @@ -27,14 +33,14 @@ "fxSlashFraction": 0.6, "fxSlashYOffset": 0.0, "fxSlashFollow": 1, - "fxRepeatInterval": 0.24, + "fxRepeatInterval": 0.2, "fxRepeatSeconds": 0, "slashHardRestart": 1, "slashGhosts": 1, "slashGhostInterval": 0.07, "slashGhostHold": 0.03, "slashGhostFade": 0.14, - "slashGhostColor": [ 0.55, 0.95, 0.85, 0.4 ], + "slashGhostColor": [ 0.0, 0.0, 0.0, 0.5 ], "_note_959": "#959 PD 「이펙트 크기 2배 더 · 플레이어 동작에 맞게 재생시간을 늘려 모션만 보이지 않게(캐릭터 전진값 고려해 위치도) · 공격 모션에 잔상(빠르게 공격하는 느낌)」 — 폭 = 공격 범위 × 1.6 → 3.2(배율 0.304 → 0.608) · 정면 사본을 0.5 s 부터 0.28 s 마다 난사가 끝나는 2.0 s 까지 되풀이(매번 그때 PC 위치·대상 방향 = 전진 반영 · 초승달 수명 0.3 s 라 1회로는 0.6 s 만 보였음) · 난사 상태는 Animator.Play 로 강제(Play 실측: 배속 1.8 에선 2타→3타 exit 전이가 틱보다 먼저 와 3타 회전·4타 돌진 0.4 m 가 섞였음 = PD 가 본 「전진」) · 난사 2.0 s 동안 0.07 s 마다 지금 자세 잔상(teal · 유지 0.03 · 소멸 0.14) · 틱 0.28 → 0.24 s · 배속 1.8 → 1.6(2타가 #959 원본 DualBlades_Attack2 16~36 f 0.61 s · exit 0.8 이 되어 1.8 배속이면 exit 0.27 s 가 틱보다 먼저 와 3타 회전이 샘 → 1.6 배속 exit 0.31 s > 0.24 s)", "_note_958": "#958 PD 「주력 이펙트가 양손에 작게 하나씩 → 무기 방향 전방에 다른 무기 궤적처럼 하나만 · 전방의 적이 있는 방향에 1개 · 플레이어가 타겟하려는 대상의 무기 공격 범위가 중점」 — 양손 난사 사본 끔(fxPerSlash 0) · 정면 사본 1개(fxMain 1 · 0.5 s) · 방향 = 대상 쪽(fxAimTarget 1) · 중심 = PC 몸 중심 + 공격 범위(WL_AttackRange) × 0.5(대상이 더 가까우면 대상 자리) · 폭 = 공격 범위 × 1.6(배율 = 폭 ÷ 프리팹 단위 초승달 폭 5.26 m · #956 편집 모드 실측 · fxScale 무시) · 높이 0 = 몸 중심", "_note_956": "#956 PD 「주력 이펙트가 너무 먼 위치(정면 1.2 m)에 재생 → 플레이어가 휘두르는 무기 위치에 맞게(휘두르는 느낌)」 — 정면 1회 사본은 끔(fxMain 0 · 켜면 fxForward 0.45 · 손 높이 0.3) · 대신 난사 루프 0.28 s 마다 같은 프리팹의 작은 사본(0.35 · 편집 모드 실측 프리팹 단위 초승달 AABB 5.26 m → 약 1.8 m · 초승달 수명 0.30 s · 2번째 초승달 시차)을 양손 소켓(w_bladedancer_l/r) 손잡이→날 끝 0.6 자리에 번갈아 띄우고 0.6 s 동안 날 방향(수평)·위치를 따라가게", diff --git a/Assets/WL/Combat/Weapon/WLGhostStrike.cs b/Assets/WL/Combat/Weapon/WLGhostStrike.cs index 0fb538bb2..77dc8fdc7 100644 --- a/Assets/WL/Combat/Weapon/WLGhostStrike.cs +++ b/Assets/WL/Combat/Weapon/WLGhostStrike.cs @@ -60,6 +60,11 @@ namespace WL.Combat.Weapon public float[] landGhostColor; // 떨어지는 반투명 캐릭터 색(r,g,b,a) · 비우면 잔상 색 public float landGhostHold = 0.02f, landGhostFade = 0.08f; // 매 프레임 띄우는 반투명 캐릭터의 유지·소멸(초) public int popGhosts = 0; // 착지 순간 착지 자세로 터뜨리는 잔상 수(잔상 색 · 기본 유지·소멸) + // #960 PD 「각성 마지막 랜딩 때 그림자는 옆에서 등장하는데 캐릭터는 공중에서 낙하 → 캐릭터를 양손 베기 모션 상태로 그림자처럼 옆에서 등장하며 랜딩」 — + // landStateStart = 착지 상태를 이 정규화 시각부터(양손 베기 클립의 베는 구간이 옆 진입 동안 보이게) · landNoEvents 1 = 착지 동안 애니메이션 이벤트 끔(4타 클립의 순간이동 · 카메라 고정 · 타격이 착지에 끼지 않게) · + // entryGhostInterval > 0 = 옆에서 들어오는 동안(실제 모델이 보일 때) 이 간격으로 지금 자세 잔상(landGhostColor · landGhostHold · landGhostFade) = 그림자처럼 지나온 길 + public float landStateStart = 0f, entryGhostInterval = 0f; + public int landNoEvents = 1; public string poseSource = "clips"; // clips = 숨은 꼭두각시에 poses 를 걸어 굽기 · live = 시전 순간 PC 자세 1 개 public string[] poses = new string[0]; // "애니메이터 상태 이름@정규화 시각" — 차례로 돌려 쓴다(시작은 무작위) } @@ -106,6 +111,7 @@ namespace WL.Combat.Weapon // #951 도약 잔상 · 착지 public int jumpDone; public float jumpNext = -1f; public bool wasHidden, sawAir, landing, landed, dustDone, hideDuringFall; public float landT0, modelBaseY, modelY = float.NaN; public int landGhostsDone, hideUntilFrame; public Vector3 landPos, landFwd, landRight; public float entryMoved; public Transform model; public UnityEngine.AI.NavMeshAgent agent; public object stopWas; // #959 landRight · entryMoved = 옆에서 들어오기 + public bool eventsWas = true, eventsOff; public float nextEntryGhost; // #960 착지 동안 애니메이션 이벤트 끔 · 옆 진입 잔상 다음 시각 public readonly List smrs = new List(); public readonly List weapons = new List(); public readonly List smrPaths = new List(); @@ -357,6 +363,14 @@ namespace WL.Combat.Weapon var lp = cast.model.localPosition; cast.model.localPosition = new Vector3(lp.x, cast.modelY, lp.z); } + public static int EntryGhosts; // #960 옆 진입 잔상 수(검증용) + /// #960 착지 동안 끈 애니메이션 이벤트를 되돌린다(한 번만). + static void RestoreEvents(Cast cast) + { + if (!cast.eventsOff) return; cast.eventsOff = false; + try { if (!DSUtil.CheckNull(cast.shooter) && cast.shooter.m_animation != null) cast.shooter.m_animation.fireEvents = cast.eventsWas; } catch (System.Exception) { } + } + /// 시전자 모델(살갗 메시 + 무기 메시) 보이기·숨기기 — 원본 Model_OnOff 와 같은 렌더러를 쓰되 한 프레임용. static void SetVisible(Cast cast, bool on) { @@ -371,7 +385,7 @@ namespace WL.Combat.Weapon if (cast.landing) { if (cast.model != null) { var lp = cast.model.localPosition; cast.model.localPosition = new Vector3(lp.x, cast.modelBaseY, lp.z); } - SetVisible(cast, true); SetStop(a, cast.stopWas); + SetVisible(cast, true); SetStop(a, cast.stopWas); RestoreEvents(cast); // #960 } cast.landing = false; cast.landed = true; cast.modelY = float.NaN; cast.hideUntilFrame = 0; cast.hideDuringFall = false; s_cast = null; LandLog += " · 취소(직업 전환)"; } @@ -406,7 +420,17 @@ namespace WL.Combat.Weapon var from = cast.landPos - cast.landRight * r.entrySide; try { if (pc != null) pc.Set_Warp(from); else cast.shooter.transform.position = from; } catch (System.Exception) { cast.shooter.transform.position = from; } } - try { if (cast.shooter.m_animation != null) { cast.shooter.m_animation.speed = 1f; cast.shooter.m_animation.CrossFade(r.landState, 0f, 0, 0f); } } catch (System.Exception ex) { LandLog = "착지 클립 실패 — " + ex.Message; } // #952-② 섞임 0 = 서 있는 자세에서 번지지 않게 + try + { + var an = cast.shooter.m_animation; + if (an != null) + { + if (r.landNoEvents != 0) { cast.eventsWas = an.fireEvents; an.fireEvents = false; cast.eventsOff = true; } // #960 양손 베기(4타) 클립의 이벤트(순간이동 · 카메라 · 타격)가 착지에 끼지 않게 + an.speed = 1f; an.CrossFade(r.landState, 0f, 0, Mathf.Clamp01(r.landStateStart)); + } + } + catch (System.Exception ex) { LandLog = "착지 클립 실패 — " + ex.Message; } // #952-② 섞임 0 = 서 있는 자세에서 번지지 않게 + cast.nextEntryGhost = now; // #960 옆 진입 잔상 첫 장 = 바로 if (cast.model != null) cast.model.localPosition = new Vector3(cast.model.localPosition.x, cast.modelBaseY + r.landHeight, cast.model.localPosition.z); Landings++; LandLog = "착지 시작 t+" + (now - cast.t0).ToString("F2") + " @" + cast.landPos.ToString("F2") + (cast.agent != null ? " agent" : " agent 없음"); } @@ -432,6 +456,11 @@ namespace WL.Combat.Weapon float step = Mathf.Min(total * (2f / fall) * (1f - kk) * Time.deltaTime, Mathf.Max(0f, total - cast.entryMoved)); var dir = cast.landRight * Mathf.Sign(r.entrySide); cast.entryMoved += step; if (cast.agent != null && cast.agent.enabled && cast.agent.isOnNavMesh) cast.agent.Move(dir * step); else cast.shooter.transform.position += dir * step; + if (r.entryGhostInterval > 0f && !cast.hideDuringFall && now >= cast.nextEntryGhost) // #960 실제 캐릭터가 들어오는 길에 그림자 잔상 + { + cast.nextEntryGhost = now + r.entryGhostInterval; + if (LiveGhost(cast.shooter, r.landGhostColor, r.landGhostHold, r.landGhostFade)) EntryGhosts++; + } } return; } @@ -468,6 +497,7 @@ namespace WL.Combat.Weapon cast.landing = false; cast.landed = true; cast.modelY = float.NaN; if (cast.model != null) { var lp = cast.model.localPosition; cast.model.localPosition = new Vector3(lp.x, cast.modelBaseY, lp.z); } var pc = cast.shooter as PCActor; + RestoreEvents(cast); // #960 SetStop(cast.shooter, cast.stopWas); try { if (pc != null) pc.Play_Idle(); } catch (System.Exception) { } LandLog += " · 끝 " + t.ToString("F2") + " s · 이동 " + Vector3.Distance(cast.landPos, cast.shooter.Get_position()).ToString("F2") + " m"; diff --git a/Assets/WL/Combat/Weapon/WLShadowSlash.cs b/Assets/WL/Combat/Weapon/WLShadowSlash.cs index 90b0adf6e..9e89e8629 100644 --- a/Assets/WL/Combat/Weapon/WLShadowSlash.cs +++ b/Assets/WL/Combat/Weapon/WLShadowSlash.cs @@ -54,6 +54,11 @@ namespace WL.Combat.Weapon public float fxRepeatInterval = 0f, fxRepeatSeconds = 0f; public int slashHardRestart = 0, slashGhosts = 0; public float slashGhostInterval = 0.07f, slashGhostHold = 0.03f, slashGhostFade = 0.14f; public float[] slashGhostColor = new float[0]; + // #960 PD 「비수 주력 스킬 공격 시 총 10회 타격이 가능하도록 애니메이션 재생 타이밍에 적절하게 판정」 — slashHits > 0 = 원본 베기 판정(시전 0.5 s 뒤 0.1 s 간격 6회 · go_effect Loop 6 · 난사와 무관)을 끄고 + // 난사 휘두름(slashInterval 틱)마다 slashHitDelay 초 뒤(칼이 지나가는 순간) 1회씩 slashHits 회까지 원본 한 대 피해(Skill_ShadowSlash.ProcessCollider · 1회 피해 · 명중 이펙트 · 피격 반응 그대로) · + // 범위 = PC 발 기준 slashHitRadius(0 = 공격 범위 × slashHitRangeMul) + 적 몸 반지름 · 대상(없으면 정면) 쪽 ±slashHitHalfAngle° + public int slashHits = 0; + public float slashHitDelay = 0.1f, slashHitRadius = 0f, slashHitRangeMul = 1.6f, slashHitHalfAngle = 110f; } public static class WLShadowSlash @@ -79,7 +84,7 @@ namespace WL.Combat.Weapon } [RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.AfterSceneLoad)] - static void Boot() { s_cfg = null; Casts = Shown = Scaled = Errors = 0; SlashLoops = SlashLeaks = FxShown = FxRepeats = FxSlashes = SlashGhostCount = 0; LastLog = SlashLog = FxRepeatLog = ""; } + static void Boot() { s_cfg = null; Casts = Shown = Scaled = Errors = 0; SlashLoops = SlashLeaks = FxShown = FxRepeats = FxSlashes = SlashGhostCount = SlashHits = SlashHitTargets = 0; LastLog = SlashLog = FxRepeatLog = SlashHitLog = ""; } public static string Describe() { return "casts " + Casts + " · shown " + Shown + " · scaled " + Scaled + " · err " + Errors + " · fx " + FxShown + "(반복 " + FxRepeats + ") · 잔상 " + SlashGhostCount + " · " + LastLog; } @@ -107,7 +112,8 @@ namespace WL.Combat.Weapon if (c.hideOriginalFx != 0 && skill.go_effect != null) foreach (var rd in skill.go_effect.GetComponentsInChildren(true)) rd.enabled = false; // 풀 인스턴스라 한 번 끄면 유지 if (c.fxMain != 0) Runner.StartCoroutine(CoFx(shooter, c)); // #956 fxMain 0 = 정면 사본 없이 난사 사본(fxPerSlash)만 } - if (c.slashStates != null && c.slashStates.Length > 0 && !DSUtil.CheckNull(shooter) && shooter.m_animation != null) Runner.StartCoroutine(CoSlash(shooter, c)); // #955 + HitMode(skill, c); // #960 원본 6회 판정 끄기(slashHits > 0) / 되돌리기 + if (c.slashStates != null && c.slashStates.Length > 0 && !DSUtil.CheckNull(shooter) && shooter.m_animation != null) Runner.StartCoroutine(CoSlash(shooter, c, skill)); // #955 · #960 휘두름마다 판정 if (c.slashGhosts != 0 && !DSUtil.CheckNull(shooter)) Runner.StartCoroutine(CoGhosts(shooter, c)); // #959 난사 잔상 LastLog = "cast " + Casts + " · shown " + Shown + " · scaled " + Scaled; } @@ -169,12 +175,52 @@ namespace WL.Combat.Weapon } } + public static int SlashHits, SlashHitTargets; public static string SlashHitLog = "", LastMissInfo = ""; + /// #960 원본 베기 판정(go_effect · Projectile_StopAndColliderControl Loop 6 · 0.1 s)을 slashHits > 0 이면 끄고(Loop 0) · 0 이면 처음 값으로(풀 인스턴스라 값이 남는다). + static void HitMode(Skill_ShadowSlash skill, WLShadowSlashJson c) + { + var ge = skill != null ? skill.go_effect : null; if (ge == null) return; + var mark = ge.GetComponent(); + if (c.slashHits > 0) + { + if (mark == null) { mark = ge.gameObject.AddComponent(); mark.loop0 = ge.Loop; mark.loopTime0 = ge.LoopTime; } + ge.Loop = 0; ge.LoopTime = 0f; + } + else if (mark != null) { ge.Loop = mark.loop0; ge.LoopTime = mark.loopTime0; Object.Destroy(mark); } + } + + /// #960 휘두름 1회 판정 — PC 발 기준 범위 + 적 몸 반지름 · 대상 쪽 ±각 안의 적마다 원본 한 대 피해(대검 각성 WLBladeStorm.Hit 와 같은 방식) · 맞은 수. + static int SlashHit(ProjectileBase pb, Actor shooter, WLShadowSlashJson c) + { + var info = ActorInfo.Ins; if (info == null || pb == null || DSUtil.CheckNull(shooter)) return 0; + var origin = shooter.Get_position(); + var fwd = shooter.transform.forward; fwd.y = 0f; var tgt = shooter.Get_Target(); + if (!DSUtil.CheckNull(tgt) && !tgt.IsDead()) { var d = tgt.Get_position() - origin; d.y = 0f; if (d.sqrMagnitude > 1e-4f) fwd = d; } + if (fwd.sqrMagnitude < 1e-4f) fwd = Vector3.forward; fwd.Normalize(); + var pcA = shooter as PCActor; float range = c.slashHitRadius > 0f ? c.slashHitRadius : (pcA != null ? pcA.WL_AttackRange : 1f) * Mathf.Max(0.1f, c.slashHitRangeMul); + var list = info.Get_EnemyActors(shooter.IsEnemy(), origin, range + 3f); if (list == null) return 0; + int n = 0; LastMissInfo = ""; + for (int k = 0; k < list.Count; k++) + { + var a = list[k]; if (a == null || a.IsDead()) continue; + var v = a.Get_position() - origin; v.y = 0f; + if (k == 0) LastMissInfo = "d" + v.magnitude.ToString("F2") + "/" + (range + WL.Combat.WLCombatMotionSettings.BodyRadius(a)).ToString("F2") + " a" + (v.sqrMagnitude > 1e-4f ? Vector3.Angle(fwd, v) : 0f).ToString("F0"); + if (v.magnitude > range + WL.Combat.WLCombatMotionSettings.BodyRadius(a)) continue; + if (v.sqrMagnitude > 1e-4f && Vector3.Angle(fwd, v) > c.slashHitHalfAngle) continue; + var col = a.GetComponent(); if (col == null || !col.enabled) continue; + if (pb.ProcessCollider(col, false)) n++; + } + info.Release_Actors(list); + return n; + } + public static int SlashLoops, SlashLeaks; public static string SlashLog = ""; - /// #955 제자리 난사 — slashSeconds 동안 slashStates 를 번갈아 CrossFade(이벤트 끔 · 배속) · 스킬 상태를 벗어나면 중단 · 끝에 이벤트·배속 원상. - static System.Collections.IEnumerator CoSlash(Actor shooter, WLShadowSlashJson c) + /// #955 제자리 난사 — slashSeconds 동안 slashStates 를 번갈아 CrossFade(이벤트 끔 · 배속) · 스킬 상태를 벗어나면 중단 · 끝에 이벤트·배속 원상. #960 휘두름마다 판정(slashHits). + static System.Collections.IEnumerator CoSlash(Actor shooter, WLShadowSlashJson c, ProjectileBase pb) { var an = shooter.m_animation; if (an == null) yield break; bool prevEvents = an.fireEvents; an.fireEvents = false; float t0 = Time.time; int i = -1, cur = 0; float interval = Mathf.Max(0.05f, c.slashInterval), tickAt = t0; + int hits = 0, targets = 0, scheduled = 0; var hitQ = new System.Collections.Generic.Queue(4); var hitTimes = new System.Text.StringBuilder(); // #960 휘두름마다 판정 시각을 쌓는다(프레임 끊김으로 틱 둘이 한 프레임에 와도 빠지지 않게) try { while (Time.time - t0 < c.slashSeconds) @@ -187,6 +233,7 @@ namespace WL.Combat.Weapon if (an.HasState(0, cur)) { if (c.slashHardRestart != 0) an.Play(cur, 0, 0f); else an.CrossFadeInFixedTime(cur, 0.05f, 0, 0f); an.speed = Mathf.Max(0.1f, c.slashSpeed); SlashLoops++; } else SlashLog = "상태 없음 " + st; if (c.fxPerSlash != 0 && !string.IsNullOrEmpty(c.fxPrefab)) SlashFx(shooter, c, i); // #956 휘두르는 손의 날 자리에 작은 사본 + if (c.slashHits > 0 && scheduled < c.slashHits) { hitQ.Enqueue(Time.time + Mathf.Max(0f, c.slashHitDelay)); scheduled++; } // #960 이 휘두름의 칼이 지나가는 순간 } else if (c.slashHardRestart != 0) { @@ -194,11 +241,24 @@ namespace WL.Combat.Weapon bool foreign = an.GetCurrentAnimatorStateInfo(0).shortNameHash != cur || (an.IsInTransition(0) && an.GetNextAnimatorStateInfo(0).shortNameHash != cur); if (foreign) { an.Play(cur, 0, 0f); an.speed = Mathf.Max(0.1f, c.slashSpeed); SlashLeaks++; } } + while (hitQ.Count > 0 && Time.time >= hitQ.Peek()) // #960 판정(이번 프레임까지 온 것 모두) + { + hitQ.Dequeue(); hits++; SlashHits++; int n = SlashHit(pb, shooter, c); targets += n; SlashHitTargets += n; + hitTimes.Append((Time.time - t0).ToString("F2")).Append(n > 0 ? "" : "(빗나감 " + LastMissInfo + ")").Append(' '); + } yield return null; } + // 끝 — 남은 판정 + (끊김으로 휘두름이 모자랐으면) 모자란 만큼 + if (c.slashHits > 0 && !DSUtil.CheckNull(shooter) && !shooter.IsDead()) + while (hits < c.slashHits && (hitQ.Count > 0 || scheduled < c.slashHits)) + { + if (hitQ.Count > 0) hitQ.Dequeue(); else scheduled++; + hits++; SlashHits++; int n = SlashHit(pb, shooter, c); targets += n; SlashHitTargets += n; hitTimes.Append((Time.time - t0).ToString("F2")).Append("(끝) "); + } } finally { if (!DSUtil.CheckNull(shooter) && shooter.m_animation != null) { shooter.m_animation.fireEvents = prevEvents; shooter.m_animation.speed = 1f; } } SlashLog = "난사 " + (i + 1) + "회 · " + (Time.time - t0).ToString("F2") + " s · 되돌림 " + SlashLeaks; + if (c.slashHits > 0) SlashHitLog = "판정 " + hits + "회 · 맞힌 적 합 " + targets + " · 시각 " + hitTimes; } public static int FxSlashes; public static string FxSlashLog = ""; @@ -260,6 +320,8 @@ namespace WL.Combat.Weapon public sealed class WLShadowSlashBase : MonoBehaviour { public Vector3 scale, size, center; public bool hasBox; } /// #954 코루틴 숙주(씬 밖 · 숨김). public sealed class WLShadowSlashRunner : MonoBehaviour { } + /// #960 원본 베기 판정을 끈 표식 — 처음 Loop · LoopTime(풀 인스턴스에 남으므로 slashHits 0 으로 되돌릴 때 쓴다). + public sealed class WLShadowSlashHits : MonoBehaviour { public int loop0; public float loopTime0; } /// #956 난사 사본 추종 — 자리 = 소켓(손잡이) → 날 끝 사이 fraction + 위 yOffset · 방향 = 날 방향의 수평 성분(없으면 PC 정면) · follow 면 매 프레임(LateUpdate · 애니메이션 평가 뒤) 다시 맞춘다. public sealed class WLShadowSlashFxFollow : MonoBehaviour { diff --git a/Assets/WL/SkillTest/Editor/WLSkillTestPlayRedirect.cs b/Assets/WL/SkillTest/Editor/WLSkillTestPlayRedirect.cs new file mode 100644 index 000000000..0c97bebed --- /dev/null +++ b/Assets/WL/SkillTest/Editor/WLSkillTestPlayRedirect.cs @@ -0,0 +1,59 @@ +// ───────────────────────────────────────────────────────────────────────────── +// WLSkillTestPlayRedirect.cs — TestMap 을 연 채 Play 하면 스킬 테스트 씬으로 시작 (PD #960 · 2026-10-21) +// +// PD 원문(#960): 「스킬 테스트 용 씬이 또 실행되지 않는거 같아 확인해보고 수정해」 (#957 에도 같은 증상) +// ■ 실측: 두 번 모두 PD 가 Play 한 씬 = Assets/Scenes/TestMap.unity(원본 2025 맵 조각 · 루트 LoadMap[LoadMapMgr] 1개). +// 이 씬은 게임 흐름(Title 부팅)이 만든 AddrResourceMgr 로 맵 프리팹을 불러오는 조각이라 혼자 Play 하면 LoadMapMgr.Awake NRE(LoadMapMgr.cs:16)로 아무것도 안 뜬다. +// 스킬 테스트 씬은 Assets/WL/SkillTest/WLSkillTest.unity(#947 하네스) — 이 씬은 정상(play_test.sh ready). +// ■ 처리: 편집기에서 활성 씬이 WLSkillTest.json redirectScenes 에 든 씬이면 Play 시작 씬을 스킬 테스트 씬으로 바꾼다 +// (EditorSceneManager.playModeStartScene · Play 가 끝나면 원래 씬이 그대로 열려 있다 · 목록 밖 씬은 영향 없음 · 에디터 전용). +// ───────────────────────────────────────────────────────────────────────────── +using UnityEditor; +using UnityEditor.SceneManagement; +using UnityEngine; + +namespace WL.SkillTest.Editor +{ + [InitializeOnLoad] + public static class WLSkillTestPlayRedirect + { + public static string LastLog = ""; + static bool s_set; + + static WLSkillTestPlayRedirect() + { + EditorApplication.playModeStateChanged -= OnPlayMode; + EditorApplication.playModeStateChanged += OnPlayMode; + } + + static string[] RedirectScenes() + { + var ta = Resources.Load(WLSkillTestHarness.ResourcesPath); if (ta == null) return new string[0]; + try { var j = JsonUtility.FromJson(ta.text); return j != null && j.redirectScenes != null ? j.redirectScenes : new string[0]; } + catch (System.Exception) { return new string[0]; } + } + + static void OnPlayMode(PlayModeStateChange s) + { + if (s == PlayModeStateChange.ExitingEditMode) + { + var active = EditorSceneManager.GetActiveScene().path; + if (System.Array.IndexOf(RedirectScenes(), active) >= 0) + { + var sa = AssetDatabase.LoadAssetAtPath(WLSkillTestSceneBuilder.ScenePath); + if (sa != null) + { + EditorSceneManager.playModeStartScene = sa; s_set = true; + LastLog = "Play 시작 씬 " + active + " → " + WLSkillTestSceneBuilder.ScenePath; + Debug.Log("[WL960] " + active + " 은 원본 맵 조각(혼자 실행 불가)이라 스킬 테스트 씬으로 Play 합니다 — " + WLSkillTestSceneBuilder.ScenePath); + } + } + else if (s_set) { EditorSceneManager.playModeStartScene = null; s_set = false; } + } + else if (s == PlayModeStateChange.EnteredEditMode && s_set) + { + EditorSceneManager.playModeStartScene = null; s_set = false; // 다음 Play 는 다시 그때 활성 씬 기준 + } + } + } +} diff --git a/Assets/WL/SkillTest/Editor/WLSkillTestPlayRedirect.cs.meta b/Assets/WL/SkillTest/Editor/WLSkillTestPlayRedirect.cs.meta new file mode 100644 index 000000000..defad17f5 --- /dev/null +++ b/Assets/WL/SkillTest/Editor/WLSkillTestPlayRedirect.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: af821b628ec287549b6635dd0432509a \ No newline at end of file diff --git a/Assets/WL/SkillTest/Resources/WL/WLSkillTest.json b/Assets/WL/SkillTest/Resources/WL/WLSkillTest.json index b3a6f99f1..e3b73b250 100644 --- a/Assets/WL/SkillTest/Resources/WL/WLSkillTest.json +++ b/Assets/WL/SkillTest/Resources/WL/WLSkillTest.json @@ -22,6 +22,8 @@ "numberKeysWeapon": 1, "allAwakened": 1, "manualSkills": 1, + "redirectScenes": [ "Assets/Scenes/TestMap.unity" ], + "_note_960": "#960 PD 「스킬 테스트 용 씬이 또 실행되지 않는 거 같아」 — 실측(#957 · #960 두 번 다): PD 가 Play 한 씬 = Assets/Scenes/TestMap.unity(원본 맵 조각 · AddrResourceMgr 가 Title 부팅에서만 생겨 LoadMapMgr.Awake NRE) → 이 씬을 연 채 Play 하면 Play 시작 씬을 Assets/WL/SkillTest/WLSkillTest.unity 로 바꾼다(WLSkillTestPlayRedirect · Play 끝나면 TestMap 그대로)", "_note_958": "#958 PD — numberKeysWeapon 1 = 숫자 키 1~9 · 0(10번)이 그 번호 무기로 즉시 교체(번호 = O/P 순환과 같은 종류 순 · 화면 왼쪽 위 목록 · 등급 치트는 이 씬에서 끔) · allAwakened 1 = 모든 무기 각성 상태(주력 · 각성 해금 등급 무시) · manualSkills 1 = 자동 스킬 시전 끔(Q · E 치트키로만 · 그 밖엔 기본 공격만)", "label": 1, "labelFontScale": 0.018, diff --git a/Assets/WL/SkillTest/WLSkillTestHarness.cs b/Assets/WL/SkillTest/WLSkillTestHarness.cs index 18c8256f7..5f1d1e812 100644 --- a/Assets/WL/SkillTest/WLSkillTestHarness.cs +++ b/Assets/WL/SkillTest/WLSkillTestHarness.cs @@ -74,6 +74,7 @@ namespace WL.SkillTest 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;