Crime Engine is a custom-made engine created by 8 programmers for a concept that was created before the start of the engine with other students. The idea of the engine was to provide a platform for designers and artists to work on the concept game with two main platforms in mind: PC and Nintendo Switch.
Due COVID-19 and project related issues we decided to drop Nintendo Switch and kept PC as the focus. The engines ways of rendering were 2D, but we implemented ways to convert to 3D later in the process if needed.
Sadly, due the fact that we didn't have enough time to finish up the engine we couldn't use the engine to create the game so in the end the design team opted to use a commercial engine, but I have learned a lot from the project, nonetheless.
My focus for this project was Engine programmer with helping on tools on the side. For this project I setup the game object manager which handles all game objects inside the engine as well as the tile system that allows us to create the world in 2D with tiles either isometric or orthographic. The levels were loaded in through a resource manager that I also created, the levels themselves were created inside a program called Tiled. I also created a scripting service that allows us and designers to work with Lua instead of C++ to decouple the engine from the game.
A quick summary of the game that this engine was made for: In Streamlined Mastermind, the player is in charge of planning bank heists. the player can plan the crew’s actions to navigate them towards the vault and back out again while circumventing the guards.
A very important back-end system I created was the Service Manager, having the Service Locator design pattern in mind I created a globally accessible system (using a Singleton) to allow users to easily access important systems, such as: Resource Manager, Game Object Manager, Event Service, etc.
template<typename T, typename ...Args>
T* ServiceManager::MakeServiceImpl(Args&& ... a_args)
{
auto it = FindService();
if(it == m_services.end())
{
T* service = dynamic_cast<T*>((m_services.emplace(GetTypeHashCode<T>(), std::make_unique<T>(std::forward<Args>(a_args)...)).first)->second.get());
service->OnCreate();
return service;
}
return dynamic_cast<T*>(it->second.get());
}
template<typename T>
void ServiceManager::DestroyServiceImpl()
{
auto it = FindService<T>();
if(it == m_services.end())
{
CE_CORE_ASSERT_M(false, "[ServiceManager] Can't destroy service({0}) as it not exists!", typeid(T).name());
return;
}
it->second->OnDestroy();
m_services.erase(it);
}
As seen above the service manager is nothing but a holder of services, in an essence each service is a singleton without being a singleton but having the benefits of being globally accessible. The Service Manager would be the focal point for all services, so you know where the services are located and stored. (Opposed to having all service being Singletons and you have no idea who is responsible for creating or destroying them.)
Using the above-mentioned service system, I created a Lua Service which allows us and or designers to use Lua as a scripting language.
template<typename RetType, typename ... Args>
RetType LuaService::InvokeFunction(const std::string& a_funcName, Args ... a_args)
{
if(!m_isInitialized)
{
return RetType();
}
const sol::object funcObj = m_luaState[a_funcName];
//Are we calling a valid function.
if(funcObj.valid() && funcObj.get_type() == sol::type::function &&
funcObj.is<std::function<RetType(Args...)>>())
{
sol::protected_function func = funcObj;
func.error_handler = m_luaState["error_handler"];
sol::protected_function_result result = func(std::forward<Args>(a_args)...);
//Call when correct
if(result.valid())
{
if(result.get_type() != GetLuaType<RetType>())
{
CE_CORE_ERROR("[LuaService] Function({0}) returned string instead of {1}", a_funcName, typeid(RetType).name());
return RetType();
}
return result.get<RetType>();
}
const sol::error err = result;
CE_CORE_ERROR("[LuaService] Error in lua function({0}): \n{1}", a_funcName, err.what());
return RetType();
}
CE_CORE_CRITICAL("[LuaService] Could't run lua function({0})!", a_funcName);
return RetType();
}
For using Lua I used SOL3 a library/framework that easily allows access the Lua functionality and handles all bookkeeping in the background. As shown above you can invoke functions on Lua or using a register function you can listen to Lua functions.
//Try to find any script files inside ./data/scripts and attempt to run them.
ls->FindAndRunScriptFiles();
//Also able to run and load a script separately.
ls->RunScriptFile("./data/scripts/GuardA.lua");
//Register global functions that will grab the returned Id and check who it belongs to.
//Doesn't have to use lambda's supports any function type. (even member functions).
ls->RegisterFunction("GuardSetFoV", OnSetFov);
ls->RegisterFunction("GetGuardPosition", [gm](uint32_t a_guardId) -> glm::vec2
{
const auto go = gm->GetGameObject<IGuard>(GameObjectId(a_guardId));
if(go != nullptr)
{
return go->GetPos();
}
return {0, 0};
});
ls->RegisterFunction("GuardMove", [gm](uint32_t a_guardId, int a_x, int a_y)
{
const auto go = gm->GetGameObject<IGuard>(GameObjectId(a_guardId));
if(go != nullptr)
{
go->OnMove({a_x, a_y});
}
});
ls->RegisterFunction("GuardShoot", [gm](uint32_t a_guardId, uint32_t a_otherId, int a_gunDmg)
{
const auto go = gm->GetGameObject<GuardA>(GameObjectId(a_guardId));
if(go != nullptr)
{
go->Shoot(a_gunDmg, a_otherId);
}
});
The Idea for Lua scripting was that Lua wouldn't handle any object/instance related stuff only numbers, seen above is that you would register Lua functions on a guard script that would return a guard Id and the appropriated adjusted values based on what happened in the Lua script. This way it kept it simple and basic enough for designers to just adjust a bunch of numbers based on what they wanted to change, for example adjusting the FoV of a guard could then be done in Lua.
I used a program called Tiled to create the levels but I still needed to import and convert the tiled data (which is in XML format) to usable game data.
auto cell = m_grid->GetCell(column, row);
const unsigned tileIndex = row * m_grid->GetGridSize().x + column;
const uint32_t tileGid = layer->getTiles()[tileIndex].ID;
Vector3 pos = grid_helper::ConvertGridToWorldPosition({ column, row }, m_mapSize, m_orientation == MapOrientation::Isometric);
pos.z = startZOffset - float(row) - float(column);
//Load in the tileset map.
const std::shared_ptr<Texture> tileTexture = m_resourceManager->LoadResource<Texture>(tmxTile->imagePath);
m_loadedTileTextures.insert(tileTexture);
Sprite sprite;
//The tile data inside the tileset.
Vector4 texRect(tmxTile->imagePosition.x, tmxTile->imagePosition.y, tmxTile->imageSize.x, tmxTile->imageSize.y);
sprite.SetTexture(*tileTexture, texRect);
auto it = gs_tileTypeMap.find(tmxTile->type);
In order to not write my own parser, I used a library called tmxparser, using this parser I could then easily convert the data to usable game object tiles as shown above. The way it works is that first the LevelService would load the Tiled map and parse it to usable data, it will then create a LevelMap.
auto tile = m_gameObjectManager->CreateGameObject<Tile>(
layer->getName() + "_Tile_" + std::to_string(row) + "_" + std::to_string(column),
pos,
UVector2(column, row),
tmxTile->ID,
layer->getName(),
type,
sprite);
//Add this tile to the current cell.
if (tile != nullptr)
{
cell->AddTile(*tile);
m_tiles.push_back(tile);
}
The LevelMap has a Grid class that holds reference to all cells each cell can contain multiple tiles allowing you to stack tiles on top each other. When the cell is created as seen above it will then pass it onto the current cell which is based on rows and columns of the grid and Tiled map.
Vector3 ConvertGridToWorldPosition(const UVector2& a_gridPos, const UVector2& a_gridSize, bool a_isometric)
{
Vector3 pos;
if(a_isometric)
{
const Vector2 cartPos = Vector2(float(a_gridPos.x) - a_gridSize.x / 2.f, float(a_gridPos.y) - a_gridSize.y / 2.f);
const Vector2 isoPos = CartesianToIsometric(cartPos);
pos.x = isoPos.x;
pos.y = isoPos.y;
pos.z = 0.f;
}
else
{
pos.x = float(a_gridPos.x) - a_gridSize.x / 2.f;
pos.y = float(a_gridPos.y) - a_gridSize.y / 2.f;
pos.z = 0.f;
}
return pos;
}
Vector2 CartesianToIsometric(const Vector2& a_pos)
{
//Convert cartesian to Iso.
return
{
((a_pos.x - a_pos.y) / 2.f),
((a_pos.x + a_pos.y) / 2.f) * 0.5f
};
}
How do I convert the grid position to a usable world position for both the world to interact with but also the renderer to draw correctly? I used a mathematical calculation as seen above that can convert from grid to orthographic coordinates or to isometric coordinates using cartesian coordinates as it base.