using System.Collections.Generic; using System.Linq; using UnityEngine; namespace MazeGen { /// /// Resolves a battle between a group of agents and a group of monsters, all in one room. /// /// Now includes grid-based positioning and movement. Combatants must move into range /// before they can attack. Movement is determined by Speed stat (Speed/2 = squares per turn). /// /// Runs to completion synchronously and returns the whole thing as an ordered /// list (see ). Nothing about this is /// frame-based or animated - "watching" a fight is purely a replay of the list this produces, /// which is what guarantees the watched and log-only outcomes are identical. /// /// Combatant health is mutated in place on the real character sheets, so damage and deaths /// persist into the maze afterwards. /// public static class CombatResolver { // Tuning. Deliberately simple and legible - swap in a richer model (abilities, damage // types, status effects) without touching the director or the arena, since both only // ever consume the event list. private const float BaseHitChance = 0.75f; private const float HitChancePerSpeedPoint = 0.01f; private const float MinHitChance = 0.35f; private const float MaxHitChance = 0.95f; private const float DamageVariance = 0.15f; private const float DefenseMitigationPerPoint = 0.1f; private const float MaxMitigation = 0.9f; private const int CombatGridWidth = 11; private const int CombatGridHeight = 7; /// /// Fights it out until one side is wiped or is exhausted /// (which yields a stalemate - both sides disengage rather than looping forever). /// public static BattleResult Resolve(IReadOnlyList combatants, int maxRounds = 40) { var result = new BattleResult { Combatants = combatants }; int agentCount = CountAlive(combatants, Combatant.TeamAgents); int monsterCount = CountAlive(combatants, Combatant.TeamMonsters); Emit(result, CombatEventKind.FightStart, 0, -1, -1, 0f, $"Fight begins: {agentCount} agent(s) vs {monsterCount} monster(s)."); // Initialize combat grid and place combatants CombatGrid grid = InitializeGrid(combatants); // Initiative is rolled once for the whole battle (classic fixed turn order) so the // arena can lay combatants out in a stable order that matches the log. var order = BuildTurnOrder(combatants); int round = 0; while (round < maxRounds && HasAlive(combatants, Combatant.TeamAgents) && HasAlive(combatants, Combatant.TeamMonsters)) { round++; Emit(result, CombatEventKind.RoundStart, round, -1, -1, 0f, $"-- Round {round} --"); // Select actions for all combatants this round var actions = SelectActions(order, combatants, grid); // Execute actions in order (movements first, then attacks) ExecuteActions(actions, order, grid, result, round, combatants); if (!HasAlive(combatants, Combatant.TeamAgents) || !HasAlive(combatants, Combatant.TeamMonsters)) break; } result.Rounds = round; Tally(result, combatants); bool agentsLeft = HasAlive(combatants, Combatant.TeamAgents); bool monstersLeft = HasAlive(combatants, Combatant.TeamMonsters); result.Outcome = !monstersLeft && agentsLeft ? BattleOutcome.AgentsVictory : !agentsLeft && monstersLeft ? BattleOutcome.MonstersVictory : !agentsLeft && !monstersLeft ? BattleOutcome.MonstersVictory // mutual wipe counts as a loss for the agents : BattleOutcome.Stalemate; if (result.Outcome == BattleOutcome.AgentsVictory) { LootMonsters(combatants); } Emit(result, CombatEventKind.FightEnd, round, -1, -1, 0f, result.Summary); return result; } /// /// Initialize the combat grid and place all combatants on it. /// Monsters start fresh (full health each room), but agents retain damage between fights. /// Resets grid positions to ensure clean placement each fight. /// private static CombatGrid InitializeGrid(IReadOnlyList combatants) { // Reset grid positions for all combatants (they persist between fights) foreach (var combatant in combatants) { combatant.GridPosition = Vector2Int.zero; } // Monsters always start at full health (they're spawned per-room) // Agents keep their damage from previous fights (only healed by potions) foreach (var combatant in combatants) { if (!combatant.IsAgent) // Only monsters start fresh { combatant.Sheet.Stats.RestoreHealth(); } } var grid = new CombatGrid(new Vector2Int(CombatGridWidth, CombatGridHeight), 1f); grid.PlaceAllCombatants(combatants); // Snapshot the starting position for playback - GridPosition keeps changing as the // fight resolves, so this is what BattleArenaView uses to place sprites correctly // from the first frame instead of wherever the fight ended up. foreach (var combatant in combatants) { combatant.StartGridPosition = combatant.GridPosition; } return grid; } /// /// Select actions for all alive combatants this turn. /// Agents try health potions first if low on health, then decide between movement and attack. /// Monsters always attack or move closer. /// private static List SelectActions( List order, IReadOnlyList allCombatants, CombatGrid grid) { var actions = new List(); foreach (var actor in order) { if (!actor.IsAlive) continue; CombatAction action = null; // Agent health potion logic if (actor.IsAgent && actor.Agent.CharacterSheet.Stats.CurrentHealth < actor.Agent.CharacterSheet.Stats.MaxHealth * 0.2f) { Consumable healthPotion = actor.Agent.CharacterSheet.GetHealthPotionFromInventory(); if (healthPotion != null) { action = new CombatAction(actor.Id, ActionType.UseItem); action.TargetItem = healthPotion; actions.Add(action); continue; } } // Pick a target Combatant target = PickTarget(allCombatants, actor); if (target == null) { action = new CombatAction(actor.Id, ActionType.Pass); actions.Add(action); continue; } // Get equipped weapon (or default to unarmed melee range 1) Weapon weapon = actor.Sheet.EquippedWeapon; int attackRange = weapon != null ? weapon.Range : 1; // Fists = range 1, adjacent only // Check if in attack range bool inRange = false; if (weapon != null) { inRange = MovementValidator.CanAttack(actor, target, weapon); } else { // Unarmed: must be adjacent (distance = 1) inRange = actor.IsAdjacentTo(target); } if (inRange) { action = new CombatAction(actor.Id, target); actions.Add(action); } else { // Out of range, try to move closer Vector2Int nextPos = MovementValidator.GetNextPositionTowards( actor.GridPosition, target.GridPosition); if (MovementValidator.CanMoveTo(actor, nextPos, grid)) { action = new CombatAction(actor.Id, nextPos); actions.Add(action); } else { // Can't move, just pass action = new CombatAction(actor.Id, ActionType.Pass); actions.Add(action); } } } return actions; } /// /// Execute all actions for this round in order. /// private static void ExecuteActions( List actions, List turnOrder, CombatGrid grid, BattleResult result, int round, IReadOnlyList allCombatants) { foreach (var action in actions) { // Find the actor Combatant actor = null; foreach (var c in turnOrder) { if (c.Id == action.ActorId) { actor = c; break; } } if (actor == null || !actor.IsAlive) continue; switch (action.ActionType) { case ActionType.Move: ExecuteMovement(actor, action.TargetPosition, grid, result, round); break; case ActionType.Attack: ExecuteAttack(actor, action.TargetCombatant, grid, result, round); break; case ActionType.UseItem: ExecuteUseItem(actor, action.TargetItem, result, round); break; case ActionType.Defend: // TODO: Implement defend action (damage reduction this turn) break; case ActionType.Pass: default: // Do nothing break; } // Check if fight is over after each action if (!HasAlive(allCombatants, Combatant.TeamAgents) || !HasAlive(allCombatants, Combatant.TeamMonsters)) { break; } } } /// /// Execute a movement action for an actor. /// private static void ExecuteMovement( Combatant actor, Vector2Int destination, CombatGrid grid, BattleResult result, int round) { Vector2Int startPos = actor.GridPosition; if (!grid.TryMoveCombatant(actor, destination)) { Emit(result, CombatEventKind.TargetUnreachable, round, actor.Id, -1, 0f, $"{actor.Name} could not reach {destination}."); return; } int distance = MovementValidator.ManhattanDistance(startPos, destination); Emit(result, CombatEventKind.MovementStart, round, actor.Id, -1, 0f, $"{actor.Name} moves from {startPos} to {destination}."); Emit(result, CombatEventKind.MovementComplete, round, actor.Id, -1, distance, $"{actor.Name} has moved {distance} square(s)."); var moveEvent = result.Events[result.Events.Count - 1]; moveEvent.SourcePosition = startPos; moveEvent.TargetPosition = destination; moveEvent.MovementDistance = distance; } /// /// Execute an attack action, with range validation. /// Supports both armed and unarmed (fist) attacks. /// private static void ExecuteAttack( Combatant actor, Combatant target, CombatGrid grid, BattleResult result, int round) { if (target == null || !target.IsAlive) return; Weapon weapon = actor.Sheet.EquippedWeapon; bool isUnarmed = weapon == null; // Validate range bool canAttack = false; if (isUnarmed) { // Unarmed: must be adjacent canAttack = actor.IsAdjacentTo(target); if (!canAttack) { Emit(result, CombatEventKind.TooFarForMelee, round, actor.Id, target.Id, 0f, $"{actor.Name} is not adjacent to {target.Name} and cannot use fists."); return; } } else { // Armed: use weapon's range canAttack = MovementValidator.CanAttack(actor, target, weapon); if (!canAttack) { if (weapon.WeaponType == WeaponType.Melee) { Emit(result, CombatEventKind.TooFarForMelee, round, actor.Id, target.Id, 0f, $"{actor.Name} is not adjacent to {target.Name} and cannot use {weapon.ItemName}."); } else { Emit(result, CombatEventKind.OutOfRange, round, actor.Id, target.Id, 0f, $"{actor.Name}'s {weapon.ItemName} cannot reach {target.Name} (range: {weapon.Range}, distance: {actor.DistanceTo(target)})."); } return; } } // Attack is valid, execute it TakeSwing(result, round, actor, target); } /// /// Execute using a consumable item. /// private static void ExecuteUseItem( Combatant actor, Consumable item, BattleResult result, int round) { if (item == null) return; if (item is Consumable consumable) { actor.Sheet.Stats.Heal(consumable.HealthBonus); Emit(result, CombatEventKind.HealthPotionUsed, round, actor.Id, -1, consumable.HealthBonus, $"{actor.Name} used {item.ItemName} to heal {consumable.HealthBonus} health."); } } /// /// Single attack: roll to hit, then damage attacker's effective attack against the /// target's effective defense. /// private static void TakeSwing(BattleResult result, int round, Combatant actor, Combatant target) { float hitChance = Mathf.Clamp( BaseHitChance + HitChancePerSpeedPoint * (actor.Sheet.GetEffectiveSpeed() - target.Sheet.GetEffectiveSpeed()), MinHitChance, MaxHitChance); if (Random.value > hitChance) { Emit(result, CombatEventKind.Miss, round, actor.Id, target.Id, 0f, $"{actor.Name} swings at {target.Name} and misses ({hitChance:P0} to hit)."); return; } // Note: this uses the target's EFFECTIVE defense (base + equipment/buffs), unlike // CharacterStats.CalculateDamageReduction which only ever sees base defense. float mitigation = Mathf.Clamp(target.Sheet.GetEffectiveDefense() * DefenseMitigationPerPoint, 0f, MaxMitigation); float roll = Random.Range(1f - DamageVariance, 1f + DamageVariance); float damage = Mathf.Max(1f, actor.Sheet.GetEffectiveAttack() * roll * (1f - mitigation)); target.Sheet.Stats.TakeDamage(damage); Emit(result, CombatEventKind.Hit, round, actor.Id, target.Id, damage, $"{actor.Name} hits {target.Name} for {damage:0.#} ({target.Sheet.Stats.CurrentHealth:0.#}/{target.MaxHealth:0.#} hp left)."); if (!target.IsAlive) { Emit(result, CombatEventKind.Death, round, actor.Id, target.Id, 0f, $"{target.Name} is killed by {actor.Name}."); } } /// Fastest first; ties broken randomly so identical monsters don't always act in spawn order. private static List BuildTurnOrder(IReadOnlyList combatants) { var order = new List(combatants); var initiative = new Dictionary(order.Count); foreach (var c in order) initiative[c.Id] = c.Sheet.GetEffectiveSpeed() + Random.Range(0f, 4f); order.Sort((a, b) => initiative[b.Id].CompareTo(initiative[a.Id])); return order; } /// Picks a random living enemy. Random rather than focus-fire so groups spread damage. private static Combatant PickTarget(IReadOnlyList combatants, Combatant actor) { var candidates = new List(); foreach (var c in combatants) if (c.Team != actor.Team && c.IsAlive) candidates.Add(c); return candidates.Count == 0 ? null : candidates[Random.Range(0, candidates.Count)]; } private static void Tally(BattleResult result, IReadOnlyList combatants) { foreach (var c in combatants) { if (c.IsAgent) { if (c.IsAlive) result.SurvivingAgents.Add(c); else result.FallenAgents.Add(c); } else if (!c.IsAlive) { result.FallenMonsters.Add(c); } } } private static void Emit(BattleResult result, CombatEventKind kind, int round, int actorId, int targetId, float amount, string text) { result.Events.Add(new CombatEvent { Kind = kind, Round = round, ActorId = actorId, TargetId = targetId, Amount = amount, Text = text }); } private static int CountAlive(IReadOnlyList combatants, int team) { int count = 0; foreach (var c in combatants) if (c.Team == team && c.IsAlive) count++; return count; } private static bool HasAlive(IReadOnlyList combatants, int team) => CountAlive(combatants, team) > 0; private static void LootMonsters(IReadOnlyList combatants) { List aliveAgentCombatants = combatants.Where(c => c.IsAgent && c.IsAlive).ToList(); List deadMonsters = combatants.Where(c => !c.IsAgent && !c.IsAlive).ToList(); aliveAgentCombatants = aliveAgentCombatants.OrderBy(x => Random.value).ToList(); // Randomize the order of agents to distribute loot fairly for (int i = 0; i < deadMonsters.Count; i++) { Combatant deadMonster = deadMonsters[i]; Combatant lootingAgent = aliveAgentCombatants[i % aliveAgentCombatants.Count]; Item loot = deadMonster.Monster.GetRandomLoot(); // Now uses the monster instance's remaining loot pool if (loot != null) { lootingAgent.Agent.CharacterSheet.AddToInventory(loot); Debug.Log($"{lootingAgent.Name} looted {loot.ItemName} from {deadMonster.Name}."); } } } } }