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}";
}
}