Actions Tutorial
Welcome to the tutorial about the Input module. Here, you will learn how to build up an object-oriented event system. From SFML, you know the procedural way of event handling, namely polling:
while (window.pollEvent(event)) { // switch on event type }
However, this approach has a few drawbacks. You have to differentiate the types explicitly, and your code reacting to the events is gathered in one place. The single events are hardwired as compile-time constants after each case mark. That makes it complicated to change controls according to the user's request.
The Action system of Thor's Input module has been designed with the following thoughts in mind:
- Different actions are adressed by IDs of arbitrary types (e.g. strings, enums)
- Add, modify and remove actions at runtime to allow dynamic assignment of input events
- Uniform treatment of one-time events (sf::Event) and realtime conditions (sf::Mouse, sf::Keyboard and sf::Joystick)
- Possibility to link actions to a callback system
Actions
The class thor::Action
represents an action. It can be used in the following situations:
- Handle whole SFML event, such as
sf::Event::Closed
orsf::Event::Resized
- React to a key, mouse button or joystick button event (pressed or released once)
- React to realtime keyboard, mouse and joystick state (is the button currently held down?)
We say that an action is active if the event or input state specified in the constructor has been triggered. In the following example, we define some actions. The comments explain when each action is active.
thor::Action a(sf::Keyboard::X, thor::Action::PressOnce); // Key X is pressed once thor::Action b(sf::Mouse::Left, thor::Action::Hold); // Left mouse button is currently held down thor::Action c(sf::Event::Closed); // SFML Window is closedWe can even combine multiple actions! The following creates an action which is active when both
a
and b
are active:
thor::Action both = a && b;The same works if at least one of them must be active in order to activate the combined action:
thor::Action atLeastOne = a || b;Of course, you can build arbitrary complex expressions using the overloaded AND and OR operators.
Action maps
Now we have defined some actions, but how can we use them? Here, the class template thor::ActionMap
comes into play.
This class template maps IDs to actions. IDs can be of any comparable type, for example std::string
or a user-defined enumeration. This type is specified in the template parameter.
thor::ActionMap<std::string> map;
We have defined an action map that stores std::string
identifiers associated with thor::Action
instances. Now let's introduce meaningful IDs and associate them with the actions above:
map["run"] = a; map["shoot"] = b; map["quit"] = c;Now the action map knows our actions. We can check if an action is currently active by writing
if (map.isActive("run")) // React to "run" action if (map.isActive("shoot")) // React to "shoot" action if (map.isActive("quit")) // React to "quit" actionHow does the action map know if an action is active? In order to make this possible, we need to feed it with the SFML events occurred every frame. This is automatically done if we call the
thor::ActionMap::update()
method. We pass the SFML window from which events are polled. Here is an example of a main loop:
while (window.isOpened()) { // Poll the window for new events, update actions map.update(window); // React to different action types if (map.isActive("quit")) window.close(); // Update window window.display(); }
As an alternative to update()
, thor::ActionMap
provides the methods pushEvent()
to add an SFML event and clearEvents()
to remove old events. These methods exist for more flexibility, if you want to poll the window yourself and decide which events are forwarded to the action map. Using this approach, you can write the following code:
while (window.isOpened()) { // Clear events from last frame map.clearEvents(); // Forward all events to the action map sf::Event event; while (window.pollEvent(event)) map.pushEvent(event); // React to different action types if (map.isActive("quit")) window.close(); // Update window window.display(); }
Connecting callbacks
Actions can act as triggers for function invocations. Let’s call the functions associated to the different event types “listeners” (other terms are “callbacks”, “slots”, “event handlers” or “observers”).
What we need now are the listeners that are invoked if an action is active. Listener functions return void
and take a parameter of type thor::ActionContext
with your ID type as template argument. This parameter contains information about the context in which an action was fired (for example the original sf::Event
that triggered the action).
Define a listener for actions with the ID "shoot"
:
void onShoot(thor::ActionContext<MyAction> context) { // context.window is a pointer to a sf::Window. It can be used // for mouse input relative to a window, as follows: sf::Vector2i mousePosition = sf::Mouse::getPosition(*context.window); // Do something with the mouse position... }
Using the class template thor::EventSystem
, you can associate different action types with different functions. thor::ActionMap
contains a member typedef CallbackSystem
for the correct type. Let's instantiate such a callback system and connect the ID "shoot"
with the just defined callback
onShoot()
:
thor::ActionMap<std::string>::CallbackSystem system;
system.connect("shoot", &onShoot);
The callback system is ready to call the correct functions if an action is triggered. We only need to forward the actions from thor::ActionMap
to the CallbackSystem
. We can do this in the main loop, instead of the isActive()
calls:
map.invokeCallbacks(system, &window);
The second parameter is the window, which is assigned to the pointer in thor::ActionContext
. If you don't need the window inside the callbacks, you can also pass a null pointer. In this case, the callback will have no information about the window, so the function onShoot()
would not work.
map.invokeCallbacks(system, nullptr);
The callback mechanism is quite powerful. The second parameter of connect()
can not only be a pointer to function like in the above example, rather any compatible callable is allowed. That is, you can also pass function objects, especially instances of std::function
. Besides, you are able to connect any number of listeners to one action type, just invoke connect()
once for each listener.
Another example is a listener that moves a sprite when the player is running:
void moveSprite(sf::Sprite& sprite)
{
sprite.move(10.f, 0.f);
}
Here, we can’t register MoveSprite()
directly at the event system, because the parameter list doesn’t match (sf::Sprite&
vs.thor::ActionContext<std::string>
). But we can build a function object:
struct SpriteMover { explicit SpriteMover(sf::Sprite& sprite) : sprite(sprite) { } void operator() (thor::ActionContext<std::string>) { // ignore parameter sprite.move(10.f, 0.f); } sf::Sprite& sprite; };
Then the registration looks like this:
sf::Sprite playerSprite;
system.connect("run", SpriteMover(playerSprite));
Binders [advanced]
This is a chapter that requires rather advanced C++ knowledge, so depending on your skills, you might want to skip it. You can use the whole functionality of the event systems in Thor without binders, however they can simplify things a lot.
You may think, the functor SpriteMover
needs quite a lot code to achieve a single-line functionality. You are right. Fortunately, C++ offers other ways, namely binders. The library Boost.Bind (which has made it into the standard library) contains a bunch of tools to compose new functions on-the-fly. If you are unfamiliar to binders, you should have a look at the Boost documentation – you don’t need Boost though.
Using the global function moveSprite()
from above, the registration looks like the following. You need std::ref
to pass the sprite as reference.
sf::Sprite playerSprite;
system.connect("run", std::bind(&moveSprite, std::ref(playerSprite)));
We got rid of the class and all the constructor/member/initialization stuff. But honestly, that annoying function does nothing more than forwarding a call to a member. Since binders support member functions, why not use them to call sf::Sprite::move()
directly?
sf::Sprite playerSprite;
system.connect("run", std::bind(&sf::Sprite::move, &playerSprite, 10.f, 0.f));
Now all the functionality is contained in a single line, and we are relieved from writing boilerplate code. The same principle applies to C++11 lambda expressions.
Disconnecting callbacks
With the function thor::EventSystem::connect()
, you have the possibility to add event-listener connections. But you don't know how to remove them from the system as soon as they aren’t needed anymore. The member function Connect()
returns an object of type thor::Connection
which exists exactly for this purpose. You can store it and call its method disconnect()
later to disconnect the listener from the event.
thor::Connection connection = system.connect("shoot", &onShoot);
connection.disconnect();
A connection automatically becomes invalid when the referenced listener is disconnected, so you can’t disconnect a listener accidentally more than once. To disconnect all listeners associated with a given event type, call thor::EventSystem::clearConnections()
.