ThreeKingdomsSlingshot/Assets/Scripts/OutGame/HeroRules.cs

289 lines
13 KiB
C#
Raw Normal View History

아웃게임 1차(PD #1093 세션 A) — 본영 로비 · 세이브 · 군량 · 영웅(레벨·승급 6칸·진화·스킬 표시) · 편성(전열 3/후열 2 · 프리셋 3) · 거래(뽑기·골드 장비 상점) · 스테이지 보상·별 3·소탕 - 데이터: 기획 tables/ 12장 → Assets/Data/OutGame/*.csv · 메뉴 TKS/Import OutGame Data(Editor/DataImporter) → Resources/OutGame 9 자산(HeroDb·GearDb·GachaConfig·EconomyConfig·RewardTable·LevelTable·PromotionTable·EvolveTable·SkillInfoDb) · 검증(행 수·필수 열·id 유일·hero↔skill↔gear·확률 합) 오류 시 자산 불변 · BalanceConfig.heroes[]/HeroDef 삭제 → HeroDb(기존 5인 id 유지) · economy.csv 키 17 + gacha.csv 키 2 추가(기획서 본문 값 · C45) - 세이브: persistentDataPath/save.json · ver 1 · 즉시 저장 · 오프라인 군량 회복 · 일일 초기화 · 신규 유저 = 유비·관우·장비·조운·황충 Lv1 녹 ★=희귀도 · 골드 1000 · 보옥 300 · 군량 30 · 테스트·캡처 = 메모리 전용 - 화면(uGUI 와이어프레임 · img03·06·08·11): 시작 = 본영 로비(출정 → 월드맵 · 편성 · 연무장/보물/내정 준비 중 · 동맹 추후) · 재화 바(월드맵 포함 · 군량 타이머) · 하단 탭 5 · ◀/Esc 일관 · 영웅 목록·상세·상세정보·도감 · 편성(탭·드래그) · 뽑기(천장 n/80 · 확률 공개 · 첫 10연 무료 · 10연 ★★ 보장 · 카드 순차 공개) · 장비 상점(흰/녹 6종 일일 · sell×5 · 새로고침 보옥 20) - 전투 연결: BattleManager.LineupProvider/Deployed(LineupEntry · §3-2 스탯 · 방어 배율 생성 뒤) · playerSpawnPoints = 전열 (−4/0/4, −12) · 후열 (−2/2, −14) · WorldMapScreen 훅(팝업 군량·보상·별 · 입장 군량 · 노드 별 star_on/off · 소탕 · ◀ 본영 · TopInset) · 결과 패널(별 3 조건 · 골드·경험·보옥·장비) · GameBootstrap/AutoPlayDriver 로비 시작 - B 세션 파일(ActionResolver·Combat·UnitView·BattleHUD·SlingshotInput·EnemyAI) 미변경 - 테스트 56/56(기존 41 유지 · 신규 OutGameTests 15) · console error 0 · 사본 GUI 캡처 11장(DataImporter.CaptureOutGame) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 12:11:10 +00:00
using System;
using System.Collections.Generic;
using UnityEngine;
namespace TKS.OutGame
{
/// <summary>
/// 영웅·병사 성장 규칙(기획서 §3) — 스탯 공식 · 전투력 · 레벨(골드 · 경험 · 챕터 상한) · 장착(1칸 = 1강) · 승급(6칸) · 진화(조각) · 신규 유저 세이브.
/// 수치는 전부 표(OutGameData)에서 읽는다(C45).
/// </summary>
public static class HeroRules
{
/// <summary>§3-2 스탯 내역(상세정보 표시 · 전투 유닛 생성 공용).</summary>
public struct Stats
{
public UnitType type;
public float baseHp, baseAtk, baseDef;
public float hpMult, atkMult, defMult;
public float lvPct, promoPct, gearPct, starPct, trainPct;
public float hp, atk, def;
public int power;
/// <summary>영웅 배율을 뺀 성장 계수 = (1+lv)(1+promo+gear)(1+star)(1+train).</summary>
public float Growth
{
get { return (1f + lvPct / 100f) * (1f + (promoPct + gearPct) / 100f) * (1f + starPct / 100f) * (1f + trainPct / 100f); }
}
public int Hp { get { return Mathf.Max(1, Mathf.RoundToInt(hp)); } }
public int Atk { get { return Mathf.Max(1, Mathf.RoundToInt(atk)); } }
public int Def { get { return Mathf.Max(0, Mathf.RoundToInt(def)); } }
}
/// <summary>
/// stat = base(unit_type) × hero_mult × (1 + lvl_bonus) × (1 + promo_bonus + gear_bonus) × (1 + star_bonus) × (1 + train_bonus) · train = 0(A 단계) ·
/// 전투력 = hp / power_hp_div + atk × power_atk_w + def × power_def_w. 병사 유닛 = 승급·장비·진화 없음(promo·gear·star = 0).
/// </summary>
public static Stats Compute(OutGameData db, BalanceConfig cfg, HeroDb.Hero h, LevelState s)
{
var b = cfg.GetStats(h.type);
var hs = s as HeroState;
var st = new Stats
{
type = h.type,
baseHp = b.maxHp, baseAtk = b.atk, baseDef = b.def,
hpMult = h.hpMult, atkMult = h.atkMult, defMult = h.defMult,
};
var lvRow = db.Levels.Get(s != null ? s.lv : 1);
st.lvPct = lvRow != null ? lvRow.statPct : 0f;
if (!h.isSoldier && hs != null)
{
var p = db.Promotion.Get(hs.grade);
st.promoPct = p != null ? p.promoPct : 0f;
st.gearPct = hs.gearTotal * db.Economy.GearBonusPct;
var ev = db.Evolve.Get(hs.star);
st.starPct = ev != null ? ev.starPct : 0f;
}
st.trainPct = 0f; // 내정 훈련 = C 단계(§7)
float g = st.Growth;
st.hp = b.maxHp * h.hpMult * g;
st.atk = b.atk * h.atkMult * g;
st.def = b.def * h.defMult * g;
var e = db.Economy;
st.power = Mathf.RoundToInt(st.hp / Mathf.Max(0.0001f, e.PowerHpDiv) + st.atk * e.PowerAtkW + st.def * e.PowerDefW);
return st;
}
// ---------- 신규 유저 ----------
public static HeroState NewHero(OutGameData db, HeroDb.Hero h, int slotCount)
{
var s = new HeroState { id = h.id, lv = 1, exp = 0, grade = db.Promotion.FirstGrade, star = Mathf.Clamp(h.rarity, 1, db.Evolve.MaxStar), shards = 0, gearTotal = 0 };
s.gear = new List<string>();
for (int i = 0; i < slotCount; i++) s.gear.Add("");
return s;
}
/// <summary>요구 장비 칸 수(hero_gear.csv slot 최댓값).</summary>
public static int GearSlotCount(OutGameData db)
{
foreach (var h in db.Heroes.heroes)
if (h.gear.Count > 0) return h.gear[0].slots.Count;
return 0;
}
/// <summary>신규 유저 = start_heroes 보유(Lv1 · 초기 등급 · ★ = 희귀도) · 재화 start_* · 프리셋 preset_count 개(모두 start_heroes 편성).</summary>
public static SaveData NewSave(OutGameData db, int formationSlots, DateTime nowUtc)
{
var e = db.Economy;
var d = new SaveData { gold = e.StartGold, gem = e.StartGem, stamina = e.StartStamina, staminaAt = SaveStore.Iso(nowUtc), chapterMax = 1 };
int gearSlots = GearSlotCount(db);
var lineup = new List<string>();
foreach (var id in e.StartHeroes)
{
var h = db.Heroes.Get(id);
if (h == null) continue;
Grant(db, d, h, gearSlots);
lineup.Add(h.id);
}
for (int p = 0; p < Mathf.Max(1, e.PresetCount); p++)
{
var pr = new Preset();
for (int i = 0; i < formationSlots; i++) pr.slots.Add(i < lineup.Count ? lineup[i] : "");
d.presets.Add(pr);
}
return d;
}
/// <summary>미보유 → 보유 추가(true) · 이미 보유 = false.</summary>
public static bool Grant(OutGameData db, SaveData d, HeroDb.Hero h, int gearSlots)
{
if (d.Owns(h.id)) return false;
if (h.isSoldier) d.units.Add(new UnitState { id = h.id, lv = 1, exp = 0, count = 1 });
else d.heroes.Add(NewHero(db, h, gearSlots));
return true;
}
/// <summary>읽은 세이브의 빈 칸·크기를 표에 맞춘다(필드가 늘어난 구버전 · 손상 대비).</summary>
public static void Sanitize(OutGameData db, SaveData d, int formationSlots)
{
int gearSlots = GearSlotCount(db);
if (d.presets == null) d.presets = new List<Preset>();
while (d.presets.Count < Mathf.Max(1, db.Economy.PresetCount)) d.presets.Add(new Preset());
foreach (var p in d.presets)
{
if (p.slots == null) p.slots = new List<string>();
while (p.slots.Count < formationSlots) p.slots.Add("");
if (p.slots.Count > formationSlots) p.slots.RemoveRange(formationSlots, p.slots.Count - formationSlots);
for (int i = 0; i < p.slots.Count; i++)
if (p.slots[i] == null || (p.slots[i].Length > 0 && !d.Owns(p.slots[i]))) p.slots[i] = "";
}
d.presetIdx = Mathf.Clamp(d.presetIdx, 0, d.presets.Count - 1);
foreach (var h in d.heroes)
{
if (h.gear == null) h.gear = new List<string>();
while (h.gear.Count < gearSlots) h.gear.Add("");
for (int i = 0; i < h.gear.Count; i++)
if (h.gear[i] == null) h.gear[i] = "";
if (string.IsNullOrEmpty(h.grade) || db.Promotion.Get(h.grade) == null) h.grade = db.Promotion.FirstGrade;
if (h.skillLv == null || h.skillLv.Length < 2) h.skillLv = new[] { 1, 1 };
h.star = Mathf.Clamp(h.star, 1, db.Evolve.MaxStar);
h.lv = Mathf.Clamp(h.lv, 1, db.Levels.MaxLevel);
}
foreach (var u in d.units) u.lv = Mathf.Clamp(u.lv, 1, db.Levels.MaxLevel);
if (d.chapterMax < 1) d.chapterMax = 1;
if (d.inv == null) d.inv = new Inventory();
if (d.adsUsed == null) d.adsUsed = new AdsUsed();
if (d.gacha == null) d.gacha = new GachaState();
if (d.shop == null) d.shop = new ShopState();
if (d.stages == null) d.stages = new List<StageState>();
}
// ---------- 레벨 ----------
public static int LevelCap(OutGameData db, SaveData d)
{
return Mathf.Min(db.Levels.MaxLevel, db.Levels.CapFor(d.chapterMax));
}
/// <summary>다음 상한을 여는 챕터(없으면 0).</summary>
public static int NextCapChapter(OutGameData db, int lv)
{
var r = db.Levels.Get(lv + 1);
return r != null ? r.capChapter : 0;
}
/// <summary>null = 레벨업 가능 · 아니면 사유.</summary>
public static string CanLevelUp(OutGameData db, SaveData d, LevelState s)
{
if (s == null) return "미보유";
if (s.lv >= db.Levels.MaxLevel) return "최고 레벨";
if (s.lv >= LevelCap(db, d)) return "레벨 상한 Lv " + LevelCap(db, d) + " — " + NextCapChapter(db, s.lv) + "장을 클리어하면 해금";
var r = db.Levels.Get(s.lv);
if (r == null) return "레벨 표에 Lv " + s.lv + " 없음";
if (s.exp < r.expToNext) return "경험 부족 " + s.exp + " / " + r.expToNext;
if (d.gold < r.goldCost) return "골드 부족 " + d.gold + " / " + r.goldCost;
return null;
}
public static bool LevelUp(OutGameData db, SaveData d, LevelState s)
{
if (CanLevelUp(db, d, s) != null) return false;
var r = db.Levels.Get(s.lv);
d.gold -= r.goldCost;
s.exp -= r.expToNext;
s.lv++;
return true;
}
public static void GrantExp(LevelState s, int exp)
{
if (s != null && exp > 0) s.exp += exp;
}
// ---------- 승급 장비 · 승급 ----------
/// <summary>현재 등급 칸 slot 에 필요한 장비 id(최고 등급·병사 = null).</summary>
public static string RequiredGear(OutGameData db, HeroDb.Hero h, HeroState s, int slot)
{
var list = db.Heroes.GearFor(h, s != null ? s.grade : "");
return list != null && slot >= 0 && slot < list.Count ? list[slot] : null;
}
public static int EquippedCount(HeroState s)
{
int n = 0;
if (s != null)
foreach (var g in s.gear)
if (!string.IsNullOrEmpty(g)) n++;
return n;
}
public static string CanEquip(OutGameData db, SaveData d, HeroDb.Hero h, HeroState s, int slot)
{
if (h == null || s == null || h.isSoldier) return "승급 없음";
var need = RequiredGear(db, h, s, slot);
if (need == null) return "최고 등급";
if (!string.IsNullOrEmpty(s.gear[slot])) return "이미 장착";
if (d.inv.GearCount(need) <= 0) return "장비 없음";
return null;
}
/// <summary>가방의 요구 장비 1개를 빈 칸에 장착(1칸 = 1강 · gearTotal +1).</summary>
public static bool Equip(OutGameData db, SaveData d, HeroDb.Hero h, HeroState s, int slot)
{
if (CanEquip(db, d, h, s, slot) != null) return false;
var need = RequiredGear(db, h, s, slot);
d.inv.AddGear(need, -1);
s.gear[slot] = need;
s.gearTotal++;
return true;
}
public static string CanPromote(OutGameData db, HeroDb.Hero h, HeroState s)
{
if (h == null || s == null || h.isSoldier) return "승급 없음";
var p = db.Promotion.Get(s.grade);
if (p == null || p.nextGrade.Length == 0) return "최고 등급";
var list = db.Heroes.GearFor(h, s.grade);
if (list == null) return "요구 장비 표 없음";
int n = EquippedCount(s);
if (n < list.Count) return "장비 " + n + " / " + list.Count + "칸";
return null;
}
/// <summary>6칸 모두 찬 상태 → 장비 소모(칸 비움) · 등급 +1(promo_pct · 스킬 상한).</summary>
public static bool Promote(OutGameData db, HeroDb.Hero h, HeroState s)
{
if (CanPromote(db, h, s) != null) return false;
s.grade = db.Promotion.Get(s.grade).nextGrade;
for (int i = 0; i < s.gear.Count; i++) s.gear[i] = "";
return true;
}
/// <summary>스킬 레벨 상한 = 승급 등급 순서(녹 1 · 청 2 …) · ★3 액티브 +1 은 B 단계(skill_unlock) 처리.</summary>
public static int SkillCap(OutGameData db, HeroState s)
{
var p = s != null ? db.Promotion.Get(s.grade) : null;
return p != null ? p.order : 1;
}
// ---------- 진화 ----------
/// <summary>다음 ★ 에 필요한 조각(최고 ★ = 0).</summary>
public static int ShardsForNext(OutGameData db, HeroState s)
{
var r = s != null ? db.Evolve.Get(s.star + 1) : null;
return r != null ? r.shardsNeeded : 0;
}
public static string CanEvolve(OutGameData db, HeroDb.Hero h, HeroState s)
{
if (h == null || s == null || h.isSoldier) return "진화 없음";
if (s.star >= db.Evolve.MaxStar) return "최고 ★";
int need = ShardsForNext(db, s);
if (s.shards < need) return "조각 " + s.shards + " / " + need;
return null;
}
public static bool Evolve(OutGameData db, HeroDb.Hero h, HeroState s)
{
if (CanEvolve(db, h, s) != null) return false;
s.shards -= ShardsForNext(db, s);
s.star++;
return true;
}
}
}