// ───────────────────────────────────────────────────────────────────────────── // WLShurikenRider.cs — 비수 기본 스킬(무자비한 일격) 경로 이펙트(Multiple Slashes 호) 위에 수리검을 여러 개 태운다 (#977 · 2026-10-24) // // PD 원문: 「비수의 "기본 스킬" 시전 시 Master Stylized Projectile 에셋의 첨부 된 스크린샷과 같은 수리검을 경로 이펙트에 다수 보이게 연출해줘. // (이때 이펙트 궤적의 색상을 기본 공격처럼 녹색 계열로 바꾸고, 투명도를 조금 부여해서 자연스럽게 구성해줘)」 // ■ 수리검 = JSON prefab(#979 = Resources/WL/Fx/WL_Fx_DaggerShurikenMSP = PD 지정 「Master Stylized Projectile」 Shurikens/ShurikenBullet 사본 · 검은 풍차형 수리검 · AutoRotate 회전 · 흰 반짝임) // ■ 원본 Skill_BrutalSlash.Co_Update 가 베기 이펙트를 켜는 줄 다음 훅 1줄(Begin) → 그 투사체의 경로 층(layer) 입자 중 every 번째(randomSeed)마다 // 수리검 1개를 붙여 매 LateUpdate 호의 가장자리(입자 위치 + 3D 회전 × 오른쪽 × 메시 반지름 × 크기 × rimFraction)로 — 호가 돌면 수리검이 호를 따라 돈다 · 입자가 사라지면 숨김 // ■ #980 mode "spiral" = 시전자 둘레 나선(회전 · 휘몰아치며 퍼져 나감 · 경로 입자 무관) · faceCamera = 수리검 면이 카메라를 봄 // ■ #983 mode "scatter" = 숨긴 경로 호(구 반지름 4 m 안 무작위 자리 · 0.5 s 동안 불규칙 생성)처럼 시전자 둘레 무작위 자리 · 무작위 시각에 하나씩 나타나 // 바깥쪽으로 날며 점점 세게 꺾여 나선으로 말려 들어가고 크기가 줄며 사라진다(나타난 자리 기준 · 시전자 이동 무관) // ■ 값 = Resources/WL/WLShurikenRider.json (C45) · 끄기 = enabled 0 // 🔴 Debug.LogError 금지(원본 팝업). // ───────────────────────────────────────────────────────────────────────────── using System.Collections.Generic; using UnityEngine; namespace WL.Combat.Weapon { [System.Serializable] public class WLShurikenRiderJson { public int enabled = 0; public string[] skills = new string[0]; public int[] classes = new int[0]; public string layer = "Multiple Slashes"; public string prefab = "WL/Fx/WL_Fx_DaggerShuriken"; public int every = 8; // 경로 입자 randomSeed % every == 0 인 것에만 public int maxRiders = 12; public float riderScale = 0.12f; public float rimFraction = 1f; // 호 반지름 대비 자리(1 = 가장자리) public float maxSeconds = 2.5f; public int hidePath = 0; // #978 1 = 경로 층 그리기만 끔(입자는 그대로 돌아 수리검이 따라감 · 끝나면 되살림) — PD 「요청한 이펙트를 확인할 수 있도록 궤적처럼 보이는 이펙트를 잠시 숨겨줘」 public float minHeight = 0f; // #979 시전자 발 높이 위 최소 높이(m) — PD 「공중을 부양하는 수리검 컨셉」(호가 땅 가까이 돌아도 수리검은 이 높이 위로) // #980 PD 「회전하는 수리검이 플레이어 주위를 나선형으로 휘몰아치며 퍼져나는 연출」 — mode "spiral" = 경로 입자와 무관하게 시전자 둘레 나선 public string mode = "path"; public int spiralCount = 8; // 수리검 수 public int spiralArms = 1; // 나선 팔 수 — 팔마다 같은 자리에서 stagger 간격으로 차례로 나와 같은 나선을 따라감(팔끼리 360/팔 간격) public float spiralStartRadius = 0.6f, spiralEndRadius = 4f; // 반지름(m) 시작 → 끝(ease-out) public float spiralSeconds = 1f; // 한 수리검이 시작 → 끝까지(s) public float spiralDegPerSec = 540f, spiralDir = 1f; // 공전 속도(°/s) · 방향(1 = 위에서 볼 때 반시계) public float spiralHeight = 0.8f, spiralRise = 0.2f; // 발 위 높이(m) · 퍼지며 더 오르는 높이(m) public float spiralStagger = 0.05f, spiralEase = 2f; // 다음 수리검까지(s) · 반지름 ease-out 지수 public float spiralFadeIn = 0.08f, spiralFadeOut = 0.2f; // 처음 · 끝에 크기 0 ↔ 1(s) public int faceCamera = 0; // #980 PD 「수리검 이펙트 방향은 카메라 시점 기준 가장 자연스럽게」 — 1 = 수리검 면이 카메라를 봄(제 축으로 회전) public float faceTilt = 0f; // #980 PD 「스크린샷과 같은 느낌」 — 카메라 정면에서 카메라 오른쪽 축으로 눕히는 각(°) · 70 ≈ 데모처럼 옆에서 본 납작한 원판 + 초승달 // #983 PD 「기존에 숨긴 궤적과 같이 수리검들이 화면을 불규칙하게 생성되어 날아가며 이때 나선형에 가깝게 회전하듯 사라지는 모습」 — mode "scatter" public int scatterCount = 12; // 수리검 수(각 구간에 1개씩 = 한쪽에 몰리지 않게 · 구간 안 무작위) public float scatterWindow = 0.5f; // 나타나는 시각 범위(s · 순서 무작위) public float scatterMinRadius = 0.8f, scatterMaxRadius = 4f; // 나타나는 자리(시전자 기준 수평 m · 넓이 고르게) public float scatterMinHeight = 0.6f, scatterMaxHeight = 2f; // 나타나는 높이(발 위 m) public float scatterMinSeconds = 0.55f, scatterMaxSeconds = 0.85f; // 한 수리검이 나타나서 사라지기까지(s · 무작위) public float scatterSpeed = 4f, scatterSpeedEnd = 0.3f; // 처음 속도(m/s) · 끝 속도 배율 public float scatterTurnStart = 90f, scatterTurnEnd = 1080f; // 꺾임(°/s) 처음 → 끝(제곱으로 커짐) — 끝이 클수록 촘촘한 나선으로 말려 들어감 · 방향 = spiralDir public float scatterJitter = 40f; // 바깥 방향에서 흔들림(± °) public float scatterRise = 0.25f; // 날며 오르는 높이(m) public float scatterShrinkFrom = 0.35f; // 이 비율부터 크기가 줄어 끝에 0(나선 끝에서 사라짐) // #985 PD 「비수가 너무 느리게 동작 · 갑자기 사라지는 느낌 · 캐릭터 주위로 퍼져나가야 · 비수 위치를 고려해 궤적 연출을 다시 살리고 · 좀 더 화려하고 타격감」 public int pathFollow = 0; // 1 = 경로 호(layer)를 원래 무작위 자리 대신 날아가는 수리검 자리에서 뿜음(호 자체 방출은 끄고 끝나면 되살림 · hidePath 0 과 함께) public float pathRate = 30f; // 수리검 1개당 초당 호 수 public float pathSizeMin = 0.6f, pathSizeMax = 1.2f; // 뿜는 호 크기(원본 시작 크기 1~3) public string endFx = ""; // 수리검이 작아지기 시작할 때(scatterShrinkFrom) 그 자리에 터지는 이펙트(Resources) — 「갑자기 사라짐」 대신 터지며 사라짐 public float endFxScale = 0.2f, endFxLife = 1f; public string hitFx = ""; // 수리검이 적 몸 hitRadius 안을 지날 때 그 적 몸에(수리검 · 적 쌍마다 1번 · 보이는 타격 · 피해는 원본 스킬 그대로) public float hitFxScale = 0.2f, hitFxLife = 1f, hitRadius = 0.8f; public string hitFxSkill = ""; // #992 비우지 않으면 hitFx 대신 WLImpactFx 스킬 피격 규칙(이 이름 · 직업)으로 — 기본 공격과 같은 이펙트 · 몸 높이 비례 · 무작위 회전 public float releaseShake = 0f, releaseShakeSeconds = 0.15f; // 수리검이 처음 나오는 순간 카메라 흔들림(진폭 · 0 = 없음 · 주파수 · 축 = WLHitFeelSettings) public float hitShake = 0f, hitShakeSeconds = 0.08f; // 명중 섬광마다 카메라 흔들림 public float spinMul = 1f; // 수리검 제 축 회전(사본 AutoRotate) 배율 // #986 PD 「궤적이 의미 없이 어지럽게 보이는데, 비수가 날아가는 나선형 궤적으로 표현되도록 … 의미 없는 궤적은 숨기고 실제 비수가 날아가는 궤적만 꼬리처럼」 // trail 1 = 수리검마다 꼬리(TrailRenderer · 재질 = SlashTrailSettings.materialAdditive · 색 = 그 직업 무기 궤적 가산색 · 끝나도 꼬리가 다 사라질 때까지 수리검 자리 유지) public int trail = 0; public float trailSeconds = 0.22f, trailWidth = 0.3f, trailWidthEnd = 0f, trailAlpha = 0.9f; public int trailClass = 0; // 색을 가져올 직업(0 = 시전자 직업) // #986 PD 「캐릭터가 기모으기 포즈 후 투사체 이펙트가 발생될 때 한바퀴 회전하는 모션 … 이때 캐릭터에게 Simple Stylized Slash Pack 2 의 Slash_Aoe_A 재생」 public float spinSeconds = 0f, spinDir = 1f; // 시전자가 한 바퀴(360°) 도는 시간(0 = 없음) · 방향(1 = 위에서 볼 때 시계) public string spinFx = ""; // 도는 순간 시전자 자리에 재생할 이펙트(Resources) public float spinFxScale = 1f, spinFxLife = 1.2f, spinFxY = 0.6f; public string note = ""; } public static class WLShurikenRider { public const string ResourcesPath = "WL/WLShurikenRider"; public static int Begun, MaxShown; public static string LastLog = ""; public static float MinY = float.MaxValue, MaxY = float.MinValue; // #979 측정용 — 수리검 높이(시전자 발 기준) 최소 · 최대 public static int Scattered; public static float MinR = float.MaxValue, MaxR; // #983 측정용 — scatter 로 나타난 수 · 나타난 자리 반지름 최소 · 최대 public static int PathEmitted, EndBursts, HitBursts, Shakes, Spins, SpinFxShown, Trails; public static float MaxReach; // #986 Spins · SpinFxShown · Trails(꼬리 붙인 수리검) // #985 측정용 — 수리검 자리에서 뿜은 호 · 끝 터짐 · 명중 섬광 · 흔들림 · 시전자에서 가장 먼 거리 static WLShurikenRiderJson s_cfg; [RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.SubsystemRegistration)] static void Reset() { s_cfg = null; MinY = float.MaxValue; MaxY = float.MinValue; Scattered = 0; MinR = float.MaxValue; MaxR = 0f; PathEmitted = EndBursts = HitBursts = Shakes = Spins = SpinFxShown = Trails = 0; MaxReach = 0f; } internal static WLShurikenRiderJson Cfg { get { if (s_cfg != null) return s_cfg; s_cfg = new WLShurikenRiderJson(); var ta = Resources.Load(ResourcesPath); if (ta == null) { LastLog = "설정 없음 " + ResourcesPath; return s_cfg; } try { JsonUtility.FromJsonOverwrite(ta.text, s_cfg); } catch (System.Exception ex) { LastLog = "설정 파싱 실패 — " + ex.Message; } return s_cfg; } } /// 원본 스킬이 베기(경로) 이펙트를 켠 직후 훅 — 규칙에 맞으면 그 투사체에 수리검 러너를 붙인다. public static void Begin(ProjectileBase pb) { try { var c = Cfg; if (c.enabled == 0 || pb == null) return; if (c.skills != null && c.skills.Length > 0 && System.Array.IndexOf(c.skills, pb.GetType().Name) < 0) return; var pd = pb.Get_ProjectTileData(); int cls = pd != null && !DSUtil.CheckNull(pd.shooter) ? pd.shooter.Get_ID() : 0; if (c.classes != null && c.classes.Length > 0 && System.Array.IndexOf(c.classes, cls) < 0) return; ParticleSystem path = null; foreach (var ps in pb.GetComponentsInChildren(true)) if (ps.name == c.layer) { path = ps; break; } if (path == null) { LastLog = "경로 층 없음 " + c.layer; return; } var run = pb.GetComponent(); if (run == null) run = pb.gameObject.AddComponent(); bool has = pd != null && !DSUtil.CheckNull(pd.shooter); run.Begin(path, c, has ? pd.shooter.Get_position().y : pb.transform.position.y, has ? pd.shooter.transform : pb.transform, has ? pd.shooter : null); Begun++; } catch (System.Exception ex) { LastLog = "실패 — " + ex.Message; } } } internal sealed class WLShurikenRiderRunner : MonoBehaviour { ParticleSystem _ps; WLShurikenRiderJson _c; ParticleSystem.Particle[] _buf = new ParticleSystem.Particle[256]; readonly List _riders = new List(); readonly Dictionary _bySeed = new Dictionary(); float _t0, _rim = 0.5f, _ground; bool _on; Transform _center; Actor _shooter; bool _pathEmOff, _pathEmWas, _shook; float[] _emitAcc; bool[] _endDone; HashSet _hitPairs = new HashSet(); bool _spinning; float _spinT0, _spinYaw0; // #986 시전자 한 바퀴 public void Begin(ParticleSystem ps, WLShurikenRiderJson c, float ground, Transform center, Actor shooter) { _ps = ps; _c = c; _t0 = Time.time; _on = true; _bySeed.Clear(); _ground = ground; _center = center; _shooter = shooter; _shook = false; _hitPairs.Clear(); var r = ps.GetComponent(); var m = r != null ? r.mesh : null; _pathRenderer = c.hidePath != 0 && r != null && r.enabled ? r : null; if (_pathRenderer != null) _pathRenderer.enabled = false; if (c.mode == "scatter" && c.pathFollow != 0) // #985 경로 호를 수리검 자리에서만 — 원래 방출(구 반지름 4 m 무작위)은 끄고 이미 나온 것도 지운다(끝나면 되살림) { var em = ps.emission; _pathEmWas = em.enabled; em.enabled = false; _pathEmOff = true; ps.Clear(false); } _rim = m != null ? Mathf.Max(m.bounds.extents.x, m.bounds.extents.y) : 0.5f; // 호 메시 반지름(입자 크기 1 기준) var p = Resources.Load(c.prefab); int need = c.mode == "spiral" ? Mathf.Max(c.maxRiders, c.spiralCount) : c.mode == "scatter" ? Mathf.Max(c.maxRiders, c.scatterCount) : c.maxRiders; if (c.spinSeconds > 0f && shooter != null) // #986 시전자 한 바퀴 + 회전 베기 이펙트 { _spinning = true; _spinT0 = Time.time; _spinYaw0 = shooter.transform.eulerAngles.y; WLShurikenRider.Spins++; if (Burst(c.spinFx, shooter.Get_position() + Vector3.up * c.spinFxY, c.spinFxScale, c.spinFxLife, shooter.transform.rotation)) WLShurikenRider.SpinFxShown++; } while (p != null && _riders.Count < need) { var g = Instantiate(p); g.SetActive(false); _riders.Add(g); if (c.trail != 0) AddTrail(g, c); if (!Mathf.Approximately(c.spinMul, 1f)) // #985 제 축 회전(사본의 팩 AutoRotate.RotateAngle) 배율 — 만들 때 1번 foreach (var mb in g.GetComponentsInChildren(true)) { if (mb == null || mb.GetType().Name != "AutoRotate") continue; var f = mb.GetType().GetField("RotateAngle"); if (f == null) continue; f.SetValue(mb, (Vector3)f.GetValue(mb) * c.spinMul); } } foreach (var g in _riders) if (g != null) { g.SetActive(false); g.transform.localScale = Vector3.one * c.riderScale; } if (p == null) WLShurikenRider.LastLog = "수리검 프리팹 없음 " + c.prefab; if (c.mode == "scatter") PlanScatter(); } // #983 scatter — 수리검마다 나타날 시각 · 자리(각도 · 반지름 · 높이) · 수명 · 첫 방향 흔들림을 미리 정한다(각도는 구간마다 1개 · 시각 순서는 섞음 → 자리와 시각이 따로 논다) struct Scat { public float t0, ang, r, h, life, jit; public bool on, done; public Vector3 pos; public float head, y0; } Scat[] _sc; void PlanScatter() { int n = Mathf.Min(Mathf.Max(1, _c.scatterCount), _riders.Count); _sc = new Scat[n]; _emitAcc = new float[n]; _endDone = new bool[n]; var order = new int[n]; for (int i = 0; i < n; i++) order[i] = i; for (int i = n - 1; i > 0; i--) { int j = Random.Range(0, i + 1); int k = order[i]; order[i] = order[j]; order[j] = k; } float a0 = Random.Range(0f, 360f), r0 = Mathf.Min(_c.scatterMinRadius, _c.scatterMaxRadius), r1 = Mathf.Max(_c.scatterMinRadius, _c.scatterMaxRadius); for (int i = 0; i < n; i++) { _sc[i].ang = a0 + 360f / n * (i + Random.Range(0.15f, 0.85f)); _sc[i].r = Mathf.Sqrt(Mathf.Lerp(r0 * r0, r1 * r1, Random.value)); // 넓이 고르게(가운데 몰림 없음) _sc[i].h = Random.Range(_c.scatterMinHeight, _c.scatterMaxHeight); _sc[i].t0 = _c.scatterWindow * (order[i] + Random.value) / n; _sc[i].life = Mathf.Max(0.05f, Random.Range(_c.scatterMinSeconds, _c.scatterMaxSeconds)); _sc[i].jit = Random.Range(-_c.scatterJitter, _c.scatterJitter); } } void LateUpdate() { if (_spinning) SpinTick(); if (!_on) return; if (_ps == null || !_ps.gameObject.activeInHierarchy || Time.time - _t0 > _c.maxSeconds) { Stop(); return; } if (_c.mode == "spiral") { Spiral(); return; } if (_c.mode == "scatter") { Scatter(); return; } int n = _ps.GetParticles(_buf); var alive = new HashSet(); int shown = 0; for (int i = 0; i < n; i++) { var p = _buf[i]; uint seed = p.randomSeed; if (_c.every > 1 && seed % (uint)_c.every != 0) continue; alive.Add(seed); int idx; if (!_bySeed.TryGetValue(seed, out idx)) { idx = FreeSlot(); if (idx < 0) continue; _bySeed[seed] = idx; } var g = _riders[idx]; bool fresh = !g.activeSelf; if (fresh) g.SetActive(true); var size = p.GetCurrentSize3D(_ps); var pos = p.position + Quaternion.Euler(p.rotation3D) * (Vector3.right * _rim * size.x * _c.rimFraction); if (_c.minHeight > 0f) pos.y = Mathf.Max(pos.y, _ground + _c.minHeight); // 공중 부양 WLShurikenRider.MinY = Mathf.Min(WLShurikenRider.MinY, pos.y - _ground); WLShurikenRider.MaxY = Mathf.Max(WLShurikenRider.MaxY, pos.y - _ground); var mv = pos - g.transform.position; // 투사체 이펙트라 진행 방향(앞 = +Z)으로 돌린다 — 꼬리가 지나온 쪽 if (_c.faceCamera != 0) g.transform.rotation = Face(_c.faceTilt, pos); else if (!fresh && mv.sqrMagnitude > 1e-6f) g.transform.rotation = Quaternion.LookRotation(mv); g.transform.position = pos; shown++; } var gone = new List(); foreach (var kv in _bySeed) if (!alive.Contains(kv.Key)) gone.Add(kv.Key); foreach (var s in gone) { _riders[_bySeed[s]].SetActive(false); _bySeed.Remove(s); } if (shown > WLShurikenRider.MaxShown) WLShurikenRider.MaxShown = shown; if (n == 0 && Time.time - _t0 > 0.3f) Stop(); } // 나선 — 팔이 1 이면 i 번째 수리검이 i × stagger 뒤 각도 360/n × i 에서 · 팔이 여럿이면 팔마다 같은 각도(360/팔 × 팔)에서 차례로 나와 // spiralSeconds 동안 공전하며 반지름 시작 → 끝(ease-out)으로 퍼지고 · 처음 · 끝은 크기로 페이드 void Spiral() { float t = Time.time - _t0; int n = Mathf.Min(_c.spiralCount, _riders.Count); bool pending = false; int shown = 0; var c0 = _center != null ? _center.position : transform.position; c0.y = _ground; var useFace = _c.faceCamera != 0; for (int i = 0; i < n; i++) { int arms = Mathf.Max(1, _c.spiralArms), arm = i % arms, idx = i / arms; var g = _riders[i]; if (g == null) continue; float ti = t - idx * _c.spiralStagger; if (ti < 0f) { pending = true; continue; } if (ti > _c.spiralSeconds) { if (g.activeSelf) g.SetActive(false); continue; } pending = true; if (!g.activeSelf) g.SetActive(true); float u = ti / Mathf.Max(0.01f, _c.spiralSeconds), e = 1f - Mathf.Pow(1f - u, Mathf.Max(1f, _c.spiralEase)); float r = Mathf.Lerp(_c.spiralStartRadius, _c.spiralEndRadius, e), ang = ((arms > 1 ? 360f / arms * arm : 360f / Mathf.Max(1, n) * i) + _c.spiralDir * _c.spiralDegPerSec * ti) * Mathf.Deg2Rad; var dir = new Vector3(Mathf.Cos(ang), 0f, Mathf.Sin(ang)); var pos = c0 + dir * r + Vector3.up * (_c.spiralHeight + _c.spiralRise * e); float k = Mathf.Min(1f, ti / Mathf.Max(0.01f, _c.spiralFadeIn)) * Mathf.Clamp01((_c.spiralSeconds - ti) / Mathf.Max(0.01f, _c.spiralFadeOut)); g.transform.position = pos; g.transform.localScale = Vector3.one * (_c.riderScale * k); g.transform.rotation = useFace ? Face(_c.faceTilt, pos) : Quaternion.LookRotation(Vector3.Cross(Vector3.up, dir) * _c.spiralDir); // 카메라를 보거나 · 공전 진행 방향 WLShurikenRider.MinY = Mathf.Min(WLShurikenRider.MinY, pos.y - _ground); WLShurikenRider.MaxY = Mathf.Max(WLShurikenRider.MaxY, pos.y - _ground); shown++; } if (shown > WLShurikenRider.MaxShown) WLShurikenRider.MaxShown = shown; if (!pending) Stop(); } // #983 scatter — 나타날 시각이 되면 그때 시전자 둘레 자리에 켜고 · 바깥쪽(+ 흔들림)으로 날며 꺾임이 처음 → 끝(제곱)으로 커져 나선으로 말려 들어가고 · // 속도는 끝 배율까지 줄고 · scatterShrinkFrom 부터 크기가 0 으로(처음 spiralFadeIn 은 0 → 1) · 다 끝나면 멈춤 void Scatter() { if (_sc == null) PlanScatter(); float t = Time.time - _t0, dt = Time.deltaTime; bool pending = false; int shown = 0; var c0 = _center != null ? _center.position : transform.position; c0.y = _ground; var useFace = _c.faceCamera != 0; for (int i = 0; i < _sc.Length; i++) { var g = _riders[i]; if (g == null || _sc[i].done) continue; pending = true; float ti = t - _sc[i].t0; if (ti < 0f) continue; if (!_sc[i].on) { float a = _sc[i].ang * Mathf.Deg2Rad; var dir = new Vector3(Mathf.Cos(a), 0f, Mathf.Sin(a)); _sc[i].pos = c0 + dir * _sc[i].r + Vector3.up * _sc[i].h; _sc[i].y0 = _sc[i].pos.y; _sc[i].head = _sc[i].ang + _sc[i].jit; _sc[i].on = true; g.transform.position = _sc[i].pos; g.SetActive(true); var tr0 = g.GetComponent(); if (tr0 != null) { tr0.Clear(); tr0.emitting = true; } // #986 꼬리는 나타난 자리부터 WLShurikenRider.Scattered++; WLShurikenRider.MaxR = Mathf.Max(WLShurikenRider.MaxR, _sc[i].r); WLShurikenRider.MinR = Mathf.Min(WLShurikenRider.MinR, _sc[i].r); } float u = Mathf.Clamp01(ti / _sc[i].life); if (ti > _sc[i].life) { var tr1 = g.GetComponent(); // #986 꼬리가 다 사라질 때까지 마지막 자리에 크기 0 으로 남김(갑자기 끊기지 않게) if (tr1 != null && ti < _sc[i].life + _c.trailSeconds) { if (tr1.emitting) { tr1.emitting = false; g.transform.localScale = Vector3.zero; } continue; } _sc[i].done = true; g.SetActive(false); continue; } float turn = Mathf.Lerp(_c.scatterTurnStart, _c.scatterTurnEnd, u * u), spd = _c.scatterSpeed * Mathf.Lerp(1f, _c.scatterSpeedEnd, u); _sc[i].head += _c.spiralDir * turn * dt; // 각도 증가 = 위에서 볼 때 반시계(spiral 과 같은 규칙) float h = _sc[i].head * Mathf.Deg2Rad; var vel = new Vector3(Mathf.Cos(h), 0f, Mathf.Sin(h)) * spd; _sc[i].pos += vel * dt; _sc[i].pos.y = _sc[i].y0 + _c.scatterRise * u; float k = Mathf.Min(1f, ti / Mathf.Max(0.01f, _c.spiralFadeIn)) * (1f - Mathf.SmoothStep(0f, 1f, Mathf.InverseLerp(_c.scatterShrinkFrom, 1f, u))); g.transform.position = _sc[i].pos; g.transform.localScale = Vector3.one * (_c.riderScale * k); g.transform.rotation = useFace ? Face(_c.faceTilt, _sc[i].pos) : Quaternion.LookRotation(vel.sqrMagnitude > 1e-6f ? vel : Vector3.forward); WLShurikenRider.MinY = Mathf.Min(WLShurikenRider.MinY, _sc[i].pos.y - _ground); WLShurikenRider.MaxY = Mathf.Max(WLShurikenRider.MaxY, _sc[i].pos.y - _ground); var fl = _sc[i].pos - c0; fl.y = 0f; WLShurikenRider.MaxReach = Mathf.Max(WLShurikenRider.MaxReach, fl.magnitude); if (_pathEmOff && _ps != null && _c.pathRate > 0f && u < _c.scatterShrinkFrom) // #985 수리검 자리에서 경로 호(궤적)를 뿜는다 — 지나온 길에 남아 원래 수명대로 사라짐 { _emitAcc[i] += _c.pathRate * dt; if (_emitAcc[i] >= 1f && !_ps.isPlaying) _ps.Play(false); // 호 층 길이(0.5 s)가 지나 멈췄으면 다시 켬(방출은 꺼 둔 채) while (_emitAcc[i] >= 1f) { _emitAcc[i] -= 1f; var ep = new ParticleSystem.EmitParams { applyShapeToPosition = false, startSize = Random.Range(_c.pathSizeMin, _c.pathSizeMax) }; ep.position = _ps.main.simulationSpace == ParticleSystemSimulationSpace.World ? _sc[i].pos : _ps.transform.InverseTransformPoint(_sc[i].pos); _ps.Emit(ep, 1); WLShurikenRider.PathEmitted++; } } if (!_endDone[i] && u >= _c.scatterShrinkFrom) { _endDone[i] = true; if (Burst(_c.endFx, _sc[i].pos, _c.endFxScale, _c.endFxLife)) WLShurikenRider.EndBursts++; } // #985 작아지기 시작할 때 터짐 shown++; } if (shown > 0 && !_shook && _c.releaseShake > 0f) { _shook = true; Shake(_c.releaseShake, _c.releaseShakeSeconds); } // #985 퍼지는 순간 흔들림 if (shown > 0 && (!string.IsNullOrEmpty(_c.hitFx) || !string.IsNullOrEmpty(_c.hitFxSkill))) HitPass(); if (shown > WLShurikenRider.MaxShown) WLShurikenRider.MaxShown = shown; if (!pending) Stop(); } // #985 날아가는 수리검이 적 몸(반지름 + hitRadius) 안을 지나면 그 적 몸 앞에 명중 섬광 + 흔들림(수리검 · 적 쌍마다 1번 · 보이는 타격만 · 피해는 원본 스킬 그대로) void HitPass() { var info = ActorInfo.Ins; if (info == null || DSUtil.CheckNull(_shooter)) return; var c0 = _shooter.Get_position(); var list = info.Get_EnemyActors(_shooter.IsEnemy(), c0, WLShurikenRider.MaxReach + _c.hitRadius + 3f); if (list == null) return; try { for (int k = 0; k < list.Count; k++) { var a = list[k]; if (a == null || a.IsDead()) continue; var ap = a.Get_position(); float rad = WL.Combat.WLCombatMotionSettings.BodyRadius(a) + _c.hitRadius; for (int i = 0; i < _sc.Length; i++) { if (!_sc[i].on || _sc[i].done) continue; var v = _sc[i].pos - ap; v.y = 0f; if (v.magnitude > rad) continue; long key = ((long)a.GetInstanceID() << 8) | (long)(i & 0xff); if (!_hitPairs.Add(key)) continue; var at = WL.Combat.Impact.WLImpactFx.BodyCenter(a) + WL.Combat.Impact.WLImpactFx.TowardCamera(a); if (!string.IsNullOrEmpty(_c.hitFxSkill) ? WL.Combat.Impact.WLImpactFx.PlaySkillHitByPC(a, _c.hitFxSkill) : Burst(_c.hitFx, at, _c.hitFxScale * WL.Combat.Impact.WLImpactFx.SizeFactor(a), _c.hitFxLife)) WLShurikenRider.HitBursts++; if (_c.hitShake > 0f) Shake(_c.hitShake, _c.hitShakeSeconds); } } } finally { info.Release_Actors(list); } } static readonly Dictionary s_fx = new Dictionary(); static bool Burst(string path, Vector3 pos, float scale, float life) { return Burst(path, pos, scale, life, Quaternion.identity); } static bool Burst(string path, Vector3 pos, float scale, float life, Quaternion rot) { if (string.IsNullOrEmpty(path)) return false; GameObject p; if (!s_fx.TryGetValue(path, out p) || p == null) { p = Resources.Load(path); s_fx[path] = p; } if (p == null) { WLShurikenRider.LastLog = "이펙트 없음 " + path; return false; } var g = Instantiate(p, pos, rot); g.transform.localScale = p.transform.localScale * scale; foreach (var ps in g.GetComponentsInChildren(true)) { var m = ps.main; m.scalingMode = ParticleSystemScalingMode.Hierarchy; } Destroy(g, Mathf.Max(0.2f, life)); return true; } // #986 시전자 한 바퀴 — 처음 방향에서 spinDir × 360° 를 spinSeconds 동안(가속 · 감속) · 끝나면 처음 방향 그대로 void SpinTick() { if (_shooter == null || _c == null) { _spinning = false; return; } float u = (Time.time - _spinT0) / Mathf.Max(0.05f, _c.spinSeconds); if (u >= 1f) { _spinning = false; _shooter.transform.rotation = Quaternion.Euler(0f, _spinYaw0, 0f); return; } _shooter.transform.rotation = Quaternion.Euler(0f, _spinYaw0 + _c.spinDir * 360f * Mathf.SmoothStep(0f, 1f, u), 0f); } // #986 꼬리 — 무기 궤적 가산 재질 · 그 직업 궤적 가산색(밝기 1 로 맞춤) · 투명도 trailAlpha → 0 · 폭 trailWidth → trailWidthEnd void AddTrail(GameObject g, WLShurikenRiderJson c) { var st = WL.Combat.SlashTrailSettings.Instance; var mat = st != null ? st.materialAdditive : null; if (mat == null) { WLShurikenRider.LastLog = "꼬리 재질 없음(SlashTrailSettings.materialAdditive)"; return; } int cls = c.trailClass > 0 ? c.trailClass : (_shooter != null ? _shooter.Get_ID() : 0); var o = st.FindOverride(cls); var col = o != null ? o.colorAdditive : Color.white; float mx = Mathf.Max(1f, Mathf.Max(col.r, Mathf.Max(col.g, col.b))); col = new Color(col.r / mx, col.g / mx, col.b / mx, 1f); var tr = g.GetComponent(); if (tr == null) tr = g.AddComponent(); tr.sharedMaterial = mat; tr.time = c.trailSeconds; tr.startWidth = c.trailWidth; tr.endWidth = c.trailWidthEnd; tr.minVertexDistance = 0.04f; var gr = new Gradient(); gr.SetKeys(new[] { new GradientColorKey(col, 0f), new GradientColorKey(col, 1f) }, new[] { new GradientAlphaKey(c.trailAlpha, 0f), new GradientAlphaKey(0f, 1f) }); tr.colorGradient = gr; tr.emitting = false; tr.shadowCastingMode = UnityEngine.Rendering.ShadowCastingMode.Off; tr.receiveShadows = false; tr.alignment = LineAlignment.View; WLShurikenRider.Trails++; } static void Shake(float amp, float seconds) { var st = WL.Feel.WLHitFeel.Settings; var sh = WL.Feel.WLHitFeel.GetShaker(true); if (st == null || sh == null) return; sh.Shake(amp, seconds, st.shakeFrequency, st.shakeAxisWeight); WLShurikenRider.Shakes++; } // 수리검 면(사본 루트 +Y)을 카메라 쪽으로 · 앞(+Z) = 카메라 위쪽 — 위에서 내려다보는 카메라에서 납작한 원판이 정면으로 보이고 제 축(AutoRotate)으로 돈다 · tilt° 만큼 카메라 오른쪽 축으로 눕힘 static Quaternion Face(float tilt, Vector3 pos) { var cam = Camera.main; if (cam == null) return Quaternion.identity; var to = cam.transform.position - pos; if (to.sqrMagnitude < 1e-6f) to = -cam.transform.forward; to.Normalize(); // 원근 — 자기 자리에서 카메라 쪽(화면 가장자리 수리검도 같은 각도로 보이게) var fwd = Vector3.ProjectOnPlane(cam.transform.up, to); if (fwd.sqrMagnitude < 1e-6f) fwd = Vector3.ProjectOnPlane(Vector3.forward, to); var face = Quaternion.LookRotation(fwd.normalized, to); return Quaternion.AngleAxis(tilt, face * Vector3.right) * face; } int FreeSlot() { for (int i = 0; i < _riders.Count; i++) if (!_bySeed.ContainsValue(i)) return i; return -1; } ParticleSystemRenderer _pathRenderer; void Stop() { _on = false; _bySeed.Clear(); _sc = null; if (_spinning && _shooter != null) { _shooter.transform.rotation = Quaternion.Euler(0f, _spinYaw0, 0f); _spinning = false; } if (_pathEmOff) { _pathEmOff = false; if (_ps != null) { var em = _ps.emission; em.enabled = _pathEmWas; } } if (_pathRenderer != null) { _pathRenderer.enabled = true; _pathRenderer = null; } foreach (var g in _riders) if (g != null) g.SetActive(false); WLShurikenRider.LastLog = "끝 · 최대 동시 " + WLShurikenRider.MaxShown; } void OnDisable() { Stop(); } void OnDestroy() { foreach (var g in _riders) if (g != null) Destroy(g); } } }