// ───────────────────────────────────────────────────────────────────────────── // WLEquipStats.cs — 장비 능력치를 힘민지 ActorStatInfo 에 반영 (#821 → 2026-10-09 등급 7단계 개편 #823) // // PD 2026-10-09 원문: 등급별 「기본 수치 + 스텟 N % 비중」 · 배율 상한 없음. // Lead 해석(PD 미지정 부분): 「스텟」 = 직업 주스탯 · 「기본 수치」 = FR 레벨표 값 → 장비 옵션 = 기본 수치 + 주스탯 × 등급 % // 기본 수치 = FR 레벨표 값(AttackAbility / HealthAbility) × WLEquipSettings.levelStatScale // 주스탯 = ActorStatInfo.Get_MainStatValue() — 직업 주스탯(힘·민첩·지능) · Reset 이 끝난 뒤의 최종값 // 등급 % = Resources/WL/WLGradeTable.json tiers[].statPercent (일반 10 … 신화 400) // 부위 환산 = parts[].statFactor · 출력 = parts[].output (공격 부위 → ATK · 체력 부위 → MaxHP) // 훅 1 = MyValue.cs ActorStatInfo.Reset() 안 `Apply(this, m_Role)` — 주스탯이 아직 최종값이 아니므로 재계산 번호만 올린다 // (구 FR 등급 % 옵션 abilityMap 은 applyLegacyFrOptions = 1 일 때만 여기서 더한다 · 기본 0). // 훅 2 = Reset 뒤 `EnforceBaseAttack(info)` — WLInventory.Notify → RefreshPc · WLEquipLauncher 0.5 s 틱. // ATK : pcBaseAttackOverride > 0 → 「기본값 + 장비 공격 합」으로 맞춘다(멱등) · 0 → 원본 공식에 장비 공격 합을 더한다 // MaxHP: 장비 생명 합을 더한다 — 재계산 1회(ResetSeq)마다 1번, 그 뒤 장착이 바뀌면 차이만 더한다 // ───────────────────────────────────────────────────────────────────────────── using System.Collections.Generic; using UnityEngine; namespace WL.Equip { public static class WLEquipStats { public static string LastReport { get; private set; } = ""; public static int LastAppliedItems { get; private set; } /// 장착 장비 공격 합(기본 수치 + 주스탯 보너스) — ATK 목표값 = pcBaseAttackOverride + 이 값. public static double LastEquipAttack { get; private set; } public static double LastEquipHp { get; private set; } public static double LastEquipDef { get; private set; } public static double LastMainStat { get; private set; } public static int BaseAttackEnforced; public static string LastEnforceLog { get; private set; } = ""; /// 훅 1 이 돈 횟수 — 생명 합을 재계산 1회당 1번만 더하기 위한 번호. public static int ResetSeq { get; private set; } struct Applied { public int seq; public double hp; public double atk; public double def; } static readonly Dictionary s_applied = new Dictionary(); static readonly List s_tmp = new List(8); static readonly HashSet s_mapped = new HashSet(); static bool s_mappedBuilt; public static bool IsMapped(eFRAbility a) { if (!s_mappedBuilt) { s_mappedBuilt = true; var cfg = WLEquipSettings.Instance; if (cfg != null && cfg.abilityMap != null) for (int i = 0; i < cfg.abilityMap.Count; i++) s_mapped.Add(cfg.abilityMap[i].ability); } return s_mapped.Contains(a); } /// 훅 1 — ActorStatInfo.Reset() 안에서 부른다(PC 만). 재계산 번호를 올리고, 구 FR % 옵션은 설정이 켜진 경우만 더한다. public static void Apply(ActorStatInfo info, eRole role) { if (info == null || role != eRole.PC) return; var cfg = WLEquipSettings.Instance; if (cfg == null || cfg.enabled_ == 0) return; ResetSeq++; if (cfg.applyLegacyFrOptions != 0) ApplyLegacyOptions(info, cfg); WL.PartSkill.WLPartStats.ApplyHook1(info); // #879 파츠 패시브 · 목걸이 랜덤 스탯 = 배율(최종 공식 전) · 설정 off 면 무동작 } static void ApplyLegacyOptions(ActorStatInfo info, WLEquipSettings cfg) { if (!WLInventory.EnsureLoaded()) return; var data = WLInventory.Data; if (data == null || cfg.abilityMap == null) return; for (int i = 0; i < data.equipItems.Count; i++) { var item = WLInventory.FindItem(data.equipItems[i]); if (item == null || !item.itemJson.IsEquipment) continue; s_tmp.Clear(); WLEquipTables.CollectOpenedOptions(item.itemJson, s_tmp); for (int o = 0; o < s_tmp.Count; o++) { var e = s_tmp[o]; for (int a = 0; a < e.Abilities.Count; a++) { var ab = e.Abilities[a]; for (int m = 0; m < cfg.abilityMap.Count; m++) { var map = cfg.abilityMap[m]; if (map.ability != ab.Ability) continue; info.Set_Stat(true, map.stat, ab.AbilityValue * map.scale); } } } } } public struct Totals { public double atk; public double hp; public double def; public double mainStat; public int items; public string report; } /// 장착 장비 합계 — 부위별 (기본 수치 + 주스탯 × 등급 % × 환산 계수). UI 와 같은 계산. public static Totals Compute(ActorStatInfo info) { var t = new Totals { report = "" }; var cfg = WLEquipSettings.Instance; if (cfg == null || cfg.enabled_ == 0 || info == null) return t; if (!WLInventory.EnsureLoaded() || !WLEquipGrades.EnsureLoaded()) { t.report = WLEquipGrades.LastLog; return t; } var data = WLInventory.Data; if (data == null || data.equipItems.Count == 0) { t.report = "장착 0"; return t; } double main = 0; try { main = info.Get_MainStatValue(); } catch { } t.mainStat = main; var sb = new System.Text.StringBuilder(); for (int i = 0; i < data.equipItems.Count; i++) { var item = WLInventory.FindItem(data.equipItems[i]); eStat outStat; double bv, bonus; string line; if (!ItemValues(item, main, out outStat, out bv, out bonus, out line)) continue; t.items++; // #879 반지 = 지금 무기 계열(근접/원거리)과 맞을 때만 공격력에 더한다 string ringName; if (item.itemJson.EquipmentType == eFREquipmentType.Belt && WLEquipGrades.IsRingOutput(WLEquipGrades.Part(item.itemJson.EquipmentType)) && !WL.PartSkill.WLPartStats.RingMatches(item, out ringName)) { sb.Append(item.itemJson.Item_name).Append(": 무기 계열 불일치(0) | "); continue; } if (outStat == eStat.MaxHP) t.hp += bv + bonus; else if (outStat == eStat.DEF) t.def += bv + bonus; else t.atk += bv + bonus; sb.Append(item.itemJson.Item_name).Append(": ").Append(line).Append(" | "); } t.report = sb.ToString(); return t; } /// 아이템 1개의 기본 수치·주스탯 보너스. line = 표시용 계산식(「공격 = 기본 7.0 + 힘 12 × 10% = 8.2」). /// 0 보다 크면 그 강화 레벨로 계산(팝업 「다음 레벨 증가분」 표시용 · #881) public static bool ItemValues(Item item, double mainStat, out eStat outStat, out double baseValue, out double bonus, out string line, int levelOverride = -1) { outStat = eStat.ATK; baseValue = 0; bonus = 0; line = ""; var cfg = WLEquipSettings.Instance; if (cfg == null || item == null || item.itemJson == null || !item.itemJson.IsEquipment) return false; if (!WLEquipGrades.EnsureLoaded()) return false; var part = WLEquipGrades.Part(item.itemJson.EquipmentType); WLGradeTier tier; int plus; if (part == null || !WLEquipGrades.TryMap(item.itemJson, out tier, out plus)) return false; if (WLEquipGrades.NoEffect(part, tier.tier)) { line = "효과 없음 — " + tier.name + " " + part.name + "(#895)"; return false; } // PD 「모든 무기의 일반 등급은 아무런 효과가 없어야」 if (WLEquipGrades.IsRateOutput(part)) return false; // #879 신발 이동 속도 · 목걸이 랜덤 = 배율(WL.PartSkill.WLPartStats) outStat = WLEquipGrades.OutputStat(part); var lt = WLEquipTables.FindItemLevelJson(item.itemJson.ItemLvAbilityID); var lv = lt != null ? lt.FindLevelInfo(levelOverride > 0 ? levelOverride : item.rawItem.level) : null; double raw = 0; if (lv != null) { if (part.levelField == "Health") raw = lv.HealthAbility; else if (part.levelField == "Defence") raw = lv.DefenceAbility; else raw = lv.AttackAbility; } baseValue = raw * cfg.levelStatScale * part.levelFactor; // #825 PD 2026-10-09 「단검 = 치명타 확률 · 검 = 치명타 피해량」 — 무기는 계열 축(WLWeaponAxis.json · 공격 표 main_stat)을 주스탯 자리에 넣는다. double statValue = mainStat; string statName = MainStatName(); WL.Combat.Weapon.WLWeaponAxisDef axis; if (item.itemJson.EquipmentType == eFREquipmentType.Weapon && WL.Combat.Weapon.WLWeaponAxis.TryFor(item.itemJson.GroupNo, out axis)) { var live = LivePcInfo(); double v = 0; try { if (live != null) v = live.Get_Stat(axis.estat); } catch { v = 0; } statValue = v * axis.scale; statName = axis.name; } bonus = statValue * (tier.statPercent / 100.0) * part.statFactor; string outName = WLEquipGrades.OutputName(outStat); if (WLEquipGrades.IsRingOutput(part)) { string rn; WL.PartSkill.WLPartStats.RingMatches(item, out rn); outName = rn; } line = outName + " = 기본 " + baseValue.ToString("F1") + " + " + statName + " " + statValue.ToString("F0") + " × " + tier.statPercent.ToString("F0") + "%" + (part.statFactor != 1.0 ? " × " + part.statFactor.ToString("F1") : "") + " = " + (baseValue + bonus).ToString("F1"); return true; } /// UI 상세용 — 등급 줄 + 계산식 줄. public static string FormulaText(Item item) { if (item == null || item.itemJson == null || !item.itemJson.IsEquipment) return ""; var sb = new System.Text.StringBuilder(); WLGradeTier tier; int plus; if (WLEquipGrades.TryMap(item.itemJson, out tier, out plus)) sb.Append("등급 ").Append(WLEquipGrades.Label(item.itemJson, true)) .Append(WLEquipGrades.NoEffect(item.itemJson) ? "" : " · 주스탯 " + tier.statPercent.ToString("F0") + "% 반영").Append(tier.isS != 0 ? " · S급" : ""); // #895 일반 무기는 % 줄 생략 else sb.Append("등급 대응 없음(FR ").Append(WLEquipTables.GradeName(item.itemJson.ItemGrade)).Append(')'); eStat o; double bv, bonus; string line; if (ItemValues(item, CurrentMainStat(), out o, out bv, out bonus, out line)) sb.Append('\n').Append(line); else if (WLEquipGrades.NoEffect(item.itemJson)) sb.Append('\n').Append(line); // #895 일반 무기 = 효과 없음 return sb.ToString(); } /// 살아 있는 PC 의 스탯 객체. MyValue.Get_ActorStatInfoorNull 은 호출마다 Reset 을 돌려 값이 원본 공식으로 되돌아가므로 읽기용으로 쓰지 않는다(2026-10-09 스모크 실측). public static ActorStatInfo LivePcInfo() { try { if (MyValue.bMyPC && MyValue.MyPC != null) return MyValue.MyPC.Get_StatInfo(); } catch { } return null; } public static double CurrentMainStat() { try { var info = LivePcInfo(); return info != null ? info.Get_MainStatValue() : 0; } catch { return 0; } } /// 직업 주스탯 이름(힘·민첩·지능) — 서버 데이터가 없으면 「주스탯」. public static string MainStatName() { try { var sd = ServerInfo.Ins != null ? ServerInfo.Ins.m_ServerData : null; if (sd == null) return "주스탯"; var cd = table_classconfig.Ins.Get_Data_orNull(sd.Get_EqiupmentEquip(eItem.PC)); if (cd == null) return "주스탯"; switch (cd.Get_MainStat()) { case eMainStatType.STR: case eMainStatType.STR_DEX: case eMainStatType.STR_INT: return "힘"; case eMainStatType.DEX: case eMainStatType.DEX_STR: case eMainStatType.DEX_INT: return "민첩"; case eMainStatType.INT: case eMainStatType.INT_STR: case eMainStatType.INT_DEX: return "지능"; } } catch { } return "주스탯"; } /// UI 상단 한 줄 — PC 현재 공격력·최대 HP·주스탯·장비 합. public static string StatusLine() { var info = LivePcInfo(); if (info == null) return "PC 스탯 없음(인게임에서 확인)"; try { EnforceBaseAttack(info); } catch { } var sb = new System.Text.StringBuilder(); sb.Append("공격력 ").Append(info.Get_Stat(eStat.ATK).ToString("F1")).Append(" (최종 ").Append(info.Get_Stat(eStat.BaseDmg).ToString("F1")) .Append(") 최대HP ").Append(info.Get_Stat(eStat.MaxHP).ToString("F0")) .Append(" ").Append(MainStatName()).Append(' ').Append(LastMainStat.ToString("F0")) .Append(" 장비 공격 +").Append(LastEquipAttack.ToString("F1")).Append(" · 장비 생명 +").Append(LastEquipHp.ToString("F1")) .Append(" · 장비 방어 +").Append(LastEquipDef.ToString("F1")); return sb.ToString(); } /// PC 스탯 재반영(힘민지 EquipmentUI 와 같은 호출 시점). 게임 밖(타이틀)에서는 아무 일도 없다. public static void RefreshPc() { try { if (ServerInfo.Ins == null || ServerInfo.Ins.m_ServerData == null) return; var info = MyValue.Get_ActorStatInfoorNull(eRole.PC); // 재계산(Reset) 뒤 반영 EnforceBaseAttack(info); var live = LivePcInfo(); if (live != null && live != info) EnforceBaseAttack(live); } catch (System.Exception ex) { Debug.LogWarning("[WL-821] PC 스탯 재반영 실패 — " + ex.Message); } } /// /// 훅 2 — Reset 뒤 장비 합을 반영한다. PD 2026-10-09 「플레이어 기본 공격력은 5」: /// pcBaseAttackOverride > 0 이면 ATK = 기본값 + 장비 공격 합(멱등 · BaseDmg 는 같은 비율) · 0 이면 원본 공식에 장비 공격 합을 더한다. /// MaxHP 에는 장비 생명 합을 더한다(재계산 1회당 1번 · 장착 변경 시 차이만). /// public static bool EnforceBaseAttack(ActorStatInfo info) { var cfg = WLEquipSettings.Instance; if (cfg == null || cfg.enabled_ == 0 || info == null) return false; var t = Compute(info); LastEquipAttack = t.atk; LastEquipHp = t.hp; LastMainStat = t.mainStat; LastAppliedItems = t.items; LastReport = t.report; bool changed = false; Applied ap; if (!s_applied.TryGetValue(info, out ap) || ap.seq != ResetSeq) ap = new Applied { seq = ResetSeq, hp = 0, atk = 0, def = 0 }; double dHp = t.hp - ap.hp; if (System.Math.Abs(dHp) >= 0.01) { info.Set_Stat(true, eStat.MaxHP, dHp); ap.hp = t.hp; changed = true; } // #879 갑옷 = 방어력 — 최종 방어(FinalDef)에 차이만 더한다(생명과 같은 방식 · 재계산 1회당 1번) double dDef = t.def - ap.def; if (System.Math.Abs(dDef) >= 0.01) { info.Set_Stat(true, eStat.FinalDef, dDef); ap.def = t.def; changed = true; } LastEquipDef = t.def; if (cfg.pcBaseAttackOverride > 0) { double atk = info.Get_Stat(eStat.ATK); double target = cfg.pcBaseAttackOverride + t.atk; if (System.Math.Abs(atk - target) >= 0.01) { double bd = info.Get_Stat(eStat.BaseDmg); double k = atk > 0.0001 ? bd / atk : 1.0; info.Set_Stat(eStat.ATK, target); info.Set_Stat(eStat.BaseDmg, target * k); BaseAttackEnforced++; LastEnforceLog = "ATK " + atk.ToString("F1") + " → " + target.ToString("F1") + " · BaseDmg " + bd.ToString("F1") + " → " + (target * k).ToString("F1") + " (장비 공격 +" + t.atk.ToString("F1") + " · 생명 +" + t.hp.ToString("F1") + " · " + MainStatName() + " " + t.mainStat.ToString("F0") + ")"; if (cfg.verboseLog != 0 && BaseAttackEnforced <= 3) Debug.Log("[WL-821] 기본 공격력 보정 — " + LastEnforceLog); changed = true; } } else { double dAtk = t.atk - ap.atk; if (System.Math.Abs(dAtk) >= 0.01) { double a0 = info.Get_Stat(eStat.ATK), b0 = info.Get_Stat(eStat.BaseDmg); double k0 = a0 > 0.0001 ? b0 / a0 : 1.0; info.Set_Stat(eStat.ATK, a0 + dAtk); info.Set_Stat(eStat.BaseDmg, (a0 + dAtk) * k0); ap.atk = t.atk; changed = true; } } s_applied[info] = ap; return changed; } /// 주기 폴링용 — PC 가 있으면 현재 스탯 객체를 가져와(재계산 없이) 반영한다. public static void EnforceBaseAttackTick() { try { if (!MyValue.bMyPC || MyValue.MyPC == null) return; EnforceBaseAttack(MyValue.MyPC.Get_StatInfo()); } catch { } } } }