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"); } } }