Autosnake

This commit is contained in:
2026-08-18 18:29:56 +02:00
commit a08e150281
10 changed files with 704 additions and 0 deletions
+24
View File
@@ -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*
+153
View File
@@ -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<AStarSearch.Node> 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<AStarSearch.Node> 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();
}
}
+71
View File
@@ -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<Point> 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();
}
}
+31
View File
@@ -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;
}
}
@@ -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<Node> frontier = new PriorityQueue<>();
Set<Node> 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<Node> getNeighbors(Node current) {
final Dimension gridSize = grid.getSize();
final LinkedList<Node> 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<Node> computePath() {
Node currentNode = search(start, goal);
LinkedList<Node> path = new LinkedList<>();
if (currentNode != null) {
while (currentNode.getParent() != null) {
path.addFirst(currentNode);
currentNode = currentNode.getParent();
}
}
return path;
}
public static class Node implements Comparable<Node> {
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());
}
}
}
+5
View File
@@ -0,0 +1,5 @@
package fr.gasser.autosnake.core;
public interface Cell {
boolean isObstacle();
}
@@ -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);
}
}
+9
View File
@@ -0,0 +1,9 @@
package fr.gasser.autosnake.core;
import java.awt.*;
public interface Grid {
Dimension getSize();
Cell get(Point loc);
}
@@ -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;
};
}
}
+240
View File
@@ -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<V2d> 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<Point> getBodyCells() {
Point headCell = cellOf(head);
HashSet<Point> 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<Rectangle> getTrail() {
var visibleTrail = new ArrayList<Rectangle>();
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);
}
}
}