WL #952 PD 지시 4건 — ① 비수 3타 이펙트 = 고리 메시를 수평으로 눕혀 손잡이 앞에(swingFx euler Z 90 · massLocal (0,0.5,0) · alignFraction 0.3 · 세로 고리가 위 카메라에서 모서리만 보이던 것) ② 비수 각성 착지 = 원본이 모델을 다시 켜는 프레임에 즉시(WLGhostStrike.LateTick · 그 프레임 숨김 · CrossFade 0) + 미끄러짐 감속 0.4 m/0.35 s + 먼지 0.15 ③ 활 1·2타 길이 0.3(늘림·궤적·월드 입자 수명) · 속도 ×2(WLProjectileVisual length/speedMul · WLProjectileSpeedMul 발사 프레임 LateUpdate) ④ 무기 전환 뒤 이동 멈춤 = 가상패드 뗌을 PCMove 와 무관하게 복구(FloatingJoystick) + 강제·PCMove 꺼진 전환 뒤 상태 원상(WLWeaponClassApplier.RestoreMotion · WLSkillCancel.ResetFor · WLGhostStrike.CancelFor) + 전환 직후 첫 시전 미룸(TryFirstCast)

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
깃 관리자 2026-10-20 21:36:12 +09:00
parent c19e37a37d
commit c6d8403f69
9 changed files with 156 additions and 24 deletions

View File

@ -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;
}
}
}
}

View File

@ -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": [

View File

@ -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": [

View File

@ -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,

View File

@ -93,7 +93,7 @@ namespace WL.Combat.Weapon
public float t0, groundY; public List<FxPoint> 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<SkinnedMeshRenderer> smrs = new List<SkinnedMeshRenderer>();
public readonly List<MeshFilter> weapons = new List<MeshFilter>();
@ -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 : "";
}
/// <summary>러너 LateUpdate — 애니메이터가 모델 위치를 쓴 뒤에 낙하 높이를 덮는다(Update 에서 쓰면 되돌아간다 · 실측).</summary>
/// <summary>러너 LateUpdate — 애니메이터가 모델 위치를 쓴 뒤에 낙하 높이를 덮는다(Update 에서 쓰면 되돌아간다 · 실측).
/// #952-② PD 「이펙트가 끝나는 시점에 잠시 캐릭터가 서 있는 상태가 보였다가 착지 → 끝나는 시점에 즉시 착지」 — 착지 시작 판정도 여기서: 원본 Co_Invisible 이 모델을 다시 켜는 순간(코루틴 = Update 뒤 · LateUpdate 앞)을
/// 같은 프레임에 잡아 착지 클립·높이를 적용하고 그 한 프레임은 모델을 숨긴다(이미 그려질 자세 = 서 있는 원본 자세) → 다음 프레임부터 공중 착지 자세. 공중 클립 끝·시각 조건은 예비.</summary>
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);
}
/// <summary>시전자 모델(살갗 메시 + 무기 메시) 보이기·숨기기 — 원본 Model_OnOff 와 같은 렌더러를 쓰되 한 프레임용.</summary>
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<Renderer>() : null; if (r != null) r.enabled = on; }
}
/// <summary>#952-④ 직업(무기) 전환 등으로 끊길 때 — 이 시전자의 착지 연출을 접고 멈춤 상태·모델 높이·보이기를 되돌린다.</summary>
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<UnityEngine.AI.NavMeshAgent>();
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;
}

View File

@ -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<WLProjectileSpeedMul>(); if (sm == null) sm = pb.gameObject.AddComponent<WLProjectileSpeedMul>(); 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<ParticleSystem>(true);
for (int i = 0; i < arr.Length; i++)
{
@ -199,6 +203,36 @@ namespace WL.Combat.Weapon
foreach (var lr in go.GetComponentsInChildren<LineRenderer>(true)) lr.widthMultiplier *= t;
Thinned++;
}
public static int Shortened, SpeedScaled; public static string LastSpeed = "";
/// <summary>#952-③ 투사체 속도 배율 — OnSet(ProjectileBase.Set 안)에서 곱하면 파생 클래스(Projectile_Strait)가 base.Set 뒤 prefab m_Speed 로 덮어쓴다(바람쐐기 10402 = 7 · 실측) → 발사 프레임의 LateUpdate(이동 코루틴이 첫 이동을 하기 전)에 그 발사의 ProjectileData 에 1회 적용.</summary>
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"); }
}
}
/// <summary>#952-③ 길이만 L 배 — 늘림 빌보드 lengthScale·velocityScale(길이 = 크기 × lengthScale + 속도 × velocityScale) · 입자 궤적 수명 · TrailRenderer 시간(길이 = 시간 × 비행 속도 → 속도를 올리면 그만큼 길어지므로 함께 줄인다) · Mesh 모드 입자는 시스템 변환의 Z(진행 축 · 계층 배율). 굵기는 그대로.</summary>
static void ApplyLength(GameObject go, float L)
{
if (go == null || L <= 0f || Mathf.Approximately(L, 1f)) return;
foreach (var ps in go.GetComponentsInChildren<ParticleSystem>(true))
{
var r = ps.GetComponent<ParticleSystemRenderer>();
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<TrailRenderer>(true)) tr.time *= L;
Shortened++;
}
static ParticleSystem.MinMaxCurve Mul(ParticleSystem.MinMaxCurve c, float t)
{
switch (c.mode)

View File

@ -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; }
/// <summary>#952-④ 직업(무기) 강제 전환처럼 스킬이 끝 이벤트 없이 끊겼을 때 — 이 PC 의 시전 잠금·미룬 브레이크를 버린다(남으면 이동 입력을 계속 무시한다).</summary>
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;
}
/// <summary>#912 seconds 동안 이 PC 의 이동 입력을 무시한다(스킬 상태일 때만 · 구르기는 그대로 = 스킵).</summary>
public static void HoldMove(PCActor pc, float seconds)
{

View File

@ -20,6 +20,9 @@ namespace WL.Combat.Weapon
{
public static WLWeaponClassApplier Instance { get; private set; }
public static int Applied, Skipped, Forced;
/// <summary>#952-④ 마지막 직업 전환 시각(unscaled) — 전환 직후 컨트롤러가 비동기로 바뀌는 동안(settleSec) 첫 시전(first_cast)이 나가면 스킬 동작이 옛 컨트롤러에서 돌아 타격 이벤트가 없고(WLJumpSlam 「타격 이벤트 없음」 실측) 스킬이 끈 PCMove 가 안 돌아온다 → WLWeaponSkillSlots.TryFirstCast 가 이 시각으로 미룬다.</summary>
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;
/// <summary>#952-④ PD 「마법책 무기 변경 후 캐릭터 이동이 멈추는 현상」 — 스킬(해머 강력한 일격 등) 도중 직업을 강제로 바꾸면(Play_Idle → Change_Class) 그 스킬이 끝에 되돌릴 상태가 남는다:
/// 가상패드(FloatingJoystick)가 읽는 OptionInfo.PCMove(false 면 드래그·뗌을 모두 무시 → 뗌을 못 받아 IsStop·JoystickStatus 1 이 굳는다 = PD 에디터 실측) · 무적 · 모델 숨김 · 애니메이터 속도 0 · WL 시전 잠금 · 착지 연출. 전환 직후 원상.</summary>
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<SkinnedMeshRenderer>(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)

View File

@ -53,9 +53,11 @@ namespace WL.Combat.Weapon
/// <summary>#902 PD 「해머 (점프 내려치기) 스킬도 시전 가능한 상태일 때(활성화 & 쿨타임 충족) 기본 공격보다 먼저 사용 · 공격 사거리 밖 적은 점프 후 내려치기」 —
/// MyActor 자동 전투가 다가가거나 때리기 직전에 부른다. 표 first_cast 1 칸을 쿨 · 사거리(표 range_m) · CC 규칙으로 시전(자동 시전 간격 2~3초는 무시 = 「쿨 충족 = 시전 가능」).</summary>
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;