Blog

Making a Game Part 11. Artificial Intelligence

2015-04-20

What is Pyatigor's Tale from the gameplay point of view? It's an arcade game in which the player tries to avoid various pitfalls that occur around Pyatigor, and at the same time he fights enemies which want to vanquish him by any means. Opponents must be assigned some behavior in order for this process to be interesting enough, and for the player to be required to make considerable efforts to defeat them. Let's take a look at how we managed to endow the golems with their own intelligence.

Golem's Life Cycle

Before we start programming, we need to decide what kind of actions are required from our monsters. Thus, let's consider the golem's life cycle.

Golem's life cycle.

  1. First, the golem is born. His intelligence doesn't work until he completely exits the lava.
  2. Having settled on the island, the golem starts to patrol. We want him to continuously cruise from one point on the island to another without falling into lava. If there is a character on the island, the golem immediately switches to him and tries to decrease the distance to inflict some damage. If there is no character on the island, the last thing that can distract the golem is the magic stones. If the obelisk beside the golem is partially or completely filled with stones, the golem will approach it and will try to destroy the gems in order to deter the character from capturing the island.
  3. If there is a character or an obelisk with gems in the golem's scope, he will make an attack.
  4. The only way the golem can finish his life-cycle is to be slayed by the player.

After this, the golem is marked as non-active and the cycle is repeated.

Let's start implementing these points!

Golem's Components

In order to better understand how to reconstruct this behavior, let's take a look at the components the golem consists of.

The main components of the golem.

  1. An armature for the skeletal animation
  2. A body for rendering
  3. A physical object for calculations

All these parts are children of the Empty object, which they are bound to on the scene because it has the Relative Group Coords flag turned on. Therefore, if this Empty is being moved, all the required objects will follow it at the same time. So, we'll be changing only its position with our API.

Initialization

All the work with golems is moved into a separate golems.js module. Each golem's data is packed into a special wrapper-object and is saved into the global _golems_wrappers array for convenience:

function init_golem_wrapper(body, rig, empty) {
    return {
        body: body, // Physical object for the golem's body
        rig: rig, // Armature object for animation
        empty: empty, // Empty object which the golem's group is bound to
        ...
        state: m_conf.GS_NONE,
        ...
    }
}

We won't consider all of this object's properties. It is important to notice that the wrapper has links to the real objects relative to the particular golem in the first three fields.

These objects are: golem's physical object body, an armature rig and an empty object which all other objects are bound to. So, having this wrapper we can perform any actions related to the physics, animation or particular golem's movement.

One more important property - the golem's state. It can have four values: GS_GETTING_OUT - golem is getting out of the lava, GS_WALKING - golem is patrolling, GS_ATTACKING - golem is in the attack process, GS_NONE - golem is inactive.

Behavior Basics

First, let's register a sensor manifold for every golem:

m_ctl.create_sensor_manifold(golem_wrapper, "GOLEM", m_ctl.CT_CONTINUOUS,
                             [elapsed_sensor], null, golem_ai_cb);

golem_ai_cb() callback will be called every frame. Let's take a look at it:

function golem_ai_cb(golem_wrapper, id) {
    if (golem_wrapper.hp <= 0) {
        kill(golem_wrapper);
        return;
    }
    switch (golem_wrapper.state) {
    case m_conf.GS_ATTACKING:
        process_attack(golem_wrapper);
        break;
    case m_conf.GS_WALKING:
        var elapsed = m_ctl.get_sensor_value(golem_wrapper, id, 0);
        move(golem_wrapper, elapsed);
        break;
    default:
        break;
    }
}

If the golem's HP is dropped under zero, his death handler kill() is called. In this function, all the golem wrapper's parameters are being reset and special animation and sound are played. Its code is rather trivial, thus, we won't consider it. The second block is a key part of the golem's intelligence. His movement or attack is processed here depending on the golem_wrapper.state value. If the golem is getting out of the lava or if he is not active, there will be nothing done in this function.

Birth

Let's create a sensor manifold responsible for the golem's resurrection:

m_ctl.create_sensor_manifold(null, "GOLEMS_SPAWN", m_ctl.CT_CONTINUOUS,
                             [elapsed_sensor], null, golems_spawn_cb);

Its handler will have the following form:

function golems_spawn_cb(obj, id) {

    var golem_wrapper = get_first_free_golem();
    if (!golem_wrapper) // no available golems
        return;

    var elapsed = m_ctl.get_sensor_value(obj, id, 0);
    _golems_spawn_timer -= elapsed;

    if (_golems_spawn_timer <= 0) {
        _golems_spawn_timer = m_conf.GOLEMS_SPAWN_INTERVAL;

        var island_id = get_random_available_island();
        if (island_id == null) // no available islands
            return;

        spawn(golem_wrapper, island_id, spawn_points, spawn_quats);
    }
}

Only three golems can be present on the island at a time. In order for another golem to rise out of the lava, the following requirements have to be met:

  • The island must not be captured by the player
  • There must be no other golem on the island
  • There has to be at least one available golem

WIth the help of the get_first_free_golem() function we'll get the wrapper of the available golem if there are any. Next, we need to decrease the golems spawn_timer and, if it goes below zero, we reset it and with the help of the get_random_available_island() function check if there is an appropriate island. When we ensure that all these conditions are true, we will start the golem's birth.

All we know is the island which the golem should be placed on. It is necessary to decide at what exact point we should place him. To do this, let's place special markers with Empty objects on every island. We'll be choosing a random one and placing a new golem at its position. This information is calculated only once during initialization and is then put into the appropriate spawn_points and spawn_quats arrays, which are forwarded to the spawn() function:

function spawn(golem_wrapper, island_id, spawn_points, spawn_quats) {
    var golem_empty = golem_wrapper.empty;

    var num_spawns = spawn_points.length / m_conf.NUM_ISLANDS;
    var spawn_id = Math.floor(Math.random() * num_spawns);
    var spawn_pt_id = num_spawns * island_id + spawn_id;
    var spawn_point = spawn_points[spawn_pt_id];
    var spawn_quat = spawn_quats[spawn_pt_id];

    m_trans.set_translation_v(golem_empty, spawn_point);
    m_trans.set_rotation_v(golem_empty, spawn_quat);

    set_state(golem_wrapper, m_conf.GS_GETTING_OUT)

    m_sfx.play_def(golem_wrapper.getout_speaker);

    golem_wrapper.island_id = island_id;
    golem_wrapper.dest_pos.set(spawn_point);
}

As we can see, the golem_wrapper copes nicely with its task. With the help of the transform (m_trans) module the golem's Empty object is positioned at a random birth point on the required island. Then the state is switched to GS_GETTING_OUT. In the set_state() function, animation is applied to the golem, and after it is finished, the state is changed to GS_WALKING and the walk animation is played. In the end, a sound of golem getting out is played and the default properties are set for the wrapper. The golem has been born and he is ready to fight!

Patrol

Based on the described requirements of the golem's positioning, we can designate two types of his movement:

  1. Golem just cruises in different directions
  2. Golem is moving toward his target in order to attack
  3. https://www.blend4web.com/en/forums/topic/471/?page=1#post-2093

The first point includes several possible realizations:

  1. One or several points which are constantly "shooting" down with rays and trying to determine the type of the surface the golem will step into are placed in front of him. Based on this info, the golem can make a decision about changing his direction. This option is not appropriate for us because it has a high probability of an error and this method assumes serious calculation loads.
  2. The method is fairly similar to the previous one, but the golem's physical bounding is used instead. When the golem touches a special hidden object placed on the perimeter of the island, he turns away from the point of collision. This method is faster but it also can lead to unpredictable results because we can get only the first collision point with the current API.
  3. And finally, the method we have chosen. This is to use predefined positions and to choose a random one of them. Well, the golem won't actually "decide" where to go and this will require some meta-information (additional coordinates). However, this method is much faster and avoids possible errors related to physics, and stability is what we mostly interested in.

Schematically this behavior can be described with the following animated image:

Golem's movement on the island.

Let's add some more base points for the golem's trajectory with new Empty objects. For optimization reasons, we'll take one point from respawn positions already presented on the island. We'll have the following positions set after these manipulations (marked with red circles):

Base points for the golem's movement.

It is needed to take into account that if the points are too close to each other, some frustrating errors can occur when the golem will try to reach the point which is right at his hand. He will just start rotating on the spot and will never get to the destination.

The function responsible for the golem's movement is the following:

function move(golem_wrapper, elapsed) {

    var char_wrapper = m_char.get_wrapper();
    var island_id = golem_wrapper.island_id;

    if (char_wrapper.island == island_id && char_wrapper.hp > 0) {
        attack_target(golem_wrapper, char_wrapper.phys_body, elapsed);
        golem_wrapper.last_target = m_conf.GT_CHAR;
    } else if (m_obelisks.num_gems(island_id)) {
        var obelisk = get_obelisk_by_island_id(island_id);
        attack_target(golem_wrapper, obelisk, elapsed);
        golem_wrapper.last_target = m_conf.GT_OBELISK;
    } else {
        patrol(golem_wrapper, elapsed);
    }
}

Here we can clearly see the designed strategy. The golem will attack the player if he is on the same island or the obelisk if there are some magic stones in it. In any other case, the patrol() function will be called, and the golem will move between base points on the island. Let's take a look at this function:

function patrol(golem_wrapper, elapsed) {
    set_dest_point(golem_wrapper);
    rotate_to_dest(golem_wrapper, elapsed);
    translate(golem_wrapper, elapsed);
}

At the beginning, the position which the golem will head to is set by the set_dest_point() function. If the golem is close enough to it, the new point will be chosen. Otherwise, the old one will remain. After the new position is written to the golem_wrapper.dest_pos field, the golem turns towards it with the help of the rotate_to_dest() function and then he just moves in his own direction with the translate() function. All these functions have no direct relationship to the golems' intelligence, thus we won't consider them. We just need to know that the Empty object is constantly changing its position as the golem moves.

Attack

The golem noticed the character and moved to attack.

Let's pay attention to the move() function again and let's see how the golem attacks his targets. These actions are described in the attack_target() function which is common for both player and obelisk attacks. They differ only in the last_target field values: GT_CHAR for the character and GT_OBELISK for the obelisk.

function attack_target(golem_wrapper, target, elapsed) {

    var golem_empty = golem_wrapper.empty;
    var trans       = _vec3_tmp;
    var targ_trans  = _vec3_tmp_2;

    m_trans.get_translation(golem_empty, trans);
    m_trans.get_translation(target, targ_trans);
    targ_trans[1] = trans[1];
    golem_wrapper.dest_pos.set(targ_trans);

    var dist_to_targ = m_vec3.distance(trans, targ_trans);
    var angle_to_targ = rotate_to_dest(golem_wrapper, elapsed);

    if (dist_to_targ >= m_conf.GOLEM_ATTACK_DIST)
        translate(golem_wrapper, elapsed);
    else if (angle_to_targ < 0.05 * Math.PI)
        perform_attack(golem_wrapper);
}

The transform module and the rotate_to_dest() function help us to find the distance and the angle to the chosen target. Keep in mind that as we equate targ_trans Y component with the trans Y component it can be said that we work only in two dimensions, and the golem is oriented on the plane surface. If the distance is greater than the reach of the golem's attack GOLEM_ATTACK_DIST, he will move toward the target using the already known translate() function. If the distance is small enough and the angle_to_targ is less than the given limit, the golem will make an attack. Let's take a look at the perform_attack() function:

function perform_attack(golem_wrapper) {

    var golem_empty = golem_wrapper.empty;
    var at_pt       = golem_wrapper.attack_point;
    var trans       = _vec3_tmp;
    var cur_dir     = _vec3_tmp_2;

    golem_wrapper.attack_done = false;
    set_state(golem_wrapper, m_conf.GS_ATTACKING)

    m_trans.get_translation(golem_empty, trans);
    m_vec3.scaleAndAdd(trans, cur_dir, m_conf.GOLEM_ATTACK_DIST, at_pt);
    at_pt[1] += 0.3; // raise attack point a bit

    if (m_sfx.is_play(golem_wrapper.walk_speaker))
        m_sfx.stop(golem_wrapper.walk_speaker);

    m_sfx.play_def(golem_wrapper.attack_speaker);
}

Coordinates of the golem's attack point are written to the golem_wrapper.attack_point field. We'll process them later. The golem_wrapper.attack_done field will also be required in the attack handler. set_state() changes the current state and sets the needed animation. Sounds are processed at the end: the walk_speaker sound is turned off if it is playing and the attack_speaker sound is turned on.

The golem attacks the obelisk.

Let's return to the main intelligence handler now. We have seen the following code there:

switch (golem_wrapper.state) {
case m_conf.GS_ATTACKING:
    process_attack(golem_wrapper);
    ...
}

Therefore, when the golem's state changes to attack, the process_attack() function will be called in every frame until his state is changed to something else. Let's look at the attack handler.

function process_attack(golem_wrapper) {
    if (!golem_wrapper.attack_done) {
        var frame = m_anim.get_frame(golem_wrapper.rig);
        if (frame >= m_conf.GOLEM_ATTACK_ANIM_FRAME) {
            if (golem_wrapper.last_target == m_conf.GT_CHAR)
                process_golem_char_attack(golem_wrapper);
            else if (golem_wrapper.last_target == m_conf.GT_OBELISK)
                process_golem_obelisk_attack(golem_wrapper);
            golem_wrapper.attack_done = true;
        }
    }
}

The attack_done flag is used as the main criterion here. Attack animation has a specific length but we want the attack handler to be called only once at some predefined moment. Thus, we check whether the animation has reached the needed GOLEM_ATTACK_ANIM_FRAME and then, based on the type of the target being attacked last_target, either process_golem_char_attack() or process_golem_obelisk_attack() is called. The content of these functions is not really important to us and, perhaps, it is enough code for this tutorial. In the first function, we check if the attack on the character succeeded, depending on his distance to the golem's attack point, and if this condition is met, the character's HP is reduced. In the second function, there isn't even a need to check the distance because the obelisk is not capable of running away.

This is how the golems' behavior is organized. We can diversify it by adding some interesting abilities and processing them as new golem_wrapper.state types, but now we'll stay with what we have as our golems don't have any special skills and can only walk and hit. And this is enough to make the player's life a bit more difficult.

Launch the game!

The source files are part of the free Blend4Web SDK.

Changelog

[2015-04-20] Initial release.