From a08e150281b8ee6a40555300a47a5544022c080c Mon Sep 17 00:00:00 2001 From: Thibaud Date: Tue, 18 Aug 2026 18:29:56 +0200 Subject: [PATCH] Autosnake --- .gitignore | 24 ++ src/fr/gasser/autosnake/Game.java | 153 +++++++++++ src/fr/gasser/autosnake/GamePanel.java | 71 ++++++ src/fr/gasser/autosnake/KeyMap.java | 31 +++ src/fr/gasser/autosnake/core/AStarSearch.java | 133 ++++++++++ src/fr/gasser/autosnake/core/Cell.java | 5 + src/fr/gasser/autosnake/core/Direction.java | 17 ++ src/fr/gasser/autosnake/core/Grid.java | 9 + src/fr/gasser/autosnake/model/Direction.java | 21 ++ src/fr/gasser/autosnake/model/Snake.java | 240 ++++++++++++++++++ 10 files changed, 704 insertions(+) create mode 100644 .gitignore create mode 100644 src/fr/gasser/autosnake/Game.java create mode 100644 src/fr/gasser/autosnake/GamePanel.java create mode 100644 src/fr/gasser/autosnake/KeyMap.java create mode 100644 src/fr/gasser/autosnake/core/AStarSearch.java create mode 100644 src/fr/gasser/autosnake/core/Cell.java create mode 100644 src/fr/gasser/autosnake/core/Direction.java create mode 100644 src/fr/gasser/autosnake/core/Grid.java create mode 100644 src/fr/gasser/autosnake/model/Direction.java create mode 100644 src/fr/gasser/autosnake/model/Snake.java diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..524f096 --- /dev/null +++ b/.gitignore @@ -0,0 +1,24 @@ +# Compiled class file +*.class + +# Log file +*.log + +# BlueJ files +*.ctxt + +# Mobile Tools for Java (J2ME) +.mtj.tmp/ + +# Package Files # +*.jar +*.war +*.nar +*.ear +*.zip +*.tar.gz +*.rar + +# virtual machine crash logs, see http://www.java.com/en/download/help/error_hotspot.xml +hs_err_pid* +replay_pid* diff --git a/src/fr/gasser/autosnake/Game.java b/src/fr/gasser/autosnake/Game.java new file mode 100644 index 0000000..06ab6fc --- /dev/null +++ b/src/fr/gasser/autosnake/Game.java @@ -0,0 +1,153 @@ +package fr.gasser.autosnake; + +import fr.gasser.autosnake.core.AStarSearch; +import fr.gasser.autosnake.model.Direction; +import fr.gasser.autosnake.model.Snake; + +import javax.swing.*; +import java.awt.*; +import java.awt.event.KeyAdapter; +import java.awt.event.KeyEvent; +import java.util.ArrayDeque; +import java.util.Objects; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; + +public class Game extends JFrame { + private enum GameMode { + MOVE, FOLLOW + } + + static final Dimension SIZE = new Dimension(600, 600); + private static final int CELL_SIZE = 20; + private Direction direction = Direction.DOWN; + private GameMode mode = GameMode.MOVE; + private final int maxAstarIterations = 1000; + private static final int FRAME_DELAY_MS = 1000 / 60; + private static final double FIXED_STEP_SECONDS = 1.0 / 60.0; + private static final double MAX_FRAME_SECONDS = 0.25; + + private final ExecutorService pathExecutor = Executors.newSingleThreadExecutor(); + + private Game() throws HeadlessException { + super("Autosnake"); + setDefaultCloseOperation(EXIT_ON_CLOSE); + final Snake snake = new Snake(CELL_SIZE, SIZE); + GamePanel gamePanel = new GamePanel(snake); + this.addKeyListener(new KeyAdapter() { + @Override + public void keyPressed(KeyEvent e) { + if (e.getKeyCode() == KeyEvent.VK_M){ + mode = mode == GameMode.FOLLOW ? GameMode.MOVE : GameMode.FOLLOW; + setTitle(mode == GameMode.FOLLOW ? "Autosnake -- FOLLOW" : "Autosnake -- MOVE"); + } + + var mapped = KeyMap.getDirection(e.getKeyCode()); + if (mapped != null) direction = mapped; + } + }); + getContentPane().add(gamePanel); + pack(); + setVisible(true); + + var state = new Object() { + long last = System.nanoTime(); + double accumulator = 0.0; + java.util.Queue path = new ArrayDeque<>(); + boolean isPathComputing = false; + boolean isDead = false; + }; + + // Avoid a large burst after a pause or debugger break. + Timer gameLoop = new Timer(FRAME_DELAY_MS, e -> { + long now = System.nanoTime(); + double frameSeconds = (now - state.last) / 1_000_000_000.0; + state.last = now; + + // Avoid a large burst after a pause or debugger break. + frameSeconds = Math.min(frameSeconds, MAX_FRAME_SECONDS); + state.accumulator += frameSeconds; + + if (state.isDead) return; + + while (state.accumulator >= FIXED_STEP_SECONDS) { + state.accumulator -= FIXED_STEP_SECONDS; + + if (mode == GameMode.MOVE) { + state.path.clear(); + gamePanel.clearPath(); + } + + if (mode == GameMode.FOLLOW && state.path.isEmpty() && !state.isPathComputing) { + state.isPathComputing = true; + gamePanel.clearPath(); + var astar = snake.newAStarSearch(maxAstarIterations); + pathExecutor.submit(() -> { + var computedPath = astar.computePath(); + SwingUtilities.invokeLater(() -> { + state.isPathComputing = false; + if (mode != GameMode.FOLLOW) { + return; + } + state.path = computedPath; + if (state.path.isEmpty()) { + System.err.println("Unable to compute path."); + } + }); + }); + } + + if (mode == GameMode.FOLLOW && !state.path.isEmpty() && !state.isPathComputing) { + // Draw path + gamePanel.clearPath(); + for (var node : state.path) { + var loc = node.getLoc(); + gamePanel.addToPath(loc.x * CELL_SIZE, loc.y * CELL_SIZE); + } + followPath(state.path, snake); + } + + state.isDead = snake.update(direction, FIXED_STEP_SECONDS); + } + + gamePanel.repaint(); + }); + gameLoop.setCoalesce(true); + gameLoop.start(); + } + + private void followPath(java.util.Queue path, Snake snake) { + // TODO modify astar search to account for wrapping + Point next; + next = Objects.requireNonNull(path.peek()).getLoc(); + Rectangle head = snake.getHead(); + int headCellX = head.x / CELL_SIZE; + int headCellY = head.y / CELL_SIZE; + if (headCellX == next.x && headCellY == next.y) { + path.poll(); + } else { + int dx = next.x - headCellX; + int dy = next.y - headCellY; + + if (dx > 0) { + direction = Direction.RIGHT; + } else if (dx < 0) { + direction = Direction.LEFT; + } else if (dy > 0) { + direction = Direction.DOWN; + } else if (dy < 0) { + direction = Direction.UP; + } + } + } + + public static void main(String[] args) { + EventQueue.invokeLater(Game::new); + } + + @Override + public void dispose() { + pathExecutor.shutdownNow(); + super.dispose(); + } +} diff --git a/src/fr/gasser/autosnake/GamePanel.java b/src/fr/gasser/autosnake/GamePanel.java new file mode 100644 index 0000000..27a9476 --- /dev/null +++ b/src/fr/gasser/autosnake/GamePanel.java @@ -0,0 +1,71 @@ +package fr.gasser.autosnake; + +import fr.gasser.autosnake.core.AStarSearch; +import fr.gasser.autosnake.model.Snake; + +import javax.swing.*; +import java.awt.*; +import java.util.ArrayList; +import java.util.Queue; + +public class GamePanel extends JPanel { + + private static final Color APPLE_COLOR = Color.RED; + private static final Color TRAIL_COLOR = Color.GREEN; + private static final Color HEAD_COLOR = Color.GREEN.darker(); + + private final Snake model; + private final java.util.List path = new ArrayList<>(); + + GamePanel(Snake model) { + this.model = model; + setOpaque(true); + setBackground(Color.BLACK); + } + + public void addToPath(int x, int y) { + path.add(new Point(x, y)); + } + + public void clearPath(){ + path.clear(); + } + + @Override + public Dimension getPreferredSize() { + return Game.SIZE; + } + + @Override + protected void paintComponent(Graphics g) { + super.paintComponent(g); + Graphics2D g2d = (Graphics2D) g; + + // JPanel clears an opaque component using its background before + // paintComponent returns. Do not clear the whole surface twice. + + // draw path + for (var current : path) { + g2d.setColor(Color.blue); + g2d.fill(new Rectangle(current.x, current.y, 20, 20)); + } + + g2d.setColor(APPLE_COLOR); + g2d.fill(model.getApple()); + + g2d.setColor(TRAIL_COLOR); + + model.getTrail().forEach(g2d::fill); + + g2d.setColor(HEAD_COLOR); + final Rectangle head = model.getHead(); + if (head != null) g2d.fill(head); + + + + + // Avoid choppy movement when not moving mouse cursor + // https://stackoverflow.com/a/26388175 + Toolkit.getDefaultToolkit().sync(); + } +} diff --git a/src/fr/gasser/autosnake/KeyMap.java b/src/fr/gasser/autosnake/KeyMap.java new file mode 100644 index 0000000..60c45e5 --- /dev/null +++ b/src/fr/gasser/autosnake/KeyMap.java @@ -0,0 +1,31 @@ +package fr.gasser.autosnake; + +import fr.gasser.autosnake.model.Direction; + +import java.awt.event.KeyEvent; + +public final class KeyMap { + private static final int[] KEY_CODES = { + KeyEvent.VK_UP, KeyEvent.VK_Z, + KeyEvent.VK_DOWN, KeyEvent.VK_S, + KeyEvent.VK_LEFT, KeyEvent.VK_Q, + KeyEvent.VK_RIGHT, KeyEvent.VK_D + }; + + private static final Direction[] DIRECTIONS = { + Direction.UP, Direction.UP, + Direction.DOWN, Direction.DOWN, + Direction.LEFT, Direction.LEFT, + Direction.RIGHT, Direction.RIGHT + }; + + public static Direction getDirection(int keyCode) { + Direction direction = null; + for (int i = 0; i < KEY_CODES.length; i++) { + if (KEY_CODES[i] == keyCode) { + direction = DIRECTIONS[i]; + } + } + return direction; + } +} \ No newline at end of file diff --git a/src/fr/gasser/autosnake/core/AStarSearch.java b/src/fr/gasser/autosnake/core/AStarSearch.java new file mode 100644 index 0000000..f9a75ff --- /dev/null +++ b/src/fr/gasser/autosnake/core/AStarSearch.java @@ -0,0 +1,133 @@ +package fr.gasser.autosnake.core; + +import java.awt.*; +import java.util.List; +import java.util.Queue; +import java.util.*; + +public class AStarSearch { + private final Grid grid; + private final Node start; + private final Node goal; + private final int maxIterations; + + public AStarSearch(Grid grid, Point start, Point goal, int maxIterations) { + this(grid, new Node(start), new Node(goal), maxIterations); + } + + private AStarSearch(Grid grid, Node start, Node goal, int maxIterations) { + this.grid = grid; + this.start = start; + this.goal = goal; + this.maxIterations = maxIterations; + } + + private Node search(Node start, Node goal) { + long startTime = System.nanoTime(); + PriorityQueue frontier = new PriorityQueue<>(); + Set explored = new HashSet<>(); + frontier.add(start); + int n = 0; + while (!frontier.isEmpty() && n < maxIterations) { + Node currentNode = frontier.poll(); + explored.add(currentNode); + + if (currentNode.loc.equals(goal.loc)) { + long endTime = System.nanoTime(); + System.out.printf("Found path in %s iterations and %s ms%n", n, (endTime - startTime) / 1000_000.0); + return currentNode; + } + + for (Node neighbor : getNeighbors(currentNode)) { + // Ignore the neighbor which is already evaluated. + if (explored.contains(neighbor)) continue; + if (!frontier.contains(neighbor)) { + neighbor.setParent(currentNode); + neighbor.cost++; + var dx = neighbor.loc.x - goal.loc.x; + var dy = neighbor.loc.y - goal.loc.y; + neighbor.hValue = neighbor.cost + (dx*dx + dy*dy); + frontier.add(neighbor); + } + } + n++; + } + + return null; + } + + private List getNeighbors(Node current) { + final Dimension gridSize = grid.getSize(); + final LinkedList neighbors = new LinkedList<>(); + for (Direction direction : Direction.values()) { + final Node node = new Node(current); + final Point loc = new Point(current.loc.x + direction.loc.x, current.loc.y + direction.loc.y); + // Don't overflow off the grid + if (loc.x < 0 || loc.x >= gridSize.width || loc.y < 0 || loc.y >= gridSize.height) continue; + // If the node is a wall, don't add it to the list + if (grid.get(loc).isObstacle()) continue; + node.setLoc(loc); + neighbors.add(node); + } + + return neighbors; + } + + public final Queue computePath() { + Node currentNode = search(start, goal); + LinkedList path = new LinkedList<>(); + + if (currentNode != null) { + while (currentNode.getParent() != null) { + path.addFirst(currentNode); + currentNode = currentNode.getParent(); + } + + } + return path; + } + + public static class Node implements Comparable { + private Node parent; + private Point loc; + private int cost; + private double hValue; // heuristic value + + Node(Point loc) { + this.loc = loc; + } + + Node(Node other) { + this.parent = other.parent; + this.loc = other.loc; + this.cost = other.cost; + this.hValue = other.hValue; + } + + public Point getLoc() { + return loc; + } + + void setLoc(Point point) { + this.loc = point; + } + + Node getParent() { + return parent; + } + + void setParent(Node parent) { + this.parent = parent; + } + + @Override + public int compareTo(Node other) { + return Double.compare(this.hValue, other.hValue); + } + + @Override + public String toString() { + return String.format("Node(%s)", getLoc()); + } + } +} \ No newline at end of file diff --git a/src/fr/gasser/autosnake/core/Cell.java b/src/fr/gasser/autosnake/core/Cell.java new file mode 100644 index 0000000..618eba6 --- /dev/null +++ b/src/fr/gasser/autosnake/core/Cell.java @@ -0,0 +1,5 @@ +package fr.gasser.autosnake.core; + +public interface Cell { + boolean isObstacle(); +} diff --git a/src/fr/gasser/autosnake/core/Direction.java b/src/fr/gasser/autosnake/core/Direction.java new file mode 100644 index 0000000..94051a9 --- /dev/null +++ b/src/fr/gasser/autosnake/core/Direction.java @@ -0,0 +1,17 @@ +package fr.gasser.autosnake.core; +import java.awt.*; +import java.util.Random; + +public enum Direction { + UP(0, -1), + DOWN(0, 1), + LEFT(-1, 0), + RIGHT(1, 0), + ; + + public final Point loc; + + Direction(int x, int y) { + loc = new Point(x, y); + } +} \ No newline at end of file diff --git a/src/fr/gasser/autosnake/core/Grid.java b/src/fr/gasser/autosnake/core/Grid.java new file mode 100644 index 0000000..fbdd9fb --- /dev/null +++ b/src/fr/gasser/autosnake/core/Grid.java @@ -0,0 +1,9 @@ +package fr.gasser.autosnake.core; + +import java.awt.*; + +public interface Grid { + Dimension getSize(); + + Cell get(Point loc); +} diff --git a/src/fr/gasser/autosnake/model/Direction.java b/src/fr/gasser/autosnake/model/Direction.java new file mode 100644 index 0000000..7390e4d --- /dev/null +++ b/src/fr/gasser/autosnake/model/Direction.java @@ -0,0 +1,21 @@ +package fr.gasser.autosnake.model; + +public enum Direction { + UP, DOWN, LEFT, RIGHT; + + public int dx() { + return switch (this) { + case LEFT -> -1; + case RIGHT -> 1; + default -> 0; + }; + } + + public int dy() { + return switch (this) { + case UP -> -1; + case DOWN -> 1; + default -> 0; + }; + } +} diff --git a/src/fr/gasser/autosnake/model/Snake.java b/src/fr/gasser/autosnake/model/Snake.java new file mode 100644 index 0000000..347830a --- /dev/null +++ b/src/fr/gasser/autosnake/model/Snake.java @@ -0,0 +1,240 @@ +package fr.gasser.autosnake.model; + +import fr.gasser.autosnake.core.AStarSearch; +import fr.gasser.autosnake.core.Cell; +import fr.gasser.autosnake.core.Grid; + +import java.awt.*; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.HashSet; +import java.util.stream.Stream; + +public class Snake { + private final int cellSize; + private final V2d bounds; + private V2d head; + private final ArrayList trail; + private double length; + private V2d apple; + private int speed = 100; + + public Snake(int cellSize, Dimension screen) { + this.cellSize = cellSize; + this.head = new V2d(5 * cellSize, 5 * cellSize); + this.length = cellSize; + this.trail = new ArrayList<>(); // TODO trim trail + this.bounds = new V2d(screen.width, screen.height); + this.apple = this.randomApple(); + } + + public boolean update(Direction d, double dt) { + double x = head.x + dt * d.dx() * this.speed; + double y = head.y + dt * d.dy() * this.speed; + + // record previous head pos into trail and move head + trail.add(head); + head = new V2d(x, y); + + // check collision with apple + var wrappedHead = head.wrap(bounds); + if (wrappedHead.wrappedDist(apple, bounds) < cellSize) { + this.apple = randomApple(); + this.length += cellSize; + this.speed += 10; + } + + trimTrail(); // trim excess trail + + + // check for collisions with snake body + var collisionPoint = checkBodyCollisions(); + if (collisionPoint != null) { + System.out.println("Collision at " + collisionPoint); + return true; + } + + return false; + } + + private Point checkBodyCollisions() { + Point headCell = cellOf(head); + boolean leftHeadCell = false; + for (int i = trail.size() - 1; i >= 0; --i) { + var segment = trail.get(i); + Point bodyCell = cellOf(segment); + // Ignore the contiguous trail portion created while the head + // was moving through its current cell. + if (!leftHeadCell) { + if (bodyCell.equals(headCell)) { + continue; + } + leftHeadCell = true; + } + if (headCell.equals(bodyCell)) { + return headCell; + } + } + return null; + } + + private HashSet getBodyCells() { + Point headCell = cellOf(head); + HashSet cells = new HashSet<>(); + boolean leftHeadCell = false; + for (int i = trail.size() - 1; i >= 0; --i) { + Point bodyCell = cellOf(trail.get(i)); + + // Ignore all historical samples belonging to the head's + // current cell. These are the neck/current-cell samples. + if (!leftHeadCell) { + if (bodyCell.equals(headCell)) { + continue; + } + leftHeadCell = true; + } + + cells.add(bodyCell); + } + return cells; + } + + private Point cellOf(V2d pos) { + var wrapped = pos.wrap(bounds); + return new Point((int) (wrapped.x / cellSize), (int) (wrapped.y / cellSize)); + } + + private void trimTrail() { + int trimPoint; + V2d current = head; + double cum = 0; + for (trimPoint = trail.size() - 1; trimPoint >= 0; --trimPoint) { + var segment = trail.get(trimPoint); + var dx = segment.x - current.x; + var dy = segment.y - current.y; + var distance = Math.hypot(dx, dy); + cum += distance; + if (cum > length) { + break; + } + current = segment; + } + + if (trimPoint > 0) { + var before = trail.size(); + trail.subList(0, trimPoint).clear(); + var after = trail.size(); + //System.out.printf("Trail trimmed from %s to %s%n", before, after); + } + } + + public AStarSearch newAStarSearch(int maxIterations) { + var wrappedHead = this.head.wrap(this.bounds); + var start = new Point((int) (wrappedHead.x / cellSize), (int) (wrappedHead.y / cellSize)); + var goal = new Point((int) (this.apple.x / cellSize), (int) (this.apple.y / cellSize)); + var grid = new Grid() { + @Override + public Dimension getSize() { + return new Dimension((int) (bounds.x / cellSize), (int) (bounds.y / cellSize)); + } + + @Override + public Cell get(Point loc) { + V2d pos = new V2d(loc.x * cellSize, loc.y * cellSize); + if (pos.wrappedDist(wrappedHead, bounds) < cellSize) return () -> true; + var bodyCells = getBodyCells(); // TODO inline trail sampling in here to remove cellOf + if (bodyCells.contains(loc)) return () -> true; // body is an obstacle + return () -> false; + } + }; + return new AStarSearch(grid, start, goal, maxIterations); + } + + public Shape getApple() { + return new Rectangle((int) this.apple.x, (int) this.apple.y, cellSize, cellSize); + } + + public Stream getTrail() { + var visibleTrail = new ArrayList(); + var remaining = this.length; + var current = this.head; + + // Sample points from the trail every cellSize until the accumulated distance is >= this.length. + for (var i = this.trail.size() - 1; i >= 0 && remaining > 0; --i) { + var previous = this.trail.get(i); + var dx = previous.x - current.x; + var dy = previous.y - current.y; + var d = Math.hypot(dx, dy); + if (d > remaining) { + // The tail ends somewhere between current and previous. + var ratio = remaining / d; + + var tailX = current.x + dx * ratio; + var tailY = current.y + dy * ratio; + + visibleTrail.add(getRect(tailX, tailY)); + break; + } + visibleTrail.add(getRect(previous.x, previous.y)); + remaining -= d; + current = previous; + } + + + return visibleTrail.stream(); + } + + public Rectangle getHead() { + return getRect(this.head.x, this.head.y); + } + + private V2d randomApple(){ + int columns = (int) bounds.x / cellSize; + int rows = (int) bounds.y / cellSize; + int column = (int) (Math.random() * columns); + int row = (int) (Math.random() * rows); + return new V2d(column * cellSize, row * cellSize); +} + +private Rectangle getRect(double x, double y) { + var wrapped = new V2d(x, y).wrap(bounds); + return new Rectangle((int) wrapped.x, (int) wrapped.y, cellSize, cellSize); + } + + private static double trueMod(double a, double b) { + // mod that works correctly for negative numbers + return ((a % b) + b) % b; + } + + private static class V2d { + private final double x; + private final double y; + + public V2d(double x, double y) { + this.x = x; + this.y = y; + } + + @Override + public String toString() { + return String.format("x=%s y=%s", x, y); + } + + private double wrappedDist(V2d other, V2d bounds) { + var dx = Math.abs(this.x - other.x); + var dy = Math.abs(this.y - other.y); + + dx = Math.min(dx, bounds.x - dx); + dy = Math.min(dy, bounds.y - dy); + + return Math.hypot(dx, dy); + } + + private V2d wrap(V2d bounds) { + // convert world space coordinates to wrapped coordinates + var newX = trueMod(x, bounds.x); + var newY = trueMod(y, bounds.y); + return new V2d(newX, newY); + } + } +}