아래 메뉴 연구|전투|유닛 · 유닛 메뉴(덱 6칸 + 프리셋 3 · 유닛 목록 · 유닛 상세) · 유닛 등급 7단계 — PD 지시

- 아래 메뉴 = 연구(= 방송 화면 · PD 선택) | 전투 | 유닛 · 전투 중에는 아래 메뉴 숨김(전투 = 화면 전체 · 칸 56×46 으로 다시 맞춤)
- 유닛 메뉴(View/GameApp.Units.cs): 클래시 로얄 전투 덱처럼 위 = 덱 6칸(프리셋 1·2·3 · 열린 칸만 전투 · 잠긴 칸 표시 · 평균 마나) · 아래 = 유닛 목록(등급 색 카드 · 별 = 등급 · 덱 N 표시)
- 유닛 상세: 등급 띠 · 이름 · 큰 그림 · 좌우 넘기기 · 역할·마나·비행 · 덱 상태 · 능력치 6칸 · 특징 · 덱에 넣기/빼기 · 덱이 꽉 차면 바꿀 칸 고르기
- 등급(battle.json grades) = 일반 회색 → 고급 녹색 → 희귀 파랑 → 에픽 보라 → 전설 주황 → 불멸 빨강 → 신화 무지개 · 유닛 6종 = 마나 순으로 1~6(확정 전)
- 덱 저장 = SaveData.decks · deckSel · 전투 카드 칸 = 고른 덱의 칸
- 헤드리스 167/167 · 렌더 ALL PASS(53장)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
깃 관리자 2026-10-29 23:42:47 +09:00
parent 848a5c53ef
commit f031b0a5b1
9 changed files with 498 additions and 31 deletions

View File

@ -31,6 +31,7 @@ namespace VS.EditorTools
app.OpenBuyNewHouse(); Shot(app, "pop_newhouse"); app.ClosePopup();
g.S.afkPending = "0";
BattleShots(app);
UnitShots(app);
app.SwitchTab(0);
}
@ -46,6 +47,7 @@ namespace VS.EditorTools
app.OpenStageInfo(); Shot(app, "stage_info"); app.ClosePopup();
app.StartStage(1, 5);
if (app.StageRoot.gameObject.activeSelf || !app.BattleRoot.gameObject.activeSelf || app.B.State != BattleState.Fighting || app.B.Stage != 1) { fails++; notes.Add("stage_start: 시작을 눌렀는데 전투 화면이 아님"); }
if (app.Root.Find("TabBar").gameObject.activeSelf) { fails++; notes.Add("stage_start: 전투 중에 아래 메뉴가 보임"); }
app.B.Mana = 10;
app.DeployCard(0); app.DebugTick(0.05f, 40); app.DeployCard(0); app.DebugTick(0.05f, 40); app.DeployCard(0);
g.S.battleSlots = 6; app.B.MaxMana = 15; app.B.Mana = 15;
@ -141,11 +143,40 @@ namespace VS.EditorTools
var root = app.StageRoot; int n = app.G.StageCount;
var title = root.Find("Title")?.GetComponent<Text>(); var start = root.Find("Start")?.GetComponent<Button>();
bool prev = root.Find("Prev").gameObject.activeSelf, next = root.Find("Next").gameObject.activeSelf, clear = root.Find("Clear").gameObject.activeSelf;
bool ok = root.gameObject.activeSelf && !app.BattleRoot.gameObject.activeSelf && title != null && title.text.StartsWith(stage + ". ") && start != null && start.interactable == open
bool ok = root.gameObject.activeSelf && !app.BattleRoot.gameObject.activeSelf && app.Root.Find("TabBar").gameObject.activeSelf && title != null && title.text.StartsWith(stage + ". ") && start != null && start.interactable == open
&& prev == (stage > 1) && next == (stage < n) && clear == app.G.StageCleared(stage);
if (!ok) { fails++; notes.Add($"{name}: 스테이지 선택 화면 이상 (제목 {title?.text} · 시작 {start?.interactable} · 화살표 {prev}/{next} · 클리어 {clear})"); }
}
// 유닛 메뉴 — 아래 메뉴 3개 · 덱(프리셋 · 6칸 · 잠긴 칸) · 유닛 목록 · 상세 · 프리셋 2 + 빈 칸 · 바꿀 칸 고르기
static void UnitShots(GameApp app)
{
var g = app.G; var us = g.BC.units; int slots0 = g.S.battleSlots;
g.S.battleSlots = 3;
app.SwitchTab(2); UnitCheck(app, "units_deck"); Shot(app, "units_deck");
app.OpenUnitDetail(us[0].id); Shot(app, "unit_detail");
app.OpenUnitDetail(us[us.Count - 1].id); Shot(app, "unit_detail_last");
app.CloseUnitDetail();
app.SelectPreset(1); g.DeckRemove(us[0].id); app.ShowUnits(); Shot(app, "units_preset2");
app.BeginUnitSwap(us[0].id); Shot(app, "units_swap");
app.PickDeckSlot(3);
if (g.ActiveDeck[3] != us[0].id || g.DeckSel != 1) { fails++; notes.Add("units_swap: 고른 칸에 안 들어감"); }
app.SelectPreset(0); g.S.battleSlots = slots0;
}
/// 아래 메뉴 = 연구 · 전투 · 유닛 · 유닛 화면 = 덱 칸 slotMax 개 · 목록 = 유닛 수 · 프리셋 버튼 = deckPresets 개
static void UnitCheck(GameApp app, string name)
{
var g = app.G; var root = app.UnitRoot; var bar = app.Root.Find("TabBar");
var content = root.Find("List/Viewport/Content");
int deck = content.Find("DeckGrid").childCount, coll = content.Find("CollGrid").childCount, presets = 0;
foreach (Transform ch in content.Find("DeckHead")) if (ch.name.StartsWith("P")) presets++;
string tabs = "";
if (bar != null) foreach (Transform ch in bar) { var t = ch.Find("Label")?.GetComponent<Text>(); if (t != null) tabs += t.text + "|"; }
bool ok = root.gameObject.activeSelf && bar != null && bar.gameObject.activeSelf && tabs == "연구|전투|유닛|" && deck == g.BC.slotMax && coll == g.BC.units.Count && presets == g.DeckPresets;
if (!ok) { fails++; notes.Add($"{name}: 유닛 화면 이상 (메뉴 {tabs} · 덱 {deck} · 목록 {coll} · 프리셋 {presets})"); }
}
static List<string> L(params string[] s) => new List<string>(s);
static int MaxMinions(Battle b) { int n = 0; foreach (var s in b.Summoners) n += s.def.max; return n; }

View File

@ -135,6 +135,26 @@ namespace VS.EditorTools
Check("B11.replay_test", stReplay.State == BattleState.Won && stReward == g11.BattleReward(1) && g11.BattleStage == 3 && g11.StageCleared(2) && g11.StageOpen(3) && !g11.StageOpen(4), $"stage={g11.BattleStage}");
g11.S.battleStage = stN; g11.WinBattle(WonAt(g11, stN));
Check("B11.all_clear", g11.BattleStage == stN + 1 && g11.StageCleared(stN) && g11.StageOpen(stN) && g11.StageFocus == stN);
// B12 덱 · 등급 — 처음 덱 = 유닛 순서 · 빼기 = 빈 칸 · 넣기 = 열린 빈 칸 먼저 · 놓기 = 맞바꿈 · 프리셋끼리 따로 · 저장 왕복 · 등급 7개 순서 · 유닛마다 등급
var g12 = NewGame(t, out _); var dk = g12.ActiveDeck; var us = c.units;
bool inOrder = dk.Length == c.slotMax;
for (int i = 0; i < dk.Length && i < us.Count; i++) inOrder &= dk[i] == us[i].id;
Check("B12.deck_default", inOrder && g12.BattleUnit(0) == us[0] && g12.DeckReady && g12.DeckPresets == c.deckPresets && c.deckPresets >= 2, $"presets={c.deckPresets}");
g12.DeckRemove(us[0].id); g12.DeckRemove(us[1].id);
bool emptied = g12.BattleUnit(0) == null && !g12.DeckReady && g12.DeckPos(us[0].id) < 0;
bool addOpen = g12.DeckAdd(us[1].id) && g12.DeckPos(us[1].id) == 0; // 열린 칸(0)이 먼저 — 잠긴 빈 칸(1)보다
g12.DeckPut(3, us[0].id); g12.DeckPut(0, us[4].id); // 3번 칸 덮어쓰기 · 덱 안 유닛끼리 맞바꿈
bool put = g12.ActiveDeck[3] == us[0].id && g12.DeckPos(us[3].id) < 0 && g12.ActiveDeck[0] == us[4].id && g12.ActiveDeck[4] == us[1].id && Mathf.Approximately(g12.DeckAvgCost, us[4].cost);
Check("B12.deck_edit", emptied && addOpen && put, string.Join(",", g12.ActiveDeck));
g12.SelectDeck(1); bool other = g12.ActiveDeck[0] == us[0].id && g12.DeckSel == 1;
g12.SelectDeck(0);
var sv = JsonUtility.FromJson<SaveData>(JsonUtility.ToJson(g12.S));
Check("B12.preset_save", other && g12.ActiveDeck[0] == us[4].id && sv.decks != null && sv.decks.Count == c.deckPresets && sv.decks[0].ids[0] == us[4].id && sv.decks[1].ids[0] == us[0].id);
string[] gradeNames = { "일반", "고급", "희귀", "에픽", "전설", "불멸", "신화" };
bool grades = c.grades != null && c.grades.Count == gradeNames.Length;
for (int i = 0; grades && i < gradeNames.Length; i++) grades &= c.grades[i].id == i + 1 && c.grades[i].name == gradeNames[i] && !string.IsNullOrEmpty(c.grades[i].color);
foreach (var u in us) grades &= c.Grade(u.grade) != null;
Check("B12.grades", grades && c.Grade(7).color == "rainbow");
MapTests(c);
CastleTests(c);

View File

@ -50,12 +50,12 @@
"rewardPassSeconds": 120,
"rewardPassSecondsPerStage": 60,
"units": [
{ "id": 1, "name": "검사", "cost": 1, "hp": 220, "damage": 60, "interval": 1.1, "range": 0.8, "speed": 1.2, "radius": 0.45, "flying": false, "projectile": false },
{ "id": 2, "name": "궁수", "cost": 2, "hp": 150, "damage": 45, "interval": 1.0, "range": 5.0, "speed": 1.2, "radius": 0.4, "flying": false, "projectile": true },
{ "id": 3, "name": "기사", "cost": 3, "hp": 900, "damage": 120, "interval": 1.2, "range": 0.8, "speed": 1.0, "radius": 0.55, "flying": false, "projectile": false },
{ "id": 4, "name": "마법사", "cost": 4, "hp": 360, "damage": 170, "interval": 1.4, "range": 5.5, "speed": 1.0, "radius": 0.45, "flying": false, "projectile": true },
{ "id": 5, "name": "자이언트", "cost": 5, "hp": 2600, "damage": 190, "interval": 1.5, "range": 0.8, "speed": 0.75, "radius": 0.8, "flying": false, "projectile": false },
{ "id": 6, "name": "드래곤", "cost": 6, "hp": 1000, "damage": 150, "interval": 1.6, "range": 3.5, "speed": 1.5, "radius": 0.7, "flying": true, "projectile": true }
{ "id": 1, "name": "검사", "cost": 1, "grade": 1, "hp": 220, "damage": 60, "interval": 1.1, "range": 0.8, "speed": 1.2, "radius": 0.45, "flying": false, "projectile": false },
{ "id": 2, "name": "궁수", "cost": 2, "grade": 2, "hp": 150, "damage": 45, "interval": 1.0, "range": 5.0, "speed": 1.2, "radius": 0.4, "flying": false, "projectile": true },
{ "id": 3, "name": "기사", "cost": 3, "grade": 3, "hp": 900, "damage": 120, "interval": 1.2, "range": 0.8, "speed": 1.0, "radius": 0.55, "flying": false, "projectile": false },
{ "id": 4, "name": "마법사", "cost": 4, "grade": 4, "hp": 360, "damage": 170, "interval": 1.4, "range": 5.5, "speed": 1.0, "radius": 0.45, "flying": false, "projectile": true },
{ "id": 5, "name": "자이언트", "cost": 5, "grade": 5, "hp": 2600, "damage": 190, "interval": 1.5, "range": 0.8, "speed": 0.75, "radius": 0.8, "flying": false, "projectile": false },
{ "id": 6, "name": "드래곤", "cost": 6, "grade": 6, "hp": 1000, "damage": 150, "interval": 1.6, "range": 3.5, "speed": 1.5, "radius": 0.7, "flying": true, "projectile": true }
],
"castle": {
"y": 28.5, "xMin": 6.5, "xMax": 11.5, "radius": 2.0,
@ -90,10 +90,21 @@
{ "stage": 7, "name": "최후의 성", "shots": ["normal", "laser"], "gimmicks": ["fireball", "lightning", "summonMelee", "summonRanged"] }
],
"testRangeMuls": [1, 2, 4],
"gradeSource": "유닛 등급(2026-10-29 PD) = 일반(회색) → 고급(녹색) → 희귀(파랑) → 에픽(보라) → 전설(주황) → 불멸(빨강) → 신화(무지개) · 등급 수 = 카드의 별 수 · 지금 유닛 6종의 등급 = 마나 비용 순으로 1~6(확정 전)",
"grades": [
{ "id": 1, "name": "일반", "color": "#A2A8B6" },
{ "id": 2, "name": "고급", "color": "#4CC85A" },
{ "id": 3, "name": "희귀", "color": "#3C8CFF" },
{ "id": 4, "name": "에픽", "color": "#A95AF0" },
{ "id": 5, "name": "전설", "color": "#FF9628" },
{ "id": 6, "name": "불멸", "color": "#F04646" },
{ "id": 7, "name": "신화", "color": "rainbow" }
],
"deckPresets": 3,
"view": {
"source": "전투 화면 비율 — 클래시 로얄 실제 전투 화면을 재서 맞춤(2026-10-29 PD 「유닛 크기를 클래시 로얄과 비슷하게 · 배경·전체 비율도 클래시 로얄 화면 비율에 맞게」). 잰 값: 맵 가로 18칸 = 화면 폭의 약 90~96% · 칸 세로 길이 ≈ 가로 × 0.81(비스듬히 내려다보는 시점) · 병사 키 ≈ 1.5칸 · 아래 카드 칸 높이 ≈ 화면 폭 × 0.34 → 1080 폭에서 칸 가로 54 · 세로 44 px(맵 = 화면 폭의 90% · 좌우 1칸 = 맵 밖 장식) · 카드 칸 360 px · 유닛 그림 한 변 = 반지름 × 4칸(검사 97 px · 몸 키 약 1.6칸 · 자이언트 173 px) · 나는 유닛 = 0.8칸 위 (확정 전)",
"tileX": 54, "tileY": 44,
"sideTiles": 1,
"source": "전투 화면 비율 — 클래시 로얄 실제 전투 화면을 재서 맞춤(2026-10-29 PD 「유닛 크기를 클래시 로얄과 비슷하게 · 배경·전체 비율도 클래시 로얄 화면 비율에 맞게」). 잰 값: 맵 가로 18칸 = 화면 폭의 약 90~96% · 칸 세로 길이 ≈ 가로 × 0.81(비스듬히 내려다보는 시점) · 병사 키 ≈ 1.5칸 · 아래 카드 칸 높이 ≈ 화면 폭 × 0.34 → 전투 중에는 아래 메뉴가 없어(2026-10-29 PD) 화면 전체 1080×1920 기준 칸 가로 56 · 세로 46 px(맵 = 화면 폭의 93% · 좌우 0.65칸 = 맵 밖 장식) · 카드 칸 360 px · 유닛 그림 한 변 = 반지름 × 4칸(검사 101 px · 몸 키 약 1.6칸 · 자이언트 179 px) · 나는 유닛 = 0.8칸 위 (확정 전)",
"tileX": 56, "tileY": 46,
"sideTiles": 0.65,
"trayH": 360,
"unitArt": 4,
"flyLift": 0.8

View File

@ -10,7 +10,9 @@ using Vector2 = UnityEngine.Vector2;
// 값 = Resources/Data/battle.json (설계값 · PD 확정 전)
namespace VS.Sim
{
[Serializable] public class BUnitDef { public int id; public string name; public int cost; public float hp, damage, interval, range, speed, radius; public bool flying, projectile; }
[Serializable] public class BUnitDef { public int id; public string name; public int cost, grade; public float hp, damage, interval, range, speed, radius; public bool flying, projectile; }
/// 유닛 등급 — 일반 → 고급 → 희귀 → 에픽 → 전설 → 불멸 → 신화(색 = "#RRGGBB" · 신화 = "rainbow")
[Serializable] public class BGradeDef { public int id; public string name, color; }
/// regen = 마지막으로 맞은 뒤 regenDelay 초가 지나면 regenInterval 초마다 최대 체력의 regenPct %
[Serializable] public class BCastleDef { public float y, xMin, xMax, radius, hp, hpGrowth, atk, atkGrowth, summonGrowth, enrageHpRatio, enrageIntervalMul, regenDelay, regenPct, regenInterval; }
/// 성 투사체(normal·splash·multi·slow·laser) · 성 스킬(fireball·lightning) — 피해 = 성 공격력 × dmgPct% · 사거리 = 성 가장자리부터
@ -43,6 +45,10 @@ namespace VS.Sim
public List<BAttackDef> shots, skills; public List<BSummonDef> summons; public List<BLoadout> stageLoadouts;
public float[] testRangeMuls;
public BViewDef view;
public List<BGradeDef> grades; public int deckPresets;
public BUnitDef UnitDef(int id) => units?.Find(u => u.id == id);
public BGradeDef Grade(int id) => grades?.Find(g => g.id == id);
public BAttackDef Shot(string id) => shots.Find(s => s.id == id);
public BAttackDef Skill(string id) => skills.Find(s => s.id == id);
@ -742,7 +748,61 @@ namespace VS.Sim
public int BattleSlots => Math.Max(1, Math.Min(BC.slotMax, S.battleSlots <= 0 ? BC.slotStart : S.battleSlots));
public int BattleMaxMana => Math.Min(BC.manaMaxCap, BC.manaMaxBase + S.battleManaLevel);
public int BattleStage => Math.Max(1, S.battleStage);
public BUnitDef BattleUnit(int slot) => slot >= 0 && slot < BC.units.Count ? BC.units[slot] : null;
/// 전투 카드 칸 slot 의 유닛 = 고른 덱 프리셋의 그 칸(0 = 빈 칸 → null) · 전투에는 열린 칸(BattleSlots)만 나감
public BUnitDef BattleUnit(int slot) { var d = ActiveDeck; return slot >= 0 && slot < d.Length ? BC.UnitDef(d[slot]) : null; }
// ---------------- 덱(유닛 메뉴) — 프리셋 deckPresets 개 · 한 덱 = slotMax 칸 · 처음 = 유닛 순서대로 ----------------
public int DeckPresets => Math.Max(1, BC.deckPresets);
public int DeckSel => Math.Max(0, Math.Min(DeckPresets - 1, S.deckSel));
public void SelectDeck(int p) => S.deckSel = Math.Max(0, Math.Min(DeckPresets - 1, p));
public int[] ActiveDeck => Deck(DeckSel);
public int[] Deck(int p)
{
if (S.decks == null) S.decks = new List<SaveDeck>();
while (S.decks.Count < DeckPresets) S.decks.Add(new SaveDeck());
var d = S.decks[Math.Max(0, Math.Min(DeckPresets - 1, p))];
if (d.ids == null || d.ids.Length != BC.slotMax)
{
var ids = new int[BC.slotMax];
for (int i = 0; i < ids.Length; i++) ids[i] = d.ids != null ? (i < d.ids.Length ? d.ids[i] : 0) : (i < BC.units.Count ? BC.units[i].id : 0);
d.ids = ids;
}
return d.ids;
}
public int DeckPos(int unitId) => Array.IndexOf(ActiveDeck, unitId);
/// 빈 칸에 넣기(열린 칸 먼저) — 빈 칸이 없으면 false(→ 바꿀 칸 고르기)
public bool DeckAdd(int unitId)
{
var d = ActiveDeck;
if (Array.IndexOf(d, unitId) >= 0) return true;
int at = -1;
for (int i = 0; i < d.Length && at < 0; i++) if (d[i] == 0 && i < BattleSlots) at = i;
for (int i = 0; i < d.Length && at < 0; i++) if (d[i] == 0) at = i;
if (at < 0) return false;
d[at] = unitId;
return true;
}
/// slot 칸에 놓기 — 이미 덱의 다른 칸에 있으면 두 칸을 맞바꿈
public void DeckPut(int slot, int unitId)
{
var d = ActiveDeck;
if (slot < 0 || slot >= d.Length) return;
int from = Array.IndexOf(d, unitId);
if (from >= 0) d[from] = d[slot];
d[slot] = unitId;
}
public void DeckRemove(int unitId) { var d = ActiveDeck; int i = Array.IndexOf(d, unitId); if (i >= 0) d[i] = 0; }
/// 열린 칸에 유닛이 하나라도 있어야 전투 시작
public bool DeckReady { get { var d = ActiveDeck; for (int i = 0; i < Math.Min(BattleSlots, d.Length); i++) if (BC.UnitDef(d[i]) != null) return true; return false; } }
/// 평균 마나 = 열린 칸 유닛 마나 평균
public float DeckAvgCost { get { var d = ActiveDeck; int n = 0, sum = 0; for (int i = 0; i < Math.Min(BattleSlots, d.Length); i++) { var u = BC.UnitDef(d[i]); if (u != null) { n++; sum += u.cost; } } return n > 0 ? (float)sum / n : 0f; } }
public BigInteger SlotUnlockPrice() => BattleSlots < BC.slotMax && BattleSlots - 1 < BC.slotUnlockCost.Length ? Big.P(BC.slotUnlockCost[BattleSlots - 1]) : BigInteger.Zero;
public BigInteger ManaUpgradePrice() => BattleMaxMana < BC.manaMaxCap && S.battleManaLevel < BC.manaUpgradeCost.Length ? Big.P(BC.manaUpgradeCost[S.battleManaLevel]) : BigInteger.Zero;

View File

@ -5,6 +5,7 @@ using System.Collections.Generic;
namespace VS.Sim
{
[Serializable] public class SaveItem { public int Type; public int Level; public long UpgradeEnd; public string Pending = "0"; }
[Serializable] public class SaveDeck { public int[] ids; }
[Serializable] public class SaveGift { public int Type; public long EffectEnd; public long CooldownEnd; public int RvProgress; public float PausedRemain; }
[Serializable] public class SaveQuest { public int id; public int state; public string progress = "0"; public string target = "0"; public string gps = "0"; }
@ -46,5 +47,7 @@ namespace VS.Sim
public string afkPending = "0"; // 아직 안 받은 방치 보상
public float playSeconds; // 누적 플레이 시간
public int battleStage = 1, battleSlots, battleManaLevel; // 전투 모드 (단계 · 소환 칸 · 최대 마나 강화 횟수)
public List<SaveDeck> decks = new List<SaveDeck>(); // 덱 프리셋(유닛 메뉴) — 칸마다 유닛 id · 0 = 빈 칸
public int deckSel; // 고른 프리셋
}
}

View File

@ -40,22 +40,25 @@ namespace VS.View
readonly List<(Transform t, float key)> depth = new List<(Transform, float)>();
bool rewardGiven;
// ---------------- 탭 ----------------
// ---------------- 탭(로비 아래 메뉴) = 연구 | 전투 | 유닛 (2026-10-29 PD) · 연구 = 방송 화면 · 전투 중에는 숨김 ----------------
RectTransform tabBar;
void BuildTabs()
{
var bar = UI.Img(Root, "TabBar", Skin.TabBar, Skin.On ? Pal.Navy : (Color)new Color32(36, 26, 52, 255), true);
bar.rectTransform.Place(new Vector2(0, 0), new Vector2(1, 0), Vector2.zero, new Vector2(0, TabH), new Vector2(0.5f, 0));
tabBar = bar.rectTransform;
tabBar.Place(new Vector2(0, 0), new Vector2(1, 0), Vector2.zero, new Vector2(0, TabH), new Vector2(0.5f, 0));
var hl = bar.gameObject.AddComponent<HorizontalLayoutGroup>();
hl.childControlWidth = true; hl.childControlHeight = true; hl.childForceExpandWidth = true; hl.padding = new RectOffset(20, 20, Skin.On ? 0 : 12, Skin.On ? 6 : 12); hl.spacing = 16;
string[] names = { L("UI.Tab.Stream", "방송"), L("UI.Tab.Battle", "전투") };
string[] icons = { "chat", "pk" };
string[] names = { L("UI.Tab.Research", "연구"), L("UI.Tab.Battle", "전투"), L("UI.Tab.Units", "유닛") };
string[] icons = { "skill", "pk", "follower" };
for (int i = 0; i < names.Length; i++)
{
int idx = i;
var b = UI.Btn(bar.transform, "Tab" + i, Skin.On ? Skin.TabFocus : Art.Round, Pal.Purple, () => SwitchTab(idx));
UI.Icon(b.transform, "Icon", Skin.Icon(icons[i]), Color.white).rectTransform.Place(new Vector2(0.5f, 0.5f), new Vector2(0.5f, 0.5f), new Vector2(-90, 0), new Vector2(84, 84));
var t = UI.Txt(b.transform, "Label", names[i], 44, Color.white, TextAnchor.MiddleCenter, true);
t.rectTransform.Place(new Vector2(0.5f, 0.5f), new Vector2(0.5f, 0.5f), new Vector2(40, 0), new Vector2(240, 80));
UI.Icon(b.transform, "Icon", Skin.Icon(icons[i]), Color.white).rectTransform.Place(new Vector2(0.5f, 0.5f), new Vector2(0.5f, 0.5f), new Vector2(-68, 0), new Vector2(80, 80));
var t = UI.Txt(b.transform, "Label", names[i], 42, Color.white, TextAnchor.MiddleCenter, true);
t.rectTransform.Place(new Vector2(0.5f, 0.5f), new Vector2(0.5f, 0.5f), new Vector2(42, 0), new Vector2(150, 80));
tabs.Add((b.GetComponent<Image>(), t));
}
SwitchTab(0);
@ -64,16 +67,22 @@ namespace VS.View
public void SwitchTab(int t)
{
Tab = t;
// 전투 탭 = 싸우는 중이면 전투 화면 · 아니면 스테이지 선택(다음에 깰 스테이지부터)
// 전투 탭 = 싸우는 중이면 전투 화면 · 아니면 스테이지 선택(다음에 깰 스테이지부터) · 유닛 탭 = 덱 + 유닛 목록
bool fighting = B != null && B.State == BattleState.Fighting;
if (StreamRoot != null) StreamRoot.gameObject.SetActive(t == 0);
if (BattleRoot != null) BattleRoot.gameObject.SetActive(t == 1 && fighting);
if (StageRoot != null) StageRoot.gameObject.SetActive(t == 1 && !fighting);
if (UnitRoot != null) UnitRoot.gameObject.SetActive(t == 2);
for (int i = 0; i < tabs.Count; i++) tabs[i].bg.color = Skin.On ? (i == t ? Pal.NavyLight : new Color(0, 0, 0, 0)) : (i == t ? Pal.Pink : (Color)new Color32(84, 64, 120, 255));
if (t == 1 && !fighting) ShowStage(G.StageFocus);
if (t == 2) ShowUnits();
UpdateTabBar();
CloseAllPopups();
}
/// 전투 화면이 떠 있으면 아래 메뉴를 숨긴다(클래시 로얄처럼 전투는 화면 전체)
void UpdateTabBar() { if (tabBar != null) tabBar.gameObject.SetActive(BattleRoot == null || !BattleRoot.gameObject.activeSelf); }
// ---------------- 아레나 그림 (전투마다 맵에 맞춰 새로) ----------------
// 칸 → 화면 px: 가로 tileX · 세로 tileY (클래시 로얄처럼 비스듬히 내려다봐 땅의 세로가 짧다 · 땅 위 원 = 타원)
Vector2 TileToLocal(Vector2 tp) => new Vector2((tp.x - B.C.arenaW * 0.5f) * V.tileX, (tp.y - B.C.arenaH * 0.5f) * V.tileY);
@ -314,7 +323,7 @@ namespace VS.View
// 클래시 로얄 비율 — 맵 = 화면 폭(좌우 장식 포함) · 아래 카드 칸(view.trayH) 바로 위 · 단계·시험(왼쪽 위)·상대방(오른쪽 위)은 맵 위에 겹쳐 놓는다
void BuildBattleScreen()
{
BattleRoot = UI.Rect("Battle", Root).Fill(0, TabH, 0, 0);
BattleRoot = UI.Rect("Battle", Root).Fill(); // 전투 = 화면 전체(아래 메뉴 숨김)
UI.Img(BattleRoot, "Bg", Art.Square, Skin.On ? Pal.Navy : (Color)new Color32(70, 66, 88, 255)).rectTransform.Fill();
if (Skin.Pattern != null) UI.Img(BattleRoot, "Pattern", Skin.Pattern, new Color(1f, 1f, 1f, 0.05f)).rectTransform.Fill();
var c = T.Battle; var v = c.view;
@ -411,6 +420,7 @@ namespace VS.View
tb.GetComponent<RectTransform>().Place(new Vector2(0, 1), new Vector2(0, 1), new Vector2(14, -94), new Vector2(130, 72), new Vector2(0, 1));
BattleRoot.gameObject.SetActive(false);
BuildStageScreen();
BuildUnitScreen();
}
/// 고른 스테이지(stageSel)로 새 전투 준비 + 전투 화면 띄움 — 시작(B.Start)은 부르는 쪽
@ -421,7 +431,7 @@ namespace VS.View
foreach (var v in minionViews.Values) Kill(v.rt.gameObject);
minionViews.Clear();
B = G.NewBattle(stageSel, seed, BattleTest);
if (Tab == 1) { BattleRoot.gameObject.SetActive(true); StageRoot.gameObject.SetActive(false); }
if (Tab == 1) { BattleRoot.gameObject.SetActive(true); StageRoot.gameObject.SetActive(false); UpdateTabBar(); }
rewardGiven = false; lastBattleReward = BigInteger.Zero;
var old = arenaImg.sprite;
arenaImg.sprite = ArenaSprite(B);
@ -553,7 +563,7 @@ namespace VS.View
p.refresh = () =>
{
var nu = G.BattleUnit(G.BattleSlots);
t1.text = $"소환 칸 {G.BattleSlots} / {T.Battle.slotMax}" + (nu != null ? $"\n다음: {nu.name} ({nu.cost} 마나)" : "");
t1.text = $"덱 칸 {G.BattleSlots} / {T.Battle.slotMax}" + (G.BattleSlots < T.Battle.slotMax ? $"\n다음 칸: {(nu != null ? $"{nu.name} ({nu.cost} 마나)" : "빈 칸")}" : "");
bool mx1 = G.BattleSlots >= T.Battle.slotMax; l1.text = mx1 ? "MAX" : "칸 열기\n" + Fmt.Num(G.SlotUnlockPrice()); b1.interactable = !mx1 && G.SlotUnlockPrice() <= G.Coin;
t2.text = $"최대 마나 {G.BattleMaxMana} / {T.Battle.manaMaxCap}";
bool mx2 = G.BattleMaxMana >= T.Battle.manaMaxCap; l2.text = mx2 ? "MAX" : "최대 +1\n" + Fmt.Num(G.ManaUpgradePrice()); b2.interactable = !mx2 && G.ManaUpgradePrice() <= G.Coin;
@ -656,9 +666,9 @@ namespace VS.View
var (b, face, cost, lockImg, lockText) = cards[i];
bool open = i < G.BattleSlots; var d = G.BattleUnit(i);
face.gameObject.SetActive(open && d != null); cost.transform.parent.gameObject.SetActive(open && d != null);
lockImg.gameObject.SetActive(!open); lockText.gameObject.SetActive(!open);
lockImg.gameObject.SetActive(!open); lockText.gameObject.SetActive(!open || d == null);
if (open && d != null) { face.sprite = UnitSprite(d.id); cost.text = d.cost.ToString(); }
lockText.text = i == G.BattleSlots ? "강화" : "";
lockText.text = !open ? (i == G.BattleSlots ? "강화" : "") : d == null ? "빈 칸" : "";
bool afford = open && d != null && B.Mana >= d.cost;
var ci = b.GetComponent<Image>();
if (Skin.On) { ci.sprite = open ? Skin.CardOn : Skin.CardOff; ci.color = !open ? new Color(0.8f, 0.8f, 0.86f) : (afford ? Color.white : new Color(0.55f, 0.6f, 0.72f)); }

View File

@ -53,9 +53,9 @@ namespace VS.View
stageClear.rectTransform.Place(new Vector2(0.5f, 1), new Vector2(0.5f, 1), new Vector2(-240, -610), new Vector2(240, 80));
UI.Txt(stageClear.transform, "T", "클리어", 40, Color.white, TextAnchor.MiddleCenter, true).rectTransform.Fill(28, 4, 28, 4);
// 좌우 화살표 = 스테이지 넘기기(잠긴 스테이지도 넘겨 볼 수 있음)
stagePrev = StageArrow("Prev", "Icon_Arrow_Prev1", "<", () => ShowStage(stageSel - 1));
stagePrev = ArrowBtn(StageRoot, "Prev", "Icon_Arrow_Prev1", "<", () => ShowStage(stageSel - 1));
stagePrev.GetComponent<RectTransform>().Place(new Vector2(0, 1), new Vector2(0, 1), new Vector2(84, -800), new Vector2(84, 164));
stageNext = StageArrow("Next", "Icon_Arrow_Next1", ">", () => ShowStage(stageSel + 1));
stageNext = ArrowBtn(StageRoot, "Next", "Icon_Arrow_Next1", ">", () => ShowStage(stageSel + 1));
stageNext.GetComponent<RectTransform>().Place(new Vector2(1, 1), new Vector2(1, 1), new Vector2(-84, -800), new Vector2(84, 164));
// 시작 = 바로 전투 · 아래 안내 한 줄
stageStart = UI.TextBtn(StageRoot, "Start", "시작", Pal.Yellow, 66, () => StartStage(stageSel), out stageStartLabel);
@ -73,12 +73,12 @@ namespace VS.View
StageRoot.gameObject.SetActive(false);
}
/// 팩 화살표 그림 — 없으면 글자 버튼
Button StageArrow(string name, string sprite, string fallback, UnityAction click)
/// 팩 화살표 그림 — 없으면 글자 버튼 (스테이지 선택 · 유닛 상세)
Button ArrowBtn(Transform parent, string name, string sprite, string fallback, UnityAction click)
{
var sp = Skin.S(sprite);
if (sp == null) return UI.TextBtn(StageRoot, name, fallback, Pal.Purple, 60, click, out _);
var b = UI.Btn(StageRoot, name, sp, Color.white, click);
if (sp == null) return UI.TextBtn(parent, name, fallback, Pal.Purple, 60, click, out _);
var b = UI.Btn(parent, name, sp, Color.white, click);
b.GetComponent<Image>().preserveAspect = true;
return b;
}
@ -125,6 +125,7 @@ namespace VS.View
{
BattleRoot.gameObject.SetActive(false);
StageRoot.gameObject.SetActive(Tab == 1);
UpdateTabBar();
ShowStage(s);
}
@ -134,6 +135,7 @@ namespace VS.View
/// 고른 스테이지로 새 전투를 만들고 바로 시작(시험 설정이 있으면 그것 · 시험은 잠긴 스테이지도 가능)
void BeginBattle(int s, int seed = -1)
{
if (!G.DeckReady) { Toast("덱의 열린 칸에 유닛이 없습니다 — 유닛 메뉴에서 넣어 주세요"); return; }
stageSel = Mathf.Clamp(s, 1, Mathf.Max(1, G.StageCount));
NewBattleRound(seed < 0 ? Random.Range(1, 99999) : seed);
B.Start();

View File

@ -0,0 +1,328 @@
using System.Collections.Generic;
using System.Text;
using UnityEngine;
using UnityEngine.Events;
using UnityEngine.UI;
using VS.Sim;
// 유닛 메뉴(2026-10-29 PD) — 클래시 로얄 「전투 덱」처럼 위 = 덱(프리셋 1·2·3 · 6칸) · 아래 = 유닛 목록(등급 색 카드 · 참고 = 수집 카드 화면)
// 카드를 누르면 유닛 상세(등급 · 이름 · 그림 · 능력치 · 특징 · 덱에 넣기/빼기) · 덱에 빈 칸이 없으면 바꿀 칸을 고른다
// 전투에는 열린 칸(전투 「강화」의 칸 열기)만 나감 · 등급 = battle.json grades(일반 → 고급 → 희귀 → 에픽 → 전설 → 불멸 → 신화 · 별 수 = 등급)
namespace VS.View
{
public partial class GameApp
{
public RectTransform UnitRoot;
RectTransform unitDeckGrid, unitCollGrid, unitDetail, unitSwapBar;
Text unitDeckInfo, unitCollTitle, unitSwapText;
readonly List<Button> presetTabs = new List<Button>();
int swapUnit; // 0 = 아님 · 아니면 덱에 넣을 유닛 id(바꿀 칸 고르는 중)
int detailUnit; // 상세에 띄운 유닛 id
Image udTag, udPortrait, udRole, udTraitIcon; Text udTagText, udName, udCost, udStatus, udTraits, udMainLabel; Button udMain, udPrev, udNext; GameObject udFly;
readonly List<Text> udStatValues = new List<Text>();
const float CardW = 320f, CardH = 350f, CardGap = 24f;
void BuildUnitScreen()
{
UnitRoot = UI.Rect("Units", Root).Fill(0, TabH, 0, 0);
UI.Img(UnitRoot, "Bg", Art.Square, Skin.On ? Pal.Navy : (Color)new Color32(46, 40, 70, 255)).rectTransform.Fill();
if (Skin.Pattern != null) UI.Img(UnitRoot, "Pattern", Skin.Pattern, new Color(1f, 1f, 1f, 0.05f)).rectTransform.Fill();
// 목록(스크롤): 덱 머리줄 · 덱 6칸 · 평균 마나 · 목록 머리줄 · 유닛 카드
var sr = UI.Scroll(UnitRoot, "List", out var content, 14f);
((RectTransform)sr.transform).Fill();
var head = UI.Rect("DeckHead", content); UI.SetLayoutHeight(head, 110);
UI.Txt(head, "T", "전투 덱", 54, Color.white, TextAnchor.MiddleLeft, true).rectTransform.Place(new Vector2(0, 0.5f), new Vector2(0, 0.5f), new Vector2(24, 0), new Vector2(420, 90), new Vector2(0, 0.5f));
for (int i = 0; i < G.DeckPresets; i++)
{
int p = i;
var b = UI.TextBtn(head, "P" + i, (i + 1).ToString(), Pal.Gray, 44, () => SelectPreset(p), out _);
b.GetComponent<RectTransform>().Place(new Vector2(1, 0.5f), new Vector2(1, 0.5f), new Vector2(-24 - (G.DeckPresets - 1 - i) * 126, 0), new Vector2(110, 96), new Vector2(1, 0.5f));
presetTabs.Add(b);
}
unitDeckGrid = Grid(content, "DeckGrid");
UI.SetLayoutHeight(unitDeckGrid, GridH(T.Battle.slotMax));
unitDeckInfo = UI.Txt(content, "DeckInfo", "", 32, new Color32(214, 222, 255, 255), TextAnchor.MiddleCenter, true);
UI.SetLayoutHeight(unitDeckInfo, 64);
unitCollTitle = UI.Txt(content, "CollHead", "", 46, Color.white, TextAnchor.MiddleLeft, true);
UI.SetLayoutHeight(unitCollTitle, 96);
unitCollGrid = Grid(content, "CollGrid");
// 바꿀 칸 고르기 띠(덱이 꽉 찼을 때 「덱에 넣기」)
var bar = UI.Img(UnitRoot, "SwapBar", Skin.Round, new Color(0.12f, 0.1f, 0.24f, 0.96f));
unitSwapBar = bar.rectTransform; unitSwapBar.Place(new Vector2(0, 0), new Vector2(1, 0), new Vector2(0, 16), new Vector2(-32, 130), new Vector2(0.5f, 0));
unitSwapText = UI.Txt(unitSwapBar, "T", "", 38, Pal.Yellow, TextAnchor.MiddleLeft, true); unitSwapText.rectTransform.Fill(30, 8, 260, 8);
var cancel = UI.TextBtn(unitSwapBar, "Cancel", "취소", Pal.Gray, 40, () => { swapUnit = 0; ShowUnits(); }, out _);
cancel.GetComponent<RectTransform>().Place(new Vector2(1, 0.5f), new Vector2(1, 0.5f), new Vector2(-24, 0), new Vector2(210, 100), new Vector2(1, 0.5f));
BuildUnitDetail();
UnitRoot.gameObject.SetActive(false);
}
RectTransform Grid(Transform parent, string name)
{
var g = UI.Rect(name, parent);
var gl = g.gameObject.AddComponent<GridLayoutGroup>();
gl.cellSize = new Vector2(CardW, CardH); gl.spacing = new Vector2(CardGap, CardGap); gl.childAlignment = TextAnchor.UpperCenter;
gl.constraint = GridLayoutGroup.Constraint.FixedColumnCount; gl.constraintCount = 3;
return g;
}
static float GridH(int n) { int rows = Mathf.Max(1, (n + 2) / 3); return rows * CardH + (rows - 1) * CardGap; }
static void ClearChildren(Transform t)
{
var old = new List<GameObject>();
foreach (Transform ch in t) old.Add(ch.gameObject);
foreach (var o in old) { o.SetActive(false); Kill(o); }
}
/// 덱 + 유닛 목록 다시 그리기
public void ShowUnits()
{
var c = T.Battle; var deck = G.ActiveDeck;
for (int i = 0; i < presetTabs.Count; i++) UI.SetBtnColor(presetTabs[i], i == G.DeckSel ? Pal.Yellow : Pal.Gray);
ClearChildren(unitDeckGrid);
for (int i = 0; i < deck.Length; i++) DeckSlot(i, deck);
unitDeckInfo.text = $"평균 마나 {G.DeckAvgCost:0.0} · 열린 칸 {G.BattleSlots}/{c.slotMax} — 열린 칸 유닛만 전투에 나갑니다";
ClearChildren(unitCollGrid);
foreach (var u in c.units) CollCard(u);
UI.SetLayoutHeight(unitCollGrid, GridH(c.units.Count));
unitCollTitle.text = $" 유닛 목록 <size=34><color=#C8D2FF>보유 {c.units.Count}/{c.units.Count}</color></size>";
unitCollTitle.supportRichText = true;
unitSwapBar.gameObject.SetActive(swapUnit != 0);
if (swapUnit != 0) unitSwapText.text = $"「{c.UnitDef(swapUnit)?.name}」 넣을 덱 칸을 누르세요";
}
public void SelectPreset(int p) { G.SelectDeck(p); swapUnit = 0; ShowUnits(); }
/// 덱 칸 — 유닛 카드 / 빈 칸 · 잠긴 칸(강화 칸 열기 전) = 어둡게 + 자물쇠 · 바꿀 칸 고르는 중 = 노란 테
void DeckSlot(int i, int[] deck)
{
var u = T.Battle.UnitDef(deck[i]);
int slot = i;
Button b;
if (u != null) b = UnitCard(unitDeckGrid, "Deck" + i, u, () => PickDeckSlot(slot));
else
{
b = UI.Btn(unitDeckGrid, "Deck" + i, Skin.Round, new Color32(28, 30, 52, 255), () => PickDeckSlot(slot));
UI.Img(b.transform, "In", Skin.RoundSoft, new Color(1f, 1f, 1f, 0.08f)).rectTransform.Fill(7, 7, 7, 7);
UI.Txt(b.transform, "T", "빈 칸", 42, new Color(1f, 1f, 1f, 0.6f), TextAnchor.MiddleCenter, true).rectTransform.Fill();
}
UI.Txt(b.transform, "No", (i + 1).ToString(), 30, new Color(1f, 1f, 1f, 0.85f), TextAnchor.MiddleCenter, true).rectTransform.Place(new Vector2(1, 1), new Vector2(1, 1), new Vector2(-26, -30), new Vector2(40, 40));
if (i >= G.BattleSlots)
{
UI.Img(b.transform, "Dim", Skin.RoundSoft, new Color(0.05f, 0.05f, 0.1f, 0.62f)).rectTransform.Fill(7, 7, 7, 7);
UI.Icon(b.transform, "Lock", Skin.Icon("lock"), Color.white).rectTransform.Place(new Vector2(0.5f, 0.5f), new Vector2(0.5f, 0.5f), new Vector2(0, 24), new Vector2(84, 112));
UI.Txt(b.transform, "LT", "잠긴 칸", 30, Color.white, TextAnchor.MiddleCenter, true).rectTransform.Place(new Vector2(0, 0.5f), new Vector2(1, 0.5f), new Vector2(0, -62), new Vector2(0, 44));
}
if (swapUnit != 0) b.GetComponent<Image>().color = Pal.Yellow;
}
/// 덱 칸 누름 — 바꿀 칸 고르는 중이면 그 칸에 넣기 · 유닛이면 상세 · 잠긴 빈 칸이면 강화 · 열린 빈 칸이면 안내
public void PickDeckSlot(int slot)
{
if (swapUnit != 0) { G.DeckPut(slot, swapUnit); swapUnit = 0; ShowUnits(); return; }
var u = T.Battle.UnitDef(G.ActiveDeck[slot]);
if (u != null) OpenUnitDetail(u.id);
else if (slot >= G.BattleSlots) OpenBattleUpgrade();
else Toast("아래 목록에서 유닛을 눌러 「덱에 넣기」");
}
void CollCard(BUnitDef u)
{
int id = u.id;
var b = UnitCard(unitCollGrid, "Unit" + id, u, () => { swapUnit = 0; OpenUnitDetail(id); });
int pos = G.DeckPos(id);
if (pos < 0) return;
var tag = UI.Img(b.transform, "InDeck", Skin.S("Label_Parallelogram_Green") ?? Skin.Pill, Color.white);
tag.rectTransform.Place(new Vector2(1, 1), new Vector2(1, 1), new Vector2(-8, -10), new Vector2(126, 50), new Vector2(1, 1));
UI.Txt(tag.transform, "T", $"덱 {pos + 1}", 28, Color.white, TextAnchor.MiddleCenter, true).rectTransform.Fill(18, 2, 18, 2);
}
/// 유닛 카드 — 짙은 테 · 안 = 등급 색(신화 = 무지개) · 왼쪽 위 이름 + 역할 · 가운데 그림 · 왼쪽 아래 마나 · 오른쪽 아래 별(= 등급)
Button UnitCard(Transform parent, string name, BUnitDef u, UnityAction click)
{
var b = UI.Btn(parent, name, Skin.Round, new Color32(28, 30, 52, 255), click);
var rt = (RectTransform)b.transform;
var mask = UI.Img(rt, "Mask", Skin.RoundSoft, Color.white); mask.rectTransform.Fill(7, 7, 7, 7);
mask.gameObject.AddComponent<Mask>().showMaskGraphic = false;
UI.Img(mask.transform, "Grade", GradeBg(u.grade), Color.white).rectTransform.Fill();
UI.Img(mask.transform, "Shade", Art.Square, new Color(0f, 0f, 0f, 0.26f)).rectTransform.Place(new Vector2(0, 0), new Vector2(1, 0), Vector2.zero, new Vector2(0, 78), new Vector2(0.5f, 0));
UI.Icon(rt, "Face", UnitSprite(u.id), Color.white).rectTransform.Fill(36, 76, 36, 66);
UI.Txt(rt, "Name", u.name, 36, Color.white, TextAnchor.MiddleLeft, true).rectTransform.Place(new Vector2(0, 1), new Vector2(1, 1), new Vector2(4, -12), new Vector2(-40, 52), new Vector2(0.5f, 1));
var role = UI.Img(rt, "Role", Skin.CirclePlain, new Color(0.1f, 0.1f, 0.18f, 0.7f));
role.rectTransform.Place(new Vector2(0, 1), new Vector2(0, 1), new Vector2(16, -66), new Vector2(54, 54), new Vector2(0, 1));
UI.Icon(role.transform, "I", RoleIcon(u), Color.white).rectTransform.Fill(9, 9, 9, 9);
var gem = UI.Icon(rt, "Gem", Skin.Gem ?? ManaDrop(), Color.white); gem.rectTransform.Place(new Vector2(0, 0), new Vector2(0, 0), new Vector2(48, 40), new Vector2(72, 66));
UI.Txt(gem.transform, "C", u.cost.ToString(), 36, Color.white, TextAnchor.MiddleCenter, true).rectTransform.Fill(0, 4, 0, 0);
var stars = UI.Rect("Stars", rt).Place(new Vector2(1, 0), new Vector2(1, 0), new Vector2(-14, 40), new Vector2(210, 34), new Vector2(1, 0.5f));
var hl = stars.gameObject.AddComponent<HorizontalLayoutGroup>();
hl.childAlignment = TextAnchor.MiddleRight; hl.spacing = -3; hl.childControlWidth = false; hl.childControlHeight = false; hl.childForceExpandWidth = false; hl.childForceExpandHeight = false;
for (int i = 0; i < Mathf.Max(1, u.grade); i++) UI.Icon(stars, "S" + i, Skin.Icon("star"), IsRainbow(u.grade) ? Color.HSVToRGB(i / 7f, 0.5f, 1f) : Color.white).rectTransform.sizeDelta = new Vector2(32, 32);
return b;
}
Sprite RoleIcon(BUnitDef u) => Skin.S(u.projectile ? "RoleIcon_Bow" : "RoleIcon_Sword") ?? Skin.Icon("pk");
bool IsRainbow(int grade) { var gd = T.Battle.Grade(grade); return gd != null && gd.color == "rainbow"; }
/// 등급 대표 색(battle.json) — 신화(무지개) = 분홍빛 보라
Color GradeColor(int grade)
{
var gd = T.Battle.Grade(grade);
if (gd == null) return Color.gray;
if (gd.color == "rainbow") return new Color32(224, 112, 255, 255);
return ColorUtility.TryParseHtmlString(gd.color, out var col) ? col : Color.gray;
}
/// 등급 바탕 — 위 밝게 → 아래 짙게 · 신화 = 대각선 무지개
Sprite GradeBg(int grade)
{
string key = "grade" + grade;
if (bcache.TryGetValue(key, out var s)) return s;
bool rainbow = IsRainbow(grade); Color baseC = GradeColor(grade);
const int W = 80, H = 100;
var r = new Raster(W, H);
for (int y = 0; y < H; y++)
for (int x = 0; x < W; x++)
{
float t = y / (float)(H - 1);
Color c = rainbow ? Color.HSVToRGB(Mathf.Repeat((x + (H - y)) / (float)(W + H), 1f), 0.42f, 1f) : Color.Lerp(baseC * 0.74f, Color.Lerp(baseC, Color.white, 0.22f), t);
c.a = 1f; r.Px[y * W + x] = c;
}
s = r.Sprite(); bcache[key] = s; return s;
}
static string RainbowText(string s)
{
var sb = new StringBuilder();
for (int i = 0; i < s.Length; i++) sb.Append($"<color=#{ColorUtility.ToHtmlStringRGB(Color.HSVToRGB(i / (float)Mathf.Max(1, s.Length) * 0.8f, 0.5f, 1f))}>{s[i]}</color>");
return sb.ToString();
}
// ---------------- 유닛 상세(참고 = 수집 카드 상세 화면) ----------------
void BuildUnitDetail()
{
unitDetail = UI.Rect("Detail", UnitRoot).Fill();
UI.Img(unitDetail, "Bg", Art.Square, new Color32(98, 110, 156, 255), true).rectTransform.Fill(); // 뒤 목록 누름 막기
if (Skin.Pattern != null) UI.Img(unitDetail, "Pattern", Skin.Pattern, new Color(1f, 1f, 1f, 0.07f)).rectTransform.Fill();
// 등급 띠 · 이름
udTag = UI.Img(unitDetail, "Grade", Skin.S("Label_Parallelogram_White_Bg") ?? Skin.Pill, Color.white);
udTag.rectTransform.Place(new Vector2(0.5f, 1), new Vector2(0.5f, 1), new Vector2(0, -64), new Vector2(230, 64));
udTagText = UI.Txt(udTag.transform, "T", "", 34, Color.white, TextAnchor.MiddleCenter, true); udTagText.supportRichText = true; udTagText.rectTransform.Fill(26, 4, 26, 4);
udName = UI.Txt(unitDetail, "Name", "", 84, Color.white, TextAnchor.MiddleCenter, true);
udName.rectTransform.Place(new Vector2(0.5f, 1), new Vector2(0.5f, 1), new Vector2(0, -152), new Vector2(900, 110));
// 그림 + 그림자 · 좌우 화살표 = 앞뒤 유닛
UI.Img(unitDetail, "Shadow", Art.SoftCircle, new Color(0f, 0f, 0f, 0.28f)).rectTransform.Place(new Vector2(0.5f, 1), new Vector2(0.5f, 1), new Vector2(0, -652), new Vector2(480, 110));
udPortrait = UI.Icon(unitDetail, "Portrait", null, Color.white);
udPortrait.rectTransform.Place(new Vector2(0.5f, 1), new Vector2(0.5f, 1), new Vector2(0, -440), new Vector2(460, 460));
udPrev = ArrowBtn(unitDetail, "Prev", "Icon_Arrow_Prev1", "<", () => StepUnit(-1));
udPrev.GetComponent<RectTransform>().Place(new Vector2(0, 1), new Vector2(0, 1), new Vector2(84, -440), new Vector2(84, 164));
udNext = ArrowBtn(unitDetail, "Next", "Icon_Arrow_Next1", ">", () => StepUnit(1));
udNext.GetComponent<RectTransform>().Place(new Vector2(1, 1), new Vector2(1, 1), new Vector2(-84, -440), new Vector2(84, 164));
// 역할 · 마나 · 비행
var role = UI.Img(unitDetail, "Role", Skin.CirclePlain, new Color(0.1f, 0.1f, 0.18f, 0.75f));
role.rectTransform.Place(new Vector2(0.5f, 1), new Vector2(0.5f, 1), new Vector2(-70, -724), new Vector2(72, 72));
udRole = UI.Icon(role.transform, "I", null, Color.white); udRole.rectTransform.Fill(12, 12, 12, 12);
var gem = UI.Icon(unitDetail, "Gem", Skin.Gem ?? ManaDrop(), Color.white);
gem.rectTransform.Place(new Vector2(0.5f, 1), new Vector2(0.5f, 1), new Vector2(16, -724), new Vector2(80, 74));
udCost = UI.Txt(gem.transform, "C", "", 40, Color.white, TextAnchor.MiddleCenter, true); udCost.rectTransform.Fill(0, 4, 0, 0);
var fly = UI.Img(unitDetail, "Fly", Skin.S("Label_Parallelogram_Blue") ?? Skin.Pill, Color.white);
fly.rectTransform.Place(new Vector2(0.5f, 1), new Vector2(0.5f, 1), new Vector2(140, -724), new Vector2(150, 60));
UI.Txt(fly.transform, "T", "비행", 32, Color.white, TextAnchor.MiddleCenter, true).rectTransform.Fill(22, 2, 22, 2);
udFly = fly.gameObject;
// 덱 상태(참고 화면의 레벨 막대 자리)
var st = UI.Img(unitDetail, "Status", Skin.Pill, new Color32(40, 44, 70, 235));
st.rectTransform.Place(new Vector2(0.5f, 1), new Vector2(0.5f, 1), new Vector2(0, -806), new Vector2(620, 64));
udStatus = UI.Txt(st.transform, "T", "", 34, Color.white, TextAnchor.MiddleCenter, true); udStatus.rectTransform.Fill(24, 4, 24, 4);
// 능력치 6칸(3줄 × 2)
string[] labels = { "체력", "공격력", "초당 피해", "공격 간격", "사거리", "이동 속도" };
Sprite[] icons = { Skin.Icon("heart"), Skin.Icon("pk"), Skin.Icon("fire"), Skin.Icon("afk"), Skin.S("RoleIcon_Bow") ?? Skin.Icon("pk"), Skin.S("RoleIcon_Boots") ?? Skin.Icon("auto") };
for (int i = 0; i < labels.Length; i++)
{
var cell = UI.Img(unitDetail, "Stat" + i, Skin.RoundSoft, new Color(1f, 1f, 1f, 0.16f));
cell.rectTransform.Place(new Vector2(0.5f, 1), new Vector2(0.5f, 1), new Vector2(i % 2 == 0 ? -232 : 232, -906 - i / 2 * 128), new Vector2(440, 112));
UI.Icon(cell.transform, "I", icons[i], Color.white).rectTransform.Place(new Vector2(0, 0.5f), new Vector2(0, 0.5f), new Vector2(56, 0), new Vector2(72, 72));
UI.Txt(cell.transform, "L", labels[i], 28, new Color32(225, 232, 255, 255), TextAnchor.MiddleLeft, true).rectTransform.Place(new Vector2(0, 1), new Vector2(1, 1), new Vector2(60, -6), new Vector2(-130, 36), new Vector2(0.5f, 1));
var v = UI.Txt(cell.transform, "V", "", 46, Color.white, TextAnchor.MiddleLeft, true);
v.rectTransform.Place(new Vector2(0, 0), new Vector2(1, 0), new Vector2(60, 8), new Vector2(-130, 60), new Vector2(0.5f, 0));
udStatValues.Add(v);
}
// 특징(참고 화면의 Ability 자리) — battle.json 값에서 만든 설명
var ab = UI.Img(unitDetail, "Traits", Skin.RoundSoft, new Color(1f, 1f, 1f, 0.16f));
ab.rectTransform.Place(new Vector2(0.5f, 1), new Vector2(0.5f, 1), new Vector2(0, -1352), new Vector2(904, 214));
UI.Txt(ab.transform, "H", "특징", 36, Color.white, TextAnchor.MiddleCenter, true).rectTransform.Place(new Vector2(0, 1), new Vector2(1, 1), new Vector2(0, -6), new Vector2(0, 46), new Vector2(0.5f, 1));
var tf = UI.Img(ab.transform, "IconBg", Skin.Round, new Color32(80, 190, 90, 255));
tf.rectTransform.Place(new Vector2(0, 0.5f), new Vector2(0, 0.5f), new Vector2(30, -18), new Vector2(124, 124), new Vector2(0, 0.5f));
udTraitIcon = UI.Icon(tf.transform, "I", null, Color.white); udTraitIcon.rectTransform.Fill(20, 20, 20, 20);
udTraits = UI.Txt(ab.transform, "T", "", 30, Color.white, TextAnchor.UpperLeft, true);
udTraits.rectTransform.Place(new Vector2(0, 0), new Vector2(1, 1), new Vector2(86, -26), new Vector2(-220, -70), new Vector2(0.5f, 0.5f));
// 덱에 넣기 / 덱에서 빼기 · 뒤로
udMain = UI.TextBtn(unitDetail, "Main", "", Pal.Green, 50, OnUnitMain, out udMainLabel);
udMain.GetComponent<RectTransform>().Place(new Vector2(0.5f, 1), new Vector2(0.5f, 1), new Vector2(0, -1550), new Vector2(480, 150));
var back = UI.Btn(unitDetail, "Back", Skin.Dark, Color.white, CloseUnitDetail);
back.GetComponent<RectTransform>().Place(new Vector2(0, 0), new Vector2(0, 0), new Vector2(-20, 24), new Vector2(210, 128), new Vector2(0, 0));
UI.Icon(back.transform, "I", Skin.S("Icon_Back"), Color.white).rectTransform.Fill(56, 22, 44, 22);
unitDetail.gameObject.SetActive(false);
}
public void OpenUnitDetail(int id)
{
unitDetail.gameObject.SetActive(true);
ShowUnitDetail(id);
}
public void CloseUnitDetail()
{
unitDetail.gameObject.SetActive(false);
ShowUnits();
}
void StepUnit(int d)
{
var units = T.Battle.units; int i = units.FindIndex(u => u.id == detailUnit) + d;
if (i >= 0 && i < units.Count) ShowUnitDetail(units[i].id);
}
void ShowUnitDetail(int id)
{
var c = T.Battle; var u = c.UnitDef(id);
if (u == null) return;
detailUnit = id;
var gd = c.Grade(u.grade); bool rainbow = IsRainbow(u.grade);
udTag.color = rainbow ? (Color)new Color32(44, 40, 70, 255) : GradeColor(u.grade);
udTagText.text = gd == null ? "" : rainbow ? RainbowText(gd.name) : gd.name;
udName.text = u.name;
udPortrait.sprite = UnitSprite(u.id);
udRole.sprite = RoleIcon(u); udTraitIcon.sprite = RoleIcon(u);
udCost.text = u.cost.ToString();
udFly.SetActive(u.flying);
int pos = G.DeckPos(id);
udStatus.text = pos < 0 ? "덱에 없음" : $"덱 {G.DeckSel + 1} · {pos + 1}번 칸" + (pos >= G.BattleSlots ? " (잠긴 칸)" : "");
string[] vals = { $"{u.hp:0}", $"{u.damage:0}", $"{u.damage / Mathf.Max(0.01f, u.interval):0.#}", $"{u.interval:0.#}초", $"{u.range:0.#}칸", $"{u.speed:0.##}칸/초" };
for (int i = 0; i < vals.Length && i < udStatValues.Count; i++) udStatValues[i].text = vals[i];
var tr = new List<string> { u.projectile ? $"원거리 — {u.range:0.#}칸 밖에서 투사체를 쏩니다" : $"근접 — 붙어서 공격합니다(사거리 {u.range:0.#}칸)" };
if (u.flying) tr.Add("비행 — 강과 바위·언덕 위로 곧장 날아갑니다");
tr.Add($"마나 {u.cost} · 크기 {u.radius * 2f:0.#}칸");
udTraits.text = string.Join("\n", tr);
udMainLabel.text = pos >= 0 ? "덱에서 빼기" : "덱에 넣기";
UI.SetBtnColor(udMain, pos >= 0 ? Pal.Red : Pal.Green);
int idx = c.units.IndexOf(u);
udPrev.gameObject.SetActive(idx > 0); udNext.gameObject.SetActive(idx < c.units.Count - 1);
}
/// 덱에 넣기(빈 칸 → 바로 · 꽉 찼으면 목록으로 돌아가 바꿀 칸 고르기) / 덱에서 빼기
void OnUnitMain()
{
int id = detailUnit;
if (G.DeckPos(id) >= 0) { G.DeckRemove(id); ShowUnitDetail(id); return; }
if (G.DeckAdd(id)) { ShowUnitDetail(id); return; }
BeginUnitSwap(id);
}
public void BeginUnitSwap(int id)
{
swapUnit = id;
unitDetail.gameObject.SetActive(false);
ShowUnits();
}
}
}

View File

@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 4465c6fac891f6e4688b90281d71e9a8