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 BPower p; public float lastCd, flash; } readonly List rivalRows = new List(); const float RivalW = 240f, RivalTop = 116f, RivalRowH = 88f; // 클래시 로얄 남은 시간 칸 자리 — 맵을 덜 가리게 작게 const int RivalCompactFrom = 7; const float RivalRowCompact = 54f; // 줄이 7개 이상(보스 스테이지) = 좁은 줄(그림 · 이름 · 막대만 · 수치는 스테이지 정보) float rivalRowH = RivalRowH; 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)>(); // 유닛 화면 = 그림 + 체력 막대 + 기절 별 + 애니 상태(2026-10-30 · 걷기 루프 · 공격 = 타격 순간에 hit 프레임이 오도록 예비 동작 → 타격 → 마무리) class UnitView { public RectTransform rt; public Image img, hp, stun; public int art; public Sprite baseSprite; public Vector2 lastPos; public float t, lastCd, sinceHit = 99f, sz; public string act = ""; } 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 칸 맵 밖 장식(짙은 풀·나무 + 돌 턱 · 강은 끝까지) + 가운데 맵 /// 래스터 1칸 = 화면 2 px(도트 · 유닛 도트 2.1 px 와 같은 밀도 · 2026-10-30 PD 「배경 해상도가 떨어져 보임」 → 예전 칸당 30 px 를 가로 1.87·세로 1.53 배로 비균등 확대하던 것을 고침) · 점 필터 Sprite ArenaSprite(Battle b) { var c = b.C; var m = b.Map; float Sx = V.tileX * 0.5f, Sy = V.tileY * 0.5f; // 칸 1개 = Sx × Sy 도트(28 × 23) — 화면 비율 그대로라 확대해도 도트가 정사각 float ox = c.view.sideTiles * Sx; int W = Mathf.RoundToInt((c.arenaW + 2f * c.view.sideTiles) * Sx), H = Mathf.RoundToInt(c.arenaH * Sy); var r = new Raster(W, H); // 그려진 지형(Resources/Art/Arena/arena_{river|plain}.png · 540×736 도트 · scripts/pixel/arena_tiles.py · 같은 칸 좌표계) // 이 그림이 있으면 풀밭·측면 장식·돌 턱·강은 그림으로 · 다리·장애물·앞마당·소환 고리는 맵마다 달라 아래 코드로 계속 그린다 · 없으면 코드 그림 var bgTex = Resources.Load("Art/Arena/" + (m.River ? "arena_river" : "arena_plain")); if (bgTex != null && bgTex.isReadable) { var bp = bgTex.GetPixels32(); int bw = bgTex.width, bh = bgTex.height; for (int y = 0; y < H; y++) for (int x = 0; x < W; x++) r.Px[y * W + x] = bp[Mathf.Min(bh - 1, y * bh / H) * bw + Mathf.Min(bw - 1, x * bw / W)]; } else { Color32 g0 = new Color32(116, 196, 118, 255), g1 = new Color32(106, 186, 108, 255), e0 = new Color32(64, 122, 76, 255), e1 = new Color32(58, 112, 70, 255); for (int y = 0; y < H; y++) for (int x = 0; x < W; x++) { float tx = (x - ox) / Sx; int k = (Mathf.FloorToInt(tx) + (int)(y / Sy)) & 1; r.Px[y * W + x] = tx >= 0f && tx < c.arenaW ? (k == 0 ? g0 : g1) : (k == 0 ? e0 : e1); } // 맵 가장자리 돌 턱(클래시 로얄 아레나 옆 벽처럼 — 유닛이 못 나가는 곳) Color curbDk = new Color32(84, 82, 100, 255), curb = new Color32(158, 154, 172, 255); foreach (float ex in new[] { ox, W - ox }) { r.RRect(ex - 3f, -4, ex + 3f, H + 4, 1, curbDk); r.RRect(ex - 2f, -4, ex + 2f, H + 4, 1, curb); } } // 성 앞마당(모래 타원 — 칸 원은 화면에서 타원) · 소환 자리(아래 가운데 — 내 진영은 평지) float pr = c.castle.radius + c.castleSeedPad; r.Ellipse(ox + m.Castle.x * Sx, m.Castle.y * Sy, pr * Sx, pr * Sy, new Color32(222, 202, 150, 255)); EllipseRing(r, ox + m.Castle.x * Sx, m.Castle.y * Sy, pr * Sx, pr * Sy, 1.5f, new Color32(178, 152, 102, 255)); var sp = b.SpawnPoint; float sr = c.spawnJitter + 0.8f; r.Ellipse(ox + sp.x * Sx, sp.y * Sy, sr * Sx, sr * Sy, new Color(1f, 1f, 1f, 0.16f)); EllipseRing(r, ox + sp.x * Sx, sp.y * Sy, sr * Sx, sr * Sy, 3f, new Color(1f, 1f, 1f, 0.55f)); float rvY = c.riverY * Sy, rvH = c.riverHalf * Sy; // 강 줄(도트) if (m.River && bgTex == null) // 그려진 지형에는 강이 이미 있다 · 다리만 코드로 { r.RRect(-4, rvY - rvH, W + 4, rvY + rvH, 3, new Color32(72, 152, 232, 255)); // 강은 맵 밖 장식까지 이어짐 r.RRect(-4, rvY - rvH * 0.35f, W + 4, rvY + rvH * 0.35f, 2, new Color32(112, 188, 246, 255)); } TintRaster(r, c.Chapter(b.Stage)); // 챕터 빛깔(늪 · 설원 · 화산 · 마왕성) — 바닥만 · 아래 다리·해자·절벽·필드·장애물은 제 빛깔 for (int i = 0; m.River && i < m.Bridges.Length; i++) DrawBridge(r, ox + m.Bridges[i] * Sx, rvY, rvH, m.BridgeHalf[i] * Sx, Sy); // 다리 폭 = 다리마다(좁은 · 보통 · 넓은) DrawStageTerrain(r, b, ox, Sx, Sy); // 해자 · 협곡 · 필드(GameApp.StageFx.cs) // 장애물 = 도트 스프라이트(Resources/Art/Arena/rock_1~3 · hill_1~3 · Krea 2 + k2-pixel32 · 2026-10-30) · 먼 것(y 큰)부터 · 그림자 타원 먼저 · 그림 없으면 코드 그림 var obs = new List(m.Obstacles); obs.Sort((p, q) => q.pos.y.CompareTo(p.pos.y)); foreach (var o in obs) r.Ellipse(ox + o.pos.x * Sx + Sx * 0.12f, o.pos.y * Sy - Sy * 0.2f, o.r * Sx * 1.05f, o.r * Sy * 0.85f, new Color(0f, 0f, 0f, 0.22f)); foreach (var o in obs) { float x = ox + o.pos.x * Sx, y = o.pos.y * Sy, w = 2f * o.r * Sx; int k = 1 + Mathf.Abs(Mathf.RoundToInt(o.pos.x * 7.3f + o.pos.y * 3.1f)) % 3; var t = Resources.Load("Art/Arena/" + (o.kind == 0 ? "rock_" : "hill_") + k); if (t != null && t.isReadable) { float h = w * t.height / t.width; r.Blit(t, x - w * 0.5f, y - o.r * Sy, w, h); continue; } float rx = o.r * Sx, ry = o.r * Sy; if (o.kind == 0) // 바위 { r.Ellipse(x, y, rx, ry, new Color32(112, 110, 124, 255)); r.Ellipse(x - rx * 0.12f, y + ry * 0.12f, rx * 0.8f, ry * 0.8f, new Color32(152, 150, 164, 255)); r.Ellipse(x - rx * 0.36f, y + ry * 0.36f, rx * 0.26f, ry * 0.26f, new Color32(196, 194, 206, 255)); } else // 언덕(흙 둔덕 + 풀) { r.Ellipse(x, y, rx, ry, new Color32(128, 96, 60, 255)); r.Ellipse(x - rx * 0.05f, y + ry * 0.08f, rx * 0.86f, ry * 0.86f, new Color32(178, 140, 90, 255)); r.Ellipse(x - rx * 0.25f, y + ry * 0.3f, rx * 0.45f, ry * 0.45f, new Color32(208, 174, 120, 255)); r.Ellipse(x + rx * 0.32f, y + ry * 0.42f, rx * 0.18f, ry * 0.18f, new Color32(96, 160, 80, 255)); } } return r.Sprite(point: true); } /// 타원 테두리(두께 w 도트) — 칸 원이 화면에서 타원이 되므로 Ring 대신 static void EllipseRing(Raster r, float cx, float cy, float rx, float ry, float w, Color col) => r.Shape((x, y) => { float dx = (x - cx) / rx, dy = (y - cy) / ry; return Mathf.Abs(Mathf.Sqrt(dx * dx + dy * dy) - 1f) * Mathf.Min(rx, ry) - w * 0.5f; }, col, cx - rx - w, cy - ry - w, cx + rx + w, cy + ry + w); static readonly Dictionary bcache = new Dictionary(); /// 성 = 도트 스프라이트(Resources/Art/Arena/castle.png · castle_enraged.png · Krea 2 + k2-pixel32 · 2026-10-30 · 1 도트 = 화면 2 px) · 없으면 코드 그림 static Sprite CastleSprite(bool enraged) { string key = "castle" + enraged; if (bcache.TryGetValue(key, out var s)) return s; s = Resources.Load("Art/Arena/" + (enraged ? "castle_enraged" : "castle")); if (s != null) { bcache[key] = 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; s = NewIcon(id); // 스테이지 50 새 요소(기술 · 소환 · 성 능력 · 필드 · 지형) if (s != null) { bcache[key] = 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); fieldLayer = UI.Rect("Fields", arena).Fill(); // 필드 빛 · 장판 · 유성 표시 · 저주 고리(유닛 아래) 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; if (castleImg.sprite != null && castleImg.sprite.texture != null && castleImg.sprite.texture.filterMode == FilterMode.Point) castleImg.rectTransform.sizeDelta = castleImg.sprite.rect.size * 2f; // 도트 성 = 1 도트 2 px(배경·유닛과 같은 밀도) 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(430, 140), new Vector2(0, 0)); infoText = UI.Txt(ib.transform, "Info", "", 22, 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; BuildStageFx(); BuildRivalRows(); RefreshBattle(); } // ---------------- 상대방 스킬 줄: 그림 · 이름 · 수치(성 공격력 × %) · 재사용 막대 ---------------- void BuildRivalRows() { foreach (var r in rivalRows) Kill(r.rt.gameObject); rivalRows.Clear(); int n = B.CastleShots.Count + B.CastleSkills.Count + B.Summoners.Count + B.Powers.Count; rivalRowH = n >= RivalCompactFrom ? RivalRowCompact : RivalRowH; 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); foreach (var p in B.Powers) AddRivalRow(p.def.id, p.def.name, PowerInfo(p.def, B.CastleMaxHp), null, null, p); rivalBox.sizeDelta = new Vector2(rivalBox.sizeDelta.x, RivalTop + rivalRows.Count * rivalRowH + 8f); } void AddRivalRow(string id, string name, string info, BWeapon w, BSummoner s, BPower p = null) { float y = -RivalTop - rivalRows.Count * rivalRowH; bool compact = rivalRowH < 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)); float ic = compact ? 38f : 44f; UI.Img(rt, "Icon", SkillIcon(id), Color.white).rectTransform.Place(new Vector2(0, 1), new Vector2(0, 1), new Vector2(4, -4), new Vector2(ic, ic), new Vector2(0, 1)); UI.Txt(rt, "Name", name, compact ? 21 : 22, Pal.Yellow, TextAnchor.MiddleLeft, true).rectTransform.Place(new Vector2(0, 1), new Vector2(1, 1), new Vector2(ic * 0.5f + 4f, compact ? -6 : -2), new Vector2(-ic - 14f, 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)); tx.gameObject.SetActive(!compact); 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, p = p, lastCd = w != null ? w.cd : s != null ? s.cd : 0f }); } /// 성 능력 줄 — 보호막 = 켜진 동안 남은 시간 · 아니면 다음까지 · 2단 분노 = 체력 기준 · 가시 성벽 · 저주 오라 = 항상(막대 가득) void RefreshPowerRow(RivalRow r) { var p = r.p; var d = p.def; bool lit = false; switch (d.id) { case "shield": lit = p.left > 0f; r.fill.fillAmount = lit ? p.left / Mathf.Max(0.01f, d.duration) : Mathf.Clamp01(1f - p.cd / Mathf.Max(0.01f, d.interval * B.IntervalMul)); r.info.text = lit ? $"보호막 {Mathf.CeilToInt(B.Shield)} 남음\n{p.left:0.0}초" : PowerInfo(d, B.CastleMaxHp); break; case "rage2": lit = B.Rage2; r.fill.fillAmount = lit ? 1f : Mathf.Clamp01(1f - (B.CastleHp / Mathf.Max(1f, B.CastleMaxHp) - d.hpRatio) / Mathf.Max(0.01f, 1f - d.hpRatio)); break; default: r.fill.fillAmount = 1f; break; } r.bg.color = lit ? new Color(1f, 0.55f, 0.35f, 0.45f + 0.15f * Mathf.Sin(Time.unscaledTime * 6f)) : new Color(1f, 1f, 1f, 0.08f); } /// 피해 = 성 공격력 × 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), dps = Mathf.RoundToInt(atk * d.dpsPct / 100f); switch (d.kind) { case "cloud": return $"초당 {dps} · {d.duration:0.#}초\n반지름 {d.radius:0.#}칸 · {range:0.#}칸" + (d.air ? "" : " · 땅만"); case "boulder": return $"피해 {dmg} · 밀침 {d.knock:0.#}칸\n가장 많은 줄로 굴림"; case "meteor": return $"{d.count}개 × {dmg}\n반지름 {d.radius:0.#}칸 · 예고 {d.warn:0.#}초"; case "nova": return $"피해 {dmg} · 얼림 {d.freezeSeconds:0.#}초\n성 둘레 {range:0.#}칸"; case "tornado": return $"초당 {dps} · {d.duration:0.#}초\n끌어당김 · 나는 유닛 밀침"; } 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) { if (r.p != null) { RefreshPowerRow(r); continue; } 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 = SummonInfo(r.s.def, B.SummonMul, B.Atk, n); } } } // ---------------- 깃발: 맵을 누른 곳 → 내 유닛이 먼저 깃발에 닿은 뒤 성으로 ---------------- 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; int s = NextOpen(B) ? B.Stage + 1 : B.Stage; if (!TryIntro(s, -1)) BeginBattle(s); // 다음 스테이지에 처음 나오는 요소가 있으면 안내 카드부터 } 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]), fields = new List(lo.fields ?? 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; if (s.fields == null) s.fields = def.fields; var p = NewPopup("BattleTest", "전투 시험 설정", 1820, Pal.Purple); var refreshers = new List(); float y = -6f; // 지도: 스테이지(고정 지도) · 무작위 · 평지/다리 1~3개 — 해자 · 협곡 = 기본(스테이지 그대로) · 없음 · 있음 var mapNames = new List { "스테이지", "무작위" }; mapNames.AddRange(c.mapNames); y = TestRow(p, refreshers, y, "지도", mapNames, k => s.mapType == (k == 0 ? BattleSetup.StageMap : k == 1 ? BattleSetup.RandomMap : k - 2), k => s.mapType = k == 0 ? BattleSetup.StageMap : k == 1 ? BattleSetup.RandomMap : k - 2); y = TestRow(p, refreshers, y, "바위·언덕", new List { "기본", "없음", "있음" }, k => s.obstacles == k - 1, k => s.obstacles = k - 1); var gates = new List { "기본", "없음" }; for (int g = 1; g <= (c.moat?.gates?.Count ?? 0); g++) gates.Add($"입구 {g}"); y = TestRow(p, refreshers, y, c.moat?.name ?? "해자", gates, k => s.moat == k - 1, k => s.moat = k - 1); y = TestRow(p, refreshers, y, c.canyon?.name ?? "협곡", new List { "기본", "없음", "있음" }, k => s.canyon == k - 1, k => s.canyon = k - 1); var fIds = c.fields.ConvertAll(d => d.id); y = TestRow(p, refreshers, y, "필드 (여러 개 선택)", c.fields.ConvertAll(d => d.name), k => s.fields.Contains(fIds[k]), k => Toggle(s.fields, fIds[k])); 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)); gIds.AddRange(c.powers.ConvertAll(d => d.id)); var gNames = c.skills.ConvertAll(d => d.name); gNames.AddRange(c.summons.ConvertAll(d => d.name)); gNames.AddRange(c.powers.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 (설계값 · 확정 전)", 26, Pal.Gray); note.rectTransform.Place(new Vector2(0, 1), new Vector2(1, 1), new Vector2(0, y - 6), new Vector2(-20, 80), 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); } /// 시험 줄 — 글 + 버튼(한 줄 최대 TestPerRow 개 · 넘치면 다음 줄) const int TestPerRow = 6; 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, 30, Pal.Ink, TextAnchor.MiddleLeft).rectTransform.Place(new Vector2(0, 1), new Vector2(1, 1), new Vector2(10, y), new Vector2(-20, 42), new Vector2(0.5f, 1)); y -= 44f; for (int start = 0, line = 0; start < names.Count; start += TestPerRow, line++) { var row = UI.Rect("R" + label + line, p.body).Place(new Vector2(0, 1), new Vector2(1, 1), new Vector2(0, y), new Vector2(0, 78), new Vector2(0.5f, 1)); var hl = row.gameObject.AddComponent(); hl.spacing = 8; hl.childControlWidth = true; hl.childControlHeight = true; hl.childForceExpandWidth = true; hl.childForceExpandHeight = true; for (int i = start; i < Mathf.Min(names.Count, start + TestPerRow); i++) { int k = i; var b = UI.TextBtn(row, "B" + i, names[i], Pal.Gray, 24, () => { click(k); p.refresh?.Invoke(); }, out _); refreshers.Add(() => UI.SetBtnColor(b, isOn(k) ? Pal.Pink : Pal.Gray)); } y -= 86f; } return y - 8f; } // ---------------- 진행 · 갱신 ---------------- /// 에디터 배치 검증용 — 재생 중이 아닐 때 애니 프레임을 흘릴 시간(초/틱) · 음수 = 정지 (EditorRender.AnimSeq) public static float AnimDtOverride = -1f; 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.ShieldUp ? (Color)new Color32(90, 160, 255, 255) : B.CastleHealFlash > 0f || B.TotemHeal > 0f ? (Color)new Color32(90, 230, 120, 255) : Pal.Red; castleHpText.text = Mathf.CeilToInt(B.CastleHp) + (B.ShieldUp ? $" +{Mathf.CeilToInt(B.Shield)}" : ""); 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 > 0 ? $" · 바위·언덕 {B.Map.Obstacles.Count}" : "") + $"\n필드 {FieldNames()}\n깃발 맵을 누르면 유닛이 깃발을 거쳐 성으로"; RefreshRival(); RefreshStageFx(); 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); } float adt = Application.isPlaying ? Time.unscaledDeltaTime : Mathf.Max(0f, AnimDtOverride), fps = UnitArt.Fps; 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 = new UnitView { rt = im.rectTransform, img = im, hp = hf, stun = st, art = UnitArt.ArtOf(u.def.id), baseSprite = im.sprite, lastPos = u.pos, lastCd = u.cd, sz = sz }; 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.img.color = UnitTint(u); // 맞음 · 얼림 · 화상 · 중독 · 저주 · 진흙 · 감속 빛깔(GameApp.StageFx.cs) // 애니 프레임 — 공격: 타격(cd 가 interval 로 되돌아간 순간)에 hit 프레임이 오도록 cd 로 예비 동작을 맞추고 타격 뒤 마무리 프레임 · 걷기: 자리가 바뀌는 동안(나는 유닛은 항상) 루프 · 그 외 = 걷기 0번 · 프레임 없는 유닛 = 예전 흔들림 var walk = v.art > 0 ? UnitArt.Frames(v.art, "walk") : null; var atk = v.art > 0 ? UnitArt.Frames(v.art, "attack") : null; var idle = v.art > 0 ? UnitArt.Frames(v.art, "idle") : null; bool moving = (u.pos - v.lastPos).sqrMagnitude > 1e-8f; v.lastPos = u.pos; if (u.cd > v.lastCd + 1e-4f) v.sinceHit = 0f; else v.sinceHit += adt; v.lastCd = u.cd; if (u.attacking && !u.Frozen && atk != null) // 기절·얼림 중엔 멈춘 자세 { int h = UnitArt.HitFrame(v.art, atk.Length), f = -1; float afps = UnitArt.ClipFps(v.art, "attack"); // 공격은 클립 fps(15 · PD 2026-10-31 「조금 더 빨라야」) · 없으면 전역 10 // 공격 클립 = 5 프레임 안(0 = 대기 자세 · 예비 · 타격 · 마무리) · 고정 fps 로 짧게 재생(PD 2026-10-30 「공격은 최대 5 프레임 · 과한 역동성 금지」) · 타격 전후 밖의 시간 = 0 번(대기) if (v.sinceHit * afps < atk.Length - h) f = h + (int)(v.sinceHit * afps); // 타격 틱 = hit 프레임 → 마무리 (h+1 로 시작하면 hit 프레임이 건너뛰어짐) else { f = h - Mathf.CeilToInt(u.cd * afps); if (f < 0) f = 0; } // 다음 타격 직전 예비 동작 · 그 전 = 0 번 v.img.sprite = atk[Mathf.Clamp(f, 0, atk.Length - 1)]; v.act = "attack"; v.rt.localScale = Vector3.one; } else if ((moving || u.def.flying) && !u.Frozen && walk != null) { if (v.act != "walk") v.t = 0f; v.t += adt; int wf = (int)(v.t * fps); v.img.sprite = walk[wf % walk.Length]; v.act = "walk"; v.rt.localScale = Vector3.one; // 들썩임은 걷기 프레임 안에 있음(CONTACT·DOWN·PASSING·UP · 2026-10-30 pixel-art-principles) — 코드 들썩임은 뺌(겹치면 2칸 튐) } else if (walk != null || atk != null || idle != null) { // 대기 = idle 클립이 있으면 루프(③ 원화 스타일 409·105·416 · 2026-10-30 PD 승인) · 없으면 걷기 0번 if (idle != null) { if (v.act != "idle") v.t = 0f; v.t += adt; v.img.sprite = idle[(int)(v.t * fps) % idle.Length]; } else v.img.sprite = walk != null ? walk[0] : v.baseSprite; v.act = "idle"; v.rt.localScale = Vector3.one; } else v.rt.localScale = Vector3.one * (u.attacking ? 1f + 0.08f * Mathf.Sin(Time.unscaledTime * 18f) : 1f); // 그림 폭 = 스프라이트 비율(세로 = sz 고정 · 발밑 pivot) — ③ 긴 무기 유닛의 128×96 프레임(409 대검)이 눌리지 않게(2026-10-30) if (v.img.sprite != null) { var rr = v.img.sprite.rect; float wv = v.sz * rr.width / Mathf.Max(1f, rr.height); if (Mathf.Abs(v.rt.sizeDelta.x - wv) > 0.5f) v.rt.sizeDelta = new Vector2(wv, v.sz); } 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, MinionArt(m.def), 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; case "beam": // 레이저 수호병 광선 — 청록 { var d = TileVec(f.b - f.a); im.sprite = BeamSprite(); rt.pivot = new Vector2(0f, 0.5f); rt.anchoredPosition = TileToLocal(f.a) + new Vector2(0f, 0.6f * V.tileY); 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(0.35f, 1f, 0.95f, 1f - k * 0.7f); break; } case "nova": // 얼음 파동 — 성에서 퍼지는 하늘색 고리 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.35f + 0.7f * k); im.color = new Color(0.7f, 0.93f, 1f, 1f - k); break; case "rubble": // 바위가 멈춘 자리 — 흙먼지 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) * (1f + 0.4f * k); im.color = new Color(0.6f, 0.52f, 0.42f, 0.85f * (1f - k)); break; case "shield": // 보호막 켜짐 — 파란 고리 im.sprite = RingSprite(); rt.pivot = new Vector2(0.5f, 0.5f); rt.anchoredPosition = TileToLocal(f.a); rt.sizeDelta = TileVec(Vector2.one * f.r * 2f) * (1f + 0.5f * k); im.color = new Color(0.45f, 0.75f, 1f, 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 = "다시 도전"; } } } }