| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518 |
- using System;
- using System.Collections;
- using System.Collections.Generic;
- using UnityEngine;
- namespace MazeGen
- {
- /// <summary>
- /// Owns the whole fight lifecycle: declaration, the join window, sneak attempts, resolution,
- /// and pushing the results back into the maze.
- ///
- /// Lifecycle of a fight:
- /// 1. An agent decides (or is forced) to fight -> <see cref="DeclareFight"/>. Every agent
- /// already standing in that room is dragged in too; they were there when it kicked off.
- /// 2. A join window (default 5s, <see cref="JoinWindowSeconds"/>) counts down on
- /// SimulationClock time. During it, agents walking into the room can join (better odds)
- /// or attempt to sneak past - a failed sneak forces them in. Agents elsewhere in the maze
- /// cannot join.
- /// 3. When the window expires the fight resolves via <see cref="CombatResolver"/>, either
- /// log-only or replayed on the arena grid.
- /// 4. Results are applied to the real character sheets, so the room afterwards differs only
- /// by who is dead and how hurt the survivors are.
- ///
- /// Committed agents are marked <see cref="MazeAgent.IsEngaged"/>, which parks their brain -
- /// they can't wander off mid-muster.
- /// </summary>
- [DisallowMultipleComponent]
- public class CombatDirector : MonoBehaviour
- {
- /// <summary>Outcome of an agent arriving in a room where a fight is already mustering.</summary>
- public enum ArrivalDecision
- {
- NoFight,
- Joined,
- SneakedPast,
- SneakFailedAndJoined
- }
- private const string ArenaHoldKey = "watching a fight";
- private const int MaxLogLines = 600;
- private const int MaxRecentFights = 12;
- [Header("Join window")]
- [Tooltip("Seconds between a fight being declared and it actually starting. Other agents can arrive and pile in (or try to sneak past) during this window.")]
- [SerializeField, Min(0f)] private float joinWindowSeconds = 5f;
- [Header("Observation")]
- [Tooltip("Freeze the whole simulation the moment a fight is declared, so you can zoom in and decide whether to watch it. The join window is frozen too - resume to let agents converge.")]
- [SerializeField] private bool pauseOnFightDeclared = true;
- [Tooltip("How a fight resolves when its join window runs out on its own: Simulate = log only, Watch = play it out on the arena grid.")]
- [SerializeField] private CombatViewMode timeoutResolution = CombatViewMode.Simulate;
- [Header("Resolution")]
- [Tooltip("Rounds after which an unresolved fight is called a stalemate and both sides disengage.")]
- [SerializeField, Min(1)] private int maxRounds = 40;
- [Header("Sneaking")]
- [Tooltip("Baseline chance an agent slips past a fight/monsters without being pulled in.")]
- [SerializeField, Range(0f, 1f)] private float sneakBaseChance = 0.55f;
- [Tooltip("How much each point of speed advantage over the monsters' average adds to the sneak chance.")]
- [SerializeField] private float sneakSpeedWeight = 0.02f;
- [Tooltip("Sneak chance penalty per hostile in the room beyond the first - more eyes, harder to slip by.")]
- [SerializeField] private float sneakPenaltyPerMonster = 0.08f;
- private readonly List<PendingFight> _active = new List<PendingFight>();
- private readonly List<PendingFight> _recent = new List<PendingFight>();
- private readonly List<MazeAgentController> _agents = new List<MazeAgentController>();
- private readonly Dictionary<MazeAgent, Color> _agentColors = new Dictionary<MazeAgent, Color>();
- private readonly List<string> _log = new List<string>();
- private BattleArenaView _arena;
- private int _nextFightId = 1;
- public float JoinWindowSeconds => joinWindowSeconds;
- public IReadOnlyList<PendingFight> ActiveFights => _active;
- public IReadOnlyList<PendingFight> RecentFights => _recent;
- public IReadOnlyList<string> CombatLog => _log;
- /// <summary>True while an arena playback is on screen; the maze HUD hides itself then.</summary>
- public bool IsWatching { get; private set; }
- public bool PauseOnFightDeclared
- {
- get => pauseOnFightDeclared;
- set => pauseOnFightDeclared = value;
- }
- public CombatViewMode TimeoutResolution
- {
- get => timeoutResolution;
- set => timeoutResolution = value;
- }
- public event Action<PendingFight> FightDeclared;
- public event Action<PendingFight> FightResolved;
- private void Awake()
- {
- EnsureArena();
- }
- /// <summary>
- /// Resolved lazily (and added if missing) because this component may itself have been
- /// added at runtime by MazeSceneBuilder, after its own Awake would have run.
- /// </summary>
- private BattleArenaView EnsureArena()
- {
- if (_arena == null)
- _arena = GetComponent<BattleArenaView>() ?? gameObject.AddComponent<BattleArenaView>();
- return _arena;
- }
- /// <summary>Agents must register so the director can find who else is standing in a room.</summary>
- public void RegisterAgent(MazeAgentController controller, Color color)
- {
- if (controller == null || _agents.Contains(controller)) return;
- _agents.Add(controller);
- if (controller.Agent != null)
- _agentColors[controller.Agent] = color;
- }
- private void Update()
- {
- if (IsWatching) return;
- float dt = SimulationClock.DeltaTime;
- // Backwards: resolving/cancelling removes from this list.
- for (int i = _active.Count - 1; i >= 0; i--)
- {
- PendingFight fight = _active[i];
- if (fight.State != FightState.Mustering) continue;
- DropUnavailableParticipants(fight);
- if (fight.Agents.Count == 0)
- {
- Cancel(fight, "every committed agent died or left before it started");
- continue;
- }
- if (!fight.Encounter.HasLivingMonsters)
- {
- Cancel(fight, "the monsters were already dead");
- continue;
- }
- if (dt <= 0f) continue; // paused - the join window is frozen too
- fight.JoinTimeRemaining -= dt;
- if (fight.JoinTimeRemaining <= 0f)
- BeginResolution(fight, fight.PreferredResolution ?? timeoutResolution);
- }
- }
- // ---------------------------------------------------------------- declaring & joining
- /// <summary>
- /// An agent commits to fighting whatever is in <paramref name="room"/>. If a fight is
- /// already mustering there this just joins it instead of starting a second one.
- /// Returns null if there's nothing to fight.
- /// </summary>
- public PendingFight DeclareFight(Room room, MazeAgent declarer, string trigger)
- {
- if (room?.Encounter == null || !room.Encounter.HasLivingMonsters) return null;
- if (declarer == null || declarer.IsDead) return null;
- PendingFight existing = FindMusteringFight(room);
- if (existing != null)
- {
- if (!existing.Contains(declarer))
- Commit(existing, declarer, trigger);
- return existing;
- }
- var fight = new PendingFight(_nextFightId++, room, room.Encounter)
- {
- JoinTimeRemaining = joinWindowSeconds,
- Trigger = trigger
- };
- _active.Add(fight);
- Commit(fight, declarer, trigger);
- // Anyone else already in this room is in it whether they like it or not.
- foreach (var controller in _agents)
- {
- MazeAgent other = controller.Agent;
- if (other == null || other == declarer) continue;
- if (!IsAvailable(other) || other.CurrentRoom != room) continue;
- Commit(fight, other, "was already in the room when the fight broke out");
- }
- Log($"{fight.Describe()} - declared by {declarer.CharacterSheet.Name} ({trigger}). Starts in {joinWindowSeconds:0.0}s.", echoToConsole: false);
- if (pauseOnFightDeclared)
- SimulationClock.PushHold(HoldKeyFor(fight));
- FightDeclared?.Invoke(fight);
- return fight;
- }
- /// <summary>
- /// Called by an agent's brain when it finds itself in a room where a fight is mustering.
- /// <paramref name="wantsToSneak"/> is the agent's own call; a failed sneak drags it in.
- /// </summary>
- public ArrivalDecision ResolveArrival(MazeAgent agent, bool wantsToSneak)
- {
- if (agent == null || agent.CurrentRoom == null) return ArrivalDecision.NoFight;
- PendingFight fight = FindMusteringFight(agent.CurrentRoom);
- if (fight == null) return ArrivalDecision.NoFight;
- if (fight.Contains(agent)) return ArrivalDecision.Joined;
- if (!wantsToSneak)
- {
- Commit(fight, agent, "arrived and joined the fight");
- Log($"{agent.CharacterSheet.Name} joins fight #{fight.Id} - now {fight.Agents.Count} agent(s) vs {fight.Encounter.AliveMonsterCount} monster(s).", echoToConsole: false);
- return ArrivalDecision.Joined;
- }
- float odds = SneakChance(agent, fight.Encounter);
- if (UnityEngine.Random.value < odds)
- {
- agent.Thoughts.Add(ThoughtKind.Sneak, $"Slipped past the fight in room {fight.Room.Id} ({odds:P0} odds) - not my problem.");
- Log($"{agent.CharacterSheet.Name} sneaks past fight #{fight.Id} ({odds:P0} odds).", echoToConsole: false);
- return ArrivalDecision.SneakedPast;
- }
- Commit(fight, agent, $"was spotted sneaking past ({odds:P0} odds) and forced in");
- Log($"{agent.CharacterSheet.Name} is spotted sneaking past fight #{fight.Id} ({odds:P0} odds) and is forced into it.", echoToConsole: false);
- return ArrivalDecision.SneakFailedAndJoined;
- }
- /// <summary>
- /// Sneak attempt against monsters in a room where no fight has been declared yet (an
- /// agent that walked in, didn't like the odds, and wants to leave quietly). A failure
- /// leaves it to the caller to declare the fight it just got caught in.
- /// </summary>
- public bool TrySneakOut(MazeAgent agent, out float odds)
- {
- odds = 0f;
- MonsterEncounter encounter = agent?.CurrentRoom?.Encounter;
- if (encounter == null || !encounter.HasLivingMonsters) return true;
- odds = SneakChance(agent, encounter);
- return UnityEngine.Random.value < odds;
- }
- /// <summary>
- /// Faster than the monsters and few of them = good odds. Clamped so sneaking is never a
- /// certainty either way.
- /// </summary>
- public float SneakChance(MazeAgent agent, MonsterEncounter encounter)
- {
- if (encounter == null || !encounter.HasLivingMonsters) return 1f;
- float totalSpeed = 0f;
- int alive = 0;
- foreach (var monster in encounter.Monsters)
- {
- if (monster.IsDefeated) continue;
- totalSpeed += monster.CharacterSheet.GetEffectiveSpeed();
- alive++;
- }
- if (alive == 0) return 1f;
- float averageSpeed = totalSpeed / alive;
- float chance = sneakBaseChance
- + sneakSpeedWeight * (agent.CharacterSheet.GetEffectiveSpeed() - averageSpeed)
- - sneakPenaltyPerMonster * (alive - 1);
- return Mathf.Clamp(chance, 0.05f, 0.95f);
- }
- public PendingFight FindMusteringFight(Room room)
- {
- if (room == null) return null;
- foreach (var fight in _active)
- if (fight.State == FightState.Mustering && fight.Room.Id == room.Id)
- return fight;
- return null;
- }
- private void Commit(PendingFight fight, MazeAgent agent, string reason)
- {
- if (fight.Contains(agent)) return;
- fight.Agents.Add(agent);
- agent.IsEngaged = true;
- agent.Thoughts.Add(ThoughtKind.Fight,
- $"In fight #{fight.Id} (room {fight.Room.Id}) - {reason}. {fight.Agents.Count} of us vs {fight.Encounter.AliveMonsterCount} hostile(s), starting in {Mathf.Max(0f, fight.JoinTimeRemaining):0.0}s.");
- }
- /// <summary>Drops committed agents that died (elsewhere) or finished before the fight began.</summary>
- private void DropUnavailableParticipants(PendingFight fight)
- {
- for (int i = fight.Agents.Count - 1; i >= 0; i--)
- {
- MazeAgent agent = fight.Agents[i];
- if (agent == null || agent.IsDead || agent.IsFinished)
- {
- if (agent != null) agent.IsEngaged = false;
- fight.Agents.RemoveAt(i);
- }
- }
- }
- // ---------------------------------------------------------------- observer controls
- /// <summary>Let a paused fight's join window continue counting down.</summary>
- public void ResumeMuster(PendingFight fight)
- {
- if (fight == null) return;
- SimulationClock.PopHold(HoldKeyFor(fight));
- }
- public void ResumeAllMusters()
- {
- foreach (var fight in _active)
- SimulationClock.PopHold(HoldKeyFor(fight));
- }
- /// <summary>
- /// Sets the observer's preference for how this fight resolves once its join window runs
- /// out. This is a preference only, NOT a trigger - the join window always has to elapse
- /// on its own first (so other agents keep getting their full chance to pile in or sneak
- /// past), even if the simulation is currently paused waiting on the observer.
- /// </summary>
- public void PreferSimulate(PendingFight fight) => SetPreferredResolution(fight, CombatViewMode.Simulate);
- /// <summary>See <see cref="PreferSimulate"/> - same, but plays out on the arena grid.</summary>
- public void PreferWatch(PendingFight fight) => SetPreferredResolution(fight, CombatViewMode.Watch);
- private void SetPreferredResolution(PendingFight fight, CombatViewMode mode)
- {
- if (fight == null || fight.State != FightState.Mustering) return;
- if (fight.PreferredResolution == mode) return;
- fight.PreferredResolution = mode;
- Log($"Fight #{fight.Id} in room {fight.Room.Id} will be {(mode == CombatViewMode.Watch ? "watched" : "logged only")} when its join window ends.", echoToConsole: false);
- }
- // ---------------------------------------------------------------- resolution
- private void BeginResolution(PendingFight fight, CombatViewMode mode)
- {
- if (fight == null || fight.State != FightState.Mustering) return;
- SimulationClock.PopHold(HoldKeyFor(fight));
- DropUnavailableParticipants(fight);
- if (fight.Agents.Count == 0)
- {
- Cancel(fight, "no agents left to fight");
- return;
- }
- if (!fight.Encounter.HasLivingMonsters)
- {
- Cancel(fight, "no monsters left to fight");
- return;
- }
- fight.State = FightState.Resolving;
- fight.ResolvedAs = mode;
- List<Combatant> combatants = BuildCombatants(fight);
- fight.Result = CombatResolver.Resolve(combatants, maxRounds);
- BattleArenaView arena = mode == CombatViewMode.Watch ? EnsureArena() : null;
- if (arena != null)
- {
- IsWatching = true;
- SimulationClock.PushHold(ArenaHoldKey);
- StartCoroutine(WatchRoutine(arena, fight, combatants));
- }
- else
- {
- ApplyResult(fight, combatants);
- }
- }
- private IEnumerator WatchRoutine(BattleArenaView arena, PendingFight fight, List<Combatant> combatants)
- {
- // Playback is pure animation over an already-decided event list, so however long the
- // observer takes (or if they skip), the outcome below is unchanged.
- yield return arena.Play(fight, combatants);
- SimulationClock.PopHold(ArenaHoldKey);
- IsWatching = false;
- ApplyResult(fight, combatants);
- }
- /// <summary>
- /// Pushes the decided outcome back into maze state: dead agents die, survivors are
- /// released to keep exploring, everyone involved gets the blow-by-blow in their thought
- /// log. Monster/agent health was already mutated in place by the resolver.
- /// </summary>
- private void ApplyResult(PendingFight fight, List<Combatant> combatants)
- {
- BattleResult result = fight.Result;
- foreach (var e in result.Events)
- RecordEvent(result, e);
- foreach (var combatant in combatants)
- {
- if (!combatant.IsAgent) continue;
- MazeAgent agent = combatant.Agent;
- agent.IsEngaged = false;
- if (!combatant.IsAlive)
- {
- agent.Thoughts.Add(ThoughtKind.Goal, $"Killed in fight #{fight.Id}, room {fight.Room.Id}.");
- agent.Die();
- continue;
- }
- agent.Thoughts.Add(ThoughtKind.Outcome,
- $"Fight #{fight.Id} over: {result.Summary} I'm on {combatant.Sheet.Stats.CurrentHealth:0.#}/{combatant.MaxHealth:0.#} hp.");
- // A stalemate would otherwise have the agent immediately re-declare the same
- // fight forever, so remember not to pick this one again.
- if (result.Outcome == BattleOutcome.Stalemate)
- {
- agent.NoteFledFrom(fight.Room.Id);
- agent.Thoughts.Add(ThoughtKind.Avoid, $"Couldn't finish them off in room {fight.Room.Id} - disengaging and not trying that again.");
- }
- }
- fight.State = FightState.Resolved;
- _active.Remove(fight);
- Remember(fight);
- Log($"Fight #{fight.Id} ({(fight.ResolvedAs == CombatViewMode.Watch ? "watched" : "simulated")}) in room {fight.Room.Id}: {result.Summary}", echoToConsole: false);
- FightResolved?.Invoke(fight);
- }
- private void Cancel(PendingFight fight, string why)
- {
- SimulationClock.PopHold(HoldKeyFor(fight));
- foreach (var agent in fight.Agents)
- if (agent != null) agent.IsEngaged = false;
- fight.State = FightState.Cancelled;
- _active.Remove(fight);
- Remember(fight);
- Log($"Fight #{fight.Id} in room {fight.Room.Id} called off - {why}.", echoToConsole: false);
- FightResolved?.Invoke(fight);
- }
- private List<Combatant> BuildCombatants(PendingFight fight)
- {
- var combatants = new List<Combatant>();
- int id = 0;
- foreach (var agent in fight.Agents)
- {
- Color color = _agentColors.TryGetValue(agent, out var c) ? c : Color.white;
- combatants.Add(Combatant.ForAgent(id++, agent, color));
- }
- foreach (var monster in fight.Encounter.Monsters)
- {
- if (monster.IsDefeated) continue; // already dead from an earlier fight in this room
- combatants.Add(Combatant.ForMonster(id++, monster));
- }
- return combatants;
- }
- /// <summary>Mirrors one combat event into the global log and the thought logs of the agents it involved.</summary>
- private void RecordEvent(BattleResult result, CombatEvent e)
- {
- // Blow-by-blow lines stay in the in-memory log/HUD only. A long fight between big
- // groups emits hundreds of events, and echoing every one to Debug.Log would drown
- // the Console and cost real frame time.
- Log(e.Text, echoToConsole: false);
- if (e.Kind == CombatEventKind.RoundStart) return;
- AddCombatThought(result, e.ActorId, e.Text);
- if (e.TargetId != e.ActorId)
- AddCombatThought(result, e.TargetId, e.Text);
- }
- private void AddCombatThought(BattleResult result, int combatantId, string text)
- {
- if (combatantId < 0 || result.Combatants == null || combatantId >= result.Combatants.Count) return;
- Combatant combatant = result.Combatants[combatantId];
- if (combatant.IsAgent)
- combatant.Agent.Thoughts.Add(ThoughtKind.Combat, text);
- }
- private void Remember(PendingFight fight)
- {
- _recent.Insert(0, fight);
- if (_recent.Count > MaxRecentFights)
- _recent.RemoveAt(_recent.Count - 1);
- }
- private void Log(string line, bool echoToConsole = true)
- {
- _log.Add(line);
- if (_log.Count > MaxLogLines)
- _log.RemoveAt(0);
- if (echoToConsole)
- Debug.Log($"[Combat] {line}");
- }
- private static bool IsAvailable(MazeAgent agent)
- => agent != null && !agent.IsDead && !agent.IsFinished && !agent.IsEngaged;
- private static string HoldKeyFor(PendingFight fight) => $"fight #{fight.Id} in room {fight.Room.Id}";
- }
- }
|