// ───────────────────────────────────────────────────────────────────────────── // WLRapidShot.cs — 활 주력 「속사」(Skill_RapidShot) WL 방식 (#916 · 2026-10-17) // // PD 원문: 「활을 쏠 때 플레이어가 뒤로 밀리지 않게」 · 「활의 주력 스킬시전할 때 플레이어의 활이 빛나며 기가 모이는 연출을 추가 // (해머 각성기 참고하되, 해머보다 임펙트가 너무 강하지는 않도록)」 · 「과녁의 크기가 몬스터의 크기를 고려해 유동적이 되도록 · // (이펙트가 몬스터에게 가려지지 않도록)」 · 「매우 빠르게 활쏘는 모션을 반복해 3발의 투사체를 연속으로 발사」 // ■ 원본(Skill_RapidShot.Co_Update): 과녁(Effect_BulletTarget · 대상 몸 가운데 · 크기 고정) → 1 초 → 0.25 초 간격 7 발 · 발마다 시전자 넉백 // (원본 넉백 최소 0.1 초 × 속도 4 = 한 발 약 0.4 m 뒤로 · Play 실측). // ■ WL — 원본 Set 의 훅 1줄(Take)이 넘겨받는다: // ① 과녁 = 원본 이펙트 그대로 · 대상 중심에서 카메라 쪽으로 몸 반지름 + markFront 앞에(몸에 가려지지 않게) · 배율 = 몹 키 × markPerHeight(markMin~markMax) // ② 모으기 chargeSeconds — 활 당긴 자세(shotState 의 drawNormalized)로 멈추고, 활에 흰 빛(WLWeaponGlowFx · glowMax) + 활 가운데 모으기 이펙트(chargeFx) // ③ shots 발 — 발마다 shotState 를 (첫 발 drawNormalized · 다음 fromNormalized) 부터 shotSpeed 배로 재생 → 놓는 순간(클립 Projectile 이벤트)에 // 원본 투사체 1 발(관통 · 원본과 같은 한 발 피해) · 대상을 본다 · 시전자 넉백 없음 // ④ 끝 — endNormalized 까지 재생 → 애니메이터 되돌림 · 대기 · 빛 glowFadeSeconds 동안 꺼짐 · 투사체 Off. 이동 · 구르기로 스킬 상태가 끊기면 즉시 끝(스킵). // ■ noRecoil = 쏠 때 시전자를 뒤로 미는 다른 활 스킬(원본 Set 훅 1줄 · 속박의 화살 Skill_BindingArrow 0.05 초 × 속도 10) // ■ 값 = Resources/WL/WLRapidShot.json (C45) · 끄기 = enabled 0 → 원본 동작 // 🔴 Debug.LogError 금지(원본 팝업). // ───────────────────────────────────────────────────────────────────────────── using System.Collections; using System.Collections.Generic; using UnityEngine; namespace WL.Combat.Weapon { [System.Serializable] public class WLRapidShotJson { public int enabled = 0; public int[] classes = new int[0]; // 비우면 모든 직업 public int shots = 3; public string shotState = "attack"; public string shotClip = "FR_Bow1_10402"; // 놓는 순간 = 이 클립의 Projectile 이벤트 public float drawNormalized = 0.8f; // 모으기 동안 멈출 자세(활 당김) · 첫 발 시작 public float fromNormalized = 0.55f; // 둘째 발부터 여기서 재생 public float shotSpeed = 2.5f; // 발 재생 배속 public float endNormalized = 0.97f; // 마지막 발 뒤 여기까지 재생하고 대기 public float chargeSeconds = 0.6f; public string socket = "w_archer_l"; // 활 소켓 public string chargeFx = ""; public float chargeFxScale = 0.06f; public string glowMaterial = "WL/Fx/WL_Mat_WeaponGlow"; public float[] glowColor = { 1.2f, 1.3f, 1.5f }; public float glowMax = 0.6f, glowFill = 0.3f, glowRimPower = 2.5f, glowFadeSeconds = 0.2f; public float markFront = 0.15f; // 과녁을 몸 반지름 + 이 거리(m)만큼 카메라 쪽 앞에 public float markPerHeight = 0.35f; // 과녁 배율 = 몹 키(m) × 이 값 public float markMin = 0.3f, markMax = 2f; public float markExtraLife = 0.3f; // 마지막 발 뒤 과녁 유지(초) public string[] noRecoil = new string[0]; // 쏠 때 시전자 넉백을 하지 않을 다른 스킬(eEffect 이름) // #921 PD 「이펙트 궤적이 타겟 방향으로만 집중해서 날아가도록 · 그 방향으로 화살 기본 공격의 사정거리만큼 직선상 경로상 모든 적에게 피해(레이저 판정)」 public int laser = 0; // 1 = 발마다 PC → 대상 방향 직선(길이 = 기본 공격 사정거리 · 흰 원) 위 모든 적에게 그 화살의 한 발 피해를 즉시 · 화살은 판정 없이 사정거리까지만 곧게 날아간다 public float laserWidth = 0.6f; // 직선 판정 폭(m) — 적 몸 반지름은 따로 더한다 public int aimBody = 1; // 1 = 화살이 대상 몸 중심(공중 몹은 그림 가운데) 쪽으로 곧게 public int chargeBlocksMove = 0; // #922 PD 「주력 스킬 모으기 이펙트 재생중에는 이동하지 못하게(구르기로 취소는 가능)」 — 1 = 모으기 동안 이동 입력 무시(WLSkillCancel.HoldMove · 구르기 = 스킵) public int holdThroughShots = 0; // #922 1 = 막기를 마지막 발까지(모으기만 막으면 이동 키를 누른 채일 때 모으기 직후 이동이 들어와 한 발도 안 쏘고 끊겼다 · Play 실측) public float holdMaxSeconds = 3f; // 막기 상한(초) — 끝나면 어떻게든 푼다 public string note = ""; } public static class WLRapidShot { public const string ResourcesPath = "WL/WLRapidShot"; public static int Taken, Shots, Aborted, RecoilSkipped, Errors, Lasers, LaserHits; public static string LastLog = ""; static WLRapidShotJson s_cfg; internal static WLRapidShotJson Cfg { get { if (s_cfg != null) return s_cfg; s_cfg = new WLRapidShotJson(); 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; } } [RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.SubsystemRegistration)] static void Reset() { s_cfg = null; } static int ClassOf(PCActor pc) { return pc != null ? pc.Get_ID() : 0; } /// 원본 Skill_RapidShot.Set 훅 — true 면 WL 이 이 투사체를 맡는다(원본 Co_Update 건너뜀). public static bool Take(ProjectileBase pb, List shots, string projectile) { try { var c = Cfg; if (c.enabled == 0 || pb == null || shots == null || shots.Count == 0) return false; var data = pb.Get_ProjectTileData(); var pc = data != null ? data.shooter as PCActor : null; if (pc == null || pc.m_animation == null) return false; if (c.classes != null && c.classes.Length > 0 && System.Array.IndexOf(c.classes, ClassOf(pc)) < 0) return false; var run = pb.GetComponent(); if (run == null) run = pb.gameObject.AddComponent(); run.Begin(pb, pc, data.target, shots, projectile, c); Taken++; return true; } catch (System.Exception ex) { Errors++; LastLog = "넘겨받기 실패 — " + ex.Message; return false; } } /// 원본 활 스킬 Set 훅 — true 면 시전자 넉백(쏠 때 뒤로 밀림)을 하지 않는다. public static bool NoRecoil(Actor shooter, string skill) { var c = Cfg; if (c.enabled == 0 || c.noRecoil == null || System.Array.IndexOf(c.noRecoil, skill) < 0 || !(shooter is PCActor)) return false; RecoilSkipped++; return true; } } /// 넘겨받은 속사 투사체 1개의 진행 — 과녁 · 모으기 · 빠른 연사 · 끝. internal sealed class WLRapidShotRunner : MonoBehaviour { ProjectileBase _pb; PCActor _pc; Actor _target; List _shots; string _proj; WLRapidShotJson _c; Animator _an; bool _animOwned; Coroutine _co; readonly WLWeaponGlowFx _glow = new WLWeaponGlowFx(); Transform _socket; Vector3 _centerLocal; GameObject _charge; static readonly Dictionary s_fx = new Dictionary(); public void Begin(ProjectileBase pb, PCActor pc, Actor target, List shots, string proj, WLRapidShotJson c) { _pb = pb; _pc = pc; _target = target; _shots = shots; _proj = proj; _c = c; _an = pc.m_animation; if (_co != null) StopCoroutine(_co); _co = StartCoroutine(Run()); } IEnumerator Run() { var c = _c; var an = _an; float releaseN = ReleaseNormalized(an, c.shotClip); if (releaseN <= 0f) { WLRapidShot.LastLog = "놓는 이벤트 없음 " + c.shotClip; releaseN = 0.92f; } int n = Mathf.Clamp(c.shots, 1, _shots.Count); // ① 과녁 — 몹 크기에 맞춘 배율 · 카메라 쪽 앞(몸에 가려지지 않게) ShowMark(c.chargeSeconds + n * 0.2f + c.markExtraLife); // ② 모으기 — 활 당긴 자세 · 활 흰 빛 · 모으기 이펙트 an.fireEvents = false; _animOwned = true; an.Play(c.shotState, 0, c.drawNormalized); an.speed = 0f; an.Update(0f); Face(); _socket = FindSocket(_pc, c.socket); if (_socket != null) { _glow.Begin(_socket, c.glowMaterial); _centerLocal = WLWeaponGlowFx.CenterLocal(_socket); } _charge = Spawn(c.chargeFx, BowCenter(), c.chargeFxScale, c.chargeSeconds + 0.5f); if (c.chargeBlocksMove != 0) { WLSkillCancel.HoldMove(_pc, c.holdThroughShots != 0 ? Mathf.Max(c.chargeSeconds, c.holdMaxSeconds) : c.chargeSeconds); _holding = true; } // #922 모으기(· 마지막 발까지) 이동 무시(구르기 = 스킵) var col = Rgb(c.glowColor, 1f); float t = 0f; while (t < c.chargeSeconds) { if (Broken()) { End(false); yield break; } if (an.speed != 0f) an.speed = 0f; if (!an.GetCurrentAnimatorStateInfo(0).IsName(c.shotState)) an.Play(c.shotState, 0, c.drawNormalized); // 다른 연출이 상태를 바꾸면 되돌림 float k = Mathf.Clamp01(t / Mathf.Max(0.01f, c.chargeSeconds)); _glow.Set(c.glowMax * k, col, c.glowFill, c.glowRimPower); if (_charge != null) _charge.transform.position = BowCenter(); t += Time.deltaTime; yield return null; } _glow.Set(c.glowMax, col, c.glowFill, c.glowRimPower); if (_charge != null) { Destroy(_charge); _charge = null; } if (_holding && c.holdThroughShots == 0) { _holding = false; WLSkillCancel.EndHoldMove(_pc, true); } // #922 모으기 끝 — 그동안 미룬 이동 입력 적용(마지막 발까지 막을 때는 연사 뒤에) // ③ 빠른 연사 n 발 for (int i = 0; i < n; i++) { Face(); float startN = i == 0 ? c.drawNormalized : c.fromNormalized, s0 = Time.time; an.Play(c.shotState, 0, startN); an.speed = Mathf.Max(0.1f, c.shotSpeed); yield return null; while (an.GetCurrentAnimatorStateInfo(0).normalizedTime < releaseN && Time.time - s0 < 1f) { if (Broken()) { End(false); yield break; } if (an.speed != c.shotSpeed) an.speed = c.shotSpeed; if (!an.GetCurrentAnimatorStateInfo(0).IsName(c.shotState)) an.Play(c.shotState, 0, startN); yield return null; } var data = _shots[i]; ProjectileInfo.Ins.Shoot_Projectile(_proj, data, p => { p.m_Pierce = true; WLProjectileVisual.ApplyTo(p, _proj); if (_c.laser != 0) Laser(p); }); // 원본과 같은 한 발 · 시전자 넉백 없음 · #917 겉모습 · 명중 이펙트(WLProjectileVisual 표) · #921 레이저 판정 WLRapidShot.Shots++; } if (_holding) { _holding = false; WLSkillCancel.EndHoldMove(_pc, true); } // #922 마지막 발 — 미룬 이동 입력 적용 // ④ 마무리 — 빛 꺼짐 · 끝 자세 float f0 = Time.time; while (an.GetCurrentAnimatorStateInfo(0).normalizedTime < c.endNormalized && Time.time - f0 < 0.5f) { if (Broken()) { End(false); yield break; } float k = 1f - Mathf.Clamp01((Time.time - f0) / Mathf.Max(0.01f, c.glowFadeSeconds)); _glow.Set(c.glowMax * k, col, c.glowFill, c.glowRimPower); yield return null; } End(true); } /// #921 레이저 판정 — PC 발에서 대상 쪽 수평 방향으로 기본 공격 사정거리(흰 원 WLRangeRing.AttackReach)만큼의 직선 위(폭 laserWidth + 몸 반지름) 모든 적에게 /// 이 화살의 원본 피해 경로(ProcessCollider · 끄지 않음)를 즉시 · 화살은 대상 몸 중심 쪽으로 곧게 사정거리까지만 날고 자기 판정은 끈다(이중 피해 방지). void Laser(ProjectileBase p) { var c = _c; if (p == null || _pc == null) return; float range = WL.Combat.RangeRing.WLRangeRing.AttackReach(_pc); if (range <= 0f) return; Vector3 origin = _pc.Get_position(); Vector3 dir = _pc.transform.forward; dir.y = 0f; bool hasTarget = !DSUtil.CheckNull(_target) && !_target.IsDead(); if (hasTarget) { var to = _target.Get_position() - origin; to.y = 0f; if (to.sqrMagnitude > 1e-4f) dir = to; } if (dir.sqrMagnitude < 1e-6f) return; dir.Normalize(); // ① 겉모습 — 대상 몸 중심 쪽으로 곧게 · 사정거리 끝에서 사라짐 Vector3 look = dir; if (c.aimBody != 0 && hasTarget) { var v = WL.Combat.Impact.WLImpactFx.BodyCenter(_target) - p.transform.position; var vf = v; vf.y = 0f; if (vf.sqrMagnitude > 0.01f) look = v; } p.transform.rotation = Quaternion.LookRotation(look); var pd = p.Get_ProjectTileData(); var start = p.transform.position - origin; start.y = 0f; if (pd != null && pd.Speed > 0f) pd.LifeTime = Mathf.Max(0.05f, (range - Vector3.Dot(start, dir)) / pd.Speed); // ② 판정 — 직선 위 모든 적 int hits = 0; var info = ActorInfo.Ins; var list = info != null ? info.Get_EnemyActors(_pc.IsEnemy(), origin, range + 2f) : null; if (list != null) { 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; float body = WL.Combat.WLCombatMotionSettings.BodyRadius(a), t = Vector3.Dot(v, dir); if (t < -body || t > range + body) continue; if ((v - dir * t).magnitude > c.laserWidth * 0.5f + body) continue; var col = a.GetComponent(); if (col == null || !col.enabled) continue; if (p.ProcessCollider(col, false)) hits++; } info.Release_Actors(list); } WLLaserMute.Mute(p); WLRapidShot.LaserHits += hits; WLRapidShot.Lasers++; WLRapidShot.LastLog = "레이저 " + range.ToString("F2") + " m · 맞음 " + hits; } bool Broken() { return _pc == null || _pc.IsDead() || _pc.Get_CurAnim() != eAnim.Skill; } bool _holding; void End(bool toIdle) { if (_holding) { _holding = false; WLSkillCancel.EndHoldMove(_pc, toIdle); } if (!toIdle) { WLRapidShot.Aborted++; WLRapidShot.LastLog = "끊김(이동 · 구르기)"; } if (_animOwned && _an != null) { _an.speed = 1f; _an.fireEvents = true; } _animOwned = false; _co = null; if (toIdle && _pc != null && !_pc.IsDead() && _pc.Get_CurAnim() == eAnim.Skill) _pc.Play_Idle(); _glow.End(); if (_charge != null) { Destroy(_charge); _charge = null; } WLSlashFlurry.Off(_pb); } void OnDisable() { if (_holding) { _holding = false; WLSkillCancel.EndHoldMove(_pc, false); } if (_co != null) { StopCoroutine(_co); _co = null; } if (_animOwned && _an != null) { _an.speed = 1f; _an.fireEvents = true; _animOwned = false; } _glow.End(); if (_charge != null) { Destroy(_charge); _charge = null; } } void Face() { if (_pc == null || DSUtil.CheckNull(_target) || _target.IsDead()) return; var to = _target.Get_position() - _pc.Get_position(); to.y = 0f; if (to.sqrMagnitude > 1e-4f) _pc.transform.rotation = Quaternion.LookRotation(to); } Vector3 BowCenter() { if (_socket != null) return _socket.TransformPoint(_centerLocal); return _pc != null ? _pc.Get_Center_position() : transform.position; } void ShowMark(float life) { if (DSUtil.CheckNull(_target) || InGameInfo.Ins == null) return; var c = _c; var target = _target; float h = Mathf.Max(0.1f, target.Get_Top_postion().y - target.Get_position().y); float scale = Mathf.Clamp(h * c.markPerHeight, c.markMin, c.markMax); InGameInfo.Ins.Show_Effect("Effect_BulletTarget", target, eEffectLocation.Mid, life, off => { if (off == null) return; var fit = off.GetComponent(); if (fit == null) fit = off.gameObject.AddComponent(); fit.Begin(target, c.markFront, scale, life); }); } static Transform FindSocket(PCActor pc, string name) { if (pc == null || pc.tfs_weapon == null) return null; foreach (var s in pc.tfs_weapon) if (s != null && s.name == name && s.gameObject.activeInHierarchy) return s; return null; } static float ReleaseNormalized(Animator an, string clipName) { if (an == null || an.runtimeAnimatorController == null) return -1f; foreach (var clip in an.runtimeAnimatorController.animationClips) { if (clip == null || clip.name != clipName || clip.length <= 0f) continue; foreach (var ev in clip.events) if (ev.functionName == "Projectile") return ev.time / clip.length; } return -1f; } static GameObject Spawn(string path, Vector3 pos, float scale, float life) { if (string.IsNullOrEmpty(path)) return null; GameObject prefab; if (!s_fx.TryGetValue(path, out prefab)) { prefab = Resources.Load(path); s_fx[path] = prefab; } if (prefab == null) { WLRapidShot.LastLog = "이펙트 없음 " + path; return null; } var go = Instantiate(prefab, pos, prefab.transform.rotation); go.transform.localScale = prefab.transform.localScale * Mathf.Max(0.01f, scale); Destroy(go, Mathf.Max(0.1f, life)); return go; } static Color Rgb(float[] a, float d) { return new Color(a != null && a.Length > 0 ? a[0] : d, a != null && a.Length > 1 ? a[1] : d, a != null && a.Length > 2 ? a[2] : d, 1f); } } /// #916 과녁 맞춤 — 원본 TurnOff_GO 가 Update 에서 대상 가운데로 옮긴 뒤(LateUpdate) 카메라 쪽 앞으로 당기고 몹 크기 배율을 준다. 기간이 지나면 원래 배율. public sealed class WLMarkFit : MonoBehaviour { Actor _target; float _front, _scale = 1f, _until; bool _on; Vector3 _scale0 = Vector3.one; bool _hasScale0; public void Begin(Actor target, float front, float scale, float life) { if (!_hasScale0) { _scale0 = transform.localScale; _hasScale0 = true; } _target = target; _front = front; _scale = scale; _until = Time.time + life; _on = true; foreach (var ps in GetComponentsInChildren(true)) { var m = ps.main; m.scalingMode = ParticleSystemScalingMode.Hierarchy; } Apply(); } void LateUpdate() { if (_on) Apply(); } void Apply() { if (Time.time > _until || DSUtil.CheckNull(_target) || _target.IsDead()) { Stop(); return; } var mid = WL.Combat.Impact.WLImpactFx.BodyCenter(_target); // #921 PD 「몸 정중앙 · 공중 몬스터라도」 — 원본 Center_position(등장 때 잰 고정 높이) 대신 지금 몸 중심(떠 있는 몹 = 지금 그림 가운데) var cam = Camera.main; var toCam = cam != null ? (cam.transform.position - mid).normalized : Vector3.up; transform.position = mid + toCam * (WL.Combat.WLCombatMotionSettings.BodyRadius(_target) + _front); transform.localScale = _scale0 * _scale; } void Stop() { _on = false; if (_hasScale0) transform.localScale = _scale0; } void OnDisable() { Stop(); } } /// #921 레이저로 피해를 준 속사 화살 — 날아가는 동안 자기 판정을 끈다(이중 피해 방지) · 풀로 돌아갈 때(꺼질 때) 되살린다. public sealed class WLLaserMute : MonoBehaviour { Collider[] _cols; public static void Mute(ProjectileBase p) { if (p == null) return; var m = p.GetComponent(); if (m == null) m = p.gameObject.AddComponent(); if (m._cols == null) m._cols = p.GetComponentsInChildren(true); for (int i = 0; i < m._cols.Length; i++) if (m._cols[i] != null) m._cols[i].enabled = false; m.enabled = true; } void OnDisable() { if (_cols != null) for (int i = 0; i < _cols.Length; i++) if (_cols[i] != null) _cols[i].enabled = true; enabled = false; } } }