From 7c9143a655a3d5984d4a36a0fb04fbfa0011f58d Mon Sep 17 00:00:00 2001 From: swrring Date: Sat, 31 Oct 2026 19:29:10 +0900 Subject: [PATCH] =?UTF-8?q?=EC=8A=A4=ED=85=8C=EC=9D=B4=EC=A7=80=2050=20?= =?UTF-8?q?=E2=80=94=205=EC=B1=95=ED=84=B0=20=C3=97=2010=20=C2=B7=20?= =?UTF-8?q?=EC=A7=80=EB=8F=84=20=EA=B3=A0=EC=A0=95(=EB=8B=A4=EB=A6=AC=20?= =?UTF-8?q?=ED=8F=AD=201=C2=B72=C2=B74=EC=B9=B8=20=C2=B7=20=ED=95=B4?= =?UTF-8?q?=EC=9E=90=20=C2=B7=20=ED=98=91=EA=B3=A1)=20=C2=B7=20=ED=95=84?= =?UTF-8?q?=EB=93=9C=206(=EB=8F=85=20=EB=8A=AA=20=C2=B7=20=EC=A7=84?= =?UTF-8?q?=ED=9D=99=EB=B0=AD=20=C2=B7=20=EB=B9=99=ED=8C=90=20=C2=B7=20?= =?UTF-8?q?=EC=86=9F=EB=8A=94=20=EA=B0=80=EC=8B=9C=20=C2=B7=20=ED=95=A8?= =?UTF-8?q?=EC=A0=95=20=EA=B0=80=EC=8B=9C=20=C2=B7=20=EC=9A=A9=EC=95=94=20?= =?UTF-8?q?=3D=20=EB=AA=A8=EB=91=90=20=ED=95=B4=EB=A1=9C=EC=9B=80)=20?= =?UTF-8?q?=C2=B7=20=EC=83=88=20=EA=B8=B0=EC=88=A0=206=20=C2=B7=20?= =?UTF-8?q?=EC=83=88=20=EC=86=8C=ED=99=98=203=20=C2=B7=20=EC=84=B1=20?= =?UTF-8?q?=EB=8A=A5=EB=A0=A5=204=20=C2=B7=20=EC=B1=95=ED=84=B0=20?= =?UTF-8?q?=EB=B9=9B=EA=B9=94=20=C2=B7=20=EB=93=B1=EA=B8=89(=EC=A0=95?= =?UTF-8?q?=EC=98=88=C2=B7=EB=B3=B4=EC=8A=A4=C2=B7=EC=B5=9C=EC=A2=85)=20?= =?UTF-8?q?=C2=B7=20NEW=20=C2=B7=20=EC=95=88=EB=82=B4=20=EC=B9=B4=EB=93=9C?= =?UTF-8?q?=20=C2=B7=20=EC=B2=AB=20=ED=81=B4=EB=A6=AC=EC=96=B4=20=C3=973/?= =?UTF-8?q?=C3=975=20=C2=B7=20=ED=97=A4=EB=93=9C=EB=A6=AC=EC=8A=A4=20198/1?= =?UTF-8?q?98=20=C2=B7=20=EB=A0=8C=EB=8D=94=20ALL=20PASS=20=E2=80=94=20PD?= =?UTF-8?q?=20=ED=99=95=EC=A0=95(2026-10-30=20=E3=80=8C=ED=95=84=EB=93=9C?= =?UTF-8?q?=20=EC=A0=84=EB=B6=80=20=EB=84=A4=EA=B1=B0=ED=8B=B0=EB=B8=8C=20?= =?UTF-8?q?=C2=B7=20=EB=82=A0=EC=94=A8=EB=8A=94=20=EB=8B=A4=EB=A5=B8=20?= =?UTF-8?q?=ED=98=95=ED=83=9C=EB=A1=9C=20=C2=B7=20=EA=B7=B8=20=EB=B0=96=20?= =?UTF-8?q?=EA=B8=B0=ED=9A=8D=EC=95=88=EB=8C=80=EB=A1=9C=20=C2=B7=20?= =?UTF-8?q?=EA=B5=AC=ED=98=84=EA=B9=8C=EC=A7=80=20=EC=9D=BC=EA=B4=84?= =?UTF-8?q?=E3=80=8D)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- Assets/Editor/EditorRender.Stage50.cs | 96 +++ Assets/Editor/EditorRender.Stage50.cs.meta | 2 + Assets/Editor/EditorRender.Systems.cs | 6 +- Assets/Editor/HeadlessRun.Battle.cs | 31 +- Assets/Editor/HeadlessRun.Stage50.cs | 276 +++++++++ Assets/Editor/HeadlessRun.Stage50.cs.meta | 2 + Assets/Resources/Data/battle.json | 342 +++++++---- Assets/Scripts/Sim/Battle.Stage.cs | 537 +++++++++++++++++ Assets/Scripts/Sim/Battle.Stage.cs.meta | 2 + Assets/Scripts/Sim/Battle.cs | 181 ++++-- Assets/Scripts/Sim/Save.cs | 1 + Assets/Scripts/View/GameApp.Battle.cs | 182 ++++-- Assets/Scripts/View/GameApp.Stage.cs | 98 ++-- Assets/Scripts/View/GameApp.StageFx.cs | 617 ++++++++++++++++++++ Assets/Scripts/View/GameApp.StageFx.cs.meta | 2 + 15 files changed, 2099 insertions(+), 276 deletions(-) create mode 100644 Assets/Editor/EditorRender.Stage50.cs create mode 100644 Assets/Editor/EditorRender.Stage50.cs.meta create mode 100644 Assets/Editor/HeadlessRun.Stage50.cs create mode 100644 Assets/Editor/HeadlessRun.Stage50.cs.meta create mode 100644 Assets/Scripts/Sim/Battle.Stage.cs create mode 100644 Assets/Scripts/Sim/Battle.Stage.cs.meta create mode 100644 Assets/Scripts/View/GameApp.StageFx.cs create mode 100644 Assets/Scripts/View/GameApp.StageFx.cs.meta diff --git a/Assets/Editor/EditorRender.Stage50.cs b/Assets/Editor/EditorRender.Stage50.cs new file mode 100644 index 0000000..2983af6 --- /dev/null +++ b/Assets/Editor/EditorRender.Stage50.cs @@ -0,0 +1,96 @@ +using System; +using System.Collections.Generic; +using UnityEngine; +using VS.Sim; +using VS.View; + +namespace VS.EditorTools +{ + // 스테이지 50 그림(2026-10-30) — 스테이지 선택(챕터 · 등급 · NEW) · 정보 · 안내 카드 · 지형 3 · 필드 6 · 새 기술 6 · 새 소환 3 · 성 능력 4 · 최종 스테이지 + // 장면 = 실제 스테이지(고정 지도 · 스테이지 구성 그대로) + 덱 6칸 · 마나 15 로 카드를 차례로 소환 → 그 요소가 보이는 순간 + public static partial class EditorRender + { + static void Stage50Shots(GameApp app) + { + var g = app.G; var c = g.BC; + int st0 = g.S.battleStage, sl0 = g.S.battleSlots; var seen0 = new List(g.S.seenIntros); + app.BattleTest = null; g.S.battleStage = c.StageCount; g.S.battleSlots = c.slotMax; g.S.seenIntros.Clear(); + app.B = null; // 앞 장면의 시험 전투를 끝내고 스테이지 선택 화면부터 + app.SwitchTab(1); app.RefreshAll(); + // 스테이지 선택 — 보스 · 챕터 첫 스테이지 · 최종 / 정보 / 안내 카드 + foreach (int s in new[] { 10, 13, 21, 31, 47, 50 }) { app.ShowStage(s); StageCheck(app, "s50_select_" + s, s, true); IconCheck(app, s); Shot(app, "s50_select_" + s); } + app.ShowStage(50); app.OpenStageInfo(); Shot(app, "s50_info_50"); app.ClosePopup(); + foreach (int s in new[] { 8, 26 }) + { + app.ShowStage(s); app.StartStage(s, 3); + if (app.OpenPopupId != "StageIntro") { fails++; notes.Add($"s50_intro_{s}: 안내 카드가 안 뜸"); } + else Shot(app, "s50_intro_" + s); + app.CloseAllPopups(); + } + for (int s = 1; s <= c.StageCount; s++) foreach (var e in c.Elements(s)) if (!g.S.seenIntros.Contains(e.id)) g.S.seenIntros.Add(e.id); // 이제부터 카드 없이 전투 + // 지형 + Scene50(app, 8, "s50_map_bridges", b => b.Units.Count >= 14 && b.Units.Exists(u => u.pos.y > c.riverY + 2f)); + Scene50(app, 18, "s50_map_moat", b => b.Units.FindAll(u => u.attacking).Count >= 3); + Scene50(app, 24, "s50_map_canyon", b => b.Units.FindAll(u => u.pos.y > c.canyon.yFrom + 3f).Count >= 6); + // 필드 + Scene50(app, 7, "s50_field_poison", b => b.Units.FindAll(u => u.poisonFx > 0f).Count >= 3, FlagOn(app, "poison")); + Scene50(app, 11, "s50_field_mud", b => b.Units.FindAll(u => u.mudMul < 1f).Count >= 3, FlagOn(app, "mud")); + Scene50(app, 31, "s50_field_lava", b => b.Units.FindAll(u => u.burnFx > 0f).Count >= 2, FlagOn(app, "lava")); + Scene50(app, 21, "s50_field_ice", b => b.Map.Patches.Exists(p => p.def.kind == "freeze" && p.flash > 0.35f) && b.Units.Exists(u => u.freeze > 0f), FlagOn(app, "ice")); + Scene50(app, 26, "s50_field_spikes", b => b.Map.Patches.Exists(p => p.def.kind == "spike" && p.state == 2 && b.Units.Exists(u => !u.def.flying && p.Inside(u.pos))), FlagOn(app, "spikes")); + Scene50(app, 27, "s50_field_trap", b => b.Map.Patches.Exists(p => p.def.kind == "trap" && p.state == 2 && b.Units.Exists(u => !u.def.flying && p.Inside(u.pos))), FlagOn(app, "trap")); + // 새 기술 + Scene50(app, 13, "s50_sk_poisoncloud", b => b.Zones.Exists(z => z.t > 0.6f && z.t < z.life - 1f)); + Scene50(app, 33, "s50_sk_firefield", b => b.Zones.Exists(z => z.def.fx == "burn" && z.t > 0.3f)); + Scene50(app, 32, "s50_sk_boulder", b => b.Boulders.Exists(o => o.pos.y < c.castle.y - 6f)); + Scene50(app, 34, "s50_sk_meteor", b => b.Meteors.FindAll(m => m.shown).Count >= 2); + Scene50(app, 23, "s50_sk_frostnova", b => b.Fx.Exists(f => f.type == "nova" && f.t < 0.2f)); + Scene50(app, 41, "s50_sk_tornado", b => b.Tornadoes.Exists(t => t.t > 1.2f)); + // 새 소환 + Scene50(app, 14, "s50_sm_laserguard", b => b.Fx.Exists(f => f.type == "beam" && f.t < 0.12f)); + Scene50(app, 16, "s50_sm_bomber", b => b.Minions.Exists(m => m.def.kind == "bomb" && m.attacking && Vector2.Distance(m.pos, m.home) > 2.5f)); + Scene50(app, 28, "s50_sm_totem", b => b.Minions.Exists(m => m.def.kind == "totem") && b.TotemHeal > 0f); + // 성 능력 + Scene50(app, 22, "s50_pw_shield", b => b.ShieldUp && b.Units.Count >= 8); + Scene50(app, 36, "s50_pw_thorns", b => b.Units.FindAll(u => u.attacking).Count >= 3); + Scene50(app, 42, "s50_pw_curse", b => b.Units.Exists(u => u.cursed)); + Scene50(app, 47, "s50_pw_rage2", b => b.Rage2 && b.Units.Count >= 8, b => b.CastleHp = b.CastleMaxHp * 0.2f); + // 최종 스테이지 — 상대방 칸 줄 수(좁은 줄) · 화면 비율 + if (Scene50(app, 50, "s50_final", b => b.Units.Count >= 7 && b.Minions.Count >= 1)) { RivalCheck(app, "s50_final"); LayoutCheck(app, "s50_final"); } + g.S.battleStage = st0; g.S.battleSlots = sl0; g.S.seenIntros = seen0; app.BattleTest = null; + } + + /// 스테이지 s 를 시작해 덱(6칸)을 차례로 소환하며 moment 가 될 때까지(최대 80초) → 그림 · 못 잡으면 실패 기록 + static bool Scene50(GameApp app, int s, string name, Func moment, Action setup = null) + { + app.StartStage(s, 100 + s); + var b = app.B; + if (b == null || b.Stage != s || b.State != BattleState.Fighting || app.PopupOpen) { fails++; notes.Add($"{name}: 스테이지 {s} 전투가 시작되지 않음"); app.CloseAllPopups(); return false; } + b.MaxMana = 15; b.Mana = 15; setup?.Invoke(b); + int card = 0; bool ok = false; + for (int i = 0; i < 1600 && !ok; i++) + { + int slot = card % app.G.BattleSlots; var d = app.G.BattleUnit(slot); + if (d != null && b.Mana >= d.cost) { app.DeployCard(slot); card++; } + app.DebugTick(0.05f, 1); + if (b.State != BattleState.Fighting) break; + ok = moment(b); + } + if (!ok) { fails++; notes.Add($"{name}: 스테이지 {s} 장면 못 잡음 (상태 {b.State} 유닛 {b.Units.Count})"); } + Shot(app, name); + return ok; + } + + /// 그 필드 첫 자리에 깃발(유닛이 필드를 지나가게) + static Action FlagOn(GameApp app, string field) => b => { var p = b.Map.Patches.Find(q => q.def.id == field); if (p != null) app.PlaceFlag(p.pos); }; + + /// 스테이지 선택 요소 그림 = 그 스테이지 요소 수 · NEW 표시 = 처음 나오는 요소 수 + static void IconCheck(GameApp app, int s) + { + var icons = app.StageRoot.Find("Icons"); int n = 0, nw = 0; + foreach (Transform ch in icons) { if (!ch.gameObject.activeSelf) continue; n++; if (ch.Find("New") != null) nw++; } + var c = app.G.BC; int want = c.Elements(s).Count, wantNew = c.NewAt(s).Count; + if (n != want || nw != wantNew) { fails++; notes.Add($"s50_select_{s}: 요소 그림 {n}/{want} · NEW {nw}/{wantNew}"); } + } + } +} diff --git a/Assets/Editor/EditorRender.Stage50.cs.meta b/Assets/Editor/EditorRender.Stage50.cs.meta new file mode 100644 index 0000000..418313d --- /dev/null +++ b/Assets/Editor/EditorRender.Stage50.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: b2af11325ec30b44ba8d63ee8265ce6e \ No newline at end of file diff --git a/Assets/Editor/EditorRender.Systems.cs b/Assets/Editor/EditorRender.Systems.cs index 85468f2..d415a2e 100644 --- a/Assets/Editor/EditorRender.Systems.cs +++ b/Assets/Editor/EditorRender.Systems.cs @@ -32,6 +32,7 @@ namespace VS.EditorTools g.S.afkPending = "0"; BattleShots(app); UnitShots(app); + Stage50Shots(app); // 스테이지 50 — 선택 · 정보 · 안내 카드 · 지형 · 필드 · 새 공격 · 성 능력(EditorRender.Stage50.cs) app.SwitchTab(0); } @@ -45,7 +46,10 @@ namespace VS.EditorTools 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(); + // 처음 나오는 요소 = 안내 카드(스테이지 1 = 일반 화살) → 「전투 시작」 app.StartStage(1, 5); + if (app.OpenPopupId != "StageIntro") { fails++; notes.Add("stage_intro: 스테이지 1 시작 때 안내 카드가 안 뜸"); } + else { Shot(app, "stage_intro"); app.ConfirmIntro(); } if (app.StageRoot.gameObject.activeSelf || !app.BattleRoot.gameObject.activeSelf || app.B.State != BattleState.Fighting || app.B.Stage != 1) { fails++; notes.Add("stage_start: 시작을 눌렀는데 전투 화면이 아님"); } if (app.Root.Find("TabBar").gameObject.activeSelf) { fails++; notes.Add("stage_start: 전투 중에 아래 메뉴가 보임"); } app.B.Mana = 10; @@ -116,7 +120,7 @@ namespace VS.EditorTools static void RivalCheck(GameApp app, string name) { var root = app.BattleRoot; var rv = root.Find("Rival") as RectTransform; - int want = app.B.CastleShots.Count + app.B.CastleSkills.Count + app.B.Summoners.Count, rows = 0; + int want = app.B.CastleShots.Count + app.B.CastleSkills.Count + app.B.Summoners.Count + app.B.Powers.Count, rows = 0; if (rv != null) foreach (Transform ch in rv) if (ch.name.StartsWith("Skill_")) rows++; bool timer = root.Find("Timer") != null; if (timer || rv == null || rows != want) { fails++; notes.Add($"{name}: 상대방 칸 이상 (남은 시간 칸 {timer} · 스킬 줄 {rows}/{want})"); return; } diff --git a/Assets/Editor/HeadlessRun.Battle.cs b/Assets/Editor/HeadlessRun.Battle.cs index 22e426d..f621061 100644 --- a/Assets/Editor/HeadlessRun.Battle.cs +++ b/Assets/Editor/HeadlessRun.Battle.cs @@ -73,7 +73,7 @@ namespace VS.EditorTools for (int i = 0; i < 1200 && unit.alive && !unit.attacking; i++) { b5.Tick(0.05f); - if (Math.Abs(unit.pos.y - c.riverY) < c.riverHalf) { crossed = true; bool on = false; foreach (var bx in b5.Map.Bridges) if (Math.Abs(unit.pos.x - bx) <= c.bridgeHalf) on = true; if (!on) crossedOnBridge = false; } + if (Math.Abs(unit.pos.y - c.riverY) < c.riverHalf) { crossed = true; if (!b5.Map.OnBridge(unit.pos.x)) crossedOnBridge = false; } } Check("B5.bridge", crossed && crossedOnBridge); var drag = c.units.Find(u => u.flying); @@ -118,8 +118,8 @@ namespace VS.EditorTools // B10 승리 보상 · 다음 단계 var g10 = NewGame(t, out _); 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); + var coin0 = g10.Coin; var reward = g10.StageWinReward(1, true); // 첫 클리어 = 기본 × 등급 firstMul + Check("B10.reward", g10.WinBattle(wb) == reward && reward > g10.BattleReward(1) && g10.Coin == coin0 + reward && g10.BattleStage == 2 && g10.NewBattle().CastleMaxHp > wb.CastleMaxHp, $"reward={reward} base={g10.BattleReward(1)}"); // B11 스테이지 — 줄마다 이름 · 다음에 깰 스테이지까지 열림 · 고른 스테이지로 전투 · 지난 스테이지·시험 승리는 진행 그대로 · 마지막을 깨면 전부 클리어 int stN = c.StageCount; bool stNamed = stN > 0; for (int st = 1; st <= stN; st++) stNamed &= !string.IsNullOrEmpty(c.StageName(st)); @@ -166,6 +166,7 @@ namespace VS.EditorTools CastleTests(c); FlagTests(c); StrengthRegenTests(c); + Stage50Tests(c, t); // 스테이지 50 — 표 · 지형 · 필드 · 새 기술 · 새 소환 · 성 능력 · 보상(HeadlessRun.Stage50.cs) } // ---------------- 깃발: 맵을 누른 곳 → 내 유닛이 깃발에 먼저 닿은 뒤 성으로 ---------------- @@ -213,8 +214,9 @@ namespace VS.EditorTools // E1 성·소환수의 공격력·체력 ≥ 내 가장 강한 유닛의 2배 (1단계 기준 · 단계마다 더 커짐) float maxHp = 0f, maxDmg = 0f; foreach (var u in c.units) { maxHp = Mathf.Max(maxHp, u.hp); maxDmg = Mathf.Max(maxDmg, u.damage); } Check("E1.castle_strong", c.castle.hp >= 2f * maxHp && c.castle.atk >= 2f * maxDmg, $"성 체력 {c.castle.hp} 공격 {c.castle.atk} · 내 최고 체력 {maxHp} 공격 {maxDmg}"); - bool sOk = true; foreach (var s in c.summons) if (s.hp < 2f * maxHp || s.damage < 2f * maxDmg) sOk = false; - Check("E1.summon_strong", sOk, string.Join(" · ", c.summons.ConvertAll(s => $"{s.name} 체력 {s.hp} 공격 {s.damage}"))); + // 싸우는 소환수(근접 · 원거리)만 — 스테이지 50 특수 소환(수호병 · 폭탄병 · 토템 = kind)은 「먼저 잡으라고」 만든 유닛이라 기획서 수치(체력 4000 · 800 · 3000) 그대로(확정 전) + bool sOk = true; foreach (var s in c.summons) if (string.IsNullOrEmpty(s.kind) && (s.hp < 2f * maxHp || s.damage < 2f * maxDmg)) sOk = false; + Check("E1.summon_strong", sOk, string.Join(" · ", c.summons.FindAll(s => string.IsNullOrEmpty(s.kind)).ConvertAll(s => $"{s.name} 체력 {s.hp} 공격 {s.damage}"))); var basic = c.units[0]; Check("E1.one_shot", c.castle.atk * c.Shot("normal").dmgPct / 100f >= basic.hp, $"일반 {c.castle.atk * c.Shot("normal").dmgPct / 100f} vs {basic.name} 체력 {basic.hp}"); @@ -245,18 +247,19 @@ namespace VS.EditorTools // ---------------- 맵 4종 · 무작위 생성 · 장애물 우회 ---------------- static void MapTests(BattleCfg c) { + int laneCells = c.BridgeCells(null); // 무작위 맵 다리 = 보통 다리(지나갈 칸 수) for (int k = 0; k <= 3; k++) { var mb = new Battle(c, 1, 10, 100 + k, new BattleSetup { mapType = k }); var mp = mb.Map; int row = Mathf.FloorToInt(c.riverY - 0.5f), lanes = 0; for (int x = 0; x < mp.W; x++) if (mp.Walk[row * mp.W + x]) lanes++; - Check("M1.type" + k, mp.Type == k && mp.Bridges.Length == k && mp.River == (k > 0) && lanes == (k > 0 ? k : mp.W) && mp.Reachable(mb.SpawnPoint), $"lanes={lanes} bridges={string.Join(",", mp.Bridges)}"); + Check("M1.type" + k, mp.Type == k && mp.Bridges.Length == k && mp.River == (k > 0) && lanes == (k > 0 ? k * laneCells : mp.W) && mp.Reachable(mb.SpawnPoint), $"lanes={lanes} bridges={string.Join(",", mp.Bridges)}"); } int n = 400, reach = 0, top = 0, near = 0, withObs = 0, blocking = 0; var cnt = new int[4]; float far = c.obstacleDistMax + c.obstacleRadiusMax * c.obstacleSpacing * c.obstaclePiecesMax * 0.5f + c.obstacleWobble + 0.01f; for (int s = 0; s < n; s++) { - var mb = new Battle(c, 1, 10, 1000 + s); var mp = mb.Map; + var mb = new Battle(c, 1, 10, 1000 + s, new BattleSetup { mapType = BattleSetup.RandomMap }); var mp = mb.Map; cnt[mp.Type]++; if (mp.Reachable(mb.SpawnPoint)) reach++; if (Math.Abs(mp.Castle.y - c.castle.y) < 1e-4f && mp.Castle.y >= c.arenaH * 0.75f && mp.Castle.x >= c.castle.xMin - 1e-4f && mp.Castle.x <= c.castle.xMax + 1e-4f) top++; @@ -294,14 +297,14 @@ namespace VS.EditorTools bool allReach = true, noBlock2 = true, bridgeOnly = true; int obsMaps = 0; for (int s = 1; s <= 12; s++) { - var rb = new Battle(c, 1, 10, 500 + s, new BattleSetup { obstacles = 1, shots = L(), gimmicks = L() }); rb.Start(); + var rb = new Battle(c, 1, 10, 500 + s, new BattleSetup { mapType = BattleSetup.RandomMap, obstacles = 1, shots = L(), gimmicks = L() }); rb.Start(); if (rb.Map.Obstacles.Count > 0) obsMaps++; var ru = Put(rb, walker, rb.SpawnPoint.x, rb.SpawnPoint.y); for (int i = 0; i < 2400 && !ru.attacking; i++) { rb.Tick(0.05f); if (rb.Map.Blocked(ru.pos)) noBlock2 = false; - if (rb.Map.River && Math.Abs(ru.pos.y - c.riverY) < c.riverHalf) { bool on = false; foreach (var bx in rb.Map.Bridges) if (Math.Abs(ru.pos.x - bx) <= c.bridgeHalf) on = true; if (!on) bridgeOnly = false; } + if (rb.Map.River && Math.Abs(ru.pos.y - c.riverY) < c.riverHalf && !rb.Map.OnBridge(ru.pos.x)) bridgeOnly = false; } if (!ru.attacking) { allReach = false; Debug.Log($"[VS] P2 seed {500 + s} 도착 못 함 type={rb.Map.Type} pos={ru.pos}"); } } @@ -449,14 +452,14 @@ namespace VS.EditorTools var xb = new Battle(c, 1, 10, 9, xs); Check("X1.setup", xb.IsTest && xb.Map.Type == 3 && xb.Map.Obstacles.Count == 0 && xb.CastleShots.Count == 2 && xb.CastleShots[0].def.id == "multi" && xb.CastleShots[1].def.id == "laser" && xb.CastleSkills.Count == 1 && xb.CastleSkills[0].def.id == "lightning" && xb.Summoners.Count == 1 && xb.Summoners[0].def.id == "summonRanged" && Near(xb.Range(c.Shot("laser")), c.Shot("laser").range * 0.5f, 1e-4)); - bool loOk = true; - for (int st = 1; st <= 9; st++) + bool loOk = true; var loBad = new List(); + for (int st = 1; st <= c.StageCount; st++) { var sb = new Battle(c, st, 10, st); var l = c.Loadout(st); - var ids = sb.CastleShots.ConvertAll(w => w.def.id); var gids = new HashSet(sb.CastleSkills.ConvertAll(w => w.def.id)); gids.UnionWith(sb.Summoners.ConvertAll(s => s.def.id)); - if (string.Join(",", ids) != string.Join(",", l.shots) || !gids.SetEquals(l.gimmicks) || sb.IsTest) loOk = false; + var ids = sb.CastleShots.ConvertAll(w => w.def.id); var gids = new HashSet(sb.CastleSkills.ConvertAll(w => w.def.id)); gids.UnionWith(sb.Summoners.ConvertAll(s => s.def.id)); gids.UnionWith(sb.Powers.ConvertAll(p => p.def.id)); + if (string.Join(",", ids) != string.Join(",", l.shots) || !gids.SetEquals(l.gimmicks) || sb.IsTest) { loOk = false; loBad.Add(st); } } - Check("X2.stage_loadout", loOk); + Check("X2.stage_loadout", loOk, string.Join(",", loBad)); } } } diff --git a/Assets/Editor/HeadlessRun.Stage50.cs b/Assets/Editor/HeadlessRun.Stage50.cs new file mode 100644 index 0000000..5cc98e4 --- /dev/null +++ b/Assets/Editor/HeadlessRun.Stage50.cs @@ -0,0 +1,276 @@ +using System; +using System.Collections.Generic; +using UnityEditor; +using UnityEngine; +using VS.Sim; +using Vector2 = UnityEngine.Vector2; + +namespace VS.EditorTools +{ + // 스테이지 50 검사(2026-10-30 PD 확정 기획서 · 필드 = 전부 해로움) — 스테이지 표 · 고정 지도 · 다리 폭 · 해자 · 협곡 · 필드 6 · 새 기술 6 · 새 소환 3 · 성 능력 4 · 첫 클리어 보상 + // 표 측정: -executeMethod VS.EditorTools.HeadlessRun.StageTable (스테이지마다 자동 플레이로 이길 수 있는지 · 강화 끝(6칸 · 마나 15) / 처음(1칸 · 10)) + public static partial class HeadlessRun + { + static BPatch Patch(Battle b, BFieldDef d, float x, float y, float r) { var p = new BPatch { def = d, pos = new Vector2(x, y), r = r }; b.Map.Patches.Add(p); return p; } + static BUnitDef Flyer() { var d = Dummy(); d.flying = true; return d; } + static void Ticks(Battle b, float sec) { for (float t = 0; t < sec - 1e-4f; t += 0.05f) b.Tick(0.05f); } + + static void Stage50Tests(BattleCfg c, Tables t) + { + float atk = c.castle.atk; // 1단계 공격력 + // ---------------- 스테이지 표 ---------------- + int n = c.StageCount; bool rows = n == 50; + for (int s = 1; s <= n; s++) + { + var rk = c.Rank(s); bool boss = rk != null && rk.firstMul > c.Rank(1).firstMul; + rows &= c.Chapter(s) != null && rk != null && !string.IsNullOrEmpty(c.StageName(s)) + && (s % 10 == 0 ? boss : s % 5 == 0 ? rk.hpMul > 1f && !boss : rk.hpMul == 1f); // 5의 배수 정예 · 10의 배수 보스(50 최종) + } + Check("S50.stages", rows && c.Rank(50).hpMul > c.Rank(10).hpMul && Mathf.Approximately(c.CastleHpAt(10), Mathf.Round(c.castle.hp * Mathf.Pow(c.castle.hpGrowth, 9) * c.Rank(10).hpMul)), $"stages={n}"); + var all = new List(); c.shots.ForEach(d => all.Add(d.id)); c.skills.ForEach(d => all.Add(d.id)); c.summons.ForEach(d => all.Add(d.id)); c.powers.ForEach(d => all.Add(d.id)); c.fields.ForEach(d => all.Add(d.id)); + var never = all.FindAll(id => c.FirstStage(id) == 0); int maxNew = 0; + for (int s = 1; s <= n; s++) maxNew = Math.Max(maxNew, c.NewAt(s).Count); + Check("S50.one_new_at_a_time", never.Count == 0 && maxNew <= 2 && c.NewAt(1).Count >= 1, $"안 나오는 요소={string.Join(",", never)} 한 스테이지 최대 NEW={maxNew}"); + // 고정 지도: 같은 스테이지 = 전투 seed 가 달라도 같은 지도 · 모든 스테이지 길 이어짐 · 다리 칸 수 = 종류 칸 수 합 · 필드 개수 = 종류마다 countMin~Max + bool same = true, reach = true, lanes = true, fieldsOk = true, terrainOk = true; var bad = new List(); + for (int s = 1; s <= n; s++) + { + var row = c.StageRow(s); var a = new Battle(c, s, 10, 11).Map; var b2 = new Battle(c, s, 10, 99).Map; + bool eq = a.Bridges.Length == b2.Bridges.Length && a.Patches.Count == b2.Patches.Count && a.Obstacles.Count == b2.Obstacles.Count; + for (int i = 0; eq && i < a.Patches.Count; i++) eq &= Vector2.Distance(a.Patches[i].pos, b2.Patches[i].pos) < 1e-4f; + same &= eq; reach &= a.Reachable(new Vector2(c.arenaW * 0.5f, c.spawnY)); + int want = 0; foreach (var br in row.bridges) want += c.BridgeCells(br.kind); + int rowY = Mathf.FloorToInt(c.riverY - 0.5f), got = 0; for (int x = 0; x < a.W; x++) if (a.Walk[rowY * a.W + x]) got++; + bool lo = row.bridges.Count == 0 ? got > 0 : got == want; lanes &= lo; + bool fo = true; foreach (var fid in row.fields) { var fd = c.Field(fid); int k = a.Patches.FindAll(p => p.def == fd).Count; fo &= k >= fd.countMin && k <= fd.countMax; } + foreach (var p in a.Patches) fo &= !a.Blocked(p.pos) && Array.IndexOf(row.fields, p.def.id) >= 0; + fieldsOk &= fo; + bool to = a.MoatGates == row.moat && a.Canyon == row.canyon; terrainOk &= to; + if (!(eq && lo && fo && to)) bad.Add(s); + if (!fo) { var cnt = new List(); foreach (var fid in row.fields) cnt.Add($"{fid}={a.Patches.FindAll(p => p.def.id == fid).Count}"); Debug.Log($"[VS] S50 stage {s} 필드 {string.Join(" ", cnt)}"); } + } + Check("S50.fixed_map", same && reach, $"bad={string.Join(",", bad)}"); + Check("S50.bridge_lanes", lanes, $"bad={string.Join(",", bad)}"); + Check("S50.field_count", fieldsOk && terrainOk, $"bad={string.Join(",", bad)}"); + + // ---------------- 지형: 해자 · 협곡 (스테이지 지도 · 성 공격 끔) ---------------- + var walker = Dummy(1.2f); walker.range = 0.8f; + bool MoatRun(int st, out string info) + { + var b = new Battle(c, st, 10, 3, new BattleSetup { shots = L(), gimmicks = L(), fields = L() }); b.Start(); + var u = Put(b, walker, b.SpawnPoint.x, b.SpawnPoint.y); bool noBlock = true, viaGate = true; var m = b.Map; + for (int i = 0; i < 2400 && !u.attacking; i++) + { + b.Tick(0.05f); + if (m.Blocked(u.pos)) noBlock = false; + float d = Vector2.Distance(u.pos, m.Castle); + if (m.MoatGates > 0 && d > m.MoatIn && d < m.MoatOut) + { + float ang = Mathf.Atan2(u.pos.y - m.Castle.y, u.pos.x - m.Castle.x) * Mathf.Rad2Deg; bool g = false; + foreach (var gd in m.GateDeg) if (Mathf.Abs(Mathf.DeltaAngle(ang, gd)) <= c.moat.gateHalfDeg + 6f) g = true; + if (!g) viaGate = false; + } + } + info = $"stage {st} attacking={u.attacking} noBlock={noBlock} viaGate={viaGate} pos={u.pos}"; + return u.attacking && noBlock && viaGate; + } + int moatSt = FirstWith(c, r => r.moat > 0 && !r.canyon), canyonSt = FirstWith(c, r => r.canyon && r.moat == 0), bothSt = FirstWith(c, r => r.canyon && r.moat > 0); + var mm = new Battle(c, moatSt, 10, 1).Map; int ringWater = 0, ringGate = 0; + for (int i = 0; i < mm.Walk.Length; i++) { var p = mm.Center(i); float d = Vector2.Distance(p, mm.Castle); if (d > mm.MoatIn + 0.1f && d < mm.MoatOut - 0.1f) { if (mm.Walk[i]) ringGate++; else ringWater++; } } + Check("S50.moat_ring", mm.MoatGates > 0 && ringWater > 10 && ringGate > 0 && ringGate < ringWater / 2, $"stage {moatSt} water={ringWater} gate={ringGate}"); + Check("S50.moat_reach", MoatRun(moatSt, out var mi), mi); + var cm = new Battle(c, canyonSt, 10, 1).Map; int wallRows = 0; + for (int y = 0; y < cm.H; y++) if (cm.InCanyonWall(0, y) && cm.InCanyonWall(cm.W - 1, y) && !cm.InCanyonWall(cm.W / 2, y)) wallRows++; + bool cOk = MoatRun(canyonSt, out var ci), bOk = MoatRun(bothSt, out var bi); + Check("S50.canyon", wallRows >= 10 && cOk && bOk, $"rows={wallRows} · {ci} · {bi}"); + + // ---------------- 필드 6 (내 땅 유닛만 · 최대 체력 %) ---------------- + var dm = Dummy(); float hp = dm.hp; + { + var f = c.Field("poison"); var b = new Battle(c, 1, 10, 1, Fixed(L(), L())); b.Start(); Patch(b, f, 9f, 8f, 2f); + var u = Put(b, dm, 9f, 8f); var fl = Put(b, Flyer(), 9.5f, 8f); + Ticks(b, 1f); float in1 = hp - u.hp; + u.pos = new Vector2(3f, 3f); Ticks(b, 1f); float out1 = hp - u.hp - in1; + Ticks(b, f.linger + 0.5f); float h2 = u.hp; Ticks(b, 1f); + Check("FD1.poison", Near(in1, hp * f.dotPct / 100f, hp * 0.004f) && Near(out1, hp * f.dotPct / 100f, hp * 0.004f) && u.hp == h2 && fl.hp == hp, $"in={in1:0} out={out1:0} after={h2 - u.hp:0} fly={hp - fl.hp:0}"); + } + { + var f = c.Field("mud"); var b = new Battle(c, 1, 10, 1, Fixed(L(), L())); b.Start(); Patch(b, f, 9f, 8f, 2.5f); + var u = Put(b, walker, 9f, 6.2f); var q0 = u.pos; Ticks(b, 0.5f); + Check("FD2.mud", Near((u.pos - q0).magnitude, walker.speed * 0.5f * (1f - f.slowPct / 100f), 0.03), $"moved={(u.pos - q0).magnitude:0.000}"); + } + { + var f = c.Field("lava"); var b = new Battle(c, 1, 10, 1, Fixed(L(), L())); b.Start(); Patch(b, f, 9f, 8f, 2f); + var u = Put(b, dm, 9f, 8f); Ticks(b, 1f); + Check("FD3.lava", Near(hp - u.hp, hp * f.dotPct / 100f, hp * 0.005f), $"{hp - u.hp:0}"); + } + { + var f = c.Field("ice"); var b = new Battle(c, 1, 10, 1, Fixed(L(), L())); b.Start(); var p = Patch(b, f, 9f, 6f, 2f); + var u = Put(b, walker, 9f, 5f); + var w0 = u.pos; Ticks(b, f.period - f.warn + 0.1f); bool warned = p.state == 1, movedBefore = (u.pos - w0).magnitude > 0.5f; + u.pos = new Vector2(9f, 6f); Ticks(b, f.warn); bool frozen = u.freeze > 0f; var z0 = u.pos; Ticks(b, 0.5f); + Check("FD4.ice", warned && movedBefore && frozen && (u.pos - z0).magnitude < 1e-4f && b.Frozen >= 1, $"warn={warned} frozen={frozen}"); + } + { + var f = c.Field("spikes"); var b = new Battle(c, 1, 10, 1, Fixed(L(), L())); b.Start(); var p = Patch(b, f, 9f, 8f, 1f); + var u = Put(b, dm, 9f, 8f); var out0 = Put(b, dm, 11f, 8f); + Ticks(b, f.period - f.warn - f.upSeconds + 0.05f); bool warn = p.state == 1; float before = hp - u.hp; + Ticks(b, f.period); float one = hp - u.hp; Ticks(b, f.period); float two = hp - u.hp; + Check("FD5.spikes", warn && before == 0f && Near(one, hp * f.hitPct / 100f, 1) && Near(two, 2f * hp * f.hitPct / 100f, 1) && out0.hp == hp, $"before={before} one={one} two={two}"); + } + { + var f = c.Field("trap"); var b = new Battle(c, 1, 10, 1, Fixed(L(), L())); b.Start(); var p = Patch(b, f, 9f, 8f, 1f); + Ticks(b, 1f); bool idle = p.state == 0; + var u = Put(b, dm, 9f, 8f); b.Tick(0.05f); bool armed = p.state == 1; + Ticks(b, f.delay - 0.15f); float early = hp - u.hp; + Ticks(b, 0.3f); float hit = hp - u.hp; + Ticks(b, f.upSeconds + 0.5f); bool rearm = p.state == 3; float during = hp - u.hp; + Ticks(b, f.rearm + f.delay + 0.3f); float again = hp - u.hp; + Check("FD6.trap", idle && armed && early == 0f && Near(hit, hp * f.hitPct / 100f, 1) && rearm && during == hit && Near(again, 2f * hp * f.hitPct / 100f, 1), $"early={early} hit={hit} again={again} state={p.state}"); + } + + // ---------------- 새 기술 6 ---------------- + { + var d = c.Skill("poisonCloud"); var b = new Battle(c, 1, 10, 1, Fixed(L(), L("poisonCloud"))); b.Start(); + float y = InY(c, d.range - 1.5f); var a = Put(b, dm, 9f, y); var a2 = Put(b, dm, 9.6f, y); var fl = Put(b, Flyer(), 8.6f, y); + b.CastleSkills[0].cd = 0f; b.Tick(0.05f); bool zone = b.Zones.Count == 1; + Ticks(b, d.duration + 0.3f); + float want = atk * d.dpsPct / 100f * d.duration; + Check("SK1.poison_cloud", zone && Near(hp - a.hp, want, want * 0.05) && Near(hp - a2.hp, want, want * 0.05) && hp - fl.hp > want * 0.9f, $"dmg={hp - a.hp:0}/{want:0} fly={hp - fl.hp:0}"); + } + { + var d = c.Skill("fireField"); var b = new Battle(c, 1, 10, 1, Fixed(L(), L("fireField"))); b.Start(); + float y = InY(c, d.range - 1.5f); var a = Put(b, dm, 9f, y); var fl = Put(b, Flyer(), 9.3f, y); + b.CastleSkills[0].cd = 0f; Ticks(b, d.duration + 0.3f); + float want = atk * d.dpsPct / 100f * d.duration; + Check("SK2.fire_field", Near(hp - a.hp, want, want * 0.05) && fl.hp == hp && a.burnFx >= 0f, $"dmg={hp - a.hp:0}/{want:0} fly={hp - fl.hp:0}"); + } + { + var d = c.Skill("boulder"); var b = new Battle(c, 1, 10, 1, Fixed(L(), L("boulder"))); b.Start(); + var a = Put(b, dm, 9f, 20f); var a2 = Put(b, dm, 9.4f, 14f); var side = Put(b, dm, 13f, 20f); + b.CastleSkills[0].cd = 0f; b.Tick(0.05f); bool rolling = b.Boulders.Count == 1; float x0 = a.pos.x; + Ticks(b, 5f); + Check("SK3.boulder", rolling && Near(hp - a.hp, atk * d.dmgPct / 100f, 1) && Near(hp - a2.hp, atk * d.dmgPct / 100f, 1) && side.hp == hp && Mathf.Abs(a.pos.x - x0) > d.knock * 0.9f && b.Boulders.Count == 0, $"hit={hp - a.hp} knock={a.pos.x - x0:0.00}"); + // 강(다리 밖)에서 멈춤 — 다리 2개 맵 · 가운데 줄(다리 없음) + var r = new Battle(c, 1, 10, 7, Fixed(L(), L("boulder"), 2)); r.Start(); + var up = Put(r, dm, 9f, c.riverY + 4f); var down = Put(r, dm, 9f, c.riverY - 4f); + r.CastleSkills[0].cd = 0f; Ticks(r, 4f); + Check("SK3.boulder_river", up.hp < hp && down.hp == hp && !r.Map.OnBridge(9f), $"up={hp - up.hp} down={hp - down.hp}"); + } + { + var d = c.Skill("meteor"); var b = new Battle(c, 1, 10, 1, Fixed(L(), L("meteor"))); b.Start(); + float y = InY(c, 3f); var us = new List(); for (int i = 0; i < 5; i++) us.Add(Put(b, dm, 8f + i * 0.5f, y)); + b.CastleSkills[0].cd = 0f; b.Tick(0.05f); int queued = b.Meteors.Count; + bool warnFirst = false; float total = 0f; + for (int i = 0; i < 80; i++) { b.Tick(0.05f); total = 0f; foreach (var u in us) total += hp - u.hp; if (total == 0f && b.Meteors.Exists(m => m.shown)) warnFirst = true; } + Check("SK4.meteor", queued == d.count && warnFirst && total >= atk * d.dmgPct / 100f && b.Meteors.Count == 0, $"queued={queued} dmg={total:0}"); + } + { + var d = c.Skill("frostNova"); var b = new Battle(c, 1, 10, 1, Fixed(L(), L("frostNova"))); b.Start(); + var a = Put(b, walker, 9f, InY(c, d.range - 0.5f)); var fl = Put(b, Flyer(), 10f, InY(c, d.range - 0.8f)); var far = Put(b, walker, 9f, InY(c, d.range + 2.5f)); + b.CastleSkills[0].cd = 0f; b.Tick(0.05f); + Check("SK5.frost_nova", Near(walker.hp - a.hp, atk * d.dmgPct / 100f, 1) && a.freeze > 0f && fl.freeze > 0f && far.freeze == 0f && far.hp == walker.hp && b.Fx.Exists(f => f.type == "nova"), $"dmg={walker.hp - a.hp} freeze={a.freeze:0.00}"); + } + { + var d = c.Skill("tornado"); var b = new Battle(c, 1, 10, 1, Fixed(L(), L("tornado"))); b.Start(); + var a = Put(b, dm, 9f, 24.4f); var g = Put(b, dm, 10.1f, 24.4f); var fl = Put(b, Flyer(), 8.4f, 24.6f); + b.CastleSkills[0].cd = 0f; b.Tick(0.05f); bool spawned = b.Tornadoes.Count == 1; float tx = spawned ? b.Tornadoes[0].pos.x : 0f, gx0 = g.pos.x, fy0 = fl.pos.y; + Ticks(b, 0.6f); + Check("SK6.tornado", spawned && Mathf.Abs(g.pos.x - tx) < Mathf.Abs(gx0 - tx) - 0.2f && fl.pos.y < fy0 - 0.5f && a.hp < hp, $"x={tx:0.0} g {gx0:0.0}→{g.pos.x:0.0} fly {fy0:0.0}→{fl.pos.y:0.0}"); + } + + // ---------------- 새 소환 3 ---------------- + { + var d = c.Summon("laserGuard"); var b = new Battle(c, 1, 10, 1, Fixed(L(), L("laserGuard"))); b.Start(); + b.Summoners[0].cd = 0f; b.Tick(0.05f); var g = b.Minions.Count > 0 ? b.Minions[0] : null; var home = g?.pos ?? Vector2.zero; + var a = g != null ? Put(b, dm, home.x, home.y - 3f) : null; var a2 = g != null ? Put(b, dm, home.x, home.y - 5f) : null; + if (g != null) Ticks(b, d.attackInterval + 0.2f); + float want = atk * d.beamPct / 100f; + Check("SM1.laser_guard", g != null && (g.pos - home).magnitude < 1e-4f && Near(hp - a.hp, want, 1) && Near(hp - a2.hp, want, 1), g == null ? "no guard" : $"a={hp - a.hp} b={hp - a2.hp} moved={(g.pos - home).magnitude:0.00}"); + } + { + var d = c.Summon("bomber"); var b = new Battle(c, 1, 10, 1, Fixed(L(), L("bomber"))); b.Start(); + b.Summoners[0].cd = 0f; b.Tick(0.05f); + var a = Put(b, dm, 9f, 18f); var far = Put(b, dm, 3f, 6f); + TickUntil(b, () => b.MinionsBlown > 0, 12f); + float want = atk * d.blastPct / 100f; + Check("SM2.bomber", b.MinionsBlown == 1 && Near(hp - a.hp, want, 1) && far.hp == hp && b.Fx.Exists(f => f.type == "blast"), $"blown={b.MinionsBlown} a={hp - a.hp} far={hp - far.hp}"); + } + { + var d = c.Summon("healTotem"); var b = new Battle(c, 1, 10, 1, Fixed(L(), L("healTotem"))); b.Start(); + b.Summoners[0].cd = 0f; b.Tick(0.05f); var tm = b.Minions.Count > 0 ? b.Minions[0] : null; var home = tm?.pos ?? Vector2.zero; + b.HitCastle(b.CastleMaxHp * 0.5f); float h0 = b.CastleHp; Ticks(b, 2f); + float want = b.CastleMaxHp * d.healPct / 100f * 2f; + Check("SM3.heal_totem", tm != null && (tm.pos - home).magnitude < 1e-4f && Near(b.CastleHp - h0, want, want * 0.06), $"heal={b.CastleHp - h0:0}/{want:0}"); + } + + // ---------------- 성 능력 4 ---------------- + { + var p = c.Power("shield"); var b = new Battle(c, 1, 10, 1, Fixed(L(), L("shield"))); b.Start(); + b.ShieldPower.cd = 0f; b.Tick(0.05f); float sh = b.Shield, h0 = b.CastleHp; + b.HitCastle(sh * 0.5f); float h1 = b.CastleHp; b.HitCastle(sh); float h2 = b.CastleHp; + Ticks(b, p.duration + 0.2f); + Check("PW1.shield", Near(sh, b.CastleMaxHp * p.pct / 100f, 1) && h1 == h0 && Near(h0 - h2, sh * 0.5f, 1) && !b.ShieldUp && b.Shield == 0f, $"shield={sh} h1={h0 - h1} h2={h0 - h2}"); + } + { + var p = c.Power("thorns"); var b = new Battle(c, 1, 10, 1, Fixed(L(), L("thorns"))); b.Start(); + var hitter = Dummy(); hitter.damage = 100f; hitter.interval = 1f; hitter.range = 0.8f; + var shooter = Dummy(); shooter.damage = 100f; shooter.interval = 1f; shooter.range = 5f; shooter.projectile = true; + var m = Put(b, hitter, 9f, InY(c, 0.3f)); var r = Put(b, shooter, 12f, InY(c, 3f)); + Ticks(b, 1.2f); + Check("PW2.thorns", Near(hitter.hp - m.hp, 100f * p.pct / 100f, 0.5) && r.hp == shooter.hp && b.HasThorns, $"melee={hitter.hp - m.hp} ranged={shooter.hp - r.hp}"); + } + { + var p = c.Power("curse"); var b = new Battle(c, 1, 10, 1, Fixed(L(), L("curse"))); b.Start(); + var hitter = Dummy(); hitter.damage = 100f; hitter.interval = 1f; hitter.range = 0.8f; + var m = Put(b, hitter, 9f, InY(c, 0.3f)); float h0 = b.CastleHp; Ticks(b, 1.2f); + Check("PW3.curse", Near(h0 - b.CastleHp, 100f * (1f - p.pct / 100f), 0.5) && m.cursed, $"dealt={h0 - b.CastleHp} cursed={m.cursed}"); + } + { + var p = c.Power("rage2"); var sn = c.Shot("normal"); + var b = new Battle(c, 1, 10, 1, Fixed(L("normal"), L("rage2"))); b.Start(); + b.CastleHp = b.CastleMaxHp * (p.hpRatio - 0.05f); float mul = b.IntervalMul; + var a = Put(b, dm, 9f, InY(c, sn.range - 1f)); b.CastleShots[0].cd = 0f; TickUntil(b, () => a.hp < hp, 3f); + Check("PW4.rage2", b.Rage2 && Near(mul, p.intervalMul, 1e-4) && Near(hp - a.hp, atk * sn.dmgPct / 100f * p.shotDmgMul, 1), $"mul={mul} dmg={hp - a.hp}"); + } + + // ---------------- 보상: 첫 클리어 × 등급 · 다시 깨기 × 1 ---------------- + { + var g = NewGame(t, out _); int boss = FirstWith(c, r => c.Rank(r.stage).firstMul > c.Rank(1).firstMul); + g.S.battleStage = boss; + Battle WonAt(int st) { var w = g.NewBattle(st, 7, null); w.Start(); w.CastleHpDebugKill(); w.Tick(0.05f); w.Tick(0.05f); return w; } + var r1 = g.WinBattle(WonAt(boss)); var r2 = g.WinBattle(WonAt(boss)); + var bigBoss = new System.Numerics.BigInteger(decimal.Ceiling((decimal)g.BattleReward(boss) * (decimal)c.Rank(boss).firstMul)); + Check("RW1.first_clear", r1 == bigBoss && r2 == g.BattleReward(boss) && g.BattleStage == boss + 1, $"boss {boss} first={r1} replay={r2}"); + } + // ---------------- 안내 카드 요소 ---------------- + var n2 = c.NewAt(FirstWith(c, r => r.bridges.Count > 0)).ConvertAll(e => e.id); + Check("IN1.new_elements", n2.Contains("river") && c.NewAt(FirstWith(c, r => Array.IndexOf(r.fields, "spikes") >= 0)).Exists(e => e.id == "spikes") && c.Elements(50).Count >= 10, $"first bridge stage = {string.Join(",", n2)}"); + } + + static int FirstWith(BattleCfg c, Predicate f) { var r = c.stageLoadouts.Find(f); return r != null ? r.stage : 1; } + + /// 스테이지 표 측정 — 50 스테이지 × (강화 끝: 덱 6칸 · 마나 15 / 처음: 1칸 · 마나 10) 자동 플레이 · 이길 수 있는지 · 걸린 시간 · 남은 성 체력 + public static void StageTable() + { + int exit = 0; + try + { + Application.SetStackTraceLogType(LogType.Log, StackTraceLogType.None); + var t = LoadTables(); var c = t.Battle; var g = NewGame(t, out _); + int winMax = 0, winMin = 0, lastMax = 0; + for (int s = 1; s <= c.StageCount; s++) + { + var hi = AutoBattle(g, s, c.slotMax, c.manaMaxCap, 7f, out float th); + var lo = AutoBattle(g, s, 1, c.manaMaxBase, 5f, out float tl); + if (hi.State == BattleState.Won) { winMax++; lastMax = s; } + if (lo.State == BattleState.Won) winMin++; + Debug.Log($"[VS] STAGE {s,2} {c.StageName(s)} [{c.Rank(s)?.name}] 지도={hi.MapName} 성 체력 {hi.CastleMaxHp:N0} 공격 {hi.Atk:0} · 강화 끝 → {hi.State} {th:0}s 남은 {hi.CastleHp / hi.CastleMaxHp:P0} 소환 {hi.Spawned} 쓰러짐 {hi.Killed} 필드 피해 {hi.FieldHits} 얼림 {hi.Frozen} · 처음 → {lo.State} {tl:0}s 남은 {lo.CastleHp / lo.CastleMaxHp:P0}"); + } + Debug.Log($"[VS] STAGE TABLE 강화 끝 승리 {winMax}/{c.StageCount} (마지막 승리 {lastMax}) · 처음 승리 {winMin}/{c.StageCount}"); + } + catch (Exception e) { Debug.LogError("[VS] EXCEPTION " + e); exit = 2; } + EditorApplication.Exit(exit); + } + } +} diff --git a/Assets/Editor/HeadlessRun.Stage50.cs.meta b/Assets/Editor/HeadlessRun.Stage50.cs.meta new file mode 100644 index 0000000..ffa2271 --- /dev/null +++ b/Assets/Editor/HeadlessRun.Stage50.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 63e681ce2d2fd9447b56aa045e9e44fc \ No newline at end of file diff --git a/Assets/Resources/Data/battle.json b/Assets/Resources/Data/battle.json index a46e89e..023be3d 100644 --- a/Assets/Resources/Data/battle.json +++ b/Assets/Resources/Data/battle.json @@ -1,121 +1,221 @@ -{ - "source": "전투 모드 클론 설계값 (2026-10-27 PD 지시 · 2026-10-28 PD 전투 수정 6건 + 추가 5건) — 원작에 없는 값 · PD 확정 전. 사거리 = 성 가장자리부터 칸 수 · 「기본 값은 절반 수준」 = 예전 값의 절반(화살 7 → 일반 3.5 · 폭탄 10 → 파이어 볼 5)으로 해석(확정 전) · 시험 창 사거리 ×4 = 맵 절반 해석. 「적은 내 유닛보다 매우 강하게 · 나는 압도적인 물량」 = 성·소환수의 공격력·체력이 내 가장 강한 유닛의 2배 이상 · 카드 1장 = 1마리(2026-10-29 PD 「한 장에 여러 마리 금지 · 물량 = 마력 코스트를 모아 많이 소환」) → 마나 충전 0.5초(1초에 검사 2마리) · 내 유닛끼리 겹치지 않게 살짝 밀어냄(무리가 퍼져 보이고 범위 공격에 한꺼번에 안 맞음)(확정 전)", - "arenaW": 18, - "arenaH": 32, - "riverY": 16, - "riverHalf": 0.9, - "bridgeHalf": 1.2, - "bridgeJitter": 1.5, - "mapWeights": [1, 1, 1, 1], - "mapNames": ["강 없는 평지", "다리 1개", "다리 2개", "다리 3개"], - "bridgeSets": [ { "x": [9] }, { "x": [3.5, 14.5] }, { "x": [2.5, 9, 15.5] } ], - "obstacleChance": 0.8, - "obstacleMin": 1, - "obstacleMax": 3, - "obstaclePiecesMin": 2, - "obstaclePiecesMax": 4, - "obstacleRadiusMin": 0.8, - "obstacleRadiusMax": 1.3, - "obstacleDistMin": 4.5, - "obstacleDistMax": 7.5, - "obstacleArcMin": 180, - "obstacleArcMax": 360, - "obstacleSpacing": 1.5, - "obstacleWobble": 0.3, - "obstaclePad": 0.2, - "obstacleCastleGap": 0.8, - "castleSeedPad": 1.0, - "spawnY": 1.6, - "spawnJitter": 0.7, - "unitSight": 4.5, - "unitShotSpeed": 10, - "castleRetarget": 0.1, - "summonArcMin": 205, - "summonArcMax": 335, - "flagReach": 1.0, - "unitSpacing": 0.8, - "unitPush": 6, - "rivalOutfits": [6, 2, 5, 3, 4, 7, 1], - "battleSeconds": 180, - "lastMinuteSeconds": 60, - "lastMinuteRegenMul": 2, - "manaStart": 5, - "manaRegenSeconds": 0.5, - "manaMaxBase": 10, - "manaMaxCap": 15, - "manaUpgradeCost": ["2000", "20000", "200000", "2000000", "20000000"], - "slotStart": 1, - "slotMax": 6, - "slotUnlockCost": ["500", "5000", "50000", "500000", "5000000"], - "rewardPassSeconds": 120, - "rewardPassSecondsPerStage": 60, - "units": [ - { "id": 1, "name": "검사", "cost": 1, "grade": 1, "hp": 220, "damage": 60, "interval": 1.1, "range": 0.8, "speed": 1.2, "radius": 0.45, "flying": false, "projectile": false }, - { "id": 2, "name": "궁수", "cost": 2, "grade": 2, "hp": 150, "damage": 45, "interval": 1.0, "range": 5.0, "speed": 1.2, "radius": 0.4, "flying": false, "projectile": true }, - { "id": 3, "name": "기사", "cost": 3, "grade": 3, "hp": 900, "damage": 120, "interval": 1.2, "range": 0.8, "speed": 1.0, "radius": 0.55, "flying": false, "projectile": false }, - { "id": 4, "name": "마법사", "cost": 4, "grade": 4, "hp": 360, "damage": 170, "interval": 1.4, "range": 5.5, "speed": 1.0, "radius": 0.45, "flying": false, "projectile": true }, - { "id": 5, "name": "자이언트", "cost": 5, "grade": 5, "hp": 2600, "damage": 190, "interval": 1.5, "range": 0.8, "speed": 0.75, "radius": 0.8, "flying": false, "projectile": false }, - { "id": 6, "name": "드래곤", "cost": 6, "grade": 6, "hp": 1000, "damage": 150, "interval": 1.6, "range": 3.5, "speed": 1.5, "radius": 0.7, "flying": true, "projectile": true } - ], - "castle": { - "y": 28.5, "xMin": 6.5, "xMax": 11.5, "radius": 2.0, - "hp": 15000, "hpGrowth": 1.35, - "atk": 400, "atkGrowth": 1.2, "summonGrowth": 1.2, - "enrageHpRatio": 0.5, "enrageIntervalMul": 0.75, - "regenDelay": 5, "regenPct": 1, "regenInterval": 1 - }, - "shots": [ - { "id": "normal", "name": "일반", "dmgPct": 100, "interval": 0.8, "range": 3.5, "speed": 14 }, - { "id": "splash", "name": "범위", "dmgPct": 100, "splashPct": 50, "splashRadius": 1.5, "interval": 1.3, "range": 3, "speed": 11 }, - { "id": "multi", "name": "멀티샷", "dmgPct": 60, "count": 5, "spreadDeg": 60, "width": 0.4, "interval": 1.6, "range": 4, "speed": 12 }, - { "id": "slow", "name": "감속", "dmgPct": 100, "slowPct": 50, "slowSeconds": 2.5, "interval": 1.0, "range": 3.5, "speed": 13 }, - { "id": "laser", "name": "레이저", "dmgPct": 80, "width": 0.8, "beamSeconds": 0.35, "interval": 2.2, "range": 5 } - ], - "skills": [ - { "id": "fireball", "name": "파이어 볼", "dmgPct": 100, "splashPct": 60, "splashRadius": 1.6, "interval": 7, "range": 5, "speed": 9 }, - { "id": "lightning", "name": "라이트닝", "dmgPct": 200, "stunSeconds": 1.5, "beamSeconds": 0.35, "interval": 9, "range": 4, "speed": 0 } - ], - "summons": [ - { "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, "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], - "gradeSource": "유닛 등급(2026-10-29 PD) = 일반(회색) → 고급(녹색) → 희귀(파랑) → 에픽(보라) → 전설(주황) → 불멸(빨강) → 신화(무지개) · 등급 수 = 카드의 별 수 · 지금 유닛 6종의 등급 = 마나 비용 순으로 1~6(확정 전)", - "grades": [ - { "id": 1, "name": "일반", "color": "#A2A8B6" }, - { "id": 2, "name": "고급", "color": "#4CC85A" }, - { "id": 3, "name": "희귀", "color": "#3C8CFF" }, - { "id": 4, "name": "에픽", "color": "#A95AF0" }, - { "id": 5, "name": "전설", "color": "#FF9628" }, - { "id": 6, "name": "불멸", "color": "#F04646" }, - { "id": 7, "name": "신화", "color": "rainbow" } - ], - "deckPresets": 3, - "rosterSource": "유닛 목록 = 우리 게임 유닛 그림(Resources/Art/Units/px_) 중 PD 가 고른 28종(2026-10-30 PD 「X표 제외 · 나머지만 사용」 + 「307 도 포함」 = 52종 제외 · 처음 「제작된 리소스 유닛을 모두 목록에 · 초기 보유 외 = 미보유(회색 딤드 + 자물쇠 + 미보유)」) · 초기 보유 = 전투 유닛 6종(units · 그림 = unit_art.json) · 나머지 = 미보유 · 이름 = 그림 모습으로 붙인 임시 이름(확정 전)", - "roster": [ - { "art": 101, "name": "금발 엘프" }, { "art": 102, "name": "은발 검객" }, { "art": 105, "name": "자이언트" }, { "art": 106, "name": "금발 소녀" }, { "art": 110, "name": "앨리스" }, - { "art": 112, "name": "그림자 도적" }, { "art": 116, "name": "숲 사냥꾼" }, { "art": 117, "name": "메이드" }, { "art": 120, "name": "비행사" }, { "art": 202, "name": "은발 수도사" }, - { "art": 206, "name": "노검사" }, { "art": 208, "name": "리본 소녀" }, { "art": 209, "name": "기계 기사" }, { "art": 212, "name": "사신" }, { "art": 218, "name": "흡혈귀" }, - { "art": 302, "name": "붉은 십자" }, { "art": 307, "name": "초록 마녀" }, { "art": 308, "name": "금발 전사" }, { "art": 314, "name": "백발 마도사" }, { "art": 319, "name": "사막 무희" }, { "art": 407, "name": "초록 요정" }, - { "art": 409, "name": "기사" }, { "art": 410, "name": "어둠 인형" }, { "art": 412, "name": "붉은 사제" }, { "art": 414, "name": "기계공" }, { "art": 416, "name": "궁수" }, - { "art": 418, "name": "금발 기사" }, { "art": 419, "name": "붉은 악마" } - ], - "view": { - "source": "전투 화면 비율 — 클래시 로얄 실제 전투 화면을 재서 맞춤(2026-10-29 PD 「유닛 크기를 클래시 로얄과 비슷하게 · 배경·전체 비율도 클래시 로얄 화면 비율에 맞게」). 잰 값: 맵 가로 18칸 = 화면 폭의 약 90~96% · 칸 세로 길이 ≈ 가로 × 0.81(비스듬히 내려다보는 시점) · 병사 키 ≈ 1.5칸 · 아래 카드 칸 높이 ≈ 화면 폭 × 0.34 → 전투 중에는 아래 메뉴가 없어(2026-10-29 PD) 화면 전체 1080×1920 기준 칸 가로 56 · 세로 46 px(맵 = 화면 폭의 93% · 좌우 0.65칸 = 맵 밖 장식) · 카드 칸 360 px · 유닛 그림 한 변 = 반지름 × 4칸(검사 101 px · 몸 키 약 1.6칸 · 자이언트 179 px) · 나는 유닛 = 0.8칸 위 (확정 전)", - "tileX": 56, "tileY": 46, - "sideTiles": 0.65, - "trayH": 360, - "unitArt": 4, - "flyLift": 0.8 - } -} +{ + "source": "전투 모드 클론 설계값 (2026-10-27 PD 지시 · 2026-10-28 PD 전투 수정 6건 + 추가 5건) — 원작에 없는 값 · PD 확정 전. 사거리 = 성 가장자리부터 칸 수 · 「기본 값은 절반 수준」 = 예전 값의 절반(화살 7 → 일반 3.5 · 폭탄 10 → 파이어 볼 5)으로 해석(확정 전) · 시험 창 사거리 ×4 = 맵 절반 해석. 「적은 내 유닛보다 매우 강하게 · 나는 압도적인 물량」 = 성·소환수의 공격력·체력이 내 가장 강한 유닛의 2배 이상 · 카드 1장 = 1마리(2026-10-29 PD 「한 장에 여러 마리 금지 · 물량 = 마력 코스트를 모아 많이 소환」) → 마나 충전 0.5초(1초에 검사 2마리) · 내 유닛끼리 겹치지 않게 살짝 밀어냄(무리가 퍼져 보이고 범위 공격에 한꺼번에 안 맞음)(확정 전)", + "arenaW": 18, + "arenaH": 32, + "riverY": 16, + "riverHalf": 0.9, + "bridgeSource": "다리 폭(2026-10-30 스테이지 50 기획서 「좁은 1.2칸 · 보통 2.4칸 · 넓은 4칸」) = 지나갈 수 있는 칸 수 cells(좁은 1 · 보통 2 · 넓은 4) · 그림 폭 = 칸 수 + 양옆 bridgeRail 칸 · 무작위 맵(시험) 다리 = 보통(확정 전)", + "bridgeKinds": [ + { "id": "narrow", "name": "좁은 다리", "short": "좁은", "cells": 1 }, + { "id": "normal", "name": "다리", "short": "", "cells": 2 }, + { "id": "wide", "name": "넓은 다리", "short": "넓은", "cells": 4 } + ], + "bridgeRail": 0.2, + "moat": { "name": "해자", "inner": 2.4, "outer": 3.4, "gateHalfDeg": 15, "gates": [ { "deg": [270] }, { "deg": [238, 302] } ] }, + "canyon": { "name": "협곡", "wall": 4, "jag": 1, "yFrom": 7, "yTo": 24 }, + "stageMapSeed": 5000, + "bridgeJitter": 1.5, + "mapWeights": [1, 1, 1, 1], + "mapNames": ["강 없는 평지", "다리 1개", "다리 2개", "다리 3개"], + "bridgeSets": [ { "x": [9] }, { "x": [3.5, 14.5] }, { "x": [2.5, 9, 15.5] } ], + "obstacleChance": 0.8, + "obstacleMin": 1, + "obstacleMax": 3, + "obstaclePiecesMin": 2, + "obstaclePiecesMax": 4, + "obstacleRadiusMin": 0.8, + "obstacleRadiusMax": 1.3, + "obstacleDistMin": 4.5, + "obstacleDistMax": 7.5, + "obstacleArcMin": 180, + "obstacleArcMax": 360, + "obstacleSpacing": 1.5, + "obstacleWobble": 0.3, + "obstaclePad": 0.2, + "obstacleCastleGap": 0.8, + "castleSeedPad": 1.0, + "spawnY": 1.6, + "spawnJitter": 0.7, + "unitSight": 4.5, + "unitShotSpeed": 10, + "castleRetarget": 0.1, + "summonArcMin": 205, + "summonArcMax": 335, + "flagReach": 1.0, + "unitSpacing": 0.8, + "unitPush": 6, + "rivalOutfits": [6, 2, 5, 3, 4, 7, 1], + "battleSeconds": 180, + "lastMinuteSeconds": 60, + "lastMinuteRegenMul": 2, + "manaStart": 5, + "manaRegenSeconds": 0.5, + "manaMaxBase": 10, + "manaMaxCap": 15, + "manaUpgradeCost": ["2000", "20000", "200000", "2000000", "20000000"], + "slotStart": 1, + "slotMax": 6, + "slotUnlockCost": ["500", "5000", "50000", "500000", "5000000"], + "rewardPassSeconds": 120, + "rewardPassSecondsPerStage": 60, + "units": [ + { "id": 1, "name": "검사", "cost": 1, "grade": 1, "hp": 220, "damage": 60, "interval": 1.1, "range": 0.8, "speed": 1.2, "radius": 0.45, "flying": false, "projectile": false }, + { "id": 2, "name": "궁수", "cost": 2, "grade": 2, "hp": 150, "damage": 45, "interval": 1.0, "range": 5.0, "speed": 1.2, "radius": 0.4, "flying": false, "projectile": true }, + { "id": 3, "name": "기사", "cost": 3, "grade": 3, "hp": 900, "damage": 120, "interval": 1.2, "range": 0.8, "speed": 1.0, "radius": 0.55, "flying": false, "projectile": false }, + { "id": 4, "name": "마법사", "cost": 4, "grade": 4, "hp": 360, "damage": 170, "interval": 1.4, "range": 5.5, "speed": 1.0, "radius": 0.45, "flying": false, "projectile": true }, + { "id": 5, "name": "자이언트", "cost": 5, "grade": 5, "hp": 2600, "damage": 190, "interval": 1.5, "range": 0.8, "speed": 0.75, "radius": 0.8, "flying": false, "projectile": false }, + { "id": 6, "name": "드래곤", "cost": 6, "grade": 6, "hp": 1000, "damage": 150, "interval": 1.6, "range": 3.5, "speed": 1.5, "radius": 0.7, "flying": true, "projectile": true } + ], + "castle": { + "y": 28.5, "xMin": 6.5, "xMax": 11.5, "radius": 2.0, + "hp": 15000, "hpGrowth": 1.07, + "atk": 400, "atkGrowth": 1.04, "summonGrowth": 1.05, + "enrageHpRatio": 0.5, "enrageIntervalMul": 0.75, + "regenDelay": 5, "regenPct": 1, "regenInterval": 1 + }, + "attackSource": "성 공격 — 투사체 5 · 기술 8(파이어 볼 · 라이트닝 + 2026-10-30 스테이지 50 기획서 새 기술 6) · 소환 5(근접 · 원거리 + 새 소환 3) · 성 능력 4 · 피해 = 성 공격력 × % · desc·tip = 처음 만날 때 안내 카드 글(숫자는 화면이 값에서 만든다) · 새 값 전부 확정 전", + "shots": [ + { "id": "normal", "name": "일반", "dmgPct": 100, "interval": 0.8, "range": 3.5, "speed": 14, "desc": "가장 가까운 유닛 1명에게 화살을 쏜다", "tip": "마나를 모아 많이 소환해 물량으로 밀어붙이세요" }, + { "id": "splash", "name": "범위", "dmgPct": 100, "splashPct": 50, "splashRadius": 1.5, "interval": 1.3, "range": 3, "speed": 11, "desc": "맞은 유닛 둘레에도 피해를 준다", "tip": "무리를 나눠 보내세요" }, + { "id": "multi", "name": "멀티샷", "dmgPct": 60, "count": 5, "spreadDeg": 60, "width": 0.4, "interval": 1.6, "range": 4, "speed": 12, "desc": "부채꼴로 여러 발 · 화살마다 처음 닿는 유닛 1명", "tip": "체력 많은 유닛을 앞세우세요" }, + { "id": "slow", "name": "감속", "dmgPct": 100, "slowPct": 50, "slowSeconds": 2.5, "interval": 1.0, "range": 3.5, "speed": 13, "desc": "맞은 유닛이 잠시 느려진다", "tip": "나는 유닛은 강과 바위를 넘어 빨리 닿아요" }, + { "id": "laser", "name": "레이저", "dmgPct": 80, "width": 0.8, "beamSeconds": 0.35, "interval": 2.2, "range": 5, "desc": "성 앞 직선 위의 유닛 모두에게 피해", "tip": "깃발로 옆길을 잡아 한 줄로 서지 않게 하세요" } + ], + "skills": [ + { "id": "fireball", "name": "파이어 볼", "dmgPct": 100, "splashPct": 60, "splashRadius": 1.6, "interval": 7, "range": 5, "speed": 9, "desc": "유닛이 가장 많은 곳에 불덩이 · 둘레에도 피해", "tip": "한 점에 몰리지 않게 하세요" }, + { "id": "lightning", "name": "라이트닝", "dmgPct": 200, "stunSeconds": 1.5, "beamSeconds": 0.35, "interval": 9, "range": 4, "speed": 0, "desc": "가까운 유닛에게 번개 · 큰 피해 + 기절", "tip": "값싼 유닛을 먼저 보내 번개를 받게 하세요" }, + { "id": "poisonCloud", "name": "독구름", "kind": "cloud", "fx": "poison", "air": true, "dpsPct": 20, "radius": 1.8, "duration": 5, "interval": 9, "range": 6, "desc": "유닛이 가장 많은 곳에 독구름 · 안에 있으면 계속 피해(나는 유닛도)", "tip": "무리를 흩거나 깃발로 비켜 가세요" }, + { "id": "fireField", "name": "화염 장판", "kind": "cloud", "fx": "burn", "air": false, "dpsPct": 40, "radius": 1.2, "duration": 3, "interval": 8, "range": 5, "desc": "땅에 짧고 센 불 장판 · 땅 유닛만", "tip": "빨리 지나가거나 나는 유닛으로" }, + { "id": "boulder", "name": "바위 굴리기", "kind": "boulder", "dmgPct": 150, "radius": 1.0, "speed": 6, "knock": 1.5, "interval": 10, "range": 30, "desc": "땅 유닛이 가장 많은 줄로 성 앞에서 바위를 굴린다 · 닿으면 피해 + 옆으로 밀림 · 강·바위에 닿으면 멈춤", "tip": "줄을 나눠 가거나 다른 다리로" }, + { "id": "meteor", "name": "유성우", "kind": "meteor", "air": true, "dmgPct": 80, "radius": 1.0, "count": 6, "duration": 2.4, "warn": 0.8, "interval": 15, "range": 9, "desc": "유닛 근처에 별똥별 여러 개 · 떨어지기 전 빨간 표시", "tip": "빨간 표시가 보이면 넓게 펼치세요" }, + { "id": "frostNova", "name": "얼음 파동", "kind": "nova", "air": true, "dmgPct": 50, "freezeSeconds": 1.5, "interval": 12, "range": 3, "desc": "성 둘레의 유닛을 모두 얼린다(나는 유닛도)", "tip": "원거리 유닛으로 밖에서 치세요" }, + { "id": "tornado", "name": "회오리", "kind": "tornado", "dpsPct": 15, "radius": 1.5, "speed": 2, "duration": 6, "pull": 2.5, "push": 2.5, "interval": 14, "range": 30, "desc": "성에서 아래로 내려오는 회오리 · 땅 유닛은 가운데로 끌려가고 나는 유닛은 뒤로 밀림", "tip": "회오리 길을 피해 다른 줄로" } + ], + "summons": [ + { "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, "desc": "성 둘레를 지키는 근접 병사 · 가까이 오면 공격", "tip": "원거리 유닛으로 먼저 잡으세요" }, + { "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, "desc": "성 둘레에서 활을 쏘는 병사", "tip": "체력 많은 유닛으로 다가가세요" }, + { "id": "laserGuard", "name": "레이저 수호병", "kind": "beam", "ranged": true, "interval": 12, "max": 1, "hp": 4000, "damage": 0, "attackRange": 6, "attackInterval": 3.5, "speed": 0, "guardRange": 6, "radius": 0.5, "homeDist": 1.4, "shotSpeed": 0, "beamPct": 70, "beamLength": 7, "beamWidth": 0.8, "beamSeconds": 0.35, "desc": "성 앞에 서서 가까운 유닛 쪽으로 긴 광선 · 광선 위 전원 피해", "tip": "수호병을 먼저 잡으세요" }, + { "id": "bomber", "name": "폭탄병", "kind": "bomb", "ranged": false, "interval": 8, "max": 3, "hp": 800, "damage": 0, "attackRange": 0.3, "attackInterval": 1, "speed": 2.2, "guardRange": 14, "radius": 0.4, "homeDist": 1.2, "shotSpeed": 0, "blastPct": 120, "blastRadius": 1.5, "desc": "가장 가까운 무리로 달려가 터진다", "tip": "원거리 유닛으로 미리 잡으세요" }, + { "id": "healTotem", "name": "치유 토템", "kind": "totem", "ranged": false, "interval": 15, "max": 2, "hp": 3000, "damage": 0, "attackRange": 0, "attackInterval": 1, "speed": 0, "guardRange": 0, "radius": 0.5, "homeDist": 1.0, "shotSpeed": 0, "healPct": 0.5, "desc": "움직이지 않는 토템 · 살아 있는 동안 성 체력을 계속 채운다", "tip": "깃발로 토템부터 부수세요" } + ], + "powers": [ + { "id": "shield", "name": "보호막", "interval": 16, "duration": 6, "pct": 10, "desc": "잠깐 성에 보호막 · 피해를 먼저 막는다", "tip": "보호막이 끝날 때 몰아치세요" }, + { "id": "thorns", "name": "가시 성벽", "pct": 20, "desc": "성을 근접 공격한 유닛이 준 피해 일부를 돌려받는다", "tip": "원거리 · 체력 많은 유닛으로" }, + { "id": "curse", "name": "저주 오라", "range": 3, "pct": 30, "desc": "성 둘레 유닛의 공격력이 약해진다", "tip": "사거리 긴 유닛으로 밖에서 치세요" }, + { "id": "rage2", "name": "2단 분노", "hpRatio": 0.25, "intervalMul": 0.6, "shotDmgMul": 1.5, "desc": "체력이 많이 줄면 한 번 더 분노 · 공격이 빨라지고 화살이 세진다", "tip": "마지막에는 마나를 모아 한 번에" } + ], + "fieldSource": "필드 효과 6종 — 모두 해로움(2026-10-30 PD 「필드 효과는 전부 네거티브 위주 · 예) 일정 시간 간격마다 솟아나는 가시 · 닿으면 잠시 후 솟아나는 가시 · 날씨 효과는 다른 형태로」 → 눈보라 = 바닥 빙판) · 내 땅 유닛만(나는 유닛 · 성 소환수 무시) · 피해 = 그 유닛 최대 체력의 % · 자리 = 스테이지마다 고정(유닛이 지나는 길 근처) · 수치 확정 전", + "fields": [ + { "id": "poison", "name": "독 늪", "kind": "poison", "countMin": 2, "countMax": 4, "radiusMin": 1.5, "radiusMax": 2.5, "dotPct": 4, "linger": 2, "desc": "들어간 땅 유닛이 중독돼 계속 체력을 잃는다 · 나와도 잠깐 이어짐", "tip": "깃발로 늪을 비켜 가세요" }, + { "id": "mud", "name": "진흙밭", "kind": "slow", "countMin": 2, "countMax": 3, "radiusMin": 1.6, "radiusMax": 2.2, "slowPct": 50, "desc": "땅 유닛이 느려진다", "tip": "돌아가는 길이 더 빠를 수도 있어요" }, + { "id": "lava", "name": "용암", "kind": "burn", "countMin": 2, "countMax": 3, "radiusMin": 1.3, "radiusMax": 1.8, "dotPct": 12, "desc": "땅 유닛이 머무는 동안 큰 피해", "tip": "용암 사이 길로 · 나는 유닛은 괜찮아요" }, + { "id": "ice", "name": "빙판", "kind": "freeze", "countMin": 2, "countMax": 3, "radiusMin": 1.4, "radiusMax": 1.9, "period": 10, "warn": 1.2, "stunSeconds": 1.5, "desc": "정해진 때마다 얼어붙어 위에 있는 땅 유닛을 얼린다 · 얼기 직전 하얗게 빛남", "tip": "빛나면 그 위로 보내지 마세요" }, + { "id": "spikes", "name": "솟는 가시", "kind": "spike", "countMin": 3, "countMax": 4, "radiusMin": 0.9, "radiusMax": 1.1, "period": 4, "warn": 0.8, "upSeconds": 1, "hitPct": 20, "desc": "일정 간격으로 바닥에서 가시가 솟아 위의 땅 유닛에게 피해 · 솟기 전 붉게 빛남", "tip": "가시판을 비켜 가거나 체력 많은 유닛을 앞세우세요" }, + { "id": "trap", "name": "함정 가시", "kind": "trap", "countMin": 3, "countMax": 4, "radiusMin": 0.8, "radiusMax": 1.0, "delay": 0.8, "rearm": 3, "upSeconds": 0.6, "hitPct": 30, "desc": "땅 유닛이 밟으면 잠시 뒤 가시가 솟아 그 위의 땅 유닛 모두 피해 · 다시 장전에 시간이 걸림", "tip": "무리를 한꺼번에 밟게 하지 마세요" } + ], + "fieldPlace": { "yMin": 4.5, "castleGap": 1.5, "spawnGap": 2.5, "gap": 0.6, "laneSpread": 1.4, "tries": 200 }, + "terrains": [ + { "id": "river", "name": "강과 다리", "desc": "강은 다리로만 건넌다(나는 유닛은 그냥 넘음)", "tip": "깃발로 건널 다리를 고르세요" }, + { "id": "narrow", "name": "좁은 다리", "desc": "한 줄로만 건너 무리가 길게 늘어진다", "tip": "범위 공격을 조심하세요" }, + { "id": "wide", "name": "넓은 다리", "desc": "여럿이 한꺼번에 건넌다", "tip": "물량을 몰아 보내기 좋아요" }, + { "id": "moat", "name": "해자", "desc": "성 둘레 물 고리 · 땅 유닛은 입구로만 들어간다", "tip": "원거리 · 나는 유닛은 물 밖에서 공격해요" }, + { "id": "canyon", "name": "협곡", "desc": "양옆이 절벽이라 가운데 길로만 지나간다", "tip": "길이 좁아 범위 공격에 약해요" } + ], + "chapterSource": "챕터 5개 × 10 스테이지(2026-10-30 기획서) · color = 스테이지 선택 글자색 · tint · tintAmt = 전투 바닥 그림과 스테이지 선택 배경을 그 색 쪽으로 물들이는 정도(물은 덜) · 확정 전", + "chapters": [ + { "id": 1, "name": "초원 왕국", "from": 1, "to": 10, "color": "#5BD06A", "tint": "#FFFFFF", "tintAmt": 0 }, + { "id": 2, "name": "독안개 늪지", "from": 11, "to": 20, "color": "#A8D04A", "tint": "#5E6B30", "tintAmt": 0.6 }, + { "id": 3, "name": "얼어붙은 설원", "from": 21, "to": 30, "color": "#7CC8FF", "tint": "#F2F8FF", "tintAmt": 0.75 }, + { "id": 4, "name": "불타는 화산", "from": 31, "to": 40, "color": "#FF8A5A", "tint": "#6A3A30", "tintAmt": 0.55 }, + { "id": 5, "name": "하늘 마왕성", "from": 41, "to": 50, "color": "#C49BFF", "tint": "#7A6E9E", "tintAmt": 0.55 } + ], + "rankSource": "스테이지 등급 = 성 체력 배율 · 첫 클리어 보상 배율(2026-10-30 기획서 「첫 클리어 ×3 · 보스 첫 클리어 ×5 · 다시 깨기 ×1」) · crown = 스테이지 선택 성 위 왕관 · 확정 전", + "ranks": [ + { "id": "", "name": "", "color": "#FFFFFF", "hpMul": 1, "firstMul": 3, "crown": false }, + { "id": "elite", "name": "정예", "color": "#D2B0FF", "hpMul": 1.2, "firstMul": 3, "crown": false }, + { "id": "boss", "name": "보스", "color": "#FF9C8C", "hpMul": 1.5, "firstMul": 5, "crown": true }, + { "id": "final", "name": "최종 보스", "color": "#FF6A6A", "hpMul": 2.0, "firstMul": 5, "crown": true } + ], + "stageSource": "스테이지 50 = 아래 stageLoadouts 한 줄(2026-10-30 PD 「오픈 스펙 50여 스테이지 · 맵마다 기믹 · 스테이지마다 점진적으로 새 기믹」 → 기획서 확정 · 필드만 PD 수정) · 1~8 = PD 예시 그대로 · 새 요소는 한 번에 하나 · 5의 배수 정예 · 10의 배수 보스 · 50 최종 · 지도 = 스테이지마다 고정(bridges · moat 입구 수 · canyon + stageMapSeed) · 성 체력 = hp × hpGrowth^(n−1) × 등급 배율 · 다음에 깰 스테이지까지 열림(확정 전)", + "stageLoadouts": [ + { "stage": 1, "name": "첫 번째 성", "rank": "", "bridges": [], "moat": 0, "canyon": false, "fields": [], "shots": ["normal"], "gimmicks": [] }, + { "stage": 2, "name": "멀티샷 망루", "rank": "", "bridges": [ { "x": 9, "kind": "normal" } ], "moat": 0, "canyon": false, "fields": [], "shots": ["multi"], "gimmicks": [] }, + { "stage": 3, "name": "병영 요새", "rank": "", "bridges": [ { "x": 9, "kind": "normal" } ], "moat": 0, "canyon": false, "fields": [], "shots": ["normal"], "gimmicks": ["summonMelee"] }, + { "stage": 4, "name": "번개 탑", "rank": "", "bridges": [ { "x": 4, "kind": "normal" }, { "x": 14, "kind": "normal" } ], "moat": 0, "canyon": false, "fields": [], "shots": ["normal"], "gimmicks": ["lightning"] }, + { "stage": 5, "name": "불꽃 보루", "rank": "elite", "bridges": [ { "x": 9, "kind": "normal" } ], "moat": 0, "canyon": false, "fields": [], "shots": ["normal"], "gimmicks": ["fireball"] }, + { "stage": 6, "name": "쌍검 성채", "rank": "", "bridges": [], "moat": 0, "canyon": false, "fields": [], "shots": ["multi"], "gimmicks": ["summonMelee"] }, + { "stage": 7, "name": "독안개 습지성", "rank": "", "bridges": [], "moat": 0, "canyon": false, "fields": ["poison"], "shots": ["normal"], "gimmicks": ["summonRanged", "lightning"] }, + { "stage": 8, "name": "두 갈래 다리", "rank": "", "bridges": [ { "x": 4.5, "kind": "narrow" }, { "x": 13, "kind": "wide" } ], "moat": 0, "canyon": false, "fields": [], "shots": ["normal"], "gimmicks": ["summonMelee", "fireball"] }, + { "stage": 9, "name": "폭발 화살 진지", "rank": "", "bridges": [ { "x": 3, "kind": "normal" }, { "x": 9, "kind": "normal" }, { "x": 15, "kind": "normal" } ], "moat": 0, "canyon": false, "fields": [], "shots": ["splash"], "gimmicks": ["summonMelee"] }, + { "stage": 10, "name": "초원의 성채", "rank": "boss", "bridges": [ { "x": 5, "kind": "wide" }, { "x": 13.5, "kind": "narrow" } ], "moat": 0, "canyon": false, "fields": ["poison"], "shots": ["multi", "splash"], "gimmicks": ["fireball", "summonMelee", "summonRanged"] }, + { "stage": 11, "name": "진흙 수렁성", "rank": "", "bridges": [ { "x": 9, "kind": "normal" } ], "moat": 0, "canyon": false, "fields": ["mud"], "shots": ["normal"], "gimmicks": ["summonMelee"] }, + { "stage": 12, "name": "얼음 화살 망루", "rank": "", "bridges": [ { "x": 4, "kind": "normal" }, { "x": 14, "kind": "normal" } ], "moat": 0, "canyon": false, "fields": [], "shots": ["slow", "normal"], "gimmicks": ["fireball"] }, + { "stage": 13, "name": "독술사의 탑", "rank": "", "bridges": [], "moat": 0, "canyon": false, "fields": ["poison"], "shots": ["normal"], "gimmicks": ["poisonCloud"] }, + { "stage": 14, "name": "레이저 감시탑", "rank": "", "bridges": [ { "x": 9, "kind": "normal" } ], "moat": 0, "canyon": false, "fields": [], "shots": ["normal"], "gimmicks": ["laserGuard"] }, + { "stage": 15, "name": "늪지 관문", "rank": "elite", "bridges": [ { "x": 3, "kind": "normal" }, { "x": 9, "kind": "normal" }, { "x": 15, "kind": "normal" } ], "moat": 0, "canyon": false, "fields": ["poison", "mud"], "shots": ["slow", "multi"], "gimmicks": ["poisonCloud", "summonMelee"] }, + { "stage": 16, "name": "자폭 공방", "rank": "", "bridges": [ { "x": 4, "kind": "normal" }, { "x": 14, "kind": "normal" } ], "moat": 0, "canyon": false, "fields": [], "shots": ["normal"], "gimmicks": ["bomber"] }, + { "stage": 17, "name": "빛의 성벽", "rank": "", "bridges": [], "moat": 0, "canyon": false, "fields": [], "shots": ["laser"], "gimmicks": ["summonRanged"] }, + { "stage": 18, "name": "해자의 성", "rank": "", "bridges": [], "moat": 2, "canyon": false, "fields": [], "shots": ["normal", "slow"], "gimmicks": ["lightning", "bomber"] }, + { "stage": 19, "name": "독사의 둥지", "rank": "", "bridges": [ { "x": 9.5, "kind": "narrow" } ], "moat": 0, "canyon": false, "fields": ["poison", "mud"], "shots": ["multi"], "gimmicks": ["poisonCloud", "laserGuard"] }, + { "stage": 20, "name": "늪지의 마녀", "rank": "boss", "bridges": [], "moat": 1, "canyon": false, "fields": ["poison"], "shots": ["slow", "laser"], "gimmicks": ["poisonCloud", "laserGuard", "bomber", "summonRanged"] }, + { "stage": 21, "name": "빙판 초소", "rank": "", "bridges": [ { "x": 4, "kind": "normal" }, { "x": 14, "kind": "normal" } ], "moat": 0, "canyon": false, "fields": ["ice"], "shots": ["normal"], "gimmicks": ["summonMelee"] }, + { "stage": 22, "name": "수정 방패성", "rank": "", "bridges": [ { "x": 9, "kind": "normal" } ], "moat": 0, "canyon": false, "fields": [], "shots": ["multi"], "gimmicks": ["shield"] }, + { "stage": 23, "name": "서리 요새", "rank": "", "bridges": [], "moat": 0, "canyon": false, "fields": [], "shots": ["slow"], "gimmicks": ["frostNova"] }, + { "stage": 24, "name": "빙벽 협곡", "rank": "", "bridges": [], "moat": 0, "canyon": true, "fields": [], "shots": ["normal", "splash"], "gimmicks": ["lightning", "summonMelee"] }, + { "stage": 25, "name": "설원 관문", "rank": "elite", "bridges": [ { "x": 3, "kind": "normal" }, { "x": 9, "kind": "normal" }, { "x": 15, "kind": "normal" } ], "moat": 0, "canyon": false, "fields": ["ice"], "shots": ["slow", "multi"], "gimmicks": ["shield", "frostNova"] }, + { "stage": 26, "name": "얼음 가시 주둔지", "rank": "", "bridges": [ { "x": 4, "kind": "normal" }, { "x": 14, "kind": "normal" } ], "moat": 0, "canyon": false, "fields": ["spikes"], "shots": ["laser"], "gimmicks": ["bomber", "summonMelee"] }, + { "stage": 27, "name": "함정 마을성", "rank": "", "bridges": [], "moat": 0, "canyon": false, "fields": ["trap", "ice"], "shots": ["splash"], "gimmicks": ["poisonCloud", "summonRanged"] }, + { "stage": 28, "name": "치유의 신전", "rank": "", "bridges": [ { "x": 9, "kind": "normal" } ], "moat": 0, "canyon": false, "fields": [], "shots": ["normal"], "gimmicks": ["healTotem", "lightning"] }, + { "stage": 29, "name": "얼음 여왕의 정원", "rank": "", "bridges": [], "moat": 2, "canyon": false, "fields": ["ice"], "shots": ["slow", "laser"], "gimmicks": ["frostNova", "healTotem", "laserGuard"] }, + { "stage": 30, "name": "얼음 요새", "rank": "boss", "bridges": [], "moat": 0, "canyon": true, "fields": ["ice", "spikes"], "shots": ["slow", "multi", "laser"], "gimmicks": ["shield", "frostNova", "healTotem", "summonRanged"] }, + { "stage": 31, "name": "용암 개울", "rank": "", "bridges": [ { "x": 4, "kind": "normal" }, { "x": 14, "kind": "normal" } ], "moat": 0, "canyon": false, "fields": ["lava"], "shots": ["normal"], "gimmicks": ["fireball"] }, + { "stage": 32, "name": "바위 굴림 요새", "rank": "", "bridges": [ { "x": 9, "kind": "wide" } ], "moat": 0, "canyon": false, "fields": [], "shots": ["normal"], "gimmicks": ["boulder"] }, + { "stage": 33, "name": "화염 술사의 탑", "rank": "", "bridges": [], "moat": 0, "canyon": false, "fields": ["lava"], "shots": ["splash"], "gimmicks": ["fireField"] }, + { "stage": 34, "name": "유성 관측소", "rank": "", "bridges": [ { "x": 3, "kind": "normal" }, { "x": 9, "kind": "normal" }, { "x": 15, "kind": "normal" } ], "moat": 0, "canyon": false, "fields": [], "shots": ["multi"], "gimmicks": ["meteor"] }, + { "stage": 35, "name": "화산 관문", "rank": "elite", "bridges": [], "moat": 0, "canyon": true, "fields": ["lava"], "shots": ["splash", "laser"], "gimmicks": ["boulder", "fireball"] }, + { "stage": 36, "name": "가시 성채", "rank": "", "bridges": [ { "x": 4.5, "kind": "narrow" }, { "x": 13, "kind": "wide" } ], "moat": 0, "canyon": false, "fields": ["spikes"], "shots": ["normal", "slow"], "gimmicks": ["summonMelee", "bomber", "thorns"] }, + { "stage": 37, "name": "잿빛 해자", "rank": "", "bridges": [], "moat": 2, "canyon": false, "fields": ["lava"], "shots": ["multi"], "gimmicks": ["fireField", "laserGuard"] }, + { "stage": 38, "name": "화산재 평원", "rank": "", "bridges": [], "moat": 0, "canyon": false, "fields": ["lava", "trap"], "shots": ["laser"], "gimmicks": ["meteor", "healTotem"] }, + { "stage": 39, "name": "불꽃 협곡", "rank": "", "bridges": [], "moat": 0, "canyon": true, "fields": ["mud"], "shots": ["splash", "slow"], "gimmicks": ["boulder", "poisonCloud", "summonRanged"] }, + { "stage": 40, "name": "화산의 군주", "rank": "boss", "bridges": [ { "x": 5, "kind": "wide" }, { "x": 13.5, "kind": "narrow" } ], "moat": 0, "canyon": false, "fields": ["lava"], "shots": ["splash", "laser"], "gimmicks": ["boulder", "meteor", "fireField", "shield"] }, + { "stage": 41, "name": "회오리 첨탑", "rank": "", "bridges": [], "moat": 0, "canyon": false, "fields": [], "shots": ["multi"], "gimmicks": ["tornado"] }, + { "stage": 42, "name": "저주받은 성", "rank": "", "bridges": [ { "x": 4, "kind": "normal" }, { "x": 14, "kind": "normal" } ], "moat": 0, "canyon": false, "fields": [], "shots": ["slow"], "gimmicks": ["curse", "summonMelee"] }, + { "stage": 43, "name": "이중 방벽", "rank": "", "bridges": [ { "x": 9, "kind": "normal" } ], "moat": 0, "canyon": false, "fields": ["trap"], "shots": ["laser"], "gimmicks": ["shield", "healTotem", "bomber", "thorns"] }, + { "stage": 44, "name": "폭풍의 다리", "rank": "", "bridges": [ { "x": 3.5, "kind": "narrow" }, { "x": 9, "kind": "wide" }, { "x": 14.5, "kind": "narrow" } ], "moat": 0, "canyon": false, "fields": ["ice"], "shots": ["splash", "multi"], "gimmicks": ["tornado", "lightning"] }, + { "stage": 45, "name": "마왕성 외곽", "rank": "elite", "bridges": [], "moat": 1, "canyon": false, "fields": ["poison"], "shots": ["slow", "laser"], "gimmicks": ["poisonCloud", "laserGuard", "frostNova"] }, + { "stage": 46, "name": "그림자 협곡", "rank": "", "bridges": [], "moat": 0, "canyon": true, "fields": ["lava", "mud"], "shots": ["splash"], "gimmicks": ["boulder", "curse", "summonRanged"] }, + { "stage": 47, "name": "분노의 탑", "rank": "", "bridges": [], "moat": 0, "canyon": false, "fields": ["spikes"], "shots": ["multi", "laser"], "gimmicks": ["rage2", "meteor", "summonMelee"] }, + { "stage": 48, "name": "마왕의 정원", "rank": "", "bridges": [ { "x": 4.5, "kind": "narrow" }, { "x": 13, "kind": "wide" } ], "moat": 0, "canyon": false, "fields": ["poison", "trap"], "shots": ["slow", "splash"], "gimmicks": ["fireField", "healTotem", "bomber", "tornado"] }, + { "stage": 49, "name": "최후의 관문", "rank": "", "bridges": [], "moat": 1, "canyon": false, "fields": ["ice", "lava"], "shots": ["multi", "laser", "slow"], "gimmicks": ["shield", "frostNova", "laserGuard", "rage2", "thorns"] }, + { "stage": 50, "name": "마왕의 하늘성", "rank": "final", "bridges": [], "moat": 1, "canyon": true, "fields": ["lava", "spikes"], "shots": ["normal", "multi", "laser"], "gimmicks": ["boulder", "meteor", "poisonCloud", "shield", "rage2", "curse", "summonRanged"] } + ], + "testRangeMuls": [1, 2, 4], + "gradeSource": "유닛 등급(2026-10-29 PD) = 일반(회색) → 고급(녹색) → 희귀(파랑) → 에픽(보라) → 전설(주황) → 불멸(빨강) → 신화(무지개) · 등급 수 = 카드의 별 수 · 지금 유닛 6종의 등급 = 마나 비용 순으로 1~6(확정 전)", + "grades": [ + { "id": 1, "name": "일반", "color": "#A2A8B6" }, + { "id": 2, "name": "고급", "color": "#4CC85A" }, + { "id": 3, "name": "희귀", "color": "#3C8CFF" }, + { "id": 4, "name": "에픽", "color": "#A95AF0" }, + { "id": 5, "name": "전설", "color": "#FF9628" }, + { "id": 6, "name": "불멸", "color": "#F04646" }, + { "id": 7, "name": "신화", "color": "rainbow" } + ], + "deckPresets": 3, + "rosterSource": "유닛 목록 = 우리 게임 유닛 그림(Resources/Art/Units/px_) 중 PD 가 고른 28종(2026-10-30 PD 「X표 제외 · 나머지만 사용」 + 「307 도 포함」 = 52종 제외 · 처음 「제작된 리소스 유닛을 모두 목록에 · 초기 보유 외 = 미보유(회색 딤드 + 자물쇠 + 미보유)」) · 초기 보유 = 전투 유닛 6종(units · 그림 = unit_art.json) · 나머지 = 미보유 · 이름 = 그림 모습으로 붙인 임시 이름(확정 전)", + "roster": [ + { "art": 101, "name": "금발 엘프" }, { "art": 102, "name": "은발 검객" }, { "art": 105, "name": "자이언트" }, { "art": 106, "name": "금발 소녀" }, { "art": 110, "name": "앨리스" }, + { "art": 112, "name": "그림자 도적" }, { "art": 116, "name": "숲 사냥꾼" }, { "art": 117, "name": "메이드" }, { "art": 120, "name": "비행사" }, { "art": 202, "name": "은발 수도사" }, + { "art": 206, "name": "노검사" }, { "art": 208, "name": "리본 소녀" }, { "art": 209, "name": "기계 기사" }, { "art": 212, "name": "사신" }, { "art": 218, "name": "흡혈귀" }, + { "art": 302, "name": "붉은 십자" }, { "art": 307, "name": "초록 마녀" }, { "art": 308, "name": "금발 전사" }, { "art": 314, "name": "백발 마도사" }, { "art": 319, "name": "사막 무희" }, { "art": 407, "name": "초록 요정" }, + { "art": 409, "name": "기사" }, { "art": 410, "name": "어둠 인형" }, { "art": 412, "name": "붉은 사제" }, { "art": 414, "name": "기계공" }, { "art": 416, "name": "궁수" }, + { "art": 418, "name": "금발 기사" }, { "art": 419, "name": "붉은 악마" } + ], + "view": { + "source": "전투 화면 비율 — 클래시 로얄 실제 전투 화면을 재서 맞춤(2026-10-29 PD 「유닛 크기를 클래시 로얄과 비슷하게 · 배경·전체 비율도 클래시 로얄 화면 비율에 맞게」). 잰 값: 맵 가로 18칸 = 화면 폭의 약 90~96% · 칸 세로 길이 ≈ 가로 × 0.81(비스듬히 내려다보는 시점) · 병사 키 ≈ 1.5칸 · 아래 카드 칸 높이 ≈ 화면 폭 × 0.34 → 전투 중에는 아래 메뉴가 없어(2026-10-29 PD) 화면 전체 1080×1920 기준 칸 가로 56 · 세로 46 px(맵 = 화면 폭의 93% · 좌우 0.65칸 = 맵 밖 장식) · 카드 칸 360 px · 유닛 그림 한 변 = 반지름 × 4칸(검사 101 px · 몸 키 약 1.6칸 · 자이언트 179 px) · 나는 유닛 = 0.8칸 위 (확정 전)", + "tileX": 56, "tileY": 46, + "sideTiles": 0.65, + "trayH": 360, + "unitArt": 4, + "flyLift": 0.8 + } +} diff --git a/Assets/Scripts/Sim/Battle.Stage.cs b/Assets/Scripts/Sim/Battle.Stage.cs new file mode 100644 index 0000000..f5bc1d9 --- /dev/null +++ b/Assets/Scripts/Sim/Battle.Stage.cs @@ -0,0 +1,537 @@ +using System; +using System.Collections.Generic; +using UnityEngine; +using Vector2 = UnityEngine.Vector2; + +// 스테이지 50 (2026-10-30 PD 확정 「기획서대로 · 필드 효과는 전부 네거티브 · 날씨는 다른 형태로 · 구현까지 일괄」) +// 스테이지 한 줄 = 지형(다리 폭 · 해자 · 협곡) + 필드 + 성 투사체 + 성 기믹(기술 · 소환 · 성 능력) · 지도는 스테이지마다 고정(stageMapSeed + 번호) +// 새 기술 6(독구름 · 화염 장판 · 바위 굴리기 · 유성우 · 얼음 파동 · 회오리) · 새 소환 3(레이저 수호병 · 폭탄병 · 치유 토템) · 성 능력 4(보호막 · 가시 성벽 · 저주 오라 · 2단 분노) +// 필드 6(독 늪 · 진흙밭 · 용암 · 빙판 · 솟는 가시 · 함정 가시) = 내 땅 유닛만 · 피해 = 그 유닛 최대 체력의 % · 값 = battle.json (확정 전) +namespace VS.Sim +{ + /// 필드 효과 — kind: poison(머물면 중독 · 나와도 linger 초) · slow(느려짐 slowPct) · burn(머물면 초당 dotPct) · freeze(period 초마다 얼림 stunSeconds · warn 초 전 예고) · spike(period 초마다 솟음 · warn 예고 · upSeconds 솟아 있음 · hitPct) · trap(밟으면 delay 초 뒤 솟음 · rearm 초 뒤 다시) + [Serializable] + public class BFieldDef + { + public string id, name, kind, desc, tip; + public int countMin, countMax; public float radiusMin, radiusMax; + public float dotPct, linger, slowPct, period, warn, upSeconds, stunSeconds, hitPct, delay, rearm; + } + /// 성 능력 — shield(interval 초마다 duration 초 · 성 최대 체력 pct % 흡수) · thorns(성을 근접 공격한 피해 pct % 반사) · curse(성 가장자리 range 칸 안 공격력 −pct %) · rage2(체력 hpRatio 아래 = 간격 ×intervalMul · 투사체 ×shotDmgMul) + [Serializable] public class BPowerDef { public string id, name, desc, tip; public float interval, duration, pct, range, hpRatio, intervalMul, shotDmgMul; } + [Serializable] public class BTerrainDef { public string id, name, desc, tip; } + [Serializable] public class BChapterDef { public int id, from, to; public string name, color, tint; public float tintAmt; } + [Serializable] public class BRankDef { public string id, name, color; public float hpMul, firstMul; public bool crown; } + [Serializable] public class BBridgeKind { public string id, name, @short; public int cells; } + [Serializable] public class BBridgeDef { public float x; public string kind; } + [Serializable] public class BGateSet { public float[] deg; } + /// 해자 = 성 가장자리에서 inner~outer 칸 물 고리 · 입구(gates[입구 수 − 1].deg 각도 ± gateHalfDeg)로만 · 하늘 유닛 무시 + [Serializable] public class BMoatDef { public string name; public float inner, outer, gateHalfDeg; public List gates; } + /// 협곡 = yFrom~yTo 줄에서 양옆 wall 칸(+ 줄마다 0~jag 칸) 절벽 + [Serializable] public class BCanyonDef { public string name; public int wall, jag; public float yFrom, yTo; } + /// 필드 자리 고르기 — 유닛이 지나는 길(소환 자리 → 다리 → 성 앞) 둘레 laneSpread 칸 · yMin 위 · 성 앞 castleGap · 소환 자리 spawnGap · 자리끼리 gap + [Serializable] public class BFieldPlace { public float yMin, castleGap, spawnGap, gap, laneSpread; public int tries; } + /// 스테이지 요소 하나(안내 카드 · NEW 표시) — kind = shot · skill · summon · power · field · terrain + public class BElement { public string kind, id, name, desc, tip; } + + public partial class BattleCfg + { + public List bridgeKinds; public float bridgeRail; + public BMoatDef moat; public BCanyonDef canyon; public BFieldPlace fieldPlace; + public List fields; public List powers; public List terrains; + public List chapters; public List ranks; + public int stageMapSeed; + [NonSerialized] Dictionary firstSeen; + + public BFieldDef Field(string id) => fields?.Find(f => f.id == id); + public BPowerDef Power(string id) => powers?.Find(p => p.id == id); + public BTerrainDef Terrain(string id) => terrains?.Find(t => t.id == id); + /// 다리 종류(없거나 모르는 이름 = 보통 다리 = 목록에서 short 가 빈 것) + public BBridgeKind BridgeKind(string id) => bridgeKinds?.Find(k => k.id == id) ?? bridgeKinds?.Find(k => string.IsNullOrEmpty(k.@short)); + public int BridgeCells(string id) => Math.Max(1, BridgeKind(id)?.cells ?? 1); + public BLoadout StageRow(int stage) => stageLoadouts?.Find(l => l.stage == stage); + public BChapterDef Chapter(int stage) => chapters?.Find(ch => stage >= ch.from && stage <= ch.to); + public BRankDef Rank(int stage) { var id = StageRow(stage)?.rank ?? ""; return ranks?.Find(r => r.id == id); } + public float RankHpMul(int stage) { var r = Rank(stage); return r != null && r.hpMul > 0f ? r.hpMul : 1f; } + public float FirstClearMul(int stage) { var r = Rank(stage); return r != null && r.firstMul > 0f ? r.firstMul : 1f; } + + /// 이 스테이지의 요소 — 지형(강 · 다리 종류 · 해자 · 협곡) → 필드 → 성 투사체 → 성 기믹 순서 · 이름·설명 = 각 목록 + public List Elements(int stage) + { + var l = new List(); var row = StageRow(stage); + if (row == null) return l; + void T(string id) { var t = Terrain(id); if (t != null && !l.Exists(e => e.id == id)) l.Add(new BElement { kind = "terrain", id = id, name = t.name, desc = t.desc, tip = t.tip }); } + if (row.bridges != null && row.bridges.Count > 0) { T("river"); foreach (var b in row.bridges) T(BridgeKind(b.kind)?.id); } + if (row.moat > 0) T("moat"); + if (row.canyon) T("canyon"); + foreach (var id in row.fields ?? new string[0]) { var f = Field(id); if (f != null) l.Add(new BElement { kind = "field", id = id, name = f.name, desc = f.desc, tip = f.tip }); } + foreach (var id in row.shots ?? new string[0]) { var d = Shot(id); if (d != null) l.Add(new BElement { kind = "shot", id = id, name = d.name, desc = d.desc, tip = d.tip }); } + foreach (var id in row.gimmicks ?? new string[0]) + { + var k = Skill(id); if (k != null) { l.Add(new BElement { kind = "skill", id = id, name = k.name, desc = k.desc, tip = k.tip }); continue; } + var m = Summon(id); if (m != null) { l.Add(new BElement { kind = "summon", id = id, name = m.name, desc = m.desc, tip = m.tip }); continue; } + var p = Power(id); if (p != null) l.Add(new BElement { kind = "power", id = id, name = p.name, desc = p.desc, tip = p.tip }); + } + return l; + } + + /// 요소가 처음 나오는 스테이지(없으면 0) — 스테이지 1부터 훑어 처음 만난 곳 + public int FirstStage(string id) + { + if (firstSeen == null) + { + firstSeen = new Dictionary(); + for (int s = 1; s <= StageCount; s++) foreach (var e in Elements(s)) if (!firstSeen.ContainsKey(e.id)) firstSeen[e.id] = s; + } + return firstSeen.TryGetValue(id, out var v) ? v : 0; + } + + /// 이 스테이지에서 처음 나오는 요소(NEW · 안내 카드) + public List NewAt(int stage) => Elements(stage).FindAll(e => FirstStage(e.id) == stage); + } + + /// 필드 자리 하나(맵이 만듦 · 스테이지마다 고정) + 돌아가는 상태 + public class BPatch + { + public BFieldDef def; public Vector2 pos; public float r; + public float t; // 빙판·솟는 가시 = 주기 시계 · 함정 = 단계 시계 + public int state; // 0 쉼 · 1 예고(빛남 · 함정 = 밟힘) · 2 솟음 · 3 다시 장전(함정) + public float flash; // 화면용: 방금 솟음·얼림 남은 초 + public readonly HashSet hit = new HashSet(); + public bool Inside(Vector2 p) => (p - pos).sqrMagnitude <= r * r; + } + public class BZone { public BAttackDef def; public Vector2 pos; public float r, t, life, dps; } + public class BBoulder { public BAttackDef def; public Vector2 pos; public float r, speed, damage, roll; public readonly HashSet hit = new HashSet(); } + public class BTornado { public BAttackDef def; public Vector2 pos; public float r, t, life, speed, dps; } + public class BMeteor { public BAttackDef def; public Vector2 pos; public float r, wait, t, damage; public bool shown; } + public class BPower { public BPowerDef def; public float cd, left; } + + public partial class BMap + { + public string[] BridgeKinds = new string[0]; + public float[] BridgeLane = new float[0]; // 다리마다 지나갈 칸 폭의 절반(칸 수 / 2) + public float[] BridgeHalf = new float[0]; // 다리마다 그림 폭의 절반(+ 난간 bridgeRail) + public int MoatGates; public float[] GateDeg = new float[0]; + public bool Canyon; + public readonly List Patches = new List(); + public string Label = ""; + + void SetTerrain(float[] bridges, string[] kinds, int moat, bool canyon) + { + Bridges = bridges; BridgeKinds = kinds; Canyon = canyon; + BridgeLane = new float[bridges.Length]; BridgeHalf = new float[bridges.Length]; + for (int i = 0; i < bridges.Length; i++) { BridgeLane[i] = C.BridgeCells(kinds[i]) * 0.5f; BridgeHalf[i] = BridgeLane[i] + C.bridgeRail; } + var gs = C.moat?.gates; + MoatGates = C.moat != null && gs != null && gs.Count > 0 ? Mathf.Clamp(moat, 0, gs.Count) : 0; + GateDeg = MoatGates > 0 ? gs[MoatGates - 1].deg ?? new float[0] : new float[0]; + } + + public bool OnBridge(float x) { for (int i = 0; i < Bridges.Length; i++) if (Mathf.Abs(x - Bridges[i]) <= BridgeLane[i] + 1e-3f) return true; return false; } + public float MoatIn => C.castle.radius + (C.moat?.inner ?? 0f); + public float MoatOut => C.castle.radius + (C.moat?.outer ?? 0f); + + /// 해자 물 = 성 가운데에서 MoatIn~MoatOut 거리 · 입구 각도 ± gateHalfDeg 는 땅 + public bool InMoat(Vector2 p) + { + if (MoatGates <= 0) return false; + float d = Vector2.Distance(p, Castle); + if (d < MoatIn || d > MoatOut) return false; + float a = Mathf.Atan2(p.y - Castle.y, p.x - Castle.x) * Mathf.Rad2Deg; if (a < 0f) a += 360f; + foreach (var g in GateDeg) if (Mathf.Abs(Mathf.DeltaAngle(a, g)) <= C.moat.gateHalfDeg) return false; + return true; + } + + /// 협곡 절벽 = yFrom~yTo 줄에서 양옆 wall + (줄마다 0~jag) 칸 + public bool InCanyonWall(int x, int y) + { + var k = C.canyon; + if (!Canyon || k == null || y < k.yFrom || y >= k.yTo) return false; + return x < k.wall + Jag(y, 1) || x >= W - k.wall - Jag(y, 2); + } + + int Jag(int y, int salt) => C.canyon.jag > 0 && ((y * 7 + salt * 3) % 5) < 2 ? C.canyon.jag : 0; + + /// 필드 자리 — 종류마다 countMin~Max 개(길이 하나뿐인 지도 = countMin) · 종류를 번갈아 하나씩(앞 종류가 길을 다 차지하지 않게) + /// 유닛이 지나는 길 둘레 · 강·절벽·바위·해자·소환 자리·서로 겹치지 않게 · 자리가 안 나면 차츰 옆으로 넓게 · 작은 반지름 쪽으로 · 솟는 가시는 자리마다 시계를 엇갈림 + void PlaceFields(List defs, System.Random r, Vector2 spawn) + { + if (defs == null || defs.Count == 0 || C.fieldPlace == null) return; + var fp = C.fieldPlace; var lanes = Lanes(spawn); bool oneWay = Canyon || Bridges.Length == 1; // 길이 하나뿐인 지도 + float top = Castle.y - (MoatGates > 0 ? MoatOut : C.castle.radius + C.castleSeedPad) - fp.castleGap; + var want = new int[defs.Count]; var mine = new List[defs.Count]; + for (int i = 0; i < defs.Count; i++) { want[i] = oneWay ? defs[i].countMin : r.Next(defs[i].countMin, defs[i].countMax + 1); mine[i] = new List(); } + int tries = fp.tries * defs.Count; + for (int t = 0; t < tries; t++) + { + int i = t % defs.Count; var d = defs[i]; + if (mine[i].Count >= want[i]) { bool done = true; for (int k = 0; k < defs.Count; k++) if (mine[k].Count < want[k]) done = false; if (done) break; continue; } + float progress = t / (float)tries, spread = fp.laneSpread * (1f + 2.5f * progress); + float rad = Mathf.Lerp(d.radiusMin, d.radiusMax, (float)r.NextDouble() * (1f - progress)); + var lane = r.NextDouble() < 0.5 ? lanes[0] : lanes[r.Next(lanes.Count)]; // 절반은 실제로 걷는 길 위(깃발로 비켜 가게) + var p = PointOn(lane, Mathf.Lerp(0.12f, 0.92f, (float)r.NextDouble())) + + new Vector2(((float)r.NextDouble() * 2f - 1f) * spread, ((float)r.NextDouble() * 2f - 1f) * spread * 0.5f); + if (p.y < fp.yMin || p.y > top - rad * 0.5f || p.x < rad || p.x > C.arenaW - rad) continue; + if (River && Mathf.Abs(p.y - C.riverY) < C.riverHalf + rad * 0.8f) continue; + if (Blocked(p) || Blocked(p + new Vector2(rad * 0.8f, 0f)) || Blocked(p - new Vector2(rad * 0.8f, 0f))) continue; + if (Vector2.Distance(p, spawn) < C.spawnJitter + fp.spawnGap + rad) continue; + bool clash = false; foreach (var q in Patches) if (Vector2.Distance(p, q.pos) < rad + q.r + fp.gap) { clash = true; break; } + if (clash) continue; + var patch = new BPatch { def = d, pos = p, r = rad }; + Patches.Add(patch); mine[i].Add(patch); + } + for (int i = 0; i < defs.Count; i++) if (defs[i].kind == "spike") for (int k = 0; k < mine[i].Count; k++) mine[i][k].t = defs[i].period * k / mine[i].Count; + } + + /// 실제로 걷는 길 — 소환 자리에서 성까지 거리장을 따라 내려간 칸들(깃발 없을 때 유닛이 지나는 길) + Vector2[] WalkPath(Vector2 spawn) + { + var pts = new List { spawn }; int cur = NearestFree(CellOf(spawn)); + for (int k = 0; k < W * H && CastleField[cur] > 0f && CastleField[cur] < float.MaxValue; k++) + { + int n = Downhill(cur, CastleField); if (n < 0) break; + cur = n; if (k % 3 == 2) pts.Add(Center(cur)); + } + pts.Add(Center(cur)); + return pts.ToArray(); + } + + /// 유닛이 지나는 길 — [0] = 실제로 걷는 길 · 강이 있으면 다리마다(소환 자리 → 다리 아래 → 다리 위 → 성 앞) · 협곡 = 가운데 한 줄 · 평지 = 가운데 + 좌우 비스듬히 + List Lanes(Vector2 spawn) + { + var front = new Vector2(Castle.x, Castle.y - (MoatGates > 0 ? MoatOut : C.castle.radius + C.castleSeedPad) - 1f); + var l = new List { WalkPath(spawn) }; + if (River) foreach (var b in Bridges) l.Add(new[] { spawn, new Vector2(b, C.riverY - C.riverHalf - 1f), new Vector2(b, C.riverY + C.riverHalf + 1f), front }); + else if (Canyon) l.Add(new[] { spawn, new Vector2(C.arenaW * 0.5f, C.riverY), front }); + else { l.Add(new[] { spawn, front }); l.Add(new[] { spawn, new Vector2(C.arenaW * 0.25f, C.riverY), front }); l.Add(new[] { spawn, new Vector2(C.arenaW * 0.75f, C.riverY), front }); } + return l; + } + + static Vector2 PointOn(Vector2[] pts, float t) + { + float total = 0f; for (int i = 1; i < pts.Length; i++) total += Vector2.Distance(pts[i - 1], pts[i]); + float want = total * Mathf.Clamp01(t); + for (int i = 1; i < pts.Length; i++) + { + float seg = Vector2.Distance(pts[i - 1], pts[i]); + if (want <= seg || i == pts.Length - 1) return Vector2.Lerp(pts[i - 1], pts[i], seg > 0f ? Mathf.Clamp01(want / seg) : 0f); + want -= seg; + } + return pts[pts.Length - 1]; + } + + /// 지도 이름 — 협곡 · 해자 입구 N · 다리 N(좁은 n·넓은 n) · 아무것도 없으면 평지(무작위 맵 = 맵 이름) + string MakeLabel(bool fixedMap) + { + var parts = new List(); + if (Canyon) parts.Add(C.canyon.name); + if (MoatGates > 0) parts.Add($"{C.moat.name} 입구 {MoatGates}"); + if (Bridges.Length > 0) + { + var cnt = new Dictionary(); + foreach (var k in BridgeKinds) { var bk = C.BridgeKind(k); if (bk != null && !string.IsNullOrEmpty(bk.@short)) cnt[bk.@short] = cnt.TryGetValue(bk.@short, out var n) ? n + 1 : 1; } + var sub = new List(); foreach (var kv in cnt) sub.Add($"{kv.Key} {kv.Value}"); + parts.Add(fixedMap ? $"다리 {Bridges.Length}" + (sub.Count > 0 ? $"({string.Join("·", sub)})" : "") : C.MapName(Type)); + } + if (parts.Count == 0) parts.Add(fixedMap ? "평지" : C.MapName(Type)); + return string.Join(" + ", parts); + } + } + + public partial class Battle + { + public readonly List Zones = new List(); + public readonly List Boulders = new List(); + public readonly List Tornadoes = new List(); + public readonly List Meteors = new List(); + public readonly List Powers = new List(); + public float Shield, ShieldMax, ShieldAbsorbed, TotemHeal, ThornsDealt; + public int MinionsBlown, Frozen, FieldHits; + BPowerDef thorns, curse, rage2; BPower shieldP; + public BPowerDef CurseDef => curse; + public bool HasThorns => thorns != null; + public bool HasRage2 => rage2 != null; + public bool Rage2 => rage2 != null && CastleHp <= CastleMaxHp * rage2.hpRatio; + public float ShotDmgMul => Rage2 ? rage2.shotDmgMul : 1f; + public BPower ShieldPower => shieldP; + public bool ShieldUp => shieldP != null && shieldP.left > 0f && Shield > 0f; + + void AddPower(BPowerDef p) + { + var bp = new BPower { def = p, cd = p.interval }; + Powers.Add(bp); + switch (p.id) { case "shield": shieldP = bp; break; case "thorns": thorns = p; break; case "curse": curse = p; break; case "rage2": rage2 = p; break; } + } + + /// 저주 오라 안(성 가장자리 range 칸)이면 공격력 × (1 − pct %) + float CurseMul(BUnit u) => curse != null && DistToCastle(u) <= curse.range ? 1f - curse.pct / 100f : 1f; + void Thorns(BUnit u, float dealt) { if (thorns == null || !u.alive) return; float d = dealt * thorns.pct / 100f; ThornsDealt += d; Damage(u, d); } + static bool Ground(BUnit u) => u.alive && !u.def.flying; + + // ---------------- 필드: 내 땅 유닛만 ---------------- + void TickFields(float dt) + { + foreach (var u in Units) { u.mudMul = 1f; u.poisonFx = Mathf.Max(0f, u.poisonFx - dt); u.burnFx = Mathf.Max(0f, u.burnFx - dt); u.windFx = Mathf.Max(0f, u.windFx - dt); } + foreach (var p in Map.Patches) + { + var d = p.def; p.flash = Mathf.Max(0f, p.flash - dt); + switch (d.kind) + { + case "poison": foreach (var u in Units) if (Ground(u) && p.Inside(u.pos)) { u.poison = Mathf.Max(u.poison, d.linger); u.poisonPct = d.dotPct; } break; + case "slow": foreach (var u in Units) if (Ground(u) && p.Inside(u.pos)) u.mudMul = Mathf.Min(u.mudMul, 1f - d.slowPct / 100f); break; + case "burn": foreach (var u in Units) if (Ground(u) && p.Inside(u.pos)) { Damage(u, u.def.hp * d.dotPct / 100f * dt, false); u.burnFx = 0.2f; } break; + case "freeze": + p.t += dt; p.state = p.t >= d.period - d.warn ? 1 : 0; + if (p.t >= d.period) + { + p.t -= d.period; p.state = 0; p.flash = 0.6f; + foreach (var u in Units) if (Ground(u) && p.Inside(u.pos)) { u.freeze = Mathf.Max(u.freeze, d.stunSeconds); Frozen++; } + } + break; + case "spike": + { + p.t += dt; float c = p.t % d.period; + int st = c < d.period - d.warn - d.upSeconds ? 0 : c < d.period - d.upSeconds ? 1 : 2; + if (st == 2 && p.state != 2) { p.hit.Clear(); p.flash = d.upSeconds; } + p.state = st; + if (st == 2) SpikeHit(p); + break; + } + case "trap": + p.t += dt; + if (p.state == 0) { p.t = 0f; foreach (var u in Units) if (Ground(u) && p.Inside(u.pos)) { p.state = 1; break; } } + else if (p.state == 1) { if (p.t >= d.delay) { p.state = 2; p.t = 0f; p.hit.Clear(); p.flash = d.upSeconds; SpikeHit(p); } } + else if (p.state == 2) { SpikeHit(p); if (p.t >= d.upSeconds) { p.state = 3; p.t = 0f; } } + else if (p.t >= d.rearm) { p.state = 0; p.t = 0f; } + break; + } + } + // 중독 = 남은 시간 동안 초당 최대 체력 poisonPct % + foreach (var u in Units) if (u.alive && u.poison > 0f) { Damage(u, u.def.hp * u.poisonPct / 100f * dt, false); u.poison = Mathf.Max(0f, u.poison - dt); u.poisonFx = 0.2f; } + } + + void SpikeHit(BPatch p) { foreach (var u in Units) if (Ground(u) && p.Inside(u.pos) && p.hit.Add(u.uid)) { Damage(u, u.def.hp * p.def.hitPct / 100f); FieldHits++; } } + + // ---------------- 새 기술: 시전 = 대상 고르기 · 실패하면 false(잠깐 뒤 다시) ---------------- + bool CastNew(BAttackDef d) + { + switch (d.kind) + { + case "cloud": + { + var t = Densest(Range(d), d.radius, d.air); if (t == null) return false; + Zones.Add(new BZone { def = d, pos = t.pos, r = d.radius, life = d.duration, dps = Atk * d.dpsPct / 100f }); + return true; + } + case "boulder": + { + if (!Column(Range(d), false, out float x)) return false; + float y0 = CastlePos.y - (Map.MoatGates > 0 ? Map.MoatOut : C.castle.radius) - d.radius - 0.2f; // 성 앞(해자가 있으면 물 바깥) + var p = new Vector2(Mathf.Clamp(x, d.radius, C.arenaW - d.radius), y0); + if (Map.Blocked(p)) return false; + Boulders.Add(new BBoulder { def = d, pos = p, r = d.radius, speed = d.speed, damage = Atk * d.dmgPct / 100f }); + return true; + } + case "meteor": + { + if (RandomUnit(Range(d), d.air) == null) return false; + int n = Math.Max(1, d.count); + for (int i = 0; i < n; i++) Meteors.Add(new BMeteor { def = d, r = d.radius, wait = d.duration * i / n, damage = Atk * d.dmgPct / 100f }); + return true; + } + case "nova": + { + var hit = Units.FindAll(u => u.alive && (d.air || !u.def.flying) && DistToCastle(u) <= Range(d)); + if (hit.Count == 0) return false; + foreach (var u in hit) { Damage(u, Atk * d.dmgPct / 100f); if (u.alive) { u.freeze = Mathf.Max(u.freeze, d.freezeSeconds); Frozen++; } } + Fx.Add(new BFx { type = "nova", a = CastlePos, r = C.castle.radius + Range(d), life = 0.6f }); + return true; + } + case "tornado": + { + if (!Column(Range(d), true, out float x)) return false; + Tornadoes.Add(new BTornado { def = d, pos = new Vector2(Mathf.Clamp(x, d.radius, C.arenaW - d.radius), CastlePos.y - C.castle.radius - d.radius), r = d.radius, life = d.duration, speed = d.speed, dps = Atk * d.dpsPct / 100f }); + return true; + } + } + return false; + } + + /// 사거리 안(성 가장자리부터)에서 둘레 radius 안에 유닛이 가장 많은 유닛 — air = 나는 유닛도 셈 + BUnit Densest(float range, float radius, bool air) + { + BUnit best = null; int bn = 0; float bd = float.MaxValue; + foreach (var u in Units) + { + if (!u.alive || (!air && u.def.flying)) continue; + float d = DistToCastle(u); if (d > range) continue; + int n = 0; foreach (var v in Units) if (v.alive && (air || !v.def.flying) && Vector2.Distance(u.pos, v.pos) <= radius) n++; + if (n > bn || (n == bn && d < bd)) { bn = n; bd = d; best = u; } + } + return best; + } + + /// 유닛이 가장 많은 세로줄(좌우 1.2칸 안) — 바위 · 회오리가 내려올 x + bool Column(float range, bool air, out float x) + { + x = 0f; int bn = 0; float bd = float.MaxValue; + foreach (var u in Units) + { + if (!u.alive || (!air && u.def.flying)) continue; + float d = DistToCastle(u); if (d > range) continue; + int n = 0; foreach (var v in Units) if (v.alive && (air || !v.def.flying) && Mathf.Abs(v.pos.x - u.pos.x) <= 1.2f) n++; + if (n > bn || (n == bn && d < bd)) { bn = n; bd = d; x = u.pos.x; } + } + return bn > 0; + } + + BUnit RandomUnit(float range, bool air) + { + var l = Units.FindAll(u => u.alive && (air || !u.def.flying) && DistToCastle(u) <= range); + return l.Count > 0 ? l[rng.Next(l.Count)] : null; + } + + /// 밀어내기 — 막힌 칸이면 반씩 줄여 다시(땅 유닛) · 맵 밖으로는 안 나감 + void Knock(BUnit u, Vector2 d) + { + for (float k = 1f; k > 0.2f; k *= 0.5f) + { + var np = new Vector2(Mathf.Clamp(u.pos.x + d.x * k, 0.3f, C.arenaW - 0.3f), Mathf.Clamp(u.pos.y + d.y * k, 0.3f, C.arenaH - 0.3f)); + if (u.def.flying || !Map.Blocked(np)) { u.pos = np; return; } + } + } + + // ---------------- 장판 · 바위 · 회오리 · 유성 ---------------- + void TickMagic(float dt) + { + for (int i = Zones.Count - 1; i >= 0; i--) + { + var z = Zones[i]; z.t += dt; + foreach (var u in Units) + { + if (!u.alive || (!z.def.air && u.def.flying) || (u.pos - z.pos).sqrMagnitude > z.r * z.r) continue; + Damage(u, z.dps * dt, false); + if (z.def.fx == "burn") u.burnFx = 0.2f; else u.poisonFx = 0.2f; + } + if (z.t >= z.life) Zones.RemoveAt(i); + } + for (int i = Boulders.Count - 1; i >= 0; i--) + { + var b = Boulders[i]; float step = b.speed * dt; + var next = b.pos + new Vector2(0f, -step); + if (next.y < 0.3f || Map.Blocked(new Vector2(next.x, next.y - b.r * 0.6f))) // 강(다리 밖) · 바위 · 절벽 · 맵 끝 = 멈춤 + { + Fx.Add(new BFx { type = "rubble", a = b.pos, r = b.r * 1.3f, life = 0.5f }); + Boulders.RemoveAt(i); continue; + } + b.pos = next; b.roll += step / Mathf.Max(0.1f, b.r); + foreach (var u in Units) + { + if (!Ground(u) || Vector2.Distance(u.pos, b.pos) > b.r + u.def.radius || !b.hit.Add(u.uid)) continue; + Damage(u, b.damage); + float side = Mathf.Abs(u.pos.x - b.pos.x) > 1e-3f ? Mathf.Sign(u.pos.x - b.pos.x) : (u.uid % 2 == 0 ? 1f : -1f); + if (u.alive) Knock(u, new Vector2(side * b.def.knock, 0f)); + } + } + for (int i = Tornadoes.Count - 1; i >= 0; i--) + { + var t = Tornadoes[i]; t.t += dt; + t.pos = new Vector2(t.pos.x, t.pos.y - t.speed * dt); + foreach (var u in Units) + { + if (!u.alive) continue; + var d = t.pos - u.pos; float dist = d.magnitude; + if (dist > t.r) continue; + Damage(u, t.dps * dt, false); u.windFx = 0.2f; + if (u.def.flying) Knock(u, new Vector2(0f, -t.def.push * dt)); // 나는 유닛 = 뒤로 밀림 + else if (dist > 0.05f) Knock(u, d / dist * Mathf.Min(dist, t.def.pull * dt)); // 땅 유닛 = 가운데로 끌려감 + } + if (t.t >= t.life || t.pos.y < 0.5f) Tornadoes.RemoveAt(i); + } + for (int i = Meteors.Count - 1; i >= 0; i--) + { + var m = Meteors[i]; + if (!m.shown) + { + if ((m.wait -= dt) > 0f) continue; + var tu = RandomUnit(Range(m.def), m.def.air); + if (tu == null) { Meteors.RemoveAt(i); continue; } + float a = (float)rng.NextDouble() * Mathf.PI * 2f, rr = (float)rng.NextDouble() * m.r * 0.8f; + m.pos = tu.pos + new Vector2(Mathf.Cos(a), Mathf.Sin(a)) * rr; m.shown = true; m.t = 0f; // 빨간 표시 자리 = 그 순간 유닛 근처(떨어질 때까지 고정) + continue; + } + if ((m.t += dt) < m.def.warn) continue; + foreach (var u in Units) if (u.alive && (m.def.air || !u.def.flying) && Vector2.Distance(u.pos, m.pos) <= m.r + u.def.radius * 0.5f) Damage(u, m.damage); + Fx.Add(new BFx { type = "blast", a = m.pos, r = m.r, life = 0.45f }); + Meteors.RemoveAt(i); + } + } + + // ---------------- 성 능력: 보호막(주기) — 가시 성벽 · 저주 오라 · 2단 분노는 항상 ---------------- + void TickPowers(float dt) + { + var p = shieldP; + if (p == null) return; + if (p.left > 0f) { if ((p.left -= dt) <= 0f) { p.left = 0f; Shield = 0f; } return; } + if ((p.cd -= dt) > 0f) return; + p.left = p.def.duration; p.cd = p.def.interval * IntervalMul; + ShieldMax = Shield = CastleMaxHp * p.def.pct / 100f; + Fx.Add(new BFx { type = "shield", a = CastlePos, r = C.castle.radius + 0.7f, life = 0.5f }); + } + + void TickTotems(float dt) + { + TotemHeal = Mathf.Max(0f, TotemHeal - dt); + float pct = 0f; foreach (var m in Minions) if (m.alive && m.def.kind == "totem") pct += m.def.healPct; + if (pct <= 0f || CastleHp <= 0f || CastleHp >= CastleMaxHp) return; + CastleHp = Mathf.Min(CastleMaxHp, CastleHp + CastleMaxHp * pct / 100f * dt); + TotemHeal = 0.3f; + } + + // ---------------- 새 소환수: 수호병(광선) · 폭탄병(달려가 터짐) · 토템(제자리) — 처리했으면 true ---------------- + bool TickSpecialMinion(BMinion m, float dt) + { + var d = m.def; + switch (d.kind) + { + case "totem": m.attacking = false; return true; + case "beam": + { + BUnit t = null; float bd = float.MaxValue; + foreach (var u in Units) { if (!u.alive) continue; float dd = Vector2.Distance(u.pos, m.pos); if (dd <= Guard(d) && dd < bd) { bd = dd; t = u; } } + m.attacking = t != null; + if (m.cd > 0f) m.cd -= dt; + if (t == null || m.cd > 0f) return true; + m.cd = d.attackInterval; + var dir = (t.pos - m.pos).normalized; var a = m.pos + dir * d.radius; var b = a + dir * d.beamLength; + float dmg = Atk * d.beamPct / 100f; + foreach (var u in Units) if (u.alive && SegDist(u.pos, a, b) <= d.beamWidth * 0.5f + u.def.radius) Damage(u, dmg); + Fx.Add(new BFx { type = "beam", a = a, b = b, r = d.beamWidth, life = d.beamSeconds }); + return true; + } + case "bomb": + { + BUnit t = null; float bd = float.MaxValue; + foreach (var u in Units) { if (!Ground(u) || Vector2.Distance(u.pos, m.home) > Guard(d)) continue; float dd = Vector2.Distance(u.pos, m.pos); if (dd < bd) { bd = dd; t = u; } } + if (t == null) + { + m.attacking = false; + if ((m.pos - m.home).sqrMagnitude > 0.0025f) Move(ref m.pos, Map.Next(m.pos, Map.FieldTo(m.home), m.home), d.speed * dt); + return true; + } + m.attacking = true; + if (bd - d.radius - t.def.radius <= d.attackRange) + { + float dmg = Atk * d.blastPct / 100f; + foreach (var u in Units) if (Ground(u) && Vector2.Distance(u.pos, m.pos) <= d.blastRadius) Damage(u, dmg); + Fx.Add(new BFx { type = "blast", a = m.pos, r = d.blastRadius, life = 0.45f }); + m.alive = false; MinionsBlown++; + return true; + } + Move(ref m.pos, Map.Next(m.pos, Map.FieldTo(t.pos), t.pos), d.speed * dt); + return true; + } + } + return false; + } + } +} diff --git a/Assets/Scripts/Sim/Battle.Stage.cs.meta b/Assets/Scripts/Sim/Battle.Stage.cs.meta new file mode 100644 index 0000000..07b154d --- /dev/null +++ b/Assets/Scripts/Sim/Battle.Stage.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: cc21938a606956a478cbfa2081ec04fb \ No newline at end of file diff --git a/Assets/Scripts/Sim/Battle.cs b/Assets/Scripts/Sim/Battle.cs index 28f9868..d4cc44f 100644 --- a/Assets/Scripts/Sim/Battle.cs +++ b/Assets/Scripts/Sim/Battle.cs @@ -4,9 +4,9 @@ using System.Numerics; using UnityEngine; using Vector2 = UnityEngine.Vector2; -// 전투 모드 — 클래시 로얄식 아레나(18×32 칸). 맵 = 강 없는 평지 · 다리 1~3개 중 무작위 + 성 둘레 바위·언덕(땅 유닛은 돌아서 간다). +// 전투 모드 — 클래시 로얄식 아레나(18×32 칸). 맵 = 스테이지마다 고정 지도(다리 폭 · 해자 · 협곡 · 필드 — Battle.Stage.cs) · 시험 설정 = 평지/다리 1~3개 무작위 + 성 둘레 바위·언덕. // 내 유닛 = 카드 1장 = 1마리 · 아래 가운데에서 바로 소환(약하지만 마나를 모아 많이 소환 = 물량) · 맵을 누르면 깃발 → 유닛이 깃발을 거쳐 성으로. -// 성(항상 위쪽 · 내 유닛보다 매우 강함) = 투사체 5종(일반·범위·멀티샷·감속·레이저) + 기믹(파이어 볼·라이트닝·소환수) + 안 맞으면 성벽 회복. +// 성(항상 위쪽 · 내 유닛보다 매우 강함) = 투사체 5종 + 기술 8 · 소환 5 · 성 능력 4 + 안 맞으면 성벽 회복 · 스테이지 50(2026-10-30 PD 확정). // 값 = Resources/Data/battle.json (설계값 · PD 확정 전) namespace VS.Sim { @@ -18,22 +18,37 @@ namespace VS.Sim /// regen = 마지막으로 맞은 뒤 regenDelay 초가 지나면 regenInterval 초마다 최대 체력의 regenPct % [Serializable] public class BCastleDef { public float y, xMin, xMax, radius, hp, hpGrowth, atk, atkGrowth, summonGrowth, enrageHpRatio, enrageIntervalMul, regenDelay, regenPct, regenInterval; } /// 성 투사체(normal·splash·multi·slow·laser) · 성 스킬(fireball·lightning) — 피해 = 성 공격력 × dmgPct% · 사거리 = 성 가장자리부터 + /// kind 가 있으면 새 기술(Battle.Stage.cs): cloud(독구름·화염 장판 = radius · duration · 초당 dpsPct%) · boulder(radius · speed · knock) · meteor(count 개를 duration 동안 · warn 초 예고) · nova(range 안 얼림 freezeSeconds) · tornado(speed · duration · pull · push) · air = 나는 유닛도 · fx = 맞은 유닛 빛깔 [Serializable] public class BAttackDef { public string id, name; public float dmgPct, interval, range, speed; public float splashPct, splashRadius, spreadDeg, slowPct, slowSeconds, width, beamSeconds, stunSeconds; public int count; + public string kind, fx, desc, tip; public bool air; + public float radius, duration, dpsPct, knock, warn, pull, push, freezeSeconds; } /// 성 소환수 — 성 둘레 집에 주둔 · 집에서 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 name; public string[] shots, gimmicks; } // 스테이지 한 줄 = 번호 · 이름 · 성 투사체 · 기믹 + /// kind: beam(레이저 수호병 = 제자리 · beamPct % 광선) · bomb(폭탄병 = 달려가 blastRadius 폭발) · totem(치유 토템 = 제자리 · 성 회복 초당 healPct %) · 없음 = 근접/원거리 + [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; + public string kind, desc, tip; public float beamPct, beamLength, beamWidth, beamSeconds, blastPct, blastRadius, healPct; + } + /// 스테이지 한 줄 = 번호 · 이름 · 등급 · 지형(다리 · 해자 입구 수 · 협곡) · 필드 · 성 투사체 · 기믹(기술 · 소환 · 성 능력) + [Serializable] + public class BLoadout + { + public int stage; public string name, rank; public string[] shots, gimmicks, fields; + public List bridges; public int moat; public bool canyon; + } [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; } [Serializable] - public class BattleCfg + public partial class BattleCfg { - public float arenaW, arenaH, riverY, riverHalf, bridgeHalf, bridgeJitter; + public float arenaW, arenaH, riverY, riverHalf, bridgeJitter; public float[] mapWeights; public string[] mapNames; public List bridgeSets; public float obstacleChance; public int obstacleMin, obstacleMax, obstaclePiecesMin, obstaclePiecesMax; public float obstacleRadiusMin, obstacleRadiusMax, obstacleDistMin, obstacleDistMax, obstacleArcMin, obstacleArcMax, obstacleSpacing, obstacleWobble, obstaclePad, obstacleCastleGap, castleSeedPad; @@ -55,7 +70,7 @@ namespace VS.Sim public BAttackDef Shot(string id) => shots.Find(s => s.id == id); public BAttackDef Skill(string id) => skills.Find(s => s.id == id); public BSummonDef Summon(string id) => summons.Find(s => s.id == id); - public string GimmickName(string id) => Skill(id)?.name ?? Summon(id)?.name ?? id; + public string GimmickName(string id) => Skill(id)?.name ?? Summon(id)?.name ?? Power(id)?.name ?? id; public string MapName(int type) => mapNames != null && type >= 0 && type < mapNames.Length ? mapNames[type] : type.ToString(); /// 단계 기본 구성 = stage 이하 중 가장 큰 stage 줄 @@ -66,35 +81,38 @@ namespace VS.Sim return best ?? new BLoadout { shots = new string[0], gimmicks = new string[0] }; } - // 스테이지 = stageLoadouts 줄 수만큼(하나씩 깨 나감) · 성 능력치는 스테이지마다 hpGrowth·atkGrowth·summonGrowth 배 + // 스테이지 = 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 CastleHpAt(int stage) => Mathf.Round(castle.hp * Mathf.Pow(castle.hpGrowth, stage - 1) * RankHpMul(stage)); 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 = 무작위 · 단계 기본 + /// 시험 설정(전투 화면 「시험」) — 음수·null = 스테이지 기본 public class BattleSetup { - public int mapType = -1; // 0 강 없는 평지 · 1~3 다리 수 + public const int StageMap = -1, RandomMap = -2; + public int mapType = StageMap; // -1 스테이지 지도(고정) · -2 무작위 · 0 강 없는 평지 · 1~3 다리 수 public int obstacles = -1; // 0 없음 · 1 있음 + public int moat = -1; // -1 스테이지 기본 · 0 없음 · 1~2 해자 입구 수 + public int canyon = -1; // -1 스테이지 기본 · 0 없음 · 1 협곡 public float castleX = -1f; - public List shots, gimmicks; // null = 단계 기본(stageLoadouts) + public List shots, gimmicks, fields; // null = 스테이지 기본(stageLoadouts) public float rangeMul = 1f; public List customObstacles; // 검사용 고정 장애물 public BattleSetup Clone() => new BattleSetup { - mapType = mapType, obstacles = obstacles, castleX = castleX, rangeMul = rangeMul, customObstacles = customObstacles, - shots = shots != null ? new List(shots) : null, gimmicks = gimmicks != null ? new List(gimmicks) : null + mapType = mapType, obstacles = obstacles, moat = moat, canyon = canyon, castleX = castleX, rangeMul = rangeMul, customObstacles = customObstacles, + shots = shots != null ? new List(shots) : null, gimmicks = gimmicks != null ? new List(gimmicks) : null, fields = fields != null ? new List(fields) : null }; } public class BObstacle { public Vector2 pos; public float r; public int kind; } // kind 0 바위 · 1 언덕 - /// 맵 = 강·다리·장애물 + 칸 단위 길찾기(8방향 · 모서리 끼어가기 금지 · 거리장) - public class BMap + /// 맵 = 강·다리·장애물(+ 해자 · 협곡 · 필드 = Battle.Stage.cs) + 칸 단위 길찾기(8방향 · 모서리 끼어가기 금지 · 거리장) + public partial class BMap { public int Type; // 0 강 없는 평지 · 1~3 다리 수 public float[] Bridges = new float[0]; @@ -110,19 +128,45 @@ namespace VS.Sim BMap(BattleCfg c) { C = c; W = Mathf.RoundToInt(c.arenaW); H = Mathf.RoundToInt(c.arenaH); } - public static BMap Generate(BattleCfg c, System.Random rng, BattleSetup s, Vector2 spawn) + /// 스테이지 지도(시험 설정 없음 · mapType -1) = 그 스테이지 줄의 지형 + 고정 난수(stageMapSeed + 번호) → 매번 같은 지도 + /// 그 밖(시험 설정 맵 지정 · 무작위) = 예전 방식(평지/다리 1~3개 · 성 x · 바위·언덕 무작위) + 시험의 해자·협곡 + public static BMap Generate(BattleCfg c, System.Random rng, BattleSetup s, Vector2 spawn, int stage = 1, List fieldDefs = null) { - int type = s != null && s.mapType >= 0 ? Mathf.Clamp(s.mapType, 0, 3) : PickWeighted(c.mapWeights, rng); - float cx = s != null && s.castleX >= 0f ? s.castleX : Mathf.Lerp(c.castle.xMin, c.castle.xMax, (float)rng.NextDouble()); - bool obst = s != null && s.obstacles >= 0 ? s.obstacles == 1 : rng.NextDouble() < c.obstacleChance; - var bridges = type > 0 ? MakeBridges(c, type, rng) : new float[0]; + var row = c.StageRow(stage); + bool fixedMap = (s == null || s.mapType == BattleSetup.StageMap) && row != null; + var r = fixedMap ? new System.Random(c.stageMapSeed + stage) : rng; + int type; float cx; bool obst; float[] bridges; string[] kinds; + int moat = s != null && s.moat >= 0 ? s.moat : fixedMap ? row.moat : 0; + bool canyon = s != null && s.canyon >= 0 ? s.canyon == 1 : fixedMap && row.canyon; + if (fixedMap) + { + var bl = row.bridges ?? new List(); + type = bl.Count; cx = c.arenaW * 0.5f; + bridges = new float[bl.Count]; kinds = new string[bl.Count]; + for (int i = 0; i < bl.Count; i++) { kinds[i] = bl[i].kind; bridges[i] = SnapBridge(c, bl[i].x, c.BridgeCells(bl[i].kind)); } + obst = s != null && s.obstacles >= 0 ? s.obstacles == 1 : moat == 0 && !canyon && r.NextDouble() < c.obstacleChance; // 바위·언덕 = 해자·협곡 없는 지도만 + } + else + { + type = s != null && s.mapType >= 0 ? Mathf.Clamp(s.mapType, 0, 3) : PickWeighted(c.mapWeights, rng); + cx = s != null && s.castleX >= 0f ? s.castleX : Mathf.Lerp(c.castle.xMin, c.castle.xMax, (float)rng.NextDouble()); + obst = s != null && s.obstacles >= 0 ? s.obstacles == 1 : rng.NextDouble() < c.obstacleChance; + bridges = type > 0 ? MakeBridges(c, type, rng) : new float[0]; + kinds = new string[bridges.Length]; for (int i = 0; i < kinds.Length; i++) kinds[i] = c.BridgeKind(null)?.id; + } for (int attempt = 0; ; attempt++) { - var m = new BMap(c) { Type = type, Castle = new Vector2(cx, c.castle.y), Bridges = bridges }; + var m = new BMap(c) { Type = type, Castle = new Vector2(cx, c.castle.y) }; + m.SetTerrain(bridges, kinds, moat, canyon); if (s?.customObstacles != null) m.Obstacles.AddRange(s.customObstacles); - else if (obst && attempt < 30) m.PlaceObstacles(rng); + else if (obst && attempt < 30) m.PlaceObstacles(r); m.Build(); - if (s?.customObstacles != null || attempt >= 30 || m.Reachable(spawn)) return m; // 길이 막힌 배치는 버리고 다시 + if (s?.customObstacles != null || attempt >= 30 || m.Reachable(spawn)) // 길이 막힌 배치는 버리고 다시 + { + m.PlaceFields(fieldDefs, r, spawn); + m.Label = m.MakeLabel(fixedMap); + return m; + } } } @@ -134,17 +178,26 @@ namespace VS.Sim return w.Length - 1; } + /// 무작위 맵 다리 = 보통 다리(bridgeKinds 기본) · 칸 경계에 맞춤 static float[] MakeBridges(BattleCfg c, int n, System.Random rng) { var set = c.bridgeSets[Mathf.Clamp(n - 1, 0, c.bridgeSets.Count - 1)].x; + int cells = c.BridgeCells(null); var r = new float[set.Length]; - for (int i = 0; i < set.Length; i++) // 칸 가운데에 맞춤 - r[i] = Mathf.Clamp(Mathf.Floor(set[i] + ((float)rng.NextDouble() * 2f - 1f) * c.bridgeJitter) + 0.5f, 1.5f, c.arenaW - 1.5f); + for (int i = 0; i < set.Length; i++) r[i] = SnapBridge(c, set[i] + ((float)rng.NextDouble() * 2f - 1f) * c.bridgeJitter, cells); Array.Sort(r); - for (int i = 1; i < r.Length; i++) if (r[i] - r[i - 1] < 3f) r[i] = Mathf.Min(c.arenaW - 0.5f, r[i - 1] + 3f); + for (int i = 1; i < r.Length; i++) if (r[i] - r[i - 1] < cells + 1f) r[i] = SnapBridge(c, r[i - 1] + cells + 1f, cells); return r; } + /// 다리 가운데 = 지나갈 칸 수가 홀수면 칸 가운데 · 짝수면 칸 경계(칸 수만큼 정확히 열림) + static float SnapBridge(BattleCfg c, float x, int cells) + { + float h = cells * 0.5f; + x = Mathf.Clamp(x, h, c.arenaW - h); + return cells % 2 == 1 ? Mathf.Floor(x) + 0.5f : Mathf.Round(x); + } + /// 성 둘레(obstacleArc 각도 · obstacleDist 거리)에 바위·언덕 줄기 obstacleMin~Max 개 — 줄기 = 원 obstaclePieces 개를 접선 방향으로 겹쳐 세운 벽 void PlaceObstacles(System.Random rng) { @@ -178,9 +231,10 @@ namespace VS.Sim bool ok = Vector2.Distance(p, Castle) >= C.castle.radius; if (ok && River && Mathf.Abs(p.y - C.riverY) <= C.riverHalf) { - ok = false; // 강 = 칸 전체가 다리 위일 때만 지나감 - foreach (var b in Bridges) if (x >= b - C.bridgeHalf - 1e-3f && x + 1 <= b + C.bridgeHalf + 1e-3f) ok = true; + ok = false; // 강 = 칸 전체가 다리 길(칸 수만큼) 안일 때만 지나감 + for (int i = 0; i < Bridges.Length; i++) if (x >= Bridges[i] - BridgeLane[i] - 1e-3f && x + 1 <= Bridges[i] + BridgeLane[i] + 1e-3f) ok = true; } + if (ok && (InMoat(p) || InCanyonWall(x, y))) ok = false; // 해자 물(입구 밖) · 협곡 절벽 if (ok) foreach (var o in Obstacles) if (Vector2.Distance(p, o.pos) < o.r + C.obstaclePad) { ok = false; break; } Walk[y * W + x] = ok; } @@ -316,7 +370,10 @@ namespace VS.Sim public class BUnit { public int uid, flag; public BUnitDef def; public Vector2 pos; public float hp, cd, hitFlash, stun, slow, slowMul = 1f; public bool alive = true, attacking; // flag = 닿은 깃발 번호 - public float Speed => def.speed * (slow > 0f ? slowMul : 1f); + // 스테이지 50: freeze = 얼림(기절처럼 멈춤) · poison = 중독 남은 초(초당 최대 체력 poisonPct %) · mudMul = 진흙 속도 배율(매 틱) · cursed = 저주 오라 안 · *Fx = 화면 빛깔 남은 초 + public float freeze, poison, poisonPct, mudMul = 1f, poisonFx, burnFx, windFx; public bool cursed; + public float Speed => def.speed * (slow > 0f ? slowMul : 1f) * mudMul; + public bool Frozen => stun > 0f || freeze > 0f; } public class BMinion { public int uid, slot; public BSummonDef def; public Vector2 pos, home; public float hp, maxHp, damage, cd, hitFlash; public bool alive = true, attacking; } /// owner 0 = 내 유닛(성·소환수 공격) · 1 = 성 · 2 = 소환수 @@ -332,7 +389,7 @@ namespace VS.Sim public enum BattleState { Ready, Fighting, Won, Lost } - public class Battle + public partial class Battle { public readonly BattleCfg C; public readonly int Stage; @@ -358,12 +415,12 @@ namespace VS.Sim public Vector2 CastlePos => Map.Castle; 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 IntervalMul => Rage2 ? rage2.intervalMul : Enraged ? C.castle.enrageIntervalMul : 1f; // 2단 분노(성 능력)는 첫 분노 배율을 대신함 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); + public string MapName => Map.Label; public float Range(BAttackDef d) => d.range * RangeMul; public float Guard(BSummonDef d) => d.guardRange * RangeMul; @@ -374,13 +431,16 @@ namespace VS.Sim 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); var lo = c.Loadout(Stage); + var fieldDefs = new List(); + foreach (var id in (IEnumerable)setup?.fields ?? lo.fields ?? new string[0]) { var f = c.Field(id); if (f != null) fieldDefs.Add(f); } + Map = BMap.Generate(c, rng, setup, SpawnPoint, Stage, fieldDefs); foreach (var id in (IEnumerable)setup?.shots ?? lo.shots ?? new string[0]) { var d = c.Shot(id); if (d != null) CastleShots.Add(new BWeapon { def = d, cd = d.interval }); } foreach (var id in (IEnumerable)setup?.gimmicks ?? lo.gimmicks ?? new string[0]) { var sk = c.Skill(id); if (sk != null) { CastleSkills.Add(new BWeapon { def = sk, cd = sk.interval }); continue; } - var sm = c.Summon(id); if (sm != null) Summoners.Add(new BSummoner { def = sm, cd = sm.interval }); + var sm = c.Summon(id); if (sm != null) { Summoners.Add(new BSummoner { def = sm, cd = sm.interval }); continue; } + var pw = c.Power(id); if (pw != null) AddPower(pw); } } @@ -388,7 +448,7 @@ namespace VS.Sim public void CastleHpDebugKill() { CastleHp = 0f; } public List ShotNames() { var l = new List(); foreach (var w in CastleShots) l.Add(w.def.name); return l; } - public List GimmickNames() { var l = new List(); foreach (var w in CastleSkills) l.Add(w.def.name); foreach (var s in Summoners) l.Add(s.def.name); return l; } + public List GimmickNames() { var l = new List(); foreach (var w in CastleSkills) l.Add(w.def.name); foreach (var s in Summoners) l.Add(s.def.name); foreach (var p in Powers) l.Add(p.def.name); return l; } /// 카드 1장 = 1마리 즉시 소환 — 아래 가운데(SpawnPoint) 둘레 spawnJitter 안 public bool Deploy(BUnitDef d) @@ -428,9 +488,11 @@ namespace VS.Sim Mana = Mathf.Min(MaxMana, Mana + dt / Mathf.Max(0.01f, C.manaRegenSeconds) * (LastMinute ? C.lastMinuteRegenMul : 1f)); CastleHitFlash = Mathf.Max(0f, CastleHitFlash - dt); for (int i = Fx.Count - 1; i >= 0; i--) { Fx[i].t += dt; if (Fx[i].t >= Fx[i].life) Fx.RemoveAt(i); } + TickFields(dt); TickUnits(dt); Separate(dt); TickCastle(dt); + TickMagic(dt); TickMinions(dt); TickShots(dt); TickRegen(dt); @@ -448,7 +510,8 @@ namespace VS.Sim if (!u.alive) continue; u.hitFlash = Mathf.Max(0f, u.hitFlash - dt); u.slow = Mathf.Max(0f, u.slow - dt); - if (u.stun > 0f) { u.stun = Mathf.Max(0f, u.stun - dt); u.attacking = false; continue; } // 기절 = 이동·공격 멈춤 + float curseMul = CurseMul(u); u.cursed = curseMul < 1f; + if (u.Frozen) { u.stun = Mathf.Max(0f, u.stun - dt); u.freeze = Mathf.Max(0f, u.freeze - dt); u.attacking = false; continue; } // 기절·얼림 = 이동·공격 멈춤 bool toFlag = ToFlag(u); var m = NearestMinion(u.pos, Mathf.Max(C.unitSight, u.def.range + u.def.radius)); float edge = m != null ? Vector2.Distance(u.pos, m.pos) - u.def.radius - m.def.radius : toFlag ? float.MaxValue : DistToCastle(u); @@ -459,9 +522,10 @@ namespace VS.Sim if (u.cd <= 0f) { u.cd = u.def.interval; - if (u.def.projectile) Shots.Add(new BShot { owner = 0, type = "unit", unitId = u.def.id, pos = u.pos, target = m != null ? m.uid : 0, toMinion = m != null, damage = u.def.damage, speed = C.unitShotSpeed }); - else if (m != null) HitMinion(m, u.def.damage); - else HitCastle(u.def.damage); + float dmg = u.def.damage * curseMul; // 저주 오라 안 = 공격력 감소 + if (u.def.projectile) Shots.Add(new BShot { owner = 0, type = "unit", unitId = u.def.id, pos = u.pos, target = m != null ? m.uid : 0, toMinion = m != null, damage = dmg, speed = C.unitShotSpeed }); + else if (m != null) HitMinion(m, dmg); + else { HitCastle(dmg); Thorns(u, dmg); } // 가시 성벽 = 성을 근접 공격하면 일부 되돌림 } continue; } @@ -498,7 +562,7 @@ namespace VS.Sim for (int i = 0; i < n; i++) { var u = Units[i]; - if (!u.alive || u.stun > 0f || push[i] == Vector2.zero) continue; + if (!u.alive || u.Frozen || push[i] == Vector2.zero) continue; var np = new Vector2(Mathf.Clamp(u.pos.x + push[i].x, 0.3f, C.arenaW - 0.3f), Mathf.Clamp(u.pos.y + push[i].y, 0.3f, C.arenaH - 0.3f)); if (u.def.flying || !Map.Blocked(np)) u.pos = np; else if (!Map.Blocked(new Vector2(np.x, u.pos.y))) u.pos = new Vector2(np.x, u.pos.y); @@ -529,10 +593,12 @@ namespace VS.Sim foreach (var w in CastleSkills) { if ((w.cd -= dt) > 0f) continue; + if (!string.IsNullOrEmpty(w.def.kind)) { w.cd = CastNew(w.def) ? w.def.interval * mul : C.castleRetarget; continue; } // 새 기술(Battle.Stage.cs) var t = w.def.splashRadius > 0f ? DensestUnit(Range(w.def), w.def.splashRadius) : NearestUnit(Range(w.def)); if (t == null) { w.cd = C.castleRetarget; continue; } CastSkill(w.def, t); w.cd = w.def.interval * mul; } + TickPowers(dt); foreach (var s in Summoners) { if ((s.cd -= dt) > 0f) continue; @@ -564,7 +630,7 @@ namespace VS.Sim void FireShot(BAttackDef d, BUnit t) { - float dmg = Atk * d.dmgPct / 100f; + float dmg = Atk * d.dmgPct / 100f * ShotDmgMul; // 2단 분노 = 투사체 피해 배율 var dir = (t.pos - CastlePos).normalized; if (d.id == "laser") // 레이저 = 성 가장자리부터 사거리 끝까지 선 위 전원 동시 { @@ -667,7 +733,7 @@ namespace VS.Sim for (; slot < d.max; slot++) { bool used = false; foreach (var m in Minions) if (m.alive && m.def == d && m.slot == slot) used = true; if (!used) break; } var home = HomeFor(d, slot); var start = CastlePos + (home - CastlePos).normalized * (C.castle.radius + 0.3f); - if (Map.Blocked(start)) start = home; + if (Map.Blocked(start) || d.speed <= 0f) start = home; // 움직이지 않는 소환수(수호병 · 토템)는 제자리에서 float g = SummonMul; Minions.Add(new BMinion { uid = nextUid++, slot = slot, def = d, pos = start, home = home, maxHp = d.hp * g, hp = d.hp * g, damage = d.damage * g, cd = d.attackInterval * 0.5f }); MinionsSpawned++; @@ -680,6 +746,7 @@ namespace VS.Sim { if (!m.alive) continue; m.hitFlash = Mathf.Max(0f, m.hitFlash - dt); + if (TickSpecialMinion(m, dt)) continue; // 수호병 · 폭탄병 · 토템(Battle.Stage.cs) var d = m.def; float guard = Guard(d); BUnit t = null; float bd = float.MaxValue; foreach (var u in Units) // 집에서 guard 안에 들어온 내 유닛만 — 그 밖(내 진영)으로는 나가지 않는다 @@ -709,10 +776,11 @@ namespace VS.Sim } } - void Damage(BUnit u, float d) + /// flash = 맞은 빛(지속 피해 — 독·용암·장판 — 은 빛 없이) + void Damage(BUnit u, float d, bool flash = true) { if (u == null || !u.alive) return; - u.hp -= d; u.hitFlash = 0.15f; + u.hp -= d; if (flash) u.hitFlash = 0.15f; if (u.hp <= 0f) { u.alive = false; Killed++; } } @@ -723,12 +791,19 @@ namespace VS.Sim if (m.hp <= 0f) { m.alive = false; MinionsKilled++; } } - public void HitCastle(float d) { if (State != BattleState.Fighting) return; CastleHp -= d; CastleHitFlash = 0.12f; CastleQuiet = 0f; regenWait = 0f; } + /// 성 피해 — 보호막(성 능력)이 있으면 먼저 흡수 + public void HitCastle(float d) + { + if (State != BattleState.Fighting) return; + if (Shield > 0f) { float a = Mathf.Min(Shield, d); Shield -= a; d -= a; ShieldAbsorbed += a; } + CastleHp -= d; CastleHitFlash = 0.12f; CastleQuiet = 0f; regenWait = 0f; + } - /// 성벽 회복 — 마지막으로 맞은 뒤 regenDelay 초가 지나면 그때부터 regenInterval 초마다 최대 체력의 regenPct % (최대 체력까지) + /// 성벽 회복 — 마지막으로 맞은 뒤 regenDelay 초가 지나면 그때부터 regenInterval 초마다 최대 체력의 regenPct % (최대 체력까지) · 치유 토템 = 맞든 안 맞든 계속 void TickRegen(float dt) { var k = C.castle; + TickTotems(dt); CastleQuiet += dt; CastleHealFlash = Mathf.Max(0f, CastleHealFlash - dt); if (k.regenPct <= 0f || k.regenInterval <= 0f || CastleQuiet < k.regenDelay || CastleHp <= 0f || CastleHp >= CastleMaxHp) { regenWait = 0f; return; } while (regenWait <= 0f && CastleHp < CastleMaxHp) @@ -838,15 +913,21 @@ namespace VS.Sim public bool StageOpen(int stage) => stage >= 1 && stage <= Math.Min(BattleStage, StageCount); public int StageFocus => Math.Max(1, Math.Min(BattleStage, StageCount)); // 스테이지 선택 첫 화면 = 다음에 깰 스테이지(다 깼으면 마지막) - /// 승리 보상 = 방치 수입(PassGpsClean) × (기본 + 단계당) 초분 — 설계값 + /// 승리 보상(기본) = 방치 수입(PassGpsClean) × (기본 + 단계당) 초분 — 설계값 public BigInteger BattleReward(int stage) => new BigInteger(decimal.Ceiling(PassGpsClean() * (decimal)(BC.rewardPassSeconds + BC.rewardPassSecondsPerStage * (stage - 1)))); + /// 첫 클리어 = 지금 깰 차례인 스테이지(BattleStage) · 보상 × 등급의 firstMul(일반·정예 ×3 · 보스·최종 ×5) · 다시 깨기 = ×1 + public bool IsFirstClear(int stage) => stage == BattleStage && stage <= StageCount; + public BigInteger StageWinReward(int stage, bool first) => first ? new BigInteger(decimal.Ceiling((decimal)BattleReward(stage) * (decimal)BC.FirstClearMul(stage))) : BattleReward(stage); + public BigInteger NextWinReward(int stage) => StageWinReward(stage, IsFirstClear(stage)); + public BigInteger WinBattle(Battle b) { if (b == null || b.State != BattleState.Won) return BigInteger.Zero; - var r = BattleReward(b.Stage); + bool first = IsFirstClear(b.Stage) && !b.IsTest; + var r = StageWinReward(b.Stage, first); AddCoin(r); - if (b.Stage == BattleStage && !b.IsTest) S.battleStage = b.Stage + 1; // 다음 스테이지 열기 = 지금 깰 차례인 스테이지를 시험 설정 없이 이겼을 때만(하나씩) + if (first) S.battleStage = b.Stage + 1; // 다음 스테이지 열기 = 지금 깰 차례인 스테이지를 시험 설정 없이 이겼을 때만(하나씩) return r; } } diff --git a/Assets/Scripts/Sim/Save.cs b/Assets/Scripts/Sim/Save.cs index d5fae5c..a8304a5 100644 --- a/Assets/Scripts/Sim/Save.cs +++ b/Assets/Scripts/Sim/Save.cs @@ -49,5 +49,6 @@ namespace VS.Sim public int battleStage = 1, battleSlots, battleManaLevel; // 전투 모드 (단계 · 소환 칸 · 최대 마나 강화 횟수) public List decks = new List(); // 덱 프리셋(유닛 메뉴) — 칸마다 유닛 id · 0 = 빈 칸 public int deckSel; // 고른 프리셋 + public List seenIntros = new List(); // 안내 카드를 본 스테이지 요소(id) — 처음 만날 때 1회 } } diff --git a/Assets/Scripts/View/GameApp.Battle.cs b/Assets/Scripts/View/GameApp.Battle.cs index 1a9dd86..31fa3b5 100644 --- a/Assets/Scripts/View/GameApp.Battle.cs +++ b/Assets/Scripts/View/GameApp.Battle.cs @@ -29,9 +29,11 @@ namespace VS.View 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; } + 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)>(); readonly Dictionary unitViews = new Dictionary(); @@ -128,21 +130,15 @@ namespace VS.View 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; + if (m.River && bgTex == null) // 그려진 지형에는 강이 이미 있다 · 다리만 코드로 { - 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)); } - } + 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)); } + TintRaster(r, c.Chapter(b.Stage)); // 챕터 빛깔(늪 · 설원 · 화산 · 마왕성) — 바닥만 · 아래 다리·해자·절벽·필드는 제 빛깔 + for (int i = 0; m.River && i < m.Bridges.Length; i++) DrawBridge(r, ox + m.Bridges[i] * S, ry, rh, m.BridgeHalf[i] * S, S); // 다리 폭 = 다리마다(좁은 · 보통 · 넓은) + DrawStageTerrain(r, b, ox, S); 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) { @@ -302,6 +298,8 @@ namespace VS.View { 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) { @@ -349,6 +347,7 @@ namespace VS.View 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 비율 @@ -381,8 +380,8 @@ namespace VS.View 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); + 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 = 마나 줄 · 위 = 카드 @@ -460,6 +459,7 @@ namespace VS.View // 성 체력 막대 = 지붕 위쪽 · 맵 맨 위를 넘지 않게 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(); } @@ -469,34 +469,67 @@ namespace VS.View { 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); - rivalBox.sizeDelta = new Vector2(rivalBox.sizeDelta.x, RivalTop + rivalRows.Count * RivalRowH + 8f); + 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) + void AddRivalRow(string id, string name, string info, BWeapon w, BSummoner s, BPower p = null) { - float y = -RivalTop - rivalRows.Count * RivalRowH; + 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)); - 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)); + // 한 줄 = 왼쪽 그림 · 오른쪽 이름 + 수치 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, lastCd = w != null ? w.cd : s.cd }); + 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줄 — 피해(성 공격력 × %) · 덧붙는 효과 · 사거리(칸) (스테이지 정보 창과 같이 씀) + /// 성 공격 한 가지 설명 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); + 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.#}칸"; @@ -509,6 +542,7 @@ namespace VS.View 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; @@ -517,7 +551,7 @@ namespace VS.View 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}"; + r.info.text = SummonInfo(r.s.def, B.SummonMul, B.Atk, n); } } } @@ -544,7 +578,8 @@ namespace VS.View void OnOverlayButton() { if (B == null) return; - BeginBattle(NextOpen(B) ? B.Stage + 1 : B.Stage); + 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); @@ -591,7 +626,7 @@ namespace VS.View 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]) }; + 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() @@ -601,21 +636,28 @@ namespace VS.View 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); + if (s.fields == null) s.fields = def.fields; + var p = NewPopup("BattleTest", "전투 시험 설정", 1820, Pal.Purple); var refreshers = new List(); - float y = -10f; - var mapNames = new List { "무작위" }; mapNames.AddRange(c.mapNames); - y = TestRow(p, refreshers, y, "맵", mapNames, k => s.mapType == k - 1, k => s.mapType = k - 1); - y = TestRow(p, refreshers, y, "바위·언덕", new List { "무작위", "없음", "있음" }, k => s.obstacles == k - 1, k => s.obstacles = k - 1); + 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)); - 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 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 (설계값 · 확정 전)", 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 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)); @@ -627,20 +669,26 @@ namespace VS.View 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, 34, Pal.Ink, TextAnchor.MiddleLeft).rectTransform.Place(new Vector2(0, 1), new Vector2(1, 1), new Vector2(10, y), new Vector2(-20, 52), new Vector2(0.5f, 1)); - y -= 58f; - var row = UI.Rect("R" + label, p.body).Place(new Vector2(0, 1), new Vector2(1, 1), new Vector2(0, y), new Vector2(0, 104), new Vector2(0.5f, 1)); - var hl = row.gameObject.AddComponent(); - hl.spacing = 10; hl.childControlWidth = true; hl.childControlHeight = true; hl.childForceExpandWidth = true; hl.childForceExpandHeight = true; - for (int i = 0; i < names.Count; i++) + 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++) { - 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)); + 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 - 124f; + return y - 8f; } // ---------------- 진행 · 갱신 ---------------- @@ -661,15 +709,17 @@ namespace VS.View if (B == null || castleImg == null) return; stageText.text = $"스테이지 {B.Stage}" + (B.IsTest ? " 시험" : "") + (B.LastMinute && B.State == BattleState.Fighting ? " 마나 x2" : ""); castleHpFill.fillAmount = B.CastleMaxHp > 0 ? B.CastleHp / B.CastleMaxHp : 0; - castleHpFill.color = B.CastleHealFlash > 0f ? (Color)new Color32(90, 230, 120, 255) : Pal.Red; - castleHpText.text = Mathf.CeilToInt(B.CastleHp).ToString(); + 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}\n깃발 맵을 누르면 유닛이 깃발을 거쳐 성으로"; + 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) { @@ -710,7 +760,7 @@ namespace VS.View } v.rt.anchoredPosition = TileToLocal(u.pos) + (u.def.flying ? new Vector2(0, V.flyLift * V.tileY) : Vector2.zero); v.hp.fillAmount = Mathf.Clamp01(u.hp / Mathf.Max(1f, u.def.hp)); - v.rt.GetComponent().color = u.hitFlash > 0 ? new Color(1f, 0.6f, 0.6f) : (u.slow > 0f ? new Color(0.55f, 0.78f, 1f) : Color.white); + v.rt.GetComponent().color = UnitTint(u); 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); @@ -724,7 +774,7 @@ namespace VS.View { if (!minionViews.TryGetValue(m, out var v)) { - var im = UI.Img(unitLayer, "M" + m.uid, MinionSprite(m.def.ranged), Color.white); + 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); @@ -800,6 +850,30 @@ namespace VS.View 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); diff --git a/Assets/Scripts/View/GameApp.Stage.cs b/Assets/Scripts/View/GameApp.Stage.cs index f9fd60a..67e074d 100644 --- a/Assets/Scripts/View/GameApp.Stage.cs +++ b/Assets/Scripts/View/GameApp.Stage.cs @@ -13,15 +13,18 @@ namespace VS.View { public RectTransform StageRoot; int stageSel = 1; // 고른 스테이지 = 다음 전투 스테이지 - Text stageTitle, stageStat, stageHint, stageBubble, stageReward, stageStartLabel; - Image stageCastle, stageFrame, stageFace, stageLock, stageClear; + Text stageTitle, stageStat, stageHint, stageBubble, stageReward, stageStartLabel, stageChapter, stageRankLabel; + Image stageCastle, stageFrame, stageFace, stageLock, stageClear, stageBg, stageCrown; 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(); + stageBg = UI.Img(StageRoot, "Bg", StageBgSprite(null), Color.white); stageBg.rectTransform.Fill(); + // 챕터(스테이지 50 · 5장 × 10) — 제목 위 + stageChapter = UI.Txt(StageRoot, "Chapter", "", 40, Color.white, TextAnchor.MiddleCenter, true); + stageChapter.rectTransform.Place(new Vector2(0.5f, 1), new Vector2(0.5f, 1), new Vector2(0, -116), new Vector2(560, 60)); // 위: 시험(왼쪽) · 지도 = 스테이지 정보(오른쪽) var tb = UI.TextBtn(StageRoot, "TestBtn", "시험", Pal.Purple, 30, () => OpenBattleTest(), out _); tb.GetComponent().Place(new Vector2(0, 1), new Vector2(0, 1), new Vector2(20, -24), new Vector2(130, 72), new Vector2(0, 1)); @@ -32,21 +35,25 @@ namespace VS.View // 제목 · 상대 성 체력·공격(참고 화면의 추천 레벨 자리 — 이 게임은 전투 레벨이 없어 상대 성 능력치) 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)); + stageRankLabel = UI.Txt(StageRoot, "StatLabel", "상대 성", 36, new Color32(80, 240, 255, 255), TextAnchor.MiddleCenter, true); + stageRankLabel.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)); + 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, -975), new Vector2(660, 150)); 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)); + stageCastle.rectTransform.Place(new Vector2(0.5f, 1), new Vector2(0.5f, 1), new Vector2(0, -775), new Vector2(360, 396)); + stageCrown = UI.Icon(StageRoot, "Crown", Skin.Icon("crown"), Color.white); // 보스 · 최종 보스(등급 crown) + stageCrown.rectTransform.Place(new Vector2(0.5f, 1), new Vector2(0.5f, 1), new Vector2(0, -540), new Vector2(120, 120)); 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(); - hl.spacing = 16; hl.childAlignment = TextAnchor.MiddleCenter; hl.childControlWidth = false; hl.childControlHeight = false; hl.childForceExpandWidth = false; hl.childForceExpandHeight = false; + // 요소 그림(지형 · 필드 · 성 투사체 · 기믹) — 많으면 두 줄 · 이 스테이지에서 처음 나오는 것 = NEW + stageIcons = UI.Rect("Icons", StageRoot).Place(new Vector2(0.5f, 1), new Vector2(0.5f, 1), new Vector2(0, -1068), new Vector2(980, 170)); + var gl = stageIcons.gameObject.AddComponent(); + gl.cellSize = new Vector2(76, 76); gl.spacing = new Vector2(12, 12); gl.childAlignment = TextAnchor.MiddleCenter; gl.constraint = GridLayoutGroup.Constraint.FixedColumnCount; 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); @@ -90,25 +97,36 @@ namespace VS.View stageSel = Mathf.Clamp(s, 1, n); bool open = G.StageOpen(stageSel), clear = G.StageCleared(stageSel); stageTitle.text = $"{stageSel}. {c.StageName(stageSel)}"; + var chap = c.Chapter(stageSel); var rank = c.Rank(stageSel); + stageChapter.text = chap != null ? $"{chap.id}장 · {chap.name}" : ""; + stageChapter.color = Hex(chap?.color, Color.white); + stageBg.sprite = StageBgSprite(chap); + bool ranked = rank != null && !string.IsNullOrEmpty(rank.name); + stageRankLabel.text = ranked ? $"{rank.name} 성 · 체력 ×{rank.hpMul:0.#}" : "상대 성"; + stageRankLabel.color = ranked ? Hex(rank.color, Color.white) : (Color)new Color32(80, 240, 255, 255); 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); + stageCrown.gameObject.SetActive(rank != null && rank.crown); stageCrown.color = dim; stageLock.gameObject.SetActive(!open); stageClear.gameObject.SetActive(clear); - // 성 공격 그림 줄(투사체 + 기믹 · 자세한 수치는 지도 버튼) + // 요소 그림 줄(지형 · 필드 · 성 투사체 · 기믹 · 자세한 수치는 지도 버튼) — 이 스테이지에서 처음 나오는 것 = NEW var old = new List(); 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(lo.shots ?? new string[0]); - if (lo.gimmicks != null) ids.AddRange(lo.gimmicks); - foreach (var id in ids) + var els = c.Elements(stageSel); + var gl = stageIcons.GetComponent(); gl.constraintCount = Mathf.Max(1, els.Count > 9 ? (els.Count + 1) / 2 : els.Count); + foreach (var e in els) { - 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); + bool isNew = c.FirstStage(e.id) == stageSel; + var bg = UI.Img(stageIcons, "I_" + e.id, Skin.CirclePlain, e.kind == "field" || e.kind == "terrain" ? new Color(0.05f, 0.18f, 0.12f, 0.6f) : new Color(0.08f, 0.1f, 0.2f, 0.55f)); + UI.Icon(bg.transform, "Icon", SkillIcon(e.id), dim).rectTransform.Fill(10, 10, 10, 10); + if (!isNew) continue; + var nb = UI.Img(bg.transform, "New", Skin.Pill, new Color32(240, 60, 70, 255)); + nb.rectTransform.Place(new Vector2(1, 1), new Vector2(1, 1), new Vector2(6, 4), new Vector2(66, 30), new Vector2(1, 1)); + UI.Txt(nb.transform, "T", "NEW", 20, Color.white, TextAnchor.MiddleCenter, true).rectTransform.Fill(); } stagePrev.gameObject.SetActive(stageSel > 1); stageNext.gameObject.SetActive(stageSel < n); @@ -116,8 +134,9 @@ namespace VS.View 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))}"; + bool first = G.IsFirstClear(stageSel); + stageBubble.text = first ? $"첫 클리어 ×{c.FirstClearMul(stageSel):0.#}!" : $"스테이지 {stageSel} 보상!"; + stageReward.text = $"코인 {Fmt.Num(G.NextWinReward(stageSel))}"; } /// 스테이지 선택 화면으로(전투 탭 · 결과 덮개의 「스테이지 선택」) @@ -129,8 +148,8 @@ namespace VS.View ShowStage(s); } - /// 「시작」 — 열린 스테이지만 · 바로 전투 - public void StartStage(int s, int seed = -1) { if (G.StageOpen(s)) BeginBattle(s, seed); } + /// 「시작」 — 열린 스테이지만 · 처음 나오는 요소가 있으면 안내 카드(「전투 시작」) 뒤 · 아니면 바로 전투 + public void StartStage(int s, int seed = -1) { if (G.StageOpen(s) && !TryIntro(s, seed)) BeginBattle(s, seed); } /// 고른 스테이지로 새 전투를 만들고 바로 시작(시험 설정이 있으면 그것 · 시험은 잠긴 스테이지도 가능) void BeginBattle(int s, int seed = -1) @@ -142,39 +161,45 @@ namespace VS.View RefreshBattle(); } - /// 지도 버튼 = 스테이지 정보 — 상대 성 능력치 · 성 공격 설명 · 맵 · 제한 시간 · 보상 (수치 = battle.json) + /// 지도 버튼 = 스테이지 정보 — 챕터·등급 · 상대 성 능력치 · 지도·필드 · 성 공격(투사체 · 기술 · 소환 · 성 능력) · 제한 시간 · 보상 (수치 = battle.json) public void OpenStageInfo() { - var c = T.Battle; int s = stageSel; var lo = c.Loadout(s); + var c = T.Battle; int s = stageSel; var lo = c.Loadout(s); var chap = c.Chapter(s); var rank = c.Rank(s); float atk = c.CastleAtkAt(s), sm = c.SummonMulAt(s); - var p = NewPopup("StageInfo", $"{s}. {c.StageName(s)}", 1050, Pal.Blue); + var p = NewPopup("StageInfo", $"{s}. {c.StageName(s)}", 1640, Pal.Blue); string H(string t) => $"{t}"; + bool ranked = rank != null && !string.IsNullOrEmpty(rank.name), first = G.IsFirstClear(s); + var map = new Battle(c, s, c.manaMaxBase, 1).Map; // 스테이지 지도(고정) 이름 · 필드 var lines = new List { - G.StageCleared(s) ? "클리어한 스테이지" : G.StageOpen(s) ? "도전할 스테이지" : "잠김 — 앞 스테이지를 깨면 열립니다", - $"{H("상대 성")} 체력 {c.CastleHpAt(s):N0} · 공격 {Mathf.RoundToInt(atk)}", + (chap != null ? $"{chap.id}장 {chap.name} · " : "") + (G.StageCleared(s) ? "클리어한 스테이지" : G.StageOpen(s) ? "도전할 스테이지" : "잠김 — 앞 스테이지를 깨면 열립니다"), + $"{H(ranked ? rank.name + " 성" : "상대 성")} 체력 {c.CastleHpAt(s):N0} · 공격 {Mathf.RoundToInt(atk)}" + (ranked ? $" · 체력 ×{rank.hpMul:0.#}" : ""), $"체력 {c.castle.enrageHpRatio * 100f:0}% 아래 = 분노(공격 간격 ×{c.castle.enrageIntervalMul:0.##}) · {c.castle.regenDelay:0}초 안 맞으면 {c.castle.regenInterval:0.#}초마다 {c.castle.regenPct:0.#}% 회복", - "" + "", + $"{H("지도")} {map.Label}" + (map.Obstacles.Count > 0 ? $" · 바위·언덕 {map.Obstacles.Count}" : "") }; + foreach (var id in lo.fields ?? new string[0]) { var f = c.Field(id); if (f != null) lines.Add($"{H(f.name)} {FieldInfo(f)} · {map.Patches.FindAll(q => q.def == f).Count}곳"); } + lines.Add(""); 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); + var k = c.Skill(id); var m = c.Summon(id); var pw = c.Power(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.#}초마다"); + else if (m != null) lines.Add($"{H(m.name)} {SummonInfo(m, sm, atk, -1).Replace("\n", " · ")}마리 · {m.interval:0.#}초마다"); + else if (pw != null) lines.Add($"{H(pw.name)} {PowerInfo(pw, c.CastleHpAt(s)).Replace("\n", " · ")}"); } 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); + lines.Add($"{H("승리 보상")} 코인 {Fmt.Num(G.NextWinReward(s))}" + (first ? $" (첫 클리어 ×{c.FirstClearMul(s):0.#} · 다시 깨면 {Fmt.Num(G.BattleReward(s))})" : "")); + var t = UI.Txt(p.body, "Info", string.Join("\n", lines), lines.Count > 20 ? 27 : 30, Pal.Ink, TextAnchor.UpperLeft); t.supportRichText = true; t.rectTransform.Fill(30, 30, 30, 24); } - /// 배경 = 하늘(위 진한 파랑 → 지평선 하늘색) · 지평선 뒤 숲 그림자 · 풀밭 · 아래 짙은 덤불 (참고 화면 구도 · 코드 그림) - static Sprite StageBgSprite() + /// 배경 = 하늘(위 진한 파랑 → 지평선 하늘색) · 지평선 뒤 숲 그림자 · 풀밭 · 아래 짙은 덤불 (참고 화면 구도 · 코드 그림) · 챕터 빛깔(battle.json chapters tint) + static Sprite StageBgSprite(BChapterDef chap) { - if (bcache.TryGetValue("stagebg", out var s)) return s; + string key = "stagebg" + (chap?.id ?? 0); + if (bcache.TryGetValue(key, 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); @@ -190,7 +215,8 @@ namespace VS.View 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; + TintRaster(r, chap); + s = r.Sprite(); bcache[key] = s; return s; } } } diff --git a/Assets/Scripts/View/GameApp.StageFx.cs b/Assets/Scripts/View/GameApp.StageFx.cs new file mode 100644 index 0000000..5db1443 --- /dev/null +++ b/Assets/Scripts/View/GameApp.StageFx.cs @@ -0,0 +1,617 @@ +using System.Collections.Generic; +using UnityEngine; +using UnityEngine.UI; +using VS.Sim; +using Vector2 = UnityEngine.Vector2; +using Vector3 = UnityEngine.Vector3; + +// 스테이지 50 화면(2026-10-30 PD 확정) — 지형(다리 폭 · 해자 · 협곡) · 필드 6 · 새 공격 13 그림 · 챕터 빛깔 · 처음 만나는 요소 안내 카드 +// 그림 = 코드 래스터(전투 바닥 그림과 같은 칸 30 px 결 · 평면 + 짙은 테두리) · 수치 = battle.json +namespace VS.View +{ + public partial class GameApp + { + RectTransform fieldLayer; // 맵 그림 위 · 유닛 아래 = 필드 빛 · 장판 · 유성 표시 · 저주 고리 + class PatchView { public BPatch p; public Image glow, spikes; } + readonly List patchViews = new List(); + readonly List zoneViews = new List(), meteorRings = new List(), meteorBalls = new List(), boulderViews = new List(), tornadoViews = new List(), linkViews = new List(); + Image curseRing, thornRing, rageAura, shieldBubble; + + static Color Hex(string hex, Color fallback) => !string.IsNullOrEmpty(hex) && ColorUtility.TryParseHtmlString(hex, out var c) ? c : fallback; + + // ---------------- 맵 그림: 챕터 빛깔 · 다리 · 해자 · 협곡 · 필드 자리 ---------------- + /// 챕터 빛깔 — 밝기를 살려 tint 쪽으로 tintAmt 만큼(물은 덜 물들임) + static void TintRaster(Raster r, BChapterDef ch) + { + if (ch == null || ch.tintAmt <= 0f) return; + var t = Hex(ch.tint, Color.white); + for (int i = 0; i < r.Px.Length; i++) + { + var c = r.Px[i]; + float lum = c.r * 0.299f + c.g * 0.587f + c.b * 0.114f, amt = ch.tintAmt * (c.b > c.r + 0.12f && c.b > c.g + 0.02f ? 0.35f : 1f); + var target = new Color(Mathf.Clamp01(t.r * (0.5f + 0.7f * lum)), Mathf.Clamp01(t.g * (0.5f + 0.7f * lum)), Mathf.Clamp01(t.b * (0.5f + 0.7f * lum)), c.a); + r.Px[i] = Color.Lerp(c, target, amt); + } + } + + /// 물 빛깔 = 그려진 강 그림(arena_river)의 강 줄에서 뽑음(해자가 강과 같은 물로 보이게) · 없으면 코드 물빛 + static Color WaterColor(BattleCfg c, out Color light) + { + Color w = new Color32(70, 150, 230, 255); + var tex = Resources.Load("Art/Arena/arena_river"); + if (tex != null && tex.isReadable) + { + int y = Mathf.Clamp(Mathf.RoundToInt(c.riverY / c.arenaH * tex.height), 0, tex.height - 1); Color sum = Color.clear; int n = 0; + for (int k = 0; k < 9; k++) { var p = tex.GetPixel(Mathf.RoundToInt(tex.width * (0.2f + 0.075f * k)), y); if (p.b > p.r) { sum += p; n++; } } + if (n > 0) w = sum / n; + } + w.a = 1f; light = Color.Lerp(w, Color.white, 0.3f); + return w; + } + + static void DrawBridge(Raster r, float x, float ry, float rh, float hw, float S) + { + float 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(110, 70, 40, 255)); + r.RRect(x - hw + 2, ry - rh - S * 0.5f + 2, x + hw - 2, ry + rh + S * 0.5f - 2, 3, 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.18f, y0, x + hw - S * 0.18f, y0 + S * 0.2f, 2, new Color32(190, 140, 90, 255)); } + } + + /// 해자 — 성 둘레 물 고리(입구 = 나무 다리) · 래스터 = 칸 공간(S px/칸) + static void DrawMoat(Raster r, float ox, float S, BMap m, BattleCfg c) + { + if (m.MoatGates <= 0) return; + var water = WaterColor(c, out var light); Color bank = new Color32(70, 56, 44, 255); + float cx = ox + m.Castle.x * S, cy = m.Castle.y * S, rin = m.MoatIn * S, rout = m.MoatOut * S; + int x0 = Mathf.Max(0, Mathf.FloorToInt(cx - rout - 2)), x1 = Mathf.Min(r.W - 1, Mathf.CeilToInt(cx + rout + 2)), y0 = Mathf.Max(0, Mathf.FloorToInt(cy - rout - 2)), y1 = Mathf.Min(r.H - 1, Mathf.CeilToInt(cy + rout + 2)); + for (int y = y0; y <= y1; y++) + for (int x = x0; x <= x1; x++) + { + var tp = new Vector2((x + 0.5f - ox) / S, (y + 0.5f) / S); + float d = Vector2.Distance(new Vector2(x + 0.5f, y + 0.5f), new Vector2(cx, cy)); + if (d < rin - 3f || d > rout + 3f) continue; + float a = Mathf.Atan2(y + 0.5f - cy, x + 0.5f - cx) * Mathf.Rad2Deg; if (a < 0f) a += 360f; + bool gate = false; foreach (var g in m.GateDeg) if (Mathf.Abs(Mathf.DeltaAngle(a, g)) <= c.moat.gateHalfDeg) gate = true; + if (gate) continue; + if (d < rin || d > rout) { r.Px[y * r.W + x] = bank; continue; } // 둑(3 px) + float mid = Mathf.Abs(d - (rin + rout) * 0.5f) / ((rout - rin) * 0.5f); + r.Px[y * r.W + x] = mid < 0.35f ? light : water; + } + // 입구 = 물 끊긴 자리에 나무 다리(성 쪽 → 바깥으로) + foreach (var g in m.GateDeg) + { + float a = g * Mathf.Deg2Rad; var dir = new Vector2(Mathf.Cos(a), Mathf.Sin(a)); var side = new Vector2(-dir.y, dir.x); + float half = Mathf.Sin(c.moat.gateHalfDeg * Mathf.Deg2Rad) * (rin + rout) * 0.5f; + Vector2 P(float rad, float s) => new Vector2(cx, cy) + dir * rad + side * s; + r.Poly(new[] { P(rin - 4, -half), P(rout + 4, -half), P(rout + 4, half), P(rin - 4, half) }, new Color32(110, 70, 40, 255)); + r.Poly(new[] { P(rin - 2, -half + 3), P(rout + 2, -half + 3), P(rout + 2, half - 3), P(rin - 2, half - 3) }, new Color32(150, 100, 60, 255)); + for (int k = 0; k < 4; k++) { float rad = rin + (rout - rin) * (k + 0.5f) / 4f; r.Line(P(rad, -half + 5), P(rad, half - 5), S * 0.18f, new Color32(190, 140, 90, 255)); } + } + } + + /// 협곡 — 양옆 절벽(맵 밖 장식까지 이어짐) · 안쪽 끝 = 짙은 테두리 + 길 쪽 그늘 + static void DrawCanyon(Raster r, float ox, float S, BMap m, BattleCfg c) + { + if (!m.Canyon || c.canyon == null) return; + // 절벽 윗면 = 바위 바탕 한 빛깔 + 칸마다 크고 작은 돌(밝은·어두운) · 길 쪽 끝 = 밝은 턱 + 짙은 테두리 + 바닥 그늘 · 아래 끝 = 보이는 절벽 면(비스듬한 시점) + Color top = new Color32(140, 128, 120, 255), lite = new Color32(164, 152, 142, 255), dark = new Color32(112, 102, 96, 255), rim = new Color32(176, 164, 152, 255), face = new Color32(88, 78, 74, 255), edge = new Color32(50, 44, 42, 255), shade = new Color(0f, 0f, 0f, 0.22f); + int y0 = Mathf.FloorToInt(c.canyon.yFrom), y1 = Mathf.CeilToInt(c.canyon.yTo), side = Mathf.CeilToInt(c.view.sideTiles) + 1; + float H(int x, int y, int k) => Mathf.Repeat(Mathf.Sin(x * 12.9898f + y * 78.233f + k * 37.719f) * 43758.5453f, 1f); + for (int y = y0; y < y1; y++) + { + int lw = 0, rw = 0; + for (int x = 0; x < m.W; x++) { if (m.InCanyonWall(x, y)) { if (x < m.W / 2) lw = x + 1; else if (rw == 0) rw = m.W - x; } } + float py0 = y * S, py1 = (y + 1) * S, lx = ox + lw * S, rx = ox + (m.W - rw) * S; + if (lw > 0) r.RRect(-4, py0, lx, py1, 0, top); + if (rw > 0) r.RRect(rx, py0, r.W + 4, py1, 0, top); + for (int x = -side; x < m.W + side; x++) + { + if (!(x < lw || x >= m.W - rw)) continue; + float px0 = ox + x * S; + for (int k = 0; k < 3; k++) r.Circle(px0 + S * (0.15f + 0.7f * H(x, y, k)), py0 + S * (0.15f + 0.7f * H(x, y, k + 5)), S * (0.08f + 0.14f * H(x, y, k + 9)), H(x, y, k + 13) < 0.5f ? dark : lite); + } + if (lw > 0) { r.RRect(lx - S * 0.22f, py0, lx - 4, py1, 0, rim); r.RRect(lx - 4, py0, lx, py1, 0, edge); r.RRect(lx, py0, lx + S * 0.28f, py1, 0, shade); } + if (rw > 0) { r.RRect(rx + 4, py0, rx + S * 0.22f, py1, 0, rim); r.RRect(rx, py0, rx + 4, py1, 0, edge); r.RRect(rx - S * 0.28f, py0, rx, py1, 0, shade); } + // 윗줄보다 넓은 줄 = 그 튀어나온 부분의 아래(보이는) 면 + int lwUp = 0, rwUp = 0; + for (int x = 0; x < m.W; x++) { if (m.InCanyonWall(x, y + 1)) { if (x < m.W / 2) lwUp = x + 1; else if (rwUp == 0) rwUp = m.W - x; } } + if (y == y0) { r.RRect(-4, py0 - S * 0.4f, lx, py0, 0, face); r.RRect(rx, py0 - S * 0.4f, r.W + 4, py0, 0, face); } + if (lwUp > 0 && lwUp < lw) r.RRect(ox + lwUp * S, py1 - 3, lx, py1, 0, edge); + if (rwUp > 0 && rwUp < rw) r.RRect(rx, py1 - 3, ox + (m.W - rwUp) * S, py1, 0, edge); + } + } + + /// 필드 자리 바닥 그림(종류 = kind) — 독 늪 · 진흙 · 용암 · 빙판 · 가시판 · 함정판 + static void DrawPatch(Raster r, float x, float y, float rr, string kind, int seed) + { + float Rnd(int k) => Mathf.Repeat(Mathf.Sin(seed * 12.9898f + k * 78.233f) * 43758.5453f, 1f); + switch (kind) + { + case "poison": + r.Circle(x, y, rr, new Color32(54, 36, 66, 255)); r.Circle(x, y + rr * 0.03f, rr * 0.9f, new Color32(92, 64, 116, 255)); + r.Circle(x - rr * 0.08f, y + rr * 0.06f, rr * 0.68f, new Color32(108, 150, 62, 240)); + for (int k = 0; k < 6; k++) { float a = k * 1.05f + Rnd(k) * 0.6f, d = rr * (0.25f + 0.4f * Rnd(k + 9)); r.Circle(x + Mathf.Cos(a) * d, y + Mathf.Sin(a) * d, rr * (0.06f + 0.05f * Rnd(k + 3)), new Color32(176, 232, 112, 255)); } + break; + case "slow": + r.Circle(x, y, rr, new Color32(78, 52, 30, 255)); r.Circle(x, y, rr * 0.9f, new Color32(124, 88, 52, 255)); + for (int k = 0; k < 5; k++) { float a = k * 1.3f + Rnd(k), d = rr * 0.5f * Rnd(k + 5); r.Circle(x + Mathf.Cos(a) * d, y + Mathf.Sin(a) * d, rr * (0.14f + 0.1f * Rnd(k + 2)), k % 2 == 0 ? new Color32(98, 68, 40, 255) : new Color32(156, 118, 76, 255)); } + break; + case "burn": + r.Circle(x, y, rr, new Color32(48, 28, 26, 255)); r.Circle(x, y, rr * 0.86f, new Color32(196, 58, 18, 255)); + r.Circle(x - rr * 0.1f, y + rr * 0.1f, rr * 0.62f, new Color32(248, 124, 30, 255)); + for (int k = 0; k < 5; k++) { float a = k * 1.25f + Rnd(k), d = rr * 0.45f * Rnd(k + 4); r.Circle(x + Mathf.Cos(a) * d, y + Mathf.Sin(a) * d, rr * (0.07f + 0.06f * Rnd(k + 1)), new Color32(255, 214, 110, 255)); } + break; + case "freeze": + r.Circle(x, y, rr, new Color32(108, 160, 204, 255)); r.Circle(x, y, rr * 0.9f, new Color32(184, 222, 248, 255)); + r.Circle(x - rr * 0.15f, y + rr * 0.15f, rr * 0.5f, new Color32(228, 246, 255, 255)); + for (int k = 0; k < 4; k++) { float a = k * 1.57f + Rnd(k) * 0.8f; r.Line(new Vector2(x, y), new Vector2(x + Mathf.Cos(a) * rr * 0.8f, y + Mathf.Sin(a) * rr * 0.8f), 2f, new Color32(140, 190, 226, 255)); } + break; + case "spike": + case "trap": + { + bool trap = kind == "trap"; + r.Circle(x, y, rr, trap ? new Color32(84, 70, 62, 255) : new Color32(78, 74, 88, 255)); + r.Circle(x, y, rr * 0.86f, trap ? new Color32(132, 116, 102, 255) : new Color32(140, 136, 152, 255)); + if (trap) { r.RRect(x - rr * 0.45f, y - rr * 0.45f, x + rr * 0.45f, y + rr * 0.45f, 3, new Color32(78, 64, 56, 255)); r.RRect(x - rr * 0.36f, y - rr * 0.36f, x + rr * 0.36f, y + rr * 0.36f, 2, new Color32(170, 150, 128, 255)); } + int holes = trap ? 4 : 6; + for (int k = 0; k < holes; k++) { float a = k * Mathf.PI * 2f / holes + (trap ? Mathf.PI / 4f : 0f), d = rr * (trap ? 0.66f : 0.55f); r.Circle(x + Mathf.Cos(a) * d, y + Mathf.Sin(a) * d, rr * 0.09f, new Color32(40, 36, 46, 255)); } + if (!trap) r.Circle(x, y, rr * 0.1f, new Color32(40, 36, 46, 255)); + break; + } + } + } + + /// 맵 그림에 지형·필드 얹기(ArenaSprite 가 부름) — 다리 그림 폭 = 다리마다 + void DrawStageTerrain(Raster r, Battle b, float ox, float S) + { + var c = b.C; var m = b.Map; + DrawMoat(r, ox, S, m, c); + DrawCanyon(r, ox, S, m, c); + int i = 0; + foreach (var p in m.Patches) DrawPatch(r, ox + p.pos.x * S, p.pos.y * S, p.r * S, p.def.kind, ++i * 31 + b.Stage); + } + + // ---------------- 움직이는 그림(필드 빛 · 가시 · 장판 · 바위 · 회오리 · 유성 · 보호막 · 저주 · 토템 줄) ---------------- + Image GroundImg(Transform parent, string name, Sprite sp) + { + var im = UI.Img(parent, name, sp, Color.white); + im.rectTransform.anchorMin = im.rectTransform.anchorMax = new Vector2(0.5f, 0.5f); + return im; + } + + /// 땅 위 원(칸 지름 d) — 비스듬한 시점이라 화면에서는 타원 + void PlaceGround(Image im, Vector2 tile, float d) { im.rectTransform.anchoredPosition = TileToLocal(tile); im.rectTransform.sizeDelta = TileVec(Vector2.one * d); } + + void Pool(List l, int n, Transform parent, Sprite sp, string name) + { + while (l.Count < n) l.Add(GroundImg(parent, name, sp)); + for (int i = 0; i < l.Count; i++) l[i].gameObject.SetActive(i < n); + } + + void BuildStageFx() + { + foreach (var v in patchViews) { Kill(v.glow.gameObject); if (v.spikes != null) Kill(v.spikes.gameObject); } + patchViews.Clear(); + foreach (var p in B.Map.Patches) + { + var v = new PatchView { p = p, glow = GroundImg(fieldLayer, "Glow_" + p.def.id, Art.SoftCircle) }; + PlaceGround(v.glow, p.pos, p.r * 2.6f); v.glow.color = Color.clear; + if (p.def.kind == "spike" || p.def.kind == "trap") + { + v.spikes = GroundImg(fieldLayer, "Spikes_" + p.def.id, SpikeSprite()); + v.spikes.rectTransform.pivot = new Vector2(0.5f, 0.3f); + v.spikes.rectTransform.anchoredPosition = TileToLocal(p.pos - new Vector2(0f, p.r * 0.55f)); + v.spikes.rectTransform.sizeDelta = new Vector2(p.r * 2f * V.tileX, p.r * 2f * V.tileX * 0.9f); + v.spikes.gameObject.SetActive(false); + } + patchViews.Add(v); + } + var c = B.C; + if (curseRing == null) + { + rageAura = GroundImg(fieldLayer, "RageAura", Art.SoftCircle); + curseRing = GroundImg(fieldLayer, "CurseRing", RingSprite()); + thornRing = GroundImg(fieldLayer, "ThornRing", ThornRingSprite()); + shieldBubble = GroundImg(fxLayerB, "Shield", ShieldBubbleSprite()); + } + curseRing.gameObject.SetActive(B.CurseDef != null); + if (B.CurseDef != null) { PlaceGround(curseRing, B.CastlePos, (c.castle.radius + B.CurseDef.range) * 2f); curseRing.color = new Color(0.72f, 0.38f, 1f, 0.75f); } + thornRing.gameObject.SetActive(B.HasThorns); + if (B.HasThorns) PlaceGround(thornRing, B.CastlePos, (c.castle.radius + 0.7f) * 2f); + PlaceGround(rageAura, B.CastlePos, (c.castle.radius + 1.6f) * 2f); rageAura.gameObject.SetActive(false); + shieldBubble.rectTransform.anchoredPosition = TileToLocal(B.CastlePos) + new Vector2(0f, castleImg.rectTransform.sizeDelta.y * 0.12f); + shieldBubble.rectTransform.sizeDelta = castleImg.rectTransform.sizeDelta * 1.25f; + shieldBubble.gameObject.SetActive(false); + } + + void RefreshStageFx() + { + float now = Time.unscaledTime; + foreach (var v in patchViews) + { + var p = v.p; var d = p.def; Color g = Color.clear; + switch (d.kind) + { + case "freeze": g = p.flash > 0f ? new Color(0.88f, 0.97f, 1f, 0.95f * p.flash / 0.6f) : p.state == 1 ? new Color(0.92f, 0.98f, 1f, 0.45f + 0.35f * Mathf.Sin(now * 16f)) : g; break; + case "spike": g = p.state == 1 ? new Color(1f, 0.22f, 0.18f, 0.35f + 0.3f * Mathf.Sin(now * 18f)) : g; break; + case "trap": g = p.state == 1 ? new Color(1f, 0.2f, 0.15f, 0.6f + 0.3f * Mathf.Sin(now * 22f)) : p.state == 3 ? new Color(0.1f, 0.1f, 0.15f, 0.3f) : g; break; + case "burn": g = new Color(1f, 0.55f, 0.1f, 0.22f + 0.12f * Mathf.Sin(now * 3f + p.pos.x)); break; + case "poison": g = new Color(0.55f, 0.95f, 0.3f, 0.12f + 0.08f * Mathf.Sin(now * 2.5f + p.pos.y)); break; + } + v.glow.color = g; + if (v.spikes != null) v.spikes.gameObject.SetActive(p.state == 2); + } + // 장판(독구름 · 화염 장판) — 나타남·사라짐 0.25·0.4초 + Pool(zoneViews, B.Zones.Count, fieldLayer, Art.SoftCircle, "Zone"); + for (int i = 0; i < B.Zones.Count; i++) + { + var z = B.Zones[i]; var im = zoneViews[i]; + float fade = Mathf.Clamp01((z.life - z.t) / 0.4f) * Mathf.Clamp01(z.t / 0.25f), pulse = 1f + 0.05f * Mathf.Sin(now * 6f + i); + PlaceGround(im, z.pos, z.r * 2.6f * pulse); + im.color = z.def.fx == "burn" ? new Color(1f, 0.42f, 0.06f, 0.75f * fade) : new Color(0.45f, 0.9f, 0.25f, 0.7f * fade); + } + // 유성 — 빨간 표시(땅) + 떨어지는 불덩이(위) + int shown = 0; foreach (var m in B.Meteors) if (m.shown) shown++; + Pool(meteorRings, shown, fieldLayer, RingSprite(), "MeteorMark"); Pool(meteorBalls, shown, fxLayerB, FireballSprite(), "Meteor"); + int j = 0; + foreach (var m in B.Meteors) + { + if (!m.shown) continue; + float k = Mathf.Clamp01(m.t / Mathf.Max(0.01f, m.def.warn)); + PlaceGround(meteorRings[j], m.pos, m.r * 2f); meteorRings[j].color = new Color(1f, 0.15f, 0.1f, 0.45f + 0.5f * k); + var ball = meteorBalls[j].rectTransform; ball.anchoredPosition = TileToLocal(m.pos) + new Vector2(-(1f - k) * 2.5f * V.tileX, (1f - k) * 9f * V.tileY); ball.sizeDelta = Vector2.one * 1.3f * V.tileX; + j++; + } + // 바위(굴러감) · 회오리(흔들림) + Pool(boulderViews, B.Boulders.Count, fxLayerB, BoulderSprite(), "Boulder"); + for (int i = 0; i < B.Boulders.Count; i++) + { + var b = B.Boulders[i]; var rt = boulderViews[i].rectTransform; + rt.anchoredPosition = TileToLocal(b.pos) + new Vector2(0f, b.r * 0.55f * V.tileY); rt.sizeDelta = Vector2.one * b.r * 2.1f * V.tileX; + rt.localEulerAngles = new Vector3(0f, 0f, b.roll * Mathf.Rad2Deg); + } + Pool(tornadoViews, B.Tornadoes.Count, fxLayerB, TornadoSprite(), "Tornado"); + for (int i = 0; i < B.Tornadoes.Count; i++) + { + var t = B.Tornadoes[i]; var rt = tornadoViews[i].rectTransform; + rt.pivot = new Vector2(0.5f, 0.08f); rt.anchoredPosition = TileToLocal(t.pos - new Vector2(0f, t.r * 0.4f)); + rt.sizeDelta = new Vector2(t.r * 2.2f * V.tileX, t.r * 3.2f * V.tileX); rt.localEulerAngles = new Vector3(0f, 0f, Mathf.Sin(now * 7f + i) * 7f); + tornadoViews[i].color = new Color(1f, 1f, 1f, Mathf.Clamp01((t.life - t.t) / 0.5f) * 0.92f); + } + // 치유 토템 → 성 = 초록 줄 + int totems = 0; foreach (var m in B.Minions) if (m.def.kind == "totem") totems++; + Pool(linkViews, totems, fxLayerB, BeamSprite(), "TotemLink"); // 성 그림 위로(줄이 성에 가려지지 않게) + int li = 0; + foreach (var m in B.Minions) + { + if (m.def.kind != "totem") continue; + var rt = linkViews[li].rectTransform; var a = TileToLocal(m.pos); var dv = TileToLocal(B.CastlePos) - a; + rt.pivot = new Vector2(0f, 0.5f); rt.anchoredPosition = a; rt.sizeDelta = new Vector2(dv.magnitude, 18f); rt.localEulerAngles = new Vector3(0f, 0f, Mathf.Atan2(dv.y, dv.x) * Mathf.Rad2Deg); + linkViews[li].color = new Color(0.3f, 1f, 0.45f, B.TotemHeal > 0f ? 0.55f + 0.3f * Mathf.Sin(now * 8f) : 0.25f); + li++; + } + // 성 능력 — 보호막 거품 · 2단 분노 붉은 빛 · 저주 고리 맥박 + shieldBubble.gameObject.SetActive(B.ShieldUp); + if (B.ShieldUp) shieldBubble.color = new Color(1f, 1f, 1f, 0.45f + 0.45f * (B.ShieldMax > 0f ? B.Shield / B.ShieldMax : 0f)); + rageAura.gameObject.SetActive(B.Rage2); + if (B.Rage2) rageAura.color = new Color(1f, 0.08f, 0.05f, 0.55f + 0.25f * Mathf.Sin(now * 9f)); + if (B.CurseDef != null) curseRing.color = new Color(0.72f, 0.38f, 1f, 0.55f + 0.2f * Mathf.Sin(now * 3f)); + } + + // ---------------- 그림(코드 래스터 · 캐시) ---------------- + static Sprite Cached(string key, System.Func draw) + { + if (bcache.TryGetValue(key, out var s)) return s; + var r = draw(); if (r == null) return null; // 그릴 것이 없는 이름 + s = r.Sprite(); bcache[key] = s; return s; + } + + /// 솟은 가시 무리(아래 = 판 위) — 회색 삼각 + 밝은 날 + static Sprite SpikeSprite() => Cached("spikes", () => + { + var r = new Raster(96, 88); + float[] xs = { 18, 34, 50, 66, 80, 26, 42, 58, 72 }; float[] ys = { 26, 20, 18, 20, 26, 8, 4, 4, 8 }; + for (int i = 0; i < xs.Length; i++) + { + float x = xs[i], y = ys[i], h = i < 5 ? 52 : 46; + r.Poly(new[] { new Vector2(x - 9, y), new Vector2(x + 9, y), new Vector2(x, y + h) }, new Color32(40, 38, 48, 255)); + r.Poly(new[] { new Vector2(x - 6, y + 2), new Vector2(x + 6, y + 2), new Vector2(x, y + h - 5) }, new Color32(170, 168, 184, 255)); + r.Poly(new[] { new Vector2(x - 1, y + 4), new Vector2(x + 4, y + 4), new Vector2(x, y + h - 8) }, new Color32(236, 236, 246, 255)); + } + return r; + }); + + static Sprite BoulderSprite() => Cached("boulder", () => + { + var r = new Raster(96, 96); + r.Circle(48, 48, 44, new Color32(46, 42, 48, 255)); r.Circle(48, 48, 40, new Color32(118, 110, 116, 255)); + r.Circle(42, 56, 26, new Color32(150, 142, 146, 255)); r.Circle(36, 64, 9, new Color32(196, 190, 192, 255)); + r.Line(new Vector2(58, 30), new Vector2(70, 48), 3, new Color32(70, 64, 70, 255)); r.Line(new Vector2(30, 34), new Vector2(46, 28), 3, new Color32(70, 64, 70, 255)); r.Line(new Vector2(60, 66), new Vector2(72, 60), 3, new Color32(70, 64, 70, 255)); + return r; + }); + + static Sprite TornadoSprite() => Cached("tornado", () => + { + var r = new Raster(96, 140); + for (int i = 0; i < 9; i++) + { + float t = i / 8f, y = 12 + i * 14, w = 10 + 36 * t, x = 48 + Mathf.Sin(i * 1.3f) * 5f * (1f - t); + r.Ellipse(x, y, w + 3, 7, new Color32(92, 104, 122, 230)); + r.Ellipse(x, y + 1, w, 5, new Color32(206, 216, 230, 240)); + } + r.Ellipse(48, 8, 16, 5, new Color(0f, 0f, 0f, 0.25f)); + return r; + }); + + /// 보호막 = 파란 반구(가장자리 진하게) + static Sprite ShieldBubbleSprite() => Cached("shieldbubble", () => + { + var r = new Raster(128, 128); + for (int y = 0; y < 128; y++) + for (int x = 0; x < 128; x++) + { + float d = Vector2.Distance(new Vector2(x + 0.5f, y + 0.5f), new Vector2(64, 64)) / 62f; + if (d > 1f) continue; + r.Px[y * 128 + x] = new Color(0.45f, 0.75f, 1f, Mathf.Lerp(0.12f, 0.75f, d * d * d)); + } + r.Ellipse(44, 88, 18, 9, new Color(1f, 1f, 1f, 0.55f)); + return r; + }); + + /// 가시 성벽 = 성 둘레 가시 고리 + static Sprite ThornRingSprite() => Cached("thornring", () => + { + var r = new Raster(160, 160); + r.Ring(80, 80, 66, 8, new Color32(70, 62, 66, 255)); + for (int i = 0; i < 18; i++) + { + float a = i * Mathf.PI * 2f / 18f; var dir = new Vector2(Mathf.Cos(a), Mathf.Sin(a)); var side = new Vector2(-dir.y, dir.x); var b = new Vector2(80, 80) + dir * 64f; + r.Poly(new[] { b - side * 7f, b + side * 7f, b + dir * 16f }, new Color32(54, 48, 52, 255)); + r.Poly(new[] { b - side * 4f + dir * 2f, b + side * 4f + dir * 2f, b + dir * 13f }, new Color32(178, 170, 176, 255)); + } + return r; + }); + + static Sprite GuardSprite() => Cached("m_guard", () => + { + var r = new Raster(96, 96); + r.RRect(22, 2, 74, 22, 5, new Color32(44, 42, 54, 255)); r.RRect(25, 5, 71, 19, 4, new Color32(128, 126, 144, 255)); + r.Ellipse(48, 42, 21, 22, new Color32(44, 30, 70, 255)); r.Ellipse(48, 42, 18, 19, new Color32(96, 58, 150, 255)); + r.Poly(new[] { new Vector2(48, 94), new Vector2(66, 70), new Vector2(48, 54), new Vector2(30, 70) }, new Color32(20, 70, 90, 255)); + r.Poly(new[] { new Vector2(48, 90), new Vector2(62, 70), new Vector2(48, 58), new Vector2(34, 70) }, new Color32(90, 230, 240, 255)); + r.Circle(48, 72, 6, Color.white); r.Circle(44, 78, 3, new Color(1f, 1f, 1f, 0.8f)); + return r; + }); + + static Sprite BomberSprite() => Cached("m_bomber", () => + { + var r = new Raster(96, 96); + r.Ellipse(36, 8, 9, 5, new Color32(30, 28, 34, 255)); r.Ellipse(60, 8, 9, 5, new Color32(30, 28, 34, 255)); + r.Circle(48, 40, 32, new Color32(24, 22, 30, 255)); r.Circle(48, 40, 29, new Color32(66, 64, 78, 255)); r.Circle(38, 52, 9, new Color32(150, 150, 168, 255)); + r.Circle(40, 38, 5, new Color32(255, 70, 50, 255)); r.Circle(56, 38, 5, new Color32(255, 70, 50, 255)); + r.RRect(54, 66, 66, 76, 3, new Color32(90, 90, 100, 255)); + r.Line(new Vector2(62, 74), new Vector2(72, 88), 4, new Color32(140, 100, 60, 255)); + r.Star(74, 90, 9, Pal.Yellow); r.Circle(74, 90, 4, new Color32(255, 120, 40, 255)); + return r; + }); + + static Sprite TotemSprite() => Cached("m_totem", () => + { + var r = new Raster(96, 96); + Color dk = new Color32(64, 40, 24, 255), wood = new Color32(150, 98, 56, 255), wood2 = new Color32(122, 78, 44, 255); + for (int i = 0; i < 3; i++) + { + float y = 4 + i * 22; + r.RRect(28, y, 68, y + 22, 4, dk); r.RRect(31, y + 3, 65, y + 19, 3, i % 2 == 0 ? wood : wood2); + r.RRect(37, y + 9, 45, y + 13, 1, dk); r.RRect(51, y + 9, 59, y + 13, 1, dk); + } + r.Poly(new[] { new Vector2(20, 64), new Vector2(76, 64), new Vector2(48, 72) }, dk); + r.Circle(48, 82, 11, new Color32(20, 80, 40, 255)); r.Circle(48, 82, 8, new Color32(90, 230, 120, 255)); r.Circle(45, 85, 3, Color.white); + return r; + }); + + /// 성 소환수 그림 — 종류(kind)별 · 없으면 근접/원거리 몬스터 + static Sprite MinionArt(BSummonDef d) => d.kind == "beam" ? GuardSprite() : d.kind == "bomb" ? BomberSprite() : d.kind == "totem" ? TotemSprite() : MinionSprite(d.ranged); + + /// 새 요소 그림(상대방 칸 · 스테이지 아이콘 · 안내 카드) — 없으면 null(예전 SkillIcon 그림) + static Sprite NewIcon(string id) + { + switch (id) + { + case "laserGuard": return GuardSprite(); + case "bomber": return BomberSprite(); + case "healTotem": return TotemSprite(); + case "boulder": return BoulderSprite(); + case "tornado": return TornadoSprite(); + } + return Cached("el_" + id, () => + { + var r = new Raster(96, 96); + switch (id) + { + case "poisonCloud": + r.Circle(30, 46, 20, new Color32(40, 90, 40, 255)); r.Circle(52, 56, 24, new Color32(40, 90, 40, 255)); r.Circle(70, 44, 18, new Color32(40, 90, 40, 255)); + r.Circle(30, 46, 17, new Color32(120, 200, 80, 255)); r.Circle(52, 56, 21, new Color32(130, 214, 90, 255)); r.Circle(70, 44, 15, new Color32(120, 200, 80, 255)); + r.Circle(44, 30, 5, new Color32(160, 90, 190, 255)); r.Circle(62, 26, 4, new Color32(160, 90, 190, 255)); r.Circle(52, 16, 3, new Color32(160, 90, 190, 255)); + break; + case "fireField": + r.Ellipse(48, 22, 40, 14, new Color32(120, 30, 10, 255)); r.Ellipse(48, 22, 34, 10, new Color32(240, 110, 30, 255)); + for (int k = 0; k < 3; k++) { float x = 26 + k * 22, h = k == 1 ? 62 : 46; r.Poly(new[] { new Vector2(x - 12, 22), new Vector2(x + 12, 22), new Vector2(x, 22 + h) }, new Color32(250, 120, 30, 255)); r.Poly(new[] { new Vector2(x - 6, 24), new Vector2(x + 6, 24), new Vector2(x, 18 + h * 0.7f) }, new Color32(255, 220, 110, 255)); } + break; + case "meteor": + r.Line(new Vector2(16, 16), new Vector2(58, 58), 18, new Color(1f, 0.55f, 0.15f, 0.55f)); r.Line(new Vector2(22, 22), new Vector2(58, 58), 8, new Color32(255, 220, 120, 255)); + r.Circle(64, 64, 20, new Color32(120, 40, 20, 255)); r.Circle(64, 64, 17, new Color32(250, 110, 40, 255)); r.Circle(60, 68, 7, new Color32(255, 230, 160, 255)); + break; + case "frostNova": + r.Ring(48, 48, 38, 8, new Color32(120, 200, 255, 255)); + for (int k = 0; k < 3; k++) { float a = k * Mathf.PI / 3f; var v = new Vector2(Mathf.Cos(a), Mathf.Sin(a)) * 24f; r.Line(new Vector2(48, 48) - v, new Vector2(48, 48) + v, 6, new Color32(40, 110, 170, 255)); r.Line(new Vector2(48, 48) - v, new Vector2(48, 48) + v, 3, Color.white); } + break; + case "shield": + r.Poly(new[] { new Vector2(16, 82), new Vector2(80, 82), new Vector2(76, 40), new Vector2(48, 8), new Vector2(20, 40) }, new Color32(30, 50, 110, 255)); + r.Poly(new[] { new Vector2(22, 77), new Vector2(74, 77), new Vector2(70, 42), new Vector2(48, 15), new Vector2(26, 42) }, new Color32(90, 160, 250, 255)); + r.Poly(new[] { new Vector2(28, 72), new Vector2(46, 72), new Vector2(44, 46), new Vector2(30, 46) }, new Color(1f, 1f, 1f, 0.5f)); + break; + case "thorns": + r.RRect(14, 10, 82, 58, 6, new Color32(60, 56, 64, 255)); r.RRect(18, 14, 78, 54, 4, new Color32(150, 146, 160, 255)); + for (int k = 0; k < 5; k++) { float x = 20 + k * 14; r.Poly(new[] { new Vector2(x - 7, 56), new Vector2(x + 7, 56), new Vector2(x, 88) }, new Color32(54, 48, 52, 255)); r.Poly(new[] { new Vector2(x - 4, 58), new Vector2(x + 4, 58), new Vector2(x, 82) }, new Color32(200, 194, 204, 255)); } + break; + case "curse": + r.Circle(48, 48, 40, new Color32(50, 20, 70, 255)); r.Circle(48, 48, 36, new Color32(150, 80, 220, 255)); + r.Ellipse(48, 48, 26, 14, new Color32(30, 10, 40, 255)); r.Circle(48, 48, 9, new Color32(255, 80, 110, 255)); r.Circle(45, 51, 3, Color.white); + break; + case "rage2": + for (int k = 0; k < 2; k++) { float x = 32 + k * 32; r.Poly(new[] { new Vector2(x - 16, 10), new Vector2(x + 16, 10), new Vector2(x + 4, 86), new Vector2(x - 8, 60) }, new Color32(150, 20, 20, 255)); r.Poly(new[] { new Vector2(x - 11, 14), new Vector2(x + 11, 14), new Vector2(x + 2, 72), new Vector2(x - 6, 52) }, new Color32(255, 80, 50, 255)); r.Poly(new[] { new Vector2(x - 5, 16), new Vector2(x + 5, 16), new Vector2(x, 44) }, new Color32(255, 220, 110, 255)); } + break; + case "poison": DrawPatch(r, 48, 48, 42, "poison", 3); break; + case "mud": DrawPatch(r, 48, 48, 42, "slow", 5); break; + case "lava": DrawPatch(r, 48, 48, 42, "burn", 7); break; + case "ice": DrawPatch(r, 48, 48, 42, "freeze", 2); break; + case "spikes": + case "trap": + DrawPatch(r, 48, 34, 40, id == "trap" ? "trap" : "spike", 4); + for (int k = 0; k < 3; k++) { float x = 30 + k * 18; r.Poly(new[] { new Vector2(x - 8, 34), new Vector2(x + 8, 34), new Vector2(x, 86 - (k == 1 ? 0 : 12)) }, new Color32(40, 38, 48, 255)); r.Poly(new[] { new Vector2(x - 5, 36), new Vector2(x + 5, 36), new Vector2(x, 80 - (k == 1 ? 0 : 12)) }, new Color32(210, 208, 222, 255)); } + break; + case "river": + case "narrow": + case "wide": + { + r.RRect(0, 30, 96, 66, 0, new Color32(40, 110, 200, 255)); r.RRect(0, 42, 96, 54, 0, new Color32(120, 190, 250, 255)); + float hw = id == "narrow" ? 8 : id == "wide" ? 30 : 16; + r.RRect(48 - hw - 3, 20, 48 + hw + 3, 76, 4, new Color32(90, 56, 30, 255)); r.RRect(48 - hw, 23, 48 + hw, 73, 3, new Color32(160, 108, 64, 255)); + for (int k = 0; k < 4; k++) r.RRect(48 - hw + 3, 28 + k * 12, 48 + hw - 3, 32 + k * 12, 1, new Color32(200, 150, 96, 255)); + break; + } + case "moat": + r.Ring(48, 48, 34, 14, new Color32(40, 110, 200, 255)); r.Ring(48, 48, 34, 5, new Color32(120, 190, 250, 255)); + r.RRect(41, 4, 55, 26, 3, new Color32(150, 100, 60, 255)); + r.RRect(34, 38, 62, 62, 4, new Color32(80, 80, 96, 255)); r.RRect(37, 41, 59, 59, 3, new Color32(160, 160, 176, 255)); r.Poly(new[] { new Vector2(32, 62), new Vector2(64, 62), new Vector2(48, 76) }, new Color32(220, 70, 80, 255)); + break; + case "canyon": + r.RRect(0, 0, 96, 96, 0, new Color32(118, 170, 92, 255)); + r.Poly(new[] { new Vector2(0, 0), new Vector2(30, 0), new Vector2(36, 40), new Vector2(26, 96), new Vector2(0, 96) }, new Color32(54, 48, 46, 255)); + r.Poly(new[] { new Vector2(0, 0), new Vector2(26, 0), new Vector2(32, 40), new Vector2(22, 96), new Vector2(0, 96) }, new Color32(130, 120, 112, 255)); + r.Poly(new[] { new Vector2(96, 0), new Vector2(66, 0), new Vector2(60, 50), new Vector2(70, 96), new Vector2(96, 96) }, new Color32(54, 48, 46, 255)); + r.Poly(new[] { new Vector2(96, 0), new Vector2(70, 0), new Vector2(64, 50), new Vector2(74, 96), new Vector2(96, 96) }, new Color32(130, 120, 112, 255)); + break; + default: return null; + } + return r; + }); + } + + // ---------------- 숫자 한 줄(상대방 칸 · 스테이지 정보 · 안내 카드) — 값 = battle.json ---------------- + static string FieldInfo(BFieldDef f) + { + switch (f.kind) + { + case "poison": return $"초당 최대 체력 {f.dotPct:0.#}% · 나온 뒤 {f.linger:0.#}초"; + case "slow": return $"이동 속도 −{f.slowPct:0}%"; + case "burn": return $"초당 최대 체력 {f.dotPct:0.#}%"; + case "freeze": return $"{f.period:0.#}초마다 {f.stunSeconds:0.#}초 얼림 · 예고 {f.warn:0.#}초"; + case "spike": return $"{f.period:0.#}초마다 최대 체력 {f.hitPct:0}% · 예고 {f.warn:0.#}초"; + case "trap": return $"밟고 {f.delay:0.#}초 뒤 최대 체력 {f.hitPct:0}% · {f.rearm:0.#}초 뒤 다시"; + } + return ""; + } + + static string SummonInfo(BSummonDef d, float mul, float atk, int alive) + { + string hp = $"체력 {Mathf.RoundToInt(d.hp * mul)}", tail = alive >= 0 ? $" · {alive}/{d.max}" : $" · 최대 {d.max}"; + switch (d.kind) + { + case "beam": return $"{hp}\n광선 {Mathf.RoundToInt(atk * d.beamPct / 100f)}{tail}"; + case "bomb": return $"{hp}\n폭발 {Mathf.RoundToInt(atk * d.blastPct / 100f)}{tail}"; + case "totem": return $"{hp}\n성 회복 초당 {d.healPct:0.#}%{tail}"; + } + return $"{hp}\n공격 {Mathf.RoundToInt(d.damage * mul)}{tail}"; + } + + static string PowerInfo(BPowerDef p, float castleMaxHp) + { + switch (p.id) + { + case "shield": return $"흡수 {Mathf.RoundToInt(castleMaxHp * p.pct / 100f)} · {p.duration:0.#}초\n{p.interval:0.#}초마다"; + case "thorns": return $"근접 피해 {p.pct:0}% 반사\n항상"; + case "curse": return $"{p.range:0.#}칸 안 공격력 −{p.pct:0}%\n항상"; + case "rage2": return $"체력 {p.hpRatio * 100f:0}% 아래\n간격 ×{p.intervalMul:0.##} · 투사체 ×{p.shotDmgMul:0.##}"; + } + return ""; + } + + /// 요소 숫자 한 줄 — 스테이지 성 공격력·소환 배율·체력 기준 + static string ElementNumbers(BattleCfg c, BElement e, int stage) + { + float atk = c.CastleAtkAt(stage); + switch (e.kind) + { + case "shot": { var d = c.Shot(e.id); return d != null ? AttackInfo(d, atk, d.range).Replace("\n", " · ") : ""; } + case "skill": { var d = c.Skill(e.id); return d != null ? AttackInfo(d, atk, d.range).Replace("\n", " · ") + $" · {d.interval:0.#}초마다" : ""; } + case "summon": { var d = c.Summon(e.id); return d != null ? SummonInfo(d, c.SummonMulAt(stage), atk, -1).Replace("\n", " · ") + $" · {d.interval:0.#}초마다" : ""; } + case "power": { var p = c.Power(e.id); return p != null ? PowerInfo(p, c.CastleHpAt(stage)).Replace("\n", " · ") : ""; } + case "field": { var f = c.Field(e.id); return f != null ? FieldInfo(f) : ""; } + } + return ""; + } + + /// 유닛 빛깔 — 맞음(빨강) > 얼림(하늘) > 화상(주황) > 중독(초록) > 저주(보라) > 진흙(갈색) > 감속(파랑) + static Color UnitTint(BUnit u) => + u.hitFlash > 0f ? new Color(1f, 0.6f, 0.6f) + : u.freeze > 0f ? new Color(0.6f, 0.86f, 1f) + : u.burnFx > 0f ? new Color(1f, 0.7f, 0.42f) + : u.poisonFx > 0f ? new Color(0.66f, 1f, 0.52f) + : u.cursed ? new Color(0.82f, 0.6f, 1f) + : u.mudMul < 1f ? new Color(0.86f, 0.72f, 0.54f) + : u.slow > 0f ? new Color(0.55f, 0.78f, 1f) : Color.white; + + string FieldNames() + { + var names = new List(); + foreach (var p in B.Map.Patches) if (!names.Contains(p.def.name)) names.Add(p.def.name); + return names.Count > 0 ? string.Join(" · ", names) : "없음"; + } + + static string KindName(string kind) => kind == "shot" ? "성 투사체" : kind == "skill" ? "성 기술" : kind == "summon" ? "성 소환" : kind == "power" ? "성 능력" : kind == "field" ? "필드" : "지형"; + + // ---------------- 처음 만나는 요소 안내 카드(스테이지마다 NEW · 한 번 본 요소는 다시 안 띄움) ---------------- + int introStage = -1, introSeed = -1; + + /// 시작 전 — 이 스테이지에서 처음 나오는 요소 중 아직 안 본 것이 있으면 카드부터(「전투 시작」 = 전투) · 시험 설정 전투는 카드 없음 + bool TryIntro(int s, int seed) + { + if (BattleTest != null) return false; + var seen = G.S.seenIntros ?? (G.S.seenIntros = new List()); + var list = T.Battle.NewAt(s).FindAll(e => !seen.Contains(e.id)); + if (list.Count == 0) return false; + introStage = s; introSeed = seed; + const float ItemH = 300f; + var p = NewPopup("StageIntro", "새로운 요소!", 330f + list.Count * ItemH, Pal.Orange); + for (int i = 0; i < list.Count; i++) + { + var e = list[i]; float y = -10f - i * ItemH; + var row = UI.Img(p.body, "Item_" + e.id, Skin.RoundSoft, Pal.Row).rectTransform.Place(new Vector2(0, 1), new Vector2(1, 1), new Vector2(0, y), new Vector2(0, ItemH - 16f), new Vector2(0.5f, 1)); + var ib = UI.Img(row, "IconBg", Skin.CirclePlain, new Color(0.12f, 0.14f, 0.26f, 0.9f)); ib.rectTransform.Place(new Vector2(0, 1), new Vector2(0, 1), new Vector2(18, -18), new Vector2(140, 140), new Vector2(0, 1)); + UI.Icon(ib.transform, "Icon", SkillIcon(e.id), Color.white).rectTransform.Fill(16, 16, 16, 16); + UI.Txt(row, "Kind", KindName(e.kind), 28, Pal.Gray, TextAnchor.MiddleLeft).rectTransform.Place(new Vector2(0, 1), new Vector2(1, 1), new Vector2(84, -14), new Vector2(-196, 36), new Vector2(0.5f, 1)); + UI.Txt(row, "Name", e.name, 46, Pal.Ink, TextAnchor.MiddleLeft).rectTransform.Place(new Vector2(0, 1), new Vector2(1, 1), new Vector2(84, -50), new Vector2(-196, 58), new Vector2(0.5f, 1)); + var num = ElementNumbers(T.Battle, e, s); + var tx = UI.Txt(row, "Desc", e.desc + (num.Length > 0 ? $"\n{num}" : ""), 30, Pal.Ink, TextAnchor.UpperLeft); + tx.supportRichText = true; tx.rectTransform.Place(new Vector2(0, 1), new Vector2(1, 1), new Vector2(84, -112), new Vector2(-196, 120), new Vector2(0.5f, 1)); + UI.Txt(row, "Tip", "팁: " + e.tip, 30, Pal.GreenDark, TextAnchor.MiddleLeft).rectTransform.Place(new Vector2(0, 0), new Vector2(1, 0), new Vector2(0, 26), new Vector2(-40, 44), new Vector2(0.5f, 0)); + } + var go = UI.TextBtn(p.body, "Go", "전투 시작", Pal.Green, 44, ConfirmIntro, out _); + go.GetComponent().Place(new Vector2(0.5f, 0), new Vector2(0.5f, 0), new Vector2(0, 70), new Vector2(460, 130)); + return true; + } + + /// 안내 카드 「전투 시작」 — 본 요소 저장 → 전투 + public void ConfirmIntro() + { + if (introStage < 0) return; + var seen = G.S.seenIntros ?? (G.S.seenIntros = new List()); + foreach (var e in T.Battle.NewAt(introStage)) if (!seen.Contains(e.id)) seen.Add(e.id); + int s = introStage, seed = introSeed; introStage = -1; + CloseAllPopups(); + BeginBattle(s, seed); + } + } +} diff --git a/Assets/Scripts/View/GameApp.StageFx.cs.meta b/Assets/Scripts/View/GameApp.StageFx.cs.meta new file mode 100644 index 0000000..581948e --- /dev/null +++ b/Assets/Scripts/View/GameApp.StageFx.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 45c1529eac5f3da4aa9851777cc8caa6 \ No newline at end of file