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

161 lines
8.7 KiB
C#

// ─────────────────────────────────────────────────────────────────────────────
// WLSkillChain.cs — 조건 발동 스킬: 무기 기술 표 trigger 칸(예: hit:Skill_ShieldAttack)의 스킬을 그 조건 때 쿨 없이 시전
//
// PD 원문(#890 · 2026-10-17): 「한손검 전설 스킬은 별도의 쿨타임 없이 "방패 치기 명중 시" 발동 되도록 해줘.」
//
// ■ 원리 — 타격 확정 훅(CombatEvents.HitConfirmed)에서 내 PC 가 쏜 스킬(피해 정보 Beater_pd)이 명중하면, 낀 무기의 표 행 중
// trigger = "hit:<그 스킬 eEffect>" 이고 등급이 해금 단계 이상인 행을 찾아 delay 초 뒤 시전한다(같은 프레임 재진입 회피).
// 원본 Use_Skill 은 스킬 동작 중이면 실패한다 → cancelTrigger 1 이면 앞 스킬의 남은 동작을 Play_Idle 로 끊고 바로 잇는다.
// 시전 = 시험 모드(쿨 무시 · MP 0 복사본 · 사거리 = 표 range_m) — 그 행은 자동 시전 슬롯에 안 들어간다(WLWeaponSkillSlots).
// ■ 값 = Resources/WL/WLSkillChain.json (C45) · 끄기 = enabled 0(구독 0) · 원본 수정 0줄
// 🔴 Debug.LogError 금지(원본 ErrorLogHook 이 팝업을 띄운다).
// ─────────────────────────────────────────────────────────────────────────────
using System.Collections.Generic;
using System.Reflection;
using UnityEngine;
using WL.Combat.Core;
using WL.Equip;
namespace WL.Combat.Weapon
{
[System.Serializable]
public class WLSkillChainJson
{
public int enabled = 0;
public float delay = 0.12f; // 명중 → 시전까지(s)
public int cancelTrigger = 1; // 1 = 앞 스킬 남은 동작을 끊고 잇는다 · 0 = 끝날 때까지 기다린다
public float maxWait = 1f; // cancelTrigger 0 일 때 최대 대기(s)
public float minRange = 0f; // 이어 쓰는 스킬의 시전 거리 하한(m) — 명중 직후라 대상이 밀려나도 끊기지 않게
public string note = "";
}
public static class WLSkillChain
{
public const string ResourcesPath = "WL/WLSkillChain";
public static int Triggered, Cast, Failed;
public static string LastLog = "", LastFail = "";
static WLSkillChainJson s_cfg;
static WLSkillChainRunner s_runner;
static PCActor s_pc;
static WLWeaponSkillRow s_row;
static float s_at, s_deadline;
static bool s_pending;
static readonly Dictionary<int, SkillListTableData> s_data = new Dictionary<int, SkillListTableData>();
static WLSkillChainJson Cfg
{
get
{
if (s_cfg != null) return s_cfg;
s_cfg = new WLSkillChainJson();
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.AfterSceneLoad)]
static void Boot()
{
s_cfg = null; s_pending = false; s_data.Clear();
if (Cfg.enabled == 0) { CombatEvents.HitConfirmed.Remove(OnHit); return; }
CombatEvents.HitConfirmed.Add(OnHit);
}
static void OnHit(in HitConfirmedEvent e)
{
try
{
if (s_pending) return; // 이미 예약됨(같은 시전의 다단 히트)
var sk = e.projectile != null ? e.projectile.m_SkillListTableData : null;
var pc = e.attacker as PCActor;
if (sk == null || pc == null || !pc.IsMainPC()) return;
var w = WLInventory.EquippedInSlot(eFREquipmentType.Weapon);
if (w == null || w.itemJson == null) return;
var rows = WLWeaponSkillTable.Rows(w.itemJson.GroupNo);
if (rows == null) return;
string trig = "hit:" + sk.e_Skill;
WLGradeTier gt; int plus;
int tier = WLEquipGrades.TryMap(w.itemJson, out gt, out plus) ? gt.tier : 1;
foreach (var r in rows)
{
if (!r.IsTriggered || r.trigger != trig || tier < r.unlockTier || r.skillId == sk.n_SkillID) continue;
var c = Cfg;
s_pc = pc; s_row = r; s_at = Time.time + c.delay; s_deadline = s_at + c.maxWait; s_pending = true;
EnsureRunner();
Triggered++;
LastLog = "예약 " + r.skillName + " ← " + sk.e_Skill + " 명중 #" + Triggered;
break;
}
}
catch (System.Exception ex) { Failed++; LastLog = "명중 처리 실패 — " + ex.Message; }
}
static void EnsureRunner()
{
if (!DSUtil.CheckNull(s_runner)) return;
var go = new GameObject("[WL890] SkillChainRunner");
go.hideFlags = HideFlags.HideAndDontSave;
s_runner = go.AddComponent<WLSkillChainRunner>();
}
internal static void Tick()
{
if (!s_pending || Time.time < s_at) return;
var pc = s_pc; var r = s_row;
if (DSUtil.CheckNull(pc) || pc.IsDead() || r == null) { s_pending = false; return; }
var c = Cfg;
// #908 이동 중(조이스틱 1 · 구르기)이면 잇지 않는다 — 이어 붙인 스킬이 이동 입력에 바로 스킵(WLSkillCancel)되어 첫 프레임만 번쩍였다(실측 2 프레임)
if (pc.JoystickStatus == 1 || pc.Get_CurAnim() == eAnim.Dodge || pc.Get_CurAnim() == eAnim.Run) { s_pending = false; Failed++; LastLog = "이동 중 — 연계 안 함 " + r.skillName; return; }
if (pc.Get_CurAnim() >= eAnim.Skill)
{
if (c.cancelTrigger != 0) pc.Play_Idle(); // 앞 스킬(방패 치기)의 남은 동작을 끊고 잇는다
else if (Time.time < s_deadline) return;
}
s_pending = false;
try
{
var data = Prep(pc, r);
if (data == null) { Failed++; LastLog = "스킬 데이터 없음 " + r.skillId; return; }
bool ok = pc.Use_Skill(data, true, Mathf.Clamp(r.slot - 1, 0, 5), true);
if (ok) { Cast++; LastLog = "발동 " + r.skillName + " #" + Cast; }
else
{
Failed++;
var tg = pc.Get_Target();
LastFail = "발동 안 됨 " + r.skillName + " · 동작 " + pc.Get_CurAnim() + " · 대상 " + (DSUtil.CheckNull(tg) ? "없음" : Vector3.Distance(tg.Get_position(), pc.Get_position()).ToString("F2") + " m / 사거리 " + data.f_SkillRange) + " · CC " + pc.IsCC();
LastLog = LastFail;
}
}
catch (System.Exception ex) { Failed++; LastLog = "발동 실패 — " + ex.Message; }
}
static MethodInfo s_clone; static FieldInfo s_fId;
/// <summary>시험 시전용 복사본(원본 표 데이터 무수정) — MP 0 · 사거리 = 표 range_m · 직업 칸 = PC 직업.</summary>
static SkillListTableData Prep(PCActor pc, WLWeaponSkillRow r)
{
SkillListTableData d;
if (s_data.TryGetValue(r.skillId, out d)) return d;
var src = table_skilllist.Ins != null ? table_skilllist.Ins.Get_Data_orNull(r.skillId) : null;
if (src == null) return null;
if (s_clone == null) s_clone = typeof(object).GetMethod("MemberwiseClone", BindingFlags.Instance | BindingFlags.NonPublic);
if (s_fId == null) s_fId = typeof(Actor).GetField("m_ID", BindingFlags.Instance | BindingFlags.NonPublic | BindingFlags.Public);
d = (SkillListTableData)s_clone.Invoke(src, null);
d.n_MP = 0;
if (r.rangeM > 0f) d.f_SkillRange = r.rangeM;
d.f_SkillRange = Mathf.Max(d.f_SkillRange, Cfg.minRange);
if (s_fId != null) d.n_SkillClassID = (int)s_fId.GetValue(pc);
s_data[r.skillId] = d;
return d;
}
}
/// <summary>WLSkillChain 의 시간 축. 숨김 GameObject 1개 · 예약이 있을 때만 일한다.</summary>
internal sealed class WLSkillChainRunner : MonoBehaviour
{
void Update() { WLSkillChain.Tick(); }
}
}