GitHub
The entire project relies here (ConnectFourFX.java) and is dependent on Connect4.java.
Code
com.github.coderodde.game.connect4.ConnectFourBoard.java:
package com.github.coderodde.game.connect4;
import com.github.coderodde.game.zerosum.PlayerType;
import com.github.coderodde.game.zerosum.GameState;
import java.awt.Point;
import java.util.ArrayList;
import java.util.List;
/**
* This class implements a board that corresponds to a game state in the game
* search tree.
*
* @version 1.0.0 (Jun 5, 2024)
* @since 1.0.0 (Jun 5, 2024)
*/
public class ConnectFourBoard implements GameState<ConnectFourBoard> {
public static final int ROWS = 6;
public static final int COLUMNS = 7;
public static final int VICTORY_LENGTH = 4;
final PlayerType[] boardData = new PlayerType[ROWS * COLUMNS];
public ConnectFourBoard(final ConnectFourBoard other) {
System.arraycopy(other.boardData, 0, boardData, 0, ROWS * COLUMNS);
}
public ConnectFourBoard() {
}
@Override
public String toString() {
final StringBuilder sb = new StringBuilder();
for (int y = 0; y < ROWS; y++) {
// Build the row:
for (int x = 0; x < COLUMNS; x++) {
sb.append("|");
sb.append(getCellChar(get(x, y)));
}
sb.append("|\n");
}
sb.append("+-+-+-+-+-+-+-+\n");
sb.append(" 1 2 3 4 5 6 7");
return sb.toString();
}
@Override
public List<ConnectFourBoard> expand(final PlayerType playerType) {
final List<ConnectFourBoard> children = new ArrayList<>(COLUMNS);
for (int x = 0; x < COLUMNS; x++) {
if (notFullAtX(x)) {
children.add(dropAtX(x, playerType));
}
}
return children;
}
@Override
public boolean isWinningFor(final PlayerType playerType) {
return hasAscendingDiagonalStrike(playerType, VICTORY_LENGTH) ||
hasDescendingDiagonalStrike(playerType, VICTORY_LENGTH) ||
hasHorizontalStrike(playerType, VICTORY_LENGTH) ||
hasVerticalStrike(playerType, VICTORY_LENGTH);
}
@Override
public boolean isTie() {
for (int x = 0; x < COLUMNS; x++) {
if (get(x, 0) == null) {
return false;
}
}
return true;
}
public List<Point> getWinningPattern() {
if (!isWinningFor(PlayerType.MINIMIZING_PLAYER) &&
!isWinningFor(PlayerType.MAXIMIZING_PLAYER)) {
return null;
}
// Check whether the minimizing/human player has a winning pattern:
List<Point> winningPattern =
tryLoadAscendingWinningPattern(PlayerType.MINIMIZING_PLAYER);
if (winningPattern != null) {
return winningPattern;
}
winningPattern =
tryLoadDescendingWinningPattern(PlayerType.MINIMIZING_PLAYER);
if (winningPattern != null) {
return winningPattern;
}
winningPattern =
tryLoadHorizontalWinningPattern(PlayerType.MINIMIZING_PLAYER);
if (winningPattern != null) {
return winningPattern;
}
winningPattern =
tryLoadVerticalWinningPattern(PlayerType.MINIMIZING_PLAYER);
if (winningPattern != null) {
return winningPattern;
}
// Check whether the maximizing/CPU player has a winning pattern:
winningPattern =
tryLoadAscendingWinningPattern(PlayerType.MAXIMIZING_PLAYER);
if (winningPattern != null) {
return winningPattern;
}
winningPattern =
tryLoadDescendingWinningPattern(PlayerType.MAXIMIZING_PLAYER);
if (winningPattern != null) {
return winningPattern;
}
winningPattern =
tryLoadHorizontalWinningPattern(PlayerType.MAXIMIZING_PLAYER);
if (winningPattern != null) {
return winningPattern;
}
winningPattern =
tryLoadVerticalWinningPattern(PlayerType.MAXIMIZING_PLAYER);
if (winningPattern != null) {
return winningPattern;
}
throw new IllegalStateException("Should not get here.");
}
public PlayerType get(final int x, final int y) {
return boardData[y * COLUMNS + x];
}
public void set(final int x,
final int y,
final PlayerType playerType) {
boardData[y * COLUMNS + x] = playerType;
}
public boolean makePly(final int x, final PlayerType playerType) {
for (int y = ROWS - 1; y >= 0; y--) {
if (get(x, y) == null) {
set(x, y, playerType);
return true;
}
}
return false;
}
public void unmakePly(final int x) {
for (int y = 0; y < ROWS; y++) {
if (get(x, y) != null) {
set(x, y, null);
return;
}
}
}
boolean hasHorizontalStrike(final PlayerType playerType, final int length) {
final int lastX = COLUMNS - length;
for (int y = ROWS - 1; y >= 0; y--) {
horizontalCheck:
for (int x = 0; x <= lastX; x++) {
for (int i = 0; i < length; i++) {
if (get(x + i, y) != playerType) {
continue horizontalCheck;
}
}
return true;
}
}
return false;
}
boolean hasVerticalStrike(final PlayerType playerType, final int length) {
int lastY = ROWS - length;
for (int x = 0; x < COLUMNS; x++) {
verticalCheck:
for (int y = 0; y <= lastY; y++) {
for (int i = 0; i < length; i++) {
if (get(x, y + i) != playerType) {
continue verticalCheck;
}
}
return true;
}
}
return false;
}
boolean hasAscendingDiagonalStrike(final PlayerType playerType,
final int length) {
final int lastX = COLUMNS - length;
final int lastY = ROWS - length;
for (int y = ROWS - 1; y > lastY; y--) {
diagonalCheck:
for (int x = 0; x <= lastX; x++) {
for (int i = 0; i < length; i++) {
if (get(x + i, y - i) != playerType) {
continue diagonalCheck;
}
}
return true;
}
}
return false;
}
boolean hasDescendingDiagonalStrike(final PlayerType playerType,
final int length) {
final int firstX = COLUMNS - length;
final int lastY = ROWS - length;
for (int y = ROWS - 1; y > lastY; y--) {
diagonalCheck:
for (int x = firstX; x < COLUMNS; x++) {
for (int i = 0; i < length; i++) {
if (get(x - i, y - i) != playerType) {
continue diagonalCheck;
}
}
return true;
}
}
return false;
}
private List<Point> tryLoadAscendingWinningPattern(
final PlayerType playerType) {
final int lastX = COLUMNS - VICTORY_LENGTH;
final int lastY = ROWS - VICTORY_LENGTH;
final List<Point> winningPattern = new ArrayList<>(VICTORY_LENGTH);
for (int y = ROWS - 1; y > lastY; y--) {
diagonalCheck:
for (int x = 0; x <= lastX; x++) {
for (int i = 0; i < VICTORY_LENGTH; i++) {
if (get(x + i, y - i) == playerType) {
winningPattern.add(new Point(x + i, y - i));
if (winningPattern.size() == VICTORY_LENGTH) {
return winningPattern;
}
} else {
winningPattern.clear();
continue diagonalCheck;
}
}
}
}
return null;
}
private List<Point> tryLoadDescendingWinningPattern(
final PlayerType playerType) {
final int firstX = COLUMNS - VICTORY_LENGTH;
final int lastY = ROWS - VICTORY_LENGTH;
final List<Point> winningPattern = new ArrayList<>(VICTORY_LENGTH);
for (int y = ROWS - 1; y > lastY; y--) {
diagonalCheck:
for (int x = firstX; x < COLUMNS; x++) {
for (int i = 0; i < VICTORY_LENGTH; i++) {
if (get(x - i, y - i) == playerType) {
winningPattern.add(new Point(x - i, y - i));
if (winningPattern.size() == VICTORY_LENGTH) {
return winningPattern;
}
} else {
winningPattern.clear();
continue diagonalCheck;
}
}
}
}
return null;
}
private List<Point> tryLoadHorizontalWinningPattern(
final PlayerType playerType) {
final int lastX = COLUMNS - VICTORY_LENGTH;
final List<Point> winningPattern = new ArrayList<>(VICTORY_LENGTH);
for (int y = ROWS - 1; y >= 0; y--) {
horizontalCheck:
for (int x = 0; x <= lastX; x++) {
for (int i = 0; i < VICTORY_LENGTH; i++) {
if (get(x + i, y) == playerType) {
winningPattern.add(new Point(x + i, y));
if (winningPattern.size() == VICTORY_LENGTH) {
return winningPattern;
}
} else {
winningPattern.clear();
continue horizontalCheck;
}
}
}
}
return null;
}
private List<Point> tryLoadVerticalWinningPattern(
final PlayerType playerType) {
final int lastY = ROWS - VICTORY_LENGTH;
final List<Point> winningPattern = new ArrayList<>(VICTORY_LENGTH);
for (int x = 0; x < COLUMNS; x++) {
verticalCheck:
for (int y = 0; y <= lastY; y++) {
for (int i = 0; i < VICTORY_LENGTH; i++) {
if (get(x, y + i) == playerType) {
winningPattern.add(new Point(x, y + i));
if (winningPattern.size() == VICTORY_LENGTH) {
return winningPattern;
}
} else {
winningPattern.clear();
continue verticalCheck;
}
}
}
}
return null;
}
private boolean notFullAtX(final int x) {
return get(x, 0) == null;
}
private ConnectFourBoard dropAtX(final int x, final PlayerType playerType) {
final ConnectFourBoard nextBoard = new ConnectFourBoard(this);
for (int y = ROWS - 1; y >= 0; y--) {
if (nextBoard.get(x, y) == null) {
nextBoard.set(x, y, playerType);
return nextBoard;
}
}
throw new IllegalStateException("Should not get here.");
}
private static char getCellChar(final PlayerType playerType) {
if (playerType == null) {
return '.';
}
switch (playerType) {
case MAXIMIZING_PLAYER:
return 'O';
case MINIMIZING_PLAYER:
return 'X';
default:
throw new IllegalStateException("Should not get here.");
}
}
private int nextXIndex(final int x, final int y) {
return y * COLUMNS + x + 1;
}
private int nextYIndex(final int x, final int y) {
return y * (COLUMNS + 1) + x;
}
private int nextAscendingDiagonalIndex(final int x, final int y) {
return y * (COLUMNS - 1) + x - 1;
}
private int nextDescendingDiagonalIndex(final int x, final int y) {
return y * (COLUMNS - 1) - x + 1;
}
}
com.github.coderodde.game.zerosum.impl.ConnectFourAlphaBetaPruningSearchEngine.java:
package com.github.coderodde.game.zerosum.impl;
import com.github.coderodde.game.connect4.ConnectFourBoard;
import static com.github.coderodde.game.connect4.ConnectFourBoard.COLUMNS;
import com.github.coderodde.game.zerosum.PlayerType;
import com.github.coderodde.game.zerosum.HeuristicFunction;
import com.github.coderodde.game.zerosum.SearchEngine;
/**
* This class implements the
* <a href="https://en.wikipedia.org/wiki/Alpha%E2%80%93beta_pruning">
* Alpha-beta pruning</a> algorithm for making a move.
*
* @version 1.0.0 (Jun 5, 2024)
* @since 1.0.0 (Jun 5, 2024)
*/
public final class ConnectFourAlphaBetaPruningSearchEngine
implements SearchEngine<ConnectFourBoard> {
private ConnectFourBoard bestMoveState;
private final HeuristicFunction<ConnectFourBoard> heuristicFunction;
public ConnectFourAlphaBetaPruningSearchEngine(
final HeuristicFunction<ConnectFourBoard> heuristicFunction) {
this.heuristicFunction = heuristicFunction;
}
@Override
public ConnectFourBoard search(final ConnectFourBoard root,
final int depth) {
bestMoveState = null;
alphaBetaRootImpl(root,
depth,
Double.NEGATIVE_INFINITY,
Double.POSITIVE_INFINITY);
return bestMoveState;
}
private void alphaBetaRootImpl(final ConnectFourBoard root,
final int depth,
double alpha,
double beta) {
// The first turn belongs to AI/the maximizing player:
double tentativeValue = Double.NEGATIVE_INFINITY;
for (int x = 0; x < COLUMNS; x++) {
if (!root.makePly(x, PlayerType.MAXIMIZING_PLAYER)) {
continue;
}
double value = alphaBetaImpl(root,
depth - 1,
Double.NEGATIVE_INFINITY,
Double.POSITIVE_INFINITY,
PlayerType.MINIMIZING_PLAYER);
if (tentativeValue < value) {
tentativeValue = value;
bestMoveState = new ConnectFourBoard(root);
}
root.unmakePly(x);
if (value > beta) {
break;
}
alpha = Math.max(alpha, value);
}
}
private double alphaBetaImpl(final ConnectFourBoard state,
final int depth,
double alpha,
double beta,
final PlayerType playerType) {
if (depth == 0 || state.isTerminal()) {
return heuristicFunction.evaluate(state, depth);
}
if (playerType == PlayerType.MAXIMIZING_PLAYER) {
double value = Double.NEGATIVE_INFINITY;
for (int x = 0; x < COLUMNS; x++) {
if (!state.makePly(x, PlayerType.MAXIMIZING_PLAYER)) {
continue;
}
value = Math.max(value,
alphaBetaImpl(state,
depth - 1,
alpha,
beta,
PlayerType.MINIMIZING_PLAYER));
state.unmakePly(x);
if (value > beta) {
break;
}
alpha = Math.max(alpha, value);
}
return value;
} else {
double value = Double.POSITIVE_INFINITY;
for (int x = 0; x < COLUMNS; x++) {
if (!state.makePly(x, PlayerType.MINIMIZING_PLAYER)) {
continue;
}
value = Math.min(value,
alphaBetaImpl(state,
depth - 1,
alpha,
beta,
PlayerType.MAXIMIZING_PLAYER));
state.unmakePly(x);
if (value < alpha) {
break;
}
beta = Math.min(beta, value);
}
return value;
}
}
}
com.github.coderodde.game.connect4fx.ConnectFourFX.java:
package com.github.coderodde.game.connect4fx;
import javafx.application.Application;
import javafx.scene.Scene;
import javafx.scene.canvas.Canvas;
import javafx.scene.layout.StackPane;
import javafx.stage.Stage;
/**
*
* @author PotilasKone
*/
public class ConnectFourFX extends Application {
public static void main(String[] args) {
launch(args);
}
@Override
public void start(Stage stage) throws Exception {
stage.setTitle("rodde's Connect 4");
final StackPane root = new StackPane();
final Canvas canvas = new ConnectFourFXCanvas();
root.getChildren().add(canvas);
stage.setScene(new Scene(root));
stage.setResizable(false);
stage.show();
}
}
com.github.coderodde.game.connect4fx.ConnectFourFXCanvas.java:
package com.github.coderodde.game.connect4fx;
import com.github.coderodde.game.connect4.ConnectFourBoard;
import static com.github.coderodde.game.connect4.ConnectFourBoard.COLUMNS;
import static com.github.coderodde.game.connect4.ConnectFourBoard.ROWS;
import com.github.coderodde.game.connect4.ConnectFourHeuristicFunction;
import com.github.coderodde.game.zerosum.PlayerType;
import com.github.coderodde.game.zerosum.impl.ConnectFourAlphaBetaPruningSearchEngine;
import java.awt.Point;
import java.util.List;
import java.util.Optional;
import javafx.application.Platform;
import javafx.geometry.Rectangle2D;
import javafx.scene.canvas.Canvas;
import javafx.scene.control.Alert;
import javafx.scene.control.Alert.AlertType;
import javafx.scene.control.ButtonType;
import javafx.scene.input.MouseEvent;
import javafx.scene.paint.Color;
import javafx.stage.Screen;
/**
* This class implements the canvas for drawing the game board.
*
* @version 1.0.0 (Jun 6, 2024)
* @since 1.0.0 (Jun 6, 2024)
*/
public final class ConnectFourFXCanvas extends Canvas {
private static final Color BACKGROUND_COLOR = Color.valueOf("#295c9e");
private static final Color AIM_COLOR = Color.valueOf("#167a0f");
private static final Color HUMAN_PLAYER_CELL_COLOR =
Color.valueOf("#bfa730");
private static final Color AI_PLAYER_CELL_COLOR =
Color.valueOf("#b33729");
private static final Color WINNING_PATTERN_COLOR = Color.BLACK;
private static final double CELL_LENGTH_SUBSTRACT = 10.0;
private static final double RADIUS_SUBSTRACTION_DELTA = 10.0;
private static final int CELL_Y_NOT_FOUND = -1;
private static final int INITIAL_AIM_X = 3;
private static final int SEARCH_DEPTH = 8;
private final ConnectFourAlphaBetaPruningSearchEngine engine =
new ConnectFourAlphaBetaPruningSearchEngine(
new ConnectFourHeuristicFunction());
// new ConnectFourAlphaBetaPruningSearchEngine(
// new ConnectFourHeuristicFunction());
private int previousAimX = INITIAL_AIM_X;
private ConnectFourBoard board = new ConnectFourBoard();
private double cellLength;
public ConnectFourFXCanvas() {
setSize();
paintBackground();
this.addEventFilter(MouseEvent.MOUSE_MOVED, event -> {
processMouseMoved(event);
});
this.addEventFilter(MouseEvent.MOUSE_CLICKED, event -> {
processMouseClicked(event);
});
}
public void hit(final int x) {
int y = getEmptyCellYForX(x);
if (y == CELL_Y_NOT_FOUND) {
// The column at X-index of x is full:
return;
}
previousAimX = x;
board.makePly(x, PlayerType.MINIMIZING_PLAYER);
paintBackground();
paintBoard();
if (board.isTerminal()) {
paintBackground();
paintBoard();
reportEndResult();
return;
}
board = engine.search(board, SEARCH_DEPTH);
if (board.isTerminal()) {
paintBackground();
paintBoard();
reportEndResult();
return;
}
paintBackground();
paintBoard();
y = getEmptyCellYForX(x);
if (y != CELL_Y_NOT_FOUND) {
paintCell(AIM_COLOR, x, y);
}
}
private static Alert getEndResultReportAlert(final String contentText) {
final Alert alert = new Alert(AlertType.CONFIRMATION);
alert.getButtonTypes().clear();
alert.getButtonTypes().addAll(ButtonType.YES, ButtonType.NO);
alert.setTitle("Game over");
alert.setHeaderText(contentText);
alert.setContentText("Do you want to play again?");
return alert;
}
private void processEndOfGameOptional(final Optional<ButtonType> optional) {
if (optional.isPresent() && optional.get().equals(ButtonType.YES)) {
board = new ConnectFourBoard();
} else {
Platform.exit();
System.exit(0);
}
}
private void reportEndResult() {
if (board.isTie()) {
final Optional<ButtonType> optional =
getEndResultReportAlert(
"It's a tie!")
.showAndWait();
processEndOfGameOptional(optional);
} else if (board.isWinningFor(PlayerType.MINIMIZING_PLAYER)) {
colorWinningPattern(PlayerType.MINIMIZING_PLAYER,
board.getWinningPattern());
final Optional<ButtonType> optional =
getEndResultReportAlert(
"You won!")
.showAndWait();
processEndOfGameOptional(optional);
} else if (board.isWinningFor(PlayerType.MAXIMIZING_PLAYER)) {
colorWinningPattern(PlayerType.MAXIMIZING_PLAYER,
board.getWinningPattern());
final Optional<ButtonType> optional =
getEndResultReportAlert(
"You lost!")
.showAndWait();
processEndOfGameOptional(optional);
}
}
private void colorWinningPattern(final PlayerType playerType,
final List<Point> winningPattern) {
for (final Point point : winningPattern) {
paintCell(WINNING_PATTERN_COLOR, point.x, point.y);
paintInnerCell(playerType, point.x, point.y);
}
}
private void paintInnerCell(final PlayerType playerType,
final int x,
final int y) {
final double topLeftX =
cellLength * x + 2.0 * RADIUS_SUBSTRACTION_DELTA;
final double topLeftY =
cellLength * y + 2.0 * RADIUS_SUBSTRACTION_DELTA;
final double diameter = cellLength - 4.0 * RADIUS_SUBSTRACTION_DELTA;
this.getGraphicsContext2D().setFill(getColor(playerType));
this.getGraphicsContext2D().fillOval(topLeftX,
topLeftY,
diameter,
diameter);
}
private void processMouseClicked(final MouseEvent mouseEvent) {
final double mouseX = mouseEvent.getSceneX();
final int cellX = (int)(mouseX / cellLength);
hit(cellX);
}
private void processMouseMoved(final MouseEvent mouseEvent) {
final double mouseX = mouseEvent.getSceneX();
final int cellX = (int)(mouseX / cellLength);
final int emptyCellY = getEmptyCellYForX(cellX);
if (cellX == previousAimX) {
// Nothing changed.
return;
}
previousAimX = cellX;
if (emptyCellY == CELL_Y_NOT_FOUND) {
return;
}
paintBackground();
paintBoard();
paintCellSelection(cellX, emptyCellY);
}
private void paintBoard() {
for (int y = 0; y < ROWS; y++) {
for (int x = 0; x < COLUMNS; x++) {
final PlayerType playerType = board.get(x, y);
final Color color = getColor(playerType);
paintCell(color, x, y);
}
}
}
private static Color getColor(final PlayerType playerType) {
if (playerType == null) {
return Color.WHITE;
} else switch (playerType) {
case MAXIMIZING_PLAYER -> {
return AI_PLAYER_CELL_COLOR;
}
case MINIMIZING_PLAYER -> {
return HUMAN_PLAYER_CELL_COLOR;
}
default -> throw new IllegalStateException(
"Unknown PlayerType: " + playerType);
}
}
private void paintCell(final Color color,
final int x,
final int y) {
final double topLeftX = cellLength * x + RADIUS_SUBSTRACTION_DELTA;
final double topLeftY = cellLength * y + RADIUS_SUBSTRACTION_DELTA;
final double innerWidth = cellLength - 2.0 * RADIUS_SUBSTRACTION_DELTA;
this.getGraphicsContext2D().setFill(color);
this.getGraphicsContext2D()
.fillOval(topLeftX,
topLeftY,
innerWidth,
innerWidth);
}
private void paintCellSelection(final int cellX, final int cellY) {
final double topLeftX = cellLength * cellX + RADIUS_SUBSTRACTION_DELTA;
final double topLeftY = cellLength * cellY + RADIUS_SUBSTRACTION_DELTA;
final double innerWidth = cellLength - 2.0 * RADIUS_SUBSTRACTION_DELTA;
this.getGraphicsContext2D().setFill(AIM_COLOR);
this.getGraphicsContext2D()
.fillOval(
topLeftX,
topLeftY,
innerWidth,
innerWidth);
}
private int getEmptyCellYForX(final int cellX) {
for (int y = ROWS - 1; y >= 0; y--) {
if (board.get(cellX, y) == null) {
return y;
}
}
return CELL_Y_NOT_FOUND;
}
private void setSize() {
final Rectangle2D primaryScreenBounds =
Screen.getPrimary().getVisualBounds();
final double verticalLength =
primaryScreenBounds.getHeight() / ConnectFourBoard.ROWS;
final double horizontalLength =
primaryScreenBounds.getWidth() / ConnectFourBoard.COLUMNS;
final double selectedLength = Math.min(verticalLength,
horizontalLength)
- CELL_LENGTH_SUBSTRACT;
this.cellLength = selectedLength;
this.setWidth(ConnectFourBoard.COLUMNS * selectedLength);
this.setHeight(ConnectFourBoard.ROWS * selectedLength);
}
private void paintBackground() {
final double width = this.getWidth();
final double height = this.getHeight();
this.getGraphicsContext2D().setFill(BACKGROUND_COLOR);
this.getGraphicsContext2D().fillRect(0.0, 0.0, width, height);
}
}
Game play
It may look like this:
Critique request
As always, I would like to hear any commentary on how to improve my work.