유닛 목록 = PD 선별 그림 28종(보유 6 + 미보유 23) · 미보유 = 회색 딤드 + 자물쇠 + 「미보유」 · 「편성」 버튼(덱 칸 화살표 · 누르기/끌어 놓기로 교체) — PD 지시

- 목록 = battle.json roster(PD 가 X표로 뺀 52종 제외 · 307 포함 = 28종 · 이름 = 그림 모습 임시 이름 · 확정 전) · 보유 = 전투 유닛 6종 · 나머지 = 미보유 카드(누르면 안내)
- 검사 그림 402 → 102 · 마법사 417 → 314(뺀 그림이라 · unit_art.json · 확정 전) · UnitArt.ArtOf(유닛 → 그림)
- 목록 머리줄 오른쪽 끝 「편성」 ↔ 「완료」: 편성 중 유닛을 누르거나 끌면 덱 칸마다 화살표 → 덱 칸 누름 또는 그 칸에 놓기 = 교체 · 아래 안내 띠
- 헤드리스 168/168(B13 유닛 목록) · 렌더 ALL PASS(54장 · 미보유 · 편성 화살표 검사)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
깃 관리자 2026-10-30 00:31:24 +09:00
parent f031b0a5b1
commit b174de6fc9
8 changed files with 210 additions and 47 deletions

View File

@ -154,14 +154,26 @@ namespace VS.EditorTools
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.ScrollUnits(0.5f); Shot(app, "units_locked"); app.ScrollUnits(1f);
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;
// 편성 — 「편성」 → 유닛 누름 = 덱 칸마다 화살표 → 덱 칸 누름 = 그 칸으로 교체(프리셋 2 · 빈 칸 하나)
app.SelectPreset(1); g.DeckRemove(us[0].id);
app.ToggleUnitEdit(); EditCheck(app, "units_edit", false); Shot(app, "units_edit");
app.TapUnitCard(us[0].id); EditCheck(app, "units_edit_pick", true); Shot(app, "units_edit_pick");
app.PickDeckSlot(3); EditCheck(app, "units_edit_put", false);
if (g.ActiveDeck[3] != us[0].id || g.DeckSel != 1) { fails++; notes.Add("units_edit: 고른 칸에 안 들어감"); }
app.ToggleUnitEdit(); app.SelectPreset(0); g.S.battleSlots = slots0;
}
/// 편성 중 — 유닛을 고르면 덱 칸마다 화살표 · 넣고 나면 화살표 없음 · 편성 버튼 = 「완료」
static void EditCheck(GameApp app, string name, bool arrows)
{
var content = app.UnitRoot.Find("List/Viewport/Content"); int n = 0;
foreach (Transform ch in content.Find("DeckGrid")) if (ch.gameObject.activeSelf && ch.Find("Arrow") != null) n++;
var label = content.Find("CollHead/Edit/Label")?.GetComponent<Text>();
if (n != (arrows ? app.G.BC.slotMax : 0) || label == null || label.text != "완료") { fails++; notes.Add($"{name}: 편성 화면 이상 (화살표 {n} · 버튼 {label?.text})"); }
}
/// 아래 메뉴 = 연구 · 전투 · 유닛 · 유닛 화면 = 덱 칸 slotMax 개 · 목록 = 유닛 수 · 프리셋 버튼 = deckPresets 개
@ -173,8 +185,12 @@ namespace VS.EditorTools
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})"); }
// 미보유 = 목록 그림 중 보유 유닛이 쓰지 않는 것 · 「편성」 버튼 = 목록 머리줄 오른쪽
var ownedArts = new HashSet<int>(); foreach (var u in g.BC.units) { int a = UnitArt.ArtOf(u.id); if (a > 0) ownedArts.Add(a); }
int want = 0; foreach (var r in g.BC.roster) if (!ownedArts.Contains(r.art)) want++;
int locked = content.Find("LockGrid").childCount; bool edit = content.Find("CollHead/Edit") != null;
bool ok = root.gameObject.activeSelf && bar != null && bar.gameObject.activeSelf && tabs == "연구|전투|유닛|" && deck == g.BC.slotMax && coll == g.BC.units.Count && presets == g.DeckPresets && want > 0 && locked == want && edit;
if (!ok) { fails++; notes.Add($"{name}: 유닛 화면 이상 (메뉴 {tabs} · 덱 {deck} · 보유 {coll} · 미보유 {locked}/{want} · 프리셋 {presets} · 편성 {edit})"); }
}
static List<string> L(params string[] s) => new List<string>(s);

View File

@ -155,6 +155,12 @@ namespace VS.EditorTools
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");
// B13 유닛 목록 = PD 가 고른 유닛 그림(battle.json roster) · 번호 겹침 없음 · 이름 · 그림 파일 있음 · 보유 유닛 그림도 그 안(고른 그림만 씀)
var artFiles = System.IO.Directory.GetFiles("Assets/Resources/Art/Units", "px_*.png");
var rosterArts = new HashSet<int>(); bool rosterOk = c.roster != null && c.roster.Count > 0 && c.roster.Count <= artFiles.Length;
if (c.roster != null) foreach (var r in c.roster) rosterOk &= rosterArts.Add(r.art) && !string.IsNullOrEmpty(r.name) && System.IO.File.Exists($"Assets/Resources/Art/Units/px_{r.art}.png") && VS.View.UnitArt.Get(r.art) != null;
int ownedArts = 0; foreach (var u in us) { int a = VS.View.UnitArt.ArtOf(u.id); if (a > 0) { ownedArts++; rosterOk &= rosterArts.Contains(a); } }
Check("B13.roster", rosterOk && ownedArts > 0, $"files={artFiles.Length} roster={c.roster?.Count} owned art={ownedArts}");
MapTests(c);
CastleTests(c);

View File

@ -101,6 +101,15 @@
{ "id": 7, "name": "신화", "color": "rainbow" }
],
"deckPresets": 3,
"rosterSource": "유닛 목록 = 우리 게임 유닛 그림(Resources/Art/Units/px_<art>) 중 PD 가 고른 28종(2026-10-30 PD 「X표 제외 · 나머지만 사용」 + 「307 도 포함」 = 52종 제외 · 처음 「제작된 리소스 유닛을 모두 목록에 · 초기 보유 외 = 미보유(회색 딤드 + 자물쇠 + 미보유)」) · 초기 보유 = 전투 유닛 6종(units · 그림 = unit_art.json) · 나머지 = 미보유 · 이름 = 그림 모습으로 붙인 임시 이름(확정 전)",
"roster": [
{ "art": 101, "name": "금발 엘프" }, { "art": 102, "name": "은발 검객" }, { "art": 105, "name": "자이언트" }, { "art": 106, "name": "금발 소녀" }, { "art": 110, "name": "앨리스" },
{ "art": 112, "name": "그림자 도적" }, { "art": 116, "name": "숲 사냥꾼" }, { "art": 117, "name": "메이드" }, { "art": 120, "name": "비행사" }, { "art": 202, "name": "은발 수도사" },
{ "art": 206, "name": "노검사" }, { "art": 208, "name": "리본 소녀" }, { "art": 209, "name": "기계 기사" }, { "art": 212, "name": "사신" }, { "art": 218, "name": "흡혈귀" },
{ "art": 302, "name": "붉은 십자" }, { "art": 307, "name": "초록 마녀" }, { "art": 308, "name": "금발 전사" }, { "art": 314, "name": "백발 마도사" }, { "art": 319, "name": "사막 무희" }, { "art": 407, "name": "초록 요정" },
{ "art": 409, "name": "기사" }, { "art": 410, "name": "어둠 인형" }, { "art": 412, "name": "붉은 사제" }, { "art": 414, "name": "기계공" }, { "art": 416, "name": "궁수" },
{ "art": 418, "name": "금발 기사" }, { "art": 419, "name": "붉은 악마" }
],
"view": {
"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,

View File

@ -1,10 +1,10 @@
{
"source": "전투 유닛 → 도트 그림(Resources/Art/Units/px_<art>.png) · 그림 = PD 원화 시트 80종(101~120 · 201~220 · 301~320 · 401~420 = 시트·행·열) · 매핑 = PM 제안(2026-10-28 · PD 확정 전) · 드래곤(6)은 맞는 원화가 없어 코드 그림 유지",
"source": "전투 유닛 → 도트 그림(Resources/Art/Units/px_<art>.png) · 그림 = PD 원화 시트 80종(101~120 · 201~220 · 301~320 · 401~420 = 시트·행·열) · 매핑 = PM 제안(2026-10-28 · PD 확정 전) · 드래곤(6)은 맞는 원화가 없어 코드 그림 유지 · 2026-10-30 PD 선별(X표 52종 제외 · 28종만 사용)로 검사 402 → 102(은발 검객) · 마법사 417 → 314(백발 마도사)(확정 전)",
"units": [
{ "id": 1, "art": 402 },
{ "id": 1, "art": 102 },
{ "id": 2, "art": 416 },
{ "id": 3, "art": 409 },
{ "id": 4, "art": 417 },
{ "id": 4, "art": 314 },
{ "id": 5, "art": 105 }
]
}

View File

@ -13,6 +13,8 @@ namespace VS.Sim
[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; }
/// 유닛 목록 한 칸 = 유닛 그림(px_<art>) + 이름 — 전투 유닛(units)이 쓰는 그림 = 보유 · 나머지 = 미보유
[Serializable] public class BRosterDef { public int art; public string name; }
/// 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% · 사거리 = 성 가장자리부터
@ -45,7 +47,7 @@ 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 List<BGradeDef> grades; public int deckPresets; public List<BRosterDef> roster;
public BUnitDef UnitDef(int id) => units?.Find(u => u.id == id);
public BGradeDef Grade(int id) => grades?.Find(g => g.id == id);

View File

@ -75,7 +75,7 @@ namespace VS.View
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();
if (t == 2) OpenUnits();
UpdateTabBar();
CloseAllPopups();
}

View File

@ -2,24 +2,32 @@ using System.Collections.Generic;
using System.Text;
using UnityEngine;
using UnityEngine.Events;
using UnityEngine.EventSystems;
using UnityEngine.UI;
using VS.Sim;
// 유닛 메뉴(2026-10-29 PD) — 클래시 로얄 「전투 덱」처럼 위 = 덱(프리셋 1·2·3 · 6칸) · 아래 = 유닛 목록(등급 색 카드 · 참고 = 수집 카드 화면)
// 카드를 누르면 유닛 상세(등급 · 이름 · 그림 · 능력치 · 특징 · 덱에 넣기/빼기) · 덱에 빈 칸이 없으면 바꿀 칸을 고른다
// 유닛 메뉴(2026-10-29~30 PD) — 클래시 로얄 「전투 덱」처럼 위 = 덱(프리셋 1·2·3 · 6칸) · 아래 = 유닛 목록(등급 색 카드 · 참고 = 수집 카드 화면)
// 목록 = 유닛 그림 80종 전부 · 보유(전투 유닛) 먼저 · 나머지 = 미보유(회색 딤드 + 자물쇠 + 「미보유」)
// 목록 머리줄 오른쪽 끝 「편성」 = 편성 상태: 유닛을 누르거나 끌면 덱 칸에 화살표 → 덱 칸을 누르거나 그 칸에 놓으면 교체 · 편성 상태가 아니면 카드 = 유닛 상세
// 전투에는 열린 칸(전투 「강화」의 칸 열기)만 나감 · 등급 = battle.json grades(일반 → 고급 → 희귀 → 에픽 → 전설 → 불멸 → 신화 · 별 수 = 등급)
namespace VS.View
{
public partial class GameApp
{
public RectTransform UnitRoot;
RectTransform unitDeckGrid, unitCollGrid, unitDetail, unitSwapBar;
Text unitDeckInfo, unitCollTitle, unitSwapText;
RectTransform unitDeckGrid, unitCollGrid, unitLockGrid, unitDetail, unitSwapBar, unitDragGhost;
ScrollRect unitScroll;
Text unitDeckInfo, unitCollTitle, unitSwapText, unitEditLabel;
Button unitEditBtn;
readonly List<Button> presetTabs = new List<Button>();
int swapUnit; // 0 = 아님 · 아니면 덱에 넣을 유닛 id(바꿀 칸 고르는 중)
readonly Dictionary<int, Image> collFrames = new Dictionary<int, Image>(); // 보유 유닛 카드 테 — 고른 유닛 = 노랑
bool unitEdit, lockBuilt; // 편성 상태 · 미보유 카드는 한 번만 만든다(바뀌지 않음)
int lockedCount;
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>();
static readonly Color CardFrame = new Color32(28, 30, 52, 255);
const float CardW = 320f, CardH = 350f, CardGap = 24f;
void BuildUnitScreen()
@ -27,9 +35,9 @@ namespace VS.View
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();
// 목록(스크롤): 덱 머리줄 · 덱 6칸 · 평균 마나 · 목록 머리줄(+ 편성) · 보유 카드 · 미보유 카드
unitScroll = UI.Scroll(UnitRoot, "List", out var content, 14f);
((RectTransform)unitScroll.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++)
@ -43,15 +51,20 @@ namespace VS.View
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);
// 목록 머리줄 = 「유닛 목록 · 보유 N/M」 + 오른쪽 끝 「편성」(편성 상태 ↔ 완료)
var ch = UI.Rect("CollHead", content); UI.SetLayoutHeight(ch, 104);
unitCollTitle = UI.Txt(ch, "T", "", 46, Color.white, TextAnchor.MiddleLeft, true); unitCollTitle.supportRichText = true;
unitCollTitle.rectTransform.Fill(24, 0, 270, 0);
unitEditBtn = UI.TextBtn(ch, "Edit", "편성", Pal.Blue, 42, ToggleUnitEdit, out unitEditLabel);
unitEditBtn.GetComponent<RectTransform>().Place(new Vector2(1, 0.5f), new Vector2(1, 0.5f), new Vector2(-24, 0), new Vector2(220, 92), new Vector2(1, 0.5f));
unitCollGrid = Grid(content, "CollGrid");
// 바꿀 칸 고르기 띠(덱이 꽉 찼을 때 「덱에 넣기」)
unitLockGrid = Grid(content, "LockGrid");
// 편성 안내 띠(아래) — 고른 유닛 · 완료
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));
unitSwapText = UI.Txt(unitSwapBar, "T", "", 36, Pal.Yellow, TextAnchor.MiddleLeft, true); unitSwapText.rectTransform.Fill(30, 8, 260, 8);
var done = UI.TextBtn(unitSwapBar, "Done", "완료", Pal.Green, 40, ToggleUnitEdit, out _);
done.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);
}
@ -74,26 +87,47 @@ namespace VS.View
foreach (var o in old) { o.SetActive(false); Kill(o); }
}
/// 유닛 탭 열기 — 편성 상태 · 상세 닫고 처음부터
public void OpenUnits()
{
unitEdit = false; swapUnit = 0;
if (unitDetail != null) unitDetail.gameObject.SetActive(false);
ShowUnits();
}
/// 덱 + 유닛 목록 다시 그리기
public void ShowUnits()
public void ShowUnits() { RefreshColl(); RefreshDeck(); }
/// 덱 칸 · 평균 마나 · 편성 버튼·띠 · 고른 유닛 카드 테(목록 카드는 다시 만들지 않음 — 끄는 중인 카드가 사라지지 않게)
void RefreshDeck()
{
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);
unitEditLabel.text = unitEdit ? "완료" : "편성";
UI.SetBtnColor(unitEditBtn, unitEdit ? Pal.Green : Pal.Blue);
unitSwapBar.gameObject.SetActive(unitEdit);
if (unitEdit) unitSwapText.text = swapUnit != 0 ? $"「{c.UnitDef(swapUnit)?.name}」 → 바꿀 덱 칸을 누르세요" : "편성 중 — 유닛을 누르거나 덱 칸으로 끌어 놓으세요";
foreach (var kv in collFrames) if (kv.Value != null) kv.Value.color = kv.Key == swapUnit ? Pal.Yellow : CardFrame;
}
void RefreshColl()
{
var c = T.Battle;
ClearChildren(unitCollGrid); collFrames.Clear();
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}」 넣을 덱 칸을 누르세요";
if (!lockBuilt) BuildLockedCards();
unitCollTitle.text = $"유닛 목록 <size=34><color=#C8D2FF>보유 {c.units.Count}/{c.units.Count + lockedCount}</color></size>";
}
public void SelectPreset(int p) { G.SelectDeck(p); swapUnit = 0; ShowUnits(); }
/// 덱 칸 — 유닛 카드 / 빈 칸 · 잠긴 칸(강화 칸 열기 전) = 어둡게 + 자물쇠 · 바꿀 칸 고르는 중 = 노란 테
public void ToggleUnitEdit() { unitEdit = !unitEdit; swapUnit = 0; ShowUnits(); }
/// 덱 칸 — 유닛 카드 / 빈 칸 · 잠긴 칸(강화 칸 열기 전) = 어둡게 + 자물쇠 · 편성 중 유닛을 고르면 = 노란 테 + 위에 화살표
void DeckSlot(int i, int[] deck)
{
var u = T.Battle.UnitDef(deck[i]);
@ -102,7 +136,7 @@ namespace VS.View
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));
b = UI.Btn(unitDeckGrid, "Deck" + i, Skin.Round, CardFrame, () => 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();
}
@ -113,23 +147,32 @@ namespace VS.View
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;
if (swapUnit != 0)
{
b.GetComponent<Image>().color = Pal.Yellow;
var ar = UI.Icon(b.transform, "Arrow", Skin.S("Icon_Arrow_Down") ?? Skin.Icon("up"), Color.white);
ar.rectTransform.Place(new Vector2(0.5f, 1), new Vector2(0.5f, 1), new Vector2(0, -4), new Vector2(64, 56)); // 칸 위 가장자리 가운데(이웃 칸을 덜 가림)
ar.gameObject.AddComponent<Bob>();
}
}
/// 덱 칸 누름 — 바꿀 칸 고르는 중이면 그 칸에 넣기 · 유닛이면 상세 · 잠긴 빈 칸이면 강화 · 열린 빈 칸이면 안내
/// 덱 칸 누름 — 고른 유닛이 있으면 그 칸에 넣기(교체) · 편성 중이면 안내 · 아니면 유닛 = 상세 · 잠긴 빈 칸 = 강화 · 열린 빈 칸 = 안내
public void PickDeckSlot(int slot)
{
if (swapUnit != 0) { G.DeckPut(slot, swapUnit); swapUnit = 0; ShowUnits(); return; }
if (unitEdit) { Toast("아래 목록에서 넣을 유닛을 먼저 고르세요"); return; }
var u = T.Battle.UnitDef(G.ActiveDeck[slot]);
if (u != null) OpenUnitDetail(u.id);
else if (slot >= G.BattleSlots) OpenBattleUpgrade();
else Toast("아래 목록에서 유닛을 눌러 「덱에 넣기」");
else Toast("「편성」을 누르고 유닛을 골라 넣으세요");
}
void CollCard(BUnitDef u)
{
int id = u.id;
var b = UnitCard(unitCollGrid, "Unit" + id, u, () => { swapUnit = 0; OpenUnitDetail(id); });
var b = UnitCard(unitCollGrid, "Unit" + id, u, () => TapUnitCard(id));
collFrames[id] = b.GetComponent<Image>();
if (unitEdit) { var dg = b.gameObject.AddComponent<UnitDrag>(); dg.app = this; dg.unitId = 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);
@ -137,10 +180,69 @@ namespace VS.View
UI.Txt(tag.transform, "T", $"덱 {pos + 1}", 28, Color.white, TextAnchor.MiddleCenter, true).rectTransform.Fill(18, 2, 18, 2);
}
/// 보유 유닛 카드 누름 — 편성 중 = 고르기(덱 칸에 화살표 · 다시 누르면 취소) · 아니면 상세
public void TapUnitCard(int id)
{
if (!unitEdit) { swapUnit = 0; OpenUnitDetail(id); return; }
swapUnit = swapUnit == id ? 0 : id;
if (swapUnit != 0) unitScroll.verticalNormalizedPosition = 1f; // 덱이 보이게 맨 위로
RefreshDeck();
}
// ---------------- 끌어서 놓기(편성 중 보유 유닛 카드) ----------------
public void BeginUnitDrag(int id, PointerEventData e)
{
swapUnit = id;
var gh = UI.Icon(UnitRoot, "Ghost", UnitSprite(id), new Color(1f, 1f, 1f, 0.9f));
unitDragGhost = gh.rectTransform;
unitDragGhost.anchorMin = unitDragGhost.anchorMax = new Vector2(0.5f, 0.5f); unitDragGhost.sizeDelta = new Vector2(230, 230);
unitScroll.verticalNormalizedPosition = 1f;
RefreshDeck();
MoveUnitDrag(e);
}
public void MoveUnitDrag(PointerEventData e)
{
if (unitDragGhost != null && RectTransformUtility.ScreenPointToLocalPointInRectangle(UnitRoot, e.position, e.pressEventCamera, out var lp)) unitDragGhost.anchoredPosition = lp;
}
/// 놓은 곳이 덱 칸이면 그 칸으로 교체 · 아니면 고른 채로(화살표) — 칸을 누르면 교체
public void EndUnitDrag(PointerEventData e)
{
if (unitDragGhost != null) { Kill(unitDragGhost.gameObject); unitDragGhost = null; }
foreach (Transform ch in unitDeckGrid)
if (ch.gameObject.activeSelf && ch.name.StartsWith("Deck") && int.TryParse(ch.name.Substring(4), out var slot)
&& RectTransformUtility.RectangleContainsScreenPoint((RectTransform)ch, e.position, e.pressEventCamera)) { PickDeckSlot(slot); return; }
}
/// 미보유 = 그림 80종 중 보유 유닛이 쓰지 않는 것 — 회색 딤드 + 자물쇠 + 「미보유」 · 누르면 안내
void BuildLockedCards()
{
lockBuilt = true; lockedCount = 0;
var owned = new HashSet<int>();
foreach (var u in T.Battle.units) { int a = UnitArt.ArtOf(u.id); if (a > 0) owned.Add(a); }
foreach (var r in T.Battle.roster ?? new List<BRosterDef>())
{
if (owned.Contains(r.art)) continue;
lockedCount++;
var b = UI.Btn(unitLockGrid, "Lock" + r.art, Skin.Round, CardFrame, () => Toast("미보유 유닛 — 아직 편성할 수 없습니다"));
UI.Img(b.transform, "In", Skin.RoundSoft, new Color32(74, 76, 88, 255)).rectTransform.Fill(7, 7, 7, 7);
UI.Icon(b.transform, "Face", UnitArt.Get(r.art), new Color(0.58f, 0.58f, 0.62f)).rectTransform.Fill(36, 76, 36, 66);
UI.Img(b.transform, "Dim", Skin.RoundSoft, new Color(0.08f, 0.08f, 0.12f, 0.32f)).rectTransform.Fill(7, 7, 7, 7);
UI.Txt(b.transform, "Name", r.name, 34, new Color(0.82f, 0.82f, 0.86f), 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));
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, 26), new Vector2(84, 112));
UI.Txt(b.transform, "LT", "미보유", 36, Color.white, TextAnchor.MiddleCenter, true).rectTransform.Place(new Vector2(0, 0.5f), new Vector2(1, 0.5f), new Vector2(0, -62), new Vector2(0, 48));
}
UI.SetLayoutHeight(unitLockGrid, lockedCount > 0 ? GridH(lockedCount) : 0f);
}
/// 목록 스크롤 위치(1 = 맨 위 · 0 = 맨 아래)
public void ScrollUnits(float v) { Canvas.ForceUpdateCanvases(); unitScroll.verticalNormalizedPosition = v; Canvas.ForceUpdateCanvases(); }
/// 유닛 카드 — 짙은 테 · 안 = 등급 색(신화 = 무지개) · 왼쪽 위 이름 + 역할 · 가운데 그림 · 왼쪽 아래 마나 · 오른쪽 아래 별(= 등급)
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 b = UI.Btn(parent, name, Skin.Round, u.id == swapUnit ? Pal.Yellow : CardFrame, 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;
@ -309,7 +411,7 @@ namespace VS.View
udPrev.gameObject.SetActive(idx > 0); udNext.gameObject.SetActive(idx < c.units.Count - 1);
}
/// 덱에 넣기(빈 칸 → 바로 · 꽉 찼으면 목록으로 돌아가 바꿀 칸 고르기) / 덱에서 빼기
/// 덱에 넣기(빈 칸 → 바로 · 꽉 찼으면 편성 상태로 돌아가 바꿀 칸 고르기) / 덱에서 빼기
void OnUnitMain()
{
int id = detailUnit;
@ -320,9 +422,27 @@ namespace VS.View
public void BeginUnitSwap(int id)
{
swapUnit = id;
unitEdit = true; swapUnit = id;
unitDetail.gameObject.SetActive(false);
ShowUnits();
unitScroll.verticalNormalizedPosition = 1f;
}
}
/// 편성 상태의 보유 유닛 카드 — 끌어서 덱 칸에 놓기(놓은 칸으로 교체)
public class UnitDrag : MonoBehaviour, IBeginDragHandler, IDragHandler, IEndDragHandler
{
public GameApp app; public int unitId;
public void OnBeginDrag(PointerEventData e) => app.BeginUnitDrag(unitId, e);
public void OnDrag(PointerEventData e) => app.MoveUnitDrag(e);
public void OnEndDrag(PointerEventData e) => app.EndUnitDrag(e);
}
/// 위아래로 통통 — 편성 중 덱 칸 화살표
public class Bob : MonoBehaviour
{
RectTransform rt; Vector2 home;
void Start() { rt = (RectTransform)transform; home = rt.anchoredPosition; }
void Update() { rt.anchoredPosition = home + new Vector2(0f, Mathf.Abs(Mathf.Sin(Time.unscaledTime * 6f)) * 12f); }
}
}

View File

@ -14,17 +14,27 @@ namespace VS.View
static Dictionary<int, int> map;
static readonly Dictionary<int, Sprite> cache = new Dictionary<int, Sprite>();
static void Load()
{
if (map != null) return;
map = new Dictionary<int, int>();
var txt = GameApp.ReadData("unit_art");
if (txt != null) foreach (var r in JsonUtility.FromJson<Table>(txt).units) map[r.id] = r.art;
}
public static Sprite ForUnit(int unitId)
{
if (map == null)
{
map = new Dictionary<int, int>();
var txt = GameApp.ReadData("unit_art");
if (txt != null) foreach (var r in JsonUtility.FromJson<Table>(txt).units) map[r.id] = r.art;
}
Load();
return map.TryGetValue(unitId, out var art) ? Get(art) : null;
}
/// 유닛 → 그림 번호(없으면 0) — 유닛 목록에서 보유 유닛이 쓰는 그림을 미보유 칸에서 뺄 때
public static int ArtOf(int unitId)
{
Load();
return map.TryGetValue(unitId, out var art) ? art : 0;
}
public static Sprite Get(int art)
{
if (cache.TryGetValue(art, out var s)) return s;