Anubis: Adding Proxy Nodes and Expanding Selection Logic

From Baldur's Gate 3 Modding

Overview

In this tutorial, we’ll explore the advanced concept of proxy nodes and how they help structure and organise complex behaviours. Proxy nodes give you greater control over behaviour trees by enabling modular and reusable components. Additionally, we’ll dive deeper into node selection logic and how Anubis processes and selects actions.

By the end of this tutorial, you will:

  • Understand what proxy nodes are and how to use them
  • Learn advanced selection logic techniques
  • Create a more complex behaviour with modular, reusable nodes

Part One: What Are Proxy Nodes?

A Proxy Node acts as a placeholder or connector in a behaviour tree. You can use proxy nodes to link to other behaviour trees or nodes, enabling you to create modular behaviours. This approach makes behaviour trees more manageable and encourages code reuse.

Think of proxy nodes like “subroutines” in traditional programming, where a proxy node represents another set of states that can be executed within the current tree.

Why Use Proxy Nodes?

  • Modularity: Break down complex behaviours into smaller, manageable components.
  • Reusability: Share behaviours across multiple characters or scenarios without duplicating code.
  • Flexibility: Dynamically switch between different behaviours based on game conditions.

Part Two: Adding Proxy Nodes to Your Behaviour Tree

Creating a Separate GuardWander Script

We will continue working with the script from the Modifying Simple Behaviours with Interruptions and Selectors tutorial.

Here’s the final version of the script from that tutorial:

game.states.Guard = State {
    function ()
        -- local variable that tells us whether the guard is tired or not
        local tired = false
        -- local variable for a trigger upon entering which the player will trigger Cower reaction from our guard
        local playerApproachTrigger = Entity("S_EventTrigger_0687d319-0436-4091-8389-15f28536a8e8")
        -- local variable that tells us whether there is a player nearby (we store the player in this variable)
        local nearbyPlayer = nil
        nodes.GuardAction = Selector {
            function(nodes)
                return FindRandomSelectable(nodes)
            end,
            Valid = function()
                return nearbyPlayer ~= nil
            end
        }
        -- Cower action
        nodes.GuardAction.Cower = Action {
            function ()
                DebugText(me, "I'm cowering!")
                Sleep(6.0)
            end
        }
        -- Attack action
        nodes.GuardAction.Attack = Action {
            function ()
                DebugText(me, "I'm attacking!")
                Sleep(6.0)
            end
        }
        -- Wander action
        nodes.Wander = Action {
            function ()
                DebugText(me, "Not tired, wandering around")
                Sleep(6.0)
                tired = true -- after walking around needs to rest
            end,
            CanEnter = function ()
                return not tired -- Wander only if not tired
            end,
            Valid = function()
                return nearbyPlayer == nil
            end
        }
        -- Stand and guard action
        nodes.Stand = Action {
            function ()
                DebugText(me, "Tired, standing still and guarding something")
                Sleep(6.0)
                tired = false
            end,
            CanEnter = function ()
                return tired -- Stand still only if tired
            end,
            Valid = function()
                return nearbyPlayer == nil
            end
        }
        events.EnteredTrigger = function(e)
            if e.Trigger == playerApproachTrigger and e.Target.Character.IsPlayer then
                nearbyPlayer = e.Target
            end
        end
        events.LeftTrigger = function(e)
            if e.Trigger == playerApproachTrigger and e.Target == nearbyPlayer then
                nearbyPlayer = nil
            end
        end
    end
}

Extracting the Wander Action into a Separate Script

Let’s assume that the Wander action in the script above is so well-written that other scripters want to reuse it. We can achieve this by extracting the code into a separate Anubis script.

Create a new script and call it GuardWander.ann. Add the following code:

game.states.GuardWander = State { 
   function ()
       params.anchor = {type = EParamType.String, help=The trigger around which the character will wander.}
       local anchor = Entity(params.anchor)
       nodes.GuardWander = Action {
           function ()
               DebugText(me, "Wandering around")
               Wander(4.0, 6.0, MovementSpeed.Stroll, anchor)
           end,
       }
   end

Understanding the GuardWander Script

  • Trigger Parameter: params.anchor = {type = EParamType.String, help=[[The trigger around which the character will wander.]]} defines a new parameter for the script. When other scripts reference this one, they must provide a trigger, formatted as a string (NAME_UUID, where NAME is optional, but the UUID is required).
  • Local Variable: After receiving a trigger as a string, it’s converted to a local variable of type Entity: local anchor = Entity(params.anchor).
  • Wander Action: Wander(4.0, 6.0, MovementSpeed.Stroll, anchor) instructs the engine to make the character wander around the specified anchor trigger, with a distance of 4 meters, a duration of 6 seconds, and a "Stroll" movement speed.

Using a Proxy Node to Reference the Script

Now, instead of defining the Wander action directly in our main script, we’ll reference the separate GuardWander behaviour using a Proxy node. Replace the existing Wander node with the following:

-- Wander action 
nodes.Wander = Proxy {	
   game.states.GuardWander,
   params = {
	anchor = [[S_WanderArea_a1017cd3-cbde-44f1-b2d9-00572a2dc9c3]]
   },
   CanEnter = function ()
	return not tired -- Wander only if not tired
   end,
   Valid = function()
	return nearbyPlayer == nil
   end,
   -- When the character finishes wandering around, we mark the character as tired
   -- OnFinished IS NOT called when the current action is interrupted (e.g. the player attacks the guard)
   OnFinished = function()
	tired = true
   end,
   -- OnLeave will be called when the current action is interrupted (e.g. the player attacks the guard)
   OnLeave = function()
	tired = true
   end,
 }

Understanding the Changes Related to the Wander Proxy Node

  • Proxy Node: The line nodes.Wander = Proxy tells Anubis that we’re using a proxy node to reference another script.
  • Reference to the Other Script: game.states.GuardWander tells Anubis which specific script to reuse.
  • Parameters of a Proxy Node: Since the GuardWander script requires a trigger parameter (anchor), we provide it here in the params section.
  • OnFinished function: Previously, we set the tired variable directly in the Wander node, but now we can no longer do that, so instead we modify it after the GuardWander action finishes (which achieves the same result).
  • OnLeave function: Does the same as OnFinished, but they are triggered under different circumstances. OnFinished is triggered when the guard actually finishes the wandering action, while OnLeave is called if the wandering action was interrupted (e.g. the player attacked the guard).

Part Three: Creating a More Complex Selector

Choosing an Action Based on Probability

Currently, the guard switches between wandering and guarding every time an action finishes. Let’s modify this behaviour to make the guard wander 70% of the time and rest (stand guard) 30% of the time.

Here’s the updated script:

game.states.Guard = State {   
   function ()
       -- local variable for a trigger upon entering which the player will trigger Cower reaction from our guard
       local playerApproachTrigger = Entity("S_EventTrigger_0687d319-0436-4091-8389-15f28536a8e8")
       -- local variable that tells us whether there is a player nearby (we store the player in this variable)
       local nearbyPlayer = nil
       nodes.GuardAction = Selector {
           function(nodes)
               return FindRandomSelectable(nodes)
           end,
           Valid = function()
               return nearbyPlayer ~= nil
           end
       }
       -- Cower action
       nodes.GuardAction.Cower = Action {
           function ()
               DebugText(me, "I'm cowering!")
               Sleep(6.0)
           end
       }
       -- Attack action
       nodes.GuardAction.Attack = Action {
           function ()
               DebugText(me, "I'm attacking!")
               Sleep(6.0)
           end
       }
       nodes.GuardPeacefulAction = Selector {
           function(nodes)
               return FindWeightedRandomSelectable(nodes, {
                   [nodes.Wander] = 7,
                   [nodes.Stand] = 3
               })
           end
       }
       -- Wander action
       nodes.GuardPeacefulAction.Wander = Proxy {
           game.states.GuardWander,
           params = {
               anchor = S_WanderArea_a1017cd3-cbde-44f1-b2d9-00572a2dc9c3
           }
       }
       -- Stand and guard action
       nodes.GuardPeacefulAction.Stand = Action {
           function ()
               DebugText(me, "Tired, standing still and guarding something")
               Sleep(6.0)
           end
       }
       events.EnteredTrigger = function(e)
           if e.Trigger == playerApproachTrigger and e.Target.Character.IsPlayer then
               nearbyPlayer = e.Target
           end
       end
       events.LeftTrigger = function(e)
           if e.Trigger == playerApproachTrigger and e.Target == nearbyPlayer then
               nearbyPlayer = nil
           end
       end
   end
}

Understanding the Changes

  • Local Variable tired: We removed this variable since we now choose the next action based on chance.
  • New Selector GuardPeacefulAction: Both Wander and Stand are now nodes within a selector. The CanEnter and Valid checks were removed because they’re no longer needed.
  • FindWeightedRandomSelectable Selection function: We use the FindWeightedRandomSelectable function to pick nodes based on their weights. Total weight is 10 (7 + 3) and Wander has a 7/10 chance of being selected, while Stand has a 3/10 chance.

Part Four: Testing the Script

  1. Launch the Editor: If it’s not already open, launch the Editor. Otherwise, reload the Anubis framework.
  2. Assign the config: Ensure the Guard config is assigned to the character you’re testing.
  3. Enter Game Mode: Observe the guard switching between the two behaviours based on the defined probabilities.

Conclusion

In this tutorial, you learned how to use proxy nodes to modularise and reuse behaviours across different scenarios. You also explored advanced selection logic, allowing your characters to make more dynamic decisions based on probabilities. These techniques allow you to build complex, responsive behaviours in a manageable way.

Next, you can explore more complex logic with events, exceptions, and deeper customisation of behaviours in Custom Combat Scripting.