/home/jeky

Guide to write a 2D game engine (3)

2018-01-03

Now we have the abilities to:

This time we will try to create our first game -- SNAKE. The demo project will be split into 2 sections and this post will only contain the first one.

The first thing we need to solve is how to render a snake. I will first introduce tile system here. Almost every 2D game you have played, from Super Mario to Warcraft 3, is based on tiles. The main idea of tile-based game is to split everything in game into tiles, then we can reuse them and. You may not know that the clouds and bushes are the same (just different in color) in Super Mario Bros. The developers reduced memory usage by reusing cloud tiles.

Super Mario Bros. Cloud and Bush

Another good example is map editor in RPG Maker. Usually we will use a tileset containing a lots of different tiles to generate a map by placing them into grid.

Map Editor in RPG Maker

A snake game can also be a tile game. We can split the screen into several tiles and render them using different colors:

Each node of the snake can be represented by a coordinate (we will use Point here). For example, the snake below can be represented by: [(5, 3), (6, 3), (7, 3), (7, 4), (7, 5), (6, 5)]. (Always remember in computer graphic, the origin (0,0) is at the top left of the screen. Positive x increases toward the right and positive y increases toward the bottom.)

Snake Coordinate Example 1

Now let's consider the next frame of this game without any input. Assuming the snake will go left (west), we can compute in next frame, the snake will be: [(5,2), (5, 3), (6, 3), (7, 3), (7, 4), (7, 5)].

Snake Coordinate Example 2

The operation here is to remove last one in snake node list and append a new node at first of this list. The coordinate of new node will be determined by the direction of snake. In Java, we have double ended queue: Deque which can be used as the data structure of snake node list.

public class Snake extends Sprite {

    public static final int SIZE = 24;
    private static final Color HEAD_COLOR = Color.RED;
    private static final Color BODY_BORDER_COLOR = Color.CYAN;
    private static final Color BODY_COLOR = Color.BLUE;
    private static final int INIT_LENGTH = 4;
    private Direction direction;
    private Deque<Point> body;

    public Snake(int x, int y) {
        this.x = x;
        this.y = y;
        width = SIZE;
        height = SIZE;
        direction = Direction.NORTH;
        body = new LinkedList<>();
        for (int i = 0; i < INIT_LENGTH; i++) {
            body.add(new Point(x, y));
        }
    }

    @Override
    public void update(Dimension canvasDimension) {
        x += direction.getHorizontal();
        y += direction.getVertical();
        body.removeLast();
        body.addFirst(new Point(x, y));
    }

    @Override
    public void draw(Graphics2D g2d) {
        // draw head
        g2d.setColor(HEAD_COLOR);
        Iterator<Point> iter = body.iterator();
        drawSnakeNode(g2d, iter.next());
        // draw body
        iter.forEachRemaining(p -> {
            g2d.setColor(BODY_COLOR);
            drawSnakeNode(g2d, p);
        });
    }

    private void drawSnakeNode(Graphics2D g2d, Point p) {
        g2d.fillRect(p.x * SIZE, p.y * SIZE, SIZE, SIZE);
        g2d.setColor(BODY_BORDER_COLOR);
        g2d.drawRect(p.x * SIZE, p.y * SIZE, SIZE, SIZE);
    }
}

Direction class is a utility class which contains enumeration of directions:

public enum Direction {

    WEST(KeyEvent.VK_LEFT, -1, 0),
    EAST(KeyEvent.VK_RIGHT, 1, 0),
    NORTH(KeyEvent.VK_UP, 0, -1),
    SOUTH(KeyEvent.VK_DOWN, 0, 1);

    static {
        WEST.left = SOUTH;
        EAST.left = NORTH;
        NORTH.left = WEST;
        SOUTH.left = EAST;

        WEST.right = NORTH;
        EAST.right = SOUTH;
        NORTH.right = EAST;
        SOUTH.right = WEST;
    }

    private final int keyCode;
    private final int horizontal;
    private final int vertical;
    private Direction left;
    private Direction right;

    Direction(int keyCode, int horizontal, int vertical) {
        this.keyCode = keyCode;
        this.horizontal = horizontal;
        this.vertical = vertical;
    }

    public int getKeyCode() {
        return keyCode;
    }

    public int getHorizontal() {
        return horizontal;
    }

    public int getVertical() {
        return vertical;
    }

    public Direction turnLeft() {
        return left;
    }

    public Direction turnRight() {
        return right;
    }
}

From last post, we can register key events to control sprite. Now we can register some keys to control this snake:


public class Snake extends Sprite {

    // ...

    public Snake(int x, int y) {
        // ...
        // register keys
        registerKeyPressedEvent(Direction.NORTH.getKeyCode(), Snake::toNorth);
        registerKeyPressedEvent(Direction.SOUTH.getKeyCode(), Snake::toSouth);
        registerKeyPressedEvent(Direction.EAST.getKeyCode(), Snake::toEast);
        registerKeyPressedEvent(Direction.WEST.getKeyCode(), Snake::toWest);
    }

    private void toNorth() {
        if (direction != Direction.NORTH && direction != Direction.SOUTH) {
            direction = Direction.NORTH;
        }
    }

    private void toSouth() {
        if (direction != Direction.NORTH && direction != Direction.SOUTH) {
            direction = Direction.SOUTH;
        }
    }

    private void toEast() {
        if (direction != Direction.EAST && direction != Direction.WEST) {
            direction = Direction.EAST;
        }
    }

    private void toWest() {
        if (direction != Direction.EAST && direction != Direction.WEST) {
            direction = Direction.WEST;
        }
    }
}

Now if you run this game, you will find it is too hard since the snake is running so fast. This is because we are running the game at 60 FPS. The speed of snake is 24 * 60 = 1440 pixel per sec, which means it will run out of your screen within 2 seconds (even if you have a 4k monitor). We have two solutions for this:

Reducing FPS is not so good because it will also increase the delay of input. You will fell lagging when playing this game. So we add a frame delay counter to update() method, which will increase on each update. And only when the counter reach some number, it will trigger the real method. Suppose FPS = 60 and FRAME_COUNT = 10, the real update times per second will only be 60 / 10 = 6.

public class Snake extends Sprite {

    // ...
    private static final int FRAME_COUNT = 10;
    private int frameCount;

    public Snake(int x, int y) {
        // ...
        frameCount = 0;
    }

    @Override
    public void update(Dimension canvasDimension) {
        frameCount++;
        if (frameCount == FRAME_COUNT) {
            frameCount = 0;

            x += direction.getHorizontal();
            y += direction.getVertical();
            body.removeLast();
            body.addFirst(new Point(x, y));
        }
    }
}

The last thing here is to check if our snake is hitting the edges or biting itself. It is true that you can check this situation right after update the nodes of snake. But here I will show you a skill to make your checking more strict (Inertance). We will try to check like this: if player doesn't change the direction before some check points, he won't be able to change it anymore. We will set the check point to be 5 frames. So within the frames between updating (10 frames), player can only change direction in the first 5 frames.

public class Snake extends Sprite {

    @Override
    public void update(Dimension canvasDimension) {
        frameCount++;

        if (frameCount == FRAME_COUNT / 2) {
            int predictX = x + direction.getHorizontal();
            int predictY = y + direction.getVertical();
            int width = (int) (canvasDimension.getWidth() / SIZE);
            int height = (int) (canvasDimension.getHeight() / SIZE);

            if (hitBody(predictX, predictY) || hitWall(predictX, predictY, width, height)) {
                System.out.println("Game Over");
                System.exit(-1);
            }
        } else if (frameCount == FRAME_COUNT) {
            frameCount = 0;

            x += direction.getHorizontal();
            y += direction.getVertical();
            body.removeLast();
            body.addFirst(new Point(x, y));
        }
    }

    private boolean hitBody(int x, int y) {
        return body.stream()
                   .filter(p -> p.getX() == x && p.getY() == y)
                   .count() > 0;
    }

    private boolean hitWall(int x, int y, int width, int height) {
        return x < 0 || y < 0 || x > width - 1 || y > height - 1;
    }
}

Now you have a truly basic runnable version of snake. The whole changes can be viewed here: https://github.com/Jeky/jam/commit/fc79d6609c7dd78fb6c909b2e713c343c8e8127e.

We will talk about event system in next post.