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; private Vector2 _hudScroll; private Vector2 _thoughtScroll; private Vector2 _combatLogScroll; private Vector2 _fightScroll; 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(); } 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; DrawStatusPanel(); DrawFightPanel(); DrawFollowedAgentPanel(); } private void DrawStatusPanel() { GUILayout.BeginArea(new Rect(10, 10, 280, Screen.height - 20), GUI.skin.box); string timeLabel = timeLimitSeconds > 0f ? $"{_elapsedTime:0.0}s / {timeLimitSeconds:0.0}s" : $"{_elapsedTime:0.0}s"; GUILayout.Label($"Elapsed: {timeLabel}"); int finishedCount = _agents.Count(a => a.Controller.Agent.IsFinished); string finishLabel = targetFinishedAgentCount > 0 ? $"{finishedCount} / {targetFinishedAgentCount}" : $"{finishedCount}"; GUILayout.Label($"Finished: {finishLabel} Dead: {_agents.Count(a => a.Controller.Agent.IsDead)}"); if (_levelEnded) GUILayout.Label("LEVEL COMPLETE", GUI.skin.box); GUILayout.Space(4); DrawSimulationControls(); GUILayout.Space(4); if (GUILayout.Button("Zoom to fit maze")) _cameraController?.FrameBounds(ComputeMazeBounds()); if (_followedAgent != null && GUILayout.Button("Stop following")) StopFollowing(); GUILayout.Space(6); GUILayout.Label("Agents (click to follow):"); _hudScroll = GUILayout.BeginScrollView(_hudScroll); foreach (var entry in _agents) { MazeAgent agent = entry.Controller.Agent; bool isFollowed = entry == _followedAgent; GUI.color = agent.IsDead ? new Color(0.55f, 0.55f, 0.55f) : entry.Color; if (GUILayout.Button($"{(isFollowed ? "> " : "")}{agent.DisplayName} - {DescribeStatus(agent)}")) FollowAgent(entry); } GUI.color = Color.white; GUILayout.EndScrollView(); GUILayout.EndArea(); } private void DrawSimulationControls() { bool paused = SimulationClock.IsPaused; GUILayout.Label(paused ? $"PAUSED ({SimulationClock.HoldSummary ?? "manual"})" : $"Running at {SimulationClock.TimeScale:0.0}x", GUI.skin.box); GUILayout.BeginHorizontal(); if (GUILayout.Button(SimulationClock.UserPaused ? "Resume (space)" : "Pause (space)")) SimulationClock.UserPaused = !SimulationClock.UserPaused; if (GUILayout.Button("0.5x")) SimulationClock.TimeScale = 0.5f; if (GUILayout.Button("1x")) SimulationClock.TimeScale = 1f; if (GUILayout.Button("4x")) SimulationClock.TimeScale = 4f; GUILayout.EndHorizontal(); _director.PauseOnFightDeclared = GUILayout.Toggle(_director.PauseOnFightDeclared, " Pause when a fight is declared"); GUILayout.BeginHorizontal(); GUILayout.Label("On join window end:", GUILayout.Width(120f)); bool watch = _director.TimeoutResolution == CombatViewMode.Watch; if (GUILayout.Button(watch ? "Watch fight" : "Log only")) _director.TimeoutResolution = watch ? CombatViewMode.Simulate : CombatViewMode.Watch; GUILayout.EndHorizontal(); } /// /// Live fights: the join-window countdown plus the controls to watch or fast-resolve. /// Drawn centre-top so a paused fight is impossible to miss. /// private void DrawFightPanel() { var fights = _director.ActiveFights; if (fights.Count == 0) return; float width = Mathf.Min(460f, Mathf.Max(280f, Screen.width - 720f)); GUILayout.BeginArea(new Rect(300f, 10f, width, Screen.height - 20f), GUI.skin.box); GUILayout.Label($"FIGHTS BREAKING OUT ({fights.Count})", GUI.skin.box); // The "resume" button stays outside the scroll view so it's always reachable even // when there are more fights than fit on screen at once. _fightScroll = GUILayout.BeginScrollView(_fightScroll); // Snapshot: resuming from this panel can resolve/cancel a fight, mutating the live list. foreach (var fight in fights.ToArray()) { GUILayout.BeginVertical(GUI.skin.box); GUILayout.Label($"#{fight.Id} in room {fight.Room.Id} - {fight.Agents.Count} agent(s) vs {fight.Encounter.AliveMonsterCount} monster(s)"); GUILayout.Label($"Starts in {Mathf.Max(0f, fight.JoinTimeRemaining):0.0}s - {(SimulationClock.IsPaused ? "frozen; resume to let the timer run out" : "others can still join")}"); GUILayout.Label($"In: {string.Join(", ", fight.Agents.Select(a => a.DisplayName))}"); // These only set a preference for when the join window naturally expires - they // never skip the timer, so late arrivals always get their full chance to join. CombatViewMode preferred = fight.PreferredResolution ?? _director.TimeoutResolution; GUILayout.Label($"When it starts: {(preferred == CombatViewMode.Watch ? "watch it" : "log only")}"); GUILayout.BeginHorizontal(); GUI.enabled = preferred != CombatViewMode.Watch; if (GUILayout.Button("Watch it")) _director.PreferWatch(fight); GUI.enabled = preferred != CombatViewMode.Simulate; if (GUILayout.Button("Log only")) _director.PreferSimulate(fight); GUI.enabled = true; if (GUILayout.Button("Go to")) _cameraController?.FocusOn(fight.Room.Position); GUILayout.EndHorizontal(); GUILayout.EndVertical(); } GUILayout.EndScrollView(); if (SimulationClock.IsPaused && GUILayout.Button("Resume - let the join window run")) { _director.ResumeAllMusters(); SimulationClock.UserPaused = false; } GUILayout.EndArea(); } /// /// "Agent thoughts and info" for the followed agent: its vitals plus its decision /// narration, newest first, and the shared combat log underneath. /// private void DrawFollowedAgentPanel() { float width = 400f; float x = Screen.width - width - 10f; float height = Screen.height - 20f; GUILayout.BeginArea(new Rect(x, 10f, width, height), GUI.skin.box); if (_followedAgent == null) { GUILayout.Label("AGENT THOUGHTS AND INFO", GUI.skin.box); GUILayout.Label("Click an agent on the left to follow it and read what it's thinking."); } else { MazeAgent agent = _followedAgent.Controller.Agent; CharacterSheet sheet = agent.CharacterSheet; GUI.color = _followedAgent.Color; GUILayout.Label($"{agent.DisplayName.ToUpperInvariant()} - {DescribeStatus(agent)}", GUI.skin.box); GUI.color = Color.white; GUILayout.Label($"Room {agent.CurrentRoom?.Id.ToString() ?? "?"} New rooms seen: {agent.NewRoomsVisited} Path: {agent.PathHistory.Count} hop(s)"); GUILayout.Label($"HP {sheet.Stats.CurrentHealth:0.#}/{sheet.Stats.MaxHealth:0.#} ATK {sheet.GetEffectiveAttack():0.#} DEF {sheet.GetEffectiveDefense():0.#} SPD {sheet.GetEffectiveSpeed():0.#}"); GUILayout.Label($"Weapon: {sheet.EquippedWeapon?.ItemName ?? "none"} Armor: {sheet.EquippedArmor?.ItemName ?? "none"}"); Room room = agent.CurrentRoom; int hostiles = room?.Encounter != null ? room.Encounter.AliveMonsterCount : 0; GUILayout.Label(hostiles > 0 ? $"This room: {hostiles} living hostile(s), threat {room.Encounter.GetPerceivedChallengeRating(sheet):0.00}" : "This room: clear"); GUILayout.Space(4); GUILayout.Label("Thoughts (newest first):"); _thoughtScroll = GUILayout.BeginScrollView(_thoughtScroll, GUILayout.Height(Mathf.Max(120f, height * 0.42f))); var thoughts = agent.Thoughts.Entries; for (int i = thoughts.Count - 1; i >= 0; i--) { Thought thought = thoughts[i]; GUI.color = AgentThoughtLog.ColorFor(thought.Kind); GUILayout.Label($"[{thought.Time:0.0}s] {thought.Text}"); } GUI.color = Color.white; GUILayout.EndScrollView(); } GUILayout.Space(4); GUILayout.Label("Combat log (all fights):"); _combatLogScroll = GUILayout.BeginScrollView(_combatLogScroll); var log = _director.CombatLog; for (int i = log.Count - 1; i >= 0; i--) GUILayout.Label(log[i]); GUILayout.EndScrollView(); GUILayout.EndArea(); } private string DescribeStatus(MazeAgent agent) { if (agent.IsDead) return "dead"; if (agent.IsFinished) return "reached exit"; if (agent.IsEngaged) return "fighting"; return $"exploring ({agent.CharacterSheet.Stats.CurrentHealth:0}hp)"; } } }