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.
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):
- The way the thread is dispatched.
- Sometimes, when I press two keys too quickly, the snake turns around and bites itself. How do I stop that?
- Any way to improve the game. (Like, general recommendations)