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))); + } +}