Decentraland Tutorial: Basic AI with 'Block Dog'

in tutorial •  3 months ago

This is a tutorial on creating a basic AI in Decentraland. We'll add a dog which follows you around and listens to some of your commands.

Full source code is available below and on GitHub.

If you are new to Decentraland development, you may want to start with our beginner tutorial, creating a Jukebox.

This tutorial was sponsored by Decentraland.


Setting Up the Environment

One time setup:

npm install -g decentraland

With a cmd prompt in the project's directory, run:

dcl init

Start with a basic scene for this tutorial. This will populate the directory with a few files for us to start from. We require scene.tsx for this tutorial which only comes with the basic and interactive templates.

Now start the game:

dcl start

This should open a new tab automatically to http://localhost:8000

Add Assets

Add the art and sounds for our app to the project's directory.

Download the models we've created or use your own of course.

Add the Models to the Scene

Modify scene.tsx, removing everything between the scene tags and then adding gltf models:

<scene>
  <gltf-model 
    id="Dog"
    src="art/BlockDog.gltf"
  />
  <gltf-model
    id="Bowl"
    src="art/BlockDogBowl.gltf"
  />
</scene>

Test: You should see both the dog and his bowl.

gltf-model

gltf is the model format Decentraland uses. It's supported by Blender by installing a glTF exporter, see Decentralands Scene Content Guide for more information.

Add Babylonjs

We will be using Babylonjs for their Vector3 and Quaternion support.

With a cmd prompt in the project's directory, run:

npm install babylonjs

Then add the following to the top of scene.tsx:

import {Vector3, Quaternion} from "babylonjs"; 

Note: The Vector3Component that Decentraland uses by default is just data. To ease coding, we'll use Babylon's instead which includes functions such as .subtract and .normalize. You could use another library for this or code the math yourself.

Add state for position and rotation

Above the SampleScene class, add an interface for state:

export interface IState 
{
  characterPosition: Vector3, 
  bowlPosition: Vector3, 
  dogPosition: Vector3, 
  dogRotation: Quaternion,
}

Add IState to the SampleScene class definition:

export default class SampleScene extends DCL.ScriptableScene<any, IState>

Then inside the class, add the state object itself including their default values:

state = { 
  characterPosition: new Vector3(0, 0, 0), 
  bowlPosition: new Vector3(1, 0, 1), 
  dogPosition: new Vector3(9, 0, 9), 
  dogRotation: new Quaternion(0, 0, 0, 1), 
};

And finally leverage this state in the render function:

<gltf-model...
  position={this.state.dogPosition} 
  rotation={this.state.dogRotation.toEulerAngles().scale(180 / Math.PI)} 
  transition={{
    rotation: {
      duration: 300
    }
  }}
/>
<gltf-model...
  position={this.state.bowlPosition} 
/>

Note: we'll be leveraging the characterPosition later in the tutorial.

Test: The dog is in one corner, the bowl in the other.

export interface IState

This is declaring the type of information that will be stored by the state object. This declaration is optional, but a best practice and will allow your IDE to display better hints.

Vector3

For more information see Babylonjs's doc on Vector3.

Quaternion

For more information see Babylonjs's doc on Quaternion.

Quaternion is the preferred format used by games for storing and modifying rotation. For more information on the Quaternion format, see our tutorial on Quaternions.

<any, IState>

This format is for Generics in Typescript. Generics allow you to write more general purpose code and then have specific types slotted in after. In this example we are allowing our base class, DCL.ScriptableScene, to know exactly what state will be stored.

.toEulerAngles().scale(180 / Math.PI)

Decentraland is expecting rotations in Eulers, using degrees. This is converting our Quaternion to that desired format.

transition

Transitions can be used in Decentraland to animate from one state to another. This works for position, rotation, scale, and color. See Scene State in the Decentraland docs for more information.

Animate the Dog

There should always be an animation playing on the dog. For this tutorial, we'll be supporting an idle animation, walking, and sitting. Weights will be used in order to transition between them.

To begin, add the animations to the dog's gltf-model and hardcode the weight for each:

const animationWeights = {idle: 1, walk: 0, sit: 0};
return (
  <scene>
    <gltf-model... 
      skeletalAnimation={[
        { 
          clip: "Idle", 
          weight: animationWeights.idle,
        },
        { 
          clip: "Walking", 
          weight: animationWeights.walk,
        },
        { 
          clip: "Sitting", 
          weight: animationWeights.sit,
        },
      ]}
    />

Test: The dog should be wagging his tail. Change the hard coded weights to view the other animations, e.g. {idle: 0, walk: 1, sit: 0}

Define Goals for the Dog

We'll be implementing the dog's AI as a state machine. Define each of the possible states in an enum:

enum Goal
{
  Idle,
  Sit,
  Follow,
  GoDrink,
  Drinking,
}

Then track the current goal, previous goal, and a weight to transition between them:

export interface IState 
{
  ...
  dogGoal: Goal,
  dogPreviousGoal: Goal,
  dogAnimationWeight: number,
}

...

state = { 
  ...
  dogGoal: Goal.Idle,
  dogPreviousGoal: Goal.Idle,
  dogAnimationWeight: 1,
};

Now implement a function to calculate each animation's weight based on the dog's goals.

getAnimationRates() : {idle: number, sit: number, walk: number} 
{
  const weight = Math.min(Math.max(this.state.dogAnimationWeight, 0), 1);
  const inverse = 1 - weight;

  let sit = 0;
  let walk = 0;
  
  switch(this.state.dogPreviousGoal)
  {
    case Goal.Sit:
      sit = inverse;
      break;
    case Goal.Follow:
    case Goal.GoDrink:
      walk = inverse;
      break;
  }

  switch(this.state.dogGoal)
  {
    case Goal.Sit:
      sit = weight;
      break;
    case Goal.Follow:
    case Goal.GoDrink:
      walk = weight;
      break;
  }

  return {idle: 1 - (sit + walk), sit, walk};
}

Then update animationWeights in render to use this function:

const animationWeights = this.getAnimationRates();

Test: The dog should be wagging his tail exactly as he did before. Try changing the dogGoal to change the animation. If the dogAnimationWeight is less than 1, you should see the animation selected by dogPreviousGoal blending in.

Math.min(Math.max(x, 0), 1)

This clamps the value of x to be between 0 and 1.

idle: 1 - (sit + walk)

The total weight across all animations should equal 1. By setting the idle weight to be the remainder, we are ensuring the dog is always moving a little.

Sit on Command

Change the dog's goal to Sit when you click on him. If he's already sitting, return to Idle.

Add an interval which fires each frame to increase the dogAnimationWeight smoothly.

sceneDidMount()
{
  this.eventSubscriber.on("Dog_click", () =>
  {
    this.setDogGoal(this.state.dogGoal == Goal.Sit ? Goal.Idle : Goal.Sit);
  });
    
  setInterval(() => 
  {
    const weight = Math.min(Math.max(this.state.dogAnimationWeight, 0), 1);
    this.setState({dogAnimationWeight: weight + .01});
  }, 1000/60);
}

setDogGoal(goal: Goal)
{
  this.setState({
    dogGoal: goal,
    dogAnimationWeight: 1 - this.state.dogAnimationWeight,
    dogPreviousGoal: this.state.dogGoal
  });
}

Test: Clicking the dog will transition between sitting and standing.

sceneDidMount

sceneDidMount is an event which is called after the scene has loaded. We use this for any initialization required, such as subscribing to object events.

this.eventSubscriber.on(...

The eventSubscriber allows you to respond to object specific events, in this example when the user clicks on the object.

this.setState

Use setState to modify any of the variables stored by state. When using this function, render is called automatically. See Decentraland's Scene State.

Go Drink Some Water

When you click on the bowl, the dog should walk over and have a drink.

Update the sceneDidMount function to add click event for the bowl. In the existing interval, add logic to walkTowards the target.

this.eventSubscriber.on("Bowl_click", () =>
{
  this.setDogGoal(Goal.GoDrink);
});

setInterval(() => 
{
  ...

  switch(this.state.dogGoal)
  {
    case Goal.Follow:
    case Goal.GoDrink:
      const targetLocation = this.state.dogGoal == Goal.Follow ? this.state.characterPosition : this.state.bowlPosition;
      const delta = targetLocation.subtract(this.state.dogPosition);
      if(delta.lengthSquared() < 2) 
      {
        this.setDogGoal(this.state.dogGoal == Goal.Follow ? Goal.Sit : Goal.Drinking);
      }
      else
      {
        this.walkTowards(targetLocation);
      }
  }
}, 1000/60);

Create a function for walkTowards which smoothly updates the dog's position and set's his rotation to face the target.

walkTowards(position: Vector3)
{
  let delta = position.subtract(this.state.dogPosition);
  delta = delta.normalize().scale(.015); // .015 is the walk speed
  delta.y = 0;
  const newPosition = this.state.dogPosition.add(delta);
  if(!isInBounds(newPosition)) 
  {
    this.setDogGoal(Goal.Idle);
  }
  else
  {
    this.setState({
      dogPosition: newPosition,
      dogRotation: lookAt(this.state.dogPosition, position, -Math.PI / 2)
    });
  }
}

Add the following functions to the bottom of the scene.tsx file:

function isInBounds(position: Vector3): boolean
{
  return position.x > .5 && position.x < 9.5
    && position.z > .5 && position.z < 9.5;
}

Note: If you own multiple parcels of land, you may want to update the bounds used here.

Unfortunately there is not a lookAt implementation easily accessible. So we copied the following from Babylon's TransformNode:

// Math from Babylon TransformNode
function lookAt(
  pos: Vector3,
  targetPoint: Vector3, 
  yawCor: number = 0, 
  pitchCor: number = 0, 
  rollCor: number = 0): Quaternion 
{
  const dv = targetPoint.subtract(pos);
  const yaw = -Math.atan2(dv.z, dv.x) - Math.PI / 2;
  const len = Math.sqrt(dv.x * dv.x + dv.z * dv.z);
  const pitch = Math.atan2(dv.y, len);
  return Quaternion.RotationYawPitchRoll(yaw + yawCor, pitch + pitchCor, rollCor);
}

Test: Click on the water bowl and the dog should walk over to it and then stop and stand in front of it.

.lengthSquared()

This checks the distance remaining to the target. When calculating distance, the lengthSquared is first calculated and then a square root is taken to get the length. Game developers often use lengthSquared where possible as a best practice anytime we do not need the specific length.

normalize().scale(.015)

By normalizing the delta position we get a direction. Then the direction is scaled, providing the correct offset for a single frame of movement.

-Math.PI / 2

This is defining a rotation offset for the model. Without this, in our example the dog would walk sideways.

Follow the Character Around

The dog should randomly decide to follow the character from time to time. When he catches up to the character, the dog sits (to ask for a treat).

Add the following to the sceneDidMount function to make the player's position available in state and to considerGoals periodically:

this.subscribeTo("positionChanged", (e) =>
{
  this.setState({characterPosition: new Vector3(e.position.x, e.position.y, e.position.z)});
}); 

setInterval(() =>
{
  if(this.state.dogAnimationWeight < 1)
  {
    return;
  }

  switch(this.state.dogGoal)
  {
    case Goal.Idle:
      this.considerGoals([
        {goal: Goal.Sit, odds: .1},
        {goal: Goal.Follow, odds: .9},
      ]);
    case Goal.Drinking:
      this.considerGoals([
        {goal: Goal.Sit, odds: .1},
      ]);
    case Goal.Follow:
      this.considerGoals([
        {goal: Goal.Idle, odds: .1},
      ]);
    case Goal.GoDrink:
    case Goal.Sit:
      this.considerGoals([
        {goal: Goal.Idle, odds: .1},
      ]);
  } 
}, 1500);

Note: We are using a second, slower interval here so the dog does not change his mind too quickly.

Then implement the considerGoals function:

considerGoals(goals: {goal: Goal, odds: number}[])
{
  for(let i = 0; i < goals.length; i++)
  {
    if(Math.random() < goals[i].odds)
    {
      switch(goals[i].goal)
      {
        case Goal.Follow:
          if(!isInBounds(this.state.characterPosition))
          {
            continue;
          }
      }

      this.setDogGoal(goals[i].goal);
      return;
    }
  }
}

Test: When you are in bounds, the dog may randomly start following you.

subscribeTo("positionChanged"...

Decentraland communicates information by way of events whereever possible. See Decentraland's Event Handling.

new Vector3

Here we are creating a Babylonjs Vector3 from Decentraland's Vector3Component.



That’s it! Hope this was helpful and let us know if you have questions.

Next steps:

  • The model has an animation called 'Drinking'. Add this animation to the code above.
  • Use slerp to adjust the rotation in code and then the dot product to scale the dog's movement speed.
  • When the animation states change rapidly, it does not transition smoothly. Can you fix this? Maybe better consideration of previous states or by leveraging animation transitions defined by your modeler.
  • Add more puppies!



Source Code

scene.tsx:

import * as DCL from 'metaverse-api'
import {Vector3, Quaternion} from "babylonjs"; 

export interface IState 
{
  characterPosition: Vector3, 
  bowlPosition: Vector3, 
  dogPosition: Vector3, 
  dogRotation: Quaternion,
  dogGoal: Goal,
  dogPreviousGoal: Goal,
  dogAnimationWeight: number,
}

enum Goal
{
  Idle,
  Sit,
  Follow,
  GoDrink,
  Drinking,
}

export default class SampleScene extends DCL.ScriptableScene<any, IState>
{
  state = { 
    characterPosition: new Vector3(0, 0, 0), 
    bowlPosition: new Vector3(1, 0, 1), 
    dogPosition: new Vector3(9, 0, 9), 
    dogRotation: new Quaternion(0, 0, 0, 1), 
    dogGoal: Goal.Idle,
    dogPreviousGoal: Goal.Idle,
    dogAnimationWeight: 1,
  };

  getAnimationRates() : {idle: number, sit: number, walk: number} 
  {
    const weight = Math.min(Math.max(this.state.dogAnimationWeight, 0), 1);
    const inverse = 1 - weight;

    let sit = 0;
    let walk = 0;
    
    switch(this.state.dogPreviousGoal)
    {
      case Goal.Sit:
        sit = inverse;
        break;
      case Goal.Follow:
      case Goal.GoDrink:
        walk = inverse;
        break;
    }

    switch(this.state.dogGoal)
    {
      case Goal.Sit:
        sit = weight;
        break;
      case Goal.Follow:
      case Goal.GoDrink:
        walk = weight;
        break;
    }

    return {idle: 1 - (sit + walk), sit, walk};
  }

  sceneDidMount()
  {
    this.eventSubscriber.on("Dog_click", () =>
    {
      this.setDogGoal(this.state.dogGoal == Goal.Sit ? Goal.Idle : Goal.Sit);
    });

    this.eventSubscriber.on("Bowl_click", () =>
    {
      this.setDogGoal(Goal.GoDrink);
    });

    setInterval(() => 
    {
      const weight = Math.min(Math.max(this.state.dogAnimationWeight, 0), 1);
      this.setState({dogAnimationWeight: weight + .01});

      switch(this.state.dogGoal)
      {
        case Goal.Follow:
        case Goal.GoDrink:
          const targetLocation = this.state.dogGoal == Goal.Follow ? this.state.characterPosition : this.state.bowlPosition;
          const delta = targetLocation.subtract(this.state.dogPosition);
          if(delta.lengthSquared() < 2) 
          {
            this.setDogGoal(this.state.dogGoal == Goal.Follow ? Goal.Sit : Goal.Drinking);
          }
          else
          {
            this.walkTowards(targetLocation);
          }
      }
    }, 1000/60);

    this.subscribeTo("positionChanged", (e) =>
    {
      this.setState({characterPosition: new Vector3(e.position.x, e.position.y, e.position.z)});
    }); 

    setInterval(() =>
    {
      if(this.state.dogAnimationWeight < 1)
      {
        return;
      }
  
      switch(this.state.dogGoal)
      {
        case Goal.Idle:
          this.considerGoals([
            {goal: Goal.Sit, odds: .1},
            {goal: Goal.Follow, odds: .9},
          ]);
        case Goal.Drinking:
          this.considerGoals([
            {goal: Goal.Sit, odds: .1},
          ]);
        case Goal.Follow:
          this.considerGoals([
            {goal: Goal.Idle, odds: .1},
          ]);
        case Goal.GoDrink:
        case Goal.Sit:
          this.considerGoals([
            {goal: Goal.Idle, odds: .1},
          ]);
      } 
    }, 1500);
  }

  considerGoals(goals: {goal: Goal, odds: number}[])
  {
    for(let i = 0; i < goals.length; i++)
    {
      if(Math.random() < goals[i].odds)
      {
        switch(goals[i].goal)
        {
          case Goal.Follow:
            if(!isInBounds(this.state.characterPosition))
            {
              continue;
            }
        }

        this.setDogGoal(goals[i].goal);
        return;
      }
    }
  }

  setDogGoal(goal: Goal)
  {
    this.setState({
      dogGoal: goal,
      dogAnimationWeight: 1 - this.state.dogAnimationWeight,
      dogPreviousGoal: this.state.dogGoal
    });
  }

  walkTowards(position: Vector3)
  {
    let delta = position.subtract(this.state.dogPosition);
    delta = delta.normalize().scale(.015); // .015 is the walk speed
    delta.y = 0;
    const newPosition = this.state.dogPosition.add(delta);
    if(!isInBounds(newPosition)) 
    {
      this.setDogGoal(Goal.Idle);
    }
    else
    {
      this.setState({
        dogPosition: newPosition,
        dogRotation: lookAt(this.state.dogPosition, position, -Math.PI / 2)
      });
    }
  }

  async render() 
  {
    const animationWeights = this.getAnimationRates();
    return (
      <scene>
        <gltf-model 
          id="Dog"
          src="art/BlockDog.gltf"
          position={this.state.dogPosition} 
          rotation={this.state.dogRotation.toEulerAngles().scale(180 / Math.PI)} 
          transition={{
            rotation: {
              duration: 300
            }
          }}
          skeletalAnimation={[
            { 
              clip: "Idle", 
              weight: animationWeights.idle,
            },
            { 
              clip: "Walking", 
              weight: animationWeights.walk,
            },
            { 
              clip: "Sitting", 
              weight: animationWeights.sit,
            },
          ]}
        />
        <gltf-model
          id="Bowl"
          src="art/BlockDogBowl.gltf"
          position={this.state.bowlPosition} 
        />
      </scene>
    )
  }
}

function isInBounds(position: Vector3): boolean
{
  return position.x > .5 && position.x < 9.5
    && position.z > .5 && position.z < 9.5;
}

// Math from Babylon TransformNode
function lookAt(
  pos: Vector3,
  targetPoint: Vector3, 
  yawCor: number = 0, 
  pitchCor: number = 0, 
  rollCor: number = 0): Quaternion 
{
  const dv = targetPoint.subtract(pos);
  const yaw = -Math.atan2(dv.z, dv.x) - Math.PI / 2;
  const len = Math.sqrt(dv.x * dv.x + dv.z * dv.z);
  const pitch = Math.atan2(dv.y, len);
  return Quaternion.RotationYawPitchRoll(yaw + yawCor, pitch + pitchCor, rollCor);
}
Authors get paid when people like you upvote their post.
If you enjoyed what you read here, create your account today and start earning FREE STEEM!