CombatResolver.cs 20 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494
  1. using System.Collections.Generic;
  2. using System.Linq;
  3. using UnityEngine;
  4. namespace MazeGen
  5. {
  6. /// <summary>
  7. /// Resolves a battle between a group of agents and a group of monsters, all in one room.
  8. ///
  9. /// Now includes grid-based positioning and movement. Combatants must move into range
  10. /// before they can attack. Movement is determined by Speed stat (Speed/2 = squares per turn).
  11. ///
  12. /// Runs to completion synchronously and returns the whole thing as an ordered
  13. /// <see cref="CombatEvent"/> list (see <see cref="BattleResult"/>). Nothing about this is
  14. /// frame-based or animated - "watching" a fight is purely a replay of the list this produces,
  15. /// which is what guarantees the watched and log-only outcomes are identical.
  16. ///
  17. /// Combatant health is mutated in place on the real character sheets, so damage and deaths
  18. /// persist into the maze afterwards.
  19. /// </summary>
  20. public static class CombatResolver
  21. {
  22. // Tuning. Deliberately simple and legible - swap in a richer model (abilities, damage
  23. // types, status effects) without touching the director or the arena, since both only
  24. // ever consume the event list.
  25. private const float BaseHitChance = 0.75f;
  26. private const float HitChancePerSpeedPoint = 0.01f;
  27. private const float MinHitChance = 0.35f;
  28. private const float MaxHitChance = 0.95f;
  29. private const float DamageVariance = 0.15f;
  30. private const float DefenseMitigationPerPoint = 0.1f;
  31. private const float MaxMitigation = 0.9f;
  32. private const int CombatGridWidth = 11;
  33. private const int CombatGridHeight = 7;
  34. /// <summary>
  35. /// Fights it out until one side is wiped or <paramref name="maxRounds"/> is exhausted
  36. /// (which yields a stalemate - both sides disengage rather than looping forever).
  37. /// </summary>
  38. public static BattleResult Resolve(IReadOnlyList<Combatant> combatants, int maxRounds = 40)
  39. {
  40. var result = new BattleResult { Combatants = combatants };
  41. int agentCount = CountAlive(combatants, Combatant.TeamAgents);
  42. int monsterCount = CountAlive(combatants, Combatant.TeamMonsters);
  43. Emit(result, CombatEventKind.FightStart, 0, -1, -1, 0f,
  44. $"Fight begins: {agentCount} agent(s) vs {monsterCount} monster(s).");
  45. // Initialize combat grid and place combatants
  46. CombatGrid grid = InitializeGrid(combatants);
  47. // Initiative is rolled once for the whole battle (classic fixed turn order) so the
  48. // arena can lay combatants out in a stable order that matches the log.
  49. var order = BuildTurnOrder(combatants);
  50. int round = 0;
  51. while (round < maxRounds && HasAlive(combatants, Combatant.TeamAgents) && HasAlive(combatants, Combatant.TeamMonsters))
  52. {
  53. round++;
  54. Emit(result, CombatEventKind.RoundStart, round, -1, -1, 0f, $"-- Round {round} --");
  55. // Select actions for all combatants this round
  56. var actions = SelectActions(order, combatants, grid);
  57. // Execute actions in order (movements first, then attacks)
  58. ExecuteActions(actions, order, grid, result, round, combatants);
  59. if (!HasAlive(combatants, Combatant.TeamAgents) || !HasAlive(combatants, Combatant.TeamMonsters))
  60. break;
  61. }
  62. result.Rounds = round;
  63. Tally(result, combatants);
  64. bool agentsLeft = HasAlive(combatants, Combatant.TeamAgents);
  65. bool monstersLeft = HasAlive(combatants, Combatant.TeamMonsters);
  66. result.Outcome = !monstersLeft && agentsLeft ? BattleOutcome.AgentsVictory
  67. : !agentsLeft && monstersLeft ? BattleOutcome.MonstersVictory
  68. : !agentsLeft && !monstersLeft ? BattleOutcome.MonstersVictory // mutual wipe counts as a loss for the agents
  69. : BattleOutcome.Stalemate;
  70. if (result.Outcome == BattleOutcome.AgentsVictory)
  71. {
  72. LootMonsters(combatants);
  73. }
  74. Emit(result, CombatEventKind.FightEnd, round, -1, -1, 0f, result.Summary);
  75. return result;
  76. }
  77. /// <summary>
  78. /// Initialize the combat grid and place all combatants on it.
  79. /// Monsters start fresh (full health each room), but agents retain damage between fights.
  80. /// Resets grid positions to ensure clean placement each fight.
  81. /// </summary>
  82. private static CombatGrid InitializeGrid(IReadOnlyList<Combatant> combatants)
  83. {
  84. // Reset grid positions for all combatants (they persist between fights)
  85. foreach (var combatant in combatants)
  86. {
  87. combatant.GridPosition = Vector2Int.zero;
  88. }
  89. // Monsters always start at full health (they're spawned per-room)
  90. // Agents keep their damage from previous fights (only healed by potions)
  91. foreach (var combatant in combatants)
  92. {
  93. if (!combatant.IsAgent) // Only monsters start fresh
  94. {
  95. combatant.Sheet.Stats.RestoreHealth();
  96. }
  97. }
  98. var grid = new CombatGrid(new Vector2Int(CombatGridWidth, CombatGridHeight), 1f);
  99. grid.PlaceAllCombatants(combatants);
  100. // Snapshot the starting position for playback - GridPosition keeps changing as the
  101. // fight resolves, so this is what BattleArenaView uses to place sprites correctly
  102. // from the first frame instead of wherever the fight ended up.
  103. foreach (var combatant in combatants)
  104. {
  105. combatant.StartGridPosition = combatant.GridPosition;
  106. }
  107. return grid;
  108. }
  109. /// <summary>
  110. /// Select actions for all alive combatants this turn.
  111. /// Agents try health potions first if low on health, then decide between movement and attack.
  112. /// Monsters always attack or move closer.
  113. /// </summary>
  114. private static List<CombatAction> SelectActions(
  115. List<Combatant> order,
  116. IReadOnlyList<Combatant> allCombatants,
  117. CombatGrid grid)
  118. {
  119. var actions = new List<CombatAction>();
  120. foreach (var actor in order)
  121. {
  122. if (!actor.IsAlive) continue;
  123. CombatAction action = null;
  124. // Agent health potion logic
  125. if (actor.IsAgent && actor.Agent.CharacterSheet.Stats.CurrentHealth <
  126. actor.Agent.CharacterSheet.Stats.MaxHealth * 0.2f)
  127. {
  128. Consumable healthPotion = actor.Agent.CharacterSheet.GetHealthPotionFromInventory();
  129. if (healthPotion != null)
  130. {
  131. action = new CombatAction(actor.Id, ActionType.UseItem);
  132. action.TargetItem = healthPotion;
  133. actions.Add(action);
  134. continue;
  135. }
  136. }
  137. // Pick a target
  138. Combatant target = PickTarget(allCombatants, actor);
  139. if (target == null)
  140. {
  141. action = new CombatAction(actor.Id, ActionType.Pass);
  142. actions.Add(action);
  143. continue;
  144. }
  145. // Get equipped weapon (or default to unarmed melee range 1)
  146. Weapon weapon = actor.Sheet.EquippedWeapon;
  147. int attackRange = weapon != null ? weapon.Range : 1; // Fists = range 1, adjacent only
  148. // Check if in attack range
  149. bool inRange = false;
  150. if (weapon != null)
  151. {
  152. inRange = MovementValidator.CanAttack(actor, target, weapon);
  153. }
  154. else
  155. {
  156. // Unarmed: must be adjacent (distance = 1)
  157. inRange = actor.IsAdjacentTo(target);
  158. }
  159. if (inRange)
  160. {
  161. action = new CombatAction(actor.Id, target);
  162. actions.Add(action);
  163. }
  164. else
  165. {
  166. // Out of range, try to move closer
  167. Vector2Int nextPos = MovementValidator.GetNextPositionTowards(
  168. actor.GridPosition, target.GridPosition);
  169. if (MovementValidator.CanMoveTo(actor, nextPos, grid))
  170. {
  171. action = new CombatAction(actor.Id, nextPos);
  172. actions.Add(action);
  173. }
  174. else
  175. {
  176. // Can't move, just pass
  177. action = new CombatAction(actor.Id, ActionType.Pass);
  178. actions.Add(action);
  179. }
  180. }
  181. }
  182. return actions;
  183. }
  184. /// <summary>
  185. /// Execute all actions for this round in order.
  186. /// </summary>
  187. private static void ExecuteActions(
  188. List<CombatAction> actions,
  189. List<Combatant> turnOrder,
  190. CombatGrid grid,
  191. BattleResult result,
  192. int round,
  193. IReadOnlyList<Combatant> allCombatants)
  194. {
  195. foreach (var action in actions)
  196. {
  197. // Find the actor
  198. Combatant actor = null;
  199. foreach (var c in turnOrder)
  200. {
  201. if (c.Id == action.ActorId)
  202. {
  203. actor = c;
  204. break;
  205. }
  206. }
  207. if (actor == null || !actor.IsAlive) continue;
  208. switch (action.ActionType)
  209. {
  210. case ActionType.Move:
  211. ExecuteMovement(actor, action.TargetPosition, grid, result, round);
  212. break;
  213. case ActionType.Attack:
  214. ExecuteAttack(actor, action.TargetCombatant, grid, result, round);
  215. break;
  216. case ActionType.UseItem:
  217. ExecuteUseItem(actor, action.TargetItem, result, round);
  218. break;
  219. case ActionType.Defend:
  220. // TODO: Implement defend action (damage reduction this turn)
  221. break;
  222. case ActionType.Pass:
  223. default:
  224. // Do nothing
  225. break;
  226. }
  227. // Check if fight is over after each action
  228. if (!HasAlive(allCombatants, Combatant.TeamAgents) ||
  229. !HasAlive(allCombatants, Combatant.TeamMonsters))
  230. {
  231. break;
  232. }
  233. }
  234. }
  235. /// <summary>
  236. /// Execute a movement action for an actor.
  237. /// </summary>
  238. private static void ExecuteMovement(
  239. Combatant actor,
  240. Vector2Int destination,
  241. CombatGrid grid,
  242. BattleResult result,
  243. int round)
  244. {
  245. Vector2Int startPos = actor.GridPosition;
  246. if (!grid.TryMoveCombatant(actor, destination))
  247. {
  248. Emit(result, CombatEventKind.TargetUnreachable, round, actor.Id, -1, 0f,
  249. $"{actor.Name} could not reach {destination}.");
  250. return;
  251. }
  252. int distance = MovementValidator.ManhattanDistance(startPos, destination);
  253. Emit(result, CombatEventKind.MovementStart, round, actor.Id, -1, 0f,
  254. $"{actor.Name} moves from {startPos} to {destination}.");
  255. Emit(result, CombatEventKind.MovementComplete, round, actor.Id, -1, distance,
  256. $"{actor.Name} has moved {distance} square(s).");
  257. var moveEvent = result.Events[result.Events.Count - 1];
  258. moveEvent.SourcePosition = startPos;
  259. moveEvent.TargetPosition = destination;
  260. moveEvent.MovementDistance = distance;
  261. }
  262. /// <summary>
  263. /// Execute an attack action, with range validation.
  264. /// Supports both armed and unarmed (fist) attacks.
  265. /// </summary>
  266. private static void ExecuteAttack(
  267. Combatant actor,
  268. Combatant target,
  269. CombatGrid grid,
  270. BattleResult result,
  271. int round)
  272. {
  273. if (target == null || !target.IsAlive)
  274. return;
  275. Weapon weapon = actor.Sheet.EquippedWeapon;
  276. bool isUnarmed = weapon == null;
  277. // Validate range
  278. bool canAttack = false;
  279. if (isUnarmed)
  280. {
  281. // Unarmed: must be adjacent
  282. canAttack = actor.IsAdjacentTo(target);
  283. if (!canAttack)
  284. {
  285. Emit(result, CombatEventKind.TooFarForMelee, round, actor.Id, target.Id, 0f,
  286. $"{actor.Name} is not adjacent to {target.Name} and cannot use fists.");
  287. return;
  288. }
  289. }
  290. else
  291. {
  292. // Armed: use weapon's range
  293. canAttack = MovementValidator.CanAttack(actor, target, weapon);
  294. if (!canAttack)
  295. {
  296. if (weapon.WeaponType == WeaponType.Melee)
  297. {
  298. Emit(result, CombatEventKind.TooFarForMelee, round, actor.Id, target.Id, 0f,
  299. $"{actor.Name} is not adjacent to {target.Name} and cannot use {weapon.ItemName}.");
  300. }
  301. else
  302. {
  303. Emit(result, CombatEventKind.OutOfRange, round, actor.Id, target.Id, 0f,
  304. $"{actor.Name}'s {weapon.ItemName} cannot reach {target.Name} (range: {weapon.Range}, distance: {actor.DistanceTo(target)}).");
  305. }
  306. return;
  307. }
  308. }
  309. // Attack is valid, execute it
  310. TakeSwing(result, round, actor, target);
  311. }
  312. /// <summary>
  313. /// Execute using a consumable item.
  314. /// </summary>
  315. private static void ExecuteUseItem(
  316. Combatant actor,
  317. Consumable item,
  318. BattleResult result,
  319. int round)
  320. {
  321. if (item == null) return;
  322. if (item is Consumable consumable)
  323. {
  324. actor.Sheet.Stats.Heal(consumable.HealthBonus);
  325. Emit(result, CombatEventKind.HealthPotionUsed, round, actor.Id, -1,
  326. consumable.HealthBonus,
  327. $"{actor.Name} used {item.ItemName} to heal {consumable.HealthBonus} health.");
  328. }
  329. }
  330. /// <summary>
  331. /// Single attack: roll to hit, then damage attacker's effective attack against the
  332. /// target's effective defense.
  333. /// </summary>
  334. private static void TakeSwing(BattleResult result, int round, Combatant actor, Combatant target)
  335. {
  336. float hitChance = Mathf.Clamp(
  337. BaseHitChance + HitChancePerSpeedPoint * (actor.Sheet.GetEffectiveSpeed() - target.Sheet.GetEffectiveSpeed()),
  338. MinHitChance, MaxHitChance);
  339. if (Random.value > hitChance)
  340. {
  341. Emit(result, CombatEventKind.Miss, round, actor.Id, target.Id, 0f,
  342. $"{actor.Name} swings at {target.Name} and misses ({hitChance:P0} to hit).");
  343. return;
  344. }
  345. // Note: this uses the target's EFFECTIVE defense (base + equipment/buffs), unlike
  346. // CharacterStats.CalculateDamageReduction which only ever sees base defense.
  347. float mitigation = Mathf.Clamp(target.Sheet.GetEffectiveDefense() * DefenseMitigationPerPoint, 0f, MaxMitigation);
  348. float roll = Random.Range(1f - DamageVariance, 1f + DamageVariance);
  349. float damage = Mathf.Max(1f, actor.Sheet.GetEffectiveAttack() * roll * (1f - mitigation));
  350. target.Sheet.Stats.TakeDamage(damage);
  351. Emit(result, CombatEventKind.Hit, round, actor.Id, target.Id, damage,
  352. $"{actor.Name} hits {target.Name} for {damage:0.#} ({target.Sheet.Stats.CurrentHealth:0.#}/{target.MaxHealth:0.#} hp left).");
  353. if (!target.IsAlive)
  354. {
  355. Emit(result, CombatEventKind.Death, round, actor.Id, target.Id, 0f,
  356. $"{target.Name} is killed by {actor.Name}.");
  357. }
  358. }
  359. /// <summary>Fastest first; ties broken randomly so identical monsters don't always act in spawn order.</summary>
  360. private static List<Combatant> BuildTurnOrder(IReadOnlyList<Combatant> combatants)
  361. {
  362. var order = new List<Combatant>(combatants);
  363. var initiative = new Dictionary<int, float>(order.Count);
  364. foreach (var c in order)
  365. initiative[c.Id] = c.Sheet.GetEffectiveSpeed() + Random.Range(0f, 4f);
  366. order.Sort((a, b) => initiative[b.Id].CompareTo(initiative[a.Id]));
  367. return order;
  368. }
  369. /// <summary>Picks a random living enemy. Random rather than focus-fire so groups spread damage.</summary>
  370. private static Combatant PickTarget(IReadOnlyList<Combatant> combatants, Combatant actor)
  371. {
  372. var candidates = new List<Combatant>();
  373. foreach (var c in combatants)
  374. if (c.Team != actor.Team && c.IsAlive)
  375. candidates.Add(c);
  376. return candidates.Count == 0 ? null : candidates[Random.Range(0, candidates.Count)];
  377. }
  378. private static void Tally(BattleResult result, IReadOnlyList<Combatant> combatants)
  379. {
  380. foreach (var c in combatants)
  381. {
  382. if (c.IsAgent)
  383. {
  384. if (c.IsAlive) result.SurvivingAgents.Add(c);
  385. else result.FallenAgents.Add(c);
  386. }
  387. else if (!c.IsAlive)
  388. {
  389. result.FallenMonsters.Add(c);
  390. }
  391. }
  392. }
  393. private static void Emit(BattleResult result, CombatEventKind kind, int round, int actorId, int targetId, float amount, string text)
  394. {
  395. result.Events.Add(new CombatEvent
  396. {
  397. Kind = kind,
  398. Round = round,
  399. ActorId = actorId,
  400. TargetId = targetId,
  401. Amount = amount,
  402. Text = text
  403. });
  404. }
  405. private static int CountAlive(IReadOnlyList<Combatant> combatants, int team)
  406. {
  407. int count = 0;
  408. foreach (var c in combatants)
  409. if (c.Team == team && c.IsAlive) count++;
  410. return count;
  411. }
  412. private static bool HasAlive(IReadOnlyList<Combatant> combatants, int team) => CountAlive(combatants, team) > 0;
  413. private static void LootMonsters(IReadOnlyList<Combatant> combatants)
  414. {
  415. List<Combatant> aliveAgentCombatants = combatants.Where(c => c.IsAgent && c.IsAlive).ToList();
  416. List<Combatant> deadMonsters = combatants.Where(c => !c.IsAgent && !c.IsAlive).ToList();
  417. aliveAgentCombatants = aliveAgentCombatants.OrderBy(x => Random.value).ToList(); // Randomize the order of agents to distribute loot fairly
  418. for (int i = 0; i < deadMonsters.Count; i++)
  419. {
  420. Combatant deadMonster = deadMonsters[i];
  421. Combatant lootingAgent = aliveAgentCombatants[i % aliveAgentCombatants.Count];
  422. Item loot = deadMonster.Monster.GetRandomLoot(); // Now uses the monster instance's remaining loot pool
  423. if (loot != null)
  424. {
  425. lootingAgent.Agent.CharacterSheet.AddToInventory(loot);
  426. Debug.Log($"{lootingAgent.Name} looted {loot.ItemName} from {deadMonster.Name}.");
  427. }
  428. }
  429. }
  430. }
  431. }