462 lines
40 KiB
C#
462 lines
40 KiB
C#
// ─────────────────────────────────────────────────────────────────────────────
|
||
// 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(); }
|
||
}
|
||
}
|