Friday, February 10, 2017

OpenGL 4 with OpenTK in C# Part 10: Asteroid Invaders

OpenGL 4 with OpenTK in C# Part 10: Asteroid Invaders Screenshot

In this part of the series we will look at putting everything we've learned so far together to create a simple game, a hybrid of the classics Asteroids and Space Invaders.
The end product will have a functional keyboard based steering of a spacecraft, that is able to shoot bullets and 3 different types of asteroids.

This is part 10 of my series on OpenGL4 with OpenTK.
For other posts in this series:
OpenGL 4 with OpenTK in C# Part 1: Initialize the GameWindow
OpenGL 4 with OpenTK in C# Part 2: Compiling shaders and linking them
OpenGL 4 with OpenTK in C# Part 3: Passing data to shaders
OpenGL 4 with OpenTK in C# Part 4: Refactoring and adding error handling
OpenGL 4 with OpenTK in C# Part 5: Buffers and Triangle
OpenGL 4 with OpenTK in C# Part 6: Rotations and Movement of objects
OpenGL 4 with OpenTK in C# Part 7: Vectors and Matrices
OpenGL 4 with OpenTK in C# Part 8: Drawing multiple objects
OpenGL 4 with OpenTK in C# Part 9: Texturing
OpenGL 4 with OpenTK in C# Part 10: Asteroid Invaders
OpenGL 4 with OpenTK in C# Part 11: Mipmap
OpenGL 4 with OpenTK in C# Part 12: Basic Moveable Camera
OpenGL 4 with OpenTK in C# Part 13: IcoSphere
OpenGL 4 with OpenTK in C# Part 14: Basic Text

As stated in the previous post, I am in no way an expert in OpenGL. I write these posts as a way to learn and if someone else finds these posts useful then all the better :)
If you think that the progress is slow, then know that I am a slow learner :P
This part will build upon the game window and shaders from part 9..

End goal

A spaceship that is able to
  • move around to dodge asteroids, 
  • shoot at asteroids and 
  • receive different score depending on type of asteroid shot down

Objects: AGameObject

This will be the base for our game objects, meaning that we put all generic code that is the same for all game objects into this class.
public abstract class AGameObject
{
    public ARenderable Model => _model;
    public Vector4 Position => _position;
    public Vector3 Scale => _scale;
    private static int GameObjectCounter;
    protected readonly int _gameObjectNumber;
    protected ARenderable _model;
    protected Vector4 _position;
    protected Vector4 _direction;
    protected Vector4 _rotation;
    protected float _velocity;
    protected Matrix4 _modelView;
    protected Vector3 _scale;

    public AGameObject(ARenderable model, Vector4 position, Vector4 direction, Vector4 rotation, float velocity)
    {
        _model = model;
        _position = position;
        _direction = direction;
        _rotation = rotation;
        _velocity = velocity;
        _scale = new Vector3(1);
        _gameObjectNumber = GameObjectCounter++;
    }

    public void SetScale(Vector3 scale)
    {
        _scale = scale;
    }
    public virtual void Update(double time, double delta)
    {
        _position += _direction*(_velocity*(float) delta);
    }

    public virtual void Render()
    {
        _model.Bind();
        var t2 = Matrix4.CreateTranslation(_position.X, _position.Y, _position.Z);
        var r1 = Matrix4.CreateRotationX(_rotation.X);
        var r2 = Matrix4.CreateRotationY(_rotation.Y);
        var r3 = Matrix4.CreateRotationZ(_rotation.Z);
        var s = Matrix4.CreateScale(_scale);
        _modelView = r1*r2*r3*s*t2;
        GL.UniformMatrix4(21, false, ref _modelView);
        _model.Render();
    }
}
Basically we say that each game object should have

  • a ARenderable object as a model.
  • a Position, i.e. where it is in the game world
  • a Direction, where it is pointing
  • a Velocity, what speed is it moving in the direction
  • a Rotation, how is it rotated in space
  • a Scale, how is the original model scaled to fit its purposes
  • a Number to identify it (mostly used for uniqueness of updates seen later)

Our generic Update method takes both the time (total time since start) and delta time (seconds since last frame). The generic update only calculates a new position for each object based on the direction and velocity variables.
At this point there should be no real surprises in here. The Render method handles the calculation of a new ModelView matrix and sets it in the shader before calling the render on the model (TexturedRenderObject). See that Matrix4.CreateScale is added to the calculation.

Objects: Asteroid

The asteroid class basically just inherits from AGameObject and adds its own override for the Update method.
public override void Update(double time, double delta)
{
    _rotation.X = (float)Math.Sin((time + _gameObjectNumber) * 0.3);
    _rotation.Y = (float)Math.Cos((time + _gameObjectNumber) * 0.5);
    _rotation.Z = (float)Math.Cos((time + _gameObjectNumber) * 0.2);
    var d = new Vector4(_rotation.X, _rotation.Y, 0, 0);
    d.Normalize();
    _direction = d;
    base.Update(time, delta);
}
So, what we do is to calculate a new rotation for the object for this frame, we want to asteroids to tumble around the screen. Here we add in the _gameObjectNumber to allow for a little uniquenes in the rotation so that not all asteroids rotate the same.
Then, from the rotation vector, we create a new direction vector based on X and Y dimensions. We want all objects to stay at the same level on the Z axis. Normalize that one and set the direction before calling the base Update method that handles the actual updating of the position.

Objects: Bullet

For the bullet we also override the Update method. But we only set a spin on it, no change in direction. We want to bullet to go straight.
public override void Update(double time, double delta)
{
    _rotation.X = (float)Math.Sin(time * 15 + _gameObjectNumber);
    _rotation.Y = (float)Math.Cos(time * 15 + _gameObjectNumber);
    _rotation.Z = (float)Math.Cos(time * 15 + _gameObjectNumber);
    base.Update(time, delta);
}

Objects: Spacecraft

Ok, here we will have a little more code, but here as well we only add to the Update method.

private bool _moveLeft;
private bool _moveRight;
public override void Update(double time, double delta)
{
    // if the use wants to move left and we are stopped or already moving left
    if (_moveLeft && !(_direction.X > 0 && _velocity > 0))
    {
        _direction.X = -1;
        _velocity += 0.8f * (float)delta;
        _moveLeft = false;
    }
    // if the use wants to move right and we are stopped or already moving right
    else if (_moveRight && !(_direction.X < 0 && _velocity > 0))
    {
        _direction.X = 1;
        _velocity += 0.8f * (float)delta;
        _moveRight = false;
    }
    // otherwise decrease speed to a stop. if the use changes direction, this will happen first
    else
    {
        _velocity -= 0.9f * (float)delta;
    }
    // maximum velocity
    if (_velocity > 0.8f)
    {
        _velocity = 0.8f;
    }
    // minimum velocity
    if (_velocity < 0)
    {
        _velocity = 0;
        _rotation.Y = 0;
    }
    // to make the spacecraft tilt in the way it is moving, we change the 
    // rotation of the Y axis based on the current velocity
    if (_direction.X < 0 && _velocity > 0)
        _rotation.Y = -_velocity;
    if (_direction.X > 0 && _velocity > 0)
        _rotation.Y = _velocity;
    base.Update(time, delta);
}

public void MoveLeft()
{
    _moveLeft = true;
}

public void MoveRight()
{
    _moveRight = true;
}

Basically we allow the user to move the ship left or right. If the ship is moving in another direction, it will first decelerate to 0 before changing direction. This is done so that the tilt animation looks fluid. We set a minimum and maximum velocity and then calculate the tilt of the ship.

GameWindow

OnLoad

asteroid texture, take from a picture of the moon over at pexels.com
Asteroid texture, take from a picture of the moon over at pexels.com

programmers art of a spacecraft
Programmers art of a spacecraft. Red with Viper stripes:D 
In the on load method we need to setup our new game objects and their textures.
So lets replace the following old code
_renderObjects.Add(new TexturedRenderObject(ObjectFactory.CreateTexturedCube(0.2f), _texturedProgram.Id, @"Components\Textures\dotted2.png"));
_renderObjects.Add(new TexturedRenderObject(ObjectFactory.CreateTexturedCube(0.2f), _texturedProgram.Id, @"Components\Textures\wooden.png"));
_renderObjects.Add(new ColoredRenderObject(ObjectFactory.CreateSolidCube(0.2f, Color4.HotPink), _solidProgram.Id));
_renderObjects.Add(new TexturedRenderObject(ObjectFactory.CreateTexturedCube(0.2f), _texturedProgram.Id, @"Components\Textures\dotted.png"));

With the following
var models = new Dictionary<string, ARenderable>();
models.Add("Wooden", new TexturedRenderObject(RenderObjectFactory.CreateTexturedCube(1, 256, 256), _texturedProgram.Id, @"Components\Textures\wooden.png"));
models.Add("Golden", new TexturedRenderObject(RenderObjectFactory.CreateTexturedCube(1, 256, 256), _texturedProgram.Id, @"Components\Textures\golden.bmp"));
models.Add("Asteroid", new TexturedRenderObject(RenderObjectFactory.CreateTexturedCube(1, 256, 256), _texturedProgram.Id, @"Components\Textures\asteroid.bmp"));
models.Add("Spacecraft", new TexturedRenderObject(RenderObjectFactory.CreateTexturedCube6(1, 1536, 256), _texturedProgram.Id, @"Components\Textures\spacecraft.png"));
models.Add("Gameover", new TexturedRenderObject(RenderObjectFactory.CreateTexturedCube6(1, 1536, 256), _texturedProgram.Id, @"Components\Textures\gameover.png"));
models.Add("Bullet", new ColoredRenderObject(RenderObjectFactory.CreateSolidCube(1, Color4.HotPink), _solidProgram.Id));
_gameObjectFactory = new GameObjectFactory(models);

_player = _gameObjectFactory.CreateSpacecraft();
_gameObjects.Add(_player);
_gameObjects.Add(_gameObjectFactory.CreateAsteroid());
_gameObjects.Add(_gameObjectFactory.CreateGoldenAsteroid());
_gameObjects.Add(_gameObjectFactory.CreateWoodenAsteroid());
Instead of storing our renderObjects directly in the GameWindow, we initialize a dictionary of objects that can be reused and send it to a new GameObjectFactory that will handle the creation of game objects. The GameWindow will have a list of game objects instead of render objects.

OnExit

public override void Exit()
{
 Debug.WriteLine("Exit called");
 _gameObjectFactory.Dispose();
 _solidProgram.Dispose();
 _texturedProgram.Dispose();
 base.Exit();
}
No big change here, we make sure that our native resources, in this case render programs and render objects (that are bound to openGL) are correctly released.
I take up this part in almost every part of this tutorial series but it can't be said enough times. When working with unmanaged resources it it Your job to dispose of them correctly. For example I had managed to remove the program disposers above and wondered why the game took longer and longer to start until I had to restart the computer. It is because you allocate memory, and if you don't clear it it will still be hogged even though your application has shut down. When I finally figured out what was wrong and reintroduced the disposes, everything works fine again.
This is something that people tend to forget about when working in C#, but it is really important.

OnUpdateFrame, Keyboard handling

Luckily we already have a method for keyboard handling. Currently it only checks for the Escape key to close the game but now we will add support for the following keys as well
  • A: Move left
  • D: Move right
  • Spacebar: fire bullet
private void HandleKeyboard(double dt)
{
    var keyState = Keyboard.GetState();

    if (keyState.IsKeyDown(Key.Escape))
    {
        Exit();
    }
    if (keyState.IsKeyDown(Key.A))
    {
        _player.MoveLeft();
    }
    if (keyState.IsKeyDown(Key.D))
    {
        _player.MoveRight();
    }
    if (!_gameOver && keyState.IsKeyDown(Key.Space) && _lastKeyboardState.IsKeyUp(Key.Space))
    {
        _gameObjects.Add(_gameObjectFactory.CreateBullet(_player.Position));
    }
    _lastKeyboardState = keyState;
}
For the movement it is OK for the user to press and hold the key down, but for the shooting part we want her to press and release space for every bullet. Otherwise we would generate a lot of bullets for every keypress as it will last for multiple frames. So we store a Last Keypress variable and check that the key was up the previous frame and is now down before we add a new bullet.

OnUpdateFrame

Here we will need to call Update for all game objects in play at the moment. Luckily we have them stored in a list that can be iterated over.
protected override void OnUpdateFrame(FrameEventArgs e)
{
    _time += e.Time;
    var remove = new HashSet<AGameObject>();
    var view = new Vector4(0, 0, -2.4f, 0);
    int outOfBoundsAsteroids = 0;
    foreach (var item in _gameObjects)
    {
        item.Update(_time, e.Time);
        if ((view - item.Position).Length > 2)
        {
            remove.Add(item);
            outOfBoundsAsteroids++;
        }

    }
    foreach (var r in remove)
        _gameObjects.Remove(r);
    for (int i = 0; i < outOfBoundsAsteroids; i++)
    {
        _gameObjects.Add(_gameObjectFactory.CreateRandomAsteroid());
    }
    HandleKeyboard(e.Time);
}
If an asteroid wanders outside of the game screen, we remove it and generate a new one. Note that the removal is done outside of the foreach loop as you should not change a collection being iterated.

OnRenderFrame

Lastly, to render we just iterate over all game objects in play and call their render method.
Note the resetting of the projectionMatrix if the render program changes between objects.
Also note the object counter and score counters in the title row. I have no idea how to write stuff on the screen yet so I went for the easy out and wrote those in the title for now :)
protected override void OnRenderFrame(FrameEventArgs e)
{
    Title = $"{_title}: FPS:{1f / e.Time:0000.0}, obj:{_gameObjects.Count}, score:{_score}";
    GL.ClearColor(Color.Black); // _backColor);
    GL.Clear(ClearBufferMask.ColorBufferBit | ClearBufferMask.DepthBufferBit);

    int lastProgram = -1;
    foreach (var obj in _gameObjects)
    {
        var program = obj.Model.Program;
        if (lastProgram != program)
            GL.UniformMatrix4(20, false, ref _projectionMatrix);
        lastProgram = obj.Model.Program;
        obj.Render();

    }
    SwapBuffers();
}

GameObjectFactory

Ok, lets look at the game object factory and see if we can get some stuff on the screen and test play a session..
public class GameObjectFactory : IDisposable
{
    // set a constant Z distance. We are pretty much doing a 2D game in a 3D space
    private const float Z = -2.7f;
    private readonly Random _random = new Random();
    private readonly Dictionary<string, ARenderable> _models;
    public GameObjectFactory(Dictionary<string, ARenderable> models)
    {
        _models = models;
    }
    
    public Spacecraft CreateSpacecraft()
    {
        var spacecraft = new Spacecraft(_models["Spacecraft"], new Vector4(0, -1f, Z, 0), Vector4.Zero, Vector4.Zero, 0);
        // as the spacecraft is just a texture, we scale the z axis to quite thin
        spacecraft.SetScale(new Vector3(0.2f, 0.2f, 0.01f));
        return spacecraft;
    }
    // create asteroid and scale, also sets the score given to the player when the asteroid is destroyed.
    public Asteroid CreateAsteroid(string model, Vector4 position)
    {
        var obj = new Asteroid(_models[model], position, Vector4.Zero, Vector4.Zero, 0.1f);
        obj.SetScale(new Vector3(0.2f));
        switch (model)
        {
            case "Asteroid":
                obj.Score = 1;
                break;
            case "Wooden":
                obj.Score = 10;
                break;
            case "Golden":
                obj.Score = 50;
                break;
        }
        return obj;
    }
    // low probablity for golden asteroid, bit higher for wooden and usually it is just a rock
    public AGameObject CreateRandomAsteroid()
    {
        var rnd = _random.NextDouble();
        var position = GetRandomPosition();
        if (rnd < 0.01)
            return CreateAsteroid("Golden", position);
        if (rnd < 0.2)
            return CreateAsteroid("Wooden", position);
        return CreateAsteroid("Asteroid", position);
    }
    // for the bullet we set the velocity from start as well as direction of movement to UnitY, i.e. straight up
    // we also send in an initial position based on the spacecraft
    public Bullet CreateBullet(Vector4 position)
    {
        var bullet = new Bullet(_models["Bullet"], position + new Vector4(0, 0.1f, 0, 0), Vector4.UnitY, Vector4.Zero, 0.8f);
        bullet.SetScale(new Vector3(0.05f));
        return bullet;
    }
    private Vector4 GetRandomPosition()
    {
        var position = new Vector4(
            ((float) _random.NextDouble() - 0.5f) * 1.1f,
            ((float) _random.NextDouble() - 0.5f) * 1.1f,
            Z,
            0);
        return position;
    }
    // cleanup models to avoid memory leak
    public void Dispose()
    {
        foreach (var obj in _models)
            obj.Value.Dispose();
    }
}

So a lot of stuff here. Key parts include the locked in Z axis to a constant value as we are basically doing a top down 2D game.
The spaceship model is just a very thin box with texture on the front facing side.
Bullet velocity is set from the start.
And Disposing of OpenGL resources at the end.

Add some action: basic collision detection

By now you should have noticed that not much is happening when bullets hit the asteroids. So lets add some code to see if objects touch each other on the screen. To accomplish this, we will just add a simple bounding sphere calculation. I.e. if two objects bounding spheres are intersecting, then we have a collision.
First in the GameWindow OnUpdateFrame loop we shall add the following to detect if a bullet or an asteroid is hitting something. Bullets will hit asteroids and if they hit the asteroid should be removed and 2 new created randomly. If the asteroid hits something, it is the spacecraft and the game is over:
if (item.GetType() == typeof (Bullet))
{
    var collide = ((Bullet) item).CheckCollision(_gameObjects);
    if (collide != null)
    {
        remove.Add(item);
        if (remove.Add(collide))
        {
            _score += ((Asteroid)collide).Score;
            removedAsteroids++;
        }
    }
}
if (item.GetType() == typeof(Spacecraft))
{
    var collide = ((Spacecraft)item).CheckCollision(_gameObjects);
    if (collide != null)
    {
        foreach (var x in _gameObjects)
            remove.Add(x);
        _gameObjects.Add(_gameObjectFactory.CreateGameOver());
        _gameOver = true;
        removedAsteroids = 0;
        break;
    }
}

Then after we have removed whatever items we will remove we add the following to generate new Asteroids.
for (int i = 0; i < removedAsteroids; i++)
{
    _gameObjects.Add(_gameObjectFactory.CreateRandomAsteroid());
    _gameObjects.Add(_gameObjectFactory.CreateRandomAsteroid());
}

Check collision method for the bullet iterates over all game objects and calculates the distance between itself and all asteroids. If the distance is smaller then the radius of the asteroid, we count it as a hit.
public AGameObject CheckCollision(List<AGameObject> gameObjects)
{
    foreach (var x in gameObjects)
    {
        if(x.GetType() != typeof(Asteroid))
            continue;
        // naive first object in radius
        if ((Position - x.Position).Length < x.Scale.X)
            return x;
    }
    return null;
}

And pretty much the same thing for the spaceship. But here we take both spaceship radius and Asteroid radius into account.
public AGameObject CheckCollision(List<AGameObject> gameObjects)
{
    foreach (var x in gameObjects)
    {
        if (x.GetType() != typeof(Asteroid))
            continue;
        // naive first object in radius
        if ((Position - x.Position).Length < (Scale.X + x.Scale.X))
            return x;
    }
    return null;
}

End results

So, nothing fancy at all in the end. But our first working game mechanics with OpenTK. At least we know that after 10 posts on the topic, it is possible to make a game that is playable :)

For the complete source code for the game at the end of this part, go to: https://github.com/eowind/dreamstatecoding

For some bullet movement patterns to add to the game, wave and homing go here.


Hope this helps someone out there :)
Until next time: Work to Live, Don’t Live to Work

No comments:

Post a Comment