Decentraland Tutorial: Dog, Cat, Mouse, Cheese (using a FSM)
Dog, Cat, Mouse, Cheese. Decentraland.
We'll be creating a scene in which a predator guards its home, attacking any prey in sight. The prey is on a mission: sneak through the fence, get some cheese, and get out safely.
In this example, we will create a stack-based finite state machine (FSM) to manage AI for animals.
- Getting Started
- Adding a Grid
- Animals
- Event Manager
- State Machine
- Path Finding
- Despawn
- Eat
- Patrol
- Polish
Getting Started
We are starting with a scene and a collection of components already created. For more information about components and how to get started with Decentraland, see one of our previous tutorials:
Music Jukebox:
A good introduction for beginners.Block Dog:
The Block Dog tutorial shows a way of controlling motion in your scene that's very different from this tutorial. You may want to consider both before deciding which approach may be best for your project.Tower Defense:
Here we create a basic game, introducing components and how you might start to scale up more complex scenes and interactions.
Decentraland also has a page showing a collection of example scenes you could learn from.
Download the Starting Scene
Get the scene and art from:
And extract it anywhere.
Start Decentraland
Open a command prompt, navigate to the project's directory, and run:
dcl start
Note this assumes you have installed Decentraland's SDK. If not, please refer to one of the other tutorials mentioned above to get started.
This will open a new browser tab with the scene.
Test: Walk around and take a look. Nice, right? Thanks James (the artist).
About the Starting Scene
Due to the size of the scene we are creating, we are kicking off the tutorial with a static scene and some basic logic. Once you have completed a Decentraland tutorial or two, I hope most of the code included here will make sense.
- All art will be rendered by a component (in the components directory). See Decentraland's docs to learn about components.
scene.tsx
includes some default state and calls to render each of the components.ts/SharedProperties.ts
: includes common types.ts/MathHelper.ts
: includes basic Vector3 math to make writing logic a bit easier.ts/SceneHelper.ts
: includes positioning information for static scenery.
Adding a Grid
For path finding and collision handling, we will be logically positioning objects into a grid in which each cell is 1m x 1m.
For simplicity, when an object moves, it jumps from cell to cell. In the rendered component, we use a transition
in order to animate that change in position.
To check for collisions, we simply check if the target grid cell is already occupied.
The grid will also make integrating a-star pathfinding (later in this tutorial) easy.
Create a Grid
Create ts\Grid.ts
:
import { Vector3Component } from "metaverse-api";
import { add } from "ts/MathHelper";
export namespace Grid
{
const grid: boolean[][] = [];
export function init(width: number, depth: number)
{
grid.length = 0;
for (let x = 0; x < width; x++)
{
grid.push([]);
for (let y = 0; y < depth; y++)
{
grid[x].push(false);
}
}
}
export function set(position: Vector3Component, canBeOccupiedAlready: boolean = false)
{
const x = Math.round(position.x);
const z = Math.round(position.z);
if (grid[x][z] && !canBeOccupiedAlready)
{
throw new Error("Grid cell is already set");
}
grid[x][z] = true;
}
export function clear(position: Vector3Component, canBeEmpty: boolean = false)
{
const x = Math.round(position.x);
const z = Math.round(position.z);
if (!grid[x][z] && !canBeEmpty)
{
throw new Error("Grid cell wasn't set");
}
grid[x][z] = false;
}
export function isAvailable(position: Vector3Component)
{
const x = Math.round(position.x);
const z = Math.round(position.z);
if (x < 0 || z < 0 || grid.length <= x || grid[x].length <= z)
{
return false;
}
return !grid[x][z];
}
export function randomPosition(border: number = 1, mustBeAvailable: boolean = true): Vector3Component
{
let position;
do
{
position = {
x: Math.random() * (grid.length - border * 2) + border,
y: 0,
z: Math.random() * (grid[0].length - border * 2) + border
};
} while (!isAvailable(position) && mustBeAvailable);
return position;
}
export function getNeighbors(startingPosition: Vector3Component): Vector3Component[]
{
let neighbors: Vector3Component[] = [];
for (const neighborDirection of [
{ x: 1, y: 0, z: 0 },
{ x: -1, y: 0, z: 0 },
{ x: 0, y: 0, z: 1 },
{ x: 0, y: 0, z: -1 },
//If enabling diag, update the 'distance' above with a formula
//{ x: 1, y: 0, z: 1 },
//{ x: -1, y: 0, z: -1 },
//{ x: -1, y: 0, z: 1 },
//{ x: 1, y: 0, z: -1 },
])
{
let position = add(startingPosition, neighborDirection);
if (!isAvailable(position))
{
continue;
}
neighbors.push(position);
}
return neighbors;
}
export function hasClearance(position: Vector3Component, range: number): boolean
{
if (!isAvailable(position))
{
return false;
}
const neighbors = getNeighbors(position);
if (neighbors.length < 4)
{
return false;
}
if (range > 1)
{
for (const neighbor of neighbors)
{
if (!hasClearance(neighbor, range - 1))
{
return false;
}
}
}
return true;
}
}
Note: you could change the precision of the grid by changing all the Math.round(position.x)
like lines. For example:
const gridCellX = Math.round(position.x * 2)
const gridCellY = Math.round(position.y * 2)
This would make each grid cell .5m x .5m (twice as precise).
Initialize the Grid
Add the following to sceneDidMount
in the scene.tsx
file to initialize the grid:
sceneDidMount()
{
Grid.init(30, 30);
This will initialize the grid's arrays to the correct size for our world.
Note that you will also need to import the Grid
for this to compile. IDEs (such as VS Code) will present a hint, allowing you to auto-complete the missing reference (usually by pressing Ctrl+Space). This will come up several times throughout this tutorial (and we will not be mentioning it explicitly each time). The import for Grid
will look like:
import { Grid } from 'ts/Grid';
Spawn Trees in Random Locations
Update spawnTrees
in the scene.tsx
file to create a number of trees in random locations:
spawnTrees()
{
let trees: ISceneryProps[] = [];
const range = config.trees.max - config.trees.min;
let counter = 0;
for (let i = 0; i < Math.random() * range + config.trees.min; i++)
{
let position;
do
{
position = Grid.randomPosition(2, true);
if (counter++ > 500)
{ // Don't get stuck working too hard
break;
}
} while (!Grid.hasClearance(position, 4));
Grid.set(position);
trees.push({
position,
rotation: { x: 0, y: Math.random() * 360, z: 0 },
scale: { x: 1, y: Math.random() * .4 + 1, z: 1 }
});
}
this.setState({ trees });
}
Test: Each time you refresh the browser, there should be a new random layout of trees. Note that trees may overlap scenery at the moment.
We are using a JSON config file to make adjusting certain settings easy. You can modify config.json
to change the number of trees spawned:
"trees": {
"min": 5,
"max": 20
},
Add Static Scenery to the Grid
The spawnTrees
algorithm above includes a loop to select a position with clearance / free space around it. For this to work, we'll need to register the position of each of our static scenery objects with the grid.
Update ts/SceneHelper.ts
by adding the following method:
export function updateGridWithStaticScenery()
{
for (const fence of fenceProps)
{
Grid.set(fence.position, true);
if (fence.rotation.y == 0 || fence.rotation.y == 180)
{
Grid.set(add(fence.position, { x: 1, y: 0, z: 0 }), true);
Grid.set(add(fence.position, { x: -1, y: 0, z: 0 }), true);
}
else
{
Grid.set(add(fence.position, { x: 0, y: 0, z: 1 }), true);
Grid.set(add(fence.position, { x: 0, y: 0, z: -1 }), true);
}
}
for (const corner of fenceCornerProps)
{
Grid.set(corner.position, true);
if (corner.rotation.y == 0 || corner.rotation.y == 180)
{
Grid.set(add(corner.position, { x: 1, y: 0, z: 0 }), true);
Grid.set(add(corner.position, { x: -1, y: 0, z: 0 }), true);
}
else
{
Grid.set(add(corner.position, { x: 0, y: 0, z: 1 }), true);
Grid.set(add(corner.position, { x: 0, y: 0, z: -1 }), true);
}
}
for (const spinner of fenceSpinnerProps)
{
Grid.clear(spinner.position, true);
}
for (let x = -1; x <= 1; x++)
{
for (let z = -1; z <= 1; z++)
{
if (x == 0 && z == 0 || z == -1 && x == 0)
{
continue;
}
Grid.set(add(houseProps.position, { x, y: 0, z }), true);
}
}
for (let x = 0; x < 2; x++)
{
for (let z = -2; z <= 2; z++)
{
if (x == 0 && z == 0)
{
continue;
}
Grid.set(add(exitProps.position, { x, y: 0, z }), true);
}
}
for (let x = -1; x <= 0; x++)
{
for (let z = -1; z <= 1; z++)
{
if (x == 0 && z == 0)
{
continue;
}
Grid.set(add(entranceProps.position, { x, y: 0, z }), true);
}
}
}
Then in scene.tsx
, update sceneDidMount
to update the grid (note there are two new lines here):
sceneDidMount()
{
Grid.init(30, 30);
SceneHelper.updateGridWithStaticScenery();
Grid.set(this.state.baitProps.position);
this.spawnTrees();
}
Test: Restart the scene several times and confirm the trees are never overlapping scenery.
Render Grid for Debugging
As we add more experiences, we'll need a way to confirm that the grid is configured correctly. Let's add a renderGrid
method to scene.tsx
:
renderGrid()
{
let trees: ISceneryProps[] = [];
for (let x = 0; x < 30; x++)
{
for (let z = 0; z < 30; z++)
{
let position = { x, y: 0, z };
if (Grid.isAvailable(position))
{
continue;
}
trees.push({
position,
rotation: { x: 0, y: Math.random() * 360, z: 0 },
scale: { x: 1, y: Math.random() * .4 + 1, z: 1 }
});
}
}
this.setState({ trees });
}
Call it from sceneDidMount
, after spawnTrees
:
sceneDidMount()
{
...
//this.spawnTrees();
this.renderGrid(); // For debugging
}
Note: commenting out spawnTrees
is optional.
Test: There should be a tree rendered on top of each fence post as well as on other scenery in the world.
Animals will only be able to walk where there is no tree (i.e., the grid cell is not occupied). So it's important that there are gaps in the fence, for example, so they can navigate through.
Turn off renderGrid
, but remember this for debugging when you need it:
this.spawnTrees();
//this.renderGrid(); // For debugging
Animals
There will be two types of animals in the scene, and we have 3 models to choose from. Model selection is driven from our config.json
file. It's intended to allow you to change the scene from a cat chasing a mouse to a dog chasing a cat. It also shows how separating components from logic allow for more flexibility - the cat here can either play the role of a predator, or a prey.
Spawn a Predator (Dog or Cat)
The predator starts at the dog house and then will patrol the area, looking for prey.
Add an event to sceneDidMount
for when the user clicks on the House
:
sceneDidMount()
{
...
this.eventSubscriber.on("House_click", e => this.onHouseClick());
}
Add the following method to respond to the click event by spawning a predator:
onHouseClick()
{ // Spawn predator
this.spawnAnimal(
config.predator.animalType,
SceneHelper.houseProps.position,
add(SceneHelper.houseProps.position, { x: 0, y: 0, z: -1 }),
config.predator.patrolSpeed);
}
Add a helper method for spawning animals, which we will use again for the prey:
spawnAnimal(animalKey: keyof typeof AnimalType,
position: Vector3Component,
lookAtPosition: Vector3Component,
moveDuration: number): IAnimalProps | null
{
if (!Grid.isAvailable(position))
{ // Space is occupied, can't spawn
return null;
}
Grid.set(position);
const animal: IAnimalProps = {
id: "Animal" + this.objectCounter++,
animalType: AnimalType[animalKey],
position,
lookAtPosition,
moveDuration,
animationWeights: [
{ animation: AnimationType.Idle, weight: 1 },
{ animation: AnimationType.Walk, weight: 0 },
{ animation: AnimationType.Drink, weight: 0 },
{ animation: AnimationType.Dead, weight: 0 },
{ animation: AnimationType.Run, weight: 0 },
{ animation: AnimationType.Sit, weight: 0 },
],
isDead: false,
scale: 1,
};
this.setState({ animals: [...this.state.animals, animal] });
return animal;
}
Test: Click on the dog house to see a cat spawn. Modify the config.json
to see a Dog
spawn instead:
"predator": {
"animalType": "Dog",
Spawn the Prey (Cat or Mouse)
Add an event to sceneDidMount
for when the user clicks on the Entrance
:
sceneDidMount()
{
...
this.eventSubscriber.on("Entrance_click", e => this.onEntranceClick());
}
Add the following method to respond to the click event by spawning prey:
onEntranceClick()
{ // Spawn prey
this.spawnAnimal(
config.prey.animalType,
SceneHelper.entranceProps.position,
add(SceneHelper.entranceProps.position, { x: 1, y: 0, z: 0 }),
config.prey.sneakSpeed);
}
Test: Click on the entrance mound... and it will look like nothing happened. The mouse spawns in the dirt mound, but we cannot see it. Modify the config.json
to see a Cat
spawn instead and you'll see a head poking through:
"prey": {
"animalType": "Cat",
Switch back to Mouse
when your done testing.
Event Manager
We are creating an EventManager
namespace to make it easy to interface with the eventSubscriber
found in scene.tsx
.
Create the Event Manager
Create an ts\EventManager.ts
file with the following:
import { EventSubscriber } from "metaverse-api";
export namespace EventManager
{
let eventSubscriber: EventSubscriber;
export function init(_eventSubscriber: EventSubscriber)
{
eventSubscriber = _eventSubscriber;
}
export function emit(eventType: string, ...params: any[])
{
eventSubscriber.emit(eventType, ...params);
}
}
Update sceneDidMount
in scene.tsx
with:
sceneDidMount()
{
EventManager.init(this.eventSubscriber);
...
}
Render Animals on Event
When one of the display properties for an animal changes, such as the position, we will fire a renderAnimals
event. Update scene.tsx
to re-render the animals when this event occurs:
sceneDidMount()
{
...
this.eventSubscriber.on('renderAnimals', e => this.onRenderAnimals());
}
Add a method to respond to the event:
onRenderAnimals()
{
this.setState({ animals: this.state.animals });
}
State Machine
We'll be creating a stack-based finite state machine to handle the AI for the animals. This means that for each animal, there is a single state which currently defines its behavior. That state may add another state to the stack in order to achieve an interm goal, or pop itself off the stack once it achieves its own goal, allowing the previous state to resume.
For the prey, we will be working towards the following scenario:
- The prey spawns with a stack of
StateDespawn
,StateGoTo
(exit), andStateEat
. StateEat
is at the top of the stack, so that executes first.- In order to eat, the animal must be near the food. If the food is out of reach, it adds
StateGoTo
(food). StateGoTo
paths to the cheese and animates the walk there.- Once there,
StateGoTo
pops itself off. StateEat
kicks in again, this time the food is within range so it plays an eating animation and then pops itself off the stack.StateGoTo
(exit) is next on the stack. That paths and animates the animal to the exit.- Once at the exit,
StateGoTo
pops itself off. StateDespawn
waits a second and then removes the animal from the scene.
Create a Shared, Abstract State
Create ts/StateMachine/AnimalState.ts
, which will be inherited by each of the states we implement below:
import { AnimationType, IAnimalProps } from "ts/SharedProperties";
import { AnimalStateMachine } from "ts/StateMachine/AnimalStateMachine";
import { setTimeout, clearTimeout } from "timers";
export class AnimalState
{
animalProps: IAnimalProps;
animationTimeout?: NodeJS.Timer = undefined;
constructor(animal: IAnimalProps)
{
this.animalProps = animal;
}
start(): void { }
stop(): void
{
if (this.animationTimeout)
{
clearTimeout(this.animationTimeout);
}
}
processMessage(message: string): boolean
{
return false;
}
animate(steps: { animation: AnimationType, for: number }[], then: () => void, stepNumber: number = 0)
{
if (stepNumber >= steps.length)
{
then();
return;
}
AnimalStateMachine.changeAnimation(this.animalProps.id, steps[stepNumber].animation);
this.animationTimeout = setTimeout(() =>
{
this.animate(steps, then, ++stepNumber);
}, steps[stepNumber].for);
}
}
Create the State Machine
Create ts/StateMachine/AnimalStateMachine.ts
, which manages the state machine for each individual animal:
import { AnimationType, IAnimalProps } from "ts/SharedProperties";
import { setInterval, clearInterval } from "timers";
import { EventManager } from "ts/EventManager";
import { AnimalState } from "ts/StateMachine/AnimalState";
export namespace AnimalStateMachine
{
interface AnimalStateObject
{
animalProps: IAnimalProps,
stateStack: AnimalState[],
animationInterval?: NodeJS.Timer
};
const animalStates: AnimalStateObject[] = [];
export function getAnimals(where: (a: AnimalStateObject) => boolean)
{
return animalStates.filter(where);
}
export function getAnimalProps(id: string): IAnimalProps | undefined
{
let state = animalStates.find(a => a.animalProps.id == id);
if (state)
{
return state.animalProps;
}
return undefined;
}
export function pushState(state: AnimalState)
{
let animalState = animalStates.find(s => s.animalProps.id == state.animalProps.id);
if (!animalState)
{
animalState = {
animalProps: state.animalProps,
stateStack: [state],
animationInterval: undefined
};
animalStates.push(animalState);
}
else
{
const previousState = animalState.stateStack[animalState.stateStack.length - 1];
if (previousState)
{
previousState.stop();
}
animalState.stateStack.push(state);
}
animalState.stateStack[animalState.stateStack.length - 1].start();
}
export function pushStates(states: AnimalState[])
{
let animalState = animalStates.find(s => s.animalProps.id == states[0].animalProps.id);
if (!animalState)
{
animalState = {
animalProps: states[0].animalProps,
stateStack: states,
animationInterval: undefined
};
animalStates.push(animalState);
}
else
{
const previousState = animalState.stateStack[animalState.stateStack.length - 1];
if (previousState)
{
previousState.stop();
}
for (const state of states)
{
animalState.stateStack.push(state);
}
}
animalState.stateStack[animalState.stateStack.length - 1].start();
}
export function popState(id: string)
{
const animalState = animalStates.find(s => s.animalProps.id == id);
if (!animalState)
{
throw new Error("Animal not found");
}
if (animalState.stateStack.length <= 1)
{
throw new Error("You're popping everything!");
}
const previousState = animalState.stateStack.pop();
if (previousState)
{
previousState.stop();
}
animalState.stateStack[animalState.stateStack.length - 1].start();
}
export function sendMessage(objectId: string, message: string)
{
const animalState = animalStates.find(s => s.animalProps.id == objectId);
if (!animalState)
{
throw new Error("Animal not found");
}
while (!animalState.stateStack[animalState.stateStack.length - 1].processMessage(message))
{
const previousState = animalState.stateStack.pop();
if(previousState)
{
previousState.stop();
}
}
animalState.stateStack[animalState.stateStack.length - 1].start();
}
export function terminate(objectId: string)
{
const index = animalStates.findIndex(s => s.animalProps.id == objectId);
if (index < 0)
{
throw new Error("Animal not found");
}
const animalState = animalStates[index];
animalState.stateStack[animalState.stateStack.length - 1].stop();
if (animalState.animationInterval)
{
clearInterval(animalState.animationInterval);
}
animalStates[index] = animalStates[animalStates.length - 1];
animalStates.length--;
}
export function changeAnimation(id: string, animation: AnimationType)
{
const animalState = animalStates.find(s => s.animalProps.id == id);
if (!animalState)
{
throw new Error("Animal not found");
}
if (animalState.animationInterval)
{
clearInterval(animalState.animationInterval);
}
const animationDeltaPerFrame = .25 / (1000 / 60);
animalState.animationInterval = setInterval(() =>
{
let isDone = true;
for (let animationWeight of animalState.animalProps.animationWeights)
{
if (animationWeight.animation == animation)
{
animationWeight.weight += animationDeltaPerFrame;
if (animationWeight.weight >= 1)
{
animationWeight.weight = 1;
}
else
{
isDone = false;
}
}
else
{
animationWeight.weight -= animationDeltaPerFrame;
if (animationWeight.weight <= 0)
{
animationWeight.weight = 0;
}
else
{
isDone = false;
}
}
}
EventManager.emit("renderAnimals");
if (isDone)
{
if (animalState.animationInterval)
{
clearInterval(animalState.animationInterval);
}
}
}, 1000 / 60);
}
}
Create a State for Idling
Create ts/StateMachine/StateIdle.ts
, a simple state which idles for a few moments and then pops itself off the stack:
import { AnimalState } from "ts/StateMachine/AnimalState";
import { AnimalStateMachine } from "ts/StateMachine/AnimalStateMachine";
import { AnimationType, IAnimalProps } from "ts/SharedProperties";
export interface IStateIdleConfig
{
minLength: number;
maxLength: number;
oddsOfSitting: number;
}
export class StateIdle extends AnimalState
{
config: IStateIdleConfig;
constructor(animal: IAnimalProps, config: IStateIdleConfig)
{
if (!config)
{
throw new Error("Missing config");
}
super(animal);
this.config = config;
}
start()
{
const howLong = Math.random() * (this.config.maxLength - this.config.minLength) + this.config.minLength;
let steps;
if (Math.random() < this.config.oddsOfSitting)
{
steps = [
{ animation: AnimationType.Idle, for: 500 },
{ animation: AnimationType.Sit, for: Math.max(500, howLong - 1000) },
{ animation: AnimationType.Idle, for: 500 },
];
}
else
{
steps = [
{ animation: AnimationType.Idle, for: howLong },
];
}
this.animate(steps, () =>
{
AnimalStateMachine.popState(this.animalProps.id);
});
}
stop()
{
super.stop();
}
}
Set the initial state to idle
Update onEntranceClick
in scene.tsx
to add StateIdle
to prey that spawns in:
onEntranceClick()
{
const animalProps = this.spawnAnimal(
config.prey.animalType,
SceneHelper.entranceProps.position,
add(SceneHelper.entranceProps.position, { x: 1, y: 0, z: 0 }),
config.prey.sneakSpeed);
if (animalProps)
{
AnimalStateMachine.pushState(
new StateIdle(animalProps, config.prey.blockedConfig),
);
}
}
Test: The scene will look the same as it did previously. Spawn a prey and it idles for a period of time... and then an error is thrown (which you can see in the console). The error will be thrown promptly. For testing you could modify config.json
to increase the minLength
\ maxLength
.
Path Finding
We are going to use an open-sounce implementation of a-star, which is an effecient way of finding the best path between points. You could, of course, implement your own as well.
Install A-Star
In the command prompt, navigate to the project's directory and run:
npm install a-star
Create an API for Pathing
Add a calcPath
method to Grid.ts
:
const aStar = require('a-star');
export namespace Grid
{
...
export function calcPath(startingPosition: Vector3Component, targetPosition: Vector3Component): Vector3Component[]
{
targetPosition = round(targetPosition);
const results = aStar({
start: round(startingPosition),
isEnd: (n: Vector3Component): boolean =>
{
return inSphere(n, targetPosition, Grid.isAvailable(targetPosition) ? 0 : 1);
},
neighbor: (x: Vector3Component): Vector3Component[] =>
{
return Grid.getNeighbors(x);
},
distance: (a: Vector3Component, b: Vector3Component): number =>
{
return 1;
},
heuristic: (x: Vector3Component): number =>
{
return lengthSquared(subtract(x, targetPosition));
},
hash: (x: Vector3Component): string =>
{
return JSON.stringify(x);
},
timeout: 10
});
if (results.status == "success")
{
return results.path;
}
return [];
}
}
Create a State For Pathing
This state will take a target position, use a-star to find a path to that position, and then animate the walk there as well as handle and collisions that arrise.
Create ts/StateMachine/StateGoTo.ts
:
import { Vector3Component } from "metaverse-api";
import { AnimalState } from "ts/StateMachine/AnimalState";
import { IAnimalProps, AnimationType } from "ts/SharedProperties";
import { setInterval, clearInterval } from "timers";
import { subtract, isZero, add, div, equals, lengthSquared, mul } from "ts/MathHelper";
import { AnimalStateMachine } from "ts/StateMachine/AnimalStateMachine";
import { Grid } from "ts/Grid";
import { IStateIdleConfig, StateIdle } from "ts/StateMachine/StateIdle";
export interface IStateGoToConfig
{
moveSpeed: number;
panicSpeed?: number;
}
export class StateGoTo extends AnimalState
{
target: {
position: Vector3Component,
isDead?: boolean
};
config: IStateGoToConfig;
blockedConfig?: IStateIdleConfig;
interval?: NodeJS.Timer = undefined;
inPanic: boolean = false;
constructor(animal: IAnimalProps, target: { position: Vector3Component, isDead?: boolean }, config: IStateGoToConfig, blockedConfig?: IStateIdleConfig)
{
super(animal);
this.target = target;
this.config = config;
this.blockedConfig = blockedConfig;
}
start()
{
const speed = this.inPanic ? (this.config.panicSpeed || this.config.moveSpeed) : this.config.moveSpeed;
this.animalProps.moveDuration = speed;
const targetPosition = this.target.position;
const path = Grid.calcPath(this.animalProps.position, targetPosition);
if (this.target.isDead || path.length <= 0)
{
if (this.blockedConfig && !this.target.isDead)
{
return AnimalStateMachine.pushState(new StateIdle(this.animalProps, this.blockedConfig));
}
else
{
return AnimalStateMachine.popState(this.animalProps.id);
}
}
if (path.length == 1)
{
return AnimalStateMachine.popState(this.animalProps.id);
}
let pathIndex = 1;
if (this.interval)
{
clearInterval(this.interval);
}
this.interval = setInterval(() =>
{
let target = path[pathIndex];
if (pathIndex < path.length - 1)
{ // Smooth diag movement
target = add(target, path[pathIndex + 1]);
target = div(target, 2);
}
try
{
this.walkTowards(target);
}
catch (e)
{
return this.repath();
}
pathIndex++;
if (pathIndex >= path.length
|| this.target.isDead)
{
return AnimalStateMachine.popState(this.animalProps.id);
}
if (!equals(this.target.position, targetPosition))
{
return this.repath();
}
}, speed);
}
repath()
{
this.stop();
this.start();
}
stop()
{
if (this.interval)
{
clearInterval(this.interval);
}
}
walkTowards(targetPosition: Vector3Component)
{
const toTarget = subtract(targetPosition, this.animalProps.position);
if (isZero(toTarget))
{ // Already there
AnimalStateMachine.changeAnimation(this.animalProps.id, AnimationType.Idle);
return;
}
Grid.clear(this.animalProps.position);
if (!Grid.isAvailable(targetPosition))
{
Grid.set(this.animalProps.position);
throw new Error("Space occupied, can't walk there.");
}
this.animalProps.position = targetPosition;
Grid.set(this.animalProps.position);
if (lengthSquared(toTarget) > .1)
{
// Look past the target
this.animalProps.lookAtPosition = add(targetPosition, mul(toTarget, 10));
}
AnimalStateMachine.changeAnimation(this.animalProps.id, this.inPanic ? AnimationType.Run : AnimationType.Walk);
}
processMessage(message: string): boolean
{
if (message == "panic")
{
this.inPanic = true;
this.repath();
return true;
}
return super.processMessage(message);
}
}
Change the Prey to Use StateGoTo
Change the prey's initial state from StateIdle
to StateGoTo
:
onEntranceClick()
{
...
if (animalProps)
{
AnimalStateMachine.pushState(
new StateGoTo(animalProps, SceneHelper.exitProps, config.prey.exitConfig, config.prey.blockedConfig),
);
}
}
Test: When you spawn in prey, it will start to walk towards the exit, navigating around obstacles such as the fence. Once they reach the exit, they start to line up (until we add the ability to despawn).
Despawn
When an animal is eaten or reaches the end, we need to despawn it, removing it from the scene and freeing up resources. We'll be using events to communicate from the state machine (or anywhere in the application) to the front-end (scene.tsx
) for removal.
Remove Animal on Despawn
Add a new event subscription to sceneDidMount
:
this.eventSubscriber.on('despawn', (animalId, delay) => this.onDespawn(animalId, delay));
And an event handler:
async onDespawn(animalId: string, delay: number)
{
const animal = this.state.animals.find(a => a.id == animalId);
if (animal)
{
AnimalStateMachine.terminate(animalId);
animal.isDead = true;
await sleep(delay);
Grid.clear(animal.position);
this.setState({ animals: this.state.animals.filter((a) => a.id != animal.id) });
}
}
Add a State to Despawn an Animal
Create ts\StateMachine\StateDespawn.ts
:
import { AnimalState } from "ts/StateMachine/AnimalState";
import { IAnimalProps, AnimationType } from "ts/SharedProperties";
import { EventManager } from "ts/EventManager";
export interface IStateDespawnConfig
{
delay?: number;
}
export class StateDespawn extends AnimalState
{
config: IStateDespawnConfig;
timeout?: NodeJS.Timer = undefined;
constructor(animal: IAnimalProps, config: IStateDespawnConfig)
{
super(animal);
this.config = config;
}
start()
{
this.animate([{ animation: AnimationType.Dead, for: this.config.delay || 0 }], () => this.despawn());
}
despawn()
{
EventManager.emit("despawn", this.animalProps.id);
}
processMessage(message: string): boolean
{
return true;
}
}
And update the prey to fallback in scene.tsx
to despawn once goto completes:
if (animalProps)
{
AnimalStateMachine.pushStates([
new StateDespawn(animalProps, {delay: 1000}),
new StateGoTo(animalProps, SceneHelper.exitProps, config.prey.exitConfig, config.prey.blockedConfig),
]);
}
Test: Spawn in several prey. Once they get to the end, they should all despawn (previously they would have lined up at the exit).
Eat
Eating requires the food is nearby. So this state will add StateGoTo
if needed in order to reach the food. Once it's close enough, the animal will animate and then pop StateEat
from the stack.
Create a State for Eating
Create ts\StateMachine\StateEat.ts
:
import { AnimalState } from "ts/StateMachine/AnimalState";
import { AnimalStateMachine } from "ts/StateMachine/AnimalStateMachine";
import { AnimationType, IAnimalProps } from "ts/SharedProperties";
import { lengthSquared, subtract } from "ts/MathHelper";
import { StateGoTo, IStateGoToConfig } from "ts/StateMachine/StateGoTo";
import { EventManager } from "ts/EventManager";
import { Vector3Component } from "metaverse-api";
import { IStateIdleConfig } from "ts/StateMachine/StateIdle";
import { StateDespawn } from "ts/StateMachine/StateDespawn";
export interface IStateEatConfig
{
eatRange: number;
huntConfig: IStateGoToConfig;
}
interface IPrey
{
id: string,
position: Vector3Component,
isDead?: boolean
}
export class StateEat extends AnimalState
{
prey: IPrey;
config: IStateEatConfig;
blockedConfig?: IStateIdleConfig;
constructor(animal: IAnimalProps, prey: IPrey, config: IStateEatConfig, blockedConfig?: IStateIdleConfig)
{
super(animal);
this.prey = prey;
this.config = config;
this.blockedConfig = blockedConfig;
if (!this.config.huntConfig)
{
throw new Error("Missing huntConfig");
}
}
start()
{
if (this.prey.isDead)
{
AnimalStateMachine.popState(this.animalProps.id);
}
else if (lengthSquared(subtract(this.prey.position, this.animalProps.position)) <= this.config.eatRange * this.config.eatRange)
{
if (this.prey.isDead !== undefined)
{
let animal = AnimalStateMachine.getAnimalProps(this.prey.id);
if (animal)
{
AnimalStateMachine.pushState(new StateDespawn(animal, { delay: 1000 }))
}
}
else
{
EventManager.emit("captureBait", this.prey.id, 1000);
}
this.animalProps.lookAtPosition = this.prey.position;
EventManager.emit("renderAnimals");
this.animate([
{ animation: AnimationType.Drink, for: 1500 },
{ animation: AnimationType.Idle, for: 500 },
{ animation: AnimationType.Sit, for: 2000 },
{ animation: AnimationType.Idle, for: 500 },
], () =>
{
AnimalStateMachine.popState(this.animalProps.id);
});
}
else
{
if (this.prey.isDead !== undefined)
{
AnimalStateMachine.sendMessage(this.prey.id, "panic");
}
AnimalStateMachine.pushState(new StateGoTo(this.animalProps, this.prey, this.config.huntConfig, this.blockedConfig));
}
}
}
Add StateEat to the Prey
Update the prey's initial state machine in scene.tsx
to eat the cheese before exiting:
AnimalStateMachine.pushStates([
new StateDespawn(animalProps, {delay: 1000}),
new StateGoTo(animalProps, SceneHelper.exitProps, config.prey.exitConfig, config.prey.blockedConfig),
new StateEat(animalProps, this.state.baitProps, config.prey.eatConfig, config.prey.blockedConfig),
]);
Test: Spawn a mouse. It should go touch the cheese and then proceed to the exit.
Patrol
The predator is going to patrol around the dog house. If it spots prey, it will try to eat it, which in turn will make the predator chase its prey.
Create a State for Patroling
Create ts\StateMachine\StatePatrol.ts
:
import { AnimalStateMachine } from "ts/StateMachine/AnimalStateMachine";
import { AnimalState } from "ts/StateMachine/AnimalState";
import { Vector3Component } from "metaverse-api";
import { IAnimalProps, AnimalType } from "ts/SharedProperties";
import { lengthSquared, subtract, inSphere } from "ts/MathHelper";
import { StateEat, IStateEatConfig } from "ts/StateMachine/StateEat";
import { StateIdle, IStateIdleConfig } from "ts/StateMachine/StateIdle";
import { StateGoTo, IStateGoToConfig } from "ts/StateMachine/StateGoTo";
import { Grid } from "ts/Grid";
interface IStatePatrolConfig
{
eatConfig: IStateEatConfig,
idleConfig: IStateIdleConfig,
wanderConfig: IStateGoToConfig,
minRadius: number,
maxRadius: number,
chanceOfMoving: number,
preyType: AnimalType,
scanRadius: number,
}
export class StatePatrol extends AnimalState
{
config: IStatePatrolConfig;
patrolAround: { position: Vector3Component };
constructor(animal: IAnimalProps, patrolAround: { position: Vector3Component }, config: IStatePatrolConfig)
{
super(animal);
this.config = config;
this.patrolAround = patrolAround;
if (!this.config.eatConfig)
{
throw new Error("Missing eatConfig")
}
}
start()
{
let prey = this.lookForPrey()
if (prey)
{ // Hunt
AnimalStateMachine.pushState(new StateEat(this.animalProps, prey, this.config.eatConfig));
}
else if (Math.random() < this.config.chanceOfMoving)
{ // Move
let targetPosition;
do
{
targetPosition = Grid.randomPosition();
} while (!inSphere(targetPosition, this.patrolAround.position, this.config.maxRadius)
|| inSphere(targetPosition, this.patrolAround.position, this.config.minRadius));
AnimalStateMachine.pushState(new StateGoTo(this.animalProps, { position: targetPosition }, this.config.wanderConfig));
}
else
{ // Idle
AnimalStateMachine.pushState(new StateIdle(this.animalProps, this.config.idleConfig))
}
}
lookForPrey(): IAnimalProps | null
{
for (const prey of AnimalStateMachine.getAnimals((a) => a.animalProps.animalType == this.config.preyType && !a.animalProps.isDead))
{
const distanceSquared = lengthSquared(subtract(prey.animalProps.position, this.animalProps.position));
if (distanceSquared <= this.config.scanRadius * this.config.scanRadius)
{
return prey.animalProps;
}
}
return null;
}
processMessage(message: string): boolean
{
return true;
}
}
Have Predators Patrol on Spawn
Update onHouseClick
in scene.tsx
to spawn in predators with StatePatrol
:
onHouseClick()
{
const animalProps = this.spawnAnimal(
config.predator.animalType,
SceneHelper.houseProps.position,
add(SceneHelper.houseProps.position, { x: 0, y: 0, z: -1 }),
config.predator.patrolSpeed);
if (animalProps)
{
AnimalStateMachine.pushState(new StatePatrol(
animalProps,
SceneHelper.houseProps,
config.predator.patrolConfig
));
}
}
Test: This one enabled a lot:
- Clicking on the dog house will spawn a cat (which you can change to a
Dog
in the config). - The cat will wander around patroling the dog house.
- If a mouse comes near a cat, it may be spotted.
Polish
Now we'll add a couple of visual effects to improve our scene.
Capture Bait
Let's animate the cheese a bit when an animal eats some. For this, we'll simply hide crumbs, which then reappear a second later.
Add a new event to sceneDidMount
in scene.tsx
:
this.eventSubscriber.on('captureBait', e => this.onCaptureBait());
Then add a method to handle the event:
async onCaptureBait()
{
await sleep(750);
this.state.baitProps.isVisible = false;
this.setState({ baitProps: this.state.baitProps });
await sleep(2000);
this.state.baitProps.isVisible = true;
this.setState({ baitProps: this.state.baitProps });
}
This event is already included in StateEat
.
Test: When a mouse eats the cheese, a few crumbs should disappear and then spawn in again a couple seconds later.
Spinning Fence Door
When any animal walks under one of the broken fence segments, let's play an animation to make it spin.
First, let's emit an event from the Grid class allowing our program to react to grid changes:
export function set(position: Vector3Component, canBeOccupiedAlready: boolean = false)
{
...
EventManager.emit("gridCellSet", position);
}
Then register to the event in sceneDidMount
of scene.tsx
:
this.eventSubscriber.on('gridCellSet', cell => this.onGridCellSet(cell));
And add a method to handle the event:
async onGridCellSet(position: Vector3Component)
{
let index = -1;
for (let i = 0; i < SceneHelper.fenceSpinnerProps.length; i++)
{
if (approxEquals(position, SceneHelper.fenceSpinnerProps[i].position))
{
index = i;
break;
}
}
if (index >= 0)
{
let fenceSpinState = this.state.fenceSpinState.slice();
if (fenceSpinState[index] != SpinState.None)
{ // One at a time to keep the animation timing
return;
}
// Note this is not always correct..
fenceSpinState[index] = index == 0 ? SpinState.Enter : SpinState.Exit;
this.setState({ fenceSpinState });
await sleep(75 * 1000 / 25);
fenceSpinState = this.state.fenceSpinState.slice();
fenceSpinState[index] = SpinState.None;
this.setState({ fenceSpinState });
}
}
Test: Spawn in a few mice and watch them walk through the fence. The same effect should work if a cat or dog travels that way, but this is harder to test as our current patrol settings keep them away from the fence.
That's it, and we covered a lot! Thanks for taking the time.
Some possible next steps:
- Modify various values in the config to tweak the experience.
- Maybe add cat food and update the state machine so that the cat occasionally takes a break from patrol in order to go eat it.
- Add more animal types and/or new states. Try reusing states across different animals, but in a way that gives each animal a unique set of behaviors.
@Nick, are you ok? Aren't there any newsworthy events in crypto world? Looking forward for your next report :)
Hi,
I'm taking a short break from the news clips in order to focus on a few bounties... got bills to pay..
I'll still post if something real interesting happens.
-nick.
Congratulations @hardlydifficult! You received a personal award!
Click here to view your Board of Honor
Congratulations @hardlydifficult! You received a personal award!
You can view your badges on your Steem Board and compare to others on the Steem Ranking
Vote for @Steemitboard as a witness to get one more award and increased upvotes!