[WL] #821 FR 장비 시스템 이식 + #822 섬 몬스터 존·전투 아바타 (2026-10-08~09 · Lead Fable)

#821 — FR(FirstRising) 기존 장비 시스템 이식: Assets/WL/Equip (데이터 모델·FR json 로더·인벤/장착/강화/합성(본1+재료2)/강등·ActorStatInfo 가산·던전 드랍·더미 UI·인게임 런처·로컬 세이브) + 설정 SO · 원본 1줄 훅 3곳(ActorStatInfo.Reset · WLIslandBridge.OnStageCleared · DummyMenuButton) · PC 기본 공격력 5 사후 보정
#822 — 섬 몬스터 존(땅 1칸 1곳 · 동시 3 · 누적 10 → 3h 쿨타임 · 라벨 · 몹 HP 18) + 섬 기본 아바타 = 전투 PC(농부 = 투명 그림자 · 밭/낚시 때 농부) · 전투 형태 재적용(로비 맵 유형 우회) · MonsterSimpleInfo 참조 보정(처치 NRE·리젠 중단 해소) · 섬 자동 부활 · WLPcScaleCompensator 안정 값만 보정(연출 펀치 누적 확대 해소)

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
깃 관리자 2026-10-09 13:28:39 +09:00
parent b95f043696
commit 64a4c38443
58 changed files with 89261 additions and 0 deletions

View File

@ -308,6 +308,7 @@ public class ActorStatInfo
Set_JobPassive(classData); // 직업 전용 패시브 값 적용
Set_SkillPassive(); // 스킬 패시브 값 적용
Set_EquipItem(); // 착용 아이템 값 적용
WL.Equip.WLEquipStats.Apply(this, m_Role); // WL-821 FR 장비(레벨표 + 등급 옵션) — PC 만 · 설정 off 면 무동작
Set_EquipSlotEnchant(classData); // 착용 슬롯 인챈트 값 적용
Set_PCAbility(classData.e_BattleType); // 캐릭터 특성 값 적용
Set_PCAwaken(); // 캐릭터 각성 값 적용

View File

@ -38,6 +38,17 @@ namespace WL.Character
float _lastWritten = float.NaN;
// WL-822(2026-10-09 PD 「플레이어 크기가 자꾸 커진다」): 원본이 쓰는 값은 두 가지뿐(로비 1.0 · 인게임 f_Scale)이다.
// 그 밖의 변화(피격·레벨업 스케일 펀치 등 **일시 연출**)까지 「원본이 새로 썼다」로 보고 ×k 를 또 곱하니
// 펀치마다 누적 확대됐다(1.05 → 1.64 → 3.96 실측). 이제 **아는 원본 값**일 때만 보정한다.
// 구분 기준 = **안정성**: 원본 대입(1.0 / f_Scale)은 그 값이 그대로 머물고, 연출 펀치는 매 프레임(사인 곡선) 값이 바뀐다.
// → 같은 값이 stableFrames 프레임 연속이면 원본 값으로 보고 보정, 아니면 무시.
[Tooltip("원본 값으로 인정하기까지 같은 값이 유지돼야 하는 LateUpdate 수")]
public int stableFrames = 3;
float _pendingValue = float.NaN;
int _pendingFrames;
public static int Ignored, Applied;
/// <summary>지금 적용 중인 보정 배수. 스위치가 꺼져 있으면 1.</summary>
public float Compensation
{
@ -61,7 +72,17 @@ namespace WL.Character
// 자기가 마지막으로 쓴 값이면 원본이 아직 안 건드린 것 = 할 일 없음(멱등).
if (!float.IsNaN(_lastWritten) && Mathf.Abs(cur - _lastWritten) <= 1e-5f) return;
// 첫 적용은 즉시(OnEnable 시점 값 = 원본 대입). 이후엔 같은 값이 stableFrames 연속일 때만 원본 대입으로 인정한다.
if (!float.IsNaN(_lastWritten))
{
if (float.IsNaN(_pendingValue) || Mathf.Abs(cur - _pendingValue) > 1e-5f) { _pendingValue = cur; _pendingFrames = 1; Ignored++; return; }
_pendingFrames++;
if (_pendingFrames < Mathf.Max(1, stableFrames)) { Ignored++; return; }
}
_pendingValue = float.NaN; _pendingFrames = 0;
float target = cur * k;
Applied++;
_lastWritten = target;
transform.localScale = new Vector3(target, target, target);

8
Assets/WL/Equip.meta Normal file
View File

@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 9d16edef4fa3f734896b8bd61165d630
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View File

@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 17160f7548ab6a443b330592517769c6
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View File

@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: edb58ba8a3b5648468a8e17195960065
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View File

@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 52265444400b870469f05703fac26ec5
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View File

@ -0,0 +1,128 @@
[
{
"Index": 0,
"ItemGrade": 0,
"ResultGrade": 1,
"RequiredGradeLevel": 0,
"ResultGradeLevel": 0,
"SameGroupNo": 1,
"RequiredItemCount": 2
},
{
"Index": 1000,
"ItemGrade": 1,
"ResultGrade": 2,
"RequiredGradeLevel": 0,
"ResultGradeLevel": 0,
"SameGroupNo": 1,
"RequiredItemCount": 2
},
{
"Index": 2000,
"ItemGrade": 2,
"ResultGrade": 3,
"RequiredGradeLevel": 0,
"ResultGradeLevel": 0,
"SameGroupNo": 1,
"RequiredItemCount": 2
},
{
"Index": 3000,
"ItemGrade": 3,
"ResultGrade": 3,
"RequiredGradeLevel": 0,
"ResultGradeLevel": 1,
"SameGroupNo": 0,
"RequiredItemCount": 1
},
{
"Index": 3001,
"ItemGrade": 3,
"ResultGrade": 3,
"RequiredGradeLevel": 0,
"ResultGradeLevel": 2,
"SameGroupNo": 0,
"RequiredItemCount": 2
},
{
"Index": 3002,
"ItemGrade": 3,
"ResultGrade": 4,
"RequiredGradeLevel": 2,
"ResultGradeLevel": 0,
"SameGroupNo": 1,
"RequiredItemCount": 1
},
{
"Index": 4000,
"ItemGrade": 4,
"ResultGrade": 4,
"RequiredGradeLevel": 0,
"ResultGradeLevel": 1,
"SameGroupNo": 0,
"RequiredItemCount": 1
},
{
"Index": 4001,
"ItemGrade": 4,
"ResultGrade": 4,
"RequiredGradeLevel": 0,
"ResultGradeLevel": 2,
"SameGroupNo": 0,
"RequiredItemCount": 1
},
{
"Index": 4002,
"ItemGrade": 4,
"ResultGrade": 4,
"RequiredGradeLevel": 0,
"ResultGradeLevel": 3,
"SameGroupNo": 0,
"RequiredItemCount": 1
},
{
"Index": 4003,
"ItemGrade": 4,
"ResultGrade": 5,
"RequiredGradeLevel": 0,
"ResultGradeLevel": 0,
"SameGroupNo": 1,
"RequiredItemCount": 2
},
{
"Index": 5000,
"ItemGrade": 5,
"ResultGrade": 5,
"RequiredGradeLevel": 0,
"ResultGradeLevel": 1,
"SameGroupNo": 0,
"RequiredItemCount": 1
},
{
"Index": 5001,
"ItemGrade": 5,
"ResultGrade": 5,
"RequiredGradeLevel": 0,
"ResultGradeLevel": 2,
"SameGroupNo": 0,
"RequiredItemCount": 1
},
{
"Index": 5002,
"ItemGrade": 5,
"ResultGrade": 5,
"RequiredGradeLevel": 0,
"ResultGradeLevel": 3,
"SameGroupNo": 0,
"RequiredItemCount": 1
},
{
"Index": 5003,
"ItemGrade": 5,
"ResultGrade": 5,
"RequiredGradeLevel": 0,
"ResultGradeLevel": 4,
"SameGroupNo": 0,
"RequiredItemCount": 1
}
]

View File

@ -0,0 +1,7 @@
fileFormatVersion: 2
guid: e0a36cd5ac4a3dd43b8782a526b0965e
TextScriptImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View File

@ -0,0 +1,156 @@
[
{
"Index": 1001,
"Name": "\uC77C\uBC18 \uC7A5\uBE44 \uC0C1\uC790 (\uB79C\uB364) ",
"DropType": 1,
"Rate": 10000,
"Package_ID": 10001
},
{
"Index": 1002,
"Name": "\uC77C\uBC18 \uBAAC\uC2A4\uD130 \uB4DC\uB78D ",
"DropType": 1,
"Rate": 2,
"Package_ID": 10001
},
{
"Name": "\uC77C\uBC18 \uAC15\uD654\uC11D \uB4DC\uB78D",
"DropType": 1,
"Rate": 10,
"Package_ID": 10003
},
{
"Index": 1003,
"Name": "\uC77C\uBC18 \uC7A5\uBE44 \uC0C1\uC790 (\uB79C\uB364) ",
"DropType": 1,
"Rate": 10000,
"Package_ID": 10001
},
{
"Name": "\uC77C\uBC18 \uBB34\uAE30 \uC0C1\uC790 (ALL)",
"DropType": 1,
"Rate": 10000,
"Package_ID": 10002
},
{
"Index": 1004,
"Name": "\uC0C1\uC810 \uC7A5\uBE44 \uC0C1\uC790 (\uC77C\uBC18)(\uB79C\uB364)",
"DropType": 1,
"Rate": 10000,
"Package_ID": 10007
},
{
"Index": 1005,
"Name": "\uC0C1\uC810 \uC7A5\uBE44 \uC0C1\uC790 (\uACE0\uAE09)(\uB79C\uB364)",
"DropType": 1,
"Rate": 10000,
"Package_ID": 10008
},
{
"Index": 1006,
"Name": "\uCC55\uD130 1 \uBCF4\uC0C1 Step1",
"DropType": 0,
"Rate": 10000,
"Package_ID": 10015
},
{
"Index": 1007,
"Name": "\uCC55\uD130 1 \uBCF4\uC0C1 Step2",
"DropType": 0,
"Rate": 10000,
"Package_ID": 10015
},
{
"Index": 1008,
"Name": "\uCC55\uD130 1 \uBCF4\uC0C1 Step3",
"DropType": 0,
"Rate": 10000,
"Package_ID": 10015
},
{
"DropType": 1,
"Rate": 10000,
"Package_ID": 10001
},
{
"Index": 1009,
"Name": "\uC77C\uC77C \uC0C1\uC810 (\uD310\uB9E4 \uC0C1\uD488)",
"DropType": 0,
"Rate": 10000,
"Package_ID": 10009
},
{
"DropType": 1,
"Rate": 10000,
"Package_ID": 10010
},
{
"DropType": 1,
"Rate": 10000,
"Package_ID": 10011
},
{
"DropType": 1,
"Rate": 10000,
"Package_ID": 10012
},
{
"DropType": 1,
"Rate": 10000,
"Package_ID": 10013
},
{
"DropType": 1,
"Rate": 10000,
"Package_ID": 10014
},
{
"Index": 2001,
"Name": "\uCC55\uD130 1 \uC77C\uBC18 \uBAAC\uC2A4\uD130(\uACE8\uB4DC)",
"DropType": 1,
"Rate": 10000,
"Package_ID": 20001
},
{
"Index": 1010,
"Name": "\uC77C\uBC18, \uB9E4\uC9C1 \uB8EC \uC0C1\uC790 (\uB79C\uB364) ",
"DropType": 1,
"Rate": 10000,
"Package_ID": 10017
},
{
"Index": 1011,
"Name": "\uC0C1\uC810 \uB8EC \uC0C1\uC790 (\uACE0\uAE09)(\uB79C\uB364)",
"DropType": 1,
"Rate": 10000,
"Package_ID": 10018
},
{
"Index": 1012,
"Name": "\uAC15\uD654 \uC8FC\uBB38\uC11C \uC0C1\uC790",
"DropType": 1,
"Rate": 10000,
"Package_ID": 10003
},
{
"Index": 1013,
"Name": "s \uD15C \uC7A5\uBE44 \uC0C1\uC790",
"DropType": 1,
"Rate": 10000,
"Package_ID": 10027
},
{
"Index": 1014,
"Name": "\uC5D8\uB9AC\uD2B8 \uC7A5\uBE44 \uC0C1\uC790",
"DropType": 1,
"Rate": 10000,
"Package_ID": 10028
},
{
"Index": 1015,
"Name": "\uC5D8\uB9AC\uD2B8 \uB8EC \uC0C1\uC790",
"DropType": 1,
"Rate": 10000,
"Package_ID": 10029
}
]

View File

@ -0,0 +1,7 @@
fileFormatVersion: 2
guid: c4e7eabc6829ac241a2f58882f3a5b34
TextScriptImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,7 @@
fileFormatVersion: 2
guid: 8f58267c85d91af4e88bfa2398566922
TextScriptImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View File

@ -0,0 +1,752 @@
[
{
"Index": 1295,
"Desc": "\uD39C\uB9B4 \uAE30\uC0AC\uB2E8 \uBCA8\uD2B8",
"ItemGrade": 1,
"Ability": 18,
"AbilityValue": 20
},
{
"ItemGrade": 2,
"Ability": 2,
"AbilityValue": 15
},
{
"ItemGrade": 3,
"ConditionalAbility": 2,
"ConditionValue0": 500,
"ConditionValue1": 3
},
{
"ItemGrade": 4,
"Ability": 2,
"AbilityValue": 10
},
{
"ItemGrade": 5,
"ConditionalAbility": 8,
"ConditionValue0": 50,
"ConditionValue1": 3
},
{
"Index": 1085,
"Desc": "\uD39C\uB9B4 \uAE30\uC0AC\uB2E8 \uBAA9\uAC78\uC774",
"ItemGrade": 1,
"Ability": 14,
"AbilityValue": 1
},
{
"ItemGrade": 2,
"Ability": 6,
"AbilityValue": 15
},
{
"ItemGrade": 3,
"Ability": "15\n16",
"AbilityValue": "10\n50"
},
{
"ItemGrade": 4,
"Ability": 6,
"AbilityValue": 10
},
{
"ItemGrade": 5,
"ConditionalAbility": 13,
"ConditionValue0": 2
},
{
"Index": 1225,
"Desc": "\uD39C\uB9B4 \uAE30\uC0AC\uB2E8 \uAC11\uC637",
"ItemGrade": 1,
"Ability": 17,
"AbilityValue": 100
},
{
"ItemGrade": 2,
"Ability": 2,
"AbilityValue": 15
},
{
"ItemGrade": 3,
"ConditionalAbility": "10\n11",
"ConditionValue0": "15\n15"
},
{
"ItemGrade": 4,
"Ability": 2,
"AbilityValue": 10
},
{
"ItemGrade": 5,
"Ability": 17,
"AbilityValue": 100,
"ConditionalAbility": 12,
"ConditionValue0": 5
},
{
"Index": 1155,
"Desc": "\uD39C\uB9B4 \uAE30\uC0AC\uB2E8 \uC7A5\uAC11",
"ItemGrade": 1,
"ConditionalAbility": 1,
"ConditionValue0": 0,
"ConditionValue1": 0
},
{
"ItemGrade": 2,
"Ability": 6,
"AbilityValue": 15
},
{
"ItemGrade": 3,
"ConditionalAbility": 15,
"ConditionValue0": 50,
"ConditionValue1": 1
},
{
"ItemGrade": 4,
"Ability": 6,
"AbilityValue": 10
},
{
"ItemGrade": 5,
"ConditionalAbility": 16,
"ConditionValue0": 50,
"ConditionValue1": 200
},
{
"Index": 1365,
"Desc": "\uD39C\uB9B4 \uAE30\uC0AC\uB2E8 \uC2E0\uBC1C",
"ItemGrade": 1,
"Ability": 19,
"AbilityValue": 1
},
{
"ItemGrade": 2,
"Ability": 2,
"AbilityValue": 15
},
{
"ItemGrade": 3,
"ConditionalAbility": 36,
"ConditionValue0": 1,
"ConditionValue1": 900011
},
{
"ItemGrade": 4,
"Ability": 2,
"AbilityValue": 10
},
{
"ItemGrade": 5,
"ConditionalAbility": 36,
"ConditionValue0": 1,
"ConditionValue1": 900012
},
{
"Index": 1309,
"Desc": "\uD589\uC6B4\uC758 \uBCA8\uD2B8",
"ItemGrade": 2,
"Ability": 2,
"AbilityValue": 10
},
{
"ItemGrade": 3,
"ConditionalAbility": 22,
"ConditionValue0": 5
},
{
"ItemGrade": 4,
"ConditionalAbility": 22,
"ConditionValue0": 7
},
{
"ItemGrade": 5,
"Ability": 2,
"AbilityValue": 5
},
{
"Index": 1099,
"Desc": "\uD589\uC6B4\uC758 \uBAA9\uAC78\uC774",
"ItemGrade": 2,
"Ability": 6,
"AbilityValue": 10
},
{
"ItemGrade": 3,
"ConditionalAbility": 23,
"ConditionValue0": 1
},
{
"ItemGrade": 4,
"ConditionalAbility": 28,
"ConditionValue0": 15
},
{
"ItemGrade": 5,
"Ability": 6,
"AbilityValue": 5
},
{
"Index": 1239,
"Desc": "\uAD6C\uC6D0\uC758 \uAC11\uC637",
"ItemGrade": 2,
"Ability": 2,
"AbilityValue": 10
},
{
"ItemGrade": 3,
"ConditionalAbility": 21,
"ConditionValue0": 5,
"ConditionValue1": 30
},
{
"ItemGrade": 4,
"ConditionalAbility": 24,
"ConditionValue0": 1
},
{
"ItemGrade": 5,
"Ability": 2,
"AbilityValue": 5
},
{
"Index": 1169,
"Desc": "\uC591\uBA74\uC758 \uC7A5\uAC11",
"ItemGrade": 2,
"Ability": 6,
"AbilityValue": 10
},
{
"ItemGrade": 3,
"ConditionalAbility": 25,
"ConditionValue0": 1,
"ConditionValue1": 50
},
{
"ItemGrade": 4,
"ConditionalAbility": 26,
"ConditionValue0": 1
},
{
"ItemGrade": 5,
"Ability": 6,
"AbilityValue": 5
},
{
"Index": 1379,
"Desc": "\uB300\uBD80\uD638\uC758 \uC2E0\uBC1C",
"ItemGrade": 2,
"Ability": 2,
"AbilityValue": 10
},
{
"ItemGrade": 3,
"ConditionalAbility": 27,
"ConditionValue0": 1
},
{
"ItemGrade": 4,
"ConditionalAbility": 27,
"ConditionValue0": 2
},
{
"ItemGrade": 5,
"Ability": 2,
"AbilityValue": 5
},
{
"Index": 1323,
"Desc": "\uC9D1\uC911\uC758 \uBCA8\uD2B8",
"ItemGrade": 2,
"Ability": 2,
"AbilityValue": 10
},
{
"ItemGrade": 3,
"ConditionalAbility": 29,
"ConditionValue0": 15
},
{
"ItemGrade": 4,
"ConditionalAbility": 5,
"ConditionValue0": 1,
"ConditionValue1": 100
},
{
"ItemGrade": 5,
"Ability": 2,
"AbilityValue": 5
},
{
"Index": 1113,
"Desc": "\uC548\uC2DD\uC758 \uBAA9\uAC78\uC774",
"ItemGrade": 2,
"Ability": 6,
"AbilityValue": 10
},
{
"ItemGrade": 3,
"ConditionalAbility": 18,
"ConditionValue0": 70,
"ConditionValue1": 5
},
{
"ItemGrade": 4,
"ConditionalAbility": 19,
"ConditionValue0": 70,
"ConditionValue1": 20
},
{
"ItemGrade": 5,
"Ability": 6,
"AbilityValue": 5
},
{
"Index": 1253,
"Desc": "\uCCA0\uBCBD\uC758 \uAC11\uC637",
"ItemGrade": 2,
"Ability": 2,
"AbilityValue": 15
},
{
"ItemGrade": 3,
"Ability": 18,
"AbilityValue": 20
},
{
"ItemGrade": 4,
"ConditionalAbility": 17,
"ConditionValue0": 100,
"ConditionValue1": 50
},
{
"ItemGrade": 5,
"Ability": 2,
"AbilityValue": 5
},
{
"Index": 1183,
"Desc": "\uC9D1\uC911\uC758 \uC7A5\uAC11",
"ItemGrade": 2,
"Ability": 6,
"AbilityValue": 10
},
{
"ItemGrade": 3,
"ConditionalAbility": 30,
"ConditionValue0": 15
},
{
"ItemGrade": 4,
"ConditionalAbility": 31,
"ConditionValue0": 5
},
{
"ItemGrade": 5,
"Ability": 6,
"AbilityValue": 5
},
{
"Index": 1393,
"Desc": "\uD0C4\uB9C9 \uC2E0\uBC1C",
"ItemGrade": 2,
"Ability": 2,
"AbilityValue": 10
},
{
"ItemGrade": 3,
"ConditionalAbility": 32,
"ConditionValue0": 15
},
{
"ItemGrade": 4,
"ConditionalAbility": 33,
"ConditionValue0": 10
},
{
"ItemGrade": 5,
"Ability": 2,
"AbilityValue": 5
},
{
"Index": 1337,
"Desc": "\uC2E0\uB150\uC758 \uBCA8\uD2B8",
"ItemGrade": 2,
"Ability": 2,
"AbilityValue": 10
},
{
"ItemGrade": 3,
"ConditionalAbility": 2,
"ConditionValue0": 300,
"ConditionValue1": 1
},
{
"ItemGrade": 4,
"ConditionalAbility": 9,
"ConditionValue0": 1
},
{
"ItemGrade": 5,
"Ability": 2,
"AbilityValue": 5
},
{
"Index": 1127,
"Desc": "\uC554\uC0B4\uC790 \uBAA9\uAC78\uC774",
"ItemGrade": 2,
"Ability": 6,
"AbilityValue": 10
},
{
"ItemGrade": 3,
"ConditionalAbility": 6,
"ConditionValue0": 1,
"ConditionValue1": 3,
"ConditionValue2": 15
},
{
"ItemGrade": 4,
"ConditionalAbility": 7,
"ConditionValue0": 1,
"ConditionValue1": 1,
"ConditionValue2": 5
},
{
"ItemGrade": 5,
"Ability": 6,
"AbilityValue": 5
},
{
"Index": 1267,
"Desc": "\uC218\uD638\uC790 \uAC11\uC637",
"ItemGrade": 2,
"Ability": 2,
"AbilityValue": 15
},
{
"ItemGrade": 3,
"ConditionalAbility": 20,
"ConditionValue0": 50,
"ConditionValue1": 20
},
{
"ItemGrade": 4,
"ConditionalAbility": 17,
"ConditionValue0": 50,
"ConditionValue1": 20
},
{
"ItemGrade": 5,
"Ability": 2,
"AbilityValue": 5
},
{
"Index": 1197,
"Desc": "\uD559\uC0B4\uC790 \uC7A5\uAC11",
"ItemGrade": 2,
"Ability": 6,
"AbilityValue": 10
},
{
"ItemGrade": 3,
"Ability": 12,
"AbilityValue": 20
},
{
"ItemGrade": 4,
"ConditionalAbility": 3,
"ConditionValue0": 200,
"ConditionValue1": 2,
"ConditionValue2": 20
},
{
"ItemGrade": 5,
"Ability": 6,
"AbilityValue": 5
},
{
"Index": 1407,
"Desc": "\uCD94\uC801\uC790 \uC2E0\uBC1C",
"ItemGrade": 2,
"Ability": 2,
"AbilityValue": 10
},
{
"ItemGrade": 3,
"Ability": 4,
"AbilityValue": 1
},
{
"ItemGrade": 4,
"ConditionalAbility": 4,
"ConditionValue0": 200,
"ConditionValue1": 2,
"ConditionValue2": 30
},
{
"ItemGrade": 5,
"Ability": 2,
"AbilityValue": 5
},
{
"Index": 1351,
"Desc": "\uC800\uC8FC\uBC1B\uC740 \uBCA8\uD2B8",
"ItemGrade": 2,
"Ability": 2,
"AbilityValue": 10
},
{
"ItemGrade": 3,
"ConditionalAbility": 35,
"ConditionValue0": 5,
"ConditionValue1": 900013
},
{
"ItemGrade": 4,
"ConditionalAbility": 35,
"ConditionValue0": 5,
"ConditionValue1": 900014
},
{
"ItemGrade": 5,
"Ability": 2,
"AbilityValue": 5
},
{
"Index": 1141,
"Desc": "\uC9D1\uD589\uAD00 \uBAA9\uAC78\uC774",
"ItemGrade": 2,
"Ability": 6,
"AbilityValue": 10
},
{
"ItemGrade": 3,
"ConditionalAbility": 34,
"ConditionValue0": 20
},
{
"ItemGrade": 4,
"ConditionalAbility": 34,
"ConditionValue0": 50
},
{
"ItemGrade": 5,
"Ability": 6,
"AbilityValue": 5
},
{
"Index": 1281,
"Desc": "\uC18C\uC0DD\uC758 \uAC11\uC637",
"ItemGrade": 2,
"Ability": 2,
"AbilityValue": 15
},
{
"ItemGrade": 3,
"Ability": 17,
"AbilityValue": 50
},
{
"ItemGrade": 4,
"ConditionalAbility": "10\n11",
"ConditionValue0": "10\n10"
},
{
"ItemGrade": 5,
"Ability": 2,
"AbilityValue": 5
},
{
"Index": 1211,
"Desc": "\uC6A9\uBCD1\uB2E8 \uC7A5\uAC11",
"ItemGrade": 2,
"Ability": 6,
"AbilityValue": 10
},
{
"ItemGrade": 3,
"Ability": "6\n12",
"AbilityValue": "-5\n10"
},
{
"ItemGrade": 4,
"Ability": "15\n16",
"AbilityValue": "3\n10"
},
{
"ItemGrade": 5,
"Ability": 6,
"AbilityValue": "10"
},
{
"Index": 1421,
"Desc": "\uC6A9\uBCD1\uB2E8 \uC2E0\uBC1C",
"ItemGrade": 2,
"Ability": 2,
"AbilityValue": 10
},
{
"ItemGrade": 3,
"Ability": "4\n18",
"AbilityValue": "10\n10"
},
{
"ItemGrade": 4,
"Ability": "5\n8",
"AbilityValue": "10\n10"
},
{
"ItemGrade": 5,
"Ability": 2,
"AbilityValue": 5
},
{
"Index": 1001,
"Desc": "\uC6D4\uAD11",
"ItemGrade": 1,
"Ability": 6,
"AbilityValue": 15
},
{
"ItemGrade": 2
},
{
"ItemGrade": 3
},
{
"ItemGrade": 4,
"Ability": 6,
"AbilityValue": 10
},
{
"ItemGrade": 5
},
{
"Index": 1015,
"Desc": "\uD30C\uAD34\uC790",
"ItemGrade": 1,
"Ability": 6,
"AbilityValue": 10
},
{
"ItemGrade": 2
},
{
"ItemGrade": 3
},
{
"ItemGrade": 4,
"Ability": 6,
"AbilityValue": 5
},
{
"ItemGrade": 5
},
{
"Index": 1029,
"Desc": "\uBC14\uB78C\uC410\uAE30",
"ItemGrade": 1,
"Ability": 6,
"AbilityValue": 10
},
{
"ItemGrade": 2
},
{
"ItemGrade": 3
},
{
"ItemGrade": 4,
"Ability": 6,
"AbilityValue": 5
},
{
"ItemGrade": 5
},
{
"Index": 1043,
"Desc": "\uBE44\uC218",
"ItemGrade": 1,
"Ability": 6,
"AbilityValue": 10
},
{
"ItemGrade": 2
},
{
"ItemGrade": 3
},
{
"ItemGrade": 4,
"Ability": 6,
"AbilityValue": 5
},
{
"ItemGrade": 5
},
{
"Index": 1057,
"Desc": "\uBA69\uC57D",
"ItemGrade": 1,
"Ability": 6,
"AbilityValue": 10
},
{
"ItemGrade": 2
},
{
"ItemGrade": 3
},
{
"ItemGrade": 4,
"Ability": 6,
"AbilityValue": 5
},
{
"ItemGrade": 5
},
{
"Index": 1071,
"Desc": "\uB9C8\uBC95\uAC80",
"ItemGrade": 1,
"Ability": 6,
"AbilityValue": 10
},
{
"ItemGrade": 2
},
{
"ItemGrade": 3
},
{
"ItemGrade": 4,
"Ability": 6,
"AbilityValue": 5
},
{
"ItemGrade": 5
},
{
"Index": 1435,
"Desc": "\uBB35\uAD11",
"ItemGrade": 1,
"Ability": 6,
"AbilityValue": 10
},
{
"ItemGrade": 2
},
{
"ItemGrade": 3
},
{
"ItemGrade": 4,
"Ability": 6,
"AbilityValue": 5
},
{
"ItemGrade": 5
}
]

View File

@ -0,0 +1,7 @@
fileFormatVersion: 2
guid: aa4e30bd270bb4c488494918e4b13587
TextScriptImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,7 @@
fileFormatVersion: 2
guid: 68bf48d3810b37940b998391b07f1ecb
TextScriptImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,7 @@
fileFormatVersion: 2
guid: 0f760bafe4ff5ab44be2f79c9b363a3b
TextScriptImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,7 @@
fileFormatVersion: 2
guid: c9d268ce35c9ec0459064070bc00e36b
TextScriptImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,7 @@
fileFormatVersion: 2
guid: 1348a18e092e6c148be2d41c5248df63
TextScriptImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,7 @@
fileFormatVersion: 2
guid: dfe57775a8d2b1d4d964f4d72f93b2df
TextScriptImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,7 @@
fileFormatVersion: 2
guid: 806cf6f49a2158c43928a1c0b02bae75
TextScriptImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,7 @@
fileFormatVersion: 2
guid: 3a3e89c28e5310a459e09571fb036053
TextScriptImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View File

@ -0,0 +1,96 @@
%YAML 1.1
%TAG !u! tag:unity3d.com,2011:
--- !u!114 &11400000
MonoBehaviour:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 0}
m_Enabled: 1
m_EditorHideFlags: 0
m_Script: {fileID: 11500000, guid: f80db86491929264a866457d2db3103e, type: 3}
m_Name: WLEquipSettings
m_EditorClassIdentifier: Assembly-CSharp::WL.Equip.WLEquipSettings
enabled_: 1
verboseLog: 1
tableFolder: WL/FR
saveName: WL_Equip
resetSaveEveryStart: 1
goldItemIndex: 1
clearDropGroupId: 1001
clearDropRolls: 1
bossExtraRolls: 1
clearStoneItemIds: 65000000660000006700000068000000690000006a000000
clearStoneCountEach: 2
clearGoldBonus: 0
levelAttackStat: 8
levelDefenceStat: 11
levelHealthStat: 9
levelStatScale: 1
abilityMap:
- ability: 2
stat: 14
scale: 10
- ability: 6
stat: 13
scale: 10
- ability: 5
stat: 15
scale: 10
- ability: 13
stat: 13
scale: 10
- ability: 13
stat: 15
scale: 10
- ability: 4
stat: 31
scale: 10
- ability: 15
stat: 22
scale: 10
- ability: 16
stat: 23
scale: 10
- ability: 12
stat: 27
scale: 10
- ability: 3
stat: 34
scale: 10
- ability: 14
stat: 26
scale: 10
- ability: 7
stat: 40
scale: 10
- ability: 7
stat: 41
scale: 10
- ability: 7
stat: 42
scale: 10
openMenuIds: weapon,upgrade
cheatGrantEnabled: 1
cheatGrantGroupId: 1001
cheatGrantRolls: 5
uiSortingOrder: 300
dimColor: {r: 0, g: 0, b: 0, a: 0.75}
panelColor: {r: 0.12, g: 0.12, b: 0.14, a: 0.98}
boxColor: {r: 0.2, g: 0.2, b: 0.24, a: 1}
buttonColor: {r: 0.32, g: 0.36, b: 0.5, a: 1}
buttonDisabledColor: {r: 0.25, g: 0.25, b: 0.28, a: 1}
equippedRowColor: {r: 0.2, g: 0.45, b: 0.3, a: 1}
rowColor: {r: 0.22, g: 0.22, b: 0.26, a: 1}
selectedRowColor: {r: 0.55, g: 0.45, b: 0.2, a: 1}
panelMarginPx: 30
titleFontPx: 44
bodyFontPx: 30
smallFontPx: 26
rowHeightPx: 72
slotHeightPx: 110
buttonHeightPx: 84
detailHeightPx: 420
maxListRows: 300
fontPath:

View File

@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 5ee51d27b478a424dac5726aaf5a150b
NativeFormatImporter:
externalObjects: {}
mainObjectFileID: 11400000
userData:
assetBundleName:
assetBundleVariant:

View File

@ -0,0 +1,263 @@
// ─────────────────────────────────────────────────────────────────────────────
// WLEquipData.cs — FR(FirstRising) 기존 장비 시스템의 데이터 모델 이식 (#821 · 2026-10-08)
//
// 원본 = FR `Assets/Scripts/GameHub/JsonCommon.cs` (ItemJson · ItemLevelJson · ItemComposeJson ·
// ItemOptionJson · ItemDropGroup/Package · RawItem) 를 **필드 이름 그대로** 옮겼다 — FR 의 JSON
// 테이블(`Resources/WL/FR/*.json`)을 수정 없이 Newtonsoft 로 읽기 위해서다.
//
// PD 지시: 탕탕특공대식 — 본 아이템 1 + 같은 등급 재료 2(표 RequiredItemCount) → 등급 업 ·
// 등급마다 옵션 개방(ItemOptionTable) · 강화석+골드로 레벨업(Item_*.json) · 강등.
// 능력치 본체는 힘민지(ActorStatInfo) 그대로 쓰고, FR eAbility 는 WLEquipSettings.abilityMap 으로 대응한다.
// ─────────────────────────────────────────────────────────────────────────────
using System;
using System.Collections.Generic;
namespace WL.Equip
{
/// <summary>FR eAbility — 값(순서)이 JSON 의 정수와 일치해야 한다. BT47 이후 항목은 옮기지 않았다(PD 제외).</summary>
public enum eFRAbility
{
None,
ItemRange, // 1 아이템 획득 범위 %
Health, // 2 체력 %
RecoveryHealth, // 3 초당 체력 회복 %
Speed, // 4 이동 속도 %
Defence, // 5 모든 피해 감소 %
Attack, // 6 공격력 %
IncreaseDrop, // 7 경험치·골드·드랍율 %
Rolling, // 8 구르기
ProjectileSpeed, // 9 투사체 속도 %
SkillRange, // 10 스킬 범위 %
SkillDuration, // 11 스킬 지속 %
Cooltime, // 12 쿨타임 감소 %
AttackDefence, // 13 공격력+방어력 %
Passive, // 14 패시브 효과 %
Critical, // 15 치명타 확률 %
CriticalDamage, // 16 치명타 피해 %
Revive, // 17 부활 횟수
HeartRecover, // 18 하트 회복 %
SpeedValue, // 19 이동 속도 값
Excellent, // 20 액설런트 확률
ExcellentDamage, // 21 액설런트 피해
Max,
}
public enum eFRItemGrade { None = -1, Normal, Magic, Rare, Elite, Epic, Legend, Max }
public enum eFREquipmentType { None = 0, Weapon, Necklace, Glove, Armor, Belt, Shose, Max }
public enum eFRItemType { None = 0, Equipment, Buff, LevelUpScroll, EnchantScroll, Goods, Normal, RewardPoint, Max }
public enum eFRDropType { All = 0, Random = 1, Max }
[Serializable]
public class ItemJson
{
public int Index;
public string Item_name;
public string Desc;
public byte IsEquip;
public byte IsStack;
public int ItemCategory;
public eFRItemType ItemType;
public int WeaponType;
public eFREquipmentType EquipmentType;
public eFRItemGrade ItemGrade;
public int ItemClass;
public int ItemDropGroup_ID;
public int GradeLv;
public int GroupNo;
public int ItemMaxLv;
public int ItemLvAbilityID;
public int SkillIndex;
public int CostumeIndex;
// FR 의 `Equipments`(프리팹 이름)는 FR 전용이라 읽지 않는다.
public bool IsEquipment { get { return IsEquip == 1 && ItemType == eFRItemType.Equipment; } }
}
[Serializable]
public class ItemLevelInfo
{
public int Index;
public eFRItemGrade Grade;
public int GradeLv;
public int Level;
public int AttackAbility;
public int DefenceAbility;
public int HealthAbility;
public int ItemKeyRequired;
public int ItemCountRequired;
public int MoneyKeyRequired;
public int MoneyCountRequired;
}
[Serializable]
public class ItemLevelJson
{
public int Index;
public List<ItemLevelInfo> LevelInfos = new List<ItemLevelInfo>();
public ItemLevelJson(int index) { Index = index; }
public ItemLevelInfo FindLevelInfo(int level) { return LevelInfos.Find(i => i.Level == level); }
}
[Serializable]
public class ItemComposeJson
{
public int Index;
public int SameGroupNo;
public eFRItemGrade ItemGrade;
public eFRItemGrade ResultGrade;
public int RequiredGradeLevel;
public int ResultGradeLevel;
public int RequiredItemCount;
}
/// <summary>ItemOptionTable.json 의 한 행(엑셀 셀 병합 → 첫 행만 Index).</summary>
[Serializable]
public class ItemOptionReader
{
public int Index;
public string Desc;
public eFRItemGrade ItemGrade;
public string Ability;
public string AbilityValue;
public string ConditionalAbility;
public string ConditionValue0;
public string ConditionValue1;
public string ConditionValue2;
}
[Serializable]
public class ItemOptionAbility
{
public eFRAbility Ability;
public int AbilityValue;
public ItemOptionAbility(eFRAbility a, int v) { Ability = a; AbilityValue = v; }
}
[Serializable]
public class ConditionalItemOptionAbility
{
public int Ability; // FR eConditionalItemOptionAbility — WL 에서는 「미적용」(표시만)
public int ConditionValue0, ConditionValue1, ConditionValue2;
}
[Serializable]
public class ItemOptionElement
{
public eFRItemGrade ItemGrade;
public List<ItemOptionAbility> Abilities = new List<ItemOptionAbility>();
public List<ConditionalItemOptionAbility> ConditionalAbilities = new List<ConditionalItemOptionAbility>();
public ItemOptionElement(ItemOptionReader r)
{
ItemGrade = r.ItemGrade;
if (!string.IsNullOrEmpty(r.Ability))
{
var a = r.Ability.Split('\n');
var v = (r.AbilityValue ?? "").Split('\n');
for (int i = 0; i < a.Length; i++)
{
int ai, vi;
if (!int.TryParse(a[i].Trim(), out ai)) continue;
vi = i < v.Length && int.TryParse(v[i].Trim(), out vi) ? vi : 0;
Abilities.Add(new ItemOptionAbility((eFRAbility)ai, vi));
}
}
if (!string.IsNullOrEmpty(r.ConditionalAbility))
{
var a = r.ConditionalAbility.Split('\n');
var c0 = string.IsNullOrEmpty(r.ConditionValue0) ? null : r.ConditionValue0.Split('\n');
var c1 = string.IsNullOrEmpty(r.ConditionValue1) ? null : r.ConditionValue1.Split('\n');
var c2 = string.IsNullOrEmpty(r.ConditionValue2) ? null : r.ConditionValue2.Split('\n');
for (int i = 0; i < a.Length; i++)
{
var ca = new ConditionalItemOptionAbility();
int t;
if (int.TryParse(a[i].Trim(), out t)) ca.Ability = t;
if (c0 != null && i < c0.Length && int.TryParse(c0[i].Trim(), out t)) ca.ConditionValue0 = t;
if (c1 != null && i < c1.Length && int.TryParse(c1[i].Trim(), out t)) ca.ConditionValue1 = t;
if (c2 != null && i < c2.Length && int.TryParse(c2[i].Trim(), out t)) ca.ConditionValue2 = t;
ConditionalAbilities.Add(ca);
}
}
}
}
[Serializable]
public class ItemOptionJson
{
public int Index; // = ItemJson.GroupNo
public string Desc;
public List<ItemOptionElement> ItemOptions = new List<ItemOptionElement>();
public ItemOptionJson(int index, string desc) { Index = index; Desc = desc; }
}
[Serializable]
public class ItemDropGroupInfo
{
public int Index;
public string Name;
public eFRDropType DropType;
public int Rate; // 10000 분율
public int Package_ID;
}
[Serializable]
public class ItemDropGroupJson
{
public int Index;
public List<ItemDropGroupInfo> ItemDropGroupInfos = new List<ItemDropGroupInfo>();
public ItemDropGroupJson(int index) { Index = index; }
}
[Serializable]
public class ItemDropPackageInfo
{
public int Index;
public string Name;
public int Item_ID;
public int MinCount;
public int MaxCount;
public int Rate;
}
[Serializable]
public class ItemDropPackageJson
{
public int Index;
public int TotalRate;
public List<ItemDropPackageInfo> ItemDropPackageInfos = new List<ItemDropPackageInfo>();
public ItemDropPackageJson(int index) { Index = index; }
}
/// <summary>보유 아이템 1건(FR RawItem). `_id` 는 세이브 안 고유 번호.</summary>
[Serializable]
public class RawItem
{
public int index;
public int level;
public int count;
public int _id;
public RawItem() { }
public RawItem(int index, int level, int count, int id) { this.index = index; this.level = level; this.count = count; _id = id; }
}
/// <summary>RawItem + 표 행.</summary>
public class Item
{
public RawItem rawItem;
public ItemJson itemJson;
public Item(RawItem r, ItemJson j) { rawItem = r; itemJson = j; }
}
/// <summary>세이브 파일 본문(JSON).</summary>
[Serializable]
public class WLEquipSaveData
{
public int version = 1;
public int nextId = 1;
public long gold; // 섬(IslandManager)이 없을 때만 쓰는 예비 골드
public List<RawItem> items = new List<RawItem>();
public List<int> equipItems = new List<int>(); // 장착 중인 _id
}
}

View File

@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 0368eeda61a55e74a96bd5c12b797cb0

View File

@ -0,0 +1,58 @@
// ─────────────────────────────────────────────────────────────────────────────
// WLEquipDrops.cs — 던전 클리어 때 FR 드랍표로 장비·강화석 지급 (#821)
//
// 훅 = `WLIslandBridge.OnStageCleared` 의 `WLIslandExpansionGate.Unlock(cfg);` 다음 1줄.
// FR 의 드랍 규칙(ItemDropGroupTable 10000 분율 → ItemDropPackageTable 가중 추첨)을 그대로 돌린다.
// 값(그룹 ID · 횟수 · 강화석)은 WLEquipSettings.
// ─────────────────────────────────────────────────────────────────────────────
using System.Collections.Generic;
using UnityEngine;
using WL.Combat.Stage;
namespace WL.Equip
{
public static class WLEquipDrops
{
public static string LastGrantLog { get; private set; } = "";
public static int Grants { get; private set; }
static readonly System.Random s_rng = new System.Random();
static readonly List<WLEquipTables.Drop> s_drops = new List<WLEquipTables.Drop>(8);
public static void OnStageCleared(in StageClearResult r)
{
var cfg = WLEquipSettings.Instance;
if (cfg == null || cfg.enabled_ == 0) return;
if (!r.win) return;
int rolls = cfg.clearDropRolls + (r.isBoss ? cfg.bossExtraRolls : 0);
string log = Grant(cfg.clearDropGroupId, rolls, cfg.clearStoneItemIds, cfg.clearStoneCountEach, cfg.clearGoldBonus);
Grants++;
LastGrantLog = "클리어 드랍(" + r.index + ") — " + log;
if (cfg.verboseLog != 0) Debug.Log("[WL-821] " + LastGrantLog);
}
/// <summary>드랍 그룹을 rolls 번 굴려 지급 + 강화석 + 골드. 돌려주는 문자열 = 지급 내역.</summary>
public static string Grant(int groupId, int rolls, int[] stoneIds, int stoneEach, int goldBonus)
{
if (!WLInventory.EnsureLoaded()) return "인벤 로드 실패";
s_drops.Clear();
for (int i = 0; i < rolls; i++) WLEquipTables.RollDropGroup(groupId, s_drops, s_rng);
var gained = new List<Item>();
var sb = new System.Text.StringBuilder();
for (int i = 0; i < s_drops.Count; i++)
{
WLInventory.AddItem(s_drops[i].itemId, 1, s_drops[i].count, gained);
var j = WLEquipTables.FindItemJson(s_drops[i].itemId);
sb.Append(j != null ? j.Item_name : s_drops[i].itemId.ToString()).Append("×").Append(s_drops[i].count).Append(' ');
}
if (stoneIds != null && stoneEach > 0)
{
for (int i = 0; i < stoneIds.Length; i++) WLInventory.AddItem(stoneIds[i], 1, stoneEach);
sb.Append("강화석 ").Append(stoneIds.Length).Append("종×").Append(stoneEach).Append(' ');
}
if (goldBonus > 0) { WLInventory.AddGold(goldBonus); sb.Append("골드+").Append(goldBonus); }
WLInventory.Commit("획득 — " + sb);
return sb.ToString();
}
}
}

View File

@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: ff59f286a796bb94897714b95909bb91

View File

@ -0,0 +1,144 @@
// ─────────────────────────────────────────────────────────────────────────────
// WLEquipSettings.cs — FR 장비 시스템 WL 이식 설정(SO · `Resources/WL/WLEquipSettings.asset`) (#821)
//
// C45: 세이브 이름 · 재화 ID · 드랍 규칙 · FR 능력치 → 힘민지 스탯 대응표 · UI 크기를 전부 여기 둔다.
// 🔴 SO 는 클래스와 같은 이름의 .cs 파일에만 둔다(816g 교훈 — 아니면 .asset 의 m_Script 가 빈다).
// ─────────────────────────────────────────────────────────────────────────────
using System.Collections.Generic;
using UnityEngine;
namespace WL.Equip
{
/// <summary>FR eAbility(%) 한 항목을 힘민지 eStat 하나에 더하는 규칙. 같은 ability 를 여러 행에 적으면 모두 더한다.</summary>
[System.Serializable]
public struct WLAbilityMap
{
public eFRAbility ability;
public eStat stat;
[Tooltip("FR 값(%)에 곱해 힘민지 값으로. 힘민지 Multiplier 계열은 1000 분율(100 = 1 %) 이라 FR 1 % → 10")]
public float scale;
}
public sealed class WLEquipSettings : ScriptableObject
{
[Header("스위치")]
[Tooltip("0 이면 장비 시스템 전체 무동작(스탯 훅·드랍·메뉴 전부)")]
public int enabled_ = 1;
public int verboseLog = 1;
[Header("데이터")]
[Tooltip("FR 표 json 이 든 Resources 폴더")]
public string tableFolder = "WL/FR";
[Header("세이브")]
[Tooltip("persistentDataPath 아래 파일 이름(확장자 제외)")]
public string saveName = "WL_Equip";
[Tooltip("1 이면 Play(실행) 1회당 첫 로드 때 세이브를 지운다 — 섬 세이브(resetSaveEveryStart)와 같은 정책")]
public int resetSaveEveryStart = 1;
[Header("재화 — FR ItemTable 의 특수 인덱스")]
[Tooltip("FR 골드 아이템 Index(강화 비용 MoneyKeyRequired). 섬이 떠 있으면 섬 코인(IslandManager.Coin)을 쓴다")]
public int goldItemIndex = 1;
[Header("던전 클리어 드랍 (FR ItemDropGroupTable)")]
[Tooltip("클리어 때 굴릴 드랍 그룹 ID(1001 = 일반 장비 상자 · 패키지 10001 에서 1개)")]
public int clearDropGroupId = 1001;
public int clearDropRolls = 1;
public int bossExtraRolls = 1;
[Tooltip("클리어마다 주는 강화석 Item Index 목록(101~106 = 부위별 강화석)")]
public int[] clearStoneItemIds = { 101, 102, 103, 104, 105, 106 };
public int clearStoneCountEach = 2;
[Tooltip("클리어 골드 보상에 더해 FR 골드도 지급할지(섬 코인으로 들어간다) · 0 = 안 줌")]
public int clearGoldBonus = 0;
[Header("플레이어 기본 공격력 (PD 2026-10-09 「기본 공격력은 5」)")]
[Tooltip("0 보다 크면 스탯 재계산 뒤 PC 의 ATK 를 「이 값 + 장착 장비의 레벨표 공격력」 으로 덮고 BaseDmg 도 같은 비율로 맞춘다. 0 = 힘민지 원본 공식(주스탯 × 배율) 그대로")]
public double pcBaseAttackOverride = 5;
[Header("레벨표 능력치 → 힘민지 스탯")]
public eStat levelAttackStat = eStat.ATK;
public eStat levelDefenceStat = eStat.DEF;
public eStat levelHealthStat = eStat.HP;
public float levelStatScale = 1f;
[Header("등급 옵션(FR eAbility %) → 힘민지 스탯")]
public List<WLAbilityMap> abilityMap = new List<WLAbilityMap>
{
new WLAbilityMap { ability = eFRAbility.Health, stat = eStat.HP_Multiplier, scale = 10f },
new WLAbilityMap { ability = eFRAbility.Attack, stat = eStat.ATK_Multiplier, scale = 10f },
new WLAbilityMap { ability = eFRAbility.Defence, stat = eStat.DEF_Multiplier, scale = 10f },
new WLAbilityMap { ability = eFRAbility.AttackDefence, stat = eStat.ATK_Multiplier, scale = 10f },
new WLAbilityMap { ability = eFRAbility.AttackDefence, stat = eStat.DEF_Multiplier, scale = 10f },
new WLAbilityMap { ability = eFRAbility.Speed, stat = eStat.MOVE_SPD_Multiplier, scale = 10f },
new WLAbilityMap { ability = eFRAbility.Critical, stat = eStat.CRI, scale = 10f },
new WLAbilityMap { ability = eFRAbility.CriticalDamage, stat = eStat.CRI_DMG_Multiplier, scale = 10f },
new WLAbilityMap { ability = eFRAbility.Cooltime, stat = eStat.COOLTIME_Multiplier, scale = 10f },
new WLAbilityMap { ability = eFRAbility.RecoveryHealth, stat = eStat.HP_REGEN_Multiplier, scale = 10f },
new WLAbilityMap { ability = eFRAbility.Passive, stat = eStat.SKILL_Multiplier, scale = 10f },
new WLAbilityMap { ability = eFRAbility.IncreaseDrop, stat = eStat.GOLD_Multiplier, scale = 10f },
new WLAbilityMap { ability = eFRAbility.IncreaseDrop, stat = eStat.EXP_Multiplier, scale = 10f },
new WLAbilityMap { ability = eFRAbility.IncreaseDrop, stat = eStat.ITEM_Multiplier, scale = 10f },
};
[Header("메뉴 연결")]
[Tooltip("하단 메뉴 DummyMenuButton 의 menuId — 이 값이면 장비 화면을 연다(쉼표로 여러 개)")]
public string openMenuIds = "weapon,upgrade";
[Tooltip("1 이면 화면에 「테스트 획득」 버튼(드랍 그룹을 굴려 바로 지급)을 둔다")]
public int cheatGrantEnabled = 1;
public int cheatGrantGroupId = 1001;
public int cheatGrantRolls = 5;
[Header("런처 버튼 — 인게임(섬·던전)에서 항상 보이는 「장비」 버튼 (하단 메뉴는 섬에서 숨겨져 있어 별도 진입점)")]
public int launcherEnabled = 1;
[Tooltip("이 씬이 활성 씬이고 서버 데이터가 있을 때만 보인다")]
public string launcherSceneName = "InGame";
public string launcherLabel = "장비";
public Vector2 launcherSizePx = new Vector2(200f, 84f);
[Tooltip("화면 오른쪽·아래 모서리에서의 거리(px · 1080×1920 기준)")]
public Vector2 launcherOffsetPx = new Vector2(24f, 300f);
[Tooltip("1 이면 장비 화면을 열 때 힘민지 원본 EquipmentUI 가 켜져 있으면 끈다(하단 「무기」 버튼의 원본 onClick 이 초기화 없이 켜는 사고 방지)")]
public int closeLegacyEquipmentUi = 1;
[Header("더미 UI (기준 1080×1920 px)")]
public int uiSortingOrder = 300;
public Color dimColor = new Color(0f, 0f, 0f, 0.75f);
public Color panelColor = new Color(0.12f, 0.12f, 0.14f, 0.98f);
public Color boxColor = new Color(0.2f, 0.2f, 0.24f, 1f);
public Color buttonColor = new Color(0.32f, 0.36f, 0.5f, 1f);
public Color buttonDisabledColor = new Color(0.25f, 0.25f, 0.28f, 1f);
public Color equippedRowColor = new Color(0.2f, 0.45f, 0.3f, 1f);
public Color rowColor = new Color(0.22f, 0.22f, 0.26f, 1f);
public Color selectedRowColor = new Color(0.55f, 0.45f, 0.2f, 1f);
public float panelMarginPx = 30f;
public float titleFontPx = 44f;
public float bodyFontPx = 30f;
public float smallFontPx = 26f;
public float rowHeightPx = 72f;
public float slotHeightPx = 110f;
public float buttonHeightPx = 84f;
public float detailHeightPx = 420f;
public int maxListRows = 300;
[Tooltip("한글 TMP 폰트 에셋 경로(비우면 화면에 있는 TMP 라벨의 폰트를 빌린다)")]
public string fontPath = "";
static WLEquipSettings s_cache;
public static WLEquipSettings Instance
{
get
{
if (s_cache == null) s_cache = Resources.Load<WLEquipSettings>("WL/WLEquipSettings");
return s_cache;
}
}
public static bool Enabled { get { var c = Instance; return c != null && c.enabled_ != 0; } }
public bool IsOpenMenuId(string id)
{
if (string.IsNullOrEmpty(openMenuIds) || string.IsNullOrEmpty(id)) return false;
var parts = openMenuIds.Split(',');
for (int i = 0; i < parts.Length; i++) if (parts[i].Trim() == id) return true;
return false;
}
}
}

View File

@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: f80db86491929264a866457d2db3103e

View File

@ -0,0 +1,145 @@
// ─────────────────────────────────────────────────────────────────────────────
// WLEquipStats.cs — FR 장비 능력치를 힘민지 ActorStatInfo 에 더한다 (#821)
//
// 훅 = `MyValue.cs` ActorStatInfo.Reset() 의 `Set_EquipItem();` 다음 1줄(`WL.Equip.WLEquipStats.Apply(this, m_Role);`).
// 능력치 본체(힘·민첩·지능·운·ATK·HP…)는 힘민지 그대로이고, 여기서는 **장착 중인 FR 장비**의
// ① 레벨표 수치(AttackAbility/DefenceAbility/HealthAbility → ATK/DEF/HP)
// ② 등급별 개방 옵션(FR eAbility % → WLEquipSettings.abilityMap 의 eStat)
// 만 Set_Stat(true, …) 로 더한다. 설정이 없거나 꺼져 있으면 아무것도 하지 않는다.
// 🔴 장비 능력치 체계(어떤 스탯을 줄지)는 PD 와 후속 논의 예정 — 대응표는 전부 SO 라 코드 수정 없이 바꾼다.
// ─────────────────────────────────────────────────────────────────────────────
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; }
/// <summary>마지막 Apply 에서 장착 장비 레벨표로 더한 공격력 합(기본 공격력 덮어쓰기의 가산분).</summary>
public static double LastEquipAttack { get; private set; }
public static int BaseAttackEnforced;
public static string LastEnforceLog { get; private set; } = "";
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);
}
/// <summary>ActorStatInfo.Reset() 안에서 부른다(PC 만).</summary>
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;
if (!WLInventory.EnsureLoaded()) return;
var data = WLInventory.Data;
LastEquipAttack = 0;
if (data == null || data.equipItems.Count == 0) { LastReport = "장착 0"; LastAppliedItems = 0; return; }
var sb = new System.Text.StringBuilder();
int applied = 0;
double equipAtk = 0;
for (int i = 0; i < data.equipItems.Count; i++)
{
var item = WLInventory.FindItem(data.equipItems[i]);
if (item == null || !item.itemJson.IsEquipment) continue;
applied++;
sb.Append(item.itemJson.Item_name).Append('(').Append(WLEquipTables.GradeName(item.itemJson.ItemGrade)).Append(" Lv").Append(item.rawItem.level).Append("): ");
var lt = WLEquipTables.FindItemLevelJson(item.itemJson.ItemLvAbilityID);
var lv = lt != null ? lt.FindLevelInfo(item.rawItem.level) : null;
if (lv != null)
{
if (lv.AttackAbility != 0) { info.Set_Stat(true, cfg.levelAttackStat, lv.AttackAbility * cfg.levelStatScale); sb.Append(cfg.levelAttackStat).Append('+').Append(lv.AttackAbility).Append(' '); if (cfg.levelAttackStat == eStat.ATK) equipAtk += lv.AttackAbility * cfg.levelStatScale; }
if (lv.DefenceAbility != 0) { info.Set_Stat(true, cfg.levelDefenceStat, lv.DefenceAbility * cfg.levelStatScale); sb.Append(cfg.levelDefenceStat).Append('+').Append(lv.DefenceAbility).Append(' '); }
if (lv.HealthAbility != 0) { info.Set_Stat(true, cfg.levelHealthStat, lv.HealthAbility * cfg.levelStatScale); sb.Append(cfg.levelHealthStat).Append('+').Append(lv.HealthAbility).Append(' '); }
}
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];
bool any = false;
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);
sb.Append(map.stat).Append('+').Append(ab.AbilityValue * map.scale).Append(' ');
any = true;
}
if (!any) sb.Append(ab.Ability).Append("(미매핑) ");
}
}
sb.Append("| ");
}
LastAppliedItems = applied;
LastEquipAttack = equipAtk;
LastReport = sb.ToString();
if (cfg.verboseLog != 0) Debug.Log("[WL-821] 장비 스탯 적용 " + applied + "개 — " + LastReport);
}
/// <summary>PC 스탯 재계산(힘민지 EquipmentUI 와 같은 호출). 게임 밖(타이틀)에서는 아무 일도 없다.</summary>
public static void RefreshPc()
{
try
{
if (ServerInfo.Ins == null || ServerInfo.Ins.m_ServerData == null) return;
var info = MyValue.Get_ActorStatInfoorNull(eRole.PC);
EnforceBaseAttack(info);
}
catch (System.Exception ex) { Debug.LogWarning("[WL-821] PC 스탯 재계산 실패 — " + ex.Message); }
}
/// <summary>
/// PD 2026-10-09 「플레이어 기본 공격력은 5」 — 힘민지 Reset 은 ATK 를 주스탯(STR/DEX/INT)×배율로 만든다(MyValue.cs:336).
/// Reset 끝에 훅이 없으므로 **재계산 뒤 사후 보정**: ATK = 기본값 + 장착 장비 공격력, BaseDmg(최종 공격력)는 같은 비율(BaseDmg/ATK)로.
/// Reset 이 다시 돌면 원본 값으로 돌아오므로 주기 폴링(WLEquipLauncher 0.5 s)에서 다시 맞춘다. 0 이면 무동작.
/// </summary>
public static bool EnforceBaseAttack(ActorStatInfo info)
{
var cfg = WLEquipSettings.Instance;
if (cfg == null || cfg.enabled_ == 0 || cfg.pcBaseAttackOverride <= 0) return false;
if (info == null) return false;
double atk = info.Get_Stat(eStat.ATK);
double target = cfg.pcBaseAttackOverride + LastEquipAttack;
if (System.Math.Abs(atk - target) < 0.01) return false;
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") + " (장비 +" + LastEquipAttack + ")";
if (cfg.verboseLog != 0 && BaseAttackEnforced <= 3) Debug.Log("[WL-821] 기본 공격력 보정 — " + LastEnforceLog);
return true;
}
/// <summary>주기 폴링용 — PC 가 있으면 현재 스탯 객체를 가져와(재계산 없이) 보정한다.</summary>
public static void EnforceBaseAttackTick()
{
try
{
if (!MyValue.bMyPC || MyValue.MyPC == null) return;
EnforceBaseAttack(MyValue.MyPC.Get_StatInfo());
}
catch { }
}
}
}

View File

@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 0e4ded02e5ff17845900c10da1d5f0a6

View File

@ -0,0 +1,316 @@
// ─────────────────────────────────────────────────────────────────────────────
// WLEquipTables.cs — FR 장비 테이블 로더 (#821)
//
// FR 은 xlsm → json → Cook(prefab) 3단이지만 WL 에서는 **json 을 Resources 에서 바로** 읽는다.
// FR 표는 엑셀 셀 병합 때문에 묶음의 첫 행에만 Index 가 있다 → FR `ItemLevelTable.LoadFromText` ·
// `JsonUtil.ReadItemDropPackageJson` · `ItemOptionTable.LoadFromText` 의 묶기 규칙을 그대로 재현한다.
// 파일 이름은 FR 원본 그대로(`WLEquipSettings.tableFolder` 아래).
// ─────────────────────────────────────────────────────────────────────────────
using System.Collections.Generic;
using Newtonsoft.Json;
using UnityEngine;
namespace WL.Equip
{
public static class WLEquipTables
{
public static bool Loaded { get; private set; }
public static string LastLog { get; private set; } = "";
static readonly Dictionary<int, ItemJson> s_items = new Dictionary<int, ItemJson>();
static readonly List<ItemJson> s_itemList = new List<ItemJson>();
static readonly Dictionary<int, ItemLevelJson> s_levels = new Dictionary<int, ItemLevelJson>();
static readonly Dictionary<int, ItemComposeJson> s_compose = new Dictionary<int, ItemComposeJson>();
static readonly Dictionary<int, ItemOptionJson> s_options = new Dictionary<int, ItemOptionJson>();
static readonly Dictionary<int, ItemDropGroupJson> s_dropGroups = new Dictionary<int, ItemDropGroupJson>();
static readonly Dictionary<int, ItemDropPackageJson> s_dropPackages = new Dictionary<int, ItemDropPackageJson>();
public static IReadOnlyList<ItemJson> Items { get { EnsureLoaded(); return s_itemList; } }
public static int ItemCount { get { return s_items.Count; } }
public static int LevelTableCount { get { return s_levels.Count; } }
public static int OptionCount { get { return s_options.Count; } }
public static bool EnsureLoaded()
{
if (Loaded) return true;
var cfg = WLEquipSettings.Instance;
string folder = cfg != null ? cfg.tableFolder : "WL/FR";
try
{
LoadItems(folder + "/ItemTable");
LoadCompose(folder + "/ItemComposeTable");
LoadOptions(folder + "/ItemOptionTable");
string[] levelFiles = { "Item_Weapon", "Item_Necklace", "Item_Gloves", "Item_Amor", "Item_Belt", "Item_Shoes" };
for (int i = 0; i < levelFiles.Length; i++) LoadLevels(folder + "/" + levelFiles[i]);
LoadDropGroups(folder + "/ItemDropGroupTable");
LoadDropPackages(folder + "/ItemDropPackageTable");
Loaded = true;
LastLog = "FR 표 로드 — 아이템 " + s_items.Count + " · 레벨표 " + s_levels.Count + " · 합성 " + s_compose.Count +
" · 옵션 " + s_options.Count + " · 드랍그룹 " + s_dropGroups.Count + " · 드랍패키지 " + s_dropPackages.Count;
if (cfg != null && cfg.verboseLog != 0) Debug.Log("[WL-821] " + LastLog);
}
catch (System.Exception ex)
{
LastLog = "FR 표 로드 실패 — " + ex.Message;
Debug.LogError("[WL-821] " + LastLog);
}
return Loaded;
}
static string ReadText(string path)
{
var ta = Resources.Load<TextAsset>(path);
if (ta == null) throw new System.IO.FileNotFoundException("Resources/" + path + ".json 없음");
return ta.text;
}
static void LoadItems(string path)
{
s_items.Clear(); s_itemList.Clear();
var arr = JsonConvert.DeserializeObject<ItemJson[]>(ReadText(path));
for (int i = 0; i < arr.Length; i++)
{
if (arr[i].Index <= 0) continue;
if (s_items.ContainsKey(arr[i].Index)) continue;
s_items.Add(arr[i].Index, arr[i]);
s_itemList.Add(arr[i]);
}
}
static void LoadCompose(string path)
{
s_compose.Clear();
var arr = JsonConvert.DeserializeObject<ItemComposeJson[]>(ReadText(path));
for (int i = 0; i < arr.Length; i++) if (!s_compose.ContainsKey(arr[i].Index)) s_compose.Add(arr[i].Index, arr[i]);
}
static void LoadOptions(string path)
{
s_options.Clear();
var readers = JsonConvert.DeserializeObject<ItemOptionReader[]>(ReadText(path));
ItemOptionJson cur = null;
for (int i = 0; i < readers.Length; i++)
{
var r = readers[i];
if (cur == null || (r.Index != 0 && cur.Index != r.Index))
{
cur = new ItemOptionJson(r.Index, r.Desc);
if (!s_options.ContainsKey(cur.Index)) s_options.Add(cur.Index, cur);
}
cur.ItemOptions.Add(new ItemOptionElement(r));
}
}
static void LoadLevels(string path)
{
var infos = JsonConvert.DeserializeObject<ItemLevelInfo[]>(ReadText(path));
ItemLevelJson cur = null;
for (int i = 0; i < infos.Length; i++)
{
int idx = infos[i].Index;
if (cur == null || (idx != 0 && cur.Index != idx))
{
cur = new ItemLevelJson(idx);
if (!s_levels.ContainsKey(idx)) s_levels.Add(idx, cur); else cur = s_levels[idx];
}
else if (idx == 0) infos[i].Index = cur.Index;
cur.LevelInfos.Add(infos[i]);
}
}
static void LoadDropGroups(string path)
{
s_dropGroups.Clear();
var infos = JsonConvert.DeserializeObject<ItemDropGroupInfo[]>(ReadText(path));
ItemDropGroupJson cur = null;
for (int i = 0; i < infos.Length; i++)
{
int idx = infos[i].Index;
if (cur == null || (idx != 0 && cur.Index != idx))
{
cur = new ItemDropGroupJson(idx);
if (!s_dropGroups.ContainsKey(idx)) s_dropGroups.Add(idx, cur); else cur = s_dropGroups[idx];
}
else if (idx == 0) infos[i].Index = cur.Index;
cur.ItemDropGroupInfos.Add(infos[i]);
}
}
static void LoadDropPackages(string path)
{
s_dropPackages.Clear();
var infos = JsonConvert.DeserializeObject<ItemDropPackageInfo[]>(ReadText(path));
ItemDropPackageJson cur = null;
for (int i = 0; i < infos.Length; i++)
{
int idx = infos[i].Index;
if (cur == null || (idx != 0 && cur.Index != idx))
{
cur = new ItemDropPackageJson(idx);
if (!s_dropPackages.ContainsKey(idx)) s_dropPackages.Add(idx, cur); else cur = s_dropPackages[idx];
}
else if (idx == 0) infos[i].Index = cur.Index;
cur.ItemDropPackageInfos.Add(infos[i]);
cur.TotalRate += infos[i].Rate;
}
}
// ── 조회 ───────────────────────────────────────────────────────────
public static ItemJson FindItemJson(int index) { EnsureLoaded(); ItemJson j; return s_items.TryGetValue(index, out j) ? j : null; }
public static ItemLevelJson FindItemLevelJson(int index) { EnsureLoaded(); ItemLevelJson j; return s_levels.TryGetValue(index, out j) ? j : null; }
public static ItemOptionJson FindItemOptionJson(int groupNo) { EnsureLoaded(); ItemOptionJson j; return s_options.TryGetValue(groupNo, out j) ? j : null; }
public static ItemDropGroupJson FindDropGroup(int index) { EnsureLoaded(); ItemDropGroupJson j; return s_dropGroups.TryGetValue(index, out j) ? j : null; }
public static ItemDropPackageJson FindDropPackage(int index) { EnsureLoaded(); ItemDropPackageJson j; return s_dropPackages.TryGetValue(index, out j) ? j : null; }
/// <summary>FR `ItemComposeTable.FindItemComposeJson` — 키 = 등급×1000 + 등급 단계.</summary>
public static ItemComposeJson FindItemComposeJson(eFRItemGrade grade, int gradeLv)
{
EnsureLoaded();
ItemComposeJson j;
return s_compose.TryGetValue((int)grade * 1000 + gradeLv, out j) ? j : null;
}
/// <summary>강등용 역조회 — 결과(등급·단계)가 주어진 값인 합성 행.</summary>
public static ItemComposeJson FindComposeByResult(eFRItemGrade resultGrade, int resultGradeLv)
{
EnsureLoaded();
foreach (var kv in s_compose)
if (kv.Value.ResultGrade == resultGrade && kv.Value.ResultGradeLevel == resultGradeLv) return kv.Value;
return null;
}
/// <summary>FR `ComposePopup.FindResultItemJson` — 같은 그룹에서 결과 등급·단계의 장비.</summary>
public static ItemJson FindResultItemJson(ItemJson source, ItemComposeJson compose)
{
return FindGroupItem(source.GroupNo, compose.ResultGrade, compose.ResultGradeLevel);
}
public static ItemJson FindGroupItem(int groupNo, eFRItemGrade grade, int gradeLv)
{
EnsureLoaded();
for (int i = 0; i < s_itemList.Count; i++)
{
var j = s_itemList[i];
if (j.ItemType != eFRItemType.Equipment) continue;
if (j.GroupNo == groupNo && j.ItemGrade == grade && j.GradeLv == gradeLv) return j;
}
return null;
}
/// <summary>표의 등급 옵션 중 지금 등급까지 열린 것(FR `PlayerManager.GetTotalAbilities` 규칙 — 등급 오름차순, 초과 시 중단).</summary>
public static void CollectOpenedOptions(ItemJson item, List<ItemOptionElement> into)
{
var oj = FindItemOptionJson(item.GroupNo);
if (oj == null) return;
for (int i = 0; i < oj.ItemOptions.Count; i++)
{
var e = oj.ItemOptions[i];
if (e == null) continue;
if (e.ItemGrade > item.ItemGrade) break;
into.Add(e);
}
}
// ── 드랍 (FR `Data.GetRandomDropPackageInfoInGroup` + `ItemDropPackageTable.GetRandomDropPackageInfo`) ──
public struct Drop { public int itemId; public int count; }
public static void RollDropGroup(int groupId, List<Drop> into, System.Random rng)
{
var g = FindDropGroup(groupId);
if (g == null) return;
for (int i = 0; i < g.ItemDropGroupInfos.Count; i++)
{
var gi = g.ItemDropGroupInfos[i];
if (rng.Next(1, 10001) > gi.Rate) continue;
var pkg = FindDropPackage(gi.Package_ID);
if (pkg == null) continue;
if (gi.DropType == eFRDropType.All)
{
for (int d = 0; d < pkg.ItemDropPackageInfos.Count; d++) into.Add(ToDrop(pkg.ItemDropPackageInfos[d], rng));
}
else
{
var p = RollPackage(pkg, rng);
if (p != null) into.Add(ToDrop(p, rng));
}
}
}
public static ItemDropPackageInfo RollPackage(ItemDropPackageJson pkg, System.Random rng)
{
if (pkg == null || pkg.TotalRate <= 0) return null;
int r = rng.Next(1, pkg.TotalRate + 1);
for (int i = 0; i < pkg.ItemDropPackageInfos.Count; i++)
{
r -= pkg.ItemDropPackageInfos[i].Rate;
if (r <= 0) return pkg.ItemDropPackageInfos[i];
}
return null;
}
static Drop ToDrop(ItemDropPackageInfo p, System.Random rng)
{
int min = p.MinCount <= 0 ? 1 : p.MinCount;
int max = p.MaxCount < min ? min : p.MaxCount;
return new Drop { itemId = p.Item_ID, count = rng.Next(min, max + 1) };
}
public static string GradeName(eFRItemGrade g)
{
switch (g)
{
case eFRItemGrade.Normal: return "일반";
case eFRItemGrade.Magic: return "매직";
case eFRItemGrade.Rare: return "레어";
case eFRItemGrade.Elite: return "엘리트";
case eFRItemGrade.Epic: return "에픽";
case eFRItemGrade.Legend: return "전설";
}
return g.ToString();
}
public static string SlotName(eFREquipmentType t)
{
switch (t)
{
case eFREquipmentType.Weapon: return "무기";
case eFREquipmentType.Necklace: return "목걸이";
case eFREquipmentType.Glove: return "장갑";
case eFREquipmentType.Armor: return "갑옷";
case eFREquipmentType.Belt: return "벨트";
case eFREquipmentType.Shose: return "신발";
}
return t.ToString();
}
public static string AbilityName(eFRAbility a)
{
switch (a)
{
case eFRAbility.ItemRange: return "획득 범위 %";
case eFRAbility.Health: return "체력 %";
case eFRAbility.RecoveryHealth: return "체력 회복 %";
case eFRAbility.Speed: return "이동 속도 %";
case eFRAbility.Defence: return "피해 감소 %";
case eFRAbility.Attack: return "공격력 %";
case eFRAbility.IncreaseDrop: return "획득량 %";
case eFRAbility.Rolling: return "구르기 %";
case eFRAbility.ProjectileSpeed: return "투사체 속도 %";
case eFRAbility.SkillRange: return "스킬 범위 %";
case eFRAbility.SkillDuration: return "스킬 지속 %";
case eFRAbility.Cooltime: return "쿨타임 감소 %";
case eFRAbility.AttackDefence: return "공격·방어 %";
case eFRAbility.Passive: return "패시브 효과 %";
case eFRAbility.Critical: return "치명타 확률 %";
case eFRAbility.CriticalDamage: return "치명타 피해 %";
case eFRAbility.Revive: return "부활 횟수";
case eFRAbility.HeartRecover: return "하트 회복 %";
case eFRAbility.SpeedValue: return "이동 속도 +";
case eFRAbility.Excellent: return "액설런트 확률 %";
case eFRAbility.ExcellentDamage: return "액설런트 피해 %";
}
return a.ToString();
}
}
}

View File

@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: d747852d081d02a4b99e9b06e2f2c379

View File

@ -0,0 +1,472 @@
// ─────────────────────────────────────────────────────────────────────────────
// WLEquipUi.cs — FR 장비 시스템 더미 UI (#821 · PD 「더미 UI 라도 좋으니 실제 기능 동작」)
//
// 코드로 만드는 uGUI(프리팹 0 · 스프라이트 0 · 글자만). 자기 Canvas(Overlay · 1080×1920 기준)를 들고
// 하단 메뉴 DummyMenuButton(menuId = weapon/upgrade · WLEquipSettings.openMenuIds)에서 연다.
// 화면: 장착 슬롯 6 → 보유 목록(스크롤) → 선택 아이템 상세 + 버튼(장착/해제 · 강화 · 전부 강화 · 합성 · 전부 합성 · 강등 · 테스트 획득 · 초기화).
// 정식 UI 는 PD 기획 후 교체 — 로직(WLInventory)은 그대로 쓴다.
// ─────────────────────────────────────────────────────────────────────────────
using System.Collections.Generic;
using TMPro;
using UnityEngine;
using UnityEngine.EventSystems;
using UnityEngine.UI;
namespace WL.Equip
{
public class WLEquipUi : MonoBehaviour
{
public static WLEquipUi Instance { get; private set; }
public static bool IsOpen { get { return Instance != null && Instance.gameObject.activeSelf; } }
public static int Opens { get; private set; }
const int UILayer = 5;
WLEquipSettings _cfg;
TMP_FontAsset _font;
Canvas _canvas;
TextMeshProUGUI _title, _info, _detail, _message, _gains;
readonly Button[] _slotBtn = new Button[6];
readonly TextMeshProUGUI[] _slotLabel = new TextMeshProUGUI[6];
RectTransform _listContent;
ScrollRect _scroll;
readonly List<Button> _rows = new List<Button>();
readonly List<TextMeshProUGUI> _rowLabels = new List<TextMeshProUGUI>();
readonly List<Image> _rowBg = new List<Image>();
readonly List<int> _rowIds = new List<int>();
Button _equipBtn, _lvBtn, _lvAllBtn, _composeBtn, _composeAllBtn, _downBtn, _cheatBtn, _resetBtn;
TextMeshProUGUI _equipLabel;
int _selectedId = -1;
// ── 진입점 ───────────────────────────────────────────────────────
/// <summary>DummyMenuButton 1줄 훅 — 이 menuId 가 장비 메뉴면 열고 true.</summary>
public static bool TryOpen(string menuId)
{
var cfg = WLEquipSettings.Instance;
if (cfg == null || cfg.enabled_ == 0 || !cfg.IsOpenMenuId(menuId)) return false;
Open();
return true;
}
/// <summary>
/// 🔴 하단 「무기」 버튼(HUD_BottomMenu.prefab)에는 힘민지 원본 `NewGameUI.OnClick_Button`(원본 EquipmentUI 열기)이
/// 프리팹에 연결돼 있고 persistent 호출이 런타임 리스너보다 먼저 돈다 → 원본 메뉴가 초기화 없이 켜지면
/// `FollowTextEnd` 가 매 프레임 NRE 를 낸다(2026-10-08 실측 · 500건). 장비 화면을 열 때 그 메뉴가 켜져 있으면 끈다.
/// </summary>
public static bool CloseLegacyEquipmentUi()
{
var cfg = WLEquipSettings.Instance;
if (cfg == null || cfg.closeLegacyEquipmentUi == 0) return false;
try
{
var ng = NewGameUI.Ins;
if (ng != null && ng.m_EquipmentUI != null && ng.m_EquipmentUI.gameObject.activeSelf)
{
ng.m_EquipmentUI.gameObject.SetActive(false);
return true;
}
}
catch (System.Exception ex) { Debug.LogWarning("[WL-821] 원본 EquipmentUI 닫기 실패 — " + ex.Message); }
return false;
}
public static WLEquipUi Open()
{
var cfg = WLEquipSettings.Instance;
if (cfg == null || cfg.enabled_ == 0) return null;
CloseLegacyEquipmentUi();
if (Instance == null)
{
var go = new GameObject("WL_EquipUi", typeof(RectTransform));
go.layer = UILayer;
DontDestroyOnLoad(go);
Instance = go.AddComponent<WLEquipUi>();
Instance._cfg = cfg;
Instance.Build();
}
Instance.gameObject.SetActive(true);
Opens++;
WLInventory.EnsureLoaded();
Instance.Refresh();
return Instance;
}
public static void Close() { if (Instance != null) Instance.gameObject.SetActive(false); }
void OnEnable() { WLInventory.Changed += Refresh; }
void OnDisable() { WLInventory.Changed -= Refresh; }
void OnDestroy() { if (Instance == this) Instance = null; }
// ── 조립 ─────────────────────────────────────────────────────────
void Build()
{
_font = ResolveFont();
if (EventSystem.current == null)
{
var es = new GameObject("WL_EventSystem(Equip)", typeof(EventSystem), typeof(StandaloneInputModule));
DontDestroyOnLoad(es);
}
_canvas = gameObject.AddComponent<Canvas>();
_canvas.renderMode = RenderMode.ScreenSpaceOverlay;
_canvas.sortingOrder = _cfg.uiSortingOrder;
var scaler = gameObject.AddComponent<CanvasScaler>();
scaler.uiScaleMode = CanvasScaler.ScaleMode.ScaleWithScreenSize;
scaler.referenceResolution = new Vector2(1080f, 1920f);
scaler.screenMatchMode = CanvasScaler.ScreenMatchMode.MatchWidthOrHeight;
scaler.matchWidthOrHeight = 0.5f;
gameObject.AddComponent<GraphicRaycaster>();
var root = (RectTransform)transform;
var dim = NewImage(root, "Dim", _cfg.dimColor); Stretch(dim.rectTransform); dim.raycastTarget = true;
var panel = NewImage(root, "Panel", _cfg.panelColor);
Stretch(panel.rectTransform);
float m = _cfg.panelMarginPx;
panel.rectTransform.offsetMin = new Vector2(m, m); panel.rectTransform.offsetMax = new Vector2(-m, -m);
var vl = panel.gameObject.AddComponent<VerticalLayoutGroup>();
vl.padding = new RectOffset(16, 16, 16, 16); vl.spacing = 10f;
vl.childControlWidth = true; vl.childControlHeight = true; vl.childForceExpandWidth = true; vl.childForceExpandHeight = false;
var prt = panel.rectTransform;
// 제목 + 닫기
var titleRow = NewRow(prt, "TitleRow", 70f);
_title = NewText(titleRow, "Title", _cfg.titleFontPx, TextAlignmentOptions.MidlineLeft); Flex(_title.rectTransform, 1f);
NewButton(titleRow, "닫기", 160f, Close);
_info = NewText(prt, "Info", _cfg.smallFontPx, TextAlignmentOptions.MidlineLeft); Pref(_info.rectTransform, 44f);
// 장착 슬롯 2×3
for (int r = 0; r < 2; r++)
{
var row = NewRow(prt, "SlotRow" + r, _cfg.slotHeightPx);
for (int c = 0; c < 3; c++)
{
int slotIndex = r * 3 + c;
_slotBtn[slotIndex] = NewButton(row, "", 0f, () => OnSlotClick(slotIndex));
Flex((RectTransform)_slotBtn[slotIndex].transform, 1f);
_slotLabel[slotIndex] = _slotBtn[slotIndex].GetComponentInChildren<TextMeshProUGUI>();
_slotLabel[slotIndex].fontSize = _cfg.smallFontPx;
}
}
// 목록(스크롤)
var listGo = new GameObject("List", typeof(RectTransform), typeof(Image), typeof(ScrollRect));
listGo.layer = UILayer; listGo.transform.SetParent(prt, false);
var listImg = listGo.GetComponent<Image>(); listImg.color = _cfg.boxColor;
var le = listGo.AddComponent<LayoutElement>(); le.flexibleHeight = 1f; le.minHeight = 200f;
_scroll = listGo.GetComponent<ScrollRect>();
var viewport = new GameObject("Viewport", typeof(RectTransform), typeof(Image), typeof(RectMask2D));
viewport.layer = UILayer; viewport.transform.SetParent(listGo.transform, false);
var vrt = (RectTransform)viewport.transform; Stretch(vrt);
viewport.GetComponent<Image>().color = new Color(0f, 0f, 0f, 0.01f);
var content = new GameObject("Content", typeof(RectTransform), typeof(VerticalLayoutGroup), typeof(ContentSizeFitter));
content.layer = UILayer; content.transform.SetParent(viewport.transform, false);
_listContent = (RectTransform)content.transform;
_listContent.anchorMin = new Vector2(0f, 1f); _listContent.anchorMax = new Vector2(1f, 1f); _listContent.pivot = new Vector2(0.5f, 1f);
_listContent.offsetMin = Vector2.zero; _listContent.offsetMax = Vector2.zero;
var cvl = content.GetComponent<VerticalLayoutGroup>();
cvl.padding = new RectOffset(8, 8, 8, 8); cvl.spacing = 6f;
cvl.childControlWidth = true; cvl.childControlHeight = true; cvl.childForceExpandWidth = true; cvl.childForceExpandHeight = false;
content.GetComponent<ContentSizeFitter>().verticalFit = ContentSizeFitter.FitMode.PreferredSize;
_scroll.content = _listContent; _scroll.viewport = vrt; _scroll.horizontal = false; _scroll.vertical = true;
_scroll.movementType = ScrollRect.MovementType.Clamped; _scroll.scrollSensitivity = 40f;
// 상세 + 버튼
var detailBox = NewImage(prt, "Detail", _cfg.boxColor); Pref(detailBox.rectTransform, _cfg.detailHeightPx);
var dvl = detailBox.gameObject.AddComponent<VerticalLayoutGroup>();
dvl.padding = new RectOffset(12, 12, 10, 10); dvl.spacing = 8f;
dvl.childControlWidth = true; dvl.childControlHeight = true; dvl.childForceExpandWidth = true; dvl.childForceExpandHeight = false;
_detail = NewText(detailBox.rectTransform, "Text", _cfg.smallFontPx, TextAlignmentOptions.TopLeft); Flex(_detail.rectTransform, 1f);
var b1 = NewRow(detailBox.rectTransform, "Buttons1", _cfg.buttonHeightPx);
_equipBtn = NewButton(b1, "장착", 0f, OnEquipToggle); Flex((RectTransform)_equipBtn.transform, 1f);
_equipLabel = _equipBtn.GetComponentInChildren<TextMeshProUGUI>();
_lvBtn = NewButton(b1, "강화 +1", 0f, () => Act(() => WLInventory.ItemLevelUp(_selectedId, false))); Flex((RectTransform)_lvBtn.transform, 1f);
_lvAllBtn = NewButton(b1, "전부 강화", 0f, () => Act(() => WLInventory.ItemLevelUp(_selectedId, true))); Flex((RectTransform)_lvAllBtn.transform, 1f);
_composeBtn = NewButton(b1, "합성", 0f, () => Act(() => WLInventory.ComposeOne(_selectedId))); Flex((RectTransform)_composeBtn.transform, 1f);
var b2 = NewRow(detailBox.rectTransform, "Buttons2", _cfg.buttonHeightPx);
_composeAllBtn = NewButton(b2, "전부 합성", 0f, () => Act(() => WLInventory.ComposeAll() > 0)); Flex((RectTransform)_composeAllBtn.transform, 1f);
_downBtn = NewButton(b2, "강등", 0f, () => Act(() => WLInventory.Downgrade(_selectedId))); Flex((RectTransform)_downBtn.transform, 1f);
_cheatBtn = NewButton(b2, "테스트 획득", 0f, OnCheat); Flex((RectTransform)_cheatBtn.transform, 1f);
_cheatBtn.gameObject.SetActive(_cfg.cheatGrantEnabled != 0);
_resetBtn = NewButton(b2, "초기화", 0f, () => { WLInventory.ResetAll(); _selectedId = -1; Refresh(); }); Flex((RectTransform)_resetBtn.transform, 1f);
_message = NewText(prt, "Message", _cfg.smallFontPx, TextAlignmentOptions.MidlineLeft); Pref(_message.rectTransform, 40f);
_gains = NewText(prt, "Gains", _cfg.smallFontPx, TextAlignmentOptions.MidlineLeft); Pref(_gains.rectTransform, 40f);
_gains.color = new Color(0.8f, 0.9f, 0.6f, 1f);
}
TMP_FontAsset ResolveFont() { return ResolveKoreanFont(_cfg); }
/// <summary>한글 글리프('장'·'비')를 가진 TMP 폰트를 고른다 — 아무 라벨의 폰트를 빌리면 FI 데모 폰트(한글 없음)가 걸려 □ 로 나온다(2026-10-08 캡처 실측).</summary>
public static TMP_FontAsset ResolveKoreanFont(WLEquipSettings cfg)
{
TMP_FontAsset f = null;
if (cfg != null && !string.IsNullOrEmpty(cfg.fontPath)) f = WL.Island.WLIslandAssets.Font(cfg.fontPath);
if (f != null && HasKorean(f)) return f;
var labels = FindObjectsByType<TextMeshProUGUI>(FindObjectsInactive.Include, FindObjectsSortMode.None);
TMP_FontAsset fallback = null;
for (int i = 0; i < labels.Length; i++)
{
var lf = labels[i] != null ? labels[i].font : null;
if (lf == null) continue;
if (fallback == null) fallback = lf;
if (HasKorean(lf)) return lf;
}
return f != null ? f : fallback;
}
static bool HasKorean(TMP_FontAsset f)
{
try { return f.HasCharacter('장', true, true) && f.HasCharacter('비', true, true); }
catch { return false; }
}
// ── 갱신 ─────────────────────────────────────────────────────────
public void Refresh()
{
if (!gameObject.activeSelf) return;
if (!WLInventory.EnsureLoaded()) { _title.text = "장비 — 표 로드 실패"; _detail.text = WLEquipTables.LastLog; return; }
_title.text = "장비 (FR 방식 · 더미 UI) 보유 " + WLInventory.ItemCount;
var sb = new System.Text.StringBuilder();
sb.Append("코인 ").Append(WLInventory.GetGold()).Append(" 강화석:");
if (_cfg.clearStoneItemIds != null)
for (int i = 0; i < _cfg.clearStoneItemIds.Length; i++)
{
var sj = WLEquipTables.FindItemJson(_cfg.clearStoneItemIds[i]);
sb.Append(' ').Append(sj != null ? sj.Item_name.Replace(" 강화석", "") : _cfg.clearStoneItemIds[i].ToString()).Append(' ').Append(WLInventory.GetItemCountByIndex(_cfg.clearStoneItemIds[i]));
}
_info.text = sb.ToString();
for (int i = 0; i < 6; i++)
{
var slot = (eFREquipmentType)(i + 1);
var it = WLInventory.EquippedInSlot(slot);
_slotLabel[i].text = WLEquipTables.SlotName(slot) + "\n" + (it != null
? it.itemJson.Item_name + " [" + WLEquipTables.GradeName(it.itemJson.ItemGrade) + (it.itemJson.GradeLv > 0 ? "+" + it.itemJson.GradeLv : "") + "] Lv" + it.rawItem.level
: "(비어 있음)");
SetButtonColor(_slotBtn[i], it != null && it.rawItem._id == _selectedId ? _cfg.selectedRowColor : (it != null ? _cfg.equippedRowColor : _cfg.buttonDisabledColor));
}
var items = WLInventory.SortedItems();
int n = Mathf.Min(items.Count, _cfg.maxListRows);
while (_rows.Count < n) AddRow();
for (int i = 0; i < _rows.Count; i++)
{
bool on = i < n;
_rows[i].gameObject.SetActive(on);
if (!on) continue;
var it = items[i];
_rowIds[i] = it.rawItem._id;
_rowLabels[i].text = WLInventory.RowText(it);
_rowBg[i].color = it.rawItem._id == _selectedId ? _cfg.selectedRowColor : (WLInventory.IsEquipped(it.rawItem._id) ? _cfg.equippedRowColor : _cfg.rowColor);
}
if (_selectedId >= 0 && WLInventory.FindItem(_selectedId) == null) _selectedId = -1;
var sel = WLInventory.FindItem(_selectedId);
_detail.text = sel != null ? WLInventory.Describe(sel) : "목록에서 아이템을 고르세요.\n던전 클리어 때 FR 드랍표(그룹 " + _cfg.clearDropGroupId + ")로 장비 1개 + 강화석이 들어옵니다.";
bool isEq = sel != null && sel.itemJson.IsEquipment;
bool equipped = sel != null && WLInventory.IsEquipped(sel.rawItem._id);
_equipLabel.text = equipped ? "해제" : "장착";
_equipBtn.interactable = isEq;
_lvBtn.interactable = isEq && WLInventory.NextLevelCost(sel) != null;
_lvAllBtn.interactable = _lvBtn.interactable;
_composeBtn.interactable = isEq && WLInventory.PlanCompose(sel) != null;
_downBtn.interactable = isEq && WLEquipTables.FindComposeByResult(sel.itemJson.ItemGrade, sel.itemJson.GradeLv) != null;
SetButtonColor(_equipBtn, _equipBtn.interactable ? _cfg.buttonColor : _cfg.buttonDisabledColor);
SetButtonColor(_lvBtn, _lvBtn.interactable ? _cfg.buttonColor : _cfg.buttonDisabledColor);
SetButtonColor(_lvAllBtn, _lvAllBtn.interactable ? _cfg.buttonColor : _cfg.buttonDisabledColor);
SetButtonColor(_composeBtn, _composeBtn.interactable ? _cfg.buttonColor : _cfg.buttonDisabledColor);
SetButtonColor(_downBtn, _downBtn.interactable ? _cfg.buttonColor : _cfg.buttonDisabledColor);
_message.text = WLInventory.LastMessage;
_gains.text = WLInventory.RecentGains.Count > 0 ? "최근 획득: " + string.Join(" · ", WLInventory.RecentGains.GetRange(0, Mathf.Min(4, WLInventory.RecentGains.Count))) : "";
}
void AddRow()
{
int i = _rows.Count;
var b = NewButton(_listContent, "", _cfg.rowHeightPx, () => OnRowClick(i));
var lbl = b.GetComponentInChildren<TextMeshProUGUI>();
lbl.alignment = TextAlignmentOptions.MidlineLeft; lbl.fontSize = _cfg.bodyFontPx;
lbl.rectTransform.offsetMin = new Vector2(14f, 0f);
_rows.Add(b); _rowLabels.Add(lbl); _rowBg.Add(b.GetComponent<Image>()); _rowIds.Add(-1);
_rowBg[i].color = _cfg.rowColor;
}
// ── 조작 ─────────────────────────────────────────────────────────
void OnRowClick(int i) { if (i < _rowIds.Count) { _selectedId = _rowIds[i]; Refresh(); } }
void OnSlotClick(int slotIndex)
{
var it = WLInventory.EquippedInSlot((eFREquipmentType)(slotIndex + 1));
if (it != null) { _selectedId = it.rawItem._id; Refresh(); }
}
void OnEquipToggle()
{
Act(() => WLInventory.IsEquipped(_selectedId) ? WLInventory.Unequip(_selectedId) : WLInventory.Equip(_selectedId));
}
void OnCheat()
{
WLEquipDrops.Grant(_cfg.cheatGrantGroupId, _cfg.cheatGrantRolls, _cfg.clearStoneItemIds, _cfg.clearStoneCountEach, 0);
Refresh();
}
void Act(System.Func<bool> f)
{
try { f(); } catch (System.Exception ex) { Debug.LogError("[WL-821] UI 동작 예외 — " + ex); }
Refresh();
}
// ── uGUI 헬퍼 ───────────────────────────────────────────────────
static void Stretch(RectTransform rt)
{
rt.anchorMin = Vector2.zero; rt.anchorMax = Vector2.one; rt.offsetMin = Vector2.zero; rt.offsetMax = Vector2.zero; rt.pivot = new Vector2(0.5f, 0.5f);
}
static void Pref(RectTransform rt, float h) { var le = rt.gameObject.GetComponent<LayoutElement>() ?? rt.gameObject.AddComponent<LayoutElement>(); le.preferredHeight = h; le.minHeight = h; }
static void Flex(RectTransform rt, float w) { var le = rt.gameObject.GetComponent<LayoutElement>() ?? rt.gameObject.AddComponent<LayoutElement>(); le.flexibleWidth = w; le.flexibleHeight = w; }
Image NewImage(RectTransform parent, string name, Color c)
{
var go = new GameObject(name, typeof(RectTransform), typeof(Image));
go.layer = UILayer; go.transform.SetParent(parent, false);
var img = go.GetComponent<Image>(); img.color = c; img.raycastTarget = false;
return img;
}
RectTransform NewRow(RectTransform parent, string name, float h)
{
var go = new GameObject(name, typeof(RectTransform), typeof(HorizontalLayoutGroup));
go.layer = UILayer; go.transform.SetParent(parent, false);
var hl = go.GetComponent<HorizontalLayoutGroup>();
hl.spacing = 8f; hl.childControlWidth = true; hl.childControlHeight = true; hl.childForceExpandWidth = false; hl.childForceExpandHeight = true;
var rt = (RectTransform)go.transform; Pref(rt, h);
return rt;
}
TextMeshProUGUI NewText(RectTransform parent, string name, float size, TextAlignmentOptions align)
{
var go = new GameObject(name, typeof(RectTransform), typeof(TextMeshProUGUI));
go.layer = UILayer; go.transform.SetParent(parent, false);
var t = go.GetComponent<TextMeshProUGUI>();
if (_font != null) t.font = _font;
t.fontSize = size; t.alignment = align; t.raycastTarget = false; t.color = Color.white;
t.overflowMode = TextOverflowModes.Truncate;
return t;
}
Button NewButton(RectTransform parent, string label, float width, UnityEngine.Events.UnityAction onClick)
{
var go = new GameObject("btn_" + label, typeof(RectTransform), typeof(Image), typeof(Button));
go.layer = UILayer; go.transform.SetParent(parent, false);
var img = go.GetComponent<Image>(); img.color = _cfg.buttonColor; img.raycastTarget = true;
var b = go.GetComponent<Button>(); b.targetGraphic = img; b.onClick.AddListener(onClick);
var le = go.AddComponent<LayoutElement>();
if (width > 0f) { le.preferredWidth = width; le.minWidth = width; }
le.preferredHeight = _cfg.buttonHeightPx;
var t = NewText((RectTransform)go.transform, "Label", _cfg.bodyFontPx, TextAlignmentOptions.Center);
t.text = label; Stretch(t.rectTransform);
return b;
}
static void SetButtonColor(Button b, Color c) { var img = b.GetComponent<Image>(); if (img != null) img.color = c; }
// ── 런처 ─────────────────────────────────────────────────────────
[RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.AfterSceneLoad)]
static void Boot()
{
var cfg = WLEquipSettings.Instance;
if (cfg == null || cfg.enabled_ == 0 || cfg.launcherEnabled == 0) return;
WLEquipLauncher.Ensure(cfg);
}
}
/// <summary>
/// 인게임(섬·던전)에서 항상 보이는 「장비」 버튼 — 하단 5메뉴는 섬에서 비활성이라(실측 2026-10-08) 별도 진입점이 필요하다.
/// 자기 Canvas(Overlay) 1장 · 버튼 1개 · 0.5 s 마다 보일 조건(활성 씬 = launcherSceneName · 서버 데이터 있음 · 장비 화면 닫힘)을 다시 본다.
/// </summary>
public class WLEquipLauncher : MonoBehaviour
{
public static WLEquipLauncher Instance { get; private set; }
public static int Clicks { get; private set; }
public bool Visible { get { return _btn != null && _btn.gameObject.activeSelf; } }
WLEquipSettings _cfg;
Button _btn;
float _timer;
public static WLEquipLauncher Ensure(WLEquipSettings cfg)
{
if (Instance != null) return Instance;
var go = new GameObject("WL_EquipLauncher", typeof(RectTransform));
go.layer = 5;
DontDestroyOnLoad(go);
Instance = go.AddComponent<WLEquipLauncher>();
Instance._cfg = cfg;
Instance.Build();
return Instance;
}
void Build()
{
var canvas = gameObject.AddComponent<Canvas>();
canvas.renderMode = RenderMode.ScreenSpaceOverlay;
canvas.sortingOrder = _cfg.uiSortingOrder - 1;
var scaler = gameObject.AddComponent<CanvasScaler>();
scaler.uiScaleMode = CanvasScaler.ScaleMode.ScaleWithScreenSize;
scaler.referenceResolution = new Vector2(1080f, 1920f);
scaler.screenMatchMode = CanvasScaler.ScreenMatchMode.MatchWidthOrHeight;
scaler.matchWidthOrHeight = 0.5f;
gameObject.AddComponent<GraphicRaycaster>();
var bgo = new GameObject("btn_equip", typeof(RectTransform), typeof(Image), typeof(Button));
bgo.layer = 5; bgo.transform.SetParent(transform, false);
var rt = (RectTransform)bgo.transform;
rt.anchorMin = rt.anchorMax = new Vector2(1f, 0f); rt.pivot = new Vector2(1f, 0f);
rt.sizeDelta = _cfg.launcherSizePx;
rt.anchoredPosition = new Vector2(-_cfg.launcherOffsetPx.x, _cfg.launcherOffsetPx.y);
var img = bgo.GetComponent<Image>(); img.color = _cfg.buttonColor;
_btn = bgo.GetComponent<Button>(); _btn.targetGraphic = img;
_btn.onClick.AddListener(() => { Clicks++; WLEquipUi.Open(); });
var tgo = new GameObject("Label", typeof(RectTransform), typeof(TextMeshProUGUI));
tgo.layer = 5; tgo.transform.SetParent(bgo.transform, false);
var t = tgo.GetComponent<TextMeshProUGUI>();
var trt = t.rectTransform; trt.anchorMin = Vector2.zero; trt.anchorMax = Vector2.one; trt.offsetMin = trt.offsetMax = Vector2.zero;
t.text = _cfg.launcherLabel; t.fontSize = _cfg.bodyFontPx; t.alignment = TextAlignmentOptions.Center; t.raycastTarget = false;
_label = t;
_btn.gameObject.SetActive(false);
}
void Update()
{
_timer += Time.unscaledDeltaTime;
if (_timer < 0.5f) return;
_timer = 0f;
WLEquipStats.EnforceBaseAttackTick(); // PD 「기본 공격력 5」 — Reset 이 돌 때마다 원본 공식으로 돌아오므로 0.5 s 마다 다시 맞춘다
bool show = false;
try
{
show = _cfg != null && _cfg.enabled_ != 0 && _cfg.launcherEnabled != 0 && !WLEquipUi.IsOpen &&
UnityEngine.SceneManagement.SceneManager.GetActiveScene().name == _cfg.launcherSceneName &&
ServerInfo.Ins != null && ServerInfo.Ins.m_ServerData != null;
}
catch { show = false; }
if (_btn != null && _btn.gameObject.activeSelf != show)
{
// 폰트는 처음 보일 때(인게임) 고른다 — 타이틀 시점에 고르면 인게임 한글 폰트(Font SDF)가 아직 없다(2026-10-08 실측 「장□」).
if (show && !_fontResolved)
{
_fontResolved = true;
var kf = WLEquipUi.ResolveKoreanFont(_cfg);
if (kf != null && _label != null) _label.font = kf;
}
_btn.gameObject.SetActive(show);
}
}
TextMeshProUGUI _label;
bool _fontResolved;
public string FontName { get { return _label != null && _label.font != null ? _label.font.name : "null"; } }
}
}

View File

@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 636dacd2657838b4198e66cf0d03382d

View File

@ -0,0 +1,593 @@
// ─────────────────────────────────────────────────────────────────────────────
// WLInventory.cs — FR 기존 장비 시스템 로직 이식 (#821)
//
// 원본 = FR `NetPlayer.cs`(AddItem · EquipItem · ItemLevelUp · ComposeItem · DowngradeItem) +
// `ComposePopup.cs`(IsMaterialItem · FindComposeItemInfo · FindResultItemJson) + `UIUtil`(강등 반환).
// FR 은 서버/오프라인 4단 패턴이지만 WL 은 서버 없이 **로컬 세이브**(PD 결정 보류 → 가정 · persistentDataPath/WL_Equip.dat)로 돈다.
// 골드 = 섬 코인(IslandManager.Coin · 던전 보상이 들어오는 재화) · 섬이 없으면 세이브 안 예비 골드.
//
// 규칙(PD 확인 2026-10-08): 합성 = 본 아이템 1 + 재료 RequiredItemCount(2) = 3개 소모 · 본 아이템이 상위 등급으로 바뀐다.
// ─────────────────────────────────────────────────────────────────────────────
using System.Collections.Generic;
using System.IO;
using Newtonsoft.Json;
using UnityEngine;
namespace WL.Equip
{
public static class WLInventory
{
public static WLEquipSaveData Data { get; private set; }
public static bool Loaded { get; private set; }
public static string LastMessage { get; private set; } = "";
/// <summary>최근 획득 로그(UI 표시용 · 최신이 앞).</summary>
public static readonly List<string> RecentGains = new List<string>();
public static event System.Action Changed;
static readonly Dictionary<int, Item> s_items = new Dictionary<int, Item>();
static bool s_resetDoneThisRun;
// ── 로드 · 세이브 ──────────────────────────────────────────────────
static string SavePath()
{
var cfg = WLEquipSettings.Instance;
string name = cfg != null && !string.IsNullOrEmpty(cfg.saveName) ? cfg.saveName : "WL_Equip";
return Application.persistentDataPath + "/" + name + ".dat";
}
public static bool EnsureLoaded()
{
if (Loaded) return true;
if (!WLEquipTables.EnsureLoaded()) return false;
var cfg = WLEquipSettings.Instance;
string path = SavePath();
try
{
if (cfg != null && cfg.resetSaveEveryStart != 0 && !s_resetDoneThisRun)
{
s_resetDoneThisRun = true;
if (File.Exists(path)) { File.Delete(path); Log("세이브 초기화(실행 1회당 1번) — " + path); }
}
Data = File.Exists(path) ? JsonConvert.DeserializeObject<WLEquipSaveData>(File.ReadAllText(path)) : null;
}
catch (System.Exception ex) { Debug.LogWarning("[WL-821] 세이브 읽기 실패 — " + ex.Message); Data = null; }
if (Data == null) Data = new WLEquipSaveData();
if (Data.items == null) Data.items = new List<RawItem>();
if (Data.equipItems == null) Data.equipItems = new List<int>();
if (Data.nextId <= 0) Data.nextId = 1;
s_items.Clear();
for (int i = Data.items.Count - 1; i >= 0; i--)
{
var raw = Data.items[i];
var json = WLEquipTables.FindItemJson(raw.index);
if (json == null || raw.count <= 0 || s_items.ContainsKey(raw._id)) { Data.items.RemoveAt(i); continue; }
s_items[raw._id] = new Item(raw, json);
}
for (int i = Data.equipItems.Count - 1; i >= 0; i--)
if (!s_items.ContainsKey(Data.equipItems[i])) Data.equipItems.RemoveAt(i);
Loaded = true;
Log("로드 — 아이템 " + s_items.Count + " · 장착 " + Data.equipItems.Count + " · " + path);
return true;
}
public static void Save()
{
if (!Loaded) return;
try { File.WriteAllText(SavePath(), JsonConvert.SerializeObject(Data)); }
catch (System.Exception ex) { Debug.LogWarning("[WL-821] 세이브 쓰기 실패 — " + ex.Message); }
}
/// <summary>세이브를 지우고 빈 상태로(UI 「세이브 초기화」).</summary>
public static void ResetAll()
{
try { var p = SavePath(); if (File.Exists(p)) File.Delete(p); } catch { }
Data = new WLEquipSaveData();
s_items.Clear();
RecentGains.Clear();
Loaded = true;
Save();
LastMessage = "세이브 초기화";
Notify();
}
static void Notify()
{
Save();
try { if (Changed != null) Changed(); } catch (System.Exception ex) { Debug.LogWarning("[WL-821] Changed 콜백 예외 — " + ex.Message); }
WLEquipStats.RefreshPc();
}
static void Log(string s)
{
var cfg = WLEquipSettings.Instance;
if (cfg != null && cfg.verboseLog != 0) Debug.Log("[WL-821] " + s);
}
// ── 재화 ──────────────────────────────────────────────────────────
public static long GetGold()
{
int c;
if (WL.Island.WLIslandCoin.TryGet(out c)) return c;
return Data != null ? Data.gold : 0;
}
static bool SpendGold(long amount)
{
if (amount <= 0) return true;
int c;
if (WL.Island.WLIslandCoin.TryGet(out c))
{
if (c < amount) return false;
return WL.Island.WLIslandCoin.Add(-(int)amount);
}
if (Data.gold < amount) return false;
Data.gold -= amount;
return true;
}
public static void AddGold(long amount)
{
if (amount <= 0) return;
if (!WL.Island.WLIslandCoin.Add((int)amount)) Data.gold += amount;
}
// ── 조회 ──────────────────────────────────────────────────────────
public static Item FindItem(int id) { Item it; return s_items.TryGetValue(id, out it) ? it : null; }
public static Item FindItemAnyByIndex(int index)
{
foreach (var it in s_items.Values) if (it.itemJson.Index == index) return it;
return null;
}
public static long GetItemCountByIndex(int index)
{
var cfg = WLEquipSettings.Instance;
if (cfg != null && index == cfg.goldItemIndex) return GetGold();
long n = 0;
foreach (var it in s_items.Values) if (it.itemJson.Index == index) n += it.rawItem.count;
return n;
}
public static bool IsEquipped(int id) { return Data != null && Data.equipItems.Contains(id); }
public static Item EquippedInSlot(eFREquipmentType slot)
{
if (Data == null) return null;
for (int i = 0; i < Data.equipItems.Count; i++)
{
var it = FindItem(Data.equipItems[i]);
if (it != null && it.itemJson.EquipmentType == slot) return it;
}
return null;
}
/// <summary>목록(장착 → 장비(등급 높은 순) → 소모품).</summary>
public static List<Item> SortedItems()
{
var list = new List<Item>(s_items.Values);
list.Sort((a, b) =>
{
bool ea = IsEquipped(a.rawItem._id), eb = IsEquipped(b.rawItem._id);
if (ea != eb) return ea ? -1 : 1;
bool qa = a.itemJson.IsEquipment, qb = b.itemJson.IsEquipment;
if (qa != qb) return qa ? -1 : 1;
if (qa)
{
int t = a.itemJson.EquipmentType.CompareTo(b.itemJson.EquipmentType); if (t != 0) return t;
int g = b.itemJson.ItemGrade.CompareTo(a.itemJson.ItemGrade); if (g != 0) return g;
int gl = b.itemJson.GradeLv.CompareTo(a.itemJson.GradeLv); if (gl != 0) return gl;
int lv = b.rawItem.level.CompareTo(a.rawItem.level); if (lv != 0) return lv;
}
int ix = a.itemJson.Index.CompareTo(b.itemJson.Index); if (ix != 0) return ix;
return a.rawItem._id.CompareTo(b.rawItem._id);
});
return list;
}
public static int ItemCount { get { return s_items.Count; } }
// ── 획득 (FR AddItem / AddItemInternal) ──────────────────────────
/// <summary>아이템 지급. 골드 인덱스는 코인으로. 비스택 장비는 count 개를 각각 1개짜리로 만든다.</summary>
public static void AddItem(int itemIndex, int level, int count, List<Item> gained = null)
{
if (count <= 0) return;
if (!EnsureLoaded()) return;
var cfg = WLEquipSettings.Instance;
if (cfg != null && itemIndex == cfg.goldItemIndex) { AddGold(count); PushGain("골드 +" + count); return; }
var json = WLEquipTables.FindItemJson(itemIndex);
if (json == null) { Log("AddItem — 표에 없는 Index " + itemIndex); return; }
if (level <= 0) level = 1;
if (json.IsStack == 1)
{
var exist = FindItemAnyByIndex(itemIndex);
if (exist != null) { exist.rawItem.count += count; if (gained != null) gained.Add(exist); }
else { var it = NewItem(json, level, count); if (gained != null) gained.Add(it); }
PushGain(json.Item_name + " ×" + count);
}
else
{
for (int i = 0; i < count; i++)
{
var it = NewItem(json, level, 1);
if (gained != null) gained.Add(it);
PushGain(json.Item_name + " [" + WLEquipTables.GradeName(json.ItemGrade) + (json.GradeLv > 0 ? "+" + json.GradeLv : "") + "]");
}
}
}
static Item NewItem(ItemJson json, int level, int count)
{
var raw = new RawItem(json.Index, level, count, Data.nextId++);
Data.items.Add(raw);
var it = new Item(raw, json);
s_items[raw._id] = it;
return it;
}
static void PushGain(string s)
{
RecentGains.Insert(0, s);
if (RecentGains.Count > 30) RecentGains.RemoveAt(RecentGains.Count - 1);
}
/// <summary>여러 번 AddItem 한 뒤 한 번만 저장·통지.</summary>
public static void Commit(string message)
{
LastMessage = message;
Notify();
}
static void UpdateItemCount(RawItem raw)
{
if (raw.count <= 0) RemoveItem(raw._id);
}
public static bool RemoveItem(int id)
{
Item it;
if (!s_items.TryGetValue(id, out it)) return false;
Data.equipItems.Remove(id);
Data.items.Remove(it.rawItem);
s_items.Remove(id);
return true;
}
// ── 장착 (FR EquipItem / UnequipItem) ────────────────────────────
public static bool Equip(int id)
{
var item = FindItem(id);
if (item == null) { LastMessage = "아이템 없음"; return false; }
if (!item.itemJson.IsEquipment) { LastMessage = "장비가 아닙니다"; return false; }
for (int i = 0; i < Data.equipItems.Count; i++)
{
var e = FindItem(Data.equipItems[i]);
if (e != null && e.itemJson.EquipmentType == item.itemJson.EquipmentType)
{
if (e == item) { LastMessage = "이미 장착 중"; return false; }
Data.equipItems.RemoveAt(i);
break;
}
}
Data.equipItems.Add(id);
LastMessage = item.itemJson.Item_name + " 장착";
Notify();
return true;
}
public static bool Unequip(int id)
{
if (!Data.equipItems.Remove(id)) { LastMessage = "장착 중이 아닙니다"; return false; }
var item = FindItem(id);
LastMessage = (item != null ? item.itemJson.Item_name : id.ToString()) + " 해제";
Notify();
return true;
}
// ── 강화(레벨업 · FR ItemLevelUp) ─────────────────────────────────
/// <summary>다음 레벨 비용(골드 · 강화석) — 없으면 null.</summary>
public static ItemLevelInfo NextLevelCost(Item item)
{
if (item == null || !item.itemJson.IsEquipment) return null;
if (item.rawItem.level >= item.itemJson.ItemMaxLv) return null;
var lt = WLEquipTables.FindItemLevelJson(item.itemJson.ItemLvAbilityID);
if (lt == null) return null;
return lt.FindLevelInfo(item.rawItem.level);
}
public static bool ItemLevelUp(int id, bool all)
{
var item = FindItem(id);
if (item == null) { LastMessage = "아이템 없음"; return false; }
if (!item.itemJson.IsEquipment) { LastMessage = "장비가 아닙니다"; return false; }
if (item.rawItem.level >= item.itemJson.ItemMaxLv) { LastMessage = "최대 레벨"; return false; }
var lt = WLEquipTables.FindItemLevelJson(item.itemJson.ItemLvAbilityID);
if (lt == null) { LastMessage = "레벨표 없음(" + item.itemJson.ItemLvAbilityID + ")"; return false; }
int start = item.rawItem.level;
string stop = "";
for (int lv = start; lv < item.itemJson.ItemMaxLv; lv++)
{
var info = lt.FindLevelInfo(lv);
var next = lt.FindLevelInfo(lv + 1);
if (info == null || next == null) { stop = "표 끝"; break; }
if (GetGold() < info.MoneyCountRequired) { stop = "골드 부족(" + info.MoneyCountRequired + ")"; break; }
Item stone = null;
if (info.ItemKeyRequired > 0 && info.ItemCountRequired > 0)
{
stone = FindItemAnyByIndex(info.ItemKeyRequired);
if (stone == null || stone.rawItem.count < info.ItemCountRequired)
{
var sj = WLEquipTables.FindItemJson(info.ItemKeyRequired);
stop = (sj != null ? sj.Item_name : "강화석") + " 부족(" + info.ItemCountRequired + ")";
break;
}
}
if (!SpendGold(info.MoneyCountRequired)) { stop = "골드 차감 실패"; break; }
if (stone != null) { stone.rawItem.count -= info.ItemCountRequired; UpdateItemCount(stone.rawItem); }
item.rawItem.level = next.Level;
if (!all) break;
}
if (item.rawItem.level == start) { LastMessage = "강화 불가 — " + stop; return false; }
LastMessage = item.itemJson.Item_name + " Lv " + start + " → " + item.rawItem.level + (stop != "" ? " (" + stop + ")" : "");
Notify();
return true;
}
/// <summary>FR ReturnItemLevel — 레벨 1 초과분에 쓴 강화석을 돌려준다(골드는 돌려주지 않는다 · FR 동일 「추정」).</summary>
static void RefundLevelMaterials(Item item)
{
if (item.rawItem.level <= 1) return;
var lt = WLEquipTables.FindItemLevelJson(item.itemJson.ItemLvAbilityID);
if (lt == null) return;
for (int lv = 1; lv < item.rawItem.level; lv++)
{
var info = lt.FindLevelInfo(lv);
if (info == null) continue;
if (info.ItemKeyRequired > 0 && info.ItemCountRequired > 0) AddItem(info.ItemKeyRequired, 1, info.ItemCountRequired);
}
}
// ── 합성(등급 업 · FR ComposePopup + NetPlayer.ComposeItem) ─────────
public class ComposePlan
{
public Item source;
public ItemComposeJson compose;
public ItemJson result;
public readonly List<Item> materials = new List<Item>();
}
/// <summary>FR `IsMaterialItem`(장비 분기 그대로).</summary>
public static bool IsMaterialItem(Item check, Item source, ItemComposeJson c)
{
if (check.itemJson.EquipmentType != source.itemJson.EquipmentType) return false;
if (c.RequiredGradeLevel != check.itemJson.GradeLv) return false;
if (c.SameGroupNo == 0)
{
return check.itemJson.ItemGrade == c.ItemGrade;
}
if (check.itemJson.ItemGrade != c.ItemGrade) return false;
if (check.itemJson.ItemType == eFRItemType.EnchantScroll) return false;
return check.itemJson.GroupNo == source.itemJson.GroupNo;
}
/// <summary>본 아이템 하나에 대한 합성 계획(재료는 장착 중이 아닌 것만 · FR 과 같이 재료끼리는 같은 Index).</summary>
public static ComposePlan PlanCompose(Item source, HashSet<int> excludeIds = null)
{
if (source == null || !source.itemJson.IsEquipment) return null;
var c = WLEquipTables.FindItemComposeJson(source.itemJson.ItemGrade, source.itemJson.GradeLv);
if (c == null) return null;
var result = WLEquipTables.FindResultItemJson(source.itemJson, c);
if (result == null) return null;
// 재료 후보를 Index 별로 묶는다(FR: 비스택 재료는 첫 재료와 같은 Index 로 RequiredItemCount 개)
var byIndex = new Dictionary<int, List<Item>>();
var order = new List<int>();
foreach (var it in s_items.Values)
{
if (it == source) continue;
if (IsEquipped(it.rawItem._id)) continue;
if (excludeIds != null && excludeIds.Contains(it.rawItem._id)) continue;
if (!IsMaterialItem(it, source, c)) continue;
List<Item> l;
if (!byIndex.TryGetValue(it.itemJson.Index, out l)) { l = new List<Item>(); byIndex[it.itemJson.Index] = l; order.Add(it.itemJson.Index); }
l.Add(it);
}
// 같은 그룹(같은 이름) 재료를 우선, 그다음 레벨 낮은 것부터
order.Sort((a, b) =>
{
bool ga = a == source.itemJson.Index, gb = b == source.itemJson.Index;
if (ga != gb) return ga ? -1 : 1;
return a.CompareTo(b);
});
for (int o = 0; o < order.Count; o++)
{
var l = byIndex[order[o]];
l.Sort((x, y) => x.rawItem.level.CompareTo(y.rawItem.level));
int have = 0;
var plan = new ComposePlan { source = source, compose = c, result = result };
for (int i = 0; i < l.Count && have < c.RequiredItemCount; i++)
{
int n = l[i].itemJson.IsStack == 1 ? l[i].rawItem.count : 1;
plan.materials.Add(l[i]);
have += n;
}
if (have >= c.RequiredItemCount) return plan;
}
return null;
}
public static bool Compose(ComposePlan p)
{
if (p == null || p.source == null) { LastMessage = "합성 재료 부족"; return false; }
int need = p.compose.RequiredItemCount;
string names = "";
for (int i = 0; i < p.materials.Count && need > 0; i++)
{
var m = p.materials[i];
if (m.itemJson.IsStack == 1)
{
int take = Mathf.Min(need, m.rawItem.count);
m.rawItem.count -= take; need -= take;
UpdateItemCount(m.rawItem);
}
else
{
RefundLevelMaterials(m);
m.rawItem.count = 0; need -= 1;
UpdateItemCount(m.rawItem);
}
names += (names == "" ? "" : ", ") + m.itemJson.Item_name;
}
string before = WLEquipTables.GradeName(p.source.itemJson.ItemGrade) + (p.source.itemJson.GradeLv > 0 ? "+" + p.source.itemJson.GradeLv : "");
p.source.rawItem.index = p.result.Index;
p.source.itemJson = p.result;
if (p.source.rawItem.level > p.result.ItemMaxLv) p.source.rawItem.level = p.result.ItemMaxLv;
string after = WLEquipTables.GradeName(p.result.ItemGrade) + (p.result.GradeLv > 0 ? "+" + p.result.GradeLv : "");
LastMessage = p.result.Item_name + " " + before + " → " + after + " (재료 " + names + ")";
return true;
}
public static bool ComposeOne(int sourceId)
{
var src = FindItem(sourceId);
var plan = PlanCompose(src);
if (plan == null) { LastMessage = "합성 불가 — 같은 등급·부위 재료 " + (src != null ? ReqCountText(src) : "?") + "개 필요"; return false; }
bool ok = Compose(plan);
if (ok) Notify();
return ok;
}
static string ReqCountText(Item src)
{
var c = WLEquipTables.FindItemComposeJson(src.itemJson.ItemGrade, src.itemJson.GradeLv);
return c != null ? c.RequiredItemCount.ToString() : "-";
}
/// <summary>전부 합성(FR 「모두 합성」) — 장착 중인 것부터 본 아이템으로, 더 안 될 때까지 반복.</summary>
public static int ComposeAll()
{
int done = 0;
for (int guard = 0; guard < 500; guard++)
{
ComposePlan plan = null;
foreach (var it in SortedItems())
{
if (!it.itemJson.IsEquipment) continue;
plan = PlanCompose(it);
if (plan != null) break;
}
if (plan == null) break;
if (!Compose(plan)) break;
done++;
}
LastMessage = done > 0 ? "전부 합성 — " + done + "회" : "합성할 조합 없음";
if (done > 0) Notify();
return done;
}
// ── 강등(FR DowngradeItem · 한 단계) ───────────────────────────────
public static bool Downgrade(int id)
{
var item = FindItem(id);
if (item == null || !item.itemJson.IsEquipment) { LastMessage = "장비가 아닙니다"; return false; }
var c = WLEquipTables.FindComposeByResult(item.itemJson.ItemGrade, item.itemJson.GradeLv);
if (c == null) { LastMessage = "최하 등급 — 강등 불가"; return false; }
var result = WLEquipTables.FindGroupItem(item.itemJson.GroupNo, c.ItemGrade, c.RequiredGradeLevel);
if (result == null) { LastMessage = "강등 결과 아이템 없음"; return false; }
string before = WLEquipTables.GradeName(item.itemJson.ItemGrade) + (item.itemJson.GradeLv > 0 ? "+" + item.itemJson.GradeLv : "");
item.rawItem.index = result.Index;
item.itemJson = result;
if (item.rawItem.level > result.ItemMaxLv) item.rawItem.level = result.ItemMaxLv;
// 재료 반환 — 같은 그룹의 재료 등급 아이템 RequiredItemCount 개(FR UIUtil 「현재 등급까지 지급」의 한 단계 판)
AddItem(result.Index, 1, c.RequiredItemCount);
LastMessage = result.Item_name + " " + before + " → " + WLEquipTables.GradeName(result.ItemGrade) + (result.GradeLv > 0 ? "+" + result.GradeLv : "") +
" (재료 " + c.RequiredItemCount + "개 반환)";
Notify();
return true;
}
// ── 설명 ───────────────────────────────────────────────────────────
public static string Describe(Item it)
{
if (it == null) return "";
var j = it.itemJson;
var sb = new System.Text.StringBuilder();
sb.Append(j.Item_name).Append(" [").Append(WLEquipTables.GradeName(j.ItemGrade));
if (j.GradeLv > 0) sb.Append('+').Append(j.GradeLv);
sb.Append("] ");
if (j.IsEquipment)
{
sb.Append(WLEquipTables.SlotName(j.EquipmentType)).Append(" Lv ").Append(it.rawItem.level).Append('/').Append(j.ItemMaxLv);
if (IsEquipped(it.rawItem._id)) sb.Append(" (장착 중)");
sb.Append('\n');
var lt = WLEquipTables.FindItemLevelJson(j.ItemLvAbilityID);
var info = lt != null ? lt.FindLevelInfo(it.rawItem.level) : null;
if (info != null)
{
if (info.AttackAbility != 0) sb.Append("공격 +").Append(info.AttackAbility).Append(" ");
if (info.DefenceAbility != 0) sb.Append("방어 +").Append(info.DefenceAbility).Append(" ");
if (info.HealthAbility != 0) sb.Append("생명 +").Append(info.HealthAbility).Append(" ");
}
var cost = NextLevelCost(it);
if (cost != null)
{
var sj = WLEquipTables.FindItemJson(cost.ItemKeyRequired);
sb.Append("\n다음 강화: 골드 ").Append(cost.MoneyCountRequired);
if (cost.ItemKeyRequired > 0) sb.Append(" + ").Append(sj != null ? sj.Item_name : "강화석").Append(' ').Append(cost.ItemCountRequired)
.Append(" (보유 ").Append(GetItemCountByIndex(cost.ItemKeyRequired)).Append(')');
}
else sb.Append("\n최대 레벨");
var comp = WLEquipTables.FindItemComposeJson(j.ItemGrade, j.GradeLv);
if (comp != null)
{
var res = WLEquipTables.FindResultItemJson(j, comp);
sb.Append("\n합성: 같은 ").Append(comp.SameGroupNo == 1 ? "아이템" : "등급·부위").Append(" ").Append(WLEquipTables.GradeName(comp.ItemGrade))
.Append(comp.RequiredGradeLevel > 0 ? "+" + comp.RequiredGradeLevel : "").Append(" 재료 ").Append(comp.RequiredItemCount).Append("개 → ")
.Append(res != null ? WLEquipTables.GradeName(res.ItemGrade) + (res.GradeLv > 0 ? "+" + res.GradeLv : "") : "?");
}
else sb.Append("\n합성: 최고 등급");
sb.Append("\n옵션(등급별 개방):");
var oj = WLEquipTables.FindItemOptionJson(j.GroupNo);
if (oj == null) sb.Append(" 없음");
else
{
for (int i = 0; i < oj.ItemOptions.Count; i++)
{
var e = oj.ItemOptions[i];
bool open = e.ItemGrade <= j.ItemGrade;
sb.Append("\n ").Append(open ? "● " : "○ ").Append(WLEquipTables.GradeName(e.ItemGrade)).Append(": ");
for (int a = 0; a < e.Abilities.Count; a++)
{
var ab = e.Abilities[a];
sb.Append(WLEquipTables.AbilityName(ab.Ability)).Append(' ').Append(ab.AbilityValue);
sb.Append(WLEquipStats.IsMapped(ab.Ability) ? "" : "(미적용)");
if (a < e.Abilities.Count - 1) sb.Append(", ");
}
if (e.ConditionalAbilities.Count > 0) sb.Append(e.Abilities.Count > 0 ? ", " : "").Append("조건부 ").Append(e.ConditionalAbilities.Count).Append("(미적용)");
}
}
}
else
{
sb.Append("×").Append(it.rawItem.count);
if (!string.IsNullOrEmpty(j.Desc)) sb.Append('\n').Append(j.Desc);
}
return sb.ToString();
}
public static string RowText(Item it)
{
var j = it.itemJson;
string g = "[" + WLEquipTables.GradeName(j.ItemGrade) + (j.GradeLv > 0 ? "+" + j.GradeLv : "") + "]";
if (j.IsEquipment)
return (IsEquipped(it.rawItem._id) ? "E " : " ") + WLEquipTables.SlotName(j.EquipmentType) + " " + j.Item_name + " " + g + " Lv" + it.rawItem.level;
return " " + j.Item_name + " ×" + it.rawItem.count;
}
}
}

View File

@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 8e7b1ca4ed7e1044989e1d4932a9b625

View File

@ -142,6 +142,12 @@ namespace WL.Island
while (t < 5f && !WLIslandCoin.Ready) { t += Time.unscaledDeltaTime; yield return null; }
GrantPending(cfg);
// WL-822 — 섬 몬스터 존(땅 1칸 1곳 · 한도·쿨타임·남은 시간 글자) · 설정 off 면 무동작
WL.IslandMob.WLIslandMobZones.Begin(cfg, scene);
// WL-822 — 섬 기본 아바타 = 전투 PC(농부는 투명 그림자) · 켜져 있으면 아래 「PC 숨김」 대신 이쪽이 PC 를 다룬다
if (WL.IslandMob.WLIslandAvatar.Enabled) { WL.IslandMob.WLIslandAvatar.Begin(cfg, scene); yield break; }
// 🔴 WL 전투 PC 는 Addressables 콜백으로 **나중에** 생긴다(PCInfo.cs:16) — 나타나면 그때 숨긴다.
t = 0f;
while (t < cfg.hidePcWatchSeconds)
@ -265,6 +271,7 @@ namespace WL.Island
// 🔴 WL-816zc ② — 「전투 승리 후 섬 확장 노드 추가」. 보상과 무관하게 승리 그 자체로 연다.
WLIslandExpansionGate.Unlock(cfg);
WL.Equip.WLEquipDrops.OnStageCleared(in r); // WL-821 FR 드랍표로 장비·강화석 지급(설정 off 면 무동작)
if (cfg.rewardEnabled == 0) return;

View File

@ -91,6 +91,7 @@ namespace WL.Island
WLIslandExpansionGate.ClearSpotMemory(); // 2026-09-17 — 확장 가격판 자리 기억도 세이브와 함께 초기화
PlayerPrefs.Save();
WLIslandBridge.ClearPending(); // 보류 보상도 비운다(정적 값까지)
WL.IslandMob.WLIslandMobZones.ResetSave(); // WL-822 — 몬스터 존 누적·재등장 시각도 같이 초기화
}
// ② 시작 골드 — `startingMoney` 는 `data == null`(세이브 없음)일 때만 쓰인다(IslandManager.cs:397)

8
Assets/WL/IslandMob.meta Normal file
View File

@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 845545a9c25297d4e88757e11972b809
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View File

@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 30f58a0bcbd4706489847634477e8900
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View File

@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: e95412f77ae60814797a2bef5894d1d3
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View File

@ -0,0 +1,44 @@
%YAML 1.1
%TAG !u! tag:unity3d.com,2011:
--- !u!114 &11400000
MonoBehaviour:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 0}
m_Enabled: 1
m_EditorHideFlags: 0
m_Script: {fileID: 11500000, guid: da6d8eae99c32414ca494c2c81db0d34, type: 3}
m_Name: WLIslandMobSettings
m_EditorClassIdentifier: Assembly-CSharp::WL.IslandMob.WLIslandMobSettings
enabled_: 1
verboseLog: 1
combatAvatarEnabled: 1
farmerModeRadius: 2
farmerExitDelay: 0.4
moveDeadzone: 0.15
pcNavSnapRadius: 3
pcNavCheckSeconds: 1
pcWaitSeconds: 10
spawnerId: 1
aliveMax: 3
zoneOffset: {x: 2.5, y: 0, z: 2.5}
zoneRadius: 3
zoneNavSnap: 4
skipBridgeTiles: 1
avoidFarmRadius: 3
rescanSeconds: 3
spawnLimit: 10
cooldownSeconds: 10800
recallOverLimit: 1
prefsPrefix: WL_MobZone_
resetWithIslandSave: 1
labelHeight: 1.8
labelActiveFormat: "\uBAAC\uC2A4\uD130 {0}/{1}"
labelCooldownFormat: "\uC7AC\uB4F1\uC7A5 {0:hh\\:mm\\:ss}"
labelRemainSuffix: " \xB7 \uB0A8\uC740 \uBAAC\uC2A4\uD130 {0}"
labelFontSize: 3.2
labelActiveColor: {r: 1, g: 0.85, b: 0.4, a: 1}
labelCooldownColor: {r: 0.8, g: 0.9, b: 1, a: 1}
labelRefreshSeconds: 1

View File

@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: b5e0af224fdb3304581b94ecccb2fb08
NativeFormatImporter:
externalObjects: {}
mainObjectFileID: 11400000
userData:
assetBundleName:
assetBundleVariant:

View File

@ -0,0 +1,343 @@
// ─────────────────────────────────────────────────────────────────────────────
// WLIslandAvatar.cs — 섬의 기본 아바타 = 힘민지 전투 PC · FI 농부 = 보이지 않는 그림자 (#822 · PD 역제안)
//
// ■ 원리
// · 전투 모드(기본): `MyValue.MyPC` 를 켜서 FI 농부 자리에 세우고, FI 조이스틱(SimpleInput "Horizontal/Vertical")을
// `PCActor.Run_byJoystick` 에 주입한다(힘민지 FloatingJoystick 과 같은 호출 순서 · 카메라는 FI vcam 이 고정 회전이라 월드 축 그대로).
// FI 농부는 **매 LateUpdate 에 PC 위치로 옮긴다** — 콜라이더·태그 "Player"·PlayerController 가 그대로라
// 발판·상인·확장 가격판·밭·낚시터·과일나무 트리거와 FI 카메라 Follow 가 전부 그대로 돈다. 농부의 치비 모델만 끈다.
// · 농부 모드: FI 가 농사 중(IsWorking) · 조작 잠김(낚시·나무 흔들기 = IsControllable=false) · 밭 가까이 → PC 를 끄고
// 농부 치비를 켠다(섬 기존 동작 그대로). 조건이 풀리면 PC 를 농부 자리에 다시 세운다.
// · 섬 확장 때 FI NavMesh 가 재베이크된다 → 주기적으로 PC 가 NavMesh 밖이면 농부 자리로 되돌린다.
// ■ 원본 수정 0줄 · 기존 `HideWlPc` 는 이 컴포넌트가 켜져 있으면 쓰지 않는다(WLIslandBridge 1줄 분기).
// ─────────────────────────────────────────────────────────────────────────────
using System.Collections;
using System.Collections.Generic;
using CryingSnow.FarmingIsland;
using UnityEngine;
using UnityEngine.AI;
using UnityEngine.SceneManagement;
using WL.Island;
namespace WL.IslandMob
{
public sealed class WLIslandAvatar : MonoBehaviour
{
public enum Mode { Waiting, Combat, Farmer }
public static WLIslandAvatar Instance { get; private set; }
public static bool Enabled { get { var c = WLIslandMobSettings.Instance; return c != null && c.enabled_ != 0 && c.combatAvatarEnabled != 0; } }
public static string LastLog = "";
public static int ModeSwitches, NavRecoveries;
public Mode CurrentMode { get; private set; } = Mode.Waiting;
public bool PcMoving { get; private set; }
WLIslandMobSettings _cfg;
WLIslandSettings _islandCfg;
PlayerController _fi;
PCActor _pc;
GameObject _fiVisual;
readonly List<Farm> _farms = new List<Farm>(8);
float _farmScanTimer, _navTimer, _farmerExitTimer;
public static WLIslandAvatar Begin(WLIslandSettings islandCfg, Scene scene)
{
if (Instance != null) return Instance;
var cfg = WLIslandMobSettings.Instance;
if (cfg == null || cfg.enabled_ == 0 || cfg.combatAvatarEnabled == 0) return null;
var go = new GameObject("~WL_IslandAvatar");
if (scene.IsValid()) SceneManager.MoveGameObjectToScene(go, scene);
Instance = go.AddComponent<WLIslandAvatar>();
Instance._cfg = cfg;
Instance._islandCfg = islandCfg;
Instance.StartCoroutine(Instance.Co_Setup());
return Instance;
}
void OnEnable() { WL.Combat.Survival.DeathFlow.Died.Add(OnPcDied); }
void OnDisable() { WL.Combat.Survival.DeathFlow.Died.Remove(OnPcDied); }
public static int AutoRevives;
/// <summary>섬에서 PC 가 죽으면 부활 팝업(던전용) 대신 제자리 자동 부활 — 「포기/로비로」 가 섬을 재로드하는 경로를 막는다.</summary>
void OnPcDied(in WL.Combat.Survival.DeathEvent e)
{
if (_cfg == null || _cfg.autoReviveOnIsland == 0) return;
if (WL.Combat.Survival.DeathFlow.Revive()) { AutoRevives++; Log("섬 자동 부활 #" + AutoRevives); }
}
void OnDestroy()
{
if (Instance == this) Instance = null;
// 섬 씬이 내려갈 때(던전 입장) 농부 모델은 원래대로(안 보여도 무해) · PC 는 던전 준비가 다시 다룬다
if (_fiVisual != null) _fiVisual.SetActive(true);
if (_pc != null && PcMoving) { try { _pc.Set_JoystickStatus(0); _pc.Set_AutoBraking(true); } catch { } }
}
IEnumerator Co_Setup()
{
float t = 0f;
while (t < _cfg.pcWaitSeconds)
{
_fi = Object.FindFirstObjectByType<PlayerController>();
if (_fi != null && MyValue.bMyPC && MyValue.MyPC != null) break;
t += Time.unscaledDeltaTime;
yield return null;
}
if (_fi == null || !MyValue.bMyPC || MyValue.MyPC == null)
{
LastLog = "아바타 준비 실패 — FI 플레이어 " + (_fi != null) + " · PC " + (MyValue.bMyPC && MyValue.MyPC != null);
Log(LastLog);
yield break;
}
_pc = MyValue.MyPC;
var vis = _fi.transform.Find(WLIslandSceneSetup.PcVisualName);
_fiVisual = vis != null ? vis.gameObject : null;
RescanFarms();
EnterCombat("시작");
}
void Log(string s) { if (_cfg != null && _cfg.verboseLog != 0) Debug.Log("[WL-822] " + s); }
// ── 모드 전환 ───────────────────────────────────────────────────
// ── 전투 형태 (섬 = BattleMapConfig e_MapType Lobby → 원본 PCActor.Set_Obj 가 로비 애니메이터(공격 없음·무기 없음)를 끼운다) ──
public static int CombatFormApplied;
public string LastFormLog = "";
float _formTimer;
/// <summary>
/// 섬 맵이 「로비」 유형이라 PC 가 로비 형태(pcanim_lobby · 무기 없음)로 세팅된다 → 공격 상태가 없어 때리지 못한다(2026-10-09 PD 실플레이 · 로그 「GotoState: State could not be found」).
/// 표·UI 모드는 그대로 두고, `InGameInfo.m_GameMode` 를 **Set_Obj 호출 동안만** Stage 로 바꿔 원본 경로로 직업 애니메이터·무기·스케일을 끼운 뒤 되돌린다. 자동전투도 켠다(AutoCombatOnEnter 는 로비를 건너뛴다).
/// </summary>
void ApplyCombatForm(string why)
{
var info = InGameInfo.Ins;
if (info == null || _pc == null) return;
EnsureIngameSingletons();
var f = typeof(InGameInfo).GetField("m_GameMode", System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic);
object prev = f != null ? f.GetValue(info) : null;
bool flipped = false;
try
{
if (f != null && prev is eGameMode && (eGameMode)prev == eGameMode.Lobby) { f.SetValue(info, eGameMode.Stage); flipped = true; }
_pc.Set_Obj(); // 직업 애니메이터(비동기 로드) + Change_Weapon + 스케일
}
catch (System.Exception ex) { LastFormLog = "전투 형태 실패 — " + ex.Message; Log(LastFormLog); }
finally { if (flipped) f.SetValue(info, prev); }
try { if (!WL.Combat.Auto.AutoCombatOnEnter.IsAutoOn) _pc.Set_Auto(); } catch (System.Exception ex) { Log("자동전투 ON 실패 — " + ex.Message); }
CombatFormApplied++;
LastFormLog = "전투 형태 적용(" + why + ") #" + CombatFormApplied + " flipped=" + flipped + " auto=" + WL.Combat.Auto.AutoCombatOnEnter.IsAutoOn;
Log(LastFormLog);
}
/// <summary>
/// 🔴 섬은 로비 UI 상태라 전투 HUD 가지(`IngameUIs/MonsterSimpleInfo`)가 한 번도 켜지지 않아 싱글턴 `Ins` 가 null 이다.
/// 원본 `MobActor.Set_Die:444` 가 `MonsterSimpleInfo.Ins.DeadTarget` 을 부르므로 **처치마다 NRE** → `base.Set_Die` 가 끊겨
/// 시체 정리(After_Die)·리젠 예약(actDie)이 영영 안 돈다(2026-10-09 실측: killmobcount 0 · 죽은 몹이 활성 상태로 남음 · 던전 815d2 와 같은 뿌리).
/// 가지를 켜지 않고(HUD 가 보이면 안 된다) **참조만** 채운다 — DeadTarget/Set 은 꺼진 오브젝트에서도 안전하다.
/// </summary>
public static int SingletonFixes;
static void EnsureIngameSingletons()
{
try
{
if (MonsterSimpleInfo.Ins == null)
{
var found = Object.FindFirstObjectByType<MonsterSimpleInfo>(FindObjectsInactive.Include);
if (found != null) { MonsterSimpleInfo.Ins = found; MonsterSimpleInfo.isIns = true; SingletonFixes++; }
}
}
catch (System.Exception ex) { Debug.LogWarning("[WL-822] 싱글턴 참조 보정 실패 — " + ex.Message); }
}
bool PcInLobbyForm()
{
var anim = _pc != null ? _pc.GetComponent<Animator>() : null;
var rac = anim != null ? anim.runtimeAnimatorController : null;
return rac != null && rac.name.IndexOf("lobby", System.StringComparison.OrdinalIgnoreCase) >= 0;
}
public string AnimatorName { get { var anim = _pc != null ? _pc.GetComponent<Animator>() : null; return anim != null && anim.runtimeAnimatorController != null ? anim.runtimeAnimatorController.name : "null"; } }
void EnterCombat(string why)
{
if (_pc == null || _fi == null) return;
if (!_pc.gameObject.activeSelf) _pc.gameObject.SetActive(true);
ApplyCombatForm(why);
WarpPcToFi();
if (_fiVisual != null) _fiVisual.SetActive(false);
CurrentMode = Mode.Combat;
ModeSwitches++;
LastLog = "전투 모드(" + why + ") — PC @" + _pc.transform.position.ToString("F1");
Log(LastLog);
}
void EnterFarmer(string why)
{
if (_pc == null || _fi == null) return;
StopPcMove();
if (_pc.gameObject.activeSelf) _pc.gameObject.SetActive(false);
if (_fiVisual != null) _fiVisual.SetActive(true);
CurrentMode = Mode.Farmer;
ModeSwitches++;
LastLog = "농부 모드(" + why + ")";
Log(LastLog);
}
public string LastWarpLog = "";
void WarpPcToFi()
{
Vector3 p = _fi.transform.position;
NavMeshHit hit;
bool onMesh = NavMesh.SamplePosition(p, out hit, _cfg.pcNavSnapRadius, NavMesh.AllAreas);
if (onMesh) p = hit.position;
Vector3 before = _pc.transform.position;
bool dead = false;
try { dead = _pc.IsDead(); } catch { }
try { _pc.Set_Warp(p); } catch (System.Exception ex) { Log("PC 워프 실패 — " + ex.Message); }
var agent = _pc.GetComponent<NavMeshAgent>();
// Set_Warp 가 IsDead(비활성 계층 포함)로 조용히 빠지면 에이전트 Warp 를 직접 쓴다(마지막 수단 · transform 대입은 NavMeshAgent 가 되돌린다)
if ((_pc.transform.position - p).sqrMagnitude > 0.25f && agent != null)
{
agent.enabled = true;
if (!agent.Warp(p)) _pc.transform.position = p;
}
_pc.transform.rotation = Quaternion.Euler(0f, _fi.transform.eulerAngles.y, 0f);
LastWarpLog = "warp " + before.ToString("F1") + " → " + _pc.transform.position.ToString("F1") + " (목표 " + p.ToString("F1") + " navHit=" + onMesh +
" dead=" + dead + " activeInHierarchy=" + _pc.gameObject.activeInHierarchy + " agentOnMesh=" + (agent != null && agent.isOnNavMesh) + ")";
Log(LastWarpLog);
}
// ── 매 프레임 ───────────────────────────────────────────────────
void Update()
{
if (CurrentMode == Mode.Waiting || _pc == null || _fi == null) return;
_farmScanTimer += Time.unscaledDeltaTime;
if (_farmScanTimer >= _cfg.rescanSeconds) { _farmScanTimer = 0f; RescanFarms(); }
bool farmerWanted = FarmerWanted();
if (CurrentMode == Mode.Combat)
{
if (farmerWanted) { EnterFarmer(FarmerReason()); return; }
DrivePc();
CheckPcNav();
// 원본 ReSet(맵 로드·부활 등)이 로비 형태로 되돌리면 다시 전투 형태로
_formTimer += Time.unscaledDeltaTime;
if (_formTimer >= 1f) { _formTimer = 0f; if (PcInLobbyForm()) ApplyCombatForm("로비 형태 감지"); }
}
else // Farmer
{
if (farmerWanted) _farmerExitTimer = 0f;
else
{
_farmerExitTimer += Time.unscaledDeltaTime;
if (_farmerExitTimer >= _cfg.farmerExitDelay) { _farmerExitTimer = 0f; EnterCombat("농사 끝"); }
}
}
}
void LateUpdate()
{
if (CurrentMode != Mode.Combat || _pc == null || _fi == null) return;
// 농부 그림자 = PC 자리(트리거·카메라 Follow 용) — CharacterController 가 켜져 있어도 transform 직접 대입은 허용된다
var p = _pc.transform.position;
_fi.transform.position = p;
_fi.transform.rotation = Quaternion.Euler(0f, _pc.transform.eulerAngles.y, 0f);
}
string _farmerReason = "";
string FarmerReason() { return _farmerReason; }
bool FarmerWanted()
{
if (_fi.IsWorking) { _farmerReason = "농사 중"; return true; }
if (!_fi.IsControllable) { _farmerReason = "조작 잠김(낚시·나무)"; return true; }
if (_fi.FishingRod != null && _fi.FishingRod.activeSelf) { _farmerReason = "낚시"; return true; }
if (_cfg.farmerModeRadius > 0f && NearFarm(_cfg.farmerModeRadius)) { _farmerReason = "밭 근처"; return true; }
return false;
}
void RescanFarms()
{
_farms.Clear();
var arr = Object.FindObjectsByType<Farm>(FindObjectsInactive.Exclude, FindObjectsSortMode.None);
for (int i = 0; i < arr.Length; i++) if (arr[i] != null) _farms.Add(arr[i]);
}
bool NearFarm(float radius)
{
Vector3 p = CurrentMode == Mode.Combat ? _pc.transform.position : _fi.transform.position;
float r2 = radius * radius;
for (int i = 0; i < _farms.Count; i++)
{
var f = _farms[i];
if (f == null || !FarmVisible(f)) continue; // 플로우가 숨긴 밭(렌더러 off)은 농작물이 없는 것으로 본다
var col = f.GetComponent<Collider>();
Vector3 c = col != null ? col.ClosestPoint(p) : f.transform.position;
c.y = p.y;
if ((c - p).sqrMagnitude <= r2) return true;
}
return false;
}
static readonly List<Renderer> s_rend = new List<Renderer>(16);
static bool FarmVisible(Farm f)
{
if (!f.gameObject.activeInHierarchy) return false;
s_rend.Clear();
f.GetComponentsInChildren<Renderer>(false, s_rend);
for (int i = 0; i < s_rend.Count; i++) if (s_rend[i] != null && s_rend[i].enabled) return true;
return false;
}
// ── 조이스틱 → PC (힘민지 FloatingJoystick 과 같은 호출 순서) ───
void DrivePc()
{
float h = 0f, v = 0f;
try { h = SimpleInput.GetAxis("Horizontal"); v = SimpleInput.GetAxis("Vertical"); } catch { }
var dir = new Vector3(h, 0f, v);
bool want = dir.sqrMagnitude >= _cfg.moveDeadzone * _cfg.moveDeadzone;
bool can = false;
try { can = _pc.CanMove(); } catch { can = false; }
if (want && can)
{
if (!PcMoving) { _pc.Set_AutoBraking(false); _pc.Set_JoystickStatus(1); PcMoving = true; }
_pc.Run_byJoystick(dir.normalized);
}
else if (PcMoving) StopPcMove();
}
void StopPcMove()
{
if (!PcMoving) return;
PcMoving = false;
try { _pc.Set_JoystickStatus(0); _pc.Set_AutoBraking(true); } catch { }
}
// ── NavMesh 이탈 복구(섬 확장 재베이크) ─────────────────────────
void CheckPcNav()
{
_navTimer += Time.unscaledDeltaTime;
if (_navTimer < _cfg.pcNavCheckSeconds) return;
_navTimer = 0f;
var agent = _pc.GetComponent<NavMeshAgent>();
if (agent == null || !agent.enabled) return;
if (agent.isOnNavMesh) return;
NavMeshHit hit;
if (NavMesh.SamplePosition(_pc.transform.position, out hit, _cfg.pcNavSnapRadius, NavMesh.AllAreas))
{
try { _pc.Set_Warp(hit.position); NavRecoveries++; Log("PC NavMesh 복구 → " + hit.position.ToString("F1")); } catch { }
}
}
public string Describe()
{
return "mode=" + CurrentMode + " reason=" + _farmerReason + " animator=" + AnimatorName + " singletonFix=" + SingletonFixes + " msiIns=" + (MonsterSimpleInfo.Ins != null) + " form=[" + LastFormLog + "] lastWarp=[" + LastWarpLog + "] pcActive=" + (_pc != null && _pc.gameObject.activeSelf) + " fiVisual=" + (_fiVisual != null && _fiVisual.activeSelf) +
" moving=" + PcMoving + " farms=" + _farms.Count + " switches=" + ModeSwitches + " navRecover=" + NavRecoveries +
(_pc != null ? " pc@" + _pc.transform.position.ToString("F1") : "") + (_fi != null ? " fi@" + _fi.transform.position.ToString("F1") : "");
}
}
}

View File

@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 36a6a22b08ab57240863b607f7a6b23c

View File

@ -0,0 +1,94 @@
// ─────────────────────────────────────────────────────────────────────────────
// WLIslandMobSettings.cs — 섬 몬스터 존 + 섬 전투 아바타 설정(SO · Resources/WL/WLIslandMobSettings.asset) (#822)
//
// PD 지시(2026-10-09): 섬에서도 몬스터 등장 · 확장 땅 1칸 = 섬 1개 · 섬마다 1곳에서만 · 한도(10) 뒤 긴 쿨타임(3시간) ·
// 남은 시간 텍스트. PD 역제안: 「기본 = 전투 캐릭터 · 농작물 있을 때만 농부」.
// 🔴 SO 는 클래스와 같은 이름의 .cs 에만(816g 교훈).
// ─────────────────────────────────────────────────────────────────────────────
using UnityEngine;
namespace WL.IslandMob
{
public sealed class WLIslandMobSettings : ScriptableObject
{
[Header("스위치")]
[Tooltip("0 이면 전부 무동작(섬은 지금처럼 농부만 · 몬스터 없음)")]
public int enabled_ = 1;
public int verboseLog = 1;
[Header("섬 아바타 — 기본 전투 PC · 농부는 보이지 않는 그림자")]
[Tooltip("1 이면 섬에서 힘민지 전투 PC 를 켜고 조이스틱으로 움직인다(FI 농부는 PC 를 따라다니는 투명 그림자 = 발판·상인·밭 트리거 유지)")]
public int combatAvatarEnabled = 1;
[Tooltip("농부 모드 조건 ① FI 가 농사 중(IsWorking) · 조작 잠김(낚시·나무 흔들기) ② 밭(Farm) 콜라이더까지 이 거리(m) 안 — 0 이면 ②는 안 씀")]
public float farmerModeRadius = 2f;
[Tooltip("농부 모드 조건이 사라진 뒤 전투로 돌아가기까지 대기(초) — 밭 가장자리에서 깜빡임 방지")]
public float farmerExitDelay = 0.4f;
[Tooltip("조이스틱 입력 데드존(0~1)")]
public float moveDeadzone = 0.15f;
[Tooltip("PC 를 FI 자리로 되돌릴 때 NavMesh 탐색 반경(m) — 섬 확장 재베이크 뒤 이탈 복구에도 쓴다")]
public float pcNavSnapRadius = 3f;
[Tooltip("PC 가 NavMesh 밖인지 확인하는 주기(초)")]
public float pcNavCheckSeconds = 1f;
[Tooltip("전투 PC 가 생길 때까지 기다리는 상한(초 · Addressables 비동기)")]
public float pcWaitSeconds = 10f;
[Tooltip("1 이면 섬에서 PC 가 죽으면 부활 팝업 없이 제자리에서 자동 부활(던전의 실패·복귀 흐름이 섬을 재로드하는 사고 방지)")]
public int autoReviveOnIsland = 1;
[Header("몬스터 존 — 땅 1칸(FI Island) 당 1곳")]
[Tooltip("MonsterAppear 표의 SpawnerID(몹 종류·리젠 간격 f_SpawnerDelay 는 표가 정한다). 🔴 e_SpawnerType = Random 이어야 한 마리 죽을 때마다 리젠된다 · 1 = 힘민지 Lv1 잡몹(101~104 · 딜레이 4 s). 813001 은 PhaseAllKill 이라 부적합")]
public int spawnerId = 1;
[Tooltip("존에 동시에 살아 있는 몬스터 상한 — 표의 n_MaxMonsterCount 가 더 커도 초과분은 즉시 회수(Off)하고 누적에 세지 않는다")]
public int aliveMax = 3;
[Tooltip("등장하는 몬스터의 최대 HP 를 이 값으로 덮는다(PD 2026-10-09 「플레이어 공격 3~4방」 · 기본 공격력 5 기준 18). 0 = MonsterAppear 표 값(스포너 1 은 300)")]
public double mobMaxHp = 18;
[Tooltip("타일 중심에서 존 자리까지의 오프셋(m · 타일 한 변 8 m)")]
public Vector3 zoneOffset = new Vector3(2.5f, 0f, 2.5f);
[Tooltip("존 반경(m) = 몹 생성 범위(MobGenRange)")]
public float zoneRadius = 3f;
[Tooltip("존 자리를 NavMesh 위로 맞출 탐색 반경(m)")]
public float zoneNavSnap = 4f;
[Tooltip("1 이면 다리 타일에는 존을 두지 않는다")]
public int skipBridgeTiles = 1;
[Tooltip("밭·플로우 설치물과 이 거리(m) 안이면 다른 모서리 오프셋을 시도한다 · 0 = 안 피함")]
public float avoidFarmRadius = 3f;
[Tooltip("새 타일 검색 주기(초)")]
public float rescanSeconds = 3f;
[Header("한도 · 쿨타임")]
[Tooltip("존당 누적 등장 한도(마리) — 이 수가 생성되면 더 안 나오고 쿨타임 시작")]
public int spawnLimit = 10;
[Tooltip("쿨타임(초) · 10800 = 3시간 · 실시간(앱을 꺼도 흐른다)")]
public double cooldownSeconds = 10800;
[Tooltip("한도를 넘긴 뒤 리젠으로 새로 나온 몹은 즉시 회수한다(원본 리젠 예약은 끊을 수 없어 사후 회수)")]
public int recallOverLimit = 1;
[Tooltip("PlayerPrefs 키 접두 — 존 누적 수·재등장 시각 저장")]
public string prefsPrefix = "WL_MobZone_";
[Tooltip("1 이면 섬 세이브 초기화(실행마다) 때 존 기록도 지운다")]
public int resetWithIslandSave = 1;
[Header("존 라벨(3D 글자)")]
public float labelHeight = 1.8f;
[Tooltip("{0} = 누적 등장 · {1} = 한도")]
public string labelActiveFormat = "몬스터 {0}/{1}";
[Tooltip("{0} = 남은 시간 TimeSpan · {1} = 남아 있는 몬스터")]
public string labelCooldownFormat = "재등장 {0:hh\\:mm\\:ss}";
public string labelRemainSuffix = " · 남은 몬스터 {0}";
public float labelFontSize = 3.2f;
public Color labelActiveColor = new Color(1f, 0.85f, 0.4f, 1f);
public Color labelCooldownColor = new Color(0.8f, 0.9f, 1f, 1f);
[Tooltip("라벨 갱신 주기(초)")]
public float labelRefreshSeconds = 1f;
static WLIslandMobSettings s_cache;
public static WLIslandMobSettings Instance
{
get
{
if (s_cache == null) s_cache = Resources.Load<WLIslandMobSettings>("WL/WLIslandMobSettings");
return s_cache;
}
}
public static bool Enabled { get { var c = Instance; return c != null && c.enabled_ != 0; } }
}
}

View File

@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: da6d8eae99c32414ca494c2c81db0d34

View File

@ -0,0 +1,393 @@
// ─────────────────────────────────────────────────────────────────────────────
// WLIslandMobZones.cs — 섬 몬스터 존: 열린 땅 1칸마다 1곳 · 한도 뒤 긴 쿨타임 · 남은 시간 글자 (#822)
//
// ■ 존 1개 = 힘민지 `MobControlMgr` 1개(SpawnerID = 설정 · MonsterAppear 표가 몹 종류·동시 수·리젠 간격을 정한다 · 던전 스포너와 같은 조립)
// + 3D 라벨(WLIslandLabel) + PlayerPrefs 기록(누적 등장 수 · 재등장 시각).
// ■ 누적 등장 = `CombatEvents.Spawned`(MobActor.On_Regen 원본 훅)를 존 반경으로 가려내 센다.
// 한도 도달 → 리젠 예약 차단(`StopAllCoroutines` · StageSpawner 와 같은 경로) + 이후 리젠으로 또 나오면 즉시 회수(`Off`).
// 남아 있는 몹은 잡을 때까지 유지. 쿨타임(실시간)이 끝나면 기록을 0 으로 하고 비활성 몹을 깨운다(`On_Regen` · StageSpawner.DeployWave 와 같은 경로).
// 🔴 원본 수정 0줄 · Debug.LogError 금지(원본 ErrorLogHook 이 팝업을 띄운다).
// ─────────────────────────────────────────────────────────────────────────────
using System;
using System.Collections.Generic;
using CryingSnow.FarmingIsland;
using TMPro;
using UnityEngine;
using UnityEngine.AI;
using UnityEngine.SceneManagement;
using WL.Combat.Core;
using WL.Island;
using FIIsland = CryingSnow.FarmingIsland.Island; // 🔴 `WL.Island` 네임스페이스와 이름이 겹친다
using FIIslandManager = CryingSnow.FarmingIsland.IslandManager;
using UObject = UnityEngine.Object;
namespace WL.IslandMob
{
public sealed class WLIslandMobZones : MonoBehaviour
{
public sealed class Zone
{
public Vector3Int tile;
public string key;
public GameObject root;
public MobControlMgr mgr;
public TextMeshPro label;
public Vector3 pos;
public int spawned;
public long cooldownUntilTicks; // 0 = 쿨타임 아님
public bool capped;
public int recalled;
/// <summary>이 존이 「살려 둔」 몹(누적에 센 것) — 죽거나 꺼지면 빠진다.</summary>
public readonly HashSet<Actor> live = new HashSet<Actor>();
public int Alive
{
get
{
live.RemoveWhere(a => a == null || !a.isActiveAndEnabled || a.IsDead());
return live.Count;
}
}
public bool Cooling { get { return cooldownUntilTicks > 0 && DateTime.UtcNow.Ticks < cooldownUntilTicks; } }
public TimeSpan Remaining { get { return cooldownUntilTicks > 0 ? new TimeSpan(Math.Max(0, cooldownUntilTicks - DateTime.UtcNow.Ticks)) : TimeSpan.Zero; } }
}
public static WLIslandMobZones Instance { get; private set; }
public static string LastLog = "";
public static int SpawnEvents, CapHits, Rearms;
public readonly List<Zone> Zones = new List<Zone>(16);
WLIslandMobSettings _cfg;
Scene _scene;
float _scanTimer, _labelTimer;
readonly Dictionary<Vector3Int, Zone> _byTile = new Dictionary<Vector3Int, Zone>(16);
public static WLIslandMobZones Begin(WLIslandSettings islandCfg, Scene scene)
{
if (Instance != null) return Instance;
var cfg = WLIslandMobSettings.Instance;
if (cfg == null || cfg.enabled_ == 0) return null;
if (InGameInfo.Ins == null) return null; // 섬 단독 Play(룩 확인)에는 테이블·ActorInfo 가 없다
var go = new GameObject("~WL_IslandMobZones");
if (scene.IsValid()) SceneManager.MoveGameObjectToScene(go, scene);
Instance = go.AddComponent<WLIslandMobZones>();
Instance._cfg = cfg;
Instance._scene = scene;
CombatEvents.Spawned.Add(Instance.OnSpawned);
CombatEvents.Killed.Add(Instance.OnKilled);
Instance.Rescan();
return Instance;
}
void OnDestroy()
{
CombatEvents.Spawned.Remove(OnSpawned);
CombatEvents.Killed.Remove(OnKilled);
if (Instance == this) Instance = null;
}
void Log(string s) { LastLog = s; if (_cfg != null && _cfg.verboseLog != 0) Debug.Log("[WL-822] " + s); }
void Update()
{
_scanTimer += Time.unscaledDeltaTime;
if (_scanTimer >= _cfg.rescanSeconds) { _scanTimer = 0f; Rescan(); }
_labelTimer += Time.unscaledDeltaTime;
if (_labelTimer >= _cfg.labelRefreshSeconds) { _labelTimer = 0f; Tick(); }
}
// ── 타일 → 존 ───────────────────────────────────────────────────
public void Rescan()
{
var mgr = FIIslandManager.Instance;
if (mgr == null) return;
var tiles = UObject.FindObjectsByType<FIIsland>(FindObjectsInactive.Exclude, FindObjectsSortMode.None);
for (int i = 0; i < tiles.Length; i++)
{
var isl = tiles[i];
if (isl == null || !isl.IsUnlocked || !isl.gameObject.activeInHierarchy) continue;
if (_cfg.skipBridgeTiles != 0 && isl.IsBridge) continue;
if (isl.transform.localScale.x < 0.999f) continue; // 활성화 애니(점프) 중
var key = isl.GetPosition();
if (_byTile.ContainsKey(key)) continue;
var z = MakeZone(isl, key);
if (z != null) { _byTile[key] = z; Zones.Add(z); }
}
}
Zone MakeZone(FIIsland isl, Vector3Int key)
{
Vector3 center = isl.transform.position;
Vector3 pos;
if (!PickZonePos(center, out pos)) { Log("존 자리 없음(NavMesh) — 타일 " + key); return null; }
var z = new Zone { tile = key, key = _cfg.prefsPrefix + key.x + "_" + key.z, pos = pos };
z.spawned = PlayerPrefs.GetInt(z.key + "_n", 0);
long ticks;
z.cooldownUntilTicks = long.TryParse(PlayerPrefs.GetString(z.key + "_t", "0"), out ticks) ? ticks : 0;
if (z.cooldownUntilTicks > 0 && DateTime.UtcNow.Ticks >= z.cooldownUntilTicks) { z.cooldownUntilTicks = 0; z.spawned = 0; SaveZone(z); }
z.capped = z.cooldownUntilTicks > 0;
z.root = new GameObject("~WL_MobZone_" + key.x + "_" + key.z);
if (_scene.IsValid()) SceneManager.MoveGameObjectToScene(z.root, _scene);
z.root.transform.position = pos;
WLIslandLabel.Attach(z.root.transform, new Vector3(0f, _cfg.labelHeight, 0f), "");
var lt = z.root.transform.Find("label");
z.label = lt != null ? lt.GetComponent<TextMeshPro>() : null;
if (z.label != null) z.label.fontSize = _cfg.labelFontSize;
if (!z.capped) ArmSpawner(z);
RegisterKey(z.key);
UpdateLabel(z);
Log("존 생성 — 타일 " + key + " @" + pos.ToString("F1") + " 누적 " + z.spawned + "/" + _cfg.spawnLimit + (z.capped ? " 쿨타임 " + z.Remaining : ""));
return z;
}
bool PickZonePos(Vector3 center, out Vector3 pos)
{
// 설정 오프셋 → 4 모서리 순으로 NavMesh 위이고 밭에서 떨어진 자리를 고른다
Vector3 o = _cfg.zoneOffset;
Vector3[] cands = { center + o, center + new Vector3(-o.x, o.y, o.z), center + new Vector3(o.x, o.y, -o.z), center + new Vector3(-o.x, o.y, -o.z), center };
for (int i = 0; i < cands.Length; i++)
{
NavMeshHit hit;
if (!NavMesh.SamplePosition(cands[i], out hit, _cfg.zoneNavSnap, NavMesh.AllAreas)) continue;
if (_cfg.avoidFarmRadius > 0f && NearFarm(hit.position, _cfg.avoidFarmRadius)) continue;
pos = hit.position;
return true;
}
pos = center;
return false;
}
static bool NearFarm(Vector3 p, float r)
{
var farms = UObject.FindObjectsByType<Farm>(FindObjectsInactive.Exclude, FindObjectsSortMode.None);
float r2 = r * r;
for (int i = 0; i < farms.Length; i++)
{
if (farms[i] == null) continue;
var c = farms[i].transform.position; c.y = p.y;
if ((c - p).sqrMagnitude <= r2) return true;
}
return false;
}
// ── 스포너 ─────────────────────────────────────────────────────
void ArmSpawner(Zone z)
{
if (z.mgr != null) return;
var go = new GameObject("~WL_Spawner_" + _cfg.spawnerId);
go.SetActive(false); // 🔴 필드부터 채우고 깨운다(던전과 동일)
go.transform.SetParent(z.root.transform, false);
go.transform.localPosition = Vector3.zero;
var labelGo = new GameObject("lv");
labelGo.transform.SetParent(go.transform, false);
var tmp = labelGo.AddComponent<TextMeshPro>();
tmp.text = "Lv.???"; tmp.fontSize = 2.4f;
labelGo.SetActive(false);
var mgr = go.AddComponent<MobControlMgr>();
mgr.SpawnerID = _cfg.spawnerId;
mgr.MobGenRange = _cfg.zoneRadius;
mgr.text_lv = tmp;
go.SetActive(true);
z.mgr = mgr;
}
/// <summary>쿨타임이 끝났을 때 — 기록 0 · 비활성 몹을 표의 동시 수만큼 깨운다(StageSpawner.DeployWave 와 같은 경로).</summary>
void Rearm(Zone z)
{
z.capped = false; z.spawned = 0; z.cooldownUntilTicks = 0; z.recalled = 0;
SaveZone(z);
Rearms++;
if (z.mgr == null) { ArmSpawner(z); Log("존 재가동(스포너 신설) — 타일 " + z.tile); return; }
int want = Mathf.Min(TableMaxCount(), _cfg.aliveMax);
int done = 0;
var info = ActorInfo.Ins;
if (DSUtil.CheckNull(info) || want <= 0) { Log("존 재가동 — 깨울 대상 없음(ActorInfo/표)"); return; }
List<Actor> actors = null;
try
{
actors = info.Get_Actors(eRole.Mob, _cfg.spawnerId, true);
if (actors != null)
{
int alive = z.Alive;
for (int i = 0; i < actors.Count && alive + done < want; i++)
{
var mob = actors[i] as MobActor;
if (DSUtil.CheckNull(mob) || mob.isActiveAndEnabled) continue;
_pendingZone = z; // 깨우는 순간 Spawned 이벤트가 이 존에 귀속되게
mob.On_Regen();
_pendingZone = null;
NavMeshHit hit;
var want2 = z.pos + new Vector3(UnityEngine.Random.Range(-_cfg.zoneRadius, _cfg.zoneRadius), 0f, UnityEngine.Random.Range(-_cfg.zoneRadius, _cfg.zoneRadius));
if (NavMesh.SamplePosition(want2, out hit, _cfg.zoneNavSnap, NavMesh.AllAreas)) mob.Set_Warp(hit.position); else mob.Set_Warp(z.pos);
done++;
}
}
}
catch (Exception ex) { Log("존 재가동 실패 — " + ex.Message); }
finally { if (actors != null) info.Release_Actors(actors); }
Log("존 재가동 — 타일 " + z.tile + " 깨움 " + done);
}
int TableMaxCount()
{
try { var d = table_monsterappear.Ins.Get_Data_orNull(_cfg.spawnerId); return d != null ? d.n_MaxMonsterCount : 0; }
catch { return 0; }
}
bool InZone(Zone z, Vector3 p)
{
float r = _cfg.zoneRadius + 2.5f;
var d = p - z.pos; d.y = 0f;
return d.sqrMagnitude <= r * r;
}
Zone ZoneOf(Vector3 p)
{
Zone best = null; float bestD = float.MaxValue;
for (int i = 0; i < Zones.Count; i++)
{
var z = Zones[i];
var d = p - z.pos; d.y = 0f;
float sq = d.sqrMagnitude;
if (sq < bestD) { bestD = sq; best = z; }
}
float r = _cfg.zoneRadius + 2.5f;
return best != null && bestD <= r * r ? best : null;
}
// ── 등장 집계(원본 훅 이벤트) ──────────────────────────────────
Zone _pendingZone;
void OnSpawned(in SpawnedEvent e)
{
if (e.actor == null) return;
if (e.actor.Get_Group() != _cfg.spawnerId) return;
var z = _pendingZone ?? ZoneOf(e.position);
if (z == null) return;
SpawnEvents++;
// 한도 뒤(쿨타임) · 동시 상한 초과 → 즉시 회수(누적에 안 센다)
if (z.capped || z.Cooling || z.Alive >= _cfg.aliveMax)
{
if (_cfg.recallOverLimit != 0) { try { e.actor.Off(); z.recalled++; } catch { } }
return;
}
z.live.Add(e.actor);
z.spawned++;
ApplyMobHp(e.actor);
if (z.spawned >= _cfg.spawnLimit)
{
z.capped = true;
z.cooldownUntilTicks = DateTime.UtcNow.Ticks + (long)(_cfg.cooldownSeconds * TimeSpan.TicksPerSecond);
CapHits++;
try { if (z.mgr != null && z.mgr.Get_LoadComplete()) z.mgr.StopAllCoroutines(); } catch { }
Log("존 한도 도달 — 타일 " + z.tile + " " + z.spawned + "/" + _cfg.spawnLimit + " → 쿨타임 " + TimeSpan.FromSeconds(_cfg.cooldownSeconds));
}
SaveZone(z);
UpdateLabel(z);
}
public static int HpOverrides;
public static string LastHpLog = "";
/// <summary>PD 「플레이어 공격 3~4방에 죽게」 — 등장 직후 MaxHP·HP 를 설정값으로(표 수정 0 · 던전 몹 무변경).</summary>
void ApplyMobHp(Actor a)
{
if (_cfg.mobMaxHp <= 0) return;
try
{
var st = a.Get_StatInfo();
if (st == null) return;
double before = st.Get_Stat(eStat.MaxHP);
st.Set_Stat(eStat.MaxHP, _cfg.mobMaxHp);
st.Set_Stat(eStat.HP, _cfg.mobMaxHp);
HpOverrides++;
LastHpLog = a.name + " MaxHP " + before.ToString("F0") + " → " + _cfg.mobMaxHp.ToString("F0") + " (HP " + a.Get_HP().ToString("F0") + "/" + a.Get_MaxHP().ToString("F0") + ")";
}
catch (Exception ex) { LastHpLog = "HP 덮기 실패 — " + ex.Message; }
}
void OnKilled(in KilledEvent e)
{
if (e.victim == null) return;
for (int i = 0; i < Zones.Count; i++) if (Zones[i].live.Remove(e.victim)) { UpdateLabel(Zones[i]); break; }
}
// ── 주기 처리 · 라벨 ───────────────────────────────────────────
void Tick()
{
for (int i = 0; i < Zones.Count; i++)
{
var z = Zones[i];
if (z.capped && !z.Cooling) Rearm(z);
UpdateLabel(z);
}
}
int AliveIn(Zone z) { return z.Alive; }
void UpdateLabel(Zone z)
{
if (z.label == null) return;
if (z.capped || z.Cooling)
{
string s = string.Format(_cfg.labelCooldownFormat, z.Remaining);
int alive = AliveIn(z);
if (alive > 0) s += string.Format(_cfg.labelRemainSuffix, alive);
z.label.text = s;
z.label.color = _cfg.labelCooldownColor;
}
else
{
z.label.text = string.Format(_cfg.labelActiveFormat, z.spawned, _cfg.spawnLimit);
z.label.color = _cfg.labelActiveColor;
}
}
// ── 저장 ───────────────────────────────────────────────────────
void SaveZone(Zone z)
{
PlayerPrefs.SetInt(z.key + "_n", z.spawned);
PlayerPrefs.SetString(z.key + "_t", z.cooldownUntilTicks.ToString());
}
void RegisterKey(string key)
{
string list = PlayerPrefs.GetString(_cfg.prefsPrefix + "keys", "");
if (list.Contains(key + ";")) return;
PlayerPrefs.SetString(_cfg.prefsPrefix + "keys", list + key + ";");
}
/// <summary>섬 세이브 초기화 때(WLIslandStart.Prepare) 존 기록도 지운다.</summary>
public static void ResetSave()
{
var cfg = WLIslandMobSettings.Instance;
if (cfg == null || cfg.resetWithIslandSave == 0) return;
string list = PlayerPrefs.GetString(cfg.prefsPrefix + "keys", "");
var keys = list.Split(';');
for (int i = 0; i < keys.Length; i++)
{
if (string.IsNullOrEmpty(keys[i])) continue;
PlayerPrefs.DeleteKey(keys[i] + "_n");
PlayerPrefs.DeleteKey(keys[i] + "_t");
}
PlayerPrefs.DeleteKey(cfg.prefsPrefix + "keys");
}
public string Describe()
{
var sb = new System.Text.StringBuilder();
sb.Append("zones=").Append(Zones.Count).Append(" spawnEvents=").Append(SpawnEvents).Append(" caps=").Append(CapHits).Append(" rearms=").Append(Rearms).Append(" hpOverrides=").Append(HpOverrides).Append(" lastHp=[").Append(LastHpLog).Append("]\n");
for (int i = 0; i < Zones.Count; i++)
{
var z = Zones[i];
sb.Append(" ").Append(z.tile).Append(" @").Append(z.pos.ToString("F1")).Append(" spawned=").Append(z.spawned).Append(" capped=").Append(z.capped)
.Append(" cooling=").Append(z.Cooling).Append(" alive=").Append(AliveIn(z)).Append(" mgr=").Append(z.mgr != null ? (z.mgr.Get_LoadComplete() ? "loaded" : "loading") : "none")
.Append(" label='").Append(z.label != null ? z.label.text : "-").Append("'\n");
}
return sb.ToString();
}
}
}

View File

@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 84088cb294be0884a972637324d665fb

View File

@ -30,6 +30,7 @@ namespace WL.UI
private void OnClick()
{
if (WL.Equip.WLEquipUi.TryOpen(menuId)) return; // WL-821 장비 메뉴(weapon/upgrade · WLEquipSettings.openMenuIds)
Debug.Log("[DummyMenuButton] " + menuId + " (더미 · 기능 미구현)");
}
}