Monster.cs 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365
  1. using UnityEngine;
  2. using System.Collections;
  3. using System.Collections.Generic;
  4. /// <summary>
  5. /// A monster that lives in a room.
  6. /// Behaviour:
  7. /// - Idles / wanders aimlessly within its spawn room when no agents are present.
  8. /// - When an agent enters the room, closes in to melee range and attacks each tick.
  9. /// - Each monster has its own health pool and 1d4 fist weapon.
  10. /// - When killed it fires MonsterKilled and destroys itself.
  11. /// - Represented as a red sphere identical in size to a solo agent.
  12. /// </summary>
  13. public class Monster : MonoBehaviour
  14. {
  15. // ------------------------------------------------------------------ //
  16. // Inspector //
  17. // ------------------------------------------------------------------ //
  18. [Header("Stats")]
  19. [SerializeField] private int maxHealth = 10;
  20. [SerializeField] private float movementSpeed = 1.5f;
  21. [Header("Combat")]
  22. [SerializeField] private float attackCooldown = 1.2f; // seconds between attacks
  23. [SerializeField] private float aggroRange = 12f; // range at which monsters notice agents entering
  24. [Header("Wander")]
  25. [SerializeField] private float wanderRadius = 3f;
  26. [SerializeField] private float wanderInterval = 2.5f;
  27. // ------------------------------------------------------------------ //
  28. // Runtime state //
  29. // ------------------------------------------------------------------ //
  30. private int currentHealth;
  31. private Weapon weapon;
  32. private MazeRoom homeRoom; // The room this monster belongs to
  33. private MazeData maze;
  34. private AIAgent target; // Current attack target
  35. private AIAgent previousTarget; // Track previous target to detect combat start/end
  36. private float lastAttackTime;
  37. private float lastWanderTime;
  38. private Vector3 wanderTarget;
  39. private bool isDead;
  40. // ------------------------------------------------------------------ //
  41. // Public interface //
  42. // ------------------------------------------------------------------ //
  43. /// <summary>
  44. /// Difficulty modifier applied during spawn (see MonsterSpawner).
  45. /// Higher = more health and slightly faster.
  46. /// </summary>
  47. public float DifficultyMultiplier { get; private set; } = 1f;
  48. public int MaxHealth => maxHealth;
  49. public int CurrentHealth => currentHealth;
  50. public bool IsDead => isDead;
  51. public MazeRoom HomeRoom => homeRoom;
  52. /// <summary>
  53. /// Convenience: threat value used by agents to estimate room danger.
  54. /// Sum of all alive monster health in a room.
  55. /// </summary>
  56. public int ThreatValue => isDead ? 0 : currentHealth;
  57. /// <summary>Fires when this monster is killed. Passes itself as argument.</summary>
  58. public System.Action<Monster> OnMonsterKilled;
  59. // ------------------------------------------------------------------ //
  60. // Initialisation //
  61. // ------------------------------------------------------------------ //
  62. /// <summary>
  63. /// Called by MonsterSpawner immediately after instantiation.
  64. /// </summary>
  65. public void Init(MazeRoom room, MazeData mazeData, float difficultyMultiplier = 1f)
  66. {
  67. homeRoom = room;
  68. maze = mazeData;
  69. DifficultyMultiplier = difficultyMultiplier;
  70. maxHealth = Mathf.RoundToInt(maxHealth * difficultyMultiplier);
  71. currentHealth = maxHealth;
  72. movementSpeed = movementSpeed * Mathf.Lerp(1f, 1.3f, difficultyMultiplier - 1f);
  73. weapon = Weapon.Fists();
  74. wanderTarget = transform.position;
  75. lastWanderTime = Time.time;
  76. }
  77. // ------------------------------------------------------------------ //
  78. // Unity loop //
  79. // ------------------------------------------------------------------ //
  80. void Update()
  81. {
  82. if (maze == null) return;
  83. // Dead bodies persist forever - just stop updating logic
  84. if (isDead)
  85. {
  86. return;
  87. }
  88. AcquireTarget();
  89. if (target != null)
  90. CombatUpdate();
  91. else
  92. WanderUpdate();
  93. }
  94. // ------------------------------------------------------------------ //
  95. // Target acquisition //
  96. // ------------------------------------------------------------------ //
  97. private void AcquireTarget()
  98. {
  99. // Drop dead targets
  100. if (target != null && (target == null || target.IsDead || target.HasReachedGoal))
  101. {
  102. // Report fight end to tracker
  103. if (target != null)
  104. FightTracker.Instance.EndFight(target, this);
  105. target = null;
  106. }
  107. if (target != null) return;
  108. // Find the nearest live agent within aggro range
  109. float bestDist = aggroRange * aggroRange;
  110. AIAgent best = null;
  111. foreach (var agent in FindObjectsByType<AIAgent>(FindObjectsSortMode.None))
  112. {
  113. if (agent.IsDead) continue;
  114. // Only aggro agents that are in or entering this room
  115. Vector2Int agentTile = WorldToTile(agent.transform.position);
  116. MazeRoom agentRoom = maze.GetRoomAtTile(agentTile.x, agentTile.y);
  117. if (agentRoom == null || agentRoom.Id != homeRoom.Id) continue;
  118. float sqDist = (agent.transform.position - transform.position).sqrMagnitude;
  119. if (sqDist < bestDist)
  120. {
  121. bestDist = sqDist;
  122. best = agent;
  123. }
  124. }
  125. // Report fight start if target changed
  126. if (best != null && best != previousTarget)
  127. {
  128. FightTracker.Instance.StartFight(best, this);
  129. }
  130. target = best;
  131. previousTarget = best;
  132. }
  133. // ------------------------------------------------------------------ //
  134. // Combat //
  135. // ------------------------------------------------------------------ //
  136. private void CombatUpdate()
  137. {
  138. float dist = Vector3.Distance(transform.position, target.transform.position);
  139. if (dist > weapon.MeleeRange)
  140. {
  141. // Close in
  142. Vector3 dir = (target.transform.position - transform.position).normalized;
  143. transform.position += dir * movementSpeed * Time.deltaTime;
  144. }
  145. else
  146. {
  147. // Attack
  148. if (Time.time - lastAttackTime >= attackCooldown)
  149. {
  150. lastAttackTime = Time.time;
  151. PerformAttack();
  152. }
  153. }
  154. }
  155. private void PerformAttack()
  156. {
  157. if (target == null || target.IsDead) return;
  158. // Monsters use base hit chance — no advantage modifier
  159. if (weapon.TryHit(1f))
  160. {
  161. int dmg = weapon.RollDamage();
  162. target.TakeDamage(dmg, this);
  163. Debug.Log($"[Monster] hit {target.AgentName} for {dmg} (HP left: {target.CurrentHealth})");
  164. }
  165. else
  166. {
  167. Debug.Log($"[Monster] missed {target.AgentName}");
  168. }
  169. }
  170. // ------------------------------------------------------------------ //
  171. // Wander (no target) //
  172. // ------------------------------------------------------------------ //
  173. private void WanderUpdate()
  174. {
  175. // Move toward wander target
  176. if (Vector3.Distance(transform.position, wanderTarget) > 0.2f)
  177. {
  178. Vector3 dir = (wanderTarget - transform.position).normalized;
  179. transform.position += dir * (movementSpeed * 0.5f) * Time.deltaTime;
  180. }
  181. // Pick new wander target periodically
  182. if (Time.time - lastWanderTime > wanderInterval)
  183. {
  184. lastWanderTime = Time.time;
  185. PickNewWanderTarget();
  186. }
  187. }
  188. private void PickNewWanderTarget()
  189. {
  190. if (homeRoom == null) return;
  191. // Random tile inside home room
  192. int x = Random.Range(homeRoom.MinX + 1, homeRoom.MaxX);
  193. int y = Random.Range(homeRoom.MinY + 1, homeRoom.MaxY);
  194. wanderTarget = new Vector3(x + 0.5f, 1f, y + 0.5f);
  195. }
  196. // ------------------------------------------------------------------ //
  197. // Damage / death //
  198. // ------------------------------------------------------------------ //
  199. /// <summary>
  200. /// Deal damage to this monster. Called by agents during their attack.
  201. /// Returns true if this hit killed the monster.
  202. /// </summary>
  203. public bool TakeDamage(int amount)
  204. {
  205. if (isDead) return false;
  206. currentHealth -= amount;
  207. if (currentHealth <= 0)
  208. {
  209. Die();
  210. return true;
  211. }
  212. return false;
  213. }
  214. private void Die()
  215. {
  216. if (isDead) return;
  217. isDead = true;
  218. currentHealth = 0;
  219. // Report fight end
  220. if (target != null)
  221. FightTracker.Instance.EndFight(target, this);
  222. // Change visual to show dead (gray/dark sphere with red X)
  223. var renderer = GetComponent<Renderer>();
  224. if (renderer != null)
  225. {
  226. var material = new Material(renderer.material);
  227. material.color = new Color(0.3f, 0.3f, 0.3f, 0.9f); // Dark gray, opaque
  228. renderer.material = material;
  229. }
  230. // Add bright red X marker BEFORE disabling (must happen while enabled)
  231. AddDeadMarker();
  232. // Disable movement and collider AFTER adding marker
  233. enabled = false;
  234. Debug.Log($"[Monster] in room {homeRoom?.Id} died.");
  235. OnMonsterKilled?.Invoke(this);
  236. }
  237. /// <summary>
  238. /// Adds a bright red X marker to indicate the body is dead.
  239. /// Uses a child GameObject for cleanliness.
  240. /// </summary>
  241. private void AddDeadMarker()
  242. {
  243. try
  244. {
  245. if (gameObject == null)
  246. return;
  247. var markerGO = new GameObject("DeadMarker");
  248. markerGO.transform.SetParent(transform, worldPositionStays: false);
  249. markerGO.transform.localPosition = Vector3.zero;
  250. var lineRenderer = markerGO.AddComponent<LineRenderer>();
  251. if (lineRenderer == null)
  252. {
  253. Debug.LogWarning("Failed to add LineRenderer component");
  254. return;
  255. }
  256. // Set material first before configuring the renderer
  257. Shader lineShader = Shader.Find("Unlit/Color");
  258. if (lineShader == null)
  259. lineShader = Shader.Find("Sprites/Default");
  260. if (lineShader == null)
  261. lineShader = Shader.Find("Standard");
  262. if (lineShader != null)
  263. {
  264. lineRenderer.material = new Material(lineShader);
  265. }
  266. else
  267. {
  268. Debug.LogWarning("No suitable shader found for dead marker");
  269. return;
  270. }
  271. // Now configure the line
  272. lineRenderer.positionCount = 4;
  273. lineRenderer.useWorldSpace = true;
  274. float offset = 0.35f;
  275. // Lift the X marker above the body so the top-down camera can see it
  276. Vector3 markerBase = transform.position + Vector3.up * 0.6f;
  277. // Draw a bright red X in XZ plane (visible from top-down camera)
  278. lineRenderer.SetPosition(0, markerBase + Vector3.left * offset + Vector3.forward * offset);
  279. lineRenderer.SetPosition(1, markerBase + Vector3.right * offset + Vector3.back * offset);
  280. lineRenderer.SetPosition(2, markerBase + Vector3.right * offset + Vector3.forward * offset);
  281. lineRenderer.SetPosition(3, markerBase + Vector3.left * offset + Vector3.back * offset);
  282. lineRenderer.startWidth = 0.15f;
  283. lineRenderer.endWidth = 0.15f;
  284. lineRenderer.startColor = new Color(1f, 0f, 0f, 1f); // Bright red
  285. lineRenderer.endColor = new Color(1f, 0f, 0f, 1f);
  286. }
  287. catch (System.Exception ex)
  288. {
  289. Debug.LogWarning($"Failed to add dead marker: {ex.Message}\n{ex.StackTrace}");
  290. }
  291. }
  292. // ------------------------------------------------------------------ //
  293. // Helpers //
  294. // ------------------------------------------------------------------ //
  295. private Vector2Int WorldToTile(Vector3 worldPos)
  296. => new Vector2Int(Mathf.FloorToInt(worldPos.x), Mathf.FloorToInt(worldPos.z));
  297. // ------------------------------------------------------------------ //
  298. // Mouse interaction – click to inspect (future) //
  299. // ------------------------------------------------------------------ //
  300. void OnMouseDown()
  301. {
  302. Debug.Log($"[Monster] Room {homeRoom?.Id} | HP {currentHealth}/{maxHealth} | Difficulty ×{DifficultyMultiplier:F1}");
  303. }
  304. }