CombatResolver.cs 7.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179
  1. using System.Collections.Generic;
  2. using UnityEngine;
  3. namespace MazeGen
  4. {
  5. /// <summary>
  6. /// Resolves a battle between a group of agents and a group of monsters, all in one room.
  7. ///
  8. /// Runs to completion synchronously and returns the whole thing as an ordered
  9. /// <see cref="CombatEvent"/> list (see <see cref="BattleResult"/>). Nothing about this is
  10. /// frame-based or animated - "watching" a fight is purely a replay of the list this produces,
  11. /// which is what guarantees the watched and log-only outcomes are identical.
  12. ///
  13. /// Combatant health is mutated in place on the real character sheets, so damage and deaths
  14. /// persist into the maze afterwards.
  15. /// </summary>
  16. public static class CombatResolver
  17. {
  18. // Tuning. Deliberately simple and legible - swap in a richer model (abilities, damage
  19. // types, status effects) without touching the director or the arena, since both only
  20. // ever consume the event list.
  21. private const float BaseHitChance = 0.75f;
  22. private const float HitChancePerSpeedPoint = 0.01f;
  23. private const float MinHitChance = 0.35f;
  24. private const float MaxHitChance = 0.95f;
  25. private const float DamageVariance = 0.15f;
  26. private const float DefenseMitigationPerPoint = 0.1f;
  27. private const float MaxMitigation = 0.9f;
  28. /// <summary>
  29. /// Fights it out until one side is wiped or <paramref name="maxRounds"/> is exhausted
  30. /// (which yields a stalemate - both sides disengage rather than looping forever).
  31. /// </summary>
  32. public static BattleResult Resolve(IReadOnlyList<Combatant> combatants, int maxRounds = 40)
  33. {
  34. var result = new BattleResult { Combatants = combatants };
  35. int agentCount = CountAlive(combatants, Combatant.TeamAgents);
  36. int monsterCount = CountAlive(combatants, Combatant.TeamMonsters);
  37. Emit(result, CombatEventKind.FightStart, 0, -1, -1, 0f,
  38. $"Fight begins: {agentCount} agent(s) vs {monsterCount} monster(s).");
  39. // Initiative is rolled once for the whole battle (classic fixed turn order) so the
  40. // arena can lay combatants out in a stable order that matches the log.
  41. var order = BuildTurnOrder(combatants);
  42. int round = 0;
  43. while (round < maxRounds && HasAlive(combatants, Combatant.TeamAgents) && HasAlive(combatants, Combatant.TeamMonsters))
  44. {
  45. round++;
  46. Emit(result, CombatEventKind.RoundStart, round, -1, -1, 0f, $"-- Round {round} --");
  47. foreach (var actor in order)
  48. {
  49. if (!actor.IsAlive) continue;
  50. Combatant target = PickTarget(combatants, actor);
  51. if (target == null) break; // no enemies left
  52. TakeSwing(result, round, actor, target);
  53. if (!HasAlive(combatants, Combatant.TeamAgents) || !HasAlive(combatants, Combatant.TeamMonsters))
  54. break;
  55. }
  56. }
  57. result.Rounds = round;
  58. Tally(result, combatants);
  59. bool agentsLeft = HasAlive(combatants, Combatant.TeamAgents);
  60. bool monstersLeft = HasAlive(combatants, Combatant.TeamMonsters);
  61. result.Outcome = !monstersLeft && agentsLeft ? BattleOutcome.AgentsVictory
  62. : !agentsLeft && monstersLeft ? BattleOutcome.MonstersVictory
  63. : !agentsLeft && !monstersLeft ? BattleOutcome.MonstersVictory // mutual wipe counts as a loss for the agents
  64. : BattleOutcome.Stalemate;
  65. Emit(result, CombatEventKind.FightEnd, round, -1, -1, 0f, result.Summary);
  66. return result;
  67. }
  68. /// <summary>
  69. /// Single attack: roll to hit, then damage attacker's effective attack against the
  70. /// target's effective defense.
  71. /// </summary>
  72. private static void TakeSwing(BattleResult result, int round, Combatant actor, Combatant target)
  73. {
  74. float hitChance = Mathf.Clamp(
  75. BaseHitChance + HitChancePerSpeedPoint * (actor.Sheet.GetEffectiveSpeed() - target.Sheet.GetEffectiveSpeed()),
  76. MinHitChance, MaxHitChance);
  77. if (Random.value > hitChance)
  78. {
  79. Emit(result, CombatEventKind.Miss, round, actor.Id, target.Id, 0f,
  80. $"{actor.Name} swings at {target.Name} and misses ({hitChance:P0} to hit).");
  81. return;
  82. }
  83. // Note: this uses the target's EFFECTIVE defense (base + equipment/buffs), unlike
  84. // CharacterStats.CalculateDamageReduction which only ever sees base defense.
  85. float mitigation = Mathf.Clamp(target.Sheet.GetEffectiveDefense() * DefenseMitigationPerPoint, 0f, MaxMitigation);
  86. float roll = Random.Range(1f - DamageVariance, 1f + DamageVariance);
  87. float damage = Mathf.Max(1f, actor.Sheet.GetEffectiveAttack() * roll * (1f - mitigation));
  88. target.Sheet.Stats.TakeDamage(damage);
  89. Emit(result, CombatEventKind.Hit, round, actor.Id, target.Id, damage,
  90. $"{actor.Name} hits {target.Name} for {damage:0.#} ({target.Sheet.Stats.CurrentHealth:0.#}/{target.MaxHealth:0.#} hp left).");
  91. if (!target.IsAlive)
  92. {
  93. Emit(result, CombatEventKind.Death, round, actor.Id, target.Id, 0f,
  94. $"{target.Name} is killed by {actor.Name}.");
  95. }
  96. }
  97. /// <summary>Fastest first; ties broken randomly so identical monsters don't always act in spawn order.</summary>
  98. private static List<Combatant> BuildTurnOrder(IReadOnlyList<Combatant> combatants)
  99. {
  100. var order = new List<Combatant>(combatants);
  101. var initiative = new Dictionary<int, float>(order.Count);
  102. foreach (var c in order)
  103. initiative[c.Id] = c.Sheet.GetEffectiveSpeed() + Random.Range(0f, 4f);
  104. order.Sort((a, b) => initiative[b.Id].CompareTo(initiative[a.Id]));
  105. return order;
  106. }
  107. /// <summary>Picks a random living enemy. Random rather than focus-fire so groups spread damage.</summary>
  108. private static Combatant PickTarget(IReadOnlyList<Combatant> combatants, Combatant actor)
  109. {
  110. var candidates = new List<Combatant>();
  111. foreach (var c in combatants)
  112. if (c.Team != actor.Team && c.IsAlive)
  113. candidates.Add(c);
  114. return candidates.Count == 0 ? null : candidates[Random.Range(0, candidates.Count)];
  115. }
  116. private static void Tally(BattleResult result, IReadOnlyList<Combatant> combatants)
  117. {
  118. foreach (var c in combatants)
  119. {
  120. if (c.IsAgent)
  121. {
  122. if (c.IsAlive) result.SurvivingAgents.Add(c);
  123. else result.FallenAgents.Add(c);
  124. }
  125. else if (!c.IsAlive)
  126. {
  127. result.FallenMonsters.Add(c);
  128. }
  129. }
  130. }
  131. private static void Emit(BattleResult result, CombatEventKind kind, int round, int actorId, int targetId, float amount, string text)
  132. {
  133. result.Events.Add(new CombatEvent
  134. {
  135. Kind = kind,
  136. Round = round,
  137. ActorId = actorId,
  138. TargetId = targetId,
  139. Amount = amount,
  140. Text = text
  141. });
  142. }
  143. private static int CountAlive(IReadOnlyList<Combatant> combatants, int team)
  144. {
  145. int count = 0;
  146. foreach (var c in combatants)
  147. if (c.Team == team && c.IsAlive) count++;
  148. return count;
  149. }
  150. private static bool HasAlive(IReadOnlyList<Combatant> combatants, int team) => CountAlive(combatants, team) > 0;
  151. }
  152. }