Project_WL/Assets/WL/Equip/WLEquipGacha.cs

181 lines
9.3 KiB
C#

// ─────────────────────────────────────────────────────────────────────────────
// WLEquipGacha.cs — 장비 상자(열쇠) 더미 구현 (PD 2026-10-09 · #823)
//
// 표 = `Resources/WL/WLEquipBoxTable.json`(WLEquipSettings.boxTablePath · C45)
// tierWeights : 등급별 출현 가중치(제안값) · epicEvery : N회마다 에픽 이상 확정(누적 개봉 수 기준)
// sPity : 마지막 S급(전설) 뒤 N회째에 S급 확정(천장) · keyCost : 1회 열쇠 수(가격은 D3 보류)
// 열쇠·누적 개봉·천장 카운터는 WLEquipSaveData(로컬 세이브)에 둔다. 지급 아이템 = 무작위 장비 그룹의 해당 등급 기본 단계.
// ─────────────────────────────────────────────────────────────────────────────
using System.Collections.Generic;
using Newtonsoft.Json;
using UnityEngine;
namespace WL.Equip
{
[System.Serializable] public class WLBoxTierWeight { public int tier; public int weight; }
[System.Serializable]
public class WLBoxDef
{
public int id; public string name; public int keyCost = 1;
public List<WLBoxTierWeight> tierWeights;
public int epicEvery; public int epicTier = 4;
public int sPity; public int sTier = 5;
}
[System.Serializable] public class WLBoxTableJson { public List<WLBoxDef> boxes; }
/// <summary>뽑기 1회 결과(연출용 · #825). json = 지급된 아이템 정의 · why = "(확정)"/"(천장)".</summary>
public struct WLDrawResult { public int tier; public bool isS; public string why; public ItemJson json; }
public static class WLEquipGacha
{
public static bool Loaded { get; private set; }
public static string LastLog { get; private set; } = "";
public static int Draws { get; private set; }
public static int LastTier { get; private set; }
/// <summary>마지막 Draw 의 결과 목록(지급 순) — 결과 화면이 읽는다.</summary>
public static readonly List<WLDrawResult> LastResults = new List<WLDrawResult>();
static readonly Dictionary<int, WLBoxDef> s_boxes = new Dictionary<int, WLBoxDef>();
static readonly List<int> s_groups = new List<int>();
static readonly System.Random s_rng = new System.Random();
public static bool EnsureLoaded()
{
if (Loaded) return true;
var cfg = WLEquipSettings.Instance;
string path = cfg != null && !string.IsNullOrEmpty(cfg.boxTablePath) ? cfg.boxTablePath : "WL/WLEquipBoxTable";
try
{
var ta = Resources.Load<TextAsset>(path);
if (ta == null) throw new System.IO.FileNotFoundException("Resources/" + path + ".json 없음");
var t = JsonConvert.DeserializeObject<WLBoxTableJson>(ta.text);
s_boxes.Clear();
if (t.boxes != null) for (int i = 0; i < t.boxes.Count; i++) if (t.boxes[i] != null) s_boxes[t.boxes[i].id] = t.boxes[i];
Loaded = s_boxes.Count > 0;
LastLog = "상자 표 로드 — 상자 " + s_boxes.Count;
if (cfg != null && cfg.verboseLog != 0) Debug.Log("[WL-823] " + LastLog);
}
catch (System.Exception ex)
{
LastLog = "상자 표 로드 실패 — " + ex.Message;
Debug.LogError("[WL-823] " + LastLog);
}
return Loaded;
}
public static WLBoxDef Box(int id) { EnsureLoaded(); WLBoxDef b; return s_boxes.TryGetValue(id, out b) ? b : null; }
static List<int> EquipGroups()
{
if (s_groups.Count > 0) return s_groups;
var items = WLEquipTables.Items;
var seen = new HashSet<int>();
for (int i = 0; i < items.Count; i++)
{
var j = items[i];
if (!j.IsEquipment || j.GroupNo <= 0) continue;
if (seen.Add(j.GroupNo)) s_groups.Add(j.GroupNo);
}
return s_groups;
}
/// <summary>상자를 count 번 연다. 열쇠가 모자라면 거기서 멈춘다. 돌려주는 값 = 실제 지급 수.</summary>
public static int Draw(int boxId, int count)
{
LastLog = "";
LastResults.Clear();
if (!WLInventory.EnsureLoaded() || !EnsureLoaded() || !WLEquipGrades.EnsureLoaded()) { LastLog = "표 로드 실패"; WLInventory.Commit(LastLog); return 0; }
var box = Box(boxId);
if (box == null) { LastLog = "상자 표 없음(" + boxId + ")"; WLInventory.Commit(LastLog); return 0; }
var groups = EquipGroups();
if (groups.Count == 0) { LastLog = "장비 그룹 없음"; WLInventory.Commit(LastLog); return 0; }
var d = WLInventory.Data;
int done = 0;
var sb = new System.Text.StringBuilder();
for (int i = 0; i < count; i++)
{
if (!WLInventory.TrySpendKeys(box.keyCost)) { sb.Append("열쇠 부족"); break; }
string why;
int tier = RollTier(box, d, out why);
var step = WLEquipGrades.BaseStepOfTier(tier);
int groupNo = groups[s_rng.Next(groups.Count)];
var json = step != null ? WLEquipTables.FindGroupItem(groupNo, (eFRItemGrade)step.frGrade, step.gradeLv) : null;
d.boxDraws++;
if (tier >= box.sTier) d.sinceS = 0; else d.sinceS++;
LastTier = tier;
if (json != null)
{
WLInventory.AddItem(json.Index, 1, 1);
LastResults.Add(new WLDrawResult { tier = tier, isS = tier >= box.sTier, why = why, json = json });
done++; Draws++;
if (sb.Length > 0) sb.Append(" · ");
sb.Append(WLEquipGrades.TierLabel(tier, false)).Append(why).Append(' ').Append(json.Item_name);
}
else sb.Append(" [등급 ").Append(tier).Append(" 아이템 없음]");
}
LastLog = sb.ToString();
WLInventory.Commit("상자 " + done + "회 — " + LastLog);
return done;
}
static int RollTier(WLBoxDef b, WLEquipSaveData d, out string why)
{
why = "";
int tier = WeightedTier(b);
if (b.sPity > 0 && d.sinceS + 1 >= b.sPity && tier < b.sTier) { tier = b.sTier; why = "(천장)"; }
else if (b.epicEvery > 0 && (d.boxDraws + 1) % b.epicEvery == 0 && tier < b.epicTier) { tier = b.epicTier; why = "(확정)"; }
return tier;
}
static int WeightedTier(WLBoxDef b)
{
if (b.tierWeights == null || b.tierWeights.Count == 0) return 1;
int total = 0;
for (int i = 0; i < b.tierWeights.Count; i++) total += Mathf.Max(0, b.tierWeights[i].weight);
if (total <= 0) return b.tierWeights[0].tier;
int r = s_rng.Next(total);
for (int i = 0; i < b.tierWeights.Count; i++)
{
r -= Mathf.Max(0, b.tierWeights[i].weight);
if (r < 0) return b.tierWeights[i].tier;
}
return b.tierWeights[b.tierWeights.Count - 1].tier;
}
/// <summary>UI 한 줄 — 열쇠 · 누적 · 다음 에픽 확정까지 · S급 확정까지.</summary>
public static string InfoLine(int boxId)
{
if (!WLInventory.EnsureLoaded() || !EnsureLoaded()) return LastLog;
var b = Box(boxId); var d = WLInventory.Data;
if (b == null || d == null) return "상자 표 없음";
int toEpic = b.epicEvery > 0 ? b.epicEvery - (d.boxDraws % b.epicEvery) : 0;
int toS = b.sPity > 0 ? Mathf.Max(0, b.sPity - d.sinceS) : 0;
var sb = new System.Text.StringBuilder();
sb.Append(b.name).Append(" 열쇠 ").Append(d.keys).Append(" · 누적 ").Append(d.boxDraws).Append("회");
if (b.epicEvery > 0) sb.Append(" · ").Append(WLEquipGrades.TierLabel(b.epicTier, true)).Append(" 확정까지 ").Append(toEpic).Append("회");
if (b.sPity > 0) sb.Append(" · ").Append(WLEquipGrades.TierLabel(b.sTier, true)).Append(" 천장까지 ").Append(toS).Append("회");
return sb.ToString();
}
/// <summary>확률 표시(공정성 표준) — 「일반 55% · 고급 25% …」.</summary>
public static string RateText(int boxId)
{
var b = Box(boxId);
if (b == null || b.tierWeights == null) return "";
int total = 0;
for (int i = 0; i < b.tierWeights.Count; i++) total += Mathf.Max(0, b.tierWeights[i].weight);
if (total <= 0) return "";
var sb = new System.Text.StringBuilder();
for (int i = 0; i < b.tierWeights.Count; i++)
{
if (i > 0) sb.Append(" · ");
sb.Append(WLEquipGrades.TierLabel(b.tierWeights[i].tier, true)).Append(' ').Append((100.0 * b.tierWeights[i].weight / total).ToString("F1")).Append('%');
}
return sb.ToString();
}
}
}