| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179 |
- using System.Collections.Generic;
- using UnityEngine;
- namespace MazeGen
- {
- /// <summary>
- /// 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
- /// <see cref="CombatEvent"/> list (see <see cref="BattleResult"/>). 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.
- /// </summary>
- 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;
- /// <summary>
- /// Fights it out until one side is wiped or <paramref name="maxRounds"/> is exhausted
- /// (which yields a stalemate - both sides disengage rather than looping forever).
- /// </summary>
- public static BattleResult Resolve(IReadOnlyList<Combatant> 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;
- }
- /// <summary>
- /// Single attack: roll to hit, then damage attacker's effective attack against the
- /// target's effective defense.
- /// </summary>
- 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}.");
- }
- }
- /// <summary>Fastest first; ties broken randomly so identical monsters don't always act in spawn order.</summary>
- private static List<Combatant> BuildTurnOrder(IReadOnlyList<Combatant> combatants)
- {
- var order = new List<Combatant>(combatants);
- var initiative = new Dictionary<int, float>(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;
- }
- /// <summary>Picks a random living enemy. Random rather than focus-fire so groups spread damage.</summary>
- private static Combatant PickTarget(IReadOnlyList<Combatant> combatants, Combatant actor)
- {
- var candidates = new List<Combatant>();
- 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<Combatant> 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<Combatant> 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<Combatant> combatants, int team) => CountAlive(combatants, team) > 0;
- }
- }
|