using System; using System.Collections; using System.Collections.Generic; using UnityEngine; namespace MazeGen { /// /// 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 -> . Every agent /// already standing in that room is dragged in too; they were there when it kicked off. /// 2. A join window (default 5s, ) 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 , 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 , which parks their brain - /// they can't wander off mid-muster. /// [DisallowMultipleComponent] public class CombatDirector : MonoBehaviour { /// Outcome of an agent arriving in a room where a fight is already mustering. 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 _active = new List(); private readonly List _recent = new List(); private readonly List _agents = new List(); private readonly Dictionary _agentColors = new Dictionary(); private readonly List _log = new List(); private BattleArenaView _arena; private int _nextFightId = 1; public float JoinWindowSeconds => joinWindowSeconds; public IReadOnlyList ActiveFights => _active; public IReadOnlyList RecentFights => _recent; public IReadOnlyList CombatLog => _log; /// True while an arena playback is on screen; the maze HUD hides itself then. 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 FightDeclared; public event Action FightResolved; private void Awake() { EnsureArena(); } /// /// 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. /// private BattleArenaView EnsureArena() { if (_arena == null) _arena = GetComponent() ?? gameObject.AddComponent(); return _arena; } /// Agents must register so the director can find who else is standing in a room. 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 /// /// An agent commits to fighting whatever is in . 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. /// 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; } /// /// Called by an agent's brain when it finds itself in a room where a fight is mustering. /// is the agent's own call; a failed sneak drags it in. /// 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; } /// /// 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. /// 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; } /// /// Faster than the monsters and few of them = good odds. Clamped so sneaking is never a /// certainty either way. /// 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."); } /// Drops committed agents that died (elsewhere) or finished before the fight began. 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 /// Let a paused fight's join window continue counting down. 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)); } /// /// 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. /// public void PreferSimulate(PendingFight fight) => SetPreferredResolution(fight, CombatViewMode.Simulate); /// See - same, but plays out on the arena grid. 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 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 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); } /// /// 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. /// private void ApplyResult(PendingFight fight, List 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 BuildCombatants(PendingFight fight) { var combatants = new List(); 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; } /// Mirrors one combat event into the global log and the thought logs of the agents it involved. 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}"; } }