Game Object
In the previous tutorial we created a demo application that showed an arrangement of playing cards that automatically rotated and showed a different card on every rotation. In that example, to better organize our code, we defined a Card object.
type Card struct {
FrontSprite, BackSprite sprite.Sprite
Rotating bool
}
Card
stores the front and back sprites for that card and
some state (Rotating
) that tracks whether the card is
spinning or not. The card also had it’s own constructor
function,NewCard
, and an Update
function that
is responsible for spinning the card and changing it’s face when the
card completed a full rotation.
This type of object comes up very often in games and most game engines have support for it. Game objects are used to represent various things found in a game: players, enemies, weapons, obstacles and so on. In this tutorial we will define a our own game object type which we will call… GameObject.
Attributes
A game object should hold data that are common to most objects in the game. Lets take the example of a hypothetical, simple, hack-and-slash game called “Knight vs Trolls”. In this game, our playable character, a knight, wields a sword and uses it to slay foul trolls. When the trolls die, they drop gold coins that our hero must collect.
The knight, trolls and coins are game objects. The player’s sword can
also be a game object if we want our player to be able to change
weapons. Each game object is positioned somewhere in the scene and has a
size and rotation (in this example sprites face left or right but to be
general we will allow all rotations). Also, each game object has one or
more sprites. The player and trolls have a few sprites that create a
walk animation and the sword and coin have a single sprite. From the
above we can devise a GameObject
type for our game with the
following attributes.
type GameObject struct {
Translation math.Vector3[float32]
Rotation math.Vector3[float32]
Scale math.Vector2[float32]
Sprites []sprite.Sprite
}
All game objects in our example must be rendered so it makes sense to
add a render method to GameObject
.
func (g *GameObject) Render(renderer *sprite.Renderer) {
activeSprite = 0
renderer.QueueRender(g.Sprites[activeSprite)
}
In the finished code we must figure out a way to switch
activeSprite
so we can have animation but we will leave
that for later.
GameObject Interface
In “Knight vs Trolls” our knight is controlled using the keyboard and
the trolls are controlled by a very simple AI function. This control
functionality is exclusive to each object. The trolls don’t need
keyboard controls and the knight doesn’t need AI. This means that this
functionality should not be part of GameObject
and must be
added to the respective specialized game objects of the knight and
troll.
In object-oriented languages this can be achieved with inheritance.
In Java, for example, we can define a Knight
class that
inherits GameObject
and includes a keyboard control
function. A Troll
class would also inherit
GameObject
and specialize by providing an AI function. In
Go, we achieve the same result using interfaces. An interface is a
collection of functions that types must implement. Lets see an
example.
type GameObject interface {
Update()
Render(*sprite.Renderer)
}
In the above, we specify that all game objects must have an
Update()
function and a
Render(*sprite.Renderer)
function. We can create types that
satisfy the interface by creating methods for the type that match these
signatures. For example, our knight type could be:
type Knight struct {
Translation math.Vector3[float32]
Rotation math.Vector3[float32]
Scale math.Vector2[float32]
Sprites []sprite.Sprite
activeSprite int
}
To make Knight
a GameOblect
we must
implement the Update
and Render
functions.
func (k *Knight) Update() {
keys := GetKeyboard()
if keys["right"] {
k.Translation = k.Translation.Add(1,0,0)
}
//...
activeSprite = (activeSprite+1) % len(k.Sprites)
}
func (k *Knight) Render(*sprite.Renderer) {
renderer.QueueRender(k.Sprites[activeSprite)
}
Knight
now implements GameObject
, no
special implements
directive is required. Troll is defined
similarly:
type Troll struct {
Translation math.Vector3[float32]
Rotation math.Vector3[float32]
Scale math.Vector2[float32]
Sprites []sprite.Sprite
AI AISystem
activeSprite int
}
Troll also has an Update
and Render
so it
satisfies GameObject
but its Update
implementation is different:
func (t *Troll) Update() {
moveVector := t.AI.GetMovement()
t.Translation.Add(moveVector)
activeSprite = (activeSprite+1) % len(k.Sprites)
}
func (t *Troll) Render(*sprite.Renderer) {
renderer.QueueRender(t.Sprites[activeSprite)
}
Because both Knight
and Troll
are
Gameobject
s we can store them in the same collection, in
this case an array:
gameScene := []GameObject{}
gameScene = append(gameScene, Knight{})
gameScene = append(gameScene, Troll{})
gameScene = append(gameScene, Troll{})
gameScene = append(gameScene, Troll{})
// Game loop
for {
for gameObject := range scene {
gameObject.Update()
gameObject.Render(renderer)
}
}
Interfaces allow us to create collections of similar objects and
process them together. In fancy computer science terms this is called
polymorphism. In the above code, the knight and the trolls are stored in
the same gameScene
array. In the game loop, we iterate over
the knight and troll game objects and call their respective Update and
Render functions. This mechanism will serve as the basis of our scene
processing code later.
Common Attributes
In our example, the knight and trolls have transform attributes
Translation, Rotation
and Scale
. Most game
objects in a game will have these attributes so we can define them in
their own type to make it easier to include in game objects.
type GameObjectCommon struct {
Translation math.Vector3[float32]
Rotation math.Vector3[float32]
Scale math.Vector2[float32]
}
In Go, we can compose structs by adding an unnamed field with the struct type into another struct. This is called struct embedding.
type Troll struct {
GameObjectCommon //embedded type
Sprites []sprite.Sprite
AI AISystem{}
activeSprite int
}
The fields of the GameObjectCommon
struct are now
present in Troll
. This creates the same Troll
type that we had before. Embedding a type is functionally equivalent to
just adding a field of that type:
type TrollNoEmbed struct {
CommontStuff GameObjectCommon
Sprites []sprite.Sprite
AI AISystem{}
activeSprite int
}
But with embedding we can access the fields of
GameObjectCommon
directly which is more convenient.
emb := Troll{}
emb.Translation = math.Vector3[float32]{1,1,1}
noEmb := TrollNoEmbed{}
noEmb.CommontStuff.Translation = math.Vector3[float32]{1,1,1}
Embedding significantly reduces code repetition as the number of game
object fields increases. We don’t have a way to force game objects to
include GameObjectCommon
but we can make
GameObject
require the existence of these attributes by
adding getter and setter methods for them.
type GameObject interface {
Update()
Render(*sprite.Renderer)
GetTranslation() math.Vector3[float32]
GetRotation() math.Vector3[float32]
GetScale() math.Vector2[float32]
SetTranslation(math.Vector3[float32])
SetRotation(math.Vector3[float32])
SetScale(math.Vector2[float32])
}
This creates a problem. Every time we implement a new game object we
must also implement these attribute methods. This would be very
cumbersome. One way to solve this is to automatically create code
templates for new game objects using code generation. Go has built-in
support for code generation using the go generate
command1.
Another approach, and the one we will be using here, is to add the
implementation of these methods into GameObjectCommon
.
func (g *GameObjectCommon) GetTranslation() math.Vector3[float32] { return g.Translation }
func (g *GameObjectCommon) GetRotation() math.Vector3[float32] { return g.Rotation }
func (g *GameObjectCommon) GetScale() math.Vector2[float32] { return g.Scale }
func (g *GameObjectCommon) SetTranslation(t math.Vector3[float32]) { g.Translation = t }
func (g *GameObjectCommon) SetRotation(r math.Vector3[float32]) { g.Rotation = r }
func (g *GameObjectCommon) SetScale(s math.Vector2[float32]) { g.Scale = s }
With this, objects that embed GameObjectCommon
automatically implement the attribute getter/setter methods. The
following is valid, for example:
type Troll struct {
GameObjectCommon
}
troll := Troll{}
troll.GetScale()
We can take this a step further and have
GameObjectCommon
implement all methods of
GameObject
.
func (g *GameObjectCommon) Render(r *sprite.Renderer) {}
func (g *GameObjectCommon) Update() {fmt.Println("Not Implemented")}
This way, any object that embeds GameObjectCommon
is
automatically a GameObject
. Objects can overwrite the
implementation of any of the GameObjectCommon
methods by
providing their own. For example, the following would print
“Trolling”.
type Troll struct {
GameObjectCommon
}
func (t *Troll) Update() {
fmt.Println("Trolling")
}
Troll{}.Update()
The GameObjectCommon method is still accessible if we need it, it’s just not the default. The following would print “Not Implemented”.
Troll{}.GameObjectCommon.Update()
Remarks
The GameObject
interface will be our main tool for
building and organize our games. In the following tutorials we will
expand GameObject
to include common functionality found in
games. A few tutorials down the line we will be able to implement the
“Knight vs Trolls” for real.
https://go.dev/blog/generate↩︎