using System.Collections; using System.Collections.Generic; using UnityEngine; using UnityEngine.SceneManagement; namespace MazeGen { /// /// Plays a fight out on a grid in its own additively-loaded scene, then throws that scene /// away and hands the camera back to the maze. /// /// Why this can't corrupt the maze: the fight's outcome is already fully decided before /// playback starts (see ), so this class is pure animation over a /// finished - it never touches a character sheet, a room, or an /// agent. Health bars run off a private display copy of each combatant's health. And because /// everything it creates lives in a separate scene which is unloaded on exit, there's nothing /// left behind to restore. The maze's own GameObjects are never disabled or re-created (which /// would kill the spawn coroutines), only hidden by suspending the maze camera; when the fight /// ends every agent is exactly where it was, and only the fight's result has changed. /// /// Runs on real Unity time, because the maze simulation is frozen via SimulationClock (not /// Time.timeScale) precisely so that this playback can still animate. /// [DisallowMultipleComponent] public class BattleArenaView : MonoBehaviour { [Header("Arena")] [Tooltip("Grid dimensions (columns x rows) of the battle floor.")] [SerializeField] private Vector2Int gridSize = new Vector2Int(11, 7); [SerializeField] private float cellSize = 1f; [Tooltip("Where in world space the arena scene is built. Far from the maze so the arena camera can't see maze geometry.")] [SerializeField] private Vector2 arenaWorldOrigin = new Vector2(0f, 10000f); [Header("Playback")] [Tooltip("Base tempo: seconds per combat event at 1x speed.")] [SerializeField, Min(0.02f)] private float secondsPerEvent = 0.4f; [SerializeField, Range(0.25f, 8f)] private float playbackSpeed = 1f; [Tooltip("Seconds the final result stays on screen before auto-returning to the maze (0 = wait for the button).")] [SerializeField, Min(0f)] private float autoReturnSeconds = 4f; /// Per-combatant playback state. DisplayHealth is a copy - never the real sheet. private class Slot { public Combatant Combatant; public Transform Transform; public SpriteRenderer Sprite; public Vector3 Home; public float DisplayHealth; public bool Felled; } private struct Floater { public Vector3 WorldPosition; public string Text; public Color Color; public float BornAt; } private readonly Dictionary _slots = new Dictionary(); private readonly List _floaters = new List(); private readonly List _feed = new List(); private readonly List _suspendedCameras = new List(); private Scene _scene; private bool _sceneLoaded; private GameObject _root; private Camera _camera; private PendingFight _fight; private BattleResult _result; private int _round; private bool _playbackFinished; private bool _skipRequested; private bool _returnRequested; private Vector2 _feedScroll; public bool IsBuilt => _sceneLoaded; /// Seconds per animation "beat" at the current playback speed. private float Beat => Mathf.Max(0.02f, secondsPerEvent) / Mathf.Max(0.25f, playbackSpeed); /// /// Builds the arena, replays the fight, waits for the observer, and tears everything back /// down. Yield on this from a coroutine; when it completes the maze is on screen again. /// public IEnumerator Play(PendingFight fight, IReadOnlyList combatants) { _fight = fight; _result = fight.Result; _skipRequested = false; _returnRequested = false; _playbackFinished = false; _round = 0; _feed.Clear(); _floaters.Clear(); Build(combatants); yield return Replay(); ApplyFinalState(); _playbackFinished = true; yield return WaitForObserver(); Teardown(); } // ---------------------------------------------------------------- build / teardown private void Build(IReadOnlyList combatants) { // A per-fight scene name: CreateScene refuses to make a second scene with a name // that's already loaded, and a previous arena may still be unloading. _scene = SceneManager.CreateScene($"CombatArena_{_fight.Id}_{Time.frameCount}"); _sceneLoaded = true; _root = new GameObject("ArenaRoot"); SceneManager.MoveGameObjectToScene(_root, _scene); _root.transform.position = arenaWorldOrigin; BuildFloor(); BuildCamera(); PlaceCombatants(combatants); } private void BuildFloor() { var floor = new GameObject("Floor"); floor.transform.SetParent(_root.transform, false); for (int x = 0; x < gridSize.x; x++) { for (int y = 0; y < gridSize.y; y++) { var tile = new GameObject($"Cell_{x}_{y}"); tile.transform.SetParent(floor.transform, false); tile.transform.position = CellToWorld(x, y); tile.transform.localScale = Vector3.one * (cellSize * 0.96f); var sr = tile.AddComponent(); sr.sprite = RoomView.SharedSquareSprite; sr.color = (x + y) % 2 == 0 ? new Color(0.16f, 0.17f, 0.2f) : new Color(0.21f, 0.22f, 0.26f); sr.sortingOrder = 0; } } } private void BuildCamera() { var cameraGO = new GameObject("ArenaCamera"); cameraGO.transform.SetParent(_root.transform, false); cameraGO.transform.position = new Vector3(arenaWorldOrigin.x, arenaWorldOrigin.y, -10f); _camera = cameraGO.AddComponent(); _camera.orthographic = true; _camera.backgroundColor = new Color(0.05f, 0.05f, 0.07f); _camera.clearFlags = CameraClearFlags.SolidColor; _camera.depth = 100f; // draw over anything else that somehow stays enabled float aspect = Mathf.Max(_camera.aspect, 0.01f); float halfHeight = gridSize.y * cellSize * 0.5f; float halfWidth = gridSize.x * cellSize * 0.5f; _camera.orthographicSize = Mathf.Max(halfHeight, halfWidth / aspect) * 1.35f; // Hide the maze by suspending its cameras rather than deactivating maze objects - // SetActive(false) on the maze root would permanently kill its spawn coroutines. _suspendedCameras.Clear(); foreach (var cam in Camera.allCameras) { if (cam == _camera) continue; _suspendedCameras.Add(cam); cam.enabled = false; } } /// Agents line up on the left of the grid, monsters on the right, facing off. private void PlaceCombatants(IReadOnlyList combatants) { _slots.Clear(); var agents = new List(); var monsters = new List(); foreach (var c in combatants) (c.Team == Combatant.TeamAgents ? agents : monsters).Add(c); LayoutTeam(agents, true); LayoutTeam(monsters, false); } private void LayoutTeam(List team, bool leftSide) { int rows = Mathf.Max(1, gridSize.y); for (int i = 0; i < team.Count; i++) { int column = i / rows; int indexInColumn = i % rows; int countInColumn = Mathf.Min(rows, team.Count - column * rows); int x = leftSide ? Mathf.Min(column, gridSize.x - 1) : Mathf.Max(0, gridSize.x - 1 - column); int y = (rows - countInColumn) / 2 + indexInColumn; _slots[team[i].Id] = CreateSlot(team[i], CellToWorld(x, y)); } } private Slot CreateSlot(Combatant combatant, Vector3 position) { var go = new GameObject($"{(combatant.IsAgent ? "Agent" : "Monster")}_{combatant.Name}"); go.transform.SetParent(_root.transform, false); go.transform.position = position; go.transform.localScale = Vector3.one * (cellSize * (combatant.IsAgent ? 0.58f : 0.5f)); var sr = go.AddComponent(); sr.sprite = RoomView.SharedSquareSprite; sr.color = combatant.Color; sr.sortingOrder = 2; return new Slot { Combatant = combatant, Transform = go.transform, Sprite = sr, Home = position, DisplayHealth = combatant.StartHealth }; } private void Teardown() { foreach (var cam in _suspendedCameras) if (cam != null) cam.enabled = true; _suspendedCameras.Clear(); _slots.Clear(); _floaters.Clear(); _camera = null; _root = null; if (_sceneLoaded && _scene.IsValid() && _scene.isLoaded) SceneManager.UnloadSceneAsync(_scene); _sceneLoaded = false; _fight = null; _result = null; } // ---------------------------------------------------------------- playback private IEnumerator Replay() { foreach (var e in _result.Events) { if (_skipRequested || _returnRequested) break; Feed(e.Text); switch (e.Kind) { case CombatEventKind.Hit: case CombatEventKind.Miss: yield return Strike(e); break; case CombatEventKind.Death: yield return Fell(e.TargetId); break; case CombatEventKind.RoundStart: _round = e.Round; yield return Hold(0.6f); break; default: yield return Hold(1.2f); break; } } } private IEnumerator Strike(CombatEvent e) { if (!_slots.TryGetValue(e.ActorId, out Slot actor) || !_slots.TryGetValue(e.TargetId, out Slot target)) { yield return Hold(0.5f); yield break; } Vector3 lunge = Vector3.Lerp(actor.Home, target.Home, 0.5f); yield return Glide(actor, actor.Home, lunge, 0.45f); if (e.Kind == CombatEventKind.Hit) { target.DisplayHealth = Mathf.Max(0f, target.DisplayHealth - e.Amount); AddFloater(target, $"-{e.Amount:0.#}", new Color(1f, 0.45f, 0.4f)); yield return Flash(target, new Color(1f, 0.35f, 0.35f)); } else { AddFloater(target, "miss", new Color(0.85f, 0.85f, 0.85f)); yield return Flash(target, Color.white); } yield return Glide(actor, lunge, actor.Home, 0.3f); } private IEnumerator Fell(int combatantId) { if (!_slots.TryGetValue(combatantId, out Slot slot)) yield break; slot.Felled = true; slot.DisplayHealth = 0f; Vector3 startScale = slot.Transform.localScale; Color startColor = slot.Sprite.color; float duration = Beat * 0.9f; for (float t = 0f; t < duration; t += Time.deltaTime) { if (_returnRequested) break; float k = Mathf.Clamp01(t / duration); slot.Transform.localScale = Vector3.Lerp(startScale, startScale * 0.35f, k); slot.Sprite.color = new Color(startColor.r * 0.4f, startColor.g * 0.4f, startColor.b * 0.4f, 1f - 0.65f * k); slot.Transform.rotation = Quaternion.Euler(0f, 0f, 90f * k); yield return null; } slot.Transform.localScale = startScale * 0.35f; slot.Sprite.color = new Color(startColor.r * 0.4f, startColor.g * 0.4f, startColor.b * 0.4f, 0.35f); slot.Transform.rotation = Quaternion.Euler(0f, 0f, 90f); } private IEnumerator Glide(Slot slot, Vector3 from, Vector3 to, float beats) { float duration = Beat * beats; for (float t = 0f; t < duration; t += Time.deltaTime) { if (_skipRequested || _returnRequested) break; slot.Transform.position = Vector3.Lerp(from, to, Mathf.Clamp01(t / duration)); yield return null; } slot.Transform.position = to; } private IEnumerator Flash(Slot slot, Color flashColor) { Color original = slot.Sprite.color; slot.Sprite.color = flashColor; yield return Hold(0.35f); if (!slot.Felled) slot.Sprite.color = original; } private IEnumerator Hold(float beats) { float duration = Beat * beats; for (float t = 0f; t < duration; t += Time.deltaTime) { if (_skipRequested || _returnRequested) yield break; yield return null; } } /// /// Snaps every combatant to the true final state of the decided battle, so a skipped or /// interrupted playback still ends on an accurate picture. /// private void ApplyFinalState() { foreach (var slot in _slots.Values) { slot.DisplayHealth = slot.Combatant.Sheet.Stats.CurrentHealth; slot.Transform.position = slot.Home; if (!slot.Combatant.IsAlive && !slot.Felled) { slot.Felled = true; Color c = slot.Sprite.color; slot.Sprite.color = new Color(c.r * 0.4f, c.g * 0.4f, c.b * 0.4f, 0.35f); slot.Transform.localScale *= 0.35f; slot.Transform.rotation = Quaternion.Euler(0f, 0f, 90f); } else if (!slot.Felled) { slot.Sprite.color = slot.Combatant.Color; } } if (_skipRequested) Feed("(skipped to the end)"); Feed(_result.Summary); } private IEnumerator WaitForObserver() { float waited = 0f; while (!_returnRequested) { if (autoReturnSeconds > 0f && waited >= autoReturnSeconds) break; waited += Time.deltaTime; yield return null; } } // ---------------------------------------------------------------- helpers & HUD private Vector3 CellToWorld(int x, int y) { float ox = (x - (gridSize.x - 1) * 0.5f) * cellSize; float oy = (y - (gridSize.y - 1) * 0.5f) * cellSize; return new Vector3(arenaWorldOrigin.x + ox, arenaWorldOrigin.y + oy, 0f); } private void AddFloater(Slot slot, string text, Color color) { _floaters.Add(new Floater { WorldPosition = slot.Home + Vector3.up * (cellSize * 0.5f), Text = text, Color = color, BornAt = Time.time }); } private void Feed(string line) { if (string.IsNullOrEmpty(line)) return; _feed.Add(line); if (_feed.Count > 200) _feed.RemoveAt(0); _feedScroll.y = float.MaxValue; // keep pinned to the newest line } private void OnGUI() { if (!_sceneLoaded || _camera == null || _fight == null) return; DrawNameplates(); DrawFloaters(); DrawPanel(); } private void DrawNameplates() { var style = new GUIStyle(GUI.skin.label) { alignment = TextAnchor.MiddleCenter, fontSize = 11 }; foreach (var slot in _slots.Values) { Vector3 screen = _camera.WorldToScreenPoint(slot.Home); if (screen.z < 0f) continue; float max = slot.Combatant.MaxHealth; float fraction = Mathf.Clamp01(max > 0f ? slot.DisplayHealth / max : 0f); var rect = new Rect(screen.x - 55f, Screen.height - screen.y - 44f, 110f, 30f); GUI.color = slot.Felled ? new Color(0.6f, 0.6f, 0.6f) : Color.white; GUI.Label(rect, $"{slot.Combatant.Name}\n{slot.DisplayHealth:0}/{max:0}", style); GUI.color = Color.white; // Health bar under the nameplate. var barBack = new Rect(screen.x - 26f, Screen.height - screen.y - 16f, 52f, 5f); GUI.color = new Color(0f, 0f, 0f, 0.6f); GUI.DrawTexture(barBack, Texture2D.whiteTexture); GUI.color = slot.Combatant.IsAgent ? new Color(0.4f, 0.9f, 0.5f) : new Color(0.9f, 0.4f, 0.4f); GUI.DrawTexture(new Rect(barBack.x, barBack.y, barBack.width * fraction, barBack.height), Texture2D.whiteTexture); GUI.color = Color.white; } } private void DrawFloaters() { const float lifetime = 1.1f; var style = new GUIStyle(GUI.skin.label) { alignment = TextAnchor.MiddleCenter, fontStyle = FontStyle.Bold }; for (int i = _floaters.Count - 1; i >= 0; i--) { Floater floater = _floaters[i]; float age = Time.time - floater.BornAt; if (age > lifetime) { _floaters.RemoveAt(i); continue; } Vector3 screen = _camera.WorldToScreenPoint(floater.WorldPosition); if (screen.z < 0f) continue; GUI.color = new Color(floater.Color.r, floater.Color.g, floater.Color.b, 1f - age / lifetime); GUI.Label(new Rect(screen.x - 40f, Screen.height - screen.y - 30f - age * 25f, 80f, 20f), floater.Text, style); GUI.color = Color.white; } } private void DrawPanel() { const float width = 380f; GUILayout.BeginArea(new Rect(Screen.width - width - 10f, 10f, width, Screen.height - 20f), GUI.skin.box); GUILayout.Label($"FIGHT #{_fight.Id} - room {_fight.Room.Id}"); GUILayout.Label(_playbackFinished ? _result.Summary : $"Round {_round} / max - {_fight.Agents.Count} agent(s) vs {_result.Combatants.Count - _fight.Agents.Count} monster(s)"); GUILayout.BeginHorizontal(); GUILayout.Label("Speed", GUILayout.Width(45f)); playbackSpeed = GUILayout.HorizontalSlider(playbackSpeed, 0.25f, 8f); GUILayout.Label($"{playbackSpeed:0.0}x", GUILayout.Width(40f)); GUILayout.EndHorizontal(); if (!_playbackFinished) { if (GUILayout.Button("Skip to end")) _skipRequested = true; } else if (GUILayout.Button("Return to the maze")) { _returnRequested = true; } GUILayout.Space(6f); GUILayout.Label("Blow by blow:"); _feedScroll = GUILayout.BeginScrollView(_feedScroll); foreach (var line in _feed) GUILayout.Label(line); GUILayout.EndScrollView(); GUILayout.EndArea(); } } }