| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494 |
- using System.Collections.Generic;
- using System.Linq;
- using UnityEngine;
- namespace MazeGen
- {
- /// <summary>
- /// 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
- /// <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;
- private const int CombatGridWidth = 11;
- private const int CombatGridHeight = 7;
- /// <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).");
- // 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;
- }
- /// <summary>
- /// 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.
- /// </summary>
- private static CombatGrid InitializeGrid(IReadOnlyList<Combatant> 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;
- }
- /// <summary>
- /// 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.
- /// </summary>
- private static List<CombatAction> SelectActions(
- List<Combatant> order,
- IReadOnlyList<Combatant> allCombatants,
- CombatGrid grid)
- {
- var actions = new List<CombatAction>();
- 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;
- }
- /// <summary>
- /// Execute all actions for this round in order.
- /// </summary>
- private static void ExecuteActions(
- List<CombatAction> actions,
- List<Combatant> turnOrder,
- CombatGrid grid,
- BattleResult result,
- int round,
- IReadOnlyList<Combatant> 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;
- }
- }
- }
- /// <summary>
- /// Execute a movement action for an actor.
- /// </summary>
- 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;
- }
- /// <summary>
- /// Execute an attack action, with range validation.
- /// Supports both armed and unarmed (fist) attacks.
- /// </summary>
- 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);
- }
- /// <summary>
- /// Execute using a consumable item.
- /// </summary>
- 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.");
- }
- }
- /// <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;
- private static void LootMonsters(IReadOnlyList<Combatant> combatants)
- {
- List<Combatant> aliveAgentCombatants = combatants.Where(c => c.IsAgent && c.IsAlive).ToList();
- List<Combatant> 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}.");
- }
- }
- }
- }
- }
|