ThreeKingdomsSlingshot/Assets/Scripts/OutGame/Domestic/DomesticScreen.cs

1000 lines
60 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

using System.Collections.Generic;
using UnityEngine;
using UnityEngine.UI;
namespace TKS.OutGame
{
/// <summary>
/// 내정(하단 탭 · 기획 S9~S11 · PD #1093 C) — 상단 탭 3(한 항목 = 한 페이지) · **PD 2026-11-12 내정 메뉴 개편 = 로비처럼 탭마다 일러 배경 장면 + 화풍 통일**:
/// ① 훈련 = 연병장 배경 + 고른 병종의 범용 병사 5명이 한 구령에 훈련(원화 대기·자세·타격 클립 · UiDrillAnim) · 병종 탭 5 · 랭크 진행 · 카드 3(HP·공·방) · 3항목 Max → 랭크 승급
/// ② 순찰 = 저잣거리 배경이 보이게 위(순찰 상태 · 「시간당 수확」 = 누르면 펼치는 드롭다운) · 아래(쌓인 보상 · 버튼 「수확」 「즉시 수확」 2개만) · 광고 2배 = 수확을 누른 뒤 고름
/// ③ 개발 = 규장각 배경 + 양피지 두루마리 위 성장 트리(갈래 ◀ 상업·농업·공업 ▶ · 선행 조건 깊이로 줄 · 노드 탭 = 강화 팝업) · 도면·골드 · 트리 초기화.
/// 배경·연출 = 표 Resources/UI/domestic_scene.csv(DomesticScene) · 연출 층 = LobbyAmbient(Frame 1장 · P41).
/// </summary>
public class DomesticScreen : OutGameScreen
{
public static readonly string[] PageNames = { "훈련", "순찰", "개발" };
class Scene
{
public List<LobbyScene.Row> rows;
public LobbyAmbient amb;
public RectTransform art;
public float artW;
public float artH;
}
int page;
int typeIdx;
int branchIdx;
RectTransform backdrop;
readonly Scene[] scenes = new Scene[3];
readonly List<Button> tabs = new List<Button>();
readonly List<RectTransform> pages = new List<RectTransform>();
public int Page
{
get { return page; }
}
/// <summary>병종 탭 순서 = training.csv 행 순서(보·창·기·궁·책).</summary>
public List<UnitType> TrainTypes
{
get
{
var l = new List<UnitType>();
foreach (var r in S.Db.Training.rows)
if (!l.Contains(r.type)) l.Add(r.type);
return l;
}
}
public UnitType TrainType
{
get
{
var l = TrainTypes;
return l.Count > 0 ? l[Mathf.Clamp(typeIdx, 0, l.Count - 1)] : UnitType.Infantry;
}
}
public string Branch
{
get
{
var b = S.Db.Dev.Branches;
return b.Count > 0 ? b[Mathf.Clamp(branchIdx, 0, b.Count - 1)] : "";
}
}
/// <summary>개발 페이지에 그려진 노드 수(테스트).</summary>
public int NodeCount { get; private set; }
/// <summary>그림 아이콘으로 그린 노드 수(테스트 · 나머지 = 첫 글자).</summary>
public int IconNodeCount { get; private set; }
/// <summary>훈련 장면에 선 병사 수(테스트).</summary>
public int SoldierCount { get; private set; }
/// <summary>그 탭의 배경 장면 층(없으면 null · 테스트).</summary>
public LobbyAmbient SceneOf(int p)
{
return p >= 0 && p < scenes.Length && scenes[p] != null ? scenes[p].amb : null;
}
public override void Build()
{
Tab = 2;
if (UISkin.On) UiKit.Paint(UiKit.Fill("ListBg", Rt), UiKit.Col("list_bg", UiKit.Bg), false); // 목록형 바탕(배경 그림이 없을 때)
Header("내정", "훈련 · 순찰 · 개발", true);
var body = Body();
backdrop = UiKit.Fill("Backdrop", body); // 탭마다 배경 장면(처음 열 때 만듦 · 탭·페이지 뒤)
for (int i = 0; i < PageNames.Length; i++)
{
int k = i;
tabs.Add(UISkin.On
? UiKit.SubTab(body, "Tab" + i, PageNames[i], 24f + i * 348f, 18f, 332f, 84f, () => SetPage(k))
: UiKit.Btn("Tab" + i, body, PageNames[i], 36, UiKit.Paper, UiKit.Ink, new Vector2(0f, 1f), new Vector2(0f, 1f), new Vector2(24f + i * 348f, -18f), new Vector2(332f, 84f), () => SetPage(k)));
pages.Add(UiKit.Fill("Page" + i, body, 0f, 0f, 118f, 0f));
}
}
public void SetPage(int p)
{
page = Mathf.Clamp(p, 0, pages.Count - 1);
Refresh();
}
public void SetTrainType(UnitType t)
{
typeIdx = Mathf.Max(0, TrainTypes.IndexOf(t));
Refresh();
}
public void SetBranch(int i)
{
int n = Mathf.Max(1, S.Db.Dev.Branches.Count);
branchIdx = ((i % n) + n) % n;
Refresh();
}
Scene EnsureScene(int p)
{
if (scenes[p] != null) return scenes[p];
var s = new Scene { rows = DomesticScene.Page(DomesticScene.Pages[p]) };
s.amb = DomesticScene.Backdrop(backdrop, "Scene_" + DomesticScene.Pages[p], s.rows, out s.art, out s.artW, out s.artH);
scenes[p] = s;
return s;
}
public override void Refresh()
{
for (int i = 0; i < tabs.Count; i++)
{
bool on = i == page;
if (UISkin.On) UiKit.SetSubTab(tabs[i], on);
else
{
tabs[i].GetComponent<Image>().color = on ? UiKit.Red : UiKit.Paper;
UiKit.LabelOf(tabs[i]).color = on ? UiKit.White : UiKit.Ink;
}
pages[i].gameObject.SetActive(on);
}
var sc = EnsureScene(page);
for (int i = 0; i < scenes.Length; i++)
if (scenes[i] != null && scenes[i].art != null)
scenes[i].art.parent.gameObject.SetActive(i == page);
UiKit.Clear(pages[page]);
if (page == 0) BuildTraining(pages[0], sc);
else if (page == 1) BuildPatrol(pages[1], sc);
else BuildDev(pages[2]);
}
/// <summary>배경 위 반투명 판(배경이 비침 · 테마 dom_panel).</summary>
static RectTransform Glass(Transform p, string name, float x, float y, float w, float h)
{
var rt = UiKit.At(p, name, x, y, w, h);
UiKit.Paint(rt, UiKit.Col("dom_panel", new Color(0.12f, 0.14f, 0.21f, 0.78f)));
return rt;
}
static Text Stroke(Text t)
{
t.horizontalOverflow = HorizontalWrapMode.Overflow;
return UiKit.Title(t, true);
}
// ---------- ① 훈련 ----------
void BuildTraining(RectTransform p, Scene sc)
{
var db = S.Db;
var d = S.Save;
var t = TrainType;
var types = TrainTypes;
for (int i = 0; i < types.Count; i++)
{
var ut = types[i];
bool on = ut == t;
if (UISkin.On)
{
var tb = UiKit.SubTab(p, "Type" + i, UnitTypeInfo.Korean(ut), 24f + i * 208f, 6f, 196f, 78f, () => SetTrainType(ut));
UiKit.SetSubTab(tb, on);
// 병종 아이콘(PD 2026-11-12 「보병 = 검 · 창병 = 창 · 기병 = 말 · 궁병 = 활 · 책사 = 깃털 부채」 · 테마 dom_tab_<병종 키>) — 아이콘 왼쪽 · 글자 오른쪽
var ti = UiKit.Icon(tb.transform, "dom_tab_" + UnitTypeInfo.Key(ut), new Vector2(0.5f, 0.5f), new Vector2(0.5f, 0.5f), new Vector2(-32f, 3f), 62f);
if (ti != null)
{
ti.raycastTarget = false;
UiKit.DropShadow(ti, 2f);
var tl = UiKit.LabelOf(tb).rectTransform;
tl.offsetMin = new Vector2(tl.offsetMin.x + 56f, tl.offsetMin.y);
}
}
else UiKit.Btn("Type" + i, p, UnitTypeInfo.Korean(ut), 36, on ? UiKit.Red : UiKit.Paper, on ? UiKit.White : UiKit.Ink, new Vector2(0f, 1f), new Vector2(0f, 1f), new Vector2(24f + i * 208f, -6f), new Vector2(196f, 78f), () => SetTrainType(ut));
}
int rank = TrainingRules.Rank(d, t);
// 랭크 칩(장면 위) + 병종 보너스 한 줄
var chip = UiKit.Rect("Rank", p, new Vector2(0.5f, 1f), new Vector2(0.5f, 1f), new Vector2(0f, -100f), new Vector2(620f, 68f));
UiKit.Skin(chip, "chip", UiKit.Col("chip_dark", UiKit.Dark));
Stroke(UiKit.FillLabel(chip, TrainingRules.RankName(db, rank) + " <size=30>랭크 " + rank + "/" + db.Training.MaxRank + "</size>", 40, UiKit.White, TextAnchor.MiddleCenter, true, 0f));
Stroke(UiKit.Label(p, UnitTypeInfo.Korean(t) + " 영웅·병사 전원 HP +" + TrainingRules.Bonus(db, d, t, "hp").ToString("0.#") + "% · 공 +" + TrainingRules.Bonus(db, d, t, "atk").ToString("0.#") + "% · 방 +" +
TrainingRules.Bonus(db, d, t, "def").ToString("0.#") + "%", 26, UiKit.White, TextAnchor.MiddleCenter, new Vector2(0.5f, 1f), new Vector2(0.5f, 1f), new Vector2(0f, -172f), new Vector2(1000f, 40f), false));
// 지휘관 파견 칸(오른쪽 위 · PD 2026-11-12 「훈련에 사용할 영웅을 지정(파견) · 병종 별 지휘관 1명」)
DispatchSlot(p, DispatchRules.TrainSlot(t), 1056f - 140f, 92f, "지휘관");
// 연병장 병사 + 지휘관(배경 장면 · 고른 병종) — 배경이 없으면 예전 병종 원
if (sc != null && sc.amb != null) PlaceSoldiers(sc, t);
else
{
SoldierCount = 0;
var face = UiKit.Dot("Face", p, new Vector2(0.5f, 1f), new Vector2(0.5f, 0.5f), new Vector2(0f, -460f), 220f, UiKit.TypeColor(t));
UiKit.FillLabel(face.transform, UnitTypeInfo.Mark(t), 110, UiKit.White);
}
// 랭크 진행(반투명 판): 현재 랭크 마커 + 랭크 보너스 · 다음 랭크 승급 비용 · 남은 레벨
// 아래 UI = 페이지 아래끝 쪽으로(PD 2026-11-12 「하단 UI를 좀 더 아래쪽으로」 · 랭크 판 872 · 카드 1016 · 줄 1330)
const float BarY = 872f, CardY = 1016f, FootY = 1330f;
var bar = Glass(p, "RankBar", 24f, BarY, 1032f, 132f);
Stroke(UiKit.Label(bar, "랭크 진행", 28, UiKit.White, TextAnchor.MiddleLeft, new Vector2(0f, 1f), new Vector2(0f, 1f), new Vector2(24f, -8f), new Vector2(300f, 44f)));
int left = TrainingRules.Remaining(db, d, t);
int total = db.Training.MaxLevel * TrainingTable.Stats.Length;
var fill = UiKit.Bar("Progress", bar, new Vector2(0f, 0.5f), new Vector2(0f, 0.5f), new Vector2(24f, 0f), new Vector2(700f, 26f), new Color(0.32f, 0.28f, 0.35f), UiKit.Coin);
float v = total > 0 ? (total - left) / (float)total : 0f;
UiKit.SetBar(fill, v);
var marker = UiKit.Dot("Marker", bar, new Vector2(0f, 0.5f), new Vector2(0.5f, 0.5f), new Vector2(24f + 700f * v, 0f), 54f, UiKit.Coin);
UiKit.FillLabel(marker.transform, "▲", 26, UiKit.Dark);
var r = db.Training.RankOf(rank);
Stroke(UiKit.Label(bar, TrainingRules.RankName(db, rank) + " · 랭크 보너스 +" + (r != null ? r.rankPct : 0f).ToString("0.#") + "%", 28, UiKit.White, TextAnchor.MiddleLeft, new Vector2(0f, 0f), new Vector2(0f, 0f), new Vector2(24f, 6f), new Vector2(700f, 44f)));
string promo = rank >= db.Training.MaxRank ? "최종 랭크" : "다음 랭크 승급 <color=#FFD36A>골드 " + TrainingRules.PromoteCost(db, d, t).ToString("N0") + "</color>";
Stroke(UiKit.Label(bar, promo, 24, UiKit.White, TextAnchor.MiddleRight, new Vector2(1f, 1f), new Vector2(1f, 1f), new Vector2(-24f, -8f), new Vector2(440f, 40f), false));
Stroke(UiKit.Label(bar, left + " <size=22>남은 레벨</size>", 36, UiKit.White, TextAnchor.MiddleRight, new Vector2(1f, 0f), new Vector2(1f, 0f), new Vector2(-24f, 6f), new Vector2(260f, 50f)));
// 카드 3: HP · 공 · 방
string[] names = { "HP", "공격력", "방어력" };
string[] marks = { "H", "공", "방" };
float cw = 330f;
for (int i = 0; i < 3; i++)
{
int k = i;
int lv = TrainingRules.Lv(d, t, i);
bool max = lv >= db.Training.MaxLevel;
var card = UiKit.Box("Card" + i, p, new Vector2(0f, 1f), new Vector2(0f, 1f), new Vector2(24f + i * (cw + 21f), -CardY), new Vector2(cw, 300f), max ? UiKit.Purple : UiKit.Paper);
UiKit.Border(card, UiKit.Line, 3f);
var head = UiKit.TopBand("Head", card.transform, 0f, 60f);
UiKit.Paint(head, max ? UiKit.Purple : UiKit.Dark);
UiKit.FillLabel(head, max ? "Max" : "LV." + lv, 32, max ? UiKit.White : UiKit.Ink);
// 항목 아이콘 = 하트 · 검 · 갑옷(PD 2026-11-12 「H = 하트 · 공 = 검 · 방 = 갑옷」 · 테마 dom_stat_*) · 없으면 예전 글자 원
// 받침 = 외곽선 없는 둥근 판 · 세 카드 같은 단색(PD 2026-11-12 「둥근 원형 아웃라인을 쓰지 말고 동일한 색상으로 채워진 백판」 · 테마 dom_icon_plate)
var dot = UiKit.Rect("Icon", card.transform, new Vector2(0.5f, 1f), new Vector2(0.5f, 1f), new Vector2(0f, -66f), new Vector2(90f, 76f));
UiKit.Skin(dot, "soft", UiKit.Col("dom_icon_plate", UiKit.Dark));
var sic = UiKit.Icon(dot, StatIcons[i], new Vector2(0.5f, 0.5f), new Vector2(0.5f, 0.5f), Vector2.zero, 62f); // 받침 안 여백 ≥ 7 px(QA r6 M)
if (sic != null) UiKit.DropShadow(sic, 2f);
else UiKit.FillLabel(dot, marks[i], 36, UiKit.Ink);
// 이름 + 지금 레벨 수치 한 줄(PD 2026-11-12 「레벨 정보가 중복 → 현재 레벨의 수치만」 · 레벨은 위 띠 LV.n 하나)
var val = UiKit.Label(card.transform, names[i] + " <color=#FFD36A>+" + TrainingRules.Bonus(db, d, t, TrainingTable.Stats[i]).ToString("0.#") + "%</color>", 36, UiKit.Ink, TextAnchor.MiddleCenter,
new Vector2(0.5f, 1f), new Vector2(0.5f, 1f), new Vector2(0f, -168f), new Vector2(316f, 52f));
val.horizontalOverflow = HorizontalWrapMode.Overflow;
if (max) UiKit.Label(card.transform, "<color=#3a8a30>✔</color>", 44, UiKit.Ok, TextAnchor.MiddleCenter, new Vector2(0.5f, 0f), new Vector2(0.5f, 0f), new Vector2(0f, 14f), new Vector2(300f, 62f));
else
{
var b = UiKit.Btn("Up", card.transform, TrainingRules.Cost(db, d, t, i).ToString("N0") + " 골드", 28, UiKit.Coin, UiKit.Ink, new Vector2(0.5f, 0f), new Vector2(0.5f, 0f), new Vector2(0f, 12f), new Vector2(280f, 62f), () =>
{
var why = S.Do(() => TrainingRules.Level(S.Db, S.Save, TrainType, k));
if (why != null) Root.Toast(why);
});
b.interactable = TrainingRules.CanLevel(db, d, t, i) == null;
}
}
if (left == 0 && rank < db.Training.MaxRank)
{
var pb = UiKit.Btn("Promote", p, "랭크 승급 → " + TrainingRules.RankName(db, rank + 1) + " · 골드 " + TrainingRules.PromoteCost(db, d, t).ToString("N0"), 34, UiKit.Red, UiKit.White, new Vector2(0.5f, 1f), new Vector2(0.5f, 1f), new Vector2(0f, -FootY + 8f), new Vector2(900f, 84f), () =>
{
var why = S.Do(() => TrainingRules.Promote(S.Db, S.Save, TrainType));
Root.Toast(why ?? UnitTypeInfo.Korean(TrainType) + " 랭크 승급 → " + TrainingRules.RankName(S.Db, TrainingRules.Rank(S.Save, TrainType)));
});
pb.interactable = TrainingRules.CanPromote(db, d, t) == null;
}
else
Stroke(UiKit.Label(p, "3항목 Max → 랭크 승급 · 레벨 리셋 · 랭크 보너스 유지", 24, UiKit.White, TextAnchor.MiddleCenter, new Vector2(0.5f, 1f), new Vector2(0.5f, 1f), new Vector2(0f, -FootY - 20f), new Vector2(1030f, 40f), false));
}
/// <summary>훈련 카드 항목 아이콘(HP · 공격 · 방어 = 테마 아이콘 id).</summary>
static readonly string[] StatIcons = { "dom_stat_hp", "dom_stat_atk", "dom_stat_def" };
/// <summary>병종의 범용 병사(heroes.csv is_soldier · 첫 줄 = 그 병종 일러).</summary>
HeroDb.Hero SoldierOf(UnitType t)
{
foreach (var h in S.Db.Heroes.heroes)
if (h.isSoldier && h.type == t)
return h;
return null;
}
/// <summary>
/// 연병장 병사(표 train_slot · 발 = 그림 px) — 그림자·흙먼지(한 아틀라스) 먼저 → 병사(뒷줄부터 · 앞줄이 앞) · 동작 = UiDrillAnim(한 구령 · 뒷줄 f_phase 늦게).
/// 병사는 장면 층(Frame · LobbyAmbient.Lineup)에 둔다 — 매 프레임 그림이 바뀜(P41).
/// </summary>
void PlaceSoldiers(Scene sc, UnitType t)
{
var line = sc.amb.Lineup;
UiKit.Clear(line);
SoldierCount = 0;
CommanderShown = false;
var h = SoldierOf(t);
var drill = DomesticScene.First(sc.rows, "drill"); // 공통 줄(맨 앞 · 표준 키 f_h)
var dustRow = DomesticScene.First(sc.rows, "dust");
var slots = DomesticScene.All(sc.rows, "slot");
if (h == null || drill == null || slots.Count == 0) return;
slots.Sort((a, b) => a.y.CompareTo(b.y)); // 뒷줄(위) 먼저
float stdPx = drill.h > 0f ? drill.h : 190f;
// 지휘관(파견 영웅 · 병사 뒷줄 위 · PD 2026-11-12) — 그림자는 병사와 한 번에
var cmdRow = DomesticScene.First(sc.rows, "cmd");
var cmd = S.Def(DispatchRules.Get(S.Save, DispatchRules.TrainSlot(t)));
var feet = new List<LobbyScene.Row>(slots);
if (cmd != null && cmdRow != null) feet.Add(cmdRow);
Shadows(sc, line, feet);
if (cmd != null && cmdRow != null)
CommanderShown = Stand(sc, line, "Commander", cmd, cmdRow, stdPx) != null;
var dusts = new Image[slots.Count];
if (dustRow != null)
{
var dsp = UISkin.Icon(dustRow.sprite);
for (int i = 0; i < slots.Count; i++)
{
var r = slots[i];
float k = r.scale > 0.01f ? r.scale : 1f;
var s = UiKit.Rect("Dust" + i, line, DomesticScene.Anchor(r.x, r.y, sc.artW, sc.artH), new Vector2(0.5f, 0.5f), new Vector2(dustRow.x * k, -dustRow.y * k), new Vector2(dustRow.w * k, dustRow.h * k));
dusts[i] = s.gameObject.AddComponent<Image>();
dusts[i].sprite = dsp != null ? dsp : UiKit.Circle;
dusts[i].color = dustRow.color;
dusts[i].raycastTarget = false;
}
}
var data = UnitArt.Load(h.art);
LobbyScene.Row footFix = null; // 그 병종 그림의 뒷발 맞춤(표 train_feet)
foreach (var fr in DomesticScene.All(sc.rows, "feet"))
if (fr.sprite == h.art) footFix = fr;
// 병종 그림별 박자(표 train_drill 중 s_sprite = 그 그림 · 없으면 공통 줄) — PD 2026-11-12 기병 「말을 세운 상태에서 돌진 그림은 빠르게 재생되며 원래 상태로」
foreach (var dr in DomesticScene.All(sc.rows, "drill"))
if (dr.sprite == h.art) drill = dr;
// 타격 때 날아가는 화살(궁병 · 표 train_shot s_sprite = 「아트 id|화살 아이콘」)
LobbyScene.Row shot = null;
Sprite shotSp = null;
foreach (var sr in DomesticScene.All(sc.rows, "shot"))
{
var ss = sr.Sprites;
if (ss.Length >= 2 && ss[0] == h.art)
{
shot = sr;
shotSp = UISkin.Icon(ss[1]);
}
}
for (int i = 0; i < slots.Count; i++)
{
var r = slots[i];
float k = r.scale > 0.01f ? r.scale : 1f;
var stand = UiKit.UnitStand("Soldier" + i, line, h, DomesticScene.Anchor(r.x, r.y, sc.artW, sc.artH), Vector2.zero, stdPx * k, MountedTop);
if (stand == null) continue;
var anim = UiDrillAnim.Attach(stand.GetComponent<Image>(), data, drill, r.phase, k, sc.artW, dusts[i], dustRow, footFix);
if (anim != null && shot != null) anim.AttachShot(shotSp, shot);
SoldierCount++;
}
}
/// <summary>훈련 장면에 지휘관이 섰는지(테스트).</summary>
public bool CommanderShown { get; private set; }
float MountedTop
{
get { return Root.Battle != null && Root.Battle.Config != null ? Root.Battle.Config.mountedArtMaxTop : 0f; }
}
/// <summary>발 자리마다 그림자(표 shadow 줄 · 한 아틀라스 = 한 묶음이라 사람보다 먼저 모두).</summary>
static void Shadows(Scene sc, RectTransform line, List<LobbyScene.Row> feet)
{
foreach (var sh in DomesticScene.All(sc.rows, "shadow"))
{
var glow = UISkin.Icon(sh.sprite);
for (int i = 0; i < feet.Count; i++)
{
var r = feet[i];
float k = r.scale > 0.01f ? r.scale : 1f;
var s = UiKit.Rect("Shadow" + i, line, DomesticScene.Anchor(r.x, r.y, sc.artW, sc.artH), new Vector2(0.5f, 0.5f), new Vector2(sh.x * k, -sh.y * k), new Vector2(sh.w * k, sh.h * k));
var simg = s.gameObject.AddComponent<Image>();
simg.sprite = glow != null ? glow : UiKit.Circle;
simg.color = sh.color;
simg.raycastTarget = false;
}
}
}
/// <summary>장면에 영웅·병사 1명(발 = 표 줄 · 키 = 표준 × f_scale · 대기 숨 고르기 클립).</summary>
RectTransform Stand(Scene sc, RectTransform line, string name, HeroDb.Hero h, LobbyScene.Row r, float stdPx)
{
float k = r.scale > 0.01f ? r.scale : 1f;
var stand = UiKit.UnitStand(name, line, h, DomesticScene.Anchor(r.x, r.y, sc.artW, sc.artH), Vector2.zero, stdPx * k, MountedTop);
if (stand != null) UiFrameAnim.Attach(stand.GetComponent<Image>(), UnitArt.Load(h.art), "front_idle");
return stand;
}
// ---------- ② 순찰 ----------
/// <summary>
/// 순찰(PD 2026-11-12) — 저잣거리 배경이 잘 보이게: 위 = 순찰 상태(게이지) + 「시간당 수확 ▼」 드롭다운(누르면 펼침) · 가운데 = 배경 · 아래 = 쌓인 보상 + 「수확」 「즉시 수확」.
/// 광고 2배는 수확을 누른 뒤 고르는 팝업(OpenHarvest).
/// </summary>
void BuildPatrol(RectTransform p, Scene sc)
{
var db = S.Db;
var d = S.Save;
var e = db.Economy;
var row = PatrolRules.Row(db, d);
float cap = PatrolRules.CapHours(db, d);
float h = PatrolRules.Hours(db, d, S.Now);
// 문 앞 순찰 영웅 + 창병 2 + 상인·주민(장면) · 영웅 파견 칸(오른쪽 위) — PD 2026-11-12
if (sc != null && sc.amb != null) PlacePatrol(sc);
DispatchSlot(p, DispatchRules.Patrol, 1056f - 140f, 6f, "순찰 영웅");
// 배치(PD 「저잣거리가 잘 보이게 · 남은 공간 활용」) = UI 를 모두 아래 한 판에 모음 · 위·가운데는 패루·상점·길
// PD 2026-11-12 예시(정보 판) = 왼쪽 「순찰 · 시계 + 7시간 / 12시간 · 게이지 · 기준」 | 오른쪽 시간당 수확 3줄(늘 보임 · 드롭다운 없앰 · 아이템 = 「전리품」 하나로) / 아래 = 프레임 안 「획득 보상」 + 칸 4
// 판 아래끝 1228 = 즉시 수확 「N시간 분량」 배지(≈1247) 와 띄움
const float W = 1032f, PanelH = 384f, PanelY = 1228f - PanelH, LeftW = 560f, GaugeX = 12f, GaugeY = 42f, FrameY = 176f, FrameH = 198f;
const float GaugeBar = LeftW - GaugeX - 86f - 12f; // 게이지 틀 = 배지 86 + 막대 · 왼쪽 칸 안
var a = Glass(p, "Patrol", 24f, PanelY, W, PanelH);
// ① 왼쪽 = 순찰 상태 — 배지(쌓인 시간) · 「순찰」 · 시계 + 「7시간 / 12시간」 · 게이지 · 기준 줄
Text badge;
var fill = UiKit.Gauge(a, "Gauge", GaugeX, GaugeY, GaugeBar, "lv_badge", "grain", out badge);
UiKit.SetGauge(fill, cap > 0f ? h / cap : 0f, GaugeBar);
badge.text = Mathf.FloorToInt(h) + "h";
badge.fontSize = 30;
float barX = GaugeX + 86f; // 막대 왼쪽 끝 = 제목 시작
Stroke(UiKit.Label(a, "순찰", 34, UiKit.White, TextAnchor.MiddleLeft, new Vector2(0f, 1f), new Vector2(0f, 1f), new Vector2(barX + 8f, -4f), new Vector2(160f, 50f)));
var tt = Stroke(UiKit.Label(a, FormatHours(h) + " / " + FormatHours(cap), 30, UiKit.White, TextAnchor.MiddleRight, new Vector2(0f, 1f), new Vector2(1f, 1f), new Vector2(LeftW - 12f, -4f), new Vector2(300f, 50f)));
// 시간 앞 시계(PD 2026-11-12 「시간 정보 앞에는 시계 아이콘」 · 테마 clock · 없으면 글자만)
var ck = UiKit.Icon(a, "clock", new Vector2(0f, 1f), new Vector2(0.5f, 0.5f), new Vector2(LeftW - 12f - Mathf.Ceil(tt.preferredWidth) - 26f, -29f), 42f);
if (ck != null)
{
ck.name = "Clock";
UiKit.DropShadow(ck, 3f);
}
var basis = UiKit.Label(a, "기준 = 마지막 클리어 " + (row != null ? row.chapter : 0) + "장 · 오프라인 누적", 22, UiKit.White, TextAnchor.MiddleLeft, new Vector2(0f, 1f), new Vector2(0f, 1f), new Vector2(26f, -(GaugeY + 98f)), new Vector2(LeftW - 30f, 34f), false);
basis.horizontalOverflow = HorizontalWrapMode.Overflow;
Stroke(basis);
// ② 오른쪽 = 시간당 수확(간략 · 3줄) — 용어 = 골드 · 경험치 · 전리품(장비·상자 구분 없이 합) · 값 = 「획득량 / 시간」만 오른쪽 정렬(PD 2026-11-12)
var div = UiKit.At(a, "Divider", LeftW + 6f, 16f, 2f, FrameY - 30f);
UiKit.Paint(div, new Color(1f, 1f, 1f, 0.18f));
float infoW = W - LeftW - 36f;
var info = UiKit.At(a, "RateInfo", LeftW + 22f, 10f, infoW, FrameY - 18f);
string[] rIcon = { "cur_gold", "cur_exp", "cur_gear" };
string[] rn = { "골드", "경험치", "전리품" };
string[] rv =
{
PatrolRules.GoldPerHour(db, d).ToString("N0"),
PatrolRules.ExpPerHour(db, d).ToString("N0"),
(PatrolRules.GearPerHour(db, d) + PatrolRules.ChestPerHour(db, d)).ToString("0.##"),
};
for (int i = 0; i < rn.Length; i++)
{
var r = UiKit.At(info, "Rate" + i, 0f, i * 52f, infoW, 50f);
var ic = UiKit.Icon(r, rIcon[i], new Vector2(0f, 0.5f), new Vector2(0.5f, 0.5f), new Vector2(24f, 0f), 44f);
if (ic != null) UiKit.DropShadow(ic, 3f);
Stroke(UiKit.Label(r, rn[i], 27, UiKit.Col("dom_rate_name", new Color(0.91f, 0.86f, 0.77f)), TextAnchor.MiddleLeft, new Vector2(0f, 0.5f), new Vector2(0f, 0.5f), new Vector2(56f, 0f), new Vector2(150f, 50f)));
var v = Stroke(UiKit.Label(r, rv[i] + "<size=20> / 시간</size>", 28, UiKit.White, TextAnchor.MiddleRight, new Vector2(1f, 0.5f), new Vector2(1f, 0.5f), new Vector2(-6f, 0f), new Vector2(infoW - 210f, 50f)));
v.name = "PerHour";
}
// ③ 아래 = 획득 보상 — 프레임(테마 dom_reward_frame · 9-slice = 네 모서리 장식 그대로 · 없으면 프레임 없이) 안 왼쪽 글자 + 칸 4
var y = PatrolRules.Preview(db, d, h, 1f);
var fsp = UISkin.Icon("dom_reward_frame");
if (fsp != null)
{
var fr = UiKit.At(a, "RewardFrame", 10f, FrameY, W - 20f, FrameH);
var fimg = fr.gameObject.AddComponent<Image>();
fimg.sprite = fsp;
fimg.raycastTarget = false;
if (fsp.border != Vector4.zero)
{
fimg.type = Image.Type.Sliced;
fimg.pixelsPerUnitMultiplier = 2f; // 테두리(모서리 매듭·막대)를 그림의 절반 크기로 = 얇은 금테(PD 예시)
}
}
Stroke(UiKit.Label(a, "획득\n보상", 30, UiKit.White, TextAnchor.MiddleCenter, new Vector2(0f, 1f), new Vector2(0f, 0.5f), new Vector2(50f, -(FrameY + FrameH * 0.5f)), new Vector2(104f, 90f)));
RewardRow(a, y, db, d, 164f, W - 10f - 36f - 164f, FrameY + 30f, 128f); // 칸 위 30 · 수량 칩 아래 ≈ 8 = 금테 막대 안쪽(QA r9 M⑥ 칩이 막대에 붙음)
// ④ 버튼 2개(PD 2026-11-12 「순찰 메뉴에는 수확과 즉시 수확만」)
var hb = UiKit.SkinBtn(p, "Harvest", h > 0f ? "btn_l_green" : "btn_gray", UiKit.White, "수확", 52, UiKit.Col("text_on_green", UiKit.White), 24f, 1270f, 505f, 128f, OpenHarvest);
hb.interactable = h > 0f;
var ins = UiKit.SkinBtn(p, "Instant", "btn_purple", UiKit.White, "즉시 수확", 44, UiKit.White, 1056f - 505f, 1270f, 505f, 128f, () =>
{
string why;
var r = S.InstantHarvest(out why);
if (r == null) Root.Toast(why);
else ShowYield("즉시 수확", r);
});
UiKit.BtnCost(ins, "cur_gem", e.PatrolInstantGem.ToString(), 40f, 32);
// 즉시 수확 분량 = 버튼 위끝에 걸친 배지(칩)
var hc = UiKit.Skin(UiKit.Rect("Hours", ins.transform, new Vector2(0.5f, 1f), new Vector2(0.5f, 0.5f), new Vector2(0f, -2f), new Vector2(200f, 42f)), "chip", UiKit.Col("chip_dark", UiKit.Dark));
Stroke(UiKit.FillLabel(hc.transform, FormatHours(e.PatrolInstantHours) + " 분량", 24, UiKit.White, TextAnchor.MiddleCenter, true, 0f));
}
/// <summary>순찰 장면에 선 NPC 수 · 창병 수 · 영웅 여부(테스트).</summary>
public int NpcCount { get; private set; }
public int GuardCount { get; private set; }
public bool PatrolHeroShown { get; private set; }
/// <summary>
/// 저잣거리 장면(PD 2026-11-12 「순찰 영웅은 문 앞에서 창병 2기를 양쪽에 두고 대기 자세」 · 「상인 및 주민 NPC 등이 보이게」) —
/// 표 patrol_hero · patrol_guard · patrol_npc 줄(그림 px) · 그림자 먼저 → 위(뒤)에서 아래(앞) 순서. NPC 는 움직이지 않는 그림.
/// </summary>
void PlacePatrol(Scene sc)
{
var line = sc.amb.Lineup;
UiKit.Clear(line);
NpcCount = GuardCount = 0;
PatrolHeroShown = false;
var unit = DomesticScene.First(sc.rows, "unit");
float stdPx = unit != null && unit.h > 0f ? unit.h : 180f;
var heroRow = DomesticScene.First(sc.rows, "hero");
var hero = S.Def(DispatchRules.Get(S.Save, DispatchRules.Patrol));
var guards = DomesticScene.All(sc.rows, "guard");
var npcs = DomesticScene.All(sc.rows, "npc");
var people = new List<LobbyScene.Row>(guards);
if (hero != null && heroRow != null) people.Add(heroRow);
people.AddRange(npcs);
Shadows(sc, line, people);
people.Sort((a, b) => a.y.CompareTo(b.y)); // 위(뒤) 먼저
foreach (var r in people)
{
if (r.kind == "npc")
{
// 상인·주민 = 유닛 아트(Resources/Units/npc_*) 대기 숨 고르기(유닛과 같은 R22 클립 · PD 2026-11-12 「NPC 들이 유닛 대기 모션처럼」) · 키 = 표 f_h(그림 몸 높이 기준)
var data = UnitArt.Load(r.sprite);
Sprite[] fr;
if (data == null || data.Meta.pivot == null || data.Meta.pivot.Length < 2 || !data.Clips.TryGetValue("front_idle", out fr) || fr.Length == 0) continue;
float hh = r.h > 0f ? r.h : 150f;
float k = hh / Mathf.Max(1f, data.BodyPx);
var rt = UiKit.Rect("Npc_" + r.sprite, line, DomesticScene.Anchor(r.x, r.y, sc.artW, sc.artH), new Vector2(0.5f, data.Meta.pivot[1]), Vector2.zero, fr[0].rect.size * k);
var img = rt.gameObject.AddComponent<Image>();
img.sprite = fr[0];
img.raycastTarget = false;
if (r.n == 1) rt.localScale = new Vector3(-1f, 1f, 1f);
UiFrameAnim.Attach(img, data, "front_idle");
NpcCount++;
}
else if (r.kind == "guard")
{
var g = S.Def(r.sprite);
if (g != null && Stand(sc, line, "Guard" + r.slot, g, r, stdPx) != null) GuardCount++;
}
else if (hero != null && Stand(sc, line, "PatrolHero", hero, r, stdPx) != null) PatrolHeroShown = true;
}
}
/// <summary>쌓인 보상 타일 4(골드 · 병사 경험 · 승급 장비 · 구리 상자) — x ~ x+w 안 가운데 정렬.</summary>
static void RewardRow(Transform c, PatrolYield y, OutGameData db, SaveData d, float x, float w, float top, float tile)
{
string[] rIcon = { "cur_gold", "cur_exp", "cur_gear", "chest_bronze" };
string[] sn = { y.gold.ToString("N0"), y.exp.ToString("N0"), y.gear.ToString(), y.chests.ToString() };
float gap = Mathf.Min(72f, (w - 4f * tile - 140f) / 3f);
float x0 = x + (w - 4f * tile - 3f * gap) * 0.5f;
for (int i = 0; i < 4; i++)
UiKit.RewardTile(c, "Tile" + i, x0 + i * (tile + gap), top, tile, i == 2 ? "gear_" + PatrolRules.GearGrade(db, d) + "_" : "gear_white_", rIcon[i], sn[i], i == 2);
}
/// <summary>수확 팝업(PD 2026-11-12 「광고 2배 보상은 수확 버튼을 눌렀을 때 선택」) — 쌓인 보상 + 「수확」 · 「광고 2배」(하루 n/m회).</summary>
public void OpenHarvest()
{
var db = S.Db;
var d = S.Save;
var e = db.Economy;
float h = PatrolRules.Hours(db, d, S.Now);
if (h <= 0f)
{
Root.Toast("쌓인 보상이 없습니다");
return;
}
EconomyRules.RollDaily(d, S.Now);
var content = Root.ModalCustom("수확", 700f);
var panel = content.parent;
UiKit.Label(content, FormatHours(h) + " 분량", 36, UiKit.Ink, TextAnchor.MiddleCenter, new Vector2(0.5f, 1f), new Vector2(0.5f, 1f), new Vector2(0f, -4f), new Vector2(800f, 54f));
RewardRow(content, PatrolRules.Preview(db, d, h, 1f), db, d, 0f, 860f, 76f, 132f);
bool adOk = d.adsUsed.patrol < e.PatrolAdDaily;
UiKit.Label(content, "광고를 보면 보상 2배 · 오늘 " + d.adsUsed.patrol + "/" + e.PatrolAdDaily + "회", 28, UiKit.Col("text_soft", UiKit.InkSoft), TextAnchor.MiddleCenter, new Vector2(0.5f, 1f), new Vector2(0.5f, 1f), new Vector2(0f, -250f), new Vector2(800f, 44f), false);
var hb = UiKit.SkinBtn(panel, "Take", "btn_l_green", UiKit.White, "수확", 44, UiKit.Col("text_on_green", UiKit.White), 40f, 700f - 150f, 420f, 116f, () =>
{
Root.CloseModal();
DoHarvest(false);
});
var ad = UiKit.SkinBtn(panel, "Ad", adOk ? "btn_sky" : "btn_gray", UiKit.White, "광고 2배", 40, UiKit.White, 940f - 40f - 420f, 700f - 150f, 420f, 116f, () =>
{
Root.CloseModal();
DoHarvest(true);
});
ad.interactable = adOk;
UiKit.BtnCost(ad, "ad", d.adsUsed.patrol + "/" + e.PatrolAdDaily, 34f, 28);
hb.name = "Take";
}
void DoHarvest(bool ad)
{
string why;
var r = S.Harvest(ad, out why);
if (r == null) Root.Toast(why);
else ShowYield(ad ? "광고 2배 수확" : "수확", r);
}
void ShowYield(string title, PatrolYield y)
{
var sb = new System.Text.StringBuilder();
sb.Append(FormatHours(y.hours)).Append(y.mult > 1f ? " × " + y.mult : "").Append(" 분량\n\n");
// 용어 = 골드 · 경험치 · 전리품(PD 2026-11-12 「3가지로 용어를 통일」) · 전리품 = 받은 장비·상자 이름
sb.Append("골드 +").Append(y.gold.ToString("N0")).Append("\n경험치 +").Append(y.exp).Append(" (").Append(y.expTo.Count).Append("명)");
var loot = new List<string>();
foreach (var g in y.gearIds) loot.Add(S.Db.Gear.Get(g).nameKo);
if (y.chestIds.Count > 0) loot.Add(S.Db.Chests.Get(y.chestIds[0]).nameKo + " ×" + y.chestIds.Count);
if (loot.Count > 0) sb.Append("\n전리품 ").Append(string.Join(" · ", loot.ToArray()));
Root.Modal(title, sb.ToString(), new OutGameRoot.ModalButton("확인", UiKit.Dark, null));
}
public static string FormatHours(float h)
{
int min = Mathf.FloorToInt(h * 60f + 1e-3f);
return (min / 60) + "시간" + (min % 60 > 0 ? " " + (min % 60) + "분" : "");
}
// ---------- 영웅 파견(훈련 지휘관 · 순찰 · 개발 · PD 2026-11-12) ----------
/// <summary>파견 칸(왼쪽 위 기준 140×150) — 파견됨 = 영웅 초상 원 + 이름표 · 비어 있음 = 「+」 · 누르면 고르기 창.</summary>
void DispatchSlot(Transform p, string slot, float x, float y, string caption)
{
var box = UiKit.At(p, "Dispatch", x, y, 140f, 150f);
var hit = box.gameObject.AddComponent<Image>();
hit.color = new Color(1f, 1f, 1f, 0f);
hit.raycastTarget = true;
box.gameObject.AddComponent<Button>().onClick.AddListener(() => OpenDispatch(slot));
var h = S.Def(DispatchRules.Get(S.Save, slot));
if (h != null) UiKit.Portrait("Face", box, h, new Vector2(0.5f, 1f), new Vector2(0.5f, 1f), Vector2.zero, new Vector2(116f, 116f), true, 2.1f, UiKit.Coin);
else
{
var c = UiKit.Rect("Empty", box, new Vector2(0.5f, 1f), new Vector2(0.5f, 1f), Vector2.zero, new Vector2(116f, 116f));
var ci = UiKit.Skin(c, "circle122", UiKit.Col("menu_btn", UiKit.Dark));
ci.preserveAspect = true;
Stroke(UiKit.FillLabel(c, "+", 64, UiKit.White, TextAnchor.MiddleCenter, true, 0f));
}
var tag = UiKit.Skin(UiKit.Rect("Tag", box, new Vector2(0.5f, 0f), new Vector2(0.5f, 0f), Vector2.zero, new Vector2(150f, 40f)), "chip", UiKit.Col("chip_dark", UiKit.Dark));
Stroke(UiKit.FillLabel(tag.transform, h != null ? h.nameKo : caption, 24, UiKit.White, TextAnchor.MiddleCenter, true, 0f));
}
/// <summary>파견 고르기 창 — 보유 영웅(훈련 = 그 병종만) 초상 격자 · 다른 자리에 있으면 「○○ 중」 표시(고르면 옮김) · 해제 · 닫기.</summary>
public void OpenDispatch(string slot)
{
var db = S.Db;
var d = S.Save;
var list = DispatchRules.Candidates(db, d, slot);
string cur = DispatchRules.Get(d, slot);
string title = slot == DispatchRules.Patrol ? "순찰 영웅 파견" : slot == DispatchRules.Dev ? "개발 영웅 배치" : DispatchRules.SlotKo(slot) + " 지휘관 파견";
var content = Root.ModalCustom(title, 1180f);
var panel = content.parent;
DispatchChoices = list.Count;
if (list.Count == 0)
UiKit.FillLabel(content, slot.StartsWith("train:") ? "이 병종의 보유 영웅이 없습니다" : "보유 영웅이 없습니다", 34, UiKit.Ink, TextAnchor.MiddleCenter, true, 0f);
else
{
ScrollRect sr;
var grid = UiKit.ScrollGrid("Pick", content, 0f, 0f, 0f, 0f, new Vector2(196f, 236f), new Vector2(16f, 16f), 4, out sr);
foreach (var hh in list)
{
var h = hh;
var cell = new GameObject("Hero_" + h.id, typeof(RectTransform)).GetComponent<RectTransform>();
cell.SetParent(grid, false);
var ci = cell.gameObject.AddComponent<Image>();
ci.color = new Color(1f, 1f, 1f, 0f);
ci.raycastTarget = true;
cell.gameObject.AddComponent<Button>().onClick.AddListener(() =>
{
var why = S.Do(() => DispatchRules.Assign(S.Db, S.Save, slot, h.id));
if (why != null) Root.Toast(why);
else Root.CloseModal();
});
UiKit.Portrait("Face", cell, h, new Vector2(0.5f, 1f), new Vector2(0.5f, 1f), new Vector2(0f, -4f), new Vector2(172f, 172f), true, 2.1f, h.id == cur ? UiKit.Coin : UiKit.Line);
Stroke(UiKit.Label(cell, h.nameKo, 28, UiKit.White, TextAnchor.MiddleCenter, new Vector2(0.5f, 0f), new Vector2(0.5f, 0f), new Vector2(0f, 14f), new Vector2(196f, 40f)));
var at = DispatchRules.Where(d, h.id);
if (at != null)
{
var chip = UiKit.Skin(UiKit.Rect("Where", cell, new Vector2(0.5f, 1f), new Vector2(0.5f, 0.5f), new Vector2(0f, -150f), new Vector2(170f, 36f)), "chip", at == slot ? UiKit.Coin : UiKit.Col("chip_dark", UiKit.Dark));
Stroke(UiKit.FillLabel(chip.transform, at == slot ? "배치 중" : DispatchRules.SlotKo(at) + " 중", 22, UiKit.White, TextAnchor.MiddleCenter, true, 0f));
}
}
}
if (!string.IsNullOrEmpty(cur))
UiKit.SkinBtn(panel, "Clear", "btn_gray", UiKit.White, "해제", 38, UiKit.White, 40f, 1180f - 142f, 420f, 108f, () =>
{
S.Do(() => DispatchRules.Clear(S.Save, slot));
Root.CloseModal();
});
UiKit.SkinBtn(panel, "Close", "btn_sky", UiKit.White, "닫기", 38, UiKit.White, 940f - 40f - 420f, 1180f - 142f, 420f, 108f, Root.CloseModal);
}
/// <summary>마지막으로 연 고르기 창의 후보 수(테스트).</summary>
public int DispatchChoices { get; private set; }
// ---------- ③ 개발 ----------
/// <summary>
/// 개발(PD 2026-11-12 「규장각 같은 연구 장소를 배경 · 양피지나 서적 내용에 성장 트리」 + 같은 날 개편 「재화 정보 판·글자 줄 제거 → 그 자리에 아이콘·재화 · 양피지 좌우 넓힘 · 트리 초기화 = 아래 두루마리」 · 「개발 메뉴에도 영웅 배치」) —
/// 두루마리(dom_scroll · 좌우 9-slice = 금 마개 그대로 가운데만 늘림) 위 막대 = 갈래 이름 ◀ ▶ · 종이 = 쪽 점 · 도면·골드 · 트리(먹 선 = 노드 가운데끼리 · 이름 = 노드 옆) · 아래 막대 = 트리 초기화 · 왼쪽 아래 = 배치 영웅.
/// 종이 사각 = 표 dev_paper(원본 px · 실측) · 그림이 없으면 같은 자리에 종이색 판.
/// </summary>
void BuildDev(RectTransform p)
{
var db = S.Db;
var d = S.Save;
var branches = db.Dev.Branches;
if (branches.Count == 0) return;
int n = branches.Count;
string br = Branch;
var ink = UiKit.Col("dom_ink", new Color(0.35f, 0.23f, 0.13f));
var inkSoft = UiKit.Col("dom_ink_soft", new Color(0.54f, 0.42f, 0.30f));
// 두루마리(표 dev_scroll · 화면 폭 f_x × 높이 f_y · 좌우 테두리 = 원본 px 그대로)
var sc = scenes[2] != null ? scenes[2].rows : DomesticScene.Page("dev");
var scrollRow = DomesticScene.First(sc, "scroll");
var paperRow = DomesticScene.First(sc, "paper");
var sp = scrollRow != null ? UISkin.Icon(scrollRow.sprite) : null;
float SH = scrollRow != null && scrollRow.y > 0f ? scrollRow.y : 1290f;
float bw = scrollRow != null && scrollRow.w > 0f ? scrollRow.w : (sp != null ? sp.rect.width : 1f);
float bh = scrollRow != null && scrollRow.h > 0f ? scrollRow.h : (sp != null ? sp.rect.height : 1f);
float ry = SH / Mathf.Max(1f, bh);
float sw = scrollRow != null && scrollRow.x > 0f ? scrollRow.x : SH * bw / Mathf.Max(1f, bh);
var scroll = UiKit.Rect("Scroll", p, new Vector2(0.5f, 1f), new Vector2(0.5f, 1f), Vector2.zero, new Vector2(sw, SH));
Rect paper; // 두루마리 안 종이(왼쪽 위 기준 · 아래로 +)
if (sp != null && paperRow != null)
{
var simg = scroll.gameObject.AddComponent<Image>();
simg.sprite = sp;
simg.raycastTarget = false;
// 좌우 테두리(spriteBorder) 가 있으면 가운데만 늘림 — 종이 왼끝 = 원본 px 그대로 · 오른끝 = 화면 폭 − (원본 폭 − 원본 오른끝)
float bl = 0f, brd = 0f;
if (sp.border.x > 0f || sp.border.z > 0f)
{
simg.type = Image.Type.Sliced;
float k = sp.rect.height / Mathf.Max(1f, bh); // 임포트 px ÷ 원본 px
simg.pixelsPerUnitMultiplier = k / Mathf.Max(0.01f, ry); // 테두리 = 원본 px × 세로 배율
bl = sp.border.x / k * ry;
brd = sp.border.z / k * ry;
}
float px0 = paperRow.x * ry, px1 = sw - (bw - (paperRow.x + paperRow.w)) * ry;
if (bl <= 0f) // 테두리 없음 = 비율대로
{
float rx = sw / Mathf.Max(1f, bw);
px0 = paperRow.x * rx;
px1 = (paperRow.x + paperRow.w) * rx;
}
paper = new Rect(px0, paperRow.y * ry, px1 - px0, paperRow.h * ry);
}
else
{
UiKit.Paint(scroll, new Color(0.97f, 0.91f, 0.78f));
paper = new Rect(40f, 140f, sw - 80f, SH - 200f);
}
// 위 막대 = 갈래 이름 + ◀ ▶ · 아래 막대 = 트리 초기화
float rollY = Mathf.Max(60f, paper.y * 0.46f);
Stroke(UiKit.Label(scroll, BranchKo(br), 50, UiKit.White, TextAnchor.MiddleCenter, new Vector2(0.5f, 1f), new Vector2(0.5f, 0.5f), new Vector2(0f, -rollY), new Vector2(360f, 70f)));
ArrowBtn(scroll, "Prev", "◀", -250f, rollY, () => SetBranch(branchIdx - 1));
ArrowBtn(scroll, "Next", "▶", 250f, rollY, () => SetBranch(branchIdx + 1));
float rollB = paper.yMax + (SH - paper.yMax) * 0.5f; // 아래 막대 가운데
// 아래 막대 = [영웅 배치 칸(다른 탭과 같은 파견 칸 · PD 2026-11-12)] [트리 초기화] — 둘을 막대 가운데에 나란히(왼쪽 아래 영웅 상반신과 안 겹치게 조금 오른쪽)
// PD 2026-11-12 「배치 영웅의 위치를 좌측으로 조금 더 · 초기화 버튼은 중앙 정렬」 = 버튼 = 막대 가운데 · 파견 칸 = 왼쪽(금 마개 안쪽)
const float ResetW = 320f;
DispatchSlot(scroll, DispatchRules.Dev, sw * 0.15f, rollB - 75f, "영웅 배치");
DevHeroShown = S.Def(DispatchRules.Get(d, DispatchRules.Dev)) != null;
var rb = UiKit.SkinBtn(scroll, "Reset", "btn_purple", UiKit.White, "트리 초기화", 30, UiKit.White, (sw - ResetW) * 0.5f, rollB - 46f, ResetW, 92f, () =>
{
Root.Modal("트리 초기화", "모든 개발 노드를 0 으로 되돌립니다.\n보옥 " + db.Economy.DevResetGem + " 소모 · 쓴 도면 " + S.Save.dev.blueprintSpent + " 환급 · 골드 환급 없음",
new OutGameRoot.ModalButton("초기화", UiKit.Red, () => Root.Toast(S.Do(() => DevRules.Reset(S.Db, S.Save)) ?? "개발 트리 초기화")),
new OutGameRoot.ModalButton("취소", UiKit.Dark, null));
});
UiKit.BtnCost(rb, "cur_gem", db.Economy.DevResetGem.ToString(), 34f, 26);
// 종이 머리: 쪽 점 + 도면·골드(예전 갈래 설명 글자 줄 자리 · PD 2026-11-12)
var pr = UiKit.At(scroll, "Paper", paper.x, paper.y, paper.width, paper.height);
for (int i = 0; i < n; i++) UiKit.Dot("Page" + i, pr, new Vector2(0.5f, 1f), new Vector2(0.5f, 0.5f), new Vector2((i - (n - 1) / 2f) * 30f, -22f), 16f, i == branchIdx ? UiKit.Red : inkSoft);
// 재화 = 양피지 색 둥근 받침(단색 · 짙은 색 금지 · PD 2026-11-12) 2개 · 받침 사이 넓게
var cur = UiKit.Rect("Currency", pr, new Vector2(0.5f, 1f), new Vector2(0.5f, 1f), new Vector2(0f, -36f), new Vector2(paper.width, 58f));
var hl = cur.gameObject.AddComponent<HorizontalLayoutGroup>();
hl.childAlignment = TextAnchor.MiddleCenter;
hl.spacing = 170f; // PD 2026-11-12 「재화 간 간격을 더 넓히고」 2회(56 → 120 → 170)
hl.childControlWidth = true;
hl.childControlHeight = false;
hl.childForceExpandWidth = hl.childForceExpandHeight = false;
CurrencyItem(cur, "cur_blueprint", "도", new Color(0.33f, 0.45f, 0.62f), "도면 ×" + d.inv.blueprint, ink);
CurrencyItem(cur, "cur_gold", "골", UiKit.Coin, d.gold.ToString("N0"), ink);
// 깊이(선행 조건) = 줄 · 같은 줄 = 가로로
var nodes = db.Dev.InBranch(br);
var depth = new Dictionary<string, int>();
int maxDepth = 0;
foreach (var nd in nodes)
{
int dp = 0;
foreach (var r in nd.requires)
{
int x;
if (depth.TryGetValue(r.node, out x)) dp = Mathf.Max(dp, x + 1);
}
depth[nd.id] = dp;
maxDepth = Mathf.Max(maxDepth, dp);
}
const float TreeTop = 100f, TreeBottom = 12f;
var tree = UiKit.Fill("Tree", pr, 0f, 0f, TreeTop, TreeBottom);
float th = paper.height - TreeTop - TreeBottom;
float rowH = th / (maxDepth + 1);
// 이름 글자 없음(PD 2026-11-12 「텍스트는 제거 · 대신 아이콘 크기를 키우고 가로 1열에 아이콘을 놓을 수 있게」 · 이름 = 노드를 누르면 팝업) —
// 한 줄 = 종이 폭에 고르게(2개 = 가운데 ± 종이 28 % · 더 많으면 종이 88 % 안에 나눔)
var pos = new Dictionary<string, Vector2>();
for (int dp = 0; dp <= maxDepth; dp++)
{
var row = new List<DevTree.Node>();
foreach (var nd in nodes)
if (depth[nd.id] == dp) row.Add(nd);
float step = row.Count > 1 ? Mathf.Min(paper.width * 0.56f, paper.width * 0.88f / (row.Count - 1)) : 0f;
for (int i = 0; i < row.Count; i++)
pos[row[i].id] = new Vector2((i - (row.Count - 1) / 2f) * step, -rowH * (dp + 0.5f));
}
float big = Mathf.Min(132f, rowH * 0.80f), small = Mathf.Min(112f, rowH * 0.70f);
// 선 = 노드 가운데끼리(끝은 노드 원이 덮음 · 줄 사이에 글자가 없어 끊기지 않음 · QA r2 B2)
// 두 줄 이상 건너는 선 = 꺾은선(위 노드에서 곧장 아래 → 아래 노드 높이에서 옆으로 진입) — 사선이 가운데 줄 노드·알약을 스치지 않게(QA r3 F1)
foreach (var nd in nodes)
foreach (var r in nd.requires)
{
if (!pos.ContainsKey(r.node)) continue;
var a = pos[r.node];
var b = pos[nd.id];
var col = DevRules.Level(d, r.node) >= r.lv ? ink : new Color(inkSoft.r, inkSoft.g, inkSoft.b, 0.55f);
if (depth[nd.id] - depth[r.node] > 1 && Mathf.Abs(a.x - b.x) > 1f)
{
var corner = new Vector2(a.x, b.y);
Line(tree, a, corner + new Vector2(0f, -3f), col);
Line(tree, corner + new Vector2(a.x > b.x ? 3f : -3f, 0f), b, col);
}
else Line(tree, a, b, col);
}
NodeCount = 0;
IconNodeCount = 0;
foreach (var nd in nodes)
{
var node = nd;
int lv = DevRules.Level(d, nd.id);
bool open = DevRules.Unlocked(d, nd);
bool max = lv >= nd.lvMax;
float size = nd.Big ? big : small;
var face = max ? UiKit.Coin : open ? new Color(1f, 0.97f, 0.88f) : new Color(0.80f, 0.74f, 0.63f);
var c = UiKit.Btn("Node_" + nd.id, tree, "", 30, face, ink, new Vector2(0.5f, 1f), new Vector2(0.5f, 0.5f), pos[nd.id], new Vector2(size, size), () => NodePopup(node.id));
var img = c.GetComponent<Image>();
img.sprite = UiKit.Circle;
img.type = Image.Type.Simple;
UiKit.Border(img, nd.Big ? new Color(0.78f, 0.55f, 0.18f) : ink, nd.Big ? 6f : 4f);
var lab = UiKit.LabelOf(c);
// 노드 그림 아이콘(테마 dev_<node_id> · PD 2026-11-12 「노드마다 그림 아이콘」) · 잠김 = 흐리게 + 자물쇠 · 그림이 없으면 예전 첫 글자
var ic = UiKit.Icon(c.transform, "dev_" + nd.id, new Vector2(0.5f, 0.5f), new Vector2(0.5f, 0.5f), new Vector2(0f, 4f), size * 0.74f);
if (ic != null)
{
lab.text = "";
ic.raycastTarget = false;
if (!open)
{
ic.color = UiKit.Col("dom_node_locked", new Color(0.62f, 0.58f, 0.52f, 0.85f));
var lk = UiKit.Icon(c.transform, "lock_s", new Vector2(1f, 1f), new Vector2(0.5f, 0.5f), new Vector2(-size * 0.12f, -size * 0.12f), size * 0.34f);
if (lk != null) lk.raycastTarget = false;
}
IconNodeCount++;
}
else
{
lab.text = open ? nd.nameKo.Substring(0, 1) : "잠";
lab.fontSize = nd.Big ? 56 : 46;
lab.color = open ? ink : inkSoft;
}
var pill = UiKit.Box("Lv", c.transform, new Vector2(0.5f, 0f), new Vector2(0.5f, 0.5f), Vector2.zero, new Vector2(100f, 32f), max ? UiKit.Purple : UiKit.Dark);
Stroke(UiKit.FillLabel(pill.transform, lv + "/" + nd.lvMax, 22, UiKit.White));
NodeCount++;
}
// 개발 배치 영웅 = 아래 두루마리 파견 칸에만(왼쪽 아래 큰 상반신은 PD 2026-11-12 「거대한 유닛 얼굴은 제거」)
}
/// <summary>재화 받침 1개(테마 chip 스킨 · 색 dom_chip = 양피지보다 조금 짙은 황갈 · 아이콘 + 먹 글자 · 폭 = 내용에 맞춤).</summary>
static void CurrencyItem(RectTransform row, string icon, string mark, Color dot, string text, Color ink)
{
var pill = UiKit.Rect("Pill_" + icon, row, new Vector2(0.5f, 0.5f), new Vector2(0.5f, 0.5f), Vector2.zero, new Vector2(10f, 58f));
UiKit.Skin(pill, "soft", UiKit.Col("dom_chip", new Color(0.90f, 0.80f, 0.60f))); // 외곽선 없는 둥근 판(PD 2026-11-12 「백판 아웃라인 제거」 · 예전 chip = 테두리 있음)
var hl = pill.gameObject.AddComponent<HorizontalLayoutGroup>();
hl.childAlignment = TextAnchor.MiddleCenter;
hl.spacing = 10f;
hl.padding = new RectOffset(14, 26, 0, 0);
hl.childControlWidth = true;
hl.childControlHeight = false;
hl.childForceExpandWidth = hl.childForceExpandHeight = false;
var ic = UiKit.IconOrDot(pill, icon, mark, dot, new Vector2(0.5f, 0.5f), new Vector2(0.5f, 0.5f), Vector2.zero, 46f);
if (ic != null)
{
var le = ic.gameObject.AddComponent<LayoutElement>();
le.minWidth = le.preferredWidth = 46f;
}
var t = UiKit.Label(pill, text, 32, ink, TextAnchor.MiddleLeft, new Vector2(0.5f, 0.5f), new Vector2(0.5f, 0.5f), Vector2.zero, new Vector2(10f, 50f));
t.horizontalOverflow = HorizontalWrapMode.Overflow;
}
/// <summary>개발 파견 칸에 배치 영웅이 보이는지(테스트 · 왼쪽 아래 큰 상반신은 PD 2026-11-12 지시로 없앰).</summary>
public bool DevHeroShown { get; private set; }
static void ArrowBtn(RectTransform scroll, string name, string mark, float x, float y, System.Action onClick)
{
var b = UiKit.Rect(name, scroll, new Vector2(0.5f, 1f), new Vector2(0.5f, 0.5f), new Vector2(x, -y), new Vector2(84f, 84f));
var img = UiKit.Skin(b, "circle122", UiKit.Col("menu_btn", UiKit.Dark), true);
img.preserveAspect = true;
b.gameObject.AddComponent<Button>().onClick.AddListener(() => onClick());
Stroke(UiKit.FillLabel(b, mark, 36, UiKit.White, TextAnchor.MiddleCenter, true, 0f));
}
static void Line(RectTransform parent, Vector2 a, Vector2 b, Color c)
{
var mid = (a + b) / 2f;
var dir = b - a;
var rt = UiKit.Rect("Link", parent, new Vector2(0.5f, 1f), new Vector2(0.5f, 0.5f), mid, new Vector2(dir.magnitude, 6f));
rt.localRotation = Quaternion.Euler(0f, 0f, Mathf.Atan2(dir.y, dir.x) * Mathf.Rad2Deg);
UiKit.Paint(rt, c, false);
}
public void NodePopup(string id)
{
var db = S.Db;
var d = S.Save;
var n = db.Dev.Get(id);
if (n == null) return;
int lv = DevRules.Level(d, id);
var sb = new System.Text.StringBuilder();
sb.Append(BranchKo(n.branch)).Append(n.Big ? " · 큰 노드" : "").Append(" Lv ").Append(lv).Append(" / ").Append(n.lvMax).Append("\n\n");
foreach (var k in n.EffectKeys)
{
string what;
DevRules.Keys.TryGetValue(k, out what);
sb.Append(what).Append(" ").Append((n.pctPerLv * lv).ToString("0.##")).Append(" → ").Append((n.pctPerLv * Mathf.Min(n.lvMax, lv + 1)).ToString("0.##")).Append("\n");
}
if (n.requires.Count > 0)
{
sb.Append("\n선행: ");
for (int i = 0; i < n.requires.Count; i++)
{
var r = n.requires[i];
bool ok = DevRules.Level(d, r.node) >= r.lv;
sb.Append(i > 0 ? " · " : "").Append(ok ? "<color=#3a6a30>" : "<color=#a03a22>").Append(db.Dev.Get(r.node).nameKo).Append(" Lv ").Append(r.lv).Append("</color>");
}
sb.Append("\n");
}
if (lv < n.lvMax)
{
int gold, bp;
DevRules.NextCost(n, lv, out gold, out bp);
sb.Append("\n다음 비용 골드 ").Append(gold.ToString("N0")).Append(bp > 0 ? " · 도면 " + bp : "");
}
var why = DevRules.CanUpgrade(db, d, id);
Root.Modal(n.nameKo, sb.ToString(),
new OutGameRoot.ModalButton(why == null ? "개발 Lv+1" : why, why == null ? UiKit.Red : UiKit.Disabled, () =>
{
var w = S.Do(() => DevRules.Upgrade(S.Db, S.Save, id));
if (w == null) NodePopup(id);
else Root.Toast(w);
}, true),
new OutGameRoot.ModalButton("닫기", UiKit.Dark, null));
}
public static string BranchKo(string b)
{
switch (b)
{
case "commerce": return "상업";
case "agri": return "농업";
case "industry": return "공업";
}
return b;
}
static string BranchGoal(string b)
{
switch (b)
{
case "commerce": return "재화 획득 효율";
case "agri": return "자원 획득 효율";
case "industry": return "장비 획득";
}
return "";
}
}
}