367 lines
12 KiB
C#
367 lines
12 KiB
C#
|
|
using System;
|
||
|
|
using System.Collections;
|
||
|
|
using System.Collections.Generic;
|
||
|
|
using UnityEngine;
|
||
|
|
|
||
|
|
namespace TKS
|
||
|
|
{
|
||
|
|
public enum BattlePhase
|
||
|
|
{
|
||
|
|
Setup,
|
||
|
|
PlayerTurn,
|
||
|
|
Resolving,
|
||
|
|
EnemyTurn,
|
||
|
|
StageClear,
|
||
|
|
Defeat
|
||
|
|
}
|
||
|
|
|
||
|
|
/// <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; }
|
||
|
|
public string LastMessage = "";
|
||
|
|
|
||
|
|
readonly List<Unit> units = new List<Unit>();
|
||
|
|
|
||
|
|
public IReadOnlyList<Unit> Units
|
||
|
|
{
|
||
|
|
get { return units; }
|
||
|
|
}
|
||
|
|
|
||
|
|
public BalanceConfig.StageDef Stage
|
||
|
|
{
|
||
|
|
get { return Config.stages[Mathf.Clamp(StageIndex, 0, Config.stages.Length - 1)]; }
|
||
|
|
}
|
||
|
|
|
||
|
|
public event Action Changed;
|
||
|
|
|
||
|
|
void Awake()
|
||
|
|
{
|
||
|
|
Instance = this;
|
||
|
|
Rng = new System.Random(Environment.TickCount);
|
||
|
|
Fx.Runner = this;
|
||
|
|
Phase = BattlePhase.Setup;
|
||
|
|
}
|
||
|
|
|
||
|
|
void OnDestroy()
|
||
|
|
{
|
||
|
|
if (Instance == this) Instance = null;
|
||
|
|
if (Fx.Runner == this) Fx.Runner = null;
|
||
|
|
}
|
||
|
|
|
||
|
|
public void Configure(BalanceConfig cfg, Board board)
|
||
|
|
{
|
||
|
|
Config = cfg;
|
||
|
|
Board = board;
|
||
|
|
Resolver = new ActionResolver(cfg, this, Rng);
|
||
|
|
}
|
||
|
|
|
||
|
|
public void NotifyChanged()
|
||
|
|
{
|
||
|
|
if (Changed != null) Changed();
|
||
|
|
}
|
||
|
|
|
||
|
|
// ---------- 유닛 ----------
|
||
|
|
|
||
|
|
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)
|
||
|
|
{
|
||
|
|
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);
|
||
|
|
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();
|
||
|
|
}
|
||
|
|
|
||
|
|
// ---------- 스테이지 ----------
|
||
|
|
|
||
|
|
public void StartStage(int index)
|
||
|
|
{
|
||
|
|
StopAllCoroutines();
|
||
|
|
ClearUnits();
|
||
|
|
StageIndex = Mathf.Clamp(index, 0, Config.stages.Length - 1);
|
||
|
|
var stage = Config.stages[StageIndex];
|
||
|
|
|
||
|
|
int heroCount = Mathf.Min(Config.heroes.Length, Config.playerSpawnPoints.Length, 5);
|
||
|
|
for (int i = 0; i < heroCount; i++)
|
||
|
|
{
|
||
|
|
var h = Config.heroes[i];
|
||
|
|
SpawnUnit(h.type, Faction.Player, Config.playerSpawnPoints[i], h.name, h.hpMult, h.atkMult, false);
|
||
|
|
}
|
||
|
|
foreach (var e in stage.enemies)
|
||
|
|
{
|
||
|
|
SpawnUnit(e.type, Faction.Enemy, e.position, e.name, e.hpMult, e.atkMult, e.isBoss);
|
||
|
|
}
|
||
|
|
|
||
|
|
TurnNumber = 1;
|
||
|
|
Phase = BattlePhase.PlayerTurn;
|
||
|
|
LastMessage = stage.title + " — 아군 차례";
|
||
|
|
NotifyChanged();
|
||
|
|
}
|
||
|
|
|
||
|
|
public void ContinueAfterResult()
|
||
|
|
{
|
||
|
|
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;
|
||
|
|
}
|
||
|
|
|
||
|
|
public bool RequestPlayerAction(Unit u, Vector2 target)
|
||
|
|
{
|
||
|
|
if (!CanAct(u)) return false;
|
||
|
|
Phase = BattlePhase.Resolving;
|
||
|
|
NotifyChanged();
|
||
|
|
StartCoroutine(PlayerActionRoutine(u, target));
|
||
|
|
return true;
|
||
|
|
}
|
||
|
|
|
||
|
|
IEnumerator PlayerActionRoutine(Unit u, Vector2 target)
|
||
|
|
{
|
||
|
|
bool moveOnly = u.BonusMoveAvailable;
|
||
|
|
var o = new ActionOutcome();
|
||
|
|
yield return Resolver.Resolve(u, target, moveOnly, o);
|
||
|
|
yield return ApplyOverwatch(u, o.Path);
|
||
|
|
|
||
|
|
if (u.IsAlive && o.BonusMove) u.GrantBonusMove();
|
||
|
|
else u.MarkActed();
|
||
|
|
LastMessage = Describe(u, o, moveOnly);
|
||
|
|
NotifyChanged();
|
||
|
|
|
||
|
|
if (CheckBattleEnd()) yield break;
|
||
|
|
if (AllPlayersActed())
|
||
|
|
{
|
||
|
|
yield return EnemyTurnRoutine();
|
||
|
|
}
|
||
|
|
else
|
||
|
|
{
|
||
|
|
Phase = BattlePhase.PlayerTurn;
|
||
|
|
NotifyChanged();
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
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) u.MarkActed();
|
||
|
|
}
|
||
|
|
Phase = BattlePhase.Resolving;
|
||
|
|
NotifyChanged();
|
||
|
|
StartCoroutine(EnemyTurnRoutine());
|
||
|
|
}
|
||
|
|
|
||
|
|
// ---------- 적군 턴 ----------
|
||
|
|
|
||
|
|
public Coroutine RunEnemyTurn()
|
||
|
|
{
|
||
|
|
return StartCoroutine(EnemyTurnRoutine());
|
||
|
|
}
|
||
|
|
|
||
|
|
IEnumerator EnemyTurnRoutine()
|
||
|
|
{
|
||
|
|
Phase = BattlePhase.EnemyTurn;
|
||
|
|
LastMessage = "적군 차례";
|
||
|
|
NotifyChanged();
|
||
|
|
foreach (var e in AliveUnits(Faction.Enemy)) e.ResetForNewTurn();
|
||
|
|
yield return Fx.Wait(0.5f);
|
||
|
|
|
||
|
|
var enemies = AliveUnits(Faction.Enemy);
|
||
|
|
float accuracy = Stage.aiAccuracy;
|
||
|
|
for (int i = 0; i < enemies.Count; i++)
|
||
|
|
{
|
||
|
|
var e = enemies[i];
|
||
|
|
if (!e.IsAlive) continue;
|
||
|
|
var players = AliveUnits(Faction.Player);
|
||
|
|
if (players.Count == 0) break;
|
||
|
|
|
||
|
|
Vector2 target = EnemyAI.Decide(e, players, accuracy, Rng, Board);
|
||
|
|
var o = new ActionOutcome();
|
||
|
|
yield return Resolver.Resolve(e, target, false, o);
|
||
|
|
yield return ApplyOverwatch(e, o.Path);
|
||
|
|
LastMessage = Describe(e, o, false);
|
||
|
|
NotifyChanged();
|
||
|
|
if (CheckBattleEnd()) yield break;
|
||
|
|
yield return Fx.Wait(0.25f);
|
||
|
|
}
|
||
|
|
|
||
|
|
foreach (var u in AliveUnits(Faction.Player)) u.ResetForNewTurn();
|
||
|
|
TurnNumber++;
|
||
|
|
Phase = BattlePhase.PlayerTurn;
|
||
|
|
LastMessage = TurnNumber + "번째 턴 — 아군 차례";
|
||
|
|
NotifyChanged();
|
||
|
|
}
|
||
|
|
|
||
|
|
/// <summary>창병 대기: 상대 유닛이 지나간 경로가 대기 중인 창병 반경을 스치면 피해(창병마다 1회).</summary>
|
||
|
|
public IEnumerator ApplyOverwatch(Unit mover, List<Vector2> path)
|
||
|
|
{
|
||
|
|
if (mover == null || path == null || path.Count < 2) yield break;
|
||
|
|
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) yield break;
|
||
|
|
bool triggered = false;
|
||
|
|
for (int i = 0; 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) <= spear.AttackRange + mover.Radius)
|
||
|
|
{
|
||
|
|
triggered = true;
|
||
|
|
break;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
if (!triggered) continue;
|
||
|
|
int dmg = Combat.OverwatchDamage(Config, spear, mover, Rng);
|
||
|
|
yield return Fx.Lunge(spear, mover.Position);
|
||
|
|
mover.TakeDamage(dmg);
|
||
|
|
Fx.Popup(mover.Position, dmg + " 견제", new Color(0.6f, 0.85f, 1f));
|
||
|
|
NotifyChanged();
|
||
|
|
yield return Fx.Wait(0.2f);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// ---------- 승패 ----------
|
||
|
|
|
||
|
|
public bool CheckBattleEnd()
|
||
|
|
{
|
||
|
|
if (AliveCount(Faction.Enemy) == 0)
|
||
|
|
{
|
||
|
|
Phase = BattlePhase.StageClear;
|
||
|
|
LastMessage = "승리 — 전장의 적을 모두 처치했습니다";
|
||
|
|
NotifyChanged();
|
||
|
|
return true;
|
||
|
|
}
|
||
|
|
if (AliveCount(Faction.Player) == 0)
|
||
|
|
{
|
||
|
|
Phase = BattlePhase.Defeat;
|
||
|
|
LastMessage = "패배 — 아군이 전멸했습니다";
|
||
|
|
NotifyChanged();
|
||
|
|
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)
|
||
|
|
{
|
||
|
|
Phase = p;
|
||
|
|
NotifyChanged();
|
||
|
|
}
|
||
|
|
|
||
|
|
static string Describe(Unit u, ActionOutcome o, bool moveOnly)
|
||
|
|
{
|
||
|
|
string who = u.DisplayName + "(" + UnitTypeInfo.Korean(u.Type) + ")";
|
||
|
|
if (o.Attacked.Count == 0)
|
||
|
|
{
|
||
|
|
return who + (moveOnly ? " 추가 이동" : " 이동");
|
||
|
|
}
|
||
|
|
var names = new List<string>();
|
||
|
|
foreach (var a in o.Attacked)
|
||
|
|
{
|
||
|
|
if (!names.Contains(a.DisplayName)) names.Add(a.DisplayName);
|
||
|
|
}
|
||
|
|
string s = who + " → " + string.Join(", ", names.ToArray()) + " 공격 " + o.DamageDealt;
|
||
|
|
if (o.Crit) s += " 크리티컬!";
|
||
|
|
if (o.CounterTaken > 0) s += " · 반격 " + o.CounterTaken;
|
||
|
|
if (o.BonusMove) s += " · 추가 이동!";
|
||
|
|
if (o.Returned) s += " · 제자리 복귀";
|
||
|
|
if (o.Overwatch) s += " · 대기(견제) 발동";
|
||
|
|
return s;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|