// ───────────────────────────────────────────────────────────────────────────── // WLBladeStorm.cs — 대검 각성 「칼날 폭풍」(Skill_Whirlwind) WL 방식: 공격 자세로 회전 + 회오리 이펙트 반복 + 간격 피해 (#923 · 2026-10-18) // // PD 원문(#923): 「대검 각성 스킬 이펙트를 해당 에셋의 첨부한 스크린샷으로 교체하고, 플레이어 캐릭터가 6초 동안 공격 모션을 고정한 상태로 // 좌/우 반전하는 동안 이펙트를 반복 재생시켜줘. (플레이어는 이펙트 중앙에 배치된 상태로 마치 검을 회전하며 공격하는 형태로 동작해야 해) // → 6초가 지나면 자연스럽게 회전을 멈추고 정지해야하며, 구르기를 하거나 스킬 유지 시간이 만료 된 경우 스킬을 종료해야 해. // 이 스킬은 시전 중 움직일 수 있으며, 피격 범위에 있는 적은 0.5초 간격으로 피해를 받을 수 있어야 해.」 // // ■ 원리 — 원본 Skill_Whirlwind.Set 의 WL 훅 1줄(Take)이 넘겨받는다: 원본 Co_Update(3 초 · 판정 상자 0.3 초 켬/끔 · 끝에 대기)를 돌리지 않고 // ① 원본 이펙트(BlueYellow_Sword 등)와 판정 상자를 끈다 // ② 조이스틱 상태 2(원본과 같음 — 시전 중 조이스틱 이동 · 달리기 동작 없이 · 자동 전투는 WLSkillFollow 가 가까운 적을 따라감) // ③ 애니메이터 = poseState 를 poseNormalized 에 멈춘다(클립 이벤트 끔 · 다른 동작이 끼어들면 매 프레임 되돌림) // ④ PC 를 spinDegPerSec 로 수평 회전(LateUpdate · 이동 방향으로 돌리는 원본 조이스틱 Update · WLSkillFollow 보다 나중) // ⑤ 회오리 이펙트(fx)를 PC 발 중심에 fxRepeat 초마다 다시 띄운다(PC 를 따라감 · 중심 어긋남 fxOffset 보정) // ⑥ hitInterval 초마다 PC 발 기준 radius m + 적 몸 반지름 안의 모든 적에게 원본 한 대 피해(ProcessCollider · 명중 이펙트 · 피격 반응 그대로) // ⑦ duration 초가 지나면 판정 · 새 이펙트를 멈추고 stopSeconds 동안 회전 감속 → 투사체 끔 → 조이스틱 0 → 대기(원본 끝과 같은 순서) // 구르기 등으로 스킬 상태를 벗어나면 즉시 끝(애니메이터 · 조이스틱 상태 되돌림 · 원본은 투사체가 꺼지면 Co_Update 가 함께 멈춰 조이스틱 상태 2 를 // 되돌리는 줄에 닿지 않는 구조 — 코드 확인 · Play 미재현) // ■ #924 PD 「대검 각성 스킬 사용 중에는 이동 속도를 50% 줄이고, 이펙트가 몸을 완전히 가려지도록 이펙트로 캐릭터의 몸을 완전히 감싸줘. // 그리고 노란색으로 깜박이며 퍼지는 이펙트는 숨겨줘.」 // ⑧ moveMul = 이 PC 의 이동 속도 배율(원본 Actor.Get_MoveSpeed 끝 훅 1줄 → 조이스틱 이동 · WLSkillFollow 자동 따라가기 둘 다) // ⑨ wraps = 몸을 감싸는 반복 이펙트 목록(회전 검기 기둥 · Hyper Casual 연기 소용돌이 — 시작에 1 개씩 · PC 를 따라감) // ⑪ fadeSync 1 = 유지 시간이 지나면 새 이펙트 · 판정을 멈추고, 남은 이펙트 전부(감싸기 · 회오리)를 fadeSeconds 동안 함께 흐려 지우며(WLFxFade = 방출 멈춤 + // 입자 알파를 0 으로) 같은 시간 동안 회전을 감속 → 이펙트가 사라지는 그 순간 모션 끝(#924 PD 「이펙트 소멸과 모션 종료가 일치」) // — 남은 입자 수명으로 기다리면 보이지 않는 긴 수명 입자(회전 검기 기둥 최상위 이미터 5 초) 때문에 이펙트가 다 사라진 뒤에도 모션이 남았다(Play 캡처) // ⑩ hideBody = 시전 동안 PC 몸(무기 소켓 밖 SkinnedMeshRenderer — 팔 · 몸 · 얼굴 · 머리카락 · 머리 · 다리)을 숨긴다(검은 보임 · 끝 · 구르기에 되돌림) // — 회오리 · 칼날 기둥은 반투명/가산 이펙트라 겹쳐도 몸이 비쳐 「완전히 가림」이 안 됐다(Play 시안 캡처: 1~3 겹 · 반투명 토네이도 · 반투명 재질판) // · 노란 빛살 = 회오리 사본 WL_Fx_BladeStorm 의 floor_ring(시작색 0.98/0.96/0.75 · 초당 8 개 · 0.3 초 방사형 빛살)을 사본에서 끔 // ■ 값 = Resources/WL/WLBladeStorm.json (C45) · 끄기 = enabled 0 → 원본 동작 · 원본 수정 = Skill_Whirlwind.Set 1줄 · Actor.Get_MoveSpeed 1줄 // 🔴 Debug.LogError 금지(원본 ErrorLogHook 이 팝업을 띄운다). // ───────────────────────────────────────────────────────────────────────────── using System.Collections; using System.Collections.Generic; using UnityEngine; namespace WL.Combat.Weapon { [System.Serializable] public class WLBladeStormJson { public int enabled = 0; public int[] classes = new int[0]; // 비우면 모든 직업 public float duration = 6f; // 회전 · 판정 유지(초) public float hitInterval = 0.5f; // 피해 간격(초) public float firstHit = 0.1f; // 첫 피해 시각(초) public float radius = 2f; // 피해 반경(m · PC 발 기준 수평) + 적 몸 반지름 public string poseState = ""; // 멈출 공격 자세 상태(PC 애니메이터 0 층) public float poseNormalized = 0.3f; public float spinDegPerSec = 720f; public int spinSign = -1; // 1 = 위에서 볼 때 시계 방향 · -1 = 반시계 public float spinUpSeconds = 0.15f; // 시작 가속(초) public float stopSeconds = 0.5f; // 끝 감속(초) — 자연스럽게 멈춤 public string fx = ""; // 회오리 이펙트(Resources 경로) public float fxScale = 0.45f; public float fxRepeat = 0.5f; // 다시 띄우는 간격(초) public float fxLife = 1.2f; public float[] fxOffset = new float[3]; // 프리팹 소용돌이 중심의 원점 어긋남(배율 1 · m) — 빼서 PC 발이 정중앙에 public int hideOriginal = 1; public float moveMul = 1f; // #924 시전 중 이 PC 의 이동 속도 배율 public WLBladeStormWrap[] wraps = new WLBladeStormWrap[0]; // #924 몸을 감싸는 반복 이펙트들 public int hideBody = 0; // #924 1 = 시전 동안 몸(무기 소켓 밖 SkinnedMeshRenderer)을 숨김 public int fadeSync = 0; // #924 1 = 유지 시간 뒤 남은 이펙트를 fadeSeconds 동안 흐려 지우며 같은 시간 감속 · 그 끝에 모션 끝 public float fadeSeconds = 1.5f; public int fadeDimAdditive = 0; // #934 1 = 흐려 지울 때 가산(더하기) 재질 입자는 색도 함께 낮춤(알파만으론 회오리 가산 심이 밝은 채 남던 것) // #929 PD 「대검 각성 스킬에 피격 된 적에게 히트 이펙트를 발생시켜 줘.」 — 판정마다 맞은 적 몸 중심에(시전자 → 적 방향) public string hitFx = ""; public float hitFxScale = 0.3f; public float hitFxLife = 1.2f; // #930 PD 「캐릭터의 색상이 푸른색으로 변하지 않게 해줘.」 — 시전 동안 PC 몸 · 무기 재질을 사본으로 바꿔 렌더 순서를 이만큼 뒤로(툰 2000 → 3050 = 반투명 이펙트 3000 뒤에 그림) // → 감싸기 · 회오리 · 칼날 이펙트가 몸 위를 덮어 몸 색이 물들지 않는다(이펙트는 몸 둘레에 그대로) · 끝 · 끊김에 원래 재질로 되돌리고 사본 파기 · 0 = 끔 public int bodyQueueAdd = 0; // #932 PD 「대검 각성 스킬 시전 때 캐릭터의 색상이 바뀌지 않도록 할 수 없을까? (적어도 캐릭터의 상체가 좀 더 잘 보였으면 좋겠어.)」 — 이름이 이 접두어로 시작하는 렌더러만 뒤로(빈 배열 = 전부) · 무기 소켓 안 렌더러는 bodyOnTopWeapon 1 일 때만 public string[] bodyOnTopNames = new string[0]; public int bodyOnTopWeapon = 0; public string note = ""; } [System.Serializable] public class WLBladeStormWrap { public string fx = ""; // Resources 경로(반복 재생 프리팹) public float scale = 0.45f; public float height = 0f; // PC 발 기준 높이(m) // #927 위에서 비스듬히 보는 카메라에선 몸 가운데 깊이의 반투명 이펙트가 몸 앞면에 가려 몸이 이펙트 앞에 보인다 → // 카메라 쪽 시선 방향으로 이만큼(m) 당겨 화면 위치는 그대로 두고 깊이만 몸보다 앞에(0 = 당기지 않음) public float towardCamera = 0f; // #958 PD 「대검 각성 회오리가 하얗게 뭉개져 보임 → 회오리 느낌이 나도록 회전 방향의 선이나 그라데이션이 보이게(참고 이미지)」 — 층별 재질 값 덮기 public WLFxLayerOverride[] layers = new WLFxLayerOverride[0]; } /// #958 이펙트 렌더러별 재질 값 덮기(프로퍼티 블록 · 재질 인스턴스 0) — names = 렌더러 이름(| 로 여럿 · * = 나머지 전부) · /// emission ≥ 0 = _Emission(HS 셰이더 발광 배율 · 원본 5 는 블룸 없는 화면에서 흰색 포화 = 「하얗게 뭉개짐」 편집 모드 실측) · tint = _Color(r,g,b) [System.Serializable] public class WLFxLayerOverride { public string names = "*"; public float emission = -1f; public float[] tint = new float[0]; } public static class WLBladeStorm { public const string ResourcesPath = "WL/WLBladeStorm"; public static int Taken, Ticks, Hits, FxSpawned, Ends, Aborts, Errors, LayerOverrides; public static float LastSpinTotal, LastSeconds, LastFade; public static int HitFxShown, BodyOnTop; public static string LastLog = ""; static WLBladeStormJson s_cfg; static Actor s_slowActor; static float s_slowMul = 1f; /// #924 원본 Actor.Get_MoveSpeed 끝 훅 — 칼날 폭풍 중인 그 PC 만 moveMul(그 밖 = 1). public static float MoveMul(Actor a) { return ReferenceEquals(a, s_slowActor) ? s_slowMul : 1f; } internal static void SetSlow(Actor a, float mul) { s_slowActor = a; s_slowMul = Mathf.Clamp(mul, 0.05f, 1f); } internal static void ClearSlow(Actor a) { if (ReferenceEquals(a, s_slowActor)) { s_slowActor = null; s_slowMul = 1f; } } internal static WLBladeStormJson Cfg { get { if (s_cfg != null) return s_cfg; s_cfg = new WLBladeStormJson(); var ta = Resources.Load(ResourcesPath); if (ta == null) { LastLog = "설정 없음 " + ResourcesPath; return s_cfg; } try { JsonUtility.FromJsonOverwrite(ta.text, s_cfg); } catch (System.Exception ex) { s_cfg.enabled = 0; LastLog = "설정 파싱 실패 — " + ex.Message; } return s_cfg; } } [RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.AfterSceneLoad)] static void Boot() { s_cfg = null; s_slowActor = null; s_slowMul = 1f; } /// 원본 Skill_Whirlwind.Set 훅 — true 면 WL 이 이 투사체를 맡는다(원본 Co_Update 건너뜀). public static bool Take(ProjectileBase pb, GameObject hitChecker) { try { var c = Cfg; if (c.enabled == 0 || pb == null) return false; var data = pb.Get_ProjectTileData(); var pc = data != null ? data.shooter as PCActor : null; if (pc == null || !pc.IsMainPC()) return false; if (c.classes != null && c.classes.Length > 0 && System.Array.IndexOf(c.classes, pc.Get_ID()) < 0) return false; var run = pb.GetComponent(); if (run == null) run = pb.gameObject.AddComponent(); run.Begin(pb, pc, hitChecker, c); Taken++; LastLog = "넘겨받음 #" + Taken; return true; } catch (System.Exception ex) { Errors++; LastLog = "넘겨받기 실패 — " + ex.Message; return false; } } } [DefaultExecutionOrder(1000)] // PC 회전을 이동 방향 회전(원본 조이스틱 · WLSkillFollow 의 Update)보다 나중에 internal sealed class WLBladeStormRunner : MonoBehaviour { ProjectileBase _pb; PCActor _pc; Animator _an; GameObject _checker; WLBladeStormJson _c; bool _running, _animOwned; float _yaw, _spin, _spinTotal, _t0; Coroutine _co; readonly List _fx = new List(); readonly List _hidden = new List(); readonly List _wraps = new List(); readonly List _wrapH = new List(), _wrapT = new List(); readonly List _fxEnd = new List(); // 회오리 인스턴스가 지워질 시각(_fx 와 같은 순서) readonly List _body = new List(); readonly List _qR = new List(); readonly List _qM = new List(); readonly List _qNew = new List(); static GameObject s_prefab; static string s_prefabPath; static readonly Dictionary s_wrapPrefabs = new Dictionary(); public void Begin(ProjectileBase pb, PCActor pc, GameObject checker, WLBladeStormJson c) { if (_running) End(false, false); _pb = pb; _pc = pc; _an = pc.m_animation; _checker = checker; _c = c; if (_checker != null) _checker.SetActive(false); if (c.hideOriginal != 0) Hide(); pc.Set_JoystickStatus(2); if (c.moveMul < 0.999f) WLBladeStorm.SetSlow(pc, c.moveMul); SpawnWraps(); if (c.hideBody != 0) HideBody(); BodyOnTop(); _yaw = pc.transform.eulerAngles.y; _spin = 0f; _spinTotal = 0f; _t0 = Time.time; _running = true; Pose(true); _co = StartCoroutine(Co()); } IEnumerator Co() { var c = _c; float nextHit = Mathf.Max(0f, c.firstHit), nextFx = 0f; while (Time.time - _t0 < c.duration) { if (Broken()) { _co = null; End(false, true); yield break; } float t = Time.time - _t0; _spin = Mathf.Max(0f, c.spinDegPerSec) * Mathf.Clamp01(t / Mathf.Max(0.01f, c.spinUpSeconds)); Pose(false); if (t >= nextFx) { SpawnFx(); nextFx += Mathf.Max(0.05f, c.fxRepeat); } if (t >= nextHit) { Hit(); nextHit += Mathf.Max(0.05f, c.hitInterval); } yield return null; } // 자연스럽게 멈춤 — 판정 · 새 이펙트 없이 자세를 둔 채 회전만 감속 · 감싸기 이펙트는 방출만 멈춰 남은 입자가 제 수명대로 사라짐 // fadeSync 1 = 남은 이펙트(감싸기 · 회오리)를 fadeSeconds 동안 함께 흐려 지우고(WLFxFade) 같은 시간 동안 감속 → 그 끝에 모션 끝 float dur = Mathf.Max(0.01f, c.stopSeconds); if (c.fadeSync != 0) { dur = Mathf.Max(0.05f, c.fadeSeconds); for (int i = 0; i < _wraps.Count; i++) if (_wraps[i] != null) WLFxFade.Attach(_wraps[i], 0f, dur, c.fadeDimAdditive != 0); for (int i = 0; i < _fx.Count; i++) if (_fx[i] != null) WLFxFade.Attach(_fx[i], 0f, Mathf.Clamp(_fxEnd[i] - Time.time, 0.05f, dur)); } else StopWraps(); float s0 = _spin, f0 = Time.time; WLBladeStorm.LastFade = dur; while (Time.time - f0 < dur) { if (Broken()) { _co = null; End(false, true); yield break; } float k = 1f - Mathf.Clamp01((Time.time - f0) / dur); _spin = s0 * k * k; Pose(false); yield return null; } _spin = 0f; _co = null; End(true, true); } bool Broken() { return _pc == null || _pc.IsDead() || _pc.Get_CurAnim() != eAnim.Skill || _pb == null || !_pb.gameObject.activeInHierarchy; } void LateUpdate() { if (!_running) return; if (Broken()) { End(false, true); return; } float d = _c.spinSign * _spin * Time.deltaTime; _yaw += d; _spinTotal += Mathf.Abs(d); _pc.transform.rotation = Quaternion.Euler(0f, _yaw, 0f); var p = _pc.Get_position(); transform.position = p; // 원본도 시전자 위치로 옮겼다(판정 상자는 끔) var at = p - FxOffset(); for (int i = _fx.Count - 1; i >= 0; i--) { var go = _fx[i]; if (go == null) { _fx.RemoveAt(i); _fxEnd.RemoveAt(i); continue; } go.transform.position = at; } var cam = Camera.main; for (int i = _wraps.Count - 1; i >= 0; i--) { var w = _wraps[i]; if (w == null) { _wraps.RemoveAt(i); _wrapH.RemoveAt(i); _wrapT.RemoveAt(i); continue; } var wp = p + Vector3.up * _wrapH[i]; if (_wrapT[i] != 0f && cam != null) wp += (cam.transform.position - wp).normalized * _wrapT[i]; // #927 시선 방향으로 당김 w.transform.position = wp; } } /// 몸 숨김 — 무기 소켓 밖의 켜진 SkinnedMeshRenderer(검 · 방패 등 소켓 무기는 그대로 보임). void HideBody() { ShowBody(); var sockets = _pc.tfs_weapon; foreach (var r in _pc.GetComponentsInChildren(false)) { if (r == null || !r.enabled) continue; bool inSocket = false; if (sockets != null) foreach (var s in sockets) if (s != null && r.transform.IsChildOf(s)) { inSocket = true; break; } if (inSocket) continue; r.enabled = false; _body.Add(r); } } void ShowBody() { for (int i = 0; i < _body.Count; i++) if (_body[i] != null) _body[i].enabled = true; _body.Clear(); } /// #930 몸 · 무기 렌더러(파티클 · 꼬리 제외) 재질을 사본으로 바꿔 renderQueue + bodyQueueAdd — 반투명 이펙트 뒤에 그려 몸 색이 이펙트에 물들지 않게. void BodyOnTop() { BodyRestore(); if (_c.bodyQueueAdd <= 0) return; foreach (var r in _pc.GetComponentsInChildren(false)) { if (r == null || !r.enabled || r is ParticleSystemRenderer || r is TrailRenderer || r is LineRenderer) continue; if (!OnTopTarget(r)) continue; var src = r.sharedMaterials; var dst = new Material[src.Length]; for (int i = 0; i < src.Length; i++) { if (src[i] == null) continue; var m = new Material(src[i]) { name = src[i].name }; m.renderQueue = src[i].renderQueue + _c.bodyQueueAdd; dst[i] = m; _qNew.Add(m); } _qR.Add(r); _qM.Add(src); r.sharedMaterials = dst; } WLBladeStorm.BodyOnTop++; } bool OnTopTarget(Renderer r) { var names = _c.bodyOnTopNames; if (names == null || names.Length == 0) return true; var sockets = _pc.tfs_weapon; if (sockets != null) foreach (var s in sockets) if (s != null && r.transform.IsChildOf(s)) return _c.bodyOnTopWeapon != 0; foreach (var n in names) if (!string.IsNullOrEmpty(n) && r.name.StartsWith(n, System.StringComparison.OrdinalIgnoreCase)) return true; return false; } void BodyRestore() { for (int i = 0; i < _qR.Count; i++) if (_qR[i] != null) _qR[i].sharedMaterials = _qM[i]; for (int i = 0; i < _qNew.Count; i++) if (_qNew[i] != null) Destroy(_qNew[i]); _qR.Clear(); _qM.Clear(); _qNew.Clear(); } static readonly MaterialPropertyBlock s_mpb = new MaterialPropertyBlock(); /// #958 층별 _Emission · _Color 덮기 — 덮은 렌더러 수. static int ApplyLayers(GameObject go, WLFxLayerOverride[] layers) { if (go == null || layers == null || layers.Length == 0) return 0; int n = 0; foreach (var r in go.GetComponentsInChildren(true)) { var m = r.sharedMaterial; if (m == null) continue; WLFxLayerOverride hit = null, def = null; foreach (var L in layers) { if (L == null || string.IsNullOrEmpty(L.names)) continue; if (L.names == "*") { if (def == null) def = L; continue; } if (System.Array.IndexOf(L.names.Split('|'), r.name) >= 0) { hit = L; break; } } var o = hit ?? def; if (o == null) continue; r.GetPropertyBlock(s_mpb); if (o.emission >= 0f && m.HasProperty("_Emission")) s_mpb.SetFloat("_Emission", o.emission); if (o.tint != null && o.tint.Length >= 3 && m.HasProperty("_Color")) s_mpb.SetColor("_Color", new Color(o.tint[0], o.tint[1], o.tint[2], 1f)); r.SetPropertyBlock(s_mpb); n++; } return n; } void SpawnWraps() { if (_c.wraps == null) return; foreach (var w in _c.wraps) { if (w == null || string.IsNullOrEmpty(w.fx)) continue; GameObject prefab; if (!s_wrapPrefabs.TryGetValue(w.fx, out prefab) || prefab == null) { prefab = Resources.Load(w.fx); s_wrapPrefabs[w.fx] = prefab; } if (prefab == null) { WLBladeStorm.LastLog = "감싸기 이펙트 없음 " + w.fx; continue; } var go = Instantiate(prefab, _pc.Get_position() + Vector3.up * w.height, prefab.transform.rotation); go.transform.localScale = prefab.transform.localScale * Mathf.Max(0.01f, w.scale); int ov = ApplyLayers(go, w.layers); if (ov > 0) WLBladeStorm.LayerOverrides += ov; // #958 _wraps.Add(go); _wrapH.Add(w.height); _wrapT.Add(w.towardCamera); WLBladeStorm.FxSpawned++; } } /// 감싸기 이펙트 — 방출만 멈춘다(남은 입자는 제 수명대로 · 다 사라진 뒤 지움) · 가장 긴 남은 수명(초)을 돌려준다. float StopWraps() { float life = 0f; for (int i = 0; i < _wraps.Count; i++) { var w = _wraps[i]; if (w == null) continue; float l = 0f; foreach (var ps in w.GetComponentsInChildren(true)) { ps.Stop(true, ParticleSystemStopBehavior.StopEmitting); l = Mathf.Max(l, ps.main.startLifetime.constantMax); } Destroy(w, l + 0.2f); life = Mathf.Max(life, l); } return life; } /// 공격 자세 고정 — 다른 동작(원본 시전 다음 동작 등)이 끼어들면 되돌린다 · 클립 이벤트(궤적 · 판정)는 끈다. void Pose(bool force) { if (_an == null || string.IsNullOrEmpty(_c.poseState)) return; if (!_animOwned) { _an.fireEvents = false; _animOwned = true; } var si = _an.GetCurrentAnimatorStateInfo(0); if (force || _an.IsInTransition(0) || !si.IsName(_c.poseState) || Mathf.Abs(si.normalizedTime - _c.poseNormalized) > 0.01f) { _an.Play(_c.poseState, 0, _c.poseNormalized); if (force) { _an.speed = 0f; _an.Update(0f); } } if (_an.speed != 0f) _an.speed = 0f; } Vector3 FxOffset() { var a = _c.fxOffset; return a != null && a.Length >= 3 ? new Vector3(a[0], a[1], a[2]) * Mathf.Max(0.01f, _c.fxScale) : Vector3.zero; } void SpawnFx() { if (string.IsNullOrEmpty(_c.fx)) return; if (s_prefab == null || s_prefabPath != _c.fx) { s_prefab = Resources.Load(_c.fx); s_prefabPath = _c.fx; } if (s_prefab == null) { WLBladeStorm.LastLog = "이펙트 없음 " + _c.fx; return; } var go = Instantiate(s_prefab, _pc.Get_position() - FxOffset(), s_prefab.transform.rotation); go.transform.localScale = s_prefab.transform.localScale * Mathf.Max(0.01f, _c.fxScale); Destroy(go, Mathf.Max(0.1f, _c.fxLife)); _fx.Add(go); _fxEnd.Add(Time.time + Mathf.Max(0.1f, _c.fxLife)); WLBladeStorm.FxSpawned++; } /// PC 발 기준 radius + 적 몸 반지름 안의 모든 적에게 원본 한 대 피해(속사 레이저 WLRapidShot.Laser 와 같은 방식). void Hit() { WLBladeStorm.Ticks++; var info = ActorInfo.Ins; if (info == null || _pb == null) return; var origin = _pc.Get_position(); int n = 0; var list = info.Get_EnemyActors(_pc.IsEnemy(), origin, _c.radius + 3f); if (list == null) return; 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 (v.magnitude > _c.radius + WL.Combat.WLCombatMotionSettings.BodyRadius(a)) continue; var col = a.GetComponent(); if (col == null || !col.enabled) continue; if (_pb.ProcessCollider(col, false)) { n++; HitFx(a, origin); } } info.Release_Actors(list); WLBladeStorm.Hits += n; } static readonly Dictionary s_hitPrefabs = new Dictionary(); void HitFx(Actor a, Vector3 from) { if (string.IsNullOrEmpty(_c.hitFx) || a == null) return; GameObject prefab; if (!s_hitPrefabs.TryGetValue(_c.hitFx, out prefab) || prefab == null) { prefab = Resources.Load(_c.hitFx); s_hitPrefabs[_c.hitFx] = prefab; } if (prefab == null) { WLBladeStorm.LastLog = "히트 이펙트 없음 " + _c.hitFx; return; } var at = a.Get_Center_position(); var d = at - from; d.y = 0f; if (d.sqrMagnitude < 1e-4f) d = _pc.transform.forward; at += WL.Combat.Impact.WLImpactFx.TowardCamera(a); // #941 몸 앞(카메라 쪽) var go = Instantiate(prefab, at, Quaternion.LookRotation(d) * prefab.transform.rotation); foreach (var ps in go.GetComponentsInChildren(true)) { var m = ps.main; m.scalingMode = ParticleSystemScalingMode.Hierarchy; } go.transform.localScale = prefab.transform.localScale * Mathf.Max(0.01f, _c.hitFxScale); Destroy(go, Mathf.Max(0.1f, _c.hitFxLife)); WLBladeStorm.HitFxShown++; } void Hide() { _hidden.Clear(); foreach (var ps in GetComponentsInChildren(true)) ps.Stop(true, ParticleSystemStopBehavior.StopEmittingAndClear); foreach (var r in GetComponentsInChildren(true)) { if (!r.enabled) continue; r.enabled = false; _hidden.Add(r); } } /// 끝 — natural = 유지 시간 만료(감속 뒤) · offProjectile = 투사체를 끈다(원본 끝 순서: 끔 → 조이스틱 0 → 대기). void End(bool natural, bool offProjectile) { if (!_running) return; _running = false; if (_co != null) { StopCoroutine(_co); _co = null; } if (_animOwned && _an != null) { _an.speed = 1f; _an.fireEvents = true; } _animOwned = false; for (int i = 0; i < _fx.Count; i++) if (_fx[i] != null) foreach (var ps in _fx[i].GetComponentsInChildren(true)) ps.Stop(true, ParticleSystemStopBehavior.StopEmitting); // 남은 입자는 제 수명대로 사라짐 _fx.Clear(); _fxEnd.Clear(); StopWraps(); _wraps.Clear(); _wrapH.Clear(); _wrapT.Clear(); ShowBody(); BodyRestore(); WLBladeStorm.ClearSlow(_pc); foreach (var r in _hidden) if (r != null) r.enabled = true; // 풀 재사용 · enabled 0 일 때 원본 그대로 _hidden.Clear(); WLBladeStorm.LastSpinTotal = _spinTotal; WLBladeStorm.LastSeconds = Time.time - _t0; if (natural) WLBladeStorm.Ends++; else WLBladeStorm.Aborts++; var pc = _pc; if (offProjectile && _pb != null && _pb.gameObject.activeInHierarchy) WLSlashFlurry.Off(_pb); if (pc != null) { pc.Set_JoystickStatus(3); if (natural && !pc.IsDead() && pc.Get_CurAnim() == eAnim.Skill) pc.Play_Idle(); } } void OnDisable() { if (_running) End(false, false); } } }