using UnityEngine;
using System.Collections;
using System.Collections.Generic;
///
/// A monster that lives in a room.
/// Behaviour:
/// - Idles / wanders aimlessly within its spawn room when no agents are present.
/// - When an agent enters the room, closes in to melee range and attacks each tick.
/// - Each monster has its own health pool and 1d4 fist weapon.
/// - When killed it fires MonsterKilled and destroys itself.
/// - Represented as a red sphere identical in size to a solo agent.
///
public class Monster : MonoBehaviour
{
// ------------------------------------------------------------------ //
// Inspector //
// ------------------------------------------------------------------ //
[Header("Stats")]
[SerializeField] private int maxHealth = 10;
[SerializeField] private float movementSpeed = 1.5f;
[Header("Combat")]
[SerializeField] private float attackCooldown = 1.2f; // seconds between attacks
[SerializeField] private float aggroRange = 12f; // range at which monsters notice agents entering
[Header("Wander")]
[SerializeField] private float wanderRadius = 3f;
[SerializeField] private float wanderInterval = 2.5f;
// ------------------------------------------------------------------ //
// Runtime state //
// ------------------------------------------------------------------ //
private int currentHealth;
private Weapon weapon;
private MazeRoom homeRoom; // The room this monster belongs to
private MazeData maze;
private AIAgent target; // Current attack target
private AIAgent previousTarget; // Track previous target to detect combat start/end
private float lastAttackTime;
private float lastWanderTime;
private Vector3 wanderTarget;
private bool isDead;
// ------------------------------------------------------------------ //
// Public interface //
// ------------------------------------------------------------------ //
///
/// Difficulty modifier applied during spawn (see MonsterSpawner).
/// Higher = more health and slightly faster.
///
public float DifficultyMultiplier { get; private set; } = 1f;
public int MaxHealth => maxHealth;
public int CurrentHealth => currentHealth;
public bool IsDead => isDead;
public MazeRoom HomeRoom => homeRoom;
///
/// Convenience: threat value used by agents to estimate room danger.
/// Sum of all alive monster health in a room.
///
public int ThreatValue => isDead ? 0 : currentHealth;
/// Fires when this monster is killed. Passes itself as argument.
public System.Action OnMonsterKilled;
// ------------------------------------------------------------------ //
// Initialisation //
// ------------------------------------------------------------------ //
///
/// Called by MonsterSpawner immediately after instantiation.
///
public void Init(MazeRoom room, MazeData mazeData, float difficultyMultiplier = 1f)
{
homeRoom = room;
maze = mazeData;
DifficultyMultiplier = difficultyMultiplier;
maxHealth = Mathf.RoundToInt(maxHealth * difficultyMultiplier);
currentHealth = maxHealth;
movementSpeed = movementSpeed * Mathf.Lerp(1f, 1.3f, difficultyMultiplier - 1f);
weapon = Weapon.Fists();
wanderTarget = transform.position;
lastWanderTime = Time.time;
}
// ------------------------------------------------------------------ //
// Unity loop //
// ------------------------------------------------------------------ //
void Update()
{
if (maze == null) return;
// Dead bodies persist forever - just stop updating logic
if (isDead)
{
return;
}
AcquireTarget();
if (target != null)
CombatUpdate();
else
WanderUpdate();
}
// ------------------------------------------------------------------ //
// Target acquisition //
// ------------------------------------------------------------------ //
private void AcquireTarget()
{
// Drop dead targets
if (target != null && (target == null || target.IsDead || target.HasReachedGoal))
{
// Report fight end to tracker
if (target != null)
FightTracker.Instance.EndFight(target, this);
target = null;
}
if (target != null) return;
// Find the nearest live agent within aggro range
float bestDist = aggroRange * aggroRange;
AIAgent best = null;
foreach (var agent in FindObjectsByType(FindObjectsSortMode.None))
{
if (agent.IsDead) continue;
// Only aggro agents that are in or entering this room
Vector2Int agentTile = WorldToTile(agent.transform.position);
MazeRoom agentRoom = maze.GetRoomAtTile(agentTile.x, agentTile.y);
if (agentRoom == null || agentRoom.Id != homeRoom.Id) continue;
float sqDist = (agent.transform.position - transform.position).sqrMagnitude;
if (sqDist < bestDist)
{
bestDist = sqDist;
best = agent;
}
}
// Report fight start if target changed
if (best != null && best != previousTarget)
{
FightTracker.Instance.StartFight(best, this);
}
target = best;
previousTarget = best;
}
// ------------------------------------------------------------------ //
// Combat //
// ------------------------------------------------------------------ //
private void CombatUpdate()
{
float dist = Vector3.Distance(transform.position, target.transform.position);
if (dist > weapon.MeleeRange)
{
// Close in
Vector3 dir = (target.transform.position - transform.position).normalized;
transform.position += dir * movementSpeed * Time.deltaTime;
}
else
{
// Attack
if (Time.time - lastAttackTime >= attackCooldown)
{
lastAttackTime = Time.time;
PerformAttack();
}
}
}
private void PerformAttack()
{
if (target == null || target.IsDead) return;
// Monsters use base hit chance — no advantage modifier
if (weapon.TryHit(1f))
{
int dmg = weapon.RollDamage();
target.TakeDamage(dmg, this);
Debug.Log($"[Monster] hit {target.AgentName} for {dmg} (HP left: {target.CurrentHealth})");
}
else
{
Debug.Log($"[Monster] missed {target.AgentName}");
}
}
// ------------------------------------------------------------------ //
// Wander (no target) //
// ------------------------------------------------------------------ //
private void WanderUpdate()
{
// Move toward wander target
if (Vector3.Distance(transform.position, wanderTarget) > 0.2f)
{
Vector3 dir = (wanderTarget - transform.position).normalized;
transform.position += dir * (movementSpeed * 0.5f) * Time.deltaTime;
}
// Pick new wander target periodically
if (Time.time - lastWanderTime > wanderInterval)
{
lastWanderTime = Time.time;
PickNewWanderTarget();
}
}
private void PickNewWanderTarget()
{
if (homeRoom == null) return;
// Random tile inside home room
int x = Random.Range(homeRoom.MinX + 1, homeRoom.MaxX);
int y = Random.Range(homeRoom.MinY + 1, homeRoom.MaxY);
wanderTarget = new Vector3(x + 0.5f, 1f, y + 0.5f);
}
// ------------------------------------------------------------------ //
// Damage / death //
// ------------------------------------------------------------------ //
///
/// Deal damage to this monster. Called by agents during their attack.
/// Returns true if this hit killed the monster.
///
public bool TakeDamage(int amount)
{
if (isDead) return false;
currentHealth -= amount;
if (currentHealth <= 0)
{
Die();
return true;
}
return false;
}
private void Die()
{
if (isDead) return;
isDead = true;
currentHealth = 0;
// Report fight end
if (target != null)
FightTracker.Instance.EndFight(target, this);
// Change visual to show dead (gray/dark sphere with red X)
var renderer = GetComponent();
if (renderer != null)
{
var material = new Material(renderer.material);
material.color = new Color(0.3f, 0.3f, 0.3f, 0.9f); // Dark gray, opaque
renderer.material = material;
}
// Add bright red X marker BEFORE disabling (must happen while enabled)
AddDeadMarker();
// Disable movement and collider AFTER adding marker
enabled = false;
Debug.Log($"[Monster] in room {homeRoom?.Id} died.");
OnMonsterKilled?.Invoke(this);
}
///
/// Adds a bright red X marker to indicate the body is dead.
/// Uses a child GameObject for cleanliness.
///
private void AddDeadMarker()
{
try
{
if (gameObject == null)
return;
var markerGO = new GameObject("DeadMarker");
markerGO.transform.SetParent(transform, worldPositionStays: false);
markerGO.transform.localPosition = Vector3.zero;
var lineRenderer = markerGO.AddComponent();
if (lineRenderer == null)
{
Debug.LogWarning("Failed to add LineRenderer component");
return;
}
// Set material first before configuring the renderer
Shader lineShader = Shader.Find("Unlit/Color");
if (lineShader == null)
lineShader = Shader.Find("Sprites/Default");
if (lineShader == null)
lineShader = Shader.Find("Standard");
if (lineShader != null)
{
lineRenderer.material = new Material(lineShader);
}
else
{
Debug.LogWarning("No suitable shader found for dead marker");
return;
}
// Now configure the line
lineRenderer.positionCount = 4;
lineRenderer.useWorldSpace = true;
float offset = 0.35f;
// Lift the X marker above the body so the top-down camera can see it
Vector3 markerBase = transform.position + Vector3.up * 0.6f;
// Draw a bright red X in XZ plane (visible from top-down camera)
lineRenderer.SetPosition(0, markerBase + Vector3.left * offset + Vector3.forward * offset);
lineRenderer.SetPosition(1, markerBase + Vector3.right * offset + Vector3.back * offset);
lineRenderer.SetPosition(2, markerBase + Vector3.right * offset + Vector3.forward * offset);
lineRenderer.SetPosition(3, markerBase + Vector3.left * offset + Vector3.back * offset);
lineRenderer.startWidth = 0.15f;
lineRenderer.endWidth = 0.15f;
lineRenderer.startColor = new Color(1f, 0f, 0f, 1f); // Bright red
lineRenderer.endColor = new Color(1f, 0f, 0f, 1f);
}
catch (System.Exception ex)
{
Debug.LogWarning($"Failed to add dead marker: {ex.Message}\n{ex.StackTrace}");
}
}
// ------------------------------------------------------------------ //
// Helpers //
// ------------------------------------------------------------------ //
private Vector2Int WorldToTile(Vector3 worldPos)
=> new Vector2Int(Mathf.FloorToInt(worldPos.x), Mathf.FloorToInt(worldPos.z));
// ------------------------------------------------------------------ //
// Mouse interaction – click to inspect (future) //
// ------------------------------------------------------------------ //
void OnMouseDown()
{
Debug.Log($"[Monster] Room {homeRoom?.Id} | HP {currentHealth}/{maxHealth} | Difficulty ×{DifficultyMultiplier:F1}");
}
}