using System.Collections;
using System.Collections.Generic;
using UnityEngine;
namespace Heroes.Battle
{
///
/// Battle facade and turn engine. Give it two 7-slot armies and it starts the
/// battle: it (re)generates the battlefield, deploys both armies, then — in
/// play mode — runs the round loop.
///
/// Each round every alive stack acts once, ordered by speed (fastest first).
/// On a stack's turn the reachable hexes are highlighted (blue) and enemies
/// in melee reach are highlighted (red); the player clicks a hex to move
/// there, clicks a highlighted enemy to move adjacent and strike, or presses
/// Space / right-click to skip. Damage = max(0, attack - defense) * attacker
/// count, subtracted from the target stack's total health.
///
/// Deployment works in edit mode too (used by the Battle Test window); the
/// interactive loop only runs during play.
///
[DisallowMultipleComponent]
public sealed class BattleController : MonoBehaviour
{
private const string UnitRootName = "Units";
private const string HighlightRootName = "Highlights";
[Header("References")]
[Tooltip("Battlefield view used for hex-to-world conversion and field generation. Its database/hex prefab must be assigned.")]
public BattlefieldView battlefield;
[Tooltip("Camera used to translate mouse clicks into hexes. Falls back to Camera.main.")]
public Camera battleCamera;
[Header("Battlefield")]
[Tooltip("If true, StartBattle regenerates the battlefield before deploying units.")]
public bool generateBattlefield = true;
[Tooltip("If true, a fresh random seed is rolled on every StartBattle.")]
public bool randomizeSeed = true;
public int seed;
public TerrainType terrain = TerrainType.Grass;
[Header("Units")]
[Tooltip("Sorting order for unit sprites; count labels render one order above, markers below.")]
public int unitSortingOrder = 3;
[Tooltip("Movement speed of unit models, in world units per second.")]
public float unitMoveSpeed = 1.5f;
[Tooltip("Placeholder tint for attacker units that have no 3D prefab assigned.")]
public Color attackerColor = new Color(0.35f, 0.55f, 1f);
[Tooltip("Placeholder tint for defender units that have no 3D prefab assigned.")]
public Color defenderColor = new Color(1f, 0.45f, 0.4f);
[Tooltip("How 3D unit models are oriented (virtual tilt plane), ordered over the board, and animated.")]
public UnitViewSettings unitView = new UnitViewSettings();
[Header("Highlights")]
public Color reachableColor = new Color(0.3f, 0.6f, 1f, 0.35f);
public Color attackableColor = new Color(1f, 0.3f, 0.25f, 0.45f);
public int highlightSortingOrder = 2;
private readonly List _stacks = new List();
private readonly List _views = new List();
private bool[,] _obstacleMap;
private Coroutine _battleRoutine;
// Turn state exposed for the on-screen HUD.
private int _round;
private UnitStack _activeStack;
private string _resultText;
/// All stacks deployed in the current battle, both sides.
public IReadOnlyList Stacks => _stacks;
public bool IsBattleRunning { get; private set; }
///
/// Starts a battle between the two armies: regenerates the battlefield
/// (when enabled), deploys every non-empty slot of both armies, and — in
/// play mode — begins the interactive round loop.
///
public void StartBattle(Army attacker, Army defender)
{
if (battlefield == null)
{
Debug.LogError($"{nameof(BattleController)}: battlefield reference is missing.", this);
return;
}
if (attacker == null || defender == null)
{
Debug.LogError($"{nameof(BattleController)}: both armies must be provided.", this);
return;
}
if (!attacker.HasUnits && !defender.HasUnits)
{
Debug.LogWarning($"{nameof(BattleController)}: both armies are empty, nothing to deploy.", this);
return;
}
StopBattleRoutine();
if (generateBattlefield && !TryGenerateBattlefield(attacker, defender)) return;
ClearUnits();
DeployArmy(attacker, BattleSide.Attacker);
DeployArmy(defender, BattleSide.Defender);
_round = 0;
_resultText = null;
if (Application.isPlaying)
{
_battleRoutine = StartCoroutine(BattleRoutine());
}
}
/// Removes all deployed units, highlights and resets the battle state.
public void ClearUnits()
{
StopBattleRoutine();
_stacks.Clear();
_views.Clear();
_activeStack = null;
IsBattleRunning = false;
ClearChildren(FindChild(UnitRootName));
ClearChildren(FindChild(HighlightRootName));
}
private void StopBattleRoutine()
{
if (_battleRoutine != null)
{
StopCoroutine(_battleRoutine);
_battleRoutine = null;
}
IsBattleRunning = false;
}
// --- Deployment --------------------------------------------------------
private bool TryGenerateBattlefield(Army attacker, Army defender)
{
if (battlefield.database == null)
{
Debug.LogError($"{nameof(BattleController)}: battlefield has no obstacle database, cannot generate.", this);
return false;
}
if (randomizeSeed) seed = new System.Random().Next();
// Wide units need the second column free of obstacles as well.
int reservedLeft = attacker.HasWideUnits ? 2 : 1;
int reservedRight = defender.HasWideUnits ? 2 : 1;
var config = BattlefieldGenerator.Generate(seed, terrain, battlefield.database, reservedLeft, reservedRight);
battlefield.Deploy(config);
_obstacleMap = BuildObstacleMap(config);
return true;
}
private void DeployArmy(Army army, BattleSide side)
{
var root = EnsureChild(UnitRootName);
int anchorX = side == BattleSide.Attacker ? 0 : BattlefieldConfig.Width - 1;
Color placeholderTint = side == BattleSide.Attacker ? attackerColor : defenderColor;
for (int i = 0; i < Army.SlotCount; i++)
{
var slot = army.GetSlot(i);
if (slot.IsEmpty) continue;
// Slot index maps straight to the row: slots fill top-to-bottom.
var stack = new UnitStack(slot.Unit, slot.Count, side, i, anchorX, i);
_stacks.Add(stack);
var view = UnitStackView.Create(root, stack, battlefield, unitSortingOrder, placeholderTint, unitMoveSpeed, unitView);
_views.Add(view);
}
}
private bool[,] BuildObstacleMap(BattlefieldConfig config)
{
var occupied = new bool[BattlefieldConfig.Width, BattlefieldConfig.Height];
if (battlefield.database == null) return occupied;
foreach (var placed in config.Obstacles)
{
var definition = battlefield.database.FindById(placed.ObstacleId);
if (definition == null || definition.blockedHexes == null) continue;
foreach (var offset in definition.blockedHexes)
{
int x = placed.X + offset.dx;
int y = placed.Y + offset.dy;
if (x < 0 || x >= BattlefieldConfig.Width) continue;
if (y < 0 || y >= BattlefieldConfig.Height) continue;
occupied[x, y] = true;
}
}
return occupied;
}
// --- Turn loop ---------------------------------------------------------
private IEnumerator BattleRoutine()
{
IsBattleRunning = true;
while (BothSidesAlive())
{
_round++;
foreach (var stack in BuildTurnOrder())
{
if (!stack.IsAlive) continue;
if (!BothSidesAlive()) break;
yield return TakeTurn(stack);
}
}
_activeStack = null;
IsBattleRunning = false;
_resultText = DetermineWinnerText();
}
private IEnumerator TakeTurn(UnitStack stack)
{
var grid = new BattleGrid(_obstacleMap, _stacks);
var reachable = grid.ComputeReachable(stack, out var cameFrom);
var attackable = grid.ComputeAttackable(stack, reachable);
var start = new Vector2Int(stack.X, stack.Y);
_activeStack = stack;
stack.Activate();
ShowHighlights(reachable, attackable);
UnitStack attackTarget = null;
Vector2Int moveDestination = start;
bool actionChosen = false;
bool skip = false;
while (!actionChosen && !skip)
{
if (Input.GetKeyDown(KeyCode.Space) || Input.GetMouseButtonDown(1))
{
skip = true;
break;
}
if (Input.GetMouseButtonDown(0) && TryGetHexUnderMouse(out Vector2Int hex))
{
var clickedStack = grid.StackAt(hex.x, hex.y);
if (clickedStack != null && attackable.TryGetValue(clickedStack, out var stand))
{
attackTarget = clickedStack;
moveDestination = stand;
actionChosen = true;
}
else if (clickedStack == null && reachable.ContainsKey(hex) && hex != start)
{
moveDestination = hex;
actionChosen = true;
}
}
yield return null;
}
ClearHighlights();
if (!skip)
{
if (moveDestination != start)
{
var path = BattleGrid.BuildPath(cameFrom, start, moveDestination);
if (path.Count > 0)
{
stack.StartMove(path);
yield return new WaitUntil(() => stack.State != UnitStackState.Moving);
}
}
if (attackTarget != null && attackTarget.IsAlive)
{
ResolveAttack(stack, attackTarget);
yield return new WaitUntil(() => stack.State != UnitStackState.Attacking);
}
}
if (stack.IsAlive) stack.Deactivate();
_activeStack = null;
}
/// Applies the damage formula and starts the attacker's strike animation.
private void ResolveAttack(UnitStack attacker, UnitStack target)
{
int damage = Mathf.Max(0, attacker.Attack - target.Defense) * attacker.Count;
attacker.StartAttack(target);
target.ApplyDamage(damage);
}
private List BuildTurnOrder()
{
var order = new List();
foreach (var stack in _stacks)
{
if (stack.IsAlive) order.Add(stack);
}
// Fastest first; ties: attacker before defender, then by slot index.
order.Sort((a, b) =>
{
int bySpeed = b.Speed.CompareTo(a.Speed);
if (bySpeed != 0) return bySpeed;
int bySide = a.Side.CompareTo(b.Side);
if (bySide != 0) return bySide;
return a.SlotIndex.CompareTo(b.SlotIndex);
});
return order;
}
private bool BothSidesAlive()
{
bool attacker = false, defender = false;
foreach (var stack in _stacks)
{
if (!stack.IsAlive) continue;
if (stack.Side == BattleSide.Attacker) attacker = true;
else defender = true;
if (attacker && defender) return true;
}
return false;
}
private string DetermineWinnerText()
{
bool attacker = false, defender = false;
foreach (var stack in _stacks)
{
if (!stack.IsAlive) continue;
if (stack.Side == BattleSide.Attacker) attacker = true;
else defender = true;
}
if (attacker && !defender) return "Attacker wins!";
if (defender && !attacker) return "Defender wins!";
return "Draw.";
}
// --- Input -------------------------------------------------------------
private bool TryGetHexUnderMouse(out Vector2Int hex)
{
hex = default;
var cam = battleCamera != null ? battleCamera : Camera.main;
if (cam == null) return false;
Vector3 world = cam.ScreenToWorldPoint(Input.mousePosition);
Vector3 local = battlefield.transform.InverseTransformPoint(new Vector3(world.x, world.y, 0f));
float bestDist = float.MaxValue;
Vector2Int best = default;
for (int y = 0; y < BattlefieldConfig.Height; y++)
{
for (int x = 0; x < BattlefieldConfig.Width; x++)
{
Vector3 center = battlefield.HexToWorld(x, y);
float d = (local.x - center.x) * (local.x - center.x) + (local.y - center.y) * (local.y - center.y);
if (d < bestDist)
{
bestDist = d;
best = new Vector2Int(x, y);
}
}
}
// Accept only clicks that land reasonably inside a hex.
float stepX = Mathf.Max(1, battlefield.EffectiveHorizontalStepPx);
float ppu = battlefield.EffectivePixelsPerUnit;
float threshold = stepX / ppu;
if (bestDist > threshold * threshold) return false;
hex = best;
return true;
}
// --- Highlights --------------------------------------------------------
private void ShowHighlights(Dictionary reachable, Dictionary attackable)
{
var root = EnsureChild(HighlightRootName);
ClearChildren(root);
var start = _activeStack != null ? new Vector2Int(_activeStack.X, _activeStack.Y) : new Vector2Int(-1, -1);
foreach (var kvp in reachable)
{
if (kvp.Key == start) continue;
CreateHighlight(root, kvp.Key, reachableColor);
}
foreach (var kvp in attackable)
{
foreach (int col in kvp.Key.OccupiedColumns())
{
CreateHighlight(root, new Vector2Int(col, kvp.Key.Y), attackableColor);
}
}
}
private void CreateHighlight(Transform root, Vector2Int hex, Color color)
{
var go = new GameObject($"HL_{hex.x}_{hex.y}");
go.transform.SetParent(root, false);
go.transform.localPosition = battlefield.HexToWorld(hex.x, hex.y);
go.transform.localScale = Vector3.one * 0.62f;
var sr = go.AddComponent();
sr.sprite = GetHighlightSprite();
sr.color = color;
sr.sortingOrder = highlightSortingOrder;
}
private void ClearHighlights() => ClearChildren(FindChild(HighlightRootName));
private static Sprite _highlightSprite;
private static Sprite GetHighlightSprite()
{
if (_highlightSprite == null)
{
var texture = Texture2D.whiteTexture;
_highlightSprite = Sprite.Create(
texture,
new Rect(0f, 0f, texture.width, texture.height),
new Vector2(0.5f, 0.5f),
texture.width / 0.6f);
_highlightSprite.name = "HexHighlight";
_highlightSprite.hideFlags = HideFlags.DontSave;
}
return _highlightSprite;
}
// --- Scene helpers -----------------------------------------------------
private Transform FindChild(string childName)
{
return battlefield != null ? battlefield.transform.Find(childName) : null;
}
private Transform EnsureChild(string childName)
{
var existing = FindChild(childName);
if (existing != null) return existing;
var go = new GameObject(childName);
go.transform.SetParent(battlefield.transform, false);
return go.transform;
}
private void ClearChildren(Transform root)
{
if (root == null) return;
for (int i = root.childCount - 1; i >= 0; i--)
{
var child = root.GetChild(i).gameObject;
if (Application.isPlaying) Destroy(child);
else DestroyImmediate(child);
}
}
private void OnGUI()
{
if (!Application.isPlaying) return;
var style = new GUIStyle(GUI.skin.label) { fontSize = 14, fontStyle = FontStyle.Bold };
var box = new Rect(10, 10, 460, 70);
GUI.Box(box, GUIContent.none);
if (!string.IsNullOrEmpty(_resultText))
{
GUI.Label(new Rect(20, 18, 440, 24), _resultText, style);
return;
}
if (!IsBattleRunning) return;
string who = _activeStack != null && _activeStack.Definition != null
? $"{_activeStack.Side} — {_activeStack.Definition.ResolveDisplayName()} (x{_activeStack.Count})"
: "—";
GUI.Label(new Rect(20, 16, 440, 22), $"Round {_round} Active: {who}", style);
GUI.Label(new Rect(20, 40, 440, 22),
"Click blue hex = move, red enemy = attack, Space/RMB = skip");
}
}
}