ObjectComponentSystem Code
Brought to you by:
lastresort92
| File | Date | Author | Commit |
|---|---|---|---|
| Test_Files | 2014-04-19 |
|
[f0aa37] Fixed a couple bugs with object destruction. Ma... |
| include | 2014-04-19 |
|
[f0aa37] Fixed a couple bugs with object destruction. Ma... |
| lib | 2014-04-19 |
|
[f0aa37] Fixed a couple bugs with object destruction. Ma... |
| LICENSE.txt | 2014-02-23 |
|
[59f614] Fixed major bug with destroying objects. |
| README.txt | 2014-04-19 |
|
[f0aa37] Fixed a couple bugs with object destruction. Ma... |
==OCS - ObjectComponentSystem==
---- 02/22/2014 ----
OCS provides a fast and easy to use interface for creating,
testing, and modifying objects, their components, and the systems that allow
everything to interact.
===================================================================================================================
ABOUT:
OCS was originally designed for a networked zombie game (<https://github.com/ayebear/undeadmmo>) created
by a few friends for both a learning experience and for some fun. We came across entity-component-systems
and were intrigued by the idea because of how different is was from object oriented design. The simplicity
of creating new entities, coupled with the efficient use of the cache was an interesting concept and knew it
could speed up our development time as well as reduce the amount of debugging we would have to do later on.
Working on this library has proven to be an invaluable learning experience and I hope that someone else will
find it useful.
Although OCS was designed for the above game, it is generic enough that it can be plugged into almost any kind
of project. Custom components, systems, and messages are very simple to create, and the interface is intuitive
enough to allow for an easy going experience. This library will continue to improve as more bugs are found and
more features are needed, so keep an eye out for any updates.
===================================================================================================================
REQUIREMENTS:
-A compiler with c++11 support. This software was built and tested using gcc 4.7.
-Cmake version >= 2.8. Can be downloaded from <http://www.cmake.org/>
===================================================================================================================
INSTALLATION:
===================================================================================================================
LICENSING:
OCS is licensed under the zlib license. See LICENSE.txt for full details.
===================================================================================================================
KNOWN BUGS:
Program will crash if wrong format is used when loading prototypes.
===================================================================================================================
SUPPORT:
If any bugs are found or if you have any suggestions for new features or changes,
you can reach me at KevM1227@gmail.com.
For more information, see the project's home page at
<http://sourceforge.net/projects/objectcomponentsystem/>
===================================================================================================================
INSTRUCTIONS:
There are four main parts to OCS: components, objects, systems, and messaging. This section
will review all of these and show examples on how to use them.
===================================================================================================================
To jump to a section, highlight the title and press ctrl-c, ctrl-f, ctrl-v.
TABLE OF CONTENTS:
COMPONENTS
CREATING A COMPONENT
SERIALIZING A COMPONENT
OBJECTS
CREATING OBJECTS
CREATING A BLANK OBJECT
CREATING AN OBJECT WITH COMPONENTS
CREATING A COMPONENT FROM A PROTOTYPE
ADDING COMPONENTS
REMOVING COMPONENTS
DESTROYING AN OBJECT
QUERYING AN OBJECT'S COMPONENTS
OBJECT PROTOTYPES
CREATING A PROTOTYPE IN THE SOURCE CODE
CREATING A PROTOTYPE IN AN EXTERNAL FILE
LOADING PROTOTYPES
PROTOTYPE SETS
LOADING A PROTOTYPE SET
SYSTEMS
CREATING A SYSTEM
ADDING SYSTEMS
REMOVING SYSTEMS
UPDATING SYSTEMS
WRITING THE UPDATE FUNCTION
MESSAGING
TRANSCEIVERS
MESSAGES
CREATING MESSAGES
LOGGING MESSAGES
LOGGING A SINGLE MESSAGE
LOGGING ALL POSTED MESSAGES
LOGGING ALL PRIVATE MESSAGES
SENDING MESSAGES
SENDING POSTING MESSAGES
SENDING PRIVATE MESSAGES
READING MESSAGES
READING POSTED MESSAGES
READING PRIVATES MESSAGES
CLEARING MESSAGES
CLEARING POSTED MESSAGES
CLEARING PRIVATE MESSAGES
TIPS
===================================================================================================================
COMPONENTS:
The component is the basis of OCS. They define what objects are and how they interact with
the rest of the world. A component in OCS is a POD type, and represents a single aspect
about an object. All components are stored in arrays to limit the amount of cache misses that
occur in a program. Components and Messages make use of the Curiously Recurring Template Pattern(CRTP)
to keep track of their ids.
NOTE: No logic other than serializing or deSerializing should be in the components and all components
MUST implement a default constructor and SHOULD implement a parameratized constructor.
CREATING A COMPONENT:
A Position component might have 2 float values: an x, and a y. To create this component, you might
make it like this.
struct Position : public ocs::Component<Position>
{
Position(float _x = 0.0f, float _y = 0.0f) : x(_x), y(_y) {}
float x, y;
};
This component can then be added to an object using the ObjectManager class.
SERIALIZING A COMPONENT*:
All components have access to a Serializer object. This object operates in a similar way to printf and scanf in
how it takes a format string and a list of variables, and returns a string in the given format.
To allow a component to be serialized or deserialized (perhaps to be loaded from a file), simply overload the serialize
and deSerialize functions in your component. Then in the definition, call the serialize or deSerialize function of the
Serializer object, and give it a format string and a list of values to serialize.
e.g.
struct Position : ocs::Component<Position>
{
Position(float _x = 0.0f, float _y = 0.0f) : x(_x), y(_y) {}
std::string serialize() { return serializer.serialize("% %", x, y); }
void deSerialize(const std::string& values) { serializer.deSerialize("% %", values, x, y; }
float x, y;
};
The component can then be converted to and from a string by calling the serialize or deSerialize functions.
*More serializing options are coming soon. Converting to and from byte arrays, and loading arrays into the string are next additions.
===================================================================================================================
OBJECTS:
An object in OCS is represented by an ID and is the element that ties a group of components together. The
ObjectManager retrieves the object's id and can use that to locate a specific component in the component's
array.
CREATING OBJECTS:
NOTE: All examples below assume an instance of ObjectManager called objManager exists. If your manager inherits from State, one is given
to you.
Objects can be created in three different ways: as an empty object with no components, an object with one or more components passed
to the createObject function, or copied from an object prototype.
CREATING A BLANK OBJECT:
auto id = objManager.createObject();
Creating an empty object should be rarely used, but if you do then it is recommended that you store the id of the
object in order to add components on set up.
CREATING AN OBJECT WITH COMPONENTS:
Assuming components called "Position", "Velocity", and "Renderable" have been created.
objManager.createObject( Position(45, 75), Velocity(34, 4), Renderable("Some texture") );
CREATING AN OBJECT FROM A PROTOTYPE:
To create objects using a prototype, pass in the name of the prototype. To create 10 objects from
a prototype called "Player" you might do this:
for(int i = 0; i < 10; ++i)
objManager.createObject("Player");
All objects created will have the same components with the same values that were given to the prototype on
creation.
ADDING COMPONENTS:
If a component needs to be added to an object after creation you can do so my calling the addComponents function.
e.g.
objManager.addComponents( Position(65, 45) );
Multiple components can be added at once. However, keep in mind that an object may only have one instance of each type
of component for technical reasons.
REMOVING COMPONENTS:
To remove one or more components from an object, call the removeComponents function.
e.g.
objManager.removeComponents<Position, Velocity>(45);
DESTROYING AN OBJECT:
The following code will destory an object with the id 4 as well as all of its components.
objManager.destroyObject(4);
QUERYING AN OBJECT'S COMPONENTS
To determine if the object 45 has Position, Velocity, and Renderable components use the following code.
objManager.hasComponents<Position, Velocity, Renderable>(45);
Returns true if the object has all components and returns false if the object is missing at least one of the components.
OBJECT PROTOTYPES
Object prototypes can make creating new objects much cleaner. Instead of specifying the types and values
of components every time a new object is needed, you can define what components an object should have and
give it an alias to refer by later. Prototypes can be made directly in the source code or in a separate file.
CREATING A PROTOTYPE IN THE SOURCE CODE
To create an object prototype, call the addComponentsToPrototype function in ObjectManager and specify the
name of the prototype to add the components to. If the prototype does not already exist, one will be created.
objManager.addComponentsToPrototype("Player", Position(65, 45), Velocity(200, .5), Renderable("Txtre File.png");
This will create a prototype under the name "Player" with Position, Velocity, and Renderable components.
Any objects that are created from this prototype will be given the same components with the initial values.
CREATING PROTOTYPES IN AN EXTERNAL FILE
The ObjectPrototypeLoader class allows you to easily create and test new types of objects by defining them in
an external file. Defining the prototypes in a file prevents the program from recompiling whenever a change
to a prototype is made. Multiple prototypes are able to be loaded in one function call to keep the source code clean.
REQUIREMENTS:
-All used components must implement a deSerialize function.
-The values for the components must be in the format that was specified in the deSerialize function.
-All used components must be bound to a string so the prototype loader can determine the type of the component.
objManager.bindStringToComponent<Position>("Position");
A prototype in a file has three main parts:
-The Section header which is the name of the prototype.
-An array of component names
-The values of each component.
Every section must have a section header and an end tag. These markers are enclosed in square brackets('[' , '/]').
A prototype called "Player" would have a section that looks like this.
[Player]
[/Player]
The component names and values would go in between these markers.
Every prototype section should have an attribute call "Components". This attribute will contain an array
of component name that define what components the prototype has. All components and attribute are enclosed
in double quotes, and an array is a comma separated list of attributes enclosed in curly braces ('{' , '}').
A colon separates an attribute name from its values.
[Player]
"Components" : { "Position", "Velocity", "Renderable" }
[/Player]
Each component name should then have an attribute with the same name as the one that is in the list.
Each attribute will then have an a single attribute, a string in the format that was specified in the serialize function.
Assuming Position and Velocity take the format "% %", and Renderable takes the format "%s".
String values should be enclosed in single quotes.
[Player]
"Components" : { "Position", "Velocity", "Renderable" }
"Position" : "65 45"
"Velocity" : "200 .5"
"Renderable" : "'Txtre File.png'"
[/Player]
After this prototype is loaded, it can be used as if it was hard coded. There is no performance difference after the
initial set up.
LOADING PROTOTYPES
Before loading a prototype, you must bind your components to a string.
Somewhere before we load our player prototype, we must bind the Position component to the string "Position",
the Velocity component to the string "Velocity", and the Renderable component to the string "Renderable".
The associated words do not need to be named the same as the component. It is just recommended to keep
everything consistent.
//In the configure function of the State class, or somewhere else.
objManager.bindStringToComponent<Position>("Position");
objManager.bindStringToComponent<Velocity>("Velocity");
objManager.bindStringToComponent<Renderable>("Renderable");
Now we can load our player prototype.
The loadObjectPrototype function is static within ObjectPrototypeLoader.
It takes an ObjectManager reference, a file path, and the name of the prototype as parameters.
//Assuming our player prototype is in "player.txt" and it is in the root folder.
ObjectPrototypeLoader::loadObjectPrototype(objManager, "player.txt", "Player");
The "Player" prototype can then be used to create objects.
Please note that there may be more than one prototype per file, but each one must have a unique name.
PROTOTYPE SETS
A prototype set is a list of prototypes that can simplify the loading of prototypes.
To load a prototype set, create a section in the same manner as above, and create an attribute
with a value that contains an array of the prototype names.
[Set]
"Prototype Set" : { "Player", "Ball", "Brick" }
[/Set]
Then somewhere else in the file would be the definitions of the prototypes. These are defined the same way
as in the previous section.
LOADING A PROTOTYPE SET
Like the loadObjectPrototype function, loadPrototypeSet is static within ObjectPrototypeLoader.
This function takes an ObjectManager reference, the file with the prototype set and definitions,
the name of the prototype set attribute, and the section that this attribute resides in.
//Assuming the above example is in prototypes.txt and all prototypes are defined
ObjectPrototypeLoader::loadPrototypeSet(objManager, "prototypes.txt", "Prototype Set", "[Set]");
Notice the square brackets around the name of the section.
Calling this function would be the same as calling loadObjectPrototype for all 3 prototype names.
===================================================================================================================
SYSTEMS:
The core of a system is a single update function that is called every frame. This is the logic of the program that allows components to
interact. A system usually operates on a single component's array and would fetch needed information from the ObjectManager.
CREATING A SYSTEM:
To create a system, simply inherit from the System class and implement an update function that takes an ObjectManager reference,
a MessageHub reference, and a double for the elapsed time.
The following code implements a movement system that operates on objects with Velocity components. This assumes that velocity has
two floats called "speed" and "angle".
struct MovementSystem : public ocs::System
{
void update(ObjectManager& objManager, MessageHub& msgHub, double dt)
{
//Iterate through ALL velocity components. Notice we are taking in a reference. We do this so we can
//modify the actual component.
for( auto& vel : objManager.getComponentArray<Velocity>() )
{
//Use the velocity component's owner id to get the object's position component
auto pos = objManager.getComponent<Position>( vel.getOwnerID() );
//Check if the object actually had a Position component.
if(pos)
{
Use the velocity component to update the object's position.
pos->x += cos(vel.angle) * vel.speed * dt;
pos->y += sin(vel.angle) * vel.speed * dt;
}
}
}
};
Systems can use the MessageHub to post messages if anything interesting occurs (Such as a collision). See Messaging section.
ADDING SYSTEMS:
NOTE: Each SystemManager can have one instance of each type of system for technical reasons. (Why would you need more?)
Assuming a system call MovementSystem has been created, and a SystemManager called sysManager is available,
add it to the SystemManager thusly:
sysManager.addSystem<MovementSystem>();
If systems are updated using the updateAllSystems function in the SystemManager, then systems will be updated in the
order that they were added in. A system can be manually updated by called the updateSystem function and passing in the
system as the template paramater.
sysManager.updateSystem<MovementSystem>(dt);
REMOVING SYSTEMS:
Removing Systems is as easy as passing in the system type to the removeSystem function.
sysManager.removeSystem<MovementSystem>();
UPDATING SYSTEMS:
All systems are, in essence, a single update function. This function should operate a single component array and be as
decoupled as possible. Every update function has access to an ObjectManager, MessageHub, and the time elapsed from the
previous frame.
Systems can be updated in two ways: update all systems in a single function call, or individually.
If updateAllSystems is called, then the systems will be updated in the order that they were added in.
sysManager.updateAllSystems(dt);
You can also call updateSystem and pass in the System type as a template paramater to manually update a system.
sysManager.updateSystem<MovementSystem>(dt);
WRITING THE UPDATE FUNCTION:
An update function for a movement system might look like this:
//Assuming components call Velocity(an angle and speed) and Position(an x and a y) have been created
void MovementSystem::update(ObjectManager& objManager, MessageHub& msgHub, double dt)
{
for(auto& vel : objManager.getComponentArray<Velocity>())
{
//Attempt to get the velocity's owner's position component
auto pos = objManager.getComponent<Position>(vel.getOwnerID);
//Ensure that the object has a position component.
if(pos)
{
pos->x += cos(vel.angle) * vel.speed * dt;
pos->y += sin(vel.angle) * vel.speed * dt;
}
}
}
===================================================================================================================
MESSAGING:
TRANSCEIVERS:
A transceiver in this context is anything that can send or receive messages through the MessageHub.
By default, all States and systems are transceivers. If there is anything else that you need to use
the MessageHub, you can make that object inherit from the Transceiver struct.
To use the MessageHub, you must have access to a Transceiver object, either through inheritance or composition.
Inheriting from Transceiver allows the creator to pass "*this" to the message functions. However, there is no
difference otherwise.
MESSAGES:
A message is similar to a component in that it is a POD type. However, messages can be posted such that
it is available for all other transceivers to see. A message can also be sent privately to another transceiver
if the receiving ID is known.
CREATING MESSAGES:
Creating Messages is very similar to creating a component. Simply inherit from "Component" and pass in the new class type as
a template paramater.
Give the message a constructor that takes a const Transceiver reference, which will be the message creator, and any
other paramaters that are needed to set up the message.
All messages must take a const Transceiver reference an pass the transceiver to the Message base class constructor.
e.g.
struct ObjectDestroyed : public Message<ObjectDestroyed
{
ObjectDestroyed(const Transceiver& transceiver, ocs::ID _destroyedObjectID) :
Message(transceiver), destroyedObjectID(_destroyedObjectID) {}
ocs::ID destroyedObjectID;
};
The message can then be posted to be viewed publically or it can be sent in a private message.
LOGGING MESSAGES:
Messages can be logged to any ostream object such as the console window or an open file.
All messages have default logging which contains the Message Family, and the sender id. To gain access to more
specialized logging overload the "log" function in your message object and define how the message should be logged.
struct ObjectDestroyed : public Message<ObjectDestroyed
{
ObjectDestroyed(const Transceiver& transceiver, ocs::ID _destroyedObjectID) :
Message(transceiver), destroyedObjectID(_destroyedObjectID) {}
void log(std::ostream& out)
{
out << "Message Type: ObjectDestroyed\n";
out << "Message Family: " << getFamily() << std::endl;
out << "Sender: " << getSender() << std::endl;
out << "Destroyed Object ID: " << destroyedObjectID << std::endl;
}
ocs::ID destroyedObjectID;
};
Ideally, the logged message should include the Message Type(The name of the message object), the message family(The family id
that was assigned to the message), the transceiver id of the sender, and all appropriate information about the message, all with labels.
LOGGING A SINGLE MESSAGE:
To log a single message, call the log function on an instantiated message object and call log and pass in an ostream object.
ObjectDestroyed objDestroyed(*this, 5);
objDestroyed.log(std::cout);
Messages will rarely be created in this manner as the MessageHub should handle all message creation.
LOGGING ALL POSTED MESSAGES:
To log all messages that are currently posted in the MessageHub, call logPostedMessages and pass in an ostream object.
msgHub.logPostedMessages(std::cout);
LOGGING ALL PRIVATE MESSAGES:
To log all of a transceiver's private messages, call logPrivateMessages and pass in the transceiver object and an ostream object.
msgHub.logPrivateMessages(*this, std::cout);
SENDING MESSAGES:
SENDING POSTING MESSAGES:
Messages are able to be posted publically for all transceivers to view. To post a message, pass in the message type as
a template paramater, and then pass in the creating transceiver and any paramaters for the message.
e.g. Assuming a message called ObjectDestroyed has been created, the poster inherits from Transceiver, and a MessageHub called
msgHub is available.
//Post that object with id '5' has been destroyed
msgHub.postMessage<ObjectDestroyed>(*this, 5);
The message hub will then create a new ObjectDestroyed object with the given paramaters, and other Transceivers can view it
by using the readPostedMessages function.
SENDING PRIVATE MESSAGES
All transceivers are given access to a "mailbox" that other transceivers can send messages to. Sending private messages
is nearly identical to posting a message. However, you must know the transceiver id of the receiver.
The following code assumes that a "TextMessage" message has been defined that takes a transceiver object and a single string argument.
Also assumes that the sender inherits from "Transceiver".
//Send a TextMessage to transceiver with id '4'
msgHub.sendPrivateMessage(4, *this, "Hello Transceiver 4!");
READING MESSAGES:
READING POSTED MESSAGES:
The MessageHub acts as a bulletin board that is organized by the types of the messages. All transceivers can view these posted messages.
To get a list of all messages of a certain type, pass in the message type as a template paramater to the readPostedMessages function.
//Get a list of all TextMessage messages and store them in textMsgs.
auto textMsgs = msgHub.readPostedMessages<TextMessage>();
Returns a vector of all posted messages of the given type. It is recommended to create a variable using auto as the return type
may change in future versions (i.e. vector -> list etc.).
Reading posted messages has no effect on the actual message board.
READING PRIVATE MESSAGES:
Reading private messages is nearly the same as reading posted messages. The only difference is you give the function your
Transceiver object to gain access to your mailbox.
auto privateTxtMessages = msgHub.readPrivateMessages<TextMessage>(*this);
CLEARING MESSAGES:
CLEARING POSTED MESSAGES:
If the message board is not cleared periodically, it can become cluttered and slow. Therefore, it is recommended to call
clearPostedMessages every frame in the main loop.
An automatic solution is in the works.
CLEARING PRIVATES MESSAGES:
If you wish to clear your personal mailbox, call clearPrivateMessages and pass in your Transceiver object.
TIPS:
-Post any user input to the MessageHub and allow the systems to determine what to do with it.
-Use the new ranged based for loops to step through the component arrays.
-for(auto& renderComp : objManager.getComponentArray<Renderable>())
-Use the configure function in the State class to create any object prototypes.
-Prefer to load prototypes from a file to allow for faster modifying of prototypes.
-The program will not need to be recompiled if an external file is modified.
-Use the initialize function in the State class to create actual objects and set their starting position.
-Call 'clearPostedMessages' for the message hub every frame to prevent messages from piling up.
-Try to avoid creating systems that rely on other systems or components that rely on other components. The more decoupled
everything is the better.
-Email me anymore tips, suggestions, or bugs as you find them.
===================================================================================================================