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