Bots and AI

In the previous we've added weapons and projectiles, but we still do not have anything to shoot at. In this tutorial part we'll add bots with simple AI:

bot

Bot Prefab

Let's start by zombie 3D model and animations for it, grab it from here and unpack into data/models/zombie folder. Open the editor and create zombie.rgs scene. Instantiate a zombie.FBX 3D model in the scene and make sure it is located at (0, 0, 0) coordinates. Scale it down to have 0.01 scale in both axes. You should get something like this:

zombie

Next thing that we'll add is animations. Create a new Animation Player scene node, open the animation editor and add three animations (use this chapter to learn how to do this) - zombie_attack, zombie_idle, zombie_running. You should get something like this:

zombie animation

Do not forget to disable looping for zombie_attack animation, otherwise our zombie will attack infinitely. Also, make sure to set up the root motion for the zombie_running animation, read this chapter for more info. Root motion will allow us to get nice animation of movement, which will also serve as a source velocity for our bot.

You can select each animation from the list and see how it plays by clicking the "Preview" check box at the toolbar. Animations by their own are not very useful for us, because our bot can be in multiple states in the game:

  • Idle - when there's no one around and the bot is just standing still; looking for potential targets.
  • Run - when the bot spotted someone and walks towards it.
  • Attack - when the bot is close enough to a target and can attack it.

We need to somehow manage all these states and do smooth transition between the states. Fyrox has a special tool for this called animation blending state machine (ABSM). Let's create a new ABSM scene node and add the three states mentioned above (if you don't know how - read this chapter):

absm states

Connect them with bidirectional transitions and set transition time for them to 0.3s. Select the Animation Blending State Machine node and assign Animation Player property in the Inspector.

Now add Play Animation pose node for each of the states (double-click on a state, right-click, Play Animation) and set an appropriate animation from the list in the inspector. For example, for Idle state it could look like this:

pose node

Click Preview check box, and you should see the bot with Idle animation playing. Let's add two parameters that will be used for transitions:

parameters

All we need to do now is to thoroughly set these variables in all six transitions. Select the Idle -> Attack transition and in the Inspector find Condition property and type in Attack parameter name:

attack transition

For the opposite transition you need to add almost the same, but with additional Not computational node:

attack reverse transition

Do the same for the rest of four transitions, all six transitions should have these values set:

  • Idle -> Attack - Parameter(Attack)
  • Attack -> Idle - Not(Parameter(Attack))
  • Idle -> Run - And(Parameter(Run), Not(Parameter(Attack)))
  • Run -> Idle - Not(Parameter(Run))
  • Run -> Attack - And(Parameter(Run), Parameter(Attack))
  • Attack -> Run - And(Parameter(Run), Not(Parameter(Attack)))

Click Preview and click on some parameters, you should see transitions between states.

You may probably notice that there's something off with Attack state, sometimes it enters it in mid-attack state. This happens because the attack animation could be in arbitrary play time. It could be fixed by adding a Rewind Animation action when entering Attack state. Select the Attack state, find On Enter Actions in the Inspector and add a new action by clicking + button, select Rewind Animation and select zombie_attack from the list.

Great, now we have all animations working, and now we can add a physical capsule for the bot, so it won't fall through the ground. Replace the root node of the prefab with a Rigid Body, add a capsule collider child node to it, adjust its size to fully enclose the bot (we did the same in the first tutorial, but for player):

rigid body

Do not forget to disable rotations for the rigid body (X/Y/Z Rotation Locked properties must be checked) and disable sleeping for it (uncheck Can Sleep). For now, our prefab is more or less finished. As usual, we need to write some code, that will drive the bot.

Code

Add a new script using the following command:

fyrox-template script --name=bot

Add this module to the lib.rs module as we did in the previous tutorials. Register the bot in the register method like so:

#![allow(unused)]
fn main() {
        context
            .serialization_context
            .script_constructors
            .add::<Bot>("Bot");
}

At first, our bot needs an ability "to see". In games such ability can be represented by a simple frustum with its top at the head of the bot and the base oriented forward. We can construct such frustum from a pair of matrices - view and projection. After that the frustum can be used for simple frustum-point intersection check. We'll check if the player's position intersects with the bot's viewing frustum and if so, the bot will start chasing the player. On to the code we go, add the following field to the Bot struct:

#![allow(unused)]
fn main() {
    #[visit(skip)]
    #[reflect(hidden)]
    frustum: Frustum,
}

To construct the frustum, add the following code somewhere in the bot.rs:

#![allow(unused)]
fn main() {
    fn update_frustum(
        &mut self,
        position: Vector3<f32>,
        look_vector: Vector3<f32>,
        up_vector: Vector3<f32>,
        max_observing_distance: f32,
    ) {
        // Calculate an average head position.
        let head_pos = position + Vector3::new(0.0, 0.4, 0.0);
        let look_at = head_pos + look_vector;

        // View matrix is constructed using three parameters - observer position, target point,
        // and an up vector (usually it is just (0,1,0) vector).
        let view_matrix =
            Matrix4::look_at_rh(&Point3::from(head_pos), &Point3::from(look_at), &up_vector);

        // Build the perspective projection matrix.
        let projection_matrix = Matrix4::new_perspective(
            // Aspect ratio
            16.0 / 9.0,
            // Field of view of the bot
            90.0f32.to_radians(),
            0.1,
            max_observing_distance,
        );
        self.frustum =
            Frustum::from_view_projection_matrix(projection_matrix * view_matrix).unwrap();
    }
}

We'll call this method every frame to keep the frustum updated with the current location and orientation of the bot. Add the following code to the on_update method:

#![allow(unused)]
fn main() {
        if let Some(rigid_body) = ctx.scene.graph.try_get_mut_of_type::<RigidBody>(ctx.handle) {
            let position = rigid_body.global_position();
            let up_vector = rigid_body.up_vector();
            let look_vector = rigid_body.look_vector();

            // Update the viewing frustum.
            self.update_frustum(position, look_vector, up_vector, 20.0);
        }
}

Now we need to check if the player's position intersects with the frustum. Add the following code at the beginning of on_update:

#![allow(unused)]
fn main() {
        // Look for targets only if we don't have one.
        if self.target.is_none() {
            for (handle, node) in ctx.scene.graph.pair_iter() {
                if node.has_script::<Player>()
                    && self.frustum.is_contains_point(node.global_position())
                {
                    self.target = handle;
                    break;
                }
            }
        }

        // A helper flag, that tells the bot that it is close enough to a target for melee
        // attack.
        let close_to_target = ctx
            .scene
            .graph
            .try_get(self.target)
            .map_or(false, |target| {
                target
                    .global_position()
                    .metric_distance(&ctx.scene.graph[ctx.handle].global_position())
                    < 1.25
            });
}

In this code we're iterating over the all available scene nodes and check if a node has Player script and if the node's position intersects with the bot's frustum. If so, we're remembering this node as a target. Do not forget to add this code to the Bot struct:

#![allow(unused)]
fn main() {
    #[visit(skip)]
    #[reflect(hidden)]
    target: Handle<Node>,
}

Now we need to add movement for the bot, we'll use root motion for that. Root motion will be extracted from the animation blending state machine we've made earlier. Let's add this code to the Bot struct:

#![allow(unused)]
fn main() {
    absm: InheritableVariable<Handle<Node>>,
    model_root: InheritableVariable<Handle<Node>>,
}

The first field will hold a handle to the ABSM and the second - a handle to the 3D model root. We'll assign these field later, now we need to add the code that will extract velocity vector for the bot movement and apply this vector to the rigid body of the bot:

#![allow(unused)]
fn main() {
        let model_transform = ctx
            .scene
            .graph
            .try_get(*self.model_root)
            .map(|model| model.global_transform())
            .unwrap_or_default();

        let mut velocity = Vector3::default();
        if let Some(state_machine) = ctx
            .scene
            .graph
            .try_get_mut(*self.absm)
            .and_then(|node| node.query_component_mut::<AnimationBlendingStateMachine>())
        {
        }
}

At first, we're getting current world-space transform of the 3D model's root and saving it into a local variable. Then we're borrowing the ABSM we've made earlier and extracting the root motion offset vector. As a final step we're scaling it by 1.0 / dt factor to convert it to velocity. This final velocity vector needs to be set to the rigid body of the bot. To do that, add the following code at the end of the last if statement (where we're borrowing the rigid body):

#![allow(unused)]
fn main() {
            let y_vel = rigid_body.lin_vel().y;
            rigid_body.set_lin_vel(Vector3::new(velocity.x, y_vel, velocity.z));
}

Next we need to somehow inform the ABSM about the current state of the bot. Remember that we have two parameters in the ABSM? We need to set them from the code, it could be done like so:

#![allow(unused)]
fn main() {
            let y_vel = rigid_body.lin_vel().y;
            rigid_body.set_lin_vel(Vector3::new(velocity.x, y_vel, velocity.z));
}

Now it's time to do small adjustments to our prefabs. Open the zombie.rgs prefab and assign the Bot script to the root node of the prefab, set its properties like so:

bot properties

Open the scene.rgs, find the zombie.rgs prefab in the asset browser and instantiate it in the scene:

bot instance

Now you can run the game and walk in front of the bot, it should run, but it runs straight and does not follow the target (the player). Let's fix that. At first, we need to calculate an angle between a target and the bot. We'll calculate it using atan2 trigonometric function, add the following code somewhere in on_update:

#![allow(unused)]
fn main() {
        let angle_to_target = ctx.scene.graph.try_get(self.target).map(|target| {
            let self_position = ctx.scene.graph[ctx.handle].global_position();
            let look_dir = target.global_position() - self_position;
            look_dir.x.atan2(look_dir.z)
        });
}

This code calculates a vector between the bot's position and a target, and then calculates an angle in XZ plane, using atan2(x, z) trigonometric function. Let's use this angle, add the following code the end of the last if statement (where we're borrowing the rigid body):

#![allow(unused)]
fn main() {
            if let Some(angle) = angle_to_target {
                rigid_body
                    .local_transform_mut()
                    .set_rotation(UnitQuaternion::from_axis_angle(&Vector3::y_axis(), angle));
            }
}

This code is trivial - we're making a rotation quaternion, that rotates the bot around Y axis using the angle we've calculated.

Run the game and the bot should follow you as long as it sees you:

bot

Conclusion

In this tutorial part we've added bots with animation and simple AI. In the next tutorial we'll add an ability to kill the bots.