11
\$\begingroup\$

I was unable to sleep on the 17th of December (after 12 AM IST) so I decided to make use of my time. I made a simple Snake game in JavaFX in 3 hours (roughly from 1 AM to 4 AM). Here are all the classes.

Sample Run A sample screenshot Another sample screenshot

Organisation

src/astrobleme
 |
 +-- gui
 |    |
 |    +-- Main.java
 |    +-- Painter.java
 |
 +-- logic
      |
      +-- Food.java
      +-- GameLoop.java
      +-- Grid.java
      +-- Point.java
      +-- Snake.java

Food.java

package astrobleme.logic;

import javafx.scene.paint.Color;

/**
 * A simple class to represent food that takes up only one square.
 *
 * @author Subhomoy Haldar
 * @version 2016.12.17
 */
public class Food {
    public static final Color COLOR = Color.ROSYBROWN;

    private Point point;

    Food(Point point) {
        this.point = point;
    }

    public Point getPoint() {
        return point;
    }

    public void setPoint(Point point) {
        this.point = point;
    }
}

GameLoop.java

package astrobleme.logic;

import astrobleme.gui.Painter;
import javafx.scene.canvas.GraphicsContext;

/**
 * @author Subhomoy Haldar
 * @version 2016.12.17
 */
public class GameLoop implements Runnable {
    private final Grid grid;
    private final GraphicsContext context;
    private int frameRate;
    private float interval;
    private boolean running;
    private boolean paused;
    private boolean keyIsPressed;

    public GameLoop(final Grid grid, final GraphicsContext context) {
        this.grid = grid;
        this.context = context;
        frameRate = 20;
        interval = 1000.0f / frameRate; // 1000 ms in a second
        running = true;
        paused = false;
        keyIsPressed = false;
    }

    @Override
    public void run() {
        while (running && !paused) {
            // Time the update and paint calls
            float time = System.currentTimeMillis();

            keyIsPressed = false;
            grid.update();
            Painter.paint(grid, context);

            if (!grid.getSnake().isSafe()) {
                pause();
                Painter.paintResetMessage(context);
                break;
            }

            time = System.currentTimeMillis() - time;

            // Adjust the timing correctly
            if (time < interval) {
                try {
                    Thread.sleep((long) (interval - time));
                } catch (InterruptedException ignore) {
                }
            }
        }
    }

    public void stop() {
        running = false;
    }

    public boolean isKeyPressed() {
        return keyIsPressed;
    }

    public void setKeyPressed() {
        keyIsPressed = true;
    }

    public void resume() {
        paused = false;
    }

    public void pause() {
        paused = true;
    }

    public boolean isPaused() {
        return paused;
    }

    public int getFrameRate() {
        return frameRate;
    }

    public void setFrameRate(int frameRate) {
        this.frameRate = frameRate;
    }
}

Grid.java

package astrobleme.logic;

import javafx.scene.paint.Color;

import java.util.Random;

/**
 * The positional system for the game. This grid will be rendered in the Canvas.
 *
 * @author Subhomoy Haldar
 * @version 2016.12.17
 */
public class Grid {
    /**
     * The side length of each square point in the grid.
     */
    public static final int SIZE = 10;
    public static final Color COLOR = new Color(0.1, 0.1, 0.1, 1);

    private final int cols;     // The number of columns
    private final int rows;     // The number of rows

    private Snake snake;
    private Food food;

    public Grid(final double width, final double height) {
        rows = (int) width / SIZE;
        cols = (int) height / SIZE;

        // initialize the snake at the centre of the screen
        snake = new Snake(this, new Point(rows / 2, cols / 2));

        // put the food at a random location
        food = new Food(getRandomPoint());
    }

    public Point wrap(Point point) {
        int x = point.getX();
        int y = point.getY();
        if (x >= rows) x = 0;
        if (y >= cols) y = 0;
        if (x < 0) x = rows - 1;
        if (y < 0) y = cols - 1;
        return new Point(x, y);
    }

    private Point getRandomPoint() {
        Random random = new Random();
        Point point;
        do {
            point = new Point(random.nextInt(rows), random.nextInt(cols));
        } while (point.equals(snake.getHead()));
        return point;
    }

    /**
     * This method is called in every cycle of execution.
     */
    public void update() {
        if (food.getPoint().equals(snake.getHead())) {
            snake.extend();
            food.setPoint(getRandomPoint());
        } else {
            snake.move();
        }
    }

    public int getCols() {
        return cols;
    }

    public int getRows() {
        return rows;
    }

    public double getWidth() {
        return rows * SIZE;
    }

    public double getHeight() {
        return cols * SIZE;
    }

    public Snake getSnake() {
        return snake;
    }

    public Food getFood() {
        return food;
    }
}

Point.java

package astrobleme.logic;

/**
 * A simple Point class to store two coordinates.
 *
 * @author Subhomoy Haldar
 * @version 2016.12.17
 */
public class Point {
    private final int x;    // The X coordinate
    private final int y;    // The Y coordinate

    /**
     * The package-visible constructor. Not meant to be used outside the package.
     *
     * @param x The X coordinate.
     * @param y The Y coordinate.
     */
    Point(final int x, final int y) {
        this.x = x;
        this.y = y;
    }

    /**
     * @return The X coordinate.
     */
    public int getX() {
        return x;
    }

    /**
     * @return The Y coordinate.
     */
    public int getY() {
        return y;
    }

    /**
     * @param dx The change in x.
     * @param dy The change in y.
     * @return A new Point which is the result of translation of this point.
     */
    public Point translate(int dx, int dy) {
        return new Point(x + dx, y + dy);
    }

    /**
     * @param other The "other" point to compare against.
     * @return {@code true} if the other Object is an instance of Point and
     * has the same coordinates.
     */
    @Override
    public boolean equals(Object other) {
        if (!(other instanceof Point)) return false;
        Point point = (Point) other;
        return x == point.x & y == point.y;
    }

    public String toString() {
        return x + ", " + y;
    }
}

Snake.java

package astrobleme.logic;

import javafx.scene.paint.Color;

import java.util.LinkedList;
import java.util.List;

/**
 * The class that encapsulates the star of the show. Basically it stores it's current
 * state and stuff like position, velocity, length and so on and so forth.
 *
 * @author Subhomoy Haldar
 * @version 2016.12.17
 */
public class Snake {
    public static final Color COLOR = Color.CORNSILK;
    public static final Color DEAD = Color.RED;
    private Grid grid;
    private int length;
    private boolean safe;
    private List<Point> points;
    private Point head;
    private int xVelocity;
    private int yVelocity;

    /**
     * The constructor the snake. It takes the initial point, for the head and the Grid
     * that it lives (and dies) in.
     *
     * @param initialPoint The {@link Point} to the put the snake's head on.
     */
    public Snake(Grid grid, Point initialPoint) {
        length = 1;
        points = new LinkedList<>();
        points.add(initialPoint);
        head = initialPoint;
        safe = true;
        this.grid = grid;
        xVelocity = 0;
        yVelocity = 0;
    }

    /**
     * This method is called after food has been consumed. It increases the length of the
     * snake by one.
     *
     * @param point The Point where the food was and the new location for the head.
     */
    private void growTo(Point point) {
        length++;
        checkAndAdd(point);
    }

    /**
     * Called during every update. It gets rid of the oldest point and adds the given point.
     *
     * @param point The new Point to add.
     */
    private void shiftTo(Point point) {
        // The head goes to the new location
        checkAndAdd(point);
        // The last/oldest position is dropped
        points.remove(0);
    }

    /**
     * Checks for an intersection and marks the "safe" flag accordingly.
     *
     * @param point The new Point to move to.
     */
    private void checkAndAdd(Point point) {
        point = grid.wrap(point);
        safe &= !points.contains(point);
        points.add(point);
        head = point;
    }

    /**
     * @return The points occupied by the snake.
     */
    public List<Point> getPoints() {
        return points;
    }

    /**
     * @return {@code true} if the Snake hasn't run into itself yet.
     */
    public boolean isSafe() {
        return safe || length == 1;
    }

    /**
     * @return The location of the head of the Snake.
     */
    public Point getHead() {
        return head;
    }

    private boolean isStill() {
        return xVelocity == 0 & yVelocity == 0;
    }

    /**
     * Make the snake move one square in it's current direction.
     */
    public void move() {
        if (!isStill()) {
            shiftTo(head.translate(xVelocity, yVelocity));
        }
    }

    /**
     * Make the snake extend/grow to the square where it's headed.
     */
    public void extend() {
        if (!isStill()) {
            growTo(head.translate(xVelocity, yVelocity));
        }
    }

    public void setUp() {
        if (yVelocity == 1 && length > 1) return;
        xVelocity = 0;
        yVelocity = -1;
    }

    public void setDown() {
        if (yVelocity == -1 && length > 1) return;
        xVelocity = 0;
        yVelocity = 1;
    }

    public void setLeft() {
        if (xVelocity == 1 && length > 1) return;
        xVelocity = -1;
        yVelocity = 0;
    }

    public void setRight() {
        if (xVelocity == -1 && length > 1) return;
        xVelocity = 1;
        yVelocity = 0;
    }
}

Painter.java

package astrobleme.gui;

import astrobleme.logic.Food;
import astrobleme.logic.Grid;
import astrobleme.logic.Point;
import astrobleme.logic.Snake;
import javafx.scene.canvas.GraphicsContext;
import javafx.scene.paint.Color;

import static astrobleme.logic.Grid.SIZE;

/**
 * @author Subhomoy Haldar
 * @version 2016.12.17
 */
public class Painter {


    public static void paint(Grid grid, GraphicsContext gc) {
        gc.setFill(Grid.COLOR);
        gc.fillRect(0, 0, grid.getWidth(), grid.getHeight());

        // Now the Food
        gc.setFill(Food.COLOR);
        paintPoint(grid.getFood().getPoint(), gc);

        // Now the snake
        Snake snake = grid.getSnake();
        gc.setFill(Snake.COLOR);
        snake.getPoints().forEach(point -> paintPoint(point, gc));
        if (!snake.isSafe()) {
            gc.setFill(Snake.DEAD);
            paintPoint(snake.getHead(), gc);
        }

        // The score
        gc.setFill(Color.BEIGE);
        gc.fillText("Score : " + 100 * snake.getPoints().size(), 10, 490);
    }

    private static void paintPoint(Point point, GraphicsContext gc) {
        gc.fillRect(point.getX() * SIZE, point.getY() * SIZE, SIZE, SIZE);
    }

    public static void paintResetMessage(GraphicsContext gc) {
        gc.setFill(Color.AQUAMARINE);
        gc.fillText("Hit RETURN to reset.", 10, 10);
    }
}

Main.java

package astrobleme.gui;

import astrobleme.logic.GameLoop;
import astrobleme.logic.Grid;
import astrobleme.logic.Snake;
import javafx.application.Application;
import javafx.scene.Scene;
import javafx.scene.canvas.Canvas;
import javafx.scene.canvas.GraphicsContext;
import javafx.scene.layout.StackPane;
import javafx.stage.Stage;

/**
 * This is the place where the threads are dispatched.
 *
 * @author Subhomoy Haldar
 * @version 2016.12.17
 */
public class Main extends Application {

    private static final int WIDTH = 500;
    private static final int HEIGHT = 500;

    private GameLoop loop;
    private Grid grid;
    private GraphicsContext context;

    public static void main(String[] args) {
        Application.launch(args);
    }

    @Override
    public void start(Stage primaryStage) throws Exception {
        StackPane root = new StackPane();
        Canvas canvas = new Canvas(WIDTH, HEIGHT);
        context = canvas.getGraphicsContext2D();

        canvas.setFocusTraversable(true);
        canvas.setOnKeyPressed(e -> {
            Snake snake = grid.getSnake();
            if (loop.isKeyPressed()) {
                return;
            }
            switch (e.getCode()) {
                case UP:
                    snake.setUp();
                    break;
                case DOWN:
                    snake.setDown();
                    break;
                case LEFT:
                    snake.setLeft();
                    break;
                case RIGHT:
                    snake.setRight();
                    break;
                case ENTER:
                    if (loop.isPaused()) {
                        reset();
                        (new Thread(loop)).start();
                    }
            }
        });

        reset();

        root.getChildren().add(canvas);

        Scene scene = new Scene(root);

        primaryStage.setResizable(false);
        primaryStage.setTitle("Snake");
        primaryStage.setOnCloseRequest(e -> System.exit(0));
        primaryStage.setScene(scene);
        primaryStage.show();

        (new Thread(loop)).start();
    }

    private void reset() {
        grid = new Grid(WIDTH, HEIGHT);
        loop = new GameLoop(grid, context);
        Painter.paint(grid, context);
    }
}

I'm fairly happy with the current state. However, there are few things I need some advice on in particular (besides general critique on all aspects):

  1. The way the thread is dispatched.
  2. Sometimes, when I press two keys too quickly, the snake turns around and bites itself. How do I stop that?
  3. Any way to improve the game. (Like, general recommendations)
\$\endgroup\$
7
\$\begingroup\$

A few recommendations per class:

Food.java

What's the point in this class? It just's just a Point with a constant colour. I would just have a FOOD_COLOUR constant in your Painter and then you can scrap this.

GameLoop.java

frameRate and interval should be final. Actually you don't need frameRate at all because you don't use it for anything other than the calculation of interval. Additionally, your setFrameRate(int frameRate) function is broken because it doesn't update the interval. You don't call it anyway, so why not just delete it? Same with the getter.

Grid.java

I don't like that your constructor is creating Food and Snake objects. You should be passing these in. Annoying to ensure that they are positioned within the grid, though - you would probably have to throw an exception.

Grid is being used too much as a "dumb" bag of data. It has too many getters. Try to encapsulate as much as possible. getCols and getRows are again unused so you should just get rid of them. Rather than expose all your properties via getWidth, getHeight, getSnake and getFood, why not have a method which will paint a grid to a GraphicsContext:

public class Grid
{
   ...
   void paint(GraphicsContext gc)
   {
       gc.setFill(Grid.COLOR);
       gc.fillRect(0, 0, rows * SIZE, cols * SIZE);
       //etc...
   }
}

Point.java

A solid, immutable Point class which does everything you need it to. Very good.

Snake.java

If your Point deserved a class, xVelocity and yVelocity should absolutely be a class as well. If you renamed Point to represent a 2D Euclidean vector - Vec2D etc. - you could use the same class for both position and velocity without causing confusion.

Why are you bitwise and-ing here?

safe &= !points.contains(point);

In a bitwise and you always evaluate both sides, so you are always doing points.contains(point) even if you don't need to.

Painter.java

If you ever find yourself writing...

// Now the Food
...
// Now the snake
...
// The score

... then you should create a separate function. It's just as obvious what's going on, but more testable and maintainable.

paintFood(...)
{
   gc.setFill(Food.COLOR);
   paintPoint(grid.getFood().getPoint(), gc);
}

paintSnake(...)
{
   //whatever
}

Main.java

I would refactor your lambda to a separate class. It's too large.

canvas.setOnKeyPressed(e -> {

would become

setOnKeyPressed(new KeyHandler());
...
public class KeyHandler implements EventHandler<KeyEvent>
{
    void handle(KeyEvent event)
    {
         //do stuff
    }
}

If you're looking to change the key bindings in the future, where is it more likely you'll look first: Main.java or KeyHandler.java ?

Your start(Stage primaryStage) function suffers from the same "sectioning" as your Painter, except without the comments. Move the sections you have grouped together with whitespace to separate functions that better explain what they do.


Overall, I was quite impressed. It was very easy to follow and everything was named sensibly.

| improve this answer | |
\$\endgroup\$
  • \$\begingroup\$ The Food class is supposed to handle the "regular" food and the "bonus" timed food (which I would implement later). I thought making a separate class would make it greatly simple later on. \$\endgroup\$ – Hungry Blue Dev Jan 6 '17 at 12:22
  • \$\begingroup\$ I've intentionally kept the frame rate separate because it can be used to control the difficulty of the game. The interval was meant to keep the update interval as regular as possible. \$\endgroup\$ – Hungry Blue Dev Jan 6 '17 at 12:39
  • \$\begingroup\$ What about the game itself? Did you try it? Did you encounter any glitches? \$\endgroup\$ – Hungry Blue Dev Jan 6 '17 at 13:42
  • \$\begingroup\$ Try to avoid adding features that you may or may not need in the future. It often feels like you'll be saving yourself time later on but that's rarely true. I didn't actually play it, no \$\endgroup\$ – Michael Jan 6 '17 at 13:57
2
\$\begingroup\$

Sometimes, when I press two keys too quickly, the snake turns around and bites itself. How do I stop that?

That happens because the Snake changes directions twice before moving. You already have a variable called keyPressed in GameLoop.java. You need to set it to false after Painter update. And you need to set it to true on Main.java like this:

canvas.setOnKeyPressed(e -> {
        Snake snake = grid.getSnake();
        if (loop.isKeyPressed()) {
            return;
        }
        loop.setKeyPressed();
        ...

No synchronized block is necessary since there's only one thread for the key pressing.

Neat code, thanks for sharing it.

| improve this answer | |
\$\endgroup\$

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service, privacy policy and cookie policy

Not the answer you're looking for? Browse other questions tagged or ask your own question.