// ───────────────────────────────────────────────────────────────────────────── // WLShadowSlash.cs — 단검(비수 1043 · Dagger 10202) 주력 「그림자 베기」(Skill_ShadowSlash) — 시전 중 캐릭터 보이기 · 이펙트 크기 배율 // // PD 원문(#943-⑧ · 2026-10-19): 「단검 주력 스킬 시전 시 캐릭터의 모습이 보이도록 수정하고, 이펙트의 크기를 절반으로 줄여줘.」 // // ■ 안 보이던 원인(코드 실측) — 원본 Skill_ShadowSlash.Set 이 shooter.Set_Invisible(2.1 초)를 부른다 → Actor.Co_Invisible 이 // 애니메이터 배속 0 · 무적 · Model_OnOff(false)(파티클 외 렌더러 전부 끔) · Weapon_OnOff(false) · 이동 금지 → 2.1 초 뒤 되돌린 다음 대상 뒤 1.5 m 로 순간이동. // 이펙트가 덮거나 카메라 문제가 아니라 원본 스킬이 모델을 끈다. // ■ 처리 — 원본 Set 의 Set_Invisible 뒤 훅 1줄(OnSet): Co_Invisible 이 끈 렌더러 · 무기를 바로 다시 켠다(원본이 2.1 초 뒤 부르는 같은 함수). // freezeAnim 0 이면 애니메이터 배속도 1 로 되돌려 원본 상태 사슬 DualBlades_Jump_Start → JumpAttack1·2·3 → Jump_End → idle 이 보인다 // (JumpAttack1~3 클립 이벤트 0 개 · Jump_Start 의 SkillEvent 는 시각 0 하나 · 종료 전이 0.7 이라 다시 안 돈다 — 2026-10-19 컨트롤러 · FBX 메타 실측). // 무적 · 이동 금지 · 2.1 초 뒤 대상 뒤 순간이동 · 판정은 원본 그대로. // ■ 이펙트 — 베기(go_effect = Slash · 파티클 6 층 · 크기 2.6 ~ 7.7)와 연기(go_invisibleEffect = FX_DarkSkill_SmokeExplosion)의 Transform 을 // 「처음 크기 × 배율」로(풀 재사용 때 누적 없음 · 처음 크기는 WLShadowSlashBase 에 기억) · 파티클 크기 모드 Hierarchy // (베기 팩 원본이 Local 이라 Transform 배율이 안 먹는다). 베기 판정 상자(BoxCollider 5.25 × 9 × 5.9)는 keepHitBox 1 이면 배율의 역수로 키워 월드 크기 그대로. // ■ 값 = Resources/WL/WLShadowSlash.json (C45) · 끄기 = enabled 0(원본 그대로) // 🔴 Debug.LogError 금지(원본 ErrorLogHook 팝업) — 실패는 LastLog // ───────────────────────────────────────────────────────────────────────────── using System.Reflection; using UnityEngine; namespace WL.Combat.Weapon { [System.Serializable] public class WLShadowSlashJson { public int enabled = 0; public int keepVisible = 1; // 1 = 시전 중 PC 렌더러 · 무기를 켠다(원본 = 2.1 초 숨김) public int freezeAnim = 0; // 1 = 원본처럼 애니메이터 배속 0 유지(보이면 2.1 초 정지 자세) · 0 = 원본 상태 사슬 재생 public float slashScale = 1f; // 베기(go_effect) 보이는 크기 배율 public float smokeScale = 1f; // 연기(go_invisibleEffect) 크기 배율 public int keepHitBox = 1; // 1 = 베기 판정 상자 월드 크기 유지(보이는 크기만 바꿈) // #954 PD 「비수 주력 = Slash Effects FREE 의 녹색 난사(Multiple Slash 2)로」 — 원본 베기 렌더러를 끄고(판정 상자 그대로) WL 사본을 같은 자리·시각에 띄운다 public string fxPrefab = ""; // Resources 경로(비우면 원본 베기 그대로) public float fxScale = 1f, fxDelay = 0.5f, fxForward = 1.5f, fxYOffset = 0f, fxLife = 2.5f; // 배율 · 시전 뒤 시각(원본 go_effect 0.5 s) · PC 정면 거리(원본 1.5) · 높이 · 수명 // #958 PD 「이펙트가 무기 방향 전방에 다른 무기 궤적처럼 하나만 — 전방의 적이 있는 방향에 1개 · 플레이어가 타겟하려는 대상의 무기 공격 범위가 중점」 — // fxAimTarget 1 = 방향 = 지금 대상 쪽(없으면 PC 정면) · fxRangeFrac > 0 = 중심 거리 = 무기 공격 범위(PCActor.WL_AttackRange) × 이 값(대상이 더 가까우면 대상 자리) · // fxRangeWidth > 0 = 보이는 폭 = 공격 범위 × 이 값(배율 = 폭 ÷ fxPrefabWidth · 프리팹 단위 폭 편집 모드 실측) — 0 이면 fxForward · fxScale 그대로 public int fxAimTarget = 0; public float fxRangeFrac = 0f, fxRangeWidth = 0f, fxPrefabWidth = 5.26f; public int hideOriginalFx = 0; // 1 = 원본 베기(go_effect) 렌더러 끔 // #955 PD 「주력 쓸 때 왜 캐릭터가 공중에 뜬 채 행동하나 — 제자리에서 단검을 난사하도록」 — 원본 상태 사슬(DualBlades_Jump_Start → JumpAttack1·2·3 = 공중 자세)을 쓰지 않고 slashSeconds 동안 slashStates 를 slashInterval 마다 번갈아 재생(배속 slashSpeed · 애니메이션 이벤트 끔 = 추가 타격·궤적 없음) public string[] slashStates = new string[0]; public float slashInterval = 0.28f, slashSpeed = 1.8f, slashSeconds = 2.0f; // #956 PD 「주력 이펙트가 너무 먼 위치에 재생 → 플레이어가 휘두르는 무기 위치에 맞게(휘두르는 느낌)」 — fxMain 0 = 정면 1회 사본(fxForward)을 안 띄움 · // fxPerSlash 1 = 난사 루프(slashInterval)마다 fxPrefab 의 작은 사본(fxSlashScale)을 무기가 달린 소켓(PCActor.tfs_weapon · 비수 = 양손 · 번갈아)의 손잡이→날 끝 fxSlashFraction 자리 + 위 fxSlashYOffset 에 // 띄우고 fxSlashLife 동안 날 방향(수평 성분)·위치를 매 프레임 따라가게(fxSlashFollow 1 · 0 = 처음 자리 고정) public int fxMain = 1, fxPerSlash = 0, fxSlashFollow = 1; public float fxSlashScale = 0.35f, fxSlashLife = 0.6f, fxSlashFraction = 0.6f, fxSlashYOffset = 0f; // #959 PD 「이펙트 크기 2배 · 플레이어 동작에 맞게 재생시간을 늘려 모션만 보이지 않게(캐릭터 전진값 고려해 위치도) · 공격 모션에 잔상(빠르게 공격하는 느낌)」 — // fxRepeatInterval > 0 = 정면 사본을 fxDelay 뒤 이 간격으로 fxRepeatSeconds(0 = slashSeconds) 까지 되풀이(매번 그때의 PC 위치·대상 방향으로 다시 계산 = 전진 반영) · // slashHardRestart 1 = 난사 상태를 CrossFade 대신 Animator.Play 로 강제(Play 실측: 배속 1.8 에선 컨트롤러 exit 전이(2타→3타 0.50)가 0.28 s 틱보다 먼저 와 3타 회전·4타 돌진(0.4 m 전진)이 섞였다) · // slashGhosts 1 = 난사 동안 slashGhostInterval 마다 지금 자세 잔상(WLGhostStrike.LiveGhost · 색 RGBA · 유지 · 소멸 초) 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; // #961 PD 「주력 중 캐릭터가 움직이면 이펙트만 그 자리에 남음 → 못 움직이게 · 구르기로 캔슬하면 이펙트도 같이 소멸」 · 「이펙트 소멸 타이밍에 맞게 모션도 종료 · 최소 0.5 s 딜레이 후 공격」 — // moveHold 1 = 난사 동안 이동 입력 무시(WLSkillCancel.HoldMove · 구르기는 그대로) · dodgeCancel 1 = 구르기(eAnim.Dodge)가 시작되면 난사 · 이펙트 반복 · 잔상 · 판정을 멈추고 살아 있는 이펙트를 지운다 · // fxEndAlign 1 = 정면 사본 반복을 fxVisibleLife(초승달 보이는 시간) 만큼 일찍 끝내 마지막 이펙트가 난사 끝에 함께 사라지게 · endHoldSeconds = 난사가 끝난 뒤 공격 금지(자동 전투 IsStop · 하네스 공격 키) public int moveHold = 0, dodgeCancel = 0, fxEndAlign = 0; public float fxVisibleLife = 0.3f, endHoldSeconds = 0f; // #962 PD 「주력 이펙트가 두 번 연속 재생되는 것 같다 → 한 번만 재생하고 모션을 이펙트에 맞춰」 · 「주력 판정이 발생되지 않는다 → 원인 확인 · 수정」 — // slashMatchFx 1 = 난사 시간 = fxDelay + 사본 1개가 보이는 시간(프리팹 입자계마다 시작 지연 + 마지막 분출 시각 + 수명의 최댓값 · FxDuration) · // invisibleMatch 1 = 원본 숨김(Set_Invisible 2.1 s = 이동 금지 · 무적 · 끝 대기 · 대상 뒤 순간이동)도 그 시간에 맞춤(원본 훅 1줄 InvisibleSeconds) · // slashHitEven 1 = 판정 slashHits 회를 난사 시간에 고르게(휘두름 수와 무관 · 그전엔 휘두름마다 1회 + 모자란 만큼 끝에 몰아서) public int slashMatchFx = 0, invisibleMatch = 0, slashHitEven = 0; public int endIdle = 0; // #961 1 = 난사가 끝나는 순간(= 마지막 이펙트 소멸) 대기 동작으로(원본은 0.1 s 뒤 2.1 s 콜백에서 대기 · 그사이 마지막 휘두름이 이어졌다) } public static class WLShadowSlash { public const string ResourcesPath = "WL/WLShadowSlash"; public static int Casts, Shown, Scaled, Errors; public static string LastLog = ""; static WLShadowSlashJson s_cfg; static MethodInfo s_model, s_weapon; static WLShadowSlashJson Cfg { get { if (s_cfg != null) return s_cfg; s_cfg = new WLShadowSlashJson(); var ta = Resources.Load(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; } } [RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.AfterSceneLoad)] static void Boot() { s_cfg = null; Casts = Shown = Scaled = Errors = 0; SlashLoops = SlashLeaks = FxShown = FxRepeats = FxSlashes = SlashGhostCount = SlashHits = SlashHitTargets = 0; DodgeCancels = EndHolds = EndSkips = EndIdles = FxStops = 0; s_holdActor = s_castShooter = s_slashActor = null; s_liveFx.Clear(); s_fxDur.Clear(); LastLog = SlashLog = FxRepeatLog = SlashHitLog = ""; } public static string Describe() { return "casts " + Casts + " · shown " + Shown + " · scaled " + Scaled + " · err " + Errors + " · fx " + FxShown + "(반복 " + FxRepeats + ") · 잔상 " + SlashGhostCount + " · " + LastLog; } /// 원본 Skill_ShadowSlash.Set 훅(Set_Invisible 바로 뒤 1줄) — 캐릭터 보이기 · 베기 · 연기 크기 배율. public static void OnSet(Skill_ShadowSlash skill) { try { var c = Cfg; if (skill == null || c.enabled == 0) return; Casts++; var pd = skill.Get_ProjectTileData(); var shooter = pd != null ? pd.shooter : null; if (c.keepVisible != 0 && !DSUtil.CheckNull(shooter)) { bool model = Call(ref s_model, "Model_OnOff", shooter); bool weapon = Call(ref s_weapon, "Weapon_OnOff", shooter); if (model && weapon) Shown++; if (c.freezeAnim == 0 && shooter.m_animation != null) shooter.m_animation.speed = 1f; } if (skill.go_effect != null) Scale(skill.go_effect.transform, c.slashScale, c.keepHitBox != 0); if (skill.go_invisibleEffect != null) Scale(skill.go_invisibleEffect.transform, c.smokeScale, false); int seq = ++s_castSeq; s_liveFx.Clear(); s_castShooter = shooter; // #961 이번 시전 번호(구르기 캔슬 때 반복 · 잔상을 멈추는 표지) if (!string.IsNullOrEmpty(c.fxPrefab) && !DSUtil.CheckNull(shooter)) // #954 { 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, seq)); // #956 fxMain 0 = 정면 사본 없이 난사 사본(fxPerSlash)만 } HitMode(skill, c); // #960 원본 6회 판정 끄기(slashHits > 0) / 되돌리기 if (c.moveHold != 0 && shooter is PCActor mh) WLSkillCancel.HoldMove(mh, Mathf.Max(0.1f, SlashSec(c))); // #961 난사 동안 이동 입력 무시(구르기는 됨) if (c.slashStates != null && c.slashStates.Length > 0 && !DSUtil.CheckNull(shooter) && shooter.m_animation != null) Runner.StartCoroutine(CoSlash(shooter, c, skill, seq)); // #955 · #960 휘두름마다 판정 · #961 구르기 캔슬 · 끝 공격 금지 if (c.slashGhosts != 0 && !DSUtil.CheckNull(shooter)) Runner.StartCoroutine(CoGhosts(shooter, c, seq)); // #959 난사 잔상 LastLog = "cast " + Casts + " · shown " + Shown + " · scaled " + Scaled; } catch (System.Exception ex) { Errors++; LastLog = "실패 — " + ex.Message; } } static readonly System.Collections.Generic.Dictionary s_fxDur = new System.Collections.Generic.Dictionary(); /// #962 이펙트 프리팹 1회가 보이는 시간 — 입자계마다 (시작 지연 + 마지막 분출 시각 + 수명 최댓값)의 최댓값(반복 입자계 · 끝없는 분출은 빼고 · 비율 분출은 duration). WL_Fx_DaggerMulti = 0.75 + 0.3 = 1.05 s. public static float FxDuration(string path) { float v; if (string.IsNullOrEmpty(path)) return 0f; if (s_fxDur.TryGetValue(path, out v)) return v; v = 0f; var p = Resources.Load(path); if (p != null) foreach (var ps in p.GetComponentsInChildren(true)) { var m = ps.main; if (m.loop) continue; float delay = m.startDelay.mode == ParticleSystemCurveMode.Constant ? m.startDelay.constant : m.startDelay.constantMax; float life = m.startLifetime.mode == ParticleSystemCurveMode.Constant ? m.startLifetime.constant : (m.startLifetime.mode == ParticleSystemCurveMode.TwoConstants ? m.startLifetime.constantMax : m.startLifetime.curveMultiplier); var em = ps.emission; float last = -1f; for (int i = 0; i < em.burstCount; i++) { var b = em.GetBurst(i); if (b.cycleCount <= 0) continue; last = Mathf.Max(last, b.time + (b.cycleCount - 1) * b.repeatInterval); } if (em.rateOverTime.constantMax > 0f) last = Mathf.Max(last, m.duration); if (last < 0f) continue; v = Mathf.Max(v, delay + last + life); } s_fxDur[path] = v; return v; } /// #962 난사 시간 — slashMatchFx 면 fxDelay + 사본 1개가 보이는 시간(없으면 slashSeconds). public static float SlashSec(WLShadowSlashJson c) { if (c.slashMatchFx != 0 && !string.IsNullOrEmpty(c.fxPrefab)) { float d = FxDuration(c.fxPrefab); if (d > 0.05f) return Mathf.Max(0f, c.fxDelay) + d; } return c.slashSeconds; } /// #962 원본 Skill_ShadowSlash.Set 의 Set_Invisible(2.1 s) 인자 훅 — invisibleMatch 면 난사 시간으로(이동 금지 · 무적 · 끝 대기 · 대상 뒤 순간이동이 모션 끝에 함께). public static float InvisibleSeconds(float orig) { try { var c = Cfg; if (c.enabled == 0 || c.invisibleMatch == 0) return orig; return Mathf.Max(0.2f, SlashSec(c)); } catch (System.Exception) { return orig; } } public static int FxShown; static WLShadowSlashRunner s_runner; static WLShadowSlashRunner Runner { get { if (s_runner == null) { var go = new GameObject("[WL954] ShadowSlashFx"); go.hideFlags = HideFlags.HideAndDontSave; Object.DontDestroyOnLoad(go); s_runner = go.AddComponent(); } return s_runner; } } public static int FxRepeats; public static string FxRepeatLog = ""; /// #954 원본 베기가 켜지는 시각(fxDelay)에 WL 사본(녹색 난사)을 PC 정면 fxForward 에 띄운다(PC 방향 · fxLife 뒤 파괴). #959 fxRepeatInterval > 0 이면 fxRepeatSeconds(0 = slashSeconds)까지 되풀이 — 매번 그때 PC 위치·대상 방향. static System.Collections.IEnumerator CoFx(Actor shooter, WLShadowSlashJson c, int seq) { if (c.fxDelay > 0f) yield return new WaitForSeconds(c.fxDelay); float t0 = Time.time - c.fxDelay, until = c.fxRepeatSeconds > 0f ? c.fxRepeatSeconds : SlashSec(c); int n = 0; if (c.fxEndAlign != 0) until = Mathf.Max(c.fxDelay + 0.01f, until - Mathf.Max(0f, c.fxVisibleLife)); // #961 마지막 초승달이 난사 끝에 함께 사라지게 while (true) { if (DSUtil.CheckNull(shooter) || shooter.IsDead() || s_cancelSeq == seq) yield break; if (!SpawnMainFx(shooter, c)) yield break; n++; if (n > 1) FxRepeats++; if (c.fxRepeatInterval <= 0f) yield break; yield return new WaitForSeconds(c.fxRepeatInterval); if (Time.time - t0 + 0.01f >= until) { // #961 사본 1개 = 초승달 6개를 0~0.75 s 에 나눠 뿜어 약 1.05 s 보인다(편집 모드 실측) → 반복을 멈추는 이 순간 살아 있는 사본의 분출도 멈춰 이미 나온 초승달(수명 fxVisibleLife)만 끝까지 = 난사 끝에 함께 소멸 if (c.fxEndAlign != 0) foreach (var go in s_liveFx) if (go != null) foreach (var ps in go.GetComponentsInChildren(true)) { ps.Stop(false, ParticleSystemStopBehavior.StopEmitting); FxStops++; } FxRepeatLog = "정면 사본 " + n + "개 · " + (Time.time - t0).ToString("F2") + " s" + (c.fxEndAlign != 0 ? " · 분출 멈춤" : ""); yield break; } } } static bool SpawnMainFx(Actor shooter, WLShadowSlashJson c) { var p = Resources.Load(c.fxPrefab); if (p == null) { LastLog = "fx 프리팹 없음 " + c.fxPrefab; return false; } var fwd = shooter.transform.forward; fwd.y = 0f; if (fwd.sqrMagnitude < 1e-4f) fwd = Vector3.forward; fwd.Normalize(); var tgt = shooter.Get_Target(); float toTarget = -1f; if (c.fxAimTarget != 0 && !DSUtil.CheckNull(tgt) && !tgt.IsDead()) // #958 대상 쪽 { var d = tgt.Get_position() - shooter.Get_position(); d.y = 0f; if (d.sqrMagnitude > 1e-4f) { toTarget = d.magnitude; fwd = d / toTarget; } } var pcA = shooter as PCActor; float range = pcA != null ? pcA.WL_AttackRange : 0f; float dist = c.fxForward, scale = c.fxScale; if (c.fxRangeFrac > 0f && range > 0.05f) { dist = range * c.fxRangeFrac; if (toTarget > 0f && toTarget < dist) dist = toTarget; } // #958 공격 범위 가운데(대상이 더 가까우면 대상 자리) if (c.fxRangeWidth > 0f && range > 0.05f) scale = range * c.fxRangeWidth / Mathf.Max(0.1f, c.fxPrefabWidth); var pos = shooter.Get_Center_position() + fwd * dist + Vector3.up * c.fxYOffset; var go = Object.Instantiate(p, pos, Quaternion.LookRotation(fwd, Vector3.up)); go.transform.localScale = p.transform.localScale * scale; foreach (var ps in go.GetComponentsInChildren(true)) { var m = ps.main; m.scalingMode = ParticleSystemScalingMode.Hierarchy; ps.Play(true); } Object.Destroy(go, Mathf.Max(0.5f, c.fxLife)); FxShown++; s_liveFx.RemoveAll(x => x == null); s_liveFx.Add(go); // #961 구르기 캔슬 때 지울 목록 LastLog = "fx " + c.fxPrefab + " @" + pos.ToString("F2") + " ×" + scale.ToString("F3") + " · 거리 " + dist.ToString("F2") + " · 공격 범위 " + range.ToString("F2") + " · 대상 " + (toTarget > 0f ? toTarget.ToString("F2") + " m" : "없음"); FxSlashLog = LastLog; return true; } public static int SlashGhostCount; /// #959 난사 동안 지금 자세 잔상 — slashGhostInterval 마다 WLGhostStrike.LiveGhost(색 · 유지 · 소멸). static System.Collections.IEnumerator CoGhosts(Actor shooter, WLShadowSlashJson c, int seq) { float t0 = Time.time; var col = c.slashGhostColor != null && c.slashGhostColor.Length >= 4 ? c.slashGhostColor : null; float sec = SlashSec(c); while (Time.time - t0 < sec) { if (DSUtil.CheckNull(shooter) || shooter.IsDead() || s_cancelSeq == seq) yield break; if (WLGhostStrike.LiveGhost(shooter, col, c.slashGhostHold, c.slashGhostFade)) SlashGhostCount++; yield return new WaitForSeconds(Mathf.Max(0.02f, c.slashGhostInterval)); } } 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(이벤트 끔 · 배속) · 스킬 상태를 벗어나면 중단 · 끝에 이벤트·배속 원상. #960 휘두름마다 판정(slashHits). static System.Collections.IEnumerator CoSlash(Actor shooter, WLShadowSlashJson c, ProjectileBase pb, int seq) { var an = shooter.m_animation; if (an == null) yield break; bool canceled = false; // #961 구르기 캔슬 if (c.moveHold != 0) s_slashActor = shooter; // #961 난사 중 공격 전진 표 금지(AttackRootMotion.Gate) 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 { float sec = SlashSec(c); if (c.slashHitEven != 0 && c.slashHits > 0) // #962 판정 시각을 미리 고르게 { float span = Mathf.Max(0.05f, sec - Mathf.Max(0f, c.slashHitDelay)); for (int k = 0; k < c.slashHits; k++) hitQ.Enqueue(t0 + Mathf.Max(0f, c.slashHitDelay) + k * span / c.slashHits); scheduled = c.slashHits; } while (Time.time - t0 < sec) { if (DSUtil.CheckNull(shooter) || shooter.IsDead()) break; // CurAnim 조건은 뺀다(실측: 3회 만에 끊겨 원본 콤보 전이 Attack3→4(회전)·Jump 사슬이 이어졌다) if (c.dodgeCancel != 0 && shooter.Get_CurAnim() == eAnim.Dodge) { canceled = true; break; } // #961 구르기 = 캔슬(강제 재생보다 먼저 봐서 구르기 동작을 덮지 않는다) if (i < 0 || Time.time >= tickAt) { i++; tickAt = t0 + (i + 1) * interval; string st = c.slashStates[i % c.slashStates.Length]; cur = Animator.StringToHash(st); 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) { // #959 Play 실측: 틱 사이에 다른 상태(원본 콤보 AI 의 Attack3→4 · 원본 Jump 사슬 · exit 전이)가 끼어들어 3타 회전·4타 돌진(1.4 m)이 섞였다 → 매 프레임 지키고 어긋나면 그 프레임에 되돌린다 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 (canceled) // #961 구르기 캔슬 — 반복 · 잔상 멈춤 + 살아 있는 이펙트 지움 · 이동 잠금 풀기 · 남은 판정 없음 { s_cancelSeq = seq; DodgeCancels++; for (int k = 0; k < s_liveFx.Count; k++) if (s_liveFx[k] != null) Object.Destroy(s_liveFx[k]); s_liveFx.Clear(); hitQ.Clear(); if (shooter is PCActor cp) WLSkillCancel.EndHoldMove(cp, false); hitTimes.Append("(구르기 캔슬 " + (Time.time - t0).ToString("F2") + ")"); } // 끝 — 남은 판정 + (끊김으로 휘두름이 모자랐으면) 모자란 만큼 if (!canceled && 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 (ReferenceEquals(s_slashActor, shooter)) s_slashActor = null; if (!DSUtil.CheckNull(shooter) && shooter.m_animation != null) { shooter.m_animation.fireEvents = prevEvents; shooter.m_animation.speed = 1f; } } if (!canceled && c.endIdle != 0 && !DSUtil.CheckNull(shooter) && !shooter.IsDead()) { shooter.Play_Idle(); EndIdles++; } // #961 모션 끝 = 이펙트 끝 SlashLog = "난사 " + (i + 1) + "회 · " + (Time.time - t0).ToString("F2") + " s · 되돌림 " + SlashLeaks + (canceled ? " · 구르기 캔슬" : ""); if (c.slashHits > 0) SlashHitLog = "판정 " + hits + "회 · 맞힌 적 합 " + targets + " · 시각 " + hitTimes; if (!canceled && c.endHoldSeconds > 0f && !DSUtil.CheckNull(shooter) && !shooter.IsDead()) Runner.StartCoroutine(CoEndHold(shooter, c.endHoldSeconds)); // #961 } // #961 난사가 끝난 뒤 공격 금지 — 그동안 매 프레임 IsStop(자동 전투 공격 · 접근 멈춤 · 원본 2.1 s 콜백이 풀어도 다시 건다) · 하네스 공격 키는 IsHolding 을 본다 static int s_castSeq, s_cancelSeq = -1; static readonly System.Collections.Generic.List s_liveFx = new System.Collections.Generic.List(); public static int DodgeCancels, EndHolds, EndSkips, EndIdles, FxStops; static Actor s_holdActor, s_castShooter, s_slashActor; static float s_holdUntil; /// #961 이 PC 가 지금 주력 난사 중(moveHold 1) — AttackRootMotion.Gate 가 공격 전진 표를 막는다. public static bool IsSlashing(Actor a) { return a != null && ReferenceEquals(a, s_slashActor); } /// #961 원본 Skill_ShadowSlash 의 2.1 s 끝 콜백 훅 — 이 시전이 구르기로 캔슬됐으면 true(끝 Play_Idle · 대상 뒤 순간이동 · 연기 · 카메라 돌리기를 건너뜀 · 이동 허용은 원본이 먼저 부름). public static bool SkipEnd(Actor shooter) { if (Cfg.dodgeCancel == 0 || s_castSeq <= 0 || s_cancelSeq != s_castSeq || !ReferenceEquals(shooter, s_castShooter)) return false; EndSkips++; return true; } static PropertyInfo s_isStop; public static bool IsHolding(Actor a) { return a != null && ReferenceEquals(a, s_holdActor) && Time.time < s_holdUntil; } static void SetStop(Actor a, bool v) { try { if (s_isStop == null) s_isStop = typeof(Actor).GetProperty("IsStop", BindingFlags.Instance | BindingFlags.NonPublic | BindingFlags.Public); if (s_isStop != null && s_isStop.CanWrite) s_isStop.SetValue(a, v, null); } catch (System.Exception) { } } static System.Collections.IEnumerator CoEndHold(Actor a, float sec) { s_holdActor = a; s_holdUntil = Time.time + sec; EndHolds++; while (Time.time < s_holdUntil) { if (DSUtil.CheckNull(a) || a.IsDead() || !ReferenceEquals(s_holdActor, a)) yield break; if (a.Get_CurAnim() == eAnim.Dodge) break; // 구르기 = 대기 끝 SetStop(a, true); yield return null; } if (ReferenceEquals(s_holdActor, a)) { s_holdActor = null; if (!DSUtil.CheckNull(a) && !a.IsDead()) SetStop(a, false); } } public static int FxSlashes; public static string FxSlashLog = ""; static readonly System.Collections.Generic.List s_sock = new System.Collections.Generic.List(2); static readonly System.Collections.Generic.List s_tip = new System.Collections.Generic.List(2); /// #956 난사 1회마다 fxPrefab 의 작은 사본을 무기 날 자리에 — 소켓은 무기 메시가 달린 것만(비수 = w_bladedancer_l · _r · WLImpactRunner.SocketTipOf) · i 번째 난사는 소켓을 번갈아 · 추종은 WLShadowSlashFxFollow. static void SlashFx(Actor shooter, WLShadowSlashJson c, int i) { var pc = shooter as PCActor; if (pc == null || pc.tfs_weapon == null) { FxSlashLog = "PC 아님"; return; } if (i == 0 || s_sock.Count == 0) { s_sock.Clear(); s_tip.Clear(); foreach (var s in pc.tfs_weapon) { Vector3 tl; if (WL.Combat.Impact.WLImpactRunner.SocketTipOf(s, out tl) > 0.05f) { s_sock.Add(s); s_tip.Add(tl); } } if (s_sock.Count == 0) { FxSlashLog = "무기 소켓 없음"; return; } } int k = i % s_sock.Count; var sock = s_sock[k]; if (sock == null) return; var p = Resources.Load(c.fxPrefab); if (p == null) { FxSlashLog = "fx 프리팹 없음 " + c.fxPrefab; return; } var go = Object.Instantiate(p); go.transform.localScale = p.transform.localScale * c.fxSlashScale; var f = go.AddComponent(); f.Set(pc, sock, s_tip[k], c.fxSlashFraction, c.fxSlashYOffset, c.fxSlashFollow != 0); f.Apply(); foreach (var ps in go.GetComponentsInChildren(true)) { var m = ps.main; m.scalingMode = ParticleSystemScalingMode.Hierarchy; ps.Play(true); } Object.Destroy(go, Mathf.Max(0.1f, c.fxSlashLife)); FxSlashes++; FxSlashLog = "난사 fx " + FxSlashes + " · " + sock.name + " 날 " + s_tip[k].magnitude.ToString("F2") + " m · @" + go.transform.position.ToString("F2") + " ×" + c.fxSlashScale; } /// Actor 의 protected 켜기 함수(Model_OnOff · Weapon_OnOff(bool))를 true 로 부른다 — 원본 Co_Invisible 끝과 같은 호출(가상 함수는 PCActor 쪽이 불린다). static bool Call(ref MethodInfo m, string name, Actor a) { if (m == null) m = typeof(Actor).GetMethod(name, BindingFlags.Instance | BindingFlags.NonPublic | BindingFlags.Public, null, new[] { typeof(bool) }, null); if (m == null) { LastLog = "함수 없음 " + name; return false; } m.Invoke(a, new object[] { true }); return true; } /// Transform 을 처음 크기 × k 로 · 파티클 크기 모드 Hierarchy · 판정 상자는 keepBox 면 1/k 로(월드 크기 유지). static void Scale(Transform t, float k, bool keepBox) { if (t == null || k <= 0f) return; var b = t.GetComponent(); if (b == null) { if (Mathf.Approximately(k, 1f)) return; // 손댄 적 없고 배율 1 = 원본 그대로 b = t.gameObject.AddComponent(); b.scale = t.localScale; var bc0 = t.GetComponent(); if (bc0 != null) { b.hasBox = true; b.size = bc0.size; b.center = bc0.center; } foreach (var ps in t.GetComponentsInChildren(true)) { var m = ps.main; m.scalingMode = ParticleSystemScalingMode.Hierarchy; } } t.localScale = b.scale * k; if (b.hasBox) { var bc = t.GetComponent(); if (bc != null) { float inv = keepBox ? 1f / k : 1f; bc.size = b.size * inv; bc.center = b.center * inv; } } Scaled++; } } /// #943-⑧ 이 Transform(원본 스킬 풀 인스턴스)의 처음 크기 · 판정 상자 — 배율 누적 방지. 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 { Actor _pc; Transform _sock; Vector3 _tip; float _frac, _yOff; bool _follow; public void Set(Actor pc, Transform sock, Vector3 tipLocal, float frac, float yOff, bool follow) { _pc = pc; _sock = sock; _tip = tipLocal; _frac = frac; _yOff = yOff; _follow = follow; } public void Apply() { if (_sock == null) { _follow = false; return; } Vector3 hilt = _sock.position, tip = _sock.TransformPoint(_tip), dir = tip - hilt; dir.y = 0f; if (dir.sqrMagnitude < 1e-4f) { dir = _pc != null ? _pc.transform.forward : Vector3.forward; dir.y = 0f; if (dir.sqrMagnitude < 1e-4f) dir = Vector3.forward; } transform.position = Vector3.Lerp(hilt, tip, _frac) + Vector3.up * _yOff; transform.rotation = Quaternion.LookRotation(dir.normalized, Vector3.up); } void LateUpdate() { if (_follow) Apply(); } } }