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

374 lines
23 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// ─────────────────────────────────────────────────────────────────────────────
// 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<TextAsset>(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; }
/// <summary>원본 Skill_RapidShot.Set 훅 — true 면 WL 이 이 투사체를 맡는다(원본 Co_Update 건너뜀).</summary>
public static bool Take(ProjectileBase pb, List<ProjectileData> 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<WLRapidShotRunner>();
if (run == null) run = pb.gameObject.AddComponent<WLRapidShotRunner>();
run.Begin(pb, pc, data.target, shots, projectile, c);
Taken++;
return true;
}
catch (System.Exception ex) { Errors++; LastLog = "넘겨받기 실패 — " + ex.Message; return false; }
}
/// <summary>원본 활 스킬 Set 훅 — true 면 시전자 넉백(쏠 때 뒤로 밀림)을 하지 않는다.</summary>
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;
}
}
/// <summary>넘겨받은 속사 투사체 1개의 진행 — 과녁 · 모으기 · 빠른 연사 · 끝.</summary>
internal sealed class WLRapidShotRunner : MonoBehaviour
{
ProjectileBase _pb; PCActor _pc; Actor _target; List<ProjectileData> _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<string, GameObject> s_fx = new Dictionary<string, GameObject>();
public void Begin(ProjectileBase pb, PCActor pc, Actor target, List<ProjectileData> 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);
}
/// <summary>#921 레이저 판정 — PC 발에서 대상 쪽 수평 방향으로 기본 공격 사정거리(흰 원 WLRangeRing.AttackReach)만큼의 직선 위(폭 laserWidth + 몸 반지름) 모든 적에게
/// 이 화살의 원본 피해 경로(ProcessCollider · 끄지 않음)를 즉시 · 화살은 대상 몸 중심 쪽으로 곧게 사정거리까지만 날고 자기 판정은 끈다(이중 피해 방지).</summary>
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<Collider>();
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<WLMarkFit>(); if (fit == null) fit = off.gameObject.AddComponent<WLMarkFit>();
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<GameObject>(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); }
}
/// <summary>#916 과녁 맞춤 — 원본 TurnOff_GO 가 Update 에서 대상 가운데로 옮긴 뒤(LateUpdate) 카메라 쪽 앞으로 당기고 몹 크기 배율을 준다. 기간이 지나면 원래 배율.</summary>
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<ParticleSystem>(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(); }
}
/// <summary>#921 레이저로 피해를 준 속사 화살 — 날아가는 동안 자기 판정을 끈다(이중 피해 방지) · 풀로 돌아갈 때(꺼질 때) 되살린다.</summary>
public sealed class WLLaserMute : MonoBehaviour
{
Collider[] _cols;
public static void Mute(ProjectileBase p)
{
if (p == null) return;
var m = p.GetComponent<WLLaserMute>(); if (m == null) m = p.gameObject.AddComponent<WLLaserMute>();
if (m._cols == null) m._cols = p.GetComponentsInChildren<Collider>(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;
}
}
}