BattleArenaView.cs 20 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525
  1. using System.Collections;
  2. using System.Collections.Generic;
  3. using UnityEngine;
  4. using UnityEngine.SceneManagement;
  5. namespace MazeGen
  6. {
  7. /// <summary>
  8. /// Plays a fight out on a grid in its own additively-loaded scene, then throws that scene
  9. /// away and hands the camera back to the maze.
  10. ///
  11. /// Why this can't corrupt the maze: the fight's outcome is already fully decided before
  12. /// playback starts (see <see cref="CombatResolver"/>), so this class is pure animation over a
  13. /// finished <see cref="BattleResult"/> - it never touches a character sheet, a room, or an
  14. /// agent. Health bars run off a private display copy of each combatant's health. And because
  15. /// everything it creates lives in a separate scene which is unloaded on exit, there's nothing
  16. /// left behind to restore. The maze's own GameObjects are never disabled or re-created (which
  17. /// would kill the spawn coroutines), only hidden by suspending the maze camera; when the fight
  18. /// ends every agent is exactly where it was, and only the fight's result has changed.
  19. ///
  20. /// Runs on real Unity time, because the maze simulation is frozen via SimulationClock (not
  21. /// Time.timeScale) precisely so that this playback can still animate.
  22. /// </summary>
  23. [DisallowMultipleComponent]
  24. public class BattleArenaView : MonoBehaviour
  25. {
  26. [Header("Arena")]
  27. [Tooltip("Grid dimensions (columns x rows) of the battle floor.")]
  28. [SerializeField] private Vector2Int gridSize = new Vector2Int(11, 7);
  29. [SerializeField] private float cellSize = 1f;
  30. [Tooltip("Where in world space the arena scene is built. Far from the maze so the arena camera can't see maze geometry.")]
  31. [SerializeField] private Vector2 arenaWorldOrigin = new Vector2(0f, 10000f);
  32. [Header("Playback")]
  33. [Tooltip("Base tempo: seconds per combat event at 1x speed.")]
  34. [SerializeField, Min(0.02f)] private float secondsPerEvent = 0.4f;
  35. [SerializeField, Range(0.25f, 8f)] private float playbackSpeed = 1f;
  36. [Tooltip("Seconds the final result stays on screen before auto-returning to the maze (0 = wait for the button).")]
  37. [SerializeField, Min(0f)] private float autoReturnSeconds = 4f;
  38. /// <summary>Per-combatant playback state. DisplayHealth is a copy - never the real sheet.</summary>
  39. private class Slot
  40. {
  41. public Combatant Combatant;
  42. public Transform Transform;
  43. public SpriteRenderer Sprite;
  44. public Vector3 Home;
  45. public float DisplayHealth;
  46. public bool Felled;
  47. }
  48. private struct Floater
  49. {
  50. public Vector3 WorldPosition;
  51. public string Text;
  52. public Color Color;
  53. public float BornAt;
  54. }
  55. private readonly Dictionary<int, Slot> _slots = new Dictionary<int, Slot>();
  56. private readonly List<Floater> _floaters = new List<Floater>();
  57. private readonly List<string> _feed = new List<string>();
  58. private readonly List<Camera> _suspendedCameras = new List<Camera>();
  59. private Scene _scene;
  60. private bool _sceneLoaded;
  61. private GameObject _root;
  62. private Camera _camera;
  63. private PendingFight _fight;
  64. private BattleResult _result;
  65. private int _round;
  66. private bool _playbackFinished;
  67. private bool _skipRequested;
  68. private bool _returnRequested;
  69. private Vector2 _feedScroll;
  70. public bool IsBuilt => _sceneLoaded;
  71. /// <summary>Seconds per animation "beat" at the current playback speed.</summary>
  72. private float Beat => Mathf.Max(0.02f, secondsPerEvent) / Mathf.Max(0.25f, playbackSpeed);
  73. /// <summary>
  74. /// Builds the arena, replays the fight, waits for the observer, and tears everything back
  75. /// down. Yield on this from a coroutine; when it completes the maze is on screen again.
  76. /// </summary>
  77. public IEnumerator Play(PendingFight fight, IReadOnlyList<Combatant> combatants)
  78. {
  79. _fight = fight;
  80. _result = fight.Result;
  81. _skipRequested = false;
  82. _returnRequested = false;
  83. _playbackFinished = false;
  84. _round = 0;
  85. _feed.Clear();
  86. _floaters.Clear();
  87. Build(combatants);
  88. yield return Replay();
  89. ApplyFinalState();
  90. _playbackFinished = true;
  91. yield return WaitForObserver();
  92. Teardown();
  93. }
  94. // ---------------------------------------------------------------- build / teardown
  95. private void Build(IReadOnlyList<Combatant> combatants)
  96. {
  97. // A per-fight scene name: CreateScene refuses to make a second scene with a name
  98. // that's already loaded, and a previous arena may still be unloading.
  99. _scene = SceneManager.CreateScene($"CombatArena_{_fight.Id}_{Time.frameCount}");
  100. _sceneLoaded = true;
  101. _root = new GameObject("ArenaRoot");
  102. SceneManager.MoveGameObjectToScene(_root, _scene);
  103. _root.transform.position = arenaWorldOrigin;
  104. BuildFloor();
  105. BuildCamera();
  106. PlaceCombatants(combatants);
  107. }
  108. private void BuildFloor()
  109. {
  110. var floor = new GameObject("Floor");
  111. floor.transform.SetParent(_root.transform, false);
  112. for (int x = 0; x < gridSize.x; x++)
  113. {
  114. for (int y = 0; y < gridSize.y; y++)
  115. {
  116. var tile = new GameObject($"Cell_{x}_{y}");
  117. tile.transform.SetParent(floor.transform, false);
  118. tile.transform.position = CellToWorld(x, y);
  119. tile.transform.localScale = Vector3.one * (cellSize * 0.96f);
  120. var sr = tile.AddComponent<SpriteRenderer>();
  121. sr.sprite = RoomView.SharedSquareSprite;
  122. sr.color = (x + y) % 2 == 0 ? new Color(0.16f, 0.17f, 0.2f) : new Color(0.21f, 0.22f, 0.26f);
  123. sr.sortingOrder = 0;
  124. }
  125. }
  126. }
  127. private void BuildCamera()
  128. {
  129. var cameraGO = new GameObject("ArenaCamera");
  130. cameraGO.transform.SetParent(_root.transform, false);
  131. cameraGO.transform.position = new Vector3(arenaWorldOrigin.x, arenaWorldOrigin.y, -10f);
  132. _camera = cameraGO.AddComponent<Camera>();
  133. _camera.orthographic = true;
  134. _camera.backgroundColor = new Color(0.05f, 0.05f, 0.07f);
  135. _camera.clearFlags = CameraClearFlags.SolidColor;
  136. _camera.depth = 100f; // draw over anything else that somehow stays enabled
  137. float aspect = Mathf.Max(_camera.aspect, 0.01f);
  138. float halfHeight = gridSize.y * cellSize * 0.5f;
  139. float halfWidth = gridSize.x * cellSize * 0.5f;
  140. _camera.orthographicSize = Mathf.Max(halfHeight, halfWidth / aspect) * 1.35f;
  141. // Hide the maze by suspending its cameras rather than deactivating maze objects -
  142. // SetActive(false) on the maze root would permanently kill its spawn coroutines.
  143. _suspendedCameras.Clear();
  144. foreach (var cam in Camera.allCameras)
  145. {
  146. if (cam == _camera) continue;
  147. _suspendedCameras.Add(cam);
  148. cam.enabled = false;
  149. }
  150. }
  151. /// <summary>Agents line up on the left of the grid, monsters on the right, facing off.</summary>
  152. private void PlaceCombatants(IReadOnlyList<Combatant> combatants)
  153. {
  154. _slots.Clear();
  155. var agents = new List<Combatant>();
  156. var monsters = new List<Combatant>();
  157. foreach (var c in combatants)
  158. (c.Team == Combatant.TeamAgents ? agents : monsters).Add(c);
  159. LayoutTeam(agents, true);
  160. LayoutTeam(monsters, false);
  161. }
  162. private void LayoutTeam(List<Combatant> team, bool leftSide)
  163. {
  164. int rows = Mathf.Max(1, gridSize.y);
  165. for (int i = 0; i < team.Count; i++)
  166. {
  167. int column = i / rows;
  168. int indexInColumn = i % rows;
  169. int countInColumn = Mathf.Min(rows, team.Count - column * rows);
  170. int x = leftSide ? Mathf.Min(column, gridSize.x - 1) : Mathf.Max(0, gridSize.x - 1 - column);
  171. int y = (rows - countInColumn) / 2 + indexInColumn;
  172. _slots[team[i].Id] = CreateSlot(team[i], CellToWorld(x, y));
  173. }
  174. }
  175. private Slot CreateSlot(Combatant combatant, Vector3 position)
  176. {
  177. var go = new GameObject($"{(combatant.IsAgent ? "Agent" : "Monster")}_{combatant.Name}");
  178. go.transform.SetParent(_root.transform, false);
  179. go.transform.position = position;
  180. go.transform.localScale = Vector3.one * (cellSize * (combatant.IsAgent ? 0.58f : 0.5f));
  181. var sr = go.AddComponent<SpriteRenderer>();
  182. sr.sprite = RoomView.SharedSquareSprite;
  183. sr.color = combatant.Color;
  184. sr.sortingOrder = 2;
  185. return new Slot
  186. {
  187. Combatant = combatant,
  188. Transform = go.transform,
  189. Sprite = sr,
  190. Home = position,
  191. DisplayHealth = combatant.StartHealth
  192. };
  193. }
  194. private void Teardown()
  195. {
  196. foreach (var cam in _suspendedCameras)
  197. if (cam != null) cam.enabled = true;
  198. _suspendedCameras.Clear();
  199. _slots.Clear();
  200. _floaters.Clear();
  201. _camera = null;
  202. _root = null;
  203. if (_sceneLoaded && _scene.IsValid() && _scene.isLoaded)
  204. SceneManager.UnloadSceneAsync(_scene);
  205. _sceneLoaded = false;
  206. _fight = null;
  207. _result = null;
  208. }
  209. // ---------------------------------------------------------------- playback
  210. private IEnumerator Replay()
  211. {
  212. foreach (var e in _result.Events)
  213. {
  214. if (_skipRequested || _returnRequested) break;
  215. Feed(e.Text);
  216. switch (e.Kind)
  217. {
  218. case CombatEventKind.Hit:
  219. case CombatEventKind.Miss:
  220. yield return Strike(e);
  221. break;
  222. case CombatEventKind.Death:
  223. yield return Fell(e.TargetId);
  224. break;
  225. case CombatEventKind.RoundStart:
  226. _round = e.Round;
  227. yield return Hold(0.6f);
  228. break;
  229. default:
  230. yield return Hold(1.2f);
  231. break;
  232. }
  233. }
  234. }
  235. private IEnumerator Strike(CombatEvent e)
  236. {
  237. if (!_slots.TryGetValue(e.ActorId, out Slot actor) || !_slots.TryGetValue(e.TargetId, out Slot target))
  238. {
  239. yield return Hold(0.5f);
  240. yield break;
  241. }
  242. Vector3 lunge = Vector3.Lerp(actor.Home, target.Home, 0.5f);
  243. yield return Glide(actor, actor.Home, lunge, 0.45f);
  244. if (e.Kind == CombatEventKind.Hit)
  245. {
  246. target.DisplayHealth = Mathf.Max(0f, target.DisplayHealth - e.Amount);
  247. AddFloater(target, $"-{e.Amount:0.#}", new Color(1f, 0.45f, 0.4f));
  248. yield return Flash(target, new Color(1f, 0.35f, 0.35f));
  249. }
  250. else
  251. {
  252. AddFloater(target, "miss", new Color(0.85f, 0.85f, 0.85f));
  253. yield return Flash(target, Color.white);
  254. }
  255. yield return Glide(actor, lunge, actor.Home, 0.3f);
  256. }
  257. private IEnumerator Fell(int combatantId)
  258. {
  259. if (!_slots.TryGetValue(combatantId, out Slot slot)) yield break;
  260. slot.Felled = true;
  261. slot.DisplayHealth = 0f;
  262. Vector3 startScale = slot.Transform.localScale;
  263. Color startColor = slot.Sprite.color;
  264. float duration = Beat * 0.9f;
  265. for (float t = 0f; t < duration; t += Time.deltaTime)
  266. {
  267. if (_returnRequested) break;
  268. float k = Mathf.Clamp01(t / duration);
  269. slot.Transform.localScale = Vector3.Lerp(startScale, startScale * 0.35f, k);
  270. slot.Sprite.color = new Color(startColor.r * 0.4f, startColor.g * 0.4f, startColor.b * 0.4f, 1f - 0.65f * k);
  271. slot.Transform.rotation = Quaternion.Euler(0f, 0f, 90f * k);
  272. yield return null;
  273. }
  274. slot.Transform.localScale = startScale * 0.35f;
  275. slot.Sprite.color = new Color(startColor.r * 0.4f, startColor.g * 0.4f, startColor.b * 0.4f, 0.35f);
  276. slot.Transform.rotation = Quaternion.Euler(0f, 0f, 90f);
  277. }
  278. private IEnumerator Glide(Slot slot, Vector3 from, Vector3 to, float beats)
  279. {
  280. float duration = Beat * beats;
  281. for (float t = 0f; t < duration; t += Time.deltaTime)
  282. {
  283. if (_skipRequested || _returnRequested) break;
  284. slot.Transform.position = Vector3.Lerp(from, to, Mathf.Clamp01(t / duration));
  285. yield return null;
  286. }
  287. slot.Transform.position = to;
  288. }
  289. private IEnumerator Flash(Slot slot, Color flashColor)
  290. {
  291. Color original = slot.Sprite.color;
  292. slot.Sprite.color = flashColor;
  293. yield return Hold(0.35f);
  294. if (!slot.Felled) slot.Sprite.color = original;
  295. }
  296. private IEnumerator Hold(float beats)
  297. {
  298. float duration = Beat * beats;
  299. for (float t = 0f; t < duration; t += Time.deltaTime)
  300. {
  301. if (_skipRequested || _returnRequested) yield break;
  302. yield return null;
  303. }
  304. }
  305. /// <summary>
  306. /// Snaps every combatant to the true final state of the decided battle, so a skipped or
  307. /// interrupted playback still ends on an accurate picture.
  308. /// </summary>
  309. private void ApplyFinalState()
  310. {
  311. foreach (var slot in _slots.Values)
  312. {
  313. slot.DisplayHealth = slot.Combatant.Sheet.Stats.CurrentHealth;
  314. slot.Transform.position = slot.Home;
  315. if (!slot.Combatant.IsAlive && !slot.Felled)
  316. {
  317. slot.Felled = true;
  318. Color c = slot.Sprite.color;
  319. slot.Sprite.color = new Color(c.r * 0.4f, c.g * 0.4f, c.b * 0.4f, 0.35f);
  320. slot.Transform.localScale *= 0.35f;
  321. slot.Transform.rotation = Quaternion.Euler(0f, 0f, 90f);
  322. }
  323. else if (!slot.Felled)
  324. {
  325. slot.Sprite.color = slot.Combatant.Color;
  326. }
  327. }
  328. if (_skipRequested)
  329. Feed("(skipped to the end)");
  330. Feed(_result.Summary);
  331. }
  332. private IEnumerator WaitForObserver()
  333. {
  334. float waited = 0f;
  335. while (!_returnRequested)
  336. {
  337. if (autoReturnSeconds > 0f && waited >= autoReturnSeconds) break;
  338. waited += Time.deltaTime;
  339. yield return null;
  340. }
  341. }
  342. // ---------------------------------------------------------------- helpers & HUD
  343. private Vector3 CellToWorld(int x, int y)
  344. {
  345. float ox = (x - (gridSize.x - 1) * 0.5f) * cellSize;
  346. float oy = (y - (gridSize.y - 1) * 0.5f) * cellSize;
  347. return new Vector3(arenaWorldOrigin.x + ox, arenaWorldOrigin.y + oy, 0f);
  348. }
  349. private void AddFloater(Slot slot, string text, Color color)
  350. {
  351. _floaters.Add(new Floater
  352. {
  353. WorldPosition = slot.Home + Vector3.up * (cellSize * 0.5f),
  354. Text = text,
  355. Color = color,
  356. BornAt = Time.time
  357. });
  358. }
  359. private void Feed(string line)
  360. {
  361. if (string.IsNullOrEmpty(line)) return;
  362. _feed.Add(line);
  363. if (_feed.Count > 200) _feed.RemoveAt(0);
  364. _feedScroll.y = float.MaxValue; // keep pinned to the newest line
  365. }
  366. private void OnGUI()
  367. {
  368. if (!_sceneLoaded || _camera == null || _fight == null) return;
  369. DrawNameplates();
  370. DrawFloaters();
  371. DrawPanel();
  372. }
  373. private void DrawNameplates()
  374. {
  375. var style = new GUIStyle(GUI.skin.label) { alignment = TextAnchor.MiddleCenter, fontSize = 11 };
  376. foreach (var slot in _slots.Values)
  377. {
  378. Vector3 screen = _camera.WorldToScreenPoint(slot.Home);
  379. if (screen.z < 0f) continue;
  380. float max = slot.Combatant.MaxHealth;
  381. float fraction = Mathf.Clamp01(max > 0f ? slot.DisplayHealth / max : 0f);
  382. var rect = new Rect(screen.x - 55f, Screen.height - screen.y - 44f, 110f, 30f);
  383. GUI.color = slot.Felled ? new Color(0.6f, 0.6f, 0.6f) : Color.white;
  384. GUI.Label(rect, $"{slot.Combatant.Name}\n{slot.DisplayHealth:0}/{max:0}", style);
  385. GUI.color = Color.white;
  386. // Health bar under the nameplate.
  387. var barBack = new Rect(screen.x - 26f, Screen.height - screen.y - 16f, 52f, 5f);
  388. GUI.color = new Color(0f, 0f, 0f, 0.6f);
  389. GUI.DrawTexture(barBack, Texture2D.whiteTexture);
  390. GUI.color = slot.Combatant.IsAgent ? new Color(0.4f, 0.9f, 0.5f) : new Color(0.9f, 0.4f, 0.4f);
  391. GUI.DrawTexture(new Rect(barBack.x, barBack.y, barBack.width * fraction, barBack.height), Texture2D.whiteTexture);
  392. GUI.color = Color.white;
  393. }
  394. }
  395. private void DrawFloaters()
  396. {
  397. const float lifetime = 1.1f;
  398. var style = new GUIStyle(GUI.skin.label) { alignment = TextAnchor.MiddleCenter, fontStyle = FontStyle.Bold };
  399. for (int i = _floaters.Count - 1; i >= 0; i--)
  400. {
  401. Floater floater = _floaters[i];
  402. float age = Time.time - floater.BornAt;
  403. if (age > lifetime)
  404. {
  405. _floaters.RemoveAt(i);
  406. continue;
  407. }
  408. Vector3 screen = _camera.WorldToScreenPoint(floater.WorldPosition);
  409. if (screen.z < 0f) continue;
  410. GUI.color = new Color(floater.Color.r, floater.Color.g, floater.Color.b, 1f - age / lifetime);
  411. GUI.Label(new Rect(screen.x - 40f, Screen.height - screen.y - 30f - age * 25f, 80f, 20f), floater.Text, style);
  412. GUI.color = Color.white;
  413. }
  414. }
  415. private void DrawPanel()
  416. {
  417. const float width = 380f;
  418. GUILayout.BeginArea(new Rect(Screen.width - width - 10f, 10f, width, Screen.height - 20f), GUI.skin.box);
  419. GUILayout.Label($"FIGHT #{_fight.Id} - room {_fight.Room.Id}");
  420. GUILayout.Label(_playbackFinished
  421. ? _result.Summary
  422. : $"Round {_round} / max - {_fight.Agents.Count} agent(s) vs {_result.Combatants.Count - _fight.Agents.Count} monster(s)");
  423. GUILayout.BeginHorizontal();
  424. GUILayout.Label("Speed", GUILayout.Width(45f));
  425. playbackSpeed = GUILayout.HorizontalSlider(playbackSpeed, 0.25f, 8f);
  426. GUILayout.Label($"{playbackSpeed:0.0}x", GUILayout.Width(40f));
  427. GUILayout.EndHorizontal();
  428. if (!_playbackFinished)
  429. {
  430. if (GUILayout.Button("Skip to end")) _skipRequested = true;
  431. }
  432. else if (GUILayout.Button("Return to the maze"))
  433. {
  434. _returnRequested = true;
  435. }
  436. GUILayout.Space(6f);
  437. GUILayout.Label("Blow by blow:");
  438. _feedScroll = GUILayout.BeginScrollView(_feedScroll);
  439. foreach (var line in _feed)
  440. GUILayout.Label(line);
  441. GUILayout.EndScrollView();
  442. GUILayout.EndArea();
  443. }
  444. }
  445. }