diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
new file mode 100644
index 0000000..61471ef
--- /dev/null
+++ b/.github/workflows/ci.yml
@@ -0,0 +1,44 @@
+name: CI
+
+on:
+ push:
+ branches: [main]
+ tags: ['v*']
+ 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
+
+ - 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
diff --git a/pom.xml b/pom.xml
index 17bdc6c..ba0edeb 100644
--- a/pom.xml
+++ b/pom.xml
@@ -38,12 +38,30 @@
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
3.3.0
- Game
+ com.mapna.snake.Game
diff --git a/src/main/java/BoardRenderer.java b/src/main/java/BoardRenderer.java
deleted file mode 100644
index 0897e30..0000000
--- a/src/main/java/BoardRenderer.java
+++ /dev/null
@@ -1,129 +0,0 @@
-import java.awt.*;
-
-public class BoardRenderer {
- public void paint(Graphics g, GameState state) {
- if (state.getMode() == GameMode.GAME_OVER) {
- paintGameOver(g, state);
- return;
- }
- if (state.getMode() == GameMode.PAUSED) {
- paintPause(g, state);
- return;
- }
- paintPixels(g, 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);
- }
-
- g.setColor(Color.yellow);
- paintTitles(g, "PAUSED", "-PRESS P TO CONTINUE-");
- }
-
- 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.setColor(Color.yellow);
- g.drawString(scoreOutput, (BoardConfig.BOARD_WIDTH - g.getFontMetrics(captionFont).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.setColor(Color.white);
- g.setFont(captionFont);
- g.drawString(captionOutput, (BoardConfig.BOARD_WIDTH - g.getFontMetrics(captionFont).stringWidth(captionOutput)) / 2, BoardConfig.COMPONENT_HEIGHT * 5 / 8);
- }
-
- private void paintPixels(Graphics g, GameState state) {
- final Graphics2D g2D = (Graphics2D) g;
-
- g2D.setPaint(Color.white);
- 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.fillRect(lemon.x * BoardConfig.PIXEL_SIZE, lemon.y * BoardConfig.PIXEL_SIZE, BoardConfig.BORDERED_PIXEL_SIZE, BoardConfig.BORDERED_PIXEL_SIZE);
-
- g2D.setPaint(Color.green);
- 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);
- }
- }
-
- 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));
- }
- }
-
- private void paintDigit(PixelDigit val, Graphics2D g2D, int startX) {
- final boolean[][] graphic = val.graphics;
- for (int x = 0; x < 3; x++) {
- for (int y = 0; y < 5; y++) {
- if (graphic[y][x]) {
- g2D.fillRect(
- startX + x * BoardConfig.PIXEL_SIZE,
- BoardConfig.BOARD_HEIGHT + BoardConfig.PIXEL_SIZE + y * BoardConfig.PIXEL_SIZE,
- BoardConfig.BORDERED_PIXEL_SIZE,
- BoardConfig.BORDERED_PIXEL_SIZE
- );
- }
- }
- }
- }
-
- 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}}),
- TWO(new boolean[][]{{true, true, true}, {false, false, true}, {true, true, true}, {true, false, false}, {true, true, true}}),
- THREE(new boolean[][]{{true, true, true}, {false, false, true}, {false, true, true}, {false, false, true}, {true, true, true}}),
- FOUR(new boolean[][]{{true, false, true}, {true, false, true}, {true, true, true}, {false, false, true}, {false, false, true}}),
- FIVE(new boolean[][]{{true, true, true}, {true, false, false}, {true, true, true}, {false, false, true}, {true, true, true}}),
- SIX(new boolean[][]{{true, true, true}, {true, false, false}, {true, true, true}, {true, false, true}, {true, true, true}}),
- SEVEN(new boolean[][]{{true, true, true}, {false, false, true}, {false, false, true}, {false, false, true}, {false, false, true}}),
- EIGHT(new boolean[][]{{true, true, true}, {true, false, true}, {true, true, true}, {true, false, true}, {true, true, true}}),
- NINE(new boolean[][]{{true, true, true}, {true, false, true}, {true, true, true}, {false, false, true}, {false, false, true}});
-
- private final boolean[][] graphics;
-
- PixelDigit(boolean[][] graphics) {
- this.graphics = graphics;
- }
- }
-}
diff --git a/src/main/java/Direction.java b/src/main/java/Direction.java
deleted file mode 100644
index ddcc392..0000000
--- a/src/main/java/Direction.java
+++ /dev/null
@@ -1,13 +0,0 @@
-public enum Direction {
- DOWN,
- UP,
- LEFT,
- RIGHT;
-
- public boolean isOpposite(Direction other) {
- return (this == DOWN && other == UP)
- || (this == UP && other == DOWN)
- || (this == LEFT && other == RIGHT)
- || (this == RIGHT && other == LEFT);
- }
-}
diff --git a/src/main/java/GameEngine.java b/src/main/java/GameEngine.java
deleted file mode 100644
index 6ade72e..0000000
--- a/src/main/java/GameEngine.java
+++ /dev/null
@@ -1,88 +0,0 @@
-import java.awt.*;
-import java.util.Objects;
-import java.util.Random;
-
-public class GameEngine {
- private final Random random;
-
- public GameEngine() {
- this(new Random());
- }
-
- public GameEngine(Random random) {
- this.random = Objects.requireNonNull(random, "random");
- }
-
- public void reset(GameState state) {
- state.setSnake(new Snake(BoardConfig.PIXEL_WIDTH, BoardConfig.PIXEL_HEIGHT));
- state.setDirection(Direction.UP);
- state.setNextDirection(Direction.UP);
- state.setMode(GameMode.RUNNING);
- spawnFood(state);
- }
-
- public void requestDirection(GameState state, Direction requested) {
- if (!state.getDirection().isOpposite(requested)) {
- state.setNextDirection(requested);
- }
- }
-
- public void togglePause(GameState state) {
- if (state.getMode() == GameMode.RUNNING) {
- state.setMode(GameMode.PAUSED);
- } else if (state.getMode() == GameMode.PAUSED) {
- state.setMode(GameMode.RUNNING);
- }
- }
-
- public void tick(GameState state) {
- if (state.getMode() != GameMode.RUNNING) {
- return;
- }
-
- final Snake snake = state.getSnake();
- final Point nextHead = nextHeadPosition(snake.getHead(), state.getNextDirection());
- final boolean growing = nextHead.equals(state.getLemon());
-
- state.setDirection(state.getNextDirection());
- snake.changePosition(growing);
- snake.getHead().setLocation(nextHead);
-
- if (snake.eatingSelf()) {
- state.setMode(GameMode.GAME_OVER);
- return;
- }
-
- if (growing) {
- spawnFood(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) {
- final Point candidate = new Point(
- random.nextInt(BoardConfig.PIXEL_WIDTH),
- random.nextInt(BoardConfig.PIXEL_HEIGHT)
- );
- if (!snake.containsPoint(candidate)) {
- state.getLemon().setLocation(candidate);
- return;
- }
- }
- }
-}
diff --git a/src/main/java/Snake.java b/src/main/java/Snake.java
deleted file mode 100644
index 1d99844..0000000
--- a/src/main/java/Snake.java
+++ /dev/null
@@ -1,76 +0,0 @@
-import java.awt.*;
-import java.util.LinkedList;
-import java.util.Random;
-
-/** The snake, constructed in terms of the board size. */
-public class Snake {
- private final Point head;
- 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);
-
- head = new Point(x, y);
- body.add(head);
- body.add(new Point(x, y + 1));
- body.add(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.
- */
- 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));
- }
-
- 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;
- }
-
- public LinkedList getBody() {
- return body;
- }
-
- public boolean containsPoint(Point point) {
- return body.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 (head.equals(body.get(i))) {
- return true;
- }
- }
- 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) {
- Point tail = new Point(body.getLast());
- for (int i = body.size() - 1; i > 0; i--) {
- body.get(i).setLocation(body.get(i - 1));
- }
- if (growing) {
- body.add(tail);
- }
- }
-
- /** Returns the difference in size of the body from the original length. */
- public int growth() {
- return body.size() - 3;
- }
-}
diff --git a/src/main/java/Board.java b/src/main/java/com/mapna/snake/Board.java
similarity index 63%
rename from src/main/java/Board.java
rename to src/main/java/com/mapna/snake/Board.java
index 968fb3e..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;
@@ -10,10 +12,9 @@ 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. */
public Board() {
addKeyListener(new DirectionAdapter());
setPreferredSize(new Dimension(BoardConfig.BOARD_WIDTH, BoardConfig.COMPONENT_HEIGHT));
@@ -23,7 +24,6 @@ public Board() {
initBoard();
}
- /** Resets fields of the Board for a new game. */
private void initBoard() {
engine.reset(state);
state.setHighScore(highScoreStore.load());
@@ -31,48 +31,45 @@ private void initBoard() {
timer.start();
}
- /** Game management code, running with each tick. */
@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;
}
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) -> {
- if (state.getMode() == GameMode.GAME_OVER) {
+ case KeyEvent.VK_R -> {
+ 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 86%
rename from src/main/java/BoardConfig.java
rename to src/main/java/com/mapna/snake/BoardConfig.java
index cad737f..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;
@@ -8,6 +10,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() {
}
diff --git a/src/main/java/com/mapna/snake/BoardRenderer.java b/src/main/java/com/mapna/snake/BoardRenderer.java
new file mode 100644
index 0000000..dc617f3
--- /dev/null
+++ b/src/main/java/com/mapna/snake/BoardRenderer.java
@@ -0,0 +1,122 @@
+package com.mapna.snake;
+
+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) {
+ 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);
+ }
+ }
+
+ private void paintPause(Graphics g, GameState state) {
+ paintGameContent(g, state, Color.lightGray, Color.gray, Color.darkGray);
+
+ g.setColor(Color.yellow);
+ paintTitles(g, "PAUSED", "-PRESS P TO CONTINUE-");
+ }
+
+ private void paintWon(Graphics g, GameState state) {
+ g.setColor(Color.green);
+ paintTitles(g, "YOU WIN!", "-PRESS R TO RESTART-");
+ 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.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().stringWidth(scoreText)) / 2, BoardConfig.COMPONENT_HEIGHT / 8);
+ }
+
+ private void paintTitles(Graphics g, String title, String caption) {
+ g.setFont(TITLE_FONT);
+ 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(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 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);
+ paintScore(g2D, state.getSnake().growth());
+
+ 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()) {
+ g2D.fillRect(point.x * BoardConfig.PIXEL_SIZE, point.y * BoardConfig.PIXEL_SIZE, BoardConfig.BORDERED_PIXEL_SIZE, BoardConfig.BORDERED_PIXEL_SIZE);
+ }
+ }
+
+ 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;
+ int startX = (BoardConfig.BOARD_WIDTH - totalWidth) / 2;
+ for (int i = 0; i < digits.length(); i++) {
+ paintDigit(PIXEL_DIGITS[digits.charAt(i) - '0'], g2D, startX + i * digitWidth);
+ }
+ }
+
+ 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]) {
+ g2D.fillRect(
+ startX + x * BoardConfig.PIXEL_SIZE,
+ BoardConfig.BOARD_HEIGHT + BoardConfig.PIXEL_SIZE + y * BoardConfig.PIXEL_SIZE,
+ BoardConfig.BORDERED_PIXEL_SIZE,
+ BoardConfig.BORDERED_PIXEL_SIZE
+ );
+ }
+ }
+ }
+ }
+
+ 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}}),
+ TWO(new boolean[][]{{true, true, true}, {false, false, true}, {true, true, true}, {true, false, false}, {true, true, true}}),
+ THREE(new boolean[][]{{true, true, true}, {false, false, true}, {false, true, true}, {false, false, true}, {true, true, true}}),
+ FOUR(new boolean[][]{{true, false, true}, {true, false, true}, {true, true, true}, {false, false, true}, {false, false, true}}),
+ FIVE(new boolean[][]{{true, true, true}, {true, false, false}, {true, true, true}, {false, false, true}, {true, true, true}}),
+ SIX(new boolean[][]{{true, true, true}, {true, false, false}, {true, true, true}, {true, false, true}, {true, true, true}}),
+ SEVEN(new boolean[][]{{true, true, true}, {false, false, true}, {false, false, true}, {false, false, true}, {false, false, true}}),
+ EIGHT(new boolean[][]{{true, true, true}, {true, false, true}, {true, true, true}, {true, false, true}, {true, true, true}}),
+ NINE(new boolean[][]{{true, true, true}, {true, false, true}, {true, true, true}, {false, false, true}, {false, false, true}});
+
+ private final boolean[][] graphics;
+
+ PixelDigit(boolean[][] graphics) {
+ this.graphics = graphics;
+ }
+ }
+}
diff --git a/src/main/java/com/mapna/snake/Direction.java b/src/main/java/com/mapna/snake/Direction.java
new file mode 100644
index 0000000..1d53131
--- /dev/null
+++ b/src/main/java/com/mapna/snake/Direction.java
@@ -0,0 +1,21 @@
+package com.mapna.snake;
+
+public enum Direction {
+ DOWN,
+ UP,
+ 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.opposite() == other;
+ }
+}
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 42a0ec9..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 (Exception 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 73%
rename from src/main/java/Game.java
rename to src/main/java/com/mapna/snake/Game.java
index 680a562..30f841a 100644
--- a/src/main/java/Game.java
+++ b/src/main/java/com/mapna/snake/Game.java
@@ -1,10 +1,12 @@
+package com.mapna.snake;
+
import javax.swing.*;
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/com/mapna/snake/GameEngine.java b/src/main/java/com/mapna/snake/GameEngine.java
new file mode 100644
index 0000000..e43ed79
--- /dev/null
+++ b/src/main/java/com/mapna/snake/GameEngine.java
@@ -0,0 +1,86 @@
+package com.mapna.snake;
+
+import java.awt.*;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Objects;
+import java.util.Random;
+
+public class GameEngine {
+ private final Random random;
+ private Direction nextDirection = Direction.UP;
+
+ public GameEngine() {
+ this(new Random());
+ }
+
+ 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);
+ nextDirection = Direction.UP;
+ state.setMode(GameMode.RUNNING);
+ spawnFood(state);
+ }
+
+ public void requestDirection(GameState state, Direction requested) {
+ if (!state.getDirection().isOpposite(requested)) {
+ nextDirection = requested;
+ }
+ }
+
+ public void togglePause(GameState state) {
+ if (state.getMode() == GameMode.RUNNING) {
+ state.setMode(GameMode.PAUSED);
+ } else if (state.getMode() == GameMode.PAUSED) {
+ state.setMode(GameMode.RUNNING);
+ }
+ }
+
+ public void tick(GameState state) {
+ if (state.getMode() != GameMode.RUNNING) {
+ return;
+ }
+
+ Snake snake = state.getSnake();
+ Point head = snake.nextHead(nextDirection, BoardConfig.PIXEL_WIDTH, BoardConfig.PIXEL_HEIGHT);
+ boolean growing = head.equals(state.getFood());
+
+ state.setDirection(nextDirection);
+ snake.move(head, growing);
+
+ if (snake.eatingSelf()) {
+ state.setMode(GameMode.GAME_OVER);
+ return;
+ }
+
+ if (growing) {
+ 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();
+ 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 70%
rename from src/main/java/GameState.java
rename to src/main/java/com/mapna/snake/GameState.java
index 393f34c..fb720e0 100644
--- a/src/main/java/GameState.java
+++ b/src/main/java/com/mapna/snake/GameState.java
@@ -1,10 +1,11 @@
+package com.mapna.snake;
+
import java.awt.*;
public class GameState {
private Snake snake;
- private final Point lemon = new Point();
+ private Point food = new Point();
private Direction direction = Direction.UP;
- private Direction nextDirection = Direction.UP;
private GameMode mode = GameMode.RUNNING;
private int highScore = -1;
@@ -16,8 +17,12 @@ public void setSnake(Snake snake) {
this.snake = snake;
}
- public Point getLemon() {
- return lemon;
+ public Point getFood() {
+ return food;
+ }
+
+ public void setFood(Point food) {
+ this.food = food;
}
public Direction getDirection() {
@@ -28,14 +33,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/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/com/mapna/snake/Snake.java b/src/main/java/com/mapna/snake/Snake.java
new file mode 100644
index 0000000..252ae16
--- /dev/null
+++ b/src/main/java/com/mapna/snake/Snake.java
@@ -0,0 +1,82 @@
+package com.mapna.snake;
+
+import java.awt.*;
+import java.util.Collections;
+import java.util.HashSet;
+import java.util.LinkedList;
+import java.util.List;
+import java.util.Random;
+import java.util.Set;
+
+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) {
+ int x = random.nextInt(width);
+ int y = random.nextInt(height - INITIAL_LENGTH);
+
+ addSegment(new Point(x, y));
+ addSegment(new Point(x, y + 1));
+ addSegment(new Point(x, y + 2));
+ }
+
+ /** 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));
+ }
+
+ private Snake(Point head, Point seg2, Point seg3) {
+ addSegment(head);
+ addSegment(seg2);
+ addSegment(seg3);
+ }
+
+ private void addSegment(Point p) {
+ body.add(p);
+ occupied.add(p);
+ }
+
+ public Point getHead() {
+ return body.getFirst();
+ }
+
+ public List getBody() {
+ return unmodifiableBody;
+ }
+
+ public boolean containsPoint(Point point) {
+ return occupied.contains(point);
+ }
+
+ 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 the next head position 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);
+ };
+ }
+
+ public void move(Point newHead, boolean growing) {
+ if (!growing) {
+ occupied.remove(body.removeLast());
+ }
+ body.addFirst(newHead);
+ occupied.add(newHead);
+ }
+
+ public int growth() {
+ return body.size() - INITIAL_LENGTH;
+ }
+}
diff --git a/src/main/java/Window.java b/src/main/java/com/mapna/snake/Window.java
similarity index 67%
rename from src/main/java/Window.java
rename to src/main/java/com/mapna/snake/Window.java
index 5eeaad9..df4caa9 100644
--- a/src/main/java/Window.java
+++ b/src/main/java/com/mapna/snake/Window.java
@@ -1,9 +1,10 @@
+package com.mapna.snake;
+
import javax.swing.*;
import java.awt.*;
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");
@@ -17,13 +18,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()));
}
}
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/GameEngineTest.java b/src/test/java/com/mapna/snake/GameEngineTest.java
similarity index 70%
rename from src/test/java/GameEngineTest.java
rename to src/test/java/com/mapna/snake/GameEngineTest.java
index 0de27b7..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;
@@ -12,7 +14,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 +23,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 +34,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
@@ -71,7 +70,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.setFood(new Point(0, 0));
engine.tick(state);
@@ -83,7 +82,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.setFood(new Point(0, 0));
engine.tick(state);
@@ -94,14 +93,14 @@ void tickWrapsVerticallyAtTopEdge() {
void tickEatingFoodGrowsSnakeAndRespawnsFood() {
GameEngine engine = new GameEngine(new Random(42L));
GameState state = runningState(Snake.createFixed(10, 10));
- state.getLemon().setLocation(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
@@ -109,14 +108,39 @@ void tickDetectsSelfCollision() {
GameEngine engine = new GameEngine(new Random(1L));
GameState state = runningState(Snake.createFixed(5, 5));
state.setDirection(Direction.DOWN);
- state.setNextDirection(Direction.DOWN);
- state.getLemon().setLocation(0, 0);
+ engine.requestDirection(state, Direction.DOWN);
+ state.setFood(new Point(0, 0));
engine.tick(state);
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));
@@ -126,8 +150,8 @@ 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()));
+ assertFalse(state.getSnake().containsPoint(state.getFood()));
}
}
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)));
+ }
+}