Basic game example - Scorbutics/ska GitHub Wiki
using GameBase = ska::GameCore<ska::EntityManager, ska::ExtensibleGameEventDispatcher<>, ska::VectorDrawableContainer, ska::SoundRenderer>;
class Game :
public GameBase {
public:
Game() = default;
void init();
virtual ~Game() = default;
private:
int onTerminate(ska::TerminateProcessException & tpe) override;
int onException(ska::GenericException & e) override;
};
As you can see, we redefine a custom GameCore with every template classes specified. I will not describe every template class used here, but be aware that those are "basics" classes defined in the engine (ska::EntityManager, ska::ExtensibleGameEventDispatcher<>, ska::VectorDrawableContainer and ska::SoundRenderer). Then, we override virtual members needed by the GameApp.
The current implementation of the Game class is here
std::unique_ptr<ska::GameApp> ska::GameApp::get() {
auto wgc = std::make_unique<Game>();
wgc->init();
return wgc;
}
I'll start by explaining the definition of std::unique_ptr<ska::GameApp> ska::GameApp::get()
.
Ska engine embeds its own main function, meaning that we do not manage application lifetime.
By doing this, we force the user to define a special function that returns a pointer of GameApp instance.
The GameApp instance is obviously subclassed by a user class, because GameApp is abstract.
This is why we create a unique_ptr of our Game class that will be returned.
int Game::onTerminate(ska::TerminateProcessException& tpe) {
SKA_LOG_MESSAGE(tpe.what());
return 0;
}
int Game::onException(ska::GenericException& e) {
/* Handles Generics Game exceptions */
std::cerr << e.what() << std::endl;
return 0;
}
Those lines define a way to react to 2 types of exceptions that can occur in the engine : a one thrown when we want to quit, another one when we have an error. Here, we just want to log when something weird happens. I won't explain here the "SKA_LOG_MESSAGE" in details, just keep in mind that it's a way to log without production code overhead, meaning that it's based on the fact you compile on "Debug" or "Release". When compiling for production code, you compile a "Release" build, meaning that you won't log the TeminateProcessException message.
Then, there is the init piece of code :
void Game::init() {
/* Configure inputs types */
addInputContext<KeyboardInputPongContext>(ska::EnumContextManager::CONTEXT_MAP);
addInputContext<ska::KeyboardInputGUIContext>(ska::EnumContextManager::CONTEXT_GUI);
ska::GUI::MENU_DEFAULT_THEME_PATH = "." FILE_SEPARATOR "Resources" FILE_SEPARATOR "Menu" FILE_SEPARATOR "start_screen_theme" FILE_SEPARATOR;
ska::SDLFont::DEFAULT_FONT_FILE = "." FILE_SEPARATOR "Resources" FILE_SEPARATOR "Fonts" FILE_SEPARATOR "FiraSans-Medium.ttf";
navigateToState<StateScreenTitle>();
}
Wow, a lot of new code here ! Let's get it step by step. First, the two "addInputContext". The use of this is setting the way we query input. Here, we can notice that we'll use Keyboard (and mouse) as input method and we define two ways to query it : first, the Pong context which is used when we are in the main state of the game (when we play) and the GUI context, used to manage the clicks on buttons, windows, etc.... The GUI context is embedded with the engine. The "KeyboardInputPongContext" is a special input context created to suit the game needs. Let's see the details and explain it step by step :
#pragma once
#include "Inputs/KeyboardInputContext.h"
class KeyboardInputPongContext : public ska::KeyboardInputContext {
public:
explicit KeyboardInputPongContext(ska::RawInputListener& ril);
virtual ~KeyboardInputPongContext() = default;
protected:
virtual void buildCodeMap(std::unordered_map<int, ska::InputAction>& codeMap, std::unordered_map<int, ska::InputToggle>& toggles) override;
};
As you can see, it's just a specialization of ska::KeyboardInputContext, but we redefine the code map. Why ? because in a pong game, you have only 2 controls : move the bar up, move the bar down. That's why we have this implementation :
#include <SDL.h>
#include "Inputs/RawInputListener.h"
#include "KeyboardInputPongContext.h"
KeyboardInputPongContext::KeyboardInputPongContext(ska::RawInputListener& ril) :
KeyboardInputContext(ril) {
}
void KeyboardInputPongContext::buildCodeMap(std::unordered_map<int, ska::InputAction>& codeMap, std::unordered_map<int, ska::InputToggle>& toggles) {
toggles[SDL_SCANCODE_W] = ska::MoveUp;
toggles[SDL_SCANCODE_S] = ska::MoveDown;
}
Now let's go back to the Game class :
ska::GUI::MENU_DEFAULT_THEME_PATH = "." FILE_SEPARATOR "Resources" FILE_SEPARATOR "Menu" FILE_SEPARATOR "start_screen_theme" FILE_SEPARATOR;
ska::SDLFont::DEFAULT_FONT_FILE = "." FILE_SEPARATOR "Resources" FILE_SEPARATOR "Fonts" FILE_SEPARATOR "FiraSans-Medium.ttf";
In order to personnalize paths were we store some default configurations. Those lines setup folders configurations for GUI and Font.
Then, the first step into our game : how we go to StateScreenTitle :
navigateToState<StateScreenTitle>();
This simple line is the common way to instantiate a (first !) game state and go for it. Until this line, the user doesn't see anything (or only the window, depending on what you do in the Game class).
On really simple games, you can do everything without any state, but it quickly becomes unmanageable. Splitting your game in several states is a good practice to write better code (and respect the open closed principle).
Now that we have a nice way to start our game, let's make a screen title : the StateScreenTitle.
class StateScreenTitle :
public ska::StateBase<ska::EntityManager, ska::ExtensibleGameEventDispatcher<>>,
public ska::SubObserver<ska::GameEvent> {
public:
StateScreenTitle(StateData& data, ska::StateHolder& sh);
virtual void onGraphicUpdate(unsigned int, ska::DrawableContainer&) override;
virtual void onEventUpdate(unsigned int) override;
virtual ~StateScreenTitle() = default;
private:
bool onGameEvent(ska::GameEvent& ge) const;
ska::GUI m_gui;
ska::DynamicWindowIG<>* m_pressStartWindow;
ska::Image* m_backgroundImage;
};
(I spare you the include and forward declaration part, which is quite big)
First, we inherit from ska::StateBase<ska::EntityManager, ska::ExtensibleGameEventDispatcher<>>, the 2 type parameters correspond to the ones we set in the ska::GameCore part (at the beginning of this tutorial).
We then inherits from a ska::SubObserverska::GameEvent, which is a class that implements an Observer (see Observer pattern on the web). Why do we need to listen to this event ? To handle window resizes. If we want to have an adaptative screen title, an easy way to do it is by being notified when the window size changes.
The constructor is pretty generic, StateData and StateHolder are mandatory parameters for every game state.
Then come graphics and events listeners overriden function members, that allows you to introduce refresh and graphic update algorithm for special classes. Here, we use these listeners to update graphics and events of the ska::GUI class.
Before looking to the implementation, here are the variable members we need :
ska::GUI m_gui;
ska::DynamicWindowIG<>* m_pressStartWindow;
ska::Image* m_backgroundImage;
We need the ska::GUI. So we have "m_gui". We need a button "Play", so we have a "m_pressStartWindow" corresponding to the button. We want a background image to make our screen title wonderful looking ! so we have a "m_backgroundImage". That's all !
Now let's jump to the implementation of the class :
void StateScreenTitle::onGraphicUpdate(unsigned int, ska::DrawableContainer& container) {
container.addHead(m_gui);
}
void StateScreenTitle::onEventUpdate(unsigned int ellapsedTime) {
m_gui.refresh(ellapsedTime);
}
What we do here is easy to understand : we put the GUI layer into the drawable container, which makes the display of the GUI. Then there is the event part, to refresh the GUI with the previous ellapsedTime transfered.
bool StateScreenTitle::onGameEvent(ska::GameEvent & ge) const{
ska::Point<int> newPos(ge.windowWidth / 2 - m_pressStartWindow->getBox().w / 2, ge.windowHeight - m_pressStartWindow->getBox().h * 1.5);
m_pressStartWindow->move(newPos);
m_backgroundImage->setWidth(ge.windowWidth);
m_backgroundImage->setHeight(ge.windowHeight);
return true;
}
This is what we do when we receive the game event : basically take the new window height and width, resizing the background image and centering the "Play" button on the screen.
Now, the complicated part : how to start the game by clicking on "Play" !
Everything is in the state constructor :
const std::string& screenTitleTheme = "." FILE_SEPARATOR "Resources" FILE_SEPARATOR "Menu" FILE_SEPARATOR "start_screen_theme" FILE_SEPARATOR;
StateScreenTitle::StateScreenTitle(StateData & data, ska::StateHolder & sh):
StateBase(data.m_entityManager, data.m_eventDispatcher, sh),
SubObserver(std::bind(&StateScreenTitle::onGameEvent, this, std::placeholders::_1), static_cast<ska::Observable<ska::GameEvent>&>(data.m_eventDispatcher)),
m_gui(data.m_eventDispatcher),
m_pressStartWindow(nullptr) {
auto& backgroundImageWindow = m_gui.addWindow<ska::WindowIG<>>("backgroundImageWindow", ska::Rectangle{ 0,0,0,0 }, "");
m_backgroundImage = &backgroundImageWindow.addWidget<ska::Image>(screenTitleTheme + "background.png", ska::Point<int>(), false, nullptr);
m_pressStartWindow = &m_gui.addWindow<ska::DynamicWindowIG<>>("screenTitleWindow", ska::Rectangle{ 0, 0, 390, 50 }, screenTitleTheme + "menu");
ska::Point<int> buttonPos(3, 1);
m_pressStartWindow->addWidget<ska::Button>(buttonPos, screenTitleTheme + "button", nullptr, [&](ska::Widget *, ska::ClickEvent& ce) {
if (ce.getState() == ska::MOUSE_RELEASE) {
makeNextState<StatePongGame>(m_backgroundImage->getBox().w, m_backgroundImage->getBox().h);
}
});
m_pressStartWindow->addWidget<ska::Label>("Play game", 36, ska::Point<int>(buttonPos.x + 120, buttonPos.y + 10)).setFontColor(255, 255, 255, 255);
}
I won't describe here the details in the initializer list of the constructor, because it's basically just calls to member class constructors. The only complicated one is the one of the SubObserver, because we link the "onGameEvent" function member to the SubObserver with std::bind.
Then we use our built-in GUI ! By using the GUI of the ska engine, we first need to setup a window were we'll store buttons. Here, we even create 2 windows : one for the background image and one for the Play button.
Now we can add the background image, which is a ska::Image, a kind of ska::Widget :
m_backgroundImage = &backgroundImageWindow.addWidget<ska::Image>(screenTitleTheme + "background.png", ska::Point<int>(), false, nullptr);
As you can see, the widget is added on the window, and we write the type of the widget to instantiate it (ska::Image here). All the parameters are the ones of the constructor of ska::Image here.
m_pressStartWindow = &m_gui.addWindow<ska::DynamicWindowIG<>>("screenTitleWindow", ska::Rectangle{ 0, 0, 390, 50 }, screenTitleTheme + "menu");
ska::Point<int> buttonPos(3, 1);
The next line is the most interesting one :
m_pressStartWindow->addWidget<ska::Button>(buttonPos, screenTitleTheme + "button", nullptr, [&](ska::Widget *, ska::ClickEvent& ce) {
if (ce.getState() == ska::MOUSE_RELEASE) {
makeNextState<StatePongGame>(m_backgroundImage->getBox().w, m_backgroundImage->getBox().h);
}
});
We add a ska::Button widget that takes as argument several things, as a position and a look from a graphic theme, but also a click handler ! The last parameter is in fact a lambda function. It is like a function pointer, it's stored somewhere to be executed later. Here, we trigger this function when the button is clicked.
A click occurs in 2 differents phases, the first one is the click press. The second one is the release. Here, we only make an action when the event is a release, and the action is to switch to a new game state, the "StatePongGame", with 2 parameters which are the width and height of the current window.
That's all for our screen title.
Here is the header of the StatePongGame :
class StatePongGame :
public ska::StateBase<ska::EntityManager, ska::ExtensibleGameEventDispatcher<>> {
public:
StatePongGame(StateData& data, ska::StateHolder& sh, unsigned int windowWidth, unsigned int windowHeight);
virtual void onEventUpdate(unsigned int ellapsedTime) override;
virtual ~StatePongGame() = default;
private:
ska::CameraSystem* m_cameraSystem;
ska::ExtensibleGameEventDispatcher<>& m_eventDispatcher;
ska::EntityManager& m_entityManager;
ska::EntityCollisionResponse m_entityCollision;
ska::EntityId m_ball;
std::unique_ptr<PongBallGoalCollisionResponse> m_scoreMaker;
AI m_ai;
ska::EntityId m_enemyBar;
};
There are no new things to explain here, except the field members part.
- The CameraSystem is used to draw things repositionned with absolute game coordinates.
- Next come the event dispatcher of our game and the entity manager (we need them to create entities, from the ska entity component system).
- Then there is a basic way to react to collisions between 2 entities : the ska::EntityCollisionResponse.
- The ball entity. As you can guess, this is just the pong ball...
- Special rules are managed here ! We'll define a reaction to ball / left and right borders by implementing the class "PongBallGoalCollisionResponse" (sweet name isn't it ?).
- Then the enemy bar ai is defined (also a custom class).
- And the enemy bar entity.
Here is the constructor :
StatePongGame::StatePongGame(StateData& data, ska::StateHolder & sh, unsigned int windowWidth, unsigned int windowHeight):
StateBase(data.m_entityManager, data.m_eventDispatcher, sh),
m_cameraSystem(nullptr),
m_eventDispatcher(data.m_eventDispatcher),
m_entityManager(data.m_entityManager),
m_entityCollision(data.m_eventDispatcher, data.m_entityManager),
m_ai(data.m_entityManager) {
m_cameraSystem = addLogic<ska::CameraFixedSystem>(windowWidth, windowHeight, ska::Point<int>());
}
It's preferable to instantiate systems in the "beforeLoad" function member of the class, that's why there is only the CameraSystem that is initialized in the constructor. In the beforeLoad function, you are sure that you have the state ready !
void StatePongGame::beforeLoad(std::unique_ptr<State>*) {
auto windowWidth = m_cameraSystem->getScreenSize().x;
auto windowHeight = m_cameraSystem->getScreenSize().y;
addGraphic<ska::GraphicSystem>(m_eventDispatcher, m_cameraSystem);
addLogic<ska::MovementSystem>();
addLogic<ska::CollisionSystem>(m_eventDispatcher);
addLogic<ska::GravitySystem>();
addLogic<ska::InputSystem>(m_eventDispatcher);
ska::Rectangle screenBox{ 0, 0, static_cast<int>(windowWidth), static_cast<int>(windowHeight) };
PongFactory::createBoundaries(m_entityManager, 0, screenBox);
PongFactory::createBoundaries(m_entityManager, 1, screenBox);
PongFactory::createBoundaries(m_entityManager, 2, screenBox);
PongFactory::createBoundaries(m_entityManager, 3, screenBox);
auto blockA = PongFactory::createPongBarEntity(m_entityManager, ska::Point<int>(10, windowHeight / 2));
m_enemyBar = PongFactory::createPongBarEntity(m_entityManager, ska::Point<int>(windowWidth - 30, windowHeight / 2));
m_ball = PongFactory::createPongBallEntity(m_entityManager, ska::Point<int>(windowWidth / 2, windowHeight / 2));
ska::InputComponent ic;
ic.movePower = std::numeric_limits<float>::max() * 0.5F;
m_entityManager.addComponent<ska::InputComponent>(blockA, std::move(ic));
m_scoreMaker = std::make_unique<PongBallGoalCollisionResponse>(m_entityManager, m_eventDispatcher, *m_cameraSystem, m_ball, m_enemyBar, blockA);
}
There are 2 different types of system in the engine : logic and graphic. Except the function member to override, there is little difference between the two types. The thing to remember is that we first have to add the systems and THEN the entities to the world, because of the way of managing systems imposes that (it might changes later when improving the engine). We add various systems :
- GraphicSystem used to draw entities from the ECS
- MovementSystem used to make entities move
- CollisionSystem used to make entities collide
- GravitySystem used to manage friction from moving (velocity)
- InputSystem used to act on entities directly from the input (here, keyboard and mouse, remember ? :))
You can also notice that we use the PongFactory by doing "createBoundaries", "createPongBallEntity" or "createPongBarEntity". I'll explain that later (it just creates the playground area).
Lastly, we add an InputComponent to indicate that it's this entity to move.
Let's look at the other function members.
We need to update the ai of the enemy bar :
void StatePongGame::onEventUpdate(unsigned int) {
m_ai.update(m_enemyBar, m_ball);
}