314 lines
20 KiB
C#
314 lines
20 KiB
C#
// ─────────────────────────────────────────────────────────────────────────────
|
|
// WLWeaponSkillSlots.cs — 장착 무기의 고유 스킬을 전투 슬롯에 넣어 자동 발동시킨다 (#825 · 2026-10-09 · 기획 v2 §11·§12·§17 구현 순서 3)
|
|
//
|
|
// 힘민지 경로 그대로(원본 0줄): PCActor.Update(:78-104) 가 arr_magicData[i] 의 쿨이 차면 Use_Skill 을 부른다.
|
|
// - 슬롯 0 = 주력 · 슬롯 1 = 각성(전설 이상 · 등급표 awaken_open) · 슬롯 2~5 = 비움(서버 프리셋 스킬이 섞여 나가지 않게)
|
|
// - 직업 게이트(Use_Skill:700) = WLWeaponClassApplier 가 무기 직업으로 바꾼 뒤에만 넣는다(불일치면 대기)
|
|
// · #882 무기 직업으로 바뀐 뒤에도 표가 다른 직업 스킬을 지정하면(방패 치기 → 묵광 · 회심의 일격 → 대검) 직업 칸만 맞춘 복사본을 넣는다
|
|
// · #882 스킬 동작 중엔 바꾸지 않고 · 바꾸기 전 기다리던 스킬(m_Magic)을 거둔다(빈 슬롯을 가리켜 MyActor.Update 매 프레임 NRE — 대검 선택 오류)
|
|
// - MP 게이트(:733) = 무기 스킬 행의 n_MP 를 0 으로 덮는다(mpOverride) · 쿨 = 표 cool_s 를 f_CoolTime 에 덮는다
|
|
// - ActiveSkillLock(activeSkillsEnabled 0) 은 런타임 스위치(RuntimeDisabled)로 해제한다 — 에셋 값은 PD 확정 때 바꾼다
|
|
// - 서버 세이브는 건드리지 않는다: 런타임 배열(arr_magicData · arr_magicMaxCoolTime · dic_MagicData)만 리플렉션으로 쓴다(ActiveSkillLock 과 같은 방식)
|
|
// + 같은 PC 의 ReSet 이 다시 읽어도 유지되게 ServerData.Equip 의 슬롯(메모리)도 같은 값으로 맞춘다(WLSkillSlotOverride 와 같은 클라 전용 API)
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
|
|
using System;
|
|
using System.Collections;
|
|
using System.Collections.Generic;
|
|
using System.Reflection;
|
|
using UnityEngine;
|
|
using WL.Equip;
|
|
|
|
namespace WL.Combat.Weapon
|
|
{
|
|
public class WLWeaponSkillSlots : MonoBehaviour
|
|
{
|
|
public static WLWeaponSkillSlots Instance { get; private set; }
|
|
public static int Applied, Skipped;
|
|
public static string LastLog { get; private set; } = "";
|
|
public static int[] LastSlots = new int[0];
|
|
|
|
/// <summary>#900 대쉬 도착 시전 횟수 · 마지막 스킬(검증용).</summary>
|
|
public static int DashCasts; public static string LastDashCast = "";
|
|
|
|
/// <summary>#900 PD 「사정 거리 밖에서 스킬이 시전 가능한 상태일 경우(쿨타임 충족 시) 대쉬 후 바로 방패치기」 — 대쉬가 끝날 때 DashDriver 가 부른다.
|
|
/// 낀 무기 표에서 dash_cast 1 인 칸(주력 · 각성)을 원본 자동 시전 규칙(PCActor.WL_TryAutoSkillNow)으로 쓴다. 못 쓰면 false = 일반 공격.</summary>
|
|
public static bool TryDashCast(PCActor pc)
|
|
{
|
|
if (pc == null || !pc.IsMainPC()) return false;
|
|
var w = WLInventory.EquippedInSlot(eFREquipmentType.Weapon);
|
|
var rows = w != null && w.itemJson != null ? WLWeaponSkillTable.Rows(w.itemJson.GroupNo) : null;
|
|
if (rows == null) return false;
|
|
foreach (var r in rows)
|
|
{
|
|
if (!r.dashCast || r.IsWlSkill || r.IsTriggered) continue;
|
|
int idx = r.slot - 1; if (idx < 0 || idx > 1) continue;
|
|
if (pc.WL_TryAutoSkillNow(idx)) { DashCasts++; LastDashCast = r.skillName; return true; }
|
|
}
|
|
return false;
|
|
}
|
|
|
|
/// <summary>#902 우선 시전 횟수 · 마지막 스킬(검증용).</summary>
|
|
public static int FirstCasts; public static string LastFirstCast = "";
|
|
|
|
/// <summary>#902 PD 「해머 (점프 내려치기) 스킬도 시전 가능한 상태일 때(활성화 & 쿨타임 충족) 기본 공격보다 먼저 사용 · 공격 사거리 밖 적은 점프 후 내려치기」 —
|
|
/// MyActor 자동 전투가 다가가거나 때리기 직전에 부른다. 표 first_cast 1 칸을 쿨 · 사거리(표 range_m) · CC 규칙으로 시전(자동 시전 간격 2~3초는 무시 = 「쿨 충족 = 시전 가능」).</summary>
|
|
public static int FirstCastDeferred;
|
|
public static bool TryFirstCast(PCActor pc)
|
|
{
|
|
if (pc == null || !pc.IsMainPC()) return false;
|
|
if (WLWeaponClassApplier.Settling) { FirstCastDeferred++; return false; } // #952-④ 직업 전환 직후(컨트롤러 비동기 교체 중)엔 첫 시전을 미룬다 — 그때 나간 해머 강력한 일격이 PCMove 를 끈 채 끝나 가상패드가 굳었다(실측)
|
|
var w = WLInventory.EquippedInSlot(eFREquipmentType.Weapon);
|
|
var rows = w != null && w.itemJson != null ? WLWeaponSkillTable.Rows(w.itemJson.GroupNo) : null;
|
|
if (rows == null) return false;
|
|
foreach (var r in rows)
|
|
{
|
|
if (!r.firstCast || r.IsWlSkill || r.IsTriggered) continue;
|
|
int idx = r.slot - 1; if (idx < 0 || idx > 1) continue;
|
|
if (pc.WL_TryAutoSkillNow(idx, true)) { FirstCasts++; LastFirstCast = r.skillName; return true; }
|
|
}
|
|
return false;
|
|
}
|
|
public static int LastWeaponId, LastTier, LastClassId;
|
|
|
|
static FieldInfo s_fData, s_fMax, s_fCool, s_fDic, s_fCards, s_fTable;
|
|
static MethodInfo s_opImplicit;
|
|
static bool s_reflected, s_faulted;
|
|
// #882 — 다른 직업의 스킬을 이 무기 표가 지정하면(예: 파괴자 방패 치기 → 묵광) 직업만 맞춘 복사본을 슬롯에 넣는다
|
|
static PropertyInfo s_pCurAnim;
|
|
static readonly MethodInfo s_clone = typeof(object).GetMethod("MemberwiseClone", BindingFlags.Instance | BindingFlags.NonPublic);
|
|
static readonly Dictionary<long, SkillListTableData> s_classCopies = new Dictionary<long, SkillListTableData>();
|
|
static readonly SkillListTableData[] s_lastChosen = new SkillListTableData[6];
|
|
|
|
float _t; UnityEngine.Object _lastPc; int _lastWeapon = -2, _lastTierApplied = -1, _lastClass = -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_WeaponSkillSlots");
|
|
DontDestroyOnLoad(go);
|
|
Instance = go.AddComponent<WLWeaponSkillSlots>();
|
|
}
|
|
|
|
void OnEnable() { WLInventory.Changed += Dirty; }
|
|
void OnDisable() { WLInventory.Changed -= Dirty; }
|
|
void OnDestroy() { if (Instance == this) Instance = null; }
|
|
void Dirty() { _lastWeapon = -2; _t = 999f; }
|
|
|
|
static void Reflect()
|
|
{
|
|
if (s_reflected) return;
|
|
s_reflected = true;
|
|
var bf = BindingFlags.Instance | BindingFlags.NonPublic | BindingFlags.Public;
|
|
s_fData = typeof(PCActor).GetField("arr_magicData", bf);
|
|
s_fMax = typeof(PCActor).GetField("arr_magicMaxCoolTime", bf);
|
|
s_fCool = typeof(PCActor).GetField("arr_magicCoolTime", bf);
|
|
s_fCards = typeof(PCActor).GetField("arr_PCSkillCard", bf);
|
|
s_fDic = typeof(Actor).GetField("dic_MagicData", bf);
|
|
s_pCurAnim = typeof(Actor).GetProperty("CurAnim", bf);
|
|
if (s_fMax != null)
|
|
{
|
|
var elem = s_fMax.FieldType.GetElementType();
|
|
if (elem != null) s_opImplicit = elem.GetMethod("op_Implicit", BindingFlags.Public | BindingFlags.Static, null, new[] { typeof(float) }, null);
|
|
}
|
|
}
|
|
|
|
void Update()
|
|
{
|
|
var cfg = WLWeaponSettings.Instance;
|
|
if (cfg == null || cfg.enabled_ == 0 || cfg.weaponSkills == 0 || s_faulted) return;
|
|
WL.Combat.Auto.ActiveSkillLock.RuntimeDisabled = true; // 잠금 해제(값 하나 · 에셋은 PD 확정 때)
|
|
_t += Time.unscaledDeltaTime;
|
|
if (_t < cfg.pollSec) return;
|
|
_t = 0f;
|
|
try { TryApply(cfg, "poll"); }
|
|
catch (Exception ex) { s_faulted = true; LastLog = "중단(예외) — " + ex.Message; Debug.LogWarning("[WL-825] 무기 스킬 슬롯 " + LastLog); }
|
|
}
|
|
|
|
static int CurrentClassId(PCActor pc)
|
|
{
|
|
var f = typeof(Actor).GetField("m_ID", BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic);
|
|
object v = f != null ? f.GetValue(pc) : null;
|
|
return v is int ? (int)v : 0;
|
|
}
|
|
|
|
/// <summary>장착 무기의 스킬을 슬롯에 넣는다. 돌려주는 값 = 결과 한 줄(프로브·로그용).</summary>
|
|
public string TryApply(WLWeaponSettings cfg, string why)
|
|
{
|
|
Reflect();
|
|
if (s_fData == null || s_fMax == null || s_fCool == null) return Note("리플렉션 실패(arr_magicData/MaxCoolTime)");
|
|
if (!MyValue.bMyPC || MyValue.MyPC == null) return Note("PC 없음");
|
|
var pc = MyValue.MyPC;
|
|
if (!WLInventory.EnsureLoaded() || !WLWeaponSkillTable.EnsureLoaded()) return Note("표·인벤토리 미로드");
|
|
|
|
var w = WLInventory.EquippedInSlot(eFREquipmentType.Weapon);
|
|
int wid = w != null && w.itemJson != null ? w.itemJson.GroupNo : 0;
|
|
int tier = 1, plus; WLGradeTier gt;
|
|
if (w != null && WLEquipGrades.TryMap(w.itemJson, out gt, out plus)) tier = gt.tier;
|
|
int cls = CurrentClassId(pc);
|
|
bool samePc = ReferenceEquals(pc, _lastPc);
|
|
var arr = s_fData.GetValue(pc) as SkillListTableData[];
|
|
if (samePc && wid == _lastWeapon && tier == _lastTierApplied && cls == _lastClass && SlotsIntact(arr)) return LastLog; // 변화 없음
|
|
|
|
// #882 스킬 동작 중이면 다음 폴링에 바꾼다 — 쓰는 중인 슬롯 데이터를 비우면 MyActor.Update(:209) 가 매 프레임 NRE(대검 선택 오류 실측)
|
|
if (InSkill(pc)) { Skipped++; return Note("스킬 사용 중 — 다음 폴링에 적용"); }
|
|
|
|
// 원하는 슬롯(0 주력 · 1 각성 · 2~5 비움)
|
|
var desired = new int[6]; var cools = new float[6]; var ranges = new float[6]; var chosen = new SkillListTableData[6];
|
|
string block = "";
|
|
int weaponClass = wid > 0 ? WLWeaponAttackTable.ClassIdFor(wid) : 0;
|
|
if (wid > 0)
|
|
{
|
|
var rows = WLWeaponSkillTable.Rows(wid);
|
|
if (rows != null)
|
|
foreach (var r in rows)
|
|
{
|
|
int idx = r.slot - 1; if (idx < 0 || idx > 1) continue;
|
|
if (r.IsWlSkill) continue; // #883 WL 파츠 스킬 칸 = 파츠 스킬 실행기가 돌린다(WLPartSkillState)
|
|
if (r.IsTriggered) continue; // #890 조건 발동 칸(예: 방패 치기 명중 시) = WLSkillChain 이 쿨 없이 시전 · 자동 시전 슬롯에 안 넣는다
|
|
if (tier < r.unlockTier) continue; // 각성 = 등급표 해금(전설부터)
|
|
var data = table_skilllist.Ins != null ? table_skilllist.Ins.Get_Data_orNull(r.skillId) : null;
|
|
if (data == null) { block += " 스킬 없음 " + r.skillId; continue; }
|
|
if (data.n_SkillClassID > 0 && data.n_SkillClassID != cls)
|
|
{
|
|
// 무기 직업으로 아직 안 바뀌었으면 기다린다 · 바뀐 뒤라면 표가 다른 직업 스킬을 지정한 것 → 직업만 맞춘 복사본(#882)
|
|
if (weaponClass <= 0 || weaponClass != cls) { block += " 직업 불일치(" + data.n_SkillClassID + "≠" + cls + ")"; continue; }
|
|
data = ForClass(data, cls); block += " 직업 맞춤 " + r.skillId;
|
|
}
|
|
desired[idx] = r.skillId; cools[idx] = r.coolS; ranges[idx] = r.rangeM; chosen[idx] = data;
|
|
}
|
|
}
|
|
|
|
// 런타임 배열 쓰기(ActiveSkillLock 과 같은 리플렉션 경로)
|
|
var maxArr = s_fMax.GetValue(pc) as Array;
|
|
var coolArr = s_fCool.GetValue(pc) as Array;
|
|
var dic = s_fDic != null ? s_fDic.GetValue(pc) as IDictionary : null;
|
|
if (arr == null || maxArr == null || coolArr == null) return Note("배열 없음");
|
|
try { pc.Del_Magic(); } catch { } // #882 기다리던 스킬(m_Magic)이 바뀔 슬롯을 가리키면 데이터가 비어 NRE → 먼저 거둔다(스킬 동작 중이 아닐 때라 시전 코루틴 없음)
|
|
double coolMul = 0; try { coolMul = pc.Get_StatInfo().Get_Stat(eStat.COOLTIME_Multiplier); } catch { }
|
|
int n = Mathf.Min(arr.Length, 6);
|
|
for (int i = 0; i < n; i++)
|
|
{
|
|
SkillListTableData data = chosen[i];
|
|
s_lastChosen[i] = data;
|
|
if (data != null)
|
|
{
|
|
if (cfg.mpOverride != 0) data.n_MP = 0;
|
|
if (cools[i] > 0f) data.f_CoolTime = cools[i];
|
|
if (ranges[i] > 0f) data.f_SkillRange = ranges[i]; // #888 표 range_m(쿨과 같은 규칙 · 지금 표 값 = 힘민지 값이고 돌진 베기만 7 → 2)
|
|
}
|
|
arr[i] = data;
|
|
SetObscured(maxArr, i, data != null ? data.Get_CoolTime(coolMul) : 0f);
|
|
SetObscured(coolArr, i, 0f); // 바로 쓸 수 있게
|
|
if (dic != null && dic.Contains(i))
|
|
{
|
|
var info = dic[i];
|
|
var fTable = info != null ? info.GetType().GetField("m_SkillTableData", BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic) : null;
|
|
if (fTable != null) fTable.SetValue(info, data);
|
|
}
|
|
}
|
|
// 서버 데이터(메모리)도 같은 값으로 — 같은 PC 의 ReSet 이 다시 읽어도 유지(WLSkillSlotOverride 와 같은 클라 전용 API · 저장 호출 0)
|
|
try
|
|
{
|
|
var sd = ServerInfo.Ins != null ? ServerInfo.Ins.m_ServerData : null;
|
|
if (sd != null && sd.Equip != null && sd.Equip.Skill != null && sd.Equip.Skill.ContainsKey(sd.Equip.Preset))
|
|
{
|
|
var lst = sd.Equip.Skill[sd.Equip.Preset];
|
|
for (int i = 0; i < n && i < lst.Count; i++) sd.Equip.Set_Equip(eItem.Skill, desired[i], i);
|
|
}
|
|
}
|
|
catch (Exception ex) { block += " 서버메모리 실패(" + ex.Message + ")"; }
|
|
// 전투 패드 카드 아이콘(있을 때만)
|
|
try
|
|
{
|
|
var cards = s_fCards != null ? s_fCards.GetValue(pc) as SkillCard[] : null;
|
|
if (cards != null)
|
|
for (int i = 0; i < n && i < cards.Length; i++)
|
|
if (cards[i] != null && arr[i] != null) { cards[i].Set(arr[i]); cards[i].Set_CardType(eUICardType.Use); }
|
|
}
|
|
catch (Exception ex) { block += " 카드 갱신 실패(" + ex.Message + ")"; }
|
|
|
|
_lastPc = pc; _lastWeapon = wid; _lastTierApplied = tier; _lastClass = cls;
|
|
LastSlots = desired; LastWeaponId = wid; LastTier = tier; LastClassId = cls;
|
|
Applied++;
|
|
return Note(why + ": 무기 " + wid + (w != null ? "(" + w.itemJson.Item_name + ")" : "") + " 등급 " + tier + " 직업 " + cls +
|
|
" → 슬롯 [" + desired[0] + ", " + desired[1] + "] 쿨 [" + cools[0].ToString("0.#") + ", " + cools[1].ToString("0.#") + "]" + block);
|
|
}
|
|
|
|
/// <summary>#882 — 지금 스킬 동작 중인가(Actor.CurAnim ≥ Skill).</summary>
|
|
static bool InSkill(PCActor pc)
|
|
{
|
|
if (s_pCurAnim == null) return false;
|
|
try { return (int)(eAnim)s_pCurAnim.GetValue(pc, null) >= (int)eAnim.Skill; } catch { return false; }
|
|
}
|
|
|
|
/// <summary>#882 — 마지막으로 넣은 슬롯이 그대로인가(PC ReSet 이 서버 슬롯으로 다시 채우면 직업 맞춤 복사본이 사라진다 → 다시 넣는다).</summary>
|
|
static bool SlotsIntact(SkillListTableData[] arr)
|
|
{
|
|
if (arr == null) return true;
|
|
for (int i = 0; i < arr.Length && i < s_lastChosen.Length; i++) if (!ReferenceEquals(arr[i], s_lastChosen[i])) return false;
|
|
return true;
|
|
}
|
|
|
|
/// <summary>#882 — 다른 직업 스킬의 직업 칸만 이 PC 직업으로 바꾼 복사본(스킬 · 쿨 · 판정은 원본 그대로 · 원본 표 데이터 무수정).</summary>
|
|
/// <summary>#906 E 치트키 — 조건 발동 칸(예: 묵광 각성 돌진 베기)처럼 자동 시전 칸에 없는 스킬의 데이터를 표에서 가져온다(직업 다르면 직업 맞춘 복사본).</summary>
|
|
public static SkillListTableData SkillDataFor(PCActor pc, int skillId)
|
|
{
|
|
var data = table_skilllist.Ins != null ? table_skilllist.Ins.Get_Data_orNull(skillId) : null;
|
|
if (data == null || pc == null) return data;
|
|
int cls = CurrentClassId(pc);
|
|
return data.n_SkillClassID > 0 && data.n_SkillClassID != cls ? ForClass(data, cls) : data;
|
|
}
|
|
|
|
static SkillListTableData ForClass(SkillListTableData src, int cls)
|
|
{
|
|
long key = (long)src.n_SkillID * 100000L + cls;
|
|
SkillListTableData c;
|
|
if (s_classCopies.TryGetValue(key, out c) && c != null) return c;
|
|
c = (SkillListTableData)s_clone.Invoke(src, null);
|
|
c.n_SkillClassID = cls;
|
|
s_classCopies[key] = c;
|
|
return c;
|
|
}
|
|
|
|
static void SetObscured(Array arr, int i, float value)
|
|
{
|
|
if (arr == null || i < 0 || i >= arr.Length) return;
|
|
if (s_opImplicit != null) arr.SetValue(s_opImplicit.Invoke(null, new object[] { value }), i);
|
|
else if (arr is float[]) ((float[])arr)[i] = value;
|
|
}
|
|
|
|
string Note(string s)
|
|
{
|
|
LastLog = s;
|
|
var cfg = WLWeaponSettings.Instance;
|
|
if (cfg != null && cfg.verboseLog != 0 && s.Contains("→")) Debug.Log("[WL-825] 무기 스킬 슬롯 " + s);
|
|
return s;
|
|
}
|
|
|
|
/// <summary>프로브용 — 지금 슬롯 상태(배열 · 쿨).</summary>
|
|
public static string Dump()
|
|
{
|
|
Reflect();
|
|
if (!MyValue.bMyPC || MyValue.MyPC == null || s_fData == null) return "PC 없음";
|
|
var pc = MyValue.MyPC;
|
|
var arr = s_fData.GetValue(pc) as SkillListTableData[];
|
|
var coolArr = s_fCool.GetValue(pc) as Array; var maxArr = s_fMax.GetValue(pc) as Array;
|
|
var sb = new System.Text.StringBuilder();
|
|
sb.Append("class=").Append(CurrentClassId(pc)).Append(" lock=").Append(WL.Combat.Auto.ActiveSkillLock.Locked).Append(" applied=").Append(Applied).Append(" | ");
|
|
if (arr != null)
|
|
for (int i = 0; i < arr.Length; i++)
|
|
{
|
|
sb.Append('[').Append(i).Append("] ");
|
|
if (arr[i] == null) { sb.Append("- "); continue; }
|
|
sb.Append(arr[i].n_SkillID).Append(" mp").Append(arr[i].n_MP).Append(" cool ").Append(coolArr != null ? Convert.ToSingle(coolArr.GetValue(i)).ToString("0.#") : "?")
|
|
.Append('/').Append(maxArr != null ? Convert.ToSingle(maxArr.GetValue(i)).ToString("0.#") : "?").Append(' ');
|
|
}
|
|
sb.Append("| ").Append(LastLog);
|
|
return sb.ToString();
|
|
}
|
|
}
|
|
}
|