ThreeKingdomsSlingshot/Assets/Scripts/Runtime/BattleManager.cs

711 lines
30 KiB
C#
Raw Permalink Blame History

This file contains ambiguous Unicode characters

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

using System;
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
namespace TKS
{
public enum BattlePhase
{
Setup,
PlayerTurn,
Resolving,
EnemyTurn,
StageClear,
Defeat
}
/// <summary>출전 1칸(아웃게임 편성 프리셋 · 2026-11-05 #1093). 배율 = 영웅 배율 × 성장 계수(기획서 §3-2) · slot = playerSpawnPoints 자리.</summary>
[Serializable]
public class LineupEntry
{
public int slot;
public string id;
public string name;
public UnitType type;
public string art;
public float hpMult = 1f;
public float atkMult = 1f;
public float defMult = 1f;
// 스킬(PD #1093 B · 편성 → SkillRules.FillLineup) — 병사 유닛 = 비어 있음
public string skillActive = "";
public string skillPassive = "";
public int activeLv = 1;
public int passiveLv = 1;
/// <summary>★5 패시브 배율(evolve.csv skill_unlock passive_x · 그 아래 ★ = 1).</summary>
public float passiveMult = 1f;
/// <summary>무장(영웅) — 참장 대상.</summary>
public bool general;
}
/// <summary>
/// 연무장 모드 전투(PD #1093 C · 탑 · 일일 던전 · 투기장 · 적장 토벌) — 스테이지 표 대신 이 적 편성(배율 반영)으로 시작한다.
/// 아군 = 출전 편성(LineupProvider) 그대로 · maxTurns 를 넘기면 전투 종료(패배 단계 · 적장 토벌 누적 피해) · 결과 탭 = onExit(월드맵 대신).
/// </summary>
public class BattleMode
{
public string kind = "";
public BalanceConfig.StageDef stage;
/// <summary>0 = 제한 없음 · n = n 턴을 마치면 끝.</summary>
public int maxTurns;
/// <summary>결과 화면 탭(연무장 복귀).</summary>
public Action onExit;
}
/// <summary>턴 진행·유닛 관리·승패 판정. 아군은 모든 유닛이 행동하면 자동으로 적군 턴이 된다(버튼 없음).</summary>
public class BattleManager : MonoBehaviour
{
public static BattleManager Instance { get; private set; }
public BalanceConfig Config;
public Board Board;
public BattleHUD Hud;
public SlingshotInput Sling;
public BattlePhase Phase { get; private set; }
public int StageIndex { get; private set; }
public int TurnNumber { get; private set; }
public System.Random Rng { get; private set; }
public ActionResolver Resolver { get; private set; }
/// <summary>스킬 상태·시한 효과(PD #1093 B) · 수치 질의 = SkillMods.</summary>
public SkillSystem Skills { get; private set; }
public string LastMessage = "";
readonly List<Unit> units = new List<Unit>();
readonly List<LineupEntry> deployed = new List<LineupEntry>();
public IReadOnlyList<Unit> Units
{
get { return units; }
}
/// <summary>출전 편성 공급자(GameBootstrap → 아웃게임 OutGameRoot 가 편성 프리셋으로 연결). null 이면 아군 없이 시작.</summary>
public Func<List<LineupEntry>> LineupProvider;
/// <summary>스킬 테스트 씬(2026-11-09): true = 적 턴에 적이 행동하지 않는다(더미).</summary>
public bool EnemyHold;
/// <summary>이번 전투에 출전한 칸(경험 지급 · 별 「전원 생존」 판정).</summary>
public IReadOnlyList<LineupEntry> Deployed
{
get { return deployed; }
}
public BalanceConfig.StageDef Stage
{
get { return Mode != null && Mode.stage != null ? Mode.stage : Config.stages[Mathf.Clamp(StageIndex, 0, Config.stages.Length - 1)]; }
}
/// <summary>지금 전투가 연무장 모드면 그 정의(스테이지 전투 = null · PD #1093 C).</summary>
public BattleMode Mode { get; private set; }
/// <summary>진영 상시 효과 공급자(보물 crit·range·splash → SkillMods · 아웃게임이 연결) · 전투 시작마다 읽어 둔다.</summary>
public Func<Faction, List<SkillEffect>> SideEffectsProvider;
readonly List<SkillEffect>[] sideEffects = new List<SkillEffect>[2];
public List<SkillEffect> SideEffects(Faction f)
{
return sideEffects[(int)f];
}
/// <summary>상태가 바뀔 때(HUD 갱신용).</summary>
public event Action Changed;
/// <summary>단계가 바뀔 때(카메라 연동용).</summary>
public event Action<BattlePhase> PhaseChanged;
/// <summary>어떤 유닛이 행동을 시작할 때(카메라 추적용).</summary>
public event Action<Unit> UnitActing;
/// <summary>스테이지 배치가 끝났을 때.</summary>
public event Action StageStarted;
void Awake()
{
Instance = this;
Rng = new System.Random(Environment.TickCount);
Fx.Runner = this;
Phase = BattlePhase.Setup;
Skills = new SkillSystem(this);
SkillSystem.Current = Skills;
}
void OnDestroy()
{
if (Instance == this) Instance = null;
if (Fx.Runner == this) Fx.Runner = null;
if (SkillSystem.Current == Skills) SkillSystem.Current = null;
}
public void Configure(BalanceConfig cfg, Board board)
{
Config = cfg;
Board = board;
Resolver = new ActionResolver(cfg, this, Rng);
}
public void NotifyChanged()
{
if (Sim.Active) return; // 가상 실행(적 AI 예측) 중 = HUD 갱신 없음
if (Changed != null) Changed();
}
/// <summary>지형 기믹 레이어(PD #1097 · 전투마다 스테이지 배치로 다시 만든다).</summary>
public GimmickLayer Gimmicks
{
get { return GimmickLayer.Current != null && GimmickLayer.Current.Battle == this ? GimmickLayer.Current : null; }
}
void SetPhase(BattlePhase p)
{
if (Phase == p) return;
Phase = p;
if (PhaseChanged != null) PhaseChanged(p);
NotifyChanged();
}
// ---------- 유닛 ----------
public List<Unit> AliveUnits(Faction f)
{
var list = new List<Unit>();
for (int i = 0; i < units.Count; i++)
{
var u = units[i];
if (u != null && u.IsAlive && u.Faction == f) list.Add(u);
}
return list;
}
public int AliveCount(Faction f)
{
int n = 0;
for (int i = 0; i < units.Count; i++)
{
var u = units[i];
if (u != null && u.IsAlive && u.Faction == f) n++;
}
return n;
}
public Unit UnitNear(Vector2 p, Faction? faction)
{
return Combat.UnitNear(units, p, faction, 0.25f);
}
public Unit SpawnUnit(UnitType type, Faction faction, Vector2 pos, string name, float hpMult = 1f, float atkMult = 1f, bool boss = false, string art = null)
{
var go = new GameObject("Unit");
go.transform.position = new Vector3(pos.x, pos.y, 0f);
var u = go.AddComponent<Unit>();
u.Init(Config, type, faction, name, hpMult, atkMult, boss, art);
u.Position = Board.ClampInside(pos, u.Radius);
units.Add(u);
return u;
}
public void ClearUnits()
{
for (int i = 0; i < units.Count; i++)
{
if (units[i] == null) continue;
units[i].gameObject.SetActive(false); // 콜라이더를 즉시 물리 월드에서 제거
Destroy(units[i].gameObject);
}
units.Clear();
if (Skills != null) Skills.Clear();
if (Gimmicks != null) Gimmicks.Clear();
}
// ---------- 스테이지 ----------
public void StartStage(int index)
{
Mode = null;
StageIndex = Mathf.Clamp(index, 0, Config.stages.Length - 1);
Begin(Config.stages[StageIndex]);
}
/// <summary>연무장 모드 전투 시작(적 편성 = mode.stage · PD #1093 C).</summary>
public void StartMode(BattleMode mode)
{
if (mode == null || mode.stage == null) return;
Mode = mode;
Begin(mode.stage);
}
void Begin(BalanceConfig.StageDef stage)
{
StopAllCoroutines();
ClearUnits();
Phase = BattlePhase.Setup;
sideEffects[0] = SideEffectsProvider != null ? SideEffectsProvider(Faction.Player) : null;
sideEffects[1] = SideEffectsProvider != null ? SideEffectsProvider(Faction.Enemy) : null;
// 아군 = 편성 프리셋(자리 0~2 전열 · 3~4 후열 → playerSpawnPoints) · 방어 배율은 Unit.Init 에 없어 생성 뒤 적용
deployed.Clear();
SkillCatalog.Invalidate(); // 임포트로 바뀐 스킬 표를 전투마다 다시 읽음
SkillVfx.Reload(); // 스킬 이펙트 표(skill_vfx.csv)도 전투마다 다시 읽음
HitFeel.Reload(); // 타격감 표(hit_feel.csv)
var lineup = LineupProvider != null ? LineupProvider() : null;
if (lineup != null)
{
foreach (var h in lineup)
{
if (h == null || h.slot < 0 || h.slot >= Config.playerSpawnPoints.Length) continue;
var u = SpawnUnit(h.type, Faction.Player, Config.playerSpawnPoints[h.slot], h.name, h.hpMult, h.atkMult, false, h.art);
u.Def = Mathf.Max(0, Mathf.RoundToInt(Config.GetStats(h.type).def * h.defMult));
var hd = SkillCatalog.Hero(h.id);
u.Camp = hd != null && hd.faction != null ? hd.faction : ""; // 진영별 이펙트(skill_vfx.csv s_camp) · 스킬 진영 대상
u.Female = hd != null && hd.Female; // 스킬 조건 남성·여성(2026-11-10 #1112)
u.HeroId = h.id ?? "";
Skills.AttachLineup(u, h); // 액티브·패시브(레벨 · ★5) · 산채 체력은 여기서 최대 HP 반영
deployed.Add(h);
}
}
foreach (var e in stage.enemies)
{
var u = SpawnUnit(e.type, Faction.Enemy, e.position, e.name, e.hpMult, e.atkMult, e.isBoss, e.art);
Skills.AttachEnemy(u, e, TacticalAI.BossUsesActive(stage, e)); // 적 무장 = 패시브 · 1부 보스 = 액티브도(적 AI 가 예약해 씀 · PD #1098)
}
GimmickLayer.Ensure(this).Build(stage); // 지형 기믹 레이어(배경 위 · 유닛 아래 · PD #1097)
TurnNumber = 1;
Skills.BeginBattle();
LastMessage = stage.title + " — 아군 차례";
string tip = GimmickTables.Table != null ? GimmickTables.Table.TipFor(stage.chapter, stage.stage) : "";
if (tip.Length > 0) LastMessage += " · " + tip; // 1장 튜토리얼 문구(tutorial_tips.csv · PD #1098 §3)
if (StageStarted != null) StageStarted();
SetPhase(BattlePhase.PlayerTurn);
}
/// <summary>결과 화면 탭 처리기(true = 승리). 있으면 다음 전투로 넘기지 않고 이 처리기(월드맵 복귀)를 부른다.</summary>
public Action<bool> ResultHandler;
/// <summary>전투 없음(월드맵 표시 중): 진행 중 루틴·유닛을 치우고 Setup 단계로.</summary>
public void GoIdle()
{
StopAllCoroutines();
ClearUnits();
SetPhase(BattlePhase.Setup);
}
public void ContinueAfterResult()
{
if (Mode != null && (Phase == BattlePhase.StageClear || Phase == BattlePhase.Defeat))
{
var exit = Mode.onExit;
Mode = null;
GoIdle();
if (exit != null) exit();
return;
}
if (ResultHandler != null && (Phase == BattlePhase.StageClear || Phase == BattlePhase.Defeat))
{
ResultHandler(Phase == BattlePhase.StageClear);
return;
}
if (Phase == BattlePhase.StageClear)
{
StartStage((StageIndex + 1) % Config.stages.Length);
}
else if (Phase == BattlePhase.Defeat)
{
StartStage(StageIndex);
}
}
// ---------- 아군 행동 ----------
public bool CanAct(Unit u)
{
return Phase == BattlePhase.PlayerTurn && u != null && u.Faction == Faction.Player && u.IsAlive && !u.ActedThisTurn;
}
/// <summary>궁병·책사 공격 가능 범위(사거리 + 접근 허용 거리 · 가장자리 간격 기준) 안에 상대 유닛이 하나라도 있는가 — 도넛 색 표시용.</summary>
public bool AnyEnemyWithinArcherReach(Unit u)
{
if (u == null) return false;
float reach = SkillMods.Range(u) + u.ArcherMoveAllow;
Faction other = u.Faction == Faction.Player ? Faction.Enemy : Faction.Player;
foreach (var e in AliveUnits(other))
{
float gap = Vector2.Distance(u.Position, e.Position) - u.Radius * Combat.CastRadiusScale - e.Radius;
if (gap <= reach) return true;
}
return false;
}
public bool RequestPlayerAction(Unit u, Vector2 target)
{
if (!CanAct(u)) return false;
SetPhase(BattlePhase.Resolving);
if (UnitActing != null) UnitActing(u);
StartCoroutine(PlayerActionRoutine(u, target));
return true;
}
IEnumerator PlayerActionRoutine(Unit u, Vector2 target)
{
bool wasBonus = u.BonusMoveAvailable;
bool moveOnly = wasBonus && !Skills.BonusCanAttack(u); // 호치·강동의 호랑이 = 추가 이동에서 공격 가능
var o = new ActionOutcome();
yield return Resolver.Resolve(u, target, moveOnly, o);
yield return ApplyOverwatch(u, o);
// 추가 이동(보병 목표점 명중) → 끝나면 스킬 재행동(이도류·자오곡·백의도강·파로·군령 …) → 행동 끝
if (u.IsAlive && Skills.NextBonus(u, wasBonus, o)) u.GrantBonusMove();
else if (u.IsAlive && Skills.TakeReaction(u))
{
Skills.ClearBonus(u);
u.GrantReAction();
}
else
{
u.MarkActed();
Skills.ClearBonus(u);
}
LastMessage = Describe(u, o, moveOnly);
NotifyChanged();
if (CheckBattleEnd()) yield break;
if (AllPlayersActed())
{
yield return EnemyTurnRoutine();
}
else
{
SetPhase(BattlePhase.PlayerTurn);
}
}
bool AllPlayersActed()
{
foreach (var u in AliveUnits(Faction.Player))
{
if (!u.ActedThisTurn) return false;
}
return true;
}
public bool OnlyBonusMovesRemain()
{
bool any = false;
foreach (var u in AliveUnits(Faction.Player))
{
if (u.ActedThisTurn) continue;
if (!u.BonusMoveAvailable) return false;
any = true;
}
return any;
}
/// <summary>빈 곳 탭: 남은 행동이 추가 이동뿐이면 건너뛰고 적군 턴으로.</summary>
public void OnEmptyTap()
{
if (Phase != BattlePhase.PlayerTurn) return;
if (!OnlyBonusMovesRemain()) return;
foreach (var u in AliveUnits(Faction.Player))
{
if (!u.BonusMoveAvailable) continue;
u.MarkActed();
Skills.ClearBonus(u);
}
SetPhase(BattlePhase.Resolving);
StartCoroutine(EnemyTurnRoutine());
}
/// <summary>즉시형 액티브 연출(장판 일갈 밀기 · 변진 이동 등) — 아군 턴에만 · 끝나면 다시 아군 턴(행동 소모 없음).</summary>
public bool RunSkillRoutine(IEnumerator routine)
{
if (Phase != BattlePhase.PlayerTurn || routine == null) return false;
SetPhase(BattlePhase.Resolving);
StartCoroutine(SkillRoutine(routine));
return true;
}
IEnumerator SkillRoutine(IEnumerator routine)
{
yield return routine;
NotifyChanged();
if (CheckBattleEnd()) yield break;
SetPhase(BattlePhase.PlayerTurn);
}
// ---------- 적군 턴 ----------
public Coroutine RunEnemyTurn()
{
return StartCoroutine(EnemyTurnRoutine());
}
IEnumerator EnemyTurnRoutine()
{
Skills.OnEnemyTurnStart(); // 이번 아군 턴 효과(동남풍·고육계) · 쓰지 않은 예약 정리
SetPhase(BattlePhase.EnemyTurn);
LastMessage = "적군 차례";
NotifyChanged();
foreach (var e in AliveUnits(Faction.Enemy)) e.ResetForNewTurn();
if (Gimmicks != null)
{
yield return Gimmicks.TurnStart(Faction.Enemy); // 군기 · 중독 · 불 · 행동 불가(PD #1097) · 스킬 기절
if (CheckBattleEnd()) yield break;
}
yield return Skills.TurnBegin(Faction.Enemy); // 스킬 라운드(아군 턴 끝 회복·정화 · 시한 효과 · 중독·화상 · 지대 · 2026-11-10 #1112)
if (CheckBattleEnd()) yield break;
yield return Fx.Wait(0.4f);
float accuracy = Stage.aiAccuracy;
float aggro = Mathf.Max(0f, Stage.aiAggroRange + Skills.AggroAdd(Faction.Enemy)); // 신산 = 적 교전 범위 −
int acted = 0;
// 예측 시뮬레이션 AI(PD #1098): 남은 적 전원 평가 → 서포터 → 돌격 → 사격 · 1기 실행마다 재평가 · 교전 범위 밖 = 대기
var pending = new List<Unit>();
foreach (var e in AliveUnits(Faction.Enemy))
{
if (Skills.Suppressed(e)) continue; // 장판 일갈·태평요술 = 이번 적 턴 교전 반응 없음
if (GimmickLayer.Skips(e)) continue; // 함정·강 = 이번 턴 행동 불가
pending.Add(e);
}
if (EnemyHold) pending.Clear(); // 스킬 테스트 씬: 더미는 행동하지 않음(턴 · 쿨 · 시한 효과는 그대로 넘어감)
bool tactical = TacticalAI.Available;
while (pending.Count > 0)
{
pending.RemoveAll(x => x == null || !x.IsAlive);
var players = AliveUnits(Faction.Player);
if (players.Count == 0 || pending.Count == 0) break;
Unit e;
Vector2 target;
bool useSkill = false;
if (tactical)
{
var plan = TacticalAI.Next(this, pending, accuracy, aggro, Rng);
if (plan == null) break; // 남은 유닛 = 교전 범위 밖 → 자리 지킴
e = plan.Unit;
target = plan.Target;
useSkill = plan.UseSkill;
LastAiPlan = plan;
}
else
{
e = pending[0];
Vector2? aim = EnemyAI.Decide(e, players, accuracy, aggro, Rng, Board, Config); // 가중치 자산이 없으면 기존 조준
if (!aim.HasValue)
{
pending.RemoveAt(0);
continue;
}
target = aim.Value;
}
pending.Remove(e);
if (UnitActing != null) UnitActing(e);
yield return Fx.Wait(0.3f); // 카메라가 따라올 시간
if (useSkill && Skills.Of(e) != null) Skills.Of(e).Reserved = true; // 적 보스 액티브(다음 행동형 · 1부 보스만)
var o = new ActionOutcome();
yield return Resolver.Resolve(e, target, false, o);
yield return ApplyOverwatch(e, o);
LastMessage = Describe(e, o, false);
NotifyChanged();
acted++;
if (e.IsAlive && Skills.TakeReaction(e)) pending.Add(e); // 적 스킬 재행동
if (CheckBattleEnd()) yield break;
yield return Fx.Wait(0.25f);
}
if (acted == 0) yield return Fx.Wait(0.3f);
if (Mode != null && Mode.maxTurns > 0 && TurnNumber >= Mode.maxTurns) // 연무장 제한 턴(적장 토벌) = 전투 끝
{
LastMessage = Mode.maxTurns + "턴 종료";
SetPhase(BattlePhase.Defeat);
yield break;
}
foreach (var u in AliveUnits(Faction.Player)) u.ResetForNewTurn();
Skills.OnPlayerTurnStart(); // 이번 적 턴 효과(호위·철벽·간웅·일갈·안개) · 견제 강화 정리
TurnNumber++;
if (Gimmicks != null)
{
yield return Gimmicks.TurnStart(Faction.Player); // 군기 · 불 지속 · 중독 · 불 · 행동 불가(PD #1097) · 스킬 기절
if (CheckBattleEnd()) yield break;
}
yield return Skills.TurnBegin(Faction.Player); // 스킬 라운드(적 턴 끝 처리 · 시한 효과 · 중독·화상 · 회복 · 지대 · 망령 소멸)
if (CheckBattleEnd()) yield break;
LastMessage = TurnNumber + "번째 턴 — 아군 차례" + (acted == 0 ? " (적은 교전 범위 밖에서 대기)" : "");
if (AliveCount(Faction.Player) > 0 && AllPlayersActed())
{
LastMessage = TurnNumber + "번째 턴 — 아군 전원 행동 불가";
yield return Fx.Wait(0.4f);
yield return EnemyTurnRoutine(); // 함정·강으로 아군 전원이 쉬는 턴
yield break;
}
SetPhase(BattlePhase.PlayerTurn);
}
/// <summary>마지막 적 AI 결정(디버그 · 테스트 · 캡처).</summary>
public AiPlan LastAiPlan;
/// <summary>창병 대기: 상대 유닛이 지나간 경로가 대기 중인 창병 반경을 스치면 피해(창병마다 1회).</summary>
public IEnumerator ApplyOverwatch(Unit mover, List<Vector2> path)
{
var o = new ActionOutcome();
if (path != null) o.Path.AddRange(path);
yield return ApplyOverwatch(mover, o);
}
/// <summary>
/// 창병 대기 견제(스킬 반영 · PD #1093 B): 반경 = SkillMods.OverwatchRadius(후계·백마의 창·청룡언월) · 지나간 적 1기당 1회(기본) × 견제 횟수(삼대 노신 = n 회 타격) ·
/// 「견제 무제한」(청룡언월) = 행동 유닛 경로가 반경을 스친 구간마다 + 그 행동으로 밀리거나 넉백된 상대 유닛도 타격 · 피해 × 교변 · 쓰러뜨리면 하북 명장 회복.
/// </summary>
public IEnumerator ApplyOverwatch(Unit mover, ActionOutcome o)
{
if (mover == null || o == null) yield break;
var path = o.Path;
Faction other = mover.Faction == Faction.Player ? Faction.Enemy : Faction.Player;
foreach (var spear in AliveUnits(other))
{
if (spear.Type != UnitType.Spear || !spear.Overwatch) continue;
if (!mover.IsAlive && o.Displaced.Count == 0) yield break;
float radius = spear.AttackRange; // 옛 견제(대기 유닛 없음 · 스킬 확장 제거 2026-11-10 #1112)
bool unlimited = false;
var victims = new List<Unit>();
int passes = 0;
for (int i = 0; path != null && i < path.Count - 1; i++)
{
Vector2 a = path[i];
Vector2 b = path[i + 1];
if (Vector2.Distance(a, b) < 0.05f) continue;
if (Combat.SegmentPointDistance(a, b, spear.Position) > radius + mover.Radius) continue;
passes++;
if (!unlimited) break;
}
for (int i = 0; i < passes; i++) victims.Add(mover);
if (unlimited)
{
foreach (var d in o.Displaced)
{
if (d.Unit == null || d.Unit == mover || d.Unit.Faction == spear.Faction) continue;
if (Vector2.Distance(d.From, d.To) < 0.05f) continue;
if (Combat.SegmentPointDistance(d.From, d.To, spear.Position) <= radius + d.Unit.Radius) victims.Add(d.Unit);
}
}
if (victims.Count == 0) continue;
const int strikes = 1;
foreach (var v in victims)
{
for (int k = 0; k < strikes; k++)
{
if (!v.IsAlive || !spear.IsAlive) break;
int dmg = Combat.OverwatchDamage(Config, spear, v, Rng);
if (spear.View != null) spear.View.PlayAttack();
yield return Fx.Lunge(spear, v.Position);
dmg = SkillSystem.Deal(v, dmg, spear, HitKind.Attack);
Fx.Popup(v.Position, dmg + " 견제", new Color(0.6f, 0.85f, 1f));
NotifyChanged();
yield return Fx.Wait(0.2f);
}
}
}
}
// ---------- 승패 ----------
/// <summary>스킬 소환(장로 망령)을 뺀 생존 수 — 승패 판정용.</summary>
int RealAlive(Faction f)
{
int n = 0;
foreach (var u in units)
if (u != null && u.IsAlive && u.Faction == f && !SkillSystem.IsSummon(u)) n++;
return n;
}
/// <summary>유닛 제거(소환 소멸 · 콜라이더 즉시 제거).</summary>
public void RemoveUnit(Unit u)
{
if (u == null) return;
units.Remove(u);
u.gameObject.SetActive(false);
Destroy(u.gameObject);
}
public bool CheckBattleEnd()
{
if (RealAlive(Faction.Enemy) == 0)
{
LastMessage = "승리 — 전장의 적을 모두 처치했습니다";
foreach (var p in AliveUnits(Faction.Player))
{
if (p.View != null) p.View.PlayVictory();
}
SetPhase(BattlePhase.StageClear);
return true;
}
if (RealAlive(Faction.Player) == 0)
{
LastMessage = "패배 — 아군이 전멸했습니다";
SetPhase(BattlePhase.Defeat);
return true;
}
return false;
}
// ---------- 테스트·자동 플레이용 ----------
public IEnumerator ResolveForTest(Unit u, Vector2 target, bool moveOnly, ActionOutcome o)
{
yield return Resolver.Resolve(u, target, moveOnly, o);
}
public void ForcePhase(BattlePhase p)
{
SetPhase(p);
NotifyChanged();
}
/// <summary>테스트·캡처: 턴 번호 지정(스킬 쿨·선봉 1턴 확인).</summary>
public void SetTurnForTest(int turn)
{
TurnNumber = Mathf.Max(1, turn);
}
static string Describe(Unit u, ActionOutcome o, bool moveOnly)
{
string who = u.DisplayName + "(" + UnitTypeInfo.Korean(u.Type) + ")" + (o.SkillUsed != null ? " 「" + o.SkillUsed.NameKo + "」" : "");
string s;
if (o.Attacked.Count == 0)
{
s = who + (o.Fired ? " 사격" : moveOnly ? " 추가 이동" : " 이동");
}
else
{
var names = new List<string>();
foreach (var a in o.Attacked)
{
if (!names.Contains(a.DisplayName)) names.Add(a.DisplayName);
}
s = who + " → " + string.Join(", ", names.ToArray()) + " 공격 " + o.DamageDealt;
if (o.Crit) s += " 크리티컬!";
if (o.CounterTaken > 0) s += " · 반격 " + o.CounterTaken;
}
if (o.Pushed.Count > 0)
{
var pushed = new List<string>();
foreach (var p in o.Pushed)
{
if (!pushed.Contains(p.DisplayName)) pushed.Add(p.DisplayName);
}
s += " · " + string.Join(", ", pushed.ToArray()) + " 밀어냄";
}
if (o.FriendlyHit.Count > 0)
{
var hurt = new List<string>();
foreach (var f in o.FriendlyHit)
{
if (!hurt.Contains(f.DisplayName)) hurt.Add(f.DisplayName);
}
s += " · 아군 오사 " + string.Join(", ", hurt.ToArray()) + " " + o.FriendlyDamage; // PD 2026-11-05 #1087 ⑤⑥
}
foreach (var n in o.Notes) s += " · " + n;
if (o.BonusMove) s += " · 추가 이동!";
return s;
}
}
}