More on Game Objects
In the game object tutorial we provided the base interface for the game objects in our games. We then extended it in the Hierarchy tutorial to enable parent-child relationships in our games and saw how this enables common game behavior like the player holding something. In this tutorial we will add the final extensions needed to complete the game object interface.
Currently our GameObject looks like this:
type GameObject interface {
// game loop methods
Update()
Render(*sprite.Renderer)
// transform getters/setters
GetTranslation() math.Vector3[float32]
GetRotation() math.Vector3[float32]
GetScale() math.Vector2[float32]
SetTranslation(math.Vector3[float32])
SetRotation(math.Vector3[float32])
SetScale(math.Vector2[float32])
// hierarchy
GetParent() GameObject
SetParent(GameObject)
GetChildren() []GameObject
AddChild(g GameObject)
}
The game loop methods are called on every loop iteration to update
the game logic and render the game object. Update holds the game logic
and it is where our users will code most of their game. Render is much
simpler and it typically just calls the Animation.Render
function to draw a sprite.
The transform setters and getters do what they say on the tin. To tidy up a bit, we can add these in their own interface and then embed it into game object. The following is identical to game object above.
type Transformer interface {
GetTranslation() math.Vector3[float32]
GetRotation() math.Vector3[float32]
GetScale() math.Vector2[float32]
SetTranslation(math.Vector3[float32])
SetRotation(math.Vector3[float32])
SetScale(math.Vector2[float32])
}
type GameObject interface {
// game loop methods
Update()
Render(*sprite.Renderer)
// transform getters/setters
Transformer
// hierarchy
GetParent() GameObject
SetParent(GameObject)
GetChildren() []GameObject
AddChild(g GameObject)
}
Finally, the hierarchy methods allow us to create object/child
relationships between our game objects. We provide a default
implementation for these in GameObjectCommon
.
Game Object Ids
Missing from our game object is a way to identify game objects. For example, in our collision tutorial we had our player check for collisions and upon finding a collision they would ‘pick up’ the coins on the ground. That worked because there where only coins in the game. If there where also enemies in the game we would need a way to distinguish between the two. To this end, we will add two ways to identify game objects.
The first is the game object’s id. This is an integer that uniquely
identifies a game object. On the game object interface we add the
Id()
method that returns it.
type GameObject interface {
// game loop methods
Update()
Render(*sprite.Renderer)
// transform getters/setters
Transformer
// hierarchy
GetParent() GameObject
SetParent(GameObject)
GetChildren() []GameObject
AddChild(g GameObject)
Id() int
}
Setting an id is just a matter of giving each game object a unique
number. The number itself doesn’t matter. In
GameObjectCommon
we create these ids by using a counter
(another solution could be to use large random numbers like UUIDs).
var commonIdSource int = 0 //0 is uninitialized ids
func nextId() int {
commonIdSource += 1
return commonIdSource
}
type GameObjectCommon struct {
id int
translation math.Vector3[float32]
rotation math.Vector3[float32]
scale math.Vector2[float32]
// ...
}
func (g *GameObjectCommon) Id() int
{
return g.id
}
We then have the issue of how to assign the id to the game object. So far, we have used constructor methods to create game objects. For example, our knight is created with:
func NewKnight(position math.Vector2[float32]) *Knight {
knight := &Knight{}
knight.animation = game.NewAnimation()
//...
return &knight
}
However, this is a user function and we don’t want to transfer the
responsibility of assigning ids to our user. Instead, we will introduce
an Init
function to our game object and give it a default
implementation in GameObjectCommon
.
```language-go
type GameObject interface {
Init() //NEW
Initialized() bool //NEW
// game loop methods
Update()
Render(*sprite.Renderer)
// transform getters/setters
Transformer
// hierarchy
GetParent() GameObject
SetParent(GameObject)
GetChildren() []GameObject
AddChild(g GameObject)
Id() int
}
func (g *GameObjectCommon) Init() {
g.id = nextId()
}
We also modify our scene Update
to initialize any
uninitialized objects. This also allows for objects to be dynamically
spawned into the game without having the user worry about calling
Init
.
// Update all gameobjects
func (s *Scene) Update(dt time.Duration) {
fn := func(g GameObject) {
if !g.Initialized() {
g.Init()
}
g.Update(dt)
}
s.ObjectsUpdated = depthFirst(&s.root, fn)
}
The user if free to overwrite the implementation of Init in their own code in which case they become responsible for setting up the id value.
Tags
Very often, we want the ability to categorize our game objects. For example we could have ‘player’, ‘enemy’ and ‘obstacle’ categories. These allow us to define behavior for whole groups of objects. In our knight vs trolls game we want the player to be able to damage enemies with their sword but not obstacles. We achieve this using tags. Tags are integer enumerations that we keep in an array on the game object.
type GameObject interface {
//...
GetTags() []int
AddTag(int)
}
func (g *GameObjectCommon) AddTag(tag int) {
g.tags = append(g.tags, tag)
}
func (g *GameObjectCommon) GetTags() []int {
return g.tags
}
This is a simple but powerful feature and we will see an example of it in use later in this tutorial.
Destroying Game Objects
In the collisions tutorial we used a Destroy
method to
make our coins disappear and have others spawn when that happens.
func (c *Coin) Destroy() {
c.bbox.Destroy()
c.GameObjectCommon.Destroy()
//... spawn new coins
}
Let’s define what Destroy
is. When we destroy a game
object we want it gone from the game. In languages where memory is
directly managed (like C), we would delete the game object from memory.
In Go it’s sufficient to remove it from our object hierarchy. To do
that, we introduce the RemoveChild
method. It’s default
implementation is to go over the children array and remove the first
child with a matching id (assumes ids are unique).
type GameObject interface {
//..
GetParent() GameObject
SetParent(GameObject)
GetChildren() []GameObject
AddChild(g GameObject)
RemoveChild(id int) // NEW
Destroy() // NEW
//...
}
func (g *GameObjectCommon) RemoveChild(id int) {
for i := range g.children {
if id == g.children[i].Id() {
g.children[i] = g.children[len(g.children)-1]
g.children = g.children[:len(g.children)-1]
break
}
}
}
The Destroy
method simply unlinks the object from its
parent. Go’s garbage collection takes care of the actual memory
de-allocation. Our object might have children and those must be
destroyed too so we set our destroy to run recursively.
func (g *GameObjectCommon) Destroy() {
depthFirst(g, func(a GameObject) {
g.parent.RemoveChild(g.id)
})
}
One possible issue is when a game object contains other objects that
must be manually de-allocated. Normally this should never happen, and if
it does it’s a good sign that that we designed something poorly. Is some
cases though it can happen and we already saw an example of this with
our collision objects. Our BoundingBox
is created in a
global manager object called CollisionManagerAABB
. When a
bounding box is no longer needed it must be destroyed by calling it’s
destroy method which removes it from the manager:
func (b *BoundingBox) Destroy() {
b.collisionSystem.Delete(b)
}
This was a design decision aimed at performance. We could have omitted the manager part, and had collisions scan the whole object hierarchy. This would have been a cleaner solution and we wouldn’t need a destroy method, but it would be slower.
With the current implementation, if a game object has a bounding box,
it must overwrite the Destroy
method of
GameObjectCommon
and have it call bounding box’s destroy as
we did with the coin.
func (c *Coin) Destroy() {
c.bbox.Destroy()
c.GameObjectCommon.Destroy()
}
To avoid this, we introduce the RunOnDestroy
method. It
accepts a function that is run automatically when an object is
destroyed. User’s can call RunOnDestroy
multiple times to
pass different functions that get run when the object is destroyed.
type GameObject interface {
RunOnDestroy(func())
//...
}
func (g *GameObjectCommon) RunOnDestroy(fn func()) {
g.onDestroyFuncs = append(g.onDestroyFuncs, fn)
}
func (g *GameObjectCommon) Destroy() {
depthFirst(g, func(a GameObject) {
g.parent.RemoveChild(g.id)
for i := range g.onDestroyFuncs {
g.onDestroyFuncs[i]()
}
})
}
With this pattern, we don’t need to overwrite the default destroy method. Instead we pass any de-allocation or cleanup functions when we create our game object. For example, our coin would be rewritten like this:
func NewCoin(position math.Vector2[float32]) *Coin {
//..
c.bbox = Game.Collisions.NewBoundingBox(true, c)
c.RunOnDestroy(c.bbox.Destroy())
}
The RunOnDestroy
pattern could also be used for
gameplay, like implementing on-death effects. For our coin we could to
this:
c.RunOnDestroy(c.bbox.Destroy())
c.RunOnDestroy(func(){
newCoins := rand.Int() % 5
for i := 0; i < newCoins; i++ {
pos := math.Vector2[float32]{
X: rand.Float32()*400 + 50,
Y: rand.Float32()*400 + 50,
}
Game.Level.AddGameObject(NewCoin(pos))
}
})
Embedding
Our GameObject
relies on GameObjectCommon
to provide the implementation for common functions. To use
GameObjectCommon
we simply embed it in our game object. For
example, to turn our Coin
object into a
GameObject
we use:
type Coin struct {
animation game.Animation
bbox *game.BoundingBox
game.GameObjectCommon // makes coin a GameObject by implementing all of GameObject's methods
}
This introduces a subtle issue. Consider what happens when we add a
child object to our coin. We do that using the AddChild
method which we get from GameObjectCommon
.
func (g *GameObjectCommon) AddChild(gg GameObject) {
gg.SetParent(g)
g.children = append(g.children, gg)
}
The issue here is that GameObjectCommon
adds itself as a
parent to the child object and not the coin. This is a problem if the
child needs to call a function that its parent has overwritten, such as
the coin’s custom Destroy
that we saw earlier. If the child
does GetParent().Destroy()
this would call the
GameObjectCommon.Destroy()
method and not the one defined
in Coin
.
We solve this issue with the SetEmbed
method.
SetEmbed
stores a reference to the object that
GameObjectCommon
is embedded in and that object is then
used in place of GameObjectCommon when GameObjectCommon needs to return
a ref of itself.
func (g *GameObjectCommon) SetEmbed(gg GameObject) {
g.embededIn = gg
}
To get the embedded game object we use the GameObject
method. It returns the embedded object if one was set or
GameObjectCommon
if it wasn’t set.
func (g *GameObjectCommon) GameObject() GameObject {
if g.embededIn != nil {
return g.embededIn
}
return g
}
Then our AddChild
method uses GameObject
which will correctly add the embedded object as the parent:
func (g *GameObjectCommon) AddChild(gg GameObject) {
gg.SetParent(g.GameObject())
g.children = append(g.children, gg)
}
An alternative to using SetEmbed
is the
GameObjectCommon
constructor which sets the embed when you
call it.
func NewGameObjectCommon(embededIn GameObject) GameObjectCommon {
return GameObjectCommon{embededIn: embededIn}
}
Knights vs Trolls
This time our addition to Knight vs Trolls will be small. When our knight pick’s up a coin the coin will have a chance of spawning coins and/or sculls. Picking up sculls removes our coins and if we touch a scull without having any coins we die. Let’s first create the scull object.
type Scull struct {
animation game.Animation
bbox *game.BoundingBox
game.GameObjectCommon
}
Scull’s constructor is pretty much identical to coin so we omit it
here. The only big difference is that sculls only spawn sculls when the
are picked up. We set the following to run on a scull’s death using
RunOnDestroy
.
func (s *Scull) SpawnSculls() {
newSculls := rand.Int() % 3
for i := 0; i < newSculls; i++ {
pos := math.Vector2[float32]{
X: rand.Float32()*400 + 50,
Y: rand.Float32()*400 + 50,
}
Game.Level.AddGameObject(NewScull(pos))
}
fmt.Println("Spawning ", newSculls, " sculls")
}
We also modify Coin so that it sometimes spawns sculls as well.
func (c *Coin) SpawnCoins() {
newCoins := rand.Int() % 5
for i := 0; i < newCoins; i++ {
pos := math.Vector2[float32]{
X: rand.Float32()*400 + 50,
Y: rand.Float32()*400 + 50,
}
Game.Level.AddGameObject(NewCoin(pos))
}
newSculls := rand.Int() % 2
for i := 0; i < newSculls; i++ {
pos := math.Vector2[float32]{
X: rand.Float32()*400 + 50,
Y: rand.Float32()*400 + 50,
}
Game.Level.AddGameObject(NewScull(pos))
}
fmt.Println("Spawned ", newCoins, " coins and ", newSculls, " sculls")
}
We now need a way for our knight to distinguish between picking a
scull or a coin. We can do this in two ways. One is to take our
collision object and use type assertion to check if its a
*Coin
.
if _, ok := collisions[i].(*Coin); ok {
fmt.Println("coin")
} else {
fmt.Println("scull")
}
The other option, and the one we will use, is to assign tags to coins and sculls. Tags are integers which we can enumerate:
const (
TagPowerup = iota
TagDebuff
)
Then in the Coin and Scull constructors we assign a tag to each.
func NewScull(position math.Vector2[float32]) *Scull {
s := &Scull{}
//...
s.AddTag(TagDebuff)
return s
}
Then in our collision check we can perform actions based on the type.
func (k *Knight) Update(dt time.Duration) {
//...
collisions := k.bbox.CheckForCollisions()
for i := range collisions {
if game.HasTag(collisions[i], TagDebuff) {
k.coins--
} else {
k.coins++
}
fmt.Println("Knight's coins: ", k.coins)
collisions[i].Destroy()
}
if k.coins < 0 {
fmt.Println("Game Over")
os.Exit(0)
}
}
Tags are a very simple and very useful mechanic. One other potential use is to optimize collision detection. Instead of placing all of our bounding boxes in one list we could split them up into bins based on their tag. Then, we can make collision checks that check only against specific types of objects.
The code for this version of Knights vs Trolls can be found here.