I am a college student, working diligently on this project in his free time. If DustyEngine has helped you in any way, please donate just $1!
Download a copy of the code and binary of the program we will be writing Here.
The program we will create in this tutorial is a simple cat-and-mouse game. The player is represented by a yellow smiley face that is being chased by red frowning faces. The point of the game is avoid the red faces while running around the screen collecting little slices of pizza. The game is a simulation of these rules:
We will be using two types of entity movement in this tutorial, INTERPOLATED and BOUNDED. The enemies will use INTERPOLATED movement, and the player will use BOUNDED movement. We use bounded movement for the player so he cannot leave the screen. The interpolation of the enemy's movement will allow us to easily make the enemy follow the player by setting the enemy's end point of movement to the player's position.
Let's get on to some code. This tutorial will assume a basic understanding of both Dusty Engine and Irrlicht. If you need some brushing up on Irrlicht, check out the tutorials at http://irrlicht.sf.net. Similarly for Dusty Engine, there are tutorials which explain the Task Tree and other necessary Dusty Engine constructs.
Start a new Console Project and add a single file, main.cpp. Set up the linking to irrlicht and dustyengine. We'll just start with the headers and namespaces.
#include <irrlicht.h> #include <dustyengine.h> using namespace irr; using namespace core; using namespace scene; using namespace io; using namespace video; using namespace DustyEngine; |
Here we just include the DustyEngine and Irrlicht headers and set the file to use the necessary namespaces. No big deal.
IrrlichtDevice * irrDevice = NULL; ISceneManager * smgr = NULL; IVideoDriver * videoDriver = NULL; DustyDriver * dustyDriver = NULL; Entity * player = NULL; IBillboardSceneNode * foodNode = NULL; bool gameOver = false; |
I don't like using global variables any more than the next guy, but for the sake of simplicity in this tutorial I decided to use them. Here we just create pointers to the irrlicht device, scene manager, video driver, and the dusty driver.
The stuff that follows is important. There will be only one player in this little game we're creating, so there is a global entity for just single player. It's defined and set to NULL, we will create it later in a function CreatePlayer(). The IBillboardSceneNode * foodNode that follows is the irrlicht scene node that will show the food the player is trying to eat, in this game that will be a slice of pizza. There will be only 1 food node out at a time, so we only need a single node to show it. Instead of destroying a food and recreating a new one somewhere else when the player eats it, we'll just move that node to a new position when necessary.
GameOver is a bool value that tells if the player has been touched by an enemy or not. When the player is touched by an enemy, the game is over and should exit.
This next part may seem a little odd, but is necessary. We will be creating a class now that uses this function, and the class needs to come before our functions in the code. You could also just put the code of the function here instead of just the declaration, but I chose not to in order to keep all my functions together. This would not be necessary if this program were separated out into different .h and .cpp files, but since we're working with a single .cpp (main.cpp), this is needed.
| bool EnemyTouchesPlayer(Entity * enemy); |
I will explain this function when we get down to creating it.
Here we will start to define an "AI" class that I think is the most eloquent way to define an artificial intelligence to control our entities. I used AI classes like this one in Dusty Demo 4 to control just about everything. Basically, the AI for each enemy is nothing for an a task which is executed on a set interval by Dusty Engine. For a simple AI like ours, the AI will just move the enemy constantly towards the player. But they can do anything here. For Demo 4 I created different states for the AI to be in, which would cause it to take different actions.
Lets just begin to define the class here and then get to the main portions of its code.
class EnemyAI : public DummyTask
{
public:
|
The AI is going to have to know what Entity it is set to control. So we will give it in the constructor a pointer to its entity. Here's the short code for the constructor:
EnemyAI(Entity * enemy)
{
e = enemy;
}
|
This simply tells the AI which entity it is supposed to control. We will set all this up when we create the entity.
The OnUpdate function for the enemy AI task is fairly short and concise so I will post its code next, and then explain it.
void OnUpdate()
{
f32 distance = player->GetPosition().getDistanceFrom(e->GetPosition());
e->SetMoveType(INTERPOLATED);
e->SetMoveStartPoint(e->GetPosition());
e->SetMoveEndPoint(player->GetPosition());
e->SetMoveInterpolationTime(distance / 3 * 1000);
e->SetMoveInterpolationValue(0.0f);
if(EnemyTouchesPlayer(e)) {
gameOver = true;
}
}
|
Here we have the OnUpdate() function for the enemy AI, which Dusty Engine will call on a regular interval. The function begins by getting the distance from the entity it controls to the player's entity. This is necessary because we will be using INTERPOLATED movement for the enemy entities, but we want them to move at a constant speed. Therefore the time it takes for the enemy to move to the player will be a function of the distance between the two.
The first thing the code does to set up enemy movement is tell the enemy to move INTERPOLATED. The default movement type is VECTOR movement, so we have to specify exactly the type of movement we want to use.
The code then sets the start point of movement to the enemy's current position. We have to do this because we are constantly updating the position of the enemy. Therefore, since we're using interpolated movement, we need to update the starting position of the line that INTERPOLATION goes through to the current position of the entity. The end point is of course the player's position, because the enemy will be moving towards the player. This has to be set on every update also because the player will be moving constantly, and we must update the point the enemy is moving to in order to reflect the player's current position.
Next is the call to SetMoveInterpolationTime(). This function will set the amount of time, in milliseconds, that it should take for the movement interpolation to go from the start-point given to the end-point given.
As I mentioned above, the time it will take to reach the player is a function of the distance between the entity and the player. In this case, I divide the distance by 3, because I want the entity to move at 3 units per second. Then I multiply that value by 1000 to convert however many units the distance per second is into actual time representation.
If the distance is 3 units, it should take 1 second to get there. So: 3 units / 3 units per second * 1000ms = 1 * 1000ms = 1 second. If the distance is 100 units, it will take 100 units / 3 units per second * 1000ms = 33.33 seconds.
So as you can see the time will maintain a constant 3 units per second movement in the interpolation.
When using an Interpolation task as the entity does, it uses what is known as an "interpolation value." This value is always in the range between 0 and 1, including 0 and 1. This value represents the percent of interpolation the task has completed. If the value is 0.5, then it has halfway interpolated, and the current position is exactly halfway between the start point and end point. If it is 0, the position is at the start point. If it is 1, then interpolation has finished and the position of the entity is the end point.
Here we reset the current MoveInterpolationValue to 0 in each update. This will reset the interpolation to the beginning. This is necessary because we want to start at the start point given, and if this were not done the interpolation value would be thrown off therefore throwing off the location of the entity.
Now that all the interpolation code is completed, we check to see if our Entity touches the player, and if it does, the game is over because an enemy has touched the player.
Now we just finish up the AI Task code.
protected: Entity * e; }; |
There are 6 functions we must write next, an Event Receiver, and then the main() function, and we're done with the tutorial.
The 6 functions are as follows:
That's it! Let's get started with CreatePlayer.
void CreatePlayer()
{
player = dustyDriver->CreateEntity();
IMesh * mesh = smgr->getMesh("media/face.obj")->getMesh(0);
ISceneNode * playerNode = smgr->addMeshSceneNode(mesh);
playerNode->setMaterialFlag(EMF_LIGHTING, false);
playerNode->setMaterialTexture(0, videoDriver->getTexture("media/smileyface.png"));
player->SetNode(playerNode);
player->SetMoveType(BOUNDED);
player->SetMoveBoundaries(vector3df(-23, -17, 0), vector3df(23, 17, 0));
}
|
Here is the function to create the player entity. You should always use DustyDriver::CreateEntity() to create an entity. DustyEngine maintains a list of of the entities it contains, so they will be automatically destroyed whenever the DustyDriver is destroyed, and can also be manually destroyed using DestroyEntity(). Using the CreateEntity function also keeps you from having to manage the memory it allocates by yourself.
There is then the typical Irrlicht fare of creating a mesh scene node using the irrlicht scene manager to add a static mesh scene node for the player's face. The face is nothing more than a texture mapped sphere, using the texture map "smileyface.png." You should be pretty well versed in this Irrlicht code, so I won't explain it any further. The code could probably be simpler if I used an AnimatedMeshSceneNode, but the smiley isn't really animated so I used a static node.
Let's skip down the to the player->SetNode line. In order for an entity to know which node it is to be controlling, you must tell the entity to use a certain node. That is what the SetNode does. If this function is not called, the entity we created would never know which node it should be controlling.
We then move on to the 2 function calls that set up BOUNDED movement for the player. The SetMoveType will tell the movement which type of action it should take upon changing the position of the node. In this case we want to use BOUNDED. The SetMoveBoundaries function then sets the minimum value and the maximum value possible for the position of the node. The node will now stay within these boundaries no matter what.
The CreateEnemy function shares much of the same code with the CreatePlayer function. The first part of it that is similar is shown below.
void CreateEnemy()
{
Entity * enemy = dustyDriver->CreateEntity();
IMesh * mesh = smgr->getMesh("media/face.obj")->getMesh(0);
ISceneNode * enemyNode = smgr->addMeshSceneNode(mesh);
enemyNode->setMaterialFlag(EMF_LIGHTING, false);
enemyNode->setMaterialTexture(0, videoDriver->getTexture("media/madface.png"));
enemy->SetNode(enemyNode);
|
Here's about half of the CreateEnemy function, which as you can see is nearly identical to the first part of the CreatePlayer function. The only difference is the mesh used and the texture. Aside from creating the Entity as described above, this is standard Irrlicht code.
EnemyAI * ai = new EnemyAI(enemy); dustyDriver->GetTaskTree()->AddTask(ai, enemy->GetParentTask(), 100); |
Continuing onward with our CreateEnemy function, here are the two magic little lines that give the enemy life. The AI Task we create is an instanciation of the class written above, given a pointer to the enemy entity that it will control. We then use Dusty Engine's task tree to add the AI task as a child of the entity's parent task, to be updated every 100 milliseconds.
That deserves a bit of explanation. An Entity in Dusty Engine is a collection of Dusty Engine tasks. Every DE Entity uses 13 tasks by default. They are the "Parent" task, and 12 other tasks that are all instanciated under the parent. The children tasks are things like a Vector Movement Task, an Interpolated Scale Task, etc. There are 4 tasks (Vector, Interpolated, Waypoint, Bounded) for each transformation available (Move, Rotate, Scale.) However, only three of these tasks are ever active at one time, with the entity managing them. If you set Movement to use VECTOR transformation, then the MoveTask under the parent is made active, and all other movement tasks are deactivated. Likewise, if you change Scaling to INTERPOLATED transformation, the ScaleInterpolatedTask is activated and all other scaling tasks are deactivated.
One benefit of using an Entity is that you can extend it, as we do here. You can add your own tasks under the Parent Task of an entity, and what happens when the entity is destroyed? Your tasks are also destroyed. Instant memory management. In this case, we add the AI task as a child on the tree to the Entity's ParentTask. When that parent task is destroyed, so will our AI Task, just as we want it to be.
int corner = dustyDriver->GetRandGen()->RandInt(1, 5);
if(corner == 1) {
enemy->SetPosition(vector3df(-20, 15, 0));
}
else if(corner == 2) {
enemy->SetPosition(vector3df(20, 15, 0));
}
else if(corner == 3) {
enemy->SetPosition(vector3df(-20, -15, 0));
}
else if(corner == 4) {
enemy->SetPosition(vector3df(20, -15, 0));
}
}
|
All that's left is to choose a random corner for the new enemy and change his position accordingly. We use DustyEngine's random number generator (which at this time is just a wrapper for rand()) for this. The values given to the positions will be explained when we set up the camera. Don't forget the little brace that closes out the function, it's easy to miss!
void PositionFood()
{
if(foodNode == NULL) {
foodNode = smgr->addBillboardSceneNode();
foodNode->setMaterialFlag(EMF_LIGHTING, false);
foodNode->setMaterialTexture(0, videoDriver->getTexture("media/pizza.png"));
foodNode->setSize(dimension2d<f32>(3, 3));
}
RandGen * rg = dustyDriver->GetRandGen();
foodNode->setPosition(rg->RandVector3df(-20, 20, -15, 15, 0, 0));
foodNode->updateAbsolutePosition();
}
|
Here is the function which will move the food node around to a random spot. Notice at the beginning of the function we create the foodNode if it doesn't already exist. That is pretty standard Irrlicht stuff. The foodNode is just a billboard scene node at a random position on the screen.
The code after the conditional that uses the RandGen is the portion that chooses a random position for the node using the RandGen's function to create a random vector, RandVector3df. The values given the function are just the boundaries of the screen we will create when we set up the camera. In this case, the position will be somewhere between (-20, -15, 0) and (20, 15, 0). These values will be explained when we set tup the camera below.
The next two functions are so short and similar that I will post both of them together.
bool EnemyTouchesPlayer(Entity * enemy)
{
vector3df epos = enemy->GetPosition();
vector3df ppos = player->GetPosition();
return (ppos.getDistanceFrom(epos) < 2);
}
bool CanEatFood()
{
vector3df fpos = foodNode->getPosition();
vector3df ppos = player->GetPosition();
return (ppos.getDistanceFrom(fpos) < 2);
}
|
These two functions are the logic that will check to see if an enemy is close enough to touch a player (EnemyTouchesPlayer) and if the player is close enough to the food node to eat it (CanEatFood).
In EnemyTouchesPlayer, it checks the distance between the enemy entity and the player entity, but calling the GetPosition() and then then getDistanceFrom between the two positions. If that distance is less than 2, the player has been touched by the enemy, and it returns true. If not, it returns false.
CanEatFood is just the same, except it checks the distance between the player entity and the ISceneNode that represents the food to be eaten.
Our last function before the event receiver and the main() is the function which creates an orthographic projection for the camera.
void SetupCamera()
{
matrix4 orthoMatrix;
ICameraSceneNode * camera = smgr->addCameraSceneNode();
orthoMatrix.buildProjectionMatrixOrthoLH(50, 37.5, -10, 1024);
camera->setProjectionMatrix(orthoMatrix);
}
|
Standard Irrlicht code here. The only thing to think about are the values given to buildProjectionmatrixOrthoLH. The first two parameters are the width and height of the worldview that will be visible on the screen. In this case, the world is 50 units wide and 37.5 units tall. Remember that this sets the origin to the center of the screen, so we have a range from -25 to 25 width, and -18.75 to 18.75 height. This is why when I set the boundaries for the player and the food's random positions I gave them the values that I did.
Irrlicht requires a class that inherits from IEventReceiver if you want to handle input from the player. Here is the code for this program's event receiver.
class InputReceiver : public IEventReceiver
{
public:
bool OnEvent(SEvent event)
{
if(event.EventType == EET_KEY_INPUT_EVENT && event.KeyInput.PressedDown) {
switch(event.KeyInput.Key) {
case KEY_RIGHT:
player->SetMoveVector(vector3df(0.15f, 0, 0));
break;
case KEY_LEFT:
player->SetMoveVector(vector3df(-0.15f, 0, 0));
break;
case KEY_UP:
player->SetMoveVector(vector3df(0, 0.15f, 0));
break;
case KEY_DOWN:
player->SetMoveVector(vector3df(0, -0.15f, 0));
break;
}
}
return true;
}
};
|
The EventReceiver has only one function, OnEvent. The SEvent structure in Irrlicht contains all the information we would be interested in when handling an event. In this case, we only care about keyboard events. There's an if that checks if the event is a key event and if the key is pressed down. If it is, it checks if they key is an arrow key and sets the player to move in the direction pressed.
Notice the SetMoveVector() function. This sets a vector to be added to the entity's position every time Dusty Engine updates the tasks in the Entity. It works with BOUNDED movement and VECTOR movement, because they are pretty much the same. The only difference between them is that BOUNDED movement will check the position against a set of boundaries, and VECTOR will not.
By giving the vector (0.15f, 0, 0) to the MoveVector of the entity, it will cause him to move 0.15 units to the right every 20 milliseconds. 20 Milliseconds is the default update interval for an entity.
So now let's move onto the main function.
int main()
{
InputReceiver input;
irrDevice = createDevice(EDT_DIRECT3D9);
smgr = irrDevice->getSceneManager();
videoDriver = irrDevice->getVideoDriver();
dustyDriver = new DustyDriver(irrDevice);
|
Here we basically create all the objects this program will be needing. We create an input receiver, and the irrlicht device using Direct3d9. The EDT_DIRECT3D9 can easily be replaced with EDT_OPENGL or just removed to use Irrlicht's software renderer. We then grab pointer's to Irrlicht's scene manager and the video driver, and create the main Dusty Driver by instanciating a new one.
dustyDriver->GetRandGen()->Seed(irrDevice->getTimer()->getRealTime()); SetupCamera(); CreatePlayer(); PositionFood(); CreateEnemy(); |
The first thing done before we set up the game is to seed Dusty Engine's random number generator. This is done using Irrlicht 0.12's timer function getRealTime. The RandGen has a function SeedTime(), but as of Dusty Engine 8 it does not function correctly with Irrlicht 0.12 and later because some changes were made to Irrlicht's timer class. This can be very easily changed, but I have held off making this change due to the fact that use of Irrlicht before verion 0.12 is still widespread at this time. Later versions of Dusty Engine will fix this problem.
We setup the camera, which as defined above will set up our orthographic projection how we like it. Then we create the player entity, setup the first food item, and create a single enemy in a random corner.
irrDevice->setEventReceiver(&input);
while(irrDevice->run() && gameOver == false) {
videoDriver->beginScene(true, true, SColor(0,0,0,0));
smgr->drawAll();
videoDriver->endScene();
if(CanEatFood()) {
PositionFood();
CreateEnemy();
}
dustyDriver->GetTaskTree()->DoUpdates();
}
irrDevice->setEventReceiver(NULL);
|
Here is the Irrlicht main loop. Before we enter the main loop, we tell Irrlicht to use our even receiver, and we must remember to remove the event receiver after we're done with it. You can also tell the IrrlichtDevice what event receiver to use when you create it, but I prefer this method.
In the main loop, we loop until the irrlicht device is closed (run() returns false) and while the gameOver variable is set to false. The GameOver variable is set in the EnemyAI if an enemy gets too close to the player.
The first 3 lines of the main loop are the standard irrlicht code to draw the screen. Immediately after that is a check to see if the player entity is close enough to a food item to eat it. If he is, then it just chooses a new random position for the food and creates a new enemy. This creates the illusion that the player has "eaten" the food.
One line that must be included to make Entities and Dusty Engine work is the DoUpdates() call to the DE Task Tree. That is the last line here of our main loop. To understand this function please read the previous Dusty Engine tutorials.
dustyDriver->drop(); irrDevice->drop(); return 0; } |
Once the main loop has exited we just destroy all of our objects and we're done! There you have it, a tutorial on Dusty Engine Entities. I hope this has increased your knowledge of DE and how to use it, it was a joy to write.
-Dave Andrews