diff --git a/app/src/main/java/org/toop/Main.java b/app/src/main/java/org/toop/Main.java index 657f9bf..fa04961 100644 --- a/app/src/main/java/org/toop/Main.java +++ b/app/src/main/java/org/toop/Main.java @@ -13,6 +13,7 @@ public final class Main { static void main(String[] args) { initSystems(); App.run(args); + } private static void initSystems() { diff --git a/app/src/main/java/org/toop/app/Server.java b/app/src/main/java/org/toop/app/Server.java index 3264eba..222a3d3 100644 --- a/app/src/main/java/org/toop/app/Server.java +++ b/app/src/main/java/org/toop/app/Server.java @@ -1,23 +1,24 @@ package org.toop.app; -import javafx.application.Platform; -import javafx.geometry.Pos; import org.toop.app.game.Connect4Game; -import org.toop.app.game.ReversiGame; -import org.toop.app.game.TicTacToeGame; +import org.toop.app.game.gameControllers.AbstractGameController; +import org.toop.app.game.gameControllers.ReversiController; +import org.toop.app.game.gameControllers.TicTacToeController; import org.toop.app.widget.WidgetContainer; -import org.toop.app.widget.complex.LoadingWidget; import org.toop.app.widget.popup.ChallengePopup; import org.toop.app.widget.popup.ErrorPopup; import org.toop.app.widget.popup.SendChallengePopup; import org.toop.app.widget.view.ServerView; import org.toop.framework.eventbus.EventFlow; -import org.toop.framework.eventbus.ListenerHandler; import org.toop.framework.networking.clients.TournamentNetworkingClient; import org.toop.framework.networking.events.NetworkEvents; import org.toop.framework.networking.types.NetworkingConnector; +import org.toop.game.players.ArtificialPlayer; +import org.toop.game.players.OnlinePlayer; +import org.toop.game.players.AbstractPlayer; +import org.toop.game.reversi.ReversiAIR; +import org.toop.game.tictactoe.TicTacToeAIR; import org.toop.local.AppContext; -import java.util.function.Consumer; import java.util.List; import java.util.concurrent.CopyOnWriteArrayList; @@ -27,6 +28,7 @@ import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicBoolean; public final class Server { + // TODO: Keep track of listeners. Remove them on Server connection close so reference is deleted. private String user = ""; private long clientId = -1; @@ -36,10 +38,14 @@ public final class Server { private ServerView primary; private boolean isPolling = true; + private AbstractGameController gameController; + private final AtomicBoolean isSingleGame = new AtomicBoolean(false); private ScheduledExecutorService scheduler; + private EventFlow eventFlow = new EventFlow(); + public static GameInformation.Type gameToType(String game) { if (game.equalsIgnoreCase("tic-tac-toe")) { return GameInformation.Type.TICTACTOE; @@ -54,6 +60,9 @@ public final class Server { return null; } + + // Server has to deal with ALL network related listen events. This "server" can then interact with the manager to make stuff happen. + // This prevents data races where events get sent to the game manager but the manager isn't ready yet. public Server(String ip, String port, String user) { if (ip.split("\\.").length < 4) { new ErrorPopup("\"" + ip + "\" " + AppContext.getString("is-not-a-valid-ip-address")); @@ -98,10 +107,14 @@ public final class Server { populateGameList(); }).postEvent(); - new EventFlow().listen(NetworkEvents.ChallengeResponse.class, this::handleReceivedChallenge, false) - .listen(NetworkEvents.GameMatchResponse.class, this::handleMatchResponse, false); - startPopulateScheduler(); - populateGameList(); + + eventFlow.listen(NetworkEvents.ChallengeResponse.class, this::handleReceivedChallenge, false) + .listen(NetworkEvents.GameMatchResponse.class, this::handleMatchResponse, false) + .listen(NetworkEvents.GameResultResponse.class, this::handleGameResult, false) + .listen(NetworkEvents.GameMoveResponse.class, this::handleReceivedMove, false) + .listen(NetworkEvents.YourTurnResponse.class, this::handleYourTurn, false); + startPopulateScheduler(); + populateGameList(); } private void sendChallenge(String opponent) { @@ -114,10 +127,16 @@ public final class Server { } private void handleMatchResponse(NetworkEvents.GameMatchResponse response) { - if (!isPolling) return; + // TODO: Redo all of this mess + if (gameController != null) { + gameController.stop(); + } + + gameController = null; + + //if (!isPolling) return; String gameType = extractQuotedValue(response.gameType()); - if (response.clientId() == clientId) { isPolling = false; onlinePlayers.clear(); @@ -138,21 +157,58 @@ public final class Server { information.players[0].computerThinkTime = 1; information.players[1].name = response.opponent(); + AbstractPlayer[] players = new AbstractPlayer[2]; + + players[(myTurn + 1) % 2] = new OnlinePlayer(response.opponent()); + + switch (type){ + case TICTACTOE ->{ + players[myTurn] = new ArtificialPlayer<>(new TicTacToeAIR(), user); + } + case REVERSI ->{ + players[myTurn] = new ArtificialPlayer<>(new ReversiAIR(), user); + } + } + Runnable onGameOverRunnable = isSingleGame.get()? null: this::gameOver; - - switch (type) { - case TICTACTOE -> - new TicTacToeGame(information, myTurn, this::forfeitGame, this::exitGame, this::sendMessage, onGameOverRunnable); + case TICTACTOE ->{ + gameController = new TicTacToeController(players, false); + } case REVERSI -> - new ReversiGame(information, myTurn, this::forfeitGame, this::exitGame, this::sendMessage, onGameOverRunnable); + gameController = new ReversiController(players, false); case CONNECT4 -> new Connect4Game(information, myTurn, this::forfeitGame, this::exitGame, this::sendMessage, onGameOverRunnable); default -> new ErrorPopup("Unsupported game type."); } + + if (gameController != null){ + gameController.start(); + } } } + private void handleYourTurn(NetworkEvents.YourTurnResponse response) { + if (gameController == null) { + return; + } + gameController.yourTurn(response); + } + + private void handleGameResult(NetworkEvents.GameResultResponse response) { + if (gameController == null) { + return; + } + gameController.gameFinished(response); + } + + private void handleReceivedMove(NetworkEvents.GameMoveResponse response) { + if (gameController == null) { + return; + } + gameController.moveReceived(response); + } + private void handleReceivedChallenge(NetworkEvents.ChallengeResponse response) { if (!isPolling) return; diff --git a/app/src/main/java/org/toop/app/canvas/Connect4Canvas.java b/app/src/main/java/org/toop/app/canvas/Connect4Canvas.java index c17dafd..117b5b2 100644 --- a/app/src/main/java/org/toop/app/canvas/Connect4Canvas.java +++ b/app/src/main/java/org/toop/app/canvas/Connect4Canvas.java @@ -1,6 +1,7 @@ package org.toop.app.canvas; import javafx.scene.paint.Color; +import org.toop.framework.gameFramework.abstractClasses.TurnBasedGameR; import java.util.function.Consumer; @@ -8,4 +9,9 @@ public class Connect4Canvas extends GameCanvas { public Connect4Canvas(Color color, int width, int height, Consumer onCellClicked) { super(color, Color.TRANSPARENT, width, height, 7, 6, 10, true, onCellClicked,null); } + + @Override + public void drawPlayerHover(int player, int move, TurnBasedGameR game) { + + } } \ No newline at end of file diff --git a/app/src/main/java/org/toop/app/canvas/DrawPlayerHover.java b/app/src/main/java/org/toop/app/canvas/DrawPlayerHover.java new file mode 100644 index 0000000..09e2372 --- /dev/null +++ b/app/src/main/java/org/toop/app/canvas/DrawPlayerHover.java @@ -0,0 +1,7 @@ +package org.toop.app.canvas; + +import org.toop.framework.gameFramework.abstractClasses.TurnBasedGameR; + +public interface DrawPlayerHover { + void drawPlayerHover(int player, int move, TurnBasedGameR game); +} diff --git a/app/src/main/java/org/toop/app/canvas/DrawPlayerMove.java b/app/src/main/java/org/toop/app/canvas/DrawPlayerMove.java new file mode 100644 index 0000000..fca2b46 --- /dev/null +++ b/app/src/main/java/org/toop/app/canvas/DrawPlayerMove.java @@ -0,0 +1,5 @@ +package org.toop.app.canvas; + +public interface DrawPlayerMove { + void drawPlayerMove(int player, int move); +} diff --git a/app/src/main/java/org/toop/app/canvas/GameCanvas.java b/app/src/main/java/org/toop/app/canvas/GameCanvas.java index f8f6d70..7ff83da 100644 --- a/app/src/main/java/org/toop/app/canvas/GameCanvas.java +++ b/app/src/main/java/org/toop/app/canvas/GameCanvas.java @@ -6,12 +6,13 @@ import javafx.scene.canvas.Canvas; import javafx.scene.canvas.GraphicsContext; import javafx.scene.input.MouseButton; import javafx.scene.paint.Color; +import javafx.scene.text.Font; import javafx.util.Duration; +import org.toop.framework.gameFramework.abstractClasses.TurnBasedGameR; -import java.util.concurrent.atomic.AtomicReference; import java.util.function.Consumer; -public abstract class GameCanvas { +public abstract class GameCanvas implements DrawPlayerMove, DrawPlayerHover { protected record Cell(float x, float y, float width, float height) { public boolean isInside(double x, double y) { return x >= this.x && x <= this.x + width && @@ -36,10 +37,18 @@ public abstract class GameCanvas { protected final Cell[] cells; + private Consumer onCellCLicked; + + public void setOnCellClicked(Consumer onClick) { + this.onCellCLicked = onClick; + } + protected GameCanvas(Color color, Color backgroundColor, int width, int height, int rowSize, int columnSize, int gapSize, boolean edges, Consumer onCellClicked, Consumer newCellEntered) { canvas = new Canvas(width, height); graphics = canvas.getGraphicsContext2D(); + this.onCellCLicked = onCellClicked; + this.color = color; this.backgroundColor = backgroundColor; @@ -78,7 +87,7 @@ public abstract class GameCanvas { if (cell.isInside(event.getX(), event.getY())) { event.consume(); - onCellClicked.accept(column + row * rowSize); + this.onCellCLicked.accept(column + row * rowSize); } }); @@ -143,6 +152,19 @@ public abstract class GameCanvas { } } + @Override + public void drawPlayerMove(int player, int move) { + final float x = cells[move].x() + gapSize; + final float y = cells[move].y() + gapSize; + + final float width = cells[move].width() - gapSize * 2; + final float height = cells[move].height() - gapSize * 2; + + graphics.setFill(color); + graphics.setFont(Font.font("Arial", 40)); // TODO different font and size + graphics.fillText(String.valueOf(player), x + width, y + height); + } + public void drawDot(Color color, int cell) { final float x = cells[cell].x() + gapSize; final float y = cells[cell].y() + gapSize; diff --git a/app/src/main/java/org/toop/app/canvas/ReversiCanvas.java b/app/src/main/java/org/toop/app/canvas/ReversiCanvas.java index b3f1628..10e034d 100644 --- a/app/src/main/java/org/toop/app/canvas/ReversiCanvas.java +++ b/app/src/main/java/org/toop/app/canvas/ReversiCanvas.java @@ -1,12 +1,14 @@ package org.toop.app.canvas; import javafx.scene.paint.Color; +import org.toop.framework.gameFramework.abstractClasses.TurnBasedGameR; import org.toop.game.records.Move; +import org.toop.game.reversi.ReversiR; import java.util.concurrent.atomic.AtomicReference; import java.util.function.Consumer; -public final class ReversiCanvas extends GameCanvas { +public final class ReversiCanvas extends GameCanvas { private Move[] currentlyHighlightedMoves = null; public ReversiCanvas(Color color, int width, int height, Consumer onCellClicked, Consumer newCellEntered) { super(color, new Color(0f,0.4f,0.2f,1f), width, height, 8, 8, 5, true, onCellClicked, newCellEntered); @@ -81,4 +83,14 @@ public final class ReversiCanvas extends GameCanvas { } drawInnerDot(innerColor, cell,false); } + + @Override + public void drawPlayerMove(int player ,int move){ + super.drawPlayerMove(player, move); + } + + @Override + public void drawPlayerHover(int player, int move, TurnBasedGameR game) { + + } } \ No newline at end of file diff --git a/app/src/main/java/org/toop/app/canvas/TicTacToeCanvas.java b/app/src/main/java/org/toop/app/canvas/TicTacToeCanvas.java index 890eb39..244751b 100644 --- a/app/src/main/java/org/toop/app/canvas/TicTacToeCanvas.java +++ b/app/src/main/java/org/toop/app/canvas/TicTacToeCanvas.java @@ -1,14 +1,25 @@ package org.toop.app.canvas; import javafx.scene.paint.Color; +import org.toop.framework.gameFramework.abstractClasses.TurnBasedGameR; +import org.toop.game.tictactoe.TicTacToeR; import java.util.function.Consumer; -public final class TicTacToeCanvas extends GameCanvas { +public final class TicTacToeCanvas extends GameCanvas { public TicTacToeCanvas(Color color, int width, int height, Consumer onCellClicked) { super(color, Color.TRANSPARENT, width, height, 3, 3, 30, false, onCellClicked,null); } + @Override + public void drawPlayerMove(int player, int move) { + switch (player) { + case 0 -> drawX(Color.RED, move); + case 1 -> drawO(Color.BLUE, move); + default -> super.drawPlayerMove(player, move); + } + } + public void drawX(Color color, int cell) { graphics.setStroke(color); graphics.setLineWidth(gapSize); @@ -35,4 +46,9 @@ public final class TicTacToeCanvas extends GameCanvas { graphics.strokeOval(x, y, width, height); } + + @Override + public void drawPlayerHover(int player, int move, TurnBasedGameR game) { + + } } \ No newline at end of file diff --git a/app/src/main/java/org/toop/app/game/Connect4Game.java b/app/src/main/java/org/toop/app/game/Connect4Game.java index 5c5f386..bab7715 100644 --- a/app/src/main/java/org/toop/app/game/Connect4Game.java +++ b/app/src/main/java/org/toop/app/game/Connect4Game.java @@ -11,7 +11,7 @@ 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.enumerators.GameState; +import org.toop.framework.gameFramework.GameState; import org.toop.game.records.Move; import java.util.concurrent.BlockingQueue; diff --git a/app/src/main/java/org/toop/app/game/ReversiGame.java b/app/src/main/java/org/toop/app/game/ReversiGame.java index 2bc2a7d..f06197d 100644 --- a/app/src/main/java/org/toop/app/game/ReversiGame.java +++ b/app/src/main/java/org/toop/app/game/ReversiGame.java @@ -8,7 +8,7 @@ import org.toop.app.widget.WidgetContainer; import org.toop.app.widget.view.GameView; import org.toop.framework.eventbus.EventFlow; import org.toop.framework.networking.events.NetworkEvents; -import org.toop.game.enumerators.GameState; +import org.toop.framework.gameFramework.GameState; import org.toop.game.records.Move; import org.toop.game.reversi.Reversi; import org.toop.game.reversi.ReversiAI; diff --git a/app/src/main/java/org/toop/app/game/TicTacToeGame.java b/app/src/main/java/org/toop/app/game/TicTacToeGame.java index b6905cc..a164d72 100644 --- a/app/src/main/java/org/toop/app/game/TicTacToeGame.java +++ b/app/src/main/java/org/toop/app/game/TicTacToeGame.java @@ -7,7 +7,7 @@ import org.toop.app.widget.WidgetContainer; import org.toop.app.widget.view.GameView; import org.toop.framework.eventbus.EventFlow; import org.toop.framework.networking.events.NetworkEvents; -import org.toop.game.enumerators.GameState; +import org.toop.framework.gameFramework.GameState; import org.toop.game.records.Move; import org.toop.game.tictactoe.TicTacToe; import org.toop.game.tictactoe.TicTacToeAI; @@ -147,9 +147,9 @@ public final class TicTacToeGame { final GameState state = game.play(move); if (move.value() == 'X') { - canvas.drawX(Color.INDIANRED, move.position()); + //canvas.drawPlayer('X', Color.INDIANRED, move.position()); } else if (move.value() == 'O') { - canvas.drawO(Color.ROYALBLUE, move.position()); + //canvas.drawPlayer('O', Color.ROYALBLUE, move.position()); } if (state != GameState.NORMAL) { @@ -198,9 +198,9 @@ public final class TicTacToeGame { } if (move.value() == 'X') { - canvas.drawX(Color.RED, move.position()); + //canvas.drawPlayer('X', Color.RED, move.position()); } else if (move.value() == 'O') { - canvas.drawO(Color.BLUE, move.position()); + //canvas.drawPlayer('O', Color.BLUE, move.position()); } setGameLabels(game.getCurrentTurn() == myTurn); diff --git a/app/src/main/java/org/toop/app/game/TicTacToeGameThread.java b/app/src/main/java/org/toop/app/game/TicTacToeGameThread.java index 960b2a2..dda8b7f 100644 --- a/app/src/main/java/org/toop/app/game/TicTacToeGameThread.java +++ b/app/src/main/java/org/toop/app/game/TicTacToeGameThread.java @@ -5,7 +5,7 @@ import org.toop.app.GameInformation; import org.toop.app.canvas.TicTacToeCanvas; import org.toop.framework.eventbus.EventFlow; import org.toop.framework.networking.events.NetworkEvents; -import org.toop.game.enumerators.GameState; +import org.toop.framework.gameFramework.GameState; import org.toop.game.records.Move; import org.toop.game.tictactoe.TicTacToe; import org.toop.game.tictactoe.TicTacToeAI; @@ -47,8 +47,8 @@ public final class TicTacToeGameThread extends BaseGameThread implements UpdatesGameUI, GameThreadStrategy, SupportsOnlinePlay { + protected final EventFlow eventFlow = new EventFlow(); + + protected final List> listeners = new ArrayList<>(); + + // Logger for logging ofcourse + protected final Logger logger = LogManager.getLogger(this.getClass()); + + // Reference to primary view + protected final GameView primary; + + // Reference to game canvas + protected final GameCanvas canvas; + + private final AbstractPlayer[] players; // List of players, can't be changed. + protected final T game; // Reference to game instance + private final GameThreadStrategy gameThreadBehaviour; + + // TODO: Change gameType to automatically happen with either dependency injection or something else. + // TODO: Make visualisation of moves a behaviour. + protected AbstractGameController(GameCanvas canvas, AbstractPlayer[] players, T game, GameThreadStrategy gameThreadBehaviour, String gameType) { + logger.info("Creating AbstractGameController"); + // Make sure player list matches expected size + if (players.length != game.getPlayerCount()){ + logger.error("Player count mismatch"); + throw new IllegalArgumentException("players and game's players must have same length"); + } + + this.canvas = canvas; + this.players = players; + this.game = game; + this.gameThreadBehaviour = gameThreadBehaviour; + + // Let players know who they are + for(int i = 0; i < players.length; i++){ + players[i].setPlayerIndex(i); + } + + primary = new GameView(null, null, null, gameType); + addListeners(); + } + + public void start(){ + logger.info("Starting GameManager"); + gameThreadBehaviour.start();; + } + + public void stop(){ + logger.info("Stopping GameManager"); + removeListeners(); + gameThreadBehaviour.stop(); + } + + public AbstractPlayer getCurrentPlayer(){ + return gameThreadBehaviour.getCurrentPlayer(); + }; + + public int getCurrentPlayerIndex(){ + return getCurrentPlayer().getPlayerIndex(); + } + + private void addListeners(){ + eventFlow + .listen(GUIEvents.RefreshGameCanvas.class, this::onUpdateGameUI, false) + .listen(GUIEvents.GameEnded.class, this::onGameFinish, false); + } + + private void removeListeners(){ + eventFlow.unsubscribeAll(); + } + + private void onUpdateGameUI(GUIEvents.RefreshGameCanvas event){ + this.updateUI(); + } + + private void onGameFinish(GUIEvents.GameEnded event){ + logger.info("Game Finished"); + String name = event.winner() == -1 ? null : getPlayer(event.winner()).getName(); + primary.gameOver(event.winOrTie(), name); + stop(); + } + + public AbstractPlayer getPlayer(int player){ + if (player < 0 || player >= players.length){ + logger.error("Invalid player index"); + throw new IllegalArgumentException("player out of range"); + } + return players[player]; + } + + private boolean isOnline(){ + return this.gameThreadBehaviour instanceof SupportsOnlinePlay; + } + + @Override + public void yourTurn(NetworkEvents.YourTurnResponse event){ + if (isOnline()){ + ((OnlineThreadBehaviour) this.gameThreadBehaviour).yourTurn(event); + } + } + + @Override + public void moveReceived(NetworkEvents.GameMoveResponse event){ + if (isOnline()){ + ((OnlineThreadBehaviour) this.gameThreadBehaviour).moveReceived(event); + } + } + + @Override + public void gameFinished(NetworkEvents.GameResultResponse event){ + if (isOnline()){ + ((OnlineThreadBehaviour) this.gameThreadBehaviour).gameFinished(event); + } + } +} diff --git a/app/src/main/java/org/toop/app/game/gameControllers/ReversiController.java b/app/src/main/java/org/toop/app/game/gameControllers/ReversiController.java new file mode 100644 index 0000000..625b8dd --- /dev/null +++ b/app/src/main/java/org/toop/app/game/gameControllers/ReversiController.java @@ -0,0 +1,138 @@ +package org.toop.app.game.gameControllers; + +import javafx.animation.SequentialTransition; +import javafx.geometry.Pos; +import javafx.scene.paint.Color; +import org.toop.app.App; +import org.toop.app.canvas.ReversiCanvas; +import org.toop.app.widget.WidgetContainer; +import org.toop.framework.eventbus.EventFlow; +import org.toop.framework.gameFramework.abstractClasses.GameR; +import org.toop.framework.gameFramework.GUIEvents; +import org.toop.game.GameThreadBehaviour.LocalFixedRateThreadBehaviour; +import org.toop.game.GameThreadBehaviour.OnlineThreadBehaviour; +import org.toop.game.players.AbstractPlayer; +import org.toop.game.players.LocalPlayer; +import org.toop.game.reversi.ReversiR; + +public class ReversiController extends AbstractGameController { + // TODO: Refactor GUI update methods to follow designed system + public ReversiController(AbstractPlayer[] players, boolean local) { + ReversiR ReversiR = new ReversiR(); + super( + new ReversiCanvas(Color.GRAY, (App.getHeight() / 4) * 3, (App.getHeight() / 4) * 3,(c) -> {new EventFlow().addPostEvent(GUIEvents.PlayerAttemptedMove.class, c).postEvent();}, (c) -> {new EventFlow().addPostEvent(GUIEvents.PlayerMoveHovered.class, c).postEvent();}), + players, + ReversiR, + local ? new LocalFixedRateThreadBehaviour(ReversiR, players) : new OnlineThreadBehaviour(ReversiR, players), // TODO: Player order matters here, this won't work atm + "Reversi"); + eventFlow.listen(GUIEvents.PlayerAttemptedMove.class, event -> {if (getCurrentPlayer() instanceof LocalPlayer lp){lp.setMove(event.move());}}, false); + eventFlow.listen(GUIEvents.PlayerMoveHovered.class, this::onHoverMove, false); + initUI(); + } + + + private void onHoverMove(GUIEvents.PlayerMoveHovered event){ + int cellEntered = event.move(); + canvas.drawPlayerHover(-1, cellEntered, game); + /*// (information.players[game.getCurrentTurn()].isHuman) { + int[] legalMoves = game.getLegalMoves(); + boolean isLegalMove = false; + for (int move : legalMoves) { + if (move == cellEntered){ + isLegalMove = true; + break; + } + } + + if (cellEntered >= 0){ + int[] moves = null; + if (isLegalMove) { + moves = game.getFlipsForPotentialMove( + new Point(cellEntered%game.getColumnSize(),cellEntered/game.getRowSize()), + game.getCurrentPlayer()); + } + canvas.drawHighlightDots(moves); + } + //}*/ + } + + public ReversiController(AbstractPlayer[] players) { + this(players, true); + } + + private void updateCanvas(boolean animate) { + // Todo: this is very inefficient. still very fast but if the grid is bigger it might cause issues. improve. + canvas.clearAll(); + + for (int i = 0; i < game.getBoard().length; i++) { + if (game.getBoard()[i] == 0) { + canvas.drawDot(Color.WHITE, i); + } else if (game.getBoard()[i] == 1) { + canvas.drawDot(Color.BLACK, i); + } + } + + final int[] flipped = game.getMostRecentlyFlippedPieces(); + + final SequentialTransition animation = new SequentialTransition(); + + final Color fromColor = getCurrentPlayerIndex() == 0? Color.WHITE : Color.BLACK; + final Color toColor = getCurrentPlayerIndex() == 0? Color.BLACK : Color.WHITE; + + if (animate && flipped != null) { + for (final int flip : flipped) { + canvas.clear(flip); + canvas.drawDot(fromColor, flip); + animation.getChildren().addFirst(canvas.flipDot(fromColor, toColor, flip)); + } + } + + animation.setOnFinished(_ -> { + + if (getCurrentPlayer() instanceof LocalPlayer) { + final int[] legalMoves = game.getLegalMoves(); + + for (final int legalMove : legalMoves) { + drawLegalPosition(legalMove, getCurrentPlayerIndex()); + } + } + }); + + animation.play(); + primary.nextPlayer(true, getCurrentPlayer().getName(), game.getCurrentTurn() == 0 ? "X" : "O", getPlayer((game.getCurrentTurn() + 1) % 2).getName()); + } + + @Override + public void updateUI() { + updateCanvas(false); + } + + public void drawLegalPosition(int cell, int player) { + Color innerColor; + if (player == 1) { + innerColor = new Color(0.0f, 0.0f, 0.0f, 0.6f); + } + else { + innerColor = new Color(1.0f, 1.0f, 1.0f, 0.75f); + } + canvas.drawInnerDot(innerColor, cell,false); + } + + private void initUI(){ + primary.add(Pos.CENTER, canvas.getCanvas()); + WidgetContainer.getCurrentView().transitionNext(primary, true); + updateCanvas(false); + } + + private void drawMoves(){ + int[] board = game.getBoard(); + + // Draw each square + for (int i = 0; i < board.length; i++){ + // If square isn't empty, draw player move + if (board[i] != GameR.EMPTY){ + canvas.drawPlayerMove(board[i], i); + } + } + } +} diff --git a/app/src/main/java/org/toop/app/game/gameControllers/TicTacToeController.java b/app/src/main/java/org/toop/app/game/gameControllers/TicTacToeController.java new file mode 100644 index 0000000..11ba925 --- /dev/null +++ b/app/src/main/java/org/toop/app/game/gameControllers/TicTacToeController.java @@ -0,0 +1,63 @@ +package org.toop.app.game.gameControllers; + +import javafx.geometry.Pos; +import javafx.scene.paint.Color; +import org.toop.app.App; +import org.toop.app.canvas.TicTacToeCanvas; +import org.toop.framework.eventbus.EventFlow; +import org.toop.framework.gameFramework.GUIEvents; +import org.toop.framework.gameFramework.abstractClasses.GameR; +import org.toop.game.GameThreadBehaviour.LocalThreadBehaviour; +import org.toop.game.GameThreadBehaviour.OnlineThreadBehaviour; +import org.toop.game.players.LocalPlayer; +import org.toop.game.players.AbstractPlayer; +import org.toop.app.widget.WidgetContainer; +import org.toop.game.tictactoe.TicTacToeR; + +public class TicTacToeController extends AbstractGameController { + + public TicTacToeController(AbstractPlayer[] players, boolean local) { + TicTacToeR ticTacToeR = new TicTacToeR(); + super( + new TicTacToeCanvas(Color.GRAY, (App.getHeight() / 4) * 3, (App.getHeight() / 4) * 3,(c) -> {new EventFlow().addPostEvent(GUIEvents.PlayerAttemptedMove.class, c).postEvent();}), + players, + ticTacToeR, + local ? new LocalThreadBehaviour(ticTacToeR, players) : new OnlineThreadBehaviour(ticTacToeR, players), // TODO: Player order matters here, this won't work atm + "TicTacToe"); + + initUI(); + eventFlow.listen(GUIEvents.PlayerAttemptedMove.class, event -> {if (getCurrentPlayer() instanceof LocalPlayer lp){lp.setMove(event.move());}}, false); + //addListener(GlobalEventBus.subscribe(GUIEvents.PlayerAttemptedMove.class, event -> {if (getCurrentPlayer() instanceof LocalPlayer lp){lp.setMove(event.move());}})); + //new EventFlow().listen(GUIEvents.PlayerAttemptedMove.class, event -> {if (getCurrentPlayer() instanceof LocalPlayer lp){lp.setMove(event.move());}}); + } + + public TicTacToeController(AbstractPlayer[] players) { + this(players, true); + } + + @Override + public void updateUI() { + canvas.clearAll(); + // TODO: wtf is even this pile of poop temp fix + primary.nextPlayer(true, getCurrentPlayer().getName(), game.getCurrentTurn() == 0 ? "X" : "O", getPlayer((game.getCurrentTurn() + 1) % 2).getName()); + drawMoves(); + } + + private void initUI(){ + primary.add(Pos.CENTER, canvas.getCanvas()); + WidgetContainer.getCurrentView().transitionNext(primary, true); + updateUI(); + } + + private void drawMoves(){ + int[] board = game.getBoard(); + + // Draw each square + for (int i = 0; i < board.length; i++){ + // If square isn't empty, draw player move + if (board[i] != GameR.EMPTY){ + canvas.drawPlayerMove(board[i], i); + } + } + } +} diff --git a/app/src/main/java/org/toop/app/widget/complex/ViewWidget.java b/app/src/main/java/org/toop/app/widget/complex/ViewWidget.java index ad9265c..fd0dbb1 100644 --- a/app/src/main/java/org/toop/app/widget/complex/ViewWidget.java +++ b/app/src/main/java/org/toop/app/widget/complex/ViewWidget.java @@ -16,8 +16,18 @@ public abstract class ViewWidget extends StackWidget { replace(Pos.CENTER, view); } - public void transitionNext(ViewWidget view) { - view.previous = this; + public void transitionNext(ViewWidget view) { + transitionNext(view, false); + } + + public void transitionNext(ViewWidget view, boolean aware) { + if (aware && this.getClass().equals(view.getClass())) { + view.previous = this.previous; + } + else{ + view.previous = this; + } + replace(Pos.CENTER, view); var backButton = Primitive.button("back", () -> { diff --git a/app/src/main/java/org/toop/app/widget/popup/GameOverPopup.java b/app/src/main/java/org/toop/app/widget/popup/GameOverPopup.java index f642272..db3eaf6 100644 --- a/app/src/main/java/org/toop/app/widget/popup/GameOverPopup.java +++ b/app/src/main/java/org/toop/app/widget/popup/GameOverPopup.java @@ -7,18 +7,17 @@ import org.toop.local.AppContext; import javafx.geometry.Pos; public final class GameOverPopup extends PopupWidget { - public GameOverPopup(boolean iWon, String winner) { + public GameOverPopup(boolean winOrTie, String winner) { var confirmWidget = new ConfirmWidget("game-over"); - if (winner.isEmpty()) { - confirmWidget.setMessage(AppContext.getString("the-game-ended-in-a-draw")); - } else if (iWon) { - confirmWidget.setMessage(AppContext.getString("you-win")); - } else { - confirmWidget.setMessage(AppContext.getString("you-lost-against") + ": " + winner); - } + if (winOrTie) { + confirmWidget.setMessage(winner + " won the game!"); + } + else{ + confirmWidget.setMessage("It was a tie!"); + } - confirmWidget.addButton("ok", () -> hide()); + confirmWidget.addButton("ok", this::hide); add(Pos.CENTER, confirmWidget); } diff --git a/app/src/main/java/org/toop/app/widget/view/LocalMultiplayerView.java b/app/src/main/java/org/toop/app/widget/view/LocalMultiplayerView.java index aa11a77..870a74f 100644 --- a/app/src/main/java/org/toop/app/widget/view/LocalMultiplayerView.java +++ b/app/src/main/java/org/toop/app/widget/view/LocalMultiplayerView.java @@ -2,14 +2,22 @@ package org.toop.app.widget.view; import javafx.application.Platform; import org.toop.app.GameInformation; -import org.toop.app.game.Connect4Game; -import org.toop.app.game.ReversiGame; -import org.toop.app.game.TicTacToeGameThread; +import org.toop.app.game.*; +import org.toop.app.game.gameControllers.AbstractGameController; +import org.toop.app.game.gameControllers.ReversiController; +import org.toop.app.game.gameControllers.TicTacToeController; +import org.toop.game.players.ArtificialPlayer; +import org.toop.game.players.LocalPlayer; +import org.toop.game.players.AbstractPlayer; +import org.toop.app.game.gameControllers.ReversiController; +import org.toop.app.game.gameControllers.TicTacToeController; import org.toop.app.widget.Primitive; import org.toop.app.widget.WidgetContainer; import org.toop.app.widget.complex.PlayerInfoWidget; import org.toop.app.widget.complex.ViewWidget; import org.toop.app.widget.popup.ErrorPopup; +import org.toop.game.reversi.ReversiAIR; +import org.toop.game.tictactoe.TicTacToeAIR; import org.toop.app.widget.tutorial.*; import org.toop.local.AppContext; @@ -17,9 +25,12 @@ import javafx.geometry.Pos; import javafx.scene.control.ScrollPane; import javafx.scene.layout.VBox; import org.toop.local.AppSettings; + public class LocalMultiplayerView extends ViewWidget { private final GameInformation information; + private AbstractGameController gameController; + public LocalMultiplayerView(GameInformation.Type type) { this(new GameInformation(type)); } @@ -27,6 +38,9 @@ public class LocalMultiplayerView extends ViewWidget { public LocalMultiplayerView(GameInformation information) { this.information = information; var playButton = Primitive.button("play", () -> { + if (gameController != null) { + gameController.stop(); + } for (var player : information.players) { if (player.isHuman && player.name.isEmpty()) { new ErrorPopup(AppContext.getString("please-enter-your-name")).show(Pos.CENTER); @@ -34,27 +48,64 @@ public class LocalMultiplayerView extends ViewWidget { } } + // TODO: Fix this temporary ass way of setting the players (Only works for TicTacToe) + AbstractPlayer[] players = new AbstractPlayer[2]; + switch (information.type) { case TICTACTOE: + if (information.players[0].isHuman){ + players[0] = new LocalPlayer(information.players[0].name); + } + else { + players[0] = new ArtificialPlayer<>(new TicTacToeAIR(), information.players[0].name); + } + if (information.players[1].isHuman){ + players[1] = new LocalPlayer(information.players[1].name); + } + else { + players[1] = new ArtificialPlayer<>(new TicTacToeAIR(), information.players[1].name); + } if (AppSettings.getSettings().getTutorialFlag() && AppSettings.getSettings().getFirstTTT()) { new ShowEnableTutorialWidget( - () -> new TicTacToeTutorialWidget(() -> new TicTacToeGameThread(information)), - () -> Platform.runLater(() -> new TicTacToeGameThread(information)), + () -> new TicTacToeTutorialWidget(() -> {gameController = new TicTacToeController(players); + gameController.start();}), + () -> Platform.runLater(() -> {gameController = new TicTacToeController(players); + gameController.start();}), () -> AppSettings.getSettings().setFirstTTT(false) ); } else { - new TicTacToeGameThread(information); + gameController = new TicTacToeController(players); + gameController.start(); } break; case REVERSI: + if (information.players[0].isHuman){ + players[0] = new LocalPlayer(information.players[0].name); + } + else { + players[0] = new ArtificialPlayer<>(new ReversiAIR(), information.players[0].name); + } + if (information.players[1].isHuman){ + players[1] = new LocalPlayer(information.players[1].name); + } + else { + players[1] = new ArtificialPlayer<>(new ReversiAIR(), information.players[1].name); + } if (AppSettings.getSettings().getTutorialFlag() && AppSettings.getSettings().getFirstReversi()) { new ShowEnableTutorialWidget( - () -> new ReversiTutorialWidget(() -> new ReversiGame(information)), - () -> Platform.runLater(() -> new ReversiGame(information)), + () -> new ReversiTutorialWidget(() -> { + gameController = new ReversiController(players); + gameController.start(); + }), + () -> Platform.runLater(() -> { + gameController = new ReversiController(players); + gameController.start(); + }), () -> AppSettings.getSettings().setFirstReversi(false) ); } else { - new ReversiGame(information); + gameController = new ReversiController(players); + gameController.start(); } break; case CONNECT4: diff --git a/framework/src/main/java/org/toop/framework/gameFramework/GUIEvents.java b/framework/src/main/java/org/toop/framework/gameFramework/GUIEvents.java new file mode 100644 index 0000000..bdce829 --- /dev/null +++ b/framework/src/main/java/org/toop/framework/gameFramework/GUIEvents.java @@ -0,0 +1,30 @@ +package org.toop.framework.gameFramework; + +import org.toop.framework.eventbus.events.EventsBase; +import org.toop.framework.eventbus.events.GenericEvent; + +/** + * Defines GUI-related events for the event bus. + *

+ * These events notify the UI about updates such as game progress, + * player actions, and game completion. + */ +public class GUIEvents extends EventsBase { + + /** Event to refresh or redraw the game canvas. */ + public record RefreshGameCanvas() implements GenericEvent {} + + /** + * Event indicating the game has ended. + * + * @param winOrTie true if the game ended in a win, false for a draw + * @param winner the index of the winning player, or -1 if no winner + */ + public record GameEnded(boolean winOrTie, int winner) implements GenericEvent {} + + /** Event indicating a player has attempted a move. */ + public record PlayerAttemptedMove(int move) implements GenericEvent {} + + /** Event indicating a player is hovering over a move (for UI feedback). */ + public record PlayerMoveHovered(int move) implements GenericEvent {} +} diff --git a/framework/src/main/java/org/toop/framework/gameFramework/GameState.java b/framework/src/main/java/org/toop/framework/gameFramework/GameState.java new file mode 100644 index 0000000..bc5a2bf --- /dev/null +++ b/framework/src/main/java/org/toop/framework/gameFramework/GameState.java @@ -0,0 +1,18 @@ +package org.toop.framework.gameFramework; + +/** + * Represents the current state of a turn-based game. + */ +public enum GameState { + /** Game is ongoing and no special condition applies. */ + NORMAL, + + /** Game ended in a draw. */ + DRAW, + + /** Game ended with a win for a player. */ + WIN, + + /** Next player's turn was skipped. */ + TURN_SKIPPED, +} diff --git a/framework/src/main/java/org/toop/framework/gameFramework/PlayResult.java b/framework/src/main/java/org/toop/framework/gameFramework/PlayResult.java new file mode 100644 index 0000000..cc18759 --- /dev/null +++ b/framework/src/main/java/org/toop/framework/gameFramework/PlayResult.java @@ -0,0 +1,12 @@ +package org.toop.framework.gameFramework; + +import org.toop.framework.gameFramework.GameState; + +/** + * Represents the result of a move in a turn-based game. + * + * @param state the resulting {@link GameState} after the move + * @param player the index of the player associated with the result (winner or relevant player) + */ +public record PlayResult(GameState state, int player) { +} diff --git a/framework/src/main/java/org/toop/framework/gameFramework/abstractClasses/AIR.java b/framework/src/main/java/org/toop/framework/gameFramework/abstractClasses/AIR.java new file mode 100644 index 0000000..623e493 --- /dev/null +++ b/framework/src/main/java/org/toop/framework/gameFramework/abstractClasses/AIR.java @@ -0,0 +1,17 @@ +package org.toop.framework.gameFramework.abstractClasses; + +import org.toop.framework.gameFramework.interfaces.IAIMoveR; + +/** + * Abstract base class for AI implementations for games extending {@link GameR}. + *

+ * Provides a common superclass for specific AI algorithms. Concrete subclasses + * must implement the {@link #findBestMove(GameR, int)} method defined by + * {@link IAIMoveR} to determine the best move given a game state and a search depth. + *

+ * + * @param the specific type of game this AI can play, extending {@link GameR} + */ +public abstract class AIR implements IAIMoveR { + // Concrete AI implementations should override findBestMove(T game, int depth) +} diff --git a/framework/src/main/java/org/toop/framework/gameFramework/abstractClasses/GameR.java b/framework/src/main/java/org/toop/framework/gameFramework/abstractClasses/GameR.java new file mode 100644 index 0000000..5f4d897 --- /dev/null +++ b/framework/src/main/java/org/toop/framework/gameFramework/abstractClasses/GameR.java @@ -0,0 +1,127 @@ +package org.toop.framework.gameFramework.abstractClasses; + +import org.toop.framework.gameFramework.interfaces.IPlayableR; + +import java.util.Arrays; + +/** + * Abstract base class representing a general grid-based game. + *

+ * Provides the basic structure for games with a two-dimensional board stored as a + * one-dimensional array. Tracks the board state, row and column sizes, and provides + * helper methods for accessing and modifying the board. + *

+ *

+ * Concrete subclasses must implement the {@link #clone()} method and can extend this + * class with specific game rules, winning conditions, and move validation logic. + *

+ */ +public abstract class GameR implements IPlayableR, Cloneable { + + /** Constant representing an empty position on the board. */ + public static final int EMPTY = -1; + + /** Number of rows in the game board. */ + private final int rowSize; + + /** Number of columns in the game board. */ + private final int columnSize; + + /** The game board stored as a one-dimensional array. */ + private final int[] board; + + /** + * Constructs a new game board with the specified row and column size. + * + * @param rowSize number of rows (> 0) + * @param columnSize number of columns (> 0) + * @throws AssertionError if rowSize or columnSize is not positive + */ + + protected GameR(int rowSize, int columnSize) { + assert rowSize > 0 && columnSize > 0; + + this.rowSize = rowSize; + this.columnSize = columnSize; + + board = new int[rowSize * columnSize]; + Arrays.fill(board, EMPTY); + } + + /** + * Copy constructor for creating a deep copy of another game instance. + * + * @param copy the game instance to copy + */ + protected GameR(GameR copy) { + this.rowSize = copy.rowSize; + this.columnSize = copy.columnSize; + this.board = copy.board.clone(); + } + + /** + * Check if an array contains a value. + * + * @param array array containing ints + * @param value int to check for + * + * @return true if array contains value + */ + + public static boolean contains(int[] array, int value) { + // O(n) + for (int element : array){ + if (element == value) return true; + } + return false; + } + + /** + * Returns the number of rows in the board. + * + * @return number of rows + */ + public int getRowSize() { + return this.rowSize; + } + + /** + * Returns the number of columns in the board. + * + * @return number of columns + */ + public int getColumnSize() { + return this.columnSize; + } + + /** + * Returns a copy of the current board state. + * + * @return a cloned array representing the board + */ + public int[] getBoard() { + return this.board.clone(); + } + + /** + * Sets the value of a specific position on the board. + * + * @param position the index in the board array + * @param player the value to set (e.g., player number) + */ + protected void setBoardPosition(int position, int player) { + this.board[position] = player; + } + + /** + * Creates and returns a deep copy of this game instance. + *

+ * Subclasses must implement this method to ensure proper copying of any + * additional fields beyond the base board structure. + *

+ * + * @return a cloned instance of this game + */ + @Override + public abstract GameR clone(); +} diff --git a/framework/src/main/java/org/toop/framework/gameFramework/abstractClasses/TurnBasedGameR.java b/framework/src/main/java/org/toop/framework/gameFramework/abstractClasses/TurnBasedGameR.java new file mode 100644 index 0000000..31efcf9 --- /dev/null +++ b/framework/src/main/java/org/toop/framework/gameFramework/abstractClasses/TurnBasedGameR.java @@ -0,0 +1,31 @@ +package org.toop.framework.gameFramework.abstractClasses; + +public abstract class TurnBasedGameR extends GameR { + private final int playerCount; // How many players are playing + private int turn = 0; // What turn it is in the game + + protected TurnBasedGameR(int rowSize, int columnSize, int playerCount) { + super(rowSize, columnSize); + this.playerCount = playerCount; + } + + protected TurnBasedGameR(TurnBasedGameR other){ + super(other); + this.playerCount = other.playerCount; + this.turn = other.turn; + } + + public int getPlayerCount(){return this.playerCount;} + + protected void nextTurn() { + turn += 1; + } + + public int getCurrentTurn() { + return turn % playerCount; + } + + protected void setBoard(int position) { + super.setBoardPosition(position, getCurrentTurn()); + } +} diff --git a/framework/src/main/java/org/toop/framework/gameFramework/interfaces/IAIMoveR.java b/framework/src/main/java/org/toop/framework/gameFramework/interfaces/IAIMoveR.java new file mode 100644 index 0000000..aeed1fb --- /dev/null +++ b/framework/src/main/java/org/toop/framework/gameFramework/interfaces/IAIMoveR.java @@ -0,0 +1,20 @@ +package org.toop.framework.gameFramework.interfaces; + +import org.toop.framework.gameFramework.abstractClasses.GameR; + +/** + * AI interface for selecting the best move in a game. + * + * @param the type of game this AI can play, extending {@link GameR} + */ +public interface IAIMoveR { + + /** + * Determines the optimal move for the current player. + * + * @param game the current game state + * @param depth the search depth for evaluating moves + * @return an integer representing the chosen move + */ + int findBestMove(T game, int depth); +} diff --git a/framework/src/main/java/org/toop/framework/gameFramework/interfaces/IPlayableR.java b/framework/src/main/java/org/toop/framework/gameFramework/interfaces/IPlayableR.java new file mode 100644 index 0000000..932d7a0 --- /dev/null +++ b/framework/src/main/java/org/toop/framework/gameFramework/interfaces/IPlayableR.java @@ -0,0 +1,25 @@ +package org.toop.framework.gameFramework.interfaces; + +import org.toop.framework.gameFramework.GameState; +import org.toop.framework.gameFramework.PlayResult; + +/** + * Interface for turn-based games that can be played and queried for legal moves. + */ +public interface IPlayableR { + + /** + * Returns the moves that are currently valid in the game. + * + * @return an array of integers representing legal moves + */ + int[] getLegalMoves(); + + /** + * Plays the given move and returns the resulting game state. + * + * @param move the move to apply + * @return the {@link GameState} and additional info after the move + */ + PlayResult play(int move); +} diff --git a/framework/src/main/java/org/toop/framework/gameFramework/interfaces/SupportsOnlinePlay.java b/framework/src/main/java/org/toop/framework/gameFramework/interfaces/SupportsOnlinePlay.java new file mode 100644 index 0000000..f523158 --- /dev/null +++ b/framework/src/main/java/org/toop/framework/gameFramework/interfaces/SupportsOnlinePlay.java @@ -0,0 +1,20 @@ +package org.toop.framework.gameFramework.interfaces; + +import org.toop.framework.networking.events.NetworkEvents; + +/** + * Interface for games that support online multiplayer play. + *

+ * Methods are called in response to network events from the server. + */ +public interface SupportsOnlinePlay { + + /** Called when it is this player's turn to make a move. */ + void yourTurn(NetworkEvents.YourTurnResponse event); + + /** Called when a move from another player is received. */ + void moveReceived(NetworkEvents.GameMoveResponse event); + + /** Called when the game has finished, with the final result. */ + void gameFinished(NetworkEvents.GameResultResponse event); +} diff --git a/framework/src/main/java/org/toop/framework/gameFramework/interfaces/UpdatesGameUI.java b/framework/src/main/java/org/toop/framework/gameFramework/interfaces/UpdatesGameUI.java new file mode 100644 index 0000000..47107fa --- /dev/null +++ b/framework/src/main/java/org/toop/framework/gameFramework/interfaces/UpdatesGameUI.java @@ -0,0 +1,10 @@ +package org.toop.framework.gameFramework.interfaces; + +/** + * Interface for classes that can trigger a UI update. + */ +public interface UpdatesGameUI { + + /** Called to refresh or update the game UI. */ + void updateUI(); +} diff --git a/framework/src/main/java/org/toop/framework/networking/handlers/NetworkingGameClientHandler.java b/framework/src/main/java/org/toop/framework/networking/handlers/NetworkingGameClientHandler.java index 25f9df9..11acf04 100644 --- a/framework/src/main/java/org/toop/framework/networking/handlers/NetworkingGameClientHandler.java +++ b/framework/src/main/java/org/toop/framework/networking/handlers/NetworkingGameClientHandler.java @@ -2,6 +2,8 @@ package org.toop.framework.networking.handlers; import io.netty.channel.ChannelHandlerContext; import io.netty.channel.ChannelInboundHandlerAdapter; + +import java.util.regex.MatchResult; import java.util.regex.Matcher; import java.util.regex.Pattern; import org.apache.logging.log4j.LogManager; @@ -70,7 +72,7 @@ public class NetworkingGameClientHandler extends ChannelInboundHandlerAdapter { case "CHALLENGE": gameChallengeHandler(recSrvRemoved); return; - case "WIN", "DRAW", "LOSE": + case "WIN", "DRAW", "LOSS": gameWinConditionHandler(recSrvRemoved); return; default: @@ -119,13 +121,12 @@ public class NetworkingGameClientHandler extends ChannelInboundHandlerAdapter { } private void gameWinConditionHandler(String rec) { - @SuppressWarnings("StreamToString") - String condition = - Pattern.compile("\\b(win|draw|lose)\\b", Pattern.CASE_INSENSITIVE) - .matcher(rec) - .results() - .toString() - .trim(); + String condition = Pattern.compile("\\b(win|draw|loss)\\b", Pattern.CASE_INSENSITIVE) + .matcher(rec) + .results() + .map(MatchResult::group) + .findFirst() + .orElse(""); new EventFlow() .addPostEvent(new NetworkEvents.GameResultResponse(this.connectionId, condition)) diff --git a/game/pom.xml b/game/pom.xml index 6785e1c..a880839 100644 --- a/game/pom.xml +++ b/game/pom.xml @@ -99,8 +99,14 @@ error_prone_annotations 2.42.0 + + org.toop + framework + 0.1 + compile + - + diff --git a/game/src/main/java/org/toop/game/Connect4/Connect4.java b/game/src/main/java/org/toop/game/Connect4/Connect4.java index 7eb9266..2543d3e 100644 --- a/game/src/main/java/org/toop/game/Connect4/Connect4.java +++ b/game/src/main/java/org/toop/game/Connect4/Connect4.java @@ -1,7 +1,7 @@ package org.toop.game.Connect4; import org.toop.game.TurnBasedGame; -import org.toop.game.enumerators.GameState; +import org.toop.framework.gameFramework.GameState; import org.toop.game.records.Move; import java.util.ArrayList; diff --git a/game/src/main/java/org/toop/game/Connect4/Connect4AI.java b/game/src/main/java/org/toop/game/Connect4/Connect4AI.java index 45f55d5..a7e8b83 100644 --- a/game/src/main/java/org/toop/game/Connect4/Connect4AI.java +++ b/game/src/main/java/org/toop/game/Connect4/Connect4AI.java @@ -1,7 +1,7 @@ package org.toop.game.Connect4; import org.toop.game.AI; -import org.toop.game.enumerators.GameState; +import org.toop.framework.gameFramework.GameState; import org.toop.game.records.Move; public class Connect4AI extends AI { diff --git a/game/src/main/java/org/toop/game/GameThreadBehaviour/GameThreadStrategy.java b/game/src/main/java/org/toop/game/GameThreadBehaviour/GameThreadStrategy.java new file mode 100644 index 0000000..537c308 --- /dev/null +++ b/game/src/main/java/org/toop/game/GameThreadBehaviour/GameThreadStrategy.java @@ -0,0 +1,24 @@ +package org.toop.game.GameThreadBehaviour; + +import org.toop.game.players.AbstractPlayer; + +/** + * Strategy interface for controlling game thread behavior. + *

+ * Defines how a game's execution is started, stopped, and which player is active. + */ +public interface GameThreadStrategy { + + /** Starts the game loop or execution according to the strategy. */ + void start(); + + /** Stops the game loop or execution according to the strategy. */ + void stop(); + + /** + * Returns the player whose turn it currently is. + * + * @return the current active {@link AbstractPlayer} + */ + AbstractPlayer getCurrentPlayer(); +} diff --git a/game/src/main/java/org/toop/game/GameThreadBehaviour/LocalFixedRateThreadBehaviour.java b/game/src/main/java/org/toop/game/GameThreadBehaviour/LocalFixedRateThreadBehaviour.java new file mode 100644 index 0000000..cf6a90f --- /dev/null +++ b/game/src/main/java/org/toop/game/GameThreadBehaviour/LocalFixedRateThreadBehaviour.java @@ -0,0 +1,94 @@ +package org.toop.game.GameThreadBehaviour; + +import org.toop.framework.eventbus.EventFlow; +import org.toop.framework.gameFramework.GameState; +import org.toop.framework.gameFramework.PlayResult; +import org.toop.framework.gameFramework.abstractClasses.TurnBasedGameR; +import org.toop.framework.gameFramework.GUIEvents; +import org.toop.game.players.AbstractPlayer; + +/** + * Handles local turn-based game logic at a fixed update rate. + *

+ * Runs a separate thread that executes game turns at a fixed frequency (default 60 updates/sec), + * applying player moves, updating the game state, and dispatching UI events. + */ +public class LocalFixedRateThreadBehaviour extends ThreadBehaviourBase implements Runnable { + + /** All players participating in the game. */ + private final AbstractPlayer[] players; + + /** + * Creates a fixed-rate behaviour for a local turn-based game. + * + * @param game the game instance + * @param players the list of players in turn order + */ + public LocalFixedRateThreadBehaviour(TurnBasedGameR game, AbstractPlayer[] players) { + super(game, players); + this.players = players; + } + + /** Starts the game loop thread if not already running. */ + @Override + public void start() { + if (isRunning.compareAndSet(false, true)) { + new Thread(this).start(); + } + } + + /** Stops the game loop after the current iteration. */ + @Override + public void stop() { + isRunning.set(false); + } + + /** + * Main loop running at a fixed rate. + *

+ * Fetches the current player's move, applies it to the game, + * updates the UI, and handles game-ending states. + */ + @Override + public void run() { + final int UPS = 60; + final long UPDATE_INTERVAL = 1_000_000_000L / UPS; + long nextUpdate = System.nanoTime(); + + while (isRunning.get()) { + long now = System.nanoTime(); + if (now >= nextUpdate) { + nextUpdate += UPDATE_INTERVAL; + + AbstractPlayer currentPlayer = getCurrentPlayer(); + int move = currentPlayer.getMove(game.clone()); + PlayResult result = game.play(move); + new EventFlow().addPostEvent(GUIEvents.RefreshGameCanvas.class).postEvent(); + + GameState state = result.state(); + switch (state) { + case WIN, DRAW -> { + isRunning.set(false); + new EventFlow().addPostEvent(GUIEvents.GameEnded.class, state == GameState.WIN, result.player()).postEvent(); + } + case NORMAL, TURN_SKIPPED -> { /* continue */ } + default -> { + logger.error("Unexpected state {}", state); + isRunning.set(false); + throw new RuntimeException("Unknown state: " + state); + } + } + } else { + try { + Thread.sleep(10); + } catch (InterruptedException ignored) {} + } + } + } + + /** Returns the player whose turn it currently is. */ + @Override + public AbstractPlayer getCurrentPlayer() { + return players[game.getCurrentTurn()]; + } +} diff --git a/game/src/main/java/org/toop/game/GameThreadBehaviour/LocalThreadBehaviour.java b/game/src/main/java/org/toop/game/GameThreadBehaviour/LocalThreadBehaviour.java new file mode 100644 index 0000000..f0d4c0a --- /dev/null +++ b/game/src/main/java/org/toop/game/GameThreadBehaviour/LocalThreadBehaviour.java @@ -0,0 +1,73 @@ +package org.toop.game.GameThreadBehaviour; + +import org.toop.framework.eventbus.EventFlow; +import org.toop.framework.gameFramework.GUIEvents; +import org.toop.framework.gameFramework.PlayResult; +import org.toop.framework.gameFramework.abstractClasses.TurnBasedGameR; +import org.toop.framework.gameFramework.GameState; +import org.toop.game.players.AbstractPlayer; + +/** + * Handles local turn-based game logic in its own thread. + *

+ * Repeatedly gets the current player's move, applies it to the game, + * updates the UI, and stops when the game ends or {@link #stop()} is called. + */ +public class LocalThreadBehaviour extends ThreadBehaviourBase implements Runnable { + + /** + * Creates a new behaviour for a local turn-based game. + * + * @param game the game instance + * @param players the list of players in turn order + */ + public LocalThreadBehaviour(TurnBasedGameR game, AbstractPlayer[] players) { + super(game, players); + } + + /** Starts the game loop in a new thread. */ + @Override + public void start() { + if (isRunning.compareAndSet(false, true)) { + new Thread(this).start(); + } + } + + /** Stops the game loop after the current iteration. */ + @Override + public void stop() { + isRunning.set(false); + } + + /** + * Main game loop: gets the current player's move, applies it, + * updates the UI, and handles end-of-game states. + */ + @Override + public void run() { + while (isRunning.get()) { + AbstractPlayer currentPlayer = getCurrentPlayer(); + int move = currentPlayer.getMove(game.clone()); + PlayResult result = game.play(move); + new EventFlow().addPostEvent(GUIEvents.RefreshGameCanvas.class).postEvent(); + + GameState state = result.state(); + switch (state) { + case WIN, DRAW -> { + isRunning.set(false); + new EventFlow().addPostEvent( + GUIEvents.GameEnded.class, + state == GameState.WIN, + result.player() + ).postEvent(); + } + case NORMAL, TURN_SKIPPED -> { /* continue normally */ } + default -> { + logger.error("Unexpected state {}", state); + isRunning.set(false); + throw new RuntimeException("Unknown state: " + state); + } + } + } + } +} diff --git a/game/src/main/java/org/toop/game/GameThreadBehaviour/OnlineThreadBehaviour.java b/game/src/main/java/org/toop/game/GameThreadBehaviour/OnlineThreadBehaviour.java new file mode 100644 index 0000000..7049d4d --- /dev/null +++ b/game/src/main/java/org/toop/game/GameThreadBehaviour/OnlineThreadBehaviour.java @@ -0,0 +1,92 @@ +package org.toop.game.GameThreadBehaviour; + +import org.toop.framework.eventbus.EventFlow; +import org.toop.framework.gameFramework.GUIEvents; +import org.toop.framework.gameFramework.abstractClasses.GameR; +import org.toop.framework.networking.events.NetworkEvents; +import org.toop.framework.gameFramework.abstractClasses.TurnBasedGameR; +import org.toop.framework.gameFramework.interfaces.SupportsOnlinePlay; +import org.toop.game.players.AbstractPlayer; +import org.toop.game.players.OnlinePlayer; + +/** + * Handles online multiplayer game logic. + *

+ * Reacts to server events, sending moves and updating the game state + * for the local player while receiving moves from other players. + */ +public class OnlineThreadBehaviour extends ThreadBehaviourBase implements SupportsOnlinePlay { + + /** The local player controlled by this client. */ + private AbstractPlayer mainPlayer; + + /** + * Creates behaviour and sets the first local player + * (non-online player) from the given array. + */ + public OnlineThreadBehaviour(TurnBasedGameR game, AbstractPlayer[] players) { + super(game, players); + this.mainPlayer = getFirstNotOnlinePlayer(players); + } + + /** Finds the first non-online player in the array. */ + private AbstractPlayer getFirstNotOnlinePlayer(AbstractPlayer[] players) { + for (AbstractPlayer player : players) { + if (!(player instanceof OnlinePlayer)) { + return player; + } + } + throw new RuntimeException("All players are online players"); + } + + /** Starts processing network events for the local player. */ + @Override + public void start() { + isRunning.set(true); + } + + /** Stops processing network events. */ + @Override + public void stop() { + isRunning.set(false); + } + + /** + * Called when the server notifies that it is the local player's turn. + * Sends the generated move back to the server. + */ + @Override + public void yourTurn(NetworkEvents.YourTurnResponse event) { + if (!isRunning.get()) return; + int move = mainPlayer.getMove(game.clone()); + new EventFlow().addPostEvent(NetworkEvents.SendMove.class, event.clientId(), (short) move).postEvent(); + } + + /** + * Handles a move received from the server for any player. + * Updates the game state and triggers a UI refresh. + */ + @Override + public void moveReceived(NetworkEvents.GameMoveResponse event) { + if (!isRunning.get()) return; + game.play(Integer.parseInt(event.move())); + new EventFlow().addPostEvent(GUIEvents.RefreshGameCanvas.class).postEvent(); + } + + /** + * Handles the end of the game as notified by the server. + * Updates the UI to show a win or draw result for the local player. + */ + @Override + public void gameFinished(NetworkEvents.GameResultResponse event) { + switch(event.condition().toUpperCase()){ + case "WIN" -> new EventFlow().addPostEvent(GUIEvents.GameEnded.class, true, mainPlayer.getPlayerIndex()).postEvent(); + case "DRAW" -> new EventFlow().addPostEvent(GUIEvents.GameEnded.class, false, TurnBasedGameR.EMPTY).postEvent(); + case "LOSS" -> new EventFlow().addPostEvent(GUIEvents.GameEnded.class, true, (mainPlayer.getPlayerIndex() + 1)%2).postEvent(); + default -> { + logger.error("Invalid condition"); + throw new RuntimeException("Unknown condition"); + } + } + } +} diff --git a/game/src/main/java/org/toop/game/GameThreadBehaviour/OnlineWithSleepThreadBehaviour.java b/game/src/main/java/org/toop/game/GameThreadBehaviour/OnlineWithSleepThreadBehaviour.java new file mode 100644 index 0000000..f550e5a --- /dev/null +++ b/game/src/main/java/org/toop/game/GameThreadBehaviour/OnlineWithSleepThreadBehaviour.java @@ -0,0 +1,42 @@ +package org.toop.game.GameThreadBehaviour; + +import org.toop.framework.gameFramework.abstractClasses.TurnBasedGameR; +import org.toop.framework.networking.events.NetworkEvents; +import org.toop.game.players.AbstractPlayer; + +/** + * Online thread behaviour that adds a fixed delay before processing + * the local player's turn. + *

+ * This is identical to {@link OnlineThreadBehaviour}, but inserts a + * short sleep before delegating to the base implementation. + */ +public class OnlineWithSleepThreadBehaviour extends OnlineThreadBehaviour { + + /** + * Creates the behaviour and forwards the players to the base class. + * + * @param game the online-capable turn-based game + * @param players the list of local and remote players + */ + public OnlineWithSleepThreadBehaviour(TurnBasedGameR game, AbstractPlayer[] players) { + super(game, players); + } + + /** + * Waits briefly before handling the "your turn" event. + * + * @param event the network event indicating it's this client's turn + */ + @Override + public void yourTurn(NetworkEvents.YourTurnResponse event) { + + try { + Thread.sleep(1000); + } catch (InterruptedException e) { + e.printStackTrace(); + } + + super.yourTurn(event); + } +} diff --git a/game/src/main/java/org/toop/game/GameThreadBehaviour/ThreadBehaviourBase.java b/game/src/main/java/org/toop/game/GameThreadBehaviour/ThreadBehaviourBase.java new file mode 100644 index 0000000..b70bd50 --- /dev/null +++ b/game/src/main/java/org/toop/game/GameThreadBehaviour/ThreadBehaviourBase.java @@ -0,0 +1,57 @@ +package org.toop.game.GameThreadBehaviour; + +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; +import org.toop.framework.gameFramework.abstractClasses.TurnBasedGameR; +import org.toop.game.players.AbstractPlayer; + +import java.util.concurrent.atomic.AtomicBoolean; + +/** + * Base class for thread-based game behaviours. + *

+ * Provides common functionality for managing game state and execution: + * a running flag, a game reference, and a logger. + * Subclasses implement the actual game-loop logic. + */ +public abstract class ThreadBehaviourBase implements GameThreadStrategy { + private final AbstractPlayer[] players; + + /** Indicates whether the game loop or event processing is active. */ + protected final AtomicBoolean isRunning = new AtomicBoolean(); + + /** The game instance controlled by this behaviour. */ + protected final TurnBasedGameR game; + + /** Logger for the subclass to report errors or debug info. */ + protected final Logger logger = LogManager.getLogger(this.getClass()); + + /** + * Creates a new base behaviour for the specified game. + * + * @param game the turn-based game to control + */ + public ThreadBehaviourBase(TurnBasedGameR game, AbstractPlayer[] players) { + this.game = game; + this.players = players; + } + + /** + * Returns the player whose turn it currently is. + * + * @return the current active player + */ + @Override + public AbstractPlayer getCurrentPlayer() { + return players[game.getCurrentTurn()]; + } + + public AbstractPlayer getFirstPlayerWithName(String name) { + for (AbstractPlayer player : players){ + if (player.getName().equals(name)){ + return player; + } + } + return null; + } +} diff --git a/game/src/main/java/org/toop/game/enumerators/GameState.java b/game/src/main/java/org/toop/game/enumerators/GameState.java deleted file mode 100644 index 4d556e3..0000000 --- a/game/src/main/java/org/toop/game/enumerators/GameState.java +++ /dev/null @@ -1,9 +0,0 @@ -package org.toop.game.enumerators; - -public enum GameState { - NORMAL, - DRAW, - WIN, - - TURN_SKIPPED, -} diff --git a/game/src/main/java/org/toop/game/interfaces/IPlayable.java b/game/src/main/java/org/toop/game/interfaces/IPlayable.java index c6ab6c6..2c54cd6 100644 --- a/game/src/main/java/org/toop/game/interfaces/IPlayable.java +++ b/game/src/main/java/org/toop/game/interfaces/IPlayable.java @@ -1,6 +1,6 @@ package org.toop.game.interfaces; -import org.toop.game.enumerators.GameState; +import org.toop.framework.gameFramework.GameState; import org.toop.game.records.Move; public interface IPlayable { diff --git a/game/src/main/java/org/toop/game/players/AbstractPlayer.java b/game/src/main/java/org/toop/game/players/AbstractPlayer.java new file mode 100644 index 0000000..dc6c56b --- /dev/null +++ b/game/src/main/java/org/toop/game/players/AbstractPlayer.java @@ -0,0 +1,51 @@ +package org.toop.game.players; + +import org.toop.framework.gameFramework.abstractClasses.GameR; + +/** + * Abstract class representing a player in a game. + *

+ * Players are entities that can make moves based on the current state of a game. + * This class implements {@link MakesMove} and serves as a base for concrete + * player types, such as human players or AI players. + *

+ *

+ * Subclasses should override the {@link #getMove(GameR)} method to provide + * specific move logic. + *

+ */ +public abstract class AbstractPlayer implements MakesMove { + private int playerIndex = -1; + private final String name; + protected AbstractPlayer(String name) { + this.name = name; + } + /** + * Determines the next move based on the provided game state. + *

+ * The default implementation throws an {@link UnsupportedOperationException}, + * indicating that concrete subclasses must override this method to provide + * actual move logic. + *

+ * + * @param gameCopy a snapshot of the current game state + * @return an integer representing the chosen move + * @throws UnsupportedOperationException if the method is not overridden + */ + @Override + public int getMove(GameR gameCopy) { + throw new UnsupportedOperationException("Not supported yet."); + } + + public String getName(){ + return this.name; + } + + public int getPlayerIndex() { + return playerIndex; + } + + public void setPlayerIndex(int playerIndex) { + this.playerIndex = playerIndex; + } +} diff --git a/game/src/main/java/org/toop/game/players/ArtificialPlayer.java b/game/src/main/java/org/toop/game/players/ArtificialPlayer.java new file mode 100644 index 0000000..5fba5ae --- /dev/null +++ b/game/src/main/java/org/toop/game/players/ArtificialPlayer.java @@ -0,0 +1,46 @@ +package org.toop.game.players; + +import org.toop.framework.gameFramework.abstractClasses.AIR; +import org.toop.framework.gameFramework.abstractClasses.GameR; + +/** + * Represents a player controlled by an AI in a game. + *

+ * This player uses an {@link AIR} instance to determine its moves. The generic + * parameter {@code T} specifies the type of {@link GameR} the AI can handle. + *

+ * + * @param the specific type of game this AI player can play + */ +public class ArtificialPlayer extends AbstractPlayer { + + /** The AI instance used to calculate moves. */ + private final AIR ai; + + /** + * Constructs a new ArtificialPlayer using the specified AI. + * + * @param ai the AI instance that determines moves for this player + */ + public ArtificialPlayer(AIR ai, String name) { + super(name); + this.ai = ai; + } + + /** + * Determines the next move for this player using its AI. + *

+ * This method overrides {@link AbstractPlayer#getMove(GameR)}. Because the AI is + * typed to {@code T}, a runtime cast is required. It is the caller's + * responsibility to ensure that {@code gameCopy} is of type {@code T}. + *

+ * + * @param gameCopy a copy of the current game state + * @return the integer representing the chosen move + * @throws ClassCastException if {@code gameCopy} is not of type {@code T} + */ + @Override + public int getMove(GameR gameCopy) { + return ai.findBestMove((T) gameCopy, 9); // TODO: Make depth configurable + } +} diff --git a/game/src/main/java/org/toop/game/players/LocalPlayer.java b/game/src/main/java/org/toop/game/players/LocalPlayer.java new file mode 100644 index 0000000..daa6dff --- /dev/null +++ b/game/src/main/java/org/toop/game/players/LocalPlayer.java @@ -0,0 +1,74 @@ +package org.toop.game.players; + +import org.toop.framework.gameFramework.abstractClasses.GameR; + +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ExecutionException; + +public class LocalPlayer extends AbstractPlayer { + // Future can be used with event system, IF unsubscribeAfterSuccess works... + // private CompletableFuture LastMove = new CompletableFuture<>(); + + private CompletableFuture LastMove; + + public LocalPlayer(String name) { + super(name); + } + + @Override + public int getMove(GameR gameCopy) { + return getValidMove(gameCopy); + } + + public void setMove(int move) { + LastMove.complete(move); + } + + // TODO: helper function, would like to replace to get rid of this method + public static boolean contains(int[] array, int value){ + for (int i : array) if (i == value) return true; + return false; + } + + private int getMove2(GameR gameCopy) { + LastMove = new CompletableFuture<>(); + int move = -1; + try { + move = LastMove.get(); + } catch (InterruptedException | ExecutionException e) { + // TODO: Add proper logging. + e.printStackTrace(); + } + return move; + } + + protected int getValidMove(GameR gameCopy){ + // Get this player's valid moves + int[] validMoves = gameCopy.getLegalMoves(); + // Make sure provided move is valid + // TODO: Limit amount of retries? + // TODO: Stop copying game so many times + int move = getMove2(gameCopy.clone()); + while (!contains(validMoves, move)) { + System.out.println("Not a valid move, try again"); + move = getMove2(gameCopy.clone()); + } + return move; + } + + /*public void register() { + // Listening to PlayerAttemptedMove + new EventFlow().listen(GUIEvents.PlayerAttemptedMove.class, event -> { + if (!LastMove.isDone()) { + LastMove.complete(event.move()); // complete the future + } + }, true); // auto-unsubscribe + } + + // This blocks until the next move arrives + public int take() throws ExecutionException, InterruptedException { + int move = LastMove.get(); // blocking + LastMove = new CompletableFuture<>(); // reset for next move + return move; + }*/ +} diff --git a/game/src/main/java/org/toop/game/players/MakesMove.java b/game/src/main/java/org/toop/game/players/MakesMove.java new file mode 100644 index 0000000..750de07 --- /dev/null +++ b/game/src/main/java/org/toop/game/players/MakesMove.java @@ -0,0 +1,23 @@ +package org.toop.game.players; + +import org.toop.framework.gameFramework.abstractClasses.GameR; + +/** + * Interface representing an entity capable of making a move in a game. + *

+ * Any class implementing this interface should provide logic to determine + * the next move given a snapshot of the current game state. + *

+ */ +public interface MakesMove { + + /** + * Determines the next move based on the provided game state. + * + * @param gameCopy a copy or snapshot of the current game state + * (never null) + * @return an integer representing the chosen move. + * The interpretation of this value depends on the specific game. + */ + int getMove(GameR gameCopy); +} diff --git a/game/src/main/java/org/toop/game/players/OnlinePlayer.java b/game/src/main/java/org/toop/game/players/OnlinePlayer.java new file mode 100644 index 0000000..f7cc4ed --- /dev/null +++ b/game/src/main/java/org/toop/game/players/OnlinePlayer.java @@ -0,0 +1,23 @@ +package org.toop.game.players; + +/** + * Represents a player controlled remotely or over a network. + *

+ * This class extends {@link AbstractPlayer} and can be used to implement game logic + * where moves are provided by an external source (e.g., another user or a server). + * Currently, this class is a placeholder and does not implement move logic. + *

+ */ +public class OnlinePlayer extends AbstractPlayer { + + /** + * Constructs a new OnlinePlayer. + *

+ * Currently, no additional initialization is performed. Subclasses or + * future implementations should provide mechanisms to receive moves from + * an external source. + */ + public OnlinePlayer(String name) { + super(name); + } +} diff --git a/game/src/main/java/org/toop/game/reversi/Reversi.java b/game/src/main/java/org/toop/game/reversi/Reversi.java index a13a44e..d81a629 100644 --- a/game/src/main/java/org/toop/game/reversi/Reversi.java +++ b/game/src/main/java/org/toop/game/reversi/Reversi.java @@ -1,7 +1,7 @@ package org.toop.game.reversi; import org.toop.game.TurnBasedGame; -import org.toop.game.enumerators.GameState; +import org.toop.framework.gameFramework.GameState; import org.toop.game.records.Move; import java.awt.*; diff --git a/game/src/main/java/org/toop/game/reversi/ReversiAIR.java b/game/src/main/java/org/toop/game/reversi/ReversiAIR.java new file mode 100644 index 0000000..8effe07 --- /dev/null +++ b/game/src/main/java/org/toop/game/reversi/ReversiAIR.java @@ -0,0 +1,17 @@ +package org.toop.game.reversi; + +import org.toop.framework.gameFramework.abstractClasses.AIR; + +import java.util.Arrays; +import java.util.Random; + +public final class ReversiAIR extends AIR { + @Override + public int findBestMove(ReversiR game, int depth) { + int[] moves = game.getLegalMoves(); + if (moves.length == 0) return -1; + + int inty = new Random().nextInt(0, moves.length); + return moves[inty]; + } +} diff --git a/game/src/main/java/org/toop/game/reversi/ReversiR.java b/game/src/main/java/org/toop/game/reversi/ReversiR.java new file mode 100644 index 0000000..d384384 --- /dev/null +++ b/game/src/main/java/org/toop/game/reversi/ReversiR.java @@ -0,0 +1,259 @@ +package org.toop.game.reversi; + +import org.toop.framework.gameFramework.GameState; +import org.toop.framework.gameFramework.PlayResult; +import org.toop.framework.gameFramework.abstractClasses.TurnBasedGameR; + +import java.awt.*; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.HashSet; +import java.util.Set; + + +public final class ReversiR extends TurnBasedGameR { + private int movesTaken; + private Set filledCells = new HashSet<>(); + private int[] mostRecentlyFlippedPieces; + + // TODO: Don't hardcore for two players :) + public record Score(int player1Score, int player2Score) {} + + @Override + public ReversiR clone() { + return new ReversiR(this); + } + + public ReversiR() { + super(8, 8, 2); + addStartPieces(); + } + + public ReversiR(ReversiR other) { + super(other); + this.movesTaken = other.movesTaken; + this.filledCells = other.filledCells; + this.mostRecentlyFlippedPieces = other.mostRecentlyFlippedPieces; + } + + + private void addStartPieces() { + this.setBoardPosition(27, 1); + this.setBoardPosition(28, 0); + this.setBoardPosition(35, 0); + this.setBoardPosition(36, 1); + updateFilledCellsSet(); + } + private void updateFilledCellsSet() { + for (int i = 0; i < 64; i++) { + if (this.getBoard()[i] != EMPTY) { + filledCells.add(new Point(i % this.getColumnSize(), i / this.getRowSize())); + } + } + } + + @Override + public int[] getLegalMoves() { + final ArrayList legalMoves = new ArrayList<>(); + int[][] boardGrid = makeBoardAGrid(); + int currentPlayer = this.getCurrentTurn(); + Set adjCell = getAdjacentCells(boardGrid); + for (Point point : adjCell){ + int[] moves = getFlipsForPotentialMove(point,currentPlayer); + int score = moves.length; + if (score > 0){ + legalMoves.add(point.x + point.y * this.getRowSize()); + } + } + return legalMoves.stream().mapToInt(Integer::intValue).toArray(); + } + + private Set getAdjacentCells(int[][] boardGrid) { + Set possibleCells = new HashSet<>(); + for (Point point : filledCells) { //for every filled cell + for (int deltaColumn = -1; deltaColumn <= 1; deltaColumn++){ //check adjacent cells + for (int deltaRow = -1; deltaRow <= 1; deltaRow++){ //orthogonally and diagonally + int newX = point.x + deltaColumn, newY = point.y + deltaRow; + if (deltaColumn == 0 && deltaRow == 0 //continue if out of bounds + || !isOnBoard(newX, newY)) { + continue; + } + if (boardGrid[newY][newX] == EMPTY) { //check if the cell is empty + possibleCells.add(new Point(newX, newY)); //and then add it to the set of possible moves + } + } + } + } + return possibleCells; + } + + public int[] getFlipsForPotentialMove(Point point, int currentPlayer) { + final ArrayList movesToFlip = new ArrayList<>(); + for (int deltaColumn = -1; deltaColumn <= 1; deltaColumn++) { //for all directions + for (int deltaRow = -1; deltaRow <= 1; deltaRow++) { + if (deltaColumn == 0 && deltaRow == 0){ + continue; + } + int[] moves = getFlipsInDirection(point,makeBoardAGrid(),currentPlayer,deltaColumn,deltaRow); + if (moves != null) { //getFlipsInDirection + Arrays.stream(moves).forEach(movesToFlip::add); + } + } + } + return movesToFlip.stream().mapToInt(Integer::intValue).toArray(); + } + + private int[] getFlipsInDirection(Point point, int[][] boardGrid, int currentPlayer, int dirX, int dirY) { + int opponent = getOpponent(currentPlayer); + final ArrayList movesToFlip = new ArrayList<>(); + int x = point.x + dirX; + int y = point.y + dirY; + + if (!isOnBoard(x, y) || boardGrid[y][x] != opponent) { //there must first be an opponents tile + return null; + } + + while (isOnBoard(x, y) && boardGrid[y][x] == opponent) { //count the opponents tiles in this direction + + movesToFlip.add(x+y*this.getRowSize()); + x += dirX; + y += dirY; + } + if (isOnBoard(x, y) && boardGrid[y][x] == currentPlayer) { + return movesToFlip.stream().mapToInt(Integer::intValue).toArray(); //only return the count if last tile is ours + } + return null; + } + + private boolean isOnBoard(int x, int y) { + return x >= 0 && x < this.getColumnSize() && y >= 0 && y < this.getRowSize(); + } + + private int[][] makeBoardAGrid() { + int[][] boardGrid = new int[this.getRowSize()][this.getColumnSize()]; + for (int i = 0; i < 64; i++) { + boardGrid[i / this.getRowSize()][i % this.getColumnSize()] = this.getBoard()[i]; //boardGrid[y -> row] [x -> column] + } + return boardGrid; + } + + private boolean gameOver(){ + ReversiR gameCopy = clone(); + return gameCopy.getLegalMoves().length == 0 && gameCopy.skipTurn().getLegalMoves().length == 0; + } + + @Override + public PlayResult play(int move) { + /*int[] legalMoves = getLegalMoves(); + boolean moveIsLegal = false; + for (int legalMove : legalMoves) { //check if the move is legal + if (move == legalMove) { + moveIsLegal = true; + break; + } + } + if (!moveIsLegal) { + return null; + } + + int[] moves = sortMovesFromCenter(Arrays.stream(getFlipsForPotentialMove(new Point(move%this.getColumnSize(),move/this.getRowSize()), getCurrentTurn())).boxed().toArray(Integer[]::new),move); + mostRecentlyFlippedPieces = moves; + this.setBoard(move); //place the move on the board + for (int m : moves) { + this.setBoard(m); //flip the correct pieces on the board + } + filledCells.add(new Point(move % this.getRowSize(), move / this.getColumnSize())); + nextTurn(); + if (getLegalMoves().length == 0) { //skip the players turn when there are no legal moves + skipMyTurn(); + if (getLegalMoves().length > 0) { + return new PlayResult(GameState.TURN_SKIPPED, getCurrentTurn()); + } + else { //end the game when neither player has a legal move + Score score = getScore(); + if (score.player1Score() == score.player2Score()) { + return new PlayResult(GameState.DRAW, EMPTY); + } + else { + return new PlayResult(GameState.WIN, getCurrentTurn()); + } + } + } + return new PlayResult(GameState.NORMAL, EMPTY);*/ + + // Check if move is legal + if (!contains(getLegalMoves(), move)){ + // Next person wins + return new PlayResult(GameState.WIN, (getCurrentTurn() + 1) % 2); + } + + // Move is legal, proceed as normal + int[] moves = sortMovesFromCenter(Arrays.stream(getFlipsForPotentialMove(new Point(move%this.getColumnSize(),move/this.getRowSize()), getCurrentTurn())).boxed().toArray(Integer[]::new),move); + mostRecentlyFlippedPieces = moves; + this.setBoard(move); //place the move on the board + for (int m : moves) { + this.setBoard(m); //flip the correct pieces on the board + } + filledCells.add(new Point(move % this.getRowSize(), move / this.getColumnSize())); + + nextTurn(); + + // Check for forced turn skip + if (getLegalMoves().length == 0){ + PlayResult result; + // Check if next turn is also a force skip + if (clone().skipTurn().getLegalMoves().length == 0){ + // Game over + int winner = getWinner(); + result = new PlayResult(winner == EMPTY ? GameState.DRAW : GameState.WIN, winner); + }else{ + // Turn skipped + result = new PlayResult(GameState.TURN_SKIPPED, getCurrentTurn()); + skipTurn(); + } + return result; + } + return new PlayResult(GameState.NORMAL, EMPTY); + } + + private ReversiR skipTurn(){ + nextTurn(); + return this; + } + + private int getOpponent(int currentPlayer){ + return (currentPlayer + 1)%2; + } + + public int getWinner(){ + int player1Score = 0, player2Score = 0; + for (int count = 0; count < this.getRowSize() * this.getColumnSize(); count++) { + if (this.getBoard()[count] == 0) { + player1Score += 1; + } + if (this.getBoard()[count] == 1) { + player2Score += 1; + } + } + return player1Score == player2Score? -1 : player1Score > player2Score ? 0 : 1; + } + private int[] sortMovesFromCenter(Integer[] moves, int center) { //sorts the pieces to be flipped for animation purposes + int centerX = center%this.getColumnSize(); + int centerY = center/this.getRowSize(); + Arrays.sort(moves, (a, b) -> { + int dxA = a%this.getColumnSize() - centerX; + int dyA = a/this.getRowSize() - centerY; + int dxB = b%this.getColumnSize() - centerX; + int dyB = b/this.getRowSize() - centerY; + + int distA = dxA * dxA + dyA * dyA; + int distB = dxB * dxB + dyB * dyB; + + return Integer.compare(distA, distB); + }); + return Arrays.stream(moves).mapToInt(Integer::intValue).toArray(); + } + public int[] getMostRecentlyFlippedPieces() { + return mostRecentlyFlippedPieces; + } +} \ No newline at end of file diff --git a/game/src/main/java/org/toop/game/tictactoe/TicTacToe.java b/game/src/main/java/org/toop/game/tictactoe/TicTacToe.java index 3d8f5f7..7388fe0 100644 --- a/game/src/main/java/org/toop/game/tictactoe/TicTacToe.java +++ b/game/src/main/java/org/toop/game/tictactoe/TicTacToe.java @@ -2,7 +2,7 @@ package org.toop.game.tictactoe; import java.util.ArrayList; import org.toop.game.TurnBasedGame; -import org.toop.game.enumerators.GameState; +import org.toop.framework.gameFramework.GameState; import org.toop.game.records.Move; public final class TicTacToe extends TurnBasedGame { diff --git a/game/src/main/java/org/toop/game/tictactoe/TicTacToeAI.java b/game/src/main/java/org/toop/game/tictactoe/TicTacToeAI.java index 3f10ab4..dd2c53c 100644 --- a/game/src/main/java/org/toop/game/tictactoe/TicTacToeAI.java +++ b/game/src/main/java/org/toop/game/tictactoe/TicTacToeAI.java @@ -1,7 +1,7 @@ package org.toop.game.tictactoe; import org.toop.game.AI; -import org.toop.game.enumerators.GameState; +import org.toop.framework.gameFramework.GameState; import org.toop.game.records.Move; public final class TicTacToeAI extends AI { diff --git a/game/src/main/java/org/toop/game/tictactoe/TicTacToeAIR.java b/game/src/main/java/org/toop/game/tictactoe/TicTacToeAIR.java new file mode 100644 index 0000000..8f5e2cf --- /dev/null +++ b/game/src/main/java/org/toop/game/tictactoe/TicTacToeAIR.java @@ -0,0 +1,103 @@ +package org.toop.game.tictactoe; + +import org.toop.framework.gameFramework.abstractClasses.AIR; +import org.toop.framework.gameFramework.PlayResult; +import org.toop.framework.gameFramework.GameState; + +/** + * AI implementation for playing Tic-Tac-Toe. + *

+ * This AI uses a recursive minimax-like strategy with a limited depth to + * evaluate moves. It attempts to maximize its chances of winning while + * minimizing the opponent's opportunities. Random moves are used in the + * opening or when no clear best move is found. + *

+ */ +public final class TicTacToeAIR extends AIR { + + /** + * Determines the best move for the given Tic-Tac-Toe game state. + *

+ * Uses a depth-limited recursive strategy to score each legal move and + * selects the move with the highest score. If no legal moves are available, + * returns -1. If multiple moves are equally good, picks one randomly. + *

+ * + * @param game the current Tic-Tac-Toe game state + * @param depth the depth of lookahead for evaluating moves (non-negative) + * @return the index of the best move, or -1 if no moves are available + */ + @Override + public int findBestMove(TicTacToeR game, int depth) { + assert game != null; + assert depth >= 0; + final int[] legalMoves = game.getLegalMoves(); + + // If there are no moves, return -1 + if (legalMoves.length == 0) { + return -1; + } + + // If first move, pick a corner + if (legalMoves.length == 9) { + return switch ((int)(Math.random() * 4)) { + case 0 -> legalMoves[2]; + case 1 -> legalMoves[6]; + case 2 -> legalMoves[8]; + default -> legalMoves[0]; + }; + } + + int bestScore = -depth; + int bestMove = -1; + + // Calculate Move score of each move, keep track what moves had the best score + for (final int move : legalMoves) { + final int score = getMoveScore(game, depth, move, true); + + if (score > bestScore) { + bestMove = move; + bestScore = score; + } + } + return bestMove != -1 ? bestMove : legalMoves[(int)(Math.random() * legalMoves.length)]; + } + + /** + * Recursively evaluates the score of a potential move using a minimax-like approach. + * + * @param game the current Tic-Tac-Toe game state + * @param depth remaining depth to evaluate + * @param move the move to evaluate + * @param maximizing true if the AI is to maximize score, false if minimizing + * @return the score of the move + */ + private int getMoveScore(TicTacToeR game, int depth, int move, boolean maximizing) { + final TicTacToeR copy = game.clone(); + final PlayResult result = copy.play(move); + + GameState state = result.state(); + + switch (state) { + case DRAW: return 0; + case WIN: return maximizing ? depth + 1 : -depth - 1; + } + + if (depth <= 0) { + return 0; + } + + final int[] legalMoves = copy.getLegalMoves(); + int score = maximizing ? depth + 1 : -depth - 1; + + for (final int 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; + } +} diff --git a/game/src/main/java/org/toop/game/tictactoe/TicTacToeR.java b/game/src/main/java/org/toop/game/tictactoe/TicTacToeR.java new file mode 100644 index 0000000..3aa1b4d --- /dev/null +++ b/game/src/main/java/org/toop/game/tictactoe/TicTacToeR.java @@ -0,0 +1,118 @@ +package org.toop.game.tictactoe; + +import org.toop.framework.gameFramework.PlayResult; +import org.toop.framework.gameFramework.abstractClasses.TurnBasedGameR; +import org.toop.framework.gameFramework.GameState; + +import java.util.ArrayList; +import java.util.Objects; + +public final class TicTacToeR extends TurnBasedGameR { + private int movesLeft; + + public TicTacToeR() { + super(3, 3, 2); + movesLeft = this.getBoard().length; + } + + public TicTacToeR(TicTacToeR other) { + super(other); + movesLeft = other.movesLeft; + } + + @Override + public int[] getLegalMoves() { + final ArrayList legalMoves = new ArrayList(); + + for (int i = 0; i < this.getBoard().length; i++) { + if (Objects.equals(this.getBoard()[i], EMPTY)) { + legalMoves.add(i); + } + } + return legalMoves.stream().mapToInt(Integer::intValue).toArray(); + } + + @Override + public PlayResult play(int move) { + // NOT MY ASSERTIONS - Stef + assert move >= 0 && move < this.getBoard().length; + + // Player loses if move is invalid + if (!contains(getLegalMoves(), move)) { + // Next player wins + return new PlayResult(GameState.WIN, (getCurrentTurn() + 1)%2); // TODO: Make this a generic method like getNextPlayer() or something similar. + } + + // Move is valid, make move. + this.setBoard(move); + movesLeft--; + nextTurn(); + + // Check if current player won TODO: Make this generic? + // Not sure why I am checking for ANY win when only current player should be able to win. + int t = checkForWin(); + if (t != EMPTY) { + return new PlayResult(GameState.WIN, t); + } + + // Check for (early) draw + if (movesLeft <= 3) { + if (checkForEarlyDraw()) { + return new PlayResult(GameState.DRAW, EMPTY); + } + } + + // Nothing weird happened, continue on as normal + return new PlayResult(GameState.NORMAL, EMPTY); + } + + private int checkForWin() { + // Horizontal + for (int i = 0; i < 3; i++) { + + final int index = i * 3; + + if (!Objects.equals(this.getBoard()[index], EMPTY) + && Objects.equals(this.getBoard()[index], this.getBoard()[index + 1]) + && Objects.equals(this.getBoard()[index], this.getBoard()[index + 2])) { + return this.getBoard()[index]; + } + } + + // Vertical + for (int i = 0; i < 3; i++) { + if (!Objects.equals(this.getBoard()[i], EMPTY) && Objects.equals(this.getBoard()[i], this.getBoard()[i + 3]) && Objects.equals(this.getBoard()[i], this.getBoard()[i + 6])) { + return this.getBoard()[i]; + } + } + + // B-Slash + if (!Objects.equals(this.getBoard()[0], EMPTY) && Objects.equals(this.getBoard()[0], this.getBoard()[4]) && Objects.equals(this.getBoard()[0], this.getBoard()[8])) { + return this.getBoard()[0]; + } + + // F-Slash + if (!Objects.equals(this.getBoard()[2], EMPTY) && Objects.equals(this.getBoard()[2], this.getBoard()[4]) && Objects.equals(this.getBoard()[2], this.getBoard()[6])) + return this.getBoard()[2]; + + // Default return + return EMPTY; + } + + private boolean checkForEarlyDraw() { + for (final int move : this.getLegalMoves()) { + final TicTacToeR copy = this.clone(); + + if (copy.play(move).state() == GameState.WIN || !copy.checkForEarlyDraw()) { + return false; + } + } + + return true; + } + + @Override + public TicTacToeR clone() { + return new TicTacToeR(this); + } +} diff --git a/game/src/test/java/org/toop/game/tictactoe/ReversiTest.java b/game/src/test/java/org/toop/game/tictactoe/ReversiTest.java index f2586a5..341478f 100644 --- a/game/src/test/java/org/toop/game/tictactoe/ReversiTest.java +++ b/game/src/test/java/org/toop/game/tictactoe/ReversiTest.java @@ -4,7 +4,7 @@ import java.util.*; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; -import org.toop.game.enumerators.GameState; +import org.toop.framework.gameFramework.GameState; import org.toop.game.records.Move; import org.toop.game.reversi.Reversi; import org.toop.game.reversi.ReversiAI; diff --git a/game/src/test/java/org/toop/game/tictactoe/TicTacToeAIRTest.java b/game/src/test/java/org/toop/game/tictactoe/TicTacToeAIRTest.java new file mode 100644 index 0000000..3b349a1 --- /dev/null +++ b/game/src/test/java/org/toop/game/tictactoe/TicTacToeAIRTest.java @@ -0,0 +1,118 @@ +package org.toop.game.tictactoe; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.*; + +final class TicTacToeAIRTest { + + private final TicTacToeAIR ai = new TicTacToeAIR(); + + // Helper: play multiple moves in sequence on a fresh board + private TicTacToeR playSequence(int... moves) { + TicTacToeR game = new TicTacToeR(); + for (int move : moves) { + game.play(move); + } + return game; + } + + @Test + @DisplayName("AI first move must choose a corner") + void testFirstMoveIsCorner() { + TicTacToeR game = new TicTacToeR(); + int move = ai.findBestMove(game, 4); + + assertTrue( + move == 0 || move == 2 || move == 6 || move == 8, + "AI should pick a corner as first move" + ); + } + + @Test + @DisplayName("AI doesn't make losing move in specific situation") + void testWinningMove(){ + TicTacToeR game = playSequence(new int[] { 0, 4, 5, 3, 6, 1, 7}); + int move = ai.findBestMove(game, 9); + + assertEquals(8, move); + } + + + @Test + @DisplayName("AI takes immediate winning move") + void testAiTakesWinningMove() { + // X = AI, O = opponent + // Board state (X to play): + // X | X | . + // O | O | . + // . | . | . + // + // AI must play 2 (top-right) to win. + TicTacToeR game = playSequence( + 0, 3, // X, O + 1, 4 // X, O + ); + + int move = ai.findBestMove(game, 4); + assertEquals(2, move, "AI must take the winning move at index 2"); + } + + @Test + @DisplayName("AI blocks opponent's winning move") + void testAiBlocksOpponent() { + // Opponent threatens to win: + // X | . | . + // O | O | . + // . | . | X + // O is about to win at index 5; AI must block it. + + TicTacToeR game = playSequence( + 0, 3, // X, O + 8, 4 // X, O (O threatens at 5) + ); + + int move = ai.findBestMove(game, 4); + assertEquals(5, move, "AI must block opponent at index 5"); + } + + @Test + @DisplayName("AI returns -1 when no legal moves exist") + void testNoMovesAvailable() { + TicTacToeR full = new TicTacToeR(); + // Fill board alternating + for (int i = 0; i < 9; i++) full.play(i); + + int move = ai.findBestMove(full, 3); + assertEquals(-1, move, "AI should return -1 when board is full"); + } + + @Test + @DisplayName("Minimax depth does not cause crashes and produces valid move") + void testDepthStability() { + TicTacToeR game = playSequence(0, 4); // Simple mid-game state + int move = ai.findBestMove(game, 6); + + assertTrue(move >= -1 && move <= 8, "AI must return a valid move index"); + } + + @Test + @DisplayName("AI chooses the optimal forced draw move") + void testForcedDrawScenario() { + // Scenario where only one move avoids immediate loss: + // + // X | O | X + // X | O | . + // O | X | . + // + // Legal moves: 5, 8 + // Only move 5 avoids losing. + TicTacToeR game = new TicTacToeR(); + int[] moves = {0,1,2,4,3,6,7}; // Hard-coded board setup + for (int m : moves) game.play(m); + + int move = ai.findBestMove(game, 4); + assertEquals(5, move, "AI must choose the only move that avoids losing"); + } +}