connect4 with minimax AI

This commit is contained in:
Ticho Hidding
2025-10-19 22:01:33 +02:00
parent df493a5eba
commit c3f764f33d
12 changed files with 520 additions and 41 deletions

View File

@@ -3,12 +3,17 @@ package org.toop.app;
public class GameInformation { public class GameInformation {
public enum Type { public enum Type {
TICTACTOE, TICTACTOE,
REVERSI; REVERSI,
CONNECT4,
BATTLESHIP;
public static int playerCount(Type type) { public static int playerCount(Type type) {
return switch (type) { return switch (type) {
case TICTACTOE -> 2; case TICTACTOE -> 2;
case REVERSI -> 2; case REVERSI -> 2;
case CONNECT4 -> 2;
case BATTLESHIP -> 2;
}; };
} }
@@ -16,6 +21,8 @@ public class GameInformation {
return switch (type) { return switch (type) {
case TICTACTOE -> 5; // Todo. 5 seems to always draw or win. could increase to 9 but that might affect performance case TICTACTOE -> 5; // Todo. 5 seems to always draw or win. could increase to 9 but that might affect performance
case REVERSI -> 10; // Todo. 10 is a guess. might be too slow or too bad. case REVERSI -> 10; // Todo. 10 is a guess. might be too slow or too bad.
case CONNECT4 -> 7;
case BATTLESHIP -> 5;
}; };
} }
} }

View File

@@ -1,6 +1,7 @@
package org.toop.app; package org.toop.app;
import com.google.common.util.concurrent.AbstractScheduledService; import com.google.common.util.concurrent.AbstractScheduledService;
import org.toop.app.game.Connect4Game;
import org.toop.app.game.ReversiGame; import org.toop.app.game.ReversiGame;
import org.toop.app.game.TicTacToeGame; import org.toop.app.game.TicTacToeGame;
import org.toop.app.view.ViewStack; import org.toop.app.view.ViewStack;
@@ -40,7 +41,11 @@ public final class Server {
return GameInformation.Type.TICTACTOE; return GameInformation.Type.TICTACTOE;
} else if (game.equalsIgnoreCase("reversi")) { } else if (game.equalsIgnoreCase("reversi")) {
return GameInformation.Type.REVERSI; return GameInformation.Type.REVERSI;
} } else if (game.equalsIgnoreCase("connect4")) {
return GameInformation.Type.CONNECT4;
} else if (game.equalsIgnoreCase("battleship")) {
return GameInformation.Type.BATTLESHIP;
}
return null; return null;
} }
@@ -123,6 +128,7 @@ public final class Server {
switch (type) { switch (type) {
case TICTACTOE: new TicTacToeGame(information, myTurn, this::forfeitGame, this::exitGame, this::sendMessage); break; case TICTACTOE: new TicTacToeGame(information, myTurn, this::forfeitGame, this::exitGame, this::sendMessage); break;
case REVERSI: new ReversiGame(information, myTurn, this::forfeitGame, this::exitGame, this::sendMessage); break; case REVERSI: new ReversiGame(information, myTurn, this::forfeitGame, this::exitGame, this::sendMessage); break;
case CONNECT4: new Connect4Game(information, myTurn, this::forfeitGame, this::exitGame, this::sendMessage); break;
} }
} }
}).postEvent(); }).postEvent();
@@ -166,6 +172,7 @@ public final class Server {
switch (type) { switch (type) {
case TICTACTOE: new TicTacToeGame(information, myTurn, this::forfeitGame, this::exitGame, this::sendMessage); break; case TICTACTOE: new TicTacToeGame(information, myTurn, this::forfeitGame, this::exitGame, this::sendMessage); break;
case REVERSI: new ReversiGame(information, myTurn, this::forfeitGame, this::exitGame, this::sendMessage); break; case REVERSI: new ReversiGame(information, myTurn, this::forfeitGame, this::exitGame, this::sendMessage); break;
case CONNECT4: new Connect4Game(information, myTurn, this::forfeitGame, this::exitGame, this::sendMessage); break;
} }
} }
}); });

View File

@@ -0,0 +1,13 @@
package org.toop.app.canvas;
import javafx.scene.paint.Color;
import java.util.function.Consumer;
public class Connect4Canvas extends GameCanvas {
public Connect4Canvas(Color color, int width, int height, Consumer<Integer> onCellClicked) {
super(color, width, height, 6, 7, 10, true, onCellClicked);
}
}

View File

@@ -5,6 +5,7 @@ import javafx.scene.canvas.GraphicsContext;
import javafx.scene.input.MouseButton; import javafx.scene.input.MouseButton;
import javafx.scene.paint.Color; import javafx.scene.paint.Color;
import java.util.Arrays;
import java.util.function.Consumer; import java.util.function.Consumer;
public abstract class GameCanvas { public abstract class GameCanvas {
@@ -48,31 +49,30 @@ public abstract class GameCanvas {
cells = new Cell[rowSize * columnSize]; cells = new Cell[rowSize * columnSize];
final float cellWidth = ((float)width - gapSize * rowSize - gapSize) / rowSize; final float cellWidth = ((float)width - gapSize * columnSize - gapSize) / columnSize;
final float cellHeight = ((float)height - gapSize * columnSize - gapSize) / columnSize; final float cellHeight = ((float)height - gapSize * rowSize - gapSize) / rowSize;
for (int y = 0; y < columnSize; y++) { for (int y = 0; y < rowSize; y++) {
final float startY = y * cellHeight + y * gapSize + gapSize; final float startY = y * cellHeight + y * gapSize + gapSize;
for (int x = 0; x < rowSize; x++) { for (int x = 0; x < columnSize; x++) {
final float startX = x * cellWidth + x * gapSize + gapSize; final float startX = x * cellWidth + x * gapSize + gapSize;
cells[x + y * rowSize] = new Cell(startX, startY, cellWidth, cellHeight); cells[x + y * columnSize] = new Cell(startX, startY, cellWidth, cellHeight);
} }
} }
canvas.setOnMouseClicked(event -> { canvas.setOnMouseClicked(event -> {
if (event.getButton() != MouseButton.PRIMARY) { if (event.getButton() != MouseButton.PRIMARY) {
return; return;
} }
final int column = (int)((event.getX() / this.width) * rowSize); final int column = (int)((event.getX() / this.width) * columnSize);
final int row = (int)((event.getY() / this.height) * columnSize); final int row = (int)((event.getY() / this.height) * rowSize);
final Cell cell = cells[column + row * rowSize]; final Cell cell = cells[column + row * columnSize];
if (cell.isInside(event.getX(), event.getY())) { if (cell.isInside(event.getX(), event.getY())) {
event.consume(); event.consume();
onCellClicked.accept(column + row * rowSize); onCellClicked.accept(column + row * columnSize);
} }
}); });
@@ -91,12 +91,12 @@ public abstract class GameCanvas {
public void render() { public void render() {
graphics.setFill(color); graphics.setFill(color);
for (int x = 0; x < rowSize - 1; x++) { for (int x = 0; x < columnSize - 1; x++) {
final float start = cells[x].x + cells[x].width; final float start = cells[x].x + cells[x].width;
graphics.fillRect(start, gapSize, gapSize, height - gapSize * 2); graphics.fillRect(start, gapSize, gapSize, height - gapSize * 2);
} }
for (int y = 0; y < columnSize - 1; y++) { for (int y = 0; y < rowSize; y++) {
final float start = cells[y * rowSize].y + cells[y * rowSize].height; final float start = cells[y * rowSize].y + cells[y * rowSize].height;
graphics.fillRect(gapSize, start, width - gapSize * 2, gapSize); graphics.fillRect(gapSize, start, width - gapSize * 2, gapSize);
} }
@@ -121,6 +121,33 @@ public abstract class GameCanvas {
graphics.fillRect(x, y, width, height); graphics.fillRect(x, y, width, height);
} }
public void drawX(Color color, int cell) {
graphics.setStroke(color);
graphics.setLineWidth(gapSize);
final float x = cells[cell].x() + gapSize;
final float y = cells[cell].y() + gapSize;
final float width = cells[cell].width() - gapSize * 2;
final float height = cells[cell].height() - gapSize * 2;
graphics.strokeLine(x, y, x + width, y + height);
graphics.strokeLine(x + width, y, x, y + height);
}
public void drawO(Color color, int cell) {
graphics.setStroke(color);
graphics.setLineWidth(gapSize);
final float x = cells[cell].x() + gapSize;
final float y = cells[cell].y() + gapSize;
final float width = cells[cell].width() - gapSize * 2;
final float height = cells[cell].height() - gapSize * 2;
graphics.strokeOval(x, y, width, height);
}
public Canvas getCanvas() { public Canvas getCanvas() {
return canvas; return canvas;
} }

View File

@@ -9,30 +9,5 @@ public final class TicTacToeCanvas extends GameCanvas {
super(color, width, height, 3, 3, 30, false, onCellClicked); super(color, width, height, 3, 3, 30, false, onCellClicked);
} }
public void drawX(Color color, int cell) {
graphics.setStroke(color);
graphics.setLineWidth(gapSize);
final float x = cells[cell].x() + gapSize;
final float y = cells[cell].y() + gapSize;
final float width = cells[cell].width() - gapSize * 2;
final float height = cells[cell].height() - gapSize * 2;
graphics.strokeLine(x, y, x + width, y + height);
graphics.strokeLine(x + width, y, x, y + height);
}
public void drawO(Color color, int cell) {
graphics.setStroke(color);
graphics.setLineWidth(gapSize);
final float x = cells[cell].x() + gapSize;
final float y = cells[cell].y() + gapSize;
final float width = cells[cell].width() - gapSize * 2;
final float height = cells[cell].height() - gapSize * 2;
graphics.strokeOval(x, y, width, height);
}
} }

View File

@@ -0,0 +1,263 @@
package org.toop.app.game;
import javafx.geometry.Pos;
import javafx.scene.paint.Color;
import org.toop.app.App;
import org.toop.app.GameInformation;
import org.toop.app.canvas.Connect4Canvas;
import org.toop.app.view.ViewStack;
import org.toop.app.view.views.GameView;
import org.toop.app.view.views.LocalMultiplayerView;
import org.toop.framework.eventbus.EventFlow;
import org.toop.framework.networking.events.NetworkEvents;
import org.toop.game.Connect4.Connect4;
import org.toop.game.Connect4.Connect4AI;
import org.toop.game.Game;
import java.util.concurrent.BlockingQueue;
import java.util.concurrent.LinkedBlockingQueue;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.function.Consumer;
public class Connect4Game {
private final GameInformation information;
private final int myTurn;
private final BlockingQueue<Game.Move> moveQueue;
private final Connect4 game;
private final Connect4AI ai;
private final int columnSize = 7;
private final int rowSize = 6;
private final GameView view;
private final Connect4Canvas canvas;
private AtomicBoolean isRunning;
public Connect4Game(GameInformation information, int myTurn, Runnable onForfeit, Runnable onExit, Consumer<String> onMessage) {
this.information = information;
this.myTurn = myTurn;
moveQueue = new LinkedBlockingQueue<Game.Move>();
game = new Connect4();
ai = new Connect4AI();
isRunning = new AtomicBoolean(true);
if (onForfeit == null || onExit == null) {
view = new GameView(null, () -> {
isRunning.set(false);
ViewStack.push(new LocalMultiplayerView(information));
}, null);
} else {
view = new GameView(onForfeit, () -> {
isRunning.set(false);
onExit.run();
}, onMessage);
}
canvas = new Connect4Canvas(Color.GRAY,
(App.getHeight() / 4) * 3, (App.getHeight() / 4) * 3,
(cell) -> {
if (onForfeit == null || onExit == null) {
if (information.players[game.getCurrentTurn()].isHuman) {
final char value = game.getCurrentTurn() == 0? 'X' : 'O';
try {
moveQueue.put(new Game.Move(cell%columnSize, value));
} catch (InterruptedException _) {}
}
} else {
if (information.players[0].isHuman) {
final char value = myTurn == 0? 'X' : 'O';
try {
moveQueue.put(new Game.Move(cell%columnSize, value));
} catch (InterruptedException _) {}
}
}
});
view.add(Pos.CENTER, canvas.getCanvas());
ViewStack.push(view);
if (onForfeit == null || onExit == null) {
new Thread(this::localGameThread).start();
} else {
new EventFlow()
.listen(NetworkEvents.GameMoveResponse.class, this::onMoveResponse)
.listen(NetworkEvents.YourTurnResponse.class, this::onYourTurnResponse)
.listen(NetworkEvents.ReceivedMessage.class, this::onReceivedMessage);
setGameLabels(myTurn == 0);
}
updateCanvas();
}
public Connect4Game(GameInformation information) {
this(information, 0, null, null, null);
}
private void localGameThread() {
while (isRunning.get()) {
final int currentTurn = game.getCurrentTurn();
final char currentValue = currentTurn == 0? 'X' : 'O';
final int nextTurn = (currentTurn + 1) % GameInformation.Type.playerCount(information.type);
view.nextPlayer(information.players[currentTurn].isHuman,
information.players[currentTurn].name,
String.valueOf(currentValue),
information.players[nextTurn].name);
Game.Move move = null;
if (information.players[currentTurn].isHuman) {
try {
final Game.Move wants = moveQueue.take();
final Game.Move[] legalMoves = game.getLegalMoves();
for (final Game.Move legalMove : legalMoves) {
if (legalMove.position() == wants.position() &&
legalMove.value() == wants.value()) {
move = wants;
break;
}
}
} catch (InterruptedException _) {}
} else {
final long start = System.currentTimeMillis();
move = ai.findBestMove(game, information.players[currentTurn].computerDifficulty);
if (information.players[currentTurn].computerThinkTime > 0) {
final long elapsedTime = System.currentTimeMillis() - start;
final long sleepTime = Math.abs(information.players[currentTurn].computerThinkTime * 1000L - elapsedTime);
try {
Thread.sleep((long)(sleepTime * Math.random()));
} catch (InterruptedException _) {}
}
}
if (move == null) {
continue;
}
final Game.State state = game.play(move);
updateCanvas();
/*
if (move.value() == 'X') {
canvas.drawX(Color.INDIANRED, move.position());
} else if (move.value() == 'O') {
canvas.drawO(Color.ROYALBLUE, move.position());
}
*/
if (state != Game.State.NORMAL) {
if (state == Game.State.WIN) {
view.gameOver(true, information.players[currentTurn].name);
} else if (state == Game.State.DRAW) {
view.gameOver(false, "");
}
isRunning.set(false);
}
}
}
private void onMoveResponse(NetworkEvents.GameMoveResponse response) {
if (!isRunning.get()) {
return;
}
char playerChar;
if (response.player().equalsIgnoreCase(information.players[0].name)) {
playerChar = myTurn == 0? 'X' : 'O';
} else {
playerChar = myTurn == 0? 'O' : 'X';
}
final Game.Move move = new Game.Move(Integer.parseInt(response.move()), playerChar);
final Game.State state = game.play(move);
if (state != Game.State.NORMAL) {
if (state == Game.State.WIN) {
if (response.player().equalsIgnoreCase(information.players[0].name)) {
view.gameOver(true, information.players[0].name);
} else {
view.gameOver(false, information.players[1].name);
}
} else if (state == Game.State.DRAW) {
view.gameOver(false, "");
}
}
if (move.value() == 'X') {
canvas.drawX(Color.INDIANRED, move.position());
} else if (move.value() == 'O') {
canvas.drawO(Color.ROYALBLUE, move.position());
}
updateCanvas();
setGameLabels(game.getCurrentTurn() == myTurn);
}
private void onYourTurnResponse(NetworkEvents.YourTurnResponse response) {
new EventFlow().addPostEvent(new NetworkEvents.SendCommand(response.clientId(), "MESSAGE hoi"))
.postEvent();
if (!isRunning.get()) {
return;
}
moveQueue.clear();
int position = -1;
if (information.players[0].isHuman) {
try {
position = moveQueue.take().position();
} catch (InterruptedException _) {}
} else {
final Game.Move move = ai.findBestMove(game, information.players[0].computerDifficulty);
assert move != null;
position = move.position();
}
new EventFlow().addPostEvent(new NetworkEvents.SendMove(response.clientId(), (short)position))
.postEvent();
}
private void onReceivedMessage(NetworkEvents.ReceivedMessage msg) {
if (!isRunning.get()) {
return;
}
view.updateChat(msg.message());
}
private void updateCanvas() {
canvas.clear();
canvas.render();
for (int i = 0; i < game.board.length; i++) {
if (game.board[i] == 'X') {
canvas.drawX(Color.RED, i);
} else if (game.board[i] == 'O') {
canvas.drawO(Color.BLUE, i);
}
}
}
private void setGameLabels(boolean isMe) {
final int currentTurn = game.getCurrentTurn();
final char currentValue = currentTurn == 0? 'X' : 'O';
view.nextPlayer(isMe,
information.players[isMe? 0 : 1].name,
String.valueOf(currentValue),
information.players[isMe? 1 : 0].name);
}
}

View File

@@ -1,6 +1,7 @@
package org.toop.app.view.views; package org.toop.app.view.views;
import org.toop.app.GameInformation; import org.toop.app.GameInformation;
import org.toop.app.game.Connect4Game;
import org.toop.app.game.ReversiGame; import org.toop.app.game.ReversiGame;
import org.toop.app.game.TicTacToeGame; import org.toop.app.game.TicTacToeGame;
import org.toop.app.view.View; import org.toop.app.view.View;
@@ -45,6 +46,8 @@ public final class LocalMultiplayerView extends View {
switch (information.type) { switch (information.type) {
case TICTACTOE: new TicTacToeGame(information); break; case TICTACTOE: new TicTacToeGame(information); break;
case REVERSI: new ReversiGame(information); break; case REVERSI: new ReversiGame(information); break;
case CONNECT4: new Connect4Game(information); break;
//case BATTLESHIP: new BattleshipGame(information); break;
} }
}); });

View File

@@ -23,10 +23,15 @@ public final class LocalView extends View {
reversiButton.setText(AppContext.getString("reversi")); reversiButton.setText(AppContext.getString("reversi"));
reversiButton.setOnAction(_ -> { ViewStack.push(new LocalMultiplayerView(GameInformation.Type.REVERSI)); }); reversiButton.setOnAction(_ -> { ViewStack.push(new LocalMultiplayerView(GameInformation.Type.REVERSI)); });
add(Pos.CENTER, final Button connect4Button = button();
connect4Button.setText(AppContext.getString("connect4"));
connect4Button.setOnAction(_ -> { ViewStack.push(new LocalMultiplayerView(GameInformation.Type.CONNECT4)); });
add(Pos.CENTER,
fit(vboxFill( fit(vboxFill(
ticTacToeButton, ticTacToeButton,
reversiButton reversiButton,
connect4Button
)) ))
); );

View File

@@ -62,6 +62,7 @@ style=Style
the-game-ended-in-a-draw=The game ended in a draw the-game-ended-in-a-draw=The game ended in a draw
theme=Theme theme=Theme
tic-tac-toe=Tic Tac Toe tic-tac-toe=Tic Tac Toe
connect4=Connect 4
to-a-game-of=to a game of to-a-game-of=to a game of
volume=Volume volume=Volume
windowed=Windowed windowed=Windowed

View File

@@ -62,6 +62,7 @@ style=Stijl
the-game-ended-in-a-draw=Het spel eindigde in een gelijkspel the-game-ended-in-a-draw=Het spel eindigde in een gelijkspel
theme=Thema theme=Thema
tic-tac-toe=Boter Kaas en Eieren tic-tac-toe=Boter Kaas en Eieren
connect4=Vier op een rij
to-a-game-of=voor een spelletje to-a-game-of=voor een spelletje
volume=Volume volume=Volume
windowed=Venstermodus windowed=Venstermodus

View File

@@ -0,0 +1,114 @@
package org.toop.game.Connect4;
import org.toop.game.TurnBasedGame;
import java.util.ArrayList;
public class Connect4 extends TurnBasedGame {
private int movesLeft;
public Connect4() {
super(6, 7, 2);
movesLeft = board.length;
}
public Connect4(Connect4 other) {
super(other);
movesLeft = other.movesLeft;
}
@Override
public Move[] getLegalMoves() {
final ArrayList<Move> legalMoves = new ArrayList<>();
final char currentValue = getCurrentValue();
for (int i = 0; i < columnSize; i++) {
if (board[i] == EMPTY) {
legalMoves.add(new Move(i, currentValue));
}
}
return legalMoves.toArray(new Move[0]);
}
@Override
public State play(Move move) {
assert move != null;
assert move.position() >= 0 && move.position() < board.length;
assert move.value() == getCurrentValue();
int lowestEmptySpot = move.position();
for (int i = 0; i < rowSize; i++) {
int checkMovePosition = move.position() + columnSize * i;
if (checkMovePosition < board.length) {
if (board[checkMovePosition] == EMPTY) {
lowestEmptySpot = checkMovePosition;
}
}
}
board[lowestEmptySpot] = move.value();
movesLeft--;
if (checkForWin()) {
return State.WIN;
}
nextTurn();
return State.NORMAL;
}
private boolean checkForWin() {
char[][] boardGrid = makeBoardAGrid();
for (int row = 0; row < rowSize; row++) {
for (int col = 0; col < columnSize; col++) {
char cell = boardGrid[row][col];
if (cell == ' ' || cell == 0) continue;
if (col + 3 < columnSize &&
cell == boardGrid[row][col + 1] &&
cell == boardGrid[row][col + 2] &&
cell == boardGrid[row][col + 3]) {
return true;
}
if (row + 3 < rowSize &&
cell == boardGrid[row + 1][col] &&
cell == boardGrid[row + 2][col] &&
cell == boardGrid[row + 3][col]) {
return true;
}
if (row + 3 < rowSize && col + 3 < columnSize &&
cell == boardGrid[row + 1][col + 1] &&
cell == boardGrid[row + 2][col + 2] &&
cell == boardGrid[row + 3][col + 3]) {
return true;
}
if (row + 3 < rowSize && col - 3 >= 0 &&
cell == boardGrid[row + 1][col - 1] &&
cell == boardGrid[row + 2][col - 2] &&
cell == boardGrid[row + 3][col - 3]) {
return true;
}
}
}
return false;
}
public char[][] makeBoardAGrid() {
char[][] boardGrid = new char[rowSize][columnSize];
for (int i = 0; i < rowSize*columnSize; i++) {
boardGrid[i / columnSize][i % columnSize] = board[i]; //boardGrid[y -> row] [x -> column]
}
return boardGrid;
}
private char getCurrentValue() {
return currentTurn == 0 ? 'X' : 'O';
}
}

View File

@@ -0,0 +1,63 @@
package org.toop.game.Connect4;
import org.toop.game.AI;
import org.toop.game.Game;
import org.toop.game.tictactoe.TicTacToe;
public class Connect4AI extends AI<Connect4> {
public Game.Move findBestMove(Connect4 game, int depth) {
assert game != null;
assert depth >= 0;
final Game.Move[] legalMoves = game.getLegalMoves();
if (legalMoves.length <= 0) {
return null;
}
int bestScore = -depth;
Game.Move bestMove = null;
for (final Game.Move move : legalMoves) {
final int score = getMoveScore(game, depth, move, true);
if (score > bestScore) {
bestMove = move;
bestScore = score;
}
}
return bestMove != null? bestMove : legalMoves[(int)(Math.random() * legalMoves.length)];
}
private int getMoveScore(Connect4 game, int depth, Game.Move move, boolean maximizing) {
final Connect4 copy = new Connect4(game);
final Game.State state = copy.play(move);
switch (state) {
case Game.State.DRAW: return 0;
case Game.State.WIN: return maximizing? depth + 1 : -depth - 1;
}
if (depth <= 0) {
return 0;
}
final Game.Move[] legalMoves = copy.getLegalMoves();
int score = maximizing? depth + 1 : -depth - 1;
for (final Game.Move next : legalMoves) {
if (maximizing) {
score = Math.min(score, getMoveScore(copy, depth - 1, next, false));
} else {
score = Math.max(score, getMoveScore(copy, depth - 1, next, true));
}
}
return score;
}
}