|
@@ -1,43 +1,39 @@
|
|
|
using System;
|
|
using System;
|
|
|
using System.Collections.Generic;
|
|
using System.Collections.Generic;
|
|
|
-using System.Linq;
|
|
|
|
|
using UnityEngine;
|
|
using UnityEngine;
|
|
|
|
|
|
|
|
namespace MazeGen
|
|
namespace MazeGen
|
|
|
{
|
|
{
|
|
|
/// <summary>
|
|
/// <summary>
|
|
|
- /// Drives one agent's autonomous exploration: waits out a (randomized) initial delay so
|
|
|
|
|
- /// many agents don't all decide in lockstep, then ticks a steady decision interval -
|
|
|
|
|
- /// each time it fires, the agent deals with whatever is in its current room (fight it,
|
|
|
|
|
- /// join a fight already forming, or sneak past) and otherwise picks an exit and moves.
|
|
|
|
|
- /// Stops deciding once the agent reaches an exit room, dies, or the level ends.
|
|
|
|
|
|
|
+ /// Autonomous agent decision-making system based on a prioritized decision tree.
|
|
|
///
|
|
///
|
|
|
- /// Agents can "peek" into adjacent rooms before entering to see if there are monsters.
|
|
|
|
|
- /// Based on the perceived threat level, they may choose a different exit rather than fight.
|
|
|
|
|
|
|
+ /// Each decision encapsulates:
|
|
|
|
|
+ /// - A precondition (CanExecute)
|
|
|
|
|
+ /// - An action (Execute)
|
|
|
|
|
+ /// - Narration (Think)
|
|
|
///
|
|
///
|
|
|
- /// Every decision this class makes is narrated into the agent's <see cref="AgentThoughtLog"/>,
|
|
|
|
|
- /// which is what the HUD shows while an agent is being followed. Adding a decision here
|
|
|
|
|
- /// without a matching Think() call makes the agent's behaviour unexplainable to the observer,
|
|
|
|
|
- /// so keep them together.
|
|
|
|
|
|
|
+ /// The decision tree evaluates decisions in priority order and returns the first
|
|
|
|
|
+ /// one that can execute. This keeps behavior and narration synchronized and makes
|
|
|
|
|
+ /// the agent's decisions easy to understand and extend.
|
|
|
///
|
|
///
|
|
|
- /// All timing runs off <see cref="SimulationClock"/>, never Time.deltaTime directly, so a
|
|
|
|
|
- /// paused simulation genuinely freezes every agent.
|
|
|
|
|
|
|
+ /// To extend agent behavior, simply create a new AgentDecision subclass and add
|
|
|
|
|
+ /// it to BuildDecisionTree(). No need to modify the Update loop or narration logic.
|
|
|
|
|
+ ///
|
|
|
|
|
+ /// All timing runs off SimulationClock, not Time.deltaTime, so paused simulation
|
|
|
|
|
+ /// genuinely freezes every agent.
|
|
|
/// </summary>
|
|
/// </summary>
|
|
|
public class AutonomousAgentBrain : MonoBehaviour
|
|
public class AutonomousAgentBrain : MonoBehaviour
|
|
|
{
|
|
{
|
|
|
- /// <summary>Peek observations about the same room are only narrated this many times, to keep the thought log readable.</summary>
|
|
|
|
|
- private const int MaxNarratedPeeksPerRoom = 2;
|
|
|
|
|
-
|
|
|
|
|
private MazeAgentController _controller;
|
|
private MazeAgentController _controller;
|
|
|
private MazeGenerator _generator;
|
|
private MazeGenerator _generator;
|
|
|
private CombatDirector _director;
|
|
private CombatDirector _director;
|
|
|
private float _decisionInterval;
|
|
private float _decisionInterval;
|
|
|
private float _exploreChance;
|
|
private float _exploreChance;
|
|
|
- private float _retreatThreshold = 1.5f; // CR above this = don't pick that fight
|
|
|
|
|
|
|
+ private float _retreatThreshold = 1.5f;
|
|
|
private Func<bool> _isLevelEnded;
|
|
private Func<bool> _isLevelEnded;
|
|
|
|
|
|
|
|
private float _timer;
|
|
private float _timer;
|
|
|
- private readonly Dictionary<int, int> _peekCount = new Dictionary<int, int>(); // narration throttle, per room id
|
|
|
|
|
|
|
+ private AgentDecisionTree _decisionTree;
|
|
|
|
|
|
|
|
public void Initialize(MazeAgentController controller, MazeGenerator generator, CombatDirector director, float initialDelay, float decisionInterval, float exploreChance, float retreatThreshold, Func<bool> isLevelEnded)
|
|
public void Initialize(MazeAgentController controller, MazeGenerator generator, CombatDirector director, float initialDelay, float decisionInterval, float exploreChance, float retreatThreshold, Func<bool> isLevelEnded)
|
|
|
{
|
|
{
|
|
@@ -49,366 +45,54 @@ namespace MazeGen
|
|
|
_retreatThreshold = Mathf.Max(0, retreatThreshold);
|
|
_retreatThreshold = Mathf.Max(0, retreatThreshold);
|
|
|
_isLevelEnded = isLevelEnded;
|
|
_isLevelEnded = isLevelEnded;
|
|
|
_timer = Mathf.Max(0f, initialDelay);
|
|
_timer = Mathf.Max(0f, initialDelay);
|
|
|
- }
|
|
|
|
|
-
|
|
|
|
|
- private void Update()
|
|
|
|
|
- {
|
|
|
|
|
- MazeAgent agent = _controller?.Agent;
|
|
|
|
|
- if (agent == null || agent.IsFinished || agent.IsDead) return;
|
|
|
|
|
- if (_isLevelEnded != null && _isLevelEnded()) return;
|
|
|
|
|
-
|
|
|
|
|
- // Committed to a fight: the CombatDirector owns this agent until it resolves.
|
|
|
|
|
- if (agent.IsEngaged) return;
|
|
|
|
|
-
|
|
|
|
|
- _timer -= SimulationClock.DeltaTime;
|
|
|
|
|
- if (_timer > 0f) return;
|
|
|
|
|
- _timer = _decisionInterval;
|
|
|
|
|
|
|
|
|
|
- if (HandleRoomHostiles(agent)) return;
|
|
|
|
|
- MoveOn(agent);
|
|
|
|
|
|
|
+ // Build the decision tree in priority order
|
|
|
|
|
+ BuildDecisionTree();
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
- private void Think(ThoughtKind kind, string text) => _controller?.Agent?.Thoughts.Add(kind, text);
|
|
|
|
|
-
|
|
|
|
|
- // ---------------------------------------------------------------- fighting & sneaking
|
|
|
|
|
-
|
|
|
|
|
/// <summary>
|
|
/// <summary>
|
|
|
- /// Deals with hostiles in the room the agent is standing in. Returns true if this took
|
|
|
|
|
- /// the agent's turn (it committed to a fight, or spent the tick slipping away).
|
|
|
|
|
- /// </summary>
|
|
|
|
|
- private bool HandleRoomHostiles(MazeAgent agent)
|
|
|
|
|
- {
|
|
|
|
|
- if (_director == null) return false;
|
|
|
|
|
-
|
|
|
|
|
- Room room = agent.CurrentRoom;
|
|
|
|
|
- if (room == null) return false;
|
|
|
|
|
-
|
|
|
|
|
- // A fight is already mustering here - pile in for better odds, or try to slip past.
|
|
|
|
|
- PendingFight mustering = _director.FindMusteringFight(room);
|
|
|
|
|
- if (mustering != null && !mustering.Contains(agent))
|
|
|
|
|
- return HandleArrivalAtFight(agent, mustering);
|
|
|
|
|
-
|
|
|
|
|
- if (room.Encounter == null || !room.Encounter.HasLivingMonsters) return false;
|
|
|
|
|
-
|
|
|
|
|
- int hostiles = room.Encounter.AliveMonsterCount;
|
|
|
|
|
- float threat = room.Encounter.GetPerceivedChallengeRating(agent.CharacterSheet);
|
|
|
|
|
-
|
|
|
|
|
- // Already tried and failed to finish these off once - don't loop on it.
|
|
|
|
|
- if (agent.HasFledFrom(room.Id))
|
|
|
|
|
- {
|
|
|
|
|
- Think(ThoughtKind.Avoid, $"Still {hostiles} hostile(s) here and I've already failed to beat them - leaving.");
|
|
|
|
|
- return false; // fall through to MoveOn so it actually walks out this tick
|
|
|
|
|
- }
|
|
|
|
|
-
|
|
|
|
|
- if (threat <= _retreatThreshold)
|
|
|
|
|
- {
|
|
|
|
|
- Think(ThoughtKind.Fight,
|
|
|
|
|
- $"Peeked: {hostiles} hostile(s), threat {threat:0.00} vs my limit {_retreatThreshold:0.00} - engaging. Fight starts in {_director.JoinWindowSeconds:0.0}s, hopefully someone joins.");
|
|
|
|
|
- _director.DeclareFight(room, agent, $"sized up {hostiles} hostile(s) at threat {threat:0.00} and attacked");
|
|
|
|
|
- return true;
|
|
|
|
|
- }
|
|
|
|
|
-
|
|
|
|
|
- // Too dangerous to pick - try to get out without a brawl.
|
|
|
|
|
- Think(ThoughtKind.Avoid, $"Peeked: {hostiles} hostile(s) at threat {threat:0.00}, over my limit {_retreatThreshold:0.00} - trying to slip out instead of fighting.");
|
|
|
|
|
-
|
|
|
|
|
- if (_director.TrySneakOut(agent, out float odds))
|
|
|
|
|
- {
|
|
|
|
|
- Think(ThoughtKind.Sneak, $"Slipped away unnoticed ({odds:P0} odds).");
|
|
|
|
|
- return false; // MoveOn carries it out of the room this same tick
|
|
|
|
|
- }
|
|
|
|
|
-
|
|
|
|
|
- Think(ThoughtKind.Sneak, $"Botched the sneak ({odds:P0} odds) - they've seen me, no choice but to fight.");
|
|
|
|
|
- _director.DeclareFight(room, agent, $"was caught trying to sneak out past {hostiles} hostile(s)");
|
|
|
|
|
- return true;
|
|
|
|
|
- }
|
|
|
|
|
-
|
|
|
|
|
- /// <summary>
|
|
|
|
|
- /// The agent walked into a room where a fight is already forming. It can join (raising
|
|
|
|
|
- /// the odds for everyone) or try to sneak by - and a botched sneak forces it in anyway.
|
|
|
|
|
- /// </summary>
|
|
|
|
|
- private bool HandleArrivalAtFight(MazeAgent agent, PendingFight fight)
|
|
|
|
|
- {
|
|
|
|
|
- float threat = fight.Encounter.GetPerceivedChallengeRating(agent.CharacterSheet);
|
|
|
|
|
-
|
|
|
|
|
- // The threat is shared: joining N-1 allies makes the same monsters worth risking.
|
|
|
|
|
- float sharedThreat = threat / (fight.Agents.Count + 1);
|
|
|
|
|
- bool wantsToSneak = sharedThreat > _retreatThreshold || agent.HasFledFrom(fight.Room.Id);
|
|
|
|
|
-
|
|
|
|
|
- if (!wantsToSneak)
|
|
|
|
|
- Think(ThoughtKind.Fight, $"Walked into fight #{fight.Id} - {fight.Agents.Count} ally(s) already in, shared threat {sharedThreat:0.00}. Joining.");
|
|
|
|
|
- else
|
|
|
|
|
- Think(ThoughtKind.Sneak, $"Walked into fight #{fight.Id} - shared threat {sharedThreat:0.00} still over my limit. Trying to sneak past it.");
|
|
|
|
|
-
|
|
|
|
|
- switch (_director.ResolveArrival(agent, wantsToSneak))
|
|
|
|
|
- {
|
|
|
|
|
- case CombatDirector.ArrivalDecision.Joined:
|
|
|
|
|
- case CombatDirector.ArrivalDecision.SneakFailedAndJoined:
|
|
|
|
|
- return true; // engaged now; the director takes it from here
|
|
|
|
|
-
|
|
|
|
|
- case CombatDirector.ArrivalDecision.SneakedPast:
|
|
|
|
|
- // Made it past - keep going straight through into the next room.
|
|
|
|
|
- Think(ThoughtKind.Sneak, "Made it past them - pressing on into the next room.");
|
|
|
|
|
- return false;
|
|
|
|
|
-
|
|
|
|
|
- default:
|
|
|
|
|
- return false;
|
|
|
|
|
- }
|
|
|
|
|
- }
|
|
|
|
|
-
|
|
|
|
|
- // ---------------------------------------------------------------- movement
|
|
|
|
|
-
|
|
|
|
|
- private void MoveOn(MazeAgent agent)
|
|
|
|
|
- {
|
|
|
|
|
- Exit choice = ChooseExit(agent, agent.CurrentRoom);
|
|
|
|
|
- if (choice == null)
|
|
|
|
|
- {
|
|
|
|
|
- Think(ThoughtKind.Move, $"No usable door out of room {agent.CurrentRoom.Id} - waiting.");
|
|
|
|
|
- return;
|
|
|
|
|
- }
|
|
|
|
|
-
|
|
|
|
|
- string heading = CompassName(choice.DirectionDegrees);
|
|
|
|
|
- Room known = choice.ConnectedRoomId.HasValue ? _generator.Graph.Get(choice.ConnectedRoomId.Value) : null;
|
|
|
|
|
- string destination = known != null
|
|
|
|
|
- ? $"room {known.Id}{(agent.HasVisited(known.Id) ? " (been there)" : " (new to me)")}"
|
|
|
|
|
- : "whatever is behind it (never opened)";
|
|
|
|
|
-
|
|
|
|
|
- if (_controller.TryUseExit(choice))
|
|
|
|
|
- Think(ThoughtKind.Move, $"Taking the {heading} door into {destination}.");
|
|
|
|
|
- else
|
|
|
|
|
- Think(ThoughtKind.Move, $"The {heading} door out of room {agent.CurrentRoom.Id} went nowhere - picking again next tick.");
|
|
|
|
|
- }
|
|
|
|
|
-
|
|
|
|
|
- /// <summary>
|
|
|
|
|
- /// Picks the next exit to take. Strongly prefers an unused exit (fresh, unexplored
|
|
|
|
|
- /// territory); failing that, prefers a used exit into a room this agent personally
|
|
|
|
|
- /// hasn't visited yet (following where another agent has already been); only
|
|
|
|
|
- /// backtracks into a room it's already visited as a last resort. `exploreChance`
|
|
|
|
|
- /// adds some randomness so a "follow" opportunity is occasionally taken even when
|
|
|
|
|
- /// exploring is available, rather than being 100% deterministic.
|
|
|
|
|
- ///
|
|
|
|
|
- /// A few anti-stuck rules keep this from looping forever around a small ring of
|
|
|
|
|
- /// already-explored rooms (e.g. a square formed by a loop-closing connection):
|
|
|
|
|
- /// - The door leading straight back to the room the agent just came from is excluded
|
|
|
|
|
- /// from consideration unless it's the only door available at all, so agents don't
|
|
|
|
|
- /// ping-pong between two rooms.
|
|
|
|
|
- /// - When forced to backtrack, exits that "peek" into a room with its own unexplored
|
|
|
|
|
- /// option beyond it (an unused exit, or one leading somewhere this agent hasn't
|
|
|
|
|
- /// personally seen) are preferred over ones that only lead deeper into fully-picked-
|
|
|
|
|
- /// over territory - this steers the agent back out of a dead loop instead of just
|
|
|
|
|
- /// circling it.
|
|
|
|
|
- /// - Ties are broken toward whichever room this agent has personally visited the
|
|
|
|
|
- /// fewest times, so a small loop gets cycled through rather than bounced around.
|
|
|
|
|
|
|
+ /// Constructs the decision tree with all available decisions in priority order.
|
|
|
|
|
+ /// Add new decisions here to extend agent behavior without modifying Update().
|
|
|
///
|
|
///
|
|
|
- /// Also evaluates monster threats in adjacent rooms - if a room looks too dangerous,
|
|
|
|
|
- /// the agent will retreat (choose a different exit) if possible.
|
|
|
|
|
- /// </summary>
|
|
|
|
|
- private Exit ChooseExit(MazeAgent agent, Room room)
|
|
|
|
|
- {
|
|
|
|
|
- Exit reverseExit = FindReverseExit(agent, room);
|
|
|
|
|
-
|
|
|
|
|
- var explore = new List<Exit>();
|
|
|
|
|
- var follow = new List<Exit>();
|
|
|
|
|
- var backtrack = new List<Exit>();
|
|
|
|
|
-
|
|
|
|
|
- foreach (var exit in room.Exits)
|
|
|
|
|
- {
|
|
|
|
|
- if (exit == reverseExit) continue;
|
|
|
|
|
- if (exit.IsBlocked) continue; // Skip exits that are permanently unusable
|
|
|
|
|
-
|
|
|
|
|
- if (!exit.IsUsed)
|
|
|
|
|
- {
|
|
|
|
|
- explore.Add(exit);
|
|
|
|
|
- continue;
|
|
|
|
|
- }
|
|
|
|
|
-
|
|
|
|
|
- bool leadsToVisited = exit.ConnectedRoomId.HasValue && agent.HasVisited(exit.ConnectedRoomId.Value);
|
|
|
|
|
- (leadsToVisited ? backtrack : follow).Add(exit);
|
|
|
|
|
- }
|
|
|
|
|
-
|
|
|
|
|
- // Filter exits by threat level - remove dangerous ones if safer options exist
|
|
|
|
|
- explore = FilterByThreat(explore, agent);
|
|
|
|
|
- follow = FilterByThreat(follow, agent);
|
|
|
|
|
- backtrack = FilterByThreat(backtrack, agent);
|
|
|
|
|
-
|
|
|
|
|
- if (explore.Count > 0 && UnityEngine.Random.value < _exploreChance)
|
|
|
|
|
- return RandomPick(explore);
|
|
|
|
|
-
|
|
|
|
|
- if (follow.Count > 0)
|
|
|
|
|
- return LeastVisitedPick(agent, follow);
|
|
|
|
|
-
|
|
|
|
|
- if (explore.Count > 0)
|
|
|
|
|
- return RandomPick(explore);
|
|
|
|
|
-
|
|
|
|
|
- if (backtrack.Count > 0)
|
|
|
|
|
- return ChooseBacktrack(agent, backtrack);
|
|
|
|
|
-
|
|
|
|
|
- // Excluding the reverse door left nothing - it's genuinely the only way out
|
|
|
|
|
- // (e.g. a single-exit dead end), so take it (even if dangerous).
|
|
|
|
|
- return reverseExit;
|
|
|
|
|
- }
|
|
|
|
|
-
|
|
|
|
|
- /// <summary>The door in `room` that leads straight back to wherever the agent's last move came from, if any.</summary>
|
|
|
|
|
- private static Exit FindReverseExit(MazeAgent agent, Room room)
|
|
|
|
|
- {
|
|
|
|
|
- int? reverseExitId = agent.LastUsedExit?.ConnectedExitId;
|
|
|
|
|
- if (!reverseExitId.HasValue) return null;
|
|
|
|
|
- return room.Exits.Find(e => e.Id == reverseExitId.Value);
|
|
|
|
|
- }
|
|
|
|
|
-
|
|
|
|
|
- private Exit ChooseBacktrack(MazeAgent agent, List<Exit> backtrack)
|
|
|
|
|
- {
|
|
|
|
|
- var promising = backtrack.Where(e => LeadsTowardUnexploredOptions(agent, e)).ToList();
|
|
|
|
|
- return LeastVisitedPick(agent, promising.Count > 0 ? promising : backtrack);
|
|
|
|
|
- }
|
|
|
|
|
-
|
|
|
|
|
- /// <summary>
|
|
|
|
|
- /// One-step lookahead: does the room beyond `exit` itself have any door (other than
|
|
|
|
|
- /// the one leading straight back here) that's still unused or leads somewhere this
|
|
|
|
|
- /// agent hasn't personally been? If so, backtracking this way still has somewhere
|
|
|
|
|
- /// useful to go; if not, it just leads deeper into already-picked-over territory.
|
|
|
|
|
|
|
+ /// Example of adding a custom decision:
|
|
|
|
|
+ /// _decisionTree.Add(new MyCustomDecision(...));
|
|
|
/// </summary>
|
|
/// </summary>
|
|
|
- private bool LeadsTowardUnexploredOptions(MazeAgent agent, Exit exit)
|
|
|
|
|
|
|
+ private void BuildDecisionTree()
|
|
|
{
|
|
{
|
|
|
- if (!exit.ConnectedRoomId.HasValue) return false;
|
|
|
|
|
-
|
|
|
|
|
- Room target = _generator.Graph.Get(exit.ConnectedRoomId.Value);
|
|
|
|
|
- if (target == null) return false;
|
|
|
|
|
-
|
|
|
|
|
- int reverseExitId = exit.ConnectedExitId ?? -1;
|
|
|
|
|
- foreach (var candidate in target.Exits)
|
|
|
|
|
- {
|
|
|
|
|
- if (candidate.Id == reverseExitId) continue;
|
|
|
|
|
- if (!candidate.IsUsed) return true;
|
|
|
|
|
- if (candidate.ConnectedRoomId.HasValue && !agent.HasVisited(candidate.ConnectedRoomId.Value)) return true;
|
|
|
|
|
- }
|
|
|
|
|
- return false;
|
|
|
|
|
- }
|
|
|
|
|
-
|
|
|
|
|
- private static Exit LeastVisitedPick(MazeAgent agent, List<Exit> exits)
|
|
|
|
|
- {
|
|
|
|
|
- int bestVisits = int.MaxValue;
|
|
|
|
|
- var bestExits = new List<Exit>();
|
|
|
|
|
-
|
|
|
|
|
- foreach (var exit in exits)
|
|
|
|
|
- {
|
|
|
|
|
- int visits = exit.ConnectedRoomId.HasValue ? agent.VisitCount(exit.ConnectedRoomId.Value) : 0;
|
|
|
|
|
- if (visits < bestVisits)
|
|
|
|
|
- {
|
|
|
|
|
- bestVisits = visits;
|
|
|
|
|
- bestExits.Clear();
|
|
|
|
|
- bestExits.Add(exit);
|
|
|
|
|
- }
|
|
|
|
|
- else if (visits == bestVisits)
|
|
|
|
|
- {
|
|
|
|
|
- bestExits.Add(exit);
|
|
|
|
|
- }
|
|
|
|
|
- }
|
|
|
|
|
|
|
+ _decisionTree = new AgentDecisionTree();
|
|
|
|
|
|
|
|
- return RandomPick(bestExits);
|
|
|
|
|
- }
|
|
|
|
|
|
|
+ // Priority order:
|
|
|
|
|
+ // 1. Heal if needed (health is more important than combat)
|
|
|
|
|
+ _decisionTree.Add(new UseHealthPotionDecision());
|
|
|
|
|
|
|
|
- private static Exit RandomPick(List<Exit> exits) => exits[UnityEngine.Random.Range(0, exits.Count)];
|
|
|
|
|
|
|
+ // 2. Handle existing fights (join or sneak past)
|
|
|
|
|
+ _decisionTree.Add(new HandleExistingFightDecision(_director, _retreatThreshold));
|
|
|
|
|
|
|
|
- // ---------------------------------------------------------------- peeking
|
|
|
|
|
|
|
+ // 3. Declare new fights if appropriate
|
|
|
|
|
+ _decisionTree.Add(new DeclareFightDecision(_director, _retreatThreshold));
|
|
|
|
|
|
|
|
- /// <summary>
|
|
|
|
|
- /// Peek into a room (via an exit) to see if there are monsters and how many.
|
|
|
|
|
- /// Returns the perceived challenge rating, or 0 if no monsters/already explored.
|
|
|
|
|
- /// Agent sees incomplete info (60-100% of actual monsters, doesn't know about hidden ones).
|
|
|
|
|
- /// </summary>
|
|
|
|
|
- public float PeekThroughExit(Exit exit, MazeAgent agent)
|
|
|
|
|
- {
|
|
|
|
|
- if (!exit.ConnectedRoomId.HasValue)
|
|
|
|
|
- return 0; // Unresolved exit - can't peek yet
|
|
|
|
|
|
|
+ // 4. Sneak away if threatened
|
|
|
|
|
+ _decisionTree.Add(new SneakAwayDecision(_director, _retreatThreshold));
|
|
|
|
|
|
|
|
- Room nextRoom = _generator.Graph.Get(exit.ConnectedRoomId.Value);
|
|
|
|
|
- if (nextRoom == null || nextRoom.Encounter == null)
|
|
|
|
|
- return 0; // No encounter in this room
|
|
|
|
|
-
|
|
|
|
|
- return nextRoom.Encounter.GetPerceivedChallengeRating(agent.CharacterSheet);
|
|
|
|
|
|
|
+ // 5. Catch-all: move to next room (always succeeds)
|
|
|
|
|
+ _decisionTree.Add(new MoveOnDecision(_generator, _controller, _exploreChance, _retreatThreshold));
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
- /// <summary>
|
|
|
|
|
- /// Decide if an exit is worth taking based on threat level inside.
|
|
|
|
|
- /// Returns true if the exit is relatively safe or if the agent has no choice,
|
|
|
|
|
- /// false if the agent decides to retreat from this exit.
|
|
|
|
|
- /// </summary>
|
|
|
|
|
- private bool ShouldTakeExit(Exit exit, MazeAgent agent)
|
|
|
|
|
- {
|
|
|
|
|
- if (!exit.ConnectedRoomId.HasValue)
|
|
|
|
|
- return true; // Unresolved exit - proceed cautiously
|
|
|
|
|
-
|
|
|
|
|
- Room nextRoom = _generator.Graph.Get(exit.ConnectedRoomId.Value);
|
|
|
|
|
- if (nextRoom == null || nextRoom.Encounter == null || !nextRoom.Encounter.HasLivingMonsters)
|
|
|
|
|
- return true; // No encounter, safe to enter
|
|
|
|
|
-
|
|
|
|
|
- // Peek and evaluate threat
|
|
|
|
|
- float perceivedCR = nextRoom.Encounter.GetPerceivedChallengeRating(agent.CharacterSheet);
|
|
|
|
|
-
|
|
|
|
|
- // If threat is below threshold, take it. If above, retreat with some randomness.
|
|
|
|
|
- if (perceivedCR <= _retreatThreshold)
|
|
|
|
|
- {
|
|
|
|
|
- NarratePeek(agent, exit, nextRoom, perceivedCR, false);
|
|
|
|
|
- return true; // Manageable threat
|
|
|
|
|
- }
|
|
|
|
|
-
|
|
|
|
|
- // Higher difficulty = more likely to retreat, but add randomness for personality
|
|
|
|
|
- float retreatChance = Mathf.Clamp01((perceivedCR - _retreatThreshold) / 2.0f);
|
|
|
|
|
- bool avoiding = UnityEngine.Random.value <= retreatChance;
|
|
|
|
|
- NarratePeek(agent, exit, nextRoom, perceivedCR, avoiding);
|
|
|
|
|
- return !avoiding;
|
|
|
|
|
- }
|
|
|
|
|
-
|
|
|
|
|
- /// <summary>
|
|
|
|
|
- /// Narrates a peek, throttled per room so an agent standing next to a dangerous room
|
|
|
|
|
- /// doesn't spam the same observation every decision tick.
|
|
|
|
|
- /// </summary>
|
|
|
|
|
- private void NarratePeek(MazeAgent agent, Exit exit, Room peeked, float threat, bool avoiding)
|
|
|
|
|
- {
|
|
|
|
|
- int narrated = _peekCount.TryGetValue(peeked.Id, out int count) ? count : 0;
|
|
|
|
|
- if (narrated >= MaxNarratedPeeksPerRoom) return;
|
|
|
|
|
- _peekCount[peeked.Id] = narrated + 1;
|
|
|
|
|
-
|
|
|
|
|
- // VisibleMonsterCount is fixed at spawn time and isn't reduced as monsters die, so
|
|
|
|
|
- // clamp it or a half-cleared room reads as busier than it is.
|
|
|
|
|
- int hostiles = Mathf.Min(peeked.Encounter.VisibleMonsterCount, peeked.Encounter.AliveMonsterCount);
|
|
|
|
|
- string heading = CompassName(exit.DirectionDegrees);
|
|
|
|
|
-
|
|
|
|
|
- if (avoiding)
|
|
|
|
|
- Think(ThoughtKind.Avoid, $"Peeked {heading} into room {peeked.Id}: ~{hostiles} hostile(s), threat {threat:0.00} - too rich for me, looking for another door.");
|
|
|
|
|
- else
|
|
|
|
|
- Think(ThoughtKind.Peek, $"Peeked {heading} into room {peeked.Id}: ~{hostiles} hostile(s), threat {threat:0.00} - that's manageable.");
|
|
|
|
|
- }
|
|
|
|
|
-
|
|
|
|
|
- /// <summary>
|
|
|
|
|
- /// Filter exits: remove those the agent wants to retreat from due to high threat.
|
|
|
|
|
- /// This is called during exit selection so dangerous rooms are avoided when possible.
|
|
|
|
|
- /// </summary>
|
|
|
|
|
- private List<Exit> FilterByThreat(List<Exit> exits, MazeAgent agent)
|
|
|
|
|
|
|
+ private void Update()
|
|
|
{
|
|
{
|
|
|
- var safeExits = new List<Exit>();
|
|
|
|
|
|
|
+ MazeAgent agent = _controller?.Agent;
|
|
|
|
|
+ if (agent == null || agent.IsFinished || agent.IsDead) return;
|
|
|
|
|
+ if (_isLevelEnded != null && _isLevelEnded()) return;
|
|
|
|
|
|
|
|
- foreach (var exit in exits)
|
|
|
|
|
- {
|
|
|
|
|
- if (ShouldTakeExit(exit, agent))
|
|
|
|
|
- safeExits.Add(exit);
|
|
|
|
|
- }
|
|
|
|
|
|
|
+ // Committed to a fight: the CombatDirector owns this agent until it resolves.
|
|
|
|
|
+ if (agent.IsEngaged) return;
|
|
|
|
|
|
|
|
- // If all exits are dangerous, agent must take one anyway (don't get stuck)
|
|
|
|
|
- return safeExits.Count > 0 ? safeExits : exits;
|
|
|
|
|
- }
|
|
|
|
|
|
|
+ _timer -= SimulationClock.DeltaTime;
|
|
|
|
|
+ if (_timer > 0f) return;
|
|
|
|
|
+ _timer = _decisionInterval;
|
|
|
|
|
|
|
|
- /// <summary>Exits are cardinal-only, so a direction always has an exact compass name.</summary>
|
|
|
|
|
- private static string CompassName(float directionDegrees)
|
|
|
|
|
- {
|
|
|
|
|
- int normalized = Mathf.RoundToInt(Mathf.Repeat(directionDegrees, 360f) / 90f) % 4;
|
|
|
|
|
- switch (normalized)
|
|
|
|
|
- {
|
|
|
|
|
- case 0: return "east";
|
|
|
|
|
- case 1: return "north";
|
|
|
|
|
- case 2: return "west";
|
|
|
|
|
- default: return "south";
|
|
|
|
|
- }
|
|
|
|
|
|
|
+ // Evaluate decision tree: returns true if a decision executed and was narrated
|
|
|
|
|
+ _decisionTree?.TryDecide(agent);
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|