Source file unity-csharp/game-ai/utility-ai/VillagerAI.cs from the GDnD code examples. Download raw file

// GDnD wiki example
// Demonstrates: a utility AI villager scoring Eat / Sleep / Work / Flee / Idle and acting on the best, with commitment (inertia)
// Related pages: [[utility-ai]], [[utility-score]], [[behaviour-trees]], [[npc-behaviour-readability]]
 
using System.Collections.Generic;
using System.Text;
using UnityEngine;
 
// There are no transitions anywhere in this script. Every decisionInterval the
// villager scores every action from its needs and picks the highest. Change a need
// in the Inspector (or drag the Threat closer) and a different action wins.
public class VillagerAI : MonoBehaviour
{
    [Header("World")]
    [SerializeField] private Transform threat;
    [SerializeField] private float threatRadius = 6f;
    [SerializeField] private float moveSpeed = 3f;
 
    [Header("Needs 0-100 (edit in Play mode)")]
    [SerializeField, Range(0f, 100f)] private float hunger = 20f;
    [SerializeField, Range(0f, 100f)] private float energy = 80f;
    [SerializeField, Range(0f, 100f)] private float money = 30f;
    [SerializeField] private float hungerPerSecond = 3f;
    [SerializeField] private float tirednessPerSecond = 2f;
 
    [Header("Decision")]
    [SerializeField] private float decisionInterval = 0.25f;
    [SerializeField] private bool compensateForConsiderationCount;
    [SerializeField, Range(1f, 2f)] private float commitmentBonus = 1.15f;
    [SerializeField] private List<UtilityAction> actions = new List<UtilityAction>();
 
    [Header("Debug (read only)")]
    [SerializeField] private string currentAction;
    [SerializeField, TextArea(5, 8)] private string scores;
 
    private UtilityAction current;
    private float nextDecisionTime;
    private Renderer body;
 
    private void Reset() => actions = DefaultActions();   // runs when the component is added
 
    private void Awake()
    {
        body = GetComponentInChildren<Renderer>();
        if (actions.Count == 0) actions = DefaultActions();
    }
 
    private void Update()
    {
        // Needs drift every frame whatever the villager is doing.
        hunger = Mathf.Clamp(hunger + hungerPerSecond * Time.deltaTime, 0f, 100f);
        energy = Mathf.Clamp(energy - tirednessPerSecond * Time.deltaTime, 0f, 100f);
 
        if (Time.time >= nextDecisionTime)
        {
            nextDecisionTime = Time.time + decisionInterval;
            Decide();
        }
        if (current != null) Perform(current);
    }
 
    // Each input is normalised to 0..1 here, so every curve works on the same scale.
    private float Sense(UtilityInput input)
    {
        switch (input)
        {
            case UtilityInput.Hunger:    return hunger / 100f;
            case UtilityInput.Tiredness: return 1f - energy / 100f;
            case UtilityInput.NeedMoney: return 1f - money / 100f;
            case UtilityInput.HasMoney:  return money / 100f;
            case UtilityInput.ThreatNearby:
                if (threat == null) return 0f;
                return 1f - Mathf.Clamp01(Vector3.Distance(transform.position, threat.position) / threatRadius);
            default: return 0f;
        }
    }
 
    private void Decide()
    {
        UtilityAction best = null;
        float bestScore = -1f;
        var report = new StringBuilder();
 
        foreach (UtilityAction action in actions)
        {
            float score = action.Score(Sense, compensateForConsiderationCount);
            // Inertia: favour what we are already doing so near-ties do not flicker.
            if (action == current) score *= commitmentBonus;
            report.AppendLine($"{action.name,-6} {score:0.00}");
            if (score > bestScore) { bestScore = score; best = action; }
        }
 
        scores = report.ToString();
        if (best != current)
        {
            current = best;
            currentAction = best.name;
            if (body != null) body.material.color = ColourFor(best.kind);
        }
    }
 
    private void Perform(UtilityAction action)
    {
        float dt = Time.deltaTime;
 
        if (action.kind == ActionKind.Flee)
        {
            if (threat == null) return;
            Vector3 away = transform.position - threat.position;
            away.y = 0f;
            transform.position += away.normalized * moveSpeed * dt;
            return;
        }
 
        // Walk to the action's location first; only act once there.
        if (action.location != null)
        {
            Vector3 target = action.location.position;
            target.y = transform.position.y;
            if (Vector3.Distance(transform.position, target) > 0.3f)
            {
                transform.position = Vector3.MoveTowards(transform.position, target, moveSpeed * dt);
                return;
            }
        }
 
        switch (action.kind)
        {
            case ActionKind.Eat:   hunger -= 20f * dt; money -= 4f * dt; break;
            case ActionKind.Sleep: energy += 15f * dt; break;
            case ActionKind.Work:  money += 8f * dt; energy -= 3f * dt; break;
        }
        hunger = Mathf.Clamp(hunger, 0f, 100f);
        energy = Mathf.Clamp(energy, 0f, 100f);
        money = Mathf.Clamp(money, 0f, 100f);
    }
 
    private static Color ColourFor(ActionKind kind) => kind switch
    {
        ActionKind.Eat => Color.green,
        ActionKind.Sleep => Color.blue,
        ActionKind.Work => Color.yellow,
        ActionKind.Flee => Color.red,
        _ => Color.grey,
    };
 
    // Starting curves. Edit them in the Inspector afterwards; that is the point.
    private static List<UtilityAction> DefaultActions()
    {
        return new List<UtilityAction>
        {
            Make("Eat", ActionKind.Eat, 1f,
                new Consideration(UtilityInput.Hunger, SlowThenSteep()),
                new Consideration(UtilityInput.HasMoney, AnyAtAll()),       // no money: vetoed
                new Consideration(UtilityInput.ThreatNearby, Falling())),
            Make("Sleep", ActionKind.Sleep, 1f,
                new Consideration(UtilityInput.Tiredness, SShape()),
                new Consideration(UtilityInput.ThreatNearby, Falling())),
            Make("Work", ActionKind.Work, 0.9f,
                new Consideration(UtilityInput.NeedMoney, Rising()),
                new Consideration(UtilityInput.Tiredness, Falling()),
                new Consideration(UtilityInput.ThreatNearby, Falling())),
            Make("Flee", ActionKind.Flee, 1f,
                new Consideration(UtilityInput.ThreatNearby, SlowThenSteep())),
            Make("Idle", ActionKind.Idle, 0.1f),                          // no considerations: a constant fallback
        };
    }
 
    // Each call makes a fresh curve so editing one consideration never changes another.
    private static AnimationCurve Rising() => AnimationCurve.Linear(0f, 0f, 1f, 1f);
    private static AnimationCurve Falling() => AnimationCurve.Linear(0f, 1f, 1f, 0f);
    private static AnimationCurve SShape() => AnimationCurve.EaseInOut(0f, 0f, 1f, 1f);
    private static AnimationCurve SlowThenSteep() =>                       // y = x squared
        new AnimationCurve(new Keyframe(0f, 0f, 0f, 0f), new Keyframe(1f, 1f, 2f, 2f));
    private static AnimationCurve AnyAtAll() =>                            // 0 at 0, 1 from 5%
        new AnimationCurve(new Keyframe(0f, 0f), new Keyframe(0.05f, 1f), new Keyframe(1f, 1f));
 
    private static UtilityAction Make(string name, ActionKind kind, float weight, params Consideration[] cs) =>
        new UtilityAction { name = name, kind = kind, weight = weight, considerations = new List<Consideration>(cs) };
}