| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529 |
- using System.Collections;
- using System.Collections.Generic;
- using System.Linq;
- using UnityEngine;
- namespace MazeGen
- {
- /// <summary>
- /// 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.
- /// </summary>
- [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<int, ExitMarker> _exitMarkers = new Dictionary<int, ExitMarker>();
- private readonly List<AgentEntry> _agents = new List<AgentEntry>();
- 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<MazeGenerator>();
- _generator.RoomGenerated += OnRoomGenerated;
- _generator.ExitAdded += OnExitAdded;
- _monsterSpawner = GetComponent<MonsterSpawner>();
- // 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<CombatDirector>() ?? gameObject.AddComponent<CombatDirector>();
- _director.FightResolved += OnFightResolved;
- var cam = Camera.main;
- if (cam != null)
- {
- _cameraController = cam.GetComponent<MazeCameraController>();
- if (_cameraController == null)
- _cameraController = cam.gameObject.AddComponent<MazeCameraController>();
- }
- 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<Room>();
- 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]));
- }
- /// <summary>
- /// 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.
- /// </summary>
- private static Vector2 PickRandomStartPosition(MazeGenerationSettings settings, List<Room> 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
- }
- /// <summary>
- /// 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.
- /// </summary>
- private static List<int>[] AssignAgentsToStartRooms(int agentCount, int roomCount)
- {
- var result = new List<int>[roomCount];
- for (int r = 0; r < roomCount; r++)
- result[r] = new List<int>();
- for (int i = 0; i < agentCount; i++)
- result[Random.Range(0, roomCount)].Add(i);
- return result;
- }
- /// <summary>
- /// 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.
- /// </summary>
- private IEnumerator SpawnAgentsForRoomRoutine(Room startRoom, List<int> 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<MazeAgentController>();
- 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<LineRenderer>();
- var path = pathGO.AddComponent<PathVisualizer>();
- path.AppendPoint(startRoom.Position);
- pathGO.SetActive(false); // only the followed agent's trail is shown
- var brain = agentGO.AddComponent<AutonomousAgentBrain>();
- 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<RoomView>().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<ExitMarker>();
- 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);
- }
- /// <summary>A fight is over: drop the views of anything that died in it and recheck the level.</summary>
- 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();
- }
- /// <summary>
- /// 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.
- /// </summary>
- 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();
- }
- /// <summary>
- /// "Agent thoughts and info" for the followed agent: its vitals plus its decision
- /// narration, newest first, and the shared combat log underneath.
- /// </summary>
- 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)";
- }
- }
- }
|