Anubis: Custom Combat Scripting

From Baldur's Gate 3 Modding

Overview

In this tutorial, we will learn how to create custom combat scripts. We will create a script called AlarmHandling for our guard character created in the Getting Started tutorial. The goal of this script is to allow the guard to:

  1. Announce his intention to trigger an alarm on his first combat turn
  2. Run toward the alarm and attempt to trigger it on subsequent turns, unless the players can stop him

We will use a combination of state transitions, combat actions, and Anubis event handling to achieve this behaviour.

By the end of this tutorial, you will have a combat behaviour that allows a guard to:

  1. Announce his intention to trigger an alarm
  2. Run to the alarm on subsequent turns
  3. Trigger the alarm if the players don’t stop him in time

Part One: Key Concepts for Combat Scripting

Combat scripting is practically identical to non-combat scripting, but you need to take into account a few things:

  • You must check the me.CanActInCombatTeamTurn field on the character to make sure that he is in combat and it is actually his turn, otherwise your scripting might trigger during someone else’s turn.
  • Handling exceptions is critical in combat scripting. If your scripting causes an exception that is not handled, then the current action will be interrupted and Anubis will re-select the same node with an error and the process will repeat, causing an endless loop. This might happen in regular scripting as well, causing an NPC to get stuck in some behaviour, but if that happens during a fight, then the whole fight will get stuck, blocking the player.
  • Our defaultCharacter config allows for plugging in custom combat states using the parameter combat.
  • When Anubis finishes executing the current action of your Anubis script, it won’t cause the character’s turn to end. Instead, you must directly call EndTurn(me). This allows you to make the combat script more flexible – depending on the game situation, you can choose to exit your code immediately and end the character’s turn, or first run your script and then simply block it (via a flag or variable) from triggering again without using EndTurn(me), which will cause the default combat scripting to start.

Part Two: Script Overview

Instead of having you write a script from scratch, we'll provide you with the whole script with comments, and we'll go over it together.

The main script, AlarmGuard_Combat, defines the Guard's behaviour during combat. The Guard has two key states:

  • DesignateRunner: In the first turn, the Guard announces his plan to trigger the alarm.
  • ActivateAlarm: In subsequent turns, the Guard runs toward the alarm and attempts to trigger it.

Here’s the full script for reference:

game.states.AlarmGuard_Combat = State { 
   function()
       -- Automated dialog that the guard will play on the first turn to announce going to the alarm
       local alarmDialogAboutRaising = Dialog("Guard_AD_AlarmWarning_0c2d677c-0e86-b8e2-ee41-473f3be6fd0c")
       -- Alarm object
       local alarm = Entity("S_Alarm_84ddea18-b5e9-4752-8841-1c7682583bda")
       -- Animation of raising the alarm (in this case the alarm is a drum)
       local animPlayingDrum = Animation("CUST_Playing_Drum_01_5ad03263-20d9-485f-aa7a-bef408deee7c")
       -- Flag which is set on the character that needs to raise the alarm
       local electedForAlarm = Flag("ElectedForAlarm_ea5cfb66-f89d-4678-b217-06a3a2bbb1aa")
       -- Flag which is set on the character that needs to raise the alarm, this flag is set after the
       -- character announces their intention to use the alarm
       local runningToAlarm = Flag("RunningToAlarm_c39d7d44-b257-45c6-baf7-e138e343424e")
       -- Flag that alarm has been raised, to prevent Anubis from using the combat scripting again
       local alarmRaised = Flag("AlarmRaised_af8d5e48-c937-40ec-a8b0-4a5b3e50c845")
       -- this state can only be selected if at least one of the nodes can be selected
    
   nodes = Selector{
        Valid = function(node)
            return CheckAnySelectable(node)
        end
    }

    -- In this state the Guard shouts about deciding to raise the alarm to give the players a turn to 
    -- stop the Guard from doing so
    nodes.DesignateRunner = Action{
        function()
            -- arguments: dialog, waitForCompletion, speaker
            StartAutomatedDialog(alarmDialogAboutRaising, false, me)
            SetFlag(runningToAlarm, me)
        end,
        Valid = function()
            return me.CanActInCombatTeamTurn
		and not GetFlag(alarmRaised)
		and GetFlag(electedForAlarm, me)
		and not GetFlag(runningToAlarm, me)
        end
    }

    -- In this state which can only happen after the state above the Guard will start running towards the alarm
    -- (on the next turn after shouting)
    nodes.ActivateAlarm = Action{
        function()
		-- MoveTo can throw an exception. We could use try/catch to handle it, but we will use an alternative 
		-- approach instead
		-- we'll pass 'false' as the last argument which will suppress exception.
		-- Instead we will analyze the result of the function - moveResult
		local moveResult = MoveTo(alarm, MovementSpeed.Run, false, true, 0.5, 1.0, false)
		if moveResult == error.MovementError.None then
		DebugText(me, "Successfully finished running to the alarm!")
		SteerTo(alarm)
		-- PlayAnimation could cause an issue. If PlayAnimation call throws an exception the execution of 
		-- ActivateToAlarm is interrupted, but Anubis will simply re-select it again and 
		-- try PlayAnimation again. It would fail again and we would enter an endless loop.
		PlayAnimation(animPlayingDrum, true)
		SetFlag(alarmRaised)
		EndTurn(me)
	elseif moveResult == error.MovementError.NoResources then
		DebugText(me, "My Movement Points ended before I reached the alarm!")
		EndTurn(me) -- we just end the turn and continue moving on the next turn
	else
		DebugText(me, "Movement to alarm failed, not sure why - standard combat actions")
		ClearFlag(runningToAlarm, me)
		SetFlag(electedForAlarm, me)
		-- we don't use EndTurn here allowing the default combat scripting to take over
	end
    end,

    CanEnter = function()
       return me.CanActInCombatTeamTurn
            and not GetFlag(alarmRaised)
            and GetFlag(electedForAlarm, me)
            and GetFlag(runningToAlarm, me) -- this state is entered only when the NPC is running to the alarm
        end
    }

    events.EntityEvent = function(e)
        if e.TargetEntity == me then
            if e.Event == "RaiseAlarm" then
                SetFlag(electedForAlarm, me)
            end
        end
    end
end
}

Part Three: Breaking Down the Script

Dialogs, Animations, and Entities

At the top of the script, we define several key variables:

  • alarmDialogAboutRaising: The name of the dialogue that the Guard will say to announce his intention to trigger the alarm.
  • alarm: The alarm entity that the Guard will attempt to reach.
  • animPlayingDrum: The animation that will play when the Guard triggers the alarm.

Additionally, we define flags that track the Guard's state:

  • electedForAlarm: Indicates that the Guard is tasked with triggering the alarm.
  • runningToAlarm: Indicates that the Guard is actively trying to reach the alarm.
  • alarmRaised: Indicates that the alarm has already been triggered.

Part Four: Defining Combat Actions

The DesignateRunner Action

The DesignateRunner action handles the Guard's first turn. Here’s what happens:

  • The Guard announces his intention to trigger the alarm using the StartAutomatedDialog function.
  • The runningToAlarm flag is set, which signals that the Guard is now attempting to reach the alarm.

Conditions for Entering the DesignateRunner State

  • The most important check is me.CanActInCombatTeamTurn. If we don’t add this condition, Anubis will try to execute this action even if the character isn't in combat.
  • The alarm must not have been raised yet.
  • The Guard must be elected to trigger the alarm (i.e. the electedForAlarm flag is set).
  • The Guard must not already be running toward the alarm.

Code for DesignateRunner

-- this state is entered only when the NPC is not running to the alarm yet
nodes.DesignateRunner = Action{
    function()
        -- arguments: dialog, waitForCompletion, speaker
        StartAutomatedDialog(alarmDialogAboutRaising, false, me)
        SetFlag(runningToAlarm, me)
    end,
    Valid = function()
        return me.CanActInCombatTeamTurn
		and not GetFlag(alarmRaised)
		and GetFlag(electedForAlarm, me)
		and not GetFlag(runningToAlarm, me)
    end
}

The ActivateAlarm Action

Once the Guard has announced his intention to raise the alarm, the ActivateAlarm action takes over on the next turn:

  • The Guard attempts to move toward the alarm using the MoveTo function.
  • If the Guard reaches the alarm, he plays an animation using PlayAnimation and raises the alarm by setting the alarmRaised flag.
  • If the Guard runs out of movement points (Movement Speed) before reaching the alarm, he ends his turn and continues moving on the next turn.
  • If movement fails for another reason, the Guard resets the electedForAlarm flag and allows the default combat script to take over.

Conditions for Entering the ActivateAlarm State

  • The Guard must be able to act in the combat turn.
  • The alarm must not have been raised yet.
  • The Guard must be elected to trigger the alarm.
  • The Guard must already be running toward the alarm (i.e. the runningToAlarm flag is set).
-- In this state which can only happen after the state above the Guard will start
-- running towards the alarm (on the next turn after shouting)
nodes.ActivateAlarm = Action{
    function()
	-- MoveTo can throw an exception. We could use try/catch to handle it, but we will use an 
	-- alternative approach instead
	-- we'll pass 'false' as the last argument which will suppress exception.
	-- Instead we will analyze the result of the function - moveResult
	local moveResult = MoveTo(alarm, MovementSpeed.Run, false, true, 0.5, 1.0, false)
	if moveResult == error.MovementError.None then
		DebugText(me, "Successfully finished running to the alarm!")
		SteerTo(alarm)
		-- PlayAnimation could cause an issue. If PlayAnimation call throws an exception the execution 
		-- of ActivateToAlarm is interrupted, but Anubis
		-- will simply re-select it again and try PlayAnimation again. It would fail again and 
		-- we would enter an endless loop.
		PlayAnimation(animPlayingDrum, true)
		SetFlag(alarmRaised)
		EndTurn(me)
	elseif moveResult == error.MovementError.NoResources then
		DebugText(me, "My Movement Points ended before I reached the alarm!")
		EndTurn(me) -- we just end the turn and continue moving on the next turn
	else
		DebugText(me, "Movement to alarm failed, not sure why - standard combat actions")
		ClearFlag(runningToAlarm, me)
		SetFlag(electedForAlarm, me)
		 -- we don't use EndTurn here allowing the default combat scripting to take over
	end
    end,
    CanEnter = function()
        return me.CanActInCombatTeamTurn
            and not GetFlag(alarmRaised)
            and GetFlag(electedForAlarm, me)
            and GetFlag(runningToAlarm, me) 
    end
}

Part Five: Handling Events

The script includes an event handler that listens for an event called RaiseAlarm. When this event is triggered, the electedForAlarm flag is set, signalling that the Guard has been chosen to raise the alarm.

Event-Handling Code

events.EntityEvent = function(e)
    if e.TargetEntity == me then
        if e.Event == "RaiseAlarm" then
            SetFlag(electedForAlarm, me)
        end
    end
end

Part Six: Choosing When to Enter Combat

This custom combat behaviour should only trigger if we can actually execute one of the two actions (DesignateRunner or ActivateAlarm). To achieve that, we can use the CheckAnySelectable selector.

Part Seven: Assigning the Combat Config to a Character

Create a new config for the Guard by following Getting Started: Part Three and assign the script from this tutorial to the customCombat field. You can assign Dummy state to idle field.

Part Eight: Putting It All Together

The Guard’s custom combat script is now complete! Here’s what now happens:

  1. Turn 1: The Guard announces his intention to raise the alarm.
  2. Turn 2+: The Guard moves toward the alarm. If he reaches it, he raises the alarm by playing an animation. If he runs out of movement points, he continues moving on the next turn.

This custom combat behaviour allows for strategic combat situations where the players must act quickly to prevent the Guard from raising the alarm.

Conclusion

In this tutorial, you’ve learned how to write a custom combat script in Anubis. You’ve seen how to define multiple actions for a Guard, including announcing an intention to raise an alarm and attempting to reach the alarm across multiple turns.

Feel free to experiment with additional behaviours or extend the script for more complex combat situations!