| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631 |
- using System.Collections;
- using System.Collections.Generic;
- using UnityEngine;
- using UnityEngine.SceneManagement;
- namespace MazeGen
- {
- /// <summary>
- /// 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 <see cref="CombatResolver"/>), so this class is pure animation over a
- /// finished <see cref="BattleResult"/> - 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.
- /// </summary>
- [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;
- /// <summary>Per-combatant playback state. DisplayHealth is a copy - never the real sheet.</summary>
- private class Slot
- {
- public Combatant Combatant;
- public Transform Transform;
- public SpriteRenderer Sprite;
- public Vector3 Home;
- public Vector3 CurrentGridPos; // Current position on the grid
- public float DisplayHealth;
- public bool Felled;
- }
- private struct Floater
- {
- public Vector3 WorldPosition;
- public string Text;
- public Color Color;
- public float BornAt;
- }
- private readonly Dictionary<int, Slot> _slots = new Dictionary<int, Slot>();
- private readonly List<Floater> _floaters = new List<Floater>();
- private readonly List<string> _feed = new List<string>();
- private readonly List<Camera> _suspendedCameras = new List<Camera>();
- 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;
- /// <summary>Seconds per animation "beat" at the current playback speed.</summary>
- private float Beat => Mathf.Max(0.02f, secondsPerEvent) / Mathf.Max(0.25f, playbackSpeed);
- /// <summary>
- /// 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.
- /// </summary>
- public IEnumerator Play(PendingFight fight, IReadOnlyList<Combatant> 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<Combatant> 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<SpriteRenderer>();
- 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>();
- _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;
- }
- }
- /// <summary>
- /// Place combatants at their starting grid positions. Uses StartGridPosition, not the
- /// live GridPosition - by the time playback starts, CombatResolver has already resolved
- /// the whole fight synchronously, so GridPosition holds the *final* position, not where
- /// the combatant actually started.
- /// </summary>
- private void PlaceCombatants(IReadOnlyList<Combatant> combatants)
- {
- _slots.Clear();
- foreach (var combatant in combatants)
- {
- Vector3 worldPos = CellToWorld(combatant.StartGridPosition.x, combatant.StartGridPosition.y);
- _slots[combatant.Id] = CreateSlot(combatant, worldPos);
- }
- }
- 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;
- // Scale based on size: Large > Medium > Small
- float sizeScale = combatant.Size switch
- {
- MonsterSize.Small => 0.4f, // Smaller creatures
- MonsterSize.Medium => 0.58f, // Standard size (was the default for agents)
- MonsterSize.Large => 1.2f, // Large creatures take up more space
- _ => 0.58f
- };
- go.transform.localScale = Vector3.one * (cellSize * sizeScale);
- var sr = go.AddComponent<SpriteRenderer>();
- sr.sprite = RoomView.SharedSquareSprite;
- sr.color = combatant.Color;
- sr.sortingOrder = 2;
- return new Slot
- {
- Combatant = combatant,
- Transform = go.transform,
- Sprite = sr,
- Home = position,
- CurrentGridPos = position, // Initialize to the actual starting position from the grid
- 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.MovementStart:
- // Movement is handled in MovementComplete, this is just flavor text
- yield return Hold(0.2f);
- break;
- case CombatEventKind.MovementComplete:
- yield return Movement(e);
- break;
- case CombatEventKind.Hit:
- case CombatEventKind.Miss:
- yield return Strike(e);
- break;
- case CombatEventKind.Death:
- yield return Fell(e.TargetId);
- break;
- case CombatEventKind.OutOfRange:
- case CombatEventKind.TooFarForMelee:
- case CombatEventKind.TargetUnreachable:
- // Visual feedback for failure: brief shake or pulse
- yield return RangeFailure(e.ActorId);
- break;
- case CombatEventKind.RoundStart:
- _round = e.Round;
- yield return Hold(0.6f);
- break;
- case CombatEventKind.HealthPotionUsed:
- yield return HealPotion(e);
- break;
- default:
- yield return Hold(0.3f);
- 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;
- }
- // Use actual grid positions from the event
- Vector3 actorPos = actor.CurrentGridPos;
- Vector3 targetPos = target.CurrentGridPos;
- // Lunge toward target (halfway)
- Vector3 lunge = Vector3.Lerp(actorPos, targetPos, 0.4f);
- yield return Glide(actor, actorPos, lunge, 0.35f);
- 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);
- }
- // Return to grid position
- yield return Glide(actor, lunge, actorPos, 0.25f);
- }
- /// <summary>
- /// Animate a combatant moving across the grid to a new position.
- /// </summary>
- private IEnumerator Movement(CombatEvent e)
- {
- if (!_slots.TryGetValue(e.ActorId, out Slot actor))
- {
- yield return Hold(0.3f);
- yield break;
- }
- // Convert grid positions to world positions
- Vector3 fromWorld = CellToWorld(e.SourcePosition.x, e.SourcePosition.y);
- Vector3 toWorld = CellToWorld(e.TargetPosition.x, e.TargetPosition.y);
- // Update the slot's current grid position
- actor.CurrentGridPos = toWorld;
- // Animate movement
- yield return Glide(actor, fromWorld, toWorld, 0.6f);
- }
- /// <summary>
- /// Visual feedback when an attack fails due to range.
- /// Brief shake/pulse effect.
- /// </summary>
- private IEnumerator RangeFailure(int combatantId)
- {
- if (!_slots.TryGetValue(combatantId, out Slot actor))
- {
- yield return Hold(0.3f);
- yield break;
- }
- Vector3 startPos = actor.Transform.position;
- Color startColor = actor.Sprite.color;
- // Brief shake
- for (int i = 0; i < 3; i++)
- {
- actor.Transform.position = startPos + Vector3.right * (cellSize * 0.15f);
- yield return Hold(0.15f);
- actor.Transform.position = startPos - Vector3.right * (cellSize * 0.15f);
- yield return Hold(0.15f);
- }
- actor.Transform.position = startPos;
- actor.Sprite.color = startColor;
- yield return Hold(0.2f);
- }
- /// <summary>
- /// Animate a combatant using a health potion.
- /// </summary>
- private IEnumerator HealPotion(CombatEvent e)
- {
- if (!_slots.TryGetValue(e.ActorId, out Slot actor))
- {
- yield return Hold(0.4f);
- yield break;
- }
- // Update displayed health
- actor.DisplayHealth = Mathf.Min(actor.Combatant.Sheet.Stats.MaxHealth,
- actor.DisplayHealth + e.Amount);
- // Green flash for healing
- Color original = actor.Sprite.color;
- actor.Sprite.color = new Color(0.5f, 1f, 0.5f);
- // Green floating text
- AddFloater(actor, $"+{e.Amount:0.#}", new Color(0.5f, 1f, 0.5f));
- yield return Hold(0.35f);
- actor.Sprite.color = original;
- yield return Hold(0.25f);
- }
- 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;
- }
- }
- /// <summary>
- /// Snaps every combatant to the true final state of the decided battle, so a skipped or
- /// interrupted playback still ends on an accurate picture.
- /// </summary>
- private void ApplyFinalState()
- {
- foreach (var slot in _slots.Values)
- {
- slot.DisplayHealth = slot.Combatant.Sheet.Stats.CurrentHealth;
- // Use final grid position from the combatant
- slot.Transform.position = CellToWorld(slot.Combatant.GridPosition.x, slot.Combatant.GridPosition.y);
- 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.Transform.position + 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.Transform.position);
- if (screen.z < 0f) continue;
- float max = slot.Combatant.Sheet.Stats.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();
- }
- }
- }
|