VSClone/Assets/Scripts/View/GameApp.Battle.cs

824 lines
61 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters

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

using System.Collections.Generic;
using 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<RivalRow> rivalRows = new List<RivalRow>();
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<BUnit, (RectTransform rt, Image hp, Image stun)> unitViews = new Dictionary<BUnit, (RectTransform, Image, Image)>();
readonly Dictionary<BMinion, (RectTransform rt, Image hp)> minionViews = new Dictionary<BMinion, (RectTransform, Image)>();
readonly List<Image> shotViews = new List<Image>(), fxViews = new List<Image>();
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<HorizontalLayoutGroup>();
hl.childControlWidth = true; hl.childControlHeight = true; hl.childForceExpandWidth = true; hl.padding = new RectOffset(20, 20, Skin.On ? 0 : 12, Skin.On ? 6 : 12); hl.spacing = 16;
string[] names = { L("UI.Tab.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<Image>(), 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) ShowUnits();
UpdateTabBar();
CloseAllPopups();
}
/// 전투 화면이 떠 있으면 아래 메뉴를 숨긴다(클래시 로얄처럼 전투는 화면 전체)
void UpdateTabBar() { if (tabBar != null) tabBar.gameObject.SetActive(BattleRoot == null || !BattleRoot.gameObject.activeSelf); }
// ---------------- 아레나 그림 (전투마다 맵에 맞춰 새로) ----------------
// 칸 → 화면 px: 가로 tileX · 세로 tileY (클래시 로얄처럼 비스듬히 내려다봐 땅의 세로가 짧다 · 땅 위 원 = 타원)
Vector2 TileToLocal(Vector2 tp) => new Vector2((tp.x - B.C.arenaW * 0.5f) * V.tileX, (tp.y - B.C.arenaH * 0.5f) * V.tileY);
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);
// 2026-10-30 그려진 지형(Resources/Art/Arena/arena_{river|plain}.png · 픽셀 도트 · 칸 ≈ 30 px · Krea 2 + k2-pixel32 · 같은 칸 좌표계)
// 이 그림이 있으면 풀밭·측면 장식·돌 턱·강은 그림으로 · 다리·장애물·앞마당·소환 고리는 맵마다 달라 아래 코드로 계속 그린다 · 없으면 예전 코드 그림
var bgTex = Resources.Load<Texture2D>("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(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;
if (bgTex == null) // 그려진 지형에는 강이 이미 있다 · 다리만 코드로
{
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<string, Sprite> bcache = new Dictionary<string, Sprite>();
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<EventTrigger>().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<RectTransform>().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<HorizontalLayoutGroup>();
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<RectTransform>().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<RectTransform>().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<RectTransform>().Place(new Vector2(0, 1), new Vector2(0, 1), new Vector2(14, -94), new Vector2(130, 72), new Vector2(0, 1));
BattleRoot.gameObject.SetActive(false);
BuildStageScreen();
BuildUnitScreen();
}
/// 고른 스테이지(stageSel)로 새 전투 준비 + 전투 화면 띄움 — 시작(B.Start)은 부르는 쪽
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<RectTransform>().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<RectTransform>().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<string>(lo.shots ?? new string[0]), gimmicks = new List<string>(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<System.Action>();
float y = -10f;
var mapNames = new List<string> { "무작위" }; 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<string> { "무작위", "없음", "있음" }, 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<float>(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<RectTransform>().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<RectTransform>().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<string> l, string id) { if (!l.Remove(id)) l.Add(id); }
float TestRow(Popup p, List<System.Action> refreshers, float y, string label, List<string> names, System.Func<int, bool> isOn, System.Action<int> 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<HorizontalLayoutGroup>();
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<string> l, string sep) => l.Count > 0 ? string.Join(sep, l) : "없음";
void RefreshBattle()
{
if (B == null || castleImg == null) return;
stageText.text = $"스테이지 {B.Stage}" + (B.IsTest ? " <color=#9CF0FF>시험</color>" : "") + (B.LastMinute && B.State == BattleState.Fighting ? " <color=#FF9CF5>마나 x2</color>" : "");
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 = $"<color=#FFE27A>맵</color> {B.MapName} · 바위·언덕 {B.Map.Obstacles.Count}\n<color=#FFE27A>깃발</color> 맵을 누르면 유닛이 깃발을 거쳐 성으로";
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<Image>();
if (Skin.On) { ci.sprite = open ? Skin.CardOn : Skin.CardOff; ci.color = !open ? new Color(0.8f, 0.8f, 0.86f) : (afford ? Color.white : new Color(0.55f, 0.6f, 0.72f)); }
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<BUnit>(B.Units);
var dead = new List<BUnit>();
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<Image>().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<BMinion>(B.Minions);
var deadM = new List<BMinion>();
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<Image>().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 = "다시 도전"; }
}
}
}