2026-10-09 04:28:39 +00:00
// ─────────────────────────────────────────────────────────────────────────────
2026-10-18 15:09:05 +00:00
// WLEquipStats.cs — 장비 능력치를 힘민지 ActorStatInfo 에 반영 (#821 → 2026-10-09 등급 7단계 개편 #823)
2026-10-09 04:28:39 +00:00
/ /
2026-10-18 15:09:05 +00:00
// 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번, 그 뒤 장착이 바뀌면 차이만 더한다
2026-10-09 04:28:39 +00:00
// ─────────────────────────────────────────────────────────────────────────────
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 ; }
2026-10-18 15:09:05 +00:00
/// <summary>장착 장비 공격 합(기본 수치 + 주스탯 보너스) — ATK 목표값 = pcBaseAttackOverride + 이 값.</summary>
2026-10-09 04:28:39 +00:00
public static double LastEquipAttack { get ; private set ; }
2026-10-18 15:09:05 +00:00
public static double LastEquipHp { get ; private set ; }
public static double LastEquipDef { get ; private set ; }
public static double LastMainStat { get ; private set ; }
2026-10-09 04:28:39 +00:00
public static int BaseAttackEnforced ;
public static string LastEnforceLog { get ; private set ; } = "" ;
2026-10-18 15:09:05 +00:00
/// <summary>훅 1 이 돈 횟수 — 생명 합을 재계산 1회당 1번만 더하기 위한 번호.</summary>
public static int ResetSeq { get ; private set ; }
struct Applied { public int seq ; public double hp ; public double atk ; public double def ; }
static readonly Dictionary < ActorStatInfo , Applied > s_applied = new Dictionary < ActorStatInfo , Applied > ( ) ;
2026-10-09 04:28:39 +00:00
static readonly List < ItemOptionElement > s_tmp = new List < ItemOptionElement > ( 8 ) ;
static readonly HashSet < eFRAbility > s_mapped = new HashSet < eFRAbility > ( ) ;
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 ) ;
}
2026-10-18 15:09:05 +00:00
/// <summary>훅 1 — ActorStatInfo.Reset() 안에서 부른다(PC 만). 재계산 번호를 올리고, 구 FR % 옵션은 설정이 켜진 경우만 더한다.</summary>
2026-10-09 04:28:39 +00:00
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 ;
2026-10-18 15:09:05 +00:00
ResetSeq + + ;
if ( cfg . applyLegacyFrOptions ! = 0 ) ApplyLegacyOptions ( info , cfg ) ;
WL . PartSkill . WLPartStats . ApplyHook1 ( info ) ; // #879 파츠 패시브 · 목걸이 랜덤 스탯 = 배율(최종 공식 전) · 설정 off 면 무동작
}
static void ApplyLegacyOptions ( ActorStatInfo info , WLEquipSettings cfg )
{
2026-10-09 04:28:39 +00:00
if ( ! WLInventory . EnsureLoaded ( ) ) return ;
var data = WLInventory . Data ;
2026-10-18 15:09:05 +00:00
if ( data = = null | | cfg . abilityMap = = null ) return ;
2026-10-09 04:28:39 +00:00
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 ) ;
}
}
}
}
}
2026-10-18 15:09:05 +00:00
public struct Totals { public double atk ; public double hp ; public double def ; public double mainStat ; public int items ; public string report ; }
/// <summary>장착 장비 합계 — 부위별 (기본 수치 + 주스탯 × 등급 % × 환산 계수). UI 와 같은 계산.</summary>
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 ;
}
/// <summary>아이템 1개의 기본 수치·주스탯 보너스. line = 표시용 계산식(「공격 = 기본 7.0 + 힘 12 × 10% = 8.2」).</summary>
/// <param name="levelOverride">0 보다 크면 그 강화 레벨로 계산(팝업 「다음 레벨 증가분」 표시용 · #881)</param>
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 ;
}
/// <summary>UI 상세용 — 등급 줄 + 계산식 줄.</summary>
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 ( ) ;
}
/// <summary>살아 있는 PC 의 스탯 객체. MyValue.Get_ActorStatInfoorNull 은 호출마다 Reset 을 돌려 값이 원본 공식으로 되돌아가므로 읽기용으로 쓰지 않는다(2026-10-09 스모크 실측).</summary>
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 ; }
}
/// <summary>직업 주스탯 이름(힘·민첩·지능) — 서버 데이터가 없으면 「주스탯」.</summary>
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 "주스탯" ;
}
/// <summary>UI 상단 한 줄 — PC 현재 공격력·최대 HP·주스탯·장비 합.</summary>
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 ( ) ;
}
/// <summary>PC 스탯 재반영(힘민지 EquipmentUI 와 같은 호출 시점). 게임 밖(타이틀)에서는 아무 일도 없다.</summary>
2026-10-09 04:28:39 +00:00
public static void RefreshPc ( )
{
try
{
if ( ServerInfo . Ins = = null | | ServerInfo . Ins . m_ServerData = = null ) return ;
2026-10-18 15:09:05 +00:00
var info = MyValue . Get_ActorStatInfoorNull ( eRole . PC ) ; // 재계산(Reset) 뒤 반영
2026-10-09 04:28:39 +00:00
EnforceBaseAttack ( info ) ;
2026-10-18 15:09:05 +00:00
var live = LivePcInfo ( ) ;
if ( live ! = null & & live ! = info ) EnforceBaseAttack ( live ) ;
2026-10-09 04:28:39 +00:00
}
2026-10-18 15:09:05 +00:00
catch ( System . Exception ex ) { Debug . LogWarning ( "[WL-821] PC 스탯 재반영 실패 — " + ex . Message ) ; }
2026-10-09 04:28:39 +00:00
}
/// <summary>
2026-10-18 15:09:05 +00:00
/// 훅 2 — Reset 뒤 장비 합을 반영한다. PD 2026-10-09 「플레이어 기본 공격력은 5」:
/// pcBaseAttackOverride > 0 이면 ATK = 기본값 + 장비 공격 합(멱등 · BaseDmg 는 같은 비율) · 0 이면 원본 공식에 장비 공격 합을 더한다.
/// MaxHP 에는 장비 생명 합을 더한다(재계산 1회당 1번 · 장착 변경 시 차이만).
2026-10-09 04:28:39 +00:00
/// </summary>
public static bool EnforceBaseAttack ( ActorStatInfo info )
{
var cfg = WLEquipSettings . Instance ;
2026-10-18 15:09:05 +00:00
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 ;
2026-10-09 04:28:39 +00:00
}
2026-10-18 15:09:05 +00:00
/// <summary>주기 폴링용 — PC 가 있으면 현재 스탯 객체를 가져와(재계산 없이) 반영한다.</summary>
2026-10-09 04:28:39 +00:00
public static void EnforceBaseAttackTick ( )
{
try
{
if ( ! MyValue . bMyPC | | MyValue . MyPC = = null ) return ;
EnforceBaseAttack ( MyValue . MyPC . Get_StatInfo ( ) ) ;
}
catch { }
}
}
}