MonsterSpawner.cs 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297
  1. using UnityEngine;
  2. using System.Collections.Generic;
  3. using System.Linq;
  4. /// <summary>
  5. /// Spawns monsters into the maze based on MazeConfig settings.
  6. ///
  7. /// Spawning rules
  8. /// ─────────────
  9. /// • Monster rooms (RoomType.Boss + Normal rooms chosen by MonsterAreaDensity %)
  10. /// → 2–4 monsters, difficulty multiplier scales with room distance from start.
  11. ///
  12. /// • Hallway tiles (walkable tiles not inside any room)
  13. /// → sparse single monsters; much lower density, difficulty ≤ 1.
  14. ///
  15. /// Monster prefab is a red sphere identical in size to a solo agent.
  16. ///
  17. /// Exposes a static room-threat lookup so AIAgents can query danger before entering.
  18. /// </summary>
  19. public class MonsterSpawner : MonoBehaviour
  20. {
  21. // ------------------------------------------------------------------ //
  22. // Inspector //
  23. // ------------------------------------------------------------------ //
  24. [Header("Prefab (auto-created if null)")]
  25. [SerializeField] private GameObject monsterPrefab;
  26. [Header("Room spawn")]
  27. [Tooltip("Min monsters in a monster room")]
  28. [SerializeField] private int minMonstersPerRoom = 2;
  29. [Tooltip("Max monsters in a monster room")]
  30. [SerializeField] private int maxMonstersPerRoom = 4;
  31. [Tooltip("Extra monsters added per Boss room")]
  32. [SerializeField] private int bossRoomBonusMonsters = 3;
  33. [Tooltip("Difficulty multiplier range for deepest rooms (1 = weakest, 2 = strongest)")]
  34. [SerializeField] private float maxDifficultyMultiplier = 2f;
  35. [Header("Hallway spawn")]
  36. [Tooltip("Probability that any given hallway tile spawns a monster (0–1)")]
  37. [SerializeField][Range(0f, 0.05f)] private float hallwaySpawnChance = 0.005f;
  38. [Tooltip("Difficulty multiplier cap for hallway monsters")]
  39. [SerializeField] private float hallwayMaxDifficulty = 1f;
  40. // ------------------------------------------------------------------ //
  41. // Runtime //
  42. // ------------------------------------------------------------------ //
  43. private MazeData maze;
  44. private MazeConfig config;
  45. /// <summary>
  46. /// Room ID → list of living monsters. Agents can query this to assess danger.
  47. /// Hallway monsters are stored under key -1.
  48. /// </summary>
  49. private static readonly Dictionary<int, List<Monster>> roomMonsters = new();
  50. // ------------------------------------------------------------------ //
  51. // Public entry point – called by MazeController after generation //
  52. // ------------------------------------------------------------------ //
  53. /// <summary>
  54. /// Called by MazeController once the maze has been fully generated.
  55. /// Destroys any previously spawned monsters and re-spawns for the new maze.
  56. /// </summary>
  57. public void SpawnForMaze(MazeData mazeData, MazeConfig mazeConfig)
  58. {
  59. // Destroy any monsters left over from a previous maze
  60. foreach (var m in FindObjectsByType<Monster>())
  61. Destroy(m.gameObject);
  62. maze = mazeData;
  63. config = mazeConfig;
  64. if (maze == null) { Debug.LogError("MonsterSpawner: Maze is null!"); return; }
  65. if (monsterPrefab == null) monsterPrefab = CreateDefaultMonsterPrefab();
  66. roomMonsters.Clear();
  67. SpawnRoomMonsters();
  68. SpawnHallwayMonsters();
  69. Debug.Log("[MonsterSpawner] Spawning complete.");
  70. }
  71. // ------------------------------------------------------------------ //
  72. // Public query API //
  73. // ------------------------------------------------------------------ //
  74. /// <summary>
  75. /// Total threat in a room (sum of living-monster health).
  76. /// Returns 0 for rooms with no monsters or already cleared.
  77. /// </summary>
  78. public static int GetRoomThreat(int roomId)
  79. {
  80. if (!roomMonsters.TryGetValue(roomId, out var list)) return 0;
  81. return list.Where(m => m != null && !m.IsDead).Sum(m => m.ThreatValue);
  82. }
  83. /// <summary>How many living monsters are in a room.</summary>
  84. public static int GetLivingMonsterCount(int roomId)
  85. {
  86. if (!roomMonsters.TryGetValue(roomId, out var list)) return 0;
  87. return list.Count(m => m != null && !m.IsDead);
  88. }
  89. // ------------------------------------------------------------------ //
  90. // Spawning //
  91. // ------------------------------------------------------------------ //
  92. private void SpawnRoomMonsters()
  93. {
  94. if (!config.UseMonsterAreas) return;
  95. var allRooms = maze.Rooms;
  96. if (allRooms.Count == 0) return;
  97. // Build a "depth" estimate: BFS distance from start rooms.
  98. var depthMap = BuildRoomDepthMap();
  99. float maxDepth = depthMap.Values.Count > 0 ? depthMap.Values.Max() : 1f;
  100. // Rooms eligible for monsters
  101. var eligibleRooms = allRooms.Where(r =>
  102. r.Type != MazeRoom.RoomType.Safe &&
  103. r.Type != MazeRoom.RoomType.End &&
  104. !r.IsStart
  105. ).ToList();
  106. // Filter down by MonsterAreaDensity %
  107. int targetCount = Mathf.RoundToInt(eligibleRooms.Count * (config.MonsterAreaDensity / 100f));
  108. Shuffle(eligibleRooms);
  109. var chosenRooms = eligibleRooms.Take(targetCount).ToList();
  110. // Always include all Boss rooms in the chosen set
  111. foreach (var boss in allRooms.Where(r => r.Type == MazeRoom.RoomType.Boss))
  112. if (!chosenRooms.Contains(boss)) chosenRooms.Add(boss);
  113. foreach (var room in chosenRooms)
  114. {
  115. int count = Random.Range(minMonstersPerRoom, maxMonstersPerRoom + 1);
  116. if (room.Type == MazeRoom.RoomType.Boss) count += bossRoomBonusMonsters;
  117. // Difficulty: rooms farther from start are harder
  118. float depth = depthMap.TryGetValue(room.Id, out float d) ? d : 0f;
  119. float t = maxDepth > 0 ? depth / maxDepth : 0f;
  120. float difficulty = Mathf.Lerp(1f, maxDifficultyMultiplier, t);
  121. SpawnMonstersInRoom(room, count, difficulty);
  122. }
  123. }
  124. private void SpawnHallwayMonsters()
  125. {
  126. if (!config.UseMonsterAreas) return;
  127. // Iterate walkable tiles that are NOT inside any room
  128. for (int x = 0; x < maze.Width; x++)
  129. {
  130. for (int y = 0; y < maze.Height; y++)
  131. {
  132. if (!maze.IsWalkable(x, y)) continue;
  133. if (maze.GetRoomAtTile(x, y) != null) continue; // skip room tiles
  134. if (Random.value > hallwaySpawnChance) continue;
  135. float difficulty = Random.Range(1f, hallwayMaxDifficulty);
  136. SpawnSingleMonster(null, new Vector3(x + 0.5f, 1f, y + 0.5f), difficulty);
  137. }
  138. }
  139. }
  140. private void SpawnMonstersInRoom(MazeRoom room, int count, float difficulty)
  141. {
  142. for (int i = 0; i < count; i++)
  143. {
  144. Vector2Int tile = room.GetRandomPoint();
  145. Vector3 worldPos = new Vector3(tile.x + 0.5f, 1f, tile.y + 0.5f);
  146. SpawnSingleMonster(room, worldPos, difficulty);
  147. }
  148. }
  149. private void SpawnSingleMonster(MazeRoom room, Vector3 worldPos, float difficulty)
  150. {
  151. GameObject go = Instantiate(monsterPrefab, worldPos, Quaternion.Euler(90, 0, 0));
  152. var monster = go.GetComponent<Monster>();
  153. if (monster == null) monster = go.AddComponent<Monster>();
  154. // Use a placeholder room for hallway monsters so Init doesn't crash
  155. MazeRoom spawnRoom = room ?? new MazeRoom(-1, 0, 0, 0, 0);
  156. monster.Init(spawnRoom, maze, difficulty);
  157. // Track in lookup
  158. int key = room?.Id ?? -1;
  159. if (!roomMonsters.ContainsKey(key)) roomMonsters[key] = new List<Monster>();
  160. roomMonsters[key].Add(monster);
  161. // Remove from list on death
  162. monster.OnMonsterKilled += m => { roomMonsters[key]?.Remove(m); };
  163. }
  164. // ------------------------------------------------------------------ //
  165. // Helpers //
  166. // ------------------------------------------------------------------ //
  167. /// <summary>BFS from all start-rooms to compute depth per room.</summary>
  168. private Dictionary<int, float> BuildRoomDepthMap()
  169. {
  170. var depth = new Dictionary<int, float>();
  171. var queue = new Queue<int>();
  172. foreach (var room in maze.Rooms.Where(r => r.IsStart))
  173. {
  174. depth[room.Id] = 0f;
  175. queue.Enqueue(room.Id);
  176. }
  177. // Build adjacency via room connectivity (rooms that share hallway tiles)
  178. var adj = BuildRoomAdjacency();
  179. while (queue.Count > 0)
  180. {
  181. int id = queue.Dequeue();
  182. if (!adj.TryGetValue(id, out var neighbours)) continue;
  183. foreach (int nId in neighbours)
  184. {
  185. if (!depth.ContainsKey(nId))
  186. {
  187. depth[nId] = depth[id] + 1f;
  188. queue.Enqueue(nId);
  189. }
  190. }
  191. }
  192. return depth;
  193. }
  194. private Dictionary<int, List<int>> BuildRoomAdjacency()
  195. {
  196. var adj = new Dictionary<int, List<int>>();
  197. foreach (var room in maze.Rooms)
  198. adj[room.Id] = new List<int>();
  199. // For each walkable non-room tile, check if it borders two different rooms
  200. for (int x = 0; x < maze.Width; x++)
  201. {
  202. for (int y = 0; y < maze.Height; y++)
  203. {
  204. if (!maze.IsWalkable(x, y)) continue;
  205. MazeRoom r1 = maze.GetRoomAtTile(x, y);
  206. if (r1 == null) continue;
  207. Vector2Int[] dirs = { new(x + 1, y), new(x - 1, y), new(x, y + 1), new(x, y - 1) };
  208. foreach (var d in dirs)
  209. {
  210. if (!maze.IsInBounds(d.x, d.y) || !maze.IsWalkable(d.x, d.y)) continue;
  211. MazeRoom r2 = maze.GetRoomAtTile(d.x, d.y);
  212. if (r2 == null || r2.Id == r1.Id) continue;
  213. if (!adj[r1.Id].Contains(r2.Id)) adj[r1.Id].Add(r2.Id);
  214. if (!adj[r2.Id].Contains(r1.Id)) adj[r2.Id].Add(r1.Id);
  215. }
  216. }
  217. }
  218. return adj;
  219. }
  220. private static void Shuffle<T>(List<T> list)
  221. {
  222. for (int i = list.Count - 1; i > 0; i--)
  223. {
  224. int j = Random.Range(0, i + 1);
  225. (list[i], list[j]) = (list[j], list[i]);
  226. }
  227. }
  228. // ------------------------------------------------------------------ //
  229. // Prefab factory //
  230. // ------------------------------------------------------------------ //
  231. private GameObject CreateDefaultMonsterPrefab()
  232. {
  233. var go = new GameObject("MonsterPrefab");
  234. go.transform.localScale = Vector3.one * 0.5f;
  235. go.AddComponent<SphereCollider>().radius = 1f;
  236. var mf = go.AddComponent<MeshFilter>();
  237. mf.mesh = Resources.GetBuiltinResource<Mesh>("Sphere.fbx");
  238. var mr = go.AddComponent<MeshRenderer>();
  239. var mat = new Material(Shader.Find("Universal Render Pipeline/Lit")
  240. ?? Shader.Find("Standard"));
  241. mat.color = Color.red;
  242. mr.material = mat;
  243. go.AddComponent<Monster>();
  244. return go;
  245. }
  246. }