// ───────────────────────────────────────────────────────────────────────────── // WLWeaponAttackPattern.cs — 무기 계열별 기본 공격 패턴 (#825 · 2026-10-09 · PD 「6종 무기의 기본 공격 패턴 구현」) // // 모션·사거리·탄(근접 Projectile_Stop / 원거리 Projectile_Strait)·무기 모델은 WLWeaponClassApplier 의 직업 전환이 바꾼다. // 이 드라이버는 공격 표(WLWeaponAttackTable.csv)의 나머지 칸을 힘민지 전투 훅(원본 0줄)에 얹는다: // - dmg_ratio_per_hit : 타격 순간(CombatEvents.HitboxHit) ProjectileData.AddDmgPercent 를 그 콤보 단계의 값으로 덮는다 // (Get_DamageInfo 가 BaseDmg × AddDmgPercent 로 계산 · 원본은 클립 이벤트 float) // - hits : 2 이상이면 그 단계 동안 MultiShot CC(원본 Shoot_Projectile 의 다발 사격 경로)를 켠다 → 탄 수 = hits · 피해 감산 0 // - attack_speed_mult : FinalAttackSpeed 를 기준값 × 배율로 유지(재계산으로 되돌아오면 0.5초 안에 다시) // - range_m(원거리) : 활·지팡이는 WLTargetingSettings 의 탐색·추적 거리(#813t3 근접 3 m 제한)를 표 사거리 + 여유로 덮는다 // - 미적용(다음 단계) : arc_deg(클립 판정 상자 고정) · aoe_radius(판정 상자 크기) · proj_speed/pierce(탄 프리팹 값) · move_m(루트모션 표) // 켜고 끄기 = WLWeaponSettings.attackPattern(값 하나 · C8). // ───────────────────────────────────────────────────────────────────────────── using System.Collections.Generic; using UnityEngine; using WL.Combat.Core; using WL.Equip; namespace WL.Combat.Weapon { public class WLWeaponAttackPattern : MonoBehaviour { public static WLWeaponAttackPattern Instance { get; private set; } public static int Applied, HitsScaled, StagesSeen, MultiShotSet, SpeedSet; public static string LastLog { get; private set; } = ""; public static int CurrentWeapon, CurrentStage; public static float LastRatio, LastSpeed; List _steps; int _weapon = -2; UnityEngine.Object _pc; float _t; float _baseSpd = -1f, _lastSetSpd = -1f, _mult = 1f; int _stage = 1; int _multiShotVal = -1; [RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.AfterSceneLoad)] static void Boot() { var cfg = WLWeaponSettings.Instance; if (cfg == null || cfg.enabled_ == 0 || Instance != null) return; var go = new GameObject("WL_WeaponAttackPattern"); DontDestroyOnLoad(go); Instance = go.AddComponent(); } void OnEnable() { CombatEvents.ComboStage.Add(OnStage); CombatEvents.HitboxHit.Add(OnHit); CombatEvents.AttackStarted.Add(OnAttackStarted); WLInventory.Changed += Dirty; } void OnDisable() { CombatEvents.ComboStage.Remove(OnStage); CombatEvents.HitboxHit.Remove(OnHit); CombatEvents.AttackStarted.Remove(OnAttackStarted); WLInventory.Changed -= Dirty; ClearOverrides(); } void OnDestroy() { if (Instance == this) Instance = null; } void Dirty() { _weapon = -2; _t = 999f; } void Update() { var cfg = WLWeaponSettings.Instance; if (cfg == null || cfg.enabled_ == 0) return; if (cfg.attackPattern == 0) { if (_steps != null) ClearOverrides(); return; } _t += Time.unscaledDeltaTime; if (_t < cfg.pollSec) return; _t = 0f; try { Tick(cfg); } catch (System.Exception ex) { LastLog = "예외 — " + ex.Message; } } void Tick(WLWeaponSettings cfg) { if (!MyValue.bMyPC || MyValue.MyPC == null) { _pc = null; return; } var pc = MyValue.MyPC; if (!WLInventory.EnsureLoaded() || !WLWeaponAttackTable.EnsureLoaded()) return; var w = WLInventory.EquippedInSlot(eFREquipmentType.Weapon); int wid = w != null && w.itemJson != null ? w.itemJson.GroupNo : 0; bool pcChanged = !ReferenceEquals(pc, _pc); if (wid != _weapon || pcChanged) { // #976 PD 「활 공격 속도만 늦추라고 했는데 다른 무기의 공격 속도도 같이 느려진 것 같아」 — 무기를 바꿀 때 기준을 버리고 지금 값(활 × 0.7 이 덮인 값)을 새 기준으로 잡아 // 다른 무기에 0.7 배가 남았다 → 같은 PC 면 기준(_baseSpd)을 유지(재계산으로 값이 바뀌었으면 ApplySpeed 가 그 값을 새 기준으로) bool keepBase = !pcChanged && _baseSpd > 0f && _lastSetSpd > 0f; _pc = pc; _weapon = wid; CurrentWeapon = wid; _steps = wid > 0 ? WLWeaponAttackTable.Steps(wid) : null; var f = _steps != null && _steps.Count > 0 ? _steps[0] : null; _mult = f != null && f.attackSpeedMult > 0f ? f.attackSpeedMult : 1f; if (!keepBase) { _baseSpd = -1f; _lastSetSpd = -1f; } // 원거리 계열 = 표 사거리로 탐색·추적 거리 덮기(근접은 에셋 값 그대로) bool ranged = f != null && (f.family == "Bow" || f.family == "Staff"); float r = ranged ? f.rangeM + cfg.rangedRangeMargin : cfg.meleeSearchRange; // #893 PD 「인식 범위는 모든 무기 공통 · 활 사정거리와 동일」 — 계열이 정해져 있으면 그 계열 표 사거리를 모든 무기에 쓴다 float common = WLWeaponAttackTable.MaxRangeOfFamily(cfg.recognitionFamily); if (common > 0f) r = common; WL.Settings.WLTargetingSettings.RuntimeSearchRangeOverride = r; WL.Settings.WLTargetingSettings.RuntimeChaseRangeOverride = r; SetMultiShot(pc, 0); Applied++; LastLog = "무기 " + wid + (f != null ? "(" + f.weaponName + " · " + f.family + ")" : "") + " 단계 " + (_steps != null ? _steps.Count : 0) + " · 공속 ×" + _mult.ToString("0.##") + (common > 0f ? " · 인식 " + r.ToString("0.#") + " m(" + cfg.recognitionFamily + " 공통)" : ranged ? " · 사거리 " + r.ToString("0.#") + " m" : ""); if (cfg.verboseLog != 0) Debug.Log("[WL-825] 공격 패턴 " + LastLog); } ApplySpeed(pc); } /// FinalAttackSpeed = 기준 × 배율. 재계산(Reset)으로 값이 바뀌면 그 값을 새 기준으로 삼아 다시 덮는다. void ApplySpeed(PCActor pc) { if (_steps == null && _baseSpd < 0f) return; // #976 표에 없는 무기 = 배율 1 · 앞 무기 배율을 덮었으면 기준으로 되돌린다 ActorStatInfo info; try { info = pc.Get_StatInfo(); } catch { return; } if (info == null) return; float cur = (float)info.Get_Stat(eStat.FinalAttackSpeed); if (_baseSpd < 0f || Mathf.Abs(cur - _lastSetSpd) > 0.0005f) _baseSpd = cur; // 처음 · 또는 밖에서 바뀜 = 새 기준 float desired = _baseSpd * _mult; if (Mathf.Abs(cur - desired) > 0.0005f) { info.Set_Stat(eStat.FinalAttackSpeed, desired); _lastSetSpd = desired; LastSpeed = desired; SpeedSet++; } else _lastSetSpd = cur; } WLWeaponAttackStep StepOf(int stage) { if (_steps == null || _steps.Count == 0) return null; for (int i = 0; i < _steps.Count; i++) if (_steps[i].step == stage) return _steps[i]; return _steps[Mathf.Clamp(stage - 1, 0, _steps.Count - 1)]; } void OnAttackStarted(in AttackStartedEvent e) { if (!e.isMainPC || _steps == null) return; _stage = 1; CurrentStage = 1; ApplyStage(e.actor as PCActor, 1); } void OnStage(in ComboStageEvent e) { if (_steps == null || !ReferenceEquals(e.actor, MyValue.MyPC)) return; StagesSeen++; _stage = Mathf.Max(1, e.stage); CurrentStage = _stage; ApplyStage(e.actor, _stage); } void ApplyStage(PCActor pc, int stage) { var s = StepOf(stage); if (s == null || pc == null) return; SetMultiShot(pc, Mathf.Max(0, s.hits - 1)); } /// 원본 다발 사격 경로(Shoot_Projectile 의 MultiShot CC) — 탄 수 = 1 + CCVal · 피해 감산 CCDmg = 0. void SetMultiShot(PCActor pc, int extra) { if (pc == null || _multiShotVal == extra) return; try { var cc = pc.Get_CCData(eCC.MultiShot); if (cc == null) return; cc.CCVal = extra; cc.CCDmg = 0; cc.CCTime = extra > 0 ? 3600f : 0f; _multiShotVal = extra; MultiShotSet++; } catch (System.Exception ex) { LastLog = "MultiShot 실패 — " + ex.Message; } } /// 타격 순간 — 이 콤보 단계의 타당 피해 배율로 AddDmgPercent 를 덮는다(원본 Get_DamageInfo 가 바로 뒤에서 읽는다). void OnHit(in HitboxHitEvent e) { if (_steps == null || e.isSkill || e.data == null || !ReferenceEquals(e.shooter, MyValue.MyPC)) return; var s = StepOf(_stage); if (s == null || s.dmgRatioPerHit <= 0f) return; e.data.AddDmgPercent = s.dmgRatioPerHit; LastRatio = s.dmgRatioPerHit; HitsScaled++; } void ClearOverrides() { WL.Settings.WLTargetingSettings.RuntimeSearchRangeOverride = -1f; WL.Settings.WLTargetingSettings.RuntimeChaseRangeOverride = -1f; if (MyValue.bMyPC && MyValue.MyPC != null) SetMultiShot(MyValue.MyPC, 0); _steps = null; _weapon = -2; } public static string Dump() { return "weapon=" + CurrentWeapon + " stage=" + CurrentStage + " applied=" + Applied + " stages=" + StagesSeen + " hitsScaled=" + HitsScaled + " lastRatio=" + LastRatio.ToString("0.##") + " multiShotSet=" + MultiShotSet + " speedSet=" + SpeedSet + " lastSpeed=" + LastSpeed.ToString("0.###") + " search=" + WL.Settings.WLTargetingSettings.RetargetSearchRange.ToString("0.#") + " chase=" + WL.Settings.WLTargetingSettings.ChaseRange.ToString("0.#") + " | " + LastLog; } } }