docs(BT47~BT48-FirstRising): 아이템 드랍 시스템 초안(확정) · PPT · 구현 기록 · 세션 인수인계서 · 검증 스크립트 · 아이콘 생성 시험

- 시스템기획: 2026-10-01 아이템 드랍 시스템 초안 v0(§7 PD 확정 목록) · PPT 21장 · 생성 도구
- 대화로그 10-01 · 세션 인수인계서 · PD 지시 로그(BT47 · BT48)
- 검증 스크립트 사본 bt_evals_1001 (run_bt48_verify · run_bt48_v3 · 테이블 굽기)
- 아이콘 생성 시험(Game Icon Institute LoRA · v4XL): 스크립트 3종 · 고른 14종 투명 PNG · 회차별 시트

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
깃 관리자 2026-10-01 23:01:19 +09:00
parent 5f99650641
commit 97f140c02b
50 changed files with 1451 additions and 0 deletions

View File

@ -33,6 +33,8 @@ C3·C13 위반에 해당. **즉시 자진 보고 후 소급 등록**.
| # | 일시 | 지시 요지 | 처리 상태 | 산출물 경로 | 중단 사유 | 사후 조치 |
|---|------|----------|----------|-----------|----------|----------|
| BT48-FirstRising | 2026-10-01 | **아이템 드랍 · 아이템 · 능력치 시스템 구현 (디아블로식 파밍) — 몬스터 처치 시 드롭, 인벤토리에서 옵션 확인, 완료까지 중단 없이 자율 반복** (BT47 초안 v1 확정 기준 · 원문은 대화로그) | **완료 · FR `bt/work` `dc45353d72` push(10-01 22:59)** — 후속 3건 반영(드롭률 10% · 예전 등급 옵션 표시/적용 제거 + 기존 장비 변환 · 활 기본 투사체 수 1) · 3차 검증 Play 오류 0 · — 처치 → 드롭(등급 색 기둥 + 한글 이름표) → 습득 → 인벤토리 · 정보 창(고정1 · 옵션) → 8부위 장착 → 능력치 엔티티 반영(방어 v2 · 신성 · 관통 · 고정 피해 · 저항 · 회피 · 막기). 보류: 공격 속도 · 사거리 · 투사체 수 · 범위 피해 · 스킬 레벨 · 상태이상 · 흡혈 · 획득량 · 조건부 옵션(스킬 시스템 수정 필요) | FR `Assets/BurningTimes/Visual/Scripts/TV{ItemData,GlobalValue,ItemDatabase,ItemGenerator,LootDrops,ItemStats,ItemStatSync,ItemText}.cs` · `Resources/BT/*.asset` · 원본 9곳 · 인계서 `공유/대화로그/FirstRising/2026-10-01_세션인수인계.md` · 검증 `몬스터보스/tools/unity_eval/bt_evals_1001/` | — | — |
| BT47-FirstRising | 2026-10-01 | **아이템 드랍 시스템 초안 설계** — 등급 10 · 부위 8 · 고정 스텟 + 랜덤 옵션 · 몬스터 레벨→드랍 범위 · 획득 찬스→상위 등급. 능력치 시스템 변경 규모 병렬 점검(원문은 대화로그) | **초안 v0 + PPT 21장 제출(10-01) · PD 결정 대기 8건(Q14~Q21)** — 판정 [실측]: 대대적 개편 아님, 중간 규모 확장. PD 확정 13건(신성 = 방어 무시 순수 피해 · 공격 속도 = 무기 스킬 쿨타임 · 관통/고정 피해/막기 정의 · 보조 장비 8종 · 반지 1/목걸이 1 · 등급 = 성능만) 반영. 방어력 공식 v2(로그형 단일 곡선, 상한 80% 하나) PD "우선 이대로" | `공유/기획/FirstRising/시스템기획/2026-10-01_FR_아이템드랍시스템_초안_v0.md` · `…_초안_v0.pptx` · `tools/build_item_drop_v0.py` | — | — |
| BT46-Org | 2026-09-30 09:40 | **Claude 윈도우 앱이 쓰지 않는 동안 트레이에서 사라짐 — 원인 확인 · 조치** (원문은 대화로그) | **원인 확정 · 조치 준비 완료(09-30 10:00) · PD 실행 대기** — 원인 [실측 · 앱 로그 + 윈도우 이벤트 로그]: 앱이 업데이트를 받아 둔 채 10분쯤 쉬면 창 · 트레이를 내리고 윈도우에 강제 종료 + 새 버전 등록을 요청하는데, 09-23 02:56 부터 앱이 자기 프로세스를 작업 묶음에 넣으면서(`[windows-app-job] joined`) 윈도우가 앱을 닫지 못한다(AppXDeploymentServer 419 · 404 — 0x800700E9 / 0x8007001F). 그 전 18회는 2~40초 만에 재시작, 그 뒤 6회는 전부 실패. 실패하면 앱은 창 · 트레이 없이 숨어서 남고 아이콘을 눌러도 뜨지 않는다. 앱 내부 결함이라 직접 고칠 수 없음 → 감시 스크립트 작성 · 시험 모드 4가지 상황 통과. 작업 스케줄러 등록은 시스템 설정이라 PD 가 명령 1개로 실행 | `%LOCALAPPDATA%/ClaudeTrayWatchdog/`(watchdog.ps1 · run-hidden.vbs) · `공유/대화로그/조직운영/2026-09-30_Claude앱_트레이.md` | — | 등록 뒤 첫 실제 발생 때 동작 확인 필요(실제 끄고 켜기는 시험하지 않음 — 이 세션이 그 앱 안에서 돈다) · 근본 수정은 Anthropic 앱 업데이트 |
| BT45-FirstRising | 2026-09-30 01:01 | **맵 이동 로딩 단축 + 로딩 진행도 표시** — 맵 이동 로딩이 긴 원인 · 줄일 방법, 로딩 중 "loading..." 글자와 진행도 막대 (원문은 대화로그) | **완료(09-30 02:10) · PD 확인 대기 · FR `bt/work` `4bca428e62` push** — 원인 [실측 5회]: 옮길 때마다 로비 씬을 통째로 다시 읽어 8.3~8.7초(엔티티 정리 1.9 · 씬 읽기 1.6 · 씬 켜짐 3.3 · 첫 프레임 1.0 · 가림 0.7), 처음 가는 맵은 에디터 전용 서브씬 굽기 4~6초 추가. 조치: 로비 씬을 다시 읽지 않고 맵만 바꿔 끼움(`TVFieldTravel.swapInPlace`) + 로딩 화면(`TVLoadingScreen` — Loading... · 진행 막대 · 퍼센트). 검증 [실측]: 1.4~2.0초(처음 가는 맵 5.6초) · 도착 즉시 몬스터 · 플레이어 자리/능력치/분위기 일치 · 보스전 출입 정상 · 예전 방식도 동작 · 에디터 로그 오류 0 | FR `Assets/BurningTimes/Visual/Scripts/TVFieldTravel.cs` · `TVLoadingScreen.cs` · `TVRuntimeFont.cs` · `공유/대화로그/FirstRising/2026-09-30.md` | — | PC 빌드 미검증(맵 서브씬이 빌드에 없음) · 처음 가는 맵의 굽기 시간은 에디터에서만 남음 |
| BT44-FirstRising | 2026-09-30 01:01 | **최소화(위젯) 모드 — 지형 숨김 + 창 크기 조절 메뉴** — 플레이어 · 몬스터 외 지형은 안 보이게, 하단 UI 위쪽에 마우스를 대면 창 크기 조절 슬라이드 메뉴 (원문은 대화로그) | **완료(09-30 02:10) · PD 확인 대기 · FR `bt/work` `4bca428e62` push** — 원인 [실측]: 게임은 최소화 모드에서 WidgetCulling 레이어만 숨기는데 숲 맵만 그 레이어이고 새 테마 맵은 기본 레이어(숲은 정상 · 묘지는 맵 엔티티 1,565 + 꾸러미 렌더러 427 이 보임). 조치: `TVWidgetScenery`(최소화 모드 동안 맵 엔티티 · 지형 · 꾸러미/전용 필드 그리기 끔 → 나올 때 복구) + `TVWidgetSizeMenu`(띠에 마우스 → 막대 → 놓으면 적용 · 저장) + 게임 코드 `DesktopWidgetManager` 2곳(조작 영역 등록 · 원형 리전 설정 따르기 — 날짜/이유 주석). 검증 [실측]: 묘지 최소화 모드에서 캐릭터 · 몬스터 · HUD 만 보임 → 복구 / 150% 적용 시 위젯 440→660 · 게임 화면 471→706 px · 에디터 로그 오류 0 | FR `TVWidgetScenery.cs` · `TVWidgetSizeMenu.cs` · `Assets/Scripts/Manager/DesktopWidgetManager.cs` · `TVBossField.cs` · `LobbyPlayScene.prefab` | — | PC 빌드 미검증(창 크기 변경 · 투명 창 · 커서 읽기는 빌드 전용 경로) · 에디터에서는 게임 화면 영역을 키우고 줄여 미리 보기 |

View File

@ -0,0 +1,12 @@
var path = "Assets/GameAssets/LobbyPlayScene.prefab";
var root = UnityEditor.PrefabUtility.LoadPrefabContents(path);
try {
UnityEngine.Transform bt = null;
foreach (var t in root.GetComponentsInChildren<UnityEngine.Transform>(true)) if (t.name == "BT_Visual") { bt = t; break; }
if (bt == null) return "BT_Visual not found";
var sb = new System.Text.StringBuilder();
if (bt.GetComponent<BurningTimes.Visual.TVLootDrops>() == null) { bt.gameObject.AddComponent<BurningTimes.Visual.TVLootDrops>(); sb.Append("added TVLootDrops; "); } else sb.Append("TVLootDrops exists; ");
if (bt.GetComponent<BurningTimes.Visual.TVItemStatSync>() == null) { bt.gameObject.AddComponent<BurningTimes.Visual.TVItemStatSync>(); sb.Append("added TVItemStatSync; "); } else sb.Append("TVItemStatSync exists; ");
UnityEditor.PrefabUtility.SaveAsPrefabAsset(root, path);
return sb.ToString();
} finally { UnityEditor.PrefabUtility.UnloadPrefabContents(root); }

View File

@ -0,0 +1 @@
var gv = BurningTimes.Visual.TVGlobalValue.Instance; float old = gv.dropChanceNormal; gv.dropChanceNormal = __VALUE__f; return "dropChanceNormal " + old + " -> " + gv.dropChanceNormal + " (asset=" + gv.name + ")";

View File

@ -0,0 +1,13 @@
var names = new[] { "WeaponSkillTable", "RareWeaponSkillTable", "EliteWeaponSkillTable", "LegendWeaponSkillTable", "GradeWeaponSkillTable" };
var sb = new System.Text.StringBuilder();
Fx.BakingHelper.Clear();
foreach (var n in names) {
var json = "Assets/DataTables/" + n + ".json"; var prefab = "Assets/GameAssets/DataTables/" + n + ".prefab";
if (!System.IO.File.Exists(json) || !System.IO.File.Exists(prefab)) { sb.Append(n).Append(": missing; "); continue; }
var go = UnityEditor.PrefabUtility.LoadPrefabContents(prefab);
try { var table = go.GetComponent<Fx.TableBase>(); table.Clear(); table.LoadFromText(System.IO.File.ReadAllText(json)); UnityEditor.EditorUtility.SetDirty(table); UnityEditor.PrefabUtility.SaveAsPrefabAsset(go, prefab); sb.Append(n).Append(": cooked; "); }
finally { UnityEditor.PrefabUtility.UnloadPrefabContents(go); }
}
Fx.FxEntitiesCacheUtility.UpdateEntitySceneGlobalDependency();
UnityEngine.Debug.Log("[BT48] cook bow tables: " + sb);
return sb.ToString();

View File

@ -0,0 +1,10 @@
var sb = new System.Text.StringBuilder();
foreach (var n in new[] { "WeaponSkillTable", "RareWeaponSkillTable", "GradeWeaponSkillTable" }) {
var text = System.IO.File.ReadAllText("Assets/GameAssets/DataTables/" + n + ".prefab");
var go = UnityEditor.AssetDatabase.LoadAssetAtPath<UnityEngine.GameObject>("Assets/GameAssets/DataTables/" + n + ".prefab");
var table = go.GetComponent<WeaponSkillTable>();
int bow = 0, bad = 0;
if (table != null) foreach (var j in table.GetJsons()) foreach (var e in j.skillElement) { if (e.WeaponType == Fx.eWeaponType.Bow || j.skillElement[0].WeaponType == Fx.eWeaponType.Bow) { bow++; if (e.skillBufferElement.spawnCount != 1) bad++; } }
sb.Append(n).Append(" bowRows=").Append(bow).Append(" notOne=").Append(bad).Append("; ");
}
return sb.ToString();

View File

@ -0,0 +1,5 @@
var pm = Fx.PlayerManager.Instance; var sb = new System.Text.StringBuilder(); var done = new System.Collections.Generic.HashSet<int>();
foreach (var kv in pm.player.itemsDic) { var it = kv.Value; if (it == null || it.rawItem == null || it.rawItem.btIlvl <= 0) continue; if (done.Contains(it.rawItem.btPart)) continue; done.Add(it.rawItem.btPart);
Fx.GameHub.Instance.EquipItem(it, (ack) => { UnityEngine.Debug.Log("[BT48] equip ack part=" + it.rawItem.btPart + " result=" + ack.result); if (Fx.MainPageUI.HasInstance()) { var pg = Fx.MainPageUI.Instance.GetPage<Fx.ItemPageUI>(); if (pg != null) pg.UpdatePage(); } });
sb.Append("equip part=").Append((BurningTimes.Visual.TVItemPart)it.rawItem.btPart).Append(" id=").Append(it.rawItem._id).Append("; "); }
return sb.Length > 0 ? sb.ToString() : "nothing to equip";

View File

@ -0,0 +1,6 @@
var pm = Fx.PlayerManager.Instance; Protocol.Item target = null; int wantPart = __PART__;
foreach (var kv in pm.player.itemsDic) { var it = kv.Value; if (it != null && it.rawItem != null && it.rawItem.btIlvl > 0 && it.rawItem.btPart == wantPart && !pm.IsMount(it.rawItem._id)) { target = it; break; } }
if (target == null) return "no item for part " + wantPart;
string result = "pending"; var id = target.rawItem._id;
Fx.GameHub.Instance.EquipItem(target, (ack) => { result = ack.result.ToString(); UnityEngine.Debug.Log("[BT48] equip ack part=" + wantPart + " id=" + id + " result=" + ack.result); if (Fx.MainPageUI.HasInstance()) { var pg = Fx.MainPageUI.Instance.GetPage<Fx.ItemPageUI>(); if (pg != null) pg.UpdatePage(); } });
return "equip part=" + wantPart + " id=" + id + " result=" + result;

View File

@ -0,0 +1 @@
var gv = UnityEditor.AssetDatabase.LoadAssetAtPath<BurningTimes.Visual.TVGlobalValue>("Assets/BurningTimes/Visual/Resources/BT/GlobalValue.asset"); return gv == null ? "no asset" : ("asset dropChanceNormal=" + gv.dropChanceNormal + " dirty=" + UnityEditor.EditorUtility.IsDirty(gv));

View File

@ -0,0 +1,11 @@
var pm = Fx.PlayerManager.Instance; Protocol.Item target = null;
foreach (var kv in pm.player.itemsDic) { var it = kv.Value; if (it != null && it.rawItem != null && it.rawItem.btIlvl > 0 && it.rawItem.btAffixes != null && it.rawItem.btAffixes.Count > 0) { target = it; break; } }
if (target == null) return "no BT item with options";
var icons = UnityEngine.Object.FindObjectsByType<Fx.ItemIconData>(UnityEngine.FindObjectsInactive.Include, UnityEngine.FindObjectsSortMode.None);
Fx.ItemIconData icon = null; foreach (var ic in icons) { if (ic.item != null && ic.item.rawItem != null && ic.item.rawItem._id == target.rawItem._id) { icon = ic; break; } }
if (icon == null) { foreach (var ic in icons) { if (ic.gameObject.name.StartsWith("TVEmptySlot") == false && ic.transform.parent != null && ic.transform.parent.name == "EquipmentGroup") { icon = ic; break; } } if (icon == null) return "no icon cell for item id=" + target.rawItem._id; icon.UISet(target); }
Fx.MainPageUI.Instance.ShowItemInfoPopup(icon, false);
var popup = UnityEngine.Object.FindFirstObjectByType<Fx.ItemInfoPopUp>(); if (popup == null) return "popup not found";
var nf = System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance; var t = typeof(Fx.ItemInfoPopUp);
string name = ((TMPro.TextMeshProUGUI)t.GetField("m_ItemNameText", nf).GetValue(popup)).text; string ab = ((TMPro.TextMeshProUGUI)t.GetField("m_ItemAbilityText", nf).GetValue(popup)).text; string desc = ((TMPro.TextMeshProUGUI)t.GetField("m_ItemDescText", nf).GetValue(popup)).text;
return "INFO id=" + target.rawItem._id + " active=" + popup.gameObject.activeInHierarchy + " name=[" + name + "] ability=[" + ab + "] desc=[" + desc.Replace("\n", " / ") + "]";

View File

@ -0,0 +1,4 @@
var popup = UnityEngine.Object.FindFirstObjectByType<Fx.ItemInfoPopUp>(); if (popup == null) return "popup not found";
var nf = System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance; var t = typeof(Fx.ItemInfoPopUp);
string name = ((TMPro.TextMeshProUGUI)t.GetField("m_ItemNameText", nf).GetValue(popup)).text; string ab = ((TMPro.TextMeshProUGUI)t.GetField("m_ItemAbilityText", nf).GetValue(popup)).text; string desc = ((TMPro.TextMeshProUGUI)t.GetField("m_ItemDescText", nf).GetValue(popup)).text;
return "INFO2 active=" + popup.gameObject.activeInHierarchy + " name=[" + name + "] ability=[" + ab + "] desc=[" + desc.Replace("\n", " / ") + "]";

View File

@ -0,0 +1,4 @@
var sb = new System.Text.StringBuilder(); var pm = Fx.PlayerManager.Instance;
foreach (var kv in pm.player.itemsDic) { var it = kv.Value; if (it == null || it.rawItem == null || it.rawItem.btIlvl <= 0) continue;
sb.Append("BT id=").Append(it.rawItem._id).Append(" base=").Append(it.itemJson.Index).Append("/").Append(it.itemJson.EquipmentType).Append(" part=").Append((BurningTimes.Visual.TVItemPart)it.rawItem.btPart).Append(" sub=").Append((BurningTimes.Visual.TVSubEquipKind)it.rawItem.btSubKind).Append(" ilvl=").Append(it.rawItem.btIlvl).Append(" grade=").Append(it.rawItem.btGrade).Append(" mounted=").Append(pm.IsMount(it.rawItem._id)).Append(" :: ").Append(BurningTimes.Visual.TVItemText.Summary(it.rawItem).Replace("\n", " / ")).Append("\n"); }
return sb.Length > 0 ? sb.ToString() : "no BT items";

View File

@ -0,0 +1,9 @@
var ld = BurningTimes.Visual.TVLootDrops.Instance; if (ld == null) return "no loot"; var p = Fx.Player.Instance; var basePos = p != null ? p.transform.position : UnityEngine.Vector3.zero;
int d0 = ld.dropCount; var rnd = new System.Random(7);
for (int i = 0; i < 300; i++) { var a = rnd.NextDouble() * 6.283; ld.SimulateKill(Fx.eCharacterEntityType.Monster, basePos + new UnityEngine.Vector3((float)System.Math.Cos(a) * 6f, 0f, (float)System.Math.Sin(a) * 6f)); }
int normalDrops = ld.dropCount - d0; int d1 = ld.dropCount;
for (int i = 0; i < 100; i++) { var a = rnd.NextDouble() * 6.283; ld.SimulateKill(Fx.eCharacterEntityType.EliteMonster, basePos + new UnityEngine.Vector3((float)System.Math.Cos(a) * 6f, 0f, (float)System.Math.Sin(a) * 6f)); }
int eliteDrops = ld.dropCount - d1; int d2 = ld.dropCount;
for (int i = 0; i < 5; i++) { ld.SimulateKill(Fx.eCharacterEntityType.BossMonster, basePos + new UnityEngine.Vector3(6f, 0f, 0f)); }
int bossDrops = ld.dropCount - d2;
return "sim: normal 300 -> " + normalDrops + " drops(expect ~9) · elite 100 -> " + eliteDrops + " (expect ~25) · boss 5 -> " + bossDrops + " (expect 15) · ground=" + ld.groundCount;

View File

@ -0,0 +1,4 @@
var ld = BurningTimes.Visual.TVLootDrops.Instance; if (ld == null) return "no loot";
int[] grades = { 2, 4, 6, 8, 10, 3, 5, 7 };
for (int i = 1; i <= 8; i++) ld.SpawnTest((BurningTimes.Visual.TVItemPart)i, 10 + i, grades[i - 1], 0.3f);
return "spawned 8 test drops next to player; ground=" + ld.groundCount;

View File

@ -0,0 +1,21 @@
var sb = new System.Text.StringBuilder();
var ld = BurningTimes.Visual.TVLootDrops.Instance;
sb.Append("loot=").Append(ld != null);
if (ld != null) sb.Append(" kills=").Append(ld.killCount).Append(" drops=").Append(ld.dropCount).Append(" pickups=").Append(ld.pickupCount).Append(" ground=").Append(ld.groundCount).Append(" area=").Append(ld.areaLevel);
var ss = UnityEngine.Object.FindFirstObjectByType<BurningTimes.Visual.TVItemStatSync>();
sb.Append(" sync=").Append(ss != null);
if (ss != null) sb.Append(" appliedDef=").Append(ss.appliedDefence).Append(" writes=").Append(ss.writeCount);
var pm = Fx.PlayerManager.Instance;
if (pm != null && pm.player != null) {
int bt = 0; foreach (var kv in pm.player.itemsDic) if (kv.Value != null && kv.Value.rawItem != null && kv.Value.rawItem.btIlvl > 0) bt++;
sb.Append(" btItems=").Append(bt).Append(" equipped=").Append(pm.player.user.equipItems.Count);
sb.Append(" calcAtk=").Append(pm.CalcAttack()).Append(" calcDef=").Append(pm.CalcDefence()).Append(" calcHp=").Append(pm.CalcHealth());
}
var w = Unity.Entities.World.DefaultGameObjectInjectionWorld; var p = Fx.Player.Instance;
if (w != null && w.IsCreated && p != null && p.characterEntity != Unity.Entities.Entity.Null && w.EntityManager.Exists(p.characterEntity)) {
var em = w.EntityManager; var d = em.GetComponentData<Fx.DefenceComponent>(p.characterEntity); var h = em.GetComponentData<Fx.HealthComponent>(p.characterEntity);
sb.Append(" | ECS def=").Append(d.btDefence).Append(" s=").Append(d.btCurveS).Append(" max=").Append(d.btCurveMax).Append(" eva=").Append(d.btEvasion.ToString("F2")).Append(" blk=").Append(d.btBlock.ToString("F2")).Append(" res=").Append(d.btMeleeResist.ToString("F2")).Append("/").Append(d.btRangedResist.ToString("F2")).Append("/").Append(d.btHolyResist.ToString("F2")).Append(" flatRed=").Append(d.btFlatReduce).Append(" legacyRate=").Append(d.defenceRate.ToString("F3"));
sb.Append(" | atk ranged=").Append(h.btRanged).Append(" wskill=").Append(h.btWeaponSkillId).Append(" flat=").Append(h.btFlatDamage).Append(" holy=").Append(h.btHolyRate.ToString("F2")).Append(" pierce=").Append(h.btPierceRate.ToString("F2")).Append(" m/r/s=").Append(h.btMeleeDamageRate.ToString("F2")).Append("/").Append(h.btRangedDamageRate.ToString("F2")).Append("/").Append(h.btSkillDamageRate.ToString("F2")).Append(" hp=").Append(h.health).Append("/").Append(h.healthMax);
if (p != null) sb.Append(" chAtk=").Append(p.CalcFinalAttack()).Append(" chHpMax=").Append(p.CalcFinalHealthMax());
}
return sb.ToString();

View File

@ -0,0 +1,12 @@
var pm = Fx.PlayerManager.Instance; Protocol.Item target = null;
foreach (var kv in pm.player.itemsDic) { var it = kv.Value; if (it != null && it.rawItem != null && it.rawItem.btIlvl > 0) { target = it; if (it.rawItem.btAffixes != null && it.rawItem.btAffixes.Count > 0) break; } }
if (target == null) return "no BT item";
var icons = UnityEngine.Object.FindObjectsByType<Fx.ItemIconData>(UnityEngine.FindObjectsInactive.Include, UnityEngine.FindObjectsSortMode.None);
Fx.ItemIconData icon = null; foreach (var ic in icons) { if (ic.item != null && ic.item.rawItem != null && ic.item.rawItem._id == target.rawItem._id) { icon = ic; break; } }
if (icon == null) return "no icon for id=" + target.rawItem._id;
Fx.MainPageUI.Instance.ShowItemInfoPopup(icon, false);
var popup = UnityEngine.Object.FindFirstObjectByType<Fx.ItemInfoPopUp>(); var nf = System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance; var t = typeof(Fx.ItemInfoPopUp);
string name = ((TMPro.TextMeshProUGUI)t.GetField("m_ItemNameText", nf).GetValue(popup)).text; string ab = ((TMPro.TextMeshProUGUI)t.GetField("m_ItemAbilityText", nf).GetValue(popup)).text; string desc = ((TMPro.TextMeshProUGUI)t.GetField("m_ItemDescText", nf).GetValue(popup)).text;
var rows = (Fx.ItemDescListItem[])t.GetField("m_ItemDescListItems", nf).GetValue(popup); var sb = new System.Text.StringBuilder();
foreach (var r in rows) { var tx = (TMPro.TextMeshProUGUI)typeof(Fx.ItemDescListItem).GetField("m_DescText", nf).GetValue(r); sb.Append(r.gameObject.activeSelf ? "[" + tx.text + "]" : "[숨김]"); }
return "INFO id=" + target.rawItem._id + " name=[" + name + "] ability=[" + ab + "] desc=[" + desc.Replace("\n", " / ") + "] rows=" + sb;

View File

@ -0,0 +1,8 @@
var sb = new System.Text.StringBuilder(); var ld = BurningTimes.Visual.TVLootDrops.Instance; var pm = Fx.PlayerManager.Instance; var p = Fx.Player.Instance;
sb.Append("migrated=").Append(ld != null ? ld.migratedCount : -1).Append(" kills=").Append(ld != null ? ld.killCount : -1).Append(" drops=").Append(ld != null ? ld.dropCount : -1).Append(" pickups=").Append(ld != null ? ld.pickupCount : -1);
sb.Append(" dropChanceNormal=").Append(BurningTimes.Visual.TVGlobalValue.Instance.dropChanceNormal);
if (p != null && p.weaponSkillElements != null && p.weaponSkillElements.Count > 0) sb.Append(" weaponId=").Append(p.attachedWeaponId).Append(" skill=").Append(p.attachedWeaponSkillId).Append(" spawnCount=").Append(p.weaponSkillElements[0].skillBufferElement.spawnCount).Append("+").Append(p.weaponSkillElements[0].skillBufferElement.addSpawnCount);
sb.Append(" calcAtk=").Append(pm.CalcAttack()).Append(" calcDef=").Append(pm.CalcDefence()).Append(" calcHp=").Append(pm.CalcHealth()).Append("\n");
foreach (var kv in pm.player.itemsDic) { var it = kv.Value; if (it == null || it.itemJson == null || it.itemJson.IsEquip != 1) continue;
sb.Append("EQUIP id=").Append(it.rawItem._id).Append(" base=").Append(it.itemJson.Index).Append("/").Append(it.itemJson.EquipmentType).Append("/").Append(it.itemJson.ItemGrade).Append(" bt=").Append(it.rawItem.btIlvl > 0).Append(" mounted=").Append(pm.IsMount(it.rawItem._id)).Append(" :: ").Append(BurningTimes.Visual.TVItemText.Summary(it.rawItem).Replace("\n", " / ")).Append("\n"); }
return sb.ToString();

View File

@ -0,0 +1,6 @@
var go = UnityEditor.AssetDatabase.LoadAssetAtPath<UnityEngine.GameObject>("Assets/GameAssets/LobbyPlayScene.prefab");
if (go == null) return "prefab null";
UnityEngine.Transform bt = null;
foreach (var t in go.GetComponentsInChildren<UnityEngine.Transform>(true)) if (t.name == "BT_Visual") { bt = t; break; }
if (bt == null) return "BT_Visual not found";
return "loot=" + (bt.GetComponent<BurningTimes.Visual.TVLootDrops>() != null) + " statSync=" + (bt.GetComponent<BurningTimes.Visual.TVItemStatSync>() != null) + " gv=" + (UnityEngine.Resources.Load<BurningTimes.Visual.TVGlobalValue>("BT/GlobalValue") != null) + " db=" + (UnityEngine.Resources.Load<BurningTimes.Visual.TVItemDatabase>("BT/ItemDatabase") != null);

View File

@ -0,0 +1,9 @@
var dir = "Assets/BurningTimes/Visual/Resources/BT";
if (!System.IO.Directory.Exists(dir)) { System.IO.Directory.CreateDirectory(dir); UnityEditor.AssetDatabase.Refresh(); }
var sb = new System.Text.StringBuilder();
var gvPath = dir + "/GlobalValue.asset";
if (UnityEditor.AssetDatabase.LoadAssetAtPath<BurningTimes.Visual.TVGlobalValue>(gvPath) == null) { var gv = UnityEngine.ScriptableObject.CreateInstance<BurningTimes.Visual.TVGlobalValue>(); UnityEditor.AssetDatabase.CreateAsset(gv, gvPath); sb.Append("GlobalValue created; "); } else sb.Append("GlobalValue exists; ");
var dbPath = dir + "/ItemDatabase.asset";
if (UnityEditor.AssetDatabase.LoadAssetAtPath<BurningTimes.Visual.TVItemDatabase>(dbPath) == null) { var db = UnityEngine.ScriptableObject.CreateInstance<BurningTimes.Visual.TVItemDatabase>(); UnityEditor.AssetDatabase.CreateAsset(db, dbPath); sb.Append("ItemDatabase created(affixes=" + db.affixes.Count + "); "); } else sb.Append("ItemDatabase exists; ");
UnityEditor.AssetDatabase.SaveAssets();
return sb.ToString();

View File

@ -0,0 +1,22 @@
$proj = 'C:\BurningTimes\FirstRising'; $dir = Join-Path $env:TEMP 'bt_evals'; $logFile = Join-Path $dir 'run_bt48_v3.log'
function Log($m) { $line = "{0:HH:mm:ss} {1}" -f (Get-Date), $m; $line; Add-Content -Path $logFile -Value $line -Encoding UTF8 }
function Ev($file, $timeout = 30) { $r = unity command eval_file --project-path $proj --file (Join-Path $dir $file) --timeout $timeout --json 2>&1 | Out-String; try { $j = $r | ConvertFrom-Json; if ($j.data) { return [string]$j.data.result.result } else { return "ERR " + (($j.errors | ForEach-Object { $_.message }) -join '; ') } } catch { $t = ($r -replace '\s+', ' '); return "RAW " + $t.Substring(0, [Math]::Min(300, $t.Length)) } }
function Lines($tag, $text) { foreach ($l in ($text -split "`n")) { if ($l.Trim()) { Log ("[$tag] " + $l.Trim()) } } }
Set-Content -Path $logFile -Value '' -Encoding UTF8
python 'E:\BurningTimes\공유\기획\FirstRising\몬스터보스\tools\unity_eval\logscan.py' mark | ForEach-Object { Log $_ }
Log ("play: " + ((unity command editor_play --project-path $proj --json 2>&1 | Out-String) -replace '\s+', ' ').Substring(0, 100))
Start-Sleep 8
$deadline = (Get-Date).AddSeconds(300)
do { Start-Sleep 3; $s = Ev 'poll.cs' } while (-not ($s -match 'lobby=True' -and $s -match 'login=False' -and $s -match 'mainPage=True' -and $s -match 'player=True') -and (Get-Date) -lt $deadline)
Log ("lobby ready: " + $s)
Start-Sleep 12
Lines 'A 시작' (Ev 'bt48_v3_state.cs')
Log ("[B 장비 창] " + (Ev 'open_equipment.cs')); Start-Sleep 3
Log ("[B 정보 창] " + (Ev 'bt48_v3_info.cs'))
Log ("[C 실전] " + (Ev 'tp_to_monster.cs')); Start-Sleep 30
Log ("[C 재이동] " + (Ev 'tp_to_monster.cs')); Start-Sleep 30
Lines 'C 전투 뒤' (Ev 'bt48_v3_state.cs')
Log ("stop: " + ((unity command editor_stop --project-path $proj --json 2>&1 | Out-String) -replace '\s+', ' ').Substring(0, 80))
Start-Sleep 6
python 'E:\BurningTimes\공유\기획\FirstRising\몬스터보스\tools\unity_eval\logscan.py' scan bt48v3 | ForEach-Object { Log $_ }
Log 'DONE'

View File

@ -0,0 +1,58 @@
$proj = 'C:\BurningTimes\FirstRising'
$dir = Join-Path $env:TEMP 'bt_evals'
$shots = 'C:\Users\sw\AppData\Local\Temp\claude\E--BurningTimes\87b07a14-ef05-46d9-b47a-a879d51eee93\scratchpad\bt48_shots'
New-Item -ItemType Directory -Force $shots | Out-Null
$logFile = Join-Path $dir 'run_bt48_verify.log'
function Log($m) { $line = "{0:HH:mm:ss} {1}" -f (Get-Date), $m; $line; Add-Content -Path $logFile -Value $line -Encoding UTF8 }
function Ev($file, $timeout = 30) {
$r = unity command eval_file --project-path $proj --file (Join-Path $dir $file) --timeout $timeout --json 2>&1 | Out-String
try { $j = $r | ConvertFrom-Json; if ($j.data) { return [string]$j.data.result.result } else { return "ERR " + (($j.errors | ForEach-Object { $_.message }) -join '; ') } }
catch { $t = ($r -replace '\s+', ' '); return "RAW " + $t.Substring(0, [Math]::Min(300, $t.Length)) }
}
function Shot($name) {
$r = unity command capture_game_view --project-path $proj --source screen --width 1080 --height 1920 --json 2>&1 | Out-String
if ($r -match '"([A-Za-z0-9+/=]{2000,})"') { [IO.File]::WriteAllBytes((Join-Path $shots $name), [Convert]::FromBase64String($matches[1])); return "shot $name saved" } else { $t = ($r -replace '\s+', ' '); return "shot failed: " + $t.Substring(0, [Math]::Min(200, $t.Length)) }
}
function Alive() { return ((Get-Process Unity -ErrorAction SilentlyContinue | Where-Object { $_.MainWindowTitle -like 'FirstRising*' }) -ne $null) }
function Lines($tag, $text) { foreach ($l in ($text -split "`n")) { if ($l.Trim()) { Log ("[$tag] " + $l.Trim()) } } }
Set-Content -Path $logFile -Value '' -Encoding UTF8
python 'E:\BurningTimes\공유\기획\FirstRising\몬스터보스\tools\unity_eval\logscan.py' mark | ForEach-Object { Log $_ }
Log ("play: " + ((unity command editor_play --project-path $proj --json 2>&1 | Out-String) -replace '\s+', ' ').Substring(0, 100))
Start-Sleep 8
$deadline = (Get-Date).AddSeconds(240)
do { Start-Sleep 3; $s = Ev 'poll.cs'; if (-not (Alive)) { Log 'EDITOR GONE'; exit 2 } }
while (-not ($s -match 'lobby=True' -and $s -match 'login=False' -and $s -match 'mainPage=True' -and $s -match 'player=True') -and (Get-Date) -lt $deadline)
Log ("lobby ready: " + $s)
Start-Sleep 10
Log ("[A 시작] " + (Ev 'bt48_state.cs'))
Log ("[B 시험 드롭] " + (Ev 'bt48_spawn.cs'))
Start-Sleep 3
Log ("[B 습득 뒤] " + (Ev 'bt48_state.cs'))
Lines 'B 가방' (Ev 'bt48_inv.cs')
Log (Shot 'B_pickup.png')
foreach ($part in 1..8) { $code = (Get-Content (Join-Path $dir 'bt48_equip_one.cs') -Raw -Encoding UTF8) -replace '__PART__', $part; [IO.File]::WriteAllText((Join-Path $dir 'tmp_equip_one.cs'), $code, (New-Object System.Text.UTF8Encoding $false)); Log ("[C 장착] " + (Ev 'tmp_equip_one.cs')); Start-Sleep 2 }
Start-Sleep 3
Log ("[C 장착 뒤] " + (Ev 'bt48_state.cs'))
Lines 'C 가방' (Ev 'bt48_inv.cs')
Log ("[D 확률 모의] " + (Ev 'bt48_sim.cs' 60))
Start-Sleep 2
Log (Shot 'D_ground.png')
$c100 = (Get-Content (Join-Path $dir 'bt48_chance.cs') -Raw -Encoding UTF8) -replace '__VALUE__', '100'; [IO.File]::WriteAllText((Join-Path $dir 'tmp_chance100.cs'), $c100, (New-Object System.Text.UTF8Encoding $false)); Log ("[E 확률 100%] " + (Ev 'tmp_chance100.cs'))
Log ("[E 실전] " + (Ev 'tp_to_monster.cs'))
$t0 = Get-Date
do { Start-Sleep 5; $st = Ev 'bt48_state.cs'; if (-not (Alive)) { Log 'EDITOR GONE'; exit 2 } } while (-not ($st -match 'kills=[1-9]') -and ((Get-Date) - $t0).TotalSeconds -lt 90)
Log ("[E 전투 뒤] " + $st)
Log ("[E 재이동] " + (Ev 'tp_to_monster.cs')); Start-Sleep 25
Log ("[E 전투 뒤 2] " + (Ev 'bt48_state.cs'))
Log (Shot 'E_field.png')
Log ("[F 장비 창] " + (Ev 'open_equipment.cs')); Start-Sleep 3
Log ("[F 정보 창] " + (Ev 'bt48_info.cs')); Start-Sleep 2
Log ("[F 정보 창 2] " + (Ev 'bt48_info_read.cs'))
Log (Shot 'F_info.png')
$c3 = (Get-Content (Join-Path $dir 'bt48_chance.cs') -Raw -Encoding UTF8) -replace '__VALUE__', '3'; [IO.File]::WriteAllText((Join-Path $dir 'tmp_chance3.cs'), $c3, (New-Object System.Text.UTF8Encoding $false)); Log ("[G 확률 원복] " + (Ev 'tmp_chance3.cs'))
Log ("[G 끝] " + (Ev 'bt48_state.cs'))
Log ("stop: " + ((unity command editor_stop --project-path $proj --json 2>&1 | Out-String) -replace '\s+', ' ').Substring(0, 80))
Start-Sleep 6
python 'E:\BurningTimes\공유\기획\FirstRising\몬스터보스\tools\unity_eval\logscan.py' scan bt48 | ForEach-Object { Log $_ }
Log ("[자산 확인] " + (Ev 'bt48_gv_check.cs'))
Log 'DONE'

View File

@ -0,0 +1,387 @@
# FR 아이템 드랍 시스템 초안 v0 (2026-10-01 · BT47-FirstRising)
> PD님 지시(2026-10-01): 디아블로식 등급 아이템 드랍 · 등급 10단계 · 부위 8 · 고정 스텟 + 랜덤 옵션 · 몬스터 레벨이 드랍 범위를 결정 · 획득 찬스가 상위 등급 확률을 올림.
> 이 문서는 **초안**이다. PD님이 보완해 확정한다. §0 은 현 코드 실측, §1~§6 은 제안, §7 은 질문·보완 의견.
> 표기: **[실측]** 코드에서 확인 · **[제안]** 설계안 · **[미검증]** 아직 안 본 것.
---
## 0. 현 게임 구조 실측 (바꿔야 할 것과 그대로 쓸 것)
### 0-1. 아이템 · 옵션
| 항목 | 현재 [실측] | 판단 |
|---|---|---|
| 아이템 인스턴스 `RawItem` | `{index, level, count, _id}` 만. **개별 랜덤 옵션 없음** | 확장 필요(§4) |
| 저장 | `User` 객체를 Newtonsoft JSON 으로 파일 저장(`DBImpl`) | `RawItem` 에 필드를 더하면 그대로 저장됨 → 저장 계층 수정 불필요 |
| 등급 `eItemGrade` | 6단계 Normal·Magic·Rare·Elite·Epic·Legend | 10단계로 확장 |
| 부위 `eEquipmentType` | 6 — Weapon·Necklace·Glove·Armor·Belt·Shose | **보조 장비·반지 신규** → 8 |
| 아이템 정의 `ItemTable` | 702행. 장비는 부위×등급×`GroupNo`(옵션 묶음) 조합으로 한 행씩 미리 만들어 둠(부위별 5~28종) | 기본 정의는 유지, 랜덤 옵션은 인스턴스로 이동 |
| 기본 능력치 | `Item_Weapon/Amor/Belt/Gloves/Necklace/Shoes.json`(부위별 1160행) — 강화 레벨별 공격/방어/체력 정수 | "고정 스텟" 원형으로 재사용 가능 |
| 옵션 `ItemOptionTable` | `GroupNo` 별 **고정** 옵션(등급 이하 누적) 140행 | 랜덤 옵션 풀 테이블 신규(§3) |
| 능력치 종류 `eAbility` | 21종, 대부분 % 배율. `int[eAbility.Max]` 배열 인덱스로 합산 | **끝에만 추가**(중간 삽입 금지) |
### 0-2. 능력치 흐름 (신규 스텟이 지나갈 길)
```
ItemOptionTable / 진화 → PlayerManager.GetTotalAbilities (int[] 합산)
→ Character.ApplyAbility (m_AbilityValues · CalcAttack/Defence/Health)
→ PlayerEntityAbilitySyncSystem (case eAbility 13개 → ECS 컴포넌트)
→ HealthComponent(치명타·액설런트 확률/피해) · DefenceComponent(defenceRate·decreaseRate)
→ FxComponentPlay.HitEntity (주는 피해) · DamageSystem (받는 피해)
```
**피해 공식 [실측]**
- 주는 피해: `스킬 baseDamage × damageRate × (1 + 공격%합)` → 첫타(HP 100%) ×2 → 액설런트 ×5(+액설런트 피해%) 또는 치명타 ×2(+치명타 피해%)
- 받는 피해: `× defenceRate` (= `1 − 방어력/(1000+방어력)`) → `× (1 − 피해감소%)` → 보호막 있으면 1회 무효
- 피해에 **속성(물리/마법)·거리(근접/원거리)·출처(기본공격/스킬) 태그가 없고, 방어를 무시하는 별도 피해 줄기(신성)도 없다**
### 0-3. 드롭 흐름 [실측]
```
MonsterJson.DropGroup_ID → ItemDropGroup(Rate 만분율 × (1 + IncreaseDrop)) → ItemDropPackage(Item_ID 고정 · Rate 가중)
→ Burst 잡(SpawnDropItemSystem) 에서 결정 → 바닥 드롭 엔티티(itemIndex, count)
→ 줍기 → GetMonsterDropItemBufferElement → Character.AddMonsterDropItem(집계)
→ 정산 시 NetPlayer.AddItem(itemId, level=1, count)
```
- **몬스터 레벨 개념 없음** — `MonsterJson` 은 Health·Speed 고정, 레벨 필드 없음. 맵(`MapTable`)·구역 단위로 대체 가능(§2)
- 드롭 확률 보정 `IncreaseDrop` 은 **경험치·골드·아이템이 한 값** → 분리 필요(§7)
### 0-4. 결론 — 변경 규모
| 구분 | 내용 | 규모 |
|---|---|---|
| 신규 스텟 "추가만"으로 되는 것 | % 배율 계열(공격력·최대 생명력·이동속도·쿨타임·치명타·범위·투사체 속도·회복·경험치/골드/아이템 획득·버프 지속·구르기) | `eAbility` 끝에 추가 + 합산 경로 그대로 |
| 합산은 그대로, 적용 지점 1~2곳 추가 | 회피·막기·피해 감소량·고정 피해·고정 피해 감소·타격당/처치시 회복·흡혈·상태이상 저항 | `HitEntity`·`DamageSystem` 각 1곳 |
| **구조 추가가 필요한 것** | 물리/마법 저항, 근접/원거리/스킬 피해 증가 → **피해에 속성 태그** 신설(스킬 테이블 열 + `FireSkill`/`TakeDamage` 필드) | 중간 |
| | 신성 피해(PD 확정 10-01: 거리 구분 없이 모든 타격에 얹히는 **방어 무시 순수 피해**, 신성 저항만 적용) → 타격당 피해를 "일반 + 신성" 두 줄기로 나눠 전달 | 소~중 |
| | 투사체 수·관통·공격 범위·기본 공격 사거리 → 스킬 모듈(`FxComponent`) 파라미터 노출 | 중간 |
| | 공격 속도 → 발동 주기 구조 실측 필요 [미검증] | 미정 |
| 데이터 구조 | `RawItem` 옵션 리스트 · 등급 10 · 부위 8 · 몬스터(구역) 레벨 · 랜덤 옵션 풀 테이블 | 중간 |
**판정: 대대적 개편은 아니다.** 기존 합산·저장·드롭 파이프라인을 유지한 채 (1) 인스턴스 옵션 (2) 피해 속성 태그 (3) enum 확장 세 가지를 얹는 "중간 규모 확장"이다. 단, `eAbility` 는 배열 인덱스이므로 순서 변경·중간 삽입은 세이브 호환을 깨므로 금지한다.
---
## 1. 등급 10단계 [제안]
**등급 = 성능 차이만** (PD 확정 10-01: 세트는 별도 시스템, 등급에 고정 세트·고유 옵션을 두지 않는다). 성능 차이는 세 축 — 고정 스텟 계수 · 랜덤 옵션 수 · 옵션 값 티어(같은 옵션이라도 상위 등급은 값 구간이 높다).
| 등급 | 이름(안) | 색 | 고정 스텟 계수 | 랜덤 옵션 수 | 옵션 값 티어 | 기본 가중치(만분율) | 최저 출현 몬스터 레벨 |
|---|---|---|---|---|---|---|---|
| 1 | 일반 Common | 회색 | 1.00 | 0 | — | 5000 | 1 |
| 2 | 고급 Uncommon | 흰색 | 1.10 | 1 | 1 | 2500 | 1 |
| 3 | 마법 Magic | 파랑 | 1.20 | 2 | 1 | 1300 | 3 |
| 4 | 희귀 Rare | 노랑 | 1.35 | 3 | 2 | 700 | 8 |
| 5 | 정예 Elite | 초록 | 1.50 | 3 | 2 | 300 | 15 |
| 6 | 영웅 Epic | 보라 | 1.70 | 4 | 3 | 120 | 25 |
| 7 | 전설 Legend | 주황 | 1.90 | 4 (조건부 옵션 허용) | 3 | 50 | 35 |
| 8 | 신화 Mythic | 빨강 | 2.15 | 5 (조건부 옵션 허용) | 4 | 20 | 50 |
| 9 | 태고 Primal | 청록 | 2.40 | 5 (조건부 옵션 허용) | 4 | 7 | 65 |
| 10 | 유일 Unique | 금색 | 2.70 | 6 (조건부 옵션 허용) | 5 | 3 | 80 |
- "조건부 옵션 허용" = 기존 `ConditionalItemOptionAbility` 36종(처치 시 공격력 ↑·보호막 등)이 그 등급부터 랜덤 옵션 풀에 섞인다는 뜻이지 고정 부여가 아니다.
- 기존 6등급(Normal~Legend)은 1·3·4·5·6·7 로 **번호를 옮기지 않고** 새 enum 을 뒤에 붙여 매핑 표로 잇는다(세이브 호환).
- 계수·가중치·최저 레벨은 **자리표시 값**이다. 밸런스 확정은 스탯 수치 설계에서.
---
## 2. 드롭 결정 흐름 [제안]
몬스터가 죽을 때 한 번에 결정한다. 순서:
```
① 드롭 여부 : 몬스터 종류별 기본 확률(일반 3% · 정예 25% · 보스 100% × N개) × (1 + 아이템 획득 찬스)
② 부위 : 8부위 균등(보스는 가중치 표 별도)
③ 아이템 레벨 ilvl : 몬스터 레벨 ± 2 (보스 +3) → 고정 스텟 크기의 기준
④ 등급 : 등급 가중치 × 레벨 게이트 × 획득 찬스 보정 → 누적 확률 추첨
⑤ 기본 정의 선택 : ItemTable 에서 (부위, 등급) 후보 중 랜덤 → index
⑥ 랜덤 옵션 : 등급별 옵션 수만큼 옵션 풀(부위 허용 · ilvl 구간)에서 중복 없이 추첨, 값은 구간 내 랜덤
⑦ 인스턴스 생성 : RawItem{index, level=1(강화), count=1, _id, ilvl, grade, options[]}
```
### 2-1. 몬스터 레벨
- `MonsterJson` 에 레벨이 없으므로 **구역(맵) 레벨**을 쓴다: `MapTable` 에 `AreaLevel` 열 추가, 몬스터는 `AreaLevel + 종류 보정(일반 0 · 정예 +2 · 보스 +5)`.
- 보스전 전용 필드(BT27~34)는 보스 정의에 레벨을 직접 둔다.
### 2-2. 획득 찬스가 상위 등급을 올리는 공식
등급 `g` 의 가중치 `W_g` 에 대해
```
W'_g = W_g × (1 + chance × k_g) k_g = (g − 1) / 9 (1등급 0 … 10등급 1)
```
- 획득 찬스 100% 면 10등급 가중치는 2배, 5등급은 1.44배, 1등급은 그대로 → 정규화 후 상위로 분포가 이동한다.
- 레벨 게이트: `몬스터 레벨 < 최저 출현 레벨` 이면 `W'_g = 0`. 단 보스는 게이트를 1단계 완화.
- 드랍 여부(①)에도 같은 `chance` 를 곱하므로 "더 자주 + 더 좋은" 두 축이 함께 오른다. 둘을 분리하고 싶으면 ①에는 `chance × 0.5` 를 쓴다(PD 결정).
### 2-3. 결정 위치
- 현재 드롭은 Burst 잡 안에서 결정하지만 옵션 추첨은 테이블 참조가 많아 **관리 코드(C#)** 에서 한다.
- 방법: 잡은 지금처럼 "장비 드롭 발생(부위·ilvl·등급까지)"만 결정해 바닥에 떨어뜨리고, **줍는 순간**(`GetMonsterDropItemBufferElement` 처리 지점) 에 옵션을 굴려 인스턴스를 만든다. 바닥에 떨어진 상태에서는 등급 색·부위 아이콘만 필요하므로 충분하다.
- 장비 드롭용 `eDropItemType.Equipment` 를 추가하고, 드롭 엔티티에 `(equipmentType, grade, ilvl)` 을 실어 보낸다.
---
## 3. 부위 8종 · 고정 스텟 · 랜덤 옵션 풀 [제안]
### 3-1. 부위별 고정 스텟과 랜덤 옵션 풀 (PD 확정 2026-10-01: 모든 아이템은 고정1 하나 · 랜덤 옵션은 부위 성격에 맞는 옵션 풀에서)
아이템 하나 = **고정1**(부위가 정함, 항상 1개) + **랜덤 옵션 N개**(등급이 개수를 정함 §1, 값은 등급 티어). 랜덤 옵션은 전체 풀이 아니라 **그 부위의 옵션 풀**에서만 뽑는다. 아래 풀은 [제안]이며 PD님이 보완한다.
| 부위 | enum(안) | 고정1 (PD 확정) | 랜덤 옵션 풀 [제안 — 등급별 N개를 이 중에서] | 비고 |
|---|---|---|---|---|
| 무기 | Weapon(기존) | 공격력 | **공격력†** · 고정 피해 · 공격 속도% · 치명타 확률 · 치명타 피해 · 근접 공격력% · 원거리 공격력% | 근접/원거리 공격력% 는 무기 종류에 맞는 쪽만(검·둔기·단검 → 근접, 활 → 원거리, 지팡이·마법 단검 → 스킬 피해로 대체) |
| 보조 장비 | **SubEquip(신규)** | 종류별(§3-2) | 종류별(§3-2) | 무기가 허용하는 종류만 장착 |
| 갑옷 | Armor(기존) | 방어력 | **방어력†** · 최대 생명력 · 근접 저항 · 원거리 저항 · 피해 감소량 · 고정 피해 감소 · 상태이상 저항 | 몸통 = 버티기 |
| 벨트 | Belt(기존) | 생명력 | **생명력†** · 초당 회복량 · 회복 효과 증가량 · 처치 시 생명력 회복 · 타격 당 생명력 회복 · 흡혈 효과 증가량 | 허리 = 회복 |
| 장갑 | Glove(기존) | 방어력 | **방어력†** · 공격 속도% · 치명타 확률 · 근접 공격력% · 원거리 공격력% · 투사체 속도 · 타격 당 생명력 회복 | 손 = 손놀림(공격 보조) |
| 부츠 | Shose(기존) | 생명력 | **생명력†** · 이동 속도 · 회피 확률 · 구르기 재사용 시간 · 상태이상 저항 · 고정 피해 감소 | 발 = 기동·회피 |
| 반지 | **Ring(신규)** | 치명타 확률% / 공격 속도% / 스킬 쿨타임% / 치명타 피해 **중 랜덤** | **치명타 확률%† · 공격 속도%† · 스킬 쿨타임%† · 치명타 피해†** · 근접 피해 증가량 · 원거리 피해 증가량 · 스킬 피해 증가량 · 신성 피해 증가량 · 고정 피해 · 관통 피해량 · 범위 공격 데미지 · 기본 공격 사거리 · 경험치 획득량 · 골드 획득량 · 아이템 획득 찬스 · 모든 스킬 레벨 | 반지 = 공격·성장 장신구 · 슬롯 1 |
| 목걸이 | Necklace(기존) | 생명력% / 공격력% / 방어력% / 스킬 피해량% **중 랜덤** | **생명력%† · 공격력%† · 방어력%† · 스킬 피해량%†** · 근접 저항 · 원거리 저항 · 신성 저항 · 경험치 획득량 · 골드 획득량 · 아이템 획득 찬스 · 모든 스킬 레벨 · 버프 지속 시간 | 목걸이 = 성장·저항 장신구 · 슬롯 1 |
† = 고정1 과 같은 능력치. 랜덤 옵션으로도 나올 수 있되 **값은 그 아이템 고정1 값의 50% 를 넘지 못한다**(PD 확정 10-01). 반지·목걸이는 고정1 후보 4개가 모두 풀에 있고, 실제 고정1 로 뽑힌 것과 같은 능력치의 랜덤 옵션만 50% 상한을 받는다.
- 고정1 값 = `기본값 × ilvl 계수 × 등급 계수` (기본값·계수는 부위별 표 1개, 기존 `Item_*.json` 의 레벨 열을 ilvl 열로 전용 가능). 강화(`RawItem.level`)는 기존 시스템대로 고정1 만 올린다(랜덤 옵션은 강화와 무관).
- 랜덤 옵션은 같은 아이템 안에서 같은 능력치 중복 불가(`ItemAffixTable.Group`). 고정1 과 같은 능력치는 허용하되 값 상한 50%(생성기가 굴린 뒤 `min(값, 고정1 × 0.5)` 로 자름). 1등급은 옵션 0개라 고정1 만 갖는다.
- 저항은 **근접 저항 · 원거리 저항 · 신성 저항** 세 가지다(PD 확정 10-01: 방어구의 물리/마법 개념은 근접/원거리로 대체). 피해 태그도 거리(근접/원거리)만 쓰고 속성(물리/마법) 태그는 두지 않는다(§4-4).
- 풀 크기는 부위마다 5~7개로 맞췄다. 6등급(옵션 4개)부터는 풀의 대부분이 붙으므로 **상위 등급의 개성은 '무엇이 붙었나'보다 '값 티어'와 조건부 옵션(7등급↑)** 에서 나온다. 풀을 더 넓히려면 §7-Q24.
- 같은 능력치가 여러 부위 풀에 겹치는 것(예: 공격 속도% — 무기 · 장갑 · 화살통 · 단검 · 보조 무기)은 의도다. 한 축을 여러 부위에서 쌓아 빌드를 만들되, 고정1 은 부위마다 달라 부위 정체성을 지킨다.
### 3-2. 보조 장비 8종 (PD 확정 10-01: 종류 · 고정1) 와 랜덤 옵션 풀 [제안] · 무기 허용표 [제안 §7-Q16]
| 보조 장비 | 고정1 (PD 확정) | 랜덤 옵션 풀 [제안 — 등급별 N개를 이 중에서] | 허용 무기(안) | 비고 |
|---|---|---|---|---|
| 방패 | 막기 확률 | **막기 확률†** · 방어력 · 피해 감소량 · 근접 저항 · 고정 피해 감소 · 최대 생명력 | 검 · 둔기 | 막기 발동 시 피해 50% 감소(§4-6 ⑦) |
| 화살통 | 원거리 피해 증가량 | **원거리 피해 증가량†** · 투사체 수 · 투사체 속도 · 기본 공격 사거리 · 관통 피해량 · 공격 속도% | 활 | |
| 마법책 | 스킬 피해 증가량 | **스킬 피해 증가량†** · 스킬 쿨타임% · 공격 범위 · 버프 지속 시간 · 원거리 저항 · 모든 스킬 레벨 | 마법 지팡이 · 마법 단검 | |
| 단검(수리검 대체) | 치명타 확률 | **치명타 확률†** · 치명타 피해 · 공격 속도% · 근접 공격력% · 고정 피해 · 흡혈 효과 증가량 | 단검 · 검 | 보조 무기와 구분: 단검 = 치명타 축 |
| 투척 무기 | 치명타 피해 | **치명타 피해†** · 원거리 공격력% · 투사체 속도 · 범위 공격 데미지 · 공격 범위 · 관통 피해량 | 검 · 둔기 · 단검 | 근접 무기에 원거리 보조 |
| 오브 | 스킬 쿨타임 감소 | **스킬 쿨타임%†** · 스킬 피해 증가량 · 회복 효과 증가량 · 초당 회복량 · 원거리 저항 · 신성 피해 증가량 | 마법 지팡이 · 마법 단검 | |
| 보조 무기(이도류용 검) | 근접 피해 증가량 | **근접 피해 증가량†** · 공격 속도% · 근접 공격력% · 고정 피해 · 막기 확률(쳐내기) · 타격 당 생명력 회복 | 검 · 단검 | 보조 무기 = 공격 속도·근접 축 |
| 없음(양손 무기 취급) | 무기 고정1 +25% | (아이템이 없으므로 옵션 없음) [해석 — §7-Q22] | 모든 무기 | 보조 슬롯을 비우는 선택 자체가 '양손 빌드' |
- 보조 장비 인스턴스의 `EquipmentType = SubEquip`, 종류는 `SubEquipKind` 필드(ItemTable 열)로 구분한다. 드롭 시 부위가 보조 장비로 정해지면 **현재 장착 무기가 허용하는 종류**에서만 추첨한다(쓸 수 없는 보조가 떨어지는 일을 막음). PD 결정으로 "모든 종류 랜덤"으로 바꿀 수 있다.
### 3-3. 랜덤 옵션 풀 테이블 (`ItemAffixTable` 신규) — §3-1 · §3-2 의 부위별 풀을 담는 그릇
| 열 | 뜻 |
|---|---|
| Index | 옵션 ID |
| Ability | 능력치(`eAbility`) |
| AllowParts | 붙을 수 있는 부위 비트마스크(보조 장비는 종류별 비트) — **§3-1 · §3-2 의 부위별 풀이 곧 이 열이다** |
| MinIlvl / MaxIlvl | 이 옵션이 나오는 ilvl 구간(같은 능력치가 구간별로 여러 행 = 티어) |
| MinValue / MaxValue | 값 구간 |
| Weight | 추첨 가중치 |
| Group | 같은 Group 은 한 아이템에 중복 불가(예: 근접 피해%와 원거리 피해%) |
| Prefix/Suffix | 이름 조합용(선택) |
- 공격 스텟 19 · 방어 스텟 12 · 보조 스텟 10 = 41종을 §3-1 · §3-2 의 부위별 풀로 배분했다. 어느 부위 풀에도 없는 능력치(예: 투사체 수는 화살통에만)는 그 부위에서만 나온다.
- 7등급 이상 "고유 옵션"은 `ConditionalItemOptionAbility`(기존 조건부 옵션 36종: 처치 시 공격력 ↑, 보호막 등)를 그대로 쓴다 → **새로 만들 필요 없음**.
---
## 4. 데이터 구조 변경 [제안]
### 4-1. `RawItem` 확장 (게임 원본 · 날짜/이유 주석)
```csharp
public int ilvl; // 드롭 레벨(고정 스텟 기준). 0 = 구버전 아이템 → 강화 level 로 대체
public int grade; // 인스턴스 등급(eItemGrade). -1 = ItemJson.ItemGrade 사용
public List<ItemAffix> affixes; // 랜덤 옵션 {affixId, value}. null = 없음
```
- Newtonsoft 가 그대로 저장/복원한다. 구버전 세이브는 필드가 없어 기본값 → 기존 동작 유지.
### 4-2. 합산 경로 한 곳 추가
`PlayerManager.GetTotalAbilities` 에서 장착 아이템마다 `affixes` 를 돌며 `rateValues[(int)ability] += value` — 기존 고정 옵션 루프 옆에 10줄.
고정 스텟은 `CalcAttack/Defence/Health` 가 `Item_*.json` 을 읽는 자리에서 ilvl·등급 계수를 곱한다.
### 4-3. `eAbility` 추가(끝에만)
```
AttackSpeed, BaseAttackRange, MeleeDamage, RangedDamage, SkillDamage, HolyDamage,
MeleeAttack, RangedAttack, ProjectileCount, AreaDamage, Pierce, FlatDamage, FlatDamageRate,
MeleeResist, RangedResist, HolyResist, Evasion, FlatDamageReduce, Block, StatusResist,
HealthOnHit, HealthOnKill, LifeSteal, HealEffect, AllSkillLevel, ExpGain, GoldGain, ItemChance
```
- 기존과 겹치는 것은 재사용: 이동 속도(Speed) · 버프 지속(SkillDuration) · 회복(RecoveryHealth) · 공격력(Attack) · 치명타(Critical/CriticalDamage) · 쿨타임(Cooltime) · 투사체 속도(ProjectileSpeed) · 공격 범위(SkillRange) · 최대 생명력(Health) · 피해 감소량(Defence) · 구르기(Rolling).
- 방어력(정수)은 `Item_*.json` 고정 스텟 경로(정수 합산)를 쓰고, `eAbility.Defence` 는 "피해 감소량 %" 로 남긴다.
### 4-4. 피해 태그 (구조 추가) — PD 확정 10-01: 물리/마법 속성 없음, 거리(근접/원거리)로 대체
- 스킬 테이블에 `DamageRange`(근접/원거리) · `DamageSource`(기본공격/스킬) 열. 속성(물리/마법) 열은 두지 않는다.
- `FireSkillBufferElement`·`TakeDamageBufferElement` 에 `byte range, source` 2 필드.
- `HitEntity` 에서 공격자의 근접/원거리/스킬 피해 증가%를, `DamageSystem` 에서 피격자의 **근접 저항/원거리 저항**%를 곱한다.
- 무기 종류로 기본값 자동 부여(검·둔기·단검=근접, 활·지팡이·마법 단검=원거리). 지팡이·마법 단검의 거리 분류는 §7-Q25.
### 4-5. 신성 피해 (PD 확정 2026-10-01)
- 정의: **거리·출처 구분 없이 모든 타격에 얹히는 방어 무시 순수 피해.** 방어력·피해 감소량·물리/마법 저항을 거치지 않고, **신성 저항**만 뺀다. 회피·막기·보호막은 타격 자체를 막으므로 신성도 함께 막힌다(PD 확정 10-01).
- 계산(PD 확정 10-01): `신성 피해 = round(일반 최종 피해 × 신성 피해 증가량%)` — 치명타·액설런트 배수가 적용된 뒤의 일반 피해를 기준으로 한다(치명타와 함께 커진다). 정수 가산(고정 피해)과 달리 공격이 셀수록 함께 커진다.
- 전달: `TakeDamageBufferElement` 에 `int holyDamage` 1 필드 추가. `DamageSystem` 은 `일반 피해 × 방어 계산` + `신성 피해 × (1 − 신성 저항%)` 을 합쳐 체력에서 뺀다. 피해 숫자 표시는 한 줄로 합산(신성 색 구분은 UI 선택).
- 몬스터 쪽: 몬스터가 신성 피해를 주려면 `MonsterJson` 에 `HolyRate` 열 1개. 플레이어 신성 저항은 여기서만 의미를 가진다 → **몬스터 작업 때 논의(PD 10-01 보류)**.
- 고정 피해(§7-Q4)와의 관계: 둘 다 방어 무시. 고정 = 정수 가산, 신성 = 비율 가산·저항 적용. 초안은 둘을 모두 유지하되 옵션 풀 `Group` 을 같게 두어 한 아이템에 동시에 붙지 않게 한다.
### 4-6. 전투 방어력 공식 [제안] (PD 요구 10-01: 초반 체감 확실 · 후반 적 피해 급증 · 무조건 비율이 아니라 피해와 방어력의 차이를 감안)
**현재 [실측]** `받는 피해 = 피해 × (1 − 방어력/(1000+방어력))` — 피해 크기와 무관한 순수 비율. 아래 표처럼 초반(방어 35)엔 3% 라 체감이 없고, 후반(방어 4190)엔 81% 로 몬스터 피해 8180 이 1576 이 되어 방어가 모든 걸 지배한다.
**제안 공식 v2 — 로그형 단일 곡선** (PD 지시 10-01: 잔몹·보스 별도 상한 없음, 끝으로 갈수록 감소 효과가 줄어드는 로그 모양)
```
x = 방어력 ÷ 피해 "이 한 방에 비해 내 방어력이 몇 배인가"
R = Rmax × (1 − e^(−x / s)) s = 0.5 Rmax = 0.80 (손잡이 둘, 몬스터 종류 구분 없음)
받는 피해 = max(1, round(피해 × (1 − R)))
```
**읽는 법(쉬운 말)**
- 방어력은 "숫자 그 자체"가 아니라 **"맞은 한 방에 비해 얼마나 큰가"(x)** 로만 작동한다. 그래서 잔몹의 약한 타격에는 방어력이 상대적으로 커서 많이 막고, 보스의 큰 한 방에는 방어력이 상대적으로 작아서 조금 막는다. 이 차이는 공식이 알아서 만들고, 몬스터 종류를 보고 상한을 따로 두지 않는다.
- 곡선은 **로그처럼 처음엔 가파르고 갈수록 눕는다.** 방어력을 0 에서 조금만 올려도 감소율이 확 뛰고(초반 체감), 이미 충분히 높으면 더 올려도 감소율이 거의 늘지 않는다(끝값 체감 감소). 80% 는 하나뿐인 상한이며 곡선이 자연히 그쪽으로 수렴하므로 "잘라내는" 지점이 없다.
- `s` 는 "방어력이 피해의 몇 배일 때 상한의 63% 에 닿나"(0.5 = 피해의 절반), `Rmax` 는 상한. 이 둘만 테이블 1행(`CombatConstTable`)으로 둔다.
- 왜 e 지수형인가: 로그는 위로 끝없이 올라가서 결국 상한에서 잘라야 하지만, `1 − e^(−x)` 는 같은 모양(처음 가파르고 갈수록 눕는 오목 곡선)이면서 상한에 부드럽게 붙는다. "로그 함수처럼"의 의도를 상한 절단 없이 만족한다.
**수치 거동 [계산]** — 방어력·몬스터 피해 성장 곡선은 자리표시 가정(방어 = 30+4lv+0.6lv² · 잔몹 피해 = 20+6lv+1.2lv² · 보스 = 잔몹×4)이다. 확정 곡선은 스탯 수치 설계에서.
표 1 — 레벨별(방어와 적 피해가 같이 자랄 때). 열 뜻: 방어력 = 그 레벨 플레이어 방어력 · 잔몹/보스 피해 = 원래 한 방 · R = 깎는 비율 · 받는 피해 = 깎고 남은 것 · 현재 = 지금 게임 공식.
| 레벨 | 방어력 | 잔몹 피해 | R | 받는 피해 | 현재 R | 현재 받는 피해 | 보스 피해 | R | 받는 피해 |
|---|---|---|---|---|---|---|---|---|---|
| 1 | 35 | 27 | 74% | 7 | 3% | 26 | 108 | 38% | 67 |
| 5 | 65 | 80 | 64% | 29 | 6% | 75 | 320 | 27% | 235 |
| 10 | 130 | 200 | 58% | 84 | 12% | 177 | 800 | 22% | 622 |
| 20 | 350 | 620 | 54% | 284 | 26% | 459 | 2480 | 20% | 1992 |
| 40 | 1150 | 2180 | 52% | 1043 | 53% | 1014 | 8720 | 19% | 7103 |
| 60 | 2430 | 4700 | 52% | 2277 | 71% | 1370 | 18800 | 18% | 15374 |
| 80 | 4190 | 8180 | 51% | 3985 | 81% | 1576 | 32720 | 18% | 26806 |
- 읽으면: 레벨 5 에서 잔몹 80 은 29 로, 보스 320 은 235 로 맞는다. 같은 방어력인데 큰 한 방은 덜 막힌다. 레벨 80 에서도 잔몹은 절반, 보스는 18% 만 막혀 **보스의 큰 한 방은 끝까지 위협**이다. 지금 공식은 후반 81% 로 방어가 모든 걸 지배한다.
표 2 — 초반 체감(레벨 5 잔몹 피해 80 고정, 방어력만 올릴 때). 로그 모양이 그대로 보인다.
| 방어력 | 0 | 10 | 20 | 40 | 80 | 160 | 320 | 640 |
|---|---|---|---|---|---|---|---|---|
| R | 0% | 18% | 31% | 51% | 69% | 79% | 80% | 80% |
| 받는 피해 | 80 | 66 | 55 | 40 | 25 | 17 | 16 | 16 |
- 읽으면: 첫 갑옷(방어 +20)이 바로 31% 감소로 보인다. 방어 160 을 넘으면 더 올려도 거의 안 변한다(끝값 체감 감소). 다음 구역으로 가서 적 피해가 커지면 x 가 작아져 다시 방어력이 의미를 갖는다.
표 3 — 같은 방어력 800 에 피해 크기별. 한 방이 클수록 덜 막힌다.
| 피해 | 100 | 400 | 800 | 1600 | 3200 | 6400 | 12800 |
|---|---|---|---|---|---|---|---|
| R | 80% | 79% | 69% | 51% | 31% | 18% | 9% |
| 받는 피해 | 20 | 86 | 247 | 791 | 2193 | 5267 | 11597 |
**피해 처리 순서(전체)** — PD 확정(10-01) 반영: 고정 피해·고정 피해 감소는 최종 연산 뒤 정수 가감, 관통 = 공격력 × 관통% 를 방어 계산 없이 가산, 막기 = 확률 발동 · 피해 50% 감소(모든 부위 옵션)
```
① 회피(확률, 전부 0) → 보호막(1회 무효, 전부 0) ※ 신성 포함 전부 막힘
② 일반 피해 × (1 − R) R = 방어력 공식(위)
③ × (1 − 피해 감소량%) 기존 decreaseRate
④ × (1 − 근접/원거리 저항%) 피해 거리 태그별
⑤ + 관통 피해 = 공격력 × 관통% ②(방어력) 미적용, ③④ 적용 여부 §7-Q18
⑥ + 신성 피해 = 일반 최종 피해 × 신성% ②③④ 미적용, × (1 − 신성 저항%)
⑦ 막기(확률) → 여기까지 합계 × 0.5 ※ 신성 포함
⑧ + 공격자 고정 피해(정수) 방어 무시
⑨ − 피격자 고정 피해 감소(정수)
받는 피해 = max(1, round(⑨))
```
- 막기를 ①이 아니라 ⑦에 둔 이유: 회피·보호막은 "타격이 없던 일"이지만 막기는 "맞되 절반"이라, 고정 피해 가감(⑧⑨) 앞·비율 연산 뒤가 자연스럽다. 막기가 고정 피해까지 절반으로 만들려면 ⑦을 ⑨ 뒤로 옮기면 된다(§7-Q19).
**구현** [코드 근거]
- `DefenceComponent` 에 `int defence` 필드 추가(지금은 미리 계산한 `defenceRate` 만 있음). `DamageSystem` 의 피해 계산 1곳에서 타격마다 R 을 계산한다 — Burst 잡 안 나눗셈·`math.exp` 각 1회라 비용 무시.
- `Character.ApplyAbility` 의 `m_BaseDefenceRate = 1 − def/(1000+def)` 는 그대로 두되(정보 창 표시용), 실제 감소는 새 공식을 쓴다. 정보 창엔 "잔몹 기준 감소율 / 보스 기준 감소율" 두 줄로 보여 주는 안.
- 몬스터 방어력: 현재 몬스터엔 방어가 없다(`decreaseRate 0`). 같은 공식을 몬스터에도 적용하면 "공격력이 낮으면 상위 구역 몬스터에 피해가 안 들어간다"는 벽이 생겨 구역 레벨 게이트로 쓸 수 있다 → §7-Q14.
**대안(참고, 비추천)**
- 감산형 `피해 − 방어력×k` (최소 비율 하한): 초반 체감은 가장 크지만 후반에 하한에 걸려 방어가 무의미해지거나, 반대로 잔몹 피해가 0 이 되어 방치 구간이 무료가 된다.
- 혼합형 `(피해 − 방어력×k) × (1 − 비율)`: 손잡이가 넷이라 밸런스 작업이 어렵다.
---
## 5. PD 제시 스텟 ↔ 시스템 매핑 [제안]
| PD 스텟 | 기존 재사용 | 신규 | 적용 지점 | 판단 |
|---|---|---|---|---|
| **공격** | | | | |
| 공격력 | Attack(%) + 고정 정수 | | 기존 | 그대로 |
| 공격 속도 | (Cooltime 을 무기 스킬에만 적용하던 부분) | AttackSpeed | **무기 스킬 발동 쿨타임 감소%**(PD 확정 10-01) | 기존 Cooltime 은 일반 스킬 전용으로 분리 §7-Q20 |
| 치명타 확률 / 피해 | Critical / CriticalDamage | | 기존 | 그대로 |
| 스킬 쿨타임 | Cooltime | | 기존 — 무기 스킬 외 일반 스킬 | §7-Q20 |
| 기본 공격 사거리 | | BaseAttackRange | **무기 스킬의 사거리 증감%**(PD 확정 10-01) | 무기 스킬 모듈 사거리에 곱 |
| 근접/원거리/스킬 피해 증가량 | | Melee/Ranged/SkillDamage | HitEntity(태그) — **최종 피해 곱%**(PD 확정) | 태그 신설 |
| 신성 피해 증가량 | | HolyDamage | HitEntity → `holyDamage` 별도 줄기(§4-5) | 방어 무시 · 신성 저항만 |
| 근접/원거리 공격 증가량 | | Melee/RangedAttack | CalcAttack(무기 태그) — **공격력 수치 %**(PD 확정) | 공격력 가산 |
| 투사체 속도 | ProjectileSpeed | | 기존 | 그대로 |
| 투사체 수 | | ProjectileCount | 스킬 모듈 | 스킬 시스템 수정 |
| 공격 범위 | SkillRange | | 기존 | 그대로 |
| 범위 공격 데미지 증가량 | | AreaDamage | HitEntity(범위 태그) | 태그 신설 |
| 관통 피해량 | | Pierce | DamageSystem ⑤ — **공격력 × 관통% 를 방어 계산 없이 가산**(PD 확정) | 추가 |
| 고정 피해 / 증가량 | | FlatDamage / FlatDamageRate | DamageSystem ⑧ — **최종 연산 뒤 정수 가산**(PD 확정) | 추가 |
| **방어** | | | | |
| 최대 생명력 | Health(%) + 고정 정수 | | 기존 | 그대로 |
| 방어력 | 정수(defence/(1000+defence)) | | 기존 | 그대로 |
| 물리/마법 저항 → **근접/원거리 저항**(PD 확정) | | Melee/RangedResist | DamageSystem(거리 태그) | 태그 신설 |
| 신성 저항 | | HolyResist | DamageSystem `holyDamage` 줄기(§4-5) | 몬스터 신성 피해가 있어야 의미 |
| 회피 확률 | | Evasion | DamageSystem 앞 | 추가 |
| 초당 회복량 | RecoveryHealth | | 기존 | 그대로 |
| 구르기 재사용 시간 | Rolling | | 기존 | 그대로 |
| 고정 피해 감소 | | FlatDamageReduce | DamageSystem ⑨ — **최종 연산 뒤 정수 차감**(PD 확정) | 추가 |
| 피해 감소량 | Defence(%) | | 기존 | 그대로 |
| 막기 확률 | | Block | DamageSystem ⑦ — **확률 발동 · 피해 50% 감소 · 모든 부위**(PD 확정) | 추가 |
| 상태이상 저항 | | StatusResist | 상태이상 부여 지점 [미검증] | 추가 |
| **보조** | | | | |
| 이동 속도 | Speed / SpeedValue | | 기존 | 그대로 |
| 버프 지속 시간 | SkillDuration | | 기존 | 그대로 |
| 타격 당 / 처치 시 생명력 회복 | (조건부 RecoverByKill… 유사) | HealthOnHit / HealthOnKill | HitRecord/DeadRecord 처리 | 추가 |
| 흡혈 효과 증가량 | | LifeSteal | HitEntity 결과 | 추가 |
| 회복 효과 증가량 | | HealEffect | 모든 회복 지점 | 추가 |
| 모든 스킬 레벨 증가 | | AllSkillLevel | 로비 스킬 레벨에 가산 — **레벨당 증가량 계수가 오르며 UI 강화 상한을 넘어 적용**(PD 확정) | 스킬 수치 조회 지점에서 `유효 레벨 = 로비 레벨 + AllSkillLevel` 로 계수 계산 §7-Q21 |
| 경험치 / 골드 / 아이템 획득 | IncreaseDrop(통합) | ExpGain / GoldGain / ItemChance **분리**(PD 확정) | 기존 3 지점 | 기존 IncreaseDrop 옵션은 셋 모두에 적용되는 하위 호환 |
---
## 6. 구현 단계 (승인 후)
> **2026-10-01 구현 결과(BT48)**: P1~P6 을 한 세션에 구현 · 실측 검증했다(처치 → 드롭 → 습득 → 정보 창 → 장착 → 능력치 반영, 오류 0). 구조 · 원본 수정 지점 · 보류 목록은 `공유/대화로그/FirstRising/2026-10-01_세션인수인계.md` §2 · §4. 아래 표의 '보류' 행은 그대로 남아 있다(스킬 시스템 수정 필요).
| 단계 | 내용 | 원본 수정 | 검증 |
|---|---|---|---|
| P1 데이터 | `eItemGrade` 10 · `eEquipmentType` 8 · `eAbility` 추가 · `RawItem` 확장 · `ItemAffixTable` · `MapTable.AreaLevel` | enum·클래스 4곳 | 로드·저장 왕복 |
| P2 생성기 | `TVItemGenerator`(BT_Visual): 부위→ilvl→등급→정의→옵션 추첨. 치트 명령으로 N개 생성해 분포 확인 | 없음(신규) | 10만 회 분포 표 |
| P3 드롭 연결 | 잡에 장비 드롭 종류 추가, 줍는 순간 인스턴스 생성 | `SpawnDropItemSystem`·`DropItemSystem`·`PlayerEntitySyncSystem` | 필드 Play 실측 |
| P4 능력치 적용 | 합산 10줄 + 회피·막기·고정 피해·흡혈·회복 적용 | `GetTotalAbilities`·`HitEntity`·`DamageSystem` | 피해 로그 |
| P5 피해 속성 태그 · 신성 줄기 | 스킬 테이블 열 + 버퍼 필드 + 저항/증가 적용, `holyDamage` 분리 전달 | 스킬 테이블·`FxComponentPlay`·`DamageSystem` | 속성별 피해 실측 · 방어 0/최대에서 신성 피해 불변 확인 |
| P6 UI | 가방·정보 창에 등급 색·옵션 목록·ilvl, 새 부위 슬롯 2개 | `ItemInfoPopUp`·장비 창 | 화면 확인 |
| 보류 | 공격 속도·투사체 수·관통·상태이상 저항 | 스킬 시스템 | 실측 후 별도 |
---
## 7. 질문 · 보완 의견 (PD 결정 필요)
**확정(PD 답변 2026-10-01)**
- ~~Q1~~ 공격 속도 = 무기 스킬 발동 쿨타임 감소. 기본 공격 사거리 = 무기 스킬 사거리 증감.
- ~~Q2~~ 피해 증가량 = 최종 피해 곱% · 공격 증가량 = 공격력 수치 %.
- ~~Q3~~ 관통 피해량 = 공격력 × 관통% 를 방어력 계산 없이 가산.
- ~~Q4~~ 고정 피해 = 최종 연산 뒤 정수 가산 · 고정 피해 감소 = 최종 연산 뒤 정수 차감.
- ~~Q5~~ 막기 = 모든 부위 옵션, 확률 발동, 피해 50% 감소.
- ~~Q6~~ 모든 스킬 레벨 증가 = 로비 스킬 레벨 가산, 레벨당 증가량 계수 상승, UI 강화 상한 초과 적용.
- ~~Q7~~ 경험치 · 골드 · 아이템 획득 셋 분리.
- ~~Q8~~ 반지 1 · 목걸이 1(장신구 2분류).
- ~~Q9~~ 보조 장비 8종: 방패 · 화살통 · 마법책 · 수리검 · 투척 무기 · 오브 · 보조 무기(이도류용 검) · 없음(양손 무기 취급).
- ~~Q10~~ 등급은 성능 차이만. 세트는 별도 구성.
**PD 결정 2026-10-01 (11건 전부 확정 → 이 문서는 v1 확정본 기준이 된다)**
- Q14 몬스터 방어력 적용 안 함. 몬스터 방어력 = 1 고정.
- Q15 방어 공식 손잡이(s · Rmax)는 **GlobalValue**(PD 조절용 설정 자산)에 두고, 방향은 임시 · 추후 PD 조절.
- Q16 보조 장비 허용 무기 표 그대로.
- Q17 '없음' = 무기 고정1 +25% 만. 특정 무기는 보조 슬롯을 **잠금**(양손 무기 컨셉) → 무기 정의에 `TwoHanded` 플래그.
- Q18 관통은 방어력만 무시(피해 감소량 · 근접/원거리 저항은 적용).
- Q19 막기 50% 는 고정 피해 가감 앞(⑦).
- Q20 공격 속도 = 무기별로 발동되는 공격에만. 스킬 쿨타임 = 액티브 형태의 모든 스킬. 둘은 분리.
- Q21 모든 스킬 레벨 유효 레벨 상한 = 테이블 최대 + 99, 선형 외삽.
- Q22 '없음' 은 옵션 없음.
- Q24 부위별 풀 현행 유지, 추후 추가 · 제거로 조정.
- Q25 투사체 발사 형태는 전부 원거리 판정. 지팡이 · 마법 단검 등 발사체 사용 시 원거리.
**결정 대기** — 없음(2026-10-01 기준). 아래는 이력.
- **Q16 보조 장비 ↔ 무기 허용표** — §3-2 의 허용표(안)를 확정해 주시면 된다. 특히 수리검 · 투척 무기가 근접 무기 전용인지, 마법책 · 오브가 지팡이 · 마법 단검 공용인지.
- **Q17 '없음(양손 무기 취급)' 보정** — 보조 슬롯을 비웠을 때 무기 고정 스텟 +25%(안). 보정 없이 순수 선택으로 둘 수도 있다.
- **Q18 관통 피해의 저항 · 피해 감소량 적용** — 방어력만 무시하고 피해 감소량% · 물리/마법 저항은 받는 것으로 제안(§4-6 ⑤). 전부 무시(신성과 동일)로 둘 수도 있다.
- **Q19 막기와 고정 피해** — 막기 50% 가 고정 피해 가감(⑧⑨) 앞에 적용되는 안(§4-6 ⑦). 고정 피해까지 절반으로 만들려면 뒤로 옮긴다.
- **Q20 공격 속도와 스킬 쿨타임 분리** — 기존 `Cooltime` 은 모든 스킬에 적용된다. 확정안대로 공격 속도 = 무기 스킬 쿨타임이면, 기존 Cooltime 을 "일반 스킬만"으로 좁혀야 두 옵션이 구분된다. 기존 아이템 옵션 테이블의 Cooltime 항목이 무기 스킬에서 빠지는 하위 호환 변화가 생긴다.
- **Q22 '없음(양손 무기 취급)'** — 보조 아이템이 없으므로 "무기 고정1 +25%" 만 적용하고 랜덤 옵션은 없는 것으로 해석했다. 대안: 보조 슬롯을 비운 동안 무기에 랜덤 옵션 1개를 더 붙인다(무기 풀에서).
- **Q25 지팡이 · 마법 단검의 거리 분류** — 물리/마법 속성이 없어졌으므로 마법 무기의 스킬은 근접/원거리 중 하나로 분류해야 저항이 걸린다. 지팡이 = 원거리, 마법 단검 = 근접(투사체 스킬은 스킬 테이블에서 개별 지정)으로 제안.
- **Q24 부위별 옵션 풀 보완** — §3-1 · §3-2 의 풀은 부위 성격을 기준으로 5~7개씩 제안했다. 빼거나 더할 능력치를 지정해 주시면 반영한다. 풀이 옵션 수(최대 6)보다 크게 넓지 않으면 상위 등급 아이템끼리 구성이 비슷해지므로, 부위당 8~10개로 넓히는 안도 있다.
- **Q21 모든 스킬 레벨의 상한** — 유효 레벨(로비 레벨 + 가산)에 상한을 둘지(예: 테이블 최대 레벨 + 10). 스킬 테이블은 레벨별 행이라 상한을 넘으면 마지막 행 계수를 선형 외삽해야 한다(§4-3 구현 메모).
- ~~Q11~~ 확정(10-01): 회피·막기·보호막은 타격 자체를 막으므로 신성도 막힌다.
- ~~Q12~~ 확정(10-01): 신성 = 일반 최종 피해의 %(치명타와 함께 커짐).
- ~~Q13~~ 보류(10-01): 몬스터 신성 피해는 몬스터 작업 때 논의.
- **Q14 몬스터 방어력** — 방어력 공식(§4-6)을 몬스터에도 적용할지. 적용하면 공격력이 구역 레벨 게이트가 되고, 안 하면 지금처럼 몬스터는 체력만으로 버틴다.
- **Q15 방어력 공식 손잡이** — v2 의 `s=0.5 · Rmax=0.80` 은 자리표시다. 방향(잔몹 약 50% · 보스 약 20% · 초반 갑옷 하나에 30%)을 먼저 정하고 수치는 스탯 설계 때 확정하는 안.
- ~~의견 A~~ (PD 확정 10-01: 신성 = 방어 무시 순수 피해로 정의되어 스킬 속성 기획 없이도 성립. 삭제)
- **의견 B** — 옵션 41종 중 방어 계열 12종은 방치형에서 체감이 약하다. 초기에는 공격 12 · 방어 6 · 보조 6 정도로 좁혀 시작하고 확장하는 편이 밸런스가 쉽다.
- **의견 C** — "아이템 획득 찬스"가 드롭 빈도와 등급 두 축 모두를 올리면(§2-2) 이 옵션 하나가 지배 옵션이 된다. 빈도는 `ItemChance`, 등급은 별도 `MagicFind`(행운)로 나누는 디아블로 2 방식도 검토 요청.

View File

@ -0,0 +1,361 @@
# FR(Dead Archive) 아이템 드랍 시스템 초안 v0 — PPT (BT47-FirstRising · 2026-10-01)
# 원고: 시스템기획/2026-10-01_FR_아이템드랍시스템_초안_v0.md · 라이트 템플릿(deckkit_light) 재사용
import os, sys
BASE = r"E:\BurningTimes\공유\기획\FirstRising"
sys.path.insert(0, os.path.join(BASE, "리뉴얼", "tools"))
from deckkit_light import *
OUT = os.path.join(BASE, "시스템기획", "2026-10-01_FR_아이템드랍시스템_초안_v0.pptx")
D = Deck(brand="FIRSTRISING", brand_sub="Item Drop System Draft v0 · BurningTimes · 2026-10-01")
GREEN = RGBColor(0x2E, 0x8B, 0x57); AMBER = RGBColor(0xC7, 0x8A, 0x1E); BLUE = RGBColor(0x3B, 0x6F, 0xB6)
def flow(sl, x, y, w, h, steps, hl=(), tsize=10, ssize=8.5):
n = len(steps); gap = 0.16; fw = (w - gap * (n - 1)) / n
for i, (t, s) in enumerate(steps):
fx = x + i * (fw + gap)
D.rect(sl, fx, y, fw, h, fill=PINK if i in hl else WHITE, line=RED if i in hl else RULE, lw=1.0, shape=MSO_SHAPE.ROUNDED_RECTANGLE, radius=0.08)
D.text(sl, fx + 0.05, y + 0.08, fw - 0.1, 0.3, t, size=tsize, bold=True, color=RED if i in hl else INK, align=PP_ALIGN.CENTER)
D.text(sl, fx + 0.05, y + 0.4, fw - 0.1, h - 0.45, s, size=ssize, color=SUB, align=PP_ALIGN.CENTER, spacing=1.15)
if i < n - 1:
D.arrow(sl, fx + fw + 0.02, y + h / 2, fx + fw + gap - 0.02, y + h / 2)
def card(sl, x, y, w, h, tag, title, lines, tag_fill=RED, size=9.5, gap=3):
D.card(sl, x, y, w, h)
D.rect(sl, x, y, w, h, fill=None, line=RULE, shape=MSO_SHAPE.ROUNDED_RECTANGLE, radius=0.03)
D.pill(sl, x + 0.15, y + 0.13, tag, fill=tag_fill, w=max(0.6, 0.16 + 0.13 * len(tag)))
D.text(sl, x + 0.15, y + 0.45, w - 0.3, 0.3, title, size=11, bold=True, color=INK)
D.bullets(sl, x + 0.15, y + 0.8, w - 0.3, h - 0.9, lines, size=size, color=BODY, gap=gap)
def code(sl, x, y, w, h, lines, size=9.5):
D.panel(sl, x, y, w, h, fill=DARK, radius=0.05)
D.text(sl, x + 0.2, y + 0.12, w - 0.4, h - 0.2, lines, size=size, color=WHITE, face="Consolas", spacing=1.25)
# 01 표지 ===============================================================================
D.page += 1
sl = D.prs.slides.add_slide(D.blank)
D.rect(sl, 0, 0, 0.19, 7.5, fill=RED)
D.rect(sl, 7.22, 0, 6.11, 7.5, fill=PANEL)
D.text(sl, 7.5, 0.3, 5.6, 0.3, "한눈에 — 이 초안이 정하는 다섯 가지", size=9.5, bold=True, color=SUB, align=PP_ALIGN.CENTER)
five = [("10", "등급", "성능 차이만 · 고정 스텟 계수 · 옵션 수 · 값 티어"), ("8", "부위", "무기 · 보조 · 갑옷 · 벨트 · 장갑 · 부츠 · 반지 · 목걸이"),
("7", "드롭 단계", "드롭 여부 → 부위 → 아이템 레벨 → 등급 → 정의 → 옵션 → 인스턴스"), ("41", "능력치", "공격 19 · 방어 12 · 보조 10 — 기존 21종에 끝 추가"),
("v2", "방어력 공식", "로그형 단일 곡선 · 상한 80% 하나 · 몬스터 종류 구분 없음")]
for i, (b, t, s) in enumerate(five):
yy = 0.75 + i * 1.12
D.card(sl, 7.5, yy, 5.6, 1.0)
D.text(sl, 7.65, yy + 0.12, 1.1, 0.75, b, size=26, bold=True, color=RED, align=PP_ALIGN.CENTER, anchor=MSO_ANCHOR.MIDDLE)
D.text(sl, 8.8, yy + 0.16, 4.2, 0.3, t, size=12, bold=True, color=INK)
D.text(sl, 8.8, yy + 0.48, 4.2, 0.45, s, size=8.8, color=SUB, spacing=1.15)
D.hline(sl, 7.5, 6.5, 5.6, color=RGBColor(0xDD, 0xDD, 0xDD))
D.text(sl, 7.5, 6.58, 5.6, 0.18, "STATUS", size=7.5, color=SUB)
D.text(sl, 7.5, 6.78, 5.6, 0.32, "초안 v0 · PD 확정 15건 반영 · 결정 대기 10건", size=12, bold=True, color=INK)
D.text(sl, 0.81, 0.81, 6.1, 0.2, "FIRSTRISING (DEAD ARCHIVE) SYSTEM DESIGN", size=9, bold=True, color=SUB)
D.text(sl, 0.81, 1.15, 6.4, 0.3, "아이템 드랍 시스템 초안 v0 · 등급 · 부위 · 드롭 · 옵션 · 방어 공식", size=12.5, color=RED, face=F_MED)
D.text(sl, 0.75, 1.52, 6.45, 1.45, ["몬스터 레벨이 정하고", [("획득 찬스가 끌어올리는", {"color": RED})], "디아블로식 장비 드롭"], size=30, color=INK, face=F_BLACK, spacing=1.05)
D.text(sl, 0.81, 3.45, 6.1, 0.9, "몬스터 레벨에 따라 드랍 가능한 아이템 레벨과 등급 범위가 정해지고, 아이템 획득 찬스가 오르면 더 높은 등급이 나온다. 8부위마다 고정 스텟이 항상 붙고 등급만큼 랜덤 옵션이 붙는다. 현 코드 구조를 실측해 무엇을 그대로 쓰고 무엇을 얹는지까지 정했다.",
size=10.5, color=BODY, spacing=1.3)
rows = [("PD 지시", "2026-10-01 — 등급 10 · 부위 8 · 고정 스텟 + 랜덤 옵션 · 몬스터 레벨 → 드랍 범위 · 획득 찬스 → 상위 등급 · 능력치 41종"),
("실측 근거", "FR bt/work 4bca428e62 — JsonCommon(eAbility · eItemGrade · ItemJson) · SpawnDropItemSystem · DamageSystem · FxComponentPlay · PlayerManager"),
("이 문서", "현 구조 실측 → 변경 규모 판정 → 등급 · 드롭 흐름 · 부위 · 옵션 풀 → 데이터 구조 → 신성 · 방어 공식 → 매핑표 → 구현 단계 → 결정 대기")]
D.hline(sl, 0.81, 4.45, 6.11)
for i, (k, v) in enumerate(rows):
yy = 4.52 + i * 0.44
D.text(sl, 0.81, yy, 1.05, 0.36, k, size=9.5, bold=True, color=RED)
D.text(sl, 1.87, yy, 5.05, 0.42, v, size=8.8, color=BODY)
D.hline(sl, 0.81, yy + 0.4, 6.11)
D.text(sl, 0.81, 5.9, 2.4, 0.4, "개발팀 · 총괄PM", size=15, bold=True, color=INK)
D.text(sl, 3.25, 5.98, 3.0, 0.3, "BurningTimes", size=12.5, color=SUB, face=F_LIGHT)
D.text(sl, 0.81, 6.35, 6.1, 0.2, "2026-10-01 · BT47-FirstRising · 초안 v0 (PD 보완 전)", size=9, color=SUB)
D.text(sl, 0.81, 6.7, 6.25, 0.42, "01 요약 · 02 실측 · 03 변경 규모 · 04 등급 · 05 드롭 흐름 · 06 부위 · 07 옵션 풀 · 08 데이터 · 09 신성 · 10 방어 공식 · 11 매핑 · 12 구현 · 13 결정", size=8, color=RED)
# 02 한 장 요약 =========================================================================
sl = D.slide("한 장 요약", "SUMMARY | 한 장 요약", "무엇을 그대로 쓰고, 무엇을 얹고, 드롭이 어떻게 정해지고, PD님 결정이 어디에 남았는지 한 장으로 정리했습니다.",
"근거 초안 v0 §0 실측 · §1~§6 제안 · §7 결정 사항")
D.panel(sl, X0, Y0, XW, 4.38)
D.hline(sl, 1.9, 2.14, 9.5, color=RED, width=1.0)
cols = [("01", "실측 · 판정", ["능력치 21종 배열 합산 · 저장은 JSON 파일", "아이템 인스턴스에 랜덤 옵션 없음 · 몬스터 레벨 없음", "판정: 대대적 개편 아님 — 중간 규모 확장"]),
("02", "등급 · 부위", ["등급 10 = 성능 차이만(계수 · 옵션 수 · 값 티어)", "부위 8 — 보조 장비 8종 · 반지 1 · 목걸이 1", "고정1 하나 + 부위별 옵션 풀에서 등급만큼 랜덤 옵션"]),
("03", "드롭 · 옵션", ["구역 레벨 ± 2 → 아이템 레벨 · 등급 게이트", "획득 찬스는 상위 등급 가중치를 더 키움", "옵션 풀 테이블: 부위 허용 · 레벨 구간 · 값 구간"]),
("04", "전투 공식", ["신성 = 방어 무시 순수 피해(최종 피해 × %)", "방어력 v2 = 로그형 단일 곡선, 상한 80% 하나 · 저항은 근접/원거리/신성", "피해 순서 9단계 — 관통 · 막기 · 고정 피해 위치 확정"])]
cw = (XW - 0.34 - 0.48) / 4
for i, (n, t, lines) in enumerate(cols):
cx = X0 + 0.17 + i * (cw + 0.16)
D.rect(sl, cx + cw / 2 - 0.3, 1.84, 0.6, 0.6, fill=WHITE, line=RED, lw=1.25, shape=MSO_SHAPE.OVAL)
D.text(sl, cx + cw / 2 - 0.3, 1.84, 0.6, 0.6, n, size=15, bold=True, color=RED, align=PP_ALIGN.CENTER, anchor=MSO_ANCHOR.MIDDLE)
D.text(sl, cx, 2.55, cw, 0.3, t, size=12, bold=True, color=INK, align=PP_ALIGN.CENTER)
D.card(sl, cx, 2.95, cw, 2.9)
D.bullets(sl, cx + 0.18, 3.1, cw - 0.36, 2.7, lines, size=9.6, color=BODY, gap=7)
D.banner(sl, "핵심", "기존 합산 · 저장 · 드롭 파이프라인은 그대로 두고, 인스턴스 옵션 · 피해 속성 태그 · enum 확장 세 가지만 얹습니다.")
# 03 현 구조 실측 ① 아이템·옵션 ==========================================================
sl = D.slide("지금의 FR — 아이템 · 옵션 구조 실측", "STATUS | 현 구조 실측 ①", "코드와 데이터 테이블에서 직접 확인한 것입니다. '판단' 열이 이 초안에서 어떻게 다룰지입니다.",
"근거 JsonCommon.cs(eAbility · eItemGrade · eEquipmentType · ItemJson · ItemOptionJson) · Protocol.cs(RawItem) · DBImpl.cs · DataTables/*.json")
rows = [["항목", "현재 [실측]", "판단"],
["아이템 인스턴스 RawItem", "{index, level, count, _id} 만 — 개별 랜덤 옵션 없음", ("확장", RED)],
["저장", "User 객체를 Newtonsoft JSON 으로 파일 저장 — RawItem 에 필드를 더하면 그대로 저장됨", ("그대로", GREEN)],
["등급 eItemGrade", "6단계 Normal · Magic · Rare · Elite · Epic · Legend", ("10으로 확장", RED)],
["부위 eEquipmentType", "6 — Weapon · Necklace · Glove · Armor · Belt · Shose (보조 장비 · 반지 없음)", ("8로 확장", RED)],
["아이템 정의 ItemTable", "702행 — 장비는 부위 × 등급 × 옵션 묶음(GroupNo) 조합으로 한 행씩 미리 생성(부위별 5~28종)", ("유지", GREEN)],
["기본 능력치 Item_*.json", "부위별 1,160행 — 강화 레벨별 공격 · 방어 · 체력 정수", ("고정 스텟 원형으로 재사용", AMBER)],
["옵션 ItemOptionTable", "GroupNo 별 고정 옵션(등급 이하 누적) 140행", ("랜덤 옵션 풀 신설", RED)],
["능력치 eAbility", "21종, 대부분 % 배율 — int[eAbility.Max] 배열 인덱스로 합산", ("끝에만 추가", AMBER)]]
D.table(sl, X0, Y0, XW, 4.3, rows, colw=[2.3, 7.7, 1.94], size=9.3, hsize=9.5, col_colors=[None, BODY, None], col_bold=[True, False, True])
D.banner(sl, "주의", "eAbility 는 배열 인덱스입니다. 순서 변경 · 중간 삽입은 세이브 호환을 깨므로 새 능력치는 반드시 끝에만 붙입니다.", y=6.15)
# 04 현 구조 실측 ② 능력치 흐름 · 피해 · 드롭 ================================================
sl = D.slide("지금의 FR — 능력치 흐름 · 피해 공식 · 드롭 흐름", "STATUS | 현 구조 실측 ②", "새 능력치가 지나갈 길과, 드롭이 결정되는 길입니다. 둘 다 그대로 쓰되 태그 · 필드를 얹습니다.",
"근거 PlayerManager.GetTotalAbilities · Character.ApplyAbility · PlayerEntityAbilitySyncSystem · FxComponentPlay.HitEntity · DamageSystem · SpawnDropItemSystem")
D.text(sl, X0, Y0, XW, 0.28, "능력치 흐름", size=10.5, bold=True, color=INK)
flow(sl, X0, Y0 + 0.32, XW, 0.95, [("테이블 · 진화", "ItemOptionTable"), ("합산", "GetTotalAbilities · int[]"), ("Character", "ApplyAbility · Calc*"),
("ECS 동기화", "case eAbility 13개"), ("컴포넌트", "Health · Defence"), ("피해 계산", "HitEntity · DamageSystem")], hl=(1, 5), tsize=9.5, ssize=8)
D.text(sl, X0, 3.05, 5.8, 0.28, "피해 공식 [실측]", size=10.5, bold=True, color=INK)
code(sl, X0, 3.35, 5.8, 1.55, ["주는 피해 = baseDamage × damageRate × (1 + 공격%합)", " → 첫타(HP 100%) ×2 → 액설런트 ×5 또는 치명타 ×2(+치명타 피해%)", "받는 피해 = × defenceRate(= 1 − 방어/(1000+방어))", " → × (1 − 피해 감소%) → 보호막 있으면 1회 무효"], size=9)
D.text(sl, X0, 5.0, 5.8, 0.8, "피해에 거리(근접/원거리) · 출처(기본공격/스킬) 태그가 없고, 방어를 무시하는 별도 피해 줄기(신성)도 없습니다.", size=9, color=SUB, spacing=1.2)
D.text(sl, 6.85, 3.05, 5.78, 0.28, "드롭 흐름 [실측]", size=10.5, bold=True, color=INK)
code(sl, 6.85, 3.35, 5.78, 1.55, ["MonsterJson.DropGroup_ID → ItemDropGroup(만분율 × (1+IncreaseDrop))", " → ItemDropPackage(Item_ID 고정 · Rate 가중) → Burst 잡에서 결정", " → 바닥 드롭(itemIndex, count) → 줍기 → 집계", " → 정산 시 NetPlayer.AddItem(itemId, level=1, count)"], size=9)
D.text(sl, 6.85, 5.0, 5.78, 0.8, "몬스터 레벨 개념이 없습니다(MonsterJson 은 Health · Speed 고정). IncreaseDrop 은 경험치 · 골드 · 아이템이 한 값입니다.", size=9, color=SUB, spacing=1.2)
D.banner(sl, "읽기", "합산 배열 · 저장 · 드롭 잡은 그대로 살리고, 인스턴스 옵션 · 피해 태그 · 구역 레벨을 얹으면 됩니다.", y=6.15)
# 05 변경 규모 판정 =======================================================================
sl = D.slide("변경 규모 판정 — 대대적 개편은 아니다", "ASSESSMENT | 변경 규모", "PD님 지시대로 '신규 스텟만 추가하는 형태가 가능한지'를 먼저 점검했습니다. 결론은 중간 규모 확장입니다.",
"근거 초안 v0 §0-4 · eAbility 21종 · HitEntity · DamageSystem · FxComponent 모듈")
rows = [["구분", "내용", "규모"],
["신규 스텟 '추가만'", "% 배율 계열 — 공격력 · 최대 생명력 · 이동속도 · 쿨타임 · 치명타 · 범위 · 투사체 속도 · 회복 · 경험치/골드/아이템 획득 · 버프 지속 · 구르기", ("eAbility 끝 추가 + 합산 그대로", GREEN)],
["적용 지점 1~2곳 추가", "회피 · 막기 · 피해 감소량 · 고정 피해 · 고정 피해 감소 · 타격당/처치시 회복 · 흡혈 · 상태이상 저항", ("HitEntity · DamageSystem 각 1곳", GREEN)],
["구조 추가 — 피해 태그", "근접/원거리 저항 · 근접/원거리/스킬 피해 증가 → 스킬 테이블 열(거리 · 출처) + FireSkill/TakeDamage 필드", ("중간", AMBER)],
["구조 추가 — 신성 줄기", "모든 타격에 얹히는 방어 무시 순수 피해 → 피해를 '일반 + 신성' 두 줄기로 전달", ("소~중", AMBER)],
["구조 추가 — 스킬 모듈", "투사체 수 · 관통 · 공격 범위 · 기본 공격 사거리 → FxComponent 파라미터 노출", ("중간", AMBER)],
["데이터 구조", "RawItem 옵션 리스트 · 등급 10 · 부위 8 · 구역 레벨 · 랜덤 옵션 풀 테이블", ("중간", AMBER)]]
D.table(sl, X0, Y0, XW, 3.9, rows, colw=[2.2, 7.4, 2.34], size=9.2, hsize=9.5, col_colors=[None, BODY, None], col_bold=[True, False, True])
D.kpis(sl, X0, 5.7, XW, 0.42 + 0.5, [("그대로", "합산 배열 · 저장 · 드롭 잡 · 강화 테이블", False), ("얹는 것 3", "인스턴스 옵션 · 피해 태그 · enum 확장", True), ("금지 1", "eAbility 중간 삽입 · 순서 변경", True)], big=13, small=8.5)
# 06 등급 10단계 ==========================================================================
sl = D.slide("등급 10단계 — 성능 차이만", "GRADE | 등급", "PD 확정(10-01): 세트는 별도 시스템이며 등급은 성능 차이만 갖습니다. 성능은 세 축 — 고정 스텟 계수 · 랜덤 옵션 수 · 옵션 값 티어.",
"근거 초안 v0 §1 · 계수 · 가중치 · 최저 레벨은 자리표시(스탯 수치 설계에서 확정)")
rows = [["등급", "이름(안)", "색", "고정 스텟 계수", "랜덤 옵션 수", "값 티어", "가중치(만분율)", "최저 몬스터 레벨"],
["1", "일반 Common", "회색", "1.00", "0", "—", "5000", "1"], ["2", "고급 Uncommon", "흰색", "1.10", "1", "1", "2500", "1"],
["3", "마법 Magic", "파랑", "1.20", "2", "1", "1300", "3"], ["4", "희귀 Rare", "노랑", "1.35", "3", "2", "700", "8"],
["5", "정예 Elite", "초록", "1.50", "3", "2", "300", "15"], ["6", "영웅 Epic", "보라", "1.70", "4", "3", "120", "25"],
["7", "전설 Legend", "주황", "1.90", "4 (조건부 허용)", "3", "50", "35"], ["8", "신화 Mythic", "빨강", "2.15", "5 (조건부 허용)", "4", "20", "50"],
["9", "태고 Primal", "청록", "2.40", "5 (조건부 허용)", "4", "7", "65"], ["10", "유일 Unique", "금색", "2.70", "6 (조건부 허용)", "5", "3", "80"]]
D.table(sl, X0, Y0, 8.3, 4.35, rows, colw=[0.6, 1.6, 0.7, 1.2, 1.5, 0.8, 1.2, 1.3], size=9, hsize=9, hl_rows=(7, 8, 9, 10))
card(sl, 9.2, Y0, 3.43, 4.35, "규칙", "등급이 하는 일", ["고정 스텟 × 계수 — 같은 부위라도 등급이 높으면 기본치가 크다", "옵션 수 — 등급만큼 랜덤 옵션이 붙는다", "값 티어 — 같은 옵션도 상위 등급은 값 구간이 높다",
"'조건부 허용' = 기존 조건부 옵션 36종(처치 시 공격력 ↑ · 보호막 등)이 그 등급부터 풀에 섞인다는 뜻. 고정 부여 아님",
"기존 6등급은 번호를 옮기지 않고 새 enum 을 뒤에 붙여 매핑(세이브 호환)"], size=9, gap=5)
D.banner(sl, "요지", "등급은 '얼마나 좋은가'만 말합니다. 세트 · 고유 효과는 등급 밖에서 따로 설계합니다.", y=6.15)
# 07 드롭 결정 흐름 =======================================================================
sl = D.slide("드롭 결정 흐름 — 몬스터가 죽을 때 한 번에", "DROP | 드롭 결정 흐름", "일곱 단계로 하나의 아이템 인스턴스가 만들어집니다. ①~④는 Burst 잡, ⑤~⑦은 줍는 순간 관리 코드에서 굴립니다.",
"근거 초안 v0 §2 · SpawnDropItemSystem(잡) · DropItemSystem(줍기) · PlayerEntitySyncSystem(집계)")
flow(sl, X0, Y0, XW, 1.2, [("① 드롭 여부", "종류별 기본 확률 × (1 + 획득 찬스)"), ("② 부위", "8부위 균등(보스 별도 표)"), ("③ 아이템 레벨", "몬스터 레벨 ± 2 (보스 +3)"),
("④ 등급", "가중치 × 레벨 게이트 × 찬스"), ("⑤ 기본 정의", "ItemTable (부위, 등급) 랜덤"), ("⑥ 랜덤 옵션", "등급 수만큼 풀에서 추첨"), ("⑦ 인스턴스", "RawItem + ilvl · grade · options")], hl=(0, 3, 5), tsize=9.5, ssize=8)
card(sl, X0, 3.1, 3.85, 2.85, "①", "드롭 여부", ["일반 3% · 정예 25% · 보스 100% × N개 (자리표시)", "× (1 + 아이템 획득 찬스)", "찬스를 빈도에는 절반만(× 0.5) 적용하는 선택지 있음"], size=9.2, gap=5)
card(sl, 4.72, 3.1, 3.85, 2.85, "③", "몬스터 레벨 = 구역 레벨", ["MonsterJson 에 레벨이 없음 → MapTable 에 AreaLevel 열 추가", "몬스터 레벨 = AreaLevel + 종류 보정(일반 0 · 정예 +2 · 보스 +5)", "보스전 전용 필드는 보스 정의에 레벨 직접"], size=9.2, gap=5)
card(sl, 8.78, 3.1, 3.85, 2.85, "⑤~⑦", "결정 위치", ["잡은 (부위 · 아이템 레벨 · 등급)까지만 정해 바닥에 떨어뜨림 — 등급 색 · 부위 아이콘만 필요", "줍는 순간 옵션을 굴려 인스턴스 생성(테이블 참조가 많아 관리 코드)", "eDropItemType.Equipment 추가 · 드롭 엔티티에 (부위, 등급, ilvl) 실음"], size=9.2, gap=5)
D.banner(sl, "요지", "바닥에는 '등급 색이 있는 상자'만 떨어지고, 주울 때 열립니다 — 잡은 가볍게, 옵션은 관리 코드에서.", y=6.15)
# 08 획득 찬스 공식 =======================================================================
sl = D.slide("아이템 획득 찬스 — 더 자주, 더 좋게", "DROP | 획득 찬스", "획득 찬스는 상위 등급일수록 가중치를 더 키웁니다. 1등급은 그대로, 10등급은 찬스 100% 에 2배.",
"근거 초안 v0 §2-2 · 등급 가중치는 §1 자리표시 값")
code(sl, X0, Y0, 5.9, 1.5, ["W'_g = W_g × (1 + chance × k_g)", "k_g = (g − 1) / 9 1등급 0 … 10등급 1", "레벨 게이트: 몬스터 레벨 < 최저 출현 레벨 → W'_g = 0", "정규화 후 누적 확률 추첨"], size=10)
rows = [["등급", "1", "3", "5", "7", "10"], ["k_g", "0", "0.22", "0.44", "0.67", "1.00"], ["찬스 0%", "×1.00", "×1.00", "×1.00", "×1.00", "×1.00"], ["찬스 50%", "×1.00", "×1.11", "×1.22", "×1.33", "×1.50"], ["찬스 100%", "×1.00", "×1.22", "×1.44", "×1.67", "×2.00"]]
D.table(sl, X0, 3.35, 5.9, 1.7, rows, size=9.5, hsize=9.5)
D.text(sl, X0, 5.2, 5.9, 0.7, "표 읽기: 찬스 100% 일 때 10등급 가중치는 2배, 5등급 1.44배, 1등급은 그대로 — 정규화하면 상위 등급 몫이 커지고 1등급 몫은 줄어든다. 레벨 게이트에 막힌 등급은 찬스와 무관하게 0.", size=8.8, color=SUB, spacing=1.2)
card(sl, 6.85, Y0, 5.78, 3.4, "설계", "두 축을 한 옵션이 올린다", ["드롭 여부(①)에도 같은 chance 를 곱하므로 '더 자주 + 더 좋은' 두 축이 함께 오른다",
"보스는 레벨 게이트를 1단계 완화(상위 등급 미리 보기)", "의견 C: 두 축을 한 옵션이 올리면 지배 옵션이 될 수 있다 → 빈도 = ItemChance, 등급 = 별도 '행운' 으로 나누는 디아블로 2 방식도 검토 요청",
"PD 확정(10-01): 경험치 · 골드 · 아이템 획득은 셋으로 분리. 기존 IncreaseDrop 옵션은 셋 모두에 적용되는 하위 호환"], size=9.2, gap=5)
D.banner(sl, "요지", "찬스 100% 면 10등급 가중치 2배, 5등급 1.44배, 1등급 그대로 — 분포가 위로 밀립니다.", y=6.15)
# 09 부위 8종 · 고정 스텟 ==================================================================
sl = D.slide("부위 8종 — 고정1 하나 + 부위별 랜덤 옵션 풀", "PARTS | 부위 · 고정1 · 옵션 풀", "PD 확정(10-01): 모든 아이템은 고정1 하나만 갖고, 랜덤 옵션(등급별 N개)은 그 부위 성격에 맞는 옵션 풀에서만 뽑습니다. 풀은 제안입니다.",
"근거 초안 v0 §3-1 · PD 확정(10-01): 고정1 지정 · 반지 1 · 목걸이 1 · 풀은 제안(Q24)")
rows = [["부위", "고정1 (PD 확정)", "랜덤 옵션 풀 [제안] — 등급별 N개를 이 중에서 († = 고정1 과 같은 능력치, 값 상한 50%)", "비고"],
["무기", "공격력", "공격력† · 고정 피해 · 공격 속도% · 치명타 확률 · 치명타 피해 · 근접 공격력% · 원거리 공격력%", "근접/원거리 공격력% 는 무기 종류에 맞는 쪽만"],
["보조 장비", "종류별", "종류별 — 다음 장", "무기가 허용하는 종류만 장착"],
["갑옷", "방어력", "방어력† · 최대 생명력 · 근접 저항 · 원거리 저항 · 피해 감소량 · 고정 피해 감소 · 상태이상 저항", "몸통 = 버티기"],
["벨트", "생명력", "생명력† · 초당 회복량 · 회복 효과 증가량 · 처치 시 생명력 회복 · 타격 당 생명력 회복 · 흡혈 효과 증가량", "허리 = 회복"],
["장갑", "방어력", "방어력† · 공격 속도% · 치명타 확률 · 근접 공격력% · 원거리 공격력% · 투사체 속도 · 타격 당 생명력 회복", "손 = 손놀림"],
["부츠", "생명력", "생명력† · 이동 속도 · 회피 확률 · 구르기 재사용 시간 · 상태이상 저항 · 고정 피해 감소", "발 = 기동 · 회피"],
["반지", "치명타 확률% / 공격 속도% / 스킬 쿨타임% / 치명타 피해 중 랜덤", "치명타 확률%† · 공격 속도%† · 스킬 쿨타임%† · 치명타 피해† · 근접/원거리/스킬/신성 피해 증가량 · 고정 피해 · 관통 피해량 · 범위 공격 데미지 · 기본 공격 사거리 · 경험치 · 골드 · 아이템 획득 찬스 · 모든 스킬 레벨", "공격 · 성장 장신구 · 슬롯 1"],
["목걸이", "생명력% / 공격력% / 방어력% / 스킬 피해량% 중 랜덤", "생명력%† · 공격력%† · 방어력%† · 스킬 피해량%† · 근접 저항 · 원거리 저항 · 신성 저항 · 경험치 · 골드 · 아이템 획득 찬스 · 모든 스킬 레벨 · 버프 지속 시간", "성장 · 저항 장신구 · 슬롯 1"]]
D.table(sl, X0, Y0, XW, 3.9, rows, colw=[1.0, 2.5, 6.3, 2.14], size=8.6, hsize=9, col_colors=[None, RED, BODY, SUB], col_bold=[True, True, False, False])
D.text(sl, X0, 5.75, XW, 0.4, "아이템 = 고정1(항상 1개) + 랜덤 옵션 N개(등급이 개수, 값은 등급 티어). 고정1 과 같은 능력치도 랜덤으로 나올 수 있되 값은 고정1 의 50% 를 넘지 못함(PD 확정). 저항은 근접 · 원거리 · 신성 셋(물리/마법 없음). 1등급은 옵션 0개라 고정1 만.", size=8.6, color=BODY, spacing=1.2)
D.banner(sl, "요지", "고정1 이 '이 부위가 무엇인가'를, 부위 풀에서 뽑힌 랜덤 옵션이 '이 아이템이 왜 특별한가'를 정합니다.", y=6.15)
# 10 보조 장비 8종 ========================================================================
sl = D.slide("보조 장비 8종 — 고정1 · 랜덤 옵션 풀 · 무기 허용표(안)", "PARTS | 보조 장비", "PD 확정(10-01): 종류 8 · 고정1. 랜덤 옵션 풀과 허용 무기 열은 제안이며 Q16 · Q24 로 확정을 요청합니다.",
"근거 초안 v0 §3-2 · 무기 6종 = eWeaponType(Sword · BluntWeapon · Bow · Dagger · MagicStick · MagicDagger)")
rows = [["보조 장비", "고정1 (PD 확정)", "랜덤 옵션 풀 [제안] († = 고정1 과 같은 능력치, 값 상한 50%)", "허용 무기(안)", "비고"],
["방패", "막기 확률", "막기 확률† · 방어력 · 피해 감소량 · 근접 저항 · 고정 피해 감소 · 최대 생명력", "검 · 둔기", "막기 = 피해 50% 감소"],
["화살통", "원거리 피해 증가량", "원거리 피해 증가량† · 투사체 수 · 투사체 속도 · 기본 공격 사거리 · 관통 피해량 · 공격 속도%", "활", ""],
["마법책", "스킬 피해 증가량", "스킬 피해 증가량† · 스킬 쿨타임% · 공격 범위 · 버프 지속 시간 · 원거리 저항 · 모든 스킬 레벨", "마법 지팡이 · 마법 단검", ""],
["단검(수리검 대체)", "치명타 확률", "치명타 확률† · 치명타 피해 · 공격 속도% · 근접 공격력% · 고정 피해 · 흡혈 효과 증가량", "단검 · 검", "단검 = 치명타 축"],
["투척 무기", "치명타 피해", "치명타 피해† · 원거리 공격력% · 투사체 속도 · 범위 공격 데미지 · 공격 범위 · 관통 피해량", "검 · 둔기 · 단검", "근접 무기에 원거리 보조"],
["오브", "스킬 쿨타임 감소", "스킬 쿨타임%† · 스킬 피해 증가량 · 회복 효과 증가량 · 초당 회복량 · 원거리 저항 · 신성 피해 증가량", "마법 지팡이 · 마법 단검", ""],
["보조 무기(이도류용 검)", "근접 피해 증가량", "근접 피해 증가량† · 공격 속도% · 근접 공격력% · 고정 피해 · 막기 확률(쳐내기) · 타격 당 생명력 회복", "검 · 단검", "보조 무기 = 공격 속도 · 근접 축"],
["없음(양손 무기 취급)", "무기 고정1 +25%", "(아이템이 없으므로 옵션 없음) → Q22", "모든 무기", "비우는 선택 = 양손 빌드"]]
D.table(sl, X0, Y0, XW, 3.9, rows, colw=[1.7, 1.6, 4.9, 1.9, 1.84], size=8.8, hsize=9.2, col_colors=[None, RED, BODY, INK, SUB], col_bold=[True, True, False, False, False])
D.text(sl, X0, 5.62, XW, 0.5, "보조 장비 인스턴스의 EquipmentType = SubEquip, 종류는 SubEquipKind(ItemTable 열). 드롭 시 부위가 보조 장비로 정해지면 현재 장착 무기가 허용하는 종류에서만 추첨(쓸 수 없는 보조가 떨어지는 일을 막음). 같은 능력치가 여러 부위 풀에 겹치는 것은 의도 — 한 축을 여러 부위에서 쌓아 빌드를 만듭니다.", size=8.8, color=BODY, spacing=1.25)
D.banner(sl, "결정 요청", "Q16 허용표 · Q22 '없음'의 옵션 · Q24 풀 보완(부위당 5~7 → 8~10 으로 넓힐지).", y=6.15)
# 11 랜덤 옵션 풀 =========================================================================
sl = D.slide("랜덤 옵션 풀 테이블 — ItemAffixTable(신규)", "OPTIONS | 랜덤 옵션 풀", "앞 두 장의 부위별 풀을 담는 그릇입니다. 능력치 41종을 부위(AllowParts) · 아이템 레벨 구간 · 값 구간 · 가중치 · 중복 그룹으로 정의합니다.",
"근거 초안 v0 §3-3 · ConditionalItemOptionAbility 36종(기존)")
rows = [["열", "뜻"], ["Index", "옵션 ID"], ["Ability", "능력치(eAbility)"], ["AllowParts", "붙을 수 있는 부위 비트마스크(보조 장비는 종류별 비트) — 앞 두 장의 부위별 풀이 곧 이 열"], ["MinIlvl / MaxIlvl", "이 옵션이 나오는 아이템 레벨 구간 — 같은 능력치가 구간별로 여러 행 = 티어"],
["MinValue / MaxValue", "값 구간(등급 값 티어로 구간 선택)"], ["Weight", "추첨 가중치"], ["Group", "같은 Group 은 한 아이템에 중복 불가"], ["Prefix / Suffix", "이름 조합용(선택)"]]
D.table(sl, X0, Y0, 6.6, 3.6, rows, colw=[1.8, 4.8], size=9.2, hsize=9.5)
card(sl, 7.55, Y0, 5.08, 3.6, "추첨", "옵션이 붙는 순서", ["등급별 옵션 수만큼, 그 부위의 풀(AllowParts) · 아이템 레벨 구간을 만족하는 행에서 가중치 추첨", "Group 이 같은 옵션은 한 번만 · 고정1 과 같은 능력치는 허용하되 값을 고정1 의 50% 로 자름(PD 확정)", "값은 MinValue~MaxValue 사이 랜덤(등급 값 티어가 구간을 고른다)",
"7등급부터 조건부 옵션 36종이 후보에 섞인다", "고정 피해와 신성 피해는 같은 Group — 한 아이템에 동시에 붙지 않음"], size=9.2, gap=5)
D.kpis(sl, X0, 5.4, XW, 0.9, [("41", "PD 제시 능력치 — 공격 19 · 방어 12 · 보조 10", True), ("36", "기존 조건부 옵션 — 새로 만들 필요 없음", False), ("의견 B", "초기 풀은 공격 12 · 방어 6 · 보조 6 으로 좁혀 시작 권장", True)], big=13, small=8.5)
# 12 데이터 구조 변경 =====================================================================
sl = D.slide("데이터 구조 변경 — 얹는 것 세 가지", "DATA | 데이터 구조", "게임 원본 수정은 날짜 · 이유 한글 주석을 답니다. 구버전 세이브는 새 필드가 없어 기본값으로 읽혀 기존 동작을 유지합니다.",
"근거 초안 v0 §4-1~§4-4 · Protocol.cs RawItem · PlayerManager.GetTotalAbilities · JsonCommon.cs eAbility")
card(sl, X0, Y0, 3.85, 4.4, "1", "RawItem 확장", ["ilvl — 드롭 레벨(고정 스텟 기준). 0 = 구버전 → 강화 level 로 대체", "grade — 인스턴스 등급. −1 = ItemJson.ItemGrade 사용", "affixes — 랜덤 옵션 {affixId, value} 목록. null = 없음", "Newtonsoft 가 그대로 저장 · 복원 → 저장 계층 수정 없음"], size=9.2, gap=5)
card(sl, 4.72, Y0, 3.85, 4.4, "2", "합산 경로 한 곳 추가", ["GetTotalAbilities 에서 장착 아이템마다 affixes 를 돌며 rateValues[ability] += value — 기존 고정 옵션 루프 옆 10줄",
"고정 스텟은 CalcAttack / Defence / Health 가 Item_*.json 을 읽는 자리에서 아이템 레벨 · 등급 계수를 곱함", "방어력(정수)은 고정 스텟 경로, eAbility.Defence 는 '피해 감소량 %' 로 유지"], size=9.2, gap=5)
card(sl, 8.78, Y0, 3.85, 4.4, "3", "eAbility · enum 확장(끝에만)", ["eItemGrade 6 → 10 · eEquipmentType 6 → 8(SubEquip · Ring)", "eAbility 신규 28종 — 공격 속도 · 사거리 · 근접/원거리/스킬 피해 · 신성 · 투사체 수 · 범위 · 관통 · 고정 피해 · 저항 3(근접 · 원거리 · 신성) · 회피 · 막기 · 상태이상 저항 · 회복 3 · 흡혈 · 스킬 레벨 · 경험치/골드/아이템 획득",
"기존 재사용 11종 — 이동 속도 · 버프 지속 · 회복 · 공격력 · 치명타 2 · 쿨타임 · 투사체 속도 · 범위 · 생명력 · 피해 감소 · 구르기", "MapTable 에 AreaLevel 열 · ItemAffixTable · CombatConstTable 신규"], size=9, gap=4)
D.banner(sl, "피해 태그", "스킬 테이블에 DamageRange(근접/원거리) · DamageSource(기본공격/스킬) 열, 피해 버퍼에 byte 2 필드 — 물리/마법 속성은 두지 않음(PD 확정). 무기 종류로 기본값 자동 부여.", y=6.15)
# 13 신성 피해 ============================================================================
sl = D.slide("신성 피해 — 방어를 무시하는 순수 피해", "COMBAT | 신성", "PD 확정(10-01): 거리 · 출처 구분 없이 모든 타격에 얹히며, 방어력 · 피해 감소 · 근접/원거리 저항을 거치지 않고 신성 저항만 뺍니다.",
"근거 초안 v0 §4-5 · TakeDamageBufferElement · DamageSystem")
code(sl, X0, Y0, 5.9, 1.5, ["신성 피해 = round(일반 최종 피해 × 신성 피해 증가량%)", " ※ 치명타 · 액설런트 배수가 적용된 뒤의 일반 피해 기준 (치명타와 함께 커진다)", "받는 신성 = 신성 피해 × (1 − 신성 저항%)", "체력 −= 일반 피해(방어 계산) + 받는 신성"], size=9.5)
card(sl, X0, 3.35, 5.9, 2.6, "확정", "PD 답변 반영", ["회피 · 막기 · 보호막은 타격 자체를 막으므로 신성도 함께 막힌다", "기준값은 '일반 최종 피해의 %' — 피해 타입(신성 · 근접 · 물리)을 구분하는 것이므로", "몬스터의 신성 피해 · 플레이어 신성 저항의 의미는 몬스터 작업 때 논의(보류)"], size=9.2, gap=5)
card(sl, 6.85, Y0, 5.78, 4.31, "구현", "피해를 두 줄기로", ["TakeDamageBufferElement 에 int holyDamage 1 필드 추가", "DamageSystem: 일반 피해 × 방어 계산 + 신성 × (1 − 신성 저항) 을 합쳐 체력에서 뺌", "피해 숫자 표시는 한 줄 합산(신성 색 구분은 UI 선택)",
"몬스터가 신성을 주려면 MonsterJson 에 HolyRate 열 1개(보류)", "고정 피해와의 관계 — 둘 다 방어 무시. 고정 = 정수 가산, 신성 = 비율 가산 · 저항 적용. 옵션 풀 Group 을 같게 두어 동시에 붙지 않음"], size=9.2, gap=5)
D.banner(sl, "요지", "신성은 '피해 태그'가 아니라 '별도 줄기'입니다 — 스킬 속성 기획 없이도 성립합니다.", y=6.15)
# 14 방어력 공식 v2 =======================================================================
sl = D.slide("방어력 공식 v2 — 로그형 단일 곡선", "COMBAT | 방어력 공식", "PD 요구: 초반 체감 확실 · 후반 적 피해 급증 · 무조건 비율이 아니라 피해와 방어력의 차이를 감안 · 몬스터 종류별 상한 없음 · 끝으로 갈수록 효과가 줄어드는 로그 모양.",
"근거 초안 v0 §4-6 · 현재 = Character.ApplyAbility 의 1 − 방어/(1000+방어) [실측] · s · Rmax 는 자리표시")
code(sl, X0, Y0, 5.9, 1.35, ["x = 방어력 ÷ 피해 '이 한 방에 비해 내 방어력이 몇 배인가'", "R = Rmax × (1 − e^(−x / s)) s = 0.5 Rmax = 0.80", "받는 피해 = max(1, round(피해 × (1 − R)))"], size=9.5)
card(sl, X0, 3.2, 5.9, 2.75, "읽는 법", "쉬운 말로", ["방어력은 숫자 자체가 아니라 '맞은 한 방에 비해 얼마나 큰가'로 작동 — 잔몹의 약한 타격은 많이 막고, 보스의 큰 한 방은 조금 막는다. 이 차이를 공식이 알아서 만든다",
"곡선은 로그처럼 처음엔 가파르고 갈수록 눕는다 — 0에서 조금만 올려도 확 뛰고, 충분히 높으면 더 올려도 거의 안 는다", "80% 는 하나뿐인 상한이며 곡선이 자연히 붙는다(잘라내는 지점 없음)",
"손잡이 둘 — s(방어가 피해의 몇 배일 때 상한의 63%) · Rmax(상한). CombatConstTable 1행"], size=9, gap=4)
D.text(sl, 6.85, Y0, 5.78, 0.28, "표 2 — 초반 체감 (레벨 5 잔몹 피해 80 고정, 방어력만 올릴 때)", size=10, bold=True, color=INK)
rows = [["방어력", "0", "10", "20", "40", "80", "160", "320"], ["깎는 비율", "0%", "18%", "31%", "51%", "69%", "79%", "80%"], ["받는 피해", "80", "66", "55", "40", "25", "17", "16"]]
D.table(sl, 6.85, Y0 + 0.32, 5.78, 1.0, rows, size=9.2, hsize=9.2)
D.text(sl, 6.85, 3.05, 5.78, 0.5, "첫 갑옷(방어 +20)이 바로 31% 감소로 보이고, 160 을 넘으면 더 올려도 거의 안 변한다. 다음 구역에서 적 피해가 커지면 x 가 작아져 방어력이 다시 의미를 갖는다.", size=8.8, color=SUB, spacing=1.2)
D.text(sl, 6.85, 3.65, 5.78, 0.28, "표 3 — 같은 방어력 800, 한 방 크기별", size=10, bold=True, color=INK)
rows = [["한 방", "100", "400", "800", "1600", "3200", "6400", "12800"], ["깎는 비율", "80%", "79%", "69%", "51%", "31%", "18%", "9%"], ["받는 피해", "20", "86", "247", "791", "2193", "5267", "11597"]]
D.table(sl, 6.85, 3.97, 5.78, 1.0, rows, size=9.2, hsize=9.2)
D.text(sl, 6.85, 5.05, 5.78, 0.9, "한 방이 클수록 덜 막힌다. 방어력의 16배가 넘는 한 방은 10% 아래, 64배면 사실상 0 — 보스의 큰 한 방은 체력 · 회피 · 막기 · 보호막 · 피해 감소량으로 버틴다(PD 결정 10-01: 우선 이대로).", size=8.8, color=SUB, spacing=1.2)
D.banner(sl, "요지", "방어력은 '잔타를 흘리는 힘'. 큰 한 방은 방어력을 같이 키워야 버티고, 아주 큰 한 방은 다른 스텟의 몫입니다.", y=6.15)
# 15 방어력 레벨별 · 피해 순서 =============================================================
sl = D.slide("방어력 v2 — 레벨별 거동과 피해 처리 순서", "COMBAT | 방어력 · 피해 순서", "표 1은 방어와 적 피해가 같이 자랄 때(자리표시 곡선)입니다. 오른쪽은 한 타격이 체력에서 빠지기까지의 9단계입니다.",
"근거 초안 v0 §4-6 · 곡선 가정: 방어 = 30+4lv+0.6lv² · 잔몹 = 20+6lv+1.2lv² · 보스 = 잔몹×4 · 현재 = 지금 게임 공식")
rows = [["레벨", "방어력", "잔몹 피해", "R", "받는 피해", "현재 R", "보스 피해", "R", "받는 피해"],
["1", "35", "27", "74%", "7", "3%", "108", "38%", "67"], ["5", "65", "80", "64%", "29", "6%", "320", "27%", "235"], ["10", "130", "200", "58%", "84", "12%", "800", "22%", "622"],
["20", "350", "620", "54%", "284", "26%", "2,480", "20%", "1,992"], ["40", "1,150", "2,180", "52%", "1,043", "53%", "8,720", "19%", "7,103"],
["60", "2,430", "4,700", "52%", "2,277", "71%", "18,800", "18%", "15,374"], ["80", "4,190", "8,180", "51%", "3,985", "81%", "32,720", "18%", "26,806"]]
D.table(sl, X0, Y0, 6.4, 2.9, rows, size=8.8, hsize=8.8, col_colors=[None, BODY, BODY, RED, INK, SUB, BODY, RED, INK], col_bold=[True, False, False, True, False, False, False, True, False])
D.text(sl, X0, 4.65, 6.4, 1.3, "레벨 5: 잔몹 80 → 29, 보스 320 → 235. 같은 방어력인데 큰 한 방은 덜 막힌다.\n레벨 80: 잔몹 절반, 보스 18% — 보스의 큰 한 방은 끝까지 위협. 지금 공식은 81% 로 방어가 모든 걸 지배한다.\n'50%대' 가 이어지는 이유: 가정 곡선에서 방어력이 잔몹 피해의 절반쯤으로 같이 자라기 때문. 방어 장비를 안 챙기면 빠르게 떨어지고, 더 챙기면 오른다.", size=8.8, color=SUB, spacing=1.25)
D.text(sl, 7.35, Y0, 5.28, 0.28, "피해 처리 순서(PD 확정 반영)", size=10, bold=True, color=INK)
steps = ["① 회피(확률, 전부 0) → 보호막(1회 무효, 전부 0) ※ 신성 포함", "② 일반 피해 × (1 − R) R = 방어력 공식", "③ × (1 − 피해 감소량%) 기존 decreaseRate", "④ × (1 − 근접/원거리 저항%) 피해 거리 태그별",
"⑤ + 관통 피해 = 공격력 × 관통% 방어력 미적용 (③④ 여부 Q18)", "⑥ + 신성 피해 = 일반 최종 피해 × 신성% × (1 − 신성 저항%)", "⑦ 막기(확률) → 여기까지 합계 × 0.5 ※ 신성 포함", "⑧ + 공격자 고정 피해(정수) 방어 무시", "⑨ − 피격자 고정 피해 감소(정수) → 받는 피해 = max(1, round)"]
D.panel(sl, 7.35, Y0 + 0.32, 5.28, 3.95, radius=0.05)
for i, s in enumerate(steps):
D.text(sl, 7.5, Y0 + 0.42 + i * 0.42, 5.0, 0.4, s, size=8.8, color=RED if i in (4, 5, 6) else BODY, bold=i in (4, 5, 6))
D.banner(sl, "구현", "DefenceComponent 에 int defence 추가, DamageSystem 1곳에서 타격마다 R 계산 — Burst 잡 안 나눗셈 · exp 각 1회.", y=6.15)
# 16~18 스텟 매핑표 =======================================================================
def mapping_slide(title, section, rows, colw, note):
sl = D.slide(title, section, "PD 제시 능력치가 기존 어느 능력치를 재사용하고, 무엇이 신규이며, 어디에서 적용되는지입니다. 굵은 빨강 = 신규. " + note,
"근거 초안 v0 §5 · eAbility 21종(기존) · PD 확정 답변 2026-10-01")
D.table(sl, X0, Y0, XW, 0.3 + 0.255 * (len(rows) - 1), rows, colw=colw, size=8.4, hsize=9, col_colors=[None, SUB, RED, BODY, INK], col_bold=[True, False, True, False, False])
return sl
atk = [["PD 스텟(공격 19)", "기존 재사용", "신규", "적용 지점", "판단"],
["공격력", "Attack(%) + 고정 정수", "", "기존", "그대로"], ["공격 속도", "", "AttackSpeed", "무기 스킬 발동 쿨타임 감소%(PD 확정) · 기존 Cooltime 은 일반 스킬 전용으로 분리", "Q20"],
["치명타 확률 / 피해", "Critical / CriticalDamage", "", "기존", "그대로"], ["스킬 쿨타임", "Cooltime", "", "기존 — 무기 스킬 외 일반 스킬", "Q20"],
["기본 공격 사거리", "", "BaseAttackRange", "무기 스킬 사거리 증감%(PD 확정)", "무기 스킬 모듈 사거리에 곱"], ["근접/원거리/스킬 피해 증가량", "", "Melee/Ranged/SkillDamage", "HitEntity(태그) — 최종 피해 곱%(PD 확정)", "태그 신설"],
["신성 피해 증가량", "", "HolyDamage", "HitEntity → holyDamage 별도 줄기", "방어 무시 · 신성 저항만"], ["근접/원거리 공격 증가량", "", "Melee/RangedAttack", "CalcAttack(무기 태그) — 공격력 수치 %(PD 확정)", "공격력 가산"],
["투사체 속도", "ProjectileSpeed", "", "기존", "그대로"], ["투사체 수", "", "ProjectileCount", "스킬 모듈", "스킬 시스템 수정"], ["공격 범위", "SkillRange", "", "기존", "그대로"],
["범위 공격 데미지 증가량", "", "AreaDamage", "HitEntity(범위 태그)", "태그 신설"], ["관통 피해량", "", "Pierce", "DamageSystem ⑤ — 공격력 × 관통% 방어 계산 없이 가산(PD 확정)", "추가"],
["고정 피해 / 증가량", "", "FlatDamage / FlatDamageRate", "DamageSystem ⑧ — 최종 연산 뒤 정수 가산(PD 확정)", "추가"]]
mapping_slide("능력치 매핑 ① — 공격 스텟 19", "MAPPING | 공격", atk, [2.2, 1.9, 2.3, 4.5, 1.04], "재사용 6 · 신규 11.")
dfn = [["PD 스텟(방어 12)", "기존 재사용", "신규", "적용 지점", "판단"],
["최대 생명력", "Health(%) + 고정 정수", "", "기존", "그대로"], ["방어력", "정수(고정 스텟 경로)", "", "방어력 공식 v2(§4-6)", "공식 교체"],
["물리/마법 → 근접/원거리 저항(PD 확정)", "", "Melee/RangedResist", "DamageSystem ④(거리 태그)", "태그 신설"], ["신성 저항", "", "HolyResist", "DamageSystem ⑥ holyDamage 줄기", "몬스터 신성 피해가 있어야 의미(보류)"],
["회피 확률", "", "Evasion", "DamageSystem ①", "추가"], ["초당 회복량", "RecoveryHealth", "", "기존", "그대로"], ["구르기 재사용 시간", "Rolling", "", "기존", "그대로"],
["고정 피해 감소", "", "FlatDamageReduce", "DamageSystem ⑨ — 최종 연산 뒤 정수 차감(PD 확정)", "추가"], ["피해 감소량", "Defence(%)", "", "기존 ③", "그대로"],
["막기 확률", "", "Block", "DamageSystem ⑦ — 확률 발동 · 피해 50% · 모든 부위(PD 확정)", "추가"], ["상태이상 저항", "", "StatusResist", "상태이상 부여 지점 [미검증]", "추가"]]
mapping_slide("능력치 매핑 ② — 방어 스텟 12", "MAPPING | 방어", dfn, [2.2, 1.9, 2.3, 4.1, 1.44], "재사용 4 · 신규 7 · 방어력은 공식만 교체.")
sub = [["PD 스텟(보조 10)", "기존 재사용", "신규", "적용 지점", "판단"],
["이동 속도", "Speed / SpeedValue", "", "기존", "그대로"], ["버프 지속 시간 증가량", "SkillDuration", "", "기존", "그대로"],
["타격 당 생명력 회복량", "", "HealthOnHit", "HitRecord 처리", "추가"], ["처치 시 생명력 회복량", "(조건부 RecoverByKill… 유사)", "HealthOnKill", "DeadRecord 처리", "추가"],
["흡혈 효과 증가량", "", "LifeSteal", "HitEntity 결과", "추가"], ["회복 효과 증가량", "", "HealEffect", "모든 회복 지점", "추가"],
["모든 스킬 레벨 증가", "", "AllSkillLevel", "유효 레벨 = 로비 레벨 + 가산 → 레벨당 계수 상승, UI 강화 상한 초과(PD 확정)", "Q21 상한 · 외삽"],
["경험치 획득량 증가", "IncreaseDrop(통합)", "ExpGain", "기존 지점 — 분리(PD 확정)", "IncreaseDrop 은 셋 모두 하위 호환"], ["골드 획득량 증가", "IncreaseDrop(통합)", "GoldGain", "기존 지점 — 분리(PD 확정)", ""],
["아이템 획득 찬스 증가", "IncreaseDrop(통합)", "ItemChance", "드롭 여부 · 등급 가중치(§2-2)", ""]]
mapping_slide("능력치 매핑 ③ — 보조 스텟 10", "MAPPING | 보조", sub, [2.2, 1.9, 2.3, 4.1, 1.44], "재사용 2 · 신규 8 · IncreaseDrop 은 셋으로 분리.")
# 19 구현 단계 ============================================================================
sl = D.slide("구현 단계 — 승인 후 P1부터", "PLAN | 구현 단계", "여섯 단계로 나누어 매 단계 실측 검증을 붙입니다. 스킬 시스템 수정이 필요한 넷은 보류로 뒤에 둡니다.",
"근거 초안 v0 §6 · 원본 수정은 날짜 · 이유 한글 주석 · 새 스크립트는 BT_Visual 노드")
rows = [["단계", "내용", "원본 수정", "검증"],
["P1 데이터", "eItemGrade 10 · eEquipmentType 8 · eAbility 추가 · RawItem 확장 · ItemAffixTable · MapTable.AreaLevel · CombatConstTable", "enum · 클래스 4곳", "로드 · 저장 왕복"],
["P2 생성기", "TVItemGenerator(BT_Visual): 부위 → 아이템 레벨 → 등급 → 정의 → 옵션 추첨. 치트로 N개 생성해 분포 확인", "없음(신규)", "10만 회 분포 표"],
["P3 드롭 연결", "잡에 장비 드롭 종류 추가, 줍는 순간 인스턴스 생성", "SpawnDropItemSystem · DropItemSystem · PlayerEntitySyncSystem", "필드 Play 실측"],
["P4 능력치 적용", "합산 10줄 + 방어 공식 v2 + 회피 · 막기 · 관통 · 고정 피해 · 흡혈 · 회복 적용", "GetTotalAbilities · HitEntity · DamageSystem", "피해 로그"],
["P5 피해 태그 · 신성", "스킬 테이블 열 + 버퍼 필드 + 저항/증가 적용, holyDamage 분리 전달", "스킬 테이블 · FxComponentPlay · DamageSystem", "속성별 실측 · 방어 0/최대에서 신성 불변"],
["P6 UI", "가방 · 정보 창에 등급 색 · 옵션 목록 · 아이템 레벨, 새 부위 슬롯 2(보조 · 반지)", "ItemInfoPopUp · 장비 창", "화면 확인"],
["보류", "공격 속도(무기 스킬 쿨타임) · 투사체 수 · 관통 · 상태이상 저항 · 모든 스킬 레벨", "스킬 시스템", "실측 후 별도"]]
D.table(sl, X0, Y0, XW, 4.3, rows, colw=[1.5, 5.6, 3.0, 1.84], size=9, hsize=9.5, col_colors=[None, BODY, SUB, INK], hl_rows=(7,))
D.banner(sl, "원칙", "매 단계 작업 트리에 두고 보고, 커밋 · push 는 PD 지시 시만. 오류 확인은 에디터 로그 기준.", y=6.15)
# 20 확정 사항 ============================================================================
sl = D.slide("확정 사항 — PD 답변 11묶음 (2026-10-01)", "DECISIONS | 확정", "이 초안이 따르는 PD님 결정입니다(질문 13건을 11묶음으로 정리). 이후 수치 설계 · 구현은 이 정의를 바꾸지 않습니다.",
"근거 PD 대화 2026-10-01 · 초안 v0 §7 확정 목록")
left = [("신성 피해", "거리 · 출처 구분 없이 모든 타격에 얹히는 방어 무시 순수 피해. 기준 = 일반 최종 피해의 %(치명타와 함께 커짐). 회피 · 막기 · 보호막은 신성도 막음. 몬스터 신성은 보류"),
("공격 속도 · 사거리", "공격 속도 = 무기 스킬 발동 쿨타임. 기본 공격 사거리 = 무기 스킬 사거리 증감"),
("피해 vs 공격 증가량", "피해 증가량 = 최종 피해 곱% · 공격 증가량 = 공격력 수치 %"),
("관통 · 고정 피해", "관통 = 공격력 × 관통% 를 방어 계산 없이 가산. 고정 피해 = 최종 연산 뒤 정수 가산 · 고정 피해 감소 = 정수 차감"),
("막기", "모든 부위 옵션 · 확률 발동 · 피해 50% 감소"),
("모든 스킬 레벨", "로비 스킬 레벨 가산 · 레벨당 증가량 계수 상승 · UI 강화 상한 초과 적용")]
right = [("획득량 분리", "경험치 · 골드 · 아이템 획득 셋 분리"), ("장신구", "반지 1 · 목걸이 1"), ("보조 장비", "방패 · 화살통 · 마법책 · 수리검 · 투척 무기 · 오브 · 보조 무기(이도류용 검) · 없음(양손 무기 취급)"),
("등급", "성능 차이만. 세트는 별도 구성"), ("방어력 공식", "잔몹 · 보스 별도 상한 없음 · 로그형 단일 곡선 · 우선 v2 그대로(큰 한 방에 방어력이 거의 안 듣는 점 인지)")]
for i, (t, s) in enumerate(left):
yy = Y0 + i * 0.72
D.num(sl, X0, yy + 0.04, i + 1, d=0.24, size=8.5)
D.text(sl, X0 + 0.35, yy, 5.6, 0.26, t, size=10, bold=True, color=INK)
D.text(sl, X0 + 0.35, yy + 0.26, 5.6, 0.45, s, size=8.6, color=SUB, spacing=1.15)
for i, (t, s) in enumerate(right):
yy = Y0 + i * 0.86
D.num(sl, 6.85, yy + 0.04, i + 7, d=0.24, size=8.5)
D.text(sl, 7.2, yy, 5.4, 0.26, t, size=10, bold=True, color=INK)
D.text(sl, 7.2, yy + 0.26, 5.4, 0.55, s, size=8.6, color=SUB, spacing=1.15)
D.banner(sl, "고정", "위 확정 사항은 초안 §7 에 기록했으며, 이후 문서 · 코드는 이 정의를 따릅니다.", y=6.15)
# 21 결정 대기 ============================================================================
sl = D.slide("결정 대기 10건 — PD님께 드리는 질문", "DECISIONS | 결정 대기", "각 항목에 제안(권장)을 붙였습니다. Q22 · Q24(보조 '없음'의 옵션 · 부위별 풀 보완)는 앞 장 참조. 결정해 주시면 초안을 확정본으로 올리고 P1 을 착수합니다.",
"근거 초안 v0 §7 결정 대기 · 의견 B · C")
qs = [("Q14", "몬스터 방어력", "방어력 공식 v2 를 몬스터에도 적용할지. 적용하면 공격력이 상위 구역 진입 게이트가 된다", "권장: 적용(구역 레벨 게이트)"),
("Q15", "방어 공식 손잡이", "s=0.5 · Rmax=0.80 은 자리표시. 방향(잔몹 약 50% · 보스 약 20% · 첫 갑옷 30%)만 먼저 정할지", "권장: 방향 확정, 수치는 스탯 설계에서"),
("Q16", "보조 장비 허용표", "수리검 · 투척 무기가 근접 전용인지, 마법책 · 오브가 지팡이 · 마법 단검 공용인지", "권장: 표(안) 그대로"),
("Q17", "양손 무기 보정", "보조 슬롯을 비우면 무기 고정 스텟 +25%(안). 보정 없이 순수 선택으로 둘 수도", "권장: +25%"),
("Q18", "관통의 저항 적용", "방어력만 무시하고 피해 감소량% · 물리/마법 저항은 받는 안. 전부 무시(신성과 동일)도 가능", "권장: 방어력만 무시"),
("Q19", "막기와 고정 피해", "막기 50% 를 고정 피해 가감 앞에 두는 안(⑦). 고정 피해까지 절반으로 만들려면 뒤로", "권장: 앞(⑦)"),
("Q20", "공격 속도 · 쿨타임 분리", "기존 Cooltime 을 '일반 스킬만'으로 좁혀야 두 옵션이 구분된다. 기존 아이템 옵션의 Cooltime 이 무기 스킬에서 빠지는 하위 호환 변화", "권장: 분리, 기존 옵션은 둘 다 적용으로 호환"),
("Q21", "모든 스킬 레벨 상한", "유효 레벨에 상한(예: 테이블 최대 +10)을 둘지. 상한 초과는 마지막 행 계수 선형 외삽", "권장: 상한 +10 · 외삽")]
for i, (q, t, s, r) in enumerate(qs):
col = i // 4; row = i % 4
x = X0 + col * (XW / 2 + 0.08); y = Y0 + row * 1.1; w = XW / 2 - 0.08
D.card(sl, x, y, w, 1.0)
D.rect(sl, x, y, w, 1.0, fill=None, line=RULE, shape=MSO_SHAPE.ROUNDED_RECTANGLE, radius=0.03)
D.pill(sl, x + 0.12, y + 0.12, q, fill=RED, w=0.55)
D.text(sl, x + 0.75, y + 0.1, w - 0.9, 0.26, t, size=10, bold=True, color=INK)
D.text(sl, x + 0.12, y + 0.4, w - 0.24, 0.4, s, size=8.4, color=SUB, spacing=1.15)
D.text(sl, x + 0.12, y + 0.76, w - 0.24, 0.22, r, size=8.4, bold=True, color=RED)
D.banner(sl, "다음", "결정 후 → 초안 확정본(v1) → P1 데이터 구조 착수 → 매 단계 실측 보고.", y=6.15)
n = D.save(OUT)
print(n, OUT)

View File

@ -0,0 +1,147 @@
# -*- coding: utf-8 -*-
# 2026-10-01 PD 지시 — civitai 143301 "Game Icon Institute anfg v3" 페이지의 아이콘 풍을 최대한 따라 만들고, 배경은 반드시 투명.
# python comfy_gii_site.py <출력폴더> <round 이름> <시작 번호> <끝 번호(미포함)> [LoRA 강도=0.6] [시드 수=2]
# 페이지에 적힌 사용법 그대로: 프롬프트 "game icon institute, game icon, ahfg, <대상>, still life, black background" · LoRA 0.6 ·
# 부정 "(easynegative:0.8), black, dark, Low resolution"(easynegative 임베딩은 이 PC 에 없어 같은 뜻의 낱말로 대신한다).
# 기반 모델: 페이지 권장은 gameIconInstitute v3.0(SD1.5)인데 이 PC 에 없어 보유한 SD1.5 체크포인트를 쓴다(바뀌면 CKPT 만 고친다).
# 512 로 그린 뒤 2배로 다시 그려(1024) 선명도를 올리고, InspyrenetRembg 로 배경을 빼 RGBA 로 저장한다.
import io, json, os, sys, time, uuid, urllib.request, urllib.parse
from PIL import Image, ImageDraw
HOST = "http://127.0.0.1:8000"
CKPT = "difconsistencyCheckpoint_v10.safetensors"
LORA = "GameIconInstitute_anfg_v3.safetensors"
TRIGGER = "game icon institute, game icon, ahfg"
NEG = "black, dark, low resolution, worst quality, low quality, blurry, text, watermark, signature, jpeg artifacts, cropped, out of frame, person, hands, multiple views"
# FR 장비 전 부위 — 페이지 예시처럼 대상 낱말만 담백하게 쓴다(학습 태그에 있던 낱말을 우선: ring · shield · gem · armor · helmet · gauntlet · boots · sword · belt · necklace)
SUBJECTS = [
("sword", "sword, longsword"),
("blunt", "mace, war hammer, blunt weapon"),
("bow", "bow, longbow"),
("dagger", "dagger"),
("staff", "magic staff, wizard staff with crystal"),
("magic_dagger", "magic dagger, enchanted dagger, glowing"),
("armor", "armor, breastplate"),
("belt", "belt, leather belt with metal buckle"),
("gloves", "gloves, gauntlets"),
("boots", "boots, armored boots"),
("ring", "ring, gem"),
("necklace", "necklace, pendant, gem"),
("shield", "shield"),
("quiver", "quiver, leather quiver with arrows inside"),
("spellbook", "book, magic book, closed spellbook"),
("throwing", "throwing knife, kunai"),
("orb", "orb, crystal ball, glowing"),
("offhand_sword", "short sword"),
]
# 2차(다시 돌리기) — 1차에서 형태가 약했던 것만. 직접 보고 고친 점:
# 검: 두 자루가 교차 → "single" 과 부정어 · 둔기: 창처럼 나옴 → 머리 모양을 적음 · 활: 활이 아님 → 시위 · 굽은 몸체를 적음
# 지팡이: 조각남 → 한 자루 · 화살통: 가방처럼 나옴 → 원통 · 깃 · 마법책: 펼쳐짐/상자 → 닫힌 책 한 권 · 투척 무기: 검처럼 나옴 → 작은 표창
RETRY = [
("sword", "sword, single one-handed sword, straight blade, crossguard, diagonal"),
("blunt", "mace, single flanged mace, heavy round metal head, short handle"),
("bow", "bow \\(weapon\\), single recurve bow, curved wooden limbs, bowstring"),
("staff", "staff, single long wooden staff, large crystal on top, diagonal"),
("magic_dagger", "dagger, single curved dagger, glowing blue runes on blade"),
("quiver", "quiver, cylindrical leather arrow quiver, many arrows with feather fletching sticking out of the top, strap"),
("spellbook", "book, single closed thick book, leather cover, metal corners, gem on cover"),
("throwing", "shuriken, single four-pointed steel throwing star"),
]
RETRY_NEG = ", two weapons, crossed weapons, multiple objects, duplicate, open book, pages, chest, box"
def post(path, data):
req = urllib.request.Request(HOST + path, data=json.dumps(data).encode("utf-8"), headers={"Content-Type": "application/json"})
return json.loads(urllib.request.urlopen(req, timeout=60).read())
def get(path):
return urllib.request.urlopen(HOST + path, timeout=180).read()
def workflow(prompt, seed, strength):
return {
"1": {"class_type": "CheckpointLoaderSimple", "inputs": {"ckpt_name": CKPT}},
"2": {"class_type": "LoraLoader", "inputs": {"model": ["1", 0], "clip": ["1", 1], "lora_name": LORA, "strength_model": strength, "strength_clip": strength}},
"3": {"class_type": "CLIPSetLastLayer", "inputs": {"clip": ["2", 1], "stop_at_clip_layer": -2}},
"4": {"class_type": "CLIPTextEncode", "inputs": {"clip": ["3", 0], "text": prompt}},
"5": {"class_type": "CLIPTextEncode", "inputs": {"clip": ["3", 0], "text": NEG}},
"6": {"class_type": "EmptyLatentImage", "inputs": {"width": 512, "height": 512, "batch_size": 1}},
"7": {"class_type": "KSampler", "inputs": {"model": ["2", 0], "positive": ["4", 0], "negative": ["5", 0], "latent_image": ["6", 0], "seed": seed, "steps": 28, "cfg": 7.0, "sampler_name": "dpmpp_2m", "scheduler": "karras", "denoise": 1.0}},
# 2배 다시 그리기 — 형태는 지키고 질감만 살리도록 약하게
"10": {"class_type": "LatentUpscaleBy", "inputs": {"samples": ["7", 0], "upscale_method": "bislerp", "scale_by": 2.0}},
"11": {"class_type": "KSampler", "inputs": {"model": ["2", 0], "positive": ["4", 0], "negative": ["5", 0], "latent_image": ["10", 0], "seed": seed, "steps": 20, "cfg": 7.0, "sampler_name": "dpmpp_2m", "scheduler": "karras", "denoise": 0.42}},
"8": {"class_type": "VAEDecode", "inputs": {"samples": ["11", 0], "vae": ["1", 2]}},
"12": {"class_type": "InspyrenetRembg", "inputs": {"image": ["8", 0], "torchscript_jit": "default"}},
"9": {"class_type": "SaveImage", "inputs": {"images": ["12", 0], "filename_prefix": "gii_site"}},
}
def run(prompt, seed, strength):
cid = str(uuid.uuid4())
pid = post("/prompt", {"prompt": workflow(prompt, seed, strength), "client_id": cid})["prompt_id"]
t0 = time.time()
while time.time() - t0 < 900:
hist = json.loads(get("/history/" + pid))
if pid in hist:
st = hist[pid].get("status", {})
if st.get("status_str") == "error":
raise RuntimeError(json.dumps(st)[:800])
outs = hist[pid].get("outputs", {})
if outs:
for node in outs.values():
for im in node.get("images", []):
q = urllib.parse.urlencode({"filename": im["filename"], "subfolder": im.get("subfolder", ""), "type": im.get("type", "output")})
return Image.open(io.BytesIO(get("/view?" + q))).convert("RGBA")
time.sleep(1.5)
raise TimeoutError("comfy timeout")
def checker(size, a=(58, 58, 58), b=(74, 74, 74), n=16):
"""투명이 보이도록 깔아 두는 체크무늬 바탕"""
im = Image.new("RGB", (size, size), a)
d = ImageDraw.Draw(im)
for y in range(0, size, n):
for x in range(0, size, n):
if (x // n + y // n) % 2:
d.rectangle([x, y, x + n - 1, y + n - 1], fill=b)
return im
def main():
out, tag = sys.argv[1], sys.argv[2]
lo, hi = int(sys.argv[3]), int(sys.argv[4])
strength = float(sys.argv[5]) if len(sys.argv) > 5 else 0.6
seeds = int(sys.argv[6]) if len(sys.argv) > 6 else 2
os.makedirs(out, exist_ok=True)
# round 이름이 w 로 시작하면 다시 돌리기 목록을 쓴다
global NEG
retry = tag.startswith("w")
if retry:
NEG = NEG + RETRY_NEG
subs = (RETRY if retry else SUBJECTS)[lo:hi]
cell, pad, label = 256, 6, 18
sheet = Image.new("RGB", (len(subs) * (cell + pad) + pad, seeds * (cell + pad) + pad + label), (40, 40, 40))
draw = ImageDraw.Draw(sheet)
for i, (name, body) in enumerate(subs):
prompt = "%s, %s, still life, black background" % (TRIGGER, body)
for j in range(seeds):
t0 = time.time()
im = run(prompt, 3000 + (lo + i) * 13 + j * 211, strength)
alpha = im.getchannel("A")
opaque = sum(1 for v in alpha.getdata() if v > 16) / float(alpha.width * alpha.height)
print("%s %s #%d %.1f초 불투명 %.0f%%" % (tag, name, j, time.time() - t0, opaque * 100)); sys.stdout.flush()
im.save(os.path.join(out, "%s_%s_%d.png" % (tag, name, j)))
bg = checker(cell)
small = im.resize((cell, cell), Image.LANCZOS)
bg.paste(small, (0, 0), small)
sheet.paste(bg, (pad + i * (cell + pad), label + pad + j * (cell + pad)))
draw.text((pad + i * (cell + pad) + 4, 3), name, fill=(240, 220, 160))
path = os.path.join(out, "%s_sheet_%d_%d.png" % (tag, lo, hi))
sheet.save(path)
print("sheet", path)
if __name__ == "__main__":
main()

View File

@ -0,0 +1,99 @@
# -*- coding: utf-8 -*-
# 2026-10-01 PD 지시 — Civitai "Game Icon Institute anfg v3" LoRA(SD1.5)로 아이콘을 직접 생성해 본다.
# ComfyUI(Comfy Desktop, 127.0.0.1:8000) API 로 작업을 보내고 결과를 받아 한 장으로 모은다.
# python comfy_gii_trial.py <출력폴더> [round 이름]
# LoRA 정보(실측): 기반 sd_v1 · 학습 기반 모델 gameIconInstitute_v30 · 트리거 "game icon institute, game icon, ahfg" · 권장 강도 0.4~0.8
import io, json, os, sys, time, uuid, urllib.request, urllib.parse
from PIL import Image, ImageDraw
HOST = "http://127.0.0.1:8000"
CKPT = "difconsistencyCheckpoint_v10.safetensors" # 보유한 SD1.5 체크포인트(권장 기반 gameIconInstitute_v30 은 없다)
LORA = "GameIconInstitute_anfg_v3.safetensors"
TRIGGER = "game icon institute, game icon, ahfg"
# 2차: 1차에서 배경이 회색 · 그라데이션, 바닥 반사가 나와 배경 관련 부정어를 더했다
NEG = "low resolution, blurry, text, watermark, signature, logo, frame, border, hands, person, cropped, out of frame, jpeg artifacts, grey background, white background, gradient background, yellow background, floor, table, reflection, shadow on ground, bright colors, all gold, brown leather"
# (이름, 프롬프트 본문) — 이번 아이템 시스템에 필요한 반지 + 보조 장비 7종
SUBJECTS = [
# 2차: FR 톤(검은 강철 + 금 장식 + 주황 발광)을 가중치로 밀고, 형태가 어색했던 화살통 · 투척 무기 문구를 고쳤다
("ring", "ring, (black iron ring:1.3), thin gold trim, (glowing orange gemstone:1.2), gem, glowing, dark fantasy"),
("shield", "shield, (black steel kite shield:1.3), gold trim, (glowing orange emblem:1.2), dark fantasy, armor"),
("quiver", "quiver, (upright cylindrical black leather quiver:1.3), arrows with feathers sticking out of the top, gold fittings, orange glow, dark fantasy"),
("spellbook", "book, (closed black leather grimoire:1.3), gold corners, (glowing orange rune on cover:1.2), dark fantasy"),
("dagger", "dagger, (black steel dagger:1.3), gold guard, (glowing orange edge:1.1), dark fantasy, single weapon"),
("throwing", "throwing knives, (three small black steel throwing knives:1.3), fanned out, gold accents, orange glow, dark fantasy"),
("orb", "orb, (glowing orange crystal orb:1.2), held by (black iron claws:1.3), crystal, glowing, dark fantasy"),
("offhand_sword", "short sword, (black steel short sword:1.3), gold hilt, orange gem in guard, dark fantasy, single weapon"),
]
def post(path, data):
req = urllib.request.Request(HOST + path, data=json.dumps(data).encode("utf-8"), headers={"Content-Type": "application/json"})
return json.loads(urllib.request.urlopen(req, timeout=60).read())
def get(path):
return urllib.request.urlopen(HOST + path, timeout=120).read()
def workflow(prompt, seed, lora_strength=0.6, steps=28, cfg=7.0, batch=2, clip_skip=-2):
return {
"1": {"class_type": "CheckpointLoaderSimple", "inputs": {"ckpt_name": CKPT}},
"2": {"class_type": "LoraLoader", "inputs": {"model": ["1", 0], "clip": ["1", 1], "lora_name": LORA, "strength_model": lora_strength, "strength_clip": lora_strength}},
"3": {"class_type": "CLIPSetLastLayer", "inputs": {"clip": ["2", 1], "stop_at_clip_layer": clip_skip}},
"4": {"class_type": "CLIPTextEncode", "inputs": {"clip": ["3", 0], "text": prompt}},
"5": {"class_type": "CLIPTextEncode", "inputs": {"clip": ["3", 0], "text": NEG}},
"6": {"class_type": "EmptyLatentImage", "inputs": {"width": 512, "height": 512, "batch_size": batch}},
"7": {"class_type": "KSampler", "inputs": {"model": ["2", 0], "positive": ["4", 0], "negative": ["5", 0], "latent_image": ["6", 0], "seed": seed, "steps": steps, "cfg": cfg, "sampler_name": "dpmpp_2m", "scheduler": "karras", "denoise": 1.0}},
"8": {"class_type": "VAEDecode", "inputs": {"samples": ["7", 0], "vae": ["1", 2]}},
"9": {"class_type": "SaveImage", "inputs": {"images": ["8", 0], "filename_prefix": "gii_trial"}},
}
def run(prompt, seed, **kw):
cid = str(uuid.uuid4())
pid = post("/prompt", {"prompt": workflow(prompt, seed, **kw), "client_id": cid})["prompt_id"]
t0 = time.time()
while time.time() - t0 < 600:
hist = json.loads(get("/history/" + pid))
if pid in hist:
st = hist[pid].get("status", {})
if st.get("status_str") == "error":
raise RuntimeError(json.dumps(st)[:600])
outs = hist[pid].get("outputs", {})
if outs:
images = []
for node in outs.values():
for im in node.get("images", []):
q = urllib.parse.urlencode({"filename": im["filename"], "subfolder": im.get("subfolder", ""), "type": im.get("type", "output")})
images.append(Image.open(io.BytesIO(get("/view?" + q))).convert("RGB"))
return images
time.sleep(1.5)
raise TimeoutError("comfy timeout")
def main():
out = sys.argv[1]
tag = sys.argv[2] if len(sys.argv) > 2 else "r1"
strength = float(sys.argv[3]) if len(sys.argv) > 3 else 0.6
os.makedirs(out, exist_ok=True)
cell, pad, label = 256, 6, 18
cols = 2
sheet = Image.new("RGB", (len(SUBJECTS) * (cell + pad) + pad, cols * (cell + pad) + pad + label), (40, 40, 40))
draw = ImageDraw.Draw(sheet)
for i, (name, body) in enumerate(SUBJECTS):
prompt = "%s, %s, still life, (pure black background:1.3), simple background, centered, single object, dark, high contrast" % (TRIGGER, body)
t0 = time.time()
images = run(prompt, 1000 + i * 7, lora_strength=strength)
print("%s %s %d장 %.1f초" % (tag, name, len(images), time.time() - t0)); sys.stdout.flush()
for j, im in enumerate(images[:cols]):
im.save(os.path.join(out, "%s_%s_%d.png" % (tag, name, j)))
sheet.paste(im.resize((cell, cell), Image.LANCZOS), (pad + i * (cell + pad), label + pad + j * (cell + pad)))
draw.text((pad + i * (cell + pad) + 4, 3), name, fill=(240, 220, 160))
path = os.path.join(out, "%s_sheet.png" % tag)
sheet.save(path)
print("sheet", path)
if __name__ == "__main__":
main()

View File

@ -0,0 +1,93 @@
# -*- coding: utf-8 -*-
# 2026-10-01 PD 지시 — 체크포인트 gameIconInstitute_v4XL(SDXL)로 사이트 분위기 그대로 아이콘을 만든다(FR 톤으로 밀지 않는다).
# python comfy_gii_xl.py <출력폴더> <round 이름> <스타일: 3dicon|xieshi|2dguofeng|2dXIANTIAO>
# 모델 설명(civitai 47800 · V4_XL): 스타일 태그 gmic_\(3dicon\) · gmic_\(2dguofeng\) · gmic_\(2dXIANTIAO\) · gmic icon_\(xieshi\). 예시 그림의 생성 값은 공개돼 있지 않아
# 같은 제작자의 XL 모델(426087) 권장값(DPM++ 2M · 20 steps · CFG 7 · clip skip 2)을 따른다. SD1.5 용 LoRA(anfg v3)는 XL 과 맞지 않아 쓰지 않는다.
import io, json, os, sys, time, uuid, urllib.request, urllib.parse
from PIL import Image, ImageDraw
HOST = "http://127.0.0.1:8000"
CKPT = "gameIconInstitute_v4XL.safetensors"
STYLE = {"3dicon": "gmic_\\(3dicon\\)", "xieshi": "gmic icon_\\(xieshi\\)", "2dguofeng": "gmic_\\(2dguofeng\\)", "2dXIANTIAO": "gmic_\\(2dXIANTIAO\\)"}
NEG = "worst quality, low quality, normal quality, lowres, blurry, cropped, text, watermark, signature, jpeg artifacts, person, hands"
# 1차(권장 설정 그대로) 때와 같은 담백한 문구 — 톤을 밀지 않는다
SUBJECTS = [
("ring", "a ring with a gemstone"),
("shield", "a shield with an emblem"),
("quiver", "a quiver full of arrows"),
("spellbook", "a closed spellbook, magic book"),
("dagger", "a dagger"),
("throwing", "three throwing knives"),
("orb", "a magic crystal orb on a stand"),
("offhand_sword", "a short sword"),
]
def post(path, data):
req = urllib.request.Request(HOST + path, data=json.dumps(data).encode("utf-8"), headers={"Content-Type": "application/json"})
return json.loads(urllib.request.urlopen(req, timeout=60).read())
def get(path):
return urllib.request.urlopen(HOST + path, timeout=180).read()
def workflow(prompt, seed, size=1024, steps=20, cfg=7.0, batch=1):
return {
"1": {"class_type": "CheckpointLoaderSimple", "inputs": {"ckpt_name": CKPT}},
"3": {"class_type": "CLIPSetLastLayer", "inputs": {"clip": ["1", 1], "stop_at_clip_layer": -2}},
"4": {"class_type": "CLIPTextEncode", "inputs": {"clip": ["3", 0], "text": prompt}},
"5": {"class_type": "CLIPTextEncode", "inputs": {"clip": ["3", 0], "text": NEG}},
"6": {"class_type": "EmptyLatentImage", "inputs": {"width": size, "height": size, "batch_size": batch}},
"7": {"class_type": "KSampler", "inputs": {"model": ["1", 0], "positive": ["4", 0], "negative": ["5", 0], "latent_image": ["6", 0], "seed": seed, "steps": steps, "cfg": cfg, "sampler_name": "dpmpp_2m", "scheduler": "karras", "denoise": 1.0}},
"8": {"class_type": "VAEDecode", "inputs": {"samples": ["7", 0], "vae": ["1", 2]}},
"9": {"class_type": "SaveImage", "inputs": {"images": ["8", 0], "filename_prefix": "gii_xl"}},
}
def run(prompt, seed, **kw):
cid = str(uuid.uuid4())
pid = post("/prompt", {"prompt": workflow(prompt, seed, **kw), "client_id": cid})["prompt_id"]
t0 = time.time()
while time.time() - t0 < 900:
hist = json.loads(get("/history/" + pid))
if pid in hist:
st = hist[pid].get("status", {})
if st.get("status_str") == "error":
raise RuntimeError(json.dumps(st)[:800])
outs = hist[pid].get("outputs", {})
if outs:
images = []
for node in outs.values():
for im in node.get("images", []):
q = urllib.parse.urlencode({"filename": im["filename"], "subfolder": im.get("subfolder", ""), "type": im.get("type", "output")})
images.append(Image.open(io.BytesIO(get("/view?" + q))).convert("RGB"))
return images
time.sleep(2)
raise TimeoutError("comfy timeout")
def main():
out, tag, style = sys.argv[1], sys.argv[2], sys.argv[3]
seeds = int(sys.argv[4]) if len(sys.argv) > 4 else 2
os.makedirs(out, exist_ok=True)
cell, pad, label = 256, 6, 18
sheet = Image.new("RGB", (len(SUBJECTS) * (cell + pad) + pad, seeds * (cell + pad) + pad + label), (40, 40, 40))
draw = ImageDraw.Draw(sheet)
for i, (name, body) in enumerate(SUBJECTS):
prompt = "%s, game icon, %s, simple background, centered, single object" % (STYLE[style], body)
for j in range(seeds):
t0 = time.time()
im = run(prompt, 2000 + i * 11 + j * 101)[0]
print("%s %s #%d %.1f초" % (tag, name, j, time.time() - t0)); sys.stdout.flush()
im.save(os.path.join(out, "%s_%s_%d.png" % (tag, name, j)))
sheet.paste(im.resize((cell, cell), Image.LANCZOS), (pad + i * (cell + pad), label + pad + j * (cell + pad)))
draw.text((pad + i * (cell + pad) + 4, 3), name, fill=(240, 220, 160))
path = os.path.join(out, "%s_sheet.png" % tag)
sheet.save(path)
print("sheet", path)
if __name__ == "__main__":
main()

Binary file not shown.

After

Width:  |  Height:  |  Size: 802 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 645 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 543 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 851 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 591 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 762 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 442 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 749 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 873 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 614 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 505 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 508 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 625 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 698 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 492 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 985 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

View File

@ -0,0 +1,60 @@
# FR 대화로그 2026-10-01 — BT47 아이템 드랍 시스템 초안 · BT48 아이템 드랍 · 아이템 · 능력치 시스템 구현
## BT47-FirstRising — 아이템 드랍 시스템 초안 설계 (PD 확정 → v1 기준)
**원문 요지**: 디아블로식 등급 아이템 드랍. 등급 10 · 부위 8(무기 · 보조 장비 · 갑옷 · 벨트 · 장갑 · 부츠 · 반지 · 목걸이) · 고정 스텟 + 랜덤 옵션 · 몬스터 레벨이 드랍 범위 결정 · 획득 찬스가 상위 등급 확률 증가. 전투 능력치 41종(공격 19 · 방어 12 · 보조 10) 첨부. 기존 능력치 시스템 변경 규모 병렬 점검. 방어력 공식 제안. PPT 문서화.
**PD 결정(전부 확정, 시간순)**
- 신성 = 거리 구분 없이 모든 타격에 얹히는 방어 무시 순수 피해(일반 최종 피해 × %, 치명타와 함께 커짐). 회피 · 막기 · 보호막은 신성도 막음. 몬스터 신성은 보류.
- 방어력 공식: 잔몹 · 보스 별도 상한 없음, 로그형 단일 곡선 `R = Rmax × (1 − e^(−(방어력÷피해)/s))`. 큰 한 방에 방어력이 거의 안 듣는 점 인지하고 "우선 이대로". 손잡이는 GlobalValue 에서 PD 조절.
- 공격 속도 = 무기별 발동 공격 쿨타임 · 스킬 쿨타임 = 액티브 스킬 전부(분리). 기본 공격 사거리 = 무기 스킬 사거리 증감.
- 피해 증가량 = 최종 피해 곱% · 공격 증가량 = 공격력 수치 %. 관통 = 공격력 × 관통% 를 방어력만 무시하고 가산. 고정 피해 · 고정 피해 감소 = 최종 연산 뒤 정수 가감. 막기 = 모든 부위 · 확률 · 50% · 고정 피해 앞.
- 모든 스킬 레벨 = 로비 스킬 레벨 가산, 상한 테이블 최대 +99, 선형 외삽. 획득량은 경험치 · 골드 · 아이템 셋 분리.
- 반지 1 · 목걸이 1. 보조 장비 8종(방패 · 화살통 · 마법책 · 단검 · 투척 무기 · 오브 · 보조 무기 · 없음=양손 취급 +25%, 옵션 없음). 허용 무기 표 그대로. 특정 무기는 보조 슬롯 잠금(양손 컨셉).
- 등급은 성능 차이만(세트 별도). 모든 아이템은 고정1 하나 + 랜덤 옵션은 부위별 옵션 풀. 고정1 능력치도 풀에 나올 수 있되 값은 고정1 의 50% 상한. 물리/마법 → 근접/원거리 대체. 풀은 현행 유지 후 조정.
- 몬스터 방어력 적용 안 함(1 고정). 투사체 발사 형태는 전부 원거리 판정.
**산출물**: `공유/기획/FirstRising/시스템기획/2026-10-01_FR_아이템드랍시스템_초안_v0.md`(§7 에 확정 목록) · `…_초안_v0.pptx`(21장) · `tools/build_item_drop_v0.py`.
## BT48-FirstRising — 아이템 드랍 · 아이템 · 능력치 시스템 구현
**원문 요지**: "위 기획 변경 사항을 반영해서 멈추지 말고 아이템 드랍 시스템 및 아이템 시스템, 능력치 시스템 구현을 진행. 작업 목표: 디아블로 스타일의 아이템 파밍 시스템 구현 및 검증. 필드에 몬스터를 처치 시 아이템이 드랍되고, 인벤토리에 습득한 아이템의 옵션을 확인할 수 있어야. 완료까지 중단 없이 자율 반복."
**결과(실측 · 2026-10-01 02:24 검증 Play 2차 · 에디터 로그 오류 0)**
- 처치 → 드롭 → 습득 → 인벤토리 → 정보 창 → 장착 → 능력치 반영이 한 줄로 동작한다.
- 시험 드롭 8부위 8개 습득 · 옵션 생성(등급별 개수 · 부위 풀 · 고정1 50% 상한) · 저장(LocalPlayer.txt) · 다음 Play 에서 유지.
- 8부위 동시 장착(반지 · 보조 장비 · 목걸이가 서로 밀어내지 않음). 방어력 5 → 155, 생명력 510 → 1187(계산) / 617 → 1543(캐릭터), 공격력 52 → 76.
- 엔티티 반영: DefenceComponent(방어력 155 · s 0.5 · Rmax 0.8 · 저항 · 고정 피해 감소 5), HealthComponent(관통 11% · 고정 피해 4 · 원거리/스킬 피해 12%/8% · 무기 거리 태그).
- 확률 모의(잔몹 300 · 정예 100 · 보스 5): 9 · 32 · 15 드롭(기대 9 · 25 · 15). 실전(임시 100%): 처치 14 → 드롭 14 → 걸어가 습득 13.
- 정보 창: 이름 `[전설] 목걸이 Lv18` · 능력치 칸 `+16%` · 설명 칸 `고정 생명력 +16% / · 신성 저항 +4% / …`. 바닥 표시는 등급 색 기둥 + 한글 이름표(부위 · 등급 · Lv).
- 시험으로 생긴 아이템 21개는 저장 파일에서 걷어내고 장착을 원래 활(id 1)로 되돌렸다(백업 세션 scratchpad `save_backup_1001`). GlobalValue 의 임시 100% 는 3% 로 원복 · 자산 dirty 아님 확인.
**만든 것(FR · 작업 트리 · 미커밋)** — 새 스크립트 8(BT_Visual) · 자산 2(`Resources/BT/GlobalValue.asset` · `ItemDatabase.asset`) · 원본 수정 9곳(날짜 · 이유 주석). 목록은 인수인계서 §2.
**보류(스킬 시스템 수정 필요 · 값은 저장 · 합산되나 적용 안 됨)**: 공격 속도(무기 공격 쿨타임 분리 · Q20) · 기본 공격 사거리 · 투사체 수 · 범위 공격 데미지 · 모든 스킬 레벨(+99 외삽) · 상태이상 저항 · 흡혈 효과 · 경험치/골드 획득량 · 조건부 옵션(7등급↑ 풀 섞기) · 양손 무기 슬롯 잠금 · 보조 장비 ↔ 무기 허용 검사(드롭 때만 걸러지고 장착 때는 검사 없음).
**남은 이슈**
- 반지 · 보조 장비는 원본 ItemTable 에 정의가 없어 목걸이 정의(아이콘 · 이름)를 빌린다. 장비 창(ItemPageUI)의 장착 슬롯이 원본 부위 기준이라 반지 · 보조 장비가 목걸이 슬롯 자리에 겹쳐 보일 수 있다(미확인).
- 등급 틀(아이콘)은 원본 6종뿐이라 1~10 을 가까운 것으로 맞췄다.
- 드롭 기둥의 등급 색이 화면에서 옅다(URP Unlit `_BaseColor` · `_Color` 둘 다 넣었으나 셰이더 단계 999 영향 가능) — 미확인.
- 몬스터 레벨 = 구역 레벨 표(TVLootDrops.areaLevels, 기본 Lobby=1)로만 정한다. 다른 필드 열쇠별 레벨은 PD 가 인스펙터에서 넣는다.
- Play 중 에디터가 재질 3개(ModularAura07 · Golem RocksBack LOD2 · Rooftiles_Shiny)를 덧건드렸다 — 내 변경 아님, 건드리지 않는다.
## BT48 후속 (10-01 09:28 지시 → 22:59 완료 · FR `bt/work` `dc45353d72` push)
**원문 요지**: ① 기본 드랍률 10% 내외로 ② 드랍된 아이템이 여전히 예전 옵션 시스템을 쓰는 것 같다 → 바뀐 옵션 시스템 적용, 인벤토리의 기존 아이템도 동일하게 수정 ③ 활 공격의 기본 투사체 수 1 · 마무리되면 푸시.
**결과(실측 · 3차 검증 Play 22:56 · 에디터 로그 오류 0)**
- ① 일반 몬스터 드롭 3% → 10%(코드 기본값 + GlobalValue 자산). 실전 처치 25 → 드롭 3.
- ② 원인: 정보 창 '등급 스킬' 줄 목록과 능력치 합산이 예전 등급 옵션(ItemOptionTable)을 그대로 쓰고 있었다. 새 장비는 줄 목록에 랜덤 옵션을 한 줄씩 보이고 예전 옵션은 표시 · 적용하지 않는다. 예전 장비는 로비 진입 때 한 번 변환(`TVItemGenerator.ConvertLegacy` · 등급 6단계 → 1·3·4·5·6·7 · 아이템 레벨 = 캐릭터 레벨 · 옵션 새로 굴림) — PD 저장의 장비 2개 변환 확인.
- ③ 무기 스킬 테이블 5종의 활 행 SpawnCount → 1(파일당 24행) · 테이블 프리팹 4종 다시 굽기(GradeWeaponSkillTable 은 프리팹 없음). 활을 든 상태의 실행 중 확인은 못 했다(검증 때 장착 무기가 1026) — 구운 프리팹의 활 블록 값 1 은 확인.
- 시험 중 주운 아이템 1개는 저장에서 걷어냈다. 변환된 장비 2개 + PD 가 주운 1개는 그대로.
**사고**: 09:28 에 에디터가 Play 중인 것을 PD 플레이로 판단해 13시간 넘게 대기 보고만 반복했다. PD 지적 "지시했으면 플레이를 중단하고 즉시 진행" → 메모리 `feedback_stop_play_when_instructed.md`.
## 아이콘 생성 시험 (10-01 · ComfyUI · 결정 대기)
- PD 질문: ComfyUI 에 Claude 연동이 됐는가 → 내 연동은 API 호출이라 워크플로우에 노드가 없다. Claude 노드(제휴 · 커스텀)는 반대 방향(ComfyUI → Claude API)이고 키가 필요 → PD 가 노드 제거, "그림을 직접 보고 다듬는 반복"으로 진행하기로.
- civitai 143301 "Game Icon Institute anfg v3"(SD1.5 LoRA · 트리거 `game icon institute, game icon, ahfg` · 강도 0.6): 방어구 · 장신구 · 방패 · 오브는 사이트 풍에 가깝고 투명 배경(InspyrenetRembg) 정상. 무기(둔기 · 활 · 지팡이) · 화살통은 실패. 긴 문장 프롬프트는 풍이 빠진다. 권장 기반(Game Icon Institute v3.0 SD1.5)은 PC 에 없어 difconsistency 로 대체.
- `gameIconInstitute_v4XL`(SDXL)은 그 LoRA 와 못 쓴다. 스타일 태그 4종 중 xieshi(사실풍 · 회색 배경) · 3dicon(점토풍)을 시험.
- 산출물: `공유/기획/FirstRising/아이템/gii_site/`(고른 14종 투명 PNG · 시트) · `gii_trial/` · 스크립트 `comfy_gii_trial.py` · `comfy_gii_xl.py` · `comfy_gii_site.py`. FR 프로젝트에는 넣지 않음.
- 결정 대기: 다음 방향(권장 기반 체크포인트 내려받기 / 실패 4종을 XL 로 형태 잡고 LoRA 로 풍 입히기 / 지금 기준으로 나머지 채우기).

View File

@ -0,0 +1,86 @@
# FR 아이템 드랍 · 아이템 · 능력치 시스템 — 세션 인수인계서 (2026-10-01 · BT47~BT48)
> C40 한도(기록 8MB · 이미지 30장) 도달로 작성. PD 지시 없이 세션 전환을 권하지 않는다 — 다음 세션이 이어 갈 때 읽는 문서다.
## 0. 다음 세션 첫 프롬프트 (그대로 붙여넣기)
```
FR(FirstRising) 작업을 이어서 진행한다.
인수인계서: 공유/대화로그/FirstRising/2026-10-01_세션인수인계.md 의 §0-1 · §1 · §2 를 먼저 읽고,
현황을 3줄로 보고한 뒤 내 지시를 기다려라. (로그 전문 읽기 금지, 필요한 절만 Grep)
고정 사항:
- 커밋 · push 는 PD 지시 시만(FR · 조직 레포 모두). 작업을 마치면 작업 트리에 두고 보고한다
- 재컴파일 · Play 조작 · 에디터 설정을 바꾸는 명령 앞에는 Play 여부를 단독 명령으로 먼저 확인한다.
PD Play 중에는 코드 수정 · Play 조작 · 무거운 스크립트 금지(읽기 전용 확인만)
- 검증 Play 는 끝나면 즉시 종료 · 임시로 바꾼 값(저장 파일 · GlobalValue · 훅)은 즉시 원복 · 백그라운드 작업을 남기지 않는다
- 오류 확인은 에디터 로그 기준(logscan.py). recompile_status 는 'completed' 글자만 찾는다
- 게임 원본 코드 수정 시 날짜 · 이유 한글 주석. 새 코드 · 고친 코드에는 선언 단위 한글 주석. 스크립트는 BT_Visual 노드에만
- 파일 삭제는 PD 지시 시만. 허위 · 추정 단정 금지. 감사 호출 생략, 무거운 에이전트 호출은 사전 승인, 세션 전환 권고 금지
- 보고는 한국어 · 간결 · 경어, 호칭 PD님. 승인이 필요하면 질문으로 끝낸다
```
## 0-1. 이번에 열 문서(필요한 절만)
| 하려는 일 | 열 문서 · 절 |
|---|---|
| 아이템 드랍 · 능력치 규칙(확정) | `공유/기획/FirstRising/시스템기획/2026-10-01_FR_아이템드랍시스템_초안_v0.md` §1 등급 · §2 드롭 · §3 부위 풀 · §4-5 신성 · §4-6 방어 공식 · §7 PD 확정 목록 |
| 구현 구조 · 원본 수정 지점 | 이 문서 §2 + 메모리 `project_fr_item_drop_bt48.md` |
| 검증 다시 돌리기 | `공유/기획/FirstRising/몬스터보스/tools/unity_eval/bt_evals_1001/` → `%TEMP%\bt_evals\` 로 복사 후 `run_bt48_verify.ps1`(시험 아이템이 저장 파일에 남으므로 끝나면 걷어낼 것 · §3) |
| 필드 이동 · 장비 동기화 · 최소화 모드 등 앞선 작업 | `2026-09-30_세션인수인계.md` §0-1 표 |
## 1. 현황 한눈에
| 구분 | 상태 |
|---|---|
| FR `bt/work` | **`dc45353d72` push 완료(BT47~48 · 10-01 22:59)** — 드롭률 10% · 예전 등급 옵션 제거 + 기존 장비 변환(ConvertLegacy) · 활 투사체 수 1(테이블 프리팹 4종 다시 굽기) 포함. 에디터가 덧건드린 재질 · 글자체 · `GlobalEntitySceneDependency.asset` 은 내 변경 아님 |
| 조직 레포 `main` | 이 인계서 · 대화로그 · 초안 · PPT · 검증 스크립트 사본 — 미커밋 |
| BT47 초안 | PD 결정 전부 반영(§7 확정 목록). 결정 대기 없음 |
| BT48 구현 | **완료 · push**. 보류 항목은 §4. 아이콘 생성 시험(Game Icon Institute LoRA)은 대화로그 10-01 끝 절 — 방향 결정 대기 |
| 저장 파일 | 시험 아이템 걷어내고 장착 원복(활 id 1). 백업 세션 scratchpad `save_backup_1001`(세션이 끝나면 사라져도 됨) |
| GlobalValue 자산 | dropChanceNormal 3(원복 확인 · dirty 아님) |
| 백그라운드 작업 · 훅 | 없음 |
## 2. 이번 세션이 만든 것(FR)
### 새 스크립트 (`Assets/BurningTimes/Visual/Scripts/`)
| 파일 | 하는 일 |
|---|---|
| `TVItemData.cs` | 부위(TVItemPart 8) · 보조 종류(TVSubEquipKind 8) · 옵션 정의(TVAffixDef) · 등급 정의(TVGradeDef) · 규칙 도우미(TVItemRules: 고정1 능력치 · 고정1 값 · 이름). `Protocol.BTItemAffix`(RawItem 에 붙는 옵션) |
| `TVGlobalValue.cs` | PD 조절용 자산 — 방어 공식 s · Rmax, 드롭 확률, 고정1 계수, 티어 배수, 등급 10 표, 습득 거리. `Resources/BT/GlobalValue.asset` |
| `TVItemDatabase.cs` | 랜덤 옵션 풀(43 정의 · 부위/보조 종류 비트) · 보조 장비 ↔ 무기 허용표 · 양손 무기 비트. `Resources/BT/ItemDatabase.asset` |
| `TVItemGenerator.cs` | 드롭 결정(RollDrop · RollGrade) · 인스턴스 생성(CreateAndGive: 원본 ItemTable 정의 빌림 → AddItem → bt 필드 · 옵션 추첨) · 시험 생성(GiveForTest) |
| `TVLootDrops.cs` | BT_Visual 구성 요소. 처치 감지 관리 시스템(TVLootDropSystem — DeadRecordBufferElement 를 게임보다 먼저 읽음 · killed/hit 이벤트) · 바닥 표시(기둥 + 한글 이름표 · 풀) · 습득 · 안내 · 구역 레벨 표 · SpawnTest/SimulateKill |
| `TVItemStats.cs` | 원본이 부르는 세 함수 — Accumulate(합산 배열) · Flat(정수 고정1 + 양손 보정 + 정수 옵션) · SamePart(장착 슬롯 비교). PercentFromEquipped · LegacyGradeIndex |
| `TVItemStatSync.cs` | BT_Visual 구성 요소. 새 능력치를 엔티티에 씀(DefenceComponent · HealthComponent bt 필드, 매 프레임 비교 후 다를 때만) · 처치 시 · 타격 당 회복 |
| `TVItemText.cs` | 이름(등급 색) · 고정1 · 옵션 목록 · 요약 문자열 |
### 원본 수정(전부 `2026-10-01 BurningTimes(BT48…)` 주석)
| 파일 | 수정 |
|---|---|
| `GameHub/JsonCommon.cs` | `eAbility` 끝에 32종 추가(BTAttackSpeed … BTDefencePercent). **중간 삽입 금지** |
| `GameHub/Protocol.cs` | `RawItem` 에 btIlvl · btGrade · btPart · btSubKind · btFixedChoice · btAffixes |
| `GameHub/NetPlayer.cs` | `EquipItem` 슬롯 비교를 `TVItemStats.SamePart` 로 |
| `Manager/PlayerManager.cs` | `GetTotalAbilities` 에 Accumulate · `CalcAttack/Defence/Health` 에 bt 아이템 고정1 경로 + 근접/원거리 공격력% · 방어력% |
| `Entity/CharacterComponents.cs` | `HealthComponent` 공격자 bt 필드 8 · `DefenceComponent` 피격자 bt 필드 10 |
| `Entity/TakeDamageComponent.cs` | `TakeDamageBufferElement` 에 btRanged · btHolyDamage · btPierceDamage · btFlatDamage |
| `Entity/Fx/FxComponentPlay.cs` | `HitEntity`: 근접/원거리/스킬 피해 곱 → 신성 · 관통 · 고정 피해 산출 → 두 AppendToBuffer 에 실음 |
| `Entity/DamageSystem.cs` | 피해 처리 9단계(회피 → 방어 v2 → 피해 감소 → 저항 → 관통 → 신성 → 막기 → 고정 피해 ± → 최소 1). bt 값 0 이면 원래 계산과 같음 |
| `UI/Lobby/ItemInfoPopUp.cs` · `ItemIconData.cs` | 드롭 장비의 이름 · 값 · 옵션 표시 · 인스턴스 등급 틀 |
| `GameAssets/LobbyPlayScene.prefab` | BT_Visual 에 TVLootDrops · TVItemStatSync 부착 |
## 3. 실측으로 알게 된 것(다시 조사하지 말 것)
- `GameHub.EquipItem` 은 같은 종류 요청이 끝나기 전에 또 보내면 `DUPLICATE_REQ` — 여러 장착은 2초 간격으로 순차.
- `LocalPlayer.txt`(LocalLow\ThunderRisingGames\Dead Archive) 는 `PlayerManager.SaveLocalPlayer()` 로 통째 저장. RawItem 새 필드는 Newtonsoft 가 그대로 저장 · 복원(2차 Play 에서 유지 확인).
- 원본 `DeadRecordBufferElement` 는 치명타격 예측 때 플레이어 엔티티에 남고 `PlayerEntityAbilitySyncSystem` 이 지운다 — 그 앞에서 읽으면 처치 감지가 된다. 시험 `SimulateKill` 도 killCount 에 섞이므로 실전 처치 수는 차이로 센다.
- 정보 창의 능력치 칸(m_ItemAbilityText) 글꼴은 한글이 없다(네모) → 값만 넣고 이름은 설명 칸에. 3D TMP 기본 글꼴도 한글이 없어 실행 중 UI 에서 '가'를 가진 글꼴을 찾아 쓴다.
- Play 중 `TVGlobalValue.Instance` 값을 바꾸면 자산 인스턴스가 바뀐다(dirty 아님) — 끝나면 반드시 원복.
- `eval_file` 로 프리팹을 열어 저장하는 부착 스크립트는 5초를 넘겨 400 이 뜨지만 실제로는 부착이 끝나 있다 — 가벼운 확인 스크립트로 재확인.
## 4. 미검증 · 보류 · 결정 대기(PD)
- **보류(스킬 시스템 수정 필요, 값은 저장 · 합산되나 적용 안 됨)**: 공격 속도(무기 공격 쿨타임과 스킬 쿨타임 분리 · Q20) · 기본 공격 사거리 · 투사체 수 · 범위 공격 데미지 · 모든 스킬 레벨(+99 외삽) · 상태이상 저항 · 흡혈 효과 증가량 · 경험치/골드 획득량 · 조건부 옵션 풀 섞기(7등급↑) · 양손 무기 슬롯 잠금 · 장착 때 보조 장비 ↔ 무기 허용 검사.
- **미검증**: 장비 창(ItemPageUI) 장착 슬롯에서 반지 · 보조 장비의 표시 자리 · 드롭 기둥 등급 색 · PC 빌드 · 다른 필드(열쇠별 구역 레벨은 기본 1).
- **PD 확인 요청**: ① 구역 레벨 표(TVLootDrops.areaLevels) 값 ② GlobalValue 자리표시 수치(드롭 확률 3/25/100 · 고정1 계수 · 티어 배수) ③ 반지 · 보조 장비 전용 아이콘 · 이름(지금은 목걸이 정의를 빌림).
## 5. 남은 이슈(기존)
- 09-30 인계서 §4 · §5 그대로(BT42 보스전 포함 여부 · BT44 PC 빌드 · BT45 첫 맵 굽기 시간 · BT46 작업 스케줄러 등록 여부).