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

451 lines
29 KiB
C#

// ─────────────────────────────────────────────────────────────────────────────
// 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];
}
/// <summary>#958 이펙트 렌더러별 재질 값 덮기(프로퍼티 블록 · 재질 인스턴스 0) — names = 렌더러 이름(| 로 여럿 · * = 나머지 전부) ·
/// emission ≥ 0 = _Emission(HS 셰이더 발광 배율 · 원본 5 는 블룸 없는 화면에서 흰색 포화 = 「하얗게 뭉개짐」 편집 모드 실측) · tint = _Color(r,g,b)</summary>
[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;
/// <summary>#924 원본 Actor.Get_MoveSpeed 끝 훅 — 칼날 폭풍 중인 그 PC 만 moveMul(그 밖 = 1).</summary>
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<TextAsset>(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; }
/// <summary>원본 Skill_Whirlwind.Set 훅 — true 면 WL 이 이 투사체를 맡는다(원본 Co_Update 건너뜀).</summary>
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<WLBladeStormRunner>();
if (run == null) run = pb.gameObject.AddComponent<WLBladeStormRunner>();
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<GameObject> _fx = new List<GameObject>();
readonly List<Renderer> _hidden = new List<Renderer>();
readonly List<GameObject> _wraps = new List<GameObject>(); readonly List<float> _wrapH = new List<float>(), _wrapT = new List<float>();
readonly List<float> _fxEnd = new List<float>(); // 회오리 인스턴스가 지워질 시각(_fx 와 같은 순서)
readonly List<Renderer> _body = new List<Renderer>();
readonly List<Renderer> _qR = new List<Renderer>(); readonly List<Material[]> _qM = new List<Material[]>(); readonly List<Material> _qNew = new List<Material>();
static GameObject s_prefab; static string s_prefabPath;
static readonly Dictionary<string, GameObject> s_wrapPrefabs = new Dictionary<string, GameObject>();
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;
}
}
/// <summary>몸 숨김 — 무기 소켓 밖의 켜진 SkinnedMeshRenderer(검 · 방패 등 소켓 무기는 그대로 보임).</summary>
void HideBody()
{
ShowBody();
var sockets = _pc.tfs_weapon;
foreach (var r in _pc.GetComponentsInChildren<SkinnedMeshRenderer>(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(); }
/// <summary>#930 몸 · 무기 렌더러(파티클 · 꼬리 제외) 재질을 사본으로 바꿔 renderQueue + bodyQueueAdd — 반투명 이펙트 뒤에 그려 몸 색이 이펙트에 물들지 않게.</summary>
void BodyOnTop()
{
BodyRestore();
if (_c.bodyQueueAdd <= 0) return;
foreach (var r in _pc.GetComponentsInChildren<Renderer>(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();
/// <summary>#958 층별 _Emission · _Color 덮기 — 덮은 렌더러 수.</summary>
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<ParticleSystemRenderer>(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<GameObject>(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++;
}
}
/// <summary>감싸기 이펙트 — 방출만 멈춘다(남은 입자는 제 수명대로 · 다 사라진 뒤 지움) · 가장 긴 남은 수명(초)을 돌려준다.</summary>
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<ParticleSystem>(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;
}
/// <summary>공격 자세 고정 — 다른 동작(원본 시전 다음 동작 등)이 끼어들면 되돌린다 · 클립 이벤트(궤적 · 판정)는 끈다.</summary>
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<GameObject>(_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++;
}
/// <summary>PC 발 기준 radius + 적 몸 반지름 안의 모든 적에게 원본 한 대 피해(속사 레이저 WLRapidShot.Laser 와 같은 방식).</summary>
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<Collider>();
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<string, GameObject> s_hitPrefabs = new Dictionary<string, GameObject>();
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<GameObject>(_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<ParticleSystem>(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<ParticleSystem>(true)) ps.Stop(true, ParticleSystemStopBehavior.StopEmittingAndClear);
foreach (var r in GetComponentsInChildren<Renderer>(true)) { if (!r.enabled) continue; r.enabled = false; _hidden.Add(r); }
}
/// <summary>끝 — natural = 유지 시간 만료(감속 뒤) · offProjectile = 투사체를 끈다(원본 끝 순서: 끔 → 조이스틱 0 → 대기).</summary>
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<ParticleSystem>(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); }
}
}