Project_WL/Assets/WL/Combat/Weapon/WLShadowSlash.cs

462 lines
40 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// ─────────────────────────────────────────────────────────────────────────────
// 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 smokeHide; // #965 PD 「비수 주력스킬 사용 때 캐릭터 주위에 생기는 독 연기 이펙트는 제거」 — 1 = 연기(시작 · 끝 순간이동 자리) 렌더러를 끈다(풀 인스턴스라 매 시전 값대로 맞춤)
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 콜백에서 대기 · 그사이 마지막 휘두름이 이어졌다)
// #970 PD 「비수 주력 스킬 사용 시 적이 갑자기 순간이동 하는 현상 … 순간이동하지 못하게」 — 원인 = 원본 끝 콜백이 PC 를 대상 뒤 1.5 m 로 옮겨(Set_Warp) 카메라가 따라가는 화면에서 적이 반대편으로 튐 ·
// 0 = 끝 순간이동과 그때의 카메라 돌리기(RotateCam_TargetBack)를 건너뛴다(PC 는 난사한 자리 그대로) · 1 = 원본
public int endWarp = 1;
}
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<TextAsset>(ResourcesPath);
if (ta == null) { LastLog = "설정 없음 " + ResourcesPath; return s_cfg; }
try { JsonUtility.FromJsonOverwrite(ta.text, s_cfg); } catch (System.Exception ex) { LastLog = "설정 파싱 실패 — " + ex.Message; }
return s_cfg;
}
}
[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; }
/// <summary>원본 Skill_ShadowSlash.Set 훅(Set_Invisible 바로 뒤 1줄) — 캐릭터 보이기 · 베기 · 연기 크기 배율.</summary>
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);
foreach (var rd in skill.go_invisibleEffect.GetComponentsInChildren<Renderer>(true)) rd.enabled = c.smokeHide == 0; // #965 독 연기 끄기(원본이 끝에 다시 켜도 렌더러는 꺼진 채)
}
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<Renderer>(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<string, float> s_fxDur = new System.Collections.Generic.Dictionary<string, float>();
/// <summary>#962 이펙트 프리팹 1회가 보이는 시간 — 입자계마다 (시작 지연 + 마지막 분출 시각 + 수명 최댓값)의 최댓값(반복 입자계 · 끝없는 분출은 빼고 · 비율 분출은 duration). WL_Fx_DaggerMulti = 0.75 + 0.3 = 1.05 s.</summary>
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<GameObject>(path);
if (p != null)
foreach (var ps in p.GetComponentsInChildren<ParticleSystem>(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;
}
/// <summary>#962 난사 시간 — slashMatchFx 면 fxDelay + 사본 1개가 보이는 시간(없으면 slashSeconds).</summary>
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;
}
/// <summary>#962 원본 Skill_ShadowSlash.Set 의 Set_Invisible(2.1 s) 인자 훅 — invisibleMatch 면 난사 시간으로(이동 금지 · 무적 · 끝 대기 · 대상 뒤 순간이동이 모션 끝에 함께).</summary>
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<WLShadowSlashRunner>(); } return s_runner; } }
public static int FxRepeats; public static string FxRepeatLog = "";
/// <summary>#954 원본 베기가 켜지는 시각(fxDelay)에 WL 사본(녹색 난사)을 PC 정면 fxForward 에 띄운다(PC 방향 · fxLife 뒤 파괴). #959 fxRepeatInterval > 0 이면 fxRepeatSeconds(0 = slashSeconds)까지 되풀이 — 매번 그때 PC 위치·대상 방향.</summary>
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<ParticleSystem>(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<GameObject>(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<ParticleSystem>(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;
/// <summary>#959 난사 동안 지금 자세 잔상 — slashGhostInterval 마다 WLGhostStrike.LiveGhost(색 · 유지 · 소멸).</summary>
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 = "";
/// <summary>#960 원본 베기 판정(go_effect · Projectile_StopAndColliderControl Loop 6 · 0.1 s)을 slashHits > 0 이면 끄고(Loop 0) · 0 이면 처음 값으로(풀 인스턴스라 값이 남는다).</summary>
static void HitMode(Skill_ShadowSlash skill, WLShadowSlashJson c)
{
var ge = skill != null ? skill.go_effect : null; if (ge == null) return;
var mark = ge.GetComponent<WLShadowSlashHits>();
if (c.slashHits > 0)
{
if (mark == null) { mark = ge.gameObject.AddComponent<WLShadowSlashHits>(); 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); }
}
/// <summary>#960 휘두름 1회 판정 — PC 발 기준 범위 + 적 몸 반지름 · 대상 쪽 ±각 안의 적마다 원본 한 대 피해(대검 각성 WLBladeStorm.Hit 와 같은 방식) · 맞은 수.</summary>
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<Collider>(); 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 = "";
/// <summary>#955 제자리 난사 — slashSeconds 동안 slashStates 를 번갈아 CrossFade(이벤트 끔 · 배속) · 스킬 상태를 벗어나면 중단 · 끝에 이벤트·배속 원상. #960 휘두름마다 판정(slashHits).</summary>
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<float>(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<GameObject> s_liveFx = new System.Collections.Generic.List<GameObject>();
public static int DodgeCancels, EndHolds, EndSkips, EndIdles, FxStops; static Actor s_holdActor, s_castShooter, s_slashActor; static float s_holdUntil;
/// <summary>#961 이 PC 가 지금 주력 난사 중(moveHold 1) — AttackRootMotion.Gate 가 공격 전진 표를 막는다.</summary>
public static bool IsSlashing(Actor a) { return a != null && ReferenceEquals(a, s_slashActor); }
/// <summary>#961 원본 Skill_ShadowSlash 의 2.1 s 끝 콜백 훅 — 이 시전이 구르기로 캔슬됐으면 true(끝 Play_Idle · 대상 뒤 순간이동 · 연기 · 카메라 돌리기를 건너뜀 · 이동 허용은 원본이 먼저 부름).</summary>
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;
}
public static int EndWarps, EndWarpSkips;
/// <summary>#970 원본 Skill_ShadowSlash 끝 콜백 훅 — false = 대상 뒤 순간이동 · 그때의 카메라 돌리기를 건너뜀(endWarp 0).</summary>
public static bool EndWarp()
{
var c = Cfg;
if (c.enabled == 0 || c.endWarp != 0) { EndWarps++; return true; }
EndWarpSkips++; return false;
}
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<Transform> s_sock = new System.Collections.Generic.List<Transform>(2);
static readonly System.Collections.Generic.List<Vector3> s_tip = new System.Collections.Generic.List<Vector3>(2);
/// <summary>#956 난사 1회마다 fxPrefab 의 작은 사본을 무기 날 자리에 — 소켓은 무기 메시가 달린 것만(비수 = w_bladedancer_l · _r · WLImpactRunner.SocketTipOf) · i 번째 난사는 소켓을 번갈아 · 추종은 WLShadowSlashFxFollow.</summary>
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<GameObject>(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<WLShadowSlashFxFollow>(); f.Set(pc, sock, s_tip[k], c.fxSlashFraction, c.fxSlashYOffset, c.fxSlashFollow != 0); f.Apply();
foreach (var ps in go.GetComponentsInChildren<ParticleSystem>(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;
}
/// <summary>Actor 의 protected 켜기 함수(Model_OnOff · Weapon_OnOff(bool))를 true 로 부른다 — 원본 Co_Invisible 끝과 같은 호출(가상 함수는 PCActor 쪽이 불린다).</summary>
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;
}
/// <summary>Transform 을 처음 크기 × k 로 · 파티클 크기 모드 Hierarchy · 판정 상자는 keepBox 면 1/k 로(월드 크기 유지).</summary>
static void Scale(Transform t, float k, bool keepBox)
{
if (t == null || k <= 0f) return;
var b = t.GetComponent<WLShadowSlashBase>();
if (b == null)
{
if (Mathf.Approximately(k, 1f)) return; // 손댄 적 없고 배율 1 = 원본 그대로
b = t.gameObject.AddComponent<WLShadowSlashBase>();
b.scale = t.localScale;
var bc0 = t.GetComponent<BoxCollider>();
if (bc0 != null) { b.hasBox = true; b.size = bc0.size; b.center = bc0.center; }
foreach (var ps in t.GetComponentsInChildren<ParticleSystem>(true)) { var m = ps.main; m.scalingMode = ParticleSystemScalingMode.Hierarchy; }
}
t.localScale = b.scale * k;
if (b.hasBox)
{
var bc = t.GetComponent<BoxCollider>();
if (bc != null) { float inv = keepBox ? 1f / k : 1f; bc.size = b.size * inv; bc.center = b.center * inv; }
}
Scaled++;
}
}
/// <summary>#943-⑧ 이 Transform(원본 스킬 풀 인스턴스)의 처음 크기 · 판정 상자 — 배율 누적 방지.</summary>
public sealed class WLShadowSlashBase : MonoBehaviour { public Vector3 scale, size, center; public bool hasBox; }
/// <summary>#954 코루틴 숙주(씬 밖 · 숨김).</summary>
public sealed class WLShadowSlashRunner : MonoBehaviour { }
/// <summary>#960 원본 베기 판정을 끈 표식 — 처음 Loop · LoopTime(풀 인스턴스에 남으므로 slashHits 0 으로 되돌릴 때 쓴다).</summary>
public sealed class WLShadowSlashHits : MonoBehaviour { public int loop0; public float loopTime0; }
/// <summary>#956 난사 사본 추종 — 자리 = 소켓(손잡이) → 날 끝 사이 fraction + 위 yOffset · 방향 = 날 방향의 수평 성분(없으면 PC 정면) · follow 면 매 프레임(LateUpdate · 애니메이션 평가 뒤) 다시 맞춘다.</summary>
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(); }
}
}