스테이지 50 — 5챕터 × 10 · 지도 고정(다리 폭 1·2·4칸 · 해자 · 협곡) · 필드 6(독 늪 · 진흙밭 · 빙판 · 솟는 가시 · 함정 가시 · 용암 = 모두 해로움) · 새 기술 6 · 새 소환 3 · 성 능력 4 · 챕터 빛깔 · 등급(정예·보스·최종) · NEW · 안내 카드 · 첫 클리어 ×3/×5 · 헤드리스 198/198 · 렌더 ALL PASS — PD 확정(2026-10-30 「필드 전부 네거티브 · 날씨는 다른 형태로 · 그 밖 기획안대로 · 구현까지 일괄」)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
48faad4dc9
commit
7c9143a655
|
|
@ -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<string>(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<Battle, bool> moment, Action<Battle> 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<Battle> 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}"); }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,2 @@
|
||||||
|
fileFormatVersion: 2
|
||||||
|
guid: b2af11325ec30b44ba8d63ee8265ce6e
|
||||||
|
|
@ -32,6 +32,7 @@ namespace VS.EditorTools
|
||||||
g.S.afkPending = "0";
|
g.S.afkPending = "0";
|
||||||
BattleShots(app);
|
BattleShots(app);
|
||||||
UnitShots(app);
|
UnitShots(app);
|
||||||
|
Stage50Shots(app); // 스테이지 50 — 선택 · 정보 · 안내 카드 · 지형 · 필드 · 새 공격 · 성 능력(EditorRender.Stage50.cs)
|
||||||
app.SwitchTab(0);
|
app.SwitchTab(0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -45,7 +46,10 @@ namespace VS.EditorTools
|
||||||
StageCheck(app, "stage_select", 1, true); Shot(app, "stage_select");
|
StageCheck(app, "stage_select", 1, true); Shot(app, "stage_select");
|
||||||
app.ShowStage(3); StageCheck(app, "stage_locked", 3, false); Shot(app, "stage_locked");
|
app.ShowStage(3); StageCheck(app, "stage_locked", 3, false); Shot(app, "stage_locked");
|
||||||
app.OpenStageInfo(); Shot(app, "stage_info"); app.ClosePopup();
|
app.OpenStageInfo(); Shot(app, "stage_info"); app.ClosePopup();
|
||||||
|
// 처음 나오는 요소 = 안내 카드(스테이지 1 = 일반 화살) → 「전투 시작」
|
||||||
app.StartStage(1, 5);
|
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.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: 전투 중에 아래 메뉴가 보임"); }
|
if (app.Root.Find("TabBar").gameObject.activeSelf) { fails++; notes.Add("stage_start: 전투 중에 아래 메뉴가 보임"); }
|
||||||
app.B.Mana = 10;
|
app.B.Mana = 10;
|
||||||
|
|
@ -116,7 +120,7 @@ namespace VS.EditorTools
|
||||||
static void RivalCheck(GameApp app, string name)
|
static void RivalCheck(GameApp app, string name)
|
||||||
{
|
{
|
||||||
var root = app.BattleRoot; var rv = root.Find("Rival") as RectTransform;
|
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++;
|
if (rv != null) foreach (Transform ch in rv) if (ch.name.StartsWith("Skill_")) rows++;
|
||||||
bool timer = root.Find("Timer") != null;
|
bool timer = root.Find("Timer") != null;
|
||||||
if (timer || rv == null || rows != want) { fails++; notes.Add($"{name}: 상대방 칸 이상 (남은 시간 칸 {timer} · 스킬 줄 {rows}/{want})"); return; }
|
if (timer || rv == null || rows != want) { fails++; notes.Add($"{name}: 상대방 칸 이상 (남은 시간 칸 {timer} · 스킬 줄 {rows}/{want})"); return; }
|
||||||
|
|
|
||||||
|
|
@ -73,7 +73,7 @@ namespace VS.EditorTools
|
||||||
for (int i = 0; i < 1200 && unit.alive && !unit.attacking; i++)
|
for (int i = 0; i < 1200 && unit.alive && !unit.attacking; i++)
|
||||||
{
|
{
|
||||||
b5.Tick(0.05f);
|
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);
|
Check("B5.bridge", crossed && crossedOnBridge);
|
||||||
var drag = c.units.Find(u => u.flying);
|
var drag = c.units.Find(u => u.flying);
|
||||||
|
|
@ -118,8 +118,8 @@ namespace VS.EditorTools
|
||||||
// B10 승리 보상 · 다음 단계
|
// B10 승리 보상 · 다음 단계
|
||||||
var g10 = NewGame(t, out _);
|
var g10 = NewGame(t, out _);
|
||||||
var wb = AutoBattle(g10, 1, 1, 10, 5f, out _);
|
var wb = AutoBattle(g10, 1, 1, 10, 5f, out _);
|
||||||
var coin0 = g10.Coin; var reward = g10.BattleReward(1);
|
var coin0 = g10.Coin; var reward = g10.StageWinReward(1, true); // 첫 클리어 = 기본 × 등급 firstMul
|
||||||
Check("B10.reward", g10.WinBattle(wb) == reward && g10.Coin == coin0 + reward && g10.BattleStage == 2 && g10.NewBattle().CastleMaxHp > wb.CastleMaxHp);
|
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 스테이지 — 줄마다 이름 · 다음에 깰 스테이지까지 열림 · 고른 스테이지로 전투 · 지난 스테이지·시험 승리는 진행 그대로 · 마지막을 깨면 전부 클리어
|
// B11 스테이지 — 줄마다 이름 · 다음에 깰 스테이지까지 열림 · 고른 스테이지로 전투 · 지난 스테이지·시험 승리는 진행 그대로 · 마지막을 깨면 전부 클리어
|
||||||
int stN = c.StageCount; bool stNamed = stN > 0;
|
int stN = c.StageCount; bool stNamed = stN > 0;
|
||||||
for (int st = 1; st <= stN; st++) stNamed &= !string.IsNullOrEmpty(c.StageName(st));
|
for (int st = 1; st <= stN; st++) stNamed &= !string.IsNullOrEmpty(c.StageName(st));
|
||||||
|
|
@ -166,6 +166,7 @@ namespace VS.EditorTools
|
||||||
CastleTests(c);
|
CastleTests(c);
|
||||||
FlagTests(c);
|
FlagTests(c);
|
||||||
StrengthRegenTests(c);
|
StrengthRegenTests(c);
|
||||||
|
Stage50Tests(c, t); // 스테이지 50 — 표 · 지형 · 필드 · 새 기술 · 새 소환 · 성 능력 · 보상(HeadlessRun.Stage50.cs)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------- 깃발: 맵을 누른 곳 → 내 유닛이 깃발에 먼저 닿은 뒤 성으로 ----------------
|
// ---------------- 깃발: 맵을 누른 곳 → 내 유닛이 깃발에 먼저 닿은 뒤 성으로 ----------------
|
||||||
|
|
@ -213,8 +214,9 @@ namespace VS.EditorTools
|
||||||
// E1 성·소환수의 공격력·체력 ≥ 내 가장 강한 유닛의 2배 (1단계 기준 · 단계마다 더 커짐)
|
// 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); }
|
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}");
|
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;
|
// 싸우는 소환수(근접 · 원거리)만 — 스테이지 50 특수 소환(수호병 · 폭탄병 · 토템 = kind)은 「먼저 잡으라고」 만든 유닛이라 기획서 수치(체력 4000 · 800 · 3000) 그대로(확정 전)
|
||||||
Check("E1.summon_strong", sOk, string.Join(" · ", c.summons.ConvertAll(s => $"{s.name} 체력 {s.hp} 공격 {s.damage}")));
|
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];
|
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}");
|
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종 · 무작위 생성 · 장애물 우회 ----------------
|
// ---------------- 맵 4종 · 무작위 생성 · 장애물 우회 ----------------
|
||||||
static void MapTests(BattleCfg c)
|
static void MapTests(BattleCfg c)
|
||||||
{
|
{
|
||||||
|
int laneCells = c.BridgeCells(null); // 무작위 맵 다리 = 보통 다리(지나갈 칸 수)
|
||||||
for (int k = 0; k <= 3; k++)
|
for (int k = 0; k <= 3; k++)
|
||||||
{
|
{
|
||||||
var mb = new Battle(c, 1, 10, 100 + k, new BattleSetup { mapType = 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;
|
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++;
|
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];
|
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;
|
float far = c.obstacleDistMax + c.obstacleRadiusMax * c.obstacleSpacing * c.obstaclePiecesMax * 0.5f + c.obstacleWobble + 0.01f;
|
||||||
for (int s = 0; s < n; s++)
|
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]++;
|
cnt[mp.Type]++;
|
||||||
if (mp.Reachable(mb.SpawnPoint)) reach++;
|
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++;
|
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;
|
bool allReach = true, noBlock2 = true, bridgeOnly = true; int obsMaps = 0;
|
||||||
for (int s = 1; s <= 12; s++)
|
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++;
|
if (rb.Map.Obstacles.Count > 0) obsMaps++;
|
||||||
var ru = Put(rb, walker, rb.SpawnPoint.x, rb.SpawnPoint.y);
|
var ru = Put(rb, walker, rb.SpawnPoint.x, rb.SpawnPoint.y);
|
||||||
for (int i = 0; i < 2400 && !ru.attacking; i++)
|
for (int i = 0; i < 2400 && !ru.attacking; i++)
|
||||||
{
|
{
|
||||||
rb.Tick(0.05f);
|
rb.Tick(0.05f);
|
||||||
if (rb.Map.Blocked(ru.pos)) noBlock2 = false;
|
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}"); }
|
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);
|
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"
|
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));
|
&& 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;
|
bool loOk = true; var loBad = new List<int>();
|
||||||
for (int st = 1; st <= 9; st++)
|
for (int st = 1; st <= c.StageCount; st++)
|
||||||
{
|
{
|
||||||
var sb = new Battle(c, st, 10, st); var l = c.Loadout(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<string>(sb.CastleSkills.ConvertAll(w => w.def.id)); gids.UnionWith(sb.Summoners.ConvertAll(s => s.def.id));
|
var ids = sb.CastleShots.ConvertAll(w => w.def.id); var gids = new HashSet<string>(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;
|
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));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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<string>(); 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<int>();
|
||||||
|
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<string>(); 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<BUnit>(); 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<BLoadout> 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,2 @@
|
||||||
|
fileFormatVersion: 2
|
||||||
|
guid: 63e681ce2d2fd9447b56aa045e9e44fc
|
||||||
|
|
@ -4,7 +4,16 @@
|
||||||
"arenaH": 32,
|
"arenaH": 32,
|
||||||
"riverY": 16,
|
"riverY": 16,
|
||||||
"riverHalf": 0.9,
|
"riverHalf": 0.9,
|
||||||
"bridgeHalf": 1.2,
|
"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,
|
"bridgeJitter": 1.5,
|
||||||
"mapWeights": [1, 1, 1, 1],
|
"mapWeights": [1, 1, 1, 1],
|
||||||
"mapNames": ["강 없는 평지", "다리 1개", "다리 2개", "다리 3개"],
|
"mapNames": ["강 없는 평지", "다리 1개", "다리 2개", "다리 3개"],
|
||||||
|
|
@ -59,35 +68,126 @@
|
||||||
],
|
],
|
||||||
"castle": {
|
"castle": {
|
||||||
"y": 28.5, "xMin": 6.5, "xMax": 11.5, "radius": 2.0,
|
"y": 28.5, "xMin": 6.5, "xMax": 11.5, "radius": 2.0,
|
||||||
"hp": 15000, "hpGrowth": 1.35,
|
"hp": 15000, "hpGrowth": 1.07,
|
||||||
"atk": 400, "atkGrowth": 1.2, "summonGrowth": 1.2,
|
"atk": 400, "atkGrowth": 1.04, "summonGrowth": 1.05,
|
||||||
"enrageHpRatio": 0.5, "enrageIntervalMul": 0.75,
|
"enrageHpRatio": 0.5, "enrageIntervalMul": 0.75,
|
||||||
"regenDelay": 5, "regenPct": 1, "regenInterval": 1
|
"regenDelay": 5, "regenPct": 1, "regenInterval": 1
|
||||||
},
|
},
|
||||||
|
"attackSource": "성 공격 — 투사체 5 · 기술 8(파이어 볼 · 라이트닝 + 2026-10-30 스테이지 50 기획서 새 기술 6) · 소환 5(근접 · 원거리 + 새 소환 3) · 성 능력 4 · 피해 = 성 공격력 × % · desc·tip = 처음 만날 때 안내 카드 글(숫자는 화면이 값에서 만든다) · 새 값 전부 확정 전",
|
||||||
"shots": [
|
"shots": [
|
||||||
{ "id": "normal", "name": "일반", "dmgPct": 100, "interval": 0.8, "range": 3.5, "speed": 14 },
|
{ "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 },
|
{ "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 },
|
{ "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 },
|
{ "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 }
|
{ "id": "laser", "name": "레이저", "dmgPct": 80, "width": 0.8, "beamSeconds": 0.35, "interval": 2.2, "range": 5, "desc": "성 앞 직선 위의 유닛 모두에게 피해", "tip": "깃발로 옆길을 잡아 한 줄로 서지 않게 하세요" }
|
||||||
],
|
],
|
||||||
"skills": [
|
"skills": [
|
||||||
{ "id": "fireball", "name": "파이어 볼", "dmgPct": 100, "splashPct": 60, "splashRadius": 1.6, "interval": 7, "range": 5, "speed": 9 },
|
{ "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 }
|
{ "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": [
|
"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": "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 }
|
{ "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": "깃발로 토템부터 부수세요" }
|
||||||
],
|
],
|
||||||
"stageSource": "스테이지 = 아래 stageLoadouts 한 줄(2026-10-29 PD 「전투 메뉴 = 스테이지 고르기 · 앞으로 스테이지 단위로 하나씩 클리어」) · 다음에 깰 스테이지까지 열림 · 줄을 더하면 스테이지가 늘어남 · 이름 = 그 성의 대표 공격으로 지음(확정 전)",
|
"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": [
|
"stageLoadouts": [
|
||||||
{ "stage": 1, "name": "첫 번째 성", "shots": ["normal"], "gimmicks": [] },
|
{ "stage": 1, "name": "첫 번째 성", "rank": "", "bridges": [], "moat": 0, "canyon": false, "fields": [], "shots": ["normal"], "gimmicks": [] },
|
||||||
{ "stage": 2, "name": "불꽃의 성", "shots": ["normal"], "gimmicks": ["fireball"] },
|
{ "stage": 2, "name": "멀티샷 망루", "rank": "", "bridges": [ { "x": 9, "kind": "normal" } ], "moat": 0, "canyon": false, "fields": [], "shots": ["multi"], "gimmicks": [] },
|
||||||
{ "stage": 3, "name": "소환사의 성", "shots": ["normal"], "gimmicks": ["fireball", "summonMelee"] },
|
{ "stage": 3, "name": "병영 요새", "rank": "", "bridges": [ { "x": 9, "kind": "normal" } ], "moat": 0, "canyon": false, "fields": [], "shots": ["normal"], "gimmicks": ["summonMelee"] },
|
||||||
{ "stage": 4, "name": "얼음의 성", "shots": ["normal", "slow"], "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": "번개의 성", "shots": ["normal", "multi"], "gimmicks": ["lightning", "summonMelee"] },
|
{ "stage": 5, "name": "불꽃 보루", "rank": "elite", "bridges": [ { "x": 9, "kind": "normal" } ], "moat": 0, "canyon": false, "fields": [], "shots": ["normal"], "gimmicks": ["fireball"] },
|
||||||
{ "stage": 6, "name": "폭발의 성", "shots": ["normal", "splash"], "gimmicks": ["fireball", "summonRanged"] },
|
{ "stage": 6, "name": "쌍검 성채", "rank": "", "bridges": [], "moat": 0, "canyon": false, "fields": [], "shots": ["multi"], "gimmicks": ["summonMelee"] },
|
||||||
{ "stage": 7, "name": "최후의 성", "shots": ["normal", "laser"], "gimmicks": ["fireball", "lightning", "summonMelee", "summonRanged"] }
|
{ "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],
|
"testRangeMuls": [1, 2, 4],
|
||||||
"gradeSource": "유닛 등급(2026-10-29 PD) = 일반(회색) → 고급(녹색) → 희귀(파랑) → 에픽(보라) → 전설(주황) → 불멸(빨강) → 신화(무지개) · 등급 수 = 카드의 별 수 · 지금 유닛 6종의 등급 = 마나 비용 순으로 1~6(확정 전)",
|
"gradeSource": "유닛 등급(2026-10-29 PD) = 일반(회색) → 고급(녹색) → 희귀(파랑) → 에픽(보라) → 전설(주황) → 불멸(빨강) → 신화(무지개) · 등급 수 = 카드의 별 수 · 지금 유닛 6종의 등급 = 마나 비용 순으로 1~6(확정 전)",
|
||||||
|
|
|
||||||
|
|
@ -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<BGateSet> 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<BBridgeKind> bridgeKinds; public float bridgeRail;
|
||||||
|
public BMoatDef moat; public BCanyonDef canyon; public BFieldPlace fieldPlace;
|
||||||
|
public List<BFieldDef> fields; public List<BPowerDef> powers; public List<BTerrainDef> terrains;
|
||||||
|
public List<BChapterDef> chapters; public List<BRankDef> ranks;
|
||||||
|
public int stageMapSeed;
|
||||||
|
[NonSerialized] Dictionary<string, int> 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<BElement> Elements(int stage)
|
||||||
|
{
|
||||||
|
var l = new List<BElement>(); 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<string, int>();
|
||||||
|
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<BElement> 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<int> hit = new HashSet<int>();
|
||||||
|
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<int> hit = new HashSet<int>(); }
|
||||||
|
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<BPatch> Patches = new List<BPatch>();
|
||||||
|
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<BFieldDef> 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<BPatch>[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<BPatch>(); }
|
||||||
|
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<Vector2> { 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<Vector2[]> Lanes(Vector2 spawn)
|
||||||
|
{
|
||||||
|
var front = new Vector2(Castle.x, Castle.y - (MoatGates > 0 ? MoatOut : C.castle.radius + C.castleSeedPad) - 1f);
|
||||||
|
var l = new List<Vector2[]> { 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<string>();
|
||||||
|
if (Canyon) parts.Add(C.canyon.name);
|
||||||
|
if (MoatGates > 0) parts.Add($"{C.moat.name} 입구 {MoatGates}");
|
||||||
|
if (Bridges.Length > 0)
|
||||||
|
{
|
||||||
|
var cnt = new Dictionary<string, int>();
|
||||||
|
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<string>(); 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<BZone> Zones = new List<BZone>();
|
||||||
|
public readonly List<BBoulder> Boulders = new List<BBoulder>();
|
||||||
|
public readonly List<BTornado> Tornadoes = new List<BTornado>();
|
||||||
|
public readonly List<BMeteor> Meteors = new List<BMeteor>();
|
||||||
|
public readonly List<BPower> Powers = new List<BPower>();
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,2 @@
|
||||||
|
fileFormatVersion: 2
|
||||||
|
guid: cc21938a606956a478cbfa2081ec04fb
|
||||||
|
|
@ -4,9 +4,9 @@ using System.Numerics;
|
||||||
using UnityEngine;
|
using UnityEngine;
|
||||||
using Vector2 = UnityEngine.Vector2;
|
using Vector2 = UnityEngine.Vector2;
|
||||||
|
|
||||||
// 전투 모드 — 클래시 로얄식 아레나(18×32 칸). 맵 = 강 없는 평지 · 다리 1~3개 중 무작위 + 성 둘레 바위·언덕(땅 유닛은 돌아서 간다).
|
// 전투 모드 — 클래시 로얄식 아레나(18×32 칸). 맵 = 스테이지마다 고정 지도(다리 폭 · 해자 · 협곡 · 필드 — Battle.Stage.cs) · 시험 설정 = 평지/다리 1~3개 무작위 + 성 둘레 바위·언덕.
|
||||||
// 내 유닛 = 카드 1장 = 1마리 · 아래 가운데에서 바로 소환(약하지만 마나를 모아 많이 소환 = 물량) · 맵을 누르면 깃발 → 유닛이 깃발을 거쳐 성으로.
|
// 내 유닛 = 카드 1장 = 1마리 · 아래 가운데에서 바로 소환(약하지만 마나를 모아 많이 소환 = 물량) · 맵을 누르면 깃발 → 유닛이 깃발을 거쳐 성으로.
|
||||||
// 성(항상 위쪽 · 내 유닛보다 매우 강함) = 투사체 5종(일반·범위·멀티샷·감속·레이저) + 기믹(파이어 볼·라이트닝·소환수) + 안 맞으면 성벽 회복.
|
// 성(항상 위쪽 · 내 유닛보다 매우 강함) = 투사체 5종 + 기술 8 · 소환 5 · 성 능력 4 + 안 맞으면 성벽 회복 · 스테이지 50(2026-10-30 PD 확정).
|
||||||
// 값 = Resources/Data/battle.json (설계값 · PD 확정 전)
|
// 값 = Resources/Data/battle.json (설계값 · PD 확정 전)
|
||||||
namespace VS.Sim
|
namespace VS.Sim
|
||||||
{
|
{
|
||||||
|
|
@ -18,22 +18,37 @@ namespace VS.Sim
|
||||||
/// regen = 마지막으로 맞은 뒤 regenDelay 초가 지나면 regenInterval 초마다 최대 체력의 regenPct %
|
/// regen = 마지막으로 맞은 뒤 regenDelay 초가 지나면 regenInterval 초마다 최대 체력의 regenPct %
|
||||||
[Serializable] public class BCastleDef { public float y, xMin, xMax, radius, hp, hpGrowth, atk, atkGrowth, summonGrowth, enrageHpRatio, enrageIntervalMul, regenDelay, regenPct, regenInterval; }
|
[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% · 사거리 = 성 가장자리부터
|
/// 성 투사체(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]
|
[Serializable]
|
||||||
public class BAttackDef
|
public class BAttackDef
|
||||||
{
|
{
|
||||||
public string id, name; public float dmgPct, interval, range, speed;
|
public string id, name; public float dmgPct, interval, range, speed;
|
||||||
public float splashPct, splashRadius, spreadDeg, slowPct, slowSeconds, width, beamSeconds, stunSeconds; public int count;
|
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
|
/// 성 소환수 — 성 둘레 집에 주둔 · 집에서 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; }
|
/// kind: beam(레이저 수호병 = 제자리 · beamPct % 광선) · bomb(폭탄병 = 달려가 blastRadius 폭발) · totem(치유 토템 = 제자리 · 성 회복 초당 healPct %) · 없음 = 근접/원거리
|
||||||
[Serializable] public class BLoadout { public int stage; public string name; public string[] shots, gimmicks; } // 스테이지 한 줄 = 번호 · 이름 · 성 투사체 · 기믹
|
[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<BBridgeDef> bridges; public int moat; public bool canyon;
|
||||||
|
}
|
||||||
[Serializable] public class BBridgeSet { public float[] x; }
|
[Serializable] public class BBridgeSet { public float[] x; }
|
||||||
/// 전투 화면 비율(화면 전용 · 시뮬 무관) — 칸 1개 = 가로 tileX · 세로 tileY px · 맵 좌우 장식 sideTiles 칸 · 아래 카드 칸 trayH px · 유닛 그림 한 변 = 반지름 × unitArt 칸 · 나는 유닛 = flyLift 칸 위
|
/// 전투 화면 비율(화면 전용 · 시뮬 무관) — 칸 1개 = 가로 tileX · 세로 tileY px · 맵 좌우 장식 sideTiles 칸 · 아래 카드 칸 trayH px · 유닛 그림 한 변 = 반지름 × unitArt 칸 · 나는 유닛 = flyLift 칸 위
|
||||||
[Serializable] public class BViewDef { public float tileX, tileY, sideTiles, trayH, unitArt, flyLift; }
|
[Serializable] public class BViewDef { public float tileX, tileY, sideTiles, trayH, unitArt, flyLift; }
|
||||||
[Serializable]
|
[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<BBridgeSet> bridgeSets;
|
public float[] mapWeights; public string[] mapNames; public List<BBridgeSet> bridgeSets;
|
||||||
public float obstacleChance; public int obstacleMin, obstacleMax, obstaclePiecesMin, obstaclePiecesMax;
|
public float obstacleChance; public int obstacleMin, obstacleMax, obstaclePiecesMin, obstaclePiecesMax;
|
||||||
public float obstacleRadiusMin, obstacleRadiusMax, obstacleDistMin, obstacleDistMax, obstacleArcMin, obstacleArcMax, obstacleSpacing, obstacleWobble, obstaclePad, obstacleCastleGap, castleSeedPad;
|
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 Shot(string id) => shots.Find(s => s.id == id);
|
||||||
public BAttackDef Skill(string id) => skills.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 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();
|
public string MapName(int type) => mapNames != null && type >= 0 && type < mapNames.Length ? mapNames[type] : type.ToString();
|
||||||
|
|
||||||
/// 단계 기본 구성 = stage 이하 중 가장 큰 stage 줄
|
/// 단계 기본 구성 = stage 이하 중 가장 큰 stage 줄
|
||||||
|
|
@ -66,35 +81,38 @@ namespace VS.Sim
|
||||||
return best ?? new BLoadout { shots = new string[0], gimmicks = new string[0] };
|
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 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 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 CastleAtkAt(int stage) => castle.atk * Mathf.Pow(castle.atkGrowth, stage - 1);
|
||||||
public float SummonMulAt(int stage) => Mathf.Pow(castle.summonGrowth, 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;
|
public int RivalOutfitAt(int stage) => rivalOutfits != null && rivalOutfits.Length > 0 ? rivalOutfits[(stage - 1) % rivalOutfits.Length] : 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 시험 설정(전투 화면 「시험」) — 음수·null = 무작위 · 단계 기본
|
/// 시험 설정(전투 화면 「시험」) — 음수·null = 스테이지 기본
|
||||||
public class BattleSetup
|
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 obstacles = -1; // 0 없음 · 1 있음
|
||||||
|
public int moat = -1; // -1 스테이지 기본 · 0 없음 · 1~2 해자 입구 수
|
||||||
|
public int canyon = -1; // -1 스테이지 기본 · 0 없음 · 1 협곡
|
||||||
public float castleX = -1f;
|
public float castleX = -1f;
|
||||||
public List<string> shots, gimmicks; // null = 단계 기본(stageLoadouts)
|
public List<string> shots, gimmicks, fields; // null = 스테이지 기본(stageLoadouts)
|
||||||
public float rangeMul = 1f;
|
public float rangeMul = 1f;
|
||||||
public List<BObstacle> customObstacles; // 검사용 고정 장애물
|
public List<BObstacle> customObstacles; // 검사용 고정 장애물
|
||||||
public BattleSetup Clone() => new BattleSetup
|
public BattleSetup Clone() => new BattleSetup
|
||||||
{
|
{
|
||||||
mapType = mapType, obstacles = obstacles, castleX = castleX, rangeMul = rangeMul, customObstacles = customObstacles,
|
mapType = mapType, obstacles = obstacles, moat = moat, canyon = canyon, castleX = castleX, rangeMul = rangeMul, customObstacles = customObstacles,
|
||||||
shots = shots != null ? new List<string>(shots) : null, gimmicks = gimmicks != null ? new List<string>(gimmicks) : null
|
shots = shots != null ? new List<string>(shots) : null, gimmicks = gimmicks != null ? new List<string>(gimmicks) : null, fields = fields != null ? new List<string>(fields) : null
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
public class BObstacle { public Vector2 pos; public float r; public int kind; } // kind 0 바위 · 1 언덕
|
public class BObstacle { public Vector2 pos; public float r; public int kind; } // kind 0 바위 · 1 언덕
|
||||||
|
|
||||||
/// 맵 = 강·다리·장애물 + 칸 단위 길찾기(8방향 · 모서리 끼어가기 금지 · 거리장)
|
/// 맵 = 강·다리·장애물(+ 해자 · 협곡 · 필드 = Battle.Stage.cs) + 칸 단위 길찾기(8방향 · 모서리 끼어가기 금지 · 거리장)
|
||||||
public class BMap
|
public partial class BMap
|
||||||
{
|
{
|
||||||
public int Type; // 0 강 없는 평지 · 1~3 다리 수
|
public int Type; // 0 강 없는 평지 · 1~3 다리 수
|
||||||
public float[] Bridges = new float[0];
|
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); }
|
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<BFieldDef> fieldDefs = null)
|
||||||
{
|
{
|
||||||
int type = s != null && s.mapType >= 0 ? Mathf.Clamp(s.mapType, 0, 3) : PickWeighted(c.mapWeights, rng);
|
var row = c.StageRow(stage);
|
||||||
float cx = s != null && s.castleX >= 0f ? s.castleX : Mathf.Lerp(c.castle.xMin, c.castle.xMax, (float)rng.NextDouble());
|
bool fixedMap = (s == null || s.mapType == BattleSetup.StageMap) && row != null;
|
||||||
bool obst = s != null && s.obstacles >= 0 ? s.obstacles == 1 : rng.NextDouble() < c.obstacleChance;
|
var r = fixedMap ? new System.Random(c.stageMapSeed + stage) : rng;
|
||||||
var bridges = type > 0 ? MakeBridges(c, type, rng) : new float[0];
|
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<BBridgeDef>();
|
||||||
|
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++)
|
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);
|
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();
|
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;
|
return w.Length - 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 무작위 맵 다리 = 보통 다리(bridgeKinds 기본) · 칸 경계에 맞춤
|
||||||
static float[] MakeBridges(BattleCfg c, int n, System.Random rng)
|
static float[] MakeBridges(BattleCfg c, int n, System.Random rng)
|
||||||
{
|
{
|
||||||
var set = c.bridgeSets[Mathf.Clamp(n - 1, 0, c.bridgeSets.Count - 1)].x;
|
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];
|
var r = new float[set.Length];
|
||||||
for (int i = 0; i < set.Length; i++) // 칸 가운데에 맞춤
|
for (int i = 0; i < set.Length; i++) r[i] = SnapBridge(c, set[i] + ((float)rng.NextDouble() * 2f - 1f) * c.bridgeJitter, cells);
|
||||||
r[i] = Mathf.Clamp(Mathf.Floor(set[i] + ((float)rng.NextDouble() * 2f - 1f) * c.bridgeJitter) + 0.5f, 1.5f, c.arenaW - 1.5f);
|
|
||||||
Array.Sort(r);
|
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;
|
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 개를 접선 방향으로 겹쳐 세운 벽
|
/// 성 둘레(obstacleArc 각도 · obstacleDist 거리)에 바위·언덕 줄기 obstacleMin~Max 개 — 줄기 = 원 obstaclePieces 개를 접선 방향으로 겹쳐 세운 벽
|
||||||
void PlaceObstacles(System.Random rng)
|
void PlaceObstacles(System.Random rng)
|
||||||
{
|
{
|
||||||
|
|
@ -178,9 +231,10 @@ namespace VS.Sim
|
||||||
bool ok = Vector2.Distance(p, Castle) >= C.castle.radius;
|
bool ok = Vector2.Distance(p, Castle) >= C.castle.radius;
|
||||||
if (ok && River && Mathf.Abs(p.y - C.riverY) <= C.riverHalf)
|
if (ok && River && Mathf.Abs(p.y - C.riverY) <= C.riverHalf)
|
||||||
{
|
{
|
||||||
ok = false; // 강 = 칸 전체가 다리 위일 때만 지나감
|
ok = false; // 강 = 칸 전체가 다리 길(칸 수만큼) 안일 때만 지나감
|
||||||
foreach (var b in Bridges) if (x >= b - C.bridgeHalf - 1e-3f && x + 1 <= b + C.bridgeHalf + 1e-3f) ok = true;
|
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; }
|
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;
|
Walk[y * W + x] = ok;
|
||||||
}
|
}
|
||||||
|
|
@ -316,7 +370,10 @@ namespace VS.Sim
|
||||||
public class BUnit
|
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 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; }
|
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 = 소환수
|
/// owner 0 = 내 유닛(성·소환수 공격) · 1 = 성 · 2 = 소환수
|
||||||
|
|
@ -332,7 +389,7 @@ namespace VS.Sim
|
||||||
|
|
||||||
public enum BattleState { Ready, Fighting, Won, Lost }
|
public enum BattleState { Ready, Fighting, Won, Lost }
|
||||||
|
|
||||||
public class Battle
|
public partial class Battle
|
||||||
{
|
{
|
||||||
public readonly BattleCfg C;
|
public readonly BattleCfg C;
|
||||||
public readonly int Stage;
|
public readonly int Stage;
|
||||||
|
|
@ -358,12 +415,12 @@ namespace VS.Sim
|
||||||
public Vector2 CastlePos => Map.Castle;
|
public Vector2 CastlePos => Map.Castle;
|
||||||
public Vector2 SpawnPoint => new Vector2(C.arenaW * 0.5f, C.spawnY);
|
public Vector2 SpawnPoint => new Vector2(C.arenaW * 0.5f, C.spawnY);
|
||||||
public bool Enraged => CastleHp <= CastleMaxHp * C.castle.enrageHpRatio;
|
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 float SummonMul => C.SummonMulAt(Stage);
|
||||||
public int RivalOutfit => C.RivalOutfitAt(Stage);
|
public int RivalOutfit => C.RivalOutfitAt(Stage);
|
||||||
public bool LastMinute => TimeLeft <= C.lastMinuteSeconds;
|
public bool LastMinute => TimeLeft <= C.lastMinuteSeconds;
|
||||||
public bool IsTest => Setup != null;
|
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 Range(BAttackDef d) => d.range * RangeMul;
|
||||||
public float Guard(BSummonDef d) => d.guardRange * RangeMul;
|
public float Guard(BSummonDef d) => d.guardRange * RangeMul;
|
||||||
|
|
||||||
|
|
@ -374,13 +431,16 @@ namespace VS.Sim
|
||||||
Atk = c.CastleAtkAt(Stage);
|
Atk = c.CastleAtkAt(Stage);
|
||||||
RangeMul = setup != null && setup.rangeMul > 0f ? setup.rangeMul : 1f;
|
RangeMul = setup != null && setup.rangeMul > 0f ? setup.rangeMul : 1f;
|
||||||
TimeLeft = c.battleSeconds; Mana = Mathf.Min(c.manaStart, maxMana);
|
TimeLeft = c.battleSeconds; Mana = Mathf.Min(c.manaStart, maxMana);
|
||||||
Map = BMap.Generate(c, rng, setup, SpawnPoint);
|
|
||||||
var lo = c.Loadout(Stage);
|
var lo = c.Loadout(Stage);
|
||||||
|
var fieldDefs = new List<BFieldDef>();
|
||||||
|
foreach (var id in (IEnumerable<string>)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<string>)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<string>)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<string>)setup?.gimmicks ?? lo.gimmicks ?? new string[0])
|
foreach (var id in (IEnumerable<string>)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 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 void CastleHpDebugKill() { CastleHp = 0f; }
|
||||||
|
|
||||||
public List<string> ShotNames() { var l = new List<string>(); foreach (var w in CastleShots) l.Add(w.def.name); return l; }
|
public List<string> ShotNames() { var l = new List<string>(); foreach (var w in CastleShots) l.Add(w.def.name); return l; }
|
||||||
public List<string> GimmickNames() { var l = new List<string>(); foreach (var w in CastleSkills) l.Add(w.def.name); foreach (var s in Summoners) l.Add(s.def.name); return l; }
|
public List<string> GimmickNames() { var l = new List<string>(); 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 안
|
/// 카드 1장 = 1마리 즉시 소환 — 아래 가운데(SpawnPoint) 둘레 spawnJitter 안
|
||||||
public bool Deploy(BUnitDef d)
|
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));
|
Mana = Mathf.Min(MaxMana, Mana + dt / Mathf.Max(0.01f, C.manaRegenSeconds) * (LastMinute ? C.lastMinuteRegenMul : 1f));
|
||||||
CastleHitFlash = Mathf.Max(0f, CastleHitFlash - dt);
|
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); }
|
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);
|
TickUnits(dt);
|
||||||
Separate(dt);
|
Separate(dt);
|
||||||
TickCastle(dt);
|
TickCastle(dt);
|
||||||
|
TickMagic(dt);
|
||||||
TickMinions(dt);
|
TickMinions(dt);
|
||||||
TickShots(dt);
|
TickShots(dt);
|
||||||
TickRegen(dt);
|
TickRegen(dt);
|
||||||
|
|
@ -448,7 +510,8 @@ namespace VS.Sim
|
||||||
if (!u.alive) continue;
|
if (!u.alive) continue;
|
||||||
u.hitFlash = Mathf.Max(0f, u.hitFlash - dt);
|
u.hitFlash = Mathf.Max(0f, u.hitFlash - dt);
|
||||||
u.slow = Mathf.Max(0f, u.slow - 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);
|
bool toFlag = ToFlag(u);
|
||||||
var m = NearestMinion(u.pos, Mathf.Max(C.unitSight, u.def.range + u.def.radius));
|
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);
|
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)
|
if (u.cd <= 0f)
|
||||||
{
|
{
|
||||||
u.cd = u.def.interval;
|
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 });
|
float dmg = u.def.damage * curseMul; // 저주 오라 안 = 공격력 감소
|
||||||
else if (m != null) HitMinion(m, u.def.damage);
|
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 HitCastle(u.def.damage);
|
else if (m != null) HitMinion(m, dmg);
|
||||||
|
else { HitCastle(dmg); Thorns(u, dmg); } // 가시 성벽 = 성을 근접 공격하면 일부 되돌림
|
||||||
}
|
}
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
@ -498,7 +562,7 @@ namespace VS.Sim
|
||||||
for (int i = 0; i < n; i++)
|
for (int i = 0; i < n; i++)
|
||||||
{
|
{
|
||||||
var u = Units[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));
|
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;
|
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);
|
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)
|
foreach (var w in CastleSkills)
|
||||||
{
|
{
|
||||||
if ((w.cd -= dt) > 0f) continue;
|
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));
|
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; }
|
if (t == null) { w.cd = C.castleRetarget; continue; }
|
||||||
CastSkill(w.def, t); w.cd = w.def.interval * mul;
|
CastSkill(w.def, t); w.cd = w.def.interval * mul;
|
||||||
}
|
}
|
||||||
|
TickPowers(dt);
|
||||||
foreach (var s in Summoners)
|
foreach (var s in Summoners)
|
||||||
{
|
{
|
||||||
if ((s.cd -= dt) > 0f) continue;
|
if ((s.cd -= dt) > 0f) continue;
|
||||||
|
|
@ -564,7 +630,7 @@ namespace VS.Sim
|
||||||
|
|
||||||
void FireShot(BAttackDef d, BUnit t)
|
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;
|
var dir = (t.pos - CastlePos).normalized;
|
||||||
if (d.id == "laser") // 레이저 = 성 가장자리부터 사거리 끝까지 선 위 전원 동시
|
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; }
|
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 home = HomeFor(d, slot);
|
||||||
var start = CastlePos + (home - CastlePos).normalized * (C.castle.radius + 0.3f);
|
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;
|
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 });
|
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++;
|
MinionsSpawned++;
|
||||||
|
|
@ -680,6 +746,7 @@ namespace VS.Sim
|
||||||
{
|
{
|
||||||
if (!m.alive) continue;
|
if (!m.alive) continue;
|
||||||
m.hitFlash = Mathf.Max(0f, m.hitFlash - dt);
|
m.hitFlash = Mathf.Max(0f, m.hitFlash - dt);
|
||||||
|
if (TickSpecialMinion(m, dt)) continue; // 수호병 · 폭탄병 · 토템(Battle.Stage.cs)
|
||||||
var d = m.def; float guard = Guard(d);
|
var d = m.def; float guard = Guard(d);
|
||||||
BUnit t = null; float bd = float.MaxValue;
|
BUnit t = null; float bd = float.MaxValue;
|
||||||
foreach (var u in Units) // 집에서 guard 안에 들어온 내 유닛만 — 그 밖(내 진영)으로는 나가지 않는다
|
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;
|
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++; }
|
if (u.hp <= 0f) { u.alive = false; Killed++; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -723,12 +791,19 @@ namespace VS.Sim
|
||||||
if (m.hp <= 0f) { m.alive = false; MinionsKilled++; }
|
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)
|
void TickRegen(float dt)
|
||||||
{
|
{
|
||||||
var k = C.castle;
|
var k = C.castle;
|
||||||
|
TickTotems(dt);
|
||||||
CastleQuiet += dt; CastleHealFlash = Mathf.Max(0f, CastleHealFlash - 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; }
|
if (k.regenPct <= 0f || k.regenInterval <= 0f || CastleQuiet < k.regenDelay || CastleHp <= 0f || CastleHp >= CastleMaxHp) { regenWait = 0f; return; }
|
||||||
while (regenWait <= 0f && CastleHp < CastleMaxHp)
|
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 bool StageOpen(int stage) => stage >= 1 && stage <= Math.Min(BattleStage, StageCount);
|
||||||
public int StageFocus => Math.Max(1, 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))));
|
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)
|
public BigInteger WinBattle(Battle b)
|
||||||
{
|
{
|
||||||
if (b == null || b.State != BattleState.Won) return BigInteger.Zero;
|
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);
|
AddCoin(r);
|
||||||
if (b.Stage == BattleStage && !b.IsTest) S.battleStage = b.Stage + 1; // 다음 스테이지 열기 = 지금 깰 차례인 스테이지를 시험 설정 없이 이겼을 때만(하나씩)
|
if (first) S.battleStage = b.Stage + 1; // 다음 스테이지 열기 = 지금 깰 차례인 스테이지를 시험 설정 없이 이겼을 때만(하나씩)
|
||||||
return r;
|
return r;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -49,5 +49,6 @@ namespace VS.Sim
|
||||||
public int battleStage = 1, battleSlots, battleManaLevel; // 전투 모드 (단계 · 소환 칸 · 최대 마나 강화 횟수)
|
public int battleStage = 1, battleSlots, battleManaLevel; // 전투 모드 (단계 · 소환 칸 · 최대 마나 강화 횟수)
|
||||||
public List<SaveDeck> decks = new List<SaveDeck>(); // 덱 프리셋(유닛 메뉴) — 칸마다 유닛 id · 0 = 빈 칸
|
public List<SaveDeck> decks = new List<SaveDeck>(); // 덱 프리셋(유닛 메뉴) — 칸마다 유닛 id · 0 = 빈 칸
|
||||||
public int deckSel; // 고른 프리셋
|
public int deckSel; // 고른 프리셋
|
||||||
|
public List<string> seenIntros = new List<string>(); // 안내 카드를 본 스테이지 요소(id) — 처음 만날 때 1회
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -29,9 +29,11 @@ namespace VS.View
|
||||||
float flagPop;
|
float flagPop;
|
||||||
// 상대방(여성 캐릭터) 얼굴 + 스킬 정보 — 전투마다 성 구성에 맞춰 줄을 새로 만든다
|
// 상대방(여성 캐릭터) 얼굴 + 스킬 정보 — 전투마다 성 구성에 맞춰 줄을 새로 만든다
|
||||||
RectTransform rivalBox; Image rivalFrame, rivalFace;
|
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<RivalRow> rivalRows = new List<RivalRow>();
|
readonly List<RivalRow> rivalRows = new List<RivalRow>();
|
||||||
const float RivalW = 240f, RivalTop = 116f, RivalRowH = 88f; // 클래시 로얄 남은 시간 칸 자리 — 맵을 덜 가리게 작게
|
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;
|
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 List<(Button b, Image face, Text cost, Image lockImg, Text lockText)> cards = new List<(Button, Image, Text, Image, Text)>();
|
||||||
readonly Dictionary<BUnit, (RectTransform rt, Image hp, Image stun)> unitViews = new Dictionary<BUnit, (RectTransform, Image, Image)>();
|
readonly Dictionary<BUnit, (RectTransform rt, Image hp, Image stun)> unitViews = new Dictionary<BUnit, (RectTransform, Image, Image)>();
|
||||||
|
|
@ -128,21 +130,15 @@ namespace VS.View
|
||||||
var sp = b.SpawnPoint; float sr = (c.spawnJitter + 0.8f) * S;
|
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.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));
|
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, 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));
|
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)
|
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); // 다리 폭 = 다리마다(좁은 · 보통 · 넓은)
|
||||||
float x = ox + bx * S, step = (2 * rh + S * 0.6f) / 5f;
|
DrawStageTerrain(r, b, ox, S);
|
||||||
r.RRect(x - hw, ry - rh - S * 0.5f, x + hw, ry + rh + S * 0.5f, 4, new Color32(150, 100, 60, 255));
|
|
||||||
for (int k = 0; k < 5; k++) { float y0 = ry - rh - S * 0.3f + k * step; r.RRect(x - hw + S * 0.2f, y0, x + hw - S * 0.2f, y0 + S * 0.2f, 2, new Color32(190, 140, 90, 255)); }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
foreach (var o in m.Obstacles) r.Ellipse(ox + o.pos.x * S + S * 0.2f, o.pos.y * S - S * 0.3f, o.r * S * 1.02f, o.r * S * 0.9f, new Color(0f, 0f, 0f, 0.22f)); // 그림자 먼저
|
foreach (var o in m.Obstacles) 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)
|
foreach (var o in m.Obstacles)
|
||||||
{
|
{
|
||||||
|
|
@ -302,6 +298,8 @@ namespace VS.View
|
||||||
{
|
{
|
||||||
string key = "sk_" + id;
|
string key = "sk_" + id;
|
||||||
if (bcache.TryGetValue(key, out var s)) return s;
|
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);
|
var r = new Raster(96, 96);
|
||||||
switch (id)
|
switch (id)
|
||||||
{
|
{
|
||||||
|
|
@ -349,6 +347,7 @@ namespace VS.View
|
||||||
var tap = new EventTrigger.Entry { eventID = EventTriggerType.PointerDown };
|
var tap = new EventTrigger.Entry { eventID = EventTriggerType.PointerDown };
|
||||||
tap.callback.AddListener(e => OnArenaTap((PointerEventData)e));
|
tap.callback.AddListener(e => OnArenaTap((PointerEventData)e));
|
||||||
arenaImg.gameObject.AddComponent<EventTrigger>().triggers.Add(tap);
|
arenaImg.gameObject.AddComponent<EventTrigger>().triggers.Add(tap);
|
||||||
|
fieldLayer = UI.Rect("Fields", arena).Fill(); // 필드 빛 · 장판 · 유성 표시 · 저주 고리(유닛 아래)
|
||||||
flagImg = UI.Img(arena, "Flag", FlagSprite(), Color.white);
|
flagImg = UI.Img(arena, "Flag", FlagSprite(), Color.white);
|
||||||
flagImg.rectTransform.anchorMin = flagImg.rectTransform.anchorMax = new Vector2(0.5f, 0.5f);
|
flagImg.rectTransform.anchorMin = flagImg.rectTransform.anchorMax = new Vector2(0.5f, 0.5f);
|
||||||
flagImg.rectTransform.pivot = new Vector2(18f / 72f, 8f / 104f); flagImg.rectTransform.sizeDelta = new Vector2(1.8f, 2.6f) * v.tileX; // 그림 72×104 비율
|
flagImg.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;
|
stageText.supportRichText = true;
|
||||||
// 왼쪽 아래(내 진영 구석 — 유닛은 가운데에서 나옴) = 맵 정보 · 깃발 안내
|
// 왼쪽 아래(내 진영 구석 — 유닛은 가운데에서 나옴) = 맵 정보 · 깃발 안내
|
||||||
var ib = UI.Img(BattleRoot, "InfoBg", Skin.RoundSoft, new Color(0.08f, 0.06f, 0.16f, 0.5f));
|
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));
|
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", "", 24, new Color32(236, 228, 255, 255), TextAnchor.UpperLeft, true);
|
infoText = UI.Txt(ib.transform, "Info", "", 22, new Color32(236, 228, 255, 255), TextAnchor.UpperLeft, true);
|
||||||
infoText.rectTransform.Fill(14, 8, 10, 8);
|
infoText.rectTransform.Fill(14, 8, 10, 8);
|
||||||
infoText.supportRichText = true;
|
infoText.supportRichText = true;
|
||||||
// 아래 카드 칸 + 마나 — 높이 view.trayH(클래시 로얄 ≈ 화면 폭 × 0.34) · 아래 116 = 마나 줄 · 위 = 카드
|
// 아래 카드 칸 + 마나 — 높이 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));
|
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;
|
flagPop = 0f;
|
||||||
|
BuildStageFx();
|
||||||
BuildRivalRows();
|
BuildRivalRows();
|
||||||
RefreshBattle();
|
RefreshBattle();
|
||||||
}
|
}
|
||||||
|
|
@ -469,34 +469,67 @@ namespace VS.View
|
||||||
{
|
{
|
||||||
foreach (var r in rivalRows) Kill(r.rt.gameObject);
|
foreach (var r in rivalRows) Kill(r.rt.gameObject);
|
||||||
rivalRows.Clear();
|
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.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 w in B.CastleSkills) AddRivalRow(w.def.id, w.def.name, RivalInfo(w.def), w, null);
|
||||||
foreach (var s in B.Summoners) AddRivalRow(s.def.id, s.def.name, "", null, s);
|
foreach (var 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));
|
var bg = UI.Img(rivalBox, "Skill_" + id, Skin.RoundSoft, new Color(1f, 1f, 1f, 0.08f));
|
||||||
// 한 줄 = 왼쪽 그림 · 오른쪽 이름 + 수치 2줄 · 맨 아래 재사용 막대
|
// 한 줄 = 왼쪽 그림 · 오른쪽 이름 + 수치 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));
|
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));
|
float ic = compact ? 38f : 44f;
|
||||||
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));
|
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);
|
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.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);
|
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));
|
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) · 사거리 = 시험 배율 반영
|
/// 피해 = 성 공격력 × dmgPct % (battle.json) · 사거리 = 시험 배율 반영
|
||||||
string RivalInfo(BAttackDef d) => AttackInfo(d, B.Atk, B.Range(d));
|
string RivalInfo(BAttackDef d) => AttackInfo(d, B.Atk, B.Range(d));
|
||||||
|
|
||||||
/// 성 공격 한 가지 설명 2줄 — 피해(성 공격력 × %) · 덧붙는 효과 · 사거리(칸) (스테이지 정보 창과 같이 씀)
|
/// 성 공격 한 가지 설명 2줄 — 피해(성 공격력 × %) · 덧붙는 효과 · 사거리(칸) (스테이지 정보 창과 같이 씀) · 새 기술 = 종류별
|
||||||
static string AttackInfo(BAttackDef d, float atk, float range)
|
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 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.#}초" : "";
|
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.#}칸";
|
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);
|
rivalFrame.color = B.Enraged ? (Color)new Color32(255, 70, 60, 255) : (Color)new Color32(255, 110, 150, 255);
|
||||||
foreach (var r in rivalRows)
|
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;
|
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 에서 줄어듦)
|
if (cd > r.lastCd + 0.05f) r.flash = 0.35f; // 방금 발동 (TickBattle 에서 줄어듦)
|
||||||
r.lastCd = cd;
|
r.lastCd = cd;
|
||||||
|
|
@ -517,7 +551,7 @@ namespace VS.View
|
||||||
if (r.s != null)
|
if (r.s != null)
|
||||||
{
|
{
|
||||||
int n = 0; foreach (var m in B.Minions) if (m.def == r.s.def) n++;
|
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()
|
void OnOverlayButton()
|
||||||
{
|
{
|
||||||
if (B == null) return;
|
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);
|
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()
|
BattleSetup StageDefaultSetup()
|
||||||
{
|
{
|
||||||
var lo = T.Battle.Loadout(stageSel);
|
var lo = T.Battle.Loadout(stageSel);
|
||||||
return new BattleSetup { shots = new List<string>(lo.shots ?? new string[0]), gimmicks = new List<string>(lo.gimmicks ?? new string[0]) };
|
return new BattleSetup { shots = new List<string>(lo.shots ?? new string[0]), gimmicks = new List<string>(lo.gimmicks ?? new string[0]), fields = new List<string>(lo.fields ?? new string[0]) };
|
||||||
}
|
}
|
||||||
|
|
||||||
public void OpenBattleTest()
|
public void OpenBattleTest()
|
||||||
|
|
@ -601,21 +636,28 @@ namespace VS.View
|
||||||
var def = StageDefaultSetup();
|
var def = StageDefaultSetup();
|
||||||
if (s.shots == null) s.shots = def.shots;
|
if (s.shots == null) s.shots = def.shots;
|
||||||
if (s.gimmicks == null) s.gimmicks = def.gimmicks;
|
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<System.Action>();
|
var refreshers = new List<System.Action>();
|
||||||
float y = -10f;
|
float y = -6f;
|
||||||
var mapNames = new List<string> { "무작위" }; mapNames.AddRange(c.mapNames);
|
// 지도: 스테이지(고정 지도) · 무작위 · 평지/다리 1~3개 — 해자 · 협곡 = 기본(스테이지 그대로) · 없음 · 있음
|
||||||
y = TestRow(p, refreshers, y, "맵", mapNames, k => s.mapType == k - 1, k => s.mapType = k - 1);
|
var mapNames = new List<string> { "스테이지", "무작위" }; mapNames.AddRange(c.mapNames);
|
||||||
y = TestRow(p, refreshers, y, "바위·언덕", new List<string> { "무작위", "없음", "있음" }, k => s.obstacles == k - 1, k => s.obstacles = k - 1);
|
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<string> { "기본", "없음", "있음" }, k => s.obstacles == k - 1, k => s.obstacles = k - 1);
|
||||||
|
var gates = new List<string> { "기본", "없음" }; 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<string> { "기본", "없음", "있음" }, 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);
|
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]));
|
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 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));
|
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]));
|
y = TestRow(p, refreshers, y, "성 기믹 (기술 · 소환 · 성 능력 · 여러 개 선택)", gNames, k => s.gimmicks.Contains(gIds[k]), k => Toggle(s.gimmicks, gIds[k]));
|
||||||
var muls = new List<float>(c.testRangeMuls);
|
var muls = new List<float>(c.testRangeMuls);
|
||||||
y = TestRow(p, refreshers, y, "성 사거리 배율", muls.ConvertAll(m => "×" + m.ToString("0.##")), k => Mathf.Abs(s.rangeMul - muls[k]) < 1e-4f, k => s.rangeMul = muls[k]);
|
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);
|
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 - 30), new Vector2(-20, 90), new Vector2(0.5f, 1));
|
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 _);
|
var reset = UI.TextBtn(p.body, "Reset", "단계 기본으로", Pal.Gray, 38, () => { BattleTest = null; CloseAllPopups(); if (B != null && B.State == BattleState.Fighting) BeginBattle(stageSel); else ShowStage(stageSel); }, out _);
|
||||||
reset.GetComponent<RectTransform>().Place(new Vector2(0, 0), new Vector2(0, 0), new Vector2(220, 80), new Vector2(400, 130));
|
reset.GetComponent<RectTransform>().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<string> l, string id) { if (!l.Remove(id)) l.Add(id); }
|
static void Toggle(List<string> l, string id) { if (!l.Remove(id)) l.Add(id); }
|
||||||
|
|
||||||
|
/// 시험 줄 — 글 + 버튼(한 줄 최대 TestPerRow 개 · 넘치면 다음 줄)
|
||||||
|
const int TestPerRow = 6;
|
||||||
float TestRow(Popup p, List<System.Action> refreshers, float y, string label, List<string> names, System.Func<int, bool> isOn, System.Action<int> click)
|
float TestRow(Popup p, List<System.Action> refreshers, float y, string label, List<string> names, System.Func<int, bool> isOn, System.Action<int> click)
|
||||||
{
|
{
|
||||||
UI.Txt(p.body, "L" + label, label, 34, Pal.Ink, TextAnchor.MiddleLeft).rectTransform.Place(new Vector2(0, 1), new Vector2(1, 1), new Vector2(10, y), new Vector2(-20, 52), new Vector2(0.5f, 1));
|
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 -= 58f;
|
y -= 44f;
|
||||||
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));
|
for (int start = 0, line = 0; start < names.Count; start += TestPerRow, line++)
|
||||||
|
{
|
||||||
|
var row = UI.Rect("R" + label + line, p.body).Place(new Vector2(0, 1), new Vector2(1, 1), new Vector2(0, y), new Vector2(0, 78), new Vector2(0.5f, 1));
|
||||||
var hl = row.gameObject.AddComponent<HorizontalLayoutGroup>();
|
var hl = row.gameObject.AddComponent<HorizontalLayoutGroup>();
|
||||||
hl.spacing = 10; hl.childControlWidth = true; hl.childControlHeight = true; hl.childForceExpandWidth = true; hl.childForceExpandHeight = true;
|
hl.spacing = 8; hl.childControlWidth = true; hl.childControlHeight = true; hl.childForceExpandWidth = true; hl.childForceExpandHeight = true;
|
||||||
for (int i = 0; i < names.Count; i++)
|
for (int i = start; i < Mathf.Min(names.Count, start + TestPerRow); i++)
|
||||||
{
|
{
|
||||||
int k = i;
|
int k = i;
|
||||||
var b = UI.TextBtn(row, "B" + i, names[i], Pal.Gray, 30, () => { click(k); p.refresh?.Invoke(); }, out _);
|
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));
|
refreshers.Add(() => UI.SetBtnColor(b, isOn(k) ? Pal.Pink : Pal.Gray));
|
||||||
}
|
}
|
||||||
return y - 124f;
|
y -= 86f;
|
||||||
|
}
|
||||||
|
return y - 8f;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------- 진행 · 갱신 ----------------
|
// ---------------- 진행 · 갱신 ----------------
|
||||||
|
|
@ -661,15 +709,17 @@ namespace VS.View
|
||||||
if (B == null || castleImg == null) return;
|
if (B == null || castleImg == null) return;
|
||||||
stageText.text = $"스테이지 {B.Stage}" + (B.IsTest ? " <color=#9CF0FF>시험</color>" : "") + (B.LastMinute && B.State == BattleState.Fighting ? " <color=#FF9CF5>마나 x2</color>" : "");
|
stageText.text = $"스테이지 {B.Stage}" + (B.IsTest ? " <color=#9CF0FF>시험</color>" : "") + (B.LastMinute && B.State == BattleState.Fighting ? " <color=#FF9CF5>마나 x2</color>" : "");
|
||||||
castleHpFill.fillAmount = B.CastleMaxHp > 0 ? B.CastleHp / B.CastleMaxHp : 0;
|
castleHpFill.fillAmount = B.CastleMaxHp > 0 ? B.CastleHp / B.CastleMaxHp : 0;
|
||||||
castleHpFill.color = B.CastleHealFlash > 0f ? (Color)new Color32(90, 230, 120, 255) : Pal.Red;
|
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).ToString();
|
castleHpText.text = Mathf.CeilToInt(B.CastleHp) + (B.ShieldUp ? $" +{Mathf.CeilToInt(B.Shield)}" : "");
|
||||||
castleImg.sprite = CastleSprite(B.Enraged);
|
castleImg.sprite = CastleSprite(B.Enraged);
|
||||||
castleImg.color = B.CastleHitFlash > 0 ? new Color(1f, 0.75f, 0.75f) : Color.white;
|
castleImg.color = B.CastleHitFlash > 0 ? new Color(1f, 0.75f, 0.75f) : Color.white;
|
||||||
manaText.text = Mathf.FloorToInt(B.Mana).ToString();
|
manaText.text = Mathf.FloorToInt(B.Mana).ToString();
|
||||||
manaFill.fillAmount = B.MaxMana > 0 ? B.Mana / B.MaxMana : 0;
|
manaFill.fillAmount = B.MaxMana > 0 ? B.Mana / B.MaxMana : 0;
|
||||||
manaMaxText.text = $"최대: {Mathf.RoundToInt(B.MaxMana)}";
|
manaMaxText.text = $"최대: {Mathf.RoundToInt(B.MaxMana)}";
|
||||||
infoText.text = $"<color=#FFE27A>맵</color> {B.MapName} · 바위·언덕 {B.Map.Obstacles.Count}\n<color=#FFE27A>깃발</color> 맵을 누르면 유닛이 깃발을 거쳐 성으로";
|
infoText.text = $"<color=#FFE27A>맵</color> {B.MapName}" + (B.Map.Obstacles.Count > 0 ? $" · 바위·언덕 {B.Map.Obstacles.Count}" : "")
|
||||||
|
+ $"\n<color=#FFE27A>필드</color> {FieldNames()}\n<color=#FFE27A>깃발</color> 맵을 누르면 유닛이 깃발을 거쳐 성으로";
|
||||||
RefreshRival();
|
RefreshRival();
|
||||||
|
RefreshStageFx();
|
||||||
flagImg.gameObject.SetActive(B.HasFlag && B.State == BattleState.Fighting);
|
flagImg.gameObject.SetActive(B.HasFlag && B.State == BattleState.Fighting);
|
||||||
if (B.HasFlag)
|
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.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.hp.fillAmount = Mathf.Clamp01(u.hp / Mathf.Max(1f, u.def.hp));
|
||||||
v.rt.GetComponent<Image>().color = u.hitFlash > 0 ? new Color(1f, 0.6f, 0.6f) : (u.slow > 0f ? new Color(0.55f, 0.78f, 1f) : Color.white);
|
v.rt.GetComponent<Image>().color = UnitTint(u);
|
||||||
v.rt.localScale = Vector3.one * (u.attacking ? 1f + 0.08f * Mathf.Sin(Time.unscaledTime * 18f) : 1f);
|
v.rt.localScale = Vector3.one * (u.attacking ? 1f + 0.08f * Mathf.Sin(Time.unscaledTime * 18f) : 1f);
|
||||||
v.stun.gameObject.SetActive(u.stun > 0f);
|
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);
|
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))
|
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;
|
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);
|
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);
|
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);
|
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));
|
im.color = new Color(0.35f, 1f, 0.45f, 0.75f * (1f - k));
|
||||||
break;
|
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
|
default: // spawn
|
||||||
im.sprite = RingSprite(); rt.pivot = new Vector2(0.5f, 0.5f);
|
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);
|
rt.anchoredPosition = TileToLocal(f.a); rt.sizeDelta = TileVec(Vector2.one * f.r * 2f) * (0.6f + 0.6f * k);
|
||||||
|
|
|
||||||
|
|
@ -13,15 +13,18 @@ namespace VS.View
|
||||||
{
|
{
|
||||||
public RectTransform StageRoot;
|
public RectTransform StageRoot;
|
||||||
int stageSel = 1; // 고른 스테이지 = 다음 전투 스테이지
|
int stageSel = 1; // 고른 스테이지 = 다음 전투 스테이지
|
||||||
Text stageTitle, stageStat, stageHint, stageBubble, stageReward, stageStartLabel;
|
Text stageTitle, stageStat, stageHint, stageBubble, stageReward, stageStartLabel, stageChapter, stageRankLabel;
|
||||||
Image stageCastle, stageFrame, stageFace, stageLock, stageClear;
|
Image stageCastle, stageFrame, stageFace, stageLock, stageClear, stageBg, stageCrown;
|
||||||
Button stageStart, stagePrev, stageNext;
|
Button stageStart, stagePrev, stageNext;
|
||||||
RectTransform stageIcons;
|
RectTransform stageIcons;
|
||||||
|
|
||||||
void BuildStageScreen()
|
void BuildStageScreen()
|
||||||
{
|
{
|
||||||
StageRoot = UI.Rect("Stage", Root).Fill(0, TabH, 0, 0);
|
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 _);
|
var tb = UI.TextBtn(StageRoot, "TestBtn", "시험", Pal.Purple, 30, () => OpenBattleTest(), out _);
|
||||||
tb.GetComponent<RectTransform>().Place(new Vector2(0, 1), new Vector2(0, 1), new Vector2(20, -24), new Vector2(130, 72), new Vector2(0, 1));
|
tb.GetComponent<RectTransform>().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 = 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));
|
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));
|
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));
|
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);
|
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 = 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 = 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));
|
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);
|
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);
|
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));
|
// 요소 그림(지형 · 필드 · 성 투사체 · 기믹) — 많으면 두 줄 · 이 스테이지에서 처음 나오는 것 = NEW
|
||||||
var hl = stageIcons.gameObject.AddComponent<HorizontalLayoutGroup>();
|
stageIcons = UI.Rect("Icons", StageRoot).Place(new Vector2(0.5f, 1), new Vector2(0.5f, 1), new Vector2(0, -1068), new Vector2(980, 170));
|
||||||
hl.spacing = 16; hl.childAlignment = TextAnchor.MiddleCenter; hl.childControlWidth = false; hl.childControlHeight = false; hl.childForceExpandWidth = false; hl.childForceExpandHeight = false;
|
var gl = stageIcons.gameObject.AddComponent<GridLayoutGroup>();
|
||||||
|
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 = 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));
|
stageLock.rectTransform.Place(new Vector2(0.5f, 1), new Vector2(0.5f, 1), new Vector2(0, -810), new Vector2(150, 200));
|
||||||
stageClear = UI.Img(StageRoot, "Clear", Skin.S("Label_Parallelogram_Green") ?? Skin.Pill, Color.white);
|
stageClear = 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);
|
stageSel = Mathf.Clamp(s, 1, n);
|
||||||
bool open = G.StageOpen(stageSel), clear = G.StageCleared(stageSel);
|
bool open = G.StageOpen(stageSel), clear = G.StageCleared(stageSel);
|
||||||
stageTitle.text = $"{stageSel}. {c.StageName(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))}";
|
stageStat.text = $"체력 {c.CastleHpAt(stageSel):N0} · 공격 {Mathf.RoundToInt(c.CastleAtkAt(stageSel))}";
|
||||||
stageFace.sprite = RivalFace(c.RivalOutfitAt(stageSel), false);
|
stageFace.sprite = RivalFace(c.RivalOutfitAt(stageSel), false);
|
||||||
Color dim = open ? Color.white : new Color(0.32f, 0.34f, 0.42f);
|
Color dim = open ? Color.white : new Color(0.32f, 0.34f, 0.42f);
|
||||||
stageCastle.color = dim; stageFace.color = dim;
|
stageCastle.color = dim; stageFace.color = dim;
|
||||||
stageFrame.color = open ? (Color)new Color32(255, 110, 150, 255) : new Color(0.4f, 0.4f, 0.48f);
|
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);
|
stageLock.gameObject.SetActive(!open);
|
||||||
stageClear.gameObject.SetActive(clear);
|
stageClear.gameObject.SetActive(clear);
|
||||||
// 성 공격 그림 줄(투사체 + 기믹 · 자세한 수치는 지도 버튼)
|
// 요소 그림 줄(지형 · 필드 · 성 투사체 · 기믹 · 자세한 수치는 지도 버튼) — 이 스테이지에서 처음 나오는 것 = NEW
|
||||||
var old = new List<GameObject>();
|
var old = new List<GameObject>();
|
||||||
foreach (Transform ch in stageIcons) old.Add(ch.gameObject);
|
foreach (Transform ch in stageIcons) old.Add(ch.gameObject);
|
||||||
foreach (var o in old) { o.SetActive(false); Kill(o); }
|
foreach (var o in old) { o.SetActive(false); Kill(o); }
|
||||||
var lo = c.Loadout(stageSel);
|
var els = c.Elements(stageSel);
|
||||||
var ids = new List<string>(lo.shots ?? new string[0]);
|
var gl = stageIcons.GetComponent<GridLayoutGroup>(); gl.constraintCount = Mathf.Max(1, els.Count > 9 ? (els.Count + 1) / 2 : els.Count);
|
||||||
if (lo.gimmicks != null) ids.AddRange(lo.gimmicks);
|
foreach (var e in els)
|
||||||
foreach (var id in ids)
|
|
||||||
{
|
{
|
||||||
var bg = UI.Img(stageIcons, "I_" + id, Skin.CirclePlain, new Color(0.08f, 0.1f, 0.2f, 0.55f));
|
bool isNew = c.FirstStage(e.id) == stageSel;
|
||||||
bg.rectTransform.sizeDelta = new Vector2(92, 92);
|
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(id), dim).rectTransform.Fill(12, 12, 12, 12);
|
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);
|
stagePrev.gameObject.SetActive(stageSel > 1);
|
||||||
stageNext.gameObject.SetActive(stageSel < n);
|
stageNext.gameObject.SetActive(stageSel < n);
|
||||||
|
|
@ -116,8 +134,9 @@ namespace VS.View
|
||||||
UI.SetBtnColor(stageStart, open ? Pal.Yellow : Pal.Gray);
|
UI.SetBtnColor(stageStart, open ? Pal.Yellow : Pal.Gray);
|
||||||
stageStartLabel.text = open ? "시작" : "잠김";
|
stageStartLabel.text = open ? "시작" : "잠김";
|
||||||
stageHint.text = BattleTest != null ? "시험 설정 적용 중 (시험 → 단계 기본으로 = 해제)" : !open ? "앞 스테이지를 깨면 열립니다" : clear ? "클리어한 스테이지 · 다시 할 수 있습니다" : "";
|
stageHint.text = BattleTest != null ? "시험 설정 적용 중 (시험 → 단계 기본으로 = 해제)" : !open ? "앞 스테이지를 깨면 열립니다" : clear ? "클리어한 스테이지 · 다시 할 수 있습니다" : "";
|
||||||
stageBubble.text = $"스테이지 {stageSel} 보상!";
|
bool first = G.IsFirstClear(stageSel);
|
||||||
stageReward.text = $"코인 {Fmt.Num(G.BattleReward(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);
|
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)
|
void BeginBattle(int s, int seed = -1)
|
||||||
|
|
@ -142,39 +161,45 @@ namespace VS.View
|
||||||
RefreshBattle();
|
RefreshBattle();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 지도 버튼 = 스테이지 정보 — 상대 성 능력치 · 성 공격 설명 · 맵 · 제한 시간 · 보상 (수치 = battle.json)
|
/// 지도 버튼 = 스테이지 정보 — 챕터·등급 · 상대 성 능력치 · 지도·필드 · 성 공격(투사체 · 기술 · 소환 · 성 능력) · 제한 시간 · 보상 (수치 = battle.json)
|
||||||
public void OpenStageInfo()
|
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);
|
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) => $"<color=#3A6FD8>{t}</color>";
|
string H(string t) => $"<color=#3A6FD8>{t}</color>";
|
||||||
|
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<string>
|
var lines = new List<string>
|
||||||
{
|
{
|
||||||
G.StageCleared(s) ? "클리어한 스테이지" : G.StageOpen(s) ? "도전할 스테이지" : "잠김 — 앞 스테이지를 깨면 열립니다",
|
(chap != null ? $"{chap.id}장 {chap.name} · " : "") + (G.StageCleared(s) ? "클리어한 스테이지" : G.StageOpen(s) ? "도전할 스테이지" : "잠김 — 앞 스테이지를 깨면 열립니다"),
|
||||||
$"{H("상대 성")} 체력 {c.CastleHpAt(s):N0} · 공격 {Mathf.RoundToInt(atk)}",
|
$"{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.#}% 회복",
|
$"체력 {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.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])
|
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.#}초마다");
|
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("");
|
||||||
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("제한 시간")} {Mathf.RoundToInt(c.battleSeconds / 60f)}분 · 마지막 {Mathf.RoundToInt(c.lastMinuteSeconds)}초 = 마나 ×{c.lastMinuteRegenMul:0}");
|
||||||
lines.Add($"{H("승리 보상")} 코인 {Fmt.Num(G.BattleReward(s))}");
|
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), 34, Pal.Ink, TextAnchor.UpperLeft);
|
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);
|
t.supportRichText = true; t.rectTransform.Fill(30, 30, 30, 24);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 배경 = 하늘(위 진한 파랑 → 지평선 하늘색) · 지평선 뒤 숲 그림자 · 풀밭 · 아래 짙은 덤불 (참고 화면 구도 · 코드 그림)
|
/// 배경 = 하늘(위 진한 파랑 → 지평선 하늘색) · 지평선 뒤 숲 그림자 · 풀밭 · 아래 짙은 덤불 (참고 화면 구도 · 코드 그림) · 챕터 빛깔(battle.json chapters tint)
|
||||||
static Sprite StageBgSprite()
|
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%
|
const int W = 360, H = 590; const float hz = H * 0.47f; // 지평선 = 아래에서 47%
|
||||||
var r = new Raster(W, H);
|
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);
|
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);
|
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);
|
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);
|
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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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<PatchView> patchViews = new List<PatchView>();
|
||||||
|
readonly List<Image> zoneViews = new List<Image>(), meteorRings = new List<Image>(), meteorBalls = new List<Image>(), boulderViews = new List<Image>(), tornadoViews = new List<Image>(), linkViews = new List<Image>();
|
||||||
|
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<Texture2D>("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<Image> 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<Raster> 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<string>();
|
||||||
|
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<string>());
|
||||||
|
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<color=#7A7F99>{num}</color>" : ""), 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<RectTransform>().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<string>());
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,2 @@
|
||||||
|
fileFormatVersion: 2
|
||||||
|
guid: 45c1529eac5f3da4aa9851777cc8caa6
|
||||||
Loading…
Reference in New Issue