From 70bca27d70ea70bf1c8984c2982668a0a70be9ab Mon Sep 17 00:00:00 2001 From: Miles Nash Date: Sat, 23 May 2026 15:57:56 +0100 Subject: [PATCH 01/21] refactor: add INITIAL_LENGTH and remove head from Snake class --- src/main/java/Snake.java | 17 +++++++---------- 1 file changed, 7 insertions(+), 10 deletions(-) diff --git a/src/main/java/Snake.java b/src/main/java/Snake.java index 1d99844..ee3550b 100644 --- a/src/main/java/Snake.java +++ b/src/main/java/Snake.java @@ -4,15 +4,14 @@ /** The snake, constructed in terms of the board size. */ public class Snake { - private final Point head; + private static final int INITIAL_LENGTH = 3; private final LinkedList body = new LinkedList<>(); public Snake(int width, int height) { final int x = new Random().nextInt(width); - final int y = new Random().nextInt(height - 3); + final int y = new Random().nextInt(height - INITIAL_LENGTH); - head = new Point(x, y); - body.add(head); + body.add(new Point(x, y)); body.add(new Point(x, y + 1)); body.add(new Point(x, y + 2)); } @@ -22,19 +21,17 @@ public Snake(int width, int height) { * Intended for tests and deterministic setups. */ public static Snake createFixed(int headX, int headY) { - Point head = new Point(headX, headY); - return new Snake(head, new Point(headX, headY + 1), new Point(headX, headY + 2)); + return new Snake(new Point(headX, headY), new Point(headX, headY + 1), new Point(headX, headY + 2)); } private Snake(Point head, Point seg2, Point seg3) { - this.head = head; body.add(head); body.add(seg2); body.add(seg3); } public Point getHead() { - return head; + return body.getFirst(); } public LinkedList getBody() { @@ -48,7 +45,7 @@ public boolean containsPoint(Point point) { /** Determines if the snake's head is touching any other part of the body. */ public boolean eatingSelf() { for (int i = 1; i < body.size(); i++) { - if (head.equals(body.get(i))) { + if (body.getFirst().equals(body.get(i))) { return true; } } @@ -71,6 +68,6 @@ public void changePosition(boolean growing) { /** Returns the difference in size of the body from the original length. */ public int growth() { - return body.size() - 3; + return body.size() - INITIAL_LENGTH; } } From 7295a635f87c9acda48c0c77e885985582bce575 Mon Sep 17 00:00:00 2001 From: Miles Nash Date: Sat, 23 May 2026 16:03:24 +0100 Subject: [PATCH 02/21] refactor: encapsulate Snake state to own movement, protect body list --- src/main/java/GameEngine.java | 9 ++++----- src/main/java/Snake.java | 21 ++++++++++++++------- 2 files changed, 18 insertions(+), 12 deletions(-) diff --git a/src/main/java/GameEngine.java b/src/main/java/GameEngine.java index 6ade72e..ee0c14f 100644 --- a/src/main/java/GameEngine.java +++ b/src/main/java/GameEngine.java @@ -41,12 +41,11 @@ public void tick(GameState state) { } final Snake snake = state.getSnake(); - final Point nextHead = nextHeadPosition(snake.getHead(), state.getNextDirection()); - final boolean growing = nextHead.equals(state.getLemon()); + final Direction direction = state.getNextDirection(); + final boolean growing = nextHeadPosition(snake.getHead(), direction).equals(state.getLemon()); - state.setDirection(state.getNextDirection()); - snake.changePosition(growing); - snake.getHead().setLocation(nextHead); + state.setDirection(direction); + snake.move(direction, BoardConfig.PIXEL_WIDTH, BoardConfig.PIXEL_HEIGHT, growing); if (snake.eatingSelf()) { state.setMode(GameMode.GAME_OVER); diff --git a/src/main/java/Snake.java b/src/main/java/Snake.java index ee3550b..ed8c114 100644 --- a/src/main/java/Snake.java +++ b/src/main/java/Snake.java @@ -1,5 +1,7 @@ import java.awt.*; +import java.util.Collections; import java.util.LinkedList; +import java.util.List; import java.util.Random; /** The snake, constructed in terms of the board size. */ @@ -34,8 +36,8 @@ public Point getHead() { return body.getFirst(); } - public LinkedList getBody() { - return body; + public List getBody() { + return Collections.unmodifiableList(body); } public boolean containsPoint(Point point) { @@ -52,11 +54,8 @@ public boolean eatingSelf() { return false; } - /** - * Moves each point of the snake's body to the preceding point's location. Adds a new point to the - * body if the snake is growing. - */ - public void changePosition(boolean growing) { + /** Moves the snake one step in the given direction, wrapping around the board. Grows if specified. */ + public void move(Direction direction, int boardWidth, int boardHeight, boolean growing) { Point tail = new Point(body.getLast()); for (int i = body.size() - 1; i > 0; i--) { body.get(i).setLocation(body.get(i - 1)); @@ -64,6 +63,14 @@ public void changePosition(boolean growing) { if (growing) { body.add(tail); } + + Point head = body.getFirst(); + switch (direction) { + case DOWN -> head.setLocation(head.x, (head.y + 1) % boardHeight); + case UP -> head.setLocation(head.x, (head.y - 1 + boardHeight) % boardHeight); + case LEFT -> head.setLocation((head.x - 1 + boardWidth) % boardWidth, head.y); + case RIGHT -> head.setLocation((head.x + 1) % boardWidth, head.y); + } } /** Returns the difference in size of the body from the original length. */ From a720fbd8a67d4417c89a8de1f5fa3f3873a08e9a Mon Sep 17 00:00:00 2001 From: Miles Nash Date: Sat, 23 May 2026 16:07:39 +0100 Subject: [PATCH 03/21] refactor: eliminate duplicated direction logic --- src/main/java/GameEngine.java | 17 ++--------------- src/main/java/Snake.java | 21 +++++++++++++-------- 2 files changed, 15 insertions(+), 23 deletions(-) diff --git a/src/main/java/GameEngine.java b/src/main/java/GameEngine.java index ee0c14f..d0828e7 100644 --- a/src/main/java/GameEngine.java +++ b/src/main/java/GameEngine.java @@ -42,7 +42,8 @@ public void tick(GameState state) { final Snake snake = state.getSnake(); final Direction direction = state.getNextDirection(); - final boolean growing = nextHeadPosition(snake.getHead(), direction).equals(state.getLemon()); + final boolean growing = snake.nextHead(direction, BoardConfig.PIXEL_WIDTH, BoardConfig.PIXEL_HEIGHT) + .equals(state.getLemon()); state.setDirection(direction); snake.move(direction, BoardConfig.PIXEL_WIDTH, BoardConfig.PIXEL_HEIGHT, growing); @@ -57,20 +58,6 @@ public void tick(GameState state) { } } - private Point nextHeadPosition(Point head, Direction direction) { - int nextX = head.x; - int nextY = head.y; - - switch (direction) { - case DOWN -> nextY = (nextY + 1) % BoardConfig.PIXEL_HEIGHT; - case UP -> nextY = (nextY - 1 + BoardConfig.PIXEL_HEIGHT) % BoardConfig.PIXEL_HEIGHT; - case LEFT -> nextX = (nextX - 1 + BoardConfig.PIXEL_WIDTH) % BoardConfig.PIXEL_WIDTH; - case RIGHT -> nextX = (nextX + 1) % BoardConfig.PIXEL_WIDTH; - } - - return new Point(nextX, nextY); - } - private void spawnFood(GameState state) { final Snake snake = state.getSnake(); while (true) { diff --git a/src/main/java/Snake.java b/src/main/java/Snake.java index ed8c114..26e601f 100644 --- a/src/main/java/Snake.java +++ b/src/main/java/Snake.java @@ -54,8 +54,20 @@ public boolean eatingSelf() { return false; } + /** Returns where the head would be after one step, without mutating state. */ + public Point nextHead(Direction direction, int boardWidth, int boardHeight) { + Point head = body.getFirst(); + return switch (direction) { + case DOWN -> new Point(head.x, (head.y + 1) % boardHeight); + case UP -> new Point(head.x, (head.y - 1 + boardHeight) % boardHeight); + case LEFT -> new Point((head.x - 1 + boardWidth) % boardWidth, head.y); + case RIGHT -> new Point((head.x + 1) % boardWidth, head.y); + }; + } + /** Moves the snake one step in the given direction, wrapping around the board. Grows if specified. */ public void move(Direction direction, int boardWidth, int boardHeight, boolean growing) { + Point next = nextHead(direction, boardWidth, boardHeight); Point tail = new Point(body.getLast()); for (int i = body.size() - 1; i > 0; i--) { body.get(i).setLocation(body.get(i - 1)); @@ -63,14 +75,7 @@ public void move(Direction direction, int boardWidth, int boardHeight, boolean g if (growing) { body.add(tail); } - - Point head = body.getFirst(); - switch (direction) { - case DOWN -> head.setLocation(head.x, (head.y + 1) % boardHeight); - case UP -> head.setLocation(head.x, (head.y - 1 + boardHeight) % boardHeight); - case LEFT -> head.setLocation((head.x - 1 + boardWidth) % boardWidth, head.y); - case RIGHT -> head.setLocation((head.x + 1) % boardWidth, head.y); - } + body.getFirst().setLocation(next); } /** Returns the difference in size of the body from the original length. */ From 46685704e6471deb60386ee4a6cfa182027bdd4b Mon Sep 17 00:00:00 2001 From: Miles Nash Date: Sat, 23 May 2026 16:08:43 +0100 Subject: [PATCH 04/21] refactor: deduplicate BoardRenderer paint methods --- src/main/java/BoardRenderer.java | 27 ++++++++------------------- 1 file changed, 8 insertions(+), 19 deletions(-) diff --git a/src/main/java/BoardRenderer.java b/src/main/java/BoardRenderer.java index 0897e30..cc3fd2d 100644 --- a/src/main/java/BoardRenderer.java +++ b/src/main/java/BoardRenderer.java @@ -14,22 +14,7 @@ public void paint(Graphics g, GameState state) { } private void paintPause(Graphics g, GameState state) { - final Graphics2D g2D = (Graphics2D) g; - - g2D.setPaint(Color.lightGray); - g2D.fillRect(0, BoardConfig.BOARD_HEIGHT, BoardConfig.BOARD_WIDTH, BoardConfig.HUD_ROWS * BoardConfig.PIXEL_SIZE); - - g2D.setPaint(Color.black); - translatePoints(g2D, state.getSnake().growth()); - - g2D.setPaint(Color.gray); - Point lemon = state.getLemon(); - g2D.fillRect(lemon.x * BoardConfig.PIXEL_SIZE, lemon.y * BoardConfig.PIXEL_SIZE, BoardConfig.BORDERED_PIXEL_SIZE, BoardConfig.BORDERED_PIXEL_SIZE); - - g2D.setPaint(Color.darkGray); - for (Point point : state.getSnake().getBody()) { - g2D.fillRect(point.x * BoardConfig.PIXEL_SIZE, point.y * BoardConfig.PIXEL_SIZE, BoardConfig.BORDERED_PIXEL_SIZE, BoardConfig.BORDERED_PIXEL_SIZE); - } + paintGameContent(g, state, Color.lightGray, Color.gray, Color.darkGray); g.setColor(Color.yellow); paintTitles(g, "PAUSED", "-PRESS P TO CONTINUE-"); @@ -60,19 +45,23 @@ private void paintTitles(Graphics g, String titleOutput, String captionOutput) { } private void paintPixels(Graphics g, GameState state) { + paintGameContent(g, state, Color.white, Color.yellow, Color.green); + } + + private void paintGameContent(Graphics g, GameState state, Color hudColor, Color lemonColor, Color snakeColor) { final Graphics2D g2D = (Graphics2D) g; - g2D.setPaint(Color.white); + g2D.setPaint(hudColor); g2D.fillRect(0, BoardConfig.BOARD_HEIGHT, BoardConfig.BOARD_WIDTH, BoardConfig.HUD_ROWS * BoardConfig.PIXEL_SIZE); g2D.setPaint(Color.black); translatePoints(g2D, state.getSnake().growth()); Point lemon = state.getLemon(); - g2D.setPaint(Color.yellow); + g2D.setPaint(lemonColor); g2D.fillRect(lemon.x * BoardConfig.PIXEL_SIZE, lemon.y * BoardConfig.PIXEL_SIZE, BoardConfig.BORDERED_PIXEL_SIZE, BoardConfig.BORDERED_PIXEL_SIZE); - g2D.setPaint(Color.green); + g2D.setPaint(snakeColor); for (Point point : state.getSnake().getBody()) { g2D.fillRect(point.x * BoardConfig.PIXEL_SIZE, point.y * BoardConfig.PIXEL_SIZE, BoardConfig.BORDERED_PIXEL_SIZE, BoardConfig.BORDERED_PIXEL_SIZE); } From ddea9d1f372375654d09a51a6cd0d75e93eadef4 Mon Sep 17 00:00:00 2001 From: Miles Nash Date: Sat, 23 May 2026 16:13:19 +0100 Subject: [PATCH 05/21] refacotr: make opposite logic cleaner in Direction.java --- src/main/java/Direction.java | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/src/main/java/Direction.java b/src/main/java/Direction.java index ddcc392..b3e5cc4 100644 --- a/src/main/java/Direction.java +++ b/src/main/java/Direction.java @@ -4,10 +4,16 @@ public enum Direction { LEFT, RIGHT; + public Direction opposite() { + return switch (this) { + case DOWN -> UP; + case UP -> DOWN; + case LEFT -> RIGHT; + case RIGHT -> LEFT; + }; + } + public boolean isOpposite(Direction other) { - return (this == DOWN && other == UP) - || (this == UP && other == DOWN) - || (this == LEFT && other == RIGHT) - || (this == RIGHT && other == LEFT); + return this.opposite() == other; } } From 7c4718610743049cf02f04d2ed9a243127103de2 Mon Sep 17 00:00:00 2001 From: Miles Nash Date: Sat, 23 May 2026 16:14:48 +0100 Subject: [PATCH 06/21] refactor: extract HIGHSCORE_FILE --- src/main/java/Board.java | 2 +- src/main/java/BoardConfig.java | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/src/main/java/Board.java b/src/main/java/Board.java index 968fb3e..f8c612d 100644 --- a/src/main/java/Board.java +++ b/src/main/java/Board.java @@ -10,7 +10,7 @@ public class Board extends JPanel implements ActionListener { private final GameEngine engine = new GameEngine(); private final GameState state = new GameState(); private final BoardRenderer renderer = new BoardRenderer(); - private final HighScoreStore highScoreStore = new FileHighScoreStore("highscore.txt"); + private final HighScoreStore highScoreStore = new FileHighScoreStore(BoardConfig.HIGHSCORE_FILE); private boolean highScoreSaved; /** A board to display the game and deal with user input. */ diff --git a/src/main/java/BoardConfig.java b/src/main/java/BoardConfig.java index cad737f..db42724 100644 --- a/src/main/java/BoardConfig.java +++ b/src/main/java/BoardConfig.java @@ -8,6 +8,7 @@ public final class BoardConfig { public static final int PIXEL_WIDTH = BOARD_WIDTH / PIXEL_SIZE; public static final int PIXEL_HEIGHT = BOARD_HEIGHT / PIXEL_SIZE; public static final int BORDERED_PIXEL_SIZE = PIXEL_SIZE - 1; + public static final String HIGHSCORE_FILE = "highscore.txt"; private BoardConfig() { } From fd91cc71eca5d03e47accfa609f5f2edf7025982 Mon Sep 17 00:00:00 2001 From: Miles Nash Date: Sat, 23 May 2026 16:15:53 +0100 Subject: [PATCH 07/21] refactor: make TITLE_FONT / CAPTION_FONT constants --- src/main/java/BoardRenderer.java | 19 +++++++++---------- 1 file changed, 9 insertions(+), 10 deletions(-) diff --git a/src/main/java/BoardRenderer.java b/src/main/java/BoardRenderer.java index cc3fd2d..5c8139c 100644 --- a/src/main/java/BoardRenderer.java +++ b/src/main/java/BoardRenderer.java @@ -1,6 +1,9 @@ import java.awt.*; public class BoardRenderer { + private static final Font TITLE_FONT = new Font("Arial", Font.BOLD, 64); + private static final Font CAPTION_FONT = new Font("Courier New", Font.BOLD, 24); + public void paint(Graphics g, GameState state) { if (state.getMode() == GameMode.GAME_OVER) { paintGameOver(g, state); @@ -21,27 +24,23 @@ private void paintPause(Graphics g, GameState state) { } private void paintGameOver(Graphics g, GameState state) { - final Font captionFont = new Font("Courier New", Font.BOLD, 24); final String scoreOutput = "SCORE: " + state.getSnake().growth(); final String highScoreOutput = "HIGH SCORE: " + state.getHighScore(); g.setColor(Color.red); paintTitles(g, "GAME OVER", "-PRESS R TO RESTART-"); - g.drawString(highScoreOutput, (BoardConfig.BOARD_WIDTH - g.getFontMetrics(captionFont).stringWidth(highScoreOutput)) / 2, BoardConfig.COMPONENT_HEIGHT / 4); + g.drawString(highScoreOutput, (BoardConfig.BOARD_WIDTH - g.getFontMetrics(CAPTION_FONT).stringWidth(highScoreOutput)) / 2, BoardConfig.COMPONENT_HEIGHT / 4); g.setColor(Color.yellow); - g.drawString(scoreOutput, (BoardConfig.BOARD_WIDTH - g.getFontMetrics(captionFont).stringWidth(scoreOutput)) / 2, BoardConfig.COMPONENT_HEIGHT / 8); + g.drawString(scoreOutput, (BoardConfig.BOARD_WIDTH - g.getFontMetrics(CAPTION_FONT).stringWidth(scoreOutput)) / 2, BoardConfig.COMPONENT_HEIGHT / 8); } private void paintTitles(Graphics g, String titleOutput, String captionOutput) { - final Font titleFont = new Font("Arial", Font.BOLD, 64); - final Font captionFont = new Font("Courier New", Font.BOLD, 24); - - g.setFont(titleFont); - g.drawString(titleOutput, (BoardConfig.BOARD_WIDTH - g.getFontMetrics(titleFont).stringWidth(titleOutput)) / 2, BoardConfig.COMPONENT_HEIGHT / 2); + g.setFont(TITLE_FONT); + g.drawString(titleOutput, (BoardConfig.BOARD_WIDTH - g.getFontMetrics(TITLE_FONT).stringWidth(titleOutput)) / 2, BoardConfig.COMPONENT_HEIGHT / 2); g.setColor(Color.white); - g.setFont(captionFont); - g.drawString(captionOutput, (BoardConfig.BOARD_WIDTH - g.getFontMetrics(captionFont).stringWidth(captionOutput)) / 2, BoardConfig.COMPONENT_HEIGHT * 5 / 8); + g.setFont(CAPTION_FONT); + g.drawString(captionOutput, (BoardConfig.BOARD_WIDTH - g.getFontMetrics(CAPTION_FONT).stringWidth(captionOutput)) / 2, BoardConfig.COMPONENT_HEIGHT * 5 / 8); } private void paintPixels(Graphics g, GameState state) { From ab8453653c0e89d1ecec94ca1fd43450c3bf154d Mon Sep 17 00:00:00 2001 From: Miles Nash Date: Sat, 23 May 2026 16:23:03 +0100 Subject: [PATCH 08/21] refactor: simplify Snake movement --- src/main/java/Snake.java | 11 +++-------- 1 file changed, 3 insertions(+), 8 deletions(-) diff --git a/src/main/java/Snake.java b/src/main/java/Snake.java index 26e601f..8d0622e 100644 --- a/src/main/java/Snake.java +++ b/src/main/java/Snake.java @@ -67,15 +67,10 @@ public Point nextHead(Direction direction, int boardWidth, int boardHeight) { /** Moves the snake one step in the given direction, wrapping around the board. Grows if specified. */ public void move(Direction direction, int boardWidth, int boardHeight, boolean growing) { - Point next = nextHead(direction, boardWidth, boardHeight); - Point tail = new Point(body.getLast()); - for (int i = body.size() - 1; i > 0; i--) { - body.get(i).setLocation(body.get(i - 1)); + body.addFirst(nextHead(direction, boardWidth, boardHeight)); + if (!growing) { + body.removeLast(); } - if (growing) { - body.add(tail); - } - body.getFirst().setLocation(next); } /** Returns the difference in size of the body from the original length. */ From d85ef065da21551bb6d12e1991aa9b625397b94a Mon Sep 17 00:00:00 2001 From: Miles Nash Date: Sat, 23 May 2026 16:24:43 +0100 Subject: [PATCH 09/21] refactor: eliminate magic floating-point multipliers from BoardRenderer --- src/main/java/BoardRenderer.java | 16 ++++++---------- 1 file changed, 6 insertions(+), 10 deletions(-) diff --git a/src/main/java/BoardRenderer.java b/src/main/java/BoardRenderer.java index 5c8139c..8540ed8 100644 --- a/src/main/java/BoardRenderer.java +++ b/src/main/java/BoardRenderer.java @@ -67,16 +67,12 @@ private void paintGameContent(Graphics g, GameState state, Color hudColor, Color } private void translatePoints(Graphics2D g2D, int points) { - final int pointsRemainder = points % 10; - if (points > 99) { - paintDigit(PixelDigit.values()[points / 100], g2D, (int) (BoardConfig.BOARD_WIDTH / 2 - 5.5 * BoardConfig.PIXEL_SIZE)); - paintDigit(PixelDigit.values()[(points / 10) % 10], g2D, (int) (BoardConfig.BOARD_WIDTH / 2 - BoardConfig.PIXEL_SIZE * 1.5)); - paintDigit(PixelDigit.values()[pointsRemainder], g2D, (int) (BoardConfig.BOARD_WIDTH / 2 + 2.5 * BoardConfig.PIXEL_SIZE)); - } else if (points > 9) { - paintDigit(PixelDigit.values()[points / 10], g2D, (int) (BoardConfig.BOARD_WIDTH / 2 - 3.5 * BoardConfig.PIXEL_SIZE)); - paintDigit(PixelDigit.values()[pointsRemainder], g2D, (int) (BoardConfig.BOARD_WIDTH / 2 + 0.5 * BoardConfig.PIXEL_SIZE)); - } else { - paintDigit(PixelDigit.values()[pointsRemainder], g2D, (int) (BoardConfig.BOARD_WIDTH / 2 - BoardConfig.PIXEL_SIZE * 1.5)); + String digits = Integer.toString(points); + int digitWidth = 4 * BoardConfig.PIXEL_SIZE; + int totalWidth = digits.length() * digitWidth - BoardConfig.PIXEL_SIZE; + int startX = (BoardConfig.BOARD_WIDTH - totalWidth) / 2; + for (int i = 0; i < digits.length(); i++) { + paintDigit(PixelDigit.values()[digits.charAt(i) - '0'], g2D, startX + i * digitWidth); } } From 8adb1578a5e818b1cd41902a71f3321428f7d403 Mon Sep 17 00:00:00 2001 From: Miles Nash Date: Sat, 23 May 2026 16:25:56 +0100 Subject: [PATCH 10/21] refactor: pass random through Snake constructor --- src/main/java/GameEngine.java | 2 +- src/main/java/Snake.java | 7 +++---- 2 files changed, 4 insertions(+), 5 deletions(-) diff --git a/src/main/java/GameEngine.java b/src/main/java/GameEngine.java index d0828e7..f7ba20c 100644 --- a/src/main/java/GameEngine.java +++ b/src/main/java/GameEngine.java @@ -14,7 +14,7 @@ public GameEngine(Random random) { } public void reset(GameState state) { - state.setSnake(new Snake(BoardConfig.PIXEL_WIDTH, BoardConfig.PIXEL_HEIGHT)); + state.setSnake(new Snake(random, BoardConfig.PIXEL_WIDTH, BoardConfig.PIXEL_HEIGHT)); state.setDirection(Direction.UP); state.setNextDirection(Direction.UP); state.setMode(GameMode.RUNNING); diff --git a/src/main/java/Snake.java b/src/main/java/Snake.java index 8d0622e..f7431e8 100644 --- a/src/main/java/Snake.java +++ b/src/main/java/Snake.java @@ -2,16 +2,15 @@ import java.util.Collections; import java.util.LinkedList; import java.util.List; -import java.util.Random; /** The snake, constructed in terms of the board size. */ public class Snake { private static final int INITIAL_LENGTH = 3; private final LinkedList body = new LinkedList<>(); - public Snake(int width, int height) { - final int x = new Random().nextInt(width); - final int y = new Random().nextInt(height - INITIAL_LENGTH); + public Snake(Random random, int width, int height) { + final int x = random.nextInt(width); + final int y = random.nextInt(height - INITIAL_LENGTH); body.add(new Point(x, y)); body.add(new Point(x, y + 1)); From 5b04166f50b01e234edfe33c62df762b87636744 Mon Sep 17 00:00:00 2001 From: Miles Nash Date: Sat, 23 May 2026 16:29:12 +0100 Subject: [PATCH 11/21] refactor: encapsulate NextDirection in GameEngine --- src/main/java/GameEngine.java | 16 ++++++++++------ src/main/java/GameState.java | 9 --------- src/test/java/GameEngineTest.java | 11 ++++------- 3 files changed, 14 insertions(+), 22 deletions(-) diff --git a/src/main/java/GameEngine.java b/src/main/java/GameEngine.java index f7ba20c..df391d6 100644 --- a/src/main/java/GameEngine.java +++ b/src/main/java/GameEngine.java @@ -4,6 +4,7 @@ public class GameEngine { private final Random random; + private Direction nextDirection = Direction.UP; public GameEngine() { this(new Random()); @@ -13,17 +14,21 @@ public GameEngine(Random random) { this.random = Objects.requireNonNull(random, "random"); } + public Direction getNextDirection() { + return nextDirection; + } + public void reset(GameState state) { state.setSnake(new Snake(random, BoardConfig.PIXEL_WIDTH, BoardConfig.PIXEL_HEIGHT)); state.setDirection(Direction.UP); - state.setNextDirection(Direction.UP); + nextDirection = Direction.UP; state.setMode(GameMode.RUNNING); spawnFood(state); } public void requestDirection(GameState state, Direction requested) { if (!state.getDirection().isOpposite(requested)) { - state.setNextDirection(requested); + nextDirection = requested; } } @@ -41,12 +46,11 @@ public void tick(GameState state) { } final Snake snake = state.getSnake(); - final Direction direction = state.getNextDirection(); - final boolean growing = snake.nextHead(direction, BoardConfig.PIXEL_WIDTH, BoardConfig.PIXEL_HEIGHT) + final boolean growing = snake.nextHead(nextDirection, BoardConfig.PIXEL_WIDTH, BoardConfig.PIXEL_HEIGHT) .equals(state.getLemon()); - state.setDirection(direction); - snake.move(direction, BoardConfig.PIXEL_WIDTH, BoardConfig.PIXEL_HEIGHT, growing); + state.setDirection(nextDirection); + snake.move(nextDirection, BoardConfig.PIXEL_WIDTH, BoardConfig.PIXEL_HEIGHT, growing); if (snake.eatingSelf()) { state.setMode(GameMode.GAME_OVER); diff --git a/src/main/java/GameState.java b/src/main/java/GameState.java index 393f34c..b669bf9 100644 --- a/src/main/java/GameState.java +++ b/src/main/java/GameState.java @@ -4,7 +4,6 @@ public class GameState { private Snake snake; private final Point lemon = new Point(); private Direction direction = Direction.UP; - private Direction nextDirection = Direction.UP; private GameMode mode = GameMode.RUNNING; private int highScore = -1; @@ -28,14 +27,6 @@ public void setDirection(Direction direction) { this.direction = direction; } - public Direction getNextDirection() { - return nextDirection; - } - - public void setNextDirection(Direction nextDirection) { - this.nextDirection = nextDirection; - } - public GameMode getMode() { return mode; } diff --git a/src/test/java/GameEngineTest.java b/src/test/java/GameEngineTest.java index 0de27b7..31ef45b 100644 --- a/src/test/java/GameEngineTest.java +++ b/src/test/java/GameEngineTest.java @@ -12,7 +12,6 @@ private static GameState runningState(Snake snake) { GameState state = new GameState(); state.setSnake(snake); state.setDirection(Direction.UP); - state.setNextDirection(Direction.UP); state.setMode(GameMode.RUNNING); return state; } @@ -22,11 +21,10 @@ void requestDirectionBlocksImmediateReverse() { GameEngine engine = new GameEngine(new Random(1L)); GameState state = runningState(Snake.createFixed(5, 5)); state.setDirection(Direction.UP); - state.setNextDirection(Direction.UP); engine.requestDirection(state, Direction.DOWN); - assertEquals(Direction.UP, state.getNextDirection()); + assertEquals(Direction.UP, engine.getNextDirection()); } @Test @@ -34,11 +32,10 @@ void requestDirectionAcceptsNonOppositeTurn() { GameEngine engine = new GameEngine(new Random(1L)); GameState state = runningState(Snake.createFixed(5, 5)); state.setDirection(Direction.UP); - state.setNextDirection(Direction.UP); engine.requestDirection(state, Direction.LEFT); - assertEquals(Direction.LEFT, state.getNextDirection()); + assertEquals(Direction.LEFT, engine.getNextDirection()); } @Test @@ -109,7 +106,7 @@ void tickDetectsSelfCollision() { GameEngine engine = new GameEngine(new Random(1L)); GameState state = runningState(Snake.createFixed(5, 5)); state.setDirection(Direction.DOWN); - state.setNextDirection(Direction.DOWN); + engine.requestDirection(state, Direction.DOWN); state.getLemon().setLocation(0, 0); engine.tick(state); @@ -126,7 +123,7 @@ void resetStartsRunningAndPlacesFoodOffSnake() { assertEquals(GameMode.RUNNING, state.getMode()); assertEquals(Direction.UP, state.getDirection()); - assertEquals(Direction.UP, state.getNextDirection()); + assertEquals(Direction.UP, engine.getNextDirection()); assertEquals(3, state.getSnake().getBody().size()); assertFalse(state.getSnake().containsPoint(state.getLemon())); } From 60b548d18f964c83ab651c9fe6e667aad6325372 Mon Sep 17 00:00:00 2001 From: Miles Nash Date: Sat, 23 May 2026 16:32:14 +0100 Subject: [PATCH 12/21] refactor: add a HashSet to Snake to improve performance --- src/main/java/Snake.java | 37 ++++++++++++++++++++++--------------- 1 file changed, 22 insertions(+), 15 deletions(-) diff --git a/src/main/java/Snake.java b/src/main/java/Snake.java index f7431e8..7149fd6 100644 --- a/src/main/java/Snake.java +++ b/src/main/java/Snake.java @@ -1,20 +1,23 @@ import java.awt.*; import java.util.Collections; +import java.util.HashSet; import java.util.LinkedList; import java.util.List; +import java.util.Set; /** The snake, constructed in terms of the board size. */ public class Snake { private static final int INITIAL_LENGTH = 3; private final LinkedList body = new LinkedList<>(); + private final Set occupied = new HashSet<>(); public Snake(Random random, int width, int height) { final int x = random.nextInt(width); final int y = random.nextInt(height - INITIAL_LENGTH); - body.add(new Point(x, y)); - body.add(new Point(x, y + 1)); - body.add(new Point(x, y + 2)); + addSegment(new Point(x, y)); + addSegment(new Point(x, y + 1)); + addSegment(new Point(x, y + 2)); } /** @@ -26,9 +29,14 @@ public static Snake createFixed(int headX, int headY) { } private Snake(Point head, Point seg2, Point seg3) { - body.add(head); - body.add(seg2); - body.add(seg3); + addSegment(head); + addSegment(seg2); + addSegment(seg3); + } + + private void addSegment(Point p) { + body.add(p); + occupied.add(p); } public Point getHead() { @@ -40,17 +48,14 @@ public List getBody() { } public boolean containsPoint(Point point) { - return body.contains(point); + return occupied.contains(point); } /** Determines if the snake's head is touching any other part of the body. */ public boolean eatingSelf() { - for (int i = 1; i < body.size(); i++) { - if (body.getFirst().equals(body.get(i))) { - return true; - } - } - return false; + // Head occupies a duplicate position in the set only if it overlaps another segment. + // Since the set deduplicates, a collision means the set is smaller than the list. + return occupied.size() < body.size(); } /** Returns where the head would be after one step, without mutating state. */ @@ -66,9 +71,11 @@ public Point nextHead(Direction direction, int boardWidth, int boardHeight) { /** Moves the snake one step in the given direction, wrapping around the board. Grows if specified. */ public void move(Direction direction, int boardWidth, int boardHeight, boolean growing) { - body.addFirst(nextHead(direction, boardWidth, boardHeight)); + Point head = nextHead(direction, boardWidth, boardHeight); + body.addFirst(head); + occupied.add(head); if (!growing) { - body.removeLast(); + occupied.remove(body.removeLast()); } } From 357a384f19aa6598c6f5b08d8685f254fe37ab07 Mon Sep 17 00:00:00 2001 From: Miles Nash Date: Sat, 23 May 2026 16:34:04 +0100 Subject: [PATCH 13/21] refactor: remove excess final keywords --- src/main/java/BoardRenderer.java | 8 ++++---- src/main/java/Game.java | 2 +- src/main/java/GameEngine.java | 8 ++++---- src/main/java/Snake.java | 4 ++-- src/main/java/Window.java | 6 +++--- 5 files changed, 14 insertions(+), 14 deletions(-) diff --git a/src/main/java/BoardRenderer.java b/src/main/java/BoardRenderer.java index 8540ed8..8686d10 100644 --- a/src/main/java/BoardRenderer.java +++ b/src/main/java/BoardRenderer.java @@ -24,8 +24,8 @@ private void paintPause(Graphics g, GameState state) { } private void paintGameOver(Graphics g, GameState state) { - final String scoreOutput = "SCORE: " + state.getSnake().growth(); - final String highScoreOutput = "HIGH SCORE: " + state.getHighScore(); + String scoreOutput = "SCORE: " + state.getSnake().growth(); + String highScoreOutput = "HIGH SCORE: " + state.getHighScore(); g.setColor(Color.red); paintTitles(g, "GAME OVER", "-PRESS R TO RESTART-"); @@ -48,7 +48,7 @@ private void paintPixels(Graphics g, GameState state) { } private void paintGameContent(Graphics g, GameState state, Color hudColor, Color lemonColor, Color snakeColor) { - final Graphics2D g2D = (Graphics2D) g; + Graphics2D g2D = (Graphics2D) g; g2D.setPaint(hudColor); g2D.fillRect(0, BoardConfig.BOARD_HEIGHT, BoardConfig.BOARD_WIDTH, BoardConfig.HUD_ROWS * BoardConfig.PIXEL_SIZE); @@ -77,7 +77,7 @@ private void translatePoints(Graphics2D g2D, int points) { } private void paintDigit(PixelDigit val, Graphics2D g2D, int startX) { - final boolean[][] graphic = val.graphics; + boolean[][] graphic = val.graphics; for (int x = 0; x < 3; x++) { for (int y = 0; y < 5; y++) { if (graphic[y][x]) { diff --git a/src/main/java/Game.java b/src/main/java/Game.java index 680a562..2c3e92c 100644 --- a/src/main/java/Game.java +++ b/src/main/java/Game.java @@ -4,7 +4,7 @@ public class Game { public static void main(String[] args) { SwingUtilities.invokeLater(() -> { - final JFrame window = new Window(); + JFrame window = new Window(); window.setVisible(true); }); } diff --git a/src/main/java/GameEngine.java b/src/main/java/GameEngine.java index df391d6..38352f5 100644 --- a/src/main/java/GameEngine.java +++ b/src/main/java/GameEngine.java @@ -45,8 +45,8 @@ public void tick(GameState state) { return; } - final Snake snake = state.getSnake(); - final boolean growing = snake.nextHead(nextDirection, BoardConfig.PIXEL_WIDTH, BoardConfig.PIXEL_HEIGHT) + Snake snake = state.getSnake(); + boolean growing = snake.nextHead(nextDirection, BoardConfig.PIXEL_WIDTH, BoardConfig.PIXEL_HEIGHT) .equals(state.getLemon()); state.setDirection(nextDirection); @@ -63,9 +63,9 @@ public void tick(GameState state) { } private void spawnFood(GameState state) { - final Snake snake = state.getSnake(); + Snake snake = state.getSnake(); while (true) { - final Point candidate = new Point( + Point candidate = new Point( random.nextInt(BoardConfig.PIXEL_WIDTH), random.nextInt(BoardConfig.PIXEL_HEIGHT) ); diff --git a/src/main/java/Snake.java b/src/main/java/Snake.java index 7149fd6..54f17ea 100644 --- a/src/main/java/Snake.java +++ b/src/main/java/Snake.java @@ -12,8 +12,8 @@ public class Snake { private final Set occupied = new HashSet<>(); public Snake(Random random, int width, int height) { - final int x = random.nextInt(width); - final int y = random.nextInt(height - INITIAL_LENGTH); + int x = random.nextInt(width); + int y = random.nextInt(height - INITIAL_LENGTH); addSegment(new Point(x, y)); addSegment(new Point(x, y + 1)); diff --git a/src/main/java/Window.java b/src/main/java/Window.java index 5eeaad9..1b60191 100644 --- a/src/main/java/Window.java +++ b/src/main/java/Window.java @@ -17,13 +17,13 @@ public Window() { } private void setIcon() { - final Toolkit toolkit = Toolkit.getDefaultToolkit(); - final URL iconUrl = getClass().getResource("/images/icon.png"); + Toolkit toolkit = Toolkit.getDefaultToolkit(); + URL iconUrl = getClass().getResource("/images/icon.png"); if (iconUrl != null) { setIconImage(toolkit.getImage(iconUrl)); return; } - final Path devIconPath = Path.of("src", "main", "resources", "images", "icon.png"); + Path devIconPath = Path.of("src", "main", "resources", "images", "icon.png"); setIconImage(toolkit.getImage(devIconPath.toAbsolutePath().toString())); } } From 6bf4405dafacdc033d6f781656f4a9e992c8ccc1 Mon Sep 17 00:00:00 2001 From: Miles Nash Date: Sat, 23 May 2026 18:34:21 +0100 Subject: [PATCH 14/21] refactor: various antipattern fixes --- src/main/java/BoardRenderer.java | 16 ++++------------ src/main/java/FileHighScoreStore.java | 2 +- src/main/java/GameEngine.java | 2 +- src/main/java/GameState.java | 8 ++++++-- src/main/java/Snake.java | 3 ++- src/test/java/GameEngineTest.java | 8 ++++---- 6 files changed, 18 insertions(+), 21 deletions(-) diff --git a/src/main/java/BoardRenderer.java b/src/main/java/BoardRenderer.java index 8686d10..426a46f 100644 --- a/src/main/java/BoardRenderer.java +++ b/src/main/java/BoardRenderer.java @@ -5,15 +5,11 @@ public class BoardRenderer { private static final Font CAPTION_FONT = new Font("Courier New", Font.BOLD, 24); public void paint(Graphics g, GameState state) { - if (state.getMode() == GameMode.GAME_OVER) { - paintGameOver(g, state); - return; + switch (state.getMode()) { + case GAME_OVER -> paintGameOver(g, state); + case PAUSED -> paintPause(g, state); + case RUNNING -> paintGameContent(g, state, Color.white, Color.yellow, Color.green); } - if (state.getMode() == GameMode.PAUSED) { - paintPause(g, state); - return; - } - paintPixels(g, state); } private void paintPause(Graphics g, GameState state) { @@ -43,10 +39,6 @@ private void paintTitles(Graphics g, String titleOutput, String captionOutput) { g.drawString(captionOutput, (BoardConfig.BOARD_WIDTH - g.getFontMetrics(CAPTION_FONT).stringWidth(captionOutput)) / 2, BoardConfig.COMPONENT_HEIGHT * 5 / 8); } - private void paintPixels(Graphics g, GameState state) { - paintGameContent(g, state, Color.white, Color.yellow, Color.green); - } - private void paintGameContent(Graphics g, GameState state, Color hudColor, Color lemonColor, Color snakeColor) { Graphics2D g2D = (Graphics2D) g; diff --git a/src/main/java/FileHighScoreStore.java b/src/main/java/FileHighScoreStore.java index 42a0ec9..9b9bcd0 100644 --- a/src/main/java/FileHighScoreStore.java +++ b/src/main/java/FileHighScoreStore.java @@ -20,7 +20,7 @@ public int load() { return -1; } return Integer.parseInt(value); - } catch (Exception ignored) { + } catch (IOException | NumberFormatException ignored) { return -1; } } diff --git a/src/main/java/GameEngine.java b/src/main/java/GameEngine.java index 38352f5..d307eea 100644 --- a/src/main/java/GameEngine.java +++ b/src/main/java/GameEngine.java @@ -70,7 +70,7 @@ private void spawnFood(GameState state) { random.nextInt(BoardConfig.PIXEL_HEIGHT) ); if (!snake.containsPoint(candidate)) { - state.getLemon().setLocation(candidate); + state.setLemon(candidate); return; } } diff --git a/src/main/java/GameState.java b/src/main/java/GameState.java index b669bf9..13f7337 100644 --- a/src/main/java/GameState.java +++ b/src/main/java/GameState.java @@ -2,7 +2,7 @@ public class GameState { private Snake snake; - private final Point lemon = new Point(); + private Point lemon = new Point(); private Direction direction = Direction.UP; private GameMode mode = GameMode.RUNNING; private int highScore = -1; @@ -16,7 +16,11 @@ public void setSnake(Snake snake) { } public Point getLemon() { - return lemon; + return new Point(lemon); + } + + public void setLemon(Point lemon) { + this.lemon = new Point(lemon); } public Direction getDirection() { diff --git a/src/main/java/Snake.java b/src/main/java/Snake.java index 54f17ea..8bcf0c7 100644 --- a/src/main/java/Snake.java +++ b/src/main/java/Snake.java @@ -9,6 +9,7 @@ public class Snake { private static final int INITIAL_LENGTH = 3; private final LinkedList body = new LinkedList<>(); + private final List unmodifiableBody = Collections.unmodifiableList(body); private final Set occupied = new HashSet<>(); public Snake(Random random, int width, int height) { @@ -44,7 +45,7 @@ public Point getHead() { } public List getBody() { - return Collections.unmodifiableList(body); + return unmodifiableBody; } public boolean containsPoint(Point point) { diff --git a/src/test/java/GameEngineTest.java b/src/test/java/GameEngineTest.java index 31ef45b..06ba005 100644 --- a/src/test/java/GameEngineTest.java +++ b/src/test/java/GameEngineTest.java @@ -68,7 +68,7 @@ void tickDoesNothingWhenPaused() { void tickMovesHeadAccordingToNextDirection() { GameEngine engine = new GameEngine(new Random(1L)); GameState state = runningState(Snake.createFixed(10, 10)); - state.getLemon().setLocation(0, 0); + state.setLemon(new Point(0, 0)); engine.tick(state); @@ -80,7 +80,7 @@ void tickMovesHeadAccordingToNextDirection() { void tickWrapsVerticallyAtTopEdge() { GameEngine engine = new GameEngine(new Random(1L)); GameState state = runningState(Snake.createFixed(10, 0)); - state.getLemon().setLocation(0, 0); + state.setLemon(new Point(0, 0)); engine.tick(state); @@ -91,7 +91,7 @@ void tickWrapsVerticallyAtTopEdge() { void tickEatingFoodGrowsSnakeAndRespawnsFood() { GameEngine engine = new GameEngine(new Random(42L)); GameState state = runningState(Snake.createFixed(10, 10)); - state.getLemon().setLocation(10, 9); + state.setLemon(new Point(10, 9)); engine.tick(state); @@ -107,7 +107,7 @@ void tickDetectsSelfCollision() { GameState state = runningState(Snake.createFixed(5, 5)); state.setDirection(Direction.DOWN); engine.requestDirection(state, Direction.DOWN); - state.getLemon().setLocation(0, 0); + state.setLemon(new Point(0, 0)); engine.tick(state); From 7d85d14f1a7296ee346e00000b43017826010692 Mon Sep 17 00:00:00 2001 From: Miles Nash Date: Sat, 23 May 2026 18:41:20 +0100 Subject: [PATCH 15/21] refactor: update docs, variable names, lint --- src/main/java/Board.java | 19 +++++++------------ src/main/java/BoardRenderer.java | 30 +++++++++++++++--------------- src/main/java/GameEngine.java | 4 ++-- src/main/java/GameState.java | 10 +++++----- src/main/java/Snake.java | 12 +++--------- src/main/java/Window.java | 1 - src/test/java/GameEngineTest.java | 12 ++++++------ 7 files changed, 38 insertions(+), 50 deletions(-) diff --git a/src/main/java/Board.java b/src/main/java/Board.java index f8c612d..b9ef5a6 100644 --- a/src/main/java/Board.java +++ b/src/main/java/Board.java @@ -13,7 +13,6 @@ public class Board extends JPanel implements ActionListener { private final HighScoreStore highScoreStore = new FileHighScoreStore(BoardConfig.HIGHSCORE_FILE); private boolean highScoreSaved; - /** A board to display the game and deal with user input. */ public Board() { addKeyListener(new DirectionAdapter()); setPreferredSize(new Dimension(BoardConfig.BOARD_WIDTH, BoardConfig.COMPONENT_HEIGHT)); @@ -23,7 +22,6 @@ public Board() { initBoard(); } - /** Resets fields of the Board for a new game. */ private void initBoard() { engine.reset(state); state.setHighScore(highScoreStore.load()); @@ -31,7 +29,6 @@ private void initBoard() { timer.start(); } - /** Game management code, running with each tick. */ @Override public void actionPerformed(ActionEvent e) { engine.tick(state); @@ -45,34 +42,32 @@ public void actionPerformed(ActionEvent e) { repaint(); } - /** Paints board or other output depending on current game state. */ @Override public void paintComponent(Graphics g) { super.paintComponent(g); renderer.paint(g, state); } - /** Class to determine appropriate response to key presses. */ private class DirectionAdapter extends KeyAdapter { @Override public void keyPressed(KeyEvent e) { switch (e.getKeyCode()) { - case (KeyEvent.VK_DOWN), (KeyEvent.VK_S) -> engine.requestDirection(state, Direction.DOWN); - case (KeyEvent.VK_UP), (KeyEvent.VK_W) -> engine.requestDirection(state, Direction.UP); - case (KeyEvent.VK_LEFT), (KeyEvent.VK_A) -> engine.requestDirection(state, Direction.LEFT); - case (KeyEvent.VK_RIGHT), (KeyEvent.VK_D) -> engine.requestDirection(state, Direction.RIGHT); - case (KeyEvent.VK_P) -> { + case KeyEvent.VK_DOWN, KeyEvent.VK_S -> engine.requestDirection(state, Direction.DOWN); + case KeyEvent.VK_UP, KeyEvent.VK_W -> engine.requestDirection(state, Direction.UP); + case KeyEvent.VK_LEFT, KeyEvent.VK_A -> engine.requestDirection(state, Direction.LEFT); + case KeyEvent.VK_RIGHT, KeyEvent.VK_D -> engine.requestDirection(state, Direction.RIGHT); + case KeyEvent.VK_P -> { engine.togglePause(state); if (state.getMode() == GameMode.RUNNING) { timer.start(); } } - case (KeyEvent.VK_R) -> { + case KeyEvent.VK_R -> { if (state.getMode() == GameMode.GAME_OVER) { initBoard(); } } - case (KeyEvent.VK_ESCAPE) -> System.exit(0); + case KeyEvent.VK_ESCAPE -> System.exit(0); } } } diff --git a/src/main/java/BoardRenderer.java b/src/main/java/BoardRenderer.java index 426a46f..788adb9 100644 --- a/src/main/java/BoardRenderer.java +++ b/src/main/java/BoardRenderer.java @@ -20,37 +20,37 @@ private void paintPause(Graphics g, GameState state) { } private void paintGameOver(Graphics g, GameState state) { - String scoreOutput = "SCORE: " + state.getSnake().growth(); - String highScoreOutput = "HIGH SCORE: " + state.getHighScore(); + String scoreText = "SCORE: " + state.getSnake().growth(); + String highScoreText = "HIGH SCORE: " + state.getHighScore(); g.setColor(Color.red); paintTitles(g, "GAME OVER", "-PRESS R TO RESTART-"); - g.drawString(highScoreOutput, (BoardConfig.BOARD_WIDTH - g.getFontMetrics(CAPTION_FONT).stringWidth(highScoreOutput)) / 2, BoardConfig.COMPONENT_HEIGHT / 4); + g.drawString(highScoreText, (BoardConfig.BOARD_WIDTH - g.getFontMetrics(CAPTION_FONT).stringWidth(highScoreText)) / 2, BoardConfig.COMPONENT_HEIGHT / 4); g.setColor(Color.yellow); - g.drawString(scoreOutput, (BoardConfig.BOARD_WIDTH - g.getFontMetrics(CAPTION_FONT).stringWidth(scoreOutput)) / 2, BoardConfig.COMPONENT_HEIGHT / 8); + g.drawString(scoreText, (BoardConfig.BOARD_WIDTH - g.getFontMetrics(CAPTION_FONT).stringWidth(scoreText)) / 2, BoardConfig.COMPONENT_HEIGHT / 8); } - private void paintTitles(Graphics g, String titleOutput, String captionOutput) { + private void paintTitles(Graphics g, String title, String caption) { g.setFont(TITLE_FONT); - g.drawString(titleOutput, (BoardConfig.BOARD_WIDTH - g.getFontMetrics(TITLE_FONT).stringWidth(titleOutput)) / 2, BoardConfig.COMPONENT_HEIGHT / 2); + g.drawString(title, (BoardConfig.BOARD_WIDTH - g.getFontMetrics(TITLE_FONT).stringWidth(title)) / 2, BoardConfig.COMPONENT_HEIGHT / 2); g.setColor(Color.white); g.setFont(CAPTION_FONT); - g.drawString(captionOutput, (BoardConfig.BOARD_WIDTH - g.getFontMetrics(CAPTION_FONT).stringWidth(captionOutput)) / 2, BoardConfig.COMPONENT_HEIGHT * 5 / 8); + g.drawString(caption, (BoardConfig.BOARD_WIDTH - g.getFontMetrics(CAPTION_FONT).stringWidth(caption)) / 2, BoardConfig.COMPONENT_HEIGHT * 5 / 8); } - private void paintGameContent(Graphics g, GameState state, Color hudColor, Color lemonColor, Color snakeColor) { + private void paintGameContent(Graphics g, GameState state, Color hudColor, Color foodColor, Color snakeColor) { Graphics2D g2D = (Graphics2D) g; g2D.setPaint(hudColor); g2D.fillRect(0, BoardConfig.BOARD_HEIGHT, BoardConfig.BOARD_WIDTH, BoardConfig.HUD_ROWS * BoardConfig.PIXEL_SIZE); g2D.setPaint(Color.black); - translatePoints(g2D, state.getSnake().growth()); + paintScore(g2D, state.getSnake().growth()); - Point lemon = state.getLemon(); - g2D.setPaint(lemonColor); - g2D.fillRect(lemon.x * BoardConfig.PIXEL_SIZE, lemon.y * BoardConfig.PIXEL_SIZE, BoardConfig.BORDERED_PIXEL_SIZE, BoardConfig.BORDERED_PIXEL_SIZE); + Point food = state.getFood(); + g2D.setPaint(foodColor); + g2D.fillRect(food.x * BoardConfig.PIXEL_SIZE, food.y * BoardConfig.PIXEL_SIZE, BoardConfig.BORDERED_PIXEL_SIZE, BoardConfig.BORDERED_PIXEL_SIZE); g2D.setPaint(snakeColor); for (Point point : state.getSnake().getBody()) { @@ -58,7 +58,7 @@ private void paintGameContent(Graphics g, GameState state, Color hudColor, Color } } - private void translatePoints(Graphics2D g2D, int points) { + private void paintScore(Graphics2D g2D, int points) { String digits = Integer.toString(points); int digitWidth = 4 * BoardConfig.PIXEL_SIZE; int totalWidth = digits.length() * digitWidth - BoardConfig.PIXEL_SIZE; @@ -68,8 +68,8 @@ private void translatePoints(Graphics2D g2D, int points) { } } - private void paintDigit(PixelDigit val, Graphics2D g2D, int startX) { - boolean[][] graphic = val.graphics; + private void paintDigit(PixelDigit digit, Graphics2D g2D, int startX) { + boolean[][] graphic = digit.graphics; for (int x = 0; x < 3; x++) { for (int y = 0; y < 5; y++) { if (graphic[y][x]) { diff --git a/src/main/java/GameEngine.java b/src/main/java/GameEngine.java index d307eea..3c691d4 100644 --- a/src/main/java/GameEngine.java +++ b/src/main/java/GameEngine.java @@ -47,7 +47,7 @@ public void tick(GameState state) { Snake snake = state.getSnake(); boolean growing = snake.nextHead(nextDirection, BoardConfig.PIXEL_WIDTH, BoardConfig.PIXEL_HEIGHT) - .equals(state.getLemon()); + .equals(state.getFood()); state.setDirection(nextDirection); snake.move(nextDirection, BoardConfig.PIXEL_WIDTH, BoardConfig.PIXEL_HEIGHT, growing); @@ -70,7 +70,7 @@ private void spawnFood(GameState state) { random.nextInt(BoardConfig.PIXEL_HEIGHT) ); if (!snake.containsPoint(candidate)) { - state.setLemon(candidate); + state.setFood(candidate); return; } } diff --git a/src/main/java/GameState.java b/src/main/java/GameState.java index 13f7337..59679e2 100644 --- a/src/main/java/GameState.java +++ b/src/main/java/GameState.java @@ -2,7 +2,7 @@ public class GameState { private Snake snake; - private Point lemon = new Point(); + private Point food = new Point(); private Direction direction = Direction.UP; private GameMode mode = GameMode.RUNNING; private int highScore = -1; @@ -15,12 +15,12 @@ public void setSnake(Snake snake) { this.snake = snake; } - public Point getLemon() { - return new Point(lemon); + public Point getFood() { + return new Point(food); } - public void setLemon(Point lemon) { - this.lemon = new Point(lemon); + public void setFood(Point food) { + this.food = new Point(food); } public Direction getDirection() { diff --git a/src/main/java/Snake.java b/src/main/java/Snake.java index 8bcf0c7..c4c4e00 100644 --- a/src/main/java/Snake.java +++ b/src/main/java/Snake.java @@ -3,9 +3,9 @@ import java.util.HashSet; import java.util.LinkedList; import java.util.List; +import java.util.Random; import java.util.Set; -/** The snake, constructed in terms of the board size. */ public class Snake { private static final int INITIAL_LENGTH = 3; private final LinkedList body = new LinkedList<>(); @@ -21,10 +21,7 @@ public Snake(Random random, int width, int height) { addSegment(new Point(x, y + 2)); } - /** - * Creates a length-3 vertical snake: {@code (headX, headY)}, {@code (headX, headY + 1)}, {@code (headX, headY + 2)}. - * Intended for tests and deterministic setups. - */ + /** Creates a length-3 vertical snake at the given head position. */ public static Snake createFixed(int headX, int headY) { return new Snake(new Point(headX, headY), new Point(headX, headY + 1), new Point(headX, headY + 2)); } @@ -52,14 +49,13 @@ public boolean containsPoint(Point point) { return occupied.contains(point); } - /** Determines if the snake's head is touching any other part of the body. */ public boolean eatingSelf() { // Head occupies a duplicate position in the set only if it overlaps another segment. // Since the set deduplicates, a collision means the set is smaller than the list. return occupied.size() < body.size(); } - /** Returns where the head would be after one step, without mutating state. */ + /** Returns the next head position without mutating state. */ public Point nextHead(Direction direction, int boardWidth, int boardHeight) { Point head = body.getFirst(); return switch (direction) { @@ -70,7 +66,6 @@ public Point nextHead(Direction direction, int boardWidth, int boardHeight) { }; } - /** Moves the snake one step in the given direction, wrapping around the board. Grows if specified. */ public void move(Direction direction, int boardWidth, int boardHeight, boolean growing) { Point head = nextHead(direction, boardWidth, boardHeight); body.addFirst(head); @@ -80,7 +75,6 @@ public void move(Direction direction, int boardWidth, int boardHeight, boolean g } } - /** Returns the difference in size of the body from the original length. */ public int growth() { return body.size() - INITIAL_LENGTH; } diff --git a/src/main/java/Window.java b/src/main/java/Window.java index 1b60191..1dfd3d4 100644 --- a/src/main/java/Window.java +++ b/src/main/java/Window.java @@ -3,7 +3,6 @@ import java.net.URL; import java.nio.file.Path; -/** A window to hold the game board inside. */ public class Window extends JFrame { public Window() { super("JavaSnake"); diff --git a/src/test/java/GameEngineTest.java b/src/test/java/GameEngineTest.java index 06ba005..fdba6bf 100644 --- a/src/test/java/GameEngineTest.java +++ b/src/test/java/GameEngineTest.java @@ -68,7 +68,7 @@ void tickDoesNothingWhenPaused() { void tickMovesHeadAccordingToNextDirection() { GameEngine engine = new GameEngine(new Random(1L)); GameState state = runningState(Snake.createFixed(10, 10)); - state.setLemon(new Point(0, 0)); + state.setFood(new Point(0, 0)); engine.tick(state); @@ -80,7 +80,7 @@ void tickMovesHeadAccordingToNextDirection() { void tickWrapsVerticallyAtTopEdge() { GameEngine engine = new GameEngine(new Random(1L)); GameState state = runningState(Snake.createFixed(10, 0)); - state.setLemon(new Point(0, 0)); + state.setFood(new Point(0, 0)); engine.tick(state); @@ -91,14 +91,14 @@ void tickWrapsVerticallyAtTopEdge() { void tickEatingFoodGrowsSnakeAndRespawnsFood() { GameEngine engine = new GameEngine(new Random(42L)); GameState state = runningState(Snake.createFixed(10, 10)); - state.setLemon(new Point(10, 9)); + state.setFood(new Point(10, 9)); engine.tick(state); assertEquals(new Point(10, 9), state.getSnake().getHead()); assertEquals(4, state.getSnake().getBody().size()); assertEquals(1, state.getSnake().growth()); - assertFalse(state.getSnake().containsPoint(state.getLemon())); + assertFalse(state.getSnake().containsPoint(state.getFood())); } @Test @@ -107,7 +107,7 @@ void tickDetectsSelfCollision() { GameState state = runningState(Snake.createFixed(5, 5)); state.setDirection(Direction.DOWN); engine.requestDirection(state, Direction.DOWN); - state.setLemon(new Point(0, 0)); + state.setFood(new Point(0, 0)); engine.tick(state); @@ -125,6 +125,6 @@ void resetStartsRunningAndPlacesFoodOffSnake() { assertEquals(Direction.UP, state.getDirection()); assertEquals(Direction.UP, engine.getNextDirection()); assertEquals(3, state.getSnake().getBody().size()); - assertFalse(state.getSnake().containsPoint(state.getLemon())); + assertFalse(state.getSnake().containsPoint(state.getFood())); } } From 548ffbf99b08cfe712dd46314ae100917778ed57 Mon Sep 17 00:00:00 2001 From: Miles Nash Date: Sat, 23 May 2026 18:48:05 +0100 Subject: [PATCH 16/21] fix: behaviour fixes --- src/main/java/BoardRenderer.java | 1 + src/main/java/Snake.java | 6 +++--- src/test/java/GameEngineTest.java | 25 +++++++++++++++++++++++++ 3 files changed, 29 insertions(+), 3 deletions(-) diff --git a/src/main/java/BoardRenderer.java b/src/main/java/BoardRenderer.java index 788adb9..f7d2a8c 100644 --- a/src/main/java/BoardRenderer.java +++ b/src/main/java/BoardRenderer.java @@ -25,6 +25,7 @@ private void paintGameOver(Graphics g, GameState state) { g.setColor(Color.red); paintTitles(g, "GAME OVER", "-PRESS R TO RESTART-"); + g.setColor(Color.red); g.drawString(highScoreText, (BoardConfig.BOARD_WIDTH - g.getFontMetrics(CAPTION_FONT).stringWidth(highScoreText)) / 2, BoardConfig.COMPONENT_HEIGHT / 4); g.setColor(Color.yellow); g.drawString(scoreText, (BoardConfig.BOARD_WIDTH - g.getFontMetrics(CAPTION_FONT).stringWidth(scoreText)) / 2, BoardConfig.COMPONENT_HEIGHT / 8); diff --git a/src/main/java/Snake.java b/src/main/java/Snake.java index c4c4e00..87a036d 100644 --- a/src/main/java/Snake.java +++ b/src/main/java/Snake.java @@ -67,12 +67,12 @@ public Point nextHead(Direction direction, int boardWidth, int boardHeight) { } public void move(Direction direction, int boardWidth, int boardHeight, boolean growing) { - Point head = nextHead(direction, boardWidth, boardHeight); - body.addFirst(head); - occupied.add(head); if (!growing) { occupied.remove(body.removeLast()); } + Point head = nextHead(direction, boardWidth, boardHeight); + body.addFirst(head); + occupied.add(head); } public int growth() { diff --git a/src/test/java/GameEngineTest.java b/src/test/java/GameEngineTest.java index fdba6bf..51e7212 100644 --- a/src/test/java/GameEngineTest.java +++ b/src/test/java/GameEngineTest.java @@ -114,6 +114,31 @@ void tickDetectsSelfCollision() { assertEquals(GameMode.GAME_OVER, state.getMode()); } + @Test + void tickAllowsMovingToVacatedTailPosition() { + GameEngine engine = new GameEngine(new Random(42L)); + GameState state = runningState(Snake.createFixed(5, 5)); + + // Grow snake to length 4 by eating food + state.setFood(new Point(5, 4)); + engine.tick(state); + assertEquals(4, state.getSnake().getBody().size()); + + // Navigate a tight U-turn where head lands on the vacated tail position + state.setFood(new Point(0, 0)); + engine.requestDirection(state, Direction.RIGHT); + engine.tick(state); // (6,4), (5,4), (5,5), (5,6) + + engine.requestDirection(state, Direction.DOWN); + engine.tick(state); // (6,5), (6,4), (5,4), (5,5) + + engine.requestDirection(state, Direction.LEFT); + engine.tick(state); // head → (5,5), old tail was at (5,5) + + assertEquals(GameMode.RUNNING, state.getMode()); + assertEquals(new Point(5, 5), state.getSnake().getHead()); + } + @Test void resetStartsRunningAndPlacesFoodOffSnake() { GameEngine engine = new GameEngine(new Random(99L)); From cfb0f952fbea2fd12aa6992b309c014d7fd913f7 Mon Sep 17 00:00:00 2001 From: Miles Nash Date: Sat, 23 May 2026 19:11:59 +0100 Subject: [PATCH 17/21] feat: add packages, WON game state and optimise food placement loop --- pom.xml | 2 +- .../java/{ => com/mapna/snake}/Board.java | 10 +++--- .../{ => com/mapna/snake}/BoardConfig.java | 2 ++ .../{ => com/mapna/snake}/BoardRenderer.java | 19 +++++++++-- .../java/{ => com/mapna/snake}/Direction.java | 2 ++ .../mapna/snake}/FileHighScoreStore.java | 8 +++-- src/main/java/{ => com/mapna/snake}/Game.java | 2 ++ .../{ => com/mapna/snake}/GameEngine.java | 32 ++++++++++++------- .../java/{ => com/mapna/snake}/GameMode.java | 5 ++- .../java/{ => com/mapna/snake}/GameState.java | 2 ++ .../{ => com/mapna/snake}/HighScoreStore.java | 2 ++ .../java/{ => com/mapna/snake}/Snake.java | 9 +++--- .../java/{ => com/mapna/snake}/Window.java | 2 ++ .../{ => com/mapna/snake}/GameEngineTest.java | 2 ++ 14 files changed, 73 insertions(+), 26 deletions(-) rename src/main/java/{ => com/mapna/snake}/Board.java (85%) rename src/main/java/{ => com/mapna/snake}/BoardConfig.java (96%) rename src/main/java/{ => com/mapna/snake}/BoardRenderer.java (85%) rename src/main/java/{ => com/mapna/snake}/Direction.java (92%) rename src/main/java/{ => com/mapna/snake}/FileHighScoreStore.java (76%) rename src/main/java/{ => com/mapna/snake}/Game.java (88%) rename src/main/java/{ => com/mapna/snake}/GameEngine.java (66%) rename src/main/java/{ => com/mapna/snake}/GameMode.java (50%) rename src/main/java/{ => com/mapna/snake}/GameState.java (97%) rename src/main/java/{ => com/mapna/snake}/HighScoreStore.java (75%) rename src/main/java/{ => com/mapna/snake}/Snake.java (91%) rename src/main/java/{ => com/mapna/snake}/Window.java (96%) rename src/test/java/{ => com/mapna/snake}/GameEngineTest.java (99%) diff --git a/pom.xml b/pom.xml index 17bdc6c..6a32336 100644 --- a/pom.xml +++ b/pom.xml @@ -43,7 +43,7 @@ exec-maven-plugin 3.3.0 - Game + com.mapna.snake.Game diff --git a/src/main/java/Board.java b/src/main/java/com/mapna/snake/Board.java similarity index 85% rename from src/main/java/Board.java rename to src/main/java/com/mapna/snake/Board.java index b9ef5a6..d059581 100644 --- a/src/main/java/Board.java +++ b/src/main/java/com/mapna/snake/Board.java @@ -1,3 +1,5 @@ +package com.mapna.snake; + import javax.swing.*; import java.awt.*; import java.awt.event.ActionEvent; @@ -32,10 +34,10 @@ private void initBoard() { @Override public void actionPerformed(ActionEvent e) { engine.tick(state); - if (state.getMode() == GameMode.PAUSED || state.getMode() == GameMode.GAME_OVER) { + if (state.getMode() == GameMode.PAUSED || state.getMode() == GameMode.GAME_OVER || state.getMode() == GameMode.WON) { timer.stop(); } - if (state.getMode() == GameMode.GAME_OVER && !highScoreSaved) { + if ((state.getMode() == GameMode.GAME_OVER || state.getMode() == GameMode.WON) && !highScoreSaved) { state.setHighScore(highScoreStore.saveIfHigher(state.getSnake().growth())); highScoreSaved = true; } @@ -63,11 +65,11 @@ public void keyPressed(KeyEvent e) { } } case KeyEvent.VK_R -> { - if (state.getMode() == GameMode.GAME_OVER) { + if (state.getMode() == GameMode.GAME_OVER || state.getMode() == GameMode.WON) { initBoard(); } } - case KeyEvent.VK_ESCAPE -> System.exit(0); + case KeyEvent.VK_ESCAPE -> SwingUtilities.getWindowAncestor(Board.this).dispose(); } } } diff --git a/src/main/java/BoardConfig.java b/src/main/java/com/mapna/snake/BoardConfig.java similarity index 96% rename from src/main/java/BoardConfig.java rename to src/main/java/com/mapna/snake/BoardConfig.java index db42724..de8905b 100644 --- a/src/main/java/BoardConfig.java +++ b/src/main/java/com/mapna/snake/BoardConfig.java @@ -1,3 +1,5 @@ +package com.mapna.snake; + public final class BoardConfig { public static final int TICK_RATE_MS = 80; public static final int BOARD_WIDTH = 480; diff --git a/src/main/java/BoardRenderer.java b/src/main/java/com/mapna/snake/BoardRenderer.java similarity index 85% rename from src/main/java/BoardRenderer.java rename to src/main/java/com/mapna/snake/BoardRenderer.java index f7d2a8c..da0fe0a 100644 --- a/src/main/java/BoardRenderer.java +++ b/src/main/java/com/mapna/snake/BoardRenderer.java @@ -1,3 +1,5 @@ +package com.mapna.snake; + import java.awt.*; public class BoardRenderer { @@ -7,6 +9,7 @@ public class BoardRenderer { public void paint(Graphics g, GameState state) { switch (state.getMode()) { case GAME_OVER -> paintGameOver(g, state); + case WON -> paintWon(g, state); case PAUSED -> paintPause(g, state); case RUNNING -> paintGameContent(g, state, Color.white, Color.yellow, Color.green); } @@ -19,13 +22,23 @@ private void paintPause(Graphics g, GameState state) { paintTitles(g, "PAUSED", "-PRESS P TO CONTINUE-"); } + private void paintWon(Graphics g, GameState state) { + String scoreText = "SCORE: " + state.getSnake().growth(); + String highScoreText = "HIGH SCORE: " + state.getHighScore(); + + g.setColor(Color.green); + paintTitles(g, "YOU WIN!", "-PRESS R TO RESTART-"); + g.drawString(highScoreText, (BoardConfig.BOARD_WIDTH - g.getFontMetrics(CAPTION_FONT).stringWidth(highScoreText)) / 2, BoardConfig.COMPONENT_HEIGHT / 4); + g.setColor(Color.yellow); + g.drawString(scoreText, (BoardConfig.BOARD_WIDTH - g.getFontMetrics(CAPTION_FONT).stringWidth(scoreText)) / 2, BoardConfig.COMPONENT_HEIGHT / 8); + } + private void paintGameOver(Graphics g, GameState state) { String scoreText = "SCORE: " + state.getSnake().growth(); String highScoreText = "HIGH SCORE: " + state.getHighScore(); g.setColor(Color.red); paintTitles(g, "GAME OVER", "-PRESS R TO RESTART-"); - g.setColor(Color.red); g.drawString(highScoreText, (BoardConfig.BOARD_WIDTH - g.getFontMetrics(CAPTION_FONT).stringWidth(highScoreText)) / 2, BoardConfig.COMPONENT_HEIGHT / 4); g.setColor(Color.yellow); g.drawString(scoreText, (BoardConfig.BOARD_WIDTH - g.getFontMetrics(CAPTION_FONT).stringWidth(scoreText)) / 2, BoardConfig.COMPONENT_HEIGHT / 8); @@ -65,7 +78,7 @@ private void paintScore(Graphics2D g2D, int points) { int totalWidth = digits.length() * digitWidth - BoardConfig.PIXEL_SIZE; int startX = (BoardConfig.BOARD_WIDTH - totalWidth) / 2; for (int i = 0; i < digits.length(); i++) { - paintDigit(PixelDigit.values()[digits.charAt(i) - '0'], g2D, startX + i * digitWidth); + paintDigit(PIXEL_DIGITS[digits.charAt(i) - '0'], g2D, startX + i * digitWidth); } } @@ -85,6 +98,8 @@ private void paintDigit(PixelDigit digit, Graphics2D g2D, int startX) { } } + private static final PixelDigit[] PIXEL_DIGITS = PixelDigit.values(); + private enum PixelDigit { ZERO(new boolean[][]{{true, true, true}, {true, false, true}, {true, false, true}, {true, false, true}, {true, true, true}}), ONE(new boolean[][]{{false, true, false}, {false, true, false}, {false, true, false}, {false, true, false}, {false, true, false}}), diff --git a/src/main/java/Direction.java b/src/main/java/com/mapna/snake/Direction.java similarity index 92% rename from src/main/java/Direction.java rename to src/main/java/com/mapna/snake/Direction.java index b3e5cc4..1d53131 100644 --- a/src/main/java/Direction.java +++ b/src/main/java/com/mapna/snake/Direction.java @@ -1,3 +1,5 @@ +package com.mapna.snake; + public enum Direction { DOWN, UP, diff --git a/src/main/java/FileHighScoreStore.java b/src/main/java/com/mapna/snake/FileHighScoreStore.java similarity index 76% rename from src/main/java/FileHighScoreStore.java rename to src/main/java/com/mapna/snake/FileHighScoreStore.java index 9b9bcd0..f68ab1e 100644 --- a/src/main/java/FileHighScoreStore.java +++ b/src/main/java/com/mapna/snake/FileHighScoreStore.java @@ -1,3 +1,5 @@ +package com.mapna.snake; + import java.io.IOException; import java.nio.file.Files; import java.nio.file.Path; @@ -20,7 +22,8 @@ public int load() { return -1; } return Integer.parseInt(value); - } catch (IOException | NumberFormatException ignored) { + } catch (IOException | NumberFormatException e) { + System.err.println("Failed to load high score: " + e.getMessage()); return -1; } } @@ -32,7 +35,8 @@ public int saveIfHigher(int score) { if (next != current) { try { Files.writeString(scorePath, Integer.toString(next)); - } catch (IOException ignored) { + } catch (IOException e) { + System.err.println("Failed to save high score: " + e.getMessage()); return current; } } diff --git a/src/main/java/Game.java b/src/main/java/com/mapna/snake/Game.java similarity index 88% rename from src/main/java/Game.java rename to src/main/java/com/mapna/snake/Game.java index 2c3e92c..30f841a 100644 --- a/src/main/java/Game.java +++ b/src/main/java/com/mapna/snake/Game.java @@ -1,3 +1,5 @@ +package com.mapna.snake; + import javax.swing.*; public class Game { diff --git a/src/main/java/GameEngine.java b/src/main/java/com/mapna/snake/GameEngine.java similarity index 66% rename from src/main/java/GameEngine.java rename to src/main/java/com/mapna/snake/GameEngine.java index 3c691d4..e43ed79 100644 --- a/src/main/java/GameEngine.java +++ b/src/main/java/com/mapna/snake/GameEngine.java @@ -1,4 +1,8 @@ +package com.mapna.snake; + import java.awt.*; +import java.util.ArrayList; +import java.util.List; import java.util.Objects; import java.util.Random; @@ -46,11 +50,11 @@ public void tick(GameState state) { } Snake snake = state.getSnake(); - boolean growing = snake.nextHead(nextDirection, BoardConfig.PIXEL_WIDTH, BoardConfig.PIXEL_HEIGHT) - .equals(state.getFood()); + Point head = snake.nextHead(nextDirection, BoardConfig.PIXEL_WIDTH, BoardConfig.PIXEL_HEIGHT); + boolean growing = head.equals(state.getFood()); state.setDirection(nextDirection); - snake.move(nextDirection, BoardConfig.PIXEL_WIDTH, BoardConfig.PIXEL_HEIGHT, growing); + snake.move(head, growing); if (snake.eatingSelf()) { state.setMode(GameMode.GAME_OVER); @@ -58,21 +62,25 @@ public void tick(GameState state) { } if (growing) { - spawnFood(state); + if (snake.getBody().size() >= BoardConfig.PIXEL_WIDTH * BoardConfig.PIXEL_HEIGHT) { + state.setMode(GameMode.WON); + } else { + spawnFood(state); + } } } private void spawnFood(GameState state) { Snake snake = state.getSnake(); - while (true) { - Point candidate = new Point( - random.nextInt(BoardConfig.PIXEL_WIDTH), - random.nextInt(BoardConfig.PIXEL_HEIGHT) - ); - if (!snake.containsPoint(candidate)) { - state.setFood(candidate); - return; + List free = new ArrayList<>(); + for (int x = 0; x < BoardConfig.PIXEL_WIDTH; x++) { + for (int y = 0; y < BoardConfig.PIXEL_HEIGHT; y++) { + Point p = new Point(x, y); + if (!snake.containsPoint(p)) { + free.add(p); + } } } + state.setFood(free.get(random.nextInt(free.size()))); } } diff --git a/src/main/java/GameMode.java b/src/main/java/com/mapna/snake/GameMode.java similarity index 50% rename from src/main/java/GameMode.java rename to src/main/java/com/mapna/snake/GameMode.java index b9dea90..15e080b 100644 --- a/src/main/java/GameMode.java +++ b/src/main/java/com/mapna/snake/GameMode.java @@ -1,5 +1,8 @@ +package com.mapna.snake; + public enum GameMode { RUNNING, PAUSED, - GAME_OVER + GAME_OVER, + WON } diff --git a/src/main/java/GameState.java b/src/main/java/com/mapna/snake/GameState.java similarity index 97% rename from src/main/java/GameState.java rename to src/main/java/com/mapna/snake/GameState.java index 59679e2..2920b06 100644 --- a/src/main/java/GameState.java +++ b/src/main/java/com/mapna/snake/GameState.java @@ -1,3 +1,5 @@ +package com.mapna.snake; + import java.awt.*; public class GameState { diff --git a/src/main/java/HighScoreStore.java b/src/main/java/com/mapna/snake/HighScoreStore.java similarity index 75% rename from src/main/java/HighScoreStore.java rename to src/main/java/com/mapna/snake/HighScoreStore.java index e875cdb..86b72d8 100644 --- a/src/main/java/HighScoreStore.java +++ b/src/main/java/com/mapna/snake/HighScoreStore.java @@ -1,3 +1,5 @@ +package com.mapna.snake; + public interface HighScoreStore { int load(); diff --git a/src/main/java/Snake.java b/src/main/java/com/mapna/snake/Snake.java similarity index 91% rename from src/main/java/Snake.java rename to src/main/java/com/mapna/snake/Snake.java index 87a036d..252ae16 100644 --- a/src/main/java/Snake.java +++ b/src/main/java/com/mapna/snake/Snake.java @@ -1,3 +1,5 @@ +package com.mapna.snake; + import java.awt.*; import java.util.Collections; import java.util.HashSet; @@ -66,13 +68,12 @@ public Point nextHead(Direction direction, int boardWidth, int boardHeight) { }; } - public void move(Direction direction, int boardWidth, int boardHeight, boolean growing) { + public void move(Point newHead, boolean growing) { if (!growing) { occupied.remove(body.removeLast()); } - Point head = nextHead(direction, boardWidth, boardHeight); - body.addFirst(head); - occupied.add(head); + body.addFirst(newHead); + occupied.add(newHead); } public int growth() { diff --git a/src/main/java/Window.java b/src/main/java/com/mapna/snake/Window.java similarity index 96% rename from src/main/java/Window.java rename to src/main/java/com/mapna/snake/Window.java index 1dfd3d4..df4caa9 100644 --- a/src/main/java/Window.java +++ b/src/main/java/com/mapna/snake/Window.java @@ -1,3 +1,5 @@ +package com.mapna.snake; + import javax.swing.*; import java.awt.*; import java.net.URL; diff --git a/src/test/java/GameEngineTest.java b/src/test/java/com/mapna/snake/GameEngineTest.java similarity index 99% rename from src/test/java/GameEngineTest.java rename to src/test/java/com/mapna/snake/GameEngineTest.java index 51e7212..507c53b 100644 --- a/src/test/java/GameEngineTest.java +++ b/src/test/java/com/mapna/snake/GameEngineTest.java @@ -1,3 +1,5 @@ +package com.mapna.snake; + import org.junit.jupiter.api.Test; import java.awt.Point; From 6e59e2e4d5971bececcb717bd6d4ac9110fbf2fd Mon Sep 17 00:00:00 2001 From: Miles Nash Date: Sat, 23 May 2026 19:25:57 +0100 Subject: [PATCH 18/21] refactor: simplify paint, remove defensive copy --- .../java/com/mapna/snake/BoardRenderer.java | 21 ++++++++++--------- src/main/java/com/mapna/snake/GameState.java | 4 ++-- 2 files changed, 13 insertions(+), 12 deletions(-) diff --git a/src/main/java/com/mapna/snake/BoardRenderer.java b/src/main/java/com/mapna/snake/BoardRenderer.java index da0fe0a..dc617f3 100644 --- a/src/main/java/com/mapna/snake/BoardRenderer.java +++ b/src/main/java/com/mapna/snake/BoardRenderer.java @@ -23,25 +23,26 @@ private void paintPause(Graphics g, GameState state) { } private void paintWon(Graphics g, GameState state) { - String scoreText = "SCORE: " + state.getSnake().growth(); - String highScoreText = "HIGH SCORE: " + state.getHighScore(); - g.setColor(Color.green); paintTitles(g, "YOU WIN!", "-PRESS R TO RESTART-"); - g.drawString(highScoreText, (BoardConfig.BOARD_WIDTH - g.getFontMetrics(CAPTION_FONT).stringWidth(highScoreText)) / 2, BoardConfig.COMPONENT_HEIGHT / 4); - g.setColor(Color.yellow); - g.drawString(scoreText, (BoardConfig.BOARD_WIDTH - g.getFontMetrics(CAPTION_FONT).stringWidth(scoreText)) / 2, BoardConfig.COMPONENT_HEIGHT / 8); + paintScoreOverlay(g, state); } private void paintGameOver(Graphics g, GameState state) { + g.setColor(Color.red); + paintTitles(g, "GAME OVER", "-PRESS R TO RESTART-"); + paintScoreOverlay(g, state); + } + + private void paintScoreOverlay(Graphics g, GameState state) { String scoreText = "SCORE: " + state.getSnake().growth(); String highScoreText = "HIGH SCORE: " + state.getHighScore(); - g.setColor(Color.red); - paintTitles(g, "GAME OVER", "-PRESS R TO RESTART-"); - g.drawString(highScoreText, (BoardConfig.BOARD_WIDTH - g.getFontMetrics(CAPTION_FONT).stringWidth(highScoreText)) / 2, BoardConfig.COMPONENT_HEIGHT / 4); + g.setFont(CAPTION_FONT); + g.setColor(Color.white); + g.drawString(highScoreText, (BoardConfig.BOARD_WIDTH - g.getFontMetrics().stringWidth(highScoreText)) / 2, BoardConfig.COMPONENT_HEIGHT / 4); g.setColor(Color.yellow); - g.drawString(scoreText, (BoardConfig.BOARD_WIDTH - g.getFontMetrics(CAPTION_FONT).stringWidth(scoreText)) / 2, BoardConfig.COMPONENT_HEIGHT / 8); + g.drawString(scoreText, (BoardConfig.BOARD_WIDTH - g.getFontMetrics().stringWidth(scoreText)) / 2, BoardConfig.COMPONENT_HEIGHT / 8); } private void paintTitles(Graphics g, String title, String caption) { diff --git a/src/main/java/com/mapna/snake/GameState.java b/src/main/java/com/mapna/snake/GameState.java index 2920b06..fb720e0 100644 --- a/src/main/java/com/mapna/snake/GameState.java +++ b/src/main/java/com/mapna/snake/GameState.java @@ -18,11 +18,11 @@ public void setSnake(Snake snake) { } public Point getFood() { - return new Point(food); + return food; } public void setFood(Point food) { - this.food = new Point(food); + this.food = food; } public Direction getDirection() { From 5e35b9a643246fbd3f0f61fa8bd681cbf98940e3 Mon Sep 17 00:00:00 2001 From: Miles Nash Date: Sat, 23 May 2026 19:27:03 +0100 Subject: [PATCH 19/21] test: add snake, high score tests --- .../mapna/snake/FileHighScoreStoreTest.java | 103 ++++++++++++ src/test/java/com/mapna/snake/SnakeTest.java | 148 ++++++++++++++++++ 2 files changed, 251 insertions(+) create mode 100644 src/test/java/com/mapna/snake/FileHighScoreStoreTest.java create mode 100644 src/test/java/com/mapna/snake/SnakeTest.java diff --git a/src/test/java/com/mapna/snake/FileHighScoreStoreTest.java b/src/test/java/com/mapna/snake/FileHighScoreStoreTest.java new file mode 100644 index 0000000..62ee785 --- /dev/null +++ b/src/test/java/com/mapna/snake/FileHighScoreStoreTest.java @@ -0,0 +1,103 @@ +package com.mapna.snake; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +class FileHighScoreStoreTest { + + @TempDir + Path tempDir; + + private FileHighScoreStore storeAt(String filename) { + return new FileHighScoreStore(tempDir.resolve(filename).toString()); + } + + @Test + void loadReturnsNegativeOneWhenFileDoesNotExist() { + assertEquals(-1, storeAt("missing.txt").load()); + } + + @Test + void loadReturnsNegativeOneWhenFileIsEmpty() throws IOException { + Files.writeString(tempDir.resolve("empty.txt"), ""); + assertEquals(-1, storeAt("empty.txt").load()); + } + + @Test + void loadReturnsNegativeOneWhenFileContainsNonNumeric() throws IOException { + Files.writeString(tempDir.resolve("corrupt.txt"), "not_a_number"); + assertEquals(-1, storeAt("corrupt.txt").load()); + } + + @Test + void loadParsesValidScore() throws IOException { + Files.writeString(tempDir.resolve("score.txt"), "42"); + assertEquals(42, storeAt("score.txt").load()); + } + + @Test + void loadTrimsWhitespace() throws IOException { + Files.writeString(tempDir.resolve("padded.txt"), " 17\n"); + assertEquals(17, storeAt("padded.txt").load()); + } + + @Test + void saveIfHigherWritesScoreWhenNoFileExists() { + FileHighScoreStore store = storeAt("new.txt"); + + int result = store.saveIfHigher(10); + + assertEquals(10, result); + assertEquals(10, store.load()); + } + + @Test + void saveIfHigherUpdatesWhenScoreIsHigher() throws IOException { + Files.writeString(tempDir.resolve("hs.txt"), "5"); + FileHighScoreStore store = storeAt("hs.txt"); + + int result = store.saveIfHigher(20); + + assertEquals(20, result); + assertEquals(20, store.load()); + } + + @Test + void saveIfHigherKeepsExistingWhenScoreIsLower() throws IOException { + Files.writeString(tempDir.resolve("hs.txt"), "50"); + FileHighScoreStore store = storeAt("hs.txt"); + + int result = store.saveIfHigher(10); + + assertEquals(50, result); + assertEquals(50, store.load()); + } + + @Test + void saveIfHigherKeepsExistingWhenScoreIsEqual() throws IOException { + Files.writeString(tempDir.resolve("hs.txt"), "25"); + FileHighScoreStore store = storeAt("hs.txt"); + + int result = store.saveIfHigher(25); + + assertEquals(25, result); + assertEquals(25, store.load()); + } + + @Test + void saveIfHigherHandlesCorruptFileAsNoScore() throws IOException { + Files.writeString(tempDir.resolve("bad.txt"), "garbage"); + FileHighScoreStore store = storeAt("bad.txt"); + + int result = store.saveIfHigher(7); + + assertEquals(7, result); + assertEquals(7, store.load()); + } +} diff --git a/src/test/java/com/mapna/snake/SnakeTest.java b/src/test/java/com/mapna/snake/SnakeTest.java new file mode 100644 index 0000000..e0a82f2 --- /dev/null +++ b/src/test/java/com/mapna/snake/SnakeTest.java @@ -0,0 +1,148 @@ +package com.mapna.snake; + +import org.junit.jupiter.api.Test; + +import java.awt.Point; +import java.util.Random; + +import static org.junit.jupiter.api.Assertions.*; + +class SnakeTest { + + @Test + void createFixedPlacesThreeSegmentsVertically() { + Snake snake = Snake.createFixed(5, 3); + + assertEquals(3, snake.getBody().size()); + assertEquals(new Point(5, 3), snake.getHead()); + assertEquals(new Point(5, 4), snake.getBody().get(1)); + assertEquals(new Point(5, 5), snake.getBody().get(2)); + } + + @Test + void randomConstructorPlacesWithinBounds() { + Snake snake = new Snake(new Random(42), 20, 20); + + assertEquals(3, snake.getBody().size()); + for (Point p : snake.getBody()) { + assertTrue(p.x >= 0 && p.x < 20); + assertTrue(p.y >= 0 && p.y < 20); + } + } + + @Test + void containsPointMatchesBodySegments() { + Snake snake = Snake.createFixed(5, 5); + + assertTrue(snake.containsPoint(new Point(5, 5))); + assertTrue(snake.containsPoint(new Point(5, 6))); + assertTrue(snake.containsPoint(new Point(5, 7))); + assertFalse(snake.containsPoint(new Point(0, 0))); + } + + @Test + void nextHeadDoesNotMutateSnake() { + Snake snake = Snake.createFixed(5, 5); + Point originalHead = new Point(snake.getHead()); + + snake.nextHead(Direction.UP, 20, 20); + + assertEquals(originalHead, snake.getHead()); + assertEquals(3, snake.getBody().size()); + } + + @Test + void nextHeadWrapsAtTopEdge() { + Snake snake = Snake.createFixed(5, 0); + assertEquals(new Point(5, 19), snake.nextHead(Direction.UP, 20, 20)); + } + + @Test + void nextHeadWrapsAtBottomEdge() { + Snake snake = Snake.createFixed(5, 19); + assertEquals(new Point(5, 0), snake.nextHead(Direction.DOWN, 20, 20)); + } + + @Test + void nextHeadWrapsAtLeftEdge() { + Snake snake = Snake.createFixed(0, 5); + assertEquals(new Point(19, 5), snake.nextHead(Direction.LEFT, 20, 20)); + } + + @Test + void nextHeadWrapsAtRightEdge() { + Snake snake = Snake.createFixed(19, 5); + assertEquals(new Point(0, 5), snake.nextHead(Direction.RIGHT, 20, 20)); + } + + @Test + void moveWithoutGrowingRemovesTail() { + Snake snake = Snake.createFixed(5, 5); + Point oldTail = snake.getBody().getLast(); + + snake.move(new Point(5, 4), false); + + assertEquals(3, snake.getBody().size()); + assertEquals(new Point(5, 4), snake.getHead()); + assertFalse(snake.containsPoint(oldTail)); + } + + @Test + void moveWithGrowingKeepsTail() { + Snake snake = Snake.createFixed(5, 5); + + snake.move(new Point(5, 4), true); + + assertEquals(4, snake.getBody().size()); + assertEquals(new Point(5, 4), snake.getHead()); + assertTrue(snake.containsPoint(new Point(5, 7))); + } + + @Test + void eatingSelfFalseInitially() { + assertFalse(Snake.createFixed(5, 5).eatingSelf()); + } + + @Test + void eatingSelfDetectsOverlap() { + Snake snake = Snake.createFixed(5, 5); + // Grow to length 4 + snake.move(new Point(5, 4), true); + // Move head onto an existing body segment + snake.move(new Point(5, 5), false); + + assertTrue(snake.eatingSelf()); + } + + @Test + void moveToVacatedTailIsNotSelfCollision() { + Snake snake = Snake.createFixed(5, 5); + snake.move(new Point(5, 4), true); // len 4: (5,4),(5,5),(5,6),(5,7) + snake.move(new Point(6, 4), false); // len 4: (6,4),(5,4),(5,5),(5,6) + snake.move(new Point(6, 5), false); // len 4: (6,5),(6,4),(5,4),(5,5) + snake.move(new Point(6, 6), false); // len 4: (6,6),(6,5),(6,4),(5,4) + + // (5,4) is the current tail — this move vacates it, then places the head there + snake.move(new Point(5, 4), false); + + assertFalse(snake.eatingSelf()); + } + + @Test + void growthReturnsSegmentsBeyondInitialLength() { + Snake snake = Snake.createFixed(5, 5); + assertEquals(0, snake.growth()); + + snake.move(new Point(5, 4), true); + assertEquals(1, snake.growth()); + + snake.move(new Point(5, 3), true); + assertEquals(2, snake.growth()); + } + + @Test + void bodyListIsUnmodifiable() { + Snake snake = Snake.createFixed(5, 5); + assertThrows(UnsupportedOperationException.class, () -> snake.getBody().add(new Point(0, 0))); + } +} From 17cb02e6f16d258d739ff164e52140f6758620e3 Mon Sep 17 00:00:00 2001 From: Miles Nash Date: Sat, 23 May 2026 19:33:15 +0100 Subject: [PATCH 20/21] feat: CI pipeline & maven package --- .github/workflows/ci.yml | 21 +++++++++++++++++++++ pom.xml | 18 ++++++++++++++++++ 2 files changed, 39 insertions(+) create mode 100644 .github/workflows/ci.yml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..02d8f3a --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,21 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + branches: [main] + +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: 21 + cache: maven + + - run: mvn verify --batch-mode --no-transfer-progress diff --git a/pom.xml b/pom.xml index 6a32336..ba0edeb 100644 --- a/pom.xml +++ b/pom.xml @@ -38,6 +38,24 @@ maven-surefire-plugin 3.5.2 + + org.apache.maven.plugins + maven-shade-plugin + 3.6.0 + + + package + shade + + + + com.mapna.snake.Game + + + + + + org.codehaus.mojo exec-maven-plugin From d9a9cf201d664b8833c8d0ff2e30e911c7aae9c5 Mon Sep 17 00:00:00 2001 From: Miles Nash Date: Sat, 23 May 2026 19:40:42 +0100 Subject: [PATCH 21/21] Add build to CI --- .github/workflows/ci.yml | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 02d8f3a..61471ef 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -3,6 +3,7 @@ name: CI on: push: branches: [main] + tags: ['v*'] pull_request: branches: [main] @@ -19,3 +20,25 @@ jobs: cache: maven - run: mvn verify --batch-mode --no-transfer-progress + + - run: mvn package --batch-mode --no-transfer-progress -DskipTests + + - uses: actions/upload-artifact@v4 + with: + name: javasnake + path: target/javasnake-*.jar + + release: + needs: build + if: startsWith(github.ref, 'refs/tags/v') + runs-on: ubuntu-latest + permissions: + contents: write + steps: + - uses: actions/download-artifact@v4 + with: + name: javasnake + + - uses: softprops/action-gh-release@v2 + with: + files: javasnake-*.jar