// ───────────────────────────────────────────────────────────────────────────── // 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) // ■ #975 multi = 활 기본 스킬(원본 Skill_MultiShot 703404 = 일반 공격 화살 추가 버프) — PD 「주력 스킬과 동일하게 모으기 이펙트 시전 후 전방으로 부채꼴 모양으로 // 퍼지는 화살 10발을 3회 연속으로 발사(멀티샷 형태 · 0.5초 간격)」 — 원본 Set 훅 1줄(TakeMulti)이 넘겨받아 ②모으기는 주력과 같고 ③에서 volleys 번 · // interval 초마다(놓는 순간 기준) 대상 쪽(없으면 정면) 부채꼴 fanDegrees 에 arrows 발(화살 = multi.projectile · 겉모습 WLProjectileVisual · 사거리 = 흰 원 × rangeMul) // ■ 값 = 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; // 모으기 동안 멈출 자세(활 당김) · 첫 발 시작 // #967 PD 「주력 스킬 모으기 시 활을 들지 않는 손이 멈춰 있는데 — 활을 쏠 때와 같이 자연스럽게 활시위를 당기는 포즈로 모으도록」 — chargeFromNormalized ≥ 0 이면 모으기 첫 chargeDrawSeconds 동안 // shotState 를 그 자리(활 든 채 시위에 손)부터 drawNormalized(끝까지 당김)까지 재생한 뒤 유지 · < 0 = 이전(처음부터 drawNormalized 정지) public float chargeFromNormalized = -1f; public float chargeDrawSeconds = 0.35f; public float releaseNormalized; // #967 > 0 = 놓는 순간(발사)을 이 정규화 시각으로 · 0 = shotClip 의 Projectile 이벤트 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 WLRapidShotMulti multi = new WLRapidShotMulti(); // #975 활 기본 스킬(멀티샷) public string note = ""; } /// #975 활 기본 스킬 — 주력과 같은 모으기 뒤 volleys 번 · interval 초 간격(놓는 순간)으로 앞쪽 부채꼴 fanDegrees 에 화살 arrows 발. [System.Serializable] public class WLRapidShotMulti { public int enabled = 0; public string projectile = "Projectile_ArcherSkill2Arrow"; // 화살(주력 속사와 같은 화살 · 겉모습 = WLProjectileVisual 표) public int volleys = 3; public float interval = 0.5f; public int arrows = 10; public float fanDegrees = 60f; // 부채꼴 전체 각(°) — 화살 사이 = fanDegrees / (arrows − 1) public float rangeMul = 1f; // 나는 거리 = 기본 공격 사정거리(흰 원) × 이 값 · 0 = 투사체 원래 수명 public int pierce = 0; // 1 = 관통 public int mark = 0; // 1 = 과녁 표시(주력처럼) public float drawFraction = 0.8f; // #976 다시 당기는 시간 상한 = interval × 이 값(간격이 당김 동작 0.23 s 보다 짧으면 그만큼 빨리 당긴다) public int noExtra = 1; // #977 1 = 화살에 원본 스킬 부가 효과(703404 방어력 감소 디버프) 없음 public string note = ""; } public static class WLRapidShot { public const string ResourcesPath = "WL/WLRapidShot"; public static int Taken, Shots, Aborted, RecoilSkipped, Errors, Lasers, LaserHits, TakenMulti, MultiArrows; 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; } } /// #975 원본 Skill_MultiShot.Set 훅 — true 면 WL 이 맡는다(원본 버프 건너뜀). 화살마다 스킬 투사체 데이터 복사본(시작 = 시전자 가운데). public static bool TakeMulti(ProjectileBase pb, ProjectileData data) { try { var c = Cfg; var m = c.multi; if (c.enabled == 0 || m == null || m.enabled == 0 || pb == null || data == null) return false; var pc = data.shooter as PCActor; 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; int n = Mathf.Max(1, m.volleys) * Mathf.Max(1, m.arrows); var list = new List(n); for (int i = 0; i < n; i++) { var d = new ProjectileData(); data.Copy(d); d.eStartPos = eProjectileStartPos.ShooterCenter; if (m.noExtra != 0) { d.e_SkillExtraType = eSkillExtraType.None; d.m_skillTypeConfigTable = null; d.SetSkillExtraType = false; } // #977 PD 「활 기본 스킬에 명중 된 적에게 발생하는 디버프를 제거」 — Actor.Check_Extra 가 None 이면 바로 돌아간다 · SetSkillExtraType 를 꺼야 ProjectileBase.Set 이 스킬 표 값으로 다시 넣지 않는다(1차 Play 실측 방어 감소 남음) list.Add(d); } var target = data.target; if (DSUtil.CheckNull(target) || target.IsDead()) target = pc.Get_Target(); var run = pb.GetComponent(); if (run == null) run = pb.gameObject.AddComponent(); run.BeginMulti(pb, pc, target, list, c); TakenMulti++; 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(); bool _multi; // #975 활 기본 스킬(부채꼴 다발) 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; _multi = false; if (_co != null) StopCoroutine(_co); _co = StartCoroutine(Run()); } /// #975 활 기본 스킬 — shots = 화살마다 데이터(volleys × arrows). public void BeginMulti(ProjectileBase pb, PCActor pc, Actor target, List shots, WLRapidShotJson c) { _pb = pb; _pc = pc; _target = target; _shots = shots; _proj = c.multi.projectile; _c = c; _an = pc.m_animation; _multi = true; if (_co != null) StopCoroutine(_co); _co = StartCoroutine(Run()); } IEnumerator Run() { var c = _c; var an = _an; var mc = c.multi; float releaseN = c.releaseNormalized > 0f ? c.releaseNormalized : ReleaseNormalized(an, c.shotClip); if (releaseN <= 0f) { WLRapidShot.LastLog = "놓는 이벤트 없음 " + c.shotClip; releaseN = 0.92f; } int n = _multi ? Mathf.Max(1, mc.volleys) : Mathf.Clamp(c.shots, 1, _shots.Count); // ① 과녁 — 몹 크기에 맞춘 배율 · 카메라 쪽 앞(몸에 가려지지 않게) · #975 기본 스킬(부채꼴)은 mark 1 일 때만 if (!_multi || mc.mark != 0) ShowMark(c.chargeSeconds + n * (_multi ? mc.interval : 0.2f) + c.markExtraLife); // ② 모으기 — 활 당긴 자세 · 활 흰 빛 · 모으기 이펙트 an.fireEvents = false; _animOwned = true; bool drawing = c.chargeFromNormalized >= 0f && c.chargeFromNormalized < c.drawNormalized; // #967 시위를 당기며 모은다 float poseN = drawing ? c.chargeFromNormalized : c.drawNormalized; an.Play(c.shotState, 0, poseN); 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 (drawing) // #967 시위 당기기 — chargeDrawSeconds 동안 부드럽게 끝까지 → 유지 { float dk = Mathf.SmoothStep(0f, 1f, Mathf.Clamp01(t / Mathf.Max(0.01f, c.chargeDrawSeconds))); float want = Mathf.Lerp(c.chargeFromNormalized, c.drawNormalized, dk); if (Mathf.Abs(want - poseN) > 1e-4f || !an.GetCurrentAnimatorStateInfo(0).IsName(c.shotState)) { poseN = want; an.Play(c.shotState, 0, poseN); } } else 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 발 · #975 기본 스킬 = n 번 부채꼴 다발(놓는 순간 사이 mc.interval 초) float lastFire = -99f; float shotSp = c.shotSpeed; // 발 재생 속도(애니메이터 속도를 직접 씀 = 기본 공격 공속 배율과 무관 · #975 실측 활 컨트롤러 재생 배수 파라미터 없음) float drawDur = Mathf.Max(0f, (releaseN - c.fromNormalized) * ClipLen(an, c.shotClip) / Mathf.Max(0.1f, c.shotSpeed)); // 다시 당겨 놓기까지(초 · 어림 — 실제 놓는 순간은 아래에서 interval 에 맞춤) // #976 간격(0.2 s)이 당김 동작보다 짧으면 다시 당기기를 interval × drawFraction 안에 끝나게 빨리 재생 float mDraw = _multi && mc.drawFraction > 0f ? Mathf.Min(drawDur, mc.interval * mc.drawFraction) : drawDur; float mSp = mDraw > 1e-3f && drawDur > 1e-3f ? c.shotSpeed * drawDur / mDraw : c.shotSpeed; for (int i = 0; i < n; i++) { if (_multi && i > 0) // #975 앞 다발을 놓은 뒤 자세를 이어 재생하다가 interval − 당기는 시간(− 몇 프레임 여유 · 첫 Play 실측 0.54 s = 2 프레임 늦음)에 다시 당긴다 while (Time.time < lastFire + mc.interval - mDraw - 3f * Time.smoothDeltaTime) { if (Broken()) { End(false); yield break; } an.speed = an.GetCurrentAnimatorStateInfo(0).normalizedTime < c.endNormalized ? shotSp : 0f; // 다음 상태로 넘어가지 않게(공격 2타 전이 0.98) yield return null; } Face(); float startN = i == 0 ? c.drawNormalized : c.fromNormalized, s0 = Time.time; float spNow = _multi && i > 0 ? mSp : shotSp; // #976 멀티샷 다시 당기기 = mSp an.Play(c.shotState, 0, startN); an.speed = Mathf.Max(0.1f, spNow); yield return null; while (an.GetCurrentAnimatorStateInfo(0).normalizedTime < releaseN && Time.time - s0 < 1f) { if (Broken()) { End(false); yield break; } if (an.speed != spNow) an.speed = spNow; if (!an.GetCurrentAnimatorStateInfo(0).IsName(c.shotState)) an.Play(c.shotState, 0, startN); yield return null; } if (_multi) { while (i > 0 && Time.time < lastFire + mc.interval) { if (Broken()) { End(false); yield break; } an.speed = 0f; yield return null; } // 놓는 순간을 interval 에 맞춤 an.speed = shotSp; Fan(i, Mathf.Max(1, mc.arrows)); } else { 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 레이저 판정 } lastFire = Time.time; 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; } /// #975 부채꼴 다발 1번 — 대상 쪽(없으면 정면) 수평 방향을 가운데로 fanDegrees 에 count 발을 고르게 · 화살은 그 방향으로 곧게 · 사거리 = 흰 원 × rangeMul. void Fan(int volley, int count) { var mc = _c.multi; var pc = _pc; if (pc == null || ProjectileInfo.Ins == null) return; Vector3 dir = pc.transform.forward; dir.y = 0f; if (!DSUtil.CheckNull(_target) && !_target.IsDead()) { var to = _target.Get_position() - pc.Get_position(); to.y = 0f; if (to.sqrMagnitude > 1e-4f) dir = to; } if (dir.sqrMagnitude < 1e-6f) dir = Vector3.forward; dir.Normalize(); float range = mc.rangeMul > 0f ? WL.Combat.RangeRing.WLRangeRing.AttackReach(pc) * mc.rangeMul : 0f; float step = count > 1 ? mc.fanDegrees / (count - 1) : 0f, a0 = count > 1 ? -mc.fanDegrees * 0.5f : 0f; for (int k = 0; k < count; k++) { int idx = volley * count + k; if (idx >= _shots.Count) break; var look = Quaternion.AngleAxis(a0 + step * k, Vector3.up) * dir; ProjectileInfo.Ins.Shoot_Projectile(_proj, _shots[idx], p => { p.m_Pierce = mc.pierce != 0; WLProjectileVisual.ApplyTo(p, _proj); p.transform.rotation = Quaternion.LookRotation(look); var pd = p.Get_ProjectTileData(); if (range > 0f && pd != null && pd.Speed > 0f) pd.LifeTime = Mathf.Max(0.05f, range / pd.Speed); }); WLRapidShot.MultiArrows++; } WLRapidShot.LastLog = "멀티샷 " + (volley + 1) + "번째 · " + count + "발 · 부채 " + mc.fanDegrees.ToString("0.#") + "° · 사거리 " + range.ToString("F1") + " m"; } static float ClipLen(Animator an, string clipName) { if (an != null && an.runtimeAnimatorController != null) foreach (var clip in an.runtimeAnimatorController.animationClips) if (clip != null && clip.name == clipName && clip.length > 0f) return clip.length; return 1f; } 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; } } }