dbriemann / blog

Blog of David Linus Briemann
https://dbriemann.github.io/blog
MIT License
9 stars 0 forks source link

Making a game with Go and Pixel: #1 Game Objects & Composition #6

Open dbriemann opened 6 years ago

dbriemann commented 6 years ago

Say hello to Gonk, the game we are going to make. You can find the code at https://github.com/dbriemann/gonk. This first chapter will cover basic structures and functions and the use of composition for game objects.

Preface

The whole code for the game made in this tutorial can be found here. You should be able to use git at least a little bit. First of get the source code. The recommended way is to use go get:

go get -d -v -u github.com/dbriemann/gonk

This will put gonk in your gopath at $GOPATH/src/github.com/dbriemann/gonk. You can at any point switch to this directory to build and run gonk. Note that the -d flag skips installing the binary into your gopath's bin directory.

Just use git to pull the newest state or checkout a specific tag. I will add a tag for each chapter so you can use

git checkout chapterX.Y

to jump to the game code containing everything up to the tagged chapter. Each chapter will have a note for this. At any point you can go back to the current state with git checkout master. If the beginning of this tutorial is too easy for you, you can just jump in at any point in the series later on.

Note that I won't discuss all of the code in the tutorial. Things that I deem not interesting, not essential or too simple will not be covered. They will however be part of the code and I will try to comment everything so you can just browse the code and understand what's happening.

1.1 Basic Structure & Functions

git checkout chapter1.1

First we should note that Pixel expects us to provide a specific function that acts similar to a main function. This is necessary to ensure that all graphics calls originate in the main thread of the program. Pixel makes this easy by allowing us to bind such a function with pixelgl.Run().

func run() {
    // ...
}

func main() {
    pixelgl.Run(run)
}

We will from now on just use run as if it was main and can be sure that all calls come from the main thread.

Now let's define some basic functions which will be extended over the course of the tutorial. This section covers the core building blocks of the game flow.

Every game needs to setup and initialize a lot of things at startup. It has to load assets (sound and graphics), select and apply the correct screen mode, check for updates and possibly many more things. That's why we need some init functions.

func initScreen() {
    primaryMonitor = pixelgl.PrimaryMonitor()
    cfg := pixelgl.WindowConfig{
        Title:   title,
        Bounds:  pixel.R(0, 0, float64(screenWidth), float64(screenHeight)),
        Monitor: nil,
    }
    win, err := pixelgl.NewWindow(cfg)
        if err != nil {
        panic(err)
    }
    window = win
}

We won't go into the details of the initScreen() function because it's just boilerplate code. The code retrieves the primary monitor and sets up a window with dimensions screenWidth x screenHeight. We will add the ability to switch to full screen mode later in the tutorial. The most important variables here are defined globally in the file globals.go.

NOTE: Games are a lot about mutating states. Thus it is often easier (and more productive) to define variables globally than passing them around from function to function.

func setFPS(fps int) {
    if fps <= 0 {
        frameTick = nil
    } else {
        frameTick = time.NewTicker(time.Second / time.Duration(fps))
    }
}

Our setFPS() function can either set the maximum frames per second (by providing a positive value) or disable any maximal value (by providing 0). frameTick is a pointer to a time.Ticker from the Go std library and defined in globals.go. If initialized it will trigger every X seconds or in our case fractions of a second. E.g. if we set the FPS to 60 it will trigger every 1/60th second.

This brings us to the game loop. Each game has it. It is the heart of the game. Our game loop looks like this if we strip some things from it that are unimportant to its basic purpose. It is part of the mentioned run() function.

    for !window.Closed() {                  // (1)
        last := now                         // (2a)
        now = time.Now()                    // (2b)
        dt := now.Sub(last).Seconds()       // (2c)

        update(dt)                          // (3)

        draw()                              // (4)

        window.Update()                     // (5)

        if frameTick != nil {               // (6a)
            <-frameTick.C                   // (6b)
        }
    }

What it does is actually not that complicated. The loop continues to run until the the window is closed via the x icon (1). At some point we will add a menu to the game which will offer the typical way to exit a game.

NOTE: Usually you want to create the game before the menu. If your game sucks you have saved time.

Lines (2*) calculate the duration of the last frame. The value is stored in the variable dt which is short for delta time. dt is passed to the update() function (3) which can then interpolate if the frames don't have a consistent duration.

NOTE: A game's update(..) function handles the logic. It is responsible for modifying the state of the game and of all its entities. Often input is handled here too. TIP: Read more about game loops and delta time in the great article Fix Your Timestep!

Next the draw() function is called (4) which will draw everything visible onto the screen. At the end we have a call to window.Update() (5) which tells Pixel that the frame is finished.

Lines (6*) show how the previously mentioned ticker is used. If there is a ticker object the code will wait until it triggers. We have seen how the ticker is initialized in the setFPS() function. With this little snippet we can limit the FPS to a specific value or disable it and the code runs as fast as possible.

Let's have a look at the draw() function.

func draw() {
    window.Clear(colornames.Black)

    // Draw HUD
    fpsText.Clear()
    fpsText.WriteString(fmt.Sprintf("FPS: %d", int(math.Round(fps))))
    fpsText.Draw(window, pixel.IM)
}

The draw() function of a game just draws everything you can see as a player to the screen. It will usually not draw objects outside of the screen or out of sight to save performance. It also should not alter any state in the game. That's what the update(..) function is there for.

For now draw() just clears the screen with black and draws the measured frames per second on the screen. This tutorial explains how to handle text in Pixel. We won't have a look at this topic here. The FPS counter is part of a heads up display we will add to the game. Soon we will add a camera to the game but the HUD will always be drawn outside the camera, directly in screen space.

1.2 Game Objects & Composition

git checkout chapter1.2

A lot of code has been added since the last chapter. As always we will have a look at the core parts. Let's start by avoiding misunderstandings. If you have used an Entity Component System before you may be a bit confused because we will use the terms entity and component here but not necessarily with the same meaning. For us a component is a minimal building block with the follwing properties:

The entity differs from the component because of the follwing properties:

We differentiate between components and entities just logically. There are no types involved. The only physical separation is that we have one file for components (components.go) and one file for entities (entities.go). Because Gonk is a small game we won't need separate files for each component and entity.

Orb

Let's start with our orb component. We know that our game will have planets and ships (copied from the original qonk). Both planets and ships show a similar behaviour. They rotate around some kind of anchor. This maybe the origin of the star system (center / sun) or another planet. If we pick the core properties of both a planet and a ship we arrive at orb.

type orb struct {
    anchor *pixel.Vec
    vel    pixel.Vec
    pos    pixel.Vec
    dir    float64
    dist   float64
}

func (o *orb) rotate(dt float64) (shift pixel.Vec) {
    mat := pixel.IM                                     // (1)
    len := o.vel.Len()                                  // (2)
    omega := o.dir * len / o.dist                       // (3)

    mat = mat.Rotated(*o.anchor, omega*dt)              // (4)
    npos := mat.Project(o.pos)                          // (5)
    shift.X, shift.Y = npos.X-o.pos.X, npos.Y-o.pos.Y   // (6)
    o.pos.X, o.pos.Y = npos.XY()                        // (7)
    return
}

You can see that an orb holds a pointer to an anchor vector. This way we can use any position of any orb specifically as anchor without having to copy the values every time the anchor moves. The other properties are simple:

Let's have a look at the rotate function. It takes one argument (dt) and returns the shift Vector. We know dt already. It is the delta time usually consumed by update() functions. It determines how far the orb rotates depending on the time passed. The returned vector shift just contains how the orb was moved by the rotation. We will see how this is used later on.

In line (1) we just create a new matrix by copying the identity matrix. In the next line we misuse the vel vector (2) by just calculating its length to act as a pseudo-speed. Because the orb only rotates on a set circle we don't need a vector here and thus we simplify and pretend to have a 1D velocity. Line (3) calculates the angle dependending on dir, dist and the speed.

We now use Pixel's functions (4,5) to rotate pos around anchor. As you can see dt is a factor for the angle. You can remember it like that: omega is rotation (in rad) per second. Now if dt equals 1/60th of a second the orb is only rotated by 1/60th of omega. The Rotated function just creates a matrix (4) to rotate something around anchor by the given angle. Afterwards Project applies this matrix to the pos vector. The result npos is the new position after rotation.

In line (6) we calculate the delta vector from the old and new position. This is the vector that is returned by the rotate function. In line (7) only the new position values are assigned to the current position and the position is now updated.

Planet and Ship

It's time for a little composition. Here are our planet and ship entities. First let me say that player is another component but we will not discuss it here. Have a look at it. It is pretty self-explanatory.

type planet struct {
    orb
    *player

    satellites    []*planet     // (1)
    ships         []*ship       // (2)
    shipsProduced float64       // (3)
    shipAngleMod  float64       // (4)
    size          float64
}

type ship struct {
    orb
    *player
}

As you see both planet and ship embed an orb and a *player. We already talked about the shared properties of planet and ship. Luckily in Go we can embed structs in other structs. By doing this the embedded struct's properties become properties of the embedding struct. E.g. both planet and ship have now access to pos, vel, *anchor etc. You could also access them via planet.orb.pos and so on if you wanted / needed to.

The planet has additionally gained some more properties. Line (1) shows a recursive declaration of a slice of pointers to planets. The satellites slice lets us keep track of other planets orbiting this one. The slice holds pointers because all planets live in a global array called planets and we only want to know which ones are satellites.

Next there is the ships slice (2). Every planet has ships circling it but ships can move from one planet to another to attack or reinforce. Now if ships attack most of them are destroyed in the process. We don't want to create and destroy too many objects and thus rely too much on garbage collection. That's why there is a global slice called recycledShips. As the name suggests it acts like a pool that collects destroyed ships and releases them as new when needed. You can check the newShip() function to see how it is done.

The shipsProduced property in line (3) keeps track of how many ships were produced since the last update() call. In many frames this number is < 1 and has no visible impact. As soon as the value is >= 1 a new ship is produced. Line (4) contains a helper variable that stores the angle modifier for all ships circling a planet.

The ship doesn't have more properties currently but will certainly be extended at some point.

Camera

For convenience I want the center of our universe to be (0,0), which also shall be at the center of the screen. Usually in Pixel (0,0) is the lower left corner of the screen. That means we have to translate the visible area to the left and to the bottom by half the screen width and half the screen height respectively. Let's introduce two variables that define our camera.

camPos = pixel.ZV
cam    pixel.Matrix

The position we are looking at is defined by camPos and is set to the zero vector. The matrix describing the actual camera transformations is named cam and is set and applied in the initScreen() function:

cam = pixel.IM.Moved(worldCanvas.Bounds().Center().Sub(camPos))
worldCanvas.SetMatrix(cam)

We see that the camera is not applied to window directly but to worldCanvas. The reason is that we want to draw HUD elements to window without camera transformations. They live in screen space. The canvas has the same size as the window and is drawn to the window before all HUD elements.

Worth noting is that we also have to translate the canvas when drawing it to the window:

worldCanvas.Draw(window, cam)

The reason is simple. We only applied the camera transformations to worldCanvas but not to window. So when drawing (anything) to window (0,0) is still the lower left corner. That means we also have to apply the translation when drawing the canvas to the window.

Update & Rotation

As last topic in this chapter we will look at rotating and updating planets. Here is the relevant code:

func (p *planet) rotateGroup(dt float64) {
    dvec := p.rotate(dt)                        // (1)
    for i := 0; i < len(p.satellites); i++ {    // (2)
        p.satellites[i].pos.X += dvec.X         // (3a)
        p.satellites[i].pos.Y += dvec.Y         // (3b)
    }
}

In the rotateGroup() method we see how the shift vector returned by orb.rotate() is used. First an orb (in this case a planet) is rotated and the delta is saved in dvec (1). Then every of its satellites (2) is moved by the same delta (3*). This is easier than rotating each satellite also around its anchor's anchor.

func (p *planet) update(dt float64) {
    p.rotateGroup(dt)
    // Ship production depends on planet size: production = sqrt(radius)/5
    prod := math.Sqrt(p.size) * productionFactor    // (1)
    p.shipsProduced += prod * dt

    // Add new ships to slice.
    for i := 0; i < int(p.shipsProduced); i++ {
        added := false
        // Search a free spot and if there is none append.
        nship := newShip(p, p.player)
        for j := 0; j < len(p.ships); j++ {
            if p.ships[i] == nil {
                p.ships[i] = nship
                added = true
            }
        }
        if !added {
            p.ships = append(p.ships, nship)
        }
        p.shipsProduced--
    }

    p.setShips(dt)                                  // (2)
}

The planet's update() method first rotates the planet and all its satellites as shown above. The production of new ships in line (1) follows the simple equation f(x) = sqrt(x)/5 where x is the radius. We have defined the maximum radius of a planet as 9 (see globals.go). This is how the ship production per second looks as a graph:

production

As you can see it is slightly above 0.5 ships per second for the biggest planets. This function will probably tweaked over time but for now I like the production speed. After adding the ships to the planet we call setShips() in line (2). This method just distributes all the ships of a planet evenly around a planet and rotates them.

1.3 Afterthought

This was the first real tutorial I've written. I needed significantly longer to write this than to write the Gonk code up to this point. I am also not really satisfied with the result. I did not cover the whole code but it is still a lot of text and I am not sure if the topics covered were too easy. I am also pretty sure that the whole structure of this chapter could be improved.

Resulting from these thoughts I have decided that the coming chapters will (probably) a bit shorter than this one and also only cover 1 specific topic. I may add git tags for code that does not receive a separate tutorial chapter if it is about a single topic.

1.4 What's next?

At the moment the draw methods for planet and ship both just draw primitive circles. In the next chapter we will procedurally generate nice planet sprites and use them instead. This not only looks nicer but will be great for performance. If you just run Gonk for a few minutes you will see how the performance becomes horrible. This is because circles need a lot of resources to draw (many triangles) and currently each ship is a circle!