| 1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357135813591360136113621363136413651366136713681369137013711372137313741375137613771378137913801381138213831384138513861387138813891390139113921393139413951396139713981399140014011402140314041405140614071408140914101411141214131414141514161417141814191420142114221423142414251426142714281429143014311432143314341435143614371438143914401441144214431444144514461447144814491450145114521453145414551456145714581459 |
- using UnityEngine;
- using System.Collections.Generic;
- using System.Linq;
- /// <summary>
- /// AI Agent that navigates the maze with limited knowledge
- /// Only knows about the room it's currently in and rooms visited by same character type
- /// </summary>
- public class AIAgent : MonoBehaviour
- {
- [Header("Agent Identity")]
- [SerializeField] private string agentCharacterType = "Default";
- [SerializeField] private int agentId;
- [Header("Agent Personalization")]
- private string agentName;
- private AgentStats agentStats;
- [Header("AI Settings")]
- [SerializeField] private float movementSpeed = 2f;
- [SerializeField] private float pathUpdateInterval = 0.5f;
- [SerializeField] private float stoppingDistance = 0.1f;
- private float actualMovementSpeed; // Per-agent speed variation to reduce synchronization
- [Header("Pathfinding")]
- [Tooltip("Disable for large agent counts to avoid LineRenderer overhead.")]
- [SerializeField] private bool showPath = false;
- [SerializeField] private LineRenderer pathRenderer;
- [SerializeField] private Color pathColor = Color.yellow;
- private MazeController mazeController;
- private MazeData maze;
- private MazePathfinder pathfinder;
- private AIRoomMemory roomMemory;
- private List<Vector2Int> currentPath = new();
- private int currentPathIndex = 0;
- private float lastPathUpdate = 0f;
- private Vector2Int currentRoom = Vector2Int.zero;
- private Vector2Int targetRoom = Vector2Int.zero;
- private Vector2Int targetExitTile = Vector2Int.zero; // Target hallway exit to move toward
- private Queue<int> recentRooms = new Queue<int>(); // Track last N rooms to avoid backtracking
- private const int RECENT_ROOMS_BUFFER_SIZE = 10; // Larger buffer = less backtracking
- private MazeRoom lastRoomExitedFrom = null; // Track which room we exited to prevent immediate backtracking
- private bool hasReachedGoal = false; // Track if agent has reached the goal room
- private bool commitedToExit = false; // Track if agent has committed to a specific hallway exit
- private float nextRandomWait = 0f; // Random wait time before exploring new areas
- private float agentRandomOffset = 0f; // Per-agent random offset to desync movement
- private bool pathRequestPending = false; // True while waiting for async pathfinding result
- // Intelligence-driven disposition (seeded at spawn from Intelligence stat)
- // Will drive group-up logic, risk assessment, and future combat decisions
- private float groupUpAffinity; // 0-1: likelihood to seek allies over going solo
- private float riskTolerance; // 0-1: willingness to enter dangerous/unknown situations
- private bool knowsExitLocation = false; // True once agent has "seen" the exit room
- // ----- Group membership -----
- private AgentGroup currentGroup = null;
- private bool isGroupFollower = false; // True when this agent defers movement to the leader
- // ----- Combat -----
- private Weapon weapon;
- private float lastAttackTime = 0f;
- private const float ATTACK_COOLDOWN = 1.0f;
- /// <summary>Agent advantage multiplier on hit rolls (can improve with experience/weapons later).</summary>
- private const float AGENT_HIT_ADVANTAGE = 1.3f;
- private bool isDead = false;
- /// <summary>True while a monster is within melee range – halts path-following.</summary>
- private bool isInCombat = false;
- /// <summary>Monsters this agent is currently fighting (for fight tracking).</summary>
- private HashSet<Monster> fightingMonsters = new();
- // ----- Room danger / avoidance -----
- /// <summary>Room IDs the agent has decided to avoid (too dangerous to fight through).</summary>
- private readonly HashSet<int> avoidedRooms = new();
- /// <summary>Speed threshold: agents above this fraction of max speed may try to run through.</summary>
- private const float RUN_THROUGH_SPEED_FRACTION = 0.6f; // Speed stat > 60 enables sprinting
- // ----- Cached maze data (set once in Start, never changes) -----
- private MazeRoom[] _allRooms; // Array copy is faster to iterate than List
- private HashSet<MazeRoom> _goalRooms; // Constant set for O(1) Contains checks
- void Start()
- {
- mazeController = FindAnyObjectByType<MazeController>();
- if (mazeController == null)
- {
- Debug.LogError("AIAgent: MazeController not found in scene!");
- enabled = false;
- return;
- }
- maze = mazeController.GetCurrentMaze();
- if (maze == null)
- {
- Debug.LogError("AIAgent: Current maze is null!");
- enabled = false;
- return;
- }
- pathfinder = new MazePathfinder(maze);
- roomMemory = AIRoomMemoryManager.GetMemory(agentCharacterType);
- // Cache room data once – avoids List allocations and LINQ on every Update
- _allRooms = maze.Rooms.ToArray();
- _goalRooms = new HashSet<MazeRoom>(maze.GetRoomsByType(MazeRoom.RoomType.End));
- if (_goalRooms.Count == 0)
- Debug.LogWarning($"[Agent] No goal rooms found! Agents will never exit.");
- else
- Debug.Log($"[Agent] {_goalRooms.Count} goal room(s) cached for exit detection.");
- // Initialize personalization
- agentName = AgentNameGenerator.GenerateRandomName();
- agentStats = new AgentStats();
- gameObject.name = $"Agent_{agentId} ({agentName})";
- // Seed intelligence-driven disposition from stats
- groupUpAffinity = agentStats.GroupUpAffinity;
- riskTolerance = agentStats.RiskTolerance;
- // Equip fists as default weapon
- weapon = Weapon.Fists();
- // Initialize with a random start point
- if (maze.StartPoints.Count > 0)
- {
- int startIndex = agentId % maze.StartPoints.Count; // Distribute agents across start points
- Vector2Int startPos = maze.StartPoints[startIndex];
- // Get the start room and spawn randomly within it
- MazeRoom startRoom = maze.GetRoomAtTile(startPos.x, startPos.y);
- if (startRoom != null)
- {
- // Spawn at random position within start room
- Vector2Int randomPosInRoom = new Vector2Int(
- Random.Range(startRoom.MinX + 1, startRoom.MaxX),
- Random.Range(startRoom.MinY + 1, startRoom.MaxY)
- );
- transform.position = new Vector3(randomPosInRoom.x + 0.5f, 1f, randomPosInRoom.y + 0.5f);
- }
- else
- {
- // Fallback to exact start point
- transform.position = new Vector3(startPos.x + 0.5f, 1f, startPos.y + 0.5f);
- }
- // Rotate to be visible from above - 90 degrees around X axis
- transform.rotation = Quaternion.Euler(90, 0, 0);
- UpdateCurrentRoom(); // This will add to recentRooms and visit room
- }
- else
- {
- Debug.LogError("AIAgent: No start points found in maze!");
- enabled = false;
- return;
- }
- // Setup per-agent random offset to desync decision timings (prevents all agents moving in sync)
- agentRandomOffset = Random.Range(0f, pathUpdateInterval * 0.5f);
- nextRandomWait = Time.time + Random.Range(2f, 4f); // Random initial wait before first decision
- // Scale movement speed by the agent's Speed stat:
- // Speed=1 → ~1× base, Speed=100 → 2× base, plus ±10% desync jitter
- float speedMultiplier = 1f + (agentStats.Speed / 100f);
- actualMovementSpeed = movementSpeed * speedMultiplier * Random.Range(0.9f, 1.1f);
- // Suppress per-agent spawn log for large counts; enable only when debugging individuals
- // Debug.Log($"[Agent {agentId}] {agentName} | {agentStats} | GroupUp: {groupUpAffinity:P0} | Risk: {riskTolerance:P0}");
- // Setup path renderer
- if (showPath && pathRenderer == null)
- {
- pathRenderer = gameObject.AddComponent<LineRenderer>();
- pathRenderer.startWidth = 0.1f;
- pathRenderer.endWidth = 0.1f;
- pathRenderer.material = new Material(Shader.Find("Sprites/Default"));
- pathRenderer.startColor = pathColor;
- pathRenderer.endColor = pathColor;
- }
- }
- void Update()
- {
- if (maze == null) return;
- // Dead bodies persist forever - just stop updating logic
- if (isDead)
- {
- return;
- }
- // Agent has reached the exit - stop all movement
- if (hasReachedGoal)
- {
- return;
- }
- // Followers defer all movement to the group leader
- if (isGroupFollower)
- {
- if (currentGroup != null && currentGroup.Leader != null)
- transform.position = currentGroup.Leader.transform.position;
- // Followers still check for exit rooms
- UpdateCurrentRoom();
- // Followers still fight monsters in range
- CombatUpdate();
- return;
- }
- // Fight any adjacent monsters
- CombatUpdate();
- // Update current room
- UpdateCurrentRoom();
- // Update pathfinding periodically – skip if an async request is already in-flight
- if (!pathRequestPending && Time.time - lastPathUpdate > pathUpdateInterval + agentRandomOffset)
- {
- UpdatePathToGoal();
- lastPathUpdate = Time.time;
- }
- // Move along path – suppressed while fighting
- if (!isInCombat)
- FollowPath();
- // Debug visualization – only update for agents near the camera to save draw calls
- if (showPath && pathRenderer != null && IsNearCamera(80f))
- {
- UpdatePathVisualization();
- }
- }
- private static Camera _mainCam;
- private static float _lastCamFetch;
- /// <summary>Returns true if this agent is within <paramref name="sqrDist"/> world units of the main camera.</summary>
- private bool IsNearCamera(float sqrDist)
- {
- if (_mainCam == null || Time.time - _lastCamFetch > 2f)
- {
- _mainCam = Camera.main;
- _lastCamFetch = Time.time;
- }
- if (_mainCam == null) return true;
- return (transform.position - _mainCam.transform.position).sqrMagnitude < sqrDist * sqrDist;
- }
- private Vector2Int _lastRoomCheckTile = new Vector2Int(-9999, -9999);
- /// <summary>
- /// Updates the current room based on position.
- /// Only queries the maze when the agent has moved to a different tile.
- /// Also checks if we've reached the goal room immediately.
- /// </summary>
- private void UpdateCurrentRoom()
- {
- Vector2Int tilePos = WorldToTile(transform.position);
- // Skip if we're still on the same tile – saves GetRoomAtTile call every frame
- if (tilePos == _lastRoomCheckTile) return;
- _lastRoomCheckTile = tilePos;
- MazeRoom room = maze.GetRoomAtTile(tilePos.x, tilePos.y);
- if (room != null)
- {
- // CHECK FOR GOAL ROOM FIRST - every tile, regardless of room change
- if (!hasReachedGoal && _goalRooms.Contains(room))
- {
- hasReachedGoal = true;
- currentPath.Clear();
- currentPathIndex = 0;
- commitedToExit = false;
- // If this agent is in a group, mark all members as having reached the goal
- if (currentGroup != null)
- {
- foreach (var member in currentGroup.Members)
- {
- if (member != null && !member.HasReachedGoal)
- {
- member.hasReachedGoal = true;
- }
- }
- }
- Debug.Log($"[Agent {agentId}] {agentName} reached the exit!");
- return; // Stop immediately
- }
- if (currentRoom.x != room.Id)
- {
- currentRoom = new Vector2Int(room.Id, 0);
- roomMemory.VisitRoom(room.Id);
- // Track recent rooms to avoid immediate backtracking
- recentRooms.Enqueue(room.Id);
- if (recentRooms.Count > RECENT_ROOMS_BUFFER_SIZE)
- recentRooms.Dequeue();
- }
- }
- }
- /// <summary>
- /// Updates pathfinding to reach the goal
- /// Agent only knows about current room and visited rooms
- /// NEW LOGIC: Pick a hallway exit from current room and move straight to it
- /// </summary>
- private void UpdatePathToGoal()
- {
- if (maze.ExitPoints.Count == 0)
- {
- Debug.LogWarning($"AIAgent {agentId}: No exit points in maze!");
- return;
- }
- Vector2Int currentPos = WorldToTile(transform.position);
- MazeRoom currentRoomData = maze.GetRoomAtTile(currentPos.x, currentPos.y);
- // CHECK FOR GOAL ROOM FIRST - this should work even while following path
- if (currentRoomData != null && _goalRooms.Contains(currentRoomData))
- {
- if (!hasReachedGoal)
- {
- hasReachedGoal = true;
- currentPath.Clear();
- currentPathIndex = 0;
- commitedToExit = false;
- // If this agent is in a group, mark all members as having reached the goal
- if (currentGroup != null)
- {
- foreach (var member in currentGroup.Members)
- {
- if (member != null && !member.HasReachedGoal)
- {
- member.hasReachedGoal = true;
- }
- }
- }
- }
- return; // Stay stopped
- }
- // If we already have a valid path and we're following it, don't recalculate (stay committed)
- if (currentPath.Count > 0 && currentPathIndex < currentPath.Count)
- {
- return; // Keep following existing path
- }
- // If we've committed to reaching an exit and haven't reached it yet, keep trying.
- // Guard with pathRequestPending to avoid writing currentPath from two places.
- if (commitedToExit && targetExitTile != Vector2Int.zero && !pathRequestPending)
- {
- if (currentPath.Count == 0 && currentRoomData != null)
- {
- pathRequestPending = true;
- var capturedExit = targetExitTile;
- var capturedRoom = currentRoomData;
- if (PathfindingScheduler.Instance != null)
- {
- PathfindingScheduler.Instance.RequestPath(new PathRequest
- {
- AgentId = agentId,
- Start = currentPos,
- Goal = capturedExit,
- RoomContext = capturedRoom,
- IsHallwayMode = false,
- Callback = result =>
- {
- pathRequestPending = false;
- if (result.Count > 0)
- {
- currentPath = result;
- currentPathIndex = 0;
- }
- }
- });
- }
- else
- {
- currentPath = FindPathInRoom(currentPos, capturedExit, capturedRoom);
- currentPathIndex = 0;
- pathRequestPending = false;
- }
- return;
- }
- }
- // If in hallway (no room), just keep moving along current path
- // The hallway pathfinding will naturally move us toward exits
- if (currentRoomData == null)
- {
- // If we have a current path, keep following it through hallway
- if (currentPath.Count > 0 && currentPathIndex < currentPath.Count)
- {
- return; // Keep following path
- }
- // In hallway with no path - find next room to enter
- // PRIORITY 1: Find unvisited rooms, avoid the room we just exited
- // Collect all unvisited rooms
- List<MazeRoom> unvisitedRooms = new();
- foreach (var room in _allRooms)
- {
- // Skip the room we just exited (backtracking prevention)
- if (lastRoomExitedFrom != null && room.Id == lastRoomExitedFrom.Id)
- continue;
- // Prioritize unvisited rooms
- if (!roomMemory.HasVisited(room.Id))
- {
- unvisitedRooms.Add(room);
- }
- }
- MazeRoom targetRoom = null;
- // Random decision: 70% closest unvisited, 30% random unvisited (increases exploration variety)
- if (unvisitedRooms.Count > 0)
- {
- if (Random.value < 0.7f && unvisitedRooms.Count > 0)
- {
- // Pick closest unvisited room
- float closestUnvisitedDistance = float.MaxValue;
- foreach (var room in unvisitedRooms)
- {
- Vector2Int roomCenter = room.GetCenter();
- float distance = Vector2Int.Distance(currentPos, roomCenter);
- if (distance < closestUnvisitedDistance)
- {
- closestUnvisitedDistance = distance;
- targetRoom = room;
- }
- }
- }
- else
- {
- // Pick random unvisited room for variety
- targetRoom = unvisitedRooms[Random.Range(0, unvisitedRooms.Count)];
- }
- }
- // If no unvisited room found, pick nearest room (except the one we came from)
- if (targetRoom == null)
- {
- float closestDistance = float.MaxValue;
- foreach (var room in _allRooms)
- {
- // Skip the room we just exited
- if (lastRoomExitedFrom != null && room.Id == lastRoomExitedFrom.Id)
- continue;
- Vector2Int roomCenter = room.GetCenter();
- float distance = Vector2Int.Distance(currentPos, roomCenter);
- if (distance < closestDistance)
- {
- closestDistance = distance;
- targetRoom = room;
- }
- }
- }
- // Exit-room caution: if the chosen target is the exit room and we spotted
- // it through a short corridor, smarter agents may hesitate or route around.
- // (Low intelligence agents rush straight in; high intelligence agents are cautious
- // and may wait for allies — once group logic is implemented.)
- if (targetRoom != null)
- {
- if (_goalRooms.Contains(targetRoom))
- {
- knowsExitLocation = true;
- // High-intelligence agents (INT > 50) pause briefly to "assess"
- // before committing — placeholder for future ally/fight evaluation
- if (agentStats.Intelligence > 50)
- {
- nextRandomWait = Time.time + (agentStats.Intelligence / 100f) * 2f;
- }
- }
- }
- if (targetRoom != null)
- {
- // Request async path – result arrives next frame via callback
- if (PathfindingScheduler.Instance != null)
- {
- pathRequestPending = true;
- var capturedRoom = targetRoom;
- PathfindingScheduler.Instance.RequestPath(new PathRequest
- {
- AgentId = agentId,
- Start = currentPos,
- Goal = capturedRoom.GetCenter(),
- IsHallwayMode = true,
- Callback = result =>
- {
- pathRequestPending = false;
- if (result.Count > 0)
- {
- currentPath = result;
- currentPathIndex = 0;
- }
- // else: fallback handled next update cycle
- }
- });
- }
- else
- {
- // Fallback: synchronous (scheduler not ready yet)
- currentPath = FindPathToNearestRoom(currentPos, targetRoom);
- currentPathIndex = 0;
- }
- return;
- }
- Debug.LogWarning($"AIAgent {agentId}: In hallway but no reachable rooms!");
- return;
- }
- // MAIN LOGIC: In regular room, find a hallway exit and path to it
- // Get all hallway tiles adjacent to this room
- List<Vector2Int> hallwayExits = FindHallwayExits(currentRoomData, currentPos);
- if (hallwayExits.Count > 0)
- {
- // Peek through each exit: if the corridor leads directly to the exit room
- // or a dangerous room, apply intelligence/danger avoidance logic.
- List<Vector2Int> safeExits = new List<Vector2Int>(hallwayExits);
- foreach (var exit in hallwayExits)
- {
- MazeRoom peekedRoom = PeekCorridorDestination(exit, currentRoomData);
- if (peekedRoom == null) continue;
- // Check if exit leads to the goal room
- if (!knowsExitLocation && _goalRooms.Contains(peekedRoom))
- {
- knowsExitLocation = true;
- if (agentStats.Intelligence > 40 && safeExits.Count > 1)
- safeExits.Remove(exit);
- }
- // Check if exit leads to a monster-heavy room
- if (ShouldAvoidRoom(peekedRoom) && safeExits.Count > 1)
- {
- avoidedRooms.Add(peekedRoom.Id);
- safeExits.Remove(exit);
- Debug.Log($"[Agent {agentId}] {agentName}: avoiding room {peekedRoom.Id} (threat {MonsterSpawner.GetRoomThreat(peekedRoom.Id)})");
- }
- }
- if (safeExits.Count == 0) safeExits = hallwayExits; // No alternative – must go through
- // Pick a random hallway exit from the (possibly filtered) list
- Vector2Int chosenExit = safeExits[Random.Range(0, safeExits.Count)];
- // Request path asynchronously via scheduler
- if (PathfindingScheduler.Instance != null)
- {
- pathRequestPending = true;
- var capturedExit = chosenExit;
- var capturedRoom = currentRoomData;
- PathfindingScheduler.Instance.RequestPath(new PathRequest
- {
- AgentId = agentId,
- Start = currentPos,
- Goal = capturedExit,
- RoomContext = capturedRoom,
- IsHallwayMode = false,
- Callback = result =>
- {
- pathRequestPending = false;
- if (result.Count > 0)
- {
- currentPath = result;
- currentPathIndex = 0;
- targetExitTile = capturedExit;
- commitedToExit = true;
- nextRandomWait = Time.time + Random.Range(1.5f, 3.5f);
- }
- else
- {
- commitedToExit = false;
- }
- }
- });
- }
- else
- {
- // Fallback: synchronous
- currentPath = FindPathInRoom(currentPos, chosenExit, currentRoomData);
- currentPathIndex = 0;
- if (currentPath.Count > 0)
- {
- targetExitTile = chosenExit;
- commitedToExit = true;
- nextRandomWait = Time.time + Random.Range(1.5f, 3.5f);
- }
- else
- {
- commitedToExit = false;
- }
- }
- }
- else
- {
- Debug.LogWarning($"AIAgent {agentId}: No hallway exits found from room {currentRoomData.Id}");
- commitedToExit = false;
- }
- }
- /// <summary>
- /// Chooses the next room to move to
- /// Searches around the current room to find adjacent rooms
- /// Avoids recently visited rooms to prevent backtracking
- /// </summary>
- private MazeRoom ChooseNextRoom(MazeRoom currentRoom)
- {
- // Find all adjacent rooms by checking walkable tiles in all directions from room boundaries
- List<MazeRoom> connectedRooms = new();
- Vector2Int roomCenter = currentRoom.GetCenter();
- // Check from each direction outward from the room center
- Vector2Int[] directions = new Vector2Int[]
- {
- Vector2Int.up, Vector2Int.down, Vector2Int.left, Vector2Int.right,
- Vector2Int.up + Vector2Int.left, Vector2Int.up + Vector2Int.right,
- Vector2Int.down + Vector2Int.left, Vector2Int.down + Vector2Int.right
- };
- // Shuffle directions for randomness
- for (int i = directions.Length - 1; i > 0; i--)
- {
- int randomIndex = Random.Range(0, i + 1);
- var temp = directions[i];
- directions[i] = directions[randomIndex];
- directions[randomIndex] = temp;
- }
- // Try each direction and look for tiles that lead to other rooms
- foreach (var dir in directions)
- {
- for (int dist = 1; dist < 20; dist++) // Search up to 20 tiles away
- {
- Vector2Int testPos = roomCenter + (dir * dist);
- if (!maze.IsInBounds(testPos.x, testPos.y) || !maze.IsWalkable(testPos.x, testPos.y))
- continue;
- MazeRoom testRoom = maze.GetRoomAtTile(testPos.x, testPos.y);
- if (testRoom != null && testRoom.Id != currentRoom.Id && !connectedRooms.Contains(testRoom))
- {
- connectedRooms.Add(testRoom);
- break; // Found a room in this direction, move to next direction
- }
- }
- }
- if (connectedRooms.Count == 0)
- {
- Debug.LogWarning($"AIAgent {agentId}: No adjacent rooms found from room {currentRoom.Id}");
- return null;
- }
- // Filter out recently visited rooms (to avoid backtracking)
- List<MazeRoom> nonRecentRooms = connectedRooms.Where(r => !recentRooms.Contains(r.Id)).ToList();
- // PRIORITY 1: Strongly prefer completely unvisited rooms (not recently visited either)
- var completelyUnvisited = nonRecentRooms.Where(r => !roomMemory.HasVisited(r.Id)).ToList();
- if (completelyUnvisited.Count > 0)
- {
- MazeRoom chosen = completelyUnvisited[Random.Range(0, completelyUnvisited.Count)];
- return chosen;
- }
- // PRIORITY 2: Try non-recent rooms even if visited by this character type
- if (nonRecentRooms.Count > 0)
- {
- // Among non-recent rooms, prefer unvisited by this agent's character type
- var unvisitedByType = nonRecentRooms.Where(r => !roomMemory.HasVisited(r.Id)).ToList();
- if (unvisitedByType.Count > 0)
- {
- MazeRoom chosen = unvisitedByType[Random.Range(0, unvisitedByType.Count)];
- return chosen;
- }
- // Otherwise just pick a random non-recent room
- MazeRoom chosen2 = nonRecentRooms[Random.Range(0, nonRecentRooms.Count)];
- return chosen2;
- }
- // PRIORITY 3: If all rooms are recent, pick one that's been visited least recently
- // Find the room in connectedRooms that was added to recentRooms earliest (will be dequeued first)
- MazeRoom leastRecentRoom = connectedRooms[0];
- foreach (var room in connectedRooms)
- {
- if (!roomMemory.HasVisited(room.Id))
- {
- leastRecentRoom = room;
- break;
- }
- }
- Debug.Log($"AIAgent {agentId}: All rooms recent, choosing least-recent room {leastRecentRoom.Id}");
- return leastRecentRoom;
- }
- /// <summary>
- /// Finds the nearest room from a position (used when in hallway)
- /// </summary>
- private MazeRoom FindNearestRoom(Vector2Int position)
- {
- MazeRoom nearest = null;
- float nearestDistance = float.MaxValue;
- foreach (var room in maze.Rooms)
- {
- Vector2Int roomCenter = room.GetCenter();
- float distance = Vector2Int.Distance(position, roomCenter);
- if (distance < nearestDistance)
- {
- nearestDistance = distance;
- nearest = room;
- }
- }
- return nearest;
- }
- /// <summary>
- /// Finds all hallway exit tiles adjacent to a room
- /// These are walkable tiles just outside the room boundary
- /// </summary>
- /// <summary>
- /// Peeks along a corridor starting from a hallway exit tile.
- /// Follows walkable non-room tiles up to a short distance and returns
- /// the first room found at the other end, or null if no room is close.
- /// Used by intelligence logic to detect if an exit leads straight to the goal.
- /// </summary>
- private MazeRoom PeekCorridorDestination(Vector2Int exitTile, MazeRoom sourceRoom, int maxPeekDistance = 12)
- {
- // BFS outward from the exit tile, staying in non-room (hallway) tiles
- var visited = new HashSet<Vector2Int> { exitTile };
- var queue = new Queue<Vector2Int>();
- queue.Enqueue(exitTile);
- while (queue.Count > 0)
- {
- Vector2Int tile = queue.Dequeue();
- Vector2Int[] dirs = {
- new(tile.x + 1, tile.y), new(tile.x - 1, tile.y),
- new(tile.x, tile.y + 1), new(tile.x, tile.y - 1)
- };
- foreach (var next in dirs)
- {
- if (!maze.IsInBounds(next.x, next.y) || !maze.IsWalkable(next.x, next.y)) continue;
- if (visited.Contains(next)) continue;
- MazeRoom nextRoom = maze.GetRoomAtTile(next.x, next.y);
- if (nextRoom != null && nextRoom.Id != sourceRoom.Id)
- return nextRoom; // Found the room at the end of this corridor
- // Only continue through hallway tiles and within peek distance
- if (nextRoom == null && Vector2Int.Distance(exitTile, next) < maxPeekDistance)
- {
- visited.Add(next);
- queue.Enqueue(next);
- }
- }
- }
- return null;
- }
- private List<Vector2Int> FindHallwayExits(MazeRoom room, Vector2Int currentPos)
- {
- List<Vector2Int> exits = new();
- HashSet<Vector2Int> addedExits = new();
- // Check all tiles on the boundary of the room
- // North boundary
- for (int x = room.MinX; x <= room.MaxX; x++)
- {
- int y = room.MinY - 1;
- if (maze.IsInBounds(x, y) && maze.IsWalkable(x, y) && !addedExits.Contains(new Vector2Int(x, y)))
- {
- exits.Add(new Vector2Int(x, y));
- addedExits.Add(new Vector2Int(x, y));
- }
- }
- // South boundary
- for (int x = room.MinX; x <= room.MaxX; x++)
- {
- int y = room.MaxY + 1;
- if (maze.IsInBounds(x, y) && maze.IsWalkable(x, y) && !addedExits.Contains(new Vector2Int(x, y)))
- {
- exits.Add(new Vector2Int(x, y));
- addedExits.Add(new Vector2Int(x, y));
- }
- }
- // West boundary
- for (int y = room.MinY; y <= room.MaxY; y++)
- {
- int x = room.MinX - 1;
- if (maze.IsInBounds(x, y) && maze.IsWalkable(x, y) && !addedExits.Contains(new Vector2Int(x, y)))
- {
- exits.Add(new Vector2Int(x, y));
- addedExits.Add(new Vector2Int(x, y));
- }
- }
- // East boundary
- for (int y = room.MinY; y <= room.MaxY; y++)
- {
- int x = room.MaxX + 1;
- if (maze.IsInBounds(x, y) && maze.IsWalkable(x, y) && !addedExits.Contains(new Vector2Int(x, y)))
- {
- exits.Add(new Vector2Int(x, y));
- addedExits.Add(new Vector2Int(x, y));
- }
- }
- return exits;
- }
- /// <summary>
- /// Finds a path from current position to the nearest room (when in hallway).
- /// Uses a min-heap open set for O(n log n) instead of O(n²).
- /// </summary>
- private List<Vector2Int> FindPathToNearestRoom(Vector2Int start, MazeRoom targetRoom)
- {
- var openSet = new MinHeap<Vector2Int>();
- var cameFrom = new Dictionary<Vector2Int, Vector2Int>();
- var gScore = new Dictionary<Vector2Int, float> { [start] = 0f };
- var targetCenter = targetRoom.GetCenter();
- openSet.Enqueue(start, Heuristic(start, targetCenter));
- const int maxIterations = 2000;
- int iterations = 0;
- while (openSet.Count > 0 && iterations++ < maxIterations)
- {
- Vector2Int current = openSet.Dequeue();
- // Reached the target room
- MazeRoom roomAtCurrent = maze.GetRoomAtTile(current.x, current.y);
- if (roomAtCurrent != null && roomAtCurrent.Id == targetRoom.Id)
- return ReconstructPath(cameFrom, current);
- Vector2Int[] dirs = {
- new(current.x + 1, current.y), new(current.x - 1, current.y),
- new(current.x, current.y + 1), new(current.x, current.y - 1)
- };
- foreach (var nb in dirs)
- {
- if (!maze.IsInBounds(nb.x, nb.y) || !maze.IsWalkable(nb.x, nb.y)) continue;
- float tentativeG = gScore[current] + 1f;
- if (!gScore.TryGetValue(nb, out float existingG) || tentativeG < existingG)
- {
- cameFrom[nb] = current;
- gScore[nb] = tentativeG;
- float f = tentativeG + Heuristic(nb, targetCenter);
- if (openSet.Contains(nb, out _)) openSet.UpdatePriority(nb, f);
- else openSet.Enqueue(nb, f);
- }
- }
- }
- return new List<Vector2Int>();
- }
- private static float Heuristic(Vector2Int a, Vector2Int b)
- => Mathf.Abs(a.x - b.x) + Mathf.Abs(a.y - b.y);
- /// <summary>
- /// Finds a boundary tile of the current room that points toward the next room
- /// </summary>
- private Vector2Int FindBoundaryTileToward(MazeRoom currentRoom, MazeRoom nextRoom, Vector2Int currentPos)
- {
- Vector2Int nextRoomCenter = nextRoom.GetCenter();
- Vector2Int closestBoundary = currentRoom.GetCenter();
- float closestDistance = float.MaxValue;
- // Check all boundary tiles of current room
- for (int x = currentRoom.MinX; x <= currentRoom.MaxX; x++)
- {
- for (int y = currentRoom.MinY; y <= currentRoom.MaxY; y++)
- {
- // Only check boundary and walkable tiles
- if ((x == currentRoom.MinX || x == currentRoom.MaxX || y == currentRoom.MinY || y == currentRoom.MaxY) &&
- maze.IsWalkable(x, y))
- {
- // Find boundary tile closest to next room center
- float distToNext = Vector2Int.Distance(new Vector2Int(x, y), nextRoomCenter);
- if (distToNext < closestDistance)
- {
- closestDistance = distToNext;
- closestBoundary = new Vector2Int(x, y);
- }
- }
- }
- }
- return closestBoundary;
- }
- /// <summary>
- /// Finds a path within a single room (limited knowledge).
- /// Uses a min-heap open set for O(n log n) performance instead of O(n²).
- /// </summary>
- private List<Vector2Int> FindPathInRoom(Vector2Int start, Vector2Int goal, MazeRoom room)
- {
- var openSet = new MinHeap<Vector2Int>();
- var cameFrom = new Dictionary<Vector2Int, Vector2Int>();
- var gScore = new Dictionary<Vector2Int, float> { [start] = 0f };
- openSet.Enqueue(start, Heuristic(start, goal));
- const int maxIterations = 1000;
- int iterations = 0;
- while (openSet.Count > 0 && iterations++ < maxIterations)
- {
- Vector2Int current = openSet.Dequeue();
- if (current == goal)
- return ReconstructPath(cameFrom, current);
- foreach (var nb in GetRoomNeighbors(current, room))
- {
- float tentativeG = gScore[current] + 1f;
- if (!gScore.TryGetValue(nb, out float existingG) || tentativeG < existingG)
- {
- cameFrom[nb] = current;
- gScore[nb] = tentativeG;
- float f = tentativeG + Heuristic(nb, goal);
- if (openSet.Contains(nb, out _)) openSet.UpdatePriority(nb, f);
- else openSet.Enqueue(nb, f);
- }
- }
- }
- return new List<Vector2Int>();
- }
- /// <summary>
- /// Gets walkable neighbors within a room or at room boundary
- /// Allows pathfinding to reach hallway exits outside room
- /// </summary>
- private List<Vector2Int> GetRoomNeighbors(Vector2Int position, MazeRoom room)
- {
- var neighbors = new List<Vector2Int>();
- Vector2Int[] directions = new[]
- {
- new Vector2Int(position.x + 1, position.y),
- new Vector2Int(position.x - 1, position.y),
- new Vector2Int(position.x, position.y + 1),
- new Vector2Int(position.x, position.y - 1),
- };
- foreach (var dir in directions)
- {
- // Allow tiles within room OR immediately adjacent to room (boundary)
- bool inBounds = maze.IsInBounds(dir.x, dir.y);
- bool isWalkable = maze.IsWalkable(dir.x, dir.y);
- bool inRoom = room.Contains(dir.x, dir.y);
- bool nearBoundary = (dir.x == room.MinX - 1 || dir.x == room.MaxX + 1 ||
- dir.y == room.MinY - 1 || dir.y == room.MaxY + 1);
- if (inBounds && isWalkable && (inRoom || nearBoundary))
- {
- neighbors.Add(dir);
- }
- }
- return neighbors;
- }
- /// <summary>
- /// Reconstructs path from A* results.
- /// Uses Add+Reverse (O(n)) instead of Insert(0,...) (O(n²)).
- /// </summary>
- private static List<Vector2Int> ReconstructPath(Dictionary<Vector2Int, Vector2Int> cameFrom, Vector2Int current)
- {
- var path = new List<Vector2Int>();
- while (cameFrom.TryGetValue(current, out var prev))
- {
- path.Add(current);
- current = prev;
- }
- path.Add(current); // start node
- path.Reverse();
- return path;
- }
- /// <summary>
- /// Follows the current path
- /// </summary>
- private void FollowPath()
- {
- if (currentPath.Count == 0) return;
- Vector2Int currentTarget = currentPath[currentPathIndex];
- // Maze coordinates: X,Y → World: X,Z (Y=0 is ground level)
- // Keep Y at 1f to stay above the maze floor
- Vector3 targetWorldPos = new Vector3(currentTarget.x + 0.5f, 1f, currentTarget.y + 0.5f);
- Vector3 direction = (targetWorldPos - transform.position).normalized;
- // Move directly instead of using Translate (no rigidbody)
- transform.position += direction * actualMovementSpeed * Time.deltaTime;
- // Check if we've exited the room (entered hallway)
- Vector2Int currentPos = WorldToTile(transform.position);
- MazeRoom roomAtPos = maze.GetRoomAtTile(currentPos.x, currentPos.y);
- if (roomAtPos == null && currentRoom.x != -1)
- {
- // We've entered a hallway - track which room we came from to prevent immediate backtracking
- lastRoomExitedFrom = maze.GetRoomById(currentRoom.x);
- commitedToExit = false; // No longer committed to that exit, now in hallway
- targetExitTile = Vector2Int.zero; // Clear the target exit
- }
- // Move to next waypoint when close enough
- if (Vector3.Distance(transform.position, targetWorldPos) < stoppingDistance)
- {
- currentPathIndex++;
- if (currentPathIndex >= currentPath.Count)
- {
- currentPath.Clear();
- }
- }
- }
- /// <summary>
- /// Updates the path visualization
- /// </summary>
- private void UpdatePathVisualization()
- {
- if (currentPath.Count == 0)
- {
- pathRenderer.positionCount = 0;
- return;
- }
- var positions = new Vector3[currentPath.Count];
- for (int i = 0; i < currentPath.Count; i++)
- {
- // Maze coordinates: X,Y → World: X,Z (Y=0 is ground level)
- // Keep Y at 1f to stay above the maze floor
- positions[i] = new Vector3(currentPath[i].x + 0.5f, 1f, currentPath[i].y + 0.5f);
- }
- pathRenderer.positionCount = positions.Length;
- pathRenderer.SetPositions(positions);
- }
- /// <summary>
- /// Converts world position to tile coordinate
- /// Maze coordinates: X,Y ← World: X,Z (Y=0 is ground level)
- /// </summary>
- private Vector2Int WorldToTile(Vector3 worldPos)
- {
- return new Vector2Int(Mathf.FloorToInt(worldPos.x), Mathf.FloorToInt(worldPos.z));
- }
- /// <summary>
- /// Gets agent ID
- /// </summary>
- public int AgentId => agentId;
- /// <summary>
- /// Gets agent character type
- /// </summary>
- public string CharacterType => agentCharacterType;
- /// <summary>
- /// Gets current room
- /// </summary>
- public Vector2Int CurrentRoom => currentRoom;
- /// <summary>
- /// Gets room memory
- /// </summary>
- public AIRoomMemory RoomMemory => roomMemory;
- /// <summary>
- /// Gets agent ID (public accessor for triggers)
- /// </summary>
- public int GetAgentId() => agentId;
- /// <summary>
- /// Called by ExitRoomTrigger when agent enters the goal room
- /// Immediately stops all movement
- /// Also marks all group members as having reached the goal
- /// </summary>
- public void StopAtGoal()
- {
- hasReachedGoal = true;
- currentPath.Clear();
- currentPathIndex = 0;
- commitedToExit = false;
- // If this agent is in a group, mark all members as having reached the goal
- if (currentGroup != null)
- {
- foreach (var member in currentGroup.Members)
- {
- if (member != null && !member.HasReachedGoal)
- {
- member.hasReachedGoal = true;
- }
- }
- }
- }
- /// <summary>
- /// Gets whether this agent has reached the goal
- /// </summary>
- public bool HasReachedGoal => hasReachedGoal;
- /// <summary>True once the agent's health reaches 0.</summary>
- public bool IsDead => isDead;
- /// <summary>Current health points (mirrors AgentStats).</summary>
- public int CurrentHealth => agentStats?.CurrentHealth ?? 0;
- // ------------------------------------------------------------------ //
- // Combat //
- // ------------------------------------------------------------------ //
- /// <summary>
- /// Called by monsters when they deal damage to this agent.
- /// </summary>
- public void TakeDamage(int amount, Monster source)
- {
- if (isDead) return;
- agentStats.ApplyDamage(amount);
- if (agentStats.IsDead)
- Die();
- }
- private void Die()
- {
- if (isDead) return;
- isDead = true;
- currentPath.Clear();
- // End all fights this agent is in
- foreach (var monster in fightingMonsters)
- {
- if (monster != null)
- FightTracker.Instance.EndFight(this, monster);
- }
- fightingMonsters.Clear();
- // Change visual to show dead (grayscale/dark with white X)
- var renderer = GetComponent<Renderer>();
- if (renderer != null)
- {
- var material = new Material(renderer.material);
- material.color = new Color(0.4f, 0.4f, 0.4f, 0.9f); // Dark gray, opaque
- renderer.material = material;
- }
- // Add X marker BEFORE disabling (must happen while enabled)
- AddDeadMarker();
- // Disable pathfinding AFTER adding marker
- if (pathRenderer != null)
- pathRenderer.enabled = false;
- Debug.Log($"[Agent {agentId}] {agentName} has died!");
- // Notify the manager so death stats can be tracked
- var manager = FindAnyObjectByType<AIAgentManager>();
- manager?.RegisterAgentDeath(this);
- // Leave group cleanly
- currentGroup?.RemoveMember(this);
- }
- /// <summary>
- /// Adds a white X marker to indicate the body is dead.
- /// Uses a child GameObject so it doesn't conflict with the path LineRenderer.
- /// </summary>
- private void AddDeadMarker()
- {
- try
- {
- if (gameObject == null)
- return;
- // Use a dedicated child GameObject so it doesn't clash with pathRenderer
- var markerGO = new GameObject("DeadMarker");
- markerGO.transform.SetParent(transform, worldPositionStays: false);
- markerGO.transform.localPosition = Vector3.zero;
- var lineRenderer = markerGO.AddComponent<LineRenderer>();
- 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 white 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 = Color.white;
- lineRenderer.endColor = Color.white;
- }
- catch (System.Exception ex)
- {
- Debug.LogWarning($"Failed to add dead marker: {ex.Message}\n{ex.StackTrace}");
- }
- }
- /// <summary>
- /// Combat tick: find the nearest in-range monster and attack it.
- /// Sets isInCombat to halt movement while a monster is close.
- /// </summary>
- private void CombatUpdate()
- {
- // Scan for the nearest living monster within melee range
- Monster nearestMonster = null;
- float bestDist = weapon.MeleeRange * weapon.MeleeRange;
- foreach (var m in FindObjectsByType<Monster>())
- {
- if (m.IsDead) continue;
- float sqDist = (m.transform.position - transform.position).sqrMagnitude;
- if (sqDist < bestDist)
- {
- bestDist = sqDist;
- nearestMonster = m;
- }
- }
- // Update combat lock – halts FollowPath while a monster is adjacent
- isInCombat = nearestMonster != null;
- // Track fight start
- if (nearestMonster != null && !fightingMonsters.Contains(nearestMonster))
- {
- fightingMonsters.Add(nearestMonster);
- FightTracker.Instance.StartFight(this, nearestMonster);
- }
- // Track fight end for monsters no longer in range
- var monstersToRemove = new List<Monster>();
- foreach (var monster in fightingMonsters)
- {
- if (monster == null || monster.IsDead)
- {
- monstersToRemove.Add(monster);
- }
- else
- {
- float dist = (monster.transform.position - transform.position).sqrMagnitude;
- if (dist > weapon.MeleeRange * weapon.MeleeRange)
- {
- monstersToRemove.Add(monster);
- }
- }
- }
- foreach (var monster in monstersToRemove)
- {
- fightingMonsters.Remove(monster);
- if (monster != null)
- FightTracker.Instance.EndFight(this, monster);
- }
- if (nearestMonster == null) return;
- // Attack on cooldown
- if (Time.time - lastAttackTime < ATTACK_COOLDOWN) return;
- lastAttackTime = Time.time;
- // Agents have advantage on hit
- if (weapon.TryHit(AGENT_HIT_ADVANTAGE))
- {
- int dmg = weapon.RollDamage();
- bool killed = nearestMonster.TakeDamage(dmg);
- Debug.Log($"[Agent {agentId}] {agentName} hit monster for {dmg} (monster HP left: {nearestMonster.CurrentHealth})");
- if (killed)
- {
- var manager = FindAnyObjectByType<AIAgentManager>();
- manager?.RegisterMonsterKill();
- }
- }
- else
- {
- Debug.Log($"[Agent {agentId}] {agentName} missed monster");
- }
- }
- // ------------------------------------------------------------------ //
- // Room danger assessment //
- // ------------------------------------------------------------------ //
- /// <summary>
- /// Returns the perceived danger score of a room.
- /// Uses MonsterSpawner's threat lookup (0 = safe, higher = more dangerous).
- /// </summary>
- private int GetRoomThreat(MazeRoom room)
- {
- if (room == null) return 0;
- return MonsterSpawner.GetRoomThreat(room.Id);
- }
- /// <summary>
- /// Decides whether to avoid, run through, or fight through a room.
- /// Called before committing to an exit that leads to a dangerous room.
- /// Returns true if the agent should avoid the target room.
- /// </summary>
- private bool ShouldAvoidRoom(MazeRoom targetRoom)
- {
- if (targetRoom == null) return false;
- int threat = GetRoomThreat(targetRoom);
- if (threat == 0) return false;
- // Estimate own fighting power: health * (Strength / 50)
- float power = agentStats.CurrentHealth * (agentStats.Strength / 50f);
- // Risk tolerance 0=cautious, 1=reckless
- float dangerThreshold = Mathf.Lerp(0.5f, 2.0f, riskTolerance);
- bool tooRisky = threat > power * dangerThreshold;
- if (!tooRisky) return false;
- // Fast agents (Speed > 60) might choose to sprint through instead of avoiding
- if (agentStats.Speed > 60)
- {
- float runChance = (agentStats.Speed - 60f) / 40f; // 0 at spd=60, 1 at spd=100
- if (Random.value < runChance)
- {
- Debug.Log($"[Agent {agentId}] {agentName}: sprinting through dangerous room {targetRoom.Id}!");
- return false; // Will run, not avoid
- }
- }
- return true; // Should avoid
- }
- /// <summary>
- /// Gets agent's personalized name
- /// </summary>
- public string AgentName => agentName;
- /// <summary>
- /// Gets agent's stats
- /// </summary>
- public AgentStats Stats => agentStats;
- /// <summary>
- /// 0-1 tendency to seek allies over going solo (driven by Intelligence)
- /// </summary>
- public float GroupUpAffinity => groupUpAffinity;
- /// <summary>
- /// 0-1 willingness to take risks in combat or unknown rooms (driven by Intelligence)
- /// </summary>
- public float RiskTolerance => riskTolerance;
- /// <summary>
- /// Whether this agent has spotted the exit room (through corridor peeking or direct entry)
- /// </summary>
- public bool KnowsExitLocation => knowsExitLocation;
- void OnMouseDown()
- {
- // If this agent is in a group, clicking any member shows the group panel
- if (currentGroup != null)
- AgentInfoPanel.ShowGroupInfo(currentGroup);
- else
- AgentInfoPanel.ShowAgentInfo(this);
- }
- public void SetShowPath(bool show)
- {
- showPath = show;
- if (pathRenderer != null)
- {
- pathRenderer.enabled = show;
- }
- }
- public bool GetShowPath() => showPath;
- // ------------------------------------------------------------------ //
- // Group API //
- // ------------------------------------------------------------------ //
- /// <summary>Called by AgentGroup when this agent is added.</summary>
- public void JoinGroup(AgentGroup group)
- {
- currentGroup = group;
- isGroupFollower = group.Leader != this;
- Debug.Log($"[Agent {agentId}] {agentName} joined group {group.GroupId} as {(isGroupFollower ? "follower" : "leader")}");
- }
- /// <summary>Called by AgentGroup when this agent leaves or group dissolves.</summary>
- public void LeaveGroup()
- {
- currentGroup = null;
- isGroupFollower = false;
- // Restore own renderer
- var mr = GetComponent<MeshRenderer>();
- if (mr != null) mr.enabled = true;
- var lr = GetComponent<LineRenderer>();
- if (lr != null) lr.enabled = showPath;
- }
- /// <summary>The group this agent belongs to, or null if solo.</summary>
- public AgentGroup Group => currentGroup;
- /// <summary>True if another agent is driving this agent's position.</summary>
- public bool IsGroupFollower => isGroupFollower;
- /// <summary>True if this agent leads a group (is not a follower but group exists).</summary>
- public bool IsGroupLeader => currentGroup != null && !isGroupFollower;
- }
|