In this chapter I focussed on animated and non-animated sprites in Goknar Engine. In general, I needed to implement dynamic mesh, a time dependent class, animation class and so on.

Static and Dynamic Mesh

Static meshes are those that are not meant to be updated during any run-time process. Their vertex position, normals, vertex colors, UV positions etc. stay the same all the time. Such a scenario is not suitable for animated sprites. Therefore implementing dynamic meshes is a must to do in a game engine since we need to change the UV coordinates of our animated sprite in certain time duration.

In terms of OpenGL, we already have an array buffer for our static meshes, but we need one more buffer for our dynamic meshes. This must be the first step to do. So I added a new buffer to the renderer.

There are some basic differences between setting a static and dynamic buffer which are:

The usage part of glBufferData:

For static buffer we have:

glGenBuffers(1, &staticVertexBufferId_);
glBindBuffer(GL_ARRAY_BUFFER, staticVertexBufferId_);
glBufferData(GL_ARRAY_BUFFER, totalStaticMeshVertexSize_ * sizeOfVertexData, nullptr, GL_STATIC_DRAW);

And for the dynamic buffer:

glGenBuffers(1, &dynamicVertexBufferId_);
glBindBuffer(GL_ARRAY_BUFFER, dynamicVertexBufferId_);
glBufferData(GL_ARRAY_BUFFER, totalDynamicMeshVertexSize_ * sizeOfVertexData, nullptr, GL_DYNAMIC_DRAW);

By making the usage part GL_DYNAMIC_DRAW, OpenGL provides us the ability to update any vertex data during runtime. But how will we do it?

In DynamicMesh class I added a function to update its vertex data:

void DynamicMesh::UpdateVertexDataAt(int index, const VertexData& vertexData)
{
	engine->GetRenderer()->UpdateDynamicMeshVertex(this, index, vertexData);
}

Which calls the corresponding function in the Renderer class:

void Renderer::UpdateDynamicMeshVertex(const DynamicMesh* object, int vertexIndex, const VertexData& newVertexData)
{
	int sizeOfVertexData = sizeof(VertexData);
	glNamedBufferSubData(dynamicVertexBufferId_, object->GetRendererVertexOffset() + vertexIndex * sizeOfVertexData, sizeOfVertexData, &newVertexData);
}

These were the main changes needed to be done in terms of buffers.

Time Dependent Objects

Every game engine has time dependent objects or functionalities. For example, Unreal Engine 4 has a FTimeHandler class and Unity has coroutines. In order to fill this gap in Goknar, I implemented a class named TimeDependentObject. It holds a delegate function and calls it in every determined interval. In basic it is such a class:


class GOKNAR_API TimeDependentObject
{
public:
	virtual ~TimeDependentObject()
	{

	}

	virtual void Init() {};
	void Tick(float deltaSecond)
	{
		if (!isActive_)
		{
			return;
		}

		elapsedTime_ += deltaSecond;
		if (timeToRefreshTimeVariables_ < elapsedTime_)
		{
			elapsedTime_ -= timeToRefreshTimeVariables_;
			Operate();
		}
	}

	int GetTicksPerSecond() const
	{
		return ticksPerSecond_;
	}

	void SetTicksPerSecond(int ticksPerSecond)
	{
		ticksPerSecond_ = ticksPerSecond;
		timeToRefreshTimeVariables_ = 1.f / ticksPerSecond;
	}

	bool GetIsActive()
	{
		return isActive_;
	}

	void Activate()
	{
		isActive_ = true;
	}

	void Deactivate()
	{
		isActive_ = false;
		elapsedTime_ = 0.f;
	}

protected:
	TimeDependentObject() :
		elapsedTime_(0.f),
		ticksPerSecond_(30), // Default 30 ticks per second
		timeToRefreshTimeVariables_(1.f / ticksPerSecond_),
		isActive_(false)
	{
		engine->RegisterTimeDependentObject(this);
	}

	virtual void Operate() = 0;

	int ticksPerSecond_;
	float timeToRefreshTimeVariables_;
	float elapsedTime_;

	bool isActive_;
};

As you can see, it is a very simple and basic timer.

Sprites

For making a 2D capable game engine we of course need to render 2D objects and I implemented SpriteMesh and AnimatedSpriteMesh classes for this purpose. There are nothing much to talk about the SpriteMesh class since it is only a mesh with 4 preset vertices. The one important thing here is that where the Sprites’ pivot points will be. This is done with a simple enum that determines a Sprite’s pivot point which is:

enum class GOKNAR_API SPRITE_PIVOT_POINT
{
	TOP_LEFT = 0,
	TOP_MIDDLE,
	TOP_RIGHT,
	MIDDLE_LEFT,
	MIDDLE_MIDDLE,
	MIDDLE_RIGHT,
	BOTTOM_LEFT,
	BOTTOM_MIDDLE,
	BOTTOM_RIGHT
};

Pivot point problem is solved with it. But there remains another problem as well. Will a Sprite be used in a 2D or a 3D game? Another problem solved with a single enum. I created another enum in the Engine class that is set by the Application. Main difference in 2D and 3D game is the coordinate system. I determined to make the engine oreinted in 3D world as +x-axis: forward, +y-axis: leftward, and +z-axis is the upward, but in 2D world it is the simple coordinate system where +x:rightward and +y: upward.

Afterall a sprite vertex data is created as follows:

Vector3 position1, position2, position3, position4;
Vector3 normal1, normal2, normal3, normal4;

AppType applicationType = engine->GetApplication()->GetAppType();
switch (applicationType)
{
case AppType::Application2D:
{
    switch (pivotPoint_)
    {
    case SPRITE_PIVOT_POINT::TOP_LEFT:
        position1 = Vector3(0.f, 0.f, 0.f);
        position2 = Vector3(width_, 0.f, 0.f);
        position3 = Vector3(0.f, -height_, 0.f);
        position4 = Vector3(width_, -height_, 0.f);
        break;
    case SPRITE_PIVOT_POINT::TOP_MIDDLE:
        position1 = Vector3(-width_ * 0.5f, 0.f, 0.f);
        position2 = Vector3(width_ * 0.5f, 0.f, 0.f);
        position3 = Vector3(-width_ * 0.5f, -height_, 0.f);
        position4 = Vector3(width_ * 0.5f, -height_, 0.f);
        break;
    case SPRITE_PIVOT_POINT::TOP_RIGHT:
        position1 = Vector3(-width_, 0.f, 0.f);
        position2 = Vector3(0.f, 0.f, 0.f);
        position3 = Vector3(-width_, -height_, 0.f);
        position4 = Vector3(0.f, -height_, 0.f);
        break;
    case SPRITE_PIVOT_POINT::MIDDLE_LEFT:
        position1 = Vector3(0.f, height_ * 0.5f, 0.f);
        position2 = Vector3(width_, height_ * 0.5f, 0.f);
        position3 = Vector3(0.f, -height_ * 0.5f, 0.f);
        position4 = Vector3(width_, -height_ * 0.5f, 0.f);
        break;
    case SPRITE_PIVOT_POINT::MIDDLE_MIDDLE:
        position1 = Vector3(-width_ * 0.5f, height_ * 0.5f, 0.f);
        position2 = Vector3(width_ * 0.5f, height_ * 0.5f, 0.f);
        position3 = Vector3(-width_ * 0.5f, -height_ * 0.5f, 0.f);
        position4 = Vector3(width_ * 0.5f, -height_ * 0.5f, 0.f);
        break;
    case SPRITE_PIVOT_POINT::MIDDLE_RIGHT:
        position1 = Vector3(-width_, height_ * 0.5f, 0.f);
        position2 = Vector3(0.f, height_ * 0.5f, 0.f);
        position3 = Vector3(-width_, -height_ * 0.5f, 0.f);
        position4 = Vector3(0.f, -height_ * 0.5f, 0.f);
        break;
    case SPRITE_PIVOT_POINT::BOTTOM_LEFT:
        position1 = Vector3(0.f, height_, 0.f);
        position2 = Vector3(width_, height_, 0.f);
        position3 = Vector3(0.f, 0.f, 0.f);
        position4 = Vector3(width_, 0.f, 0.f);
        break;
    case SPRITE_PIVOT_POINT::BOTTOM_MIDDLE:
        position1 = Vector3(-width_ * 0.5f, height_, 0.f);
        position2 = Vector3(width_ * 0.5f, height_, 0.f);
        position3 = Vector3(-width_ * 0.5f, 0.f, 0.f);
        position4 = Vector3(width_ * 0.5f, 0.f, 0.f);
        break;
    case SPRITE_PIVOT_POINT::BOTTOM_RIGHT:
        position1 = Vector3(-width_, height_, 0.f);
        position2 = Vector3(0.f, height_, 0.f);
        position3 = Vector3(-width_, 0.f, 0.f);
        position4 = Vector3(0.f, 0.f, 0.f);
        break;
    default:
        break;
    }

    normal1 = Vector3(0.f, 0.f, 1.f);
    normal2 = Vector3(0.f, 0.f, 1.f);
    normal3 = Vector3(0.f, 0.f, 1.f);
    normal4 = Vector3(0.f, 0.f, 1.f);
    break;
}
case AppType::Application3D:
{
    switch (pivotPoint_)
    {
    case SPRITE_PIVOT_POINT::TOP_LEFT:
        position1 = Vector3(0.f, 0.f, 0.f);
        position2 = Vector3(width_, 0.f, 0.f);
        position3 = Vector3(0.f, 0.f, -height_);
        position4 = Vector3(width_, 0.f, -height_);
        break;
    case SPRITE_PIVOT_POINT::TOP_MIDDLE:
        position1 = Vector3(-width_ * 0.5f, 0.f, 0.f);
        position2 = Vector3(width_ * 0.5f, 0.f, 0.f);
        position3 = Vector3(-width_ * 0.5f, 0.f, -height_);
        position4 = Vector3(width_ * 0.5f, 0.f, -height_);
        break;
    case SPRITE_PIVOT_POINT::TOP_RIGHT:
        position1 = Vector3(-width_, 0.f, 0.f);
        position2 = Vector3(0.f, 0.f, 0.f);
        position3 = Vector3(-width_, 0.f, -height_);
        position4 = Vector3(0.f, 0.f, -height_);
        break;
    case SPRITE_PIVOT_POINT::MIDDLE_LEFT:
        position1 = Vector3(0.f, 0.f, height_ * 0.5f);
        position2 = Vector3(width_, 0.f, height_ * 0.5f);
        position3 = Vector3(0.f, 0.f, -height_ * 0.5f);
        position4 = Vector3(width_, 0.f, -height_ * 0.5f);
        break;
    case SPRITE_PIVOT_POINT::MIDDLE_MIDDLE:
        position1 = Vector3(-width_ * 0.5f, 0.f, height_ * 0.5f);
        position2 = Vector3(width_ * 0.5f, 0.f, height_ * 0.5f);
        position3 = Vector3(-width_ * 0.5f, 0.f, -height_ * 0.5f);
        position4 = Vector3(width_ * 0.5f, 0.f, -height_ * 0.5f);
        break;
    case SPRITE_PIVOT_POINT::MIDDLE_RIGHT:
        position1 = Vector3(-width_, 0.f, height_ * 0.5f);
        position2 = Vector3(0.f, 0.f, height_ * 0.5f);
        position3 = Vector3(-width_, 0.f, -height_ * 0.5f);
        position4 = Vector3(0.f, 0.f, -height_ * 0.5f);
        break;
    case SPRITE_PIVOT_POINT::BOTTOM_LEFT:
        position1 = Vector3(0.f, 0.f, height_);
        position2 = Vector3(width_, 0.f, height_);
        position3 = Vector3(0.f, 0.f, 0.f);
        position4 = Vector3(width_, 0.f, 0.f);
        break;
    case SPRITE_PIVOT_POINT::BOTTOM_MIDDLE:
        position1 = Vector3(-width_ * 0.5f, 0.f, height_);
        position2 = Vector3(width_ * 0.5f, 0.f, height_);
        position3 = Vector3(-width_ * 0.5f, 0.f, 0.f);
        position4 = Vector3(width_ * 0.5f, 0.f, 0.f);
        break;
    case SPRITE_PIVOT_POINT::BOTTOM_RIGHT:
        position1 = Vector3(-width_, 0.f, height_);
        position2 = Vector3(0.f, 0.f, height_);
        position3 = Vector3(-width_, 0.f, 0.f);
        position4 = Vector3(0.f, 0.f, 0.f);
        break;
    default:
        break;
    }
    normal1 = Vector3(0.f, 1.f, 0.f);
    normal2 = Vector3(0.f, 1.f, 0.f);
    normal3 = Vector3(0.f, 1.f, 0.f);
    normal4 = Vector3(0.f, 1.f, 0.f);
    break;
}
default:
    break;
}

AddVertexData(VertexData(position1, normal1, Vector4::ZeroVector, Vector2(minX, minY)));
AddVertexData(VertexData(position2, normal2, Vector4::ZeroVector, Vector2(maxX, minY)));
AddVertexData(VertexData(position3, normal3, Vector4::ZeroVector, Vector2(minX, maxY)));
AddVertexData(VertexData(position4, normal4, Vector4::ZeroVector, Vector2(maxX, maxY)));

AddFace(Face(1, 0, 2));
AddFace(Face(1, 2, 3));

Animated Sprites

We created our sprite class, but it needs to be updated and animated. The TimeDependentObject class I implemented earlier succours here. I used it to update a sprite’s uv coordinates in order to animate it. But first we need a class to hold animation data. It holds animation name, UV coordinates of the animation, whether it repeats or not etc. For now, I used a simple vector to hold the coordinates, but in the future I will implement a circular linked list class to hold it and many other things. The class is as follows:

class AnimatedSpriteAnimation
{
	friend class AnimatedSpriteMesh;
public:
	AnimatedSpriteAnimation() = delete;
	AnimatedSpriteAnimation(const std::string& animationName) :
		name_(animationName),
		textureCoordinatesIndex_(0),
		textureCoordinatesSize_(0),
		repeat_(true)
	{

	}

	void AddTextureCoordinate(const Rect& textureCoordinate)
	{
		textureCoordinates.push_back(textureCoordinate);
		textureCoordinatesSize_++;
	}

	void ResetTextureCoordinateAt(int index, const Rect& textureCoordinate)
	{
		textureCoordinates[index] = textureCoordinate;
	}

	void RemoveTextureCoordinateAt(int index)
	{
		textureCoordinates.erase(textureCoordinates.begin() + index);
		textureCoordinatesSize_++;
	}

	void SetRepeat(bool repeat)
	{
		repeat_ = repeat;
	}

private:
	std::vector<Rect> textureCoordinates;

	std::string name_;

	int textureCoordinatesIndex_;
	int textureCoordinatesSize_;

	bool repeat_;
};

We created our sprite mesh and animations, everything is wonderful so far. But how do we use these data? Animated mesh class is derived from both SpriteMesh and TimeDependentObject classes. It has a hash map to hold attached animations, and functions to control them. Its Operate function taken from the TimeDependentObject class operates the animations. We can change the animation speed by changing AnimatedSpriteMesh’ SetTicksPerSecond function.

class GOKNAR_API AnimatedSpriteMesh : public SpriteMesh, public TimeDependentObject
{
public:
	AnimatedSpriteMesh();
	AnimatedSpriteMesh(Material* material);

	virtual ~AnimatedSpriteMesh();

	void Init() override;

	void PlayAnimation(const std::string& name);
	void AddAnimation(AnimatedSpriteAnimation* animation);

	const std::unordered_map<std::string, AnimatedSpriteAnimation*>& GetAnimationsHashMap() const
	{
		return animations_;
	}

private:
	void Operate() override;

	std::string currentAnimationName_;
	std::unordered_map<std::string, AnimatedSpriteAnimation*> animations_;
};
AnimatedSpriteMesh::AnimatedSpriteMesh() :
	SpriteMesh(),
	TimeDependentObject()
{
	animations_ = {};
}

AnimatedSpriteMesh::AnimatedSpriteMesh(Material* material) :
	SpriteMesh(material),
	TimeDependentObject()
{
	animations_ = {};
}

AnimatedSpriteMesh::~AnimatedSpriteMesh()
{
	for (std::unordered_map<std::string, AnimatedSpriteAnimation*>::iterator ite = animations_.begin(); ite != animations_.end(); ite++)
	{
		delete ite->second;
	}
	animations_.clear();
}

void AnimatedSpriteMesh::Init()
{
	if(animations_.count(currentAnimationName_) != 0)
	{
		AnimatedSpriteAnimation* currentAnimation = animations_[currentAnimationName_];
		if (currentAnimation->textureCoordinatesSize_ > 0)
		{
			textureCoordinate_ = currentAnimation->textureCoordinates[0];
		}
	}

	Activate();
	SpriteMesh::Init();
}

void AnimatedSpriteMesh::PlayAnimation(const std::string& name)
{
	if (!isInitialized_)
	{
		GOKNAR_ERROR("Animation cannot be played at constructor or before initialization.", name);
		return;
	}

	if (animations_.count(name) == 0)
	{
		GOKNAR_ERROR("Animation '{}' is not found.", name);
		return;
	}

	if (currentAnimationName_ != name)
	{
		currentAnimationName_ = name;
		Operate();
		elapsedTime_ = 0.f;
	}
}

void AnimatedSpriteMesh::AddAnimation(AnimatedSpriteAnimation* animation)
{
	if (animations_.count(animation->name_) != 0)
	{
		GOKNAR_ERROR("Animation '{}' already exists.", animation->name_);
		delete animation;
		return;
	}
	else
	{
		animations_[animation->name_] = animation;
	}
}

void AnimatedSpriteMesh::Operate()
{
	AnimatedSpriteAnimation* currentAnimation = animations_[currentAnimationName_];
	if (currentAnimation == nullptr)
	{
		return;
	}

	if (currentAnimation->textureCoordinatesIndex_ < currentAnimation->textureCoordinatesSize_ - 1)
	{
		currentAnimation->textureCoordinatesIndex_++;
	}
	else if(currentAnimation->repeat_)
	{
		currentAnimation->textureCoordinatesIndex_ = 0;
	}

	textureCoordinate_ = currentAnimation->textureCoordinates[currentAnimation->textureCoordinatesIndex_];
	UpdateSpriteMeshVertexData();
}

Creating a project

Because Goknar is a component based engine, game objects contain components. I created SpriteMeshComponent and AnimatedSpriteMeshComponent for the corresponding meshes. I will only give example of AnimatedSpriteMesh because it is more complicated, and SpriteMesh can be understood with the same example.

I created a class named “Deceased”. The sprites I use have been gotten from the internet and names are according to the namings of the sprites. The class has an animated sprite component:

class Deceased : public ObjectBase
{
	AnimatedSpriteComponent* deceasedSprite_;
};

And in its constructor, it creates it as follows:

Deceased::Deceased()
{
	Texture* texture = new Texture("./Content/Sprites/Deceased.png");
	texture->SetName("texture0");
	texture->SetTextureMinFilter(TextureMinFilter::NEAREST);
	texture->SetTextureMagFilter(TextureMagFilter::NEAREST);

	Scene* scene = engine->GetApplication()->GetMainScene();
	scene->AddTexture(texture);

	Material* material = new Material();
	material->SetBlendModel(MaterialBlendModel::Masked);
	material->SetShadingModel(MaterialShadingModel::TwoSided);
	material->SetDiffuseReflectance(Vector3(1.f));
	material->SetSpecularReflectance(Vector3(0.f));
	scene->AddMaterial(material);

	Shader* shader = new Shader();
	shader->SetVertexShaderPath("./Content/Shaders/2DVertexShader.glsl");
	shader->SetFragmentShaderPath("./Content/Shaders/2DFragmentShader.glsl");
	shader->SetShaderType(ShaderType::SelfContained);
	shader->AddTexture(texture);
	scene->AddShader(shader);
	material->SetShader(shader);

	AnimatedSpriteMesh* spriteMesh = new AnimatedSpriteMesh(material);
	spriteMesh->SetTicksPerSecond(12);

	AnimatedSpriteAnimation* idleAnimation = new AnimatedSpriteAnimation("idle");
	idleAnimation->AddTextureCoordinate(Rect(Vector2(0.f, 0.f), Vector2(47.f, 47.f)));
	idleAnimation->AddTextureCoordinate(Rect(Vector2(48.f, 0.f), Vector2(95.f, 47.f)));
	idleAnimation->AddTextureCoordinate(Rect(Vector2(96.f, 0.f), Vector2(143.f, 47.f)));
	idleAnimation->AddTextureCoordinate(Rect(Vector2(144.f, 0.f), Vector2(191.f, 47.f)));
	spriteMesh->AddAnimation(idleAnimation);

	AnimatedSpriteAnimation* walkAnimation = new AnimatedSpriteAnimation("walk");
	walkAnimation->AddTextureCoordinate(Rect(Vector2(0.f, 48.f), Vector2(47.f, 95.f)));
	walkAnimation->AddTextureCoordinate(Rect(Vector2(48.f, 48.f), Vector2(95.f, 95.f)));
	walkAnimation->AddTextureCoordinate(Rect(Vector2(96.f, 48.f), Vector2(143.f, 95.f)));
	walkAnimation->AddTextureCoordinate(Rect(Vector2(144.f, 48.f), Vector2(191.f, 95.f)));
	walkAnimation->AddTextureCoordinate(Rect(Vector2(192.f, 48.f), Vector2(239.f, 95.f)));
	walkAnimation->AddTextureCoordinate(Rect(Vector2(240.f, 48.f), Vector2(287.f, 95.f)));
	spriteMesh->AddAnimation(walkAnimation);

	AnimatedSpriteAnimation* attackAnimation = new AnimatedSpriteAnimation("attack");
	attackAnimation->AddTextureCoordinate(Rect(Vector2(0.f, 96.f), Vector2(47.f, 143.f)));
	attackAnimation->AddTextureCoordinate(Rect(Vector2(48.f, 96.f), Vector2(95.f, 143.f)));
	attackAnimation->AddTextureCoordinate(Rect(Vector2(96.f, 96.f), Vector2(143.f, 143.f)));
	attackAnimation->AddTextureCoordinate(Rect(Vector2(144.f, 96.f), Vector2(191.f, 143.f)));
	spriteMesh->AddAnimation(attackAnimation);

	AnimatedSpriteAnimation* hurtAnimation = new AnimatedSpriteAnimation("hurt");
	hurtAnimation->AddTextureCoordinate(Rect(Vector2(0.f, 144.f), Vector2(47.f, 191.f)));
	hurtAnimation->AddTextureCoordinate(Rect(Vector2(48.f, 144.f), Vector2(95.f, 191.f)));
	spriteMesh->AddAnimation(hurtAnimation);

	AnimatedSpriteAnimation* deathAnimation = new AnimatedSpriteAnimation("death");
	deathAnimation->AddTextureCoordinate(Rect(Vector2(0.f, 192.f), Vector2(47.f, 239.f)));
	deathAnimation->AddTextureCoordinate(Rect(Vector2(48.f, 192.f), Vector2(95.f, 239.f)));
	deathAnimation->AddTextureCoordinate(Rect(Vector2(96.f, 192.f), Vector2(143.f, 239.f)));
	deathAnimation->AddTextureCoordinate(Rect(Vector2(144.f, 192.f), Vector2(191.f, 239.f)));
	deathAnimation->AddTextureCoordinate(Rect(Vector2(192.f, 192.f), Vector2(239.f, 239.f)));
	deathAnimation->AddTextureCoordinate(Rect(Vector2(240.f, 192.f), Vector2(287.f, 239.f)));
	deathAnimation->SetRepeat(false);
	spriteMesh->AddAnimation(deathAnimation);

	spriteMesh->SetSize(1, 1);
	spriteMesh->SetPivotPoint(SPRITE_PIVOT_POINT::BOTTOM_MIDDLE);

	deceasedSprite_ = new AnimatedSpriteComponent(this);
	deceasedSprite_->SetMesh(spriteMesh);
	deceasedSprite_->SetPivotPoint(Vector3(-0.1f, -0.1f, 0.f));
	deceasedSprite_->SetRelativeRotation(Vector3(0.f, 0.f, DEGREE_TO_RADIAN(180.f)));
}

As you can see here 5 different animations are added to the deceasedSprite_ object, and they can be played as follows:

deceasedSprite_->GetAnimatedSpriteMesh()->PlayAnimation("idle");
deceasedSprite_->GetAnimatedSpriteMesh()->PlayAnimation("walk");
deceasedSprite_->GetAnimatedSpriteMesh()->PlayAnimation("attack");
deceasedSprite_->GetAnimatedSpriteMesh()->PlayAnimation("hurt");
deceasedSprite_->GetAnimatedSpriteMesh()->PlayAnimation("death");

Final

I implemented a flappy bird clone game to demonstrate a game in 2D world, and a orthographics 2.5D game to demonstrate a game in 3D world. Since I focus on graphics engine demonstrated games do not have any physical phenomenon such as collisions.

You can download the demos:

Scary Mammal

2.5D Orthographic Game