// GDnD wiki example // Demonstrates: a guard NPC driven by a behaviour tree (flee when hurt > attack when close > chase when seen > patrol) // Related pages: [[behaviour-trees]], [[ai-state-machine-pattern]], [[steering-behaviours]], [[npc-behaviour-readability]] using UnityEngine; // The tree is rebuilt from scratch in Awake and ticked from the root every frame. // Because nothing remembers which branch was running, a higher-priority branch // takes over the moment its condition becomes true: the guard drops its patrol // as soon as it sees you, and drops the fight as soon as its health is low. public class GuardAI : MonoBehaviour { [Header("References")] [SerializeField] private Transform player; [SerializeField] private Transform[] waypoints; [Header("Senses")] [SerializeField] private float viewRadius = 8f; [SerializeField, Range(10f, 360f)] private float viewAngle = 110f; [SerializeField] private LayerMask obstacleMask; // leave as Nothing to ignore walls [Header("Movement")] [SerializeField] private float patrolSpeed = 2f; [SerializeField] private float chaseSpeed = 4f; [SerializeField] private float fleeSpeed = 4.5f; [Header("Combat")] [SerializeField] private float attackRange = 1.5f; [SerializeField] private float attackCooldown = 1f; [Header("Health (edit in Play mode)")] [SerializeField, Range(0f, 100f)] private float health = 100f; [SerializeField] private float fleeBelow = 30f; [SerializeField] private float recoverAbove = 60f; [SerializeField] private float regenWhileFleeing = 10f; [Header("Debug (read only)")] [SerializeField] private string currentAction; private BTNode root; private Renderer body; private int waypointIndex; private float lastAttackTime = -999f; private bool fleeing; private void Awake() { body = GetComponentInChildren(); // Priority runs top to bottom: the first branch that does not fail wins. root = new SelectorNode("Root", new SequenceNode("Flee when hurt", new ConditionNode("Health low?", ShouldFlee), new ActionNode("Flee", Flee)), new SequenceNode("Attack when close", new ConditionNode("Player in reach?", PlayerInReach), new InverterNode("Not cooling down", new ConditionNode("Cooling down?", AttackCoolingDown)), new ActionNode("Strike", Strike)), new SequenceNode("Chase when seen", new ConditionNode("Can see player?", CanSeePlayer), new ActionNode("Chase", Chase)), new ActionNode("Patrol", Patrol)); } private void Update() => root.Tick(); // ---------- Conditions ---------- // Two thresholds (hysteresis) stop the guard flickering between flee and fight // when its health hovers around a single cut-off value. private bool ShouldFlee() { if (health < fleeBelow) fleeing = true; else if (health >= recoverAbove) fleeing = false; return fleeing; } private bool PlayerInReach() => player != null && Flat(player.position - transform.position).magnitude <= attackRange; private bool AttackCoolingDown() => Time.time < lastAttackTime + attackCooldown; private bool CanSeePlayer() { if (player == null) return false; Vector3 toPlayer = Flat(player.position - transform.position); if (toPlayer.magnitude > viewRadius) return false; if (Vector3.Angle(transform.forward, toPlayer) > viewAngle * 0.5f) return false; // Optional line of sight: blocked if a wall on obstacleMask is in the way. return !Physics.Linecast(transform.position, player.position, obstacleMask); } // ---------- Actions ---------- private NodeState Patrol() { Show("Patrol", Color.green); if (waypoints == null || waypoints.Length == 0) return NodeState.Failure; Vector3 target = waypoints[waypointIndex].position; if (MoveTowards(target, patrolSpeed, 0.1f)) waypointIndex = (waypointIndex + 1) % waypoints.Length; return NodeState.Running; // patrolling never "finishes" } private NodeState Chase() { Show("Chase", Color.yellow); MoveTowards(player.position, chaseSpeed, attackRange * 0.9f); return NodeState.Running; } private NodeState Strike() { Show("Strike", Color.red); Face(player.position); lastAttackTime = Time.time; Debug.Log($"{name} strikes at {player.name}!"); return NodeState.Success; } private NodeState Flee() { Show("Flee", Color.cyan); health = Mathf.Min(100f, health + regenWhileFleeing * Time.deltaTime); if (player == null) return NodeState.Running; Vector3 away = Flat(transform.position - player.position).normalized; MoveTowards(transform.position + away * 2f, fleeSpeed, 0f); return NodeState.Running; } // ---------- Helpers ---------- // Moves on the ground plane; returns true once within stopDistance of the target. private bool MoveTowards(Vector3 target, float speed, float stopDistance) { target.y = transform.position.y; if (Vector3.Distance(transform.position, target) <= stopDistance) return true; Face(target); transform.position = Vector3.MoveTowards(transform.position, target, speed * Time.deltaTime); return Vector3.Distance(transform.position, target) <= stopDistance; } private void Face(Vector3 target) { Vector3 dir = Flat(target - transform.position); if (dir.sqrMagnitude > 0.0001f) transform.rotation = Quaternion.LookRotation(dir); } private static Vector3 Flat(Vector3 v) => new Vector3(v.x, 0f, v.z); private void Show(string action, Color colour) { currentAction = action; if (body != null) body.material.color = colour; } [ContextMenu("Take 25 damage")] private void TakeDamage() => health = Mathf.Max(0f, health - 25f); private void OnDrawGizmosSelected() { Gizmos.color = Color.yellow; Gizmos.DrawWireSphere(transform.position, viewRadius); Gizmos.color = Color.red; Gizmos.DrawWireSphere(transform.position, attackRange); Vector3 left = Quaternion.Euler(0f, -viewAngle * 0.5f, 0f) * transform.forward; Vector3 right = Quaternion.Euler(0f, viewAngle * 0.5f, 0f) * transform.forward; Gizmos.color = Color.white; Gizmos.DrawRay(transform.position, left * viewRadius); Gizmos.DrawRay(transform.position, right * viewRadius); } }