CombatDirector.cs 22 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518
  1. using System;
  2. using System.Collections;
  3. using System.Collections.Generic;
  4. using UnityEngine;
  5. namespace MazeGen
  6. {
  7. /// <summary>
  8. /// Owns the whole fight lifecycle: declaration, the join window, sneak attempts, resolution,
  9. /// and pushing the results back into the maze.
  10. ///
  11. /// Lifecycle of a fight:
  12. /// 1. An agent decides (or is forced) to fight -> <see cref="DeclareFight"/>. Every agent
  13. /// already standing in that room is dragged in too; they were there when it kicked off.
  14. /// 2. A join window (default 5s, <see cref="JoinWindowSeconds"/>) counts down on
  15. /// SimulationClock time. During it, agents walking into the room can join (better odds)
  16. /// or attempt to sneak past - a failed sneak forces them in. Agents elsewhere in the maze
  17. /// cannot join.
  18. /// 3. When the window expires the fight resolves via <see cref="CombatResolver"/>, either
  19. /// log-only or replayed on the arena grid.
  20. /// 4. Results are applied to the real character sheets, so the room afterwards differs only
  21. /// by who is dead and how hurt the survivors are.
  22. ///
  23. /// Committed agents are marked <see cref="MazeAgent.IsEngaged"/>, which parks their brain -
  24. /// they can't wander off mid-muster.
  25. /// </summary>
  26. [DisallowMultipleComponent]
  27. public class CombatDirector : MonoBehaviour
  28. {
  29. /// <summary>Outcome of an agent arriving in a room where a fight is already mustering.</summary>
  30. public enum ArrivalDecision
  31. {
  32. NoFight,
  33. Joined,
  34. SneakedPast,
  35. SneakFailedAndJoined
  36. }
  37. private const string ArenaHoldKey = "watching a fight";
  38. private const int MaxLogLines = 600;
  39. private const int MaxRecentFights = 12;
  40. [Header("Join window")]
  41. [Tooltip("Seconds between a fight being declared and it actually starting. Other agents can arrive and pile in (or try to sneak past) during this window.")]
  42. [SerializeField, Min(0f)] private float joinWindowSeconds = 5f;
  43. [Header("Observation")]
  44. [Tooltip("Freeze the whole simulation the moment a fight is declared, so you can zoom in and decide whether to watch it. The join window is frozen too - resume to let agents converge.")]
  45. [SerializeField] private bool pauseOnFightDeclared = true;
  46. [Tooltip("How a fight resolves when its join window runs out on its own: Simulate = log only, Watch = play it out on the arena grid.")]
  47. [SerializeField] private CombatViewMode timeoutResolution = CombatViewMode.Simulate;
  48. [Header("Resolution")]
  49. [Tooltip("Rounds after which an unresolved fight is called a stalemate and both sides disengage.")]
  50. [SerializeField, Min(1)] private int maxRounds = 40;
  51. [Header("Sneaking")]
  52. [Tooltip("Baseline chance an agent slips past a fight/monsters without being pulled in.")]
  53. [SerializeField, Range(0f, 1f)] private float sneakBaseChance = 0.55f;
  54. [Tooltip("How much each point of speed advantage over the monsters' average adds to the sneak chance.")]
  55. [SerializeField] private float sneakSpeedWeight = 0.02f;
  56. [Tooltip("Sneak chance penalty per hostile in the room beyond the first - more eyes, harder to slip by.")]
  57. [SerializeField] private float sneakPenaltyPerMonster = 0.08f;
  58. private readonly List<PendingFight> _active = new List<PendingFight>();
  59. private readonly List<PendingFight> _recent = new List<PendingFight>();
  60. private readonly List<MazeAgentController> _agents = new List<MazeAgentController>();
  61. private readonly Dictionary<MazeAgent, Color> _agentColors = new Dictionary<MazeAgent, Color>();
  62. private readonly List<string> _log = new List<string>();
  63. private BattleArenaView _arena;
  64. private int _nextFightId = 1;
  65. public float JoinWindowSeconds => joinWindowSeconds;
  66. public IReadOnlyList<PendingFight> ActiveFights => _active;
  67. public IReadOnlyList<PendingFight> RecentFights => _recent;
  68. public IReadOnlyList<string> CombatLog => _log;
  69. /// <summary>True while an arena playback is on screen; the maze HUD hides itself then.</summary>
  70. public bool IsWatching { get; private set; }
  71. public bool PauseOnFightDeclared
  72. {
  73. get => pauseOnFightDeclared;
  74. set => pauseOnFightDeclared = value;
  75. }
  76. public CombatViewMode TimeoutResolution
  77. {
  78. get => timeoutResolution;
  79. set => timeoutResolution = value;
  80. }
  81. public event Action<PendingFight> FightDeclared;
  82. public event Action<PendingFight> FightResolved;
  83. private void Awake()
  84. {
  85. EnsureArena();
  86. }
  87. /// <summary>
  88. /// Resolved lazily (and added if missing) because this component may itself have been
  89. /// added at runtime by MazeSceneBuilder, after its own Awake would have run.
  90. /// </summary>
  91. private BattleArenaView EnsureArena()
  92. {
  93. if (_arena == null)
  94. _arena = GetComponent<BattleArenaView>() ?? gameObject.AddComponent<BattleArenaView>();
  95. return _arena;
  96. }
  97. /// <summary>Agents must register so the director can find who else is standing in a room.</summary>
  98. public void RegisterAgent(MazeAgentController controller, Color color)
  99. {
  100. if (controller == null || _agents.Contains(controller)) return;
  101. _agents.Add(controller);
  102. if (controller.Agent != null)
  103. _agentColors[controller.Agent] = color;
  104. }
  105. private void Update()
  106. {
  107. if (IsWatching) return;
  108. float dt = SimulationClock.DeltaTime;
  109. // Backwards: resolving/cancelling removes from this list.
  110. for (int i = _active.Count - 1; i >= 0; i--)
  111. {
  112. PendingFight fight = _active[i];
  113. if (fight.State != FightState.Mustering) continue;
  114. DropUnavailableParticipants(fight);
  115. if (fight.Agents.Count == 0)
  116. {
  117. Cancel(fight, "every committed agent died or left before it started");
  118. continue;
  119. }
  120. if (!fight.Encounter.HasLivingMonsters)
  121. {
  122. Cancel(fight, "the monsters were already dead");
  123. continue;
  124. }
  125. if (dt <= 0f) continue; // paused - the join window is frozen too
  126. fight.JoinTimeRemaining -= dt;
  127. if (fight.JoinTimeRemaining <= 0f)
  128. BeginResolution(fight, fight.PreferredResolution ?? timeoutResolution);
  129. }
  130. }
  131. // ---------------------------------------------------------------- declaring & joining
  132. /// <summary>
  133. /// An agent commits to fighting whatever is in <paramref name="room"/>. If a fight is
  134. /// already mustering there this just joins it instead of starting a second one.
  135. /// Returns null if there's nothing to fight.
  136. /// </summary>
  137. public PendingFight DeclareFight(Room room, MazeAgent declarer, string trigger)
  138. {
  139. if (room?.Encounter == null || !room.Encounter.HasLivingMonsters) return null;
  140. if (declarer == null || declarer.IsDead) return null;
  141. PendingFight existing = FindMusteringFight(room);
  142. if (existing != null)
  143. {
  144. if (!existing.Contains(declarer))
  145. Commit(existing, declarer, trigger);
  146. return existing;
  147. }
  148. var fight = new PendingFight(_nextFightId++, room, room.Encounter)
  149. {
  150. JoinTimeRemaining = joinWindowSeconds,
  151. Trigger = trigger
  152. };
  153. _active.Add(fight);
  154. Commit(fight, declarer, trigger);
  155. // Anyone else already in this room is in it whether they like it or not.
  156. foreach (var controller in _agents)
  157. {
  158. MazeAgent other = controller.Agent;
  159. if (other == null || other == declarer) continue;
  160. if (!IsAvailable(other) || other.CurrentRoom != room) continue;
  161. Commit(fight, other, "was already in the room when the fight broke out");
  162. }
  163. Log($"{fight.Describe()} - declared by {declarer.CharacterSheet.Name} ({trigger}). Starts in {joinWindowSeconds:0.0}s.", echoToConsole: false);
  164. if (pauseOnFightDeclared)
  165. SimulationClock.PushHold(HoldKeyFor(fight));
  166. FightDeclared?.Invoke(fight);
  167. return fight;
  168. }
  169. /// <summary>
  170. /// Called by an agent's brain when it finds itself in a room where a fight is mustering.
  171. /// <paramref name="wantsToSneak"/> is the agent's own call; a failed sneak drags it in.
  172. /// </summary>
  173. public ArrivalDecision ResolveArrival(MazeAgent agent, bool wantsToSneak)
  174. {
  175. if (agent == null || agent.CurrentRoom == null) return ArrivalDecision.NoFight;
  176. PendingFight fight = FindMusteringFight(agent.CurrentRoom);
  177. if (fight == null) return ArrivalDecision.NoFight;
  178. if (fight.Contains(agent)) return ArrivalDecision.Joined;
  179. if (!wantsToSneak)
  180. {
  181. Commit(fight, agent, "arrived and joined the fight");
  182. Log($"{agent.CharacterSheet.Name} joins fight #{fight.Id} - now {fight.Agents.Count} agent(s) vs {fight.Encounter.AliveMonsterCount} monster(s).", echoToConsole: false);
  183. return ArrivalDecision.Joined;
  184. }
  185. float odds = SneakChance(agent, fight.Encounter);
  186. if (UnityEngine.Random.value < odds)
  187. {
  188. agent.Thoughts.Add(ThoughtKind.Sneak, $"Slipped past the fight in room {fight.Room.Id} ({odds:P0} odds) - not my problem.");
  189. Log($"{agent.CharacterSheet.Name} sneaks past fight #{fight.Id} ({odds:P0} odds).", echoToConsole: false);
  190. return ArrivalDecision.SneakedPast;
  191. }
  192. Commit(fight, agent, $"was spotted sneaking past ({odds:P0} odds) and forced in");
  193. Log($"{agent.CharacterSheet.Name} is spotted sneaking past fight #{fight.Id} ({odds:P0} odds) and is forced into it.", echoToConsole: false);
  194. return ArrivalDecision.SneakFailedAndJoined;
  195. }
  196. /// <summary>
  197. /// Sneak attempt against monsters in a room where no fight has been declared yet (an
  198. /// agent that walked in, didn't like the odds, and wants to leave quietly). A failure
  199. /// leaves it to the caller to declare the fight it just got caught in.
  200. /// </summary>
  201. public bool TrySneakOut(MazeAgent agent, out float odds)
  202. {
  203. odds = 0f;
  204. MonsterEncounter encounter = agent?.CurrentRoom?.Encounter;
  205. if (encounter == null || !encounter.HasLivingMonsters) return true;
  206. odds = SneakChance(agent, encounter);
  207. return UnityEngine.Random.value < odds;
  208. }
  209. /// <summary>
  210. /// Faster than the monsters and few of them = good odds. Clamped so sneaking is never a
  211. /// certainty either way.
  212. /// </summary>
  213. public float SneakChance(MazeAgent agent, MonsterEncounter encounter)
  214. {
  215. if (encounter == null || !encounter.HasLivingMonsters) return 1f;
  216. float totalSpeed = 0f;
  217. int alive = 0;
  218. foreach (var monster in encounter.Monsters)
  219. {
  220. if (monster.IsDefeated) continue;
  221. totalSpeed += monster.CharacterSheet.GetEffectiveSpeed();
  222. alive++;
  223. }
  224. if (alive == 0) return 1f;
  225. float averageSpeed = totalSpeed / alive;
  226. float chance = sneakBaseChance
  227. + sneakSpeedWeight * (agent.CharacterSheet.GetEffectiveSpeed() - averageSpeed)
  228. - sneakPenaltyPerMonster * (alive - 1);
  229. return Mathf.Clamp(chance, 0.05f, 0.95f);
  230. }
  231. public PendingFight FindMusteringFight(Room room)
  232. {
  233. if (room == null) return null;
  234. foreach (var fight in _active)
  235. if (fight.State == FightState.Mustering && fight.Room.Id == room.Id)
  236. return fight;
  237. return null;
  238. }
  239. private void Commit(PendingFight fight, MazeAgent agent, string reason)
  240. {
  241. if (fight.Contains(agent)) return;
  242. fight.Agents.Add(agent);
  243. agent.IsEngaged = true;
  244. agent.Thoughts.Add(ThoughtKind.Fight,
  245. $"In fight #{fight.Id} (room {fight.Room.Id}) - {reason}. {fight.Agents.Count} of us vs {fight.Encounter.AliveMonsterCount} hostile(s), starting in {Mathf.Max(0f, fight.JoinTimeRemaining):0.0}s.");
  246. }
  247. /// <summary>Drops committed agents that died (elsewhere) or finished before the fight began.</summary>
  248. private void DropUnavailableParticipants(PendingFight fight)
  249. {
  250. for (int i = fight.Agents.Count - 1; i >= 0; i--)
  251. {
  252. MazeAgent agent = fight.Agents[i];
  253. if (agent == null || agent.IsDead || agent.IsFinished)
  254. {
  255. if (agent != null) agent.IsEngaged = false;
  256. fight.Agents.RemoveAt(i);
  257. }
  258. }
  259. }
  260. // ---------------------------------------------------------------- observer controls
  261. /// <summary>Let a paused fight's join window continue counting down.</summary>
  262. public void ResumeMuster(PendingFight fight)
  263. {
  264. if (fight == null) return;
  265. SimulationClock.PopHold(HoldKeyFor(fight));
  266. }
  267. public void ResumeAllMusters()
  268. {
  269. foreach (var fight in _active)
  270. SimulationClock.PopHold(HoldKeyFor(fight));
  271. }
  272. /// <summary>
  273. /// Sets the observer's preference for how this fight resolves once its join window runs
  274. /// out. This is a preference only, NOT a trigger - the join window always has to elapse
  275. /// on its own first (so other agents keep getting their full chance to pile in or sneak
  276. /// past), even if the simulation is currently paused waiting on the observer.
  277. /// </summary>
  278. public void PreferSimulate(PendingFight fight) => SetPreferredResolution(fight, CombatViewMode.Simulate);
  279. /// <summary>See <see cref="PreferSimulate"/> - same, but plays out on the arena grid.</summary>
  280. public void PreferWatch(PendingFight fight) => SetPreferredResolution(fight, CombatViewMode.Watch);
  281. private void SetPreferredResolution(PendingFight fight, CombatViewMode mode)
  282. {
  283. if (fight == null || fight.State != FightState.Mustering) return;
  284. if (fight.PreferredResolution == mode) return;
  285. fight.PreferredResolution = mode;
  286. Log($"Fight #{fight.Id} in room {fight.Room.Id} will be {(mode == CombatViewMode.Watch ? "watched" : "logged only")} when its join window ends.", echoToConsole: false);
  287. }
  288. // ---------------------------------------------------------------- resolution
  289. private void BeginResolution(PendingFight fight, CombatViewMode mode)
  290. {
  291. if (fight == null || fight.State != FightState.Mustering) return;
  292. SimulationClock.PopHold(HoldKeyFor(fight));
  293. DropUnavailableParticipants(fight);
  294. if (fight.Agents.Count == 0)
  295. {
  296. Cancel(fight, "no agents left to fight");
  297. return;
  298. }
  299. if (!fight.Encounter.HasLivingMonsters)
  300. {
  301. Cancel(fight, "no monsters left to fight");
  302. return;
  303. }
  304. fight.State = FightState.Resolving;
  305. fight.ResolvedAs = mode;
  306. List<Combatant> combatants = BuildCombatants(fight);
  307. fight.Result = CombatResolver.Resolve(combatants, maxRounds);
  308. BattleArenaView arena = mode == CombatViewMode.Watch ? EnsureArena() : null;
  309. if (arena != null)
  310. {
  311. IsWatching = true;
  312. SimulationClock.PushHold(ArenaHoldKey);
  313. StartCoroutine(WatchRoutine(arena, fight, combatants));
  314. }
  315. else
  316. {
  317. ApplyResult(fight, combatants);
  318. }
  319. }
  320. private IEnumerator WatchRoutine(BattleArenaView arena, PendingFight fight, List<Combatant> combatants)
  321. {
  322. // Playback is pure animation over an already-decided event list, so however long the
  323. // observer takes (or if they skip), the outcome below is unchanged.
  324. yield return arena.Play(fight, combatants);
  325. SimulationClock.PopHold(ArenaHoldKey);
  326. IsWatching = false;
  327. ApplyResult(fight, combatants);
  328. }
  329. /// <summary>
  330. /// Pushes the decided outcome back into maze state: dead agents die, survivors are
  331. /// released to keep exploring, everyone involved gets the blow-by-blow in their thought
  332. /// log. Monster/agent health was already mutated in place by the resolver.
  333. /// </summary>
  334. private void ApplyResult(PendingFight fight, List<Combatant> combatants)
  335. {
  336. BattleResult result = fight.Result;
  337. foreach (var e in result.Events)
  338. RecordEvent(result, e);
  339. foreach (var combatant in combatants)
  340. {
  341. if (!combatant.IsAgent) continue;
  342. MazeAgent agent = combatant.Agent;
  343. agent.IsEngaged = false;
  344. if (!combatant.IsAlive)
  345. {
  346. agent.Thoughts.Add(ThoughtKind.Goal, $"Killed in fight #{fight.Id}, room {fight.Room.Id}.");
  347. agent.Die();
  348. continue;
  349. }
  350. agent.Thoughts.Add(ThoughtKind.Outcome,
  351. $"Fight #{fight.Id} over: {result.Summary} I'm on {combatant.Sheet.Stats.CurrentHealth:0.#}/{combatant.MaxHealth:0.#} hp.");
  352. // A stalemate would otherwise have the agent immediately re-declare the same
  353. // fight forever, so remember not to pick this one again.
  354. if (result.Outcome == BattleOutcome.Stalemate)
  355. {
  356. agent.NoteFledFrom(fight.Room.Id);
  357. agent.Thoughts.Add(ThoughtKind.Avoid, $"Couldn't finish them off in room {fight.Room.Id} - disengaging and not trying that again.");
  358. }
  359. }
  360. fight.State = FightState.Resolved;
  361. _active.Remove(fight);
  362. Remember(fight);
  363. Log($"Fight #{fight.Id} ({(fight.ResolvedAs == CombatViewMode.Watch ? "watched" : "simulated")}) in room {fight.Room.Id}: {result.Summary}", echoToConsole: false);
  364. FightResolved?.Invoke(fight);
  365. }
  366. private void Cancel(PendingFight fight, string why)
  367. {
  368. SimulationClock.PopHold(HoldKeyFor(fight));
  369. foreach (var agent in fight.Agents)
  370. if (agent != null) agent.IsEngaged = false;
  371. fight.State = FightState.Cancelled;
  372. _active.Remove(fight);
  373. Remember(fight);
  374. Log($"Fight #{fight.Id} in room {fight.Room.Id} called off - {why}.", echoToConsole: false);
  375. FightResolved?.Invoke(fight);
  376. }
  377. private List<Combatant> BuildCombatants(PendingFight fight)
  378. {
  379. var combatants = new List<Combatant>();
  380. int id = 0;
  381. foreach (var agent in fight.Agents)
  382. {
  383. Color color = _agentColors.TryGetValue(agent, out var c) ? c : Color.white;
  384. combatants.Add(Combatant.ForAgent(id++, agent, color));
  385. }
  386. foreach (var monster in fight.Encounter.Monsters)
  387. {
  388. if (monster.IsDefeated) continue; // already dead from an earlier fight in this room
  389. combatants.Add(Combatant.ForMonster(id++, monster));
  390. }
  391. return combatants;
  392. }
  393. /// <summary>Mirrors one combat event into the global log and the thought logs of the agents it involved.</summary>
  394. private void RecordEvent(BattleResult result, CombatEvent e)
  395. {
  396. // Blow-by-blow lines stay in the in-memory log/HUD only. A long fight between big
  397. // groups emits hundreds of events, and echoing every one to Debug.Log would drown
  398. // the Console and cost real frame time.
  399. Log(e.Text, echoToConsole: false);
  400. if (e.Kind == CombatEventKind.RoundStart) return;
  401. AddCombatThought(result, e.ActorId, e.Text);
  402. if (e.TargetId != e.ActorId)
  403. AddCombatThought(result, e.TargetId, e.Text);
  404. }
  405. private void AddCombatThought(BattleResult result, int combatantId, string text)
  406. {
  407. if (combatantId < 0 || result.Combatants == null || combatantId >= result.Combatants.Count) return;
  408. Combatant combatant = result.Combatants[combatantId];
  409. if (combatant.IsAgent)
  410. combatant.Agent.Thoughts.Add(ThoughtKind.Combat, text);
  411. }
  412. private void Remember(PendingFight fight)
  413. {
  414. _recent.Insert(0, fight);
  415. if (_recent.Count > MaxRecentFights)
  416. _recent.RemoveAt(_recent.Count - 1);
  417. }
  418. private void Log(string line, bool echoToConsole = true)
  419. {
  420. _log.Add(line);
  421. if (_log.Count > MaxLogLines)
  422. _log.RemoveAt(0);
  423. if (echoToConsole)
  424. Debug.Log($"[Combat] {line}");
  425. }
  426. private static bool IsAvailable(MazeAgent agent)
  427. => agent != null && !agent.IsDead && !agent.IsFinished && !agent.IsEngaged;
  428. private static string HoldKeyFor(PendingFight fight) => $"fight #{fight.Id} in room {fight.Room.Id}";
  429. }
  430. }