전투 탭 = 스테이지 선택 화면 + 스테이지 단위 진행 — PD 지시(참고 화면: 스테이지 고르기)

- 전투 탭을 누르면 스테이지 선택부터: 「번호. 이름」 · 상대 성 체력·공격 · 성 + 상대방 얼굴 + 성 공격 그림 · 좌우 화살표 · 시작 = 바로 전투 · 지도 = 스테이지 정보 · 오른쪽 아래 = 승리 보상
- 스테이지 = battle.json stageLoadouts 한 줄(이름 추가 · 7개) · 다음에 깰 스테이지까지 열림 · 클리어 표시 · 시험 전투 승리는 기록 안 함
- 결과 덮개 = 스테이지 선택 / 다음 스테이지·다시 도전 · 전투 화면 라벨 = 스테이지 N
- 화면 비율(클래시 로얄) 수치 = battle.json view
- 헤드리스 163/163 · 렌더 ALL PASS(48장 · 스테이지 화면 검사 포함)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
깃 관리자 2026-10-29 22:58:23 +09:00
parent e31e624c09
commit 848a5c53ef
7 changed files with 304 additions and 45 deletions

View File

@ -1,6 +1,7 @@
using System;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.UI;
using VS.Sim;
using VS.View;
@ -38,8 +39,14 @@ namespace VS.EditorTools
{
var g = app.G;
app.BattleTest = null;
app.SwitchTab(1); app.NewBattleRound(5); app.RefreshAll(); Shot(app, "battle_ready");
app.B.Start(); app.B.Mana = 10;
// 스테이지 선택 — 전투 탭 = 고르는 화면부터(첫 화면 = 다음에 깰 스테이지 1) · 잠긴 스테이지 · 지도 = 정보 창 · 시작 = 바로 전투
app.SwitchTab(1); app.RefreshAll();
StageCheck(app, "stage_select", 1, true); Shot(app, "stage_select");
app.ShowStage(3); StageCheck(app, "stage_locked", 3, false); Shot(app, "stage_locked");
app.OpenStageInfo(); Shot(app, "stage_info"); app.ClosePopup();
app.StartStage(1, 5);
if (app.StageRoot.gameObject.activeSelf || !app.BattleRoot.gameObject.activeSelf || app.B.State != BattleState.Fighting || app.B.Stage != 1) { fails++; notes.Add("stage_start: 시작을 눌렀는데 전투 화면이 아님"); }
app.B.Mana = 10;
app.DeployCard(0); app.DebugTick(0.05f, 40); app.DeployCard(0); app.DebugTick(0.05f, 40); app.DeployCard(0);
g.S.battleSlots = 6; app.B.MaxMana = 15; app.B.Mana = 15;
app.DeployCard(1); app.DeployCard(4); app.DeployCard(5);
@ -50,6 +57,9 @@ namespace VS.EditorTools
app.OpenBattleUpgrade(); Shot(app, "battle_upgrade"); app.ClosePopup();
app.OpenBattleTest(); Shot(app, "battle_test"); app.ClosePopup();
app.B.CastleHpDebugKill(); app.DebugTick(0.05f, 2); Shot(app, "battle_won");
// 이긴 뒤 스테이지 선택 = 1 클리어 표시 · 2 열림(첫 화면)
app.OpenStageSelect(1); StageCheck(app, "stage_cleared", 1, true); Shot(app, "stage_cleared");
app.OpenStageSelect(app.G.StageFocus); StageCheck(app, "stage_next", 2, true); Shot(app, "stage_next");
// 맵 4종 (바위·언덕 켬 · 성 공격 끔 — 길 찾기 장면)
string[] mapKey = { "plain", "b1", "b2", "b3" };
for (int k = 0; k < 4; k++)
@ -125,6 +135,17 @@ namespace VS.EditorTools
else Debug.Log($"[VS] LAYOUT {name} 맵 {playW:0}/{w:0} px({playW / Mathf.Max(1f, w) * 100f:0}%) · 칸 {v.tileX:0}×{v.tileY:0} · 카드 칸 {tray.sizeDelta.y:0} · 맵 {bottom:0}~{top:0}/{h:0} · 가장 작은 유닛 그림 {minUnit:0} px");
}
/// 스테이지 선택 화면 — 보임(전투 화면은 숨김) · 제목 「번호. 이름」 · 시작 버튼 열림/잠김 · 화살표(처음 = 왼쪽 없음 · 끝 = 오른쪽 없음) · 클리어 표시
static void StageCheck(GameApp app, string name, int stage, bool open)
{
var root = app.StageRoot; int n = app.G.StageCount;
var title = root.Find("Title")?.GetComponent<Text>(); var start = root.Find("Start")?.GetComponent<Button>();
bool prev = root.Find("Prev").gameObject.activeSelf, next = root.Find("Next").gameObject.activeSelf, clear = root.Find("Clear").gameObject.activeSelf;
bool ok = root.gameObject.activeSelf && !app.BattleRoot.gameObject.activeSelf && title != null && title.text.StartsWith(stage + ". ") && start != null && start.interactable == open
&& prev == (stage > 1) && next == (stage < n) && clear == app.G.StageCleared(stage);
if (!ok) { fails++; notes.Add($"{name}: 스테이지 선택 화면 이상 (제목 {title?.text} · 시작 {start?.interactable} · 화살표 {prev}/{next} · 클리어 {clear})"); }
}
static List<string> L(params string[] s) => new List<string>(s);
static int MaxMinions(Battle b) { int n = 0; foreach (var s in b.Summoners) n += s.def.max; return n; }

View File

@ -120,6 +120,21 @@ namespace VS.EditorTools
var wb = AutoBattle(g10, 1, 1, 10, 5f, out _);
var coin0 = g10.Coin; var reward = g10.BattleReward(1);
Check("B10.reward", g10.WinBattle(wb) == reward && g10.Coin == coin0 + reward && g10.BattleStage == 2 && g10.NewBattle().CastleMaxHp > wb.CastleMaxHp);
// B11 스테이지 — 줄마다 이름 · 다음에 깰 스테이지까지 열림 · 고른 스테이지로 전투 · 지난 스테이지·시험 승리는 진행 그대로 · 마지막을 깨면 전부 클리어
int stN = c.StageCount; bool stNamed = stN > 0;
for (int st = 1; st <= stN; st++) stNamed &= !string.IsNullOrEmpty(c.StageName(st));
Check("B11.stages", stNamed && stN == c.stageLoadouts.Count, $"stages={stN}");
var g11 = NewGame(t, out _);
Check("B11.open", g11.StageOpen(1) && !g11.StageOpen(2) && !g11.StageCleared(1) && g11.StageFocus == 1);
var stPick = g11.NewBattle(3, 7, null);
Check("B11.pick", stPick.Stage == 3 && stPick.CastleMaxHp == c.CastleHpAt(3) && Mathf.Approximately(stPick.Atk, c.CastleAtkAt(3)), $"hp={stPick.CastleMaxHp}");
Battle WonAt(Game gg, int st, BattleSetup set = null) { var w = gg.NewBattle(st, 7, set); w.Start(); w.CastleHpDebugKill(); w.Tick(0.05f); w.Tick(0.05f); return w; }
g11.S.battleStage = 3; // 1 · 2 클리어
var stReplay = WonAt(g11, 1); var stReward = g11.WinBattle(stReplay);
g11.WinBattle(WonAt(g11, 3, Fixed(L("normal"), L()))); // 시험 설정으로 이김 = 기록 안 함
Check("B11.replay_test", stReplay.State == BattleState.Won && stReward == g11.BattleReward(1) && g11.BattleStage == 3 && g11.StageCleared(2) && g11.StageOpen(3) && !g11.StageOpen(4), $"stage={g11.BattleStage}");
g11.S.battleStage = stN; g11.WinBattle(WonAt(g11, stN));
Check("B11.all_clear", g11.BattleStage == stN + 1 && g11.StageCleared(stN) && g11.StageOpen(stN) && g11.StageFocus == stN);
MapTests(c);
CastleTests(c);

View File

@ -79,14 +79,15 @@
{ "id": "summonMelee", "name": "소환(근접)", "ranged": false, "interval": 8, "max": 2, "hp": 6000, "damage": 400, "attackRange": 0.7, "attackInterval": 1.6, "speed": 1.3, "guardRange": 4, "radius": 0.45, "homeDist": 2.2, "shotSpeed": 0 },
{ "id": "summonRanged", "name": "소환(원거리)", "ranged": true, "interval": 10, "max": 2, "hp": 5400, "damage": 400, "attackRange": 3.5, "attackInterval": 2.0, "speed": 1.1, "guardRange": 5, "radius": 0.4, "homeDist": 1.2, "shotSpeed": 10 }
],
"stageSource": "스테이지 = 아래 stageLoadouts 한 줄(2026-10-29 PD 「전투 메뉴 = 스테이지 고르기 · 앞으로 스테이지 단위로 하나씩 클리어」) · 다음에 깰 스테이지까지 열림 · 줄을 더하면 스테이지가 늘어남 · 이름 = 그 성의 대표 공격으로 지음(확정 전)",
"stageLoadouts": [
{ "stage": 1, "shots": ["normal"], "gimmicks": [] },
{ "stage": 2, "shots": ["normal"], "gimmicks": ["fireball"] },
{ "stage": 3, "shots": ["normal"], "gimmicks": ["fireball", "summonMelee"] },
{ "stage": 4, "shots": ["normal", "slow"], "gimmicks": ["summonMelee"] },
{ "stage": 5, "shots": ["normal", "multi"], "gimmicks": ["lightning", "summonMelee"] },
{ "stage": 6, "shots": ["normal", "splash"], "gimmicks": ["fireball", "summonRanged"] },
{ "stage": 7, "shots": ["normal", "laser"], "gimmicks": ["fireball", "lightning", "summonMelee", "summonRanged"] }
{ "stage": 1, "name": "첫 번째 성", "shots": ["normal"], "gimmicks": [] },
{ "stage": 2, "name": "불꽃의 성", "shots": ["normal"], "gimmicks": ["fireball"] },
{ "stage": 3, "name": "소환사의 성", "shots": ["normal"], "gimmicks": ["fireball", "summonMelee"] },
{ "stage": 4, "name": "얼음의 성", "shots": ["normal", "slow"], "gimmicks": ["summonMelee"] },
{ "stage": 5, "name": "번개의 성", "shots": ["normal", "multi"], "gimmicks": ["lightning", "summonMelee"] },
{ "stage": 6, "name": "폭발의 성", "shots": ["normal", "splash"], "gimmicks": ["fireball", "summonRanged"] },
{ "stage": 7, "name": "최후의 성", "shots": ["normal", "laser"], "gimmicks": ["fireball", "lightning", "summonMelee", "summonRanged"] }
],
"testRangeMuls": [1, 2, 4],
"view": {

View File

@ -22,7 +22,7 @@ namespace VS.Sim
}
/// 성 소환수 — 성 둘레 집에 주둔 · 집에서 guardRange 안에 들어온 내 유닛만 공격 · 종류별 최대 max
[Serializable] public class BSummonDef { public string id, name; public bool ranged; public int max; public float interval, hp, damage, attackRange, attackInterval, speed, guardRange, radius, homeDist, shotSpeed; }
[Serializable] public class BLoadout { public int stage; public string[] shots, gimmicks; }
[Serializable] public class BLoadout { public int stage; public string name; public string[] shots, gimmicks; } // 스테이지 한 줄 = 번호 · 이름 · 성 투사체 · 기믹
[Serializable] public class BBridgeSet { public float[] x; }
/// 전투 화면 비율(화면 전용 · 시뮬 무관) — 칸 1개 = 가로 tileX · 세로 tileY px · 맵 좌우 장식 sideTiles 칸 · 아래 카드 칸 trayH px · 유닛 그림 한 변 = 반지름 × unitArt 칸 · 나는 유닛 = flyLift 칸 위
[Serializable] public class BViewDef { public float tileX, tileY, sideTiles, trayH, unitArt, flyLift; }
@ -57,6 +57,14 @@ namespace VS.Sim
if (stageLoadouts != null) foreach (var l in stageLoadouts) if (l.stage <= stage && (best == null || l.stage > best.stage)) best = l;
return best ?? new BLoadout { shots = new string[0], gimmicks = new string[0] };
}
// 스테이지 = stageLoadouts 줄 수만큼(하나씩 깨 나감) · 성 능력치는 스테이지마다 hpGrowth·atkGrowth·summonGrowth 배
public int StageCount { get { int n = 0; if (stageLoadouts != null) foreach (var l in stageLoadouts) n = Math.Max(n, l.stage); return n; } }
public string StageName(int stage) => stageLoadouts?.Find(l => l.stage == stage)?.name ?? "";
public float CastleHpAt(int stage) => Mathf.Round(castle.hp * Mathf.Pow(castle.hpGrowth, stage - 1));
public float CastleAtkAt(int stage) => castle.atk * Mathf.Pow(castle.atkGrowth, stage - 1);
public float SummonMulAt(int stage) => Mathf.Pow(castle.summonGrowth, stage - 1);
public int RivalOutfitAt(int stage) => rivalOutfits != null && rivalOutfits.Length > 0 ? rivalOutfits[(stage - 1) % rivalOutfits.Length] : 0;
}
/// 시험 설정(전투 화면 「시험」) — 음수·null = 무작위 · 단계 기본
@ -343,8 +351,8 @@ namespace VS.Sim
public Vector2 SpawnPoint => new Vector2(C.arenaW * 0.5f, C.spawnY);
public bool Enraged => CastleHp <= CastleMaxHp * C.castle.enrageHpRatio;
public float IntervalMul => Enraged ? C.castle.enrageIntervalMul : 1f;
public float SummonMul => Mathf.Pow(C.castle.summonGrowth, Stage - 1);
public int RivalOutfit => C.rivalOutfits != null && C.rivalOutfits.Length > 0 ? C.rivalOutfits[(Stage - 1) % C.rivalOutfits.Length] : 0;
public float SummonMul => C.SummonMulAt(Stage);
public int RivalOutfit => C.RivalOutfitAt(Stage);
public bool LastMinute => TimeLeft <= C.lastMinuteSeconds;
public bool IsTest => Setup != null;
public string MapName => C.MapName(Map.Type);
@ -354,8 +362,8 @@ namespace VS.Sim
public Battle(BattleCfg c, int stage, int maxMana, int seed = 7, BattleSetup setup = null)
{
C = c; Stage = Math.Max(1, stage); MaxMana = maxMana; rng = new System.Random(seed); Setup = setup;
CastleMaxHp = CastleHp = Mathf.Round(c.castle.hp * Mathf.Pow(c.castle.hpGrowth, Stage - 1));
Atk = c.castle.atk * Mathf.Pow(c.castle.atkGrowth, Stage - 1);
CastleMaxHp = CastleHp = c.CastleHpAt(Stage);
Atk = c.CastleAtkAt(Stage);
RangeMul = setup != null && setup.rangeMul > 0f ? setup.rangeMul : 1f;
TimeLeft = c.battleSeconds; Mana = Mathf.Min(c.manaStart, maxMana);
Map = BMap.Generate(c, rng, setup, SpawnPoint);
@ -760,6 +768,13 @@ namespace VS.Sim
}
public Battle NewBattle(int seed = 7, BattleSetup setup = null) => new Battle(BC, BattleStage, BattleMaxMana, seed, setup);
public Battle NewBattle(int stage, int seed, BattleSetup setup) => new Battle(BC, stage, BattleMaxMana, seed, setup);
// 스테이지 진행 — 다음에 깰 스테이지(BattleStage)까지 열림 · 그 앞은 클리어 · 전부 깨면 BattleStage = StageCount + 1
public int StageCount => BC.StageCount;
public bool StageCleared(int stage) => stage >= 1 && stage < BattleStage;
public bool StageOpen(int stage) => stage >= 1 && stage <= Math.Min(BattleStage, StageCount);
public int StageFocus => Math.Max(1, Math.Min(BattleStage, StageCount)); // 스테이지 선택 첫 화면 = 다음에 깰 스테이지(다 깼으면 마지막)
/// 승리 보상 = 방치 수입(PassGpsClean) × (기본 + 단계당) 초분 — 설계값
public BigInteger BattleReward(int stage) => new BigInteger(decimal.Ceiling(PassGpsClean() * (decimal)(BC.rewardPassSeconds + BC.rewardPassSecondsPerStage * (stage - 1))));
@ -769,7 +784,7 @@ namespace VS.Sim
if (b == null || b.State != BattleState.Won) return BigInteger.Zero;
var r = BattleReward(b.Stage);
AddCoin(r);
if (b.Stage >= BattleStage) S.battleStage = b.Stage + 1;
if (b.Stage == BattleStage && !b.IsTest) S.battleStage = b.Stage + 1; // 다음 스테이지 열기 = 지금 깰 차례인 스테이지를 시험 설정 없이 이겼을 때만(하나씩)
return r;
}
}

View File

@ -64,10 +64,13 @@ namespace VS.View
public void SwitchTab(int t)
{
Tab = t;
// 전투 탭 = 싸우는 중이면 전투 화면 · 아니면 스테이지 선택(다음에 깰 스테이지부터)
bool fighting = B != null && B.State == BattleState.Fighting;
if (StreamRoot != null) StreamRoot.gameObject.SetActive(t == 0);
if (BattleRoot != null) BattleRoot.gameObject.SetActive(t == 1);
if (BattleRoot != null) BattleRoot.gameObject.SetActive(t == 1 && fighting);
if (StageRoot != null) StageRoot.gameObject.SetActive(t == 1 && !fighting);
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 && (B == null || B.State != BattleState.Fighting)) NewBattleRound();
if (t == 1 && !fighting) ShowStage(G.StageFocus);
CloseAllPopups();
}
@ -390,7 +393,7 @@ namespace VS.View
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);
@ -399,23 +402,26 @@ namespace VS.View
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 ob = UI.TextBtn(card.transform, "Go", "", Pal.Green, 52, () => OnOverlayButton(), out overlayBtnLabel);
ob.GetComponent<RectTransform>().Place(new Vector2(0.5f, 0), new Vector2(0.5f, 0), new Vector2(0, 90), new Vector2(460, 150));
// 시험 버튼(덮개 위 — 전투 시작 전에도 누를 수 있게)
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();
}
void NewBattleRound() => NewBattleRound(Random.Range(1, 99999));
/// 고른 스테이지(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(seed, BattleTest);
B = G.NewBattle(stageSel, seed, BattleTest);
if (Tab == 1) { BattleRoot.gameObject.SetActive(true); StageRoot.gameObject.SetActive(false); }
rewardGiven = false; lastBattleReward = BigInteger.Zero;
var old = arenaImg.sprite;
arenaImg.sprite = ArenaSprite(B);
@ -459,12 +465,15 @@ namespace VS.View
}
/// 피해 = 성 공격력 × dmgPct % (battle.json) · 사거리 = 시험 배율 반영
string RivalInfo(BAttackDef d)
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(B.Atk * d.dmgPct / 100f), sp = Mathf.RoundToInt(B.Atk * d.splashPct / 100f);
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 + " · " : "") + $"{B.Range(d):0.#}칸";
return a + "\n" + (b.Length > 0 ? b + " · " : "") + $"{range:0.#}칸";
}
void RefreshRival()
@ -505,14 +514,15 @@ namespace VS.View
BigInteger lastBattleReward;
/// 결과 오른쪽 버튼 — 이겼고 다음 스테이지가 열렸으면 다음 스테이지 · 아니면 같은 스테이지 다시
void OnOverlayButton()
{
if (B == null) return;
if (B.State == BattleState.Ready) B.Start();
else NewBattleRound();
RefreshBattle();
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; }
@ -554,7 +564,7 @@ namespace VS.View
// ---------------- 시험 설정: 맵 · 바위·언덕 · 성 투사체 · 성 기믹 · 사거리 배율 → 다음 전투 ----------------
BattleSetup StageDefaultSetup()
{
var lo = T.Battle.Loadout(G.BattleStage);
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]) };
}
@ -578,11 +588,12 @@ namespace VS.View
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", "적용하면 새 전투가 준비됩니다 · 수치 = battle.json (설계값 · 확정 전)", 28, Pal.Gray);
note.rectTransform.Place(new Vector2(0, 1), new Vector2(1, 1), new Vector2(0, y - 30), new Vector2(-20, 70), new Vector2(0.5f, 1));
var reset = UI.TextBtn(p.body, "Reset", "단계 기본으로", Pal.Gray, 38, () => { BattleTest = null; CloseAllPopups(); NewBattleRound(); }, out _);
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(); NewBattleRound(); }, out _);
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();
@ -622,7 +633,7 @@ namespace VS.View
void RefreshBattle()
{
if (B == null || castleImg == null) return;
stageText.text = $"성 Lv.{B.Stage}" + (B.IsTest ? " <color=#9CF0FF>시험</color>" : "") + (B.LastMinute && B.State == BattleState.Fighting ? " <color=#FF9CF5>마나 x2</color>" : "");
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();
@ -770,16 +781,16 @@ namespace VS.View
break;
}
}
// 덮개
overlay.gameObject.SetActive(B.State != BattleState.Fighting);
if (B.State == BattleState.Ready)
{
overlayTitle.text = $"성 Lv.{B.Stage}" + (B.IsTest ? " (시험)" : "");
overlayInfo.text = $"성 체력 {Mathf.CeilToInt(B.CastleMaxHp)} · 제한 {Mathf.RoundToInt(B.C.battleSeconds / 60)}분\n맵: {B.MapName} · 바위·언덕 {B.Map.Obstacles.Count}\n성 투사체: {JoinNames(B.ShotNames(), " · ")}\n성 기믹: {JoinNames(B.GimmickNames(), " · ")}\n카드 = 아래 가운데에서 바로 소환 · 맵을 누르면 깃발\n승리 보상 ≈ {Fmt.Num(G.BattleReward(B.Stage))} 코인";
overlayBtnLabel.text = "전투 시작";
}
// 결과 덮개(승리·패배만 — 시작 정보는 스테이지 선택 화면의 지도 버튼)
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) { overlayTitle.text = "승리!"; overlayInfo.text = $"보상 {Fmt.Num(lastBattleReward)} 코인\n다음: 성 Lv.{G.BattleStage}"; overlayBtnLabel.text = "다음 전투"; }
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 = "다시 도전"; }
}
}

View File

@ -0,0 +1,194 @@
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.Events;
using UnityEngine.UI;
using VS.Sim;
// 스테이지 선택 — 「전투」 탭을 누르면 먼저 나오는 화면(2026-10-29 PD 지시 · 참고 = 숲 문 그림의 스테이지 고르기 화면)
// 위 = 「번호. 이름」 · 상대 성 체력·공격 · 가운데 = 성 + 상대방 얼굴 + 성 공격 그림 · 좌우 화살표 = 스테이지 넘기기 · 「시작」 = 바로 전투
// 오른쪽 위 지도 = 스테이지 정보 · 오른쪽 아래 = 승리 보상 · 다음에 깰 스테이지까지 열림(뒤는 잠김) · 스테이지 = battle.json stageLoadouts 한 줄
namespace VS.View
{
public partial class GameApp
{
public RectTransform StageRoot;
int stageSel = 1; // 고른 스테이지 = 다음 전투 스테이지
Text stageTitle, stageStat, stageHint, stageBubble, stageReward, stageStartLabel;
Image stageCastle, stageFrame, stageFace, stageLock, stageClear;
Button stageStart, stagePrev, stageNext;
RectTransform stageIcons;
void BuildStageScreen()
{
StageRoot = UI.Rect("Stage", Root).Fill(0, TabH, 0, 0);
UI.Img(StageRoot, "Bg", StageBgSprite(), Color.white).rectTransform.Fill();
// 위: 시험(왼쪽) · 지도 = 스테이지 정보(오른쪽)
var tb = UI.TextBtn(StageRoot, "TestBtn", "시험", Pal.Purple, 30, () => OpenBattleTest(), out _);
tb.GetComponent<RectTransform>().Place(new Vector2(0, 1), new Vector2(0, 1), new Vector2(20, -24), new Vector2(130, 72), new Vector2(0, 1));
var info = UI.Btn(StageRoot, "Info", Skin.Icon("map"), Color.white, OpenStageInfo);
info.GetComponent<Image>().preserveAspect = true;
info.GetComponent<RectTransform>().Place(new Vector2(1, 1), new Vector2(1, 1), new Vector2(-84, -78), new Vector2(124, 124));
UI.Txt(info.transform, "T", "정보", 30, Color.white, TextAnchor.MiddleCenter, true).rectTransform.Place(new Vector2(0.5f, 0), new Vector2(0.5f, 0), new Vector2(0, -4), new Vector2(150, 42));
// 제목 · 상대 성 체력·공격(참고 화면의 추천 레벨 자리 — 이 게임은 전투 레벨이 없어 상대 성 능력치)
stageTitle = UI.Txt(StageRoot, "Title", "", 76, Color.white, TextAnchor.MiddleCenter, true);
stageTitle.rectTransform.Place(new Vector2(0.5f, 1), new Vector2(0.5f, 1), new Vector2(0, -210), new Vector2(980, 120));
UI.Txt(StageRoot, "StatLabel", "상대 성", 36, new Color32(80, 240, 255, 255), TextAnchor.MiddleCenter, true).rectTransform.Place(new Vector2(0.5f, 1), new Vector2(0.5f, 1), new Vector2(0, -300), new Vector2(600, 56));
var pill = UI.Img(StageRoot, "StatPill", Skin.Pill, new Color32(50, 54, 82, 255));
pill.rectTransform.Place(new Vector2(0.5f, 1), new Vector2(0.5f, 1), new Vector2(0, -382), new Vector2(620, 100));
stageStat = UI.Txt(pill.transform, "T", "", 42, Color.white, TextAnchor.MiddleCenter, true); stageStat.rectTransform.Fill(36, 8, 36, 8);
// 가운데 그림: 땅 그림자 · 성 · 상대방 얼굴 · 성 공격 그림 줄 · 잠김 · 클리어
UI.Img(StageRoot, "Ground", Art.SoftCircle, new Color(0.08f, 0.28f, 0.1f, 0.4f)).rectTransform.Place(new Vector2(0.5f, 1), new Vector2(0.5f, 1), new Vector2(0, -1020), new Vector2(720, 170));
stageCastle = UI.Img(StageRoot, "Castle", CastleSprite(false), Color.white);
stageCastle.rectTransform.Place(new Vector2(0.5f, 1), new Vector2(0.5f, 1), new Vector2(0, -800), new Vector2(400, 440));
stageFrame = UI.Img(StageRoot, "FaceFrame", Skin.ProfileFrame, new Color32(255, 110, 150, 255));
stageFrame.rectTransform.Place(new Vector2(0.5f, 1), new Vector2(0.5f, 1), new Vector2(250, -640), new Vector2(170, 170));
UI.Img(stageFrame.transform, "In", Skin.ProfileInner, new Color32(255, 236, 244, 255)).rectTransform.Fill(12, 12, 12, 12);
stageFace = UI.Img(stageFrame.transform, "Face", null, Color.white); stageFace.preserveAspect = true; stageFace.rectTransform.Fill(14, 14, 14, 14);
stageIcons = UI.Rect("Icons", StageRoot).Place(new Vector2(0.5f, 1), new Vector2(0.5f, 1), new Vector2(0, -1140), new Vector2(760, 96));
var hl = stageIcons.gameObject.AddComponent<HorizontalLayoutGroup>();
hl.spacing = 16; hl.childAlignment = TextAnchor.MiddleCenter; hl.childControlWidth = false; hl.childControlHeight = false; hl.childForceExpandWidth = false; hl.childForceExpandHeight = false;
stageLock = UI.Icon(StageRoot, "Lock", Skin.Icon("lock"), Color.white);
stageLock.rectTransform.Place(new Vector2(0.5f, 1), new Vector2(0.5f, 1), new Vector2(0, -810), new Vector2(150, 200));
stageClear = UI.Img(StageRoot, "Clear", Skin.S("Label_Parallelogram_Green") ?? Skin.Pill, Color.white);
stageClear.rectTransform.Place(new Vector2(0.5f, 1), new Vector2(0.5f, 1), new Vector2(-240, -610), new Vector2(240, 80));
UI.Txt(stageClear.transform, "T", "클리어", 40, Color.white, TextAnchor.MiddleCenter, true).rectTransform.Fill(28, 4, 28, 4);
// 좌우 화살표 = 스테이지 넘기기(잠긴 스테이지도 넘겨 볼 수 있음)
stagePrev = StageArrow("Prev", "Icon_Arrow_Prev1", "<", () => ShowStage(stageSel - 1));
stagePrev.GetComponent<RectTransform>().Place(new Vector2(0, 1), new Vector2(0, 1), new Vector2(84, -800), new Vector2(84, 164));
stageNext = StageArrow("Next", "Icon_Arrow_Next1", ">", () => ShowStage(stageSel + 1));
stageNext.GetComponent<RectTransform>().Place(new Vector2(1, 1), new Vector2(1, 1), new Vector2(-84, -800), new Vector2(84, 164));
// 시작 = 바로 전투 · 아래 안내 한 줄
stageStart = UI.TextBtn(StageRoot, "Start", "시작", Pal.Yellow, 66, () => StartStage(stageSel), out stageStartLabel);
stageStart.GetComponent<RectTransform>().Place(new Vector2(0.5f, 1), new Vector2(0.5f, 1), new Vector2(0, -1290), new Vector2(430, 180));
stageHint = UI.Txt(StageRoot, "Hint", "", 32, Color.white, TextAnchor.MiddleCenter, true);
stageHint.rectTransform.Place(new Vector2(0.5f, 1), new Vector2(0.5f, 1), new Vector2(0, -1412), new Vector2(940, 48));
// 오른쪽 아래 = 승리 보상(말풍선 + 상자 + 코인)
var bub = UI.Img(StageRoot, "RewardBubble", Skin.S("BubbleFrame02_Bg") ?? Skin.Round, Color.white);
bub.rectTransform.Place(new Vector2(1, 0), new Vector2(1, 0), new Vector2(-200, 280), new Vector2(340, 96));
UI.Img(bub.transform, "Tail", Skin.S("Label_Bubble_Arrow"), Color.white).rectTransform.Place(new Vector2(0.5f, 0), new Vector2(0.5f, 0), new Vector2(40, -14), new Vector2(40, 33));
stageBubble = UI.Txt(bub.transform, "T", "", 34, Pal.Ink); stageBubble.rectTransform.Fill(18, 10, 18, 10);
UI.Icon(StageRoot, "Chest", Skin.Icon("chest"), Color.white).rectTransform.Place(new Vector2(1, 0), new Vector2(1, 0), new Vector2(-160, 140), new Vector2(170, 170));
stageReward = UI.Txt(StageRoot, "Reward", "", 40, Color.white, TextAnchor.MiddleCenter, true);
stageReward.rectTransform.Place(new Vector2(1, 0), new Vector2(1, 0), new Vector2(-160, 44), new Vector2(320, 54));
StageRoot.gameObject.SetActive(false);
}
/// 팩 화살표 그림 — 없으면 글자 버튼
Button StageArrow(string name, string sprite, string fallback, UnityAction click)
{
var sp = Skin.S(sprite);
if (sp == null) return UI.TextBtn(StageRoot, name, fallback, Pal.Purple, 60, click, out _);
var b = UI.Btn(StageRoot, name, sp, Color.white, click);
b.GetComponent<Image>().preserveAspect = true;
return b;
}
/// 고른 스테이지 보여 주기 — 다음에 깰 스테이지까지 열림 · 그 뒤는 잠김(넘겨 보기만)
public void ShowStage(int s)
{
var c = T.Battle; int n = Mathf.Max(1, G.StageCount);
stageSel = Mathf.Clamp(s, 1, n);
bool open = G.StageOpen(stageSel), clear = G.StageCleared(stageSel);
stageTitle.text = $"{stageSel}. {c.StageName(stageSel)}";
stageStat.text = $"체력 {c.CastleHpAt(stageSel):N0} · 공격 {Mathf.RoundToInt(c.CastleAtkAt(stageSel))}";
stageFace.sprite = RivalFace(c.RivalOutfitAt(stageSel), false);
Color dim = open ? Color.white : new Color(0.32f, 0.34f, 0.42f);
stageCastle.color = dim; stageFace.color = dim;
stageFrame.color = open ? (Color)new Color32(255, 110, 150, 255) : new Color(0.4f, 0.4f, 0.48f);
stageLock.gameObject.SetActive(!open);
stageClear.gameObject.SetActive(clear);
// 성 공격 그림 줄(투사체 + 기믹 · 자세한 수치는 지도 버튼)
var old = new List<GameObject>();
foreach (Transform ch in stageIcons) old.Add(ch.gameObject);
foreach (var o in old) { o.SetActive(false); Kill(o); }
var lo = c.Loadout(stageSel);
var ids = new List<string>(lo.shots ?? new string[0]);
if (lo.gimmicks != null) ids.AddRange(lo.gimmicks);
foreach (var id in ids)
{
var bg = UI.Img(stageIcons, "I_" + id, Skin.CirclePlain, new Color(0.08f, 0.1f, 0.2f, 0.55f));
bg.rectTransform.sizeDelta = new Vector2(92, 92);
UI.Icon(bg.transform, "Icon", SkillIcon(id), dim).rectTransform.Fill(12, 12, 12, 12);
}
stagePrev.gameObject.SetActive(stageSel > 1);
stageNext.gameObject.SetActive(stageSel < n);
stageStart.interactable = open;
UI.SetBtnColor(stageStart, open ? Pal.Yellow : Pal.Gray);
stageStartLabel.text = open ? "시작" : "잠김";
stageHint.text = BattleTest != null ? "시험 설정 적용 중 (시험 → 단계 기본으로 = 해제)" : !open ? "앞 스테이지를 깨면 열립니다" : clear ? "클리어한 스테이지 · 다시 할 수 있습니다" : "";
stageBubble.text = $"스테이지 {stageSel} 보상!";
stageReward.text = $"코인 {Fmt.Num(G.BattleReward(stageSel))}";
}
/// 스테이지 선택 화면으로(전투 탭 · 결과 덮개의 「스테이지 선택」)
public void OpenStageSelect(int s)
{
BattleRoot.gameObject.SetActive(false);
StageRoot.gameObject.SetActive(Tab == 1);
ShowStage(s);
}
/// 「시작」 — 열린 스테이지만 · 바로 전투
public void StartStage(int s, int seed = -1) { if (G.StageOpen(s)) BeginBattle(s, seed); }
/// 고른 스테이지로 새 전투를 만들고 바로 시작(시험 설정이 있으면 그것 · 시험은 잠긴 스테이지도 가능)
void BeginBattle(int s, int seed = -1)
{
stageSel = Mathf.Clamp(s, 1, Mathf.Max(1, G.StageCount));
NewBattleRound(seed < 0 ? Random.Range(1, 99999) : seed);
B.Start();
RefreshBattle();
}
/// 지도 버튼 = 스테이지 정보 — 상대 성 능력치 · 성 공격 설명 · 맵 · 제한 시간 · 보상 (수치 = battle.json)
public void OpenStageInfo()
{
var c = T.Battle; int s = stageSel; var lo = c.Loadout(s);
float atk = c.CastleAtkAt(s), sm = c.SummonMulAt(s);
var p = NewPopup("StageInfo", $"{s}. {c.StageName(s)}", 1050, Pal.Blue);
string H(string t) => $"<color=#3A6FD8>{t}</color>";
var lines = new List<string>
{
G.StageCleared(s) ? "클리어한 스테이지" : G.StageOpen(s) ? "도전할 스테이지" : "잠김 — 앞 스테이지를 깨면 열립니다",
$"{H("상대 성")} 체력 {c.CastleHpAt(s):N0} · 공격 {Mathf.RoundToInt(atk)}",
$"체력 {c.castle.enrageHpRatio * 100f:0}% 아래 = 분노(공격 간격 ×{c.castle.enrageIntervalMul:0.##}) · {c.castle.regenDelay:0}초 안 맞으면 {c.castle.regenInterval:0.#}초마다 {c.castle.regenPct:0.#}% 회복",
""
};
foreach (var id in lo.shots ?? new string[0]) { var d = c.Shot(id); if (d != null) lines.Add($"{H(d.name)} {AttackInfo(d, atk, d.range).Replace("\n", " · ")}"); }
foreach (var id in lo.gimmicks ?? new string[0])
{
var k = c.Skill(id); var m = c.Summon(id);
if (k != null) lines.Add($"{H(k.name)} {AttackInfo(k, atk, k.range).Replace("\n", " · ")} · {k.interval:0.#}초마다");
else if (m != null) lines.Add($"{H(m.name)} 체력 {Mathf.RoundToInt(m.hp * sm)} · 공격 {Mathf.RoundToInt(m.damage * sm)} · 최대 {m.max}마리 · {m.interval:0.#}초마다");
}
lines.Add("");
lines.Add($"{H("맵")} 무작위({string.Join(" · ", c.mapNames)}) · 바위·언덕 무작위");
lines.Add($"{H("제한 시간")} {Mathf.RoundToInt(c.battleSeconds / 60f)}분 · 마지막 {Mathf.RoundToInt(c.lastMinuteSeconds)}초 = 마나 ×{c.lastMinuteRegenMul:0}");
lines.Add($"{H("승리 보상")} 코인 {Fmt.Num(G.BattleReward(s))}");
var t = UI.Txt(p.body, "Info", string.Join("\n", lines), 34, Pal.Ink, TextAnchor.UpperLeft);
t.supportRichText = true; t.rectTransform.Fill(30, 30, 30, 24);
}
/// 배경 = 하늘(위 진한 파랑 → 지평선 하늘색) · 지평선 뒤 숲 그림자 · 풀밭 · 아래 짙은 덤불 (참고 화면 구도 · 코드 그림)
static Sprite StageBgSprite()
{
if (bcache.TryGetValue("stagebg", out var s)) return s;
const int W = 360, H = 590; const float hz = H * 0.47f; // 지평선 = 아래에서 47%
var r = new Raster(W, H);
Color skyTop = new Color32(84, 92, 250, 255), skyLow = new Color32(92, 196, 255, 255), grassNear = new Color32(150, 202, 66, 255), grassFar = new Color32(132, 192, 70, 255);
for (int y = 0; y < H; y++)
{
Color c = y >= hz ? Color.Lerp(skyLow, skyTop, Mathf.Pow((y - hz) / (H - hz), 0.8f)) : Color.Lerp(grassNear, grassFar, y / hz);
for (int x = 0; x < W; x++) r.Px[y * W + x] = c;
}
Color far = new Color(0.45f, 0.72f, 1f, 0.55f);
for (int i = 0; i < 7; i++) { float x = 18 + i * 54 + (i % 2) * 14, h = 70 + i * 37 % 40; r.Poly(new[] { new Vector2(x - 26, hz), new Vector2(x + 26, hz), new Vector2(x, hz + h) }, far); }
Color hill = new Color32(96, 176, 74, 255), hillDk = new Color32(70, 150, 84, 255);
r.Circle(30, hz + 4, 46, hillDk); r.Circle(80, hz - 4, 40, hill); r.Circle(330, hz + 2, 48, hillDk); r.Circle(282, hz - 6, 36, hill);
Color bush = new Color32(26, 102, 104, 255), bushDk = new Color32(18, 80, 88, 255);
for (int i = 0; i < 9; i++) r.Circle(i * 45 - 10, 34 + i % 3 * 14, 48 + i % 2 * 12, bush);
r.RRect(-4, -4, W + 4, 34, 2, bushDk);
s = r.Sprite(); bcache["stagebg"] = s; return s;
}
}
}

View File

@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 2a9da3af1bc38ec4f95a730531346c19