Using Custom Component System
Custom Component System enables you to implement your application’s logic via a set of building blocks - components, and assign these blocks to nodes. A logic component integrates a node, a property and a C++ class containing logic implementation.
This example demonstrates how to:
- Decompose application logic into building blocks
- Create your own logic components
- Implement interaction between your components
We are going to make a simple game to demonstrate how the whole system works.
Game Description
In the center of the play area we are going to have an object (Pawn) controlled by the player via the keyboard. It has certain amount of HP and movement parameters (movement and rotation speed).
Four corners of the play area are occupied by rotating objects (Spinners) that throw other small objects (Projectiles) in all directions while rotating.
Each Projectile moves along a straight line in the directon it was initially thrown by the Spinner. In case if a Projectile hits a Pawn, the Pawn will take damage according to the value set for the hitting Projectile (each of them has a random speed and damage value). The pawn is destroyed if the amount of HP drops below zero.
We shall use boxes for simplicity, but you can easily replace them with any other objects.
The basic workflow for implementing application logic using the Component System is given below.
1. Prepare a Project
Before we can start creating components and implementing our game logic we should do some preparations.
- Create a new C++ project.
- Copy the ComponentSystem folder (with header and implementation files) to your project's folder from the following directory:
source/samples/Api/Logics/ComponentSystem/ - Include the header file of the Component System by adding the following line to all related files:
#include "ComponentSystem/ComponentSystem.h"
2. Decompose Application Logic into Building Blocks
First of all we should decompose our application logic in terms of bulding blocks - components. So, we should define parameters for each component (all these parameters will be stored in a corresponding .prop file) and decide in which functions of the execution sequence the component's logic will be implemented.
For our little game we are going to use one component for each type of object. So, we need 3 components:
- Pawn with the following parameters:
- name - name of the Pawn.
- moving speed - how fast the Pawn moves.
- rotation speed - how fast the Pawn rotates.
- health - HP count for the Pawn.
We are going to initialize a Pawn, do something with it each frame, and report a message, when it dies. So, this logic will be implemented inside the init(), update(), and shutdown() methods.
- Spinner with the following parameters:
- rotation speed - how fast the Spinner rotates.
- acceleration - how fast Spinner's rotation rate increases.
- node to be used as a projectile
- minimum spawn delay
- maximum spawn delay
We are going to initialize a Spinner and do something with it each frame. So, this logic will go to the init() and update().
- Projectile with the following parameters:
- speed - how fast the Projectile moves.
- life time - how long the Projectile lives.
- damage - how much damage the Projectile causes to the Pawn it hits.
As for the projectile, it will be spawned and initialized by the Spinner. The only thing we are going to do with it, is checking for a hit and control the life time left every frame. All of this goes to update().
3. Create a C++ Class for Each Component
For each of our components we should derive a new C++ class from the ComponentBase class. So, in the header file we:
- define the name of the property for our component (corresponding .prop file will be automatically saved in your project's data folder)
To define property name for the component we use the following:
static const char* getPropertyName() { return "my_property_name"; }
- declare all parameters defined above with their default values (if any).
To declare a parameter we use the following macro:
PROPERTY_PARAMETER(type, name[, default_value]);
- declare in the protected section which methods of the execution sequence we are going to override to implement our logic:
protected: void init() override; void update() override; void shutdown() override;
- declare all necessary auxiliary parameters and functions.
Thus, for our Pawn, Spinner and Projectile classes we will have the following declarations:
Pawn.h
#pragma once
#include <UnigineGame.h>
#include <UnigineControls.h>
// include the header file of the Component System
#include "ComponentSystem/ComponentSystem.h"
// derive our class from the ComponentBase
class Pawn : public ComponentBase
{
public:
// declare constructor and destructor for our class
Pawn(const NodePtr &node, int num) : ComponentBase(node, num) {}
virtual ~Pawn() {}
// property name. A pawn.prop file containing all parameters listed below will be saved in your project's data folder
static const char* getPropertyName() { return "pawn"; }
// parameters
PROPERTY_PARAMETER(String, name, "Pawn1"); // Pawn's name
PROPERTY_PARAMETER(Int, health, 200); // health points
PROPERTY_PARAMETER(Float, move_speed, 4.0f); // move speed (meters/s)
PROPERTY_PARAMETER(Float, turn_speed, 90.0f); // turn speed (deg/s)
// methods
void hit(int damage); // decrease Pawn's HP
protected:
// world main loop overrides
void init() override;
void update() override;
void shutdown() override;
private:
// auxiliary parameters and functions
ControlsPtr controls;
PlayerPtr player;
float damage_effect_timer = 0;
Mat4 default_model_view;
void show_damage_effect();
};
Spinner.h
#pragma once
#include <UnigineMaterial.h>
#include "ComponentSystem/ComponentSystem.h"
class Spinner : public ComponentBase
{
public:
Spinner(const NodePtr &node, int num) : ComponentBase(node, num) { }
virtual ~Spinner() {}
// property name
static const char* getPropertyName() { return "spinner"; }
// parameters
PROPERTY_PARAMETER(Float, turn_speed, 30.0f);
PROPERTY_PARAMETER(Float, acceleration, 5.0f);
PROPERTY_PARAMETER(Node, spawn_node);
PROPERTY_PARAMETER(Float, min_spawn_delay, 1.0f);
PROPERTY_PARAMETER(Float, max_spawn_delay, 4.0f);
protected:
// world main loop
void init() override;
void update() override;
private:
float start_turn_speed = 0;
float color_offset = 0;
float time_to_spawn = 0;
MaterialPtr material;
// converter from HSV to RGB color model
vec3 hsv2rgb(float h, float s, float v);
};
Projectile.h
#pragma once
#include <UnigineMaterial.h>
#include "ComponentSystem/ComponentSystem.h"
class Projectile :
public ComponentBase
{
public:
Projectile(const NodePtr &node, int num) : ComponentBase(node, num) {}
virtual ~Projectile() {}
// property name
static const char* getPropertyName() { return "projectile"; }
// parameters
PROPERTY_PARAMETER(Float, speed, 5.0f);
PROPERTY_PARAMETER(Float, lifetime, 5.0f); // life time of the projectile (declaration with a default value)
PROPERTY_PARAMETER(Int, damage); // damage caused by the projectile (declaration with no default value)
// methods
void setMaterial(const MaterialPtr &mat);
protected:
// world main loop
void update() override;
};
4. Implement Each Component's Logic
After making necessary declarations, we should implement logic for all our components. Let's do it in the corresponding .cpp files.
Pawn's Logic
Pawn's logic is divided into the following elements:
- Initialization - here we set necessary parameters, and the Pawn reports its name:
Log::message("PAWN: INIT \"%s\"\n", name.get());
You can access parameters of your component via: <parameter_name>.get() - Main loop - here we implement player's keyboard control. To access the node from the component we can simply use node, e.g. to get current node's direction we can write:
Vec3 direction = node->getWorldTransform().getColumn3(1);
- Shutdown - here we implement actions to be performed when a Pawn dies. We'll just print a message to the console.
- Auxiliary - method to be called when the pawn is hit and some visual effects.
Implementation of Pawn's logic is given below:
Pawn.cpp
#include "Pawn.h"
#include <UnigineConsole.h>
#include <UnigineRender.h>
#define DAMAGE_EFFECT_TIME 0.5f
void Pawn::init()
{
player = Game::get()->getPlayer();
controls = player->getControls();
default_model_view = player->getCamera()->getModelview();
damage_effect_timer = 0;
show_damage_effect();
Log::message("PAWN: INIT \"%s\"\n", name.get());
}
void Pawn::update()
{
// get delta time between frames
float ifps = Game::get()->getIFps();
// show damage effect
if (damage_effect_timer > 0)
{
damage_effect_timer = Math::clamp(damage_effect_timer - ifps, 0.0f, DAMAGE_EFFECT_TIME);
show_damage_effect();
}
// if console is opened we disable any controls
if (Console::get()->getActivity())
return;
// get the direction vector of the mesh from the second column (y axis) of the transformation matrix
Vec3 direction = node->getWorldTransform().getColumn3(1);
// checking controls states and changing pawn position and rotation
if (controls->getState(Controls::STATE_FORWARD) || controls->getState(Controls::STATE_TURN_UP))
{
node->setWorldPosition(node->getWorldPosition() + direction * move_speed * ifps);
}
if (controls->getState(Controls::STATE_BACKWARD) || controls->getState(Controls::STATE_TURN_DOWN))
{
node->setWorldPosition(node->getWorldPosition() - direction * move_speed * ifps);
}
if (controls->getState(Controls::STATE_MOVE_LEFT) || controls->getState(Controls::STATE_TURN_LEFT))
{
node->setWorldRotation(node->getWorldRotation() * quat(0.0f, 0.0f, turn_speed * ifps));
}
if (controls->getState(Controls::STATE_MOVE_RIGHT) || controls->getState(Controls::STATE_TURN_RIGHT))
{
node->setWorldRotation(node->getWorldRotation() * quat(0.0f, 0.0f, -turn_speed * ifps));
}
}
void Pawn::shutdown()
{
Log::message("PAWN: DEAD!\n");
}
// method to be called when the Pawn is hit by a Projectile
void Pawn::hit(int damage)
{
// take damage
health = health - damage;
// show effect
damage_effect_timer = DAMAGE_EFFECT_TIME;
show_damage_effect();
// destroy
if (health <= 0)
destroyNode(node);
Log::message("PAWN: damage taken (%d) - HP left (%d)\n", damage, health.get());
}
// auxiliary method implementing visual damage effect
void Pawn::show_damage_effect()
{
float strength = damage_effect_timer / DAMAGE_EFFECT_TIME;
Render::get()->setFadeColor(vec4(0.5f, 0, 0, saturate(strength - 0.5f)));
player->getCamera()->setModelview(default_model_view * Mat4(
rotateX(Game::get()->getRandomFloat(-5, 5) * strength) *
rotateY(Game::get()->getRandomFloat(-5, 5) * strength)));
}
Projectile's Logic
Projectile's logic is simpler - we just have to perform a check each frame whether we hit the Pawn or not. This means that we have to access a Pawn component from the Projectile component.
// get the component assigned to a node by type "MyComponent"
MyComponent *my_component = getComponent<MyComponent>(some_node);
// access some method of MyComponent
my_component->someMyComponentMethod();
The Projectile has a limited life time, so we should destroy the node when its life time is expired.
Implementation of Projectile's logic is given below:
Projectile.cpp
#include "Projectile.h"
#include "Pawn.h"
#include "Spinner.h"
#include <UnigineGame.h>
#include <UnigineWorld.h>
void Projectile::update()
{
// get delta time between frames
float ifps = Game::get()->getIFps();
// get the direction vector of the mesh from the second column (y axis) of the transformation matrix
Vec3 direction = node->getWorldTransform().getColumn3(1);
// move forward
node->setWorldPosition(node->getWorldPosition() + direction * speed * ifps);
// lifetime
lifetime = lifetime - ifps;
if (lifetime < 0)
{
// destroy current node with its properties and components
destroyNode(node);
return;
}
// check the intersection with nodes
VectorStack<NodePtr> nodes; // VectorStack is much faster than Vector, but has some limits
World::get()->getIntersection(node->getWorldBoundBox(), nodes);
if (nodes.size() > 1) // (because the current node is also in this list)
{
for (int i = 0; i < nodes.size(); i++)
{
Pawn *pawn = getComponent<Pawn>(nodes[i]);
if (pawn)
{
// hit the player!
pawn->hit(damage);
// ...and destroy current node
destroyNode(node);
return;
}
}
}
}
void Projectile::setMaterial(const MaterialPtr &mat)
{
Object::cast(node)->setMaterial(mat, "*");
}
Spinner's Logic
Spinner's logic is divided into the following elements:
- Initialization - here we set necessary parameters to be used in the main loop
- Main loop - here we rotate our Spinner and spawn nodes with Projectile components. We also set some parameters of the Projectile.
There are 3 ways to change variables of another component:
- directly via component (fast, easy)
component->int_parameter = component->int_parameter + 1;
- via node's property (slower, more awkward)
for (int i = 0; i < node->getNumProperties(); i++) { PropertyPtr prop = node->getProperty(i); if (prop && (!strcmp(prop->getName(), "my_prop_name") || prop->isParent("my_prop_name"))) prop->setParameterInt(prop->findParameter("int_parameter"), 5); }
- via component's property
PropertyPtr prop = component->getProperty(); prop->setParameterInt(prop->findParameter("int_parameter"), 5));
- directly via component (fast, easy)
- Auxiliary - color conversion function.
Implementation of Spinner's logic is given below:
Spinner.cpp
#include "Spinner.h"
#include "Projectile.h"
#include <UnigineGame.h>
void Spinner::init()
{
// get current material (from the first surface)
ObjectPtr obj = Object::cast(node);
if (obj && obj->getNumSurfaces())
material = obj->getMaterialInherit(0);
// init randoms
time_to_spawn = Game::get()->getRandomFloat(min_spawn_delay, max_spawn_delay);
color_offset = Game::get()->getRandomFloat(0, 360.0f);
start_turn_speed = turn_speed;
}
void Spinner::update()
{
// rotate spinner
float ifps = Game::get()->getIFps();
turn_speed = turn_speed + acceleration * ifps;
node->setRotation(node->getRotation() * quat(0, 0, turn_speed * ifps));
// change color
int id = material->fetchParameter("albedo_color", 0);
if (id != -1)
{
float hue = Math::mod(Game::get()->getTime() * 60.0f + color_offset, 360.0f);
material->setParameter(id, vec4(hsv2rgb(hue, 1, 1), 1.0f));
}
// spawn projectiles
time_to_spawn -= ifps;
if (time_to_spawn < 0 && spawn_node)
{
// reset timer and increase difficulty
time_to_spawn = Game::get()->getRandomFloat(min_spawn_delay, max_spawn_delay) / (turn_speed / start_turn_speed);
// create node
NodePtr spawned = spawn_node->clone();
spawned->setEnabled(1);
spawned->setWorldTransform(node->getWorldTransform());
spawned->release(); // don't destroy it on exit current scope
// create component
Projectile *proj_component = addComponent<Projectile>(spawned);
// there are three ways to change variables inside another component:
// 1) direct change via component (fast, easy)
proj_component->speed = Game::get()->getRandomFloat(proj_component->speed * 0.5f, proj_component->speed * 1.5f);
// 2) change via property of the node (more slow, more awkward)
for (int i = 0; i < spawned->getNumProperties(); i++)
{
PropertyPtr prop = spawned->getProperty(i);
if (prop && (!strcmp(prop->getName(), "projectile") || prop->isParent("projectile")))
prop->setParameterInt(prop->findParameter("damage"), Game::get()->getRandomInt(75, 100));
}
// 3) change via property of the component
PropertyPtr proj_property = proj_component->getProperty();
proj_property->setParameterFloat(proj_property->findParameter("lifetime"), Game::get()->getRandomFloat(5.0f, 10.0f));
// call public method of another component
proj_component->setMaterial(material);
}
}
// color conversion H - [0, 360), S,V - [0, 1]
vec3 Spinner::hsv2rgb(float h, float s, float v)
{
float p, q, t, fract;
h /= 60.0f;
fract = h - Math::floor(h);
p = v * (1.0f - s);
q = v * (1.0f - s * fract);
t = v * (1.0f - s * (1.0f - fract));
if (0.0f <= h && h < 1.0f) return vec3(v, t, p);
else if (1.0f <= h && h < 2.0f) return vec3(q, v, p);
else if (2.0f <= h && h < 3.0f) return vec3(p, v, t);
else if (3.0f <= h && h < 4.0f) return vec3(p, q, v);
else if (4.0f <= h && h < 5.0f) return vec3(t, p, v);
else if (5.0f <= h && h < 6.0f) return vec3(v, p, q);
else return vec3(0, 0, 0);
}
5. Register Components in the Component System
Now we have all our game logic implemented in the corresponding components: Pawn, Spinner, and Projectile. There is one more thing to be done before we can start using them. We should register our components in the System.
To register a component we should include its header file along with ComponentSystem.h and add a single line to the AppSystemLogic::init() method:
#include "ComponentSystem/ComponentSystem.h"
#include "MyComponent.h"
/* ... */
int AppSystemLogic::init()
{
/* ... */
// register user component
ComponentSystem::get()->registerComponent<MyComponent>();
/* ... */
}
In our case that would be:
// register user components
ComponentSystem::get()->registerComponent<Spinner>();
ComponentSystem::get()->registerComponent<Projectile>();
ComponentSystem::get()->registerComponent<Pawn>();
6. Add Components to Nodes
As we implemented our game logic in the components and registered them in the System, we can actually start using them. There are two ways to add a logic component to a node:
- by simply assigning the corresponding property to it via the UnigineEditor or code:
object1->addProperty("MyComponentProperty"); object2->setProperty(0, "MyComponentProperty");
- by calling the corresponding method of the Component System::
ComponentSystem::get()->addComponent<MyComponent>(object->getNode());
So, here is the resulting code for our game including registration of components as well as adding them to nodes:
game.cpp
#include <UnigineApp.h>
#include <UnigineConsole.h>
#include <UnigineEngine.h>
#include <UnigineGame.h>
#include <UnigineLights.h>
#include <UnigineLogic.h>
#include <UnigineWorld.h>
#include <UnigineGui.h>
#include <UnigineWidgets.h>
#include "ComponentSystem/ComponentSystem.h"
#include "Spinner.h"
#include "Projectile.h"
#include "Pawn.h"
using namespace Unigine;
using namespace Math;
//////////////////////////////////////////////////////////////////////////
// System logic class
//////////////////////////////////////////////////////////////////////////
class AppSystemLogic : public SystemLogic
{
public:
AppSystemLogic() {}
virtual ~AppSystemLogic() {}
virtual int init()
{
// register user components
ComponentSystem::get()->registerComponent<Spinner>();
ComponentSystem::get()->registerComponent<Projectile>();
ComponentSystem::get()->registerComponent<Pawn>();
// run in background
App::get()->setUpdate(1);
// load world
Console::get()->run("world_load data/cs");
return 1;
}
};
//////////////////////////////////////////////////////////////////////////
// World logic class
//////////////////////////////////////////////////////////////////////////
class AppWorldLogic : public WorldLogic
{
public:
AppWorldLogic() {}
virtual ~AppWorldLogic() {}
virtual int init()
{
// create static camera
PlayerDummyPtr player = PlayerDummy::create();
player->release();
player->setPosition(Vec3(17.0f));
player->setDirection(vec3(-1.0f), vec3(0.0f, 0.0f, 1.0f));
Game::get()->setPlayer(player->getPlayer());
// create light
LightWorldPtr sun = LightWorld::create(vec4::ONE);
sun->release();
sun->setName("Sun");
sun->setWorldRotation(Math::quat(45.0f, 30.0f, 300.0f));
// create objects
ObjectMeshDynamicPtr obj[4];
obj[0] = create_box(translate(Vec3(-16.0f, 0.0f, 0.0f)), vec3(1.0f));
obj[1] = create_box(translate(Vec3(16.0f, 0.0f, 0.0f)), vec3(1.0f));
obj[2] = create_box(translate(Vec3(0.0f, -16.0f, 0.0f)), vec3(1.0f));
obj[3] = create_box(translate(Vec3(0.0f, 16.0f, 0.0f)), vec3(1.0f));
// there are two ways to create components on nodes:
// 1) via component system
ComponentSystem::get()->addComponent<Spinner>(obj[0]->getNode());
ComponentSystem::get()->addComponent<Spinner>(obj[1]->getNode());
// 2) via property
obj[2]->addProperty("spinner");
obj[3]->setProperty(0, "spinner");
// set up spinners (set "spawn_node" variable)
ObjectMeshDynamicPtr projectile_obj = create_box(Mat4::IDENTITY, vec3(0.15f));
projectile_obj->setEnabled(0);
for (int i = 0; i < 4; i++)
ComponentSystem::get()->getComponent<Spinner>(obj[i]->getNode())->spawn_node = projectile_obj->getNode();
// create player
ObjectMeshDynamicPtr my_pawn_object = create_box(translate(Vec3(1.0f, 1.0f, 0.0f)), vec3(1.3f, 1.3f, 0.3f));
my_pawn = ComponentSystem::get()->addComponent<Pawn>(my_pawn_object->getNode());
my_pawn->setDestroyCallback(MakeCallback(this, &AppWorldLogic::my_pawn_destroyed));
time = 0;
// create info label
label = WidgetLabel::create(Gui::get());
label->setPosition(10, 10);
label->setFontSize(24);
label->setFontOutline(1);
Gui::get()->addChild(label->getWidget(), Gui::ALIGN_OVERLAP);
return 1;
}
virtual int update()
{
// increase time while player is alive
if (my_pawn)
time += Game::get()->getIFps();
// show game info
label->setText(String::format(
"Player:\n"
"Health Points: %d\n"
"Time: %.1f sec\n"
"\n"
"Statisics:\n"
"Components: %d",
(my_pawn ? my_pawn->health.get() : 0),
time,
ComponentSystem::get()->getNumComponents()).get());
return 1;
}
private:
Pawn* my_pawn {nullptr}; // Pawn player
float time = 0;
WidgetLabelPtr label;
void my_pawn_destroyed()
{
my_pawn = nullptr;
}
// method creating a box
ObjectMeshDynamicPtr create_box(const Mat4 &transform, const vec3 &size)
{
MeshPtr mesh = Mesh::create();
mesh->addBoxSurface("box", size);
ObjectMeshDynamicPtr object = ObjectMeshDynamic::create(1);
object->setMesh(mesh);
object->setWorldTransform(transform);
object->setMaterial("mesh_base", "*");
object->setSurfaceProperty("surface_base", "*");
object->release();
return object;
}
};
//////////////////////////////////////////////////////////////////////////
// Main
//////////////////////////////////////////////////////////////////////////
int main(int argc, char **argv)
{
// init engine
EnginePtr engine(UNIGINE_VERSION, argc, argv);
// enter main loop
AppWorldLogic world_logic;
AppSystemLogic system_logic;
engine->main(&system_logic, &world_logic, NULL);
return 0;
}