From c6d8403f695c5c89b57976a887a58d4fb9258653 Mon Sep 17 00:00:00 2001 From: swrring Date: Tue, 20 Oct 2026 21:36:12 +0900 Subject: [PATCH] =?UTF-8?q?WL=20#952=20PD=20=EC=A7=80=EC=8B=9C=204?= =?UTF-8?q?=EA=B1=B4=20=E2=80=94=20=E2=91=A0=20=EB=B9=84=EC=88=98=203?= =?UTF-8?q?=ED=83=80=20=EC=9D=B4=ED=8E=99=ED=8A=B8=20=3D=20=EA=B3=A0?= =?UTF-8?q?=EB=A6=AC=20=EB=A9=94=EC=8B=9C=EB=A5=BC=20=EC=88=98=ED=8F=89?= =?UTF-8?q?=EC=9C=BC=EB=A1=9C=20=EB=88=95=ED=98=80=20=EC=86=90=EC=9E=A1?= =?UTF-8?q?=EC=9D=B4=20=EC=95=9E=EC=97=90(swingFx=20euler=20Z=2090=20?= =?UTF-8?q?=C2=B7=20massLocal=20(0,0.5,0)=20=C2=B7=20alignFraction=200.3?= =?UTF-8?q?=20=C2=B7=20=EC=84=B8=EB=A1=9C=20=EA=B3=A0=EB=A6=AC=EA=B0=80=20?= =?UTF-8?q?=EC=9C=84=20=EC=B9=B4=EB=A9=94=EB=9D=BC=EC=97=90=EC=84=9C=20?= =?UTF-8?q?=EB=AA=A8=EC=84=9C=EB=A6=AC=EB=A7=8C=20=EB=B3=B4=EC=9D=B4?= =?UTF-8?q?=EB=8D=98=20=EA=B2=83)=20=E2=91=A1=20=EB=B9=84=EC=88=98=20?= =?UTF-8?q?=EA=B0=81=EC=84=B1=20=EC=B0=A9=EC=A7=80=20=3D=20=EC=9B=90?= =?UTF-8?q?=EB=B3=B8=EC=9D=B4=20=EB=AA=A8=EB=8D=B8=EC=9D=84=20=EB=8B=A4?= =?UTF-8?q?=EC=8B=9C=20=EC=BC=9C=EB=8A=94=20=ED=94=84=EB=A0=88=EC=9E=84?= =?UTF-8?q?=EC=97=90=20=EC=A6=89=EC=8B=9C(WLGhostStrike.LateTick=20=C2=B7?= =?UTF-8?q?=20=EA=B7=B8=20=ED=94=84=EB=A0=88=EC=9E=84=20=EC=88=A8=EA=B9=80?= =?UTF-8?q?=20=C2=B7=20CrossFade=200)=20+=20=EB=AF=B8=EB=81=84=EB=9F=AC?= =?UTF-8?q?=EC=A7=90=20=EA=B0=90=EC=86=8D=200.4=20m/0.35=20s=20+=20?= =?UTF-8?q?=EB=A8=BC=EC=A7=80=200.15=20=E2=91=A2=20=ED=99=9C=201=C2=B72?= =?UTF-8?q?=ED=83=80=20=EA=B8=B8=EC=9D=B4=200.3(=EB=8A=98=EB=A6=BC=C2=B7?= =?UTF-8?q?=EA=B6=A4=EC=A0=81=C2=B7=EC=9B=94=EB=93=9C=20=EC=9E=85=EC=9E=90?= =?UTF-8?q?=20=EC=88=98=EB=AA=85)=20=C2=B7=20=EC=86=8D=EB=8F=84=20=C3=972(?= =?UTF-8?q?WLProjectileVisual=20length/speedMul=20=C2=B7=20WLProjectileSpe?= =?UTF-8?q?edMul=20=EB=B0=9C=EC=82=AC=20=ED=94=84=EB=A0=88=EC=9E=84=20Late?= =?UTF-8?q?Update)=20=E2=91=A3=20=EB=AC=B4=EA=B8=B0=20=EC=A0=84=ED=99=98?= =?UTF-8?q?=20=EB=92=A4=20=EC=9D=B4=EB=8F=99=20=EB=A9=88=EC=B6=A4=20=3D=20?= =?UTF-8?q?=EA=B0=80=EC=83=81=ED=8C=A8=EB=93=9C=20=EB=97=8C=EC=9D=84=20PCM?= =?UTF-8?q?ove=20=EC=99=80=20=EB=AC=B4=EA=B4=80=ED=95=98=EA=B2=8C=20?= =?UTF-8?q?=EB=B3=B5=EA=B5=AC(FloatingJoystick)=20+=20=EA=B0=95=EC=A0=9C?= =?UTF-8?q?=C2=B7PCMove=20=EA=BA=BC=EC=A7=84=20=EC=A0=84=ED=99=98=20?= =?UTF-8?q?=EB=92=A4=20=EC=83=81=ED=83=9C=20=EC=9B=90=EC=83=81(WLWeaponCla?= =?UTF-8?q?ssApplier.RestoreMotion=20=C2=B7=20WLSkillCancel.ResetFor=20?= =?UTF-8?q?=C2=B7=20WLGhostStrike.CancelFor)=20+=20=EC=A0=84=ED=99=98=20?= =?UTF-8?q?=EC=A7=81=ED=9B=84=20=EC=B2=AB=20=EC=8B=9C=EC=A0=84=20=EB=AF=B8?= =?UTF-8?q?=EB=A3=B8(TryFirstCast)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5.1 --- .../Scripts/Joysticks/FloatingJoystick.cs | 14 ++++- .../Impact/Resources/WL/WLImpactFx.json | 8 ++- .../Weapon/Resources/WL/WLGhostStrike.json | 7 ++- .../Resources/WL/WLProjectileVisual.json | 6 ++ Assets/WL/Combat/Weapon/WLGhostStrike.cs | 60 +++++++++++++++---- Assets/WL/Combat/Weapon/WLProjectileVisual.cs | 34 +++++++++++ Assets/WL/Combat/Weapon/WLSkillCancel.cs | 9 +++ .../WL/Combat/Weapon/WLWeaponClassApplier.cs | 40 ++++++++++++- Assets/WL/Combat/Weapon/WLWeaponSkillSlots.cs | 2 + 9 files changed, 156 insertions(+), 24 deletions(-) diff --git a/Assets/ThirdParty/Joystick Pack/Scripts/Joysticks/FloatingJoystick.cs b/Assets/ThirdParty/Joystick Pack/Scripts/Joysticks/FloatingJoystick.cs index bcbe04a8f..96178a743 100644 --- a/Assets/ThirdParty/Joystick Pack/Scripts/Joysticks/FloatingJoystick.cs +++ b/Assets/ThirdParty/Joystick Pack/Scripts/Joysticks/FloatingJoystick.cs @@ -33,6 +33,9 @@ public class FloatingJoystick : Joystick if (OptionInfo.Ins.Get_PCMove() && direction != Vector3.zero) { + // WL #849 (2026-10-12) — 섬 MainCamera 에는 RealCamera 가 없어 다음 줄이 매 프레임 NullReference 팝업을 냈다. + // 예외 때문에 섬에서는 원래 아래 이동(Run_byJoystick)도 실행된 적이 없다(섬 이동 = FI 조이스틱) → 이동 블록 전체를 건너뛰어 동작은 그대로 · 예외만 없앤다. + if (MyValue.m_RealCamera == null) return; MyValue.m_RealCamera.Set_DistanceUp(Vertical < 0); // 아래로 움직일 경우 MyValue.m_RealCamera.m_BackMove = Vertical < 0; MyValue.MyPC.Run_byJoystick(direction); @@ -56,12 +59,17 @@ public class FloatingJoystick : Joystick { background.gameObject.SetActive(false); base.OnPointerUp(eventData); - if (OptionInfo.Ins.Get_PCMove() && MyValue.bMyPC && MyValue.MyPC.CanMove()) + // WL #952-④ (2026-10-20) — 누름(OnPointerDown)은 PCMove 와 무관하게 Set_AutoBraking(false)(IsStop = true · 자동 전투 중단) + JoystickStatus 1 을 걸지만 뗌은 PCMove 가 꺼져 있으면 건너뛰어 + // 스킬이 PCMove 를 끈 채 끝나면(해머 강력한 일격 실측) 캐릭터가 영영 굳었다(PD 에디터 실측: IsStop true · JoystickStatus 1 · 대기). 누름이 걸어 둔 상태(JoystickStatus 1)는 PCMove 와 무관하게 되돌린다. + if (MyValue.bMyPC && (MyValue.MyPC.JoystickStatus == 1 || (OptionInfo.Ins.Get_PCMove() && MyValue.MyPC.CanMove()))) { MyValue.MyPC.Set_JoystickStatus(0); MyValue.MyPC.Set_AutoBraking(true); - MyValue.m_RealCamera.Set_DistanceUp(false); - MyValue.m_RealCamera.m_BackMove = false; + if (MyValue.m_RealCamera != null) // WL #849 — 섬에는 RealCamera 가 없다(위와 같은 가드) + { + MyValue.m_RealCamera.Set_DistanceUp(false); + MyValue.m_RealCamera.m_BackMove = false; + } } } } \ No newline at end of file diff --git a/Assets/WL/Combat/Impact/Resources/WL/WLImpactFx.json b/Assets/WL/Combat/Impact/Resources/WL/WLImpactFx.json index d036f9b3c..3c511c8a3 100644 --- a/Assets/WL/Combat/Impact/Resources/WL/WLImpactFx.json +++ b/Assets/WL/Combat/Impact/Resources/WL/WLImpactFx.json @@ -107,7 +107,7 @@ "stages": [ 3 ], "prefab": "WL_Impact_DaggerSlash3", "scale": 1.0, - "euler": [ 0, 0, 0 ], + "euler": [ 0, 0, 90 ], "yOffset": 0.0, "calibrated": false, "follow": true, @@ -116,10 +116,12 @@ "life": 0.8, "minInterval": 0.3, "alignWeapon": true, - "alignFraction": 0.5, + "alignFraction": 0.3, + "massLocal": [ 0, 0.5, 0 ], "fixedDir": false, "lockFacing": false, - "_note_951": "#951 PD 「비수 3타 이펙트가 공격한 검 위치를 기준으로 호 형태로 펼쳐지도록」 — 원본 Effect_Slash_10202_3 의 WL 사본을 매 프레임 무기(손잡이 → 날 끝) 방향·위치에 맞춰 따라가게(창 2타 alignWeapon 과 같은 방식) · 수치는 Play 실측 뒤 조정" + "_note_951": "#951 PD 「비수 3타 이펙트가 공격한 검 위치를 기준으로 호 형태로 펼쳐지도록」 — 원본 Effect_Slash_10202_3 의 WL 사본을 매 프레임 무기(손잡이 → 날 끝) 방향·위치에 맞춰 따라가게(창 2타 alignWeapon 과 같은 방식) · 수치는 Play 실측 뒤 조정", + "_note_952": "#952 PD 「이펙트 위치가 실제 검 위치와 너무 동떨어져 있고 크기가 너무 작아 어색」 — 편집 모드 실측(952_slash.cs): 보이는 호 = HighPolySwordRing 메시 입자(지름 1.98 × 크기 2.92 × 0.5 = 프리팹 단위 2.9 m 고리 · 중심 로컬 (0, 0.5, 0))가 Z 85° 로 기울어 세로 평면(YZ)에 서 있어 위 카메라에서는 모서리(두께 0.43)만 보이고 주변 별 입자만 보였다 → euler Z 90 으로 고리를 수평(XZ)으로 눕히고 massLocal 로 고리 중심을 손잡이 앞 alignFraction 자리에 맞춤(그 전 = 고리 중심이 피벗 위 0.5 × 배율 · 별이 피벗에서 1 m 밖). 배율은 1.0(실제 0.637 = 0.55 × 1.16) 그대로 두고 PD 판정" }, { "classIds": [ diff --git a/Assets/WL/Combat/Weapon/Resources/WL/WLGhostStrike.json b/Assets/WL/Combat/Weapon/Resources/WL/WLGhostStrike.json index 45157eb7a..07ed15f28 100644 --- a/Assets/WL/Combat/Weapon/Resources/WL/WLGhostStrike.json +++ b/Assets/WL/Combat/Weapon/Resources/WL/WLGhostStrike.json @@ -44,14 +44,15 @@ "landState": "ghost_land", "landHeight": 1.2, "landFallSeconds": 0.25, - "slideDistance": 0.5, - "slideSeconds": 0.3, + "slideDistance": 0.4, + "slideSeconds": 0.35, "landTotalSeconds": 1.0, "landAtSeconds": 3.4, "airClip": "Skill8_Air", "landGhosts": 2, "dustPrefab": "WL/Fx/WL_Fx_LandDust", - "dustScale": 0.5, + "dustScale": 0.15, + "_note_952": "#952 PD 「이펙트가 끝나는 시점에 즉시 착지(서 있는 모습 없이) · 떨어진 후 미끄러지듯 살짝 전진하다 멈춤 · 흙먼지는 캐릭터 등신에 맞게」 — 착지 시작 = 원본이 모델을 다시 켜는 그 프레임(WLGhostStrike.LateTick · 그 프레임은 숨김 · 섞임 0) · 미끄러짐 = 감속(2D/T → 0 · 0.4 m / 0.35 s) · 먼지 = HCFX Dust_Dash 사본의 긴 줄기(3D 크기 1.7 × 5.0 × lengthScale 1 = 프리팹 단위 5 m) × 0.15 ≈ 0.75 m(PC 키 1.13 m 실측 · Bounds952.Model)", "_note_951": "#951 PD 「각성 시전 시 뛰어오르는 잔상 → 사라진 채 공격 → 잔상과 함께 등장 · 낮은 공중에서 떨어져 착지 포즈 · 0.5 m 미끄러지다 정지 · 흙먼지」 — 시전 순간 위·앞으로 잔상 jumpGhosts 개(jumpSeconds 동안) · PC 가 다시 보이는 순간(또는 landAtSeconds) 모델을 landHeight 에서 landFallSeconds 에 내리며 잔상 landGhosts · 착지 클립 landState(어새신 컨트롤러 ghost_land = Platform Sub_HeroLanding 0~36) · slideSeconds 동안 NavMeshAgent.Move 로 slideDistance 앞으로 · 착지 때 dustPrefab(Hyper Casual Dust_Dash 사본) · landTotalSeconds 뒤 idle", "poseSource": "clips", "poses": [ diff --git a/Assets/WL/Combat/Weapon/Resources/WL/WLProjectileVisual.json b/Assets/WL/Combat/Weapon/Resources/WL/WLProjectileVisual.json index c5a651f50..ff43394e5 100644 --- a/Assets/WL/Combat/Weapon/Resources/WL/WLProjectileVisual.json +++ b/Assets/WL/Combat/Weapon/Resources/WL/WLProjectileVisual.json @@ -35,6 +35,9 @@ "visual": "WL/Fx/WL_Fx_ArrowLightShot", "scale": 1.0, "thickness": 0.5, + "length": 0.3, + "speedMul": 2.0, + "_note_952": "#952 PD 「1, 2타 화살의 투사체 길이를 3타 투사체와 비슷하게 줄이고 · 활 투사체 속도를 2배로」 — Play 실측(같은 AABB 투영법 · 14 m/s): length 0.5 = 일반 4.3 m vs 강화(3타 ArrowYellow) 2.6 m → 0.3 으로(추정 2.8 m) · — length = 1·2타 겉모습(ArrowLightShot)의 길이 배율(늘림 머리 lengthScale 8/5 · 꼬리 TrailRenderer 0.3/0.2 s = 길이가 속도에 비례하므로 속도 2배와 함께 줄임) · 3타(ArrowYellow 강화 겉모습)는 그대로 · speedMul = 발사마다 ProjectileData.Speed × 2(10402 prefab m_Speed 7 · 10401 = 기본 5 + 스탯 보정 · 발사 프레임 LateUpdate 적용) · 값은 Play 실측(1·2타 vs 3타 길이)으로 맞춤", "chargedVisual": "WL/Fx/WL_Fx_ArrowYellow", "chargedScale": 1.0, "chargedReplaces": true, @@ -48,6 +51,9 @@ "visual": "WL/Fx/WL_Fx_ArrowLightShot", "scale": 1.0, "thickness": 0.5, + "length": 0.3, + "speedMul": 2.0, + "_note_952": "#952 — 10402 와 같은 값(1·2타 길이 0.3 · 속도 ×2 · 10401 은 prefab m_Speed 없음 = 기본 5 + 스탯 → ×2) · Play 미확인(들꽃 단궁은 인벤토리 지급 목록에 없음 · 원장 10)", "_note_943": "#943-② PD 「발사되는 화살이 좀 더 샤프하게 두께를 줄여줘」 — thickness 0.5 = 굵기만 절반(길이 그대로 · 강화 화살 WL_Fx_ArrowYellow 도 같은 값)", "chargedVisual": "WL/Fx/WL_Fx_ArrowYellow", "chargedScale": 1.0, diff --git a/Assets/WL/Combat/Weapon/WLGhostStrike.cs b/Assets/WL/Combat/Weapon/WLGhostStrike.cs index da3089f4e..7e9154e24 100644 --- a/Assets/WL/Combat/Weapon/WLGhostStrike.cs +++ b/Assets/WL/Combat/Weapon/WLGhostStrike.cs @@ -93,7 +93,7 @@ namespace WL.Combat.Weapon public float t0, groundY; public List fx; public int next, poseCursor; public Vector3 lastPos; public bool hasLast, posesReady; public string sig = ""; // #951 도약 잔상 · 착지 - public int jumpDone; public float jumpNext = -1f; public bool wasHidden, sawAir, landing, landed, dustDone; public float landT0, modelBaseY, modelY = float.NaN; public int landGhostsDone; + public int jumpDone; public float jumpNext = -1f; public bool wasHidden, sawAir, landing, landed, dustDone; public float landT0, modelBaseY, modelY = float.NaN; public int landGhostsDone, hideUntilFrame; public Vector3 landPos, landFwd; public Transform model; public UnityEngine.AI.NavMeshAgent agent; public object stopWas; public readonly List smrs = new List(); public readonly List weapons = new List(); @@ -224,13 +224,7 @@ namespace WL.Combat.Weapon if (Spawn(c, cast, sp + Vector3.up * (rl.jumpHeight * k) + fw * (rl.jumpForward * k), fw)) JumpSpawned++; cast.jumpDone++; cast.jumpNext = now + rl.jumpSeconds / rl.jumpGhosts; } - // #951 ② 다시 보이는 순간(또는 landAtSeconds) → 착지 연출 - if (!string.IsNullOrEmpty(rl.landState) && !cast.landing && !cast.landed) - { - string clip = CurrentClip(cast.shooter); - if (!string.IsNullOrEmpty(rl.airClip) && clip.Contains(rl.airClip)) cast.sawAir = true; - else if ((cast.sawAir && now - cast.t0 > 0.3f) || (rl.landAtSeconds > 0f && now - cast.t0 >= rl.landAtSeconds)) StartLanding(cast, now); - } + // #951 ② 착지 연출 — 시작 판정은 LateTick(#952-② 다시 보이는 그 프레임에 즉시 · 서 있는 자세가 한 프레임도 안 보이게) · 진행은 여기 if (cast.landing) UpdateLanding(c, cast, now); if (cast.fx != null) while (cast.next < cast.fx.Count && now >= cast.t0 + cast.fx[cast.next].delay - cast.rule.fxLead) @@ -269,13 +263,53 @@ namespace WL.Combat.Weapon var ci = an.GetCurrentAnimatorClipInfo(0); return ci.Length > 0 && ci[0].clip != null ? ci[0].clip.name : ""; } - /// 러너 LateUpdate — 애니메이터가 모델 위치를 쓴 뒤에 낙하 높이를 덮는다(Update 에서 쓰면 되돌아간다 · 실측). + /// 러너 LateUpdate — 애니메이터가 모델 위치를 쓴 뒤에 낙하 높이를 덮는다(Update 에서 쓰면 되돌아간다 · 실측). + /// #952-② PD 「이펙트가 끝나는 시점에 잠시 캐릭터가 서 있는 상태가 보였다가 착지 → 끝나는 시점에 즉시 착지」 — 착지 시작 판정도 여기서: 원본 Co_Invisible 이 모델을 다시 켜는 순간(코루틴 = Update 뒤 · LateUpdate 앞)을 + /// 같은 프레임에 잡아 착지 클립·높이를 적용하고 그 한 프레임은 모델을 숨긴다(이미 그려질 자세 = 서 있는 원본 자세) → 다음 프레임부터 공중 착지 자세. 공중 클립 끝·시각 조건은 예비. internal static void LateTick() { - var cast = s_cast; if (cast == null || !cast.landing || cast.model == null || float.IsNaN(cast.modelY)) return; + var cast = s_cast; if (cast == null || DSUtil.CheckNull(cast.shooter)) return; + var rl = cast.rule; + if (!string.IsNullOrEmpty(rl.landState) && !cast.landing && !cast.landed) + { + float now = Time.time; bool hidden = IsHidden(cast); string clip = CurrentClip(cast.shooter); + if (hidden) cast.wasHidden = true; + bool inAir = !string.IsNullOrEmpty(rl.airClip) && clip.Contains(rl.airClip); + if (inAir) cast.sawAir = true; + bool reappeared = cast.wasHidden && !hidden; + bool airEnded = cast.sawAir && !inAir && !hidden && now - cast.t0 > 0.3f; + if (reappeared || airEnded || (rl.landAtSeconds > 0f && now - cast.t0 >= rl.landAtSeconds)) + { + StartLanding(cast, now); + cast.modelY = cast.modelBaseY + rl.landHeight; + SetVisible(cast, false); cast.hideUntilFrame = Time.frameCount + 1; + LandLog += reappeared ? " · 보이는 순간" : airEnded ? " · 공중 클립 끝" : " · 시각"; + } + } + if (cast.landing && cast.hideUntilFrame > 0 && Time.frameCount >= cast.hideUntilFrame) { cast.hideUntilFrame = 0; SetVisible(cast, true); } + if (!cast.landing || cast.model == null || float.IsNaN(cast.modelY)) return; var lp = cast.model.localPosition; cast.model.localPosition = new Vector3(lp.x, cast.modelY, lp.z); } + /// 시전자 모델(살갗 메시 + 무기 메시) 보이기·숨기기 — 원본 Model_OnOff 와 같은 렌더러를 쓰되 한 프레임용. + static void SetVisible(Cast cast, bool on) + { + for (int i = 0; i < cast.smrs.Count; i++) { var r = cast.smrs[i]; if (r != null) r.enabled = on; } + for (int i = 0; i < cast.weapons.Count; i++) { var w = cast.weapons[i]; var r = w != null ? w.GetComponent() : null; if (r != null) r.enabled = on; } + } + + /// #952-④ 직업(무기) 전환 등으로 끊길 때 — 이 시전자의 착지 연출을 접고 멈춤 상태·모델 높이·보이기를 되돌린다. + public static void CancelFor(Actor a) + { + var cast = s_cast; if (cast == null || a == null || !ReferenceEquals(cast.shooter, a)) return; + 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); + } + cast.landing = false; cast.landed = true; cast.modelY = float.NaN; cast.hideUntilFrame = 0; s_cast = null; LandLog += " · 취소(직업 전환)"; + } + static bool IsHidden(Cast cast) { for (int i = 0; i < cast.smrs.Count; i++) { var r = cast.smrs[i]; if (r == null) continue; return !r.enabled || !r.gameObject.activeInHierarchy; } @@ -300,7 +334,7 @@ namespace WL.Combat.Weapon cast.model = cast.shooter.m_animation != null ? cast.shooter.m_animation.transform : null; cast.modelBaseY = cast.model != null ? cast.model.localPosition.y : 0f; cast.agent = cast.shooter.GetComponent(); if (pc != null) { try { if (s_isStop == null) s_isStop = typeof(Actor).GetProperty("IsStop", System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Public); cast.stopWas = s_isStop != null ? s_isStop.GetValue(pc, null) : null; } catch (System.Exception) { } SetStop(pc, true); pc.WL_StopNavMove(); } - try { if (cast.shooter.m_animation != null) cast.shooter.m_animation.CrossFade(r.landState, 0.05f, 0, 0f); } catch (System.Exception ex) { LandLog = "착지 클립 실패 — " + ex.Message; } + 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 = 서 있는 자세에서 번지지 않게 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 없음"); } @@ -330,7 +364,9 @@ namespace WL.Combat.Weapon float slideT = t - fall; if (slideT < r.slideSeconds && r.slideDistance > 0f) { - float step = r.slideDistance * Time.deltaTime / Mathf.Max(0.05f, r.slideSeconds); + // #952-② PD 「떨어진 후 미끄러지듯 살짝 앞으로 전진하다 멈추게」 — 속도를 처음 2D/T 에서 0 으로 선형 감속(총 이동 = slideDistance · 등속 뒤 급정지였음) + float T = Mathf.Max(0.05f, r.slideSeconds); float k = Mathf.Clamp01(slideT / T); + float step = r.slideDistance * (2f / T) * (1f - k) * Time.deltaTime; if (cast.agent != null && cast.agent.enabled && cast.agent.isOnNavMesh) cast.agent.Move(cast.landFwd * step); else cast.shooter.transform.position += cast.landFwd * step; } diff --git a/Assets/WL/Combat/Weapon/WLProjectileVisual.cs b/Assets/WL/Combat/Weapon/WLProjectileVisual.cs index e4e55b44b..8d1796811 100644 --- a/Assets/WL/Combat/Weapon/WLProjectileVisual.cs +++ b/Assets/WL/Combat/Weapon/WLProjectileVisual.cs @@ -25,6 +25,8 @@ namespace WL.Combat.Weapon public string visual = ""; public float scale = 1f; public float thickness = 1f; // #943-② PD 「발사되는 화살이 좀 더 샤프하게 두께를 줄여줘」 — 시각(일반·강화)의 굵기만 배율(입자 크기 · 3D 크기 X/Y · 늘림 길이 보정 · 궤적 폭) · 길이는 그대로 + public float length = 1f; // #952-③ PD 「1, 2타 화살의 투사체 길이를 3타 투사체와 비슷하게 줄이고」 — 기본 겉모습(visual)의 길이만 배율(늘림 빌보드 lengthScale · 입자 궤적 수명 · TrailRenderer 시간 · Mesh 모드 입자의 진행 축 Z) · 굵기 그대로 · 강화(3타) 겉모습에는 안 씀 + public float speedMul = 1f; // #952-③ PD 「활 투사체 속도를 2배로」 — 쏘는 순간 ProjectileData.Speed 배율(원본 prefab 은 Speed 를 직렬화하지 않아 클래스 기본값 5 + 스탯 보정 · 매 발사 적용 · 1 = 그대로) // #839 활 3타 강화 화살 — WLBowChargeReceiver.ArmedUntil 창 안에 켜지는 인스턴스에 WLChargedMark 가 적용(크기 × chargedScale + 글로우 자식) public string chargedVisual = ""; public float chargedScale = 1.6f; @@ -96,6 +98,7 @@ namespace WL.Combat.Weapon s_rules.TryGetValue(key, out rule); s_byName[n] = rule; } if (rule == null) return; + if (rule.speedMul > 0f && !Mathf.Approximately(rule.speedMul, 1f)) { var sm = pb.GetComponent(); if (sm == null) sm = pb.gameObject.AddComponent(); sm.mul = rule.speedMul; sm.Arm(pb); } // #952-③ 매 발사 · 적용은 그 프레임 LateUpdate(Projectile_Strait.Set 이 base.Set 뒤에 m_Speed 로 덮어쓰므로 — 10402 prefab m_Speed 7 실측) if (!s_done.Contains(pb.GetInstanceID())) { Events++; if (Attach(pb, rule)) s_done.Add(pb.GetInstanceID()); } ApplyStageScale(pb, rule); // #948 매 발사(풀 재사용 포함) — 콤보 단계별 배율 } @@ -154,6 +157,7 @@ namespace WL.Combat.Weapon var go = InstantiateVisual(rule.visual, pb.transform, rule.scale, "~WL_Visual"); if (go == null) { LastLog = "시각 프리팹 없음 " + rule.visual; return false; } ApplyThickness(go, rule.thickness); // #943-② + ApplyLength(go, rule.length); // #952-③ 기본 겉모습만(강화 화살은 그대로) var arr = go.GetComponentsInChildren(true); for (int i = 0; i < arr.Length; i++) { @@ -199,6 +203,36 @@ namespace WL.Combat.Weapon foreach (var lr in go.GetComponentsInChildren(true)) lr.widthMultiplier *= t; Thinned++; } + public static int Shortened, SpeedScaled; public static string LastSpeed = ""; + /// #952-③ 투사체 속도 배율 — OnSet(ProjectileBase.Set 안)에서 곱하면 파생 클래스(Projectile_Strait)가 base.Set 뒤 prefab m_Speed 로 덮어쓴다(바람쐐기 10402 = 7 · 실측) → 발사 프레임의 LateUpdate(이동 코루틴이 첫 이동을 하기 전)에 그 발사의 ProjectileData 에 1회 적용. + public sealed class WLProjectileSpeedMul : MonoBehaviour + { + public float mul = 1f; ProjectileBase _pb; bool _pending; ProjectileData _last; + public void Arm(ProjectileBase pb) { _pb = pb; _pending = true; } + void LateUpdate() + { + if (!_pending || _pb == null) return; + var pd = _pb.Get_ProjectTileData(); if (pd == null) return; + _last = pd; _pending = false; // 발사마다 Arm(OnSet) 이 다시 켠다 — 풀 인스턴스는 ProjectileData 객체를 재사용하므로(최초 로드 때 1회 new · Set 은 값 복사) 참조 비교로는 발사를 못 가른다(실측: 첫 발만 ×2) + if (mul > 0f && !Mathf.Approximately(mul, 1f)) { pd.Speed *= mul; SpeedScaled++; LastSpeed = _pb.name + " 속도 " + pd.Speed.ToString("F1"); } + } + } + /// #952-③ 길이만 L 배 — 늘림 빌보드 lengthScale·velocityScale(길이 = 크기 × lengthScale + 속도 × velocityScale) · 입자 궤적 수명 · TrailRenderer 시간(길이 = 시간 × 비행 속도 → 속도를 올리면 그만큼 길어지므로 함께 줄인다) · Mesh 모드 입자는 시스템 변환의 Z(진행 축 · 계층 배율). 굵기는 그대로. + static void ApplyLength(GameObject go, float L) + { + if (go == null || L <= 0f || Mathf.Approximately(L, 1f)) return; + foreach (var ps in go.GetComponentsInChildren(true)) + { + var r = ps.GetComponent(); + if (r != null && r.renderMode == ParticleSystemRenderMode.Stretch) { r.lengthScale *= L; r.velocityScale *= L; } + else if (r != null && r.renderMode == ParticleSystemRenderMode.Mesh) { var s = ps.transform.localScale; ps.transform.localScale = new Vector3(s.x, s.y, s.z * L); } + var m = ps.main; + if (m.simulationSpace == ParticleSystemSimulationSpace.World) m.startLifetime = Mul(m.startLifetime, L); // 월드 공간 발광(flares 0.6 s · lines 0.2 s)은 지나온 길에 남아 꼬리 = 수명 × 비행 속도(14 m/s 면 8 m · Play AABB 실측 4.3 m 가 length 와 무관하게 같았던 이유) → 수명도 같은 배율 + var tr = ps.trails; if (tr.enabled) tr.lifetime = Mul(tr.lifetime, L); + } + foreach (var tr in go.GetComponentsInChildren(true)) tr.time *= L; + Shortened++; + } static ParticleSystem.MinMaxCurve Mul(ParticleSystem.MinMaxCurve c, float t) { switch (c.mode) diff --git a/Assets/WL/Combat/Weapon/WLSkillCancel.cs b/Assets/WL/Combat/Weapon/WLSkillCancel.cs index cc5d900d0..a3d90fb58 100644 --- a/Assets/WL/Combat/Weapon/WLSkillCancel.cs +++ b/Assets/WL/Combat/Weapon/WLSkillCancel.cs @@ -110,6 +110,15 @@ namespace WL.Combat.Weapon static void OnFired(in SkillFiredEvent e) { if (s_active && ReferenceEquals(e.actor, s_pc)) s_fired = true; } + /// #952-④ 직업(무기) 강제 전환처럼 스킬이 끝 이벤트 없이 끊겼을 때 — 이 PC 의 시전 잠금·미룬 브레이크를 버린다(남으면 이동 입력을 계속 무시한다). + public static void ResetFor(PCActor pc) + { + if (pc == null) return; + if (ReferenceEquals(pc, s_pc)) { s_active = false; s_lock = s_hold = s_lockFire = s_heldAtCast = false; s_pendingBrake = -1; } + if (ReferenceEquals(pc, s_extPc)) { s_extPc = null; s_extPending = false; } + LastLog = "초기화(직업 전환) " + s_effect; + } + /// #912 seconds 동안 이 PC 의 이동 입력을 무시한다(스킬 상태일 때만 · 구르기는 그대로 = 스킵). public static void HoldMove(PCActor pc, float seconds) { diff --git a/Assets/WL/Combat/Weapon/WLWeaponClassApplier.cs b/Assets/WL/Combat/Weapon/WLWeaponClassApplier.cs index 04ffa3ce4..6e117fc58 100644 --- a/Assets/WL/Combat/Weapon/WLWeaponClassApplier.cs +++ b/Assets/WL/Combat/Weapon/WLWeaponClassApplier.cs @@ -20,6 +20,9 @@ namespace WL.Combat.Weapon { public static WLWeaponClassApplier Instance { get; private set; } public static int Applied, Skipped, Forced; + /// #952-④ 마지막 직업 전환 시각(unscaled) — 전환 직후 컨트롤러가 비동기로 바뀌는 동안(settleSec) 첫 시전(first_cast)이 나가면 스킬 동작이 옛 컨트롤러에서 돌아 타격 이벤트가 없고(WLJumpSlam 「타격 이벤트 없음」 실측) 스킬이 끈 PCMove 가 안 돌아온다 → WLWeaponSkillSlots.TryFirstCast 가 이 시각으로 미룬다. + public static float LastSwitchAt = -999f; + public static bool Settling { get { var cfg = WLWeaponSettings.Instance; return Time.unscaledTime - LastSwitchAt < (cfg != null ? cfg.settleSec : 1.5f); } } public static string LastLog { get; private set; } = ""; public static int LastClassId, DesiredClassId; @@ -110,14 +113,16 @@ namespace WL.Combat.Weapon return Note("이미 직업 " + desired + " (" + w.itemJson.Item_name + ")" + (ev ? "" : " · 클립 이벤트 대기") + (rm ? " · 모델 재부착" : "")); } if (Time.unscaledTime < _settleUntil) { Skipped++; return Note("안정화 대기"); } + bool forced = false; if (pc.Get_CurAnim() >= eAnim.Attack) { // 연속 전투에서는 공격 끝 프레임을 폴링으로 못 잡는다(2026-10-09 스모크: 6종 전부 「보류」) → 일정 시간 뒤 공격을 끊고 바꾼다 if (_pendingSince < 0f) _pendingSince = Time.unscaledTime; if (cfg.forceSwitchAfterSec <= 0f || Time.unscaledTime - _pendingSince < cfg.forceSwitchAfterSec) { Skipped++; return Note("공격 중 — 보류"); } try { pc.Play_Idle(); } catch (System.Exception) { } - Forced++; + Forced++; forced = true; } + bool stalePcMove = false; try { stalePcMove = OptionInfo.Ins != null && !OptionInfo.Ins.Get_PCMove(); } catch (System.Exception) { } // #952-④ 스킬이 꺼 둔 가상패드 이동 허용이 대기 상태에 남아 있음 = 끊긴 스킬의 잔재 _pendingSince = -1f; ClassConfigTableData cd = null; try { cd = table_classconfig.Ins != null ? table_classconfig.Ins.Get_Data_orNull(desired) : null; } catch (System.Exception) { cd = null; } // 원본은 없는 ID 에 예외를 던진다 @@ -136,9 +141,38 @@ namespace WL.Combat.Weapon catch (System.Exception ex) { return Note("전환 실패 — " + ex.Message); } finally { if (flipped) gf.SetValue(info, prev); } try { WLEquipStats.RefreshPc(); } catch (System.Exception ex) { Debug.LogWarning("[WL-825] 스탯 재반영 실패 — " + ex.Message); } - Applied++; + Applied++; LastSwitchAt = Time.unscaledTime; + if (forced || stalePcMove) RestoreMotion(pc, forced ? "강제 전환" : "PCMove 꺼진 채 전환"); // #952-④ _settleUntil = Time.unscaledTime + cfg.settleSec; - return Note(why + ": 직업 " + cur + " → " + desired + " (" + w.itemJson.Item_name + " · " + WLWeaponAttackTable.Describe(w.itemJson.GroupNo) + ")" + (flipped ? " · 로비 모드 우회" : "")); + return Note(why + ": 직업 " + cur + " → " + desired + " (" + w.itemJson.Item_name + " · " + WLWeaponAttackTable.Describe(w.itemJson.GroupNo) + ")" + (flipped ? " · 로비 모드 우회" : "") + (forced || stalePcMove ? " · 상태 원상(" + LastRestore + ")" : "")); + } + + public static int Restored; public static string LastRestore = ""; + static System.Reflection.PropertyInfo s_pIsStop; static System.Reflection.FieldInfo s_fImmortal; static System.Reflection.MethodInfo s_mModel, s_mWeapon; + /// #952-④ PD 「마법책 무기 변경 후 캐릭터 이동이 멈추는 현상」 — 스킬(해머 강력한 일격 등) 도중 직업을 강제로 바꾸면(Play_Idle → Change_Class) 그 스킬이 끝에 되돌릴 상태가 남는다: + /// 가상패드(FloatingJoystick)가 읽는 OptionInfo.PCMove(false 면 드래그·뗌을 모두 무시 → 뗌을 못 받아 IsStop·JoystickStatus 1 이 굳는다 = PD 에디터 실측) · 무적 · 모델 숨김 · 애니메이터 속도 0 · WL 시전 잠금 · 착지 연출. 전환 직후 원상. + static void RestoreMotion(PCActor pc, string why) + { + var sb = new System.Text.StringBuilder(why); + try { if (OptionInfo.Ins != null && !OptionInfo.Ins.Get_PCMove()) { OptionInfo.Ins.Set_PCMove(pc, true); sb.Append(" · PCMove"); } } catch (System.Exception) { } + try { if (pc.m_animation != null && pc.m_animation.speed != 1f) { pc.m_animation.speed = 1f; sb.Append(" · 속도 1"); } } catch (System.Exception) { } + try + { + var t = typeof(Actor); var bf = System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Public; + if (s_fImmortal == null) s_fImmortal = t.GetField("m_bImmortal", bf); + if (s_fImmortal != null && (bool)s_fImmortal.GetValue(pc)) { s_fImmortal.SetValue(pc, false); sb.Append(" · 무적 해제"); } + if (s_mModel == null) s_mModel = t.GetMethod("Model_OnOff", bf); + if (s_mWeapon == null) s_mWeapon = t.GetMethod("Weapon_OnOff", bf); + bool hidden = false; foreach (var r in pc.GetComponentsInChildren(true)) if (r != null && !r.enabled) { hidden = true; break; } + if (hidden) { if (s_mModel != null) s_mModel.Invoke(pc, new object[] { true }); if (s_mWeapon != null) s_mWeapon.Invoke(pc, new object[] { true }); sb.Append(" · 모델 보임"); } + if (s_pIsStop == null) s_pIsStop = t.GetProperty("IsStop", bf); + if (s_pIsStop != null && pc.JoystickStatus == 0 && (bool)s_pIsStop.GetValue(pc, null)) { s_pIsStop.SetValue(pc, false, null); sb.Append(" · IsStop 해제"); } // 조이스틱을 쥔 상태(1)의 IsStop 은 뗄 때 원본이 푼다 + } + catch (System.Exception ex) { sb.Append(" · 리플렉션 실패 ").Append(ex.Message); } + try { if (pc.JoystickStatus == 2) { pc.Set_JoystickStatus(3); sb.Append(" · 조이스틱 2→0"); } } catch (System.Exception) { } + try { WLSkillCancel.ResetFor(pc); } catch (System.Exception) { } + try { WLGhostStrike.CancelFor(pc); } catch (System.Exception) { } + Restored++; LastRestore = sb.ToString(); } string Note(string s) diff --git a/Assets/WL/Combat/Weapon/WLWeaponSkillSlots.cs b/Assets/WL/Combat/Weapon/WLWeaponSkillSlots.cs index 0a00ee1ff..c5119228b 100644 --- a/Assets/WL/Combat/Weapon/WLWeaponSkillSlots.cs +++ b/Assets/WL/Combat/Weapon/WLWeaponSkillSlots.cs @@ -53,9 +53,11 @@ namespace WL.Combat.Weapon /// #902 PD 「해머 (점프 내려치기) 스킬도 시전 가능한 상태일 때(활성화 & 쿨타임 충족) 기본 공격보다 먼저 사용 · 공격 사거리 밖 적은 점프 후 내려치기」 — /// MyActor 자동 전투가 다가가거나 때리기 직전에 부른다. 표 first_cast 1 칸을 쿨 · 사거리(표 range_m) · CC 규칙으로 시전(자동 시전 간격 2~3초는 무시 = 「쿨 충족 = 시전 가능」). + public static int FirstCastDeferred; public static bool TryFirstCast(PCActor pc) { if (pc == null || !pc.IsMainPC()) return false; + if (WLWeaponClassApplier.Settling) { FirstCastDeferred++; return false; } // #952-④ 직업 전환 직후(컨트롤러 비동기 교체 중)엔 첫 시전을 미룬다 — 그때 나간 해머 강력한 일격이 PCMove 를 끈 채 끝나 가상패드가 굳었다(실측) var w = WLInventory.EquippedInSlot(eFREquipmentType.Weapon); var rows = w != null && w.itemJson != null ? WLWeaponSkillTable.Rows(w.itemJson.GroupNo) : null; if (rows == null) return false;