using System.Collections.Generic; using UnityEngine; namespace MazeGen { /// /// Resolves a battle between a group of agents and a group of monsters, all in one room. /// /// 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; /// /// 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)."); // 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} --"); foreach (var actor in order) { if (!actor.IsAlive) continue; Combatant target = PickTarget(combatants, actor); if (target == null) break; // no enemies left TakeSwing(result, round, actor, target); 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; Emit(result, CombatEventKind.FightEnd, round, -1, -1, 0f, result.Summary); return result; } /// /// 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; } }