using System.Collections.Generic; using System.Numerics; using UnityEngine; using UnityEngine.EventSystems; using UnityEngine.UI; using VS.Sim; using Vector2 = UnityEngine.Vector2; using Vector3 = UnityEngine.Vector3; // 전투 탭 — 클래시 로얄 화면 구성(위 적 성 · 무작위 맵 · 아래 카드 칸 + 마나 막대 · 오른쪽 위 = 상대방 얼굴 + 스킬 정보) // 화면 비율 = 클래시 로얄(맵이 화면 폭을 채움 · 비스듬히 내려다봐 칸 세로가 짧음 · 유닛 키 약 1.5칸) — 수치 = battle.json view // 카드를 누르면 아래 가운데에서 바로 소환 · 맵을 누르면 깃발 → 내 유닛이 깃발을 거쳐 성으로 · 왼쪽 위 「시험」 = 맵·성 투사체·기믹을 골라 다음 전투에 적용 namespace VS.View { public partial class GameApp { public const float TabH = 150f; public RectTransform StreamRoot, BattleRoot; public int Tab; // 0 방송 · 1 전투 readonly List<(Image bg, Text label)> tabs = new List<(Image, Text)>(); public Battle B; public BattleSetup BattleTest; // 시험 설정 (null = 단계 기본 · 무작위 맵) RectTransform arena, unitLayer, fxLayerB, castleHpBar; Image arenaImg; BViewDef V => T.Battle.view; Image castleImg, castleHpFill, manaFill, flagImg; Text castleHpText, stageText, manaText, manaMaxText, infoText; float flagPop; // 상대방(여성 캐릭터) 얼굴 + 스킬 정보 — 전투마다 성 구성에 맞춰 줄을 새로 만든다 RectTransform rivalBox; Image rivalFrame, rivalFace; class RivalRow { public RectTransform rt; public Image bg, fill; public Text info; public BWeapon w; public BSummoner s; public float lastCd, flash; } readonly List rivalRows = new List(); const float RivalW = 240f, RivalTop = 116f, RivalRowH = 88f; // 클래시 로얄 남은 시간 칸 자리 — 맵을 덜 가리게 작게 RectTransform overlay; Image overlayRibbon; Text overlayTitle, overlayInfo, overlayBtnLabel; readonly List<(Button b, Image face, Text cost, Image lockImg, Text lockText)> cards = new List<(Button, Image, Text, Image, Text)>(); readonly Dictionary unitViews = new Dictionary(); readonly Dictionary minionViews = new Dictionary(); readonly List shotViews = new List(), fxViews = new List(); 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); 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(); 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.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(-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(), t)); } SwitchTab(0); } 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) OpenUnits(); 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); Vector2 TileVec(Vector2 d) => new Vector2(d.x * V.tileX, d.y * V.tileY); /// 맵 그림 = 좌우 sideTiles 칸 맵 밖 장식(짙은 풀 + 돌 턱 · 강은 끝까지) + 가운데 맵 · 칸당 텍스처 S px → 화면에서 칸 가로·세로 비율대로 늘어남 Sprite ArenaSprite(Battle b) { var c = b.C; var m = b.Map; const float S = 30f; float ox = c.view.sideTiles * S; int W = Mathf.RoundToInt((c.arenaW + 2f * c.view.sideTiles) * S), H = Mathf.RoundToInt(c.arenaH * S); var r = new Raster(W, H); Color32 g0 = new Color32(96, 180, 110, 255), g1 = new Color32(88, 170, 102, 255), e0 = new Color32(66, 132, 78, 255), e1 = new Color32(60, 124, 72, 255); for (int y = 0; y < H; y++) for (int x = 0; x < W; x++) { float tx = (x - ox) / S; int k = (Mathf.FloorToInt(tx) + (int)(y / S)) & 1; r.Px[y * W + x] = tx >= 0f && tx < c.arenaW ? (k == 0 ? g0 : g1) : (k == 0 ? e0 : e1); } // 맵 가장자리 돌 턱(클래시 로얄 아레나 옆 벽처럼 — 유닛이 못 나가는 곳) Color curbDk = new Color32(104, 100, 116, 255), curb = new Color32(156, 152, 166, 255); foreach (float ex in new[] { ox, W - ox }) { r.RRect(ex - S * 0.3f, -4, ex + S * 0.3f, H + 4, 2, curbDk); r.RRect(ex - S * 0.18f, -4, ex + S * 0.18f, H + 4, 2, curb); } // 성 앞마당 · 소환 자리(아래 가운데 — 내 진영은 평지) r.Circle(ox + m.Castle.x * S, m.Castle.y * S, (c.castle.radius + c.castleSeedPad) * S, new Color32(214, 186, 130, 255)); var sp = b.SpawnPoint; float sr = (c.spawnJitter + 0.8f) * S; r.Circle(ox + sp.x * S, sp.y * S, sr, new Color(1f, 1f, 1f, 0.16f)); r.Ring(ox + sp.x * S, sp.y * S, sr, S * 0.25f, new Color(1f, 1f, 1f, 0.55f)); if (m.River) { float ry = c.riverY * S, rh = c.riverHalf * S, hw = c.bridgeHalf * S; r.RRect(-4, ry - rh, W + 4, ry + rh, 4, new Color32(70, 150, 230, 255)); // 강은 맵 밖 장식까지 이어짐 r.RRect(-4, ry - rh * 0.35f, W + 4, ry + rh * 0.35f, 3, new Color32(110, 190, 250, 255)); foreach (var bx in m.Bridges) { float x = ox + bx * S, step = (2 * rh + S * 0.6f) / 5f; r.RRect(x - hw, ry - rh - S * 0.5f, x + hw, ry + rh + S * 0.5f, 4, new Color32(150, 100, 60, 255)); for (int k = 0; k < 5; k++) { float y0 = ry - rh - S * 0.3f + k * step; r.RRect(x - hw + S * 0.2f, y0, x + hw - S * 0.2f, y0 + S * 0.2f, 2, new Color32(190, 140, 90, 255)); } } } foreach (var o in m.Obstacles) r.Ellipse(ox + o.pos.x * S + S * 0.2f, o.pos.y * S - S * 0.3f, o.r * S * 1.02f, o.r * S * 0.9f, new Color(0f, 0f, 0f, 0.22f)); // 그림자 먼저 foreach (var o in m.Obstacles) { float x = ox + o.pos.x * S, y = o.pos.y * S, rr = o.r * S; if (o.kind == 0) // 바위 { r.Circle(x, y, rr, new Color32(112, 110, 124, 255)); r.Circle(x - rr * 0.12f, y + rr * 0.12f, rr * 0.8f, new Color32(152, 150, 164, 255)); r.Circle(x - rr * 0.36f, y + rr * 0.36f, rr * 0.26f, new Color32(196, 194, 206, 255)); } else // 언덕(흙 둔덕 + 풀) { r.Circle(x, y, rr, new Color32(128, 96, 60, 255)); r.Circle(x - rr * 0.05f, y + rr * 0.08f, rr * 0.86f, new Color32(178, 140, 90, 255)); r.Circle(x - rr * 0.25f, y + rr * 0.3f, rr * 0.45f, new Color32(208, 174, 120, 255)); r.Circle(x + rr * 0.32f, y + rr * 0.42f, rr * 0.18f, new Color32(96, 160, 80, 255)); } } return r.Sprite(); } static readonly Dictionary bcache = new Dictionary(); static Sprite CastleSprite(bool enraged) { string key = "castle" + enraged; if (bcache.TryGetValue(key, out var s)) return s; var r = new Raster(200, 220); Color stone = new Color32(150, 150, 168, 255), dark = new Color32(108, 108, 126, 255), roof = enraged ? new Color32(255, 80, 60, 255) : new Color32(220, 70, 80, 255); r.RRect(20, 10, 180, 150, 14, dark); r.RRect(30, 20, 170, 140, 10, stone); for (int i = 0; i < 5; i++) r.RRect(26 + i * 32, 140, 50 + i * 32, 168, 4, stone); r.RRect(60, 20, 140, 90, 10, new Color32(90, 70, 60, 255)); r.Poly(new[] { new Vector2(40, 168), new Vector2(160, 168), new Vector2(100, 214) }, roof); r.Star(100, 115, 20, Pal.Yellow); s = r.Sprite(); bcache[key] = s; return s; } static readonly Color32[] UnitColor = { new Color32(80, 140, 255, 255), new Color32(90, 200, 110, 255), new Color32(170, 170, 190, 255), new Color32(170, 100, 240, 255), new Color32(200, 140, 90, 255), new Color32(110, 210, 140, 255) }; public static Sprite UnitSprite(int id) { string key = "u" + id; if (bcache.TryGetValue(key, out var s)) return s; s = UnitArt.ForUnit(id); // 도트 그림(Data/unit_art.json) 이 있으면 그것 · 없으면 아래 코드 그림 if (s != null) { bcache[key] = s; return s; } var r = new Raster(96, 96); Color c = UnitColor[(id - 1 + UnitColor.Length) % UnitColor.Length], dk = Color.Lerp(c, Pal.Ink, 0.4f), skin = new Color32(255, 220, 190, 255); switch (id) { case 6: r.Poly(new[] { new Vector2(10, 60), new Vector2(48, 40), new Vector2(86, 60), new Vector2(48, 30) }, dk); r.Ellipse(48, 44, 22, 18, c); r.Circle(48, 66, 14, c); r.Circle(43, 70, 3, Pal.Ink); r.Circle(53, 70, 3, Pal.Ink); break; case 5: r.Ellipse(48, 40, 34, 30, c); r.Circle(48, 74, 18, skin); r.Circle(16, 40, 12, skin); r.Circle(80, 40, 12, skin); r.Circle(42, 76, 3, Pal.Ink); r.Circle(54, 76, 3, Pal.Ink); break; default: r.Ellipse(48, 34, 22, 24, c); r.Circle(48, 66, 16, skin); r.Circle(43, 68, 3, Pal.Ink); r.Circle(53, 68, 3, Pal.Ink); if (id == 1) { r.Line(new Vector2(70, 20), new Vector2(84, 70), 6, new Color32(200, 200, 215, 255)); r.Ellipse(48, 80, 18, 8, dk); } if (id == 2) { r.Ring(78, 44, 18, 4, new Color32(150, 100, 60, 255)); r.Poly(new[] { new Vector2(30, 76), new Vector2(66, 76), new Vector2(48, 92) }, dk); } if (id == 3) { r.RRect(10, 18, 34, 54, 8, dk); r.Star(22, 36, 8, Pal.Yellow); r.Ellipse(48, 80, 18, 8, new Color32(120, 120, 140, 255)); } if (id == 4) { r.Poly(new[] { new Vector2(28, 76), new Vector2(68, 76), new Vector2(48, 96) }, dk); r.Circle(80, 52, 10, new Color32(255, 140, 255, 255)); r.Line(new Vector2(80, 44), new Vector2(80, 14), 4, new Color32(150, 100, 60, 255)); } break; } s = r.Sprite(); bcache[key] = s; return s; } /// 성 소환수 — 붉은 몸 + 뿔 · 근접 = 몽둥이 · 원거리 = 활 static Sprite MinionSprite(bool ranged) { string key = ranged ? "m1" : "m0"; if (bcache.TryGetValue(key, out var s)) return s; var r = new Raster(96, 96); Color body = ranged ? new Color32(150, 50, 110, 255) : new Color32(178, 52, 52, 255), dk = Color.Lerp(body, Pal.Ink, 0.45f); Color skin = ranged ? new Color32(236, 230, 214, 255) : new Color32(140, 196, 92, 255), eye = new Color32(220, 30, 30, 255); r.Ellipse(48, 34, 22, 24, body); r.Poly(new[] { new Vector2(34, 78), new Vector2(38, 95), new Vector2(45, 80) }, dk); r.Poly(new[] { new Vector2(62, 78), new Vector2(58, 95), new Vector2(51, 80) }, dk); r.Circle(48, 66, 16, skin); r.Circle(42, 68, 3.5f, eye); r.Circle(54, 68, 3.5f, eye); if (ranged) { r.Ring(78, 44, 18, 4, new Color32(110, 70, 40, 255)); r.Line(new Vector2(78, 26), new Vector2(78, 62), 2, Color.white); } else r.Line(new Vector2(70, 18), new Vector2(84, 56), 9, new Color32(120, 80, 50, 255)); s = r.Sprite(); bcache[key] = s; return s; } static Sprite FireballSprite() { if (bcache.TryGetValue("fire", out var s)) return s; var r = new Raster(96, 96); r.Circle(48, 48, 44, new Color(1f, 0.35f, 0.1f, 0.35f)); r.Circle(48, 48, 32, new Color32(255, 110, 40, 255)); r.Circle(44, 52, 20, new Color32(255, 190, 70, 255)); r.Circle(40, 56, 9, new Color32(255, 245, 200, 255)); s = r.Sprite(); bcache["fire"] = s; return s; } /// 레이저 띠(가로 = 길이) — 가운데 흰색 → 가장자리 분홍 static Sprite BeamSprite() { if (bcache.TryGetValue("beam", out var s)) return s; var r = new Raster(16, 32); for (int y = 0; y < 32; y++) { float d = Mathf.Abs(y + 0.5f - 16f) / 16f; var c = Color.Lerp(Color.white, new Color(1f, 0.25f, 0.55f), Mathf.Clamp01(d * 1.6f)); c.a = Mathf.Clamp01(1.15f - d); for (int x = 0; x < 16; x++) r.Px[y * 16 + x] = c; } s = r.Sprite(); bcache["beam"] = s; return s; } /// 번개(세로 · 아래 끝 = 맞는 곳) static Sprite BoltSprite() { if (bcache.TryGetValue("bolt", out var s)) return s; var r = new Raster(64, 256); var pts = new[] { new Vector2(32, 0), new Vector2(18, 50), new Vector2(42, 96), new Vector2(20, 146), new Vector2(44, 196), new Vector2(30, 256) }; for (int i = 0; i < pts.Length - 1; i++) r.Line(pts[i], pts[i + 1], 14, new Color(1f, 0.95f, 0.4f, 0.45f)); for (int i = 0; i < pts.Length - 1; i++) r.Line(pts[i], pts[i + 1], 5, Color.white); r.Circle(32, 8, 12, new Color(1f, 1f, 0.6f, 0.7f)); s = r.Sprite(); bcache["bolt"] = s; return s; } static Sprite StunSprite() { if (bcache.TryGetValue("stun", out var s)) return s; var r = new Raster(96, 48); r.Star(20, 24, 16, Pal.Yellow); r.Star(48, 30, 14, Pal.Yellow); r.Star(76, 24, 16, Pal.Yellow); s = r.Sprite(); bcache["stun"] = s; return s; } static Sprite RingSprite() { if (bcache.TryGetValue("ring", out var s)) return s; var r = new Raster(128, 128); r.Ring(64, 64, 56, 10, Color.white); s = r.Sprite(); bcache["ring"] = s; return s; } /// 깃발(내 편 파랑) — 그림 왼쪽 아래 깃대 밑동 = 꽂은 곳 static Sprite FlagSprite() { if (bcache.TryGetValue("flag", out var s)) return s; var r = new Raster(72, 104); r.Ellipse(18, 8, 16, 6, new Color(0f, 0f, 0f, 0.3f)); r.Line(new Vector2(18, 8), new Vector2(18, 100), 6, new Color32(110, 76, 46, 255)); r.Poly(new[] { new Vector2(20, 98), new Vector2(68, 82), new Vector2(20, 64) }, new Color32(70, 130, 255, 255)); r.Poly(new[] { new Vector2(20, 92), new Vector2(50, 82), new Vector2(20, 72) }, new Color32(150, 200, 255, 255)); r.Circle(18, 100, 5, Pal.Yellow); s = r.Sprite(); bcache["flag"] = s; return s; } /// 상대방 얼굴 = 방송 캐릭터 그림(의상 번호)의 머리 부분 · 분노 = 별 눈(프레임 3) static Sprite RivalFace(int outfit, bool enraged) { string key = $"rival{outfit}{enraged}"; if (bcache.TryGetValue(key, out var s)) return s; var tex = Art.Idol(outfit, enraged ? 3 : 0).texture; s = Sprite.Create(tex, new Rect(40, 190, 220, 220), new Vector2(0.5f, 0.5f), 100f); bcache[key] = s; return s; } /// 상대방 스킬 그림 — 성 투사체 5종 · 파이어 볼 · 라이트닝 · 소환수 static Sprite SkillIcon(string id) { string key = "sk_" + id; if (bcache.TryGetValue(key, out var s)) return s; var r = new Raster(96, 96); switch (id) { case "fireball": s = FireballSprite(); bcache[key] = s; return s; case "summonMelee": s = MinionSprite(false); bcache[key] = s; return s; case "summonRanged": s = MinionSprite(true); bcache[key] = s; return s; case "lightning": { var pts = new[] { new Vector2(60, 92), new Vector2(34, 52), new Vector2(56, 50), new Vector2(30, 4) }; for (int i = 0; i < pts.Length - 1; i++) r.Line(pts[i], pts[i + 1], 18, new Color(1f, 0.95f, 0.4f, 0.5f)); for (int i = 0; i < pts.Length - 1; i++) r.Line(pts[i], pts[i + 1], 8, new Color32(255, 240, 120, 255)); break; } case "splash": r.Ring(48, 48, 40, 6, new Color32(255, 160, 60, 200)); r.Circle(48, 48, 24, new Color32(255, 160, 60, 255)); r.Circle(42, 54, 8, new Color(1, 1, 1, 0.6f)); break; case "multi": for (int i = 0; i < 5; i++) { float a = (120f - 15f * i) * Mathf.Deg2Rad; r.Line(new Vector2(48, 10), new Vector2(48 + Mathf.Cos(a) * 78, 10 + Mathf.Sin(a) * 78), 7, new Color32(210, 255, 250, 255)); } break; case "slow": r.Circle(48, 48, 30, new Color32(140, 220, 255, 255)); for (int i = 0; i < 3; i++) { float a = (90f + 60f * i) * Mathf.Deg2Rad; var v = new Vector2(Mathf.Cos(a), Mathf.Sin(a)) * 40f; r.Line(new Vector2(48, 48) - v, new Vector2(48, 48) + v, 6, Color.white); } break; case "laser": r.Line(new Vector2(8, 16), new Vector2(88, 80), 22, new Color(1f, 0.3f, 0.6f, 0.5f)); r.Line(new Vector2(8, 16), new Vector2(88, 80), 9, Color.white); break; default: r.Circle(48, 48, 26, new Color32(255, 240, 160, 255)); r.Circle(40, 56, 9, new Color(1, 1, 1, 0.7f)); break; // normal } s = r.Sprite(); bcache[key] = s; return s; } static Sprite ManaDrop() { if (bcache.TryGetValue("drop", out var s)) return s; var r = new Raster(96, 120); r.Circle(48, 44, 38, new Color32(214, 60, 200, 255)); r.Poly(new[] { new Vector2(14, 56), new Vector2(82, 56), new Vector2(48, 116) }, new Color32(214, 60, 200, 255)); r.Circle(36, 56, 10, new Color(1, 1, 1, 0.5f)); s = r.Sprite(); bcache["drop"] = s; return s; } // ---------------- 화면 만들기 ---------------- // 클래시 로얄 비율 — 맵 = 화면 폭(좌우 장식 포함) · 아래 카드 칸(view.trayH) 바로 위 · 단계·시험(왼쪽 위)·상대방(오른쪽 위)은 맵 위에 겹쳐 놓는다 void BuildBattleScreen() { 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; arenaImg = UI.Img(BattleRoot, "Arena", Art.Square, Color.white, true); // 그림은 NewBattleRound 에서 맵마다 넣는다 · 누르면 깃발 arena = arenaImg.rectTransform; arena.Place(new Vector2(0.5f, 0), new Vector2(0.5f, 0), new Vector2(0, v.trayH + c.arenaH * v.tileY * 0.5f), new Vector2((c.arenaW + 2f * v.sideTiles) * v.tileX, c.arenaH * v.tileY)); var tap = new EventTrigger.Entry { eventID = EventTriggerType.PointerDown }; tap.callback.AddListener(e => OnArenaTap((PointerEventData)e)); arenaImg.gameObject.AddComponent().triggers.Add(tap); flagImg = UI.Img(arena, "Flag", FlagSprite(), Color.white); flagImg.rectTransform.anchorMin = flagImg.rectTransform.anchorMax = new Vector2(0.5f, 0.5f); flagImg.rectTransform.pivot = new Vector2(18f / 72f, 8f / 104f); flagImg.rectTransform.sizeDelta = new Vector2(1.8f, 2.6f) * v.tileX; // 그림 72×104 비율 flagImg.gameObject.SetActive(false); unitLayer = UI.Rect("Units", arena).Fill(); // 성 = 유닛과 같은 층(앞뒤 순서를 같이 정렬 — 성 뒤로 돌아간 유닛은 성에 가린다) · 그림 비율 그대로 칸 가로 기준 castleImg = UI.Img(unitLayer, "Castle", CastleSprite(false), Color.white); castleImg.rectTransform.anchorMin = castleImg.rectTransform.anchorMax = new Vector2(0.5f, 0.5f); castleImg.rectTransform.sizeDelta = new Vector2(c.castle.radius * 2.4f, c.castle.radius * 2.6f) * v.tileX; fxLayerB = UI.Rect("Fx", arena).Fill(); var hpBar = UI.Bar(arena, "CastleHp", new Color(0.12f, 0.12f, 0.2f, 0.9f), Pal.Red, out castleHpFill); castleHpBar = hpBar.rectTransform; castleHpBar.anchorMin = castleHpBar.anchorMax = new Vector2(0.5f, 0.5f); castleHpBar.sizeDelta = new Vector2(220, 30); castleHpText = UI.Txt(hpBar.transform, "T", "", 26, Color.white, TextAnchor.MiddleCenter, true); castleHpText.rectTransform.Fill(); // 오른쪽 위 = 상대방 얼굴 + 스킬 정보(클래시 로얄 남은 시간 칸 자리 · 2026-10-28 PD) — 맵 위에 겹치므로 작게 var rb = UI.Img(BattleRoot, "Rival", Skin.Round, new Color(0.17f, 0.19f, 0.32f, 0.92f)); rivalBox = rb.rectTransform; rivalBox.Place(new Vector2(1, 1), new Vector2(1, 1), new Vector2(-8, -8), new Vector2(RivalW, RivalTop), new Vector2(1, 1)); rivalFrame = UI.Img(rivalBox, "Frame", Skin.ProfileFrame, new Color32(255, 110, 150, 255)); rivalFrame.rectTransform.Place(new Vector2(0, 1), new Vector2(0, 1), new Vector2(8, -8), new Vector2(100, 100), new Vector2(0, 1)); UI.Img(rivalFrame.transform, "In", Skin.ProfileInner, new Color32(255, 236, 244, 255)).rectTransform.Fill(7, 7, 7, 7); rivalFace = UI.Img(rivalFrame.transform, "Face", null, Color.white); rivalFace.preserveAspect = true; rivalFace.rectTransform.Fill(9, 9, 9, 9); UI.Txt(rivalBox, "Name", "상대방", 30, Color.white, TextAnchor.MiddleCenter, true).rectTransform.Place(new Vector2(0, 1), new Vector2(1, 1), new Vector2(54, -58), new Vector2(-124, 44), new Vector2(0.5f, 0.5f)); // 왼쪽 위 = 단계(클래시 로얄 상대 이름 자리) · 아래 시험 버튼 — 성 지붕을 가리지 않게 폭 300 var stageBg = UI.Img(BattleRoot, "StageLabel", Skin.Label, Skin.On ? Color.white : new Color(0, 0, 0, 0)); stageBg.rectTransform.Place(new Vector2(0, 1), new Vector2(0, 1), new Vector2(10, -10), new Vector2(300, 76), new Vector2(0, 1)); stageText = UI.Txt(stageBg.transform, "Stage", "", 38, Color.white, TextAnchor.MiddleLeft, true); stageText.rectTransform.Fill(30, 4, 16, 4); stageText.supportRichText = true; // 왼쪽 아래(내 진영 구석 — 유닛은 가운데에서 나옴) = 맵 정보 · 깃발 안내 var ib = UI.Img(BattleRoot, "InfoBg", Skin.RoundSoft, new Color(0.08f, 0.06f, 0.16f, 0.5f)); ib.rectTransform.Place(new Vector2(0, 0), new Vector2(0, 0), new Vector2(12, v.trayH + 10), new Vector2(360, 110), new Vector2(0, 0)); infoText = UI.Txt(ib.transform, "Info", "", 24, new Color32(236, 228, 255, 255), TextAnchor.UpperLeft, true); infoText.rectTransform.Fill(14, 8, 10, 8); infoText.supportRichText = true; // 아래 카드 칸 + 마나 — 높이 view.trayH(클래시 로얄 ≈ 화면 폭 × 0.34) · 아래 116 = 마나 줄 · 위 = 카드 float cardH = v.trayH - 136f, cardY = v.trayH - 10f - cardH * 0.5f; var panel = UI.Img(BattleRoot, "CardPanel", Skin.BottomBar, Skin.On ? Color.white : (Color)new Color32(120, 80, 50, 255)); panel.rectTransform.Place(new Vector2(0, 0), new Vector2(1, 0), Vector2.zero, new Vector2(0, v.trayH), new Vector2(0.5f, 0)); UI.Img(panel.transform, "Inner", Skin.RoundSoft, Skin.On ? (Color)new Color32(54, 62, 98, 255) : (Color)new Color32(150, 104, 66, 255)).rectTransform.Fill(10, 10, 10, 10); var upSp = Skin.Button(Pal.Orange); var up = UI.Btn(panel.transform, "Upgrade", upSp ?? Art.Round, upSp != null ? Color.white : Pal.Orange, () => OpenBattleUpgrade()); up.GetComponent().Place(new Vector2(0, 0), new Vector2(0, 0), new Vector2(84, cardY), new Vector2(130, Mathf.Min(150f, cardH))); UI.Icon(up.transform, "I", Skin.Icon("up"), Color.white).rectTransform.Place(new Vector2(0.5f, 1), new Vector2(0.5f, 1), new Vector2(0, -46), new Vector2(70, 70)); UI.Txt(up.transform, "T", "강화", 32, Color.white, TextAnchor.MiddleCenter, true).rectTransform.Place(new Vector2(0, 0), new Vector2(1, 0), new Vector2(0, 34), new Vector2(0, 44)); var row = UI.Rect("Cards", panel.transform).Place(new Vector2(0, 0), new Vector2(1, 0), new Vector2(80, cardY), new Vector2(-200, cardH), new Vector2(0.5f, 0.5f)); var hl = row.gameObject.AddComponent(); hl.spacing = 12; hl.childControlWidth = true; hl.childControlHeight = true; hl.childForceExpandWidth = true; for (int i = 0; i < c.slotMax; i++) { int slot = i; var b = UI.Btn(row, "Card" + i, Skin.On ? Skin.CardOn : Art.Round, Skin.On ? Color.white : (Color)new Color32(90, 160, 230, 255), () => OnCard(slot)); var face = UI.Img(b.transform, "Face", null, Color.white); face.preserveAspect = true; face.rectTransform.Fill(12, 50, 12, 14); var gem = UI.Icon(b.transform, "Gem", Skin.Gem ?? ManaDrop(), Color.white); gem.rectTransform.Place(new Vector2(0.5f, 0), new Vector2(0.5f, 0), new Vector2(0, 16), new Vector2(78, 72)); var cost = UI.Txt(gem.transform, "C", "", 38, Color.white, TextAnchor.MiddleCenter, true); cost.rectTransform.Fill(0, 4, 0, 0); var lk = UI.Icon(b.transform, "Lock", Skin.Icon("lock"), Color.white); lk.rectTransform.Place(new Vector2(0.5f, 0.5f), new Vector2(0.5f, 0.5f), new Vector2(0, 20), new Vector2(80, 80)); var lt = UI.Txt(b.transform, "LT", "", 24, Color.white, TextAnchor.MiddleCenter, true); lt.rectTransform.Place(new Vector2(0, 0), new Vector2(1, 0), new Vector2(0, 26), new Vector2(0, 40)); cards.Add((b, face, cost, lk, lt)); } var drop = UI.Icon(panel.transform, "Drop", Skin.ManaGem ?? ManaDrop(), Color.white); drop.rectTransform.Place(new Vector2(0, 0), new Vector2(0, 0), new Vector2(84, 70), new Vector2(110, 100)); manaText = UI.Txt(drop.transform, "N", "5", 50, Color.white, TextAnchor.MiddleCenter, true); manaText.rectTransform.Fill(0, 4, 0, 0); var mbar = UI.Img(panel.transform, "ManaBar", Skin.BarBgThick, Skin.On ? (Color)new Color32(40, 36, 66, 255) : new Color(0.18f, 0.1f, 0.2f, 0.9f)); mbar.rectTransform.Place(new Vector2(0, 0), new Vector2(1, 0), new Vector2(90, 70), new Vector2(-240, 56), new Vector2(0.5f, 0.5f)); manaFill = UI.Img(mbar.transform, "Fill", Skin.BarFill, new Color32(220, 80, 210, 255)); manaFill.type = Image.Type.Filled; manaFill.fillMethod = Image.FillMethod.Horizontal; manaFill.preserveAspect = false; manaFill.rectTransform.Fill(7, 7, 7, 7); manaMaxText = UI.Txt(panel.transform, "Max", "", 28, Color.white, TextAnchor.MiddleLeft, true); manaMaxText.rectTransform.Place(new Vector2(0, 0), new Vector2(0, 0), new Vector2(150, 26), new Vector2(300, 40), new Vector2(0, 0.5f)); // 결과 덮개(승리·패배) — 왼쪽 「스테이지 선택」 · 오른쪽 = 다음 스테이지 / 다시 도전 (시작은 스테이지 선택 화면의 「시작」) var ov = UI.Img(BattleRoot, "Overlay", Art.Square, new Color(0.05f, 0.03f, 0.1f, 0.55f), true); overlay = ov.rectTransform.Fill(0, v.trayH, 0, 0); var card = UI.Img(overlay, "Card", Skin.PopupPlain, Pal.Panel); card.rectTransform.Place(new Vector2(0.5f, 0.5f), new Vector2(0.5f, 0.5f), Vector2.zero, new Vector2(840, 800)); overlayRibbon = UI.Img(card.transform, "Ribbon", Skin.Ribbon("Blue"), Skin.On ? Color.white : new Color(0, 0, 0, 0)); overlayRibbon.rectTransform.Place(new Vector2(0.5f, 1), new Vector2(0.5f, 1), new Vector2(0, 50), new Vector2(560, 230)); overlayTitle = UI.Txt(overlayRibbon.transform, "Title", "", 64, Skin.On ? Color.white : Pal.Purple, TextAnchor.MiddleCenter, true); overlayTitle.rectTransform.Fill(120, 70, 120, 50); overlayInfo = UI.Txt(card.transform, "Info", "", 34, Pal.Ink); overlayInfo.rectTransform.Place(new Vector2(0, 1), new Vector2(1, 1), new Vector2(0, -350), new Vector2(-60, 400)); var ex = UI.TextBtn(card.transform, "Exit", "스테이지 선택", Pal.Gray, 40, () => OpenStageSelect(B.State == BattleState.Won ? G.StageFocus : B.Stage), out _); ex.GetComponent().Place(new Vector2(0.5f, 0), new Vector2(0.5f, 0), new Vector2(-195, 90), new Vector2(370, 150)); var ob = UI.TextBtn(card.transform, "Go", "", Pal.Green, 44, () => OnOverlayButton(), out overlayBtnLabel); ob.GetComponent().Place(new Vector2(0.5f, 0), new Vector2(0.5f, 0), new Vector2(195, 90), new Vector2(370, 150)); // 시험 버튼(덮개 위 — 결과 화면에서도 누를 수 있게) var tb = UI.TextBtn(BattleRoot, "TestBtn", "시험", Pal.Purple, 30, () => OpenBattleTest(), out _); tb.GetComponent().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)은 부르는 쪽 public void NewBattleRound(int seed) { foreach (var v in unitViews.Values) Kill(v.rt.gameObject); unitViews.Clear(); 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); UpdateTabBar(); } rewardGiven = false; lastBattleReward = BigInteger.Zero; var old = arenaImg.sprite; arenaImg.sprite = ArenaSprite(B); if (old != null && old != Art.Square) { var tex = old.texture; if (Application.isPlaying) { Destroy(old); Destroy(tex); } else { DestroyImmediate(old); DestroyImmediate(tex); } } var cp = castleImg.rectTransform.anchoredPosition = TileToLocal(B.CastlePos); // 성 체력 막대 = 지붕 위쪽 · 맵 맨 위를 넘지 않게 castleHpBar.anchoredPosition = new Vector2(cp.x, Mathf.Min(cp.y + castleImg.rectTransform.sizeDelta.y * 0.5f - 6f, arena.sizeDelta.y * 0.5f - castleHpBar.sizeDelta.y * 0.5f - 6f)); flagPop = 0f; BuildRivalRows(); RefreshBattle(); } // ---------------- 상대방 스킬 줄: 그림 · 이름 · 수치(성 공격력 × %) · 재사용 막대 ---------------- void BuildRivalRows() { foreach (var r in rivalRows) Kill(r.rt.gameObject); rivalRows.Clear(); foreach (var w in B.CastleShots) AddRivalRow(w.def.id, w.def.name, RivalInfo(w.def), w, null); foreach (var w in B.CastleSkills) AddRivalRow(w.def.id, w.def.name, RivalInfo(w.def), w, null); foreach (var s in B.Summoners) AddRivalRow(s.def.id, s.def.name, "", null, s); rivalBox.sizeDelta = new Vector2(rivalBox.sizeDelta.x, RivalTop + rivalRows.Count * RivalRowH + 8f); } void AddRivalRow(string id, string name, string info, BWeapon w, BSummoner s) { float y = -RivalTop - rivalRows.Count * RivalRowH; var bg = UI.Img(rivalBox, "Skill_" + id, Skin.RoundSoft, new Color(1f, 1f, 1f, 0.08f)); // 한 줄 = 왼쪽 그림 · 오른쪽 이름 + 수치 2줄 · 맨 아래 재사용 막대 var rt = bg.rectTransform.Place(new Vector2(0, 1), new Vector2(1, 1), new Vector2(0, y), new Vector2(-12, RivalRowH - 6f), new Vector2(0.5f, 1)); UI.Img(rt, "Icon", SkillIcon(id), Color.white).rectTransform.Place(new Vector2(0, 1), new Vector2(0, 1), new Vector2(4, -4), new Vector2(44, 44), new Vector2(0, 1)); UI.Txt(rt, "Name", name, 22, Pal.Yellow, TextAnchor.MiddleLeft, true).rectTransform.Place(new Vector2(0, 1), new Vector2(1, 1), new Vector2(26, -2), new Vector2(-58, 28), new Vector2(0.5f, 1)); var tx = UI.Txt(rt, "Info", info, 18, new Color32(236, 228, 255, 255), TextAnchor.UpperLeft); tx.rectTransform.Place(new Vector2(0, 1), new Vector2(1, 1), new Vector2(26, -30), new Vector2(-58, 42), new Vector2(0.5f, 1)); var bar = UI.Bar(rt, "Cd", new Color(0.1f, 0.1f, 0.18f, 0.9f), new Color32(255, 200, 90, 255), out var fill, true); bar.rectTransform.Place(new Vector2(0, 0), new Vector2(1, 0), new Vector2(0, 3), new Vector2(-12, 7), new Vector2(0.5f, 0)); rivalRows.Add(new RivalRow { rt = rt, bg = bg, fill = fill, info = tx, w = w, s = s, lastCd = w != null ? w.cd : s.cd }); } /// 피해 = 성 공격력 × dmgPct % (battle.json) · 사거리 = 시험 배율 반영 string RivalInfo(BAttackDef d) => AttackInfo(d, B.Atk, B.Range(d)); /// 성 공격 한 가지 설명 2줄 — 피해(성 공격력 × %) · 덧붙는 효과 · 사거리(칸) (스테이지 정보 창과 같이 씀) static string AttackInfo(BAttackDef d, float atk, float range) { int dmg = Mathf.RoundToInt(atk * d.dmgPct / 100f), sp = Mathf.RoundToInt(atk * d.splashPct / 100f); string a = d.id == "multi" ? $"피해 {dmg} × {d.count}발" : d.id == "laser" ? $"줄 위 전원 {dmg}" : $"피해 {dmg}"; string b = d.splashRadius > 0f ? $"주변 {sp}" : d.slowSeconds > 0f ? $"감속 {d.slowPct:0}%" : d.stunSeconds > 0f ? $"기절 {d.stunSeconds:0.#}초" : ""; return a + "\n" + (b.Length > 0 ? b + " · " : "") + $"{range:0.#}칸"; } void RefreshRival() { rivalFace.sprite = RivalFace(B.RivalOutfit, B.Enraged); rivalFace.color = B.CastleHitFlash > 0 ? new Color(1f, 0.75f, 0.75f) : Color.white; rivalFrame.color = B.Enraged ? (Color)new Color32(255, 70, 60, 255) : (Color)new Color32(255, 110, 150, 255); foreach (var r in rivalRows) { float cd = r.w != null ? r.w.cd : r.s.cd, full = r.w != null ? r.w.def.interval * B.IntervalMul : r.s.def.interval; if (cd > r.lastCd + 0.05f) r.flash = 0.35f; // 방금 발동 (TickBattle 에서 줄어듦) r.lastCd = cd; r.fill.fillAmount = Mathf.Clamp01(1f - cd / Mathf.Max(0.01f, full)); r.bg.color = r.flash > 0f ? new Color(1f, 0.85f, 0.4f, 0.55f) : new Color(1f, 1f, 1f, 0.08f); if (r.s != null) { int n = 0; foreach (var m in B.Minions) if (m.def == r.s.def) n++; r.info.text = $"체력 {Mathf.RoundToInt(r.s.def.hp * B.SummonMul)}\n공격 {Mathf.RoundToInt(r.s.def.damage * B.SummonMul)} · {n}/{r.s.def.max}"; } } } // ---------------- 깃발: 맵을 누른 곳 → 내 유닛이 먼저 깃발에 닿은 뒤 성으로 ---------------- void OnArenaTap(PointerEventData e) { if (B == null || B.State != BattleState.Fighting) return; if (RectTransformUtility.ScreenPointToLocalPointInRectangle(arena, e.position, e.pressEventCamera, out var lp)) PlaceFlag(new Vector2(lp.x / V.tileX + B.C.arenaW * 0.5f, lp.y / V.tileY + B.C.arenaH * 0.5f)); } public bool PlaceFlag(Vector2 tile) { if (B == null || !B.SetFlag(tile)) return false; flagPop = 0.25f; RefreshBattle(); return true; } BigInteger lastBattleReward; /// 결과 오른쪽 버튼 — 이겼고 다음 스테이지가 열렸으면 다음 스테이지 · 아니면 같은 스테이지 다시 void OnOverlayButton() { if (B == null) return; BeginBattle(NextOpen(B) ? B.Stage + 1 : B.Stage); } bool NextOpen(Battle b) => b.State == BattleState.Won && b.Stage < G.StageCount && G.StageOpen(b.Stage + 1); void OnCard(int slot) { if (slot >= G.BattleSlots) { OpenBattleUpgrade(); return; } DeployCard(slot); } /// 카드 = 즉시 소환(아래 가운데) — 위치 선택 없음 public bool DeployCard(int slot) { var d = G.BattleUnit(slot); if (d == null || B == null || B.State != BattleState.Fighting) return false; if (B.Mana < d.cost) { Toast("마나가 부족합니다"); return false; } bool ok = B.Deploy(d); RefreshBattle(); return ok; } public void OpenBattleUpgrade() { var p = NewPopup("BattleUpgrade", "전투 강화", 1000, Pal.Orange); var t1 = UI.Txt(p.body, "Slots", "", 40, Pal.Ink, TextAnchor.MiddleLeft); t1.rectTransform.Place(new Vector2(0, 1), new Vector2(1, 1), new Vector2(20, -90), new Vector2(-400, 120), new Vector2(0, 0.5f)); var b1 = UI.TextBtn(p.body, "UnlockSlot", "", Pal.Green, 34, () => { var r = G.UnlockBattleSlot(); if (r != BuyResult.Ok) Toast(BuyMsg(r)); RefreshBattle(); RefreshAll(); }, out var l1); b1.GetComponent().Place(new Vector2(1, 1), new Vector2(1, 1), new Vector2(-190, -90), new Vector2(340, 140)); var t2 = UI.Txt(p.body, "Mana", "", 40, Pal.Ink, TextAnchor.MiddleLeft); t2.rectTransform.Place(new Vector2(0, 1), new Vector2(1, 1), new Vector2(20, -290), new Vector2(-400, 120), new Vector2(0, 0.5f)); var b2 = UI.TextBtn(p.body, "UpMana", "", Pal.Purple, 34, () => { var r = G.UpgradeBattleMana(); if (r != BuyResult.Ok) Toast(BuyMsg(r)); RefreshBattle(); RefreshAll(); }, out var l2); b2.GetComponent().Place(new Vector2(1, 1), new Vector2(1, 1), new Vector2(-190, -290), new Vector2(340, 140)); var note = UI.Txt(p.body, "Note", "비용·능력치 = 설계값(확정 전) · 강화는 다음 전투부터 적용", 28, Pal.Gray); note.rectTransform.Place(new Vector2(0, 0), new Vector2(1, 0), new Vector2(0, 60), new Vector2(-40, 60)); p.refresh = () => { var nu = G.BattleUnit(G.BattleSlots); 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; }; p.refresh(); } // ---------------- 시험 설정: 맵 · 바위·언덕 · 성 투사체 · 성 기믹 · 사거리 배율 → 다음 전투 ---------------- BattleSetup StageDefaultSetup() { var lo = T.Battle.Loadout(stageSel); return new BattleSetup { shots = new List(lo.shots ?? new string[0]), gimmicks = new List(lo.gimmicks ?? new string[0]) }; } public void OpenBattleTest() { var c = T.Battle; var s = BattleTest != null ? BattleTest.Clone() : StageDefaultSetup(); var def = StageDefaultSetup(); if (s.shots == null) s.shots = def.shots; if (s.gimmicks == null) s.gimmicks = def.gimmicks; var p = NewPopup("BattleTest", "전투 시험 설정", 1600, Pal.Purple); var refreshers = new List(); float y = -10f; var mapNames = new List { "무작위" }; mapNames.AddRange(c.mapNames); y = TestRow(p, refreshers, y, "맵", mapNames, k => s.mapType == k - 1, k => s.mapType = k - 1); y = TestRow(p, refreshers, y, "바위·언덕", new List { "무작위", "없음", "있음" }, k => s.obstacles == k - 1, k => s.obstacles = k - 1); var shotIds = c.shots.ConvertAll(d => d.id); y = TestRow(p, refreshers, y, "성 투사체 (여러 개 선택)", c.shots.ConvertAll(d => d.name), k => s.shots.Contains(shotIds[k]), k => Toggle(s.shots, shotIds[k])); var gIds = c.skills.ConvertAll(d => d.id); gIds.AddRange(c.summons.ConvertAll(d => d.id)); var gNames = c.skills.ConvertAll(d => d.name); gNames.AddRange(c.summons.ConvertAll(d => d.name)); y = TestRow(p, refreshers, y, "성 기믹 (여러 개 선택)", gNames, k => s.gimmicks.Contains(gIds[k]), k => Toggle(s.gimmicks, gIds[k])); var muls = new List(c.testRangeMuls); y = TestRow(p, refreshers, y, "성 사거리 배율", muls.ConvertAll(m => "×" + m.ToString("0.##")), k => Mathf.Abs(s.rangeMul - muls[k]) < 1e-4f, k => s.rangeMul = muls[k]); var note = UI.Txt(p.body, "Note", $"적용 = 이 설정으로 스테이지 {stageSel} 바로 시작(잠긴 스테이지도 가능 · 시험 전투는 클리어 기록 안 함) · 수치 = battle.json (설계값 · 확정 전)", 28, Pal.Gray); note.rectTransform.Place(new Vector2(0, 1), new Vector2(1, 1), new Vector2(0, y - 30), new Vector2(-20, 90), new Vector2(0.5f, 1)); // 단계 기본으로 = 시험 해제(싸우는 중이면 기본 설정으로 다시 시작 · 스테이지 선택 화면이면 그대로) var reset = UI.TextBtn(p.body, "Reset", "단계 기본으로", Pal.Gray, 38, () => { BattleTest = null; CloseAllPopups(); if (B != null && B.State == BattleState.Fighting) BeginBattle(stageSel); else ShowStage(stageSel); }, out _); reset.GetComponent().Place(new Vector2(0, 0), new Vector2(0, 0), new Vector2(220, 80), new Vector2(400, 130)); var apply = UI.TextBtn(p.body, "Apply", "적용 → 새 전투", Pal.Green, 38, () => { BattleTest = s; CloseAllPopups(); BeginBattle(stageSel); }, out _); apply.GetComponent().Place(new Vector2(1, 0), new Vector2(1, 0), new Vector2(-220, 80), new Vector2(400, 130)); p.refresh = () => { foreach (var r in refreshers) r(); }; p.refresh(); } static void Toggle(List l, string id) { if (!l.Remove(id)) l.Add(id); } float TestRow(Popup p, List refreshers, float y, string label, List names, System.Func isOn, System.Action click) { UI.Txt(p.body, "L" + label, label, 34, Pal.Ink, TextAnchor.MiddleLeft).rectTransform.Place(new Vector2(0, 1), new Vector2(1, 1), new Vector2(10, y), new Vector2(-20, 52), new Vector2(0.5f, 1)); y -= 58f; var row = UI.Rect("R" + label, p.body).Place(new Vector2(0, 1), new Vector2(1, 1), new Vector2(0, y), new Vector2(0, 104), new Vector2(0.5f, 1)); var hl = row.gameObject.AddComponent(); hl.spacing = 10; hl.childControlWidth = true; hl.childControlHeight = true; hl.childForceExpandWidth = true; hl.childForceExpandHeight = true; for (int i = 0; i < names.Count; i++) { int k = i; var b = UI.TextBtn(row, "B" + i, names[i], Pal.Gray, 30, () => { click(k); p.refresh?.Invoke(); }, out _); refreshers.Add(() => UI.SetBtnColor(b, isOn(k) ? Pal.Pink : Pal.Gray)); } return y - 124f; } // ---------------- 진행 · 갱신 ---------------- void TickBattle(float dt) { if (B == null || Tab != 1) return; B.Tick(dt); flagPop = Mathf.Max(0f, flagPop - dt); foreach (var r in rivalRows) r.flash = Mathf.Max(0f, r.flash - dt); if (B.State == BattleState.Won && !rewardGiven) { rewardGiven = true; lastBattleReward = G.WinBattle(B); } RefreshBattle(); } static string JoinNames(List l, string sep) => l.Count > 0 ? string.Join(sep, l) : "없음"; void RefreshBattle() { if (B == null || castleImg == null) return; stageText.text = $"스테이지 {B.Stage}" + (B.IsTest ? " 시험" : "") + (B.LastMinute && B.State == BattleState.Fighting ? " 마나 x2" : ""); castleHpFill.fillAmount = B.CastleMaxHp > 0 ? B.CastleHp / B.CastleMaxHp : 0; castleHpFill.color = B.CastleHealFlash > 0f ? (Color)new Color32(90, 230, 120, 255) : Pal.Red; castleHpText.text = Mathf.CeilToInt(B.CastleHp).ToString(); castleImg.sprite = CastleSprite(B.Enraged); castleImg.color = B.CastleHitFlash > 0 ? new Color(1f, 0.75f, 0.75f) : Color.white; manaText.text = Mathf.FloorToInt(B.Mana).ToString(); manaFill.fillAmount = B.MaxMana > 0 ? B.Mana / B.MaxMana : 0; manaMaxText.text = $"최대: {Mathf.RoundToInt(B.MaxMana)}"; infoText.text = $"맵 {B.MapName} · 바위·언덕 {B.Map.Obstacles.Count}\n깃발 맵을 누르면 유닛이 깃발을 거쳐 성으로"; RefreshRival(); flagImg.gameObject.SetActive(B.HasFlag && B.State == BattleState.Fighting); if (B.HasFlag) { flagImg.rectTransform.anchoredPosition = TileToLocal(B.FlagPos); flagImg.rectTransform.localScale = Vector3.one * (1f + flagPop * 2f); flagImg.rectTransform.localEulerAngles = new Vector3(0, 0, Mathf.Sin(Time.unscaledTime * 3f) * 3f); } for (int i = 0; i < cards.Count; i++) { 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 || d == null); if (open && d != null) { face.sprite = UnitSprite(d.id); cost.text = d.cost.ToString(); } lockText.text = !open ? (i == G.BattleSlots ? "강화" : "") : d == null ? "빈 칸" : ""; bool afford = open && d != null && B.Mana >= d.cost; var ci = b.GetComponent(); 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)); } else ci.color = !open ? new Color32(70, 60, 80, 255) : (afford ? new Color32(90, 160, 230, 255) : new Color32(110, 120, 140, 255)); } // 내 유닛 (기절 = 별 · 감속 = 파란 빛) var alive = new HashSet(B.Units); var dead = new List(); foreach (var kv in unitViews) if (!alive.Contains(kv.Key)) dead.Add(kv.Key); foreach (var u in dead) { Kill(unitViews[u].rt.gameObject); unitViews.Remove(u); } foreach (var u in B.Units) { if (!unitViews.TryGetValue(u, out var v)) { var im = UI.Img(unitLayer, "U" + u.uid, UnitSprite(u.def.id), Color.white); float sz = u.def.radius * V.unitArt * V.tileX; // 그림 한 변 = 반지름 × unitArt 칸(클래시 로얄 병사 키 ≈ 1.5칸) im.rectTransform.anchorMin = im.rectTransform.anchorMax = new Vector2(0.5f, 0.5f); im.rectTransform.pivot = new Vector2(0.5f, 0f); im.rectTransform.sizeDelta = new Vector2(sz, sz); // 그림 발밑 = 유닛 자리 var hb = UI.Bar(im.transform, "Hp", new Color(0.1f, 0.1f, 0.18f, 0.85f), new Color32(90, 200, 255, 255), out var hf, true); hb.rectTransform.Place(new Vector2(0.5f, 1), new Vector2(0.5f, 1), new Vector2(0, 6), new Vector2(sz * 0.6f, 12)); var st = UI.Img(im.transform, "Stun", StunSprite(), Color.white); st.rectTransform.Place(new Vector2(0.5f, 1), new Vector2(0.5f, 1), new Vector2(0, 30), new Vector2(sz * 0.7f, sz * 0.35f)); v = (im.rectTransform, hf, st); unitViews[u] = v; } v.rt.anchoredPosition = TileToLocal(u.pos) + (u.def.flying ? new Vector2(0, V.flyLift * V.tileY) : Vector2.zero); v.hp.fillAmount = Mathf.Clamp01(u.hp / Mathf.Max(1f, u.def.hp)); v.rt.GetComponent().color = u.hitFlash > 0 ? new Color(1f, 0.6f, 0.6f) : (u.slow > 0f ? new Color(0.55f, 0.78f, 1f) : Color.white); v.rt.localScale = Vector3.one * (u.attacking ? 1f + 0.08f * Mathf.Sin(Time.unscaledTime * 18f) : 1f); v.stun.gameObject.SetActive(u.stun > 0f); if (u.stun > 0f) v.stun.rectTransform.localEulerAngles = new Vector3(0, 0, Mathf.Sin(Time.unscaledTime * 6f) * 15f); } // 성 소환수 var aliveM = new HashSet(B.Minions); var deadM = new List(); foreach (var kv in minionViews) if (!aliveM.Contains(kv.Key)) deadM.Add(kv.Key); foreach (var m in deadM) { Kill(minionViews[m].rt.gameObject); minionViews.Remove(m); } foreach (var m in B.Minions) { if (!minionViews.TryGetValue(m, out var v)) { var im = UI.Img(unitLayer, "M" + m.uid, MinionSprite(m.def.ranged), Color.white); float sz = m.def.radius * V.unitArt * V.tileX; im.rectTransform.anchorMin = im.rectTransform.anchorMax = new Vector2(0.5f, 0.5f); im.rectTransform.pivot = new Vector2(0.5f, 0f); im.rectTransform.sizeDelta = new Vector2(sz, sz); var hb = UI.Bar(im.transform, "Hp", new Color(0.1f, 0.1f, 0.18f, 0.85f), Pal.Red, out var hf, true); hb.rectTransform.Place(new Vector2(0.5f, 1), new Vector2(0.5f, 1), new Vector2(0, 6), new Vector2(sz * 0.6f, 12)); v = (im.rectTransform, hf); minionViews[m] = v; } v.rt.anchoredPosition = TileToLocal(m.pos); v.hp.fillAmount = Mathf.Clamp01(m.hp / Mathf.Max(1f, m.maxHp)); v.rt.GetComponent().color = m.hitFlash > 0 ? new Color(1f, 0.6f, 0.6f) : Color.white; v.rt.localScale = Vector3.one * (m.attacking ? 1f + 0.08f * Mathf.Sin(Time.unscaledTime * 18f) : 1f); } // 앞뒤 순서 — 아래(가까운) 것이 위(먼) 것을 가린다 · 성은 가운데 줄 기준 · 나는 유닛은 맨 앞 depth.Clear(); depth.Add((castleImg.transform, B.CastlePos.y)); foreach (var kv in unitViews) depth.Add((kv.Value.rt, kv.Key.def.flying ? kv.Key.pos.y - 1000f : kv.Key.pos.y)); foreach (var kv in minionViews) depth.Add((kv.Value.rt, kv.Key.pos.y)); depth.Sort((a, b) => b.key.CompareTo(a.key)); for (int i = 0; i < depth.Count; i++) if (depth[i].t.GetSiblingIndex() != i) depth[i].t.SetSiblingIndex(i); // 투사체 — 크기 = 칸 가로 기준 · 유닛 그림이 발밑에서 위로 서 있으므로 몸통 높이(0.9칸)로 띄워 날린다(파이어 볼 = 땅에 떨어짐) while (shotViews.Count < B.Shots.Count) { var s = UI.Img(fxLayerB, "Shot", Art.Circle, Color.white); s.rectTransform.anchorMin = s.rectTransform.anchorMax = new Vector2(0.5f, 0.5f); shotViews.Add(s); } float tw = V.tileX; for (int i = 0; i < shotViews.Count; i++) { bool on = i < B.Shots.Count; shotViews[i].gameObject.SetActive(on); if (!on) continue; var sh = B.Shots[i]; var im = shotViews[i]; var rt = im.rectTransform; rt.anchoredPosition = TileToLocal(sh.pos) + new Vector2(0f, sh.type == "fireball" ? 0f : 0.9f * V.tileY); rt.localEulerAngles = Vector3.zero; im.sprite = Art.Circle; switch (sh.type) { case "fireball": im.sprite = FireballSprite(); rt.sizeDelta = Vector2.one * 1.6f * tw; im.color = Color.white; break; case "splash": rt.sizeDelta = Vector2.one * 0.7f * tw; im.color = new Color32(255, 160, 60, 255); break; case "slow": rt.sizeDelta = Vector2.one * 0.6f * tw; im.color = new Color32(140, 220, 255, 255); break; case "multi": { var dp = TileVec(sh.dir); rt.sizeDelta = new Vector2(0.85f, 0.35f) * tw; im.color = new Color32(210, 255, 250, 255); rt.localEulerAngles = new Vector3(0, 0, Mathf.Atan2(dp.y, dp.x) * Mathf.Rad2Deg); break; } case "normal": rt.sizeDelta = Vector2.one * 0.45f * tw; im.color = new Color32(255, 240, 160, 255); break; case "minion": rt.sizeDelta = Vector2.one * 0.4f * tw; im.color = new Color32(255, 90, 90, 255); break; default: rt.sizeDelta = Vector2.one * 0.6f * tw; im.color = sh.unitId == 4 ? new Color32(255, 120, 255, 255) : (sh.unitId == 6 ? new Color32(255, 140, 60, 255) : new Color32(220, 255, 220, 255)); break; } } // 순간 효과 — 레이저 · 번개 · 폭발 · 소환 while (fxViews.Count < B.Fx.Count) fxViews.Add(UI.Img(fxLayerB, "Fx", Art.SoftCircle, Color.white)); for (int i = 0; i < fxViews.Count; i++) { bool on = i < B.Fx.Count; fxViews[i].gameObject.SetActive(on); if (!on) continue; var f = B.Fx[i]; var im = fxViews[i]; var rt = im.rectTransform; float k = Mathf.Clamp01(f.t / Mathf.Max(0.01f, f.life)); rt.anchorMin = rt.anchorMax = new Vector2(0.5f, 0.5f); rt.localEulerAngles = Vector3.zero; switch (f.type) { case "laser": { var d = TileVec(f.b - f.a); // 화면 px(세로가 짧은 땅) 기준 길이·각도 im.sprite = BeamSprite(); rt.pivot = new Vector2(0f, 0.5f); rt.anchoredPosition = TileToLocal(f.a); rt.sizeDelta = new Vector2(d.magnitude, f.r * tw * (1.2f - 0.5f * k)); rt.localEulerAngles = new Vector3(0, 0, Mathf.Atan2(d.y, d.x) * Mathf.Rad2Deg); im.color = new Color(1f, 1f, 1f, 1f - k * 0.7f); break; } case "lightning": { var d = TileVec(f.a - f.b); im.sprite = BoltSprite(); rt.pivot = new Vector2(0.5f, 0f); rt.anchoredPosition = TileToLocal(f.b); rt.sizeDelta = new Vector2(1.75f * tw, d.magnitude); rt.localEulerAngles = new Vector3(0, 0, Mathf.Atan2(d.y, d.x) * Mathf.Rad2Deg - 90f); im.color = new Color(1f, 1f, 1f, 1f - k * 0.6f); break; } case "blast": im.sprite = Art.SoftCircle; rt.pivot = new Vector2(0.5f, 0.5f); rt.anchoredPosition = TileToLocal(f.a); rt.sizeDelta = TileVec(Vector2.one * f.r * 2f) * (1.3f + 0.4f * k); // 땅 위 타원 · 부드러운 원이라 1.3배 = 눈에 보이는 크기 ≈ 피해 반경 im.color = new Color(1f, 0.5f, 0.05f, 1f - 0.8f * k); break; case "heal": // 성벽 회복 — 초록 빛이 성을 감쌈 im.sprite = Art.SoftCircle; rt.pivot = new Vector2(0.5f, 0.5f); rt.anchoredPosition = TileToLocal(f.a); rt.sizeDelta = TileVec(Vector2.one * f.r * 2f) * (1.1f + 0.3f * k); im.color = new Color(0.35f, 1f, 0.45f, 0.75f * (1f - k)); break; default: // spawn im.sprite = RingSprite(); rt.pivot = new Vector2(0.5f, 0.5f); rt.anchoredPosition = TileToLocal(f.a); rt.sizeDelta = TileVec(Vector2.one * f.r * 2f) * (0.6f + 0.6f * k); im.color = new Color(0.7f, 0.9f, 1f, 0.9f * (1f - k)); break; } } // 결과 덮개(승리·패배만 — 시작 정보는 스테이지 선택 화면의 지도 버튼) overlay.gameObject.SetActive(B.State == BattleState.Won || B.State == BattleState.Lost); overlayRibbon.sprite = Skin.Ribbon(B.State == BattleState.Won ? "Yellow" : B.State == BattleState.Lost ? "Red" : "Blue"); if (B.State == BattleState.Won) { bool next = NextOpen(B); overlayTitle.text = "승리!"; overlayInfo.text = $"스테이지 {B.Stage}. {B.C.StageName(B.Stage)} 클리어" + (B.IsTest ? "\n(시험 전투 — 클리어 기록 안 함)" : next && G.StageFocus == B.Stage + 1 ? $"\n스테이지 {B.Stage + 1} 열림!" : B.Stage >= G.StageCount ? "\n모든 스테이지 클리어!" : "") + $"\n보상 {Fmt.Num(lastBattleReward)} 코인"; overlayBtnLabel.text = next ? "다음 스테이지" : "다시 하기"; } else if (B.State == BattleState.Lost) { overlayTitle.text = "패배"; overlayInfo.text = $"성 체력 {Mathf.CeilToInt(B.CastleHp)} 남음\n강화 후 다시 도전하세요"; overlayBtnLabel.text = "다시 도전"; } } } }