using System.Collections;
using System.Collections.Generic;
using System.Linq;
using UnityEngine;
namespace MazeGen
{
///
/// Scene entry point: drop this on one empty GameObject in the scene and press Play.
/// Generates the configured start rooms, then spawns and drives a configurable number
/// of autonomous agents that explore the maze on their own (see AutonomousAgentBrain).
/// The level ends when enough agents reach an exit room or a time limit is hit.
///
/// Also owns the observation HUD:
/// - simulation controls (pause/speed - the sim runs on SimulationClock, not Time.timeScale)
/// - the agent list (click to have the camera follow one)
/// - "Agent thoughts and info" for the followed agent, fed by its AgentThoughtLog
/// - live fights with their join-window countdown and watch/simulate controls
/// - the global combat log
/// The HUD hides itself entirely while a fight is being watched, since BattleArenaView
/// draws its own.
///
[RequireComponent(typeof(MazeGenerator))]
[RequireComponent(typeof(MonsterSpawner))]
[RequireComponent(typeof(CombatDirector))]
[RequireComponent(typeof(BattleArenaView))]
public class MazeSceneBuilder : MonoBehaviour
{
[Header("Agents")]
[SerializeField, Min(1)] private int agentCount = 1;
[Tooltip("Each agent's spawn is delayed by a random amount in this range (seconds), so they enter the maze staggered over time instead of all at once.")]
[SerializeField] private Vector2 spawnStaggerRange = new Vector2(0f, 3f);
[Tooltip("Each agent's FIRST decision is delayed by a random amount in this range (seconds); every decision after that uses the steady decisionInterval.")]
[SerializeField] private Vector2 initialDecisionDelayRange = new Vector2(0.5f, 2f);
[Tooltip("Steady-state time (seconds) between an agent's decisions once it gets going.")]
[SerializeField] private float decisionInterval = 1.5f;
[Tooltip("Chance an agent explores a brand-new exit even when a 'follow' (known but personally-unvisited) option is also available.")]
[SerializeField, Range(0f, 1f)] private float exploreChance = 0.85f;
[Tooltip("Perceived challenge rating an agent will take on alone. Above this it avoids the room, or tries to sneak past instead of fighting.")]
[SerializeField] private float retreatThreshold = 1.5f;
[Header("Win conditions (at least one must be > 0)")]
[SerializeField] private int targetFinishedAgentCount = 1;
[SerializeField] private float timeLimitSeconds = 0f;
private class AgentEntry
{
public MazeAgentController Controller;
public PathVisualizer Path;
public Color Color;
}
private MazeGenerator _generator;
private MonsterSpawner _monsterSpawner;
private CombatDirector _director;
private MazeCameraController _cameraController;
private readonly Dictionary _exitMarkers = new Dictionary();
private readonly List _agents = new List();
private AgentEntry _followedAgent;
private bool _levelEnded;
private float _elapsedTime;
// UI Panels
private StatusPanelUI _statusPanel;
private FightPanelUI _fightPanel;
private AgentPanelUI _agentPanel;
private InfoPanelUI _infoPanel;
private void Awake()
{
// Static state, and the editor can skip domain reload between Play sessions - clear
// it so a stale pause hold from the last run can't freeze this one on startup.
SimulationClock.Reset();
_generator = GetComponent();
_generator.RoomGenerated += OnRoomGenerated;
_generator.ExitAdded += OnExitAdded;
_monsterSpawner = GetComponent();
// RequireComponent only auto-adds when the script is attached in the editor, so a
// scene saved before these existed would come up without them - add them if missing
// rather than null-referencing through the whole HUD.
_director = GetComponent() ?? gameObject.AddComponent();
_director.FightResolved += OnFightResolved;
var cam = Camera.main;
if (cam != null)
{
_cameraController = cam.GetComponent();
if (_cameraController == null)
_cameraController = cam.gameObject.AddComponent();
}
// Initialize UI panels
// Note: pass empty bounds to StatusPanelUI; ComputeMazeBounds() is called in the zoom callback
_statusPanel = new StatusPanelUI(targetFinishedAgentCount, timeLimitSeconds, _director, _cameraController, new Bounds(Vector3.zero, Vector3.one));
_fightPanel = new FightPanelUI(_director, _cameraController);
_agentPanel = new AgentPanelUI();
_infoPanel = new InfoPanelUI(_director);
if (targetFinishedAgentCount <= 0 && timeLimitSeconds <= 0f)
{
Debug.LogWarning("[MazeGen] No end condition configured (targetFinishedAgentCount and timeLimitSeconds are both 0) - defaulting targetFinishedAgentCount to agentCount so the level can still end.");
targetFinishedAgentCount = agentCount;
}
}
private void Start()
{
var settings = _generator.Settings;
int startRoomCount = Mathf.Max(1, settings.startRoomCount);
var startRooms = new List();
for (int i = 0; i < startRoomCount; i++)
{
Vector2 pos = startRoomCount > 1 ? PickRandomStartPosition(settings, startRooms) : Vector2.zero;
startRooms.Add(_generator.GenerateStartRoom(pos));
}
_cameraController?.FrameBounds(ComputeMazeBounds());
// Assign each agent to a start room, then spawn per-room with independent stagger
var assignments = AssignAgentsToStartRooms(agentCount, startRooms.Count);
for (int r = 0; r < startRooms.Count; r++)
StartCoroutine(SpawnAgentsForRoomRoutine(startRooms[r], assignments[r]));
}
///
/// Picks a uniformly random point within a disk of radius `startRoomSpread` (instead
/// of evenly around its edge), retrying a few times to avoid landing on top of a
/// start room already placed this call.
///
private static Vector2 PickRandomStartPosition(MazeGenerationSettings settings, List placed)
{
float spread = Mathf.Max(settings.startRoomSpread, 0.01f);
float minDistance = settings.startRoomRadius * 2f;
for (int attempt = 0; attempt < 30; attempt++)
{
Vector2 candidate = Random.insideUnitCircle * spread;
bool overlapsPlaced = false;
foreach (var room in placed)
{
if (Vector2.Distance(candidate, room.Position) < minDistance)
{
overlapsPlaced = true;
break;
}
}
if (!overlapsPlaced) return candidate;
}
return Random.insideUnitCircle * spread; // give up avoiding overlap, use whatever we last rolled
}
///
/// Randomly assigns each agent index (0..agentCount-1) to one of the start rooms, decided
/// upfront before any staggering/spawning begins, so the same statistical distribution as
/// today's "pick a random room per spawn" applies, but grouped so each room's agents can be
/// staggered independently and concurrently with other rooms.
///
private static List[] AssignAgentsToStartRooms(int agentCount, int roomCount)
{
var result = new List[roomCount];
for (int r = 0; r < roomCount; r++)
result[r] = new List();
for (int i = 0; i < agentCount; i++)
result[Random.Range(0, roomCount)].Add(i);
return result;
}
///
/// One independent instance of this runs per start room, all started concurrently from
/// Start(). Staggers only the agents assigned to THIS room by spawnStaggerRange, so agents
/// in different start rooms can spawn at the same wall-clock time while agents sharing a
/// room are still staggered relative to each other, same as before.
///
private IEnumerator SpawnAgentsForRoomRoutine(Room startRoom, List agentIndices)
{
foreach (int index in agentIndices)
{
float delay = Random.Range(spawnStaggerRange.x, spawnStaggerRange.y);
if (delay > 0f) yield return new WaitForSeconds(delay);
SpawnAgent(startRoom, index);
}
}
private void SpawnAgent(Room startRoom, int index)
{
var agentGO = new GameObject($"Agent_{index}");
agentGO.transform.SetParent(transform, false);
var controller = agentGO.AddComponent();
controller.Initialize(_generator);
// Golden-ratio hue spread gives visually distinct colors even for many agents.
Color color = Color.HSVToRGB((index * 0.618034f) % 1f, 0.65f, 1f);
controller.Spawn(startRoom, color);
var pathGO = new GameObject("Path");
pathGO.transform.SetParent(agentGO.transform, false);
pathGO.AddComponent();
var path = pathGO.AddComponent();
path.AppendPoint(startRoom.Position);
pathGO.SetActive(false); // only the followed agent's trail is shown
var brain = agentGO.AddComponent();
float initialDelay = Random.Range(initialDecisionDelayRange.x, initialDecisionDelayRange.y);
brain.Initialize(controller, _generator, _director, initialDelay, decisionInterval, exploreChance, retreatThreshold, () => _levelEnded);
// The director needs the roster to know who else is standing in a room when a fight
// breaks out there, and which color to draw each agent with in the arena.
_director.RegisterAgent(controller, color);
controller.Agent.RoomEntered += room => path.AppendPoint(room.Position);
controller.Agent.RoomEntered += _ => RefreshMarkerVisuals();
controller.Agent.RoomEntered += _ => CheckFinishCondition();
controller.Agent.ReachedExitRoom += room => Debug.Log($"[MazeGen] {controller.Agent.DisplayName} reached the exit room (id {room.Id})!");
_agents.Add(new AgentEntry { Controller = controller, Path = path, Color = color });
}
private void OnRoomGenerated(Room room)
{
var roomGO = new GameObject($"Room_{room.Id}_{room.Kind}");
roomGO.transform.SetParent(transform, false);
roomGO.AddComponent().Initialize(room);
foreach (var exit in room.Exits)
SpawnExitMarker(room, exit);
}
private void OnExitAdded(Room room, Exit exit)
{
SpawnExitMarker(room, exit);
RefreshMarkerVisuals();
}
private void SpawnExitMarker(Room room, Exit exit)
{
var markerGO = new GameObject($"Exit_{exit.Id}");
markerGO.transform.SetParent(transform, false);
var marker = markerGO.AddComponent();
marker.Initialize(room, exit);
_exitMarkers[exit.Id] = marker;
}
private void RefreshMarkerVisuals()
{
Room currentRoom = _followedAgent?.Controller.Agent?.CurrentRoom;
MazeAgent agent = _followedAgent?.Controller.Agent;
foreach (var marker in _exitMarkers.Values)
marker.UpdateVisual(currentRoom, agent);
}
/// A fight is over: drop the views of anything that died in it and recheck the level.
private void OnFightResolved(PendingFight fight)
{
_monsterSpawner?.RefreshRoom(fight.Room);
RefreshMarkerVisuals();
CheckFinishCondition();
}
private void Update()
{
if (MazeInput.WasPauseTogglePressed())
SimulationClock.UserPaused = !SimulationClock.UserPaused;
if (_levelEnded) return;
// The level clock is simulation time, so pausing to watch a fight doesn't burn it.
_elapsedTime += SimulationClock.DeltaTime;
if (timeLimitSeconds > 0f && _elapsedTime >= timeLimitSeconds)
EndLevel($"Time limit of {timeLimitSeconds:0.0}s reached.");
}
private void CheckFinishCondition()
{
if (_levelEnded) return;
int finished = _agents.Count(a => a.Controller.Agent.IsFinished);
if (targetFinishedAgentCount > 0 && finished >= targetFinishedAgentCount)
{
EndLevel($"{finished} agent(s) reached the exit.");
return;
}
if (_agents.Count > 0 && _agents.Count >= agentCount && _agents.All(a => a.Controller.Agent.IsDead || a.Controller.Agent.IsFinished))
EndLevel($"Every agent is dead or finished ({finished} reached the exit).");
}
private void EndLevel(string reason)
{
_levelEnded = true;
Debug.Log($"[MazeGen] LEVEL COMPLETE - {reason}");
}
private void FollowAgent(AgentEntry entry)
{
if (_followedAgent == entry) return;
StopFollowing();
_followedAgent = entry;
entry.Controller.SetFollowed(true);
entry.Path.gameObject.SetActive(true);
entry.Controller.Agent.RoomEntered += OnFollowedAgentMoved;
_cameraController?.FocusOn(entry.Controller.Agent.CurrentRoom.Position);
RefreshMarkerVisuals();
}
private void StopFollowing()
{
if (_followedAgent == null) return;
_followedAgent.Controller.SetFollowed(false);
_followedAgent.Path.gameObject.SetActive(false);
_followedAgent.Controller.Agent.RoomEntered -= OnFollowedAgentMoved;
_followedAgent = null;
RefreshMarkerVisuals();
}
private void OnFollowedAgentMoved(Room room) => _cameraController?.FocusOn(room.Position);
private Bounds ComputeMazeBounds()
{
var rooms = _generator.Graph.Rooms.Values;
if (rooms.Count == 0) return new Bounds(Vector3.zero, Vector3.one * 8f);
var bounds = new Bounds(rooms.First().Position, Vector3.zero);
foreach (var room in rooms)
bounds.Encapsulate(new Bounds(room.Position, Vector3.one * (room.Radius * 2f)));
return bounds;
}
// ---------------------------------------------------------------- HUD
private void OnGUI()
{
// The arena draws its own HUD and owns the screen while a fight is being watched.
if (_director != null && _director.IsWatching) return;
// Draw status panel (top-left)
_statusPanel?.Draw(
_elapsedTime,
_agents.Count,
_agents.Count(a => a.Controller.Agent.IsFinished),
_agents.Count(a => a.Controller.Agent.IsDead),
_levelEnded,
() => _cameraController?.FrameBounds(ComputeMazeBounds())
);
// Draw fight panel (center-top)
_fightPanel?.Draw();
// Draw agent list (left, below status)
var clickedAgent = _agentPanel?.Draw(
_agents.Select(a => a.Controller.Agent).ToList(),
_followedAgent?.Controller.Agent,
_followedAgent != null,
StopFollowing
);
if (clickedAgent != null)
{
var entry = _agents.FirstOrDefault(a => a.Controller.Agent == clickedAgent);
if (entry != null)
FollowAgent(entry);
}
// Draw info panel (right side)
_infoPanel?.Draw(_followedAgent?.Controller.Agent);
}
}
}